@seasonkoh/webaz 0.1.23 → 0.1.25

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 (187) hide show
  1. package/README.md +2 -0
  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 +198 -83
  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 +173 -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 +10 -2
  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-engine.js +109 -0
  35. package/dist/layer2-business/L2-9-contribution/identity-claim-fact-precondition.js +22 -0
  36. package/dist/layer2-business/L2-9-contribution/identity-claim-proof-verifier.js +97 -0
  37. package/dist/layer2-business/L2-9-contribution/identity-claim-read.js +59 -0
  38. package/dist/layer2-business/L2-9-contribution/task-proposal-store.js +129 -0
  39. package/dist/layer2-business/L2-notes/note-photo-storage.js +4 -2
  40. package/dist/layer3-trust/L3-1-dispute-engine/dispute-engine.js +17 -15
  41. package/dist/layer3-trust/L3-1-dispute-engine/evidence-storage.js +11 -8
  42. package/dist/layer4-economics/L4-3-reputation/reputation-engine.js +9 -8
  43. package/dist/layer4-economics/L4-4-skill-market/skill-engine.js +11 -8
  44. package/dist/layer4-economics/L4-4-skill-market/skill-listing-engine.js +22 -16
  45. package/dist/pwa/acp-feed.js +13 -1
  46. package/dist/pwa/contract-fingerprint.js +2 -0
  47. package/dist/pwa/endpoint-actions.js +5 -1
  48. package/dist/pwa/goal-index.js +8 -8
  49. package/dist/pwa/human-presence.js +62 -0
  50. package/dist/pwa/public/app.js +575 -68
  51. package/dist/pwa/public/i18n.js +29 -20
  52. package/dist/pwa/public/index.html +1 -0
  53. package/dist/pwa/public/openapi.json +2 -2
  54. package/dist/pwa/rate-limit.js +22 -0
  55. package/dist/pwa/routes/account-deletion.js +15 -13
  56. package/dist/pwa/routes/addresses.js +10 -9
  57. package/dist/pwa/routes/admin-admins.js +13 -14
  58. package/dist/pwa/routes/admin-analytics.js +109 -69
  59. package/dist/pwa/routes/admin-catalog.js +13 -11
  60. package/dist/pwa/routes/admin-editor-picks.js +15 -10
  61. package/dist/pwa/routes/admin-events.js +5 -3
  62. package/dist/pwa/routes/admin-health.js +2 -1
  63. package/dist/pwa/routes/admin-moderation.js +26 -29
  64. package/dist/pwa/routes/admin-ops.js +22 -21
  65. package/dist/pwa/routes/admin-protocol-params.js +16 -19
  66. package/dist/pwa/routes/admin-reports.js +23 -21
  67. package/dist/pwa/routes/admin-tokenomics.js +26 -25
  68. package/dist/pwa/routes/admin-users-lifecycle.js +37 -40
  69. package/dist/pwa/routes/admin-users-query.js +54 -53
  70. package/dist/pwa/routes/admin-verifier-flow.js +82 -41
  71. package/dist/pwa/routes/admin-verifier-whitelist.js +55 -27
  72. package/dist/pwa/routes/admin-wallet-ops.js +7 -5
  73. package/dist/pwa/routes/agent-buy.js +46 -22
  74. package/dist/pwa/routes/agent-governance.js +52 -56
  75. package/dist/pwa/routes/ai.js +7 -5
  76. package/dist/pwa/routes/analytics.js +43 -41
  77. package/dist/pwa/routes/anchors.js +19 -20
  78. package/dist/pwa/routes/announcements.js +13 -13
  79. package/dist/pwa/routes/arbitrator.js +97 -31
  80. package/dist/pwa/routes/auction.js +153 -114
  81. package/dist/pwa/routes/auth-login.js +6 -4
  82. package/dist/pwa/routes/auth-read.js +11 -9
  83. package/dist/pwa/routes/auth-register.js +35 -20
  84. package/dist/pwa/routes/auth-sessions.js +12 -11
  85. package/dist/pwa/routes/blocklist.js +16 -15
  86. package/dist/pwa/routes/build-feedback.js +10 -9
  87. package/dist/pwa/routes/build-reputation.js +6 -2
  88. package/dist/pwa/routes/build-tasks.js +45 -13
  89. package/dist/pwa/routes/buyer-feeds.js +27 -25
  90. package/dist/pwa/routes/cart.js +16 -15
  91. package/dist/pwa/routes/charity.js +212 -150
  92. package/dist/pwa/routes/chat.js +42 -43
  93. package/dist/pwa/routes/checkin-tasks.js +10 -9
  94. package/dist/pwa/routes/checkout-helpers.js +12 -10
  95. package/dist/pwa/routes/claim-initiators.js +34 -14
  96. package/dist/pwa/routes/claim-verify.js +86 -53
  97. package/dist/pwa/routes/claim-voting.js +43 -18
  98. package/dist/pwa/routes/contribution-identity.js +147 -0
  99. package/dist/pwa/routes/contribution-score.js +19 -0
  100. package/dist/pwa/routes/coupons.js +19 -16
  101. package/dist/pwa/routes/dashboards.js +18 -16
  102. package/dist/pwa/routes/dispute-cases.js +25 -24
  103. package/dist/pwa/routes/disputes-read.js +45 -51
  104. package/dist/pwa/routes/disputes-write.js +124 -61
  105. package/dist/pwa/routes/evidence.js +9 -9
  106. package/dist/pwa/routes/external-anchors.js +13 -12
  107. package/dist/pwa/routes/feedback.js +29 -33
  108. package/dist/pwa/routes/flash-sales.js +18 -16
  109. package/dist/pwa/routes/follows.js +25 -24
  110. package/dist/pwa/routes/governance-auto-deactivate.js +21 -9
  111. package/dist/pwa/routes/governance-onboarding.js +70 -59
  112. package/dist/pwa/routes/group-buys.js +22 -22
  113. package/dist/pwa/routes/growth.js +33 -30
  114. package/dist/pwa/routes/import-product.js +12 -10
  115. package/dist/pwa/routes/kyc.js +9 -8
  116. package/dist/pwa/routes/leaderboard.js +20 -18
  117. package/dist/pwa/routes/listings.js +23 -22
  118. package/dist/pwa/routes/logistics.js +10 -8
  119. package/dist/pwa/routes/manifests.js +27 -27
  120. package/dist/pwa/routes/me-data.js +23 -21
  121. package/dist/pwa/routes/notifications.js +7 -6
  122. package/dist/pwa/routes/offers.js +30 -12
  123. package/dist/pwa/routes/orders-action.js +33 -17
  124. package/dist/pwa/routes/orders-create.js +75 -20
  125. package/dist/pwa/routes/orders-read.js +21 -20
  126. package/dist/pwa/routes/p2p-products.js +30 -18
  127. package/dist/pwa/routes/payments-governance.js +61 -56
  128. package/dist/pwa/routes/peers.js +9 -8
  129. package/dist/pwa/routes/pin-receipts.js +13 -13
  130. package/dist/pwa/routes/products-aliases.js +12 -10
  131. package/dist/pwa/routes/products-claims.js +36 -17
  132. package/dist/pwa/routes/products-create.js +53 -38
  133. package/dist/pwa/routes/products-crud.js +17 -16
  134. package/dist/pwa/routes/products-links.js +49 -26
  135. package/dist/pwa/routes/products-list.js +6 -4
  136. package/dist/pwa/routes/products-meta.js +40 -39
  137. package/dist/pwa/routes/products-update.js +19 -5
  138. package/dist/pwa/routes/profile-credentials.js +14 -16
  139. package/dist/pwa/routes/profile-identity.js +14 -13
  140. package/dist/pwa/routes/profile-location.js +7 -6
  141. package/dist/pwa/routes/profile-placement.js +19 -17
  142. package/dist/pwa/routes/profile-prefs.js +11 -11
  143. package/dist/pwa/routes/promoter.js +55 -49
  144. package/dist/pwa/routes/public-build-tasks.js +19 -0
  145. package/dist/pwa/routes/public-utils.js +108 -46
  146. package/dist/pwa/routes/push.js +16 -15
  147. package/dist/pwa/routes/ratings.js +30 -30
  148. package/dist/pwa/routes/recover-key.js +13 -12
  149. package/dist/pwa/routes/referral.js +37 -32
  150. package/dist/pwa/routes/reputation.js +3 -2
  151. package/dist/pwa/routes/returns.js +76 -73
  152. package/dist/pwa/routes/reviews.js +41 -18
  153. package/dist/pwa/routes/rewards-apply.js +16 -15
  154. package/dist/pwa/routes/rewards-auto-downgrade.js +9 -7
  155. package/dist/pwa/routes/rewards-escrow-expire.js +7 -5
  156. package/dist/pwa/routes/rfqs.js +163 -85
  157. package/dist/pwa/routes/search.js +16 -14
  158. package/dist/pwa/routes/secondhand.js +25 -22
  159. package/dist/pwa/routes/seller-quota.js +24 -26
  160. package/dist/pwa/routes/share-redirects.js +59 -55
  161. package/dist/pwa/routes/shareables-interactions.js +34 -35
  162. package/dist/pwa/routes/shareables.js +55 -51
  163. package/dist/pwa/routes/shop-referral.js +57 -0
  164. package/dist/pwa/routes/shops.js +20 -18
  165. package/dist/pwa/routes/signaling.js +10 -9
  166. package/dist/pwa/routes/skill-market.js +16 -16
  167. package/dist/pwa/routes/skills.js +15 -14
  168. package/dist/pwa/routes/snf.js +14 -13
  169. package/dist/pwa/routes/tags.js +10 -9
  170. package/dist/pwa/routes/task-proposals.js +45 -0
  171. package/dist/pwa/routes/trial.js +69 -51
  172. package/dist/pwa/routes/trusted-kpi.js +20 -18
  173. package/dist/pwa/routes/url-claim.js +67 -28
  174. package/dist/pwa/routes/users-public.js +62 -60
  175. package/dist/pwa/routes/variants.js +12 -13
  176. package/dist/pwa/routes/verifier-user.js +61 -21
  177. package/dist/pwa/routes/verify-tasks.js +49 -25
  178. package/dist/pwa/routes/waitlist.js +16 -15
  179. package/dist/pwa/routes/wallet-read.js +74 -36
  180. package/dist/pwa/routes/wallet-write.js +12 -9
  181. package/dist/pwa/routes/webauthn.js +25 -26
  182. package/dist/pwa/routes/webhooks.js +26 -26
  183. package/dist/pwa/routes/welcome.js +45 -50
  184. package/dist/pwa/routes/wishlist-qa.js +29 -32
  185. package/dist/pwa/server.js +237 -81
  186. package/dist/version.js +1 -1
  187. package/package.json +47 -2
