@seasonkoh/webaz 0.1.8 → 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 +3543 -852
  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 +31230 -2345
  22. package/dist/pwa/public/i18n.js +5282 -111
  23. package/dist/pwa/public/icon.svg +11 -0
  24. package/dist/pwa/public/index.html +4 -1
  25. package/dist/pwa/public/manifest.json +39 -4
  26. package/dist/pwa/public/openapi.json +5946 -0
  27. package/dist/pwa/public/style.css +278 -5
  28. package/dist/pwa/public/sw.js +41 -2
  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 +9247 -2097
  153. package/package.json +8 -3
@@ -0,0 +1,267 @@
1
+ export function registerDisputeCasesRoutes(app, deps) {
2
+ const { db, auth, getUser, generateId, piiSanitize, detectFraud, commentBlocklistHit, llmModerateComment } = deps;
3
+ // 公共发言门槛 — 防新号/小号刷评论/投票
4
+ // 至少满足其一:账户 >= 3 天 / 完成过 >= 1 单 / lifetime_score >= 5
5
+ function meetsPublicSpeechThreshold(user) {
6
+ const lifetime = Number(user.lifetime_score || 0);
7
+ if (lifetime >= 5)
8
+ return { ok: true };
9
+ const completed = db.prepare(`SELECT COUNT(*) as n FROM orders WHERE (buyer_id = ? OR seller_id = ?) AND status = 'completed'`).get(user.id, user.id).n;
10
+ if (completed >= 1)
11
+ return { ok: true };
12
+ const created = user.created_at ? new Date(String(user.created_at).replace(' ', 'T') + 'Z').getTime() : 0;
13
+ if (created > 0 && (Date.now() - created) > 3 * 86400_000)
14
+ return { ok: true };
15
+ return { ok: false, reason: '账号需 ≥ 3 天 或 完成 ≥ 1 单 或 lifetime_score ≥ 5 才能公开发言(防小号刷量)' };
16
+ }
17
+ // 公开列表(全网)— 判例库总览
18
+ app.get('/api/disputes/cases', (req, res) => {
19
+ const limit = Math.min(50, Math.max(5, Number(req.query.limit) || 20));
20
+ const category = req.query.category ? String(req.query.category) : null;
21
+ const winner = req.query.winner ? String(req.query.winner) : null;
22
+ // 2026-05-22 A1:全文搜索 — ruling_text + buyer_argument + seller_argument + product_title
23
+ const q = req.query.q ? String(req.query.q).trim().slice(0, 80) : null;
24
+ // 排序选项 — newest(默认) / discussed(评论多)/ fair(公平评价高)
25
+ const sort = String(req.query.sort || 'newest');
26
+ const where = [];
27
+ const args = [];
28
+ if (category) {
29
+ where.push('category_tag = ?');
30
+ args.push(category);
31
+ }
32
+ if (winner) {
33
+ where.push('winner = ?');
34
+ args.push(winner);
35
+ }
36
+ if (q) {
37
+ where.push(`(
38
+ ruling_text LIKE ? OR buyer_argument LIKE ? OR seller_argument LIKE ? OR resolution LIKE ?
39
+ OR EXISTS (SELECT 1 FROM products p WHERE p.id = dispute_cases.product_id AND p.title LIKE ?)
40
+ )`);
41
+ const pat = '%' + q.replace(/[%_]/g, '\\$&') + '%';
42
+ args.push(pat, pat, pat, pat, pat);
43
+ }
44
+ const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
45
+ let orderSql = 'published_at DESC';
46
+ if (sort === 'discussed')
47
+ orderSql = 'comment_count DESC, fairness_yes DESC, published_at DESC';
48
+ else if (sort === 'fair')
49
+ orderSql = 'fairness_yes DESC, comment_count DESC, published_at DESC';
50
+ const rows = db.prepare(`
51
+ SELECT id, product_id, category_tag, winner, resolution, amount_bucket,
52
+ fairness_yes, fairness_no, comment_count, published_at,
53
+ (SELECT title FROM products WHERE id = dispute_cases.product_id) as product_title,
54
+ (SELECT category FROM products WHERE id = dispute_cases.product_id) as product_category
55
+ FROM dispute_cases
56
+ ${whereSql}
57
+ ORDER BY ${orderSql}
58
+ LIMIT ?
59
+ `).all(...args, limit);
60
+ // 类目统计(侧栏过滤用)— 受 q 影响(搜索时只显匹配 q 的类目计数)
61
+ const catCountWhere = q ? `WHERE (ruling_text LIKE ? OR buyer_argument LIKE ? OR seller_argument LIKE ? OR resolution LIKE ?
62
+ OR EXISTS (SELECT 1 FROM products p WHERE p.id = dispute_cases.product_id AND p.title LIKE ?))` : '';
63
+ const catCountArgs = q ? Array(5).fill('%' + q.replace(/[%_]/g, '\\$&') + '%') : [];
64
+ const categoryCounts = db.prepare(`SELECT category_tag, COUNT(*) as n FROM dispute_cases ${catCountWhere} GROUP BY category_tag ORDER BY n DESC`).all(...catCountArgs);
65
+ res.json({ items: rows, category_counts: categoryCounts, total: rows.length, query: q, sort });
66
+ });
67
+ // 公开列表(按商品)
68
+ app.get('/api/disputes/cases/by-product/:product_id', (req, res) => {
69
+ const rows = db.prepare(`
70
+ SELECT id, category_tag, winner, resolution, amount_bucket, ruling_text,
71
+ fairness_yes, fairness_no, comment_count, published_at
72
+ FROM dispute_cases
73
+ WHERE product_id = ?
74
+ ORDER BY published_at DESC
75
+ LIMIT 50
76
+ `).all(req.params.product_id);
77
+ res.json({ items: rows });
78
+ });
79
+ // 案件详情(含评论 + 评论者身份标签)
80
+ app.get('/api/disputes/cases/:case_id', (req, res) => {
81
+ const me = getUser(req);
82
+ const c = db.prepare(`SELECT * FROM dispute_cases WHERE id = ?`).get(req.params.case_id);
83
+ if (!c)
84
+ return void res.status(404).json({ error: '判例不存在' });
85
+ // 不外露内部 ID(buyer_id/dispute_id/order_id 不返回给前端)
86
+ const safeCase = {
87
+ id: c.id, category_tag: c.category_tag, winner: c.winner, resolution: c.resolution,
88
+ amount_bucket: c.amount_bucket, buyer_argument: c.buyer_argument, seller_argument: c.seller_argument,
89
+ ruling_text: c.ruling_text, fairness_yes: c.fairness_yes, fairness_no: c.fairness_no,
90
+ comment_count: c.comment_count, published_at: c.published_at,
91
+ product_id: c.product_id, seller_id: c.seller_id,
92
+ };
93
+ // 评论 + 自动身份标签
94
+ const rawComments = db.prepare(`
95
+ SELECT dc.*, u.handle, u.name, u.role, u.lifetime_score,
96
+ (SELECT COUNT(*) FROM orders o
97
+ WHERE o.buyer_id = dc.commenter_id AND o.product_id = ? AND o.status = 'completed') as bought_count,
98
+ (SELECT COUNT(*) FROM products p
99
+ WHERE p.seller_id = dc.commenter_id AND p.category = (SELECT category FROM products WHERE id = ?) AND p.status = 'active') as same_cat_seller_count
100
+ FROM dispute_comments dc
101
+ JOIN users u ON u.id = dc.commenter_id
102
+ WHERE dc.case_id = ? AND dc.flagged = 0
103
+ ORDER BY
104
+ (bought_count > 0) DESC,
105
+ (u.role IN ('verifier','arbitrator')) DESC,
106
+ dc.created_at DESC
107
+ LIMIT 50
108
+ `).all(c.product_id, c.product_id, c.id);
109
+ // 脱敏:anonymous=1 时清除 handle/name/commenter_id(保留贡献标签字段:bought/same_cat/role/lifetime)
110
+ // 验证员/仲裁员角色在脱敏时降级为 'staff'(避免小池子反推身份)
111
+ const anonymize = (row) => {
112
+ if (!row.anonymous)
113
+ return row;
114
+ return { ...row, commenter_id: '__anon__', handle: null, name: null,
115
+ role: (row.role === 'verifier' || row.role === 'arbitrator') ? 'staff' : 'user' };
116
+ };
117
+ // W5: 取所有子回复,按 parent_comment_id 分组挂在 comments 下
118
+ const commentIds = rawComments.map(r => r.id);
119
+ const rawReplies = commentIds.length > 0 ? db.prepare(`
120
+ SELECT r.*, u.handle, u.name, u.role, u.lifetime_score
121
+ FROM dispute_comment_replies r LEFT JOIN users u ON u.id = r.replier_id
122
+ WHERE r.parent_comment_id IN (${commentIds.map(() => '?').join(',')})
123
+ ORDER BY r.created_at ASC
124
+ `).all(...commentIds) : [];
125
+ const repliesByParent = new Map();
126
+ for (const r of rawReplies) {
127
+ const pid = String(r.parent_comment_id);
128
+ const arr = repliesByParent.get(pid) || [];
129
+ arr.push(anonymize(r));
130
+ repliesByParent.set(pid, arr);
131
+ }
132
+ const comments = rawComments.map(c => ({
133
+ ...anonymize(c),
134
+ replies: repliesByParent.get(c.id) || [],
135
+ }));
136
+ // 我的公正度投票(如已投)
137
+ let myVote = null;
138
+ if (me) {
139
+ const v = db.prepare('SELECT vote FROM dispute_fairness_votes WHERE case_id = ? AND voter_id = ?').get(c.id, me.id);
140
+ myVote = v?.vote || null;
141
+ }
142
+ // 我是否当事人(决定能否评论 / 投票)
143
+ const isParty = !!me && (me.id === c.buyer_id || me.id === c.seller_id || me.id === c.arbitrator_id);
144
+ res.json({ case: safeCase, comments, my_vote: myVote, is_party: isParty });
145
+ });
146
+ // 写评论 — 当事人禁评,一人一案一次
147
+ app.post('/api/disputes/cases/:case_id/comment', async (req, res) => {
148
+ const user = auth(req, res);
149
+ if (!user)
150
+ return;
151
+ const gate = meetsPublicSpeechThreshold(user);
152
+ if (!gate.ok)
153
+ return void res.status(403).json({ error: gate.reason, error_code: 'SPEECH_THRESHOLD' });
154
+ const c = db.prepare(`SELECT id, buyer_id, seller_id, arbitrator_id FROM dispute_cases WHERE id = ?`).get(req.params.case_id);
155
+ if (!c)
156
+ return void res.status(404).json({ error: '判例不存在' });
157
+ if (user.id === c.buyer_id || user.id === c.seller_id || user.id === c.arbitrator_id) {
158
+ return void res.status(403).json({ error: '当事人禁止评论', error_code: 'PARTY_NO_COMMENT' });
159
+ }
160
+ const rawBody = String(req.body?.body || '').trim();
161
+ if (rawBody.length < 5)
162
+ return void res.status(400).json({ error: '评论至少 5 字' });
163
+ if (rawBody.length > 500)
164
+ return void res.status(400).json({ error: '评论最多 500 字' });
165
+ // P2 评论审核:blocklist → PII 脱敏 → LLM 兜底
166
+ const blocked = commentBlocklistHit(rawBody);
167
+ if (blocked)
168
+ return void res.status(400).json({ error: blocked, error_code: 'COMMENT_BLOCKED' });
169
+ const body = piiSanitize(rawBody);
170
+ const llm = await llmModerateComment(body);
171
+ if (!llm.ok)
172
+ return void res.status(400).json({ error: llm.reason || '内容不符合社区规范', error_code: 'COMMENT_MODERATED' });
173
+ const anonymous = req.body?.anonymous ? 1 : 0;
174
+ // 注:仲裁评论的 flagged 列保留给管理员"隐藏"语义;fraud detect 仅写 flag_reasons
175
+ // 前端按 flag_reasons.length > 0 显示反诈 banner,按 flagged=1 隐藏
176
+ // detectFraud 用 rawBody — piiSanitize 已脱敏的 body 会让电话/银行卡 regex miss
177
+ const reasons = detectFraud(rawBody);
178
+ try {
179
+ db.transaction(() => {
180
+ db.prepare(`INSERT INTO dispute_comments (id, case_id, commenter_id, body, anonymous, flag_reasons) VALUES (?,?,?,?,?,?)`)
181
+ .run(generateId('dcom'), c.id, user.id, body, anonymous, reasons.length ? JSON.stringify(reasons) : null);
182
+ db.prepare(`UPDATE dispute_cases SET comment_count = comment_count + 1 WHERE id = ?`).run(c.id);
183
+ })();
184
+ }
185
+ catch (e) {
186
+ if (e.message?.includes('UNIQUE'))
187
+ return void res.status(400).json({ error: '你已评论过此判例(一案一次)' });
188
+ throw e;
189
+ }
190
+ res.json({ success: true, flag_reasons: reasons });
191
+ });
192
+ // W5 子回复 — 任意人可对顶层评论回复多次(不受"一人一案一次"限制)
193
+ app.post('/api/disputes/cases/:case_id/comments/:comment_id/reply', async (req, res) => {
194
+ const user = auth(req, res);
195
+ if (!user)
196
+ return;
197
+ const gate = meetsPublicSpeechThreshold(user);
198
+ if (!gate.ok)
199
+ return void res.status(403).json({ error: gate.reason, error_code: 'SPEECH_THRESHOLD' });
200
+ const c = db.prepare(`SELECT id, buyer_id, seller_id, arbitrator_id FROM dispute_cases WHERE id = ?`).get(req.params.case_id);
201
+ if (!c)
202
+ return void res.status(404).json({ error: '判例不存在' });
203
+ if (user.id === c.buyer_id || user.id === c.seller_id || user.id === c.arbitrator_id) {
204
+ return void res.status(403).json({ error: '当事人禁止评论', error_code: 'PARTY_NO_COMMENT' });
205
+ }
206
+ const parent = db.prepare(`SELECT id FROM dispute_comments WHERE id = ? AND case_id = ?`).get(req.params.comment_id, c.id);
207
+ if (!parent)
208
+ return void res.status(404).json({ error: '父评论不存在' });
209
+ const rawBody = String(req.body?.body || '').trim();
210
+ if (rawBody.length < 2)
211
+ return void res.status(400).json({ error: '回复至少 2 字' });
212
+ if (rawBody.length > 300)
213
+ return void res.status(400).json({ error: '回复最多 300 字' });
214
+ const blocked = commentBlocklistHit(rawBody);
215
+ if (blocked)
216
+ return void res.status(400).json({ error: blocked, error_code: 'COMMENT_BLOCKED' });
217
+ const body = piiSanitize(rawBody);
218
+ const llm = await llmModerateComment(body);
219
+ if (!llm.ok)
220
+ return void res.status(400).json({ error: llm.reason || '内容不符合社区规范', error_code: 'COMMENT_MODERATED' });
221
+ const anonymous = req.body?.anonymous ? 1 : 0;
222
+ // 同上:flagged 给管理员,flag_reasons 给反诈;用 rawBody
223
+ const repReasons = detectFraud(rawBody);
224
+ const rid = generateId('drep');
225
+ db.transaction(() => {
226
+ db.prepare(`INSERT INTO dispute_comment_replies (id, parent_comment_id, case_id, replier_id, body, anonymous, flag_reasons) VALUES (?,?,?,?,?,?,?)`)
227
+ .run(rid, parent.id, c.id, user.id, body, anonymous, repReasons.length ? JSON.stringify(repReasons) : null);
228
+ db.prepare(`UPDATE dispute_cases SET comment_count = comment_count + 1 WHERE id = ?`).run(c.id);
229
+ })();
230
+ res.json({ success: true, id: rid, flag_reasons: repReasons });
231
+ });
232
+ // 公正度投票(👍 / 👎)— 一人一案一票
233
+ app.post('/api/disputes/cases/:case_id/fairness', (req, res) => {
234
+ const user = auth(req, res);
235
+ if (!user)
236
+ return;
237
+ const gate = meetsPublicSpeechThreshold(user);
238
+ if (!gate.ok)
239
+ return void res.status(403).json({ error: gate.reason, error_code: 'SPEECH_THRESHOLD' });
240
+ const vote = req.body?.vote;
241
+ if (vote !== 'yes' && vote !== 'no')
242
+ return void res.status(400).json({ error: 'vote 必须是 yes 或 no' });
243
+ const c = db.prepare(`SELECT id, buyer_id, seller_id, arbitrator_id FROM dispute_cases WHERE id = ?`).get(req.params.case_id);
244
+ if (!c)
245
+ return void res.status(404).json({ error: '判例不存在' });
246
+ if (user.id === c.buyer_id || user.id === c.seller_id || user.id === c.arbitrator_id) {
247
+ return void res.status(403).json({ error: '当事人禁止投票' });
248
+ }
249
+ db.transaction(() => {
250
+ // 若已投过,先回滚旧票计数再插新票
251
+ const old = db.prepare('SELECT vote FROM dispute_fairness_votes WHERE case_id = ? AND voter_id = ?').get(c.id, user.id);
252
+ if (old) {
253
+ if (old.vote === vote)
254
+ return; // 重复投同一票 no-op
255
+ const dec = old.vote === 'yes' ? 'fairness_yes' : 'fairness_no';
256
+ db.prepare(`UPDATE dispute_cases SET ${dec} = MAX(0, ${dec} - 1) WHERE id = ?`).run(c.id);
257
+ db.prepare("UPDATE dispute_fairness_votes SET vote = ?, created_at = datetime('now') WHERE case_id = ? AND voter_id = ?").run(vote, c.id, user.id);
258
+ }
259
+ else {
260
+ db.prepare('INSERT INTO dispute_fairness_votes (case_id, voter_id, vote) VALUES (?,?,?)').run(c.id, user.id, vote);
261
+ }
262
+ const inc = vote === 'yes' ? 'fairness_yes' : 'fairness_no';
263
+ db.prepare(`UPDATE dispute_cases SET ${inc} = ${inc} + 1 WHERE id = ?`).run(c.id);
264
+ })();
265
+ res.json({ success: true, vote });
266
+ });
267
+ }
@@ -0,0 +1,358 @@
1
+ export function registerDisputesReadRoutes(app, deps) {
2
+ const { db, auth, errorRes, getOpenDisputes, getDisputeDetails, getEvidenceRequests, listEvidenceFiles, isEligibleArbitrator } = deps;
3
+ // 仲裁员:查看所有开放争议
4
+ app.get('/api/disputes', (req, res) => {
5
+ const user = auth(req, res);
6
+ if (!user)
7
+ return;
8
+ const elig = isEligibleArbitrator(user.id);
9
+ if (!elig.ok)
10
+ return void errorRes(res, 403, 'NOT_ARBITRATOR', elig.reason || '仅限仲裁员访问');
11
+ res.json(getOpenDisputes(db));
12
+ });
13
+ // A2 同类判例推荐
14
+ app.get('/api/disputes/:id/similar-cases', (req, res) => {
15
+ const user = auth(req, res);
16
+ if (!user)
17
+ return;
18
+ const dispute = db.prepare('SELECT id, order_id, reason, initiator_id, defendant_id FROM disputes WHERE id = ?').get(req.params.id);
19
+ if (!dispute)
20
+ return void res.status(404).json({ error: '争议不存在' });
21
+ const role = user.role;
22
+ if (dispute.initiator_id !== user.id && dispute.defendant_id !== user.id && role !== 'arbitrator') {
23
+ return void res.status(403).json({ error: '无权查看' });
24
+ }
25
+ const order = db.prepare('SELECT product_id FROM orders WHERE id = ?').get(dispute.order_id);
26
+ const productCategory = order ? db.prepare('SELECT category FROM products WHERE id = ?').get(order.product_id)?.category : null;
27
+ const reasonWords = (dispute.reason || '').split(/[\s,,。;\n]+/).filter(w => w.length >= 2).slice(0, 3);
28
+ const results = [];
29
+ const seen = new Set();
30
+ const pushIfNew = (r) => {
31
+ if (!seen.has(String(r.id))) {
32
+ seen.add(String(r.id));
33
+ results.push(r);
34
+ }
35
+ };
36
+ // ① 同 product 类目
37
+ if (productCategory) {
38
+ const r1 = db.prepare(`
39
+ SELECT dc.id, dc.product_id, dc.category_tag, dc.winner, dc.resolution,
40
+ dc.amount_bucket, dc.fairness_yes, dc.comment_count, dc.published_at,
41
+ (SELECT title FROM products WHERE id = dc.product_id) as product_title,
42
+ 'same_category' as match_reason
43
+ FROM dispute_cases dc
44
+ JOIN products p ON p.id = dc.product_id
45
+ WHERE p.category = ? AND (dc.dispute_id IS NULL OR dc.dispute_id != ?)
46
+ ORDER BY dc.published_at DESC LIMIT 3
47
+ `).all(productCategory, dispute.id);
48
+ r1.forEach(pushIfNew);
49
+ }
50
+ // ② reason 关键词命中 ruling_text / 双方陈述
51
+ if (results.length < 3 && reasonWords.length > 0) {
52
+ for (const w of reasonWords) {
53
+ if (results.length >= 3)
54
+ break;
55
+ const pat = '%' + w.replace(/[%_]/g, '\\$&') + '%';
56
+ const r2 = db.prepare(`
57
+ SELECT dc.id, dc.product_id, dc.category_tag, dc.winner, dc.resolution,
58
+ dc.amount_bucket, dc.fairness_yes, dc.comment_count, dc.published_at,
59
+ (SELECT title FROM products WHERE id = dc.product_id) as product_title,
60
+ 'keyword_match' as match_reason
61
+ FROM dispute_cases dc
62
+ WHERE (dc.dispute_id IS NULL OR dc.dispute_id != ?)
63
+ AND (dc.ruling_text LIKE ? OR dc.buyer_argument LIKE ? OR dc.seller_argument LIKE ?)
64
+ ORDER BY dc.published_at DESC LIMIT 3
65
+ `).all(dispute.id, pat, pat, pat);
66
+ r2.forEach(pushIfNew);
67
+ }
68
+ }
69
+ // ③ 兜底:最近 3 条已发布判例
70
+ if (results.length < 3) {
71
+ const r3 = db.prepare(`
72
+ SELECT dc.id, dc.product_id, dc.category_tag, dc.winner, dc.resolution,
73
+ dc.amount_bucket, dc.fairness_yes, dc.comment_count, dc.published_at,
74
+ (SELECT title FROM products WHERE id = dc.product_id) as product_title,
75
+ 'recent' as match_reason
76
+ FROM dispute_cases dc
77
+ WHERE (dc.dispute_id IS NULL OR dc.dispute_id != ?)
78
+ ORDER BY dc.published_at DESC LIMIT 3
79
+ `).all(dispute.id);
80
+ r3.forEach(pushIfNew);
81
+ }
82
+ res.json({ items: results.slice(0, 3), product_category: productCategory, reason_keywords: reasonWords });
83
+ });
84
+ // 详情聚合(含 W4 timeline + chain ruling)
85
+ app.get('/api/disputes/:id', (req, res) => {
86
+ const user = auth(req, res);
87
+ if (!user)
88
+ return;
89
+ const dispute = getDisputeDetails(db, req.params.id);
90
+ if (!dispute)
91
+ return void res.status(404).json({ error: '争议不存在' });
92
+ const role = user.role;
93
+ // 允许:发起方、被告方、物流方、仲裁员
94
+ const orderForAuth = db.prepare('SELECT logistics_id FROM orders WHERE id = ?')
95
+ .get(dispute.order_id);
96
+ const isLogisticsParty = orderForAuth?.logistics_id === user.id;
97
+ if (dispute.initiator_id !== user.id && dispute.defendant_id !== user.id
98
+ && !isLogisticsParty && role !== 'arbitrator') {
99
+ return void res.status(403).json({ error: '无权查看此争议' });
100
+ }
101
+ // 原告证据 — 从状态机历史中取 disputed 转移时附带的
102
+ const hist = db.prepare(`SELECT evidence_ids FROM order_state_history WHERE order_id = ? AND to_status = 'disputed'`).get(dispute.order_id);
103
+ // P1 fix: 单条脏 JSON 不应封死整个 dispute 详情
104
+ const safeJsonArr = (s) => {
105
+ try {
106
+ const p = JSON.parse(s || '[]');
107
+ return Array.isArray(p) ? p : [];
108
+ }
109
+ catch {
110
+ return [];
111
+ }
112
+ };
113
+ const plaintiffEvidenceIds = hist ? safeJsonArr(hist.evidence_ids) : [];
114
+ const defEvidenceIds = safeJsonArr(dispute.defendant_evidence_ids);
115
+ const fetchEvidence = (ids) => ids.length
116
+ ? db.prepare(`SELECT * FROM evidence WHERE id IN (${ids.map(() => '?').join(',')})`).all(...ids)
117
+ : [];
118
+ const evidenceRequests = getEvidenceRequests(db, req.params.id);
119
+ const myPendingRequests = evidenceRequests.filter((r) => r.requested_from_id === user.id && r.status === 'pending');
120
+ const order = db.prepare('SELECT buyer_id, seller_id, logistics_id FROM orders WHERE id = ?')
121
+ .get(dispute.order_id);
122
+ const partyIds = [dispute.initiator_id, dispute.defendant_id, order?.logistics_id].filter(Boolean);
123
+ const parties = [...new Set(partyIds)].map(id => db.prepare('SELECT id, name, role FROM users WHERE id = ?').get(id)).filter(Boolean);
124
+ const partyEvidenceIds = safeJsonArr(dispute.party_evidence_ids);
125
+ const orderParties = db.prepare('SELECT buyer_id, seller_id, logistics_id FROM orders WHERE id = ?')
126
+ .get(dispute.order_id);
127
+ const allPartyIds = [
128
+ orderParties?.buyer_id, orderParties?.seller_id, orderParties?.logistics_id,
129
+ dispute.initiator_id, dispute.defendant_id
130
+ ].filter(Boolean);
131
+ const isParty = allPartyIds.includes(user.id);
132
+ const plaintiffEvidence = fetchEvidence(plaintiffEvidenceIds);
133
+ const defendantEvidence = fetchEvidence(defEvidenceIds);
134
+ const partyEvidence = fetchEvidence(partyEvidenceIds);
135
+ // W4 timeline 归一化
136
+ const partyMap = new Map();
137
+ partyMap.set(dispute.initiator_id, 'plaintiff');
138
+ if (dispute.defendant_id)
139
+ partyMap.set(dispute.defendant_id, 'defendant');
140
+ const orderRole = (uid) => {
141
+ if (!uid || !order)
142
+ return 'unknown';
143
+ if (uid === order.buyer_id)
144
+ return 'buyer';
145
+ if (uid === order.seller_id)
146
+ return 'seller';
147
+ if (uid === order.logistics_id)
148
+ return 'logistics';
149
+ return 'unknown';
150
+ };
151
+ const userById = new Map();
152
+ const loadUser = (uid) => {
153
+ if (!uid || userById.has(uid))
154
+ return;
155
+ const u = db.prepare('SELECT id, name, handle, role FROM users WHERE id = ?').get(uid);
156
+ if (u)
157
+ userById.set(uid, u);
158
+ };
159
+ loadUser(dispute.initiator_id);
160
+ loadUser(dispute.defendant_id);
161
+ const events = [];
162
+ // 1) open
163
+ events.push({
164
+ id: `open-${dispute.id}`,
165
+ type: 'open',
166
+ ts: String(dispute.created_at || ''),
167
+ actor_id: dispute.initiator_id,
168
+ actor_role: 'plaintiff',
169
+ body: String(dispute.reason || ''),
170
+ meta: {
171
+ stake_deposit: dispute.stake_deposit,
172
+ respond_deadline: dispute.respond_deadline,
173
+ arbitrate_deadline: dispute.arbitrate_deadline,
174
+ },
175
+ });
176
+ // 2) plaintiff evidence
177
+ const evToEvent = (ev, role) => {
178
+ loadUser(ev.uploader_id);
179
+ let fr = [];
180
+ try {
181
+ fr = ev.flag_reasons ? JSON.parse(String(ev.flag_reasons)) : [];
182
+ }
183
+ catch { }
184
+ return {
185
+ id: `ev-${ev.id}`,
186
+ type: 'evidence',
187
+ ts: String(ev.created_at || ''),
188
+ actor_id: ev.uploader_id || null,
189
+ actor_role: role,
190
+ body: String(ev.description || ''),
191
+ flagged: fr.length > 0 ? 1 : 0,
192
+ flag_reasons: fr,
193
+ meta: {
194
+ evidence_id: ev.id,
195
+ evidence_type: ev.type,
196
+ file_hash: ev.file_hash,
197
+ mime: ev.mime || null,
198
+ size: ev.size || null,
199
+ filename: ev.filename || null,
200
+ sig: ev.sig || null,
201
+ has_blob: !!ev.file_path,
202
+ withdrawn_at: ev.withdrawn_at || null,
203
+ },
204
+ };
205
+ };
206
+ for (const ev of plaintiffEvidence)
207
+ events.push(evToEvent(ev, 'plaintiff'));
208
+ // 3) defendant response — 用 defendant 第一条证据时间逼近 respond_at
209
+ if (dispute.defendant_notes) {
210
+ const firstDefTs = defendantEvidence[0]?.created_at;
211
+ events.push({
212
+ id: `resp-${dispute.id}`,
213
+ type: 'response',
214
+ ts: firstDefTs || String(dispute.created_at || ''),
215
+ actor_id: dispute.defendant_id,
216
+ actor_role: 'defendant',
217
+ body: String(dispute.defendant_notes || ''),
218
+ });
219
+ }
220
+ for (const ev of defendantEvidence)
221
+ events.push(evToEvent(ev, 'defendant'));
222
+ // 4) party evidence (物流等)
223
+ for (const ev of partyEvidence) {
224
+ const r = orderRole(ev.uploader_id);
225
+ events.push(evToEvent(ev, r === 'unknown' ? 'party' : r));
226
+ }
227
+ // 5) evidence requests + submitted_items
228
+ for (const r of evidenceRequests) {
229
+ let evidenceTypes = [];
230
+ try {
231
+ evidenceTypes = JSON.parse(String(r.evidence_types || '[]'));
232
+ }
233
+ catch { }
234
+ loadUser(r.requested_from_id);
235
+ events.push({
236
+ id: `req-${r.id}`,
237
+ type: 'evidence_request',
238
+ ts: String(r.created_at || ''),
239
+ actor_id: null,
240
+ actor_role: 'arbitrator',
241
+ body: String(r.description || ''),
242
+ meta: {
243
+ request_id: r.id,
244
+ requested_from_id: r.requested_from_id,
245
+ requested_from_name: r.requested_from_name,
246
+ requested_from_role: orderRole(r.requested_from_id),
247
+ evidence_types: evidenceTypes,
248
+ deadline: r.deadline,
249
+ status: r.status,
250
+ },
251
+ });
252
+ const submitted = (r.submitted_items || []);
253
+ for (const si of submitted) {
254
+ const role = orderRole(si.uploader_id);
255
+ const ev = evToEvent(si, role === 'unknown' ? 'party' : role);
256
+ ev.meta = { ...(ev.meta || {}), in_response_to: r.id };
257
+ events.push(ev);
258
+ }
259
+ }
260
+ // 6) ruling — 从 order_events 签名链取
261
+ try {
262
+ const chainRows = db.prepare(`SELECT actor_id, signed_at, payload_json FROM order_events WHERE order_id = ? ORDER BY seq ASC`).all(dispute.order_id);
263
+ for (const row of chainRows) {
264
+ let payload = {};
265
+ try {
266
+ payload = JSON.parse(row.payload_json);
267
+ }
268
+ catch {
269
+ continue;
270
+ }
271
+ const extra = (payload.extra || {});
272
+ if (extra.action === 'arbitration_ruling' && extra.dispute_id === dispute.id) {
273
+ loadUser(row.actor_id);
274
+ events.push({
275
+ id: `rule-${row.signed_at}`,
276
+ type: 'ruling',
277
+ ts: row.signed_at,
278
+ actor_id: row.actor_id,
279
+ actor_role: 'arbitrator',
280
+ body: String(extra.reason || ''),
281
+ meta: {
282
+ ruling: extra.ruling,
283
+ refund_amount: extra.refund_amount,
284
+ liable_party_id: extra.liable_party_id,
285
+ liability_parties: extra.liability_parties,
286
+ },
287
+ });
288
+ }
289
+ }
290
+ }
291
+ catch (e) {
292
+ console.warn('[timeline] chain query failed:', e.message);
293
+ }
294
+ // 7) resolved/dismissed marker
295
+ if (dispute.resolved_at) {
296
+ const hasRulingEvent = events.some(e => e.type === 'ruling');
297
+ events.push({
298
+ id: `done-${dispute.id}`,
299
+ type: 'resolved',
300
+ ts: String(dispute.resolved_at),
301
+ actor_id: null,
302
+ actor_role: 'system',
303
+ body: hasRulingEvent ? '' : String(dispute.verdict_reason || ''),
304
+ meta: {
305
+ status: dispute.status,
306
+ ruling_type: dispute.ruling_type,
307
+ refund_amount: dispute.refund_amount,
308
+ liability_parties: dispute.liability_parties,
309
+ },
310
+ });
311
+ }
312
+ events.sort((a, b) => String(a.ts).localeCompare(String(b.ts)));
313
+ res.json({
314
+ ...dispute,
315
+ plaintiff_evidence: plaintiffEvidence,
316
+ defendant_evidence: defendantEvidence,
317
+ party_evidence: partyEvidence,
318
+ evidence_requests: evidenceRequests,
319
+ my_pending_requests: myPendingRequests,
320
+ parties,
321
+ is_party: isParty,
322
+ timeline: events,
323
+ actors: Object.fromEntries([...userById].map(([id, u]) => [id, u])),
324
+ });
325
+ });
326
+ // 当事人 + 仲裁员可查(meta only,blob 单独拉)
327
+ app.get('/api/disputes/:id/evidence-list', (req, res) => {
328
+ const user = auth(req, res);
329
+ if (!user)
330
+ return;
331
+ try {
332
+ const rows = listEvidenceFiles(db, req.params.id, user.id);
333
+ res.json(rows);
334
+ }
335
+ catch (e) {
336
+ const msg = e.message;
337
+ res.status(msg === 'not_dispute_party' ? 403 : 404).json({ error: msg });
338
+ }
339
+ });
340
+ // 涉案三方(仲裁员选择发证据请求的对象)
341
+ app.get('/api/disputes/:id/parties', (req, res) => {
342
+ const user = auth(req, res);
343
+ if (!user)
344
+ return;
345
+ const dispute = getDisputeDetails(db, req.params.id);
346
+ if (!dispute)
347
+ return void res.status(404).json({ error: '争议不存在' });
348
+ const order = db.prepare('SELECT buyer_id, seller_id, logistics_id FROM orders WHERE id = ?')
349
+ .get(dispute.order_id);
350
+ const partyIds = [dispute.initiator_id, dispute.defendant_id, order?.logistics_id].filter(Boolean);
351
+ const uniqueIds = [...new Set(partyIds)];
352
+ const parties = uniqueIds.map(id => {
353
+ const u = db.prepare('SELECT id, name, role FROM users WHERE id = ?').get(id);
354
+ return u;
355
+ }).filter(Boolean);
356
+ res.json(parties);
357
+ });
358
+ }