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`。
## 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() {
const rows = await api(`/prices?appCode=${APP}`);
const list = Array.isArray(rows) ? rows : [];
const [rows, apps] = await Promise.all([
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 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 || '—'));
@ -2404,9 +2419,26 @@ const pages = {
const keys = Object.keys(m).filter((k) => m[k]);
return keys.length ? keys.map((k) => k + ':' + m[k]).join(' · ') : '—';
};
const pkgLabel = (p) => (p && String(p).trim()) ? p : '(默认·全包兜底)';
$('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">
<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)">
<select id="pfBilling" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
<option value="subscription">订阅 subscription</option>
@ -2419,18 +2451,23 @@ const pages = {
</div>
<div class="toolbar" style="flex-wrap:wrap;gap:8px;margin-top:8px">${skuInputs}</div>
<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>
${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>
</table>`;
$('pfPkgFilter').onchange = () => { window.__pricePkgFilter = $('pfPkgFilter').value; pages.prices(); };
$('pfReload').onclick = () => pages.prices();
$('pfSave').onclick = async () => {
const channelProductCodes = {};
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', {
method: 'POST',
body: JSON.stringify({
appCode: APP,
packageName,
name: $('pfName').value,
billingType: $('pfBilling').value,
price: $('pfPrice').value,
@ -3106,6 +3143,7 @@ const pages = {
if (!p) return;
$('spPlan').value = p.plan_id || 'week';
$('spLang').value = p.lang || 'zh-CN';
if ($('spPkg')) $('spPkg').value = p.package_name || '';
$('spTitle').value = p.title || '';
$('spSub').value = p.subtitle || '';
$('spPrice').value = p.price_display || '';
@ -3131,10 +3169,11 @@ const pages = {
};
$('content').innerHTML = `
<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">
<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>
<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="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">
@ -3150,9 +3189,9 @@ const pages = {
<button type="button" class="secondary" id="spReload">刷新</button>
</div>
<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>
${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>
</table>`;
const seed = items.find((p) => p.plan_id === 'month' && p.lang === 'zh-CN')
@ -3161,7 +3200,7 @@ const pages = {
if (seed) fillForm(seed);
else applyPlanDefaults();
$('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);
else {
$('spTitle').value = ''; $('spSub').value = ''; $('spPrice').value = '';
@ -3170,7 +3209,7 @@ const pages = {
}
};
$('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);
};
$('spFillDefaults').onclick = () => {
@ -3193,7 +3232,8 @@ const pages = {
const planId = $('spPlan').value;
const d = PLAN_DEFAULTS[planId] || PLAN_DEFAULTS.week;
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,
price_display: $('spPrice').value.trim() || d.price_display,
price_cents: parseInt($('spCents').value, 10) || d.price_cents,
@ -5346,61 +5386,37 @@ $('wlAdd').onclick = async () => {
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 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('');
const embedUrl = DOC + '/docs?from=popadmin&v=' + bust;
$('content').innerHTML = `
<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>
<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)}
${tableBlock('邮箱等共享客户端 API', doc.sharedClientApis)}
${tableBlock('公开 API', doc.publicApis)}`;
} catch (e) {
$('content').innerHTML = `<p class="error">${esc(e.message)}</p>`;
}
referrerpolicy="no-referrer-when-downgrade"></iframe>`;
},

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 全页面接入检查
## 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反馈页加筛选下拉
- 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 + 发帖声明宽高/体积软检测(默认可日志,可拒收)。
- 机器人配图:入库对象存储前压到最长边 ≤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)
| 功能 | 多广场 + 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.
// IAP product_id placeholders: health.wyrs365.com.vip.{week|month|quarter|year}
// (ASC App 6815533078 · Team 49S9UL5MN4 · Bundle health.wyrs365.com).
// PackageName 为空表示默认兜底;客户端传 packageName / X-Wyrs-Package 时优先精确匹配。
type SubscriptionPlan struct {
PlanID string `json:"plan_id"`
Lang string `json:"lang"`
PackageName string `json:"package_name,omitempty"`
Title string `json:"title"`
Subtitle string `json:"subtitle"`
PriceDisplay string `json:"price_display"`

View File

@ -22,8 +22,8 @@ type Repository interface {
InsertFeedback(ctx context.Context, f *entity.Feedback) error
ListFeedback(ctx context.Context, page, size int, authType string) ([]entity.Feedback, int64, error)
UpdateFeedbackStatus(ctx context.Context, id int64, status string) error
ListPlans(ctx context.Context, lang string) ([]entity.SubscriptionPlan, error)
ListPlansAdmin(ctx context.Context, lang string) ([]entity.SubscriptionPlan, error)
ListPlans(ctx context.Context, lang, packageName string) ([]entity.SubscriptionPlan, error)
ListPlansAdmin(ctx context.Context, lang, packageName string) ([]entity.SubscriptionPlan, error)
UpsertPlan(ctx context.Context, p *entity.SubscriptionPlan) error
GetPasswordHash(ctx context.Context, userID int64) (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)
}
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))
packageName = strings.TrimSpace(packageName)
headline, sub := subscriptionCopy(lang)
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 {
return &entity.SubscriptionConfig{Headline: headline, Subheadline: sub, Plans: plans}, nil
}
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 {
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
}
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 {
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 {
@ -217,6 +218,7 @@ func (s *Service) UpsertPlanAdmin(ctx context.Context, p *entity.SubscriptionPla
}
p.Lang = normalizeLang(p.Lang)
p.PlanID = strings.TrimSpace(p.PlanID)
p.PackageName = strings.TrimSpace(p.PackageName)
switch p.PlanID {
case "week", "month", "quarter", "year":
default:

View File

@ -71,7 +71,7 @@ func (s *stubClientRepo) UpdateFeedbackStatus(_ context.Context, id int64, statu
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)
for i := range s.plans {
p := &s.plans[i]
@ -79,19 +79,35 @@ func (s *stubClientRepo) ListPlans(_ context.Context, lang string) ([]entity.Sub
out = append(out, *p)
}
}
if packageName == "" {
return out, nil
}
func (s *stubClientRepo) ListPlansAdmin(_ context.Context, lang string) ([]entity.SubscriptionPlan, error) {
if lang == "" {
return s.plans, nil
exact := make([]entity.SubscriptionPlan, 0)
fallback := make([]entity.SubscriptionPlan, 0)
for _, p := range out {
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)
for i := range s.plans {
p := &s.plans[i]
if p.Lang == lang {
out = append(out, *p)
if lang != "" && p.Lang != lang {
continue
}
if packageName != "" && p.PackageName != packageName {
continue
}
out = append(out, *p)
}
return out, nil
}
@ -167,7 +183,7 @@ func TestLegalPlansFeedbackFallback(t *testing.T) {
if err != nil || legal.Privacy.Title == "" || legal.Terms.Title == "" {
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 {
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 {
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 {
t.Fatalf("plans %+v %v", adminPlans, err)
}
@ -315,7 +331,7 @@ func TestDeveloperAppsAdmin(t *testing.T) {
if err != nil || len(apps.Items) != 0 {
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 {
t.Fatalf("plans %+v %v", cfg, err)
}

View File

@ -3,6 +3,7 @@ package model
import (
"context"
"errors"
"strings"
"time"
"gorm.io/gorm"
@ -50,6 +51,7 @@ func (feedbackRow) TableName() string { return "t_feedback" }
type planRow struct {
PlanID string `gorm:"column:plan_id;primaryKey"`
Lang string `gorm:"column:lang;primaryKey"`
PackageName string `gorm:"column:package_name;primaryKey"`
Title string `gorm:"column:title"`
Subtitle string `gorm:"column:subtitle"`
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
}
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
err := r.db.WithContext(ctx).
Where("lang = ? AND enabled = 1", lang).
@ -179,14 +181,17 @@ func (r *Repo) ListPlans(ctx context.Context, lang string) ([]entity.Subscriptio
if err != nil {
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) {
q := r.db.WithContext(ctx).Order("lang ASC, sort_order ASC, plan_id ASC")
func (r *Repo) ListPlansAdmin(ctx context.Context, lang, packageName string) ([]entity.SubscriptionPlan, error) {
q := r.db.WithContext(ctx).Order("package_name ASC, lang ASC, sort_order ASC, plan_id ASC")
if lang != "" {
q = q.Where("lang = ?", lang)
}
if packageName != "" {
q = q.Where("package_name = ?", packageName)
}
var rows []planRow
if err := q.Find(&rows).Error; err != nil {
return nil, err
@ -199,7 +204,8 @@ func mapPlans(rows []planRow) []entity.SubscriptionPlan {
for i := range rows {
row := &rows[i]
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,
PeriodDays: row.PeriodDays, Badge: row.Badge, SortOrder: row.SortOrder,
Enabled: row.Enabled, ProductID: row.ProductID,
@ -208,12 +214,46 @@ func mapPlans(rows []planRow) []entity.SubscriptionPlan {
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 {
if p == nil {
return errors.New("client: nil subscription plan")
}
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,
PeriodDays: p.PeriodDays, Badge: p.Badge, SortOrder: p.SortOrder,
Enabled: p.Enabled, ProductID: p.ProductID, UpdatedAt: time.Now(),

View File

@ -37,4 +37,6 @@ type Catalog struct {
MomentVisibleDays []Item `json:"moment_visible_days"`
VerificationPurpose []Item `json:"verification_purpose"`
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),
VerificationPurpose: verificationPurpose(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" {
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" {
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.MailboxEventType, c.TCPCommand, c.EnvelopeCode, c.DeviceType, c.Language, c.UploadPurpose, c.SystemKind,
c.MomentKind, c.MomentVisibility, c.MomentStatus, c.MomentNoticeKind, c.MomentVisibleDays,
c.AppID,
c.AppID, c.SquareChannel, c.SquareCategory,
} {
if len(items) == 0 {
t.Fatal("empty set")

View File

@ -52,3 +52,18 @@ type Playlist struct {
Categories []Category `json:"categories"`
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)
}
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() {
return nil, ErrNotReady
}
@ -114,14 +120,15 @@ func (s *Service) Playlist(ctx context.Context, categoryID int64) (*entity.Playl
items []entity.Track
err error
)
if categoryID > 0 {
items, err = s.repo.ListEnabledByCategory(ctx, categoryID)
if q.CategoryID > 0 {
items, err = s.repo.ListEnabledByCategory(ctx, q.CategoryID)
} else {
items, err = s.repo.ListEnabled(ctx)
}
if err != nil {
return nil, err
}
items = filterRequireMember(items, q.RequireMember)
sec, err := s.repo.MemberPreviewSeconds(ctx)
if err != nil {
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
}
// 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) {
if !s.Ready() {
return nil, ErrNotReady

View File

@ -42,8 +42,24 @@ func (s *stubMusicRepo) ListEnabled(_ context.Context) ([]entity.Track, error) {
return out, nil
}
func (s *stubMusicRepo) ListEnabledByCategory(_ context.Context, _ int64) ([]entity.Track, error) {
return s.ListEnabled(context.Background())
func (s *stubMusicRepo) ListEnabledByCategory(_ context.Context, categoryID int64) ([]entity.Track, error) {
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) {
@ -182,7 +198,7 @@ func newTestService() (*Service, *stubMusicRepo) {
func TestServicePlaylistAndSettings(t *testing.T) {
svc, repo := newTestService()
_ = 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 {
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) {
svc, _ := newTestService()
ctx := context.Background()
@ -308,7 +391,7 @@ func TestNewServicePublicURLDefaults(t *testing.T) {
func TestServiceNotReady(t *testing.T) {
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)
}
}

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 {
ID int64
UserID int64
Channel Channel
Category Category
Kind Kind
Text string
Content []byte
@ -65,6 +67,7 @@ type Comment struct {
type Notice struct {
ID int64
ToUserID int64
Channel Channel
FromUserID int64
Kind NoticeKind
PostID int64
@ -73,13 +76,17 @@ type Notice struct {
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 {
UserID int64
Channel Channel
LastReadNoticeID int64
UpdatedAtMS int64
}
func DefaultNoticeState(userID int64) *NoticeState {
return &NoticeState{UserID: userID}
func DefaultNoticeState(userID int64, channel Channel) *NoticeState {
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).
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 {
return nil, errs.ErrUnauthorized
}
if ownerID <= 0 {
return nil, errs.ErrInvalidArgument
}
channel = normalizeChannel(channel)
if !channel.Valid() {
return nil, errs.ErrInvalidArgument
}
if ownerID != viewerID {
blocked, err := s.friends.BlockedEither(ctx, viewerID, ownerID)
if err != nil {
@ -28,7 +32,7 @@ func (s *Service) UserPosts(ctx context.Context, viewerID, ownerID, cursor int64
picked := make([]*entity.Post, 0, limit)
scan := cursor
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 {
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)
commentViews, commentN := filterComments(comments, users, remarks, full)
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,
LikeCount: likeN, CommentCount: commentN,
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"`
}
func (s *Service) Comment(ctx context.Context, userID, postID int64, in *CommentInput) (*View, error) {
post, err := s.visiblePost(ctx, userID, postID)
func (s *Service) Comment(ctx context.Context, userID, postID int64, channel entity.Channel, in *CommentInput) (*View, error) {
post, err := s.visiblePost(ctx, userID, postID, channel)
if err != nil {
return nil, err
}
@ -56,11 +56,11 @@ func (s *Service) Comment(ctx context.Context, userID, postID int64, in *Comment
return nil, err
}
_ = 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) {
post, err := s.visiblePost(ctx, userID, postID)
func (s *Service) DeleteComment(ctx context.Context, userID, postID, commentID int64, channel entity.Channel) (*View, error) {
post, err := s.visiblePost(ctx, userID, postID, channel)
if err != nil {
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 {
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) {
@ -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 {
seen := map[int64]struct{}{}
out := make([]*entity.Notice, 0, 2)
ch := post.Channel
if ch == "" {
ch = entity.ChannelSquare
}
add := func(to int64, kind entity.NoticeKind) {
if to <= 0 || to == from {
return
@ -106,7 +110,7 @@ func commentNotices(post *entity.Post, c *entity.Comment, from int64) []*entity.
}
seen[to] = struct{}{}
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 {

View File

@ -7,18 +7,26 @@ import (
"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 {
return errs.ErrUnauthorized
}
if postID <= 0 {
return errs.ErrInvalidArgument
}
post, err := s.repo.SoftDeletePost(ctx, userID, postID)
channel = normalizeChannel(channel)
post, err := s.repo.GetPost(ctx, postID)
if err != nil {
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
}

View File

@ -7,15 +7,33 @@ import (
"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 {
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)
scan := cursor
scan := q.Cursor
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 {
return nil, err
}
@ -55,3 +73,23 @@ func (s *Service) listResult(ctx context.Context, viewer int64, posts []*entity.
hasMore := len(posts) >= limit && limit > 0
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"
)
func (s *Service) Like(ctx context.Context, userID, postID int64) (*View, error) {
post, err := s.visiblePost(ctx, userID, postID)
func (s *Service) Like(ctx context.Context, userID, postID int64, channel entity.Channel) (*View, error) {
post, err := s.visiblePost(ctx, userID, postID, channel)
if err != nil {
return nil, err
}
@ -22,32 +22,36 @@ func (s *Service) Like(ctx context.Context, userID, postID int64) (*View, error)
return nil, err
}
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) {
if _, err := s.visiblePost(ctx, userID, postID); err != nil {
func (s *Service) Unlike(ctx context.Context, userID, postID int64, channel entity.Channel) (*View, error) {
if _, err := s.visiblePost(ctx, userID, postID, channel); err != nil {
return nil, err
}
if err := s.repo.RemoveLike(ctx, postID, userID); err != nil && !errors.Is(err, errs.ErrNotFound) {
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 {
return nil, errs.ErrUnauthorized
}
if postID <= 0 {
return nil, errs.ErrInvalidArgument
}
channel = normalizeChannel(channel)
post, err := s.repo.GetPost(ctx, postID)
if err != nil {
return nil, err
}
if normalizeChannel(post.Channel) != channel {
return nil, errs.ErrNotFound
}
vis, err := s.visFor(ctx, viewerID, []*entity.Post{post})
if err != nil {
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"`
}
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 {
return nil, errs.ErrUnauthorized
}
channel = normalizeChannel(channel)
if !channel.Valid() {
return nil, errs.ErrInvalidArgument
}
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 {
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
}
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 {
return errs.ErrUnauthorized
}
channel = normalizeChannel(channel)
if !channel.Valid() {
return errs.ErrInvalidArgument
}
cursor := int64(0)
if in != nil {
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
}
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 {
return err
}
if latest > 0 {
return s.repo.AdvanceNoticeRead(ctx, userID, latest)
return s.repo.AdvanceNoticeRead(ctx, userID, channel, latest)
}
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 {
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 {
return nil, err
}
latest, err := s.repo.LatestNoticeID(ctx, userID)
latest, err := s.repo.LatestNoticeID(ctx, userID, channel)
if err != nil {
return nil, err
}
@ -111,14 +123,20 @@ func (s *Service) notify(ctx context.Context, rows []*entity.Notice) error {
}
n.ID = id
n.CreatedAtMS = now
if n.Channel == "" {
n.Channel = entity.ChannelSquare
}
out = append(out, n)
}
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 {
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"`
AudienceUIDs []int64 `json:"audience_uids" validate:"omitempty,max=200,dive,gt=0" label:"field.square_audience"`
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 {
return nil, errs.ErrUnauthorized
}
channel = normalizeChannel(channel)
if !channel.Valid() {
return nil, errs.ErrInvalidArgument
}
if in == nil {
return nil, errs.ErrInvalidArgument
}
if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil {
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) {
return nil, errs.ErrRateLimited
}
@ -60,7 +68,8 @@ func (s *Service) Publish(ctx context.Context, userID int64, in *PublishInput) (
vis = entity.VisibilityPublic
}
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,
CreatedAtMS: now.UnixMilli(),
}

View File

@ -16,8 +16,8 @@ type Repository interface {
CreatePost(ctx context.Context, post *entity.Post, audience []int64) error
CreateBotPost(ctx context.Context, post *entity.Post) error
GetPost(ctx context.Context, id int64) (*entity.Post, error)
ListPublicFeed(ctx context.Context, cursor int64, limit int) ([]*entity.Post, error)
ListByAuthor(ctx context.Context, authorID, cursor int64, limit int) ([]*entity.Post, error)
ListPublicFeed(ctx context.Context, channel entity.Channel, q entity.FeedListQuery) ([]*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)
SoftDeleteExpiredBotPosts(ctx context.Context, nowMS int64, limit int) ([]*entity.Post, 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
SoftDeleteComment(ctx context.Context, id int64) error
CreateNotices(ctx context.Context, rows []*entity.Notice) error
ListNotices(ctx context.Context, userID, cursor int64, limit int) ([]*entity.Notice, error)
MarkNoticesRead(ctx context.Context, userID, cursor int64) error
GetNoticeState(ctx context.Context, userID int64) (*entity.NoticeState, error)
LatestNoticeID(ctx context.Context, userID int64) (int64, error)
AdvanceNoticeRead(ctx context.Context, userID, noticeID int64) error
ListNotices(ctx context.Context, userID int64, channel entity.Channel, cursor int64, limit int) ([]*entity.Notice, error)
MarkNoticesRead(ctx context.Context, userID int64, channel entity.Channel, cursor int64) error
GetNoticeState(ctx context.Context, userID int64, channel entity.Channel) (*entity.NoticeState, error)
LatestNoticeID(ctx context.Context, userID int64, channel entity.Channel) (int64, error)
AdvanceNoticeRead(ctx context.Context, userID int64, channel entity.Channel, noticeID int64) error
ListAllByAuthor(ctx context.Context, authorID int64) ([]*entity.Post, 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 {
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)
ctxA := ctxUser(t, a.ID, "10.0.0.1")
ctxB := ctxUser(t, b.ID, "10.0.0.2")
pub, err := svc.Publish(ctxA, a.ID, &PublishInput{Text: "hello square"})
if err != nil || pub.Text != "hello square" {
ch := entity.ChannelSquare
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)
}
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 {
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 {
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 {
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)
}
liked, err := svc.Like(ctxB, b.ID, pub.ID)
liked, err := svc.Like(ctxB, b.ID, pub.ID, ch)
if err != nil || !liked.Liked {
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 {
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 {
t.Fatalf("unread %+v", unread)
}
@ -117,16 +118,17 @@ func TestSquareExcludeIncludeAndUserPosts(t *testing.T) {
if err != nil {
t.Fatal(err)
}
ch := entity.ChannelSquare
ctxA := ctxUser(t, a.ID, "10.0.0.1")
ctxB := ctxUser(t, b.ID, "10.0.0.2")
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},
})
if err != nil {
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 {
t.Fatal(err)
}
@ -135,17 +137,17 @@ func TestSquareExcludeIncludeAndUserPosts(t *testing.T) {
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 {
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},
})
if err != nil {
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 {
t.Fatal(err)
}
@ -154,7 +156,7 @@ func TestSquareExcludeIncludeAndUserPosts(t *testing.T) {
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 {
t.Fatal(err)
}
@ -170,7 +172,7 @@ func TestSquareExcludeIncludeAndUserPosts(t *testing.T) {
if !sawIncl {
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)
}
}
@ -179,59 +181,60 @@ func TestSquareUnlikeCommentDeleteAndPurge(t *testing.T) {
svc, friends, users := testSquare(t)
a, b := twoAccounts(t, users)
becomeFriends(t, friends, a, b)
ch := entity.ChannelSquare
ctxA := ctxUser(t, a.ID, "10.0.0.1")
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 {
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)
}
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 {
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 {
t.Fatalf("comment %+v %v", commented, err)
}
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 {
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)
}
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 {
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)
}
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 {
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"}},
})
if err != nil {
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)
}
unread, err := svc.NoticeUnread(ctxA, a.ID)
unread, err := svc.NoticeUnread(ctxA, a.ID, ch)
if err != nil || unread.HasNew {
t.Fatalf("after read %+v %v", unread, err)
}
if err := svc.PurgeUser(ctxA, a.ID); err != nil {
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)
}
}
@ -240,10 +243,49 @@ func TestSquareImageKeyRejectedIfChatPrefix(t *testing.T) {
svc, _, users := testSquare(t)
a, _ := twoAccounts(t, users)
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"}},
})
if err == nil {
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 {
ID int64 `json:"id"`
Channel entity.Channel `json:"channel"`
Category entity.Category `json:"category,omitempty"`
User UserCard `json:"user"`
Kind entity.Kind `json:"kind"`
Text string `json:"text"`
@ -49,17 +51,21 @@ type ListResult struct {
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 {
return nil, errs.ErrUnauthorized
}
if postID <= 0 {
return nil, errs.ErrInvalidArgument
}
channel = normalizeChannel(channel)
post, err := s.repo.GetPost(ctx, postID)
if err != nil {
return nil, err
}
if normalizeChannel(post.Channel) != channel {
return nil, errs.ErrNotFound
}
vis, err := s.visFor(ctx, viewerID, []*entity.Post{post})
if err != nil {
return nil, err

View File

@ -2,6 +2,7 @@ package model
import (
"context"
"math"
"sort"
"sync"
"time"
@ -15,6 +16,11 @@ type likeKey struct {
UserID int64
}
type noticeStateKey struct {
UserID int64
Channel entity.Channel
}
// MemoryRepo is an in-process square store for tests and empty-DSN bring-up.
type MemoryRepo struct {
mu sync.Mutex
@ -23,7 +29,7 @@ type MemoryRepo struct {
likes map[likeKey]*entity.Like
comments map[int64]*entity.Comment
notices map[int64]*entity.Notice
noticeState map[int64]*entity.NoticeState
noticeState map[noticeStateKey]*entity.NoticeState
botLinks map[string]int64 // url -> post_id
}
@ -34,11 +40,18 @@ func NewMemoryRepo() *MemoryRepo {
likes: make(map[likeKey]*entity.Like),
comments: make(map[int64]*entity.Comment),
notices: make(map[int64]*entity.Notice),
noticeState: make(map[int64]*entity.NoticeState),
noticeState: make(map[noticeStateKey]*entity.NoticeState),
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) {
m.mu.Lock()
defer m.mu.Unlock()
@ -49,14 +62,15 @@ func (m *MemoryRepo) GetPost(_ context.Context, id int64) (*entity.Post, error)
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()
defer m.mu.Unlock()
ch := memChannel(channel)
allow := toSet(authorIDs)
nowMS := time.Now().UnixMilli()
out := make([]*entity.Post, 0)
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
}
if row.Expired(nowMS) {
@ -74,8 +88,8 @@ func (m *MemoryRepo) ListByAuthors(_ context.Context, authorIDs []int64, cursor
return out, nil
}
func (m *MemoryRepo) ListByAuthor(ctx context.Context, authorID, cursor int64, limit int) ([]*entity.Post, error) {
return m.ListByAuthors(ctx, []int64{authorID}, cursor, limit)
func (m *MemoryRepo) ListByAuthor(ctx context.Context, channel entity.Channel, authorID, cursor int64, limit int) ([]*entity.Post, error) {
return m.ListByAuthors(ctx, channel, []int64{authorID}, cursor, limit)
}
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
}
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()
defer m.mu.Unlock()
nowMS := time.Now().UnixMilli()
ch := memChannel(channel)
out := make([]*entity.Post, 0)
for _, row := range m.posts {
if row.Status != entity.StatusNormal || row.AuditStatus != entity.AuditApproved {
continue
}
if memChannel(row.Channel) != ch {
continue
}
if row.Visibility != entity.VisibilityPublic && row.Visibility != entity.VisibilityExclude {
continue
}
if row.Expired(nowMS) {
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
}
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 })
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
}
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()
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
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()
defer m.mu.Unlock()
ch := memChannel(channel)
var latest int64
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
}
}
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()
defer m.mu.Unlock()
ch := memChannel(channel)
out := make([]*entity.Notice, 0)
for _, n := range m.notices {
if n.ToUserID != userID {
if n.ToUserID != userID || memChannel(n.Channel) != ch {
continue
}
if cursor > 0 && n.ID >= cursor {
@ -223,6 +312,9 @@ func clonePost(p *entity.Post) *entity.Post {
if p.Content != nil {
cp.Content = append([]byte(nil), p.Content...)
}
if cp.Channel == "" {
cp.Channel = entity.ChannelSquare
}
return &cp
}

View File

@ -78,7 +78,11 @@ func (m *MemoryRepo) PurgeUser(_ context.Context, userID int64) error {
}
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 {
delete(m.posts, 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 {
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 {
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
}
cp := *n
if cp.Channel == "" {
cp.Channel = entity.ChannelSquare
}
m.notices[n.ID] = &cp
}
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()
defer m.mu.Unlock()
ch := memChannel(channel)
now := time.Now().UnixMilli()
for _, n := range m.notices {
if n.ToUserID != userID || n.ReadAtMS > 0 {
if n.ToUserID != userID || memChannel(n.Channel) != ch || n.ReadAtMS > 0 {
continue
}
if cursor > 0 && n.ID > cursor {
@ -188,16 +196,18 @@ func (m *MemoryRepo) MarkNoticesRead(_ context.Context, userID, cursor int64) er
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 {
return errs.ErrInvalidArgument
}
m.mu.Lock()
defer m.mu.Unlock()
cur := m.noticeState[userID]
ch := memChannel(channel)
key := noticeStateKey{UserID: userID, Channel: ch}
cur := m.noticeState[key]
if cur == nil {
cur = entity.DefaultNoticeState(userID)
m.noticeState[userID] = cur
cur = entity.DefaultNoticeState(userID, ch)
m.noticeState[key] = cur
}
if cur.LastReadNoticeID >= noticeID {
return nil

View File

@ -10,6 +10,8 @@ import (
type Post struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement:false;comment:Snowflake 帖 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视频"`
Text string `gorm:"column:text;comment:正文"`
Content []byte `gorm:"column:content;type:varbinary(16384);comment:序列化 JSON 字节"`
@ -36,8 +38,13 @@ func (r *Post) Entity() *entity.Post {
if r == nil {
return nil
}
ch := entity.Channel(r.Channel)
if ch == "" {
ch = entity.ChannelSquare
}
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},
Visibility: entity.Visibility(r.Visibility), AuditStatus: entity.AuditStatus(r.AuditStatus),
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 {
return nil
}
ch := string(p.Channel)
if ch == "" {
ch = string(entity.ChannelSquare)
}
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,
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,
@ -152,6 +164,7 @@ func commentFrom(c *entity.Comment) *Comment {
type Notice struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement:false;comment:Snowflake 通知 id"`
ToUserID int64 `gorm:"column:to_user_id;comment:接收人"`
Channel string `gorm:"column:channel;comment:广场实例"`
FromUserID int64 `gorm:"column:from_user_id;comment:发起人"`
Kind int16 `gorm:"column:kind;comment:1赞 2评论 3回复"`
PostID int64 `gorm:"column:post_id;comment:帖 id"`
@ -166,8 +179,12 @@ func (r *Notice) Entity() *entity.Notice {
if r == nil {
return nil
}
ch := entity.Channel(r.Channel)
if ch == "" {
ch = entity.ChannelSquare
}
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(),
}
if r.ReadAt != nil {
@ -179,6 +196,7 @@ func (r *Notice) Entity() *entity.Notice {
// NoticeState maps square_notice_state.
type NoticeState struct {
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:已读水位"`
UpdatedAt time.Time `gorm:"column:updated_at;comment:更新时间"`
}
@ -189,7 +207,11 @@ func (r *NoticeState) Entity() *entity.NoticeState {
if r == nil {
return nil
}
ch := entity.Channel(r.Channel)
if ch == "" {
ch = entity.ChannelSquare
}
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 (
"context"
"errors"
"math"
"sort"
"time"
"gorm.io/gorm"
@ -25,7 +27,7 @@ func (m *Repo) GetPost(ctx context.Context, id int64) (*entity.Post, error) {
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 {
return []*entity.Post{}, nil
}
@ -33,7 +35,8 @@ func (m *Repo) ListByAuthors(ctx context.Context, authorIDs []int64, cursor int6
if err != nil {
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 {
q = q.Where("id < ?", cursor)
}
@ -44,31 +47,113 @@ func (m *Repo) ListByAuthors(ctx context.Context, authorIDs []int64, cursor int6
return collectPosts(rows), nil
}
func (m *Repo) ListByAuthor(ctx context.Context, authorID, cursor int64, limit int) ([]*entity.Post, error) {
return m.ListByAuthors(ctx, []int64{authorID}, cursor, limit)
func (m *Repo) ListByAuthor(ctx context.Context, channel entity.Channel, authorID, cursor int64, limit int) ([]*entity.Post, error) {
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)
if err != nil {
return nil, err
}
q := db.Where(
"audit_status = ? AND status = ? AND visibility IN ? AND "+notExpiredSQL,
entity.AuditApproved, entity.StatusNormal,
ch := normalizeChannel(channel)
base := db.Where(
"channel = ? AND audit_status = ? AND status = ? AND visibility IN ? AND "+notExpiredSQL,
ch, entity.AuditApproved, entity.StatusNormal,
[]int16{int16(entity.VisibilityPublic), int16(entity.VisibilityExclude)},
time.Now(),
)
if cursor > 0 {
q = q.Where("id < ?", cursor)
if q.Category.IsTag() {
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
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 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) {
out := make(map[int64][]int64)
if len(postIDs) == 0 {
@ -140,28 +225,30 @@ func (m *Repo) GetComment(ctx context.Context, id int64) (*entity.Comment, error
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)
if err != nil {
return nil, err
}
ch := normalizeChannel(channel)
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) {
return entity.DefaultNoticeState(userID), nil
return entity.DefaultNoticeState(userID, entity.Channel(ch)), nil
}
return nil, mapDBErr(err)
}
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)
if err != nil {
return 0, err
}
ch := normalizeChannel(channel)
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) {
return 0, nil
}
@ -170,12 +257,13 @@ func (m *Repo) LatestNoticeID(ctx context.Context, userID int64) (int64, error)
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)
if err != nil {
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 {
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()
for _, n := range rows {
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,
}
if n.CreatedAtMS > 0 {
@ -176,19 +177,20 @@ func (m *Repo) CreateNotices(ctx context.Context, rows []*entity.Notice) error {
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)
if err != nil {
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 {
q = q.Where("id <= ?", cursor)
}
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 {
return errs.ErrInvalidArgument
}
@ -196,7 +198,8 @@ func (m *Repo) AdvanceNoticeRead(ctx context.Context, userID, noticeID int64) er
if err != nil {
return err
}
cur, err := m.GetNoticeState(ctx, userID)
ch := entity.Channel(normalizeChannel(channel))
cur, err := m.GetNoticeState(ctx, userID, ch)
if err != nil {
return err
}
@ -206,11 +209,11 @@ func (m *Repo) AdvanceNoticeRead(ctx context.Context, userID, noticeID int64) er
cur.LastReadNoticeID = noticeID
cur.UpdatedAtMS = time.Now().UnixMilli()
row := NoticeState{
UserID: cur.UserID, LastReadNoticeID: cur.LastReadNoticeID,
UserID: cur.UserID, Channel: string(ch), LastReadNoticeID: cur.LastReadNoticeID,
UpdatedAt: time.UnixMilli(cur.UpdatedAtMS),
}
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"}),
}).Create(&row).Error)
}

View File

@ -20,6 +20,9 @@ func init() {
KeyFieldSquareNoticeCursor: {
ZhCN: "游标", En: "Cursor", ZhTW: "遊標",
},
KeyFieldSquareCategory: {
ZhCN: "分类", En: "Category", ZhTW: "分類",
},
KeySquareTextInvalid: {
ZhCN: "正文最多1000字", En: "Text must be at most 1000 characters", ZhTW: "正文最多1000字",
},
@ -47,6 +50,9 @@ func init() {
KeySquareReplyInvalid: {
ZhCN: "回复目标无效", En: "Invalid reply target", ZhTW: "回覆目標無效",
},
KeySquareCategoryInvalid: {
ZhCN: "分类无效", En: "Invalid category", ZhTW: "分類無效",
},
"enum.square_kind.text": {ZhCN: "纯文", En: "Text", ZhTW: "純文"},
"enum.square_kind.image": {ZhCN: "图片", En: "Image", 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.comment": {ZhCN: "评论", En: "Comment", 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_video": {ZhCN: "广场视频", En: "Square video", ZhTW: "广场影片"},
})

View File

@ -161,6 +161,7 @@ const (
KeyFieldSquareComment = "field.square_comment"
KeyFieldSquareReply = "field.square_reply"
KeyFieldSquareNoticeCursor = "field.square_notice_cursor"
KeyFieldSquareCategory = "field.square_category"
KeySquareTextInvalid = "validate.square.text_invalid"
KeySquareVisibilityInvalid = "validate.square.visibility_invalid"
KeySquareAudienceInvalid = "validate.square.audience_invalid"
@ -170,6 +171,7 @@ const (
KeySquareCommentRequired = "validate.square.comment_required"
KeySquareCommentInvalid = "validate.square.comment_invalid"
KeySquareReplyInvalid = "validate.square.reply_invalid"
KeySquareCategoryInvalid = "validate.square.category_invalid"
KeyFieldVerificationTarget = "field.verification_target"
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":
return true
default:
if publicMusicPath(path) {
return true
}
// Admin routes use AdminAuth (Java popadmin JWT), not client Bearer.
if adminPath(path) {
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) {
if len(h) < len(bearerPrefix) || !strings.EqualFold(h[:len(bearerPrefix)], bearerPrefix) {
return "", false

View File

@ -1,4 +1,4 @@
openapi: 3.0.3
openapi: 3.0.3
info:
title: Spark IM API
version: 0.1.0
@ -65,7 +65,7 @@ info:
| 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-App-ID` | Header | 可选 | 若传,须与 JWT **`aid`(app_id)** 一致。 |
| `X-Language` | Header | 可选 | `zh-CN` / `en` / `zh-TW`。优先于 `Accept-Language`。缺省 `zh-CN`。决定 `msg` 语言。 |
@ -373,9 +373,16 @@ tags:
- name: favorites
- name: moments
- name: square
description: |
多广场:路径 `/v1/{square_channel}/…`,channel=`square|squarenovel|squaresteps|squarehealth`。
分类 Tab:`GET …/categories`;Feed 用 query `category`(推荐/最新/热门/附近/搞笑/美女)。
- name: music
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`。
- name: admin-music
description: |
@ -821,9 +828,21 @@ paths:
description: |
返回 headline/subheadline 与 week/month/quarter/year 套餐卡片(含 IAP product_id)。
公开接口;无库数据时回退默认四档。
支持按 **包名 / applicationId** 筛选:`packageName`(或 `package_name`)Query,或 Header `X-Wyrs-Package`。
有该包精确配置则返回之;否则回退 `package_name` 为空的默认档。
operationId: subscriptionPlans
parameters:
- $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:
"200":
description: data.plans
@ -1837,21 +1856,54 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
/v1/square/posts:
/v1/{square_channel}/categories:
get:
tags: [square]
summary: 全站广场 Feed
summary: 广场分类 Tab
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
parameters:
- $ref: "#/components/parameters/SquareChannel"
- in: query
name: category
schema:
$ref: "#/components/schemas/SquareCategory"
description: 缺省 recommend
- in: query
name: cursor
schema: { type: integer, format: int64, default: 0 }
- in: query
name: limit
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:
"200":
description: 统一 envelope
@ -1865,13 +1917,16 @@ paths:
description: |
内容与朋友圈相同。媒体先 `POST /v1/files`(`square_image` / `square_video`)再填 key。
`visibility`:0 全站公开 1 仅自己 2 白名单 3 黑名单。2/3 需 `audience_uids`(双向好友,最多 200)。
可选 `category` 内容标签:`funny` / `beauty`(不可传 feed 模式值)。
operationId: publishSquarePost
parameters:
- $ref: "#/components/parameters/SquareChannel"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/PublishMomentRequest"
$ref: "#/components/schemas/PublishSquareRequest"
responses:
"200":
description: 统一 envelope
@ -1879,12 +1934,13 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
/v1/square/posts/{id}:
/v1/{square_channel}/posts/{id}:
get:
tags: [square]
summary: 单条广场帖
operationId: getSquarePost
parameters:
- $ref: "#/components/parameters/SquareChannel"
- $ref: "#/components/parameters/SquarePostID"
responses:
"200":
@ -1898,6 +1954,7 @@ paths:
summary: 删除自己的广场帖
operationId: deleteSquarePost
parameters:
- $ref: "#/components/parameters/SquareChannel"
- $ref: "#/components/parameters/SquarePostID"
responses:
"200":
@ -1906,12 +1963,13 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
/v1/square/posts/{id}/like:
/v1/{square_channel}/posts/{id}/like:
put:
tags: [square]
summary: 点赞
operationId: likeSquarePost
parameters:
- $ref: "#/components/parameters/SquareChannel"
- $ref: "#/components/parameters/SquarePostID"
responses:
"200":
@ -1925,6 +1983,7 @@ paths:
summary: 取消赞
operationId: unlikeSquarePost
parameters:
- $ref: "#/components/parameters/SquareChannel"
- $ref: "#/components/parameters/SquarePostID"
responses:
"200":
@ -1933,12 +1992,13 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
/v1/square/posts/{id}/comments:
/v1/{square_channel}/posts/{id}/comments:
post:
tags: [square]
summary: 评论或回复
operationId: commentSquarePost
parameters:
- $ref: "#/components/parameters/SquareChannel"
- $ref: "#/components/parameters/SquarePostID"
requestBody:
required: true
@ -1953,12 +2013,13 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
/v1/square/posts/{id}/comments/{cid}:
/v1/{square_channel}/posts/{id}/comments/{cid}:
delete:
tags: [square]
summary: 删除评论
operationId: deleteSquareComment
parameters:
- $ref: "#/components/parameters/SquareChannel"
- $ref: "#/components/parameters/SquarePostID"
- name: cid
in: path
@ -1971,13 +2032,14 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
/v1/square/users/{uid}/posts:
/v1/{square_channel}/users/{uid}/posts:
get:
tags: [square]
summary: 某用户广场帖
description: 按当前浏览者与帖可见性、拉黑过滤;看自己可见私密与白名单帖。
description: 按当前浏览者与帖可见性、拉黑过滤;看自己可见私密与白名单帖。仅返回该 channel 下的帖。
operationId: listSquareUserPosts
parameters:
- $ref: "#/components/parameters/SquareChannel"
- $ref: "#/components/parameters/FriendUID"
- in: query
name: cursor
@ -1992,12 +2054,13 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
/v1/square/notices:
/v1/{square_channel}/notices:
get:
tags: [square]
summary: 赞评通知列表
operationId: listSquareNotices
parameters:
- $ref: "#/components/parameters/SquareChannel"
- in: query
name: cursor
schema: { type: integer, format: int64, default: 0 }
@ -2011,11 +2074,13 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
/v1/square/notices/unread:
/v1/{square_channel}/notices/unread:
get:
tags: [square]
summary: 广场互动未读角标
operationId: squareNoticeUnread
parameters:
- $ref: "#/components/parameters/SquareChannel"
responses:
"200":
description: 统一 envelope
@ -2023,11 +2088,13 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/Envelope"
/v1/square/notices/read:
/v1/{square_channel}/notices/read:
post:
tags: [square]
summary: 通知标已读
operationId: readSquareNotices
parameters:
- $ref: "#/components/parameters/SquareChannel"
requestBody:
content:
application/json:
@ -2050,6 +2117,7 @@ paths:
封面/背景图/音频为 sparkr 公网 URL(`https://sparkr.wyrs365.com/spark/perm/music/...`)。
客户端应按 `requireMember` 与 `memberPreviewSeconds` 控制试听;`backgroundUrl` 可空。
传 `categoryId` 时仅返回归属该分类或其子分类的曲目(多对多)。
传 `requireMember=true|false` 可仅返回会员曲或免费曲(对齐欢休 VIP 分区筛选)。
operationId: getMusicPlaylist
security: []
parameters:
@ -2058,6 +2126,11 @@ paths:
required: false
schema: { type: integer, format: int64, minimum: 0 }
description: 按分类筛选;0 或不传=全部上架曲目
- name: requireMember
in: query
required: false
schema: { type: boolean }
description: true=仅会员曲;false=仅免费曲;不传=全部
responses:
"200":
description: 统一 envelope,`data` 为 MusicPlaylist
@ -2070,6 +2143,61 @@ paths:
properties:
data:
$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:
get:
tags: [music]
@ -3327,6 +3455,23 @@ components:
type: integer
enum: [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:
type: object
properties:
@ -3516,6 +3661,25 @@ components:
$ref: "#/components/schemas/MusicCategory"
description: 启用中的分类树
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:
type: integer
enum: [1, 2, 3, 4, 5, 6, 7, 8, 9]
@ -4331,6 +4495,13 @@ components:
type: integer
format: int64
description: 朋友圈 Snowflake post_id。
SquareChannel:
in: path
name: square_channel
required: true
schema:
$ref: "#/components/schemas/SquareChannel"
description: 广场实例 square / squarenovel / squaresteps / squarehealth
SquarePostID:
in: path
name: id

View File

@ -61,7 +61,11 @@ func (h *ClientHandler) ListPlans(c *gin.Context) {
return
}
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 {
Fail(c, http.StatusInternalServerError, err.Error())
return

View File

@ -1,6 +1,8 @@
package clienthandler
import (
"strings"
"github.com/gin-gonic/gin"
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) {
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 {
respond.JSONError(c, err)
return

View File

@ -20,21 +20,60 @@ func New(svc *musiclogic.Service) *Handler {
// Playlist GET /v1/music/playlist — public curated list + category tree.
// 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) {
if h == nil || h.svc == nil || !h.svc.Ready() {
respond.JSONError(c, errs.ErrAvailable)
return
}
var categoryID int64
if raw := c.Query("categoryId"); raw != "" {
n, err := strconv.ParseInt(raw, 10, 64)
if err != nil || n < 0 {
respond.JSONError(c, errs.ErrInvalidArgument.WithMsg("categoryId 无效"))
q, err := parsePlaylistQuery(c)
if err != nil {
respond.JSONError(c, err)
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 {
respond.JSONError(c, err)
return
@ -69,3 +108,27 @@ func (h *Handler) Settings(c *gin.Context) {
}
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"
"spark-server/internal/module/square/entity"
squarelogic "spark-server/internal/module/square/logic"
"spark-server/internal/pkg/errs"
"spark-server/internal/pkg/reqctx"
"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 {
square *squarelogic.Service
channel entity.Channel
}
func New(square *squarelogic.Service) *Handler {
return &Handler{square: square}
func New(square *squarelogic.Service, channel entity.Channel) *Handler {
if channel == "" {
channel = entity.ChannelSquare
}
return &Handler{square: square, channel: channel}
}
func currentUID(c *gin.Context) (int64, bool) {
@ -43,12 +48,32 @@ func queryInt(c *gin.Context, name string) int64 {
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) {
uid, ok := currentUID(c)
if !ok {
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 {
respond.JSONError(c, err)
return
@ -66,7 +91,7 @@ func (h *Handler) Publish(c *gin.Context) {
respond.JSONError(c, err)
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 {
respond.JSONError(c, err)
return
@ -83,7 +108,7 @@ func (h *Handler) UserPosts(c *gin.Context) {
if !ok {
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 {
respond.JSONError(c, err)
return
@ -100,7 +125,7 @@ func (h *Handler) Get(c *gin.Context) {
if !ok {
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 {
respond.JSONError(c, err)
return
@ -117,7 +142,7 @@ func (h *Handler) Delete(c *gin.Context) {
if !ok {
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)
return
}
@ -133,7 +158,7 @@ func (h *Handler) Like(c *gin.Context) {
if !ok {
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 {
respond.JSONError(c, err)
return
@ -150,7 +175,7 @@ func (h *Handler) Unlike(c *gin.Context) {
if !ok {
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 {
respond.JSONError(c, err)
return
@ -172,7 +197,7 @@ func (h *Handler) CreateComment(c *gin.Context) {
respond.JSONError(c, err)
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 {
respond.JSONError(c, err)
return
@ -193,7 +218,7 @@ func (h *Handler) DeleteComment(c *gin.Context) {
if !ok {
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 {
respond.JSONError(c, err)
return
@ -206,7 +231,7 @@ func (h *Handler) ListNotices(c *gin.Context) {
if !ok {
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 {
respond.JSONError(c, err)
return
@ -219,7 +244,7 @@ func (h *Handler) NoticeUnread(c *gin.Context) {
if !ok {
return
}
out, err := h.square.NoticeUnread(c.Request.Context(), uid)
out, err := h.square.NoticeUnread(c.Request.Context(), uid, h.channel)
if err != nil {
respond.JSONError(c, err)
return
@ -237,7 +262,7 @@ func (h *Handler) ReadNotices(c *gin.Context) {
respond.JSONError(c, err)
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)
return
}

View File

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