ErisPulse-GovReport 0.1.0__py3-none-any.whl

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.
GovReport/Config.py ADDED
@@ -0,0 +1,62 @@
1
+ from dataclasses import dataclass, field
2
+
3
+ from ErisPulse.Core.Bases import BaseConfig
4
+
5
+
6
+ @dataclass
7
+ class GovReportConfig(BaseConfig):
8
+
9
+ enabled: bool = field(
10
+ default=True,
11
+ metadata={
12
+ "description": {"i18n": "GovReport.enabled", "default": "是否启用模块"},
13
+ "ui": {"widget": "switch", "group": "basic", "order": 1},
14
+ },
15
+ )
16
+ collect_command_calls: bool = field(
17
+ default=True,
18
+ metadata={
19
+ "description": {"i18n": "GovReport.collect_command_calls", "default": "采集命令执行记录(command.executed)"},
20
+ "ui": {"widget": "switch", "group": "collect", "order": 2},
21
+ },
22
+ )
23
+ collect_blocked: bool = field(
24
+ default=True,
25
+ metadata={
26
+ "description": {"i18n": "GovReport.collect_blocked", "default": "采集事件拦截记录(中间件否决 / 作用域拦截)"},
27
+ "ui": {"widget": "switch", "group": "collect", "order": 3},
28
+ },
29
+ )
30
+ retention_days: int = field(
31
+ default=30,
32
+ metadata={
33
+ "description": {"i18n": "GovReport.retention_days", "default": "记录保留天数(0 永久保留,启动与每日清理)"},
34
+ "ui": {"widget": "number", "group": "collect", "order": 4},
35
+ },
36
+ )
37
+ top_n: int = field(
38
+ default=10,
39
+ metadata={
40
+ "description": {"i18n": "GovReport.top_n", "default": "统计报表单次展示的最大条目数"},
41
+ "ui": {"widget": "number", "group": "report", "order": 5},
42
+ },
43
+ )
44
+ render_card: bool = field(
45
+ default=True,
46
+ metadata={
47
+ "description": {"i18n": "GovReport.render_card", "default": "报表用 Takumi 渲染为卡片(不可用时降级文本)"},
48
+ "ui": {"widget": "switch", "group": "render", "order": 6},
49
+ },
50
+ )
51
+ theme: str = field(
52
+ default="auto",
53
+ metadata={
54
+ "description": {"i18n": "GovReport.theme", "default": "卡片配色 (auto=按时间自动切深浅色)"},
55
+ "ui": {
56
+ "widget": "select",
57
+ "options": ["auto", "light", "dark"],
58
+ "group": "render",
59
+ "order": 7,
60
+ },
61
+ },
62
+ )
GovReport/Core.py ADDED
@@ -0,0 +1,209 @@
1
+ import asyncio
2
+ import time
3
+
4
+ from ErisPulse import i18n, sdk
5
+ from ErisPulse.Core import Depends
6
+ from ErisPulse.Core.Bases import BaseModule, ModuleMeta
7
+ from ErisPulse.Core.Event import command
8
+ from ErisPulse.loaders import ModuleLoadStrategy
9
+
10
+ from .Config import GovReportConfig
11
+ from .I18n import GovReportI18n
12
+ from .Models import BlockEvent, CommandCall
13
+ from .Stats import GovStatsService, parse_window
14
+ from .Visualizer import CardVisualizer
15
+
16
+
17
+ def get_stats_service(event):
18
+ from ErisPulse import module as module_mgr
19
+
20
+ inst = module_mgr.get("GovReport")
21
+ if inst is None:
22
+ raise RuntimeError("GovReport not loaded")
23
+ return inst._stats
24
+
25
+
26
+ class Main(BaseModule):
27
+
28
+ ConfigClass = GovReportConfig
29
+ I18nClass = GovReportI18n
30
+
31
+ def __init__(self):
32
+ self.sdk = sdk
33
+ self.logger = sdk.logger.get_child("GovReport")
34
+ self._stats = GovStatsService(self.logger)
35
+ self._viz = CardVisualizer(sdk, self.logger)
36
+ self._daily_task = None
37
+
38
+ @staticmethod
39
+ def get_load_strategy() -> ModuleLoadStrategy:
40
+ return ModuleLoadStrategy(lazy_load=False, priority=20)
41
+
42
+ @staticmethod
43
+ def get_meta() -> ModuleMeta:
44
+ return ModuleMeta(
45
+ name="GovReport",
46
+ description="治理观测模块:采集命令执行与事件拦截记录,输出统计报表与配额消耗",
47
+ version="0.1.0",
48
+ author="ErisPulse-devs",
49
+ group="运维",
50
+ tags=["治理", "统计", "ORM", "观测"],
51
+ min_sdk_version="2.9.0",
52
+ )
53
+
54
+ async def on_load(self, event: dict) -> bool:
55
+ if not self.cfg.enabled:
56
+ self.logger.info("治理观测未启用(enabled=false)")
57
+ return True
58
+
59
+ await CommandCall.create_table()
60
+ await BlockEvent.create_table()
61
+ await self._stats.purge_expired(self.cfg.retention_days)
62
+
63
+ self._register_collectors()
64
+ self._register_commands()
65
+ self._daily_task = self.spawn(self._daily_purge_loop())
66
+ self.logger.info(i18n.t("GovReport.module_loaded", event=event))
67
+ return True
68
+
69
+ async def on_unload(self, event: dict) -> bool:
70
+ if self._daily_task is not None:
71
+ self._daily_task.cancel()
72
+ await asyncio.gather(self._daily_task, return_exceptions=True)
73
+ self._daily_task = None
74
+ self.logger.info(i18n.t("GovReport.module_unloaded", event=event))
75
+ return True
76
+
77
+ async def _daily_purge_loop(self):
78
+ while True:
79
+ await asyncio.sleep(86400)
80
+ try:
81
+ await self._stats.purge_expired(self.cfg.retention_days)
82
+ except Exception as e:
83
+ self.logger.warning(f"每日清理失败: {e}")
84
+
85
+ def _register_collectors(self):
86
+ @sdk.lifecycle.on("command.executed")
87
+ async def _on_command_executed(data):
88
+ if not self.cfg.collect_command_calls:
89
+ return
90
+ try:
91
+ await CommandCall.create(
92
+ ts=time.time(),
93
+ command=str(data.get("command", ""))[:128],
94
+ platform=str(data.get("platform", ""))[:64],
95
+ user_id=str(data.get("user_id", ""))[:128],
96
+ success=bool(data.get("success")),
97
+ error=str(data.get("error", "") or "")[:255],
98
+ )
99
+ except Exception as e:
100
+ self.logger.warning(f"记录命令执行失败: {e}")
101
+
102
+ @sdk.lifecycle.on("adapter.event.blocked")
103
+ async def _on_event_blocked(data):
104
+ if not self.cfg.collect_blocked:
105
+ return
106
+ raw = data.get("event") or {}
107
+ try:
108
+ await BlockEvent.create(
109
+ ts=time.time(),
110
+ source="middleware",
111
+ middleware=str(data.get("middleware", ""))[:128],
112
+ platform=str(data.get("platform", ""))[:64],
113
+ detail_type=str(data.get("detail_type", ""))[:64],
114
+ user_id=str(raw.get("user_id", "") or "")[:128],
115
+ )
116
+ except Exception as e:
117
+ self.logger.warning(f"记录拦截事件失败: {e}")
118
+
119
+ @sdk.lifecycle.on("scope.blocked")
120
+ async def _on_scope_blocked(data):
121
+ if not self.cfg.collect_blocked:
122
+ return
123
+ try:
124
+ await BlockEvent.create(
125
+ ts=time.time(),
126
+ source="scope",
127
+ dimension=str(data.get("dimension", ""))[:16],
128
+ module=str(data.get("module", "") or "")[:128],
129
+ platform=str(data.get("platform", ""))[:64],
130
+ user_id=str(data.get("user_id", "") or "")[:128],
131
+ session_id=str(data.get("session_id", "") or "")[:128],
132
+ )
133
+ except Exception as e:
134
+ self.logger.warning(f"记录作用域拦截失败: {e}")
135
+
136
+ async def _reply_card(self, event, html, fallback_text: str):
137
+ if self.cfg.render_card:
138
+ png = await self._viz.render(html, theme=self.cfg.theme)
139
+ if png is not None:
140
+ try:
141
+ await event.reply(png, method="Image")
142
+ return
143
+ except Exception as e:
144
+ self.logger.warning(f"图片回复失败,降级文本: {e}")
145
+ await event.reply(fallback_text)
146
+
147
+ def _register_commands(self):
148
+ @command("gov", master=True, help="治理观测")
149
+ async def gov_handler(event):
150
+ await event.reply(
151
+ "用法: /gov stats [窗口] | /gov user <用户ID> [窗口] | /gov blocked [窗口] | "
152
+ "/gov usage | /gov list [命令] | /gov purge <天数>"
153
+ )
154
+
155
+ @command("gov stats", master=True, help="命令执行统计", args="[window:str=24h]")
156
+ async def gov_stats_handler(event, window: str = "24h", stats=Depends(get_stats_service)):
157
+ secs = parse_window(window)
158
+ if secs is None:
159
+ await event.reply(i18n.t("GovReport.invalid_window", window=window))
160
+ return
161
+ await self._reply_card(
162
+ event,
163
+ await stats.command_stats_card(window, self.cfg.top_n, self._viz),
164
+ await stats.command_stats(secs, window, self.cfg.top_n),
165
+ )
166
+
167
+ @command("gov user", master=True, help="单用户统计", args="<user_id:str> [window:str=24h]")
168
+ async def gov_user_handler(event, user_id: str, window: str = "24h"):
169
+ secs = parse_window(window)
170
+ if secs is None:
171
+ await event.reply(i18n.t("GovReport.invalid_window", window=window))
172
+ return
173
+ await event.reply(await self._stats.user_stats(user_id, secs, window, self.cfg.top_n))
174
+
175
+ @command("gov blocked", master=True, help="拦截统计", args="[window:str=24h]")
176
+ async def gov_blocked_handler(event, window: str = "24h"):
177
+ secs = parse_window(window)
178
+ if secs is None:
179
+ await event.reply(i18n.t("GovReport.invalid_window", window=window))
180
+ return
181
+ await self._reply_card(
182
+ event,
183
+ await self._stats.blocked_stats_card(window, self.cfg.top_n, self._viz),
184
+ await self._stats.blocked_stats(secs, window, self.cfg.top_n),
185
+ )
186
+
187
+ @command("gov usage", master=True, help="配额消耗报表")
188
+ async def gov_usage_handler(event):
189
+ await self._reply_card(
190
+ event,
191
+ await self._stats.usage_report_card(self._viz),
192
+ await self._stats.usage_report(),
193
+ )
194
+
195
+ @command("gov list", master=True, help="治理声明清单", args="[cmd:str]")
196
+ async def gov_list_handler(event, cmd: str = ""):
197
+ await self._reply_card(
198
+ event,
199
+ self._stats.governance_list_card(cmd or None, self._viz),
200
+ self._stats.governance_list(cmd or None),
201
+ )
202
+
203
+ @command("gov purge", master=True, help="清理旧记录", args="<days:int>")
204
+ async def gov_purge_handler(event, days: int):
205
+ if days <= 0:
206
+ await event.reply(i18n.t("GovReport.invalid_window", window=str(days)))
207
+ return
208
+ calls, blocks = await self._stats.purge(days)
209
+ await event.reply(i18n.t("GovReport.purge_done", calls=calls, blocks=blocks, days=days))
GovReport/I18n.py ADDED
@@ -0,0 +1,173 @@
1
+ from ErisPulse.Core.Bases import BaseI18n, I18nKey
2
+
3
+
4
+ class GovReportI18n(BaseI18n):
5
+
6
+ enabled: I18nKey = I18nKey(
7
+ default="Enable module",
8
+ zh_CN="是否启用模块",
9
+ zh_TW="是否啟用模組",
10
+ en="Enable module",
11
+ ja="モジュールを有効化",
12
+ ru="Включить модуль",
13
+ )
14
+ collect_command_calls: I18nKey = I18nKey(
15
+ default="Collect command execution records",
16
+ zh_CN="采集命令执行记录(command.executed)",
17
+ zh_TW="採集命令執行記錄(command.executed)",
18
+ en="Collect command execution records",
19
+ ja="コマンド実行記録を収集",
20
+ ru="Собирать записи выполнения команд",
21
+ )
22
+ collect_blocked: I18nKey = I18nKey(
23
+ default="Collect blocked event records (middleware veto / scope)",
24
+ zh_CN="采集事件拦截记录(中间件否决 / 作用域拦截)",
25
+ zh_TW="採集事件攔截記錄(中介軟體否決 / 作用域攔截)",
26
+ en="Collect blocked event records (middleware veto / scope)",
27
+ ja="ブロック記録を収集(ミドルウェア拒否 / スコープ)",
28
+ ru="Собирать записи блокировки событий",
29
+ )
30
+ retention_days: I18nKey = I18nKey(
31
+ default="Retention days (0 = keep forever)",
32
+ zh_CN="记录保留天数(0 永久保留,启动与每日清理)",
33
+ zh_TW="記錄保留天數(0 永久保留,啟動與每日清理)",
34
+ en="Retention days (0 = keep forever)",
35
+ ja="保持日数(0 で無期限保持)",
36
+ ru="Срок хранения в днях (0 = бессрочно)",
37
+ )
38
+ top_n: I18nKey = I18nKey(
39
+ default="Max entries shown in one report",
40
+ zh_CN="统计报表单次展示的最大条目数",
41
+ zh_TW="統計報表單次展示的最大條目數",
42
+ en="Max entries shown in one report",
43
+ ja="レポート1回の最大表示件数",
44
+ ru="Максимум записей в одном отчёте",
45
+ )
46
+ render_card: I18nKey = I18nKey(
47
+ default="Render reports as image cards via Takumi (fallback to text)",
48
+ zh_CN="报表用 Takumi 渲染为卡片(不可用时降级文本)",
49
+ zh_TW="報表用 Takumi 渲染為卡片(不可用時降級文本)",
50
+ en="Render reports as image cards via Takumi (fallback to text)",
51
+ ja="レポートを Takumi でカード画像化(不可時はテキスト)",
52
+ ru="Рендер отчётов в карточки через Takumi (иначе текст)",
53
+ )
54
+ theme: I18nKey = I18nKey(
55
+ default="Card color theme (auto = light/dark by time)",
56
+ zh_CN="卡片配色 (auto=按时间自动切深浅色)",
57
+ zh_TW="卡片配色 (auto=按時間自動切深淺色)",
58
+ en="Card color theme (auto = light/dark by time)",
59
+ ja="カード配色(auto=時間で自動切替)",
60
+ ru="Тема карточек (auto = светлая/тёмная по времени)",
61
+ )
62
+ module_loaded: I18nKey = I18nKey(
63
+ default="GovReport loaded: {event}",
64
+ zh_CN="治理观测已加载: {event}",
65
+ zh_TW="治理觀測已載入: {event}",
66
+ en="GovReport loaded: {event}",
67
+ ja="GovReport を読み込みました: {event}",
68
+ ru="GovReport загружен: {event}",
69
+ )
70
+ module_unloaded: I18nKey = I18nKey(
71
+ default="GovReport unloaded: {event}",
72
+ zh_CN="治理观测已卸载: {event}",
73
+ zh_TW="治理觀測已卸載: {event}",
74
+ en="GovReport unloaded: {event}",
75
+ ja="GovReport を解除しました: {event}",
76
+ ru="GovReport выгружен: {event}",
77
+ )
78
+ stats_header: I18nKey = I18nKey(
79
+ default="Command stats in last {window}: total {total}, success {ok}, failed {failed}, deprecated uses {dep}",
80
+ zh_CN="近 {window} 命令统计:共 {total} 次,成功 {ok},失败 {failed},废弃命令使用 {dep} 次",
81
+ zh_TW="近 {window} 命令統計:共 {total} 次,成功 {ok},失敗 {failed},廢棄命令使用 {dep} 次",
82
+ en="Command stats in last {window}: total {total}, success {ok}, failed {failed}, deprecated uses {dep}",
83
+ ja="直近 {window} のコマンド統計:合計 {total}、成功 {ok}、失敗 {failed}、非推奨使用 {dep}",
84
+ ru="Статистика команд за {window}: всего {total}, успешно {ok}, ошибок {failed}, устаревших {dep}",
85
+ )
86
+ stats_empty: I18nKey = I18nKey(
87
+ default="No command records in window {window}",
88
+ zh_CN="窗口 {window} 内没有命令记录",
89
+ zh_TW="視窗 {window} 內沒有命令記錄",
90
+ en="No command records in window {window}",
91
+ ja="期間 {window} にコマンド記録がありません",
92
+ ru="За период {window} записей команд нет",
93
+ )
94
+ user_header: I18nKey = I18nKey(
95
+ default="User {user} in last {window}: total {total}, success {ok}, failed {failed}",
96
+ zh_CN="用户 {user} 近 {window}:共 {total} 次,成功 {ok},失败 {failed}",
97
+ zh_TW="用戶 {user} 近 {window}:共 {total} 次,成功 {ok},失敗 {failed}",
98
+ en="User {user} in last {window}: total {total}, success {ok}, failed {failed}",
99
+ ja="ユーザー {user} 直近 {window}:合計 {total}、成功 {ok}、失敗 {failed}",
100
+ ru="Пользователь {user} за {window}: всего {total}, успешно {ok}, ошибок {failed}",
101
+ )
102
+ user_empty: I18nKey = I18nKey(
103
+ default="User {user} has no records in window {window}",
104
+ zh_CN="用户 {user} 在窗口 {window} 内没有记录",
105
+ zh_TW="用戶 {user} 在視窗 {window} 內沒有記錄",
106
+ en="User {user} has no records in window {window}",
107
+ ja="ユーザー {user} は期間 {window} に記録がありません",
108
+ ru="У пользователя {user} нет записей за {window}",
109
+ )
110
+ blocked_header: I18nKey = I18nKey(
111
+ default="Blocked events in last {window}: total {total}",
112
+ zh_CN="近 {window} 拦截统计:共 {total} 次",
113
+ zh_TW="近 {window} 攔截統計:共 {total} 次",
114
+ en="Blocked events in last {window}: total {total}",
115
+ ja="直近 {window} のブロック統計:合計 {total}",
116
+ ru="Блокировки за {window}: всего {total}",
117
+ )
118
+ blocked_empty: I18nKey = I18nKey(
119
+ default="No blocked events in window {window}",
120
+ zh_CN="窗口 {window} 内没有拦截记录",
121
+ zh_TW="視窗 {window} 內沒有攔截記錄",
122
+ en="No blocked events in window {window}",
123
+ ja="期間 {window} にブロック記録がありません",
124
+ ru="За период {window} блокировок нет",
125
+ )
126
+ usage_header: I18nKey = I18nKey(
127
+ default="Usage quota consumption ({count} active key(s)):",
128
+ zh_CN="配额消耗(活跃键 {count} 个):",
129
+ zh_TW="配額消耗(活躍鍵 {count} 個):",
130
+ en="Usage quota consumption ({count} active key(s)):",
131
+ ja="クォータ消費(アクティブキー {count} 件):",
132
+ ru="Потребление квот (активных ключей: {count}):",
133
+ )
134
+ usage_empty: I18nKey = I18nKey(
135
+ default="No usage quota records",
136
+ zh_CN="没有配额消耗记录",
137
+ zh_TW="沒有配額消耗記錄",
138
+ en="No usage quota records",
139
+ ja="クォータ消費記録がありません",
140
+ ru="Нет записей потребления квот",
141
+ )
142
+ gov_list_empty: I18nKey = I18nKey(
143
+ default="No command has governance declarations",
144
+ zh_CN="没有任何命令声明治理规则",
145
+ zh_TW="沒有任何命令聲明治理規則",
146
+ en="No command has governance declarations",
147
+ ja="治理宣言を持つコマンドはありません",
148
+ ru="Нет команд с правилами управления",
149
+ )
150
+ gov_list_header: I18nKey = I18nKey(
151
+ default="Governance declarations ({count} command(s)):",
152
+ zh_CN="治理声明清单({count} 个命令):",
153
+ zh_TW="治理聲明清單({count} 個命令):",
154
+ en="Governance declarations ({count} command(s)):",
155
+ ja="治理宣言一覧({count} 件):",
156
+ ru="Объявления управления ({count} команд):",
157
+ )
158
+ purge_done: I18nKey = I18nKey(
159
+ default="Purged {calls} call record(s) and {blocks} block record(s) older than {days} day(s)",
160
+ zh_CN="已清理 {days} 天前的记录:命令 {calls} 条,拦截 {blocks} 条",
161
+ zh_TW="已清理 {days} 天前的記錄:命令 {calls} 條,攔截 {blocks} 條",
162
+ en="Purged {calls} call record(s) and {blocks} block record(s) older than {days} day(s)",
163
+ ja="{days} 日より前の記録を削除:コマンド {calls} 件、ブロック {blocks} 件",
164
+ ru="Удалено записей старше {days} дн.: команд {calls}, блокировок {blocks}",
165
+ )
166
+ invalid_window: I18nKey = I18nKey(
167
+ default="Invalid window: {window} (examples: 30m / 24h / 7d)",
168
+ zh_CN="无效的时间窗口: {window}(示例: 30m / 24h / 7d)",
169
+ zh_TW="無效的時間視窗: {window}(示例: 30m / 24h / 7d)",
170
+ en="Invalid window: {window} (examples: 30m / 24h / 7d)",
171
+ ja="無効な期間指定: {window}(例: 30m / 24h / 7d)",
172
+ ru="Неверный период: {window} (примеры: 30m / 24h / 7d)",
173
+ )
GovReport/Models.py ADDED
@@ -0,0 +1,30 @@
1
+ from ErisPulse.Core.Bases import Field, Model
2
+
3
+
4
+ class CommandCall(Model):
5
+
6
+ __tablename__ = "gov_command_calls"
7
+
8
+ id: int = Field(primary_key=True, autoincrement=True)
9
+ ts: float = Field(index=True)
10
+ command: str = Field(max_length=128, index=True)
11
+ platform: str = Field(max_length=64, default="")
12
+ user_id: str = Field(max_length=128, default="", index=True)
13
+ success: bool = Field(default=True)
14
+ error: str = Field(max_length=255, default="")
15
+
16
+
17
+ class BlockEvent(Model):
18
+
19
+ __tablename__ = "gov_block_events"
20
+
21
+ id: int = Field(primary_key=True, autoincrement=True)
22
+ ts: float = Field(index=True)
23
+ source: str = Field(max_length=16, choices=["middleware", "scope"], index=True)
24
+ middleware: str = Field(max_length=128, default="")
25
+ dimension: str = Field(max_length=16, default="")
26
+ module: str = Field(max_length=128, default="")
27
+ platform: str = Field(max_length=64, default="")
28
+ detail_type: str = Field(max_length=64, default="")
29
+ user_id: str = Field(max_length=128, default="")
30
+ session_id: str = Field(max_length=128, default="")
GovReport/Stats.py ADDED
@@ -0,0 +1,331 @@
1
+ import time
2
+ from typing import Any, Optional
3
+
4
+ from ErisPulse import i18n
5
+
6
+ from .Models import BlockEvent, CommandCall
7
+
8
+ USAGE_KEY_PREFIX = "erispulse.usage"
9
+ SEP = "\x00"
10
+
11
+
12
+ def parse_window(window: str) -> Optional[float]:
13
+ from ErisPulse.Core.Event import command_args
14
+
15
+ try:
16
+ seconds = command_args.parse_duration(window)
17
+ except Exception:
18
+ return None
19
+ if seconds is None or seconds <= 0:
20
+ return None
21
+ return float(seconds)
22
+
23
+
24
+ def _fmt_window(window: str) -> str:
25
+ return window
26
+
27
+
28
+ class GovStatsService:
29
+
30
+ def __init__(self, logger):
31
+ self._logger = logger
32
+
33
+ async def _command_window(self, window_secs: float) -> list:
34
+ cutoff = time.time() - window_secs
35
+ return await CommandCall.where(CommandCall.ts >= cutoff).all()
36
+
37
+ @staticmethod
38
+ def _top(rows: list, key: str, top_n: int) -> list:
39
+ counter: dict[str, int] = {}
40
+ for r in rows:
41
+ k = getattr(r, key) or "-"
42
+ counter[k] = counter.get(k, 0) + 1
43
+ return sorted(counter.items(), key=lambda kv: -kv[1])[:top_n]
44
+
45
+ async def _usage_entries(self) -> dict:
46
+ from ErisPulse import sdk
47
+
48
+ storage = sdk.storage
49
+ try:
50
+ root = await storage.aget("erispulse")
51
+ except Exception as e:
52
+ self._logger.warning(f"读取配额根键失败: {e}")
53
+ return {}
54
+ if not isinstance(root, dict):
55
+ return {}
56
+ return {
57
+ k: v for k, v in root.items()
58
+ if isinstance(k, str) and k.startswith("usage" + SEP)
59
+ }
60
+
61
+ async def command_stats_card(self, window_text: str, top_n: int, viz) -> str:
62
+ import datetime
63
+
64
+ rows = await self._command_window(parse_window(window_text) or 0)
65
+ total = len(rows)
66
+ if not total:
67
+ body = "<div class='empty'>窗口内没有命令记录</div>"
68
+ else:
69
+ ok = sum(1 for r in rows if r.success)
70
+ dep = sum(1 for r in rows if r.error == "deprecated")
71
+ body = viz.nums([
72
+ ("总调用", total, ""),
73
+ ("成功", ok, "ok"),
74
+ ("失败", total - ok, "bad"),
75
+ ("废弃使用", dep, "warn"),
76
+ ])
77
+ body += viz.bar_section("命令 Top", self._top(rows, "command", top_n))
78
+ body += viz.bar_section("用户 Top", self._top(rows, "user_id", top_n), color="accent2")
79
+ stamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
80
+ return (
81
+ viz.head("命令执行统计", f"窗口 {window_text} · 共 {total} 次调用")
82
+ + f"<div class='body'>{body}</div>"
83
+ + viz.foot(f"GovReport · {stamp}", f"Top {top_n}")
84
+ )
85
+
86
+ async def blocked_stats_card(self, window_text: str, top_n: int, viz) -> str:
87
+ import datetime
88
+
89
+ cutoff = time.time() - (parse_window(window_text) or 0)
90
+ rows = await BlockEvent.where(BlockEvent.ts >= cutoff).all()
91
+ total = len(rows)
92
+ if not total:
93
+ body = "<div class='empty'>窗口内没有拦截记录</div>"
94
+ else:
95
+ mw = sum(1 for r in rows if r.source == "middleware")
96
+ body = viz.nums([
97
+ ("总拦截", total, ""),
98
+ ("中间件否决", mw, "warn"),
99
+ ("作用域拦截", total - mw, ""),
100
+ ])
101
+ by_name: dict[str, int] = {}
102
+ for r in rows:
103
+ if r.source == "middleware":
104
+ name = r.middleware or "-"
105
+ else:
106
+ name = f"{r.dimension or '-'}:{r.module or '-'}"
107
+ by_name[name] = by_name.get(name, 0) + 1
108
+ pairs = sorted(by_name.items(), key=lambda kv: -kv[1])[:top_n]
109
+ body += viz.bar_section("拦截来源", pairs, color="err")
110
+ stamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
111
+ return (
112
+ viz.head("事件拦截统计", f"窗口 {window_text} · 共 {total} 次")
113
+ + f"<div class='body'>{body}</div>"
114
+ + viz.foot(f"GovReport · {stamp}", "middleware / scope")
115
+ )
116
+
117
+ async def usage_report_card(self, viz) -> str:
118
+ import datetime
119
+
120
+ entries = await self._usage_entries()
121
+ if not entries:
122
+ body = "<div class='empty'>没有配额消耗记录</div>"
123
+ else:
124
+ limits = self._usage_limits()
125
+ rows = []
126
+ for key in sorted(entries, key=lambda k: -entries[k]):
127
+ parts = key[len("usage" + SEP):].split(SEP)
128
+ main_name = parts[0]
129
+ scope = parts[1] if len(parts) > 1 else "?"
130
+ period = parts[2] if len(parts) > 2 else "?"
131
+ limit, unit = limits.get(main_name, (None, ""))
132
+ right = f"{entries[key]}/{limit}" if limit is not None else str(entries[key])
133
+ chips = viz.chips([f"period {period}", f"scope {scope}"])
134
+ rows.append(viz.count_row(main_name, right, unit or "", chips))
135
+ body = "".join(rows)
136
+ stamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
137
+ return (
138
+ viz.head("配额消耗", f"活跃键 {len(entries)} 个")
139
+ + f"<div class='body'>{body}</div>"
140
+ + viz.foot(f"GovReport · {stamp}", "usage_limit")
141
+ )
142
+
143
+ def governance_list_card(self, name_filter, viz) -> str:
144
+ import datetime
145
+
146
+ from ErisPulse.Core.Event import command
147
+
148
+ registry = command.get_commands()
149
+ rows = []
150
+ for cmd_name, info in sorted(registry.items()):
151
+ if cmd_name != info.get("main_name"):
152
+ continue
153
+ if name_filter and name_filter not in cmd_name:
154
+ continue
155
+ chips = []
156
+ cooldown = info.get("cooldown_seconds")
157
+ if cooldown:
158
+ chips.append(f"cooldown {cooldown:g}s · {info.get('cooldown_key') or 'user'}")
159
+ rate = info.get("rate_limit_spec")
160
+ if rate:
161
+ chips.append(f"rate {rate[0]}/{rate[1]:g}s · {info.get('rate_limit_key') or 'user'}")
162
+ usage = info.get("usage_spec")
163
+ if usage:
164
+ chips.append(f"usage {usage[0]}/{usage[1]} · {info.get('usage_limit_key') or 'user'}")
165
+ deprecated = info.get("deprecated")
166
+ if deprecated:
167
+ chips.append(f"deprecated{'(reject)' if info.get('deprecated_reject') else ''}: {deprecated}")
168
+ if chips:
169
+ rows.append(viz.count_row(
170
+ cmd_name, "", "",
171
+ viz.chips(chips, cls="warn" if deprecated else ""),
172
+ ))
173
+ body = "".join(rows) if rows else "<div class='empty'>没有任何命令声明治理规则</div>"
174
+ stamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
175
+ return (
176
+ viz.head("治理声明清单", f"{len(rows)} 个命令声明了治理规则")
177
+ + f"<div class='body'>{body}</div>"
178
+ + viz.foot(f"GovReport · {stamp}", "cooldown / rate / usage / deprecated")
179
+ )
180
+
181
+ async def command_stats(self, window_secs: float, window_text: str, top_n: int) -> str:
182
+ cutoff = time.time() - window_secs
183
+ rows = await CommandCall.where(CommandCall.ts >= cutoff).all()
184
+ if not rows:
185
+ return i18n.t("GovReport.stats_empty", window=_fmt_window(window_text))
186
+ total = len(rows)
187
+ ok = sum(1 for r in rows if r.success)
188
+ dep = sum(1 for r in rows if r.error == "deprecated")
189
+ lines = [i18n.t(
190
+ "GovReport.stats_header",
191
+ window=_fmt_window(window_text), total=total, ok=ok, failed=total - ok, dep=dep,
192
+ )]
193
+ by_cmd: dict[str, int] = {}
194
+ for r in rows:
195
+ by_cmd[r.command] = by_cmd.get(r.command, 0) + 1
196
+ for cmd, count in sorted(by_cmd.items(), key=lambda kv: -kv[1])[:top_n]:
197
+ lines.append(f"{cmd}: {count}")
198
+ return "\n".join(lines)
199
+
200
+ async def user_stats(self, user_id: str, window_secs: float, window_text: str, top_n: int) -> str:
201
+ cutoff = time.time() - window_secs
202
+ rows = await CommandCall.where(
203
+ (CommandCall.ts >= cutoff) & (CommandCall.user_id == user_id)
204
+ ).all()
205
+ if not rows:
206
+ return i18n.t("GovReport.user_empty", user=user_id, window=_fmt_window(window_text))
207
+ total = len(rows)
208
+ ok = sum(1 for r in rows if r.success)
209
+ lines = [i18n.t(
210
+ "GovReport.user_header",
211
+ user=user_id, window=_fmt_window(window_text), total=total, ok=ok, failed=total - ok,
212
+ )]
213
+ by_cmd: dict[str, int] = {}
214
+ for r in rows:
215
+ by_cmd[r.command] = by_cmd.get(r.command, 0) + 1
216
+ for cmd, count in sorted(by_cmd.items(), key=lambda kv: -kv[1])[:top_n]:
217
+ lines.append(f"{cmd}: {count}")
218
+ return "\n".join(lines)
219
+
220
+ async def blocked_stats(self, window_secs: float, window_text: str, top_n: int) -> str:
221
+ cutoff = time.time() - window_secs
222
+ rows = await BlockEvent.where(BlockEvent.ts >= cutoff).all()
223
+ if not rows:
224
+ return i18n.t("GovReport.blocked_empty", window=_fmt_window(window_text))
225
+ lines = [i18n.t("GovReport.blocked_header", window=_fmt_window(window_text), total=len(rows))]
226
+ by_source: dict[str, int] = {}
227
+ by_name: dict[str, int] = {}
228
+ for r in rows:
229
+ by_source[r.source] = by_source.get(r.source, 0) + 1
230
+ if r.source == "middleware":
231
+ name = f"middleware:{r.middleware or '-'}"
232
+ else:
233
+ name = f"scope:{r.dimension or '-'}:{r.module or '-'}"
234
+ by_name[name] = by_name.get(name, 0) + 1
235
+ rendered = " ".join(f"{k}={v}" for k, v in sorted(by_source.items()))
236
+ lines.append(f"by_source: {rendered}")
237
+ for name, count in sorted(by_name.items(), key=lambda kv: -kv[1])[:top_n]:
238
+ lines.append(f"{name}: {count}")
239
+ return "\n".join(lines)
240
+
241
+ async def usage_report(self) -> str:
242
+ from ErisPulse import sdk
243
+
244
+ storage = sdk.storage
245
+ root_key = USAGE_KEY_PREFIX.split(".")[0]
246
+ nested_prefix = USAGE_KEY_PREFIX.split(".", 1)[1] + SEP
247
+ try:
248
+ root = await storage.aget(root_key)
249
+ except Exception as e:
250
+ self._logger.warning(f"读取配额根键失败: {e}")
251
+ root = None
252
+ entries: dict[str, Any] = {}
253
+ if isinstance(root, dict):
254
+ entries = {
255
+ k: v for k, v in root.items()
256
+ if isinstance(k, str) and k.startswith(nested_prefix)
257
+ }
258
+ if not entries:
259
+ return i18n.t("GovReport.usage_empty")
260
+
261
+ limits = self._usage_limits()
262
+ lines = [i18n.t("GovReport.usage_header", count=len(entries))]
263
+ for key in sorted(entries):
264
+ parts = key[len(nested_prefix):].split(SEP)
265
+ main_name = parts[0] if parts else "?"
266
+ scope = parts[1] if len(parts) > 1 else "?"
267
+ period = parts[2] if len(parts) > 2 else "?"
268
+ used = entries[key]
269
+ limit, unit = limits.get(main_name, (None, ""))
270
+ limit_text = f"/{limit}({unit})" if limit is not None else ""
271
+ lines.append(f"{main_name}: {used}{limit_text} period={period} scope={scope}")
272
+ return "\n".join(lines)
273
+
274
+ @staticmethod
275
+ def _usage_limits() -> dict[str, tuple[Any, str]]:
276
+ from ErisPulse.Core.Event import command
277
+
278
+ limits: dict[str, tuple[Any, str]] = {}
279
+ try:
280
+ registry = command.get_commands()
281
+ except Exception:
282
+ return limits
283
+ for info in registry.values():
284
+ spec = info.get("usage_spec")
285
+ main_name = info.get("main_name")
286
+ if spec and main_name:
287
+ limits[main_name] = (spec[0], spec[1])
288
+ return limits
289
+
290
+ def governance_list(self, name_filter: Optional[str] = None) -> str:
291
+ from ErisPulse.Core.Event import command
292
+
293
+ registry = command.get_commands()
294
+ lines = []
295
+ for cmd_name, info in sorted(registry.items()):
296
+ if cmd_name != info.get("main_name"):
297
+ continue
298
+ if name_filter and name_filter not in cmd_name:
299
+ continue
300
+ parts = []
301
+ cooldown = info.get("cooldown_seconds")
302
+ if cooldown:
303
+ parts.append(f"cooldown={cooldown:g}s({info.get('cooldown_key') or 'user'})")
304
+ rate = info.get("rate_limit_spec")
305
+ if rate:
306
+ parts.append(f"rate={rate[0]}/{rate[1]:g}s({info.get('rate_limit_key') or 'user'})")
307
+ usage = info.get("usage_spec")
308
+ if usage:
309
+ parts.append(f"usage={usage[0]}/{usage[1]}({info.get('usage_limit_key') or 'user'})")
310
+ deprecated = info.get("deprecated")
311
+ if deprecated:
312
+ reject = "reject" if info.get("deprecated_reject") else "notice"
313
+ parts.append(f"deprecated[{reject}]={deprecated}")
314
+ if parts:
315
+ lines.append(f"{cmd_name}: {' '.join(parts)}")
316
+ if not lines:
317
+ return i18n.t("GovReport.gov_list_empty")
318
+ return "\n".join([i18n.t("GovReport.gov_list_header", count=len(lines))] + lines)
319
+
320
+ async def purge(self, days: int) -> tuple[int, int]:
321
+ cutoff = time.time() - days * 86400
322
+ calls = await CommandCall.delete_all(CommandCall.ts < cutoff)
323
+ blocks = await BlockEvent.delete_all(BlockEvent.ts < cutoff)
324
+ return calls, blocks
325
+
326
+ async def purge_expired(self, retention_days: int) -> None:
327
+ if retention_days <= 0:
328
+ return
329
+ calls, blocks = await self.purge(retention_days)
330
+ if calls or blocks:
331
+ self._logger.info(f"清理过期记录: 命令 {calls} 条, 拦截 {blocks} 条")
@@ -0,0 +1,190 @@
1
+ import asyncio
2
+ from typing import Optional
3
+
4
+ PALETTES = {
5
+ "light": {
6
+ "page": "#f4f6f9", "card": "#ffffff", "ink": "#2b3440", "sub": "#7d8a99",
7
+ "accent": "#4a7fb5", "accent2": "#5aa27b", "warn": "#c0804a", "err": "#bf6a6a",
8
+ "accent_soft": "#e3edf7", "border": "#dfe6ee", "chipbg": "#f0f4f9", "codebg": "#eef2f7",
9
+ },
10
+ "dark": {
11
+ "page": "#171b21", "card": "#20262e", "ink": "#e6ebf1", "sub": "#93a1b0",
12
+ "accent": "#7aa9d9", "accent2": "#7fc29a", "warn": "#d3a569", "err": "#d98a8a",
13
+ "accent_soft": "rgba(122,169,217,0.14)", "border": "#2c343e",
14
+ "chipbg": "rgba(122,169,217,0.08)", "codebg": "#262d36",
15
+ },
16
+ }
17
+
18
+ _FONTS = '"Noto Sans SC", "Roboto", "PingFang SC", "Microsoft YaHei", sans-serif'
19
+ _FONTS_MONO = '"JetBrains Mono", "Cascadia Code", Consolas, ui-monospace, monospace'
20
+
21
+ _CSS_TPL = """
22
+ * { margin: 0; padding: 0; box-sizing: border-box; }
23
+ body { font-family: __FONTS__; color: __INK__; -webkit-font-smoothing: antialiased; }
24
+ .page { background: __PAGE__; padding: 24px; }
25
+ .card { background: __CARD__; border: 1px solid __BORDER__; border-radius: 16px; overflow: hidden; }
26
+ .head { display: flex; justify-content: space-between; align-items: center; padding: 18px 24px; border-bottom: 1px solid __BORDER__; }
27
+ .title { font-size: 19px; font-weight: 700; letter-spacing: 0.5px; }
28
+ .subtitle { font-size: 12.5px; color: __SUB__; margin-top: 3px; }
29
+ .body { padding: 18px 24px 22px; }
30
+ .nums { display: flex; gap: 12px; }
31
+ .num { flex: 1; background: __CODEBG__; border-radius: 12px; padding: 14px 16px; text-align: center; }
32
+ .num .label { font-size: 12px; color: __SUB__; }
33
+ .num .value { font-size: 26px; font-weight: 700; color: __ACCENT__; margin-top: 4px; }
34
+ .num.ok .value { color: __ACCENT2__; }
35
+ .num.bad .value { color: __ERR__; }
36
+ .num.warn .value { color: __WARN__; }
37
+ .section { margin-top: 18px; }
38
+ .section-title { font-size: 12.5px; color: __SUB__; font-weight: 600; letter-spacing: 1px; margin-bottom: 8px; text-transform: uppercase; }
39
+ .bars { display: flex; flex-direction: column; gap: 8px; }
40
+ .bar-row { display: flex; align-items: center; gap: 10px; }
41
+ .bar-label { flex: 0 0 170px; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: __FONTMONO__; }
42
+ .bar-track { flex: 1; height: 16px; background: __CODEBG__; border-radius: 9999px; overflow: hidden; }
43
+ .bar-fill { height: 100%; border-radius: 9999px; background: __ACCENT__; }
44
+ .bar-value { flex: 0 0 52px; text-align: right; font-size: 12.5px; color: __SUB__; font-family: __FONTMONO__; }
45
+ .kv-grid { display: flex; flex-wrap: wrap; gap: 8px; }
46
+ .kv { background: __CHIPBG__; border: 1px solid __BORDER__; border-radius: 8px; padding: 6px 12px; font-size: 13px; }
47
+ .kv b { color: __ACCENT__; font-weight: 600; }
48
+ .row { padding: 10px 12px; border-radius: 10px; margin-bottom: 6px; background: __CHIPBG__; border: 1px solid __BORDER__; }
49
+ .row:last-child { margin-bottom: 0; }
50
+ .row-main { display: flex; align-items: center; gap: 10px; }
51
+ .row-name { font-family: __FONTMONO__; font-size: 13px; background: __CODEBG__; border-radius: 6px; padding: 3px 8px; }
52
+ .row-dim { font-size: 12px; color: __SUB__; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
53
+ .row-count { font-size: 15px; font-weight: 700; color: __ACCENT__; font-family: __FONTMONO__; }
54
+ .chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
55
+ .chip { font-size: 11.5px; background: __ACCENT_SOFT__; color: __ACCENT__; border-radius: 9999px; padding: 2px 10px; }
56
+ .chip.warn { color: __WARN__; }
57
+ .chip.err { color: __ERR__; }
58
+ .empty { text-align: center; color: __SUB__; font-size: 13px; padding: 18px 0; }
59
+ .foot { padding: 12px 24px; border-top: 1px solid __BORDER__; font-size: 11.5px; color: __SUB__; display: flex; justify-content: space-between; }
60
+ """
61
+
62
+
63
+ def _esc(text) -> str:
64
+ import html
65
+
66
+ return html.escape(str(text))
67
+
68
+
69
+ class CardVisualizer:
70
+
71
+ CARD_WIDTH = 780
72
+
73
+ def __init__(self, sdk, logger):
74
+ self.sdk = sdk
75
+ self.logger = logger
76
+ self._takumi_inst = None
77
+
78
+ @property
79
+ def takumi(self):
80
+ if self._takumi_inst is None:
81
+ inst = None
82
+ try:
83
+ inst = self.sdk.module.get("Takumi")
84
+ except Exception:
85
+ inst = None
86
+ if inst is None:
87
+ inst = getattr(self.sdk, "Takumi", None)
88
+ self._takumi_inst = inst
89
+ return self._takumi_inst
90
+
91
+ def resolve_palette(self, theme: str) -> dict:
92
+ import datetime
93
+
94
+ if theme in PALETTES:
95
+ mode = theme
96
+ else:
97
+ hour = datetime.datetime.now().hour
98
+ mode = "dark" if (hour >= 19 or hour < 7) else "light"
99
+ return dict(PALETTES[mode])
100
+
101
+ def _css(self, palette: dict) -> str:
102
+ css = _CSS_TPL.replace("__FONTS__", _FONTS).replace("__FONTMONO__", _FONTS_MONO)
103
+ for key, value in palette.items():
104
+ css = css.replace(f"__{key.upper()}__", value)
105
+ return css
106
+
107
+ async def render(self, body_html: str, theme: str = "auto") -> Optional[bytes]:
108
+ takumi = self.takumi
109
+ if takumi is None or not hasattr(takumi, "render_html"):
110
+ self.logger.debug("Takumi 不可用,跳过渲染")
111
+ return None
112
+ try:
113
+ result = takumi.render_html(
114
+ f"<div class='page'>{body_html}</div>",
115
+ stylesheets=[self._css(self.resolve_palette(theme))],
116
+ width=self.CARD_WIDTH,
117
+ height=None,
118
+ lang="zh-CN",
119
+ )
120
+ if asyncio.iscoroutine(result):
121
+ result = await result
122
+ return result if isinstance(result, (bytes, bytearray)) else None
123
+ except Exception as e:
124
+ self.logger.warning(f"渲染卡片失败: {e}")
125
+ return None
126
+
127
+ def head(self, title: str, subtitle: str) -> str:
128
+ return (
129
+ f"<div class='head'><div><div class='title'>{_esc(title)}</div>"
130
+ f"<div class='subtitle'>{_esc(subtitle)}</div></div></div>"
131
+ )
132
+
133
+ def foot(self, left: str, right: str) -> str:
134
+ return f"<div class='foot'><span>{_esc(left)}</span><span>{_esc(right)}</span></div>"
135
+
136
+ @staticmethod
137
+ def nums(items) -> str:
138
+ cells = "".join(
139
+ f"<div class='num {cls}'><div class='label'>{_esc(label)}</div>"
140
+ f"<div class='value'>{_esc(value)}</div></div>"
141
+ for label, value, cls in items
142
+ )
143
+ return f"<div class='nums'>{cells}</div>"
144
+
145
+ @staticmethod
146
+ def bars(pairs, color: str = "accent") -> str:
147
+ if not pairs:
148
+ return "<div class='empty'>无数据</div>"
149
+ top = max(v for _, v in pairs) or 1
150
+ fill = f"__ACCENT__" if color == "accent" else f"__{color.upper()}__"
151
+ rows = "".join(
152
+ f"<div class='bar-row'><span class='bar-label'>{_esc(k)}</span>"
153
+ f"<div class='bar-track'><div class='bar-fill' style='width:{max(3, round(v / top * 100))}%;background:{fill}'></div></div>"
154
+ f"<span class='bar-value'>{_esc(v)}</span></div>"
155
+ for k, v in pairs
156
+ )
157
+ return f"<div class='bars'>{rows}</div>"
158
+
159
+ @staticmethod
160
+ def kv_section(title: str, pairs) -> str:
161
+ chips = "".join(f"<div class='kv'>{_esc(k)} <b>{_esc(v)}</b></div>" for k, v in pairs)
162
+ return (
163
+ f"<div class='section'><div class='section-title'>{_esc(title)}</div>"
164
+ f"<div class='kv-grid'>{chips}</div></div>"
165
+ )
166
+
167
+ @staticmethod
168
+ def bar_section(title: str, pairs, color: str = "accent") -> str:
169
+ return (
170
+ f"<div class='section'><div class='section-title'>{_esc(title)}</div>"
171
+ f"{CardVisualizer.bars(pairs, color)}</div>"
172
+ )
173
+
174
+ @staticmethod
175
+ def count_row(name: str, right: str, dim: str = "", chips: str = "") -> str:
176
+ return (
177
+ f"<div class='row'><div class='row-main'>"
178
+ f"<span class='row-name'>{_esc(name)}</span>"
179
+ f"<span class='row-dim'>{_esc(dim)}</span>"
180
+ f"<span class='row-count'>{_esc(right)}</span></div>{chips}</div>"
181
+ )
182
+
183
+ @staticmethod
184
+ def chips(items, cls: str = "") -> str:
185
+ if not items:
186
+ return ""
187
+ rendered = "".join(
188
+ f"<span class='chip {cls}'>{_esc(t)}</span>" for t in items
189
+ )
190
+ return f"<div class='chips'>{rendered}</div>"
GovReport/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .Config import GovReportConfig
2
+ from .I18n import GovReportI18n
3
+ from .Models import BlockEvent, CommandCall
4
+ from .Core import Main
@@ -0,0 +1,160 @@
1
+ Metadata-Version: 2.4
2
+ Name: ErisPulse-GovReport
3
+ Version: 0.1.0
4
+ Summary: 治理观测模块:采集命令执行与事件拦截记录,输出统计报表与配额消耗
5
+ Author: wsu2059q
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Provides-Extra: test
11
+ Requires-Dist: pytest>=7.0; extra == "test"
12
+ Requires-Dist: pytest-asyncio>=0.21; extra == "test"
13
+ Requires-Dist: ErisPulse-Testing; extra == "test"
14
+ Dynamic: license-file
15
+
16
+ <div align="center">
17
+
18
+ <img src=".github/assets/ErisPulseLogo.png" width="180" alt="ErisPulse GovReport" />
19
+
20
+ # ErisPulse GovReport
21
+
22
+ **治理观测模块 —— 命令执行、事件拦截与配额消耗的统计报表。**
23
+
24
+ 基于 ErisPulse 2.9 命令治理与生命周期事件的观测模块,使用内置 ORM 持久化记录,报表经 Takumi 渲染为卡片。
25
+
26
+ <p>
27
+ <a href="https://pypi.org/project/ErisPulse-GovReport/"><img src="https://img.shields.io/pypi/v/ErisPulse-GovReport?style=for-the-badge&logo=pypi&logoColor=white" alt="PyPI"></a>
28
+ <a href="https://pypi.org/project/ErisPulse-GovReport/"><img src="https://img.shields.io/badge/Python-3.10+-FFD43B?style=for-the-badge&logo=python&logoColor=blue" alt="Python"></a>
29
+ <a href="./LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue?style=for-the-badge" alt="License"></a>
30
+ <a href="https://github.com/ErisPulse/ErisPulse-GovReport"><img src="https://img.shields.io/github/stars/ErisPulse/ErisPulse-GovReport?style=for-the-badge&logo=github&color=brightgreen" alt="Stars"></a>
31
+ <a href="https://pepy.tech/project/ErisPulse-GovReport"><img src="https://img.shields.io/pepy/dt/ErisPulse-GovReport?style=for-the-badge&color=blue" alt="Downloads"></a>
32
+ <a href="https://github.com/ErisPulse/ErisPulse"><img src="https://img.shields.io/badge/Powered_by-ErisPulse-FF6B9D?style=for-the-badge&logo=bookstack&logoColor=white" alt="ErisPulse"></a>
33
+ </p>
34
+
35
+ <p>
36
+ <a href="./README.md">简体中文</a> | <a href="./README.en.md">English</a>
37
+ </p>
38
+
39
+ </div>
40
+
41
+ ---
42
+
43
+ ## 简介
44
+
45
+ GovReport 是基于 [ErisPulse](https://github.com/ErisPulse/ErisPulse/) 架构开发的治理观测模块:
46
+
47
+ - 采集 `command.executed` 命令执行记录(成功/失败/废弃)
48
+ - 采集 `adapter.event.blocked`(中间件否决)与 `scope.blocked`(作用域拦截)记录
49
+ - 读取 `usage_limit` 配额计数(`erispulse.usage` 存储键),对照声明上限展示消耗
50
+ - 枚举全部命令的治理声明(cooldown / rate_limit / usage_limit / deprecated)
51
+ - 记录用内置 ORM(`Model` / `Field`)持久化,按保留天数自动清理
52
+ - 报表经 Takumi 渲染为图片卡片,不可用时降级文本
53
+
54
+ 要求 ErisPulse >= 2.9.0。
55
+
56
+ ---
57
+
58
+ ## 效果展示
59
+
60
+ | `/gov stats` | `/gov blocked` |
61
+ |:---:|:---:|
62
+ | ![stats](.github/assets/demo/stats-light.png) | ![blocked](.github/assets/demo/blocked-light.png) |
63
+
64
+ | `/gov usage` | `/gov list` |
65
+ |:---:|:---:|
66
+ | ![usage](.github/assets/demo/usage-light.png) | ![list](.github/assets/demo/list-light.png) |
67
+
68
+ ---
69
+
70
+
71
+ ---
72
+
73
+ ## 安装
74
+
75
+ ```bash
76
+ epsdk install ErisPulse-GovReport
77
+ ```
78
+
79
+ ---
80
+
81
+ ## 命令
82
+
83
+ | 命令 | 说明 |
84
+ |------|------|
85
+ | `/gov` | 显示用法 |
86
+ | `/gov stats [窗口]` | 命令执行统计(调用 Top / 成功率 / 废弃使用),窗口如 `24h`、`7d`,默认 `24h` |
87
+ | `/gov user <用户ID> [窗口]` | 单用户调用统计 |
88
+ | `/gov blocked [窗口]` | 拦截统计(中间件否决 / 作用域拦截) |
89
+ | `/gov usage` | 配额消耗报表(used/limit + 周期 + 作用域) |
90
+ | `/gov list [命令]` | 治理声明清单(可按命令名过滤) |
91
+ | `/gov purge <天数>` | 清理指定天数之前的记录 |
92
+
93
+ 全部命令需要框架主人权限。
94
+
95
+ ---
96
+
97
+ ## 使用示例
98
+
99
+ ```toml
100
+ # 给命令声明治理规则(本模块负责观测这些规则的运行效果)
101
+ [ErisPulse.event.command]
102
+ prefix = "/"
103
+
104
+ # Python 侧
105
+ # @command("translate", usage_limit="100/day", usage_limit_key="user")
106
+ ```
107
+
108
+ ```python
109
+ from ErisPulse.Core.Event import command
110
+
111
+ @command("translate", cooldown="30s", rate_limit="5/minute",
112
+ usage_limit="100/day", usage_limit_key="user", help="翻译")
113
+ async def translate_handler(event):
114
+ await event.reply("结果")
115
+
116
+ @command("oldcmd", deprecated="请用 /translate", deprecated_reject=True)
117
+ async def old_handler(event):
118
+ ...
119
+ ```
120
+
121
+ ---
122
+
123
+ ## 配置说明
124
+
125
+ ```toml
126
+ [GovReport]
127
+ enabled = true # 是否启用模块
128
+ collect_command_calls = true # 采集命令执行记录
129
+ collect_blocked = true # 采集拦截记录
130
+ retention_days = 30 # 记录保留天数(0 永久保留)
131
+ top_n = 10 # 报表单次展示条目数
132
+ render_card = true # 报表用 Takumi 渲染为卡片
133
+ theme = "auto" # 卡片配色 (auto/light/dark)
134
+ ```
135
+
136
+ | 配置项 | 类型 | 默认值 | 说明 |
137
+ |--------|------|--------|------|
138
+ | `enabled` | bool | `true` | 是否启用模块 |
139
+ | `collect_command_calls` | bool | `true` | 采集 `command.executed` |
140
+ | `collect_blocked` | bool | `true` | 采集中间件否决与作用域拦截 |
141
+ | `retention_days` | int | `30` | 保留天数(0 永久,启动与每日清理) |
142
+ | `top_n` | int | `10` | 报表单次展示条目数 |
143
+ | `render_card` | bool | `true` | 报表渲染为图片卡片 |
144
+ | `theme` | str | `auto` | 卡片配色 |
145
+
146
+ ---
147
+
148
+ ## 注意事项
149
+
150
+ 1. 配额消耗读取自治理系统的存储键(`erispulse.usage`),重启不丢
151
+ 2. 冷却/限流命中默认静默,不在采集范围内(可用 ErisPulse-Testing 的 DispatchTrace 排查单条命令)
152
+ 3. 记录写入框架存储后端(SQLite / MySQL / PostgreSQL 由存储配置决定)
153
+
154
+ ---
155
+
156
+ ## 参考链接
157
+
158
+ - [ErisPulse 主库](https://github.com/ErisPulse/ErisPulse/)
159
+ - [命令治理文档](https://github.com/ErisPulse/ErisPulse/tree/main/docs/zh-CN/getting-started/event-handling.md)
160
+ - [ORM 文档](https://github.com/ErisPulse/ErisPulse/tree/main/docs/zh-CN/developer-guide/orm.md)
@@ -0,0 +1,13 @@
1
+ GovReport/Config.py,sha256=5iKlKXcOgSyhZ2URLYxLASNaw4aKkU0Kd82IsFCAJiY,2250
2
+ GovReport/Core.py,sha256=VXayibRRZXKRHU108hr6ceSPQo-YtHILk1p1t3dSLw8,8517
3
+ GovReport/I18n.py,sha256=dy4BiCerl5m7ZHnQdAlo_qvlfVVdkIGObXm7nA108eQ,9667
4
+ GovReport/Models.py,sha256=tJI-_xhMjzSDKJxmreia3i2UhGEMCzkw2ciCSa_9LsM,1094
5
+ GovReport/Stats.py,sha256=aFIYPd78oc58Q8eeHggc8oCPSOXbAs9zwoEp4ozemGE,13828
6
+ GovReport/Visualizer.py,sha256=dTHCgkB-jLd7vjzzaA_GTsbx3gMscFvr4ObfLp_EJxk,8420
7
+ GovReport/__init__.py,sha256=SffgNmAK1lNORgDCsR-JDNcKMZRwf1Vpm1jU9PGS0Cg,135
8
+ erispulse_govreport-0.1.0.dist-info/licenses/LICENSE,sha256=OO7qHOo6QdvZahvf8NTfkgcVGuh8fqFcxKTvxmbmz_k,1065
9
+ erispulse_govreport-0.1.0.dist-info/METADATA,sha256=38j8h2yw3z2ZZztvnQdm_TyGjniIMcNI_-InX7jEmvE,5911
10
+ erispulse_govreport-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
11
+ erispulse_govreport-0.1.0.dist-info/entry_points.txt,sha256=-sFfjdLvyPa2Yc5689_Sg31sgFd-nx-jSy6tTTfQob8,46
12
+ erispulse_govreport-0.1.0.dist-info/top_level.txt,sha256=ZgBDF5xOVYAHdI38LKA153-RJ98OOBl29MFIiKXQVV4,10
13
+ erispulse_govreport-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [erispulse.module]
2
+ GovReport = GovReport:Main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 wsu2059q
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ GovReport