@seasonkoh/webaz 0.1.28 → 0.1.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +3 -2
  2. package/README.zh-CN.md +7 -6
  3. package/dist/currency.js +16 -0
  4. package/dist/deposit-rails.js +52 -0
  5. package/dist/direct-pay-aml-monitor.js +67 -0
  6. package/dist/direct-pay-aml-review.js +40 -0
  7. package/dist/direct-pay-base-bond-entry.js +5 -0
  8. package/dist/direct-pay-bond-rail-clearance.js +94 -0
  9. package/dist/direct-pay-compliance-ingress.js +145 -0
  10. package/dist/direct-pay-controls.js +136 -0
  11. package/dist/direct-pay-create.js +162 -0
  12. package/dist/direct-pay-deferral-quota.js +43 -0
  13. package/dist/direct-pay-disclosures.js +44 -0
  14. package/dist/direct-pay-eligibility.js +46 -0
  15. package/dist/direct-pay-fee-ar.js +241 -0
  16. package/dist/direct-pay-launch-readiness.js +108 -0
  17. package/dist/direct-pay-launch-summary.js +68 -0
  18. package/dist/direct-pay-ledger.js +94 -0
  19. package/dist/direct-receive-account-qr.js +77 -0
  20. package/dist/direct-receive-accounts.js +82 -0
  21. package/dist/direct-receive-deferral.js +142 -0
  22. package/dist/direct-receive-deposits.js +260 -0
  23. package/dist/direct-receive-payment-instruction.js +26 -0
  24. package/dist/fx-rates.js +71 -0
  25. package/dist/layer0-foundation/L0-1-database/schema.js +531 -1
  26. package/dist/layer0-foundation/L0-2-state-machine/genuine-sale.js +15 -5
  27. package/dist/layer0-foundation/L0-2-state-machine/transitions.js +41 -0
  28. package/dist/layer0-foundation/L0-5-manifest/manifest.js +1 -1
  29. package/dist/layer1-agent/L1-1-mcp-server/cli.js +64 -0
  30. package/dist/layer1-agent/L1-1-mcp-server/network-mode.js +11 -0
  31. package/dist/layer1-agent/L1-1-mcp-server/server.js +18 -16
  32. package/dist/layer4-economics/L4-4-skill-market/skill-engine.js +4 -4
  33. package/dist/ledger.js +12 -3
  34. package/dist/mcp.js +23 -4
  35. package/dist/merchant-bond-domain.js +64 -0
  36. package/dist/merchant-bond-exposure.js +78 -0
  37. package/dist/merchant-bond-watcher.js +26 -0
  38. package/dist/payment-rails.js +29 -0
  39. package/dist/product-verification.js +93 -0
  40. package/dist/pwa/acp-feed.js +3 -2
  41. package/dist/pwa/contract-fingerprint.js +3 -0
  42. package/dist/pwa/direct-pay-guards.js +20 -0
  43. package/dist/pwa/direct-pay-order-redaction.js +24 -0
  44. package/dist/pwa/endpoint-actions.js +3 -0
  45. package/dist/pwa/public/app-ai.js +10 -10
  46. package/dist/pwa/public/app-chat-poll.js +29 -0
  47. package/dist/pwa/public/app-create-kinds.js +17 -0
  48. package/dist/pwa/public/app-direct-pay-accounts.js +141 -0
  49. package/dist/pwa/public/app-direct-pay-buyer.js +72 -0
  50. package/dist/pwa/public/app-direct-pay-compliance.js +67 -0
  51. package/dist/pwa/public/app-direct-pay-deferral-admin.js +72 -0
  52. package/dist/pwa/public/app-direct-pay-deferral.js +61 -0
  53. package/dist/pwa/public/app-direct-pay-fee-center.js +33 -0
  54. package/dist/pwa/public/app-direct-pay-fee-ops.js +112 -0
  55. package/dist/pwa/public/app-direct-pay-product-verify.js +103 -0
  56. package/dist/pwa/public/app-direct-pay-readiness.js +38 -0
  57. package/dist/pwa/public/app-direct-pay-store-verify.js +100 -0
  58. package/dist/pwa/public/app-direct-pay.js +227 -0
  59. package/dist/pwa/public/app-discover.js +20 -20
  60. package/dist/pwa/public/app-external-links.js +32 -0
  61. package/dist/pwa/public/app-listings.js +4 -4
  62. package/dist/pwa/public/app-prelaunch-waz.js +39 -0
  63. package/dist/pwa/public/app-price.js +55 -0
  64. package/dist/pwa/public/app-product-media.js +15 -0
  65. package/dist/pwa/public/app-profile.js +6 -6
  66. package/dist/pwa/public/app-seller.js +5 -5
  67. package/dist/pwa/public/app-shop.js +19 -19
  68. package/dist/pwa/public/app.js +142 -146
  69. package/dist/pwa/public/i18n.js +398 -197
  70. package/dist/pwa/public/index.html +17 -0
  71. package/dist/pwa/public/openapi.json +495 -1
  72. package/dist/pwa/routes/admin-analytics.js +4 -2
  73. package/dist/pwa/routes/admin-direct-receive-deposits.js +321 -0
  74. package/dist/pwa/routes/buyer-feeds.js +2 -1
  75. package/dist/pwa/routes/chat.js +6 -1
  76. package/dist/pwa/routes/dashboards.js +2 -1
  77. package/dist/pwa/routes/direct-pay-availability.js +196 -0
  78. package/dist/pwa/routes/direct-pay-disclosure-acks.js +74 -0
  79. package/dist/pwa/routes/direct-pay-timeouts.js +71 -0
  80. package/dist/pwa/routes/direct-receive-accounts.js +155 -0
  81. package/dist/pwa/routes/direct-receive-payment-instructions.js +45 -0
  82. package/dist/pwa/routes/fx.js +12 -0
  83. package/dist/pwa/routes/leaderboard.js +47 -9
  84. package/dist/pwa/routes/listings.js +2 -2
  85. package/dist/pwa/routes/logistics.js +4 -0
  86. package/dist/pwa/routes/manifests.js +38 -0
  87. package/dist/pwa/routes/me-data.js +5 -2
  88. package/dist/pwa/routes/orders-action.js +117 -9
  89. package/dist/pwa/routes/orders-create.js +4 -0
  90. package/dist/pwa/routes/orders-read.js +36 -0
  91. package/dist/pwa/routes/p2p-products.js +2 -2
  92. package/dist/pwa/routes/products-create.js +5 -3
  93. package/dist/pwa/routes/products-links.js +34 -0
  94. package/dist/pwa/routes/products-list.js +2 -1
  95. package/dist/pwa/routes/products-update.js +22 -2
  96. package/dist/pwa/routes/promoter.js +3 -0
  97. package/dist/pwa/routes/referral.js +4 -0
  98. package/dist/pwa/routes/returns.js +26 -1
  99. package/dist/pwa/routes/rewards-apply.js +10 -5
  100. package/dist/pwa/routes/rewards-clearing-mature.js +96 -0
  101. package/dist/pwa/routes/rewards-escrow-expire.js +6 -2
  102. package/dist/pwa/routes/shops.js +2 -1
  103. package/dist/pwa/routes/url-claim.js +2 -2
  104. package/dist/pwa/routes/users-public.js +8 -0
  105. package/dist/pwa/routes/wallet-read.js +3 -0
  106. package/dist/pwa/routes/webauthn.js +1 -1
  107. package/dist/pwa/server.js +59 -102
  108. package/dist/runtime/webaz-schema-helpers.js +104 -0
  109. package/dist/store-verification.js +77 -0
  110. package/dist/version.js +1 -1
  111. package/package.json +71 -2
