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