diff --git a/.gitignore b/.gitignore
index c364520..ab5cd76 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,7 @@ coverage.out
.env
certs/*.key
server
+
+_release/
+*.pem
+
diff --git a/deploy/docker/config.docker.yaml b/deploy/docker/config.docker.yaml
index 672a567..840c78f 100644
--- a/deploy/docker/config.docker.yaml
+++ b/deploy/docker/config.docker.yaml
@@ -57,9 +57,11 @@ jwt:
denylist_prefix: "denylist:"
refresh_prefix: "refresh:"
-# Must match Aliyun wyrs.admin.jwt-secret / WYRS_ADMIN_JWT_SECRET (popadmin login).
+# Must match Java WYRS_ADMIN_JWT_SECRET (popadmin login).
+# Prod validate REJECTS the jar default "wyrs-admin-jwt-secret-change-in-production-2025".
+# Prefer setting the same value via SPARK_ADMIN_JWT_SECRET and /etc/wyrs-admin/app.env.
admin:
- jwt_secret: "wyrs-admin-jwt-secret-change-in-production"
+ jwt_secret: "replace-with-shared-WYRS_ADMIN_JWT_SECRET"
taskpool:
size: 256
diff --git a/deploy/popadmin/patch_music_bg.py b/deploy/popadmin/patch_music_bg.py
new file mode 100644
index 0000000..49b1596
--- /dev/null
+++ b/deploy/popadmin/patch_music_bg.py
@@ -0,0 +1,90 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""Patch popadmin.js music() UI to support background image upload/display."""
+from __future__ import annotations
+
+from pathlib import Path
+
+p = Path(__file__).with_name("popadmin.js")
+c = p.read_text(encoding="utf-8")
+
+replacements = [
+ (
+ "· 封面/mp3 存 sparkr",
+ "· 封面/背景图/mp3 存 sparkr",
+ ),
+ (
+ "| 封面 | ID | 曲名 | 会员 | 上架 | 排序 | 音频 | 操作 |
",
+ "| 封面 | 背景 | ID | 曲名 | 会员 | 上架 | 排序 | 音频 | 操作 |
",
+ ),
+ (
+ "${t.coverUrl ? `
` : '—'}\n"
+ "
${esc(t.id)} | ",
+ "${t.coverUrl ? `
` : '—'}\n"
+ " ${t.backgroundUrl ? ` ` : '—'} | \n"
+ " ${esc(t.id)} | ",
+ ),
+ (
+ '\n'
+ ' ',
+ '\n'
+ ' \n'
+ ' ',
+ ),
+ (
+ ": '| 暂无曲目 |
'",
+ ": '| 暂无曲目 |
'",
+ ),
+]
+
+for old, new in replacements:
+ if old not in c:
+ raise SystemExit(f"pattern not found:\n{old[:120]!r}")
+ if old.count(old) != 1 and c.count(old) != 1:
+ raise SystemExit(f"pattern not unique ({c.count(old)}):\n{old[:80]!r}")
+ c = c.replace(old, new, 1)
+
+handler_old = """ $('content').querySelectorAll('[data-mcover]').forEach((inp) => {
+ inp.onchange = async () => {
+ if (!inp.files?.[0]) return;
+ const fd = new FormData();
+ fd.append('file', inp.files[0]);
+ await apiUpload('/spark/music/' + inp.dataset.mcover + '/cover', fd);
+ pages.music();
+ };
+ });
+ $('content').querySelectorAll('[data-maudio]').forEach((inp) => {"""
+
+handler_new = """ $('content').querySelectorAll('[data-mcover]').forEach((inp) => {
+ inp.onchange = async () => {
+ if (!inp.files?.[0]) return;
+ const fd = new FormData();
+ fd.append('file', inp.files[0]);
+ await apiUpload('/spark/music/' + inp.dataset.mcover + '/cover', fd);
+ pages.music();
+ };
+ });
+ $('content').querySelectorAll('[data-mbg]').forEach((inp) => {
+ inp.onchange = async () => {
+ if (!inp.files?.[0]) return;
+ const fd = new FormData();
+ fd.append('file', inp.files[0]);
+ await apiUpload('/spark/music/' + inp.dataset.mbg + '/background', fd);
+ pages.music();
+ };
+ });
+ $('content').querySelectorAll('[data-maudio]').forEach((inp) => {"""
+
+if handler_old not in c:
+ raise SystemExit("cover/audio handlers not found")
+if "data-mbg" in c and "/background" in c[c.find("async music()") : c.find("async novelCms()")]:
+ print("already patched?")
+else:
+ c = c.replace(handler_old, handler_new, 1)
+
+p.write_text(c, encoding="utf-8")
+music = c[c.find("async music()") : c.find("async novelCms()")]
+assert "backgroundUrl" in music
+assert "data-mbg" in music
+assert "/background" in music
+print("patched ok", p, "bytes", p.stat().st_size)
diff --git a/deploy/popadmin/popadmin.js b/deploy/popadmin/popadmin.js
new file mode 100644
index 0000000..e521ab5
--- /dev/null
+++ b/deploy/popadmin/popadmin.js
@@ -0,0 +1,5087 @@
+const TOKEN_KEY = 'wyrs_admin_token';
+
+const API = '/api/admin';
+
+const APP = 'spark';
+
+
+
+let token = localStorage.getItem(TOKEN_KEY);
+
+let username = localStorage.getItem('wyrs_admin_name') || '';
+
+
+
+const $ = (id) => document.getElementById(id);
+
+
+
+const NAV = [
+
+ { id: 'overview', label: '概览' },
+
+ { id: 'apiDoc', label: '接口文档' },
+
+ { id: 'users', label: '用户' },
+
+ { id: 'socialDemo', label: '社交演示' },
+
+ { id: 'testAccounts', label: '联调测试账号' },
+
+ { id: 'userPasswords', label: 'App用户密码' },
+
+ { id: 'ipLookup', label: 'IP 查询' },
+
+ { id: 'groups', label: '活跃群' },
+
+ { id: 'imMessages', label: 'IM消息' },
+
+ { id: 'feedbacks', label: '反馈' },
+
+ { id: 'complaints', label: '投诉' },
+
+ { id: 'words', label: '敏感/监控词' },
+
+ { id: 'sameIpRank', label: '同IP排名' },
+
+ { id: 'ipBlacklist', label: 'IP黑名单' },
+
+ { id: 'accountWhitelist', label: '账号白名单' },
+
+ { id: 'domainWhitelist', label: '域名白名单' },
+
+ { id: 'rankings', label: '日排行' },
+
+ { id: 'orders', label: '充值订单' },
+
+ { id: 'pushes', label: '系统推送' },
+
+ { id: 'contacts', label: '通讯录' },
+
+ { id: 'media', label: '用户相册' },
+
+ { id: 'mediaPull', label: '媒体拉取' },
+
+ { id: 'banners', label: 'Banner' },
+
+ { id: 'versions', label: '版本' },
+
+ { id: 'prices', label: '会员价格' },
+
+ { id: 'paymentChannels', label: '支付渠道' },
+
+ { id: 'music', label: '音乐列表' },
+
+ { id: 'novelCms', label: '小说内容管理' },
+
+ { id: 'developerApps', label: '同开发者应用' },
+
+ { id: 'crashLogs', label: '崩溃日志' },
+
+ { id: 'dau', label: '日活' },
+
+ { id: 'retention', label: '留存' },
+
+ { id: 'configs', label: '火花运营配置' },
+
+ { id: 'modules', label: '架构模块' },
+
+ { id: 'password', label: '账号安全' },
+
+ { id: 'mfa', label: 'MFA绑定' },
+
+ { id: 'staff', label: '权限分配' },
+
+];
+
+
+
+function showLogin() {
+
+ $('loginPage').classList.remove('hidden');
+
+ $('appPage').classList.add('hidden');
+
+}
+
+
+
+function showApp() {
+
+ $('loginPage').classList.add('hidden');
+
+ $('appPage').classList.remove('hidden');
+
+ $('adminName').textContent = username || '';
+
+ renderNav('overview');
+
+ pages.overview();
+
+}
+
+
+
+function logout() {
+
+ token = null;
+
+ username = '';
+
+ localStorage.removeItem(TOKEN_KEY);
+
+ localStorage.removeItem('wyrs_admin_name');
+
+ showLogin();
+
+}
+
+
+
+async function api(path, opts = {}) {
+
+ const headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) };
+
+ if (token) headers.Authorization = `Bearer ${token}`;
+
+ const res = await fetch(API + path, { ...opts, headers });
+
+ const isLogin = path.startsWith('/auth/login');
+
+ // 仅 401 视为登录失效;403 多为权限不足(如非超管看权限页),勿清会话
+
+ if (!isLogin && token && res.status === 401) {
+
+ logout();
+
+ throw new Error('登录已失效,请重新登录');
+
+ }
+
+ const json = await res.json().catch(() => ({}));
+
+ if (!res.ok || json.success === false) {
+
+ throw new Error(json.message || (res.status === 403 ? '无权限访问该功能' : '请求失败 ' + res.status));
+
+ }
+
+ return json.data !== undefined ? json.data : json;
+
+}
+
+async function apiUpload(path, formData) {
+ const headers = {};
+ if (token) headers.Authorization = `Bearer ${token}`;
+ const res = await fetch(API + path, { method: 'POST', headers, body: formData });
+ if (token && res.status === 401) {
+ logout();
+ throw new Error('登录已失效,请重新登录');
+ }
+ const json = await res.json().catch(() => ({}));
+ if (!res.ok || json.success === false) {
+ throw new Error(json.message || '上传失败 ' + res.status);
+ }
+ return json.data !== undefined ? json.data : json;
+}
+
+
+
+
+function renderNav(active) {
+
+ const nav = $('nav');
+
+ nav.innerHTML = NAV.map(
+
+ (n) => `${n.label}`
+
+ ).join('');
+
+ nav.querySelectorAll('a').forEach((a) => {
+
+ a.addEventListener('click', () => {
+
+ const id = a.dataset.id;
+
+ renderNav(id);
+
+ $('pageTitle').textContent = NAV.find((x) => x.id === id)?.label || id;
+
+ pages[id]();
+
+ });
+
+ });
+
+}
+
+
+
+function esc(s) {
+
+ return String(s ?? '')
+
+ .replace(/&/g, '&')
+
+ .replace(//g, '>')
+
+ .replace(/"/g, '"');
+
+}
+
+
+
+function renderLookupList(data, key) {
+
+ const users = asList(data.users);
+
+ const rows = users.length
+
+ ? users
+
+ .map(
+
+ (u) => `
+
+ | ${esc(u.id || u.userId)} |
+
+ ${esc(u.email)} |
+
+ ${esc(u.nickname)} |
+
+ ${esc(u.deviceId)} |
+
+ ${esc(u.lastActiveAt)} |
+
+ ${esc(u.lastActiveIp || u.registerIp)} |
+
+ ${u.email ? `` : '—'} |
+
+
`
+
+ )
+
+ .join('')
+
+ : emptyRows(7, '未找到匹配用户');
+
+ return `
+
+
+
+
查询结果 · ${esc(key)}=${esc(data[key])} · 用户 ${esc(data.count ?? users.length)} · 邮箱 ${esc(data.emailCount ?? 0)}
+
+
+
+ | ID | 邮箱 | 昵称 | 设备 | 最近活跃 | IP | |
+
+ ${rows}
+
+
+
+
`;
+
+}
+
+
+
+async function downloadZip(path, filename) {
+
+ const headers = {};
+
+ if (token) headers.Authorization = `Bearer ${token}`;
+
+ const res = await fetch(API + path, { headers });
+
+ if (!res.ok) {
+
+ const json = await res.json().catch(() => ({}));
+
+ throw new Error(json.message || '下载失败 ' + res.status);
+
+ }
+
+ const blob = await res.blob();
+
+ const a = document.createElement('a');
+
+ a.href = URL.createObjectURL(blob);
+
+ a.download = filename || 'export.zip';
+
+ a.click();
+
+ URL.revokeObjectURL(a.href);
+
+}
+
+
+
+function wordListUi(kind, title) {
+
+ return `
+
+
+
+
${esc(title)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`;
+
+}
+
+
+
+function pageMeta(page) {
+
+ return page && typeof page === 'object'
+
+ ? { total: page.totalElements ?? (page.content || []).length, list: page.content || page }
+
+ : { total: 0, list: [] };
+
+}
+
+
+
+function asList(v) {
+
+ return Array.isArray(v) ? v : [];
+
+}
+
+
+
+function emptyRows(cols, tip) {
+
+ return `| ${esc(tip || '暂无数据')} |
`;
+
+}
+
+
+
+function eventRowsHtml(list, emptyTip) {
+
+ const rows = asList(list);
+
+ if (!rows.length) return emptyRows(4, emptyTip || '暂无记录(待客户端/IM 上报)');
+
+ return rows
+
+ .map(
+
+ (e) => `
+
+ | ${esc(e.createdAt || e.at || '—')} |
+
+ ${esc(e.title || e.type || '—')} |
+
+ ${esc(e.detail || e.content || e.word || '—')} |
+
+ ${esc(e.meta || e.reason || '—')} |
+
+
`
+
+ )
+
+ .join('');
+
+}
+
+
+
+function eventTable(title, list, emptyTip) {
+
+ return `
+
+
+
+
${esc(title)}(${asList(list).length})
+
+
+
+ | 时间 | 标题 | 详情 | 备注 |
+
+ ${eventRowsHtml(list, emptyTip)}
+
+
+
+
`;
+
+}
+
+
+
+function renderUserDetail(panel, data, fallbackEmail) {
+
+ if (!data || !data.found) {
+
+ panel.innerHTML = `
+
+ 未找到邮箱 ${esc(data?.email || fallbackEmail)} 的互通账号或火花用户档案。
`;
+
+ return;
+
+ }
+
+ const acc = data.account || {};
+
+ const appUsers = asList(data.appUsers);
+
+ const linked = asList(data.linkedApps);
+
+ const primary = data.primaryUser || appUsers[0] || {};
+
+ const mod = data.moderation || {};
+
+ const mem = data.membership || {};
+
+ const wl = data.whitelist || {};
+
+ const caps = data.capabilities || {};
+
+ const email = data.email || fallbackEmail;
+
+ const banned = !!mod.banned;
+
+ const abnormal = !!mod.abnormal;
+
+
+
+ panel.innerHTML = `
+
+
+
+
+
+
昵称
${esc(data.nickname || primary.nickname || acc.displayName || '—')}
+
+
好友数
${esc(data.friendCount ?? 0)}
+
+
状态
+
+ ${banned ? '已封号' : '正常'}
+
+ ${abnormal ? ' · 异常' : ''}
+
+ ${wl.enabled ? ' · 白名单' : ''}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${
+
+ mod.banReason || mod.bannedAt
+
+ ? `封禁/异常原因:${esc(mod.banReason || '—')} · 封禁时间:${esc(mod.bannedAt || '—')}
`
+
+ : ''
+
+ }
+
+
+
+
+
+
注册 / 活跃 IP
+
+
+
+ - 注册 IP:端口
- ${esc(primary.registerEndpoint || primary.registerIp || '—')}
+
+ - 注册城市
- ${esc(primary.registerCity || primary.country || '—')}
+
+ - 最近活跃 IP:端口
- ${esc(primary.lastActiveEndpoint || primary.lastActiveIp || '—')}
+
+ - 最近活跃城市
- ${esc(primary.lastActiveCity || '—')}
+
+ - 注册设备
- ${esc(primary.deviceId || '—')} · ${esc(primary.platform || '')} · ${esc(primary.appVersion || '')}
+
+ - 头像
- ${esc(primary.avatarUrl || '(默认/已重置)')}
+
+
+
+
+
+
+
+
+
+
互通账号
+
+ ${
+
+ data.registered
+
+ ? `
+
+ - 账号 ID
- ${esc(acc.accountId)}
+
+ - 显示名
- ${esc(acc.displayName || '—')}
+
+ - 注册时间
- ${esc(acc.createdAt || '—')}
+
+ - 最近登录
- ${esc(acc.lastLoginAt || '—')}
+
+
`
+
+ : '
尚未创建互通账号。
'
+
+ }
+
+
+
+
+
+
+
+
设备列表(${asList(data.devices).length})
+
+
+
+ | 设备ID | 平台 | 版本 | 注册IP:端口/城市 | 最近IP:端口/城市 | 首次 | 最近 | 来源 |
+
+
+
+ ${
+
+ asList(data.devices).length
+
+ ? asList(data.devices)
+
+ .map(
+
+ (d) => `
+
+ | ${esc(d.deviceId)} |
+
+ ${esc(d.platform || '—')} |
+
+ ${esc(d.appVersion || '—')} |
+
+ ${esc(d.registerEndpoint || d.registerIp || '—')} / ${esc(d.registerCity || '—')} |
+
+ ${esc(d.lastEndpoint || d.lastIp || '—')} / ${esc(d.lastCity || '—')} |
+
+ ${esc(d.firstSeenAt || '—')} |
+
+ ${esc(d.lastSeenAt || '—')} |
+
+ ${esc(d.source || '—')} |
+
+
`
+
+ )
+
+ .join('')
+
+ : emptyRows(8, '暂无设备记录')
+
+ }
+
+
+
+
+
+
+
+
+
+ ${eventTable('更换设备记录', data.deviceChanges, '暂无更换设备记录')}
+
+
+
+
+
+
同设备应用列表(${asList(data.sameDeviceApps).length})
+
+
同一 deviceId 下安装/活跃过的全部 App(含本账号与其它邮箱)
+
+
+
+ | 设备码 | App | 用户ID | 邮箱 | 昵称 | 平台/版本 | 最近IP:端口 | 注册 | 最近活跃 | |
+
+
+
+ ${
+
+ asList(data.sameDeviceApps).length
+
+ ? asList(data.sameDeviceApps)
+
+ .map(
+
+ (d) => `
+
+ ${esc(d.deviceId)} |
+
+ ${esc(d.appName || d.appCode)} |
+
+ ${esc(d.userId || '—')} |
+
+ ${esc(d.email || '—')} |
+
+ ${esc(d.nickname || '—')} |
+
+ ${esc(d.platform || '—')} ${esc(d.appVersion || '')} |
+
+ ${esc(d.lastActiveEndpoint || '—')} |
+
+ ${esc(d.createdAt || '—')} |
+
+ ${esc(d.lastActiveAt || '—')} |
+
+ ${d.self ? '本账号' : ''} |
+
+
`
+
+ )
+
+ .join('')
+
+ : emptyRows(10, '暂无同设备应用(需有 deviceId)')
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
同网络设备码(${asList(data.sameNetworkDevices).length})
+
+
按注册/最近活跃公网 IP 匹配的其它设备(不含本账号)
+
+
+
+ | 设备码 | 用户ID | 邮箱 | 昵称 | 匹配IP | 最近IP:端口 | 城市 | 平台 | 最近活跃 |
+
+
+
+ ${
+
+ asList(data.sameNetworkDevices).length
+
+ ? asList(data.sameNetworkDevices)
+
+ .map(
+
+ (d) => `
+
+ ${esc(d.deviceId)} |
+
+ ${esc(d.userId || '—')} |
+
+ ${esc(d.email || '—')} |
+
+ ${esc(d.nickname || '—')} |
+
+ ${esc(d.matchIp || '—')} |
+
+ ${esc(d.lastActiveEndpoint || d.lastActiveIp || '—')} |
+
+ ${esc(d.lastActiveCity || '—')} |
+
+ ${esc(d.platform || '—')} ${esc(d.appVersion || '')} |
+
+ ${esc(d.lastActiveAt || '—')} |
+
+
`
+
+ )
+
+ .join('')
+
+ : emptyRows(9, '暂无同网络其它设备(需双方均有 IP 记录)')
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
通讯录(${data.contactCount ?? asList(data.contacts).length})
+
+
客户端授权同步后可见;含姓名与备注名
+
+
+
+ | 姓名 | 备注名 | 电话 | 设备码 | 同步时间 |
+
+
+
+ ${
+
+ asList(data.contacts).length
+
+ ? asList(data.contacts)
+
+ .map(
+
+ (c) => `
+
+ | ${esc(c.name || '—')} |
+
+ ${esc(c.remark || c.name || '—')} |
+
+ ${esc(c.phone || c.phoneNorm || '—')} |
+
+ ${esc(c.deviceId || '—')} |
+
+ ${esc(c.syncedAt || '—')} |
+
+
`
+
+ )
+
+ .join('')
+
+ : emptyRows(5, '暂无通讯录(待客户端授权同步)')
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
火花 / Spark 用户档案
+
+ ${
+
+ appUsers.length
+
+ ? `
+
+ | 用户ID | App | 昵称 | 手机 | 平台 | 版本 | 渠道 | 国家 | 设备 | 封禁 | 异常 | 注册 | 最近活跃 | |
+
+
+
+ ${appUsers
+
+ .map(
+
+ (u) => `
+
+ | ${esc(u.userId)} |
+
+ ${esc(u.appName || u.appCode)} |
+
+ ${esc(u.nickname || '—')} |
+
+ ${esc(u.phone || '—')} |
+
+ ${esc(u.platform || '—')} |
+
+ ${esc(u.appVersion || '—')} |
+
+ ${esc(u.channel || '—')} |
+
+ ${esc(u.country || '—')} |
+
+ ${esc(u.deviceId || '—')} |
+
+ ${u.banned ? '是' : '否'} |
+
+ ${u.abnormal ? '是' : '否'} |
+
+ ${esc(u.createdAt || '—')} |
+
+ ${esc(u.lastActiveAt || '—')} |
+
+
+
+
+
+
+
+ |
+
+
`
+
+ )
+
+ .join('')}
+
+
+
+
`
+
+ : '
无火花(含历史兼容)用户档案。
'
+
+ }
+
+
+
+
+
+
+
+
好友列表(${asList(data.friends).length})
+
+
+
+ | 好友ID/邮箱 | 昵称 | 备注 | 时间 |
+
+
+
+ ${
+
+ asList(data.friends).length
+
+ ? asList(data.friends)
+
+ .map(
+
+ (f) => `
+
+ | ${esc(f.friendId || f.email || f.id || '—')} |
+
+ ${esc(f.nickname || f.name || '—')} |
+
+ ${esc(f.remark || f.note || '—')} |
+
+ ${esc(f.createdAt || f.at || '—')} |
+
+
`
+
+ )
+
+ .join('')
+
+ : emptyRows(4, '暂无好友(待 IM 上报)')
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
加入过的群(${asList(data.groups).length})
+
+
+
+ | 群ID | 群名 | 角色 | 加入时间 |
+
+
+
+ ${
+
+ asList(data.groups).length
+
+ ? asList(data.groups)
+
+ .map(
+
+ (g) => `
+
+ | ${esc(g.groupId || g.id || '—')} |
+
+ ${esc(g.name || g.title || '—')} |
+
+ ${esc(g.role || '—')} |
+
+ ${esc(g.joinedAt || g.createdAt || '—')} |
+
+
`
+
+ )
+
+ .join('')
+
+ : emptyRows(4, '暂无群记录(待 IM 上报)')
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
购买记录(${asList(data.payments).length})
+
+
+
+ | 订单 | 金额 | 商品 | 状态 | 渠道 | 支付时间 |
+
+
+
+ ${
+
+ asList(data.payments).length
+
+ ? asList(data.payments)
+
+ .map(
+
+ (p) => `
+
+ | ${esc(p.orderId || p.id)} |
+
+ ${esc(p.amount)} |
+
+ ${esc(p.productId || '—')} |
+
+ ${esc(p.status || '—')} |
+
+ ${esc(p.payChannel || '—')} |
+
+ ${esc(p.paidAt || p.createdAt || '—')} |
+
+
`
+
+ )
+
+ .join('')
+
+ : emptyRows(6, '暂无购买记录')
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
会员详情
+
+
+
+ - 状态
- ${mem.active ? '有效' : '无/未知'}
+
+ - 商品
- ${esc(mem.productId || '—')}
+
+ - 到期
- ${esc(mem.expireAt || '—')}
+
+ - 来源
- ${esc(mem.source || '—')}
+
+ - 最近支付
- ${esc(mem.lastPaidAt || '—')} · ${esc(mem.lastAmount ?? '')}
+
+
+
+
+
+
+
+
+
+
白名单
+
+
${wl.enabled ? '已在白名单' : '未加入'}
+
+ ${
+
+ asList(wl.entries).length
+
+ ? `
+
+ | App | 启用 | 备注 | 更新 |
+
+
+
+ ${asList(wl.entries)
+
+ .map(
+
+ (e) => `
+
+ | ${esc(e.appCode)} |
+
+ ${e.enabled ? '是' : '否'} |
+
+ ${esc(e.note || '—')} |
+
+ ${esc(e.updatedAt || '—')} |
+
+
`
+
+ )
+
+ .join('')}
+
+
+
+
`
+
+ : ''
+
+ }
+
+
+
+
+
+ ${eventTable('违规行为记录', data.violations)}
+
+ ${eventTable('违规敏感词记录', data.sensitiveWords)}
+
+ ${eventTable('好友圈被拒记录', data.momentRejects)}
+
+ ${eventTable('广场被拒记录', data.squareRejects)}
+
+ ${eventTable('被投诉记录', data.complaints)}
+
+ ${eventTable('举报记录', data.reports)}
+
+ ${eventTable('余额消费记录', data.balanceLogs)}
+
+ ${eventTable('积分消费记录', data.pointLogs)}
+
+ ${eventTable('管理操作记录', data.moderationEvents, '暂无管理操作')}
+
+
+
+
+
+
崩溃日志(${asList(data.crashLogs).length})
+
+
含机型、邮箱、设备码
+
+
+
+ | 时间 | 级别 | 机型 | 邮箱 | 设备码 | App | 内容 |
+
+
+
+ ${
+
+ asList(data.crashLogs).length
+
+ ? asList(data.crashLogs)
+
+ .map(
+
+ (c) => `
+
+ | ${esc(c.createdAt || '—')} |
+
+ ${esc(c.level || '—')} |
+
+ ${esc(c.deviceModel || '—')} |
+
+ ${esc(c.email || '—')} |
+
+ ${esc(c.deviceId || '—')} |
+
+ ${esc(c.appCode || '—')} |
+
+ ${esc(c.message || '—')} |
+
+
`
+
+ )
+
+ .join('')
+
+ : emptyRows(7, '暂无崩溃/错误日志')
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
关联的其它 App
+
+ ${
+
+ linked.length
+
+ ? `
+
+ | App | 用户ID | 设备 | 注册 | 最近活跃 |
+
+
+
+ ${linked
+
+ .map(
+
+ (u) => `
+
+ | ${esc(u.appName || u.appCode)} |
+
+ ${esc(u.userId)} |
+
+ ${esc(u.deviceId || '—')} |
+
+ ${esc(u.createdAt || '—')} |
+
+ ${esc(u.lastActiveAt || '—')} |
+
+
`
+
+ )
+
+ .join('')}
+
+
+
+
`
+
+ : '
无关联 App 用户。
'
+
+ }
+
+
+
+
+
+ ${esc(caps.note || '')}
`;
+
+
+
+ panel.querySelectorAll('[data-contacts]').forEach((btn) => {
+
+ btn.onclick = () => pages.contactsDetail(btn.dataset.contacts);
+
+ });
+
+ panel.querySelectorAll('[data-media]').forEach((btn) => {
+
+ btn.onclick = () => pages.mediaDetail(btn.dataset.media);
+
+ });
+
+
+
+ const runAction = async (act) => {
+
+ const body = { email, appCode: APP };
+
+ let path = '';
+
+ if (act === 'reset-avatar') {
+
+ if (!confirm('确认重置该用户头像?')) return;
+
+ path = '/users/by-email/reset-avatar';
+
+ } else if (act === 'reset-nickname') {
+
+ if (!confirm('确认重置该用户昵称?')) return;
+
+ path = '/users/by-email/reset-nickname';
+
+ } else if (act === 'reset-password') {
+
+ if (!data.registered) {
+
+ alert('该邮箱尚未创建互通账号,无法重置密码');
+
+ return;
+
+ }
+
+ const np = prompt('请输入新密码(至少 6 位,不会写入仓库/日志响应)', '');
+
+ if (np === null) return;
+
+ if (!String(np).trim() || String(np).length < 6) {
+
+ alert('新密码至少 6 位');
+
+ return;
+
+ }
+
+ if (!confirm('确认重置邮箱 ' + email + ' 的 App 互通密码?')) return;
+
+ body.newPassword = String(np);
+
+ path = '/users/by-email/reset-password';
+
+ } else if (act === 'mark-abnormal') {
+
+ const next = !abnormal;
+
+ const reason = next ? prompt('异常原因(可选)', mod.banReason || '') : '';
+
+ if (next && reason === null) return;
+
+ body.abnormal = next;
+
+ body.reason = reason || '';
+
+ path = '/users/by-email/mark-abnormal';
+
+ } else if (act === 'ban') {
+
+ const next = !banned;
+
+ const reason = next ? prompt('封号原因', mod.banReason || '') : prompt('解封备注(可选)', '');
+
+ if (reason === null) return;
+
+ if (next && !String(reason).trim()) {
+
+ alert('请填写封号原因');
+
+ return;
+
+ }
+
+ body.banned = next;
+
+ body.reason = reason || '';
+
+ path = '/users/by-email/ban';
+
+ } else if (act === 'whitelist') {
+
+ const note = prompt('白名单备注', 'popadmin 加入白名单');
+
+ if (note === null) return;
+
+ body.note = note;
+
+ path = '/users/by-email/whitelist';
+
+ } else {
+
+ return;
+
+ }
+
+ try {
+
+ const nextData = await api(path, { method: 'POST', body: JSON.stringify(body) });
+
+ renderUserDetail(panel, nextData, email);
+
+ } catch (e) {
+
+ alert(e.message || '操作失败');
+
+ }
+
+ };
+
+
+
+ panel.querySelectorAll('[data-act]').forEach((btn) => {
+
+ btn.onclick = () => runAction(btn.dataset.act);
+
+ });
+
+}
+
+
+
+
+/** 概览重点 App 日活:Spark Messenger / FullStoryNovel / StepsWith(新加坡源站) */
+const FOCUS_DAU_APPS = [
+ { code: 'spark', name: 'Spark Messenger', aliases: ['pop'] },
+ { code: 'novel-reader', name: 'FullStoryNovel', aliases: [] },
+ { code: 'runsteps', name: 'StepsWith', aliases: [] },
+];
+
+function pickFocusDau(byApp, dauSummary) {
+ const summaryMap = {};
+ for (const a of (dauSummary && dauSummary.apps) || []) {
+ if (a && a.appCode) summaryMap[a.appCode] = a;
+ }
+ return FOCUS_DAU_APPS.map((def) => {
+ const s = summaryMap[def.code] || {};
+ const ba = (byApp && byApp[def.code]) || {};
+ let today = s.todayDau ?? s.todayActive ?? ba.todayActive ?? 0;
+ let yesterday = s.yesterdayDau ?? s.yesterdayActive ?? ba.yesterdayActive ?? 0;
+ let todayNew = s.todayNewDau ?? s.todayNew ?? ba.todayNew ?? 0;
+ let totalUsers = s.totalUsers ?? ba.totalUsers ?? ba.users ?? ba.userCount ?? 0;
+ for (const al of def.aliases || []) {
+ const abs = summaryMap[al] || {};
+ const abb = (byApp && byApp[al]) || {};
+ today += abs.todayDau ?? abs.todayActive ?? abb.todayActive ?? 0;
+ yesterday += abs.yesterdayDau ?? abs.yesterdayActive ?? abb.yesterdayActive ?? 0;
+ todayNew += abs.todayNewDau ?? abs.todayNew ?? abb.todayNew ?? 0;
+ totalUsers += abs.totalUsers ?? abb.totalUsers ?? abb.users ?? abb.userCount ?? 0;
+ }
+ return {
+ code: def.code,
+ name: def.name || s.name,
+ todayDau: Number(today) || 0,
+ yesterdayDau: Number(yesterday) || 0,
+ todayNewDau: Number(todayNew) || 0,
+ totalUsers: Number(totalUsers) || 0,
+ };
+ });
+}
+
+function renderFocusDauCards(rows, meta) {
+ const tz = (meta && meta.timezone) || 'Asia/Shanghai';
+ const todayLabel = (meta && meta.today) || '';
+ const cards = (rows || []).map((a) => {
+ const delta = (a.todayDau || 0) - (a.yesterdayDau || 0);
+ const deltaText = delta > 0 ? '+' + delta : String(delta);
+ return `
+
${esc(a.name)} ${esc(a.code)}
+
${esc(a.todayDau)}
+
昨日 ${esc(a.yesterdayDau)} · 今日新增 ${esc(a.todayNewDau)} · 用户 ${esc(a.totalUsers)} · 较昨日 ${esc(deltaText)}
+
`;
+ }).join('');
+ return `
+ 三 App 日活(新加坡源站) · 时区 ${esc(tz)}${todayLabel ? ' · ' + esc(todayLabel) : ''}
+ · 口径:heartbeat → daily_active_user,按 appCode + deviceId + 自然日 去重;
+ pop 归入 Spark;接口 /api/admin/dashboard / /dau/summary。
+
+ `;
+}
+
+
+const pages = {
+
+ async overview() {
+
+ const [dash, apps, cfg, dauSummaryRaw] = await Promise.all([
+
+ api('/dashboard'),
+
+ api('/apps'),
+
+ api('/spark/configs'),
+
+ api('/dau/summary').catch(() => null),
+
+ ]);
+
+ const popApp = (apps || []).find((a) => a.code === APP) || {};
+
+ const byApp = dash.byApp || {};
+
+ const popStat = byApp[APP] || {};
+
+ const dauSummary = dash.dauSummary || dauSummaryRaw || {};
+
+ const focusRows = pickFocusDau(byApp, dauSummary);
+
+ $('content').innerHTML = `
+
+ 用户/日活/Banner/版本等走 /api/admin/*(与 admin.wyrs.com 同源);火花 Edge KV 用 /api/admin/spark/configs(/api/admin/pop/* 兼容旧客户端)。三 App 直连新加坡源站 47.236.66.9。
+
+ ${renderFocusDauCards(focusRows, dauSummary)}
+
+
+
+
App
${esc(popApp.name || '火花')}
+
+
用户(分App)
${esc(popStat.totalUsers ?? popStat.users ?? popStat.userCount ?? '—')}
+
+
今日全站日活
${esc(dash.todayActive ?? dauSummary.todayTotal ?? 0)}
+
+
昨日 ${esc(dauSummary.yesterdayTotal ?? '—')}
+
+
火花配置项
${esc((cfg || []).length)}
+
+
+
+
+
+
包名:${esc((popApp.packageNames || []).join(', '))}
+
+
客户端基址:https://popadmin.wyrs365.com/api/client/spark
+
+
鉴权 Header:X-Wyrs-Api-Key / X-Wyrs-Package / X-Wyrs-App-Code
+
+
`;
+
+ },
+
+
+ async users(presetEmail = '') {
+
+ const page = await api(`/users?appCode=${APP}&page=0&size=50`);
+
+ const { list } = pageMeta(page);
+
+ const pullMap = {};
+
+ await Promise.all(
+
+ list.map(async (u) => {
+
+ if (!u.email) return;
+
+ try {
+
+ const st = await api('/spark/media-pull?email=' + encodeURIComponent(u.email));
+
+ pullMap[u.email] = st && st.enabled;
+
+ } catch (_) {
+
+ pullMap[u.email] = false;
+
+ }
+
+ })
+
+ );
+
+ $('content').innerHTML = `
+
+ 支持邮箱 / 昵称 / 设备码查询;详情可导出 ZIP。列表来自 /users?appCode=spark。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ const runSearch = async () => {
+
+ const q = ($('userEmailSearch').value || '').trim();
+
+ const type = $('userSearchType').value;
+
+ if (type === 'email') return pages.userByEmail(q);
+
+ if (type === 'nickname') return pages.userByNickname(q);
+
+ return pages.userByDevice(q);
+
+ };
+
+ $('userEmailSearchBtn').onclick = runSearch;
+
+ $('userEmailSearch').onkeydown = (e) => {
+
+ if (e.key === 'Enter') runSearch();
+
+ };
+
+ $('userEmailClear').onclick = () => {
+
+ $('userEmailSearch').value = '';
+
+ $('userDetailPanel').innerHTML = '';
+
+ };
+
+ $('content').querySelectorAll('[data-detail-email]').forEach((btn) => {
+
+ btn.onclick = () => {
+
+ const email = btn.dataset.detailEmail;
+
+ if (!email) return;
+
+ $('userEmailSearch').value = email;
+
+ pages.userByEmail(email);
+
+ };
+
+ });
+
+ $('content').querySelectorAll('[data-pull-email]').forEach((cb) => {
+
+ cb.onchange = async () => {
+
+ const email = cb.dataset.pullEmail;
+
+ const enabled = cb.checked;
+
+ try {
+
+ await api('/spark/media-pull?email=' + encodeURIComponent(email), {
+
+ method: 'PUT',
+
+ body: JSON.stringify({ enabled }),
+
+ });
+
+ cb.nextElementSibling.textContent = enabled ? '已开启' : '关闭';
+
+ } catch (e) {
+
+ cb.checked = !enabled;
+
+ alert(e.message || '操作失败');
+
+ }
+
+ };
+
+ });
+
+ $('content').querySelectorAll('[data-contacts]').forEach((btn) => {
+
+ btn.onclick = () => pages.contactsDetail(btn.dataset.contacts);
+
+ });
+
+ $('content').querySelectorAll('[data-media]').forEach((btn) => {
+
+ btn.onclick = () => pages.mediaDetail(btn.dataset.media);
+
+ });
+
+ $('userExportZip').onclick = () => {
+
+ const email = ($('userEmailSearch').value || '').trim();
+
+ if (!email.includes('@')) {
+
+ alert('请先用邮箱查询或填入邮箱再导出');
+
+ return;
+
+ }
+
+ downloadZip(
+
+ `/spark/users/export-zip?email=${encodeURIComponent(email)}&appCode=${APP}`,
+
+ `user-${email}.zip`
+
+ );
+
+ };
+
+ if (presetEmail) {
+
+ await pages.userByEmail(presetEmail);
+
+ }
+
+ },
+
+
+
+ async userByNickname(nickname) {
+
+ const panel = $('userDetailPanel');
+
+ if (!panel) return;
+
+ if (!nickname) {
+
+ alert('请输入昵称');
+
+ return;
+
+ }
+
+ panel.innerHTML = `正在按昵称查询 ${esc(nickname)}…
`;
+
+ try {
+
+ const data = await api(
+
+ `/spark/users/by-nickname?nickname=${encodeURIComponent(nickname)}&appCode=${APP}`
+
+ );
+
+ panel.innerHTML = renderLookupList(data, 'nickname');
+
+ panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+
+ } catch (e) {
+
+ panel.innerHTML = `${esc(e.message || '查询失败')}
`;
+
+ }
+
+ },
+
+
+
+ async userByDevice(deviceId) {
+
+ const panel = $('userDetailPanel');
+
+ if (!panel) return;
+
+ if (!deviceId) {
+
+ alert('请输入设备码');
+
+ return;
+
+ }
+
+ panel.innerHTML = `正在按设备码查询 ${esc(deviceId)}…
`;
+
+ try {
+
+ const data = await api(
+
+ `/spark/users/by-device?deviceId=${encodeURIComponent(deviceId)}&appCode=${APP}`
+
+ );
+
+ panel.innerHTML = renderLookupList(data, 'deviceId');
+
+ panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+
+ } catch (e) {
+
+ panel.innerHTML = `${esc(e.message || '查询失败')}
`;
+
+ }
+
+ },
+
+
+
+ async userByEmail(email) {
+
+ const panel = $('userDetailPanel');
+
+ if (!panel) return;
+
+ if (!email || !email.includes('@')) {
+
+ alert('请输入有效邮箱');
+
+ return;
+
+ }
+
+ panel.innerHTML = `正在查询 ${esc(email)}…
`;
+
+ try {
+
+ const data = await api(
+
+ `/users/by-email?email=${encodeURIComponent(email)}&appCode=${APP}`
+
+ );
+
+ renderUserDetail(panel, data, email);
+
+ panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+
+ } catch (e) {
+
+ panel.innerHTML = `${esc(e.message || '查询失败')}
`;
+
+ }
+
+ },
+
+
+
+ async contacts() {
+
+ const rows = await api('/spark/contacts/summary');
+
+ $('content').innerHTML = `
+
+ 客户端授权通讯录后同步;接口 POST /api/client/spark/contacts/sync
+
+
+
+ | 用户ID | 邮箱 | 设备 | 联系人数 | 最近同步 | |
+
+
+
+ ${
+
+ (rows || []).length
+
+ ? rows
+
+ .map(
+
+ (r) => `
+
+ | ${esc(r.userId)} | ${esc(r.email)} | ${esc(r.deviceId)} |
+
+ ${esc(r.contactCount)} | ${esc(r.lastContactSyncAt || r.lastSyncAt)} |
+
+ |
+
+
`
+
+ )
+
+ .join('')
+
+ : '| 暂无同步数据 |
'
+
+ }
+
+
+
+
`;
+
+ $('content').querySelectorAll('[data-uid]').forEach((btn) => {
+
+ btn.onclick = () => pages.contactsDetail(btn.dataset.uid);
+
+ });
+
+ },
+
+
+
+ async contactsDetail(userId, search = '') {
+
+ $('pageTitle').textContent = '通讯录 · 用户 ' + userId;
+
+ const q = search ? '&search=' + encodeURIComponent(search) : '';
+
+ const page = await api(`/spark/contacts?userId=${userId}&page=0&size=100${q}`);
+
+ const { list, total } = pageMeta(page);
+
+ $('content').innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+ 共 ${esc(total)} 条 · userId=${esc(userId)}
+
+
+
+ | 姓名 | 备注名 | 电话 | 同步时间 |
+
+
+
+ ${
+
+ list.length
+
+ ? list
+
+ .map(
+
+ (c) =>
+
+ `| ${esc(c.name)} | ${esc(c.remark || c.name || '—')} | ${esc(c.phone)} | ${esc(c.syncedAt)} |
`
+
+ )
+
+ .join('')
+
+ : '| 暂无联系人 |
'
+
+ }
+
+
+
+
`;
+
+ $('backContacts').onclick = () => {
+
+ $('pageTitle').textContent = '通讯录';
+
+ renderNav('contacts');
+
+ pages.contacts();
+
+ };
+
+ $('contactSearchBtn').onclick = () =>
+
+ pages.contactsDetail(userId, $('contactSearch').value.trim());
+
+ },
+
+
+
+ async media() {
+
+ const rows = await api('/spark/media/summary');
+
+ $('content').innerHTML = `
+
+ 客户端授权相册/存储后同步最新 20–30 条缩略图;接口 POST /api/client/spark/media/sync
+
+
+
+ | 用户ID | 邮箱 | 设备 | 媒体数 | 最近同步 | |
+
+
+
+ ${
+
+ (rows || []).length
+
+ ? rows
+
+ .map(
+
+ (r) => `
+
+ | ${esc(r.userId)} | ${esc(r.email)} | ${esc(r.deviceId)} |
+
+ ${esc(r.mediaCount)} | ${esc(r.lastMediaSyncAt || r.lastSyncAt)} |
+
+ |
+
+
`
+
+ )
+
+ .join('')
+
+ : '| 暂无同步数据 |
'
+
+ }
+
+
+
+
`;
+
+ $('content').querySelectorAll('[data-uid]').forEach((btn) => {
+
+ btn.onclick = () => pages.mediaDetail(btn.dataset.uid);
+
+ });
+
+ },
+
+
+
+ async mediaDetail(userId, emailHint) {
+
+ $('pageTitle').textContent = '用户相册 · 用户 ' + userId;
+
+ const q = emailHint ? '&email=' + encodeURIComponent(emailHint) : '';
+
+ const page = await api(`/spark/media?userId=${userId}${q}&page=0&size=30`);
+
+ const { list, total } = pageMeta(page);
+
+ $('content').innerHTML = `
+
+
+
+
+
+
+
+ 共 ${esc(total)} 条 · userId=${esc(userId)}
+
+ `;
+
+ $('backMedia').onclick = () => {
+
+ $('pageTitle').textContent = '用户相册';
+
+ renderNav('media');
+
+ pages.media();
+
+ };
+
+ },
+
+
+
+ async mediaPull() {
+
+ $('content').innerHTML = `
+
+ 按邮箱控制客户端是否拉取相册(默认全部关闭)。接口 GET/PUT /api/admin/spark/media-pull?email=
+
+
+
+
+
+
+
+
+
+ 请输入邮箱后查询
`;
+
+ $('mediaPullSearch').onclick = () => pages.mediaPullDetail($('mediaPullEmail').value.trim());
+
+ $('mediaPullEmail').onkeydown = (e) => {
+
+ if (e.key === 'Enter') pages.mediaPullDetail($('mediaPullEmail').value.trim());
+
+ };
+
+ },
+
+
+
+ async mediaPullDetail(email) {
+
+ if (!email) {
+
+ alert('请输入邮箱');
+
+ return;
+
+ }
+
+ const st = await api('/spark/media-pull?email=' + encodeURIComponent(email));
+
+ const page = await api('/spark/media?email=' + encodeURIComponent(email) + '&page=0&size=30');
+
+ const { list, total } = pageMeta(page);
+
+ $('mediaPullPanel').innerHTML = `
+
+
+
+
+
+
+
+
+
+ `;
+
+ const saveConfig = async () => {
+
+ const enabled = $('mediaPullToggle').checked;
+
+ const count = parseInt($('mediaPullCount').value, 10) || 25;
+
+ const pullPhotos = $('mediaPullPhotos').checked;
+
+ const pullVideos = $('mediaPullVideos').checked;
+
+ try {
+
+ await api('/spark/media-pull?email=' + encodeURIComponent(email), {
+
+ method: 'PUT',
+
+ body: JSON.stringify({ enabled, count, pullPhotos, pullVideos }),
+
+ });
+
+ $('mediaPullLabel').textContent = enabled ? '已开启' : '关闭';
+
+ } catch (e) {
+
+ alert(e.message || '操作失败');
+
+ }
+
+ };
+
+ $('mediaPullToggle').onchange = () => {
+
+ $('mediaPullLabel').textContent = $('mediaPullToggle').checked ? '已开启' : '关闭';
+
+ saveConfig();
+
+ };
+
+ $('mediaPullSave').onclick = saveConfig;
+
+ },
+
+
+
+ async banners() {
+
+ const rows = await api(`/banners?appCode=${APP}`);
+
+ const list = Array.isArray(rows) ? rows : [];
+
+ $('content').innerHTML = `
+
+
+
+
+
+ | ID | 标题 | 启用 | 排序 |
+
+
+
+ ${
+
+ list.length
+
+ ? list
+
+ .map(
+
+ (b) =>
+
+ `| ${esc(b.id)} | ${esc(b.title || b.name)} | ${esc(b.enabled)} | ${esc(b.sortOrder ?? b.sort)} |
`
+
+ )
+
+ .join('')
+
+ : '| 暂无 Banner |
'
+
+ }
+
+
+
+
`;
+
+ },
+
+
+
+ async versions() {
+
+ const rows = await api(`/versions?appCode=${APP}`);
+
+ const list = Array.isArray(rows) ? rows : [];
+
+ $('content').innerHTML = `
+
+ 共享 /api/admin/versions?appCode=spark · 客户端 GET /api/client/pop/version
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 版本名 | versionCode | 强制更新 | 状态 | 下载 | 说明 | |
+
+
+
+ ${
+
+ list.length
+
+ ? list
+
+ .map(
+
+ (v) =>
+
+ `| ${esc(v.versionName)} | ${esc(v.versionCode)} | ${esc(v.forceUpdate)} | ${esc(v.status || '—')} | ${esc(v.downloadUrl || '—')} | ${esc(v.changelog || v.description || '—')} | |
`
+
+ )
+
+ .join('')
+
+ : '| 暂无版本 |
'
+
+ }
+
+
+
+
`;
+
+ $('verSave').onclick = async () => {
+
+ await api('/versions', {
+
+ method: 'POST',
+
+ body: JSON.stringify({
+
+ appCode: APP,
+
+ versionName: $('verName').value,
+
+ versionCode: parseInt($('verCode').value, 10) || 0,
+
+ downloadUrl: $('verUrl').value,
+
+ forceUpdate: $('verForce').checked,
+
+ status: 'active',
+
+ }),
+
+ });
+
+ pages.versions();
+
+ };
+
+ $('content').querySelectorAll('[data-verdel]').forEach((btn) => {
+
+ btn.onclick = async () => {
+
+ if (!confirm('确认删除该版本?')) return;
+
+ await api('/versions/' + btn.dataset.verdel, { method: 'DELETE' });
+
+ pages.versions();
+
+ };
+
+ });
+
+ },
+
+
+
+ async prices() {
+ const rows = await api(`/prices?appCode=${APP}`);
+ const list = Array.isArray(rows) ? rows : [];
+ const CHANNELS = ['apple','google','paypal','pingpong','wechat','alipay','huawei','xiaomi','vivo','oppo','honor'];
+ const skuInputs = CHANNELS.map((k) => ``).join('');
+ const billingLabel = (bt) => bt === 'subscription' ? '订阅' : (bt === 'one_time' ? '一次性' : (bt || '—'));
+ const skuSummary = (m) => {
+ if (!m || typeof m !== 'object') return '—';
+ const keys = Object.keys(m).filter((k) => m[k]);
+ return keys.length ? keys.map((k) => k + ':' + m[k]).join(' · ') : '—';
+ };
+ $('content').innerHTML = `
+ 会员价格(火花)· 区分订阅/一次性;各渠道商品码非密钥。客户端 GET /api/client/spark/prices · 渠道密钥见「支付渠道」
+
+
+
+
+
+
+
+
+ ${skuInputs}
+
+ | ID | 名称 | 类型 | 价格 | 原价 | 天数 | 渠道码 | 启用 | |
+
+ ${list.length ? list.map((p) => `| ${esc(p.id)} | ${esc(p.name)} | ${esc(billingLabel(p.billingType))} | ${esc(p.price)} | ${esc(p.originalPrice)} | ${esc(p.durationDays)} | ${esc(skuSummary(p.channelProductCodes))} | ${esc(p.enabled)} | |
`).join('') : emptyRows(9, '暂无价格')}
+
+
`;
+ $('pfSave').onclick = async () => {
+ const channelProductCodes = {};
+ CHANNELS.forEach((k) => { const v = $('pfSku_' + k)?.value?.trim(); if (v) channelProductCodes[k] = v; });
+ await api('/prices', {
+ method: 'POST',
+ body: JSON.stringify({
+ appCode: APP,
+ name: $('pfName').value,
+ billingType: $('pfBilling').value,
+ price: $('pfPrice').value,
+ originalPrice: $('pfOrig').value,
+ durationDays: parseInt($('pfDays').value, 10) || 0,
+ channelProductCodes,
+ enabled: true,
+ }),
+ });
+ pages.prices();
+ };
+ $('content').querySelectorAll('[data-pdel]').forEach((btn) => {
+ btn.onclick = async () => {
+ if (!confirm('确认删除?')) return;
+ await api('/prices/' + btn.dataset.pdel, { method: 'DELETE' });
+ pages.prices();
+ };
+ });
+ },
+
+ async paymentChannels() {
+ const CHANNELS = ['apple','google','paypal','pingpong','wechat','alipay','huawei','xiaomi','vivo','oppo','honor'];
+ let rows = [];
+ try { rows = await api(`/payment-channels?appCode=${APP}`); } catch (_) { rows = []; }
+ if (!Array.isArray(rows)) rows = [];
+ $('content').innerHTML = `
+ 支付渠道参数(火花)· PingPong 兼容 pingpang · 密钥回显掩码 · 客户端只读 GET /api/client/spark/payment-channels
+
+
+
+
+
+
+
+
+
+
+
+ | ID | 渠道 | 启用 | 已配密钥 | 备注 | 更新 | |
+
+ ${rows.length ? rows.map((r)=>`| ${esc(r.id)} | ${esc(r.channel)} | ${esc(r.enabled)} | ${esc(r.secretsConfigured)} | ${esc(r.remark||'')} | ${esc(r.updatedAt||'')} | |
`).join('') : emptyRows(7,'暂无渠道配置')}
+
+
`;
+ $('pcSave').onclick = async () => {
+ let params = {}, secrets = {};
+ try { params = JSON.parse($('pcParams').value || '{}'); } catch (e) { return alert('params JSON 无效'); }
+ try { secrets = JSON.parse($('pcSecrets').value || '{}'); } catch (e) { return alert('secrets JSON 无效'); }
+ await api('/payment-channels', {
+ method: 'POST',
+ body: JSON.stringify({
+ appCode: APP,
+ channel: $('pcChannel').value,
+ enabled: !!$('pcEnabled')?.checked,
+ remark: $('pcRemark')?.value || '',
+ params,
+ secrets,
+ }),
+ });
+ pages.paymentChannels();
+ };
+ $('content').querySelectorAll('[data-pcdel]').forEach((btn) => {
+ btn.onclick = async () => {
+ if (!confirm('确认删除该渠道配置?')) return;
+ await api('/payment-channels/' + btn.dataset.pcdel, { method: 'DELETE' });
+ pages.paymentChannels();
+ };
+ });
+ },
+
+ async music() {
+ let data = { items: [], settings: { memberPreviewSeconds: 30 } };
+ try {
+ data = await api('/spark/music');
+ } catch (e) {
+ $('content').innerHTML = `音乐 API 不可用:${esc(e.message)}。需部署 spark-server 迁移 0019_music 并更新 nginx music 路由。
`;
+ return;
+ }
+ const items = Array.isArray(data.items) ? data.items : [];
+ const preview = data.settings?.memberPreviewSeconds ?? 30;
+ $('content').innerHTML = `
+ 火花音乐列表 · 管理 /api/admin/spark/music* · 客户端 GET /v1/music/playlist(sparkapi)· 封面/背景图/mp3 存 sparkr
+
+
总控 · 会员音乐免费试听(秒)
+
+
+
+ 0~3600;仅对 requireMember=true 的曲目生效
+
+
+
+
+
+
+
+
+
+ `;
+ $('musicPreviewSave').onclick = async () => {
+ const sec = parseInt($('musicPreviewSec').value, 10);
+ await api('/spark/music/settings', {
+ method: 'PUT',
+ body: JSON.stringify({ memberPreviewSeconds: Number.isFinite(sec) ? sec : 30 }),
+ });
+ alert('已保存试听时长');
+ pages.music();
+ };
+ $('musicCreate').onclick = async () => {
+ const title = $('musicTitle').value.trim();
+ if (!title) return alert('请填写曲名');
+ await api('/spark/music', {
+ method: 'POST',
+ body: JSON.stringify({
+ title,
+ requireMember: !!$('musicMember').checked,
+ sortOrder: parseInt($('musicSort').value, 10) || 0,
+ enabled: true,
+ }),
+ });
+ pages.music();
+ };
+ $('musicReload').onclick = () => pages.music();
+ $('content').querySelectorAll('[data-medit]').forEach((btn) => {
+ btn.onclick = async () => {
+ const id = btn.dataset.medit;
+ const row = items.find((x) => String(x.id) === String(id));
+ const title = prompt('曲名', row?.title || '');
+ if (title == null) return;
+ const member = confirm('是否需要会员?(确定=是,取消=否)');
+ const enabled = confirm('是否上架?(确定=是,取消=否)');
+ await api('/spark/music/' + id, {
+ method: 'PATCH',
+ body: JSON.stringify({ title: title.trim(), requireMember: member, enabled }),
+ });
+ pages.music();
+ };
+ });
+ $('content').querySelectorAll('[data-mdel]').forEach((btn) => {
+ btn.onclick = async () => {
+ if (!confirm('确认删除该曲目?')) return;
+ await api('/spark/music/' + btn.dataset.mdel, { method: 'DELETE' });
+ pages.music();
+ };
+ });
+ $('content').querySelectorAll('[data-mcover]').forEach((inp) => {
+ inp.onchange = async () => {
+ if (!inp.files?.[0]) return;
+ const fd = new FormData();
+ fd.append('file', inp.files[0]);
+ await apiUpload('/spark/music/' + inp.dataset.mcover + '/cover', fd);
+ pages.music();
+ };
+ });
+ $('content').querySelectorAll('[data-mbg]').forEach((inp) => {
+ inp.onchange = async () => {
+ if (!inp.files?.[0]) return;
+ const fd = new FormData();
+ fd.append('file', inp.files[0]);
+ await apiUpload('/spark/music/' + inp.dataset.mbg + '/background', fd);
+ pages.music();
+ };
+ });
+ $('content').querySelectorAll('[data-maudio]').forEach((inp) => {
+ inp.onchange = async () => {
+ if (!inp.files?.[0]) return;
+ const fd = new FormData();
+ fd.append('file', inp.files[0]);
+ await apiUpload('/spark/music/' + inp.dataset.maudio + '/audio', fd);
+ pages.music();
+ };
+ });
+ },
+
+ async novelCms() {
+ const FSN = 'https://admin.fullstorynovel.com';
+ const embedUrl = FSN + '/admin/?embed=1&from=popadmin&tab=novels&v=20260923';
+ $('content').innerHTML = `
+ FullStoryNovel 小说 CMS(列表/编辑/上下架/章节/分类)嵌入自
+
${FSN}/admin/。
+ 使用当前 popadmin JWT 经
POST /api/admin/adopt-ops-session 单点登录。
+ 若 iframe 被拦截,请部署 FSN nginx 的
frame-ancestors 放行 popadmin。
+
新窗口打开
+
+ `;
+ const frame = $('fsnNovelFrame');
+ const sendToken = () => {
+ try {
+ frame.contentWindow.postMessage(
+ { type: 'fsn-adopt-wyrs', token },
+ FSN
+ );
+ } catch (_) {}
+ };
+ frame.addEventListener('load', () => setTimeout(sendToken, 400));
+ window.addEventListener('message', function onFsnMsg(ev) {
+ if (ev.origin !== FSN) return;
+ if (ev.data?.type === 'fsn-embed-ready') sendToken();
+ });
+ },
+
+ async developerApps() {
+
+ const rows = await api(`/developer-apps?appCode=${APP}`);
+
+ const list = Array.isArray(rows) ? rows : [];
+
+ $('content').innerHTML = `
+
+ 同开发者应用(火花)· 客户端 GET /api/client/pop/developer-apps
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | ID | 名称 | 包名 | 链接 | 排序 | 启用 | |
+
+
+
+ ${
+
+ list.length
+
+ ? list
+
+ .map(
+
+ (d) =>
+
+ `| ${esc(d.id)} | ${esc(d.targetAppName || d.name)} | ${esc(d.targetPackageName || '—')} | ${esc(d.storeUrl || d.fallbackUrl || '—')} | ${esc(d.sortOrder)} | ${esc(d.enabled)} | |
`
+
+ )
+
+ .join('')
+
+ : emptyRows(7, '暂无推荐应用')
+
+ }
+
+
+
+
`;
+
+ $('daSave').onclick = async () => {
+
+ await api('/developer-apps', {
+
+ method: 'POST',
+
+ body: JSON.stringify({
+
+ sourceAppCode: APP,
+
+ targetAppName: $('daName').value,
+
+ targetPackageName: $('daPkg').value,
+
+ storeUrl: $('daUrl').value,
+
+ sortOrder: parseInt($('daSort').value, 10) || 0,
+
+ enabled: true,
+
+ }),
+
+ });
+
+ pages.developerApps();
+
+ };
+
+ $('daSync').onclick = async () => {
+
+ await api('/developer-apps/sync-website-icons', { method: 'POST', body: '{}' });
+
+ alert('已同步');
+
+ pages.developerApps();
+
+ };
+
+ $('content').querySelectorAll('[data-dadel]').forEach((btn) => {
+
+ btn.onclick = async () => {
+
+ if (!confirm('确认删除?')) return;
+
+ await api('/developer-apps/' + btn.dataset.dadel, { method: 'DELETE' });
+
+ pages.developerApps();
+
+ };
+
+ });
+
+ },
+
+
+
+ async feedbacks() {
+
+ const page = await api(`/feedbacks?appCode=${APP}&page=0&size=50`);
+
+ const { list } = pageMeta(page);
+
+ $('content').innerHTML = `
+
+ 共享 /api/admin/feedbacks?appCode=spark
+
+
+
+ | ID | 内容 | 联系方式 | 状态 | 时间 |
+
+
+
+ ${
+
+ list.length
+
+ ? list
+
+ .map(
+
+ (f) =>
+
+ `| ${esc(f.id)} | ${esc(f.content)} | ${esc(f.contact)} | ${esc(f.status)} | ${esc(f.createdAt)} |
`
+
+ )
+
+ .join('')
+
+ : '| 暂无反馈 |
'
+
+ }
+
+
+
+
`;
+
+ },
+
+
+
+ async crashLogs() {
+
+ const email = ($('crashEmail') && $('crashEmail').value) || '';
+
+ const deviceId = ($('crashDevice') && $('crashDevice').value) || '';
+
+ let q = `?appCode=${APP}&crashOnly=true&page=0&size=50`;
+
+ if (email.trim()) q += `&email=${encodeURIComponent(email.trim())}`;
+
+ if (deviceId.trim()) q += `&deviceId=${encodeURIComponent(deviceId.trim())}`;
+
+ const page = await api('/logs' + q);
+
+ const { list, total } = pageMeta(page);
+
+ $('content').innerHTML = `
+
+ 火花崩溃/错误日志 · 展示机型、邮箱、设备码 · GET /api/admin/logs?crashOnly=true
+
+
+
+
+
+
+
+
+
+
+
+ 共 ${esc(total)} 条
+
+
+
+ | ID | 级别 | 机型 | 邮箱 | 设备码 | 用户ID | 内容 | 时间 |
+
+
+
+ ${
+
+ list.length
+
+ ? list
+
+ .map(
+
+ (c) => `
+
+ | ${esc(c.id)} |
+
+ ${esc(c.level || '—')} |
+
+ ${esc(c.deviceModel || '—')} |
+
+ ${esc(c.email || '—')} |
+
+ ${esc(c.deviceId || '—')} |
+
+ ${esc(c.userId || '—')} |
+
+ ${esc(c.message || '—')} |
+
+ ${esc(c.createdAt || '—')} |
+
+
`
+
+ )
+
+ .join('')
+
+ : '| 暂无崩溃日志 |
'
+
+ }
+
+
+
+
`;
+
+ $('crashSearchBtn').onclick = () => pages.crashLogs();
+
+ },
+
+
+
+ async dau() {
+
+ const now = new Date();
+
+ const y = now.getFullYear();
+
+ const m = String(now.getMonth() + 1).padStart(2, '0');
+
+ const d = String(now.getDate()).padStart(2, '0');
+
+ $('content').innerHTML = `
+
+ 日月年用户日活 + 渠道日活(火花)。
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ const rangeOf = () => {
+
+ const period = $('dauPeriod').value;
+
+ const day = $('dauDay').value || `${y}-${m}-${d}`;
+
+ const [yy, mm] = day.split('-');
+
+ if (period === 'day') return { start: day, end: day };
+
+ if (period === 'month') {
+
+ const last = new Date(Number(yy), Number(mm), 0).getDate();
+
+ return { start: `${yy}-${mm}-01`, end: `${yy}-${mm}-${String(last).padStart(2, '0')}` };
+
+ }
+
+ return { start: `${yy}-01-01`, end: `${yy}-12-31` };
+
+ };
+
+ const load = async () => {
+
+ const { start, end } = rangeOf();
+
+ const rows = await api(`/stats/dau?appCode=${APP}&start=${start}&end=${end}`);
+
+ let channels = [];
+
+ try {
+
+ channels = await api(`/stats/dau/channels?appCode=${APP}&start=${start}&end=${end}`);
+
+ } catch (_) {}
+
+ const list = Array.isArray(rows) ? rows : [];
+
+ const totalDau = list.reduce((s, r) => s + (Number(r.activeCount ?? r.dau ?? 0) || 0), 0);
+
+ $('dauBox').innerHTML = `
+
+
+
+
区间
${esc(start)} ~ ${esc(end)}
+
+
+
+
+
+
+
+ 日活明细
+
+ | 日期 | 日活 | 新增 |
+
+ ${list.length ? list.map(r => `| ${esc(r.statDate || r.date)} | ${esc(r.activeCount ?? r.dau)} | ${esc(r.newCount ?? r.newUsers)} |
`).join('') : emptyRows(3)}
+
+ 渠道日活
+
+ | 日期 | 渠道 | 日活 |
+
+ ${asList(channels).length ? asList(channels).map(r => `| ${esc(r.statDate || r.date || '—')} | ${esc(r.channelName || r.channel)} | ${esc(r.activeCount ?? r.dau ?? r.count)} |
`).join('') : emptyRows(3)}
`;
+
+ };
+
+ $('dauLoad').onclick = load;
+
+ load();
+
+ },
+
+
+
+ async configs() {
+
+ const rows = await api('/spark/configs');
+
+ $('content').innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Key | Value | 公开 | 备注 | |
+
+
+
+ ${(rows || [])
+
+ .map(
+
+ (r) => `
+
+ | ${esc(r.configKey)} |
+
+ ${esc(r.configValue)} |
+
+ ${r.publicReadable ? '是' : '否'} |
+
+ ${esc(r.remark)} |
+
+ |
+
+
`
+
+ )
+
+ .join('')}
+
+
+
+
`;
+
+ $('cfgSave').onclick = async () => {
+
+ try {
+
+ await api('/spark/configs', {
+
+ method: 'PUT',
+
+ body: JSON.stringify({
+
+ configKey: $('cfgKey').value,
+
+ configValue: $('cfgVal').value,
+
+ remark: $('cfgRemark').value,
+
+ publicReadable: $('cfgPublic').value === 'true',
+
+ }),
+
+ });
+
+ pages.configs();
+
+ } catch (e) {
+
+ alert(e.message);
+
+ }
+
+ };
+
+ $('cfgReload').onclick = () => pages.configs();
+
+ $('content').querySelectorAll('[data-del]').forEach((btn) => {
+
+ btn.onclick = async () => {
+
+ if (!confirm('确认删除?')) return;
+
+ try {
+
+ await api('/spark/configs/' + btn.dataset.del, { method: 'DELETE' });
+
+ pages.configs();
+
+ } catch (e) {
+
+ alert(e.message);
+
+ }
+
+ };
+
+ });
+
+ },
+
+
+
+ async modules() {
+
+ const rows = await api('/spark/architecture-modules');
+
+ $('content').innerHTML = `
+
+ 架构文档领域模块(只读静态)。完整 IM 走 IM Cmd.proto。
+
+
+
+ | Service | 模块号 | 说明 |
+
+
+
+ ${(rows || [])
+
+ .map(
+
+ (r) =>
+
+ `| ${esc(r.service)} | ${esc(r.module)} | ${esc(r.desc)} |
`
+
+ )
+
+ .join('')}
+
+
+
+
`;
+
+ },
+
+
+
+ async socialDemo() {
+
+ $('pageTitle').textContent = '社交演示';
+
+ $('content').innerHTML = '加载中…
';
+
+ try {
+
+ const data = await api('/spark/social-demo');
+
+ let bot = {};
+
+ try { bot = await api('/spark/bot-feed'); } catch (_) { bot = {}; }
+
+ const friends = asList(data.friends);
+
+ const moments = asList(data.moments);
+
+ const square = asList(data.square);
+
+ const messages = asList(data.messages);
+
+ const cards = asList(data.matchCards);
+
+ const follows = asList(data.follows);
+
+ const fans = asList(data.fans);
+
+ const avatarCell = (u) =>
+
+ u && u.avatarUrl
+
+ ? `
`
+
+ : '';
+
+ const botTag = (c) => (c && (c.demoBot || c.botFeed) ? ' demo/bot' : '');
+
+ $('content').innerHTML = `
+
+ 目标邮箱 ${esc(data.targetEmail || '—')} · 种子时间 ${esc(data.seededAt || '—')} · 对外 ID 为 ≥12 位字母数字混编 · 头像来自 /popadmin/avatars/*.svg
+
+ 机器人广场日更:快手 ${esc(bot.kuaishouCount ?? 20)} + 小红书 ${esc(bot.xiaohongshuCount ?? 20)} · 样本 ${esc(bot.sampleCount ?? 0)} · 有效 bot 帖 ${esc(bot.activeBotSquare ?? 0)} · 保留 ${esc(bot.retainDays ?? 7)} 天 · 上次 ${esc(bot.lastRunDate || '—')} ${esc(bot.lastStatus || '')} · cron ${esc(bot.cron || '0 10 8 * * *')} · 清理 ${esc(bot.cleanupCron || '0 40 3 * * *')}
+
+
+
+
+
+
+
+
+
+ AI 用户池加载中…
+
+
+
+
好友
${esc(data.friendCount ?? friends.length)}
+
+
好友圈
${esc(data.momentCount ?? moments.length)}
+
+
广场
${esc(data.squareCount ?? square.length)}
+
+
私信
${esc(data.messageCount ?? messages.length)}
+
+
匹配卡片
${esc(data.matchCardCount ?? cards.length)}
+
+
关注
${esc(data.followCount ?? follows.length)}
+
+
粉丝
${esc(data.fanCount ?? fans.length)}
+
+
+
+ 匹配卡片(美女机器人)
+
+ | 头像 | publicId | 昵称 | 城市 | 年龄 | 简介 | 标签 |
+
+ ${cards.length ? cards.map(c => `| ${avatarCell(c)} | ${esc(c.publicId||c.userId)} | ${esc(c.nickname)} | ${esc(c.city)} | ${esc(c.age)} | ${esc(c.bio)} | ${esc((c.tags||[]).join?.(',') || c.tags)} |
`).join('') : emptyRows(7)}
+
+ 关注
+
+ | 头像 | publicId | 昵称 | 互关 |
+
+ ${follows.length ? follows.map(c => `| ${avatarCell(c)} | ${esc(c.publicId)} | ${esc(c.nickname)} | ${esc(c.mutual)} |
`).join('') : emptyRows(4)}
+
+ 粉丝
+
+ | 头像 | publicId | 昵称 | 互关 |
+
+ ${fans.length ? fans.map(c => `| ${avatarCell(c)} | ${esc(c.publicId)} | ${esc(c.nickname)} | ${esc(c.mutual)} |
`).join('') : emptyRows(4)}
+
+ 好友
+
+ | 头像 | publicId | 昵称 | 邮箱 |
+
+ ${friends.length ? friends.map(c => `| ${avatarCell(c)} | ${esc(c.publicId)} | ${esc(c.nickname)} | ${esc(c.email)} |
`).join('') : emptyRows(4)}
+
+ 好友圈
+
+ | 作者 | publicId | 内容 | 赞 | 时间 |
+
+ ${moments.length ? moments.map(c => `| ${avatarCell(c)}${esc(c.nickname)}${botTag(c)} | ${esc(c.publicId)} | ${esc(c.content)} | ${esc(c.likeCount)} | ${esc(c.createdAt)} |
`).join('') : emptyRows(5)}
+
+ 广场
+
+ | 作者 | 内容 | 赞 | 时间 |
+
+ ${square.length ? square.map(c => `| ${avatarCell(c)}${esc(c.nickname)}${botTag(c)} | ${esc(c.content)} | ${esc(c.likeCount)} | ${esc(c.createdAt)} |
`).join('') : emptyRows(4)}
+
+ 私信
+
+ | 方向 | 对方 | 内容 | 时间 |
+
+ ${messages.length ? messages.map(c => `| ${c.inbound ? '收' : '发'} | ${esc(c.fromNickname || c.peerPublicId)} | ${esc(c.content)} | ${esc(c.sentAt)} |
`).join('') : emptyRows(4)}
`;
+
+ const runBot = async (force) => {
+
+ try {
+
+ await api('/spark/bot-feed/run?force=' + (force ? 'true' : 'false'), { method: 'POST' });
+
+ await pages.socialDemo();
+
+ } catch (e) {
+
+ alert(e.message || '触发失败');
+
+ }
+
+ };
+
+ $('botFeedRunBtn')?.addEventListener('click', () => runBot(false));
+
+ $('botFeedForceBtn')?.addEventListener('click', () => runBot(true));
+
+ $('botFeedCleanupBtn')?.addEventListener('click', async () => {
+
+ try {
+
+ await api('/spark/bot-feed/cleanup', { method: 'POST' });
+
+ await pages.socialDemo();
+
+ } catch (e) {
+
+ alert(e.message || '清理失败');
+
+ }
+
+ });
+
+ // AI 用户池 + 欢迎系统消息
+ (async () => {
+ const panel = $('aiUsersPanel');
+ if (!panel) return;
+ try {
+ const ai = await api('/spark/ai-users');
+ const welcome = (ai && ai.welcomeMessage) || 'Welcome to the Spark family!';
+ const samples = asList(ai.sample);
+ panel.innerHTML = `
+ AI 用户池 · 文件 spark-ai-users.json · 数量 ${esc(ai.count ?? 0)} · 分配用户 ${esc(ai.assignmentCount ?? 0)}
+ · 生成于 ${esc(ai.generatedAt || '—')}
+
+
+ 欢迎系统消息(英文默认,可改):
+
+
+
+ 样本:${samples.length ? samples.map(u => `${avatarCell(u)}${esc(u.nickname)}`).join(' · ') : '—'}
+ 真人登录后幂等分配 2–5 条好友申请;欢迎语写入 IM / social-demo messages。不 wipe 其它 data JSON。
`;
+ $('aiRegenBtn')?.addEventListener('click', async () => {
+ try {
+ await api('/spark/ai-users/regenerate?count=1000', { method: 'POST' });
+ await pages.socialDemo();
+ } catch (e) {
+ alert(e.message || '生成失败');
+ }
+ });
+ $('aiWelcomeSave')?.addEventListener('click', async () => {
+ try {
+ const v = $('aiWelcomeInput')?.value || '';
+ await api('/spark/ai-users/welcome-message', {
+ method: 'PUT',
+ body: JSON.stringify({ welcomeMessage: v }),
+ });
+ alert('已保存');
+ } catch (e) {
+ alert(e.message || '保存失败');
+ }
+ });
+ } catch (e) {
+ panel.innerHTML = `AI 用户池:${esc(e.message || '加载失败')}`;
+ }
+ })();
+
+ } catch (e) {
+
+ $('content').innerHTML = `${esc(e.message || '加载失败')}
`;
+
+ }
+
+ },
+
+
+
+ async testAccounts() {
+
+ $('pageTitle').textContent = '联调测试账号';
+
+ $('content').innerHTML = '加载中…
';
+
+ try {
+
+ const data = await api('/spark/test-accounts?revealPassword=true');
+
+ const rows = asList(data.accounts);
+
+ const hint = data.deviceHint || {};
+
+ $('content').innerHTML = `
+
+ Alpha 客户端 GET /api/client/spark/debug/test-accounts · 管理端 /api/admin/spark/test-accounts(pop 别名等价)。明文仅存 spark-test-accounts.json,部署不 wipe。
+
+ 建议双机:A=${esc(hint.deviceA || 'spark.test.01@wyrs.test.local')} · B=${esc(hint.deviceB || 'spark.test.02@wyrs.test.local')} · 共 ${esc(data.count ?? rows.length)} 个
+
+
+
+
+
+
+
+ | # | 邮箱 | 密码 | 昵称 | publicId | appUserId |
+
+ ${rows.length ? rows.map((r, i) => `
+
+ | ${esc(r.index ?? i + 1)} |
+
+ ${esc(r.email)} |
+
+ ${esc(r.password)} |
+
+ ${esc(r.displayName || r.nickname)} |
+
+ ${esc(r.publicId)} |
+
+ ${esc(r.appUserId || r.userId || '—')} |
+
+
`).join('') : emptyRows(6, '尚未灌入,请点「确保已灌入」')}
`;
+
+ const seed = async (force) => {
+
+ try {
+
+ await api('/spark/test-accounts/seed', {
+
+ method: 'POST',
+
+ body: JSON.stringify({ force: !!force }),
+
+ });
+
+ await pages.testAccounts();
+
+ } catch (e) {
+
+ alert(e.message || '操作失败');
+
+ }
+
+ };
+
+ $('taSeedBtn')?.addEventListener('click', () => seed(false));
+
+ $('taForceBtn')?.addEventListener('click', () => {
+
+ if (confirm('将重写测试账号密码并写回 spark-test-accounts.json,确认?')) seed(true);
+
+ });
+
+ } catch (e) {
+
+ $('content').innerHTML = `${esc(e.message || '加载失败')}
`;
+
+ }
+
+ },
+
+
+
+ async ipLookup() {
+
+ $('content').innerHTML = `
+
+ 按公网 IP 查火花用户与设备,并给出总数。
+
+
+
+
+
+
+
+
+
+ `;
+
+ const run = async () => {
+
+ const ip = ($('ipQ').value || '').trim();
+
+ if (!ip) return alert('请输入 IP');
+
+ $('ipResult').innerHTML = '查询中…
';
+
+ try {
+
+ const data = await api(`/spark/users/by-ip?ip=${encodeURIComponent(ip)}&appCode=${APP}`);
+
+ const users = asList(data.users);
+
+ const devices = asList(data.devices);
+
+ $('ipResult').innerHTML = `
+
+
+
+
用户数
${esc(data.userCount)}
+
+
设备数
${esc(data.deviceCount)}
+
+
+
+ 用户列表
+
+ | ID | 邮箱 | 昵称 | 设备 | 最近活跃 |
+
+ ${users.length ? users.map(u => `| ${esc(u.id||u.userId)} | ${esc(u.email)} | ${esc(u.nickname)} | ${esc(u.deviceId)} | ${esc(u.lastActiveAt)} |
`).join('') : emptyRows(5)}
+
+ 设备列表
+
+ | 设备码 | 邮箱 | 昵称 | 注册IP | 活跃IP |
+
+ ${devices.length ? devices.map(d => `| ${esc(d.deviceId)} | ${esc(d.email)} | ${esc(d.nickname)} | ${esc(d.registerIp)} | ${esc(d.lastActiveIp)} |
`).join('') : emptyRows(5)}
`;
+
+ } catch (e) {
+
+ $('ipResult').innerHTML = `${esc(e.message)}
`;
+
+ }
+
+ };
+
+ $('ipSearchBtn').onclick = run;
+
+ $('ipQ').onkeydown = (e) => e.key === 'Enter' && run();
+
+ },
+
+
+
+ async groups() {
+
+ const q = '';
+
+ const rows = await api('/spark/groups' + (q ? '?q=' + encodeURIComponent(q) : ''));
+
+ $('content').innerHTML = `
+
+ 活跃群列表(可按群 ID / 群名检索)。成员与聊天记录导出依赖 IM 上报;可在此维护群元数据。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 群ID | 群名 | 成员数 | 活跃 | 更新 | |
+
+
+
+ ${(rows || []).length ? rows.map(g => `
+
+ ${esc(g.groupId)} | ${esc(g.name)} | ${esc(g.memberCount)} |
+
+ ${g.active ? '是' : '否'} | ${esc(g.updatedAt)} |
+
+
+
+
+
+
+
+ |
+
+
`).join('') : emptyRows(6, '暂无活跃群(可登记或等待 IM 同步)')}
+
+
+
+
`;
+
+ const reload = async (qq) => {
+
+ const list = await api('/spark/groups' + (qq ? '?q=' + encodeURIComponent(qq) : ''));
+
+ $('groupBody').innerHTML = (list || []).length
+
+ ? list.map(g => `
+
+ ${esc(g.groupId)} | ${esc(g.name)} | ${esc(g.memberCount)} |
+
+ ${g.active ? '是' : '否'} | ${esc(g.updatedAt)} |
+
+
+
+
+
+
+
+ |
+
+
`).join('')
+
+ : emptyRows(6, '暂无匹配群');
+
+ bindGroupBtns();
+
+ };
+
+ const bindGroupBtns = () => {
+
+ $('content').querySelectorAll('[data-g]').forEach((btn) => {
+
+ btn.onclick = async () => {
+
+ const g = await api('/spark/groups/' + encodeURIComponent(btn.dataset.g));
+
+ const members = asList(g.members);
+
+ $('groupDetail').innerHTML = `
+
+
+
+
${esc(g.name)} ${esc(g.groupId)} · 成员 ${members.length}
+
+
| userId | 邮箱 | 昵称 | 角色 |
+
+ ${members.length ? members.map(m => `| ${esc(m.userId)} | ${esc(m.email)} | ${esc(m.nickname)} | ${esc(m.role)} |
`).join('') : emptyRows(4, '暂无成员(待 IM 上报)')}
+
+
`;
+
+ };
+
+ });
+
+ $('content').querySelectorAll('[data-gz]').forEach((btn) => {
+
+ btn.onclick = () =>
+
+ downloadZip(
+
+ `/spark/groups/${encodeURIComponent(btn.dataset.gz)}/export-zip`,
+
+ `group-${btn.dataset.gz}.zip`
+
+ ).catch((e) => alert(e.message));
+
+ });
+
+ };
+
+ bindGroupBtns();
+
+ $('groupSearch').onclick = () => reload(($('groupQ').value || '').trim());
+
+ $('groupAdd').onclick = async () => {
+
+ const groupId = prompt('群 ID(可空自动生成)') || '';
+
+ const name = prompt('群名');
+
+ if (name == null) return;
+
+ await api('/spark/groups', { method: 'POST', body: JSON.stringify({ groupId, name, active: true }) });
+
+ reload(($('groupQ').value || '').trim());
+
+ };
+
+ },
+
+
+
+ async imMessages() {
+
+ const stats = await api('/spark/im/stats').catch(() => ({}));
+
+ $('content').innerHTML = `
+
+ MQTT IM 消息(spark-im-messages.json)· 含文本/名片/撤回标记 · 接口 GET /api/admin/spark/im/messages
+
+
+
+
消息数
${esc(stats.messageCount ?? '-')}
+
+
会话数
${esc(stats.conversationCount ?? '-')}
+
+
群数
${esc(stats.groupCount ?? '-')}
+
+
已撤回
${esc(stats.revokedCount ?? '-')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 时间 | 类型 | from | to/群 | 内容 | 撤回 |
+
+ | 加载中… |
+
+
`;
+
+ const load = async (q) => {
+
+ try {
+
+ const data = await api('/spark/im/messages?limit=100' + (q ? '&q=' + encodeURIComponent(q) : ''));
+
+ const rows = asList(data.messages || data);
+
+ $('imBody').innerHTML = rows.length
+
+ ? rows.map(m => `
+
+ | ${esc(m.ts)} | ${esc(m.type || 'text')} |
+
+ ${esc(m.from)} | ${esc(m.groupId || m.to)} |
+
+ ${esc(m.text || (m.cardUsername ? '[名片] ' + m.cardUsername : ''))} |
+
+ ${m.revoked ? '是' : ''} |
+
+
`).join('')
+
+ : emptyRows(6, '暂无 IM 消息(客户端发消息后落库)');
+
+ } catch (e) {
+
+ $('imBody').innerHTML = emptyRows(6, esc(e.message || '加载失败'));
+
+ }
+
+ };
+
+ await load('');
+
+ $('imSearch').onclick = () => load(($('imQ').value || '').trim());
+
+ },
+
+
+
+ async complaints() {
+
+ const rows = await api('/spark/complaints');
+
+ $('content').innerHTML = `
+
+ 投诉列表(客户端上报或运营录入)。
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 时间 | 邮箱 | 对象 | 原因 | 详情 | 状态 |
+
+ ${(rows||[]).length ? rows.map(r => `| ${esc(r.createdAt)} | ${esc(r.email)} | ${esc(r.target)} | ${esc(r.reason)} | ${esc(r.detail)} | ${esc(r.status)} |
`).join('') : emptyRows(6, '暂无投诉')}
`;
+
+ $('cAdd').onclick = async () => {
+
+ await api('/spark/complaints', {
+
+ method: 'POST',
+
+ body: JSON.stringify({
+
+ email: $('cEmail').value,
+
+ target: $('cTarget').value,
+
+ reason: $('cReason').value,
+
+ }),
+
+ });
+
+ pages.complaints();
+
+ };
+
+ },
+
+
+
+ async words() {
+
+ $('content').innerHTML = `
+
+ 敏感词与监控词列表,运营可增删;客户端命中后可上报到用户详情事件流。
+
+ ${wordListUi('sensitive', '敏感词')}
+
+ ${wordListUi('monitor', '监控词')}`;
+
+ const load = async (kind) => {
+
+ const rows = await api('/spark/words/' + kind);
+
+ $(kind + 'Table').innerHTML = `| 词 | 备注 | 时间 | |
+
+ ${(rows||[]).length ? rows.map(r => `| ${esc(r.word)} | ${esc(r.note)} | ${esc(r.createdAt)} |
+
+ |
`).join('') : emptyRows(4)}
`;
+
+ $('content').querySelectorAll(`[data-del-word^="${kind}:"]`).forEach((btn) => {
+
+ btn.onclick = async () => {
+
+ const [k, id] = btn.dataset.delWord.split(':');
+
+ await api(`/spark/words/${k}/${id}`, { method: 'DELETE' });
+
+ load(k);
+
+ };
+
+ });
+
+ };
+
+ await load('sensitive');
+
+ await load('monitor');
+
+ $('content').querySelectorAll('[data-add-word]').forEach((btn) => {
+
+ btn.onclick = async () => {
+
+ const kind = btn.dataset.addWord;
+
+ await api('/spark/words/' + kind, {
+
+ method: 'POST',
+
+ body: JSON.stringify({ word: $(kind + 'Word').value, note: $(kind + 'Note').value }),
+
+ });
+
+ $(kind + 'Word').value = '';
+
+ load(kind);
+
+ };
+
+ });
+
+ },
+
+
+
+ async sameIpRank() {
+
+ const data = await api('/spark/rankings/same-ip?appCode=' + APP + '&limit=50');
+
+ const items = asList(data.items);
+
+ $('content').innerHTML = `
+
+ 当日(${esc(data.date)} ${esc(data.timezone)})同 IP 活跃用户数排名。
+
+ | # | IP | 用户数 | 设备数 | |
+
+ ${items.length ? items.map((r, i) => `
+
+ | ${i + 1} | ${esc(r.ip)} | ${esc(r.userCount)} | ${esc(r.deviceCount)} |
+
+ |
+
+
`).join('') : emptyRows(5, '今日暂无同 IP 活跃数据')}
`;
+
+ $('content').querySelectorAll('[data-ip]').forEach((btn) => {
+
+ btn.onclick = () => {
+
+ renderNav('ipLookup');
+
+ $('pageTitle').textContent = 'IP 查询';
+
+ pages.ipLookup().then(() => {
+
+ $('ipQ').value = btn.dataset.ip;
+
+ $('ipSearchBtn').click();
+
+ });
+
+ };
+
+ });
+
+ },
+
+
+
+ async ipBlacklist() {
+
+ await pages._simpleList('ip', 'IP 黑名单', 'IP 地址');
+
+ },
+
+
+
+ async domainWhitelist() {
+
+ await pages._simpleList('domain', '域名白名单', '域名');
+
+ },
+
+
+
+ async _simpleList(kind, title, ph) {
+
+ const rows = await api('/spark/lists/' + kind);
+
+ $('content').innerHTML = `
+
+ ${esc(title)}
+
+
+
+
+
+
+
+
+
+
+
+ | 值 | 备注 | 时间 | |
+
+ ${(rows||[]).length ? rows.map(r => `| ${esc(r.value)} | ${esc(r.note)} | ${esc(r.createdAt)} |
+
+ |
`).join('') : emptyRows(4)}
`;
+
+ $('listAdd').onclick = async () => {
+
+ await api('/spark/lists/' + kind, {
+
+ method: 'POST',
+
+ body: JSON.stringify({ value: $('listVal').value, note: $('listNote').value }),
+
+ });
+
+ pages._simpleList(kind, title, ph);
+
+ };
+
+ $('content').querySelectorAll('[data-del]').forEach((btn) => {
+
+ btn.onclick = async () => {
+
+ await api(`/spark/lists/${kind}/${btn.dataset.del}`, { method: 'DELETE' });
+
+ pages._simpleList(kind, title, ph);
+
+ };
+
+ });
+
+ },
+
+
+
+ async accountWhitelist() {
+
+ const rows = await api('/whitelist?appCode=' + APP);
+
+ let geoNote = '';
+
+ let geoRestrictionEnabled = false;
+
+ try {
+
+ const geo = await api('/spark/geo-access/roots');
+
+ const roots = (geo && geo.items) ? geo.items : [];
+
+ geoRestrictionEnabled = !!(geo && geo.geoRestrictionEnabled);
+
+ geoNote = `大陆 IP 登录/注册放行根邮箱(种子 + 本表白名单 EMAIL):${
+
+ roots.length ? roots.map(r => `${esc(r.email)}${r.inviteCode ? ' · 邀请码 ' + esc(r.inviteCode) + '' : ''}`).join(';') : '(无额外)'
+
+ }。好友与邀请链:/api/admin/spark/geo-access。港/澳/台不拦截。
`;
+
+ } catch (e) {
+
+ try {
+
+ const st = await api('/spark/geo-access/restriction');
+
+ geoRestrictionEnabled = !!(st && st.geoRestrictionEnabled);
+
+ } catch (_) {}
+
+ geoNote = `大陆 IP 拦截:白名单 EMAIL + 其好友 + 邀请码邀请链可放行。管理 API:/api/admin/spark/geo-access
`;
+
+ }
+
+ const statusText = geoRestrictionEnabled
+
+ ? '当前:限制开启(大陆 IP 按白名单/好友/邀请链拦截)'
+
+ : '当前:限制关闭(登录/注册全球放行)';
+
+ $('content').innerHTML = `
+
+
+
+
启用 IP/国家登录注册限制
+
+
关闭 = 暂时放开大陆等地区拦截;开启 = 恢复原 geo 规则(白名单 / 好友 / 邀请链例外)。配置键 geoRestrictionEnabled,写入 spark-geo-access.json。
+
+
+
+
${statusText}
+
+
+
+ 账号白名单(EMAIL / PUBLIC_ID),与主运营后台共用 /api/admin/whitelist。EMAIL 同时作为大陆登录/注册放行根(仅在上方限制开启时生效)。
+
+ ${geoNote}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 类型 | 值 | 启用 | 备注 | |
+
+ ${(rows||[]).length ? rows.map(r => `
+
+ | ${esc(r.type)} | ${esc(r.value)} | ${r.enabled ? '是' : '否'} | ${esc(r.note)} |
+
+ |
+
+
`).join('') : emptyRows(5)}
`;
+
+ const geoToggle = $('geoRestrictToggle');
+
+ if (geoToggle) {
+
+ geoToggle.onchange = async () => {
+
+ const enabled = !!geoToggle.checked;
+
+ try {
+
+ const res = await api('/spark/geo-access/restriction', {
+
+ method: 'PUT',
+
+ body: JSON.stringify({ enabled }),
+
+ });
+
+ const on = !!(res && res.geoRestrictionEnabled);
+
+ geoToggle.checked = on;
+
+ $('geoRestrictLabel').textContent = on ? '已开启限制' : '限制已关闭(放行)';
+
+ const statusEl = $('geoRestrictStatus');
+
+ if (statusEl) {
+
+ statusEl.textContent = on
+
+ ? '当前:限制开启(大陆 IP 按白名单/好友/邀请链拦截)'
+
+ : '当前:限制关闭(登录/注册全球放行)';
+
+ }
+
+ } catch (e) {
+
+ geoToggle.checked = !enabled;
+
+ alert(e.message || '更新限制开关失败');
+
+ }
+
+ };
+
+ }
+
+$('wlAdd').onclick = async () => {
+
+ await api('/whitelist', {
+
+ method: 'POST',
+
+ body: JSON.stringify({
+
+ appCode: APP,
+
+ type: $('wlType').value,
+
+ value: $('wlVal').value,
+
+ note: $('wlNote').value,
+
+ enabled: true,
+
+ }),
+
+ });
+
+ pages.accountWhitelist();
+
+ };
+
+ $('content').querySelectorAll('[data-wldel]').forEach((btn) => {
+
+ btn.onclick = async () => {
+
+ await api('/whitelist/' + btn.dataset.wldel, { method: 'DELETE' });
+
+ pages.accountWhitelist();
+
+ };
+
+ });
+
+ },
+
+ async rankings() {
+
+ const types = [
+
+ ['messages', '日消息量'],
+
+ ['images', '日发图片数'],
+
+ ['domains', '日发域名'],
+
+ ['postLikes', '日点赞帖子'],
+
+ ];
+
+ $('content').innerHTML = `
+
+ 日排行依赖客户端/IM 统计上报;未上报时为空。
+
+ ${types.map(([k, l]) => ``).join('')}
+
+ `;
+
+ $('content').querySelectorAll('[data-rk]').forEach((btn) => {
+
+ btn.onclick = async () => {
+
+ const data = await api('/spark/rankings/' + btn.dataset.rk);
+
+ const items = asList(data.items);
+
+ $('rankBox').innerHTML = `
+
+ ${esc(data.note || '')}
+
+ | # | 邮箱 | 昵称 | 数值 | 说明 |
+
+ ${items.length ? items.map((r, i) => `| ${i+1} | ${esc(r.email)} | ${esc(r.nickname)} | ${esc(r.value)} | ${esc(r.label)} |
`).join('') : emptyRows(5, '暂无排行数据')}
`;
+
+ };
+
+ });
+
+ },
+
+
+
+ async orders() {
+
+ const now = new Date();
+
+ const y = now.getFullYear();
+
+ const m = String(now.getMonth() + 1).padStart(2, '0');
+
+ const d = String(now.getDate()).padStart(2, '0');
+
+ $('content').innerHTML = `
+
+ 用户充值订单(spark)。可选日 / 月 / 年范围。
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ const load = async () => {
+
+ const period = $('ordPeriod').value;
+
+ const day = $('ordDay').value || `${y}-${m}-${d}`;
+
+ const [yy, mm] = day.split('-');
+
+ let start;
+
+ let end;
+
+ if (period === 'day') {
+
+ start = day;
+
+ end = day;
+
+ } else if (period === 'month') {
+
+ start = `${yy}-${mm}-01`;
+
+ const last = new Date(Number(yy), Number(mm), 0).getDate();
+
+ end = `${yy}-${mm}-${String(last).padStart(2, '0')}`;
+
+ } else {
+
+ start = `${yy}-01-01`;
+
+ end = `${yy}-12-31`;
+
+ }
+
+ const page = await api(
+
+ `/payments?appCode=${APP}&start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}&page=0&size=100`
+
+ );
+
+ let sum = {};
+
+ try {
+
+ sum = await api(
+
+ `/payments/summary?appCode=${APP}&start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`
+
+ );
+
+ } catch (_) {}
+
+ const list = page.content || page || [];
+
+ $('ordSum').innerHTML = `
+
+ 订单数
${esc(sum.count ?? list.length ?? 0)}
+
+ 金额合计
${esc(sum.amount ?? sum.totalAmount ?? '—')}
`;
+
+ $('ordTable').innerHTML = `| 时间 | 用户 | 金额 | 渠道 | 状态 | 订单号 |
+
+ ${asList(list).length ? asList(list).map(r => `
+
+ | ${esc(r.createdAt || r.paidAt)} | ${esc(r.email || r.userId)} | ${esc(r.amount)} |
+
+ ${esc(r.channel || r.payChannel)} | ${esc(r.status)} | ${esc(r.orderId || r.tradeNo)} |
+
+
`).join('') : emptyRows(6)}
`;
+
+ };
+
+ $('ordLoad').onclick = load;
+
+ load();
+
+ },
+
+
+
+ async pushes() {
+
+ const rows = await api('/spark/pushes');
+
+ $('content').innerHTML = `
+
+ 系统消息推送列表(草稿/计划,实际下发需对接推送通道)。
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 时间 | 标题 | 内容 | 受众 | 状态 | |
+
+ ${(rows||[]).length ? rows.map(r => `
+
+ | ${esc(r.createdAt)} | ${esc(r.title)} | ${esc(r.content)} | ${esc(r.audience)} | ${esc(r.status)} |
+
+ |
+
+
`).join('') : emptyRows(6)}
`;
+
+ $('pAdd').onclick = async () => {
+
+ await api('/spark/pushes', {
+
+ method: 'POST',
+
+ body: JSON.stringify({
+
+ title: $('pTitle').value,
+
+ content: $('pContent').value,
+
+ audience: $('pAud').value,
+
+ }),
+
+ });
+
+ pages.pushes();
+
+ };
+
+ $('content').querySelectorAll('[data-pdel]').forEach((btn) => {
+
+ btn.onclick = async () => {
+
+ await api('/spark/pushes/' + btn.dataset.pdel, { method: 'DELETE' });
+
+ pages.pushes();
+
+ };
+
+ });
+
+ },
+
+
+
+ async retention() {
+
+ let summary = {};
+
+ let channel = [];
+
+ try {
+
+ summary = await api('/retention/summary?appCode=' + APP + '&days=30');
+
+ } catch (_) {}
+
+ try {
+
+ channel = await api('/stats/retention?appCode=' + APP + '&cohortDays=7');
+
+ } catch (_) {}
+
+ $('content').innerHTML = `
+
+ 日月年用户留存(火花)。摘要来自 /retention/summary,渠道留存来自 /stats/retention。
+
+
+
+
窗口天数
${esc(summary.days ?? 30)}
+
+
新增
${esc(summary.newUsers ?? summary.cohortSize ?? '—')}
+
+
次日留存
${esc(summary.retain1Rate ?? summary.d1 ?? '—')}
+
+
7日留存
${esc(summary.retain7Rate ?? summary.d7 ?? '—')}
+
+
30日留存
${esc(summary.retain30Rate ?? summary.d30 ?? '—')}
+
+
+
+ 渠道留存
+
+ | 渠道 | cohort | D1 | D7 | D30 |
+
+ ${asList(channel).length ? asList(channel).map(r => `
+
+ | ${esc(r.channelName || r.channel)} | ${esc(r.cohort || r.cohortSize)} |
+
+ ${esc(r.retain1Rate ?? r.d1)} | ${esc(r.retain7Rate ?? r.d7)} | ${esc(r.retain30Rate ?? r.d30)} |
+
+
`).join('') : emptyRows(5)}
`;
+
+ },
+
+
+
+ async password() {
+
+ $('content').innerHTML = `
+
+ 运营管理账号改密(与 App 用户邮箱账号相互独立)。对齐主站「账号安全」。
+
+
+
+ 子管理员密码重置请到「权限分配」;App 用户密码请到「App用户密码」或用户详情。
`;
+
+ $('pwdSave').onclick = async () => {
+
+ try {
+
+ await api('/auth/change-password', {
+
+ method: 'POST',
+
+ body: JSON.stringify({
+
+ oldPassword: $('oldPass').value,
+
+ newPassword: $('newPass').value,
+
+ }),
+
+ });
+
+ $('pwdMsg').style.color = 'var(--ok, #34d399)';
+
+ $('pwdMsg').textContent = '密码修改成功';
+
+ $('oldPass').value = '';
+
+ $('newPass').value = '';
+
+ } catch (e) {
+
+ $('pwdMsg').style.color = '';
+
+ $('pwdMsg').textContent = e.message || '修改失败';
+
+ }
+
+ };
+
+ },
+
+
+
+ async userPasswords() {
+
+ $('content').innerHTML = `
+
+ 重置 App 互通邮箱密码(全局一份密码,影响所有接入 App)。明文仅本次提交,不写入仓库与接口响应。
+
+
+
+ `;
+
+ $('upSave').onclick = async () => {
+
+ const email = ($('upEmail').value || '').trim();
+
+ const p1 = $('upPass').value || '';
+
+ const p2 = $('upPass2').value || '';
+
+ if (!email || !email.includes('@')) {
+
+ $('upMsg').textContent = '请输入有效邮箱';
+
+ return;
+
+ }
+
+ if (p1.length < 6) {
+
+ $('upMsg').textContent = '新密码至少 6 位';
+
+ return;
+
+ }
+
+ if (p1 !== p2) {
+
+ $('upMsg').textContent = '两次密码不一致';
+
+ return;
+
+ }
+
+ if (!confirm('确认重置 ' + email + ' 的 App 密码?')) return;
+
+ try {
+
+ await api('/users/by-email/reset-password', {
+
+ method: 'POST',
+
+ body: JSON.stringify({ email, appCode: APP, newPassword: p1 }),
+
+ });
+
+ $('upMsg').style.color = 'var(--ok, #34d399)';
+
+ $('upMsg').textContent = '已重置(请线下告知用户新密码)';
+
+ $('upPass').value = '';
+
+ $('upPass2').value = '';
+
+ } catch (e) {
+
+ $('upMsg').style.color = '';
+
+ $('upMsg').textContent = e.message || '重置失败';
+
+ }
+
+ };
+
+ $('upGotoUser').onclick = () => {
+
+ const email = ($('upEmail').value || '').trim();
+
+ if (!email) {
+
+ alert('请先填写邮箱');
+
+ return;
+
+ }
+
+ renderNav('users');
+
+ $('pageTitle').textContent = '用户';
+
+ pages.users(email);
+
+ };
+
+ },
+
+
+
+ async mfa() {
+
+ let status = { enabled: false };
+
+ try {
+
+ status = await api('/auth/mfa/status');
+
+ } catch (e) {
+
+ $('content').innerHTML = `${esc(e.message)}
`;
+
+ return;
+
+ }
+
+ $('content').innerHTML = `
+
+ 虚拟 MFA(TOTP)。用 Google / 微软 Authenticator 扫码绑定。状态:${status.enabled ? '已启用' : '未启用'}。
+
+
+
+
+
+ ${status.enabled ? '' : ''}
+
+
+
+ `;
+
+ $('mfaSetupBtn').onclick = async () => {
+
+ try {
+
+ const data = await api('/auth/mfa/setup', { method: 'POST', body: '{}' });
+
+ $('mfaBox').innerHTML = `
+
+ 密钥:${esc(data.secret)}
+
+
+
+
+
+
+
+
+
+
`;
+
+ $('mfaConfirmBtn').onclick = async () => {
+
+ await api('/auth/mfa/confirm', {
+
+ method: 'POST',
+
+ body: JSON.stringify({ code: ($('mfaCode').value || '').trim() }),
+
+ });
+
+ alert('MFA 已启用');
+
+ pages.mfa();
+
+ };
+
+ } catch (e) {
+
+ alert(e.message);
+
+ }
+
+ };
+
+ const dis = $('mfaDisableBtn');
+
+ if (dis) {
+
+ dis.onclick = async () => {
+
+ const code = prompt('输入当前 MFA 验证码以关闭');
+
+ if (code == null) return;
+
+ await api('/auth/mfa/disable', { method: 'POST', body: JSON.stringify({ code }) });
+
+ pages.mfa();
+
+ };
+
+ }
+
+ },
+
+
+
+ async staff() {
+
+ let rows = [];
+
+ let catalog = { popadminMenu: [] };
+
+ try {
+
+ rows = await api('/auth/staff');
+
+ const st = await api('/auth/mfa/status');
+
+ catalog = st.permissionCatalog || catalog;
+
+ } catch (e) {
+
+ $('content').innerHTML = `${esc(e.message || '仅超级管理员可见')}
`;
+
+ return;
+
+ }
+
+ const menuOpts = catalog.popadminMenu || catalog.adminMenu || [];
+
+ const labels = catalog.labels || {};
+
+ window.__permLabels = labels;
+
+ const permLabel = (code) => {
+
+ if (!code) return '';
+
+ if (code === '*') return '全部权限';
+
+ if (labels[code]) return labels[code];
+
+ const lower = String(code).toLowerCase();
+
+ for (const k of Object.keys(labels)) {
+
+ if (k.toLowerCase() === lower) return labels[k];
+
+ }
+
+ return code;
+
+ };
+
+ const formatPerms = (perms) => {
+
+ if (!perms || !perms.length) return '—';
+
+ if (perms.includes('*')) return '全部权限 (*)';
+
+ return perms.map((p) => `${permLabel(p)}(${p})`).join('、');
+
+ };
+
+ $('content').innerHTML = `
+
+ 超级管理员:运营账号权限分配(与主后台共用 admin_user)。勾选项:中文说明(权限码)。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${menuOpts.map(p => ``).join('')}
+
+ | 用户名 | 邮箱 | 角色 | 权限 | MFA | |
+
+ ${asList(rows).length ? asList(rows).map(r => `
+
+ | ${esc(r.username)} | ${esc(r.email)} | ${esc(r.role)} |
+
+ ${esc(formatPerms(r.permissions || []))} |
+
+ ${r.mfaEnabled ? '是' : '否'} |
+
+
+
+
+
+
+
+
+
+ |
+
+
`).join('') : emptyRows(6)}
`;
+
+ $('stCreate').onclick = async () => {
+
+ const permissions = [...document.querySelectorAll('#stPerms input:checked')].map((c) => c.value);
+
+ await api('/auth/staff', {
+
+ method: 'POST',
+
+ body: JSON.stringify({
+
+ username: $('stUser').value,
+
+ email: $('stEmail').value,
+
+ password: $('stPass').value,
+
+ role: $('stRole').value,
+
+ permissions,
+
+ }),
+
+ });
+
+ pages.staff();
+
+ };
+
+ $('content').querySelectorAll('[data-stperm]').forEach((btn) => {
+
+ btn.onclick = async () => {
+
+ const permissions = [...document.querySelectorAll('#stPerms input:checked')].map((c) => c.value);
+
+ const role = prompt('角色 ADMIN / SUPER_ADMIN', 'ADMIN');
+
+ if (role == null) return;
+
+ await api('/auth/staff/' + btn.dataset.stperm + '/permissions', {
+
+ method: 'PUT',
+
+ body: JSON.stringify({ role, permissions }),
+
+ });
+
+ pages.staff();
+
+ };
+
+ });
+
+ $('content').querySelectorAll('[data-stmfa]').forEach((btn) => {
+
+ btn.onclick = async () => {
+
+ if (!confirm('确认重置该账号 MFA?')) return;
+
+ await api('/auth/staff/' + btn.dataset.stmfa + '/reset-mfa', { method: 'POST', body: '{}' });
+
+ alert('已重置');
+
+ pages.staff();
+
+ };
+
+ });
+
+ $('content').querySelectorAll('[data-stpass]').forEach((btn) => {
+
+ btn.onclick = async () => {
+
+ const un = btn.dataset.stun || '';
+
+ const np = prompt('为运营账号 ' + un + ' 设置新密码(至少 6 位)', '');
+
+ if (np === null) return;
+
+ if (String(np).length < 6) {
+
+ alert('新密码至少 6 位');
+
+ return;
+
+ }
+
+ if (!confirm('确认重置子管理员密码?')) return;
+
+ await api('/auth/staff/' + btn.dataset.stpass + '/reset-password', {
+
+ method: 'POST',
+
+ body: JSON.stringify({ newPassword: String(np) }),
+
+ });
+
+ alert('已重置(请线下告知对方)');
+
+ pages.staff();
+
+ };
+
+ });
+
+ },
+
+
+
+ async apiDoc() {
+
+ $('content').innerHTML = '加载接口文档…
';
+
+ try {
+
+ const doc = await api('/spark/api-doc');
+
+ const row = (a) =>
+
+ `| ${esc(a.method)} | ${esc(a.path)} | ${esc(a.auth || '-')} | ${esc(a.desc || '')} |
`;
+
+ const tableBlock = (title, list) => `
+
+
+
+
${esc(title)}
+
+
+
+ | 方法 | 路径 | 鉴权 | 说明 |
+
+ ${(list || []).map(row).join('') || emptyRows(4, '暂无')}
+
+
+
+
`;
+
+ const notes = (doc.notes || []).map((n) => `${esc(n)}`).join('');
+
+ $('content').innerHTML = `
+
+
+
+ Base URL:${esc(doc.baseUrl || 'https://popadmin.wyrs365.com')}
+
+ · 数据源 GET /api/admin/spark/api-doc
+
+ · 路径别名 /api/admin/pop/…、/api/client/pop/…
+
+
+
+
+
+ ${tableBlock('管理端 API(popadmin 常用)', doc.adminApis)}
+
+ ${tableBlock('火花客户端 API(/api/client/spark)', doc.clientApis)}
+
+ ${tableBlock('邮箱等共享客户端 API', doc.sharedClientApis)}
+
+ ${tableBlock('公开 API', doc.publicApis)}`;
+
+ } catch (e) {
+
+ $('content').innerHTML = `${esc(e.message)}
`;
+
+ }
+
+ },
+
+};
+
+
+
+$('loginBtn').addEventListener('click', async () => {
+
+ $('loginError').textContent = '';
+
+ try {
+
+ const identity = ($('loginEmail').value || '').trim();
+
+ const password = $('loginPass').value || '';
+
+ if (!identity || !password) throw new Error('请输入账号和密码');
+
+ const data = await api('/auth/login', {
+
+ method: 'POST',
+
+ body: JSON.stringify(
+
+ identity.includes('@')
+
+ ? { email: identity, password }
+
+ : { username: identity, password }
+
+ ),
+
+ });
+
+ if (data.mfaRequired && data.mfaToken) {
+
+ pendingMfaToken = data.mfaToken;
+
+ $('loginStepPrimary')?.classList.add('hidden');
+
+ $('loginStepMfa')?.classList.remove('hidden');
+
+ if ($('loginMfaCode')) $('loginMfaCode').value = '';
+
+ return;
+
+ }
+
+ finishPopLogin(data, identity);
+
+ } catch (e) {
+
+ $('loginError').textContent = e.message;
+
+ }
+
+});
+
+
+
+let pendingMfaToken = null;
+
+
+
+function finishPopLogin(data, fallbackName) {
+
+ token = data.token;
+
+ username = data.email || data.username || fallbackName || '';
+
+ localStorage.setItem(TOKEN_KEY, token);
+
+ localStorage.setItem('wyrs_admin_name', username);
+
+ pendingMfaToken = null;
+
+ $('loginStepMfa')?.classList.add('hidden');
+
+ $('loginStepPrimary')?.classList.remove('hidden');
+
+ showApp();
+
+}
+
+
+
+$('loginMfaBtn')?.addEventListener('click', async () => {
+
+ $('loginError').textContent = '';
+
+ try {
+
+ const code = ($('loginMfaCode')?.value || '').trim();
+
+ if (!pendingMfaToken || !code) throw new Error('请输入 MFA 验证码');
+
+ const data = await api('/auth/login-mfa', {
+
+ method: 'POST',
+
+ body: JSON.stringify({ mfaToken: pendingMfaToken, code }),
+
+ });
+
+ finishPopLogin(data);
+
+ } catch (e) {
+
+ $('loginError').textContent = e.message;
+
+ }
+
+});
+
+
+
+$('loginMfaBack')?.addEventListener('click', () => {
+
+ pendingMfaToken = null;
+
+ $('loginStepMfa')?.classList.add('hidden');
+
+ $('loginStepPrimary')?.classList.remove('hidden');
+
+ $('loginError').textContent = '';
+
+});
+
+
+
+$('logoutBtn').addEventListener('click', logout);
+
+
+
+if (token) showApp();
+
+else showLogin();
+
diff --git a/docs/popadmin-music-novels-deploy.md b/docs/popadmin-music-novels-deploy.md
index 3bcbb6c..6af8169 100644
--- a/docs/popadmin-music-novels-deploy.md
+++ b/docs/popadmin-music-novels-deploy.md
@@ -3,28 +3,38 @@
## 1. spark-server(音乐 API + OpenAPI)
```bash
-# 新加坡机
-cd /opt/spark-server # 或实际路径
-# 拉代码后
-./cmd/migrate # 或项目惯用 migrate,执行 migrations/0020_music.sql
-# 重启 spark-server 进程 / docker compose
+# 本机(Windows/WSL):构建并部署到新加坡机
+make deploy
+# 会:linux 交叉编译 → docker image → 上传 ECS → spark-migrate(含 0020_music / 0021_music_background)→ 重启容器 → nginx reload
+```
+
+新加坡机手工补跑迁移(仅 image 已更新但 migrate 未跑时):
+
+```bash
+docker exec spark-server spark-migrate -config /app/configs/config.yaml
+# 或 compose 目录下:docker compose run --rm spark-server spark-migrate -config /app/configs/config.yaml
sudo nginx -t && sudo nginx -s reload
-# 确认 include 了更新后的 popadmin-spark-admin-locations.conf(含 music)
+# 确认 include 了 popadmin-spark-admin-locations.conf(含 music,client_max_body_size 55m)
```
验证:
-- `GET https://sparkapi.wyrs365.com/v1/music/playlist` → `code=0`
-- `GET https://sparkapi.wyrs365.com/docs` → 可见 music / admin-music / novels / admin-novels / 登录设备 Breaking 说明
-- popadmin 登录后「音乐列表」:`GET/POST /api/admin/spark/music`
+- `GET https://sparkapi.wyrs365.com/v1/music/playlist` → `code=0`,曲目含 `coverUrl` / `backgroundUrl`(可空)/ `audioUrl`
+- `GET https://sparkapi.wyrs365.com/docs` → 可见 music / admin-music
+- popadmin「音乐列表」:封面 / **背景** / mp3 上传;`POST /api/admin/spark/music/{id}/background`
## 2. Aliyun wyrs-admin(popadmin 静态)
-热更或发版 `static/popadmin/js/popadmin.js`(侧栏「音乐列表」「小说内容管理」)。
+热更音乐背景图 UI(与现网一致):
-新加坡若用 static-override,同步覆盖:
+```bash
+# 源:spark-server/deploy/popadmin/popadmin.js(已含背景列与上传)
+scp -i ~/.ssh/wyrs_shanghai_deploy.pem deploy/popadmin/popadmin.js \
+ root@47.236.66.9:/opt/wyrs-admin/static-override/popadmin.js
+# 若 index 带 ?v= 缓存戳,同步 bump popadmin.index.html
+```
-`/opt/wyrs-admin/static-override/popadmin.js`(或等价路径)并 bump `?v=`。
+新加坡若用 static-override,覆盖后强刷浏览器即可。
## 3. FullStoryNovel CMS(小说 iframe)
@@ -39,3 +49,16 @@ sudo nginx -t && sudo nginx -s reload
OpenAPI 已按 `Spark资源文档/devices-client.md` 更新契约。
**当前 Go 实现仍为旧登录**(Body 必填客户端 `device_id`、字符串 `device_type`,JWT 无 `aid`)。上线新契约前需改 auth/login、JWT、meta 枚举。
+
+## 5. Demo 曲目(可选)
+
+仓库旁 `_demo_music/`:`tracks.json` + `01..10.mp3/png` + `upload_demo.py`。
+背景图**可空**,demo **不强制**补背景;脚本勿删已有曲目(仅 POST 新建)。
+
+```bash
+# 在新加坡机(或可访问 popadmin 登录与 spark admin API 的环境)
+export SPARK_ADMIN_BASE=http://127.0.0.1:18080
+export POPADMIN_LOGIN=http://127.0.0.1:8090/api/admin/auth/login
+python3 upload_demo.py
+curl -sS https://sparkapi.wyrs365.com/v1/music/playlist | jq '.data.items|length,.data.items[0]|{title,coverUrl,backgroundUrl,audioUrl}'
+```
diff --git a/internal/infrastructure/objectstore/policy.go b/internal/infrastructure/objectstore/policy.go
index ef93967..ff0ab2e 100644
--- a/internal/infrastructure/objectstore/policy.go
+++ b/internal/infrastructure/objectstore/policy.go
@@ -86,8 +86,8 @@ func (s *Store) applyCORS(ctx context.Context) {
}
}
-// applyPublicRead lets browsers load avatars without a presigned GET.
-// chat/ and perm/fav/ stay private.
+// applyPublicRead lets browsers load avatars / group icons / music assets without a
+// presigned GET. chat/ and perm/fav/ stay private.
func (s *Store) applyPublicRead(ctx context.Context) {
pabCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
@@ -117,13 +117,14 @@ func publicReadPolicy(bucket string) string {
return fmt.Sprintf(`{
"Version": "2012-10-17",
"Statement": [{
- "Sid": "PublicReadAvatars",
+ "Sid": "PublicReadPerm",
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:GetObject"],
"Resource": [
"arn:aws:s3:::%[1]s/perm/avatar/*",
- "arn:aws:s3:::%[1]s/perm/group/*"
+ "arn:aws:s3:::%[1]s/perm/group/*",
+ "arn:aws:s3:::%[1]s/perm/music/*"
]
}]
}`, bucket)
diff --git a/internal/infrastructure/objectstore/store_test.go b/internal/infrastructure/objectstore/store_test.go
index b05a41c..564c7f0 100644
--- a/internal/infrastructure/objectstore/store_test.go
+++ b/internal/infrastructure/objectstore/store_test.go
@@ -104,9 +104,9 @@ func TestGetPutNotReady(t *testing.T) {
}
}
-func TestPublicReadPolicyCoversAvatarsOnly(t *testing.T) {
+func TestPublicReadPolicyCoversAvatarsAndMusic(t *testing.T) {
p := publicReadPolicy("spark")
- if !strings.Contains(p, "spark/perm/avatar/*") || !strings.Contains(p, "spark/perm/group/*") {
+ if !strings.Contains(p, "spark/perm/avatar/*") || !strings.Contains(p, "spark/perm/group/*") || !strings.Contains(p, "spark/perm/music/*") {
t.Fatalf("%s", p)
}
if strings.Contains(p, "chat/") || strings.Contains(p, "perm/fav") || strings.Contains(p, "perm/moment") {
diff --git a/internal/module/music/entity/track.go b/internal/module/music/entity/track.go
index dbef646..ac05446 100644
--- a/internal/module/music/entity/track.go
+++ b/internal/module/music/entity/track.go
@@ -6,8 +6,9 @@ const (
SettingMemberPreviewSeconds = "member_preview_seconds"
DefaultPreviewSeconds = 30
- MaxCoverBytes = 5 << 20 // 5 MiB
- MaxAudioBytes = 50 << 20 // 50 MiB
+ MaxCoverBytes = 5 << 20 // 5 MiB
+ MaxBackgroundBytes = 5 << 20 // 5 MiB
+ MaxAudioBytes = 50 << 20 // 50 MiB
)
// Track is one curated music item for the Spark music list.
@@ -16,8 +17,10 @@ type Track struct {
Title string `json:"title"`
CoverKey string `json:"coverKey"`
AudioKey string `json:"audioKey"`
+ BackgroundKey string `json:"backgroundKey"`
CoverURL string `json:"coverUrl"`
AudioURL string `json:"audioUrl"`
+ BackgroundURL string `json:"backgroundUrl"`
RequireMember bool `json:"requireMember"`
SortOrder int `json:"sortOrder"`
Enabled bool `json:"enabled"`
@@ -32,6 +35,6 @@ type Settings struct {
// Playlist is the client-facing payload.
type Playlist struct {
- Items []Track `json:"items"`
- MemberPreviewSeconds int `json:"memberPreviewSeconds"`
+ Items []Track `json:"items"`
+ MemberPreviewSeconds int `json:"memberPreviewSeconds"`
}
diff --git a/internal/module/music/logic/service.go b/internal/module/music/logic/service.go
index f437052..591efe4 100644
--- a/internal/module/music/logic/service.go
+++ b/internal/module/music/logic/service.go
@@ -181,36 +181,58 @@ func (s *Service) Delete(ctx context.Context, id int64) error {
return errs.ErrNotFound.WithMsg("曲目不存在")
}
_ = s.deleteObject(ctx, t.CoverKey)
+ _ = s.deleteObject(ctx, t.BackgroundKey)
_ = s.deleteObject(ctx, t.AudioKey)
return s.repo.Delete(ctx, id)
}
func (s *Service) UploadCover(ctx context.Context, id int64, filename string, body []byte, contentType string) (*entity.Track, error) {
+ return s.uploadImage(ctx, id, filename, body, contentType, "cover")
+}
+
+func (s *Service) UploadBackground(ctx context.Context, id int64, filename string, body []byte, contentType string) (*entity.Track, error) {
+ return s.uploadImage(ctx, id, filename, body, contentType, "background")
+}
+
+func (s *Service) uploadImage(ctx context.Context, id int64, filename string, body []byte, contentType, kind string) (*entity.Track, error) {
if !s.Ready() {
return nil, ErrNotReady
}
- if len(body) == 0 {
- return nil, errs.ErrInvalidArgument.WithMsg("请选择封面文件")
+ label := "封面"
+ maxBytes := entity.MaxCoverBytes
+ if kind == "background" {
+ label = "背景图"
+ maxBytes = entity.MaxBackgroundBytes
}
- if len(body) > entity.MaxCoverBytes {
- return nil, errs.ErrInvalidArgument.WithMsg("封面不能超过 5MB")
+ if len(body) == 0 {
+ return nil, errs.ErrInvalidArgument.WithMsg("请选择" + label + "文件")
+ }
+ if len(body) > maxBytes {
+ return nil, errs.ErrInvalidArgument.WithMsg(label + "不能超过 5MB")
}
ct := normalizeMIME(contentType, filename)
if !isImageMIME(ct) {
- return nil, errs.ErrInvalidArgument.WithMsg("封面仅支持 jpg/png/webp/gif")
+ return nil, errs.ErrInvalidArgument.WithMsg(label + "仅支持 jpg/png/webp/gif")
}
t, err := s.repo.Get(ctx, id)
if err != nil {
return nil, errs.ErrNotFound.WithMsg("曲目不存在")
}
ext := extFor(ct, filename, "jpg")
- key := fmt.Sprintf("perm/music/cover/%d/%d.%s", id, time.Now().UnixNano(), ext)
- if err := s.put(ctx, key, body, ct, entity.MaxCoverBytes); err != nil {
+ key := fmt.Sprintf("perm/music/%s/%d/%d.%s", kind, id, time.Now().UnixNano(), ext)
+ if err := s.put(ctx, key, body, ct, maxBytes); err != nil {
return nil, err
}
- old := t.CoverKey
- t.CoverKey = key
- t.CoverURL = s.publicURL(key)
+ var old string
+ if kind == "background" {
+ old = t.BackgroundKey
+ t.BackgroundKey = key
+ t.BackgroundURL = s.publicURL(key)
+ } else {
+ old = t.CoverKey
+ t.CoverKey = key
+ t.CoverURL = s.publicURL(key)
+ }
if err := s.repo.Update(ctx, t); err != nil {
return nil, err
}
diff --git a/internal/module/music/model/repo.go b/internal/module/music/model/repo.go
index 86d7681..9b5451f 100644
--- a/internal/module/music/model/repo.go
+++ b/internal/module/music/model/repo.go
@@ -31,8 +31,10 @@ type trackRow struct {
Title string `gorm:"column:title"`
CoverKey string `gorm:"column:cover_key"`
AudioKey string `gorm:"column:audio_key"`
+ BackgroundKey string `gorm:"column:background_key"`
CoverURL string `gorm:"column:cover_url"`
AudioURL string `gorm:"column:audio_url"`
+ BackgroundURL string `gorm:"column:background_url"`
RequireMember bool `gorm:"column:require_member"`
SortOrder int `gorm:"column:sort_order"`
Enabled bool `gorm:"column:enabled"`
@@ -104,8 +106,10 @@ func (r *Repo) Insert(ctx context.Context, t *entity.Track) error {
Title: t.Title,
CoverKey: t.CoverKey,
AudioKey: t.AudioKey,
+ BackgroundKey: t.BackgroundKey,
CoverURL: t.CoverURL,
AudioURL: t.AudioURL,
+ BackgroundURL: t.BackgroundURL,
RequireMember: t.RequireMember,
SortOrder: t.SortOrder,
Enabled: t.Enabled,
@@ -131,8 +135,10 @@ func (r *Repo) Update(ctx context.Context, t *entity.Track) error {
"title": t.Title,
"cover_key": t.CoverKey,
"audio_key": t.AudioKey,
+ "background_key": t.BackgroundKey,
"cover_url": t.CoverURL,
"audio_url": t.AudioURL,
+ "background_url": t.BackgroundURL,
"require_member": t.RequireMember,
"sort_order": t.SortOrder,
"enabled": t.Enabled,
@@ -192,8 +198,10 @@ func toEntity(row trackRow) entity.Track {
Title: row.Title,
CoverKey: row.CoverKey,
AudioKey: row.AudioKey,
+ BackgroundKey: row.BackgroundKey,
CoverURL: row.CoverURL,
AudioURL: row.AudioURL,
+ BackgroundURL: row.BackgroundURL,
RequireMember: row.RequireMember,
SortOrder: row.SortOrder,
Enabled: row.Enabled,
diff --git a/internal/transport/http/docs/openapi.yaml b/internal/transport/http/docs/openapi.yaml
index 4926ce5..70dcde4 100644
--- a/internal/transport/http/docs/openapi.yaml
+++ b/internal/transport/http/docs/openapi.yaml
@@ -1869,8 +1869,8 @@ paths:
summary: 音乐列表(公开)
description: |
返回已上架曲目与全局「会员音乐免费试听秒数」。无需登录。
- 封面/音频为 sparkr 公网 URL(`https://sparkr.wyrs365.com/spark/perm/music/...`)。
- 客户端应按 `requireMember` 与 `memberPreviewSeconds` 控制试听。
+ 封面/背景图/音频为 sparkr 公网 URL(`https://sparkr.wyrs365.com/spark/perm/music/...`)。
+ 客户端应按 `requireMember` 与 `memberPreviewSeconds` 控制试听;`backgroundUrl` 可空。
operationId: getMusicPlaylist
security: []
responses:
@@ -2044,6 +2044,36 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/AdminApiResponse"
+ /api/admin/spark/music/{id}/background:
+ post:
+ tags: [admin-music]
+ summary: 上传背景图(multipart file)
+ description: 字段名 `file`;jpg/png/webp/gif,≤5MB。写入 RustFS `perm/music/background/...`。可空(未上传时 `backgroundUrl` 为空串)。
+ operationId: adminUploadMusicBackground
+ security:
+ - bearerAuth: []
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema: { type: integer, format: int64 }
+ requestBody:
+ required: true
+ content:
+ multipart/form-data:
+ schema:
+ type: object
+ required: [file]
+ properties:
+ file:
+ type: string
+ format: binary
+ responses:
+ "200":
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AdminApiResponse"
/api/admin/spark/music/{id}/audio:
post:
tags: [admin-music]
@@ -2926,8 +2956,10 @@ components:
title: { type: string }
coverKey: { type: string }
audioKey: { type: string }
+ backgroundKey: { type: string, description: 背景图 object_key;可空 }
coverUrl: { type: string, description: sparkr 公网 URL }
audioUrl: { type: string, description: sparkr 公网 URL }
+ backgroundUrl: { type: string, description: 背景图 sparkr 公网 URL;可空 }
requireMember: { type: boolean, description: true=需会员 }
sortOrder: { type: integer }
enabled: { type: boolean }
diff --git a/internal/transport/http/handler/admin/music.go b/internal/transport/http/handler/admin/music.go
index 4b984b6..d6504a9 100644
--- a/internal/transport/http/handler/admin/music.go
+++ b/internal/transport/http/handler/admin/music.go
@@ -133,14 +133,18 @@ func (h *MusicHandler) Delete(c *gin.Context) {
}
func (h *MusicHandler) UploadCover(c *gin.Context) {
- h.upload(c, true)
+ h.upload(c, "cover")
+}
+
+func (h *MusicHandler) UploadBackground(c *gin.Context) {
+ h.upload(c, "background")
}
func (h *MusicHandler) UploadAudio(c *gin.Context) {
- h.upload(c, false)
+ h.upload(c, "audio")
}
-func (h *MusicHandler) upload(c *gin.Context, cover bool) {
+func (h *MusicHandler) upload(c *gin.Context, kind string) {
if !h.ready(c) {
return
}
@@ -162,9 +166,12 @@ func (h *MusicHandler) upload(c *gin.Context, cover bool) {
}
ct := hdr.Header.Get("Content-Type")
var out any
- if cover {
+ switch kind {
+ case "cover":
out, err = h.svc.UploadCover(c.Request.Context(), id, hdr.Filename, body, ct)
- } else {
+ case "background":
+ out, err = h.svc.UploadBackground(c.Request.Context(), id, hdr.Filename, body, ct)
+ default:
out, err = h.svc.UploadAudio(c.Request.Context(), id, hdr.Filename, body, ct)
}
if err != nil {
diff --git a/internal/transport/http/router_admin.go b/internal/transport/http/router_admin.go
index 5184b3e..471afa5 100644
--- a/internal/transport/http/router_admin.go
+++ b/internal/transport/http/router_admin.go
@@ -84,5 +84,6 @@ func registerMusicAdmin(g *gin.RouterGroup, h *adminhandler.MusicHandler) {
g.PATCH("/music/:id", h.Update)
g.DELETE("/music/:id", h.Delete)
g.POST("/music/:id/cover", h.UploadCover)
+ g.POST("/music/:id/background", h.UploadBackground)
g.POST("/music/:id/audio", h.UploadAudio)
}
diff --git a/migrations/0021_music_background.sql b/migrations/0021_music_background.sql
new file mode 100644
index 0000000..67635e3
--- /dev/null
+++ b/migrations/0021_music_background.sql
@@ -0,0 +1,5 @@
+-- 曲目背景图(与 cover 同形态:object key + 公网 URL;可空)。
+
+ALTER TABLE t_music_tracks
+ ADD COLUMN background_key VARCHAR(512) NOT NULL DEFAULT '' COMMENT '背景图 object_key(sparkr)' AFTER cover_url,
+ ADD COLUMN background_url VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '背景图公网 URL' AFTER background_key;