spendshield 0.6.0__tar.gz

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.
@@ -0,0 +1,178 @@
1
+ Metadata-Version: 2.4
2
+ Name: spendshield
3
+ Version: 0.6.0
4
+ Summary: AI Agent 付款安全层: 干跑/预算/人工确认/审计 四道闸门
5
+ License: MIT
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: cryptography>=41
9
+
10
+ # 💰 SpendShield — AI Agent 付款安全层
11
+
12
+ > **AI 替你花钱之前,先过 SpendShield 这关。**
13
+
14
+ 让 AI Agent 下单、转账、调付费 API 之前,自动过四道闸门:
15
+ **干跑预览 → 预算上限 → 人工确认 → 全量审计。**
16
+
17
+ ## 🩸 为什么会有这个项目(真实事故)
18
+
19
+ 2026 年 8 月 9 日,我的自动化系统测试下单。
20
+
21
+ 我传了 `dry: true`,以为只是试算价格。但服务器只认 `?dry=1` —— **4 单 99 元真实出码扣款,当天全部打水漂。**
22
+
23
+ 这不是我一个人的坑。AI Agent 时代正在到来:Agent 替你订餐、替你充值、替你调付费 API——**当 AI 开始花真钱,谁给它上闸门?**
24
+
25
+ 我把我踩过的坑,做成了一个库。
26
+
27
+ ## ✨ 四道闸门
28
+
29
+ | 闸门 | 默认 | 作用 |
30
+ |------|------|------|
31
+ | 🧪 **dry_run** 干跑 | ✅ 开 | 只预览不执行——`dry` 参数失效也无所谓,库层面兜底 |
32
+ | 💰 **budget** 预算 | 不限 | 总预算超支直接拒绝,绝不超花 |
33
+ | 🚧 **max_amount** 单次上限 | 不限 | 单笔超限拦截(防"转 9999 给陌生人") |
34
+ | 🙋 **approval** 人工确认 | 关 | 花钱前必须人点头(console / 回调) |
35
+ | 📜 **audit** 审计 | ✅ 开 | 每次尝试全留痕,导出 JSON 对账 |
36
+
37
+ ## 🔑 身份层(KYA 最小实现,v0.4)
38
+
39
+ AI 没有法律人格,但必须有“数字身份”。每个 agent 注册专属策略,**未注册默认拒绝**:
40
+
41
+ ```python
42
+ from spendshield import SpendShield, UnknownAgent
43
+
44
+ guard = SpendShield(dry_run=False)
45
+ guard.register_agent("mcd_bot", budget=50, max_amount=30,
46
+ blacklist=["测试收款"], whitelist=["麦当劳"],
47
+ rate_limit={"window_s": 60, "max_calls": 3})
48
+
49
+ @guard.protect("下单", agent="mcd_bot") # 或运行时传 agent=xx
50
+
51
+ def place_order(amount, to):
52
+ return call_real_api(amount, to)
53
+ ```
54
+
55
+ - 未注册的 agent 调用 → 直接拒绝(`UnknownAgent`),审计留痕 `blocked_unknown_agent`
56
+ - `allow_unknown: true` 可回落全局策略(不推荐)
57
+ - 每条审计记录带 `agent` 字段:**谁在花、花给谁、用户知不知道**
58
+ - 策略即代码支持 `agents:` 段(YAML),预算/黑名单/频率/审批按 agent 隔离
59
+
60
+ ## 🎯 意图一致性(防提示注入,v0.5)
61
+
62
+ AI 可能被劫持:提示注入、返利诱惑……闸门只知道“花多少、给谁”,不知道“这是用户要的吗”。
63
+ 解法:**敏感操作强制人工确认**——即使没配全局审批,新收款方/大额也默认拦下:
64
+
65
+ ```python
66
+ # 新收款方(从未交易过)→ 必须确认;没配审批通道 → 直接拒绝
67
+ # 金额 > approve_above → 必须确认
68
+ guard = SpendShield(approve_new_recipient=True, approve_above=1000)
69
+ ```
70
+
71
+ - 交易成功的收款方自动进入记忆,之后不再反复烦你
72
+ - 白名单收款方永远跳过
73
+ - 未配置审批通道时,敏感操作**直接拒绝**(宁可拦死,不放行)
74
+ - 拦截记录 `blocked_approval` 带原因:新收款方 / 超阈值 / 未配置通道
75
+
76
+ ## 🔐 密钥保险库(v0.6)
77
+
78
+ 私钥不落地是 AI 支付的命门——**一次泄露,钱包被掏空**。密钥加密落盘,主密钥放环境变量,取用必须过闸门:
79
+
80
+ ```bash
81
+ python -c "from spendshield import KeyVault; print(KeyVault.generate_key())" # 生成主密钥(仅此一次)
82
+ export SPENDGUARD_MASTER_KEY=<刚才的输出> # 放环境变量, 别写进代码/仓库
83
+ ```
84
+
85
+ ```python
86
+ from spendshield import SpendShield, KeyVault
87
+
88
+ vault = KeyVault("vault.json") # 主密钥从环境变量读
89
+ vault.store("mcd_sk", "sk_live_xxxx") # 加密落盘, 文件里只有密文
90
+
91
+ guard = SpendShield(key_vault=vault)
92
+ guard.register_agent("mcd_bot", whitelist=["mcd_sk"])
93
+ sk = guard.get_secret("mcd_sk", agent="mcd_bot") # 过身份+意图闸门才能取
94
+ ```
95
+
96
+ - 落盘文件无明文(AES128-CBC + HMAC);主密钥不落盘
97
+ - 取密钥 = 敏感操作:未注册 agent 拒绝;新密钥名无审批通道默认拒绝(防提示注入偷密钥)
98
+ - 每次取用留审计 `secret_access`:谁、何时、取了哪个密钥
99
+
100
+ ## 🚀 快速开始
101
+
102
+ ```bash
103
+ pip install spendshield # 或直接 clone 用
104
+ ```
105
+
106
+ ```python
107
+ from spendshield import SpendShield
108
+
109
+ guard = SpendShield(budget=200, dry_run=True, whitelist=["麦当劳"]) # 默认干跑 + 信任收款方
110
+
111
+ @guard.protect("下单")
112
+ def place_order(amount, to):
113
+ return call_real_api(amount, to) # 真实下单逻辑
114
+
115
+ # 干跑模式: 报错提示, 绝不真花
116
+ place_order(amount=99, to="麦当劳")
117
+ # => [SpendShield] dry_run: 下单 ¥99.0 -> 麦当劳 (未执行)
118
+ # => DryRunBlocked: 关掉 dry_run 才会真花
119
+
120
+ # 确认无误后放行, 预算闸门兜底
121
+ guard.dry_run = False
122
+ for i in range(4):
123
+ place_order(amount=99, to="麦当劳") # 第3单被 BudgetExceeded 拦住
124
+ ```
125
+
126
+ > 💡 新收款方默认需人工确认(意图一致性, 防提示注入)——把常用收款方加白名单或注册 Agent 身份可免。
127
+
128
+ ## 🎯 谁需要它
129
+
130
+ - **AI Agent 框架用户**:给你的 Agent 工具加装饰器,一行接入
131
+ - **自动化系统运维**:批量任务/定时下单,防误操作真扣款
132
+ - **MCP / Function Call 开发者**:LLM 生成的工具调用,过闸门再执行
133
+ - **所有被"测试单变真单"坑过的人** 🩸
134
+
135
+ ## 🗺 Roadmap
136
+
137
+ - [x] v0.1 四道闸门 + 审计 + 装饰器接入
138
+ - [ ] 收款方黑名单/白名单(陌生收款方强制确认)
139
+ - [ ] 频率限制(同一收款方短时间 N 次)
140
+ - [ ] MCP server 版(Agent 工具调用直接过闸)
141
+ - [ ] 远程审批(企业微信/Telegram 确认)
142
+ - [ ] 多策略插件(风控规则引擎)
143
+
144
+ ## 🧪 测试
145
+
146
+ ```bash
147
+ python3 tests/test_guard.py # 6 个测试全过
148
+ ```
149
+
150
+ ## 📄 License
151
+
152
+ MIT — 拿去用。愿 AI 时代,没人再被"测试单"坑第二次。
153
+
154
+ ---
155
+
156
+ **⭐ 如果这个项目对你有用,点个 star,让更多被坑过的人看到。**
157
+
158
+ ## 🤖 MCP Server(AI Agent 直接调用)
159
+
160
+ 让 Claude Code / OpenClaw 等 MCP 兼容 agent 直接通过工具调用过闸门:
161
+
162
+ ```bash
163
+ # 启动(stdio 模式, agent 配置里指向它)
164
+ python -m spendshield.mcp_server --policy spendshield.yaml
165
+ # 或安装后: spendshield-mcp --policy spendshield.yaml
166
+ ```
167
+
168
+ **工具**:
169
+ - `spend_protect(action, amount, to)` — 保护一次花钱操作(走全部闸门)
170
+ - `spend_status()` — 预算/已花/拦截统计
171
+ - `spend_audit(limit)` — 最近审计记录
172
+ - `spend_reset()` — 重置会话已花
173
+
174
+ ```json
175
+ // agent 调用示例
176
+ {"name": "spend_protect", "arguments": {"action": "下单", "amount": 99, "to": "麦当劳"}}
177
+ // => {"ok": false, "reason": "[干跑] 下单 ¥99.0 -> 麦当劳 (未执行...)", "spent": 0.0}
178
+ ```
@@ -0,0 +1,169 @@
1
+ # 💰 SpendShield — AI Agent 付款安全层
2
+
3
+ > **AI 替你花钱之前,先过 SpendShield 这关。**
4
+
5
+ 让 AI Agent 下单、转账、调付费 API 之前,自动过四道闸门:
6
+ **干跑预览 → 预算上限 → 人工确认 → 全量审计。**
7
+
8
+ ## 🩸 为什么会有这个项目(真实事故)
9
+
10
+ 2026 年 8 月 9 日,我的自动化系统测试下单。
11
+
12
+ 我传了 `dry: true`,以为只是试算价格。但服务器只认 `?dry=1` —— **4 单 99 元真实出码扣款,当天全部打水漂。**
13
+
14
+ 这不是我一个人的坑。AI Agent 时代正在到来:Agent 替你订餐、替你充值、替你调付费 API——**当 AI 开始花真钱,谁给它上闸门?**
15
+
16
+ 我把我踩过的坑,做成了一个库。
17
+
18
+ ## ✨ 四道闸门
19
+
20
+ | 闸门 | 默认 | 作用 |
21
+ |------|------|------|
22
+ | 🧪 **dry_run** 干跑 | ✅ 开 | 只预览不执行——`dry` 参数失效也无所谓,库层面兜底 |
23
+ | 💰 **budget** 预算 | 不限 | 总预算超支直接拒绝,绝不超花 |
24
+ | 🚧 **max_amount** 单次上限 | 不限 | 单笔超限拦截(防"转 9999 给陌生人") |
25
+ | 🙋 **approval** 人工确认 | 关 | 花钱前必须人点头(console / 回调) |
26
+ | 📜 **audit** 审计 | ✅ 开 | 每次尝试全留痕,导出 JSON 对账 |
27
+
28
+ ## 🔑 身份层(KYA 最小实现,v0.4)
29
+
30
+ AI 没有法律人格,但必须有“数字身份”。每个 agent 注册专属策略,**未注册默认拒绝**:
31
+
32
+ ```python
33
+ from spendshield import SpendShield, UnknownAgent
34
+
35
+ guard = SpendShield(dry_run=False)
36
+ guard.register_agent("mcd_bot", budget=50, max_amount=30,
37
+ blacklist=["测试收款"], whitelist=["麦当劳"],
38
+ rate_limit={"window_s": 60, "max_calls": 3})
39
+
40
+ @guard.protect("下单", agent="mcd_bot") # 或运行时传 agent=xx
41
+
42
+ def place_order(amount, to):
43
+ return call_real_api(amount, to)
44
+ ```
45
+
46
+ - 未注册的 agent 调用 → 直接拒绝(`UnknownAgent`),审计留痕 `blocked_unknown_agent`
47
+ - `allow_unknown: true` 可回落全局策略(不推荐)
48
+ - 每条审计记录带 `agent` 字段:**谁在花、花给谁、用户知不知道**
49
+ - 策略即代码支持 `agents:` 段(YAML),预算/黑名单/频率/审批按 agent 隔离
50
+
51
+ ## 🎯 意图一致性(防提示注入,v0.5)
52
+
53
+ AI 可能被劫持:提示注入、返利诱惑……闸门只知道“花多少、给谁”,不知道“这是用户要的吗”。
54
+ 解法:**敏感操作强制人工确认**——即使没配全局审批,新收款方/大额也默认拦下:
55
+
56
+ ```python
57
+ # 新收款方(从未交易过)→ 必须确认;没配审批通道 → 直接拒绝
58
+ # 金额 > approve_above → 必须确认
59
+ guard = SpendShield(approve_new_recipient=True, approve_above=1000)
60
+ ```
61
+
62
+ - 交易成功的收款方自动进入记忆,之后不再反复烦你
63
+ - 白名单收款方永远跳过
64
+ - 未配置审批通道时,敏感操作**直接拒绝**(宁可拦死,不放行)
65
+ - 拦截记录 `blocked_approval` 带原因:新收款方 / 超阈值 / 未配置通道
66
+
67
+ ## 🔐 密钥保险库(v0.6)
68
+
69
+ 私钥不落地是 AI 支付的命门——**一次泄露,钱包被掏空**。密钥加密落盘,主密钥放环境变量,取用必须过闸门:
70
+
71
+ ```bash
72
+ python -c "from spendshield import KeyVault; print(KeyVault.generate_key())" # 生成主密钥(仅此一次)
73
+ export SPENDGUARD_MASTER_KEY=<刚才的输出> # 放环境变量, 别写进代码/仓库
74
+ ```
75
+
76
+ ```python
77
+ from spendshield import SpendShield, KeyVault
78
+
79
+ vault = KeyVault("vault.json") # 主密钥从环境变量读
80
+ vault.store("mcd_sk", "sk_live_xxxx") # 加密落盘, 文件里只有密文
81
+
82
+ guard = SpendShield(key_vault=vault)
83
+ guard.register_agent("mcd_bot", whitelist=["mcd_sk"])
84
+ sk = guard.get_secret("mcd_sk", agent="mcd_bot") # 过身份+意图闸门才能取
85
+ ```
86
+
87
+ - 落盘文件无明文(AES128-CBC + HMAC);主密钥不落盘
88
+ - 取密钥 = 敏感操作:未注册 agent 拒绝;新密钥名无审批通道默认拒绝(防提示注入偷密钥)
89
+ - 每次取用留审计 `secret_access`:谁、何时、取了哪个密钥
90
+
91
+ ## 🚀 快速开始
92
+
93
+ ```bash
94
+ pip install spendshield # 或直接 clone 用
95
+ ```
96
+
97
+ ```python
98
+ from spendshield import SpendShield
99
+
100
+ guard = SpendShield(budget=200, dry_run=True, whitelist=["麦当劳"]) # 默认干跑 + 信任收款方
101
+
102
+ @guard.protect("下单")
103
+ def place_order(amount, to):
104
+ return call_real_api(amount, to) # 真实下单逻辑
105
+
106
+ # 干跑模式: 报错提示, 绝不真花
107
+ place_order(amount=99, to="麦当劳")
108
+ # => [SpendShield] dry_run: 下单 ¥99.0 -> 麦当劳 (未执行)
109
+ # => DryRunBlocked: 关掉 dry_run 才会真花
110
+
111
+ # 确认无误后放行, 预算闸门兜底
112
+ guard.dry_run = False
113
+ for i in range(4):
114
+ place_order(amount=99, to="麦当劳") # 第3单被 BudgetExceeded 拦住
115
+ ```
116
+
117
+ > 💡 新收款方默认需人工确认(意图一致性, 防提示注入)——把常用收款方加白名单或注册 Agent 身份可免。
118
+
119
+ ## 🎯 谁需要它
120
+
121
+ - **AI Agent 框架用户**:给你的 Agent 工具加装饰器,一行接入
122
+ - **自动化系统运维**:批量任务/定时下单,防误操作真扣款
123
+ - **MCP / Function Call 开发者**:LLM 生成的工具调用,过闸门再执行
124
+ - **所有被"测试单变真单"坑过的人** 🩸
125
+
126
+ ## 🗺 Roadmap
127
+
128
+ - [x] v0.1 四道闸门 + 审计 + 装饰器接入
129
+ - [ ] 收款方黑名单/白名单(陌生收款方强制确认)
130
+ - [ ] 频率限制(同一收款方短时间 N 次)
131
+ - [ ] MCP server 版(Agent 工具调用直接过闸)
132
+ - [ ] 远程审批(企业微信/Telegram 确认)
133
+ - [ ] 多策略插件(风控规则引擎)
134
+
135
+ ## 🧪 测试
136
+
137
+ ```bash
138
+ python3 tests/test_guard.py # 6 个测试全过
139
+ ```
140
+
141
+ ## 📄 License
142
+
143
+ MIT — 拿去用。愿 AI 时代,没人再被"测试单"坑第二次。
144
+
145
+ ---
146
+
147
+ **⭐ 如果这个项目对你有用,点个 star,让更多被坑过的人看到。**
148
+
149
+ ## 🤖 MCP Server(AI Agent 直接调用)
150
+
151
+ 让 Claude Code / OpenClaw 等 MCP 兼容 agent 直接通过工具调用过闸门:
152
+
153
+ ```bash
154
+ # 启动(stdio 模式, agent 配置里指向它)
155
+ python -m spendshield.mcp_server --policy spendshield.yaml
156
+ # 或安装后: spendshield-mcp --policy spendshield.yaml
157
+ ```
158
+
159
+ **工具**:
160
+ - `spend_protect(action, amount, to)` — 保护一次花钱操作(走全部闸门)
161
+ - `spend_status()` — 预算/已花/拦截统计
162
+ - `spend_audit(limit)` — 最近审计记录
163
+ - `spend_reset()` — 重置会话已花
164
+
165
+ ```json
166
+ // agent 调用示例
167
+ {"name": "spend_protect", "arguments": {"action": "下单", "amount": 99, "to": "麦当劳"}}
168
+ // => {"ok": false, "reason": "[干跑] 下单 ¥99.0 -> 麦当劳 (未执行...)", "spent": 0.0}
169
+ ```
@@ -0,0 +1,18 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "spendshield"
7
+ version = "0.6.0"
8
+ description = "AI Agent 付款安全层: 干跑/预算/人工确认/审计 四道闸门"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = {text = "MIT"}
12
+ dependencies = ["cryptography>=41"]
13
+
14
+ [tool.setuptools]
15
+ packages = ["spendshield"]
16
+
17
+ [project.scripts]
18
+ spendshield-mcp = "spendshield.mcp_server:main"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,32 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ SpendShield — AI Agent 付款安全层
4
+
5
+ 给 AI Agent 的「花钱动作」加上四道闸门:
6
+ 1. dry_run 干跑模式(默认开): 只预览, 不真花
7
+ 2. budget 预算上限: 超了直接拒绝
8
+ 3. approval 人工确认: 花钱前必须人点头
9
+ 4. audit 全量审计: 每次尝试都留痕
10
+
11
+ 血泪背景: 2026-08-09, 我让自动化系统测试下单, 因为 dry 参数没生效,
12
+ 4 单 99 元真实出码扣款。这个库就是那次事故的产物——
13
+ AI 时代, 别让 Agent 替你花钱之前没有闸门。
14
+
15
+ 用法:
16
+ from spendshield import SpendShield
17
+
18
+ guard = SpendShield(budget=100, dry_run=True, approval="console")
19
+
20
+ @guard.protect("下单", max_amount=50)
21
+ def place_order(order_id, amount, to):
22
+ # ... 真实下单逻辑
23
+ return {"ok": True}
24
+ """
25
+ from .guard import SpendShield, GuardedError, BudgetExceeded, NeedsApproval, DryRunBlocked, UnknownAgent, AuditRecord
26
+ from .vault import KeyVault
27
+
28
+ __version__ = "0.6.0"
29
+ __all__ = [
30
+ "SpendShield", "GuardedError", "BudgetExceeded",
31
+ "NeedsApproval", "DryRunBlocked", "UnknownAgent", "AuditRecord", "KeyVault",
32
+ ]
@@ -0,0 +1,119 @@
1
+ # -*- coding: utf-8 -*-
2
+ """SpendShield 审计 Dashboard — 可视化审计记录
3
+
4
+ 用法:
5
+ 1. 程序里导出审计: guard.export_audit("audit.json")
6
+ 2. 启动看板: python -m spendshield.dashboard --file audit.json --port 8775
7
+ 3. 打开 http://localhost:8775
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import os
14
+ import sys
15
+ import time
16
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
17
+ from urllib.parse import urlparse
18
+
19
+ PAGE = """<!DOCTYPE html>
20
+ <html lang="zh-CN"><head><meta charset="utf-8">
21
+ <meta name="viewport" content="width=device-width,initial-scale=1">
22
+ <title>SpendShield 审计 Dashboard</title>
23
+ <style>
24
+ *{margin:0;padding:0;box-sizing:border-box}
25
+ body{font-family:-apple-system,'PingFang SC','Microsoft YaHei',sans-serif;background:radial-gradient(900px 500px at 20% -10%,#1b1633 0%,#0b0e14 55%);color:#e6e8ee;padding:28px;min-height:100vh}
26
+ h1{font-size:20px;font-weight:800;margin-bottom:4px}h1 span{color:#a78bfa}
27
+ .sub{color:#7d8590;font-size:13px;margin-bottom:20px}
28
+ .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:20px}
29
+ .stat{background:#141b26;border:1px solid #21262d;border-radius:10px;padding:12px 14px}
30
+ .stat .v{font-size:24px;font-weight:800;line-height:1.2}
31
+ .stat .k{font-size:12px;color:#7d8590;margin-top:2px}
32
+ .stat .v.green{color:#3fb950}.stat .v.red{color:#f85149}.stat .v.purple{color:#a78bfa}.stat .v.yellow{color:#d29922}
33
+ table{width:100%;border-collapse:collapse;font-size:13px}
34
+ th,td{padding:9px 12px;text-align:left;border-bottom:1px solid #21262d}
35
+ th{color:#7d8590;font-weight:600;font-size:12px}
36
+ .blocked{color:#f85149}.executed{color:#3fb950}.dry{color:#d29922}
37
+ </style></head><body>
38
+ <h1>💰 SpendShield <span>审计 Dashboard</span></h1>
39
+ <div class="sub" id="meta">加载中...</div>
40
+ <div class="stats" id="stats"></div>
41
+ <table id="tbl"><thead><tr>
42
+ <th>时间</th><th>操作</th><th>金额</th><th>收款方</th><th>决策</th><th>原因</th><th>累计已花</th>
43
+ </tr></thead><tbody></tbody></table>
44
+ <script>
45
+ async function load(){
46
+ const r=await fetch('/api/audit');const d=await r.json();
47
+ const recs=d.records||[];
48
+ document.getElementById('meta').textContent=`${recs.length} 条记录 · 更新于 ${new Date().toLocaleTimeString()} · 30s 自动刷新`;
49
+ const executed=recs.filter(x=>x.decision==='executed');
50
+ const blocked=recs.filter(x=>x.decision.startsWith('blocked')||x.decision==='dry_run');
51
+ const spent=executed.reduce((s,x)=>s+(x.amount||0),0);
52
+ const maxAmt=Math.max(...recs.map(x=>x.amount||0),0);
53
+ document.getElementById('stats').innerHTML=
54
+ `<div class="stat"><div class="v purple">${recs.length}</div><div class="k">总尝试</div></div>
55
+ <div class="stat"><div class="v green">${executed.length}</div><div class="k">已放行</div></div>
56
+ <div class="stat"><div class="v red">${blocked.length}</div><div class="k">被拦截</div></div>
57
+ <div class="stat"><div class="v">¥${spent.toFixed(2)}</div><div class="k">累计已花</div></div>
58
+ <div class="stat"><div class="v yellow">¥${maxAmt.toFixed(2)}</div><div class="k">最大单笔</div></div>`;
59
+ const tb=document.querySelector('#tbl tbody');tb.innerHTML='';
60
+ for(const r of recs){
61
+ const cls=r.decision==='executed'?'executed':(r.decision==='dry_run'?'dry':'blocked');
62
+ const t=new Date(r.ts*1000).toLocaleString('zh-CN',{month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit'});
63
+ tb.insertAdjacentHTML('beforeend',
64
+ `<tr><td>${t}</td><td>${r.action}</td><td>¥${(r.amount||0).toFixed(2)}</td><td>${r.to}</td>
65
+ <td class="${cls}">${r.decision}</td><td style="color:#9da7b3">${r.reason||''}</td><td>¥${(r.spent_after||0).toFixed(2)}</td></tr>`);
66
+ }
67
+ }
68
+ load();setInterval(load,30000);
69
+ </script></body></html>
70
+ """
71
+
72
+
73
+ class Handler(BaseHTTPRequestHandler):
74
+ file = "audit.json"
75
+
76
+ def _json(self, obj):
77
+ body = json.dumps(obj, ensure_ascii=False).encode()
78
+ self.send_response(200)
79
+ self.send_header("Content-Type", "application/json; charset=utf-8")
80
+ self.send_header("Content-Length", str(len(body)))
81
+ self.end_headers()
82
+ self.wfile.write(body)
83
+
84
+ def do_GET(self):
85
+ u = urlparse(self.path)
86
+ if u.path in ("/", "/index.html"):
87
+ body = PAGE.encode()
88
+ self.send_response(200)
89
+ self.send_header("Content-Type", "text/html; charset=utf-8")
90
+ self.send_header("Content-Length", str(len(body)))
91
+ self.end_headers()
92
+ self.wfile.write(body)
93
+ elif u.path == "/api/audit":
94
+ try:
95
+ with open(self.file, encoding="utf-8") as f:
96
+ records = json.load(f)
97
+ except Exception:
98
+ records = []
99
+ self._json({"records": records})
100
+ else:
101
+ self._json({"error": "not found"})
102
+
103
+ def log_message(self, *a):
104
+ pass
105
+
106
+
107
+ def main():
108
+ ap = argparse.ArgumentParser(prog="spendshield-dashboard")
109
+ ap.add_argument("--file", default="audit.json", help="审计 JSON 文件")
110
+ ap.add_argument("--port", type=int, default=8775)
111
+ args = ap.parse_args()
112
+ Handler.file = args.file
113
+ srv = ThreadingHTTPServer(("0.0.0.0", args.port), Handler)
114
+ print(f"SpendShield 审计看板: http://localhost:{args.port} (file={args.file})")
115
+ srv.serve_forever()
116
+
117
+
118
+ if __name__ == "__main__":
119
+ main()