Compare commits

..
79 Commits
Author SHA1 Message Date
焦龙言 3edba99d2d 调整部分前端布局和后端增删改逻辑 2026-09-20 10:38:10 +08:00
焦龙言 acddc8a237 同步业务菜单现有图标配置 2026-07-28 09:39:54 +08:00
焦龙言 b4822fef12 修复业务菜单图标并固化菌种编码模糊查询 2026-07-28 08:43:31 +08:00
焦龙言 9ef214f040 测试:封闭菜单补丁原子性回归 2026-07-27 21:41:12 +08:00
焦龙言 b608a166c3 测试:加固菜单补丁门禁契约 2026-07-27 21:17:28 +08:00
焦龙言 a1613f8780 迁移:新增业务菜单层级补丁 2026-07-27 21:02:48 +08:00
焦龙言 db5755586d 测试:加强一级菜单稳定字段契约 2026-07-27 20:41:20 +08:00
焦龙言 619f3d5d63 调整:提升业务菜单为一级目录 2026-07-27 20:30:18 +08:00
焦龙言 9a9c57578d 计划:实施业务菜单一级展示 2026-07-27 20:19:53 +08:00
焦龙言 23b3cc3821 设计:定义业务菜单一级展示 2026-07-27 20:05:26 +08:00
焦龙言 8a5a8d976e 文档:记录本地数据库执行结果 2026-07-27 19:45:48 +08:00
焦龙言 64ed087f5c 构建:同步前端业务依赖 2026-07-27 19:24:30 +08:00
焦龙言 9a32f1a897 修复:保障菜单补丁原子执行 2026-07-27 19:18:23 +08:00
焦龙言 b6f3b1e1f0 修复:限定幂等键精确业务域 2026-07-27 18:54:50 +08:00
焦龙言 4a321098f8 修复:收紧托盘业务权限与数据契约 2026-07-27 17:40:24 +08:00
焦龙言 9b1a807098 文档:补充托盘业务部署说明 2026-07-27 16:09:58 +08:00
焦龙言 684dd451bb 格式:统一使用业务代码 Ruff 格式 2026-07-27 15:59:09 +08:00
焦龙言 37c94b6713 测试:限定前端权限门禁接收者 2026-07-27 15:45:45 +08:00
焦龙言 9bdafd3bac 测试:收紧前端权限与组件契约 2026-07-27 15:36:30 +08:00
焦龙言 6f751486e4 测试:拒绝非约定前端权限门禁 2026-07-27 15:11:53 +08:00
焦龙言 33d73ca0ef 测试:校验菜单接口权限一致性 2026-07-27 14:59:36 +08:00
焦龙言 9b5f479f0d 修复:校验 MySQL 菜单固定ID冲突 2026-07-27 14:24:30 +08:00
焦龙言 3d1b64971f 测试:加强 PostgreSQL 时间类型契约 2026-07-27 13:42:34 +08:00
焦龙言 7a786800bd 修复:保留 PostgreSQL 时间微秒精度 2026-07-27 13:33:08 +08:00
焦龙言 b858340890 功能:新增托盘业务数据库脚本 2026-07-27 13:12:19 +08:00
焦龙言 626e15fe89 修复:移除发货报表无效状态筛选 2026-07-27 12:25:24 +08:00
焦龙言 2ab075f095 修复:补全托盘追溯位置回退 2026-07-27 11:50:37 +08:00
焦龙言 8361c11c4c 功能:新增托盘业务报表页面 2026-07-27 11:14:36 +08:00
焦龙言 85faef213e 修复:按发货状态读取当前余量 2026-07-27 10:50:25 +08:00
焦龙言 4340d03a70 修复:完善装填状态与确认权限 2026-07-27 10:12:05 +08:00
焦龙言 a223971a3c 修复:完善业务页面异步与扫码状态 2026-07-27 09:39:53 +08:00
焦龙言 e4477f321c 修复:补充发货草稿当前余量 2026-07-27 09:05:04 +08:00
焦龙言 d5da207206 功能:新增托盘业务操作页面 2026-07-27 08:31:31 +08:00
焦龙言 e5006c6e43 修复:返回使用后的余量与状态 2026-07-27 06:27:29 +08:00
焦龙言 d0fa20afe8 修复:完善基础资料页异步与提交保护 2026-07-27 06:07:17 +08:00
焦龙言 2d2b3cf6c6 修复:防止异步响应覆盖与打印资源泄漏 2026-07-27 05:44:20 +08:00
焦龙言 137b2934f7 功能:新增业务基础资料页面 2026-07-27 05:27:20 +08:00
焦龙言 246e69f5c8 补强主数据可删除性查询测试 2026-07-27 04:51:29 +08:00
焦龙言 33f29df40e 补充主数据删除能力契约 2026-07-27 04:34:10 +08:00
焦龙言 d1f0b9d48b 修复:增强扫码与接口契约校验 2026-07-27 04:00:40 +08:00
焦龙言 1f23d92ffb 功能:新增业务接口与扫码组件 2026-07-27 03:17:52 +08:00
焦龙言 c08b5a5d3d 测试:补强业务权限契约覆盖范围 2026-07-27 02:42:24 +08:00
焦龙言 dc190f1162 测试:固化业务接口权限契约 2026-07-27 02:25:39 +08:00
焦龙言 6b97bb9f49 修复:完善报表查询校验 2026-07-27 02:04:45 +08:00
焦龙言 dfd720359a 功能:新增托盘业务报表接口 2026-07-27 01:27:16 +08:00
焦龙言 c9180b3832 修复:统一退回锁序并避免提交后访问 2026-07-27 00:49:29 +08:00
焦龙言 80c2288122 修复:支持已确认退回的并发幂等 2026-07-27 00:23:04 +08:00
焦龙言 12260fdf83 修复:完善退回并发幂等与信息隐藏 2026-07-27 00:08:57 +08:00
焦龙言 e58e110dd1 功能:实现托盘两阶段退回 2026-07-26 23:33:01 +08:00
焦龙言 cda87a4d65 修复:消除使用幂等缺失键间隙锁 2026-07-26 22:34:46 +08:00
焦龙言 33228faffc 修复:补强使用并发幂等复核 2026-07-26 21:43:55 +08:00
焦龙言 bc774c74e6 功能:实现客户现场使用记录 2026-07-26 21:14:01 +08:00
焦龙言 3076194328 修复:统一发货路由错误隐藏 2026-07-26 20:26:59 +08:00
焦龙言 dc28cf6df0 修复:强化发货权限与幂等一致性 2026-07-26 20:06:35 +08:00
焦龙言 7317631a13 功能:实现多托盘发货出库 2026-07-26 19:11:52 +08:00
焦龙言 66890ca63c 修复:装填前复核仓库权限 2026-07-26 18:16:34 +08:00
焦龙言 f6e994d95f 修复:强化装填事务一致性 2026-07-26 17:56:58 +08:00
焦龙言 18abe861f9 功能:实现单托盘装填 2026-07-26 17:09:17 +08:00
焦龙言 2e616ab6e0 修复:收紧入库数据范围校验 2026-07-26 16:14:27 +08:00
焦龙言 6f22cf0282 功能:实现空托盘首次入库 2026-07-26 15:19:25 +08:00
焦龙言 317a0fbf68 修复:避免首事件间隙锁死锁 2026-07-26 14:24:55 +08:00
焦龙言 6c2a5fa344 修复:强化库存事务并发与日期边界 2026-07-26 13:54:42 +08:00
焦龙言 85905d85ca 功能:新增托盘库存事务基础 2026-07-26 13:18:56 +08:00
焦龙言 2e35da13cc 修复:限制托盘分页大小 2026-07-26 12:51:32 +08:00
焦龙言 aba8a96cb8 修复:消除托盘与用户分配查询竞态 2026-07-26 12:36:00 +08:00
焦龙言 6851499be2 功能:新增托盘二维码与用户分配 2026-07-26 11:48:22 +08:00
焦龙言 f3eda856b0 功能:新增业务基础资料接口 2026-07-26 10:41:14 +08:00
焦龙言 f5d47ad2bb 测试:强化数据权限查询谓词校验 2026-07-26 10:02:45 +08:00
焦龙言 83a834f87e 功能:新增仓库与客户现场数据权限 2026-07-26 09:42:12 +08:00
焦龙言 36e60af46b 修复:收紧托盘业务包数校验 2026-07-24 21:29:20 +08:00
焦龙言 f747a80a2a 功能:新增托盘业务接口模型 2026-07-24 21:07:43 +08:00
焦龙言 eab725a541 修复:校正业务模型审计默认值 2026-07-24 20:45:10 +08:00
焦龙言 f814f916a5 测试:强化业务模型元数据契约 2026-07-24 20:25:02 +08:00
焦龙言 f6e9566f70 功能:新增托盘业务数据模型 2026-07-24 20:09:36 +08:00
焦龙言 cb3bf5bf4c 修复:校验托盘状态类型 2026-07-24 19:53:04 +08:00
焦龙言 bab09f4eb3 功能:新增托盘状态机 2026-07-24 19:37:46 +08:00
焦龙言 5209911dd8 重构:移除 AI 管理模块 2026-07-24 19:06:55 +08:00
焦龙言 a509dada79 文档:新增托盘业务实施计划 2026-07-24 18:00:27 +08:00
焦龙言 ce6846f920 文档:新增托盘业务系统设计 2026-07-24 16:58:26 +08:00
335 changed files with 167941 additions and 4857 deletions
+15
View File
@@ -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>
+2
View File
@@ -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管理模块。
### 代码优化
+11 -1
View File
@@ -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)。
## 在线体验
+86
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
# ShiErFeng 移动端
# 智链云枢管理系统移动端
基于 `uni-app` 的 `vite` + `vue3` + `tailwindcss` 开发。
+1 -1
View File
@@ -4,7 +4,7 @@ export default {
// 应用信息
appInfo: {
// 应用名称
name: "ShiErFeng-FastAPI-APP",
name: "智链云枢管理系统",
// 应用版本
version: "1.9.0",
// 应用logo
+2 -2
View File
@@ -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": "./"
+2 -2
View File
@@ -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>
+1 -1
View File
@@ -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 -->
+1 -1
View File
@@ -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",
},
],
+1 -1
View File
@@ -14,7 +14,7 @@
/>
</view>
<text class="text-xl font-bold tracking-wide text-gray-800"
>ShiErFeng-FastAPI移动端注册</text
>智链云枢管理系统注册</text
>
</view>
+1 -1
View File
@@ -51,7 +51,7 @@ DB_PORT = 3306
# 数据库用户名
DB_USERNAME = 'root'
# 数据库密码
DB_PASSWORD = 'mysqlroot'
DB_PASSWORD = '12345678'
# 数据库名称
DB_DATABASE = 'shierfeng-fastapi'
# 是否开启sqlalchemy日志
+1 -1
View File
@@ -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