@@ -0,0 +1,93 @@
1
+ export const MAX_URL_LEN = 2048;
2
+ export const MAX_PLATFORM_LEN = 60;
3
+ export const MAX_NOTES_LEN = 500;
4
+ const ACTIVE = "('issued','submitted','verified')";
5
+ const getActiveForProduct = (db, productId) => db.prepare(`SELECT * FROM product_verifications WHERE product_id = ? AND status IN ${ACTIVE} ORDER BY created_at DESC, rowid DESC LIMIT 1`).get(productId);
6
+ /** http(s) 链接最弱校验(仅存储,不抓取):必须 http/https、长度受限。拒 javascript:/data: 等危险 scheme。 */
7
+ function isStorableHttpUrl(url) {
8
+ if (typeof url !== 'string' || url.length === 0 || url.length > MAX_URL_LEN)
9
+ return false;
10
+ return /^https?:\/\/[^\s]+$/i.test(url.trim());
11
+ }
12
+ /** 卖家为【某产品】申请认证 → issued(签发 code)。绝不自动 verify。单一活跃(per product)。调用方须先校验 seller 拥有该产品。 */
13
+ export function requestProductVerification(db, args) {
14
+ const { id, productId, sellerId, code } = args;
15
+ if (!id || !productId || !sellerId || !code)
16
+ return { ok: false, reason: 'missing id/productId/sellerId/code' };
17
+ const platform = args.platform ? String(args.platform).trim().slice(0, MAX_PLATFORM_LEN) : null;
18
+ const active = getActiveForProduct(db, productId);
19
+ if (active)
20
+ return { ok: false, reason: `该产品已有进行中的认证(${active.status})` };
21
+ db.prepare(`INSERT INTO product_verifications (id, product_id, seller_id, code, platform, status, created_at, updated_at)
22
+ VALUES (?,?,?,?,?, 'issued', datetime('now'), datetime('now'))`).run(id, productId, sellerId, code, platform);
23
+ return { ok: true, status: 'issued', code };
24
+ }
25
+ /** 卖家为【某产品】提交外部链接 → submitted。要求该产品有 issued 记录;链接仅存储(不抓取)。调用方须校验 seller 拥有该产品。 */
26
+ export function submitProductVerificationLink(db, args) {
27
+ const url = String(args.externalUrl || '').trim();
28
+ if (!isStorableHttpUrl(url))
29
+ return { ok: false, reason: '请提交有效的 http(s) 商品链接' };
30
+ const active = getActiveForProduct(db, args.productId);
31
+ if (!active)
32
+ return { ok: false, reason: '请先为该产品申请认证获取验证码' };
33
+ if (active.status === 'verified')
34
+ return { ok: false, reason: '该产品认证已通过,无需重复提交' };
35
+ const platform = args.platform != null ? String(args.platform).trim().slice(0, MAX_PLATFORM_LEN) : active.platform;
36
+ db.prepare(`UPDATE product_verifications SET external_url = ?, platform = ?, status = 'submitted', updated_at = datetime('now') WHERE id = ?`)
37
+ .run(url, platform, active.id);
38
+ return { ok: true, status: 'submitted' };
39
+ }
40
+ /** 真人 admin 手动核对结论 → verified | rejected。仅从 submitted 流转。reviewerId 必填(责任分层;ROOT/Passkey 由调用方强制)。 */
41
+ export function reviewProductVerification(db, args) {
42
+ const { id, reviewerId, decision } = args;
43
+ if (!reviewerId)
44
+ return { ok: false, reason: 'reviewProductVerification requires a human reviewerId' };
45
+ if (decision !== 'verified' && decision !== 'rejected')
46
+ return { ok: false, reason: 'decision must be verified|rejected' };
47
+ const row = db.prepare('SELECT * FROM product_verifications WHERE id = ?').get(id);
48
+ if (!row)
49
+ return { ok: false, reason: 'product verification not found' };
50
+ if (row.status === decision)
51
+ return { ok: true, status: decision, already: true };
52
+ if (row.status !== 'submitted')
53
+ return { ok: false, reason: `cannot review from status '${row.status}' (need submitted)` };
54
+ const notes = args.notes != null ? String(args.notes).slice(0, MAX_NOTES_LEN) : null;
55
+ db.prepare(`UPDATE product_verifications SET status = ?, reviewed_by = ?, reviewed_at = datetime('now'), notes = ?, updated_at = datetime('now') WHERE id = ?`)
56
+ .run(decision, reviewerId, notes, id);
57
+ return { ok: true, status: decision };
58
+ }
59
+ /** 某产品最新一条认证记录(任意状态)。无 → null。 */
60
+ export function getProductVerification(db, productId) {
61
+ return db.prepare('SELECT * FROM product_verifications WHERE product_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1').get(productId) ?? null;
62
+ }
63
+ /** 某卖家所有产品认证记录(供卖家自助面板逐产品展示状态)。最新在前。 */
64
+ export function listSellerProductVerifications(db, sellerId) {
65
+ return db.prepare('SELECT * FROM product_verifications WHERE seller_id = ? ORDER BY created_at DESC, rowid DESC').all(sellerId);
66
+ }
67
+ export function toSellerProductVerificationView(row) {
68
+ return {
69
+ id: row.id, product_id: row.product_id, code: row.code, platform: row.platform, external_url: row.external_url,
70
+ status: row.status, reviewed_at: row.reviewed_at, created_at: row.created_at, updated_at: row.updated_at,
71
+ }; // 故意省略 reviewed_by + notes(admin 身份 / 内部备注)
72
+ }
73
+ /** admin 队列:按 status 过滤(默认全部),最新在前。纯读。 */
74
+ export function listProductVerifications(db, opts = {}) {
75
+ if (opts.status)
76
+ return db.prepare(`SELECT * FROM product_verifications WHERE status = ? ORDER BY created_at DESC, rowid DESC`).all(opts.status);
77
+ return db.prepare(`SELECT * FROM product_verifications ORDER BY created_at DESC, rowid DESC`).all();
78
+ }
79
+ /** 硬门读取:该产品是否【已验证】(有 verified 记录)。供 direct-pay create/availability 强制(未验证 → 不可直付)。 */
80
+ export function productStoreVerified(db, productId) {
81
+ return !!db.prepare("SELECT 1 FROM product_verifications WHERE product_id = ? AND status = 'verified' LIMIT 1").get(productId);
82
+ }
83
+ /**
84
+ * 反作弊生命周期:商品发生【重大编辑】(标题/价格/详情/外部链接)时作废其验证 → status='stale'。
85
+ * 之后 productStoreVerified=false(硬门重新拦截),且 active 记录被清(卖家可重新申领→提交→admin 重核)。
86
+ * 防"先把商品 A 验证通过,再改标题/价格/详情/链接变成商品 B"绕过逐品门。返回作废条数。纯写,只改本表。
87
+ * 注:store-level 豁免卖家不依赖逐品验证,本作废对其直付资格无影响。
88
+ */
89
+ export function invalidateProductVerification(db, productId) {
90
+ const r = db.prepare(`UPDATE product_verifications SET status = 'stale', updated_at = datetime('now')
91
+ WHERE product_id = ? AND status IN ('issued','submitted','verified')`).run(productId);
92
+ return { invalidated: r.changes };
93
+ }
@@ -1,3 +1,4 @@
1
+ import { displayCurrency } from '../currency.js'; // agent-facing 币种统一 WAZ,遗留 'DCP' 读时归一化(绝不外泄 DCP)
1
2
  const BASE = 'https://webaz.xyz';
2
3
  // ACP availability 枚举(spec):in_stock | out_of_stock | pre_order | backorder | unknown
