cli-claw-kit 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +245 -0
- package/config/default-groups.json +1 -0
- package/config/global-agents-md.template.md +37 -0
- package/config/mount-allowlist.json +11 -0
- package/container/Dockerfile +160 -0
- package/container/agent-runner/dist/.tsbuildinfo +1 -0
- package/container/agent-runner/dist/agent-definitions.js +22 -0
- package/container/agent-runner/dist/channel-prefixes.js +16 -0
- package/container/agent-runner/dist/codex-config.js +29 -0
- package/container/agent-runner/dist/image-detector.js +96 -0
- package/container/agent-runner/dist/index.js +2587 -0
- package/container/agent-runner/dist/mcp-tools.js +1076 -0
- package/container/agent-runner/dist/stream-event.types.js +5 -0
- package/container/agent-runner/dist/stream-processor.js +867 -0
- package/container/agent-runner/dist/types.js +6 -0
- package/container/agent-runner/dist/utils.js +115 -0
- package/container/agent-runner/package.json +36 -0
- package/container/agent-runner/prompts/security-rules.md +31 -0
- package/container/agent-runner/src/agent-definitions.ts +27 -0
- package/container/agent-runner/src/channel-prefixes.ts +16 -0
- package/container/agent-runner/src/codex-config.ts +40 -0
- package/container/agent-runner/src/image-detector.ts +116 -0
- package/container/agent-runner/src/index.ts +3107 -0
- package/container/agent-runner/src/mcp-tools.ts +1295 -0
- package/container/agent-runner/src/stream-event.types.ts +10 -0
- package/container/agent-runner/src/stream-processor.ts +932 -0
- package/container/agent-runner/src/types.ts +75 -0
- package/container/agent-runner/src/utils.ts +114 -0
- package/container/agent-runner/tsconfig.json +17 -0
- package/container/build.sh +28 -0
- package/container/entrypoint.sh +64 -0
- package/container/skills/agent-browser/SKILL.md +159 -0
- package/container/skills/install-skill/SKILL.md +64 -0
- package/container/skills/post-test-cleanup/SKILL.md +121 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/agent-output-parser.js +459 -0
- package/dist/app-root.js +52 -0
- package/dist/assistant-meta-footer.js +1 -0
- package/dist/auth.js +91 -0
- package/dist/billing.js +694 -0
- package/dist/channel-prefixes.js +16 -0
- package/dist/cli.js +86 -0
- package/dist/commands.js +79 -0
- package/dist/config.js +120 -0
- package/dist/container-runner.js +981 -0
- package/dist/daily-summary.js +210 -0
- package/dist/db.js +3683 -0
- package/dist/dingtalk.js +1347 -0
- package/dist/feishu-markdown-style.js +97 -0
- package/dist/feishu-streaming-card.js +1875 -0
- package/dist/feishu.js +1628 -0
- package/dist/file-manager.js +270 -0
- package/dist/group-queue.js +1070 -0
- package/dist/group-runtime.js +35 -0
- package/dist/host-workspace-cwd.js +85 -0
- package/dist/im-channel.js +384 -0
- package/dist/im-command-utils.js +142 -0
- package/dist/im-downloader.js +45 -0
- package/dist/im-manager.js +527 -0
- package/dist/im-utils.js +53 -0
- package/dist/image-detector.js +96 -0
- package/dist/index.js +5828 -0
- package/dist/logger.js +22 -0
- package/dist/mcp-utils.js +66 -0
- package/dist/message-attachments.js +69 -0
- package/dist/message-notifier.js +36 -0
- package/dist/middleware/auth.js +85 -0
- package/dist/mount-security.js +315 -0
- package/dist/permissions.js +67 -0
- package/dist/project-memory.js +6 -0
- package/dist/provider-pool.js +189 -0
- package/dist/qq.js +826 -0
- package/dist/reset-admin.js +42 -0
- package/dist/routes/admin.js +543 -0
- package/dist/routes/agent-definitions.js +241 -0
- package/dist/routes/agents.js +533 -0
- package/dist/routes/auth.js +675 -0
- package/dist/routes/billing.js +490 -0
- package/dist/routes/browse.js +210 -0
- package/dist/routes/bug-report.js +387 -0
- package/dist/routes/config.js +1868 -0
- package/dist/routes/files.js +671 -0
- package/dist/routes/groups.js +1367 -0
- package/dist/routes/mcp-servers.js +320 -0
- package/dist/routes/memory.js +523 -0
- package/dist/routes/monitor.js +307 -0
- package/dist/routes/skills.js +777 -0
- package/dist/routes/tasks.js +509 -0
- package/dist/routes/usage.js +64 -0
- package/dist/routes/workspace-config.js +458 -0
- package/dist/runtime-build.js +112 -0
- package/dist/runtime-command-handler.js +189 -0
- package/dist/runtime-command-registry.js +1 -0
- package/dist/runtime-config.js +1777 -0
- package/dist/runtime-identity.js +52 -0
- package/dist/schemas.js +590 -0
- package/dist/script-runner.js +64 -0
- package/dist/sdk-query.js +82 -0
- package/dist/skill-utils.js +145 -0
- package/dist/sqlite-compat.js +19 -0
- package/dist/stream-event.types.js +5 -0
- package/dist/streaming-runtime-meta.js +29 -0
- package/dist/task-scheduler.js +695 -0
- package/dist/task-utils.js +13 -0
- package/dist/telegram-pairing.js +59 -0
- package/dist/telegram.js +897 -0
- package/dist/terminal-manager.js +307 -0
- package/dist/tool-step-display.js +1 -0
- package/dist/types.js +1 -0
- package/dist/utils.js +85 -0
- package/dist/web-context.js +161 -0
- package/dist/web.js +1377 -0
- package/dist/wechat-crypto.js +182 -0
- package/dist/wechat.js +589 -0
- package/dist/workspace-runtime-reset.js +35 -0
- package/package.json +107 -0
- package/shared/assistant-meta-footer.ts +127 -0
- package/shared/channel-prefixes.ts +16 -0
- package/shared/dist/assistant-meta-footer.d.ts +29 -0
- package/shared/dist/assistant-meta-footer.js +85 -0
- package/shared/dist/channel-prefixes.d.ts +4 -0
- package/shared/dist/channel-prefixes.js +16 -0
- package/shared/dist/image-detector.d.ts +20 -0
- package/shared/dist/image-detector.js +96 -0
- package/shared/dist/runtime-command-registry.d.ts +38 -0
- package/shared/dist/runtime-command-registry.js +185 -0
- package/shared/dist/stream-event.d.ts +65 -0
- package/shared/dist/stream-event.js +8 -0
- package/shared/dist/tool-step-display.d.ts +4 -0
- package/shared/dist/tool-step-display.js +11 -0
- package/shared/image-detector.ts +116 -0
- package/shared/runtime-command-registry.ts +252 -0
- package/shared/stream-event.ts +67 -0
- package/shared/tool-step-display.ts +21 -0
- package/shared/tsconfig.json +24 -0
- package/web/dist/assets/BillingPage-B1wBR_o-.js +52 -0
- package/web/dist/assets/ChatPage-6GBZ9nXN.css +32 -0
- package/web/dist/assets/ChatPage-BOJcXtaj.js +161 -0
- package/web/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/web/dist/assets/SettingsPage-DoY7FoZ_.js +153 -0
- package/web/dist/assets/ShareImageDialog-C1ga8b7l.js +22 -0
- package/web/dist/assets/TasksPage-CRivnNsx.js +14 -0
- package/web/dist/assets/_basePickBy-Bf-bSoS9.js +1 -0
- package/web/dist/assets/_baseUniq-zAOaCuKw.js +1 -0
- package/web/dist/assets/arc-Dm9mVQ9U.js +1 -0
- package/web/dist/assets/architectureDiagram-2XIMDMQ5-BLmzX1wr.js +36 -0
- package/web/dist/assets/band-CquvqAHh.js +1 -0
- package/web/dist/assets/blockDiagram-WCTKOSBZ-B9pcqm3j.js +132 -0
- package/web/dist/assets/c4Diagram-IC4MRINW-Cytx1q3b.js +10 -0
- package/web/dist/assets/channel-BOVj73LR.js +1 -0
- package/web/dist/assets/channel-meta-CQD0Pei-.js +41 -0
- package/web/dist/assets/chunk-4BX2VUAB-0ToDr6RE.js +1 -0
- package/web/dist/assets/chunk-55IACEB6-DQDjnXfS.js +1 -0
- package/web/dist/assets/chunk-FMBD7UC4-Di8ABm6c.js +15 -0
- package/web/dist/assets/chunk-JSJVCQXG-BZQN6rnX.js +1 -0
- package/web/dist/assets/chunk-KX2RTZJC-zBbcpaN_.js +1 -0
- package/web/dist/assets/chunk-NQ4KR5QH-BCrLoU88.js +220 -0
- package/web/dist/assets/chunk-QZHKN3VN-Bqk8juan.js +1 -0
- package/web/dist/assets/chunk-WL4C6EOR-D2YX-MHY.js +189 -0
- package/web/dist/assets/classDiagram-VBA2DB6C-DUUoMyaK.js +1 -0
- package/web/dist/assets/classDiagram-v2-RAHNMMFH-DUUoMyaK.js +1 -0
- package/web/dist/assets/clone-BmaCesfa.js +1 -0
- package/web/dist/assets/cose-bilkent-S5V4N54A-CTsv6qQA.js +1 -0
- package/web/dist/assets/cytoscape.esm-BQaXIfA_.js +331 -0
- package/web/dist/assets/dagre-KLK3FWXG-Ci4Jh9nu.js +4 -0
- package/web/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/web/dist/assets/diagram-E7M64L7V-BFRnfTI2.js +24 -0
- package/web/dist/assets/diagram-IFDJBPK2-B7Zhnp0b.js +43 -0
- package/web/dist/assets/diagram-P4PSJMXO-BVyP7nwq.js +24 -0
- package/web/dist/assets/erDiagram-INFDFZHY-NorKdTOF.js +70 -0
- package/web/dist/assets/error-CGD5mp5f.js +1 -0
- package/web/dist/assets/flowDiagram-PKNHOUZH-Ch97nABF.js +162 -0
- package/web/dist/assets/ganttDiagram-A5KZAMGK-BQ2pLWsy.js +292 -0
- package/web/dist/assets/gitGraphDiagram-K3NZZRJ6-bcvnBsD2.js +65 -0
- package/web/dist/assets/graph-CeAEckur.js +1 -0
- package/web/dist/assets/index-CPnL1_qC.js +768 -0
- package/web/dist/assets/index-DVevCbcO.css +10 -0
- package/web/dist/assets/infoDiagram-LFFYTUFH-CcsrFdj-.js +2 -0
- package/web/dist/assets/init-Dmth1JHB.js +1 -0
- package/web/dist/assets/ishikawaDiagram-PHBUUO56-1upyMfHN.js +70 -0
- package/web/dist/assets/journeyDiagram-4ABVD52K-CKUi-V0c.js +139 -0
- package/web/dist/assets/kanban-definition-K7BYSVSG-DOnQwXfL.js +89 -0
- package/web/dist/assets/layout-BmMMqTnJ.js +1 -0
- package/web/dist/assets/linear-DiaJloY5.js +1 -0
- package/web/dist/assets/mermaid.core-BWLV1B2v.js +254 -0
- package/web/dist/assets/mindmap-definition-YRQLILUH-BeAKHVWP.js +68 -0
- package/web/dist/assets/ordinal-DILIJJjt.js +1 -0
- package/web/dist/assets/pieDiagram-SKSYHLDU-DfiMSfWo.js +30 -0
- package/web/dist/assets/quadrantDiagram-337W2JSQ-wZxZOJxd.js +7 -0
- package/web/dist/assets/requirementDiagram-Z7DCOOCP-BK4HHm17.js +73 -0
- package/web/dist/assets/sankeyDiagram-WA2Y5GQK-BX6t2avX.js +10 -0
- package/web/dist/assets/sequenceDiagram-2WXFIKYE-BPQlkbAa.js +145 -0
- package/web/dist/assets/sheet-rI0FfB1g.js +6 -0
- package/web/dist/assets/sliders-horizontal-CuijWFNK.js +6 -0
- package/web/dist/assets/sparkles-BsMYXJoT.js +11 -0
- package/web/dist/assets/square-0CqMX1Q3.js +11 -0
- package/web/dist/assets/stateDiagram-RAJIS63D-DxkV0Vwd.js +1 -0
- package/web/dist/assets/stateDiagram-v2-FVOUBMTO-qLYoiOPe.js +1 -0
- package/web/dist/assets/step-D51IIHGA.js +1 -0
- package/web/dist/assets/tasks-D8JjBTwx.js +1 -0
- package/web/dist/assets/time-O8zIGux3.js +1 -0
- package/web/dist/assets/timeline-definition-YZTLITO2-kNp1DyFc.js +61 -0
- package/web/dist/assets/treemap-KZPCXAKY-CkrClVhk.js +162 -0
- package/web/dist/assets/utils-KGAn0XTg.js +11 -0
- package/web/dist/assets/vennDiagram-LZ73GAT5-CgdzEZz4.js +34 -0
- package/web/dist/assets/xychartDiagram-JWTSCODW-DfYGPfNB.js +7 -0
- package/web/dist/assets/zap-_hKJYy7J.js +6 -0
- package/web/dist/favicon.svg +332 -0
- package/web/dist/fonts/AlibabaPuHuiTi-3-55-Regular.woff2 +0 -0
- package/web/dist/fonts/AlibabaPuHuiTi-3-65-Medium.woff2 +0 -0
- package/web/dist/fonts/AlibabaPuHuiTi-3-75-SemiBold.woff2 +0 -0
- package/web/dist/fonts/DMSans-latin-ext.woff2 +0 -0
- package/web/dist/fonts/DMSans-latin.woff2 +0 -0
- package/web/dist/icons/README.md +20 -0
- package/web/dist/icons/apple-touch-icon-180.png +0 -0
- package/web/dist/icons/icon-128.png +0 -0
- package/web/dist/icons/icon-144.png +0 -0
- package/web/dist/icons/icon-152.png +0 -0
- package/web/dist/icons/icon-192.png +0 -0
- package/web/dist/icons/icon-192.svg +332 -0
- package/web/dist/icons/icon-384.png +0 -0
- package/web/dist/icons/icon-48.png +0 -0
- package/web/dist/icons/icon-512-maskable.png +0 -0
- package/web/dist/icons/icon-512.png +0 -0
- package/web/dist/icons/icon-512.svg +332 -0
- package/web/dist/icons/icon-72.png +0 -0
- package/web/dist/icons/icon-96.png +0 -0
- package/web/dist/icons/loading-logo.svg +332 -0
- package/web/dist/icons/logo-1024.png +0 -0
- package/web/dist/icons/logo-icon.svg +332 -0
- package/web/dist/icons/logo-text.svg +332 -0
- package/web/dist/index.html +30 -0
- package/web/dist/manifest.webmanifest +1 -0
- package/web/dist/registerSW.js +1 -0
- package/web/dist/sw.js +1 -0
- package/web/dist/workbox-08d6266a.js +1 -0
package/dist/billing.js
ADDED
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core billing logic: plan management, balance, quota checks, redeem codes, monthly aggregation.
|
|
3
|
+
*/
|
|
4
|
+
import crypto from 'crypto';
|
|
5
|
+
import { batchAssignPlan as dbBatchAssignPlan, getBillingPlan, getDefaultBillingPlan, getUserById, getUserActiveSubscription, createUserSubscription, cancelUserSubscription as dbCancelSubscription, getUserBalance, adjustUserBalance, getMonthlyUsage, incrementMonthlyUsage, incrementDailyUsage, getDailyUsage, getWeeklyUsageSummary, getUserGroupCount, logBillingAudit, getRedeemCode, hasUserRedeemedCode, tryIncrementRedeemCodeUsage, expireSubscriptions, getDailyUsageSumForMonth, correctMonthlyUsage, } from './db.js';
|
|
6
|
+
import { getSystemSettings } from './runtime-config.js';
|
|
7
|
+
import { logger } from './logger.js';
|
|
8
|
+
// --- Billing enabled check ---
|
|
9
|
+
// Delegates to getSystemSettings() which already has mtime-based file caching.
|
|
10
|
+
// No extra manual cache needed — avoids stale state when settings change.
|
|
11
|
+
export function isBillingEnabled() {
|
|
12
|
+
return getSystemSettings().billingEnabled === true;
|
|
13
|
+
}
|
|
14
|
+
/** @deprecated No longer needed — isBillingEnabled reads from getSystemSettings cache */
|
|
15
|
+
export function clearBillingEnabledCache() {
|
|
16
|
+
// no-op: getSystemSettings() handles its own cache invalidation via file mtime
|
|
17
|
+
}
|
|
18
|
+
// --- Plan management ---
|
|
19
|
+
export function getUserEffectivePlan(userId) {
|
|
20
|
+
const sub = getUserActiveSubscription(userId);
|
|
21
|
+
if (sub)
|
|
22
|
+
return { plan: sub.plan, subscription: sub };
|
|
23
|
+
// Fallback to default plan
|
|
24
|
+
const defaultPlan = getDefaultBillingPlan();
|
|
25
|
+
if (!defaultPlan)
|
|
26
|
+
return null;
|
|
27
|
+
return {
|
|
28
|
+
plan: defaultPlan,
|
|
29
|
+
subscription: {
|
|
30
|
+
id: `fallback_${userId}`,
|
|
31
|
+
user_id: userId,
|
|
32
|
+
plan_id: defaultPlan.id,
|
|
33
|
+
status: 'active',
|
|
34
|
+
started_at: new Date().toISOString(),
|
|
35
|
+
expires_at: null,
|
|
36
|
+
cancelled_at: null,
|
|
37
|
+
auto_renew: false,
|
|
38
|
+
created_at: new Date().toISOString(),
|
|
39
|
+
trial_ends_at: null,
|
|
40
|
+
notes: null,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export function assignPlan(userId, planId, actorId, durationDays, opts) {
|
|
45
|
+
const plan = getBillingPlan(planId);
|
|
46
|
+
if (!plan)
|
|
47
|
+
throw new Error(`Plan not found: ${planId}`);
|
|
48
|
+
const now = new Date();
|
|
49
|
+
const expiresAt = durationDays
|
|
50
|
+
? new Date(now.getTime() + durationDays * 24 * 60 * 60 * 1000).toISOString()
|
|
51
|
+
: null;
|
|
52
|
+
const trialDays = opts?.trialDays ?? plan.trial_days;
|
|
53
|
+
const trialEndsAt = trialDays
|
|
54
|
+
? new Date(now.getTime() + trialDays * 24 * 60 * 60 * 1000).toISOString()
|
|
55
|
+
: null;
|
|
56
|
+
const sub = {
|
|
57
|
+
id: `sub_${userId}_${Date.now()}`,
|
|
58
|
+
user_id: userId,
|
|
59
|
+
plan_id: planId,
|
|
60
|
+
status: 'active',
|
|
61
|
+
started_at: now.toISOString(),
|
|
62
|
+
expires_at: expiresAt,
|
|
63
|
+
cancelled_at: null,
|
|
64
|
+
trial_ends_at: trialEndsAt,
|
|
65
|
+
notes: opts?.notes ?? null,
|
|
66
|
+
auto_renew: opts?.autoRenew ?? false,
|
|
67
|
+
created_at: now.toISOString(),
|
|
68
|
+
};
|
|
69
|
+
createUserSubscription(sub);
|
|
70
|
+
invalidateUserBillingCache(userId);
|
|
71
|
+
logBillingAudit('subscription_assigned', userId, actorId, {
|
|
72
|
+
planId,
|
|
73
|
+
planName: plan.name,
|
|
74
|
+
durationDays: durationDays ?? null,
|
|
75
|
+
trialDays: trialDays ?? null,
|
|
76
|
+
expiresAt,
|
|
77
|
+
autoRenew: sub.auto_renew,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
export function cancelSubscription(userId, actorId) {
|
|
81
|
+
dbCancelSubscription(userId);
|
|
82
|
+
invalidateUserBillingCache(userId);
|
|
83
|
+
logBillingAudit('subscription_cancelled', userId, actorId, {});
|
|
84
|
+
}
|
|
85
|
+
// --- Quota check (core path) ---
|
|
86
|
+
// In-memory LRU cache for quota checks (30s TTL, max 500 entries)
|
|
87
|
+
const QUOTA_CACHE_MAX = 500;
|
|
88
|
+
const QUOTA_CACHE_TTL = 30_000;
|
|
89
|
+
const _quotaCache = new Map();
|
|
90
|
+
const _accessCache = new Map();
|
|
91
|
+
function _quotaCacheSet(key, result) {
|
|
92
|
+
// Delete first to re-insert at end (Map insertion order = LRU)
|
|
93
|
+
_quotaCache.delete(key);
|
|
94
|
+
_quotaCache.set(key, { result, expires: Date.now() + QUOTA_CACHE_TTL });
|
|
95
|
+
// Evict oldest entries if over capacity
|
|
96
|
+
if (_quotaCache.size > QUOTA_CACHE_MAX) {
|
|
97
|
+
const first = _quotaCache.keys().next().value;
|
|
98
|
+
if (first !== undefined)
|
|
99
|
+
_quotaCache.delete(first);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function _accessCacheSet(key, result) {
|
|
103
|
+
_accessCache.delete(key);
|
|
104
|
+
_accessCache.set(key, { result, expires: Date.now() + QUOTA_CACHE_TTL });
|
|
105
|
+
if (_accessCache.size > QUOTA_CACHE_MAX) {
|
|
106
|
+
const first = _accessCache.keys().next().value;
|
|
107
|
+
if (first !== undefined)
|
|
108
|
+
_accessCache.delete(first);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
export function invalidateUserBillingCache(userId) {
|
|
112
|
+
_quotaCache.delete(userId);
|
|
113
|
+
_accessCache.delete(userId);
|
|
114
|
+
}
|
|
115
|
+
export function invalidateAllBillingCaches() {
|
|
116
|
+
_quotaCache.clear();
|
|
117
|
+
_accessCache.clear();
|
|
118
|
+
}
|
|
119
|
+
export function checkQuota(userId, userRole) {
|
|
120
|
+
// Admin bypasses all billing
|
|
121
|
+
if (userRole === 'admin') {
|
|
122
|
+
return { allowed: true };
|
|
123
|
+
}
|
|
124
|
+
// Billing disabled → allow all
|
|
125
|
+
if (!isBillingEnabled()) {
|
|
126
|
+
return { allowed: true };
|
|
127
|
+
}
|
|
128
|
+
// Check cache
|
|
129
|
+
const cached = _quotaCache.get(userId);
|
|
130
|
+
if (cached && cached.expires > Date.now()) {
|
|
131
|
+
return cached.result;
|
|
132
|
+
}
|
|
133
|
+
const result = _checkQuotaInternal(userId);
|
|
134
|
+
// Cache result
|
|
135
|
+
_quotaCacheSet(userId, result);
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
export function checkBillingAccess(userId, userRole) {
|
|
139
|
+
const cached = _accessCache.get(userId);
|
|
140
|
+
if (cached && cached.expires > Date.now()) {
|
|
141
|
+
return cached.result;
|
|
142
|
+
}
|
|
143
|
+
const result = _checkBillingAccessInternal(userId, userRole);
|
|
144
|
+
_accessCacheSet(userId, result);
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
export function checkBillingAccessFresh(userId, userRole) {
|
|
148
|
+
const result = _checkBillingAccessInternal(userId, userRole);
|
|
149
|
+
_accessCacheSet(userId, result);
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
function _checkBillingAccessInternal(userId, userRole) {
|
|
153
|
+
const balance = getUserBalance(userId);
|
|
154
|
+
const minBalanceUsd = getSystemSettings().billingMinStartBalanceUsd ?? 0.01;
|
|
155
|
+
if (userRole === 'admin' || !isBillingEnabled()) {
|
|
156
|
+
return {
|
|
157
|
+
allowed: true,
|
|
158
|
+
balanceUsd: balance.balance_usd,
|
|
159
|
+
minBalanceUsd,
|
|
160
|
+
planId: null,
|
|
161
|
+
planName: null,
|
|
162
|
+
subscriptionStatus: null,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
const effective = getUserEffectivePlan(userId);
|
|
166
|
+
if (!effective) {
|
|
167
|
+
return {
|
|
168
|
+
allowed: false,
|
|
169
|
+
blockType: 'plan_inactive',
|
|
170
|
+
reason: '未找到可用套餐,请联系管理员分配套餐',
|
|
171
|
+
balanceUsd: balance.balance_usd,
|
|
172
|
+
minBalanceUsd,
|
|
173
|
+
balanceMissingUsd: Math.max(minBalanceUsd - balance.balance_usd, 0),
|
|
174
|
+
planId: null,
|
|
175
|
+
planName: null,
|
|
176
|
+
subscriptionStatus: null,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
const realSubscription = getUserActiveSubscription(userId);
|
|
180
|
+
const subscriptionStatus = realSubscription?.status ?? 'default';
|
|
181
|
+
const quota = checkQuota(userId, userRole);
|
|
182
|
+
if (balance.balance_usd < minBalanceUsd) {
|
|
183
|
+
return {
|
|
184
|
+
allowed: false,
|
|
185
|
+
blockType: 'insufficient_balance',
|
|
186
|
+
reason: `余额不足,当前余额 $${balance.balance_usd.toFixed(2)},至少需要 $${minBalanceUsd.toFixed(2)} 才能开始使用`,
|
|
187
|
+
balanceUsd: balance.balance_usd,
|
|
188
|
+
minBalanceUsd,
|
|
189
|
+
balanceMissingUsd: Math.max(minBalanceUsd - balance.balance_usd, 0),
|
|
190
|
+
planId: effective.plan.id,
|
|
191
|
+
planName: effective.plan.name,
|
|
192
|
+
subscriptionStatus,
|
|
193
|
+
warningPercent: quota.warningPercent,
|
|
194
|
+
usage: quota.usage,
|
|
195
|
+
exceededWindow: quota.exceededWindow,
|
|
196
|
+
resetAt: quota.resetAt,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
if (!quota.allowed) {
|
|
200
|
+
return {
|
|
201
|
+
allowed: false,
|
|
202
|
+
blockType: 'quota_exceeded',
|
|
203
|
+
reason: quota.reason,
|
|
204
|
+
balanceUsd: balance.balance_usd,
|
|
205
|
+
minBalanceUsd,
|
|
206
|
+
planId: effective.plan.id,
|
|
207
|
+
planName: effective.plan.name,
|
|
208
|
+
subscriptionStatus,
|
|
209
|
+
warningPercent: quota.warningPercent,
|
|
210
|
+
usage: quota.usage,
|
|
211
|
+
exceededWindow: quota.exceededWindow,
|
|
212
|
+
resetAt: quota.resetAt,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
allowed: true,
|
|
217
|
+
balanceUsd: balance.balance_usd,
|
|
218
|
+
minBalanceUsd,
|
|
219
|
+
planId: effective.plan.id,
|
|
220
|
+
planName: effective.plan.name,
|
|
221
|
+
subscriptionStatus,
|
|
222
|
+
warningPercent: quota.warningPercent,
|
|
223
|
+
usage: quota.usage,
|
|
224
|
+
exceededWindow: quota.exceededWindow,
|
|
225
|
+
resetAt: quota.resetAt,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
export function formatBillingAccessDeniedMessage(accessResult) {
|
|
229
|
+
const reason = accessResult.reason || '当前账户不可用';
|
|
230
|
+
let resetHint = '';
|
|
231
|
+
if (accessResult.resetAt) {
|
|
232
|
+
const resetDate = new Date(accessResult.resetAt);
|
|
233
|
+
const diffMs = resetDate.getTime() - Date.now();
|
|
234
|
+
if (diffMs > 0) {
|
|
235
|
+
const hours = Math.ceil(diffMs / (1000 * 60 * 60));
|
|
236
|
+
resetHint =
|
|
237
|
+
hours >= 24
|
|
238
|
+
? `,约 ${Math.ceil(hours / 24)} 天后重置`
|
|
239
|
+
: `,约 ${hours} 小时后重置`;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const actionHint = accessResult.blockType === 'insufficient_balance'
|
|
243
|
+
? '请联系管理员充值余额后继续使用。'
|
|
244
|
+
: '请联系管理员调整套餐或额度后继续使用。';
|
|
245
|
+
return `⚠️ ${reason}${resetHint}。${actionHint}`;
|
|
246
|
+
}
|
|
247
|
+
function logAccessTransition(userId, actorId, before, after) {
|
|
248
|
+
if (before.allowed === after.allowed)
|
|
249
|
+
return;
|
|
250
|
+
if (!after.allowed && after.blockType === 'insufficient_balance') {
|
|
251
|
+
logBillingAudit('wallet_blocked', userId, actorId, {
|
|
252
|
+
balanceUsd: after.balanceUsd,
|
|
253
|
+
minBalanceUsd: after.minBalanceUsd,
|
|
254
|
+
reason: after.reason,
|
|
255
|
+
});
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (after.allowed) {
|
|
259
|
+
logBillingAudit('wallet_unblocked', userId, actorId, {
|
|
260
|
+
balanceUsd: after.balanceUsd,
|
|
261
|
+
minBalanceUsd: after.minBalanceUsd,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function _checkQuotaInternal(userId) {
|
|
266
|
+
const effective = getUserEffectivePlan(userId);
|
|
267
|
+
if (!effective) {
|
|
268
|
+
return {
|
|
269
|
+
allowed: false,
|
|
270
|
+
reason: '未找到有效套餐,请联系管理员',
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
const { plan } = effective;
|
|
274
|
+
const now = new Date();
|
|
275
|
+
const usageSnapshot = getQuotaUsageSnapshot(userId, plan, now);
|
|
276
|
+
const { dailyCost, dailyTokens, weeklyCost, weeklyTokens, monthlyCost, monthlyTokens, baseUsage, } = usageSnapshot;
|
|
277
|
+
// Helper to check a single window
|
|
278
|
+
const checkWindow = (costUsed, costQuota, tokenUsed, tokenQuota, windowName, resetAt) => {
|
|
279
|
+
const labels = { daily: '日度', weekly: '周度', monthly: '月度' };
|
|
280
|
+
if (costQuota != null && costUsed >= costQuota) {
|
|
281
|
+
return {
|
|
282
|
+
allowed: false,
|
|
283
|
+
reason: `${labels[windowName]}费用已达上限 $${costQuota.toFixed(2)}`,
|
|
284
|
+
exceededWindow: windowName,
|
|
285
|
+
resetAt,
|
|
286
|
+
warningPercent: 100,
|
|
287
|
+
usage: baseUsage,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
if (tokenQuota != null && tokenUsed >= tokenQuota) {
|
|
291
|
+
return {
|
|
292
|
+
allowed: false,
|
|
293
|
+
reason: `${labels[windowName]} Token 已达上限 ${tokenQuota.toLocaleString()}`,
|
|
294
|
+
exceededWindow: windowName,
|
|
295
|
+
resetAt,
|
|
296
|
+
warningPercent: 100,
|
|
297
|
+
usage: baseUsage,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
return null;
|
|
301
|
+
};
|
|
302
|
+
// Calculate reset times
|
|
303
|
+
// Check daily → weekly → monthly (first exceeded wins)
|
|
304
|
+
const dailyExceeded = checkWindow(dailyCost, plan.daily_cost_quota, dailyTokens, plan.daily_token_quota, 'daily', usageSnapshot.dailyResetAt);
|
|
305
|
+
if (dailyExceeded)
|
|
306
|
+
return dailyExceeded;
|
|
307
|
+
const weeklyExceeded = checkWindow(weeklyCost, plan.weekly_cost_quota, weeklyTokens, plan.weekly_token_quota, 'weekly', usageSnapshot.weeklyResetAt);
|
|
308
|
+
if (weeklyExceeded)
|
|
309
|
+
return weeklyExceeded;
|
|
310
|
+
const monthlyExceeded = checkWindow(monthlyCost, plan.monthly_cost_quota, monthlyTokens, plan.monthly_token_quota, 'monthly', usageSnapshot.monthlyResetAt);
|
|
311
|
+
if (monthlyExceeded)
|
|
312
|
+
return monthlyExceeded;
|
|
313
|
+
// Calculate warning percentage (highest of all windows)
|
|
314
|
+
let warningPercent;
|
|
315
|
+
const percents = [];
|
|
316
|
+
if (plan.monthly_cost_quota != null && plan.monthly_cost_quota > 0)
|
|
317
|
+
percents.push(Math.round((monthlyCost / plan.monthly_cost_quota) * 100));
|
|
318
|
+
if (plan.monthly_token_quota != null && plan.monthly_token_quota > 0)
|
|
319
|
+
percents.push(Math.round((monthlyTokens / plan.monthly_token_quota) * 100));
|
|
320
|
+
if (plan.daily_cost_quota != null && plan.daily_cost_quota > 0)
|
|
321
|
+
percents.push(Math.round((dailyCost / plan.daily_cost_quota) * 100));
|
|
322
|
+
if (plan.daily_token_quota != null && plan.daily_token_quota > 0)
|
|
323
|
+
percents.push(Math.round((dailyTokens / plan.daily_token_quota) * 100));
|
|
324
|
+
if (plan.weekly_cost_quota != null && plan.weekly_cost_quota > 0)
|
|
325
|
+
percents.push(Math.round((weeklyCost / plan.weekly_cost_quota) * 100));
|
|
326
|
+
if (plan.weekly_token_quota != null && plan.weekly_token_quota > 0)
|
|
327
|
+
percents.push(Math.round((weeklyTokens / plan.weekly_token_quota) * 100));
|
|
328
|
+
if (percents.length > 0)
|
|
329
|
+
warningPercent = Math.max(...percents);
|
|
330
|
+
return {
|
|
331
|
+
allowed: true,
|
|
332
|
+
warningPercent,
|
|
333
|
+
usage: baseUsage,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
// --- Resource limit checks ---
|
|
337
|
+
export function checkGroupLimit(userId, userRole) {
|
|
338
|
+
if (userRole === 'admin' || !isBillingEnabled())
|
|
339
|
+
return { allowed: true };
|
|
340
|
+
const effective = getUserEffectivePlan(userId);
|
|
341
|
+
if (!effective)
|
|
342
|
+
return { allowed: true };
|
|
343
|
+
const { plan } = effective;
|
|
344
|
+
if (plan.max_groups == null)
|
|
345
|
+
return { allowed: true };
|
|
346
|
+
const count = getUserGroupCount(userId);
|
|
347
|
+
if (count >= plan.max_groups) {
|
|
348
|
+
return {
|
|
349
|
+
allowed: false,
|
|
350
|
+
reason: `工作区数量已达套餐上限 (${plan.max_groups})`,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
return { allowed: true };
|
|
354
|
+
}
|
|
355
|
+
export function checkImChannelLimit(userId, userRole, currentEnabledCount) {
|
|
356
|
+
if (userRole === 'admin' || !isBillingEnabled())
|
|
357
|
+
return { allowed: true };
|
|
358
|
+
const effective = getUserEffectivePlan(userId);
|
|
359
|
+
if (!effective)
|
|
360
|
+
return { allowed: true };
|
|
361
|
+
const { plan } = effective;
|
|
362
|
+
if (plan.max_im_channels == null)
|
|
363
|
+
return { allowed: true };
|
|
364
|
+
if (currentEnabledCount >= plan.max_im_channels) {
|
|
365
|
+
return {
|
|
366
|
+
allowed: false,
|
|
367
|
+
reason: `IM 通道数已达套餐上限 (${plan.max_im_channels})`,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
return { allowed: true };
|
|
371
|
+
}
|
|
372
|
+
export function checkMcpServerLimit(userId, userRole, currentCount) {
|
|
373
|
+
if (userRole === 'admin' || !isBillingEnabled())
|
|
374
|
+
return { allowed: true };
|
|
375
|
+
const effective = getUserEffectivePlan(userId);
|
|
376
|
+
if (!effective)
|
|
377
|
+
return { allowed: true };
|
|
378
|
+
const { plan } = effective;
|
|
379
|
+
if (plan.max_mcp_servers == null)
|
|
380
|
+
return { allowed: true };
|
|
381
|
+
if (currentCount >= plan.max_mcp_servers) {
|
|
382
|
+
return {
|
|
383
|
+
allowed: false,
|
|
384
|
+
reason: `MCP Server 数已达套餐上限 (${plan.max_mcp_servers})`,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
return { allowed: true };
|
|
388
|
+
}
|
|
389
|
+
export function checkStorageLimit(userId, userRole, currentStorageBytes, additionalBytes) {
|
|
390
|
+
if (userRole === 'admin' || !isBillingEnabled())
|
|
391
|
+
return { allowed: true };
|
|
392
|
+
const effective = getUserEffectivePlan(userId);
|
|
393
|
+
if (!effective)
|
|
394
|
+
return { allowed: true };
|
|
395
|
+
const { plan } = effective;
|
|
396
|
+
if (plan.max_storage_mb == null)
|
|
397
|
+
return { allowed: true };
|
|
398
|
+
const limitBytes = plan.max_storage_mb * 1024 * 1024;
|
|
399
|
+
if (currentStorageBytes + additionalBytes > limitBytes) {
|
|
400
|
+
const usedMB = Math.round(currentStorageBytes / (1024 * 1024));
|
|
401
|
+
return {
|
|
402
|
+
allowed: false,
|
|
403
|
+
reason: `存储空间已达套餐上限 (${usedMB}MB / ${plan.max_storage_mb}MB)`,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
return { allowed: true };
|
|
407
|
+
}
|
|
408
|
+
export function getUserConcurrentContainerLimit(userId, userRole) {
|
|
409
|
+
if (userRole === 'admin' || !isBillingEnabled())
|
|
410
|
+
return null;
|
|
411
|
+
const effective = getUserEffectivePlan(userId);
|
|
412
|
+
if (!effective)
|
|
413
|
+
return null;
|
|
414
|
+
return effective.plan.max_concurrent_containers;
|
|
415
|
+
}
|
|
416
|
+
export function applyAdminBalanceAdjustment(userId, amountUSD, description, actorId, idempotencyKey) {
|
|
417
|
+
const user = getUserById(userId);
|
|
418
|
+
const userRole = user?.role ?? 'member';
|
|
419
|
+
const before = checkBillingAccess(userId, userRole);
|
|
420
|
+
const tx = adjustUserBalance(userId, amountUSD, amountUSD > 0 ? 'deposit' : 'adjustment', description, 'admin_adjust', null, actorId, idempotencyKey, {
|
|
421
|
+
source: amountUSD > 0 ? 'admin_manual_recharge' : 'admin_manual_deduct',
|
|
422
|
+
operatorType: 'admin',
|
|
423
|
+
notes: description,
|
|
424
|
+
allowNegative: false,
|
|
425
|
+
});
|
|
426
|
+
invalidateUserBillingCache(userId);
|
|
427
|
+
const after = checkBillingAccess(userId, userRole);
|
|
428
|
+
logBillingAudit(amountUSD > 0 ? 'manual_recharge' : 'manual_deduct', userId, actorId, {
|
|
429
|
+
amount: amountUSD,
|
|
430
|
+
description,
|
|
431
|
+
newBalance: tx.balance_after,
|
|
432
|
+
});
|
|
433
|
+
logAccessTransition(userId, actorId, before, after);
|
|
434
|
+
return tx;
|
|
435
|
+
}
|
|
436
|
+
export function batchAssignPlans(userIds, planId, actorId, durationDays) {
|
|
437
|
+
const count = dbBatchAssignPlan(userIds, planId, actorId, durationDays);
|
|
438
|
+
for (const userId of userIds)
|
|
439
|
+
invalidateUserBillingCache(userId);
|
|
440
|
+
return count;
|
|
441
|
+
}
|
|
442
|
+
// --- Usage tracking ---
|
|
443
|
+
export function updateUsage(userId, costUSD, inputTokens, outputTokens) {
|
|
444
|
+
// Apply rate_multiplier
|
|
445
|
+
const effective = getUserEffectivePlan(userId);
|
|
446
|
+
const multiplier = effective?.plan.rate_multiplier ?? 1.0;
|
|
447
|
+
const effectiveCost = costUSD * multiplier;
|
|
448
|
+
const now = new Date();
|
|
449
|
+
const month = now.toISOString().slice(0, 7);
|
|
450
|
+
const date = now.toISOString().slice(0, 10);
|
|
451
|
+
incrementMonthlyUsage(userId, month, inputTokens, outputTokens, effectiveCost);
|
|
452
|
+
incrementDailyUsage(userId, date, inputTokens, outputTokens, effectiveCost);
|
|
453
|
+
// Invalidate quota cache
|
|
454
|
+
invalidateUserBillingCache(userId);
|
|
455
|
+
return effective;
|
|
456
|
+
}
|
|
457
|
+
export function deductUsageCost(userId, costUSD, msgId, cachedEffective) {
|
|
458
|
+
if (!isBillingEnabled() || costUSD <= 0)
|
|
459
|
+
return;
|
|
460
|
+
const effective = cachedEffective ?? getUserEffectivePlan(userId);
|
|
461
|
+
if (!effective)
|
|
462
|
+
return;
|
|
463
|
+
const { plan } = effective;
|
|
464
|
+
const effectiveCost = costUSD * (plan.rate_multiplier ?? 1.0);
|
|
465
|
+
const user = getUserById(userId);
|
|
466
|
+
const userRole = user?.role ?? 'member';
|
|
467
|
+
if (userRole === 'admin')
|
|
468
|
+
return;
|
|
469
|
+
const before = checkBillingAccess(userId, userRole);
|
|
470
|
+
adjustUserBalance(userId, -effectiveCost, 'deduction', 'AI 调用消费扣费', 'message', msgId, null, msgId ? `usage_${msgId}` : null, {
|
|
471
|
+
source: 'usage_charge',
|
|
472
|
+
operatorType: 'system',
|
|
473
|
+
notes: `消息消费扣费: ${msgId}`,
|
|
474
|
+
allowNegative: true,
|
|
475
|
+
});
|
|
476
|
+
invalidateUserBillingCache(userId);
|
|
477
|
+
const after = checkBillingAccess(userId, userRole);
|
|
478
|
+
logBillingAudit('balance_deducted', userId, null, {
|
|
479
|
+
amount: effectiveCost,
|
|
480
|
+
messageId: msgId,
|
|
481
|
+
balanceUsd: after.balanceUsd,
|
|
482
|
+
});
|
|
483
|
+
logAccessTransition(userId, null, before, after);
|
|
484
|
+
}
|
|
485
|
+
// --- Redeem codes ---
|
|
486
|
+
export function redeemCode(userId, code) {
|
|
487
|
+
const rc = getRedeemCode(code);
|
|
488
|
+
if (!rc) {
|
|
489
|
+
return { success: false, message: '兑换码不存在' };
|
|
490
|
+
}
|
|
491
|
+
// Check expiry
|
|
492
|
+
if (rc.expires_at && new Date(rc.expires_at) < new Date()) {
|
|
493
|
+
return { success: false, message: '兑换码已过期' };
|
|
494
|
+
}
|
|
495
|
+
// Check usage limit
|
|
496
|
+
if (rc.used_count >= rc.max_uses) {
|
|
497
|
+
return { success: false, message: '兑换码已达使用上限' };
|
|
498
|
+
}
|
|
499
|
+
// Check if user already redeemed
|
|
500
|
+
if (hasUserRedeemedCode(userId, code)) {
|
|
501
|
+
return { success: false, message: '您已使用过此兑换码' };
|
|
502
|
+
}
|
|
503
|
+
// Pre-validate code data before consuming usage (no rollback on failure)
|
|
504
|
+
if (rc.type === 'balance' && (rc.value_usd ?? 0) <= 0) {
|
|
505
|
+
return { success: false, message: '兑换码金额无效' };
|
|
506
|
+
}
|
|
507
|
+
if (rc.type === 'subscription') {
|
|
508
|
+
if (!rc.plan_id)
|
|
509
|
+
return { success: false, message: '兑换码配置错误(无套餐)' };
|
|
510
|
+
if (!getBillingPlan(rc.plan_id))
|
|
511
|
+
return { success: false, message: '兑换码关联的套餐不存在' };
|
|
512
|
+
}
|
|
513
|
+
// Optimistic lock: try to increment usage count atomically
|
|
514
|
+
if (!tryIncrementRedeemCodeUsage(code, userId)) {
|
|
515
|
+
return { success: false, message: '兑换码已达使用上限' };
|
|
516
|
+
}
|
|
517
|
+
// Apply redeem code
|
|
518
|
+
if (rc.type === 'balance') {
|
|
519
|
+
const amount = rc.value_usd;
|
|
520
|
+
adjustUserBalance(userId, amount, 'redeem', `兑换码充值: ${code}`, 'redeem_code', code, null, `redeem_${code}_${userId}`, {
|
|
521
|
+
source: 'redeem_code',
|
|
522
|
+
operatorType: 'user',
|
|
523
|
+
notes: `兑换码充值: ${code}`,
|
|
524
|
+
allowNegative: false,
|
|
525
|
+
});
|
|
526
|
+
invalidateUserBillingCache(userId);
|
|
527
|
+
logBillingAudit('code_redeemed', userId, null, {
|
|
528
|
+
code,
|
|
529
|
+
type: 'balance',
|
|
530
|
+
amount,
|
|
531
|
+
});
|
|
532
|
+
return {
|
|
533
|
+
success: true,
|
|
534
|
+
message: `成功充值 $${amount.toFixed(2)}`,
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
if (rc.type === 'subscription') {
|
|
538
|
+
const planId = rc.plan_id;
|
|
539
|
+
const plan = getBillingPlan(planId);
|
|
540
|
+
assignPlan(userId, planId, userId, rc.duration_days ?? undefined);
|
|
541
|
+
logBillingAudit('code_redeemed', userId, null, {
|
|
542
|
+
code,
|
|
543
|
+
type: 'subscription',
|
|
544
|
+
planId,
|
|
545
|
+
planName: plan.name,
|
|
546
|
+
durationDays: rc.duration_days,
|
|
547
|
+
});
|
|
548
|
+
return {
|
|
549
|
+
success: true,
|
|
550
|
+
message: `成功激活套餐「${plan.name}」`,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
if (rc.type === 'trial') {
|
|
554
|
+
const days = rc.duration_days ?? 7;
|
|
555
|
+
if (!redeemTrial(userId, days)) {
|
|
556
|
+
return { success: false, message: '无法激活试用(未找到可用套餐)' };
|
|
557
|
+
}
|
|
558
|
+
logBillingAudit('code_redeemed', userId, null, {
|
|
559
|
+
code,
|
|
560
|
+
type: 'trial',
|
|
561
|
+
trialDays: days,
|
|
562
|
+
});
|
|
563
|
+
return {
|
|
564
|
+
success: true,
|
|
565
|
+
message: `成功激活 ${days} 天试用`,
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
return { success: false, message: '未知兑换码类型' };
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Extend or create a trial period for a user's current subscription.
|
|
572
|
+
*/
|
|
573
|
+
export function redeemTrial(userId, days) {
|
|
574
|
+
const effective = getUserEffectivePlan(userId);
|
|
575
|
+
if (!effective)
|
|
576
|
+
return false;
|
|
577
|
+
const now = new Date();
|
|
578
|
+
const currentTrialEnd = effective.subscription.trial_ends_at
|
|
579
|
+
? new Date(effective.subscription.trial_ends_at)
|
|
580
|
+
: now;
|
|
581
|
+
const base = currentTrialEnd > now ? currentTrialEnd : now;
|
|
582
|
+
const newTrialEnd = new Date(base.getTime() + days * 24 * 60 * 60 * 1000);
|
|
583
|
+
// Re-assign the same plan with extended trial
|
|
584
|
+
assignPlan(userId, effective.plan.id, userId, undefined, {
|
|
585
|
+
trialDays: Math.ceil((newTrialEnd.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)),
|
|
586
|
+
notes: `试用延长 ${days} 天`,
|
|
587
|
+
});
|
|
588
|
+
return true;
|
|
589
|
+
}
|
|
590
|
+
// --- Generate redeem codes ---
|
|
591
|
+
export function generateRedeemCode(length = 16) {
|
|
592
|
+
return crypto
|
|
593
|
+
.randomBytes(length)
|
|
594
|
+
.toString('base64url')
|
|
595
|
+
.slice(0, length)
|
|
596
|
+
.toUpperCase();
|
|
597
|
+
}
|
|
598
|
+
// --- Periodic tasks ---
|
|
599
|
+
export function checkAndExpireSubscriptions() {
|
|
600
|
+
try {
|
|
601
|
+
const expired = expireSubscriptions();
|
|
602
|
+
if (expired > 0) {
|
|
603
|
+
invalidateAllBillingCaches();
|
|
604
|
+
logger.info({ expired }, 'Expired billing subscriptions');
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
catch (err) {
|
|
608
|
+
logger.error({ err }, 'Failed to check subscription expiry');
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Reconcile monthly_usage against daily_usage aggregation for a specific user/month.
|
|
613
|
+
* Used as a periodic safety net to fix drift when incrementMonthlyUsage was missed.
|
|
614
|
+
* If drift exceeds threshold, corrects monthly_usage to match daily_usage sum.
|
|
615
|
+
*/
|
|
616
|
+
export function reconcileMonthlyUsage(userId, month) {
|
|
617
|
+
try {
|
|
618
|
+
const dailySum = getDailyUsageSumForMonth(userId, month);
|
|
619
|
+
const existing = getMonthlyUsage(userId, month);
|
|
620
|
+
const recordedCost = existing?.total_cost_usd ?? 0;
|
|
621
|
+
const actualCost = dailySum.totalCost;
|
|
622
|
+
const drift = Math.abs(recordedCost - actualCost);
|
|
623
|
+
// Only correct if drift exceeds $0.01 threshold
|
|
624
|
+
if (drift > 0.01) {
|
|
625
|
+
logger.info({
|
|
626
|
+
userId,
|
|
627
|
+
month,
|
|
628
|
+
recorded: recordedCost,
|
|
629
|
+
actual: actualCost,
|
|
630
|
+
drift,
|
|
631
|
+
recordedTokens: (existing?.total_input_tokens ?? 0) +
|
|
632
|
+
(existing?.total_output_tokens ?? 0),
|
|
633
|
+
actualTokens: dailySum.totalInputTokens + dailySum.totalOutputTokens,
|
|
634
|
+
}, 'Monthly usage drift detected, correcting');
|
|
635
|
+
correctMonthlyUsage(userId, month, dailySum.totalInputTokens, dailySum.totalOutputTokens, dailySum.totalCost, dailySum.messageCount);
|
|
636
|
+
// Invalidate quota cache after correction
|
|
637
|
+
invalidateUserBillingCache(userId);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
catch (err) {
|
|
641
|
+
logger.warn({ err, userId, month }, 'Monthly usage reconciliation failed');
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
function getQuotaUsageSnapshot(userId, plan, now = new Date()) {
|
|
645
|
+
const today = now.toISOString().slice(0, 10);
|
|
646
|
+
const month = now.toISOString().slice(0, 7);
|
|
647
|
+
const dailyUsage = getDailyUsage(userId, today);
|
|
648
|
+
const weeklySummary = getWeeklyUsageSummary(userId);
|
|
649
|
+
const monthlyUsage = getMonthlyUsage(userId, month);
|
|
650
|
+
const dailyCost = dailyUsage?.total_cost_usd ?? 0;
|
|
651
|
+
const dailyTokens = (dailyUsage?.total_input_tokens ?? 0) +
|
|
652
|
+
(dailyUsage?.total_output_tokens ?? 0);
|
|
653
|
+
const weeklyCost = weeklySummary.totalCost;
|
|
654
|
+
const weeklyTokens = weeklySummary.totalTokens;
|
|
655
|
+
const monthlyCost = monthlyUsage?.total_cost_usd ?? 0;
|
|
656
|
+
const monthlyTokens = (monthlyUsage?.total_input_tokens ?? 0) +
|
|
657
|
+
(monthlyUsage?.total_output_tokens ?? 0);
|
|
658
|
+
const tomorrow = new Date(now);
|
|
659
|
+
tomorrow.setDate(tomorrow.getDate() + 1);
|
|
660
|
+
tomorrow.setHours(0, 0, 0, 0);
|
|
661
|
+
const nextMonday = new Date(now);
|
|
662
|
+
nextMonday.setDate(nextMonday.getDate() + ((7 - nextMonday.getDay()) % 7) + 1);
|
|
663
|
+
nextMonday.setHours(0, 0, 0, 0);
|
|
664
|
+
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
|
665
|
+
return {
|
|
666
|
+
dailyCost,
|
|
667
|
+
dailyTokens,
|
|
668
|
+
weeklyCost,
|
|
669
|
+
weeklyTokens,
|
|
670
|
+
monthlyCost,
|
|
671
|
+
monthlyTokens,
|
|
672
|
+
dailyResetAt: tomorrow.toISOString(),
|
|
673
|
+
weeklyResetAt: nextMonday.toISOString(),
|
|
674
|
+
monthlyResetAt: nextMonth.toISOString(),
|
|
675
|
+
baseUsage: {
|
|
676
|
+
costUsed: monthlyCost,
|
|
677
|
+
costQuota: plan.monthly_cost_quota,
|
|
678
|
+
tokenUsed: monthlyTokens,
|
|
679
|
+
tokenQuota: plan.monthly_token_quota,
|
|
680
|
+
daily: {
|
|
681
|
+
costUsed: dailyCost,
|
|
682
|
+
costQuota: plan.daily_cost_quota,
|
|
683
|
+
tokenUsed: dailyTokens,
|
|
684
|
+
tokenQuota: plan.daily_token_quota,
|
|
685
|
+
},
|
|
686
|
+
weekly: {
|
|
687
|
+
costUsed: weeklyCost,
|
|
688
|
+
costQuota: plan.weekly_cost_quota,
|
|
689
|
+
tokenUsed: weeklyTokens,
|
|
690
|
+
tokenQuota: plan.weekly_token_quota,
|
|
691
|
+
},
|
|
692
|
+
},
|
|
693
|
+
};
|
|
694
|
+
}
|