@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.
- package/README.md +2 -0
- package/dist/layer0-foundation/L0-1-database/db-backends/pg-backend.js +51 -0
- package/dist/layer0-foundation/L0-1-database/db-backends/sql-dialect-datetime.js +437 -0
- package/dist/layer0-foundation/L0-1-database/db-backends/sql-placeholders.js +98 -0
- package/dist/layer0-foundation/L0-1-database/db.js +65 -0
- package/dist/layer0-foundation/L0-2-state-machine/order-chain.js +13 -11
- package/dist/layer0-foundation/L0-2-state-machine/transitions.js +1 -1
- package/dist/layer0-foundation/L0-5-manifest/manifest.js +13 -11
- package/dist/layer1-agent/L1-1-mcp-server/server.js +198 -83
- package/dist/layer1-agent/L1-2-external-anchor/anchor-engine.js +14 -12
- package/dist/layer2-business/L2-6-notifications/notification-engine.js +8 -5
- package/dist/layer2-business/L2-7-snf/snf-engine.js +16 -14
- package/dist/layer2-business/L2-8-feedback/build-feedback-engine.js +18 -10
- package/dist/layer2-business/L2-9-contribution/build-reputation-engine.js +37 -23
- package/dist/layer2-business/L2-9-contribution/build-task-agent-metadata-store.js +173 -0
- package/dist/layer2-business/L2-9-contribution/build-task-participation.js +47 -0
- package/dist/layer2-business/L2-9-contribution/build-task-read.js +222 -0
- package/dist/layer2-business/L2-9-contribution/build-tasks-engine.js +10 -2
- package/dist/layer2-business/L2-9-contribution/canonical-contribution-target.js +16 -0
- package/dist/layer2-business/L2-9-contribution/contribution-display-envelope.js +40 -0
- package/dist/layer2-business/L2-9-contribution/contribution-score-contract.js +36 -0
- package/dist/layer2-business/L2-9-contribution/contribution-score-evidence.js +61 -0
- package/dist/layer2-business/L2-9-contribution/github-credential/canonical.js +60 -0
- package/dist/layer2-business/L2-9-contribution/github-credential/github-credential.schema.js +140 -0
- package/dist/layer2-business/L2-9-contribution/github-credential/github-fetch-adapter.js +437 -0
- package/dist/layer2-business/L2-9-contribution/github-credential/self-consistency.js +38 -0
- package/dist/layer2-business/L2-9-contribution/github-credential/verifier.js +231 -0
- package/dist/layer2-business/L2-9-contribution/github-credential-ingestion-engine.js +145 -0
- package/dist/layer2-business/L2-9-contribution/github-credential-store.js +115 -0
- package/dist/layer2-business/L2-9-contribution/identity-binding-engine.js +134 -0
- package/dist/layer2-business/L2-9-contribution/identity-binding-store.js +101 -0
- package/dist/layer2-business/L2-9-contribution/identity-claim-challenge-engine.js +126 -0
- package/dist/layer2-business/L2-9-contribution/identity-claim-challenge-store.js +30 -0
- package/dist/layer2-business/L2-9-contribution/identity-claim-engine.js +109 -0
- package/dist/layer2-business/L2-9-contribution/identity-claim-fact-precondition.js +22 -0
- package/dist/layer2-business/L2-9-contribution/identity-claim-proof-verifier.js +97 -0
- package/dist/layer2-business/L2-9-contribution/identity-claim-read.js +59 -0
- package/dist/layer2-business/L2-9-contribution/task-proposal-store.js +129 -0
- package/dist/layer2-business/L2-notes/note-photo-storage.js +4 -2
- package/dist/layer3-trust/L3-1-dispute-engine/dispute-engine.js +17 -15
- package/dist/layer3-trust/L3-1-dispute-engine/evidence-storage.js +11 -8
- package/dist/layer4-economics/L4-3-reputation/reputation-engine.js +9 -8
- package/dist/layer4-economics/L4-4-skill-market/skill-engine.js +11 -8
- package/dist/layer4-economics/L4-4-skill-market/skill-listing-engine.js +22 -16
- package/dist/pwa/acp-feed.js +13 -1
- package/dist/pwa/contract-fingerprint.js +2 -0
- package/dist/pwa/endpoint-actions.js +5 -1
- package/dist/pwa/goal-index.js +8 -8
- package/dist/pwa/human-presence.js +62 -0
- package/dist/pwa/public/app.js +575 -68
- package/dist/pwa/public/i18n.js +29 -20
- package/dist/pwa/public/index.html +1 -0
- package/dist/pwa/public/openapi.json +2 -2
- package/dist/pwa/rate-limit.js +22 -0
- package/dist/pwa/routes/account-deletion.js +15 -13
- package/dist/pwa/routes/addresses.js +10 -9
- package/dist/pwa/routes/admin-admins.js +13 -14
- package/dist/pwa/routes/admin-analytics.js +109 -69
- package/dist/pwa/routes/admin-catalog.js +13 -11
- package/dist/pwa/routes/admin-editor-picks.js +15 -10
- package/dist/pwa/routes/admin-events.js +5 -3
- package/dist/pwa/routes/admin-health.js +2 -1
- package/dist/pwa/routes/admin-moderation.js +26 -29
- package/dist/pwa/routes/admin-ops.js +22 -21
- package/dist/pwa/routes/admin-protocol-params.js +16 -19
- package/dist/pwa/routes/admin-reports.js +23 -21
- package/dist/pwa/routes/admin-tokenomics.js +26 -25
- package/dist/pwa/routes/admin-users-lifecycle.js +37 -40
- package/dist/pwa/routes/admin-users-query.js +54 -53
- package/dist/pwa/routes/admin-verifier-flow.js +82 -41
- package/dist/pwa/routes/admin-verifier-whitelist.js +55 -27
- package/dist/pwa/routes/admin-wallet-ops.js +7 -5
- package/dist/pwa/routes/agent-buy.js +46 -22
- package/dist/pwa/routes/agent-governance.js +52 -56
- package/dist/pwa/routes/ai.js +7 -5
- package/dist/pwa/routes/analytics.js +43 -41
- package/dist/pwa/routes/anchors.js +19 -20
- package/dist/pwa/routes/announcements.js +13 -13
- package/dist/pwa/routes/arbitrator.js +97 -31
- package/dist/pwa/routes/auction.js +153 -114
- package/dist/pwa/routes/auth-login.js +6 -4
- package/dist/pwa/routes/auth-read.js +11 -9
- package/dist/pwa/routes/auth-register.js +35 -20
- package/dist/pwa/routes/auth-sessions.js +12 -11
- package/dist/pwa/routes/blocklist.js +16 -15
- package/dist/pwa/routes/build-feedback.js +10 -9
- package/dist/pwa/routes/build-reputation.js +6 -2
- package/dist/pwa/routes/build-tasks.js +45 -13
- package/dist/pwa/routes/buyer-feeds.js +27 -25
- package/dist/pwa/routes/cart.js +16 -15
- package/dist/pwa/routes/charity.js +212 -150
- package/dist/pwa/routes/chat.js +42 -43
- package/dist/pwa/routes/checkin-tasks.js +10 -9
- package/dist/pwa/routes/checkout-helpers.js +12 -10
- package/dist/pwa/routes/claim-initiators.js +34 -14
- package/dist/pwa/routes/claim-verify.js +86 -53
- package/dist/pwa/routes/claim-voting.js +43 -18
- package/dist/pwa/routes/contribution-identity.js +147 -0
- package/dist/pwa/routes/contribution-score.js +19 -0
- package/dist/pwa/routes/coupons.js +19 -16
- package/dist/pwa/routes/dashboards.js +18 -16
- package/dist/pwa/routes/dispute-cases.js +25 -24
- package/dist/pwa/routes/disputes-read.js +45 -51
- package/dist/pwa/routes/disputes-write.js +124 -61
- package/dist/pwa/routes/evidence.js +9 -9
- package/dist/pwa/routes/external-anchors.js +13 -12
- package/dist/pwa/routes/feedback.js +29 -33
- package/dist/pwa/routes/flash-sales.js +18 -16
- package/dist/pwa/routes/follows.js +25 -24
- package/dist/pwa/routes/governance-auto-deactivate.js +21 -9
- package/dist/pwa/routes/governance-onboarding.js +70 -59
- package/dist/pwa/routes/group-buys.js +22 -22
- package/dist/pwa/routes/growth.js +33 -30
- package/dist/pwa/routes/import-product.js +12 -10
- package/dist/pwa/routes/kyc.js +9 -8
- package/dist/pwa/routes/leaderboard.js +20 -18
- package/dist/pwa/routes/listings.js +23 -22
- package/dist/pwa/routes/logistics.js +10 -8
- package/dist/pwa/routes/manifests.js +27 -27
- package/dist/pwa/routes/me-data.js +23 -21
- package/dist/pwa/routes/notifications.js +7 -6
- package/dist/pwa/routes/offers.js +30 -12
- package/dist/pwa/routes/orders-action.js +33 -17
- package/dist/pwa/routes/orders-create.js +75 -20
- package/dist/pwa/routes/orders-read.js +21 -20
- package/dist/pwa/routes/p2p-products.js +30 -18
- package/dist/pwa/routes/payments-governance.js +61 -56
- package/dist/pwa/routes/peers.js +9 -8
- package/dist/pwa/routes/pin-receipts.js +13 -13
- package/dist/pwa/routes/products-aliases.js +12 -10
- package/dist/pwa/routes/products-claims.js +36 -17
- package/dist/pwa/routes/products-create.js +53 -38
- package/dist/pwa/routes/products-crud.js +17 -16
- package/dist/pwa/routes/products-links.js +49 -26
- package/dist/pwa/routes/products-list.js +6 -4
- package/dist/pwa/routes/products-meta.js +40 -39
- package/dist/pwa/routes/products-update.js +19 -5
- package/dist/pwa/routes/profile-credentials.js +14 -16
- package/dist/pwa/routes/profile-identity.js +14 -13
- package/dist/pwa/routes/profile-location.js +7 -6
- package/dist/pwa/routes/profile-placement.js +19 -17
- package/dist/pwa/routes/profile-prefs.js +11 -11
- package/dist/pwa/routes/promoter.js +55 -49
- package/dist/pwa/routes/public-build-tasks.js +19 -0
- package/dist/pwa/routes/public-utils.js +108 -46
- package/dist/pwa/routes/push.js +16 -15
- package/dist/pwa/routes/ratings.js +30 -30
- package/dist/pwa/routes/recover-key.js +13 -12
- package/dist/pwa/routes/referral.js +37 -32
- package/dist/pwa/routes/reputation.js +3 -2
- package/dist/pwa/routes/returns.js +76 -73
- package/dist/pwa/routes/reviews.js +41 -18
- package/dist/pwa/routes/rewards-apply.js +16 -15
- package/dist/pwa/routes/rewards-auto-downgrade.js +9 -7
- package/dist/pwa/routes/rewards-escrow-expire.js +7 -5
- package/dist/pwa/routes/rfqs.js +163 -85
- package/dist/pwa/routes/search.js +16 -14
- package/dist/pwa/routes/secondhand.js +25 -22
- package/dist/pwa/routes/seller-quota.js +24 -26
- package/dist/pwa/routes/share-redirects.js +59 -55
- package/dist/pwa/routes/shareables-interactions.js +34 -35
- package/dist/pwa/routes/shareables.js +55 -51
- package/dist/pwa/routes/shop-referral.js +57 -0
- package/dist/pwa/routes/shops.js +20 -18
- package/dist/pwa/routes/signaling.js +10 -9
- package/dist/pwa/routes/skill-market.js +16 -16
- package/dist/pwa/routes/skills.js +15 -14
- package/dist/pwa/routes/snf.js +14 -13
- package/dist/pwa/routes/tags.js +10 -9
- package/dist/pwa/routes/task-proposals.js +45 -0
- package/dist/pwa/routes/trial.js +69 -51
- package/dist/pwa/routes/trusted-kpi.js +20 -18
- package/dist/pwa/routes/url-claim.js +67 -28
- package/dist/pwa/routes/users-public.js +62 -60
- package/dist/pwa/routes/variants.js +12 -13
- package/dist/pwa/routes/verifier-user.js +61 -21
- package/dist/pwa/routes/verify-tasks.js +49 -25
- package/dist/pwa/routes/waitlist.js +16 -15
- package/dist/pwa/routes/wallet-read.js +74 -36
- package/dist/pwa/routes/wallet-write.js +12 -9
- package/dist/pwa/routes/webauthn.js +25 -26
- package/dist/pwa/routes/webhooks.js +26 -26
- package/dist/pwa/routes/welcome.js +45 -50
- package/dist/pwa/routes/wishlist-qa.js +29 -32
- package/dist/pwa/server.js +237 -81
- package/dist/version.js +1 -1
- package/package.json +47 -2
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { recordRepEvent } from '../../layer4-economics/L4-3-reputation/reputation-engine.js';
|
|
2
|
+
// RFC-016 Phase 1 — 端点校验读/列表读 + 状态翻转/消息/通知单写 → async seam;
|
|
3
|
+
// executeReturnRefund 退款 db.transaction(钱+库存)与 escalate 建争议 tx 保持同步(Phase 3 迁 pg)。
|
|
4
|
+
import { dbOne, dbAll, dbRun } from '../../layer0-foundation/L0-1-database/db.js';
|
|
2
5
|
const VALID_RETURN_REASONS = new Set(['quality', 'wrong_item', 'damaged', 'no_longer_needed', 'other']);
|
|
3
6
|
const RETURN_REASON_DEFAULT_LABEL = {
|
|
4
7
|
quality: '质量问题',
|
|
@@ -10,15 +13,24 @@ const RETURN_REASON_DEFAULT_LABEL = {
|
|
|
10
13
|
export function registerReturnsRoutes(app, deps) {
|
|
11
14
|
const { db, generateId, auth, isTrustedRole, errorRes, broadcastSystemEvent, detectFraud } = deps;
|
|
12
15
|
// L3 Phase 2 抽出:accept(无 pickup) 和 received(有 pickup) 共享退款 + 库存 + 通知
|
|
13
|
-
|
|
16
|
+
// fromStatus = 调用方允许的源状态(accept-no-pickup='pending' / received='picked_up')。
|
|
17
|
+
// Codex #235 P1:两个端点都 await 预读 rr.status 后才进入这个同步 tx,await 间隔内
|
|
18
|
+
// 并发请求可都看到 pending/picked_up → 双双退款。故 tx 内先用 fromStatus CAS 抢占,
|
|
19
|
+
// 先于任何钱/库存写;changes!==1 即并发已结算 → 抛回滚。
|
|
20
|
+
function executeReturnRefund(rr, response, fromStatus) {
|
|
14
21
|
db.transaction(() => {
|
|
15
22
|
const refundAmt = Number(rr.refund_amount);
|
|
16
|
-
|
|
17
|
-
|
|
23
|
+
// 1. CAS 抢占 return 行(fromStatus→refunded),先于任何写
|
|
24
|
+
const claimed = db.prepare(`UPDATE return_requests SET status = 'refunded', seller_response = COALESCE(?, seller_response), resolved_at = datetime('now') WHERE id = ? AND status = ?`)
|
|
25
|
+
.run(response, rr.id, fromStatus);
|
|
26
|
+
if (claimed.changes !== 1)
|
|
27
|
+
throw new Error('RETURN_ALREADY_SETTLED');
|
|
28
|
+
// 2. 卖家扣款带余额守卫;changes!==1 = 余额不足 → 抛回滚,买家不入账
|
|
29
|
+
const debited = db.prepare('UPDATE wallets SET balance = balance - ? WHERE user_id = ? AND balance >= ?').run(refundAmt, rr.seller_id, refundAmt);
|
|
30
|
+
if (debited.changes !== 1)
|
|
18
31
|
throw new Error('INSUFFICIENT_SELLER_BALANCE');
|
|
19
|
-
}
|
|
20
|
-
db.prepare('UPDATE wallets SET balance = balance - ? WHERE user_id = ?').run(refundAmt, rr.seller_id);
|
|
21
32
|
db.prepare('UPDATE wallets SET balance = balance + ? WHERE user_id = ?').run(refundAmt, rr.buyer_id);
|
|
33
|
+
// 3. 恢复库存(CAS + 扣款成功后)
|
|
22
34
|
const ord = db.prepare('SELECT quantity, source, variant_id FROM orders WHERE id = ?').get(rr.order_id);
|
|
23
35
|
if (ord && ord.source !== 'secondhand') {
|
|
24
36
|
const qty = Math.max(1, Number(ord.quantity) || 1);
|
|
@@ -27,8 +39,6 @@ export function registerReturnsRoutes(app, deps) {
|
|
|
27
39
|
db.prepare('UPDATE product_variants SET stock = stock + ?, updated_at = datetime(\'now\') WHERE id = ?').run(qty, ord.variant_id);
|
|
28
40
|
}
|
|
29
41
|
}
|
|
30
|
-
db.prepare(`UPDATE return_requests SET status = 'refunded', seller_response = COALESCE(?, seller_response), resolved_at = datetime('now') WHERE id = ?`)
|
|
31
|
-
.run(response, rr.id);
|
|
32
42
|
try {
|
|
33
43
|
db.prepare(`INSERT INTO return_messages (id, return_id, sender_id, sender_role, body) VALUES (?,?,?,?,?)`)
|
|
34
44
|
.run(generateId('rmsg'), rr.id, rr.seller_id, 'seller', `[✓ 已退款] ${response || ''}`);
|
|
@@ -51,18 +61,18 @@ export function registerReturnsRoutes(app, deps) {
|
|
|
51
61
|
}
|
|
52
62
|
}
|
|
53
63
|
// buyer 发起退货
|
|
54
|
-
app.post('/api/orders/:order_id/return-request', (req, res) => {
|
|
64
|
+
app.post('/api/orders/:order_id/return-request', async (req, res) => {
|
|
55
65
|
const user = auth(req, res);
|
|
56
66
|
if (!user)
|
|
57
67
|
return;
|
|
58
68
|
if (isTrustedRole(user))
|
|
59
69
|
return void errorRes(res, 403, 'TRUSTED_ROLE_NO_TRADE', '受信角色无购物功能');
|
|
60
|
-
const order =
|
|
70
|
+
const order = await dbOne(`
|
|
61
71
|
SELECT o.id, o.buyer_id, o.seller_id, o.product_id, o.status, o.total_amount, o.created_at, o.updated_at,
|
|
62
72
|
p.return_days, p.title as product_title
|
|
63
73
|
FROM orders o JOIN products p ON p.id = o.product_id
|
|
64
74
|
WHERE o.id = ?
|
|
65
|
-
|
|
75
|
+
`, [req.params.order_id]);
|
|
66
76
|
if (!order)
|
|
67
77
|
return void res.status(404).json({ error: '订单不存在' });
|
|
68
78
|
if (order.buyer_id !== user.id)
|
|
@@ -79,9 +89,9 @@ export function registerReturnsRoutes(app, deps) {
|
|
|
79
89
|
if (Date.now() > deadlineMs) {
|
|
80
90
|
return void res.status(400).json({ error: `已超过 ${returnDays} 天退货窗口` });
|
|
81
91
|
}
|
|
82
|
-
const existing =
|
|
92
|
+
const existing = await dbOne(`
|
|
83
93
|
SELECT id, status FROM return_requests WHERE order_id = ? AND status IN ('pending', 'accepted') LIMIT 1
|
|
84
|
-
|
|
94
|
+
`, [order.id]);
|
|
85
95
|
if (existing)
|
|
86
96
|
return void res.status(400).json({ error: `已存在退货请求 (${existing.status})` });
|
|
87
97
|
const reason = String(req.body?.reason || '');
|
|
@@ -101,15 +111,14 @@ export function registerReturnsRoutes(app, deps) {
|
|
|
101
111
|
return void res.status(400).json({ error: '请求上门取件时必须提供取件地址(≥ 4 字)' });
|
|
102
112
|
}
|
|
103
113
|
const reqId = generateId('ret');
|
|
104
|
-
|
|
114
|
+
await dbRun(`
|
|
105
115
|
INSERT INTO return_requests (id, order_id, buyer_id, seller_id, product_id, reason, reason_text, refund_amount, status, pickup_requested, pickup_address)
|
|
106
116
|
VALUES (?,?,?,?,?,?,?,?,'pending',?,?)
|
|
107
|
-
|
|
117
|
+
`, [reqId, order.id, order.buyer_id, order.seller_id, order.product_id, reason, reasonText, refundAmount, pickupRequested, pickupAddress]);
|
|
108
118
|
try {
|
|
109
119
|
const actions = JSON.stringify([{ kind: 'navigate', label: '处理退货', href: `#order/${order.id}`, style: 'primary' }]);
|
|
110
120
|
const pickupNote = pickupRequested ? '(含上门取件请求)' : '';
|
|
111
|
-
|
|
112
|
-
.run(generateId('ntf'), order.seller_id, 'return_request', '⚠ 收到退货请求' + pickupNote, `订单 ${order.product_title} 申请退货 — 原因:${reason}${pickupRequested ? '\n📍 上门取件:' + pickupAddress : ''}`, order.id, actions);
|
|
121
|
+
await dbRun(`INSERT INTO notifications (id, user_id, type, title, body, order_id, actions) VALUES (?,?,?,?,?,?,?)`, [generateId('ntf'), order.seller_id, 'return_request', '⚠ 收到退货请求' + pickupNote, `订单 ${order.product_title} 申请退货 — 原因:${reason}${pickupRequested ? '\n📍 上门取件:' + pickupAddress : ''}`, order.id, actions]);
|
|
113
122
|
}
|
|
114
123
|
catch (e) {
|
|
115
124
|
console.error('[return notify]', e);
|
|
@@ -121,26 +130,26 @@ export function registerReturnsRoutes(app, deps) {
|
|
|
121
130
|
res.json({ success: true, id: reqId, pickup_requested: !!pickupRequested });
|
|
122
131
|
});
|
|
123
132
|
// P1-5: 订单级直查
|
|
124
|
-
app.get('/api/orders/:order_id/return-request', (req, res) => {
|
|
133
|
+
app.get('/api/orders/:order_id/return-request', async (req, res) => {
|
|
125
134
|
const user = auth(req, res);
|
|
126
135
|
if (!user)
|
|
127
136
|
return;
|
|
128
|
-
const order =
|
|
137
|
+
const order = await dbOne('SELECT buyer_id, seller_id FROM orders WHERE id = ?', [req.params.order_id]);
|
|
129
138
|
if (!order)
|
|
130
139
|
return void res.status(404).json({ error: '订单不存在' });
|
|
131
140
|
if (order.buyer_id !== user.id && order.seller_id !== user.id) {
|
|
132
141
|
return void res.status(403).json({ error: '无权查看' });
|
|
133
142
|
}
|
|
134
|
-
const row =
|
|
143
|
+
const row = await dbOne(`
|
|
135
144
|
SELECT id, order_id, product_id, reason, reason_text, refund_amount,
|
|
136
145
|
status, seller_response, escalated_dispute_id, created_at, resolved_at
|
|
137
146
|
FROM return_requests
|
|
138
147
|
WHERE order_id = ?
|
|
139
148
|
ORDER BY created_at DESC LIMIT 1
|
|
140
|
-
|
|
149
|
+
`, [req.params.order_id]);
|
|
141
150
|
res.json({ item: row || null });
|
|
142
151
|
});
|
|
143
|
-
app.get('/api/return-requests', (req, res) => {
|
|
152
|
+
app.get('/api/return-requests', async (req, res) => {
|
|
144
153
|
const user = auth(req, res);
|
|
145
154
|
if (!user)
|
|
146
155
|
return;
|
|
@@ -153,7 +162,7 @@ export function registerReturnsRoutes(app, deps) {
|
|
|
153
162
|
where.push('r.status = ?');
|
|
154
163
|
params.push(status);
|
|
155
164
|
}
|
|
156
|
-
const rows =
|
|
165
|
+
const rows = await dbAll(`
|
|
157
166
|
SELECT r.id, r.order_id, r.product_id, r.reason, r.reason_text, r.refund_amount,
|
|
158
167
|
r.status, r.seller_response, r.escalated_dispute_id, r.created_at, r.resolved_at,
|
|
159
168
|
p.title as product_title, p.category,
|
|
@@ -167,14 +176,14 @@ export function registerReturnsRoutes(app, deps) {
|
|
|
167
176
|
JOIN users us ON us.id = r.seller_id
|
|
168
177
|
WHERE ${where.join(' AND ')}
|
|
169
178
|
ORDER BY r.created_at DESC LIMIT 200
|
|
170
|
-
|
|
179
|
+
`, params);
|
|
171
180
|
res.json({ items: rows });
|
|
172
181
|
});
|
|
173
|
-
app.post('/api/return-requests/:id/decide', (req, res) => {
|
|
182
|
+
app.post('/api/return-requests/:id/decide', async (req, res) => {
|
|
174
183
|
const user = auth(req, res);
|
|
175
184
|
if (!user)
|
|
176
185
|
return;
|
|
177
|
-
const rr =
|
|
186
|
+
const rr = await dbOne(`SELECT * FROM return_requests WHERE id = ?`, [req.params.id]);
|
|
178
187
|
if (!rr)
|
|
179
188
|
return void res.status(404).json({ error: '退货请求不存在' });
|
|
180
189
|
if (rr.seller_id !== user.id)
|
|
@@ -189,65 +198,62 @@ export function registerReturnsRoutes(app, deps) {
|
|
|
189
198
|
return void res.status(400).json({ error: '拒绝时必须填写说明' });
|
|
190
199
|
if (decision === 'accept') {
|
|
191
200
|
if (Number(rr.pickup_requested) === 1) {
|
|
192
|
-
|
|
193
|
-
.run(response, rr.id);
|
|
201
|
+
await dbRun(`UPDATE return_requests SET status = 'accepted_pickup_pending', seller_response = ? WHERE id = ?`, [response, rr.id]);
|
|
194
202
|
try {
|
|
195
|
-
|
|
196
|
-
.run(generateId('rmsg'), rr.id, rr.seller_id, 'seller', `[✓ 同意 · 等待上门取件] ${response || ''}`);
|
|
203
|
+
await dbRun(`INSERT INTO return_messages (id, return_id, sender_id, sender_role, body) VALUES (?,?,?,?,?)`, [generateId('rmsg'), rr.id, rr.seller_id, 'seller', `[✓ 同意 · 等待上门取件] ${response || ''}`]);
|
|
197
204
|
}
|
|
198
205
|
catch { }
|
|
199
206
|
try {
|
|
200
|
-
|
|
201
|
-
.run(generateId('ntf'), rr.buyer_id, '✓ 退货已接受 · 等待上门取件', `卖家将安排物流到 ${rr.pickup_address || '指定地址'} 上门取件`, rr.order_id);
|
|
207
|
+
await dbRun(`INSERT INTO notifications (id, user_id, title, body, order_id) VALUES (?,?,?,?,?)`, [generateId('ntf'), rr.buyer_id, '✓ 退货已接受 · 等待上门取件', `卖家将安排物流到 ${rr.pickup_address || '指定地址'} 上门取件`, rr.order_id]);
|
|
202
208
|
}
|
|
203
209
|
catch { }
|
|
204
210
|
return void res.json({ success: true, status: 'accepted_pickup_pending' });
|
|
205
211
|
}
|
|
206
212
|
try {
|
|
207
|
-
executeReturnRefund(rr, response);
|
|
213
|
+
executeReturnRefund(rr, response, 'pending');
|
|
208
214
|
}
|
|
209
215
|
catch (e) {
|
|
210
|
-
const
|
|
216
|
+
const m = e.message;
|
|
217
|
+
if (m === 'RETURN_ALREADY_SETTLED')
|
|
218
|
+
return void res.status(409).json({ error: '该退货已处理(请刷新后查看)' });
|
|
219
|
+
const msg = m === 'INSUFFICIENT_SELLER_BALANCE' ? '卖家余额不足以退款' : '退款失败';
|
|
211
220
|
return void res.status(400).json({ error: msg });
|
|
212
221
|
}
|
|
213
222
|
return void res.json({ success: true, status: 'refunded' });
|
|
214
223
|
}
|
|
215
224
|
else {
|
|
216
|
-
|
|
217
|
-
.run(response, rr.id);
|
|
225
|
+
await dbRun(`UPDATE return_requests SET status = 'rejected', seller_response = ?, resolved_at = datetime('now') WHERE id = ?`, [response, rr.id]);
|
|
218
226
|
try {
|
|
219
|
-
|
|
220
|
-
.run(generateId('rmsg'), rr.id, rr.seller_id, 'seller', `[✗ 拒绝退款] ${response}`);
|
|
227
|
+
await dbRun(`INSERT INTO return_messages (id, return_id, sender_id, sender_role, body) VALUES (?,?,?,?,?)`, [generateId('rmsg'), rr.id, rr.seller_id, 'seller', `[✗ 拒绝退款] ${response}`]);
|
|
221
228
|
}
|
|
222
229
|
catch { }
|
|
223
230
|
try {
|
|
224
|
-
|
|
225
|
-
.run(generateId('ntf'), rr.buyer_id, '⚠ 退货请求被拒绝', `卖家说明:${response} — 如有异议可发起争议`, rr.order_id);
|
|
231
|
+
await dbRun(`INSERT INTO notifications (id, user_id, title, body, order_id) VALUES (?,?,?,?,?)`, [generateId('ntf'), rr.buyer_id, '⚠ 退货请求被拒绝', `卖家说明:${response} — 如有异议可发起争议`, rr.order_id]);
|
|
226
232
|
}
|
|
227
233
|
catch { }
|
|
228
234
|
return void res.json({ success: true, status: 'rejected' });
|
|
229
235
|
}
|
|
230
236
|
});
|
|
231
|
-
app.delete('/api/return-requests/:id', (req, res) => {
|
|
237
|
+
app.delete('/api/return-requests/:id', async (req, res) => {
|
|
232
238
|
const user = auth(req, res);
|
|
233
239
|
if (!user)
|
|
234
240
|
return;
|
|
235
|
-
const rr =
|
|
241
|
+
const rr = await dbOne(`SELECT id, buyer_id, status FROM return_requests WHERE id = ?`, [req.params.id]);
|
|
236
242
|
if (!rr)
|
|
237
243
|
return void res.status(404).json({ error: '不存在' });
|
|
238
244
|
if (rr.buyer_id !== user.id)
|
|
239
245
|
return void res.status(403).json({ error: '仅买家可取消' });
|
|
240
246
|
if (rr.status !== 'pending')
|
|
241
247
|
return void res.status(400).json({ error: `当前状态 ${rr.status},不可取消` });
|
|
242
|
-
|
|
248
|
+
await dbRun(`UPDATE return_requests SET status = 'cancelled', resolved_at = datetime('now') WHERE id = ?`, [rr.id]);
|
|
243
249
|
res.json({ success: true });
|
|
244
250
|
});
|
|
245
251
|
// ─── W2 售后协商时间线 ───────────────────────────────
|
|
246
|
-
app.get('/api/return-requests/:id', (req, res) => {
|
|
252
|
+
app.get('/api/return-requests/:id', async (req, res) => {
|
|
247
253
|
const user = auth(req, res);
|
|
248
254
|
if (!user)
|
|
249
255
|
return;
|
|
250
|
-
const rr =
|
|
256
|
+
const rr = await dbOne(`
|
|
251
257
|
SELECT r.*, p.title as product_title, p.category,
|
|
252
258
|
o.total_amount as order_total,
|
|
253
259
|
ub.name as buyer_name, ub.handle as buyer_handle,
|
|
@@ -258,17 +264,17 @@ export function registerReturnsRoutes(app, deps) {
|
|
|
258
264
|
JOIN users ub ON ub.id = r.buyer_id
|
|
259
265
|
JOIN users us ON us.id = r.seller_id
|
|
260
266
|
WHERE r.id = ?
|
|
261
|
-
|
|
267
|
+
`, [req.params.id]);
|
|
262
268
|
if (!rr)
|
|
263
269
|
return void res.status(404).json({ error: '不存在' });
|
|
264
270
|
if (rr.buyer_id !== user.id && rr.seller_id !== user.id) {
|
|
265
271
|
return void res.status(403).json({ error: '无权查看' });
|
|
266
272
|
}
|
|
267
|
-
const messages =
|
|
273
|
+
const messages = await dbAll(`
|
|
268
274
|
SELECT m.*, u.name as sender_name, u.handle as sender_handle
|
|
269
275
|
FROM return_messages m LEFT JOIN users u ON u.id = m.sender_id
|
|
270
276
|
WHERE m.return_id = ? ORDER BY m.created_at ASC
|
|
271
|
-
|
|
277
|
+
`, [rr.id]);
|
|
272
278
|
const events = [];
|
|
273
279
|
events.push({
|
|
274
280
|
id: `create-${rr.id}`,
|
|
@@ -332,13 +338,13 @@ export function registerReturnsRoutes(app, deps) {
|
|
|
332
338
|
res.json({ item: rr, timeline: events });
|
|
333
339
|
});
|
|
334
340
|
// L3 Phase 2: 物流揽收
|
|
335
|
-
app.post('/api/return-requests/:id/picked-up', (req, res) => {
|
|
341
|
+
app.post('/api/return-requests/:id/picked-up', async (req, res) => {
|
|
336
342
|
const user = auth(req, res);
|
|
337
343
|
if (!user)
|
|
338
344
|
return;
|
|
339
345
|
if (user.role !== 'logistics')
|
|
340
346
|
return void res.status(403).json({ error: '仅物流角色可确认揽收' });
|
|
341
|
-
const rr =
|
|
347
|
+
const rr = await dbOne(`SELECT * FROM return_requests WHERE id = ?`, [req.params.id]);
|
|
342
348
|
if (!rr)
|
|
343
349
|
return void res.status(404).json({ error: '退货请求不存在' });
|
|
344
350
|
if (rr.status !== 'accepted_pickup_pending')
|
|
@@ -346,26 +352,24 @@ export function registerReturnsRoutes(app, deps) {
|
|
|
346
352
|
const evidence = String(req.body?.evidence || '').trim().slice(0, 500);
|
|
347
353
|
if (evidence.length < 4)
|
|
348
354
|
return void res.status(400).json({ error: '请提供揽收证据(快递单号 / GPS / 照片描述)≥ 4 字' });
|
|
349
|
-
|
|
355
|
+
await dbRun(`UPDATE return_requests SET status = 'picked_up' WHERE id = ?`, [rr.id]);
|
|
350
356
|
try {
|
|
351
|
-
|
|
352
|
-
.run(generateId('rmsg'), rr.id, user.id, 'logistics', `[📦 已揽收] ${evidence}`);
|
|
357
|
+
await dbRun(`INSERT INTO return_messages (id, return_id, sender_id, sender_role, body) VALUES (?,?,?,?,?)`, [generateId('rmsg'), rr.id, user.id, 'logistics', `[📦 已揽收] ${evidence}`]);
|
|
353
358
|
}
|
|
354
359
|
catch { }
|
|
355
360
|
try {
|
|
356
361
|
const actions = JSON.stringify([{ kind: 'navigate', label: '处理退货', href: `#returns`, style: 'primary' }]);
|
|
357
|
-
|
|
358
|
-
.run(generateId('ntf'), rr.seller_id, 'return_pickup', '📦 退货已揽收 · 等待你确认收到', `物流已揽收:${evidence.slice(0, 80)}`, rr.order_id, actions);
|
|
362
|
+
await dbRun(`INSERT INTO notifications (id, user_id, type, title, body, order_id, actions) VALUES (?,?,?,?,?,?,?)`, [generateId('ntf'), rr.seller_id, 'return_pickup', '📦 退货已揽收 · 等待你确认收到', `物流已揽收:${evidence.slice(0, 80)}`, rr.order_id, actions]);
|
|
359
363
|
}
|
|
360
364
|
catch { }
|
|
361
365
|
res.json({ success: true, status: 'picked_up' });
|
|
362
366
|
});
|
|
363
367
|
// L3 Phase 2: 卖家确认收到 → refunded
|
|
364
|
-
app.post('/api/return-requests/:id/received', (req, res) => {
|
|
368
|
+
app.post('/api/return-requests/:id/received', async (req, res) => {
|
|
365
369
|
const user = auth(req, res);
|
|
366
370
|
if (!user)
|
|
367
371
|
return;
|
|
368
|
-
const rr =
|
|
372
|
+
const rr = await dbOne(`SELECT * FROM return_requests WHERE id = ?`, [req.params.id]);
|
|
369
373
|
if (!rr)
|
|
370
374
|
return void res.status(404).json({ error: '退货请求不存在' });
|
|
371
375
|
if (rr.seller_id !== user.id)
|
|
@@ -374,21 +378,24 @@ export function registerReturnsRoutes(app, deps) {
|
|
|
374
378
|
return void res.status(400).json({ error: `当前状态 ${rr.status},不可确认(应在 picked_up 状态)` });
|
|
375
379
|
const note = req.body?.note ? String(req.body.note).slice(0, 300) : null;
|
|
376
380
|
try {
|
|
377
|
-
executeReturnRefund(rr, note);
|
|
381
|
+
executeReturnRefund(rr, note, 'picked_up');
|
|
378
382
|
}
|
|
379
383
|
catch (e) {
|
|
380
|
-
const
|
|
384
|
+
const m = e.message;
|
|
385
|
+
if (m === 'RETURN_ALREADY_SETTLED')
|
|
386
|
+
return void res.status(409).json({ error: '该退货已处理(请刷新后查看)' });
|
|
387
|
+
const msg = m === 'INSUFFICIENT_SELLER_BALANCE' ? '卖家余额不足以退款' : '退款失败';
|
|
381
388
|
return void res.status(400).json({ error: msg });
|
|
382
389
|
}
|
|
383
390
|
res.json({ success: true, status: 'refunded' });
|
|
384
391
|
});
|
|
385
|
-
app.get('/api/logistics/return-pickups', (req, res) => {
|
|
392
|
+
app.get('/api/logistics/return-pickups', async (req, res) => {
|
|
386
393
|
const user = auth(req, res);
|
|
387
394
|
if (!user)
|
|
388
395
|
return;
|
|
389
396
|
if (user.role !== 'logistics')
|
|
390
397
|
return void res.status(403).json({ error: '仅物流角色' });
|
|
391
|
-
const rows =
|
|
398
|
+
const rows = await dbAll(`
|
|
392
399
|
SELECT rr.id, rr.order_id, rr.product_id, rr.refund_amount, rr.pickup_address,
|
|
393
400
|
rr.reason, rr.created_at, p.title as product_title,
|
|
394
401
|
ub.handle as buyer_handle, us.name as seller_name
|
|
@@ -398,15 +405,14 @@ export function registerReturnsRoutes(app, deps) {
|
|
|
398
405
|
JOIN users us ON us.id = rr.seller_id
|
|
399
406
|
WHERE rr.status = 'accepted_pickup_pending' AND rr.pickup_requested = 1
|
|
400
407
|
ORDER BY rr.created_at ASC LIMIT 50
|
|
401
|
-
`)
|
|
408
|
+
`);
|
|
402
409
|
res.json({ items: rows });
|
|
403
410
|
});
|
|
404
|
-
app.post('/api/return-requests/:id/messages', (req, res) => {
|
|
411
|
+
app.post('/api/return-requests/:id/messages', async (req, res) => {
|
|
405
412
|
const user = auth(req, res);
|
|
406
413
|
if (!user)
|
|
407
414
|
return;
|
|
408
|
-
const rr =
|
|
409
|
-
.get(req.params.id);
|
|
415
|
+
const rr = await dbOne(`SELECT id, buyer_id, seller_id, status FROM return_requests WHERE id = ?`, [req.params.id]);
|
|
410
416
|
if (!rr)
|
|
411
417
|
return void res.status(404).json({ error: '不存在' });
|
|
412
418
|
const isBuyer = rr.buyer_id === user.id;
|
|
@@ -421,14 +427,12 @@ export function registerReturnsRoutes(app, deps) {
|
|
|
421
427
|
return void res.status(400).json({ error: '消息长度 1-1000 字' });
|
|
422
428
|
const reasons = detectFraud(body);
|
|
423
429
|
const mid = generateId('rmsg');
|
|
424
|
-
|
|
425
|
-
.run(mid, rr.id, user.id, isBuyer ? 'buyer' : 'seller', body, reasons.length ? 1 : 0, reasons.length ? JSON.stringify(reasons) : null);
|
|
430
|
+
await dbRun(`INSERT INTO return_messages (id, return_id, sender_id, sender_role, body, flagged, flag_reasons) VALUES (?,?,?,?,?,?,?)`, [mid, rr.id, user.id, isBuyer ? 'buyer' : 'seller', body, reasons.length ? 1 : 0, reasons.length ? JSON.stringify(reasons) : null]);
|
|
426
431
|
try {
|
|
427
432
|
const otherId = isBuyer ? rr.seller_id : rr.buyer_id;
|
|
428
433
|
const orderId = rr.order_id;
|
|
429
434
|
const actions = JSON.stringify([{ kind: 'navigate', label: '查看协商', href: `#order/${orderId}`, style: 'primary' }]);
|
|
430
|
-
|
|
431
|
-
.run(generateId('ntf'), otherId, 'return_msg', '💬 退货协商新消息', body.slice(0, 80), orderId, actions);
|
|
435
|
+
await dbRun(`INSERT INTO notifications (id, user_id, type, title, body, order_id, actions) VALUES (?,?,?,?,?,?,?)`, [generateId('ntf'), otherId, 'return_msg', '💬 退货协商新消息', body.slice(0, 80), orderId, actions]);
|
|
432
436
|
}
|
|
433
437
|
catch (e) {
|
|
434
438
|
console.warn('[notif return_msg]', e.message);
|
|
@@ -436,11 +440,11 @@ export function registerReturnsRoutes(app, deps) {
|
|
|
436
440
|
res.json({ success: true, id: mid, flagged: reasons.length > 0, flag_reasons: reasons });
|
|
437
441
|
});
|
|
438
442
|
// buyer 升级到争议(仅 rejected 后或 pending ≥ 7 天)
|
|
439
|
-
app.post('/api/return-requests/:id/escalate', (req, res) => {
|
|
443
|
+
app.post('/api/return-requests/:id/escalate', async (req, res) => {
|
|
440
444
|
const user = auth(req, res);
|
|
441
445
|
if (!user)
|
|
442
446
|
return;
|
|
443
|
-
const rr =
|
|
447
|
+
const rr = await dbOne(`SELECT * FROM return_requests WHERE id = ?`, [req.params.id]);
|
|
444
448
|
if (!rr)
|
|
445
449
|
return void res.status(404).json({ error: '不存在' });
|
|
446
450
|
if (rr.buyer_id !== user.id)
|
|
@@ -456,7 +460,7 @@ export function registerReturnsRoutes(app, deps) {
|
|
|
456
460
|
}
|
|
457
461
|
if (rr.escalated_dispute_id)
|
|
458
462
|
return void res.status(400).json({ error: '已升级' });
|
|
459
|
-
const order =
|
|
463
|
+
const order = await dbOne('SELECT id, total_amount FROM orders WHERE id = ?', [rr.order_id]);
|
|
460
464
|
if (!order)
|
|
461
465
|
return void res.status(500).json({ error: '订单数据缺失' });
|
|
462
466
|
const reason = `退货协商失败:${RETURN_REASON_DEFAULT_LABEL[String(rr.reason)] || rr.reason}${rr.reason_text ? ' — ' + rr.reason_text : ''}`;
|
|
@@ -480,8 +484,7 @@ export function registerReturnsRoutes(app, deps) {
|
|
|
480
484
|
return void res.status(500).json({ error: '升级失败:' + e.message });
|
|
481
485
|
}
|
|
482
486
|
try {
|
|
483
|
-
|
|
484
|
-
.run(generateId('ntf'), rr.seller_id, '⚖️ 退货已升级为争议', `争议 ${disputeId} 已创建,请在 48h 内提交反驳`, rr.order_id);
|
|
487
|
+
await dbRun(`INSERT INTO notifications (id, user_id, title, body, order_id) VALUES (?,?,?,?,?)`, [generateId('ntf'), rr.seller_id, '⚖️ 退货已升级为争议', `争议 ${disputeId} 已创建,请在 48h 内提交反驳`, rr.order_id]);
|
|
485
488
|
}
|
|
486
489
|
catch { }
|
|
487
490
|
try {
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
import { dbOne, dbAll } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
|
|
1
2
|
export function registerReviewsRoutes(app, deps) {
|
|
3
|
+
// 只读/单写站点走 RFC-016 异步 seam(dbOne/dbAll/dbRun)。
|
|
4
|
+
// db 保留:claim 是质押/escrow 资金路径(dup 门 + 钱包扣减 + INSERT 任务必须原子),
|
|
5
|
+
// 用 db.transaction 同步事务守恒;Phase 3 随资金路径迁 pg(BEGIN + SELECT...FOR UPDATE)。
|
|
2
6
|
const { db, auth, isTrustedRole, errorRes, generateId, REVIEW_CLAIM_TARGETS, REVIEW_CLAIM_STAKE, REVIEW_CLAIM_DEADLINE_HOURS, REVIEW_VERIFIERS_NEEDED } = deps;
|
|
3
|
-
app.get('/api/reviews/recent', (req, res) => {
|
|
7
|
+
app.get('/api/reviews/recent', async (req, res) => {
|
|
4
8
|
const limit = Math.min(100, Math.max(10, Number(req.query.limit) || 50));
|
|
5
|
-
const items =
|
|
9
|
+
const items = await dbAll(`
|
|
6
10
|
SELECT s.id, s.external_url, s.external_platform, s.thumbnail_url, s.title, s.click_count, s.like_count,
|
|
7
11
|
s.created_at, s.related_product_id,
|
|
8
12
|
u.handle as owner_handle, u.name as owner_name,
|
|
@@ -12,10 +16,10 @@ export function registerReviewsRoutes(app, deps) {
|
|
|
12
16
|
LEFT JOIN products p ON p.id = s.related_product_id AND p.status = 'active'
|
|
13
17
|
WHERE s.status = 'active'
|
|
14
18
|
ORDER BY s.created_at DESC LIMIT ?
|
|
15
|
-
|
|
19
|
+
`, [limit]);
|
|
16
20
|
res.json({ items });
|
|
17
21
|
});
|
|
18
|
-
app.post('/api/reviews/:type/:id/claim', (req, res) => {
|
|
22
|
+
app.post('/api/reviews/:type/:id/claim', async (req, res) => {
|
|
19
23
|
const user = auth(req, res);
|
|
20
24
|
if (!user)
|
|
21
25
|
return;
|
|
@@ -29,14 +33,14 @@ export function registerReviewsRoutes(app, deps) {
|
|
|
29
33
|
let productId = null;
|
|
30
34
|
// #1017 fix: shareables / manifest_registry 实际列名是 related_product_id
|
|
31
35
|
if (reviewType === 'shareable') {
|
|
32
|
-
const row =
|
|
36
|
+
const row = await dbOne('SELECT owner_id, related_product_id FROM shareables WHERE id = ?', [req.params.id]);
|
|
33
37
|
if (!row)
|
|
34
38
|
return void res.status(404).json({ error: '评测不存在' });
|
|
35
39
|
reviewerId = row.owner_id;
|
|
36
40
|
productId = row.related_product_id;
|
|
37
41
|
}
|
|
38
42
|
else {
|
|
39
|
-
const row =
|
|
43
|
+
const row = await dbOne('SELECT owner_id, related_product_id FROM manifest_registry WHERE hash = ?', [req.params.id]);
|
|
40
44
|
if (!row)
|
|
41
45
|
return void res.status(404).json({ error: '原生评测不存在' });
|
|
42
46
|
reviewerId = row.owner_id;
|
|
@@ -51,31 +55,50 @@ export function registerReviewsRoutes(app, deps) {
|
|
|
51
55
|
if (text.length < 6 || text.length > 500)
|
|
52
56
|
return void res.status(400).json({ error: 'claim_text 长度需 6-500 字' });
|
|
53
57
|
const evidence = req.body?.evidence_uri ? String(req.body.evidence_uri).trim().slice(0, 500) : null;
|
|
54
|
-
|
|
58
|
+
// 友好预检查(读):余额不足直接早退;真正的守恒门在下面的事务内(WHERE balance >= stake)。
|
|
59
|
+
const wallet = await dbOne('SELECT balance FROM wallets WHERE user_id = ?', [user.id]);
|
|
55
60
|
if (!wallet || wallet.balance < REVIEW_CLAIM_STAKE) {
|
|
56
61
|
return void res.status(400).json({ error: `余额不足:发起需锁 ${REVIEW_CLAIM_STAKE} WAZ` });
|
|
57
62
|
}
|
|
58
|
-
const dup = db.prepare(`SELECT id FROM review_claim_tasks WHERE review_type = ? AND review_id = ? AND claimant_id = ? AND claim_target = ? AND status = 'open'`)
|
|
59
|
-
.get(reviewType, req.params.id, user.id, target);
|
|
60
|
-
if (dup)
|
|
61
|
-
return void res.status(409).json({ error: '你已对此评测同一项发起过 open 声明' });
|
|
62
63
|
const id = generateId('rct');
|
|
63
64
|
const deadline = new Date(Date.now() + REVIEW_CLAIM_DEADLINE_HOURS * 3600_000).toISOString();
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
.
|
|
65
|
+
// 质押/escrow 原子段(同步事务):dup 门 + 钱包扣减(守恒 guard)+ INSERT 任务,
|
|
66
|
+
// 任一失败整段回滚 → 不会出现"任务已建但钱没锁"或"双重 open 声明"或透支。
|
|
67
|
+
try {
|
|
68
|
+
db.transaction(() => {
|
|
69
|
+
const dup = db.prepare(`SELECT id FROM review_claim_tasks WHERE review_type = ? AND review_id = ? AND claimant_id = ? AND claim_target = ? AND status = 'open'`)
|
|
70
|
+
.get(reviewType, req.params.id, user.id, target);
|
|
71
|
+
if (dup)
|
|
72
|
+
throw new Error('CLAIM_DUP');
|
|
73
|
+
// 守恒:仅当余额仍 >= stake 才扣(挡并发透支);changes=0 → 回滚
|
|
74
|
+
const debit = db.prepare('UPDATE wallets SET balance = balance - ?, escrowed = escrowed + ? WHERE user_id = ? AND balance >= ?')
|
|
75
|
+
.run(REVIEW_CLAIM_STAKE, REVIEW_CLAIM_STAKE, user.id, REVIEW_CLAIM_STAKE);
|
|
76
|
+
if (debit.changes === 0)
|
|
77
|
+
throw new Error('CLAIM_INSUFFICIENT');
|
|
78
|
+
db.prepare(`INSERT INTO review_claim_tasks (id, review_type, review_id, product_id, reviewer_id, claimant_id, claim_target, claim_text, evidence_uri, stake_claimant, deadline_at, status) VALUES (?,?,?,?,?,?,?,?,?,?,?,'open')`)
|
|
79
|
+
.run(id, reviewType, req.params.id, productId, reviewerId, user.id, target, text, evidence, REVIEW_CLAIM_STAKE, deadline);
|
|
80
|
+
})();
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
const msg = e.message;
|
|
84
|
+
if (msg === 'CLAIM_DUP')
|
|
85
|
+
return void res.status(409).json({ error: '你已对此评测同一项发起过 open 声明' });
|
|
86
|
+
if (msg === 'CLAIM_INSUFFICIENT')
|
|
87
|
+
return void res.status(400).json({ error: `余额不足:发起需锁 ${REVIEW_CLAIM_STAKE} WAZ` });
|
|
88
|
+
console.error('[reviews claim tx]', msg);
|
|
89
|
+
return void res.status(500).json({ error: '发起声明失败,请重试' });
|
|
90
|
+
}
|
|
68
91
|
res.json({ success: true, claim_id: id, deadline_at: deadline, stake_locked: REVIEW_CLAIM_STAKE });
|
|
69
92
|
});
|
|
70
|
-
app.get('/api/reviews/:type/:id/claims', (req, res) => {
|
|
71
|
-
const rows =
|
|
93
|
+
app.get('/api/reviews/:type/:id/claims', async (req, res) => {
|
|
94
|
+
const rows = await dbAll(`
|
|
72
95
|
SELECT rct.id, rct.claim_target, rct.claim_text, rct.evidence_uri, rct.status, rct.ruling, rct.deadline_at, rct.resolved_at, rct.created_at,
|
|
73
96
|
u.name as claimant_name,
|
|
74
97
|
(SELECT COUNT(*) FROM review_claim_votes WHERE claim_id = rct.id) as votes_count
|
|
75
98
|
FROM review_claim_tasks rct JOIN users u ON u.id = rct.claimant_id
|
|
76
99
|
WHERE rct.review_type = ? AND rct.review_id = ?
|
|
77
100
|
ORDER BY rct.created_at DESC LIMIT 50
|
|
78
|
-
|
|
101
|
+
`, [req.params.type, req.params.id]);
|
|
79
102
|
res.json({ claims: rows, votes_needed: REVIEW_VERIFIERS_NEEDED });
|
|
80
103
|
});
|
|
81
104
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { dbOne } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
|
|
2
3
|
function sha256_hex(s) {
|
|
3
4
|
return createHash('sha256').update(s).digest('hex');
|
|
4
5
|
}
|
|
@@ -8,14 +9,14 @@ export function registerRewardsApplyRoutes(app, deps) {
|
|
|
8
9
|
return sha256_hex(`rewards_apply|consent_version=${consentVersion}|user=${userId}|page_loaded_at=${pageLoadedAt}`);
|
|
9
10
|
}
|
|
10
11
|
// GET /api/rewards/status — current state + escrow tally
|
|
11
|
-
app.get('/api/rewards/status', (req, res) => {
|
|
12
|
+
app.get('/api/rewards/status', async (req, res) => {
|
|
12
13
|
const user = auth(req, res);
|
|
13
14
|
if (!user)
|
|
14
15
|
return;
|
|
15
16
|
const userId = user.id;
|
|
16
|
-
const optIn =
|
|
17
|
-
const lastAction =
|
|
18
|
-
const currentMajor =
|
|
17
|
+
const optIn = (await dbOne("SELECT rewards_opted_in FROM users WHERE id = ?", [userId]))?.rewards_opted_in ?? 0;
|
|
18
|
+
const lastAction = (await dbOne("SELECT action, created_at FROM rewards_applications WHERE user_id = ? ORDER BY created_at DESC LIMIT 1", [userId]));
|
|
19
|
+
const currentMajor = await dbOne("SELECT version, hash, change_class, effective_at, text_zh, text_en FROM rewards_consent_texts WHERE change_class='major' ORDER BY effective_at DESC LIMIT 1", []);
|
|
19
20
|
let state;
|
|
20
21
|
if (optIn === 1)
|
|
21
22
|
state = 'opted_in';
|
|
@@ -25,8 +26,8 @@ export function registerRewardsApplyRoutes(app, deps) {
|
|
|
25
26
|
state = 'auto_downgraded';
|
|
26
27
|
else
|
|
27
28
|
state = 'never_activated';
|
|
28
|
-
const completedOrders =
|
|
29
|
-
const passkeyCount =
|
|
29
|
+
const completedOrders = (await dbOne("SELECT COUNT(*) AS n FROM orders WHERE buyer_id = ? AND status = 'completed'", [userId])).n;
|
|
30
|
+
const passkeyCount = (await dbOne("SELECT COUNT(*) AS n FROM webauthn_credentials WHERE user_id = ?", [userId])).n;
|
|
30
31
|
const minOrders = Number(getProtocolParam('rewards_opt_in.min_completed_orders', 1));
|
|
31
32
|
const requirePasskey = Number(getProtocolParam('rewards_opt_in.require_passkey', 1));
|
|
32
33
|
const delaySec = Number(getProtocolParam('rewards_opt_in.consent_delay_seconds', 8));
|
|
@@ -35,8 +36,8 @@ export function registerRewardsApplyRoutes(app, deps) {
|
|
|
35
36
|
missing.push(`completed_orders ${completedOrders}/${minOrders}`);
|
|
36
37
|
if (requirePasskey === 1 && passkeyCount === 0)
|
|
37
38
|
missing.push('passkey_not_registered');
|
|
38
|
-
const pending =
|
|
39
|
-
const expired =
|
|
39
|
+
const pending = (await dbOne("SELECT COUNT(*) AS n, COALESCE(SUM(amount),0) AS total FROM pending_commission_escrow WHERE recipient_user_id = ? AND status = 'pending'", [userId]));
|
|
40
|
+
const expired = (await dbOne("SELECT COUNT(*) AS n, COALESCE(SUM(amount),0) AS total FROM pending_commission_escrow WHERE recipient_user_id = ? AND status = 'expired'", [userId]));
|
|
40
41
|
res.json({
|
|
41
42
|
state,
|
|
42
43
|
opted_in: optIn === 1,
|
|
@@ -60,7 +61,7 @@ export function registerRewardsApplyRoutes(app, deps) {
|
|
|
60
61
|
});
|
|
61
62
|
});
|
|
62
63
|
// POST /api/rewards/apply — activate (or reconfirm) opt-in + drain escrow
|
|
63
|
-
app.post('/api/rewards/apply', (req, res) => {
|
|
64
|
+
app.post('/api/rewards/apply', async (req, res) => {
|
|
64
65
|
const user = auth(req, res);
|
|
65
66
|
if (!user)
|
|
66
67
|
return;
|
|
@@ -71,11 +72,11 @@ export function registerRewardsApplyRoutes(app, deps) {
|
|
|
71
72
|
const page_loaded_at = Number(body.page_loaded_at || 0);
|
|
72
73
|
const webauthn_token = body.webauthn_token ? String(body.webauthn_token) : undefined;
|
|
73
74
|
// 1. Verify currently opted-out
|
|
74
|
-
const optIn =
|
|
75
|
+
const optIn = (await dbOne("SELECT rewards_opted_in FROM users WHERE id = ?", [userId]))?.rewards_opted_in ?? 0;
|
|
75
76
|
if (optIn === 1)
|
|
76
77
|
return void errorRes(res, 409, 'ALREADY_OPTED_IN', '已 opted-in,无需重复申请');
|
|
77
78
|
// 2. Verify consent version matches current major
|
|
78
|
-
const currentMajor =
|
|
79
|
+
const currentMajor = await dbOne("SELECT version, hash FROM rewards_consent_texts WHERE change_class='major' ORDER BY effective_at DESC LIMIT 1", []);
|
|
79
80
|
if (!currentMajor)
|
|
80
81
|
return void errorRes(res, 500, 'NO_CONSENT_TEXT', 'rewards_consent_texts 未 seed,无法申请');
|
|
81
82
|
if (consent_version !== currentMajor.version) {
|
|
@@ -102,11 +103,11 @@ export function registerRewardsApplyRoutes(app, deps) {
|
|
|
102
103
|
}
|
|
103
104
|
// 5. Pre-conditions (re-check inside server)
|
|
104
105
|
const minOrders = Number(getProtocolParam('rewards_opt_in.min_completed_orders', 1));
|
|
105
|
-
const completedOrders =
|
|
106
|
+
const completedOrders = (await dbOne("SELECT COUNT(*) AS n FROM orders WHERE buyer_id = ? AND status = 'completed'", [userId])).n;
|
|
106
107
|
if (completedOrders < minOrders)
|
|
107
108
|
return void errorRes(res, 403, 'INSUFFICIENT_ORDERS', `需 ${minOrders} 笔已完成订单,目前 ${completedOrders}`);
|
|
108
109
|
const requirePasskey = Number(getProtocolParam('rewards_opt_in.require_passkey', 1));
|
|
109
|
-
const passkeyCount =
|
|
110
|
+
const passkeyCount = (await dbOne("SELECT COUNT(*) AS n FROM webauthn_credentials WHERE user_id = ?", [userId])).n;
|
|
110
111
|
if (requirePasskey === 1 && passkeyCount === 0)
|
|
111
112
|
return void errorRes(res, 403, 'PASSKEY_REQUIRED', '需先注册 Passkey');
|
|
112
113
|
// 6. Atomic: consume Passkey gate + insert audit + flip flag + drain escrow → wallet
|
|
@@ -165,14 +166,14 @@ export function registerRewardsApplyRoutes(app, deps) {
|
|
|
165
166
|
res.json({ ok: true, state: 'opted_in', drained_from_escrow: drained });
|
|
166
167
|
});
|
|
167
168
|
// POST /api/rewards/deactivate — flip off; subsequent commissions → charity
|
|
168
|
-
app.post('/api/rewards/deactivate', (req, res) => {
|
|
169
|
+
app.post('/api/rewards/deactivate', async (req, res) => {
|
|
169
170
|
const user = auth(req, res);
|
|
170
171
|
if (!user)
|
|
171
172
|
return;
|
|
172
173
|
const userId = user.id;
|
|
173
174
|
const body = req.body || {};
|
|
174
175
|
const webauthn_token = body.webauthn_token ? String(body.webauthn_token) : undefined;
|
|
175
|
-
const optIn =
|
|
176
|
+
const optIn = (await dbOne("SELECT rewards_opted_in FROM users WHERE id = ?", [userId]))?.rewards_opted_in ?? 0;
|
|
176
177
|
if (optIn === 0)
|
|
177
178
|
return void errorRes(res, 409, 'ALREADY_OPTED_OUT', '本来就未 opted-in,无需关闭');
|
|
178
179
|
const requirePasskey = Number(getProtocolParam('rewards_opt_in.require_passkey', 1));
|