Compare commits
79
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3edba99d2d | ||
|
|
acddc8a237 | ||
|
|
b4822fef12 | ||
|
|
9ef214f040 | ||
|
|
b608a166c3 | ||
|
|
a1613f8780 | ||
|
|
db5755586d | ||
|
|
619f3d5d63 | ||
|
|
9a9c57578d | ||
|
|
23b3cc3821 | ||
|
|
8a5a8d976e | ||
|
|
64ed087f5c | ||
|
|
9a32f1a897 | ||
|
|
b6f3b1e1f0 | ||
|
|
4a321098f8 | ||
|
|
9b1a807098 | ||
|
|
684dd451bb | ||
|
|
37c94b6713 | ||
|
|
9bdafd3bac | ||
|
|
6f751486e4 | ||
|
|
33d73ca0ef | ||
|
|
9b5f479f0d | ||
|
|
3d1b64971f | ||
|
|
7a786800bd | ||
|
|
b858340890 | ||
|
|
626e15fe89 | ||
|
|
2ab075f095 | ||
|
|
8361c11c4c | ||
|
|
85faef213e | ||
|
|
4340d03a70 | ||
|
|
a223971a3c | ||
|
|
e4477f321c | ||
|
|
d5da207206 | ||
|
|
e5006c6e43 | ||
|
|
d0fa20afe8 | ||
|
|
2d2b3cf6c6 | ||
|
|
137b2934f7 | ||
|
|
246e69f5c8 | ||
|
|
33f29df40e | ||
|
|
d1f0b9d48b | ||
|
|
1f23d92ffb | ||
|
|
c08b5a5d3d | ||
|
|
dc190f1162 | ||
|
|
6b97bb9f49 | ||
|
|
dfd720359a | ||
|
|
c9180b3832 | ||
|
|
80c2288122 | ||
|
|
12260fdf83 | ||
|
|
e58e110dd1 | ||
|
|
cda87a4d65 | ||
|
|
33228faffc | ||
|
|
bc774c74e6 | ||
|
|
3076194328 | ||
|
|
dc28cf6df0 | ||
|
|
7317631a13 | ||
|
|
66890ca63c | ||
|
|
f6e994d95f | ||
|
|
18abe861f9 | ||
|
|
2e616ab6e0 | ||
|
|
6f22cf0282 | ||
|
|
317a0fbf68 | ||
|
|
6c2a5fa344 | ||
|
|
85905d85ca | ||
|
|
2e35da13cc | ||
|
|
aba8a96cb8 | ||
|
|
6851499be2 | ||
|
|
f3eda856b0 | ||
|
|
f5d47ad2bb | ||
|
|
83a834f87e | ||
|
|
36e60af46b | ||
|
|
f747a80a2a | ||
|
|
eab725a541 | ||
|
|
f814f916a5 | ||
|
|
f6e9566f70 | ||
|
|
cb3bf5bf4c | ||
|
|
bab09f4eb3 | ||
|
|
5209911dd8 | ||
|
|
a509dada79 | ||
|
|
ce6846f920 |
Generated
+15
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GitToolBoxProjectSettings">
|
||||
<option name="commitMessageIssueKeyValidationOverride">
|
||||
<BoolValueOverride>
|
||||
<option name="enabled" value="true" />
|
||||
</BoolValueOverride>
|
||||
</option>
|
||||
<option name="commitMessageValidationEnabledOverride">
|
||||
<BoolValueOverride>
|
||||
<option name="enabled" value="true" />
|
||||
</BoolValueOverride>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
@@ -12,6 +12,7 @@
|
||||
2.新增移动端模块 (#73)。
|
||||
3.新增多worker运行支持 (#76)。
|
||||
4.应用新增演示模式 (#78)。
|
||||
5.新增托盘流转业务,覆盖基础资料、仓库入库至退回仓库的全流程、只读报表及 Android PDA 浏览器扫码操作。
|
||||
|
||||
### BUG修复
|
||||
|
||||
@@ -23,6 +24,7 @@
|
||||
### 代码重构
|
||||
|
||||
1.移除对python3.9的支持 (#67)。
|
||||
2.移除AI管理模块。
|
||||
|
||||
### 代码优化
|
||||
|
||||
|
||||
@@ -35,7 +35,17 @@
|
||||
16. 在线构建器:拖动表单元素生成相应的HTML代码。
|
||||
17. 系统接口:根据业务代码自动生成相关的api接口文档。
|
||||
18. 代码生成:配置数据库表信息一键生成前后端代码(python、sql、vue、js),支持下载。
|
||||
19. AI管理:提供AI模型管理和AI对话功能。
|
||||
|
||||
## 托盘流转业务
|
||||
|
||||
本轮新增托盘全生命周期流转管理:
|
||||
|
||||
* 基础资料:菌种、客户、客户现场、产线、仓库、托盘。
|
||||
* 业务流程:仓库入库、工厂装填、发货出库、客户现场使用、退回仓库。
|
||||
* 只读报表:托盘库存、装填记录、发货出库、产线消耗、退回仓库、托盘追溯。
|
||||
* Android PDA 可直接通过浏览器使用响应式 Web 页面,并支持 HID 扫码枪扫码后回车提交。
|
||||
|
||||
本轮交付不是 APP/uni-app 原生端,也未调用设备摄像头。上线前请阅读[托盘流转业务部署说明](./docs/pallet-workflow-deployment.md)。
|
||||
|
||||
## 在线体验
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# 托盘流转业务部署说明
|
||||
|
||||
## 适用范围与当前状态
|
||||
|
||||
本文适用于 MySQL 增量补丁:
|
||||
|
||||
`shierfeng-fastapi-backend/sql/patches/20260724_pallet_workflow_mysql.sql`
|
||||
|
||||
2026-07-27 已在本地主工作区 `.env.dev` 指向的 MySQL 8.0.36 执行该补丁,且完成下述本地核对。其他环境尚未执行,Redis 缓存也未清理;本记录不能替代其他环境的上线审批、备份及恢复演练。
|
||||
|
||||
## 上线前门禁
|
||||
|
||||
执行补丁前必须完成以下准备:
|
||||
|
||||
1. 对目标 MySQL 实例进行完整备份,至少覆盖库结构、业务数据、菜单和角色关联数据,并验证备份可读。
|
||||
2. 在隔离环境完成恢复演练,确认恢复步骤、耗时和责任人,不能只以“备份命令成功”代替可恢复性验证。
|
||||
3. 由 DBA 人工审查补丁、目标库现状、已有同名表结构、固定菜单 ID 和回退方案。
|
||||
4. 申请维护窗口,在补丁执行和缓存清理完成前保持后端停止,不接入业务流量。
|
||||
|
||||
任一门禁未满足时,不得执行补丁。
|
||||
|
||||
## MySQL 补丁执行与核对
|
||||
|
||||
MySQL DDL 会隐式提交,因此必须将补丁的 DDL、DML 分节执行,并在每一节后逐项核对结果。执行失败时不能假设整个补丁已自动回滚,应立即停止后续步骤,由 DBA 根据实际已提交状态决定人工修复或从已验证备份恢复。
|
||||
|
||||
### 1. DDL 节
|
||||
|
||||
1. 先执行补丁中的 DDL 节,逐表核对表结构、索引、外键和约束。
|
||||
2. `CREATE TABLE IF NOT EXISTS` 只会跳过已经存在的同名表,不会修复已存在但结构发生漂移的表。发现同名表时,必须先人工比对实际结构;存在差异时停止执行并制定独立修复方案。
|
||||
3. DDL 节核对通过前,不得继续执行 DML 节。
|
||||
|
||||
### 2. DML 节
|
||||
|
||||
1. DML 节包含固定 64 行菜单 seed。执行后必须核对结果恰为 64 行,并确认稳定字段与补丁定义一致。
|
||||
2. 任一固定 ID 与未知既有数据冲突时,必须停止并由 DBA、业务负责人共同人工处理;不得覆盖、改写或复用未知 ID,也不得绕过冲突继续执行。
|
||||
3. DML 节核对失败时,不得清理缓存或启动后端。
|
||||
|
||||
### 3. 2026-07-27 本地执行记录
|
||||
|
||||
1. 执行前备份:`/private/tmp/shierfeng-fastapi-before-pallet-workflow-20260727-1925.sql`,文件权限 `600`,大小 69,180 字节,SHA-256 为 `a217a7b071c7e3ecda0d4c94dd1010711b6061025148689156405b70a02f6c7e`。已校验文件非空、可读且包含核心系统表;未执行恢复演练。
|
||||
2. DDL 分节执行成功:新增 18 张 `biz_*` InnoDB 表,业务域幂等键的复合唯一索引列顺序已核对。
|
||||
3. DML 分节使用标准 `mysql` 批处理执行,未使用 `--force` 或 continue-on-error 模式。64 条新业务菜单已写入,旧 AI/外链菜单、AI 字典及旧角色映射已核对为 0。
|
||||
4. 新菜单没有自动授予普通角色;后续应通过现有角色菜单机制按实际职责分配。
|
||||
|
||||
## 历史 AI/Agno 遗留表
|
||||
|
||||
曾运行过历史 AI/Agno 版本的数据库可能残留以下 11 张表:
|
||||
|
||||
* `ai_models`
|
||||
* `ai_chat_config`
|
||||
* `ai_sessions`
|
||||
* `ai_memories`
|
||||
* `ai_metrics`
|
||||
* `ai_eval_runs`
|
||||
* `ai_knowledge`
|
||||
* `ai_culture`
|
||||
* `ai_traces`
|
||||
* `ai_spans`
|
||||
* `ai_schema_versions`
|
||||
|
||||
本补丁不会删除或修改这些表。未经另行确认,不得处理这些历史表。后续如需清理,应单独盘点表结构、数据量和外部依赖,完成备份与恢复验证,确认无消费者后,再通过独立变更审批和维护窗口人工执行。
|
||||
|
||||
## Redis 缓存清理
|
||||
|
||||
只有 MySQL 补丁全部执行成功且核对通过后,才能在目标 Redis 实例定向清理以下缓存:
|
||||
|
||||
* `api_cache:login:user:routers:*`
|
||||
* `api_cache:login:user:info:*`
|
||||
* `sys_dict:ai_provider_type`
|
||||
|
||||
对前两类模式应使用 `SCAN` 定位后逐项删除,只处理明确匹配的键;禁止使用 `FLUSHALL`。本轮未执行任何 Redis 缓存清理,不能把上述清单视为已清理证明。
|
||||
|
||||
## 启动与运行态验收
|
||||
|
||||
正式环境只有在 MySQL 补丁成功、逐节核对通过并完成上述定向缓存清理后,才可启动后端并开展运行态验收。至少应核对 `/getRouters` 返回的托盘业务菜单、真实角色权限,以及真实用户与仓库/客户现场映射下的业务访问范围,再验证响应式 Web 页面和 Android PDA HID 扫码回车流程。
|
||||
|
||||
本地环境已使用独立查询参数验证管理员 `/getRouters`:包含 3 个新业务目录和 17 个业务页面,不包含 AI 管理或“施尔丰仓库”。该结果不等于已完成 Redis 清理,也不代表普通角色已完成授权验收。
|
||||
|
||||
## 本轮未验证项
|
||||
|
||||
以下事项需要在后续真实环境中验证,本轮没有完成:
|
||||
|
||||
* 备份恢复演练。
|
||||
* 真实角色与仓库/客户现场映射的端到端(E2E)流程。
|
||||
* MySQL 行锁、并发和事务回滚行为。
|
||||
* Redis 缓存清理。
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,774 @@
|
||||
# 业务菜单一级展示 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
|
||||
|
||||
**Goal:** 将“基础资料维护”“业务管理”“报表查询”提升为独立一级菜单,并让六个目标一级目录按 121、122、123、1、2、3 的顺序展示。
|
||||
|
||||
**Architecture:** Web 菜单继续由后端 /getRouters 根据 sys_menu 动态生成,不增加前端拍平或重排代码。新建库通过 MySQL/PostgreSQL 初始化 SQL 得到目标层级,已有 MySQL 库通过独立、幂等、fail-closed 的 20260727 增量补丁迁移;已执行的 20260724 历史补丁保持不变。
|
||||
|
||||
**Tech Stack:** MySQL 8、PostgreSQL、Python 3.10 unittest、Ruff、Vue 3、Vite 6、Node 20。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构与保护边界
|
||||
|
||||
- 修改 shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py:区分初始化器目标状态与 20260724 补丁历史状态,并新增 20260727 补丁契约。
|
||||
- 修改 shierfeng-fastapi-backend/sql/shierfeng-fastapi.sql:新建 MySQL 数据库的六个一级菜单层级与排序。
|
||||
- 修改 shierfeng-fastapi-backend/sql/shierfeng-fastapi-pg.sql:新建 PostgreSQL 数据库的六个一级菜单层级与排序。
|
||||
- 创建 shierfeng-fastapi-backend/sql/patches/20260727_promote_business_menus_mysql.sql:迁移现有 MySQL 数据库。
|
||||
- 不修改 shierfeng-fastapi-backend/sql/patches/20260724_pallet_workflow_mysql.sql。
|
||||
- 不修改 Web 前端源码、APP、API、权限标识、角色菜单关联、业务子菜单或数据库表结构。
|
||||
- 当前工作区已有 APP、后端/前端环境文件和已暂存 .idea/git_toolbox_prj.xml;每次暂存和提交都必须按路径执行,禁止 git add -A。
|
||||
|
||||
## Task 1:用契约固定初始化器的目标层级,并保留历史补丁语义
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py
|
||||
- Modify: shierfeng-fastapi-backend/sql/shierfeng-fastapi.sql
|
||||
- Modify: shierfeng-fastapi-backend/sql/shierfeng-fastapi-pg.sql
|
||||
- Preserve: shierfeng-fastapi-backend/sql/patches/20260724_pallet_workflow_mysql.sql
|
||||
|
||||
- [ ] **Step 1:先修改契约测试,表达目标状态和历史状态**
|
||||
|
||||
在测试文件顶部保留旧补丁路径,并新增新补丁路径:
|
||||
|
||||
PATCH_PATH = SQL_ROOT / 'patches/20260724_pallet_workflow_mysql.sql'
|
||||
MENU_PROMOTION_PATCH_PATH = SQL_ROOT / 'patches/20260727_promote_business_menus_mysql.sql'
|
||||
|
||||
把 BUSINESS_MENU_SPECS 的前三行改为初始化器目标值,其余 61 行原样保留:
|
||||
|
||||
BUSINESS_MENU_SPECS = """
|
||||
121|0|基础资料维护|businessMaster||1||<NULL>
|
||||
122|0|业务管理|businessOperation||2||<NULL>
|
||||
123|0|报表查询|businessReport||3||<NULL>
|
||||
502|121|菌种资料|strain||1|master:strain:list|master/strain/index
|
||||
|
||||
在 EXPECTED_BUSINESS_MENU_FIELDS 和 BUSINESS_MENU_IDS 定义之后加入:
|
||||
|
||||
LEGACY_PATCH_BUSINESS_MENU_FIELDS = {
|
||||
**EXPECTED_BUSINESS_MENU_FIELDS,
|
||||
121: (1, '基础资料维护', 'businessMaster', '', 10, '', None),
|
||||
122: (1, '业务管理', 'businessOperation', '', 11, '', None),
|
||||
123: (1, '报表查询', 'businessReport', '', 12, '', None),
|
||||
}
|
||||
EXPECTED_TOP_LEVEL_MENU_POSITIONS = {
|
||||
121: ('基础资料维护', 0, 1),
|
||||
122: ('业务管理', 0, 2),
|
||||
123: ('报表查询', 0, 3),
|
||||
1: ('系统管理', 0, 4),
|
||||
2: ('系统监控', 0, 5),
|
||||
3: ('系统工具', 0, 6),
|
||||
}
|
||||
MENU_PROMOTION_IDS = set(EXPECTED_TOP_LEVEL_MENU_POSITIONS)
|
||||
|
||||
不要把 1、2、3 加入 BUSINESS_MENU_IDS;该集合还被“新业务菜单不默认授权角色”的现有测试使用。
|
||||
|
||||
把菜单提取和字段断言改为以下实现:
|
||||
|
||||
def _menu_rows(sql: str, menu_ids: set[int]) -> dict[int, list]:
|
||||
rows = _insert_rows(sql, 'sys_menu')
|
||||
selected = [
|
||||
row
|
||||
for row in rows
|
||||
if row and str(row[0]).isdigit() and int(row[0]) in menu_ids
|
||||
]
|
||||
counts = {
|
||||
menu_id: sum(int(row[0]) == menu_id for row in selected)
|
||||
for menu_id in menu_ids
|
||||
}
|
||||
if any(count != 1 for count in counts.values()):
|
||||
raise AssertionError(f'menu row counts differ: {counts}')
|
||||
return {int(row[0]): row for row in selected}
|
||||
|
||||
|
||||
def _business_menu_rows(sql: str) -> dict[int, list]:
|
||||
return _menu_rows(sql, BUSINESS_MENU_IDS)
|
||||
|
||||
|
||||
def _assert_business_menu_fields(
|
||||
test_case: unittest.TestCase,
|
||||
sql: str,
|
||||
expected_fields: dict[int, tuple] = EXPECTED_BUSINESS_MENU_FIELDS,
|
||||
) -> None:
|
||||
rows = _business_menu_rows(sql)
|
||||
invalid_widths = {
|
||||
menu_id: len(row)
|
||||
for menu_id, row in rows.items()
|
||||
if len(row) != SYS_MENU_VALUE_COUNT
|
||||
}
|
||||
test_case.assertEqual({}, invalid_widths)
|
||||
actual = {
|
||||
menu_id: (
|
||||
int(row[2]),
|
||||
row[1],
|
||||
row[4],
|
||||
row[7],
|
||||
int(row[3]),
|
||||
row[13],
|
||||
row[5],
|
||||
)
|
||||
for menu_id, row in rows.items()
|
||||
}
|
||||
test_case.assertEqual(expected_fields, actual)
|
||||
|
||||
在 test_initializers_define_fixed_business_menus_and_permissions 中把三个根目录断言改成:
|
||||
|
||||
self.assertEqual(
|
||||
('基础资料维护', '0', '1', 'businessMaster', 'M', ''),
|
||||
tuple(rows[121][index] for index in (1, 2, 3, 4, 10, 13)),
|
||||
)
|
||||
self.assertEqual(
|
||||
('业务管理', '0', '2', 'businessOperation', 'M', ''),
|
||||
tuple(rows[122][index] for index in (1, 2, 3, 4, 10, 13)),
|
||||
)
|
||||
self.assertEqual(
|
||||
('报表查询', '0', '3', 'businessReport', 'M', ''),
|
||||
tuple(rows[123][index] for index in (1, 2, 3, 4, 10, 13)),
|
||||
)
|
||||
|
||||
紧接该测试新增六个一级目录的顺序契约:
|
||||
|
||||
def test_initializers_define_expected_top_level_menu_order(self) -> None:
|
||||
for filename in INITIALIZERS:
|
||||
with self.subTest(filename=filename):
|
||||
sql = (SQL_ROOT / filename).read_text(encoding='utf-8')
|
||||
rows = _menu_rows(sql, MENU_PROMOTION_IDS)
|
||||
actual = {
|
||||
menu_id: (row[1], int(row[2]), int(row[3]))
|
||||
for menu_id, row in rows.items()
|
||||
}
|
||||
self.assertEqual(EXPECTED_TOP_LEVEL_MENU_POSITIONS, actual)
|
||||
|
||||
在 test_patch_is_documented_idempotent_and_contains_full_contract 中,将旧补丁字段断言改成:
|
||||
|
||||
_assert_business_menu_fields(
|
||||
self,
|
||||
raw,
|
||||
LEGACY_PATCH_BUSINESS_MENU_FIELDS,
|
||||
)
|
||||
|
||||
同一测试中,旧补丁与当前 MySQL 初始化器的比较只比较不含 parent_id、order_num 的稳定字段:
|
||||
|
||||
compared_indexes = (0, 1, *range(4, 15))
|
||||
self.assertEqual(
|
||||
{
|
||||
menu_id: tuple(row[index] for index in compared_indexes)
|
||||
for menu_id, row in mysql_rows.items()
|
||||
},
|
||||
{
|
||||
menu_id: tuple(row[index] for index in compared_indexes)
|
||||
for menu_id, row in patch_rows.items()
|
||||
},
|
||||
)
|
||||
|
||||
- [ ] **Step 2:运行初始化器契约,确认 RED**
|
||||
|
||||
工作目录:shierfeng-fastapi-backend
|
||||
|
||||
PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest \
|
||||
tests.module_business.test_sql_contract.SqlContractTest.test_initializers_define_fixed_business_menus_and_permissions \
|
||||
tests.module_business.test_sql_contract.SqlContractTest.test_initializers_define_expected_top_level_menu_order \
|
||||
-v
|
||||
|
||||
预期:FAIL。MySQL/PostgreSQL 初始化器仍将 121/122/123 放在 parent_id=1、order_num=10/11/12,1/2/3 仍使用 order_num=1/2/3。
|
||||
|
||||
- [ ] **Step 3:对两份初始化 SQL 做六行最小修改**
|
||||
|
||||
MySQL 的六行目标内容:
|
||||
|
||||
insert into sys_menu values('1', '系统管理', '0', '4', 'system', null, '', '', 1, 0, 'M', '0', '0', '', 'system', 'admin', sysdate(), '', null, '系统管理目录');
|
||||
insert into sys_menu values('2', '系统监控', '0', '5', 'monitor', null, '', '', 1, 0, 'M', '0', '0', '', 'monitor', 'admin', sysdate(), '', null, '系统监控目录');
|
||||
insert into sys_menu values('3', '系统工具', '0', '6', 'tool', null, '', '', 1, 0, 'M', '0', '0', '', 'tool', 'admin', sysdate(), '', null, '系统工具目录');
|
||||
|
||||
insert into sys_menu values('121', '基础资料维护', '0', '1', 'businessMaster', null, '', '', 1, 0, 'M', '0', '0', '', '#', 'admin', sysdate(), '', null, '基础资料维护目录');
|
||||
insert into sys_menu values('122', '业务管理', '0', '2', 'businessOperation', null, '', '', 1, 0, 'M', '0', '0', '', '#', 'admin', sysdate(), '', null, '业务管理目录');
|
||||
insert into sys_menu values('123', '报表查询', '0', '3', 'businessReport', null, '', '', 1, 0, 'M', '0', '0', '', '#', 'admin', sysdate(), '', null, '报表查询目录');
|
||||
|
||||
把 MySQL 注释“托盘业务目录”同步改为“托盘业务一级目录”。
|
||||
|
||||
PostgreSQL 的六行目标内容:
|
||||
|
||||
insert into sys_menu values(1, '系统管理', 0, '4', 'system', null, '', '', 1, 0, 'M', '0', '0', '', 'system', 'admin', current_timestamp, '', null, '系统管理目录');
|
||||
insert into sys_menu values(2, '系统监控', 0, '5', 'monitor', null, '', '', 1, 0, 'M', '0', '0', '', 'monitor', 'admin', current_timestamp, '', null, '系统监控目录');
|
||||
insert into sys_menu values(3, '系统工具', 0, '6', 'tool', null, '', '', 1, 0, 'M', '0', '0', '', 'tool', 'admin', current_timestamp, '', null, '系统工具目录');
|
||||
|
||||
(121, '基础资料维护', 0, '1', 'businessMaster', null, '', '', 1, 0, 'M', '0', '0', '', '#', 'admin', current_timestamp, '', null, ''),
|
||||
(122, '业务管理', 0, '2', 'businessOperation', null, '', '', 1, 0, 'M', '0', '0', '', '#', 'admin', current_timestamp, '', null, ''),
|
||||
(123, '报表查询', 0, '3', 'businessReport', null, '', '', 1, 0, 'M', '0', '0', '', '#', 'admin', current_timestamp, '', null, ''),
|
||||
|
||||
把 PostgreSQL 注释“初始化-托盘业务菜单和权限”同步改为“初始化-托盘业务一级菜单和权限”。
|
||||
|
||||
- [ ] **Step 4:运行 GREEN 和历史补丁兼容回归**
|
||||
|
||||
工作目录:shierfeng-fastapi-backend
|
||||
|
||||
PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest \
|
||||
tests.module_business.test_sql_contract.SqlContractTest.test_initializers_define_fixed_business_menus_and_permissions \
|
||||
tests.module_business.test_sql_contract.SqlContractTest.test_initializers_define_expected_top_level_menu_order \
|
||||
tests.module_business.test_sql_contract.SqlContractTest.test_mysql_and_postgresql_business_menu_fields_are_identical \
|
||||
tests.module_business.test_sql_contract.SqlContractTest.test_patch_is_documented_idempotent_and_contains_full_contract \
|
||||
-v
|
||||
|
||||
预期:4 项均通过;旧 20260724 补丁仍按历史层级通过契约,不发生文件改动。
|
||||
|
||||
- [ ] **Step 5:提交初始化器和对应契约**
|
||||
|
||||
从仓库根目录执行:
|
||||
|
||||
git add -- \
|
||||
shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py \
|
||||
shierfeng-fastapi-backend/sql/shierfeng-fastapi.sql \
|
||||
shierfeng-fastapi-backend/sql/shierfeng-fastapi-pg.sql
|
||||
git commit --only -m '调整:提升业务菜单为一级目录' -- \
|
||||
shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py \
|
||||
shierfeng-fastapi-backend/sql/shierfeng-fastapi.sql \
|
||||
shierfeng-fastapi-backend/sql/shierfeng-fastapi-pg.sql
|
||||
|
||||
提交后确认 .idea/git_toolbox_prj.xml 仍单独处于暂存区。
|
||||
|
||||
## Task 2:新增幂等、fail-closed 的 MySQL 前向补丁
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py
|
||||
- Create: shierfeng-fastapi-backend/sql/patches/20260727_promote_business_menus_mysql.sql
|
||||
|
||||
- [ ] **Step 1:先写新补丁的失败契约**
|
||||
|
||||
在 SqlContractTest 中新增:
|
||||
|
||||
def test_menu_promotion_patch_is_fail_closed_atomic_and_exact(self) -> None:
|
||||
self.assertTrue(
|
||||
MENU_PROMOTION_PATCH_PATH.is_file(),
|
||||
f'missing patch: {MENU_PROMOTION_PATCH_PATH}',
|
||||
)
|
||||
raw = MENU_PROMOTION_PATCH_PATH.read_text(encoding='utf-8')
|
||||
self.assertIn('备份', raw)
|
||||
self.assertIn('未自动执行', raw)
|
||||
self.assertIn('可幂等重跑', raw)
|
||||
self.assertIn('禁止使用 mysql --force', raw)
|
||||
|
||||
statements = _sql_statements(raw)
|
||||
normalized = [_normalized_sql_statement(statement) for statement in statements]
|
||||
expected_table = 'promote_business_menus_expected'
|
||||
guard_table = 'promote_business_menus_guard'
|
||||
|
||||
expected_insert_indexes = [
|
||||
index
|
||||
for index, statement in enumerate(normalized)
|
||||
if statement.startswith(f'insert into {expected_table} ')
|
||||
]
|
||||
self.assertEqual(1, len(expected_insert_indexes))
|
||||
expected_rows = _insert_rows(
|
||||
statements[expected_insert_indexes[0]],
|
||||
expected_table,
|
||||
)
|
||||
self.assertEqual(6, len(expected_rows))
|
||||
actual_expected_rows = {
|
||||
int(row[0]): tuple(row[1:])
|
||||
for row in expected_rows
|
||||
}
|
||||
self.assertEqual(
|
||||
{
|
||||
1: ('系统管理', 'system', None, '', '', '1', '0', 'M', '0', '0', '', 'system', '0', '1', '0', '4'),
|
||||
2: ('系统监控', 'monitor', None, '', '', '1', '0', 'M', '0', '0', '', 'monitor', '0', '2', '0', '5'),
|
||||
3: ('系统工具', 'tool', None, '', '', '1', '0', 'M', '0', '0', '', 'tool', '0', '3', '0', '6'),
|
||||
121: ('基础资料维护', 'businessMaster', None, '', '', '1', '0', 'M', '0', '0', '', '#', '1', '10', '0', '1'),
|
||||
122: ('业务管理', 'businessOperation', None, '', '', '1', '0', 'M', '0', '0', '', '#', '1', '11', '0', '2'),
|
||||
123: ('报表查询', 'businessReport', None, '', '', '1', '0', 'M', '0', '0', '', '#', '1', '12', '0', '3'),
|
||||
},
|
||||
actual_expected_rows,
|
||||
)
|
||||
|
||||
starts = [
|
||||
index
|
||||
for index, statement in enumerate(normalized)
|
||||
if statement == 'start transaction'
|
||||
]
|
||||
commits = [
|
||||
index
|
||||
for index, statement in enumerate(normalized)
|
||||
if statement == 'commit'
|
||||
]
|
||||
self.assertEqual(1, len(starts))
|
||||
self.assertEqual(1, len(commits))
|
||||
start_transaction = starts[0]
|
||||
commit = commits[0]
|
||||
|
||||
row_locks = [
|
||||
index
|
||||
for index, statement in enumerate(normalized)
|
||||
if re.fullmatch(
|
||||
r'select menu_id from sys_menu '
|
||||
r'where menu_id in \(1, 2, 3, 121, 122, 123\) '
|
||||
r'order by menu_id for update',
|
||||
statement,
|
||||
)
|
||||
]
|
||||
self.assertEqual(1, len(row_locks))
|
||||
|
||||
guard_indexes = [
|
||||
index
|
||||
for index, statement in enumerate(normalized)
|
||||
if statement.startswith(f'insert into {guard_table} ')
|
||||
]
|
||||
self.assertEqual(2, len(guard_indexes))
|
||||
precondition_guard = normalized[guard_indexes[0]]
|
||||
postcondition_guard = normalized[guard_indexes[1]]
|
||||
self.assertIn('as precondition_guard_rows', precondition_guard)
|
||||
self.assertIn('as postcondition_guard_rows', postcondition_guard)
|
||||
self.assertEqual(
|
||||
2,
|
||||
precondition_guard.count(
|
||||
"'promote-business-menus-precondition-conflict'"
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
2,
|
||||
postcondition_guard.count(
|
||||
"'promote-business-menus-postcondition-conflict'"
|
||||
),
|
||||
)
|
||||
|
||||
identity_columns = (
|
||||
('menu_name', 'menu_name'),
|
||||
('path', 'path'),
|
||||
('component', 'component'),
|
||||
('query', 'query_value'),
|
||||
('route_name', 'route_name'),
|
||||
('is_frame', 'is_frame'),
|
||||
('is_cache', 'is_cache'),
|
||||
('menu_type', 'menu_type'),
|
||||
('visible', 'visible'),
|
||||
('status', 'status'),
|
||||
('perms', 'perms'),
|
||||
('icon', 'icon'),
|
||||
)
|
||||
for actual_column, expected_column in identity_columns:
|
||||
comparison = (
|
||||
f'actual.{actual_column} <=> expected.{expected_column}'
|
||||
)
|
||||
self.assertIn(comparison, precondition_guard)
|
||||
self.assertIn(comparison, postcondition_guard)
|
||||
|
||||
compact_precondition = re.sub(r'\s+', '', precondition_guard)
|
||||
self.assertIn('count(actual.menu_id)=6', compact_precondition)
|
||||
self.assertIn(
|
||||
'sum((actual.parent_id<=>expected.old_parent_id)'
|
||||
'and(actual.order_num<=>expected.old_order_num))=6',
|
||||
compact_precondition,
|
||||
)
|
||||
self.assertIn(
|
||||
'sum((actual.parent_id<=>expected.new_parent_id)'
|
||||
'and(actual.order_num<=>expected.new_order_num))=6',
|
||||
compact_precondition,
|
||||
)
|
||||
compact_postcondition = re.sub(r'\s+', '', postcondition_guard)
|
||||
self.assertIn('count(actual.menu_id)=6', compact_postcondition)
|
||||
self.assertIn(
|
||||
'sum((actual.parent_id<=>expected.new_parent_id)'
|
||||
'and(actual.order_num<=>expected.new_order_num))=6',
|
||||
compact_postcondition,
|
||||
)
|
||||
|
||||
expected_update = (
|
||||
'update sys_menu set parent_id = case '
|
||||
'when menu_id in (121, 122, 123) then 0 '
|
||||
'else parent_id end, order_num = case menu_id '
|
||||
'when 121 then 1 when 122 then 2 when 123 then 3 '
|
||||
'when 1 then 4 when 2 then 5 when 3 then 6 '
|
||||
'else order_num end '
|
||||
'where menu_id in (1, 2, 3, 121, 122, 123)'
|
||||
)
|
||||
update_indexes = [
|
||||
index
|
||||
for index, statement in enumerate(normalized)
|
||||
if statement == expected_update
|
||||
]
|
||||
self.assertEqual(1, len(update_indexes))
|
||||
update_index = update_indexes[0]
|
||||
|
||||
permanent_mutations = [
|
||||
statement
|
||||
for statement in normalized
|
||||
if re.match(
|
||||
r'(?:insert\s+into|replace\s+into|update|delete\s+from)\s+sys_',
|
||||
statement,
|
||||
)
|
||||
]
|
||||
self.assertEqual([expected_update], permanent_mutations)
|
||||
self.assertEqual(
|
||||
(
|
||||
start_transaction,
|
||||
row_locks[0],
|
||||
guard_indexes[0],
|
||||
update_index,
|
||||
guard_indexes[1],
|
||||
commit,
|
||||
),
|
||||
tuple(
|
||||
sorted(
|
||||
(
|
||||
start_transaction,
|
||||
row_locks[0],
|
||||
guard_indexes[0],
|
||||
update_index,
|
||||
guard_indexes[1],
|
||||
commit,
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
expected_verification = (
|
||||
'select menu_id, menu_name, parent_id, order_num, path, component, '
|
||||
'menu_type, visible, status, perms from sys_menu '
|
||||
'where menu_id in (1, 2, 3, 121, 122, 123) '
|
||||
'order by order_num, menu_id'
|
||||
)
|
||||
self.assertEqual(expected_verification, normalized[-1])
|
||||
|
||||
- [ ] **Step 2:运行新补丁契约,确认 RED**
|
||||
|
||||
工作目录:shierfeng-fastapi-backend
|
||||
|
||||
PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest \
|
||||
tests.module_business.test_sql_contract.SqlContractTest.test_menu_promotion_patch_is_fail_closed_atomic_and_exact \
|
||||
-v
|
||||
|
||||
预期:FAIL,错误指出缺少 20260727_promote_business_menus_mysql.sql。
|
||||
|
||||
- [ ] **Step 3:创建最小 MySQL 前向补丁**
|
||||
|
||||
创建 shierfeng-fastapi-backend/sql/patches/20260727_promote_business_menus_mysql.sql,内容如下:
|
||||
|
||||
-- 将托盘业务目录 121/122/123 提升为一级菜单,并将系统菜单 1/2/3 后移。
|
||||
-- 本补丁只更新 sys_menu.parent_id 和 sys_menu.order_num。
|
||||
-- 本文件未自动执行;执行前必须备份目标数据库。
|
||||
--
|
||||
-- 必须在独立连接中使用标准 mysql batch 执行,并确保客户端遇错立即退出。
|
||||
-- 禁止使用 mysql --force;guard 的 Duplicate entry 是故意设计的 fail-closed 错误,
|
||||
-- 不能忽略错误继续执行到 commit。
|
||||
--
|
||||
-- 允许的写前状态只有两种:
|
||||
-- 1. 20260724 旧状态;
|
||||
-- 2. 本补丁目标状态,因此可幂等重跑。
|
||||
-- 缺行、固定 ID 身份冲突或新旧状态混杂都会在永久更新前中止。
|
||||
|
||||
drop temporary table if exists promote_business_menus_expected;
|
||||
create temporary table promote_business_menus_expected (
|
||||
menu_id bigint not null,
|
||||
menu_name varchar(50) not null,
|
||||
path varchar(200),
|
||||
component varchar(255),
|
||||
query_value varchar(255),
|
||||
route_name varchar(50),
|
||||
is_frame int,
|
||||
is_cache int,
|
||||
menu_type char(1),
|
||||
visible char(1),
|
||||
status char(1),
|
||||
perms varchar(100),
|
||||
icon varchar(100),
|
||||
old_parent_id bigint not null,
|
||||
old_order_num int not null,
|
||||
new_parent_id bigint not null,
|
||||
new_order_num int not null,
|
||||
primary key (menu_id)
|
||||
) engine=innodb;
|
||||
|
||||
insert into promote_business_menus_expected (
|
||||
menu_id, menu_name, path, component, query_value, route_name,
|
||||
is_frame, is_cache, menu_type, visible, status, perms, icon,
|
||||
old_parent_id, old_order_num, new_parent_id, new_order_num
|
||||
) values
|
||||
(1, '系统管理', 'system', null, '', '', 1, 0, 'M', '0', '0', '', 'system', 0, 1, 0, 4),
|
||||
(2, '系统监控', 'monitor', null, '', '', 1, 0, 'M', '0', '0', '', 'monitor', 0, 2, 0, 5),
|
||||
(3, '系统工具', 'tool', null, '', '', 1, 0, 'M', '0', '0', '', 'tool', 0, 3, 0, 6),
|
||||
(121, '基础资料维护', 'businessMaster', null, '', '', 1, 0, 'M', '0', '0', '', '#', 1, 10, 0, 1),
|
||||
(122, '业务管理', 'businessOperation', null, '', '', 1, 0, 'M', '0', '0', '', '#', 1, 11, 0, 2),
|
||||
(123, '报表查询', 'businessReport', null, '', '', 1, 0, 'M', '0', '0', '', '#', 1, 12, 0, 3);
|
||||
|
||||
drop temporary table if exists promote_business_menus_guard;
|
||||
create temporary table promote_business_menus_guard (
|
||||
guard_token varchar(64) not null,
|
||||
primary key (guard_token)
|
||||
) engine=innodb;
|
||||
|
||||
start transaction;
|
||||
|
||||
-- 锁定目标行,防止身份校验与更新之间被并发修改。
|
||||
select menu_id
|
||||
from sys_menu
|
||||
where menu_id in (1, 2, 3, 121, 122, 123)
|
||||
order by menu_id
|
||||
for update;
|
||||
|
||||
-- 写前身份和整体状态校验。失败时重复 token 触发主键冲突并终止 batch。
|
||||
insert into promote_business_menus_guard (guard_token)
|
||||
select guard_token
|
||||
from (
|
||||
select 'promote-business-menus-precondition-conflict' as guard_token
|
||||
union all
|
||||
select if(
|
||||
count(actual.menu_id) = 6
|
||||
and sum(
|
||||
(actual.menu_name <=> expected.menu_name)
|
||||
and (actual.path <=> expected.path)
|
||||
and (actual.component <=> expected.component)
|
||||
and (actual.query <=> expected.query_value)
|
||||
and (actual.route_name <=> expected.route_name)
|
||||
and (actual.is_frame <=> expected.is_frame)
|
||||
and (actual.is_cache <=> expected.is_cache)
|
||||
and (actual.menu_type <=> expected.menu_type)
|
||||
and (actual.visible <=> expected.visible)
|
||||
and (actual.status <=> expected.status)
|
||||
and (actual.perms <=> expected.perms)
|
||||
and (actual.icon <=> expected.icon)
|
||||
) = 6
|
||||
and (
|
||||
sum(
|
||||
(actual.parent_id <=> expected.old_parent_id)
|
||||
and (actual.order_num <=> expected.old_order_num)
|
||||
) = 6
|
||||
or sum(
|
||||
(actual.parent_id <=> expected.new_parent_id)
|
||||
and (actual.order_num <=> expected.new_order_num)
|
||||
) = 6
|
||||
),
|
||||
'promote-business-menus-precondition-verified',
|
||||
'promote-business-menus-precondition-conflict'
|
||||
) as guard_token
|
||||
from promote_business_menus_expected as expected
|
||||
left join sys_menu as actual
|
||||
on actual.menu_id = expected.menu_id
|
||||
) as precondition_guard_rows;
|
||||
|
||||
-- 永久变更只涉及 parent_id 和 order_num。
|
||||
update sys_menu
|
||||
set parent_id = case
|
||||
when menu_id in (121, 122, 123) then 0
|
||||
else parent_id
|
||||
end,
|
||||
order_num = case menu_id
|
||||
when 121 then 1
|
||||
when 122 then 2
|
||||
when 123 then 3
|
||||
when 1 then 4
|
||||
when 2 then 5
|
||||
when 3 then 6
|
||||
else order_num
|
||||
end
|
||||
where menu_id in (1, 2, 3, 121, 122, 123);
|
||||
|
||||
-- 提交前再次校验目标状态,捕获未覆盖行或触发器造成的意外变化。
|
||||
insert into promote_business_menus_guard (guard_token)
|
||||
select guard_token
|
||||
from (
|
||||
select 'promote-business-menus-postcondition-conflict' as guard_token
|
||||
union all
|
||||
select if(
|
||||
count(actual.menu_id) = 6
|
||||
and sum(
|
||||
(actual.menu_name <=> expected.menu_name)
|
||||
and (actual.path <=> expected.path)
|
||||
and (actual.component <=> expected.component)
|
||||
and (actual.query <=> expected.query_value)
|
||||
and (actual.route_name <=> expected.route_name)
|
||||
and (actual.is_frame <=> expected.is_frame)
|
||||
and (actual.is_cache <=> expected.is_cache)
|
||||
and (actual.menu_type <=> expected.menu_type)
|
||||
and (actual.visible <=> expected.visible)
|
||||
and (actual.status <=> expected.status)
|
||||
and (actual.perms <=> expected.perms)
|
||||
and (actual.icon <=> expected.icon)
|
||||
) = 6
|
||||
and sum(
|
||||
(actual.parent_id <=> expected.new_parent_id)
|
||||
and (actual.order_num <=> expected.new_order_num)
|
||||
) = 6,
|
||||
'promote-business-menus-postcondition-verified',
|
||||
'promote-business-menus-postcondition-conflict'
|
||||
) as guard_token
|
||||
from promote_business_menus_expected as expected
|
||||
left join sys_menu as actual
|
||||
on actual.menu_id = expected.menu_id
|
||||
) as postcondition_guard_rows;
|
||||
|
||||
commit;
|
||||
|
||||
drop temporary table promote_business_menus_guard;
|
||||
drop temporary table promote_business_menus_expected;
|
||||
|
||||
-- 固定 ID 事后核对:顺序应为 121、122、123、1、2、3。
|
||||
select menu_id, menu_name, parent_id, order_num, path, component,
|
||||
menu_type, visible, status, perms
|
||||
from sys_menu
|
||||
where menu_id in (1, 2, 3, 121, 122, 123)
|
||||
order by order_num, menu_id;
|
||||
|
||||
- [ ] **Step 4:运行新补丁契约与完整 SQL 契约**
|
||||
|
||||
工作目录:shierfeng-fastapi-backend
|
||||
|
||||
PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest \
|
||||
tests.module_business.test_sql_contract.SqlContractTest.test_menu_promotion_patch_is_fail_closed_atomic_and_exact \
|
||||
-v
|
||||
PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest \
|
||||
tests.module_business.test_sql_contract \
|
||||
-v
|
||||
conda run -n ShiErFeng python -m ruff format --check \
|
||||
tests/module_business/test_sql_contract.py
|
||||
conda run -n ShiErFeng python -m ruff check \
|
||||
tests/module_business/test_sql_contract.py
|
||||
|
||||
预期:新补丁定向契约通过,完整 SQL 契约全部通过,Ruff format/check 均为成功退出。
|
||||
|
||||
- [ ] **Step 5:提交新补丁和对应契约**
|
||||
|
||||
从仓库根目录执行:
|
||||
|
||||
git add -- \
|
||||
shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py \
|
||||
shierfeng-fastapi-backend/sql/patches/20260727_promote_business_menus_mysql.sql
|
||||
git commit --only -m '迁移:新增业务菜单层级补丁' -- \
|
||||
shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py \
|
||||
shierfeng-fastapi-backend/sql/patches/20260727_promote_business_menus_mysql.sql
|
||||
|
||||
提交后再次确认 .idea/git_toolbox_prj.xml 仍单独处于暂存区,APP 和环境文件指纹未变化。
|
||||
|
||||
## Task 3:完成代码级回归验证
|
||||
|
||||
**Files:**
|
||||
|
||||
- Verify only: shierfeng-fastapi-backend
|
||||
- Verify only: shierfeng-fastapi-frontend
|
||||
|
||||
- [ ] **Step 1:运行后端业务测试和 SQL 契约**
|
||||
|
||||
工作目录:shierfeng-fastapi-backend
|
||||
|
||||
PYTHONDONTWRITEBYTECODE=1 conda run -n ShiErFeng python -m unittest discover \
|
||||
-s tests/module_business -p 'test_*.py' -v
|
||||
|
||||
预期:全部业务测试通过,不生成新的 Python 字节码。
|
||||
|
||||
- [ ] **Step 2:运行后端格式和静态检查**
|
||||
|
||||
工作目录:shierfeng-fastapi-backend
|
||||
|
||||
conda run -n ShiErFeng python -m ruff format --check \
|
||||
module_business tests/module_business
|
||||
conda run -n ShiErFeng python -m ruff check \
|
||||
module_business tests/module_business
|
||||
|
||||
预期:两条命令均成功退出,无格式或 lint 错误。
|
||||
|
||||
- [ ] **Step 3:运行 Web 契约与生产构建**
|
||||
|
||||
工作目录:shierfeng-fastapi-frontend
|
||||
|
||||
/Users/jiaolongyan/.nvm/versions/node/v20.20.2/bin/node \
|
||||
--test scripts/verify-business-contract.mjs
|
||||
/Users/jiaolongyan/.nvm/versions/node/v20.20.2/bin/node \
|
||||
/Users/jiaolongyan/.nvm/versions/node/v20.20.2/lib/node_modules/npm/bin/npm-cli.js \
|
||||
run build:prod
|
||||
|
||||
预期:Node 业务契约全部通过,Vite 生产构建成功。构建会读取用户已有的前端环境文件,但本任务不得修改或提交这些文件。
|
||||
|
||||
- [ ] **Step 4:检查 diff、提交边界和受保护改动**
|
||||
|
||||
从仓库根目录执行:
|
||||
|
||||
git diff --check
|
||||
git status --short --branch
|
||||
git diff --cached --name-status
|
||||
|
||||
预期:git diff --check 无输出;本任务代码已按路径提交;.idea/git_toolbox_prj.xml 仍保持用户原有暂存状态;APP、环境文件、缓存、日志和构建产物未被纳入提交。
|
||||
|
||||
## Task 4:经再次确认后迁移本地 MySQL 并验收菜单
|
||||
|
||||
**Files:**
|
||||
|
||||
- Read configuration: shierfeng-fastapi-backend/.env.dev
|
||||
- Execute: shierfeng-fastapi-backend/sql/patches/20260727_promote_business_menus_mysql.sql
|
||||
- Backup output pattern: /private/tmp/shierfeng-fastapi-before-promote-business-menus-YYYYMMDD-HHMMSS.sql
|
||||
|
||||
- [ ] **Step 1:停止并取得数据库写入确认**
|
||||
|
||||
向用户列出:目标数据库来自 .env.dev、将创建新的全库备份、将执行的补丁文件、只更新 sys_menu 六行的 parent_id/order_num、禁止 mysql --force。没有本次明确确认时不得执行后续步骤。
|
||||
|
||||
- [ ] **Step 2:确认后创建新的数据库备份并执行补丁**
|
||||
|
||||
工作目录:shierfeng-fastapi-backend。以下命令不打印密码,且 set -e 保证备份或补丁遇错立即退出:
|
||||
|
||||
set -euo pipefail
|
||||
task_db_type=$(sed -nE "s/^[[:space:]]*DB_TYPE[[:space:]]*=[[:space:]]*'([^']*)'.*/\1/p" .env.dev)
|
||||
task_db_host=$(sed -nE "s/^[[:space:]]*DB_HOST[[:space:]]*=[[:space:]]*'([^']*)'.*/\1/p" .env.dev)
|
||||
task_db_port=$(sed -nE "s/^[[:space:]]*DB_PORT[[:space:]]*=[[:space:]]*([0-9]+).*/\1/p" .env.dev)
|
||||
task_db_user=$(sed -nE "s/^[[:space:]]*DB_USERNAME[[:space:]]*=[[:space:]]*'([^']*)'.*/\1/p" .env.dev)
|
||||
task_db_password=$(sed -nE "s/^[[:space:]]*DB_PASSWORD[[:space:]]*=[[:space:]]*'([^']*)'.*/\1/p" .env.dev)
|
||||
task_db_name=$(sed -nE "s/^[[:space:]]*DB_DATABASE[[:space:]]*=[[:space:]]*'([^']*)'.*/\1/p" .env.dev)
|
||||
test "$task_db_type" = 'mysql'
|
||||
test -n "$task_db_host"
|
||||
test -n "$task_db_port"
|
||||
test -n "$task_db_user"
|
||||
test -n "$task_db_password"
|
||||
test -n "$task_db_name"
|
||||
task_backup_path="/private/tmp/shierfeng-fastapi-before-promote-business-menus-$(date +%Y%m%d-%H%M%S).sql"
|
||||
MYSQL_PWD="$task_db_password" mysqldump \
|
||||
--host="$task_db_host" \
|
||||
--port="$task_db_port" \
|
||||
--user="$task_db_user" \
|
||||
--single-transaction \
|
||||
--routines \
|
||||
--triggers \
|
||||
--events \
|
||||
--no-tablespaces \
|
||||
"$task_db_name" > "$task_backup_path"
|
||||
test -s "$task_backup_path"
|
||||
shasum -a 256 "$task_backup_path"
|
||||
MYSQL_PWD="$task_db_password" mysql \
|
||||
--host="$task_db_host" \
|
||||
--port="$task_db_port" \
|
||||
--user="$task_db_user" \
|
||||
--database="$task_db_name" \
|
||||
< sql/patches/20260727_promote_business_menus_mysql.sql
|
||||
unset task_db_password
|
||||
|
||||
预期:生成新的非空备份并输出其 SHA-256;mysql 使用独立连接、未带 --force;补丁最后输出六个目标菜单,parent_id 均为 0,order_num 依次为 1、2、3、4、5、6。
|
||||
|
||||
- [ ] **Step 3:只读核对数据库菜单和不变量**
|
||||
|
||||
继续使用 .env.dev 解析出的连接参数,在新的 mysql 连接执行:
|
||||
|
||||
select menu_id, menu_name, parent_id, order_num, path, component,
|
||||
menu_type, visible, status, perms
|
||||
from sys_menu
|
||||
where menu_id in (1, 2, 3, 121, 122, 123)
|
||||
order by order_num, menu_id;
|
||||
|
||||
select parent_id, count(*) as child_count
|
||||
from sys_menu
|
||||
where parent_id in (121, 122, 123)
|
||||
group by parent_id
|
||||
order by parent_id;
|
||||
|
||||
select count(*) as business_permission_count
|
||||
from sys_menu
|
||||
where perms regexp '^(master|business|report):';
|
||||
|
||||
预期:六个根目录顺序为 121、122、123、1、2、3;121/122/123 的直属子菜单数为 6、5、6;业务权限菜单总数为 61。
|
||||
|
||||
- [ ] **Step 4:重新登录并核对 /getRouters 与 Web 侧边栏**
|
||||
|
||||
保持现有前后端启动方式不变,退出 Web 登录后重新登录,使现有登录缓存刷新。确认 /getRouters 返回的根节点依次包含“基础资料维护”“业务管理”“报表查询”“系统管理”“系统监控”“系统工具”,三个业务目录不再出现在“系统管理”的 children 中;侧边栏顺序与返回结果一致。不得执行 Redis FLUSHALL,也不修改 APP。
|
||||
|
||||
## 最终验收
|
||||
|
||||
- 新建 MySQL/PostgreSQL 数据库直接得到目标一级菜单层级。
|
||||
- 已有 MySQL 数据库只能从完整旧状态或完整目标状态执行 20260727 补丁;缺行、ID 复用、身份漂移和新旧状态混杂均在永久更新前失败。
|
||||
- 永久 DML 只更新六个 sys_menu 行的 parent_id/order_num,并在提交前复核目标状态。
|
||||
- 20260724 历史补丁、子菜单、API、权限标识、角色关联、Web 动态路由机制与 APP 均保持不变。
|
||||
- 后端业务测试、SQL 契约、Ruff、Web 业务契约和生产构建均通过。
|
||||
@@ -0,0 +1,440 @@
|
||||
# 智链云枢托盘业务系统设计
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
当前项目是基于 Vue 3、Element Plus、Pinia、Vite、FastAPI 和异步 SQLAlchemy 的通用管理后台。Web 菜单由后端 `/getRouters` 根据 `sys_menu`、`sys_role_menu` 动态生成,按钮权限由前端 `v-hasPermi` 控制,真实接口权限由后端 `UserInterfaceAuthDependency` 校验。
|
||||
|
||||
本次以用户提供的流程图为最终业务依据,完成以下目标:
|
||||
|
||||
- 完整移除“AI管理”专属功能。
|
||||
- 删除“施尔丰仓库”外链菜单。
|
||||
- 在“系统管理”下新增托盘基础资料、五类业务操作和六类报表。
|
||||
- 保证菜单、页面、API、按钮权限、后端权限与数据范围一致。
|
||||
- 建立可审计、可并发控制的托盘库存和追溯模型。
|
||||
|
||||
## 2. 已确认的范围
|
||||
|
||||
### 2.1 本次包含
|
||||
|
||||
- Web 前端页面、动态菜单、按钮权限、API 请求和类型约定。
|
||||
- FastAPI 后端 Controller、Service、DAO、DO、VO、状态机和权限校验。
|
||||
- MySQL、PostgreSQL 初始化脚本同步。
|
||||
- 一份仅供审查、不自动执行的 MySQL 增量补丁。
|
||||
- 直接相关的测试、README、CHANGELOG 和依赖声明。
|
||||
- 适配 Android PDA 浏览器的 Web 操作页面。
|
||||
|
||||
### 2.2 本次明确排除
|
||||
|
||||
- 不修改 `shierfeng-fastapi-app` 下任何文件,也不兼容其现有接口逻辑。
|
||||
- 不连接、不写入、不迁移本地 MySQL。
|
||||
- 不生成或执行 PostgreSQL 增量补丁。
|
||||
- 不删除现有数据库中的 AI 业务表、模型配置、聊天配置或 Agno 运行数据。
|
||||
- 不新增在途、签收、拒收、冲销、库存调整、导入、导出或摄像头扫码功能。
|
||||
- 不预置新的业务角色,不给现有普通角色自动分配新增权限。
|
||||
- 不删除用户、角色、菜单、部门、岗位、字典、参数、通知、日志、监控或系统工具等通用功能。
|
||||
|
||||
## 3. 总体架构
|
||||
|
||||
### 3.1 后端
|
||||
|
||||
新增独立的 `module_business`,继续采用现有分层:
|
||||
|
||||
```text
|
||||
module_business/
|
||||
├── controller/
|
||||
├── service/
|
||||
├── dao/
|
||||
├── entity/
|
||||
│ ├── do/
|
||||
│ └── vo/
|
||||
└── domain/
|
||||
```
|
||||
|
||||
模块内部职责划分:
|
||||
|
||||
- `master`:菌种、客户、客户现场、产线、仓库、托盘及用户授权。
|
||||
- `operation`:首次入库、工厂装填、发货、现场使用、退回。
|
||||
- `report`:六类只读报表和托盘追溯。
|
||||
- `domain`:托盘状态枚举、状态迁移规则和跨业务公共校验。
|
||||
|
||||
Controller 只负责协议、参数、认证和权限依赖;Service 负责事务、状态机、数据范围和幂等;DAO 负责持久化查询;前端不得直接指定最终库存或最终状态。
|
||||
|
||||
### 3.2 Web
|
||||
|
||||
新增以下目录,沿用现有页面与请求风格:
|
||||
|
||||
```text
|
||||
src/api/master/
|
||||
src/api/business/
|
||||
src/api/report/
|
||||
src/views/master/
|
||||
src/views/business/
|
||||
src/views/report/
|
||||
src/components/PalletScanInput/
|
||||
```
|
||||
|
||||
PDA 页面属于 Web 页面,使用响应式布局。扫码设备按 HID 键盘输入处理:输入框自动聚焦,扫码头输入二维码后回车提交;不调用摄像头,不引入前端扫码依赖。
|
||||
|
||||
### 3.3 库存一致性
|
||||
|
||||
托盘是业务聚合根。每次状态变更必须在一个数据库事务内同时完成:
|
||||
|
||||
1. 校验接口权限和仓库/客户现场数据范围。
|
||||
2. 锁定托盘当前库存行。
|
||||
3. 校验当前状态、位置、数量和幂等键。
|
||||
4. 写入或确认业务单据。
|
||||
5. 更新托盘当前库存快照。
|
||||
6. 追加不可变托盘事件。
|
||||
|
||||
任何一步失败都整体回滚,多托盘单据不允许部分成功。
|
||||
|
||||
## 4. 主数据模型
|
||||
|
||||
### 4.1 `biz_strain`
|
||||
|
||||
- 菌种是基础品种/SKU,不代表生产批次。
|
||||
- `strain_code` 非空且唯一。
|
||||
- 生产批次、包装规格和本次装填包数保存在装填记录中。
|
||||
- 已被业务引用后只能停用,不能删除。
|
||||
|
||||
### 4.2 `biz_warehouse`
|
||||
|
||||
- 保存仓库编码、名称、地址和启停状态。
|
||||
- `warehouse_code` 非空且唯一。
|
||||
- 通过 `biz_user_warehouse` 与现有 `sys_user` 多对多关联。
|
||||
|
||||
### 4.3 客户、现场与产线
|
||||
|
||||
- `biz_customer`:客户主数据,客户编码唯一。
|
||||
- `biz_customer_site`:归属于客户,现场编码在同一客户下唯一。
|
||||
- `biz_production_line`:仅表示客户现场使用/消耗产线,产线编码在同一现场下唯一。
|
||||
- `biz_user_site`:现有用户与客户现场的多对多关联。
|
||||
- 本次不建立工厂内部装填产线。
|
||||
|
||||
### 4.4 `biz_pallet`
|
||||
|
||||
- 保存托盘编号、容量包数、资产启停状态和固定二维码标识。
|
||||
- `pallet_code`、`qr_token` 非空且唯一,创建后不可修改。
|
||||
- 托盘创建时同时建立 `biz_pallet_inventory`,初始流转状态为 `UNSTOCKED`。
|
||||
- 二维码只编码不可推测的随机 `qr_token`,不包含菌种、批次、数量、客户或权限信息。
|
||||
- 使用后端 `qrcode` 和现有 Pillow 生成可打印二维码;二维码损坏时补打原标识,不允许换码。
|
||||
|
||||
## 5. 业务与库存模型
|
||||
|
||||
### 5.1 单据与事实表
|
||||
|
||||
| 表 | 用途和核心约束 |
|
||||
|---|---|
|
||||
| `biz_inbound_order` | 首次入库单,状态仅为 `DRAFT`、`CONFIRMED`,单号唯一。 |
|
||||
| `biz_inbound_item` | 入库单托盘明细,同一单据内托盘唯一。 |
|
||||
| `biz_filling_record` | 单托盘即时装填;保存菌种、批次、包装规格、初始整数包数、仓库和操作人。 |
|
||||
| `biz_shipment_order` | 发货单,状态仅为 `DRAFT`、`CONFIRMED`,单号唯一。 |
|
||||
| `biz_shipment_item` | 多托盘整托发货明细;包数由确认时的当前余量快照生成,不允许手填拆分。 |
|
||||
| `biz_usage_record` | 每次现场使用事实;保存产线、本次使用包数、使用后余量和幂等键。 |
|
||||
| `biz_return_order` | 两阶段退回单,状态为 `REQUESTED`、`CONFIRMED`。 |
|
||||
| `biz_return_item` | 退回托盘及发起时余量快照;同一退回单内托盘唯一。 |
|
||||
| `biz_pallet_inventory` | 每托盘唯一一行当前状态、位置、余量、当前装填记录、当前绑定产线和版本号。 |
|
||||
| `biz_pallet_event` | 只追加追溯流水;记录来源单据、前后状态、位置、数量、操作人、时间和幂等键。 |
|
||||
|
||||
### 5.2 数量约束
|
||||
|
||||
- 库存最小单位为“包”,所有包数均为整数。
|
||||
- 装填量和使用量必须大于零。
|
||||
- 剩余量必须满足 `0 <= remaining_packages <= pallet.capacity_packages`。
|
||||
- 一个托盘的同一装填周期只能包含一个菌种和一个生产批次,禁止混装。
|
||||
- 当前装填记录在余量归零后关闭;下一次装填形成新的独立记录,不覆盖历史。
|
||||
|
||||
### 5.3 位置约束
|
||||
|
||||
`biz_pallet_inventory` 在任意时刻只能表达一个有效位置:
|
||||
|
||||
- 位于仓库时只能有 `warehouse_id`。
|
||||
- 位于客户现场时只能有 `site_id`。
|
||||
- `UNSTOCKED` 时两者都为空。
|
||||
- `current_line_id` 只能在客户现场第一次使用后设置,并在仓库确认退回时清空。
|
||||
|
||||
## 6. 托盘状态机
|
||||
|
||||
```text
|
||||
UNSTOCKED
|
||||
└─ 首次入库确认 → EMPTY_IN_WAREHOUSE
|
||||
└─ 单托盘装填 → FILLED_IN_WAREHOUSE
|
||||
└─ 发货确认 → AT_SITE_UNUSED
|
||||
├─ 部分使用 → AT_SITE_PARTIAL
|
||||
├─ 全部用完 → AT_SITE_EMPTY
|
||||
└─ 发起退回 → RETURNING
|
||||
AT_SITE_PARTIAL
|
||||
├─ 继续部分使用 → AT_SITE_PARTIAL
|
||||
├─ 全部用完 → AT_SITE_EMPTY
|
||||
└─ 发起退回 → RETURNING
|
||||
AT_SITE_EMPTY
|
||||
└─ 发起退回 → RETURNING
|
||||
RETURNING
|
||||
└─ 仓库确认收货:
|
||||
├─ remaining_packages > 0 → FILLED_IN_WAREHOUSE
|
||||
└─ remaining_packages = 0 → EMPTY_IN_WAREHOUSE
|
||||
```
|
||||
|
||||
补充规则:
|
||||
|
||||
- 发货确认后直接归属目标客户现场,不增加在途或签收状态。
|
||||
- 客户现场第一次使用时选择产线,本次驻场周期内不可变更。
|
||||
- 客户发起退回后冻结使用和再次发货,直到仓库确认。
|
||||
- 仓库确认退回后解除产线绑定。
|
||||
- 带料退回保留原菌种、批次和余量,可再次整托发货;再次到达现场后重新绑定产线。
|
||||
- 空托盘必须退回仓库后才能再次装填。
|
||||
|
||||
## 7. 五类业务流程
|
||||
|
||||
### 7.1 仓库入库
|
||||
|
||||
- 只处理已建档空托盘的首次入库。
|
||||
- 创建草稿,选择目标仓库,连续扫描 `UNSTOCKED` 托盘。
|
||||
- 草稿可编辑或删除;确认后锁定全部托盘并整单转为 `EMPTY_IN_WAREHOUSE`。
|
||||
- 流程图中连续出现的第二个“创建入库单”按重复节点处理。
|
||||
|
||||
### 7.2 工厂装填
|
||||
|
||||
- 扫描 `EMPTY_IN_WAREHOUSE` 托盘。
|
||||
- 校验当前用户拥有该仓库数据权限。
|
||||
- 选择菌种,填写生产批次、包装规格和正整数包数。
|
||||
- 一次操作只处理一个托盘并即时确认。
|
||||
- 成功后创建装填记录,状态转为 `FILLED_IN_WAREHOUSE`。
|
||||
|
||||
### 7.3 发货出库
|
||||
|
||||
- 创建草稿,选择来源仓库和目标客户现场。
|
||||
- 一张发货单允许多个托盘,只允许整托发货。
|
||||
- 当前用户必须同时拥有来源仓库和目标客户现场数据权限。
|
||||
- 确认时按托盘 ID 排序加锁;全部成功后转为 `AT_SITE_UNUSED`。
|
||||
- 不增加在途、签收或拒收流程。
|
||||
|
||||
### 7.4 客户现场使用
|
||||
|
||||
- 用户必须具备 `business:usage:add` 且关联当前客户现场。
|
||||
- 扫码后后端返回托盘、菌种、批次、当前余量和允许操作。
|
||||
- 第一次使用选择该现场下的产线,并锁定至本次退回完成。
|
||||
- 使用包数必须为正整数且不得超过当前余量。
|
||||
- 使用后余量大于零时转为 `AT_SITE_PARTIAL`,等于零时转为 `AT_SITE_EMPTY`。
|
||||
- 使用请求必须携带幂等键,PDA 重复提交不得重复扣减。
|
||||
|
||||
### 7.5 退回仓库
|
||||
|
||||
- 客户现场用户扫描一个或多个同现场托盘,选择目标仓库并发起退回。
|
||||
- 发起后保存余量快照,托盘转为 `RETURNING`,禁止继续使用。
|
||||
- 仓库人员实际收货扫码确认,必须拥有目标仓库数据权限。
|
||||
- 确认时重新校验状态和余量快照,整单成功或整单回滚。
|
||||
- 有余料时转为 `FILLED_IN_WAREHOUSE`,无余料时转为 `EMPTY_IN_WAREHOUSE`。
|
||||
|
||||
## 8. 菜单、页面、API 与权限
|
||||
|
||||
### 8.1 菜单结构
|
||||
|
||||
```text
|
||||
系统管理
|
||||
├─ 基础资料维护
|
||||
│ ├─ 菌种资料
|
||||
│ ├─ 客户资料
|
||||
│ ├─ 客户现场
|
||||
│ ├─ 产线资料
|
||||
│ ├─ 仓库资料
|
||||
│ └─ 托盘资料
|
||||
├─ 业务管理
|
||||
│ ├─ 仓库入库
|
||||
│ ├─ 工厂装填
|
||||
│ ├─ 发货出库
|
||||
│ ├─ 客户现场使用
|
||||
│ └─ 退回仓库
|
||||
└─ 报表查询
|
||||
├─ 托盘库存
|
||||
├─ 装填记录
|
||||
├─ 发货出库
|
||||
├─ 产线消耗
|
||||
├─ 退回仓库
|
||||
└─ 托盘追溯
|
||||
```
|
||||
|
||||
三个目录节点不设置权限字符串。页面菜单使用 `*:list`,按钮节点使用具体操作权限。
|
||||
|
||||
### 8.2 权限映射
|
||||
|
||||
| 页面 | 资源前缀 | 动作 |
|
||||
|---|---|---|
|
||||
| 菌种资料 | `master:strain` | `list/query/add/edit/remove` |
|
||||
| 客户资料 | `master:customer` | `list/query/add/edit/remove` |
|
||||
| 客户现场 | `master:site` | `list/query/add/edit/remove/assign` |
|
||||
| 产线资料 | `master:line` | `list/query/add/edit/remove` |
|
||||
| 仓库资料 | `master:warehouse` | `list/query/add/edit/remove/assign` |
|
||||
| 托盘资料 | `master:pallet` | `list/query/add/edit/remove/print` |
|
||||
| 仓库入库 | `business:inbound` | `list/query/add/edit/remove/confirm` |
|
||||
| 工厂装填 | `business:filling` | `list/query/add` |
|
||||
| 发货出库 | `business:shipment` | `list/query/add/edit/remove/confirm` |
|
||||
| 客户现场使用 | `business:usage` | `list/query/add` |
|
||||
| 退回仓库 | `business:return` | `list/query/request/confirm` |
|
||||
| 托盘库存 | `report:palletinventory` | `list` |
|
||||
| 装填记录 | `report:filling` | `list` |
|
||||
| 发货出库 | `report:shipment` | `list` |
|
||||
| 产线消耗 | `report:consumption` | `list` |
|
||||
| 退回仓库 | `report:return` | `list` |
|
||||
| 托盘追溯 | `report:pallettrace` | `list` |
|
||||
|
||||
每个权限字符串必须同时出现在:
|
||||
|
||||
1. `sys_menu.perms`。
|
||||
2. Web 的 `v-hasPermi` 或页面权限判断。
|
||||
3. 后端 `UserInterfaceAuthDependency`。
|
||||
4. 对应权限测试。
|
||||
|
||||
未授权用户绕过 Web 直接调用接口时返回现有规范的 403。拥有功能权限但没有目标仓库或客户现场关联时,同样拒绝访问。
|
||||
|
||||
### 8.3 API 前缀
|
||||
|
||||
- 主数据:`/master/strain`、`/master/customer`、`/master/site`、`/master/line`、`/master/warehouse`、`/master/pallet`。
|
||||
- 业务:`/business/inbound`、`/business/filling`、`/business/shipment`、`/business/usage`、`/business/return`。
|
||||
- 报表:`/report/pallet-inventory`、`/report/filling`、`/report/shipment`、`/report/consumption`、`/report/return`、`/report/pallet-trace`。
|
||||
|
||||
各业务 Controller 提供自己的扫码查询接口,避免现场操作员被迫获得托盘主数据管理权限。所有写接口由后端重新查询当前状态,不信任前端提交的库存、位置或状态。
|
||||
|
||||
### 8.4 角色
|
||||
|
||||
- 不新增固定业务角色。
|
||||
- 不写入新增菜单对应的普通角色种子。
|
||||
- 超级管理员沿用现有特例获得全部菜单和数据。
|
||||
- 其他用户通过现有角色管理分配菜单和按钮,并通过仓库/客户现场页面分配数据范围。
|
||||
- 角色授权必须包含“系统管理 → 分组目录 → 页面 → 按钮”的完整祖先链。
|
||||
|
||||
## 9. Web 页面行为
|
||||
|
||||
### 9.1 基础资料
|
||||
|
||||
- 沿用现有搜索、分页、表格和弹窗表单风格。
|
||||
- 仓库和客户现场页面提供“分配用户”。
|
||||
- 托盘页面提供二维码生成、查看、打印和补打。
|
||||
- 被引用数据只允许停用;未引用数据才显示删除操作。
|
||||
|
||||
### 9.2 PDA Web
|
||||
|
||||
- 页面使用大尺寸输入区和操作按钮,适配 Android PDA 浏览器。
|
||||
- 扫码输入框在成功、失败和页面恢复后重新聚焦。
|
||||
- 回车触发扫码查询,重复扫码给出明确提示。
|
||||
- 使用 `crypto.randomUUID()` 生成请求幂等键,不新增前端 UUID 依赖。
|
||||
- 前端展示后端返回的状态和允许动作,不自行推算业务状态。
|
||||
|
||||
### 9.3 报表
|
||||
|
||||
六个独立页面只提供查询、筛选和分页:
|
||||
|
||||
- 托盘库存:当前状态、位置、菌种、批次、初始包数和剩余包数。
|
||||
- 装填记录:装填时间、仓库、托盘、菌种、批次、规格和包数。
|
||||
- 发货出库:发货单、客户现场、托盘和发货时余量。
|
||||
- 产线消耗:客户、现场、产线、托盘、菌种、批次和使用包数。
|
||||
- 退回仓库:退回申请、目标仓库、托盘、余量和确认时间。
|
||||
- 托盘追溯:按事件时间展示托盘完整状态、位置和数量变化。
|
||||
|
||||
筛选条件从日期、仓库、客户、现场、产线、菌种、批次、托盘编号和状态中按页面需要选取。本次不提供 Excel 导出。
|
||||
|
||||
## 10. AI 管理与外链菜单清理
|
||||
|
||||
### 10.1 删除范围
|
||||
|
||||
- 删除后端 `module_ai` 全部源码。
|
||||
- 删除 `utils/ai_util.py` 和仅被 AI 使用的 `utils/crypto_util.py`。
|
||||
- 删除 Web `src/api/ai`、`src/views/ai` 和三个 AI 图标。
|
||||
- 删除 13 个 `/ai/*` 接口及 `ai:model:*`、`ai:chat:*` 权限。
|
||||
- 删除 AI 专属缓存命名空间、限流常量和 LiteLLM 日志配置。
|
||||
- 从后端 requirements 删除 Agno 和模型提供商 SDK。
|
||||
- 从前端 `package.json` 删除仅供 AI 使用的 Markdown、Monaco、UUID 等依赖,并删除 Monaco Vite 配置。
|
||||
- README 删除当前 AI 功能说明;CHANGELOG 保留历史新增记录并增加移除记录。
|
||||
|
||||
### 10.2 保留范围
|
||||
|
||||
- 认证、JWT、接口权限、数据范围、自动路由扫描、缓存、日志、限流和通用上传。
|
||||
- `utils/transport_crypto_util.py`。
|
||||
- `@vueuse/core`、Pillow、PyJWT、cryptography 等共享依赖。
|
||||
- 通用 API Key 日志脱敏测试。
|
||||
- 导航栏独立“源码地址”和“文档地址”入口。
|
||||
|
||||
### 10.3 数据库残留策略
|
||||
|
||||
- 两套初始化脚本删除 AI 菜单、角色关联、AI 字典和 AI 表 DDL,确保新环境不再创建 AI 功能。
|
||||
- MySQL 增量补丁删除 AI 菜单元数据、AI 字典元数据和“施尔丰仓库”菜单,但不执行。
|
||||
- 增量补丁不得包含 `DROP TABLE ai_models`、`DROP TABLE ai_chat_config` 或任何 Agno 表删除语句。
|
||||
- 现有 AI 表、行数、敏感配置和备份需求在最终交付中单独列出,后续获得明确确认后再清理。
|
||||
|
||||
## 11. SQL 交付策略
|
||||
|
||||
### 11.1 初始化脚本
|
||||
|
||||
MySQL 和 PostgreSQL 初始化脚本保持语义一致:
|
||||
|
||||
- 删除 AI 和外链菜单种子。
|
||||
- 删除 AI 字典及 AI 表定义。
|
||||
- 新增业务表、唯一约束、检查约束和索引。
|
||||
- 新增三个目录、十一个业务/主数据页面、六个报表页面及按钮权限。
|
||||
- 不重新编号现有无关菜单、字典或序列。
|
||||
- 不给普通角色增加 `sys_role_menu` 记录。
|
||||
|
||||
### 11.2 MySQL 增量补丁
|
||||
|
||||
- 文件名使用日期和业务含义,作为一次性、可审查补丁。
|
||||
- DDL 使用明确表名;菜单删除使用已审计的明确 ID,禁止通配删除。
|
||||
- DDL 与菜单 DML 分节书写,明确 MySQL DDL 隐式提交边界。
|
||||
- 包含业务表创建、菜单与权限插入、AI 元数据和菜单 ID 99 清理。
|
||||
- 不连接、不执行本地 MySQL。
|
||||
- 补丁应用后的 Redis 菜单、用户信息和 AI 字典缓存清理作为部署步骤记录,不在本次自动执行。
|
||||
|
||||
## 12. 错误、并发与审计
|
||||
|
||||
- 认证失败、功能越权和数据范围越权沿用项目现有异常与 403 处理。
|
||||
- 非法数量、资源不存在、托盘状态冲突或单据状态冲突必须返回失败,不得伪成功。
|
||||
- 所有确认、使用和退回请求使用唯一幂等键。
|
||||
- 状态变更使用数据库行锁;多托盘按托盘 ID 排序加锁以降低死锁概率。
|
||||
- `biz_pallet_inventory.version` 用于并发版本校验。
|
||||
- `biz_pallet_event` 只追加、不修改、不删除;`sys_oper_log` 只作为补充审计,不能替代业务追溯。
|
||||
- 已确认单据不可编辑或删除,本次不实现冲销或补偿事件入口。
|
||||
|
||||
## 13. 验证与验收
|
||||
|
||||
### 13.1 后端
|
||||
|
||||
- 运行 `ruff` 和相关 `pytest`。
|
||||
- 验证应用可以导入,OpenAPI 中不存在 `/ai/*`。
|
||||
- 覆盖状态机合法/非法迁移、整数数量边界、超用、幂等、数据范围和 403。
|
||||
- 覆盖入库、装填、发货、使用、退回的事务回滚行为。
|
||||
- 验证二维码生成接口输出有效图形。
|
||||
|
||||
### 13.2 Web
|
||||
|
||||
- 运行项目可用的 lint/check 命令和生产构建。
|
||||
- 检查所有按钮权限和请求 URL 与后端一致。
|
||||
- 检查 PDA 扫码输入、自动聚焦、重复提交和错误展示。
|
||||
- 确认 `shierfeng-fastapi-app` 没有本次任务产生的改动。
|
||||
|
||||
### 13.3 静态和 SQL
|
||||
|
||||
- 使用 `rg` 检查 AI 页面、接口、权限、依赖和配置残留。
|
||||
- 对比 MySQL、PostgreSQL 初始化脚本中的业务表和权限集合。
|
||||
- 检查 MySQL 增量补丁不包含 AI 业务表删除。
|
||||
- 检查菜单组件路径均对应真实 Web 页面。
|
||||
|
||||
### 13.4 本轮无法完成的运行态验收
|
||||
|
||||
由于用户选择只生成而不执行 MySQL 补丁,本轮不能真实验证:
|
||||
|
||||
- 业务表在 MySQL 中的实际建表结果。
|
||||
- 运行态 `/getRouters` 是否返回新增菜单并移除旧菜单。
|
||||
- 不同真实角色和仓库/现场授权数据下的完整端到端流程。
|
||||
- 补丁执行后的 Redis 缓存失效行为。
|
||||
|
||||
这些项目必须在用户后续审查并应用补丁后执行,交付时不得表述为已通过。
|
||||
|
||||
## 14. 实施顺序
|
||||
|
||||
1. 保护现有未提交改动并建立验证基线。
|
||||
2. 删除 AI 专属代码、配置、依赖和外链菜单种子。
|
||||
3. 实现主数据、用户授权、二维码和状态机基础。
|
||||
4. 实现首次入库、装填、发货、现场使用和两阶段退回。
|
||||
5. 实现六类只读报表和托盘追溯。
|
||||
6. 同步两套初始化脚本并生成不执行的 MySQL 增量补丁。
|
||||
7. 执行后端、Web、权限、静态和 SQL 验证。
|
||||
8. 交付修改清单、验证证据、AI 数据库残留和补丁应用后验收项。
|
||||
@@ -0,0 +1,66 @@
|
||||
# 业务菜单提升为一级菜单设计
|
||||
|
||||
## 目标
|
||||
|
||||
将“基础资料维护”、“业务管理”、“报表查询”从“系统管理”下提升为独立一级菜单,使业务入口与系统管理入口在侧边栏中并列展示。
|
||||
|
||||
## 目标层级与顺序
|
||||
|
||||
一级菜单按以下顺序展示:
|
||||
|
||||
1. 基础资料维护(`menu_id=121`)
|
||||
2. 业务管理(`menu_id=122`)
|
||||
3. 报表查询(`menu_id=123`)
|
||||
4. 系统管理(`menu_id=1`)
|
||||
5. 系统监控(`menu_id=2`)
|
||||
6. 系统工具(`menu_id=3`)
|
||||
|
||||
对应的持久化字段为:
|
||||
|
||||
| 菜单 ID | 菜单名称 | `parent_id` | `order_num` |
|
||||
|---|---|---:|---:|
|
||||
| 121 | 基础资料维护 | 0 | 1 |
|
||||
| 122 | 业务管理 | 0 | 2 |
|
||||
| 123 | 报表查询 | 0 | 3 |
|
||||
| 1 | 系统管理 | 0 | 4 |
|
||||
| 2 | 系统监控 | 0 | 5 |
|
||||
| 3 | 系统工具 | 0 | 6 |
|
||||
|
||||
## 架构与改动边界
|
||||
|
||||
项目的 Web 路由和侧边栏由后端 `/getRouters` 根据 `sys_menu` 动态生成,因此应修改菜单数据,不在前端增加拍平或重排逻辑。
|
||||
|
||||
本次仅调整六个目录菜单的层级和排序:
|
||||
|
||||
- 保持业务子菜单、组件路径、API 和权限标识不变。
|
||||
- 保持角色菜单关联不变。
|
||||
- 不修改 APP 端。
|
||||
- 不新增依赖,不改动现有动态路由机制。
|
||||
|
||||
## SQL 与迁移设计
|
||||
|
||||
1. 同步修改 MySQL 和 PostgreSQL 初始化 SQL,使新建数据库直接得到目标层级。
|
||||
2. 保留已执行的 `20260724_pallet_workflow_mysql.sql` 历史补丁,不改写已落库迁移的语义。
|
||||
3. 新增一个 MySQL 增量补丁,同时支持“调整前”和“已调整”两种合法状态,保证可幂等重跑。
|
||||
4. 增量补丁在第一条永久 DML 前校验六个固定 ID 的菜单身份和允许的层级状态。任一 ID 缺失、被复用或稳定字段异常时,在写入前中止。
|
||||
5. 六条更新置于显式事务中,只修改 `parent_id` 和 `order_num`;执行器不得使用 `--force` 或 continue-on-error 模式。
|
||||
6. 补丁末尾输出六个目录的实际层级和排序,用于人工核对。
|
||||
|
||||
## 缓存与运行态
|
||||
|
||||
数据库更新后,不使用 Redis `FLUSHALL`。本地验证通过重新登录触发现有登录缓存失效逻辑,并使用独立查询参数核对 `/getRouters` 的新层级。
|
||||
|
||||
## 测试与验收
|
||||
|
||||
- 先增加失败契约,要求 MySQL/PostgreSQL 初始化 SQL 定义上述六个一级菜单顺序。
|
||||
- 增加增量补丁契约,覆盖写前校验、幂等性、显式事务、精确更新范围和事后查询。
|
||||
- 运行后端 SQL 契约、业务测试、Ruff 检查、前端业务契约和生产构建。
|
||||
- 数据库执行前重新创建备份,并只有在用户再次明确确认后才执行增量补丁。
|
||||
- 数据库验收必须确认六个目录均为 `parent_id=0`,排序为 `1..6`,业务子菜单数量、页面组件和权限标识不变。
|
||||
|
||||
## 不在本次范围内
|
||||
|
||||
- 业务页面的功能、样式或交互调整。
|
||||
- 普通角色的新授权或仓库/现场数据映射。
|
||||
- 历史 AI 数据库表或其他数据清理。
|
||||
- Redis 全库清理。
|
||||
@@ -1,4 +1,4 @@
|
||||
# ShiErFeng 移动端
|
||||
# 智链云枢管理系统移动端
|
||||
|
||||
基于 `uni-app` 的 `vite` + `vue3` + `tailwindcss` 开发。
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ export default {
|
||||
// 应用信息
|
||||
appInfo: {
|
||||
// 应用名称
|
||||
name: "ShiErFeng-FastAPI-APP",
|
||||
name: "智链云枢管理系统",
|
||||
// 应用版本
|
||||
version: "1.9.0",
|
||||
// 应用logo
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "ShiErFeng-FastAPI移动端",
|
||||
"name": "智链云枢管理系统",
|
||||
"appid": "__UNI__25A9D80",
|
||||
"description": "",
|
||||
"versionName": "1.9.0",
|
||||
@@ -61,7 +61,7 @@
|
||||
"port": 9090,
|
||||
"https": false
|
||||
},
|
||||
"title": "ShiErFeng-FastAPI-APP",
|
||||
"title": "智链云枢管理系统",
|
||||
"router": {
|
||||
"mode": "hash",
|
||||
"base": "./"
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
{
|
||||
"path": "pages/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "ShiErFeng-FastAPI移动端",
|
||||
"navigationBarTitleText": "智链云枢管理系统",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
@@ -126,7 +126,7 @@
|
||||
},
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "ShiErFeng",
|
||||
"navigationBarTitleText": "智链云枢管理系统",
|
||||
"navigationBarBackgroundColor": "#FFFFFF"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
>1. 服务条款的确认和接纳</text
|
||||
>
|
||||
<text
|
||||
>欢迎使用ShiErFeng-FastAPI移动端框架(以下简称“本产品”)。本产品的服务所有权和运作权归ShiErFeng-FastAPI所有。用户在使用本产品前,请仔细阅读本协议。当您点击“同意”或实际使用本产品服务时,即视为您已阅读并完全同意本协议的所有条款。</text
|
||||
>欢迎使用智链云枢管理系统(以下简称“本产品”)。本产品的服务所有权和运作权归智链云枢管理系统所有。用户在使用本产品前,请仔细阅读本协议。当您点击“同意”或实际使用本产品服务时,即视为您已阅读并完全同意本协议的所有条款。</text
|
||||
>
|
||||
</view>
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<view>
|
||||
<text class="mb-2 block font-bold text-gray-800">5. 知识产权声明</text>
|
||||
<text
|
||||
>本产品包含的所有内容(包括但不限于文字、图片、音频、视频、软件代码等)的知识产权归ShiErFeng-FastAPI或相关权利人所有。未经授权,任何人不得擅自使用、复制、修改或传播。</text
|
||||
>本产品包含的所有内容(包括但不限于文字、图片、音频、视频、软件代码等)的知识产权归智链云枢管理系统或相关权利人所有。未经授权,任何人不得擅自使用、复制、修改或传播。</text
|
||||
>
|
||||
</view>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<view class="space-y-6 text-justify text-sm leading-relaxed text-gray-600">
|
||||
<view>
|
||||
<text
|
||||
>ShiErFeng-FastAPI非常重视您的隐私保护。本隐私政策旨在向您说明我们如何收集、使用、存储和保护您的个人信息。请您在使用本产品前仔细阅读本政策。</text
|
||||
>智链云枢管理系统非常重视您的隐私保护。本隐私政策旨在向您说明我们如何收集、使用、存储和保护您的个人信息。请您在使用本产品前仔细阅读本政策。</text
|
||||
>
|
||||
</view>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<view class="bg-white pb-6 rounded-b-3xl shadow-sm z-50">
|
||||
<!-- Title -->
|
||||
<view class="pt-12 pb-4 px-4 flex justify-center items-center">
|
||||
<text class="text-lg font-bold text-gray-800">ShiErFeng-FastAPI移动端</text>
|
||||
<text class="text-lg font-bold text-gray-800">智链云枢管理系统</text>
|
||||
</view>
|
||||
|
||||
<!-- Search & Notification -->
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
/>
|
||||
</view>
|
||||
<text class="text-xl font-bold tracking-wide text-gray-800"
|
||||
>ShiErFeng-FastAPI移动端登录</text
|
||||
>智链云枢管理系统登录</text
|
||||
>
|
||||
</view>
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
mode="widthFix"
|
||||
/>
|
||||
<text class="mt-4 text-xl font-bold text-gray-800"
|
||||
>ShiErFeng-FastAPI移动端</text
|
||||
>智链云枢管理系统</text
|
||||
>
|
||||
</view>
|
||||
|
||||
|
||||
@@ -36,22 +36,22 @@ const { proxy } = getCurrentInstance();
|
||||
const list = ref([
|
||||
{
|
||||
icon: "i-mdi-github",
|
||||
title: "ShiErFeng-FastAPI问题",
|
||||
title: "智链云枢管理系统问题",
|
||||
childList: [
|
||||
{
|
||||
title: "ShiErFeng-FastAPI开源吗?",
|
||||
title: "智链云枢管理系统开源吗?",
|
||||
content: "开源",
|
||||
},
|
||||
{
|
||||
title: "ShiErFeng-FastAPI可以商用吗?",
|
||||
title: "智链云枢管理系统可以商用吗?",
|
||||
content: "可以",
|
||||
},
|
||||
{
|
||||
title: "ShiErFeng-FastAPI官网地址多少?",
|
||||
title: "智链云枢管理系统官网地址多少?",
|
||||
content: "https://vfadmin.insistence.tech",
|
||||
},
|
||||
{
|
||||
title: "ShiErFeng-FastAPI文档地址多少?",
|
||||
title: "智链云枢管理系统文档地址多少?",
|
||||
content: "https://vfadmin.insistence.tech",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
/>
|
||||
</view>
|
||||
<text class="text-xl font-bold tracking-wide text-gray-800"
|
||||
>ShiErFeng-FastAPI移动端注册</text
|
||||
>智链云枢管理系统注册</text
|
||||
>
|
||||
</view>
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ DB_PORT = 3306
|
||||
# 数据库用户名
|
||||
DB_USERNAME = 'root'
|
||||
# 数据库密码
|
||||
DB_PASSWORD = 'mysqlroot'
|
||||
DB_PASSWORD = '12345678'
|
||||
# 数据库名称
|
||||
DB_DATABASE = 'shierfeng-fastapi'
|
||||
# 是否开启sqlalchemy日志
|
||||
|
||||
@@ -51,7 +51,7 @@ DB_PORT = 3306
|
||||
# 数据库用户名
|
||||
DB_USERNAME = 'root'
|
||||
# 数据库密码
|
||||
DB_PASSWORD = 'root'
|
||||
DB_PASSWORD = '12345678'
|
||||
# 数据库名称
|
||||
DB_DATABASE = 'shierfeng-fastapi'
|
||||
# 是否开启sqlalchemy日志
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import os
|
||||
|
||||
from cli.runtime import RUNTIME_ENVIRONMENT, RuntimeEnvironmentService
|
||||
|
||||
|
||||
class AppBootstrapService:
|
||||
"""
|
||||
应用引导服务。
|
||||
|
||||
该服务负责解析应用原始入口路径、构建启动命令,并以 `exec`
|
||||
方式将当前 CLI 进程切换为 FastAPI 应用入口进程。
|
||||
"""
|
||||
|
||||
def __init__(self, *, runtime_environment: RuntimeEnvironmentService | None = None) -> None:
|
||||
"""
|
||||
初始化应用引导服务。
|
||||
|
||||
:param runtime_environment: 运行时环境服务
|
||||
:return: None
|
||||
"""
|
||||
self.runtime_environment = runtime_environment or RUNTIME_ENVIRONMENT
|
||||
|
||||
def get_app_entry_path(self) -> str:
|
||||
"""
|
||||
获取应用启动入口路径。
|
||||
|
||||
:return: 应用启动入口绝对路径
|
||||
"""
|
||||
return os.path.join(self.runtime_environment.get_backend_dir(), 'app.py')
|
||||
|
||||
def build_app_run_command(self, env: str) -> list[str]:
|
||||
"""
|
||||
构建应用启动命令。
|
||||
|
||||
:param env: 运行环境
|
||||
:return: 应用启动命令参数列表
|
||||
"""
|
||||
return [
|
||||
self.runtime_environment.get_python_executable(),
|
||||
self.get_app_entry_path(),
|
||||
'--env',
|
||||
env,
|
||||
]
|
||||
|
||||
def exec_app_run_command(self, env: str) -> None:
|
||||
"""
|
||||
以应用原始入口进程启动当前 FastAPI 应用。
|
||||
|
||||
:param env: 运行环境
|
||||
:return: None
|
||||
"""
|
||||
command = self.build_app_run_command(env)
|
||||
os.execvp(command[0], command)
|
||||
|
||||
|
||||
APP_BOOTSTRAP = AppBootstrapService()
|
||||
@@ -0,0 +1,155 @@
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
|
||||
from cli.completion.controller import CompletionCommandController
|
||||
from cli.completion.providers import COMPLETION_PROVIDER_GATEWAY, CompletionProviderGateway
|
||||
from cli.context import OutputOption
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CompletionCommandRegistration:
|
||||
"""
|
||||
completion 子命令注册描述。
|
||||
|
||||
:param name: 子命令名称
|
||||
:param help_text: 子命令帮助文本
|
||||
"""
|
||||
|
||||
name: str
|
||||
help_text: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class CompletionSubcommandRegistrar:
|
||||
"""
|
||||
completion 子命令注册器。
|
||||
|
||||
该对象负责将 `show/install/doctor` 子命令挂载到 completion 应用,
|
||||
将 Typer 命令声明细节从构建器主体中拆出。
|
||||
|
||||
:param completion_provider_gateway: completion 提供器对外网关
|
||||
"""
|
||||
|
||||
completion_provider_gateway: CompletionProviderGateway = field(default_factory=lambda: COMPLETION_PROVIDER_GATEWAY)
|
||||
|
||||
@staticmethod
|
||||
def build_registrations() -> tuple[CompletionCommandRegistration, ...]:
|
||||
"""
|
||||
返回 completion 子命令注册描述列表。
|
||||
|
||||
:return: 子命令注册描述元组
|
||||
"""
|
||||
return (
|
||||
CompletionCommandRegistration(name='show', help_text='输出指定 shell 的 completion 脚本'),
|
||||
CompletionCommandRegistration(name='install', help_text='安装指定 shell 的 completion 脚本'),
|
||||
CompletionCommandRegistration(name='doctor', help_text='检查当前 completion 配置状态'),
|
||||
)
|
||||
|
||||
def register(self, app: typer.Typer, *, controller: CompletionCommandController) -> None:
|
||||
"""
|
||||
向 completion 应用注册全部子命令。
|
||||
|
||||
:param app: completion 子应用
|
||||
:param controller: completion 命令控制器
|
||||
:return: None
|
||||
"""
|
||||
registrations = {registration.name: registration for registration in self.build_registrations()}
|
||||
|
||||
@app.command(registrations['show'].name, help=registrations['show'].help_text)
|
||||
def show(
|
||||
shell: str = typer.Argument(
|
||||
...,
|
||||
help='shell 名称,如 bash、zsh、fish',
|
||||
autocompletion=self.completion_provider_gateway.complete_shell_names,
|
||||
),
|
||||
) -> None:
|
||||
"""
|
||||
输出指定 shell 的 completion 脚本。
|
||||
|
||||
:param shell: shell 名称
|
||||
:return: None
|
||||
"""
|
||||
controller.show(shell)
|
||||
|
||||
@app.command(registrations['install'].name, help=registrations['install'].help_text)
|
||||
def install(
|
||||
shell: str | None = typer.Option(
|
||||
None,
|
||||
'--shell',
|
||||
help='shell 名称,默认自动识别当前 shell',
|
||||
autocompletion=self.completion_provider_gateway.complete_shell_names,
|
||||
),
|
||||
output: OutputOption = 'text',
|
||||
target_file: Path | None = typer.Option(None, '--target-file', help='completion 脚本目标文件路径'),
|
||||
activate: bool = typer.Option(False, '--activate', help='将激活命令写入 shell rc 文件'),
|
||||
rc_file: Path | None = typer.Option(None, '--rc-file', help='自定义 shell rc 文件路径'),
|
||||
force: bool = typer.Option(False, '--force', help='覆盖已存在且内容不同的目标文件'),
|
||||
) -> None:
|
||||
"""
|
||||
安装指定 shell 的 completion 脚本。
|
||||
|
||||
:param shell: shell 名称,默认自动识别当前 shell
|
||||
:param output: 输出格式
|
||||
:param target_file: completion 脚本目标文件路径
|
||||
:param activate: 是否写入 shell rc 文件激活命令
|
||||
:param rc_file: 自定义 shell rc 文件路径
|
||||
:param force: 是否强制覆盖已存在文件
|
||||
:return: None
|
||||
"""
|
||||
controller.install(
|
||||
output,
|
||||
shell=shell,
|
||||
target_file=target_file,
|
||||
activate=activate,
|
||||
rc_file=rc_file,
|
||||
force=force,
|
||||
)
|
||||
|
||||
@app.command(registrations['doctor'].name, help=registrations['doctor'].help_text)
|
||||
def doctor(
|
||||
output: OutputOption = 'text',
|
||||
) -> None:
|
||||
"""
|
||||
检查当前 completion 配置状态。
|
||||
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
controller.doctor(output)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CompletionCommandBuilder:
|
||||
"""
|
||||
completion 子应用构建器。
|
||||
|
||||
该构建器负责装配 `completion` 子应用,并将控制器实例和子命令注册
|
||||
细节收口到类式对象协作中。
|
||||
|
||||
:param completion_subcommand_registrar: completion 子命令注册器
|
||||
"""
|
||||
|
||||
completion_subcommand_registrar: CompletionSubcommandRegistrar = field(
|
||||
default_factory=CompletionSubcommandRegistrar
|
||||
)
|
||||
|
||||
def build(self, root_cli: typer.Typer) -> typer.Typer:
|
||||
"""
|
||||
构建 completion 命令组。
|
||||
|
||||
:param root_cli: 根 Typer 应用
|
||||
:return: completion 子应用
|
||||
"""
|
||||
app = typer.Typer(
|
||||
help='shell completion 相关命令',
|
||||
no_args_is_help=True,
|
||||
context_settings={'help_option_names': ['-h', '--help']},
|
||||
)
|
||||
completion_command_controller = CompletionCommandController(root_cli)
|
||||
self.completion_subcommand_registrar.register(app, controller=completion_command_controller)
|
||||
return app
|
||||
|
||||
|
||||
COMPLETION_COMMAND_BUILDER = CompletionCommandBuilder()
|
||||
@@ -0,0 +1,116 @@
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
|
||||
from cli.completion.doctor import COMPLETION_DOCTOR, CompletionDoctorService
|
||||
from cli.completion.installers import COMPLETION_INSTALLER, CompletionInstallerService
|
||||
from cli.completion.presenter import CompletionCommandPresenter
|
||||
from cli.core import (
|
||||
DEFAULT_CORE_SERVICES,
|
||||
CliContextFactory,
|
||||
CliExecutionService,
|
||||
)
|
||||
|
||||
|
||||
class CompletionCommandController:
|
||||
"""
|
||||
completion 命令控制器。
|
||||
|
||||
该控制器负责组织 `completion` 命令组的上下文准备、payload 生成、
|
||||
文本渲染与脚本输出收口。
|
||||
|
||||
:param root_cli: 根 Typer 应用
|
||||
:param context_factory: CLI 上下文工厂
|
||||
:param execution_service: CLI 执行服务
|
||||
:param presenter: completion 命令文本渲染器
|
||||
:param installer_service: completion 安装与脚本生成服务
|
||||
:param doctor_service: completion 诊断服务
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root_cli: typer.Typer,
|
||||
*,
|
||||
context_factory: CliContextFactory | None = None,
|
||||
execution_service: CliExecutionService | None = None,
|
||||
presenter: CompletionCommandPresenter | None = None,
|
||||
installer_service: CompletionInstallerService | None = None,
|
||||
doctor_service: CompletionDoctorService | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化 completion 命令控制器。
|
||||
|
||||
:param root_cli: 根 Typer 应用
|
||||
:param context_factory: CLI 上下文工厂
|
||||
:param execution_service: CLI 执行服务
|
||||
:param presenter: completion 命令文本渲染器
|
||||
:param installer_service: completion 安装与脚本生成服务
|
||||
:param doctor_service: completion 诊断服务
|
||||
:return: None
|
||||
"""
|
||||
self.root_cli = root_cli
|
||||
self.context_factory = context_factory or DEFAULT_CORE_SERVICES.context_factory
|
||||
self.execution_service = execution_service or DEFAULT_CORE_SERVICES.execution_service
|
||||
self.presenter = presenter or CompletionCommandPresenter()
|
||||
self.installer_service = installer_service or COMPLETION_INSTALLER
|
||||
self.doctor_service = doctor_service or COMPLETION_DOCTOR
|
||||
|
||||
def show(self, shell: str) -> None:
|
||||
"""
|
||||
输出指定 shell 的 completion 脚本。
|
||||
|
||||
:param shell: shell 名称
|
||||
:return: None
|
||||
"""
|
||||
typer.echo(self.installer_service.render_completion_script(self.root_cli, shell), nl=False)
|
||||
|
||||
def install(
|
||||
self,
|
||||
output: str,
|
||||
*,
|
||||
shell: str | None,
|
||||
target_file: Path | None,
|
||||
activate: bool,
|
||||
rc_file: Path | None,
|
||||
force: bool,
|
||||
) -> None:
|
||||
"""
|
||||
安装指定 shell 的 completion 脚本。
|
||||
|
||||
:param output: 输出格式
|
||||
:param shell: shell 名称
|
||||
:param target_file: completion 脚本目标文件路径
|
||||
:param activate: 是否写入 shell rc 文件激活命令
|
||||
:param rc_file: 自定义 shell rc 文件路径
|
||||
:param force: 是否强制覆盖已存在文件
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly('dev', output)
|
||||
payload = self.installer_service.install_completion_script(
|
||||
self.root_cli,
|
||||
shell,
|
||||
target_file=target_file,
|
||||
activate=activate,
|
||||
rc_file=rc_file,
|
||||
force=force,
|
||||
)
|
||||
self.execution_service.complete_payload_result(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=self.presenter.build_completion_install_text,
|
||||
)
|
||||
|
||||
def doctor(self, output: str) -> None:
|
||||
"""
|
||||
检查当前 completion 配置状态。
|
||||
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly('dev', output)
|
||||
payload = self.doctor_service.build_completion_doctor_payload()
|
||||
self.execution_service.complete_payload_result(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=self.presenter.build_completion_doctor_text,
|
||||
)
|
||||
@@ -0,0 +1,95 @@
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from cli.completion.installers import (
|
||||
CLICK_COMPLETE_ENV_VAR,
|
||||
COMPLETION_INSTALLER,
|
||||
CompletionInstallerService,
|
||||
)
|
||||
from cli.metadata import (
|
||||
COMPLETION_SHELL_SPEC_REGISTRY,
|
||||
ENVIRONMENT_OPTION_SERVICE,
|
||||
CompletionShellSpecRegistry,
|
||||
EnvironmentOptionService,
|
||||
)
|
||||
|
||||
|
||||
class CompletionDoctorService:
|
||||
"""
|
||||
completion 诊断服务。
|
||||
|
||||
该服务负责汇总活跃 shell、目标脚本路径、rc 文件状态、推荐安装命令
|
||||
与环境候选列表,生成 `completion doctor` 所需的结构化结果。
|
||||
|
||||
:param installer_service: completion 安装服务
|
||||
:param shell_spec_registry: shell 元数据注册表
|
||||
:param environment_option_service: 环境选项服务
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
installer_service: CompletionInstallerService | None = None,
|
||||
shell_spec_registry: CompletionShellSpecRegistry | None = None,
|
||||
environment_option_service: EnvironmentOptionService | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化 completion 诊断服务。
|
||||
|
||||
:param installer_service: completion 安装服务
|
||||
:param shell_spec_registry: shell 元数据注册表
|
||||
:param environment_option_service: 环境选项服务
|
||||
:return: None
|
||||
"""
|
||||
self.installer_service = installer_service or COMPLETION_INSTALLER
|
||||
self.shell_spec_registry = shell_spec_registry or COMPLETION_SHELL_SPEC_REGISTRY
|
||||
self.environment_option_service = environment_option_service or ENVIRONMENT_OPTION_SERVICE
|
||||
|
||||
def build_completion_doctor_payload(self) -> dict[str, Any]:
|
||||
"""
|
||||
构建 completion 诊断结果。
|
||||
|
||||
:return: 诊断结果字典
|
||||
"""
|
||||
active_shell = self.installer_service.detect_active_shell()
|
||||
shells: dict[str, dict[str, Any]] = {}
|
||||
for shell_name, shell_spec in self.shell_spec_registry.specs.items():
|
||||
target_file = self.installer_service.resolve_completion_target(shell_name)
|
||||
rc_file = self.installer_service.resolve_completion_rc_file(shell_name)
|
||||
source_command = None
|
||||
if shell_spec.supported and shell_spec.generator in {'click', 'custom'}:
|
||||
source_command = self.installer_service.build_source_command(target_file, shell_name)
|
||||
recommended_install_command = f'shierfeng completion install --shell={shell_name}'
|
||||
if shell_spec.supported and not shell_spec.auto_discovery:
|
||||
recommended_install_command = f'{recommended_install_command} --activate'
|
||||
shells[shell_name] = {
|
||||
'supported': shell_spec.supported,
|
||||
'detected': shell_name == active_shell,
|
||||
'description': shell_spec.description,
|
||||
'targetFile': str(target_file),
|
||||
'targetFileExists': target_file.exists(),
|
||||
'rcFile': str(rc_file) if rc_file is not None else None,
|
||||
'rcFileExists': rc_file.exists() if rc_file is not None else None,
|
||||
'autoDiscovery': shell_spec.auto_discovery,
|
||||
'sourceCommand': source_command,
|
||||
'recommendedInstallCommand': recommended_install_command,
|
||||
}
|
||||
|
||||
recommended_shell = active_shell if self.shell_spec_registry.get_spec(active_shell) is not None else None
|
||||
recommended_install_command = None
|
||||
if recommended_shell is not None:
|
||||
recommended_install_command = shells[recommended_shell]['recommendedInstallCommand']
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'message': 'completion 诊断信息已生成',
|
||||
'activeShell': active_shell or None,
|
||||
'projectDir': str(Path.cwd().resolve()),
|
||||
'envChoices': self.environment_option_service.discover_env_names(),
|
||||
'completeEnvVar': CLICK_COMPLETE_ENV_VAR,
|
||||
'recommendedInstallCommand': recommended_install_command,
|
||||
'shells': shells,
|
||||
}
|
||||
|
||||
|
||||
COMPLETION_DOCTOR = CompletionDoctorService()
|
||||
@@ -0,0 +1,465 @@
|
||||
import io
|
||||
import os
|
||||
from contextlib import redirect_stderr
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
import typer
|
||||
import typer.main
|
||||
from click.shell_completion import BashComplete, FishComplete, ZshComplete
|
||||
|
||||
from cli.completion.providers import COMPLETION_PROVIDER_GATEWAY, CompletionProviderGateway
|
||||
from cli.completion.shells import PowerShellComplete, ensure_custom_completion_classes_registered
|
||||
from cli.exit_codes import ARGUMENT_ERROR, RUNTIME_ERROR
|
||||
from cli.metadata import COMPLETION_SHELL_SPEC_REGISTRY, CompletionShellSpec, CompletionShellSpecRegistry
|
||||
|
||||
CLICK_COMPLETE_ENV_VAR = '_SHIERFENG_COMPLETE'
|
||||
|
||||
|
||||
class CompletionInstallerShellSupport:
|
||||
"""
|
||||
completion shell 差异支持对象。
|
||||
|
||||
该对象负责封装不同 shell 在脚本兼容处理和 source 命令构建上的差异,
|
||||
供安装服务通过轻量 strategy 协作对象统一复用。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def keep_script_text(script_text: str) -> str:
|
||||
"""
|
||||
保持脚本文本原样返回。
|
||||
|
||||
:param script_text: 原始脚本文本
|
||||
:return: 原样脚本文本
|
||||
"""
|
||||
return script_text
|
||||
|
||||
@staticmethod
|
||||
def make_bash_completion_script_compatible(script_text: str) -> str:
|
||||
"""
|
||||
对 Click 生成的 Bash completion 脚本做旧版本兼容处理。
|
||||
|
||||
兼容目标主要包括 macOS 默认 Bash 3.2:
|
||||
|
||||
- `complete -o nosort` 在旧 Bash 上会在 `source` 阶段直接报错
|
||||
- `compopt` 在旧 Bash 上可能不存在
|
||||
|
||||
:param script_text: Click 原始 Bash completion 脚本文本
|
||||
:return: 兼容处理后的脚本文本
|
||||
"""
|
||||
compatible_lines: list[str] = []
|
||||
for line in script_text.splitlines():
|
||||
stripped_line = line.strip()
|
||||
if stripped_line == 'compopt -o dirnames':
|
||||
indent = line[: len(line) - len(line.lstrip(' '))]
|
||||
compatible_lines.extend(
|
||||
[
|
||||
f'{indent}if command -v compopt >/dev/null 2>&1; then',
|
||||
f'{indent} compopt -o dirnames',
|
||||
f'{indent}fi',
|
||||
]
|
||||
)
|
||||
continue
|
||||
if stripped_line == 'compopt -o default':
|
||||
indent = line[: len(line) - len(line.lstrip(' '))]
|
||||
compatible_lines.extend(
|
||||
[
|
||||
f'{indent}if command -v compopt >/dev/null 2>&1; then',
|
||||
f'{indent} compopt -o default',
|
||||
f'{indent}fi',
|
||||
]
|
||||
)
|
||||
continue
|
||||
if stripped_line.startswith('complete -o nosort -F '):
|
||||
compatibility_line = stripped_line.replace('complete -o nosort -F ', '', 1)
|
||||
function_name, command_name = compatibility_line.split(' ', 1)
|
||||
compatible_lines.extend(
|
||||
[
|
||||
f'if complete -o nosort -F {function_name} {command_name} 2>/dev/null; then',
|
||||
' :',
|
||||
'else',
|
||||
f' complete -F {function_name} {command_name}',
|
||||
'fi',
|
||||
]
|
||||
)
|
||||
continue
|
||||
compatible_lines.append(line)
|
||||
return '\n'.join(compatible_lines) + '\n'
|
||||
|
||||
@staticmethod
|
||||
def build_posix_source_command(target_file: Path) -> str:
|
||||
"""
|
||||
构建 POSIX shell 使用的 source 命令。
|
||||
|
||||
:param target_file: completion 脚本文件路径
|
||||
:return: source 命令
|
||||
"""
|
||||
return f'source {target_file}'
|
||||
|
||||
@staticmethod
|
||||
def build_fish_source_command(target_file: Path) -> str:
|
||||
"""
|
||||
构建 Fish shell 使用的 source 命令。
|
||||
|
||||
:param target_file: completion 脚本文件路径
|
||||
:return: source 命令
|
||||
"""
|
||||
return f'status --is-interactive; and source {target_file}'
|
||||
|
||||
@staticmethod
|
||||
def build_powershell_source_command(target_file: Path) -> str:
|
||||
"""
|
||||
构建 PowerShell 使用的 source 命令。
|
||||
|
||||
:param target_file: completion 脚本文件路径
|
||||
:return: source 命令
|
||||
"""
|
||||
return f'. "{target_file}"'
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CompletionShellRuntimePolicy:
|
||||
"""
|
||||
completion shell 运行时策略定义。
|
||||
|
||||
:param name: shell 名称
|
||||
:param click_completion_class: Click completion 生成器类型
|
||||
:param script_transformer: 脚本文本后处理函数
|
||||
:param source_command_builder: 激活命令构建函数
|
||||
"""
|
||||
|
||||
name: str
|
||||
click_completion_class: type[Any]
|
||||
script_transformer: Any
|
||||
source_command_builder: Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CompletionShellRuntimePolicyRegistry:
|
||||
"""
|
||||
completion shell 运行时策略注册表。
|
||||
|
||||
:param policies: 按 shell 名称索引的运行时策略映射
|
||||
"""
|
||||
|
||||
policies: dict[str, CompletionShellRuntimePolicy]
|
||||
|
||||
def get(self, shell_name: str) -> CompletionShellRuntimePolicy | None:
|
||||
"""
|
||||
获取指定 shell 的运行时策略。
|
||||
|
||||
:param shell_name: shell 名称
|
||||
:return: 运行时策略,不存在时返回 None
|
||||
"""
|
||||
return self.policies.get(shell_name)
|
||||
|
||||
|
||||
DEFAULT_COMPLETION_SHELL_RUNTIME_POLICIES = CompletionShellRuntimePolicyRegistry(
|
||||
policies={
|
||||
'bash': CompletionShellRuntimePolicy(
|
||||
name='bash',
|
||||
click_completion_class=BashComplete,
|
||||
script_transformer=CompletionInstallerShellSupport.make_bash_completion_script_compatible,
|
||||
source_command_builder=CompletionInstallerShellSupport.build_posix_source_command,
|
||||
),
|
||||
'zsh': CompletionShellRuntimePolicy(
|
||||
name='zsh',
|
||||
click_completion_class=ZshComplete,
|
||||
script_transformer=CompletionInstallerShellSupport.keep_script_text,
|
||||
source_command_builder=CompletionInstallerShellSupport.build_posix_source_command,
|
||||
),
|
||||
'fish': CompletionShellRuntimePolicy(
|
||||
name='fish',
|
||||
click_completion_class=FishComplete,
|
||||
script_transformer=CompletionInstallerShellSupport.keep_script_text,
|
||||
source_command_builder=CompletionInstallerShellSupport.build_fish_source_command,
|
||||
),
|
||||
'powershell': CompletionShellRuntimePolicy(
|
||||
name='powershell',
|
||||
click_completion_class=PowerShellComplete,
|
||||
script_transformer=CompletionInstallerShellSupport.keep_script_text,
|
||||
source_command_builder=CompletionInstallerShellSupport.build_powershell_source_command,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class CompletionInstallerService:
|
||||
"""
|
||||
completion 安装与脚本生成服务。
|
||||
|
||||
该服务负责 shell 元数据解析、脚本生成、目标路径解析、激活命令构建、
|
||||
rc 文件写入以及安装结果收口。
|
||||
|
||||
:param completion_provider_gateway: completion 提供器对外网关
|
||||
:param shell_spec_registry: shell 元数据注册表
|
||||
:param shell_runtime_policy_registry: shell 运行时策略注册表
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
completion_provider_gateway: CompletionProviderGateway | None = None,
|
||||
shell_spec_registry: CompletionShellSpecRegistry | None = None,
|
||||
shell_runtime_policy_registry: CompletionShellRuntimePolicyRegistry | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化 completion 安装服务。
|
||||
|
||||
:param completion_provider_gateway: completion 提供器对外网关
|
||||
:param shell_spec_registry: shell 元数据注册表
|
||||
:param shell_runtime_policy_registry: shell 运行时策略注册表
|
||||
:return: None
|
||||
"""
|
||||
self.completion_provider_gateway = completion_provider_gateway or COMPLETION_PROVIDER_GATEWAY
|
||||
self.shell_spec_registry = shell_spec_registry or COMPLETION_SHELL_SPEC_REGISTRY
|
||||
self.shell_runtime_policy_registry = shell_runtime_policy_registry or DEFAULT_COMPLETION_SHELL_RUNTIME_POLICIES
|
||||
ensure_custom_completion_classes_registered()
|
||||
|
||||
def resolve_completion_shell_spec(self, shell: str) -> CompletionShellSpec:
|
||||
"""
|
||||
获取指定 shell 的 completion 元数据。
|
||||
|
||||
:param shell: shell 名称
|
||||
:return: shell 元数据
|
||||
:raises typer.BadParameter: shell 不存在时抛出异常
|
||||
"""
|
||||
normalized_shell = shell.strip().lower()
|
||||
shell_spec = self.shell_spec_registry.get_spec(normalized_shell)
|
||||
if shell_spec is None:
|
||||
supported_shells = ', '.join(self.completion_provider_gateway.list_completion_shells())
|
||||
raise typer.BadParameter(f'不支持的 shell:{shell},可选值为 {supported_shells}')
|
||||
return shell_spec
|
||||
|
||||
def resolve_shell_runtime_policy(self, shell: str) -> CompletionShellRuntimePolicy:
|
||||
"""
|
||||
获取指定 shell 的运行时策略。
|
||||
|
||||
:param shell: shell 名称
|
||||
:return: shell 运行时策略
|
||||
:raises typer.BadParameter: 当前 shell 未实现运行时策略时抛出异常
|
||||
"""
|
||||
shell_spec = self.resolve_completion_shell_spec(shell)
|
||||
runtime_policy = self.shell_runtime_policy_registry.get(shell_spec.name)
|
||||
if runtime_policy is None:
|
||||
raise typer.BadParameter(f'{shell_spec.name} completion 当前版本未实现')
|
||||
return runtime_policy
|
||||
|
||||
@staticmethod
|
||||
def build_completion_click_command(root_cli: typer.Typer) -> click.Command:
|
||||
"""
|
||||
将 Typer 根应用转换为 Click 命令对象,供 shell completion 生成使用。
|
||||
|
||||
:param root_cli: Typer 根应用
|
||||
:return: Click 命令对象
|
||||
"""
|
||||
return typer.main.get_command(root_cli)
|
||||
|
||||
def render_completion_script(self, root_cli: typer.Typer, shell: str) -> str:
|
||||
"""
|
||||
生成指定 shell 的 completion 脚本文本。
|
||||
|
||||
:param root_cli: Typer 根应用
|
||||
:param shell: shell 名称
|
||||
:return: completion 脚本文本
|
||||
:raises typer.BadParameter: 当前 shell 未实现脚本生成时抛出异常
|
||||
"""
|
||||
shell_spec = self.resolve_completion_shell_spec(shell)
|
||||
if not shell_spec.supported or shell_spec.generator not in {'click', 'custom'}:
|
||||
raise typer.BadParameter(f'{shell_spec.name} completion 当前版本未实现')
|
||||
|
||||
runtime_policy = self.resolve_shell_runtime_policy(shell_spec.name)
|
||||
click_command = self.build_completion_click_command(root_cli)
|
||||
stderr_buffer = io.StringIO()
|
||||
with redirect_stderr(stderr_buffer):
|
||||
script_text = runtime_policy.click_completion_class(
|
||||
click_command,
|
||||
{},
|
||||
'shierfeng',
|
||||
CLICK_COMPLETE_ENV_VAR,
|
||||
).source()
|
||||
return runtime_policy.script_transformer(script_text)
|
||||
|
||||
def resolve_completion_target(self, shell: str, target_file: Path | None = None) -> Path:
|
||||
"""
|
||||
解析 completion 脚本目标文件路径。
|
||||
|
||||
:param shell: shell 名称
|
||||
:param target_file: 用户显式指定的目标文件
|
||||
:return: 目标文件绝对路径
|
||||
"""
|
||||
if target_file is not None:
|
||||
return target_file.expanduser().resolve()
|
||||
|
||||
shell_spec = self.resolve_completion_shell_spec(shell)
|
||||
return (Path.home() / shell_spec.default_target).expanduser().resolve()
|
||||
|
||||
def resolve_completion_rc_file(self, shell: str, rc_file: Path | None = None) -> Path | None:
|
||||
"""
|
||||
解析 completion 激活所使用的 rc 文件路径。
|
||||
|
||||
:param shell: shell 名称
|
||||
:param rc_file: 用户显式指定的 rc 文件
|
||||
:return: rc 文件绝对路径,若当前 shell 无 rc 文件则返回 None
|
||||
"""
|
||||
if rc_file is not None:
|
||||
return rc_file.expanduser().resolve()
|
||||
|
||||
shell_spec = self.resolve_completion_shell_spec(shell)
|
||||
if shell_spec.default_rc_file is None:
|
||||
return None
|
||||
return (Path.home() / shell_spec.default_rc_file).expanduser().resolve()
|
||||
|
||||
def build_source_command(self, target_file: Path, shell: str) -> str:
|
||||
"""
|
||||
构建当前 shell 对应的激活命令。
|
||||
|
||||
:param target_file: completion 脚本文件路径
|
||||
:param shell: shell 名称
|
||||
:return: 激活命令文本
|
||||
"""
|
||||
runtime_policy = self.resolve_shell_runtime_policy(shell)
|
||||
return runtime_policy.source_command_builder(target_file)
|
||||
|
||||
@staticmethod
|
||||
def detect_active_shell() -> str:
|
||||
"""
|
||||
检测当前进程环境下的活跃 shell 名称。
|
||||
|
||||
:return: shell 名称,未知时返回空字符串
|
||||
"""
|
||||
shell_path = os.environ.get('SHELL', '').strip()
|
||||
if not shell_path:
|
||||
return ''
|
||||
return Path(shell_path).name.lower()
|
||||
|
||||
def resolve_install_shell(self, shell: str | None) -> str:
|
||||
"""
|
||||
解析安装命令实际使用的 shell。
|
||||
|
||||
:param shell: 用户显式指定的 shell,允许为空
|
||||
:return: 实际使用的 shell 名称
|
||||
:raises typer.BadParameter: 无法推断或不支持时抛出异常
|
||||
"""
|
||||
if shell and shell.strip():
|
||||
return self.resolve_completion_shell_spec(shell).name
|
||||
|
||||
active_shell = self.detect_active_shell()
|
||||
if not active_shell:
|
||||
raise typer.BadParameter('未检测到当前 shell,请显式传入 --shell')
|
||||
|
||||
shell_spec = self.shell_spec_registry.get_spec(active_shell)
|
||||
if shell_spec is None:
|
||||
supported_shells = ', '.join(self.completion_provider_gateway.list_completion_shells())
|
||||
raise typer.BadParameter(
|
||||
f'当前 shell `{active_shell}` 不在支持列表中,请显式传入 --shell,可选值为 {supported_shells}'
|
||||
)
|
||||
return shell_spec.name
|
||||
|
||||
@staticmethod
|
||||
def append_activation_line(rc_file: Path, source_command: str) -> bool:
|
||||
"""
|
||||
将激活命令追加到 rc 文件,若已存在则不重复写入。
|
||||
|
||||
:param rc_file: rc 文件路径
|
||||
:param source_command: 激活命令文本
|
||||
:return: 本次是否发生写入
|
||||
"""
|
||||
existing_text = ''
|
||||
if rc_file.exists():
|
||||
existing_text = rc_file.read_text(encoding='utf-8')
|
||||
if source_command in existing_text:
|
||||
return False
|
||||
|
||||
rc_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with rc_file.open('a', encoding='utf-8') as file_object:
|
||||
if existing_text and not existing_text.endswith('\n'):
|
||||
file_object.write('\n')
|
||||
file_object.write(f'{source_command}\n')
|
||||
return True
|
||||
|
||||
def install_completion_script(
|
||||
self,
|
||||
root_cli: typer.Typer,
|
||||
shell: str | None,
|
||||
*,
|
||||
target_file: Path | None = None,
|
||||
activate: bool = False,
|
||||
rc_file: Path | None = None,
|
||||
force: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
安装指定 shell 的 completion 脚本文件。
|
||||
|
||||
:param root_cli: Typer 根应用
|
||||
:param shell: shell 名称
|
||||
:param target_file: completion 脚本目标文件
|
||||
:param activate: 是否写入 rc 文件激活命令
|
||||
:param rc_file: 自定义 rc 文件路径
|
||||
:param force: 目标文件已存在时是否强制覆盖
|
||||
:return: 安装结果字典
|
||||
"""
|
||||
resolved_shell = self.resolve_install_shell(shell)
|
||||
shell_spec = self.resolve_completion_shell_spec(resolved_shell)
|
||||
if not shell_spec.supported:
|
||||
return {
|
||||
'ok': False,
|
||||
'message': f'{shell_spec.name} completion 当前版本未实现',
|
||||
'shell': shell_spec.name,
|
||||
'exit_code': ARGUMENT_ERROR,
|
||||
}
|
||||
|
||||
resolved_target_file = self.resolve_completion_target(shell_spec.name, target_file)
|
||||
script_text = self.render_completion_script(root_cli, shell_spec.name)
|
||||
existing_text = resolved_target_file.read_text(encoding='utf-8') if resolved_target_file.exists() else None
|
||||
|
||||
if existing_text is not None and existing_text != script_text and not force:
|
||||
return {
|
||||
'ok': False,
|
||||
'message': '目标文件已存在且内容不同,请传入 --force 覆盖',
|
||||
'shell': shell_spec.name,
|
||||
'targetFile': str(resolved_target_file),
|
||||
'exit_code': RUNTIME_ERROR,
|
||||
}
|
||||
|
||||
resolved_target_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
resolved_target_file.write_text(script_text, encoding='utf-8')
|
||||
|
||||
source_command = self.build_source_command(resolved_target_file, shell_spec.name)
|
||||
activated = shell_spec.auto_discovery
|
||||
rc_file_path = self.resolve_completion_rc_file(shell_spec.name, rc_file)
|
||||
rc_file_updated = False
|
||||
activation_required = not shell_spec.auto_discovery
|
||||
detected_shell = self.detect_active_shell() or None
|
||||
|
||||
if activate and rc_file_path is not None:
|
||||
rc_file_updated = self.append_activation_line(rc_file_path, source_command)
|
||||
activated = True
|
||||
|
||||
next_step = '当前 shell 会自动发现 completion 脚本,无需额外 source 命令'
|
||||
if activation_required and not activate:
|
||||
next_step = f'请执行 `{source_command}`,或重新运行并传入 --activate'
|
||||
elif activation_required and activate:
|
||||
next_step = '请重启当前 shell,或手动执行 rc 文件中的 source 命令使其立即生效'
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'message': 'completion 脚本已安装',
|
||||
'shell': shell_spec.name,
|
||||
'detectedShell': detected_shell,
|
||||
'targetFile': str(resolved_target_file),
|
||||
'activated': activated,
|
||||
'activateRequested': activate,
|
||||
'rcFile': str(rc_file_path) if rc_file_path is not None else None,
|
||||
'rcFileUpdated': rc_file_updated,
|
||||
'sourceCommand': source_command,
|
||||
'autoDiscovery': shell_spec.auto_discovery,
|
||||
'activationRequired': activation_required,
|
||||
'nextStep': next_step,
|
||||
'completeEnvVar': CLICK_COMPLETE_ENV_VAR,
|
||||
}
|
||||
|
||||
|
||||
COMPLETION_INSTALLER = CompletionInstallerService()
|
||||
@@ -0,0 +1,83 @@
|
||||
from typing import Any
|
||||
|
||||
|
||||
class CompletionCommandPresenter:
|
||||
"""
|
||||
completion 命令文本渲染器。
|
||||
|
||||
该渲染器负责将 `completion` 命令组产生的结构化 payload 转换为稳定的文本摘要,
|
||||
同时保持 JSON 输出仍由控制器直接返回,不在此处做契约变形。
|
||||
"""
|
||||
|
||||
def build_completion_doctor_text(self, payload: dict[str, Any]) -> str:
|
||||
"""
|
||||
将 completion 诊断结果渲染为文本摘要。
|
||||
|
||||
:param payload: completion 诊断结果字典
|
||||
:return: 文本摘要
|
||||
"""
|
||||
lines = [
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'message: {payload.get("message", "-")}',
|
||||
f'active_shell: {payload.get("activeShell") or "-"}',
|
||||
f'project_dir: {payload.get("projectDir", "-")}',
|
||||
f'complete_env_var: {payload.get("completeEnvVar", "-")}',
|
||||
]
|
||||
if payload.get('recommendedInstallCommand'):
|
||||
lines.append(f'recommended_install_command: {payload.get("recommendedInstallCommand")}')
|
||||
env_choices = payload.get('envChoices')
|
||||
if isinstance(env_choices, list) and env_choices:
|
||||
lines.append('env_choices:')
|
||||
lines.extend(f' - {env_name}' for env_name in env_choices)
|
||||
shells = payload.get('shells')
|
||||
if isinstance(shells, dict) and shells:
|
||||
lines.append('shells:')
|
||||
for shell_name, shell_payload in shells.items():
|
||||
if not isinstance(shell_payload, dict):
|
||||
continue
|
||||
lines.extend(
|
||||
[
|
||||
f' {shell_name}:',
|
||||
f' supported: {str(shell_payload.get("supported", False)).lower()}',
|
||||
f' detected: {str(shell_payload.get("detected", False)).lower()}',
|
||||
f' target_file: {shell_payload.get("targetFile", "-")}',
|
||||
f' target_file_exists: {str(shell_payload.get("targetFileExists", False)).lower()}',
|
||||
f' rc_file: {shell_payload.get("rcFile", "-") or "-"}',
|
||||
f' rc_file_exists: {str(shell_payload.get("rcFileExists", False)).lower()}',
|
||||
f' auto_discovery: {str(shell_payload.get("autoDiscovery", False)).lower()}',
|
||||
f' source_command: {shell_payload.get("sourceCommand", "-")}',
|
||||
f' recommended_install_command: {shell_payload.get("recommendedInstallCommand", "-")}',
|
||||
]
|
||||
)
|
||||
return '\n'.join(lines)
|
||||
|
||||
@staticmethod
|
||||
def build_completion_install_text(payload: dict[str, Any]) -> str:
|
||||
"""
|
||||
将 completion 安装结果渲染为文本摘要。
|
||||
|
||||
:param payload: completion 安装结果字典
|
||||
:return: 文本摘要
|
||||
"""
|
||||
lines = [
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'message: {payload.get("message", "-")}',
|
||||
f'shell: {payload.get("shell", "-")}',
|
||||
]
|
||||
field_label_mapping = {
|
||||
'detectedShell': 'detected_shell',
|
||||
'targetFile': 'target_file',
|
||||
'activated': 'activated',
|
||||
'activateRequested': 'activate_requested',
|
||||
'rcFile': 'rc_file',
|
||||
'rcFileUpdated': 'rc_file_updated',
|
||||
'sourceCommand': 'source_command',
|
||||
'autoDiscovery': 'auto_discovery',
|
||||
'activationRequired': 'activation_required',
|
||||
'nextStep': 'next_step',
|
||||
'completeEnvVar': 'complete_env_var',
|
||||
}
|
||||
for field_name, field_label in field_label_mapping.items():
|
||||
if field_name in payload:
|
||||
lines.append(f'{field_label}: {payload.get(field_name)}')
|
||||
return '\n'.join(lines)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
||||
import os
|
||||
|
||||
from click.shell_completion import (
|
||||
CompletionItem,
|
||||
ShellComplete,
|
||||
add_completion_class,
|
||||
get_completion_class,
|
||||
split_arg_string,
|
||||
)
|
||||
|
||||
_SOURCE_POWERSHELL = """\
|
||||
$%(complete_func)s = {
|
||||
param($wordToComplete, $commandAst, $cursorPosition)
|
||||
|
||||
$previousCompWords = $env:COMP_WORDS
|
||||
$previousCompCword = $env:COMP_CWORD
|
||||
$previousCompleteInstruction = $env:%(complete_var)s
|
||||
|
||||
$commandLine = $commandAst.ToString()
|
||||
if ($cursorPosition -lt $commandLine.Length) {
|
||||
$commandLine = $commandLine.Substring(0, $cursorPosition)
|
||||
}
|
||||
|
||||
$env:COMP_WORDS = $commandLine
|
||||
$env:COMP_CWORD = $wordToComplete
|
||||
$env:%(complete_var)s = "powershell_complete"
|
||||
|
||||
try {
|
||||
%(prog_name)s | ForEach-Object {
|
||||
$line = $_.ToString()
|
||||
if ([string]::IsNullOrWhiteSpace($line)) {
|
||||
return
|
||||
}
|
||||
$parts = $line -split "`t", 3
|
||||
$completionValue = if ($parts.Length -ge 2) { $parts[1] } else { "" }
|
||||
$completionHelp = if ($parts.Length -ge 3 -and $parts[2]) { $parts[2] } else { $completionValue }
|
||||
[System.Management.Automation.CompletionResult]::new(
|
||||
$completionValue,
|
||||
$completionValue,
|
||||
[System.Management.Automation.CompletionResultType]::ParameterValue,
|
||||
$completionHelp
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if ($null -ne $previousCompWords) {
|
||||
$env:COMP_WORDS = $previousCompWords
|
||||
} else {
|
||||
Remove-Item Env:\\COMP_WORDS -ErrorAction SilentlyContinue
|
||||
}
|
||||
if ($null -ne $previousCompCword) {
|
||||
$env:COMP_CWORD = $previousCompCword
|
||||
} else {
|
||||
Remove-Item Env:\\COMP_CWORD -ErrorAction SilentlyContinue
|
||||
}
|
||||
if ($null -ne $previousCompleteInstruction) {
|
||||
$env:%(complete_var)s = $previousCompleteInstruction
|
||||
} else {
|
||||
Remove-Item Env:\\%(complete_var)s -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Register-ArgumentCompleter -Native -CommandName %(prog_name)s -ScriptBlock $%(complete_func)s
|
||||
"""
|
||||
|
||||
|
||||
class PowerShellComplete(ShellComplete):
|
||||
"""
|
||||
PowerShell shell completion 支持。
|
||||
|
||||
基于 PowerShell `Register-ArgumentCompleter -Native` 协议,将当前
|
||||
命令行和光标位置传回 Click completion 分发器,再把返回的候选项转换为
|
||||
`CompletionResult` 对象。
|
||||
"""
|
||||
|
||||
name = 'powershell'
|
||||
source_template = _SOURCE_POWERSHELL
|
||||
|
||||
def get_completion_args(self) -> tuple[list[str], str]:
|
||||
"""
|
||||
从 PowerShell 注入的环境变量中恢复 CLI 上下文。
|
||||
|
||||
:return: 已解析的完整参数与当前不完整输入
|
||||
"""
|
||||
cwords = split_arg_string(os.environ.get('COMP_WORDS', ''))
|
||||
incomplete = os.environ.get('COMP_CWORD', '')
|
||||
if incomplete:
|
||||
incomplete_parts = split_arg_string(incomplete)
|
||||
incomplete = incomplete_parts[0] if incomplete_parts else incomplete
|
||||
args = cwords[1:]
|
||||
if incomplete and args and args[-1] == incomplete:
|
||||
args.pop()
|
||||
return args, incomplete
|
||||
|
||||
def format_completion(self, item: CompletionItem) -> str:
|
||||
"""
|
||||
将候选项格式化为 PowerShell 脚本可解析的文本行。
|
||||
|
||||
:param item: Click completion 候选项
|
||||
:return: 格式化后的文本
|
||||
"""
|
||||
help_text = item.help or item.value
|
||||
return f'{item.type}\t{item.value}\t{help_text}'
|
||||
|
||||
|
||||
def ensure_custom_completion_classes_registered() -> None:
|
||||
"""
|
||||
确保自定义 shell completion 类已注册到 Click。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
if get_completion_class(PowerShellComplete.name) is None:
|
||||
add_completion_class(PowerShellComplete)
|
||||
@@ -0,0 +1,74 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated, Literal
|
||||
|
||||
import typer
|
||||
|
||||
from cli.completion.providers import COMPLETION_PROVIDER_GATEWAY
|
||||
|
||||
|
||||
@dataclass
|
||||
class CliContext:
|
||||
"""
|
||||
CLI 运行上下文。
|
||||
|
||||
env: 当前命令使用的环境名称
|
||||
output: 结果输出格式
|
||||
operator: 操作者标识,后续用于审计
|
||||
allow_prod: 是否显式允许生产环境执行危险命令
|
||||
yes: 是否跳过二次确认
|
||||
dry_run: 是否执行演练模式
|
||||
"""
|
||||
|
||||
env: str = 'dev'
|
||||
output: Literal['text', 'json'] = 'text'
|
||||
operator: str | None = None
|
||||
allow_prod: bool = False
|
||||
yes: bool = False
|
||||
dry_run: bool = False
|
||||
|
||||
|
||||
class CliContextBuilder:
|
||||
"""
|
||||
CLI 上下文构建器。
|
||||
|
||||
该对象负责将命令层显式参数收口为统一的 `CliContext` 数据对象,
|
||||
供上下文工厂与测试场景复用。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build(
|
||||
env: str,
|
||||
output: Literal['text', 'json'],
|
||||
allow_prod: bool,
|
||||
yes: bool,
|
||||
dry_run: bool,
|
||||
) -> CliContext:
|
||||
"""
|
||||
构建命令执行上下文。
|
||||
|
||||
:param env: 当前命令环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过二次确认
|
||||
:param dry_run: 是否执行演练模式
|
||||
:return: 构建完成的 CLI 上下文
|
||||
"""
|
||||
return CliContext(
|
||||
env=env,
|
||||
output=output,
|
||||
allow_prod=allow_prod,
|
||||
yes=yes,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
|
||||
|
||||
CLI_CONTEXT_BUILDER = CliContextBuilder()
|
||||
|
||||
EnvOption = Annotated[
|
||||
str,
|
||||
typer.Option('--env', help='运行环境名称', autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_env_values),
|
||||
]
|
||||
OutputOption = Annotated[Literal['text', 'json'], typer.Option('--output', help='输出格式,支持 text 或 json')]
|
||||
AllowProdOption = Annotated[bool, typer.Option('--allow-prod', help='允许在生产环境中执行危险命令')]
|
||||
YesOption = Annotated[bool, typer.Option('--yes', help='跳过危险命令确认')]
|
||||
DryRunOption = Annotated[bool, typer.Option('--dry-run', help='仅执行预演,不落地实际变更')]
|
||||
@@ -0,0 +1,51 @@
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from cli.core.app_builder import CliApplicationBuilder, ProjectRuntimeLocator
|
||||
from cli.core.completion_dispatcher import CompletionDispatcher
|
||||
from cli.core.context_factory import CliContextFactory, CliRuntimeState
|
||||
from cli.core.execution import CliExecutionService
|
||||
from cli.output import OutputRenderer
|
||||
|
||||
|
||||
@dataclass
|
||||
class CoreServiceContainer:
|
||||
"""
|
||||
CLI 核心服务容器。
|
||||
|
||||
该容器负责统一装配输出渲染器、运行期状态、上下文工厂与执行服务,
|
||||
作为控制器、向导和 TUI 等编排层的默认依赖入口。
|
||||
|
||||
:param output_renderer: 输出渲染器
|
||||
:param runtime_state: CLI 运行期状态对象
|
||||
"""
|
||||
|
||||
output_renderer: OutputRenderer = field(default_factory=OutputRenderer)
|
||||
runtime_state: CliRuntimeState = field(default_factory=CliRuntimeState)
|
||||
context_factory: CliContextFactory = field(init=False)
|
||||
execution_service: CliExecutionService = field(init=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""
|
||||
初始化核心服务依赖图。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.context_factory = CliContextFactory(
|
||||
runtime_state=self.runtime_state,
|
||||
output_renderer=self.output_renderer,
|
||||
)
|
||||
self.execution_service = CliExecutionService(output_renderer=self.output_renderer)
|
||||
|
||||
|
||||
DEFAULT_CORE_SERVICES = CoreServiceContainer()
|
||||
|
||||
__all__ = [
|
||||
'DEFAULT_CORE_SERVICES',
|
||||
'CliApplicationBuilder',
|
||||
'CliContextFactory',
|
||||
'CliExecutionService',
|
||||
'CliRuntimeState',
|
||||
'CompletionDispatcher',
|
||||
'CoreServiceContainer',
|
||||
'ProjectRuntimeLocator',
|
||||
]
|
||||
@@ -0,0 +1,363 @@
|
||||
import importlib
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Literal
|
||||
|
||||
import typer
|
||||
|
||||
from cli.output import OutputRenderer
|
||||
from cli.runtime.base import RUNTIME_ENVIRONMENT
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProjectRuntimeLocator:
|
||||
"""
|
||||
CLI 项目运行时定位器。
|
||||
|
||||
该对象负责判断当前目录是否为后端项目根目录、注入 `sys.path`,
|
||||
并为配置导入阶段提取最小化的 `argv` 参数。
|
||||
"""
|
||||
|
||||
def is_backend_project_dir(self, project_dir: Path) -> bool:
|
||||
"""
|
||||
判断给定目录是否为后端项目根目录。
|
||||
|
||||
:param project_dir: 待检查目录
|
||||
:return: 是否为后端项目根目录
|
||||
"""
|
||||
return RUNTIME_ENVIRONMENT.is_backend_project_dir(project_dir)
|
||||
|
||||
def ensure_backend_dir_on_sys_path(self) -> Path:
|
||||
"""
|
||||
将当前后端项目根目录加入 `sys.path` 首位。
|
||||
|
||||
:return: 当前后端项目根目录
|
||||
:raises typer.Exit: 当前目录不是后端项目根目录时退出命令
|
||||
"""
|
||||
project_dir = Path.cwd().resolve()
|
||||
if not self.is_backend_project_dir(project_dir):
|
||||
typer.echo('`shierfeng` 命令需在 `shierfeng-fastapi-backend` 目录下执行', err=True)
|
||||
raise typer.Exit(code=2)
|
||||
|
||||
project_dir_str = str(project_dir)
|
||||
if sys.path and sys.path[0] == project_dir_str:
|
||||
return project_dir
|
||||
|
||||
sys.path.insert(0, project_dir_str)
|
||||
return project_dir
|
||||
|
||||
def extract_import_argv(self, argv: list[str] | None = None) -> list[str]:
|
||||
"""
|
||||
提取供配置模块导入阶段使用的最小参数集。
|
||||
|
||||
:param argv: 原始命令行参数列表
|
||||
:return: 仅保留程序名和 `--env` 的参数列表
|
||||
"""
|
||||
current_argv = argv or sys.argv
|
||||
program_name = current_argv[0] if current_argv else 'shierfeng'
|
||||
import_argv = [program_name]
|
||||
env_value = ''
|
||||
current_args = current_argv[1:]
|
||||
for index, argument in enumerate(current_args):
|
||||
if argument.startswith('--env='):
|
||||
env_value = argument.split('=', 1)[1].strip()
|
||||
break
|
||||
if argument == '--env' and index + 1 < len(current_args):
|
||||
env_value = current_args[index + 1].strip()
|
||||
break
|
||||
|
||||
if env_value:
|
||||
import_argv.extend(['--env', env_value])
|
||||
return import_argv
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CliCommandGroupRegistry:
|
||||
"""
|
||||
CLI 命令组注册表。
|
||||
|
||||
该注册表集中维护根 CLI 需要挂载的业务命令组模块路径,
|
||||
避免命令组声明散落在应用构建器主体中。
|
||||
|
||||
:param command_modules: 命令组到模块路径的映射
|
||||
"""
|
||||
|
||||
command_modules: dict[str, str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CliExtensionRegistration:
|
||||
"""
|
||||
CLI 扩展入口注册描述。
|
||||
|
||||
:param module_path: 扩展模块导入路径
|
||||
:param attach: 已加载模块挂载到根 CLI 的函数
|
||||
"""
|
||||
|
||||
module_path: str
|
||||
attach: Callable[[typer.Typer, object], None]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CliExtensionRegistry:
|
||||
"""
|
||||
CLI 扩展入口注册表。
|
||||
|
||||
该注册表集中维护 completion、wizard、TUI 等扩展入口的模块路径与
|
||||
挂载方式,避免这些规则继续散落在注册器主体中。
|
||||
|
||||
:param registrations: 扩展注册描述列表
|
||||
"""
|
||||
|
||||
registrations: tuple[CliExtensionRegistration, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CliCommandGroupRegistrar:
|
||||
"""
|
||||
CLI 命令组注册器。
|
||||
|
||||
该对象负责根据命令组注册表向根 Typer 应用挂载业务命令组。
|
||||
|
||||
:param command_group_registry: CLI 命令组注册表
|
||||
"""
|
||||
|
||||
command_group_registry: CliCommandGroupRegistry
|
||||
module_loader: 'CliModuleLoader' = field(default_factory=lambda: DEFAULT_CLI_MODULE_LOADER)
|
||||
|
||||
def register(self, cli: typer.Typer) -> None:
|
||||
"""
|
||||
向根应用注册业务命令组。
|
||||
|
||||
:param cli: 根 Typer 应用
|
||||
:return: None
|
||||
"""
|
||||
for command_name, module_path in self.command_group_registry.command_modules.items():
|
||||
command_module = self.module_loader.load(module_path)
|
||||
cli.add_typer(command_module.app, name=command_name)
|
||||
|
||||
|
||||
class CliExtensionMountSupport:
|
||||
"""
|
||||
CLI 扩展入口挂载支持对象。
|
||||
|
||||
该对象负责封装不同扩展模块挂载到根 CLI 的差异,避免扩展注册器本体
|
||||
继续堆积 `if/else` 或硬编码模块处理逻辑。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def attach_completion(cli: typer.Typer, module: object) -> None:
|
||||
"""
|
||||
挂载 completion 扩展入口。
|
||||
|
||||
:param cli: 根 Typer 应用
|
||||
:param module: 已加载模块
|
||||
:return: None
|
||||
"""
|
||||
cli.add_typer(module.COMPLETION_COMMAND_BUILDER.build(cli), name='completion')
|
||||
|
||||
@staticmethod
|
||||
def attach_wizard(cli: typer.Typer, module: object) -> None:
|
||||
"""
|
||||
挂载 wizard 扩展入口。
|
||||
|
||||
:param cli: 根 Typer 应用
|
||||
:param module: 已加载模块
|
||||
:return: None
|
||||
"""
|
||||
cli.add_typer(module.WIZARD_COMMAND_BUILDER.build(), name='wizard')
|
||||
|
||||
@staticmethod
|
||||
def attach_tui(cli: typer.Typer, module: object) -> None:
|
||||
"""
|
||||
挂载 TUI 扩展入口。
|
||||
|
||||
:param cli: 根 Typer 应用
|
||||
:param module: 已加载模块
|
||||
:return: None
|
||||
"""
|
||||
module.TUI_COMMAND_REGISTRATION.register(cli)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CliExtensionRegistrar:
|
||||
"""
|
||||
CLI 扩展入口注册器。
|
||||
|
||||
该对象负责向根 Typer 应用挂载 completion、wizard 与 TUI
|
||||
等扩展入口。
|
||||
"""
|
||||
|
||||
extension_registry: CliExtensionRegistry
|
||||
module_loader: 'CliModuleLoader' = field(default_factory=lambda: DEFAULT_CLI_MODULE_LOADER)
|
||||
|
||||
def register(self, cli: typer.Typer) -> None:
|
||||
"""
|
||||
向根应用注册扩展入口。
|
||||
|
||||
:param cli: 根 Typer 应用
|
||||
:return: None
|
||||
"""
|
||||
for registration in self.extension_registry.registrations:
|
||||
extension_module = self.module_loader.load(registration.module_path)
|
||||
registration.attach(cli, extension_module)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CliRootOptionInitializer:
|
||||
"""
|
||||
CLI 根级选项初始化器。
|
||||
|
||||
该对象负责将根级 `--color`、`--icon` 选项映射到共享输出渲染器,
|
||||
避免根回调继续内联输出配置逻辑。
|
||||
|
||||
:param output_renderer: 根命令共享的输出渲染器
|
||||
"""
|
||||
|
||||
output_renderer: OutputRenderer
|
||||
|
||||
def initialize(self, *, color: str, icon: str) -> None:
|
||||
"""
|
||||
初始化根级输出选项。
|
||||
|
||||
:param color: 文本输出颜色模式
|
||||
:param icon: 文本输出图标模式
|
||||
:return: None
|
||||
"""
|
||||
self.output_renderer.set_color_mode(color)
|
||||
self.output_renderer.set_icon_mode(icon)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CliRootCallbackRegistrar:
|
||||
"""
|
||||
CLI 根回调注册器。
|
||||
|
||||
该对象负责把根级 `--color`、`--icon` 选项回调挂载到 Typer 根应用,
|
||||
让 `CliApplicationBuilder` 更专注于整体装配流程。
|
||||
|
||||
:param root_option_initializer: 根级选项初始化器
|
||||
"""
|
||||
|
||||
root_option_initializer: CliRootOptionInitializer
|
||||
|
||||
def register(self, cli: typer.Typer) -> None:
|
||||
"""
|
||||
向根应用注册回调。
|
||||
|
||||
:param cli: 根 Typer 应用
|
||||
:return: None
|
||||
"""
|
||||
|
||||
@cli.callback()
|
||||
def root_callback(
|
||||
color: Annotated[
|
||||
Literal['auto', 'always', 'never'],
|
||||
typer.Option('--color', help='文本输出颜色模式'),
|
||||
] = 'always',
|
||||
icon: Annotated[
|
||||
Literal['emoji', 'ascii', 'none'],
|
||||
typer.Option('--icon', help='文本输出图标模式'),
|
||||
] = 'emoji',
|
||||
) -> None:
|
||||
"""
|
||||
初始化 CLI 根级运行参数。
|
||||
|
||||
:param color: 文本输出颜色模式
|
||||
:param icon: 文本输出图标模式
|
||||
:return: None
|
||||
"""
|
||||
self.root_option_initializer.initialize(color=color, icon=icon)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CliModuleLoader:
|
||||
"""
|
||||
CLI 模块加载服务。
|
||||
|
||||
该对象负责集中执行命令组与扩展入口所需的延迟导入,
|
||||
让注册器本身更专注于挂载动作。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def load(module_path: str) -> object:
|
||||
"""
|
||||
按模块路径加载模块对象。
|
||||
|
||||
:param module_path: 模块导入路径
|
||||
:return: 已加载模块
|
||||
"""
|
||||
return importlib.import_module(module_path)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CliApplicationBuilder:
|
||||
"""
|
||||
CLI 根应用构建器。
|
||||
|
||||
:param output_renderer: 根命令共享的输出渲染器
|
||||
:param command_group_registrar: CLI 命令组注册器
|
||||
:param extension_registrar: CLI 扩展入口注册器
|
||||
"""
|
||||
|
||||
output_renderer: OutputRenderer
|
||||
command_group_registrar: CliCommandGroupRegistrar = field(
|
||||
default_factory=lambda: CliCommandGroupRegistrar(DEFAULT_COMMAND_GROUP_REGISTRY)
|
||||
)
|
||||
extension_registrar: CliExtensionRegistrar = field(
|
||||
default_factory=lambda: CliExtensionRegistrar(DEFAULT_CLI_EXTENSION_REGISTRY)
|
||||
)
|
||||
root_option_initializer: CliRootOptionInitializer = field(init=False)
|
||||
root_callback_registrar: CliRootCallbackRegistrar = field(init=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""
|
||||
初始化根应用构建器依赖。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.root_option_initializer = CliRootOptionInitializer(self.output_renderer)
|
||||
self.root_callback_registrar = CliRootCallbackRegistrar(self.root_option_initializer)
|
||||
|
||||
def build(self) -> typer.Typer:
|
||||
"""
|
||||
构建 Typer 根应用并注册全部命令组。
|
||||
|
||||
:return: Typer 根应用
|
||||
"""
|
||||
cli = typer.Typer(
|
||||
name='shierfeng',
|
||||
help='ShiErFeng FastAPI 后端统一命令入口',
|
||||
no_args_is_help=True,
|
||||
add_completion=False,
|
||||
context_settings={'help_option_names': ['-h', '--help']},
|
||||
)
|
||||
self.root_callback_registrar.register(cli)
|
||||
self.command_group_registrar.register(cli)
|
||||
self.extension_registrar.register(cli)
|
||||
return cli
|
||||
|
||||
|
||||
DEFAULT_COMMAND_GROUP_REGISTRY = CliCommandGroupRegistry(
|
||||
command_modules={
|
||||
'app': 'cli.groups.app',
|
||||
'db': 'cli.groups.db',
|
||||
'ops': 'cli.groups.ops',
|
||||
'cache': 'cli.groups.cache',
|
||||
'job': 'cli.groups.job',
|
||||
'config': 'cli.groups.config',
|
||||
'crypto': 'cli.groups.crypto',
|
||||
'gen': 'cli.groups.gen',
|
||||
'dev': 'cli.groups.dev',
|
||||
}
|
||||
)
|
||||
DEFAULT_CLI_EXTENSION_REGISTRY = CliExtensionRegistry(
|
||||
registrations=(
|
||||
CliExtensionRegistration('cli.completion.commands', CliExtensionMountSupport.attach_completion),
|
||||
CliExtensionRegistration('cli.wizard.commands', CliExtensionMountSupport.attach_wizard),
|
||||
CliExtensionRegistration('cli.tui', CliExtensionMountSupport.attach_tui),
|
||||
)
|
||||
)
|
||||
DEFAULT_CLI_MODULE_LOADER = CliModuleLoader()
|
||||
@@ -0,0 +1,121 @@
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import typer
|
||||
from click.shell_completion import shell_complete as click_shell_complete
|
||||
from typer.completion import shell_complete as typer_shell_complete
|
||||
|
||||
from cli.completion.shells import ensure_custom_completion_classes_registered
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CompletionInstructionSupport:
|
||||
"""
|
||||
completion 指令解析支持对象。
|
||||
|
||||
该对象负责管理 completion 环境变量名、受支持 shell 列表,以及
|
||||
Click / Typer 两种指令格式的判定逻辑。
|
||||
|
||||
:param complete_env_var: completion 环境变量名
|
||||
:param supported_shells: 支持的 shell 名称列表
|
||||
"""
|
||||
|
||||
complete_env_var: str = '_SHIERFENG_COMPLETE'
|
||||
supported_shells: tuple[str, ...] = ('bash', 'zsh', 'fish', 'powershell')
|
||||
|
||||
def is_click_style_instruction(self, instruction: str) -> bool:
|
||||
"""
|
||||
判断 completion 指令是否为 Click 原生格式。
|
||||
|
||||
Click 原生格式为 `<shell>_<instruction>`,例如 `bash_complete`。
|
||||
|
||||
:param instruction: completion 指令文本
|
||||
:return: 是否为 Click 风格指令
|
||||
"""
|
||||
return any(instruction.startswith(f'{shell}_') for shell in self.supported_shells)
|
||||
|
||||
def is_typer_style_instruction(self, instruction: str) -> bool:
|
||||
"""
|
||||
判断 completion 指令是否为 Typer 兼容格式。
|
||||
|
||||
Typer 兼容格式为 `<instruction>_<shell>`,例如 `complete_bash`。
|
||||
|
||||
:param instruction: completion 指令文本
|
||||
:return: 是否为 Typer 风格指令
|
||||
"""
|
||||
return any(instruction.endswith(f'_{shell}') for shell in self.supported_shells)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CompletionInstructionReader:
|
||||
"""
|
||||
completion 指令读取器。
|
||||
|
||||
该对象负责从当前进程环境中读取并规范化 completion 指令。
|
||||
|
||||
:param support: completion 指令解析支持对象
|
||||
"""
|
||||
|
||||
support: CompletionInstructionSupport
|
||||
|
||||
def read_instruction(self) -> str:
|
||||
"""
|
||||
读取当前进程中的 completion 指令文本。
|
||||
|
||||
:return: 规范化后的 completion 指令文本
|
||||
"""
|
||||
return os.environ.get(self.support.complete_env_var, '').strip()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CompletionDispatcher:
|
||||
"""
|
||||
shell completion 请求分发器。
|
||||
|
||||
该对象负责识别 Click 与 Typer 两种 completion 指令格式,
|
||||
并在命中时直接执行补全流程后退出当前进程。
|
||||
|
||||
:param support: completion 指令解析支持对象
|
||||
:param instruction_reader: completion 指令读取器
|
||||
"""
|
||||
|
||||
support: CompletionInstructionSupport = field(default_factory=CompletionInstructionSupport)
|
||||
instruction_reader: CompletionInstructionReader = field(init=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""
|
||||
初始化 completion 指令读取器。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
object.__setattr__(self, 'instruction_reader', CompletionInstructionReader(self.support))
|
||||
|
||||
def dispatch(self, cli: typer.Typer) -> None:
|
||||
"""
|
||||
处理 shell completion 请求并在命中时直接退出。
|
||||
|
||||
这里同时兼容 Click 与 Typer 两种 completion 指令格式,避免
|
||||
生成脚本格式和运行时解析格式不一致时出现 `Shell complete not supported.`。
|
||||
|
||||
:param cli: Typer 根应用
|
||||
:return: None
|
||||
"""
|
||||
ensure_custom_completion_classes_registered()
|
||||
instruction = self.instruction_reader.read_instruction()
|
||||
if not instruction:
|
||||
return
|
||||
|
||||
click_command = typer.main.get_command(cli)
|
||||
if self.support.is_click_style_instruction(instruction):
|
||||
raise SystemExit(
|
||||
click_shell_complete(click_command, {}, 'shierfeng', self.support.complete_env_var, instruction)
|
||||
)
|
||||
if self.support.is_typer_style_instruction(instruction):
|
||||
raise SystemExit(
|
||||
typer_shell_complete(click_command, {}, 'shierfeng', self.support.complete_env_var, instruction)
|
||||
)
|
||||
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
DEFAULT_COMPLETION_INSTRUCTION_SUPPORT = CompletionInstructionSupport()
|
||||
@@ -0,0 +1,239 @@
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
from cli.context import CLI_CONTEXT_BUILDER, CliContext, CliContextBuilder
|
||||
from cli.guards import (
|
||||
DEFAULT_DANGEROUS_COMMAND_GUARD,
|
||||
DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY,
|
||||
DangerousCommandGuardService,
|
||||
DangerousCommandRuleRegistry,
|
||||
)
|
||||
from cli.output import OutputRenderer
|
||||
|
||||
|
||||
@dataclass
|
||||
class CliRuntimeState:
|
||||
"""
|
||||
CLI 运行期状态。
|
||||
|
||||
:param logs_suppressed: 当前进程日志是否已切换为静默模式
|
||||
"""
|
||||
|
||||
logs_suppressed: bool = False
|
||||
sqlalchemy_logs_suppressed: bool = False
|
||||
|
||||
def get_logger(self) -> Any:
|
||||
"""
|
||||
获取 CLI 使用的日志对象。
|
||||
|
||||
:return: 日志对象
|
||||
"""
|
||||
return import_module('utils.log_util').logger
|
||||
|
||||
def suppress_logs(self) -> None:
|
||||
"""
|
||||
关闭 CLI 进程中的终端日志输出。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
if self.logs_suppressed:
|
||||
return
|
||||
self.get_logger().remove()
|
||||
self.logs_suppressed = True
|
||||
|
||||
def suppress_sqlalchemy_logs(self) -> None:
|
||||
"""
|
||||
关闭 CLI 进程中的 SQLAlchemy 终端 SQL 日志输出。
|
||||
|
||||
该逻辑同时处理两类来源:
|
||||
|
||||
1. 将 `config.env.DataBaseConfig.db_echo` 强制关闭,避免后续新建 Engine 时
|
||||
继续打开 SQLAlchemy echo。
|
||||
2. 将已知 SQLAlchemy logger 级别提升到 WARNING,避免已有 logger 配置把
|
||||
`INFO sqlalchemy.engine.Engine ...` 继续打到标准输出。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
if self.sqlalchemy_logs_suppressed:
|
||||
return
|
||||
env_module = import_module('config.env')
|
||||
database_config = getattr(env_module, 'DataBaseConfig', None)
|
||||
if database_config is not None and hasattr(database_config, 'db_echo'):
|
||||
database_config.db_echo = False
|
||||
for logger_name in (
|
||||
'sqlalchemy',
|
||||
'sqlalchemy.engine',
|
||||
'sqlalchemy.engine.Engine',
|
||||
'sqlalchemy.pool',
|
||||
):
|
||||
logging.getLogger(logger_name).setLevel(logging.WARNING)
|
||||
self.sqlalchemy_logs_suppressed = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class CliLogPolicy:
|
||||
"""
|
||||
CLI 日志策略服务。
|
||||
|
||||
该对象负责在上下文构建前应用统一的日志输出策略,
|
||||
将“是否静默日志”这一策略从上下文工厂主体中拆出。
|
||||
|
||||
:param runtime_state: CLI 运行期状态对象
|
||||
"""
|
||||
|
||||
runtime_state: CliRuntimeState
|
||||
|
||||
def prepare_regular_command(self) -> None:
|
||||
"""
|
||||
为普通 CLI 命令应用默认日志策略。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.runtime_state.suppress_logs()
|
||||
self.runtime_state.suppress_sqlalchemy_logs()
|
||||
|
||||
|
||||
@dataclass
|
||||
class DangerousCommandContextSupport:
|
||||
"""
|
||||
危险命令上下文支持服务。
|
||||
|
||||
该对象负责危险命令规则查询、保护执行与拒绝结果收口,
|
||||
让上下文工厂本体只保留装配职责。
|
||||
|
||||
:param dangerous_command_rule_registry: 危险命令规则注册表
|
||||
:param dangerous_command_guard_service: 危险命令保护执行服务
|
||||
:param output_renderer: 输出渲染器
|
||||
"""
|
||||
|
||||
dangerous_command_rule_registry: DangerousCommandRuleRegistry
|
||||
dangerous_command_guard_service: DangerousCommandGuardService
|
||||
output_renderer: OutputRenderer
|
||||
|
||||
def guard_context(self, ctx: CliContext, *, command_name: str) -> CliContext:
|
||||
"""
|
||||
对危险命令上下文执行统一保护。
|
||||
|
||||
:param ctx: 已构建的 CLI 上下文
|
||||
:param command_name: 命令唯一标识
|
||||
:return: 通过保护后的 CLI 上下文
|
||||
"""
|
||||
rule = self.dangerous_command_rule_registry.require_rule(command_name)
|
||||
guard_result = self.dangerous_command_guard_service.guard(ctx, rule=rule)
|
||||
if guard_result is not None:
|
||||
self.output_renderer.complete_command(guard_result, ctx)
|
||||
return ctx
|
||||
|
||||
|
||||
@dataclass
|
||||
class CliContextFactory:
|
||||
"""
|
||||
统一构建 CLI 命令上下文。
|
||||
|
||||
:param runtime_state: CLI 运行期状态对象
|
||||
:param output_renderer: 输出渲染器
|
||||
:param cli_context_builder: CLI 上下文构建器
|
||||
:param dangerous_command_rule_registry: 危险命令规则注册表
|
||||
:param dangerous_command_guard_service: 危险命令保护执行服务
|
||||
"""
|
||||
|
||||
runtime_state: CliRuntimeState = field(default_factory=CliRuntimeState)
|
||||
output_renderer: OutputRenderer = field(default_factory=OutputRenderer)
|
||||
cli_context_builder: CliContextBuilder = field(default_factory=lambda: CLI_CONTEXT_BUILDER)
|
||||
dangerous_command_rule_registry: DangerousCommandRuleRegistry = field(
|
||||
default_factory=lambda: DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY
|
||||
)
|
||||
dangerous_command_guard_service: DangerousCommandGuardService = field(
|
||||
default_factory=lambda: DEFAULT_DANGEROUS_COMMAND_GUARD
|
||||
)
|
||||
_log_policy: CliLogPolicy | None = field(default=None, init=False, repr=False)
|
||||
_dangerous_command_support: DangerousCommandContextSupport | None = field(
|
||||
default=None,
|
||||
init=False,
|
||||
repr=False,
|
||||
)
|
||||
|
||||
def get_log_policy(self) -> CliLogPolicy:
|
||||
"""
|
||||
获取当前上下文工厂使用的日志策略服务。
|
||||
|
||||
:return: CLI 日志策略服务
|
||||
"""
|
||||
if self._log_policy is None:
|
||||
self._log_policy = CliLogPolicy(runtime_state=self.runtime_state)
|
||||
return self._log_policy
|
||||
|
||||
def get_dangerous_command_support(self) -> DangerousCommandContextSupport:
|
||||
"""
|
||||
获取当前上下文工厂使用的危险命令上下文支持服务。
|
||||
|
||||
:return: 危险命令上下文支持服务
|
||||
"""
|
||||
if self._dangerous_command_support is None:
|
||||
self._dangerous_command_support = DangerousCommandContextSupport(
|
||||
dangerous_command_rule_registry=self.dangerous_command_rule_registry,
|
||||
dangerous_command_guard_service=self.dangerous_command_guard_service,
|
||||
output_renderer=self.output_renderer,
|
||||
)
|
||||
return self._dangerous_command_support
|
||||
|
||||
def build_regular(
|
||||
self,
|
||||
env: str,
|
||||
output: str,
|
||||
allow_prod: bool,
|
||||
yes: bool,
|
||||
dry_run: bool,
|
||||
) -> CliContext:
|
||||
"""
|
||||
构建普通命令上下文。
|
||||
|
||||
:param env: 运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:param dry_run: 是否演练执行
|
||||
:return: CLI 上下文
|
||||
"""
|
||||
self.get_log_policy().prepare_regular_command()
|
||||
return self.cli_context_builder.build(env, output, allow_prod, yes, dry_run)
|
||||
|
||||
def build_readonly(
|
||||
self,
|
||||
env: str,
|
||||
output: str,
|
||||
) -> CliContext:
|
||||
"""
|
||||
构建只读命令上下文。
|
||||
|
||||
:param env: 运行环境
|
||||
:param output: 输出格式
|
||||
:return: CLI 上下文
|
||||
"""
|
||||
return self.build_regular(env, output, False, False, False)
|
||||
|
||||
def build_dangerous(
|
||||
self,
|
||||
env: str,
|
||||
output: str,
|
||||
allow_prod: bool,
|
||||
yes: bool,
|
||||
dry_run: bool,
|
||||
*,
|
||||
command_name: str,
|
||||
) -> CliContext:
|
||||
"""
|
||||
构建危险命令上下文并执行统一保护。
|
||||
|
||||
:param env: 运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:param dry_run: 是否演练执行
|
||||
:param command_name: 命令唯一标识
|
||||
:return: CLI 上下文
|
||||
"""
|
||||
ctx = self.build_regular(env, output, allow_prod, yes, dry_run)
|
||||
return self.get_dangerous_command_support().guard_context(ctx, command_name=command_name)
|
||||
@@ -0,0 +1,137 @@
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from cli.context import CliContext
|
||||
from cli.exit_codes import RUNTIME_ERROR
|
||||
from cli.output import CommandResult, OutputRenderer
|
||||
|
||||
|
||||
@dataclass
|
||||
class CliExecutionService:
|
||||
"""
|
||||
CLI 执行与结果收口服务。
|
||||
|
||||
:param output_renderer: 输出渲染器
|
||||
"""
|
||||
|
||||
output_renderer: OutputRenderer = field(default_factory=OutputRenderer)
|
||||
|
||||
def run_async(self, coroutine: Any) -> Any:
|
||||
"""
|
||||
执行异步协程并返回结果。
|
||||
|
||||
:param coroutine: 待执行协程
|
||||
:return: 协程执行结果
|
||||
"""
|
||||
return asyncio.run(coroutine)
|
||||
|
||||
def complete_payload(
|
||||
self,
|
||||
ctx: CliContext,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
default_exit_code: int = 0,
|
||||
) -> None:
|
||||
"""
|
||||
输出标准字典负载并结束命令。
|
||||
|
||||
:param ctx: CLI 上下文
|
||||
:param payload: 标准结果字典
|
||||
:param default_exit_code: 默认退出码
|
||||
:return: None
|
||||
"""
|
||||
self.output_renderer.complete_command(self.build_result(payload, default_exit_code=default_exit_code), ctx)
|
||||
|
||||
def complete_result(
|
||||
self,
|
||||
ctx: CliContext,
|
||||
result: CommandResult,
|
||||
) -> None:
|
||||
"""
|
||||
输出命令结果对象并结束命令。
|
||||
|
||||
:param ctx: CLI 上下文
|
||||
:param result: 命令执行结果对象
|
||||
:return: None
|
||||
"""
|
||||
self.output_renderer.complete_command(result, ctx)
|
||||
|
||||
def complete_payload_with_text(
|
||||
self,
|
||||
ctx: CliContext,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
text_builder: Callable[[dict[str, Any]], Any],
|
||||
default_exit_code: int = 0,
|
||||
text_condition: Callable[[dict[str, Any]], bool] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
按输出格式收口标准负载,必要时先转换为文本结果。
|
||||
|
||||
:param ctx: CLI 上下文
|
||||
:param payload: 标准结果字典
|
||||
:param text_builder: 文本结果构建函数
|
||||
:param default_exit_code: 默认退出码
|
||||
:param text_condition: 文本模式下是否应用构建函数的判定函数
|
||||
:return: None
|
||||
"""
|
||||
if ctx.output != 'text':
|
||||
self.complete_payload(ctx, payload, default_exit_code=default_exit_code)
|
||||
return
|
||||
|
||||
result = self.build_result(payload, default_exit_code=default_exit_code)
|
||||
should_build_text = True
|
||||
if callable(text_condition) and isinstance(result.data, dict):
|
||||
should_build_text = bool(text_condition(result.data))
|
||||
if should_build_text and isinstance(result.data, dict):
|
||||
result.data = text_builder(result.data)
|
||||
self.complete_result(ctx, result)
|
||||
|
||||
def complete_payload_result(
|
||||
self,
|
||||
ctx: CliContext,
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
text_builder: Callable[[dict[str, Any]], Any] | None = None,
|
||||
default_exit_code: int = 0,
|
||||
text_condition: Callable[[dict[str, Any]], bool] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
统一收口标准负载,按输出格式决定是否渲染为文本结果。
|
||||
|
||||
:param ctx: CLI 上下文
|
||||
:param payload: 标准结果字典
|
||||
:param text_builder: 文本结果构建函数
|
||||
:param default_exit_code: 默认退出码
|
||||
:param text_condition: 文本模式下是否应用构建函数的判定函数
|
||||
:return: None
|
||||
"""
|
||||
if text_builder is None:
|
||||
self.complete_payload(ctx, payload, default_exit_code=default_exit_code)
|
||||
return
|
||||
self.complete_payload_with_text(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=text_builder,
|
||||
default_exit_code=default_exit_code,
|
||||
text_condition=text_condition,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_result(payload: dict[str, Any], *, default_exit_code: int = 0) -> CommandResult:
|
||||
"""
|
||||
将标准字典负载转换为命令结果对象。
|
||||
|
||||
:param payload: 标准结果字典
|
||||
:param default_exit_code: 默认退出码;当 payload 未显式提供 exit_code 且
|
||||
该值仍为 0 时,失败结果会统一回退到 `RUNTIME_ERROR`
|
||||
:return: 命令结果对象
|
||||
"""
|
||||
result_payload = dict(payload)
|
||||
exit_code = result_payload.pop('exit_code', None)
|
||||
if exit_code is None:
|
||||
is_ok = bool(result_payload.get('ok', True))
|
||||
exit_code = default_exit_code if is_ok or default_exit_code != 0 else RUNTIME_ERROR
|
||||
return CommandResult(data=result_payload, exit_code=exit_code)
|
||||
@@ -0,0 +1,8 @@
|
||||
SUCCESS = 0
|
||||
ARGUMENT_ERROR = 2
|
||||
DEPENDENCY_ERROR = 10
|
||||
DATABASE_ERROR = 20
|
||||
REDIS_ERROR = 21
|
||||
SCHEDULER_ERROR = 22
|
||||
GUARD_REJECTED = 30
|
||||
RUNTIME_ERROR = 50
|
||||
@@ -0,0 +1,3 @@
|
||||
from .command import app
|
||||
|
||||
__all__ = ['app']
|
||||
@@ -0,0 +1,102 @@
|
||||
from typing import Annotated, Literal
|
||||
|
||||
import typer
|
||||
|
||||
from cli.context import EnvOption, OutputOption
|
||||
|
||||
from .controller import AppCommandController
|
||||
|
||||
app = typer.Typer(
|
||||
help='应用相关命令',
|
||||
no_args_is_help=True,
|
||||
context_settings={'help_option_names': ['-h', '--help']},
|
||||
)
|
||||
_APP_COMMAND_CONTROLLER = AppCommandController()
|
||||
|
||||
|
||||
@app.command('run', help='启动当前 FastAPI 应用')
|
||||
def run_app(
|
||||
env: EnvOption = 'dev',
|
||||
) -> None:
|
||||
"""
|
||||
启动当前 FastAPI 应用。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:return: None
|
||||
"""
|
||||
_APP_COMMAND_CONTROLLER.run_app(env)
|
||||
|
||||
|
||||
@app.command('doctor', help='执行应用启动前检查')
|
||||
def doctor(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
) -> None:
|
||||
"""
|
||||
执行应用启动前检查。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
_APP_COMMAND_CONTROLLER.doctor(env, output)
|
||||
|
||||
|
||||
@app.command('config', help='查看当前应用配置快照')
|
||||
def app_config(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
) -> None:
|
||||
"""
|
||||
输出当前应用配置快照。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
_APP_COMMAND_CONTROLLER.show_config(env, output)
|
||||
|
||||
|
||||
@app.command('env', help='查看当前 CLI 解析到的应用环境信息')
|
||||
def app_env(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
) -> None:
|
||||
"""
|
||||
输出当前 CLI 解析到的应用环境信息。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
_APP_COMMAND_CONTROLLER.show_env(env, output)
|
||||
|
||||
|
||||
@app.command('routes', help='查看当前应用注册路由清单')
|
||||
def routes(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
path_prefix: Annotated[str, typer.Option('--path-prefix', help='按路由前缀过滤')] = '',
|
||||
method: Annotated[str, typer.Option('--method', help='按请求方法过滤,如 GET、POST')] = '',
|
||||
group_by: Annotated[Literal['none', 'tag'], typer.Option('--group-by', help='路由分组方式')] = 'none',
|
||||
include_hidden: Annotated[bool, typer.Option('--include-hidden', help='包含未出现在 OpenAPI 中的路由')] = False,
|
||||
) -> None:
|
||||
"""
|
||||
输出当前应用注册路由清单。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param path_prefix: 路由前缀过滤条件
|
||||
:param method: 请求方法过滤条件
|
||||
:param group_by: 路由分组方式
|
||||
:param include_hidden: 是否包含隐藏路由
|
||||
:return: None
|
||||
"""
|
||||
_APP_COMMAND_CONTROLLER.show_routes(
|
||||
env,
|
||||
output,
|
||||
path_prefix=path_prefix,
|
||||
method=method,
|
||||
group_by=group_by,
|
||||
include_hidden=include_hidden,
|
||||
)
|
||||
@@ -0,0 +1,236 @@
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
|
||||
from fastapi.routing import APIRoute
|
||||
|
||||
from cli.bootstrap import APP_BOOTSTRAP, AppBootstrapService
|
||||
from cli.core import (
|
||||
DEFAULT_CORE_SERVICES,
|
||||
CliContextFactory,
|
||||
CliExecutionService,
|
||||
)
|
||||
from cli.exit_codes import DEPENDENCY_ERROR, SUCCESS
|
||||
from cli.runtime.app import APP_RUNTIME, AppRuntimeService
|
||||
from cli.runtime.crypto import CRYPTO_RUNTIME, CryptoRuntimeService
|
||||
from cli.runtime.db import DATABASE_RUNTIME, DatabaseRuntimeService
|
||||
from cli.runtime.ops import OPERATIONS_RUNTIME, OperationsRuntimeService
|
||||
|
||||
from .presenter import AppCommandPresenter
|
||||
|
||||
|
||||
class AppCommandController:
|
||||
"""
|
||||
应用命令控制器。
|
||||
|
||||
该控制器负责组织 `app` 命令组的上下文准备、runtime 调用、
|
||||
payload 构建,以及基于输出格式选择 presenter 或直接返回 JSON。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
context_factory: CliContextFactory | None = None,
|
||||
execution_service: CliExecutionService | None = None,
|
||||
presenter: AppCommandPresenter | None = None,
|
||||
runtime_service: AppRuntimeService | None = None,
|
||||
database_runtime: DatabaseRuntimeService | None = None,
|
||||
operations_runtime: OperationsRuntimeService | None = None,
|
||||
crypto_runtime: CryptoRuntimeService | None = None,
|
||||
bootstrap_service: AppBootstrapService | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化应用命令控制器。
|
||||
|
||||
:param context_factory: CLI 上下文工厂
|
||||
:param execution_service: CLI 执行服务
|
||||
:param presenter: 应用命令文本渲染器
|
||||
:param runtime_service: 应用运行时服务
|
||||
:param database_runtime: 数据库运行时服务
|
||||
:param operations_runtime: 运维运行时服务
|
||||
:param crypto_runtime: 传输加密运行时服务
|
||||
:param bootstrap_service: 应用引导服务
|
||||
:return: None
|
||||
"""
|
||||
self.context_factory = context_factory or DEFAULT_CORE_SERVICES.context_factory
|
||||
self.execution_service = execution_service or DEFAULT_CORE_SERVICES.execution_service
|
||||
self.presenter = presenter or AppCommandPresenter()
|
||||
self.runtime_service = runtime_service or APP_RUNTIME
|
||||
self.database_runtime = database_runtime or DATABASE_RUNTIME
|
||||
self.operations_runtime = operations_runtime or OPERATIONS_RUNTIME
|
||||
self.crypto_runtime = crypto_runtime or CRYPTO_RUNTIME
|
||||
self.bootstrap_service = bootstrap_service or APP_BOOTSTRAP
|
||||
|
||||
def run_app(self, env: str) -> None:
|
||||
"""
|
||||
启动当前 FastAPI 应用。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:return: None
|
||||
"""
|
||||
self.bootstrap_service.exec_app_run_command(env)
|
||||
|
||||
def doctor(self, env: str, output: str) -> None:
|
||||
"""
|
||||
执行应用启动前检查。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
db_status = self.execution_service.run_async(self.database_runtime.ping_database())
|
||||
redis_status = self.execution_service.run_async(self.operations_runtime.ping_redis())
|
||||
crypto_status = self.crypto_runtime.validate_crypto_config()
|
||||
payload = {
|
||||
'env': ctx.env,
|
||||
'database': db_status,
|
||||
'redis': redis_status,
|
||||
'crypto': crypto_status,
|
||||
}
|
||||
payload['ok'] = all(item.get('ok', False) for item in (db_status, redis_status, crypto_status))
|
||||
exit_code = SUCCESS if payload['ok'] else DEPENDENCY_ERROR
|
||||
self.execution_service.complete_payload_with_text(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=self.presenter.build_doctor_text,
|
||||
default_exit_code=exit_code,
|
||||
)
|
||||
|
||||
def show_config(self, env: str, output: str) -> None:
|
||||
"""
|
||||
输出当前应用配置快照。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
self.execution_service.complete_payload_result(
|
||||
ctx,
|
||||
{'ok': True, 'env': ctx.env, 'config': self.runtime_service.get_app_config_snapshot()},
|
||||
text_builder=self.presenter.build_app_config_text,
|
||||
default_exit_code=SUCCESS,
|
||||
)
|
||||
|
||||
def show_env(self, env: str, output: str) -> None:
|
||||
"""
|
||||
输出当前 CLI 解析到的应用环境信息。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
self.execution_service.complete_payload_result(
|
||||
ctx,
|
||||
{'ok': True, 'env': ctx.env, 'runtime': self.runtime_service.get_app_env_snapshot()},
|
||||
text_builder=self.presenter.build_app_env_text,
|
||||
default_exit_code=SUCCESS,
|
||||
)
|
||||
|
||||
def show_routes(
|
||||
self,
|
||||
env: str,
|
||||
output: str,
|
||||
*,
|
||||
path_prefix: str,
|
||||
method: str,
|
||||
group_by: str,
|
||||
include_hidden: bool,
|
||||
) -> None:
|
||||
"""
|
||||
输出当前应用注册路由清单。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param path_prefix: 路由前缀过滤条件
|
||||
:param method: 请求方法过滤条件
|
||||
:param group_by: 路由分组方式
|
||||
:param include_hidden: 是否包含隐藏路由
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
routes_payload = self._serialize_routes(
|
||||
self.runtime_service.build_app_instance(),
|
||||
path_prefix=path_prefix,
|
||||
method=method,
|
||||
include_hidden=include_hidden,
|
||||
)
|
||||
grouped_routes = self._group_routes_by_tag(routes_payload) if group_by == 'tag' else None
|
||||
payload = {
|
||||
'ok': True,
|
||||
'env': ctx.env,
|
||||
'count': len(routes_payload),
|
||||
'filters': {
|
||||
'pathPrefix': path_prefix,
|
||||
'method': method.upper().strip(),
|
||||
'groupBy': group_by,
|
||||
'includeHidden': include_hidden,
|
||||
},
|
||||
'routes': routes_payload,
|
||||
'groupedRoutes': grouped_routes,
|
||||
}
|
||||
self.execution_service.complete_payload_with_text(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=self.presenter.build_routes_text,
|
||||
default_exit_code=SUCCESS,
|
||||
text_condition=lambda data: data.get('ok', False),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _serialize_routes(
|
||||
app_instance: Any,
|
||||
*,
|
||||
path_prefix: str = '',
|
||||
method: str = '',
|
||||
include_hidden: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
序列化 FastAPI 路由信息。
|
||||
|
||||
:param app_instance: FastAPI 应用实例
|
||||
:param path_prefix: 路径前缀过滤条件
|
||||
:param method: 请求方法过滤条件
|
||||
:param include_hidden: 是否包含未出现在 OpenAPI 中的路由
|
||||
:return: 序列化后的路由列表
|
||||
"""
|
||||
normalized_method = method.upper().strip()
|
||||
routes = []
|
||||
for route in app_instance.routes:
|
||||
if not isinstance(route, APIRoute):
|
||||
continue
|
||||
if not include_hidden and not route.include_in_schema:
|
||||
continue
|
||||
if path_prefix and not route.path.startswith(path_prefix):
|
||||
continue
|
||||
route_methods = sorted(item for item in route.methods if item not in {'HEAD', 'OPTIONS'})
|
||||
if normalized_method and normalized_method not in route_methods:
|
||||
continue
|
||||
routes.append(
|
||||
{
|
||||
'path': route.path,
|
||||
'methods': route_methods,
|
||||
'name': route.name,
|
||||
'summary': route.summary or '',
|
||||
'operationId': route.operation_id or '',
|
||||
'tags': route.tags or [],
|
||||
'includeInSchema': route.include_in_schema,
|
||||
}
|
||||
)
|
||||
return sorted(routes, key=lambda item: (item['path'], ','.join(item['methods'])))
|
||||
|
||||
@staticmethod
|
||||
def _group_routes_by_tag(routes_payload: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
|
||||
"""
|
||||
按标签对路由信息分组。
|
||||
|
||||
:param routes_payload: 原始路由列表
|
||||
:return: 按标签分组后的路由映射
|
||||
"""
|
||||
grouped_routes: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
for route in routes_payload:
|
||||
tags = route.get('tags') or ['__untagged__']
|
||||
for tag in tags:
|
||||
grouped_routes[tag].append(route)
|
||||
return dict(sorted(grouped_routes.items(), key=lambda item: item[0]))
|
||||
@@ -0,0 +1,210 @@
|
||||
from typing import Any
|
||||
|
||||
from cli.utils import SHELL_TEXT_FORMATTER
|
||||
|
||||
|
||||
class AppCommandPresenter:
|
||||
"""
|
||||
应用命令文本渲染器。
|
||||
|
||||
该渲染器负责将 `app` 命令组产生的结构化 payload 转换为稳定的文本摘要,
|
||||
同时保持 JSON 输出仍由控制器直接返回,不在此处做契约变形。
|
||||
"""
|
||||
|
||||
def build_routes_text(self, payload: dict[str, Any]) -> str:
|
||||
"""
|
||||
将路由列表结果渲染为文本摘要。
|
||||
|
||||
:param payload: 路由列表结果字典
|
||||
:return: 文本摘要
|
||||
"""
|
||||
lines = [
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'env: {payload.get("env", "")}',
|
||||
f'count: {payload.get("count", 0)}',
|
||||
]
|
||||
filters = payload.get('filters')
|
||||
if isinstance(filters, dict):
|
||||
lines.extend(self._build_route_filter_lines(filters))
|
||||
|
||||
grouped_routes = payload.get('groupedRoutes')
|
||||
if filters and filters.get('groupBy') == 'tag' and isinstance(grouped_routes, dict):
|
||||
lines.extend(self._build_grouped_routes_text(grouped_routes))
|
||||
return '\n'.join(lines)
|
||||
|
||||
routes = payload.get('routes')
|
||||
if not isinstance(routes, list) or not routes:
|
||||
lines.append('routes: none')
|
||||
return '\n'.join(lines)
|
||||
|
||||
lines.append('routes:')
|
||||
for route in routes:
|
||||
if isinstance(route, dict):
|
||||
lines.extend([f' {line}' for line in self._build_route_item_lines(route)])
|
||||
return '\n'.join(lines)
|
||||
|
||||
def build_app_config_text(self, payload: dict[str, Any]) -> str:
|
||||
"""
|
||||
将应用配置快照渲染为文本摘要。
|
||||
|
||||
:param payload: 应用配置结果字典
|
||||
:return: 文本摘要
|
||||
"""
|
||||
config = payload.get('config')
|
||||
if not isinstance(config, dict):
|
||||
return '\n'.join(
|
||||
[
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'env: {payload.get("env", "")}',
|
||||
'config: none',
|
||||
]
|
||||
)
|
||||
|
||||
lines = [
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'env: {payload.get("env", "")}',
|
||||
'application:',
|
||||
f' name: {config.get("name", "-")}',
|
||||
f' host: {config.get("host", "-")}:{config.get("port", "-")}',
|
||||
f' root_path: {config.get("rootPath", "-") or "/"}',
|
||||
f' reload: {str(config.get("reload", False)).lower()}',
|
||||
f' workers: {config.get("workers", "-")}',
|
||||
f' disable_swagger: {str(config.get("disableSwagger", False)).lower()}',
|
||||
f' disable_redoc: {str(config.get("disableRedoc", False)).lower()}',
|
||||
'database:',
|
||||
f' type: {config.get("dbType", "-")}',
|
||||
f' host: {config.get("dbHost", "-")}:{config.get("dbPort", "-")}',
|
||||
f' name: {config.get("dbDatabase", "-")}',
|
||||
'redis:',
|
||||
f' host: {config.get("redisHost", "-")}:{config.get("redisPort", "-")}',
|
||||
'logging:',
|
||||
f' level: {config.get("logLevel", "-")}',
|
||||
'transport_crypto:',
|
||||
f' enabled: {str(config.get("transportCryptoEnabled", False)).lower()}',
|
||||
f' mode: {config.get("transportCryptoMode", "-")}',
|
||||
]
|
||||
return '\n'.join(lines)
|
||||
|
||||
def build_app_env_text(self, payload: dict[str, Any]) -> str:
|
||||
"""
|
||||
将应用环境解析结果渲染为文本摘要。
|
||||
|
||||
:param payload: 应用环境结果字典
|
||||
:return: 文本摘要
|
||||
"""
|
||||
runtime = payload.get('runtime')
|
||||
if not isinstance(runtime, dict):
|
||||
return '\n'.join(
|
||||
[
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'env: {payload.get("env", "")}',
|
||||
'runtime: none',
|
||||
]
|
||||
)
|
||||
|
||||
return '\n'.join(
|
||||
[
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'env: {payload.get("env", "")}',
|
||||
'runtime:',
|
||||
f' cli_env: {runtime.get("cliEnv", "-")}',
|
||||
f' config_env: {runtime.get("configEnv", "-")}',
|
||||
f' app_env: {runtime.get("appEnv", "-")}',
|
||||
f' env_file: {runtime.get("envFile", "-")}',
|
||||
f' env_file_exists: {str(runtime.get("envFileExists", False)).lower()}',
|
||||
f' backend_dir: {runtime.get("backendDir", "-")}',
|
||||
f' python_executable: {runtime.get("pythonExecutable", "-")}',
|
||||
]
|
||||
)
|
||||
|
||||
def build_doctor_text(self, payload: dict[str, Any]) -> str:
|
||||
"""
|
||||
将应用启动前检查结果渲染为文本摘要。
|
||||
|
||||
:param payload: 启动前检查结果字典
|
||||
:return: 文本摘要
|
||||
"""
|
||||
return '\n'.join(
|
||||
[
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'env: {payload.get("env", "")}',
|
||||
'checks:',
|
||||
self._build_check_status_line('database', payload.get('database')),
|
||||
self._build_check_status_line('redis', payload.get('redis')),
|
||||
self._build_check_status_line('crypto', payload.get('crypto')),
|
||||
]
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_route_filter_lines(filters: dict[str, object]) -> list[str]:
|
||||
"""
|
||||
构建路由过滤条件文本行。
|
||||
|
||||
:param filters: 过滤条件字典
|
||||
:return: 过滤条件文本行列表
|
||||
"""
|
||||
active_filters = []
|
||||
for key, value in filters.items():
|
||||
if value in (None, '', False, 'none'):
|
||||
continue
|
||||
active_filters.append(f'{SHELL_TEXT_FORMATTER.to_snake_case(key)}={value}')
|
||||
if not active_filters:
|
||||
return ['filters: none']
|
||||
return ['filters:', *[f' - {item}' for item in active_filters]]
|
||||
|
||||
@staticmethod
|
||||
def _build_route_item_lines(route_item: dict[str, Any]) -> list[str]:
|
||||
"""
|
||||
构建单条路由记录的文本行。
|
||||
|
||||
:param route_item: 单条路由记录
|
||||
:return: 文本行列表
|
||||
"""
|
||||
methods = ','.join(route_item.get('methods', [])) or '-'
|
||||
path = SHELL_TEXT_FORMATTER.truncate_text(route_item.get('path', ''), 72)
|
||||
tags = ','.join(route_item.get('tags', [])) or '-'
|
||||
summary = SHELL_TEXT_FORMATTER.truncate_text(route_item.get('summary', ''), 60)
|
||||
name = SHELL_TEXT_FORMATTER.truncate_text(route_item.get('name', ''), 40)
|
||||
return [
|
||||
f'- [{methods}] {path}',
|
||||
f' name: {name or "-"} | tags: {tags}',
|
||||
f' summary: {summary or "-"}',
|
||||
]
|
||||
|
||||
def _build_grouped_routes_text(self, grouped_routes: dict[str, Any]) -> list[str]:
|
||||
"""
|
||||
将按标签分组后的路由结果渲染为文本行。
|
||||
|
||||
:param grouped_routes: 按标签分组后的路由映射
|
||||
:return: 文本行列表
|
||||
"""
|
||||
if not grouped_routes:
|
||||
return ['groups: none']
|
||||
|
||||
lines = [f'groups: {len(grouped_routes)}']
|
||||
for tag, routes in grouped_routes.items():
|
||||
route_items = routes if isinstance(routes, list) else []
|
||||
lines.append(f' {tag}: {len(route_items)}')
|
||||
for route in route_items:
|
||||
if isinstance(route, dict):
|
||||
lines.extend([f' {line}' for line in self._build_route_item_lines(route)])
|
||||
return lines
|
||||
|
||||
@staticmethod
|
||||
def _build_check_status_line(name: str, status_payload: dict[str, Any] | None) -> str:
|
||||
"""
|
||||
构建单项检查结果摘要行。
|
||||
|
||||
:param name: 检查项名称
|
||||
:param status_payload: 检查结果字典
|
||||
:return: 摘要行文本
|
||||
"""
|
||||
if not isinstance(status_payload, dict):
|
||||
return f' {name}: unknown'
|
||||
|
||||
ok = str(status_payload.get('ok', False)).lower()
|
||||
message = status_payload.get('message', '-') or '-'
|
||||
error = status_payload.get('error')
|
||||
if error:
|
||||
return f' {name}: {ok} | {message} | error: {SHELL_TEXT_FORMATTER.truncate_text(error, 120)}'
|
||||
return f' {name}: {ok} | {message}'
|
||||
@@ -0,0 +1,3 @@
|
||||
from .command import app
|
||||
|
||||
__all__ = ['app']
|
||||
@@ -0,0 +1,163 @@
|
||||
import typer
|
||||
|
||||
from cli.completion.providers import COMPLETION_PROVIDER_GATEWAY
|
||||
from cli.context import AllowProdOption, DryRunOption, EnvOption, OutputOption, YesOption
|
||||
|
||||
from .controller import CacheCommandController
|
||||
|
||||
app = typer.Typer(
|
||||
help='缓存相关命令',
|
||||
no_args_is_help=True,
|
||||
context_settings={'help_option_names': ['-h', '--help']},
|
||||
)
|
||||
_CACHE_COMMAND_CONTROLLER = CacheCommandController()
|
||||
|
||||
|
||||
@app.command('stats', help='查看缓存统计信息')
|
||||
def stats(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
) -> None:
|
||||
"""
|
||||
查看缓存统计信息。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
_CACHE_COMMAND_CONTROLLER.stats(env, output)
|
||||
|
||||
|
||||
@app.command('keys', help='查看指定缓存名称下的键名列表')
|
||||
def keys(
|
||||
cache_name: str = typer.Argument(
|
||||
...,
|
||||
help='缓存名称',
|
||||
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_cache_names,
|
||||
),
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
) -> None:
|
||||
"""
|
||||
查看指定缓存名称下的键名列表。
|
||||
|
||||
:param cache_name: 缓存名称
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
_CACHE_COMMAND_CONTROLLER.keys(cache_name, env, output)
|
||||
|
||||
|
||||
@app.command('get', help='查看指定缓存内容')
|
||||
def get(
|
||||
cache_name: str = typer.Argument(
|
||||
...,
|
||||
help='缓存名称',
|
||||
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_cache_names,
|
||||
),
|
||||
cache_key: str = typer.Argument(
|
||||
...,
|
||||
help='缓存键名',
|
||||
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_cache_keys,
|
||||
),
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
) -> None:
|
||||
"""
|
||||
查看指定缓存内容。
|
||||
|
||||
:param cache_name: 缓存名称
|
||||
:param cache_key: 缓存键名
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
_CACHE_COMMAND_CONTROLLER.get(cache_name, cache_key, env, output)
|
||||
|
||||
|
||||
@app.command('clear', help='执行缓存清理')
|
||||
def clear(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
allow_prod: AllowProdOption = False,
|
||||
yes: YesOption = False,
|
||||
dry_run: DryRunOption = False,
|
||||
cache_name: str = typer.Option(
|
||||
'',
|
||||
'--cache-name',
|
||||
help='按缓存名称前缀清理',
|
||||
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_cache_names,
|
||||
),
|
||||
cache_key: str = typer.Option('', '--cache-key', help='按缓存键名模糊清理'),
|
||||
clear_all: bool = typer.Option(False, '--all', help='清理全部缓存并重建系统基础缓存'),
|
||||
) -> None:
|
||||
"""
|
||||
清理缓存。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:param dry_run: 是否演练执行
|
||||
:param cache_name: 缓存名称前缀
|
||||
:param cache_key: 缓存键名
|
||||
:param clear_all: 是否清理全部缓存
|
||||
:return: None
|
||||
"""
|
||||
_CACHE_COMMAND_CONTROLLER.clear(
|
||||
env,
|
||||
output,
|
||||
allow_prod,
|
||||
yes,
|
||||
dry_run,
|
||||
cache_name=cache_name,
|
||||
cache_key=cache_key,
|
||||
clear_all=clear_all,
|
||||
)
|
||||
|
||||
|
||||
@app.command('ttl', help='查看指定缓存键的剩余过期时间')
|
||||
def ttl(
|
||||
cache_name: str = typer.Argument(
|
||||
...,
|
||||
help='缓存名称',
|
||||
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_cache_names,
|
||||
),
|
||||
cache_key: str = typer.Argument(
|
||||
...,
|
||||
help='缓存键名',
|
||||
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_cache_keys,
|
||||
),
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
) -> None:
|
||||
"""
|
||||
查看指定缓存键的剩余过期时间。
|
||||
|
||||
:param cache_name: 缓存名称
|
||||
:param cache_key: 缓存键名
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
_CACHE_COMMAND_CONTROLLER.ttl(cache_name, cache_key, env, output)
|
||||
|
||||
|
||||
@app.command('warmup', help='执行系统缓存预热')
|
||||
def warmup(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
allow_prod: AllowProdOption = False,
|
||||
yes: YesOption = False,
|
||||
) -> None:
|
||||
"""
|
||||
执行系统缓存预热。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:return: None
|
||||
"""
|
||||
_CACHE_COMMAND_CONTROLLER.warmup(env, output, allow_prod, yes)
|
||||
@@ -0,0 +1,183 @@
|
||||
from cli.core import (
|
||||
DEFAULT_CORE_SERVICES,
|
||||
CliContextFactory,
|
||||
CliExecutionService,
|
||||
)
|
||||
from cli.runtime.cache import CACHE_RUNTIME, CacheRuntimeService
|
||||
|
||||
from .presenter import CacheCommandPresenter
|
||||
|
||||
|
||||
class CacheCommandController:
|
||||
"""
|
||||
缓存命令控制器。
|
||||
|
||||
该控制器负责组织 `cache` 命令组的上下文准备、runtime 调用、
|
||||
payload 注入,以及基于输出格式选择 presenter 或直接返回 JSON。
|
||||
|
||||
:param context_factory: CLI 上下文工厂
|
||||
:param execution_service: CLI 执行服务
|
||||
:param presenter: 缓存命令文本渲染器
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
context_factory: CliContextFactory | None = None,
|
||||
execution_service: CliExecutionService | None = None,
|
||||
presenter: CacheCommandPresenter | None = None,
|
||||
runtime_service: CacheRuntimeService | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化缓存命令控制器。
|
||||
|
||||
:param context_factory: CLI 上下文工厂
|
||||
:param execution_service: CLI 执行服务
|
||||
:param presenter: 缓存命令文本渲染器
|
||||
:param runtime_service: 缓存运行时服务
|
||||
:return: None
|
||||
"""
|
||||
self.context_factory = context_factory or DEFAULT_CORE_SERVICES.context_factory
|
||||
self.execution_service = execution_service or DEFAULT_CORE_SERVICES.execution_service
|
||||
self.presenter = presenter or CacheCommandPresenter()
|
||||
self.runtime_service = runtime_service or CACHE_RUNTIME
|
||||
|
||||
def stats(self, env: str, output: str) -> None:
|
||||
"""
|
||||
查看缓存统计信息。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
payload = self.execution_service.run_async(self.runtime_service.get_cache_stats())
|
||||
self.execution_service.complete_payload_with_text(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=self.presenter.build_cache_stats_text,
|
||||
text_condition=lambda result_data: bool(result_data.get('ok', False)),
|
||||
)
|
||||
|
||||
def keys(self, cache_name: str, env: str, output: str) -> None:
|
||||
"""
|
||||
查看指定缓存名称下的键名列表。
|
||||
|
||||
:param cache_name: 缓存名称
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
payload = self.execution_service.run_async(self.runtime_service.list_cache_keys(cache_name))
|
||||
self.execution_service.complete_payload_with_text(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=self.presenter.build_cache_keys_text,
|
||||
text_condition=lambda result_data: bool(result_data.get('ok', False)),
|
||||
)
|
||||
|
||||
def get(self, cache_name: str, cache_key: str, env: str, output: str) -> None:
|
||||
"""
|
||||
查看指定缓存内容。
|
||||
|
||||
:param cache_name: 缓存名称
|
||||
:param cache_key: 缓存键名
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
payload = self.execution_service.run_async(self.runtime_service.get_cache_value(cache_name, cache_key))
|
||||
self.execution_service.complete_payload_with_text(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=self.presenter.build_cache_value_text,
|
||||
text_condition=lambda result_data: bool(result_data.get('ok', False)),
|
||||
)
|
||||
|
||||
def clear(
|
||||
self,
|
||||
env: str,
|
||||
output: str,
|
||||
allow_prod: bool,
|
||||
yes: bool,
|
||||
dry_run: bool,
|
||||
*,
|
||||
cache_name: str,
|
||||
cache_key: str,
|
||||
clear_all: bool,
|
||||
) -> None:
|
||||
"""
|
||||
清理缓存。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:param dry_run: 是否演练执行
|
||||
:param cache_name: 缓存名称前缀
|
||||
:param cache_key: 缓存键名
|
||||
:param clear_all: 是否清理全部缓存
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_dangerous(
|
||||
env,
|
||||
output,
|
||||
allow_prod,
|
||||
yes,
|
||||
dry_run,
|
||||
command_name='cache clear',
|
||||
)
|
||||
self.execution_service.complete_payload(
|
||||
ctx,
|
||||
self.execution_service.run_async(
|
||||
self.runtime_service.clear_cache(
|
||||
cache_name=cache_name,
|
||||
cache_key=cache_key,
|
||||
clear_all=clear_all,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
def ttl(self, cache_name: str, cache_key: str, env: str, output: str) -> None:
|
||||
"""
|
||||
查看指定缓存键的剩余过期时间。
|
||||
|
||||
:param cache_name: 缓存名称
|
||||
:param cache_key: 缓存键名
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
payload = self.execution_service.run_async(self.runtime_service.get_cache_ttl(cache_name, cache_key))
|
||||
self.execution_service.complete_payload_with_text(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=self.presenter.build_cache_ttl_text,
|
||||
text_condition=lambda result_data: 'error' not in result_data,
|
||||
)
|
||||
|
||||
def warmup(self, env: str, output: str, allow_prod: bool, yes: bool) -> None:
|
||||
"""
|
||||
执行系统缓存预热。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_dangerous(
|
||||
env,
|
||||
output,
|
||||
allow_prod,
|
||||
yes,
|
||||
False,
|
||||
command_name='cache warmup',
|
||||
)
|
||||
self.execution_service.complete_payload(
|
||||
ctx, self.execution_service.run_async(self.runtime_service.warmup_cache())
|
||||
)
|
||||
@@ -0,0 +1,150 @@
|
||||
from cli.utils import SHELL_TEXT_FORMATTER
|
||||
|
||||
|
||||
class CacheCommandPresenter:
|
||||
"""
|
||||
缓存命令文本渲染器。
|
||||
|
||||
该渲染器负责将 `cache` 命令组产生的结构化 payload 转换为稳定的文本摘要,
|
||||
同时保持 JSON 输出仍由控制器直接返回,不在此处做契约变形。
|
||||
"""
|
||||
|
||||
def build_cache_stats_text(self, payload: dict[str, object]) -> str:
|
||||
"""
|
||||
将缓存统计结果渲染为文本摘要。
|
||||
|
||||
:param payload: 缓存统计结果
|
||||
:return: 文本摘要
|
||||
"""
|
||||
info = payload.get('info')
|
||||
command_stats = payload.get('commandStats')
|
||||
cache_names = payload.get('cacheNames')
|
||||
|
||||
redis_version = '-'
|
||||
connected_clients = '-'
|
||||
used_memory = '-'
|
||||
uptime_seconds = '-'
|
||||
keyspace_hits = '-'
|
||||
keyspace_misses = '-'
|
||||
if isinstance(info, dict):
|
||||
redis_version = info.get('redis_version', '-')
|
||||
connected_clients = info.get('connected_clients', '-')
|
||||
used_memory = info.get('used_memory_human', info.get('used_memory', '-'))
|
||||
uptime_seconds = info.get('uptime_in_seconds', '-')
|
||||
keyspace_hits = info.get('keyspace_hits', '-')
|
||||
keyspace_misses = info.get('keyspace_misses', '-')
|
||||
|
||||
lines = [
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'db_size: {payload.get("dbSize", 0)}',
|
||||
'redis:',
|
||||
f' version: {redis_version}',
|
||||
f' connected_clients: {connected_clients}',
|
||||
f' used_memory: {used_memory}',
|
||||
f' uptime_seconds: {uptime_seconds}',
|
||||
f' keyspace_hits: {keyspace_hits}',
|
||||
f' keyspace_misses: {keyspace_misses}',
|
||||
]
|
||||
|
||||
if isinstance(command_stats, list) and command_stats:
|
||||
lines.append('command_stats_top10:')
|
||||
lines.extend(
|
||||
f' - {item.get("name", "-")}: {item.get("value", 0)}'
|
||||
for item in command_stats[:10]
|
||||
if isinstance(item, dict)
|
||||
)
|
||||
else:
|
||||
lines.append('command_stats_top10: none')
|
||||
|
||||
if isinstance(cache_names, list) and cache_names:
|
||||
lines.append(f'cache_names: {len(cache_names)}')
|
||||
lines.append('cache_name_samples:')
|
||||
lines.extend(
|
||||
f' - {item.get("cacheName", "-")}: {self._build_cache_name_remark(item.get("remark", ""))}'
|
||||
for item in cache_names[:10]
|
||||
if isinstance(item, dict)
|
||||
)
|
||||
else:
|
||||
lines.append('cache_names: 0')
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
def build_cache_keys_text(self, payload: dict[str, object]) -> str:
|
||||
"""
|
||||
将缓存键列表结果渲染为文本摘要。
|
||||
|
||||
:param payload: 缓存键列表结果
|
||||
:return: 文本摘要
|
||||
"""
|
||||
keys = payload.get('keys')
|
||||
lines = [
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'cache_name: {payload.get("cacheName", "")}',
|
||||
f'count: {payload.get("count", 0)}',
|
||||
]
|
||||
if not isinstance(keys, list) or not keys:
|
||||
lines.append('keys: none')
|
||||
return '\n'.join(lines)
|
||||
|
||||
lines.append('keys:')
|
||||
lines.extend(f' - {key}' for key in keys)
|
||||
return '\n'.join(lines)
|
||||
|
||||
def build_cache_value_text(self, payload: dict[str, object]) -> str:
|
||||
"""
|
||||
将缓存值读取结果渲染为文本摘要。
|
||||
|
||||
:param payload: 缓存值读取结果字典
|
||||
:return: 文本摘要
|
||||
"""
|
||||
cache_value = '' if payload.get('cacheValue') is None else str(payload.get('cacheValue'))
|
||||
cache_value_lines = cache_value.splitlines() if cache_value else []
|
||||
if not cache_value_lines:
|
||||
rendered_value_lines = [' -']
|
||||
elif len(cache_value_lines) == 1:
|
||||
rendered_value_lines = [f' {SHELL_TEXT_FORMATTER.truncate_text(cache_value_lines[0], 200)}']
|
||||
else:
|
||||
rendered_value_lines = [' |', *[f' {line}' for line in cache_value_lines]]
|
||||
|
||||
return '\n'.join(
|
||||
[
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'cache_name: {payload.get("cacheName", "")}',
|
||||
f'cache_key: {payload.get("cacheKey", "")}',
|
||||
f'full_cache_key: {payload.get("fullCacheKey", "")}',
|
||||
'cache_value:',
|
||||
*rendered_value_lines,
|
||||
]
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_cache_ttl_text(payload: dict[str, object]) -> str:
|
||||
"""
|
||||
将缓存 TTL 结果渲染为文本摘要。
|
||||
|
||||
:param payload: 缓存 TTL 结果字典
|
||||
:return: 文本摘要
|
||||
"""
|
||||
return '\n'.join(
|
||||
[
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'cache_name: {payload.get("cacheName", "")}',
|
||||
f'cache_key: {payload.get("cacheKey", "")}',
|
||||
f'full_cache_key: {payload.get("fullCacheKey", "")}',
|
||||
f'message: {payload.get("message", "-")}',
|
||||
f'ttl_seconds: {payload.get("ttlSeconds", "-")}',
|
||||
f'persistent: {str(payload.get("persistent", False)).lower()}',
|
||||
f'expires: {str(payload.get("expires", False)).lower()}',
|
||||
]
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_cache_name_remark(remark: object) -> str:
|
||||
"""
|
||||
规范化缓存名称备注文本。
|
||||
|
||||
:param remark: 原始备注
|
||||
:return: 规范化后的备注
|
||||
"""
|
||||
text = '' if remark is None else str(remark).strip()
|
||||
return text or '-'
|
||||
@@ -0,0 +1,3 @@
|
||||
from .command import app
|
||||
|
||||
__all__ = ['app']
|
||||
@@ -0,0 +1,173 @@
|
||||
from typing import Annotated, Literal
|
||||
|
||||
import typer
|
||||
|
||||
from cli.completion.providers import COMPLETION_PROVIDER_GATEWAY
|
||||
from cli.context import AllowProdOption, DryRunOption, EnvOption, OutputOption, YesOption
|
||||
|
||||
from .controller import ConfigCommandController
|
||||
|
||||
app = typer.Typer(
|
||||
help='参数配置相关命令',
|
||||
no_args_is_help=True,
|
||||
context_settings={'help_option_names': ['-h', '--help']},
|
||||
)
|
||||
_CONFIG_COMMAND_CONTROLLER = ConfigCommandController()
|
||||
|
||||
|
||||
@app.command('list', help='查看参数配置列表')
|
||||
def list_command(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
config_name: Annotated[str, typer.Option('--config-name', help='按参数名称过滤')] = '',
|
||||
config_key: Annotated[
|
||||
str,
|
||||
typer.Option(
|
||||
'--config-key',
|
||||
help='按参数键名过滤',
|
||||
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_config_keys,
|
||||
),
|
||||
] = '',
|
||||
config_type: Annotated[Literal['Y', 'N'] | None, typer.Option('--config-type', help='按系统内置标记过滤')] = None,
|
||||
begin_date: Annotated[str, typer.Option('--begin-date', help='按创建时间开始日期过滤,格式 YYYY-MM-DD')] = '',
|
||||
end_date: Annotated[str, typer.Option('--end-date', help='按创建时间结束日期过滤,格式 YYYY-MM-DD')] = '',
|
||||
paged: Annotated[bool, typer.Option('--paged/--no-paged', help='是否启用分页结果')] = False,
|
||||
page_num: Annotated[int, typer.Option('--page-num', min=1, help='分页页码')] = 1,
|
||||
page_size: Annotated[int, typer.Option('--page-size', min=1, help='分页每页数量')] = 20,
|
||||
) -> None:
|
||||
"""
|
||||
查看参数配置列表。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param config_name: 参数名称过滤条件
|
||||
:param config_key: 参数键名过滤条件
|
||||
:param config_type: 参数类型过滤条件
|
||||
:param begin_date: 查询开始日期
|
||||
:param end_date: 查询结束日期
|
||||
:param paged: 是否启用分页
|
||||
:param page_num: 页码
|
||||
:param page_size: 每页数量
|
||||
:return: None
|
||||
"""
|
||||
_CONFIG_COMMAND_CONTROLLER.list_configs(
|
||||
env,
|
||||
output,
|
||||
config_name=config_name,
|
||||
config_key=config_key,
|
||||
config_type=config_type,
|
||||
begin_date=begin_date,
|
||||
end_date=end_date,
|
||||
paged=paged,
|
||||
page_num=page_num,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@app.command('get', help='查看单个参数配置详情')
|
||||
def get_command(
|
||||
config_key: Annotated[
|
||||
str,
|
||||
typer.Argument(
|
||||
help='需要查询的参数键名',
|
||||
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_config_keys,
|
||||
),
|
||||
],
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
source: Annotated[Literal['db', 'cache', 'both'], typer.Option('--source', help='读取来源')] = 'both',
|
||||
) -> None:
|
||||
"""
|
||||
查看单个参数配置详情。
|
||||
|
||||
:param config_key: 需要查询的参数键名
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param source: 配置读取来源
|
||||
:return: None
|
||||
"""
|
||||
_CONFIG_COMMAND_CONTROLLER.get_config(config_key, env, output, source=source)
|
||||
|
||||
|
||||
@app.command('set', help='新增或更新单个参数配置')
|
||||
def set_command(
|
||||
config_key: Annotated[
|
||||
str,
|
||||
typer.Argument(
|
||||
help='需要写入的参数键名',
|
||||
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_config_keys,
|
||||
),
|
||||
],
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
allow_prod: AllowProdOption = False,
|
||||
yes: YesOption = False,
|
||||
dry_run: DryRunOption = False,
|
||||
value: Annotated[str, typer.Option('--value', help='参数键值')] = ...,
|
||||
name: Annotated[str | None, typer.Option('--name', help='参数名称,新增时必填')] = None,
|
||||
config_type: Annotated[Literal['Y', 'N'] | None, typer.Option('--config-type', help='系统内置标记')] = None,
|
||||
remark: Annotated[str | None, typer.Option('--remark', help='参数备注')] = None,
|
||||
) -> None:
|
||||
"""
|
||||
新增或更新单个参数配置。
|
||||
|
||||
:param config_key: 需要写入的参数键名
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:param dry_run: 是否演练执行
|
||||
:param value: 参数键值
|
||||
:param name: 参数名称
|
||||
:param config_type: 参数类型
|
||||
:param remark: 参数备注
|
||||
:return: None
|
||||
"""
|
||||
_CONFIG_COMMAND_CONTROLLER.set_config(
|
||||
config_key,
|
||||
env,
|
||||
output,
|
||||
allow_prod,
|
||||
yes,
|
||||
dry_run,
|
||||
value=value,
|
||||
name=name,
|
||||
config_type=config_type,
|
||||
remark=remark,
|
||||
)
|
||||
|
||||
|
||||
@app.command('sync-cache', help='刷新参数配置缓存')
|
||||
def sync_cache(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
allow_prod: AllowProdOption = False,
|
||||
yes: YesOption = False,
|
||||
) -> None:
|
||||
"""
|
||||
刷新参数配置缓存。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:return: None
|
||||
"""
|
||||
_CONFIG_COMMAND_CONTROLLER.sync_cache(env, output, allow_prod, yes)
|
||||
|
||||
|
||||
@app.command('doctor', help='诊断参数配置数据库与缓存是否一致')
|
||||
def doctor(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
sample_limit: Annotated[int, typer.Option('--sample-limit', min=1, help='问题示例键名输出上限')] = 10,
|
||||
) -> None:
|
||||
"""
|
||||
诊断参数配置数据库与缓存是否一致。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param sample_limit: 问题示例键名输出上限
|
||||
:return: None
|
||||
"""
|
||||
_CONFIG_COMMAND_CONTROLLER.doctor(env, output, sample_limit=sample_limit)
|
||||
@@ -0,0 +1,225 @@
|
||||
from typing import Literal
|
||||
|
||||
from cli.core import (
|
||||
DEFAULT_CORE_SERVICES,
|
||||
CliContextFactory,
|
||||
CliExecutionService,
|
||||
)
|
||||
from cli.runtime.config import CONFIG_RUNTIME, ConfigRuntimeService
|
||||
|
||||
from .presenter import ConfigCommandPresenter
|
||||
|
||||
|
||||
class ConfigCommandController:
|
||||
"""
|
||||
参数配置命令控制器。
|
||||
|
||||
该控制器负责组织 `config` 命令组的上下文准备、runtime 调用、
|
||||
payload 注入,以及基于输出格式选择 presenter 或直接返回 JSON。
|
||||
|
||||
:param context_factory: CLI 上下文工厂
|
||||
:param execution_service: CLI 执行服务
|
||||
:param presenter: 参数配置命令文本渲染器
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
context_factory: CliContextFactory | None = None,
|
||||
execution_service: CliExecutionService | None = None,
|
||||
presenter: ConfigCommandPresenter | None = None,
|
||||
runtime_service: ConfigRuntimeService | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化参数配置命令控制器。
|
||||
|
||||
:param context_factory: CLI 上下文工厂
|
||||
:param execution_service: CLI 执行服务
|
||||
:param presenter: 参数配置命令文本渲染器
|
||||
:param runtime_service: 参数配置运行时服务
|
||||
:return: None
|
||||
"""
|
||||
self.context_factory = context_factory or DEFAULT_CORE_SERVICES.context_factory
|
||||
self.execution_service = execution_service or DEFAULT_CORE_SERVICES.execution_service
|
||||
self.presenter = presenter or ConfigCommandPresenter()
|
||||
self.runtime_service = runtime_service or CONFIG_RUNTIME
|
||||
|
||||
def list_configs(
|
||||
self,
|
||||
env: str,
|
||||
output: str,
|
||||
*,
|
||||
config_name: str,
|
||||
config_key: str,
|
||||
config_type: Literal['Y', 'N'] | None,
|
||||
begin_date: str,
|
||||
end_date: str,
|
||||
paged: bool,
|
||||
page_num: int,
|
||||
page_size: int,
|
||||
) -> None:
|
||||
"""
|
||||
查看参数配置列表。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param config_name: 参数名称过滤条件
|
||||
:param config_key: 参数键名过滤条件
|
||||
:param config_type: 参数类型过滤条件
|
||||
:param begin_date: 查询开始日期
|
||||
:param end_date: 查询结束日期
|
||||
:param paged: 是否启用分页
|
||||
:param page_num: 页码
|
||||
:param page_size: 每页数量
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
payload = self.execution_service.run_async(
|
||||
self.runtime_service.list_configs(
|
||||
config_name=config_name,
|
||||
config_key=config_key,
|
||||
config_type=config_type,
|
||||
begin_date=begin_date,
|
||||
end_date=end_date,
|
||||
paged=paged,
|
||||
page_num=page_num,
|
||||
page_size=page_size,
|
||||
)
|
||||
)
|
||||
payload['env'] = ctx.env
|
||||
self.execution_service.complete_payload_with_text(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=self.presenter.build_config_list_text,
|
||||
text_condition=lambda data: data.get('ok', False),
|
||||
)
|
||||
|
||||
def get_config(
|
||||
self,
|
||||
config_key: str,
|
||||
env: str,
|
||||
output: str,
|
||||
*,
|
||||
source: Literal['db', 'cache', 'both'],
|
||||
) -> None:
|
||||
"""
|
||||
查看单个参数配置详情。
|
||||
|
||||
:param config_key: 需要查询的参数键名
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param source: 配置读取来源
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
payload = self.execution_service.run_async(self.runtime_service.get_config(config_key, source=source))
|
||||
payload['env'] = ctx.env
|
||||
self.execution_service.complete_payload_with_text(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=self.presenter.build_config_get_text,
|
||||
text_condition=lambda data: data.get('ok', False),
|
||||
)
|
||||
|
||||
def set_config(
|
||||
self,
|
||||
config_key: str,
|
||||
env: str,
|
||||
output: str,
|
||||
allow_prod: bool,
|
||||
yes: bool,
|
||||
dry_run: bool,
|
||||
*,
|
||||
value: str,
|
||||
name: str | None,
|
||||
config_type: Literal['Y', 'N'] | None,
|
||||
remark: str | None,
|
||||
) -> None:
|
||||
"""
|
||||
新增或更新单个参数配置。
|
||||
|
||||
:param config_key: 需要写入的参数键名
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:param dry_run: 是否演练执行
|
||||
:param value: 参数键值
|
||||
:param name: 参数名称
|
||||
:param config_type: 参数类型
|
||||
:param remark: 参数备注
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_dangerous(
|
||||
env,
|
||||
output,
|
||||
allow_prod,
|
||||
yes,
|
||||
dry_run,
|
||||
command_name='config set',
|
||||
)
|
||||
payload = self.execution_service.run_async(
|
||||
self.runtime_service.set_config(
|
||||
config_key,
|
||||
value,
|
||||
config_name=name,
|
||||
config_type=config_type,
|
||||
remark=remark,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
)
|
||||
payload['env'] = ctx.env
|
||||
self.execution_service.complete_payload(ctx, payload)
|
||||
|
||||
def sync_cache(
|
||||
self,
|
||||
env: str,
|
||||
output: str,
|
||||
allow_prod: bool,
|
||||
yes: bool,
|
||||
) -> None:
|
||||
"""
|
||||
刷新参数配置缓存。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_dangerous(
|
||||
env,
|
||||
output,
|
||||
allow_prod,
|
||||
yes,
|
||||
False,
|
||||
command_name='config sync-cache',
|
||||
)
|
||||
payload = self.execution_service.run_async(self.runtime_service.sync_config_cache())
|
||||
payload['env'] = ctx.env
|
||||
self.execution_service.complete_payload(ctx, payload)
|
||||
|
||||
def doctor(
|
||||
self,
|
||||
env: str,
|
||||
output: str,
|
||||
*,
|
||||
sample_limit: int,
|
||||
) -> None:
|
||||
"""
|
||||
诊断参数配置数据库与缓存是否一致。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param sample_limit: 问题示例键名输出上限
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
payload = self.execution_service.run_async(self.runtime_service.diagnose_config(sample_limit=sample_limit))
|
||||
payload['env'] = ctx.env
|
||||
self.execution_service.complete_payload_with_text(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=self.presenter.build_config_doctor_text,
|
||||
text_condition=lambda data: 'error' not in data,
|
||||
)
|
||||
@@ -0,0 +1,180 @@
|
||||
from cli.utils import SHELL_TEXT_FORMATTER
|
||||
|
||||
|
||||
class ConfigCommandPresenter:
|
||||
"""
|
||||
参数配置命令文本渲染器。
|
||||
|
||||
该渲染器负责将 `config` 命令组产生的结构化 payload 转换为稳定的文本摘要,
|
||||
同时保持 JSON 输出仍由控制器直接返回,不在此处做契约变形。
|
||||
"""
|
||||
|
||||
def build_config_list_text(self, payload: dict[str, object]) -> str:
|
||||
"""
|
||||
将参数配置列表结果渲染为文本摘要。
|
||||
|
||||
:param payload: 参数配置列表结果字典
|
||||
:return: 文本摘要
|
||||
"""
|
||||
lines = [
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'env: {payload.get("env", "")}',
|
||||
]
|
||||
|
||||
filters = payload.get('filters')
|
||||
if isinstance(filters, dict):
|
||||
lines.extend(self._build_config_filter_lines(filters))
|
||||
|
||||
page_payload = payload.get('page')
|
||||
if isinstance(page_payload, dict):
|
||||
rows = page_payload.get('rows')
|
||||
lines.append(
|
||||
'page: '
|
||||
f'{page_payload.get("pageNum", "-")}/{page_payload.get("pages", "-")} '
|
||||
f'(page_size={page_payload.get("pageSize", "-")}, total={page_payload.get("total", "-")})'
|
||||
)
|
||||
lines.append(f'count: {len(rows) if isinstance(rows, list) else 0}')
|
||||
if not isinstance(rows, list) or not rows:
|
||||
lines.append('configs: none')
|
||||
return '\n'.join(lines)
|
||||
lines.append('configs:')
|
||||
for row in rows:
|
||||
if isinstance(row, dict):
|
||||
lines.extend([f' {item}' for item in self._build_config_item_lines(row)])
|
||||
return '\n'.join(lines)
|
||||
|
||||
items = payload.get('items')
|
||||
lines.append(f'count: {payload.get("count", 0)}')
|
||||
if not isinstance(items, list) or not items:
|
||||
lines.append('configs: none')
|
||||
return '\n'.join(lines)
|
||||
|
||||
lines.append('configs:')
|
||||
for item in items:
|
||||
if isinstance(item, dict):
|
||||
lines.extend([f' {line}' for line in self._build_config_item_lines(item)])
|
||||
return '\n'.join(lines)
|
||||
|
||||
def build_config_get_text(self, payload: dict[str, object]) -> str:
|
||||
"""
|
||||
将单个参数配置详情结果渲染为文本摘要。
|
||||
|
||||
:param payload: 参数配置详情结果字典
|
||||
:return: 文本摘要
|
||||
"""
|
||||
lines = [
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'env: {payload.get("env", "")}',
|
||||
f'key: {payload.get("key", "")}',
|
||||
f'source: {payload.get("source", "")}',
|
||||
]
|
||||
|
||||
if payload.get('source') == 'both':
|
||||
lines.append(f'in_sync: {str(payload.get("inSync", False)).lower()}')
|
||||
|
||||
if payload.get('source') in {'db', 'both'}:
|
||||
lines.extend(self._build_config_detail_section('database', payload.get('database')))
|
||||
if payload.get('source') in {'cache', 'both'}:
|
||||
lines.extend(self._build_config_detail_section('cache', payload.get('cache')))
|
||||
return '\n'.join(lines)
|
||||
|
||||
def build_config_doctor_text(self, payload: dict[str, object]) -> str:
|
||||
"""
|
||||
将参数配置诊断结果渲染为文本摘要。
|
||||
|
||||
:param payload: 参数配置诊断结果字典
|
||||
:return: 文本摘要
|
||||
"""
|
||||
lines = [
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'env: {payload.get("env", "")}',
|
||||
f'message: {payload.get("message", "-")}',
|
||||
f'database_count: {payload.get("databaseCount", 0)}',
|
||||
f'cache_count: {payload.get("cacheCount", 0)}',
|
||||
f'missing_in_cache_count: {payload.get("missingInCacheCount", 0)}',
|
||||
f'orphan_in_cache_count: {payload.get("orphanInCacheCount", 0)}',
|
||||
f'mismatch_count: {payload.get("mismatchCount", 0)}',
|
||||
f'sample_limit: {payload.get("sampleLimit", 0)}',
|
||||
]
|
||||
lines.extend(self._build_config_doctor_items('missing_in_cache', payload.get('missingInCache')))
|
||||
lines.extend(self._build_config_doctor_items('orphan_in_cache', payload.get('orphanInCache')))
|
||||
lines.extend(self._build_config_doctor_items('mismatch_keys', payload.get('mismatchKeys')))
|
||||
return '\n'.join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _build_config_filter_lines(filters: dict[str, object]) -> list[str]:
|
||||
"""
|
||||
构建参数配置过滤条件文本行。
|
||||
|
||||
:param filters: 过滤条件字典
|
||||
:return: 过滤条件文本行列表
|
||||
"""
|
||||
active_filters = []
|
||||
for key, value in filters.items():
|
||||
if value in (None, '', False):
|
||||
continue
|
||||
active_filters.append(f'{SHELL_TEXT_FORMATTER.to_snake_case(key)}={value}')
|
||||
if not active_filters:
|
||||
return ['filters: none']
|
||||
return ['filters:', *[f' - {item}' for item in active_filters]]
|
||||
|
||||
@staticmethod
|
||||
def _build_config_item_lines(config_item: dict[str, object]) -> list[str]:
|
||||
"""
|
||||
构建单条参数配置记录的文本行。
|
||||
|
||||
:param config_item: 单条参数配置记录
|
||||
:return: 文本行列表
|
||||
"""
|
||||
config_id = config_item.get('configId', '-')
|
||||
config_key = SHELL_TEXT_FORMATTER.truncate_text(config_item.get('configKey', ''), 50)
|
||||
config_name = SHELL_TEXT_FORMATTER.truncate_text(config_item.get('configName', ''), 24)
|
||||
config_type = config_item.get('configType', '-')
|
||||
config_value = SHELL_TEXT_FORMATTER.truncate_text(config_item.get('configValue', ''), 80)
|
||||
remark = SHELL_TEXT_FORMATTER.truncate_text(config_item.get('remark', ''), 60)
|
||||
return [
|
||||
f'- [{config_id}] {config_key} | 名称: {config_name} | 内置: {config_type}',
|
||||
f' value: {config_value or "-"}',
|
||||
f' remark: {remark or "-"}',
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _build_config_detail_section(title: str, config_item: dict[str, object] | None) -> list[str]:
|
||||
"""
|
||||
构建单个配置来源的详情文本段落。
|
||||
|
||||
:param title: 段落标题
|
||||
:param config_item: 配置详情字典
|
||||
:return: 文本行列表
|
||||
"""
|
||||
if not isinstance(config_item, dict):
|
||||
return [f'{title}: none']
|
||||
|
||||
config_key = SHELL_TEXT_FORMATTER.truncate_text(config_item.get('configKey', ''), 60)
|
||||
config_name = SHELL_TEXT_FORMATTER.truncate_text(config_item.get('configName', ''), 30)
|
||||
config_value = SHELL_TEXT_FORMATTER.truncate_text(config_item.get('configValue', ''), 120)
|
||||
config_type = config_item.get('configType', '-')
|
||||
remark = SHELL_TEXT_FORMATTER.truncate_text(config_item.get('remark', ''), 80)
|
||||
config_id = config_item.get('configId', '-')
|
||||
return [
|
||||
f'{title}:',
|
||||
f' id: {config_id}',
|
||||
f' key: {config_key or "-"}',
|
||||
f' name: {config_name or "-"}',
|
||||
f' value: {config_value or "-"}',
|
||||
f' type: {config_type}',
|
||||
f' remark: {remark or "-"}',
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _build_config_doctor_items(title: str, items: list[object] | object) -> list[str]:
|
||||
"""
|
||||
构建配置诊断问题示例段落。
|
||||
|
||||
:param title: 段落标题
|
||||
:param items: 问题键名列表
|
||||
:return: 文本行列表
|
||||
"""
|
||||
if not isinstance(items, list) or not items:
|
||||
return [f'{title}: none']
|
||||
return [f'{title}:', *[f' - {item}' for item in items]]
|
||||
@@ -0,0 +1,3 @@
|
||||
from .command import app
|
||||
|
||||
__all__ = ['app']
|
||||
@@ -0,0 +1,94 @@
|
||||
import typer
|
||||
|
||||
from cli.context import AllowProdOption, DryRunOption, EnvOption, OutputOption, YesOption
|
||||
|
||||
from .controller import CryptoCommandController
|
||||
|
||||
app = typer.Typer(
|
||||
help='传输加密相关命令',
|
||||
no_args_is_help=True,
|
||||
context_settings={'help_option_names': ['-h', '--help']},
|
||||
)
|
||||
_CRYPTO_COMMAND_CONTROLLER = CryptoCommandController()
|
||||
|
||||
|
||||
@app.command('validate', help='校验传输加密配置')
|
||||
def validate(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
) -> None:
|
||||
"""
|
||||
校验传输加密配置。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
_CRYPTO_COMMAND_CONTROLLER.validate(env, output)
|
||||
|
||||
|
||||
@app.command('keygen', help='生成新的传输加密密钥对')
|
||||
def keygen(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
kid: str = typer.Option('default', '--kid', help='生成的密钥版本标识'),
|
||||
key_size: int = typer.Option(2048, '--key-size', help='RSA 密钥长度'),
|
||||
) -> None:
|
||||
"""
|
||||
生成新的传输加密密钥对。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param kid: 生成的密钥版本标识
|
||||
:param key_size: RSA 密钥长度
|
||||
:return: None
|
||||
"""
|
||||
_CRYPTO_COMMAND_CONTROLLER.keygen(env, output, kid=kid, key_size=key_size)
|
||||
|
||||
|
||||
@app.command('rotate', help='生成传输加密密钥轮换辅助结果')
|
||||
def rotate(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
allow_prod: AllowProdOption = False,
|
||||
yes: YesOption = False,
|
||||
dry_run: DryRunOption = False,
|
||||
next_kid: str = typer.Option('rotated', '--next-kid', help='轮换后的新密钥版本标识'),
|
||||
key_size: int = typer.Option(2048, '--key-size', help='新密钥的 RSA 长度'),
|
||||
) -> None:
|
||||
"""
|
||||
生成传输加密密钥轮换辅助结果。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:param dry_run: 是否演练执行
|
||||
:param next_kid: 轮换后的新密钥版本标识
|
||||
:param key_size: 新密钥的 RSA 长度
|
||||
:return: None
|
||||
"""
|
||||
_CRYPTO_COMMAND_CONTROLLER.rotate(
|
||||
env,
|
||||
output,
|
||||
allow_prod,
|
||||
yes,
|
||||
dry_run,
|
||||
next_kid=next_kid,
|
||||
key_size=key_size,
|
||||
)
|
||||
|
||||
|
||||
@app.command('export-public', help='查看当前运行环境的公钥信息')
|
||||
def export_public(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
) -> None:
|
||||
"""
|
||||
导出当前运行环境的公钥信息。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
_CRYPTO_COMMAND_CONTROLLER.export_public(env, output)
|
||||
@@ -0,0 +1,125 @@
|
||||
from cli.core import (
|
||||
DEFAULT_CORE_SERVICES,
|
||||
CliContextFactory,
|
||||
CliExecutionService,
|
||||
)
|
||||
from cli.runtime.crypto import CRYPTO_RUNTIME, CryptoRuntimeService
|
||||
|
||||
from .presenter import CryptoCommandPresenter
|
||||
|
||||
|
||||
class CryptoCommandController:
|
||||
"""
|
||||
传输加密命令控制器。
|
||||
|
||||
该控制器负责组织 `crypto` 命令组的上下文准备、runtime 调用、
|
||||
payload 注入,以及基于输出格式选择 presenter 或直接返回 JSON。
|
||||
|
||||
:param context_factory: CLI 上下文工厂
|
||||
:param execution_service: CLI 执行服务
|
||||
:param presenter: 传输加密命令文本渲染器
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
context_factory: CliContextFactory | None = None,
|
||||
execution_service: CliExecutionService | None = None,
|
||||
presenter: CryptoCommandPresenter | None = None,
|
||||
runtime_service: CryptoRuntimeService | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化传输加密命令控制器。
|
||||
|
||||
:param context_factory: CLI 上下文工厂
|
||||
:param execution_service: CLI 执行服务
|
||||
:param presenter: 传输加密命令文本渲染器
|
||||
:param runtime_service: 传输加密运行时服务
|
||||
:return: None
|
||||
"""
|
||||
self.context_factory = context_factory or DEFAULT_CORE_SERVICES.context_factory
|
||||
self.execution_service = execution_service or DEFAULT_CORE_SERVICES.execution_service
|
||||
self.presenter = presenter or CryptoCommandPresenter()
|
||||
self.runtime_service = runtime_service or CRYPTO_RUNTIME
|
||||
|
||||
def validate(self, env: str, output: str) -> None:
|
||||
"""
|
||||
校验传输加密配置。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
self.execution_service.complete_payload(ctx, self.runtime_service.validate_crypto_config())
|
||||
|
||||
def keygen(self, env: str, output: str, *, kid: str, key_size: int) -> None:
|
||||
"""
|
||||
生成新的传输加密密钥对。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param kid: 生成的密钥版本标识
|
||||
:param key_size: RSA 密钥长度
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
payload = self.runtime_service.generate_crypto_key_pair(kid, key_size)
|
||||
payload['env'] = ctx.env
|
||||
self.execution_service.complete_payload_with_text(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=self.presenter.build_crypto_keygen_text,
|
||||
text_condition=lambda data: data.get('ok', False),
|
||||
)
|
||||
|
||||
def rotate(
|
||||
self,
|
||||
env: str,
|
||||
output: str,
|
||||
allow_prod: bool,
|
||||
yes: bool,
|
||||
dry_run: bool,
|
||||
*,
|
||||
next_kid: str,
|
||||
key_size: int,
|
||||
) -> None:
|
||||
"""
|
||||
生成传输加密密钥轮换辅助结果。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:param dry_run: 是否演练执行
|
||||
:param next_kid: 轮换后的新密钥版本标识
|
||||
:param key_size: 新密钥的 RSA 长度
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_dangerous(
|
||||
env,
|
||||
output,
|
||||
allow_prod,
|
||||
yes,
|
||||
dry_run,
|
||||
command_name='crypto rotate',
|
||||
)
|
||||
self.execution_service.complete_payload(ctx, self.runtime_service.build_rotation_payload(next_kid, key_size))
|
||||
|
||||
def export_public(self, env: str, output: str) -> None:
|
||||
"""
|
||||
导出当前运行环境的公钥信息。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
payload = self.runtime_service.export_public_key()
|
||||
payload['env'] = ctx.env
|
||||
self.execution_service.complete_payload_with_text(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=self.presenter.build_export_public_text,
|
||||
text_condition=lambda data: data.get('ok', False),
|
||||
)
|
||||
@@ -0,0 +1,79 @@
|
||||
class CryptoCommandPresenter:
|
||||
"""
|
||||
传输加密命令文本渲染器。
|
||||
|
||||
该渲染器负责将 `crypto` 命令组产生的结构化 payload 转换为稳定的文本摘要,
|
||||
同时保持 JSON 输出仍由控制器直接返回,不在此处做契约变形。
|
||||
"""
|
||||
|
||||
def build_crypto_keygen_text(self, payload: dict[str, object]) -> str:
|
||||
"""
|
||||
将密钥生成结果渲染为文本摘要。
|
||||
|
||||
:param payload: 密钥生成结果字典
|
||||
:return: 文本摘要
|
||||
"""
|
||||
env_patch = payload.get('envPatch')
|
||||
env_patch_keys = sorted(env_patch.keys()) if isinstance(env_patch, dict) else []
|
||||
lines = [
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'env: {payload.get("env", "")}',
|
||||
f'kid: {payload.get("kid", "-")}',
|
||||
f'key_size: {payload.get("keySize", "-")}',
|
||||
]
|
||||
lines.extend(self._build_multiline_block('public_key', payload.get('publicKey')))
|
||||
lines.extend(self._build_multiline_block('private_key', payload.get('privateKey')))
|
||||
if env_patch_keys:
|
||||
lines.append('env_patch_keys:')
|
||||
lines.extend(f' - {key}' for key in env_patch_keys)
|
||||
else:
|
||||
lines.append('env_patch_keys: none')
|
||||
return '\n'.join(lines)
|
||||
|
||||
def build_export_public_text(self, payload: dict[str, object]) -> str:
|
||||
"""
|
||||
将公钥导出结果渲染为文本摘要。
|
||||
|
||||
:param payload: 公钥导出结果字典
|
||||
:return: 文本摘要
|
||||
"""
|
||||
public_key_payload = payload.get('publicKey')
|
||||
if not isinstance(public_key_payload, dict):
|
||||
return '\n'.join(
|
||||
[
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'env: {payload.get("env", "")}',
|
||||
'public_key: none',
|
||||
]
|
||||
)
|
||||
|
||||
supported_kids = public_key_payload.get('supportedKids')
|
||||
lines = [
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'env: {payload.get("env", "")}',
|
||||
f'kid: {public_key_payload.get("kid", "-")}',
|
||||
f'alg: {public_key_payload.get("alg", "-")}',
|
||||
f'envelope_version: {public_key_payload.get("envelopeVersion", "-")}',
|
||||
f'expire_at: {public_key_payload.get("expireAt", "-")}',
|
||||
]
|
||||
if isinstance(supported_kids, list) and supported_kids:
|
||||
lines.append('supported_kids:')
|
||||
lines.extend(f' - {kid}' for kid in supported_kids)
|
||||
else:
|
||||
lines.append('supported_kids: none')
|
||||
lines.extend(self._build_multiline_block('public_key_pem', public_key_payload.get('publicKey')))
|
||||
return '\n'.join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _build_multiline_block(title: str, value: object) -> list[str]:
|
||||
"""
|
||||
将多行文本构建为带标题的块级文本。
|
||||
|
||||
:param title: 块标题
|
||||
:param value: 原始文本值
|
||||
:return: 文本行列表
|
||||
"""
|
||||
text = '' if value is None else str(value).strip()
|
||||
if not text:
|
||||
return [f'{title}: -']
|
||||
return [f'{title}:', ' |', *[f' {line}' for line in text.splitlines()]]
|
||||
@@ -0,0 +1,3 @@
|
||||
from .command import app
|
||||
|
||||
__all__ = ['app']
|
||||
@@ -0,0 +1,191 @@
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
|
||||
from cli.completion.providers import COMPLETION_PROVIDER_GATEWAY
|
||||
from cli.context import AllowProdOption, DryRunOption, EnvOption, OutputOption, YesOption
|
||||
|
||||
from .controller import DbCommandController
|
||||
|
||||
app = typer.Typer(
|
||||
help='数据库相关命令',
|
||||
no_args_is_help=True,
|
||||
context_settings={'help_option_names': ['-h', '--help']},
|
||||
)
|
||||
_DB_COMMAND_CONTROLLER = DbCommandController()
|
||||
|
||||
|
||||
@app.command('check', help='检查数据库连接状态')
|
||||
def check(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
) -> None:
|
||||
"""
|
||||
检查数据库连接状态。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
_DB_COMMAND_CONTROLLER.check(env, output)
|
||||
|
||||
|
||||
@app.command('current', help='查看当前数据库迁移版本')
|
||||
def current(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
) -> None:
|
||||
"""
|
||||
查看数据库当前迁移版本。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
_DB_COMMAND_CONTROLLER.current(env, output)
|
||||
|
||||
|
||||
@app.command('upgrade', help='执行数据库升级')
|
||||
def upgrade(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
allow_prod: AllowProdOption = False,
|
||||
yes: YesOption = False,
|
||||
dry_run: DryRunOption = False,
|
||||
revision: Annotated[
|
||||
str,
|
||||
typer.Option(
|
||||
'--revision',
|
||||
help='目标迁移版本',
|
||||
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_alembic_revisions,
|
||||
),
|
||||
] = 'head',
|
||||
) -> None:
|
||||
"""
|
||||
执行数据库升级。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:param dry_run: 是否演练执行
|
||||
:param revision: 目标迁移版本
|
||||
:return: None
|
||||
"""
|
||||
_DB_COMMAND_CONTROLLER.upgrade(env, output, allow_prod, yes, dry_run, revision=revision)
|
||||
|
||||
|
||||
@app.command('init', help='初始化数据库到最新迁移版本')
|
||||
def init(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
allow_prod: AllowProdOption = False,
|
||||
yes: YesOption = False,
|
||||
dry_run: DryRunOption = False,
|
||||
) -> None:
|
||||
"""
|
||||
初始化数据库到最新迁移版本。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:param dry_run: 是否演练执行
|
||||
:return: None
|
||||
"""
|
||||
_DB_COMMAND_CONTROLLER.init(env, output, allow_prod, yes, dry_run)
|
||||
|
||||
|
||||
@app.command('downgrade', help='执行数据库回退')
|
||||
def downgrade(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
allow_prod: AllowProdOption = False,
|
||||
yes: YesOption = False,
|
||||
dry_run: DryRunOption = False,
|
||||
revision: Annotated[
|
||||
str,
|
||||
typer.Option(
|
||||
'--revision',
|
||||
help='目标回退版本',
|
||||
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_alembic_revisions,
|
||||
),
|
||||
] = '-1',
|
||||
) -> None:
|
||||
"""
|
||||
执行数据库回退。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:param dry_run: 是否演练执行
|
||||
:param revision: 目标回退版本
|
||||
:return: None
|
||||
"""
|
||||
_DB_COMMAND_CONTROLLER.downgrade(env, output, allow_prod, yes, dry_run, revision=revision)
|
||||
|
||||
|
||||
@app.command('revision', help='创建新的数据库迁移版本文件')
|
||||
def revision(
|
||||
message: Annotated[str, typer.Option('--message', '-m', help='迁移说明')],
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
allow_prod: AllowProdOption = False,
|
||||
yes: YesOption = False,
|
||||
dry_run: DryRunOption = False,
|
||||
autogenerate: bool = typer.Option(False, '--autogenerate/--no-autogenerate', help='是否自动生成迁移内容'),
|
||||
) -> None:
|
||||
"""
|
||||
创建新的数据库迁移版本文件。
|
||||
|
||||
:param message: 迁移说明
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:param dry_run: 是否演练执行
|
||||
:param autogenerate: 是否自动生成迁移内容
|
||||
:return: None
|
||||
"""
|
||||
_DB_COMMAND_CONTROLLER.revision(
|
||||
message,
|
||||
env,
|
||||
output,
|
||||
allow_prod,
|
||||
yes,
|
||||
dry_run,
|
||||
autogenerate=autogenerate,
|
||||
)
|
||||
|
||||
|
||||
@app.command('heads', help='查看当前代码仓库中的 Alembic heads')
|
||||
def heads(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
) -> None:
|
||||
"""
|
||||
查看当前代码仓库中的 Alembic heads。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
_DB_COMMAND_CONTROLLER.heads(env, output)
|
||||
|
||||
|
||||
@app.command('history', help='查看当前代码仓库中的 Alembic 历史版本')
|
||||
def history(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
limit: Annotated[int, typer.Option('--limit', min=1, help='输出的最大历史记录数量')] = 20,
|
||||
) -> None:
|
||||
"""
|
||||
查看当前代码仓库中的 Alembic 历史版本。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param limit: 输出的最大历史记录数量
|
||||
:return: None
|
||||
"""
|
||||
_DB_COMMAND_CONTROLLER.history(env, output, limit=limit)
|
||||
@@ -0,0 +1,237 @@
|
||||
from cli.core import (
|
||||
DEFAULT_CORE_SERVICES,
|
||||
CliContextFactory,
|
||||
CliExecutionService,
|
||||
)
|
||||
from cli.runtime.db import DATABASE_RUNTIME, DatabaseRuntimeService
|
||||
|
||||
from .presenter import DbCommandPresenter
|
||||
|
||||
|
||||
class DbCommandController:
|
||||
"""
|
||||
数据库命令控制器。
|
||||
|
||||
该控制器负责组织 `db` 命令组的上下文准备、runtime 调用、
|
||||
payload 注入,以及基于输出格式选择 presenter 或直接返回 JSON。
|
||||
|
||||
:param context_factory: CLI 上下文工厂
|
||||
:param execution_service: CLI 执行服务
|
||||
:param presenter: 数据库命令文本渲染器
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
context_factory: CliContextFactory | None = None,
|
||||
execution_service: CliExecutionService | None = None,
|
||||
presenter: DbCommandPresenter | None = None,
|
||||
runtime_service: DatabaseRuntimeService | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化数据库命令控制器。
|
||||
|
||||
:param context_factory: CLI 上下文工厂
|
||||
:param execution_service: CLI 执行服务
|
||||
:param presenter: 数据库命令文本渲染器
|
||||
:param runtime_service: 数据库运行时服务
|
||||
:return: None
|
||||
"""
|
||||
self.context_factory = context_factory or DEFAULT_CORE_SERVICES.context_factory
|
||||
self.execution_service = execution_service or DEFAULT_CORE_SERVICES.execution_service
|
||||
self.presenter = presenter or DbCommandPresenter()
|
||||
self.runtime_service = runtime_service or DATABASE_RUNTIME
|
||||
|
||||
def check(self, env: str, output: str) -> None:
|
||||
"""
|
||||
检查数据库连接状态。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
self.execution_service.complete_payload(
|
||||
ctx, self.execution_service.run_async(self.runtime_service.ping_database())
|
||||
)
|
||||
|
||||
def current(self, env: str, output: str) -> None:
|
||||
"""
|
||||
查看数据库当前迁移版本。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
payload = self.runtime_service.get_current_revision()
|
||||
payload['env'] = ctx.env
|
||||
self.execution_service.complete_payload_with_text(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=self.presenter.build_current_revision_text,
|
||||
text_condition=lambda result_data: bool(result_data.get('ok', False)),
|
||||
)
|
||||
|
||||
def upgrade(
|
||||
self,
|
||||
env: str,
|
||||
output: str,
|
||||
allow_prod: bool,
|
||||
yes: bool,
|
||||
dry_run: bool,
|
||||
*,
|
||||
revision: str,
|
||||
) -> None:
|
||||
"""
|
||||
执行数据库升级。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:param dry_run: 是否演练执行
|
||||
:param revision: 目标迁移版本
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_dangerous(
|
||||
env,
|
||||
output,
|
||||
allow_prod,
|
||||
yes,
|
||||
dry_run,
|
||||
command_name='db upgrade',
|
||||
)
|
||||
self.execution_service.complete_payload(ctx, self.runtime_service.upgrade_database(revision, dry_run=dry_run))
|
||||
|
||||
def init(
|
||||
self,
|
||||
env: str,
|
||||
output: str,
|
||||
allow_prod: bool,
|
||||
yes: bool,
|
||||
dry_run: bool,
|
||||
) -> None:
|
||||
"""
|
||||
初始化数据库到最新迁移版本。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:param dry_run: 是否演练执行
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_dangerous(
|
||||
env,
|
||||
output,
|
||||
allow_prod,
|
||||
yes,
|
||||
dry_run,
|
||||
command_name='db init',
|
||||
)
|
||||
self.execution_service.complete_payload(ctx, self.runtime_service.init_database(dry_run=dry_run))
|
||||
|
||||
def downgrade(
|
||||
self,
|
||||
env: str,
|
||||
output: str,
|
||||
allow_prod: bool,
|
||||
yes: bool,
|
||||
dry_run: bool,
|
||||
*,
|
||||
revision: str,
|
||||
) -> None:
|
||||
"""
|
||||
执行数据库回退。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:param dry_run: 是否演练执行
|
||||
:param revision: 目标回退版本
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_dangerous(
|
||||
env,
|
||||
output,
|
||||
allow_prod,
|
||||
yes,
|
||||
dry_run,
|
||||
command_name='db downgrade',
|
||||
)
|
||||
self.execution_service.complete_payload(ctx, self.runtime_service.downgrade_database(revision, dry_run=dry_run))
|
||||
|
||||
def revision(
|
||||
self,
|
||||
message: str,
|
||||
env: str,
|
||||
output: str,
|
||||
allow_prod: bool,
|
||||
yes: bool,
|
||||
dry_run: bool,
|
||||
*,
|
||||
autogenerate: bool,
|
||||
) -> None:
|
||||
"""
|
||||
创建新的数据库迁移版本文件。
|
||||
|
||||
:param message: 迁移说明
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:param dry_run: 是否演练执行
|
||||
:param autogenerate: 是否自动生成迁移内容
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_dangerous(
|
||||
env,
|
||||
output,
|
||||
allow_prod,
|
||||
yes,
|
||||
dry_run,
|
||||
command_name='db revision',
|
||||
)
|
||||
self.execution_service.complete_payload(
|
||||
ctx,
|
||||
self.runtime_service.create_revision(message, autogenerate=autogenerate, dry_run=dry_run),
|
||||
)
|
||||
|
||||
def heads(self, env: str, output: str) -> None:
|
||||
"""
|
||||
查看当前代码仓库中的 Alembic heads。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
payload = self.runtime_service.get_alembic_heads()
|
||||
payload['env'] = ctx.env
|
||||
self.execution_service.complete_payload_with_text(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=self.presenter.build_alembic_revisions_text,
|
||||
text_condition=lambda result_data: 'error' not in result_data,
|
||||
)
|
||||
|
||||
def history(self, env: str, output: str, *, limit: int) -> None:
|
||||
"""
|
||||
查看当前代码仓库中的 Alembic 历史版本。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param limit: 输出的最大历史记录数量
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
payload = self.runtime_service.get_alembic_history(limit=limit)
|
||||
payload['env'] = ctx.env
|
||||
self.execution_service.complete_payload_with_text(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=self.presenter.build_alembic_revisions_text,
|
||||
text_condition=lambda result_data: 'error' not in result_data,
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
class DbCommandPresenter:
|
||||
"""
|
||||
数据库命令文本渲染器。
|
||||
|
||||
该渲染器负责将 `db` 命令组产生的结构化 payload 转换为稳定的文本摘要,
|
||||
同时保持 JSON 输出仍由控制器直接返回,不在此处做契约变形。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_current_revision_text(payload: dict[str, object]) -> str:
|
||||
"""
|
||||
将数据库当前迁移版本结果渲染为文本摘要。
|
||||
|
||||
:param payload: 当前迁移版本结果字典
|
||||
:return: 文本摘要
|
||||
"""
|
||||
return '\n'.join(
|
||||
[
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'env: {payload.get("env", "")}',
|
||||
f'current_revision: {payload.get("currentRevision", "-")}',
|
||||
]
|
||||
)
|
||||
|
||||
def build_alembic_revisions_text(self, payload: dict[str, object]) -> str:
|
||||
"""
|
||||
将 Alembic 修订版本结果渲染为文本摘要。
|
||||
|
||||
:param payload: 修订版本结果字典
|
||||
:return: 文本摘要
|
||||
"""
|
||||
items = payload.get('items')
|
||||
lines = [
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'env: {payload.get("env", "")}',
|
||||
f'message: {payload.get("message", "-")}',
|
||||
f'count: {payload.get("count", 0)}',
|
||||
]
|
||||
if 'totalCount' in payload:
|
||||
lines.append(f'total_count: {payload.get("totalCount", 0)}')
|
||||
if 'limit' in payload:
|
||||
lines.append(f'limit: {payload.get("limit", 0)}')
|
||||
if not isinstance(items, list) or not items:
|
||||
lines.append('items: none')
|
||||
return '\n'.join(lines)
|
||||
|
||||
lines.append('items:')
|
||||
for item in items:
|
||||
if isinstance(item, dict):
|
||||
lines.extend([f' {line}' for line in self._build_alembic_revision_item_lines(item)])
|
||||
return '\n'.join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _build_alembic_revision_item_lines(revision_item: dict[str, object]) -> list[str]:
|
||||
"""
|
||||
构建单个 Alembic 修订版本的文本行。
|
||||
|
||||
:param revision_item: 修订版本结果字典
|
||||
:return: 文本行列表
|
||||
"""
|
||||
down_revisions = revision_item.get('downRevisions')
|
||||
branch_labels = revision_item.get('branchLabels')
|
||||
depends_on = revision_item.get('dependsOn')
|
||||
return [
|
||||
f'- revision: {revision_item.get("revision", "-")}',
|
||||
f' down_revisions: {",".join(down_revisions) if isinstance(down_revisions, list) and down_revisions else "-"}',
|
||||
f' branch_labels: {",".join(branch_labels) if isinstance(branch_labels, list) and branch_labels else "-"}',
|
||||
f' depends_on: {",".join(depends_on) if isinstance(depends_on, list) and depends_on else "-"}',
|
||||
f' doc: {revision_item.get("doc", "") or "-"}',
|
||||
f' path: {revision_item.get("path", "-")}',
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
from .command import app
|
||||
|
||||
__all__ = ['app']
|
||||
@@ -0,0 +1,74 @@
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
|
||||
from cli.context import EnvOption, OutputOption
|
||||
|
||||
from .controller import DevCommandController
|
||||
|
||||
app = typer.Typer(
|
||||
help='开发相关命令',
|
||||
no_args_is_help=True,
|
||||
context_settings={'help_option_names': ['-h', '--help']},
|
||||
)
|
||||
_DEV_COMMAND_CONTROLLER = DevCommandController()
|
||||
|
||||
|
||||
@app.command('lint', help='执行 Ruff 格式化与静态检查')
|
||||
def lint(
|
||||
targets: Annotated[list[str] | None, typer.Argument(help='待检查路径,可传多个')] = None,
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
check_only: bool = typer.Option(False, '--check-only', help='仅检查,不写回格式化结果'),
|
||||
fix: bool = typer.Option(False, '--fix', help='执行 ruff check --fix'),
|
||||
unsafe_fixes: bool = typer.Option(False, '--unsafe-fixes', help='允许 Ruff 不安全修复'),
|
||||
) -> None:
|
||||
"""
|
||||
执行 Ruff 格式化与静态检查。
|
||||
|
||||
:param targets: 待检查路径列表
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param check_only: 是否仅检查不写回
|
||||
:param fix: 是否自动修复可修复问题
|
||||
:param unsafe_fixes: 是否允许不安全修复
|
||||
:return: None
|
||||
"""
|
||||
_DEV_COMMAND_CONTROLLER.lint(
|
||||
targets,
|
||||
env,
|
||||
output,
|
||||
check_only=check_only,
|
||||
fix=fix,
|
||||
unsafe_fixes=unsafe_fixes,
|
||||
)
|
||||
|
||||
|
||||
@app.command('test', help='执行项目测试')
|
||||
def test(
|
||||
targets: Annotated[list[str] | None, typer.Argument(help='待执行的测试路径,可传多个')] = None,
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
keyword: str = typer.Option('', '--keyword', '-k', help='pytest -k 过滤表达式'),
|
||||
maxfail: int = typer.Option(0, '--maxfail', min=0, help='最大失败数,0 表示不限制'),
|
||||
quiet: bool = typer.Option(False, '--quiet', '-q', help='启用简洁输出'),
|
||||
) -> None:
|
||||
"""
|
||||
执行项目测试。
|
||||
|
||||
:param targets: 测试目标路径列表
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param keyword: pytest 关键字过滤表达式
|
||||
:param maxfail: 最大失败数
|
||||
:param quiet: 是否启用简洁输出
|
||||
:return: None
|
||||
"""
|
||||
_DEV_COMMAND_CONTROLLER.test(
|
||||
targets,
|
||||
env,
|
||||
output,
|
||||
keyword=keyword,
|
||||
maxfail=maxfail,
|
||||
quiet=quiet,
|
||||
)
|
||||
@@ -0,0 +1,103 @@
|
||||
from cli.core import (
|
||||
DEFAULT_CORE_SERVICES,
|
||||
CliContextFactory,
|
||||
CliExecutionService,
|
||||
)
|
||||
from cli.runtime.dev import DEVELOPMENT_RUNTIME, DevelopmentRuntimeService
|
||||
|
||||
from .presenter import DevCommandPresenter
|
||||
|
||||
|
||||
class DevCommandController:
|
||||
"""
|
||||
开发命令控制器。
|
||||
|
||||
该控制器负责组织 `dev` 命令组的上下文准备、runtime 调用、
|
||||
payload 注入,以及基于输出格式选择 presenter 或直接返回 JSON。
|
||||
|
||||
:param context_factory: CLI 上下文工厂
|
||||
:param execution_service: CLI 执行服务
|
||||
:param presenter: 开发命令文本渲染器
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
context_factory: CliContextFactory | None = None,
|
||||
execution_service: CliExecutionService | None = None,
|
||||
presenter: DevCommandPresenter | None = None,
|
||||
runtime_service: DevelopmentRuntimeService | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化开发命令控制器。
|
||||
|
||||
:param context_factory: CLI 上下文工厂
|
||||
:param execution_service: CLI 执行服务
|
||||
:param presenter: 开发命令文本渲染器
|
||||
:param runtime_service: 开发运行时服务
|
||||
:return: None
|
||||
"""
|
||||
self.context_factory = context_factory or DEFAULT_CORE_SERVICES.context_factory
|
||||
self.execution_service = execution_service or DEFAULT_CORE_SERVICES.execution_service
|
||||
self.presenter = presenter or DevCommandPresenter()
|
||||
self.runtime_service = runtime_service or DEVELOPMENT_RUNTIME
|
||||
|
||||
def lint(
|
||||
self,
|
||||
targets: list[str] | None,
|
||||
env: str,
|
||||
output: str,
|
||||
*,
|
||||
check_only: bool,
|
||||
fix: bool,
|
||||
unsafe_fixes: bool,
|
||||
) -> None:
|
||||
"""
|
||||
执行 Ruff 格式化与静态检查。
|
||||
|
||||
:param targets: 待检查路径列表
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param check_only: 是否仅检查不写回
|
||||
:param fix: 是否自动修复可修复问题
|
||||
:param unsafe_fixes: 是否允许不安全修复
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
payload = self.runtime_service.run_lint(targets, check_only=check_only, fix=fix, unsafe_fixes=unsafe_fixes)
|
||||
payload['env'] = ctx.env
|
||||
self.execution_service.complete_payload_with_text(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=self.presenter.build_dev_lint_text,
|
||||
)
|
||||
|
||||
def test(
|
||||
self,
|
||||
targets: list[str] | None,
|
||||
env: str,
|
||||
output: str,
|
||||
*,
|
||||
keyword: str,
|
||||
maxfail: int,
|
||||
quiet: bool,
|
||||
) -> None:
|
||||
"""
|
||||
执行项目测试。
|
||||
|
||||
:param targets: 测试目标路径列表
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param keyword: pytest 关键字过滤表达式
|
||||
:param maxfail: 最大失败数
|
||||
:param quiet: 是否启用简洁输出
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
payload = self.runtime_service.run_tests(targets, keyword=keyword, maxfail=maxfail, quiet=quiet)
|
||||
payload['env'] = ctx.env
|
||||
self.execution_service.complete_payload_with_text(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=self.presenter.build_dev_test_text,
|
||||
)
|
||||
@@ -0,0 +1,96 @@
|
||||
class DevCommandPresenter:
|
||||
"""
|
||||
开发命令文本渲染器。
|
||||
|
||||
该渲染器负责将 `dev` 命令组产生的结构化 payload 转换为稳定的文本摘要,
|
||||
同时保持 JSON 输出仍由控制器直接返回,不在此处做契约变形。
|
||||
"""
|
||||
|
||||
def build_dev_lint_text(self, payload: dict[str, object]) -> str:
|
||||
"""
|
||||
将 lint 执行结果渲染为文本摘要。
|
||||
|
||||
:param payload: lint 执行结果字典
|
||||
:return: 文本摘要
|
||||
"""
|
||||
targets = payload.get('targets')
|
||||
lines = [
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'env: {payload.get("env", "")}',
|
||||
f'check_only: {str(payload.get("checkOnly", False)).lower()}',
|
||||
f'fix: {str(payload.get("fix", False)).lower()}',
|
||||
f'unsafe_fixes: {str(payload.get("unsafeFixes", False)).lower()}',
|
||||
]
|
||||
if isinstance(targets, list) and targets:
|
||||
lines.append('targets:')
|
||||
lines.extend(f' - {target}' for target in targets)
|
||||
else:
|
||||
lines.append('targets: none')
|
||||
lines.extend(self._build_command_result_section('format', payload.get('format')))
|
||||
lines.extend(self._build_command_result_section('check', payload.get('check')))
|
||||
return '\n'.join(lines)
|
||||
|
||||
def build_dev_test_text(self, payload: dict[str, object]) -> str:
|
||||
"""
|
||||
将测试执行结果渲染为文本摘要。
|
||||
|
||||
:param payload: 测试执行结果字典
|
||||
:return: 文本摘要
|
||||
"""
|
||||
targets = payload.get('targets')
|
||||
lines = [
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'env: {payload.get("env", "")}',
|
||||
f'keyword: {payload.get("keyword", "") or "-"}',
|
||||
f'maxfail: {payload.get("maxfail", 0)}',
|
||||
f'quiet: {str(payload.get("quiet", False)).lower()}',
|
||||
]
|
||||
if isinstance(targets, list) and targets:
|
||||
lines.append('targets:')
|
||||
lines.extend(f' - {target}' for target in targets)
|
||||
else:
|
||||
lines.append('targets: none')
|
||||
lines.extend(self._build_command_result_section('test', payload.get('test')))
|
||||
return '\n'.join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _build_command_result_section(title: str, payload: dict[str, object] | None) -> list[str]:
|
||||
"""
|
||||
构建单个子命令执行结果段落。
|
||||
|
||||
:param title: 段落标题
|
||||
:param payload: 子命令执行结果
|
||||
:return: 文本行列表
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
return [f'{title}: none']
|
||||
|
||||
lines = [
|
||||
f'{title}:',
|
||||
f' ok: {str(payload.get("ok", False)).lower()}',
|
||||
f' return_code: {payload.get("returnCode", "-")}',
|
||||
f' command: {DevCommandPresenter._format_command_text(payload.get("command"))}',
|
||||
]
|
||||
stdout = payload.get('stdout')
|
||||
stderr = payload.get('stderr')
|
||||
if stdout:
|
||||
lines.append(' stdout:')
|
||||
lines.append(' |')
|
||||
lines.extend(f' {line}' for line in str(stdout).splitlines())
|
||||
if stderr:
|
||||
lines.append(' stderr:')
|
||||
lines.append(' |')
|
||||
lines.extend(f' {line}' for line in str(stderr).splitlines())
|
||||
return lines
|
||||
|
||||
@staticmethod
|
||||
def _format_command_text(command: object) -> str:
|
||||
"""
|
||||
将命令参数列表格式化为可读文本。
|
||||
|
||||
:param command: 命令参数列表
|
||||
:return: 格式化后的命令文本
|
||||
"""
|
||||
if not isinstance(command, list):
|
||||
return '-'
|
||||
return ' '.join(str(item) for item in command) or '-'
|
||||
@@ -0,0 +1,3 @@
|
||||
from .command import app
|
||||
|
||||
__all__ = ['app']
|
||||
@@ -0,0 +1,273 @@
|
||||
from typing import Annotated, Literal
|
||||
|
||||
import typer
|
||||
|
||||
from cli.completion.providers import COMPLETION_PROVIDER_GATEWAY
|
||||
from cli.context import AllowProdOption, DryRunOption, EnvOption, OutputOption, YesOption
|
||||
|
||||
from .controller import GenCommandController
|
||||
|
||||
app = typer.Typer(
|
||||
help='代码生成相关命令',
|
||||
no_args_is_help=True,
|
||||
context_settings={'help_option_names': ['-h', '--help']},
|
||||
)
|
||||
_GEN_COMMAND_CONTROLLER = GenCommandController()
|
||||
|
||||
|
||||
@app.command('import-table', help='导入数据库表到代码生成业务表')
|
||||
def import_table(
|
||||
table_names: Annotated[
|
||||
list[str],
|
||||
typer.Argument(
|
||||
help='待导入的数据库表名,可传多个',
|
||||
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_gen_db_table_names,
|
||||
),
|
||||
],
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
allow_prod: AllowProdOption = False,
|
||||
yes: YesOption = False,
|
||||
dry_run: DryRunOption = False,
|
||||
) -> None:
|
||||
"""
|
||||
导入数据库表到代码生成业务表。
|
||||
|
||||
:param table_names: 待导入表名列表
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:param dry_run: 是否演练执行
|
||||
:return: None
|
||||
"""
|
||||
_GEN_COMMAND_CONTROLLER.import_table(
|
||||
table_names,
|
||||
env,
|
||||
output,
|
||||
allow_prod,
|
||||
yes,
|
||||
dry_run,
|
||||
)
|
||||
|
||||
|
||||
@app.command('list', help='查看代码生成业务表列表')
|
||||
def list_command(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
table_name: str = typer.Option(
|
||||
'',
|
||||
'--table-name',
|
||||
help='按表名称过滤',
|
||||
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_gen_table_names,
|
||||
),
|
||||
table_comment: str = typer.Option('', '--table-comment', help='按表描述过滤'),
|
||||
paged: bool = typer.Option(False, '--paged/--no-paged', help='是否启用分页结果'),
|
||||
page_num: int = typer.Option(1, '--page-num', min=1, help='分页页码'),
|
||||
page_size: int = typer.Option(20, '--page-size', min=1, help='分页每页数量'),
|
||||
) -> None:
|
||||
"""
|
||||
查看代码生成业务表列表。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param table_name: 表名称过滤条件
|
||||
:param table_comment: 表描述过滤条件
|
||||
:param paged: 是否启用分页
|
||||
:param page_num: 页码
|
||||
:param page_size: 每页数量
|
||||
:return: None
|
||||
"""
|
||||
_GEN_COMMAND_CONTROLLER.list_tables(
|
||||
env,
|
||||
output,
|
||||
table_name=table_name,
|
||||
table_comment=table_comment,
|
||||
paged=paged,
|
||||
page_num=page_num,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@app.command('db-list', help='查看数据库中可导入的物理表列表')
|
||||
def db_list(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
table_name: str = typer.Option(
|
||||
'',
|
||||
'--table-name',
|
||||
help='按表名称过滤',
|
||||
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_gen_db_table_names,
|
||||
),
|
||||
table_comment: str = typer.Option('', '--table-comment', help='按表描述过滤'),
|
||||
paged: bool = typer.Option(False, '--paged/--no-paged', help='是否启用分页结果'),
|
||||
page_num: int = typer.Option(1, '--page-num', min=1, help='分页页码'),
|
||||
page_size: int = typer.Option(20, '--page-size', min=1, help='分页每页数量'),
|
||||
) -> None:
|
||||
"""
|
||||
查看数据库中可导入的物理表列表。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param table_name: 表名称过滤条件
|
||||
:param table_comment: 表描述过滤条件
|
||||
:param paged: 是否启用分页
|
||||
:param page_num: 页码
|
||||
:param page_size: 每页数量
|
||||
:return: None
|
||||
"""
|
||||
_GEN_COMMAND_CONTROLLER.list_db_tables(
|
||||
env,
|
||||
output,
|
||||
table_name=table_name,
|
||||
table_comment=table_comment,
|
||||
paged=paged,
|
||||
page_num=page_num,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@app.command('detail', help='查看单个代码生成业务表详情')
|
||||
def detail(
|
||||
table_id: Annotated[int, typer.Argument(help='业务表 ID')],
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
) -> None:
|
||||
"""
|
||||
查看单个代码生成业务表详情。
|
||||
|
||||
:param table_id: 业务表 ID
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
_GEN_COMMAND_CONTROLLER.show_detail(table_id, env, output)
|
||||
|
||||
|
||||
@app.command('create-table', help='根据建表 SQL 创建表结构并导入代码生成业务表')
|
||||
def create_table(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
allow_prod: AllowProdOption = False,
|
||||
yes: YesOption = False,
|
||||
dry_run: DryRunOption = False,
|
||||
sql: str = typer.Option('', '--sql', help='直接传入建表 SQL 文本'),
|
||||
sql_file: str = typer.Option(
|
||||
'',
|
||||
'--sql-file',
|
||||
help='从文件读取建表 SQL',
|
||||
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_sql_files,
|
||||
),
|
||||
) -> None:
|
||||
"""
|
||||
根据建表 SQL 创建表结构并导入代码生成业务表。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:param dry_run: 是否演练执行
|
||||
:param sql: 直接传入的 SQL 文本
|
||||
:param sql_file: SQL 文件路径
|
||||
:return: None
|
||||
"""
|
||||
_GEN_COMMAND_CONTROLLER.create_table(
|
||||
env,
|
||||
output,
|
||||
allow_prod,
|
||||
yes,
|
||||
dry_run,
|
||||
sql=sql,
|
||||
sql_file=sql_file,
|
||||
)
|
||||
|
||||
|
||||
@app.command('preview', help='查看指定业务表的代码生成结果预览')
|
||||
def preview(
|
||||
table_id: Annotated[int, typer.Argument(help='业务表 ID')],
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
) -> None:
|
||||
"""
|
||||
预览指定业务表的代码生成结果。
|
||||
|
||||
:param table_id: 业务表 ID
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
_GEN_COMMAND_CONTROLLER.preview(table_id, env, output)
|
||||
|
||||
|
||||
@app.command('export', help='导出代码生成结果')
|
||||
def export(
|
||||
table_names: Annotated[
|
||||
list[str],
|
||||
typer.Argument(
|
||||
help='待导出的业务表名,可传多个',
|
||||
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_gen_table_names,
|
||||
),
|
||||
],
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
allow_prod: AllowProdOption = False,
|
||||
yes: YesOption = False,
|
||||
dry_run: DryRunOption = False,
|
||||
mode: Annotated[Literal['zip', 'local'], typer.Option('--mode', help='导出模式')] = 'zip',
|
||||
output_file: str = typer.Option(
|
||||
'',
|
||||
'--output-file',
|
||||
help='zip 导出目标文件路径',
|
||||
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_output_paths,
|
||||
),
|
||||
) -> None:
|
||||
"""
|
||||
导出代码生成结果。
|
||||
|
||||
:param table_names: 业务表名称列表
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:param dry_run: 是否演练执行
|
||||
:param mode: 导出模式
|
||||
:param output_file: zip 导出目标文件路径
|
||||
:return: None
|
||||
"""
|
||||
_GEN_COMMAND_CONTROLLER.export(
|
||||
table_names,
|
||||
env,
|
||||
output,
|
||||
allow_prod,
|
||||
yes,
|
||||
dry_run,
|
||||
mode=mode,
|
||||
output_file=output_file,
|
||||
)
|
||||
|
||||
|
||||
@app.command('sync-db', help='同步指定业务表的数据库表结构')
|
||||
def sync_db(
|
||||
table_name: Annotated[
|
||||
str,
|
||||
typer.Argument(
|
||||
help='业务表名称',
|
||||
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_gen_table_names,
|
||||
),
|
||||
],
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
allow_prod: AllowProdOption = False,
|
||||
yes: YesOption = False,
|
||||
) -> None:
|
||||
"""
|
||||
同步指定业务表的数据库表结构。
|
||||
|
||||
:param table_name: 业务表名称
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:return: None
|
||||
"""
|
||||
_GEN_COMMAND_CONTROLLER.sync_db(table_name, env, output, allow_prod, yes)
|
||||
@@ -0,0 +1,304 @@
|
||||
from cli.core import (
|
||||
DEFAULT_CORE_SERVICES,
|
||||
CliContextFactory,
|
||||
CliExecutionService,
|
||||
)
|
||||
from cli.runtime.gen import GEN_RUNTIME, GenRuntimeService
|
||||
|
||||
from .presenter import GenCommandPresenter
|
||||
|
||||
|
||||
class GenCommandController:
|
||||
"""
|
||||
代码生成命令控制器。
|
||||
|
||||
该控制器负责组织 `gen` 命令组的上下文准备、runtime 调用、
|
||||
payload 注入,以及基于输出格式选择 presenter 或直接返回 JSON。
|
||||
|
||||
:param context_factory: CLI 上下文工厂
|
||||
:param execution_service: CLI 执行服务
|
||||
:param presenter: 代码生成命令文本渲染器
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
context_factory: CliContextFactory | None = None,
|
||||
execution_service: CliExecutionService | None = None,
|
||||
presenter: GenCommandPresenter | None = None,
|
||||
runtime_service: GenRuntimeService | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化代码生成命令控制器。
|
||||
|
||||
:param context_factory: CLI 上下文工厂
|
||||
:param execution_service: CLI 执行服务
|
||||
:param presenter: 代码生成命令文本渲染器
|
||||
:param runtime_service: 代码生成运行时服务
|
||||
:return: None
|
||||
"""
|
||||
self.context_factory = context_factory or DEFAULT_CORE_SERVICES.context_factory
|
||||
self.execution_service = execution_service or DEFAULT_CORE_SERVICES.execution_service
|
||||
self.presenter = presenter or GenCommandPresenter()
|
||||
self.runtime_service = runtime_service or GEN_RUNTIME
|
||||
|
||||
def import_table(
|
||||
self,
|
||||
table_names: list[str],
|
||||
env: str,
|
||||
output: str,
|
||||
allow_prod: bool,
|
||||
yes: bool,
|
||||
dry_run: bool,
|
||||
) -> None:
|
||||
"""
|
||||
导入数据库表到代码生成业务表。
|
||||
|
||||
:param table_names: 待导入表名列表
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:param dry_run: 是否演练执行
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_dangerous(
|
||||
env,
|
||||
output,
|
||||
allow_prod,
|
||||
yes,
|
||||
dry_run,
|
||||
command_name='gen import-table',
|
||||
)
|
||||
payload = self.execution_service.run_async(self.runtime_service.import_tables(table_names, dry_run=dry_run))
|
||||
payload['env'] = ctx.env
|
||||
self.execution_service.complete_payload(ctx, payload)
|
||||
|
||||
def list_tables(
|
||||
self,
|
||||
env: str,
|
||||
output: str,
|
||||
*,
|
||||
table_name: str,
|
||||
table_comment: str,
|
||||
paged: bool,
|
||||
page_num: int,
|
||||
page_size: int,
|
||||
) -> None:
|
||||
"""
|
||||
查看代码生成业务表列表。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param table_name: 表名称过滤条件
|
||||
:param table_comment: 表描述过滤条件
|
||||
:param paged: 是否启用分页
|
||||
:param page_num: 页码
|
||||
:param page_size: 每页数量
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
payload = self.execution_service.run_async(
|
||||
self.runtime_service.list_gen_tables(
|
||||
table_name=table_name,
|
||||
table_comment=table_comment,
|
||||
paged=paged,
|
||||
page_num=page_num,
|
||||
page_size=page_size,
|
||||
)
|
||||
)
|
||||
payload['env'] = ctx.env
|
||||
self.execution_service.complete_payload_with_text(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=lambda data: self.presenter.build_gen_table_list_text(data, db_mode=False),
|
||||
text_condition=lambda data: data.get('ok', False),
|
||||
)
|
||||
|
||||
def list_db_tables(
|
||||
self,
|
||||
env: str,
|
||||
output: str,
|
||||
*,
|
||||
table_name: str,
|
||||
table_comment: str,
|
||||
paged: bool,
|
||||
page_num: int,
|
||||
page_size: int,
|
||||
) -> None:
|
||||
"""
|
||||
查看数据库中可导入的物理表列表。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param table_name: 表名称过滤条件
|
||||
:param table_comment: 表描述过滤条件
|
||||
:param paged: 是否启用分页
|
||||
:param page_num: 页码
|
||||
:param page_size: 每页数量
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
payload = self.execution_service.run_async(
|
||||
self.runtime_service.list_gen_db_tables(
|
||||
table_name=table_name,
|
||||
table_comment=table_comment,
|
||||
paged=paged,
|
||||
page_num=page_num,
|
||||
page_size=page_size,
|
||||
)
|
||||
)
|
||||
payload['env'] = ctx.env
|
||||
self.execution_service.complete_payload_with_text(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=lambda data: self.presenter.build_gen_table_list_text(data, db_mode=True),
|
||||
text_condition=lambda data: data.get('ok', False),
|
||||
)
|
||||
|
||||
def show_detail(self, table_id: int, env: str, output: str) -> None:
|
||||
"""
|
||||
查看单个代码生成业务表详情。
|
||||
|
||||
:param table_id: 业务表 ID
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
payload = self.execution_service.run_async(self.runtime_service.get_gen_table_detail(table_id))
|
||||
payload['env'] = ctx.env
|
||||
self.execution_service.complete_payload_with_text(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=self.presenter.build_gen_detail_text,
|
||||
text_condition=lambda data: 'error' not in data,
|
||||
)
|
||||
|
||||
def create_table(
|
||||
self,
|
||||
env: str,
|
||||
output: str,
|
||||
allow_prod: bool,
|
||||
yes: bool,
|
||||
dry_run: bool,
|
||||
*,
|
||||
sql: str,
|
||||
sql_file: str,
|
||||
) -> None:
|
||||
"""
|
||||
根据建表 SQL 创建表结构并导入代码生成业务表。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:param dry_run: 是否演练执行
|
||||
:param sql: 直接传入的 SQL 文本
|
||||
:param sql_file: SQL 文件路径
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_dangerous(
|
||||
env,
|
||||
output,
|
||||
allow_prod,
|
||||
yes,
|
||||
dry_run,
|
||||
command_name='gen create-table',
|
||||
)
|
||||
payload = self.execution_service.run_async(self.runtime_service.create_tables(sql, sql_file, dry_run=dry_run))
|
||||
payload['env'] = ctx.env
|
||||
self.execution_service.complete_payload(ctx, payload)
|
||||
|
||||
def preview(self, table_id: int, env: str, output: str) -> None:
|
||||
"""
|
||||
预览指定业务表的代码生成结果。
|
||||
|
||||
:param table_id: 业务表 ID
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
payload = self.execution_service.run_async(self.runtime_service.preview_code(table_id))
|
||||
payload['env'] = ctx.env
|
||||
self.execution_service.complete_payload_with_text(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=self.presenter.build_gen_preview_text,
|
||||
text_condition=lambda data: data.get('ok', False),
|
||||
)
|
||||
|
||||
def export(
|
||||
self,
|
||||
table_names: list[str],
|
||||
env: str,
|
||||
output: str,
|
||||
allow_prod: bool,
|
||||
yes: bool,
|
||||
dry_run: bool,
|
||||
*,
|
||||
mode: str,
|
||||
output_file: str,
|
||||
) -> None:
|
||||
"""
|
||||
导出代码生成结果。
|
||||
|
||||
:param table_names: 业务表名称列表
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:param dry_run: 是否演练执行
|
||||
:param mode: 导出模式
|
||||
:param output_file: zip 导出目标文件路径
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_dangerous(
|
||||
env,
|
||||
output,
|
||||
allow_prod,
|
||||
yes,
|
||||
dry_run,
|
||||
command_name='gen export',
|
||||
)
|
||||
payload = self.execution_service.run_async(
|
||||
self.runtime_service.export_code(table_names, mode=mode, output_file=output_file, dry_run=dry_run)
|
||||
)
|
||||
payload['env'] = ctx.env
|
||||
self.execution_service.complete_payload_with_text(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=self.presenter.build_gen_export_text,
|
||||
text_condition=lambda data: data.get('ok', False),
|
||||
)
|
||||
|
||||
def sync_db(
|
||||
self,
|
||||
table_name: str,
|
||||
env: str,
|
||||
output: str,
|
||||
allow_prod: bool,
|
||||
yes: bool,
|
||||
) -> None:
|
||||
"""
|
||||
同步指定业务表的数据库表结构。
|
||||
|
||||
:param table_name: 业务表名称
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_dangerous(
|
||||
env,
|
||||
output,
|
||||
allow_prod,
|
||||
yes,
|
||||
False,
|
||||
command_name='gen sync-db',
|
||||
)
|
||||
payload = self.execution_service.run_async(self.runtime_service.sync_gen_table_from_db(table_name))
|
||||
payload['env'] = ctx.env
|
||||
self.execution_service.complete_payload(ctx, payload)
|
||||
@@ -0,0 +1,231 @@
|
||||
from cli.utils import SHELL_TEXT_FORMATTER
|
||||
|
||||
|
||||
class GenCommandPresenter:
|
||||
"""
|
||||
代码生成命令文本渲染器。
|
||||
|
||||
该渲染器负责将 `gen` 命令组产生的结构化 payload 转换为稳定的文本摘要,
|
||||
同时保持 JSON 输出仍由控制器直接返回,不在此处做契约变形。
|
||||
"""
|
||||
|
||||
def build_gen_preview_text(self, payload: dict[str, object]) -> str:
|
||||
"""
|
||||
将代码预览结果渲染为文本摘要。
|
||||
|
||||
:param payload: 代码预览结果字典
|
||||
:return: 文本摘要
|
||||
"""
|
||||
preview_payload = payload.get('preview')
|
||||
lines = [
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'env: {payload.get("env", "")}',
|
||||
f'table_id: {payload.get("tableId", "-")}',
|
||||
f'template_count: {payload.get("templateCount", 0)}',
|
||||
]
|
||||
if not isinstance(preview_payload, dict) or not preview_payload:
|
||||
lines.append('templates: none')
|
||||
return '\n'.join(lines)
|
||||
|
||||
lines.append('templates:')
|
||||
for template_name, template_content in preview_payload.items():
|
||||
lines.extend([f' {line}' for line in self._build_text_block(str(template_name), template_content)])
|
||||
return '\n'.join(lines)
|
||||
|
||||
def build_gen_export_text(self, payload: dict[str, object]) -> str:
|
||||
"""
|
||||
将代码导出结果渲染为文本摘要。
|
||||
|
||||
:param payload: 代码导出结果字典
|
||||
:return: 文本摘要
|
||||
"""
|
||||
table_names = payload.get('tableNames')
|
||||
results = payload.get('results')
|
||||
lines = [
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'env: {payload.get("env", "")}',
|
||||
f'mode: {payload.get("mode", "-")}',
|
||||
f'dry_run: {str(payload.get("dryRun", False)).lower()}',
|
||||
f'message: {payload.get("message", "-")}',
|
||||
]
|
||||
if isinstance(table_names, list) and table_names:
|
||||
lines.append('table_names:')
|
||||
lines.extend(f' - {table_name}' for table_name in table_names)
|
||||
else:
|
||||
lines.append('table_names: none')
|
||||
|
||||
if payload.get('outputFile'):
|
||||
lines.append(f'output_file: {payload.get("outputFile")}')
|
||||
if payload.get('genPath'):
|
||||
lines.append(f'gen_path: {payload.get("genPath")}')
|
||||
if payload.get('size') is not None:
|
||||
lines.append(f'size: {payload.get("size")}')
|
||||
|
||||
if isinstance(results, list) and results:
|
||||
lines.append('results:')
|
||||
lines.extend(
|
||||
f' - {item.get("tableName", "-")}: {str(item.get("ok", False)).lower()} | {item.get("message", "-")}'
|
||||
for item in results
|
||||
if isinstance(item, dict)
|
||||
)
|
||||
return '\n'.join(lines)
|
||||
|
||||
def build_gen_table_list_text(self, payload: dict[str, object], *, db_mode: bool = False) -> str:
|
||||
"""
|
||||
将代码生成表列表或数据库表列表渲染为文本摘要。
|
||||
|
||||
:param payload: 列表结果字典
|
||||
:param db_mode: 是否为数据库物理表列表模式
|
||||
:return: 文本摘要
|
||||
"""
|
||||
lines = [f'ok: {str(payload.get("ok", False)).lower()}']
|
||||
filters = payload.get('filters')
|
||||
if isinstance(filters, dict):
|
||||
lines.extend(self._build_gen_filter_lines(filters))
|
||||
|
||||
page_payload = payload.get('page')
|
||||
if isinstance(page_payload, dict):
|
||||
rows = page_payload.get('rows', [])
|
||||
lines.append(
|
||||
'page: '
|
||||
f'{page_payload.get("pageNum", "-")}/{page_payload.get("pages", "-")} '
|
||||
f'(page_size={page_payload.get("pageSize", "-")}, total={page_payload.get("total", "-")})'
|
||||
)
|
||||
lines.append(f'count: {len(rows) if isinstance(rows, list) else 0}')
|
||||
if not isinstance(rows, list) or not rows:
|
||||
lines.append('items: none')
|
||||
return '\n'.join(lines)
|
||||
lines.append('items:')
|
||||
for row in rows:
|
||||
if isinstance(row, dict):
|
||||
item_lines = (
|
||||
self._build_gen_db_table_item_lines(row) if db_mode else self._build_gen_table_item_lines(row)
|
||||
)
|
||||
lines.extend([f' {item}' for item in item_lines])
|
||||
return '\n'.join(lines)
|
||||
|
||||
items = payload.get('items')
|
||||
lines.append(f'count: {payload.get("count", 0)}')
|
||||
if not isinstance(items, list) or not items:
|
||||
lines.append('items: none')
|
||||
return '\n'.join(lines)
|
||||
|
||||
lines.append('items:')
|
||||
for item in items:
|
||||
if isinstance(item, dict):
|
||||
item_lines = (
|
||||
self._build_gen_db_table_item_lines(item) if db_mode else self._build_gen_table_item_lines(item)
|
||||
)
|
||||
lines.extend([f' {line}' for line in item_lines])
|
||||
return '\n'.join(lines)
|
||||
|
||||
def build_gen_detail_text(self, payload: dict[str, object]) -> str:
|
||||
"""
|
||||
将代码生成业务表详情渲染为文本摘要。
|
||||
|
||||
:param payload: 详情结果字典
|
||||
:return: 文本摘要
|
||||
"""
|
||||
detail = payload.get('detail')
|
||||
if not isinstance(detail, dict):
|
||||
return '\n'.join(
|
||||
[
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'table_id: {payload.get("tableId", "-")}',
|
||||
'detail: none',
|
||||
]
|
||||
)
|
||||
|
||||
info = detail.get('info')
|
||||
if not isinstance(info, dict):
|
||||
return '\n'.join(
|
||||
[
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'table_id: {payload.get("tableId", "-")}',
|
||||
'info: none',
|
||||
]
|
||||
)
|
||||
|
||||
return '\n'.join(
|
||||
[
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'table_id: {payload.get("tableId", "-")}',
|
||||
f'table_name: {payload.get("tableName", "-")}',
|
||||
f'column_count: {payload.get("columnCount", 0)}',
|
||||
f'table_count: {payload.get("tableCount", 0)}',
|
||||
f'table_comment: {SHELL_TEXT_FORMATTER.truncate_text(info.get("tableComment", ""), 80) or "-"}',
|
||||
f'class_name: {info.get("className", "-")}',
|
||||
f'tpl_category: {info.get("tplCategory", "-")}',
|
||||
f'tpl_web_type: {info.get("tplWebType", "-")}',
|
||||
f'package_name: {SHELL_TEXT_FORMATTER.truncate_text(info.get("packageName", ""), 80) or "-"}',
|
||||
f'module_name: {info.get("moduleName", "-")}',
|
||||
f'business_name: {info.get("businessName", "-")}',
|
||||
f'function_name: {info.get("functionName", "-")}',
|
||||
f'function_author: {info.get("functionAuthor", "-")}',
|
||||
f'gen_type: {info.get("genType", "-")}',
|
||||
f'gen_path: {SHELL_TEXT_FORMATTER.truncate_text(info.get("genPath", ""), 120) or "-"}',
|
||||
f'remark: {SHELL_TEXT_FORMATTER.truncate_text(info.get("remark", ""), 160) or "-"}',
|
||||
]
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_text_block(title: str, value: object) -> list[str]:
|
||||
"""
|
||||
将多行文本构建为带标题的块级文本。
|
||||
|
||||
:param title: 块标题
|
||||
:param value: 原始文本值
|
||||
:return: 文本行列表
|
||||
"""
|
||||
text = '' if value is None else str(value)
|
||||
if not text.strip():
|
||||
return [f'{title}: -']
|
||||
return [f'{title}:', ' |', *[f' {line}' for line in text.splitlines()]]
|
||||
|
||||
@staticmethod
|
||||
def _build_gen_filter_lines(filters: dict[str, object]) -> list[str]:
|
||||
"""
|
||||
构建代码生成表过滤条件文本行。
|
||||
|
||||
:param filters: 过滤条件字典
|
||||
:return: 过滤条件文本行列表
|
||||
"""
|
||||
active_filters = []
|
||||
for key, value in filters.items():
|
||||
if value in (None, '', False):
|
||||
continue
|
||||
active_filters.append(f'{SHELL_TEXT_FORMATTER.to_snake_case(key)}={value}')
|
||||
if not active_filters:
|
||||
return ['filters: none']
|
||||
return ['filters:', *[f' - {item}' for item in active_filters]]
|
||||
|
||||
@staticmethod
|
||||
def _build_gen_table_item_lines(table_item: dict[str, object]) -> list[str]:
|
||||
"""
|
||||
构建单条代码生成表记录的文本行。
|
||||
|
||||
:param table_item: 代码生成表记录
|
||||
:return: 文本行列表
|
||||
"""
|
||||
return [
|
||||
f'- [{table_item.get("tableId", "-")}] {SHELL_TEXT_FORMATTER.truncate_text(table_item.get("tableName", ""), 40) or "-"}',
|
||||
f' comment: {SHELL_TEXT_FORMATTER.truncate_text(table_item.get("tableComment", ""), 60) or "-"}',
|
||||
f' class_name: {table_item.get("className", "-")}',
|
||||
f' tpl_category: {table_item.get("tplCategory", "-")} | module_name: {table_item.get("moduleName", "-")}',
|
||||
f' business_name: {table_item.get("businessName", "-")} | function_name: {table_item.get("functionName", "-")}',
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _build_gen_db_table_item_lines(table_item: dict[str, object]) -> list[str]:
|
||||
"""
|
||||
构建单条数据库物理表记录的文本行。
|
||||
|
||||
:param table_item: 数据库物理表记录
|
||||
:return: 文本行列表
|
||||
"""
|
||||
return [
|
||||
f'- {SHELL_TEXT_FORMATTER.truncate_text(table_item.get("tableName", ""), 48) or "-"}',
|
||||
f' comment: {SHELL_TEXT_FORMATTER.truncate_text(table_item.get("tableComment", ""), 80) or "-"}',
|
||||
f' create_time: {table_item.get("createTime", "-")}',
|
||||
f' update_time: {table_item.get("updateTime", "-")}',
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
from .command import app
|
||||
|
||||
__all__ = ['app']
|
||||
@@ -0,0 +1,216 @@
|
||||
import typer
|
||||
|
||||
from cli.completion.providers import COMPLETION_PROVIDER_GATEWAY
|
||||
from cli.context import AllowProdOption, EnvOption, OutputOption, YesOption
|
||||
|
||||
from .controller import JobCommandController
|
||||
|
||||
app = typer.Typer(
|
||||
help='定时任务相关命令',
|
||||
no_args_is_help=True,
|
||||
context_settings={'help_option_names': ['-h', '--help']},
|
||||
)
|
||||
_JOB_COMMAND_CONTROLLER = JobCommandController()
|
||||
|
||||
|
||||
@app.command('list', help='查看定时任务列表')
|
||||
def list_command(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
job_name: str = typer.Option(
|
||||
'',
|
||||
'--job-name',
|
||||
help='按任务名称过滤',
|
||||
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_job_names,
|
||||
),
|
||||
job_group: str = typer.Option('', '--job-group', help='按任务组过滤'),
|
||||
status: str | None = typer.Option(None, '--status', help='按任务状态过滤,0正常 1暂停'),
|
||||
paged: bool = typer.Option(False, '--paged/--no-paged', help='是否启用分页结果'),
|
||||
page_num: int = typer.Option(1, '--page-num', min=1, help='分页页码'),
|
||||
page_size: int = typer.Option(20, '--page-size', min=1, help='分页每页数量'),
|
||||
) -> None:
|
||||
"""
|
||||
查看定时任务列表。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param job_name: 任务名称过滤条件
|
||||
:param job_group: 任务组过滤条件
|
||||
:param status: 状态过滤条件
|
||||
:param paged: 是否启用分页
|
||||
:param page_num: 页码
|
||||
:param page_size: 每页数量
|
||||
:return: None
|
||||
"""
|
||||
_JOB_COMMAND_CONTROLLER.list(
|
||||
env,
|
||||
output,
|
||||
job_name=job_name,
|
||||
job_group=job_group,
|
||||
status=status,
|
||||
paged=paged,
|
||||
page_num=page_num,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@app.command('run-once', help='执行一次定时任务')
|
||||
def run_once(
|
||||
job_id: int = typer.Argument(
|
||||
...,
|
||||
help='任务ID',
|
||||
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_job_ids,
|
||||
),
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
allow_prod: AllowProdOption = False,
|
||||
yes: YesOption = False,
|
||||
) -> None:
|
||||
"""
|
||||
执行一次定时任务。
|
||||
|
||||
:param job_id: 任务ID
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:return: None
|
||||
"""
|
||||
_JOB_COMMAND_CONTROLLER.run_once(job_id, env, output, allow_prod, yes)
|
||||
|
||||
|
||||
@app.command('detail', help='查看单个定时任务详情')
|
||||
def detail(
|
||||
job_id: int = typer.Argument(
|
||||
...,
|
||||
help='任务ID',
|
||||
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_job_ids,
|
||||
),
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
) -> None:
|
||||
"""
|
||||
查看单个定时任务详情。
|
||||
|
||||
:param job_id: 任务ID
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
_JOB_COMMAND_CONTROLLER.detail(job_id, env, output)
|
||||
|
||||
|
||||
@app.command('logs', help='查看定时任务执行日志列表')
|
||||
def logs(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
job_name: str = typer.Option(
|
||||
'',
|
||||
'--job-name',
|
||||
help='按任务名称过滤',
|
||||
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_job_names,
|
||||
),
|
||||
job_group: str = typer.Option('', '--job-group', help='按任务组过滤'),
|
||||
status: str | None = typer.Option(None, '--status', help='按执行状态过滤,0成功 1失败'),
|
||||
begin_date: str = typer.Option('', '--begin-date', help='按创建时间开始日期过滤,格式 YYYY-MM-DD'),
|
||||
end_date: str = typer.Option('', '--end-date', help='按创建时间结束日期过滤,格式 YYYY-MM-DD'),
|
||||
paged: bool = typer.Option(False, '--paged/--no-paged', help='是否启用分页结果'),
|
||||
page_num: int = typer.Option(1, '--page-num', min=1, help='分页页码'),
|
||||
page_size: int = typer.Option(20, '--page-size', min=1, help='分页每页数量'),
|
||||
) -> None:
|
||||
"""
|
||||
查看定时任务执行日志列表。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param job_name: 任务名称过滤条件
|
||||
:param job_group: 任务组过滤条件
|
||||
:param status: 执行状态过滤条件
|
||||
:param begin_date: 查询开始日期
|
||||
:param end_date: 查询结束日期
|
||||
:param paged: 是否启用分页
|
||||
:param page_num: 页码
|
||||
:param page_size: 每页数量
|
||||
:return: None
|
||||
"""
|
||||
_JOB_COMMAND_CONTROLLER.logs(
|
||||
env,
|
||||
output,
|
||||
job_name=job_name,
|
||||
job_group=job_group,
|
||||
status=status,
|
||||
begin_date=begin_date,
|
||||
end_date=end_date,
|
||||
paged=paged,
|
||||
page_num=page_num,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@app.command('pause', help='暂停定时任务')
|
||||
def pause(
|
||||
job_id: int = typer.Argument(
|
||||
...,
|
||||
help='任务ID',
|
||||
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_job_ids,
|
||||
),
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
allow_prod: AllowProdOption = False,
|
||||
yes: YesOption = False,
|
||||
) -> None:
|
||||
"""
|
||||
暂停定时任务。
|
||||
|
||||
:param job_id: 任务ID
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:return: None
|
||||
"""
|
||||
_JOB_COMMAND_CONTROLLER.pause(job_id, env, output, allow_prod, yes)
|
||||
|
||||
|
||||
@app.command('resume', help='恢复定时任务')
|
||||
def resume(
|
||||
job_id: int = typer.Argument(
|
||||
...,
|
||||
help='任务ID',
|
||||
autocompletion=COMPLETION_PROVIDER_GATEWAY.complete_job_ids,
|
||||
),
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
allow_prod: AllowProdOption = False,
|
||||
yes: YesOption = False,
|
||||
) -> None:
|
||||
"""
|
||||
恢复定时任务。
|
||||
|
||||
:param job_id: 任务ID
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:return: None
|
||||
"""
|
||||
_JOB_COMMAND_CONTROLLER.resume(job_id, env, output, allow_prod, yes)
|
||||
|
||||
|
||||
@app.command('sync', help='同步调度任务配置')
|
||||
def sync(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
allow_prod: AllowProdOption = False,
|
||||
yes: YesOption = False,
|
||||
) -> None:
|
||||
"""
|
||||
同步调度任务配置。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:return: None
|
||||
"""
|
||||
_JOB_COMMAND_CONTROLLER.sync(env, output, allow_prod, yes)
|
||||
@@ -0,0 +1,221 @@
|
||||
from cli.core import (
|
||||
DEFAULT_CORE_SERVICES,
|
||||
CliContextFactory,
|
||||
CliExecutionService,
|
||||
)
|
||||
from cli.runtime.job import JOB_RUNTIME, JobRuntimeService
|
||||
from cli.runtime.ops import OPERATIONS_RUNTIME, OperationsRuntimeService
|
||||
|
||||
from .presenter import JobCommandPresenter
|
||||
|
||||
|
||||
class JobCommandController:
|
||||
"""
|
||||
定时任务命令控制器。
|
||||
|
||||
该控制器负责组织 `job` 命令组的上下文准备、runtime 调用、
|
||||
payload 注入,以及基于输出格式选择 presenter 或直接返回 JSON。
|
||||
|
||||
:param context_factory: CLI 上下文工厂
|
||||
:param execution_service: CLI 执行服务
|
||||
:param presenter: 定时任务命令文本渲染器
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
context_factory: CliContextFactory | None = None,
|
||||
execution_service: CliExecutionService | None = None,
|
||||
presenter: JobCommandPresenter | None = None,
|
||||
operations_runtime: OperationsRuntimeService | None = None,
|
||||
job_runtime: JobRuntimeService | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化定时任务命令控制器。
|
||||
|
||||
:param context_factory: CLI 上下文工厂
|
||||
:param execution_service: CLI 执行服务
|
||||
:param presenter: 定时任务命令文本渲染器
|
||||
:param operations_runtime: 运维运行时服务
|
||||
:param job_runtime: 定时任务运行时服务
|
||||
:return: None
|
||||
"""
|
||||
self.context_factory = context_factory or DEFAULT_CORE_SERVICES.context_factory
|
||||
self.execution_service = execution_service or DEFAULT_CORE_SERVICES.execution_service
|
||||
self.presenter = presenter or JobCommandPresenter()
|
||||
self.operations_runtime = operations_runtime or OPERATIONS_RUNTIME
|
||||
self.job_runtime = job_runtime or JOB_RUNTIME
|
||||
|
||||
def list(
|
||||
self,
|
||||
env: str,
|
||||
output: str,
|
||||
*,
|
||||
job_name: str,
|
||||
job_group: str,
|
||||
status: str | None,
|
||||
paged: bool,
|
||||
page_num: int,
|
||||
page_size: int,
|
||||
) -> None:
|
||||
"""
|
||||
查看定时任务列表。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param job_name: 任务名称过滤条件
|
||||
:param job_group: 任务组过滤条件
|
||||
:param status: 状态过滤条件
|
||||
:param paged: 是否启用分页
|
||||
:param page_num: 页码
|
||||
:param page_size: 每页数量
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
payload = self.execution_service.run_async(
|
||||
self.job_runtime.list_jobs(
|
||||
job_name=job_name,
|
||||
job_group=job_group,
|
||||
status=status,
|
||||
paged=paged,
|
||||
page_num=page_num,
|
||||
page_size=page_size,
|
||||
)
|
||||
)
|
||||
self.execution_service.complete_payload_with_text(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=self.presenter.build_job_list_text,
|
||||
text_condition=lambda result_data: bool(result_data.get('ok', False)),
|
||||
)
|
||||
|
||||
def run_once(self, job_id: int, env: str, output: str, allow_prod: bool, yes: bool) -> None:
|
||||
"""
|
||||
执行一次定时任务。
|
||||
|
||||
:param job_id: 任务ID
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_dangerous(env, output, allow_prod, yes, False, command_name='job run-once')
|
||||
self.execution_service.complete_payload(
|
||||
ctx, self.execution_service.run_async(self.job_runtime.run_job_once(job_id))
|
||||
)
|
||||
|
||||
def detail(self, job_id: int, env: str, output: str) -> None:
|
||||
"""
|
||||
查看单个定时任务详情。
|
||||
|
||||
:param job_id: 任务ID
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
payload = self.execution_service.run_async(self.job_runtime.get_job_detail(job_id))
|
||||
self.execution_service.complete_payload_with_text(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=self.presenter.build_job_detail_text,
|
||||
text_condition=lambda result_data: 'error' not in result_data,
|
||||
)
|
||||
|
||||
def logs(
|
||||
self,
|
||||
env: str,
|
||||
output: str,
|
||||
*,
|
||||
job_name: str,
|
||||
job_group: str,
|
||||
status: str | None,
|
||||
begin_date: str,
|
||||
end_date: str,
|
||||
paged: bool,
|
||||
page_num: int,
|
||||
page_size: int,
|
||||
) -> None:
|
||||
"""
|
||||
查看定时任务执行日志列表。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param job_name: 任务名称过滤条件
|
||||
:param job_group: 任务组过滤条件
|
||||
:param status: 执行状态过滤条件
|
||||
:param begin_date: 查询开始日期
|
||||
:param end_date: 查询结束日期
|
||||
:param paged: 是否启用分页
|
||||
:param page_num: 页码
|
||||
:param page_size: 每页数量
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
payload = self.execution_service.run_async(
|
||||
self.job_runtime.list_job_logs(
|
||||
job_name=job_name,
|
||||
job_group=job_group,
|
||||
status=status,
|
||||
begin_date=begin_date,
|
||||
end_date=end_date,
|
||||
paged=paged,
|
||||
page_num=page_num,
|
||||
page_size=page_size,
|
||||
)
|
||||
)
|
||||
self.execution_service.complete_payload_with_text(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=self.presenter.build_job_logs_text,
|
||||
text_condition=lambda result_data: bool(result_data.get('ok', False)),
|
||||
)
|
||||
|
||||
def pause(self, job_id: int, env: str, output: str, allow_prod: bool, yes: bool) -> None:
|
||||
"""
|
||||
暂停定时任务。
|
||||
|
||||
:param job_id: 任务ID
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_dangerous(env, output, allow_prod, yes, False, command_name='job pause')
|
||||
self.execution_service.complete_payload(
|
||||
ctx, self.execution_service.run_async(self.job_runtime.pause_job(job_id))
|
||||
)
|
||||
|
||||
def resume(self, job_id: int, env: str, output: str, allow_prod: bool, yes: bool) -> None:
|
||||
"""
|
||||
恢复定时任务。
|
||||
|
||||
:param job_id: 任务ID
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_dangerous(env, output, allow_prod, yes, False, command_name='job resume')
|
||||
self.execution_service.complete_payload(
|
||||
ctx, self.execution_service.run_async(self.job_runtime.resume_job(job_id))
|
||||
)
|
||||
|
||||
def sync(self, env: str, output: str, allow_prod: bool, yes: bool) -> None:
|
||||
"""
|
||||
同步调度任务配置。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param allow_prod: 是否允许生产环境危险命令
|
||||
:param yes: 是否跳过确认
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_dangerous(env, output, allow_prod, yes, False, command_name='job sync')
|
||||
self.execution_service.complete_payload(
|
||||
ctx,
|
||||
self.execution_service.run_async(self.operations_runtime.sync_jobs()),
|
||||
)
|
||||
@@ -0,0 +1,197 @@
|
||||
from cli.utils import SHELL_TEXT_FORMATTER
|
||||
|
||||
|
||||
class JobCommandPresenter:
|
||||
"""
|
||||
定时任务命令文本渲染器。
|
||||
|
||||
该渲染器负责将 `job` 命令组产生的结构化 payload 转换为稳定的文本摘要,
|
||||
同时保持 JSON 输出仍由控制器直接返回,不在此处做契约变形。
|
||||
"""
|
||||
|
||||
def build_job_list_text(self, payload: dict[str, object]) -> str:
|
||||
"""
|
||||
将任务列表结果渲染为文本摘要。
|
||||
|
||||
:param payload: 任务列表结果字典
|
||||
:return: 文本摘要
|
||||
"""
|
||||
lines = [f'ok: {str(payload.get("ok", False)).lower()}']
|
||||
|
||||
filters = payload.get('filters')
|
||||
if isinstance(filters, dict):
|
||||
lines.extend(self._build_job_filter_lines(filters))
|
||||
|
||||
page_payload = payload.get('page')
|
||||
if isinstance(page_payload, dict):
|
||||
rows = page_payload.get('rows', [])
|
||||
lines.append(
|
||||
'page: '
|
||||
f'{page_payload.get("pageNum", "-")}/{page_payload.get("pages", "-")} '
|
||||
f'(page_size={page_payload.get("pageSize", "-")}, total={page_payload.get("total", "-")})'
|
||||
)
|
||||
lines.append(f'count: {len(rows) if isinstance(rows, list) else 0}')
|
||||
if not isinstance(rows, list) or not rows:
|
||||
lines.append('jobs: none')
|
||||
return '\n'.join(lines)
|
||||
lines.append('jobs:')
|
||||
for row in rows:
|
||||
if isinstance(row, dict):
|
||||
lines.extend([f' {item}' for item in self._build_job_item_lines(row)])
|
||||
return '\n'.join(lines)
|
||||
|
||||
items = payload.get('items')
|
||||
lines.append(f'count: {payload.get("count", 0)}')
|
||||
if not isinstance(items, list) or not items:
|
||||
lines.append('jobs: none')
|
||||
return '\n'.join(lines)
|
||||
|
||||
lines.append('jobs:')
|
||||
for item in items:
|
||||
if isinstance(item, dict):
|
||||
lines.extend([f' {line}' for line in self._build_job_item_lines(item)])
|
||||
return '\n'.join(lines)
|
||||
|
||||
def build_job_detail_text(self, payload: dict[str, object]) -> str:
|
||||
"""
|
||||
将单个定时任务详情渲染为文本摘要。
|
||||
|
||||
:param payload: 定时任务详情结果字典
|
||||
:return: 文本摘要
|
||||
"""
|
||||
job = payload.get('job')
|
||||
if not isinstance(job, dict):
|
||||
return '\n'.join(
|
||||
[
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'job_id: {payload.get("jobId", "-")}',
|
||||
'job: none',
|
||||
]
|
||||
)
|
||||
|
||||
return '\n'.join(
|
||||
[
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'job_id: {job.get("jobId", "-")}',
|
||||
f'job_name: {job.get("jobName", "-")}',
|
||||
f'job_group: {job.get("jobGroup", "-")}',
|
||||
f'job_executor: {job.get("jobExecutor", "-")}',
|
||||
f'status: {job.get("status", "-")}',
|
||||
f'cron_expression: {job.get("cronExpression", "-")}',
|
||||
f'invoke_target: {SHELL_TEXT_FORMATTER.truncate_text(job.get("invokeTarget", ""), 160) or "-"}',
|
||||
f'job_args: {SHELL_TEXT_FORMATTER.truncate_text(job.get("jobArgs", ""), 120) or "-"}',
|
||||
f'job_kwargs: {SHELL_TEXT_FORMATTER.truncate_text(job.get("jobKwargs", ""), 120) or "-"}',
|
||||
f'misfire_policy: {job.get("misfirePolicy", "-")}',
|
||||
f'concurrent: {job.get("concurrent", "-")}',
|
||||
f'remark: {SHELL_TEXT_FORMATTER.truncate_text(job.get("remark", ""), 160) or "-"}',
|
||||
f'create_by: {job.get("createBy", "-")}',
|
||||
f'create_time: {job.get("createTime", "-")}',
|
||||
f'update_by: {job.get("updateBy", "-")}',
|
||||
f'update_time: {job.get("updateTime", "-")}',
|
||||
]
|
||||
)
|
||||
|
||||
def build_job_logs_text(self, payload: dict[str, object]) -> str:
|
||||
"""
|
||||
将定时任务日志列表结果渲染为文本摘要。
|
||||
|
||||
:param payload: 定时任务日志列表结果字典
|
||||
:return: 文本摘要
|
||||
"""
|
||||
lines = [f'ok: {str(payload.get("ok", False)).lower()}']
|
||||
|
||||
filters = payload.get('filters')
|
||||
if isinstance(filters, dict):
|
||||
lines.extend(self._build_job_filter_lines(filters))
|
||||
|
||||
page_payload = payload.get('page')
|
||||
if isinstance(page_payload, dict):
|
||||
rows = page_payload.get('rows', [])
|
||||
lines.append(
|
||||
'page: '
|
||||
f'{page_payload.get("pageNum", "-")}/{page_payload.get("pages", "-")} '
|
||||
f'(page_size={page_payload.get("pageSize", "-")}, total={page_payload.get("total", "-")})'
|
||||
)
|
||||
lines.append(f'count: {len(rows) if isinstance(rows, list) else 0}')
|
||||
if not isinstance(rows, list) or not rows:
|
||||
lines.append('logs: none')
|
||||
return '\n'.join(lines)
|
||||
lines.append('logs:')
|
||||
for row in rows:
|
||||
if isinstance(row, dict):
|
||||
lines.extend([f' {item}' for item in self._build_job_log_item_lines(row)])
|
||||
return '\n'.join(lines)
|
||||
|
||||
items = payload.get('items')
|
||||
lines.append(f'count: {payload.get("count", 0)}')
|
||||
if not isinstance(items, list) or not items:
|
||||
lines.append('logs: none')
|
||||
return '\n'.join(lines)
|
||||
|
||||
lines.append('logs:')
|
||||
for item in items:
|
||||
if isinstance(item, dict):
|
||||
lines.extend([f' {line}' for line in self._build_job_log_item_lines(item)])
|
||||
return '\n'.join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _build_job_filter_lines(filters: dict[str, object]) -> list[str]:
|
||||
"""
|
||||
构建任务列表过滤条件文本行。
|
||||
|
||||
:param filters: 过滤条件字典
|
||||
:return: 过滤条件文本行列表
|
||||
"""
|
||||
active_filters = []
|
||||
for key, value in filters.items():
|
||||
if value in (None, '', False):
|
||||
continue
|
||||
active_filters.append(f'{SHELL_TEXT_FORMATTER.to_snake_case(key)}={value}')
|
||||
if not active_filters:
|
||||
return ['filters: none']
|
||||
return ['filters:', *[f' - {item}' for item in active_filters]]
|
||||
|
||||
@staticmethod
|
||||
def _build_job_item_lines(job_item: dict[str, object]) -> list[str]:
|
||||
"""
|
||||
构建单条任务记录的文本行。
|
||||
|
||||
:param job_item: 单条任务记录
|
||||
:return: 任务记录文本行列表
|
||||
"""
|
||||
job_id = job_item.get('jobId', '-')
|
||||
job_name = SHELL_TEXT_FORMATTER.truncate_text(job_item.get('jobName', ''), 30)
|
||||
job_group = SHELL_TEXT_FORMATTER.truncate_text(job_item.get('jobGroup', ''), 16)
|
||||
status = job_item.get('status', '-')
|
||||
cron_expression = SHELL_TEXT_FORMATTER.truncate_text(job_item.get('cronExpression', ''), 40)
|
||||
invoke_target = SHELL_TEXT_FORMATTER.truncate_text(job_item.get('invokeTarget', ''), 80)
|
||||
executor = SHELL_TEXT_FORMATTER.truncate_text(job_item.get('jobExecutor', ''), 24)
|
||||
return [
|
||||
f'- [{job_id}] {job_name} | 组: {job_group} | 状态: {status}',
|
||||
f' cron: {cron_expression or "-"}',
|
||||
f' invoke: {invoke_target or "-"}',
|
||||
f' executor: {executor or "-"}',
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _build_job_log_item_lines(job_log_item: dict[str, object]) -> list[str]:
|
||||
"""
|
||||
构建单条定时任务日志记录的文本行。
|
||||
|
||||
:param job_log_item: 单条定时任务日志记录
|
||||
:return: 文本行列表
|
||||
"""
|
||||
job_log_id = job_log_item.get('jobLogId', '-')
|
||||
job_name = SHELL_TEXT_FORMATTER.truncate_text(job_log_item.get('jobName', ''), 30)
|
||||
job_group = SHELL_TEXT_FORMATTER.truncate_text(job_log_item.get('jobGroup', ''), 16)
|
||||
status = job_log_item.get('status', '-')
|
||||
job_trigger = SHELL_TEXT_FORMATTER.truncate_text(job_log_item.get('jobTrigger', ''), 40)
|
||||
job_message = SHELL_TEXT_FORMATTER.truncate_text(job_log_item.get('jobMessage', ''), 80)
|
||||
exception_info = SHELL_TEXT_FORMATTER.truncate_text(job_log_item.get('exceptionInfo', ''), 120)
|
||||
return [
|
||||
f'- [{job_log_id}] {job_name} | 组: {job_group} | 状态: {status}',
|
||||
f' trigger: {job_trigger or "-"}',
|
||||
f' message: {job_message or "-"}',
|
||||
f' exception: {exception_info or "-"}',
|
||||
f' create_time: {job_log_item.get("createTime", "-")}',
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
from .command import app
|
||||
|
||||
__all__ = ['app']
|
||||
@@ -0,0 +1,89 @@
|
||||
import typer
|
||||
|
||||
from cli.context import EnvOption, OutputOption
|
||||
|
||||
from .controller import OpsCommandController
|
||||
|
||||
app = typer.Typer(
|
||||
help='运维相关命令',
|
||||
no_args_is_help=True,
|
||||
context_settings={'help_option_names': ['-h', '--help']},
|
||||
)
|
||||
_OPS_COMMAND_CONTROLLER = OpsCommandController()
|
||||
|
||||
|
||||
@app.command('ping-db', help='检查数据库连接')
|
||||
def ping_db(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
) -> None:
|
||||
"""
|
||||
检查数据库连接。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
_OPS_COMMAND_CONTROLLER.ping_db(env, output)
|
||||
|
||||
|
||||
@app.command('ping-redis', help='检查 Redis 连接')
|
||||
def ping_redis_command(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
) -> None:
|
||||
"""
|
||||
检查 Redis 连接。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
_OPS_COMMAND_CONTROLLER.ping_redis(env, output)
|
||||
|
||||
|
||||
@app.command('health', help='查看基础健康检查结果')
|
||||
def health(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
) -> None:
|
||||
"""
|
||||
输出基础健康检查结果。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
_OPS_COMMAND_CONTROLLER.health(env, output)
|
||||
|
||||
|
||||
@app.command('deps', help='查看当前 CLI 和后端运行依赖版本')
|
||||
def deps(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
include_dev: bool = typer.Option(False, '--include-dev', help='附带输出开发依赖'),
|
||||
) -> None:
|
||||
"""
|
||||
检查当前 CLI 和后端运行依赖版本。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param include_dev: 是否附带输出开发依赖
|
||||
:return: None
|
||||
"""
|
||||
_OPS_COMMAND_CONTROLLER.deps(env, output, include_dev=include_dev)
|
||||
|
||||
|
||||
@app.command('server-info', help='查看服务器运行时信息')
|
||||
def server_info(
|
||||
env: EnvOption = 'dev',
|
||||
output: OutputOption = 'text',
|
||||
) -> None:
|
||||
"""
|
||||
输出服务器运行时信息。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
_OPS_COMMAND_CONTROLLER.server_info(env, output)
|
||||
@@ -0,0 +1,137 @@
|
||||
from cli.core import (
|
||||
DEFAULT_CORE_SERVICES,
|
||||
CliContextFactory,
|
||||
CliExecutionService,
|
||||
)
|
||||
from cli.exit_codes import DEPENDENCY_ERROR, SUCCESS
|
||||
from cli.runtime.db import DATABASE_RUNTIME, DatabaseRuntimeService
|
||||
from cli.runtime.ops import OPERATIONS_RUNTIME, OperationsRuntimeService
|
||||
|
||||
from .presenter import OpsCommandPresenter
|
||||
|
||||
|
||||
class OpsCommandController:
|
||||
"""
|
||||
运维命令控制器。
|
||||
|
||||
该控制器负责组织 `ops` 命令组的上下文准备、runtime 调用、
|
||||
payload 注入,以及基于输出格式选择 presenter 或直接返回 JSON。
|
||||
|
||||
:param context_factory: CLI 上下文工厂
|
||||
:param execution_service: CLI 执行服务
|
||||
:param presenter: 运维命令文本渲染器
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
context_factory: CliContextFactory | None = None,
|
||||
execution_service: CliExecutionService | None = None,
|
||||
presenter: OpsCommandPresenter | None = None,
|
||||
database_runtime: DatabaseRuntimeService | None = None,
|
||||
operations_runtime: OperationsRuntimeService | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化运维命令控制器。
|
||||
|
||||
:param context_factory: CLI 上下文工厂
|
||||
:param execution_service: CLI 执行服务
|
||||
:param presenter: 运维命令文本渲染器
|
||||
:param database_runtime: 数据库运行时服务
|
||||
:param operations_runtime: 运维运行时服务
|
||||
:return: None
|
||||
"""
|
||||
self.context_factory = context_factory or DEFAULT_CORE_SERVICES.context_factory
|
||||
self.execution_service = execution_service or DEFAULT_CORE_SERVICES.execution_service
|
||||
self.presenter = presenter or OpsCommandPresenter()
|
||||
self.database_runtime = database_runtime or DATABASE_RUNTIME
|
||||
self.operations_runtime = operations_runtime or OPERATIONS_RUNTIME
|
||||
|
||||
def ping_db(self, env: str, output: str) -> None:
|
||||
"""
|
||||
检查数据库连接。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
self.execution_service.complete_payload(
|
||||
ctx, self.execution_service.run_async(self.database_runtime.ping_database())
|
||||
)
|
||||
|
||||
def ping_redis(self, env: str, output: str) -> None:
|
||||
"""
|
||||
检查 Redis 连接。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
self.execution_service.complete_payload(
|
||||
ctx,
|
||||
self.execution_service.run_async(self.operations_runtime.ping_redis()),
|
||||
)
|
||||
|
||||
def health(self, env: str, output: str) -> None:
|
||||
"""
|
||||
输出基础健康检查结果。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
db_status = self.execution_service.run_async(self.database_runtime.ping_database())
|
||||
redis_status = self.execution_service.run_async(self.operations_runtime.ping_redis())
|
||||
payload = {
|
||||
'env': ctx.env,
|
||||
'database': db_status,
|
||||
'redis': redis_status,
|
||||
'ok': db_status.get('ok', False) and redis_status.get('ok', False),
|
||||
}
|
||||
exit_code = SUCCESS if payload['ok'] else DEPENDENCY_ERROR
|
||||
self.execution_service.complete_payload_with_text(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=self.presenter.build_health_text,
|
||||
default_exit_code=exit_code,
|
||||
)
|
||||
|
||||
def deps(self, env: str, output: str, *, include_dev: bool) -> None:
|
||||
"""
|
||||
检查当前 CLI 和后端运行依赖版本。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:param include_dev: 是否附带输出开发依赖
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
payload = self.operations_runtime.get_dependency_versions(include_dev=include_dev)
|
||||
exit_code = SUCCESS if payload.get('ok', False) else DEPENDENCY_ERROR
|
||||
self.execution_service.complete_payload_with_text(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=self.presenter.build_dependencies_text,
|
||||
default_exit_code=exit_code,
|
||||
)
|
||||
|
||||
def server_info(self, env: str, output: str) -> None:
|
||||
"""
|
||||
输出服务器运行时信息。
|
||||
|
||||
:param env: 当前命令运行环境
|
||||
:param output: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
ctx = self.context_factory.build_readonly(env, output)
|
||||
payload = self.execution_service.run_async(self.operations_runtime.get_server_info())
|
||||
self.execution_service.complete_payload_with_text(
|
||||
ctx,
|
||||
payload,
|
||||
text_builder=self.presenter.build_server_info_text,
|
||||
default_exit_code=SUCCESS,
|
||||
text_condition=lambda data: data.get('ok', False),
|
||||
)
|
||||
@@ -0,0 +1,177 @@
|
||||
from cli.utils import SHELL_TEXT_FORMATTER
|
||||
|
||||
|
||||
class OpsCommandPresenter:
|
||||
"""
|
||||
运维命令文本渲染器。
|
||||
|
||||
该渲染器负责将 `ops` 命令组产生的结构化 payload 转换为稳定的文本摘要,
|
||||
同时保持 JSON 输出仍由控制器直接返回,不在此处做契约变形。
|
||||
"""
|
||||
|
||||
def build_health_text(self, payload: dict[str, object]) -> str:
|
||||
"""
|
||||
将基础健康检查结果渲染为文本摘要。
|
||||
|
||||
:param payload: 健康检查结果字典
|
||||
:return: 文本摘要
|
||||
"""
|
||||
return '\n'.join(
|
||||
[
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'env: {payload.get("env", "")}',
|
||||
'checks:',
|
||||
self._build_check_status_line('database', payload.get('database')),
|
||||
self._build_check_status_line('redis', payload.get('redis')),
|
||||
]
|
||||
)
|
||||
|
||||
def build_server_info_text(self, payload: dict[str, object]) -> str:
|
||||
"""
|
||||
将服务器运行时信息渲染为文本摘要。
|
||||
|
||||
:param payload: 服务器运行时信息结果字典
|
||||
:return: 文本摘要
|
||||
"""
|
||||
server = payload.get('server')
|
||||
if not isinstance(server, dict):
|
||||
return '\n'.join(
|
||||
[
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
'server: none',
|
||||
]
|
||||
)
|
||||
|
||||
sys_info = server.get('sys')
|
||||
cpu_info = server.get('cpu')
|
||||
mem_info = server.get('mem')
|
||||
py_info = server.get('py')
|
||||
sys_files = server.get('sysFiles')
|
||||
|
||||
lines = [f'ok: {str(payload.get("ok", False)).lower()}']
|
||||
if isinstance(sys_info, dict):
|
||||
lines.extend(
|
||||
[
|
||||
'host:',
|
||||
f' name: {sys_info.get("computerName", "-")}',
|
||||
f' ip: {sys_info.get("computerIp", "-")}',
|
||||
f' os: {SHELL_TEXT_FORMATTER.truncate_text(sys_info.get("osName", "-"), 90)}',
|
||||
f' arch: {sys_info.get("osArch", "-")}',
|
||||
f' user_dir: {SHELL_TEXT_FORMATTER.truncate_text(sys_info.get("userDir", "-"), 100)}',
|
||||
]
|
||||
)
|
||||
|
||||
if isinstance(cpu_info, dict):
|
||||
lines.extend(
|
||||
[
|
||||
'cpu:',
|
||||
f' cores: {cpu_info.get("cpuNum", "-")}',
|
||||
f' used: {cpu_info.get("used", "-")}%',
|
||||
f' sys: {cpu_info.get("sys", "-")}%',
|
||||
f' free: {cpu_info.get("free", "-")}%',
|
||||
]
|
||||
)
|
||||
|
||||
if isinstance(mem_info, dict):
|
||||
lines.extend(
|
||||
[
|
||||
'memory:',
|
||||
f' total: {mem_info.get("total", "-")}',
|
||||
f' used: {mem_info.get("used", "-")}',
|
||||
f' free: {mem_info.get("free", "-")}',
|
||||
f' usage: {mem_info.get("usage", "-")}%',
|
||||
]
|
||||
)
|
||||
|
||||
if isinstance(py_info, dict):
|
||||
lines.extend(
|
||||
[
|
||||
'python:',
|
||||
f' name: {py_info.get("name", "-")}',
|
||||
f' version: {py_info.get("version", "-")}',
|
||||
f' start_time: {py_info.get("startTime", "-")}',
|
||||
f' run_time: {py_info.get("runTime", "-")}',
|
||||
f' home: {SHELL_TEXT_FORMATTER.truncate_text(py_info.get("home", "-"), 100)}',
|
||||
f' process_memory: {py_info.get("used", "-")} / {py_info.get("total", "-")} ({py_info.get("usage", "-")}%)',
|
||||
]
|
||||
)
|
||||
|
||||
if isinstance(sys_files, list):
|
||||
lines.append(f'disks: {len(sys_files)}')
|
||||
if sys_files:
|
||||
lines.append('disk_samples:')
|
||||
lines.extend(
|
||||
f' - {item.get("dirName", "-")} | used: {item.get("used", "-")} / {item.get("total", "-")} | usage: {item.get("usage", "-")}'
|
||||
for item in sys_files[:10]
|
||||
if isinstance(item, dict)
|
||||
)
|
||||
|
||||
return '\n'.join(lines)
|
||||
|
||||
def build_dependencies_text(self, payload: dict[str, object]) -> str:
|
||||
"""
|
||||
将依赖版本检查结果渲染为文本摘要。
|
||||
|
||||
:param payload: 依赖版本检查结果字典
|
||||
:return: 文本摘要
|
||||
"""
|
||||
packages = payload.get('packages')
|
||||
lines = [
|
||||
f'ok: {str(payload.get("ok", False)).lower()}',
|
||||
f'message: {payload.get("message", "-")}',
|
||||
f'include_dev: {str(payload.get("includeDev", False)).lower()}',
|
||||
]
|
||||
missing_required = payload.get('missingRequired')
|
||||
if isinstance(missing_required, list) and missing_required:
|
||||
lines.append('missing_required:')
|
||||
lines.extend(f' - {item}' for item in missing_required)
|
||||
else:
|
||||
lines.append('missing_required: none')
|
||||
|
||||
if not isinstance(packages, dict) or not packages:
|
||||
lines.append('packages: none')
|
||||
return '\n'.join(lines)
|
||||
|
||||
lines.append('packages:')
|
||||
for package_name in sorted(packages):
|
||||
dependency_payload = packages.get(package_name)
|
||||
if isinstance(dependency_payload, dict):
|
||||
lines.append(self._build_dependency_line(package_name, dependency_payload))
|
||||
return '\n'.join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _build_check_status_line(name: str, status_payload: dict[str, object] | None) -> str:
|
||||
"""
|
||||
构建单项检查结果摘要行。
|
||||
|
||||
:param name: 检查项名称
|
||||
:param status_payload: 检查结果字典
|
||||
:return: 摘要行文本
|
||||
"""
|
||||
if not isinstance(status_payload, dict):
|
||||
return f' {name}: unknown'
|
||||
|
||||
ok = str(status_payload.get('ok', False)).lower()
|
||||
message = status_payload.get('message', '-') or '-'
|
||||
error = status_payload.get('error')
|
||||
if error:
|
||||
return f' {name}: {ok} | {message} | error: {SHELL_TEXT_FORMATTER.truncate_text(error, 120)}'
|
||||
return f' {name}: {ok} | {message}'
|
||||
|
||||
@staticmethod
|
||||
def _build_dependency_line(name: str, dependency_payload: dict[str, object] | None) -> str:
|
||||
"""
|
||||
构建单个依赖项的文本摘要行。
|
||||
|
||||
:param name: 依赖项名称
|
||||
:param dependency_payload: 依赖项结果字典
|
||||
:return: 文本摘要行
|
||||
"""
|
||||
if not isinstance(dependency_payload, dict):
|
||||
return f' {name}: not-installed'
|
||||
|
||||
installed = str(dependency_payload.get('installed', False)).lower()
|
||||
version = dependency_payload.get('version', '-') or '-'
|
||||
required = str(dependency_payload.get('required', False)).lower()
|
||||
distribution = dependency_payload.get('distribution', '') or name
|
||||
return f' {name}: {installed} | version: {version} | required: {required} | dist: {distribution}'
|
||||
@@ -0,0 +1,210 @@
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
|
||||
import click
|
||||
import typer
|
||||
|
||||
from cli.context import CliContext
|
||||
from cli.exit_codes import GUARD_REJECTED
|
||||
from cli.output import CommandResult
|
||||
|
||||
DangerousCommandRiskLevel = Literal['high', 'normal']
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DangerousCommandRule:
|
||||
"""
|
||||
危险命令保护规则。
|
||||
|
||||
:param command_name: 命令唯一标识
|
||||
:param risk_level: 风险级别
|
||||
:param supports_dry_run: 是否支持演练执行
|
||||
"""
|
||||
|
||||
command_name: str
|
||||
risk_level: DangerousCommandRiskLevel
|
||||
supports_dry_run: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DangerousCommandRuleRegistry:
|
||||
"""
|
||||
危险命令规则注册表。
|
||||
|
||||
该注册表负责维护 CLI 内所有危险命令的风险元数据,并提供查询与
|
||||
强制获取能力,作为上下文工厂、风险元数据与测试的统一入口。
|
||||
|
||||
:param rules: 按命令名索引的危险命令规则表
|
||||
"""
|
||||
|
||||
rules: dict[str, DangerousCommandRule]
|
||||
|
||||
def get_rule(self, command_name: str) -> DangerousCommandRule | None:
|
||||
"""
|
||||
获取指定命令的危险命令规则。
|
||||
|
||||
:param command_name: 命令唯一标识
|
||||
:return: 命令规则,不存在时返回 None
|
||||
"""
|
||||
return self.rules.get(command_name)
|
||||
|
||||
def require_rule(self, command_name: str) -> DangerousCommandRule:
|
||||
"""
|
||||
获取指定命令的危险命令规则,不存在时抛出异常。
|
||||
|
||||
:param command_name: 命令唯一标识
|
||||
:return: 命令规则
|
||||
:raises ValueError: 命令未注册危险命令规则时抛出
|
||||
"""
|
||||
rule = self.get_rule(command_name)
|
||||
if rule is None:
|
||||
raise ValueError(f'危险命令未注册保护规则:{command_name}')
|
||||
return rule
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DangerousCommandResultBuilder:
|
||||
"""
|
||||
危险命令结果构建器。
|
||||
|
||||
该对象负责统一构建危险命令拒绝结果,避免保护服务内部继续拼装
|
||||
结构化负载细节。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_guard_reject_result(message: str, hint: str) -> CommandResult:
|
||||
"""
|
||||
构建危险命令拒绝结果。
|
||||
|
||||
:param message: 拒绝原因
|
||||
:param hint: 补充提示
|
||||
:return: 命令拒绝结果
|
||||
"""
|
||||
return CommandResult(
|
||||
data={
|
||||
'ok': False,
|
||||
'message': message,
|
||||
'hint': hint,
|
||||
},
|
||||
exit_code=GUARD_REJECTED,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DangerousCommandConfirmationService:
|
||||
"""
|
||||
危险命令确认服务。
|
||||
|
||||
该对象负责处理 TTY 检测、确认提示与交互取消异常收口。
|
||||
|
||||
:param result_builder: 危险命令结果构建器
|
||||
"""
|
||||
|
||||
result_builder: DangerousCommandResultBuilder
|
||||
|
||||
def confirm(self, ctx: CliContext, *, command_name: str) -> CommandResult | None:
|
||||
"""
|
||||
执行危险命令交互确认。
|
||||
|
||||
:param ctx: CLI 上下文
|
||||
:param command_name: 命令唯一标识
|
||||
:return: 拒绝结果或 None
|
||||
"""
|
||||
if ctx.yes:
|
||||
return None
|
||||
|
||||
if not sys.stdin.isatty():
|
||||
return self.result_builder.build_guard_reject_result(
|
||||
f'已取消危险命令执行:{command_name}',
|
||||
'当前命令需要交互确认;如需非交互执行,请传入 --yes',
|
||||
)
|
||||
|
||||
try:
|
||||
confirmed = typer.confirm(
|
||||
f'确认执行危险命令 `{command_name}` 吗? 当前环境:{ctx.env}{"(dry-run)" if ctx.dry_run else ""}',
|
||||
default=False,
|
||||
)
|
||||
except (click.Abort, EOFError, KeyboardInterrupt):
|
||||
return self.result_builder.build_guard_reject_result(
|
||||
f'已取消危险命令执行:{command_name}',
|
||||
'当前命令需要交互确认;如需非交互执行,请传入 --yes',
|
||||
)
|
||||
if confirmed:
|
||||
return None
|
||||
|
||||
return self.result_builder.build_guard_reject_result(
|
||||
f'已取消危险命令执行:{command_name}',
|
||||
'如需跳过确认,请传入 --yes',
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DangerousCommandGuardService:
|
||||
"""
|
||||
危险命令保护执行服务。
|
||||
|
||||
该服务负责根据命令规则和 CLI 上下文执行生产环境保护、交互确认
|
||||
与拒绝结果收口。
|
||||
|
||||
:param rule_registry: 危险命令规则注册表
|
||||
:param result_builder: 危险命令结果构建器
|
||||
:param confirmation_service: 危险命令确认服务
|
||||
"""
|
||||
|
||||
rule_registry: DangerousCommandRuleRegistry
|
||||
result_builder: DangerousCommandResultBuilder = field(default_factory=DangerousCommandResultBuilder)
|
||||
confirmation_service: DangerousCommandConfirmationService = field(init=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""
|
||||
初始化危险命令保护服务依赖。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
self.confirmation_service = DangerousCommandConfirmationService(self.result_builder)
|
||||
|
||||
def guard(self, ctx: CliContext, *, rule: DangerousCommandRule) -> CommandResult | None:
|
||||
"""
|
||||
执行危险命令保护与确认。
|
||||
|
||||
:param ctx: CLI 上下文
|
||||
:param rule: 危险命令规则
|
||||
:return: 拒绝结果或 None
|
||||
"""
|
||||
command_name = rule.command_name
|
||||
if ctx.env == 'prod' and not ctx.allow_prod:
|
||||
return self.result_builder.build_guard_reject_result(
|
||||
f'生产环境默认禁止直接执行危险命令:{command_name}',
|
||||
'如确认执行,请传入 --allow-prod;如需跳过确认,请同时传入 --yes',
|
||||
)
|
||||
|
||||
return self.confirmation_service.confirm(ctx, command_name=command_name)
|
||||
|
||||
|
||||
DEFAULT_DANGEROUS_COMMAND_RULES: dict[str, DangerousCommandRule] = {
|
||||
'cache clear': DangerousCommandRule(command_name='cache clear', risk_level='high', supports_dry_run=True),
|
||||
'cache warmup': DangerousCommandRule(command_name='cache warmup', risk_level='normal', supports_dry_run=False),
|
||||
'db upgrade': DangerousCommandRule(command_name='db upgrade', risk_level='high', supports_dry_run=True),
|
||||
'db init': DangerousCommandRule(command_name='db init', risk_level='high', supports_dry_run=True),
|
||||
'db downgrade': DangerousCommandRule(command_name='db downgrade', risk_level='high', supports_dry_run=True),
|
||||
'db revision': DangerousCommandRule(command_name='db revision', risk_level='high', supports_dry_run=True),
|
||||
'config set': DangerousCommandRule(command_name='config set', risk_level='high', supports_dry_run=True),
|
||||
'config sync-cache': DangerousCommandRule(
|
||||
command_name='config sync-cache',
|
||||
risk_level='normal',
|
||||
supports_dry_run=False,
|
||||
),
|
||||
'crypto rotate': DangerousCommandRule(command_name='crypto rotate', risk_level='high', supports_dry_run=True),
|
||||
'job run-once': DangerousCommandRule(command_name='job run-once', risk_level='normal', supports_dry_run=False),
|
||||
'job pause': DangerousCommandRule(command_name='job pause', risk_level='normal', supports_dry_run=False),
|
||||
'job resume': DangerousCommandRule(command_name='job resume', risk_level='normal', supports_dry_run=False),
|
||||
'job sync': DangerousCommandRule(command_name='job sync', risk_level='normal', supports_dry_run=False),
|
||||
'gen import-table': DangerousCommandRule(command_name='gen import-table', risk_level='high', supports_dry_run=True),
|
||||
'gen create-table': DangerousCommandRule(command_name='gen create-table', risk_level='high', supports_dry_run=True),
|
||||
'gen export': DangerousCommandRule(command_name='gen export', risk_level='high', supports_dry_run=True),
|
||||
'gen sync-db': DangerousCommandRule(command_name='gen sync-db', risk_level='normal', supports_dry_run=False),
|
||||
}
|
||||
|
||||
DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY = DangerousCommandRuleRegistry(rules=DEFAULT_DANGEROUS_COMMAND_RULES)
|
||||
DEFAULT_DANGEROUS_COMMAND_GUARD = DangerousCommandGuardService(rule_registry=DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY)
|
||||
@@ -0,0 +1,100 @@
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
|
||||
from cli.core import (
|
||||
DEFAULT_CORE_SERVICES,
|
||||
CliApplicationBuilder,
|
||||
CompletionDispatcher,
|
||||
ProjectRuntimeLocator,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CliImportArgvScope:
|
||||
"""
|
||||
CLI 导入期参数作用域。
|
||||
|
||||
该对象负责在根应用构建阶段暂时切换 `sys.argv`,只保留导入期
|
||||
需要的最小参数集,避免该过程继续以内联 `try/finally` 形式散落
|
||||
在入口运行器中。
|
||||
|
||||
:param project_runtime_locator: CLI 项目运行时定位器
|
||||
"""
|
||||
|
||||
project_runtime_locator: ProjectRuntimeLocator
|
||||
|
||||
def run(self, callback: object) -> object:
|
||||
"""
|
||||
在导入期参数作用域内执行回调。
|
||||
|
||||
:param callback: 待执行回调
|
||||
:return: 回调执行结果
|
||||
"""
|
||||
original_argv = list(sys.argv)
|
||||
try:
|
||||
sys.argv = self.project_runtime_locator.extract_import_argv(original_argv)
|
||||
return callback()
|
||||
finally:
|
||||
sys.argv = original_argv
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CliMainRunner:
|
||||
"""
|
||||
CLI 根入口运行器。
|
||||
|
||||
该运行器负责串联项目目录定位、导入期参数裁剪、根应用构建、
|
||||
completion 分发与 Typer 应用启动,避免 `main.py` 继续停留在
|
||||
模块级散乱编排状态。
|
||||
|
||||
:param project_runtime_locator: CLI 项目运行时定位器
|
||||
:param completion_dispatcher: shell completion 分发器
|
||||
:param application_builder: 根应用构建器
|
||||
"""
|
||||
|
||||
project_runtime_locator: ProjectRuntimeLocator
|
||||
completion_dispatcher: CompletionDispatcher
|
||||
application_builder: CliApplicationBuilder
|
||||
import_argv_scope: CliImportArgvScope
|
||||
|
||||
def build_cli(self) -> object:
|
||||
"""
|
||||
构建 CLI 根应用,并在导入期仅保留最小化参数集。
|
||||
|
||||
:return: Typer 根应用
|
||||
"""
|
||||
self.project_runtime_locator.ensure_backend_dir_on_sys_path()
|
||||
return self.import_argv_scope.run(self.application_builder.build)
|
||||
|
||||
def run(self) -> None:
|
||||
"""
|
||||
执行 Typer CLI 根应用。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
cli = self.build_cli()
|
||||
self.completion_dispatcher.dispatch(cli)
|
||||
cli(prog_name='shierfeng')
|
||||
|
||||
|
||||
CLI_APPLICATION_BUILDER = CliApplicationBuilder(output_renderer=DEFAULT_CORE_SERVICES.output_renderer)
|
||||
PROJECT_RUNTIME_LOCATOR = ProjectRuntimeLocator()
|
||||
CLI_MAIN_RUNNER = CliMainRunner(
|
||||
project_runtime_locator=PROJECT_RUNTIME_LOCATOR,
|
||||
completion_dispatcher=CompletionDispatcher(),
|
||||
application_builder=CLI_APPLICATION_BUILDER,
|
||||
import_argv_scope=CliImportArgvScope(project_runtime_locator=PROJECT_RUNTIME_LOCATOR),
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""
|
||||
执行 CLI 根入口运行器。
|
||||
|
||||
:return: None
|
||||
"""
|
||||
CLI_MAIN_RUNNER.run()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,10 @@
|
||||
from cli.metadata.command_specs import COMPLETION_SHELL_SPEC_REGISTRY, CompletionShellSpec, CompletionShellSpecRegistry
|
||||
from cli.metadata.option_specs import ENVIRONMENT_OPTION_SERVICE, EnvironmentOptionService
|
||||
|
||||
__all__ = [
|
||||
'COMPLETION_SHELL_SPEC_REGISTRY',
|
||||
'ENVIRONMENT_OPTION_SERVICE',
|
||||
'CompletionShellSpec',
|
||||
'CompletionShellSpecRegistry',
|
||||
'EnvironmentOptionService',
|
||||
]
|
||||
@@ -0,0 +1,98 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CompletionShellSpec:
|
||||
"""
|
||||
Shell completion 元数据定义。
|
||||
|
||||
name: shell 名称
|
||||
description: shell 描述文本
|
||||
generator: 脚本生成方式
|
||||
default_target: 默认脚本落盘路径,相对于用户 home 目录
|
||||
default_rc_file: 默认 rc 文件路径,相对于用户 home 目录
|
||||
auto_discovery: 是否会被 shell 自动发现
|
||||
supported: 当前版本是否已支持
|
||||
"""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
generator: Literal['click', 'custom', 'unsupported']
|
||||
default_target: str
|
||||
default_rc_file: str | None
|
||||
auto_discovery: bool
|
||||
supported: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CompletionShellSpecRegistry:
|
||||
"""
|
||||
completion shell 元数据注册表。
|
||||
|
||||
该注册表集中维护 CLI 当前支持的 shell completion 元数据,
|
||||
供 installer、doctor 和 provider 等场景统一查询。
|
||||
|
||||
:param specs: 按 shell 名称索引的元数据表
|
||||
"""
|
||||
|
||||
specs: dict[str, CompletionShellSpec]
|
||||
|
||||
def get_spec(self, shell_name: str) -> CompletionShellSpec | None:
|
||||
"""
|
||||
获取指定 shell 的 completion 元数据。
|
||||
|
||||
:param shell_name: shell 名称
|
||||
:return: shell 元数据,不存在时返回 None
|
||||
"""
|
||||
return self.specs.get(shell_name)
|
||||
|
||||
def list_shell_names(self) -> list[str]:
|
||||
"""
|
||||
获取已注册的 shell 名称列表。
|
||||
|
||||
:return: shell 名称列表
|
||||
"""
|
||||
return list(self.specs)
|
||||
|
||||
|
||||
DEFAULT_COMPLETION_SHELL_SPECS: dict[str, CompletionShellSpec] = {
|
||||
'bash': CompletionShellSpec(
|
||||
name='bash',
|
||||
description='GNU Bash shell completion',
|
||||
generator='click',
|
||||
default_target='.local/share/shierfeng/completion/shierfeng.bash',
|
||||
default_rc_file='.bashrc',
|
||||
auto_discovery=False,
|
||||
supported=True,
|
||||
),
|
||||
'zsh': CompletionShellSpec(
|
||||
name='zsh',
|
||||
description='Zsh shell completion',
|
||||
generator='click',
|
||||
default_target='.local/share/shierfeng/completion/shierfeng.zsh',
|
||||
default_rc_file='.zshrc',
|
||||
auto_discovery=False,
|
||||
supported=True,
|
||||
),
|
||||
'fish': CompletionShellSpec(
|
||||
name='fish',
|
||||
description='Fish shell completion',
|
||||
generator='click',
|
||||
default_target='.config/fish/completions/shierfeng.fish',
|
||||
default_rc_file=None,
|
||||
auto_discovery=True,
|
||||
supported=True,
|
||||
),
|
||||
'powershell': CompletionShellSpec(
|
||||
name='powershell',
|
||||
description='Windows PowerShell / PowerShell 7 completion',
|
||||
generator='custom',
|
||||
default_target='Documents/PowerShell/Profile/shierfeng.ps1',
|
||||
default_rc_file='Documents/PowerShell/Microsoft.PowerShell_profile.ps1',
|
||||
auto_discovery=False,
|
||||
supported=True,
|
||||
),
|
||||
}
|
||||
|
||||
COMPLETION_SHELL_SPEC_REGISTRY = CompletionShellSpecRegistry(specs=DEFAULT_COMPLETION_SHELL_SPECS)
|
||||
@@ -0,0 +1,36 @@
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_ENVIRONMENTS = ('dev', 'prod', 'dockermy', 'dockerpg')
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EnvironmentOptionService:
|
||||
"""
|
||||
CLI 环境选项服务。
|
||||
|
||||
该服务负责发现当前项目下可用的环境名称集合,供 completion、
|
||||
wizard 和诊断信息等场景复用。
|
||||
|
||||
:param default_environments: 默认内置环境名称列表
|
||||
"""
|
||||
|
||||
default_environments: tuple[str, ...] = DEFAULT_ENVIRONMENTS
|
||||
|
||||
def discover_env_names(self, project_dir: Path | None = None) -> list[str]:
|
||||
"""
|
||||
发现当前项目可用的环境名称列表。
|
||||
|
||||
:param project_dir: 后端项目目录,默认使用当前工作目录
|
||||
:return: 去重且排序后的环境名称列表
|
||||
"""
|
||||
resolved_project_dir = (project_dir or Path.cwd()).resolve()
|
||||
env_names = set(self.default_environments)
|
||||
for env_file in resolved_project_dir.glob('.env.*'):
|
||||
suffix = env_file.name.removeprefix('.env.').strip()
|
||||
if suffix:
|
||||
env_names.add(suffix)
|
||||
return sorted(env_names)
|
||||
|
||||
|
||||
ENVIRONMENT_OPTION_SERVICE = EnvironmentOptionService()
|
||||
@@ -0,0 +1,75 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
from cli.guards import DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY, DangerousCommandRuleRegistry
|
||||
|
||||
CommandRiskLevel = Literal['readonly', 'normal', 'high']
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommandRiskSpec:
|
||||
"""
|
||||
CLI 命令风险元数据定义。
|
||||
|
||||
:param command_name: 命令唯一标识
|
||||
:param risk_level: 风险级别
|
||||
:param supports_dry_run: 是否支持演练执行
|
||||
"""
|
||||
|
||||
command_name: str
|
||||
risk_level: CommandRiskLevel
|
||||
supports_dry_run: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommandRiskSpecRegistry:
|
||||
"""
|
||||
CLI 命令风险元数据注册表。
|
||||
|
||||
该注册表负责从危险命令规则注册表派生风险元数据,供 TUI、文档、
|
||||
诊断提示等只读场景统一查询。
|
||||
|
||||
:param specs: 按命令名索引的风险元数据表
|
||||
"""
|
||||
|
||||
specs: dict[str, CommandRiskSpec]
|
||||
|
||||
def get_spec(self, command_name: str) -> CommandRiskSpec | None:
|
||||
"""
|
||||
获取指定命令的风险元数据。
|
||||
|
||||
:param command_name: 命令唯一标识
|
||||
:return: 风险元数据,不存在时返回 None
|
||||
"""
|
||||
return self.specs.get(command_name)
|
||||
|
||||
|
||||
class CommandRiskSpecRegistryBuilder:
|
||||
"""
|
||||
CLI 命令风险元数据注册表构建器。
|
||||
|
||||
该构建器负责基于危险命令规则注册表生成命令风险元数据注册表,
|
||||
避免模块导入阶段散落重复的字典推导逻辑。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build(rule_registry: DangerousCommandRuleRegistry) -> CommandRiskSpecRegistry:
|
||||
"""
|
||||
基于危险命令规则注册表构建风险元数据注册表。
|
||||
|
||||
:param rule_registry: 危险命令规则注册表
|
||||
:return: 风险元数据注册表
|
||||
"""
|
||||
return CommandRiskSpecRegistry(
|
||||
specs={
|
||||
command_name: CommandRiskSpec(
|
||||
command_name=rule.command_name,
|
||||
risk_level=rule.risk_level,
|
||||
supports_dry_run=rule.supports_dry_run,
|
||||
)
|
||||
for command_name, rule in rule_registry.rules.items()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
COMMAND_RISK_SPEC_REGISTRY = CommandRiskSpecRegistryBuilder.build(DEFAULT_DANGEROUS_COMMAND_RULE_REGISTRY)
|
||||
@@ -0,0 +1,873 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import typer
|
||||
|
||||
from cli.context import CliContext
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutputSettings:
|
||||
"""
|
||||
输出渲染配置。
|
||||
|
||||
:param color_mode: 颜色模式
|
||||
:param icon_mode: 图标模式
|
||||
"""
|
||||
|
||||
color_mode: str = 'always'
|
||||
icon_mode: str = 'emoji'
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StatusTokenDefinition:
|
||||
"""
|
||||
状态标签定义。
|
||||
|
||||
:param emoji: emoji 模式文案
|
||||
:param ascii: ASCII 模式文案
|
||||
:param none: 无图标模式文案
|
||||
:param color: 默认前景色
|
||||
"""
|
||||
|
||||
emoji: str
|
||||
ascii: str
|
||||
none: str
|
||||
color: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandResult:
|
||||
"""
|
||||
统一命令执行结果。
|
||||
|
||||
:param data: 命令结果负载
|
||||
:param exit_code: 命令退出码
|
||||
:param already_printed: 是否已由上层提前输出
|
||||
"""
|
||||
|
||||
data: Any = None
|
||||
exit_code: int = 0
|
||||
already_printed: bool = False
|
||||
|
||||
|
||||
class OutputStatusStyler:
|
||||
"""
|
||||
统一负责状态标签与单行文本样式。
|
||||
|
||||
:param settings: 输出配置
|
||||
"""
|
||||
|
||||
_STATUS_DEFINITIONS = {
|
||||
'ok': StatusTokenDefinition(emoji='✅ OK', ascii='[OK]', none='OK', color='green'),
|
||||
'fail': StatusTokenDefinition(emoji='❌ FAIL', ascii='[FAIL]', none='FAIL', color='red'),
|
||||
'warn': StatusTokenDefinition(emoji='💡 HINT', ascii='[HINT]', none='HINT', color='yellow'),
|
||||
'error': StatusTokenDefinition(emoji='🚨 ERROR', ascii='[ERROR]', none='ERROR', color='red'),
|
||||
'info': StatusTokenDefinition(emoji='💬 INFO', ascii='[INFO]', none='INFO', color='blue'),
|
||||
}
|
||||
_STATUS_FIELD_KIND_MAPPING = {
|
||||
'message': 'info',
|
||||
'info': 'info',
|
||||
'hint': 'warn',
|
||||
'warn': 'warn',
|
||||
'warning': 'warn',
|
||||
'error': 'error',
|
||||
}
|
||||
|
||||
def __init__(self, settings: OutputSettings) -> None:
|
||||
"""
|
||||
初始化状态样式器。
|
||||
|
||||
:param settings: 输出配置
|
||||
:return: None
|
||||
"""
|
||||
self.settings = settings
|
||||
|
||||
def supports_color(self, stream: Any) -> bool:
|
||||
"""
|
||||
判断当前输出流是否应启用颜色。
|
||||
|
||||
:param stream: 输出流对象
|
||||
:return: 是否启用颜色
|
||||
"""
|
||||
if self.settings.color_mode == 'always':
|
||||
return True
|
||||
if self.settings.color_mode == 'never':
|
||||
return False
|
||||
if os.environ.get('NO_COLOR'):
|
||||
return False
|
||||
if os.environ.get('TERM') == 'dumb':
|
||||
return False
|
||||
return bool(getattr(stream, 'isatty', lambda: False)())
|
||||
|
||||
@staticmethod
|
||||
def style_text(text: str, *, fg: str | None = None, bold: bool = False, dim: bool = False) -> str:
|
||||
"""
|
||||
为文本附加终端样式。
|
||||
|
||||
:param text: 原始文本
|
||||
:param fg: 前景色
|
||||
:param bold: 是否加粗
|
||||
:param dim: 是否弱化显示
|
||||
:return: 样式化后的文本
|
||||
"""
|
||||
return typer.style(text, fg=fg, bold=bold, dim=dim)
|
||||
|
||||
def get_status_definition(self, kind: str) -> StatusTokenDefinition:
|
||||
"""
|
||||
获取状态标签定义。
|
||||
|
||||
:param kind: 状态类型
|
||||
:return: 状态标签定义
|
||||
"""
|
||||
return self._STATUS_DEFINITIONS.get(kind, self._STATUS_DEFINITIONS['info'])
|
||||
|
||||
def build_status_token(self, kind: str) -> str:
|
||||
"""
|
||||
根据当前图标模式构建状态标签文本。
|
||||
|
||||
:param kind: 标签类型
|
||||
:return: 标签文本
|
||||
"""
|
||||
definition = self.get_status_definition(kind)
|
||||
return getattr(definition, self.settings.icon_mode, definition.none)
|
||||
|
||||
def style_status_token(self, kind: str, *, fg: str | None = None) -> str:
|
||||
"""
|
||||
将状态标签渲染为高亮样式。
|
||||
|
||||
:param kind: 标签类型
|
||||
:param fg: 前景色
|
||||
:return: 样式化后的标签文本
|
||||
"""
|
||||
definition = self.get_status_definition(kind)
|
||||
return self.style_text(self.build_status_token(kind), fg=fg or definition.color, bold=True)
|
||||
|
||||
def style_status_message(self, kind: str, text: str) -> str:
|
||||
"""
|
||||
使用统一状态图标和颜色渲染消息文本。
|
||||
|
||||
:param kind: 状态类型
|
||||
:param text: 原始消息文本
|
||||
:return: 样式化后的消息文本
|
||||
"""
|
||||
color = self.get_status_definition(kind).color
|
||||
return f'{self.style_status_token(kind, fg=color)} {self.style_text(text, fg=color, bold=kind == "error")}'
|
||||
|
||||
def build_result_header(self, is_ok: bool) -> str:
|
||||
"""
|
||||
构建统一的结果头部文本。
|
||||
|
||||
:param is_ok: 是否成功
|
||||
:return: 结果头部文本
|
||||
"""
|
||||
if is_ok:
|
||||
return f'{self.build_status_token("ok")} SUCCESS'
|
||||
return f'{self.build_status_token("fail")} FAILED'
|
||||
|
||||
def style_scalar_text(self, value: str) -> str:
|
||||
"""
|
||||
为标量文本值应用颜色样式。
|
||||
|
||||
:param value: 原始标量文本
|
||||
:return: 样式化后的文本
|
||||
"""
|
||||
normalized_value = value.strip().lower()
|
||||
if normalized_value == 'true':
|
||||
return self.style_text(value, fg='green', bold=True)
|
||||
if normalized_value == 'false':
|
||||
return self.style_text(value, fg='red', bold=True)
|
||||
if normalized_value in {'null', 'none', '-'}:
|
||||
return self.style_text(value, fg='bright_black')
|
||||
return value
|
||||
|
||||
def style_inline_status_segment(self, segment: str) -> str:
|
||||
"""
|
||||
为行内状态片段应用样式。
|
||||
|
||||
:param segment: 原始片段文本
|
||||
:return: 样式化后的片段文本
|
||||
"""
|
||||
normalized_segment = segment.strip().lower()
|
||||
if normalized_segment == 'true':
|
||||
return self.style_status_token('ok', fg='green')
|
||||
if normalized_segment == 'false':
|
||||
return self.style_status_token('fail', fg='red')
|
||||
for prefix, kind in self._STATUS_FIELD_KIND_MAPPING.items():
|
||||
if normalized_segment.startswith(f'{prefix}:'):
|
||||
status_message = segment.split(':', 1)[1].strip()
|
||||
return self.style_status_message(kind, status_message)
|
||||
if ': ' in segment:
|
||||
segment_key, segment_value = segment.split(': ', 1)
|
||||
styled_segment_value = self.style_inline_status_value(segment_value)
|
||||
if styled_segment_value != segment_value:
|
||||
return f'{segment_key}: {styled_segment_value}'
|
||||
return segment
|
||||
|
||||
def style_inline_status_value(self, value: str) -> str:
|
||||
"""
|
||||
为带分隔符的行内状态值应用样式。
|
||||
|
||||
:param value: 原始字段值
|
||||
:return: 样式化后的字段值
|
||||
"""
|
||||
if ' | ' not in value:
|
||||
styled_value = self.style_inline_status_segment(value)
|
||||
if styled_value != value:
|
||||
return styled_value
|
||||
return self.style_scalar_text(value)
|
||||
return ' | '.join(self.style_inline_status_segment(segment) for segment in value.split(' | '))
|
||||
|
||||
def style_named_value(self, key: str, value: str) -> str:
|
||||
"""
|
||||
按字段语义为值应用样式。
|
||||
|
||||
:param key: 字段名
|
||||
:param value: 原始字段值
|
||||
:return: 样式化后的字段值
|
||||
"""
|
||||
normalized_key = key.strip().lower()
|
||||
normalized_value = value.strip().lower()
|
||||
|
||||
if normalized_key == 'ok':
|
||||
if normalized_value == 'true':
|
||||
return self.style_status_token('ok', fg='green')
|
||||
if normalized_value == 'false':
|
||||
return self.style_status_token('fail', fg='red')
|
||||
|
||||
status_kind = self._STATUS_FIELD_KIND_MAPPING.get(normalized_key)
|
||||
if status_kind:
|
||||
return self.style_status_message(status_kind, value)
|
||||
|
||||
return self.style_inline_status_value(value)
|
||||
|
||||
def style_line(self, line: str) -> str:
|
||||
"""
|
||||
对单行文本输出应用可读性增强样式。
|
||||
|
||||
:param line: 原始输出行
|
||||
:return: 样式化后的输出行
|
||||
"""
|
||||
if not line.strip():
|
||||
return line
|
||||
|
||||
leading_spaces = len(line) - len(line.lstrip(' '))
|
||||
indent = line[:leading_spaces]
|
||||
content = line[leading_spaces:]
|
||||
|
||||
if content == self.build_result_header(True):
|
||||
return (
|
||||
f'{indent}{self.style_status_token("ok", fg="green")} '
|
||||
f'{self.style_text("SUCCESS", fg="green", bold=True)}'
|
||||
)
|
||||
if content == self.build_result_header(False):
|
||||
return (
|
||||
f'{indent}{self.style_status_token("fail", fg="red")} {self.style_text("FAILED", fg="red", bold=True)}'
|
||||
)
|
||||
|
||||
if content.startswith('- '):
|
||||
return (
|
||||
f'{indent}{self.style_text("-", fg="bright_black", bold=True)} '
|
||||
f'{self.style_inline_status_value(content[2:])}'
|
||||
)
|
||||
|
||||
if ': ' in content:
|
||||
key, value = content.split(': ', 1)
|
||||
styled_key = self.style_text(f'{key}:', fg='cyan', bold=leading_spaces == 0)
|
||||
return f'{indent}{styled_key} {self.style_named_value(key, value)}'
|
||||
|
||||
if content.endswith(':'):
|
||||
return f'{indent}{self.style_text(content, fg="blue", bold=True)}'
|
||||
|
||||
if content == '|':
|
||||
return f'{indent}{self.style_text(content, fg="bright_black")}'
|
||||
|
||||
return line
|
||||
|
||||
def render_error_text(self, message: str, exit_code: int, stream: Any) -> str:
|
||||
"""
|
||||
渲染标准错误输出文本。
|
||||
|
||||
:param message: 错误消息
|
||||
:param exit_code: 退出码
|
||||
:param stream: 输出流对象
|
||||
:return: 渲染后的错误文本
|
||||
"""
|
||||
error_prefix = f'{self.build_status_token("error")}[{exit_code}]:'
|
||||
if not self.supports_color(stream):
|
||||
return f'{error_prefix} {message}'
|
||||
return (
|
||||
f'{self.style_status_token("error", fg="red")}[{exit_code}]: '
|
||||
f'{self.style_text(message, fg="red", bold=True)}'
|
||||
)
|
||||
|
||||
|
||||
class StructuredTextRenderer:
|
||||
"""
|
||||
负责结构化数据到文本的转换与装饰。
|
||||
|
||||
:param status_styler: 状态样式器
|
||||
"""
|
||||
|
||||
_SNAKE_CASE_BOUNDARY_PATTERN = re.compile(r'(?<!^)(?=[A-Z])')
|
||||
|
||||
def __init__(self, status_styler: OutputStatusStyler) -> None:
|
||||
"""
|
||||
初始化结构化文本渲染器。
|
||||
|
||||
:param status_styler: 状态样式器
|
||||
:return: None
|
||||
"""
|
||||
self.status_styler = status_styler
|
||||
|
||||
def render_text_output(self, data: Any) -> str:
|
||||
"""
|
||||
将任意数据渲染为纯文本输出。
|
||||
|
||||
:param data: 待渲染数据
|
||||
:return: 文本输出结果
|
||||
"""
|
||||
if isinstance(data, str):
|
||||
return data
|
||||
return '\n'.join(self.render_text_lines(data))
|
||||
|
||||
def colorize_text_output(self, text: str, stream: Any) -> str:
|
||||
"""
|
||||
按终端能力为文本输出增加颜色。
|
||||
|
||||
:param text: 原始文本输出
|
||||
:param stream: 输出流对象
|
||||
:return: 样式化后的文本输出
|
||||
"""
|
||||
if not self.status_styler.supports_color(stream):
|
||||
return text
|
||||
return '\n'.join(self.status_styler.style_line(line) for line in text.splitlines())
|
||||
|
||||
def decorate_text_output(self, text: str) -> str:
|
||||
"""
|
||||
对文本输出进行结构化装饰。
|
||||
|
||||
:param text: 原始文本输出
|
||||
:return: 装饰后的文本输出
|
||||
"""
|
||||
lines = text.splitlines()
|
||||
if not lines:
|
||||
return text
|
||||
|
||||
first_non_empty_index = next((index for index, line in enumerate(lines) if line.strip()), None)
|
||||
if first_non_empty_index is None:
|
||||
return text
|
||||
|
||||
first_line = lines[first_non_empty_index].strip().lower()
|
||||
if first_line == 'ok: true':
|
||||
decorated_lines = list(lines)
|
||||
decorated_lines[first_non_empty_index] = self.status_styler.build_result_header(True)
|
||||
return '\n'.join(decorated_lines)
|
||||
if first_line == 'ok: false':
|
||||
decorated_lines = list(lines)
|
||||
decorated_lines[first_non_empty_index] = self.status_styler.build_result_header(False)
|
||||
return '\n'.join(decorated_lines)
|
||||
return text
|
||||
|
||||
@staticmethod
|
||||
def format_scalar(value: Any) -> str:
|
||||
"""
|
||||
将标量值格式化为文本输出。
|
||||
|
||||
:param value: 待格式化的值
|
||||
:return: 文本格式化结果
|
||||
"""
|
||||
if value is None:
|
||||
return 'null'
|
||||
if isinstance(value, bool):
|
||||
return 'true' if value else 'false'
|
||||
return str(value)
|
||||
|
||||
@classmethod
|
||||
def format_field_name(cls, key: object) -> str:
|
||||
"""
|
||||
将字段名标准化为文本输出使用的 `snake_case` 形式。
|
||||
|
||||
:param key: 原始字段名
|
||||
:return: 标准化后的字段名
|
||||
"""
|
||||
text = str(key).strip()
|
||||
if not text:
|
||||
return ''
|
||||
normalized_text = text.replace('-', '_').replace(' ', '_')
|
||||
return cls._SNAKE_CASE_BOUNDARY_PATTERN.sub('_', normalized_text).lower()
|
||||
|
||||
def append_multiline_text(self, lines: list[str], prefix: str, value: str, indent: str) -> None:
|
||||
"""
|
||||
将多行文本追加到输出行列表中。
|
||||
|
||||
:param lines: 输出行列表
|
||||
:param prefix: 当前字段前缀
|
||||
:param value: 多行文本内容
|
||||
:param indent: 子级缩进
|
||||
:return: None
|
||||
"""
|
||||
lines.append(f'{prefix} |')
|
||||
lines.extend(f'{indent}{line}' for line in value.splitlines())
|
||||
|
||||
def render_nested_lines(self, prefix: str, nested_data: dict[str, Any] | list[Any], indent_level: int) -> list[str]:
|
||||
"""
|
||||
渲染嵌套字典或列表字段。
|
||||
|
||||
:param prefix: 当前字段前缀
|
||||
:param nested_data: 嵌套数据
|
||||
:param indent_level: 当前缩进层级
|
||||
:return: 渲染后的文本行列表
|
||||
"""
|
||||
nested_lines = self.render_text_lines(nested_data, indent_level=indent_level + 1)
|
||||
if len(nested_lines) == 1 and nested_lines[0].strip() in {'{}', '[]'}:
|
||||
return [f'{prefix} {nested_lines[0].strip()}']
|
||||
return [prefix, *nested_lines]
|
||||
|
||||
def render_mapping_lines(self, data: dict[str, Any], *, indent_level: int) -> list[str]:
|
||||
"""
|
||||
将字典渲染为层级化文本输出。
|
||||
|
||||
:param data: 待渲染字典
|
||||
:param indent_level: 当前缩进层级
|
||||
:return: 文本输出行列表
|
||||
"""
|
||||
indent = ' ' * indent_level
|
||||
child_indent = ' ' * (indent_level + 1)
|
||||
if not data:
|
||||
return [f'{indent}{{}}']
|
||||
|
||||
lines: list[str] = []
|
||||
for key, value in data.items():
|
||||
field_name = self.format_field_name(key)
|
||||
prefix = f'{indent}{field_name}:'
|
||||
if isinstance(value, str) and '\n' in value:
|
||||
self.append_multiline_text(lines, prefix, value, child_indent)
|
||||
continue
|
||||
if isinstance(value, dict | list):
|
||||
lines.extend(self.render_nested_lines(prefix, value, indent_level))
|
||||
continue
|
||||
lines.append(f'{prefix} {self.format_scalar(value)}')
|
||||
return lines
|
||||
|
||||
def render_list_lines(self, data: list[Any], *, indent_level: int) -> list[str]:
|
||||
"""
|
||||
将列表渲染为层级化文本输出。
|
||||
|
||||
:param data: 待渲染列表
|
||||
:param indent_level: 当前缩进层级
|
||||
:return: 文本输出行列表
|
||||
"""
|
||||
indent = ' ' * indent_level
|
||||
child_indent = ' ' * (indent_level + 1)
|
||||
if not data:
|
||||
return [f'{indent}[]']
|
||||
|
||||
lines: list[str] = []
|
||||
for item in data:
|
||||
item_prefix = f'{indent}-'
|
||||
if isinstance(item, str) and '\n' in item:
|
||||
self.append_multiline_text(lines, item_prefix, item, child_indent)
|
||||
continue
|
||||
if isinstance(item, dict | list):
|
||||
lines.extend(self.render_nested_lines(item_prefix, item, indent_level))
|
||||
continue
|
||||
lines.append(f'{item_prefix} {self.format_scalar(item)}')
|
||||
return lines
|
||||
|
||||
def render_text_lines(self, data: Any, *, indent_level: int = 0) -> list[str]:
|
||||
"""
|
||||
将任意数据结构渲染为层级化文本输出。
|
||||
|
||||
:param data: 待渲染数据
|
||||
:param indent_level: 当前缩进层级
|
||||
:return: 文本输出行列表
|
||||
"""
|
||||
if isinstance(data, dict):
|
||||
return self.render_mapping_lines(data, indent_level=indent_level)
|
||||
|
||||
if isinstance(data, list):
|
||||
return self.render_list_lines(data, indent_level=indent_level)
|
||||
|
||||
indent = ' ' * indent_level
|
||||
if isinstance(data, str) and '\n' in data:
|
||||
return [f'{indent}{line}' for line in data.splitlines()]
|
||||
|
||||
return [f'{indent}{self.format_scalar(data)}']
|
||||
|
||||
|
||||
class OutputEmitter:
|
||||
"""
|
||||
负责标准输出、标准错误与命令退出收口。
|
||||
|
||||
:param text_renderer: 结构化文本渲染器
|
||||
:param status_styler: 状态样式器
|
||||
"""
|
||||
|
||||
def __init__(self, text_renderer: StructuredTextRenderer, status_styler: OutputStatusStyler) -> None:
|
||||
"""
|
||||
初始化输出发射器。
|
||||
|
||||
:param text_renderer: 结构化文本渲染器
|
||||
:param status_styler: 状态样式器
|
||||
:return: None
|
||||
"""
|
||||
self.text_renderer = text_renderer
|
||||
self.status_styler = status_styler
|
||||
|
||||
def emit_output(self, data: Any, output_format: str) -> None:
|
||||
"""
|
||||
输出命令结果。
|
||||
|
||||
:param data: 输出数据
|
||||
:param output_format: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
if data is None:
|
||||
return
|
||||
if output_format == 'json':
|
||||
print(json.dumps(data, ensure_ascii=False, indent=2, default=str))
|
||||
return
|
||||
rendered_text = self.text_renderer.decorate_text_output(self.text_renderer.render_text_output(data))
|
||||
print(self.text_renderer.colorize_text_output(rendered_text, sys.stdout))
|
||||
|
||||
def emit_error(self, message: str, output_format: str, *, exit_code: int) -> None:
|
||||
"""
|
||||
输出错误信息。
|
||||
|
||||
:param message: 错误消息
|
||||
:param output_format: 输出格式
|
||||
:param exit_code: 退出码
|
||||
:return: None
|
||||
"""
|
||||
if output_format == 'json':
|
||||
print(
|
||||
json.dumps({'ok': False, 'error': message, 'exitCode': exit_code}, ensure_ascii=False),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return
|
||||
print(self.status_styler.render_error_text(message, exit_code, sys.stderr), file=sys.stderr)
|
||||
|
||||
def complete_command(self, result: CommandResult, ctx: CliContext) -> None:
|
||||
"""
|
||||
输出命令结果并结束当前命令。
|
||||
|
||||
:param result: 命令执行结果
|
||||
:param ctx: CLI 上下文
|
||||
:return: None
|
||||
:raises typer.Exit: 始终通过 Typer 退出并返回对应退出码
|
||||
"""
|
||||
if not result.already_printed:
|
||||
self.emit_output(result.data, ctx.output)
|
||||
raise typer.Exit(code=result.exit_code)
|
||||
|
||||
|
||||
class OutputRenderer:
|
||||
"""
|
||||
CLI 输出渲染门面。
|
||||
|
||||
:param color_mode: 颜色模式
|
||||
:param icon_mode: 图标模式
|
||||
"""
|
||||
|
||||
def __init__(self, *, color_mode: str = 'always', icon_mode: str = 'emoji') -> None:
|
||||
"""
|
||||
初始化输出渲染器。
|
||||
|
||||
:param color_mode: 颜色模式,支持 `auto`、`always`、`never`
|
||||
:param icon_mode: 图标模式,支持 `emoji`、`ascii`、`none`
|
||||
:return: None
|
||||
"""
|
||||
self.settings = OutputSettings(color_mode=color_mode, icon_mode=icon_mode)
|
||||
self.status_styler = OutputStatusStyler(self.settings)
|
||||
self.text_renderer = StructuredTextRenderer(self.status_styler)
|
||||
self.emitter = OutputEmitter(self.text_renderer, self.status_styler)
|
||||
|
||||
@property
|
||||
def color_mode(self) -> str:
|
||||
"""
|
||||
返回当前颜色模式。
|
||||
|
||||
:return: 颜色模式
|
||||
"""
|
||||
return self.settings.color_mode
|
||||
|
||||
@property
|
||||
def icon_mode(self) -> str:
|
||||
"""
|
||||
返回当前图标模式。
|
||||
|
||||
:return: 图标模式
|
||||
"""
|
||||
return self.settings.icon_mode
|
||||
|
||||
def set_color_mode(self, color_mode: str) -> None:
|
||||
"""
|
||||
设置颜色模式。
|
||||
|
||||
:param color_mode: 颜色模式
|
||||
:return: None
|
||||
"""
|
||||
self.settings.color_mode = color_mode
|
||||
|
||||
def set_icon_mode(self, icon_mode: str) -> None:
|
||||
"""
|
||||
设置图标模式。
|
||||
|
||||
:param icon_mode: 图标模式
|
||||
:return: None
|
||||
"""
|
||||
self.settings.icon_mode = icon_mode
|
||||
|
||||
def supports_color(self, stream: Any) -> bool:
|
||||
"""
|
||||
判断当前输出流是否应启用颜色。
|
||||
|
||||
:param stream: 输出流对象
|
||||
:return: 是否启用颜色
|
||||
"""
|
||||
return self.status_styler.supports_color(stream)
|
||||
|
||||
@staticmethod
|
||||
def style_text(text: str, *, fg: str | None = None, bold: bool = False, dim: bool = False) -> str:
|
||||
"""
|
||||
为文本附加终端样式。
|
||||
|
||||
:param text: 原始文本
|
||||
:param fg: 前景色
|
||||
:param bold: 是否加粗
|
||||
:param dim: 是否弱化显示
|
||||
:return: 样式化后的文本
|
||||
"""
|
||||
return OutputStatusStyler.style_text(text, fg=fg, bold=bold, dim=dim)
|
||||
|
||||
def build_status_token(self, kind: str) -> str:
|
||||
"""
|
||||
根据当前图标模式构建状态标签文本。
|
||||
|
||||
:param kind: 标签类型
|
||||
:return: 标签文本
|
||||
"""
|
||||
return self.status_styler.build_status_token(kind)
|
||||
|
||||
def style_status_token(self, kind: str, *, fg: str) -> str:
|
||||
"""
|
||||
将状态标签渲染为高亮样式。
|
||||
|
||||
:param kind: 标签类型
|
||||
:param fg: 前景色
|
||||
:return: 样式化后的标签文本
|
||||
"""
|
||||
return self.status_styler.style_status_token(kind, fg=fg)
|
||||
|
||||
def style_status_message(self, kind: str, text: str) -> str:
|
||||
"""
|
||||
使用统一状态图标和颜色渲染消息文本。
|
||||
|
||||
:param kind: 状态类型
|
||||
:param text: 原始消息文本
|
||||
:return: 样式化后的消息文本
|
||||
"""
|
||||
return self.status_styler.style_status_message(kind, text)
|
||||
|
||||
def build_result_header(self, is_ok: bool) -> str:
|
||||
"""
|
||||
构建统一的结果头部文本。
|
||||
|
||||
:param is_ok: 是否成功
|
||||
:return: 结果头部文本
|
||||
"""
|
||||
return self.status_styler.build_result_header(is_ok)
|
||||
|
||||
def style_scalar_text(self, value: str) -> str:
|
||||
"""
|
||||
为标量文本值应用颜色样式。
|
||||
|
||||
:param value: 原始标量文本
|
||||
:return: 样式化后的文本
|
||||
"""
|
||||
return self.status_styler.style_scalar_text(value)
|
||||
|
||||
def style_inline_status_segment(self, segment: str) -> str:
|
||||
"""
|
||||
为行内状态片段应用样式。
|
||||
|
||||
:param segment: 原始片段文本
|
||||
:return: 样式化后的片段文本
|
||||
"""
|
||||
return self.status_styler.style_inline_status_segment(segment)
|
||||
|
||||
def style_inline_status_value(self, value: str) -> str:
|
||||
"""
|
||||
为带分隔符的行内状态值应用样式。
|
||||
|
||||
:param value: 原始字段值
|
||||
:return: 样式化后的字段值
|
||||
"""
|
||||
return self.status_styler.style_inline_status_value(value)
|
||||
|
||||
def style_named_value(self, key: str, value: str) -> str:
|
||||
"""
|
||||
按字段语义为值应用样式。
|
||||
|
||||
:param key: 字段名
|
||||
:param value: 原始字段值
|
||||
:return: 样式化后的字段值
|
||||
"""
|
||||
return self.status_styler.style_named_value(key, value)
|
||||
|
||||
def style_line(self, line: str) -> str:
|
||||
"""
|
||||
对单行文本输出应用可读性增强样式。
|
||||
|
||||
:param line: 原始输出行
|
||||
:return: 样式化后的输出行
|
||||
"""
|
||||
return self.status_styler.style_line(line)
|
||||
|
||||
def render_text_output(self, data: Any) -> str:
|
||||
"""
|
||||
将任意数据渲染为纯文本输出。
|
||||
|
||||
:param data: 待渲染数据
|
||||
:return: 文本输出结果
|
||||
"""
|
||||
return self.text_renderer.render_text_output(data)
|
||||
|
||||
def colorize_text_output(self, text: str, stream: Any) -> str:
|
||||
"""
|
||||
按终端能力为文本输出增加颜色。
|
||||
|
||||
:param text: 原始文本输出
|
||||
:param stream: 输出流对象
|
||||
:return: 样式化后的文本输出
|
||||
"""
|
||||
return self.text_renderer.colorize_text_output(text, stream)
|
||||
|
||||
def decorate_text_output(self, text: str) -> str:
|
||||
"""
|
||||
对文本输出进行结构化装饰。
|
||||
|
||||
:param text: 原始文本输出
|
||||
:return: 装饰后的文本输出
|
||||
"""
|
||||
return self.text_renderer.decorate_text_output(text)
|
||||
|
||||
@staticmethod
|
||||
def format_scalar(value: Any) -> str:
|
||||
"""
|
||||
将标量值格式化为文本输出。
|
||||
|
||||
:param value: 待格式化的值
|
||||
:return: 文本格式化结果
|
||||
"""
|
||||
return StructuredTextRenderer.format_scalar(value)
|
||||
|
||||
@staticmethod
|
||||
def format_field_name(key: object) -> str:
|
||||
"""
|
||||
将字段名标准化为文本输出使用的 `snake_case` 形式。
|
||||
|
||||
:param key: 原始字段名
|
||||
:return: 标准化后的字段名
|
||||
"""
|
||||
return StructuredTextRenderer.format_field_name(key)
|
||||
|
||||
def append_multiline_text(self, lines: list[str], prefix: str, value: str, indent: str) -> None:
|
||||
"""
|
||||
将多行文本追加到输出行列表中。
|
||||
|
||||
:param lines: 输出行列表
|
||||
:param prefix: 当前字段前缀
|
||||
:param value: 多行文本内容
|
||||
:param indent: 子级缩进
|
||||
:return: None
|
||||
"""
|
||||
self.text_renderer.append_multiline_text(lines, prefix, value, indent)
|
||||
|
||||
def render_nested_lines(self, prefix: str, nested_data: dict[str, Any] | list[Any], indent_level: int) -> list[str]:
|
||||
"""
|
||||
渲染嵌套字典或列表字段。
|
||||
|
||||
:param prefix: 当前字段前缀
|
||||
:param nested_data: 嵌套数据
|
||||
:param indent_level: 当前缩进层级
|
||||
:return: 渲染后的文本行列表
|
||||
"""
|
||||
return self.text_renderer.render_nested_lines(prefix, nested_data, indent_level)
|
||||
|
||||
def render_mapping_lines(self, data: dict[str, Any], *, indent_level: int) -> list[str]:
|
||||
"""
|
||||
将字典渲染为层级化文本输出。
|
||||
|
||||
:param data: 待渲染字典
|
||||
:param indent_level: 当前缩进层级
|
||||
:return: 文本输出行列表
|
||||
"""
|
||||
return self.text_renderer.render_mapping_lines(data, indent_level=indent_level)
|
||||
|
||||
def render_list_lines(self, data: list[Any], *, indent_level: int) -> list[str]:
|
||||
"""
|
||||
将列表渲染为层级化文本输出。
|
||||
|
||||
:param data: 待渲染列表
|
||||
:param indent_level: 当前缩进层级
|
||||
:return: 文本输出行列表
|
||||
"""
|
||||
return self.text_renderer.render_list_lines(data, indent_level=indent_level)
|
||||
|
||||
def render_text_lines(self, data: Any, *, indent_level: int = 0) -> list[str]:
|
||||
"""
|
||||
将任意数据结构渲染为层级化文本输出。
|
||||
|
||||
:param data: 待渲染数据
|
||||
:param indent_level: 当前缩进层级
|
||||
:return: 文本输出行列表
|
||||
"""
|
||||
return self.text_renderer.render_text_lines(data, indent_level=indent_level)
|
||||
|
||||
def emit_output(self, data: Any, output_format: str) -> None:
|
||||
"""
|
||||
输出命令结果。
|
||||
|
||||
:param data: 输出数据
|
||||
:param output_format: 输出格式
|
||||
:return: None
|
||||
"""
|
||||
self.emitter.emit_output(data, output_format)
|
||||
|
||||
def render_error_text(self, message: str, exit_code: int, stream: Any) -> str:
|
||||
"""
|
||||
渲染标准错误输出文本。
|
||||
|
||||
:param message: 错误消息
|
||||
:param exit_code: 退出码
|
||||
:param stream: 输出流对象
|
||||
:return: 渲染后的错误文本
|
||||
"""
|
||||
return self.status_styler.render_error_text(message, exit_code, stream)
|
||||
|
||||
def emit_error(self, message: str, output_format: str, *, exit_code: int) -> None:
|
||||
"""
|
||||
输出错误信息。
|
||||
|
||||
:param message: 错误消息
|
||||
:param output_format: 输出格式
|
||||
:param exit_code: 退出码
|
||||
:return: None
|
||||
"""
|
||||
self.emitter.emit_error(message, output_format, exit_code=exit_code)
|
||||
|
||||
def complete_command(self, result: CommandResult, ctx: CliContext) -> None:
|
||||
"""
|
||||
输出命令结果并结束当前命令。
|
||||
|
||||
:param result: 命令执行结果
|
||||
:param ctx: CLI 上下文
|
||||
:return: None
|
||||
"""
|
||||
self.emitter.complete_command(result, ctx)
|
||||
@@ -0,0 +1,3 @@
|
||||
from cli.runtime.base import RUNTIME_ENVIRONMENT, RuntimeEnvironmentService
|
||||
|
||||
__all__ = ['RUNTIME_ENVIRONMENT', 'RuntimeEnvironmentService']
|
||||
@@ -0,0 +1,3 @@
|
||||
from .service import APP_RUNTIME, AppRuntimeService
|
||||
|
||||
__all__ = ['APP_RUNTIME', 'AppRuntimeService']
|
||||
@@ -0,0 +1,29 @@
|
||||
from importlib import import_module
|
||||
from types import ModuleType
|
||||
|
||||
|
||||
class AppInfrastructureGateway:
|
||||
"""
|
||||
应用基础设施网关。
|
||||
|
||||
该对象负责延迟加载应用实例构建和环境配置模块,
|
||||
供应用运行时 facade 与其协作对象统一复用。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_server_module() -> ModuleType:
|
||||
"""
|
||||
获取应用 server 模块。
|
||||
|
||||
:return: server 模块
|
||||
"""
|
||||
return import_module('server')
|
||||
|
||||
@staticmethod
|
||||
def get_env_module() -> ModuleType:
|
||||
"""
|
||||
获取环境配置模块。
|
||||
|
||||
:return: 环境配置模块
|
||||
"""
|
||||
return import_module('config.env')
|
||||
@@ -0,0 +1,69 @@
|
||||
from typing import Any
|
||||
|
||||
from cli.runtime.base import RUNTIME_ENVIRONMENT, RuntimeEnvironmentService
|
||||
|
||||
from .gateway import AppInfrastructureGateway
|
||||
from .support import AppSnapshotSupport
|
||||
|
||||
|
||||
class AppRuntimeService:
|
||||
"""
|
||||
应用运行时服务。
|
||||
|
||||
该服务作为应用运行时 facade,对外统一暴露应用实例构建、
|
||||
应用配置快照与环境信息快照入口。
|
||||
|
||||
:param runtime_environment: 运行时环境服务
|
||||
:param infrastructure_gateway: 应用基础设施网关
|
||||
:param snapshot_support: 应用快照支持对象
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
runtime_environment: RuntimeEnvironmentService | None = None,
|
||||
infrastructure_gateway: AppInfrastructureGateway | None = None,
|
||||
snapshot_support: AppSnapshotSupport | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化应用运行时服务。
|
||||
|
||||
:param runtime_environment: 运行时环境服务
|
||||
:param infrastructure_gateway: 应用基础设施网关
|
||||
:param snapshot_support: 应用快照支持对象
|
||||
:return: None
|
||||
"""
|
||||
self.runtime_environment = runtime_environment or RUNTIME_ENVIRONMENT
|
||||
self.infrastructure_gateway = infrastructure_gateway or AppInfrastructureGateway()
|
||||
self.snapshot_support = snapshot_support or AppSnapshotSupport(
|
||||
self.infrastructure_gateway,
|
||||
self.runtime_environment,
|
||||
)
|
||||
|
||||
def build_app_instance(self) -> Any:
|
||||
"""
|
||||
构建当前环境下的 FastAPI 应用实例。
|
||||
|
||||
:return: FastAPI 应用实例
|
||||
"""
|
||||
server_module = self.infrastructure_gateway.get_server_module()
|
||||
return server_module.create_app()
|
||||
|
||||
def get_app_config_snapshot(self) -> dict[str, Any]:
|
||||
"""
|
||||
读取当前运行环境的应用配置快照。
|
||||
|
||||
:return: 应用配置快照
|
||||
"""
|
||||
return self.snapshot_support.build_app_config_snapshot()
|
||||
|
||||
def get_app_env_snapshot(self) -> dict[str, Any]:
|
||||
"""
|
||||
读取当前 CLI 进程的环境解析结果快照。
|
||||
|
||||
:return: 环境解析结果快照
|
||||
"""
|
||||
return self.snapshot_support.build_app_env_snapshot()
|
||||
|
||||
|
||||
APP_RUNTIME = AppRuntimeService()
|
||||
@@ -0,0 +1,90 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from cli.runtime.base import RuntimeEnvironmentService
|
||||
|
||||
from .gateway import AppInfrastructureGateway
|
||||
|
||||
|
||||
class AppSnapshotSupport:
|
||||
"""
|
||||
应用快照支持对象。
|
||||
|
||||
该对象负责应用配置快照和环境快照的构建,
|
||||
避免主运行时服务继续承载快照字段拼装细节。
|
||||
|
||||
:param infrastructure_gateway: 应用基础设施网关
|
||||
:param runtime_environment: 运行时环境服务
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
infrastructure_gateway: AppInfrastructureGateway,
|
||||
runtime_environment: RuntimeEnvironmentService,
|
||||
) -> None:
|
||||
"""
|
||||
初始化应用快照支持对象。
|
||||
|
||||
:param infrastructure_gateway: 应用基础设施网关
|
||||
:param runtime_environment: 运行时环境服务
|
||||
:return: None
|
||||
"""
|
||||
self.infrastructure_gateway = infrastructure_gateway
|
||||
self.runtime_environment = runtime_environment
|
||||
|
||||
def build_app_config_snapshot(self) -> dict[str, Any]:
|
||||
"""
|
||||
读取当前运行环境的应用配置快照。
|
||||
|
||||
:return: 应用配置快照
|
||||
"""
|
||||
env_module = self.infrastructure_gateway.get_env_module()
|
||||
app_config = env_module.AppConfig
|
||||
database_config = env_module.DataBaseConfig
|
||||
log_config = env_module.LogConfig
|
||||
redis_config = env_module.RedisConfig
|
||||
transport_crypto_config = env_module.TransportCryptoConfig
|
||||
return {
|
||||
'env': app_config.app_env,
|
||||
'name': app_config.app_name,
|
||||
'host': app_config.app_host,
|
||||
'port': app_config.app_port,
|
||||
'rootPath': app_config.app_root_path,
|
||||
'reload': app_config.app_reload,
|
||||
'workers': app_config.app_workers,
|
||||
'disableSwagger': app_config.app_disable_swagger,
|
||||
'disableRedoc': app_config.app_disable_redoc,
|
||||
'dbType': database_config.db_type,
|
||||
'dbHost': database_config.db_host,
|
||||
'dbPort': database_config.db_port,
|
||||
'dbDatabase': database_config.db_database,
|
||||
'redisHost': redis_config.redis_host,
|
||||
'redisPort': redis_config.redis_port,
|
||||
'logLevel': log_config.loguru_level,
|
||||
'transportCryptoEnabled': transport_crypto_config.transport_crypto_enabled,
|
||||
'transportCryptoMode': transport_crypto_config.transport_crypto_mode,
|
||||
}
|
||||
|
||||
def build_app_env_snapshot(self) -> dict[str, Any]:
|
||||
"""
|
||||
读取当前 CLI 进程的环境解析结果快照。
|
||||
|
||||
:return: 环境解析结果快照
|
||||
"""
|
||||
env_module = self.infrastructure_gateway.get_env_module()
|
||||
app_config = env_module.AppConfig
|
||||
backend_dir = Path(self.runtime_environment.get_backend_dir())
|
||||
resolved_env = os.environ.get('APP_ENV', '') or 'dev'
|
||||
env_file_name = f'.env.{resolved_env}'
|
||||
env_file_path = backend_dir / env_file_name
|
||||
return {
|
||||
'cliEnv': resolved_env,
|
||||
'configEnv': app_config.app_env,
|
||||
'appEnv': os.environ.get('APP_ENV', ''),
|
||||
'envFile': env_file_name,
|
||||
'envFilePath': str(env_file_path),
|
||||
'envFileExists': env_file_path.exists(),
|
||||
'backendDir': str(backend_dir),
|
||||
'pythonExecutable': self.runtime_environment.get_python_executable(),
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import getpass
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class RuntimeEnvironmentService:
|
||||
"""
|
||||
CLI 运行时环境服务。
|
||||
|
||||
该服务集中管理后端根目录解析、Python 解释器定位等稳定运行时能力,
|
||||
避免调用方继续依赖零散模块级函数。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def is_backend_project_dir(project_dir: Path) -> bool:
|
||||
"""
|
||||
判断目录是否为后端项目根目录。
|
||||
|
||||
:param project_dir: 待检查目录
|
||||
:return: 是否为后端项目根目录
|
||||
"""
|
||||
return (
|
||||
(project_dir / 'app.py').exists()
|
||||
and (project_dir / 'config' / 'env.py').exists()
|
||||
and (project_dir / 'cli').is_dir()
|
||||
)
|
||||
|
||||
def get_backend_dir(self) -> str:
|
||||
"""
|
||||
获取后端根目录绝对路径。
|
||||
|
||||
:return: 后端根目录绝对路径
|
||||
"""
|
||||
current_dir = Path.cwd().resolve()
|
||||
if self.is_backend_project_dir(current_dir):
|
||||
return str(current_dir)
|
||||
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
@staticmethod
|
||||
def get_python_executable() -> str:
|
||||
"""
|
||||
获取当前 CLI 进程使用的 Python 解释器。
|
||||
|
||||
:return: Python 解释器路径或命令名
|
||||
"""
|
||||
return sys.executable or 'python'
|
||||
|
||||
|
||||
class RuntimeOperatorService:
|
||||
"""
|
||||
CLI 运行时操作者解析服务。
|
||||
|
||||
该服务集中提供当前 CLI 进程的操作者名称解析能力,
|
||||
避免各领域运行时对象继续复制相同的 `getpass.getuser()` 回退逻辑。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def resolve_operator() -> str:
|
||||
"""
|
||||
解析当前 CLI 操作者名称。
|
||||
|
||||
:return: 操作者名称
|
||||
"""
|
||||
try:
|
||||
return getpass.getuser() or 'shierfeng-cli'
|
||||
except Exception:
|
||||
return 'shierfeng-cli'
|
||||
|
||||
|
||||
RUNTIME_ENVIRONMENT = RuntimeEnvironmentService()
|
||||
RUNTIME_OPERATOR = RuntimeOperatorService()
|
||||
@@ -0,0 +1,3 @@
|
||||
from .service import CACHE_RUNTIME, CacheRuntimeService
|
||||
|
||||
__all__ = ['CACHE_RUNTIME', 'CacheRuntimeService']
|
||||
@@ -0,0 +1,41 @@
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
REDIS_TTL_KEY_NOT_FOUND = -2
|
||||
REDIS_TTL_PERSISTENT = -1
|
||||
|
||||
|
||||
class CacheInfrastructureGateway:
|
||||
"""
|
||||
缓存基础设施网关。
|
||||
|
||||
该对象负责延迟加载 Redis 相关基础设施依赖,
|
||||
供缓存运行时 facade 和其协作对象统一复用。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_redis_error_class() -> type[Exception]:
|
||||
"""
|
||||
获取 Redis 异常类型。
|
||||
|
||||
:return: Redis 异常类型
|
||||
"""
|
||||
return import_module('redis.exceptions').RedisError
|
||||
|
||||
@staticmethod
|
||||
def get_redis_init_key_config() -> Any:
|
||||
"""
|
||||
获取系统缓存初始化键枚举。
|
||||
|
||||
:return: 缓存初始化键枚举
|
||||
"""
|
||||
return import_module('common.enums').RedisInitKeyConfig
|
||||
|
||||
@staticmethod
|
||||
def get_redis_util() -> Any:
|
||||
"""
|
||||
获取 Redis 工具类。
|
||||
|
||||
:return: Redis 工具类
|
||||
"""
|
||||
return import_module('config.get_redis').RedisUtil
|
||||
@@ -0,0 +1,216 @@
|
||||
from typing import Any
|
||||
|
||||
from cli.exit_codes import RUNTIME_ERROR
|
||||
|
||||
from .gateway import REDIS_TTL_KEY_NOT_FOUND, REDIS_TTL_PERSISTENT, CacheInfrastructureGateway
|
||||
from .support import CacheDomainSupport, CacheRedisSupport
|
||||
|
||||
|
||||
class CacheRuntimeService:
|
||||
"""
|
||||
缓存运行时服务。
|
||||
|
||||
该服务作为缓存运行时 facade,对外统一暴露缓存统计、键列表、键值、
|
||||
TTL 读取,以及缓存清理和预热入口。
|
||||
|
||||
:param infrastructure_gateway: 缓存基础设施网关
|
||||
:param domain_support: 缓存领域支持对象
|
||||
:param redis_support: 缓存 Redis 访问支持对象
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
infrastructure_gateway: CacheInfrastructureGateway | None = None,
|
||||
domain_support: CacheDomainSupport | None = None,
|
||||
redis_support: CacheRedisSupport | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化缓存运行时服务。
|
||||
|
||||
:param infrastructure_gateway: 缓存基础设施网关
|
||||
:param domain_support: 缓存领域支持对象
|
||||
:param redis_support: 缓存 Redis 访问支持对象
|
||||
:return: None
|
||||
"""
|
||||
self.infrastructure_gateway = infrastructure_gateway or CacheInfrastructureGateway()
|
||||
self.domain_support = domain_support or CacheDomainSupport(self.infrastructure_gateway)
|
||||
self.redis_support = redis_support or CacheRedisSupport(self.infrastructure_gateway, self.domain_support)
|
||||
|
||||
async def get_cache_stats(self) -> dict[str, Any]:
|
||||
"""
|
||||
获取缓存统计信息。
|
||||
|
||||
:return: 缓存统计结果
|
||||
"""
|
||||
redis_error = self.infrastructure_gateway.get_redis_error_class()
|
||||
try:
|
||||
async with self.redis_support.redis_session() as (redis, _redis_util):
|
||||
info = await redis.info()
|
||||
db_size = await redis.dbsize()
|
||||
command_stats_dict = await redis.info('commandstats')
|
||||
command_stats = [
|
||||
{'name': key.split('_', 1)[1], 'value': int(value.get('calls', 0))}
|
||||
for key, value in sorted(command_stats_dict.items(), key=lambda item: item[0])
|
||||
]
|
||||
return {
|
||||
'ok': True,
|
||||
'dbSize': db_size,
|
||||
'commandStats': command_stats,
|
||||
'cacheNames': self.domain_support.build_cache_name_items(),
|
||||
'info': info,
|
||||
}
|
||||
except redis_error as exc:
|
||||
return self.redis_support.build_redis_error_result('读取缓存统计失败', exc)
|
||||
|
||||
async def list_cache_keys(self, cache_name: str) -> dict[str, Any]:
|
||||
"""
|
||||
列出指定缓存名称下的键名。
|
||||
|
||||
:param cache_name: 缓存名称
|
||||
:return: 缓存键名列表结果
|
||||
"""
|
||||
redis_error = self.infrastructure_gateway.get_redis_error_class()
|
||||
try:
|
||||
async with self.redis_support.redis_session() as (redis, _redis_util):
|
||||
cache_keys: list[str] = await redis.keys(self.redis_support.build_cache_name_keys_pattern(cache_name))
|
||||
key_items = self.domain_support.extract_cache_key_items(cache_name, cache_keys)
|
||||
return {'ok': True, 'cacheName': cache_name, 'count': len(key_items), 'keys': key_items}
|
||||
except redis_error as exc:
|
||||
return self.redis_support.build_redis_error_result('读取缓存键名失败', exc)
|
||||
|
||||
async def get_cache_value(self, cache_name: str, cache_key: str) -> dict[str, Any]:
|
||||
"""
|
||||
读取指定缓存键值。
|
||||
|
||||
:param cache_name: 缓存名称
|
||||
:param cache_key: 缓存键名
|
||||
:return: 缓存值结果
|
||||
"""
|
||||
redis_error = self.infrastructure_gateway.get_redis_error_class()
|
||||
full_cache_key = self.domain_support.build_full_cache_key(cache_name, cache_key)
|
||||
try:
|
||||
async with self.redis_support.redis_session() as (redis, _redis_util):
|
||||
cache_value = await redis.get(full_cache_key)
|
||||
if cache_value is None:
|
||||
return self.domain_support.build_missing_cache_result(cache_name, cache_key)
|
||||
return {
|
||||
'ok': True,
|
||||
'cacheName': cache_name,
|
||||
'cacheKey': cache_key,
|
||||
'fullCacheKey': full_cache_key,
|
||||
'cacheValue': cache_value,
|
||||
}
|
||||
except redis_error as exc:
|
||||
return self.redis_support.build_redis_error_result('读取缓存内容失败', exc)
|
||||
|
||||
async def get_cache_ttl(self, cache_name: str, cache_key: str) -> dict[str, Any]:
|
||||
"""
|
||||
读取指定缓存键的剩余过期时间。
|
||||
|
||||
:param cache_name: 缓存名称
|
||||
:param cache_key: 缓存键名
|
||||
:return: 缓存 TTL 结果
|
||||
"""
|
||||
redis_error = self.infrastructure_gateway.get_redis_error_class()
|
||||
full_cache_key = self.domain_support.build_full_cache_key(cache_name, cache_key)
|
||||
try:
|
||||
async with self.redis_support.redis_session() as (redis, _redis_util):
|
||||
ttl_seconds = await redis.ttl(full_cache_key)
|
||||
if ttl_seconds == REDIS_TTL_KEY_NOT_FOUND:
|
||||
return self.domain_support.build_missing_cache_result(cache_name, cache_key)
|
||||
return {
|
||||
'ok': True,
|
||||
'message': '缓存剩余过期时间读取成功' if ttl_seconds >= 0 else '缓存存在且未设置过期时间',
|
||||
'cacheName': cache_name,
|
||||
'cacheKey': cache_key,
|
||||
'fullCacheKey': full_cache_key,
|
||||
'ttlSeconds': ttl_seconds,
|
||||
'persistent': ttl_seconds == REDIS_TTL_PERSISTENT,
|
||||
'expires': ttl_seconds >= 0,
|
||||
}
|
||||
except redis_error as exc:
|
||||
return self.redis_support.build_redis_error_result('读取缓存剩余过期时间失败', exc)
|
||||
|
||||
async def clear_cache(
|
||||
self,
|
||||
*,
|
||||
cache_name: str = '',
|
||||
cache_key: str = '',
|
||||
clear_all: bool = False,
|
||||
dry_run: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
清理缓存。
|
||||
|
||||
:param cache_name: 需要按缓存名称清理的前缀
|
||||
:param cache_key: 需要按缓存键名模糊清理的键
|
||||
:param clear_all: 是否清理全部缓存
|
||||
:param dry_run: 是否仅演练执行
|
||||
:return: 缓存清理结果
|
||||
"""
|
||||
selected_modes = [bool(cache_name), bool(cache_key), clear_all]
|
||||
if sum(selected_modes) != 1:
|
||||
return {
|
||||
'ok': False,
|
||||
'message': '必须且只能指定一种清理方式:--cache-name、--cache-key 或 --all',
|
||||
'exit_code': RUNTIME_ERROR,
|
||||
}
|
||||
|
||||
redis_error = self.infrastructure_gateway.get_redis_error_class()
|
||||
try:
|
||||
async with self.redis_support.redis_session() as (redis, redis_util):
|
||||
target_pattern = self.redis_support.build_clear_target_pattern(
|
||||
cache_name=cache_name,
|
||||
cache_key=cache_key,
|
||||
clear_all=clear_all,
|
||||
)
|
||||
if target_pattern is None:
|
||||
target_keys = sorted(await redis.keys())
|
||||
else:
|
||||
target_keys = sorted(await redis.keys(target_pattern))
|
||||
|
||||
result = {
|
||||
'ok': True,
|
||||
'dryRun': dry_run,
|
||||
'matchedCount': len(target_keys),
|
||||
'matchedKeys': target_keys,
|
||||
'scope': self.domain_support.build_clear_scope(
|
||||
cache_name=cache_name,
|
||||
cache_key=cache_key,
|
||||
clear_all=clear_all,
|
||||
),
|
||||
}
|
||||
if dry_run:
|
||||
result['message'] = '缓存清理演练完成,未执行实际删除'
|
||||
return result
|
||||
|
||||
if target_keys:
|
||||
await redis.delete(*target_keys)
|
||||
|
||||
if clear_all:
|
||||
await redis_util.init_sys_dict(redis)
|
||||
await redis_util.init_sys_config(redis)
|
||||
|
||||
result['message'] = '缓存清理完成'
|
||||
return result
|
||||
except redis_error as exc:
|
||||
return self.redis_support.build_redis_error_result('清理缓存失败', exc)
|
||||
|
||||
async def warmup_cache(self) -> dict[str, Any]:
|
||||
"""
|
||||
预热系统缓存。
|
||||
|
||||
:return: 缓存预热执行结果
|
||||
"""
|
||||
redis_error = self.infrastructure_gateway.get_redis_error_class()
|
||||
try:
|
||||
async with self.redis_support.redis_session() as (redis, redis_util):
|
||||
await redis_util.init_sys_dict(redis)
|
||||
await redis_util.init_sys_config(redis)
|
||||
return {'ok': True, 'message': '缓存预热完成'}
|
||||
except redis_error as exc:
|
||||
return self.redis_support.build_redis_error_result('缓存预热失败', exc)
|
||||
|
||||
|
||||
CACHE_RUNTIME = CacheRuntimeService()
|
||||
@@ -0,0 +1,201 @@
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from cli.exit_codes import REDIS_ERROR, RUNTIME_ERROR
|
||||
|
||||
from .gateway import CacheInfrastructureGateway
|
||||
|
||||
|
||||
class CacheDomainSupport:
|
||||
"""
|
||||
缓存领域支持对象。
|
||||
|
||||
该对象负责缓存名称列表构建、键范围解析和 TTL 结果判定等
|
||||
局部缓存规则,避免主运行时服务继续承载细碎领域判断。
|
||||
|
||||
:param infrastructure_gateway: 缓存基础设施网关
|
||||
"""
|
||||
|
||||
def __init__(self, infrastructure_gateway: CacheInfrastructureGateway) -> None:
|
||||
"""
|
||||
初始化缓存领域支持对象。
|
||||
|
||||
:param infrastructure_gateway: 缓存基础设施网关
|
||||
:return: None
|
||||
"""
|
||||
self.infrastructure_gateway = infrastructure_gateway
|
||||
|
||||
def build_cache_name_items(self) -> list[dict[str, Any]]:
|
||||
"""
|
||||
构建系统内置缓存名称列表。
|
||||
|
||||
:return: 缓存名称列表
|
||||
"""
|
||||
redis_init_key_config = self.infrastructure_gateway.get_redis_init_key_config()
|
||||
return [
|
||||
{
|
||||
'cacheName': key_config.key,
|
||||
'remark': key_config.remark,
|
||||
}
|
||||
for key_config in redis_init_key_config
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def build_full_cache_key(cache_name: str, cache_key: str) -> str:
|
||||
"""
|
||||
构建缓存完整键名。
|
||||
|
||||
:param cache_name: 缓存名称
|
||||
:param cache_key: 缓存键名
|
||||
:return: 完整缓存键名
|
||||
"""
|
||||
return f'{cache_name}:{cache_key}'
|
||||
|
||||
def build_missing_cache_result(self, cache_name: str, cache_key: str) -> dict[str, Any]:
|
||||
"""
|
||||
构建缓存不存在的统一结果。
|
||||
|
||||
:param cache_name: 缓存名称
|
||||
:param cache_key: 缓存键名
|
||||
:return: 缓存不存在结果
|
||||
"""
|
||||
full_cache_key = self.build_full_cache_key(cache_name, cache_key)
|
||||
return {
|
||||
'ok': False,
|
||||
'message': f'缓存不存在:{full_cache_key}',
|
||||
'cacheName': cache_name,
|
||||
'cacheKey': cache_key,
|
||||
'fullCacheKey': full_cache_key,
|
||||
'exit_code': RUNTIME_ERROR,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def build_cache_name_pattern(cache_name: str) -> str:
|
||||
"""
|
||||
构建按缓存名称扫描时使用的 Redis 键模式。
|
||||
|
||||
:param cache_name: 缓存名称
|
||||
:return: Redis 键匹配模式
|
||||
"""
|
||||
return f'{cache_name}:*'
|
||||
|
||||
@staticmethod
|
||||
def extract_cache_key_items(cache_name: str, cache_keys: list[str]) -> list[str]:
|
||||
"""
|
||||
从缓存完整键名列表中提取相对键名。
|
||||
|
||||
:param cache_name: 缓存名称
|
||||
:param cache_keys: 完整缓存键名列表
|
||||
:return: 相对键名列表
|
||||
"""
|
||||
return [key.split(':', 1)[1] for key in sorted(cache_keys) if key.startswith(f'{cache_name}:')]
|
||||
|
||||
@staticmethod
|
||||
def build_clear_scope(
|
||||
*,
|
||||
cache_name: str,
|
||||
cache_key: str,
|
||||
clear_all: bool,
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
构建缓存清理范围定义。
|
||||
|
||||
:param cache_name: 缓存名称
|
||||
:param cache_key: 缓存键名片段
|
||||
:param clear_all: 是否清理全部缓存
|
||||
:return: 清理范围定义
|
||||
"""
|
||||
if clear_all:
|
||||
return {'mode': 'all'}
|
||||
if cache_name:
|
||||
return {'mode': 'cacheName', 'cacheName': cache_name}
|
||||
return {'mode': 'cacheKey', 'cacheKey': cache_key}
|
||||
|
||||
|
||||
class CacheRedisSupport:
|
||||
"""
|
||||
缓存 Redis 访问支持对象。
|
||||
|
||||
该对象负责 Redis 连接生命周期、错误结果规整和缓存键模式拼装,
|
||||
避免主运行时服务继续承载基础设施桥接细节。
|
||||
|
||||
:param infrastructure_gateway: 缓存基础设施网关
|
||||
:param domain_support: 缓存领域支持对象
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
infrastructure_gateway: CacheInfrastructureGateway,
|
||||
domain_support: CacheDomainSupport,
|
||||
) -> None:
|
||||
"""
|
||||
初始化缓存 Redis 访问支持对象。
|
||||
|
||||
:param infrastructure_gateway: 缓存基础设施网关
|
||||
:param domain_support: 缓存领域支持对象
|
||||
:return: None
|
||||
"""
|
||||
self.infrastructure_gateway = infrastructure_gateway
|
||||
self.domain_support = domain_support
|
||||
|
||||
@asynccontextmanager
|
||||
async def redis_session(self) -> Any:
|
||||
"""
|
||||
创建并托管 Redis 会话。
|
||||
|
||||
:return: `(redis, redis_util)` 元组
|
||||
"""
|
||||
redis = None
|
||||
redis_util = self.infrastructure_gateway.get_redis_util()
|
||||
try:
|
||||
redis = await redis_util.create_redis_pool(log_enabled=False)
|
||||
yield redis, redis_util
|
||||
finally:
|
||||
if redis is not None:
|
||||
await redis.close()
|
||||
|
||||
@staticmethod
|
||||
def build_redis_error_result(message: str, exc: Exception) -> dict[str, Any]:
|
||||
"""
|
||||
构建统一 Redis 异常结果。
|
||||
|
||||
:param message: 失败消息
|
||||
:param exc: 原始异常
|
||||
:return: 标准失败结果
|
||||
"""
|
||||
return {
|
||||
'ok': False,
|
||||
'message': message,
|
||||
'error': str(exc),
|
||||
'exit_code': REDIS_ERROR,
|
||||
}
|
||||
|
||||
def build_cache_name_keys_pattern(self, cache_name: str) -> str:
|
||||
"""
|
||||
构建按缓存名称读取键列表时使用的模式。
|
||||
|
||||
:param cache_name: 缓存名称
|
||||
:return: Redis 键匹配模式
|
||||
"""
|
||||
return self.domain_support.build_cache_name_pattern(cache_name)
|
||||
|
||||
def build_clear_target_pattern(
|
||||
self,
|
||||
*,
|
||||
cache_name: str,
|
||||
cache_key: str,
|
||||
clear_all: bool,
|
||||
) -> str | None:
|
||||
"""
|
||||
构建缓存清理目标的 Redis 键匹配模式。
|
||||
|
||||
:param cache_name: 缓存名称
|
||||
:param cache_key: 缓存键名片段
|
||||
:param clear_all: 是否清理全部缓存
|
||||
:return: Redis 键匹配模式;全部清理时返回 `None`
|
||||
"""
|
||||
if clear_all:
|
||||
return None
|
||||
if cache_name:
|
||||
return self.domain_support.build_cache_name_pattern(cache_name)
|
||||
return f'*{cache_key}'
|
||||
@@ -0,0 +1,3 @@
|
||||
from .service import CONFIG_RUNTIME, ConfigRuntimeService
|
||||
|
||||
__all__ = ['CONFIG_RUNTIME', 'ConfigRuntimeService']
|
||||
@@ -0,0 +1,101 @@
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ConfigInfrastructureGateway:
|
||||
"""
|
||||
参数配置基础设施网关。
|
||||
|
||||
该对象负责延迟加载数据库会话、Redis、DAO、Service 与脱敏工具依赖,
|
||||
供参数配置运行时 facade 和其协作对象统一复用。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_redis_error_class() -> type[Exception]:
|
||||
"""
|
||||
获取 Redis 异常类型。
|
||||
|
||||
:return: Redis 异常类型
|
||||
"""
|
||||
return import_module('redis.exceptions').RedisError
|
||||
|
||||
@staticmethod
|
||||
def get_common_constant() -> Any:
|
||||
"""
|
||||
获取公共常量定义。
|
||||
|
||||
:return: 公共常量定义模块
|
||||
"""
|
||||
return import_module('common.constant').CommonConstant
|
||||
|
||||
@staticmethod
|
||||
def get_redis_init_key_config() -> Any:
|
||||
"""
|
||||
获取 Redis 初始化键枚举。
|
||||
|
||||
:return: Redis 初始化键枚举
|
||||
"""
|
||||
return import_module('common.enums').RedisInitKeyConfig
|
||||
|
||||
@staticmethod
|
||||
def get_page_model() -> Any:
|
||||
"""
|
||||
获取分页模型类型。
|
||||
|
||||
:return: 分页模型类型
|
||||
"""
|
||||
return import_module('common.vo').PageModel
|
||||
|
||||
@staticmethod
|
||||
def get_async_session_local() -> Any:
|
||||
"""
|
||||
获取异步数据库会话工厂。
|
||||
|
||||
:return: 异步数据库会话工厂
|
||||
"""
|
||||
return import_module('config.database').AsyncSessionLocal
|
||||
|
||||
@staticmethod
|
||||
def get_redis_util() -> Any:
|
||||
"""
|
||||
获取 Redis 工具类。
|
||||
|
||||
:return: Redis 工具类
|
||||
"""
|
||||
return import_module('config.get_redis').RedisUtil
|
||||
|
||||
@staticmethod
|
||||
def get_config_dao() -> Any:
|
||||
"""
|
||||
获取参数配置 DAO。
|
||||
|
||||
:return: 参数配置 DAO
|
||||
"""
|
||||
return import_module('module_admin.dao.config_dao').ConfigDao
|
||||
|
||||
@staticmethod
|
||||
def get_config_vo_module() -> Any:
|
||||
"""
|
||||
获取参数配置 VO 模块。
|
||||
|
||||
:return: 参数配置 VO 模块
|
||||
"""
|
||||
return import_module('module_admin.entity.vo.config_vo')
|
||||
|
||||
@staticmethod
|
||||
def get_config_service() -> Any:
|
||||
"""
|
||||
获取参数配置服务类。
|
||||
|
||||
:return: 参数配置服务类
|
||||
"""
|
||||
return import_module('module_admin.service.config_service').ConfigService
|
||||
|
||||
@staticmethod
|
||||
def get_log_sanitizer() -> Any:
|
||||
"""
|
||||
获取日志脱敏工具类。
|
||||
|
||||
:return: 日志脱敏工具类
|
||||
"""
|
||||
return import_module('utils.log_util').LogSanitizer
|
||||
@@ -0,0 +1,406 @@
|
||||
from typing import Any, Literal
|
||||
|
||||
from cli.exit_codes import ARGUMENT_ERROR, DATABASE_ERROR, REDIS_ERROR
|
||||
|
||||
from .gateway import ConfigInfrastructureGateway
|
||||
from .support import ConfigDomainSupport
|
||||
|
||||
|
||||
class ConfigRuntimeService:
|
||||
"""
|
||||
参数配置运行时服务。
|
||||
|
||||
该服务作为参数配置运行时 facade,对外统一暴露参数配置列表、详情、
|
||||
写入、缓存刷新与一致性诊断入口。
|
||||
|
||||
:param infrastructure_gateway: 参数配置基础设施网关
|
||||
:param domain_support: 参数配置领域支持对象
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
infrastructure_gateway: ConfigInfrastructureGateway | None = None,
|
||||
domain_support: ConfigDomainSupport | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化参数配置运行时服务。
|
||||
|
||||
:param infrastructure_gateway: 参数配置基础设施网关
|
||||
:param domain_support: 参数配置领域支持对象
|
||||
:return: None
|
||||
"""
|
||||
self.infrastructure_gateway = infrastructure_gateway or ConfigInfrastructureGateway()
|
||||
self.domain_support = domain_support or ConfigDomainSupport(self.infrastructure_gateway)
|
||||
|
||||
@staticmethod
|
||||
def build_cli_config_model(config_vo_module: Any, config_record: Any) -> Any | None:
|
||||
"""
|
||||
将 ORM 配置记录显式转换为 CLI 可用的配置模型。
|
||||
|
||||
`ConfigModel` 当前未统一开启 `populate_by_name`,因此 CLI 侧在处理
|
||||
ORM 蛇形字段对象时,需要主动映射为驼峰 alias 输入,避免详情查询结果
|
||||
被静默序列化为空。
|
||||
|
||||
:param config_vo_module: 配置 VO 模块
|
||||
:param config_record: ORM 配置记录
|
||||
:return: CLI 可用配置模型
|
||||
"""
|
||||
if config_record is None:
|
||||
return None
|
||||
return config_vo_module.ConfigModel(
|
||||
configId=getattr(config_record, 'config_id', None),
|
||||
configName=getattr(config_record, 'config_name', None),
|
||||
configKey=getattr(config_record, 'config_key', None),
|
||||
configValue=getattr(config_record, 'config_value', None),
|
||||
configType=getattr(config_record, 'config_type', None),
|
||||
createBy=getattr(config_record, 'create_by', None),
|
||||
createTime=getattr(config_record, 'create_time', None),
|
||||
updateBy=getattr(config_record, 'update_by', None),
|
||||
updateTime=getattr(config_record, 'update_time', None),
|
||||
remark=getattr(config_record, 'remark', None),
|
||||
)
|
||||
|
||||
async def load_config_from_database(
|
||||
self,
|
||||
config_key: str,
|
||||
) -> tuple[Any | None, dict[str, Any] | None] | dict[str, Any]:
|
||||
"""
|
||||
从数据库加载参数配置。
|
||||
|
||||
:param config_key: 参数键名
|
||||
:return: 成功时返回配置模型与序列化结果,失败时返回错误结果字典
|
||||
"""
|
||||
async_session_local = self.infrastructure_gateway.get_async_session_local()
|
||||
config_dao = self.infrastructure_gateway.get_config_dao()
|
||||
config_vo_module = self.infrastructure_gateway.get_config_vo_module()
|
||||
try:
|
||||
async with async_session_local() as session:
|
||||
config_record = await config_dao.get_config_detail_by_info(
|
||||
session,
|
||||
config_vo_module.ConfigModel(configKey=config_key),
|
||||
)
|
||||
except Exception as exc:
|
||||
return {'ok': False, 'message': '读取参数配置失败', 'error': str(exc), 'exit_code': DATABASE_ERROR}
|
||||
|
||||
database_model = self.build_cli_config_model(config_vo_module, config_record)
|
||||
return database_model, self.domain_support.serialize_config_model(database_model)
|
||||
|
||||
async def load_config_from_cache(
|
||||
self,
|
||||
config_key: str,
|
||||
) -> tuple[str | None, dict[str, Any] | None] | dict[str, Any]:
|
||||
"""
|
||||
从缓存加载参数配置。
|
||||
|
||||
:param config_key: 参数键名
|
||||
:return: 成功时返回原始缓存值与序列化结果,失败时返回错误结果字典
|
||||
"""
|
||||
redis = None
|
||||
redis_util = self.infrastructure_gateway.get_redis_util()
|
||||
config_service = self.infrastructure_gateway.get_config_service()
|
||||
redis_error = self.infrastructure_gateway.get_redis_error_class()
|
||||
try:
|
||||
redis = await redis_util.create_redis_pool(log_enabled=False)
|
||||
cache_value = await config_service.query_config_list_from_cache_services(redis, config_key)
|
||||
return cache_value, self.domain_support.serialize_cache_payload(config_key, cache_value)
|
||||
except redis_error as exc:
|
||||
return {'ok': False, 'message': '读取参数缓存失败', 'error': str(exc), 'exit_code': REDIS_ERROR}
|
||||
finally:
|
||||
if redis is not None:
|
||||
await redis.close()
|
||||
|
||||
async def list_configs(
|
||||
self,
|
||||
config_name: str = '',
|
||||
config_key: str = '',
|
||||
config_type: Literal['Y', 'N'] | None = None,
|
||||
begin_date: str = '',
|
||||
end_date: str = '',
|
||||
*,
|
||||
paged: bool = False,
|
||||
page_num: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
查询参数配置列表。
|
||||
|
||||
:param config_name: 参数名称过滤条件
|
||||
:param config_key: 参数键名过滤条件
|
||||
:param config_type: 参数类型过滤条件
|
||||
:param begin_date: 开始日期
|
||||
:param end_date: 结束日期
|
||||
:param paged: 是否开启分页
|
||||
:param page_num: 页码
|
||||
:param page_size: 每页数量
|
||||
:return: CLI 标准结果字典
|
||||
"""
|
||||
async_session_local = self.infrastructure_gateway.get_async_session_local()
|
||||
config_service = self.infrastructure_gateway.get_config_service()
|
||||
query_model = self.domain_support.build_config_query(
|
||||
config_name,
|
||||
config_key,
|
||||
config_type,
|
||||
begin_date,
|
||||
end_date,
|
||||
page_num,
|
||||
page_size,
|
||||
)
|
||||
try:
|
||||
async with async_session_local() as session:
|
||||
result = await config_service.get_config_list_services(session, query_model, is_page=paged)
|
||||
except Exception as exc:
|
||||
return {'ok': False, 'message': '读取参数配置列表失败', 'error': str(exc), 'exit_code': DATABASE_ERROR}
|
||||
|
||||
filters = self.domain_support.build_list_filters(
|
||||
config_name=config_name,
|
||||
config_key=config_key,
|
||||
config_type=config_type,
|
||||
begin_date=begin_date,
|
||||
end_date=end_date,
|
||||
paged=paged,
|
||||
page_num=page_num,
|
||||
page_size=page_size,
|
||||
)
|
||||
if paged and isinstance(result, self.infrastructure_gateway.get_page_model()):
|
||||
page_payload = result.model_dump(by_alias=True)
|
||||
page_payload['rows'] = [
|
||||
self.domain_support.sanitize_config_mapping(item) for item in page_payload.get('rows', [])
|
||||
]
|
||||
return {'ok': True, 'filters': filters, 'page': page_payload}
|
||||
|
||||
items = [self.domain_support.sanitize_config_mapping(item) for item in result]
|
||||
return {'ok': True, 'filters': filters, 'count': len(items), 'items': items}
|
||||
|
||||
async def get_config(
|
||||
self,
|
||||
config_key: str,
|
||||
*,
|
||||
source: Literal['db', 'cache', 'both'] = 'both',
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
按参数键读取配置详情。
|
||||
|
||||
:param config_key: 参数键名
|
||||
:param source: 读取来源
|
||||
:return: CLI 标准结果字典
|
||||
"""
|
||||
database_model: Any | None = None
|
||||
database_payload: dict[str, Any] | None = None
|
||||
cache_value: str | None = None
|
||||
cache_payload: dict[str, Any] | None = None
|
||||
|
||||
if source in {'db', 'both'}:
|
||||
database_result = await self.load_config_from_database(config_key)
|
||||
if isinstance(database_result, dict):
|
||||
return database_result
|
||||
database_model, database_payload = database_result
|
||||
|
||||
if source in {'cache', 'both'}:
|
||||
cache_result = await self.load_config_from_cache(config_key)
|
||||
if isinstance(cache_result, dict):
|
||||
error_payload = {**cache_result, 'source': source}
|
||||
if database_payload is not None:
|
||||
error_payload['database'] = database_payload
|
||||
return error_payload
|
||||
cache_value, cache_payload = cache_result
|
||||
|
||||
if source == 'db' and database_payload is None:
|
||||
return self.domain_support.build_missing_config_result(config_key, source)
|
||||
if source == 'cache' and cache_payload is None:
|
||||
return self.domain_support.build_missing_config_result(config_key, source)
|
||||
if source == 'both' and database_payload is None and cache_payload is None:
|
||||
return self.domain_support.build_missing_config_result(config_key, source)
|
||||
|
||||
payload = {'ok': True, 'key': config_key, 'source': source}
|
||||
if source in {'db', 'both'}:
|
||||
payload['database'] = database_payload
|
||||
if source in {'cache', 'both'}:
|
||||
payload['cache'] = cache_payload
|
||||
if source == 'both':
|
||||
payload['inSync'] = (database_model.config_value if database_model else None) == cache_value
|
||||
return payload
|
||||
|
||||
async def set_config(
|
||||
self,
|
||||
config_key: str,
|
||||
config_value: str,
|
||||
*,
|
||||
config_name: str | None = None,
|
||||
config_type: Literal['Y', 'N'] | None = None,
|
||||
remark: str | None = None,
|
||||
dry_run: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
新增或更新参数配置,并同步缓存。
|
||||
|
||||
:param config_key: 参数键名
|
||||
:param config_value: 参数键值
|
||||
:param config_name: 参数名称
|
||||
:param config_type: 参数类型
|
||||
:param remark: 备注
|
||||
:param dry_run: 是否仅执行演练
|
||||
:return: CLI 标准结果字典
|
||||
"""
|
||||
async_session_local = self.infrastructure_gateway.get_async_session_local()
|
||||
config_dao = self.infrastructure_gateway.get_config_dao()
|
||||
config_vo_module = self.infrastructure_gateway.get_config_vo_module()
|
||||
redis_util = self.infrastructure_gateway.get_redis_util()
|
||||
redis_init_key_config = self.infrastructure_gateway.get_redis_init_key_config()
|
||||
redis_error = self.infrastructure_gateway.get_redis_error_class()
|
||||
existing_config = None
|
||||
target_config = None
|
||||
try:
|
||||
async with async_session_local() as session:
|
||||
config_record = await config_dao.get_config_detail_by_info(
|
||||
session,
|
||||
config_vo_module.ConfigModel(configKey=config_key),
|
||||
)
|
||||
existing_config = self.build_cli_config_model(config_vo_module, config_record)
|
||||
if existing_config is None and not config_name:
|
||||
return {
|
||||
'ok': False,
|
||||
'message': '新增参数配置时必须传入 --name',
|
||||
'exit_code': ARGUMENT_ERROR,
|
||||
}
|
||||
|
||||
target_config = self.domain_support.build_target_config_model(
|
||||
config_key,
|
||||
config_value,
|
||||
config_name,
|
||||
config_type,
|
||||
remark,
|
||||
existing_config,
|
||||
)
|
||||
if dry_run:
|
||||
return {
|
||||
'ok': True,
|
||||
'message': '参数配置演练完成,未执行实际写入',
|
||||
'dryRun': True,
|
||||
'action': 'update' if existing_config else 'create',
|
||||
'config': self.domain_support.serialize_config_model(target_config),
|
||||
}
|
||||
|
||||
if existing_config:
|
||||
await config_dao.edit_config_dao(session, target_config.model_dump(exclude_none=True))
|
||||
else:
|
||||
await config_dao.add_config_dao(session, target_config)
|
||||
await session.commit()
|
||||
except Exception as exc:
|
||||
return {'ok': False, 'message': '写入参数配置失败', 'error': str(exc), 'exit_code': DATABASE_ERROR}
|
||||
|
||||
redis = None
|
||||
try:
|
||||
redis = await redis_util.create_redis_pool(log_enabled=False)
|
||||
await redis.set(f'{redis_init_key_config.SYS_CONFIG.key}:{config_key}', config_value)
|
||||
except redis_error as exc:
|
||||
return {
|
||||
'ok': False,
|
||||
'message': '参数配置已写入数据库,但同步缓存失败',
|
||||
'error': str(exc),
|
||||
'config': self.domain_support.serialize_config_model(target_config),
|
||||
'databaseCommitted': True,
|
||||
'exit_code': REDIS_ERROR,
|
||||
}
|
||||
finally:
|
||||
if redis is not None:
|
||||
await redis.close()
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'message': '参数配置已同步到数据库和缓存',
|
||||
'action': 'update' if existing_config else 'create',
|
||||
'config': self.domain_support.serialize_config_model(target_config),
|
||||
}
|
||||
|
||||
async def sync_config_cache(self) -> dict[str, Any]:
|
||||
"""
|
||||
刷新参数配置缓存。
|
||||
|
||||
:return: CLI 标准结果字典
|
||||
"""
|
||||
redis = None
|
||||
async_session_local = self.infrastructure_gateway.get_async_session_local()
|
||||
config_service = self.infrastructure_gateway.get_config_service()
|
||||
config_vo_module = self.infrastructure_gateway.get_config_vo_module()
|
||||
redis_util = self.infrastructure_gateway.get_redis_util()
|
||||
redis_error = self.infrastructure_gateway.get_redis_error_class()
|
||||
try:
|
||||
async with async_session_local() as session:
|
||||
config_list = await config_service.get_config_list_services(
|
||||
session,
|
||||
config_vo_module.ConfigPageQueryModel(),
|
||||
is_page=False,
|
||||
)
|
||||
redis = await redis_util.create_redis_pool(log_enabled=False)
|
||||
await config_service.init_cache_sys_config_services(session, redis)
|
||||
except redis_error as exc:
|
||||
return {'ok': False, 'message': '刷新参数缓存失败', 'error': str(exc), 'exit_code': REDIS_ERROR}
|
||||
except Exception as exc:
|
||||
return {'ok': False, 'message': '刷新参数缓存失败', 'error': str(exc), 'exit_code': DATABASE_ERROR}
|
||||
finally:
|
||||
if redis is not None:
|
||||
await redis.close()
|
||||
|
||||
return {'ok': True, 'message': '参数缓存刷新成功', 'count': len(config_list)}
|
||||
|
||||
async def diagnose_config(self, *, sample_limit: int = 10) -> dict[str, Any]:
|
||||
"""
|
||||
诊断数据库参数配置与 Redis 缓存的一致性状态。
|
||||
|
||||
:param sample_limit: 示例键名输出上限
|
||||
:return: CLI 标准结果字典
|
||||
"""
|
||||
redis = None
|
||||
async_session_local = self.infrastructure_gateway.get_async_session_local()
|
||||
config_service = self.infrastructure_gateway.get_config_service()
|
||||
config_vo_module = self.infrastructure_gateway.get_config_vo_module()
|
||||
redis_util = self.infrastructure_gateway.get_redis_util()
|
||||
redis_init_key_config = self.infrastructure_gateway.get_redis_init_key_config()
|
||||
redis_error = self.infrastructure_gateway.get_redis_error_class()
|
||||
try:
|
||||
async with async_session_local() as session:
|
||||
config_list = await config_service.get_config_list_services(
|
||||
session,
|
||||
config_vo_module.ConfigPageQueryModel(),
|
||||
is_page=False,
|
||||
)
|
||||
redis = await redis_util.create_redis_pool(log_enabled=False)
|
||||
cache_prefix = f'{redis_init_key_config.SYS_CONFIG.key}:'
|
||||
cache_keys = sorted(await redis.keys(f'{cache_prefix}*'))
|
||||
cache_values = await redis.mget(*cache_keys) if cache_keys else []
|
||||
except redis_error as exc:
|
||||
return {'ok': False, 'message': '读取参数缓存诊断信息失败', 'error': str(exc), 'exit_code': REDIS_ERROR}
|
||||
except Exception as exc:
|
||||
return {'ok': False, 'message': '读取参数配置诊断信息失败', 'error': str(exc), 'exit_code': DATABASE_ERROR}
|
||||
finally:
|
||||
if redis is not None:
|
||||
await redis.close()
|
||||
|
||||
database_map = {str(item.get('configKey')): str(item.get('configValue', '')) for item in config_list}
|
||||
cache_map = {
|
||||
cache_key.replace(cache_prefix, '', 1): '' if cache_value is None else str(cache_value)
|
||||
for cache_key, cache_value in zip(cache_keys, cache_values, strict=False)
|
||||
}
|
||||
missing_in_cache = sorted(key for key in database_map if key not in cache_map)
|
||||
orphan_in_cache = sorted(key for key in cache_map if key not in database_map)
|
||||
mismatch_keys = sorted(
|
||||
key for key, database_value in database_map.items() if key in cache_map and database_value != cache_map[key]
|
||||
)
|
||||
in_sync = not missing_in_cache and not orphan_in_cache and not mismatch_keys
|
||||
return {
|
||||
'ok': in_sync,
|
||||
'message': '参数配置数据库与缓存一致' if in_sync else '参数配置存在数据库与缓存不一致项',
|
||||
'databaseCount': len(database_map),
|
||||
'cacheCount': len(cache_map),
|
||||
'missingInCacheCount': len(missing_in_cache),
|
||||
'orphanInCacheCount': len(orphan_in_cache),
|
||||
'mismatchCount': len(mismatch_keys),
|
||||
'sampleLimit': sample_limit,
|
||||
'missingInCache': missing_in_cache[:sample_limit],
|
||||
'orphanInCache': orphan_in_cache[:sample_limit],
|
||||
'mismatchKeys': mismatch_keys[:sample_limit],
|
||||
}
|
||||
|
||||
|
||||
CONFIG_RUNTIME = ConfigRuntimeService()
|
||||
@@ -0,0 +1,191 @@
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from cli.exit_codes import RUNTIME_ERROR
|
||||
from cli.runtime.base import RUNTIME_OPERATOR, RuntimeOperatorService
|
||||
|
||||
from .gateway import ConfigInfrastructureGateway
|
||||
|
||||
|
||||
class ConfigDomainSupport:
|
||||
"""
|
||||
参数配置领域支持对象。
|
||||
|
||||
该对象负责操作者解析、配置脱敏、序列化、查询模型构建与缺失结果构建,
|
||||
避免主运行时服务继续承载大量领域规则。
|
||||
|
||||
:param infrastructure_gateway: 参数配置基础设施网关
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
infrastructure_gateway: ConfigInfrastructureGateway,
|
||||
operator_service: RuntimeOperatorService = RUNTIME_OPERATOR,
|
||||
) -> None:
|
||||
"""
|
||||
初始化参数配置领域支持对象。
|
||||
|
||||
:param infrastructure_gateway: 参数配置基础设施网关
|
||||
:param operator_service: 运行时操作者解析服务
|
||||
:return: None
|
||||
"""
|
||||
self.infrastructure_gateway = infrastructure_gateway
|
||||
self.operator_service = operator_service
|
||||
|
||||
def sanitize_config_mapping(self, config_mapping: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
对配置字典进行脱敏处理。
|
||||
|
||||
:param config_mapping: 原始配置字典
|
||||
:return: 脱敏后的配置字典
|
||||
"""
|
||||
return self.infrastructure_gateway.get_log_sanitizer().sanitize_data(config_mapping)
|
||||
|
||||
def serialize_config_model(self, config_model: Any | None) -> dict[str, Any] | None:
|
||||
"""
|
||||
将配置模型序列化为 CLI 输出字典。
|
||||
|
||||
:param config_model: 配置模型对象
|
||||
:return: 序列化后的配置字典
|
||||
"""
|
||||
if config_model is None or config_model.config_id is None:
|
||||
return None
|
||||
return self.sanitize_config_mapping(config_model.model_dump(by_alias=True, exclude_none=True))
|
||||
|
||||
def serialize_cache_payload(self, config_key: str, config_value: str | None) -> dict[str, Any] | None:
|
||||
"""
|
||||
将缓存中的配置值序列化为 CLI 输出字典。
|
||||
|
||||
:param config_key: 参数键名
|
||||
:param config_value: 参数键值
|
||||
:return: 序列化后的缓存配置字典
|
||||
"""
|
||||
if config_value is None:
|
||||
return None
|
||||
return self.sanitize_config_mapping({'configKey': config_key, 'configValue': config_value})
|
||||
|
||||
@staticmethod
|
||||
def build_missing_config_result(
|
||||
config_key: str,
|
||||
source: Literal['db', 'cache', 'both'],
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
构建配置不存在的统一结果。
|
||||
|
||||
:param config_key: 参数键名
|
||||
:param source: 读取来源
|
||||
:return: 配置不存在的结果字典
|
||||
"""
|
||||
message = f'参数缓存不存在:{config_key}' if source == 'cache' else f'参数配置不存在:{config_key}'
|
||||
return {'ok': False, 'message': message, 'source': source, 'exit_code': RUNTIME_ERROR}
|
||||
|
||||
def build_config_query(
|
||||
self,
|
||||
config_name: str,
|
||||
config_key: str,
|
||||
config_type: Literal['Y', 'N'] | None,
|
||||
begin_date: str,
|
||||
end_date: str,
|
||||
page_num: int,
|
||||
page_size: int,
|
||||
) -> Any:
|
||||
"""
|
||||
构建配置列表查询对象。
|
||||
|
||||
:param config_name: 参数名称过滤条件
|
||||
:param config_key: 参数键名过滤条件
|
||||
:param config_type: 参数类型过滤条件
|
||||
:param begin_date: 开始日期
|
||||
:param end_date: 结束日期
|
||||
:param page_num: 页码
|
||||
:param page_size: 每页数量
|
||||
:return: 配置分页查询模型
|
||||
"""
|
||||
config_vo_module = self.infrastructure_gateway.get_config_vo_module()
|
||||
return config_vo_module.ConfigPageQueryModel(
|
||||
configName=config_name or None,
|
||||
configKey=config_key or None,
|
||||
configType=config_type,
|
||||
beginTime=begin_date or None,
|
||||
endTime=end_date or None,
|
||||
pageNum=page_num,
|
||||
pageSize=page_size,
|
||||
)
|
||||
|
||||
def build_target_config_model(
|
||||
self,
|
||||
config_key: str,
|
||||
config_value: str,
|
||||
config_name: str | None,
|
||||
config_type: Literal['Y', 'N'] | None,
|
||||
remark: str | None,
|
||||
existing_config: Any | None,
|
||||
) -> Any:
|
||||
"""
|
||||
基于当前状态构建目标配置模型。
|
||||
|
||||
:param config_key: 参数键名
|
||||
:param config_value: 参数键值
|
||||
:param config_name: 参数名称
|
||||
:param config_type: 参数类型
|
||||
:param remark: 参数备注
|
||||
:param existing_config: 已存在的配置模型
|
||||
:return: 目标配置模型
|
||||
"""
|
||||
operator = self.operator_service.resolve_operator()
|
||||
current_time = datetime.now()
|
||||
common_constant = self.infrastructure_gateway.get_common_constant()
|
||||
config_vo_module = self.infrastructure_gateway.get_config_vo_module()
|
||||
default_config_type = existing_config.config_type if existing_config else common_constant.NO
|
||||
target_config = config_vo_module.ConfigModel(
|
||||
configId=existing_config.config_id if existing_config else None,
|
||||
configName=config_name
|
||||
if config_name is not None
|
||||
else (existing_config.config_name if existing_config else None),
|
||||
configKey=config_key,
|
||||
configValue=config_value,
|
||||
configType=config_type if config_type is not None else default_config_type,
|
||||
createBy=existing_config.create_by if existing_config else operator,
|
||||
createTime=existing_config.create_time if existing_config else current_time,
|
||||
updateBy=operator,
|
||||
updateTime=current_time,
|
||||
remark=remark if remark is not None else (existing_config.remark if existing_config else None),
|
||||
)
|
||||
target_config.validate_fields()
|
||||
return target_config
|
||||
|
||||
def build_list_filters(
|
||||
self,
|
||||
*,
|
||||
config_name: str,
|
||||
config_key: str,
|
||||
config_type: Literal['Y', 'N'] | None,
|
||||
begin_date: str,
|
||||
end_date: str,
|
||||
paged: bool,
|
||||
page_num: int,
|
||||
page_size: int,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
构建配置列表过滤条件。
|
||||
|
||||
:param config_name: 参数名称
|
||||
:param config_key: 参数键名
|
||||
:param config_type: 参数类型
|
||||
:param begin_date: 开始日期
|
||||
:param end_date: 结束日期
|
||||
:param paged: 是否分页
|
||||
:param page_num: 页码
|
||||
:param page_size: 每页数量
|
||||
:return: 过滤条件字典
|
||||
"""
|
||||
return {
|
||||
'configName': config_name,
|
||||
'configKey': config_key,
|
||||
'configType': config_type,
|
||||
'beginDate': begin_date,
|
||||
'endDate': end_date,
|
||||
'paged': paged,
|
||||
'pageNum': page_num,
|
||||
'pageSize': page_size,
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
from .service import CRYPTO_RUNTIME, CryptoRuntimeService
|
||||
|
||||
__all__ = ['CRYPTO_RUNTIME', 'CryptoRuntimeService']
|
||||
@@ -0,0 +1,56 @@
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
|
||||
class CryptoInfrastructureGateway:
|
||||
"""
|
||||
传输加密基础设施网关。
|
||||
|
||||
该对象负责延迟加载 cryptography、环境配置和传输加密工具依赖,
|
||||
供传输加密运行时 facade 与其协作对象统一复用。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_serialization_module() -> Any:
|
||||
"""
|
||||
获取 cryptography 序列化模块。
|
||||
|
||||
:return: 序列化模块
|
||||
"""
|
||||
return import_module('cryptography.hazmat.primitives.serialization')
|
||||
|
||||
@staticmethod
|
||||
def get_rsa_module() -> Any:
|
||||
"""
|
||||
获取 RSA 非对称算法模块。
|
||||
|
||||
:return: RSA 模块
|
||||
"""
|
||||
return import_module('cryptography.hazmat.primitives.asymmetric.rsa')
|
||||
|
||||
@staticmethod
|
||||
def get_transport_crypto_config() -> Any:
|
||||
"""
|
||||
获取传输加密配置对象。
|
||||
|
||||
:return: 传输加密配置对象
|
||||
"""
|
||||
return import_module('config.env').TransportCryptoConfig
|
||||
|
||||
@staticmethod
|
||||
def get_transport_crypto_util() -> Any:
|
||||
"""
|
||||
获取传输加密工具类。
|
||||
|
||||
:return: 传输加密工具类
|
||||
"""
|
||||
return import_module('utils.transport_crypto_util').TransportCryptoUtil
|
||||
|
||||
@staticmethod
|
||||
def get_transport_key_provider() -> Any:
|
||||
"""
|
||||
获取传输加密密钥提供者。
|
||||
|
||||
:return: 传输加密密钥提供者
|
||||
"""
|
||||
return import_module('utils.transport_crypto_util').TransportKeyProvider
|
||||
@@ -0,0 +1,141 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from .gateway import CryptoInfrastructureGateway
|
||||
from .support import CryptoDomainSupport, CryptoResultSupport
|
||||
|
||||
|
||||
class CryptoRuntimeService:
|
||||
"""
|
||||
传输加密运行时服务。
|
||||
|
||||
该服务作为传输加密运行时 facade,对外统一暴露配置校验、
|
||||
公钥导出、密钥生成和轮换辅助方案构建入口。
|
||||
|
||||
:param infrastructure_gateway: 传输加密基础设施网关
|
||||
:param domain_support: 传输加密领域支持对象
|
||||
:param result_support: 传输加密结果支持对象
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
infrastructure_gateway: CryptoInfrastructureGateway | None = None,
|
||||
domain_support: CryptoDomainSupport | None = None,
|
||||
result_support: CryptoResultSupport | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
初始化传输加密运行时服务。
|
||||
|
||||
:param infrastructure_gateway: 传输加密基础设施网关
|
||||
:param domain_support: 传输加密领域支持对象
|
||||
:param result_support: 传输加密结果支持对象
|
||||
:return: None
|
||||
"""
|
||||
self.infrastructure_gateway = infrastructure_gateway or CryptoInfrastructureGateway()
|
||||
self.domain_support = domain_support or CryptoDomainSupport(self.infrastructure_gateway)
|
||||
self.result_support = result_support or CryptoResultSupport()
|
||||
|
||||
def generate_crypto_key_pair(self, kid: str, key_size: int) -> dict[str, Any]:
|
||||
"""
|
||||
生成新的传输加密密钥对。
|
||||
|
||||
:param kid: 目标密钥版本标识
|
||||
:param key_size: RSA 密钥长度
|
||||
:return: 密钥生成结果
|
||||
"""
|
||||
|
||||
def _operation() -> dict[str, Any]:
|
||||
self.domain_support.validate_rsa_key_size(key_size)
|
||||
private_key_pem, public_key_pem = self.domain_support.generate_rsa_key_pair(key_size)
|
||||
return {
|
||||
'ok': True,
|
||||
'message': '传输加密密钥生成完成',
|
||||
'kid': kid,
|
||||
'keySize': key_size,
|
||||
'privateKey': private_key_pem,
|
||||
'publicKey': public_key_pem,
|
||||
'envPatch': self.domain_support.build_key_pair_env_patch(kid, private_key_pem, public_key_pem),
|
||||
}
|
||||
|
||||
return self.result_support.run_argument_guarded(_operation, failure_message='生成传输加密密钥失败')
|
||||
|
||||
def validate_crypto_config(self) -> dict[str, Any]:
|
||||
"""
|
||||
校验传输加密运行配置。
|
||||
|
||||
:return: 传输加密配置校验结果
|
||||
"""
|
||||
transport_key_provider = self.infrastructure_gateway.get_transport_key_provider()
|
||||
|
||||
def _operation() -> dict[str, Any]:
|
||||
transport_key_provider.validate_runtime_configuration()
|
||||
return {'ok': True, 'message': '传输加密配置校验通过'}
|
||||
|
||||
return self.result_support.run_runtime_guarded(_operation, failure_message='传输加密配置校验失败')
|
||||
|
||||
def export_public_key(self) -> dict[str, Any]:
|
||||
"""
|
||||
导出当前运行环境的传输加密公钥信息。
|
||||
|
||||
:return: 公钥导出结果
|
||||
"""
|
||||
transport_key_provider = self.infrastructure_gateway.get_transport_key_provider()
|
||||
transport_crypto_util = self.infrastructure_gateway.get_transport_crypto_util()
|
||||
|
||||
def _operation() -> dict[str, Any]:
|
||||
transport_key_provider.validate_runtime_configuration()
|
||||
return {'ok': True, 'publicKey': transport_crypto_util.build_public_key_payload()}
|
||||
|
||||
return self.result_support.run_runtime_guarded(_operation, failure_message='导出传输加密公钥失败')
|
||||
|
||||
def build_rotation_payload(self, next_kid: str, key_size: int) -> dict[str, Any]:
|
||||
"""
|
||||
生成传输加密密钥轮换辅助结果。
|
||||
|
||||
:param next_kid: 新密钥版本标识
|
||||
:param key_size: 新密钥的 RSA 长度
|
||||
:return: 轮换辅助结果
|
||||
"""
|
||||
transport_key_provider = self.infrastructure_gateway.get_transport_key_provider()
|
||||
|
||||
def _operation() -> dict[str, Any]:
|
||||
self.domain_support.validate_rsa_key_size(key_size)
|
||||
transport_key_provider.validate_runtime_configuration()
|
||||
current_key_pair = transport_key_provider.get_current_key_pair()
|
||||
if next_kid == current_key_pair.kid:
|
||||
raise ValueError('新密钥版本不能与当前版本相同')
|
||||
|
||||
private_key_pem, public_key_pem = self.domain_support.generate_rsa_key_pair(key_size)
|
||||
legacy_key_pairs = self.domain_support.load_existing_legacy_key_pairs()
|
||||
merged_legacy_key_pairs = self.domain_support.merge_rotation_legacy_key_pairs(
|
||||
legacy_key_pairs,
|
||||
current_key_pair,
|
||||
next_kid,
|
||||
)
|
||||
return {
|
||||
'ok': True,
|
||||
'message': '传输加密密钥轮换方案已生成',
|
||||
'currentKid': current_key_pair.kid,
|
||||
'nextKid': next_kid,
|
||||
'keySize': key_size,
|
||||
'generatedAt': datetime.now().isoformat(),
|
||||
'nextKeyPair': {
|
||||
'kid': next_kid,
|
||||
'privateKey': private_key_pem,
|
||||
'publicKey': public_key_pem,
|
||||
},
|
||||
'legacyKeyPairs': merged_legacy_key_pairs,
|
||||
'envPatch': self.domain_support.build_rotation_env_patch(
|
||||
next_kid,
|
||||
private_key_pem,
|
||||
public_key_pem,
|
||||
merged_legacy_key_pairs,
|
||||
),
|
||||
'applySteps': self.result_support.build_rotation_apply_steps(),
|
||||
}
|
||||
|
||||
return self.result_support.run_argument_guarded(_operation, failure_message='生成密钥轮换方案失败')
|
||||
|
||||
|
||||
CRYPTO_RUNTIME = CryptoRuntimeService()
|
||||
@@ -0,0 +1,234 @@
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from cli.exit_codes import ARGUMENT_ERROR, RUNTIME_ERROR
|
||||
|
||||
from .gateway import CryptoInfrastructureGateway
|
||||
|
||||
MIN_RSA_KEY_SIZE = 2048
|
||||
RSA_KEY_SIZE_STEP = 256
|
||||
|
||||
|
||||
class CryptoDomainSupport:
|
||||
"""
|
||||
传输加密领域支持对象。
|
||||
|
||||
该对象负责 RSA 密钥规则校验、密钥对生成和历史密钥规整,
|
||||
避免主运行时服务继续承载局部安全规则和数据拼装细节。
|
||||
|
||||
:param infrastructure_gateway: 传输加密基础设施网关
|
||||
"""
|
||||
|
||||
def __init__(self, infrastructure_gateway: CryptoInfrastructureGateway) -> None:
|
||||
"""
|
||||
初始化传输加密领域支持对象。
|
||||
|
||||
:param infrastructure_gateway: 传输加密基础设施网关
|
||||
:return: None
|
||||
"""
|
||||
self.infrastructure_gateway = infrastructure_gateway
|
||||
|
||||
@staticmethod
|
||||
def validate_rsa_key_size(key_size: int) -> None:
|
||||
"""
|
||||
校验 RSA 密钥长度是否合法。
|
||||
|
||||
:param key_size: RSA 密钥长度
|
||||
:return: None
|
||||
:raises ValueError: 密钥长度不满足约束时抛出异常
|
||||
"""
|
||||
if key_size < MIN_RSA_KEY_SIZE or key_size % RSA_KEY_SIZE_STEP != 0:
|
||||
raise ValueError('RSA 密钥长度必须大于等于 2048,且为 256 的整数倍')
|
||||
|
||||
def generate_rsa_key_pair(self, key_size: int) -> tuple[str, str]:
|
||||
"""
|
||||
生成 PEM 格式的 RSA 密钥对。
|
||||
|
||||
:param key_size: RSA 密钥长度
|
||||
:return: 私钥与公钥 PEM 字符串
|
||||
"""
|
||||
rsa_module = self.infrastructure_gateway.get_rsa_module()
|
||||
serialization_module = self.infrastructure_gateway.get_serialization_module()
|
||||
private_key = rsa_module.generate_private_key(public_exponent=65537, key_size=key_size)
|
||||
private_key_pem = private_key.private_bytes(
|
||||
encoding=serialization_module.Encoding.PEM,
|
||||
format=serialization_module.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization_module.NoEncryption(),
|
||||
).decode('utf-8')
|
||||
public_key_pem = (
|
||||
private_key.public_key()
|
||||
.public_bytes(
|
||||
encoding=serialization_module.Encoding.PEM,
|
||||
format=serialization_module.PublicFormat.SubjectPublicKeyInfo,
|
||||
)
|
||||
.decode('utf-8')
|
||||
)
|
||||
return private_key_pem, public_key_pem
|
||||
|
||||
def load_existing_legacy_key_pairs(self) -> list[dict[str, str]]:
|
||||
"""
|
||||
解析当前配置中的历史密钥列表。
|
||||
|
||||
:return: 历史密钥列表
|
||||
:raises ValueError: 历史密钥配置非法时抛出异常
|
||||
"""
|
||||
transport_crypto_config = self.infrastructure_gateway.get_transport_crypto_config()
|
||||
raw_legacy_key_pairs = transport_crypto_config.transport_crypto_legacy_key_pairs or '[]'
|
||||
parsed_legacy_key_pairs = json.loads(raw_legacy_key_pairs)
|
||||
if not isinstance(parsed_legacy_key_pairs, list):
|
||||
raise ValueError('TRANSPORT_CRYPTO_LEGACY_KEY_PAIRS 必须为 JSON 数组')
|
||||
return [item for item in parsed_legacy_key_pairs if isinstance(item, dict)]
|
||||
|
||||
@staticmethod
|
||||
def merge_rotation_legacy_key_pairs(
|
||||
legacy_key_pairs: list[dict[str, str]],
|
||||
current_key_pair: Any,
|
||||
next_kid: str,
|
||||
) -> list[dict[str, str]]:
|
||||
"""
|
||||
合并轮换后的历史密钥列表。
|
||||
|
||||
:param legacy_key_pairs: 当前历史密钥列表
|
||||
:param current_key_pair: 当前生效密钥对对象
|
||||
:param next_kid: 即将切换的新密钥版本
|
||||
:return: 合并后的历史密钥列表
|
||||
"""
|
||||
normalized_legacy_key_pairs: dict[str, dict[str, str]] = {
|
||||
str(item.get('kid')): item for item in legacy_key_pairs if item.get('kid')
|
||||
}
|
||||
normalized_legacy_key_pairs[current_key_pair.kid] = {
|
||||
'kid': current_key_pair.kid,
|
||||
'privateKey': current_key_pair.private_key_pem,
|
||||
'publicKey': current_key_pair.public_key_pem,
|
||||
}
|
||||
normalized_legacy_key_pairs.pop(next_kid, None)
|
||||
return list(normalized_legacy_key_pairs.values())
|
||||
|
||||
@staticmethod
|
||||
def build_key_pair_env_patch(kid: str, private_key_pem: str, public_key_pem: str) -> dict[str, str]:
|
||||
"""
|
||||
构建密钥对写回环境变量所需的补丁数据。
|
||||
|
||||
:param kid: 密钥版本标识
|
||||
:param private_key_pem: 私钥 PEM 文本
|
||||
:param public_key_pem: 公钥 PEM 文本
|
||||
:return: 环境变量补丁数据
|
||||
"""
|
||||
return {
|
||||
'TRANSPORT_CRYPTO_KID': kid,
|
||||
'TRANSPORT_CRYPTO_PRIVATE_KEY': private_key_pem,
|
||||
'TRANSPORT_CRYPTO_PUBLIC_KEY': public_key_pem,
|
||||
}
|
||||
|
||||
def build_rotation_env_patch(
|
||||
self,
|
||||
next_kid: str,
|
||||
private_key_pem: str,
|
||||
public_key_pem: str,
|
||||
merged_legacy_key_pairs: list[dict[str, str]],
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
构建密钥轮换写回环境变量所需的补丁数据。
|
||||
|
||||
:param next_kid: 新密钥版本标识
|
||||
:param private_key_pem: 新私钥 PEM 文本
|
||||
:param public_key_pem: 新公钥 PEM 文本
|
||||
:param merged_legacy_key_pairs: 合并后的历史密钥列表
|
||||
:return: 环境变量补丁数据
|
||||
"""
|
||||
env_patch = self.build_key_pair_env_patch(next_kid, private_key_pem, public_key_pem)
|
||||
env_patch['TRANSPORT_CRYPTO_LEGACY_KEY_PAIRS'] = json.dumps(merged_legacy_key_pairs, ensure_ascii=False)
|
||||
return env_patch
|
||||
|
||||
|
||||
class CryptoResultSupport:
|
||||
"""
|
||||
传输加密结果支持对象。
|
||||
|
||||
该对象负责参数错误/运行时错误结果翻译,以及固定轮换提示文案构建,
|
||||
避免主运行时服务继续承载重复异常映射与静态 payload 片段。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def build_argument_error_result(message: str, exc: Exception) -> dict[str, Any]:
|
||||
"""
|
||||
构建参数错误结果。
|
||||
|
||||
:param message: 失败消息
|
||||
:param exc: 原始异常
|
||||
:return: 标准参数错误结果
|
||||
"""
|
||||
return {
|
||||
'ok': False,
|
||||
'message': message,
|
||||
'error': str(exc),
|
||||
'exit_code': ARGUMENT_ERROR,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def build_runtime_error_result(message: str, exc: Exception) -> dict[str, Any]:
|
||||
"""
|
||||
构建运行时错误结果。
|
||||
|
||||
:param message: 失败消息
|
||||
:param exc: 原始异常
|
||||
:return: 标准运行时错误结果
|
||||
"""
|
||||
return {
|
||||
'ok': False,
|
||||
'message': message,
|
||||
'error': str(exc),
|
||||
'exit_code': RUNTIME_ERROR,
|
||||
}
|
||||
|
||||
def run_argument_guarded(
|
||||
self,
|
||||
operation: Callable[[], dict[str, Any]],
|
||||
*,
|
||||
failure_message: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
以统一参数/运行时错误翻译执行操作。
|
||||
|
||||
:param operation: 待执行操作
|
||||
:param failure_message: 失败消息
|
||||
:return: 操作结果
|
||||
"""
|
||||
try:
|
||||
return operation()
|
||||
except ValueError as exc:
|
||||
return self.build_argument_error_result(failure_message, exc)
|
||||
except Exception as exc:
|
||||
return self.build_runtime_error_result(failure_message, exc)
|
||||
|
||||
def run_runtime_guarded(
|
||||
self,
|
||||
operation: Callable[[], dict[str, Any]],
|
||||
*,
|
||||
failure_message: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
以统一运行时错误翻译执行操作。
|
||||
|
||||
:param operation: 待执行操作
|
||||
:param failure_message: 失败消息
|
||||
:return: 操作结果
|
||||
"""
|
||||
try:
|
||||
return operation()
|
||||
except Exception as exc:
|
||||
return self.build_runtime_error_result(failure_message, exc)
|
||||
|
||||
@staticmethod
|
||||
def build_rotation_apply_steps() -> list[str]:
|
||||
"""
|
||||
构建密钥轮换后的应用步骤说明。
|
||||
|
||||
:return: 轮换应用步骤列表
|
||||
"""
|
||||
return [
|
||||
'将 envPatch 中的新密钥写入目标环境配置',
|
||||
'发布后保留 legacyKeyPairs,确保旧请求在轮换窗口内可解密',
|
||||
'确认前端已刷新到 nextKid 后,再移除不再需要的历史密钥',
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user