@seasonkoh/webaz 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/LICENSE +48 -0
  2. package/README.md +156 -20
  3. package/dist/layer0-foundation/L0-1-database/schema.js +5 -4
  4. package/dist/layer0-foundation/L0-2-state-machine/engine.js +228 -7
  5. package/dist/layer0-foundation/L0-2-state-machine/order-chain.js +156 -0
  6. package/dist/layer0-foundation/L0-2-state-machine/transitions.js +53 -12
  7. package/dist/layer0-foundation/L0-5-manifest/manifest.js +14 -1
  8. package/dist/layer1-agent/L1-1-mcp-server/auth.js +1 -1
  9. package/dist/layer1-agent/L1-1-mcp-server/server.js +3691 -714
  10. package/dist/layer1-agent/L1-2-external-anchor/anchor-engine.js +324 -0
  11. package/dist/layer1-agent/L1-2-identity/agent-passport.js +100 -0
  12. package/dist/layer2-business/L2-6-notifications/notification-engine.js +72 -5
  13. package/dist/layer2-business/L2-7-snf/snf-engine.js +287 -0
  14. package/dist/layer2-business/L2-anchor-registry/anchor-registry.js +396 -0
  15. package/dist/layer2-business/L2-notes/note-photo-storage.js +133 -0
  16. package/dist/layer3-trust/L3-1-dispute-engine/dispute-engine.js +6 -6
  17. package/dist/layer3-trust/L3-1-dispute-engine/evidence-storage.js +246 -0
  18. package/dist/layer4-economics/L4-3-reputation/reputation-engine.js +95 -1
  19. package/dist/layer4-economics/L4-4-skill-market/skill-engine.js +31 -2
  20. package/dist/layer4-economics/L4-4-skill-market/skill-listing-engine.js +358 -0
  21. package/dist/pwa/public/app.js +31947 -0
  22. package/dist/pwa/public/i18n.js +5751 -0
  23. package/dist/pwa/public/icon.svg +11 -0
  24. package/dist/pwa/public/index.html +21 -0
  25. package/dist/pwa/public/manifest.json +48 -0
  26. package/dist/pwa/public/openapi.json +5946 -0
  27. package/dist/pwa/public/style.css +535 -0
  28. package/dist/pwa/public/sw.js +63 -0
  29. package/dist/pwa/public/vendor/jsQR.js +10102 -0
  30. package/dist/pwa/public/webaz-logo.png +0 -0
  31. package/dist/pwa/routes/account-deletion.js +53 -0
  32. package/dist/pwa/routes/addresses.js +105 -0
  33. package/dist/pwa/routes/admin-admins.js +151 -0
  34. package/dist/pwa/routes/admin-analytics.js +253 -0
  35. package/dist/pwa/routes/admin-atomic.js +21 -0
  36. package/dist/pwa/routes/admin-catalog.js +64 -0
  37. package/dist/pwa/routes/admin-editor-picks.js +45 -0
  38. package/dist/pwa/routes/admin-events.js +60 -0
  39. package/dist/pwa/routes/admin-health.js +66 -0
  40. package/dist/pwa/routes/admin-moderation.js +120 -0
  41. package/dist/pwa/routes/admin-ops.js +179 -0
  42. package/dist/pwa/routes/admin-protocol-params.js +79 -0
  43. package/dist/pwa/routes/admin-reports.js +154 -0
  44. package/dist/pwa/routes/admin-tokenomics.js +113 -0
  45. package/dist/pwa/routes/admin-users-lifecycle.js +237 -0
  46. package/dist/pwa/routes/admin-users-query.js +390 -0
  47. package/dist/pwa/routes/admin-verifier-flow.js +126 -0
  48. package/dist/pwa/routes/admin-verifier-whitelist.js +111 -0
  49. package/dist/pwa/routes/admin-wallet-ops.js +66 -0
  50. package/dist/pwa/routes/agent-buy.js +215 -0
  51. package/dist/pwa/routes/agent-governance.js +341 -0
  52. package/dist/pwa/routes/agent-reputation.js +34 -0
  53. package/dist/pwa/routes/ai.js +101 -0
  54. package/dist/pwa/routes/analytics.js +272 -0
  55. package/dist/pwa/routes/anchors.js +169 -0
  56. package/dist/pwa/routes/announcements.js +110 -0
  57. package/dist/pwa/routes/arbitrator.js +117 -0
  58. package/dist/pwa/routes/auction.js +436 -0
  59. package/dist/pwa/routes/auth-login.js +40 -0
  60. package/dist/pwa/routes/auth-read.js +66 -0
  61. package/dist/pwa/routes/auth-register.js +138 -0
  62. package/dist/pwa/routes/auth-sessions.js +62 -0
  63. package/dist/pwa/routes/blocklist.js +60 -0
  64. package/dist/pwa/routes/buyer-feeds.js +224 -0
  65. package/dist/pwa/routes/cart.js +155 -0
  66. package/dist/pwa/routes/charity.js +816 -0
  67. package/dist/pwa/routes/chat.js +318 -0
  68. package/dist/pwa/routes/checkin-tasks.js +122 -0
  69. package/dist/pwa/routes/checkout-helpers.js +85 -0
  70. package/dist/pwa/routes/claim-initiators.js +88 -0
  71. package/dist/pwa/routes/claim-verify.js +615 -0
  72. package/dist/pwa/routes/claim-voting.js +114 -0
  73. package/dist/pwa/routes/claim-withdrawals.js +20 -0
  74. package/dist/pwa/routes/coupons.js +165 -0
  75. package/dist/pwa/routes/dashboards.js +99 -0
  76. package/dist/pwa/routes/dispute-cases.js +267 -0
  77. package/dist/pwa/routes/disputes-read.js +358 -0
  78. package/dist/pwa/routes/disputes-write.js +475 -0
  79. package/dist/pwa/routes/evidence.js +86 -0
  80. package/dist/pwa/routes/external-anchors.js +107 -0
  81. package/dist/pwa/routes/feedback.js +270 -0
  82. package/dist/pwa/routes/flash-sales.js +130 -0
  83. package/dist/pwa/routes/follows.js +103 -0
  84. package/dist/pwa/routes/group-buys.js +208 -0
  85. package/dist/pwa/routes/growth.js +199 -0
  86. package/dist/pwa/routes/import-product.js +153 -0
  87. package/dist/pwa/routes/kyc.js +40 -0
  88. package/dist/pwa/routes/leaderboard.js +149 -0
  89. package/dist/pwa/routes/listings.js +281 -0
  90. package/dist/pwa/routes/logistics.js +35 -0
  91. package/dist/pwa/routes/manifests.js +126 -0
  92. package/dist/pwa/routes/me-data.js +101 -0
  93. package/dist/pwa/routes/notifications.js +48 -0
  94. package/dist/pwa/routes/offers.js +96 -0
  95. package/dist/pwa/routes/orders-action.js +285 -0
  96. package/dist/pwa/routes/orders-create.js +339 -0
  97. package/dist/pwa/routes/orders-read.js +180 -0
  98. package/dist/pwa/routes/p2p-products.js +178 -0
  99. package/dist/pwa/routes/payments-governance.js +311 -0
  100. package/dist/pwa/routes/peers.js +34 -0
  101. package/dist/pwa/routes/pin-receipts.js +39 -0
  102. package/dist/pwa/routes/products-aliases.js +119 -0
  103. package/dist/pwa/routes/products-claims.js +60 -0
  104. package/dist/pwa/routes/products-create.js +206 -0
  105. package/dist/pwa/routes/products-crud.js +73 -0
  106. package/dist/pwa/routes/products-links.js +129 -0
  107. package/dist/pwa/routes/products-list.js +424 -0
  108. package/dist/pwa/routes/products-meta.js +155 -0
  109. package/dist/pwa/routes/products-update.js +125 -0
  110. package/dist/pwa/routes/profile-credentials.js +105 -0
  111. package/dist/pwa/routes/profile-identity.js +174 -0
  112. package/dist/pwa/routes/profile-location.js +35 -0
  113. package/dist/pwa/routes/profile-placement.js +70 -0
  114. package/dist/pwa/routes/profile-prefs.js +93 -0
  115. package/dist/pwa/routes/promoter.js +208 -0
  116. package/dist/pwa/routes/public-utils.js +170 -0
  117. package/dist/pwa/routes/push.js +54 -0
  118. package/dist/pwa/routes/ratings.js +220 -0
  119. package/dist/pwa/routes/recover-key.js +100 -0
  120. package/dist/pwa/routes/referral.js +58 -0
  121. package/dist/pwa/routes/reputation.js +34 -0
  122. package/dist/pwa/routes/returns.js +493 -0
  123. package/dist/pwa/routes/reviews.js +81 -0
  124. package/dist/pwa/routes/rfqs.js +443 -0
  125. package/dist/pwa/routes/search.js +172 -0
  126. package/dist/pwa/routes/secondhand.js +278 -0
  127. package/dist/pwa/routes/seller-quota.js +225 -0
  128. package/dist/pwa/routes/share-redirects.js +164 -0
  129. package/dist/pwa/routes/shareables-interactions.js +212 -0
  130. package/dist/pwa/routes/shareables.js +470 -0
  131. package/dist/pwa/routes/shops.js +98 -0
  132. package/dist/pwa/routes/signaling.js +43 -0
  133. package/dist/pwa/routes/skill-market.js +173 -0
  134. package/dist/pwa/routes/skills.js +174 -0
  135. package/dist/pwa/routes/snf.js +126 -0
  136. package/dist/pwa/routes/tags.js +47 -0
  137. package/dist/pwa/routes/trial.js +333 -0
  138. package/dist/pwa/routes/trusted-kpi.js +87 -0
  139. package/dist/pwa/routes/url-claim.js +113 -0
  140. package/dist/pwa/routes/users-public.js +317 -0
  141. package/dist/pwa/routes/variants.js +156 -0
  142. package/dist/pwa/routes/verifier-user.js +107 -0
  143. package/dist/pwa/routes/verify-tasks.js +120 -0
  144. package/dist/pwa/routes/waitlist.js +65 -0
  145. package/dist/pwa/routes/wallet-read.js +218 -0
  146. package/dist/pwa/routes/wallet-write.js +273 -0
  147. package/dist/pwa/routes/webauthn.js +188 -0
  148. package/dist/pwa/routes/webhooks.js +162 -0
  149. package/dist/pwa/routes/welcome.js +226 -0
  150. package/dist/pwa/routes/wishlist-qa.js +135 -0
  151. package/dist/pwa/security/ssrf.js +110 -0
  152. package/dist/pwa/server.js +9679 -698
  153. package/package.json +11 -4