3
4
  function availability(stock) {
@@ -55,7 +56,7 @@ export function buildAcpProductFeed(db, opts = {}) {
55
56
  description: plainText(p.description, 5000), // spec: plain text, max 5000
56
57
  url: `${BASE}/#order-product/${p.id}`, // 商品详情页(SPA hash,200)
57
58
  // price: spec = Number + ISO 4217 code。WAZ 非 ISO 4217 → 见 feed 级 _disclosures.currency
58
- price: { amount: Number(p.price), currency: p.currency || 'WAZ' },
59
+ price: { amount: Number(p.price), currency: displayCurrency(p.currency) },
59
60
  availability: availability(p.stock),
60
61
  is_digital: p.product_type === 'digital',
61
62
  seller_name: (p.seller_name || p.seller_id).slice(0, 70),
@@ -106,7 +107,7 @@ export function buildAcpProductFeed(db, opts = {}) {
106
107
  },
107
108
  _disclosures: {
108
109
  phase: 'pre-launch',
109
- currency: "Each item's price.currency is the protocol's SIMULATED pre-launch internal unit (WAZ, or legacy 'DCP'), NOT an ISO 4217 fiat currency. No real money is involved.",
110
+ currency: "Each item's price.currency is the protocol's SIMULATED pre-launch internal unit, always emitted as WAZ (legacy internal-code rows are normalized to WAZ on read), NOT an ISO 4217 fiat currency. No real money is involved.",
110
111
  checkout: 'is_eligible_checkout is false for every item: ACP checkout is not yet wired. Products are DISCOVERABLE via ACP, not yet PURCHASABLE via ACP. Native WebAZ checkout + escrow + state machine govern real purchases (see RFC-015).',
111
112
  },
112
113
  product_count: products.length,
@@ -36,6 +36,9 @@ export const CONTRACT_CHANGES = [
36
36
  { contract_version: 3, date: '2026-06-09', surface: 'entity', kind: 'changed', summary: '§① order lifecycle: corrected declined_nofault state meaning text — it is NOT terminal (transitions declined_nofault→completed on settlement). Dropped the contradictory "(terminal)" label that conflicted with the auto-derived terminal:false. Semantics/state-machine unchanged; text-only clarification for agents reading the entity dictionary.' },
37
37
  { contract_version: 4, date: '2026-06-09', surface: 'capability', kind: 'changed', summary: '§② capability matrix: POST /api/reviews/:type/:id/claim now requires the new "review_claim" action scope instead of being SAFE (unscoped). The review-claim path locks a 5 WAZ stake (escrow), so it belongs under default-deny accountability like other value writes. GET reviews endpoints stay open.', migration: 'A declared agent that calls review claim must add the "review_claim" scope to its api_key (or hold a Passkey, or declare "*"). Passkey-bound humans and "*" agents are unaffected; GET reviews unchanged.' },
38
38
  { contract_version: 5, date: '2026-06-24', surface: 'integration', kind: 'added', summary: '§④ integration-contract entry (/.well-known/webaz-integration.json) gains an agent_quickstart block: a 60-second stranger-agent cold-start with discrete, machine-parseable fields (canonical_start_url, public_readonly_entrypoints, anonymous_allowed_actions, authenticated_required_actions, safe_next_actions, proposal_flow, contribution_boundary). Additive — no existing field changed; agents may ignore. NB: this surface is NOT covered by the §②/§① fingerprint, so it is registered here by hand.' },
39
+ { contract_version: 6, date: '2026-06-27', surface: 'entity', kind: 'added', summary: '§① order lifecycle gains two Direct Pay Rail 1 (non-custodial off-protocol payment) states: direct_pay_window (seller fee-stake locked, payment method shown, awaiting buyer off-protocol payment) and direct_expired_unconfirmed (payment window timed out without buyer confirmation — order is NOT silently closed; buyer retains a dispute/confirm window). Additive — existing order states/transitions unchanged; escrow orders never enter these states; agents may ignore unless they place direct_p2p orders.' },
40
+ { contract_version: 7, date: '2026-06-27', surface: 'capability', kind: 'added', summary: '§② capability matrix RESERVES a new "direct_pay" action scope for the future /api/direct-pay/* surface (Direct Pay Rail 1). SCAFFOLD ONLY: this revision ships the D1/D2 disclosure helper + append-only ack model and a Passkey RISK-guard helper for later wiring — NO production route is gated yet, and the current order-action paths (mark_paid / cancel / confirm / confirm-in-person) do NOT yet enforce the Passkey or the two-disclosure-ack gate. The classifier maps /api/direct-pay/* → direct_pay so that surface is RISK-scoped the moment routes are added. Additive — no existing action changed.', migration: 'No behavioural change yet — do NOT assume Direct Pay order actions are Passkey-gated or disclosure-gated at this revision, and do NOT assume agents are blocked from them. Real enforcement (Passkey + two-disclosure-ack on mark_paid/confirm/confirm-in-person) is wired in a later PR together with the create route, ack endpoints, and UI.' },
41
+ { contract_version: 8, date: '2026-07-02', surface: 'capability', kind: 'added', summary: 'Direct Pay Rail 1 (non-custodial) buyer account selection: POST /api/orders now accepts an OPTIONAL direct_receive_account_id — when a seller offers multiple receive accounts (GET /api/direct-receive/selectable-accounts, metadata-only), the buyer picks one and the create route snapshots THAT account. Omitted → unchanged dual-read fallback to the seller\'s single legacy payment instruction (no behaviour change for sellers with only that). The direct_p2p order gains a direct_pay_account_snapshot (non-sensitive metadata {account_id,method,currency,label,qr_ref} frozen at create); the raw instruction text stays in the existing disclosure-gated snapshot. New buyer read GET /api/orders/:id/direct-pay-qr serves the snapshotted receive-QR image bytes, gated to the order buyer AND both D1/D2 disclosure acks (non-enumerating 404 otherwise). Additive — this create-body field + order-metadata snapshot are NOT part of the §②/§① fingerprint projection, so registered here by hand; no existing action or order state changed.', migration: 'Agents placing direct_p2p orders MAY pass direct_receive_account_id (from selectable-accounts) to choose how they pay; omitting it preserves the prior single-instruction behaviour. The raw payment instruction and the QR image remain revealed only after both disclosure acks.' },
39
42
  ];
40
43
  export function buildChangeFeed() {
41
44
  return {
@@ -0,0 +1,20 @@
1
+ /**
2
+ * 直付 / 直接收款 RISK 门:
3
+ * ① 无 Passkey credential → 硬拒(agent 路径:agent 不可能持有 Passkey assertion → 永远过不了)。
4
+ * ② 需一次性真人 WebAuthn gate token(purpose-bound, 60s)。
5
+ * 两步都过才返回 ok。无自助批准路径。
6
+ */
7
+ export function requireDirectPayHumanPasskey(deps, args) {
8
+ const { db, consumeGateToken } = deps;
9
+ const { userId, webauthnToken, purpose, validate } = args;
10
+ // ① agent 硬拒:没有 Passkey 凭证的身份(agent)绝不能发起/批准直付
11
+ const hasPk = db.prepare('SELECT COUNT(*) AS n FROM webauthn_credentials WHERE user_id = ?').get(userId).n > 0;
12
+ if (!hasPk) {
13
+ return { ok: false, error_code: 'PASSKEY_REQUIRED_FOR_DIRECT_PAY', reason: '直付/直接收款是 RISK 操作:需绑定 Passkey 并真人二次确认,agent 不可代操作' };
14
+ }
15
+ // ② 一次性 purpose-bound 真人 WebAuthn gate token
16
+ const gate = consumeGateToken(userId, webauthnToken, purpose, validate ?? (() => true));
17
+ if (!gate.ok)
18
+ return { ok: false, error_code: 'HUMAN_PRESENCE_REQUIRED', reason: gate.reason || '需真实人工 WebAuthn 验证' };
19
+ return { ok: true };
20
+ }
@@ -0,0 +1,24 @@
1
+ import { requireBothDisclosuresAcked } from '../direct-pay-disclosures.js';
2
+ /** 买家自视角:未 both-acked 的 direct_p2p 订单,删收款目标(instruction 原文 + 账号快照 qr_ref)。就地改 o。 */
3
+ export function redactUnackedDirectPayTarget(db, o, userId) {
4
+ if (o.payment_rail !== 'direct_p2p' || o.buyer_id !== userId)
5
+ return;
6
+ if (requireBothDisclosuresAcked(db, o.id).ok)
7
+ return;
8
+ delete o.direct_pay_instruction_snapshot;
9
+ if (o.direct_pay_account_snapshot != null) {
10
+ try {
11
+ const s = JSON.parse(o.direct_pay_account_snapshot);
12
+ delete s.qr_ref;
13
+ o.direct_pay_account_snapshot = JSON.stringify(s);
14
+ }
15
+ catch {
16
+ delete o.direct_pay_account_snapshot;
17
+ }
18
+ }
19
+ }
20
+ /** 无条件删除收款目标(instruction 快照 + 整个账号快照)。用于非买家第三方 reader,他们绝不该看到收款目标。就地改 o。 */
21
+ export function stripDirectPayPaymentTarget(o) {
22
+ delete o.direct_pay_instruction_snapshot;
23
+ delete o.direct_pay_account_snapshot;
24
+ }
@@ -31,6 +31,9 @@ export const WRITE_RULES = [
31
31
  { method: 'POST', re: /^\/api\/skill-market\/[^/]+\/purchase/, action: 'purchase' },
32
32
  { method: 'POST', re: /^\/api\/secondhand\/[^/]+\/order/, action: 'buy_secondhand' },
33
33
  { method: 'POST', re: /^\/api\/group-buys\/[^/]+\/join/, action: 'group_buy_join' },
34
+ // Direct Pay (Rail 1) = RISK scope:为【未来】/api/direct-pay/* 写面【保留】'direct_pay' 分类(该面尚无路由)。
35
+ // ⚠️ SCAFFOLD:Passkey/两次披露门当前是 helper,【尚未】接到任何 handler;真实 enforcement 随 create-route/UI/ack 端点在后续 PR 落地。
36
+ { method: 'WRITE', re: /^\/api\/direct-pay\//, action: 'direct_pay' },
34
37
  // Codex #98 P1:review claim 锁 5 WAZ stake(扣 balance + escrowed)—— 资金写,绝不能落 SAFE,纳入 default-deny 问责门。
35
38
  // 只命中 .../:type/:id/claim;其余 reviews 写无规则 → 落通用 'write'(仍 default-deny),GET reviews 由 endpointToAction(GET) 返回 null。
36
39
  { method: 'POST', re: /^\/api\/reviews\/[^/]+\/[^/]+\/claim$/, action: 'review_claim' },
@@ -912,7 +912,7 @@ async function aiChatTurn(conversation, userText, attachments, onProgress) {
912
912
  conversation.messages.push({ role: 'user', content: toolResults })
913
913
  }
914
914
  await aiSaveConversation(conversation)
915
- return { content: [{ type: 'text', text: '⚠️ 达到工具调用上限,请重新提问或细化问题。' }] }
915
+ return { content: [{ type: 'text', text: t('⚠️ 达到工具调用上限,请重新提问或细化问题。') }] }
916
916
  }
917
917
 
918
918
  function aiCreateConversation() {
@@ -1096,7 +1096,7 @@ function renderAIStatusBar() {
1096
1096
  ${visionChip}
1097
1097
  <span style="font-weight:600;font-size:13px;color:#111827;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(provider.name)}</span>
1098
1098
  <span style="color:#d1d5db;font-size:11px;flex-shrink:0">·</span>
1099
- <span style="color:#6b7280;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(curModel?.label || modelId)}</span>
1099
+ <span style="color:#6b7280;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(t(curModel?.label || modelId))}</span>
1100
1100
  </div>
1101
1101
  ${aiTTS.supported() ? `
1102
1102
  <button onclick="aiToggleTTS()" title="${aiTTS.isEnabled() ? t('点击关闭朗读') : t('点击开启朗读 AI 回话')}"
@@ -1326,16 +1326,16 @@ async function renderAIDemo(app) {
1326
1326
  ${d.expectedSteps.map((s, j) => `
1327
1327
  <div style="display:flex;gap:8px;padding:4px 0;font-size:11px;color:#374151">
1328
1328
  <span style="color:#9ca3af;flex-shrink:0">${j+1}.</span>
1329
- <code style="background:#eef2ff;color:#3730a3;padding:1px 6px;border-radius:4px;font-size:10px;font-family:ui-monospace,Consolas,monospace;flex-shrink:0">${escHtml(s.tool)}</code>
1329
+ <code style="background:#eef2ff;color:#3730a3;padding:1px 6px;border-radius:4px;font-size:10px;font-family:ui-monospace,Consolas,monospace;flex-shrink:0">${escHtml(t(s.tool))}</code>
1330
1330
  <span style="color:#6b7280">${t(s.desc)}</span>
1331
1331
  </div>`).join('')}
1332
1332
  </div>
1333
1333
  </details>
1334
1334
  <div style="display:flex;gap:8px">
1335
- <button data-prompt="${escAttr(d.prompt)}" onclick="runAIDemo(this.dataset.prompt)" style="flex:1;padding:11px;background:#007aff;color:#fff;border:none;border-radius:10px;font-size:14px;font-weight:600;cursor:pointer">${t('一键运行')} →</button>
1335
+ <button data-prompt="${escAttr(t(d.prompt))}" onclick="runAIDemo(this.dataset.prompt)" style="flex:1;padding:11px;background:#007aff;color:#fff;border:none;border-radius:10px;font-size:14px;font-weight:600;cursor:pointer">${t('一键运行')} →</button>
1336
1336
  </div>
1337
1337
  <div style="margin-top:8px;font-size:10px;color:#9ca3af;padding:6px 8px;background:#f9fafb;border-radius:6px;line-height:1.5">
1338
- 💬 ${t('提示原文')}:${escHtml(d.prompt.slice(0, 80))}${d.prompt.length > 80 ? '…' : ''}
1338
+ 💬 ${t('提示原文')}:${escHtml(t(d.prompt).slice(0, 80))}${t(d.prompt).length > 80 ? '…' : ''}
1339
1339
  </div>
1340
1340
  </div>
1341
1341
  `).join('')
@@ -1488,7 +1488,7 @@ async function renderAIRecommend(app) {
1488
1488
  <!-- 快速模板(intent 空对话才显示)-->
1489
1489
  <div style="margin-top:8px;display:flex;gap:6px;overflow-x:auto;padding-bottom:2px;scrollbar-width:none">
1490
1490
  ${AI_QUICK_TEMPLATES.map(t2 => `
1491
- <button onclick="aiFillTemplate('${t2.prompt.replace(/'/g, "\\'")}')"
1491
+ <button onclick="aiFillTemplate('${t(t2.prompt).replace(/'/g, "\\'")}')"
1492
1492
  style="background:#fff;border:1px dashed #c7d2fe;border-radius:8px;padding:6px 10px;font-size:11px;color:#4338ca;cursor:pointer;white-space:nowrap;flex-shrink:0;font-weight:500">
1493
1493
  ${t2.icon} ${t(t2.label)}
1494
1494
  </button>`).join('')}
@@ -1959,8 +1959,8 @@ window.aiOpenProviderConfig = (pid) => {
1959
1959
  <div class="form-group">
1960
1960
  <label class="form-label">${t('协议格式')}</label>
1961
1961
  <select id="ai-pcfg-cformat" class="form-control" style="font-size:13px">
1962
- <option value="openai" ${cFormat==='openai'?'selected':''}>OpenAI 兼容 (chat/completions)</option>
1963
- <option value="anthropic" ${cFormat==='anthropic'?'selected':''}>Anthropic 兼容 (messages)</option>
1962
+ <option value="openai" ${cFormat==='openai'?'selected':''}>${t('OpenAI 兼容 (chat/completions)')}</option>
1963
+ <option value="anthropic" ${cFormat==='anthropic'?'selected':''}>${t('Anthropic 兼容 (messages)')}</option>
1964
1964
  </select>
1965
1965
  <div style="font-size:11px;color:#9ca3af;margin-top:4px">${t('多数自建 agent / 代理用 OpenAI 协议')}</div>
1966
1966
  </div>
@@ -1968,7 +1968,7 @@ window.aiOpenProviderConfig = (pid) => {
1968
1968
  <div class="form-group">
1969
1969
  <label class="form-label">${t('默认模型')}</label>
1970
1970
  <select id="ai-pcfg-model" class="form-control" style="font-size:13px">
1971
- ${p.models.map(m => `<option value="${m.id}" ${m.id === (localStorage.getItem('webaz_ai_model_' + p.id) || p.defaultModel) ? 'selected' : ''}>${escHtml(m.label)}</option>`).join('')}
1971
+ ${p.models.map(m => `<option value="${m.id}" ${m.id === (localStorage.getItem('webaz_ai_model_' + p.id) || p.defaultModel) ? 'selected' : ''}>${escHtml(t(m.label))}</option>`).join('')}
1972
1972
  </select>
1973
1973
  </div>
1974
1974
  `}
@@ -1991,7 +1991,7 @@ window.aiSaveProviderConfig = (pid, mode) => {
1991
1991
 
1992
1992
  if (isCustom) {
1993
1993
  // custom: name + model + format 都来自用户填的字段
1994
- const cname = document.getElementById('ai-pcfg-cname')?.value?.trim() || '我的 Agent'
1994
+ const cname = document.getElementById('ai-pcfg-cname')?.value?.trim() || t('我的 Agent')
1995
1995
  const cmodel = document.getElementById('ai-pcfg-cmodel')?.value?.trim()
1996
1996
  const clabel = document.getElementById('ai-pcfg-clabel')?.value?.trim()
1997
1997
  const cformat = document.getElementById('ai-pcfg-cformat')?.value || 'openai'
@@ -0,0 +1,29 @@
1
+ // 聊天详情实时轮询。renderChatDetail 只在进入/发消息时拉取消息(无实时推送),对方发来的消息原先停留会话时
2
+ // 看不到(只有通知那条独立轮询能提示)。这里每几秒拉一次会话,就地刷新 #chat-msgs:保持"已在底部则贴底"、
3
+ // 不碰输入框/快捷面板、查看时把新到消息标记已读。自清理:#chat-msgs 离开 DOM 或路由已不是本会话时立即停;
4
+ // 单计时器(window._chatPollTimer),重新进入任一会话先清旧的(不堆叠)。逻辑放独立文件因 app.js 已到 LOC 上限。
5
+ // ⚠️ 用【最后一条消息 id】判变化,不能用消息条数——后端默认只返回最近 50 条(滑动窗口),长会话里新消息进来后
6
+ // 条数仍是 50,用 length 比较会永远判"无变化"而不刷新/不标已读(长会话恰是最需要 live update 的场景)。
7
+ window.startChatPoll = (id, initialMsgs) => {
8
+ if (window._chatPollTimer) { clearInterval(window._chatPollTimer); window._chatPollTimer = null }
9
+ const lastOf = (arr) => (arr && arr.length) ? String(arr[arr.length - 1].id || arr[arr.length - 1].created_at || '') : ''
10
+ let last = lastOf(initialMsgs)
11
+ const stop = () => { if (window._chatPollTimer) { clearInterval(window._chatPollTimer); window._chatPollTimer = null } }
12
+ window._chatPollTimer = setInterval(async () => {
13
+ const box = document.getElementById('chat-msgs')
14
+ if (!box || !location.hash.includes('chat/' + id)) return stop() // 已离开本会话 → 自清理
15
+ let rr = null
16
+ try { rr = await GET('/conversations/' + encodeURIComponent(id)) } catch { return }
17
+ if (!rr || rr.error) return
18
+ const msgs = rr.messages || []
19
+ const newLast = lastOf(msgs)
20
+ if (newLast === last) return // 最后一条没变 → 无新消息
21
+ last = newLast
22
+ const atBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 40
23
+ box.innerHTML = msgs.length === 0
24
+ ? `<div style="text-align:center;color:#9ca3af;padding:30px 0;font-size:12px">${t('开始第一句问候')}</div>`
25
+ : msgs.map(m => window.renderChatBubble(m, state.user.id)).join('')
26
+ if (atBottom) box.scrollTop = box.scrollHeight // 原本贴底就继续贴底,否则不打扰阅读位置
27
+ POST('/conversations/' + encodeURIComponent(id) + '/read').catch(() => {}) // fire-and-forget:用 .catch 而非 try/catch(POST 返回 Promise,异步 reject 包不住)
28
+ }, 4000)
29
+ }
@@ -0,0 +1,17 @@
1
+ // Unified create entry — a small "全新商品 ⇄ 二手闲置" kind chooser shown at the top of BOTH create surfaces
2
+ // (the seller new-goods form and the secondhand publish page), so each create flow surfaces the other kind
3
+ // instead of being a dead-end. Mirrors UOMA's single Goods-incl.-secondhand create entry, WITHOUT merging the
4
+ // two backends: /api/products and /api/secondhand keep their own flows, fee rates and role rules.
5
+ // createKindChooserHtml(current): current ∈ {'new','secondhand'}. The active pill is inert; the other pill
6
+ // navigates — 'new' → goCreateListingFromBuy() (the smart seller entry: handles anon / buyer→seller
7
+ // upgrade / role switch), 'secondhand' → #secondhand/publish (open to any logged-in user).
8
+ window.createKindChooserHtml = (current) => {
9
+ const t = window.t || ((s) => s)
10
+ const pill = (active, label, onclick) => active
11
+ ? `<span style="flex:1;text-align:center;padding:7px 0;border-radius:8px;font-size:13px;font-weight:600;background:#111827;color:#fff">${label}</span>`
12
+ : `<button type="button" onclick="${onclick}" style="flex:1;text-align:center;padding:7px 0;border-radius:8px;font-size:13px;font-weight:500;background:#f3f4f6;color:#374151;border:1px solid #e5e7eb;cursor:pointer">${label}</button>`
13
+ return `<div style="display:flex;gap:8px;margin-bottom:16px">`
14
+ + pill(current === 'new', '🆕 ' + t('全新商品'), 'goCreateListingFromBuy()')
15
+ + pill(current === 'secondhand', '♻️ ' + t('二手闲置'), "location.hash='#secondhand/publish'")
16
+ + `</div>`
17
+ }
@@ -0,0 +1,141 @@
1
+ // Direct Pay (Rail 1) — 卖家多收款账号 + 收款二维码 管理 UI (Phase C2)。UI ONLY。
2
+ // 接 Phase C1 后端 /api/direct-receive/accounts(list / add / update / deactivate + /:id/qr upload·preview)。
3
+ // 写操作(add/update/deactivate/qr)均需现场真人 Passkey(purpose direct_receive_account_manage,
4
+ // action[+account_id] 绑 purpose_data);QR 预览走 Authorization header fetch→blob(<img src> 带不了 header)。
5
+ // WebAZ 只存储/展示卖家自填内容与二维码字节,绝不验证/路由/托管资金,也不解析二维码。双语 parity 由 test-direct-pay-ui.ts 守。
6
+
7
+ // 账号相关 error_code → 双语文案;未命中回落到 dpErrorText(直付通用码)。
8
+ window.draAccountErrorText = (code, fallback) => {
9
+ const M = {
10
+ SELLER_ONLY: t('仅卖家可管理收款账号'),
11
+ ACCOUNT_NOT_FOUND: t('账号不存在'),
12
+ ACCOUNT_INPUT_INVALID: t('账号信息不合法'),
13
+ QR_INVALID: t('二维码图片不合法(仅支持 PNG/WebP,解码后 ≤ 64KB)'),
14
+ HUMAN_PRESENCE_REQUIRED: t('需现场真人 Passkey 确认'),
15
+ PASSKEY_REQUIRED: t('需先注册 Passkey'),
16
+ }
17
+ return M[code] || (window.dpErrorText ? window.dpErrorText(code, fallback) : (fallback || t('操作失败,请重试')))
18
+ }
19
+
20
+ // ── 卖家设置:多收款账号面板(section + hydrate)──────────────────────────────────────────────
21
+ window.draAccountsSection = () => `
22
+ <div class="card" style="margin-bottom:12px">
23
+ <div style="font-size:14px;font-weight:700;margin-bottom:6px">🧾 ${t('直付收款账号')}</div>
24
+ <div style="font-size:12px;color:#6b7280;line-height:1.6;margin-bottom:10px">${t('你可维护多个收款账号(各自币种、可选二维码),买家直付时自选其一。WebAZ 只存储与展示,不验证付款方式或币种,不路由/托管资金,也不解析二维码。')}</div>
25
+ <div id="dra-accounts-box">${loading$()}</div>
26
+ </div>`
27
+
28
+ window.draHydrateAccounts = async () => {
29
+ const box = document.getElementById('dra-accounts-box')
30
+ if (!box) return
31
+ const r = await GET('/direct-receive/accounts')
32
+ if (r.error) { box.innerHTML = alert$('error', window.draAccountErrorText(r.error_code, r.error)); return }
33
+ const accounts = r.accounts || []
34
+ box.innerHTML = `
35
+ <div id="dra-msg"></div>
36
+ ${accounts.length ? accounts.map(a => window.draAccountCard(a)).join('') : `<div style="font-size:12px;color:#9ca3af;margin-bottom:10px">${t('尚未添加收款账号')}</div>`}
37
+ <details style="margin-top:8px"><summary style="font-size:13px;font-weight:600;color:#2563eb;cursor:pointer">+ ${t('新增收款账号')}</summary>
38
+ <div style="padding:8px 2px 2px">${window.draAccountForm('new')}</div></details>`
39
+ window.draLoadQrThumbs(accounts)
40
+ }
41
+
42
+ window.draAccountCard = (a) => {
43
+ const inactive = a.status !== 'active'
44
+ return `<div style="border:1px solid ${inactive ? '#e5e7eb' : '#d1fae5'};background:${inactive ? '#f9fafb' : '#f0fdf4'};border-radius:10px;padding:10px 12px;margin-bottom:8px;opacity:${inactive ? '0.6' : '1'}">
45
+ <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
46
+ <div style="min-width:0">
47
+ <div style="font-size:13px;font-weight:700;color:#111827">${a.label ? escHtml(a.label) : (a.method ? escHtml(a.method) : t('收款账号'))}${inactive ? ` · <span style="color:#9ca3af;font-weight:400">${t('已停用')}</span>` : ''}</div>
48
+ <div style="font-size:11px;color:#6b7280;margin:2px 0 4px">${a.method ? escHtml(a.method) : ''}${a.method && a.currency ? ' · ' : ''}${a.currency ? escHtml(a.currency) : ''}</div>
49
+ <div style="font-size:12px;color:#374151;white-space:pre-wrap;word-break:break-word">${escHtml(a.instruction)}</div>
50
+ </div>
51
+ <div id="dra-qr-${a.id}" style="flex:0 0 auto;width:72px;height:72px;display:flex;align-items:center;justify-content:center;border:1px dashed #d1d5db;border-radius:8px;font-size:10px;color:#9ca3af;text-align:center">${a.qr_image_ref ? loading$() : t('无二维码')}</div>
52
+ </div>
53
+ ${inactive ? '' : `<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px;align-items:center">
54
+ <details style="flex:0 0 auto"><summary style="font-size:12px;color:#2563eb;cursor:pointer">${t('编辑')}</summary><div style="padding:8px 2px 2px">${window.draAccountForm(a.id, a)}</div></details>
55
+ <label class="btn btn-outline btn-sm" style="cursor:pointer;margin:0">${a.qr_image_ref ? t('更换二维码') : t('上传二维码')}<input type="file" accept="image/png,image/webp" style="display:none" onchange="draUploadQr('${a.id}', this)"></label>
56
+ <button class="btn btn-outline btn-sm" style="color:#dc2626;border-color:#dc2626" onclick="draDeactivateAccount('${a.id}')">${t('停用')}</button>
57
+ </div>`}
58
+ </div>`
59
+ }
60
+
61
+ // 文本字段表单(id='new' 为新增;否则为该账号编辑)。QR 不在此处改 —— 由文件上传单独走 /:id/qr。
62
+ window.draAccountForm = (id, a) => {
63
+ const p = id === 'new' ? 'new' : id
64
+ return `
65
+ <div class="form-group"><label class="form-label">${t('收款说明')} <span style="font-size:11px;color:#9ca3af">${t('(展示给买家,如 PayNow / 银行转账 等)')}</span></label>
66
+ <textarea class="form-control" id="dra-instr-${p}" rows="3" maxlength="500" placeholder="${t('例:PayNow +65 9xxx(场外结算)')}">${a ? escHtml(a.instruction) : ''}</textarea></div>
67
+ <div style="display:flex;gap:8px">
68
+ <div class="form-group" style="flex:1"><label class="form-label">${t('收款方式')}</label><input class="form-control" id="dra-method-${p}" maxlength="40" value="${a && a.method ? escHtml(a.method) : ''}" placeholder="${t('如 PayNow')}"></div>
69
+ <div class="form-group" style="flex:1"><label class="form-label">${t('币种')}</label><input class="form-control" id="dra-currency-${p}" maxlength="8" value="${a && a.currency ? escHtml(a.currency) : ''}" placeholder="${t('如 SGD / USDC')}"></div>
70
+ </div>
71
+ <div class="form-group"><label class="form-label">${t('标签(可选)')}</label><input class="form-control" id="dra-label-${p}" maxlength="40" value="${a && a.label ? escHtml(a.label) : ''}" placeholder="${t('如 PayNow')}"></div>
72
+ <button class="btn btn-primary btn-sm" onclick="${id === 'new' ? 'draAddAccount()' : `draUpdateAccount('${id}')`}">${t('保存')}</button>`
73
+ }
74
+
75
+ window.draReadForm = (p) => ({
76
+ instruction: document.getElementById(`dra-instr-${p}`)?.value?.trim() || '',
77
+ method: document.getElementById(`dra-method-${p}`)?.value?.trim() || '',
78
+ currency: document.getElementById(`dra-currency-${p}`)?.value?.trim() || '',
79
+ label: document.getElementById(`dra-label-${p}`)?.value?.trim() || '',
80
+ })
81
+
82
+ // 现场真人 Passkey token;失败(无 Passkey / 取消 / 不支持)→ 提示注册入口并返回 null。
83
+ window.draGate = async (action, accountId) => {
84
+ try { return await requestPasskeyGate('direct_receive_account_manage', accountId ? { action, account_id: accountId } : { action }) }
85
+ catch (e) { if (window.dpPromptRegisterPasskey) await window.dpPromptRegisterPasskey(e && e.message); return null }
86
+ }
87
+
88
+ window.draAddAccount = async () => {
89
+ const f = window.draReadForm('new')
90
+ if (!f.instruction) { if (typeof toast$ === 'function') toast$(t('收款说明不能为空'), 'error'); return }
91
+ const token = await window.draGate('add'); if (!token) return
92
+ const r = await POST('/direct-receive/accounts', { ...f, webauthn_token: token })
93
+ if (r.error) { if (typeof toast$ === 'function') toast$(window.draAccountErrorText(r.error_code, r.error), 'error'); return }
94
+ if (typeof toast$ === 'function') toast$(t('已保存'), 'success'); window.draHydrateAccounts()
95
+ }
96
+
97
+ window.draUpdateAccount = async (id) => {
98
+ const f = window.draReadForm(id)
99
+ if (!f.instruction) { if (typeof toast$ === 'function') toast$(t('收款说明不能为空'), 'error'); return }
100
+ const token = await window.draGate('update', id); if (!token) return
101
+ const r = await PUT('/direct-receive/accounts/' + id, { ...f, webauthn_token: token })
102
+ if (r.error) { if (typeof toast$ === 'function') toast$(window.draAccountErrorText(r.error_code, r.error), 'error'); return }
103
+ if (typeof toast$ === 'function') toast$(t('已保存'), 'success'); window.draHydrateAccounts()
104
+ }
105
+
106
+ window.draDeactivateAccount = async (id) => {
107
+ const go = await confirmModal(t('停用后买家将无法选择该收款账号,确定停用?'), t('停用'), { danger: true })
108
+ if (!go) return
109
+ const token = await window.draGate('deactivate', id); if (!token) return
110
+ const r = await api('DELETE', '/direct-receive/accounts/' + id, { webauthn_token: token })
111
+ if (r.error) { if (typeof toast$ === 'function') toast$(window.draAccountErrorText(r.error_code, r.error), 'error'); return }
112
+ if (typeof toast$ === 'function') toast$(t('已停用'), 'success'); window.draHydrateAccounts()
113
+ }
114
+
115
+ // 上传/更换二维码:客户端先卡类型(png|webp)+大小(≤64KB)→ 读为 data URI → Passkey → PUT。后端仍严格复核。
116
+ window.draUploadQr = async (id, input) => {
117
+ const file = input && input.files && input.files[0]; if (input) input.value = ''
118
+ if (!file) return
119
+ if (file.type !== 'image/png' && file.type !== 'image/webp') { if (typeof toast$ === 'function') toast$(t('二维码仅支持 PNG 或 WebP 图片'), 'error'); return }
120
+ if (file.size > 64 * 1024) { if (typeof toast$ === 'function') toast$(t('二维码图片过大(需 ≤ 64KB)'), 'error'); return }
121
+ let dataUri
122
+ try { dataUri = await new Promise((res, rej) => { const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = rej; fr.readAsDataURL(file) }) }
123
+ catch { if (typeof toast$ === 'function') toast$(t('二维码读取失败'), 'error'); return }
124
+ const token = await window.draGate('qr', id); if (!token) return
125
+ const r = await PUT('/direct-receive/accounts/' + id + '/qr', { qr_data_uri: dataUri, webauthn_token: token })
126
+ if (r.error) { if (typeof toast$ === 'function') toast$(window.draAccountErrorText(r.error_code, r.error), 'error'); return }
127
+ if (typeof toast$ === 'function') toast$(t('二维码已上传'), 'success'); window.draHydrateAccounts()
128
+ }
129
+
130
+ // 二维码缩略图:owner-only 端点需 Authorization header,<img src> 带不了 → fetch→blob→objectURL。
131
+ window.draLoadQrThumbs = (accounts) => {
132
+ (accounts || []).filter(a => a.qr_image_ref).forEach(a => setTimeout(async () => {
133
+ const el = document.getElementById('dra-qr-' + a.id); if (!el) return
134
+ try {
135
+ const resp = await fetch('/api/direct-receive/accounts/' + a.id + '/qr', { headers: { Authorization: 'Bearer ' + state.apiKey } })
136
+ if (!resp.ok) { el.textContent = '❌'; return }
137
+ const url = URL.createObjectURL(await resp.blob())
138
+ el.innerHTML = `<img src="${url}" alt="${t('收款二维码')}" style="width:72px;height:72px;object-fit:contain;border-radius:6px">`
139
+ } catch { el.textContent = '❌' }
140
+ }, 0))
141
+ }
@@ -0,0 +1,72 @@
1
+ // Direct Pay (Rail 1) — 买家侧收款账号选择 + 分账号 FX + ack 门后收款码展示 (Phase D3)。UI ONLY。
2
+ // 接 D1(GET /api/direct-receive/selectable-accounts 只读元数据)+ D2(建单带 direct_receive_account_id、
3
+ // GET /api/orders/:id/direct-pay-qr ack 门后取图)。选账号在【下单前】(与建单契约一致);收款目标(instruction 原文 +
4
+ // QR 图)仍只在 D1/D2 both-acked 后经订单快照 / QR 端点展示。WebAZ 不验证/路由/托管,FX 纯展示。双语 parity 由 test-direct-pay-ui.ts 守。
5
+
6
+ // USDC 金额 → 任意币种展示串(用于"卖家按 <币种> 收款 ≈ X");无 rate/币种不支持 → 仅币种码。
7
+ window.dpFxInCurrency = (usdc, currency) => {
8
+ const cur = String(currency || '').toUpperCase()
9
+ const r = window._fxRates
10
+ const n = Number(usdc)
11
+ const sym = { CNY: '¥', EUR: '€', INR: '₹', SGD: 'S$', USD: '$', IDR: 'Rp', MYR: 'RM', PHP: '₱', VND: '₫', THB: '฿' }
12
+ if (!cur) return ''
13
+ if (cur === 'USDC' || cur === 'USD') return Number.isFinite(n) ? `${sym.USD}${n >= 100 ? Math.round(n) : n.toFixed(2)}` : ''
14
+ const rate = r && r.rates ? Number(r.rates[cur]) : NaN
15
+ if (!(rate > 0) || !Number.isFinite(n)) return cur // 币种不在 FX 表:只显示币种码,不臆造换算
16
+ const local = n * rate
17
+ return `${sym[cur] || (cur + ' ')}${local >= 100 ? Math.round(local) : local.toFixed(2)}`
18
+ }
19
+
20
+ // 选直付且可用时加载卖家可选收款账号(元数据 only)。0 个 → 清空(legacy 单条说明,无需选);≥1 → 单选列表(默认首个)。
21
+ window.dpLoadBuyerAccounts = async (productId) => {
22
+ const box = document.getElementById('dp-account-picker')
23
+ if (!box) return
24
+ let r
25
+ try { r = await GET('/direct-receive/selectable-accounts?product_id=' + encodeURIComponent(productId || '')) } catch { r = null }
26
+ const opts = (r && r.options) || []
27
+ if (!opts.length) { box.innerHTML = ''; return } // legacy-only 卖家:无多账号,走单条说明,不显示选择器
28
+ box.innerHTML = `
29
+ <div style="font-size:12px;font-weight:600;color:#374151;margin:8px 0 4px">${t('选择卖家收款方式')}</div>
30
+ ${opts.map((o, i) => {
31
+ const title = o.label ? escHtml(o.label) : (o.method ? escHtml(o.method) : t('收款账号'))
32
+ const sub = [o.method ? escHtml(o.method) : '', o.currency ? escHtml(o.currency) : ''].filter(Boolean).join(' · ')
33
+ return `<label style="display:flex;gap:8px;align-items:flex-start;font-size:13px;padding:6px 2px;cursor:pointer">
34
+ <input type="radio" name="dp-account" value="${escHtml(o.account_id)}" ${i === 0 ? 'checked' : ''}>
35
+ <span><b>${title}</b>${sub ? ` <span style="color:#6b7280;font-size:11px">${sub}</span>` : ''}<span data-dp-fx-cur="${o.currency ? escHtml(o.currency) : ''}" style="display:block;color:#6b7280;font-size:11px"></span></span>
36
+ </label>`
37
+ }).join('')}
38
+ <div style="font-size:11px;color:#9ca3af;margin-top:2px">${t('收款明细与二维码将在完成风险确认后显示')}</div>`
39
+ window.dpRenderAccountFx() // 按【当前订单总额 = 单价 × 数量】渲染分账号 FX(数量后续变化由 qtyStep/qtyClamp 再刷新)
40
+ }
41
+
42
+ // 当前直付订单总额(USDC)= 单价(data-amt)×【当前数量】(#inp-qty);直付买家场外付这个数,绝不能只按单价显示。
43
+ window.dpAccountTotalUsdc = () => {
44
+ const unit = Number(document.getElementById('dp-rail-block')?.getAttribute('data-amt') || '')
45
+ const q = document.getElementById('inp-qty')
46
+ const qty = q ? Math.max(1, Math.floor(Number(q.value) || 1)) : 1
47
+ return Number.isFinite(unit) ? unit * qty : NaN
48
+ }
49
+ // 用【当前订单总额】渲染/刷新分账号 FX 金额(数量改变后由 qtyStep/qtyClamp 调用)。无该币种汇率 → dpFxInCurrency 回落币种码。
50
+ window.dpRenderAccountFx = () => {
51
+ const total = window.dpAccountTotalUsdc()
52
+ document.querySelectorAll('#dp-account-picker [data-dp-fx-cur]').forEach((el) => {
53
+ const cur = el.getAttribute('data-dp-fx-cur')
54
+ const fx = (cur && Number.isFinite(total)) ? window.dpFxInCurrency(total, cur) : ''
55
+ el.innerHTML = fx ? `${t('卖家按此收款')} ≈ ${fx}` : ''
56
+ })
57
+ }
58
+
59
+ // 当前选中的收款账号 id(供建单带上);无选择器(legacy)/未选 → '' → 建单省略该字段,后端回落单条说明。
60
+ window.dpSelectedAccountId = () => document.querySelector('input[name="dp-account"]:checked')?.value || ''
61
+
62
+ // ack 门后:取订单收款二维码(owner+both-acked 才 200;Authorization header 走 fetch→blob,<img src> 带不了头)。
63
+ window.dpLoadOrderQr = async (orderId) => {
64
+ const box = document.getElementById('dp-order-qr')
65
+ if (!box) return
66
+ try {
67
+ const resp = await fetch('/api/orders/' + encodeURIComponent(orderId) + '/direct-pay-qr', { headers: { Authorization: 'Bearer ' + (window.state && window.state.apiKey) } })
68
+ if (!resp.ok) { box.innerHTML = ''; return } // 无 QR / 未 ack:静默不显(instruction 文本已单独展示)
69
+ const url = URL.createObjectURL(await resp.blob())
70
+ box.innerHTML = `<div style="font-size:11px;color:#9ca3af;margin:6px 0 2px">${t('收款二维码')}</div><img src="${url}" alt="${t('收款二维码')}" style="width:150px;height:150px;object-fit:contain;border:1px solid #e5e7eb;border-radius:8px">`
71
+ } catch { box.innerHTML = '' }
72
+ }
@@ -0,0 +1,67 @@
1
+ // Direct Pay (Rail 1) — ADMIN 商户合规录入 (PR-⑧)。UI ONLY。
2
+ // 录入【每个卖家】的 KYB 复核结论 + 制裁筛查结论(直付入场硬门:必须 KYB approved + sanctions clear)。
3
+ // 后端已有受门控端点(admin-direct-receive-deposits):POST /api/admin/direct-receive/kyb-reviews · /sanctions-screenings,
4
+ // 均 ROOT + 真人 Passkey(purpose direct_pay_kyb_ingress / direct_pay_sanctions_ingress;purpose_data 绑
5
+ // user_id+status+provider_ref+expires_at,签 A 写 B 拒)。本屏只接线,不碰资金/状态机。
6
+ // ⚠️ 仅【记录你已实际完成的尽调结论】——不代替真实 KYB/制裁筛查。
7
+
8
+ window.renderAdminDirectReceiveCompliance = async function (app) {
9
+ if (!state.user) { renderLogin(); return }
10
+ if (!isAdmin()) { app.innerHTML = shell(`<div class="alert alert-info">${t('仅限管理员')}</div>`, 'admin'); return }
11
+ const sel = (id, opts) => `<select id="${id}" style="width:100%;padding:7px;border:1px solid #d1d5db;border-radius:6px;font-size:13px">${opts.map(o => `<option value="${o[0]}">${o[1]}</option>`).join('')}</select>`
12
+ const inp = (id, ph) => `<input id="${id}" placeholder="${ph}" style="width:100%;padding:7px;border:1px solid #d1d5db;border-radius:6px;font-size:13px">`
13
+ app.innerHTML = shell(`
14
+ <h1 class="page-title">🧾 ${t('商户合规录入')}</h1>
15
+ <div style="margin-bottom:12px"><button class="btn btn-outline btn-sm" style="width:auto" onclick="navigate('#admin')">${t('返回概览')}</button></div>
16
+ <div style="font-size:12px;color:#6b7280;line-height:1.6;margin-bottom:6px">${t('录入卖家的 KYB / 制裁筛查结论(直付入场硬门)。仅记录你【已实际完成】的尽调结论,不代替真实筛查;均需真人 Passkey。')}</div>
17
+ <div style="font-size:12px;color:#b45309;line-height:1.6;margin-bottom:10px">${t('⚠️ 外部审核编号只填外部审核系统的 case id;切勿填写身份证/护照/住址/证件文件链接 —— 该字段会【明文入库】。')}</div>
18
+ <div class="card" style="margin-bottom:12px;border:1px solid #e5e7eb">
19
+ <div style="font-size:13px;font-weight:700;margin-bottom:8px">${t('卖家用户 ID')}</div>
20
+ ${inp('cmp-user', t('卖家用户 ID(seller user id)'))}
21
+ </div>
22
+ <div class="card" style="margin-bottom:12px;border:1px solid #e5e7eb">
23
+ <div style="font-size:13px;font-weight:700;margin-bottom:8px">🪪 ${t('KYB 复核结论')}</div>
24
+ <div style="display:flex;flex-direction:column;gap:6px">
25
+ ${sel('cmp-kyb-status', [['approved', t('通过 approved')], ['pending', t('待定 pending')], ['rejected', t('拒绝 rejected')], ['revoked', t('撤销 revoked')]])}
26
+ ${inp('cmp-kyb-ref', t('外部审核编号 / vendor case id(选填)'))}
27
+ ${inp('cmp-kyb-exp', t('有效期 expires_at(选填,如 2027-01-01)'))}
28
+ <button class="btn btn-primary btn-sm" style="align-self:flex-start;font-size:12px" onclick="window.doIngestKyb()">${t('记录 KYB(真人 Passkey)')}</button>
29
+ </div>
30
+ </div>
31
+ <div class="card" style="margin-bottom:12px;border:1px solid #e5e7eb">
32
+ <div style="font-size:13px;font-weight:700;margin-bottom:8px">🛡️ ${t('制裁筛查结论')}</div>
33
+ <div style="display:flex;flex-direction:column;gap:6px">
34
+ ${sel('cmp-sanc-status', [['clear', t('通过 clear')], ['flagged', t('命中 flagged')], ['blocked', t('阻断 blocked')]])}
35
+ ${inp('cmp-sanc-ref', t('外部审核编号 / vendor case id(选填)'))}
36
+ ${inp('cmp-sanc-exp', t('有效期 expires_at(选填)'))}
37
+ <button class="btn btn-primary btn-sm" style="align-self:flex-start;font-size:12px" onclick="window.doIngestSanctions()">${t('记录制裁筛查(真人 Passkey)')}</button>
38
+ </div>
39
+ </div>
40
+ <div style="font-size:12px;color:#6b7280">${t('录入后用就绪报告核对:')} <code>npm run direct-pay:readiness</code></div>
41
+ `, 'admin')
42
+ }
43
+
44
+ // 通用:取卖家 id + 组装 body(provider_ref/expires_at 仅在非空时带);purpose_data 必须与请求体逐字一致。
45
+ function _dpComplianceBody(statusId, refId, expId) {
46
+ const get = (id) => { const e = document.getElementById(id); return e ? e.value.trim() : '' }
47
+ const user_id = get('cmp-user')
48
+ if (!user_id) return null
49
+ const body = { user_id, status: get(statusId) }
50
+ const ref = get(refId), exp = get(expId)
51
+ if (ref) body.provider_ref = ref
52
+ if (exp) body.expires_at = exp
53
+ return body
54
+ }
55
+
56
+ async function _dpComplianceIngest(purpose, path, statusId, refId, expId, okMsg) {
57
+ const body = _dpComplianceBody(statusId, refId, expId)
58
+ if (!body) { if (typeof toast$ === 'function') toast$(t('请填写卖家用户 ID'), 'error'); return }
59
+ const token = await requestPasskeyGate(purpose, body) // purpose_data = body(逐字绑定)
60
+ if (!token) { if (typeof toast$ === 'function') toast$(t('需要真人 Passkey 确认'), 'error'); return }
61
+ const r = await POST(path, { ...body, webauthn_token: token })
62
+ if (!r || r.error) { if (typeof toast$ === 'function') toast$(window.dpErrorText ? window.dpErrorText(r && r.error_code, r && r.error) : t('操作失败,请重试'), 'error'); return }
63
+ if (typeof toast$ === 'function') toast$(okMsg)
64
+ }
65
+
66
+ window.doIngestKyb = () => _dpComplianceIngest('direct_pay_kyb_ingress', '/admin/direct-receive/kyb-reviews', 'cmp-kyb-status', 'cmp-kyb-ref', 'cmp-kyb-exp', t('KYB 结论已记录'))
67
+ window.doIngestSanctions = () => _dpComplianceIngest('direct_pay_sanctions_ingress', '/admin/direct-receive/sanctions-screenings', 'cmp-sanc-status', 'cmp-sanc-ref', 'cmp-sanc-exp', t('制裁筛查结论已记录'))