@@ -1,7 +1,9 @@
1
- export function runAutoDowngradeSweep(deps) {
1
+ // RFC-016 Phase 1 — cron 的 currentMajor + 候选扫描读 → async seam;逐用户降级 db.transaction 写仍同步(Phase 3)
2
+ import { dbOne, dbAll } from '../../layer0-foundation/L0-1-database/db.js';
3
+ export async function runAutoDowngradeSweep(deps) {
2
4
  const { db, getProtocolParam } = deps;
3
5
  // Current major consent
4
- const currentMajor = db.prepare(`SELECT version, effective_at FROM rewards_consent_texts WHERE change_class='major' ORDER BY effective_at DESC LIMIT 1`).get();
6
+ const currentMajor = await dbOne(`SELECT version, effective_at FROM rewards_consent_texts WHERE change_class='major' ORDER BY effective_at DESC LIMIT 1`);
5
7
  if (!currentMajor)
6
8
  return { scanned: 0, downgraded: [], skip_reason: 'no major consent text in rewards_consent_texts' };
7
9
  const graceDays = Number(getProtocolParam('rewards_opt_in.reconfirm_grace_days', 14));
@@ -11,14 +13,14 @@ export function runAutoDowngradeSweep(deps) {
11
13
  return { scanned: 0, downgraded: [], skip_reason: `current_major ${currentMajor.version} grace not yet expired (deadline=${deadline})` };
12
14
  // Candidates: opted-in users whose LATEST activate-or-reconfirm consent_version
13
15
  // is older than the current major.
14
- const candidates = db.prepare(`
16
+ const candidates = await dbAll(`
15
17
  SELECT u.id AS user_id, (
16
18
  SELECT consent_version FROM rewards_applications
17
19
  WHERE user_id = u.id AND action IN ('activate','reconfirm')
18
20
  ORDER BY created_at DESC LIMIT 1
19
21
  ) AS last_version
20
22
  FROM users u WHERE u.rewards_opted_in = 1
21
- `).all();
23
+ `);
22
24
  const downgraded = [];
23
25
  for (const c of candidates) {
24
26
  if (c.last_version === currentMajor.version)
@@ -40,7 +42,7 @@ export function runAutoDowngradeSweep(deps) {
40
42
  // Failure here is non-fatal — notification is best-effort, downgrade itself is the source of truth.
41
43
  try {
42
44
  db.prepare(`INSERT INTO notifications (id, user_id, type, title, body) VALUES (?, ?, 'rewards_auto_downgrade', ?, ?)`)
43
- .run(`ntf_rwd_${c.user_id}_${now}`, c.user_id, '共建身份已自动降级 / Rewards auto-downgraded', `新 consent 版本 ${currentMajor.version} 未在 grace 期内重新确认。未来 commission 进入 escrow(30 天可激活领回)。前往 #rewards-me 重新申请。 / New consent ${currentMajor.version} not re-confirmed within grace window. Future commission flows to escrow (30d recovery window). Visit #rewards-me to re-apply.`);
45
+ .run(`ntf_rwd_${c.user_id}_${now}`, c.user_id, '分享分润已自动降级 / Rewards auto-downgraded', `新 consent 版本 ${currentMajor.version} 未在 grace 期内重新确认。未来 commission 进入 escrow(30 天可激活领回)。前往 #rewards-me 重新申请。 / New consent ${currentMajor.version} not re-confirmed within grace window. Future commission flows to escrow (30d recovery window). Visit #rewards-me to re-apply.`);
44
46
  }
45
47
  catch { /* notifications schema diff between envs; best-effort */ }
46
48
  downgraded.push({ user_id: c.user_id, last_version: c.last_version, current_major: currentMajor.version, effective_at: currentMajor.effective_at });
@@ -50,9 +52,9 @@ export function runAutoDowngradeSweep(deps) {
50
52
  }
51
53
  export function startAutoDowngradeCron(deps) {
52
54
  const ms = 24 * 60 * 60 * 1000; // 1d fixed
53
- setInterval(() => {
55
+ setInterval(async () => {
54
56
  try {
55
- const r = runAutoDowngradeSweep(deps);
57
+ const r = await runAutoDowngradeSweep(deps);
56
58
  if (r.downgraded.length > 0) {
57
59
  console.log(`[rewards-auto-downgrade] swept ${r.scanned}, downgraded ${r.downgraded.length}: ${r.downgraded.map(d => `${d.user_id} ${d.last_version || '(none)'}→${d.current_major}`).join(', ')}`);
58
60
  }
@@ -1,13 +1,15 @@
1
- export function runEscrowExpireSweep(deps) {
1
+ // RFC-016 Phase 1 — cron 扫描读 → async seam;到期 materialize 的 db.transaction 写仍同步(Phase 3 迁 pg)
2
+ import { dbAll } from '../../layer0-foundation/L0-1-database/db.js';
3
+ export async function runEscrowExpireSweep(deps) {
2
4
  const { db, redirectToCommissionReserve } = deps;
3
5
  const now = Date.now();
4
- const rows = db.prepare(`
6
+ const rows = await dbAll(`
5
7
  SELECT id, recipient_user_id, order_id, amount, attribution_path, expires_at
6
8
  FROM pending_commission_escrow
7
9
  WHERE status = 'pending' AND expires_at <= ?
8
10
  ORDER BY expires_at ASC
9
11
  LIMIT 1000
10
- `).all(now);
12
+ `, [now]);
11
13
  const expired = [];
12
14
  for (const r of rows) {
13
15
  db.transaction(() => {
@@ -33,9 +35,9 @@ export function runEscrowExpireSweep(deps) {
33
35
  }
34
36
  export function startEscrowExpireCron(deps) {
35
37
  const ms = 60 * 60 * 1000; // 1h fixed (escrow_days is in days; sub-day granularity unnecessary)
36
- setInterval(() => {
38
+ setInterval(async () => {
37
39
  try {
38
- const r = runEscrowExpireSweep(deps);
40
+ const r = await runEscrowExpireSweep(deps);
39
41
  if (r.expired.length > 0) {
40
42
  console.log(`[rewards-escrow-expire] swept ${r.scanned}, expired ${r.expired.length}: ${r.expired.map(e => `${e.recipient_user_id}/${e.attribution_path}/${e.amount}`).join(', ')}`);
41
43
  }
@@ -1,3 +1,4 @@
1
+ import { dbOne, dbAll, dbRun } from '../../layer0-foundation/L0-1-database/db.js';
1
2
  export function registerRfqsRoutes(app, deps) {
2
3
  const { db, auth, generateId, VALID_RFQ_URGENCIES, VALID_AWARD_MODES, RFQ_MAX_QTY, RFQ_MAX_PRICE, RFQ_DAILY_CAP_PER_BUYER, RFQ_MAX_WINDOW_MIN, RFQ_DEFAULT_WINDOW_MIN, BID_DAILY_CAP_PER_SELLER, BID_STAKE_RATE, VALID_FULFILLMENT_TYPES, isListingCategoryKey, LISTING_CATEGORIES, awardBidAndCreateOrder, notifyMatchedSellers, evaluateAutoBidsForRfq, shouldAutoAccept, transition, notifyTransition } = deps;
3
4
  // 内联押金 helper(小巧、仅 rfq 域用)
@@ -7,7 +8,7 @@ export function registerRfqsRoutes(app, deps) {
7
8
  };
8
9
  const bidStakeFor = (price, qty) => Math.max(0.5, Math.round(price * qty * BID_STAKE_RATE * 100) / 100);
9
10
  // 买家:创建 RFQ
10
- app.post('/api/rfqs', (req, res) => {
11
+ app.post('/api/rfqs', async (req, res) => {
11
12
  const user = auth(req, res);
12
13
  if (!user)
13
14
  return;
@@ -36,17 +37,17 @@ export function registerRfqsRoutes(app, deps) {
36
37
  return void res.json({ error: '类目无效' });
37
38
  const explicitWindow = body.award_window_min != null ? Math.max(5, Math.min(RFQ_MAX_WINDOW_MIN, Math.floor(Number(body.award_window_min)))) : null;
38
39
  const windowMin = explicitWindow ?? RFQ_DEFAULT_WINDOW_MIN[urgency];
39
- const todayCount = db.prepare("SELECT COUNT(1) as n FROM rfqs WHERE buyer_id = ? AND created_at > datetime('now','-1 day')").get(user.id).n;
40
+ const todayCount = (await dbOne("SELECT COUNT(1) as n FROM rfqs WHERE buyer_id = ? AND created_at > datetime('now','-1 day')", [user.id])).n;
40
41
  if (todayCount >= RFQ_DAILY_CAP_PER_BUYER) {
41
42
  return void res.json({ error: `今日已达上限 ${RFQ_DAILY_CAP_PER_BUYER} 单求购` });
42
43
  }
43
44
  const deposit = buyerRfqDeposit(maxPrice, qty);
44
- const wallet = db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(user.id);
45
+ const wallet = await dbOne('SELECT balance FROM wallets WHERE user_id = ?', [user.id]);
45
46
  if (!wallet || Number(wallet.balance) < deposit) {
46
47
  return void res.json({ error: `余额不足,发求购需 ${deposit} WAZ 押金(中标后释放,撤销扣 30%)` });
47
48
  }
48
49
  // P3c:award 自动建单需要收货地址。优先 body,否则 buyer 的默认地址
49
- const buyerProfile = db.prepare('SELECT default_address_text, default_address_json FROM users WHERE id = ?').get(user.id);
50
+ const buyerProfile = await dbOne('SELECT default_address_text, default_address_json FROM users WHERE id = ?', [user.id]);
50
51
  let shippingAddress = body.shipping_address ? String(body.shipping_address).trim() : null;
51
52
  if (!shippingAddress) {
52
53
  if (buyerProfile?.default_address_text)
@@ -66,14 +67,25 @@ export function registerRfqsRoutes(app, deps) {
66
67
  const id = generateId('rfq');
67
68
  const regionRequired = body.region_required ? String(body.region_required) : user.region || null;
68
69
  const fulfillmentRequired = body.fulfillment_required ? JSON.stringify(body.fulfillment_required) : null;
69
- db.transaction(() => {
70
- db.prepare(`
71
- INSERT INTO rfqs (id, buyer_id, listing_id, title, spec_json, qty, category, region_required, urgency,
72
- max_price, fulfillment_required, award_mode, award_window_min, deadline_at, buyer_stake_locked, notes, shipping_address)
73
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now', '+' || ? || ' minutes'),?,?,?)
74
- `).run(id, user.id, body.listing_id ? String(body.listing_id) : null, title, body.spec_json ? JSON.stringify(body.spec_json) : null, qty, category, regionRequired, urgency, maxPrice, fulfillmentRequired, awardMode, windowMin, windowMin, deposit, body.notes ? String(body.notes) : null, shippingAddress);
75
- db.prepare('UPDATE wallets SET balance = balance - ?, staked = staked + ? WHERE user_id = ?').run(deposit, deposit, user.id);
76
- })();
70
+ // Codex #236 P1:await 余额预检与同步 tx 间有 yield;扣款带 balance>=deposit 守卫,
71
+ // changes!==1 即并发已花掉余额 → 抛回滚(连带回滚已插入的 rfq),杜绝超额。
72
+ try {
73
+ db.transaction(() => {
74
+ db.prepare(`
75
+ INSERT INTO rfqs (id, buyer_id, listing_id, title, spec_json, qty, category, region_required, urgency,
76
+ max_price, fulfillment_required, award_mode, award_window_min, deadline_at, buyer_stake_locked, notes, shipping_address)
77
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,datetime('now', '+' || ? || ' minutes'),?,?,?)
78
+ `).run(id, user.id, body.listing_id ? String(body.listing_id) : null, title, body.spec_json ? JSON.stringify(body.spec_json) : null, qty, category, regionRequired, urgency, maxPrice, fulfillmentRequired, awardMode, windowMin, windowMin, deposit, body.notes ? String(body.notes) : null, shippingAddress);
79
+ const d = db.prepare('UPDATE wallets SET balance = balance - ?, staked = staked + ? WHERE user_id = ? AND balance >= ?').run(deposit, deposit, user.id, deposit);
80
+ if (d.changes !== 1)
81
+ throw new Error('RFQ_INSUFFICIENT_BALANCE');
82
+ })();
83
+ }
84
+ catch (e) {
85
+ if (e.message === 'RFQ_INSUFFICIENT_BALANCE')
86
+ return void res.json({ error: `余额不足,发求购需 ${deposit} WAZ 押金(中标后释放,撤销扣 30%)` });
87
+ throw e;
88
+ }
77
89
  try {
78
90
  notifyMatchedSellers(id);
79
91
  }
@@ -90,7 +102,7 @@ export function registerRfqsRoutes(app, deps) {
90
102
  res.json({ id, deposit, window_min: windowMin, deadline_at_minutes: windowMin, auto_bids: autoBidCount });
91
103
  });
92
104
  // 卖家 RFQ 看板
93
- app.get('/api/rfqs', (req, res) => {
105
+ app.get('/api/rfqs', async (req, res) => {
94
106
  const user = auth(req, res);
95
107
  if (!user)
96
108
  return;
@@ -118,7 +130,7 @@ export function registerRfqsRoutes(app, deps) {
118
130
  args.push('%' + qE + '%', '%' + qE + '%');
119
131
  }
120
132
  const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 30));
121
- const rows = db.prepare(`
133
+ const rows = await dbAll(`
122
134
  SELECT r.id, r.title, r.qty, r.category, r.region_required, r.urgency, r.max_price,
123
135
  r.award_mode, r.deadline_at, r.bid_count, r.created_at,
124
136
  (SELECT MIN(price) FROM bids b WHERE b.rfq_id = r.id AND b.status = 'active') as current_lowest_bid,
@@ -127,32 +139,32 @@ export function registerRfqsRoutes(app, deps) {
127
139
  WHERE ${where.join(' AND ')}
128
140
  ORDER BY r.created_at DESC
129
141
  LIMIT ?
130
- `).all(user.id, ...args, limit);
142
+ `, [user.id, ...args, limit]);
131
143
  res.json({ items: rows, urgencies: ['now', 'today', 'flex'], categories: LISTING_CATEGORIES });
132
144
  });
133
- app.get('/api/rfqs/mine', (req, res) => {
145
+ app.get('/api/rfqs/mine', async (req, res) => {
134
146
  const user = auth(req, res);
135
147
  if (!user)
136
148
  return;
137
- const rows = db.prepare(`
149
+ const rows = await dbAll(`
138
150
  SELECT r.*,
139
151
  (SELECT MIN(price) FROM bids b WHERE b.rfq_id = r.id AND b.status = 'active') as current_lowest_bid
140
152
  FROM rfqs r
141
153
  WHERE r.buyer_id = ?
142
154
  ORDER BY r.created_at DESC
143
155
  LIMIT 100
144
- `).all(user.id);
156
+ `, [user.id]);
145
157
  res.json({ items: rows });
146
158
  });
147
- app.get('/api/rfqs/:id', (req, res) => {
159
+ app.get('/api/rfqs/:id', async (req, res) => {
148
160
  const user = auth(req, res);
149
161
  if (!user)
150
162
  return;
151
- const rfq = db.prepare('SELECT * FROM rfqs WHERE id = ?').get(req.params.id);
163
+ const rfq = await dbOne('SELECT * FROM rfqs WHERE id = ?', [req.params.id]);
152
164
  if (!rfq)
153
165
  return void res.status(404).json({ error: 'RFQ 不存在' });
154
166
  const isOwner = rfq.buyer_id === user.id;
155
- const bids = db.prepare(`
167
+ const bids = await dbAll(`
156
168
  SELECT b.id, b.seller_id, b.price, b.qty_offered, b.eta_hours, b.fulfillment_type, b.note,
157
169
  b.auto_bid_skill, b.status, b.submitted_at, b.offer_id,
158
170
  u.handle as seller_handle, u.region as seller_region,
@@ -161,7 +173,7 @@ export function registerRfqsRoutes(app, deps) {
161
173
  LEFT JOIN users u ON u.id = b.seller_id
162
174
  WHERE b.rfq_id = ?
163
175
  ORDER BY b.price ASC, b.submitted_at ASC
164
- `).all(req.params.id);
176
+ `, [req.params.id]);
165
177
  // 仅 owner 看全部;第三方只看自己 + 计数
166
178
  const visibleBids = isOwner ? bids : bids.filter(b => b.seller_id === user.id);
167
179
  // P1:非 owner 时 buyer 身份脱敏(防止私下交易)
@@ -173,11 +185,11 @@ export function registerRfqsRoutes(app, deps) {
173
185
  }
174
186
  res.json({ rfq: safeRfq, bids: visibleBids, bid_count: bids.length, is_owner: isOwner });
175
187
  });
176
- app.delete('/api/rfqs/:id', (req, res) => {
188
+ app.delete('/api/rfqs/:id', async (req, res) => {
177
189
  const user = auth(req, res);
178
190
  if (!user)
179
191
  return;
180
- const rfq = db.prepare('SELECT * FROM rfqs WHERE id = ?').get(req.params.id);
192
+ const rfq = await dbOne('SELECT * FROM rfqs WHERE id = ?', [req.params.id]);
181
193
  if (!rfq)
182
194
  return void res.status(404).json({ error: 'RFQ 不存在' });
183
195
  if (rfq.buyer_id !== user.id)
@@ -187,26 +199,39 @@ export function registerRfqsRoutes(app, deps) {
187
199
  const deposit = Number(rfq.buyer_stake_locked) || 0;
188
200
  const forfeit = Math.round(deposit * 0.30 * 100) / 100;
189
201
  const refund = Math.round((deposit - forfeit) * 100) / 100;
190
- db.transaction(() => {
191
- db.prepare("UPDATE rfqs SET status = 'cancelled', updated_at = datetime('now') WHERE id = ?").run(req.params.id);
192
- if (refund > 0)
193
- db.prepare('UPDATE wallets SET balance = balance + ?, staked = staked - ? WHERE user_id = ?').run(refund, deposit, user.id);
194
- const activeBids = db.prepare("SELECT id, seller_id, stake_locked FROM bids WHERE rfq_id = ? AND status = 'active'").all(req.params.id);
195
- for (const b of activeBids) {
196
- db.prepare("UPDATE bids SET status = 'cancelled', resolved_at = datetime('now') WHERE id = ?").run(b.id);
197
- if (b.stake_locked > 0)
198
- db.prepare('UPDATE wallets SET balance = balance + ?, staked = staked - ? WHERE user_id = ?').run(b.stake_locked, b.stake_locked, b.seller_id);
199
- }
200
- })();
201
- res.json({ success: true, refund, forfeit, active_bids_released: 0 });
202
+ // Codex #236 P1:tx 内先 CAS RFQ open→cancelled,changes!==1 即并发已取消/中标 → 抛回滚,
203
+ // 先于释放买家/bid stake,杜绝重复释放。
204
+ let releasedCount = 0;
205
+ try {
206
+ db.transaction(() => {
207
+ const c = db.prepare("UPDATE rfqs SET status = 'cancelled', updated_at = datetime('now') WHERE id = ? AND status = 'open'").run(req.params.id);
208
+ if (c.changes !== 1)
209
+ throw new Error('RFQ_NOT_OPEN');
210
+ if (refund > 0)
211
+ db.prepare('UPDATE wallets SET balance = balance + ?, staked = staked - ? WHERE user_id = ?').run(refund, deposit, user.id);
212
+ const activeBids = db.prepare("SELECT id, seller_id, stake_locked FROM bids WHERE rfq_id = ? AND status = 'active'").all(req.params.id);
213
+ for (const b of activeBids) {
214
+ db.prepare("UPDATE bids SET status = 'cancelled', resolved_at = datetime('now') WHERE id = ?").run(b.id);
215
+ if (b.stake_locked > 0)
216
+ db.prepare('UPDATE wallets SET balance = balance + ?, staked = staked - ? WHERE user_id = ?').run(b.stake_locked, b.stake_locked, b.seller_id);
217
+ releasedCount++;
218
+ }
219
+ })();
220
+ }
221
+ catch (e) {
222
+ if (e.message === 'RFQ_NOT_OPEN')
223
+ return void res.json({ error: `当前状态不可取消(可能已取消/中标)` });
224
+ throw e;
225
+ }
226
+ res.json({ success: true, refund, forfeit, active_bids_released: releasedCount });
202
227
  });
203
- app.post('/api/rfqs/:id/bids', (req, res) => {
228
+ app.post('/api/rfqs/:id/bids', async (req, res) => {
204
229
  const user = auth(req, res);
205
230
  if (!user)
206
231
  return;
207
232
  if (user.role !== 'seller')
208
233
  return void res.json({ error: '仅卖家可报价' });
209
- const rfq = db.prepare('SELECT * FROM rfqs WHERE id = ?').get(req.params.id);
234
+ const rfq = await dbOne('SELECT * FROM rfqs WHERE id = ?', [req.params.id]);
210
235
  if (!rfq)
211
236
  return void res.status(404).json({ error: 'RFQ 不存在' });
212
237
  if (rfq.status !== 'open')
@@ -228,32 +253,48 @@ export function registerRfqsRoutes(app, deps) {
228
253
  const fulfillmentType = String(body.fulfillment_type || 'standard');
229
254
  if (!VALID_FULFILLMENT_TYPES.has(fulfillmentType))
230
255
  return void res.json({ error: 'fulfillment_type 无效' });
231
- const today = db.prepare("SELECT COUNT(1) as n FROM bids WHERE seller_id = ? AND submitted_at > datetime('now','-1 day')").get(user.id).n;
256
+ const today = (await dbOne("SELECT COUNT(1) as n FROM bids WHERE seller_id = ? AND submitted_at > datetime('now','-1 day')", [user.id])).n;
232
257
  if (today >= BID_DAILY_CAP_PER_SELLER) {
233
258
  return void res.json({ error: `今日报价已达上限 ${BID_DAILY_CAP_PER_SELLER} 条` });
234
259
  }
235
260
  // 一卖家 × 一 RFQ = 一 bid(已有则用 PATCH)
236
- const existing = db.prepare("SELECT id, status FROM bids WHERE rfq_id = ? AND seller_id = ?").get(req.params.id, user.id);
261
+ const existing = await dbOne("SELECT id, status FROM bids WHERE rfq_id = ? AND seller_id = ?", [req.params.id, user.id]);
237
262
  if (existing && existing.status === 'active')
238
263
  return void res.json({ error: '已有进行中的 bid,请改用 PATCH 修改', bid_id: existing.id });
239
264
  const stake = bidStakeFor(price, qtyOffered);
240
- const wallet = db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(user.id);
265
+ const wallet = await dbOne('SELECT balance FROM wallets WHERE user_id = ?', [user.id]);
241
266
  if (!wallet || Number(wallet.balance) < stake) {
242
267
  return void res.json({ error: `余额不足,bid 押金 ${stake} WAZ(落选/取消立即释放,中标后转 escrow)` });
243
268
  }
244
269
  const id = generateId('bid');
245
- db.transaction(() => {
246
- db.prepare(`
247
- INSERT INTO bids (id, rfq_id, seller_id, offer_id, price, qty_offered, eta_hours, fulfillment_type, note, stake_locked, auto_bid_skill)
248
- VALUES (?,?,?,?,?,?,?,?,?,?,?)
249
- `).run(id, req.params.id, user.id, body.offer_id ? String(body.offer_id) : null, price, qtyOffered, body.eta_hours != null ? Number(body.eta_hours) : null, fulfillmentType, body.note ? String(body.note).slice(0, 500) : null, stake, body.auto_bid_skill ? 1 : 0);
250
- db.prepare('UPDATE wallets SET balance = balance - ?, staked = staked + ? WHERE user_id = ?').run(stake, stake, user.id);
251
- db.prepare(`UPDATE rfqs SET bid_count = bid_count + 1, status = 'open', updated_at = datetime('now') WHERE id = ?`).run(req.params.id);
252
- })();
270
+ // Codex #236 P1:await 预检(rfq open / 余额)与同步 tx 间有 yield。tx 内先原子确认 RFQ 仍 open
271
+ // (WHERE status='open' bump bid_count;原来无条件 SET status='open' 会把已中标/取消的 RFQ 复活,一并修掉),
272
+ // 再插 bid + balance>=stake 守卫扣款;任一失败抛回滚(连带回滚已插 bid),杜绝超额/向已关闭 RFQ 报价。
253
273
  try {
254
- db.prepare(`INSERT INTO notifications (id, user_id, type, title, body, created_at)
255
- VALUES (?,?,'rfq_bid',?,?,datetime('now'))`)
256
- .run(generateId('ntf'), rfq.buyer_id, `💰 新报价 ${price} WAZ`, `RFQ:${rfq.title} · #${req.params.id}`);
274
+ db.transaction(() => {
275
+ const rOpen = db.prepare(`UPDATE rfqs SET bid_count = bid_count + 1, updated_at = datetime('now') WHERE id = ? AND status = 'open'`).run(req.params.id);
276
+ if (rOpen.changes !== 1)
277
+ throw new Error('RFQ_NOT_OPEN');
278
+ db.prepare(`
279
+ INSERT INTO bids (id, rfq_id, seller_id, offer_id, price, qty_offered, eta_hours, fulfillment_type, note, stake_locked, auto_bid_skill)
280
+ VALUES (?,?,?,?,?,?,?,?,?,?,?)
281
+ `).run(id, req.params.id, user.id, body.offer_id ? String(body.offer_id) : null, price, qtyOffered, body.eta_hours != null ? Number(body.eta_hours) : null, fulfillmentType, body.note ? String(body.note).slice(0, 500) : null, stake, body.auto_bid_skill ? 1 : 0);
282
+ const d = db.prepare('UPDATE wallets SET balance = balance - ?, staked = staked + ? WHERE user_id = ? AND balance >= ?').run(stake, stake, user.id, stake);
283
+ if (d.changes !== 1)
284
+ throw new Error('BID_INSUFFICIENT_BALANCE');
285
+ })();
286
+ }
287
+ catch (e) {
288
+ const m = e.message;
289
+ if (m === 'RFQ_NOT_OPEN')
290
+ return void res.json({ error: `当前状态不接受报价(可能已中标/取消)` });
291
+ if (m === 'BID_INSUFFICIENT_BALANCE')
292
+ return void res.json({ error: `余额不足,bid 押金 ${stake} WAZ(落选/取消立即释放,中标后转 escrow)` });
293
+ throw e;
294
+ }
295
+ try {
296
+ await dbRun(`INSERT INTO notifications (id, user_id, type, title, body, created_at)
297
+ VALUES (?,?,'rfq_bid',?,?,datetime('now'))`, [generateId('ntf'), rfq.buyer_id, `💰 新报价 ${price} WAZ`, `RFQ:${rfq.title} · #${req.params.id}`]);
257
298
  }
258
299
  catch (e) {
259
300
  console.error('[P3 notify bid]', e);
@@ -277,9 +318,8 @@ export function registerRfqsRoutes(app, deps) {
277
318
  if (result.ok) {
278
319
  autoAwardedOrder = result.order_id;
279
320
  try {
280
- db.prepare(`INSERT INTO notifications (id, user_id, type, title, body, created_at)
281
- VALUES (?,?,'rfq_won',?,?,datetime('now'))`)
282
- .run(generateId('ntf'), user.id, `🎉 中标(first_match 自动选)`, `订单 ${result.order_id}`);
321
+ await dbRun(`INSERT INTO notifications (id, user_id, type, title, body, created_at)
322
+ VALUES (?,?,'rfq_won',?,?,datetime('now'))`, [generateId('ntf'), user.id, `🎉 中标(first_match 自动选)`, `订单 ${result.order_id}`]);
283
323
  }
284
324
  catch (e) {
285
325
  console.error('[P3c notify first_match won]', e);
@@ -294,18 +334,18 @@ export function registerRfqsRoutes(app, deps) {
294
334
  res.json({ id, stake_locked: stake, auto_awarded_order_id: autoAwardedOrder });
295
335
  });
296
336
  // 卖家:修改 bid(仅 active;stake 差额自动结算)
297
- app.patch('/api/bids/:id', (req, res) => {
337
+ app.patch('/api/bids/:id', async (req, res) => {
298
338
  const user = auth(req, res);
299
339
  if (!user)
300
340
  return;
301
- const bid = db.prepare('SELECT * FROM bids WHERE id = ?').get(req.params.id);
341
+ const bid = await dbOne('SELECT * FROM bids WHERE id = ?', [req.params.id]);
302
342
  if (!bid)
303
343
  return void res.status(404).json({ error: 'bid 不存在' });
304
344
  if (bid.seller_id !== user.id)
305
345
  return void res.status(403).json({ error: '仅本人可修改' });
306
346
  if (bid.status !== 'active')
307
347
  return void res.json({ error: `当前状态 ${bid.status} 不可修改` });
308
- const rfq = db.prepare('SELECT max_price, status, deadline_at FROM rfqs WHERE id = ?').get(bid.rfq_id);
348
+ const rfq = await dbOne('SELECT max_price, status, deadline_at FROM rfqs WHERE id = ?', [bid.rfq_id]);
309
349
  if (!rfq || rfq.status !== 'open')
310
350
  return void res.json({ error: 'RFQ 已不接受改价' });
311
351
  const body = req.body;
@@ -342,50 +382,89 @@ export function registerRfqsRoutes(app, deps) {
342
382
  const newStake = bidStakeFor(newPrice, newQty);
343
383
  const delta = Math.round((newStake - oldStake) * 100) / 100;
344
384
  if (delta > 0) {
345
- const wallet = db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(user.id);
385
+ const wallet = await dbOne('SELECT balance FROM wallets WHERE user_id = ?', [user.id]);
346
386
  if (!wallet || Number(wallet.balance) < delta) {
347
387
  return void res.json({ error: `余额不足补足 stake 差额 ${delta} WAZ` });
348
388
  }
349
389
  }
350
- db.transaction(() => {
351
- db.prepare(`UPDATE bids SET price = ?, qty_offered = ?, eta_hours = ?, fulfillment_type = ?, note = ?, stake_locked = ?
352
- WHERE id = ?`).run(newPrice, newQty, newEta, newFt, newNote, newStake, req.params.id);
353
- if (delta > 0) {
354
- db.prepare('UPDATE wallets SET balance = balance - ?, staked = staked + ? WHERE user_id = ?').run(delta, delta, user.id);
355
- }
356
- else if (delta < 0) {
357
- const back = -delta;
358
- db.prepare('UPDATE wallets SET balance = balance + ?, staked = staked - ? WHERE user_id = ?').run(back, back, user.id);
359
- }
360
- })();
361
- res.json({ success: true, stake_locked: newStake, stake_delta: delta });
390
+ // Codex #236 P1:await 预检后进入同步 tx 前,bid/rfq 状态与 stake 可能变。tx 内重读 bid(必须仍
391
+ // active)+ rfq(必须仍 open),delta 用【tx 内重读的 stake】重算,正 delta 扣款带 balance 守卫。
392
+ let txDelta = delta;
393
+ try {
394
+ db.transaction(() => {
395
+ const freshBid = db.prepare('SELECT status, stake_locked FROM bids WHERE id = ?').get(req.params.id);
396
+ if (!freshBid || freshBid.status !== 'active')
397
+ throw new Error('BID_NOT_ACTIVE');
398
+ const freshRfq = db.prepare('SELECT status FROM rfqs WHERE id = ?').get(bid.rfq_id);
399
+ if (!freshRfq || freshRfq.status !== 'open')
400
+ throw new Error('RFQ_NOT_OPEN');
401
+ txDelta = Math.round((newStake - (Number(freshBid.stake_locked) || 0)) * 100) / 100;
402
+ db.prepare(`UPDATE bids SET price = ?, qty_offered = ?, eta_hours = ?, fulfillment_type = ?, note = ?, stake_locked = ?
403
+ WHERE id = ?`).run(newPrice, newQty, newEta, newFt, newNote, newStake, req.params.id);
404
+ if (txDelta > 0) {
405
+ const d = db.prepare('UPDATE wallets SET balance = balance - ?, staked = staked + ? WHERE user_id = ? AND balance >= ?').run(txDelta, txDelta, user.id, txDelta);
406
+ if (d.changes !== 1)
407
+ throw new Error('PATCH_INSUFFICIENT_BALANCE');
408
+ }
409
+ else if (txDelta < 0) {
410
+ const back = -txDelta;
411
+ db.prepare('UPDATE wallets SET balance = balance + ?, staked = staked - ? WHERE user_id = ?').run(back, back, user.id);
412
+ }
413
+ })();
414
+ }
415
+ catch (e) {
416
+ const m = e.message;
417
+ if (m === 'BID_NOT_ACTIVE')
418
+ return void res.json({ error: 'bid 已不是 active 状态,不可修改' });
419
+ if (m === 'RFQ_NOT_OPEN')
420
+ return void res.json({ error: 'RFQ 已不接受改价' });
421
+ if (m === 'PATCH_INSUFFICIENT_BALANCE')
422
+ return void res.json({ error: `余额不足补足 stake 差额 ${txDelta} WAZ` });
423
+ throw e;
424
+ }
425
+ res.json({ success: true, stake_locked: newStake, stake_delta: txDelta });
362
426
  });
363
427
  // 卖家:撤回 bid(释放 stake)
364
- app.delete('/api/bids/:id', (req, res) => {
428
+ app.delete('/api/bids/:id', async (req, res) => {
365
429
  const user = auth(req, res);
366
430
  if (!user)
367
431
  return;
368
- const bid = db.prepare('SELECT * FROM bids WHERE id = ?').get(req.params.id);
432
+ const bid = await dbOne('SELECT * FROM bids WHERE id = ?', [req.params.id]);
369
433
  if (!bid)
370
434
  return void res.status(404).json({ error: 'bid 不存在' });
371
435
  if (bid.seller_id !== user.id)
372
436
  return void res.status(403).json({ error: '仅本人可撤回' });
373
437
  if (bid.status !== 'active')
374
438
  return void res.json({ error: `当前状态 ${bid.status} 不可撤回` });
375
- const stake = Number(bid.stake_locked) || 0;
376
- db.transaction(() => {
377
- db.prepare("UPDATE bids SET status = 'cancelled', resolved_at = datetime('now') WHERE id = ?").run(req.params.id);
378
- if (stake > 0)
379
- db.prepare('UPDATE wallets SET balance = balance + ?, staked = staked - ? WHERE user_id = ?').run(stake, stake, user.id);
380
- db.prepare("UPDATE rfqs SET bid_count = MAX(0, bid_count - 1), updated_at = datetime('now') WHERE id = ?").run(String(bid.rfq_id));
381
- })();
382
- res.json({ success: true, stake_released: stake });
439
+ // Codex #236 P1:tx 内先 CAS bid active→cancelled,changes!==1 即并发已撤回/中标 → 抛回滚,
440
+ // 先于释放 stake + 减 bid_count;释放额用 tx 内重读的 stake_locked(防并发 patch 改过)。
441
+ let releasedStake = 0;
442
+ try {
443
+ db.transaction(() => {
444
+ const c = db.prepare("UPDATE bids SET status = 'cancelled', resolved_at = datetime('now') WHERE id = ? AND status = 'active'").run(req.params.id);
445
+ if (c.changes !== 1)
446
+ throw new Error('BID_NOT_ACTIVE');
447
+ const fresh = db.prepare('SELECT stake_locked FROM bids WHERE id = ?').get(req.params.id);
448
+ releasedStake = Number(fresh.stake_locked) || 0;
449
+ if (releasedStake > 0)
450
+ db.prepare('UPDATE wallets SET balance = balance + ?, staked = staked - ? WHERE user_id = ?').run(releasedStake, releasedStake, user.id);
451
+ db.prepare("UPDATE rfqs SET bid_count = MAX(0, bid_count - 1), updated_at = datetime('now') WHERE id = ?").run(String(bid.rfq_id));
452
+ })();
453
+ }
454
+ catch (e) {
455
+ if (e.message === 'BID_NOT_ACTIVE')
456
+ return void res.json({ error: `当前状态不可撤回(可能已撤回/中标)` });
457
+ throw e;
458
+ }
459
+ res.json({ success: true, stake_released: releasedStake });
383
460
  });
384
461
  // 买家:选定 winning bid
385
- app.post('/api/rfqs/:id/award', (req, res) => {
462
+ app.post('/api/rfqs/:id/award', async (req, res) => {
386
463
  const user = auth(req, res);
387
464
  if (!user)
388
465
  return;
466
+ // 选标读保持同步:rfq/winner 直接作为权威 subject 喂进 awardBidAndCreateOrder,
467
+ // 而该函数事务内不再 re-read,async 化会在读→建单事务之间插入 await gap → 破坏原子性。
389
468
  const rfq = db.prepare('SELECT * FROM rfqs WHERE id = ?').get(req.params.id);
390
469
  if (!rfq)
391
470
  return void res.status(404).json({ error: 'RFQ 不存在' });
@@ -417,9 +496,8 @@ export function registerRfqsRoutes(app, deps) {
417
496
  }
418
497
  // 通知(事务外):中标
419
498
  try {
420
- db.prepare(`INSERT INTO notifications (id, user_id, type, title, body, created_at)
421
- VALUES (?,?,'rfq_won',?,?,datetime('now'))`)
422
- .run(generateId('ntf'), winner.seller_id, `🎉 中标:${rfq.title}`, `订单 ${result.order_id}`);
499
+ await dbRun(`INSERT INTO notifications (id, user_id, type, title, body, created_at)
500
+ VALUES (?,?,'rfq_won',?,?,datetime('now'))`, [generateId('ntf'), winner.seller_id, `🎉 中标:${rfq.title}`, `订单 ${result.order_id}`]);
423
501
  }
424
502
  catch (e) {
425
503
  console.error('[P3 notify won]', e);
@@ -1,13 +1,15 @@
1
+ import { dbOne, dbAll } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
1
2
  export function registerSearchRoutes(app, deps) {
2
- const { db, auth, applyCouponToOrder, extractUrlFromText, extractTitleFromText, parsePlatformUrl, searchByExternalLink, detectShareCommandFormat, formatProductForAgent } = deps;
3
- app.get('/api/coupons/preview', (req, res) => {
3
+ // db 已走 RFC-016 异步 seam(dbOne/dbAll);applyCouponToOrder 是注入的同步 wrapper(订单金钱路径)
4
+ const { auth, applyCouponToOrder, extractUrlFromText, extractTitleFromText, parsePlatformUrl, searchByExternalLink, detectShareCommandFormat, formatProductForAgent } = deps;
5
+ app.get('/api/coupons/preview', async (req, res) => {
4
6
  const user = auth(req, res);
5
7
  if (!user)
6
8
  return;
7
9
  const { code, product_id } = req.query;
8
10
  if (!code || !product_id)
9
11
  return void res.status(400).json({ error: '需提供 code + product_id' });
10
- const p = db.prepare('SELECT seller_id, price FROM products WHERE id = ?').get(product_id);
12
+ const p = await dbOne('SELECT seller_id, price FROM products WHERE id = ?', [product_id]);
11
13
  if (!p)
12
14
  return void res.status(404).json({ error: '商品不存在' });
13
15
  const result = applyCouponToOrder(code, p.seller_id, product_id, Number(p.price));
@@ -15,11 +17,11 @@ export function registerSearchRoutes(app, deps) {
15
17
  return void res.json({ ok: false, error: result.error });
16
18
  res.json({ ok: true, discount: result.discount, final_price: Math.max(0, Number(p.price) - (result.discount || 0)) });
17
19
  });
18
- app.get('/api/my-products', (req, res) => {
20
+ app.get('/api/my-products', async (req, res) => {
19
21
  const user = auth(req, res);
20
22
  if (!user)
21
23
  return;
22
- const products = db.prepare(`
24
+ const products = await dbAll(`
23
25
  SELECT p.*,
24
26
  CASE WHEN EXISTS (
25
27
  SELECT 1 FROM verify_tasks WHERE product_id=p.id AND status IN ('code_issued','open')
@@ -28,7 +30,7 @@ export function registerSearchRoutes(app, deps) {
28
30
  AND NOT EXISTS (SELECT 1 FROM product_external_links WHERE product_id=p.id AND verified=1 AND (revoked IS NULL OR revoked=0))
29
31
  THEN 1 ELSE 0 END as all_links_revoked
30
32
  FROM products p WHERE p.seller_id = ? ORDER BY p.created_at DESC
31
- `).all(user.id);
33
+ `, [user.id]);
32
34
  res.json(products);
33
35
  });
34
36
  app.post('/api/search-by-link', (req, res) => {
@@ -83,7 +85,7 @@ export function registerSearchRoutes(app, deps) {
83
85
  ...(unsupportedHint ? { unsupported_format: true, hint: unsupportedHint } : {}),
84
86
  });
85
87
  });
86
- app.get('/api/search-fuzzy', (req, res) => {
88
+ app.get('/api/search-fuzzy', async (req, res) => {
87
89
  const q = String(req.query.q ?? '').trim();
88
90
  const threshold = 0.5;
89
91
  if (!q)
@@ -103,14 +105,14 @@ export function registerSearchRoutes(app, deps) {
103
105
  return out;
104
106
  };
105
107
  const qg = grams(qn);
106
- const rows = db.prepare(`
108
+ const rows = await dbAll(`
107
109
  SELECT p.*, u.name as seller_name,
108
110
  COALESCE(rs.total_points, 0) as rep_points, COALESCE(rs.level, 'new') as rep_level
109
111
  FROM products p
110
112
  JOIN users u ON p.seller_id = u.id
111
113
  LEFT JOIN reputation_scores rs ON rs.user_id = p.seller_id
112
114
  WHERE p.status = 'active' AND p.stock > 0
113
- `).all();
115
+ `);
114
116
  const scored = rows
115
117
  .map((r) => {
116
118
  const tn = norm(String(r.title ?? ''));
@@ -144,26 +146,26 @@ export function registerSearchRoutes(app, deps) {
144
146
  score_threshold: threshold,
145
147
  });
146
148
  });
147
- app.get('/api/check-url', (req, res) => {
149
+ app.get('/api/check-url', async (req, res) => {
148
150
  const user = auth(req, res);
149
151
  if (!user)
150
152
  return;
151
153
  const url = req.query.url;
152
154
  if (!url)
153
155
  return void res.json({ error: '请提供 url 参数' });
154
- const selfClaim = db.prepare(`
156
+ const selfClaim = await dbOne(`
155
157
  SELECT p.id as product_id, p.title FROM product_external_links pel
156
158
  JOIN products p ON pel.product_id = p.id
157
159
  WHERE pel.url = ? AND p.seller_id = ?
158
- `).get(url, user.id);
160
+ `, [url, user.id]);
159
161
  if (selfClaim) {
160
162
  return void res.json({ claimed: true, self: true, product_title: selfClaim.title, message: `您已在商品「${selfClaim.title}」中关联了此链接` });
161
163
  }
162
- const otherClaim = db.prepare(`
164
+ const otherClaim = await dbOne(`
163
165
  SELECT p.title as product_title FROM product_external_links pel
164
166
  JOIN products p ON pel.product_id = p.id
165
167
  WHERE pel.url = ? AND pel.verified = 1 AND p.seller_id != ?
166
- `).get(url, user.id);
168
+ `, [url, user.id]);
167
169
  if (otherClaim) {
168
170
  return void res.json({ claimed: true, self: false, message: `此链接已被其他商家认领,不能直接添加,上架后请在商品编辑页发起认领验证任务` });
169
171
  }