@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
@@ -0,0 +1,816 @@
1
+ import { createHash, createHmac, randomBytes } from 'node:crypto';
2
+ // ─── 域常量 ───────────────────────────────────────────────
3
+ const CHARITY_CATEGORIES = ['medical', 'education', 'daily', 'elderly', 'disaster', 'tech', 'other'];
4
+ const CHARITY_CATEGORY_LABEL = {
5
+ medical: '医疗救助', education: '教育求学', daily: '生活物资',
6
+ elderly: '助老', disaster: '灾害互助', tech: '科技/设备', other: '其它',
7
+ };
8
+ const CHARITY_MAX_CASH_WAZ = 500;
9
+ const CHARITY_MONTHLY_WISH_CAP = 5;
10
+ const CHARITY_MONTHLY_FULFILL_CAP = 10;
11
+ const CHARITY_WINDOW_MIN_HOURS = 24;
12
+ const CHARITY_WINDOW_MAX_HOURS = 30 * 24;
13
+ const CHARITY_CLAIM_TIMEOUT_HOURS = 48;
14
+ const CHARITY_AUTO_CONFIRM_DAYS = 14; // P1.4: cash wish 兜底自动确认
15
+ const CHARITY_REPAY_MIN = 0.1;
16
+ const CHARITY_REPAY_AUTO_ACCEPT_DAYS = 7;
17
+ const CHARITY_DONATION_MIN = 0.1;
18
+ const CHARITY_DONATION_DAILY_HONOR_CAP = 50;
19
+ function isCharityCategory(s) {
20
+ return CHARITY_CATEGORIES.includes(s);
21
+ }
22
+ function charityBadgeTier(prestige) {
23
+ if (prestige >= 1000)
24
+ return 'diamond';
25
+ if (prestige >= 200)
26
+ return 'gold';
27
+ if (prestige >= 50)
28
+ return 'silver';
29
+ if (prestige >= 10)
30
+ return 'bronze';
31
+ return 'none';
32
+ }
33
+ // P2.6 修复:独立 ANON_SEED;MASTER_SEED 单独泄露 ≠ 全员去匿名化
34
+ // 模块加载时读 env(与 server.ts MASTER_SEED 用同源 env 变量)
35
+ const CHARITY_ANON_SEED = process.env.CHARITY_ANON_SEED
36
+ || ((process.env.WALLET_MASTER_SEED ?? 'webaz-dev-seed-changeme') + ':charity:anon:v1');
37
+ function charityAnonHandle(userId, wishId, role) {
38
+ return createHmac('sha256', CHARITY_ANON_SEED).update(`charity:${role}:${userId}:${wishId}`).digest('hex').slice(0, 12);
39
+ }
40
+ // ─── 模块级 db-taking helpers(供 cron + 路由共用)──────────
41
+ // exported because server.ts order-creation path (B5 下单捐赠) 仍引用
42
+ export function ensureCharityRep(db, userId) {
43
+ db.prepare(`INSERT OR IGNORE INTO charity_reputation (user_id) VALUES (?)`).run(userId);
44
+ }
45
+ function isCharityBlocked(db, userId) {
46
+ const row = db.prepare("SELECT reason, until FROM charity_blocklist WHERE user_id = ? AND until > datetime('now')").get(userId);
47
+ if (row)
48
+ return { blocked: true, reason: row.reason, until: row.until };
49
+ return { blocked: false };
50
+ }
51
+ // 自动过期清理(仅 runEnforcement 调用;GET 端点不再触发写)
52
+ export function expireCharityWishes(db) {
53
+ // 超时未认领 → expired,释放托管
54
+ const expired = db.prepare(`
55
+ SELECT id, user_id, escrow_locked FROM wishes
56
+ WHERE status = 'open' AND expires_at <= datetime('now')
57
+ `).all();
58
+ for (const w of expired) {
59
+ db.transaction(() => {
60
+ db.prepare("UPDATE wishes SET status = 'expired' WHERE id = ?").run(w.id);
61
+ if (w.escrow_locked > 0) {
62
+ db.prepare('UPDATE wallets SET balance = balance + ?, staked = staked - ? WHERE user_id = ?').run(w.escrow_locked, w.escrow_locked, w.user_id);
63
+ }
64
+ })();
65
+ }
66
+ // P1.3 修复:48h 仅指 claim 后 *零证据* 的情况;如果已提交 proof_pending wf 则不强 reset
67
+ // 改判定:NOT EXISTS ANY wf at all(含 proof_pending)。已交证据的 fulfiller 不被回收
68
+ const stale = db.prepare(`
69
+ SELECT w.id, w.fulfiller_user_id FROM wishes w
70
+ WHERE w.status = 'claimed' AND w.claimed_at <= datetime('now', '-${CHARITY_CLAIM_TIMEOUT_HOURS} hours')
71
+ AND NOT EXISTS (SELECT 1 FROM wish_fulfillments wf WHERE wf.wish_id = w.id)
72
+ `).all();
73
+ for (const w of stale) {
74
+ db.transaction(() => {
75
+ db.prepare("UPDATE wishes SET status = 'open', fulfiller_user_id = NULL, claimed_at = NULL WHERE id = ?").run(w.id);
76
+ if (w.fulfiller_user_id) {
77
+ ensureCharityRep(db, w.fulfiller_user_id);
78
+ db.prepare("UPDATE charity_reputation SET prestige_score = MAX(0, prestige_score - 1) WHERE user_id = ?").run(w.fulfiller_user_id);
79
+ }
80
+ })();
81
+ }
82
+ // P1.4 修复:fulfiller 兜底 — wisher 14 天不 confirm → 自动确认(防止 cash wish escrow 永久锁)
83
+ // 仅 proof_pending 的 wf 才参与
84
+ const orphans = db.prepare(`
85
+ SELECT w.id as wid, w.user_id as wuid, w.fulfiller_user_id as fuid,
86
+ w.target_kind, w.escrow_locked, wf.id as wfid, wf.proof_hash
87
+ FROM wishes w JOIN wish_fulfillments wf ON wf.wish_id = w.id
88
+ WHERE w.status = 'claimed' AND wf.status = 'proof_pending'
89
+ AND wf.created_at <= datetime('now', '-${CHARITY_AUTO_CONFIRM_DAYS} days')
90
+ `).all();
91
+ for (const o of orphans) {
92
+ db.transaction(() => {
93
+ // 不签名(许愿人未参与),仅记录 auto_confirmed
94
+ const upd = db.prepare(`UPDATE wish_fulfillments SET status='confirmed', wisher_sig='AUTO_CONFIRM', confirmed_at=datetime('now')
95
+ WHERE id = ? AND status='proof_pending'`).run(o.wfid);
96
+ if (upd.changes === 0)
97
+ return;
98
+ db.prepare(`UPDATE wishes SET status='completed', completed_at=datetime('now') WHERE id = ? AND status='claimed'`).run(o.wid);
99
+ if (o.target_kind === 'cash' && Number(o.escrow_locked) > 0) {
100
+ db.prepare('UPDATE wallets SET staked = staked - ? WHERE user_id = ?').run(o.escrow_locked, o.wuid);
101
+ db.prepare('UPDATE wallets SET balance = balance + ? WHERE user_id = ?').run(o.escrow_locked, o.fuid);
102
+ }
103
+ ensureCharityRep(db, o.fuid);
104
+ db.prepare(`UPDATE charity_reputation SET wishes_fulfilled = wishes_fulfilled + 1,
105
+ prestige_score = prestige_score + 10, last_active = datetime('now') WHERE user_id = ?`).run(o.fuid);
106
+ const s = db.prepare('SELECT prestige_score FROM charity_reputation WHERE user_id = ?').get(o.fuid).prestige_score;
107
+ db.prepare(`UPDATE charity_reputation SET badge_tier = ? WHERE user_id = ?`).run(charityBadgeTier(s), o.fuid);
108
+ })();
109
+ }
110
+ }
111
+ // 自动接受过期还愿(每 5 分钟 enforcement 调用)
112
+ export function autoAcceptExpiredRepayments(db) {
113
+ const rows = db.prepare(`SELECT * FROM wish_repayments WHERE status = 'offered' AND auto_expire_at <= datetime('now')`).all();
114
+ for (const r of rows) {
115
+ const amount = Number(r.amount);
116
+ db.transaction(() => {
117
+ // P0.3 修复:UPDATE 带 status 守门;与手动 respond 撞车时 changes=0 直接跳过
118
+ const upd = db.prepare(`UPDATE wish_repayments SET status='expired_auto_accept', responded_at=datetime('now'), locked=0
119
+ WHERE id = ? AND status='offered'`).run(r.id);
120
+ if (upd.changes === 0)
121
+ return;
122
+ db.prepare(`UPDATE wallets SET staked = staked - ? WHERE user_id = ?`).run(amount, r.wisher_user_id);
123
+ db.prepare(`UPDATE wallets SET balance = balance + ? WHERE user_id = ?`).run(amount, r.fulfiller_user_id);
124
+ ensureCharityRep(db, r.fulfiller_user_id);
125
+ db.prepare(`UPDATE charity_reputation SET repay_honor = repay_honor + 5, prestige_score = prestige_score + 5 WHERE user_id = ?`).run(r.fulfiller_user_id);
126
+ })();
127
+ }
128
+ }
129
+ export function registerCharityRoutes(app, deps) {
130
+ const { db, auth, generateId, rateLimitOk, getUser, isTrustedRole, requireContentAdmin, requireProtocolAdmin, fireWebhooks } = deps;
131
+ // POST /api/wishes — 发布愿望
132
+ app.post('/api/wishes', (req, res) => {
133
+ const user = auth(req, res);
134
+ if (!user)
135
+ return;
136
+ if (!rateLimitOk(req.ip || '', 10, 60_000))
137
+ return void res.status(429).json({ error: '请求过于频繁' });
138
+ const blocked = isCharityBlocked(db, user.id);
139
+ if (blocked.blocked)
140
+ return void res.json({ error: `已被暂时禁言:${blocked.reason}(${blocked.until} 解除)`, blocklist_reason: blocked.reason, blocklist_until: blocked.until });
141
+ const body = req.body;
142
+ const title = String(body.title || '').trim();
143
+ if (title.length < 4 || title.length > 100)
144
+ return void res.json({ error: '标题 4-100 字' });
145
+ const content = String(body.content || '').trim();
146
+ if (content.length < 10 || content.length > 1000)
147
+ return void res.json({ error: '描述 10-1000 字' });
148
+ const cat = String(body.category || 'other');
149
+ if (!isCharityCategory(cat))
150
+ return void res.json({ error: '类目无效' });
151
+ const targetKind = String(body.target_kind || 'item');
152
+ if (!['item', 'service', 'cash'].includes(targetKind))
153
+ return void res.json({ error: 'target_kind 无效' });
154
+ const windowHours = Math.max(CHARITY_WINDOW_MIN_HOURS, Math.min(CHARITY_WINDOW_MAX_HOURS, Math.floor(Number(body.window_hours || 168))));
155
+ const allowPublic = body.allow_public ? 1 : 0;
156
+ // 月度上限
157
+ const monthly = db.prepare("SELECT COUNT(1) as n FROM wishes WHERE user_id = ? AND created_at > datetime('now','-30 days')").get(user.id).n;
158
+ if (monthly >= CHARITY_MONTHLY_WISH_CAP)
159
+ return void res.json({ error: `月度许愿上限 ${CHARITY_MONTHLY_WISH_CAP} 个,请下月再来` });
160
+ // 现金类需托管
161
+ let targetWaz = null;
162
+ let escrow = 0;
163
+ if (targetKind === 'cash') {
164
+ targetWaz = Number(body.target_waz);
165
+ if (!Number.isFinite(targetWaz) || targetWaz <= 0)
166
+ return void res.json({ error: 'cash 类型需 target_waz > 0' });
167
+ if (targetWaz > CHARITY_MAX_CASH_WAZ)
168
+ return void res.json({ error: `单愿金额上限 ${CHARITY_MAX_CASH_WAZ} WAZ` });
169
+ // 卖家承诺托管:可选 — 锁仓 0 表示纯协调(不推荐),>0 表示真托管
170
+ const lockSelf = body.escrow_self ? Number(body.target_waz) : 0;
171
+ if (lockSelf > 0) {
172
+ const w = db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(user.id);
173
+ if (!w || w.balance < lockSelf)
174
+ return void res.json({ error: '余额不足以自托管' });
175
+ escrow = lockSelf;
176
+ }
177
+ }
178
+ const id = generateId('wish');
179
+ // P2.1 修复:去掉 secret_keep_safe(无 reveal 端点用不上,节省一次握手)
180
+ const commitHash = createHash('sha256').update(`${user.id}|${randomBytes(16).toString('hex')}|${id}|${Date.now()}`).digest('hex');
181
+ const wisherHandle = charityAnonHandle(user.id, id, 'wisher');
182
+ db.transaction(() => {
183
+ db.prepare(`INSERT INTO wishes (id, user_id, wisher_handle, category, title, content, target_kind, target_waz, escrow_locked, commit_hash, allow_public, expires_at)
184
+ VALUES (?,?,?,?,?,?,?,?,?,?,?, datetime('now', '+' || ? || ' hours'))`).run(id, user.id, wisherHandle, cat, title, content, targetKind, targetWaz, escrow, commitHash, allowPublic, windowHours);
185
+ if (escrow > 0) {
186
+ db.prepare('UPDATE wallets SET balance = balance - ?, staked = staked + ? WHERE user_id = ?').run(escrow, escrow, user.id);
187
+ }
188
+ ensureCharityRep(db, user.id);
189
+ db.prepare("UPDATE charity_reputation SET wishes_made = wishes_made + 1, last_active = datetime('now') WHERE user_id = ?").run(user.id);
190
+ })();
191
+ res.json({ id, wisher_handle: wisherHandle, escrow_locked: escrow });
192
+ });
193
+ // GET /api/wishes — 浏览(匿名可访问)
194
+ app.get('/api/wishes', (req, res) => {
195
+ const where = ["status IN ('open','claimed')"];
196
+ const args = [];
197
+ if (req.query.category && isCharityCategory(String(req.query.category))) {
198
+ where.push('category = ?');
199
+ args.push(String(req.query.category));
200
+ }
201
+ if (req.query.target_kind) {
202
+ const k = String(req.query.target_kind);
203
+ if (['item', 'service', 'cash'].includes(k)) {
204
+ where.push('target_kind = ?');
205
+ args.push(k);
206
+ }
207
+ }
208
+ if (req.query.status) {
209
+ where[0] = 'status = ?';
210
+ args.unshift(String(req.query.status));
211
+ }
212
+ if (req.query.q && typeof req.query.q === 'string' && req.query.q.trim()) {
213
+ const qE = req.query.q.trim().replace(/[\\%_]/g, '\\$&');
214
+ where.push("(title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\')");
215
+ args.push('%' + qE + '%', '%' + qE + '%');
216
+ }
217
+ const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 30));
218
+ const rows = db.prepare(`
219
+ SELECT id, wisher_handle, category, title,
220
+ substr(content, 1, 120) as content_preview,
221
+ target_kind, target_waz, escrow_locked, status, allow_public,
222
+ expires_at, created_at, claimed_at
223
+ FROM wishes
224
+ WHERE ${where.join(' AND ')}
225
+ ORDER BY created_at DESC
226
+ LIMIT ?
227
+ `).all(...args, limit);
228
+ res.json({ items: rows, categories: CHARITY_CATEGORIES, category_labels: CHARITY_CATEGORY_LABEL });
229
+ });
230
+ // GET /api/wishes/:id — 详情
231
+ app.get('/api/wishes/:id', (req, res) => {
232
+ const id = req.params.id;
233
+ const w = db.prepare(`SELECT * FROM wishes WHERE id = ?`).get(id);
234
+ if (!w)
235
+ return void res.json({ error: '愿望不存在' });
236
+ const me = getUser(req);
237
+ const isWisher = !!me && me.id === w.user_id;
238
+ const isFulfiller = !!me && me.id === w.fulfiller_user_id;
239
+ const fulfillments = db.prepare(`
240
+ SELECT id, fulfiller_handle, proof_hash, proof_note, status,
241
+ confirmed_at, disclose_wisher, disclose_fulfiller, disclosed_at, created_at
242
+ FROM wish_fulfillments WHERE wish_id = ?
243
+ ORDER BY created_at DESC
244
+ `).all(id);
245
+ const repayments = db.prepare(`
246
+ SELECT id, fulfillment_id, amount, note, status, responded_at, auto_expire_at, created_at
247
+ FROM wish_repayments WHERE wish_id = ?
248
+ ORDER BY created_at DESC
249
+ `).all(id);
250
+ res.json({
251
+ id: w.id, wisher_handle: w.wisher_handle, category: w.category, title: w.title,
252
+ content: w.content, target_kind: w.target_kind, target_waz: w.target_waz,
253
+ escrow_locked: w.escrow_locked, commit_hash: w.commit_hash, allow_public: w.allow_public,
254
+ status: w.status, claimed_at: w.claimed_at, completed_at: w.completed_at,
255
+ expires_at: w.expires_at, created_at: w.created_at,
256
+ fulfillments, repayments,
257
+ is_wisher: isWisher, is_fulfiller: isFulfiller,
258
+ });
259
+ });
260
+ // POST /api/wishes/:id/fulfill — 圆梦人认领
261
+ // #1018 改名:原 /claim path 与 claim-initiators 的 wish_claim_task (fraud claim) 冲突
262
+ // /claim 让 fraud-claim 独占(与 secondhand/auctions 三垂类对称)
263
+ app.post('/api/wishes/:id/fulfill', (req, res) => {
264
+ const user = auth(req, res);
265
+ if (!user)
266
+ return;
267
+ if (!rateLimitOk(req.ip || '', 30, 60_000))
268
+ return void res.status(429).json({ error: '请求过于频繁' });
269
+ const id = req.params.id;
270
+ const blocked = isCharityBlocked(db, user.id);
271
+ if (blocked.blocked)
272
+ return void res.json({ error: `已被暂时禁言:${blocked.reason}`, blocklist_reason: blocked.reason, blocklist_until: blocked.until });
273
+ const w = db.prepare(`SELECT user_id, status FROM wishes WHERE id = ?`).get(id);
274
+ if (!w)
275
+ return void res.json({ error: '愿望不存在' });
276
+ if (w.status !== 'open')
277
+ return void res.json({ error: '该愿望已被认领或已结束' });
278
+ if (w.user_id === user.id) {
279
+ // 反自施善(防自己给自己许愿圆满,套取威望):直接封锁 30 天
280
+ db.prepare("INSERT OR REPLACE INTO charity_blocklist (user_id, reason, until) VALUES (?, 'self_fulfill_fraud', datetime('now','+30 days'))").run(user.id);
281
+ return void res.json({ error: '禁止圆自己的愿。已封锁 30 天。' });
282
+ }
283
+ const monthly = db.prepare("SELECT COUNT(1) as n FROM wishes WHERE fulfiller_user_id = ? AND claimed_at > datetime('now','-30 days')").get(user.id).n;
284
+ if (monthly >= CHARITY_MONTHLY_FULFILL_CAP)
285
+ return void res.json({ error: `月度施善上限 ${CHARITY_MONTHLY_FULFILL_CAP} 次` });
286
+ const claimRes = db.prepare(`UPDATE wishes SET status='claimed', fulfiller_user_id=?, claimed_at=datetime('now')
287
+ WHERE id = ? AND status='open'`).run(user.id, id);
288
+ if (claimRes.changes === 0)
289
+ return void res.json({ error: '该愿望已被他人认领,请刷新' });
290
+ // P2.4 通知:许愿人收到"你的愿望被认领"
291
+ try {
292
+ const t = db.prepare('SELECT title FROM wishes WHERE id = ?').get(id).title;
293
+ db.prepare(`INSERT INTO notifications (id, user_id, wish_id, type, title, body, created_at)
294
+ VALUES (?,?,?,'wish_claimed',?,?,datetime('now'))`)
295
+ .run(generateId('ntf'), w.user_id, id, '🤝 你的愿望被认领', `「${t}」 施善人已开始行动,请等待证据`);
296
+ }
297
+ catch (e) {
298
+ console.error('[charity notify claim]', e);
299
+ }
300
+ res.json({ ok: true, claim_timeout_hours: CHARITY_CLAIM_TIMEOUT_HOURS });
301
+ });
302
+ // POST /api/wishes/:id/proof — 提交证据
303
+ app.post('/api/wishes/:id/proof', (req, res) => {
304
+ const user = auth(req, res);
305
+ if (!user)
306
+ return;
307
+ if (!rateLimitOk(req.ip || '', 30, 60_000))
308
+ return void res.status(429).json({ error: '请求过于频繁' });
309
+ const id = req.params.id;
310
+ const w = db.prepare(`SELECT user_id, fulfiller_user_id, status FROM wishes WHERE id = ?`).get(id);
311
+ if (!w)
312
+ return void res.json({ error: '愿望不存在' });
313
+ if (w.fulfiller_user_id !== user.id)
314
+ return void res.json({ error: '仅施善人可提交证据' });
315
+ if (w.status !== 'claimed')
316
+ return void res.json({ error: '当前状态不可提交证据' });
317
+ const body = req.body;
318
+ const proofHash = String(body.proof_hash || '').trim();
319
+ if (proofHash.length < 16 || proofHash.length > 128)
320
+ return void res.json({ error: 'proof_hash 长度无效(16-128 hex)' });
321
+ const proofNote = body.proof_note ? String(body.proof_note).slice(0, 500) : null;
322
+ // 施善人签名 = HMAC(api_key, wish_id||proof_hash)
323
+ const sig = createHmac('sha256', user.api_key).update(`${id}|${proofHash}`).digest('hex');
324
+ const fid = generateId('wf');
325
+ const handle = charityAnonHandle(user.id, id, 'fulfiller');
326
+ db.prepare(`INSERT INTO wish_fulfillments (id, wish_id, fulfiller_user_id, fulfiller_handle, proof_hash, proof_note, fulfiller_sig)
327
+ VALUES (?,?,?,?,?,?,?)`).run(fid, id, user.id, handle, proofHash, proofNote, sig);
328
+ // P2.4 通知:许愿人收到"施善证据已提交,请确认"
329
+ try {
330
+ const t = db.prepare('SELECT title FROM wishes WHERE id = ?').get(id).title;
331
+ db.prepare(`INSERT INTO notifications (id, user_id, wish_id, type, title, body, created_at)
332
+ VALUES (?,?,?,'wish_proof',?,?,datetime('now'))`)
333
+ .run(generateId('ntf'), w.user_id, id, '📤 施善证据已提交', `「${t}」 请尽快确认(14 天不响应会自动确认)`);
334
+ }
335
+ catch (e) {
336
+ console.error('[charity notify proof]', e);
337
+ }
338
+ res.json({ id: fid, fulfiller_handle: handle, signature: sig });
339
+ });
340
+ // POST /api/wishes/:id/confirm — 许愿人确认
341
+ app.post('/api/wishes/:id/confirm', (req, res) => {
342
+ const user = auth(req, res);
343
+ if (!user)
344
+ return;
345
+ if (!rateLimitOk(req.ip || '', 30, 60_000))
346
+ return void res.status(429).json({ error: '请求过于频繁' });
347
+ const id = req.params.id;
348
+ const w = db.prepare(`SELECT * FROM wishes WHERE id = ?`).get(id);
349
+ if (!w)
350
+ return void res.json({ error: '愿望不存在' });
351
+ if (w.user_id !== user.id)
352
+ return void res.json({ error: '仅许愿人可确认' });
353
+ if (w.status !== 'claimed')
354
+ return void res.json({ error: '当前状态不可确认' });
355
+ const fid = String(req.body.fulfillment_id || '');
356
+ const wf = db.prepare(`SELECT * FROM wish_fulfillments WHERE id = ? AND wish_id = ?`).get(fid, id);
357
+ if (!wf)
358
+ return void res.json({ error: '证据不存在' });
359
+ if (wf.status !== 'proof_pending')
360
+ return void res.json({ error: '该证据已处理' });
361
+ const wisherSig = createHmac('sha256', user.api_key).update(`${id}|${wf.proof_hash}|confirm`).digest('hex');
362
+ let raceLost = false;
363
+ db.transaction(() => {
364
+ // P0.1 修复:原子状态推进 — 仅 status='proof_pending' 时 update;双击/重放只有一次生效
365
+ const upd = db.prepare(`UPDATE wish_fulfillments SET status='confirmed', wisher_sig=?, confirmed_at=datetime('now')
366
+ WHERE id = ? AND status='proof_pending'`).run(wisherSig, fid);
367
+ if (upd.changes === 0) {
368
+ raceLost = true;
369
+ return;
370
+ }
371
+ db.prepare(`UPDATE wishes SET status='completed', completed_at=datetime('now') WHERE id = ? AND status='claimed'`).run(id);
372
+ // cash 模式释放托管给施善人
373
+ if (w.target_kind === 'cash' && Number(w.escrow_locked) > 0) {
374
+ const amt = Number(w.escrow_locked);
375
+ db.prepare('UPDATE wallets SET staked = staked - ? WHERE user_id = ?').run(amt, w.user_id);
376
+ db.prepare('UPDATE wallets SET balance = balance + ? WHERE user_id = ?').run(amt, w.fulfiller_user_id);
377
+ }
378
+ // prestige:施善人 +10,许愿人 +1(鼓励确认)
379
+ ensureCharityRep(db, w.fulfiller_user_id);
380
+ db.prepare(`UPDATE charity_reputation SET wishes_fulfilled = wishes_fulfilled + 1,
381
+ prestige_score = prestige_score + 10, last_active = datetime('now') WHERE user_id = ?`).run(w.fulfiller_user_id);
382
+ const newScore = db.prepare('SELECT prestige_score FROM charity_reputation WHERE user_id = ?').get(w.fulfiller_user_id).prestige_score;
383
+ db.prepare(`UPDATE charity_reputation SET badge_tier = ? WHERE user_id = ?`).run(charityBadgeTier(newScore), w.fulfiller_user_id);
384
+ ensureCharityRep(db, user.id);
385
+ db.prepare(`UPDATE charity_reputation SET prestige_score = prestige_score + 1, last_active = datetime('now') WHERE user_id = ?`).run(user.id);
386
+ })();
387
+ if (raceLost)
388
+ return void res.json({ error: '该证据已被处理,请刷新' });
389
+ // P2.4 通知:施善人收到"许愿人已确认"
390
+ try {
391
+ db.prepare(`INSERT INTO notifications (id, user_id, wish_id, type, title, body, created_at)
392
+ VALUES (?,?,?,'wish_confirmed',?,?,datetime('now'))`)
393
+ .run(generateId('ntf'), w.fulfiller_user_id, id, '✓ 许愿人已确认圆梦', `「${w.title}」 +10 威望已入账`);
394
+ }
395
+ catch (e) {
396
+ console.error('[charity notify confirm]', e);
397
+ }
398
+ // 📡 Webhook fire — 通知双方 (异步不 await)
399
+ fireWebhooks('wish.confirmed', { wish_id: id, wisher_handle: w.wisher_handle, title: w.title }, [w.user_id, w.fulfiller_user_id]).catch(e => console.error('[webhook]', e));
400
+ res.json({ ok: true, wisher_sig: wisherSig });
401
+ });
402
+ // POST /api/wishes/:id/disclose — 申请公开(双方同意才公开)
403
+ app.post('/api/wishes/:id/disclose', (req, res) => {
404
+ const user = auth(req, res);
405
+ if (!user)
406
+ return;
407
+ const id = req.params.id;
408
+ const w = db.prepare(`SELECT user_id, fulfiller_user_id, status, allow_public FROM wishes WHERE id = ?`).get(id);
409
+ if (!w)
410
+ return void res.json({ error: '愿望不存在' });
411
+ if (w.status !== 'completed')
412
+ return void res.json({ error: '仅完成后可申请公开' });
413
+ if (!w.allow_public)
414
+ return void res.json({ error: '该愿望已声明保持匿名,不可公开' });
415
+ const wf = db.prepare(`SELECT id, disclose_wisher, disclose_fulfiller FROM wish_fulfillments WHERE wish_id = ? AND status='confirmed' ORDER BY created_at DESC LIMIT 1`).get(id);
416
+ if (!wf)
417
+ return void res.json({ error: '未找到对应证据' });
418
+ let update = null;
419
+ if (user.id === w.user_id)
420
+ update = 'disclose_wisher = 1';
421
+ else if (user.id === w.fulfiller_user_id)
422
+ update = 'disclose_fulfiller = 1';
423
+ else
424
+ return void res.json({ error: '非当事人不可申请公开' });
425
+ db.prepare(`UPDATE wish_fulfillments SET ${update} WHERE id = ?`).run(wf.id);
426
+ // 双方都同意 → 标记 disclosed_at
427
+ const both = db.prepare(`SELECT disclose_wisher, disclose_fulfiller FROM wish_fulfillments WHERE id = ?`).get(wf.id);
428
+ let disclosed = false;
429
+ if (both.disclose_wisher && both.disclose_fulfiller) {
430
+ db.prepare(`UPDATE wish_fulfillments SET disclosed_at = datetime('now') WHERE id = ?`).run(wf.id);
431
+ disclosed = true;
432
+ }
433
+ res.json({ ok: true, disclosed, wisher_agreed: !!both.disclose_wisher, fulfiller_agreed: !!both.disclose_fulfiller });
434
+ });
435
+ // POST /api/wishes/:id/cancel — 许愿人取消(仅 open 状态)
436
+ app.post('/api/wishes/:id/cancel', (req, res) => {
437
+ const user = auth(req, res);
438
+ if (!user)
439
+ return;
440
+ const id = req.params.id;
441
+ const w = db.prepare(`SELECT user_id, status, escrow_locked FROM wishes WHERE id = ?`).get(id);
442
+ if (!w)
443
+ return void res.json({ error: '愿望不存在' });
444
+ if (w.user_id !== user.id)
445
+ return void res.json({ error: '仅许愿人可取消' });
446
+ if (w.status !== 'open')
447
+ return void res.json({ error: '已认领或已完成的愿望不可取消' });
448
+ db.transaction(() => {
449
+ db.prepare("UPDATE wishes SET status='cancelled' WHERE id = ?").run(id);
450
+ if (w.escrow_locked > 0) {
451
+ db.prepare('UPDATE wallets SET balance = balance + ?, staked = staked - ? WHERE user_id = ?').run(w.escrow_locked, w.escrow_locked, user.id);
452
+ }
453
+ })();
454
+ res.json({ ok: true });
455
+ });
456
+ // GET /api/charity/me — 我的慈善档案
457
+ app.get('/api/charity/me', (req, res) => {
458
+ const user = auth(req, res);
459
+ if (!user)
460
+ return;
461
+ ensureCharityRep(db, user.id);
462
+ const rep = db.prepare(`SELECT * FROM charity_reputation WHERE user_id = ?`).get(user.id);
463
+ const myWishes = db.prepare(`SELECT id, wisher_handle, category, title, status, target_kind, target_waz, expires_at, created_at, completed_at
464
+ FROM wishes WHERE user_id = ? ORDER BY created_at DESC LIMIT 50`).all(user.id);
465
+ const myFulfilled = db.prepare(`
466
+ SELECT w.id, w.title, w.category, w.target_kind, w.target_waz, w.status, w.completed_at, wf.fulfiller_handle, wf.status as wf_status
467
+ FROM wish_fulfillments wf JOIN wishes w ON w.id = wf.wish_id
468
+ WHERE wf.fulfiller_user_id = ? ORDER BY wf.created_at DESC LIMIT 50
469
+ `).all(user.id);
470
+ // 待我响应的还愿
471
+ const pendingRepays = db.prepare(`
472
+ SELECT r.id, r.wish_id, r.amount, r.note, r.auto_expire_at, w.title
473
+ FROM wish_repayments r JOIN wishes w ON w.id = r.wish_id
474
+ WHERE r.fulfiller_user_id = ? AND r.status = 'offered'
475
+ ORDER BY r.created_at DESC
476
+ `).all(user.id);
477
+ res.json({ reputation: rep, my_wishes: myWishes, my_fulfillments: myFulfilled, pending_repayments: pendingRepays });
478
+ });
479
+ // GET /api/charity/stories — 公开披露的故事板
480
+ app.get('/api/charity/stories', (req, res) => {
481
+ const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 30));
482
+ const rows = db.prepare(`
483
+ SELECT w.id, w.category, w.title, w.content, w.target_kind, w.target_waz, w.completed_at,
484
+ wf.disclosed_at, wf.proof_note,
485
+ uw.handle as wisher_name, uw.region as wisher_region,
486
+ uf.handle as fulfiller_name, uf.region as fulfiller_region
487
+ FROM wish_fulfillments wf
488
+ JOIN wishes w ON w.id = wf.wish_id
489
+ JOIN users uw ON uw.id = w.user_id
490
+ JOIN users uf ON uf.id = wf.fulfiller_user_id
491
+ WHERE wf.disclosed_at IS NOT NULL
492
+ ORDER BY wf.disclosed_at DESC
493
+ LIMIT ?
494
+ `).all(limit);
495
+ res.json({ items: rows });
496
+ });
497
+ // 还愿:许愿人发起
498
+ app.post('/api/wishes/:id/repay', (req, res) => {
499
+ const user = auth(req, res);
500
+ if (!user)
501
+ return;
502
+ if (!rateLimitOk(req.ip || '', 20, 60_000))
503
+ return void res.status(429).json({ error: '请求过于频繁' });
504
+ const id = req.params.id;
505
+ const body = req.body;
506
+ const amount = Number(body.amount);
507
+ if (!Number.isFinite(amount) || amount < CHARITY_REPAY_MIN)
508
+ return void res.json({ error: `金额需 ≥ ${CHARITY_REPAY_MIN} WAZ` });
509
+ const w = db.prepare(`SELECT user_id, fulfiller_user_id, status FROM wishes WHERE id = ?`).get(id);
510
+ if (!w)
511
+ return void res.json({ error: '愿望不存在' });
512
+ if (w.user_id !== user.id)
513
+ return void res.json({ error: '仅许愿人可发起还愿' });
514
+ if (w.status !== 'completed')
515
+ return void res.json({ error: '仅已施善完成的愿望可还愿' });
516
+ const fid = String(body.fulfillment_id || '');
517
+ const wf = db.prepare(`SELECT id, status FROM wish_fulfillments WHERE id = ? AND wish_id = ?`).get(fid, id);
518
+ if (!wf || wf.status !== 'confirmed')
519
+ return void res.json({ error: '证据不存在或未确认' });
520
+ // 已发起的等待中还愿不可重复
521
+ const existing = db.prepare(`SELECT id FROM wish_repayments WHERE wish_id = ? AND status = 'offered'`).get(id);
522
+ if (existing)
523
+ return void res.json({ error: '已有进行中的还愿,请等待对方响应' });
524
+ // 余额检查 + 锁仓
525
+ const wallet = db.prepare(`SELECT balance FROM wallets WHERE user_id = ?`).get(user.id);
526
+ if (!wallet || wallet.balance < amount)
527
+ return void res.json({ error: '余额不足' });
528
+ const rid = generateId('repay');
529
+ db.transaction(() => {
530
+ db.prepare(`INSERT INTO wish_repayments (id, wish_id, fulfillment_id, wisher_user_id, fulfiller_user_id, amount, note, locked, auto_expire_at)
531
+ VALUES (?,?,?,?,?,?,?,?, datetime('now', '+${CHARITY_REPAY_AUTO_ACCEPT_DAYS} days'))`).run(rid, id, fid, user.id, w.fulfiller_user_id, amount, body.note ? String(body.note).slice(0, 300) : null, amount);
532
+ db.prepare(`UPDATE wallets SET balance = balance - ?, staked = staked + ? WHERE user_id = ?`).run(amount, amount, user.id);
533
+ })();
534
+ // P2.4 通知:施善人收到"有人向你还愿"
535
+ try {
536
+ const t = db.prepare('SELECT title FROM wishes WHERE id = ?').get(id).title;
537
+ db.prepare(`INSERT INTO notifications (id, user_id, wish_id, type, title, body, created_at)
538
+ VALUES (?,?,?,'wish_repay',?,?,datetime('now'))`)
539
+ .run(generateId('ntf'), w.fulfiller_user_id, id, `🙏 有人向你还愿 ${amount} WAZ`, `「${t}」 可接受或谢绝转入慈善基金(7 天不响应自动接受)`);
540
+ }
541
+ catch (e) {
542
+ console.error('[charity notify repay]', e);
543
+ }
544
+ res.json({ id: rid, auto_accept_in_days: CHARITY_REPAY_AUTO_ACCEPT_DAYS });
545
+ });
546
+ // 施善人响应还愿(accept / decline_to_fund)
547
+ app.post('/api/wishes/:id/repay/:rid/respond', (req, res) => {
548
+ const user = auth(req, res);
549
+ if (!user)
550
+ return;
551
+ if (!rateLimitOk(req.ip || '', 20, 60_000))
552
+ return void res.status(429).json({ error: '请求过于频繁' });
553
+ const id = req.params.id;
554
+ const rid = req.params.rid;
555
+ const choice = String(req.body.choice || '');
556
+ if (!['accept', 'decline_to_fund'].includes(choice))
557
+ return void res.json({ error: 'choice 必须是 accept 或 decline_to_fund' });
558
+ const r = db.prepare(`SELECT * FROM wish_repayments WHERE id = ? AND wish_id = ?`).get(rid, id);
559
+ if (!r)
560
+ return void res.json({ error: '还愿不存在' });
561
+ if (r.fulfiller_user_id !== user.id)
562
+ return void res.json({ error: '仅施善人可响应' });
563
+ if (r.status !== 'offered')
564
+ return void res.json({ error: '已处理' });
565
+ const amount = Number(r.amount);
566
+ let raceLost = false;
567
+ db.transaction(() => {
568
+ const newStatus = choice === 'accept' ? 'accepted' : 'declined_to_fund';
569
+ const upd = db.prepare(`UPDATE wish_repayments SET status=?, responded_at=datetime('now'), locked=0
570
+ WHERE id = ? AND status='offered'`).run(newStatus, rid);
571
+ if (upd.changes === 0) {
572
+ raceLost = true;
573
+ return;
574
+ }
575
+ if (choice === 'accept') {
576
+ db.prepare(`UPDATE wallets SET staked = staked - ? WHERE user_id = ?`).run(amount, r.wisher_user_id);
577
+ db.prepare(`UPDATE wallets SET balance = balance + ? WHERE user_id = ?`).run(amount, r.fulfiller_user_id);
578
+ ensureCharityRep(db, user.id);
579
+ db.prepare(`UPDATE charity_reputation SET repay_honor = repay_honor + 5, prestige_score = prestige_score + 5, last_active = datetime('now') WHERE user_id = ?`).run(user.id);
580
+ }
581
+ else {
582
+ // decline_to_fund:钱转入基金,双方都得荣誉
583
+ db.prepare(`UPDATE wallets SET staked = staked - ? WHERE user_id = ?`).run(amount, r.wisher_user_id);
584
+ db.prepare(`UPDATE charity_fund SET balance = balance + ?, total_redirected = total_redirected + ?, updated_at = datetime('now') WHERE id = 'main'`).run(amount, amount);
585
+ db.prepare(`INSERT INTO charity_fund_txns (id, kind, from_user_id, to_user_id, amount, related_wish_id, related_repay_id, note)
586
+ VALUES (?, 'repay_redirect', ?, NULL, ?, ?, ?, ?)`).run(generateId('cft'), r.wisher_user_id, amount, id, rid, r.note || null);
587
+ // 许愿人:还愿 5 + 转捐额外 3
588
+ ensureCharityRep(db, r.wisher_user_id);
589
+ db.prepare(`UPDATE charity_reputation SET redirect_honor = redirect_honor + 3, prestige_score = prestige_score + 8, last_active = datetime('now') WHERE user_id = ?`).run(r.wisher_user_id);
590
+ // 施善人:谢绝接受荣誉 +2
591
+ ensureCharityRep(db, user.id);
592
+ db.prepare(`UPDATE charity_reputation SET grace_honor = grace_honor + 2, prestige_score = prestige_score + 2, last_active = datetime('now') WHERE user_id = ?`).run(user.id);
593
+ }
594
+ // 重算徽章
595
+ for (const uid of [r.wisher_user_id, r.fulfiller_user_id]) {
596
+ const s = db.prepare('SELECT prestige_score FROM charity_reputation WHERE user_id = ?').get(uid)?.prestige_score || 0;
597
+ db.prepare('UPDATE charity_reputation SET badge_tier = ? WHERE user_id = ?').run(charityBadgeTier(s), uid);
598
+ }
599
+ })();
600
+ if (raceLost)
601
+ return void res.json({ error: '该还愿已被处理(可能 auto-accept 或重复点击),请刷新' });
602
+ // P2.4 通知:许愿人收到响应
603
+ try {
604
+ const label = choice === 'accept' ? '已接受你的还愿' : '谢绝接受 · 已转入慈善基金';
605
+ const t = db.prepare('SELECT title FROM wishes WHERE id = ?').get(id).title;
606
+ db.prepare(`INSERT INTO notifications (id, user_id, wish_id, type, title, body, created_at)
607
+ VALUES (?,?,?,'wish_repay_resp',?,?,datetime('now'))`)
608
+ .run(generateId('ntf'), r.wisher_user_id, id, `🌸 ${label}`, `「${t}」 ${choice === 'accept' ? '施善人已接受还愿' : '+8 威望已入账(含 +3 转捐荣誉)'}`);
609
+ }
610
+ catch (e) {
611
+ console.error('[charity notify repay resp]', e);
612
+ }
613
+ res.json({ ok: true, choice });
614
+ });
615
+ // 任何人捐款给慈善基金
616
+ app.post('/api/charity/fund/donate', (req, res) => {
617
+ const user = auth(req, res);
618
+ if (!user)
619
+ return;
620
+ // P0 fix: 受信角色不可捐款(无钱包)— 鼓励中立治理
621
+ if (isTrustedRole(user))
622
+ return void res.status(403).json({ error: '受信角色无钱包', error_code: 'TRUSTED_ROLE_NO_WALLET' });
623
+ if (!rateLimitOk(req.ip || '', 20, 60_000))
624
+ return void res.status(429).json({ error: '请求过于频繁' });
625
+ const body = req.body;
626
+ const amount = Number(body.amount);
627
+ if (!Number.isFinite(amount) || amount < CHARITY_DONATION_MIN)
628
+ return void res.json({ error: `捐款需 ≥ ${CHARITY_DONATION_MIN} WAZ` });
629
+ const wallet = db.prepare(`SELECT balance FROM wallets WHERE user_id = ?`).get(user.id);
630
+ if (!wallet || wallet.balance < amount)
631
+ return void res.json({ error: '余额不足' });
632
+ // 当日已得荣誉上限
633
+ const todayHonor = db.prepare(`SELECT IFNULL(SUM(amount),0) as s FROM charity_fund_txns WHERE kind='donation' AND from_user_id = ? AND created_at > datetime('now','-1 day')`).get(user.id).s;
634
+ const remain = Math.max(0, CHARITY_DONATION_DAILY_HONOR_CAP - todayHonor);
635
+ const honor = Math.min(amount, remain); // 1 WAZ = 1 honor,封顶 50/日
636
+ db.transaction(() => {
637
+ db.prepare(`UPDATE wallets SET balance = balance - ? WHERE user_id = ?`).run(amount, user.id);
638
+ db.prepare(`UPDATE charity_fund SET balance = balance + ?, total_donated = total_donated + ?, updated_at = datetime('now') WHERE id = 'main'`).run(amount, amount);
639
+ db.prepare(`INSERT INTO charity_fund_txns (id, kind, from_user_id, to_user_id, amount, note)
640
+ VALUES (?, 'donation', ?, NULL, ?, ?)`).run(generateId('cft'), user.id, amount, body.note ? String(body.note).slice(0, 300) : null);
641
+ ensureCharityRep(db, user.id);
642
+ db.prepare(`UPDATE charity_reputation SET donation_total = donation_total + ?, donation_honor = donation_honor + ?, prestige_score = prestige_score + ?, last_active = datetime('now') WHERE user_id = ?`).run(amount, honor, honor, user.id);
643
+ const s = db.prepare('SELECT prestige_score FROM charity_reputation WHERE user_id = ?').get(user.id).prestige_score;
644
+ db.prepare('UPDATE charity_reputation SET badge_tier = ? WHERE user_id = ?').run(charityBadgeTier(s), user.id);
645
+ })();
646
+ // 📡 Webhook fire — 通知 donor 自己(可订阅自己的捐款历史)
647
+ fireWebhooks('charity.donation', { amount, note: body.note || null, honor_earned: honor }, [user.id]).catch(e => console.error('[webhook]', e));
648
+ res.json({ ok: true, amount, honor_earned: honor, daily_cap_remaining: Math.max(0, remain - honor) });
649
+ });
650
+ // GET 基金概况 + 最近流水
651
+ app.get('/api/charity/fund', (_req, res) => {
652
+ const fund = db.prepare(`SELECT * FROM charity_fund WHERE id = 'main'`).get();
653
+ const recent = db.prepare(`
654
+ SELECT cft.id, cft.kind, cft.amount, cft.note, cft.created_at,
655
+ u.handle as donor_handle, u.region as donor_region
656
+ FROM charity_fund_txns cft
657
+ LEFT JOIN users u ON u.id = cft.from_user_id
658
+ ORDER BY cft.created_at DESC LIMIT 50
659
+ `).all();
660
+ const topDonors = db.prepare(`
661
+ SELECT u.handle, u.region, cr.donation_total, cr.donation_honor
662
+ FROM charity_reputation cr JOIN users u ON u.id = cr.user_id
663
+ WHERE cr.donation_total > 0
664
+ ORDER BY cr.donation_total DESC LIMIT 20
665
+ `).all();
666
+ res.json({ fund, recent, top_donors: topDonors });
667
+ });
668
+ // P2.3 — 举报愿望
669
+ app.post('/api/wishes/:id/report', (req, res) => {
670
+ const user = auth(req, res);
671
+ if (!user)
672
+ return;
673
+ if (!rateLimitOk(req.ip || '', 10, 60_000))
674
+ return void res.status(429).json({ error: '请求过于频繁' });
675
+ const id = req.params.id;
676
+ const body = req.body;
677
+ const reason = String(body.reason || '');
678
+ if (!['spam', 'fraud', 'inappropriate', 'other'].includes(reason))
679
+ return void res.json({ error: 'reason 无效' });
680
+ const note = body.note ? String(body.note).slice(0, 300) : null;
681
+ const exists = db.prepare('SELECT 1 FROM wishes WHERE id = ?').get(id);
682
+ if (!exists)
683
+ return void res.json({ error: '愿望不存在' });
684
+ try {
685
+ db.prepare(`INSERT INTO wish_reports (id, wish_id, reporter_id, reason, note) VALUES (?,?,?,?,?)`)
686
+ .run(generateId('wr'), id, user.id, reason, note);
687
+ }
688
+ catch {
689
+ return void res.json({ error: '你已举报过此愿望' });
690
+ }
691
+ // 3 个不同举报人 → 自动隐藏(status='disputed')
692
+ const cnt = db.prepare("SELECT COUNT(1) as n FROM wish_reports WHERE wish_id = ? AND status = 'pending'").get(id).n;
693
+ if (cnt >= 3) {
694
+ db.prepare("UPDATE wishes SET status = 'disputed' WHERE id = ? AND status IN ('open','claimed')").run(id);
695
+ }
696
+ res.json({ ok: true, total_reports: cnt, auto_hidden: cnt >= 3 });
697
+ });
698
+ // ─── admin 慈善管理 ─────────────────────────────────────────
699
+ app.get('/api/admin/wish-reports', (req, res) => {
700
+ const admin = requireContentAdmin(req, res);
701
+ if (!admin)
702
+ return;
703
+ const status = String(req.query.status || 'pending');
704
+ const where = status === 'all' ? '1=1' : 'wr.status = ?';
705
+ const args = status === 'all' ? [] : [status];
706
+ const rows = db.prepare(`
707
+ SELECT wr.id, wr.wish_id, wr.reporter_id, wr.reason, wr.note, wr.status, wr.created_at,
708
+ w.title as wish_title, w.user_id as wish_owner_id, w.status as wish_status,
709
+ u.handle as reporter_handle
710
+ FROM wish_reports wr
711
+ JOIN wishes w ON w.id = wr.wish_id
712
+ LEFT JOIN users u ON u.id = wr.reporter_id
713
+ WHERE ${where}
714
+ ORDER BY wr.created_at DESC LIMIT 200
715
+ `).all(...args);
716
+ res.json({ items: rows });
717
+ });
718
+ app.patch('/api/admin/wish-reports/:id', (req, res) => {
719
+ const admin = requireContentAdmin(req, res);
720
+ if (!admin)
721
+ return;
722
+ const action = String(req.body.action || '');
723
+ if (!['dismiss', 'actioned'].includes(action))
724
+ return void res.json({ error: 'action 必须是 dismiss 或 actioned' });
725
+ const r = db.prepare(`UPDATE wish_reports SET status = ? WHERE id = ?`).run(action === 'dismiss' ? 'dismissed' : 'actioned', req.params.id);
726
+ if (r.changes === 0)
727
+ return void res.json({ error: '举报不存在' });
728
+ try {
729
+ db.prepare(`INSERT INTO admin_audit_log (id, admin_id, action, target_type, target_id, detail) VALUES (?,?,?,?,?,?)`)
730
+ .run(generateId('audit'), admin.id, 'wish_report_' + action, 'wish_report', req.params.id, null);
731
+ }
732
+ catch { }
733
+ res.json({ ok: true, status: action === 'dismiss' ? 'dismissed' : 'actioned' });
734
+ });
735
+ app.post('/api/admin/wishes/:id/takedown', (req, res) => {
736
+ const admin = requireContentAdmin(req, res);
737
+ if (!admin)
738
+ return;
739
+ const reason = String(req.body.reason || '').trim();
740
+ if (!reason)
741
+ return void res.json({ error: '必须填写下架原因' });
742
+ const w = db.prepare(`SELECT user_id, status, escrow_locked FROM wishes WHERE id = ?`).get(req.params.id);
743
+ if (!w)
744
+ return void res.json({ error: '愿望不存在' });
745
+ db.transaction(() => {
746
+ db.prepare(`UPDATE wishes SET status='cancelled' WHERE id = ?`).run(req.params.id);
747
+ if (w.escrow_locked > 0) {
748
+ db.prepare(`UPDATE wallets SET balance = balance + ?, staked = staked - ? WHERE user_id = ?`).run(w.escrow_locked, w.escrow_locked, w.user_id);
749
+ }
750
+ db.prepare(`INSERT INTO admin_audit_log (id, admin_id, action, target_type, target_id, detail) VALUES (?,?,?,?,?,?)`)
751
+ .run(generateId('audit'), admin.id, 'wish_takedown', 'wish', req.params.id, JSON.stringify({ reason }));
752
+ })();
753
+ res.json({ ok: true });
754
+ });
755
+ app.post('/api/admin/charity/fund/disburse', (req, res) => {
756
+ const admin = requireProtocolAdmin(req, res);
757
+ if (!admin)
758
+ return;
759
+ const body = req.body;
760
+ const amount = Number(body.amount);
761
+ const toUserId = String(body.to_user_id || '').trim();
762
+ const note = String(body.note || '').trim();
763
+ if (!Number.isFinite(amount) || amount <= 0)
764
+ return void res.json({ error: 'amount 无效' });
765
+ if (!toUserId)
766
+ return void res.json({ error: 'to_user_id 必填' });
767
+ if (!note)
768
+ return void res.json({ error: '必须填写拨款用途(写入审计)' });
769
+ const targetUser = db.prepare(`SELECT id, name FROM users WHERE id = ?`).get(toUserId);
770
+ if (!targetUser)
771
+ return void res.json({ error: '收款用户不存在' });
772
+ const fund = db.prepare(`SELECT balance FROM charity_fund WHERE id = 'main'`).get();
773
+ if (fund.balance < amount)
774
+ return void res.json({ error: `基金余额不足 (当前 ${fund.balance})` });
775
+ db.transaction(() => {
776
+ db.prepare(`UPDATE charity_fund SET balance = balance - ?, total_disbursed = total_disbursed + ?, updated_at = datetime('now') WHERE id = 'main'`).run(amount, amount);
777
+ db.prepare(`UPDATE wallets SET balance = balance + ? WHERE user_id = ?`).run(amount, toUserId);
778
+ db.prepare(`INSERT INTO charity_fund_txns (id, kind, from_user_id, to_user_id, amount, note) VALUES (?, 'disburse', NULL, ?, ?, ?)`)
779
+ .run(generateId('cft'), toUserId, amount, note);
780
+ db.prepare(`INSERT INTO admin_audit_log (id, admin_id, action, target_type, target_id, detail) VALUES (?,?,?,?,?,?)`)
781
+ .run(generateId('audit'), admin.id, 'charity_disburse', 'user', toUserId, JSON.stringify({ amount, note }));
782
+ try {
783
+ db.prepare(`INSERT INTO notifications (id, user_id, type, title, body, created_at) VALUES (?,?,'charity_disburse',?,?,datetime('now'))`)
784
+ .run(generateId('ntf'), toUserId, `💰 慈善基金拨款 +${amount} WAZ`, note);
785
+ }
786
+ catch { }
787
+ })();
788
+ res.json({ ok: true, amount, to_user: targetUser.name });
789
+ });
790
+ app.get('/api/admin/charity/fund', (req, res) => {
791
+ const admin = requireProtocolAdmin(req, res);
792
+ if (!admin)
793
+ return;
794
+ const fund = db.prepare(`SELECT * FROM charity_fund WHERE id = 'main'`).get();
795
+ const recent = db.prepare(`
796
+ SELECT cft.*, uf.name as from_name, ut.name as to_name
797
+ FROM charity_fund_txns cft
798
+ LEFT JOIN users uf ON uf.id = cft.from_user_id
799
+ LEFT JOIN users ut ON ut.id = cft.to_user_id
800
+ ORDER BY cft.created_at DESC LIMIT 100
801
+ `).all();
802
+ res.json({ fund, recent });
803
+ });
804
+ // 慈善排行
805
+ app.get('/api/charity/leaderboard', (_req, res) => {
806
+ const rows = db.prepare(`
807
+ SELECT cr.prestige_score, cr.wishes_fulfilled, cr.wishes_made, cr.badge_tier,
808
+ u.handle, u.region
809
+ FROM charity_reputation cr JOIN users u ON u.id = cr.user_id
810
+ WHERE cr.prestige_score > 0
811
+ ORDER BY cr.prestige_score DESC, cr.wishes_fulfilled DESC
812
+ LIMIT 50
813
+ `).all();
814
+ res.json({ items: rows });
815
+ });
816
+ }