@seasonkoh/webaz 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +3691 -714
- 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 +31947 -0
- package/dist/pwa/public/i18n.js +5751 -0
- package/dist/pwa/public/icon.svg +11 -0
- package/dist/pwa/public/index.html +21 -0
- package/dist/pwa/public/manifest.json +48 -0
- package/dist/pwa/public/openapi.json +5946 -0
- package/dist/pwa/public/style.css +535 -0
- package/dist/pwa/public/sw.js +63 -0
- 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 +9679 -698
- package/package.json +11 -4
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
export function registerClaimVotingRoutes(app, deps) {
|
|
2
|
+
const { db, auth, isEligibleClaimVerifier, generateId, settleProductClaim, settleGenericClaim, PRODUCT_CLAIM_VERIFIERS_NEEDED, REVIEW_VERIFIERS_NEEDED } = deps;
|
|
3
|
+
const wire = (cfg) => {
|
|
4
|
+
const { vertical, taskTable, voteTable, taskAlias: a, partyIdCol, votePrefix, votesNeeded } = cfg;
|
|
5
|
+
// GET /api/<vertical>-claims/available
|
|
6
|
+
app.get(`/api/${vertical}-claims/available`, (req, res) => {
|
|
7
|
+
const user = auth(req, res);
|
|
8
|
+
if (!user)
|
|
9
|
+
return;
|
|
10
|
+
const elig = isEligibleClaimVerifier(user.id);
|
|
11
|
+
if (!elig.ok)
|
|
12
|
+
return void res.status(403).json({ error: elig.reason, eligible: false });
|
|
13
|
+
const sql = `
|
|
14
|
+
SELECT ${cfg.availableBaseCols},
|
|
15
|
+
(SELECT COUNT(*) FROM ${voteTable} WHERE claim_id = ${a}.id) as votes_count
|
|
16
|
+
${cfg.availableExtraSelect}
|
|
17
|
+
FROM ${taskTable} ${a}
|
|
18
|
+
${cfg.availableJoin}
|
|
19
|
+
WHERE ${a}.status = 'open'
|
|
20
|
+
AND ${a}.claimant_id != ? AND ${a}.${partyIdCol} != ?
|
|
21
|
+
AND NOT EXISTS (SELECT 1 FROM ${voteTable} WHERE claim_id = ${a}.id AND verifier_id = ?)
|
|
22
|
+
AND (SELECT COUNT(*) FROM ${voteTable} WHERE claim_id = ${a}.id) < ${votesNeeded}
|
|
23
|
+
ORDER BY ${a}.created_at ASC LIMIT 50
|
|
24
|
+
`;
|
|
25
|
+
const rows = db.prepare(sql).all(user.id, user.id, user.id);
|
|
26
|
+
res.json({ items: rows, eligible: true });
|
|
27
|
+
});
|
|
28
|
+
// POST /api/<vertical>-claims/:id/vote
|
|
29
|
+
app.post(`/api/${vertical}-claims/:id/vote`, (req, res) => {
|
|
30
|
+
const user = auth(req, res);
|
|
31
|
+
if (!user)
|
|
32
|
+
return;
|
|
33
|
+
const elig = isEligibleClaimVerifier(user.id);
|
|
34
|
+
if (!elig.ok)
|
|
35
|
+
return void res.status(403).json({ error: elig.reason });
|
|
36
|
+
const claim = db.prepare(`SELECT * FROM ${taskTable} WHERE id = ?`).get(req.params.id);
|
|
37
|
+
if (!claim)
|
|
38
|
+
return void res.status(404).json({ error: '声明不存在' });
|
|
39
|
+
if (claim.status !== 'open')
|
|
40
|
+
return void res.status(400).json({ error: `状态 ${claim.status} 不接受投票` });
|
|
41
|
+
if (claim.claimant_id === user.id || claim[partyIdCol] === user.id) {
|
|
42
|
+
return void res.status(403).json({ error: vertical === 'product' ? (claim.claimant_id === user.id ? '发起人不可对自己的声明投票' : '商品卖家不可对自己被诉声明投票') : '当事人不可投票' });
|
|
43
|
+
}
|
|
44
|
+
const vote = String(req.body?.vote || '').trim();
|
|
45
|
+
if (!['upheld', 'dismissed', 'insufficient'].includes(vote)) {
|
|
46
|
+
return void res.status(400).json({ error: `vote 须为 upheld / dismissed / insufficient` });
|
|
47
|
+
}
|
|
48
|
+
const dup = db.prepare(`SELECT id FROM ${voteTable} WHERE claim_id = ? AND verifier_id = ?`).get(req.params.id, user.id);
|
|
49
|
+
if (dup)
|
|
50
|
+
return void res.status(409).json({ error: '已投过票' });
|
|
51
|
+
const votesNow = db.prepare(`SELECT COUNT(*) as n FROM ${voteTable} WHERE claim_id = ?`).get(req.params.id).n;
|
|
52
|
+
if (votesNow >= votesNeeded)
|
|
53
|
+
return void res.status(409).json({ error: '已收齐共识票数' });
|
|
54
|
+
const evidence_uri = req.body?.evidence_uri ? String(req.body.evidence_uri).trim().slice(0, 500) : null;
|
|
55
|
+
const note = req.body?.note ? String(req.body.note).trim().slice(0, 500) : null;
|
|
56
|
+
const voteId = generateId(votePrefix);
|
|
57
|
+
try {
|
|
58
|
+
db.prepare(`INSERT INTO ${voteTable} (id, claim_id, verifier_id, vote, evidence_uri, note) VALUES (?,?,?,?,?,?)`)
|
|
59
|
+
.run(voteId, req.params.id, user.id, vote, evidence_uri, note);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return void res.status(409).json({ error: '投票失败(可能并发重复)' });
|
|
63
|
+
}
|
|
64
|
+
const after = db.prepare(`SELECT COUNT(*) as n FROM ${voteTable} WHERE claim_id = ?`).get(req.params.id).n;
|
|
65
|
+
let settlement = null;
|
|
66
|
+
if (after >= votesNeeded) {
|
|
67
|
+
db.prepare(`UPDATE ${taskTable} SET status = 'sealed' WHERE id = ? AND status = 'open'`).run(req.params.id);
|
|
68
|
+
settlement = cfg.useProductSettle ? settleProductClaim(req.params.id) : settleGenericClaim(taskTable, voteTable, req.params.id);
|
|
69
|
+
}
|
|
70
|
+
res.json({ success: true, votes_collected: after, sealed: after >= votesNeeded, settlement });
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
// 5 个垂类配置
|
|
74
|
+
wire({
|
|
75
|
+
vertical: 'product', taskTable: 'product_claim_tasks', voteTable: 'product_claim_votes',
|
|
76
|
+
taskAlias: 'pct', partyIdCol: 'seller_id', votePrefix: 'pcv',
|
|
77
|
+
votesNeeded: PRODUCT_CLAIM_VERIFIERS_NEEDED, useProductSettle: true,
|
|
78
|
+
availableBaseCols: 'pct.id, pct.product_id, pct.claim_target, pct.claim_text, pct.evidence_uri, pct.deadline_at, pct.created_at',
|
|
79
|
+
availableExtraSelect: ', p.title as product_title',
|
|
80
|
+
availableJoin: 'JOIN products p ON p.id = pct.product_id',
|
|
81
|
+
});
|
|
82
|
+
wire({
|
|
83
|
+
vertical: 'review', taskTable: 'review_claim_tasks', voteTable: 'review_claim_votes',
|
|
84
|
+
taskAlias: 'rct', partyIdCol: 'reviewer_id', votePrefix: 'rcv',
|
|
85
|
+
votesNeeded: REVIEW_VERIFIERS_NEEDED, useProductSettle: false,
|
|
86
|
+
availableBaseCols: 'rct.id, rct.review_type, rct.review_id, rct.product_id, rct.claim_target, rct.claim_text, rct.evidence_uri, rct.deadline_at, rct.created_at',
|
|
87
|
+
availableExtraSelect: '',
|
|
88
|
+
availableJoin: '',
|
|
89
|
+
});
|
|
90
|
+
wire({
|
|
91
|
+
vertical: 'secondhand', taskTable: 'secondhand_claim_tasks', voteTable: 'secondhand_claim_votes',
|
|
92
|
+
taskAlias: 'sct', partyIdCol: 'seller_id', votePrefix: 'scv',
|
|
93
|
+
votesNeeded: 3, useProductSettle: false,
|
|
94
|
+
availableBaseCols: 'sct.id, sct.sh_item_id, sct.claim_target, sct.claim_text, sct.evidence_uri, sct.deadline_at, sct.created_at',
|
|
95
|
+
availableExtraSelect: ', si.title as item_title',
|
|
96
|
+
availableJoin: 'JOIN secondhand_items si ON si.id = sct.sh_item_id',
|
|
97
|
+
});
|
|
98
|
+
wire({
|
|
99
|
+
vertical: 'auction', taskTable: 'auction_claim_tasks', voteTable: 'auction_claim_votes',
|
|
100
|
+
taskAlias: 'act', partyIdCol: 'seller_id', votePrefix: 'acv',
|
|
101
|
+
votesNeeded: 3, useProductSettle: false,
|
|
102
|
+
availableBaseCols: 'act.id, act.auction_id, act.claim_target, act.claim_text, act.evidence_uri, act.deadline_at, act.created_at',
|
|
103
|
+
availableExtraSelect: ', a.title as auction_title',
|
|
104
|
+
availableJoin: 'JOIN auctions a ON a.id = act.auction_id',
|
|
105
|
+
});
|
|
106
|
+
wire({
|
|
107
|
+
vertical: 'wish', taskTable: 'wish_claim_tasks', voteTable: 'wish_claim_votes',
|
|
108
|
+
taskAlias: 'wct', partyIdCol: 'wisher_id', votePrefix: 'wcv',
|
|
109
|
+
votesNeeded: 3, useProductSettle: false,
|
|
110
|
+
availableBaseCols: 'wct.id, wct.wish_id, wct.claim_target, wct.claim_text, wct.evidence_uri, wct.deadline_at, wct.created_at',
|
|
111
|
+
availableExtraSelect: ', w.title as wish_title',
|
|
112
|
+
availableJoin: 'JOIN wishes w ON w.id = wct.wish_id',
|
|
113
|
+
});
|
|
114
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export function registerClaimWithdrawalsRoutes(app, deps) {
|
|
2
|
+
const { auth, withdrawClaim } = deps;
|
|
3
|
+
const mk = (path, taskTable, voteTable) => {
|
|
4
|
+
app.delete(path, (req, res) => {
|
|
5
|
+
const user = auth(req, res);
|
|
6
|
+
if (!user)
|
|
7
|
+
return;
|
|
8
|
+
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
9
|
+
const r = withdrawClaim(taskTable, voteTable, id, user.id);
|
|
10
|
+
if (!r.ok)
|
|
11
|
+
return void res.status(400).json({ error: r.error });
|
|
12
|
+
res.json({ success: true, stake_refunded: true });
|
|
13
|
+
});
|
|
14
|
+
};
|
|
15
|
+
mk('/api/product-claims/:id', 'product_claim_tasks', 'product_claim_votes');
|
|
16
|
+
mk('/api/review-claims/:id', 'review_claim_tasks', 'review_claim_votes');
|
|
17
|
+
mk('/api/secondhand-claims/:id', 'secondhand_claim_tasks', 'secondhand_claim_votes');
|
|
18
|
+
mk('/api/auction-claims/:id', 'auction_claim_tasks', 'auction_claim_votes');
|
|
19
|
+
mk('/api/wish-claims/:id', 'wish_claim_tasks', 'wish_claim_votes');
|
|
20
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/** 计算 coupon 适用 + 折扣。跨域调用:orders 下单流程会用。 */
|
|
2
|
+
export function applyCouponToOrder(db, couponCode, sellerId, productId, totalAmount) {
|
|
3
|
+
const code = couponCode.trim().toUpperCase();
|
|
4
|
+
if (!code)
|
|
5
|
+
return { ok: false, error: '空优惠码' };
|
|
6
|
+
// scope='all' 平台券由 admin 创建(seller_id = admin id),不能用 product 的 seller_id 匹配
|
|
7
|
+
// 先查 shop/product 范围(属于该 seller),没找到再查 'all' 全局
|
|
8
|
+
let coupon = db.prepare(`SELECT * FROM coupons WHERE seller_id = ? AND code = ? AND is_active = 1 AND scope IN ('shop', 'product')`).get(sellerId, code);
|
|
9
|
+
if (!coupon) {
|
|
10
|
+
coupon = db.prepare(`SELECT * FROM coupons WHERE code = ? AND is_active = 1 AND scope = 'all' LIMIT 1`).get(code);
|
|
11
|
+
}
|
|
12
|
+
if (!coupon)
|
|
13
|
+
return { ok: false, error: '优惠码无效或已失效' };
|
|
14
|
+
const now = new Date();
|
|
15
|
+
if (coupon.starts_at && new Date(coupon.starts_at) > now)
|
|
16
|
+
return { ok: false, error: '优惠码未到生效时间' };
|
|
17
|
+
if (coupon.expires_at && new Date(coupon.expires_at) < now)
|
|
18
|
+
return { ok: false, error: '优惠码已过期' };
|
|
19
|
+
const maxUses = Number(coupon.max_uses || 0);
|
|
20
|
+
if (maxUses > 0 && Number(coupon.uses_count || 0) >= maxUses)
|
|
21
|
+
return { ok: false, error: '优惠码已用完' };
|
|
22
|
+
const minAmount = Number(coupon.min_order_amount || 0);
|
|
23
|
+
if (totalAmount < minAmount)
|
|
24
|
+
return { ok: false, error: `订单需满 ${minAmount} WAZ 才可用此券` };
|
|
25
|
+
if (coupon.scope === 'product' && coupon.scope_id !== productId) {
|
|
26
|
+
return { ok: false, error: '此优惠码不适用本商品' };
|
|
27
|
+
}
|
|
28
|
+
// shop / all 不需要 scope_id 检查
|
|
29
|
+
let discount = 0;
|
|
30
|
+
if (coupon.discount_type === 'percentage') {
|
|
31
|
+
discount = Math.round(totalAmount * Number(coupon.discount_value) / 100 * 100) / 100;
|
|
32
|
+
}
|
|
33
|
+
else if (coupon.discount_type === 'fixed') {
|
|
34
|
+
discount = Math.min(Number(coupon.discount_value), totalAmount);
|
|
35
|
+
}
|
|
36
|
+
return { ok: true, coupon, discount };
|
|
37
|
+
}
|
|
38
|
+
export function registerCouponsRoutes(app, deps) {
|
|
39
|
+
const { db, generateId, auth, isTrustedRole, safeRoles, errorRes } = deps;
|
|
40
|
+
app.post('/api/coupons', (req, res) => {
|
|
41
|
+
const user = auth(req, res);
|
|
42
|
+
if (!user)
|
|
43
|
+
return;
|
|
44
|
+
if (user.role !== 'seller' && !safeRoles(user).includes('seller')) {
|
|
45
|
+
return void res.status(403).json({ error: '仅卖家可发券' });
|
|
46
|
+
}
|
|
47
|
+
const { code, scope, scope_id, discount_type, discount_value, min_order_amount, max_uses, starts_at, expires_at } = req.body || {};
|
|
48
|
+
const codeStr = String(code || '').trim().toUpperCase();
|
|
49
|
+
if (codeStr.length < 3 || codeStr.length > 24)
|
|
50
|
+
return void res.status(400).json({ error: 'code 长度需 3-24(大写字母数字)' });
|
|
51
|
+
if (!/^[A-Z0-9_-]+$/.test(codeStr))
|
|
52
|
+
return void res.status(400).json({ error: 'code 仅允许大写字母 / 数字 / _ -' });
|
|
53
|
+
if (!['product', 'shop', 'all'].includes(scope))
|
|
54
|
+
return void res.status(400).json({ error: 'scope 须为 product / shop / all' });
|
|
55
|
+
if (scope === 'product' && !scope_id)
|
|
56
|
+
return void res.status(400).json({ error: 'product scope 需 scope_id' });
|
|
57
|
+
if (!['percentage', 'fixed'].includes(discount_type))
|
|
58
|
+
return void res.status(400).json({ error: 'discount_type 须为 percentage / fixed' });
|
|
59
|
+
const dv = Number(discount_value);
|
|
60
|
+
if (!Number.isFinite(dv) || dv <= 0)
|
|
61
|
+
return void res.status(400).json({ error: 'discount_value 须为正数' });
|
|
62
|
+
if (discount_type === 'percentage' && dv > 90)
|
|
63
|
+
return void res.status(400).json({ error: 'percentage 最高 90' });
|
|
64
|
+
if (scope === 'product') {
|
|
65
|
+
const p = db.prepare('SELECT seller_id FROM products WHERE id = ?').get(scope_id);
|
|
66
|
+
if (!p)
|
|
67
|
+
return void res.status(404).json({ error: '商品不存在' });
|
|
68
|
+
if (p.seller_id !== user.id)
|
|
69
|
+
return void res.status(403).json({ error: '仅能为自己的商品发券' });
|
|
70
|
+
}
|
|
71
|
+
// 'all' scope 仅 admin 可创建
|
|
72
|
+
if (scope === 'all' && user.role !== 'admin' && !safeRoles(user).includes('admin')) {
|
|
73
|
+
return void res.status(403).json({ error: 'all-scope 优惠码仅平台可发' });
|
|
74
|
+
}
|
|
75
|
+
const id = generateId('cpn');
|
|
76
|
+
try {
|
|
77
|
+
db.prepare(`INSERT INTO coupons (id, seller_id, code, scope, scope_id, discount_type, discount_value, min_order_amount, max_uses, starts_at, expires_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)`)
|
|
78
|
+
.run(id, user.id, codeStr, scope, scope_id || null, discount_type, dv, Number(min_order_amount) || 0, Number(max_uses) || 0, starts_at || null, expires_at || null);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return void res.status(409).json({ error: '此 code 已存在(每个卖家 code 唯一)' });
|
|
82
|
+
}
|
|
83
|
+
res.json({ success: true, id, code: codeStr });
|
|
84
|
+
});
|
|
85
|
+
// buyer 视角:全平台 + 已购卖家店铺/单品券 + 历史
|
|
86
|
+
app.get('/api/coupons/available', (req, res) => {
|
|
87
|
+
const user = auth(req, res);
|
|
88
|
+
if (!user)
|
|
89
|
+
return;
|
|
90
|
+
if (isTrustedRole(user))
|
|
91
|
+
return void errorRes(res, 403, 'TRUSTED_ROLE_NO_TRADE', '受信角色无购物功能');
|
|
92
|
+
const purchasedSellers = db.prepare(`SELECT DISTINCT seller_id FROM orders WHERE buyer_id = ?`).all(user.id);
|
|
93
|
+
const sellerIds = purchasedSellers.map(r => r.seller_id);
|
|
94
|
+
const placeholders = sellerIds.length > 0 ? sellerIds.map(() => '?').join(',') : '';
|
|
95
|
+
const sellerCondition = sellerIds.length > 0 ? `OR (c.seller_id IN (${placeholders}) AND c.scope IN ('shop','product'))` : '';
|
|
96
|
+
const sql = `
|
|
97
|
+
SELECT c.id, c.code, c.scope, c.scope_id, c.discount_type, c.discount_value,
|
|
98
|
+
c.min_order_amount, c.max_uses, c.uses_count, c.starts_at, c.expires_at,
|
|
99
|
+
u.name as seller_name, u.handle as seller_handle,
|
|
100
|
+
p.title as product_title
|
|
101
|
+
FROM coupons c
|
|
102
|
+
LEFT JOIN users u ON u.id = c.seller_id
|
|
103
|
+
LEFT JOIN products p ON p.id = c.scope_id AND c.scope = 'product'
|
|
104
|
+
WHERE c.is_active = 1
|
|
105
|
+
AND (c.expires_at IS NULL OR c.expires_at > datetime('now'))
|
|
106
|
+
AND (c.starts_at IS NULL OR c.starts_at <= datetime('now'))
|
|
107
|
+
AND (c.max_uses = 0 OR c.uses_count < c.max_uses)
|
|
108
|
+
AND (c.scope = 'all' ${sellerCondition})
|
|
109
|
+
ORDER BY
|
|
110
|
+
CASE c.scope WHEN 'product' THEN 1 WHEN 'shop' THEN 2 ELSE 3 END,
|
|
111
|
+
c.expires_at ASC NULLS LAST,
|
|
112
|
+
c.created_at DESC
|
|
113
|
+
LIMIT 100
|
|
114
|
+
`;
|
|
115
|
+
const rows = db.prepare(sql).all(...sellerIds);
|
|
116
|
+
const history = db.prepare(`
|
|
117
|
+
SELECT o.id as order_id, o.created_at, o.coupon_discount,
|
|
118
|
+
c.code, c.scope, c.discount_type, c.discount_value,
|
|
119
|
+
p.title as product_title
|
|
120
|
+
FROM orders o
|
|
121
|
+
JOIN coupons c ON c.id = o.coupon_id
|
|
122
|
+
JOIN products p ON p.id = o.product_id
|
|
123
|
+
WHERE o.buyer_id = ? AND o.coupon_id IS NOT NULL
|
|
124
|
+
ORDER BY o.created_at DESC LIMIT 50
|
|
125
|
+
`).all(user.id);
|
|
126
|
+
res.json({ available: rows, history });
|
|
127
|
+
});
|
|
128
|
+
app.get('/api/coupons/mine', (req, res) => {
|
|
129
|
+
const user = auth(req, res);
|
|
130
|
+
if (!user)
|
|
131
|
+
return;
|
|
132
|
+
const rows = db.prepare(`
|
|
133
|
+
SELECT * FROM coupons WHERE seller_id = ? ORDER BY created_at DESC LIMIT 100
|
|
134
|
+
`).all(user.id);
|
|
135
|
+
res.json({ items: rows });
|
|
136
|
+
});
|
|
137
|
+
app.patch('/api/coupons/:id', (req, res) => {
|
|
138
|
+
const user = auth(req, res);
|
|
139
|
+
if (!user)
|
|
140
|
+
return;
|
|
141
|
+
const coupon = db.prepare('SELECT * FROM coupons WHERE id = ? AND seller_id = ?').get(req.params.id, user.id);
|
|
142
|
+
if (!coupon)
|
|
143
|
+
return void res.status(404).json({ error: '优惠码不存在或无权限' });
|
|
144
|
+
const { is_active, expires_at, max_uses } = req.body || {};
|
|
145
|
+
const sets = [];
|
|
146
|
+
const args = [];
|
|
147
|
+
if (is_active !== undefined) {
|
|
148
|
+
sets.push('is_active = ?');
|
|
149
|
+
args.push(is_active ? 1 : 0);
|
|
150
|
+
}
|
|
151
|
+
if (expires_at !== undefined) {
|
|
152
|
+
sets.push('expires_at = ?');
|
|
153
|
+
args.push(expires_at);
|
|
154
|
+
}
|
|
155
|
+
if (max_uses !== undefined) {
|
|
156
|
+
sets.push('max_uses = ?');
|
|
157
|
+
args.push(Number(max_uses) || 0);
|
|
158
|
+
}
|
|
159
|
+
if (sets.length === 0)
|
|
160
|
+
return void res.status(400).json({ error: '无可更新字段' });
|
|
161
|
+
args.push(req.params.id);
|
|
162
|
+
db.prepare(`UPDATE coupons SET ${sets.join(', ')} WHERE id = ?`).run(...args);
|
|
163
|
+
res.json({ success: true });
|
|
164
|
+
});
|
|
165
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
export function registerDashboardsRoutes(app, deps) {
|
|
2
|
+
const { db, auth } = deps;
|
|
3
|
+
app.get('/api/tokenomics/status', (_req, res) => {
|
|
4
|
+
const gf = db.prepare("SELECT pool_balance FROM global_fund WHERE id = 1").get();
|
|
5
|
+
const poolBalance = Number(gf?.pool_balance ?? 0);
|
|
6
|
+
const histRow = db.prepare(`
|
|
7
|
+
SELECT AVG(deposited_this_period) as avg_dep
|
|
8
|
+
FROM settlement_periods
|
|
9
|
+
WHERE status = 'completed' AND started_at > datetime('now', '-28 days')
|
|
10
|
+
`).get();
|
|
11
|
+
const historyAverage = Math.round(Number(histRow?.avg_dep ?? 0) * 100) / 100;
|
|
12
|
+
let healthLevel = 'cold_start';
|
|
13
|
+
let distributionCap = 1.0;
|
|
14
|
+
if (historyAverage > 0) {
|
|
15
|
+
const healthRatio = poolBalance / historyAverage;
|
|
16
|
+
distributionCap = Math.max(1.0, Math.min(1.6, healthRatio));
|
|
17
|
+
if (healthRatio >= 2.0)
|
|
18
|
+
healthLevel = 'healthy';
|
|
19
|
+
else if (healthRatio >= 0.5)
|
|
20
|
+
healthLevel = 'normal';
|
|
21
|
+
else if (healthRatio >= 0.2)
|
|
22
|
+
healthLevel = 'strained';
|
|
23
|
+
else
|
|
24
|
+
healthLevel = 'critical';
|
|
25
|
+
}
|
|
26
|
+
const lastSettled = db.prepare(`
|
|
27
|
+
SELECT effective_unit_cash, payout_rate, started_at FROM settlement_periods
|
|
28
|
+
WHERE status = 'completed' ORDER BY started_at DESC LIMIT 1
|
|
29
|
+
`).get();
|
|
30
|
+
const recentPaused = db.prepare(`
|
|
31
|
+
SELECT period_id, started_at, note FROM settlement_periods
|
|
32
|
+
WHERE status = 'paused_low_water' AND started_at > datetime('now', '-7 days')
|
|
33
|
+
ORDER BY started_at DESC LIMIT 1
|
|
34
|
+
`).get();
|
|
35
|
+
res.json({
|
|
36
|
+
pool_balance: poolBalance,
|
|
37
|
+
history_average: historyAverage,
|
|
38
|
+
health_ratio: historyAverage > 0 ? Math.round(poolBalance / historyAverage * 100) / 100 : null,
|
|
39
|
+
paused_recent: recentPaused || null,
|
|
40
|
+
health_level: healthLevel,
|
|
41
|
+
distribution_cap: Math.round(distributionCap * 100) / 100,
|
|
42
|
+
last_settlement: lastSettled || null,
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
app.get('/api/shares/dashboard', (req, res) => {
|
|
46
|
+
const user = auth(req, res);
|
|
47
|
+
if (!user)
|
|
48
|
+
return;
|
|
49
|
+
const userId = user.id;
|
|
50
|
+
const bought = db.prepare(`
|
|
51
|
+
SELECT
|
|
52
|
+
o.id as order_id,
|
|
53
|
+
o.updated_at as completed_at,
|
|
54
|
+
p.id, p.title, p.price, p.commission_rate, p.images, p.category,
|
|
55
|
+
(SELECT COUNT(*) FROM shareables s WHERE s.owner_id = ? AND s.related_order_id = o.id AND s.type = 'note' AND s.status = 'active') as note_count,
|
|
56
|
+
(SELECT id FROM shareables s WHERE s.owner_id = ? AND s.related_order_id = o.id AND s.type = 'note' AND s.status = 'active' LIMIT 1) as first_note_id,
|
|
57
|
+
(SELECT COUNT(*) FROM shareables s WHERE s.owner_id = ? AND s.related_product_id = p.id AND s.status = 'active') as product_share_count,
|
|
58
|
+
(SELECT COUNT(*) FROM anchor_registry ar WHERE ar.owner_id = ? AND ar.target_kind = 'product' AND ar.target_id = p.id AND ar.status = 'active') as anchor_count,
|
|
59
|
+
(SELECT COUNT(DISTINCT o2.id) FROM orders o2
|
|
60
|
+
JOIN product_share_attribution psa ON psa.recipient_id = o2.buyer_id AND psa.product_id = o2.product_id
|
|
61
|
+
JOIN shareables s2 ON s2.id = psa.shareable_id
|
|
62
|
+
WHERE s2.owner_id = ? AND s2.related_product_id = p.id AND o2.status = 'completed') as induced_orders
|
|
63
|
+
FROM orders o
|
|
64
|
+
JOIN products p ON p.id = o.product_id
|
|
65
|
+
WHERE o.buyer_id = ? AND o.status = 'completed'
|
|
66
|
+
ORDER BY o.updated_at DESC
|
|
67
|
+
LIMIT 50
|
|
68
|
+
`).all(userId, userId, userId, userId, userId, userId);
|
|
69
|
+
const highComm = db.prepare(`
|
|
70
|
+
SELECT p.id, p.title, p.price, p.commission_rate, p.images, p.category,
|
|
71
|
+
(SELECT COUNT(*) FROM orders o WHERE o.product_id = p.id AND o.status = 'completed') as sales_count
|
|
72
|
+
FROM products p
|
|
73
|
+
WHERE p.status = 'active'
|
|
74
|
+
AND p.commission_rate > 0
|
|
75
|
+
AND p.seller_id != ?
|
|
76
|
+
AND p.id NOT IN (SELECT product_id FROM orders WHERE buyer_id = ? AND status = 'completed')
|
|
77
|
+
ORDER BY p.commission_rate DESC, sales_count DESC
|
|
78
|
+
LIMIT 10
|
|
79
|
+
`).all(userId, userId);
|
|
80
|
+
const myCreations = db.prepare(`
|
|
81
|
+
SELECT s.id, s.type, s.title, s.external_platform, s.external_url,
|
|
82
|
+
s.related_product_id, s.related_order_id, s.related_anchor, p.title as product_title,
|
|
83
|
+
s.click_count, s.like_count, s.created_at,
|
|
84
|
+
(SELECT COUNT(DISTINCT o.id) FROM orders o
|
|
85
|
+
JOIN product_share_attribution psa ON psa.recipient_id = o.buyer_id AND psa.product_id = o.product_id
|
|
86
|
+
WHERE psa.shareable_id = s.id AND o.status = 'completed') as induced_orders
|
|
87
|
+
FROM shareables s
|
|
88
|
+
LEFT JOIN products p ON p.id = s.related_product_id
|
|
89
|
+
WHERE s.owner_id = ? AND s.status = 'active'
|
|
90
|
+
ORDER BY s.created_at DESC
|
|
91
|
+
LIMIT 30
|
|
92
|
+
`).all(userId);
|
|
93
|
+
res.json({
|
|
94
|
+
bought_products: bought,
|
|
95
|
+
high_commission_products: highComm,
|
|
96
|
+
my_creations: myCreations,
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
}
|