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)}

+ + + + + + ${rows} + +
ID邮箱昵称设备最近活跃IP
+ +
`; + +} + + + +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(email)}
+ +
昵称
${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})

+ + + + + + + + ${ + + asList(data.devices).length + + ? asList(data.devices) + + .map( + + (d) => ` + + + + + + + + + + + + + + + + + + ` + + ) + + .join('') + + : emptyRows(8, '暂无设备记录') + + } + + + +
设备ID平台版本注册IP:端口/城市最近IP:端口/城市首次最近来源
${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 || '—')}
+ +
+ + + + ${eventTable('更换设备记录', data.deviceChanges, '暂无更换设备记录')} + + + +
+ +

同设备应用列表(${asList(data.sameDeviceApps).length})

+ +

同一 deviceId 下安装/活跃过的全部 App(含本账号与其它邮箱)

+ + + + + + + + ${ + + asList(data.sameDeviceApps).length + + ? asList(data.sameDeviceApps) + + .map( + + (d) => ` + + + + + + + + + + + + + + + + + + + + + + ` + + ) + + .join('') + + : emptyRows(10, '暂无同设备应用(需有 deviceId)') + + } + + + +
设备码App用户ID邮箱昵称平台/版本最近IP:端口注册最近活跃
${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 ? '本账号' : ''}
+ +
+ + + +
+ +

同网络设备码(${asList(data.sameNetworkDevices).length})

+ +

按注册/最近活跃公网 IP 匹配的其它设备(不含本账号)

+ + + + + + + + ${ + + asList(data.sameNetworkDevices).length + + ? asList(data.sameNetworkDevices) + + .map( + + (d) => ` + + + + + + + + + + + + + + + + + + + + ` + + ) + + .join('') + + : emptyRows(9, '暂无同网络其它设备(需双方均有 IP 记录)') + + } + + + +
设备码用户ID邮箱昵称匹配IP最近IP:端口城市平台最近活跃
${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 || '—')}
+ +
+ + + +
+ +

通讯录(${data.contactCount ?? asList(data.contacts).length})

+ +

客户端授权同步后可见;含姓名与备注名

+ + + + + + + + ${ + + asList(data.contacts).length + + ? asList(data.contacts) + + .map( + + (c) => ` + + + + + + + + + + + + ` + + ) + + .join('') + + : emptyRows(5, '暂无通讯录(待客户端授权同步)') + + } + + + +
姓名备注名电话设备码同步时间
${esc(c.name || '—')}${esc(c.remark || c.name || '—')}${esc(c.phone || c.phoneNorm || '—')}${esc(c.deviceId || '—')}${esc(c.syncedAt || '—')}
+ +
+ + + +
+ +

火花 / Spark 用户档案

+ + ${ + + appUsers.length + + ? ` + + + + + + ${appUsers + + .map( + + (u) => ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ` + + ) + + .join('')} + + + +
用户IDApp昵称手机平台版本渠道国家设备封禁异常注册最近活跃
${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 || '—')} + + + + + +
` + + : '

无火花(含历史兼容)用户档案。

' + + } + +
+ + + +
+ +

好友列表(${asList(data.friends).length})

+ + + + + + + + ${ + + asList(data.friends).length + + ? asList(data.friends) + + .map( + + (f) => ` + + + + + + + + + + ` + + ) + + .join('') + + : emptyRows(4, '暂无好友(待 IM 上报)') + + } + + + +
好友ID/邮箱昵称备注时间
${esc(f.friendId || f.email || f.id || '—')}${esc(f.nickname || f.name || '—')}${esc(f.remark || f.note || '—')}${esc(f.createdAt || f.at || '—')}
+ +
+ + + +
+ +

加入过的群(${asList(data.groups).length})

+ + + + + + + + ${ + + asList(data.groups).length + + ? asList(data.groups) + + .map( + + (g) => ` + + + + + + + + + + ` + + ) + + .join('') + + : emptyRows(4, '暂无群记录(待 IM 上报)') + + } + + + +
群ID群名角色加入时间
${esc(g.groupId || g.id || '—')}${esc(g.name || g.title || '—')}${esc(g.role || '—')}${esc(g.joinedAt || g.createdAt || '—')}
+ +
+ + + +
+ +

购买记录(${asList(data.payments).length})

+ + + + + + + + ${ + + asList(data.payments).length + + ? asList(data.payments) + + .map( + + (p) => ` + + + + + + + + + + + + + + ` + + ) + + .join('') + + : emptyRows(6, '暂无购买记录') + + } + + + +
订单金额商品状态渠道支付时间
${esc(p.orderId || p.id)}${esc(p.amount)}${esc(p.productId || '—')}${esc(p.status || '—')}${esc(p.payChannel || '—')}${esc(p.paidAt || p.createdAt || '—')}
+ +
+ + + +
+ +

会员详情

+ +
+ +
状态
${mem.active ? '有效' : '无/未知'}
+ +
商品
${esc(mem.productId || '—')}
+ +
到期
${esc(mem.expireAt || '—')}
+ +
来源
${esc(mem.source || '—')}
+ +
最近支付
${esc(mem.lastPaidAt || '—')} · ${esc(mem.lastAmount ?? '')}
+ +
+ +
+ + + +
+ +

白名单

+ +

${wl.enabled ? '已在白名单' : '未加入'}

+ + ${ + + asList(wl.entries).length + + ? ` + + + + + + ${asList(wl.entries) + + .map( + + (e) => ` + + + + + + + + + + ` + + ) + + .join('')} + + + +
App启用备注更新
${esc(e.appCode)}${e.enabled ? '是' : '否'}${esc(e.note || '—')}${esc(e.updatedAt || '—')}
` + + : '' + + } + +
+ + + + ${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})

+ +

含机型、邮箱、设备码

+ + + + + + + + ${ + + asList(data.crashLogs).length + + ? asList(data.crashLogs) + + .map( + + (c) => ` + + + + + + + + + + + + + + + + ` + + ) + + .join('') + + : emptyRows(7, '暂无崩溃/错误日志') + + } + + + +
时间级别机型邮箱设备码App内容
${esc(c.createdAt || '—')}${esc(c.level || '—')}${esc(c.deviceModel || '—')}${esc(c.email || '—')}${esc(c.deviceId || '—')}${esc(c.appCode || '—')}${esc(c.message || '—')}
+ +
+ + + +
+ +

关联的其它 App

+ + ${ + + linked.length + + ? ` + + + + + + ${linked + + .map( + + (u) => ` + + + + + + + + + + + + ` + + ) + + .join('')} + + + +
App用户ID设备注册最近活跃
${esc(u.appName || u.appCode)}${esc(u.userId)}${esc(u.deviceId || '—')}${esc(u.createdAt || '—')}${esc(u.lastActiveAt || '—')}
` + + : '

无关联 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。 +
+
${cards || '

暂无

'}
`; +} + + +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。
+ +
+ + + + + + + + + + + +
+ +
+ + + + + + + + ${ + + list.length + + ? list + + .map( + + (u) => ` + + + + + + + + + + ` + + ) + + .join('') + + : '' + + } + + + +
ID邮箱昵称设备渠道最近活跃媒体拉取
${esc(u.id)}${esc(u.email)}${esc(u.nickname)}${esc(u.deviceId)}${esc(u.channel)}${esc(u.lastActiveAt)} + + + + + + + + + + + +
暂无用户
`; + + 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
+ + + + + + + + ${ + + (rows || []).length + + ? rows + + .map( + + (r) => ` + + + + + + + + ` + + ) + + .join('') + + : '' + + } + + + +
用户ID邮箱设备联系人数最近同步
${esc(r.userId)}${esc(r.email)}${esc(r.deviceId)}${esc(r.contactCount)}${esc(r.lastContactSyncAt || r.lastSyncAt)}
暂无同步数据
`; + + $('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) => + + `` + + ) + + .join('') + + : '' + + } + + + +
姓名备注名电话同步时间
${esc(c.name)}${esc(c.remark || c.name || '—')}${esc(c.phone)}${esc(c.syncedAt)}
暂无联系人
`; + + $('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
+ + + + + + + + ${ + + (rows || []).length + + ? rows + + .map( + + (r) => ` + + + + + + + + ` + + ) + + .join('') + + : '' + + } + + + +
用户ID邮箱设备媒体数最近同步
${esc(r.userId)}${esc(r.email)}${esc(r.deviceId)}${esc(r.mediaCount)}${esc(r.lastMediaSyncAt || r.lastSyncAt)}
暂无同步数据
`; + + $('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)}
+ +
+ + ${ + + list.length + + ? list + + .map( + + (m) => `
+ + + + ${esc(m.fileName)} + + + +
+ + ${esc(m.mediaType)} + + ${esc(m.fileName || m.mediaKey)} + + ${esc(m.takenAt || m.syncedAt)} + +
+ +
` + + ) + + .join('') + + : '

暂无媒体

' + + } + +
`; + + $('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 = ` + +
+ +
邮箱
${esc(st.email)}
+ +
媒体拉取
+ + + +
+ +
拉取数量
+ + + + 20–30 + +
+ +
类型
+ + + + + +
+ +
已同步
${esc(total)} 条
+ +
+ +
+ + + +
+ +
+ + ${ + + list.length + + ? list + + .map( + + (m) => `
+ + + + ${esc(m.fileName)} + + + +
+ + ${esc(m.mediaType)} + + ${esc(m.fileName || m.mediaKey)} + + ${esc(m.takenAt || m.syncedAt)} + +
+ +
` + + ) + + .join('') + + : '

暂无媒体(需后台开启 + 用户授权后客户端才会上传)

' + + } + +
`; + + 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 = ` + +
共享 /api/admin/banners?appCode=spark。完整编辑请用 admin.wyrs.com。
+ + + + + + + + ${ + + list.length + + ? list + + .map( + + (b) => + + `` + + ) + + .join('') + + : '' + + } + + + +
ID标题启用排序
${esc(b.id)}${esc(b.title || b.name)}${esc(b.enabled)}${esc(b.sortOrder ?? b.sort)}
暂无 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
+ +
+ + + + + + + + + + + +
+ + + + + + + + ${ + + list.length + + ? list + + .map( + + (v) => + + `` + + ) + + .join('') + + : '' + + } + + + +
版本名versionCode强制更新状态下载说明
${esc(v.versionName)}${esc(v.versionCode)}${esc(v.forceUpdate)}${esc(v.status || '—')}${esc(v.downloadUrl || '—')}${esc(v.changelog || v.description || '—')}
暂无版本
`; + + $('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}
+ + + + ${list.length ? list.map((p) => ``).join('') : emptyRows(9, '暂无价格')} + +
ID名称类型价格原价天数渠道码启用
${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)}
`; + $('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
+
+ + + + +
+
+ + +
+ + + + ${rows.length ? rows.map((r)=>``).join('') : emptyRows(7,'暂无渠道配置')} + +
ID渠道启用已配密钥备注更新
${esc(r.id)}${esc(r.channel)}${esc(r.enabled)}${esc(r.secretsConfigured)}${esc(r.remark||'')}${esc(r.updatedAt||'')}
`; + $('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 的曲目生效 +
+
+
+ + + + + +
+ + + + ${ + items.length + ? items + .map( + (t) => ` + + + + + + + + + + ` + ) + .join('') + : '' + } + +
封面背景ID曲名会员上架排序音频操作
${t.coverUrl ? `` : '—'}${t.backgroundUrl ? `` : '—'}${esc(t.id)}${esc(t.title)}${t.requireMember ? '是' : '否'}${t.enabled ? '是' : '否'}${esc(t.sortOrder)}${t.audioUrl ? `mp3` : '—'} + + + + + +
暂无曲目
`; + $('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
+ +
+ + + + + + + + + + + + + +
+ + + + + + + + ${ + + list.length + + ? list + + .map( + + (d) => + + `` + + ) + + .join('') + + : emptyRows(7, '暂无推荐应用') + + } + + + +
ID名称包名链接排序启用
${esc(d.id)}${esc(d.targetAppName || d.name)}${esc(d.targetPackageName || '—')}${esc(d.storeUrl || d.fallbackUrl || '—')}${esc(d.sortOrder)}${esc(d.enabled)}
`; + + $('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
+ + + + + + + + ${ + + list.length + + ? list + + .map( + + (f) => + + `` + + ) + + .join('') + + : '' + + } + + + +
ID内容联系方式状态时间
${esc(f.id)}${esc(f.content)}${esc(f.contact)}${esc(f.status)}${esc(f.createdAt)}
暂无反馈
`; + + }, + + + + 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)} 条
+ + + + + + + + ${ + + list.length + + ? list + + .map( + + (c) => ` + + + + + + + + + + + + + + + + + + ` + + ) + + .join('') + + : '' + + } + + + +
ID级别机型邮箱设备码用户ID内容时间
${esc(c.id)}${esc(c.level || '—')}${esc(c.deviceModel || '—')}${esc(c.email || '—')}${esc(c.deviceId || '—')}${esc(c.userId || '—')}${esc(c.message || '—')}${esc(c.createdAt || '—')}
暂无崩溃日志
`; + + $('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)}
+ +
日活合计(点)
${esc(totalDau)}
+ +
天数
${esc(list.length)}
+ +
+ +

日活明细

+ + + + ${list.length ? list.map(r => ``).join('') : emptyRows(3)}
日期日活新增
${esc(r.statDate || r.date)}${esc(r.activeCount ?? r.dau)}${esc(r.newCount ?? r.newUsers)}
+ +

渠道日活

+ + + + ${asList(channels).length ? asList(channels).map(r => ``).join('') : emptyRows(3)}
日期渠道日活
${esc(r.statDate || r.date || '—')}${esc(r.channelName || r.channel)}${esc(r.activeCount ?? r.dau ?? r.count)}
`; + + }; + + $('dauLoad').onclick = load; + + load(); + + }, + + + + async configs() { + + const rows = await api('/spark/configs'); + + $('content').innerHTML = ` + +
+ + + + + + + + + +
+ +
+ + + + + +
+ + + + + + + + ${(rows || []) + + .map( + + (r) => ` + + + + + + + + + + + + ` + + ) + + .join('')} + + + +
KeyValue公开备注
${esc(r.configKey)}${esc(r.configValue)}${r.publicReadable ? '是' : '否'}${esc(r.remark)}
`; + + $('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。
+ + + + + + + + ${(rows || []) + + .map( + + (r) => + + `` + + ) + + .join('')} + + + +
Service模块号说明
${esc(r.service)}${esc(r.module)}${esc(r.desc)}
`; + + }, + + + + 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)}
+ +
+ +

匹配卡片(美女机器人)

+ + + + ${cards.length ? cards.map(c => ``).join('') : emptyRows(7)}
头像publicId昵称城市年龄简介标签
${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)}
+ +

关注

+ + + + ${follows.length ? follows.map(c => ``).join('') : emptyRows(4)}
头像publicId昵称互关
${avatarCell(c)}${esc(c.publicId)}${esc(c.nickname)}${esc(c.mutual)}
+ +

粉丝

+ + + + ${fans.length ? fans.map(c => ``).join('') : emptyRows(4)}
头像publicId昵称互关
${avatarCell(c)}${esc(c.publicId)}${esc(c.nickname)}${esc(c.mutual)}
+ +

好友

+ + + + ${friends.length ? friends.map(c => ``).join('') : emptyRows(4)}
头像publicId昵称邮箱
${avatarCell(c)}${esc(c.publicId)}${esc(c.nickname)}${esc(c.email)}
+ +

好友圈

+ + + + ${moments.length ? moments.map(c => ``).join('') : emptyRows(5)}
作者publicId内容赞时间
${avatarCell(c)}${esc(c.nickname)}${botTag(c)}${esc(c.publicId)}${esc(c.content)}${esc(c.likeCount)}${esc(c.createdAt)}
+ +

广场

+ + + + ${square.length ? square.map(c => ``).join('') : emptyRows(4)}
作者内容赞时间
${avatarCell(c)}${esc(c.nickname)}${botTag(c)}${esc(c.content)}${esc(c.likeCount)}${esc(c.createdAt)}
+ +

私信

+ + + + ${messages.length ? messages.map(c => ``).join('') : emptyRows(4)}
方向对方内容时间
${c.inbound ? '收' : '发'}${esc(c.fromNickname || c.peerPublicId)}${esc(c.content)}${esc(c.sentAt)}
`; + + 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)} 个 + + + + + +
+ + + + ${rows.length ? rows.map((r, i) => ` + + + + + + + + + + + + + + `).join('') : emptyRows(6, '尚未灌入,请点「确保已灌入」')}
#邮箱密码昵称publicIdappUserId
${esc(r.index ?? i + 1)}${esc(r.email)}${esc(r.password)}${esc(r.displayName || r.nickname)}${esc(r.publicId)}${esc(r.appUserId || r.userId || '—')}
`; + + 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)}
+ +
+ +

用户列表

+ + + + ${users.length ? users.map(u => ``).join('') : emptyRows(5)}
ID邮箱昵称设备最近活跃
${esc(u.id||u.userId)}${esc(u.email)}${esc(u.nickname)}${esc(u.deviceId)}${esc(u.lastActiveAt)}
+ +

设备列表

+ + + + ${devices.length ? devices.map(d => ``).join('') : emptyRows(5)}
设备码邮箱昵称注册IP活跃IP
${esc(d.deviceId)}${esc(d.email)}${esc(d.nickname)}${esc(d.registerIp)}${esc(d.lastActiveIp)}
`; + + } 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 上报;可在此维护群元数据。
+ +
+ + + + + + + +
+ +
+ + + + + + + + ${(rows || []).length ? rows.map(g => ` + + + + + + + + `).join('') : emptyRows(6, '暂无活跃群(可登记或等待 IM 同步)')} + + + +
群ID群名成员数活跃更新
${esc(g.groupId)}${esc(g.name)}${esc(g.memberCount)}${g.active ? '是' : '否'}${esc(g.updatedAt)} + + + + + +
`; + + 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}

+ + + + ${members.length ? members.map(m => ``).join('') : emptyRows(4, '暂无成员(待 IM 上报)')}
userId邮箱昵称角色
${esc(m.userId)}${esc(m.email)}${esc(m.nickname)}${esc(m.role)}
+ +
`; + + }; + + }); + + $('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 ?? '-')}
+ +
+ +
+ + + + + +
+ + + + + + + +
时间类型fromto/群内容撤回
加载中…
`; + + 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 => ``).join('') : emptyRows(6, '暂无投诉')}
时间邮箱对象原因详情状态
${esc(r.createdAt)}${esc(r.email)}${esc(r.target)}${esc(r.reason)}${esc(r.detail)}${esc(r.status)}
`; + + $('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 => ` + + `).join('') : emptyRows(4)}
词备注时间
${esc(r.word)}${esc(r.note)}${esc(r.createdAt)}
`; + + $('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 活跃用户数排名。
+ + + + ${items.length ? items.map((r, i) => ` + + + + + + `).join('') : emptyRows(5, '今日暂无同 IP 活跃数据')}
#IP用户数设备数
${i + 1}${esc(r.ip)}${esc(r.userCount)}${esc(r.deviceCount)}
`; + + $('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 => ` + + `).join('') : emptyRows(4)}
值备注时间
${esc(r.value)}${esc(r.note)}${esc(r.createdAt)}
`; + + $('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 => ` + + + + + + `).join('') : emptyRows(5)}
类型值启用备注
${esc(r.type)}${esc(r.value)}${r.enabled ? '是' : '否'}${esc(r.note)}
`; + + 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) => ``).join('') : emptyRows(5, '暂无排行数据')}
#邮箱昵称数值说明
${i+1}${esc(r.email)}${esc(r.nickname)}${esc(r.value)}${esc(r.label)}
`; + + }; + + }); + + }, + + + + 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 => ` + + + + + + `).join('') : emptyRows(6)}
时间用户金额渠道状态订单号
${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)}
`; + + }; + + $('ordLoad').onclick = load; + + load(); + + }, + + + + async pushes() { + + const rows = await api('/spark/pushes'); + + $('content').innerHTML = ` + +
系统消息推送列表(草稿/计划,实际下发需对接推送通道)。
+ +
+ + + + + + + + + +
+ + + + ${(rows||[]).length ? rows.map(r => ` + + + + + + `).join('') : emptyRows(6)}
时间标题内容受众状态
${esc(r.createdAt)}${esc(r.title)}${esc(r.content)}${esc(r.audience)}${esc(r.status)}
`; + + $('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 ?? '—')}
+ +
+ +

渠道留存

+ + + + ${asList(channel).length ? asList(channel).map(r => ` + + + + + + `).join('') : emptyRows(5)}
渠道cohortD1D7D30
${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)}
`; + + }, + + + + 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)}

+ + MFA QR + +
+ + + + + +
`; + + $('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('')}
+ + + + ${asList(rows).length ? asList(rows).map(r => ` + + + + + + + + + + `).join('') : emptyRows(6)}
用户名邮箱角色权限MFA
${esc(r.username)}${esc(r.email)}${esc(r.role)}${esc(formatPerms(r.permissions || []))}${r.mfaEnabled ? '是' : '否'} + + + + + + + +
`; + + $('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;