@seasonkoh/webaz 0.1.24 → 0.1.26

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 (195) hide show
  1. package/README.md +5 -1
  2. package/dist/layer0-foundation/L0-1-database/db-backends/pg-backend.js +51 -0
  3. package/dist/layer0-foundation/L0-1-database/db-backends/sql-dialect-datetime.js +437 -0
  4. package/dist/layer0-foundation/L0-1-database/db-backends/sql-placeholders.js +98 -0
  5. package/dist/layer0-foundation/L0-1-database/db.js +65 -0
  6. package/dist/layer0-foundation/L0-2-state-machine/order-chain.js +13 -11
  7. package/dist/layer0-foundation/L0-2-state-machine/transitions.js +1 -1
  8. package/dist/layer0-foundation/L0-5-manifest/manifest.js +13 -11
  9. package/dist/layer1-agent/L1-1-mcp-server/server.js +288 -208
  10. package/dist/layer1-agent/L1-2-external-anchor/anchor-engine.js +14 -12
  11. package/dist/layer2-business/L2-6-notifications/notification-engine.js +8 -5
  12. package/dist/layer2-business/L2-7-snf/snf-engine.js +16 -14
  13. package/dist/layer2-business/L2-8-feedback/build-feedback-engine.js +18 -10
  14. package/dist/layer2-business/L2-9-contribution/build-reputation-engine.js +37 -23
  15. package/dist/layer2-business/L2-9-contribution/build-task-agent-metadata-store.js +182 -0
  16. package/dist/layer2-business/L2-9-contribution/build-task-participation.js +47 -0
  17. package/dist/layer2-business/L2-9-contribution/build-task-read.js +222 -0
  18. package/dist/layer2-business/L2-9-contribution/build-tasks-engine.js +11 -3
  19. package/dist/layer2-business/L2-9-contribution/canonical-contribution-target.js +16 -0
  20. package/dist/layer2-business/L2-9-contribution/contribution-display-envelope.js +40 -0
  21. package/dist/layer2-business/L2-9-contribution/contribution-score-contract.js +36 -0
  22. package/dist/layer2-business/L2-9-contribution/contribution-score-evidence.js +61 -0
  23. package/dist/layer2-business/L2-9-contribution/github-credential/canonical.js +60 -0
  24. package/dist/layer2-business/L2-9-contribution/github-credential/github-credential.schema.js +140 -0
  25. package/dist/layer2-business/L2-9-contribution/github-credential/github-fetch-adapter.js +437 -0
  26. package/dist/layer2-business/L2-9-contribution/github-credential/self-consistency.js +38 -0
  27. package/dist/layer2-business/L2-9-contribution/github-credential/verifier.js +231 -0
  28. package/dist/layer2-business/L2-9-contribution/github-credential-ingestion-engine.js +145 -0
  29. package/dist/layer2-business/L2-9-contribution/github-credential-store.js +115 -0
  30. package/dist/layer2-business/L2-9-contribution/identity-binding-engine.js +134 -0
  31. package/dist/layer2-business/L2-9-contribution/identity-binding-store.js +101 -0
  32. package/dist/layer2-business/L2-9-contribution/identity-claim-challenge-engine.js +126 -0
  33. package/dist/layer2-business/L2-9-contribution/identity-claim-challenge-store.js +30 -0
  34. package/dist/layer2-business/L2-9-contribution/identity-claim-discovery.js +55 -0
  35. package/dist/layer2-business/L2-9-contribution/identity-claim-engine.js +109 -0
  36. package/dist/layer2-business/L2-9-contribution/identity-claim-fact-precondition.js +22 -0
  37. package/dist/layer2-business/L2-9-contribution/identity-claim-proof-verifier.js +97 -0
  38. package/dist/layer2-business/L2-9-contribution/identity-claim-read.js +59 -0
  39. package/dist/layer2-business/L2-9-contribution/task-proposal-ai-store.js +99 -0
  40. package/dist/layer2-business/L2-9-contribution/task-proposal-draft.js +191 -0
  41. package/dist/layer2-business/L2-9-contribution/task-proposal-store.js +129 -0
  42. package/dist/layer2-business/L2-notes/note-photo-storage.js +4 -2
  43. package/dist/layer3-trust/L3-1-dispute-engine/dispute-engine.js +17 -15
  44. package/dist/layer3-trust/L3-1-dispute-engine/evidence-storage.js +11 -8
  45. package/dist/layer4-economics/L4-3-reputation/reputation-engine.js +9 -8
  46. package/dist/layer4-economics/L4-4-skill-market/skill-engine.js +11 -8
  47. package/dist/layer4-economics/L4-4-skill-market/skill-listing-engine.js +22 -16
  48. package/dist/pwa/acp-feed.js +13 -1
  49. package/dist/pwa/admin-bearer-auth.js +21 -0
  50. package/dist/pwa/contract-fingerprint.js +2 -0
  51. package/dist/pwa/email-delivery.js +127 -0
  52. package/dist/pwa/endpoint-actions.js +5 -1
  53. package/dist/pwa/goal-index.js +8 -8
  54. package/dist/pwa/human-presence.js +62 -0
  55. package/dist/pwa/public/app.js +1485 -283
  56. package/dist/pwa/public/i18n.js +297 -59
  57. package/dist/pwa/public/index.html +1 -0
  58. package/dist/pwa/public/openapi.json +5 -5
  59. package/dist/pwa/public/whitepaper/en/index.html +153 -0
  60. package/dist/pwa/public/whitepaper/zh-CN/index.html +153 -0
  61. package/dist/pwa/rate-limit.js +22 -0
  62. package/dist/pwa/routes/account-deletion.js +15 -13
  63. package/dist/pwa/routes/addresses.js +10 -9
  64. package/dist/pwa/routes/admin-admins.js +13 -14
  65. package/dist/pwa/routes/admin-analytics.js +109 -69
  66. package/dist/pwa/routes/admin-atomic.js +10 -4
  67. package/dist/pwa/routes/admin-catalog.js +13 -11
  68. package/dist/pwa/routes/admin-editor-picks.js +15 -10
  69. package/dist/pwa/routes/admin-events.js +5 -3
  70. package/dist/pwa/routes/admin-health.js +2 -1
  71. package/dist/pwa/routes/admin-moderation.js +50 -29
  72. package/dist/pwa/routes/admin-ops.js +35 -23
  73. package/dist/pwa/routes/admin-protocol-params.js +16 -19
  74. package/dist/pwa/routes/admin-reports.js +23 -21
  75. package/dist/pwa/routes/admin-tokenomics.js +26 -25
  76. package/dist/pwa/routes/admin-users-lifecycle.js +37 -40
  77. package/dist/pwa/routes/admin-users-query.js +65 -53
  78. package/dist/pwa/routes/admin-verifier-flow.js +82 -41
  79. package/dist/pwa/routes/admin-verifier-whitelist.js +55 -27
  80. package/dist/pwa/routes/admin-wallet-ops.js +32 -7
  81. package/dist/pwa/routes/agent-buy.js +46 -22
  82. package/dist/pwa/routes/agent-governance.js +52 -56
  83. package/dist/pwa/routes/ai.js +7 -5
  84. package/dist/pwa/routes/analytics.js +43 -41
  85. package/dist/pwa/routes/anchors.js +19 -20
  86. package/dist/pwa/routes/announcements.js +13 -13
  87. package/dist/pwa/routes/arbitrator.js +97 -31
  88. package/dist/pwa/routes/auction.js +157 -116
  89. package/dist/pwa/routes/auth-login.js +6 -4
  90. package/dist/pwa/routes/auth-read.js +21 -10
  91. package/dist/pwa/routes/auth-register.js +111 -26
  92. package/dist/pwa/routes/auth-sessions.js +12 -11
  93. package/dist/pwa/routes/blocklist.js +16 -15
  94. package/dist/pwa/routes/build-feedback.js +10 -9
  95. package/dist/pwa/routes/build-reputation.js +6 -2
  96. package/dist/pwa/routes/build-tasks.js +45 -13
  97. package/dist/pwa/routes/buyer-feeds.js +27 -25
  98. package/dist/pwa/routes/cart.js +16 -15
  99. package/dist/pwa/routes/charity.js +212 -150
  100. package/dist/pwa/routes/chat.js +42 -43
  101. package/dist/pwa/routes/checkin-tasks.js +10 -9
  102. package/dist/pwa/routes/checkout-helpers.js +12 -10
  103. package/dist/pwa/routes/claim-initiators.js +34 -14
  104. package/dist/pwa/routes/claim-verify.js +86 -53
  105. package/dist/pwa/routes/claim-voting.js +43 -18
  106. package/dist/pwa/routes/contribution-identity.js +164 -0
  107. package/dist/pwa/routes/contribution-score.js +19 -0
  108. package/dist/pwa/routes/coupons.js +19 -16
  109. package/dist/pwa/routes/dashboards.js +18 -16
  110. package/dist/pwa/routes/dispute-cases.js +25 -24
  111. package/dist/pwa/routes/disputes-read.js +45 -51
  112. package/dist/pwa/routes/disputes-write.js +124 -61
  113. package/dist/pwa/routes/evidence.js +9 -9
  114. package/dist/pwa/routes/external-anchors.js +13 -12
  115. package/dist/pwa/routes/feedback.js +29 -33
  116. package/dist/pwa/routes/flash-sales.js +18 -16
  117. package/dist/pwa/routes/follows.js +25 -24
  118. package/dist/pwa/routes/governance-auto-deactivate.js +21 -9
  119. package/dist/pwa/routes/governance-onboarding.js +70 -59
  120. package/dist/pwa/routes/group-buys.js +22 -22
  121. package/dist/pwa/routes/growth.js +34 -31
  122. package/dist/pwa/routes/import-product.js +12 -10
  123. package/dist/pwa/routes/kyc.js +9 -8
  124. package/dist/pwa/routes/leaderboard.js +20 -18
  125. package/dist/pwa/routes/listings.js +23 -22
  126. package/dist/pwa/routes/logistics.js +10 -8
  127. package/dist/pwa/routes/manifests.js +27 -27
  128. package/dist/pwa/routes/me-data.js +23 -21
  129. package/dist/pwa/routes/notifications.js +7 -6
  130. package/dist/pwa/routes/offers.js +30 -12
  131. package/dist/pwa/routes/orders-action.js +51 -29
  132. package/dist/pwa/routes/orders-create.js +75 -20
  133. package/dist/pwa/routes/orders-read.js +21 -20
  134. package/dist/pwa/routes/p2p-products.js +30 -18
  135. package/dist/pwa/routes/payments-governance.js +61 -56
  136. package/dist/pwa/routes/peers.js +9 -8
  137. package/dist/pwa/routes/pin-receipts.js +13 -13
  138. package/dist/pwa/routes/products-aliases.js +12 -10
  139. package/dist/pwa/routes/products-claims.js +36 -17
  140. package/dist/pwa/routes/products-create.js +53 -38
  141. package/dist/pwa/routes/products-crud.js +17 -16
  142. package/dist/pwa/routes/products-links.js +49 -26
  143. package/dist/pwa/routes/products-list.js +6 -4
  144. package/dist/pwa/routes/products-meta.js +40 -39
  145. package/dist/pwa/routes/products-update.js +19 -5
  146. package/dist/pwa/routes/profile-credentials.js +20 -19
  147. package/dist/pwa/routes/profile-identity.js +14 -13
  148. package/dist/pwa/routes/profile-location.js +7 -6
  149. package/dist/pwa/routes/profile-placement.js +20 -19
  150. package/dist/pwa/routes/profile-prefs.js +11 -11
  151. package/dist/pwa/routes/promoter.js +58 -66
  152. package/dist/pwa/routes/public-build-tasks.js +19 -0
  153. package/dist/pwa/routes/public-utils.js +108 -46
  154. package/dist/pwa/routes/push.js +16 -15
  155. package/dist/pwa/routes/ratings.js +92 -32
  156. package/dist/pwa/routes/recover-key.js +66 -26
  157. package/dist/pwa/routes/referral.js +37 -52
  158. package/dist/pwa/routes/reputation.js +3 -2
  159. package/dist/pwa/routes/returns.js +76 -73
  160. package/dist/pwa/routes/reviews.js +41 -18
  161. package/dist/pwa/routes/rewards-apply.js +16 -15
  162. package/dist/pwa/routes/rewards-auto-downgrade.js +9 -7
  163. package/dist/pwa/routes/rewards-escrow-expire.js +7 -5
  164. package/dist/pwa/routes/rfqs.js +163 -85
  165. package/dist/pwa/routes/search.js +16 -14
  166. package/dist/pwa/routes/secondhand.js +25 -22
  167. package/dist/pwa/routes/seller-quota.js +24 -26
  168. package/dist/pwa/routes/share-redirects.js +60 -55
  169. package/dist/pwa/routes/shareables-interactions.js +34 -35
  170. package/dist/pwa/routes/shareables.js +55 -51
  171. package/dist/pwa/routes/shop-referral.js +58 -0
  172. package/dist/pwa/routes/shops.js +25 -20
  173. package/dist/pwa/routes/signaling.js +10 -9
  174. package/dist/pwa/routes/skill-market.js +16 -16
  175. package/dist/pwa/routes/skills.js +15 -14
  176. package/dist/pwa/routes/snf.js +14 -13
  177. package/dist/pwa/routes/tags.js +10 -9
  178. package/dist/pwa/routes/task-proposals.js +121 -0
  179. package/dist/pwa/routes/trial.js +72 -52
  180. package/dist/pwa/routes/trusted-kpi.js +20 -18
  181. package/dist/pwa/routes/url-claim.js +67 -28
  182. package/dist/pwa/routes/users-public.js +62 -70
  183. package/dist/pwa/routes/variants.js +12 -13
  184. package/dist/pwa/routes/verifier-user.js +61 -21
  185. package/dist/pwa/routes/verify-tasks.js +49 -25
  186. package/dist/pwa/routes/waitlist.js +16 -15
  187. package/dist/pwa/routes/wallet-read.js +75 -37
  188. package/dist/pwa/routes/wallet-write.js +12 -9
  189. package/dist/pwa/routes/webauthn.js +25 -26
  190. package/dist/pwa/routes/webhooks.js +26 -26
  191. package/dist/pwa/routes/welcome.js +45 -50
  192. package/dist/pwa/routes/wishlist-qa.js +29 -32
  193. package/dist/pwa/server.js +304 -90
  194. package/dist/version.js +1 -1
  195. package/package.json +76 -3
