@seasonkoh/webaz 0.1.8 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +48 -0
- package/README.md +156 -20
- package/dist/layer0-foundation/L0-1-database/schema.js +5 -4
- package/dist/layer0-foundation/L0-2-state-machine/engine.js +228 -7
- package/dist/layer0-foundation/L0-2-state-machine/order-chain.js +156 -0
- package/dist/layer0-foundation/L0-2-state-machine/transitions.js +53 -12
- package/dist/layer0-foundation/L0-5-manifest/manifest.js +14 -1
- package/dist/layer1-agent/L1-1-mcp-server/auth.js +1 -1
- package/dist/layer1-agent/L1-1-mcp-server/server.js +3543 -852
- package/dist/layer1-agent/L1-2-external-anchor/anchor-engine.js +324 -0
- package/dist/layer1-agent/L1-2-identity/agent-passport.js +100 -0
- package/dist/layer2-business/L2-6-notifications/notification-engine.js +72 -5
- package/dist/layer2-business/L2-7-snf/snf-engine.js +287 -0
- package/dist/layer2-business/L2-anchor-registry/anchor-registry.js +396 -0
- package/dist/layer2-business/L2-notes/note-photo-storage.js +133 -0
- package/dist/layer3-trust/L3-1-dispute-engine/dispute-engine.js +6 -6
- package/dist/layer3-trust/L3-1-dispute-engine/evidence-storage.js +246 -0
- package/dist/layer4-economics/L4-3-reputation/reputation-engine.js +95 -1
- package/dist/layer4-economics/L4-4-skill-market/skill-engine.js +31 -2
- package/dist/layer4-economics/L4-4-skill-market/skill-listing-engine.js +358 -0
- package/dist/pwa/public/app.js +31230 -2345
- package/dist/pwa/public/i18n.js +5282 -111
- package/dist/pwa/public/icon.svg +11 -0
- package/dist/pwa/public/index.html +4 -1
- package/dist/pwa/public/manifest.json +39 -4
- package/dist/pwa/public/openapi.json +5946 -0
- package/dist/pwa/public/style.css +278 -5
- package/dist/pwa/public/sw.js +41 -2
- package/dist/pwa/public/vendor/jsQR.js +10102 -0
- package/dist/pwa/public/webaz-logo.png +0 -0
- package/dist/pwa/routes/account-deletion.js +53 -0
- package/dist/pwa/routes/addresses.js +105 -0
- package/dist/pwa/routes/admin-admins.js +151 -0
- package/dist/pwa/routes/admin-analytics.js +253 -0
- package/dist/pwa/routes/admin-atomic.js +21 -0
- package/dist/pwa/routes/admin-catalog.js +64 -0
- package/dist/pwa/routes/admin-editor-picks.js +45 -0
- package/dist/pwa/routes/admin-events.js +60 -0
- package/dist/pwa/routes/admin-health.js +66 -0
- package/dist/pwa/routes/admin-moderation.js +120 -0
- package/dist/pwa/routes/admin-ops.js +179 -0
- package/dist/pwa/routes/admin-protocol-params.js +79 -0
- package/dist/pwa/routes/admin-reports.js +154 -0
- package/dist/pwa/routes/admin-tokenomics.js +113 -0
- package/dist/pwa/routes/admin-users-lifecycle.js +237 -0
- package/dist/pwa/routes/admin-users-query.js +390 -0
- package/dist/pwa/routes/admin-verifier-flow.js +126 -0
- package/dist/pwa/routes/admin-verifier-whitelist.js +111 -0
- package/dist/pwa/routes/admin-wallet-ops.js +66 -0
- package/dist/pwa/routes/agent-buy.js +215 -0
- package/dist/pwa/routes/agent-governance.js +341 -0
- package/dist/pwa/routes/agent-reputation.js +34 -0
- package/dist/pwa/routes/ai.js +101 -0
- package/dist/pwa/routes/analytics.js +272 -0
- package/dist/pwa/routes/anchors.js +169 -0
- package/dist/pwa/routes/announcements.js +110 -0
- package/dist/pwa/routes/arbitrator.js +117 -0
- package/dist/pwa/routes/auction.js +436 -0
- package/dist/pwa/routes/auth-login.js +40 -0
- package/dist/pwa/routes/auth-read.js +66 -0
- package/dist/pwa/routes/auth-register.js +138 -0
- package/dist/pwa/routes/auth-sessions.js +62 -0
- package/dist/pwa/routes/blocklist.js +60 -0
- package/dist/pwa/routes/buyer-feeds.js +224 -0
- package/dist/pwa/routes/cart.js +155 -0
- package/dist/pwa/routes/charity.js +816 -0
- package/dist/pwa/routes/chat.js +318 -0
- package/dist/pwa/routes/checkin-tasks.js +122 -0
- package/dist/pwa/routes/checkout-helpers.js +85 -0
- package/dist/pwa/routes/claim-initiators.js +88 -0
- package/dist/pwa/routes/claim-verify.js +615 -0
- package/dist/pwa/routes/claim-voting.js +114 -0
- package/dist/pwa/routes/claim-withdrawals.js +20 -0
- package/dist/pwa/routes/coupons.js +165 -0
- package/dist/pwa/routes/dashboards.js +99 -0
- package/dist/pwa/routes/dispute-cases.js +267 -0
- package/dist/pwa/routes/disputes-read.js +358 -0
- package/dist/pwa/routes/disputes-write.js +475 -0
- package/dist/pwa/routes/evidence.js +86 -0
- package/dist/pwa/routes/external-anchors.js +107 -0
- package/dist/pwa/routes/feedback.js +270 -0
- package/dist/pwa/routes/flash-sales.js +130 -0
- package/dist/pwa/routes/follows.js +103 -0
- package/dist/pwa/routes/group-buys.js +208 -0
- package/dist/pwa/routes/growth.js +199 -0
- package/dist/pwa/routes/import-product.js +153 -0
- package/dist/pwa/routes/kyc.js +40 -0
- package/dist/pwa/routes/leaderboard.js +149 -0
- package/dist/pwa/routes/listings.js +281 -0
- package/dist/pwa/routes/logistics.js +35 -0
- package/dist/pwa/routes/manifests.js +126 -0
- package/dist/pwa/routes/me-data.js +101 -0
- package/dist/pwa/routes/notifications.js +48 -0
- package/dist/pwa/routes/offers.js +96 -0
- package/dist/pwa/routes/orders-action.js +285 -0
- package/dist/pwa/routes/orders-create.js +339 -0
- package/dist/pwa/routes/orders-read.js +180 -0
- package/dist/pwa/routes/p2p-products.js +178 -0
- package/dist/pwa/routes/payments-governance.js +311 -0
- package/dist/pwa/routes/peers.js +34 -0
- package/dist/pwa/routes/pin-receipts.js +39 -0
- package/dist/pwa/routes/products-aliases.js +119 -0
- package/dist/pwa/routes/products-claims.js +60 -0
- package/dist/pwa/routes/products-create.js +206 -0
- package/dist/pwa/routes/products-crud.js +73 -0
- package/dist/pwa/routes/products-links.js +129 -0
- package/dist/pwa/routes/products-list.js +424 -0
- package/dist/pwa/routes/products-meta.js +155 -0
- package/dist/pwa/routes/products-update.js +125 -0
- package/dist/pwa/routes/profile-credentials.js +105 -0
- package/dist/pwa/routes/profile-identity.js +174 -0
- package/dist/pwa/routes/profile-location.js +35 -0
- package/dist/pwa/routes/profile-placement.js +70 -0
- package/dist/pwa/routes/profile-prefs.js +93 -0
- package/dist/pwa/routes/promoter.js +208 -0
- package/dist/pwa/routes/public-utils.js +170 -0
- package/dist/pwa/routes/push.js +54 -0
- package/dist/pwa/routes/ratings.js +220 -0
- package/dist/pwa/routes/recover-key.js +100 -0
- package/dist/pwa/routes/referral.js +58 -0
- package/dist/pwa/routes/reputation.js +34 -0
- package/dist/pwa/routes/returns.js +493 -0
- package/dist/pwa/routes/reviews.js +81 -0
- package/dist/pwa/routes/rfqs.js +443 -0
- package/dist/pwa/routes/search.js +172 -0
- package/dist/pwa/routes/secondhand.js +278 -0
- package/dist/pwa/routes/seller-quota.js +225 -0
- package/dist/pwa/routes/share-redirects.js +164 -0
- package/dist/pwa/routes/shareables-interactions.js +212 -0
- package/dist/pwa/routes/shareables.js +470 -0
- package/dist/pwa/routes/shops.js +98 -0
- package/dist/pwa/routes/signaling.js +43 -0
- package/dist/pwa/routes/skill-market.js +173 -0
- package/dist/pwa/routes/skills.js +174 -0
- package/dist/pwa/routes/snf.js +126 -0
- package/dist/pwa/routes/tags.js +47 -0
- package/dist/pwa/routes/trial.js +333 -0
- package/dist/pwa/routes/trusted-kpi.js +87 -0
- package/dist/pwa/routes/url-claim.js +113 -0
- package/dist/pwa/routes/users-public.js +317 -0
- package/dist/pwa/routes/variants.js +156 -0
- package/dist/pwa/routes/verifier-user.js +107 -0
- package/dist/pwa/routes/verify-tasks.js +120 -0
- package/dist/pwa/routes/waitlist.js +65 -0
- package/dist/pwa/routes/wallet-read.js +218 -0
- package/dist/pwa/routes/wallet-write.js +273 -0
- package/dist/pwa/routes/webauthn.js +188 -0
- package/dist/pwa/routes/webhooks.js +162 -0
- package/dist/pwa/routes/welcome.js +226 -0
- package/dist/pwa/routes/wishlist-qa.js +135 -0
- package/dist/pwa/security/ssrf.js +110 -0
- package/dist/pwa/server.js +9247 -2097
- package/package.json +8 -3
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
export function registerUsersPublicRoutes(app, deps) {
|
|
2
|
+
const { db, auth, noteAuthenticityBadges } = deps;
|
|
3
|
+
// ref → user id(usr_xxx / permanent_code / @handle 三态)
|
|
4
|
+
const resolveUserId = (ref0) => {
|
|
5
|
+
const ref = String(ref0 || '').trim();
|
|
6
|
+
if (/^usr_[A-Za-z0-9_]+$/.test(ref))
|
|
7
|
+
return ref;
|
|
8
|
+
if (/^[A-Z0-9]{6,7}$/i.test(ref) && !ref.startsWith('@')) {
|
|
9
|
+
const r = db.prepare("SELECT id FROM users WHERE permanent_code = ?").get(ref.toUpperCase());
|
|
10
|
+
if (r)
|
|
11
|
+
return r.id;
|
|
12
|
+
}
|
|
13
|
+
const h = ref.replace(/^@/, '').toLowerCase();
|
|
14
|
+
const r = db.prepare("SELECT id FROM users WHERE handle = ?").get(h);
|
|
15
|
+
return r ? r.id : null;
|
|
16
|
+
};
|
|
17
|
+
// 公开 reputation — 仅 level
|
|
18
|
+
app.get('/api/users/:id/reputation', (req, res) => {
|
|
19
|
+
let userId = req.params.id;
|
|
20
|
+
if (userId === 'me') {
|
|
21
|
+
const user = auth(req, res);
|
|
22
|
+
if (!user)
|
|
23
|
+
return;
|
|
24
|
+
userId = user.id;
|
|
25
|
+
}
|
|
26
|
+
const row = db.prepare(`
|
|
27
|
+
SELECT level, MAX(trust_score) as max_score
|
|
28
|
+
FROM agent_reputation WHERE user_id = ?
|
|
29
|
+
GROUP BY user_id
|
|
30
|
+
`).get(userId);
|
|
31
|
+
if (!row)
|
|
32
|
+
return void res.json({ user_id: userId, level: 'new' });
|
|
33
|
+
res.json({ user_id: userId, level: row.level });
|
|
34
|
+
});
|
|
35
|
+
// PV 简报:组织图点击节点用
|
|
36
|
+
app.get('/api/users/:id/pv-summary', (req, res) => {
|
|
37
|
+
const me = auth(req, res);
|
|
38
|
+
if (!me)
|
|
39
|
+
return;
|
|
40
|
+
const ref = String(req.params.id || '').trim();
|
|
41
|
+
let targetId = null;
|
|
42
|
+
if (/^usr_[A-Za-z0-9_]+$/.test(ref))
|
|
43
|
+
targetId = ref;
|
|
44
|
+
else if (/^[A-Z0-9]{6,7}$/i.test(ref) && !ref.startsWith('@')) {
|
|
45
|
+
const r = db.prepare("SELECT id FROM users WHERE permanent_code = ?").get(ref.toUpperCase());
|
|
46
|
+
if (r)
|
|
47
|
+
targetId = r.id;
|
|
48
|
+
}
|
|
49
|
+
if (!targetId)
|
|
50
|
+
return void res.json({ error: 'user not found' });
|
|
51
|
+
const u = db.prepare(`
|
|
52
|
+
SELECT id, name, permanent_code, handle, total_left_pv, total_right_pv,
|
|
53
|
+
placement_id, placement_side, placement_depth, left_child_id, right_child_id, created_at
|
|
54
|
+
FROM users WHERE id = ?
|
|
55
|
+
`).get(targetId);
|
|
56
|
+
if (!u)
|
|
57
|
+
return void res.json({ error: 'user not found' });
|
|
58
|
+
const placementName = u.placement_id ? db.prepare("SELECT name FROM users WHERE id = ?").get(u.placement_id)?.name : null;
|
|
59
|
+
const leftChildName = u.left_child_id ? db.prepare("SELECT name FROM users WHERE id = ?").get(u.left_child_id)?.name : null;
|
|
60
|
+
const rightChildName = u.right_child_id ? db.prepare("SELECT name FROM users WHERE id = ?").get(u.right_child_id)?.name : null;
|
|
61
|
+
const score = db.prepare(`
|
|
62
|
+
SELECT
|
|
63
|
+
COALESCE(SUM(CASE WHEN settled_at IS NULL THEN score ELSE 0 END),0) AS pending_score,
|
|
64
|
+
COALESCE(SUM(CASE WHEN settled_at IS NOT NULL THEN waz_amount ELSE 0 END),0) AS settled_waz,
|
|
65
|
+
COUNT(*) AS total_hits
|
|
66
|
+
FROM binary_score_records WHERE user_id = ?
|
|
67
|
+
`).get(targetId);
|
|
68
|
+
const leftPv = Number(u.total_left_pv ?? 0);
|
|
69
|
+
const rightPv = Number(u.total_right_pv ?? 0);
|
|
70
|
+
const weak = Math.min(leftPv, rightPv);
|
|
71
|
+
res.json({
|
|
72
|
+
id: u.id,
|
|
73
|
+
name: u.name,
|
|
74
|
+
permanent_code: u.permanent_code || null,
|
|
75
|
+
handle: u.handle || null,
|
|
76
|
+
placement: u.placement_id ? { id: u.placement_id, name: placementName, side: u.placement_side, depth: u.placement_depth } : null,
|
|
77
|
+
left_child: u.left_child_id ? { id: u.left_child_id, name: leftChildName } : null,
|
|
78
|
+
right_child: u.right_child_id ? { id: u.right_child_id, name: rightChildName } : null,
|
|
79
|
+
total_left_pv: leftPv,
|
|
80
|
+
total_right_pv: rightPv,
|
|
81
|
+
weak_leg_pv: weak,
|
|
82
|
+
pending_score: Number(score.pending_score),
|
|
83
|
+
settled_waz: Number(score.settled_waz),
|
|
84
|
+
total_hits: Number(score.total_hits),
|
|
85
|
+
joined_at: u.created_at,
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
// 用户公开 shareables
|
|
89
|
+
app.get('/api/users/:id/shareables', (req, res) => {
|
|
90
|
+
const ref = String(req.params.id || '').trim();
|
|
91
|
+
let ownerId = null;
|
|
92
|
+
if (/^usr_[A-Za-z0-9_]+$/.test(ref))
|
|
93
|
+
ownerId = ref;
|
|
94
|
+
else if (/^[A-Z0-9]{6,7}$/i.test(ref) && !ref.startsWith('@')) {
|
|
95
|
+
const r = db.prepare("SELECT id FROM users WHERE permanent_code = ?").get(ref.toUpperCase());
|
|
96
|
+
if (r)
|
|
97
|
+
ownerId = r.id;
|
|
98
|
+
}
|
|
99
|
+
if (!ownerId) {
|
|
100
|
+
const h = ref.replace(/^@/, '').toLowerCase();
|
|
101
|
+
const r = db.prepare("SELECT id FROM users WHERE handle = ?").get(h);
|
|
102
|
+
if (r)
|
|
103
|
+
ownerId = r.id;
|
|
104
|
+
}
|
|
105
|
+
if (!ownerId)
|
|
106
|
+
return void res.status(404).json({ error: 'user not found' });
|
|
107
|
+
const rows = db.prepare(`
|
|
108
|
+
SELECT s.id, s.owner_id, s.owner_code, s.type, s.external_url, s.external_platform, s.external_video_id,
|
|
109
|
+
s.thumbnail_url, s.title, s.description, s.related_product_id, s.related_anchor,
|
|
110
|
+
s.related_order_id, s.photo_hashes,
|
|
111
|
+
s.click_count, s.created_at,
|
|
112
|
+
p.title AS product_title
|
|
113
|
+
FROM shareables s
|
|
114
|
+
LEFT JOIN products p ON p.id = s.related_product_id
|
|
115
|
+
WHERE s.owner_id = ? AND s.status = 'active'
|
|
116
|
+
ORDER BY s.created_at DESC LIMIT 100
|
|
117
|
+
`).all(ownerId);
|
|
118
|
+
for (const r of rows) {
|
|
119
|
+
r.badges = noteAuthenticityBadges(r);
|
|
120
|
+
}
|
|
121
|
+
res.json({ shareables: rows });
|
|
122
|
+
});
|
|
123
|
+
// 用户在售二手(公开:available + reserved)
|
|
124
|
+
app.get('/api/users/:id/secondhand', (req, res) => {
|
|
125
|
+
const ownerId = resolveUserId(req.params.id);
|
|
126
|
+
if (!ownerId)
|
|
127
|
+
return void res.status(404).json({ error: 'user not found' });
|
|
128
|
+
const items = db.prepare(`
|
|
129
|
+
SELECT id, title, price, condition_grade, images, status, category, created_at
|
|
130
|
+
FROM secondhand_items
|
|
131
|
+
WHERE seller_id = ? AND status IN ('available', 'reserved')
|
|
132
|
+
ORDER BY created_at DESC LIMIT 50
|
|
133
|
+
`).all(ownerId);
|
|
134
|
+
res.json({ items });
|
|
135
|
+
});
|
|
136
|
+
// 用户进行中拍卖(公开:open)
|
|
137
|
+
app.get('/api/users/:id/auctions', (req, res) => {
|
|
138
|
+
const ownerId = resolveUserId(req.params.id);
|
|
139
|
+
if (!ownerId)
|
|
140
|
+
return void res.status(404).json({ error: 'user not found' });
|
|
141
|
+
const items = db.prepare(`
|
|
142
|
+
SELECT id, title, current_price, starting_price, status, deadline_at, bid_count, category, created_at
|
|
143
|
+
FROM auctions
|
|
144
|
+
WHERE seller_id = ? AND status = 'open' AND deadline_at > datetime('now')
|
|
145
|
+
ORDER BY deadline_at ASC LIMIT 50
|
|
146
|
+
`).all(ownerId);
|
|
147
|
+
res.json({ items });
|
|
148
|
+
});
|
|
149
|
+
// 用户写的测评(公开:作为买家给出的评价)
|
|
150
|
+
app.get('/api/users/:id/reviews', (req, res) => {
|
|
151
|
+
const ownerId = resolveUserId(req.params.id);
|
|
152
|
+
if (!ownerId)
|
|
153
|
+
return void res.status(404).json({ error: 'user not found' });
|
|
154
|
+
const items = db.prepare(`
|
|
155
|
+
SELECT r.order_id, r.product_id, r.stars, r.comment, r.reply, r.created_at,
|
|
156
|
+
p.title AS product_title, p.images AS product_images
|
|
157
|
+
FROM order_ratings r
|
|
158
|
+
LEFT JOIN products p ON p.id = r.product_id
|
|
159
|
+
WHERE r.buyer_id = ? AND (r.hidden_until IS NULL OR r.hidden_until <= datetime('now'))
|
|
160
|
+
ORDER BY r.created_at DESC LIMIT 50
|
|
161
|
+
`).all(ownerId);
|
|
162
|
+
res.json({ items });
|
|
163
|
+
});
|
|
164
|
+
// 用户在售商品(公开:卖家 active 商品)
|
|
165
|
+
app.get('/api/users/:id/products', (req, res) => {
|
|
166
|
+
const ownerId = resolveUserId(req.params.id);
|
|
167
|
+
if (!ownerId)
|
|
168
|
+
return void res.status(404).json({ error: 'user not found' });
|
|
169
|
+
const items = db.prepare(`
|
|
170
|
+
SELECT id, title, price, images, category, completion_count, total_likes, created_at
|
|
171
|
+
FROM products
|
|
172
|
+
WHERE seller_id = ? AND status = 'active' AND stock > 0
|
|
173
|
+
ORDER BY completion_count DESC, created_at DESC LIMIT 50
|
|
174
|
+
`).all(ownerId);
|
|
175
|
+
res.json({ items });
|
|
176
|
+
});
|
|
177
|
+
// 用户赞过的 shareables(仅 owner 可见)
|
|
178
|
+
app.get('/api/users/:id/liked-shareables', (req, res) => {
|
|
179
|
+
const me = auth(req, res);
|
|
180
|
+
if (!me)
|
|
181
|
+
return;
|
|
182
|
+
const ref = String(req.params.id || '').trim();
|
|
183
|
+
let ownerId = null;
|
|
184
|
+
if (ref === 'me' || ref === me.id)
|
|
185
|
+
ownerId = me.id;
|
|
186
|
+
if (!ownerId)
|
|
187
|
+
return void res.status(403).json({ error: 'only owner can view liked list' });
|
|
188
|
+
const rows = db.prepare(`
|
|
189
|
+
SELECT s.id, s.owner_id, s.owner_code, s.type, s.external_url, s.external_platform,
|
|
190
|
+
s.thumbnail_url, s.title, s.description, s.photo_hashes, s.related_product_id, s.related_anchor,
|
|
191
|
+
s.click_count, s.like_count, s.created_at,
|
|
192
|
+
p.title AS product_title,
|
|
193
|
+
l.created_at as liked_at
|
|
194
|
+
FROM shareable_likes l
|
|
195
|
+
JOIN shareables s ON s.id = l.shareable_id
|
|
196
|
+
LEFT JOIN products p ON p.id = s.related_product_id
|
|
197
|
+
WHERE l.user_id = ? AND s.status = 'active'
|
|
198
|
+
ORDER BY l.created_at DESC LIMIT 100
|
|
199
|
+
`).all(ownerId);
|
|
200
|
+
for (const r of rows) {
|
|
201
|
+
if (typeof r.photo_hashes === 'string') {
|
|
202
|
+
try {
|
|
203
|
+
r.photo_hashes = JSON.parse(r.photo_hashes);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
r.photo_hashes = [];
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
res.json({ shareables: rows });
|
|
211
|
+
});
|
|
212
|
+
// 公开卡(未登录可调,分享 banner 用)
|
|
213
|
+
app.get('/api/users/:id/public-card', (req, res) => {
|
|
214
|
+
const ref = String(req.params.id || '').trim();
|
|
215
|
+
const cols = "id, name, bio, search_anchor, created_at, permanent_code, handle";
|
|
216
|
+
const filter = " AND id != 'sys_protocol'";
|
|
217
|
+
let row;
|
|
218
|
+
if (/^usr_[A-Za-z0-9_]+$/.test(ref)) {
|
|
219
|
+
row = db.prepare(`SELECT ${cols} FROM users WHERE id = ?${filter}`).get(ref);
|
|
220
|
+
}
|
|
221
|
+
if (!row && /^[A-Z0-9]{6,7}$/.test(ref.toUpperCase()) && !ref.startsWith('@')) {
|
|
222
|
+
row = db.prepare(`SELECT ${cols} FROM users WHERE permanent_code = ?${filter}`).get(ref.toUpperCase());
|
|
223
|
+
}
|
|
224
|
+
if (!row) {
|
|
225
|
+
const h = ref.replace(/^@/, '').toLowerCase();
|
|
226
|
+
row = db.prepare(`SELECT ${cols} FROM users WHERE handle = ?${filter}`).get(h);
|
|
227
|
+
}
|
|
228
|
+
if (!row)
|
|
229
|
+
return void res.status(404).json({ error: 'not_found' });
|
|
230
|
+
const created = String(row.created_at || '');
|
|
231
|
+
const days = created ? Math.floor((Date.now() - new Date(created).getTime()) / 86400_000) : null;
|
|
232
|
+
res.json({
|
|
233
|
+
id: row.id,
|
|
234
|
+
permanent_code: row.permanent_code || null,
|
|
235
|
+
handle: row.handle || null,
|
|
236
|
+
name: row.name,
|
|
237
|
+
bio: row.bio || null,
|
|
238
|
+
search_anchor: row.search_anchor || null,
|
|
239
|
+
joined_days_ago: days,
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
// 公开用户主页 + D2 信誉徽章墙
|
|
243
|
+
app.get('/api/users/:user_id', (req, res) => {
|
|
244
|
+
const me = auth(req, res);
|
|
245
|
+
if (!me)
|
|
246
|
+
return;
|
|
247
|
+
const ref = String(req.params.user_id || '').trim();
|
|
248
|
+
const cols = "id, name, role, region, bio, search_anchor, created_at, reputation, COALESCE(feed_visible, 1) as feed_visible";
|
|
249
|
+
let target;
|
|
250
|
+
if (/^usr_[A-Za-z0-9_]+$/.test(ref)) {
|
|
251
|
+
target = db.prepare(`SELECT ${cols} FROM users WHERE id = ? AND id != 'sys_protocol'`).get(ref);
|
|
252
|
+
}
|
|
253
|
+
else if (/^[A-Z0-9]{6,7}$/.test(ref) && !ref.startsWith('@')) {
|
|
254
|
+
target = db.prepare(`SELECT ${cols} FROM users WHERE permanent_code = ? AND id != 'sys_protocol'`).get(ref.toUpperCase());
|
|
255
|
+
}
|
|
256
|
+
if (!target) {
|
|
257
|
+
const h = ref.replace(/^@/, '').toLowerCase();
|
|
258
|
+
target = db.prepare(`SELECT ${cols} FROM users WHERE handle = ? AND id != 'sys_protocol'`).get(h);
|
|
259
|
+
}
|
|
260
|
+
if (!target)
|
|
261
|
+
return void res.status(404).json({ error: '用户不存在' });
|
|
262
|
+
const followers = db.prepare("SELECT COUNT(*) as n FROM follows WHERE followee_id = ?").get(target.id).n;
|
|
263
|
+
const following = db.prepare("SELECT COUNT(*) as n FROM follows WHERE follower_id = ?").get(target.id).n;
|
|
264
|
+
const isFollowing = !!db.prepare("SELECT 1 FROM follows WHERE follower_id = ? AND followee_id = ?").get(me.id, target.id);
|
|
265
|
+
const purchaseCount = db.prepare("SELECT COUNT(*) as n FROM orders WHERE buyer_id = ? AND status = 'completed'").get(target.id).n;
|
|
266
|
+
const salesCount = db.prepare("SELECT COUNT(*) as n FROM orders WHERE seller_id = ? AND status = 'completed'").get(target.id).n;
|
|
267
|
+
const likesReceived = db.prepare("SELECT COALESCE(SUM(like_count), 0) as n FROM shareables WHERE owner_id = ? AND status = 'active'").get(target.id).n;
|
|
268
|
+
// 主人视角加私有统计
|
|
269
|
+
const isOwner = me.id === target.id;
|
|
270
|
+
let privateStats = null;
|
|
271
|
+
if (isOwner) {
|
|
272
|
+
const w = db.prepare('SELECT balance, earned FROM wallets WHERE user_id = ?').get(me.id);
|
|
273
|
+
const pv = db.prepare("SELECT total_left_pv, total_right_pv FROM users WHERE id = ?").get(me.id);
|
|
274
|
+
const score = db.prepare("SELECT COALESCE(SUM(score),0) as s FROM binary_score_records WHERE user_id = ? AND settled_at IS NULL").get(me.id).s;
|
|
275
|
+
privateStats = {
|
|
276
|
+
wallet_balance: Number(w?.balance ?? 0),
|
|
277
|
+
wallet_earned: Number(w?.earned ?? 0),
|
|
278
|
+
total_left_pv: Number(pv?.total_left_pv ?? 0),
|
|
279
|
+
total_right_pv: Number(pv?.total_right_pv ?? 0),
|
|
280
|
+
pending_score: Number(score),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
// D2 信誉徽章墙
|
|
284
|
+
const rep = Number(target.reputation ?? 0);
|
|
285
|
+
const commercialLevel = rep >= 400 ? { tier: 5, label: '传奇', emoji: '🌟', color: '#dc2626' } :
|
|
286
|
+
rep >= 200 ? { tier: 4, label: '专家', emoji: '👑', color: '#9333ea' } :
|
|
287
|
+
rep >= 100 ? { tier: 3, label: '资深', emoji: '⭐', color: '#4f46e5' } :
|
|
288
|
+
rep >= 30 ? { tier: 2, label: '可靠', emoji: '✓', color: '#16a34a' } :
|
|
289
|
+
{ tier: 1, label: '新手', emoji: '🌱', color: '#9ca3af' };
|
|
290
|
+
// Agent trust band(P1.2 隐私:trust_score 仅 owner 看,他人仅 level)
|
|
291
|
+
const agentRow = db.prepare(`SELECT level, MAX(trust_score) as score FROM agent_reputation WHERE user_id = ? GROUP BY user_id`).get(target.id);
|
|
292
|
+
const agentBand = agentRow ? (isOwner
|
|
293
|
+
? { level: agentRow.level, score: Math.round(agentRow.score || 0) }
|
|
294
|
+
: { level: agentRow.level }) : null;
|
|
295
|
+
const charity = db.prepare(`SELECT prestige_score, badge_tier, wishes_fulfilled, wishes_made FROM charity_reputation WHERE user_id = ?`).get(target.id);
|
|
296
|
+
let verifier;
|
|
297
|
+
try {
|
|
298
|
+
verifier = db.prepare(`SELECT tier FROM verifier_whitelist WHERE user_id = ?`).get(target.id);
|
|
299
|
+
}
|
|
300
|
+
catch { }
|
|
301
|
+
res.json({
|
|
302
|
+
id: target.id, name: target.name, role: target.role, region: target.region,
|
|
303
|
+
bio: target.bio, search_anchor: target.search_anchor, created_at: target.created_at,
|
|
304
|
+
feed_visible: Number(target.feed_visible),
|
|
305
|
+
followers, following, is_following: isFollowing,
|
|
306
|
+
purchase_count: purchaseCount, sales_count: salesCount, likes_received: likesReceived,
|
|
307
|
+
is_owner: isOwner,
|
|
308
|
+
private_stats: privateStats,
|
|
309
|
+
badges: {
|
|
310
|
+
commercial: { ...commercialLevel, score: rep },
|
|
311
|
+
agent: agentBand,
|
|
312
|
+
charity: charity ? { prestige: Math.round(charity.prestige_score || 0), badge: charity.badge_tier, fulfilled: charity.wishes_fulfilled || 0, made: charity.wishes_made || 0 } : null,
|
|
313
|
+
verifier: verifier ? { tier: verifier.tier } : null,
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/** options → canonical key(sort + join)— 防同规格组合两次入库 */
|
|
2
|
+
function canonicalOptionsKey(options) {
|
|
3
|
+
return Object.keys(options).sort().map(k => `${k}=${String(options[k])}`).join('|');
|
|
4
|
+
}
|
|
5
|
+
export function registerVariantsRoutes(app, deps) {
|
|
6
|
+
const { db, generateId, auth } = deps;
|
|
7
|
+
// 公开列出(含 buyer 下单页查可选项)
|
|
8
|
+
app.get('/api/products/:product_id/variants', (req, res) => {
|
|
9
|
+
const rows = db.prepare(`
|
|
10
|
+
SELECT id, sku, options_json, price_override, stock, images_json, is_active, created_at
|
|
11
|
+
FROM product_variants
|
|
12
|
+
WHERE product_id = ? AND is_active = 1
|
|
13
|
+
ORDER BY created_at ASC LIMIT 100
|
|
14
|
+
`).all(req.params.product_id);
|
|
15
|
+
const items = rows.map(r => {
|
|
16
|
+
let options = {};
|
|
17
|
+
let images = [];
|
|
18
|
+
try {
|
|
19
|
+
options = JSON.parse(r.options_json);
|
|
20
|
+
}
|
|
21
|
+
catch { }
|
|
22
|
+
try {
|
|
23
|
+
if (r.images_json)
|
|
24
|
+
images = JSON.parse(r.images_json);
|
|
25
|
+
}
|
|
26
|
+
catch { }
|
|
27
|
+
return { ...r, options, images, options_json: undefined, images_json: undefined };
|
|
28
|
+
});
|
|
29
|
+
res.json({ items });
|
|
30
|
+
});
|
|
31
|
+
app.post('/api/products/:product_id/variants', (req, res) => {
|
|
32
|
+
const user = auth(req, res);
|
|
33
|
+
if (!user)
|
|
34
|
+
return;
|
|
35
|
+
const p = db.prepare('SELECT id, seller_id FROM products WHERE id = ?').get(req.params.product_id);
|
|
36
|
+
if (!p)
|
|
37
|
+
return void res.status(404).json({ error: '商品不存在' });
|
|
38
|
+
if (p.seller_id !== user.id)
|
|
39
|
+
return void res.status(403).json({ error: '仅自己商品可加 variant' });
|
|
40
|
+
const { sku, options, price_override, stock, images } = req.body || {};
|
|
41
|
+
if (!options || typeof options !== 'object' || Object.keys(options).length === 0) {
|
|
42
|
+
return void res.status(400).json({ error: 'options 必填 (e.g. {"颜色":"红","尺寸":"L"})' });
|
|
43
|
+
}
|
|
44
|
+
const stockN = Number.isFinite(Number(stock)) ? Math.max(0, Number(stock)) : 0;
|
|
45
|
+
const priceN = price_override != null ? Number(price_override) : null;
|
|
46
|
+
if (priceN != null && (priceN <= 0 || priceN > 1_000_000))
|
|
47
|
+
return void res.status(400).json({ error: 'price_override 无效' });
|
|
48
|
+
const optKey = canonicalOptionsKey(options);
|
|
49
|
+
const dup = db.prepare(`SELECT id FROM product_variants WHERE product_id = ? AND options_key = ? AND is_active = 1`)
|
|
50
|
+
.get(req.params.product_id, optKey);
|
|
51
|
+
if (dup)
|
|
52
|
+
return void res.status(409).json({ error: '该规格组合已存在', existing_id: dup.id });
|
|
53
|
+
const id = generateId('pv');
|
|
54
|
+
db.transaction(() => {
|
|
55
|
+
db.prepare(`INSERT INTO product_variants (id, product_id, sku, options_json, options_key, price_override, stock, images_json) VALUES (?,?,?,?,?,?,?,?)`)
|
|
56
|
+
.run(id, req.params.product_id, sku || null, JSON.stringify(options), optKey, priceN, stockN, images ? JSON.stringify(images) : null);
|
|
57
|
+
// P1-1: 首次添加 variant 时,把 product.stock 重置为该 variant 的 stock;
|
|
58
|
+
// 后续添加直接累加 — 让 product.stock 等于 sum(variant.stock)
|
|
59
|
+
const isFirst = db.prepare(`SELECT COUNT(*) as n FROM product_variants WHERE product_id = ?`).get(req.params.product_id).n === 1;
|
|
60
|
+
if (isFirst) {
|
|
61
|
+
db.prepare(`UPDATE products SET has_variants = 1, stock = ?, updated_at = datetime('now') WHERE id = ?`).run(stockN, req.params.product_id);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
db.prepare(`UPDATE products SET stock = stock + ?, updated_at = datetime('now') WHERE id = ?`).run(stockN, req.params.product_id);
|
|
65
|
+
}
|
|
66
|
+
})();
|
|
67
|
+
res.json({ success: true, id });
|
|
68
|
+
});
|
|
69
|
+
app.patch('/api/products/:product_id/variants/:variant_id', (req, res) => {
|
|
70
|
+
const user = auth(req, res);
|
|
71
|
+
if (!user)
|
|
72
|
+
return;
|
|
73
|
+
const v = db.prepare(`SELECT v.*, p.seller_id FROM product_variants v JOIN products p ON p.id = v.product_id WHERE v.id = ? AND v.product_id = ?`).get(req.params.variant_id, req.params.product_id);
|
|
74
|
+
if (!v)
|
|
75
|
+
return void res.status(404).json({ error: 'variant 不存在' });
|
|
76
|
+
if (v.seller_id !== user.id)
|
|
77
|
+
return void res.status(403).json({ error: '无权限' });
|
|
78
|
+
const { sku, options, price_override, stock, images, is_active } = req.body || {};
|
|
79
|
+
const sets = [];
|
|
80
|
+
const args = [];
|
|
81
|
+
if (sku !== undefined) {
|
|
82
|
+
sets.push('sku = ?');
|
|
83
|
+
args.push(sku || null);
|
|
84
|
+
}
|
|
85
|
+
if (options !== undefined) {
|
|
86
|
+
if (!options || typeof options !== 'object' || Object.keys(options).length === 0) {
|
|
87
|
+
return void res.status(400).json({ error: 'options 不能为空' });
|
|
88
|
+
}
|
|
89
|
+
const newKey = canonicalOptionsKey(options);
|
|
90
|
+
const dup = db.prepare(`SELECT id FROM product_variants WHERE product_id = ? AND options_key = ? AND id != ? AND is_active = 1`)
|
|
91
|
+
.get(req.params.product_id, newKey, req.params.variant_id);
|
|
92
|
+
if (dup)
|
|
93
|
+
return void res.status(409).json({ error: '该规格组合已存在', existing_id: dup.id });
|
|
94
|
+
sets.push('options_json = ?');
|
|
95
|
+
args.push(JSON.stringify(options));
|
|
96
|
+
sets.push('options_key = ?');
|
|
97
|
+
args.push(newKey);
|
|
98
|
+
}
|
|
99
|
+
if (price_override !== undefined) {
|
|
100
|
+
sets.push('price_override = ?');
|
|
101
|
+
args.push(price_override == null ? null : Number(price_override));
|
|
102
|
+
}
|
|
103
|
+
let stockDelta = 0;
|
|
104
|
+
if (stock !== undefined) {
|
|
105
|
+
const newStock = Math.max(0, Number(stock) || 0);
|
|
106
|
+
const oldStock = Number(v.stock || 0);
|
|
107
|
+
stockDelta = newStock - oldStock;
|
|
108
|
+
sets.push('stock = ?');
|
|
109
|
+
args.push(newStock);
|
|
110
|
+
}
|
|
111
|
+
if (images !== undefined) {
|
|
112
|
+
sets.push('images_json = ?');
|
|
113
|
+
args.push(images ? JSON.stringify(images) : null);
|
|
114
|
+
}
|
|
115
|
+
if (is_active !== undefined) {
|
|
116
|
+
sets.push('is_active = ?');
|
|
117
|
+
args.push(is_active ? 1 : 0);
|
|
118
|
+
}
|
|
119
|
+
if (sets.length === 0)
|
|
120
|
+
return void res.status(400).json({ error: '无可更新字段' });
|
|
121
|
+
sets.push(`updated_at = datetime('now')`);
|
|
122
|
+
args.push(req.params.variant_id);
|
|
123
|
+
db.transaction(() => {
|
|
124
|
+
db.prepare(`UPDATE product_variants SET ${sets.join(', ')} WHERE id = ?`).run(...args);
|
|
125
|
+
if (stockDelta !== 0) {
|
|
126
|
+
db.prepare(`UPDATE products SET stock = MAX(0, stock + ?), updated_at = datetime('now') WHERE id = ?`)
|
|
127
|
+
.run(stockDelta, req.params.product_id);
|
|
128
|
+
}
|
|
129
|
+
})();
|
|
130
|
+
res.json({ success: true });
|
|
131
|
+
});
|
|
132
|
+
app.delete('/api/products/:product_id/variants/:variant_id', (req, res) => {
|
|
133
|
+
const user = auth(req, res);
|
|
134
|
+
if (!user)
|
|
135
|
+
return;
|
|
136
|
+
const v = db.prepare(`SELECT v.id, v.stock, p.seller_id FROM product_variants v JOIN products p ON p.id = v.product_id WHERE v.id = ? AND v.product_id = ?`).get(req.params.variant_id, req.params.product_id);
|
|
137
|
+
if (!v)
|
|
138
|
+
return void res.status(404).json({ error: 'variant 不存在' });
|
|
139
|
+
if (v.seller_id !== user.id)
|
|
140
|
+
return void res.status(403).json({ error: '无权限' });
|
|
141
|
+
db.transaction(() => {
|
|
142
|
+
db.prepare('DELETE FROM product_variants WHERE id = ?').run(req.params.variant_id);
|
|
143
|
+
// P1-1: 从 product.stock aggregate 中扣除该 variant 的 stock
|
|
144
|
+
const variantStock = Number(v.stock || 0);
|
|
145
|
+
if (variantStock > 0) {
|
|
146
|
+
db.prepare(`UPDATE products SET stock = MAX(0, stock - ?), updated_at = datetime('now') WHERE id = ?`)
|
|
147
|
+
.run(variantStock, req.params.product_id);
|
|
148
|
+
}
|
|
149
|
+
const remaining = db.prepare('SELECT COUNT(*) as n FROM product_variants WHERE product_id = ?').get(req.params.product_id).n;
|
|
150
|
+
if (remaining === 0) {
|
|
151
|
+
db.prepare(`UPDATE products SET has_variants = 0, updated_at = datetime('now') WHERE id = ?`).run(req.params.product_id);
|
|
152
|
+
}
|
|
153
|
+
})();
|
|
154
|
+
res.json({ success: true });
|
|
155
|
+
});
|
|
156
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
export function registerVerifierUserRoutes(app, deps) {
|
|
2
|
+
const { db, generateId, auth, errorRes, checkVerifierEligibility, getVerifierState, resetDailyQuotaIfNeeded, TIER_QUOTAS, VERIFIER_STAKE_REQUIRED, APP_REJECT_COOLDOWN_DAYS, } = deps;
|
|
3
|
+
app.get('/api/verifier/eligibility', (req, res) => {
|
|
4
|
+
const user = auth(req, res);
|
|
5
|
+
if (!user)
|
|
6
|
+
return;
|
|
7
|
+
res.json(checkVerifierEligibility(user.id));
|
|
8
|
+
});
|
|
9
|
+
app.get('/api/verifier/status', (req, res) => {
|
|
10
|
+
const user = auth(req, res);
|
|
11
|
+
if (!user)
|
|
12
|
+
return;
|
|
13
|
+
if (user.id)
|
|
14
|
+
resetDailyQuotaIfNeeded(user.id);
|
|
15
|
+
const state = getVerifierState(user.id);
|
|
16
|
+
const wl = state.whitelist;
|
|
17
|
+
const tier = wl?.tier;
|
|
18
|
+
const quota = tier ? TIER_QUOTAS[tier] : 0;
|
|
19
|
+
res.json({
|
|
20
|
+
...state,
|
|
21
|
+
tier: tier ?? null,
|
|
22
|
+
daily_quota: quota,
|
|
23
|
+
tasks_today: wl?.tasks_today ?? 0,
|
|
24
|
+
remaining: quota > 0 ? Math.max(0, quota - Number(wl?.tasks_today ?? 0)) : 0,
|
|
25
|
+
is_system: wl?.is_system === 1,
|
|
26
|
+
stake_amount: Number(wl?.stake_amount ?? 0),
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
app.post('/api/verifier/apply', (req, res) => {
|
|
30
|
+
const user = auth(req, res);
|
|
31
|
+
if (!user)
|
|
32
|
+
return;
|
|
33
|
+
const userId = user.id;
|
|
34
|
+
// 外部审核员仅 buyer 角色(内部审核员由 admin 创建)
|
|
35
|
+
if (user.role !== 'buyer') {
|
|
36
|
+
return void errorRes(res, 403, 'ROLE_NOT_BUYER', '外部审核员仅 buyer 角色可申请(卖家 / 受信角色请联系管理员)');
|
|
37
|
+
}
|
|
38
|
+
const wl = db.prepare("SELECT 1 FROM verifier_whitelist WHERE user_id = ?").get(userId);
|
|
39
|
+
if (wl)
|
|
40
|
+
return void res.json({ error: '你已经是审核员,无需重新申请' });
|
|
41
|
+
const pending = db.prepare("SELECT 1 FROM verifier_applications WHERE user_id = ? AND status = 'pending'").get(userId);
|
|
42
|
+
if (pending)
|
|
43
|
+
return void res.json({ error: '你已有待审申请' });
|
|
44
|
+
// 30d 拒绝冷却
|
|
45
|
+
const lastReject = db.prepare("SELECT reviewed_at FROM verifier_applications WHERE user_id = ? AND status = 'rejected' ORDER BY reviewed_at DESC LIMIT 1").get(userId);
|
|
46
|
+
if (lastReject?.reviewed_at) {
|
|
47
|
+
const cooldownEnd = new Date(new Date(lastReject.reviewed_at).getTime() + APP_REJECT_COOLDOWN_DAYS * 86400_000);
|
|
48
|
+
if (cooldownEnd > new Date()) {
|
|
49
|
+
return void res.json({ error: `申请冷却期未结束,可在 ${cooldownEnd.toISOString().slice(0, 10)} 后重新申请` });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const elig = checkVerifierEligibility(userId);
|
|
53
|
+
if (!elig.eligible) {
|
|
54
|
+
return void res.json({ error: '信誉指标未达标', eligibility: elig });
|
|
55
|
+
}
|
|
56
|
+
if (VERIFIER_STAKE_REQUIRED > 0) {
|
|
57
|
+
const wallet = db.prepare("SELECT balance, staked FROM wallets WHERE user_id = ?").get(userId);
|
|
58
|
+
if (!wallet || wallet.balance < VERIFIER_STAKE_REQUIRED) {
|
|
59
|
+
return void res.json({ error: `质押需 ${VERIFIER_STAKE_REQUIRED} WAZ,钱包余额不足` });
|
|
60
|
+
}
|
|
61
|
+
db.prepare("UPDATE wallets SET balance = balance - ?, staked = staked + ? WHERE user_id = ?")
|
|
62
|
+
.run(VERIFIER_STAKE_REQUIRED, VERIFIER_STAKE_REQUIRED, userId);
|
|
63
|
+
}
|
|
64
|
+
db.prepare(`INSERT INTO verifier_applications (id, user_id, status, snapshot) VALUES (?,?,?,?)`)
|
|
65
|
+
.run(generateId('vapp'), userId, 'pending', JSON.stringify(elig.items));
|
|
66
|
+
res.json({ success: true, stake_locked: VERIFIER_STAKE_REQUIRED });
|
|
67
|
+
});
|
|
68
|
+
app.post('/api/verifier/withdraw-application', (req, res) => {
|
|
69
|
+
const user = auth(req, res);
|
|
70
|
+
if (!user)
|
|
71
|
+
return;
|
|
72
|
+
const userId = user.id;
|
|
73
|
+
const pending = db.prepare("SELECT id FROM verifier_applications WHERE user_id = ? AND status = 'pending' LIMIT 1").get(userId);
|
|
74
|
+
if (!pending)
|
|
75
|
+
return void res.json({ error: '没有待审申请' });
|
|
76
|
+
db.prepare("UPDATE verifier_applications SET status = 'withdrawn', reviewed_at = datetime('now') WHERE id = ?").run(pending.id);
|
|
77
|
+
if (VERIFIER_STAKE_REQUIRED > 0) {
|
|
78
|
+
db.prepare("UPDATE wallets SET balance = balance + ?, staked = staked - ? WHERE user_id = ?")
|
|
79
|
+
.run(VERIFIER_STAKE_REQUIRED, VERIFIER_STAKE_REQUIRED, userId);
|
|
80
|
+
}
|
|
81
|
+
res.json({ success: true });
|
|
82
|
+
});
|
|
83
|
+
app.post('/api/verifier/appeal', (req, res) => {
|
|
84
|
+
const user = auth(req, res);
|
|
85
|
+
if (!user)
|
|
86
|
+
return;
|
|
87
|
+
const { task_id, submission_id, reason, evidence_urls } = req.body;
|
|
88
|
+
if (!reason?.trim())
|
|
89
|
+
return void res.json({ error: '请填写申诉理由' });
|
|
90
|
+
if (reason.length > 500)
|
|
91
|
+
return void res.json({ error: '申诉理由过长(>500 字)' });
|
|
92
|
+
// 必须当前 suspended
|
|
93
|
+
const stats = db.prepare("SELECT suspended_until FROM verifier_stats WHERE user_id = ?").get(user.id);
|
|
94
|
+
if (!stats?.suspended_until || new Date(stats.suspended_until).getTime() <= Date.now()) {
|
|
95
|
+
return void res.json({ error: '当前未处于暂停状态,无需申诉' });
|
|
96
|
+
}
|
|
97
|
+
// 近 30 天有申诉过即重复
|
|
98
|
+
const recent = db.prepare(`SELECT 1 FROM verifier_appeals WHERE user_id = ? AND created_at > datetime('now','-30 day')`).get(user.id);
|
|
99
|
+
if (recent)
|
|
100
|
+
return void res.json({ error: '近期已申诉过,每次处罚只能申诉一次' });
|
|
101
|
+
const evidenceArr = Array.isArray(evidence_urls) ? evidence_urls.slice(0, 3) : [];
|
|
102
|
+
db.prepare(`INSERT INTO verifier_appeals (id, user_id, task_id, submission_id, reason, evidence_urls)
|
|
103
|
+
VALUES (?,?,?,?,?,?)`)
|
|
104
|
+
.run(generateId('vapl'), user.id, task_id || null, submission_id || null, reason.trim(), JSON.stringify(evidenceArr));
|
|
105
|
+
res.json({ success: true });
|
|
106
|
+
});
|
|
107
|
+
}
|