Compare commits
2 Commits
7f2567cf15
...
5d05eb0804
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d05eb0804 | |||
| 53426af122 |
@ -62,3 +62,18 @@ sudo nginx -t && sudo systemctl reload nginx
|
|||||||
```
|
```
|
||||||
|
|
||||||
确保 spark-server 容器监听 `127.0.0.1:18080`,且 `admin.jwt_secret` 与 Java `WYRS_ADMIN_JWT_SECRET` 一致。登录、仪表盘、configs/通讯录/相册仍走 Java `:8090`。
|
确保 spark-server 容器监听 `127.0.0.1:18080`,且 `admin.jwt_secret` 与 Java `WYRS_ADMIN_JWT_SECRET` 一致。登录、仪表盘、configs/通讯录/相册仍走 Java `:8090`。
|
||||||
|
|
||||||
|
## popadmin 世界地图底图(IP 属地日活)
|
||||||
|
|
||||||
|
`popadmin-sg-panels.js` 请求 `GET /admin/geo/world.json`(echarts GeoJSON)。必须在 nginx 里单独 `alias` 静态文件,否则会被 `location /` 302 成 HTML,地图报 `Unexpected token '<'`。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 静态文件放到服务器
|
||||||
|
sudo mkdir -p /opt/wyrs-admin/static-override/geo
|
||||||
|
# 从 Aliyun SG GO/scripts/popadmin-static/geo/world.json 上传
|
||||||
|
sudo cp world.json /opt/wyrs-admin/static-override/geo/world.json
|
||||||
|
# 把 host/popadmin-world-geo.snippet.conf 插入 popadmin-wyrs.conf 的 location / 之前
|
||||||
|
# 或执行:Aliyun SG GO/scripts/popadmin-static/patch_world_geo.sh
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
curl -skI https://popadmin.wyrs365.com/admin/geo/world.json # 期望 200 application/json
|
||||||
|
```
|
||||||
|
|||||||
14
deploy/nginx/host/popadmin-world-geo.snippet.conf
Normal file
14
deploy/nginx/host/popadmin-world-geo.snippet.conf
Normal 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
|
||||||
25
deploy/popadmin/apply_api_doc_prod.sh
Normal file
25
deploy/popadmin/apply_api_doc_prod.sh
Normal 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
|
||||||
1
deploy/popadmin/geo/world.json
Normal file
1
deploy/popadmin/geo/world.json
Normal file
File diff suppressed because one or more lines are too long
63
deploy/popadmin/hot_openapi_override.sh
Normal file
63
deploy/popadmin/hot_openapi_override.sh
Normal 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
|
||||||
112
deploy/popadmin/patch_api_doc.py
Normal file
112
deploy/popadmin/patch_api_doc.py
Normal 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())
|
||||||
76
deploy/popadmin/patch_world_geo.sh
Normal file
76
deploy/popadmin/patch_world_geo.sh
Normal 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
|
||||||
@ -2394,8 +2394,23 @@ const pages = {
|
|||||||
|
|
||||||
|
|
||||||
async prices() {
|
async prices() {
|
||||||
const rows = await api(`/prices?appCode=${APP}`);
|
const [rows, apps] = await Promise.all([
|
||||||
const list = Array.isArray(rows) ? rows : [];
|
api(`/prices?appCode=${APP}`),
|
||||||
|
api('/apps').catch(() => []),
|
||||||
|
]);
|
||||||
|
const all = Array.isArray(rows) ? rows : [];
|
||||||
|
const popApp = (Array.isArray(apps) ? apps : []).find((a) => a.code === APP) || {};
|
||||||
|
const pkgOptions = Array.isArray(popApp.packageNames) ? popApp.packageNames.slice() : [];
|
||||||
|
// 常见火花包名兜底(注册表缺省时仍可手填)
|
||||||
|
['com.jspp.pop', 'spark.wyrs.com', 'pop.wyrs.com'].forEach((p) => {
|
||||||
|
if (!pkgOptions.includes(p)) pkgOptions.push(p);
|
||||||
|
});
|
||||||
|
const savedFilter = (typeof window.__pricePkgFilter === 'string') ? window.__pricePkgFilter : '';
|
||||||
|
const list = (() => {
|
||||||
|
if (!savedFilter) return all;
|
||||||
|
if (savedFilter === '__default__') return all.filter((p) => !p.packageName);
|
||||||
|
return all.filter((p) => (p.packageName || '') === savedFilter);
|
||||||
|
})();
|
||||||
const CHANNELS = ['apple','google','paypal','pingpong','wechat','alipay','huawei','xiaomi','vivo','oppo','honor'];
|
const CHANNELS = ['apple','google','paypal','pingpong','wechat','alipay','huawei','xiaomi','vivo','oppo','honor'];
|
||||||
const skuInputs = CHANNELS.map((k) => `<input id="pfSku_${k}" placeholder="${k} 商品码" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:140px">`).join('');
|
const skuInputs = CHANNELS.map((k) => `<input id="pfSku_${k}" placeholder="${k} 商品码" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:140px">`).join('');
|
||||||
const billingLabel = (bt) => bt === 'subscription' ? '订阅' : (bt === 'one_time' ? '一次性' : (bt || '—'));
|
const billingLabel = (bt) => bt === 'subscription' ? '订阅' : (bt === 'one_time' ? '一次性' : (bt || '—'));
|
||||||
@ -2404,9 +2419,26 @@ const pages = {
|
|||||||
const keys = Object.keys(m).filter((k) => m[k]);
|
const keys = Object.keys(m).filter((k) => m[k]);
|
||||||
return keys.length ? keys.map((k) => k + ':' + m[k]).join(' · ') : '—';
|
return keys.length ? keys.map((k) => k + ':' + m[k]).join(' · ') : '—';
|
||||||
};
|
};
|
||||||
|
const pkgLabel = (p) => (p && String(p).trim()) ? p : '(默认·全包兜底)';
|
||||||
$('content').innerHTML = `
|
$('content').innerHTML = `
|
||||||
<div class="note">会员价格(火花)· 区分<strong>订阅</strong>/<strong>一次性</strong>;各渠道商品码非密钥。客户端 <code>GET /api/client/spark/prices</code> · 渠道密钥见「支付渠道」</div>
|
<div class="note">会员价格(火花)· 可按 <strong>applicationId / 包名</strong> 配置不同价格;包名为空表示该 App 默认兜底。客户端 <code>GET /api/client/spark/prices?packageName=...</code> 或 Header <code>X-Wyrs-Package</code> · 有精确包名价则用之,否则回退默认。</div>
|
||||||
|
<div class="toolbar" style="flex-wrap:wrap;gap:8px;align-items:center">
|
||||||
|
<label>筛选包名
|
||||||
|
<select id="pfPkgFilter" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
|
||||||
|
<option value="">全部</option>
|
||||||
|
<option value="__default__"${savedFilter==='__default__'?' selected':''}>仅默认兜底</option>
|
||||||
|
${pkgOptions.map((p) => `<option value="${esc(p)}"${savedFilter===p?' selected':''}>${esc(p)}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<span class="note" style="margin:0">共 ${esc(list.length)} / ${esc(all.length)} 条</span>
|
||||||
|
<button type="button" class="secondary" id="pfReload">刷新</button>
|
||||||
|
</div>
|
||||||
<div class="toolbar" style="flex-wrap:wrap;gap:8px">
|
<div class="toolbar" style="flex-wrap:wrap;gap:8px">
|
||||||
|
<select id="pfPkg" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);min-width:200px">
|
||||||
|
<option value="">默认兜底(空包名)</option>
|
||||||
|
${pkgOptions.map((p) => `<option value="${esc(p)}">${esc(p)}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
<input id="pfPkgCustom" placeholder="或手写包名 applicationId" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:200px">
|
||||||
<input id="pfName" placeholder="套餐名称" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
|
<input id="pfName" placeholder="套餐名称" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
|
||||||
<select id="pfBilling" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
|
<select id="pfBilling" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text)">
|
||||||
<option value="subscription">订阅 subscription</option>
|
<option value="subscription">订阅 subscription</option>
|
||||||
@ -2419,18 +2451,23 @@ const pages = {
|
|||||||
</div>
|
</div>
|
||||||
<div class="toolbar" style="flex-wrap:wrap;gap:8px;margin-top:8px">${skuInputs}</div>
|
<div class="toolbar" style="flex-wrap:wrap;gap:8px;margin-top:8px">${skuInputs}</div>
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>ID</th><th>名称</th><th>类型</th><th>价格</th><th>原价</th><th>天数</th><th>渠道码</th><th>启用</th><th></th></tr></thead>
|
<thead><tr><th>ID</th><th>包名</th><th>名称</th><th>类型</th><th>价格</th><th>原价</th><th>天数</th><th>渠道码</th><th>启用</th><th></th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${list.length ? list.map((p) => `<tr><td>${esc(p.id)}</td><td>${esc(p.name)}</td><td>${esc(billingLabel(p.billingType))}</td><td>${esc(p.price)}</td><td>${esc(p.originalPrice)}</td><td>${esc(p.durationDays)}</td><td style="max-width:220px;word-break:break-all">${esc(skuSummary(p.channelProductCodes))}</td><td>${esc(p.enabled)}</td><td><button type="button" class="secondary btn-sm" data-pdel="${esc(p.id)}">删</button></td></tr>`).join('') : emptyRows(9, '暂无价格')}
|
${list.length ? list.map((p) => `<tr><td>${esc(p.id)}</td><td>${esc(pkgLabel(p.packageName))}</td><td>${esc(p.name)}</td><td>${esc(billingLabel(p.billingType))}</td><td>${esc(p.price)}</td><td>${esc(p.originalPrice)}</td><td>${esc(p.durationDays)}</td><td style="max-width:220px;word-break:break-all">${esc(skuSummary(p.channelProductCodes))}</td><td>${esc(p.enabled)}</td><td><button type="button" class="secondary btn-sm" data-pdel="${esc(p.id)}">删</button></td></tr>`).join('') : emptyRows(10, '暂无价格')}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>`;
|
</table>`;
|
||||||
|
$('pfPkgFilter').onchange = () => { window.__pricePkgFilter = $('pfPkgFilter').value; pages.prices(); };
|
||||||
|
$('pfReload').onclick = () => pages.prices();
|
||||||
$('pfSave').onclick = async () => {
|
$('pfSave').onclick = async () => {
|
||||||
const channelProductCodes = {};
|
const channelProductCodes = {};
|
||||||
CHANNELS.forEach((k) => { const v = $('pfSku_' + k)?.value?.trim(); if (v) channelProductCodes[k] = v; });
|
CHANNELS.forEach((k) => { const v = $('pfSku_' + k)?.value?.trim(); if (v) channelProductCodes[k] = v; });
|
||||||
|
const custom = ($('pfPkgCustom')?.value || '').trim();
|
||||||
|
const packageName = custom || ($('pfPkg')?.value || '').trim();
|
||||||
await api('/prices', {
|
await api('/prices', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
appCode: APP,
|
appCode: APP,
|
||||||
|
packageName,
|
||||||
name: $('pfName').value,
|
name: $('pfName').value,
|
||||||
billingType: $('pfBilling').value,
|
billingType: $('pfBilling').value,
|
||||||
price: $('pfPrice').value,
|
price: $('pfPrice').value,
|
||||||
@ -3106,6 +3143,7 @@ const pages = {
|
|||||||
if (!p) return;
|
if (!p) return;
|
||||||
$('spPlan').value = p.plan_id || 'week';
|
$('spPlan').value = p.plan_id || 'week';
|
||||||
$('spLang').value = p.lang || 'zh-CN';
|
$('spLang').value = p.lang || 'zh-CN';
|
||||||
|
if ($('spPkg')) $('spPkg').value = p.package_name || '';
|
||||||
$('spTitle').value = p.title || '';
|
$('spTitle').value = p.title || '';
|
||||||
$('spSub').value = p.subtitle || '';
|
$('spSub').value = p.subtitle || '';
|
||||||
$('spPrice').value = p.price_display || '';
|
$('spPrice').value = p.price_display || '';
|
||||||
@ -3131,10 +3169,11 @@ const pages = {
|
|||||||
};
|
};
|
||||||
$('content').innerHTML = `
|
$('content').innerHTML = `
|
||||||
<div class="note">SleepHeart 订阅引导 · ASC App <code>6815533078</code> · Team <code>49S9UL5MN4</code> · Bundle <code>health.wyrs365.com</code><br>
|
<div class="note">SleepHeart 订阅引导 · ASC App <code>6815533078</code> · Team <code>49S9UL5MN4</code> · Bundle <code>health.wyrs365.com</code><br>
|
||||||
Admin <code>/api/admin/spark/subscriptions/plans</code> · 客户端 <code>GET /v1/subscriptions/plans</code> · 默认 USD <b>$1/周 · $3/月 · $12/年</b>(季卡 $8 可保留)· IAP <code>health.wyrs365.com.vip.{week|month|quarter|year}</code></div>
|
Admin <code>/api/admin/spark/subscriptions/plans</code> · 客户端 <code>GET /v1/subscriptions/plans?packageName=...</code> · 包名为空=默认兜底 · USD <b>$1/周 · $3/月 · $12/年</b></div>
|
||||||
<div class="toolbar" style="flex-wrap:wrap;gap:8px">
|
<div class="toolbar" style="flex-wrap:wrap;gap:8px">
|
||||||
<select id="spPlan"><option value="week">week</option><option value="month">month</option><option value="quarter">quarter</option><option value="year">year</option></select>
|
<select id="spPlan"><option value="week">week</option><option value="month">month</option><option value="quarter">quarter</option><option value="year">year</option></select>
|
||||||
<select id="spLang"><option value="zh-CN">zh-CN</option><option value="en">en</option><option value="zh-TW">zh-TW</option></select>
|
<select id="spLang"><option value="zh-CN">zh-CN</option><option value="en">en</option><option value="zh-TW">zh-TW</option></select>
|
||||||
|
<input id="spPkg" placeholder="包名/applicationId 空=默认" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:200px" value="health.wyrs365.com">
|
||||||
<input id="spTitle" placeholder="标题" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:120px">
|
<input id="spTitle" placeholder="标题" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:120px">
|
||||||
<input id="spSub" placeholder="副标题" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:140px">
|
<input id="spSub" placeholder="副标题" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:140px">
|
||||||
<input id="spPrice" placeholder="$1/周" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:90px">
|
<input id="spPrice" placeholder="$1/周" style="padding:8px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);width:90px">
|
||||||
@ -3150,9 +3189,9 @@ const pages = {
|
|||||||
<button type="button" class="secondary" id="spReload">刷新</button>
|
<button type="button" class="secondary" id="spReload">刷新</button>
|
||||||
</div>
|
</div>
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>plan</th><th>lang</th><th>标题</th><th>价格</th><th>币种</th><th>天数</th><th>角标</th><th>product</th><th>启用</th></tr></thead>
|
<thead><tr><th>plan</th><th>lang</th><th>包名</th><th>标题</th><th>价格</th><th>币种</th><th>天数</th><th>角标</th><th>product</th><th>启用</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${items.length ? items.map((p, i)=>`<tr data-sp-idx="${i}" style="cursor:pointer"><td>${esc(p.plan_id)}</td><td>${esc(p.lang)}</td><td>${esc(p.title)}<div class="note" style="margin:0">${esc(p.subtitle||'')}</div></td><td>${esc(p.price_display)} (${esc(p.price_cents)})</td><td>${esc(p.currency||'USD')}</td><td>${esc(p.period_days)}</td><td>${esc(p.badge||'')}</td><td>${esc(p.product_id)}</td><td>${esc(p.enabled)}</td></tr>`).join('') : '<tr><td colspan="9">暂无套餐(可点「填入默认价」后保存)</td></tr>'}
|
${items.length ? items.map((p, i)=>`<tr data-sp-idx="${i}" style="cursor:pointer"><td>${esc(p.plan_id)}</td><td>${esc(p.lang)}</td><td>${esc(p.package_name||'(默认)')}</td><td>${esc(p.title)}<div class="note" style="margin:0">${esc(p.subtitle||'')}</div></td><td>${esc(p.price_display)} (${esc(p.price_cents)})</td><td>${esc(p.currency||'USD')}</td><td>${esc(p.period_days)}</td><td>${esc(p.badge||'')}</td><td>${esc(p.product_id)}</td><td>${esc(p.enabled)}</td></tr>`).join('') : '<tr><td colspan="10">暂无套餐(可点「填入默认价」后保存)</td></tr>'}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>`;
|
</table>`;
|
||||||
const seed = items.find((p) => p.plan_id === 'month' && p.lang === 'zh-CN')
|
const seed = items.find((p) => p.plan_id === 'month' && p.lang === 'zh-CN')
|
||||||
@ -3161,7 +3200,7 @@ const pages = {
|
|||||||
if (seed) fillForm(seed);
|
if (seed) fillForm(seed);
|
||||||
else applyPlanDefaults();
|
else applyPlanDefaults();
|
||||||
$('spPlan').onchange = () => {
|
$('spPlan').onchange = () => {
|
||||||
const hit = items.find((p) => p.plan_id === $('spPlan').value && p.lang === $('spLang').value);
|
const hit = items.find((p) => p.plan_id === $('spPlan').value && p.lang === $('spLang').value && (p.package_name || '') === ($('spPkg').value || '').trim());
|
||||||
if (hit) fillForm(hit);
|
if (hit) fillForm(hit);
|
||||||
else {
|
else {
|
||||||
$('spTitle').value = ''; $('spSub').value = ''; $('spPrice').value = '';
|
$('spTitle').value = ''; $('spSub').value = ''; $('spPrice').value = '';
|
||||||
@ -3170,7 +3209,7 @@ const pages = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
$('spLang').onchange = () => {
|
$('spLang').onchange = () => {
|
||||||
const hit = items.find((p) => p.plan_id === $('spPlan').value && p.lang === $('spLang').value);
|
const hit = items.find((p) => p.plan_id === $('spPlan').value && p.lang === $('spLang').value && (p.package_name || '') === ($('spPkg').value || '').trim());
|
||||||
if (hit) fillForm(hit);
|
if (hit) fillForm(hit);
|
||||||
};
|
};
|
||||||
$('spFillDefaults').onclick = () => {
|
$('spFillDefaults').onclick = () => {
|
||||||
@ -3193,7 +3232,8 @@ const pages = {
|
|||||||
const planId = $('spPlan').value;
|
const planId = $('spPlan').value;
|
||||||
const d = PLAN_DEFAULTS[planId] || PLAN_DEFAULTS.week;
|
const d = PLAN_DEFAULTS[planId] || PLAN_DEFAULTS.week;
|
||||||
await api('/spark/subscriptions/plans', { method: 'PUT', body: JSON.stringify({
|
await api('/spark/subscriptions/plans', { method: 'PUT', body: JSON.stringify({
|
||||||
plan_id: planId, lang: $('spLang').value, title: $('spTitle').value.trim() || d.title,
|
plan_id: planId, lang: $('spLang').value, package_name: ($('spPkg').value || '').trim(),
|
||||||
|
title: $('spTitle').value.trim() || d.title,
|
||||||
subtitle: $('spSub').value.trim() || d.subtitle,
|
subtitle: $('spSub').value.trim() || d.subtitle,
|
||||||
price_display: $('spPrice').value.trim() || d.price_display,
|
price_display: $('spPrice').value.trim() || d.price_display,
|
||||||
price_cents: parseInt($('spCents').value, 10) || d.price_cents,
|
price_cents: parseInt($('spCents').value, 10) || d.price_cents,
|
||||||
@ -5346,61 +5386,37 @@ $('wlAdd').onclick = async () => {
|
|||||||
|
|
||||||
async apiDoc() {
|
async apiDoc() {
|
||||||
|
|
||||||
$('content').innerHTML = '<p class="muted">加载接口文档…</p>';
|
const DOC = 'https://sparkapi.wyrs365.com';
|
||||||
|
|
||||||
try {
|
const bust = '202609241556';
|
||||||
|
|
||||||
const doc = await api('/spark/api-doc');
|
const embedUrl = DOC + '/docs?from=popadmin&v=' + bust;
|
||||||
|
|
||||||
const row = (a) =>
|
$('content').innerHTML = `
|
||||||
|
|
||||||
`<tr><td>${esc(a.method)}</td><td><code>${esc(a.path)}</code></td><td>${esc(a.auth || '-')}</td><td>${esc(a.desc || '')}</td></tr>`;
|
<div class="note" style="margin-bottom:12px">
|
||||||
|
|
||||||
const tableBlock = (title, list) => `
|
Spark 客户端 HTTP API 权威文档(OpenAPI / Swagger)内嵌自
|
||||||
|
|
||||||
<div class="section-block" style="margin-bottom:16px">
|
<a href="${DOC}/docs" target="_blank" rel="noopener">${DOC}/docs</a>
|
||||||
|
|
||||||
<h3>${esc(title)}</h3>
|
· 规范 <code>${DOC}/openapi.yaml</code>
|
||||||
|
|
||||||
<table>
|
· 含音乐分类、多广场 <code>square</code> / <code>squarenovel</code> / <code>squaresteps</code> / <code>squarehealth</code> 等
|
||||||
|
|
||||||
<thead><tr><th>方法</th><th>路径</th><th>鉴权</th><th>说明</th></tr></thead>
|
· <a href="${DOC}/docs" target="_blank" rel="noopener">新窗口打开</a>
|
||||||
|
|
||||||
<tbody>${(list || []).map(row).join('') || emptyRows(4, '暂无')}</tbody>
|
· <a href="${DOC}/docs/enums" target="_blank" rel="noopener">枚举</a>
|
||||||
|
|
||||||
</table>
|
· <a href="${DOC}/docs/errors" target="_blank" rel="noopener">错误码</a>
|
||||||
|
|
||||||
</div>`;
|
</div>
|
||||||
|
|
||||||
const notes = (doc.notes || []).map((n) => `<li>${esc(n)}</li>`).join('');
|
<iframe id="sparkApiDocFrame" title="Spark API 文档" src="${embedUrl}"
|
||||||
|
|
||||||
$('content').innerHTML = `
|
style="width:100%;height:calc(100vh - 160px);min-height:640px;border:1px solid var(--border);border-radius:10px;background:#0b1220"
|
||||||
|
|
||||||
<div class="note" style="margin-bottom:12px">
|
referrerpolicy="no-referrer-when-downgrade"></iframe>`;
|
||||||
|
|
||||||
Base URL:<code>${esc(doc.baseUrl || 'https://popadmin.wyrs365.com')}</code>
|
|
||||||
|
|
||||||
· 数据源 <code>GET /api/admin/spark/api-doc</code>
|
|
||||||
|
|
||||||
· 路径别名 <code>/api/admin/pop/…</code>、<code>/api/client/pop/…</code>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul class="note" style="margin-bottom:16px;padding-left:18px">${notes}</ul>
|
|
||||||
|
|
||||||
${tableBlock('管理端 API(popadmin 常用)', doc.adminApis)}
|
|
||||||
|
|
||||||
${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>`;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
1368
deploy/popadmin/price-package-patch/AdminManageController.java
Normal file
1368
deploy/popadmin/price-package-patch/AdminManageController.java
Normal file
File diff suppressed because it is too large
Load Diff
150
deploy/popadmin/price-package-patch/AppClientApiController.java
Normal file
150
deploy/popadmin/price-package-patch/AppClientApiController.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
576
deploy/popadmin/price-package-patch/AppClientService.java
Normal file
576
deploy/popadmin/price-package-patch/AppClientService.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
156
deploy/popadmin/price-package-patch/MembershipPrice.java
Normal file
156
deploy/popadmin/price-package-patch/MembershipPrice.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
deploy/popadmin/price-package-patch/PackageNameContext.java
Normal file
24
deploy/popadmin/price-package-patch/PackageNameContext.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
96
deploy/popadmin/price-package-patch/PricesConfigService.java
Normal file
96
deploy/popadmin/price-package-patch/PricesConfigService.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
70
deploy/popadmin/price-package-patch/apply_package_price.sh
Normal file
70
deploy/popadmin/price-package-patch/apply_package_price.sh
Normal 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"
|
||||||
22
deploy/popadmin/price-package-patch/fix_auth_public.py
Normal file
22
deploy/popadmin/price-package-patch/fix_auth_public.py
Normal 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")
|
||||||
29
deploy/popadmin/price-package-patch/prices.seed.json
Normal file
29
deploy/popadmin/price-package-patch/prices.seed.json
Normal 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
|
||||||
|
}
|
||||||
@ -77,7 +77,6 @@
|
|||||||
|
|
||||||
- Spark Messenger 全页面接入检查
|
- Spark Messenger 全页面接入检查
|
||||||
|
|
||||||
|
|
||||||
## 14:48 — 修复 make lint / make test
|
## 14:48 — 修复 make lint / make test
|
||||||
|
|
||||||
### 概要
|
### 概要
|
||||||
@ -130,6 +129,14 @@ make install-git-hooks # 本机只需一次
|
|||||||
- 表增加 `auth_type` / `contact`;Admin list 支持 `?auth_type=login|guest`;popadmin App反馈页加筛选下拉
|
- 表增加 `auth_type` / `contact`;Admin list 支持 `?auth_type=login|guest`;popadmin App反馈页加筛选下拉
|
||||||
- OpenAPI 同步说明
|
- OpenAPI 同步说明
|
||||||
|
|
||||||
|
## 14:53 — Android 白噪音对照后补音乐公开 API
|
||||||
|
|
||||||
|
### 概要
|
||||||
|
|
||||||
|
- 对照 `WhiteNoiseJSPPOldAndroid`:音乐主路径仍走欢休 `app.huanxiu.vip`;IM/登录/支付多为旧 JSPP TCP protobuf。
|
||||||
|
- 在 Spark V3 风格下补齐迁端缺口:单曲详情、分区嵌套列表、`requireMember` 筛选;公开路由免登录。
|
||||||
|
- 顺手修复先前好友拒绝用例测试(Accept 双返回值、测试邮箱)。
|
||||||
|
|
||||||
### 变更范围
|
### 变更范围
|
||||||
|
|
||||||
| 类型 | 说明 |
|
| 类型 | 说明 |
|
||||||
@ -178,6 +185,35 @@ make install-git-hooks # 本机只需一次
|
|||||||
- 用户上传路径:凭证 size + 发帖声明宽高/体积软检测(默认可日志,可拒收)。
|
- 用户上传路径:凭证 size + 发帖声明宽高/体积软检测(默认可日志,可拒收)。
|
||||||
- 机器人配图:入库对象存储前压到最长边 ≤1080;视频路径 ≤720(ffmpeg)。
|
- 机器人配图:入库对象存储前压到最长边 ≤1080;视频路径 ≤720(ffmpeg)。
|
||||||
|
|
||||||
|
| 功能 | 音乐公开读 API 增强 |
|
||||||
|
| 文档 | OpenAPI music 路径与 schema |
|
||||||
|
| 修复 | friend Reject 单测 |
|
||||||
|
|
||||||
|
### 主要文件
|
||||||
|
|
||||||
|
- `internal/module/music/entity/track.go` — GroupedPlaylist / PlaylistGroup
|
||||||
|
- `internal/module/music/logic/service.go` — GetPublic、PlaylistGrouped、PlaylistQuery
|
||||||
|
- `internal/transport/http/handler/music/handler.go`、`router.go`、`auth.go`
|
||||||
|
- `internal/transport/http/docs/openapi.yaml`
|
||||||
|
- `internal/module/music/logic/service_test.go`、`friend/logic/friend_test.go`
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
|
||||||
|
- `go test ./...`:通过(本机无 `make`;`golangci-lint` 与 Go 1.27 export data 不兼容,未跑 lint)
|
||||||
|
- `music/logic` coverage:74.7%
|
||||||
|
|
||||||
|
### 关联
|
||||||
|
|
||||||
|
- 用户:对照 Android 补全 spark-server 缺失 API(不 commit/push)
|
||||||
|
|
||||||
|
## 15:20 — 多广场实例 + 分类筛选
|
||||||
|
|
||||||
|
### 概要
|
||||||
|
|
||||||
|
- 同一套广场能力按 `channel` 隔离数据:`square` / `squarenovel` / `squaresteps` / `squarehealth`。
|
||||||
|
- 各广场支持分类 Tab:推荐/最新/热门/附近(排序)+ 搞笑/美女(帖标签);附近为 lat/lng 曼哈顿距离 stub。
|
||||||
|
- 发帖/点赞/评论/通知水位均带 channel;保留原 `/v1/square/...` 路径。
|
||||||
|
|
||||||
### 变更范围
|
### 变更范围
|
||||||
|
|
||||||
| 类型 | 说明 |
|
| 类型 | 说明 |
|
||||||
@ -238,3 +274,25 @@ make install-git-hooks # 本机只需一次
|
|||||||
### 关联
|
### 关联
|
||||||
|
|
||||||
- 任务:发现页顶部接入同开发者应用列表(stash 合并进 main)
|
- 任务:发现页顶部接入同开发者应用列表(stash 合并进 main)
|
||||||
|
|
||||||
|
| 功能 | 多广场 + Feed `category` 筛选 |
|
||||||
|
| 迁移 | `0031_square_channel_category.sql` |
|
||||||
|
| 文档 | OpenAPI `{square_channel}` 路径;meta 枚举 |
|
||||||
|
|
||||||
|
### 主要文件
|
||||||
|
|
||||||
|
- `migrations/0031_square_channel_category.sql` — posts/notices/notice_state 加 channel/category
|
||||||
|
- `internal/module/square/entity/channel.go` — Channel / Category 枚举
|
||||||
|
- `internal/module/square/logic/*`、`model/*` — channel 隔离与 Feed 排序
|
||||||
|
- `internal/transport/http/handler/square`、`router.go` — 四前缀同路由 + `/categories`
|
||||||
|
- `internal/transport/http/docs/openapi.yaml`、`meta`、`i18n`
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
|
||||||
|
- `go run ./cmd/migrate -config configs/config.yaml`:通过
|
||||||
|
- `go test ./...`:通过(本机无 `make`;golangci-lint 与 Go 1.27 不兼容)
|
||||||
|
- `square/logic` coverage:77.5%
|
||||||
|
|
||||||
|
### 关联
|
||||||
|
|
||||||
|
- 用户:spark-server 多广场 + 分类筛选(不 commit/push)
|
||||||
|
|||||||
@ -44,9 +44,11 @@ type Feedback struct {
|
|||||||
// SubscriptionPlan is one week/month/quarter/year card.
|
// SubscriptionPlan is one week/month/quarter/year card.
|
||||||
// IAP product_id placeholders: health.wyrs365.com.vip.{week|month|quarter|year}
|
// IAP product_id placeholders: health.wyrs365.com.vip.{week|month|quarter|year}
|
||||||
// (ASC App 6815533078 · Team 49S9UL5MN4 · Bundle health.wyrs365.com).
|
// (ASC App 6815533078 · Team 49S9UL5MN4 · Bundle health.wyrs365.com).
|
||||||
|
// PackageName 为空表示默认兜底;客户端传 packageName / X-Wyrs-Package 时优先精确匹配。
|
||||||
type SubscriptionPlan struct {
|
type SubscriptionPlan struct {
|
||||||
PlanID string `json:"plan_id"`
|
PlanID string `json:"plan_id"`
|
||||||
Lang string `json:"lang"`
|
Lang string `json:"lang"`
|
||||||
|
PackageName string `json:"package_name,omitempty"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Subtitle string `json:"subtitle"`
|
Subtitle string `json:"subtitle"`
|
||||||
PriceDisplay string `json:"price_display"`
|
PriceDisplay string `json:"price_display"`
|
||||||
|
|||||||
@ -22,8 +22,8 @@ type Repository interface {
|
|||||||
InsertFeedback(ctx context.Context, f *entity.Feedback) error
|
InsertFeedback(ctx context.Context, f *entity.Feedback) error
|
||||||
ListFeedback(ctx context.Context, page, size int, authType string) ([]entity.Feedback, int64, error)
|
ListFeedback(ctx context.Context, page, size int, authType string) ([]entity.Feedback, int64, error)
|
||||||
UpdateFeedbackStatus(ctx context.Context, id int64, status string) error
|
UpdateFeedbackStatus(ctx context.Context, id int64, status string) error
|
||||||
ListPlans(ctx context.Context, lang string) ([]entity.SubscriptionPlan, error)
|
ListPlans(ctx context.Context, lang, packageName string) ([]entity.SubscriptionPlan, error)
|
||||||
ListPlansAdmin(ctx context.Context, lang string) ([]entity.SubscriptionPlan, error)
|
ListPlansAdmin(ctx context.Context, lang, packageName string) ([]entity.SubscriptionPlan, error)
|
||||||
UpsertPlan(ctx context.Context, p *entity.SubscriptionPlan) error
|
UpsertPlan(ctx context.Context, p *entity.SubscriptionPlan) error
|
||||||
GetPasswordHash(ctx context.Context, userID int64) (string, error)
|
GetPasswordHash(ctx context.Context, userID int64) (string, error)
|
||||||
SetPasswordHash(ctx context.Context, userID int64, hash string) error
|
SetPasswordHash(ctx context.Context, userID int64, hash string) error
|
||||||
@ -183,16 +183,17 @@ func (s *Service) UpdateFeedbackStatus(ctx context.Context, id int64, status str
|
|||||||
return s.repo.UpdateFeedbackStatus(ctx, id, status)
|
return s.repo.UpdateFeedbackStatus(ctx, id, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) SubscriptionPlans(ctx context.Context) (*entity.SubscriptionConfig, error) {
|
func (s *Service) SubscriptionPlans(ctx context.Context, packageName string) (*entity.SubscriptionConfig, error) {
|
||||||
lang := normalizeLang(reqctx.Language(ctx))
|
lang := normalizeLang(reqctx.Language(ctx))
|
||||||
|
packageName = strings.TrimSpace(packageName)
|
||||||
headline, sub := subscriptionCopy(lang)
|
headline, sub := subscriptionCopy(lang)
|
||||||
if s.repo != nil {
|
if s.repo != nil {
|
||||||
plans, err := s.repo.ListPlans(ctx, lang)
|
plans, err := s.repo.ListPlans(ctx, lang, packageName)
|
||||||
if err == nil && len(plans) > 0 {
|
if err == nil && len(plans) > 0 {
|
||||||
return &entity.SubscriptionConfig{Headline: headline, Subheadline: sub, Plans: plans}, nil
|
return &entity.SubscriptionConfig{Headline: headline, Subheadline: sub, Plans: plans}, nil
|
||||||
}
|
}
|
||||||
if lang != string(i18n.ZhCN) {
|
if lang != string(i18n.ZhCN) {
|
||||||
plans, err = s.repo.ListPlans(ctx, string(i18n.ZhCN))
|
plans, err = s.repo.ListPlans(ctx, string(i18n.ZhCN), packageName)
|
||||||
if err == nil && len(plans) > 0 {
|
if err == nil && len(plans) > 0 {
|
||||||
return &entity.SubscriptionConfig{Headline: headline, Subheadline: sub, Plans: plans}, nil
|
return &entity.SubscriptionConfig{Headline: headline, Subheadline: sub, Plans: plans}, nil
|
||||||
}
|
}
|
||||||
@ -201,11 +202,11 @@ func (s *Service) SubscriptionPlans(ctx context.Context) (*entity.SubscriptionCo
|
|||||||
return &entity.SubscriptionConfig{Headline: headline, Subheadline: sub, Plans: defaultPlans(lang)}, nil
|
return &entity.SubscriptionConfig{Headline: headline, Subheadline: sub, Plans: defaultPlans(lang)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ListPlansAdmin(ctx context.Context, lang string) ([]entity.SubscriptionPlan, error) {
|
func (s *Service) ListPlansAdmin(ctx context.Context, lang, packageName string) ([]entity.SubscriptionPlan, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return defaultPlans(normalizeLang(lang)), nil
|
return defaultPlans(normalizeLang(lang)), nil
|
||||||
}
|
}
|
||||||
return s.repo.ListPlansAdmin(ctx, strings.TrimSpace(lang))
|
return s.repo.ListPlansAdmin(ctx, strings.TrimSpace(lang), strings.TrimSpace(packageName))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) UpsertPlanAdmin(ctx context.Context, p *entity.SubscriptionPlan) error {
|
func (s *Service) UpsertPlanAdmin(ctx context.Context, p *entity.SubscriptionPlan) error {
|
||||||
@ -217,6 +218,7 @@ func (s *Service) UpsertPlanAdmin(ctx context.Context, p *entity.SubscriptionPla
|
|||||||
}
|
}
|
||||||
p.Lang = normalizeLang(p.Lang)
|
p.Lang = normalizeLang(p.Lang)
|
||||||
p.PlanID = strings.TrimSpace(p.PlanID)
|
p.PlanID = strings.TrimSpace(p.PlanID)
|
||||||
|
p.PackageName = strings.TrimSpace(p.PackageName)
|
||||||
switch p.PlanID {
|
switch p.PlanID {
|
||||||
case "week", "month", "quarter", "year":
|
case "week", "month", "quarter", "year":
|
||||||
default:
|
default:
|
||||||
|
|||||||
@ -71,7 +71,7 @@ func (s *stubClientRepo) UpdateFeedbackStatus(_ context.Context, id int64, statu
|
|||||||
return errs.ErrNotFound
|
return errs.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubClientRepo) ListPlans(_ context.Context, lang string) ([]entity.SubscriptionPlan, error) {
|
func (s *stubClientRepo) ListPlans(_ context.Context, lang, packageName string) ([]entity.SubscriptionPlan, error) {
|
||||||
out := make([]entity.SubscriptionPlan, 0)
|
out := make([]entity.SubscriptionPlan, 0)
|
||||||
for i := range s.plans {
|
for i := range s.plans {
|
||||||
p := &s.plans[i]
|
p := &s.plans[i]
|
||||||
@ -79,19 +79,35 @@ func (s *stubClientRepo) ListPlans(_ context.Context, lang string) ([]entity.Sub
|
|||||||
out = append(out, *p)
|
out = append(out, *p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, nil
|
if packageName == "" {
|
||||||
|
return out, 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 string) ([]entity.SubscriptionPlan, error) {
|
func (s *stubClientRepo) ListPlansAdmin(_ context.Context, lang, packageName string) ([]entity.SubscriptionPlan, error) {
|
||||||
if lang == "" {
|
|
||||||
return s.plans, nil
|
|
||||||
}
|
|
||||||
out := make([]entity.SubscriptionPlan, 0)
|
out := make([]entity.SubscriptionPlan, 0)
|
||||||
for i := range s.plans {
|
for i := range s.plans {
|
||||||
p := &s.plans[i]
|
p := &s.plans[i]
|
||||||
if p.Lang == lang {
|
if lang != "" && p.Lang != lang {
|
||||||
out = append(out, *p)
|
continue
|
||||||
}
|
}
|
||||||
|
if packageName != "" && p.PackageName != packageName {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, *p)
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
@ -167,7 +183,7 @@ func TestLegalPlansFeedbackFallback(t *testing.T) {
|
|||||||
if err != nil || legal.Privacy.Title == "" || legal.Terms.Title == "" {
|
if err != nil || legal.Privacy.Title == "" || legal.Terms.Title == "" {
|
||||||
t.Fatalf("legal %+v err=%v", legal, err)
|
t.Fatalf("legal %+v err=%v", legal, err)
|
||||||
}
|
}
|
||||||
plans, err := svc.SubscriptionPlans(t.Context())
|
plans, err := svc.SubscriptionPlans(t.Context(), "")
|
||||||
if err != nil || len(plans.Plans) != 4 {
|
if err != nil || len(plans.Plans) != 4 {
|
||||||
t.Fatalf("plans %+v err=%v", plans, err)
|
t.Fatalf("plans %+v err=%v", plans, err)
|
||||||
}
|
}
|
||||||
@ -231,7 +247,7 @@ func TestClientServiceAdminAndRepo(t *testing.T) {
|
|||||||
if err := svc.UpdateFeedbackStatus(ctx, 1, "closed"); err != nil {
|
if err := svc.UpdateFeedbackStatus(ctx, 1, "closed"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
adminPlans, err := svc.ListPlansAdmin(ctx, string(i18n.ZhCN))
|
adminPlans, err := svc.ListPlansAdmin(ctx, string(i18n.ZhCN), "")
|
||||||
if err != nil || len(adminPlans) != 1 {
|
if err != nil || len(adminPlans) != 1 {
|
||||||
t.Fatalf("plans %+v %v", adminPlans, err)
|
t.Fatalf("plans %+v %v", adminPlans, err)
|
||||||
}
|
}
|
||||||
@ -315,7 +331,7 @@ func TestDeveloperAppsAdmin(t *testing.T) {
|
|||||||
if err != nil || len(apps.Items) != 0 {
|
if err != nil || len(apps.Items) != 0 {
|
||||||
t.Fatalf("exclude package %+v %v", apps, err)
|
t.Fatalf("exclude package %+v %v", apps, err)
|
||||||
}
|
}
|
||||||
cfg, err := svc.SubscriptionPlans(ctxLang(string(i18n.ZhTW)))
|
cfg, err := svc.SubscriptionPlans(ctxLang(string(i18n.ZhTW)), "")
|
||||||
if err != nil || len(cfg.Plans) == 0 {
|
if err != nil || len(cfg.Plans) == 0 {
|
||||||
t.Fatalf("plans %+v %v", cfg, err)
|
t.Fatalf("plans %+v %v", cfg, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package model
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@ -50,6 +51,7 @@ func (feedbackRow) TableName() string { return "t_feedback" }
|
|||||||
type planRow struct {
|
type planRow struct {
|
||||||
PlanID string `gorm:"column:plan_id;primaryKey"`
|
PlanID string `gorm:"column:plan_id;primaryKey"`
|
||||||
Lang string `gorm:"column:lang;primaryKey"`
|
Lang string `gorm:"column:lang;primaryKey"`
|
||||||
|
PackageName string `gorm:"column:package_name;primaryKey"`
|
||||||
Title string `gorm:"column:title"`
|
Title string `gorm:"column:title"`
|
||||||
Subtitle string `gorm:"column:subtitle"`
|
Subtitle string `gorm:"column:subtitle"`
|
||||||
PriceDisplay string `gorm:"column:price_display"`
|
PriceDisplay string `gorm:"column:price_display"`
|
||||||
@ -170,7 +172,7 @@ func (r *Repo) UpdateFeedbackStatus(ctx context.Context, id int64, status string
|
|||||||
return r.db.WithContext(ctx).Model(&feedbackRow{}).Where("id = ?", id).Update("status", status).Error
|
return r.db.WithContext(ctx).Model(&feedbackRow{}).Where("id = ?", id).Update("status", status).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repo) ListPlans(ctx context.Context, lang string) ([]entity.SubscriptionPlan, error) {
|
func (r *Repo) ListPlans(ctx context.Context, lang, packageName string) ([]entity.SubscriptionPlan, error) {
|
||||||
var rows []planRow
|
var rows []planRow
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
Where("lang = ? AND enabled = 1", lang).
|
Where("lang = ? AND enabled = 1", lang).
|
||||||
@ -179,14 +181,17 @@ func (r *Repo) ListPlans(ctx context.Context, lang string) ([]entity.Subscriptio
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return mapPlans(rows), nil
|
return filterPlansByPackage(mapPlans(rows), packageName), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repo) ListPlansAdmin(ctx context.Context, lang string) ([]entity.SubscriptionPlan, error) {
|
func (r *Repo) ListPlansAdmin(ctx context.Context, lang, packageName string) ([]entity.SubscriptionPlan, error) {
|
||||||
q := r.db.WithContext(ctx).Order("lang ASC, sort_order ASC, plan_id ASC")
|
q := r.db.WithContext(ctx).Order("package_name ASC, lang ASC, sort_order ASC, plan_id ASC")
|
||||||
if lang != "" {
|
if lang != "" {
|
||||||
q = q.Where("lang = ?", lang)
|
q = q.Where("lang = ?", lang)
|
||||||
}
|
}
|
||||||
|
if packageName != "" {
|
||||||
|
q = q.Where("package_name = ?", packageName)
|
||||||
|
}
|
||||||
var rows []planRow
|
var rows []planRow
|
||||||
if err := q.Find(&rows).Error; err != nil {
|
if err := q.Find(&rows).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -199,7 +204,8 @@ func mapPlans(rows []planRow) []entity.SubscriptionPlan {
|
|||||||
for i := range rows {
|
for i := range rows {
|
||||||
row := &rows[i]
|
row := &rows[i]
|
||||||
out = append(out, entity.SubscriptionPlan{
|
out = append(out, entity.SubscriptionPlan{
|
||||||
PlanID: row.PlanID, Lang: row.Lang, Title: row.Title, Subtitle: row.Subtitle,
|
PlanID: row.PlanID, Lang: row.Lang, PackageName: row.PackageName,
|
||||||
|
Title: row.Title, Subtitle: row.Subtitle,
|
||||||
PriceDisplay: row.PriceDisplay, PriceCents: row.PriceCents, Currency: row.Currency,
|
PriceDisplay: row.PriceDisplay, PriceCents: row.PriceCents, Currency: row.Currency,
|
||||||
PeriodDays: row.PeriodDays, Badge: row.Badge, SortOrder: row.SortOrder,
|
PeriodDays: row.PeriodDays, Badge: row.Badge, SortOrder: row.SortOrder,
|
||||||
Enabled: row.Enabled, ProductID: row.ProductID,
|
Enabled: row.Enabled, ProductID: row.ProductID,
|
||||||
@ -208,12 +214,46 @@ func mapPlans(rows []planRow) []entity.SubscriptionPlan {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// filterPlansByPackage:有精确包名则用之,否则回退 package_name 为空的默认档。
|
||||||
|
func filterPlansByPackage(items []entity.SubscriptionPlan, packageName string) []entity.SubscriptionPlan {
|
||||||
|
packageName = strings.TrimSpace(packageName)
|
||||||
|
if packageName == "" {
|
||||||
|
// 无包名:仅返回默认兜底,避免多包混在一起
|
||||||
|
out := make([]entity.SubscriptionPlan, 0, len(items))
|
||||||
|
for _, p := range items {
|
||||||
|
if strings.TrimSpace(p.PackageName) == "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) > 0 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
want := strings.ToLower(packageName)
|
||||||
|
exact := make([]entity.SubscriptionPlan, 0)
|
||||||
|
fallback := make([]entity.SubscriptionPlan, 0)
|
||||||
|
for _, p := range items {
|
||||||
|
pkg := strings.TrimSpace(p.PackageName)
|
||||||
|
if pkg != "" && strings.ToLower(pkg) == want {
|
||||||
|
exact = append(exact, p)
|
||||||
|
} else if pkg == "" {
|
||||||
|
fallback = append(fallback, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(exact) > 0 {
|
||||||
|
return exact
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repo) UpsertPlan(ctx context.Context, p *entity.SubscriptionPlan) error {
|
func (r *Repo) UpsertPlan(ctx context.Context, p *entity.SubscriptionPlan) error {
|
||||||
if p == nil {
|
if p == nil {
|
||||||
return errors.New("client: nil subscription plan")
|
return errors.New("client: nil subscription plan")
|
||||||
}
|
}
|
||||||
row := planRow{
|
row := planRow{
|
||||||
PlanID: p.PlanID, Lang: p.Lang, Title: p.Title, Subtitle: p.Subtitle,
|
PlanID: p.PlanID, Lang: p.Lang, PackageName: strings.TrimSpace(p.PackageName),
|
||||||
|
Title: p.Title, Subtitle: p.Subtitle,
|
||||||
PriceDisplay: p.PriceDisplay, PriceCents: p.PriceCents, Currency: p.Currency,
|
PriceDisplay: p.PriceDisplay, PriceCents: p.PriceCents, Currency: p.Currency,
|
||||||
PeriodDays: p.PeriodDays, Badge: p.Badge, SortOrder: p.SortOrder,
|
PeriodDays: p.PeriodDays, Badge: p.Badge, SortOrder: p.SortOrder,
|
||||||
Enabled: p.Enabled, ProductID: p.ProductID, UpdatedAt: time.Now(),
|
Enabled: p.Enabled, ProductID: p.ProductID, UpdatedAt: time.Now(),
|
||||||
|
|||||||
@ -37,4 +37,6 @@ type Catalog struct {
|
|||||||
MomentVisibleDays []Item `json:"moment_visible_days"`
|
MomentVisibleDays []Item `json:"moment_visible_days"`
|
||||||
VerificationPurpose []Item `json:"verification_purpose"`
|
VerificationPurpose []Item `json:"verification_purpose"`
|
||||||
PhoneCarrier []Item `json:"phone_carrier"`
|
PhoneCarrier []Item `json:"phone_carrier"`
|
||||||
|
SquareChannel []Item `json:"square_channel"`
|
||||||
|
SquareCategory []Item `json:"square_category"`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,6 +38,8 @@ func Catalog(lang i18n.Lang) *entity.Catalog {
|
|||||||
MomentVisibleDays: momentVisibleDays(lang),
|
MomentVisibleDays: momentVisibleDays(lang),
|
||||||
VerificationPurpose: verificationPurpose(lang),
|
VerificationPurpose: verificationPurpose(lang),
|
||||||
PhoneCarrier: phoneCarrier(lang),
|
PhoneCarrier: phoneCarrier(lang),
|
||||||
|
SquareChannel: squareChannel(lang),
|
||||||
|
SquareCategory: squareCategory(lang),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
25
internal/module/meta/logic/enums_square.go
Normal file
25
internal/module/meta/logic/enums_square.go
Normal 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...)
|
||||||
|
}
|
||||||
@ -32,6 +32,12 @@ func TestCatalogValuesMatchEntity(t *testing.T) {
|
|||||||
if c.MomentKind[0].Value != 1 || c.MomentVisibility[2].Name != "include" {
|
if c.MomentKind[0].Value != 1 || c.MomentVisibility[2].Name != "include" {
|
||||||
t.Fatalf("moment %+v %+v", c.MomentKind, c.MomentVisibility)
|
t.Fatalf("moment %+v %+v", c.MomentKind, c.MomentVisibility)
|
||||||
}
|
}
|
||||||
|
if len(c.SquareChannel) != 4 || c.SquareChannel[0].Value != "square" {
|
||||||
|
t.Fatalf("square_channel %+v", c.SquareChannel)
|
||||||
|
}
|
||||||
|
if len(c.SquareCategory) != 6 || c.SquareCategory[0].Value != "recommend" {
|
||||||
|
t.Fatalf("square_category %+v", c.SquareCategory)
|
||||||
|
}
|
||||||
if len(c.DeviceType) != 6 || c.DeviceType[0].Value != 1 || c.DeviceType[0].Name != "ios" {
|
if len(c.DeviceType) != 6 || c.DeviceType[0].Value != 1 || c.DeviceType[0].Name != "ios" {
|
||||||
t.Fatalf("device_type %+v", c.DeviceType)
|
t.Fatalf("device_type %+v", c.DeviceType)
|
||||||
}
|
}
|
||||||
@ -44,7 +50,7 @@ func TestCatalogValuesMatchEntity(t *testing.T) {
|
|||||||
c.RelationKind, c.FriendRequestStatus, c.FriendSource, c.ReportReason, c.GroupStatus, c.GroupRole,
|
c.RelationKind, c.FriendRequestStatus, c.FriendSource, c.ReportReason, c.GroupStatus, c.GroupRole,
|
||||||
c.MailboxEventType, c.TCPCommand, c.EnvelopeCode, c.DeviceType, c.Language, c.UploadPurpose, c.SystemKind,
|
c.MailboxEventType, c.TCPCommand, c.EnvelopeCode, c.DeviceType, c.Language, c.UploadPurpose, c.SystemKind,
|
||||||
c.MomentKind, c.MomentVisibility, c.MomentStatus, c.MomentNoticeKind, c.MomentVisibleDays,
|
c.MomentKind, c.MomentVisibility, c.MomentStatus, c.MomentNoticeKind, c.MomentVisibleDays,
|
||||||
c.AppID,
|
c.AppID, c.SquareChannel, c.SquareCategory,
|
||||||
} {
|
} {
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
t.Fatal("empty set")
|
t.Fatal("empty set")
|
||||||
|
|||||||
@ -52,3 +52,18 @@ type Playlist struct {
|
|||||||
Categories []Category `json:"categories"`
|
Categories []Category `json:"categories"`
|
||||||
MemberPreviewSeconds int `json:"memberPreviewSeconds"`
|
MemberPreviewSeconds int `json:"memberPreviewSeconds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PlaylistGroup is one category (zone) with its enabled tracks.
|
||||||
|
// Maps to HuanXiu-style zoneAndZoneMusicList entries for Spark clients.
|
||||||
|
type PlaylistGroup struct {
|
||||||
|
CategoryID int64 `json:"categoryId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ParentID *int64 `json:"parentId"`
|
||||||
|
Items []Track `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupedPlaylist is categories-with-tracks for home tab migration.
|
||||||
|
type GroupedPlaylist struct {
|
||||||
|
Groups []PlaylistGroup `json:"groups"`
|
||||||
|
MemberPreviewSeconds int `json:"memberPreviewSeconds"`
|
||||||
|
}
|
||||||
|
|||||||
@ -106,7 +106,13 @@ func (s *Service) ListAdmin(ctx context.Context) ([]entity.Track, error) {
|
|||||||
return s.repo.ListAll(ctx)
|
return s.repo.ListAll(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Playlist(ctx context.Context, categoryID int64) (*entity.Playlist, error) {
|
// PlaylistQuery filters the public music list.
|
||||||
|
type PlaylistQuery struct {
|
||||||
|
CategoryID int64
|
||||||
|
RequireMember *bool // nil=all; true=仅会员曲;false=仅免费曲
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Playlist(ctx context.Context, q PlaylistQuery) (*entity.Playlist, error) {
|
||||||
if !s.Ready() {
|
if !s.Ready() {
|
||||||
return nil, ErrNotReady
|
return nil, ErrNotReady
|
||||||
}
|
}
|
||||||
@ -114,14 +120,15 @@ func (s *Service) Playlist(ctx context.Context, categoryID int64) (*entity.Playl
|
|||||||
items []entity.Track
|
items []entity.Track
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
if categoryID > 0 {
|
if q.CategoryID > 0 {
|
||||||
items, err = s.repo.ListEnabledByCategory(ctx, categoryID)
|
items, err = s.repo.ListEnabledByCategory(ctx, q.CategoryID)
|
||||||
} else {
|
} else {
|
||||||
items, err = s.repo.ListEnabled(ctx)
|
items, err = s.repo.ListEnabled(ctx)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
items = filterRequireMember(items, q.RequireMember)
|
||||||
sec, err := s.repo.MemberPreviewSeconds(ctx)
|
sec, err := s.repo.MemberPreviewSeconds(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -133,6 +140,115 @@ func (s *Service) Playlist(ctx context.Context, categoryID int64) (*entity.Playl
|
|||||||
return &entity.Playlist{Items: items, Categories: tree, MemberPreviewSeconds: sec}, nil
|
return &entity.Playlist{Items: items, Categories: tree, MemberPreviewSeconds: sec}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetPublic returns one enabled track for clients (maps to HuanXiu /music/v1/detail).
|
||||||
|
func (s *Service) GetPublic(ctx context.Context, id int64) (*entity.Track, error) {
|
||||||
|
if !s.Ready() {
|
||||||
|
return nil, ErrNotReady
|
||||||
|
}
|
||||||
|
if id <= 0 {
|
||||||
|
return nil, errs.ErrInvalidArgument.WithMsg("id 无效")
|
||||||
|
}
|
||||||
|
t, err := s.repo.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.ErrNotFound.WithMsg("曲目不存在")
|
||||||
|
}
|
||||||
|
if !t.Enabled {
|
||||||
|
return nil, errs.ErrNotFound.WithMsg("曲目不存在")
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlaylistGrouped returns leaf categories each with their tracks
|
||||||
|
// (maps to HuanXiu zoneAndZoneMusicList for home tab).
|
||||||
|
// parentID>0 limits to that category itself (if leaf) or its children.
|
||||||
|
func (s *Service) PlaylistGrouped(ctx context.Context, parentID int64) (*entity.GroupedPlaylist, error) {
|
||||||
|
if !s.Ready() {
|
||||||
|
return nil, ErrNotReady
|
||||||
|
}
|
||||||
|
if parentID < 0 {
|
||||||
|
return nil, errs.ErrInvalidArgument.WithMsg("parentId 无效")
|
||||||
|
}
|
||||||
|
tree, err := s.CategoryTree(ctx, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
leaves := collectLeafCategories(tree, parentID)
|
||||||
|
sec, err := s.repo.MemberPreviewSeconds(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
groups := make([]entity.PlaylistGroup, 0, len(leaves))
|
||||||
|
for _, cat := range leaves {
|
||||||
|
items, err := s.repo.ListEnabledByCategory(ctx, cat.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if items == nil {
|
||||||
|
items = []entity.Track{}
|
||||||
|
}
|
||||||
|
groups = append(groups, entity.PlaylistGroup{
|
||||||
|
CategoryID: cat.ID,
|
||||||
|
Name: cat.Name,
|
||||||
|
ParentID: cat.ParentID,
|
||||||
|
Items: items,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return &entity.GroupedPlaylist{Groups: groups, MemberPreviewSeconds: sec}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterRequireMember(items []entity.Track, requireMember *bool) []entity.Track {
|
||||||
|
if requireMember == nil {
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
out := make([]entity.Track, 0, len(items))
|
||||||
|
want := *requireMember
|
||||||
|
for _, t := range items {
|
||||||
|
if t.RequireMember == want {
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectLeafCategories walks the enabled tree.
|
||||||
|
// parentID==0 → all leaves; parentID>0 → that node if leaf, else its child leaves.
|
||||||
|
func collectLeafCategories(tree []entity.Category, parentID int64) []entity.Category {
|
||||||
|
if parentID > 0 {
|
||||||
|
if cat, ok := findCategory(tree, parentID); ok {
|
||||||
|
if len(cat.Children) == 0 {
|
||||||
|
return []entity.Category{cat}
|
||||||
|
}
|
||||||
|
return leafCategories(cat.Children)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return leafCategories(tree)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findCategory(tree []entity.Category, id int64) (entity.Category, bool) {
|
||||||
|
for _, c := range tree {
|
||||||
|
if c.ID == id {
|
||||||
|
return c, true
|
||||||
|
}
|
||||||
|
if found, ok := findCategory(c.Children, id); ok {
|
||||||
|
return found, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entity.Category{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func leafCategories(tree []entity.Category) []entity.Category {
|
||||||
|
var out []entity.Category
|
||||||
|
for _, c := range tree {
|
||||||
|
if len(c.Children) == 0 {
|
||||||
|
out = append(out, c)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, leafCategories(c.Children)...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) Settings(ctx context.Context) (*entity.Settings, error) {
|
func (s *Service) Settings(ctx context.Context) (*entity.Settings, error) {
|
||||||
if !s.Ready() {
|
if !s.Ready() {
|
||||||
return nil, ErrNotReady
|
return nil, ErrNotReady
|
||||||
|
|||||||
@ -42,8 +42,24 @@ func (s *stubMusicRepo) ListEnabled(_ context.Context) ([]entity.Track, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubMusicRepo) ListEnabledByCategory(_ context.Context, _ int64) ([]entity.Track, error) {
|
func (s *stubMusicRepo) ListEnabledByCategory(_ context.Context, categoryID int64) ([]entity.Track, error) {
|
||||||
return s.ListEnabled(context.Background())
|
out := make([]entity.Track, 0)
|
||||||
|
for _, t := range s.tracks {
|
||||||
|
if !t.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ids := s.trackCategories[t.ID]
|
||||||
|
if ids == nil {
|
||||||
|
ids = t.CategoryIDs
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
if id == categoryID {
|
||||||
|
out = append(out, *t)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubMusicRepo) Get(_ context.Context, id int64) (*entity.Track, error) {
|
func (s *stubMusicRepo) Get(_ context.Context, id int64) (*entity.Track, error) {
|
||||||
@ -182,7 +198,7 @@ func newTestService() (*Service, *stubMusicRepo) {
|
|||||||
func TestServicePlaylistAndSettings(t *testing.T) {
|
func TestServicePlaylistAndSettings(t *testing.T) {
|
||||||
svc, repo := newTestService()
|
svc, repo := newTestService()
|
||||||
_ = repo.Insert(context.Background(), &entity.Track{Title: "a", Enabled: true})
|
_ = repo.Insert(context.Background(), &entity.Track{Title: "a", Enabled: true})
|
||||||
pl, err := svc.Playlist(context.Background(), 0)
|
pl, err := svc.Playlist(context.Background(), PlaylistQuery{})
|
||||||
if err != nil || len(pl.Items) != 1 || pl.MemberPreviewSeconds != 30 {
|
if err != nil || len(pl.Items) != 1 || pl.MemberPreviewSeconds != 30 {
|
||||||
t.Fatalf("playlist %+v %v", pl, err)
|
t.Fatalf("playlist %+v %v", pl, err)
|
||||||
}
|
}
|
||||||
@ -196,6 +212,73 @@ func TestServicePlaylistAndSettings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServiceGetPublicAndRequireMember(t *testing.T) {
|
||||||
|
svc, repo := newTestService()
|
||||||
|
ctx := context.Background()
|
||||||
|
_ = repo.Insert(ctx, &entity.Track{Title: "free", Enabled: true, RequireMember: false})
|
||||||
|
_ = repo.Insert(ctx, &entity.Track{Title: "vip", Enabled: true, RequireMember: true})
|
||||||
|
_ = repo.Insert(ctx, &entity.Track{Title: "off", Enabled: false})
|
||||||
|
|
||||||
|
got, err := svc.GetPublic(ctx, 1)
|
||||||
|
if err != nil || got.Title != "free" {
|
||||||
|
t.Fatalf("get %+v %v", got, err)
|
||||||
|
}
|
||||||
|
if _, err := svc.GetPublic(ctx, 3); err == nil {
|
||||||
|
t.Fatal("want disabled not found")
|
||||||
|
}
|
||||||
|
if _, err := svc.GetPublic(ctx, 0); err == nil {
|
||||||
|
t.Fatal("want invalid id")
|
||||||
|
}
|
||||||
|
|
||||||
|
wantVIP := true
|
||||||
|
vipOnly, err := svc.Playlist(ctx, PlaylistQuery{RequireMember: &wantVIP})
|
||||||
|
if err != nil || len(vipOnly.Items) != 1 || !vipOnly.Items[0].RequireMember {
|
||||||
|
t.Fatalf("vip filter %+v %v", vipOnly, err)
|
||||||
|
}
|
||||||
|
wantFree := false
|
||||||
|
freeOnly, err := svc.Playlist(ctx, PlaylistQuery{RequireMember: &wantFree})
|
||||||
|
if err != nil || len(freeOnly.Items) != 1 || freeOnly.Items[0].RequireMember {
|
||||||
|
t.Fatalf("free filter %+v %v", freeOnly, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServicePlaylistGrouped(t *testing.T) {
|
||||||
|
svc, _ := newTestService()
|
||||||
|
ctx := context.Background()
|
||||||
|
root, err := svc.CreateCategory(ctx, CategoryInput{Name: "Home"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pid := root.ID
|
||||||
|
rain, err := svc.CreateCategory(ctx, CategoryInput{Name: "Rain", ParentID: &pid})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wind, err := svc.CreateCategory(ctx, CategoryInput{Name: "Wind", ParentID: &pid})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := svc.Create(ctx, UpsertInput{Title: "soft rain", CategoryIDs: []int64{rain.ID}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := svc.Create(ctx, UpsertInput{Title: "gentle wind", CategoryIDs: []int64{wind.ID}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
all, err := svc.PlaylistGrouped(ctx, 0)
|
||||||
|
if err != nil || len(all.Groups) != 2 {
|
||||||
|
t.Fatalf("grouped all %+v %v", all, err)
|
||||||
|
}
|
||||||
|
underHome, err := svc.PlaylistGrouped(ctx, root.ID)
|
||||||
|
if err != nil || len(underHome.Groups) != 2 {
|
||||||
|
t.Fatalf("under home %+v %v", underHome, err)
|
||||||
|
}
|
||||||
|
one, err := svc.PlaylistGrouped(ctx, rain.ID)
|
||||||
|
if err != nil || len(one.Groups) != 1 || one.Groups[0].CategoryID != rain.ID || len(one.Groups[0].Items) != 1 {
|
||||||
|
t.Fatalf("leaf group %+v %v", one, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestServiceCRUD(t *testing.T) {
|
func TestServiceCRUD(t *testing.T) {
|
||||||
svc, _ := newTestService()
|
svc, _ := newTestService()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@ -308,7 +391,7 @@ func TestNewServicePublicURLDefaults(t *testing.T) {
|
|||||||
|
|
||||||
func TestServiceNotReady(t *testing.T) {
|
func TestServiceNotReady(t *testing.T) {
|
||||||
var svc *Service
|
var svc *Service
|
||||||
if _, err := svc.Playlist(context.Background(), 0); !errors.Is(err, ErrNotReady) {
|
if _, err := svc.Playlist(context.Background(), PlaylistQuery{}); !errors.Is(err, ErrNotReady) {
|
||||||
t.Fatalf("%v", err)
|
t.Fatalf("%v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
104
internal/module/square/entity/channel.go
Normal file
104
internal/module/square/entity/channel.go
Normal 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
|
||||||
|
}
|
||||||
@ -4,6 +4,8 @@ package entity
|
|||||||
type Post struct {
|
type Post struct {
|
||||||
ID int64
|
ID int64
|
||||||
UserID int64
|
UserID int64
|
||||||
|
Channel Channel
|
||||||
|
Category Category
|
||||||
Kind Kind
|
Kind Kind
|
||||||
Text string
|
Text string
|
||||||
Content []byte
|
Content []byte
|
||||||
@ -65,6 +67,7 @@ type Comment struct {
|
|||||||
type Notice struct {
|
type Notice struct {
|
||||||
ID int64
|
ID int64
|
||||||
ToUserID int64
|
ToUserID int64
|
||||||
|
Channel Channel
|
||||||
FromUserID int64
|
FromUserID int64
|
||||||
Kind NoticeKind
|
Kind NoticeKind
|
||||||
PostID int64
|
PostID int64
|
||||||
@ -73,13 +76,17 @@ type Notice struct {
|
|||||||
CreatedAtMS int64
|
CreatedAtMS int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// NoticeState is per-user read watermark for square notices.
|
// NoticeState is per-user per-channel read watermark for square notices.
|
||||||
type NoticeState struct {
|
type NoticeState struct {
|
||||||
UserID int64
|
UserID int64
|
||||||
|
Channel Channel
|
||||||
LastReadNoticeID int64
|
LastReadNoticeID int64
|
||||||
UpdatedAtMS int64
|
UpdatedAtMS int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultNoticeState(userID int64) *NoticeState {
|
func DefaultNoticeState(userID int64, channel Channel) *NoticeState {
|
||||||
return &NoticeState{UserID: userID}
|
if channel == "" {
|
||||||
|
channel = ChannelSquare
|
||||||
|
}
|
||||||
|
return &NoticeState{UserID: userID, Channel: channel}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,13 +8,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// UserPosts lists posts by owner visible to viewer (profile square tab).
|
// UserPosts lists posts by owner visible to viewer (profile square tab).
|
||||||
func (s *Service) UserPosts(ctx context.Context, viewerID, ownerID, cursor int64, limit int) (*ListResult, error) {
|
func (s *Service) UserPosts(ctx context.Context, viewerID, ownerID int64, channel entity.Channel, cursor int64, limit int) (*ListResult, error) {
|
||||||
if !s.ready() || viewerID <= 0 {
|
if !s.ready() || viewerID <= 0 {
|
||||||
return nil, errs.ErrUnauthorized
|
return nil, errs.ErrUnauthorized
|
||||||
}
|
}
|
||||||
if ownerID <= 0 {
|
if ownerID <= 0 {
|
||||||
return nil, errs.ErrInvalidArgument
|
return nil, errs.ErrInvalidArgument
|
||||||
}
|
}
|
||||||
|
channel = normalizeChannel(channel)
|
||||||
|
if !channel.Valid() {
|
||||||
|
return nil, errs.ErrInvalidArgument
|
||||||
|
}
|
||||||
if ownerID != viewerID {
|
if ownerID != viewerID {
|
||||||
blocked, err := s.friends.BlockedEither(ctx, viewerID, ownerID)
|
blocked, err := s.friends.BlockedEither(ctx, viewerID, ownerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -28,7 +32,7 @@ func (s *Service) UserPosts(ctx context.Context, viewerID, ownerID, cursor int64
|
|||||||
picked := make([]*entity.Post, 0, limit)
|
picked := make([]*entity.Post, 0, limit)
|
||||||
scan := cursor
|
scan := cursor
|
||||||
for range entity.OverfetchRounds {
|
for range entity.OverfetchRounds {
|
||||||
batch, err := s.repo.ListByAuthor(ctx, ownerID, scan, entity.Overfetch)
|
batch, err := s.repo.ListByAuthor(ctx, channel, ownerID, scan, entity.Overfetch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,7 +34,8 @@ func (s *Service) oneView(viewer int64, p *entity.Post, likes []*entity.Like, co
|
|||||||
likeCards, liked, likeN := filterLikes(viewer, likes, users, remarks, full)
|
likeCards, liked, likeN := filterLikes(viewer, likes, users, remarks, full)
|
||||||
commentViews, commentN := filterComments(comments, users, remarks, full)
|
commentViews, commentN := filterComments(comments, users, remarks, full)
|
||||||
return &View{
|
return &View{
|
||||||
ID: p.ID, User: cardOf(users[p.UserID], remarks[p.UserID]), Kind: p.Kind, Text: p.Text,
|
ID: p.ID, Channel: normalizeChannel(p.Channel), Category: p.Category,
|
||||||
|
User: cardOf(users[p.UserID], remarks[p.UserID]), Kind: p.Kind, Text: p.Text,
|
||||||
Content: contentJSON(p.Content), POI: poiPtr(p.POI), Visibility: p.Visibility,
|
Content: contentJSON(p.Content), POI: poiPtr(p.POI), Visibility: p.Visibility,
|
||||||
LikeCount: likeN, CommentCount: commentN,
|
LikeCount: likeN, CommentCount: commentN,
|
||||||
Liked: liked, Likes: likeCards, Comments: commentViews, CreatedAt: p.CreatedAtMS,
|
Liked: liked, Likes: likeCards, Comments: commentViews, CreatedAt: p.CreatedAtMS,
|
||||||
|
|||||||
@ -18,8 +18,8 @@ type CommentInput struct {
|
|||||||
ReplyToID int64 `json:"reply_to_id" validate:"omitempty,gt=0" label:"field.square_reply"`
|
ReplyToID int64 `json:"reply_to_id" validate:"omitempty,gt=0" label:"field.square_reply"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Comment(ctx context.Context, userID, postID int64, in *CommentInput) (*View, error) {
|
func (s *Service) Comment(ctx context.Context, userID, postID int64, channel entity.Channel, in *CommentInput) (*View, error) {
|
||||||
post, err := s.visiblePost(ctx, userID, postID)
|
post, err := s.visiblePost(ctx, userID, postID, channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -56,11 +56,11 @@ func (s *Service) Comment(ctx context.Context, userID, postID int64, in *Comment
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
_ = s.notify(ctx, commentNotices(post, row, userID))
|
_ = s.notify(ctx, commentNotices(post, row, userID))
|
||||||
return s.Get(ctx, userID, postID)
|
return s.Get(ctx, userID, postID, channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) DeleteComment(ctx context.Context, userID, postID, commentID int64) (*View, error) {
|
func (s *Service) DeleteComment(ctx context.Context, userID, postID, commentID int64, channel entity.Channel) (*View, error) {
|
||||||
post, err := s.visiblePost(ctx, userID, postID)
|
post, err := s.visiblePost(ctx, userID, postID, channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -77,7 +77,7 @@ func (s *Service) DeleteComment(ctx context.Context, userID, postID, commentID i
|
|||||||
if err := s.repo.SoftDeleteComment(ctx, commentID); err != nil {
|
if err := s.repo.SoftDeleteComment(ctx, commentID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.Get(ctx, userID, postID)
|
return s.Get(ctx, userID, postID, channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) loadParent(ctx context.Context, postID, replyToID int64) (*entity.Comment, error) {
|
func (s *Service) loadParent(ctx context.Context, postID, replyToID int64) (*entity.Comment, error) {
|
||||||
@ -97,6 +97,10 @@ func (s *Service) loadParent(ctx context.Context, postID, replyToID int64) (*ent
|
|||||||
func commentNotices(post *entity.Post, c *entity.Comment, from int64) []*entity.Notice {
|
func commentNotices(post *entity.Post, c *entity.Comment, from int64) []*entity.Notice {
|
||||||
seen := map[int64]struct{}{}
|
seen := map[int64]struct{}{}
|
||||||
out := make([]*entity.Notice, 0, 2)
|
out := make([]*entity.Notice, 0, 2)
|
||||||
|
ch := post.Channel
|
||||||
|
if ch == "" {
|
||||||
|
ch = entity.ChannelSquare
|
||||||
|
}
|
||||||
add := func(to int64, kind entity.NoticeKind) {
|
add := func(to int64, kind entity.NoticeKind) {
|
||||||
if to <= 0 || to == from {
|
if to <= 0 || to == from {
|
||||||
return
|
return
|
||||||
@ -106,7 +110,7 @@ func commentNotices(post *entity.Post, c *entity.Comment, from int64) []*entity.
|
|||||||
}
|
}
|
||||||
seen[to] = struct{}{}
|
seen[to] = struct{}{}
|
||||||
out = append(out, &entity.Notice{
|
out = append(out, &entity.Notice{
|
||||||
ToUserID: to, FromUserID: from, Kind: kind, PostID: post.ID, CommentID: c.ID,
|
ToUserID: to, Channel: ch, FromUserID: from, Kind: kind, PostID: post.ID, CommentID: c.ID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if c.ReplyToUID > 0 {
|
if c.ReplyToUID > 0 {
|
||||||
|
|||||||
@ -7,18 +7,26 @@ import (
|
|||||||
"spark-server/internal/pkg/errs"
|
"spark-server/internal/pkg/errs"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Service) Delete(ctx context.Context, userID, postID int64) error {
|
func (s *Service) Delete(ctx context.Context, userID, postID int64, channel entity.Channel) error {
|
||||||
if !s.ready() || userID <= 0 {
|
if !s.ready() || userID <= 0 {
|
||||||
return errs.ErrUnauthorized
|
return errs.ErrUnauthorized
|
||||||
}
|
}
|
||||||
if postID <= 0 {
|
if postID <= 0 {
|
||||||
return errs.ErrInvalidArgument
|
return errs.ErrInvalidArgument
|
||||||
}
|
}
|
||||||
post, err := s.repo.SoftDeletePost(ctx, userID, postID)
|
channel = normalizeChannel(channel)
|
||||||
|
post, err := s.repo.GetPost(ctx, postID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.deleteMedia(ctx, post)
|
if normalizeChannel(post.Channel) != channel || post.UserID != userID {
|
||||||
|
return errs.ErrNotFound
|
||||||
|
}
|
||||||
|
deleted, err := s.repo.SoftDeletePost(ctx, userID, postID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.deleteMedia(ctx, deleted)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,15 +7,33 @@ import (
|
|||||||
"spark-server/internal/pkg/errs"
|
"spark-server/internal/pkg/errs"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Service) Feed(ctx context.Context, viewerID, cursor int64, limit int) (*ListResult, error) {
|
// FeedQuery is list filter/sort for a plaza channel.
|
||||||
|
type FeedQuery struct {
|
||||||
|
Cursor int64
|
||||||
|
Limit int
|
||||||
|
Category entity.Category
|
||||||
|
Lat float64
|
||||||
|
Lng float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Feed(ctx context.Context, viewerID int64, channel entity.Channel, q FeedQuery) (*ListResult, error) {
|
||||||
if !s.ready() || viewerID <= 0 {
|
if !s.ready() || viewerID <= 0 {
|
||||||
return nil, errs.ErrUnauthorized
|
return nil, errs.ErrUnauthorized
|
||||||
}
|
}
|
||||||
limit = entity.ClampLimit(limit)
|
channel = normalizeChannel(channel)
|
||||||
|
if !channel.Valid() {
|
||||||
|
return nil, errs.ErrInvalidArgument
|
||||||
|
}
|
||||||
|
if !q.Category.Valid() {
|
||||||
|
return nil, errs.ErrInvalidArgument
|
||||||
|
}
|
||||||
|
limit := entity.ClampLimit(q.Limit)
|
||||||
picked := make([]*entity.Post, 0, limit)
|
picked := make([]*entity.Post, 0, limit)
|
||||||
scan := cursor
|
scan := q.Cursor
|
||||||
for range entity.OverfetchRounds {
|
for range entity.OverfetchRounds {
|
||||||
batch, err := s.repo.ListPublicFeed(ctx, scan, entity.Overfetch)
|
batch, err := s.repo.ListPublicFeed(ctx, channel, entity.FeedListQuery{
|
||||||
|
Cursor: scan, Limit: entity.Overfetch, Category: q.Category, Lat: q.Lat, Lng: q.Lng,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -55,3 +73,23 @@ func (s *Service) listResult(ctx context.Context, viewer int64, posts []*entity.
|
|||||||
hasMore := len(posts) >= limit && limit > 0
|
hasMore := len(posts) >= limit && limit > 0
|
||||||
return &ListResult{Items: views, NextCursor: next, HasMore: hasMore}, nil
|
return &ListResult{Items: views, NextCursor: next, HasMore: hasMore}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CategoryItem is one filter tab for clients.
|
||||||
|
type CategoryItem struct {
|
||||||
|
Value entity.Category `json:"value"`
|
||||||
|
Kind string `json:"kind"` // feed | tag
|
||||||
|
LabelKey string `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Categories returns filter tabs (feed modes + tags) shared by every channel.
|
||||||
|
func (s *Service) Categories() []CategoryItem {
|
||||||
|
out := make([]CategoryItem, 0, len(entity.Categories()))
|
||||||
|
for _, c := range entity.Categories() {
|
||||||
|
kind := "feed"
|
||||||
|
if c.IsTag() {
|
||||||
|
kind = "tag"
|
||||||
|
}
|
||||||
|
out = append(out, CategoryItem{Value: c, Kind: kind})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@ -9,8 +9,8 @@ import (
|
|||||||
"spark-server/internal/pkg/errs"
|
"spark-server/internal/pkg/errs"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Service) Like(ctx context.Context, userID, postID int64) (*View, error) {
|
func (s *Service) Like(ctx context.Context, userID, postID int64, channel entity.Channel) (*View, error) {
|
||||||
post, err := s.visiblePost(ctx, userID, postID)
|
post, err := s.visiblePost(ctx, userID, postID, channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -22,32 +22,36 @@ func (s *Service) Like(ctx context.Context, userID, postID int64) (*View, error)
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if created {
|
if created {
|
||||||
_ = s.notify(ctx, []*entity.Notice{s.newNotice(post.UserID, userID, entity.NoticeLike, postID, 0)})
|
_ = s.notify(ctx, []*entity.Notice{s.newNotice(post.UserID, userID, entity.NoticeLike, post.Channel, postID, 0)})
|
||||||
}
|
}
|
||||||
return s.Get(ctx, userID, postID)
|
return s.Get(ctx, userID, postID, channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Unlike(ctx context.Context, userID, postID int64) (*View, error) {
|
func (s *Service) Unlike(ctx context.Context, userID, postID int64, channel entity.Channel) (*View, error) {
|
||||||
if _, err := s.visiblePost(ctx, userID, postID); err != nil {
|
if _, err := s.visiblePost(ctx, userID, postID, channel); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := s.repo.RemoveLike(ctx, postID, userID); err != nil && !errors.Is(err, errs.ErrNotFound) {
|
if err := s.repo.RemoveLike(ctx, postID, userID); err != nil && !errors.Is(err, errs.ErrNotFound) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.Get(ctx, userID, postID)
|
return s.Get(ctx, userID, postID, channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) visiblePost(ctx context.Context, viewerID, postID int64) (*entity.Post, error) {
|
func (s *Service) visiblePost(ctx context.Context, viewerID, postID int64, channel entity.Channel) (*entity.Post, error) {
|
||||||
if !s.ready() || viewerID <= 0 {
|
if !s.ready() || viewerID <= 0 {
|
||||||
return nil, errs.ErrUnauthorized
|
return nil, errs.ErrUnauthorized
|
||||||
}
|
}
|
||||||
if postID <= 0 {
|
if postID <= 0 {
|
||||||
return nil, errs.ErrInvalidArgument
|
return nil, errs.ErrInvalidArgument
|
||||||
}
|
}
|
||||||
|
channel = normalizeChannel(channel)
|
||||||
post, err := s.repo.GetPost(ctx, postID)
|
post, err := s.repo.GetPost(ctx, postID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if normalizeChannel(post.Channel) != channel {
|
||||||
|
return nil, errs.ErrNotFound
|
||||||
|
}
|
||||||
vis, err := s.visFor(ctx, viewerID, []*entity.Post{post})
|
vis, err := s.visFor(ctx, viewerID, []*entity.Post{post})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@ -32,12 +32,16 @@ type NoticeReadInput struct {
|
|||||||
Cursor int64 `json:"cursor" validate:"omitempty,gte=0" label:"field.square_notice_cursor"`
|
Cursor int64 `json:"cursor" validate:"omitempty,gte=0" label:"field.square_notice_cursor"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ListNotices(ctx context.Context, userID, cursor int64, limit int) (*NoticeList, error) {
|
func (s *Service) ListNotices(ctx context.Context, userID int64, channel entity.Channel, cursor int64, limit int) (*NoticeList, error) {
|
||||||
if !s.ready() || userID <= 0 {
|
if !s.ready() || userID <= 0 {
|
||||||
return nil, errs.ErrUnauthorized
|
return nil, errs.ErrUnauthorized
|
||||||
}
|
}
|
||||||
|
channel = normalizeChannel(channel)
|
||||||
|
if !channel.Valid() {
|
||||||
|
return nil, errs.ErrInvalidArgument
|
||||||
|
}
|
||||||
limit = entity.ClampLimit(limit)
|
limit = entity.ClampLimit(limit)
|
||||||
rows, err := s.repo.ListNotices(ctx, userID, cursor, limit)
|
rows, err := s.repo.ListNotices(ctx, userID, channel, cursor, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -59,39 +63,47 @@ func (s *Service) ListNotices(ctx context.Context, userID, cursor int64, limit i
|
|||||||
return &NoticeList{Items: items, NextCursor: next}, nil
|
return &NoticeList{Items: items, NextCursor: next}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ReadNotices(ctx context.Context, userID int64, in *NoticeReadInput) error {
|
func (s *Service) ReadNotices(ctx context.Context, userID int64, channel entity.Channel, in *NoticeReadInput) error {
|
||||||
if !s.ready() || userID <= 0 {
|
if !s.ready() || userID <= 0 {
|
||||||
return errs.ErrUnauthorized
|
return errs.ErrUnauthorized
|
||||||
}
|
}
|
||||||
|
channel = normalizeChannel(channel)
|
||||||
|
if !channel.Valid() {
|
||||||
|
return errs.ErrInvalidArgument
|
||||||
|
}
|
||||||
cursor := int64(0)
|
cursor := int64(0)
|
||||||
if in != nil {
|
if in != nil {
|
||||||
cursor = in.Cursor
|
cursor = in.Cursor
|
||||||
}
|
}
|
||||||
if err := s.repo.MarkNoticesRead(ctx, userID, cursor); err != nil {
|
if err := s.repo.MarkNoticesRead(ctx, userID, channel, cursor); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if cursor > 0 {
|
if cursor > 0 {
|
||||||
return s.repo.AdvanceNoticeRead(ctx, userID, cursor)
|
return s.repo.AdvanceNoticeRead(ctx, userID, channel, cursor)
|
||||||
}
|
}
|
||||||
latest, err := s.repo.LatestNoticeID(ctx, userID)
|
latest, err := s.repo.LatestNoticeID(ctx, userID, channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if latest > 0 {
|
if latest > 0 {
|
||||||
return s.repo.AdvanceNoticeRead(ctx, userID, latest)
|
return s.repo.AdvanceNoticeRead(ctx, userID, channel, latest)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) NoticeUnread(ctx context.Context, userID int64) (*NoticeUnread, error) {
|
func (s *Service) NoticeUnread(ctx context.Context, userID int64, channel entity.Channel) (*NoticeUnread, error) {
|
||||||
if !s.ready() || userID <= 0 {
|
if !s.ready() || userID <= 0 {
|
||||||
return nil, errs.ErrUnauthorized
|
return nil, errs.ErrUnauthorized
|
||||||
}
|
}
|
||||||
st, err := s.repo.GetNoticeState(ctx, userID)
|
channel = normalizeChannel(channel)
|
||||||
|
if !channel.Valid() {
|
||||||
|
return nil, errs.ErrInvalidArgument
|
||||||
|
}
|
||||||
|
st, err := s.repo.GetNoticeState(ctx, userID, channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
latest, err := s.repo.LatestNoticeID(ctx, userID)
|
latest, err := s.repo.LatestNoticeID(ctx, userID, channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -111,14 +123,20 @@ func (s *Service) notify(ctx context.Context, rows []*entity.Notice) error {
|
|||||||
}
|
}
|
||||||
n.ID = id
|
n.ID = id
|
||||||
n.CreatedAtMS = now
|
n.CreatedAtMS = now
|
||||||
|
if n.Channel == "" {
|
||||||
|
n.Channel = entity.ChannelSquare
|
||||||
|
}
|
||||||
out = append(out, n)
|
out = append(out, n)
|
||||||
}
|
}
|
||||||
return s.repo.CreateNotices(ctx, out)
|
return s.repo.CreateNotices(ctx, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) newNotice(to, from int64, kind entity.NoticeKind, postID, commentID int64) *entity.Notice {
|
func (s *Service) newNotice(to, from int64, kind entity.NoticeKind, channel entity.Channel, postID, commentID int64) *entity.Notice {
|
||||||
if to <= 0 || to == from {
|
if to <= 0 || to == from {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &entity.Notice{ToUserID: to, FromUserID: from, Kind: kind, PostID: postID, CommentID: commentID}
|
if channel == "" {
|
||||||
|
channel = entity.ChannelSquare
|
||||||
|
}
|
||||||
|
return &entity.Notice{ToUserID: to, Channel: channel, FromUserID: from, Kind: kind, PostID: postID, CommentID: commentID}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,18 +21,26 @@ type PublishInput struct {
|
|||||||
Visibility entity.Visibility `json:"visibility" validate:"omitempty,oneof=0 1 2 3" label:"field.square_visibility" errmsg:"oneof:validate.square.visibility_invalid"`
|
Visibility entity.Visibility `json:"visibility" validate:"omitempty,oneof=0 1 2 3" label:"field.square_visibility" errmsg:"oneof:validate.square.visibility_invalid"`
|
||||||
AudienceUIDs []int64 `json:"audience_uids" validate:"omitempty,max=200,dive,gt=0" label:"field.square_audience"`
|
AudienceUIDs []int64 `json:"audience_uids" validate:"omitempty,max=200,dive,gt=0" label:"field.square_audience"`
|
||||||
POI *entity.POI `json:"poi"`
|
POI *entity.POI `json:"poi"`
|
||||||
|
Category entity.Category `json:"category" validate:"omitempty,oneof=funny beauty" label:"field.square_category" errmsg:"oneof:validate.square.category_invalid"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Publish(ctx context.Context, userID int64, in *PublishInput) (*View, error) {
|
func (s *Service) Publish(ctx context.Context, userID int64, channel entity.Channel, in *PublishInput) (*View, error) {
|
||||||
if !s.ready() || userID <= 0 {
|
if !s.ready() || userID <= 0 {
|
||||||
return nil, errs.ErrUnauthorized
|
return nil, errs.ErrUnauthorized
|
||||||
}
|
}
|
||||||
|
channel = normalizeChannel(channel)
|
||||||
|
if !channel.Valid() {
|
||||||
|
return nil, errs.ErrInvalidArgument
|
||||||
|
}
|
||||||
if in == nil {
|
if in == nil {
|
||||||
return nil, errs.ErrInvalidArgument
|
return nil, errs.ErrInvalidArgument
|
||||||
}
|
}
|
||||||
if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil {
|
if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if in.Category != "" && !in.Category.IsTag() {
|
||||||
|
return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeySquareCategoryInvalid, nil))
|
||||||
|
}
|
||||||
if !s.allowPublish(ctx, userID) {
|
if !s.allowPublish(ctx, userID) {
|
||||||
return nil, errs.ErrRateLimited
|
return nil, errs.ErrRateLimited
|
||||||
}
|
}
|
||||||
@ -60,7 +68,8 @@ func (s *Service) Publish(ctx context.Context, userID int64, in *PublishInput) (
|
|||||||
vis = entity.VisibilityPublic
|
vis = entity.VisibilityPublic
|
||||||
}
|
}
|
||||||
post := &entity.Post{
|
post := &entity.Post{
|
||||||
ID: id, UserID: userID, Kind: kind, Text: strings.TrimSpace(in.Text), Content: raw,
|
ID: id, UserID: userID, Channel: channel, Category: in.Category,
|
||||||
|
Kind: kind, Text: strings.TrimSpace(in.Text), Content: raw,
|
||||||
Visibility: vis, AuditStatus: entity.AuditApproved, Status: entity.StatusNormal,
|
Visibility: vis, AuditStatus: entity.AuditApproved, Status: entity.StatusNormal,
|
||||||
CreatedAtMS: now.UnixMilli(),
|
CreatedAtMS: now.UnixMilli(),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,8 +16,8 @@ type Repository interface {
|
|||||||
CreatePost(ctx context.Context, post *entity.Post, audience []int64) error
|
CreatePost(ctx context.Context, post *entity.Post, audience []int64) error
|
||||||
CreateBotPost(ctx context.Context, post *entity.Post) error
|
CreateBotPost(ctx context.Context, post *entity.Post) error
|
||||||
GetPost(ctx context.Context, id int64) (*entity.Post, error)
|
GetPost(ctx context.Context, id int64) (*entity.Post, error)
|
||||||
ListPublicFeed(ctx context.Context, cursor int64, limit int) ([]*entity.Post, error)
|
ListPublicFeed(ctx context.Context, channel entity.Channel, q entity.FeedListQuery) ([]*entity.Post, error)
|
||||||
ListByAuthor(ctx context.Context, authorID, cursor int64, limit int) ([]*entity.Post, error)
|
ListByAuthor(ctx context.Context, channel entity.Channel, authorID, cursor int64, limit int) ([]*entity.Post, error)
|
||||||
SoftDeletePost(ctx context.Context, userID, postID int64) (*entity.Post, error)
|
SoftDeletePost(ctx context.Context, userID, postID int64) (*entity.Post, error)
|
||||||
SoftDeleteExpiredBotPosts(ctx context.Context, nowMS int64, limit int) ([]*entity.Post, error)
|
SoftDeleteExpiredBotPosts(ctx context.Context, nowMS int64, limit int) ([]*entity.Post, error)
|
||||||
ListAudience(ctx context.Context, postIDs []int64) (map[int64][]int64, error)
|
ListAudience(ctx context.Context, postIDs []int64) (map[int64][]int64, error)
|
||||||
@ -29,11 +29,11 @@ type Repository interface {
|
|||||||
AddComment(ctx context.Context, c *entity.Comment) error
|
AddComment(ctx context.Context, c *entity.Comment) error
|
||||||
SoftDeleteComment(ctx context.Context, id int64) error
|
SoftDeleteComment(ctx context.Context, id int64) error
|
||||||
CreateNotices(ctx context.Context, rows []*entity.Notice) error
|
CreateNotices(ctx context.Context, rows []*entity.Notice) error
|
||||||
ListNotices(ctx context.Context, userID, cursor int64, limit int) ([]*entity.Notice, error)
|
ListNotices(ctx context.Context, userID int64, channel entity.Channel, cursor int64, limit int) ([]*entity.Notice, error)
|
||||||
MarkNoticesRead(ctx context.Context, userID, cursor int64) error
|
MarkNoticesRead(ctx context.Context, userID int64, channel entity.Channel, cursor int64) error
|
||||||
GetNoticeState(ctx context.Context, userID int64) (*entity.NoticeState, error)
|
GetNoticeState(ctx context.Context, userID int64, channel entity.Channel) (*entity.NoticeState, error)
|
||||||
LatestNoticeID(ctx context.Context, userID int64) (int64, error)
|
LatestNoticeID(ctx context.Context, userID int64, channel entity.Channel) (int64, error)
|
||||||
AdvanceNoticeRead(ctx context.Context, userID, noticeID int64) error
|
AdvanceNoticeRead(ctx context.Context, userID int64, channel entity.Channel, noticeID int64) error
|
||||||
ListAllByAuthor(ctx context.Context, authorID int64) ([]*entity.Post, error)
|
ListAllByAuthor(ctx context.Context, authorID int64) ([]*entity.Post, error)
|
||||||
PurgeUser(ctx context.Context, userID int64) error
|
PurgeUser(ctx context.Context, userID int64) error
|
||||||
}
|
}
|
||||||
@ -91,3 +91,10 @@ func (s *Service) ConfigureMedia(p media.Policy, log *zap.Logger) {
|
|||||||
func (s *Service) ready() bool {
|
func (s *Service) ready() bool {
|
||||||
return s != nil && s.repo != nil && s.friends != nil && s.users != nil && s.ids != nil
|
return s != nil && s.repo != nil && s.friends != nil && s.users != nil && s.ids != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeChannel(ch entity.Channel) entity.Channel {
|
||||||
|
if ch == "" {
|
||||||
|
return entity.ChannelSquare
|
||||||
|
}
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|||||||
@ -75,34 +75,35 @@ func TestSquarePublishFeedPrivateAndNotice(t *testing.T) {
|
|||||||
becomeFriends(t, friends, a, b)
|
becomeFriends(t, friends, a, b)
|
||||||
ctxA := ctxUser(t, a.ID, "10.0.0.1")
|
ctxA := ctxUser(t, a.ID, "10.0.0.1")
|
||||||
ctxB := ctxUser(t, b.ID, "10.0.0.2")
|
ctxB := ctxUser(t, b.ID, "10.0.0.2")
|
||||||
pub, err := svc.Publish(ctxA, a.ID, &PublishInput{Text: "hello square"})
|
ch := entity.ChannelSquare
|
||||||
if err != nil || pub.Text != "hello square" {
|
pub, err := svc.Publish(ctxA, a.ID, ch, &PublishInput{Text: "hello square"})
|
||||||
|
if err != nil || pub.Text != "hello square" || pub.Channel != ch {
|
||||||
t.Fatalf("%+v %v", pub, err)
|
t.Fatalf("%+v %v", pub, err)
|
||||||
}
|
}
|
||||||
feed, err := svc.Feed(ctxB, b.ID, 0, 20)
|
feed, err := svc.Feed(ctxB, b.ID, ch, FeedQuery{Limit: 20})
|
||||||
if err != nil || len(feed.Items) != 1 {
|
if err != nil || len(feed.Items) != 1 {
|
||||||
t.Fatalf("feed %+v %v", feed, err)
|
t.Fatalf("feed %+v %v", feed, err)
|
||||||
}
|
}
|
||||||
priv, err := svc.Publish(ctxA, a.ID, &PublishInput{Text: "secret", Visibility: entity.VisibilityPrivate})
|
priv, err := svc.Publish(ctxA, a.ID, ch, &PublishInput{Text: "secret", Visibility: entity.VisibilityPrivate})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
feed, err = svc.Feed(ctxB, b.ID, 0, 20)
|
feed, err = svc.Feed(ctxB, b.ID, ch, FeedQuery{Limit: 20})
|
||||||
if err != nil || len(feed.Items) != 1 {
|
if err != nil || len(feed.Items) != 1 {
|
||||||
t.Fatalf("private in feed %+v", feed)
|
t.Fatalf("private in feed %+v", feed)
|
||||||
}
|
}
|
||||||
if _, err := svc.Get(ctxB, b.ID, priv.ID); !errors.Is(err, errs.ErrNotFound) {
|
if _, err := svc.Get(ctxB, b.ID, priv.ID, ch); !errors.Is(err, errs.ErrNotFound) {
|
||||||
t.Fatalf("private get %v", err)
|
t.Fatalf("private get %v", err)
|
||||||
}
|
}
|
||||||
liked, err := svc.Like(ctxB, b.ID, pub.ID)
|
liked, err := svc.Like(ctxB, b.ID, pub.ID, ch)
|
||||||
if err != nil || !liked.Liked {
|
if err != nil || !liked.Liked {
|
||||||
t.Fatal(liked, err)
|
t.Fatal(liked, err)
|
||||||
}
|
}
|
||||||
notices, err := svc.ListNotices(ctxA, a.ID, 0, 20)
|
notices, err := svc.ListNotices(ctxA, a.ID, ch, 0, 20)
|
||||||
if err != nil || len(notices.Items) != 1 || notices.Items[0].Kind != entity.NoticeLike {
|
if err != nil || len(notices.Items) != 1 || notices.Items[0].Kind != entity.NoticeLike {
|
||||||
t.Fatalf("notice %+v %v", notices, err)
|
t.Fatalf("notice %+v %v", notices, err)
|
||||||
}
|
}
|
||||||
unread, err := svc.NoticeUnread(ctxA, a.ID)
|
unread, err := svc.NoticeUnread(ctxA, a.ID, ch)
|
||||||
if err != nil || !unread.HasNew {
|
if err != nil || !unread.HasNew {
|
||||||
t.Fatalf("unread %+v", unread)
|
t.Fatalf("unread %+v", unread)
|
||||||
}
|
}
|
||||||
@ -117,16 +118,17 @@ func TestSquareExcludeIncludeAndUserPosts(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
ch := entity.ChannelSquare
|
||||||
ctxA := ctxUser(t, a.ID, "10.0.0.1")
|
ctxA := ctxUser(t, a.ID, "10.0.0.1")
|
||||||
ctxB := ctxUser(t, b.ID, "10.0.0.2")
|
ctxB := ctxUser(t, b.ID, "10.0.0.2")
|
||||||
ctxC := ctxUser(t, c.ID, "10.0.0.3")
|
ctxC := ctxUser(t, c.ID, "10.0.0.3")
|
||||||
excl, err := svc.Publish(ctxA, a.ID, &PublishInput{
|
excl, err := svc.Publish(ctxA, a.ID, ch, &PublishInput{
|
||||||
Text: "hide from b", Visibility: entity.VisibilityExclude, AudienceUIDs: []int64{b.ID},
|
Text: "hide from b", Visibility: entity.VisibilityExclude, AudienceUIDs: []int64{b.ID},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
feedB, err := svc.Feed(ctxB, b.ID, 0, 20)
|
feedB, err := svc.Feed(ctxB, b.ID, ch, FeedQuery{Limit: 20})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@ -135,17 +137,17 @@ func TestSquareExcludeIncludeAndUserPosts(t *testing.T) {
|
|||||||
t.Fatal("excluded user still sees post in feed")
|
t.Fatal("excluded user still sees post in feed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
feedC, err := svc.Feed(ctxC, c.ID, 0, 20)
|
feedC, err := svc.Feed(ctxC, c.ID, ch, FeedQuery{Limit: 20})
|
||||||
if err != nil || len(feedC.Items) != 1 {
|
if err != nil || len(feedC.Items) != 1 {
|
||||||
t.Fatalf("stranger feed %+v %v", feedC, err)
|
t.Fatalf("stranger feed %+v %v", feedC, err)
|
||||||
}
|
}
|
||||||
incl, err := svc.Publish(ctxA, a.ID, &PublishInput{
|
incl, err := svc.Publish(ctxA, a.ID, ch, &PublishInput{
|
||||||
Text: "only b", Visibility: entity.VisibilityInclude, AudienceUIDs: []int64{b.ID},
|
Text: "only b", Visibility: entity.VisibilityInclude, AudienceUIDs: []int64{b.ID},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
feedC, err = svc.Feed(ctxC, c.ID, 0, 20)
|
feedC, err = svc.Feed(ctxC, c.ID, ch, FeedQuery{Limit: 20})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@ -154,7 +156,7 @@ func TestSquareExcludeIncludeAndUserPosts(t *testing.T) {
|
|||||||
t.Fatal("include post leaked to stranger feed")
|
t.Fatal("include post leaked to stranger feed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
album, err := svc.UserPosts(ctxB, b.ID, a.ID, 0, 20)
|
album, err := svc.UserPosts(ctxB, b.ID, a.ID, ch, 0, 20)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@ -170,7 +172,7 @@ func TestSquareExcludeIncludeAndUserPosts(t *testing.T) {
|
|||||||
if !sawIncl {
|
if !sawIncl {
|
||||||
t.Fatalf("include missing from album %+v", album)
|
t.Fatalf("include missing from album %+v", album)
|
||||||
}
|
}
|
||||||
if _, err := svc.Get(ctxB, b.ID, incl.ID); err != nil {
|
if _, err := svc.Get(ctxB, b.ID, incl.ID, ch); err != nil {
|
||||||
t.Fatalf("include get %v", err)
|
t.Fatalf("include get %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -179,59 +181,60 @@ func TestSquareUnlikeCommentDeleteAndPurge(t *testing.T) {
|
|||||||
svc, friends, users := testSquare(t)
|
svc, friends, users := testSquare(t)
|
||||||
a, b := twoAccounts(t, users)
|
a, b := twoAccounts(t, users)
|
||||||
becomeFriends(t, friends, a, b)
|
becomeFriends(t, friends, a, b)
|
||||||
|
ch := entity.ChannelSquare
|
||||||
ctxA := ctxUser(t, a.ID, "10.0.0.1")
|
ctxA := ctxUser(t, a.ID, "10.0.0.1")
|
||||||
ctxB := ctxUser(t, b.ID, "10.0.0.2")
|
ctxB := ctxUser(t, b.ID, "10.0.0.2")
|
||||||
post, err := svc.Publish(ctxA, a.ID, &PublishInput{Text: "hi"})
|
post, err := svc.Publish(ctxA, a.ID, ch, &PublishInput{Text: "hi"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := svc.Like(ctxB, b.ID, post.ID); err != nil {
|
if _, err := svc.Like(ctxB, b.ID, post.ID, ch); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
unliked, err := svc.Unlike(ctxB, b.ID, post.ID)
|
unliked, err := svc.Unlike(ctxB, b.ID, post.ID, ch)
|
||||||
if err != nil || unliked.Liked || unliked.LikeCount != 0 {
|
if err != nil || unliked.Liked || unliked.LikeCount != 0 {
|
||||||
t.Fatalf("unlike %+v %v", unliked, err)
|
t.Fatalf("unlike %+v %v", unliked, err)
|
||||||
}
|
}
|
||||||
commented, err := svc.Comment(ctxB, b.ID, post.ID, &CommentInput{Text: "nice"})
|
commented, err := svc.Comment(ctxB, b.ID, post.ID, ch, &CommentInput{Text: "nice"})
|
||||||
if err != nil || commented.CommentCount != 1 {
|
if err != nil || commented.CommentCount != 1 {
|
||||||
t.Fatalf("comment %+v %v", commented, err)
|
t.Fatalf("comment %+v %v", commented, err)
|
||||||
}
|
}
|
||||||
cid := commented.Comments[0].ID
|
cid := commented.Comments[0].ID
|
||||||
replied, err := svc.Comment(ctxA, a.ID, post.ID, &CommentInput{Text: "thanks", ReplyToID: cid})
|
replied, err := svc.Comment(ctxA, a.ID, post.ID, ch, &CommentInput{Text: "thanks", ReplyToID: cid})
|
||||||
if err != nil || replied.CommentCount != 2 {
|
if err != nil || replied.CommentCount != 2 {
|
||||||
t.Fatalf("reply %+v %v", replied, err)
|
t.Fatalf("reply %+v %v", replied, err)
|
||||||
}
|
}
|
||||||
if _, err := svc.DeleteComment(ctxB, b.ID, post.ID, cid); err != nil {
|
if _, err := svc.DeleteComment(ctxB, b.ID, post.ID, cid, ch); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
got, err := svc.Get(ctxA, a.ID, post.ID)
|
got, err := svc.Get(ctxA, a.ID, post.ID, ch)
|
||||||
if err != nil || got.CommentCount != 1 {
|
if err != nil || got.CommentCount != 1 {
|
||||||
t.Fatalf("deleted comment %+v %v", got, err)
|
t.Fatalf("deleted comment %+v %v", got, err)
|
||||||
}
|
}
|
||||||
if err := svc.Delete(ctxA, a.ID, post.ID); err != nil {
|
if err := svc.Delete(ctxA, a.ID, post.ID, ch); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
feed, err := svc.Feed(ctxB, b.ID, 0, 20)
|
feed, err := svc.Feed(ctxB, b.ID, ch, FeedQuery{Limit: 20})
|
||||||
if err != nil || len(feed.Items) != 0 {
|
if err != nil || len(feed.Items) != 0 {
|
||||||
t.Fatalf("deleted feed %+v %v", feed, err)
|
t.Fatalf("deleted feed %+v %v", feed, err)
|
||||||
}
|
}
|
||||||
post2, err := svc.Publish(ctxA, a.ID, &PublishInput{
|
post2, err := svc.Publish(ctxA, a.ID, ch, &PublishInput{
|
||||||
Images: []*entity.Image{{Key: "perm/square/image/20260922/abcdefghijklmnop.jpg"}},
|
Images: []*entity.Image{{Key: "perm/square/image/20260922/abcdefghijklmnop.jpg"}},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := svc.ReadNotices(ctxA, a.ID, &NoticeReadInput{Cursor: 0}); err != nil {
|
if err := svc.ReadNotices(ctxA, a.ID, ch, &NoticeReadInput{Cursor: 0}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
unread, err := svc.NoticeUnread(ctxA, a.ID)
|
unread, err := svc.NoticeUnread(ctxA, a.ID, ch)
|
||||||
if err != nil || unread.HasNew {
|
if err != nil || unread.HasNew {
|
||||||
t.Fatalf("after read %+v %v", unread, err)
|
t.Fatalf("after read %+v %v", unread, err)
|
||||||
}
|
}
|
||||||
if err := svc.PurgeUser(ctxA, a.ID); err != nil {
|
if err := svc.PurgeUser(ctxA, a.ID); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := svc.Get(ctxA, a.ID, post2.ID); !errors.Is(err, errs.ErrNotFound) {
|
if _, err := svc.Get(ctxA, a.ID, post2.ID, ch); !errors.Is(err, errs.ErrNotFound) {
|
||||||
t.Fatalf("purged get %v", err)
|
t.Fatalf("purged get %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -240,10 +243,49 @@ func TestSquareImageKeyRejectedIfChatPrefix(t *testing.T) {
|
|||||||
svc, _, users := testSquare(t)
|
svc, _, users := testSquare(t)
|
||||||
a, _ := twoAccounts(t, users)
|
a, _ := twoAccounts(t, users)
|
||||||
ctx := ctxUser(t, a.ID, "10.0.0.1")
|
ctx := ctxUser(t, a.ID, "10.0.0.1")
|
||||||
_, err := svc.Publish(ctx, a.ID, &PublishInput{
|
_, err := svc.Publish(ctx, a.ID, entity.ChannelSquare, &PublishInput{
|
||||||
Images: []*entity.Image{{Key: "chat/image/20260918/xxxx.jpg"}},
|
Images: []*entity.Image{{Key: "chat/image/20260918/xxxx.jpg"}},
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("accepted chat key")
|
t.Fatal("accepted chat key")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSquareChannelIsolationAndCategory(t *testing.T) {
|
||||||
|
svc, friends, users := testSquare(t)
|
||||||
|
a, b := twoAccounts(t, users)
|
||||||
|
becomeFriends(t, friends, a, b)
|
||||||
|
ctxA := ctxUser(t, a.ID, "10.0.0.1")
|
||||||
|
ctxB := ctxUser(t, b.ID, "10.0.0.2")
|
||||||
|
main, err := svc.Publish(ctxA, a.ID, entity.ChannelSquare, &PublishInput{Text: "main", Category: entity.CategoryFunny})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
novel, err := svc.Publish(ctxA, a.ID, entity.ChannelSquareNovel, &PublishInput{Text: "novel", Category: entity.CategoryBeauty})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
feedMain, err := svc.Feed(ctxB, b.ID, entity.ChannelSquare, FeedQuery{Limit: 20})
|
||||||
|
if err != nil || len(feedMain.Items) != 1 || feedMain.Items[0].ID != main.ID {
|
||||||
|
t.Fatalf("main feed %+v %v", feedMain, err)
|
||||||
|
}
|
||||||
|
feedNovel, err := svc.Feed(ctxB, b.ID, entity.ChannelSquareNovel, FeedQuery{Limit: 20})
|
||||||
|
if err != nil || len(feedNovel.Items) != 1 || feedNovel.Items[0].ID != novel.ID {
|
||||||
|
t.Fatalf("novel feed %+v %v", feedNovel, err)
|
||||||
|
}
|
||||||
|
if _, err := svc.Get(ctxB, b.ID, novel.ID, entity.ChannelSquare); !errors.Is(err, errs.ErrNotFound) {
|
||||||
|
t.Fatalf("cross-channel get %v", err)
|
||||||
|
}
|
||||||
|
funny, err := svc.Feed(ctxB, b.ID, entity.ChannelSquare, FeedQuery{Category: entity.CategoryFunny, Limit: 20})
|
||||||
|
if err != nil || len(funny.Items) != 1 || funny.Items[0].Category != entity.CategoryFunny {
|
||||||
|
t.Fatalf("funny %+v %v", funny, err)
|
||||||
|
}
|
||||||
|
beauty, err := svc.Feed(ctxB, b.ID, entity.ChannelSquare, FeedQuery{Category: entity.CategoryBeauty, Limit: 20})
|
||||||
|
if err != nil || len(beauty.Items) != 0 {
|
||||||
|
t.Fatalf("beauty on main %+v %v", beauty, err)
|
||||||
|
}
|
||||||
|
cats := svc.Categories()
|
||||||
|
if len(cats) < 6 {
|
||||||
|
t.Fatalf("categories %+v", cats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -29,6 +29,8 @@ type CommentView struct {
|
|||||||
|
|
||||||
type View struct {
|
type View struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
|
Channel entity.Channel `json:"channel"`
|
||||||
|
Category entity.Category `json:"category,omitempty"`
|
||||||
User UserCard `json:"user"`
|
User UserCard `json:"user"`
|
||||||
Kind entity.Kind `json:"kind"`
|
Kind entity.Kind `json:"kind"`
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
@ -49,17 +51,21 @@ type ListResult struct {
|
|||||||
HasMore bool `json:"has_more"`
|
HasMore bool `json:"has_more"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Get(ctx context.Context, viewerID, postID int64) (*View, error) {
|
func (s *Service) Get(ctx context.Context, viewerID, postID int64, channel entity.Channel) (*View, error) {
|
||||||
if !s.ready() || viewerID <= 0 {
|
if !s.ready() || viewerID <= 0 {
|
||||||
return nil, errs.ErrUnauthorized
|
return nil, errs.ErrUnauthorized
|
||||||
}
|
}
|
||||||
if postID <= 0 {
|
if postID <= 0 {
|
||||||
return nil, errs.ErrInvalidArgument
|
return nil, errs.ErrInvalidArgument
|
||||||
}
|
}
|
||||||
|
channel = normalizeChannel(channel)
|
||||||
post, err := s.repo.GetPost(ctx, postID)
|
post, err := s.repo.GetPost(ctx, postID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if normalizeChannel(post.Channel) != channel {
|
||||||
|
return nil, errs.ErrNotFound
|
||||||
|
}
|
||||||
vis, err := s.visFor(ctx, viewerID, []*entity.Post{post})
|
vis, err := s.visFor(ctx, viewerID, []*entity.Post{post})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package model
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"math"
|
||||||
"sort"
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@ -15,6 +16,11 @@ type likeKey struct {
|
|||||||
UserID int64
|
UserID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type noticeStateKey struct {
|
||||||
|
UserID int64
|
||||||
|
Channel entity.Channel
|
||||||
|
}
|
||||||
|
|
||||||
// MemoryRepo is an in-process square store for tests and empty-DSN bring-up.
|
// MemoryRepo is an in-process square store for tests and empty-DSN bring-up.
|
||||||
type MemoryRepo struct {
|
type MemoryRepo struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@ -23,7 +29,7 @@ type MemoryRepo struct {
|
|||||||
likes map[likeKey]*entity.Like
|
likes map[likeKey]*entity.Like
|
||||||
comments map[int64]*entity.Comment
|
comments map[int64]*entity.Comment
|
||||||
notices map[int64]*entity.Notice
|
notices map[int64]*entity.Notice
|
||||||
noticeState map[int64]*entity.NoticeState
|
noticeState map[noticeStateKey]*entity.NoticeState
|
||||||
botLinks map[string]int64 // url -> post_id
|
botLinks map[string]int64 // url -> post_id
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -34,11 +40,18 @@ func NewMemoryRepo() *MemoryRepo {
|
|||||||
likes: make(map[likeKey]*entity.Like),
|
likes: make(map[likeKey]*entity.Like),
|
||||||
comments: make(map[int64]*entity.Comment),
|
comments: make(map[int64]*entity.Comment),
|
||||||
notices: make(map[int64]*entity.Notice),
|
notices: make(map[int64]*entity.Notice),
|
||||||
noticeState: make(map[int64]*entity.NoticeState),
|
noticeState: make(map[noticeStateKey]*entity.NoticeState),
|
||||||
botLinks: make(map[string]int64),
|
botLinks: make(map[string]int64),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func memChannel(ch entity.Channel) entity.Channel {
|
||||||
|
if ch == "" {
|
||||||
|
return entity.ChannelSquare
|
||||||
|
}
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
func (m *MemoryRepo) GetPost(_ context.Context, id int64) (*entity.Post, error) {
|
func (m *MemoryRepo) GetPost(_ context.Context, id int64) (*entity.Post, error) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
@ -49,14 +62,15 @@ func (m *MemoryRepo) GetPost(_ context.Context, id int64) (*entity.Post, error)
|
|||||||
return clonePost(row), nil
|
return clonePost(row), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MemoryRepo) ListByAuthors(_ context.Context, authorIDs []int64, cursor int64, limit int) ([]*entity.Post, error) {
|
func (m *MemoryRepo) ListByAuthors(_ context.Context, channel entity.Channel, authorIDs []int64, cursor int64, limit int) ([]*entity.Post, error) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
ch := memChannel(channel)
|
||||||
allow := toSet(authorIDs)
|
allow := toSet(authorIDs)
|
||||||
nowMS := time.Now().UnixMilli()
|
nowMS := time.Now().UnixMilli()
|
||||||
out := make([]*entity.Post, 0)
|
out := make([]*entity.Post, 0)
|
||||||
for _, row := range m.posts {
|
for _, row := range m.posts {
|
||||||
if row.Status != entity.StatusNormal || !allow[row.UserID] {
|
if row.Status != entity.StatusNormal || !allow[row.UserID] || memChannel(row.Channel) != ch {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if row.Expired(nowMS) {
|
if row.Expired(nowMS) {
|
||||||
@ -74,8 +88,8 @@ func (m *MemoryRepo) ListByAuthors(_ context.Context, authorIDs []int64, cursor
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MemoryRepo) ListByAuthor(ctx context.Context, authorID, cursor int64, limit int) ([]*entity.Post, error) {
|
func (m *MemoryRepo) ListByAuthor(ctx context.Context, channel entity.Channel, authorID, cursor int64, limit int) ([]*entity.Post, error) {
|
||||||
return m.ListByAuthors(ctx, []int64{authorID}, cursor, limit)
|
return m.ListByAuthors(ctx, channel, []int64{authorID}, cursor, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MemoryRepo) ListAudience(_ context.Context, postIDs []int64) (map[int64][]int64, error) {
|
func (m *MemoryRepo) ListAudience(_ context.Context, postIDs []int64) (map[int64][]int64, error) {
|
||||||
@ -137,61 +151,136 @@ func (m *MemoryRepo) GetComment(_ context.Context, id int64) (*entity.Comment, e
|
|||||||
return cloneComment(row), nil
|
return cloneComment(row), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MemoryRepo) ListPublicFeed(_ context.Context, cursor int64, limit int) ([]*entity.Post, error) {
|
func (m *MemoryRepo) ListPublicFeed(_ context.Context, channel entity.Channel, q entity.FeedListQuery) ([]*entity.Post, error) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
nowMS := time.Now().UnixMilli()
|
nowMS := time.Now().UnixMilli()
|
||||||
|
ch := memChannel(channel)
|
||||||
out := make([]*entity.Post, 0)
|
out := make([]*entity.Post, 0)
|
||||||
for _, row := range m.posts {
|
for _, row := range m.posts {
|
||||||
if row.Status != entity.StatusNormal || row.AuditStatus != entity.AuditApproved {
|
if row.Status != entity.StatusNormal || row.AuditStatus != entity.AuditApproved {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if memChannel(row.Channel) != ch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if row.Visibility != entity.VisibilityPublic && row.Visibility != entity.VisibilityExclude {
|
if row.Visibility != entity.VisibilityPublic && row.Visibility != entity.VisibilityExclude {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if row.Expired(nowMS) {
|
if row.Expired(nowMS) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if cursor > 0 && row.ID >= cursor {
|
if q.Category.IsTag() && row.Category != q.Category {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if q.Category == entity.CategoryNearby && row.POI.Lat == 0 && row.POI.Lng == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, clonePost(row))
|
out = append(out, clonePost(row))
|
||||||
}
|
}
|
||||||
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
|
switch q.Category {
|
||||||
if limit > 0 && len(out) > limit {
|
case entity.CategoryHot:
|
||||||
out = out[:limit]
|
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 q.Cursor > 0 {
|
||||||
|
filtered := make([]*entity.Post, 0, len(out))
|
||||||
|
for _, p := range out {
|
||||||
|
if p.ID < q.Cursor {
|
||||||
|
filtered = append(filtered, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = filtered
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
|
||||||
|
if q.Cursor > 0 {
|
||||||
|
filtered := make([]*entity.Post, 0, len(out))
|
||||||
|
for _, p := range out {
|
||||||
|
if p.ID < q.Cursor {
|
||||||
|
filtered = append(filtered, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = filtered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if q.Limit > 0 && len(out) > q.Limit {
|
||||||
|
out = out[:q.Limit]
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MemoryRepo) GetNoticeState(_ context.Context, userID int64) (*entity.NoticeState, error) {
|
func (m *MemoryRepo) GetNoticeState(_ context.Context, userID int64, channel entity.Channel) (*entity.NoticeState, error) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
if row, ok := m.noticeState[userID]; ok {
|
ch := memChannel(channel)
|
||||||
|
if row, ok := m.noticeState[noticeStateKey{UserID: userID, Channel: ch}]; ok {
|
||||||
cp := *row
|
cp := *row
|
||||||
return &cp, nil
|
return &cp, nil
|
||||||
}
|
}
|
||||||
return entity.DefaultNoticeState(userID), nil
|
return entity.DefaultNoticeState(userID, ch), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MemoryRepo) LatestNoticeID(_ context.Context, userID int64) (int64, error) {
|
func (m *MemoryRepo) LatestNoticeID(_ context.Context, userID int64, channel entity.Channel) (int64, error) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
ch := memChannel(channel)
|
||||||
var latest int64
|
var latest int64
|
||||||
for _, n := range m.notices {
|
for _, n := range m.notices {
|
||||||
if n.ToUserID == userID && n.ID > latest {
|
if n.ToUserID == userID && memChannel(n.Channel) == ch && n.ID > latest {
|
||||||
latest = n.ID
|
latest = n.ID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return latest, nil
|
return latest, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MemoryRepo) ListNotices(_ context.Context, userID, cursor int64, limit int) ([]*entity.Notice, error) {
|
func (m *MemoryRepo) ListNotices(_ context.Context, userID int64, channel entity.Channel, cursor int64, limit int) ([]*entity.Notice, error) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
ch := memChannel(channel)
|
||||||
out := make([]*entity.Notice, 0)
|
out := make([]*entity.Notice, 0)
|
||||||
for _, n := range m.notices {
|
for _, n := range m.notices {
|
||||||
if n.ToUserID != userID {
|
if n.ToUserID != userID || memChannel(n.Channel) != ch {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if cursor > 0 && n.ID >= cursor {
|
if cursor > 0 && n.ID >= cursor {
|
||||||
@ -223,6 +312,9 @@ func clonePost(p *entity.Post) *entity.Post {
|
|||||||
if p.Content != nil {
|
if p.Content != nil {
|
||||||
cp.Content = append([]byte(nil), p.Content...)
|
cp.Content = append([]byte(nil), p.Content...)
|
||||||
}
|
}
|
||||||
|
if cp.Channel == "" {
|
||||||
|
cp.Channel = entity.ChannelSquare
|
||||||
|
}
|
||||||
return &cp
|
return &cp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -78,7 +78,11 @@ func (m *MemoryRepo) PurgeUser(_ context.Context, userID int64) error {
|
|||||||
}
|
}
|
||||||
m.audience[postID] = kept
|
m.audience[postID] = kept
|
||||||
}
|
}
|
||||||
delete(m.noticeState, userID)
|
for key := range m.noticeState {
|
||||||
|
if key.UserID == userID {
|
||||||
|
delete(m.noticeState, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
for id := range owned {
|
for id := range owned {
|
||||||
delete(m.posts, id)
|
delete(m.posts, id)
|
||||||
delete(m.audience, id)
|
delete(m.audience, id)
|
||||||
|
|||||||
@ -18,7 +18,11 @@ func (m *MemoryRepo) CreatePost(_ context.Context, post *entity.Post, audience [
|
|||||||
if _, ok := m.posts[post.ID]; ok {
|
if _, ok := m.posts[post.ID]; ok {
|
||||||
return errs.ErrDuplicate
|
return errs.ErrDuplicate
|
||||||
}
|
}
|
||||||
m.posts[post.ID] = clonePost(post)
|
cp := clonePost(post)
|
||||||
|
if cp.Channel == "" {
|
||||||
|
cp.Channel = entity.ChannelSquare
|
||||||
|
}
|
||||||
|
m.posts[post.ID] = cp
|
||||||
if len(audience) > 0 {
|
if len(audience) > 0 {
|
||||||
m.audience[post.ID] = append([]int64(nil), audience...)
|
m.audience[post.ID] = append([]int64(nil), audience...)
|
||||||
}
|
}
|
||||||
@ -167,17 +171,21 @@ func (m *MemoryRepo) CreateNotices(_ context.Context, rows []*entity.Notice) err
|
|||||||
return errs.ErrDuplicate
|
return errs.ErrDuplicate
|
||||||
}
|
}
|
||||||
cp := *n
|
cp := *n
|
||||||
|
if cp.Channel == "" {
|
||||||
|
cp.Channel = entity.ChannelSquare
|
||||||
|
}
|
||||||
m.notices[n.ID] = &cp
|
m.notices[n.ID] = &cp
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MemoryRepo) MarkNoticesRead(_ context.Context, userID, cursor int64) error {
|
func (m *MemoryRepo) MarkNoticesRead(_ context.Context, userID int64, channel entity.Channel, cursor int64) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
ch := memChannel(channel)
|
||||||
now := time.Now().UnixMilli()
|
now := time.Now().UnixMilli()
|
||||||
for _, n := range m.notices {
|
for _, n := range m.notices {
|
||||||
if n.ToUserID != userID || n.ReadAtMS > 0 {
|
if n.ToUserID != userID || memChannel(n.Channel) != ch || n.ReadAtMS > 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if cursor > 0 && n.ID > cursor {
|
if cursor > 0 && n.ID > cursor {
|
||||||
@ -188,16 +196,18 @@ func (m *MemoryRepo) MarkNoticesRead(_ context.Context, userID, cursor int64) er
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MemoryRepo) AdvanceNoticeRead(_ context.Context, userID, noticeID int64) error {
|
func (m *MemoryRepo) AdvanceNoticeRead(_ context.Context, userID int64, channel entity.Channel, noticeID int64) error {
|
||||||
if userID <= 0 || noticeID <= 0 {
|
if userID <= 0 || noticeID <= 0 {
|
||||||
return errs.ErrInvalidArgument
|
return errs.ErrInvalidArgument
|
||||||
}
|
}
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
cur := m.noticeState[userID]
|
ch := memChannel(channel)
|
||||||
|
key := noticeStateKey{UserID: userID, Channel: ch}
|
||||||
|
cur := m.noticeState[key]
|
||||||
if cur == nil {
|
if cur == nil {
|
||||||
cur = entity.DefaultNoticeState(userID)
|
cur = entity.DefaultNoticeState(userID, ch)
|
||||||
m.noticeState[userID] = cur
|
m.noticeState[key] = cur
|
||||||
}
|
}
|
||||||
if cur.LastReadNoticeID >= noticeID {
|
if cur.LastReadNoticeID >= noticeID {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import (
|
|||||||
type Post struct {
|
type Post struct {
|
||||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement:false;comment:Snowflake 帖 id"`
|
ID int64 `gorm:"column:id;primaryKey;autoIncrement:false;comment:Snowflake 帖 id"`
|
||||||
UserID int64 `gorm:"column:user_id;comment:作者 users.id"`
|
UserID int64 `gorm:"column:user_id;comment:作者 users.id"`
|
||||||
|
Channel string `gorm:"column:channel;comment:广场实例"`
|
||||||
|
Category string `gorm:"column:category;comment:内容标签"`
|
||||||
Kind int16 `gorm:"column:kind;comment:1纯文 2图 3视频"`
|
Kind int16 `gorm:"column:kind;comment:1纯文 2图 3视频"`
|
||||||
Text string `gorm:"column:text;comment:正文"`
|
Text string `gorm:"column:text;comment:正文"`
|
||||||
Content []byte `gorm:"column:content;type:varbinary(16384);comment:序列化 JSON 字节"`
|
Content []byte `gorm:"column:content;type:varbinary(16384);comment:序列化 JSON 字节"`
|
||||||
@ -36,8 +38,13 @@ func (r *Post) Entity() *entity.Post {
|
|||||||
if r == nil {
|
if r == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
ch := entity.Channel(r.Channel)
|
||||||
|
if ch == "" {
|
||||||
|
ch = entity.ChannelSquare
|
||||||
|
}
|
||||||
p := &entity.Post{
|
p := &entity.Post{
|
||||||
ID: r.ID, UserID: r.UserID, Kind: entity.Kind(r.Kind), Text: r.Text, Content: r.Content,
|
ID: r.ID, UserID: r.UserID, Channel: ch, Category: entity.Category(r.Category),
|
||||||
|
Kind: entity.Kind(r.Kind), Text: r.Text, Content: r.Content,
|
||||||
POI: entity.POI{Lat: r.POILat, Lng: r.POILng, Name: r.POIName, Address: r.POIAddress},
|
POI: entity.POI{Lat: r.POILat, Lng: r.POILng, Name: r.POIName, Address: r.POIAddress},
|
||||||
Visibility: entity.Visibility(r.Visibility), AuditStatus: entity.AuditStatus(r.AuditStatus),
|
Visibility: entity.Visibility(r.Visibility), AuditStatus: entity.AuditStatus(r.AuditStatus),
|
||||||
Status: entity.Status(r.Status), Source: entity.Source(r.Source), SourceURL: r.SourceURL,
|
Status: entity.Status(r.Status), Source: entity.Source(r.Source), SourceURL: r.SourceURL,
|
||||||
@ -53,8 +60,13 @@ func postFrom(p *entity.Post) *Post {
|
|||||||
if p == nil {
|
if p == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
ch := string(p.Channel)
|
||||||
|
if ch == "" {
|
||||||
|
ch = string(entity.ChannelSquare)
|
||||||
|
}
|
||||||
row := &Post{
|
row := &Post{
|
||||||
ID: p.ID, UserID: p.UserID, Kind: int16(p.Kind), Text: p.Text, Content: p.Content,
|
ID: p.ID, UserID: p.UserID, Channel: ch, Category: string(p.Category),
|
||||||
|
Kind: int16(p.Kind), Text: p.Text, Content: p.Content,
|
||||||
POILat: p.POI.Lat, POILng: p.POI.Lng, POIName: p.POI.Name, POIAddress: p.POI.Address,
|
POILat: p.POI.Lat, POILng: p.POI.Lng, POIName: p.POI.Name, POIAddress: p.POI.Address,
|
||||||
Visibility: int16(p.Visibility), AuditStatus: int16(p.AuditStatus), Status: int16(p.Status),
|
Visibility: int16(p.Visibility), AuditStatus: int16(p.AuditStatus), Status: int16(p.Status),
|
||||||
Source: int16(p.Source), SourceURL: p.SourceURL, LikeCount: p.LikeCount, CommentCount: p.CommentCount,
|
Source: int16(p.Source), SourceURL: p.SourceURL, LikeCount: p.LikeCount, CommentCount: p.CommentCount,
|
||||||
@ -152,6 +164,7 @@ func commentFrom(c *entity.Comment) *Comment {
|
|||||||
type Notice struct {
|
type Notice struct {
|
||||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement:false;comment:Snowflake 通知 id"`
|
ID int64 `gorm:"column:id;primaryKey;autoIncrement:false;comment:Snowflake 通知 id"`
|
||||||
ToUserID int64 `gorm:"column:to_user_id;comment:接收人"`
|
ToUserID int64 `gorm:"column:to_user_id;comment:接收人"`
|
||||||
|
Channel string `gorm:"column:channel;comment:广场实例"`
|
||||||
FromUserID int64 `gorm:"column:from_user_id;comment:发起人"`
|
FromUserID int64 `gorm:"column:from_user_id;comment:发起人"`
|
||||||
Kind int16 `gorm:"column:kind;comment:1赞 2评论 3回复"`
|
Kind int16 `gorm:"column:kind;comment:1赞 2评论 3回复"`
|
||||||
PostID int64 `gorm:"column:post_id;comment:帖 id"`
|
PostID int64 `gorm:"column:post_id;comment:帖 id"`
|
||||||
@ -166,8 +179,12 @@ func (r *Notice) Entity() *entity.Notice {
|
|||||||
if r == nil {
|
if r == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
ch := entity.Channel(r.Channel)
|
||||||
|
if ch == "" {
|
||||||
|
ch = entity.ChannelSquare
|
||||||
|
}
|
||||||
n := &entity.Notice{
|
n := &entity.Notice{
|
||||||
ID: r.ID, ToUserID: r.ToUserID, FromUserID: r.FromUserID, Kind: entity.NoticeKind(r.Kind),
|
ID: r.ID, ToUserID: r.ToUserID, Channel: ch, FromUserID: r.FromUserID, Kind: entity.NoticeKind(r.Kind),
|
||||||
PostID: r.PostID, CommentID: r.CommentID, CreatedAtMS: r.CreatedAt.UnixMilli(),
|
PostID: r.PostID, CommentID: r.CommentID, CreatedAtMS: r.CreatedAt.UnixMilli(),
|
||||||
}
|
}
|
||||||
if r.ReadAt != nil {
|
if r.ReadAt != nil {
|
||||||
@ -179,6 +196,7 @@ func (r *Notice) Entity() *entity.Notice {
|
|||||||
// NoticeState maps square_notice_state.
|
// NoticeState maps square_notice_state.
|
||||||
type NoticeState struct {
|
type NoticeState struct {
|
||||||
UserID int64 `gorm:"column:user_id;primaryKey;comment:用户 id"`
|
UserID int64 `gorm:"column:user_id;primaryKey;comment:用户 id"`
|
||||||
|
Channel string `gorm:"column:channel;primaryKey;comment:广场实例"`
|
||||||
LastReadNoticeID int64 `gorm:"column:last_read_notice_id;comment:已读水位"`
|
LastReadNoticeID int64 `gorm:"column:last_read_notice_id;comment:已读水位"`
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at;comment:更新时间"`
|
UpdatedAt time.Time `gorm:"column:updated_at;comment:更新时间"`
|
||||||
}
|
}
|
||||||
@ -189,7 +207,11 @@ func (r *NoticeState) Entity() *entity.NoticeState {
|
|||||||
if r == nil {
|
if r == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
ch := entity.Channel(r.Channel)
|
||||||
|
if ch == "" {
|
||||||
|
ch = entity.ChannelSquare
|
||||||
|
}
|
||||||
return &entity.NoticeState{
|
return &entity.NoticeState{
|
||||||
UserID: r.UserID, LastReadNoticeID: r.LastReadNoticeID, UpdatedAtMS: r.UpdatedAt.UnixMilli(),
|
UserID: r.UserID, Channel: ch, LastReadNoticeID: r.LastReadNoticeID, UpdatedAtMS: r.UpdatedAt.UnixMilli(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,8 @@ package model
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@ -25,7 +27,7 @@ func (m *Repo) GetPost(ctx context.Context, id int64) (*entity.Post, error) {
|
|||||||
return row.Entity(), nil
|
return row.Entity(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Repo) ListByAuthors(ctx context.Context, authorIDs []int64, cursor int64, limit int) ([]*entity.Post, error) {
|
func (m *Repo) ListByAuthors(ctx context.Context, channel entity.Channel, authorIDs []int64, cursor int64, limit int) ([]*entity.Post, error) {
|
||||||
if len(authorIDs) == 0 {
|
if len(authorIDs) == 0 {
|
||||||
return []*entity.Post{}, nil
|
return []*entity.Post{}, nil
|
||||||
}
|
}
|
||||||
@ -33,7 +35,8 @@ func (m *Repo) ListByAuthors(ctx context.Context, authorIDs []int64, cursor int6
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
q := db.Where("user_id IN ? AND status = ? AND "+notExpiredSQL, authorIDs, entity.StatusNormal, time.Now())
|
ch := normalizeChannel(channel)
|
||||||
|
q := db.Where("channel = ? AND user_id IN ? AND status = ? AND "+notExpiredSQL, ch, authorIDs, entity.StatusNormal, time.Now())
|
||||||
if cursor > 0 {
|
if cursor > 0 {
|
||||||
q = q.Where("id < ?", cursor)
|
q = q.Where("id < ?", cursor)
|
||||||
}
|
}
|
||||||
@ -44,31 +47,113 @@ func (m *Repo) ListByAuthors(ctx context.Context, authorIDs []int64, cursor int6
|
|||||||
return collectPosts(rows), nil
|
return collectPosts(rows), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Repo) ListByAuthor(ctx context.Context, authorID, cursor int64, limit int) ([]*entity.Post, error) {
|
func (m *Repo) ListByAuthor(ctx context.Context, channel entity.Channel, authorID, cursor int64, limit int) ([]*entity.Post, error) {
|
||||||
return m.ListByAuthors(ctx, []int64{authorID}, cursor, limit)
|
return m.ListByAuthors(ctx, channel, []int64{authorID}, cursor, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Repo) ListPublicFeed(ctx context.Context, cursor int64, limit int) ([]*entity.Post, error) {
|
func (m *Repo) ListPublicFeed(ctx context.Context, channel entity.Channel, q entity.FeedListQuery) ([]*entity.Post, error) {
|
||||||
db, err := m.session(ctx)
|
db, err := m.session(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
q := db.Where(
|
ch := normalizeChannel(channel)
|
||||||
"audit_status = ? AND status = ? AND visibility IN ? AND "+notExpiredSQL,
|
base := db.Where(
|
||||||
entity.AuditApproved, entity.StatusNormal,
|
"channel = ? AND audit_status = ? AND status = ? AND visibility IN ? AND "+notExpiredSQL,
|
||||||
|
ch, entity.AuditApproved, entity.StatusNormal,
|
||||||
[]int16{int16(entity.VisibilityPublic), int16(entity.VisibilityExclude)},
|
[]int16{int16(entity.VisibilityPublic), int16(entity.VisibilityExclude)},
|
||||||
time.Now(),
|
time.Now(),
|
||||||
)
|
)
|
||||||
if cursor > 0 {
|
if q.Category.IsTag() {
|
||||||
q = q.Where("id < ?", cursor)
|
base = base.Where("category = ?", string(q.Category))
|
||||||
|
}
|
||||||
|
switch q.Category {
|
||||||
|
case entity.CategoryHot:
|
||||||
|
return listHotFeed(base, q)
|
||||||
|
case entity.CategoryNearby:
|
||||||
|
return listNearbyFeed(base, q)
|
||||||
|
default:
|
||||||
|
return listLatestFeed(base, q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func listLatestFeed(base *gorm.DB, q entity.FeedListQuery) ([]*entity.Post, error) {
|
||||||
|
query := base
|
||||||
|
if q.Cursor > 0 {
|
||||||
|
query = query.Where("id < ?", q.Cursor)
|
||||||
}
|
}
|
||||||
var rows []Post
|
var rows []Post
|
||||||
if err := q.Order("id DESC").Limit(limit).Find(&rows).Error; err != nil {
|
if err := query.Order("id DESC").Limit(q.Limit).Find(&rows).Error; err != nil {
|
||||||
return nil, mapDBErr(err)
|
return nil, mapDBErr(err)
|
||||||
}
|
}
|
||||||
return collectPosts(rows), nil
|
return collectPosts(rows), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func listHotFeed(base *gorm.DB, q entity.FeedListQuery) ([]*entity.Post, error) {
|
||||||
|
query := base
|
||||||
|
if q.Cursor > 0 {
|
||||||
|
var cur Post
|
||||||
|
if err := base.Session(&gorm.Session{}).Where("id = ?", q.Cursor).Take(&cur).Error; err == nil {
|
||||||
|
query = query.Where(
|
||||||
|
"(like_count < ? OR (like_count = ? AND id < ?))",
|
||||||
|
cur.LikeCount, cur.LikeCount, cur.ID,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
query = query.Where("id < ?", q.Cursor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var rows []Post
|
||||||
|
if err := query.Order("like_count DESC, id DESC").Limit(q.Limit).Find(&rows).Error; err != nil {
|
||||||
|
return nil, mapDBErr(err)
|
||||||
|
}
|
||||||
|
return collectPosts(rows), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func listNearbyFeed(base *gorm.DB, q entity.FeedListQuery) ([]*entity.Post, error) {
|
||||||
|
query := base.Where("(poi_lat <> 0 OR poi_lng <> 0)")
|
||||||
|
if q.Cursor > 0 {
|
||||||
|
query = query.Where("id < ?", q.Cursor)
|
||||||
|
}
|
||||||
|
// Over-fetch then sort by manhattan distance in process (nearby stub).
|
||||||
|
fetch := q.Limit
|
||||||
|
if fetch < 50 {
|
||||||
|
fetch = 50
|
||||||
|
}
|
||||||
|
var rows []Post
|
||||||
|
if err := query.Order("id DESC").Limit(fetch).Find(&rows).Error; err != nil {
|
||||||
|
return nil, mapDBErr(err)
|
||||||
|
}
|
||||||
|
posts := collectPosts(rows)
|
||||||
|
if q.Lat == 0 && q.Lng == 0 {
|
||||||
|
if q.Limit > 0 && len(posts) > q.Limit {
|
||||||
|
posts = posts[:q.Limit]
|
||||||
|
}
|
||||||
|
return posts, nil
|
||||||
|
}
|
||||||
|
sort.SliceStable(posts, func(i, j int) bool {
|
||||||
|
di := manhattan(q.Lat, q.Lng, posts[i].POI.Lat, posts[i].POI.Lng)
|
||||||
|
dj := manhattan(q.Lat, q.Lng, posts[j].POI.Lat, posts[j].POI.Lng)
|
||||||
|
if di == dj {
|
||||||
|
return posts[i].ID > posts[j].ID
|
||||||
|
}
|
||||||
|
return di < dj
|
||||||
|
})
|
||||||
|
if q.Limit > 0 && len(posts) > q.Limit {
|
||||||
|
posts = posts[:q.Limit]
|
||||||
|
}
|
||||||
|
return posts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func manhattan(lat1, lng1, lat2, lng2 float64) float64 {
|
||||||
|
return math.Abs(lat1-lat2) + math.Abs(lng1-lng2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeChannel(ch entity.Channel) string {
|
||||||
|
if ch == "" {
|
||||||
|
return string(entity.ChannelSquare)
|
||||||
|
}
|
||||||
|
return string(ch)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Repo) ListAudience(ctx context.Context, postIDs []int64) (map[int64][]int64, error) {
|
func (m *Repo) ListAudience(ctx context.Context, postIDs []int64) (map[int64][]int64, error) {
|
||||||
out := make(map[int64][]int64)
|
out := make(map[int64][]int64)
|
||||||
if len(postIDs) == 0 {
|
if len(postIDs) == 0 {
|
||||||
@ -140,28 +225,30 @@ func (m *Repo) GetComment(ctx context.Context, id int64) (*entity.Comment, error
|
|||||||
return row.Entity(), nil
|
return row.Entity(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Repo) GetNoticeState(ctx context.Context, userID int64) (*entity.NoticeState, error) {
|
func (m *Repo) GetNoticeState(ctx context.Context, userID int64, channel entity.Channel) (*entity.NoticeState, error) {
|
||||||
db, err := m.session(ctx)
|
db, err := m.session(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
ch := normalizeChannel(channel)
|
||||||
var row NoticeState
|
var row NoticeState
|
||||||
if err := db.Where("user_id = ?", userID).Take(&row).Error; err != nil {
|
if err := db.Where("user_id = ? AND channel = ?", userID, ch).Take(&row).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return entity.DefaultNoticeState(userID), nil
|
return entity.DefaultNoticeState(userID, entity.Channel(ch)), nil
|
||||||
}
|
}
|
||||||
return nil, mapDBErr(err)
|
return nil, mapDBErr(err)
|
||||||
}
|
}
|
||||||
return row.Entity(), nil
|
return row.Entity(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Repo) LatestNoticeID(ctx context.Context, userID int64) (int64, error) {
|
func (m *Repo) LatestNoticeID(ctx context.Context, userID int64, channel entity.Channel) (int64, error) {
|
||||||
db, err := m.session(ctx)
|
db, err := m.session(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
ch := normalizeChannel(channel)
|
||||||
var row Notice
|
var row Notice
|
||||||
if err := db.Where("to_user_id = ?", userID).Order("id DESC").Limit(1).Take(&row).Error; err != nil {
|
if err := db.Where("to_user_id = ? AND channel = ?", userID, ch).Order("id DESC").Limit(1).Take(&row).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
@ -170,12 +257,13 @@ func (m *Repo) LatestNoticeID(ctx context.Context, userID int64) (int64, error)
|
|||||||
return row.ID, nil
|
return row.ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Repo) ListNotices(ctx context.Context, userID, cursor int64, limit int) ([]*entity.Notice, error) {
|
func (m *Repo) ListNotices(ctx context.Context, userID int64, channel entity.Channel, cursor int64, limit int) ([]*entity.Notice, error) {
|
||||||
db, err := m.session(ctx)
|
db, err := m.session(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
q := db.Where("to_user_id = ?", userID)
|
ch := normalizeChannel(channel)
|
||||||
|
q := db.Where("to_user_id = ? AND channel = ?", userID, ch)
|
||||||
if cursor > 0 {
|
if cursor > 0 {
|
||||||
q = q.Where("id < ?", cursor)
|
q = q.Where("id < ?", cursor)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -163,7 +163,8 @@ func (m *Repo) CreateNotices(ctx context.Context, rows []*entity.Notice) error {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
for _, n := range rows {
|
for _, n := range rows {
|
||||||
row := Notice{
|
row := Notice{
|
||||||
ID: n.ID, ToUserID: n.ToUserID, FromUserID: n.FromUserID, Kind: int16(n.Kind),
|
ID: n.ID, ToUserID: n.ToUserID, Channel: normalizeChannel(n.Channel),
|
||||||
|
FromUserID: n.FromUserID, Kind: int16(n.Kind),
|
||||||
PostID: n.PostID, CommentID: n.CommentID, CreatedAt: now,
|
PostID: n.PostID, CommentID: n.CommentID, CreatedAt: now,
|
||||||
}
|
}
|
||||||
if n.CreatedAtMS > 0 {
|
if n.CreatedAtMS > 0 {
|
||||||
@ -176,19 +177,20 @@ func (m *Repo) CreateNotices(ctx context.Context, rows []*entity.Notice) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Repo) MarkNoticesRead(ctx context.Context, userID, cursor int64) error {
|
func (m *Repo) MarkNoticesRead(ctx context.Context, userID int64, channel entity.Channel, cursor int64) error {
|
||||||
db, err := m.session(ctx)
|
db, err := m.session(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
q := db.Model(&Notice{}).Where("to_user_id = ? AND read_at IS NULL", userID)
|
ch := normalizeChannel(channel)
|
||||||
|
q := db.Model(&Notice{}).Where("to_user_id = ? AND channel = ? AND read_at IS NULL", userID, ch)
|
||||||
if cursor > 0 {
|
if cursor > 0 {
|
||||||
q = q.Where("id <= ?", cursor)
|
q = q.Where("id <= ?", cursor)
|
||||||
}
|
}
|
||||||
return mapDBErr(q.Update("read_at", time.Now()).Error)
|
return mapDBErr(q.Update("read_at", time.Now()).Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Repo) AdvanceNoticeRead(ctx context.Context, userID, noticeID int64) error {
|
func (m *Repo) AdvanceNoticeRead(ctx context.Context, userID int64, channel entity.Channel, noticeID int64) error {
|
||||||
if userID <= 0 || noticeID <= 0 {
|
if userID <= 0 || noticeID <= 0 {
|
||||||
return errs.ErrInvalidArgument
|
return errs.ErrInvalidArgument
|
||||||
}
|
}
|
||||||
@ -196,7 +198,8 @@ func (m *Repo) AdvanceNoticeRead(ctx context.Context, userID, noticeID int64) er
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
cur, err := m.GetNoticeState(ctx, userID)
|
ch := entity.Channel(normalizeChannel(channel))
|
||||||
|
cur, err := m.GetNoticeState(ctx, userID, ch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -206,11 +209,11 @@ func (m *Repo) AdvanceNoticeRead(ctx context.Context, userID, noticeID int64) er
|
|||||||
cur.LastReadNoticeID = noticeID
|
cur.LastReadNoticeID = noticeID
|
||||||
cur.UpdatedAtMS = time.Now().UnixMilli()
|
cur.UpdatedAtMS = time.Now().UnixMilli()
|
||||||
row := NoticeState{
|
row := NoticeState{
|
||||||
UserID: cur.UserID, LastReadNoticeID: cur.LastReadNoticeID,
|
UserID: cur.UserID, Channel: string(ch), LastReadNoticeID: cur.LastReadNoticeID,
|
||||||
UpdatedAt: time.UnixMilli(cur.UpdatedAtMS),
|
UpdatedAt: time.UnixMilli(cur.UpdatedAtMS),
|
||||||
}
|
}
|
||||||
return mapDBErr(db.Clauses(clause.OnConflict{
|
return mapDBErr(db.Clauses(clause.OnConflict{
|
||||||
Columns: []clause.Column{{Name: "user_id"}},
|
Columns: []clause.Column{{Name: "user_id"}, {Name: "channel"}},
|
||||||
DoUpdates: clause.AssignmentColumns([]string{"last_read_notice_id", "updated_at"}),
|
DoUpdates: clause.AssignmentColumns([]string{"last_read_notice_id", "updated_at"}),
|
||||||
}).Create(&row).Error)
|
}).Create(&row).Error)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,9 @@ func init() {
|
|||||||
KeyFieldSquareNoticeCursor: {
|
KeyFieldSquareNoticeCursor: {
|
||||||
ZhCN: "游标", En: "Cursor", ZhTW: "遊標",
|
ZhCN: "游标", En: "Cursor", ZhTW: "遊標",
|
||||||
},
|
},
|
||||||
|
KeyFieldSquareCategory: {
|
||||||
|
ZhCN: "分类", En: "Category", ZhTW: "分類",
|
||||||
|
},
|
||||||
KeySquareTextInvalid: {
|
KeySquareTextInvalid: {
|
||||||
ZhCN: "正文最多1000字", En: "Text must be at most 1000 characters", ZhTW: "正文最多1000字",
|
ZhCN: "正文最多1000字", En: "Text must be at most 1000 characters", ZhTW: "正文最多1000字",
|
||||||
},
|
},
|
||||||
@ -47,6 +50,9 @@ func init() {
|
|||||||
KeySquareReplyInvalid: {
|
KeySquareReplyInvalid: {
|
||||||
ZhCN: "回复目标无效", En: "Invalid reply target", ZhTW: "回覆目標無效",
|
ZhCN: "回复目标无效", En: "Invalid reply target", ZhTW: "回覆目標無效",
|
||||||
},
|
},
|
||||||
|
KeySquareCategoryInvalid: {
|
||||||
|
ZhCN: "分类无效", En: "Invalid category", ZhTW: "分類無效",
|
||||||
|
},
|
||||||
"enum.square_kind.text": {ZhCN: "纯文", En: "Text", ZhTW: "純文"},
|
"enum.square_kind.text": {ZhCN: "纯文", En: "Text", ZhTW: "純文"},
|
||||||
"enum.square_kind.image": {ZhCN: "图片", En: "Image", ZhTW: "圖片"},
|
"enum.square_kind.image": {ZhCN: "图片", En: "Image", ZhTW: "圖片"},
|
||||||
"enum.square_kind.video": {ZhCN: "视频", En: "Video", ZhTW: "影片"},
|
"enum.square_kind.video": {ZhCN: "视频", En: "Video", ZhTW: "影片"},
|
||||||
@ -59,6 +65,16 @@ func init() {
|
|||||||
"enum.square_notice_kind.like": {ZhCN: "赞", En: "Like", ZhTW: "讚"},
|
"enum.square_notice_kind.like": {ZhCN: "赞", En: "Like", ZhTW: "讚"},
|
||||||
"enum.square_notice_kind.comment": {ZhCN: "评论", En: "Comment", ZhTW: "評論"},
|
"enum.square_notice_kind.comment": {ZhCN: "评论", En: "Comment", ZhTW: "評論"},
|
||||||
"enum.square_notice_kind.reply": {ZhCN: "回复", En: "Reply", ZhTW: "回覆"},
|
"enum.square_notice_kind.reply": {ZhCN: "回复", En: "Reply", ZhTW: "回覆"},
|
||||||
|
"enum.square_channel.square": {ZhCN: "广场", En: "Square", ZhTW: "廣場"},
|
||||||
|
"enum.square_channel.squarenovel": {ZhCN: "小说广场", En: "Novel square", ZhTW: "小說廣場"},
|
||||||
|
"enum.square_channel.squaresteps": {ZhCN: "步数广场", En: "Steps square", ZhTW: "步數廣場"},
|
||||||
|
"enum.square_channel.squarehealth": {ZhCN: "健康广场", En: "Health square", ZhTW: "健康廣場"},
|
||||||
|
"enum.square_category.recommend": {ZhCN: "推荐", En: "Recommend", ZhTW: "推薦"},
|
||||||
|
"enum.square_category.latest": {ZhCN: "最新", En: "Latest", ZhTW: "最新"},
|
||||||
|
"enum.square_category.hot": {ZhCN: "热门", En: "Hot", ZhTW: "熱門"},
|
||||||
|
"enum.square_category.nearby": {ZhCN: "附近", En: "Nearby", ZhTW: "附近"},
|
||||||
|
"enum.square_category.funny": {ZhCN: "搞笑", En: "Funny", ZhTW: "搞笑"},
|
||||||
|
"enum.square_category.beauty": {ZhCN: "美女", En: "Beauty", ZhTW: "美女"},
|
||||||
"enum.upload_purpose.square_image": {ZhCN: "广场图片", En: "Square image", ZhTW: "广场圖片"},
|
"enum.upload_purpose.square_image": {ZhCN: "广场图片", En: "Square image", ZhTW: "广场圖片"},
|
||||||
"enum.upload_purpose.square_video": {ZhCN: "广场视频", En: "Square video", ZhTW: "广场影片"},
|
"enum.upload_purpose.square_video": {ZhCN: "广场视频", En: "Square video", ZhTW: "广场影片"},
|
||||||
})
|
})
|
||||||
|
|||||||
@ -161,6 +161,7 @@ const (
|
|||||||
KeyFieldSquareComment = "field.square_comment"
|
KeyFieldSquareComment = "field.square_comment"
|
||||||
KeyFieldSquareReply = "field.square_reply"
|
KeyFieldSquareReply = "field.square_reply"
|
||||||
KeyFieldSquareNoticeCursor = "field.square_notice_cursor"
|
KeyFieldSquareNoticeCursor = "field.square_notice_cursor"
|
||||||
|
KeyFieldSquareCategory = "field.square_category"
|
||||||
KeySquareTextInvalid = "validate.square.text_invalid"
|
KeySquareTextInvalid = "validate.square.text_invalid"
|
||||||
KeySquareVisibilityInvalid = "validate.square.visibility_invalid"
|
KeySquareVisibilityInvalid = "validate.square.visibility_invalid"
|
||||||
KeySquareAudienceInvalid = "validate.square.audience_invalid"
|
KeySquareAudienceInvalid = "validate.square.audience_invalid"
|
||||||
@ -170,6 +171,7 @@ const (
|
|||||||
KeySquareCommentRequired = "validate.square.comment_required"
|
KeySquareCommentRequired = "validate.square.comment_required"
|
||||||
KeySquareCommentInvalid = "validate.square.comment_invalid"
|
KeySquareCommentInvalid = "validate.square.comment_invalid"
|
||||||
KeySquareReplyInvalid = "validate.square.reply_invalid"
|
KeySquareReplyInvalid = "validate.square.reply_invalid"
|
||||||
|
KeySquareCategoryInvalid = "validate.square.category_invalid"
|
||||||
|
|
||||||
KeyFieldVerificationTarget = "field.verification_target"
|
KeyFieldVerificationTarget = "field.verification_target"
|
||||||
KeySMSLanguageRequired = "validate.sms.language_required"
|
KeySMSLanguageRequired = "validate.sms.language_required"
|
||||||
|
|||||||
@ -102,6 +102,9 @@ func publicPath(path string) bool {
|
|||||||
"/api/client/pop/developer-apps", "/api/client/spark/developer-apps":
|
"/api/client/pop/developer-apps", "/api/client/spark/developer-apps":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
|
if publicMusicPath(path) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
// Admin routes use AdminAuth (Java popadmin JWT), not client Bearer.
|
// Admin routes use AdminAuth (Java popadmin JWT), not client Bearer.
|
||||||
if adminPath(path) {
|
if adminPath(path) {
|
||||||
return true
|
return true
|
||||||
@ -110,6 +113,16 @@ func publicPath(path string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func publicMusicPath(path string) bool {
|
||||||
|
switch path {
|
||||||
|
case "/v1/music/playlist", "/v1/music/playlist/grouped",
|
||||||
|
"/v1/music/categories", "/v1/music/settings":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return strings.HasPrefix(path, "/v1/music/tracks/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func bearerToken(h string) (string, bool) {
|
func bearerToken(h string) (string, bool) {
|
||||||
if len(h) < len(bearerPrefix) || !strings.EqualFold(h[:len(bearerPrefix)], bearerPrefix) {
|
if len(h) < len(bearerPrefix) || !strings.EqualFold(h[:len(bearerPrefix)], bearerPrefix) {
|
||||||
return "", false
|
return "", false
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
openapi: 3.0.3
|
openapi: 3.0.3
|
||||||
info:
|
info:
|
||||||
title: Spark IM API
|
title: Spark IM API
|
||||||
version: 0.1.0
|
version: 0.1.0
|
||||||
@ -65,7 +65,7 @@ info:
|
|||||||
|
|
||||||
| Header | 放哪 | 必填 | 说明 |
|
| Header | 放哪 | 必填 | 说明 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `Authorization` | Header | 需登录的接口必填 | `Bearer <access_token>`。登录、发码、刷新、`/healthz`、`/readyz`、`/docs`、`/openapi.yaml`、`GET /v1/meta/enums`、`GET /v1/music/playlist`、`GET /v1/music/categories`、`GET /v1/music/settings` **不要**带。 |
|
| `Authorization` | Header | 需登录的接口必填 | `Bearer <access_token>`。登录、发码、刷新、`/healthz`、`/readyz`、`/docs`、`/openapi.yaml`、`GET /v1/meta/enums`、以及公开音乐读接口(`/v1/music/playlist`、`/v1/music/playlist/grouped`、`/v1/music/tracks/{id}`、`/v1/music/categories`、`/v1/music/settings`)**不要**带。 |
|
||||||
| `X-Device-ID` | Header | 可选 | 十进制 `device_id`,须等于登录响应 / JWT `did`(**服务端按物理指纹生成**,客户端勿自造)。不一致则 `40101`。 |
|
| `X-Device-ID` | Header | 可选 | 十进制 `device_id`,须等于登录响应 / JWT `did`(**服务端按物理指纹生成**,客户端勿自造)。不一致则 `40101`。 |
|
||||||
| `X-App-ID` | Header | 可选 | 若传,须与 JWT **`aid`(app_id)** 一致。 |
|
| `X-App-ID` | Header | 可选 | 若传,须与 JWT **`aid`(app_id)** 一致。 |
|
||||||
| `X-Language` | Header | 可选 | `zh-CN` / `en` / `zh-TW`。优先于 `Accept-Language`。缺省 `zh-CN`。决定 `msg` 语言。 |
|
| `X-Language` | Header | 可选 | `zh-CN` / `en` / `zh-TW`。优先于 `Accept-Language`。缺省 `zh-CN`。决定 `msg` 语言。 |
|
||||||
@ -373,9 +373,16 @@ tags:
|
|||||||
- name: favorites
|
- name: favorites
|
||||||
- name: moments
|
- name: moments
|
||||||
- name: square
|
- name: square
|
||||||
|
description: |
|
||||||
|
多广场:路径 `/v1/{square_channel}/…`,channel=`square|squarenovel|squaresteps|squarehealth`。
|
||||||
|
分类 Tab:`GET …/categories`;Feed 用 query `category`(推荐/最新/热门/附近/搞笑/美女)。
|
||||||
- name: music
|
- name: music
|
||||||
description: |
|
description: |
|
||||||
运营音乐列表。客户端公开读 `GET /v1/music/playlist`(可带 `categoryId`)、`GET /v1/music/categories`、`GET /v1/music/settings`(无需登录,V3 envelope)。
|
运营音乐列表。客户端公开读(无需登录,V3 envelope):
|
||||||
|
`GET /v1/music/playlist`(可带 `categoryId` / `requireMember`)、
|
||||||
|
`GET /v1/music/playlist/grouped`(分类分区嵌套曲目,对齐欢休 zone 列表)、
|
||||||
|
`GET /v1/music/tracks/{id}`(单曲详情)、
|
||||||
|
`GET /v1/music/categories`、`GET /v1/music/settings`。
|
||||||
playlist 返回曲目、`categories` 分类树与 `memberPreviewSeconds`;曲目含 `backgroundUrl`、`requireMember`、`categoryIds`。
|
playlist 返回曲目、`categories` 分类树与 `memberPreviewSeconds`;曲目含 `backgroundUrl`、`requireMember`、`categoryIds`。
|
||||||
- name: admin-music
|
- name: admin-music
|
||||||
description: |
|
description: |
|
||||||
@ -821,9 +828,21 @@ paths:
|
|||||||
description: |
|
description: |
|
||||||
返回 headline/subheadline 与 week/month/quarter/year 套餐卡片(含 IAP product_id)。
|
返回 headline/subheadline 与 week/month/quarter/year 套餐卡片(含 IAP product_id)。
|
||||||
公开接口;无库数据时回退默认四档。
|
公开接口;无库数据时回退默认四档。
|
||||||
|
支持按 **包名 / applicationId** 筛选:`packageName`(或 `package_name`)Query,或 Header `X-Wyrs-Package`。
|
||||||
|
有该包精确配置则返回之;否则回退 `package_name` 为空的默认档。
|
||||||
operationId: subscriptionPlans
|
operationId: subscriptionPlans
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: "#/components/parameters/X-Language"
|
- $ref: "#/components/parameters/X-Language"
|
||||||
|
- name: packageName
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema: { type: string }
|
||||||
|
description: applicationId / Bundle ID / 包名;也可用 package_name 或 Header X-Wyrs-Package
|
||||||
|
- name: X-Wyrs-Package
|
||||||
|
in: header
|
||||||
|
required: false
|
||||||
|
schema: { type: string }
|
||||||
|
description: 与 packageName 等价
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: data.plans
|
description: data.plans
|
||||||
@ -1837,21 +1856,54 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Envelope"
|
$ref: "#/components/schemas/Envelope"
|
||||||
/v1/square/posts:
|
/v1/{square_channel}/categories:
|
||||||
get:
|
get:
|
||||||
tags: [square]
|
tags: [square]
|
||||||
summary: 全站广场 Feed
|
summary: 广场分类 Tab
|
||||||
description: |
|
description: |
|
||||||
`cursor` 为上一页最后一条 `post_id`,0 表示最新。仅 `visibility` 公开与不给谁看、且审核通过的帖;
|
返回筛选分类:`recommend`/`latest`/`hot`/`nearby`(feed 模式)与 `funny`/`beauty`(内容标签)。
|
||||||
读时按拉黑、黑名单过滤。评论随帖详情返回,无单独评论列表接口。
|
各广场实例共用同一套分类枚举;数据按 `square_channel` 隔离。
|
||||||
|
operationId: listSquareCategories
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/SquareChannel"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 统一 envelope;data.items[].value / kind(feed|tag)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Envelope"
|
||||||
|
/v1/{square_channel}/posts:
|
||||||
|
get:
|
||||||
|
tags: [square]
|
||||||
|
summary: 广场 Feed
|
||||||
|
description: |
|
||||||
|
`square_channel`:`square`(原路径兼容)/ `squarenovel` / `squaresteps` / `squarehealth`。
|
||||||
|
`category`:推荐/最新/热门/附近为排序模式;搞笑/美女按帖标签筛选。
|
||||||
|
`nearby` 可传 `lat`/`lng`(曼哈顿距离 stub);未传则仅返回带 POI 的帖并按 id 倒序。
|
||||||
|
`cursor` 为上一页最后一条 `post_id`,0 表示最新。
|
||||||
operationId: listSquareFeed
|
operationId: listSquareFeed
|
||||||
parameters:
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/SquareChannel"
|
||||||
|
- in: query
|
||||||
|
name: category
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/SquareCategory"
|
||||||
|
description: 缺省 recommend
|
||||||
- in: query
|
- in: query
|
||||||
name: cursor
|
name: cursor
|
||||||
schema: { type: integer, format: int64, default: 0 }
|
schema: { type: integer, format: int64, default: 0 }
|
||||||
- in: query
|
- in: query
|
||||||
name: limit
|
name: limit
|
||||||
schema: { type: integer, default: 20, maximum: 50 }
|
schema: { type: integer, default: 20, maximum: 50 }
|
||||||
|
- in: query
|
||||||
|
name: lat
|
||||||
|
schema: { type: number, format: double }
|
||||||
|
description: 附近排序纬度(category=nearby)
|
||||||
|
- in: query
|
||||||
|
name: lng
|
||||||
|
schema: { type: number, format: double }
|
||||||
|
description: 附近排序经度(category=nearby)
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: 统一 envelope
|
description: 统一 envelope
|
||||||
@ -1865,13 +1917,16 @@ paths:
|
|||||||
description: |
|
description: |
|
||||||
内容与朋友圈相同。媒体先 `POST /v1/files`(`square_image` / `square_video`)再填 key。
|
内容与朋友圈相同。媒体先 `POST /v1/files`(`square_image` / `square_video`)再填 key。
|
||||||
`visibility`:0 全站公开 1 仅自己 2 白名单 3 黑名单。2/3 需 `audience_uids`(双向好友,最多 200)。
|
`visibility`:0 全站公开 1 仅自己 2 白名单 3 黑名单。2/3 需 `audience_uids`(双向好友,最多 200)。
|
||||||
|
可选 `category` 内容标签:`funny` / `beauty`(不可传 feed 模式值)。
|
||||||
operationId: publishSquarePost
|
operationId: publishSquarePost
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/SquareChannel"
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/PublishMomentRequest"
|
$ref: "#/components/schemas/PublishSquareRequest"
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: 统一 envelope
|
description: 统一 envelope
|
||||||
@ -1879,12 +1934,13 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Envelope"
|
$ref: "#/components/schemas/Envelope"
|
||||||
/v1/square/posts/{id}:
|
/v1/{square_channel}/posts/{id}:
|
||||||
get:
|
get:
|
||||||
tags: [square]
|
tags: [square]
|
||||||
summary: 单条广场帖
|
summary: 单条广场帖
|
||||||
operationId: getSquarePost
|
operationId: getSquarePost
|
||||||
parameters:
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/SquareChannel"
|
||||||
- $ref: "#/components/parameters/SquarePostID"
|
- $ref: "#/components/parameters/SquarePostID"
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
@ -1898,6 +1954,7 @@ paths:
|
|||||||
summary: 删除自己的广场帖
|
summary: 删除自己的广场帖
|
||||||
operationId: deleteSquarePost
|
operationId: deleteSquarePost
|
||||||
parameters:
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/SquareChannel"
|
||||||
- $ref: "#/components/parameters/SquarePostID"
|
- $ref: "#/components/parameters/SquarePostID"
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
@ -1906,12 +1963,13 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Envelope"
|
$ref: "#/components/schemas/Envelope"
|
||||||
/v1/square/posts/{id}/like:
|
/v1/{square_channel}/posts/{id}/like:
|
||||||
put:
|
put:
|
||||||
tags: [square]
|
tags: [square]
|
||||||
summary: 点赞
|
summary: 点赞
|
||||||
operationId: likeSquarePost
|
operationId: likeSquarePost
|
||||||
parameters:
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/SquareChannel"
|
||||||
- $ref: "#/components/parameters/SquarePostID"
|
- $ref: "#/components/parameters/SquarePostID"
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
@ -1925,6 +1983,7 @@ paths:
|
|||||||
summary: 取消赞
|
summary: 取消赞
|
||||||
operationId: unlikeSquarePost
|
operationId: unlikeSquarePost
|
||||||
parameters:
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/SquareChannel"
|
||||||
- $ref: "#/components/parameters/SquarePostID"
|
- $ref: "#/components/parameters/SquarePostID"
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
@ -1933,12 +1992,13 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Envelope"
|
$ref: "#/components/schemas/Envelope"
|
||||||
/v1/square/posts/{id}/comments:
|
/v1/{square_channel}/posts/{id}/comments:
|
||||||
post:
|
post:
|
||||||
tags: [square]
|
tags: [square]
|
||||||
summary: 评论或回复
|
summary: 评论或回复
|
||||||
operationId: commentSquarePost
|
operationId: commentSquarePost
|
||||||
parameters:
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/SquareChannel"
|
||||||
- $ref: "#/components/parameters/SquarePostID"
|
- $ref: "#/components/parameters/SquarePostID"
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
@ -1953,12 +2013,13 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Envelope"
|
$ref: "#/components/schemas/Envelope"
|
||||||
/v1/square/posts/{id}/comments/{cid}:
|
/v1/{square_channel}/posts/{id}/comments/{cid}:
|
||||||
delete:
|
delete:
|
||||||
tags: [square]
|
tags: [square]
|
||||||
summary: 删除评论
|
summary: 删除评论
|
||||||
operationId: deleteSquareComment
|
operationId: deleteSquareComment
|
||||||
parameters:
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/SquareChannel"
|
||||||
- $ref: "#/components/parameters/SquarePostID"
|
- $ref: "#/components/parameters/SquarePostID"
|
||||||
- name: cid
|
- name: cid
|
||||||
in: path
|
in: path
|
||||||
@ -1971,13 +2032,14 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Envelope"
|
$ref: "#/components/schemas/Envelope"
|
||||||
/v1/square/users/{uid}/posts:
|
/v1/{square_channel}/users/{uid}/posts:
|
||||||
get:
|
get:
|
||||||
tags: [square]
|
tags: [square]
|
||||||
summary: 某用户广场帖
|
summary: 某用户广场帖
|
||||||
description: 按当前浏览者与帖可见性、拉黑过滤;看自己可见私密与白名单帖。
|
description: 按当前浏览者与帖可见性、拉黑过滤;看自己可见私密与白名单帖。仅返回该 channel 下的帖。
|
||||||
operationId: listSquareUserPosts
|
operationId: listSquareUserPosts
|
||||||
parameters:
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/SquareChannel"
|
||||||
- $ref: "#/components/parameters/FriendUID"
|
- $ref: "#/components/parameters/FriendUID"
|
||||||
- in: query
|
- in: query
|
||||||
name: cursor
|
name: cursor
|
||||||
@ -1992,12 +2054,13 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Envelope"
|
$ref: "#/components/schemas/Envelope"
|
||||||
/v1/square/notices:
|
/v1/{square_channel}/notices:
|
||||||
get:
|
get:
|
||||||
tags: [square]
|
tags: [square]
|
||||||
summary: 赞评通知列表
|
summary: 赞评通知列表
|
||||||
operationId: listSquareNotices
|
operationId: listSquareNotices
|
||||||
parameters:
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/SquareChannel"
|
||||||
- in: query
|
- in: query
|
||||||
name: cursor
|
name: cursor
|
||||||
schema: { type: integer, format: int64, default: 0 }
|
schema: { type: integer, format: int64, default: 0 }
|
||||||
@ -2011,11 +2074,13 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Envelope"
|
$ref: "#/components/schemas/Envelope"
|
||||||
/v1/square/notices/unread:
|
/v1/{square_channel}/notices/unread:
|
||||||
get:
|
get:
|
||||||
tags: [square]
|
tags: [square]
|
||||||
summary: 广场互动未读角标
|
summary: 广场互动未读角标
|
||||||
operationId: squareNoticeUnread
|
operationId: squareNoticeUnread
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/SquareChannel"
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: 统一 envelope
|
description: 统一 envelope
|
||||||
@ -2023,11 +2088,13 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Envelope"
|
$ref: "#/components/schemas/Envelope"
|
||||||
/v1/square/notices/read:
|
/v1/{square_channel}/notices/read:
|
||||||
post:
|
post:
|
||||||
tags: [square]
|
tags: [square]
|
||||||
summary: 通知标已读
|
summary: 通知标已读
|
||||||
operationId: readSquareNotices
|
operationId: readSquareNotices
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/SquareChannel"
|
||||||
requestBody:
|
requestBody:
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
@ -2050,6 +2117,7 @@ paths:
|
|||||||
封面/背景图/音频为 sparkr 公网 URL(`https://sparkr.wyrs365.com/spark/perm/music/...`)。
|
封面/背景图/音频为 sparkr 公网 URL(`https://sparkr.wyrs365.com/spark/perm/music/...`)。
|
||||||
客户端应按 `requireMember` 与 `memberPreviewSeconds` 控制试听;`backgroundUrl` 可空。
|
客户端应按 `requireMember` 与 `memberPreviewSeconds` 控制试听;`backgroundUrl` 可空。
|
||||||
传 `categoryId` 时仅返回归属该分类或其子分类的曲目(多对多)。
|
传 `categoryId` 时仅返回归属该分类或其子分类的曲目(多对多)。
|
||||||
|
传 `requireMember=true|false` 可仅返回会员曲或免费曲(对齐欢休 VIP 分区筛选)。
|
||||||
operationId: getMusicPlaylist
|
operationId: getMusicPlaylist
|
||||||
security: []
|
security: []
|
||||||
parameters:
|
parameters:
|
||||||
@ -2058,6 +2126,11 @@ paths:
|
|||||||
required: false
|
required: false
|
||||||
schema: { type: integer, format: int64, minimum: 0 }
|
schema: { type: integer, format: int64, minimum: 0 }
|
||||||
description: 按分类筛选;0 或不传=全部上架曲目
|
description: 按分类筛选;0 或不传=全部上架曲目
|
||||||
|
- name: requireMember
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema: { type: boolean }
|
||||||
|
description: true=仅会员曲;false=仅免费曲;不传=全部
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: 统一 envelope,`data` 为 MusicPlaylist
|
description: 统一 envelope,`data` 为 MusicPlaylist
|
||||||
@ -2070,6 +2143,61 @@ paths:
|
|||||||
properties:
|
properties:
|
||||||
data:
|
data:
|
||||||
$ref: "#/components/schemas/MusicPlaylist"
|
$ref: "#/components/schemas/MusicPlaylist"
|
||||||
|
/v1/music/playlist/grouped:
|
||||||
|
get:
|
||||||
|
tags: [music]
|
||||||
|
summary: 分区嵌套音乐列表(公开)
|
||||||
|
description: |
|
||||||
|
按叶子分类分组返回曲目,便于首页「分区 + 分区下音乐」一次拉取。
|
||||||
|
对齐欢休 `zoneAndZoneMusicList` 语义(Spark 用 category 作 zone)。
|
||||||
|
`parentId`>0 时:该分类为叶子则只返回自身;有子分类则返回其子叶子。
|
||||||
|
无需登录。
|
||||||
|
operationId: getMusicPlaylistGrouped
|
||||||
|
security: []
|
||||||
|
parameters:
|
||||||
|
- name: parentId
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
schema: { type: integer, format: int64, minimum: 0 }
|
||||||
|
description: 父分类 id;0 或不传=全部叶子分类
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 统一 envelope,`data` 为 MusicGroupedPlaylist
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: "#/components/schemas/Envelope"
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
$ref: "#/components/schemas/MusicGroupedPlaylist"
|
||||||
|
/v1/music/tracks/{id}:
|
||||||
|
get:
|
||||||
|
tags: [music]
|
||||||
|
summary: 单曲详情(公开)
|
||||||
|
description: |
|
||||||
|
返回已上架曲目详情(含播放 `audioUrl`、封面/背景)。下架或不存在返回 `40401`。
|
||||||
|
对齐欢休 `/music/v1/detail`;无需登录。
|
||||||
|
operationId: getMusicTrack
|
||||||
|
security: []
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: integer, format: int64, minimum: 1 }
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 统一 envelope,`data` 为 MusicTrack
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: "#/components/schemas/Envelope"
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
$ref: "#/components/schemas/MusicTrack"
|
||||||
/v1/music/categories:
|
/v1/music/categories:
|
||||||
get:
|
get:
|
||||||
tags: [music]
|
tags: [music]
|
||||||
@ -3327,6 +3455,23 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
enum: [0, 3, 30, 180]
|
enum: [0, 3, 30, 180]
|
||||||
description: moment_settings.visible_days。0全部 3/30/180 天
|
description: moment_settings.visible_days。0全部 3/30/180 天
|
||||||
|
SquareChannel:
|
||||||
|
type: string
|
||||||
|
enum: [square, squarenovel, squaresteps, squarehealth]
|
||||||
|
description: 广场实例路径段;与 t_square_posts.channel 一致
|
||||||
|
SquareCategory:
|
||||||
|
type: string
|
||||||
|
enum: [recommend, latest, hot, nearby, funny, beauty]
|
||||||
|
description: |
|
||||||
|
Feed 筛选。recommend/latest/hot/nearby 为排序模式;funny/beauty 为帖标签(发帖可写)。
|
||||||
|
PublishSquareRequest:
|
||||||
|
allOf:
|
||||||
|
- $ref: "#/components/schemas/PublishMomentRequest"
|
||||||
|
- type: object
|
||||||
|
properties:
|
||||||
|
category:
|
||||||
|
$ref: "#/components/schemas/SquareCategory"
|
||||||
|
description: 仅 funny/beauty;发帖不可传 feed 模式值
|
||||||
PublishMomentRequest:
|
PublishMomentRequest:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@ -3516,6 +3661,25 @@ components:
|
|||||||
$ref: "#/components/schemas/MusicCategory"
|
$ref: "#/components/schemas/MusicCategory"
|
||||||
description: 启用中的分类树
|
description: 启用中的分类树
|
||||||
memberPreviewSeconds: { type: integer }
|
memberPreviewSeconds: { type: integer }
|
||||||
|
MusicPlaylistGroup:
|
||||||
|
type: object
|
||||||
|
description: 一个分区(叶子分类)及其曲目
|
||||||
|
properties:
|
||||||
|
categoryId: { type: integer, format: int64 }
|
||||||
|
name: { type: string }
|
||||||
|
parentId: { type: integer, format: int64, nullable: true }
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/MusicTrack"
|
||||||
|
MusicGroupedPlaylist:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
groups:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/MusicPlaylistGroup"
|
||||||
|
memberPreviewSeconds: { type: integer }
|
||||||
SensitiveCategory:
|
SensitiveCategory:
|
||||||
type: integer
|
type: integer
|
||||||
enum: [1, 2, 3, 4, 5, 6, 7, 8, 9]
|
enum: [1, 2, 3, 4, 5, 6, 7, 8, 9]
|
||||||
@ -4331,6 +4495,13 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
format: int64
|
format: int64
|
||||||
description: 朋友圈 Snowflake post_id。
|
description: 朋友圈 Snowflake post_id。
|
||||||
|
SquareChannel:
|
||||||
|
in: path
|
||||||
|
name: square_channel
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/SquareChannel"
|
||||||
|
description: 广场实例 square / squarenovel / squaresteps / squarehealth
|
||||||
SquarePostID:
|
SquarePostID:
|
||||||
in: path
|
in: path
|
||||||
name: id
|
name: id
|
||||||
|
|||||||
@ -61,7 +61,11 @@ func (h *ClientHandler) ListPlans(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
lang := c.Query("lang")
|
lang := c.Query("lang")
|
||||||
list, err := h.svc.ListPlansAdmin(c.Request.Context(), lang)
|
pkg := strings.TrimSpace(c.Query("packageName"))
|
||||||
|
if pkg == "" {
|
||||||
|
pkg = strings.TrimSpace(c.Query("package_name"))
|
||||||
|
}
|
||||||
|
list, err := h.svc.ListPlansAdmin(c.Request.Context(), lang, pkg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Fail(c, http.StatusInternalServerError, err.Error())
|
Fail(c, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
package clienthandler
|
package clienthandler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
clientlogic "spark-server/internal/module/client/logic"
|
clientlogic "spark-server/internal/module/client/logic"
|
||||||
@ -39,7 +41,14 @@ func (h *Handler) SubmitFeedback(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) SubscriptionPlans(c *gin.Context) {
|
func (h *Handler) SubscriptionPlans(c *gin.Context) {
|
||||||
out, err := h.svc.SubscriptionPlans(c.Request.Context())
|
pkg := strings.TrimSpace(c.Query("packageName"))
|
||||||
|
if pkg == "" {
|
||||||
|
pkg = strings.TrimSpace(c.Query("package_name"))
|
||||||
|
}
|
||||||
|
if pkg == "" {
|
||||||
|
pkg = strings.TrimSpace(c.GetHeader("X-Wyrs-Package"))
|
||||||
|
}
|
||||||
|
out, err := h.svc.SubscriptionPlans(c.Request.Context(), pkg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond.JSONError(c, err)
|
respond.JSONError(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@ -20,21 +20,60 @@ func New(svc *musiclogic.Service) *Handler {
|
|||||||
|
|
||||||
// Playlist GET /v1/music/playlist — public curated list + category tree.
|
// Playlist GET /v1/music/playlist — public curated list + category tree.
|
||||||
// Query categoryId>0 filters tracks belonging to that category or its descendants.
|
// Query categoryId>0 filters tracks belonging to that category or its descendants.
|
||||||
|
// Query requireMember=true|false filters member-only / free tracks.
|
||||||
func (h *Handler) Playlist(c *gin.Context) {
|
func (h *Handler) Playlist(c *gin.Context) {
|
||||||
if h == nil || h.svc == nil || !h.svc.Ready() {
|
if h == nil || h.svc == nil || !h.svc.Ready() {
|
||||||
respond.JSONError(c, errs.ErrAvailable)
|
respond.JSONError(c, errs.ErrAvailable)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var categoryID int64
|
q, err := parsePlaylistQuery(c)
|
||||||
if raw := c.Query("categoryId"); raw != "" {
|
if err != nil {
|
||||||
|
respond.JSONError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := h.svc.Playlist(c.Request.Context(), q)
|
||||||
|
if err != nil {
|
||||||
|
respond.JSONError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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)
|
n, err := strconv.ParseInt(raw, 10, 64)
|
||||||
if err != nil || n < 0 {
|
if err != nil || n < 0 {
|
||||||
respond.JSONError(c, errs.ErrInvalidArgument.WithMsg("categoryId 无效"))
|
respond.JSONError(c, errs.ErrInvalidArgument.WithMsg("parentId 无效"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
categoryID = n
|
parentID = n
|
||||||
}
|
}
|
||||||
out, err := h.svc.Playlist(c.Request.Context(), categoryID)
|
out, err := h.svc.PlaylistGrouped(c.Request.Context(), parentID)
|
||||||
|
if err != nil {
|
||||||
|
respond.JSONError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respond.JSON(c, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTrack GET /v1/music/tracks/:id — single enabled track (public).
|
||||||
|
func (h *Handler) GetTrack(c *gin.Context) {
|
||||||
|
if h == nil || h.svc == nil || !h.svc.Ready() {
|
||||||
|
respond.JSONError(c, errs.ErrAvailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
respond.JSONError(c, errs.ErrInvalidArgument.WithMsg("id 无效"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := h.svc.GetPublic(c.Request.Context(), id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond.JSONError(c, err)
|
respond.JSONError(c, err)
|
||||||
return
|
return
|
||||||
@ -69,3 +108,27 @@ func (h *Handler) Settings(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
respond.JSON(c, out)
|
respond.JSON(c, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parsePlaylistQuery(c *gin.Context) (musiclogic.PlaylistQuery, error) {
|
||||||
|
var q musiclogic.PlaylistQuery
|
||||||
|
if raw := c.Query("categoryId"); raw != "" {
|
||||||
|
n, err := strconv.ParseInt(raw, 10, 64)
|
||||||
|
if err != nil || n < 0 {
|
||||||
|
return q, errs.ErrInvalidArgument.WithMsg("categoryId 无效")
|
||||||
|
}
|
||||||
|
q.CategoryID = n
|
||||||
|
}
|
||||||
|
if raw := c.Query("requireMember"); raw != "" {
|
||||||
|
switch raw {
|
||||||
|
case "true", "1":
|
||||||
|
v := true
|
||||||
|
q.RequireMember = &v
|
||||||
|
case "false", "0":
|
||||||
|
v := false
|
||||||
|
q.RequireMember = &v
|
||||||
|
default:
|
||||||
|
return q, errs.ErrInvalidArgument.WithMsg("requireMember 须为 true|false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return q, nil
|
||||||
|
}
|
||||||
|
|||||||
@ -5,19 +5,24 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"spark-server/internal/module/square/entity"
|
||||||
squarelogic "spark-server/internal/module/square/logic"
|
squarelogic "spark-server/internal/module/square/logic"
|
||||||
"spark-server/internal/pkg/errs"
|
"spark-server/internal/pkg/errs"
|
||||||
"spark-server/internal/pkg/reqctx"
|
"spark-server/internal/pkg/reqctx"
|
||||||
"spark-server/internal/transport/http/respond"
|
"spark-server/internal/transport/http/respond"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler is the square HTTP controller.
|
// Handler is the square HTTP controller for one plaza channel.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
square *squarelogic.Service
|
square *squarelogic.Service
|
||||||
|
channel entity.Channel
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(square *squarelogic.Service) *Handler {
|
func New(square *squarelogic.Service, channel entity.Channel) *Handler {
|
||||||
return &Handler{square: square}
|
if channel == "" {
|
||||||
|
channel = entity.ChannelSquare
|
||||||
|
}
|
||||||
|
return &Handler{square: square, channel: channel}
|
||||||
}
|
}
|
||||||
|
|
||||||
func currentUID(c *gin.Context) (int64, bool) {
|
func currentUID(c *gin.Context) (int64, bool) {
|
||||||
@ -43,12 +48,32 @@ func queryInt(c *gin.Context, name string) int64 {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func queryFloat(c *gin.Context, name string) float64 {
|
||||||
|
n, _ := strconv.ParseFloat(c.Query(name), 64)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) Categories(c *gin.Context) {
|
||||||
|
respond.JSON(c, gin.H{"items": h.square.Categories()})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) Feed(c *gin.Context) {
|
func (h *Handler) Feed(c *gin.Context) {
|
||||||
uid, ok := currentUID(c)
|
uid, ok := currentUID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out, err := h.square.Feed(c.Request.Context(), uid, queryInt(c, "cursor"), int(queryInt(c, "limit")))
|
cat, ok := entity.ParseCategory(c.Query("category"))
|
||||||
|
if !ok {
|
||||||
|
respond.JSONError(c, errs.ErrInvalidArgument)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cat == "" {
|
||||||
|
cat = entity.CategoryRecommend
|
||||||
|
}
|
||||||
|
out, err := h.square.Feed(c.Request.Context(), uid, h.channel, squarelogic.FeedQuery{
|
||||||
|
Cursor: queryInt(c, "cursor"), Limit: int(queryInt(c, "limit")),
|
||||||
|
Category: cat, Lat: queryFloat(c, "lat"), Lng: queryFloat(c, "lng"),
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond.JSONError(c, err)
|
respond.JSONError(c, err)
|
||||||
return
|
return
|
||||||
@ -66,7 +91,7 @@ func (h *Handler) Publish(c *gin.Context) {
|
|||||||
respond.JSONError(c, err)
|
respond.JSONError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out, err := h.square.Publish(c.Request.Context(), uid, &in)
|
out, err := h.square.Publish(c.Request.Context(), uid, h.channel, &in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond.JSONError(c, err)
|
respond.JSONError(c, err)
|
||||||
return
|
return
|
||||||
@ -83,7 +108,7 @@ func (h *Handler) UserPosts(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out, err := h.square.UserPosts(c.Request.Context(), uid, owner, queryInt(c, "cursor"), int(queryInt(c, "limit")))
|
out, err := h.square.UserPosts(c.Request.Context(), uid, owner, h.channel, queryInt(c, "cursor"), int(queryInt(c, "limit")))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond.JSONError(c, err)
|
respond.JSONError(c, err)
|
||||||
return
|
return
|
||||||
@ -100,7 +125,7 @@ func (h *Handler) Get(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out, err := h.square.Get(c.Request.Context(), uid, id)
|
out, err := h.square.Get(c.Request.Context(), uid, id, h.channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond.JSONError(c, err)
|
respond.JSONError(c, err)
|
||||||
return
|
return
|
||||||
@ -117,7 +142,7 @@ func (h *Handler) Delete(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.square.Delete(c.Request.Context(), uid, id); err != nil {
|
if err := h.square.Delete(c.Request.Context(), uid, id, h.channel); err != nil {
|
||||||
respond.JSONError(c, err)
|
respond.JSONError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -133,7 +158,7 @@ func (h *Handler) Like(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out, err := h.square.Like(c.Request.Context(), uid, id)
|
out, err := h.square.Like(c.Request.Context(), uid, id, h.channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond.JSONError(c, err)
|
respond.JSONError(c, err)
|
||||||
return
|
return
|
||||||
@ -150,7 +175,7 @@ func (h *Handler) Unlike(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out, err := h.square.Unlike(c.Request.Context(), uid, id)
|
out, err := h.square.Unlike(c.Request.Context(), uid, id, h.channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond.JSONError(c, err)
|
respond.JSONError(c, err)
|
||||||
return
|
return
|
||||||
@ -172,7 +197,7 @@ func (h *Handler) CreateComment(c *gin.Context) {
|
|||||||
respond.JSONError(c, err)
|
respond.JSONError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out, err := h.square.Comment(c.Request.Context(), uid, id, &in)
|
out, err := h.square.Comment(c.Request.Context(), uid, id, h.channel, &in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond.JSONError(c, err)
|
respond.JSONError(c, err)
|
||||||
return
|
return
|
||||||
@ -193,7 +218,7 @@ func (h *Handler) DeleteComment(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out, err := h.square.DeleteComment(c.Request.Context(), uid, postID, cid)
|
out, err := h.square.DeleteComment(c.Request.Context(), uid, postID, cid, h.channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond.JSONError(c, err)
|
respond.JSONError(c, err)
|
||||||
return
|
return
|
||||||
@ -206,7 +231,7 @@ func (h *Handler) ListNotices(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out, err := h.square.ListNotices(c.Request.Context(), uid, queryInt(c, "cursor"), int(queryInt(c, "limit")))
|
out, err := h.square.ListNotices(c.Request.Context(), uid, h.channel, queryInt(c, "cursor"), int(queryInt(c, "limit")))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond.JSONError(c, err)
|
respond.JSONError(c, err)
|
||||||
return
|
return
|
||||||
@ -219,7 +244,7 @@ func (h *Handler) NoticeUnread(c *gin.Context) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out, err := h.square.NoticeUnread(c.Request.Context(), uid)
|
out, err := h.square.NoticeUnread(c.Request.Context(), uid, h.channel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respond.JSONError(c, err)
|
respond.JSONError(c, err)
|
||||||
return
|
return
|
||||||
@ -237,7 +262,7 @@ func (h *Handler) ReadNotices(c *gin.Context) {
|
|||||||
respond.JSONError(c, err)
|
respond.JSONError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.square.ReadNotices(c.Request.Context(), uid, &in); err != nil {
|
if err := h.square.ReadNotices(c.Request.Context(), uid, h.channel, &in); err != nil {
|
||||||
respond.JSONError(c, err)
|
respond.JSONError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"spark-server/internal/module/square/entity"
|
||||||
"spark-server/internal/pkg/errs"
|
"spark-server/internal/pkg/errs"
|
||||||
"spark-server/internal/pkg/i18n"
|
"spark-server/internal/pkg/i18n"
|
||||||
"spark-server/internal/pkg/reply"
|
"spark-server/internal/pkg/reply"
|
||||||
@ -226,8 +227,15 @@ func registerSquareRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {
|
|||||||
if deps.Square == nil {
|
if deps.Square == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h := squarehandler.New(deps.Square)
|
for _, ch := range []string{"square", "squarenovel", "squaresteps", "squarehealth"} {
|
||||||
g := v1.Group("/square")
|
registerSquareChannelRoutes(v1, deps, ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerSquareChannelRoutes(v1 *gin.RouterGroup, deps *EngineDeps, channel string) {
|
||||||
|
h := squarehandler.New(deps.Square, entity.Channel(channel))
|
||||||
|
g := v1.Group("/" + channel)
|
||||||
|
g.GET("/categories", h.Categories)
|
||||||
g.GET("/posts", h.Feed)
|
g.GET("/posts", h.Feed)
|
||||||
g.POST("/posts", h.Publish)
|
g.POST("/posts", h.Publish)
|
||||||
g.GET("/posts/:id", h.Get)
|
g.GET("/posts/:id", h.Get)
|
||||||
@ -250,6 +258,8 @@ func registerMusicRoutes(v1 *gin.RouterGroup, deps *EngineDeps) {
|
|||||||
g := v1.Group("/music")
|
g := v1.Group("/music")
|
||||||
// 客户端可读:无需登录(列表为运营配置的公开曲目元数据)
|
// 客户端可读:无需登录(列表为运营配置的公开曲目元数据)
|
||||||
g.GET("/playlist", h.Playlist)
|
g.GET("/playlist", h.Playlist)
|
||||||
|
g.GET("/playlist/grouped", h.PlaylistGrouped)
|
||||||
|
g.GET("/tracks/:id", h.GetTrack)
|
||||||
g.GET("/categories", h.Categories)
|
g.GET("/categories", h.Categories)
|
||||||
g.GET("/settings", h.Settings)
|
g.GET("/settings", h.Settings)
|
||||||
}
|
}
|
||||||
|
|||||||
12
migrations/0030_subscription_plans_package_name.sql
Normal file
12
migrations/0030_subscription_plans_package_name.sql
Normal 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);
|
||||||
24
migrations/0031_square_channel_category.sql
Normal file
24
migrations/0031_square_channel_category.sql
Normal 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);
|
||||||
Loading…
Reference in New Issue
Block a user