@@ -1,4 +1,11 @@
1
1
  import { createHash, createHmac, randomBytes } from 'node:crypto';
2
+ import { dbOne, dbAll, dbRun } from '../../layer0-foundation/L0-1-database/db.js';
3
+ // RFC-016 Phase 1 — 仅端点纯校验读/公开列表/读回 + 单语句标记/CAS/通知写 → async seam。
4
+ // 保持同步(Phase 3 再用 pg tx/行锁):
5
+ // - 模块级 helper ensureCharityRep(被多个 tx 内部调用)/ isCharityBlocked(为一致性);
6
+ // - 两个 cron 函数 expireCharityWishes / autoAcceptExpiredRepayments 整体(逐项 db.transaction 写,
7
+ // 由 server.ts 同步 runEnforcement 调用,不动该扫描循环);
8
+ // - 所有端点 db.transaction 钱块(发布/确认/取消/还愿/响应/捐款/下架/拨款)。
2
9
  // ─── 域常量 ───────────────────────────────────────────────
3
10
  const CHARITY_CATEGORIES = ['medical', 'education', 'daily', 'elderly', 'disaster', 'tech', 'other'];
4
11
  const CHARITY_CATEGORY_LABEL = {
@@ -129,7 +136,7 @@ export function autoAcceptExpiredRepayments(db) {
129
136
  export function registerCharityRoutes(app, deps) {
130
137
  const { db, auth, generateId, rateLimitOk, getUser, isTrustedRole, requireContentAdmin, requireProtocolAdmin, fireWebhooks } = deps;
131
138
  // POST /api/wishes — 发布愿望
132
- app.post('/api/wishes', (req, res) => {
139
+ app.post('/api/wishes', async (req, res) => {
133
140
  const user = auth(req, res);
134
141
  if (!user)
135
142
  return;
@@ -154,7 +161,7 @@ export function registerCharityRoutes(app, deps) {
154
161
  const windowHours = Math.max(CHARITY_WINDOW_MIN_HOURS, Math.min(CHARITY_WINDOW_MAX_HOURS, Math.floor(Number(body.window_hours || 168))));
155
162
  const allowPublic = body.allow_public ? 1 : 0;
156
163
  // 月度上限
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;
164
+ const monthly = (await dbOne("SELECT COUNT(1) as n FROM wishes WHERE user_id = ? AND created_at > datetime('now','-30 days')", [user.id])).n;
158
165
  if (monthly >= CHARITY_MONTHLY_WISH_CAP)
159
166
  return void res.json({ error: `月度许愿上限 ${CHARITY_MONTHLY_WISH_CAP} 个,请下月再来` });
160
167
  // 现金类需托管
@@ -169,7 +176,7 @@ export function registerCharityRoutes(app, deps) {
169
176
  // 卖家承诺托管:可选 — 锁仓 0 表示纯协调(不推荐),>0 表示真托管
170
177
  const lockSelf = body.escrow_self ? Number(body.target_waz) : 0;
171
178
  if (lockSelf > 0) {
172
- const w = db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(user.id);
179
+ const w = await dbOne('SELECT balance FROM wallets WHERE user_id = ?', [user.id]);
173
180
  if (!w || w.balance < lockSelf)
174
181
  return void res.json({ error: '余额不足以自托管' });
175
182
  escrow = lockSelf;
@@ -179,19 +186,30 @@ export function registerCharityRoutes(app, deps) {
179
186
  // P2.1 修复:去掉 secret_keep_safe(无 reveal 端点用不上,节省一次握手)
180
187
  const commitHash = createHash('sha256').update(`${user.id}|${randomBytes(16).toString('hex')}|${id}|${Date.now()}`).digest('hex');
181
188
  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
- })();
189
+ // Codex #238 P1:await 余额预检与同步 tx 间有 yield;escrow 扣款带 balance>=escrow 守卫,
190
+ // changes!==1 即并发已花掉余额 → 抛回滚(连带回滚已插 wish),杜绝超额自助托管。
191
+ try {
192
+ db.transaction(() => {
193
+ 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)
194
+ VALUES (?,?,?,?,?,?,?,?,?,?,?, datetime('now', '+' || ? || ' hours'))`).run(id, user.id, wisherHandle, cat, title, content, targetKind, targetWaz, escrow, commitHash, allowPublic, windowHours);
195
+ if (escrow > 0) {
196
+ const d = db.prepare('UPDATE wallets SET balance = balance - ?, staked = staked + ? WHERE user_id = ? AND balance >= ?').run(escrow, escrow, user.id, escrow);
197
+ if (d.changes !== 1)
198
+ throw new Error('CHARITY_INSUFFICIENT_BALANCE');
199
+ }
200
+ ensureCharityRep(db, user.id);
201
+ db.prepare("UPDATE charity_reputation SET wishes_made = wishes_made + 1, last_active = datetime('now') WHERE user_id = ?").run(user.id);
202
+ })();
203
+ }
204
+ catch (e) {
205
+ if (e.message === 'CHARITY_INSUFFICIENT_BALANCE')
206
+ return void res.json({ error: '余额不足,无法锁定自助托管金' });
207
+ throw e;
208
+ }
191
209
  res.json({ id, wisher_handle: wisherHandle, escrow_locked: escrow });
192
210
  });
193
211
  // GET /api/wishes — 浏览(匿名可访问)
194
- app.get('/api/wishes', (req, res) => {
212
+ app.get('/api/wishes', async (req, res) => {
195
213
  const where = ["status IN ('open','claimed')"];
196
214
  const args = [];
197
215
  if (req.query.category && isCharityCategory(String(req.query.category))) {
@@ -215,7 +233,7 @@ export function registerCharityRoutes(app, deps) {
215
233
  args.push('%' + qE + '%', '%' + qE + '%');
216
234
  }
217
235
  const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 30));
218
- const rows = db.prepare(`
236
+ const rows = await dbAll(`
219
237
  SELECT id, wisher_handle, category, title,
220
238
  substr(content, 1, 120) as content_preview,
221
239
  target_kind, target_waz, escrow_locked, status, allow_public,
@@ -224,29 +242,29 @@ export function registerCharityRoutes(app, deps) {
224
242
  WHERE ${where.join(' AND ')}
225
243
  ORDER BY created_at DESC
226
244
  LIMIT ?
227
- `).all(...args, limit);
245
+ `, [...args, limit]);
228
246
  res.json({ items: rows, categories: CHARITY_CATEGORIES, category_labels: CHARITY_CATEGORY_LABEL });
229
247
  });
230
248
  // GET /api/wishes/:id — 详情
231
- app.get('/api/wishes/:id', (req, res) => {
249
+ app.get('/api/wishes/:id', async (req, res) => {
232
250
  const id = req.params.id;
233
- const w = db.prepare(`SELECT * FROM wishes WHERE id = ?`).get(id);
251
+ const w = await dbOne(`SELECT * FROM wishes WHERE id = ?`, [id]);
234
252
  if (!w)
235
253
  return void res.json({ error: '愿望不存在' });
236
254
  const me = getUser(req);
237
255
  const isWisher = !!me && me.id === w.user_id;
238
256
  const isFulfiller = !!me && me.id === w.fulfiller_user_id;
239
- const fulfillments = db.prepare(`
257
+ const fulfillments = await dbAll(`
240
258
  SELECT id, fulfiller_handle, proof_hash, proof_note, status,
241
259
  confirmed_at, disclose_wisher, disclose_fulfiller, disclosed_at, created_at
242
260
  FROM wish_fulfillments WHERE wish_id = ?
243
261
  ORDER BY created_at DESC
244
- `).all(id);
245
- const repayments = db.prepare(`
262
+ `, [id]);
263
+ const repayments = await dbAll(`
246
264
  SELECT id, fulfillment_id, amount, note, status, responded_at, auto_expire_at, created_at
247
265
  FROM wish_repayments WHERE wish_id = ?
248
266
  ORDER BY created_at DESC
249
- `).all(id);
267
+ `, [id]);
250
268
  res.json({
251
269
  id: w.id, wisher_handle: w.wisher_handle, category: w.category, title: w.title,
252
270
  content: w.content, target_kind: w.target_kind, target_waz: w.target_waz,
@@ -260,7 +278,7 @@ export function registerCharityRoutes(app, deps) {
260
278
  // POST /api/wishes/:id/fulfill — 圆梦人认领
261
279
  // #1018 改名:原 /claim path 与 claim-initiators 的 wish_claim_task (fraud claim) 冲突
262
280
  // /claim 让 fraud-claim 独占(与 secondhand/auctions 三垂类对称)
263
- app.post('/api/wishes/:id/fulfill', (req, res) => {
281
+ app.post('/api/wishes/:id/fulfill', async (req, res) => {
264
282
  const user = auth(req, res);
265
283
  if (!user)
266
284
  return;
@@ -270,29 +288,28 @@ export function registerCharityRoutes(app, deps) {
270
288
  const blocked = isCharityBlocked(db, user.id);
271
289
  if (blocked.blocked)
272
290
  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);
291
+ const w = await dbOne(`SELECT user_id, status FROM wishes WHERE id = ?`, [id]);
274
292
  if (!w)
275
293
  return void res.json({ error: '愿望不存在' });
276
294
  if (w.status !== 'open')
277
295
  return void res.json({ error: '该愿望已被认领或已结束' });
278
296
  if (w.user_id === user.id) {
279
297
  // 反自施善(防自己给自己许愿圆满,套取威望):直接封锁 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);
298
+ await dbRun("INSERT OR REPLACE INTO charity_blocklist (user_id, reason, until) VALUES (?, 'self_fulfill_fraud', datetime('now','+30 days'))", [user.id]);
281
299
  return void res.json({ error: '禁止圆自己的愿。已封锁 30 天。' });
282
300
  }
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;
301
+ const monthly = (await dbOne("SELECT COUNT(1) as n FROM wishes WHERE fulfiller_user_id = ? AND claimed_at > datetime('now','-30 days')", [user.id])).n;
284
302
  if (monthly >= CHARITY_MONTHLY_FULFILL_CAP)
285
303
  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);
304
+ const claimRes = await dbRun(`UPDATE wishes SET status='claimed', fulfiller_user_id=?, claimed_at=datetime('now')
305
+ WHERE id = ? AND status='open'`, [user.id, id]);
288
306
  if (claimRes.changes === 0)
289
307
  return void res.json({ error: '该愿望已被他人认领,请刷新' });
290
308
  // P2.4 通知:许愿人收到"你的愿望被认领"
291
309
  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}」 施善人已开始行动,请等待证据`);
310
+ const t = (await dbOne('SELECT title FROM wishes WHERE id = ?', [id])).title;
311
+ await dbRun(`INSERT INTO notifications (id, user_id, wish_id, type, title, body, created_at)
312
+ VALUES (?,?,?,'wish_claimed',?,?,datetime('now'))`, [generateId('ntf'), w.user_id, id, '🤝 你的愿望被认领', `「${t}」 施善人已开始行动,请等待证据`]);
296
313
  }
297
314
  catch (e) {
298
315
  console.error('[charity notify claim]', e);
@@ -300,14 +317,14 @@ export function registerCharityRoutes(app, deps) {
300
317
  res.json({ ok: true, claim_timeout_hours: CHARITY_CLAIM_TIMEOUT_HOURS });
301
318
  });
302
319
  // POST /api/wishes/:id/proof — 提交证据
303
- app.post('/api/wishes/:id/proof', (req, res) => {
320
+ app.post('/api/wishes/:id/proof', async (req, res) => {
304
321
  const user = auth(req, res);
305
322
  if (!user)
306
323
  return;
307
324
  if (!rateLimitOk(req.ip || '', 30, 60_000))
308
325
  return void res.status(429).json({ error: '请求过于频繁' });
309
326
  const id = req.params.id;
310
- const w = db.prepare(`SELECT user_id, fulfiller_user_id, status FROM wishes WHERE id = ?`).get(id);
327
+ const w = await dbOne(`SELECT user_id, fulfiller_user_id, status FROM wishes WHERE id = ?`, [id]);
311
328
  if (!w)
312
329
  return void res.json({ error: '愿望不存在' });
313
330
  if (w.fulfiller_user_id !== user.id)
@@ -323,14 +340,13 @@ export function registerCharityRoutes(app, deps) {
323
340
  const sig = createHmac('sha256', user.api_key).update(`${id}|${proofHash}`).digest('hex');
324
341
  const fid = generateId('wf');
325
342
  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);
343
+ await dbRun(`INSERT INTO wish_fulfillments (id, wish_id, fulfiller_user_id, fulfiller_handle, proof_hash, proof_note, fulfiller_sig)
344
+ VALUES (?,?,?,?,?,?,?)`, [fid, id, user.id, handle, proofHash, proofNote, sig]);
328
345
  // P2.4 通知:许愿人收到"施善证据已提交,请确认"
329
346
  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 天不响应会自动确认)`);
347
+ const t = (await dbOne('SELECT title FROM wishes WHERE id = ?', [id])).title;
348
+ await dbRun(`INSERT INTO notifications (id, user_id, wish_id, type, title, body, created_at)
349
+ VALUES (?,?,?,'wish_proof',?,?,datetime('now'))`, [generateId('ntf'), w.user_id, id, '📤 施善证据已提交', `「${t}」 请尽快确认(14 天不响应会自动确认)`]);
334
350
  }
335
351
  catch (e) {
336
352
  console.error('[charity notify proof]', e);
@@ -338,14 +354,14 @@ export function registerCharityRoutes(app, deps) {
338
354
  res.json({ id: fid, fulfiller_handle: handle, signature: sig });
339
355
  });
340
356
  // POST /api/wishes/:id/confirm — 许愿人确认
341
- app.post('/api/wishes/:id/confirm', (req, res) => {
357
+ app.post('/api/wishes/:id/confirm', async (req, res) => {
342
358
  const user = auth(req, res);
343
359
  if (!user)
344
360
  return;
345
361
  if (!rateLimitOk(req.ip || '', 30, 60_000))
346
362
  return void res.status(429).json({ error: '请求过于频繁' });
347
363
  const id = req.params.id;
348
- const w = db.prepare(`SELECT * FROM wishes WHERE id = ?`).get(id);
364
+ const w = await dbOne(`SELECT * FROM wishes WHERE id = ?`, [id]);
349
365
  if (!w)
350
366
  return void res.json({ error: '愿望不存在' });
351
367
  if (w.user_id !== user.id)
@@ -353,7 +369,7 @@ export function registerCharityRoutes(app, deps) {
353
369
  if (w.status !== 'claimed')
354
370
  return void res.json({ error: '当前状态不可确认' });
355
371
  const fid = String(req.body.fulfillment_id || '');
356
- const wf = db.prepare(`SELECT * FROM wish_fulfillments WHERE id = ? AND wish_id = ?`).get(fid, id);
372
+ const wf = await dbOne(`SELECT * FROM wish_fulfillments WHERE id = ? AND wish_id = ?`, [fid, id]);
357
373
  if (!wf)
358
374
  return void res.json({ error: '证据不存在' });
359
375
  if (wf.status !== 'proof_pending')
@@ -388,9 +404,8 @@ export function registerCharityRoutes(app, deps) {
388
404
  return void res.json({ error: '该证据已被处理,请刷新' });
389
405
  // P2.4 通知:施善人收到"许愿人已确认"
390
406
  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 威望已入账`);
407
+ await dbRun(`INSERT INTO notifications (id, user_id, wish_id, type, title, body, created_at)
408
+ VALUES (?,?,?,'wish_confirmed',?,?,datetime('now'))`, [generateId('ntf'), w.fulfiller_user_id, id, '✓ 许愿人已确认圆梦', `「${w.title}」 +10 威望已入账`]);
394
409
  }
395
410
  catch (e) {
396
411
  console.error('[charity notify confirm]', e);
@@ -400,19 +415,19 @@ export function registerCharityRoutes(app, deps) {
400
415
  res.json({ ok: true, wisher_sig: wisherSig });
401
416
  });
402
417
  // POST /api/wishes/:id/disclose — 申请公开(双方同意才公开)
403
- app.post('/api/wishes/:id/disclose', (req, res) => {
418
+ app.post('/api/wishes/:id/disclose', async (req, res) => {
404
419
  const user = auth(req, res);
405
420
  if (!user)
406
421
  return;
407
422
  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);
423
+ const w = await dbOne(`SELECT user_id, fulfiller_user_id, status, allow_public FROM wishes WHERE id = ?`, [id]);
409
424
  if (!w)
410
425
  return void res.json({ error: '愿望不存在' });
411
426
  if (w.status !== 'completed')
412
427
  return void res.json({ error: '仅完成后可申请公开' });
413
428
  if (!w.allow_public)
414
429
  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);
430
+ const wf = await dbOne(`SELECT id, disclose_wisher, disclose_fulfiller FROM wish_fulfillments WHERE wish_id = ? AND status='confirmed' ORDER BY created_at DESC LIMIT 1`, [id]);
416
431
  if (!wf)
417
432
  return void res.json({ error: '未找到对应证据' });
418
433
  let update = null;
@@ -422,64 +437,74 @@ export function registerCharityRoutes(app, deps) {
422
437
  update = 'disclose_fulfiller = 1';
423
438
  else
424
439
  return void res.json({ error: '非当事人不可申请公开' });
425
- db.prepare(`UPDATE wish_fulfillments SET ${update} WHERE id = ?`).run(wf.id);
440
+ await dbRun(`UPDATE wish_fulfillments SET ${update} WHERE id = ?`, [wf.id]);
426
441
  // 双方都同意 → 标记 disclosed_at
427
- const both = db.prepare(`SELECT disclose_wisher, disclose_fulfiller FROM wish_fulfillments WHERE id = ?`).get(wf.id);
442
+ const both = (await dbOne(`SELECT disclose_wisher, disclose_fulfiller FROM wish_fulfillments WHERE id = ?`, [wf.id]));
428
443
  let disclosed = false;
429
444
  if (both.disclose_wisher && both.disclose_fulfiller) {
430
- db.prepare(`UPDATE wish_fulfillments SET disclosed_at = datetime('now') WHERE id = ?`).run(wf.id);
445
+ await dbRun(`UPDATE wish_fulfillments SET disclosed_at = datetime('now') WHERE id = ?`, [wf.id]);
431
446
  disclosed = true;
432
447
  }
433
448
  res.json({ ok: true, disclosed, wisher_agreed: !!both.disclose_wisher, fulfiller_agreed: !!both.disclose_fulfiller });
434
449
  });
435
450
  // POST /api/wishes/:id/cancel — 许愿人取消(仅 open 状态)
436
- app.post('/api/wishes/:id/cancel', (req, res) => {
451
+ app.post('/api/wishes/:id/cancel', async (req, res) => {
437
452
  const user = auth(req, res);
438
453
  if (!user)
439
454
  return;
440
455
  const id = req.params.id;
441
- const w = db.prepare(`SELECT user_id, status, escrow_locked FROM wishes WHERE id = ?`).get(id);
456
+ const w = await dbOne(`SELECT user_id, status, escrow_locked FROM wishes WHERE id = ?`, [id]);
442
457
  if (!w)
443
458
  return void res.json({ error: '愿望不存在' });
444
459
  if (w.user_id !== user.id)
445
460
  return void res.json({ error: '仅许愿人可取消' });
446
461
  if (w.status !== 'open')
447
462
  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
- })();
463
+ // Codex #238 P1:tx 内先 CAS open→cancelled,changes!==1 即并发已认领/取消 → 抛回滚,先于释放 escrow,杜绝双退。
464
+ try {
465
+ db.transaction(() => {
466
+ const c = db.prepare("UPDATE wishes SET status='cancelled' WHERE id = ? AND status = 'open'").run(id);
467
+ if (c.changes !== 1)
468
+ throw new Error('WISH_NOT_OPEN');
469
+ if (w.escrow_locked > 0) {
470
+ db.prepare('UPDATE wallets SET balance = balance + ?, staked = staked - ? WHERE user_id = ?').run(w.escrow_locked, w.escrow_locked, user.id);
471
+ }
472
+ })();
473
+ }
474
+ catch (e) {
475
+ if (e.message === 'WISH_NOT_OPEN')
476
+ return void res.json({ error: '已认领或已完成的愿望不可取消' });
477
+ throw e;
478
+ }
454
479
  res.json({ ok: true });
455
480
  });
456
481
  // GET /api/charity/me — 我的慈善档案
457
- app.get('/api/charity/me', (req, res) => {
482
+ app.get('/api/charity/me', async (req, res) => {
458
483
  const user = auth(req, res);
459
484
  if (!user)
460
485
  return;
461
486
  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(`
487
+ const rep = await dbOne(`SELECT * FROM charity_reputation WHERE user_id = ?`, [user.id]);
488
+ const myWishes = await dbAll(`SELECT id, wisher_handle, category, title, status, target_kind, target_waz, expires_at, created_at, completed_at
489
+ FROM wishes WHERE user_id = ? ORDER BY created_at DESC LIMIT 50`, [user.id]);
490
+ const myFulfilled = await dbAll(`
466
491
  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
492
  FROM wish_fulfillments wf JOIN wishes w ON w.id = wf.wish_id
468
493
  WHERE wf.fulfiller_user_id = ? ORDER BY wf.created_at DESC LIMIT 50
469
- `).all(user.id);
494
+ `, [user.id]);
470
495
  // 待我响应的还愿
471
- const pendingRepays = db.prepare(`
496
+ const pendingRepays = await dbAll(`
472
497
  SELECT r.id, r.wish_id, r.amount, r.note, r.auto_expire_at, w.title
473
498
  FROM wish_repayments r JOIN wishes w ON w.id = r.wish_id
474
499
  WHERE r.fulfiller_user_id = ? AND r.status = 'offered'
475
500
  ORDER BY r.created_at DESC
476
- `).all(user.id);
501
+ `, [user.id]);
477
502
  res.json({ reputation: rep, my_wishes: myWishes, my_fulfillments: myFulfilled, pending_repayments: pendingRepays });
478
503
  });
479
504
  // GET /api/charity/stories — 公开披露的故事板
480
- app.get('/api/charity/stories', (req, res) => {
505
+ app.get('/api/charity/stories', async (req, res) => {
481
506
  const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 30));
482
- const rows = db.prepare(`
507
+ const rows = await dbAll(`
483
508
  SELECT w.id, w.category, w.title, w.content, w.target_kind, w.target_waz, w.completed_at,
484
509
  wf.disclosed_at, wf.proof_note,
485
510
  uw.handle as wisher_name, uw.region as wisher_region,
@@ -491,11 +516,11 @@ export function registerCharityRoutes(app, deps) {
491
516
  WHERE wf.disclosed_at IS NOT NULL
492
517
  ORDER BY wf.disclosed_at DESC
493
518
  LIMIT ?
494
- `).all(limit);
519
+ `, [limit]);
495
520
  res.json({ items: rows });
496
521
  });
497
522
  // 还愿:许愿人发起
498
- app.post('/api/wishes/:id/repay', (req, res) => {
523
+ app.post('/api/wishes/:id/repay', async (req, res) => {
499
524
  const user = auth(req, res);
500
525
  if (!user)
501
526
  return;
@@ -506,7 +531,7 @@ export function registerCharityRoutes(app, deps) {
506
531
  const amount = Number(body.amount);
507
532
  if (!Number.isFinite(amount) || amount < CHARITY_REPAY_MIN)
508
533
  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);
534
+ const w = await dbOne(`SELECT user_id, fulfiller_user_id, status FROM wishes WHERE id = ?`, [id]);
510
535
  if (!w)
511
536
  return void res.json({ error: '愿望不存在' });
512
537
  if (w.user_id !== user.id)
@@ -514,29 +539,44 @@ export function registerCharityRoutes(app, deps) {
514
539
  if (w.status !== 'completed')
515
540
  return void res.json({ error: '仅已施善完成的愿望可还愿' });
516
541
  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);
542
+ const wf = await dbOne(`SELECT id, status FROM wish_fulfillments WHERE id = ? AND wish_id = ?`, [fid, id]);
518
543
  if (!wf || wf.status !== 'confirmed')
519
544
  return void res.json({ error: '证据不存在或未确认' });
520
545
  // 已发起的等待中还愿不可重复
521
- const existing = db.prepare(`SELECT id FROM wish_repayments WHERE wish_id = ? AND status = 'offered'`).get(id);
546
+ const existing = await dbOne(`SELECT id FROM wish_repayments WHERE wish_id = ? AND status = 'offered'`, [id]);
522
547
  if (existing)
523
548
  return void res.json({ error: '已有进行中的还愿,请等待对方响应' });
524
549
  // 余额检查 + 锁仓
525
- const wallet = db.prepare(`SELECT balance FROM wallets WHERE user_id = ?`).get(user.id);
550
+ const wallet = await dbOne(`SELECT balance FROM wallets WHERE user_id = ?`, [user.id]);
526
551
  if (!wallet || wallet.balance < amount)
527
552
  return void res.json({ error: '余额不足' });
528
553
  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
- })();
554
+ // Codex #238 P1:tx 内重检无并发 offered 还愿 + 余额守卫锁仓,任一失败回滚已插 repayment。
555
+ try {
556
+ db.transaction(() => {
557
+ const dup = db.prepare(`SELECT id FROM wish_repayments WHERE wish_id = ? AND status = 'offered'`).get(id);
558
+ if (dup)
559
+ throw new Error('REPAY_EXISTS');
560
+ db.prepare(`INSERT INTO wish_repayments (id, wish_id, fulfillment_id, wisher_user_id, fulfiller_user_id, amount, note, locked, auto_expire_at)
561
+ 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);
562
+ const d = db.prepare(`UPDATE wallets SET balance = balance - ?, staked = staked + ? WHERE user_id = ? AND balance >= ?`).run(amount, amount, user.id, amount);
563
+ if (d.changes !== 1)
564
+ throw new Error('REPAY_INSUFFICIENT_BALANCE');
565
+ })();
566
+ }
567
+ catch (e) {
568
+ const m = e.message;
569
+ if (m === 'REPAY_EXISTS')
570
+ return void res.json({ error: '已有进行中的还愿,请等待对方响应' });
571
+ if (m === 'REPAY_INSUFFICIENT_BALANCE')
572
+ return void res.json({ error: '余额不足' });
573
+ throw e;
574
+ }
534
575
  // P2.4 通知:施善人收到"有人向你还愿"
535
576
  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 天不响应自动接受)`);
577
+ const t = (await dbOne('SELECT title FROM wishes WHERE id = ?', [id])).title;
578
+ await dbRun(`INSERT INTO notifications (id, user_id, wish_id, type, title, body, created_at)
579
+ VALUES (?,?,?,'wish_repay',?,?,datetime('now'))`, [generateId('ntf'), w.fulfiller_user_id, id, `🙏 有人向你还愿 ${amount} WAZ`, `「${t}」 可接受或谢绝转入慈善基金(7 天不响应自动接受)`]);
540
580
  }
541
581
  catch (e) {
542
582
  console.error('[charity notify repay]', e);
@@ -544,7 +584,7 @@ export function registerCharityRoutes(app, deps) {
544
584
  res.json({ id: rid, auto_accept_in_days: CHARITY_REPAY_AUTO_ACCEPT_DAYS });
545
585
  });
546
586
  // 施善人响应还愿(accept / decline_to_fund)
547
- app.post('/api/wishes/:id/repay/:rid/respond', (req, res) => {
587
+ app.post('/api/wishes/:id/repay/:rid/respond', async (req, res) => {
548
588
  const user = auth(req, res);
549
589
  if (!user)
550
590
  return;
@@ -555,7 +595,7 @@ export function registerCharityRoutes(app, deps) {
555
595
  const choice = String(req.body.choice || '');
556
596
  if (!['accept', 'decline_to_fund'].includes(choice))
557
597
  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);
598
+ const r = await dbOne(`SELECT * FROM wish_repayments WHERE id = ? AND wish_id = ?`, [rid, id]);
559
599
  if (!r)
560
600
  return void res.json({ error: '还愿不存在' });
561
601
  if (r.fulfiller_user_id !== user.id)
@@ -602,10 +642,9 @@ export function registerCharityRoutes(app, deps) {
602
642
  // P2.4 通知:许愿人收到响应
603
643
  try {
604
644
  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 转捐荣誉)'}`);
645
+ const t = (await dbOne('SELECT title FROM wishes WHERE id = ?', [id])).title;
646
+ await dbRun(`INSERT INTO notifications (id, user_id, wish_id, type, title, body, created_at)
647
+ VALUES (?,?,?,'wish_repay_resp',?,?,datetime('now'))`, [generateId('ntf'), r.wisher_user_id, id, `🌸 ${label}`, `「${t}」 ${choice === 'accept' ? '施善人已接受还愿' : '+8 威望已入账(含 +3 转捐荣誉)'}`]);
609
648
  }
610
649
  catch (e) {
611
650
  console.error('[charity notify repay resp]', e);
@@ -613,7 +652,7 @@ export function registerCharityRoutes(app, deps) {
613
652
  res.json({ ok: true, choice });
614
653
  });
615
654
  // 任何人捐款给慈善基金
616
- app.post('/api/charity/fund/donate', (req, res) => {
655
+ app.post('/api/charity/fund/donate', async (req, res) => {
617
656
  const user = auth(req, res);
618
657
  if (!user)
619
658
  return;
@@ -626,47 +665,57 @@ export function registerCharityRoutes(app, deps) {
626
665
  const amount = Number(body.amount);
627
666
  if (!Number.isFinite(amount) || amount < CHARITY_DONATION_MIN)
628
667
  return void res.json({ error: `捐款需 ≥ ${CHARITY_DONATION_MIN} WAZ` });
629
- const wallet = db.prepare(`SELECT balance FROM wallets WHERE user_id = ?`).get(user.id);
668
+ const wallet = await dbOne(`SELECT balance FROM wallets WHERE user_id = ?`, [user.id]);
630
669
  if (!wallet || wallet.balance < amount)
631
670
  return void res.json({ error: '余额不足' });
632
671
  // 当日已得荣誉上限
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;
672
+ const todayHonor = (await dbOne(`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')`, [user.id])).s;
634
673
  const remain = Math.max(0, CHARITY_DONATION_DAILY_HONOR_CAP - todayHonor);
635
674
  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
- })();
675
+ // Codex #238 P1:扣款带 balance>=amount 守卫,changes!==1 → 余额已变 → 抛回滚,基金不入账
676
+ try {
677
+ db.transaction(() => {
678
+ const d = db.prepare(`UPDATE wallets SET balance = balance - ? WHERE user_id = ? AND balance >= ?`).run(amount, user.id, amount);
679
+ if (d.changes !== 1)
680
+ throw new Error('DONATE_INSUFFICIENT_BALANCE');
681
+ db.prepare(`UPDATE charity_fund SET balance = balance + ?, total_donated = total_donated + ?, updated_at = datetime('now') WHERE id = 'main'`).run(amount, amount);
682
+ db.prepare(`INSERT INTO charity_fund_txns (id, kind, from_user_id, to_user_id, amount, note)
683
+ VALUES (?, 'donation', ?, NULL, ?, ?)`).run(generateId('cft'), user.id, amount, body.note ? String(body.note).slice(0, 300) : null);
684
+ ensureCharityRep(db, user.id);
685
+ 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);
686
+ const s = db.prepare('SELECT prestige_score FROM charity_reputation WHERE user_id = ?').get(user.id).prestige_score;
687
+ db.prepare('UPDATE charity_reputation SET badge_tier = ? WHERE user_id = ?').run(charityBadgeTier(s), user.id);
688
+ })();
689
+ }
690
+ catch (e) {
691
+ if (e.message === 'DONATE_INSUFFICIENT_BALANCE')
692
+ return void res.json({ error: '余额不足' });
693
+ throw e;
694
+ }
646
695
  // 📡 Webhook fire — 通知 donor 自己(可订阅自己的捐款历史)
647
696
  fireWebhooks('charity.donation', { amount, note: body.note || null, honor_earned: honor }, [user.id]).catch(e => console.error('[webhook]', e));
648
697
  res.json({ ok: true, amount, honor_earned: honor, daily_cap_remaining: Math.max(0, remain - honor) });
649
698
  });
650
699
  // 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(`
700
+ app.get('/api/charity/fund', async (_req, res) => {
701
+ const fund = await dbOne(`SELECT * FROM charity_fund WHERE id = 'main'`, []);
702
+ const recent = await dbAll(`
654
703
  SELECT cft.id, cft.kind, cft.amount, cft.note, cft.created_at,
655
704
  u.handle as donor_handle, u.region as donor_region
656
705
  FROM charity_fund_txns cft
657
706
  LEFT JOIN users u ON u.id = cft.from_user_id
658
707
  ORDER BY cft.created_at DESC LIMIT 50
659
- `).all();
660
- const topDonors = db.prepare(`
708
+ `, []);
709
+ const topDonors = await dbAll(`
661
710
  SELECT u.handle, u.region, cr.donation_total, cr.donation_honor
662
711
  FROM charity_reputation cr JOIN users u ON u.id = cr.user_id
663
712
  WHERE cr.donation_total > 0
664
713
  ORDER BY cr.donation_total DESC LIMIT 20
665
- `).all();
714
+ `, []);
666
715
  res.json({ fund, recent, top_donors: topDonors });
667
716
  });
668
717
  // P2.3 — 举报愿望
669
- app.post('/api/wishes/:id/report', (req, res) => {
718
+ app.post('/api/wishes/:id/report', async (req, res) => {
670
719
  const user = auth(req, res);
671
720
  if (!user)
672
721
  return;
@@ -678,32 +727,31 @@ export function registerCharityRoutes(app, deps) {
678
727
  if (!['spam', 'fraud', 'inappropriate', 'other'].includes(reason))
679
728
  return void res.json({ error: 'reason 无效' });
680
729
  const note = body.note ? String(body.note).slice(0, 300) : null;
681
- const exists = db.prepare('SELECT 1 FROM wishes WHERE id = ?').get(id);
730
+ const exists = await dbOne('SELECT 1 FROM wishes WHERE id = ?', [id]);
682
731
  if (!exists)
683
732
  return void res.json({ error: '愿望不存在' });
684
733
  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);
734
+ await dbRun(`INSERT INTO wish_reports (id, wish_id, reporter_id, reason, note) VALUES (?,?,?,?,?)`, [generateId('wr'), id, user.id, reason, note]);
687
735
  }
688
736
  catch {
689
737
  return void res.json({ error: '你已举报过此愿望' });
690
738
  }
691
739
  // 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;
740
+ const cnt = (await dbOne("SELECT COUNT(1) as n FROM wish_reports WHERE wish_id = ? AND status = 'pending'", [id])).n;
693
741
  if (cnt >= 3) {
694
- db.prepare("UPDATE wishes SET status = 'disputed' WHERE id = ? AND status IN ('open','claimed')").run(id);
742
+ await dbRun("UPDATE wishes SET status = 'disputed' WHERE id = ? AND status IN ('open','claimed')", [id]);
695
743
  }
696
744
  res.json({ ok: true, total_reports: cnt, auto_hidden: cnt >= 3 });
697
745
  });
698
746
  // ─── admin 慈善管理 ─────────────────────────────────────────
699
- app.get('/api/admin/wish-reports', (req, res) => {
747
+ app.get('/api/admin/wish-reports', async (req, res) => {
700
748
  const admin = requireContentAdmin(req, res);
701
749
  if (!admin)
702
750
  return;
703
751
  const status = String(req.query.status || 'pending');
704
752
  const where = status === 'all' ? '1=1' : 'wr.status = ?';
705
753
  const args = status === 'all' ? [] : [status];
706
- const rows = db.prepare(`
754
+ const rows = await dbAll(`
707
755
  SELECT wr.id, wr.wish_id, wr.reporter_id, wr.reason, wr.note, wr.status, wr.created_at,
708
756
  w.title as wish_title, w.user_id as wish_owner_id, w.status as wish_status,
709
757
  u.handle as reporter_handle
@@ -712,47 +760,50 @@ export function registerCharityRoutes(app, deps) {
712
760
  LEFT JOIN users u ON u.id = wr.reporter_id
713
761
  WHERE ${where}
714
762
  ORDER BY wr.created_at DESC LIMIT 200
715
- `).all(...args);
763
+ `, args);
716
764
  res.json({ items: rows });
717
765
  });
718
- app.patch('/api/admin/wish-reports/:id', (req, res) => {
766
+ app.patch('/api/admin/wish-reports/:id', async (req, res) => {
719
767
  const admin = requireContentAdmin(req, res);
720
768
  if (!admin)
721
769
  return;
722
770
  const action = String(req.body.action || '');
723
771
  if (!['dismiss', 'actioned'].includes(action))
724
772
  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);
773
+ const r = await dbRun(`UPDATE wish_reports SET status = ? WHERE id = ?`, [action === 'dismiss' ? 'dismissed' : 'actioned', req.params.id]);
726
774
  if (r.changes === 0)
727
775
  return void res.json({ error: '举报不存在' });
728
776
  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);
777
+ await dbRun(`INSERT INTO admin_audit_log (id, admin_id, action, target_type, target_id, detail) VALUES (?,?,?,?,?,?)`, [generateId('audit'), admin.id, 'wish_report_' + action, 'wish_report', req.params.id, null]);
731
778
  }
732
779
  catch { }
733
780
  res.json({ ok: true, status: action === 'dismiss' ? 'dismissed' : 'actioned' });
734
781
  });
735
- app.post('/api/admin/wishes/:id/takedown', (req, res) => {
782
+ app.post('/api/admin/wishes/:id/takedown', async (req, res) => {
736
783
  const admin = requireContentAdmin(req, res);
737
784
  if (!admin)
738
785
  return;
739
786
  const reason = String(req.body.reason || '').trim();
740
787
  if (!reason)
741
788
  return void res.json({ error: '必须填写下架原因' });
742
- const w = db.prepare(`SELECT user_id, status, escrow_locked FROM wishes WHERE id = ?`).get(req.params.id);
789
+ const w = await dbOne(`SELECT user_id, status, escrow_locked FROM wishes WHERE id = ?`, [req.params.id]);
743
790
  if (!w)
744
791
  return void res.json({ error: '愿望不存在' });
745
792
  db.transaction(() => {
746
- db.prepare(`UPDATE wishes SET status='cancelled' WHERE id = ?`).run(req.params.id);
747
- if (w.escrow_locked > 0) {
793
+ // Codex #238 P1 + #247 复审:CAS open/claimed/disputed→cancelled。仅当本次真正完成该转换(changes===1)
794
+ // 才释放 escrow——若已是 escrow 已释放终态(completed/cancelled)则不重复释放,避免双退;审计始终记录。
795
+ // 'disputed' = 被 3 个举报人自动隐藏(line 734),escrow 仍锁定未释放,故必须纳入释放集合,否则
796
+ // 现金愿望先被举报隐藏再被 takedown 时 staked 会永久卡死。
797
+ const c = db.prepare(`UPDATE wishes SET status='cancelled' WHERE id = ? AND status IN ('open','claimed','disputed')`).run(req.params.id);
798
+ if (c.changes === 1 && w.escrow_locked > 0) {
748
799
  db.prepare(`UPDATE wallets SET balance = balance + ?, staked = staked - ? WHERE user_id = ?`).run(w.escrow_locked, w.escrow_locked, w.user_id);
749
800
  }
750
801
  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 }));
802
+ .run(generateId('audit'), admin.id, 'wish_takedown', 'wish', req.params.id, JSON.stringify({ reason, escrow_released: c.changes === 1 && w.escrow_locked > 0 }));
752
803
  })();
753
804
  res.json({ ok: true });
754
805
  });
755
- app.post('/api/admin/charity/fund/disburse', (req, res) => {
806
+ app.post('/api/admin/charity/fund/disburse', async (req, res) => {
756
807
  const admin = requireProtocolAdmin(req, res);
757
808
  if (!admin)
758
809
  return;
@@ -766,51 +817,62 @@ export function registerCharityRoutes(app, deps) {
766
817
  return void res.json({ error: 'to_user_id 必填' });
767
818
  if (!note)
768
819
  return void res.json({ error: '必须填写拨款用途(写入审计)' });
769
- const targetUser = db.prepare(`SELECT id, name FROM users WHERE id = ?`).get(toUserId);
820
+ const targetUser = await dbOne(`SELECT id, name FROM users WHERE id = ?`, [toUserId]);
770
821
  if (!targetUser)
771
822
  return void res.json({ error: '收款用户不存在' });
772
- const fund = db.prepare(`SELECT balance FROM charity_fund WHERE id = 'main'`).get();
823
+ const fund = (await dbOne(`SELECT balance FROM charity_fund WHERE id = 'main'`, []));
773
824
  if (fund.balance < amount)
774
825
  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
- })();
826
+ // Codex #238 P1:基金扣款带 balance>=amount 守卫(WHERE id='main' AND balance>=?),changes!==1
827
+ // 余额在 await 预检后已变 抛回滚,先于给收款人入账,杜绝基金超额拨款。
828
+ try {
829
+ db.transaction(() => {
830
+ const f = db.prepare(`UPDATE charity_fund SET balance = balance - ?, total_disbursed = total_disbursed + ?, updated_at = datetime('now') WHERE id = 'main' AND balance >= ?`).run(amount, amount, amount);
831
+ if (f.changes !== 1)
832
+ throw new Error('FUND_INSUFFICIENT');
833
+ db.prepare(`UPDATE wallets SET balance = balance + ? WHERE user_id = ?`).run(amount, toUserId);
834
+ db.prepare(`INSERT INTO charity_fund_txns (id, kind, from_user_id, to_user_id, amount, note) VALUES (?, 'disburse', NULL, ?, ?, ?)`)
835
+ .run(generateId('cft'), toUserId, amount, note);
836
+ db.prepare(`INSERT INTO admin_audit_log (id, admin_id, action, target_type, target_id, detail) VALUES (?,?,?,?,?,?)`)
837
+ .run(generateId('audit'), admin.id, 'charity_disburse', 'user', toUserId, JSON.stringify({ amount, note }));
838
+ try {
839
+ db.prepare(`INSERT INTO notifications (id, user_id, type, title, body, created_at) VALUES (?,?,'charity_disburse',?,?,datetime('now'))`)
840
+ .run(generateId('ntf'), toUserId, `💰 慈善基金拨款 +${amount} WAZ`, note);
841
+ }
842
+ catch { }
843
+ })();
844
+ }
845
+ catch (e) {
846
+ if (e.message === 'FUND_INSUFFICIENT')
847
+ return void res.json({ error: `基金余额不足` });
848
+ throw e;
849
+ }
788
850
  res.json({ ok: true, amount, to_user: targetUser.name });
789
851
  });
790
- app.get('/api/admin/charity/fund', (req, res) => {
852
+ app.get('/api/admin/charity/fund', async (req, res) => {
791
853
  const admin = requireProtocolAdmin(req, res);
792
854
  if (!admin)
793
855
  return;
794
- const fund = db.prepare(`SELECT * FROM charity_fund WHERE id = 'main'`).get();
795
- const recent = db.prepare(`
856
+ const fund = await dbOne(`SELECT * FROM charity_fund WHERE id = 'main'`, []);
857
+ const recent = await dbAll(`
796
858
  SELECT cft.*, uf.name as from_name, ut.name as to_name
797
859
  FROM charity_fund_txns cft
798
860
  LEFT JOIN users uf ON uf.id = cft.from_user_id
799
861
  LEFT JOIN users ut ON ut.id = cft.to_user_id
800
862
  ORDER BY cft.created_at DESC LIMIT 100
801
- `).all();
863
+ `, []);
802
864
  res.json({ fund, recent });
803
865
  });
804
866
  // 慈善排行
805
- app.get('/api/charity/leaderboard', (_req, res) => {
806
- const rows = db.prepare(`
867
+ app.get('/api/charity/leaderboard', async (_req, res) => {
868
+ const rows = await dbAll(`
807
869
  SELECT cr.prestige_score, cr.wishes_fulfilled, cr.wishes_made, cr.badge_tier,
808
870
  u.handle, u.region
809
871
  FROM charity_reputation cr JOIN users u ON u.id = cr.user_id
810
872
  WHERE cr.prestige_score > 0
811
873
  ORDER BY cr.prestige_score DESC, cr.wishes_fulfilled DESC
812
874
  LIMIT 50
813
- `).all();
875
+ `, []);
814
876
  res.json({ items: rows });
815
877
  });
816
878
  }