chore(deploy): geo snippet、popadmin 补丁与订阅包名脚本

补充 world geo nginx snippet、OpenAPI/geo 热更新脚本,以及 popadmin package_name 定价补丁,便于运维对齐。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
DESKTOP-L8OUL9F\gfl0034 2026-09-30 09:17:18 +08:00
parent 53426af122
commit 5d05eb0804
18 changed files with 2899 additions and 51 deletions

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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