Compare commits

...

2 Commits

Author SHA1 Message Date
5d05eb0804 chore(deploy): geo snippet、popadmin 补丁与订阅包名脚本
补充 world geo nginx snippet、OpenAPI/geo 热更新脚本,以及 popadmin package_name 定价补丁,便于运维对齐。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 09:17:18 +08:00
53426af122 feat: 多广场 channel、音乐公开 API 与订阅 package_name
支持 square/squarenovel/squaresteps/squarehealth 实例与 category 筛选;音乐增加免登录 grouped/track;订阅套餐按 package_name 区分;补 migration 0030/0031 与 OpenAPI。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-30 09:02:28 +08:00
60 changed files with 4296 additions and 261 deletions

View File

@ -62,3 +62,18 @@ sudo nginx -t && sudo systemctl reload nginx
``` ```
确保 spark-server 容器监听 `127.0.0.1:18080`,且 `admin.jwt_secret` 与 Java `WYRS_ADMIN_JWT_SECRET` 一致。登录、仪表盘、configs/通讯录/相册仍走 Java `:8090`。 确保 spark-server 容器监听 `127.0.0.1:18080`,且 `admin.jwt_secret` 与 Java `WYRS_ADMIN_JWT_SECRET` 一致。登录、仪表盘、configs/通讯录/相册仍走 Java `:8090`。
## popadmin 世界地图底图(IP 属地日活)
`popadmin-sg-panels.js` 请求 `GET /admin/geo/world.json`(echarts GeoJSON)。必须在 nginx 里单独 `alias` 静态文件,否则会被 `location /` 302 成 HTML,地图报 `Unexpected token '<'`。
```bash
# 静态文件放到服务器
sudo mkdir -p /opt/wyrs-admin/static-override/geo
# 从 Aliyun SG GO/scripts/popadmin-static/geo/world.json 上传
sudo cp world.json /opt/wyrs-admin/static-override/geo/world.json
# 把 host/popadmin-world-geo.snippet.conf 插入 popadmin-wyrs.conf 的 location / 之前
# 或执行:Aliyun SG GO/scripts/popadmin-static/patch_world_geo.sh
sudo nginx -t && sudo systemctl reload nginx
curl -skI https://popadmin.wyrs365.com/admin/geo/world.json # 期望 200 application/json
```

View File

@ -0,0 +1,14 @@
# echarts 世界地图底图 GeoJSON
# 插入到 popadmin HTTPS server 内、`location / { return 302 /popadmin/; }` 之前。
# 静态文件:/opt/wyrs-admin/static-override/geo/world.json
# (源:Aliyun SG GO/scripts/popadmin-static/geo/world.json)
# 若不配置,/admin/geo/world.json 会被 location / 302 成 HTML,前端报
# Unexpected token '<' ... is not valid JSON。
# BEGIN popadmin-world-geo
location = /admin/geo/world.json {
alias /opt/wyrs-admin/static-override/geo/world.json;
default_type application/json;
add_header Cache-Control "public, max-age=86400";
}
# END popadmin-world-geo

View File

@ -0,0 +1,25 @@
#!/bin/bash
set -euo pipefail
DIR=/opt/wyrs-admin/static-override
TS=$(date +%Y%m%d%H%M%S)
cd "$DIR"
cp -a popadmin.js "popadmin.js.bak.apidoc.$TS"
cp -a popadmin.index.html "popadmin.index.html.bak.apidoc.$TS"
python3 /tmp/patch_api_doc.py "$DIR/popadmin.js" "$DIR/popadmin.index.html"
python3 <<'PY'
from pathlib import Path
js = Path("/opt/wyrs-admin/static-override/popadmin.js").read_text(encoding="utf-8")
i = js.find("async apiDoc")
chunk = js[i:i+2500]
assert "sparkApiDocFrame" in chunk, "missing iframe"
assert "sparkapi.wyrs365.com" in chunk, "missing sparkapi url"
assert "/spark/api-doc" not in chunk, "old api-doc still present"
idx = Path("/opt/wyrs-admin/static-override/popadmin.index.html").read_text(encoding="utf-8")
assert "202609241556-apidoc" in idx, "cache bust missing"
print("VERIFY_OK")
print(chunk[:600])
print("--- script tag ---")
for line in idx.splitlines():
if "popadmin.js" in line:
print(line.strip())
PY

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,63 @@
#!/bin/bash
# Hot-override sparkapi /openapi.yaml with latest spec (no container rebuild / no JWT touch).
set -euo pipefail
SPEC_SRC="${1:-/tmp/openapi.yaml}"
SPEC_DST=/var/www/sparkapi-docs/openapi.yaml
NGINX_CONF=/etc/nginx/conf.d/sparkapi-wyrs.conf
# fallback name used on this host
if [[ ! -f "$NGINX_CONF" ]]; then
NGINX_CONF=$(ls /etc/nginx/conf.d/sparkapi*.conf 2>/dev/null | grep -v '\.bak' | head -1)
fi
echo "nginx_conf=$NGINX_CONF"
install -d -m 755 /var/www/sparkapi-docs
install -m 644 "$SPEC_SRC" "$SPEC_DST"
wc -c "$SPEC_DST"
grep -c squarenovel "$SPEC_DST" || true
MARKER='# BEGIN sparkapi-openapi-override'
if grep -q "$MARKER" "$NGINX_CONF"; then
echo "nginx override block already present"
else
TS=$(date +%Y%m%d%H%M%S)
cp -a "$NGINX_CONF" "${NGINX_CONF}.bak.openapi.$TS"
python3 <<PY
from pathlib import Path
p = Path("$NGINX_CONF")
text = p.read_text(encoding="utf-8")
block = '''
# BEGIN sparkapi-openapi-override
# Serve latest OpenAPI without rebuilding spark-server image (embed).
location = /openapi.yaml {
default_type application/yaml;
charset utf-8;
alias /var/www/sparkapi-docs/openapi.yaml;
add_header Cache-Control "no-store";
add_header Access-Control-Allow-Origin "*";
}
# END sparkapi-openapi-override
'''
needle = " location / {"
idx = text.find(needle)
if idx < 0:
raise SystemExit("location / not found in " + str(p))
# insert before the last occurrence of location / inside sparkapi server (HTTPS)
# Prefer inserting before location / that has proxy_pass spark_http
pos = text.rfind(" location / {\n proxy_pass http://spark_http;")
if pos < 0:
pos = text.rfind(needle)
text2 = text[:pos] + block + "\n" + text[pos:]
p.write_text(text2, encoding="utf-8")
print("inserted openapi override")
PY
fi
nginx -t
systemctl reload nginx
echo RELOAD_OK
# verify via local loopback Host header if possible
curl -sk -o /tmp/oa.out -w "https_loop:%{http_code} size:%{size_download}\n" -H "Host: sparkapi.wyrs365.com" https://127.0.0.1:8443/openapi.yaml || true
grep -c squarenovel /tmp/oa.out || true
head -c 80 /tmp/oa.out; echo

View File