Binary file
@@ -0,0 +1,53 @@
1
+ export function registerAccountDeletionRoutes(app, deps) {
2
+ const { db, auth } = deps;
3
+ app.post('/api/me/delete-request', (req, res) => {
4
+ const user = auth(req, res);
5
+ if (!user)
6
+ return;
7
+ const uid = user.id;
8
+ const reason = String((req.body || {}).reason || '').slice(0, 500);
9
+ const blockers = [];
10
+ const pendingOrders = db.prepare(`SELECT COUNT(*) as n FROM orders WHERE (buyer_id = ? OR seller_id = ?) AND status NOT IN ('completed', 'confirmed', 'cancelled', 'refunded_full', 'refunded_partial')`).get(uid, uid).n;
11
+ if (pendingOrders > 0)
12
+ blockers.push(`你有 ${pendingOrders} 个未完成订单,请先处理`);
13
+ // #1017 fix: dispute_cases 是已结公开判例(无 status 列);查"未结争议"应走 disputes 表
14
+ const openDisputes = db.prepare(`SELECT COUNT(*) as n FROM disputes WHERE (initiator_id = ? OR defendant_id = ?) AND status NOT IN ('resolved', 'closed')`).get(uid, uid).n;
15
+ if (openDisputes > 0)
16
+ blockers.push(`你有 ${openDisputes} 个未结争议,请先处理`);
17
+ const wallet = db.prepare(`SELECT balance FROM wallets WHERE user_id = ?`).get(uid);
18
+ if (wallet && wallet.balance > 0.01)
19
+ blockers.push(`钱包余额 ${wallet.balance} WAZ — 请先提现`);
20
+ if (blockers.length > 0)
21
+ return void res.status(400).json({ error: '账号注销前请先处理', blockers });
22
+ db.prepare(`INSERT OR REPLACE INTO account_deletion_requests (user_id, requested_at, reason, cancelled_at, pii_wiped_at) VALUES (?, datetime('now'), ?, NULL, NULL)`).run(uid, reason);
23
+ db.prepare(`UPDATE users SET feed_visible = 0, deleted_requested_at = datetime('now') WHERE id = ?`).run(uid);
24
+ res.json({
25
+ ok: true,
26
+ cooldown_days: 7,
27
+ wipe_after_days: 14,
28
+ notice: '账号已进入注销冷却期。7 天内可撤销,14 天后 PII 永久擦除(笔记/订单匿名化保留)。',
29
+ });
30
+ });
31
+ app.post('/api/me/delete-cancel', (req, res) => {
32
+ const user = auth(req, res);
33
+ if (!user)
34
+ return;
35
+ const uid = user.id;
36
+ const req_row = db.prepare(`SELECT * FROM account_deletion_requests WHERE user_id = ? AND cancelled_at IS NULL AND pii_wiped_at IS NULL`).get(uid);
37
+ if (!req_row)
38
+ return void res.status(404).json({ error: 'no active deletion request' });
39
+ const reqTs = new Date(req_row.requested_at.replace(' ', 'T') + 'Z').getTime();
40
+ if (Date.now() - reqTs > 7 * 86400_000)
41
+ return void res.status(400).json({ error: '冷却期已过,无法撤销' });
42
+ db.prepare(`UPDATE account_deletion_requests SET cancelled_at = datetime('now') WHERE user_id = ?`).run(uid);
43
+ db.prepare(`UPDATE users SET feed_visible = 1, deleted_requested_at = NULL WHERE id = ?`).run(uid);
44
+ res.json({ ok: true, message: '账号注销已撤销' });
45
+ });
46
+ app.get('/api/me/delete-status', (req, res) => {
47
+ const user = auth(req, res);
48
+ if (!user)
49
+ return;
50
+ const row = db.prepare(`SELECT requested_at, cancelled_at, pii_wiped_at, reason FROM account_deletion_requests WHERE user_id = ?`).get(user.id);
51
+ res.json({ deletion_request: row || null });
52
+ });
53
+ }
@@ -0,0 +1,105 @@
1
+ export function registerAddressesRoutes(app, deps) {
2
+ const { db, generateId, auth, isTrustedRole, errorRes } = deps;
3
+ app.get('/api/addresses', (req, res) => {
4
+ const user = auth(req, res);
5
+ if (!user)
6
+ return;
7
+ if (isTrustedRole(user))
8
+ return void errorRes(res, 403, 'TRUSTED_ROLE_NO_TRADE', '受信角色无购物功能');
9
+ const rows = db.prepare(`SELECT id, label, recipient, phone, region, detail, is_default, created_at
10
+ FROM user_addresses WHERE user_id = ? ORDER BY is_default DESC, created_at DESC LIMIT 50`).all(user.id);
11
+ res.json({ items: rows });
12
+ });
13
+ app.post('/api/addresses', (req, res) => {
14
+ const user = auth(req, res);
15
+ if (!user)
16
+ return;
17
+ if (isTrustedRole(user))
18
+ return void errorRes(res, 403, 'TRUSTED_ROLE_NO_TRADE', '受信角色无购物功能');
19
+ const { label, recipient, phone, region, detail, is_default } = req.body || {};
20
+ if (!label || !recipient || !detail)
21
+ return void res.status(400).json({ error: '标签 / 收件人 / 详细地址必填' });
22
+ if (String(label).length > 30 || String(recipient).length > 60 || String(detail).length > 200) {
23
+ return void res.status(400).json({ error: '字段超长(标签≤30, 收件人≤60, 详址≤200)' });
24
+ }
25
+ const cnt = db.prepare('SELECT COUNT(*) as n FROM user_addresses WHERE user_id = ?').get(user.id).n;
26
+ if (cnt >= 20)
27
+ return void res.status(400).json({ error: '地址数量已达上限 (20)' });
28
+ const id = generateId('adr');
29
+ db.transaction(() => {
30
+ if (is_default || cnt === 0) {
31
+ db.prepare('UPDATE user_addresses SET is_default = 0 WHERE user_id = ?').run(user.id);
32
+ }
33
+ db.prepare(`INSERT INTO user_addresses (id, user_id, label, recipient, phone, region, detail, is_default) VALUES (?,?,?,?,?,?,?,?)`)
34
+ .run(id, user.id, String(label), String(recipient), phone ? String(phone) : null, region ? String(region) : null, String(detail), (is_default || cnt === 0) ? 1 : 0);
35
+ })();
36
+ res.json({ success: true, id });
37
+ });
38
+ app.patch('/api/addresses/:id', (req, res) => {
39
+ const user = auth(req, res);
40
+ if (!user)
41
+ return;
42
+ const row = db.prepare('SELECT user_id FROM user_addresses WHERE id = ?').get(req.params.id);
43
+ if (!row)
44
+ return void res.status(404).json({ error: '地址不存在' });
45
+ if (row.user_id !== user.id)
46
+ return void res.status(403).json({ error: '无权限' });
47
+ const { label, recipient, phone, region, detail, is_default } = req.body || {};
48
+ const sets = [];
49
+ const args = [];
50
+ if (label !== undefined) {
51
+ sets.push('label = ?');
52
+ args.push(String(label).slice(0, 30));
53
+ }
54
+ if (recipient !== undefined) {
55
+ sets.push('recipient = ?');
56
+ args.push(String(recipient).slice(0, 60));
57
+ }
58
+ if (phone !== undefined) {
59
+ sets.push('phone = ?');
60
+ args.push(phone ? String(phone).slice(0, 30) : null);
61
+ }
62
+ if (region !== undefined) {
63
+ sets.push('region = ?');
64
+ args.push(region ? String(region).slice(0, 60) : null);
65
+ }
66
+ if (detail !== undefined) {
67
+ sets.push('detail = ?');
68
+ args.push(String(detail).slice(0, 200));
69
+ }
70
+ if (sets.length === 0 && is_default === undefined)
71
+ return void res.status(400).json({ error: '无可更新字段' });
72
+ db.transaction(() => {
73
+ if (sets.length > 0) {
74
+ sets.push(`updated_at = datetime('now')`);
75
+ args.push(req.params.id);
76
+ db.prepare(`UPDATE user_addresses SET ${sets.join(', ')} WHERE id = ?`).run(...args);
77
+ }
78
+ if (is_default) {
79
+ db.prepare('UPDATE user_addresses SET is_default = 0 WHERE user_id = ?').run(user.id);
80
+ db.prepare('UPDATE user_addresses SET is_default = 1 WHERE id = ?').run(req.params.id);
81
+ }
82
+ })();
83
+ res.json({ success: true });
84
+ });
85
+ app.delete('/api/addresses/:id', (req, res) => {
86
+ const user = auth(req, res);
87
+ if (!user)
88
+ return;
89
+ const row = db.prepare('SELECT user_id, is_default FROM user_addresses WHERE id = ?').get(req.params.id);
90
+ if (!row)
91
+ return void res.status(404).json({ error: '地址不存在' });
92
+ if (row.user_id !== user.id)
93
+ return void res.status(403).json({ error: '无权限' });
94
+ db.transaction(() => {
95
+ db.prepare('DELETE FROM user_addresses WHERE id = ?').run(req.params.id);
96
+ // 若删了默认地址,挑一个最近的设为默认
97
+ if (row.is_default) {
98
+ const next = db.prepare('SELECT id FROM user_addresses WHERE user_id = ? ORDER BY created_at DESC LIMIT 1').get(user.id);
99
+ if (next)
100
+ db.prepare('UPDATE user_addresses SET is_default = 1 WHERE id = ?').run(next.id);
101
+ }
102
+ })();
103
+ res.json({ success: true });
104
+ });
105
+ }
@@ -0,0 +1,151 @@
1
+ export function registerAdminAdminsRoutes(app, deps) {
2
+ const { db, generateId, requireAdmin, requireRootAdmin, isRootAdmin, getAdminPermissions, ADMIN_PERMISSIONS } = deps;
3
+ // GET 全部 admin 列表
4
+ app.get('/api/admin/admins', (req, res) => {
5
+ const me = requireAdmin(req, res);
6
+ if (!me)
7
+ return;
8
+ const items = db.prepare(`
9
+ SELECT id, name, handle, role, admin_type, admin_scope, admin_permissions, email, created_at,
10
+ (SELECT MAX(created_at) FROM admin_audit_log WHERE admin_id = users.id) AS last_action_at
11
+ FROM users
12
+ WHERE role = 'admin' OR (roles IS NOT NULL AND roles LIKE '%admin%')
13
+ ORDER BY admin_type DESC, created_at ASC
14
+ `).all();
15
+ // 普通 admin 视角下 email 脱敏
16
+ const masked = items.map((u) => {
17
+ const enriched = { ...u, admin_permissions: u.admin_type === 'root' ? ['all'] : (() => { try {
18
+ return JSON.parse(u.admin_permissions || '[]');
19
+ }
20
+ catch {
21
+ return [];
22
+ } })() };
23
+ return isRootAdmin(me) ? enriched : ({ ...enriched, email: u.email ? '***' : null });
24
+ });
25
+ res.json({
26
+ items: masked,
27
+ my_type: (me.admin_type || 'root'),
28
+ my_scope: (me.admin_scope || 'global'),
29
+ my_permissions: getAdminPermissions(me),
30
+ available_permissions: ADMIN_PERMISSIONS,
31
+ });
32
+ });
33
+ // POST 创建 admin(仅 root)
34
+ app.post('/api/admin/admins', (req, res) => {
35
+ const root = requireRootAdmin(req, res);
36
+ if (!root)
37
+ return;
38
+ const body = req.body;
39
+ const targetUserId = String(body.user_id || '').trim();
40
+ const adminType = String(body.admin_type || 'regional').trim();
41
+ const adminScope = String(body.admin_scope || 'global').trim();
42
+ let adminPerms = [];
43
+ if (adminType === 'regional') {
44
+ const raw = body.admin_permissions;
45
+ if (Array.isArray(raw))
46
+ adminPerms = raw.map(String).filter(Boolean);
47
+ else if (typeof raw === 'string')
48
+ adminPerms = raw.split(',').map(s => s.trim()).filter(Boolean);
49
+ const validPerms = new Set(['all', ...ADMIN_PERMISSIONS]);
50
+ for (const p of adminPerms) {
51
+ if (!validPerms.has(p))
52
+ return void res.json({ error: `权限 "${p}" 无效;支持: all / ${ADMIN_PERMISSIONS.join(' / ')}` });
53
+ }
54
+ if (adminPerms.length === 0)
55
+ return void res.json({ error: 'regional admin 必须至少授予一项权限' });
56
+ }
57
+ if (!targetUserId)
58
+ return void res.json({ error: '必须指定 user_id' });
59
+ if (!['root', 'regional'].includes(adminType))
60
+ return void res.json({ error: 'admin_type 无效' });
61
+ if (!['global', 'china', 'us', 'eu', 'india', 'singapore', 'global_north'].includes(adminScope))
62
+ return void res.json({ error: 'admin_scope 无效' });
63
+ const target = db.prepare(`SELECT id, role, roles, admin_type FROM users WHERE id = ?`).get(targetUserId);
64
+ if (!target)
65
+ return void res.json({ error: '用户不存在' });
66
+ if (target.admin_type)
67
+ return void res.json({ error: '该用户已是 admin(' + target.admin_type + ');如需调整请先撤销再重建' });
68
+ // 受信角色锁:目标用户不能已有 buyer/seller
69
+ const targetRoles = (() => { try {
70
+ return JSON.parse(target.roles || '[]');
71
+ }
72
+ catch {
73
+ return [target.role];
74
+ } })();
75
+ const conflictRoles = targetRoles.filter(r => ['buyer', 'seller'].includes(r));
76
+ if (conflictRoles.length > 0) {
77
+ return void res.json({ error: `目标用户已有 ${conflictRoles.join('/')} 角色,与 admin 冲突。请先用脚本剥离或换一个纯净账号。` });
78
+ }
79
+ const permsJson = adminType === 'root' ? null : JSON.stringify(adminPerms);
80
+ db.transaction(() => {
81
+ const newRoles = Array.from(new Set([...targetRoles, 'admin']));
82
+ db.prepare(`UPDATE users SET role = 'admin', roles = ?, admin_type = ?, admin_scope = ?, admin_permissions = ?, updated_at = datetime('now') WHERE id = ?`)
83
+ .run(JSON.stringify(newRoles), adminType, adminScope, permsJson, targetUserId);
84
+ db.prepare(`INSERT INTO admin_audit_log (id, admin_id, action, target_type, target_id, detail) VALUES (?,?,?,?,?,?)`)
85
+ .run(generateId('audit'), root.id, 'admin_create', 'user', targetUserId, JSON.stringify({ admin_type: adminType, admin_scope: adminScope, admin_permissions: adminPerms }));
86
+ })();
87
+ res.json({ ok: true, user_id: targetUserId, admin_type: adminType, admin_scope: adminScope, admin_permissions: adminPerms });
88
+ });
89
+ // PATCH 更新权限(root only)
90
+ app.patch('/api/admin/admins/:id/permissions', (req, res) => {
91
+ const root = requireRootAdmin(req, res);
92
+ if (!root)
93
+ return;
94
+ const targetId = req.params.id;
95
+ const body = req.body;
96
+ const adminScope = body.admin_scope ? String(body.admin_scope) : null;
97
+ const raw = body.admin_permissions;
98
+ let adminPerms = [];
99
+ if (Array.isArray(raw))
100
+ adminPerms = raw.map(String).filter(Boolean);
101
+ const validPerms = new Set(['all', ...ADMIN_PERMISSIONS]);
102
+ for (const p of adminPerms)
103
+ if (!validPerms.has(p))
104
+ return void res.json({ error: `权限 "${p}" 无效` });
105
+ const target = db.prepare(`SELECT id, admin_type FROM users WHERE id = ?`).get(targetId);
106
+ if (!target?.admin_type)
107
+ return void res.json({ error: '该用户不是 admin' });
108
+ if (target.admin_type === 'root')
109
+ return void res.json({ error: 'root admin 权限不可手动调整(永远是 all)' });
110
+ if (adminPerms.length === 0)
111
+ return void res.json({ error: '至少保留一项权限' });
112
+ db.prepare(`UPDATE users SET admin_permissions = ?${adminScope ? ', admin_scope = ?' : ''} WHERE id = ?`)
113
+ .run(JSON.stringify(adminPerms), ...(adminScope ? [adminScope, targetId] : [targetId]));
114
+ db.prepare(`INSERT INTO admin_audit_log (id, admin_id, action, target_type, target_id, detail) VALUES (?,?,?,?,?,?)`)
115
+ .run(generateId('audit'), root.id, 'admin_update_perms', 'user', targetId, JSON.stringify({ admin_permissions: adminPerms, admin_scope: adminScope }));
116
+ res.json({ ok: true, admin_permissions: adminPerms, admin_scope: adminScope });
117
+ });
118
+ // DELETE 撤销 admin(root only;不能撤自己;至少保留 1 个 root)
119
+ app.delete('/api/admin/admins/:id', (req, res) => {
120
+ const root = requireRootAdmin(req, res);
121
+ if (!root)
122
+ return;
123
+ const targetId = req.params.id;
124
+ if (targetId === root.id)
125
+ return void res.json({ error: '不能撤销自己' });
126
+ const target = db.prepare(`SELECT id, name, admin_type FROM users WHERE id = ?`).get(targetId);
127
+ if (!target || !target.admin_type)
128
+ return void res.json({ error: '该用户不是 admin' });
129
+ // 保护:至少保留 1 个 root
130
+ if (target.admin_type === 'root') {
131
+ const rootCount = db.prepare(`SELECT COUNT(1) as n FROM users WHERE admin_type = 'root'`).get().n;
132
+ if (rootCount <= 1)
133
+ return void res.json({ error: '至少保留 1 个 root admin,不可撤销最后一个' });
134
+ }
135
+ db.transaction(() => {
136
+ const cur = db.prepare(`SELECT roles FROM users WHERE id = ?`).get(targetId);
137
+ let rolesArr = [];
138
+ try {
139
+ rolesArr = JSON.parse(cur.roles || '[]');
140
+ }
141
+ catch { }
142
+ rolesArr = rolesArr.filter(r => r !== 'admin');
143
+ const newRole = rolesArr[0] || 'buyer';
144
+ db.prepare(`UPDATE users SET role = ?, roles = ?, admin_type = NULL, admin_scope = NULL, updated_at = datetime('now') WHERE id = ?`)
145
+ .run(newRole, JSON.stringify(rolesArr), targetId);
146
+ db.prepare(`INSERT INTO admin_audit_log (id, admin_id, action, target_type, target_id, detail) VALUES (?,?,?,?,?,?)`)
147
+ .run(generateId('audit'), root.id, 'admin_revoke', 'user', targetId, JSON.stringify({ revoked_type: target.admin_type, new_role: newRole }));
148
+ })();
149
+ res.json({ ok: true });
150
+ });
151
+ }
@@ -0,0 +1,253 @@
1
+ export function registerAdminAnalyticsRoutes(app, deps) {
2
+ const { db, adminAuth, requireAdmin, requireRootAdmin, getProtocolParam, INTERNAL_AUDITOR_ID } = deps;
3
+ app.get('/api/admin/usage', (req, res) => {
4
+ if (!adminAuth(req, res))
5
+ return;
6
+ const total = db.prepare(`SELECT COUNT(*) as n FROM mcp_tool_calls`).get();
7
+ const total24h = db.prepare(`SELECT COUNT(*) as n FROM mcp_tool_calls WHERE ts > datetime('now','-1 day')`).get();
8
+ const total7d = db.prepare(`SELECT COUNT(*) as n FROM mcp_tool_calls WHERE ts > datetime('now','-7 day')`).get();
9
+ const totalUsers = db.prepare(`SELECT COUNT(DISTINCT user_id_hash) as n FROM mcp_tool_calls WHERE user_id_hash IS NOT NULL`).get();
10
+ const wau7d = db.prepare(`SELECT COUNT(DISTINCT user_id_hash) as n FROM mcp_tool_calls WHERE user_id_hash IS NOT NULL AND ts > datetime('now','-7 day')`).get();
11
+ const dau24h = db.prepare(`SELECT COUNT(DISTINCT user_id_hash) as n FROM mcp_tool_calls WHERE user_id_hash IS NOT NULL AND ts > datetime('now','-1 day')`).get();
12
+ const byTool = db.prepare(`
13
+ SELECT tool_name,
14
+ COUNT(*) AS calls,
15
+ SUM(CASE WHEN outcome='error' THEN 1 ELSE 0 END) AS errors,
16
+ ROUND(AVG(latency_ms), 0) AS avg_latency_ms
17
+ FROM mcp_tool_calls WHERE ts > datetime('now','-7 day')
18
+ GROUP BY tool_name ORDER BY calls DESC
19
+ `).all();
20
+ const byDay = db.prepare(`
21
+ SELECT substr(ts, 1, 10) AS day,
22
+ COUNT(*) AS calls,
23
+ COUNT(DISTINCT user_id_hash) AS distinct_users
24
+ FROM mcp_tool_calls WHERE ts > datetime('now','-14 day')
25
+ GROUP BY day ORDER BY day
26
+ `).all();
27
+ const byVersion = db.prepare(`
28
+ SELECT server_version,
29
+ COUNT(*) AS calls,
30
+ COUNT(DISTINCT user_id_hash) AS distinct_users
31
+ FROM mcp_tool_calls WHERE ts > datetime('now','-7 day')
32
+ GROUP BY server_version ORDER BY calls DESC
33
+ `).all();
34
+ res.json({
35
+ summary: {
36
+ total_calls: total.n,
37
+ total_calls_24h: total24h.n,
38
+ total_calls_7d: total7d.n,
39
+ distinct_users_all: totalUsers.n,
40
+ dau_24h: dau24h.n,
41
+ wau_7d: wau7d.n,
42
+ },
43
+ by_tool_7d: byTool,
44
+ by_day_14d: byDay,
45
+ by_version_7d: byVersion,
46
+ });
47
+ });
48
+ app.get('/api/admin/auditor', (req, res) => {
49
+ const user = requireRootAdmin(req, res);
50
+ if (!user)
51
+ return;
52
+ const auditor = db.prepare('SELECT id, name, api_key, created_at FROM users WHERE id = ?')
53
+ .get(INTERNAL_AUDITOR_ID);
54
+ if (!auditor)
55
+ return void res.json({ error: '内部审核账号未初始化' });
56
+ res.json({ id: auditor.id, name: auditor.name, api_key: auditor.api_key });
57
+ });
58
+ app.get('/api/admin/finance/monthly', (req, res) => {
59
+ const admin = requireAdmin(req, res);
60
+ if (!admin)
61
+ return;
62
+ const months = Math.max(3, Math.min(24, Number(req.query.months) || 12));
63
+ const feeShop = getProtocolParam('protocol_fee_rate_shop', 0.02);
64
+ const feeSecondhand = getProtocolParam('protocol_fee_rate_secondhand', 0.01);
65
+ const orderRows = db.prepare(`
66
+ SELECT strftime('%Y-%m', created_at) as ym,
67
+ COALESCE(SUM(CASE WHEN source = 'secondhand' THEN total_amount * ? ELSE total_amount * ? END), 0) as fee,
68
+ COALESCE(SUM(total_amount), 0) as gmv,
69
+ COUNT(*) as orders_count
70
+ FROM orders
71
+ WHERE status = 'completed'
72
+ AND created_at > datetime('now', '-' || ? || ' months')
73
+ GROUP BY ym ORDER BY ym DESC
74
+ `).all(feeSecondhand, feeShop, months);
75
+ const rewardRows = db.prepare(`
76
+ SELECT strftime('%Y-%m', created_at) as ym, COALESCE(SUM(amount), 0) as rewards, COUNT(*) as count
77
+ FROM platform_reward_log
78
+ WHERE created_at > datetime('now', '-' || ? || ' months')
79
+ GROUP BY ym ORDER BY ym DESC
80
+ `).all(months);
81
+ const byMonth = new Map();
82
+ for (const o of orderRows)
83
+ byMonth.set(o.ym, { ym: o.ym, fee: o.fee, gmv: o.gmv, orders: o.orders_count, rewards: 0, reward_count: 0 });
84
+ for (const r of rewardRows) {
85
+ const m = byMonth.get(r.ym);
86
+ if (m) {
87
+ m.rewards = r.rewards;
88
+ m.reward_count = r.count;
89
+ }
90
+ else
91
+ byMonth.set(r.ym, { ym: r.ym, fee: 0, gmv: 0, orders: 0, rewards: r.rewards, reward_count: r.count });
92
+ }
93
+ const rows = [...byMonth.values()].sort((a, b) => b.ym.localeCompare(a.ym));
94
+ const totalFee = rows.reduce((s, r) => s + r.fee, 0);
95
+ const totalRewards = rows.reduce((s, r) => s + r.rewards, 0);
96
+ const totalGmv = rows.reduce((s, r) => s + r.gmv, 0);
97
+ const sysWallet = db.prepare("SELECT balance FROM wallets WHERE user_id = 'sys_protocol'").get();
98
+ res.json({
99
+ months,
100
+ fee_rate_shop: feeShop,
101
+ fee_rate_secondhand: feeSecondhand,
102
+ monthly: rows.map(r => ({ ...r, net: r.fee - r.rewards })),
103
+ totals: {
104
+ fee_revenue: totalFee,
105
+ reward_expenditure: totalRewards,
106
+ net: totalFee - totalRewards,
107
+ gmv: totalGmv,
108
+ sys_protocol_balance: Number(sysWallet?.balance || 0),
109
+ },
110
+ });
111
+ });
112
+ app.get('/api/admin/protocol-kpi', (req, res) => {
113
+ const admin = requireAdmin(req, res);
114
+ if (!admin)
115
+ return;
116
+ const windowCounts = (label, days) => {
117
+ const t = `datetime('now','-${days} days')`;
118
+ const orders = db.prepare(`SELECT COUNT(*) as n, COALESCE(SUM(total_amount),0) as gmv FROM orders WHERE created_at > ${t}`).get();
119
+ const completed = db.prepare(`SELECT COUNT(*) as n FROM orders WHERE status='completed' AND created_at > ${t}`).get().n;
120
+ const disputes = db.prepare(`SELECT COUNT(*) as n FROM disputes WHERE created_at > ${t}`).get().n;
121
+ const refunds = db.prepare(`SELECT COUNT(*) as n FROM return_requests WHERE status='refunded' AND created_at > ${t}`).get().n;
122
+ const newUsers = db.prepare(`SELECT COUNT(*) as n FROM users WHERE created_at > ${t} AND id NOT IN ('sys_protocol', ?)`).get(INTERNAL_AUDITOR_ID).n;
123
+ return { label, days, orders: orders.n, gmv: orders.gmv, completed, disputes, refunds, new_users: newUsers,
124
+ dispute_rate: orders.n > 0 ? disputes / orders.n : 0,
125
+ refund_rate: completed > 0 ? refunds / completed : 0,
126
+ };
127
+ };
128
+ const dauProxy = db.prepare(`
129
+ SELECT COUNT(DISTINCT u_id) as n FROM (
130
+ SELECT buyer_id as u_id FROM orders WHERE created_at > datetime('now', '-1 day')
131
+ UNION SELECT seller_id FROM orders WHERE created_at > datetime('now', '-1 day')
132
+ UNION SELECT buyer_id FROM order_ratings WHERE created_at > datetime('now', '-1 day')
133
+ UNION SELECT user_id FROM daily_checkins WHERE checkin_date >= date('now', '-1 day')
134
+ UNION SELECT user_id FROM feedback_tickets WHERE created_at > datetime('now', '-1 day')
135
+ )
136
+ `).get().n;
137
+ const mauProxy = db.prepare(`
138
+ SELECT COUNT(DISTINCT u_id) as n FROM (
139
+ SELECT buyer_id as u_id FROM orders WHERE created_at > datetime('now', '-30 days')
140
+ UNION SELECT seller_id FROM orders WHERE created_at > datetime('now', '-30 days')
141
+ UNION SELECT buyer_id FROM order_ratings WHERE created_at > datetime('now', '-30 days')
142
+ UNION SELECT user_id FROM daily_checkins WHERE checkin_date >= date('now', '-30 days')
143
+ UNION SELECT user_id FROM feedback_tickets WHERE created_at > datetime('now', '-30 days')
144
+ )
145
+ `).get().n;
146
+ const userTotals = db.prepare(`
147
+ SELECT
148
+ SUM(CASE WHEN role='buyer' THEN 1 ELSE 0 END) as buyers,
149
+ SUM(CASE WHEN role='seller' THEN 1 ELSE 0 END) as sellers,
150
+ SUM(CASE WHEN role='logistics' THEN 1 ELSE 0 END) as logistics,
151
+ SUM(CASE WHEN role='verifier' THEN 1 ELSE 0 END) as verifiers,
152
+ SUM(CASE WHEN role='arbitrator' THEN 1 ELSE 0 END) as arbitrators,
153
+ SUM(CASE WHEN role='admin' THEN 1 ELSE 0 END) as admins,
154
+ COUNT(*) as total
155
+ FROM users WHERE id NOT IN ('sys_protocol', ?)
156
+ `).get(INTERNAL_AUDITOR_ID);
157
+ const sysWallet = db.prepare("SELECT balance FROM wallets WHERE user_id = 'sys_protocol'").get();
158
+ const totalEscrowed = db.prepare("SELECT COALESCE(SUM(escrowed),0) as t FROM wallets").get().t;
159
+ const totalStaked = db.prepare("SELECT COALESCE(SUM(staked),0) as t FROM wallets").get().t;
160
+ const platformRewards = db.prepare("SELECT COALESCE(SUM(amount),0) as t FROM platform_reward_log").get().t;
161
+ const platformRewardsToday = db.prepare("SELECT COALESCE(SUM(amount),0) as t FROM platform_reward_log WHERE created_at > datetime('now','-1 day')").get().t;
162
+ const products = db.prepare("SELECT COUNT(*) as n, SUM(CASE WHEN status='active' THEN 1 ELSE 0 END) as active FROM products").get();
163
+ const ratings = db.prepare("SELECT COUNT(*) as n FROM order_ratings").get().n;
164
+ const subs = db.prepare("SELECT COUNT(*) as n FROM push_subscriptions WHERE enabled=1").get().n;
165
+ const trustOpen = db.prepare(`
166
+ SELECT
167
+ (SELECT COUNT(*) FROM disputes WHERE status IN ('open','in_review')) as disputes_open,
168
+ (SELECT COUNT(*) FROM feedback_tickets WHERE status IN ('open','in_progress')) as feedback_open,
169
+ (SELECT COUNT(*) FROM return_requests WHERE status='pending') as returns_pending
170
+ `).get();
171
+ res.json({
172
+ activity: {
173
+ dau_proxy: dauProxy,
174
+ mau_proxy: mauProxy,
175
+ windows: [windowCounts('24h', 1), windowCounts('7d', 7), windowCounts('30d', 30)],
176
+ },
177
+ users: userTotals,
178
+ finance: {
179
+ sys_protocol_balance: Number(sysWallet?.balance || 0),
180
+ total_escrowed: totalEscrowed,
181
+ total_staked: totalStaked,
182
+ platform_rewards_cumulative: platformRewards,
183
+ platform_rewards_today: platformRewardsToday,
184
+ },
185
+ content: {
186
+ products_total: products.n,
187
+ products_active: products.active,
188
+ ratings_total: ratings,
189
+ push_subscriptions: subs,
190
+ },
191
+ trust_open: trustOpen,
192
+ });
193
+ });
194
+ app.get('/api/admin/dashboard', (req, res) => {
195
+ const admin = requireAdmin(req, res);
196
+ if (!admin)
197
+ return;
198
+ const u = db.prepare("SELECT COUNT(*) as n FROM users WHERE id NOT IN ('sys_protocol', ?)").get(INTERNAL_AUDITOR_ID);
199
+ const sellers = db.prepare("SELECT COUNT(*) as n FROM users WHERE role = 'seller' AND id NOT IN ('sys_protocol', ?)").get(INTERNAL_AUDITOR_ID);
200
+ const active = db.prepare("SELECT COUNT(*) as n FROM products WHERE status = 'active'").get();
201
+ const o24 = db.prepare("SELECT COUNT(*) as n, COALESCE(SUM(total_amount),0) as gmv FROM orders WHERE created_at > datetime('now','-1 day')").get();
202
+ const dOpen = db.prepare("SELECT COUNT(*) as n FROM disputes WHERE status IN ('open','in_review')").get();
203
+ const vOpen = db.prepare("SELECT COUNT(*) as n FROM verify_tasks WHERE status IN ('open','code_issued')").get();
204
+ const locked = db.prepare("SELECT COALESCE(SUM(staked + escrowed),0) as t FROM wallets").get();
205
+ const sus = db.prepare("SELECT COUNT(*) as n FROM user_moderation WHERE suspended = 1").get();
206
+ const verifierApps = db.prepare("SELECT COUNT(*) as n FROM verifier_applications WHERE status = 'pending'").get();
207
+ const verifierAppeals = db.prepare("SELECT COUNT(*) as n FROM verifier_appeals WHERE status = 'pending'").get();
208
+ const quotaApps = db.prepare("SELECT COUNT(*) as n FROM quota_increase_applications WHERE status = 'pending'").get();
209
+ const listingPaused = db.prepare("SELECT COUNT(*) as n FROM users WHERE listing_paused = 1").get();
210
+ const activeVerifiers = db.prepare(`
211
+ SELECT COUNT(*) as n FROM verifier_whitelist vw
212
+ LEFT JOIN verifier_stats vs ON vs.user_id = vw.user_id
213
+ WHERE (vw.cooldown_until IS NULL OR vw.cooldown_until < datetime('now'))
214
+ AND (vs.suspended_until IS NULL OR vs.suspended_until < datetime('now'))
215
+ `).get();
216
+ const tokenomics = (() => {
217
+ const gf = db.prepare("SELECT pool_balance, total_scores_pending, current_n, last_settled_at FROM global_fund WHERE id=1").get();
218
+ const mb = db.prepare("SELECT balance FROM management_bonus_pool WHERE id=1").get();
219
+ const pendingLedger = db.prepare("SELECT COUNT(*) as n FROM pv_ledger WHERE processed = 0").get().n;
220
+ const commCount = db.prepare("SELECT COUNT(*) as n, COALESCE(SUM(amount),0) as t FROM commission_records").get();
221
+ const dirtyUsers = db.prepare("SELECT COUNT(*) as n FROM users WHERE pv_dirty_at IS NOT NULL").get().n;
222
+ const matchedTotal = db.prepare("SELECT COUNT(*) as n, COALESCE(SUM(waz_amount),0) as w FROM binary_score_records WHERE settled_at IS NOT NULL").get();
223
+ return {
224
+ pool_balance: Number(gf?.pool_balance ?? 0),
225
+ scores_pending: Number(gf?.total_scores_pending ?? 0),
226
+ current_n: Number(gf?.current_n ?? 0),
227
+ last_settled_at: gf?.last_settled_at,
228
+ management_bonus: Number(mb?.balance ?? 0),
229
+ ledger_pending: pendingLedger,
230
+ dirty_users: dirtyUsers,
231
+ commission_records: commCount.n,
232
+ commission_total: commCount.t,
233
+ binary_settled: matchedTotal.n,
234
+ binary_waz_total: matchedTotal.w,
235
+ };
236
+ })();
237
+ res.json({
238
+ users: u.n, sellers: sellers.n,
239
+ products_active: active.n,
240
+ orders_24h: o24.n, gmv_24h: o24.gmv,
241
+ disputes_open: dOpen.n,
242
+ verify_tasks_open: vOpen.n,
243
+ total_locked: locked.t,
244
+ users_suspended: sus.n,
245
+ verifier_apps_pending: verifierApps.n,
246
+ verifier_appeals_pending: verifierAppeals.n,
247
+ active_verifiers: activeVerifiers.n,
248
+ quota_apps_pending: quotaApps.n,
249
+ listing_paused_count: listingPaused.n,
250
+ tokenomics,
251
+ });
252
+ });
253
+ }
@@ -0,0 +1,21 @@
1
+ export function registerAdminAtomicRoutes(app, deps) {
2
+ const { requireProtocolAdmin, processPvLedger, runBinarySettlement, executeSafeSettlementCron } = deps;
3
+ app.post('/api/admin/atomic/process-ledger', (req, res) => {
4
+ const admin = requireProtocolAdmin(req, res);
5
+ if (!admin)
6
+ return;
7
+ res.json({ processed: processPvLedger() });
8
+ });
9
+ app.post('/api/admin/atomic/run-settlement', (req, res) => {
10
+ const admin = requireProtocolAdmin(req, res);
11
+ if (!admin)
12
+ return;
13
+ res.json({ settled: runBinarySettlement() });
14
+ });
15
+ app.post('/api/admin/atomic/distribute', (req, res) => {
16
+ const admin = requireProtocolAdmin(req, res);
17
+ if (!admin)
18
+ return;
19
+ res.json(executeSafeSettlementCron());
20
+ });
21
+ }
@@ -0,0 +1,64 @@
1
+ export function registerAdminCatalogRoutes(app, deps) {
2
+ const { db, requireContentAdmin, logAdminAction } = deps;
3
+ // ─── 类目 季节性配置 ─────────────────────────────────────
4
+ app.post('/api/admin/categories/:id/seasonal', (req, res) => {
5
+ const admin = requireContentAdmin(req, res);
6
+ if (!admin)
7
+ return;
8
+ const { months } = req.body;
9
+ if (!Array.isArray(months) || months.length === 0) {
10
+ return void res.json({ error: 'months 必须是非空数组(1-12 的整数)' });
11
+ }
12
+ const valid = months.filter(m => Number.isInteger(m) && m >= 1 && m <= 12);
13
+ if (valid.length === 0)
14
+ return void res.json({ error: '没有有效的月份(1-12)' });
15
+ const cat = db.prepare('SELECT id, name FROM product_categories WHERE id = ?').get(req.params.id);
16
+ if (!cat)
17
+ return void res.status(404).json({ error: 'category 不存在' });
18
+ const csv = [...new Set(valid)].sort((a, b) => a - b).join(',');
19
+ db.prepare('UPDATE product_categories SET seasonal_months = ? WHERE id = ?').run(csv, req.params.id);
20
+ res.json({ success: true, category: cat.name, seasonal_months: csv });
21
+ });
22
+ app.delete('/api/admin/categories/:id/seasonal', (req, res) => {
23
+ const admin = requireContentAdmin(req, res);
24
+ if (!admin)
25
+ return;
26
+ db.prepare('UPDATE product_categories SET seasonal_months = NULL WHERE id = ?').run(req.params.id);
27
+ res.json({ success: true });
28
+ });
29
+ // ─── 商品 列表 + 强制下架 ───────────────────────────────
30
+ app.get('/api/admin/products', (req, res) => {
31
+ const admin = requireContentAdmin(req, res);
32
+ if (!admin)
33
+ return;
34
+ const status = req.query.status;
35
+ let sql = `SELECT p.id, p.title, p.price, p.stock, p.status, p.category, p.seller_id, p.created_at,
36
+ u.name as seller_name
37
+ FROM products p JOIN users u ON p.seller_id = u.id`;
38
+ const params = [];
39
+ if (status && status.trim()) {
40
+ sql += ` WHERE p.status = ?`;
41
+ params.push(status);
42
+ }
43
+ sql += ` ORDER BY p.created_at DESC LIMIT 100`;
44
+ res.json({ products: db.prepare(sql).all(...params) });
45
+ });
46
+ app.post('/api/admin/products/:id/force-delist', (req, res) => {
47
+ // P0.5: 需 content 权限(之前仅 requireAdmin)
48
+ const admin = requireContentAdmin(req, res);
49
+ if (!admin)
50
+ return;
51
+ const { reason } = req.body;
52
+ const productId = req.params.id;
53
+ const product = db.prepare("SELECT id, status, title FROM products WHERE id = ?").get(productId);
54
+ if (!product)
55
+ return void res.json({ error: '商品不存在' });
56
+ if (product.status === 'deleted')
57
+ return void res.json({ error: '商品已删除' });
58
+ if (product.status === 'paused')
59
+ return void res.json({ error: '商品已是下架状态' });
60
+ db.prepare("UPDATE products SET status = 'paused', updated_at = datetime('now') WHERE id = ?").run(productId);
61
+ logAdminAction(admin.id, 'force_delist', 'product', productId, { reason: reason || null, title: product.title });
62
+ res.json({ success: true });
63
+ });
64
+ }