@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""Replace popadmin apiDoc() to embed sparkapi /docs; bump index cache bust."""
from __future__ import annotations
import re
import sys
from pathlib import Path
NEW_API_DOC = """async apiDoc() {
const DOC = 'https://sparkapi.wyrs365.com';
const bust = '202609241556';
const embedUrl = DOC + '/docs?from=popadmin&v=' + bust;
$('content').innerHTML = `
<div class="note" style="margin-bottom:12px">
Spark 客户端 HTTP API 权威文档(OpenAPI / Swagger)内嵌自
<a href="${DOC}/docs" target="_blank" rel="noopener">${DOC}/docs</a>
· 规范 <code>${DOC}/openapi.yaml</code>
· 含音乐分类、多广场 <code>square</code> / <code>squarenovel</code> / <code>squaresteps</code> / <code>squarehealth</code> 等
· <a href="${DOC}/docs" target="_blank" rel="noopener">新窗口打开</a>
· <a href="${DOC}/docs/enums" target="_blank" rel="noopener">枚举</a>
· <a href="${DOC}/docs/errors" target="_blank" rel="noopener">错误码</a>
</div>
<iframe id="sparkApiDocFrame" title="Spark API 文档" src="${embedUrl}"
style="width:100%;height:calc(100vh - 160px);min-height:640px;border:1px solid var(--border);border-radius:10px;background:#0b1220"
referrerpolicy="no-referrer-when-downgrade"></iframe>`;
},"""
CACHE_BUST = "202609241556-apidoc"
def normalize_newlines(text: str) -> str:
return text.replace("\r\n", "\n").replace("\r", "\n")
def double_crlf_style(body: str) -> str:
"""Match production/local popadmin.js style: blank line between every line, CRLF."""
lines = [ln.rstrip() for ln in body.strip("\n").split("\n")]
return "\r\n\r\n".join(lines) + "\r\n\r\n"
def patch_js(path: Path) -> bool:
raw = path.read_bytes()
text = normalize_newlines(raw.decode("utf-8"))
# Match from async apiDoc() through the closing `},` before next top-level sibling
# (pages object ends with apiDoc then `};`)
pat = re.compile(
r"async apiDoc\(\)\s*\{.*?\n \},",
re.DOTALL,
)
m = pat.search(text)
if not m:
print(f"FAIL: apiDoc() not found in {path}", file=sys.stderr)
return False
new_block = NEW_API_DOC.strip()
# Detect if original uses blank-line-between-lines style
sample = m.group(0)[:80]
uses_double = "\n\n " in m.group(0) or m.group(0).count("\n\n") > 5
if uses_double:
# Keep double-newline + we write back as CRLF later if original was CRLF
dbl = "\n\n".join(ln.rstrip() for ln in new_block.split("\n"))
replacement = dbl
else:
replacement = new_block
text2 = text[: m.start()] + replacement + text[m.end() :]
if raw.count(b"\r\n") > raw.count(b"\n") // 2:
out = text2.replace("\n", "\r\n").encode("utf-8")
else:
out = text2.encode("utf-8")
path.write_bytes(out)
print(f"OK js: {path} ({len(out)} bytes)")
return True
def patch_index(path: Path) -> bool:
raw = path.read_text(encoding="utf-8")
new, n = re.subn(
r'(src="/popadmin/js/popadmin\.js)\?v=[^"]+"',
rf'\1?v={CACHE_BUST}"',
raw,
count=1,
)
if n == 0:
print(f"FAIL: popadmin.js script tag not found in {path}", file=sys.stderr)
return False
path.write_text(new, encoding="utf-8", newline="\n")
print(f"OK index: {path} bust={CACHE_BUST}")
return True
def main() -> int:
root = Path(__file__).resolve().parent
targets = sys.argv[1:]
if not targets:
ok = patch_js(root / "popadmin.js")
idx = root / "popadmin.index.html"
if idx.exists():
ok = patch_index(idx) and ok
return 0 if ok else 1
ok = True
for t in targets:
p = Path(t)
if p.name.endswith(".js") or "popadmin.js" in p.name:
ok = patch_js(p) and ok
elif "index" in p.name or p.suffix == ".html":
ok = patch_index(p) and ok
else:
print(f"skip unknown {p}", file=sys.stderr)
return 0 if ok else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,76 @@
#!/bin/bash
# Serve echarts world map GeoJSON at /admin/geo/world.json on popadmin.
# Avoids location / catching the path and 302'ing HTML (breaks resp.json()).
set -euo pipefail
CONF=/etc/nginx/conf.d/popadmin-wyrs.conf
GEO_DIR=/opt/wyrs-admin/static-override/geo
GEO_FILE="$GEO_DIR/world.json"
if [[ ! -f "$GEO_FILE" ]]; then
echo "ERROR: missing $GEO_FILE — upload world.json first" >&2
exit 1
fi
chmod 644 "$GEO_FILE"
ls -la "$GEO_FILE"
MARKER_BEGIN='# BEGIN popadmin-world-geo'
if grep -Fq "$MARKER_BEGIN" "$CONF"; then
echo "nginx world-geo snippet already present"
else
cp -a "$CONF" "$CONF.bak.worldgeo.$(date +%Y%m%d%H%M%S)"
python3 <<'PY'
from pathlib import Path
conf = Path("/etc/nginx/conf.d/popadmin-wyrs.conf")
text = conf.read_text()
snip = """
# BEGIN popadmin-world-geo
# echarts 世界地图底图(勿被 location / 302 成 HTML)
location = /admin/geo/world.json {
alias /opt/wyrs-admin/static-override/geo/world.json;
default_type application/json;
add_header Cache-Control "public, max-age=86400";
}
# END popadmin-world-geo
"""
needle = " location / {\n return 302 /popadmin/;\n }"
if needle in text:
text = text.replace(needle, snip + "\n" + needle, 1)
else:
idx = text.rfind(" location / {")
if idx < 0:
raise SystemExit("cannot find location / insert point")
text = text[:idx] + snip + "\n" + text[idx:]
conf.write_text(text)
print("nginx conf patched with world-geo location")
PY
fi
nginx -t
systemctl reload nginx
echo "nginx reloaded"
# smoke via localhost Host header (HTTP → may redirect; prefer HTTPS if available)
python3 <<'PY'
import json, ssl, urllib.request
ctx = ssl._create_unverified_context()
url = "https://127.0.0.1/admin/geo/world.json"
req = urllib.request.Request(url, headers={"Host": "popadmin.wyrs365.com"})
try:
with urllib.request.urlopen(req, context=ctx, timeout=20) as r:
raw = r.read()
ct = r.headers.get("Content-Type", "")
print("status", r.status, "ct", ct, "bytes", len(raw))
d = json.loads(raw)
print("OK type=", d.get("type"), "features=", len(d.get("features") or []))
except Exception as e:
print("HTTPS smoke FAIL", e)
# fallback: direct file check
import pathlib
p = pathlib.Path("/opt/wyrs-admin/static-override/geo/world.json")
d = json.loads(p.read_text(encoding="utf-8"))
print("file OK type=", d.get("type"), "features=", len(d.get("features") or []))
PY
grep -n "popadmin-world-geo\|admin/geo/world" "$CONF" | head -20
echo DONE

View File

@ -2394,8 +2394,23 @@ const pages = {
async prices() { async prices() {
const rows = await api(`/prices?appCode=${APP}`); const [rows, apps] = await Promise.all([
const list = Array.isArray(rows) ? rows : []; api(`/prices?appCode=${APP}`),
api('/apps').catch(() => []),
]);
const all = Array.isArray(rows) ? rows : [];
const popApp = (Array.isArray(apps) ? apps : []).find((a) => a.code === APP) || {};
const pkgOptions = Array.isArray(popApp.packageNames) ? popApp.packageNames.slice() : [];
// 常见火花包名兜底(注册表缺省时仍可手填)
['com.jspp.pop', 'spark.wyrs.com', 'pop.wyrs.com'].forEach((p) => {
if (!pkgOptions.includes(p)) pkgOptions.push(p);
});
const savedFilter = (typeof window.__pricePkgFilter === 'string') ? window.__pricePkgFilter : '';
const list = (() => {
if (!savedFilter) return all;
if (savedFilter === '__default__') return all.filter((p) => !p.packageName);
return all.filter((p) => (p.packageName || '') === savedFilter);
})();
const CHANNELS = ['apple','google','paypal','pingpong','wechat','alipay','huawei','xiaomi','vivo','oppo','honor']; const CHANNELS = ['apple','google','paypal','pingpong','wechat','alipay','huawei','xiaomi','vivo','oppo','honor'];
const skuInputs = CHANNELS.map((k) => `<input id="pfSku_${k}" placeholder="${k} 商品码" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:140px">`).join(''); const skuInputs = CHANNELS.map((k) => `<input id="pfSku_${k}" placeholder="${k} 商品码" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:140px">`).join('');
const billingLabel = (bt) => bt === 'subscription' ? '订阅' : (bt === 'one_time' ? '一次性' : (bt || '—')); const billingLabel = (bt) => bt === 'subscription' ? '订阅' : (bt === 'one_time' ? '一次性' : (bt || '—'));
@ -2404,9 +2419,26 @@ const pages = {
const keys = Object.keys(m).filter((k) => m[k]); const keys = Object.keys(m).filter((k) => m[k]);
return keys.length ? keys.map((k) => k + ':' + m[k]).join(' · ') : '—'; return keys.length ? keys.map((k) => k + ':' + m[k]).join(' · ') : '—';
}; };
const pkgLabel = (p) => (p && String(p).trim()) ? p : '(默认·全包兜底)';
$('content').innerHTML = ` $('content').innerHTML = `
<div class="note">会员价格(火花)· 区分<strong>订阅</strong>/<strong>一次性</strong>;各渠道商品码非密钥。客户端 <code>GET /api/client/spark/prices</code> · 渠道密钥见「支付渠道」</div> <div class="note">会员价格(火花)· 可按 <strong>applicationId / 包名</strong> 配置不同价格;包名为空表示该 App 默认兜底。客户端 <code>GET /api/client/spark/prices?packageName=...</code> 或 Header <code>X-Wyrs-Package</code> · 有精确包名价则用之,否则回退默认。</div>
<div class="toolbar" style="flex-wrap:wrap;gap:8px;align-items:center">
<label>筛选包名
<select id="pfPkgFilter" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<option value="">全部</option>
<option value="__default__"${savedFilter==='__default__'?' selected':''}>仅默认兜底</option>
${pkgOptions.map((p) => `<option value="${esc(p)}"${savedFilter===p?' selected':''}>${esc(p)}</option>`).join('')}
</select>
</label>
<span class="note" style="margin:0">共 ${esc(list.length)} / ${esc(all.length)} 条</span>
<button type="button" class="secondary" id="pfReload">刷新</button>
</div>
<div class="toolbar" style="flex-wrap:wrap;gap:8px"> <div class="toolbar" style="flex-wrap:wrap;gap:8px">
<select id="pfPkg" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);min-width:200px">
<option value="">默认兜底(空包名)</option>
${pkgOptions.map((p) => `<option value="${esc(p)}">${esc(p)}</option>`).join('')}
</select>
<input id="pfPkgCustom" placeholder="或手写包名 applicationId" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:200px">
<input id="pfName" placeholder="套餐名称" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)"> <input id="pfName" placeholder="套餐名称" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<select id="pfBilling" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)"> <select id="pfBilling" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<option value="subscription">订阅 subscription</option> <option value="subscription">订阅 subscription</option>
@ -2419,18 +2451,23 @@ const pages = {
</div> </div>
<div class="toolbar" style="flex-wrap:wrap;gap:8px;margin-top:8px">${skuInputs}</div> <div class="toolbar" style="flex-wrap:wrap;gap:8px;margin-top:8px">${skuInputs}</div>
<table> <table>
<thead><tr><th>ID</th><th>名称</th><th>类型</th><th>价格</th><th>原价</th><th>天数</th><th>渠道码</th><th>启用</th><th></th></tr></thead> <thead><tr><th>ID</th><th>包名</th><th>名称</th><th>类型</th><th>价格</th><th>原价</th><th>天数</th><th>渠道码</th><th>启用</th><th></th></tr></thead>
<tbody> <tbody>
${list.length ? list.map((p) => `<tr><td>${esc(p.id)}</td><td>${esc(p.name)}</td><td>${esc(billingLabel(p.billingType))}</td><td>${esc(p.price)}</td><td>${esc(p.originalPrice)}</td><td>${esc(p.durationDays)}</td><td style="max-width:220px;word-break:break-all">${esc(skuSummary(p.channelProductCodes))}</td><td>${esc(p.enabled)}</td><td><button type="button" class="secondary btn-sm" data-pdel="${esc(p.id)}">删</button></td></tr>`).join('') : emptyRows(9, '暂无价格')} ${list.length ? list.map((p) => `<tr><td>${esc(p.id)}</td><td>${esc(pkgLabel(p.packageName))}</td><td>${esc(p.name)}</td><td>${esc(billingLabel(p.billingType))}</td><td>${esc(p.price)}</td><td>${esc(p.originalPrice)}</td><td>${esc(p.durationDays)}</td><td style="max-width:220px;word-break:break-all">${esc(skuSummary(p.channelProductCodes))}</td><td>${esc(p.enabled)}</td><td><button type="button" class="secondary btn-sm" data-pdel="${esc(p.id)}">删</button></td></tr>`).join('') : emptyRows(10, '暂无价格')}
</tbody> </tbody>
</table>`; </table>`;
$('pfPkgFilter').onchange = () => { window.__pricePkgFilter = $('pfPkgFilter').value; pages.prices(); };
$('pfReload').onclick = () => pages.prices();
$('pfSave').onclick = async () => { $('pfSave').onclick = async () => {
const channelProductCodes = {}; const channelProductCodes = {};
CHANNELS.forEach((k) => { const v = $('pfSku_' + k)?.value?.trim(); if (v) channelProductCodes[k] = v; }); CHANNELS.forEach((k) => { const v = $('pfSku_' + k)?.value?.trim(); if (v) channelProductCodes[k] = v; });
const custom = ($('pfPkgCustom')?.value || '').trim();
const packageName = custom || ($('pfPkg')?.value || '').trim();
await api('/prices', { await api('/prices', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
appCode: APP, appCode: APP,
packageName,
name: $('pfName').value, name: $('pfName').value,
billingType: $('pfBilling').value, billingType: $('pfBilling').value,
price: $('pfPrice').value, price: $('pfPrice').value,
@ -3106,6 +3143,7 @@ const pages = {
if (!p) return; if (!p) return;
$('spPlan').value = p.plan_id || 'week'; $('spPlan').value = p.plan_id || 'week';
$('spLang').value = p.lang || 'zh-CN'; $('spLang').value = p.lang || 'zh-CN';
if ($('spPkg')) $('spPkg').value = p.package_name || '';
$('spTitle').value = p.title || ''; $('spTitle').value = p.title || '';
$('spSub').value = p.subtitle || ''; $('spSub').value = p.subtitle || '';
$('spPrice').value = p.price_display || ''; $('spPrice').value = p.price_display || '';
@ -3131,10 +3169,11 @@ const pages = {
}; };
$('content').innerHTML = ` $('content').innerHTML = `
<div class="note">SleepHeart 订阅引导 · ASC App <code>6815533078</code> · Team <code>49S9UL5MN4</code> · Bundle <code>health.wyrs365.com</code><br> <div class="note">SleepHeart 订阅引导 · ASC App <code>6815533078</code> · Team <code>49S9UL5MN4</code> · Bundle <code>health.wyrs365.com</code><br>
Admin <code>/api/admin/spark/subscriptions/plans</code> · 客户端 <code>GET /v1/subscriptions/plans</code> · 默认 USD <b>$1/周 · $3/月 · $12/年</b>(季卡 $8 可保留)· IAP <code>health.wyrs365.com.vip.{week|month|quarter|year}</code></div> Admin <code>/api/admin/spark/subscriptions/plans</code> · 客户端 <code>GET /v1/subscriptions/plans?packageName=...</code> · 包名为空=默认兜底 · USD <b>$1/周 · $3/月 · $12/年</b></div>
<div class="toolbar" style="flex-wrap:wrap;gap:8px"> <div class="toolbar" style="flex-wrap:wrap;gap:8px">
<select id="spPlan"><option value="week">week</option><option value="month">month</option><option value="quarter">quarter</option><option value="year">year</option></select> <select id="spPlan"><option value="week">week</option><option value="month">month</option><option value="quarter">quarter</option><option value="year">year</option></select>
<select id="spLang"><option value="zh-CN">zh-CN</option><option value="en">en</option><option value="zh-TW">zh-TW</option></select> <select id="spLang"><option value="zh-CN">zh-CN</option><option value="en">en</option><option value="zh-TW">zh-TW</option></select>
<input id="spPkg" placeholder="包名/applicationId 空=默认" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:200px" value="health.wyrs365.com">
<input id="spTitle" placeholder="标题" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:120px"> <input id="spTitle" placeholder="标题" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:120px">
<input id="spSub" placeholder="副标题" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:140px"> <input id="spSub" placeholder="副标题" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:140px">
<input id="spPrice" placeholder="$1/周" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:90px"> <input id="spPrice" placeholder="$1/周" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:90px">
@ -3150,9 +3189,9 @@ const pages = {
<button type="button" class="secondary" id="spReload">刷新</button> <button type="button" class="secondary" id="spReload">刷新</button>
</div> </div>
<table> <table>
<thead><tr><th>plan</th><th>lang</th><th>标题</th><th>价格</th><th>币种</th><th>天数</th><th>角标</th><th>product</th><th>启用</th></tr></thead> <thead><tr><th>plan</th><th>lang</th><th>包名</th><th>标题</th><th>价格</th><th>币种</th><th>天数</th><th>角标</th><th>product</th><th>启用</th></tr></thead>
<tbody> <tbody>
${items.length ? items.map((p, i)=>`<tr data-sp-idx="${i}" style="cursor:pointer"><td>${esc(p.plan_id)}</td><td>${esc(p.lang)}</td><td>${esc(p.title)}<div class="note" style="margin:0">${esc(p.subtitle||'')}</div></td><td>${esc(p.price_display)} (${esc(p.price_cents)})</td><td>${esc(p.currency||'USD')}</td><td>${esc(p.period_days)}</td><td>${esc(p.badge||'')}</td><td>${esc(p.product_id)}</td><td>${esc(p.enabled)}</td></tr>`).join('') : '<tr><td colspan="9">暂无套餐(可点「填入默认价」后保存)</td></tr>'} ${items.length ? items.map((p, i)=>`<tr data-sp-idx="${i}" style="cursor:pointer"><td>${esc(p.plan_id)}</td><td>${esc(p.lang)}</td><td>${esc(p.package_name||'(默认)')}</td><td>${esc(p.title)}<div class="note" style="margin:0">${esc(p.subtitle||'')}</div></td><td>${esc(p.price_display)} (${esc(p.price_cents)})</td><td>${esc(p.currency||'USD')}</td><td>${esc(p.period_days)}</td><td>${esc(p.badge||'')}</td><td>${esc(p.product_id)}</td><td>${esc(p.enabled)}</td></tr>`).join('') : '<tr><td colspan="10">暂无套餐(可点「填入默认价」后保存)</td></tr>'}
</tbody> </tbody>
</table>`; </table>`;
const seed = items.find((p) => p.plan_id === 'month' && p.lang === 'zh-CN') const seed = items.find((p) => p.plan_id === 'month' && p.lang === 'zh-CN')
@ -3161,7 +3200,7 @@ const pages = {
if (seed) fillForm(seed); if (seed) fillForm(seed);
else applyPlanDefaults(); else applyPlanDefaults();
$('spPlan').onchange = () => { $('spPlan').onchange = () => {
const hit = items.find((p) => p.plan_id === $('spPlan').value && p.lang === $('spLang').value); const hit = items.find((p) => p.plan_id === $('spPlan').value && p.lang === $('spLang').value && (p.package_name || '') === ($('spPkg').value || '').trim());
if (hit) fillForm(hit); if (hit) fillForm(hit);
else { else {
$('spTitle').value = ''; $('spSub').value = ''; $('spPrice').value = ''; $('spTitle').value = ''; $('spSub').value = ''; $('spPrice').value = '';
@ -3170,7 +3209,7 @@ const pages = {
} }
}; };
$('spLang').onchange = () => { $('spLang').onchange = () => {
const hit = items.find((p) => p.plan_id === $('spPlan').value && p.lang === $('spLang').value); const hit = items.find((p) => p.plan_id === $('spPlan').value && p.lang === $('spLang').value && (p.package_name || '') === ($('spPkg').value || '').trim());
if (hit) fillForm(hit); if (hit) fillForm(hit);
}; };
$('spFillDefaults').onclick = () => { $('spFillDefaults').onclick = () => {
@ -3193,7 +3232,8 @@ const pages = {
const planId = $('spPlan').value; const planId = $('spPlan').value;
const d = PLAN_DEFAULTS[planId] || PLAN_DEFAULTS.week; const d = PLAN_DEFAULTS[planId] || PLAN_DEFAULTS.week;
await api('/spark/subscriptions/plans', { method: 'PUT', body: JSON.stringify({ await api('/spark/subscriptions/plans', { method: 'PUT', body: JSON.stringify({
plan_id: planId, lang: $('spLang').value, title: $('spTitle').value.trim() || d.title, plan_id: planId, lang: $('spLang').value, package_name: ($('spPkg').value || '').trim(),
title: $('spTitle').value.trim() || d.title,
subtitle: $('spSub').value.trim() || d.subtitle, subtitle: $('spSub').value.trim() || d.subtitle,
price_display: $('spPrice').value.trim() || d.price_display, price_display: $('spPrice').value.trim() || d.price_display,
price_cents: parseInt($('spCents').value, 10) || d.price_cents, price_cents: parseInt($('spCents').value, 10) || d.price_cents,
@ -5346,61 +5386,37 @@ $('wlAdd').onclick = async () => {
async apiDoc() { async apiDoc() {
$('content').innerHTML = '<p class="muted">加载接口文档…</p>'; const DOC = 'https://sparkapi.wyrs365.com';
try { const bust = '202609241556';
const doc = await api('/spark/api-doc'); const embedUrl = DOC + '/docs?from=popadmin&v=' + bust;
const row = (a) =>
`<tr><td>${esc(a.method)}</td><td><code>${esc(a.path)}</code></td><td>${esc(a.auth || '-')}</td><td>${esc(a.desc || '')}</td></tr>`;
const tableBlock = (title, list) => `
<div class="section-block" style="margin-bottom:16px">
<h3>${esc(title)}</h3>
<table>
<thead><tr><th>方法</th><th>路径</th><th>鉴权</th><th>说明</th></tr></thead>
<tbody>${(list || []).map(row).join('') || emptyRows(4, '暂无')}</tbody>
</table>
</div>`;
const notes = (doc.notes || []).map((n) => `<li>${esc(n)}</li>`).join('');
$('content').innerHTML = ` $('content').innerHTML = `
<div class="note" style="margin-bottom:12px"> <div class="note" style="margin-bottom:12px">
Base URL:<code>${esc(doc.baseUrl || 'https://popadmin.wyrs365.com')}</code> Spark 客户端 HTTP API 权威文档(OpenAPI / Swagger)内嵌自
· 数据源 <code>GET /api/admin/spark/api-doc</code> <a href="${DOC}/docs" target="_blank" rel="noopener">${DOC}/docs</a>
· 路径别名 <code>/api/admin/pop/…</code>、<code>/api/client/pop/…</code> · 规范 <code>${DOC}/openapi.yaml</code>
· 含音乐分类、多广场 <code>square</code> / <code>squarenovel</code> / <code>squaresteps</code> / <code>squarehealth</code> 等
· <a href="${DOC}/docs" target="_blank" rel="noopener">新窗口打开</a>
· <a href="${DOC}/docs/enums" target="_blank" rel="noopener">枚举</a>
· <a href="${DOC}/docs/errors" target="_blank" rel="noopener">错误码</a>
</div> </div>
<ul class="note" style="margin-bottom:16px;padding-left:18px">${notes}</ul> <iframe id="sparkApiDocFrame" title="Spark API 文档" src="${embedUrl}"
${tableBlock('管理端 API(popadmin 常用)', doc.adminApis)} style="width:100%;height:calc(100vh - 160px);min-height:640px;border:1px solid var(--border);border-radius:10px;background:#0b1220"
${tableBlock('火花客户端 API(/api/client/spark)', doc.clientApis)} referrerpolicy="no-referrer-when-downgrade"></iframe>`;
${tableBlock('邮箱等共享客户端 API', doc.sharedClientApis)}
${tableBlock('公开 API', doc.publicApis)}`;
} catch (e) {
$('content').innerHTML = `<p class="error">${esc(e.message)}</p>`;
}
}, },

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,150 @@
/*
* Decompiled with CFR 0.152.
*
* Could not load the following classes:
* com.aliyun.control.dto.ApiResponse
* com.wyrs.admin.controller.AppClientApiController
* com.wyrs.admin.dto.AdPlacementClientDto
* com.wyrs.admin.dto.DeveloperAppClientItem
* com.wyrs.admin.dto.FeedbackSubmitRequest
* com.wyrs.admin.dto.PaymentSubmitRequest
* com.wyrs.admin.entity.AppBanner
* com.wyrs.admin.entity.AppUser
* com.wyrs.admin.entity.AppVersion
* com.wyrs.admin.entity.ClientLog
* com.wyrs.admin.entity.MembershipPrice
* com.wyrs.admin.entity.PaymentRecord
* com.wyrs.admin.entity.UserFeedback
* com.wyrs.admin.entity.UserShareEvent
* com.wyrs.admin.security.EmailUserAuthHelper
* com.wyrs.admin.security.EmailUserAuthHelper$EmailUser
* com.wyrs.admin.service.AppClientService
* com.wyrs.admin.service.AppClientService$HeartbeatRequest
* com.wyrs.admin.service.AppClientService$ShareRequest
* com.wyrs.admin.service.ClientDiagnosticsService
* com.wyrs.admin.service.config.PaymentChannelConfigService
* jakarta.servlet.http.HttpServletRequest
* org.springframework.http.HttpStatus
* org.springframework.web.bind.annotation.GetMapping
* org.springframework.web.bind.annotation.PathVariable
* org.springframework.web.bind.annotation.PostMapping
* org.springframework.web.bind.annotation.RequestBody
* org.springframework.web.bind.annotation.RequestMapping
* org.springframework.web.bind.annotation.RequestParam
* org.springframework.web.bind.annotation.ResponseStatus
* org.springframework.web.bind.annotation.RestController
*/
package com.wyrs.admin.controller;
import com.aliyun.control.dto.ApiResponse;
import com.wyrs.admin.dto.AdPlacementClientDto;
import com.wyrs.admin.dto.DeveloperAppClientItem;
import com.wyrs.admin.dto.FeedbackSubmitRequest;
import com.wyrs.admin.dto.PaymentSubmitRequest;
import com.wyrs.admin.entity.AppBanner;
import com.wyrs.admin.entity.AppUser;
import com.wyrs.admin.entity.AppVersion;
import com.wyrs.admin.entity.ClientLog;
import com.wyrs.admin.entity.MembershipPrice;
import com.wyrs.admin.entity.PaymentRecord;
import com.wyrs.admin.entity.UserFeedback;
import com.wyrs.admin.entity.UserShareEvent;
import com.wyrs.admin.security.EmailUserAuthHelper;
import com.wyrs.admin.service.AppClientService;
import com.wyrs.admin.service.ClientDiagnosticsService;
import com.wyrs.admin.service.config.PaymentChannelConfigService;
import jakarta.servlet.http.HttpServletRequest;
import java.util.List;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping(value={"/api/client/{appCode}"})
public class AppClientApiController {
private final AppClientService appClientService;
private final ClientDiagnosticsService clientDiagnosticsService;
private final EmailUserAuthHelper emailUserAuthHelper;
private final PaymentChannelConfigService paymentChannelConfigService;
@PostMapping(value={"/heartbeat"})
public ApiResponse<AppUser> heartbeat(@PathVariable String appCode, @RequestBody AppClientService.HeartbeatRequest req, HttpServletRequest request) {
return ApiResponse.ok((Object)this.appClientService.heartbeat(appCode, req, request));
}
@PostMapping(value={"/logs"})
public ApiResponse<ClientLog> logs(@PathVariable String appCode, @RequestBody ClientLog log) {
return ApiResponse.ok((Object)this.appClientService.reportLog(appCode, log));
}
@PostMapping(value={"/diagnostics"})
@ResponseStatus(value=HttpStatus.CREATED)
public ApiResponse<Map<String, Object>> diagnostics(@PathVariable String appCode, @RequestBody Map<String, Object> body) {
String id = this.clientDiagnosticsService.ingest(body);
return ApiResponse.ok(Map.of("ok", true, "id", id, "appCode", appCode));
}
@PostMapping(value={"/feedback"})
public ApiResponse<UserFeedback> feedback(@PathVariable String appCode, @RequestBody FeedbackSubmitRequest req, HttpServletRequest request) {
if ("weiyu-pet".equals(appCode)) {
EmailUserAuthHelper.EmailUser user = this.emailUserAuthHelper.require(request);
req.setContact(user.email());
}
return ApiResponse.ok((Object)this.appClientService.feedback(appCode, req));
}
@PostMapping(value={"/payment"})
public ApiResponse<PaymentRecord> payment(@PathVariable String appCode, @RequestBody PaymentSubmitRequest req) {
return ApiResponse.ok((Object)this.appClientService.payment(appCode, req));
}
@PostMapping(value={"/share"})
public ApiResponse<UserShareEvent> share(@PathVariable String appCode, @RequestBody AppClientService.ShareRequest req) {
return ApiResponse.ok((Object)this.appClientService.share(appCode, req));
}
@GetMapping(value={"/version"})
public ApiResponse<AppVersion> version(@PathVariable String appCode) {
return ApiResponse.ok((Object)this.appClientService.latestVersion(appCode));
}
@GetMapping(value={"/banners"})
public ApiResponse<List<AppBanner>> banners(@PathVariable String appCode) {
return ApiResponse.ok((Object)this.appClientService.enabledBanners(appCode));
}
@GetMapping(value={"/prices"})
public ApiResponse<List<MembershipPrice>> prices(@PathVariable String appCode) {
return ApiResponse.ok((Object)this.appClientService.enabledPrices(appCode));
}
@GetMapping(value={"/payment-channels"})
public ApiResponse<Map<String, Object>> paymentChannels(@PathVariable String appCode) {
return ApiResponse.ok(Map.of("appCode", appCode, "channels", this.paymentChannelConfigService.listPublicForClient(appCode)));
}
@GetMapping(value={"/developer-apps"})
public ApiResponse<List<DeveloperAppClientItem>> developerApps(@PathVariable String appCode, @RequestParam(required=false) String channel, HttpServletRequest request) {
return ApiResponse.ok((Object)this.appClientService.developerApps(appCode, request, channel));
}
@GetMapping(value={"/ad-placements"})
public ApiResponse<AdPlacementClientDto> adPlacements(@PathVariable String appCode) {
return ApiResponse.ok((Object)this.appClientService.adPlacements(appCode));
}
public AppClientApiController(AppClientService appClientService, ClientDiagnosticsService clientDiagnosticsService, EmailUserAuthHelper emailUserAuthHelper, PaymentChannelConfigService paymentChannelConfigService) {
this.appClientService = appClientService;
this.clientDiagnosticsService = clientDiagnosticsService;
this.emailUserAuthHelper = emailUserAuthHelper;
this.paymentChannelConfigService = paymentChannelConfigService;
}
}

View File

@ -0,0 +1,576 @@
/*
* Decompiled with CFR 0.152.
*
* Could not load the following classes:
* com.aliyun.control.exception.BusinessException
* com.fasterxml.jackson.databind.ObjectMapper
* com.wyrs.admin.config.WyrsAppRegistry
* com.wyrs.admin.dto.AdPlacementClientDto
* com.wyrs.admin.dto.DeveloperAppClientItem
* com.wyrs.admin.dto.FeedbackSubmitRequest
* com.wyrs.admin.dto.GeoInfo
* com.wyrs.admin.dto.PaymentSubmitRequest
* com.wyrs.admin.entity.AppBanner
* com.wyrs.admin.entity.AppUser
* com.wyrs.admin.entity.AppVersion
* com.wyrs.admin.entity.ChannelDailyStat
* com.wyrs.admin.entity.ClientLog
* com.wyrs.admin.entity.CountryDailyStat
* com.wyrs.admin.entity.DailyActiveStat
* com.wyrs.admin.entity.MembershipPrice
* com.wyrs.admin.entity.PaymentRecord
* com.wyrs.admin.entity.UserFeedback
* com.wyrs.admin.entity.UserShareEvent
* com.wyrs.admin.repository.AppUserRepository
* com.wyrs.admin.repository.ChannelDailyStatRepository
* com.wyrs.admin.repository.ClientLogRepository
* com.wyrs.admin.repository.CountryDailyStatRepository
* com.wyrs.admin.repository.DailyActiveStatRepository
* com.wyrs.admin.repository.PaymentRecordRepository
* com.wyrs.admin.repository.UserFeedbackRepository
* com.wyrs.admin.repository.UserShareEventRepository
* com.wyrs.admin.security.ClientApiAuthException
* com.wyrs.admin.service.AdminStatsService
* com.wyrs.admin.service.AppClientService
* com.wyrs.admin.service.AppClientService$HeartbeatRequest
* com.wyrs.admin.service.AppClientService$ShareRequest
* com.wyrs.admin.service.BannerStateService
* com.wyrs.admin.service.CountryResolveService
* com.wyrs.admin.service.DauDedupService
* com.wyrs.admin.service.DefaultAvatarService
* com.wyrs.admin.service.DiscordFeedbackNotifier
* com.wyrs.admin.service.OnlineDeviceTracker
* com.wyrs.admin.service.SparkUserOpsStore
* com.wyrs.admin.service.config.AdPlacementsConfigService
* com.wyrs.admin.service.config.AppClientFeatureConfigService
* com.wyrs.admin.service.config.AppWhitelistConfigService
* com.wyrs.admin.service.config.BannersConfigService
* com.wyrs.admin.service.config.DeveloperAppsConfigService
* com.wyrs.admin.service.config.PricesConfigService
* com.wyrs.admin.service.config.VersionsConfigService
* com.wyrs.admin.util.AppChannelNames
* com.wyrs.admin.util.ClientIpUtils
* com.wyrs.admin.util.CountryNames
* com.wyrs.admin.util.DefaultAvatarPool
* com.wyrs.admin.util.TestDevicePatterns
* com.wyrs.admin.util.WyrsClientLanguage
* jakarta.servlet.http.HttpServletRequest
* org.springframework.core.env.Environment
* org.springframework.http.HttpStatus
* org.springframework.stereotype.Service
* org.springframework.transaction.annotation.Transactional
*/
package com.wyrs.admin.service;
import com.aliyun.control.exception.BusinessException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.wyrs.admin.config.WyrsAppRegistry;
import com.wyrs.admin.dto.AdPlacementClientDto;
import com.wyrs.admin.dto.DeveloperAppClientItem;
import com.wyrs.admin.dto.FeedbackSubmitRequest;
import com.wyrs.admin.dto.GeoInfo;
import com.wyrs.admin.dto.PaymentSubmitRequest;
import com.wyrs.admin.entity.AppBanner;
import com.wyrs.admin.entity.AppUser;
import com.wyrs.admin.entity.AppVersion;
import com.wyrs.admin.entity.ChannelDailyStat;
import com.wyrs.admin.entity.ClientLog;
import com.wyrs.admin.entity.CountryDailyStat;
import com.wyrs.admin.entity.DailyActiveStat;
import com.wyrs.admin.entity.MembershipPrice;
import com.wyrs.admin.entity.PaymentRecord;
import com.wyrs.admin.entity.UserFeedback;
import com.wyrs.admin.entity.UserShareEvent;
import com.wyrs.admin.repository.AppUserRepository;
import com.wyrs.admin.repository.ChannelDailyStatRepository;
import com.wyrs.admin.repository.ClientLogRepository;
import com.wyrs.admin.repository.CountryDailyStatRepository;
import com.wyrs.admin.repository.DailyActiveStatRepository;
import com.wyrs.admin.repository.PaymentRecordRepository;
import com.wyrs.admin.repository.UserFeedbackRepository;
import com.wyrs.admin.repository.UserShareEventRepository;
import com.wyrs.admin.security.ClientApiAuthException;
import com.wyrs.admin.service.AdminStatsService;
import com.wyrs.admin.service.AppClientService;
import com.wyrs.admin.service.BannerStateService;
import com.wyrs.admin.service.CountryResolveService;
import com.wyrs.admin.service.DauDedupService;
import com.wyrs.admin.service.DefaultAvatarService;
import com.wyrs.admin.service.DiscordFeedbackNotifier;
import com.wyrs.admin.service.OnlineDeviceTracker;
import com.wyrs.admin.service.SparkUserOpsStore;
import com.wyrs.admin.service.config.AdPlacementsConfigService;
import com.wyrs.admin.service.config.AppClientFeatureConfigService;
import com.wyrs.admin.service.config.AppWhitelistConfigService;
import com.wyrs.admin.service.config.BannersConfigService;
import com.wyrs.admin.service.config.DeveloperAppsConfigService;
import com.wyrs.admin.service.config.PricesConfigService;
import com.wyrs.admin.service.config.VersionsConfigService;
import com.wyrs.admin.util.AppChannelNames;
import com.wyrs.admin.util.ClientIpUtils;
import com.wyrs.admin.util.CountryNames;
import com.wyrs.admin.util.DefaultAvatarPool;
import com.wyrs.admin.util.TestDevicePatterns;
import com.wyrs.admin.util.WyrsClientLanguage;
import jakarta.servlet.http.HttpServletRequest;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import org.springframework.core.env.Environment;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/*
* Exception performing whole class analysis ignored.
*/
@Service
public class AppClientService {
private final AppUserRepository appUserRepository;
private final ClientLogRepository clientLogRepository;
private final UserFeedbackRepository userFeedbackRepository;
private final DailyActiveStatRepository dailyActiveStatRepository;
private final ChannelDailyStatRepository channelDailyStatRepository;
private final DauDedupService dauDedupService;
private final PaymentRecordRepository paymentRecordRepository;
private final UserShareEventRepository userShareEventRepository;
private final CountryDailyStatRepository countryDailyStatRepository;
private final CountryResolveService countryResolveService;
private final BannerStateService bannerStateService;
private final BannersConfigService bannersConfigService;
private final VersionsConfigService versionsConfigService;
private final PricesConfigService pricesConfigService;
private final DeveloperAppsConfigService developerAppsConfigService;
private final AdPlacementsConfigService adPlacementsConfigService;
private final AppWhitelistConfigService appWhitelistConfigService;
private final AppClientFeatureConfigService appClientFeatureConfigService;
private final SparkUserOpsStore sparkUserOpsStore;
private final OnlineDeviceTracker onlineDeviceTracker;
private final Environment environment;
private final DiscordFeedbackNotifier discordFeedbackNotifier;
private final DefaultAvatarService defaultAvatarService;
public void validateApp(String appCode) {
if (!WyrsAppRegistry.isValid((String)appCode)) {
throw new ClientApiAuthException("\u672a\u77e5 App: " + appCode, HttpStatus.NOT_FOUND);
}
}
@Transactional
public AppUser heartbeat(String appCode, HeartbeatRequest req, HttpServletRequest request) {
boolean firstSeenToday;
String resolvedCountry;
this.validateApp(appCode);
if (req.getDeviceId() == null || req.getDeviceId().isBlank()) {
throw new BusinessException("deviceId \u4e0d\u80fd\u4e3a\u7a7a");
}
if (this.isProdProfile() && TestDevicePatterns.isSimulatedDevice((String)req.getDeviceId(), (String)req.getNickname())) {
throw new BusinessException("\u65e0\u6548\u7684 deviceId");
}
String channel = AppClientService.normalizeChannel((String)req.getChannel());
String clientIp = ClientIpUtils.resolve((HttpServletRequest)request);
Integer clientPort = ClientIpUtils.resolvePort((HttpServletRequest)request);
GeoInfo geo = this.countryResolveService.resolveGeo(request);
String country = req.getCountry() != null && !req.getCountry().isBlank() ? CountryNames.normalize((String)req.getCountry()) : geo.countryCode();
String city = geo.city() != null ? geo.city() : geo.region();
Optional existing = this.appUserRepository.findByAppCodeAndDeviceId(appCode, req.getDeviceId());
boolean isNew = existing.isEmpty();
LocalDate today = AdminStatsService.todayInChina();
LocalDateTime dayStart = today.atStartOfDay();
LocalDateTime dayEnd = today.plusDays(1L).atStartOfDay();
LocalDateTime prevActive = existing.map(AppUser::getLastActiveAt).orElse(null);
boolean alreadyActiveToday = prevActive != null && !prevActive.isBefore(dayStart) && prevActive.isBefore(dayEnd);
AppUser user = existing.orElseGet(() -> AppUser.builder().appCode(appCode).deviceId(req.getDeviceId()).channel(channel).country(country).registerIp(clientIp).registerPort(clientPort).registerCity(city).build());
boolean dirty = isNew;
if (isNew) {
user.setRegisterIp(clientIp);
user.setRegisterPort(clientPort);
user.setRegisterCity(city);
if (DefaultAvatarPool.isBlank((String)user.getAvatarUrl())) {
String seed = req.getEmail() != null && !req.getEmail().isBlank() ? req.getEmail() : req.getDeviceId();
user.setAvatarUrl(this.defaultAvatarService.pickStable(seed));
}
}
if (req.getPhone() != null && !Objects.equals(req.getPhone(), user.getPhone())) {
user.setPhone(req.getPhone());
dirty = true;
}
if (req.getNickname() != null && !Objects.equals(req.getNickname(), user.getNickname())) {
user.setNickname(req.getNickname());
dirty = true;
}
if (req.getPlatform() != null && !Objects.equals(req.getPlatform(), user.getPlatform())) {
user.setPlatform(req.getPlatform());
dirty = true;
}
if (req.getAppVersion() != null && !Objects.equals(req.getAppVersion(), user.getAppVersion())) {
user.setAppVersion(req.getAppVersion());
dirty = true;
}
if (user.getChannel() == null || user.getChannel().isBlank()) {
user.setChannel(channel);
dirty = true;
}
if (country != null && !"UN".equals(country)) {
if (!Objects.equals(country, user.getCountry())) {
user.setCountry(country);
dirty = true;
}
} else if (user.getCountry() == null || user.getCountry().isBlank()) {
user.setCountry(country);
dirty = true;
}
if (!Objects.equals(clientIp, user.getLastActiveIp())) {
user.setLastActiveIp(clientIp);
dirty = true;
}
if (!Objects.equals(clientPort, user.getLastActivePort())) {
user.setLastActivePort(clientPort);
dirty = true;
}
if (city != null && !Objects.equals(city, user.getLastActiveCity())) {
user.setLastActiveCity(city);
dirty = true;
}
if (user.getRegisterIp() == null || user.getRegisterIp().isBlank()) {
user.setRegisterIp(clientIp);
dirty = true;
}
if (user.getRegisterPort() == null && clientPort != null) {
user.setRegisterPort(clientPort);
dirty = true;
}
if ((user.getRegisterCity() == null || user.getRegisterCity().isBlank()) && city != null) {
user.setRegisterCity(city);
dirty = true;
}
LocalDateTime now = LocalDateTime.now(AdminStatsService.CHINA_ZONE);
if (!alreadyActiveToday) {
user.setLastActiveAt(now);
dirty = true;
}
if (Boolean.TRUE.equals(user.getBanned())) {
throw new BusinessException("\u8d26\u53f7\u5df2\u5c01\u7981", HttpStatus.FORBIDDEN);
}
if (dirty) {
user = (AppUser)this.appUserRepository.save((Object)user);
}
String string = resolvedCountry = user.getCountry() != null ? user.getCountry() : country;
if (!alreadyActiveToday && (firstSeenToday = this.dauDedupService.tryMarkFirstActive(appCode, req.getDeviceId(), today, channel, resolvedCountry, now))) {
this.bumpDau(appCode, channel, isNew);
this.bumpCountryDau(appCode, resolvedCountry, isNew);
}
String publicId = AppWhitelistConfigService.normalizePublicId((String)req.getPublicId());
String email = AppWhitelistConfigService.normalizeEmail((String)req.getEmail());
boolean whitelisted = this.appWhitelistConfigService.isWhitelisted(appCode, publicId, email);
user.setWhitelist(Boolean.valueOf(whitelisted));
user.setFamilySharingEnabled(Boolean.valueOf(this.appClientFeatureConfigService.isFamilySharingEnabled(appCode)));
user.setPublicId(publicId);
if (email != null && !email.isBlank()) {
user.setEmail(email);
this.appUserRepository.save((Object)user);
if ("spark".equals(appCode) || "pop".equals(appCode)) {
this.sparkUserOpsStore.touchDevice(email, req.getDeviceId(), req.getPlatform(), req.getAppVersion(), clientIp, clientPort, city);
}
}
this.onlineDeviceTracker.touch(appCode, req.getDeviceId());
return user;
}
public ClientLog reportLog(String appCode, ClientLog log) {
this.validateApp(appCode);
log.setAppCode(appCode);
if (log.getCreatedAt() == null) {
log.setCreatedAt(LocalDateTime.now());
}
this.enrichClientLogIdentity(log);
if (log.getLevel() == null || log.getLevel().isBlank()) {
String msg;
String string = msg = log.getMessage() == null ? "" : log.getMessage().toLowerCase();
if (msg.contains("crash") || msg.contains("fatal") || msg.contains("uncaughtexception") || msg.contains("\u5d29\u6e83")) {
log.setLevel("crash");
} else {
log.setLevel("info");
}
} else {
log.setLevel(log.getLevel().trim().toLowerCase());
}
return (ClientLog)this.clientLogRepository.save((Object)log);
}
private void enrichClientLogIdentity(ClientLog log) {
if (log.getEmail() != null && !log.getEmail().isBlank()) {
log.setEmail(log.getEmail().trim().toLowerCase());
}
if (log.getDeviceId() != null) {
log.setDeviceId(log.getDeviceId().trim());
}
if (log.getDeviceModel() != null) {
log.setDeviceModel(log.getDeviceModel().trim());
}
this.parseDeviceInfoJson(log);
if ((log.getEmail() == null || log.getEmail().isBlank() || log.getDeviceModel() == null) && log.getUserId() != null) {
this.appUserRepository.findById((Object)log.getUserId()).ifPresent(u -> {
if ((log.getEmail() == null || log.getEmail().isBlank()) && u.getEmail() != null && !u.getEmail().isBlank()) {
log.setEmail(u.getEmail().trim().toLowerCase());
}
if ((log.getDeviceId() == null || log.getDeviceId().isBlank()) && u.getDeviceId() != null) {
log.setDeviceId(u.getDeviceId());
}
});
}
if ((log.getEmail() == null || log.getEmail().isBlank()) && log.getDeviceId() != null && !log.getDeviceId().isBlank()) {
this.appUserRepository.findByAppCodeAndDeviceId(log.getAppCode(), log.getDeviceId()).ifPresent(u -> {
if (u.getEmail() != null && !u.getEmail().isBlank()) {
log.setEmail(u.getEmail().trim().toLowerCase());
}
if (log.getUserId() == null) {
log.setUserId(u.getId());
}
});
}
}
private void parseDeviceInfoJson(ClientLog log) {
String raw = log.getDeviceInfo();
if (raw == null || raw.isBlank() || !raw.trim().startsWith("{")) {
if ((log.getDeviceModel() == null || log.getDeviceModel().isBlank()) && raw != null && !raw.isBlank() && raw.length() <= 128) {
log.setDeviceModel(raw.trim());
}
return;
}
try {
Object v;
Map map = (Map)new ObjectMapper().readValue(raw, Map.class);
if ((log.getDeviceId() == null || log.getDeviceId().isBlank()) && (v = AppClientService.first((Map)map, (String[])new String[]{"deviceId", "device_id", "uuid"})) != null) {
log.setDeviceId(String.valueOf(v).trim());
}
if ((log.getEmail() == null || log.getEmail().isBlank()) && (v = AppClientService.first((Map)map, (String[])new String[]{"email", "account", "userEmail"})) != null && String.valueOf(v).contains("@")) {
log.setEmail(String.valueOf(v).trim().toLowerCase());
}
if (log.getDeviceModel() == null || log.getDeviceModel().isBlank()) {
v = AppClientService.first((Map)map, (String[])new String[]{"deviceModel", "model", "phoneModel", "device", "brandModel"});
if (v != null) {
log.setDeviceModel(String.valueOf(v).trim());
} else {
String bm;
Object brand = AppClientService.first((Map)map, (String[])new String[]{"brand", "manufacturer"});
Object model = AppClientService.first((Map)map, (String[])new String[]{"model", "device"});
if (!(brand == null && model == null || (bm = (String.valueOf(brand == null ? "" : brand) + " " + String.valueOf(model == null ? "" : model)).trim()).isBlank())) {
log.setDeviceModel(bm);
}
}
}
}
catch (Exception exception) {
// empty catch block
}
}
private static Object first(Map<String, Object> map, String ... keys) {
for (String k : keys) {
String s;
if (!map.containsKey(k) || map.get(k) == null || (s = String.valueOf(map.get(k)).trim()).isEmpty() || "null".equalsIgnoreCase(s)) continue;
return map.get(k);
}
return null;
}
public UserFeedback feedback(String appCode, FeedbackSubmitRequest req) {
this.validateApp(appCode);
UserFeedback fb = new UserFeedback();
fb.setAppCode(appCode);
fb.setUserId(req.getUserId());
fb.setContent(req.getContent());
fb.setContact(req.getContact());
fb.setStatus("pending");
fb.setCreatedAt(LocalDateTime.now());
fb = (UserFeedback)this.userFeedbackRepository.save((Object)fb);
this.discordFeedbackNotifier.notifyFeedbackAsync(fb);
return fb;
}
public PaymentRecord payment(String appCode, PaymentSubmitRequest req) {
this.validateApp(appCode);
PaymentRecord record = new PaymentRecord();
record.setAppCode(appCode);
record.setUserId(req.getUserId());
record.setOrderId(req.getOrderId());
record.setThirdPartyOrderId(req.getThirdPartyOrderId());
record.setAmount(req.getAmount());
record.setProductId(req.getProductId());
record.setStatus(req.getStatus() != null ? req.getStatus() : "pending");
record.setPayChannel(req.getPayChannel());
record.setSimulated(req.getSimulated());
record.setPaymentType(req.getPaymentType());
record.setPaidAt(req.getPaidAt());
record.setCreatedAt(LocalDateTime.now());
AppClientService.normalizePayment((PaymentRecord)record);
return (PaymentRecord)this.paymentRecordRepository.save((Object)record);
}
public UserFeedback feedback(String appCode, UserFeedback fb) {
this.validateApp(appCode);
fb.setAppCode(appCode);
if (fb.getStatus() == null) {
fb.setStatus("pending");
}
if (fb.getCreatedAt() == null) {
fb.setCreatedAt(LocalDateTime.now());
}
fb = (UserFeedback)this.userFeedbackRepository.save((Object)fb);
this.discordFeedbackNotifier.notifyFeedbackAsync(fb);
return fb;
}
public PaymentRecord payment(String appCode, PaymentRecord record) {
this.validateApp(appCode);
record.setAppCode(appCode);
if (record.getCreatedAt() == null) {
record.setCreatedAt(LocalDateTime.now());
}
if (record.getStatus() == null) {
record.setStatus("pending");
}
AppClientService.normalizePayment((PaymentRecord)record);
return (PaymentRecord)this.paymentRecordRepository.save((Object)record);
}
private static void normalizePayment(PaymentRecord record) {
boolean isSimulated;
boolean bl = isSimulated = Boolean.TRUE.equals(record.getSimulated()) || AppClientService.isSimulatedMarker((String)record.getPayChannel()) || AppClientService.isSimulatedMarker((String)record.getPaymentType());
if (isSimulated) {
record.setSimulated(Boolean.valueOf(true));
record.setPayChannel("simulate");
} else if (record.getSimulated() == null) {
record.setSimulated(Boolean.valueOf(false));
}
if ("paid".equalsIgnoreCase(record.getStatus()) && record.getPaidAt() == null) {
record.setPaidAt(LocalDateTime.now());
}
if (record.getThirdPartyOrderId() != null) {
record.setThirdPartyOrderId(record.getThirdPartyOrderId().trim());
if (record.getThirdPartyOrderId().isEmpty()) {
record.setThirdPartyOrderId(null);
}
}
}
private static boolean isSimulatedMarker(String value) {
if (value == null || value.isBlank()) {
return false;
}
String s = value.trim().toLowerCase(Locale.ROOT);
return "simulate".equals(s) || "mock".equals(s) || "simulated".equals(s);
}
public UserShareEvent share(String appCode, ShareRequest req) {
this.validateApp(appCode);
UserShareEvent event = UserShareEvent.builder().appCode(appCode).userId(req.getUserId()).deviceId(req.getDeviceId()).shareTarget(req.getShareTarget()).channel(AppClientService.normalizeChannel((String)req.getChannel())).build();
event = (UserShareEvent)this.userShareEventRepository.save((Object)event);
this.bumpShareCount(appCode, AppClientService.normalizeChannel((String)req.getChannel()));
return event;
}
public AppVersion latestVersion(String appCode) {
this.validateApp(appCode);
return this.versionsConfigService.latestVersion(appCode).orElse(null);
}
public List<AppBanner> enabledBanners(String appCode) {
this.validateApp(appCode);
return this.bannerStateService.filterEnabled(this.bannersConfigService.findByAppCodeOrderBySortOrderAsc(appCode));
}
public List<MembershipPrice> enabledPrices(String appCode) {
this.validateApp(appCode);
return this.pricesConfigService.findByAppCodeAndEnabledTrueOrderByPriceAsc(appCode);
}
public List<DeveloperAppClientItem> developerApps(String appCode, HttpServletRequest request, String channel) {
this.validateApp(appCode);
return this.developerAppsConfigService.findEnabledClientItems(appCode, WyrsClientLanguage.resolve((HttpServletRequest)request), channel);
}
public AdPlacementClientDto adPlacements(String appCode) {
this.validateApp(appCode);
return this.adPlacementsConfigService.findOneByAppCode(appCode).map(p -> AdPlacementClientDto.builder().appId(p.getAppId()).splashPlacementId(p.getSplashPlacementId()).bannerPlacementId(p.getBannerPlacementId()).interstitialPlacementId(p.getInterstitialPlacementId()).build()).orElseGet(AdPlacementClientDto::new);
}
private void bumpDau(String appCode, String channel, boolean isNew) {
LocalDate today = AdminStatsService.todayInChina();
DailyActiveStat stat = this.dailyActiveStatRepository.findByAppCodeAndStatDate(appCode, today).orElseGet(() -> DailyActiveStat.builder().appCode(appCode).statDate(today).activeUsers(Long.valueOf(0L)).newUsers(Long.valueOf(0L)).build());
stat.setActiveUsers(Long.valueOf(stat.getActiveUsers() + 1L));
if (isNew) {
stat.setNewUsers(Long.valueOf(stat.getNewUsers() + 1L));
}
this.dailyActiveStatRepository.save((Object)stat);
ChannelDailyStat channelStat = this.channelDailyStatRepository.findByAppCodeAndStatDateAndChannel(appCode, today, channel).orElseGet(() -> ChannelDailyStat.builder().appCode(appCode).statDate(today).channel(channel).activeUsers(Long.valueOf(0L)).newUsers(Long.valueOf(0L)).shareCount(Long.valueOf(0L)).build());
channelStat.setActiveUsers(Long.valueOf(channelStat.getActiveUsers() + 1L));
if (isNew) {
channelStat.setNewUsers(Long.valueOf(channelStat.getNewUsers() + 1L));
}
this.channelDailyStatRepository.save((Object)channelStat);
}
private void bumpCountryDau(String appCode, String country, boolean isNew) {
String code = CountryNames.normalize((String)country);
LocalDate today = AdminStatsService.todayInChina();
CountryDailyStat stat = this.countryDailyStatRepository.findByAppCodeAndStatDateAndCountry(appCode, today, code).orElseGet(() -> CountryDailyStat.builder().appCode(appCode).statDate(today).country(code).activeUsers(Long.valueOf(0L)).newUsers(Long.valueOf(0L)).build());
stat.setActiveUsers(Long.valueOf(stat.getActiveUsers() + 1L));
if (isNew) {
stat.setNewUsers(Long.valueOf(stat.getNewUsers() + 1L));
}
this.countryDailyStatRepository.save((Object)stat);
}
private void bumpShareCount(String appCode, String channel) {
LocalDate today = AdminStatsService.todayInChina();
ChannelDailyStat channelStat = this.channelDailyStatRepository.findByAppCodeAndStatDateAndChannel(appCode, today, channel).orElseGet(() -> ChannelDailyStat.builder().appCode(appCode).statDate(today).channel(channel).activeUsers(Long.valueOf(0L)).newUsers(Long.valueOf(0L)).shareCount(Long.valueOf(0L)).build());
channelStat.setShareCount(Long.valueOf(channelStat.getShareCount() + 1L));
this.channelDailyStatRepository.save((Object)channelStat);
}
private static String normalizeChannel(String channel) {
return AppChannelNames.normalize((String)channel);
}
private boolean isProdProfile() {
for (String profile : this.environment.getActiveProfiles()) {
if (!"prod".equalsIgnoreCase(profile)) continue;
return true;
}
return false;
}
public AppClientService(AppUserRepository appUserRepository, ClientLogRepository clientLogRepository, UserFeedbackRepository userFeedbackRepository, DailyActiveStatRepository dailyActiveStatRepository, ChannelDailyStatRepository channelDailyStatRepository, DauDedupService dauDedupService, PaymentRecordRepository paymentRecordRepository, UserShareEventRepository userShareEventRepository, CountryDailyStatRepository countryDailyStatRepository, CountryResolveService countryResolveService, BannerStateService bannerStateService, BannersConfigService bannersConfigService, VersionsConfigService versionsConfigService, PricesConfigService pricesConfigService, DeveloperAppsConfigService developerAppsConfigService, AdPlacementsConfigService adPlacementsConfigService, AppWhitelistConfigService appWhitelistConfigService, AppClientFeatureConfigService appClientFeatureConfigService, SparkUserOpsStore sparkUserOpsStore, OnlineDeviceTracker onlineDeviceTracker, Environment environment, DiscordFeedbackNotifier discordFeedbackNotifier, DefaultAvatarService defaultAvatarService) {
this.appUserRepository = appUserRepository;
this.clientLogRepository = clientLogRepository;
this.userFeedbackRepository = userFeedbackRepository;
this.dailyActiveStatRepository = dailyActiveStatRepository;
this.channelDailyStatRepository = channelDailyStatRepository;
this.dauDedupService = dauDedupService;
this.paymentRecordRepository = paymentRecordRepository;
this.userShareEventRepository = userShareEventRepository;
this.countryDailyStatRepository = countryDailyStatRepository;
this.countryResolveService = countryResolveService;
this.bannerStateService = bannerStateService;
this.bannersConfigService = bannersConfigService;
this.versionsConfigService = versionsConfigService;
this.pricesConfigService = pricesConfigService;
this.developerAppsConfigService = developerAppsConfigService;
this.adPlacementsConfigService = adPlacementsConfigService;
this.appWhitelistConfigService = appWhitelistConfigService;
this.appClientFeatureConfigService = appClientFeatureConfigService;
this.sparkUserOpsStore = sparkUserOpsStore;
this.onlineDeviceTracker = onlineDeviceTracker;
this.environment = environment;
this.discordFeedbackNotifier = discordFeedbackNotifier;
this.defaultAvatarService = defaultAvatarService;
}
}

View File

@ -0,0 +1,156 @@
package com.wyrs.admin.entity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import jakarta.persistence.Transient;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.Map;
import org.hibernate.annotations.JdbcTypeCode;
@Entity
@Table(name = "membership_price")
public class MembershipPrice {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 32)
private String appCode;
/** applicationId / package name; empty = default for appCode. @Transient avoids ddl=validate schema change; still persisted in prices.json via Jackson. */
@Transient
private String packageName;
@Column(length = 64)
private String productId;
@Column(length = 128)
private String name;
@Column(precision = 10, scale = 2)
private BigDecimal price;
@Column(precision = 10, scale = 2)
private BigDecimal originalPrice;
private Integer durationDays;
@Column(length = 32)
private String billingType;
@JdbcTypeCode(value = 3001)
@Column(columnDefinition = "TEXT")
private Map<String, String> channelProductCodes;
private Boolean enabled;
private LocalDateTime createdAt;
@PrePersist
void prePersist() {
if (this.createdAt == null) {
this.createdAt = LocalDateTime.now();
}
if (this.enabled == null) {
this.enabled = true;
}
if (this.billingType == null || this.billingType.isBlank()) {
this.billingType = this.durationDays != null && this.durationDays > 0 ? "subscription" : "one_time";
}
if (this.channelProductCodes == null) {
this.channelProductCodes = new LinkedHashMap<>();
}
if (this.packageName == null) {
this.packageName = "";
}
}
private static Map<String, String> defaultChannelProductCodes() {
return new LinkedHashMap<>();
}
public static MembershipPriceBuilder builder() {
return new MembershipPriceBuilder();
}
public Long getId() { return this.id; }
public String getAppCode() { return this.appCode; }
public String getPackageName() { return this.packageName == null ? "" : this.packageName; }
public String getProductId() { return this.productId; }
public String getName() { return this.name; }
public BigDecimal getPrice() { return this.price; }
public BigDecimal getOriginalPrice() { return this.originalPrice; }
public Integer getDurationDays() { return this.durationDays; }
public String getBillingType() { return this.billingType; }
public Map<String, String> getChannelProductCodes() { return this.channelProductCodes; }
public Boolean getEnabled() { return this.enabled; }
public LocalDateTime getCreatedAt() { return this.createdAt; }
public void setId(Long id) { this.id = id; }
public void setAppCode(String appCode) { this.appCode = appCode; }
public void setPackageName(String packageName) {
this.packageName = packageName == null ? "" : packageName.trim();
}
public void setProductId(String productId) { this.productId = productId; }
public void setName(String name) { this.name = name; }
public void setPrice(BigDecimal price) { this.price = price; }
public void setOriginalPrice(BigDecimal originalPrice) { this.originalPrice = originalPrice; }
public void setDurationDays(Integer durationDays) { this.durationDays = durationDays; }
public void setBillingType(String billingType) { this.billingType = billingType; }
public void setChannelProductCodes(Map<String, String> channelProductCodes) {
this.channelProductCodes = channelProductCodes;
}
public void setEnabled(Boolean enabled) { this.enabled = enabled; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public MembershipPrice() {
this.channelProductCodes = MembershipPrice.defaultChannelProductCodes();
this.packageName = "";
}
public MembershipPrice(Long id, String appCode, String packageName, String productId, String name,
BigDecimal price, BigDecimal originalPrice, Integer durationDays, String billingType,
Map<String, String> channelProductCodes, Boolean enabled, LocalDateTime createdAt) {
this.id = id;
this.appCode = appCode;
this.packageName = packageName == null ? "" : packageName;
this.productId = productId;
this.name = name;
this.price = price;
this.originalPrice = originalPrice;
this.durationDays = durationDays;
this.billingType = billingType;
this.channelProductCodes = channelProductCodes;
this.enabled = enabled;
this.createdAt = createdAt;
}
public static class MembershipPriceBuilder {
private Long id;
private String appCode;
private String packageName = "";
private String productId;
private String name;
private BigDecimal price;
private BigDecimal originalPrice;
private Integer durationDays;
private String billingType;
private Map<String, String> channelProductCodes = MembershipPrice.defaultChannelProductCodes();
private Boolean enabled;
private LocalDateTime createdAt;
public MembershipPriceBuilder id(Long id) { this.id = id; return this; }
public MembershipPriceBuilder appCode(String appCode) { this.appCode = appCode; return this; }
public MembershipPriceBuilder packageName(String packageName) { this.packageName = packageName; return this; }
public MembershipPriceBuilder productId(String productId) { this.productId = productId; return this; }
public MembershipPriceBuilder name(String name) { this.name = name; return this; }
public MembershipPriceBuilder price(BigDecimal price) { this.price = price; return this; }
public MembershipPriceBuilder originalPrice(BigDecimal originalPrice) { this.originalPrice = originalPrice; return this; }
public MembershipPriceBuilder durationDays(Integer durationDays) { this.durationDays = durationDays; return this; }
public MembershipPriceBuilder billingType(String billingType) { this.billingType = billingType; return this; }
public MembershipPriceBuilder channelProductCodes(Map<String, String> m) { this.channelProductCodes = m; return this; }
public MembershipPriceBuilder enabled(Boolean enabled) { this.enabled = enabled; return this; }
public MembershipPriceBuilder createdAt(LocalDateTime createdAt) { this.createdAt = createdAt; return this; }
public MembershipPrice build() {
return new MembershipPrice(id, appCode, packageName, productId, name, price, originalPrice,
durationDays, billingType, channelProductCodes, enabled, createdAt);
}
}
}

View File

@ -0,0 +1,24 @@
package com.wyrs.admin.util;
/** Request-scoped package name (applicationId) for membership price filtering. */
public final class PackageNameContext {
private static final ThreadLocal<String> HOLDER = new ThreadLocal<>();
private PackageNameContext() {}
public static void set(String packageName) {
if (packageName == null || packageName.isBlank()) {
HOLDER.remove();
} else {
HOLDER.set(packageName.trim());
}
}
public static String get() {
return HOLDER.get();
}
public static void clear() {
HOLDER.remove();
}
}

View File

@ -0,0 +1,35 @@
package com.wyrs.admin.web;
import com.wyrs.admin.util.PackageNameContext;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
/** Reads packageName query or X-Wyrs-Package header into PackageNameContext. */
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 20)
public class PackageNameRequestFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
try {
String pkg = request.getParameter("packageName");
if (pkg == null || pkg.isBlank()) {
pkg = request.getHeader("X-Wyrs-Package");
}
if (pkg == null || pkg.isBlank()) {
pkg = request.getHeader("X-Package-Name");
}
PackageNameContext.set(pkg);
filterChain.doFilter(request, response);
} finally {
PackageNameContext.clear();
}
}
}

View File

@ -0,0 +1,96 @@
package com.wyrs.admin.service.config;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.wyrs.admin.config.WyrsConfigProperties;
import com.wyrs.admin.entity.MembershipPrice;
import com.wyrs.admin.util.PackageNameContext;
import java.math.BigDecimal;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import org.springframework.stereotype.Service;
@Service
public class PricesConfigService extends AbstractEntityListConfigService<MembershipPrice> {
private static final TypeReference<EntityListStore<MembershipPrice>> TYPE =
new TypeReference<EntityListStore<MembershipPrice>>() {};
public PricesConfigService(WyrsConfigProperties config, ObjectMapper objectMapper) {
super(config.resolveFile("prices.json"), objectMapper, TYPE, MembershipPrice::getId, MembershipPrice::getAppCode);
}
public List<MembershipPrice> findByAppCode(String appCode) {
// Admin: all packages under appCode
return this.findByAppCodeOrderByPriceAsc(appCode, false, null);
}
public List<MembershipPrice> findByAppCodeAndEnabledTrueOrderByPriceAsc(String appCode) {
// Client: filter by X-Wyrs-Package / packageName
return this.findByAppCodeOrderByPriceAsc(appCode, true, PackageNameContext.get());
}
private List<MembershipPrice> findByAppCodeOrderByPriceAsc(String appCode, boolean enabledOnly, String packageName) {
Object object = this.lock;
synchronized (object) {
List<MembershipPrice> base = this.store.getItems().stream()
.filter(p -> appCode.equals(p.getAppCode()))
.filter(p -> !enabledOnly || Boolean.TRUE.equals(p.getEnabled()))
.sorted(Comparator.comparing(MembershipPrice::getPrice, Comparator.nullsLast(BigDecimal::compareTo)))
.toList();
return filterByPackage(base, packageName);
}
}
/**
* Empty packageName: return all.
* Non-empty: prefer exact package match; else fall back to blank packageName defaults.
*/
public static List<MembershipPrice> filterByPackage(List<MembershipPrice> items, String packageName) {
if (packageName == null || packageName.isBlank()) {
return items;
}
String want = packageName.trim().toLowerCase(Locale.ROOT);
List<MembershipPrice> exact = items.stream()
.filter(p -> {
String pkg = p.getPackageName();
return pkg != null && !pkg.isBlank() && pkg.trim().toLowerCase(Locale.ROOT).equals(want);
})
.toList();
if (!exact.isEmpty()) {
return exact;
}
return items.stream()
.filter(p -> {
String pkg = p.getPackageName();
return pkg == null || pkg.isBlank();
})
.toList();
}
public MembershipPrice save(MembershipPrice price) {
if (price != null) {
if (price.getPackageName() == null) {
price.setPackageName("");
}
if (price.getBillingType() == null || price.getBillingType().isBlank()) {
Integer days = price.getDurationDays();
price.setBillingType(days != null && days > 0 ? "subscription" : "one_time");
} else {
String bt = price.getBillingType().trim().toLowerCase(Locale.ROOT);
if ("sub".equals(bt) || "renew".equals(bt) || "auto".equals(bt)) {
bt = "subscription";
}
if ("once".equals(bt) || "onetime".equals(bt) || "iap".equals(bt)) {
bt = "one_time";
}
price.setBillingType("subscription".equals(bt) ? "subscription" : "one_time");
}
if (price.getChannelProductCodes() == null) {
price.setChannelProductCodes(new LinkedHashMap<>());
}
}
return this.save(price, MembershipPrice::setId);
}
}

View File

@ -0,0 +1,70 @@
#!/bin/bash
# Hot-patch wyrs-admin jar: MembershipPrice.packageName + package filter.
# Does NOT touch JWT secrets / env files.
set -euo pipefail
ROOT=/tmp/price-patch
JAR=/opt/wyrs-admin/remote-control-1.0.0.jar
STAMP=$(date +%Y%m%d%H%M%S)
BACKUP="${JAR}.bak.pricepkg.${STAMP}"
WORK="$ROOT/work"
OUT="$ROOT/classes-out"
SRC="$ROOT/compile-src"
rm -rf "$WORK" "$OUT" "$SRC"
mkdir -p "$WORK" "$OUT" "$SRC/com/wyrs/admin/entity" \
"$SRC/com/wyrs/admin/service/config" \
"$SRC/com/wyrs/admin/util" \
"$SRC/com/wyrs/admin/web"
cp "$ROOT/upload/MembershipPrice.java" "$SRC/com/wyrs/admin/entity/"
cp "$ROOT/upload/PricesConfigService.java" "$SRC/com/wyrs/admin/service/config/"
cp "$ROOT/upload/PackageNameContext.java" "$SRC/com/wyrs/admin/util/"
cp "$ROOT/upload/PackageNameRequestFilter.java" "$SRC/com/wyrs/admin/web/"
cd "$WORK"
jar xf "$JAR"
# classpath
CP="BOOT-INF/classes"
while IFS= read -r j; do CP="$CP:$j"; done < <(find BOOT-INF/lib -name '*.jar' | sort)
echo "Compiling..."
javac --release 17 -encoding UTF-8 -cp "$CP" -d "$OUT" \
"$SRC/com/wyrs/admin/entity/MembershipPrice.java" \
"$SRC/com/wyrs/admin/util/PackageNameContext.java" \
"$SRC/com/wyrs/admin/web/PackageNameRequestFilter.java" \
"$SRC/com/wyrs/admin/service/config/PricesConfigService.java"
echo "Injecting classes into jar..."
cp -a "$JAR" "$BACKUP"
# remove old builder nested class if present; our builder is inner static
rm -f BOOT-INF/classes/com/wyrs/admin/entity/MembershipPrice\$MembershipPriceBuilder.class
mkdir -p BOOT-INF/classes/com/wyrs/admin/entity \
BOOT-INF/classes/com/wyrs/admin/service/config \
BOOT-INF/classes/com/wyrs/admin/util \
BOOT-INF/classes/com/wyrs/admin/web
cp -a "$OUT/com/wyrs/admin/entity/"*.class BOOT-INF/classes/com/wyrs/admin/entity/
cp -a "$OUT/com/wyrs/admin/service/config/"*.class BOOT-INF/classes/com/wyrs/admin/service/config/
cp -a "$OUT/com/wyrs/admin/util/"*.class BOOT-INF/classes/com/wyrs/admin/util/
cp -a "$OUT/com/wyrs/admin/web/"*.class BOOT-INF/classes/com/wyrs/admin/web/
jar uf "$JAR" \
BOOT-INF/classes/com/wyrs/admin/entity/MembershipPrice.class \
BOOT-INF/classes/com/wyrs/admin/entity/MembershipPrice\$MembershipPriceBuilder.class \
BOOT-INF/classes/com/wyrs/admin/service/config/PricesConfigService.class \
BOOT-INF/classes/com/wyrs/admin/service/config/PricesConfigService\$1.class \
BOOT-INF/classes/com/wyrs/admin/util/PackageNameContext.class \
BOOT-INF/classes/com/wyrs/admin/web/PackageNameRequestFilter.class
# PricesConfigService may produce $1 for TypeReference anon
if [ -f BOOT-INF/classes/com/wyrs/admin/service/config/PricesConfigService\$1.class ]; then
jar uf "$JAR" BOOT-INF/classes/com/wyrs/admin/service/config/PricesConfigService\$1.class
fi
echo "Restarting wyrs-admin (JWT env untouched)..."
systemctl restart wyrs-admin
sleep 8
systemctl is-active wyrs-admin
journalctl -u wyrs-admin -n 40 --no-pager | tail -40
echo "BACKUP=$BACKUP"
echo "DONE"

View File

@ -0,0 +1,22 @@
#!/usr/bin/env python3
from pathlib import Path
p = Path("/opt/spark-server/internal/transport/http/auth.go")
t = p.read_text()
bad = "strings.HasPrefix(path, /api/admin/)"
good = 'strings.HasPrefix(path, "/api/admin/")'
if bad in t:
t = t.replace(bad, good)
print("fixed broken quote")
t = t.replace("if adminPath(path) {\n\t\t\treturn true\n\t\t}", 'if strings.HasPrefix(path, "/api/admin/") {\n\t\t\treturn true\n\t\t}')
# remove AppID assignment if present
lines = []
for line in t.splitlines(True):
if "rc.AppID = principal.AppID" in line:
continue
lines.append(line)
t = "".join(lines)
p.write_text(t)
assert "adminPath(" not in t
assert "rc.AppID" not in t
assert '"/v1/subscriptions/plans"' in t
print("auth.go ready")

View File

@ -0,0 +1,29 @@
{
"items": [
{
"id": 1,
"appCode": "spark",
"packageName": "",
"name": "默认月卡",
"price": 9.9,
"originalPrice": 19.9,
"durationDays": 30,
"billingType": "subscription",
"channelProductCodes": {},
"enabled": true
},
{
"id": 2,
"appCode": "spark",
"packageName": "com.jspp.pop",
"name": "Pop专属月卡",
"price": 6.9,
"originalPrice": 12.9,
"durationDays": 30,
"billingType": "subscription",
"channelProductCodes": {},
"enabled": true
}
],
"nextId": 3
}

View File

@ -77,7 +77,6 @@
- Spark Messenger 全页面接入检查 - Spark Messenger 全页面接入检查
## 14:48 — 修复 make lint / make test ## 14:48 — 修复 make lint / make test
### 概要 ### 概要
@ -130,6 +129,14 @@ make install-git-hooks # 本机只需一次
- 表增加 `auth_type` / `contact`;Admin list 支持 `?auth_type=login|guest`;popadmin App反馈页加筛选下拉 - 表增加 `auth_type` / `contact`;Admin list 支持 `?auth_type=login|guest`;popadmin App反馈页加筛选下拉
- OpenAPI 同步说明 - OpenAPI 同步说明
## 14:53 — Android 白噪音对照后补音乐公开 API
### 概要
- 对照 `WhiteNoiseJSPPOldAndroid`:音乐主路径仍走欢休 `app.huanxiu.vip`;IM/登录/支付多为旧 JSPP TCP protobuf。
- 在 Spark V3 风格下补齐迁端缺口:单曲详情、分区嵌套列表、`requireMember` 筛选;公开路由免登录。
- 顺手修复先前好友拒绝用例测试(Accept 双返回值、测试邮箱)。
### 变更范围 ### 变更范围
| 类型 | 说明 | | 类型 | 说明 |
@ -178,6 +185,35 @@ make install-git-hooks # 本机只需一次
- 用户上传路径:凭证 size + 发帖声明宽高/体积软检测(默认可日志,可拒收)。 - 用户上传路径:凭证 size + 发帖声明宽高/体积软检测(默认可日志,可拒收)。
- 机器人配图:入库对象存储前压到最长边 ≤1080;视频路径 ≤720(ffmpeg)。 - 机器人配图:入库对象存储前压到最长边 ≤1080;视频路径 ≤720(ffmpeg)。
| 功能 | 音乐公开读 API 增强 |
| 文档 | OpenAPI music 路径与 schema |
| 修复 | friend Reject 单测 |
### 主要文件
- `internal/module/music/entity/track.go` — GroupedPlaylist / PlaylistGroup
- `internal/module/music/logic/service.go` — GetPublic、PlaylistGrouped、PlaylistQuery
- `internal/transport/http/handler/music/handler.go`、`router.go`、`auth.go`
- `internal/transport/http/docs/openapi.yaml`
- `internal/module/music/logic/service_test.go`、`friend/logic/friend_test.go`
### 验证
- `go test ./...`:通过(本机无 `make`;`golangci-lint` 与 Go 1.27 export data 不兼容,未跑 lint)
- `music/logic` coverage:74.7%
### 关联
- 用户:对照 Android 补全 spark-server 缺失 API(不 commit/push)
## 15:20 — 多广场实例 + 分类筛选
### 概要
- 同一套广场能力按 `channel` 隔离数据:`square` / `squarenovel` / `squaresteps` / `squarehealth`。
- 各广场支持分类 Tab:推荐/最新/热门/附近(排序)+ 搞笑/美女(帖标签);附近为 lat/lng 曼哈顿距离 stub。
- 发帖/点赞/评论/通知水位均带 channel;保留原 `/v1/square/...` 路径。
### 变更范围 ### 变更范围
| 类型 | 说明 | | 类型 | 说明 |
@ -238,3 +274,25 @@ make install-git-hooks # 本机只需一次
### 关联 ### 关联
- 任务:发现页顶部接入同开发者应用列表(stash 合并进 main) - 任务:发现页顶部接入同开发者应用列表(stash 合并进 main)
| 功能 | 多广场 + Feed `category` 筛选 |
| 迁移 | `0031_square_channel_category.sql` |
| 文档 | OpenAPI `{square_channel}` 路径;meta 枚举 |
### 主要文件
- `migrations/0031_square_channel_category.sql` — posts/notices/notice_state 加 channel/category
- `internal/module/square/entity/channel.go` — Channel / Category 枚举
- `internal/module/square/logic/*`、`model/*` — channel 隔离与 Feed 排序
- `internal/transport/http/handler/square`、`router.go` — 四前缀同路由 + `/categories`
- `internal/transport/http/docs/openapi.yaml`、`meta`、`i18n`
### 验证
- `go run ./cmd/migrate -config configs/config.yaml`:通过
- `go test ./...`:通过(本机无 `make`;golangci-lint 与 Go 1.27 不兼容)
- `square/logic` coverage:77.5%
### 关联
- 用户:spark-server 多广场 + 分类筛选(不 commit/push)

View File

@ -44,9 +44,11 @@ type Feedback struct {
// SubscriptionPlan is one week/month/quarter/year card. // SubscriptionPlan is one week/month/quarter/year card.
// IAP product_id placeholders: health.wyrs365.com.vip.{week|month|quarter|year} // IAP product_id placeholders: health.wyrs365.com.vip.{week|month|quarter|year}
// (ASC App 6815533078 · Team 49S9UL5MN4 · Bundle health.wyrs365.com). // (ASC App 6815533078 · Team 49S9UL5MN4 · Bundle health.wyrs365.com).
// PackageName 为空表示默认兜底;客户端传 packageName / X-Wyrs-Package 时优先精确匹配。
type SubscriptionPlan struct { type SubscriptionPlan struct {
PlanID string `json:"plan_id"` PlanID string `json:"plan_id"`
Lang string `json:"lang"` Lang string `json:"lang"`
PackageName string `json:"package_name,omitempty"`
Title string `json:"title"` Title string `json:"title"`
Subtitle string `json:"subtitle"` Subtitle string `json:"subtitle"`
PriceDisplay string `json:"price_display"` PriceDisplay string `json:"price_display"`

View File

@ -22,8 +22,8 @@ type Repository interface {
InsertFeedback(ctx context.Context, f *entity.Feedback) error InsertFeedback(ctx context.Context, f *entity.Feedback) error
ListFeedback(ctx context.Context, page, size int, authType string) ([]entity.Feedback, int64, error) ListFeedback(ctx context.Context, page, size int, authType string) ([]entity.Feedback, int64, error)
UpdateFeedbackStatus(ctx context.Context, id int64, status string) error UpdateFeedbackStatus(ctx context.Context, id int64, status string) error
ListPlans(ctx context.Context, lang string) ([]entity.SubscriptionPlan, error) ListPlans(ctx context.Context, lang, packageName string) ([]entity.SubscriptionPlan, error)
ListPlansAdmin(ctx context.Context, lang string) ([]entity.SubscriptionPlan, error) ListPlansAdmin(ctx context.Context, lang, packageName string) ([]entity.SubscriptionPlan, error)
UpsertPlan(ctx context.Context, p *entity.SubscriptionPlan) error UpsertPlan(ctx context.Context, p *entity.SubscriptionPlan) error
GetPasswordHash(ctx context.Context, userID int64) (string, error) GetPasswordHash(ctx context.Context, userID int64) (string, error)
SetPasswordHash(ctx context.Context, userID int64, hash string) error SetPasswordHash(ctx context.Context, userID int64, hash string) error
@ -183,16 +183,17 @@ func (s *Service) UpdateFeedbackStatus(ctx context.Context, id int64, status str
return s.repo.UpdateFeedbackStatus(ctx, id, status) return s.repo.UpdateFeedbackStatus(ctx, id, status)
} }
func (s *Service) SubscriptionPlans(ctx context.Context) (*entity.SubscriptionConfig, error) { func (s *Service) SubscriptionPlans(ctx context.Context, packageName string) (*entity.SubscriptionConfig, error) {
lang := normalizeLang(reqctx.Language(ctx)) lang := normalizeLang(reqctx.Language(ctx))
packageName = strings.TrimSpace(packageName)
headline, sub := subscriptionCopy(lang) headline, sub := subscriptionCopy(lang)
if s.repo != nil { if s.repo != nil {
plans, err := s.repo.ListPlans(ctx, lang) plans, err := s.repo.ListPlans(ctx, lang, packageName)
if err == nil && len(plans) > 0 { if err == nil && len(plans) > 0 {
return &entity.SubscriptionConfig{Headline: headline, Subheadline: sub, Plans: plans}, nil return &entity.SubscriptionConfig{Headline: headline, Subheadline: sub, Plans: plans}, nil
} }
if lang != string(i18n.ZhCN) { if lang != string(i18n.ZhCN) {
plans, err = s.repo.ListPlans(ctx, string(i18n.ZhCN)) plans, err = s.repo.ListPlans(ctx, string(i18n.ZhCN), packageName)
if err == nil && len(plans) > 0 { if err == nil && len(plans) > 0 {
return &entity.SubscriptionConfig{Headline: headline, Subheadline: sub, Plans: plans}, nil return &entity.SubscriptionConfig{Headline: headline, Subheadline: sub, Plans: plans}, nil
} }
@ -201,11 +202,11 @@ func (s *Service) SubscriptionPlans(ctx context.Context) (*entity.SubscriptionCo
return &entity.SubscriptionConfig{Headline: headline, Subheadline: sub, Plans: defaultPlans(lang)}, nil return &entity.SubscriptionConfig{Headline: headline, Subheadline: sub, Plans: defaultPlans(lang)}, nil
} }
func (s *Service) ListPlansAdmin(ctx context.Context, lang string) ([]entity.SubscriptionPlan, error) { func (s *Service) ListPlansAdmin(ctx context.Context, lang, packageName string) ([]entity.SubscriptionPlan, error) {
if s.repo == nil { if s.repo == nil {
return defaultPlans(normalizeLang(lang)), nil return defaultPlans(normalizeLang(lang)), nil
} }
return s.repo.ListPlansAdmin(ctx, strings.TrimSpace(lang)) return s.repo.ListPlansAdmin(ctx, strings.TrimSpace(lang), strings.TrimSpace(packageName))
} }
func (s *Service) UpsertPlanAdmin(ctx context.Context, p *entity.SubscriptionPlan) error { func (s *Service) UpsertPlanAdmin(ctx context.Context, p *entity.SubscriptionPlan) error {
@ -217,6 +218,7 @@ func (s *Service) UpsertPlanAdmin(ctx context.Context, p *entity.SubscriptionPla
} }
p.Lang = normalizeLang(p.Lang) p.Lang = normalizeLang(p.Lang)
p.PlanID = strings.TrimSpace(p.PlanID) p.PlanID = strings.TrimSpace(p.PlanID)
p.PackageName = strings.TrimSpace(p.PackageName)
switch p.PlanID { switch p.PlanID {
case "week", "month", "quarter", "year": case "week", "month", "quarter", "year":
default: default:

View File

@ -71,7 +71,7 @@ func (s *stubClientRepo) UpdateFeedbackStatus(_ context.Context, id int64, statu
return errs.ErrNotFound return errs.ErrNotFound
} }
func (s *stubClientRepo) ListPlans(_ context.Context, lang string) ([]entity.SubscriptionPlan, error) { func (s *stubClientRepo) ListPlans(_ context.Context, lang, packageName string) ([]entity.SubscriptionPlan, error) {
out := make([]entity.SubscriptionPlan, 0) out := make([]entity.SubscriptionPlan, 0)
for i := range s.plans { for i := range s.plans {
p := &s.plans[i] p := &s.plans[i]
@ -79,19 +79,35 @@ func (s *stubClientRepo) ListPlans(_ context.Context, lang string) ([]entity.Sub
out = append(out, *p) out = append(out, *p)
} }
} }
if packageName == "" {
return out, nil return out, nil
} }
exact := make([]entity.SubscriptionPlan, 0)
func (s *stubClientRepo) ListPlansAdmin(_ context.Context, lang string) ([]entity.SubscriptionPlan, error) { fallback := make([]entity.SubscriptionPlan, 0)
if lang == "" { for _, p := range out {
return s.plans, nil if p.PackageName == packageName {
exact = append(exact, p)
} else if p.PackageName == "" {
fallback = append(fallback, p)
} }
}
if len(exact) > 0 {
return exact, nil
}
return fallback, nil
}
func (s *stubClientRepo) ListPlansAdmin(_ context.Context, lang, packageName string) ([]entity.SubscriptionPlan, error) {
out := make([]entity.SubscriptionPlan, 0) out := make([]entity.SubscriptionPlan, 0)
for i := range s.plans { for i := range s.plans {
p := &s.plans[i] p := &s.plans[i]
if p.Lang == lang { if lang != "" && p.Lang != lang {
out = append(out, *p) continue
} }
if packageName != "" && p.PackageName != packageName {
continue
}
out = append(out, *p)
} }
return out, nil return out, nil
} }
@ -167,7 +183,7 @@ func TestLegalPlansFeedbackFallback(t *testing.T) {
if err != nil || legal.Privacy.Title == "" || legal.Terms.Title == "" { if err != nil || legal.Privacy.Title == "" || legal.Terms.Title == "" {
t.Fatalf("legal %+v err=%v", legal, err) t.Fatalf("legal %+v err=%v", legal, err)
} }
plans, err := svc.SubscriptionPlans(t.Context()) plans, err := svc.SubscriptionPlans(t.Context(), "")
if err != nil || len(plans.Plans) != 4 { if err != nil || len(plans.Plans) != 4 {
t.Fatalf("plans %+v err=%v", plans, err) t.Fatalf("plans %+v err=%v", plans, err)
} }
@ -231,7 +247,7 @@ func TestClientServiceAdminAndRepo(t *testing.T) {
if err := svc.UpdateFeedbackStatus(ctx, 1, "closed"); err != nil { if err := svc.UpdateFeedbackStatus(ctx, 1, "closed"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
adminPlans, err := svc.ListPlansAdmin(ctx, string(i18n.ZhCN)) adminPlans, err := svc.ListPlansAdmin(ctx, string(i18n.ZhCN), "")
if err != nil || len(adminPlans) != 1 { if err != nil || len(adminPlans) != 1 {
t.Fatalf("plans %+v %v", adminPlans, err) t.Fatalf("plans %+v %v", adminPlans, err)
} }
@ -315,7 +331,7 @@ func TestDeveloperAppsAdmin(t *testing.T) {
if err != nil || len(apps.Items) != 0 { if err != nil || len(apps.Items) != 0 {
t.Fatalf("exclude package %+v %v", apps, err) t.Fatalf("exclude package %+v %v", apps, err)
} }
cfg, err := svc.SubscriptionPlans(ctxLang(string(i18n.ZhTW))) cfg, err := svc.SubscriptionPlans(ctxLang(string(i18n.ZhTW)), "")
if err != nil || len(cfg.Plans) == 0 { if err != nil || len(cfg.Plans) == 0 {
t.Fatalf("plans %+v %v", cfg, err) t.Fatalf("plans %+v %v", cfg, err)
} }

View File

@ -3,6 +3,7 @@ package model
import ( import (
"context" "context"
"errors" "errors"
"strings"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
@ -50,6 +51,7 @@ func (feedbackRow) TableName() string { return "t_feedback" }
type planRow struct { type planRow struct {
PlanID string `gorm:"column:plan_id;primaryKey"` PlanID string `gorm:"column:plan_id;primaryKey"`
Lang string `gorm:"column:lang;primaryKey"` Lang string `gorm:"column:lang;primaryKey"`
PackageName string `gorm:"column:package_name;primaryKey"`
Title string `gorm:"column:title"` Title string `gorm:"column:title"`
Subtitle string `gorm:"column:subtitle"` Subtitle string `gorm:"column:subtitle"`
PriceDisplay string `gorm:"column:price_display"` PriceDisplay string `gorm:"column:price_display"`
@ -170,7 +172,7 @@ func (r *Repo) UpdateFeedbackStatus(ctx context.Context, id int64, status string
return r.db.WithContext(ctx).Model(&feedbackRow{}).Where("id = ?", id).Update("status", status).Error return r.db.WithContext(ctx).Model(&feedbackRow{}).Where("id = ?", id).Update("status", status).Error
} }
func (r *Repo) ListPlans(ctx context.Context, lang string) ([]entity.SubscriptionPlan, error) { func (r *Repo) ListPlans(ctx context.Context, lang, packageName string) ([]entity.SubscriptionPlan, error) {
var rows []planRow var rows []planRow
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Where("lang = ? AND enabled = 1", lang). Where("lang = ? AND enabled = 1", lang).
@ -179,14 +181,17 @@ func (r *Repo) ListPlans(ctx context.Context, lang string) ([]entity.Subscriptio
if err != nil { if err != nil {
return nil, err return nil, err
} }
return mapPlans(rows), nil return filterPlansByPackage(mapPlans(rows), packageName), nil
} }
func (r *Repo) ListPlansAdmin(ctx context.Context, lang string) ([]entity.SubscriptionPlan, error) { func (r *Repo) ListPlansAdmin(ctx context.Context, lang, packageName string) ([]entity.SubscriptionPlan, error) {
q := r.db.WithContext(ctx).Order("lang ASC, sort_order ASC, plan_id ASC") q := r.db.WithContext(ctx).Order("package_name ASC, lang ASC, sort_order ASC, plan_id ASC")
if lang != "" { if lang != "" {
q = q.Where("lang = ?", lang) q = q.Where("lang = ?", lang)
} }
if packageName != "" {
q = q.Where("package_name = ?", packageName)
}
var rows []planRow var rows []planRow
if err := q.Find(&rows).Error; err != nil { if err := q.Find(&rows).Error; err != nil {
return nil, err return nil, err
@ -199,7 +204,8 @@ func mapPlans(rows []planRow) []entity.SubscriptionPlan {
for i := range rows { for i := range rows {
row := &rows[i] row := &rows[i]
out = append(out, entity.SubscriptionPlan{ out = append(out, entity.SubscriptionPlan{
PlanID: row.PlanID, Lang: row.Lang, Title: row.Title, Subtitle: row.Subtitle, PlanID: row.PlanID, Lang: row.Lang, PackageName: row.PackageName,
Title: row.Title, Subtitle: row.Subtitle,
PriceDisplay: row.PriceDisplay, PriceCents: row.PriceCents, Currency: row.Currency, PriceDisplay: row.PriceDisplay, PriceCents: row.PriceCents, Currency: row.Currency,
PeriodDays: row.PeriodDays, Badge: row.Badge, SortOrder: row.SortOrder, PeriodDays: row.PeriodDays, Badge: row.Badge, SortOrder: row.SortOrder,
Enabled: row.Enabled, ProductID: row.ProductID, Enabled: row.Enabled, ProductID: row.ProductID,
@ -208,12 +214,46 @@ func mapPlans(rows []planRow) []entity.SubscriptionPlan {
return out return out
} }
// filterPlansByPackage:有精确包名则用之,否则回退 package_name 为空的默认档。
func filterPlansByPackage(items []entity.SubscriptionPlan, packageName string) []entity.SubscriptionPlan {
packageName = strings.TrimSpace(packageName)
if packageName == "" {
// 无包名:仅返回默认兜底,避免多包混在一起
out := make([]entity.SubscriptionPlan, 0, len(items))
for _, p := range items {
if strings.TrimSpace(p.PackageName) == "" {
out = append(out, p)
}
}
if len(out) > 0 {
return out
}
return items
}
want := strings.ToLower(packageName)
exact := make([]entity.SubscriptionPlan, 0)
fallback := make([]entity.SubscriptionPlan, 0)
for _, p := range items {
pkg := strings.TrimSpace(p.PackageName)
if pkg != "" && strings.ToLower(pkg) == want {
exact = append(exact, p)
} else if pkg == "" {
fallback = append(fallback, p)
}
}
if len(exact) > 0 {
return exact
}
return fallback
}
func (r *Repo) UpsertPlan(ctx context.Context, p *entity.SubscriptionPlan) error { func (r *Repo) UpsertPlan(ctx context.Context, p *entity.SubscriptionPlan) error {
if p == nil { if p == nil {
return errors.New("client: nil subscription plan") return errors.New("client: nil subscription plan")
} }
row := planRow{ row := planRow{
PlanID: p.PlanID, Lang: p.Lang, Title: p.Title, Subtitle: p.Subtitle, PlanID: p.PlanID, Lang: p.Lang, PackageName: strings.TrimSpace(p.PackageName),
Title: p.Title, Subtitle: p.Subtitle,
PriceDisplay: p.PriceDisplay, PriceCents: p.PriceCents, Currency: p.Currency, PriceDisplay: p.PriceDisplay, PriceCents: p.PriceCents, Currency: p.Currency,
PeriodDays: p.PeriodDays, Badge: p.Badge, SortOrder: p.SortOrder, PeriodDays: p.PeriodDays, Badge: p.Badge, SortOrder: p.SortOrder,
Enabled: p.Enabled, ProductID: p.ProductID, UpdatedAt: time.Now(), Enabled: p.Enabled, ProductID: p.ProductID, UpdatedAt: time.Now(),

View File

@ -37,4 +37,6 @@ type Catalog struct {
MomentVisibleDays []Item `json:"moment_visible_days"` MomentVisibleDays []Item `json:"moment_visible_days"`
VerificationPurpose []Item `json:"verification_purpose"` VerificationPurpose []Item `json:"verification_purpose"`
PhoneCarrier []Item `json:"phone_carrier"` PhoneCarrier []Item `json:"phone_carrier"`
SquareChannel []Item `json:"square_channel"`
SquareCategory []Item `json:"square_category"`
} }

View File

@ -38,6 +38,8 @@ func Catalog(lang i18n.Lang) *entity.Catalog {
MomentVisibleDays: momentVisibleDays(lang), MomentVisibleDays: momentVisibleDays(lang),
VerificationPurpose: verificationPurpose(lang), VerificationPurpose: verificationPurpose(lang),
PhoneCarrier: phoneCarrier(lang), PhoneCarrier: phoneCarrier(lang),
SquareChannel: squareChannel(lang),
SquareCategory: squareCategory(lang),
} }
} }

View File

@ -0,0 +1,25 @@
package logic
import (
"spark-server/internal/module/meta/entity"
squareentity "spark-server/internal/module/square/entity"
"spark-server/internal/pkg/i18n"
)
func squareChannel(lang i18n.Lang) []entity.Item {
chs := squareentity.Channels()
vals := make([]string, 0, len(chs))
for _, c := range chs {
vals = append(vals, string(c))
}
return strSet(lang, "square_channel", vals...)
}
func squareCategory(lang i18n.Lang) []entity.Item {
cats := squareentity.Categories()
vals := make([]string, 0, len(cats))
for _, c := range cats {
vals = append(vals, string(c))
}
return strSet(lang, "square_category", vals...)
}

View File

@ -32,6 +32,12 @@ func TestCatalogValuesMatchEntity(t *testing.T) {
if c.MomentKind[0].Value != 1 || c.MomentVisibility[2].Name != "include" { if c.MomentKind[0].Value != 1 || c.MomentVisibility[2].Name != "include" {
t.Fatalf("moment %+v %+v", c.MomentKind, c.MomentVisibility) t.Fatalf("moment %+v %+v", c.MomentKind, c.MomentVisibility)
} }
if len(c.SquareChannel) != 4 || c.SquareChannel[0].Value != "square" {
t.Fatalf("square_channel %+v", c.SquareChannel)
}
if len(c.SquareCategory) != 6 || c.SquareCategory[0].Value != "recommend" {
t.Fatalf("square_category %+v", c.SquareCategory)
}
if len(c.DeviceType) != 6 || c.DeviceType[0].Value != 1 || c.DeviceType[0].Name != "ios" { if len(c.DeviceType) != 6 || c.DeviceType[0].Value != 1 || c.DeviceType[0].Name != "ios" {
t.Fatalf("device_type %+v", c.DeviceType) t.Fatalf("device_type %+v", c.DeviceType)
} }
@ -44,7 +50,7 @@ func TestCatalogValuesMatchEntity(t *testing.T) {
c.RelationKind, c.FriendRequestStatus, c.FriendSource, c.ReportReason, c.GroupStatus, c.GroupRole, c.RelationKind, c.FriendRequestStatus, c.FriendSource, c.ReportReason, c.GroupStatus, c.GroupRole,
c.MailboxEventType, c.TCPCommand, c.EnvelopeCode, c.DeviceType, c.Language, c.UploadPurpose, c.SystemKind, c.MailboxEventType, c.TCPCommand, c.EnvelopeCode, c.DeviceType, c.Language, c.UploadPurpose, c.SystemKind,
c.MomentKind, c.MomentVisibility, c.MomentStatus, c.MomentNoticeKind, c.MomentVisibleDays, c.MomentKind, c.MomentVisibility, c.MomentStatus, c.MomentNoticeKind, c.MomentVisibleDays,
c.AppID, c.AppID, c.SquareChannel, c.SquareCategory,
} { } {
if len(items) == 0 { if len(items) == 0 {
t.Fatal("empty set") t.Fatal("empty set")

View File

@ -52,3 +52,18 @@ type Playlist struct {
Categories []Category `json:"categories"` Categories []Category `json:"categories"`
MemberPreviewSeconds int `json:"memberPreviewSeconds"` MemberPreviewSeconds int `json:"memberPreviewSeconds"`
} }
// PlaylistGroup is one category (zone) with its enabled tracks.
// Maps to HuanXiu-style zoneAndZoneMusicList entries for Spark clients.
type PlaylistGroup struct {
CategoryID int64 `json:"categoryId"`
Name string `json:"name"`
ParentID *int64 `json:"parentId"`
Items []Track `json:"items"`
}
// GroupedPlaylist is categories-with-tracks for home tab migration.
type GroupedPlaylist struct {
Groups []PlaylistGroup `json:"groups"`
MemberPreviewSeconds int `json:"memberPreviewSeconds"`
}

View File

@ -106,7 +106,13 @@ func (s *Service) ListAdmin(ctx context.Context) ([]entity.Track, error) {
return s.repo.ListAll(ctx) return s.repo.ListAll(ctx)
} }
func (s *Service) Playlist(ctx context.Context, categoryID int64) (*entity.Playlist, error) { // PlaylistQuery filters the public music list.
type PlaylistQuery struct {
CategoryID int64
RequireMember *bool // nil=all; true=仅会员曲;false=仅免费曲
}
func (s *Service) Playlist(ctx context.Context, q PlaylistQuery) (*entity.Playlist, error) {
if !s.Ready() { if !s.Ready() {
return nil, ErrNotReady return nil, ErrNotReady
} }
@ -114,14 +120,15 @@ func (s *Service) Playlist(ctx context.Context, categoryID int64) (*entity.Playl
items []entity.Track items []entity.Track
err error err error
) )
if categoryID > 0 { if q.CategoryID > 0 {
items, err = s.repo.ListEnabledByCategory(ctx, categoryID) items, err = s.repo.ListEnabledByCategory(ctx, q.CategoryID)
} else { } else {
items, err = s.repo.ListEnabled(ctx) items, err = s.repo.ListEnabled(ctx)
} }
if err != nil { if err != nil {
return nil, err return nil, err
} }
items = filterRequireMember(items, q.RequireMember)
sec, err := s.repo.MemberPreviewSeconds(ctx) sec, err := s.repo.MemberPreviewSeconds(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
@ -133,6 +140,115 @@ func (s *Service) Playlist(ctx context.Context, categoryID int64) (*entity.Playl
return &entity.Playlist{Items: items, Categories: tree, MemberPreviewSeconds: sec}, nil return &entity.Playlist{Items: items, Categories: tree, MemberPreviewSeconds: sec}, nil
} }
// GetPublic returns one enabled track for clients (maps to HuanXiu /music/v1/detail).
func (s *Service) GetPublic(ctx context.Context, id int64) (*entity.Track, error) {
if !s.Ready() {
return nil, ErrNotReady
}
if id <= 0 {
return nil, errs.ErrInvalidArgument.WithMsg("id 无效")
}
t, err := s.repo.Get(ctx, id)
if err != nil {
return nil, errs.ErrNotFound.WithMsg("曲目不存在")
}
if !t.Enabled {
return nil, errs.ErrNotFound.WithMsg("曲目不存在")
}
return t, nil
}
// PlaylistGrouped returns leaf categories each with their tracks
// (maps to HuanXiu zoneAndZoneMusicList for home tab).
// parentID>0 limits to that category itself (if leaf) or its children.
func (s *Service) PlaylistGrouped(ctx context.Context, parentID int64) (*entity.GroupedPlaylist, error) {
if !s.Ready() {
return nil, ErrNotReady
}
if parentID < 0 {
return nil, errs.ErrInvalidArgument.WithMsg("parentId 无效")
}
tree, err := s.CategoryTree(ctx, true)
if err != nil {
return nil, err
}
leaves := collectLeafCategories(tree, parentID)
sec, err := s.repo.MemberPreviewSeconds(ctx)
if err != nil {
return nil, err
}
groups := make([]entity.PlaylistGroup, 0, len(leaves))
for _, cat := range leaves {
items, err := s.repo.ListEnabledByCategory(ctx, cat.ID)
if err != nil {
return nil, err
}
if items == nil {
items = []entity.Track{}
}
groups = append(groups, entity.PlaylistGroup{
CategoryID: cat.ID,
Name: cat.Name,
ParentID: cat.ParentID,
Items: items,
})
}
return &entity.GroupedPlaylist{Groups: groups, MemberPreviewSeconds: sec}, nil
}
func filterRequireMember(items []entity.Track, requireMember *bool) []entity.Track {
if requireMember == nil {
return items
}
out := make([]entity.Track, 0, len(items))
want := *requireMember
for _, t := range items {
if t.RequireMember == want {
out = append(out, t)
}
}
return out
}
// collectLeafCategories walks the enabled tree.
// parentID==0 → all leaves; parentID>0 → that node if leaf, else its child leaves.
func collectLeafCategories(tree []entity.Category, parentID int64) []entity.Category {
if parentID > 0 {
if cat, ok := findCategory(tree, parentID); ok {
if len(cat.Children) == 0 {
return []entity.Category{cat}
}
return leafCategories(cat.Children)
}
return nil
}
return leafCategories(tree)
}
func findCategory(tree []entity.Category, id int64) (entity.Category, bool) {
for _, c := range tree {
if c.ID == id {
return c, true
}
if found, ok := findCategory(c.Children, id); ok {
return found, true
}
}
return entity.Category{}, false
}
func leafCategories(tree []entity.Category) []entity.Category {
var out []entity.Category
for _, c := range tree {
if len(c.Children) == 0 {
out = append(out, c)
continue
}
out = append(out, leafCategories(c.Children)...)
}
return out
}
func (s *Service) Settings(ctx context.Context) (*entity.Settings, error) { func (s *Service) Settings(ctx context.Context) (*entity.Settings, error) {
if !s.Ready() { if !s.Ready() {
return nil, ErrNotReady return nil, ErrNotReady

View File

@ -42,8 +42,24 @@ func (s *stubMusicRepo) ListEnabled(_ context.Context) ([]entity.Track, error) {
return out, nil return out, nil
} }
func (s *stubMusicRepo) ListEnabledByCategory(_ context.Context, _ int64) ([]entity.Track, error) { func (s *stubMusicRepo) ListEnabledByCategory(_ context.Context, categoryID int64) ([]entity.Track, error) {
return s.ListEnabled(context.Background()) out := make([]entity.Track, 0)
for _, t := range s.tracks {
if !t.Enabled {
continue
}
ids := s.trackCategories[t.ID]
if ids == nil {
ids = t.CategoryIDs
}
for _, id := range ids {
if id == categoryID {
out = append(out, *t)
break
}
}
}
return out, nil
} }
func (s *stubMusicRepo) Get(_ context.Context, id int64) (*entity.Track, error) { func (s *stubMusicRepo) Get(_ context.Context, id int64) (*entity.Track, error) {
@ -182,7 +198,7 @@ func newTestService() (*Service, *stubMusicRepo) {
func TestServicePlaylistAndSettings(t *testing.T) { func TestServicePlaylistAndSettings(t *testing.T) {
svc, repo := newTestService() svc, repo := newTestService()
_ = repo.Insert(context.Background(), &entity.Track{Title: "a", Enabled: true}) _ = repo.Insert(context.Background(), &entity.Track{Title: "a", Enabled: true})
pl, err := svc.Playlist(context.Background(), 0) pl, err := svc.Playlist(context.Background(), PlaylistQuery{})
if err != nil || len(pl.Items) != 1 || pl.MemberPreviewSeconds != 30 { if err != nil || len(pl.Items) != 1 || pl.MemberPreviewSeconds != 30 {
t.Fatalf("playlist %+v %v", pl, err) t.Fatalf("playlist %+v %v", pl, err)
} }
@ -196,6 +212,73 @@ func TestServicePlaylistAndSettings(t *testing.T) {
} }
} }
func TestServiceGetPublicAndRequireMember(t *testing.T) {
svc, repo := newTestService()
ctx := context.Background()
_ = repo.Insert(ctx, &entity.Track{Title: "free", Enabled: true, RequireMember: false})
_ = repo.Insert(ctx, &entity.Track{Title: "vip", Enabled: true, RequireMember: true})
_ = repo.Insert(ctx, &entity.Track{Title: "off", Enabled: false})
got, err := svc.GetPublic(ctx, 1)
if err != nil || got.Title != "free" {
t.Fatalf("get %+v %v", got, err)
}
if _, err := svc.GetPublic(ctx, 3); err == nil {
t.Fatal("want disabled not found")
}
if _, err := svc.GetPublic(ctx, 0); err == nil {
t.Fatal("want invalid id")
}
wantVIP := true
vipOnly, err := svc.Playlist(ctx, PlaylistQuery{RequireMember: &wantVIP})
if err != nil || len(vipOnly.Items) != 1 || !vipOnly.Items[0].RequireMember {
t.Fatalf("vip filter %+v %v", vipOnly, err)
}
wantFree := false
freeOnly, err := svc.Playlist(ctx, PlaylistQuery{RequireMember: &wantFree})
if err != nil || len(freeOnly.Items) != 1 || freeOnly.Items[0].RequireMember {
t.Fatalf("free filter %+v %v", freeOnly, err)
}
}
func TestServicePlaylistGrouped(t *testing.T) {
svc, _ := newTestService()
ctx := context.Background()
root, err := svc.CreateCategory(ctx, CategoryInput{Name: "Home"})
if err != nil {
t.Fatal(err)
}
pid := root.ID
rain, err := svc.CreateCategory(ctx, CategoryInput{Name: "Rain", ParentID: &pid})
if err != nil {
t.Fatal(err)
}
wind, err := svc.CreateCategory(ctx, CategoryInput{Name: "Wind", ParentID: &pid})
if err != nil {
t.Fatal(err)
}
if _, err := svc.Create(ctx, UpsertInput{Title: "soft rain", CategoryIDs: []int64{rain.ID}}); err != nil {
t.Fatal(err)
}
if _, err := svc.Create(ctx, UpsertInput{Title: "gentle wind", CategoryIDs: []int64{wind.ID}}); err != nil {
t.Fatal(err)
}
all, err := svc.PlaylistGrouped(ctx, 0)
if err != nil || len(all.Groups) != 2 {
t.Fatalf("grouped all %+v %v", all, err)
}
underHome, err := svc.PlaylistGrouped(ctx, root.ID)
if err != nil || len(underHome.Groups) != 2 {
t.Fatalf("under home %+v %v", underHome, err)
}
one, err := svc.PlaylistGrouped(ctx, rain.ID)
if err != nil || len(one.Groups) != 1 || one.Groups[0].CategoryID != rain.ID || len(one.Groups[0].Items) != 1 {
t.Fatalf("leaf group %+v %v", one, err)
}
}
func TestServiceCRUD(t *testing.T) { func TestServiceCRUD(t *testing.T) {
svc, _ := newTestService() svc, _ := newTestService()
ctx := context.Background() ctx := context.Background()
@ -308,7 +391,7 @@ func TestNewServicePublicURLDefaults(t *testing.T) {
func TestServiceNotReady(t *testing.T) { func TestServiceNotReady(t *testing.T) {
var svc *Service var svc *Service
if _, err := svc.Playlist(context.Background(), 0); !errors.Is(err, ErrNotReady) { if _, err := svc.Playlist(context.Background(), PlaylistQuery{}); !errors.Is(err, ErrNotReady) {
t.Fatalf("%v", err) t.Fatalf("%v", err)
} }
} }

View File

@ -0,0 +1,104 @@
package entity
// Channel is a square plaza instance (path prefix / data isolation).
type Channel string
const (
ChannelSquare Channel = "square"
ChannelSquareNovel Channel = "squarenovel"
ChannelSquareSteps Channel = "squaresteps"
ChannelSquareHealth Channel = "squarehealth"
)
// Channels is the closed set of plaza instances.
func Channels() []Channel {
return []Channel{ChannelSquare, ChannelSquareNovel, ChannelSquareSteps, ChannelSquareHealth}
}
func (c Channel) Valid() bool {
switch c {
case ChannelSquare, ChannelSquareNovel, ChannelSquareSteps, ChannelSquareHealth:
return true
default:
return false
}
}
func ParseChannel(s string) (Channel, bool) {
c := Channel(s)
if !c.Valid() {
return "", false
}
return c, true
}
// Category is a feed tab / post tag under every channel.
// Feed modes (recommend/latest/hot/nearby) only affect list sort; tags are stored on posts.
type Category string
const (
CategoryRecommend Category = "recommend"
CategoryLatest Category = "latest"
CategoryHot Category = "hot"
CategoryNearby Category = "nearby"
CategoryFunny Category = "funny"
CategoryBeauty Category = "beauty"
)
// Categories lists every filter tab (feed modes + tags).
func Categories() []Category {
return []Category{
CategoryRecommend, CategoryLatest, CategoryHot, CategoryNearby,
CategoryFunny, CategoryBeauty,
}
}
// PublishCategories are tags that may be written on a post.
func PublishCategories() []Category {
return []Category{CategoryFunny, CategoryBeauty}
}
func (c Category) Valid() bool {
switch c {
case CategoryRecommend, CategoryLatest, CategoryHot, CategoryNearby,
CategoryFunny, CategoryBeauty, "":
return true
default:
return false
}
}
func (c Category) IsFeedMode() bool {
switch c {
case CategoryRecommend, CategoryLatest, CategoryHot, CategoryNearby, "":
return true
default:
return false
}
}
func (c Category) IsTag() bool {
switch c {
case CategoryFunny, CategoryBeauty:
return true
default:
return false
}
}
func ParseCategory(s string) (Category, bool) {
c := Category(s)
if !c.Valid() {
return "", false
}
return c, true
}
// FeedListQuery drives public feed filtering and sort in the repository.
type FeedListQuery struct {
Cursor int64
Limit int
Category Category
Lat float64
Lng float64
}

View File

@ -4,6 +4,8 @@ package entity
type Post struct { type Post struct {
ID int64 ID int64
UserID int64 UserID int64
Channel Channel
Category Category
Kind Kind Kind Kind
Text string Text string
Content []byte Content []byte
@ -65,6 +67,7 @@ type Comment struct {
type Notice struct { type Notice struct {
ID int64 ID int64
ToUserID int64 ToUserID int64
Channel Channel
FromUserID int64 FromUserID int64
Kind NoticeKind Kind NoticeKind
PostID int64 PostID int64
@ -73,13 +76,17 @@ type Notice struct {
CreatedAtMS int64 CreatedAtMS int64
} }
// NoticeState is per-user read watermark for square notices. // NoticeState is per-user per-channel read watermark for square notices.
type NoticeState struct { type NoticeState struct {
UserID int64 UserID int64
Channel Channel
LastReadNoticeID int64 LastReadNoticeID int64
UpdatedAtMS int64 UpdatedAtMS int64
} }
func DefaultNoticeState(userID int64) *NoticeState { func DefaultNoticeState(userID int64, channel Channel) *NoticeState {
return &NoticeState{UserID: userID} if channel == "" {
channel = ChannelSquare
}
return &NoticeState{UserID: userID, Channel: channel}
} }

View File

@ -8,13 +8,17 @@ import (
) )
// UserPosts lists posts by owner visible to viewer (profile square tab). // UserPosts lists posts by owner visible to viewer (profile square tab).
func (s *Service) UserPosts(ctx context.Context, viewerID, ownerID, cursor int64, limit int) (*ListResult, error) { func (s *Service) UserPosts(ctx context.Context, viewerID, ownerID int64, channel entity.Channel, cursor int64, limit int) (*ListResult, error) {
if !s.ready() || viewerID <= 0 { if !s.ready() || viewerID <= 0 {
return nil, errs.ErrUnauthorized return nil, errs.ErrUnauthorized
} }
if ownerID <= 0 { if ownerID <= 0 {
return nil, errs.ErrInvalidArgument return nil, errs.ErrInvalidArgument
} }
channel = normalizeChannel(channel)
if !channel.Valid() {
return nil, errs.ErrInvalidArgument
}
if ownerID != viewerID { if ownerID != viewerID {
blocked, err := s.friends.BlockedEither(ctx, viewerID, ownerID) blocked, err := s.friends.BlockedEither(ctx, viewerID, ownerID)
if err != nil { if err != nil {
@ -28,7 +32,7 @@ func (s *Service) UserPosts(ctx context.Context, viewerID, ownerID, cursor int64
picked := make([]*entity.Post, 0, limit) picked := make([]*entity.Post, 0, limit)
scan := cursor scan := cursor
for range entity.OverfetchRounds { for range entity.OverfetchRounds {
batch, err := s.repo.ListByAuthor(ctx, ownerID, scan, entity.Overfetch) batch, err := s.repo.ListByAuthor(ctx, channel, ownerID, scan, entity.Overfetch)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@ -34,7 +34,8 @@ func (s *Service) oneView(viewer int64, p *entity.Post, likes []*entity.Like, co
likeCards, liked, likeN := filterLikes(viewer, likes, users, remarks, full) likeCards, liked, likeN := filterLikes(viewer, likes, users, remarks, full)
commentViews, commentN := filterComments(comments, users, remarks, full) commentViews, commentN := filterComments(comments, users, remarks, full)
return &View{ return &View{
ID: p.ID, User: cardOf(users[p.UserID], remarks[p.UserID]), Kind: p.Kind, Text: p.Text, ID: p.ID, Channel: normalizeChannel(p.Channel), Category: p.Category,
User: cardOf(users[p.UserID], remarks[p.UserID]), Kind: p.Kind, Text: p.Text,
Content: contentJSON(p.Content), POI: poiPtr(p.POI), Visibility: p.Visibility, Content: contentJSON(p.Content), POI: poiPtr(p.POI), Visibility: p.Visibility,
LikeCount: likeN, CommentCount: commentN, LikeCount: likeN, CommentCount: commentN,
Liked: liked, Likes: likeCards, Comments: commentViews, CreatedAt: p.CreatedAtMS, Liked: liked, Likes: likeCards, Comments: commentViews, CreatedAt: p.CreatedAtMS,

View File

@ -18,8 +18,8 @@ type CommentInput struct {
ReplyToID int64 `json:"reply_to_id" validate:"omitempty,gt=0" label:"field.square_reply"` ReplyToID int64 `json:"reply_to_id" validate:"omitempty,gt=0" label:"field.square_reply"`
} }
func (s *Service) Comment(ctx context.Context, userID, postID int64, in *CommentInput) (*View, error) { func (s *Service) Comment(ctx context.Context, userID, postID int64, channel entity.Channel, in *CommentInput) (*View, error) {
post, err := s.visiblePost(ctx, userID, postID) post, err := s.visiblePost(ctx, userID, postID, channel)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -56,11 +56,11 @@ func (s *Service) Comment(ctx context.Context, userID, postID int64, in *Comment
return nil, err return nil, err
} }
_ = s.notify(ctx, commentNotices(post, row, userID)) _ = s.notify(ctx, commentNotices(post, row, userID))
return s.Get(ctx, userID, postID) return s.Get(ctx, userID, postID, channel)
} }
func (s *Service) DeleteComment(ctx context.Context, userID, postID, commentID int64) (*View, error) { func (s *Service) DeleteComment(ctx context.Context, userID, postID, commentID int64, channel entity.Channel) (*View, error) {
post, err := s.visiblePost(ctx, userID, postID) post, err := s.visiblePost(ctx, userID, postID, channel)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -77,7 +77,7 @@ func (s *Service) DeleteComment(ctx context.Context, userID, postID, commentID i
if err := s.repo.SoftDeleteComment(ctx, commentID); err != nil { if err := s.repo.SoftDeleteComment(ctx, commentID); err != nil {
return nil, err return nil, err
} }
return s.Get(ctx, userID, postID) return s.Get(ctx, userID, postID, channel)
} }
func (s *Service) loadParent(ctx context.Context, postID, replyToID int64) (*entity.Comment, error) { func (s *Service) loadParent(ctx context.Context, postID, replyToID int64) (*entity.Comment, error) {
@ -97,6 +97,10 @@ func (s *Service) loadParent(ctx context.Context, postID, replyToID int64) (*ent
func commentNotices(post *entity.Post, c *entity.Comment, from int64) []*entity.Notice { func commentNotices(post *entity.Post, c *entity.Comment, from int64) []*entity.Notice {
seen := map[int64]struct{}{} seen := map[int64]struct{}{}
out := make([]*entity.Notice, 0, 2) out := make([]*entity.Notice, 0, 2)
ch := post.Channel
if ch == "" {
ch = entity.ChannelSquare
}
add := func(to int64, kind entity.NoticeKind) { add := func(to int64, kind entity.NoticeKind) {
if to <= 0 || to == from { if to <= 0 || to == from {
return return
@ -106,7 +110,7 @@ func commentNotices(post *entity.Post, c *entity.Comment, from int64) []*entity.
} }
seen[to] = struct{}{} seen[to] = struct{}{}
out = append(out, &entity.Notice{ out = append(out, &entity.Notice{
ToUserID: to, FromUserID: from, Kind: kind, PostID: post.ID, CommentID: c.ID, ToUserID: to, Channel: ch, FromUserID: from, Kind: kind, PostID: post.ID, CommentID: c.ID,
}) })
} }
if c.ReplyToUID > 0 { if c.ReplyToUID > 0 {

View File

@ -7,18 +7,26 @@ import (
"spark-server/internal/pkg/errs" "spark-server/internal/pkg/errs"
) )
func (s *Service) Delete(ctx context.Context, userID, postID int64) error { func (s *Service) Delete(ctx context.Context, userID, postID int64, channel entity.Channel) error {
if !s.ready() || userID <= 0 { if !s.ready() || userID <= 0 {
return errs.ErrUnauthorized return errs.ErrUnauthorized
} }
if postID <= 0 { if postID <= 0 {
return errs.ErrInvalidArgument return errs.ErrInvalidArgument
} }
post, err := s.repo.SoftDeletePost(ctx, userID, postID) channel = normalizeChannel(channel)
post, err := s.repo.GetPost(ctx, postID)
if err != nil { if err != nil {
return err return err
} }
s.deleteMedia(ctx, post) if normalizeChannel(post.Channel) != channel || post.UserID != userID {
return errs.ErrNotFound
}
deleted, err := s.repo.SoftDeletePost(ctx, userID, postID)
if err != nil {
return err
}
s.deleteMedia(ctx, deleted)
return nil return nil
} }

View File

@ -7,15 +7,33 @@ import (
"spark-server/internal/pkg/errs" "spark-server/internal/pkg/errs"
) )
func (s *Service) Feed(ctx context.Context, viewerID, cursor int64, limit int) (*ListResult, error) { // FeedQuery is list filter/sort for a plaza channel.
type FeedQuery struct {
Cursor int64
Limit int
Category entity.Category
Lat float64
Lng float64
}
func (s *Service) Feed(ctx context.Context, viewerID int64, channel entity.Channel, q FeedQuery) (*ListResult, error) {
if !s.ready() || viewerID <= 0 { if !s.ready() || viewerID <= 0 {
return nil, errs.ErrUnauthorized return nil, errs.ErrUnauthorized
} }
limit = entity.ClampLimit(limit) channel = normalizeChannel(channel)
if !channel.Valid() {
return nil, errs.ErrInvalidArgument
}
if !q.Category.Valid() {
return nil, errs.ErrInvalidArgument
}
limit := entity.ClampLimit(q.Limit)
picked := make([]*entity.Post, 0, limit) picked := make([]*entity.Post, 0, limit)
scan := cursor scan := q.Cursor
for range entity.OverfetchRounds { for range entity.OverfetchRounds {
batch, err := s.repo.ListPublicFeed(ctx, scan, entity.Overfetch) batch, err := s.repo.ListPublicFeed(ctx, channel, entity.FeedListQuery{
Cursor: scan, Limit: entity.Overfetch, Category: q.Category, Lat: q.Lat, Lng: q.Lng,
})
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -55,3 +73,23 @@ func (s *Service) listResult(ctx context.Context, viewer int64, posts []*entity.
hasMore := len(posts) >= limit && limit > 0 hasMore := len(posts) >= limit && limit > 0
return &ListResult{Items: views, NextCursor: next, HasMore: hasMore}, nil return &ListResult{Items: views, NextCursor: next, HasMore: hasMore}, nil
} }
// CategoryItem is one filter tab for clients.
type CategoryItem struct {
Value entity.Category `json:"value"`
Kind string `json:"kind"` // feed | tag
LabelKey string `json:"-"`
}
// Categories returns filter tabs (feed modes + tags) shared by every channel.
func (s *Service) Categories() []CategoryItem {
out := make([]CategoryItem, 0, len(entity.Categories()))
for _, c := range entity.Categories() {
kind := "feed"
if c.IsTag() {
kind = "tag"
}
out = append(out, CategoryItem{Value: c, Kind: kind})
}
return out
}

View File

@ -9,8 +9,8 @@ import (
"spark-server/internal/pkg/errs" "spark-server/internal/pkg/errs"
) )
func (s *Service) Like(ctx context.Context, userID, postID int64) (*View, error) { func (s *Service) Like(ctx context.Context, userID, postID int64, channel entity.Channel) (*View, error) {
post, err := s.visiblePost(ctx, userID, postID) post, err := s.visiblePost(ctx, userID, postID, channel)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -22,32 +22,36 @@ func (s *Service) Like(ctx context.Context, userID, postID int64) (*View, error)
return nil, err return nil, err
} }
if created { if created {
_ = s.notify(ctx, []*entity.Notice{s.newNotice(post.UserID, userID, entity.NoticeLike, postID, 0)}) _ = s.notify(ctx, []*entity.Notice{s.newNotice(post.UserID, userID, entity.NoticeLike, post.Channel, postID, 0)})
} }
return s.Get(ctx, userID, postID) return s.Get(ctx, userID, postID, channel)
} }
func (s *Service) Unlike(ctx context.Context, userID, postID int64) (*View, error) { func (s *Service) Unlike(ctx context.Context, userID, postID int64, channel entity.Channel) (*View, error) {
if _, err := s.visiblePost(ctx, userID, postID); err != nil { if _, err := s.visiblePost(ctx, userID, postID, channel); err != nil {
return nil, err return nil, err
} }
if err := s.repo.RemoveLike(ctx, postID, userID); err != nil && !errors.Is(err, errs.ErrNotFound) { if err := s.repo.RemoveLike(ctx, postID, userID); err != nil && !errors.Is(err, errs.ErrNotFound) {
return nil, err return nil, err
} }
return s.Get(ctx, userID, postID) return s.Get(ctx, userID, postID, channel)
} }
func (s *Service) visiblePost(ctx context.Context, viewerID, postID int64) (*entity.Post, error) { func (s *Service) visiblePost(ctx context.Context, viewerID, postID int64, channel entity.Channel) (*entity.Post, error) {
if !s.ready() || viewerID <= 0 { if !s.ready() || viewerID <= 0 {
return nil, errs.ErrUnauthorized return nil, errs.ErrUnauthorized
} }
if postID <= 0 { if postID <= 0 {
return nil, errs.ErrInvalidArgument return nil, errs.ErrInvalidArgument
} }
channel = normalizeChannel(channel)
post, err := s.repo.GetPost(ctx, postID) post, err := s.repo.GetPost(ctx, postID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if normalizeChannel(post.Channel) != channel {
return nil, errs.ErrNotFound
}
vis, err := s.visFor(ctx, viewerID, []*entity.Post{post}) vis, err := s.visFor(ctx, viewerID, []*entity.Post{post})
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -32,12 +32,16 @@ type NoticeReadInput struct {
Cursor int64 `json:"cursor" validate:"omitempty,gte=0" label:"field.square_notice_cursor"` Cursor int64 `json:"cursor" validate:"omitempty,gte=0" label:"field.square_notice_cursor"`
} }
func (s *Service) ListNotices(ctx context.Context, userID, cursor int64, limit int) (*NoticeList, error) { func (s *Service) ListNotices(ctx context.Context, userID int64, channel entity.Channel, cursor int64, limit int) (*NoticeList, error) {
if !s.ready() || userID <= 0 { if !s.ready() || userID <= 0 {
return nil, errs.ErrUnauthorized return nil, errs.ErrUnauthorized
} }
channel = normalizeChannel(channel)
if !channel.Valid() {
return nil, errs.ErrInvalidArgument
}
limit = entity.ClampLimit(limit) limit = entity.ClampLimit(limit)
rows, err := s.repo.ListNotices(ctx, userID, cursor, limit) rows, err := s.repo.ListNotices(ctx, userID, channel, cursor, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -59,39 +63,47 @@ func (s *Service) ListNotices(ctx context.Context, userID, cursor int64, limit i
return &NoticeList{Items: items, NextCursor: next}, nil return &NoticeList{Items: items, NextCursor: next}, nil
} }
func (s *Service) ReadNotices(ctx context.Context, userID int64, in *NoticeReadInput) error { func (s *Service) ReadNotices(ctx context.Context, userID int64, channel entity.Channel, in *NoticeReadInput) error {
if !s.ready() || userID <= 0 { if !s.ready() || userID <= 0 {
return errs.ErrUnauthorized return errs.ErrUnauthorized
} }
channel = normalizeChannel(channel)
if !channel.Valid() {
return errs.ErrInvalidArgument
}
cursor := int64(0) cursor := int64(0)
if in != nil { if in != nil {
cursor = in.Cursor cursor = in.Cursor
} }
if err := s.repo.MarkNoticesRead(ctx, userID, cursor); err != nil { if err := s.repo.MarkNoticesRead(ctx, userID, channel, cursor); err != nil {
return err return err
} }
if cursor > 0 { if cursor > 0 {
return s.repo.AdvanceNoticeRead(ctx, userID, cursor) return s.repo.AdvanceNoticeRead(ctx, userID, channel, cursor)
} }
latest, err := s.repo.LatestNoticeID(ctx, userID) latest, err := s.repo.LatestNoticeID(ctx, userID, channel)
if err != nil { if err != nil {
return err return err
} }
if latest > 0 { if latest > 0 {
return s.repo.AdvanceNoticeRead(ctx, userID, latest) return s.repo.AdvanceNoticeRead(ctx, userID, channel, latest)
} }
return nil return nil
} }
func (s *Service) NoticeUnread(ctx context.Context, userID int64) (*NoticeUnread, error) { func (s *Service) NoticeUnread(ctx context.Context, userID int64, channel entity.Channel) (*NoticeUnread, error) {
if !s.ready() || userID <= 0 { if !s.ready() || userID <= 0 {
return nil, errs.ErrUnauthorized return nil, errs.ErrUnauthorized
} }
st, err := s.repo.GetNoticeState(ctx, userID) channel = normalizeChannel(channel)
if !channel.Valid() {
return nil, errs.ErrInvalidArgument
}
st, err := s.repo.GetNoticeState(ctx, userID, channel)
if err != nil { if err != nil {
return nil, err return nil, err
} }
latest, err := s.repo.LatestNoticeID(ctx, userID) latest, err := s.repo.LatestNoticeID(ctx, userID, channel)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -111,14 +123,20 @@ func (s *Service) notify(ctx context.Context, rows []*entity.Notice) error {
} }
n.ID = id n.ID = id
n.CreatedAtMS = now n.CreatedAtMS = now
if n.Channel == "" {
n.Channel = entity.ChannelSquare
}
out = append(out, n) out = append(out, n)
} }
return s.repo.CreateNotices(ctx, out) return s.repo.CreateNotices(ctx, out)
} }
func (s *Service) newNotice(to, from int64, kind entity.NoticeKind, postID, commentID int64) *entity.Notice { func (s *Service) newNotice(to, from int64, kind entity.NoticeKind, channel entity.Channel, postID, commentID int64) *entity.Notice {
if to <= 0 || to == from { if to <= 0 || to == from {
return nil return nil
} }
return &entity.Notice{ToUserID: to, FromUserID: from, Kind: kind, PostID: postID, CommentID: commentID} if channel == "" {
channel = entity.ChannelSquare
}
return &entity.Notice{ToUserID: to, Channel: channel, FromUserID: from, Kind: kind, PostID: postID, CommentID: commentID}
} }

View File

@ -21,18 +21,26 @@ type PublishInput struct {
Visibility entity.Visibility `json:"visibility" validate:"omitempty,oneof=0 1 2 3" label:"field.square_visibility" errmsg:"oneof:validate.square.visibility_invalid"` Visibility entity.Visibility `json:"visibility" validate:"omitempty,oneof=0 1 2 3" label:"field.square_visibility" errmsg:"oneof:validate.square.visibility_invalid"`
AudienceUIDs []int64 `json:"audience_uids" validate:"omitempty,max=200,dive,gt=0" label:"field.square_audience"` AudienceUIDs []int64 `json:"audience_uids" validate:"omitempty,max=200,dive,gt=0" label:"field.square_audience"`
POI *entity.POI `json:"poi"` POI *entity.POI `json:"poi"`
Category entity.Category `json:"category" validate:"omitempty,oneof=funny beauty" label:"field.square_category" errmsg:"oneof:validate.square.category_invalid"`
} }
func (s *Service) Publish(ctx context.Context, userID int64, in *PublishInput) (*View, error) { func (s *Service) Publish(ctx context.Context, userID int64, channel entity.Channel, in *PublishInput) (*View, error) {
if !s.ready() || userID <= 0 { if !s.ready() || userID <= 0 {
return nil, errs.ErrUnauthorized return nil, errs.ErrUnauthorized
} }
channel = normalizeChannel(channel)
if !channel.Valid() {
return nil, errs.ErrInvalidArgument
}
if in == nil { if in == nil {
return nil, errs.ErrInvalidArgument return nil, errs.ErrInvalidArgument
} }
if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil { if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil {
return nil, err return nil, err
} }
if in.Category != "" && !in.Category.IsTag() {
return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeySquareCategoryInvalid, nil))
}
if !s.allowPublish(ctx, userID) { if !s.allowPublish(ctx, userID) {
return nil, errs.ErrRateLimited return nil, errs.ErrRateLimited
} }
@ -60,7 +68,8 @@ func (s *Service) Publish(ctx context.Context, userID int64, in *PublishInput) (
vis = entity.VisibilityPublic vis = entity.VisibilityPublic
} }
post := &entity.Post{ post := &entity.Post{
ID: id, UserID: userID, Kind: kind, Text: strings.TrimSpace(in.Text), Content: raw, ID: id, UserID: userID, Channel: channel, Category: in.Category,
Kind: kind, Text: strings.TrimSpace(in.Text), Content: raw,
Visibility: vis, AuditStatus: entity.AuditApproved, Status: entity.StatusNormal, Visibility: vis, AuditStatus: entity.AuditApproved, Status: entity.StatusNormal,
CreatedAtMS: now.UnixMilli(), CreatedAtMS: now.UnixMilli(),
} }

View File

@ -16,8 +16,8 @@ type Repository interface {
CreatePost(ctx context.Context, post *entity.Post, audience []int64) error CreatePost(ctx context.Context, post *entity.Post, audience []int64) error
CreateBotPost(ctx context.Context, post *entity.Post) error CreateBotPost(ctx context.Context, post *entity.Post) error
GetPost(ctx context.Context, id int64) (*entity.Post, error) GetPost(ctx context.Context, id int64) (*entity.Post, error)
ListPublicFeed(ctx context.Context, cursor int64, limit int) ([]*entity.Post, error) ListPublicFeed(ctx context.Context, channel entity.Channel, q entity.FeedListQuery) ([]*entity.Post, error)
ListByAuthor(ctx context.Context, authorID, cursor int64, limit int) ([]*entity.Post, error) ListByAuthor(ctx context.Context, channel entity.Channel, authorID, cursor int64, limit int) ([]*entity.Post, error)
SoftDeletePost(ctx context.Context, userID, postID int64) (*entity.Post, error) SoftDeletePost(ctx context.Context, userID, postID int64) (*entity.Post, error)
SoftDeleteExpiredBotPosts(ctx context.Context, nowMS int64, limit int) ([]*entity.Post, error) SoftDeleteExpiredBotPosts(ctx context.Context, nowMS int64, limit int) ([]*entity.Post, error)
ListAudience(ctx context.Context, postIDs []int64) (map[int64][]int64, error) ListAudience(ctx context.Context, postIDs []int64) (map[int64][]int64, error)
@ -29,11 +29,11 @@ type Repository interface {
AddComment(ctx context.Context, c *entity.Comment) error AddComment(ctx context.Context, c *entity.Comment) error
SoftDeleteComment(ctx context.Context, id int64) error SoftDeleteComment(ctx context.Context, id int64) error
CreateNotices(ctx context.Context, rows []*entity.Notice) error CreateNotices(ctx context.Context, rows []*entity.Notice) error
ListNotices(ctx context.Context, userID, cursor int64, limit int) ([]*entity.Notice, error) ListNotices(ctx context.Context, userID int64, channel entity.Channel, cursor int64, limit int) ([]*entity.Notice, error)
MarkNoticesRead(ctx context.Context, userID, cursor int64) error MarkNoticesRead(ctx context.Context, userID int64, channel entity.Channel, cursor int64) error
GetNoticeState(ctx context.Context, userID int64) (*entity.NoticeState, error) GetNoticeState(ctx context.Context, userID int64, channel entity.Channel) (*entity.NoticeState, error)
LatestNoticeID(ctx context.Context, userID int64) (int64, error) LatestNoticeID(ctx context.Context, userID int64, channel entity.Channel) (int64, error)
AdvanceNoticeRead(ctx context.Context, userID, noticeID int64) error AdvanceNoticeRead(ctx context.Context, userID int64, channel entity.Channel, noticeID int64) error
ListAllByAuthor(ctx context.Context, authorID int64) ([]*entity.Post, error) ListAllByAuthor(ctx context.Context, authorID int64) ([]*entity.Post, error)
PurgeUser(ctx context.Context, userID int64) error PurgeUser(ctx context.Context, userID int64) error
} }
@ -91,3 +91,10 @@ func (s *Service) ConfigureMedia(p media.Policy, log *zap.Logger) {
func (s *Service) ready() bool { func (s *Service) ready() bool {
return s != nil && s.repo != nil && s.friends != nil && s.users != nil && s.ids != nil return s != nil && s.repo != nil && s.friends != nil && s.users != nil && s.ids != nil
} }
func normalizeChannel(ch entity.Channel) entity.Channel {
if ch == "" {
return entity.ChannelSquare
}
return ch
}

View File

@ -75,34 +75,35 @@ func TestSquarePublishFeedPrivateAndNotice(t *testing.T) {
becomeFriends(t, friends, a, b) becomeFriends(t, friends, a, b)
ctxA := ctxUser(t, a.ID, "10.0.0.1") ctxA := ctxUser(t, a.ID, "10.0.0.1")
ctxB := ctxUser(t, b.ID, "10.0.0.2") ctxB := ctxUser(t, b.ID, "10.0.0.2")
pub, err := svc.Publish(ctxA, a.ID, &PublishInput{Text: "hello square"}) ch := entity.ChannelSquare
if err != nil || pub.Text != "hello square" { pub, err := svc.Publish(ctxA, a.ID, ch, &PublishInput{Text: "hello square"})
if err != nil || pub.Text != "hello square" || pub.Channel != ch {
t.Fatalf("%+v %v", pub, err) t.Fatalf("%+v %v", pub, err)
} }
feed, err := svc.Feed(ctxB, b.ID, 0, 20) feed, err := svc.Feed(ctxB, b.ID, ch, FeedQuery{Limit: 20})
if err != nil || len(feed.Items) != 1 { if err != nil || len(feed.Items) != 1 {
t.Fatalf("feed %+v %v", feed, err) t.Fatalf("feed %+v %v", feed, err)
} }
priv, err := svc.Publish(ctxA, a.ID, &PublishInput{Text: "secret", Visibility: entity.VisibilityPrivate}) priv, err := svc.Publish(ctxA, a.ID, ch, &PublishInput{Text: "secret", Visibility: entity.VisibilityPrivate})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
feed, err = svc.Feed(ctxB, b.ID, 0, 20) feed, err = svc.Feed(ctxB, b.ID, ch, FeedQuery{Limit: 20})
if err != nil || len(feed.Items) != 1 { if err != nil || len(feed.Items) != 1 {
t.Fatalf("private in feed %+v", feed) t.Fatalf("private in feed %+v", feed)
} }
if _, err := svc.Get(ctxB, b.ID, priv.ID); !errors.Is(err, errs.ErrNotFound) { if _, err := svc.Get(ctxB, b.ID, priv.ID, ch); !errors.Is(err, errs.ErrNotFound) {
t.Fatalf("private get %v", err) t.Fatalf("private get %v", err)
} }
liked, err := svc.Like(ctxB, b.ID, pub.ID) liked, err := svc.Like(ctxB, b.ID, pub.ID, ch)
if err != nil || !liked.Liked { if err != nil || !liked.Liked {
t.Fatal(liked, err) t.Fatal(liked, err)
} }
notices, err := svc.ListNotices(ctxA, a.ID, 0, 20) notices, err := svc.ListNotices(ctxA, a.ID, ch, 0, 20)
if err != nil || len(notices.Items) != 1 || notices.Items[0].Kind != entity.NoticeLike { if err != nil || len(notices.Items) != 1 || notices.Items[0].Kind != entity.NoticeLike {
t.Fatalf("notice %+v %v", notices, err) t.Fatalf("notice %+v %v", notices, err)
} }
unread, err := svc.NoticeUnread(ctxA, a.ID) unread, err := svc.NoticeUnread(ctxA, a.ID, ch)
if err != nil || !unread.HasNew { if err != nil || !unread.HasNew {
t.Fatalf("unread %+v", unread) t.Fatalf("unread %+v", unread)
} }
@ -117,16 +118,17 @@ func TestSquareExcludeIncludeAndUserPosts(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
ch := entity.ChannelSquare
ctxA := ctxUser(t, a.ID, "10.0.0.1") ctxA := ctxUser(t, a.ID, "10.0.0.1")
ctxB := ctxUser(t, b.ID, "10.0.0.2") ctxB := ctxUser(t, b.ID, "10.0.0.2")
ctxC := ctxUser(t, c.ID, "10.0.0.3") ctxC := ctxUser(t, c.ID, "10.0.0.3")
excl, err := svc.Publish(ctxA, a.ID, &PublishInput{ excl, err := svc.Publish(ctxA, a.ID, ch, &PublishInput{
Text: "hide from b", Visibility: entity.VisibilityExclude, AudienceUIDs: []int64{b.ID}, Text: "hide from b", Visibility: entity.VisibilityExclude, AudienceUIDs: []int64{b.ID},
}) })
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
feedB, err := svc.Feed(ctxB, b.ID, 0, 20) feedB, err := svc.Feed(ctxB, b.ID, ch, FeedQuery{Limit: 20})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -135,17 +137,17 @@ func TestSquareExcludeIncludeAndUserPosts(t *testing.T) {
t.Fatal("excluded user still sees post in feed") t.Fatal("excluded user still sees post in feed")
} }
} }
feedC, err := svc.Feed(ctxC, c.ID, 0, 20) feedC, err := svc.Feed(ctxC, c.ID, ch, FeedQuery{Limit: 20})
if err != nil || len(feedC.Items) != 1 { if err != nil || len(feedC.Items) != 1 {
t.Fatalf("stranger feed %+v %v", feedC, err) t.Fatalf("stranger feed %+v %v", feedC, err)
} }
incl, err := svc.Publish(ctxA, a.ID, &PublishInput{ incl, err := svc.Publish(ctxA, a.ID, ch, &PublishInput{
Text: "only b", Visibility: entity.VisibilityInclude, AudienceUIDs: []int64{b.ID}, Text: "only b", Visibility: entity.VisibilityInclude, AudienceUIDs: []int64{b.ID},
}) })
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
feedC, err = svc.Feed(ctxC, c.ID, 0, 20) feedC, err = svc.Feed(ctxC, c.ID, ch, FeedQuery{Limit: 20})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -154,7 +156,7 @@ func TestSquareExcludeIncludeAndUserPosts(t *testing.T) {
t.Fatal("include post leaked to stranger feed") t.Fatal("include post leaked to stranger feed")
} }
} }
album, err := svc.UserPosts(ctxB, b.ID, a.ID, 0, 20) album, err := svc.UserPosts(ctxB, b.ID, a.ID, ch, 0, 20)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -170,7 +172,7 @@ func TestSquareExcludeIncludeAndUserPosts(t *testing.T) {
if !sawIncl { if !sawIncl {
t.Fatalf("include missing from album %+v", album) t.Fatalf("include missing from album %+v", album)
} }
if _, err := svc.Get(ctxB, b.ID, incl.ID); err != nil { if _, err := svc.Get(ctxB, b.ID, incl.ID, ch); err != nil {
t.Fatalf("include get %v", err) t.Fatalf("include get %v", err)
} }
} }
@ -179,59 +181,60 @@ func TestSquareUnlikeCommentDeleteAndPurge(t *testing.T) {
svc, friends, users := testSquare(t) svc, friends, users := testSquare(t)
a, b := twoAccounts(t, users) a, b := twoAccounts(t, users)
becomeFriends(t, friends, a, b) becomeFriends(t, friends, a, b)
ch := entity.ChannelSquare
ctxA := ctxUser(t, a.ID, "10.0.0.1") ctxA := ctxUser(t, a.ID, "10.0.0.1")
ctxB := ctxUser(t, b.ID, "10.0.0.2") ctxB := ctxUser(t, b.ID, "10.0.0.2")
post, err := svc.Publish(ctxA, a.ID, &PublishInput{Text: "hi"}) post, err := svc.Publish(ctxA, a.ID, ch, &PublishInput{Text: "hi"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := svc.Like(ctxB, b.ID, post.ID); err != nil { if _, err := svc.Like(ctxB, b.ID, post.ID, ch); err != nil {
t.Fatal(err) t.Fatal(err)
} }
unliked, err := svc.Unlike(ctxB, b.ID, post.ID) unliked, err := svc.Unlike(ctxB, b.ID, post.ID, ch)
if err != nil || unliked.Liked || unliked.LikeCount != 0 { if err != nil || unliked.Liked || unliked.LikeCount != 0 {
t.Fatalf("unlike %+v %v", unliked, err) t.Fatalf("unlike %+v %v", unliked, err)
} }
commented, err := svc.Comment(ctxB, b.ID, post.ID, &CommentInput{Text: "nice"}) commented, err := svc.Comment(ctxB, b.ID, post.ID, ch, &CommentInput{Text: "nice"})
if err != nil || commented.CommentCount != 1 { if err != nil || commented.CommentCount != 1 {
t.Fatalf("comment %+v %v", commented, err) t.Fatalf("comment %+v %v", commented, err)
} }
cid := commented.Comments[0].ID cid := commented.Comments[0].ID
replied, err := svc.Comment(ctxA, a.ID, post.ID, &CommentInput{Text: "thanks", ReplyToID: cid}) replied, err := svc.Comment(ctxA, a.ID, post.ID, ch, &CommentInput{Text: "thanks", ReplyToID: cid})
if err != nil || replied.CommentCount != 2 { if err != nil || replied.CommentCount != 2 {
t.Fatalf("reply %+v %v", replied, err) t.Fatalf("reply %+v %v", replied, err)
} }
if _, err := svc.DeleteComment(ctxB, b.ID, post.ID, cid); err != nil { if _, err := svc.DeleteComment(ctxB, b.ID, post.ID, cid, ch); err != nil {
t.Fatal(err) t.Fatal(err)
} }
got, err := svc.Get(ctxA, a.ID, post.ID) got, err := svc.Get(ctxA, a.ID, post.ID, ch)
if err != nil || got.CommentCount != 1 { if err != nil || got.CommentCount != 1 {
t.Fatalf("deleted comment %+v %v", got, err) t.Fatalf("deleted comment %+v %v", got, err)
} }
if err := svc.Delete(ctxA, a.ID, post.ID); err != nil { if err := svc.Delete(ctxA, a.ID, post.ID, ch); err != nil {
t.Fatal(err) t.Fatal(err)
} }
feed, err := svc.Feed(ctxB, b.ID, 0, 20) feed, err := svc.Feed(ctxB, b.ID, ch, FeedQuery{Limit: 20})
if err != nil || len(feed.Items) != 0 { if err != nil || len(feed.Items) != 0 {
t.Fatalf("deleted feed %+v %v", feed, err) t.Fatalf("deleted feed %+v %v", feed, err)
} }
post2, err := svc.Publish(ctxA, a.ID, &PublishInput{ post2, err := svc.Publish(ctxA, a.ID, ch, &PublishInput{
Images: []*entity.Image{{Key: "perm/square/image/20260922/abcdefghijklmnop.jpg"}}, Images: []*entity.Image{{Key: "perm/square/image/20260922/abcdefghijklmnop.jpg"}},
}) })
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := svc.ReadNotices(ctxA, a.ID, &NoticeReadInput{Cursor: 0}); err != nil { if err := svc.ReadNotices(ctxA, a.ID, ch, &NoticeReadInput{Cursor: 0}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
unread, err := svc.NoticeUnread(ctxA, a.ID) unread, err := svc.NoticeUnread(ctxA, a.ID, ch)
if err != nil || unread.HasNew { if err != nil || unread.HasNew {
t.Fatalf("after read %+v %v", unread, err) t.Fatalf("after read %+v %v", unread, err)
} }
if err := svc.PurgeUser(ctxA, a.ID); err != nil { if err := svc.PurgeUser(ctxA, a.ID); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := svc.Get(ctxA, a.ID, post2.ID); !errors.Is(err, errs.ErrNotFound) { if _, err := svc.Get(ctxA, a.ID, post2.ID, ch); !errors.Is(err, errs.ErrNotFound) {
t.Fatalf("purged get %v", err) t.Fatalf("purged get %v", err)
} }
} }
@ -240,10 +243,49 @@ func TestSquareImageKeyRejectedIfChatPrefix(t *testing.T) {
svc, _, users := testSquare(t) svc, _, users := testSquare(t)
a, _ := twoAccounts(t, users) a, _ := twoAccounts(t, users)
ctx := ctxUser(t, a.ID, "10.0.0.1") ctx := ctxUser(t, a.ID, "10.0.0.1")
_, err := svc.Publish(ctx, a.ID, &PublishInput{ _, err := svc.Publish(ctx, a.ID, entity.ChannelSquare, &PublishInput{
Images: []*entity.Image{{Key: "chat/image/20260918/xxxx.jpg"}}, Images: []*entity.Image{{Key: "chat/image/20260918/xxxx.jpg"}},
}) })
if err == nil { if err == nil {
t.Fatal("accepted chat key") t.Fatal("accepted chat key")
} }
} }
func TestSquareChannelIsolationAndCategory(t *testing.T) {
svc, friends, users := testSquare(t)
a, b := twoAccounts(t, users)
becomeFriends(t, friends, a, b)
ctxA := ctxUser(t, a.ID, "10.0.0.1")
ctxB := ctxUser(t, b.ID, "10.0.0.2")
main, err := svc.Publish(ctxA, a.ID, entity.ChannelSquare, &PublishInput{Text: "main", Category: entity.CategoryFunny})
if err != nil {
t.Fatal(err)
}
novel, err := svc.Publish(ctxA, a.ID, entity.ChannelSquareNovel, &PublishInput{Text: "novel", Category: entity.CategoryBeauty})
if err != nil {
t.Fatal(err)
}
feedMain, err := svc.Feed(ctxB, b.ID, entity.ChannelSquare, FeedQuery{Limit: 20})
if err != nil || len(feedMain.Items) != 1 || feedMain.Items[0].ID != main.ID {
t.Fatalf("main feed %+v %v", feedMain, err)
}
feedNovel, err := svc.Feed(ctxB, b.ID, entity.ChannelSquareNovel, FeedQuery{Limit: 20})
if err != nil || len(feedNovel.Items) != 1 || feedNovel.Items[0].ID != novel.ID {
t.Fatalf("novel feed %+v %v", feedNovel, err)
}
if _, err := svc.Get(ctxB, b.ID, novel.ID, entity.ChannelSquare); !errors.Is(err, errs.ErrNotFound) {
t.Fatalf("cross-channel get %v", err)
}
funny, err := svc.Feed(ctxB, b.ID, entity.ChannelSquare, FeedQuery{Category: entity.CategoryFunny, Limit: 20})
if err != nil || len(funny.Items) != 1 || funny.Items[0].Category != entity.CategoryFunny {
t.Fatalf("funny %+v %v", funny, err)
}
beauty, err := svc.Feed(ctxB, b.ID, entity.ChannelSquare, FeedQuery{Category: entity.CategoryBeauty, Limit: 20})
if err != nil || len(beauty.Items) != 0 {
t.Fatalf("beauty on main %+v %v", beauty, err)
}
cats := svc.Categories()
if len(cats) < 6 {
t.Fatalf("categories %+v", cats)
}
}

View File

@ -29,6 +29,8 @@ type CommentView struct {
type View struct { type View struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Channel entity.Channel `json:"channel"`
Category entity.Category `json:"category,omitempty"`
User UserCard `json:"user"` User UserCard `json:"user"`
Kind entity.Kind `json:"kind"` Kind entity.Kind `json:"kind"`
Text string `json:"text"` Text string `json:"text"`
@ -49,17 +51,21 @@ type ListResult struct {
HasMore bool `json:"has_more"` HasMore bool `json:"has_more"`
} }
func (s *Service) Get(ctx context.Context, viewerID, postID int64) (*View, error) { func (s *Service) Get(ctx context.Context, viewerID, postID int64, channel entity.Channel) (*View, error) {
if !s.ready() || viewerID <= 0 { if !s.ready() || viewerID <= 0 {
return nil, errs.ErrUnauthorized return nil, errs.ErrUnauthorized
} }
if postID <= 0 { if postID <= 0 {
return nil, errs.ErrInvalidArgument return nil, errs.ErrInvalidArgument
} }
channel = normalizeChannel(channel)
post, err := s.repo.GetPost(ctx, postID) post, err := s.repo.GetPost(ctx, postID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if normalizeChannel(post.Channel) != channel {
return nil, errs.ErrNotFound
}
vis, err := s.visFor(ctx, viewerID, []*entity.Post{post}) vis, err := s.visFor(ctx, viewerID, []*entity.Post{post})
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -2,6 +2,7 @@ package model
import ( import (
"context" "context"
"math"
"sort" "sort"
"sync" "sync"
"time" "time"
@ -15,6 +16,11 @@ type likeKey struct {
UserID int64 UserID int64
} }
type noticeStateKey struct {
UserID int64
Channel entity.Channel
}
// MemoryRepo is an in-process square store for tests and empty-DSN bring-up. // MemoryRepo is an in-process square store for tests and empty-DSN bring-up.
type MemoryRepo struct { type MemoryRepo struct {
mu sync.Mutex mu sync.Mutex
@ -23,7 +29,7 @@ type MemoryRepo struct {
likes map[likeKey]*entity.Like likes map[likeKey]*entity.Like
comments map[int64]*entity.Comment comments map[int64]*entity.Comment
notices map[int64]*entity.Notice notices map[int64]*entity.Notice
noticeState map[int64]*entity.NoticeState noticeState map[noticeStateKey]*entity.NoticeState
botLinks map[string]int64 // url -> post_id botLinks map[string]int64 // url -> post_id
} }
@ -34,11 +40,18 @@ func NewMemoryRepo() *MemoryRepo {
likes: make(map[likeKey]*entity.Like), likes: make(map[likeKey]*entity.Like),
comments: make(map[int64]*entity.Comment), comments: make(map[int64]*entity.Comment),
notices: make(map[int64]*entity.Notice), notices: make(map[int64]*entity.Notice),
noticeState: make(map[int64]*entity.NoticeState), noticeState: make(map[noticeStateKey]*entity.NoticeState),
botLinks: make(map[string]int64), botLinks: make(map[string]int64),
} }
} }
func memChannel(ch entity.Channel) entity.Channel {
if ch == "" {
return entity.ChannelSquare
}
return ch
}
func (m *MemoryRepo) GetPost(_ context.Context, id int64) (*entity.Post, error) { func (m *MemoryRepo) GetPost(_ context.Context, id int64) (*entity.Post, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@ -49,14 +62,15 @@ func (m *MemoryRepo) GetPost(_ context.Context, id int64) (*entity.Post, error)
return clonePost(row), nil return clonePost(row), nil
} }
func (m *MemoryRepo) ListByAuthors(_ context.Context, authorIDs []int64, cursor int64, limit int) ([]*entity.Post, error) { func (m *MemoryRepo) ListByAuthors(_ context.Context, channel entity.Channel, authorIDs []int64, cursor int64, limit int) ([]*entity.Post, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
ch := memChannel(channel)
allow := toSet(authorIDs) allow := toSet(authorIDs)
nowMS := time.Now().UnixMilli() nowMS := time.Now().UnixMilli()
out := make([]*entity.Post, 0) out := make([]*entity.Post, 0)
for _, row := range m.posts { for _, row := range m.posts {
if row.Status != entity.StatusNormal || !allow[row.UserID] { if row.Status != entity.StatusNormal || !allow[row.UserID] || memChannel(row.Channel) != ch {
continue continue
} }
if row.Expired(nowMS) { if row.Expired(nowMS) {
@ -74,8 +88,8 @@ func (m *MemoryRepo) ListByAuthors(_ context.Context, authorIDs []int64, cursor
return out, nil return out, nil
} }
func (m *MemoryRepo) ListByAuthor(ctx context.Context, authorID, cursor int64, limit int) ([]*entity.Post, error) { func (m *MemoryRepo) ListByAuthor(ctx context.Context, channel entity.Channel, authorID, cursor int64, limit int) ([]*entity.Post, error) {
return m.ListByAuthors(ctx, []int64{authorID}, cursor, limit) return m.ListByAuthors(ctx, channel, []int64{authorID}, cursor, limit)
} }
func (m *MemoryRepo) ListAudience(_ context.Context, postIDs []int64) (map[int64][]int64, error) { func (m *MemoryRepo) ListAudience(_ context.Context, postIDs []int64) (map[int64][]int64, error) {
@ -137,61 +151,136 @@ func (m *MemoryRepo) GetComment(_ context.Context, id int64) (*entity.Comment, e
return cloneComment(row), nil return cloneComment(row), nil
} }
func (m *MemoryRepo) ListPublicFeed(_ context.Context, cursor int64, limit int) ([]*entity.Post, error) { func (m *MemoryRepo) ListPublicFeed(_ context.Context, channel entity.Channel, q entity.FeedListQuery) ([]*entity.Post, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
nowMS := time.Now().UnixMilli() nowMS := time.Now().UnixMilli()
ch := memChannel(channel)
out := make([]*entity.Post, 0) out := make([]*entity.Post, 0)
for _, row := range m.posts { for _, row := range m.posts {
if row.Status != entity.StatusNormal || row.AuditStatus != entity.AuditApproved { if row.Status != entity.StatusNormal || row.AuditStatus != entity.AuditApproved {
continue continue
} }
if memChannel(row.Channel) != ch {
continue
}
if row.Visibility != entity.VisibilityPublic && row.Visibility != entity.VisibilityExclude { if row.Visibility != entity.VisibilityPublic && row.Visibility != entity.VisibilityExclude {
continue continue
} }
if row.Expired(nowMS) { if row.Expired(nowMS) {
continue continue
} }
if cursor > 0 && row.ID >= cursor { if q.Category.IsTag() && row.Category != q.Category {
continue
}
if q.Category == entity.CategoryNearby && row.POI.Lat == 0 && row.POI.Lng == 0 {
continue continue
} }
out = append(out, clonePost(row)) out = append(out, clonePost(row))
} }
switch q.Category {
case entity.CategoryHot:
sort.Slice(out, func(i, j int) bool {
if out[i].LikeCount == out[j].LikeCount {
return out[i].ID > out[j].ID
}
return out[i].LikeCount > out[j].LikeCount
})
if q.Cursor > 0 {
var lc int
var found bool
for _, p := range out {
if p.ID == q.Cursor {
lc = p.LikeCount
found = true
break
}
}
filtered := make([]*entity.Post, 0, len(out))
for _, p := range out {
if !found {
if p.ID < q.Cursor {
filtered = append(filtered, p)
}
continue
}
if p.LikeCount < lc || (p.LikeCount == lc && p.ID < q.Cursor) {
filtered = append(filtered, p)
}
}
out = filtered
}
case entity.CategoryNearby:
if q.Lat != 0 || q.Lng != 0 {
sort.SliceStable(out, func(i, j int) bool {
di := math.Abs(q.Lat-out[i].POI.Lat) + math.Abs(q.Lng-out[i].POI.Lng)
dj := math.Abs(q.Lat-out[j].POI.Lat) + math.Abs(q.Lng-out[j].POI.Lng)
if di == dj {
return out[i].ID > out[j].ID
}
return di < dj
})
} else {
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID }) sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
if limit > 0 && len(out) > limit { }
out = out[:limit] if q.Cursor > 0 {
filtered := make([]*entity.Post, 0, len(out))
for _, p := range out {
if p.ID < q.Cursor {
filtered = append(filtered, p)
}
}
out = filtered
}
default:
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
if q.Cursor > 0 {
filtered := make([]*entity.Post, 0, len(out))
for _, p := range out {
if p.ID < q.Cursor {
filtered = append(filtered, p)
}
}
out = filtered
}
}
if q.Limit > 0 && len(out) > q.Limit {
out = out[:q.Limit]
} }
return out, nil return out, nil
} }
func (m *MemoryRepo) GetNoticeState(_ context.Context, userID int64) (*entity.NoticeState, error) { func (m *MemoryRepo) GetNoticeState(_ context.Context, userID int64, channel entity.Channel) (*entity.NoticeState, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
if row, ok := m.noticeState[userID]; ok { ch := memChannel(channel)
if row, ok := m.noticeState[noticeStateKey{UserID: userID, Channel: ch}]; ok {
cp := *row cp := *row
return &cp, nil return &cp, nil
} }
return entity.DefaultNoticeState(userID), nil return entity.DefaultNoticeState(userID, ch), nil
} }
func (m *MemoryRepo) LatestNoticeID(_ context.Context, userID int64) (int64, error) { func (m *MemoryRepo) LatestNoticeID(_ context.Context, userID int64, channel entity.Channel) (int64, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
ch := memChannel(channel)
var latest int64 var latest int64
for _, n := range m.notices { for _, n := range m.notices {
if n.ToUserID == userID && n.ID > latest { if n.ToUserID == userID && memChannel(n.Channel) == ch && n.ID > latest {
latest = n.ID latest = n.ID
} }
} }
return latest, nil return latest, nil
} }
func (m *MemoryRepo) ListNotices(_ context.Context, userID, cursor int64, limit int) ([]*entity.Notice, error) { func (m *MemoryRepo) ListNotices(_ context.Context, userID int64, channel entity.Channel, cursor int64, limit int) ([]*entity.Notice, error) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
ch := memChannel(channel)
out := make([]*entity.Notice, 0) out := make([]*entity.Notice, 0)
for _, n := range m.notices { for _, n := range m.notices {
if n.ToUserID != userID { if n.ToUserID != userID || memChannel(n.Channel) != ch {
continue continue
} }
if cursor > 0 && n.ID >= cursor { if cursor > 0 && n.ID >= cursor {
@ -223,6 +312,9 @@ func clonePost(p *entity.Post) *entity.Post {
if p.Content != nil { if p.Content != nil {
cp.Content = append([]byte(nil), p.Content...) cp.Content = append([]byte(nil), p.Content...)
} }
if cp.Channel == "" {
cp.Channel = entity.ChannelSquare
}
return &cp return &cp
} }

View File

@ -78,7 +78,11 @@ func (m *MemoryRepo) PurgeUser(_ context.Context, userID int64) error {
} }
m.audience[postID] = kept m.audience[postID] = kept
} }
delete(m.noticeState, userID) for key := range m.noticeState {
if key.UserID == userID {
delete(m.noticeState, key)
}
}
for id := range owned { for id := range owned {
delete(m.posts, id) delete(m.posts, id)
delete(m.audience, id) delete(m.audience, id)

View File

@ -18,7 +18,11 @@ func (m *MemoryRepo) CreatePost(_ context.Context, post *entity.Post, audience [
if _, ok := m.posts[post.ID]; ok { if _, ok := m.posts[post.ID]; ok {
return errs.ErrDuplicate return errs.ErrDuplicate
} }
m.posts[post.ID] = clonePost(post) cp := clonePost(post)
if cp.Channel == "" {
cp.Channel = entity.ChannelSquare
}
m.posts[post.ID] = cp
if len(audience) > 0 { if len(audience) > 0 {
m.audience[post.ID] = append([]int64(nil), audience...) m.audience[post.ID] = append([]int64(nil), audience...)
} }
@ -167,17 +171,21 @@ func (m *MemoryRepo) CreateNotices(_ context.Context, rows []*entity.Notice) err
return errs.ErrDuplicate return errs.ErrDuplicate
} }
cp := *n cp := *n
if cp.Channel == "" {
cp.Channel = entity.ChannelSquare
}
m.notices[n.ID] = &cp m.notices[n.ID] = &cp
} }
return nil return nil
} }
func (m *MemoryRepo) MarkNoticesRead(_ context.Context, userID, cursor int64) error { func (m *MemoryRepo) MarkNoticesRead(_ context.Context, userID int64, channel entity.Channel, cursor int64) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
ch := memChannel(channel)
now := time.Now().UnixMilli() now := time.Now().UnixMilli()
for _, n := range m.notices { for _, n := range m.notices {
if n.ToUserID != userID || n.ReadAtMS > 0 { if n.ToUserID != userID || memChannel(n.Channel) != ch || n.ReadAtMS > 0 {
continue continue
} }
if cursor > 0 && n.ID > cursor { if cursor > 0 && n.ID > cursor {
@ -188,16 +196,18 @@ func (m *MemoryRepo) MarkNoticesRead(_ context.Context, userID, cursor int64) er
return nil return nil
} }
func (m *MemoryRepo) AdvanceNoticeRead(_ context.Context, userID, noticeID int64) error { func (m *MemoryRepo) AdvanceNoticeRead(_ context.Context, userID int64, channel entity.Channel, noticeID int64) error {
if userID <= 0 || noticeID <= 0 { if userID <= 0 || noticeID <= 0 {
return errs.ErrInvalidArgument return errs.ErrInvalidArgument
} }
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
cur := m.noticeState[userID] ch := memChannel(channel)
key := noticeStateKey{UserID: userID, Channel: ch}
cur := m.noticeState[key]
if cur == nil { if cur == nil {
cur = entity.DefaultNoticeState(userID) cur = entity.DefaultNoticeState(userID, ch)
m.noticeState[userID] = cur m.noticeState[key] = cur
} }
if cur.LastReadNoticeID >= noticeID { if cur.LastReadNoticeID >= noticeID {
return nil return nil

View File

@ -10,6 +10,8 @@ import (
type Post struct { type Post struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement:false;comment:Snowflake 帖 id"` ID int64 `gorm:"column:id;primaryKey;autoIncrement:false;comment:Snowflake 帖 id"`
UserID int64 `gorm:"column:user_id;comment:作者 users.id"` UserID int64 `gorm:"column:user_id;comment:作者 users.id"`
Channel string `gorm:"column:channel;comment:广场实例"`
Category string `gorm:"column:category;comment:内容标签"`
Kind int16 `gorm:"column:kind;comment:1纯文 2图 3视频"` Kind int16 `gorm:"column:kind;comment:1纯文 2图 3视频"`
Text string `gorm:"column:text;comment:正文"` Text string `gorm:"column:text;comment:正文"`
Content []byte `gorm:"column:content;type:varbinary(16384);comment:序列化 JSON 字节"` Content []byte `gorm:"column:content;type:varbinary(16384);comment:序列化 JSON 字节"`
@ -36,8 +38,13 @@ func (r *Post) Entity() *entity.Post {
if r == nil { if r == nil {
return nil return nil
} }
ch := entity.Channel(r.Channel)
if ch == "" {
ch = entity.ChannelSquare
}
p := &entity.Post{ p := &entity.Post{
ID: r.ID, UserID: r.UserID, Kind: entity.Kind(r.Kind), Text: r.Text, Content: r.Content, ID: r.ID, UserID: r.UserID, Channel: ch, Category: entity.Category(r.Category),
Kind: entity.Kind(r.Kind), Text: r.Text, Content: r.Content,
POI: entity.POI{Lat: r.POILat, Lng: r.POILng, Name: r.POIName, Address: r.POIAddress}, POI: entity.POI{Lat: r.POILat, Lng: r.POILng, Name: r.POIName, Address: r.POIAddress},
Visibility: entity.Visibility(r.Visibility), AuditStatus: entity.AuditStatus(r.AuditStatus), Visibility: entity.Visibility(r.Visibility), AuditStatus: entity.AuditStatus(r.AuditStatus),
Status: entity.Status(r.Status), Source: entity.Source(r.Source), SourceURL: r.SourceURL, Status: entity.Status(r.Status), Source: entity.Source(r.Source), SourceURL: r.SourceURL,
@ -53,8 +60,13 @@ func postFrom(p *entity.Post) *Post {
if p == nil { if p == nil {
return nil return nil
} }
ch := string(p.Channel)
if ch == "" {
ch = string(entity.ChannelSquare)
}
row := &Post{ row := &Post{
ID: p.ID, UserID: p.UserID, Kind: int16(p.Kind), Text: p.Text, Content: p.Content, ID: p.ID, UserID: p.UserID, Channel: ch, Category: string(p.Category),
Kind: int16(p.Kind), Text: p.Text, Content: p.Content,
POILat: p.POI.Lat, POILng: p.POI.Lng, POIName: p.POI.Name, POIAddress: p.POI.Address, POILat: p.POI.Lat, POILng: p.POI.Lng, POIName: p.POI.Name, POIAddress: p.POI.Address,
Visibility: int16(p.Visibility), AuditStatus: int16(p.AuditStatus), Status: int16(p.Status), Visibility: int16(p.Visibility), AuditStatus: int16(p.AuditStatus), Status: int16(p.Status),
Source: int16(p.Source), SourceURL: p.SourceURL, LikeCount: p.LikeCount, CommentCount: p.CommentCount, Source: int16(p.Source), SourceURL: p.SourceURL, LikeCount: p.LikeCount, CommentCount: p.CommentCount,
@ -152,6 +164,7 @@ func commentFrom(c *entity.Comment) *Comment {
type Notice struct { type Notice struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement:false;comment:Snowflake 通知 id"` ID int64 `gorm:"column:id;primaryKey;autoIncrement:false;comment:Snowflake 通知 id"`
ToUserID int64 `gorm:"column:to_user_id;comment:接收人"` ToUserID int64 `gorm:"column:to_user_id;comment:接收人"`
Channel string `gorm:"column:channel;comment:广场实例"`
FromUserID int64 `gorm:"column:from_user_id;comment:发起人"` FromUserID int64 `gorm:"column:from_user_id;comment:发起人"`
Kind int16 `gorm:"column:kind;comment:1赞 2评论 3回复"` Kind int16 `gorm:"column:kind;comment:1赞 2评论 3回复"`
PostID int64 `gorm:"column:post_id;comment:帖 id"` PostID int64 `gorm:"column:post_id;comment:帖 id"`
@ -166,8 +179,12 @@ func (r *Notice) Entity() *entity.Notice {
if r == nil { if r == nil {
return nil return nil
} }
ch := entity.Channel(r.Channel)
if ch == "" {
ch = entity.ChannelSquare
}
n := &entity.Notice{ n := &entity.Notice{
ID: r.ID, ToUserID: r.ToUserID, FromUserID: r.FromUserID, Kind: entity.NoticeKind(r.Kind), ID: r.ID, ToUserID: r.ToUserID, Channel: ch, FromUserID: r.FromUserID, Kind: entity.NoticeKind(r.Kind),
PostID: r.PostID, CommentID: r.CommentID, CreatedAtMS: r.CreatedAt.UnixMilli(), PostID: r.PostID, CommentID: r.CommentID, CreatedAtMS: r.CreatedAt.UnixMilli(),
} }
if r.ReadAt != nil { if r.ReadAt != nil {
@ -179,6 +196,7 @@ func (r *Notice) Entity() *entity.Notice {
// NoticeState maps square_notice_state. // NoticeState maps square_notice_state.
type NoticeState struct { type NoticeState struct {
UserID int64 `gorm:"column:user_id;primaryKey;comment:用户 id"` UserID int64 `gorm:"column:user_id;primaryKey;comment:用户 id"`
Channel string `gorm:"column:channel;primaryKey;comment:广场实例"`
LastReadNoticeID int64 `gorm:"column:last_read_notice_id;comment:已读水位"` LastReadNoticeID int64 `gorm:"column:last_read_notice_id;comment:已读水位"`
UpdatedAt time.Time `gorm:"column:updated_at;comment:更新时间"` UpdatedAt time.Time `gorm:"column:updated_at;comment:更新时间"`
} }
@ -189,7 +207,11 @@ func (r *NoticeState) Entity() *entity.NoticeState {
if r == nil { if r == nil {
return nil return nil
} }
ch := entity.Channel(r.Channel)
if ch == "" {
ch = entity.ChannelSquare
}
return &entity.NoticeState{ return &entity.NoticeState{
UserID: r.UserID, LastReadNoticeID: r.LastReadNoticeID, UpdatedAtMS: r.UpdatedAt.UnixMilli(), UserID: r.UserID, Channel: ch, LastReadNoticeID: r.LastReadNoticeID, UpdatedAtMS: r.UpdatedAt.UnixMilli(),
} }
} }

View File

@ -3,6 +3,8 @@ package model
import ( import (
"context" "context"
"errors" "errors"
"math"
"sort"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
@ -25,7 +27,7 @@ func (m *Repo) GetPost(ctx context.Context, id int64) (*entity.Post, error) {
return row.Entity(), nil return row.Entity(), nil
} }
func (m *Repo) ListByAuthors(ctx context.Context, authorIDs []int64, cursor int64, limit int) ([]*entity.Post, error) { func (m *Repo) ListByAuthors(ctx context.Context, channel entity.Channel, authorIDs []int64, cursor int64, limit int) ([]*entity.Post, error) {
if len(authorIDs) == 0 { if len(authorIDs) == 0 {
return []*entity.Post{}, nil return []*entity.Post{}, nil
} }
@ -33,7 +35,8 @@ func (m *Repo) ListByAuthors(ctx context.Context, authorIDs []int64, cursor int6
if err != nil { if err != nil {
return nil, err return nil, err
} }
q := db.Where("user_id IN ? AND status = ? AND "+notExpiredSQL, authorIDs, entity.StatusNormal, time.Now()) ch := normalizeChannel(channel)
q := db.Where("channel = ? AND user_id IN ? AND status = ? AND "+notExpiredSQL, ch, authorIDs, entity.StatusNormal, time.Now())
if cursor > 0 { if cursor > 0 {
q = q.Where("id < ?", cursor) q = q.Where("id < ?", cursor)
} }
@ -44,31 +47,113 @@ func (m *Repo) ListByAuthors(ctx context.Context, authorIDs []int64, cursor int6
return collectPosts(rows), nil return collectPosts(rows), nil
} }
func (m *Repo) ListByAuthor(ctx context.Context, authorID, cursor int64, limit int) ([]*entity.Post, error) { func (m *Repo) ListByAuthor(ctx context.Context, channel entity.Channel, authorID, cursor int64, limit int) ([]*entity.Post, error) {
return m.ListByAuthors(ctx, []int64{authorID}, cursor, limit) return m.ListByAuthors(ctx, channel, []int64{authorID}, cursor, limit)
} }
func (m *Repo) ListPublicFeed(ctx context.Context, cursor int64, limit int) ([]*entity.Post, error) { func (m *Repo) ListPublicFeed(ctx context.Context, channel entity.Channel, q entity.FeedListQuery) ([]*entity.Post, error) {
db, err := m.session(ctx) db, err := m.session(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
q := db.Where( ch := normalizeChannel(channel)
"audit_status = ? AND status = ? AND visibility IN ? AND "+notExpiredSQL, base := db.Where(
entity.AuditApproved, entity.StatusNormal, "channel = ? AND audit_status = ? AND status = ? AND visibility IN ? AND "+notExpiredSQL,
ch, entity.AuditApproved, entity.StatusNormal,
[]int16{int16(entity.VisibilityPublic), int16(entity.VisibilityExclude)}, []int16{int16(entity.VisibilityPublic), int16(entity.VisibilityExclude)},
time.Now(), time.Now(),
) )
if cursor > 0 { if q.Category.IsTag() {
q = q.Where("id < ?", cursor) base = base.Where("category = ?", string(q.Category))
}
switch q.Category {
case entity.CategoryHot:
return listHotFeed(base, q)
case entity.CategoryNearby:
return listNearbyFeed(base, q)
default:
return listLatestFeed(base, q)
}
}
func listLatestFeed(base *gorm.DB, q entity.FeedListQuery) ([]*entity.Post, error) {
query := base
if q.Cursor > 0 {
query = query.Where("id < ?", q.Cursor)
} }
var rows []Post var rows []Post
if err := q.Order("id DESC").Limit(limit).Find(&rows).Error; err != nil { if err := query.Order("id DESC").Limit(q.Limit).Find(&rows).Error; err != nil {
return nil, mapDBErr(err) return nil, mapDBErr(err)
} }
return collectPosts(rows), nil return collectPosts(rows), nil
} }
func listHotFeed(base *gorm.DB, q entity.FeedListQuery) ([]*entity.Post, error) {
query := base
if q.Cursor > 0 {
var cur Post
if err := base.Session(&gorm.Session{}).Where("id = ?", q.Cursor).Take(&cur).Error; err == nil {
query = query.Where(
"(like_count < ? OR (like_count = ? AND id < ?))",
cur.LikeCount, cur.LikeCount, cur.ID,
)
} else {
query = query.Where("id < ?", q.Cursor)
}
}
var rows []Post
if err := query.Order("like_count DESC, id DESC").Limit(q.Limit).Find(&rows).Error; err != nil {
return nil, mapDBErr(err)
}
return collectPosts(rows), nil
}
func listNearbyFeed(base *gorm.DB, q entity.FeedListQuery) ([]*entity.Post, error) {
query := base.Where("(poi_lat <> 0 OR poi_lng <> 0)")
if q.Cursor > 0 {
query = query.Where("id < ?", q.Cursor)
}
// Over-fetch then sort by manhattan distance in process (nearby stub).
fetch := q.Limit
if fetch < 50 {
fetch = 50
}
var rows []Post
if err := query.Order("id DESC").Limit(fetch).Find(&rows).Error; err != nil {
return nil, mapDBErr(err)
}
posts := collectPosts(rows)
if q.Lat == 0 && q.Lng == 0 {
if q.Limit > 0 && len(posts) > q.Limit {
posts = posts[:q.Limit]
}
return posts, nil
}
sort.SliceStable(posts, func(i, j int) bool {
di := manhattan(q.Lat, q.Lng, posts[i].POI.Lat, posts[i].POI.Lng)
dj := manhattan(q.Lat, q.Lng, posts[j].POI.Lat, posts[j].POI.Lng)
if di == dj {
return posts[i].ID > posts[j].ID
}
return di < dj
})
if q.Limit > 0 && len(posts) > q.Limit {
posts = posts[:q.Limit]
}
return posts, nil
}
func manhattan(lat1, lng1, lat2, lng2 float64) float64 {
return math.Abs(lat1-lat2) + math.Abs(lng1-lng2)
}
func normalizeChannel(ch entity.Channel) string {
if ch == "" {
return string(entity.ChannelSquare)
}
return string(ch)
}
func (m *Repo) ListAudience(ctx context.Context, postIDs []int64) (map[int64][]int64, error) { func (m *Repo) ListAudience(ctx context.Context, postIDs []int64) (map[int64][]int64, error) {
out := make(map[int64][]int64) out := make(map[int64][]int64)
if len(postIDs) == 0 { if len(postIDs) == 0 {
@ -140,28 +225,30 @@ func (m *Repo) GetComment(ctx context.Context, id int64) (*entity.Comment, error
return row.Entity(), nil return row.Entity(), nil
} }
func (m *Repo) GetNoticeState(ctx context.Context, userID int64) (*entity.NoticeState, error) { func (m *Repo) GetNoticeState(ctx context.Context, userID int64, channel entity.Channel) (*entity.NoticeState, error) {
db, err := m.session(ctx) db, err := m.session(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
ch := normalizeChannel(channel)
var row NoticeState var row NoticeState
if err := db.Where("user_id = ?", userID).Take(&row).Error; err != nil { if err := db.Where("user_id = ? AND channel = ?", userID, ch).Take(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return entity.DefaultNoticeState(userID), nil return entity.DefaultNoticeState(userID, entity.Channel(ch)), nil
} }
return nil, mapDBErr(err) return nil, mapDBErr(err)
} }
return row.Entity(), nil return row.Entity(), nil
} }
func (m *Repo) LatestNoticeID(ctx context.Context, userID int64) (int64, error) { func (m *Repo) LatestNoticeID(ctx context.Context, userID int64, channel entity.Channel) (int64, error) {
db, err := m.session(ctx) db, err := m.session(ctx)
if err != nil { if err != nil {
return 0, err return 0, err
} }
ch := normalizeChannel(channel)
var row Notice var row Notice
if err := db.Where("to_user_id = ?", userID).Order("id DESC").Limit(1).Take(&row).Error; err != nil { if err := db.Where("to_user_id = ? AND channel = ?", userID, ch).Order("id DESC").Limit(1).Take(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, nil return 0, nil
} }
@ -170,12 +257,13 @@ func (m *Repo) LatestNoticeID(ctx context.Context, userID int64) (int64, error)
return row.ID, nil return row.ID, nil
} }
func (m *Repo) ListNotices(ctx context.Context, userID, cursor int64, limit int) ([]*entity.Notice, error) { func (m *Repo) ListNotices(ctx context.Context, userID int64, channel entity.Channel, cursor int64, limit int) ([]*entity.Notice, error) {
db, err := m.session(ctx) db, err := m.session(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
q := db.Where("to_user_id = ?", userID) ch := normalizeChannel(channel)
q := db.Where("to_user_id = ? AND channel = ?", userID, ch)
if cursor > 0 { if cursor > 0 {
q = q.Where("id < ?", cursor) q = q.Where("id < ?", cursor)
} }

View File

@ -163,7 +163,8 @@ func (m *Repo) CreateNotices(ctx context.Context, rows []*entity.Notice) error {
now := time.Now() now := time.Now()
for _, n := range rows { for _, n := range rows {
row := Notice{ row := Notice{
ID: n.ID, ToUserID: n.ToUserID, FromUserID: n.FromUserID, Kind: int16(n.Kind), ID: n.ID, ToUserID: n.ToUserID, Channel: normalizeChannel(n.Channel),
FromUserID: n.FromUserID, Kind: int16(n.Kind),
PostID: n.PostID, CommentID: n.CommentID, CreatedAt: now, PostID: n.PostID, CommentID: n.CommentID, CreatedAt: now,
} }
if n.CreatedAtMS > 0 { if n.CreatedAtMS > 0 {
@ -176,19 +177,20 @@ func (m *Repo) CreateNotices(ctx context.Context, rows []*entity.Notice) error {
return nil return nil
} }
func (m *Repo) MarkNoticesRead(ctx context.Context, userID, cursor int64) error { func (m *Repo) MarkNoticesRead(ctx context.Context, userID int64, channel entity.Channel, cursor int64) error {
db, err := m.session(ctx) db, err := m.session(ctx)
if err != nil { if err != nil {
return err return err
} }
q := db.Model(&Notice{}).Where("to_user_id = ? AND read_at IS NULL", userID) ch := normalizeChannel(channel)
q := db.Model(&Notice{}).Where("to_user_id = ? AND channel = ? AND read_at IS NULL", userID, ch)
if cursor > 0 { if cursor > 0 {
q = q.Where("id <= ?", cursor) q = q.Where("id <= ?", cursor)
} }
return mapDBErr(q.Update("read_at", time.Now()).Error) return mapDBErr(q.Update("read_at", time.Now()).Error)
} }
func (m *Repo) AdvanceNoticeRead(ctx context.Context, userID, noticeID int64) error { func (m *Repo) AdvanceNoticeRead(ctx context.Context, userID int64, channel entity.Channel, noticeID int64) error {
if userID <= 0 || noticeID <= 0 { if userID <= 0 || noticeID <= 0 {
return errs.ErrInvalidArgument return errs.ErrInvalidArgument
} }
@ -196,7 +198,8 @@ func (m *Repo) AdvanceNoticeRead(ctx context.Context, userID, noticeID int64) er
if err != nil { if err != nil {
return err return err
} }
cur, err := m.GetNoticeState(ctx, userID) ch := entity.Channel(normalizeChannel(channel))
cur, err := m.GetNoticeState(ctx, userID, ch)
if err != nil { if err != nil {
return err return err
} }
@ -206,11 +209,11 @@ func (m *Repo) AdvanceNoticeRead(ctx context.Context, userID, noticeID int64) er
cur.LastReadNoticeID = noticeID cur.LastReadNoticeID = noticeID
cur.UpdatedAtMS = time.Now().UnixMilli() cur.UpdatedAtMS = time.Now().UnixMilli()
row := NoticeState{ row := NoticeState{
UserID: cur.UserID, LastReadNoticeID: cur.LastReadNoticeID, UserID: cur.UserID, Channel: string(ch), LastReadNoticeID: cur.LastReadNoticeID,
UpdatedAt: time.UnixMilli(cur.UpdatedAtMS), UpdatedAt: time.UnixMilli(cur.UpdatedAtMS),
} }
return mapDBErr(db.Clauses(clause.OnConflict{ return mapDBErr(db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}}, Columns: []clause.Column{{Name: "user_id"}, {Name: "channel"}},
DoUpdates: clause.AssignmentColumns([]string{"last_read_notice_id", "updated_at"}), DoUpdates: clause.AssignmentColumns([]string{"last_read_notice_id", "updated_at"}),
}).Create(&row).Error) }).Create(&row).Error)
} }

View File

@ -20,6 +20,9 @@ func init() {
KeyFieldSquareNoticeCursor: { KeyFieldSquareNoticeCursor: {
ZhCN: "游标", En: "Cursor", ZhTW: "遊標", ZhCN: "游标", En: "Cursor", ZhTW: "遊標",
}, },
KeyFieldSquareCategory: {
ZhCN: "分类", En: "Category", ZhTW: "分類",
},
KeySquareTextInvalid: { KeySquareTextInvalid: {
ZhCN: "正文最多1000字", En: "Text must be at most 1000 characters", ZhTW: "正文最多1000字", ZhCN: "正文最多1000字", En: "Text must be at most 1000 characters", ZhTW: "正文最多1000字",
}, },
@ -47,6 +50,9 @@ func init() {
KeySquareReplyInvalid: { KeySquareReplyInvalid: {
ZhCN: "回复目标无效", En: "Invalid reply target", ZhTW: "回覆目標無效", ZhCN: "回复目标无效", En: "Invalid reply target", ZhTW: "回覆目標無效",
}, },
KeySquareCategoryInvalid: {
ZhCN: "分类无效", En: "Invalid category", ZhTW: "分類無效",
},
"enum.square_kind.text": {ZhCN: "纯文", En: "Text", ZhTW: "純文"}, "enum.square_kind.text": {ZhCN: "纯文", En: "Text", ZhTW: "純文"},
"enum.square_kind.image": {ZhCN: "图片", En: "Image", ZhTW: "圖片"}, "enum.square_kind.image": {ZhCN: "图片", En: "Image", ZhTW: "圖片"},
"enum.square_kind.video": {ZhCN: "视频", En: "Video", ZhTW: "影片"}, "enum.square_kind.video": {ZhCN: "视频", En: "Video", ZhTW: "影片"},
@ -59,6 +65,16 @@ func init() {
"enum.square_notice_kind.like": {ZhCN: "赞", En: "Like", ZhTW: "讚"}, "enum.square_notice_kind.like": {ZhCN: "赞", En: "Like", ZhTW: "讚"},
"enum.square_notice_kind.comment": {ZhCN: "评论", En: "Comment", ZhTW: "評論"}, "enum.square_notice_kind.comment": {ZhCN: "评论", En: "Comment", ZhTW: "評論"},
"enum.square_notice_kind.reply": {ZhCN: "回复", En: "Reply", ZhTW: "回覆"}, "enum.square_notice_kind.reply": {ZhCN: "回复", En: "Reply", ZhTW: "回覆"},
"enum.square_channel.square": {ZhCN: "广场", En: "Square", ZhTW: "廣場"},
"enum.square_channel.squarenovel": {ZhCN: "小说广场", En: "Novel square", ZhTW: "小說廣場"},
"enum.square_channel.squaresteps": {ZhCN: "步数广场", En: "Steps square", ZhTW: "步數廣場"},
"enum.square_channel.squarehealth": {ZhCN: "健康广场", En: "Health square", ZhTW: "健康廣場"},
"enum.square_category.recommend": {ZhCN: "推荐", En: "Recommend", ZhTW: "推薦"},
"enum.square_category.latest": {ZhCN: "最新", En: "Latest", ZhTW: "最新"},
"enum.square_category.hot": {ZhCN: "热门", En: "Hot", ZhTW: "熱門"},
"enum.square_category.nearby": {ZhCN: "附近", En: "Nearby", ZhTW: "附近"},
"enum.square_category.funny": {ZhCN: "搞笑", En: "Funny", ZhTW: "搞笑"},
"enum.square_category.beauty": {ZhCN: "美女", En: "Beauty", ZhTW: "美女"},
"enum.upload_purpose.square_image": {ZhCN: "广场图片", En: "Square image", ZhTW: "广场圖片"}, "enum.upload_purpose.square_image": {ZhCN: "广场图片", En: "Square image", ZhTW: "广场圖片"},
"enum.upload_purpose.square_video": {ZhCN: "广场视频", En: "Square video", ZhTW: "广场影片"}, "enum.upload_purpose.square_video": {ZhCN: "广场视频", En: "Square video", ZhTW: "广场影片"},
}) })

View File

@ -161,6 +161,7 @@ const (
KeyFieldSquareComment = "field.square_comment" KeyFieldSquareComment = "field.square_comment"
KeyFieldSquareReply = "field.square_reply" KeyFieldSquareReply = "field.square_reply"
KeyFieldSquareNoticeCursor = "field.square_notice_cursor" KeyFieldSquareNoticeCursor = "field.square_notice_cursor"
KeyFieldSquareCategory = "field.square_category"
KeySquareTextInvalid = "validate.square.text_invalid" KeySquareTextInvalid = "validate.square.text_invalid"
KeySquareVisibilityInvalid = "validate.square.visibility_invalid" KeySquareVisibilityInvalid = "validate.square.visibility_invalid"
KeySquareAudienceInvalid = "validate.square.audience_invalid" KeySquareAudienceInvalid = "validate.square.audience_invalid"
@ -170,6 +171,7 @@ const (
KeySquareCommentRequired = "validate.square.comment_required" KeySquareCommentRequired = "validate.square.comment_required"
KeySquareCommentInvalid = "validate.square.comment_invalid" KeySquareCommentInvalid = "validate.square.comment_invalid"
KeySquareReplyInvalid = "validate.square.reply_invalid" KeySquareReplyInvalid = "validate.square.reply_invalid"
KeySquareCategoryInvalid = "validate.square.category_invalid"
KeyFieldVerificationTarget = "field.verification_target" KeyFieldVerificationTarget = "field.verification_target"
KeySMSLanguageRequired = "validate.sms.language_required" KeySMSLanguageRequired = "validate.sms.language_required"

View File

@ -102,6 +102,9 @@ func publicPath(path string) bool {
"/api/client/pop/developer-apps", "/api/client/spark/developer-apps": "/api/client/pop/developer-apps", "/api/client/spark/developer-apps":
return true return true
default: default:
if publicMusicPath(path) {
return true
}
// Admin routes use AdminAuth (Java popadmin JWT), not client Bearer. // Admin routes use AdminAuth (Java popadmin JWT), not client Bearer.
if adminPath(path) { if adminPath(path) {
return true return true
@ -110,6 +113,16 @@ func publicPath(path string) bool {
} }
} }
func publicMusicPath(path string) bool {
switch path {
case "/v1/music/playlist", "/v1/music/playlist/grouped",
"/v1/music/categories", "/v1/music/settings":
return true
default:
return strings.HasPrefix(path, "/v1/music/tracks/")
}
}
func bearerToken(h string) (string, bool) { func bearerToken(h string) (string, bool) {
if len(h) < len(bearerPrefix) || !strings.EqualFold(h[:len(bearerPrefix)], bearerPrefix) { if len(h) < len(bearerPrefix) || !strings.EqualFold(h[:len(bearerPrefix)], bearerPrefix) {
return "", false return "", false

View File

@ -1,4 +1,4 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: Spark IM API title: Spark IM API
version: 0.1.0 version: 0.1.0
@ -65,7 +65,7 @@ info:
| Header | 放哪 | 必填 | 说明 | | Header | 放哪 | 必填 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `Authorization` | Header | 需登录的接口必填 | `Bearer <access_token>`。登录、发码、刷新、`/healthz`、`/readyz`、`/docs`、`/openapi.yaml`、`GET /v1/meta/enums`、`GET /v1/music/playlist`、`GET /v1/music/categories`、`GET /v1/music/settings` **不要**带。 | | `Authorization` | Header | 需登录的接口必填 | `Bearer <access_token>`。登录、发码、刷新、`/healthz`、`/readyz`、`/docs`、`/openapi.yaml`、`GET /v1/meta/enums`、以及公开音乐读接口(`/v1/music/playlist`、`/v1/music/playlist/grouped`、`/v1/music/tracks/{id}`、`/v1/music/categories`、`/v1/music/settings`)**不要**带。 |
| `X-Device-ID` | Header | 可选 | 十进制 `device_id`,须等于登录响应 / JWT `did`(**服务端按物理指纹生成**,客户端勿自造)。不一致则 `40101`。 | | `X-Device-ID` | Header | 可选 | 十进制 `device_id`,须等于登录响应 / JWT `did`(**服务端按物理指纹生成**,客户端勿自造)。不一致则 `40101`。 |
| `X-App-ID` | Header | 可选 | 若传,须与 JWT **`aid`(app_id)** 一致。 | | `X-App-ID` | Header | 可选 | 若传,须与 JWT **`aid`(app_id)** 一致。 |
| `X-Language` | Header | 可选 | `zh-CN` / `en` / `zh-TW`。优先于 `Accept-Language`。缺省 `zh-CN`。决定 `msg` 语言。 | | `X-Language` | Header | 可选 | `zh-CN` / `en` / `zh-TW`。优先于 `Accept-Language`。缺省 `zh-CN`。决定 `msg` 语言。 |
@ -373,9 +373,16 @@ tags:
- name: favorites - name: favorites
- name: moments - name: moments
- name: square - name: square
description: |
多广场:路径 `/v1/{square_channel}/…`,channel=`square|squarenovel|squaresteps|squarehealth`。
分类 Tab:`GET …/categories`;Feed 用 query `category`(推荐/最新/热门/附近/搞笑/美女)。
- name: music - name: music
description: | description: |
运营音乐列表。客户端公开读 `GET /v1/music/playlist`(可带 `categoryId`)、`GET /v1/music/categories`、`GET /v1/music/settings`(无需登录,V3 envelope)。 运营音乐列表。客户端公开读(无需登录,V3 envelope):
`GET /v1/music/playlist`(可带 `categoryId` / `requireMember`)、
`GET /v1/music/playlist/grouped`(分类分区嵌套曲目,对齐欢休 zone 列表)、
`GET /v1/music/tracks/{id}`(单曲详情)、
`GET /v1/music/categories`、`GET /v1/music/settings`。
playlist 返回曲目、`categories` 分类树与 `memberPreviewSeconds`;曲目含 `backgroundUrl`、`requireMember`、`categoryIds`。 playlist 返回曲目、`categories` 分类树与 `memberPreviewSeconds`;曲目含 `backgroundUrl`、`requireMember`、`categoryIds`。
- name: admin-music - name: admin-music
description: | description: |
@ -821,9 +828,21 @@ paths:
description: | description: |
返回 headline/subheadline 与 week/month/quarter/year 套餐卡片(含 IAP product_id)。 返回 headline/subheadline 与 week/month/quarter/year 套餐卡片(含 IAP product_id)。
公开接口;无库数据时回退默认四档。 公开接口;无库数据时回退默认四档。
支持按 **包名 / applicationId** 筛选:`packageName`(或 `package_name`)Query,或 Header `X-Wyrs-Package`。
有该包精确配置则返回之;否则回退 `package_name` 为空的默认档。
operationId: subscriptionPlans operationId: subscriptionPlans
parameters: parameters:
- $ref: "#/components/parameters/X-Language" - $ref: "#/components/parameters/X-Language"
- name: packageName
in: query
required: false
schema: { type: string }
description: applicationId / Bundle ID / 包名;也可用 package_name 或 Header X-Wyrs-Package
- name: X-Wyrs-Package
in: header
required: false
schema: { type: string }
description: 与 packageName 等价
responses: responses:
"200": "200":
description: data.plans description: data.plans
@ -1837,21 +1856,54 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Envelope" $ref: "#/components/schemas/Envelope"
/v1/square/posts: /v1/{square_channel}/categories:
get: get:
tags: [square] tags: [square]
summary: 全站广场 Feed summary: 广场分类 Tab
description: | description: |
`cursor` 为上一页最后一条 `post_id`,0 表示最新。仅 `visibility` 公开与不给谁看、且审核通过的帖; 返回筛选分类:`recommend`/`latest`/`hot`/`nearby`(feed 模式)与 `funny`/`beauty`(内容标签)。
读时按拉黑、黑名单过滤。评论随帖详情返回,无单独评论列表接口。 各广场实例共用同一套分类枚举;数据按 `square_channel` 隔离。
operationId: listSquareCategories
parameters:
- $ref: "#/components/parameters/SquareChannel"
responses:
"200":
description: 统一 envelope;data.items[].value / kind(feed|tag)
content:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
/v1/{square_channel}/posts:
get:
tags: [square]
summary: 广场 Feed
description: |
`square_channel`:`square`(原路径兼容)/ `squarenovel` / `squaresteps` / `squarehealth`。
`category`:推荐/最新/热门/附近为排序模式;搞笑/美女按帖标签筛选。
`nearby` 可传 `lat`/`lng`(曼哈顿距离 stub);未传则仅返回带 POI 的帖并按 id 倒序。
`cursor` 为上一页最后一条 `post_id`,0 表示最新。
operationId: listSquareFeed operationId: listSquareFeed
parameters: parameters:
- $ref: "#/components/parameters/SquareChannel"
- in: query
name: category
schema:
$ref: "#/components/schemas/SquareCategory"
description: 缺省 recommend
- in: query - in: query
name: cursor name: cursor
schema: { type: integer, format: int64, default: 0 } schema: { type: integer, format: int64, default: 0 }
- in: query - in: query
name: limit name: limit
schema: { type: integer, default: 20, maximum: 50 } schema: { type: integer, default: 20, maximum: 50 }
- in: query
name: lat
schema: { type: number, format: double }
description: 附近排序纬度(category=nearby)
- in: query
name: lng
schema: { type: number, format: double }
description: 附近排序经度(category=nearby)
responses: responses:
"200": "200":
description: 统一 envelope description: 统一 envelope
@ -1865,13 +1917,16 @@ paths:
description: | description: |
内容与朋友圈相同。媒体先 `POST /v1/files`(`square_image` / `square_video`)再填 key。 内容与朋友圈相同。媒体先 `POST /v1/files`(`square_image` / `square_video`)再填 key。
`visibility`:0 全站公开 1 仅自己 2 白名单 3 黑名单。2/3 需 `audience_uids`(双向好友,最多 200)。 `visibility`:0 全站公开 1 仅自己 2 白名单 3 黑名单。2/3 需 `audience_uids`(双向好友,最多 200)。
可选 `category` 内容标签:`funny` / `beauty`(不可传 feed 模式值)。
operationId: publishSquarePost operationId: publishSquarePost
parameters:
- $ref: "#/components/parameters/SquareChannel"
requestBody: requestBody:
required: true required: true
content: content:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/PublishMomentRequest" $ref: "#/components/schemas/PublishSquareRequest"
responses: responses:
"200": "200":
description: 统一 envelope description: 统一 envelope
@ -1879,12 +1934,13 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Envelope" $ref: "#/components/schemas/Envelope"
/v1/square/posts/{id}: /v1/{square_channel}/posts/{id}:
get: get:
tags: [square] tags: [square]
summary: 单条广场帖 summary: 单条广场帖
operationId: getSquarePost operationId: getSquarePost
parameters: parameters:
- $ref: "#/components/parameters/SquareChannel"
- $ref: "#/components/parameters/SquarePostID" - $ref: "#/components/parameters/SquarePostID"
responses: responses:
"200": "200":
@ -1898,6 +1954,7 @@ paths:
summary: 删除自己的广场帖 summary: 删除自己的广场帖
operationId: deleteSquarePost operationId: deleteSquarePost
parameters: parameters:
- $ref: "#/components/parameters/SquareChannel"
- $ref: "#/components/parameters/SquarePostID" - $ref: "#/components/parameters/SquarePostID"
responses: responses:
"200": "200":
@ -1906,12 +1963,13 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Envelope" $ref: "#/components/schemas/Envelope"
/v1/square/posts/{id}/like: /v1/{square_channel}/posts/{id}/like:
put: put:
tags: [square] tags: [square]
summary: 点赞 summary: 点赞
operationId: likeSquarePost operationId: likeSquarePost
parameters: parameters:
- $ref: "#/components/parameters/SquareChannel"
- $ref: "#/components/parameters/SquarePostID" - $ref: "#/components/parameters/SquarePostID"
responses: responses:
"200": "200":
@ -1925,6 +1983,7 @@ paths:
summary: 取消赞 summary: 取消赞
operationId: unlikeSquarePost operationId: unlikeSquarePost
parameters: parameters:
- $ref: "#/components/parameters/SquareChannel"
- $ref: "#/components/parameters/SquarePostID" - $ref: "#/components/parameters/SquarePostID"
responses: responses:
"200": "200":
@ -1933,12 +1992,13 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Envelope" $ref: "#/components/schemas/Envelope"
/v1/square/posts/{id}/comments: /v1/{square_channel}/posts/{id}/comments:
post: post:
tags: [square] tags: [square]
summary: 评论或回复 summary: 评论或回复
operationId: commentSquarePost operationId: commentSquarePost
parameters: parameters:
- $ref: "#/components/parameters/SquareChannel"
- $ref: "#/components/parameters/SquarePostID" - $ref: "#/components/parameters/SquarePostID"
requestBody: requestBody:
required: true required: true
@ -1953,12 +2013,13 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Envelope" $ref: "#/components/schemas/Envelope"
/v1/square/posts/{id}/comments/{cid}: /v1/{square_channel}/posts/{id}/comments/{cid}:
delete: delete:
tags: [square] tags: [square]
summary: 删除评论 summary: 删除评论
operationId: deleteSquareComment operationId: deleteSquareComment
parameters: parameters:
- $ref: "#/components/parameters/SquareChannel"
- $ref: "#/components/parameters/SquarePostID" - $ref: "#/components/parameters/SquarePostID"
- name: cid - name: cid
in: path in: path
@ -1971,13 +2032,14 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Envelope" $ref: "#/components/schemas/Envelope"
/v1/square/users/{uid}/posts: /v1/{square_channel}/users/{uid}/posts:
get: get:
tags: [square] tags: [square]
summary: 某用户广场帖 summary: 某用户广场帖
description: 按当前浏览者与帖可见性、拉黑过滤;看自己可见私密与白名单帖。 description: 按当前浏览者与帖可见性、拉黑过滤;看自己可见私密与白名单帖。仅返回该 channel 下的帖。
operationId: listSquareUserPosts operationId: listSquareUserPosts
parameters: parameters:
- $ref: "#/components/parameters/SquareChannel"
- $ref: "#/components/parameters/FriendUID" - $ref: "#/components/parameters/FriendUID"
- in: query - in: query
name: cursor name: cursor
@ -1992,12 +2054,13 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Envelope" $ref: "#/components/schemas/Envelope"
/v1/square/notices: /v1/{square_channel}/notices:
get: get:
tags: [square] tags: [square]
summary: 赞评通知列表 summary: 赞评通知列表
operationId: listSquareNotices operationId: listSquareNotices
parameters: parameters:
- $ref: "#/components/parameters/SquareChannel"
- in: query - in: query
name: cursor name: cursor
schema: { type: integer, format: int64, default: 0 } schema: { type: integer, format: int64, default: 0 }
@ -2011,11 +2074,13 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Envelope" $ref: "#/components/schemas/Envelope"
/v1/square/notices/unread: /v1/{square_channel}/notices/unread:
get: get:
tags: [square] tags: [square]
summary: 广场互动未读角标 summary: 广场互动未读角标
operationId: squareNoticeUnread operationId: squareNoticeUnread
parameters:
- $ref: "#/components/parameters/SquareChannel"
responses: responses:
"200": "200":
description: 统一 envelope description: 统一 envelope
@ -2023,11 +2088,13 @@ paths:
application/json: application/json:
schema: schema:
$ref: "#/components/schemas/Envelope" $ref: "#/components/schemas/Envelope"
/v1/square/notices/read: /v1/{square_channel}/notices/read:
post: post:
tags: [square] tags: [square]
summary: 通知标已读 summary: 通知标已读
operationId: readSquareNotices operationId: readSquareNotices
parameters:
- $ref: "#/components/parameters/SquareChannel"
requestBody: requestBody:
content: content:
application/json: application/json:
@ -2050,6 +2117,7 @@ paths:
封面/背景图/音频为 sparkr 公网 URL(`https://sparkr.wyrs365.com/spark/perm/music/...`)。 封面/背景图/音频为 sparkr 公网 URL(`https://sparkr.wyrs365.com/spark/perm/music/...`)。
客户端应按 `requireMember` 与 `memberPreviewSeconds` 控制试听;`backgroundUrl` 可空。 客户端应按 `requireMember` 与 `memberPreviewSeconds` 控制试听;`backgroundUrl` 可空。
传 `categoryId` 时仅返回归属该分类或其子分类的曲目(多对多)。 传 `categoryId` 时仅返回归属该分类或其子分类的曲目(多对多)。
传 `requireMember=true|false` 可仅返回会员曲或免费曲(对齐欢休 VIP 分区筛选)。
operationId: getMusicPlaylist operationId: getMusicPlaylist
security: [] security: []
parameters: parameters:
@ -2058,6 +2126,11 @@ paths:
required: false required: false
schema: { type: integer, format: int64, minimum: 0 } schema: { type: integer, format: int64, minimum: 0 }
description: 按分类筛选;0 或不传=全部上架曲目 description: 按分类筛选;0 或不传=全部上架曲目
- name: requireMember
in: query
required: false
schema: { type: boolean }
description: true=仅会员曲;false=仅免费曲;不传=全部
responses: responses:
"200": "200":
description: 统一 envelope,`data` 为 MusicPlaylist description: 统一 envelope,`data` 为 MusicPlaylist
@ -2070,6 +2143,61 @@ paths:
properties: properties:
data: data:
$ref: "#/components/schemas/MusicPlaylist" $ref: "#/components/schemas/MusicPlaylist"
/v1/music/playlist/grouped:
get:
tags: [music]
summary: 分区嵌套音乐列表(公开)
description: |
按叶子分类分组返回曲目,便于首页「分区 + 分区下音乐」一次拉取。
对齐欢休 `zoneAndZoneMusicList` 语义(Spark 用 category 作 zone)。
`parentId`>0 时:该分类为叶子则只返回自身;有子分类则返回其子叶子。
无需登录。
operationId: getMusicPlaylistGrouped
security: []
parameters:
- name: parentId
in: query
required: false
schema: { type: integer, format: int64, minimum: 0 }
description: 父分类 id;0 或不传=全部叶子分类
responses:
"200":
description: 统一 envelope,`data` 为 MusicGroupedPlaylist
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/Envelope"
- type: object
properties:
data:
$ref: "#/components/schemas/MusicGroupedPlaylist"
/v1/music/tracks/{id}:
get:
tags: [music]
summary: 单曲详情(公开)
description: |
返回已上架曲目详情(含播放 `audioUrl`、封面/背景)。下架或不存在返回 `40401`。
对齐欢休 `/music/v1/detail`;无需登录。
operationId: getMusicTrack
security: []
parameters:
- name: id
in: path
required: true
schema: { type: integer, format: int64, minimum: 1 }
responses:
"200":
description: 统一 envelope,`data` 为 MusicTrack
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/Envelope"
- type: object
properties:
data:
$ref: "#/components/schemas/MusicTrack"
/v1/music/categories: /v1/music/categories:
get: get:
tags: [music] tags: [music]
@ -3327,6 +3455,23 @@ components:
type: integer type: integer
enum: [0, 3, 30, 180] enum: [0, 3, 30, 180]
description: moment_settings.visible_days。0全部 3/30/180 天 description: moment_settings.visible_days。0全部 3/30/180 天
SquareChannel:
type: string
enum: [square, squarenovel, squaresteps, squarehealth]
description: 广场实例路径段;与 t_square_posts.channel 一致
SquareCategory:
type: string
enum: [recommend, latest, hot, nearby, funny, beauty]
description: |
Feed 筛选。recommend/latest/hot/nearby 为排序模式;funny/beauty 为帖标签(发帖可写)。
PublishSquareRequest:
allOf:
- $ref: "#/components/schemas/PublishMomentRequest"
- type: object
properties:
category:
$ref: "#/components/schemas/SquareCategory"
description: 仅 funny/beauty;发帖不可传 feed 模式值
PublishMomentRequest: PublishMomentRequest:
type: object type: object
properties: properties:
@ -3516,6 +3661,25 @@ components:
$ref: "#/components/schemas/MusicCategory" $ref: "#/components/schemas/MusicCategory"
description: 启用中的分类树 description: 启用中的分类树
memberPreviewSeconds: { type: integer } memberPreviewSeconds: { type: integer }
MusicPlaylistGroup:
type: object
description: 一个分区(叶子分类)及其曲目
properties:
categoryId: { type: integer, format: int64 }
name: { type: string }
parentId: { type: integer, format: int64, nullable: true }
items:
type: array
items:
$ref: "#/components/schemas/MusicTrack"
MusicGroupedPlaylist:
type: object
properties:
groups:
type: array
items:
$ref: "#/components/schemas/MusicPlaylistGroup"
memberPreviewSeconds: { type: integer }
SensitiveCategory: SensitiveCategory:
type: integer type: integer
enum: [1, 2, 3, 4, 5, 6, 7, 8, 9] enum: [1, 2, 3, 4, 5, 6, 7, 8, 9]
@ -4331,6 +4495,13 @@ components:
type: integer type: integer
format: int64 format: int64
description: 朋友圈 Snowflake post_id。 description: 朋友圈 Snowflake post_id。
SquareChannel:
in: path
name: square_channel
required: true
schema:
$ref: "#/components/schemas/SquareChannel"
description: 广场实例 square / squarenovel / squaresteps / squarehealth
SquarePostID: SquarePostID:
in: path in: path
name: id name: id

View File

@ -61,7 +61,11 @@ func (h *ClientHandler) ListPlans(c *gin.Context) {
return return
} }
lang := c.Query("lang") lang := c.Query("lang")
list, err := h.svc.ListPlansAdmin(c.Request.Context(), lang) pkg := strings.TrimSpace(c.Query("packageName"))
if pkg == "" {
pkg = strings.TrimSpace(c.Query("package_name"))
}
list, err := h.svc.ListPlansAdmin(c.Request.Context(), lang, pkg)
if err != nil { if err != nil {
Fail(c, http.StatusInternalServerError, err.Error()) Fail(c, http.StatusInternalServerError, err.Error())
return return

View File

@ -1,6 +1,8 @@
package clienthandler package clienthandler
import ( import (
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
clientlogic "spark-server/internal/module/client/logic" clientlogic "spark-server/internal/module/client/logic"
@ -39,7 +41,14 @@ func (h *Handler) SubmitFeedback(c *gin.Context) {
} }
func (h *Handler) SubscriptionPlans(c *gin.Context) { func (h *Handler) SubscriptionPlans(c *gin.Context) {
out, err := h.svc.SubscriptionPlans(c.Request.Context()) pkg := strings.TrimSpace(c.Query("packageName"))
if pkg == "" {
pkg = strings.TrimSpace(c.Query("package_name"))
}
if pkg == "" {
pkg = strings.TrimSpace(c.GetHeader("X-Wyrs-Package"))
}
out, err := h.svc.SubscriptionPlans(c.Request.Context(), pkg)
if err != nil { if err != nil {
respond.JSONError(c, err) respond.JSONError(c, err)
return return

View File

@ -20,21 +20,60 @@ func New(svc *musiclogic.Service) *Handler {
// Playlist GET /v1/music/playlist — public curated list + category tree. // Playlist GET /v1/music/playlist — public curated list + category tree.
// Query categoryId>0 filters tracks belonging to that category or its descendants. // Query categoryId>0 filters tracks belonging to that category or its descendants.
// Query requireMember=true|false filters member-only / free tracks.
func (h *Handler) Playlist(c *gin.Context) { func (h *Handler) Playlist(c *gin.Context) {
if h == nil || h.svc == nil || !h.svc.Ready() { if h == nil || h.svc == nil || !h.svc.Ready() {
respond.JSONError(c, errs.ErrAvailable) respond.JSONError(c, errs.ErrAvailable)
return return
} }
var categoryID int64 q, err := parsePlaylistQuery(c)
if raw := c.Query("categoryId"); raw != "" { if err != nil {
n, err := strconv.ParseInt(raw, 10, 64) respond.JSONError(c, err)
if err != nil || n < 0 {
respond.JSONError(c, errs.ErrInvalidArgument.WithMsg("categoryId 无效"))
return return
} }
categoryID = n out, err := h.svc.Playlist(c.Request.Context(), q)
if err != nil {
respond.JSONError(c, err)
return
} }
out, err := h.svc.Playlist(c.Request.Context(), categoryID) respond.JSON(c, out)
}
// PlaylistGrouped GET /v1/music/playlist/grouped — leaf categories with nested items.
func (h *Handler) PlaylistGrouped(c *gin.Context) {
if h == nil || h.svc == nil || !h.svc.Ready() {
respond.JSONError(c, errs.ErrAvailable)
return
}
var parentID int64
if raw := c.Query("parentId"); raw != "" {
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil || n < 0 {
respond.JSONError(c, errs.ErrInvalidArgument.WithMsg("parentId 无效"))
return
}
parentID = n
}
out, err := h.svc.PlaylistGrouped(c.Request.Context(), parentID)
if err != nil {
respond.JSONError(c, err)
return
}
respond.JSON(c, out)
}
// GetTrack GET /v1/music/tracks/:id — single enabled track (public).
func (h *Handler) GetTrack(c *gin.Context) {
if h == nil || h.svc == nil || !h.svc.Ready() {
respond.JSONError(c, errs.ErrAvailable)
return
}
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
respond.JSONError(c, errs.ErrInvalidArgument.WithMsg("id 无效"))
return
}
out, err := h.svc.GetPublic(c.Request.Context(), id)
if err != nil { if err != nil {
respond.JSONError(c, err) respond.JSONError(c, err)
return return
@ -69,3 +108,27 @@ func (h *Handler) Settings(c *gin.Context) {
} }
respond.JSON(c, out) respond.JSON(c, out)
} }
func parsePlaylistQuery(c *gin.Context) (musiclogic.PlaylistQuery, error) {
var q musiclogic.PlaylistQuery
if raw := c.Query("categoryId"); raw != "" {
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil || n < 0 {
return q, errs.ErrInvalidArgument.WithMsg("categoryId 无效")
}
q.CategoryID = n
}
if raw := c.Query("requireMember"); raw != "" {
switch raw {
case "true", "1":
v := true
q.RequireMember = &v
case "false", "0":
v := false
q.RequireMember = &v
default:
return q, errs.ErrInvalidArgument.WithMsg("requireMember 须为 true|false")
}
}
return q, nil
}

View File

@ -5,19 +5,24 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"spark-server/internal/module/square/entity"
squarelogic "spark-server/internal/module/square/logic" squarelogic "spark-server/internal/module/square/logic"
"spark-server/internal/pkg/errs" "spark-server/internal/pkg/errs"
"spark-server/internal/pkg/reqctx" "spark-server/internal/pkg/reqctx"
"spark-server/internal/transport/http/respond" "spark-server/internal/transport/http/respond"
) )
// Handler is the square HTTP controller. // Handler is the square HTTP controller for one plaza channel.
type Handler struct { type Handler struct {
square *squarelogic.Service square *squarelogic.Service
channel entity.Channel
} }
func New(square *squarelogic.Service) *Handler { func New(square *squarelogic.Service, channel entity.Channel) *Handler {
return &Handler{square: square} if channel == "" {
channel = entity.ChannelSquare
}
return &Handler{square: square, channel: channel}
} }
func currentUID(c *gin.Context) (int64, bool) { func currentUID(c *gin.Context) (int64, bool) {
@ -43,12 +48,32 @@ func queryInt(c *gin.Context, name string) int64 {
return n return n
} }
func queryFloat(c *gin.Context, name string) float64 {
n, _ := strconv.ParseFloat(c.Query(name), 64)
return n
}
func (h *Handler) Categories(c *gin.Context) {
respond.JSON(c, gin.H{"items": h.square.Categories()})
}
func (h *Handler) Feed(c *gin.Context) { func (h *Handler) Feed(c *gin.Context) {
uid, ok := currentUID(c) uid, ok := currentUID(c)
if !ok { if !ok {
return return
} }
out, err := h.square.Feed(c.Request.Context(), uid, queryInt(c, "cursor"), int(queryInt(c, "limit"))) cat, ok := entity.ParseCategory(c.Query("category"))
if !ok {
respond.JSONError(c, errs.ErrInvalidArgument)
return
}
if cat == "" {
cat = entity.CategoryRecommend
}
out, err := h.square.Feed(c.Request.Context(), uid, h.channel, squarelogic.FeedQuery{
Cursor: queryInt(c, "cursor"), Limit: int(queryInt(c, "limit")),
Category: cat, Lat: queryFloat(c, "lat"), Lng: queryFloat(c, "lng"),
})
if err != nil { if err != nil {
respond.JSONError(c, err) respond.JSONError(c, err)
return return
@ -66,7 +91,7 @@ func (h *Handler) Publish(c *gin.Context) {
respond.JSONError(c, err) respond.JSONError(c, err)
return return
} }
out, err := h.square.Publish(c.Request.Context(), uid, &in) out, err := h.square.Publish(c.Request.Context(), uid, h.channel, &in)
if err != nil { if err != nil {
respond.JSONError(c, err) respond.JSONError(c, err)
return return
@ -83,7 +108,7 @@ func (h *Handler) UserPosts(c *gin.Context) {
if !ok { if !ok {
return return
} }
out, err := h.square.UserPosts(c.Request.Context(), uid, owner, queryInt(c, "cursor"), int(queryInt(c, "limit"))) out, err := h.square.UserPosts(c.Request.Context(), uid, owner, h.channel, queryInt(c, "cursor"), int(queryInt(c, "limit")))
if err != nil { if err != nil {
respond.JSONError(c, err) respond.JSONError(c, err)
return return
@ -100,7 +125,7 @@ func (h *Handler) Get(c *gin.Context) {
if !ok { if !ok {
return return
} }
out, err := h.square.Get(c.Request.Context(), uid, id) out, err := h.square.Get(c.Request.Context(), uid, id, h.channel)
if err != nil { if err != nil {
respond.JSONError(c, err) respond.JSONError(c, err)
return return
@ -117,7 +142,7 @@ func (h *Handler) Delete(c *gin.Context) {
if !ok { if !ok {
return return
} }
if err := h.square.Delete(c.Request.Context(), uid, id); err != nil { if err := h.square.Delete(c.Request.Context(), uid, id, h.channel); err != nil {
respond.JSONError(c, err) respond.JSONError(c, err)
return return
} }
@ -133,7 +158,7 @@ func (h *Handler) Like(c *gin.Context) {
if !ok { if !ok {
return return
} }
out, err := h.square.Like(c.Request.Context(), uid, id) out, err := h.square.Like(c.Request.Context(), uid, id, h.channel)
if err != nil { if err != nil {
respond.JSONError(c, err) respond.JSONError(c, err)
return return
@ -150,7 +175,7 @@ func (h *Handler) Unlike(c *gin.Context) {
if !ok { if !ok {
return return
} }
out, err := h.square.Unlike(c.Request.Context(), uid, id) out, err := h.square.Unlike(c.Request.Context(), uid, id, h.channel)
if err != nil { if err != nil {
respond.JSONError(c, err) respond.JSONError(c, err)
return return
@ -172,7 +197,7 @@ func (h *Handler) CreateComment(c *gin.Context) {
respond.JSONError(c, err) respond.JSONError(c, err)
return return
} }
out, err := h.square.Comment(c.Request.Context(), uid, id, &in) out, err := h.square.Comment(c.Request.Context(), uid, id, h.channel, &in)
if err != nil { if err != nil {
respond.JSONError(c, err) respond.JSONError(c, err)
return return
@ -193,7 +218,7 @@ func (h *Handler) DeleteComment(c *gin.Context) {
if !ok { if !ok {
return return
} }
out, err := h.square.DeleteComment(c.Request.Context(), uid, postID, cid) out, err := h.square.DeleteComment(c.Request.Context(), uid, postID, cid, h.channel)
if err != nil { if err != nil {
respond.JSONError(c, err) respond.JSONError(c, err)
return return
@ -206,7 +231,7 @@ func (h *Handler) ListNotices(c *gin.Context) {
if !ok { if !ok {
return return
} }
out, err := h.square.ListNotices(c.Request.Context(), uid, queryInt(c, "cursor"), int(queryInt(c, "limit"))) out, err := h.square.ListNotices(c.Request.Context(), uid, h.channel, queryInt(c, "cursor"), int(queryInt(c, "limit")))
if err != nil { if err != nil {
respond.JSONError(c, err) respond.JSONError(c, err)
return return
@ -219,7 +244,7 @@ func (h *Handler) NoticeUnread(c *gin.Context) {
if !ok { if !ok {
return return
} }
out, err := h.square.NoticeUnread(c.Request.Context(), uid) out, err := h.square.NoticeUnread(c.Request.Context(), uid, h.channel)
if err != nil { if err != nil {
respond.JSONError(c, err) respond.JSONError(c, err)
return return
@ -237,7 +262,7 @@ func (h *Handler) ReadNotices(c *gin.Context) {
respond.JSONError(c, err) respond.JSONError(c, err)
return return
} }
if err := h.square.ReadNotices(c.Request.Context(), uid, &in); err != nil { if err := h.square.ReadNotices(c.Request.Context(), uid, h.channel, &in); err != nil {
respond.JSONError(c, err) respond.JSONError(c, err)
return return
} }

View File

@ -5,6 +5,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"spark-server/internal/module/square/entity"
"spark-server/internal/pkg/errs" "spark-server/internal/pkg/errs"
"spark-server/internal/pkg/i18n" "spark-server/internal/pkg/i18n"
"spark-server/internal/pkg/reply" "spark-server/internal/pkg/reply"
@ -226,8 +227,15 @@ func registerSquareRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {
if deps.Square == nil { if deps.Square == nil {
return return
} }
h := squarehandler.New(deps.Square) for _, ch := range []string{"square", "squarenovel", "squaresteps", "squarehealth"} {
g := v1.Group("/square") registerSquareChannelRoutes(v1, deps, ch)
}
}
func registerSquareChannelRoutes(v1 *gin.RouterGroup, deps *EngineDeps, channel string) {
h := squarehandler.New(deps.Square, entity.Channel(channel))
g := v1.Group("/" + channel)
g.GET("/categories", h.Categories)
g.GET("/posts", h.Feed) g.GET("/posts", h.Feed)
g.POST("/posts", h.Publish) g.POST("/posts", h.Publish)
g.GET("/posts/:id", h.Get) g.GET("/posts/:id", h.Get)
@ -250,6 +258,8 @@ func registerMusicRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {
g := v1.Group("/music") g := v1.Group("/music")
// 客户端可读:无需登录(列表为运营配置的公开曲目元数据) // 客户端可读:无需登录(列表为运营配置的公开曲目元数据)
g.GET("/playlist", h.Playlist) g.GET("/playlist", h.Playlist)
g.GET("/playlist/grouped", h.PlaylistGrouped)
g.GET("/tracks/:id", h.GetTrack)
g.GET("/categories", h.Categories) g.GET("/categories", h.Categories)
g.GET("/settings", h.Settings) g.GET("/settings", h.Settings)
} }

View File

@ -0,0 +1,12 @@
-- 订阅套餐按 applicationId / 包名区分定价;空字符串 = 默认兜底。
-- 原 PRIMARY KEY (plan_id, lang) → (plan_id, lang, package_name)
ALTER TABLE t_subscription_plans
ADD COLUMN package_name VARCHAR(256) NOT NULL DEFAULT '' COMMENT 'applicationId/包名,空=默认兜底' AFTER lang;
ALTER TABLE t_subscription_plans
DROP PRIMARY KEY,
ADD PRIMARY KEY (plan_id, lang, package_name);
ALTER TABLE t_subscription_plans
ADD KEY idx_sub_plans_pkg (package_name, enabled, sort_order);

View File

@ -0,0 +1,24 @@
-- 多广场实例(channel)+ 分类筛选(category)。旧帖默认 channel=square、category 空。
ALTER TABLE t_square_posts
ADD COLUMN channel VARCHAR(32) NOT NULL DEFAULT 'square' COMMENT '广场实例:square/squarenovel/squaresteps/squarehealth' AFTER user_id,
ADD COLUMN category VARCHAR(32) NOT NULL DEFAULT '' COMMENT '内容标签:funny/beauty 等;空为未分类' AFTER channel;
ALTER TABLE t_square_posts
ADD KEY idx_channel_public_feed (channel, audit_status, visibility, id),
ADD KEY idx_channel_hot (channel, audit_status, visibility, like_count, id),
ADD KEY idx_channel_category (channel, category, id),
ADD KEY idx_channel_user (channel, user_id, id);
ALTER TABLE t_square_notices
ADD COLUMN channel VARCHAR(32) NOT NULL DEFAULT 'square' COMMENT '广场实例,与帖一致' AFTER to_user_id;
ALTER TABLE t_square_notices
ADD KEY idx_channel_to_user (channel, to_user_id, id);
ALTER TABLE t_square_notice_state
ADD COLUMN channel VARCHAR(32) NOT NULL DEFAULT 'square' COMMENT '广场实例' AFTER user_id;
ALTER TABLE t_square_notice_state
DROP PRIMARY KEY,
ADD PRIMARY KEY (user_id, channel);