refactor: 重构框架项目,轻量微服务+copier+底包仓库依赖方式,而非代码直接进入

main v0.1.0
NoahLan 3 weeks ago
parent 13395ea3e3
commit 3c11b39b79

@ -0,0 +1,71 @@
# grill-me
A relentless interviewer skill for any AI coding assistant that supports the [open agent skills](https://github.com/vercel-labs/skills) format — Claude Code, Cursor, Codex, OpenCode, Continue, Windsurf, and 40+ others.
`/grill-me` does not hunt for bugs. It expands your understanding of what you actually want by surfacing intent, constraints, hidden assumptions, and unstated alternatives — across coding, marketing, personal branding, SOPs, systems thinking, process design, and tough business decisions.
## Install
```bash
# Project-local install (default) — committed with your project
npx skills@latest add satya-janghu/agent-skills/skills/grill-me
# Global install — available across all your projects
npx skills@latest add satya-janghu/agent-skills/skills/grill-me -g
# Non-interactive, Claude Code only, global
npx skills@latest add satya-janghu/agent-skills/skills/grill-me -g -a claude-code -y
```
The `skills` CLI prompts you for which AI agent to install for (Claude Code, Cursor, Codex, etc.) and whether to install project-locally or user-globally.
If you don't want to use the CLI, see [Manual install](#manual-install) below.
## Usage
Inside any AI agent that supports skills:
```
/grill-me <what you want grilled on>
```
Or trigger by phrase: "grill me on…", "interview me about…", "pressure-test this…", "help me think through…".
The skill ends when the next concrete action becomes possible (writing code, drafting a brief, editing an SOP, making a commit). Before that action, it writes a distilled session log to `<cwd>/.grill/<slug>.md`.
## What makes it different
Most AI assistants ask too few questions and declare "I have enough to start" too early. `grill-me` is engineered to fight that:
- **One question at a time, with a recommended answer attached** — gives you something to react to instead of a blank prompt.
- **Drills the last answer before moving sideways** — the depth comes from following one thread to the bottom, not from breadth.
- **Pulls from a menu of lenses** without naming them — first-principles, pre-mortem, steelman, reversibility, five-whys, audience, hidden-assumption excavation, second-best, sustainability, plus established mental-model frames (Naval permissionless leverage, Thiel "what do you believe…", Hormozi value equation, Christensen JTBD, Munger inversion, Bezos regret minimization). The conversation feels natural; the structure is hidden.
- **Pushes back on vague answers, deflections, and contradictions** rather than accepting fog.
- **Strawmans half-answers by default** — easier to disagree with a draft than invent from blank.
- **Adapts the lens to the domain** (coding vs. marketing vs. SOPs vs. business decisions), but does not bug-hunt. The goal is expanding the user's understanding of what they want, not finding flaws in execution.
- **Writes a session log to `<cwd>/.grill/<slug>.md`** — Intent, Constraints, Key decisions, Surfaced assumptions, Open questions, Out of scope. The log is the distilled output, not a transcript.
See [SKILL.md](SKILL.md) for the full instruction set.
## Manual install
If you prefer not to use the `skills` CLI, drop `SKILL.md` directly into the right location for your agent:
| Agent | Location |
|---|---|
| Claude Code (global) | `~/.claude/skills/grill-me/SKILL.md` |
| Claude Code (project) | `<project>/.claude/skills/grill-me/SKILL.md` |
| Cursor | `<project>/.cursor/skills/grill-me/SKILL.md` |
| Codex | `<project>/.codex/skills/grill-me/SKILL.md` |
A one-liner using `curl`:
```bash
mkdir -p ~/.claude/skills/grill-me && \
curl -fsSL https://raw.githubusercontent.com/satya-janghu/agent-skills/main/skills/grill-me/SKILL.md \
-o ~/.claude/skills/grill-me/SKILL.md
```
## License
MIT — see [LICENSE](../../LICENSE).

@ -0,0 +1,96 @@
---
name: grill-me
description: Interview the user relentlessly to expand context and surface intent, constraints, hidden assumptions, and unstated alternatives. Use whenever the user invokes `/grill-me`, says "grill me", "interview me", "pressure-test this", "help me think through", or whenever the user's first message is more decision than task — across coding, business, marketing, personal branding, SOPs, systems thinking, process design, and tough decisions.
---
# grill-me
Your job is to **expand the user's context and understanding of what they actually want** through relentless, high-quality questioning. This is not bug-hunting. It is not a checklist. You are surfacing intent, constraints, hidden assumptions, and unstated alternatives that the user has not yet made explicit — even to themselves.
## Core loop
1. Ask **one question at a time**.
2. Provide your **recommended answer** alongside each question, so the user has something to react to rather than a blank prompt.
3. After each answer, **drill into the answer you just got** before moving sideways to a new branch. Most premature exits happen because you moved on too soon.
4. If a question can be answered by reading code, files, or the project itself — **investigate instead of asking**.
5. End when the next concrete action (writing code, editing an SOP, drafting a brief, making a commit, etc.) becomes possible — and only then. Before taking that action, write the session log (see "Logging" below).
## How to ask better questions than you normally would
Your default behavior is to ask too few questions and declare convergence too early. Counteract that:
- **When you feel you have enough to act, ask three more questions.** That feeling is the surface, not the bottom.
- **Do not summarize as progress.** "So what I'm hearing is X, Y, Z" ends grilling — it does not advance it. Ask, don't paraphrase.
- **Push back on vague answers.** "I'll figure it out later", "probably X", "something like Y" are signals to drill, not move on.
- **You are allowed — and expected — to call out contradictions, deflections, and hand-waving.** Politely, but without softening to the point of accepting fog.
- **Adapt the questioning lens to the domain** (coding, marketing, branding, SOPs, business decisions). Read the project — what files exist, what the user just said, what the work actually is — and let that shape what you probe. The lens shapes the *kind* of question, not whether you ask it.
## Question lenses to draw from
You have a menu of lenses. **Do not name the lens out loud** — keep the conversation natural. Pull from these dynamically, mixing freely. There is no required count and no domain-locked subset. Use what fits.
- **First-principles.** Strip the problem to fundamentals. "If you started from zero — no existing tools, audience, or code — would you still do it this way?"
- **Intent and desired outcome.** What does *winning* look like for the user personally, not the project's stated success criteria?
- **Constraint surfacing.** What is non-negotiable? Time, money, energy, values, identity. The real design lives in the constraints.
- **Hidden assumption excavation.** "You said X — what has to be true for X to hold?"
- **Second-best alternative.** What's the path they're *not* taking? If they can't name it, they haven't actually chosen.
- **Pre-mortem.** "It's 12 months from now and this failed. Walk me through why."
- **Steelman the opposite.** Make the strongest case *against* their plan. If they can't, conviction is shallow.
- **Audience / stakeholder lens.** Who is this *for*, specifically — name a single person. What do they think, fear, want?
- **Reversibility.** One-way door or two-way door? They are designed differently.
- **Five-whys / root cause.** "Why does that matter?" recursively until you hit a value, identity, or non-negotiable.
- **Boundary testing.** What is *out of scope*? Naming what you will not do is often more clarifying than what you will.
- **Sustainability.** Would they still do this if it took 3x as long as expected? If not, the plan is fragile.
You may also draw from established mental-model frames — Naval's permissionless leverage, Thiel's "what do you believe that nobody agrees with", Hormozi's value equation, Christensen's jobs-to-be-done, Bezos's regret minimization, Munger's inversion, Kahneman's pre-commitment, Drucker's "what does the customer value?", Andy Grove's "what are we trying to optimize for?", and similar — without naming the source. Adopt the frame, not the brand.
## Handling half-answers
When the user gives a hedge or a placeholder ("I dunno, maybe X"):
- **Default: propose a strawman they can react to.** "Here's an answer — tell me where it's wrong: …" This is higher-leverage than open-ended pushing because disagreement is easier than invention.
- **When the user pushes back on the question itself** (i.e., they think the question is wrong, not the answer): reframe — "what would you need to know to make this answerable?" — and follow that thread.
## Logging
When grilling converges and the next action is possible, **before taking that action**, write a markdown log to:
```
<cwd>/.grill/<slug>.md
```
where `<slug>` is a kebab-case summary of the topic. Create the directory if it does not exist.
Use this structure. **Delete any section that ended up empty** — do not leave "TBD" placeholders.
```markdown
# Grill: <topic>
Date: <ISO date>
## Intent
What the user is actually trying to achieve, in their words, refined.
## Constraints
Non-negotiables surfaced during grilling.
## Key decisions
- Decision: <what was decided>. Reason: <why>. Alternative considered: <what was rejected>.
## Surfaced assumptions
Things the user was implicitly assuming, now made explicit.
## Open questions
Things the user could not answer yet, deferred for later.
## Out of scope
Things the user explicitly chose not to do.
```
The log is the *distilled* output, not a transcript. Capture conclusions and the reasoning behind them, not the back-and-forth.
## What this skill is not
- **Not a bug hunt.** You are not looking for race conditions, broken positioning, or weak SOP steps. You are expanding the user's understanding of what they want and why.
- **Not a checklist.** No mandatory questions, no required count, no fixed order. Adapt to what the user just said.
- **Not a summary tool.** Summarizing is the opposite of grilling. Save synthesis for the log at the end.
- **Not a coach.** Don't motivate. Don't validate. Probe.

@ -0,0 +1,255 @@
---
name: mcp-builder
description: MCP 服务器构建方法论 — 系统化构建生产级 MCP 工具,让 AI 助手连接外部能力
---
# MCP 服务器构建
系统化设计、实现、测试和部署 Model Context Protocol 服务器的方法论。
## 1. 协议核心概念
MCP 定义三种原语:
- **Tools工具**AI 助手主动调用的函数,有副作用。如搜索、创建、删除操作。
- **Resources资源**AI 助手只读访问的数据源,用 URI 标识。如 `users://{id}/profile`
- **Prompts提示词模板**:预定义交互模板,引导用户触发工作流。
**选择原则:** 执行操作 → Tool | 读取数据 → Resource | 引导交互 → Prompt
## 2. 项目结构规范
### TypeScript
```
my-mcp-server/
├── src/
│ ├── index.ts # 入口,注册 tools/resources
│ ├── tools/ # 按功能拆分
│ ├── resources/
│ └── lib/ # 客户端封装、校验逻辑
├── tests/
├── package.json
└── tsconfig.json
```
关键依赖:`@modelcontextprotocol/sdk` + `zod`
### Python
```
my-mcp-server/
├── src/my_mcp_server/
│ ├── server.py
│ ├── tools/
│ └── lib/
├── tests/
└── pyproject.toml
```
关键依赖:`mcp` + `pydantic`
## 3. Tool 设计原则
### 命名
- `snake_case` 格式,动词开头:`search_users`、`create_issue`、`delete_file`
- 名称自解释AI 助手靠名称选工具,模糊命名导致误调用
### 参数
- 每个参数有类型约束和 `.describe()` 描述
- 可选参数给默认值,减少 AI 决策负担
- 用枚举代替布尔开关
```typescript
server.tool("search_issues", {
query: z.string().describe("搜索关键词"),
status: z.enum(["open", "closed", "all"]).default("open").describe("状态筛选"),
limit: z.number().min(1).max(100).default(20).describe("返回上限"),
}, async ({ query, status, limit }) => { /* ... */ });
```
### 描述
说明**用途 + 返回内容 + 限制**,这是 AI 选择工具的关键依据:
```typescript
server.tool("search_users",
"根据姓名或邮箱搜索用户。返回 ID、姓名、邮箱列表。模糊匹配最多 50 条。",
schema, handler);
```
### 输出
- 结构化数据 → JSON人类可读内容 → Markdown
- 始终用 `content: [{ type: "text", text: "..." }]` 格式返回
## 4. 输入验证和错误处理
用 Zod/Pydantic 做 Schema 级校验,业务级校验放 handler 开头:
```typescript
server.tool("get_user", { id: z.string() }, async ({ id }) => {
try {
const user = await db.getUser(id);
if (!user) {
return {
content: [{ type: "text", text: `用户 ${id} 不存在,请检查 ID。` }],
isError: true,
};
}
return { content: [{ type: "text", text: JSON.stringify(user, null, 2) }] };
} catch (err) {
return {
content: [{ type: "text", text: `查询失败:${err.message}` }],
isError: true,
};
}
});
```
**错误处理四原则:**
1. 永远不让服务器崩溃 — try/catch 包裹所有外部调用
2. 返回可操作的错误信息 — 告诉 AI 问题是什么、能做什么
3. 使用 `isError: true` — 让 AI 知道调用失败
4. 区分错误类型 — 参数错误、权限不足、资源不存在、服务不可用
## 5. 资源管理和生命周期
```typescript
// 资源注册
server.resource("user-profile", "users://{userId}/profile", async (uri) => {
const profile = await db.getProfile(extractId(uri));
return { contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(profile) }] };
});
// 生命周期:先初始化 → 再 connect → 监听关闭信号
const db = await Database.connect(config.dbUrl);
await server.connect(new StdioServerTransport());
process.on("SIGINT", async () => { await db.disconnect(); await server.close(); process.exit(0); });
```
关键点:使用连接池、所有外部调用设超时、优雅关闭清理资源。
## 6. 测试策略
### 单元测试 — 业务逻辑与 MCP 注册分离
```typescript
// tools/search.ts 导出纯函数
export async function searchUsers(query: string, limit: number) { /* ... */ }
// search.test.ts 独立测试
test("返回匹配结果", async () => {
const results = await searchUsers("alice", 10);
expect(results[0].name).toContain("Alice");
});
```
### 集成测试 — 用 SDK Client 做端到端验证
```typescript
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
const client = new Client({ name: "test", version: "1.0.0" });
await client.connect(clientTransport);
const result = await client.callTool("search_users", { query: "test" });
expect(result.isError).toBeFalsy();
```
### MCP Inspector — 交互式调试
```bash
npx @modelcontextprotocol/inspector node dist/index.js
```
在浏览器中查看所有 tools/resources手动调用并查看结果。
**测试要点:** 每个 Tool 覆盖正常 + 异常路径、边界值、外部服务失败模拟。
## 7. 安全考虑
**权限控制:**
- 最小权限原则,读写 Tool 分离
- 危险操作要求确认参数(如 `confirm: true`
**输入安全:**
- SQL 注入 → 参数化查询,绝不拼接
- 路径遍历 → 校验路径,禁止 `../`
- 命令注入 → 用 `execFile` 而非 `exec`
**敏感数据:**
- 密钥通过环境变量传入,不硬编码
- 日志不打印完整敏感信息
- 返回数据做脱敏处理
**沙箱:** 文件操作限制目录、网络请求限制白名单、设置资源配额。
## 8. 部署和分发
### npm 发布
```json
{ "bin": { "mcp-server-myservice": "dist/index.js" }, "files": ["dist"] }
```
用户配置:
```json
{ "mcpServers": { "myservice": { "command": "npx", "args": ["@yourorg/mcp-server-myservice"], "env": { "API_KEY": "xxx" } } } }
```
### pip 发布
```toml
[project.scripts]
mcp-server-myservice = "my_mcp_server.server:main"
```
### Docker — 适用于复杂依赖或隔离场景
```dockerfile
FROM node:20-slim
WORKDIR /app
COPY package*.json ./ && RUN npm ci --production
COPY dist ./dist
ENTRYPOINT ["node", "dist/index.js"]
```
## 9. 调试技巧
**关键MCP 用 stdio 通信,不能用 `console.log`,会破坏协议流。**
```typescript
// 错误
console.log("debug");
// 正确
console.error("[DEBUG]", info);
// 更好
server.sendLoggingMessage({ level: "info", data: "处理中" });
```
**常见问题:**
| 症状 | 原因 | 解决 |
|------|------|------|
| 启动无响应 | transport 未连接 | 检查 `server.connect()` |
| Tool 不出现 | 注册在 connect 之后 | 先注册再 connect |
| AI 不调用 Tool | 描述不清晰 | 改善名称和描述 |
| 参数总错 | Schema 不明确 | 添加 `.describe()` |
| 调用超时 | 外部服务慢 | 加超时和缓存 |
**调试流程:** Inspector 验证基本功能 → 手动调用确认输入输出 → 连接真实 AI 客户端观察调用模式 → 根据实际行为调整设计。
## 10. 构建检查清单
### 设计
- [ ] 明确 Tools vs Resources vs Prompts 分工
- [ ] Tool 命名 `动词_名词`,描述说明用途和返回内容
- [ ] 参数简洁,可选参数有合理默认值
### 实现
- [ ] 输入用 Zod/Pydantic 校验
- [ ] 外部调用有 try/catch 和超时
- [ ] 错误返回 `isError: true` 并附可操作信息
- [ ] 不用 `console.log`(用 stderr 或 SDK 日志)
- [ ] 敏感数据走环境变量
### 测试
- [ ] 核心逻辑有单元测试
- [ ] 有集成测试验证 MCP 协议交互
- [ ] 用 MCP Inspector 手动验证过
- [ ] 用真实 AI 客户端测试过
### 部署
- [ ] README 含安装和配置说明
- [ ] 提供客户端配置 JSON 示例
- [ ] 遵循 semver无硬编码密钥

@ -0,0 +1,122 @@
---
name: netx-coding
description: Use when implementing code changes in the netx repository across Rust crates, controller/core runtime, protocol DTOs, Admin Console, Desktop Core UI, build scripts, or verification flows.
---
# netx 编码
## 使用场景
`/root/Projects/Mine/netx` 中写代码、修 bug、改构建、改 API、改 UI 时使用。
## 先读
按任务读取:
- 通用编码:`docs/specs/coding-guide.md`
- 架构边界:`docs/specs/architecture.md`
- 协议字段:`docs/specs/protocol.md`
- UI`docs/specs/ui-design.md`
- 需求状态:`docs/specs/traceability.md`
## 当前边界
只维护:
- `netx-controller`
- `netx-core`
- `apps/netx-desktop`
- `web/admin`
新增入口必须归入这些产品边界。
## 修改位置
| 任务 | 位置 |
| --- | --- |
| 控制 API、状态投影、任务、controller state/bootstrap/metrics/API DTO 聚合 | `crates/netx-controller/src/state.rs`、`bootstrap_plan.rs`、`runtime_metrics.rs`、`control_api_models.rs`、其它 `control_api_*` 模块 |
| Controller audit/session API、managed session 执行和 system metrics/diagnostics | `crates/netx-controller/src/sessions.rs`、`sessions_diagnostics.rs` |
| Controller core plan、gateway assignment helper 和 service projection | `crates/netx-controller/src/core_planner.rs`、`core_planner_gateway.rs`、`core_planner_http.rs`、`core_planner_services.rs` |
| Controller embedded gateway handler、bridge accept、hosted service supervisor、public/mixed entry、HTTP entry、HTTP3、HTTPS passthrough/terminate、routing/http/body/backend/connection 适配 | `crates/netx-controller/src/gateway_http_entry.rs`、`gateway_bridge_accept.rs`、`gateway_hosted_services.rs`、`gateway_public_entry.rs`、`gateway_mixed_entry.rs`、`gateway_http3.rs`、`gateway_https_passthrough.rs`、`gateway_https_terminate.rs`、`gateway_routing.rs`、`gateway_http.rs`、`gateway_body.rs`、`gateway_backend.rs`、`gateway_connection.rs` |
| Core 运行编排、heartbeat loop state、DeliveredConfig managed client/service/proxy/overlay runtime config selection、initial service/proxy selection、NAT probe binding snapshot、peer engine tick/requested-attempt TTL state、punch ready/候选排序/端口扫描策略和 punch attempt 到 peer identity 映射 | `crates/netx-core-runtime` |
| 共享路由、执行计划、path selection | `crates/netx-core-engine` |
| 本机 Local API | `crates/netx-core-local` |
| Core service 命令、低阶 CLI runtime、Controller API 命令、解析和报告 | `apps/netx-core/src/cli_service.rs`、`cli_local_runtime.rs`、`cli_controller_api.rs`、`cli_parse.rs`、`cli_reports.rs` |
| Core app session 前置/attached/bootstrap/startup/overlay/loop | `apps/netx-core/src/client_session.rs`、`client_session_attached.rs`、`client_session_bootstrap.rs`、`client_session_startup.rs`、`client_session_overlay.rs`、`client_session_loop.rs` |
| Core overlay hosts/DNS/resolved/NRPT 和 Linux transparent TCP intercept 执行胶水 | `apps/netx-core/src/overlay_integration.rs`、`overlay_transparent_proxy.rs` |
| Core local proxy 监听、协议 helper、NETX path、上游 TLS 和 proxy chain helper | `apps/netx-core/src/local_proxy.rs`、`local_proxy_protocol.rs`、`local_proxy_netx.rs`、`local_proxy_tls.rs`、`local_proxy_chain.rs` |
| Core NAT probe、punch poll、UDP/TCP punch 执行和直连/relay 隧道循环 | `apps/netx-core/src/punch_nat_probe.rs`、`punch.rs`、`punch_tunnel.rs` |
| 配置 | `crates/netx-config` |
| Wire DTO | `crates/netx-proto/src/wire.rs` |
| UI DTO | `crates/netx-ui-api` |
| 存储 service 注册/加载、service row mapping 和 overlay relay port 分配 | `crates/netx-control/src/service_store.rs` |
| 存储 service validation、service parse/normalize helper 和 service auth JSON helper | `crates/netx-control/src/service_validation.rs` |
| 存储控制面入口、overview、core state 聚合和剩余共享 validation/helper 方法 | `crates/netx-control/src/lib.rs` |
| StoreExecutor async wrapper | `crates/netx-control/src/executor.rs` |
| 存储 schema/open/migration/backfill | `crates/netx-control/src/schema.rs` |
| 存储 kv/singleton JSON helper | `crates/netx-control/src/kv.rs` |
| 存储 service token 生命周期 | `crates/netx-control/src/service_tokens.rs` |
| 存储 task/audit 持久层 | `crates/netx-control/src/audit_tasks.rs` |
| 存储 admin principal/token | `crates/netx-control/src/admin_store.rs` |
| 存储节点接入、心跳、NAT/overlay probe、enrollment 和 blocked identity | `crates/netx-control/src/node_store.rs` |
| 存储 Network/resource/membership、overlay subnet routes、node service capability 和 service gateway assignment KV store | `crates/netx-control/src/network_store.rs` |
| 存储 service config、local proxy config、managed client config、overlay policy 和 setup draft | `crates/netx-control/src/config_store.rs` |
| 存储公共记录/错误类型 | `crates/netx-control/src/models.rs` |
| Admin API client | `web/admin/src/lib/api/*` |
| Desktop 状态编排 | `apps/netx-desktop/src/composables/use-client-workbench.ts` |
| Desktop Tauri 命令/DTO/IPC/local/profile/projection/runtime/service/remote 边界 | `apps/netx-desktop/src-tauri/src/core_control.rs`、`core_control_models.rs`、`core_control_ipc.rs`、`core_control_local.rs`、`core_control_profile.rs`、`core_control_projection.rs`、`core_control_runtime.rs`、`core_control_service.rs`、`core_control_remote.rs` |
## 实现规则
- Handler 做编排,不堆业务内核。
- SQLite 访问走 `StoreExecutor`
- 协议新增先落 `netx-proto`
- UI 不自己推导 runtime plan。
- 会话路径要同时看 Controller、Core、bridge executor、CLI/UI。
- 拆模块时同步修测试显式 import。
- 大验证分层跑,先轻后重。
## 重构期间
本 skill 描述当前有效工程结构,不用于阻止已确认的重构。
当重构改变以下内容时,同步更新本 skill 和 `docs/specs/coding-guide.md`、`docs/specs/architecture.md`
- 产品边界。
- app / crate / module 责任。
- 前端目录归属。
- API 真相源。
- 构建命令。
- 验证命令。
- 运行入口。
若本 skill 与当前源码或已确认重构目标冲突,以当前源码和重构目标为准,并在同次修改中修正本 skill。
## 验证
轻量:
```bash
cargo check --workspace --all-targets
pnpm -C web/admin exec vue-tsc --noEmit
pnpm -C apps/netx-desktop exec vue-tsc --noEmit
```
仓库:
```bash
make verify-workspace
make verify-linux
make verify-windows
```
前端:
```bash
pnpm -C web/admin check:design-contracts
pnpm -C web/admin test
pnpm -C web/admin build
pnpm -C apps/netx-desktop check:design-contracts
pnpm -C apps/netx-desktop test
pnpm -C apps/netx-desktop build
```

@ -0,0 +1,91 @@
---
name: netx-design
description: Use when changing or reviewing netx Web Admin, Desktop Core UI, visual design contracts, navigation, components, page layout, copy, or frontend interaction behavior.
---
# netx UI 设计
## 使用场景
`/root/Projects/Mine/netx` 中处理这些内容时使用:
- `web/admin`
- `apps/netx-desktop`
- 导航信息架构。
- 页面布局。
- 组件复用。
- UI 文案。
- 设计契约检查失败。
## 先读
按顺序读取:
1. `docs/specs/ui-design.md`
2. `web/admin/src/app/routes.ts`
3. `web/admin/src/components/netx/*`
4. `web/admin/src/components/common/DataWorkbench.vue`
5. `apps/netx-desktop/src/App.vue`
6. `apps/netx-desktop/src/components/client/*`
视觉资产在 `docs/design/netx-ui-dev-assets`。页面源码不得直接引用该目录。
## Admin 规则
- Admin 是高信息密度控制台。
- 一级导航固定走 `Overview / Nodes / Networks / Gateways / Services / Routes / DNS / Security / Diagnostics / Settings`
- `Gateways` 是具有 Gateway capability 的节点视图。
- `Services` 覆盖 HTTP、HTTPS、TCP、UDP、SOCKS5、Shadowsocks。
- HTTP、Tunnel、SOCKS5、Shadowsocks 都归入 `Services`
- 页面优先复用 `components/netx``DataWorkbench`
## Desktop 规则
- Desktop 是本机 Core 工作台。
- 交互按 PC 分屏处理。
- Desktop 不承载网络逻辑。
- 通过 Local API / IPC 管理 `netx-core`
- 使用分屏工作台壳。
## 重构期间
本 skill 描述当前 UI 结构,不用于阻止已确认的 UI 或产品重构。
当重构改变以下内容时,同步更新本 skill 和 `docs/specs/ui-design.md`
- Admin 目录归属。
- Desktop 目录归属。
- 导航信息架构。
- 组件入口。
- 设计资产路径。
- 设计检查命令。
- 页面交互主模式。
若本 skill 与当前源码或已确认重构目标冲突,以当前源码和重构目标为准,并在同次修改中修正本 skill。
## 必跑检查
改 Admin
```bash
pnpm -C web/admin check:design-contracts
pnpm -C web/admin test
pnpm -C web/admin build
```
改 Desktop
```bash
pnpm -C apps/netx-desktop check:design-contracts
pnpm -C apps/netx-desktop test
pnpm -C apps/netx-desktop build
```
## 禁止项
- 不用原生 `<select>`
- 不用 `window.confirm`
- 不直接引用 `docs/design`
- 不新增手写 Logo SVG。
- 不加全局假搜索和假环境切换。
- 不为单页创造新视觉体系。

@ -0,0 +1,69 @@
---
name: netx-requirements
description: Use when working in the netx repository on requirements, feature scope, product boundaries, implementation gap analysis, or docs-to-code traceability for controller, core, desktop, and web/admin surfaces.
---
# netx 需求判断
## 使用场景
`/root/Projects/Mine/netx` 中处理这些问题时使用:
- 判断某能力是否属于当前产品范围。
- 对照需求和实现。
- 补功能缺口。
- 改需求文档。
- 回答产品边界、产品名、交付形态。
## 先读
按顺序读取:
1. `docs/specs/requirements.md`
2. `docs/specs/traceability.md`
3. `docs/specs/architecture.md`
4. 当前源码、`Cargo.toml`、`Makefile`
## 当前产品主语
只使用这些产品主语:
- `netx-controller`
- `netx-core`
- `apps/netx-desktop`
- `web/admin`
Gateway 是 `netx-core` 的 capability不是独立程序。
## 判断规则
- 需求判断必须回到当前源码和新规格。
- 文档说已实现但源码没有入口时,按未实现处理。
- 源码已有入口但缺真实网络、系统权限或跨平台证据时,按待人工验收处理。
- 发现真实缺口后,默认继续补齐,不停在报告。
- 自动化验证和最终人工验收分开汇报。
## 重构期间
本 skill 描述当前有效结构,不用于阻止已确认的重构。
当重构改变以下内容时,同步更新本 skill 和 `docs/specs/requirements.md`、`docs/specs/traceability.md`
- 产品边界。
- 程序入口。
- 目录归属。
- 能力域划分。
- 需求状态。
- 验收口径。
若本 skill 与当前源码或已确认重构目标冲突,以当前源码和重构目标为准,并在同次修改中修正本 skill。
## 常见陷阱
| 陷阱 | 处理 |
| --- | --- |
| 控制面入口不明确 | 改查 `apps/netx-controller`、`crates/netx-controller` |
| 把 `core-gateway` 当产品 | 它只是构建档位 |
| 把 Desktop 当网络内核 | Desktop 只是本机管理壳 |
| 只看文档判断现状 | 改读 `docs/specs/*` 和源码 |
| 默认安全优先 | netx 当前前提是效率和功能优先,安全可配置 |

@ -0,0 +1,277 @@
---
name: subagent-driven-development
description: 当在当前会话中执行包含独立任务的实现计划时使用
---
# 子智能体驱动开发
通过为每个任务分派一个全新的子智能体来执行计划,每个任务完成后进行两阶段审查:先审查规格合规性,再审查代码质量。
**为什么用子智能体:** 你将任务委派给具有隔离上下文的专用智能体。通过精心设计它们的指令和上下文,确保它们专注并成功完成任务。它们不应继承你的会话上下文或历史记录——你要精确构造它们所需的一切。这样也能为你自己保留用于协调工作的上下文。
**核心原则:** 每个任务一个全新子智能体 + 两阶段审查(先规格后质量)= 高质量、快速迭代
## 何时使用
```dot
digraph when_to_use {
"有实现计划?" [shape=diamond];
"任务基本独立?" [shape=diamond];
"留在当前会话?" [shape=diamond];
"subagent-driven-development" [shape=box];
"executing-plans" [shape=box];
"手动执行或先头脑风暴" [shape=box];
"有实现计划?" -> "任务基本独立?" [label="是"];
"有实现计划?" -> "手动执行或先头脑风暴" [label="否"];
"任务基本独立?" -> "留在当前会话?" [label="是"];
"任务基本独立?" -> "手动执行或先头脑风暴" [label="否 - 紧密耦合"];
"留在当前会话?" -> "subagent-driven-development" [label="是"];
"留在当前会话?" -> "executing-plans" [label="否 - 并行会话"];
}
```
**与 Executing Plans并行会话的对比**
- 同一会话(无上下文切换)
- 每个任务全新子智能体(无上下文污染)
- 每个任务后两阶段审查:先规格合规性,再代码质量
- 更快的迭代(任务间无需人工介入)
## 流程
```dot
digraph process {
rankdir=TB;
subgraph cluster_per_task {
label="每个任务";
"分派实现子智能体 (./implementer-prompt.md)" [shape=box];
"实现子智能体有疑问?" [shape=diamond];
"回答问题,提供上下文" [shape=box];
"实现子智能体实现、测试、提交、自审" [shape=box];
"分派规格审查子智能体 (./spec-reviewer-prompt.md)" [shape=box];
"规格审查子智能体确认代码匹配规格?" [shape=diamond];
"实现子智能体修复规格差距" [shape=box];
"分派代码质量审查子智能体 (./code-quality-reviewer-prompt.md)" [shape=box];
"代码质量审查子智能体通过?" [shape=diamond];
"实现子智能体修复质量问题" [shape=box];
"在 TodoWrite 中标记任务完成" [shape=box];
}
"读取计划,提取所有任务的完整文本,记录上下文,创建 TodoWrite" [shape=box];
"还有剩余任务?" [shape=diamond];
"分派最终代码审查子智能体审查整体实现" [shape=box];
"使用 superpowers:finishing-a-development-branch" [shape=box style=filled fillcolor=lightgreen];
"读取计划,提取所有任务的完整文本,记录上下文,创建 TodoWrite" -> "分派实现子智能体 (./implementer-prompt.md)";
"分派实现子智能体 (./implementer-prompt.md)" -> "实现子智能体有疑问?";
"实现子智能体有疑问?" -> "回答问题,提供上下文" [label="是"];
"回答问题,提供上下文" -> "分派实现子智能体 (./implementer-prompt.md)";
"实现子智能体有疑问?" -> "实现子智能体实现、测试、提交、自审" [label="否"];
"实现子智能体实现、测试、提交、自审" -> "分派规格审查子智能体 (./spec-reviewer-prompt.md)";
"分派规格审查子智能体 (./spec-reviewer-prompt.md)" -> "规格审查子智能体确认代码匹配规格?";
"规格审查子智能体确认代码匹配规格?" -> "实现子智能体修复规格差距" [label="否"];
"实现子智能体修复规格差距" -> "分派规格审查子智能体 (./spec-reviewer-prompt.md)" [label="重新审查"];
"规格审查子智能体确认代码匹配规格?" -> "分派代码质量审查子智能体 (./code-quality-reviewer-prompt.md)" [label="是"];
"分派代码质量审查子智能体 (./code-quality-reviewer-prompt.md)" -> "代码质量审查子智能体通过?";
"代码质量审查子智能体通过?" -> "实现子智能体修复质量问题" [label="否"];
"实现子智能体修复质量问题" -> "分派代码质量审查子智能体 (./code-quality-reviewer-prompt.md)" [label="重新审查"];
"代码质量审查子智能体通过?" -> "在 TodoWrite 中标记任务完成" [label="是"];
"在 TodoWrite 中标记任务完成" -> "还有剩余任务?";
"还有剩余任务?" -> "分派实现子智能体 (./implementer-prompt.md)" [label="是"];
"还有剩余任务?" -> "分派最终代码审查子智能体审查整体实现" [label="否"];
"分派最终代码审查子智能体审查整体实现" -> "使用 superpowers:finishing-a-development-branch";
}
```
## 模型选择
使用能胜任每个角色的最低成本模型,以节省开支并提高速度。
**机械性实现任务**隔离的函数、清晰的规格、1-2 个文件):使用快速、便宜的模型。当计划编写得足够详细时,大多数实现任务都是机械性的。
**集成和判断类任务**(多文件协调、模式匹配、调试):使用标准模型。
**架构、设计和审查类任务**:使用最强的可用模型。
**任务复杂度信号:**
- 涉及 1-2 个文件且有完整规格 → 便宜模型
- 涉及多个文件且有集成考虑 → 标准模型
- 需要设计判断或广泛的代码库理解 → 最强模型
## 处理实现者状态
实现子智能体报告四种状态之一。根据每种状态进行相应处理:
**DONE** 进入规格合规性审查。
**DONE_WITH_CONCERNS** 实现者完成了工作但标记了疑虑。在继续之前阅读这些疑虑。如果疑虑涉及正确性或范围,在审查前解决。如果只是观察性说明(如"这个文件越来越大了"),记录下来并继续审查。
**NEEDS_CONTEXT** 实现者需要未提供的信息。提供缺失的上下文并重新分派。
**BLOCKED** 实现者无法完成任务。评估阻塞原因:
1. 如果是上下文问题,提供更多上下文并用同一模型重新分派
2. 如果任务需要更强的推理能力,用更强的模型重新分派
3. 如果任务太大,拆分为更小的部分
4. 如果计划本身有问题,上报给人类
**绝不** 忽略上报或在不做任何更改的情况下让同一模型重试。如果实现者说卡住了,说明有什么东西需要改变。
## 提示词模板
- `./implementer-prompt.md` - 分派实现子智能体
- `./spec-reviewer-prompt.md` - 分派规格合规审查子智能体
- `./code-quality-reviewer-prompt.md` - 分派代码质量审查子智能体
## 示例工作流
```
你:我正在使用子智能体驱动开发来执行这个计划。
[一次性读取计划文件docs/superpowers/plans/feature-plan.md]
[提取全部 5 个任务的完整文本和上下文]
[用所有任务创建 TodoWrite]
任务 1Hook 安装脚本
[获取任务 1 的文本和上下文(已提取)]
[分派实现子智能体,附带完整任务文本 + 上下文]
实现者:"在我开始之前——hook 应该安装在用户级别还是系统级别?"
你:"用户级别(~/.config/superpowers/hooks/"
实现者:"明白了。现在开始实现……"
[稍后] 实现者:
- 实现了 install-hook 命令
- 添加了测试5/5 通过
- 自审:发现遗漏了 --force 参数,已添加
- 已提交
[分派规格合规审查]
规格审查者:✅ 符合规格 - 所有需求已满足,无多余内容
[获取 git SHA分派代码质量审查]
代码审查者:优点:测试覆盖好,代码整洁。问题:无。通过。
[标记任务 1 完成]
任务 2恢复模式
[获取任务 2 的文本和上下文(已提取)]
[分派实现子智能体,附带完整任务文本 + 上下文]
实现者:[无疑问,直接开始]
实现者:
- 添加了 verify/repair 模式
- 8/8 测试通过
- 自审:一切正常
- 已提交
[分派规格合规审查]
规格审查者:❌ 问题:
- 缺失:进度报告(规格要求"每 100 项报告一次"
- 多余:添加了 --json 参数(未被要求)
[实现者修复问题]
实现者:移除了 --json 参数,添加了进度报告
[规格审查者再次审查]
规格审查者:✅ 现在符合规格
[分派代码质量审查]
代码审查者优点扎实。问题重要魔法数字100
[实现者修复]
实现者:提取了 PROGRESS_INTERVAL 常量
[代码审查者再次审查]
代码审查者:✅ 通过
[标记任务 2 完成]
...
[所有任务完成后]
[分派最终代码审查]
最终审查者:所有需求已满足,可以合并
完成!
```
## 优势
**与手动执行相比:**
- 子智能体自然遵循 TDD
- 每个任务全新上下文(不会混淆)
- 并行安全(子智能体不会互相干扰)
- 子智能体可以提问(工作前和工作中都可以)
**与 Executing Plans 相比:**
- 同一会话(无交接)
- 持续进展(无需等待)
- 审查检查点自动化
**效率提升:**
- 无文件读取开销(控制者提供完整文本)
- 控制者精确策划所需上下文
- 子智能体预先获得完整信息
- 问题在工作开始前就被提出(而非工作结束后)
**质量关卡:**
- 自审在交接前发现问题
- 两阶段审查:规格合规性,然后代码质量
- 审查循环确保修复确实有效
- 规格合规防止过度/不足构建
- 代码质量确保实现良好
**成本:**
- 更多子智能体调用(每个任务需要实现者 + 2 个审查者)
- 控制者需要更多准备工作(预先提取所有任务)
- 审查循环增加迭代次数
- 但能及早发现问题(比后期调试更省成本)
## 红线
**绝不:**
- 未经用户明确同意就在 main/master 分支上开始实现
- 跳过审查(规格合规性或代码质量)
- 带着未修复的问题继续
- 并行分派多个实现子智能体(会冲突)
- 让子智能体读取计划文件(应提供完整文本)
- 跳过场景铺设上下文(子智能体需要理解任务在哪个环节)
- 忽视子智能体的问题(在让它们继续之前先回答)
- 在规格合规性上接受"差不多就行"(规格审查者发现问题 = 未完成)
- 跳过审查循环(审查者发现问题 = 实现者修复 = 再次审查)
- 让实现者的自审替代正式审查(两者都需要)
- **在规格合规性审查通过之前开始代码质量审查**(顺序错误)
- 在任一审查有未解决问题时就进入下一个任务
**如果子智能体提问:**
- 清晰完整地回答
- 必要时提供额外上下文
- 不要催促它们进入实现阶段
**如果审查者发现问题:**
- 实现者(同一子智能体)修复
- 审查者再次审查
- 重复直到通过
- 不要跳过重新审查
**如果子智能体失败:**
- 分派修复子智能体并提供具体指令
- 不要尝试手动修复(上下文污染)
## 集成
**必需的工作流技能:**
- **superpowers:using-git-worktrees** - 必需:在开始前建立隔离工作区
- **superpowers:writing-plans** - 创建本技能执行的计划
- **superpowers:requesting-code-review** - 审查子智能体的代码审查模板
- **superpowers:finishing-a-development-branch** - 所有任务完成后收尾
**子智能体应使用:**
- **superpowers:test-driven-development** - 子智能体对每个任务遵循 TDD
**替代工作流:**
- **superpowers:executing-plans** - 用于并行会话而非同会话执行

@ -0,0 +1,26 @@
# 代码质量审查者提示词模板
分派代码质量审查子智能体时使用此模板。
**目的:** 验证实现是否构建良好(整洁、有测试、可维护)
**仅在规格合规性审查通过后才分派。**
```
Task tool (superpowers:code-reviewer):
使用模板 requesting-code-review/code-reviewer.md
WHAT_WAS_IMPLEMENTED: [来自实现者的报告]
PLAN_OR_REQUIREMENTS: [plan-file] 中的任务 N
BASE_SHA: [任务开始前的提交]
HEAD_SHA: [当前提交]
DESCRIPTION: [任务摘要]
```
**除标准代码质量关注点外,审查者还应检查:**
- 每个文件是否有单一明确的职责和定义清晰的接口?
- 各单元是否拆分得足以独立理解和测试?
- 实现是否遵循了计划中的文件结构?
- 本次实现是否创建了已经很大的新文件,或显著增大了现有文件?(不要标记已有的文件大小问题——聚焦于本次变更带来的影响。)
**代码审查者返回:** 优点、问题(关键/重要/次要)、评估结论

@ -0,0 +1,113 @@
# 实现子智能体提示词模板
分派实现子智能体时使用此模板。
```
Task tool (general-purpose):
description: "实现任务 N[任务名称]"
prompt: |
你正在实现任务 N[任务名称]
## 任务描述
[计划中任务的完整文本 - 粘贴到这里,不要让子智能体去读文件]
## 上下文
[场景铺设:这个任务在哪个环节、依赖关系、架构上下文]
## 开始之前
如果你对以下内容有疑问:
- 需求或验收标准
- 方案或实现策略
- 依赖或假设
- 任务描述中任何不清楚的地方
**现在就问。** 在开始工作之前提出任何疑虑。
## 你的工作
当你确认需求清晰后:
1. 严格按照任务指定的内容实现
2. 编写测试(如果任务要求则遵循 TDD
3. 验证实现是否正常工作
4. 提交你的工作
5. 自审(见下文)
6. 汇报
工作目录:[directory]
**工作过程中:** 如果遇到意料之外或不清楚的情况,**提问**。
随时可以暂停并澄清。不要猜测或做假设。
## 代码组织
你在能一次性放入上下文的代码上推理效果最好,文件聚焦时编辑也更可靠。
请牢记:
- 遵循计划中定义的文件结构
- 每个文件应有单一明确的职责和定义清晰的接口
- 如果你正在创建的文件超出了计划预期的规模,停下来并以
DONE_WITH_CONCERNS 状态报告——不要在没有计划指导的情况下自行拆分文件
- 如果你正在修改的现有文件已经很大或很混乱,小心操作
并在报告中将其标注为疑虑
- 在已有代码库中,遵循已建立的模式。像一个好的开发者那样
改善你接触的代码,但不要重构你任务范围之外的东西。
## 当你力不从心时
说"这对我来说太难了"完全没问题。劣质的工作比不做更糟。
上报不会受到惩罚。
**遇到以下情况时停下来上报:**
- 任务需要在多个有效方案之间做架构决策
- 你需要理解提供内容之外的代码但找不到答案
- 你对自己的方案是否正确感到不确定
- 任务涉及计划未预期的现有代码重构
- 你一直在逐个读文件试图理解系统但没有进展
**如何上报:** 以 BLOCKED 或 NEEDS_CONTEXT 状态汇报。具体描述
你卡在哪里、尝试了什么、需要什么帮助。
控制者可以提供更多上下文、用更强的模型重新分派,
或将任务拆分为更小的部分。
## 汇报前:自审
用全新的视角审查你的工作。问自己:
**完整性:**
- 我是否完全实现了规格中的所有内容?
- 我是否遗漏了任何需求?
- 是否有我没处理的边界情况?
**质量:**
- 这是我最好的工作吗?
- 命名是否清晰准确(匹配事物做什么,而非怎么做)?
- 代码是否整洁且可维护?
**纪律:**
- 我是否避免了过度构建YAGNI
- 我是否只构建了被要求的内容?
- 我是否遵循了代码库中的已有模式?
**测试:**
- 测试是否真正验证了行为(而非只是 mock 行为)?
- 如果要求了 TDD我是否遵循了
- 测试是否全面?
如果在自审中发现问题,在汇报前就修复。
## 汇报格式
完成后汇报:
- **状态:** DONE | DONE_WITH_CONCERNS | BLOCKED | NEEDS_CONTEXT
- 你实现了什么(或尝试了什么,如果被阻塞)
- 你测试了什么以及测试结果
- 修改了哪些文件
- 自审发现(如果有)
- 任何问题或疑虑
如果你完成了工作但对正确性有疑虑,使用 DONE_WITH_CONCERNS。
如果你无法完成任务,使用 BLOCKED。如果你需要
未提供的信息,使用 NEEDS_CONTEXT。绝不默默产出你不确定的工作。
```

@ -0,0 +1,61 @@
# 规格合规审查者提示词模板
分派规格合规审查子智能体时使用此模板。
**目的:** 验证实现者是否构建了所要求的内容(不多不少)
```
Task tool (general-purpose):
description: "审查任务 N 的规格合规性"
prompt: |
你正在审查一个实现是否与其规格匹配。
## 要求的内容
[任务需求的完整文本]
## 实现者声称构建了什么
[来自实现者的报告]
## 关键:不要信任报告
实现者完成得疑似过快。他们的报告可能不完整、
不准确或过于乐观。你必须独立验证所有内容。
**不要:**
- 相信他们关于实现内容的说法
- 信任他们关于完整性的声明
- 接受他们对需求的解读
**要做的:**
- 阅读他们写的实际代码
- 逐行对比实际实现和需求
- 检查他们声称已实现但实际遗漏的部分
- 寻找他们未提及的多余功能
## 你的工作
阅读实现代码并验证:
**缺失的需求:**
- 他们是否实现了所有被要求的内容?
- 是否有他们跳过或遗漏的需求?
- 是否有他们声称可用但实际未实现的功能?
**多余/不需要的工作:**
- 他们是否构建了未被要求的内容?
- 他们是否过度工程化或添加了不必要的功能?
- 他们是否添加了规格中没有的"锦上添花"功能?
**理解偏差:**
- 他们是否以不同于预期的方式解读了需求?
- 他们是否解决了错误的问题?
- 他们是否实现了正确的功能但方式不对?
**通过阅读代码来验证,而非信任报告。**
报告:
- ✅ 符合规格(如果经过代码检查后一切匹配)
- ❌ 发现问题:[具体列出缺失或多余的内容,附带 file:line 引用]
```

@ -0,0 +1,4 @@
FLASK_ENV=dev
SECRET_KEY=change-me
JWT_SECRET_KEY=change-me
DATABASE_URL=sqlite:///runtime/iti-flask_dev.db

8
.gitignore vendored

@ -33,10 +33,14 @@ shell/
venv/
env/
ENV/
uv.lock
# Flask 实例与本地配置
instance/
.flaskenv
.env
.env.*
!.env.example
.envrc
# 运行期与数据库文件(你的项目含 iti/runtime/iti-flask_dev.db
@ -47,9 +51,11 @@ instance/
*.db-wal
*.db-shm
runtime/
iti/runtime/
logs/
*.log
*.pid
iti/static/dist/
# 测试与覆盖率
.pytest_cache/
@ -86,5 +92,3 @@ Desktop.ini
# 其他
/.python-version
# migrations
migrations/versions/*.py

@ -0,0 +1,55 @@
# Grill: iTi-Flask 框架重构
Date: 2026-05-08
## Intent
iTi-Flask 要成为多个长期业务项目复用的后端基座。它需要提供稳定的系统域、模块边界、服务调用实践和项目模板,同时不承载具体业务逻辑。
## Constraints
- 业务项目必须依赖版本化 Git tag不复制框架源码。
- 业务项目可以扩展框架,但不能修改或覆盖框架实现。
- 框架保留系统域 API并作为核心默认能力。
- 框架必须保持轻量。它不是微服务平台。
- 生产只从 `main` 发布。
- Python 基线是 `>=3.11`
- 第一版服务调用只支持 HTTP JSON。
- 第一版任务运行器是单进程能力。
- 前端构建产物不作为框架内容发布。
- 运行时数据库和生成物不是源码资产。
## Key decisions
- Decision: 使用 Python 包 + Copier 模板。Reason: 多个业务项目需要共享运行时代码和一致项目骨架。Alternative considered: 只用 Copier 复制代码。
- Decision: 先用 Git tag 发布。Reason: 在私有 PyPI 建立前,私有 Git tag 已够用。Alternative considered: 本地路径依赖或一开始就上私有 PyPI。
- Decision: 系统域留在 core并默认启用。Reason: 用户、角色、菜单、部门、字典、配置、文件、认证、审计日志和用户扩展属性是后台基座共同能力。Alternative considered: 把系统域拆成独立包。
- Decision: 业务项目只能扩展框架行为。Reason: 直接覆盖会破坏后续升级。Alternative considered: 本地覆盖和 monkey patch。
- Decision: ERP 离开 core成为独立 Gateway 服务。Reason: ERP 是多个业务系统共享的 Monitor API 和 ODBC 解释层应只有一个口径。Alternative considered: ERP 作为 core module 或每个项目各接一次。
- Decision: 框架同时支持进程内 module 和 service client。Reason: module 是代码边界service 是部署边界。Alternative considered: 全部微服务化。
- Decision: module 跨调用走 facade。Reason: 保留 Python 调用的简单性同时避免穿透内部实现。Alternative considered: module 之间全走 HTTP 或随意 import。
- Decision: 业务项目模型集中在结构化 `models/` 树中,并保留一条 migration 流。Reason: 降低重复 migrations同时支持多人按 module 协作。Alternative considered: module 自有 migrations。
- Decision: migrations 版本化并提交。Reason: 多人开发和生产升级需要可复现数据库历史。Alternative considered: 忽略 migrations 和手工改库。
- Decision: 业务项目使用 `main` 做生产、`dev` 做集成、个人分支做开发。Reason: migration 顺序必须在生产前收敛。Alternative considered: 生产从个人分支发布。
- Decision: migration 文件名使用日期时间、revision 和 message slug。Reason: 作者名放在自由 message 中不强制业务域格式。Alternative considered: 固定 domain/action message 格式。
- Decision: 框架系统迁移复制或同步到业务项目 migrations。Reason: 生产只保留一个 `flask db upgrade` 路径。Alternative considered: 框架和业务两套迁移命令。
- Decision: seed 使用 Python幂等只写系统初始数据。Reason: 运行时 SQLite 文件不是源码资产MySQL 等数据库应由代码初始化。Alternative considered: 保留 dev 数据库或使用 JSON/SQL seed。
- Decision: 后台 API 可以保留 envelope 和业务失败 HTTP 200。服务 API 使用真实 HTTP 状态码。Reason: 重试、熔断和监控需要真实状态码语义。Alternative considered: 所有 API 都继续 HTTP 200。
- Decision: 同时保留显式 module protocol 和现有 runtime plugin 思路。Reason: 业务模块是稳定应用代码插件是部署时可选扩展。Alternative considered: 只保留插件扫描或不保留插件能力。
- Decision: 第一阶段必须让 service client 和 task runner 可用。Reason: 架构要可验证不能只停在契约。Alternative considered: 第一阶段只写契约。
## Surfaced assumptions
- ERP 不是普通业务逻辑,而是面向既有外部系统的公共 Gateway。
- 团队更常用个人 Git 分支,而不是严格功能分支。
- 历史上可能存在手工生产库变更,但目标纪律是 migration-based。
- 开发和生产后续可能使用 MySQL 或其它数据库,所以 SQLite 快照不是可靠 seed 策略。
- 轻量微服务只服务于职责边界、协作和复用,不追求 service mesh、服务发现、分布式事务或平台复杂度。
## Out of scope
- 服务发现。
- Kubernetes service mesh。
- 分布式事务。
- Saga 编排。
- gRPC。
- 消息总线平台。
- 自动弹性伸缩平台。
- 多租户网关。
- 完整 async service client。
- 分布式任务锁或 exactly-once 保证。
- 内置前端后台应用。

@ -1,21 +1,65 @@
# back
# iTi-Flask
[![PyPI - Version](https://img.shields.io/pypi/v/back.svg)](https://pypi.org/project/back)
[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/back.svg)](https://pypi.org/project/back)
iTi-Flask 是基于 APIFlask / Flask 的轻量后端框架基座。
-----
它用于多个业务项目复用统一的系统域能力和工程约束。
它不是业务应用,不内置前端产物,也不是微服务平台。
## Table of Contents
## 当前边界
- [Installation](#installation)
- [License](#license)
框架内置:
## Installation
- 应用工厂和配置加载。
- SQLAlchemy、Flask-Migrate、JWT、缓存、限流、日志、错误处理。
- 系统域 API认证、用户、角色、菜单、部门、字典、配置、文件、日志、用户扩展属性。
- 进程内 module 协议。
- HTTP JSON service client。
- 单进程 task runner。
- Python system seed。
- 框架迁移同步命令。
- Copier 业务项目模板。
```console
pip install back
ERP 不属于 core。
后续应作为独立 ERP Gateway 服务提供能力。
## 开发
```bash
uv sync --extra dev
uv run --extra dev --extra mysql --extra image --extra excel pytest
```
## License
## 数据库初始化
框架仓库本身:
```bash
uv run python -m flask --app iti/app.py db upgrade
uv run python -m flask --app iti/app.py iti seed system
```
业务项目:
```bash
uv run python -m flask --app app.py iti migrations sync
uv run python -m flask --app app.py db upgrade
uv run python -m flask --app app.py iti seed system
```
## 业务项目生成
```bash
uvx copier copy ./copier-template ../my-business-app
```
业务项目依赖 iTi-Flask 的 Git tag。
框架升级后,业务项目更新依赖 tag再同步框架迁移。
## 文档
`back` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
- [框架边界](docs/FRAMEWORK_BOUNDARY.md)
- [架构重构计划](docs/ARCHITECTURE_REFACTOR_PLAN.md)
- [数据库迁移](docs/MIGRATIONS.md)
- [种子数据](docs/SEEDS.md)
- [服务客户端](docs/SERVICE_CLIENT.md)
- [任务运行器](docs/TASKS.md)

@ -0,0 +1,18 @@
__pycache__/
*.py[cod]
.venv/
.hatch/
.pytest_cache/
.mypy_cache/
.coverage
htmlcov/
*.db
*.sqlite
*.sqlite3
runtime/
logs/
.env
.env.local
!migrations/
!migrations/versions/
!migrations/versions/*.py

@ -0,0 +1,54 @@
# {{ project_name }}
这是由 iTi-Flask Copier 模板生成的业务后端项目。
业务代码只扩展框架。
不要复制、覆盖或修改框架内部实现。
## 初始化
```bash
uv sync --extra dev
uv run python -m flask --app app.py iti migrations sync
uv run python -m flask --app app.py db upgrade
uv run python -m flask --app app.py iti seed system
```
## 开发
```bash
uv run python -m flask --app app.py run --debug
uv run --extra dev pytest
```
## 数据库迁移
生成 migration
```bash
uv run python -m flask --app app.py db migrate -m "alice add example table"
```
升级数据库:
```bash
uv run python -m flask --app app.py db upgrade
```
规则:
- `migrations/versions` 必须提交。
- migration message 第一个词写作者名,后面自由描述。
- 生产只从 `main` 执行 `flask db upgrade`。
- 框架升级后先执行 `flask iti migrations sync`。
## 种子数据
系统域数据由框架 seed 写入:
```bash
uv run python -m flask --app app.py iti seed system
```
业务 seed 放到业务项目自己的模块中。
不要把业务数据写进框架 seed。

@ -0,0 +1,16 @@
from iti.applications import create_app
from config import config
from {{ project_slug }}.models import import_models
from {{ project_slug }}.modules.example.module import ExampleModule
app = create_app(
config_mapping=config,
model_imports=[import_models],
modules=[ExampleModule()],
)
if __name__ == "__main__":
app.run(debug=True)

@ -0,0 +1,35 @@
from pathlib import Path
from iti.config import DevConfig as BaseDevConfig
from iti.config import ProdConfig as BaseProdConfig
from iti.config import TestConfig as BaseTestConfig
BASE_DIR = Path(__file__).resolve().parent
class DevConfig(BaseDevConfig):
SQLALCHEMY_DATABASE_URI = f"sqlite:///{BASE_DIR / 'runtime' / '{{ project_slug }}_dev.db'}"
FILE_STORAGE = {
**BaseDevConfig.FILE_STORAGE,
"LOCAL": {
**BaseDevConfig.FILE_STORAGE.get("LOCAL", {}),
"base_path": str(BASE_DIR / "runtime" / "uploads"),
},
}
class TestConfig(BaseTestConfig):
SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
class ProdConfig(BaseProdConfig):
pass
config = {
"dev": DevConfig,
"test": TestConfig,
"prod": ProdConfig,
"default": DevConfig,
}

@ -0,0 +1,19 @@
project_name:
type: str
help: Business project display name
default: iTi Business App
project_slug:
type: str
help: Python package name for the business project
default: iti_business_app
framework_git:
type: str
help: iTi-Flask Git URL
default: git+ssh://git@example.com/iTi-Flask.git
framework_tag:
type: str
help: iTi-Flask Git tag
default: v0.1.0

@ -0,0 +1,17 @@
# 数据库迁移
本目录是业务项目唯一的 Alembic migration 流。
初始化时先同步框架 migration
```bash
python -m flask --app app.py iti migrations sync
```
再升级数据库:
```bash
python -m flask --app app.py db upgrade
```
`versions/` 下的 migration 文件必须提交到 Git。

@ -0,0 +1,43 @@
# A generic, single database configuration.
[alembic]
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

@ -0,0 +1,72 @@
import logging
from logging.config import fileConfig
from alembic import context
from flask import current_app
config = context.config
fileConfig(config.config_file_name)
logger = logging.getLogger("alembic.env")
def get_engine():
try:
return current_app.extensions["migrate"].db.get_engine()
except (TypeError, AttributeError):
return current_app.extensions["migrate"].db.engine
def get_engine_url():
try:
return get_engine().url.render_as_string(hide_password=False).replace("%", "%%")
except AttributeError:
return str(get_engine().url).replace("%", "%%")
config.set_main_option("sqlalchemy.url", get_engine_url())
target_db = current_app.extensions["migrate"].db
def get_metadata():
if hasattr(target_db, "metadatas"):
return target_db.metadatas[None]
return target_db.metadata
def run_migrations_offline():
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=get_metadata(), literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, "autogenerate", False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info("No changes in schema detected.")
conf_args = current_app.extensions["migrate"].configure_args
if conf_args.get("process_revision_directives") is None:
conf_args["process_revision_directives"] = process_revision_directives
connectable = get_engine()
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=get_metadata(),
**conf_args,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

@ -0,0 +1,28 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "{{ project_slug | replace('_', '-') }}"
version = "0.1.0"
description = "{{ project_name }}"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
{% if framework_tag %}
"iti-flask @ {{ framework_git }}@{{ framework_tag }}",
{% else %}
"iti-flask @ {{ framework_git }}",
{% endif %}
]
[project.optional-dependencies]
dev = [
"pytest>=7.0.0",
]
[tool.setuptools.packages.find]
include = ["{{ project_slug }}*"]
[tool.pytest.ini_options]
pythonpath = ["."]

@ -0,0 +1,8 @@
from app import app
def test_example_ping():
client = app.test_client()
response = client.get("/example/ping")
assert response.status_code == 200
assert response.json["data"]["pong"] is True

@ -0,0 +1 @@
"""{{ project_name }} package."""

@ -0,0 +1,2 @@
def import_models() -> None:
from .example import Example # noqa: F401

@ -0,0 +1,3 @@
from .example import Example
__all__ = ["Example"]

@ -0,0 +1,8 @@
from iti.applications.common.crud import BaseModelMixin
from iti.applications.extensions import db
class Example(BaseModelMixin):
__tablename__ = "biz_example"
name = db.Column(db.String(128), nullable=False, comment="名称")

@ -0,0 +1,3 @@
from .module import ExampleModule
__all__ = ["ExampleModule"]

@ -0,0 +1,35 @@
from .routes import bp
from iti.applications.common.enums import MenuTypeEnum
from iti.modules import ModuleMenuSeed, ModulePermission, get_module_registry
class ExampleModule:
name = "example"
def register_routes(self, app):
app.register_blueprint(bp, url_prefix="/example")
def register_permissions(self, app):
registry = get_module_registry(app)
registry.register_permission(
ModulePermission(
code="example:item:list",
name="示例列表",
description="查看示例模块数据",
)
)
def register_menu_seed(self, app):
registry = get_module_registry(app)
registry.register_menu_seed(
ModuleMenuSeed(
id="example-menu-root",
name="Example",
type=MenuTypeEnum.MENU.value,
path="/example",
component="/example/list",
auth_code="example:item:list",
meta={"title": "示例模块", "icon": "carbon:application"},
sort=100,
)
)

@ -0,0 +1,10 @@
from apiflask import APIBlueprint
from iti.applications.common.utils import success
bp = APIBlueprint("example", __name__, tag="Example")
@bp.get("/ping")
def ping():
return success({"pong": True})

@ -0,0 +1,305 @@
# iTi-Flask 架构重构计划
## 目标
把当前仓库整理成可被多个业务项目长期复用的后端基座。
第一阶段采用 hard-cut 重构。
不保留当前未成型框架形态的兼容行为。
## 第一阶段范围
第一阶段必须产出可运行的基础能力,而不是只写文档。
交付内容:
- `pyproject.toml` 成为依赖真相源。
- 不使用 Hatch 管理环境,统一使用 `uv`
- Python 基线调整为 `>=3.11`
- ERP 从 core 默认初始化中移出。
- 前端 `static/dist` 从框架包内容中移出。
- 运行时文件和生成物被忽略。
- Alembic migration 文件名模板包含日期、时间、revision 和 message slug。
- module protocol 可用,并能注册业务模块。
- 运行时插件加载保留,但定位为部署时插件能力。
- HTTP JSON service client 可用。
- 单进程 task registry 和 runner 可用。
- Python system seed 命令可用。
- 最小 Copier 模板可用。
- 边界文档完成更新。
## 第一阶段文件计划
打包配置:
- `pyproject.toml`
- `.gitignore`
框架边界:
- `iti/applications/__init__.py`
- `iti/applications/extensions/__init__.py`
- `iti/applications/extensions/plugins.py`
- `iti/applications/routes/__init__.py`
- `iti/applications/service/__init__.py`
新增模块支持:
- `iti/modules/__init__.py`
- `iti/modules/base.py`
- `iti/modules/registry.py`
新增服务客户端:
- `iti/service_client/__init__.py`
- `iti/service_client/config.py`
- `iti/service_client/client.py`
- `iti/service_client/errors.py`
- `iti/service_client/registry.py`
新增任务支持:
- `iti/tasks/__init__.py`
- `iti/tasks/registry.py`
- `iti/tasks/runner.py`
seed 和命令:
- `iti/cli.py`
- `iti/seeds/__init__.py`
- `iti/seeds/system.py`
模板:
- `copier-template/copier.yml`
- `copier-template/pyproject.toml.jinja`
- `copier-template/app.py.jinja`
- `copier-template/config.py.jinja`
- `copier-template/modules/example/*`
- `copier-template/models/example/*`
- `copier-template/tests/*`
- `copier-template/.gitignore`
- `copier-template/README.md.jinja`
文档:
- `docs/FRAMEWORK_BOUNDARY.md`
- `docs/ARCHITECTURE_REFACTOR_PLAN.md`
- `docs/MIGRATIONS.md`
- `docs/SERVICE_CLIENT.md`
- `docs/TASKS.md`
- `docs/SEEDS.md`
## 打包规则
运行依赖放到 `[project.dependencies]`
可选集成放到 `[project.optional-dependencies]`
核心依赖只覆盖框架运行需要。
云存储、ODBC、Excel、图片处理和开发工具都放到 extras。
推荐 extras
- `mysql`
- `storage-aliyun`
- `storage-tencent`
- `storage-qiniu`
- `storage-huawei`
- `storage-s3`
- `storage-minio`
- `erp`
- `excel`
- `image`
- `dev`
## 迁移规则
每个业务项目只有一条 migration 流。
迁移文件必须提交。
`migrations/versions` 不忽略。
文件名模板:
```ini
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
```
migration message 第一个词是作者名,后面自由描述:
```bash
uv run python -m flask --app app.py db migrate -m "alice add workorder priority"
```
生产只从 `main` 执行迁移。
## 服务客户端规则
第一版只支持 HTTP JSON。
默认行为:
- 必须配置超时。
- 重试策略保守。
- 默认只重试幂等方法。
- 熔断可选。
- 服务失败使用真实 HTTP 状态码。
- trace id 会透传。
## 任务运行器规则
任务运行器是单进程能力。
适用场景:
- 小型定时同步任务。
- 手动触发的后台操作。
- ERP Gateway 在专用实例中做轮询。
它不是分布式任务平台。
## Seed 规则
系统 seed 使用 Python 代码。
seed 必须幂等。
seed 不得删除已有用户数据。
seed 不得重置已有管理员密码。
命令:
```bash
flask iti seed system
```
## 验证计划
第一阶段最低验证:
```bash
uv run --extra dev pytest
uv run --extra dev mypy
python -m flask --app iti/app.py --help
python -m flask --app iti/app.py iti --help
python -m flask --app iti/app.py db --help
```
如果可选依赖缺失,测试应跳过可选集成路径,而不是让核心框架检查失败。
v0.1.0 的 mypy 门禁只覆盖新增框架基础层。
历史系统域 model、schema、route 仍是动态 Flask / SQLAlchemy / Marshmallow 写法,不作为本阶段类型门禁。
## 第二阶段范围
第二阶段把第一阶段的可用能力收口成业务项目可复制的初始化流程。
状态:已完成。
交付内容:
- 框架 migration 源文件进入包内容。
- `flask iti migrations sync` 可用。
- system seed 对齐当前系统域默认数据。
- seed 使用 `ADMIN`、`COMMON`、系统菜单、系统配置。
- seed 不写入业务菜单。
- 无请求上下文时审计字段可安全写入。
- Copier 模板内置 migration 目录和初始化说明。
- 根 README 和 docs README 中文化。
- 新增迁移同步与 seed 幂等测试。
验证命令:
```bash
uv run --extra dev --extra mysql --extra image --extra excel pytest
```
当前结果:
```text
48 passed, 1 skipped
```
## 第三阶段建议
第三阶段应处理“业务项目真实接入”的问题。
建议范围:
- 用 Copier 生成一个临时业务项目并跑通初始化。
- 补 module 注册业务菜单、权限和 seed 扩展的正式协议。
- 补 service client 的 mock transport 测试和失败语义测试。
- 补 task runner 的调度与防重复执行测试。
- 明确 ERP Gateway 的独立仓库结构和 API 契约草案。
## 第三阶段范围
状态:已完成。
交付内容:
- 修复框架包环境文件加载边界。
- 框架包不再携带 `iti/.env*`
- Copier 生成项目可跑通 migration sync、db upgrade、system seed。
- module 协议补充 `ModulePermission``ModuleMenuSeed`
- `flask iti seed system` 支持写入模块菜单 seed。
- service client 覆盖 token、trace、重试、非 2xx、熔断测试。
- task runner 覆盖成功、失败、防重复执行和调度解析测试。
- ERP Gateway 契约草案成文。
第三阶段验证命令:
```bash
uv run --extra dev --extra mysql --extra image --extra excel pytest
```
模板验证:
```bash
uvx copier copy -l /path/to/copier-template /tmp/iti-flask-template-check
uv run --refresh-package iti-flask python -m flask --app app.py iti migrations sync
uv run --refresh-package iti-flask python -m flask --app app.py db upgrade
uv run --refresh-package iti-flask python -m flask --app app.py iti seed system
uv run --refresh-package iti-flask --with pytest pytest
```
当前结果:
```text
仓库测试61 passed, 1 skipped
模板测试1 passed
```
## 当前收口结果
状态:已完成。
本轮处理:
- 版本号统一为 `0.1.0`
- `pyproject.toml` 只保留一套项目管理配置。
- `hatch.toml` 已移除。
- `uv.lock` 作为本地解析结果处理,不进入框架仓库版本控制。
- v0.1.0 mypy 门禁已建立。
- ERP Gateway 已定位为 iTi-Flask 仓库外的独立业务项目。
验证结果:
```text
uv run --extra dev mypy
Success: no issues found in 13 source files
uv run --extra dev --extra mysql --extra image --extra excel pytest
61 passed, 1 skipped
iTi-ERP-Gateway: uv run --extra dev pytest
4 passed
ERP Gateway 正式依赖 iTi-Flask Git tag。
本地验证时临时把依赖替换为 `/root/Projects/iTi/iTi-Flask`
Copier 模板初始化验证
1 passed
python -m build
生成 iti_flask-0.1.0.tar.gz 和 iti_flask-0.1.0-py3-none-any.whl
```

@ -0,0 +1,214 @@
# ERP Gateway 契约草案
ERP Gateway 是独立业务项目。
它不属于 iTi-Flask core。
它应按业务项目方式依赖 iTi-Flask并遵从框架的应用工厂、module、migration、seed 和配置规则。
ERP Gateway 实际项目应放在 iTi-Flask 仓库之外。
```text
/root/Projects/iTi/iTi-ERP-Gateway
```
在 ERP Gateway 这个业务项目内部Monitor 能力以 module 组织。
本仓库只保留契约文档,不内置 ERP Gateway 代码。
## 职责
ERP Gateway 负责隔离既有 Monitor ERP 系统。
第一版职责:
- 调用 Monitor ERP HTTP API。
- 通过 ODBC 读取 Monitor ERP 数据库。
- 提供轻量定时同步任务。
- 将 ERP 数据上报给业务项目。
- 对业务项目提供统一 HTTP JSON API。
业务项目不直接连接 Monitor API。
业务项目不直接连接 Monitor ODBC。
## 非目标
不做:
- 服务注册发现。
- Kubernetes service mesh。
- 分布式事务。
- Saga。
- gRPC。
- 消息总线全家桶。
- 自动弹性伸缩。
- 多租户网关。
第一版只做 HTTP JSON。
## 服务边界
ERP Gateway 自己持有:
- Monitor API 配置。
- Monitor ODBC 配置。
- ERP 字段映射。
- ERP 查询和转换逻辑。
- ERP 同步任务状态。
业务项目只关心:
- 请求哪个 ERP 能力。
- 传入业务参数。
- 得到标准 JSON 响应。
- 处理真实 HTTP 状态码。
## 认证
服务间调用使用 service token。
请求头:
```http
Authorization: Bearer <service-token>
X-Trace-Id: <trace-id>
```
`X-Trace-Id` 由调用方传入。
没有时调用方生成。
## 状态码
服务间 API 使用真实 HTTP 状态码。
建议:
- `200`:成功。
- `202`:任务已接受。
- `400`:请求参数错误。
- `401`:服务 token 无效。
- `404`:资源不存在。
- `409`:幂等键冲突或任务状态冲突。
- `422`ERP 返回数据无法转换。
- `502`Monitor API 或 ODBC 返回异常。
- `503`Monitor 不可用。
- `504`Monitor 超时。
不要把服务间失败包装成 HTTP 200。
## API 草案
健康检查:
```http
GET /health
```
返回:
```json
{"status": "ok"}
```
ERP API 代理能力:
```http
POST /monitor/api/call
```
请求:
```json
{
"name": "get_customer",
"params": {"customer_id": "C001"}
}
```
ODBC 查询能力:
```http
POST /monitor/odbc/query
```
请求:
```json
{
"name": "customer_by_id",
"params": {"customer_id": "C001"}
}
```
同步任务:
```http
POST /sync/jobs
GET /sync/jobs/{job_id}
```
创建请求:
```json
{
"kind": "customers",
"since": "2026-05-08T00:00:00+08:00",
"callback_url": "http://business.local/internal/erp/customers",
"idempotency_key": "customers-20260508"
}
```
创建返回:
```json
{
"job_id": "01HX...",
"status": "accepted"
}
```
## 调用约定
业务项目调用 ERP Gateway 时使用 `iti.service_client`
默认建议:
```python
SERVICES = {
"erp": {
"base_url": "http://erp-gateway:8000",
"token": "...",
"timeout": {
"connect": 1.0,
"read": 10.0,
"write": 5.0,
"pool": 1.0,
},
"retry": {
"attempts": 2,
"backoff": 0.2,
"statuses": [502, 503, 504],
},
"circuit_breaker": {
"enabled": True,
"fail_max": 5,
"reset_timeout": 30,
},
}
}
```
GET 查询可以默认重试。
POST 默认不重试。
需要重试 POST 时,必须提供 `idempotency_key`
## 任务边界
ERP Gateway 可以使用 iTi-Flask 的轻量 task runner。
限制:
- 只在一个专用进程启用。
- 任务状态第一版可以存在进程内。
- 需要跨重启恢复时,再引入数据库任务表。
这不是分布式任务平台。

@ -0,0 +1,214 @@
# iTi-Flask 框架边界
iTi-Flask 是给多个业务项目复用的轻量后端基座。
它不是业务应用。
它不是微服务平台。
它不是前端发布包。
## 包和模板
iTi-Flask 以 Python 包发布。
业务项目依赖 Git tag
```toml
dependencies = [
"iti-flask @ git+ssh://git@example.com/iTi-Flask.git@v0.1.0",
]
```
Copier 只负责生成项目骨架。
模板不会把框架源码复制到业务项目里。
## 核心职责
框架负责:
- 应用工厂。
- 配置加载。
- APIFlask 集成。
- SQLAlchemy、迁移、缓存、限流、JWT、日志、错误处理集成。
- 系统域 API。
- 模块协议。
- 运行时插件加载。
- HTTP JSON 服务客户端。
- 单进程任务注册表和运行器。
- 框架系统种子数据。
- 框架迁移同步辅助能力。
## 系统域
系统域属于框架核心,并默认启用。
系统域包括:
- 认证。
- 用户。
- 角色。
- 菜单。
- 部门。
- 字典。
- 系统配置。
- 文件。
- 审计日志。
- 用户扩展属性。
业务项目可以直接使用用户扩展属性。
不需要额外声明属性使用范围。
## 扩展规则
业务项目只能扩展框架。
不能修改或覆盖框架实现。
允许:
- 注册业务模块。
- 跨模块调用时只调用公开 facade 函数。
- 注册业务蓝图。
- 注册业务权限和菜单。
- 在业务项目中增加业务模型。
- 使用用户扩展属性。
- 配置服务和插件。
- 安装框架 optional extras。
禁止:
- 复制并修改框架模块。
- 导入其它模块的内部 model、repository 或私有 service 层。
- shadow 框架 import path。
- monkey patch 框架函数。
- 改变框架系统表字段语义。
- 直接修改已安装的包源码。
框架问题必须在框架仓库修复,并通过新的 Git tag 发布。
## 模块和服务
模块是代码边界。
模块运行在同一个 Flask 进程内。
服务是部署边界。
服务通过 HTTP JSON 调用。
默认使用模块。
只有同时满足以下条件时,才拆成服务:
- 至少两个业务项目需要复用这个能力。
- 该能力有外部系统、独立数据源或独立发布诉求。
- 业务项目不应该理解该能力的内部细节。
ERP 是服务候选。
它应当作为 ERP Gateway 服务存在,不属于框架核心。
模块菜单可以通过 `ModuleMenuSeed` 声明。
`flask iti seed system` 会把模块菜单写入系统菜单表,并默认绑定给 `ADMIN`
模块权限通过 `ModulePermission` 声明,实际授权仍然由菜单 `auth_code` 生效。
## 服务客户端边界
第一版服务客户端只支持 HTTP JSON。
提供:
- base URL 配置。
- service token 鉴权。
- 超时。
- 保守重试。
- 可选熔断。
- trace id 透传。
- 结构化调用日志。
- JSON 编码和解码。
- 测试用 mock transport。
不提供:
- 服务发现。
- 负载均衡。
- gRPC。
- streaming。
- async client。
- service mesh。
## 任务运行器边界
第一版任务运行器是单进程能力。
提供:
- 任务注册。
- 手动触发。
- interval 和简单 cron-like 调度。
- 单进程内防重复执行。
- 运行日志。
- 状态查询。
不提供:
- 分布式锁。
- 多实例 exactly-once。
- 默认 Celery 或 RQ。
多进程生产部署时,只在一个专用实例中启用 scheduler。
## API 状态码规则
面向后台前端的 API 可以保留现有响应 envelope
```json
{"code": 200, "message": "success", "data": {}}
```
服务间 API 必须使用真实 HTTP 状态码。
服务客户端依赖状态码做重试、熔断和监控判断。
## 数据库规则
业务项目只保留一条 migration 流。
迁移文件必须提交到 Git。
生产只从 `main` 执行迁移。
框架系统迁移会同步到业务项目 migration 流。
生产应只执行一个命令:
```bash
flask db upgrade
```
## 分支规则
推荐分支模型:
- `main`:只用于生产发布。
- `dev`:集成分支。
- `user/<name>`:个人开发分支。
`dev` 发布前必须收敛成一个 migration head。
生产不能从个人分支发布。
## 种子数据规则
框架 seed 代码只初始化系统域数据。
seed 规则:
- 只用 Python seed。
- 必须幂等。
- 只写系统数据。
- 可以写入模块声明的菜单 seed。
- 按唯一键 upsert。
- 不删除用户数据。
- 不重置已有管理员密码。
- 输出变更摘要。
运行时数据库不是源码资产。
## 生成物
仓库不应跟踪:
- 运行时数据库。
- 前端构建产物。
- `__pycache__`
- 测试缓存。
- 覆盖率产物。

@ -748,13 +748,13 @@ def handle_exception(error):
```bash
# 运行 HTTP 工具测试
hatch run test tests/test_http_utils.py -v
uv run --extra dev pytest tests/test_http_utils.py -v
# 运行所有测试
hatch run test
uv run --extra dev pytest
# 测试覆盖率
hatch run cov
uv run --extra dev pytest --cov=iti --cov-report=html
```
### 测试统计

@ -0,0 +1,107 @@
# 数据库迁移
## 规则
每个业务项目只保留一条 Alembic migration 流。
`migrations/versions` 必须提交到 Git。
运行时数据库文件不提交。
生产只从 `main` 升级:
```bash
flask db upgrade
```
## 分支模型
推荐分支模型:
- `main`:生产发布分支。
- `dev`:集成分支。
- `user/<name>`:个人开发分支。
`dev` 合并到 `main` 前必须只有一个 migration head。
## 文件名
migration 文件名使用日期、时间、revision 和 message slug。
`migrations/alembic.ini`
```ini
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
```
migration message 第一个词是作者名。
后面自由描述。
```bash
uv run python -m flask --app app.py db migrate -m "alice add workorder priority"
```
生成示例:
```text
20260508_1430_9f8a7c6d2e1a_alice_add_workorder_priority.py
```
## 合并多个 Head
个人分支合入 `dev` 前执行:
```bash
git fetch
git merge origin/dev
uv run python -m flask --app app.py db heads
uv run python -m flask --app app.py db current
uv run python -m flask --app app.py db upgrade
```
如果出现多个 head
```bash
uv run python -m flask --app app.py db merge heads -m "alice merge heads before release"
uv run python -m flask --app app.py db upgrade
```
`dev` 应保持一个 head。
## 手工数据库变更
直接修改生产库结构不是正常流程。
如果发生紧急手工变更,必须当天补 migration并把目标库恢复到一致的 Alembic 版本。
已经合并并部署过的 migration 不允许回头修改。
## 框架迁移
框架系统迁移会复制或同步到业务项目 migration 流。
业务项目生产仍然只执行一个命令:
```bash
flask db upgrade
```
同步命令:
```bash
flask iti migrations sync
```
默认同步到:
```text
migrations/versions
```
也可以指定目录:
```bash
flask iti migrations sync --target migrations/versions
```
该命令只复制业务项目缺失的框架 migration。
已有同名文件不会覆盖。

@ -0,0 +1,141 @@
# 模块协议
模块用于业务项目内的代码边界。
模块运行在同一个 Flask 进程内。
它不是独立服务。
## 适用范围
适合做模块的能力:
- 某个业务项目内部的业务域。
- 需要独立路由、service 和 model 目录的功能。
- 多人并行开发,但共享同一个部署单元和数据库。
不适合做模块的能力:
- 需要被多个项目跨进程复用。
- 有独立数据源。
- 有独立发布节奏。
- 业务项目不应该理解其内部细节。
这类能力应拆成 HTTP JSON 服务。
ERP Gateway 属于服务候选。
## 注册方式
业务项目在 `app.py` 传入模块实例:
```python
from iti.applications import create_app
from my_project.modules.example import ExampleModule
app = create_app(modules=[ExampleModule()])
```
模块可以声明这些阶段:
```python
class ExampleModule:
name = "example"
def init_app(self, app):
...
def register_commands(self, app):
...
def register_routes(self, app):
...
def register_permissions(self, app):
...
def register_menu_seed(self, app):
...
```
执行顺序固定:
1. `init_app`
2. `register_commands`
3. `register_routes`
4. `register_permissions`
5. `register_menu_seed`
## 权限声明
模块通过注册表声明权限码:
```python
from iti.modules import ModulePermission, get_module_registry
def register_permissions(self, app):
registry = get_module_registry(app)
registry.register_permission(
ModulePermission(
code="example:item:list",
name="示例列表",
description="查看示例模块数据",
)
)
```
权限码本身不单独落表。
实际授权仍然来自菜单 `auth_code`
## 菜单 Seed
模块可以声明菜单 seed
```python
from iti.applications.common.enums import MenuTypeEnum
from iti.modules import ModuleMenuSeed, get_module_registry
def register_menu_seed(self, app):
registry = get_module_registry(app)
registry.register_menu_seed(
ModuleMenuSeed(
id="example-menu-root",
name="Example",
type=MenuTypeEnum.MENU.value,
path="/example",
component="/example/list",
auth_code="example:item:list",
meta={"title": "示例模块", "icon": "carbon:application"},
sort=100,
)
)
```
执行命令:
```bash
flask iti seed system
```
命令会把模块菜单写入 `sys_menu`
默认只绑定到 `ADMIN`
## 边界
模块可以:
- 注册蓝图。
- 注册 CLI 命令。
- 声明权限。
- 声明菜单 seed。
- 使用业务项目自己的 model。
模块不应该:
- 修改框架内部实现。
- 直接导入其它模块内部 model 或 service。
- 自建独立 migration 流。
- 在框架 seed 中写业务数据。
业务 model 仍然集中在业务项目 `models/` 下。
整个业务项目只保留一条 migration 流。

@ -1,190 +1,20 @@
# iTi-Flask 项目文档
# iTi-Flask 文档
## 📚 文档索引
## 架构与边界
### 核心文档
- [框架边界](FRAMEWORK_BOUNDARY.md)
- [架构重构计划](ARCHITECTURE_REFACTOR_PLAN.md)
- [模块协议](MODULES.md)
- [ERP Gateway 契约草案](ERP_GATEWAY.md)
- **[HTTP_RESPONSE_UTILS.md](./HTTP_RESPONSE_UTILS.md)** - HTTP 响应包装工具使用指南
## 工程规则
---
- [数据库迁移](MIGRATIONS.md)
- [种子数据](SEEDS.md)
- [服务客户端](SERVICE_CLIENT.md)
- [任务运行器](TASKS.md)
## 🎯 快速开始
### HTTP 响应工具
统一的 API 响应格式包装工具,提供 `success()`, `fail()`, `page()` 等函数。
```python
from iti.applications.common.utils import success, fail, page
# 成功响应
@app.get('/api/users/<int:user_id>')
def get_user(user_id):
user = User.query.get(user_id)
return success(user, message='获取成功')
# 失败响应
@app.get('/api/users/<int:user_id>')
def get_user(user_id):
user = User.query.get(user_id)
if not user:
return fail('用户不存在', code=404)
return success(user)
# 分页响应
@app.get('/api/users')
def get_users():
pagination = User.query.paginate(page=1, per_page=10)
return page(pagination, message='获取用户列表成功')
```
**详细文档:** [HTTP_RESPONSE_UTILS.md](./HTTP_RESPONSE_UTILS.md)
---
## 🗂️ 项目结构
```
back/
├── docs/ # 📚 文档目录
│ ├── README.md # 文档索引(本文件)
│ └── HTTP_RESPONSE_UTILS.md # HTTP 工具使用指南
├── src/
│ ├── applications/
│ │ ├── __init__.py # 应用工厂
│ │ ├── extensions/ # 扩展初始化
│ │ │ ├── http.py # BaseResponse Schema
│ │ │ └── ...
│ │ └── common/
│ │ └── utils/
│ │ ├── __init__.py # 工具导出
│ │ └── http.py # 🔥 HTTP 响应包装工具
│ │
│ ├── config.py # 配置文件
│ └── app.py # 应用入口
├── tests/ # 测试目录
├── hatch.toml # Hatch 配置
└── pyproject.toml # 项目配置
```
---
## 🔧 工具模块
### `applications.common.utils`
**提供的函数:**
| 函数 | 说明 | 示例 |
|------|------|------|
| `success()` | 成功响应包装 | `return success(data, message='成功')` |
| `fail()` | 失败响应包装 | `return fail('错误', code=404)` |
| `page()` | 分页响应包装 | `return page(pagination)` |
| `pagination_builder()` | 分页信息构建器 | `pagination_builder(None, page=1, size=10, total=100)` |
**提供的 Schema**
| Schema | 说明 |
|--------|------|
| `PaginationSchema` | 分页信息 Schema`per_page` → `size` |
| `PageDataSchema` | 分页数据 Schema |
---
## 📝 编码规范
### 1. 统一返回格式
所有 API 响应必须使用统一格式:
```json
{
"data": <返回数据>,
"code": <业务状态码>,
"message": <提示信息>
}
```
### 2. HTTP 状态码
- ✅ **成功和业务失败都返回 HTTP 200**
- ✅ 由 `code` 字段区分业务状态
- ✅ 前端统一处理,无需捕获 HTTP 异常
### 3. 分页参数
- ✅ 使用 `page` 表示页码(从 1 开始)
- ✅ 使用 `size` 表示每页数量(不是 `per_page`
**示例:**
```
GET /api/users?page=1&size=10
```
---
## 🧪 测试
### 运行测试
```bash
# 运行 HTTP 工具测试
hatch run test tests/test_http_utils.py -v
# 运行所有测试
hatch run test
# 带详细输出
hatch run test -v
```
### 测试覆盖率
```bash
# 生成覆盖率报告
hatch run cov
# 查看 HTML 报告
# 打开 htmlcov/index.html
```
### 测试统计
| 测试文件 | 测试数量 | 说明 |
|---------|---------|------|
| `test_config.py` | 7 个 | 配置相关测试 |
| `test_limiter.py` | 6 个 | 限流器测试 |
| `test_http_utils.py` | 33 个 | HTTP 工具测试 |
| **总计** | **46 个** | 全部通过 ✅ |
---
## 🚀 开发环境
### 启动开发服务器
```bash
hatch run dev
```
### 生产环境
```bash
hatch run prod:start
```
---
## 📖 相关链接
- [APIFlask 官方文档](https://apiflask.com/)
- [Flask 官方文档](https://flask.palletsprojects.com/)
- [Flask-SQLAlchemy 文档](https://flask-sqlalchemy.palletsprojects.com/)
---
**最后更新:** 2025-10-14
**维护者:** iTi-Flask Team
## 既有工具文档
- [HTTP 响应工具](HTTP_RESPONSE_UTILS.md)
- [限流配置](LIMITER_CONFIG.md)

@ -0,0 +1,67 @@
# 种子数据
框架 seed 用于初始化系统域数据。
seed 使用 Python 代码。
通过 SQLAlchemy model 和 service 适配不同数据库。
## 规则
seed 代码必须:
- 幂等。
- 只写系统域初始数据。
- 可重复执行。
- 不做破坏性操作。
seed 代码不得:
- 删除用户数据。
- 重置已有管理员密码。
- 写入业务表数据。
允许:
- 创建缺失的内置角色:`ADMIN`、`COMMON`。
- 创建缺失的内置菜单。
- 创建缺失的内置字典。
- 创建缺失的内置配置。
- 在没有管理员时创建默认管理员。
- 更新内置菜单名称、排序和权限码。
- 为 `ADMIN` 绑定全部系统菜单。
- 为 `admin` 绑定 `ADMIN` 角色。
- 写入模块通过 `ModuleMenuSeed` 声明的菜单。
- 默认为 `ADMIN` 绑定模块菜单。
不写入:
- ERP 数据。
- 业务字典。
- 业务默认账号。
- 业务表初始数据。
默认管理员:
```text
username: admin
password: 123456
role: ADMIN
```
如果 `admin` 已存在seed 不会重置密码。
## 命令
```bash
flask iti seed system
```
预期输出:
```text
roles: created 2, updated 1, skipped 0
menus: created 18, updated 4, skipped 0
users: created 1, updated 0, skipped 1
```
运行时数据库文件不是 seed 文件,也不提交。

@ -0,0 +1,90 @@
# 服务客户端
iTi-Flask 提供轻量 HTTP JSON 服务客户端。
它用于调用 ERP 这类独立 Gateway 服务。
它不是 service mesh也不是服务发现系统。
## 范围
第一版支持:
- HTTP JSON。
- 同步调用。
- base URL 配置。
- service token 鉴权。
- 超时。
- 保守重试。
- 可选熔断。
- trace id 透传。
- 结构化调用日志。
- 测试用 mock transport。
不支持:
- async client。
- 服务发现。
- 负载均衡。
- gRPC。
- streaming。
- OpenAPI client 生成。
## 配置
Flask 配置示例:
```python
SERVICES = {
"erp": {
"base_url": "http://iti-erp:8000",
"token": "change-me",
"timeout": {
"connect": 1.0,
"read": 5.0,
"write": 5.0,
"pool": 1.0,
},
"retry": {
"attempts": 2,
"backoff": 0.2,
"statuses": [502, 503, 504],
},
"circuit_breaker": {
"enabled": False,
"fail_max": 5,
"reset_timeout": 30,
},
}
}
```
## 使用
```python
from iti.service_client import service_client
erp = service_client("erp")
payload = erp.get("/erp/users/{id}", path={"id": user_id})
```
POST 默认不重试。
```python
result = erp.post("/erp/sync/jobs", json={"kind": "users"})
```
## 错误语义
服务间 API 使用真实 HTTP 状态码。
客户端在以下情况抛出框架服务错误:
- 缺少服务配置。
- 超时。
- 传输错误。
- 熔断打开。
- 非 2xx HTTP 响应。
- 期望 JSON 但响应不是合法 JSON。
后台 API 的 envelope 规则不适用于服务间 API。

@ -0,0 +1,64 @@
# 任务
iTi-Flask 提供单进程任务注册表和运行器。
它用于轻量定时任务或手动触发任务。
它不是分布式任务平台。
## 范围
支持:
- 任务注册。
- 手动触发。
- interval 调度。
- 简单 cron-like 调度。
- 单进程内防重复执行。
- 内存中的运行状态。
- 结构化日志。
不支持:
- 分布式锁。
- 多实例 exactly-once。
- 默认 Celery 或 RQ。
- 持久化队列存储。
多进程生产部署时,只在一个专用进程中启用 scheduler。
## 使用
```python
from iti.tasks import task_registry
def sync_users():
return {"synced": 10}
task_registry.register(
name="erp.sync.users",
handler=sync_users,
schedule="interval:600",
)
```
手动触发:
```python
from iti.tasks import task_registry
run = task_registry.trigger("erp.sync.users")
```
## 调度格式
第一版支持的 schedule 格式:
```text
interval:60
cron:*/10 * * * *
```
内置 cron 支持刻意保持很小。
复杂调度后续接专用 scheduler 集成。

@ -1,159 +0,0 @@
# 开发环境(默认)
[envs.default]
type = "virtual"
dependencies = [
"flask>=3.1.0",
"apiflask>=2.4.0",
"flask-cors>=6.0.0",
"flask-sqlalchemy>=3.1.0",
"SQLAlchemy>=2.0.0",
"flask-migrate>=4.1.0",
"flask-marshmallow>=1.3.0",
"Flask-JWT-Extended>=4.7.0",
"Flask-Limiter>=4.0.0",
"flask-moment>=1.0.0",
"Flask-Caching>=2.3.0",
"validators>=0.35.0",
"marshmallow>=4.0.0",
"marshmallow-sqlalchemy>=1.4.0",
"marshmallow-dataclass>=8.7.0",
"watchdog>=6.0.0",
"Werkzeug>=3.1.0",
"importlib-metadata>=8.7.0",
"PyMySQL>=1.1.0",
"python-dotenv>=1.0.0",
"mypy>=1.0.0",
"Pillow>=12.0.0",
"pandas>=2.3.3",
"openpyxl>=3.1.5",
# 阿里云OSS
"oss2>=2.19.1",
# 腾讯云COS
"cos-python-sdk-v5>=1.9.38",
# 七牛云Kodo
"qiniu>=7.17.0",
# 华为云OBS
"esdk-obs-python>=3.25.8",
# AWS S3
"boto3>=1.40.62",
# MinIO
"minio>=7.2.18",
# 测试
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
"pytest-mock>=3.0.0",
"pytest-asyncio>=0.21.0",
]
[envs.default.env-vars]
FLASK_ENV = "dev"
[envs.default.scripts]
# 开发环境启动
dev = "python -m flask --app iti/app.py run --debug"
# db
db = "python -m flask --app iti/app.py db {args}"
# 类型检查
check = "mypy --install-types --non-interactive {args:iti tests}"
# 运行测试
test = "pytest {args:tests}"
# 带覆盖率的测试
cov = "pytest --cov=iti --cov-report=html {args:tests}"
# 测试环境
[envs.test]
type = "virtual"
dependencies = [
"flask>=3.1.0",
"apiflask>=2.4.0",
"flask-cors>=6.0.0",
"flask-sqlalchemy>=3.1.0",
"SQLAlchemy>=2.0.0",
"flask-migrate>=4.1.0",
"flask-marshmallow>=1.3.0",
"Flask-JWT-Extended>=4.7.0",
"Flask-Limiter>=4.0.0",
"flask-moment>=1.0.0",
"Flask-Caching>=2.3.0",
"validators>=0.35.0",
"marshmallow>=4.0.0",
"marshmallow-sqlalchemy>=1.4.0",
"marshmallow-dataclass>=8.7.0",
"Pillow>=12.0.0",
"pandas>=2.3.3",
"openpyxl>=3.1.5",
# 阿里云OSS
"oss2>=2.19.1",
# 腾讯云COS
"cos-python-sdk-v5>=1.9.38",
# 七牛云Kodo
"qiniu>=7.17.0",
# 华为云OBS
"esdk-obs-python>=3.25.8",
# AWS S3
"boto3>=1.40.62",
# MinIO
"minio>=7.2.18",
# 测试
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
"pytest-mock>=3.0.0",
]
[envs.test.env-vars]
FLASK_ENV = "test"
[envs.test.scripts]
test = "pytest {args:tests}"
cov = "pytest --cov=iti --cov-report=html --cov-report=term {args:tests}"
# 生产环境
[envs.prod]
type = "virtual"
python = ">=3.11"
dependencies = [
"flask>=3.1.0",
"apiflask>=2.4.0",
"flask-cors>=6.0.0",
"flask-sqlalchemy>=3.1.0",
"SQLAlchemy>=2.0.0",
"flask-migrate>=4.1.0",
"flask-marshmallow>=1.3.0",
"Flask-JWT-Extended>=4.7.0",
"Flask-Limiter>=4.0.0",
"flask-moment>=1.0.0",
"Flask-Caching>=2.3.0",
"validators>=0.35.0",
"marshmallow>=4.0.0",
"marshmallow-sqlalchemy>=1.4.0",
"marshmallow-dataclass>=8.7.0",
"Werkzeug>=3.1.0",
"importlib-metadata>=8.7.0",
"PyMySQL>=1.1.0",
"python-dotenv>=1.0.0",
"waitress>=2.1.0", # 跨平台生产服务器(支持 Windows
"Pillow>=12.0.0",
"pandas>=2.3.3",
"openpyxl>=3.1.5",
# 阿里云OSS
"oss2>=2.19.1",
# 腾讯云COS
# "cos-python-sdk-v5>=1.9.38",
# 七牛云Kodo
# "qiniu>=7.17.0",
# 华为云OBS
# "esdk-obs-python>=3.25.8",
# AWS S3
# "boto3>=1.40.62",
# MinIO
# "minio>=7.2.18",
]
[envs.prod.env-vars]
FLASK_ENV = "prod"
[envs.prod.scripts]
# 生产环境启动(使用 Waitress支持 Windows
prod = "waitress-serve --host=0.0.0.0 --port=8000 --threads=4 app:app"
# Linux 环境可选 Gunicorn需手动安装pip install gunicorn
# start-gunicorn = "gunicorn -w 4 -b 0.0.0.0:8000 'app:app'"

@ -1,40 +0,0 @@
FLASK_ENV=dev
SECRET_KEY=iti-flask
JWT_SECRET_KEY=iti-flask
DATABASE_URL=sqlite:///./../runtime/iti-flask_dev.db
# 前端相关
# FRONTEND_ENABLED=False # 是否启用前端渲染
# FRONTEND_PATH=dist # 前端文件所在位置,若static则无需填写
# ============================================
# 文件存储 - 阿里云OSS
# ============================================
# ALIYUN_OSS_ACCESS_KEY_ID=LTAI5t...
# ALIYUN_OSS_ACCESS_KEY_SECRET=your_access_key_secret
# ALIYUN_OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
# ALIYUN_OSS_BUCKET=your-bucket-name
# ============================================
# 文件存储 - 腾讯云COS
# ============================================
# TENCENT_COS_SECRET_ID=AKIDxxx
# TENCENT_COS_SECRET_KEY=your_secret_key
# TENCENT_COS_REGION=ap-guangzhou
# TENCENT_COS_BUCKET=your-bucket-1234567890
# ============================================
# 文件存储 - 七牛云Kodo
# ============================================
# QINIU_KODO_ACCESS_KEY=your_access_key
# QINIU_KODO_SECRET_KEY=your_secret_key
# QINIU_KODO_BUCKET=your-bucket-name
# QINIU_KODO_DOMAIN=cdn.example.com
# ============================================
# 文件存储 - 华为云OBS
# ============================================
# HUAWEI_OBS_ACCESS_KEY_ID=your_access_key_id
# HUAWEI_OBS_SECRET_ACCESS_KEY=your_secret_access_key
# HUAWEI_OBS_SERVER=obs.cn-north-4.myhuaweicloud.com
# HUAWEI_OBS_BUCKET=your-bucket-name

@ -1,15 +0,0 @@
FLASK_ENV=dev
SECRET_KEY=iti-flask
JWT_SECRET_KEY=iti-flask
DATABASE_URL=mysql+pymysql://root:root@127.0.0.1:3307/iti-flask?charset=utf8mb4
# 前端相关
FRONTEND_ENABLED=False # 是否启用前端渲染
FRONTEND_PATH=dist # 前端文件所在位置,若static则无需填写
# ============================================
# 文件存储 - 阿里云OSS
# ============================================
ALIYUN_OSS_ACCESS_KEY_ID=LTAI5t9cymUAWHVEo36yygaT
ALIYUN_OSS_ACCESS_KEY_SECRET=FaaUsxadRYyshbYeAV8ypZNYVOx3tE
ALIYUN_OSS_ENDPOINT=oss-cn-chengdu.aliyuncs.com
ALIYUN_OSS_BUCKET=maintaince-dev

@ -1,7 +0,0 @@
FLASK_ENV=prod
SECRET_KEY=zhSYJn577LgxyWDuQboM9wX3j2BHEFUP
JWT_SECRET_KEY=8YD37VvM3WgdpmKNt7kVFNbKnya4hBRh
DATABASE_URL=mysql+pymysql://root:root@127.0.0.1:3307/iti-flask?charset=utf8mb4
# 前端相关
FRONTEND_ENABLED=False # 是否启用前端渲染
FRONTEND_PATH=dist # 前端文件所在位置,若static则无需填写

@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: 2025-present NoahLan <6995syu@163.com>
#
# SPDX-License-Identifier: MIT
__version__ = "1.0.0"
__version__ = "0.1.0"

@ -1,22 +1,29 @@
import os
import warnings
from collections.abc import Mapping
from apiflask import APIFlask
from iti.applications.common.utils.schema import custom_schema_name_resolver
from iti.applications.service import init_services
from iti.modules import init_modules
from iti.service_client import init_service_clients
from iti.tasks import init_task_runner
from .extensions import init_exts
from .routes import init_routes
from ..config import get_config
from .events import init_event_handlers
def create_app(config_name=None):
def create_app(config_name=None, modules=None, config_mapping=None, model_imports=None):
"""
应用工厂函数
Args:
config_name: 配置名称 ('dev', 'test', 'prod')
如果为 None则从环境变量 FLASK_ENV 读取
modules: 进程内业务模块列表
config_mapping: 业务项目配置映射格式与 iti.config.config 一致
model_imports: 业务模型导入函数列表用于让 Flask-Migrate 看到业务模型
Returns:
Flask 应用实例
@ -41,7 +48,7 @@ def create_app(config_name=None):
)
# 加载配置
config_obj = get_config(config_name)
config_obj = _resolve_config(config_name, config_mapping)
app.config.from_object(config_obj)
# 配置自定义 schema 名称解析器
@ -52,6 +59,11 @@ def create_app(config_name=None):
# 确保必要的目录存在
_ensure_directories(app)
# 注册框架 CLI
from iti.cli import iti_cli
app.cli.add_command(iti_cli, "iti")
# 使用第三方JWT自定义Security避免doc无法传递header
# 等同于 SECURITY_SCHEMES 配置
app.security_schemes = {
@ -69,12 +81,27 @@ def create_app(config_name=None):
# 初始化扩展
init_exts(app)
# 导入业务模型,确保 Alembic autogenerate 能看到业务表。
for import_models in model_imports or []:
import_models()
# 初始化可配置服务客户端与任务系统
init_service_clients(app)
init_task_runner(app)
# 初始化业务模块。模块路由会在系统路由之后注册。
module_registry = init_modules(app, modules)
# 初始化事件处理器
init_event_handlers(app)
# 初始化路由
init_routes(app)
module_registry.run_phase("register_routes", app)
module_registry.run_phase("register_permissions", app)
module_registry.run_phase("register_menu_seed", app)
# 初始化Services
init_services(app)
@ -86,6 +113,19 @@ def create_app(config_name=None):
return app
def _resolve_config(config_name=None, config_mapping=None):
if config_mapping is None:
return get_config(config_name)
env_name = config_name or os.getenv("FLASK_ENV", "dev")
if isinstance(config_mapping, Mapping):
return config_mapping.get(
env_name, config_mapping.get("default", get_config(config_name))
)
return config_mapping
def _ensure_directories(app):
"""确保必要的目录存在"""
# 数据库目录SQLite

@ -4,6 +4,7 @@ import datetime
from marshmallow import Schema
from marshmallow_sqlalchemy import SQLAlchemyAutoSchema
import uuid
from flask import has_request_context
from flask_jwt_extended import current_user, verify_jwt_in_request
from typing import Optional
@ -28,6 +29,8 @@ class AuditModelMixin(object):
"""
def get_current_user_identity():
if not has_request_context():
return None
verify_jwt_in_request(True)
return current_user.id if current_user else None

@ -29,7 +29,7 @@ def get_permission_config(key: str, default=None):
def get_super_admin_role() -> str:
"""获取超级管理员角色代码"""
return get_permission_config("SUPER_ADMIN_ROLE", "SUPER_ADMIN")
return get_permission_config("SUPER_ADMIN_ROLE", "ADMIN")
def get_default_error_message() -> str:

@ -17,7 +17,7 @@ def get_user_identifier():
return request.remote_addr
# 全局 limiter 实例
# 全局 limiter 实例。路由模块会在导入期使用 @limiter.limit。
limiter = Limiter(key_func=get_user_identifier)
@ -26,6 +26,13 @@ def init_limiter(app) -> None:
初始化限流器
Flask 配置中读取限流设置
"""
storage_uri = app.config.get(
"RATELIMIT_STORAGE_URL",
app.config.get("RATELIMIT_STORAGE_URI", "memory://"),
)
limiter.enabled = app.config.get("RATELIMIT_ENABLED", True)
limiter._storage_uri = storage_uri
app.config["RATELIMIT_STORAGE_URI"] = storage_uri
limiter.init_app(app)
@app.errorhandler(429)

@ -11,7 +11,10 @@ PLUGIN_IMPORTLIB = []
def init_plugin(app) -> None:
"""
初始化插件
初始化运行时插件
插件是部署时可选扩展不是业务项目主模块业务主模块应通过
create_app(modules=[...]) 显式注册
"""
global PLUGIN_ENABLE_FOLDERS
app.register_blueprint(plugin_bp)

@ -9,10 +9,9 @@ from .menu import bp as menu_bp
from .dept import bp as dept_bp
from .file import bp as file_bp
sys_bp = APIBlueprint("sys", __name__, url_prefix="/sys")
def register_sys_bp(app):
sys_bp = APIBlueprint("sys", __name__, url_prefix="/sys")
sys_bp.register_blueprint(log_bp)
sys_bp.register_blueprint(user_bp)
sys_bp.register_blueprint(role_bp)

@ -1,10 +1,13 @@
def init_services(app) -> None:
"""初始化Services"""
if not app.config.get("INIT_SYSTEM_SERVICES_ON_STARTUP", False):
return
# 初始化文件系统配置
from iti.applications.service.sys.sys_file_config import init_app as init_file_config
init_file_config(app)
# 初始化文件目录
from iti.applications.service.sys.sys_file_directory import init_app as init_file_directory
init_file_directory(app)
init_file_directory(app)

@ -0,0 +1,73 @@
from __future__ import annotations
import importlib.resources
import shutil
from pathlib import Path
import click
@click.group()
def iti_cli() -> None:
"""iTi-Flask framework commands."""
@iti_cli.group()
def seed() -> None:
"""Seed framework data."""
@seed.command("system")
def seed_system() -> None:
"""Seed system-domain data."""
from flask import current_app
from iti.modules import get_module_registry
from .seeds.system import seed_system_data
summary = seed_system_data(get_module_registry(current_app))
for name, result in summary.items():
click.echo(
f"{name}: created {result['created']}, updated {result['updated']}, skipped {result['skipped']}"
)
@iti_cli.group()
def migrations() -> None:
"""Framework migration helpers."""
@migrations.command("sync")
@click.option(
"--target",
default="migrations/versions",
show_default=True,
help="业务项目 migration versions 目录",
)
def sync_migrations(target: str) -> None:
"""同步框架系统迁移到当前项目 migration 流。"""
target_dir = Path(target)
target_dir.mkdir(parents=True, exist_ok=True)
synced: list[str] = []
skipped: list[str] = []
source_root = importlib.resources.files("iti.framework_migrations").joinpath(
"versions"
)
for source in source_root.iterdir():
if not source.name.endswith(".py") or source.name == "__init__.py":
continue
target_file = target_dir / source.name
if target_file.exists():
skipped.append(source.name)
continue
with importlib.resources.as_file(source) as source_path:
shutil.copy2(source_path, target_file)
synced.append(source.name)
for name in synced:
click.echo(f"synced: {name}")
for name in skipped:
click.echo(f"skipped: {name}")
click.echo(f"summary: synced {len(synced)}, skipped {len(skipped)}")

@ -1,6 +1,8 @@
import os
from datetime import timedelta
import json
from pathlib import Path
from typing import Any
# 项目根目录
@ -23,30 +25,26 @@ def _get_bool_env(key: str, default: bool = False) -> bool:
return value.lower() in ("true", "1", "yes", "on")
def _load_env_file():
"""加载 .env 文件(模块级别调用)"""
def _load_env_file(env_dir: str | os.PathLike | None = None) -> bool:
"""从项目目录加载 .env 文件。"""
try:
from dotenv import load_dotenv
# 按优先级查找 .env 文件
search_dir = Path(env_dir or os.getenv("ITI_ENV_DIR") or os.getcwd()).resolve()
env_name = os.getenv("FLASK_ENV", "dev")
env_files = [
".env.local", # 本地配置(最高优先级)
f".env.{os.getenv('FLASK_ENV', 'dev')}", # 环境特定配置
".env", # 通用配置
".env.local",
f".env.{env_name}",
".env",
]
for env_file in env_files:
# 构建绝对路径
env_path = os.path.join(os.path.dirname(__file__), env_file)
if os.path.exists(env_path):
loaded = load_dotenv(env_path)
print(f"[ENV] 加载环境配置: {env_path} - {loaded}")
env_path = search_dir / env_file
if env_path.exists():
load_dotenv(env_path, override=False)
return True
print("[WARN] 未找到 .env 文件")
return False
except ImportError:
print("[WARN] python-dotenv 未安装,跳过 .env 文件加载")
return False
@ -88,6 +86,7 @@ class BaseConfig:
# 限流配置
RATELIMIT_ENABLED = True
RATELIMIT_KEY_PREFIX = "iti_rate_limit_"
RATELIMIT_STORAGE_URL = "memory://"
RATELIMIT_STORAGE_URI = "memory://"
RATELIMIT_DEFAULT = "200 per hour"
@ -205,10 +204,20 @@ class BaseConfig:
SYSLOG_MAX_BODY_CHARS = 2048
SYSLOG_ITEMS_SAMPLE = 5
# 服务调用配置。业务项目按需添加具体服务。
SERVICES: dict[str, dict[str, Any]] = {}
# 轻量任务配置。多进程部署时只应在一个专用实例启用。
TASKS_ENABLED = False
# 是否在应用启动时写入系统默认服务数据。
# 默认关闭,系统初始数据通过 `flask iti seed system` 幂等写入。
INIT_SYSTEM_SERVICES_ON_STARTUP = False
# 权限配置
PERMISSION_CONFIG = {
# 超级管理员角色代码(自动跳过所有权限检查)
"SUPER_ADMIN_ROLE": "SUPER_ADMIN",
"SUPER_ADMIN_ROLE": "ADMIN",
# 默认错误消息
"DEFAULT_ERROR_MESSAGE": "无权访问!",
# 默认错误代码
@ -270,8 +279,8 @@ class ProdConfig(BaseConfig):
TESTING = False
# 生产环境必须使用环境变量
SECRET_KEY = os.getenv("SECRET_KEY")
JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY")
SECRET_KEY = os.getenv("SECRET_KEY", "")
JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "")
# 生产环境数据库
SQLALCHEMY_DATABASE_URI = os.getenv(

@ -0,0 +1 @@
"""Framework-owned migration source files."""

@ -0,0 +1,359 @@
"""framework initial schema
Revision ID: 7de264f96a03
Revises:
Create Date: 2026-05-08 04:33:59.055459
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '7de264f96a03'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('sys_config',
sa.Column('type', sa.String(length=64), nullable=False, comment='配置类型'),
sa.Column('name', sa.String(length=255), nullable=False, comment='配置名称'),
sa.Column('code', sa.String(length=128), nullable=False, comment='配置编码'),
sa.Column('value', sa.Text(), nullable=True, comment='配置值'),
sa.Column('desc', sa.Text(), nullable=True, comment='配置描述'),
sa.Column('sort', sa.Integer(), nullable=False, comment='排序'),
sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'),
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'),
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'),
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_config')),
mysql_charset='utf8mb4',
mysql_collate='utf8mb4_general_ci'
)
with op.batch_alter_table('sys_config', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_sys_config_created_by'), ['created_by'], unique=False)
batch_op.create_index(batch_op.f('ix_sys_config_updated_by'), ['updated_by'], unique=False)
op.create_table('sys_dept',
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
sa.Column('name', sa.String(length=255), nullable=False, comment='部门名称'),
sa.Column('parent_id', sa.String(length=36), nullable=True, comment='父部门ID'),
sa.Column('desc', sa.Text(), nullable=True, comment='部门描述'),
sa.Column('sort', sa.Integer(), nullable=False, comment='排序'),
sa.Column('leader_id', sa.String(length=36), nullable=True, comment='负责人ID'),
sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'),
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_dept')),
mysql_charset='utf8mb4',
mysql_collate='utf8mb4_general_ci'
)
op.create_table('sys_dict_data',
sa.Column('type_code', sa.String(length=36), nullable=False, comment='类型编码'),
sa.Column('label', sa.String(length=255), nullable=False, comment='数据标签'),
sa.Column('code', sa.String(length=128), nullable=False, comment='数据编码'),
sa.Column('value', sa.Text(), nullable=True, comment='数据值'),
sa.Column('desc', sa.Text(), nullable=True, comment='数据描述'),
sa.Column('sort', sa.Integer(), nullable=False, comment='排序'),
sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'),
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'),
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'),
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_dict_data')),
mysql_charset='utf8mb4',
mysql_collate='utf8mb4_general_ci'
)
with op.batch_alter_table('sys_dict_data', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_sys_dict_data_created_by'), ['created_by'], unique=False)
batch_op.create_index(batch_op.f('ix_sys_dict_data_updated_by'), ['updated_by'], unique=False)
op.create_table('sys_dict_type',
sa.Column('type_name', sa.String(length=255), nullable=False, comment='类型名称'),
sa.Column('type_code', sa.String(length=128), nullable=False, comment='类型编码'),
sa.Column('desc', sa.Text(), nullable=True, comment='类型描述'),
sa.Column('sort', sa.Integer(), nullable=False, comment='排序'),
sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'),
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'),
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'),
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_dict_type')),
sa.UniqueConstraint('type_code', name=op.f('uq_sys_dict_type_type_code')),
mysql_charset='utf8mb4',
mysql_collate='utf8mb4_general_ci'
)
with op.batch_alter_table('sys_dict_type', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_sys_dict_type_created_by'), ['created_by'], unique=False)
batch_op.create_index(batch_op.f('ix_sys_dict_type_updated_by'), ['updated_by'], unique=False)
op.create_table('sys_file',
sa.Column('filename', sa.String(length=255), nullable=False, comment='原始文件名'),
sa.Column('file_key', sa.String(length=512), nullable=False, comment='存储路径'),
sa.Column('file_hash', sa.String(length=64), nullable=True, comment='文件哈希'),
sa.Column('mime_type', sa.String(length=128), nullable=True, comment='MIME类型'),
sa.Column('file_size', sa.BigInteger(), nullable=False, comment='文件大小(字节)'),
sa.Column('extension', sa.String(length=32), nullable=True, comment='文件扩展名'),
sa.Column('storage_type', sa.Enum('local', 'aliyun_oss', 'tencent_cos', 'qiniu_kodo', 'huawei_obs', 'aws_s3', 'minio', name='storagetypeenum'), nullable=False, comment='存储类型'),
sa.Column('storage_info', sa.JSON(), nullable=True, comment='存储信息(bucket/region/endpoint/meta等)'),
sa.Column('directory_id', sa.String(length=36), nullable=True, comment='所属目录ID'),
sa.Column('metadata', sa.JSON(), nullable=True, comment='扩展元数据'),
sa.Column('is_deleted', sa.Boolean(), nullable=False, comment='是否已删除(回收站)'),
sa.Column('deleted_at', sa.DateTime(), nullable=True, comment='删除时间'),
sa.Column('deleted_by', sa.String(length=36), nullable=True, comment='删除人ID'),
sa.Column('share_code', sa.String(length=64), nullable=True, comment='分享码'),
sa.Column('share_password', sa.String(length=64), nullable=True, comment='分享密码'),
sa.Column('share_expire_at', sa.DateTime(), nullable=True, comment='分享过期时间'),
sa.Column('share_count', sa.Integer(), nullable=False, comment='分享访问次数'),
sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'),
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'),
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'),
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_file')),
mysql_charset='utf8mb4',
mysql_collate='utf8mb4_general_ci'
)
with op.batch_alter_table('sys_file', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_sys_file_created_by'), ['created_by'], unique=False)
batch_op.create_index(batch_op.f('ix_sys_file_directory_id'), ['directory_id'], unique=False)
batch_op.create_index(batch_op.f('ix_sys_file_file_hash'), ['file_hash'], unique=False)
batch_op.create_index(batch_op.f('ix_sys_file_file_key'), ['file_key'], unique=True)
batch_op.create_index(batch_op.f('ix_sys_file_share_code'), ['share_code'], unique=True)
batch_op.create_index(batch_op.f('ix_sys_file_updated_by'), ['updated_by'], unique=False)
op.create_table('sys_file_directory',
sa.Column('name', sa.String(length=255), nullable=False, comment='目录名称'),
sa.Column('path', sa.String(length=1024), nullable=False, comment='完整路径'),
sa.Column('parent_id', sa.String(length=36), nullable=True, comment='父目录ID'),
sa.Column('level', sa.Integer(), nullable=True, comment='层级'),
sa.Column('sort', sa.Integer(), nullable=True, comment='排序'),
sa.Column('icon', sa.String(length=128), nullable=True, comment='目录图标'),
sa.Column('color', sa.String(length=32), nullable=True, comment='颜色标记'),
sa.Column('description', sa.Text(), nullable=True, comment='目录描述'),
sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'),
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'),
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'),
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_file_directory'))
)
with op.batch_alter_table('sys_file_directory', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_sys_file_directory_created_by'), ['created_by'], unique=False)
batch_op.create_index('ix_sys_file_directory_path', ['path'], unique=False, mysql_length=255)
batch_op.create_index(batch_op.f('ix_sys_file_directory_updated_by'), ['updated_by'], unique=False)
op.create_table('sys_log',
sa.Column('name', sa.String(length=100), nullable=True, comment='操作名称'),
sa.Column('method', sa.String(length=10), nullable=True, comment='请求方法'),
sa.Column('user_id', sa.String(length=36), nullable=True, comment='用户ID'),
sa.Column('path', sa.String(length=255), nullable=True, comment='请求路径'),
sa.Column('ip', sa.String(length=255), nullable=True, comment='IP地址'),
sa.Column('user_agent', sa.Text(), nullable=True, comment='用户代理'),
sa.Column('headers', sa.Text(), nullable=True, comment='请求头'),
sa.Column('query_params', sa.Text(), nullable=True, comment='请求参数'),
sa.Column('body_params', sa.Text(), nullable=True, comment='请求体参数'),
sa.Column('execution_time', sa.Float(), nullable=True, comment='执行时间(毫秒)'),
sa.Column('response', sa.Text(), nullable=True, comment='响应结果'),
sa.Column('exception', sa.Text(), nullable=True, comment='异常信息'),
sa.Column('success', sa.Boolean(), nullable=True, comment='是否成功'),
sa.Column('desc', sa.Text(), nullable=True, comment='描述'),
sa.Column('type', sa.Enum('SYSTEM', 'AUTH', 'OPERATION', 'AUDIT', 'SECURITY', 'JOB', 'API', 'DB', 'PAYMENT', 'MESSAGE', 'OSS', 'OTHER', name='logtype'), nullable=False, comment='日志类型'),
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'),
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'),
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_log')),
mysql_charset='utf8mb4',
mysql_collate='utf8mb4_general_ci'
)
with op.batch_alter_table('sys_log', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_sys_log_created_by'), ['created_by'], unique=False)
batch_op.create_index(batch_op.f('ix_sys_log_updated_by'), ['updated_by'], unique=False)
op.create_table('sys_menu',
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
sa.Column('name', sa.String(length=255), nullable=False, comment='菜单名称'),
sa.Column('type', sa.Enum('catalog', 'menu', 'button', 'embedded', 'link', name='menutypeenum'), nullable=False, comment='菜单类型'),
sa.Column('path', sa.String(length=255), nullable=True, comment='菜单路径'),
sa.Column('component', sa.String(length=255), nullable=True, comment='菜单组件'),
sa.Column('redirect', sa.String(length=255), nullable=True, comment='菜单重定向'),
sa.Column('sort', sa.Integer(), nullable=False, comment='排序'),
sa.Column('auth_code', sa.String(length=128), nullable=True, comment='权限编码'),
sa.Column('meta', sa.JSON(), nullable=True, comment='菜单元数据'),
sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'),
sa.Column('parent_id', sa.String(length=36), nullable=True, comment='父菜单ID'),
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_menu')),
sa.UniqueConstraint('name', name=op.f('uq_sys_menu_name')),
mysql_charset='utf8mb4',
mysql_collate='utf8mb4_general_ci'
)
op.create_table('sys_role',
sa.Column('name', sa.String(length=64), nullable=False, comment='名称'),
sa.Column('code', sa.String(length=64), nullable=False, comment='编码'),
sa.Column('desc', sa.Text(), nullable=True, comment='描述'),
sa.Column('sort', sa.Integer(), nullable=False, comment='排序'),
sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'),
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'),
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'),
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_role')),
sa.UniqueConstraint('code', name=op.f('uq_sys_role_code')),
mysql_charset='utf8mb4',
mysql_collate='utf8mb4_general_ci'
)
with op.batch_alter_table('sys_role', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_sys_role_created_by'), ['created_by'], unique=False)
batch_op.create_index(batch_op.f('ix_sys_role_updated_by'), ['updated_by'], unique=False)
op.create_table('sys_role_menu',
sa.Column('role_id', sa.String(length=36), nullable=False, comment='角色ID'),
sa.Column('menu_id', sa.String(length=36), nullable=False, comment='菜单ID'),
sa.PrimaryKeyConstraint('role_id', 'menu_id', name=op.f('pk_sys_role_menu'))
)
op.create_table('sys_user',
sa.Column('username', sa.String(length=64), nullable=False, comment='用户名'),
sa.Column('phone', sa.String(length=13), nullable=True, comment='手机号'),
sa.Column('email', sa.String(length=255), nullable=True, comment='邮箱'),
sa.Column('password', sa.String(length=255), nullable=False, comment='密码'),
sa.Column('realname', sa.String(length=32), nullable=True, comment='真实姓名'),
sa.Column('desc', sa.Text(), nullable=True, comment='描述'),
sa.Column('avatar', sa.String(length=255), nullable=True, comment='头像'),
sa.Column('gender', sa.Enum('male', 'female', 'secure', name='genderenum'), nullable=False, comment='性别'),
sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'),
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'),
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'),
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_user')),
mysql_charset='utf8mb4',
mysql_collate='utf8mb4_general_ci'
)
with op.batch_alter_table('sys_user', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_sys_user_created_by'), ['created_by'], unique=False)
batch_op.create_index(batch_op.f('ix_sys_user_updated_by'), ['updated_by'], unique=False)
op.create_table('sys_user_dept',
sa.Column('user_id', sa.String(length=36), nullable=False, comment='用户ID'),
sa.Column('dept_id', sa.String(length=36), nullable=False, comment='部门ID'),
sa.PrimaryKeyConstraint('user_id', 'dept_id', name=op.f('pk_sys_user_dept'))
)
op.create_table('sys_user_role',
sa.Column('user_id', sa.String(length=36), nullable=False, comment='用户ID'),
sa.Column('role_id', sa.String(length=36), nullable=False, comment='角色ID'),
sa.PrimaryKeyConstraint('user_id', 'role_id', name=op.f('pk_sys_user_role'))
)
op.create_table('sys_user_attribute',
sa.Column('user_id', sa.String(length=36), nullable=False, comment='用户ID'),
sa.Column('attr_group', sa.String(length=64), nullable=False, comment='属性分组(如: erp, custom)'),
sa.Column('attr_key', sa.String(length=128), nullable=False, comment='属性键'),
sa.Column('attr_value', sa.Text(), nullable=True, comment='属性值'),
sa.Column('attr_type', sa.String(length=32), nullable=False, comment='值类型(string/int/float/bool/json/encrypted)'),
sa.Column('description', sa.String(length=255), nullable=True, comment='属性描述'),
sa.Column('sort', sa.Integer(), nullable=False, comment='排序'),
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'),
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'),
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
sa.ForeignKeyConstraint(['user_id'], ['sys_user.id'], name=op.f('fk_sys_user_attribute_user_id_sys_user'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_user_attribute')),
sa.UniqueConstraint('user_id', 'attr_group', 'attr_key', name='uk_user_group_key')
)
with op.batch_alter_table('sys_user_attribute', schema=None) as batch_op:
batch_op.create_index('idx_user_group_key', ['user_id', 'attr_group', 'attr_key'], unique=False)
batch_op.create_index(batch_op.f('ix_sys_user_attribute_attr_group'), ['attr_group'], unique=False)
batch_op.create_index(batch_op.f('ix_sys_user_attribute_created_by'), ['created_by'], unique=False)
batch_op.create_index(batch_op.f('ix_sys_user_attribute_updated_by'), ['updated_by'], unique=False)
batch_op.create_index(batch_op.f('ix_sys_user_attribute_user_id'), ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('sys_user_attribute', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_sys_user_attribute_user_id'))
batch_op.drop_index(batch_op.f('ix_sys_user_attribute_updated_by'))
batch_op.drop_index(batch_op.f('ix_sys_user_attribute_created_by'))
batch_op.drop_index(batch_op.f('ix_sys_user_attribute_attr_group'))
batch_op.drop_index('idx_user_group_key')
op.drop_table('sys_user_attribute')
op.drop_table('sys_user_role')
op.drop_table('sys_user_dept')
with op.batch_alter_table('sys_user', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_sys_user_updated_by'))
batch_op.drop_index(batch_op.f('ix_sys_user_created_by'))
op.drop_table('sys_user')
op.drop_table('sys_role_menu')
with op.batch_alter_table('sys_role', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_sys_role_updated_by'))
batch_op.drop_index(batch_op.f('ix_sys_role_created_by'))
op.drop_table('sys_role')
op.drop_table('sys_menu')
with op.batch_alter_table('sys_log', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_sys_log_updated_by'))
batch_op.drop_index(batch_op.f('ix_sys_log_created_by'))
op.drop_table('sys_log')
with op.batch_alter_table('sys_file_directory', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_sys_file_directory_updated_by'))
batch_op.drop_index('ix_sys_file_directory_path', mysql_length=255)
batch_op.drop_index(batch_op.f('ix_sys_file_directory_created_by'))
op.drop_table('sys_file_directory')
with op.batch_alter_table('sys_file', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_sys_file_updated_by'))
batch_op.drop_index(batch_op.f('ix_sys_file_share_code'))
batch_op.drop_index(batch_op.f('ix_sys_file_file_key'))
batch_op.drop_index(batch_op.f('ix_sys_file_file_hash'))
batch_op.drop_index(batch_op.f('ix_sys_file_directory_id'))
batch_op.drop_index(batch_op.f('ix_sys_file_created_by'))
op.drop_table('sys_file')
with op.batch_alter_table('sys_dict_type', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_sys_dict_type_updated_by'))
batch_op.drop_index(batch_op.f('ix_sys_dict_type_created_by'))
op.drop_table('sys_dict_type')
with op.batch_alter_table('sys_dict_data', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_sys_dict_data_updated_by'))
batch_op.drop_index(batch_op.f('ix_sys_dict_data_created_by'))
op.drop_table('sys_dict_data')
op.drop_table('sys_dept')
with op.batch_alter_table('sys_config', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_sys_config_updated_by'))
batch_op.drop_index(batch_op.f('ix_sys_config_created_by'))
op.drop_table('sys_config')
# ### end Alembic commands ###

@ -0,0 +1 @@
"""Framework migration versions."""

@ -0,0 +1,11 @@
from .base import ItiModule, ModuleMenuSeed, ModulePermission
from .registry import ModuleRegistry, get_module_registry, init_modules
__all__ = [
"ItiModule",
"ModuleMenuSeed",
"ModulePermission",
"ModuleRegistry",
"get_module_registry",
"init_modules",
]

@ -0,0 +1,68 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from typing import Protocol
@dataclass(frozen=True)
class ModulePermission:
"""业务模块声明的权限码。"""
code: str
name: str
description: str | None = None
@dataclass(frozen=True)
class ModuleMenuSeed:
"""业务模块声明的菜单 seed 数据。"""
id: str
name: str
type: str
path: str | None = None
component: str | None = None
redirect: str | None = None
parent_id: str | None = None
auth_code: str | None = None
meta: dict[str, Any] = field(default_factory=dict)
sort: int = 0
status: str = "enabled"
admin_roles: tuple[str, ...] = ("ADMIN",)
def as_menu_payload(self) -> dict[str, Any]:
return {
"id": self.id,
"name": self.name,
"type": self.type,
"path": self.path,
"component": self.component,
"redirect": self.redirect,
"parent_id": self.parent_id,
"auth_code": self.auth_code,
"meta": self.meta,
"sort": self.sort,
"status": self.status,
}
class ItiModule(Protocol):
"""Protocol for in-process business modules."""
name: str
def init_app(self, app) -> None:
"""Initialize the module against the Flask app."""
def register_routes(self, app) -> None:
"""Register module routes."""
def register_permissions(self, app) -> None:
"""Register permission metadata."""
def register_menu_seed(self, app) -> None:
"""Register menu seed metadata."""
def register_commands(self, app) -> None:
"""Register CLI commands."""

@ -0,0 +1,86 @@
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass, field
from typing import Any
from .base import ModuleMenuSeed, ModulePermission
ModulePhase = str
@dataclass
class ModuleRegistry:
"""Registry for in-process modules."""
modules: list[Any] = field(default_factory=list)
permissions: dict[str, ModulePermission] = field(default_factory=dict)
menu_seeds: dict[str, ModuleMenuSeed] = field(default_factory=dict)
def register(self, module: Any) -> None:
name = getattr(module, "name", None)
if not name:
raise ValueError("module must define a non-empty name")
if self.get(name) is not None:
raise ValueError(f"module already registered: {name}")
self.modules.append(module)
def extend(self, modules: Iterable[Any] | None) -> None:
for module in modules or []:
self.register(module)
def get(self, name: str) -> Any | None:
for module in self.modules:
if getattr(module, "name", None) == name:
return module
return None
def run_phase(self, phase: ModulePhase, app) -> None:
for module in self.modules:
callback = getattr(module, phase, None)
if callback is not None:
callback(app)
def register_permission(self, permission: ModulePermission) -> ModulePermission:
if not permission.code:
raise ValueError("permission code is required")
if permission.code in self.permissions:
raise ValueError(f"permission already registered: {permission.code}")
self.permissions[permission.code] = permission
return permission
def register_menu_seed(self, menu_seed: ModuleMenuSeed) -> ModuleMenuSeed:
if not menu_seed.id:
raise ValueError("menu seed id is required")
if not menu_seed.name:
raise ValueError("menu seed name is required")
if menu_seed.id in self.menu_seeds:
raise ValueError(f"menu seed already registered: {menu_seed.id}")
self.menu_seeds[menu_seed.id] = menu_seed
return menu_seed
def list_permissions(self) -> list[ModulePermission]:
return list(self.permissions.values())
def list_menu_seeds(self) -> list[ModuleMenuSeed]:
return sorted(
self.menu_seeds.values(),
key=lambda menu_seed: (menu_seed.sort, menu_seed.name),
)
def get_module_registry(app) -> ModuleRegistry:
registry = app.extensions.get("iti_modules")
if registry is None:
registry = ModuleRegistry()
app.extensions["iti_modules"] = registry
return registry
def init_modules(app, modules: Iterable[Any] | None = None) -> ModuleRegistry:
registry = get_module_registry(app)
registry.extend(modules)
registry.run_phase("init_app", app)
registry.run_phase("register_commands", app)
return registry

@ -0,0 +1,3 @@
from .system import seed_system_data
__all__ = ["seed_system_data"]

@ -0,0 +1,697 @@
from __future__ import annotations
from dataclasses import dataclass
from collections.abc import Iterable
from typing import Any
from sqlalchemy import select
from iti.applications.common.enums import GenderEnum, MenuTypeEnum, StatusEnum, SysConfigType
from iti.applications.extensions import db
from iti.applications.models import Role, SysConfig, SysDictData, SysDictType, SysMenu, User
from iti.modules import ModuleMenuSeed
from iti.modules.registry import ModuleRegistry
@dataclass
class SeedCounter:
created: int = 0
updated: int = 0
skipped: int = 0
def as_dict(self) -> dict[str, int]:
return {
"created": self.created,
"updated": self.updated,
"skipped": self.skipped,
}
SYSTEM_MENU_ID = "b3af308711954e62ba7471891b82f721"
USER_MENU_ID = "93e1c7448c144f60875c4725dfa93b5a"
ROLE_MENU_ID = "8ac71de8a14c413997f7f81f5fcf343c"
DEPT_MENU_ID = "5d76b276594349b5bfbed42da656bd53"
MENU_MENU_ID = "b3f689cf6d594f94aeed912bd5c7dd80"
CONFIG_MENU_ID = "42a830108d9c49bca4e0108aba27a3cf"
DICT_MENU_ID = "50d583e8c8584d43ab94939420dce0cb"
LOG_MENU_ID = "774da56753514b4eafc913162970f4f1"
DEFAULT_ROLES = [
{
"name": "管理员",
"code": "ADMIN",
"desc": "系统默认管理员",
"sort": 0,
},
{
"name": "普通角色",
"code": "COMMON",
"desc": "一般角色",
"sort": 100,
},
]
DEFAULT_USERS = [
{
"username": "admin",
"password": "123456",
"realname": "管理员",
"phone": "18888888888",
"email": "a@a.com",
"avatar": "",
"gender": GenderEnum.SECURE.value,
"desc": "系统默认管理员",
"role_codes": ["ADMIN"],
},
]
DEFAULT_MENUS = [
{
"id": SYSTEM_MENU_ID,
"name": "System",
"type": MenuTypeEnum.CATALOG.value,
"path": "/system",
"sort": 0,
"meta": {"title": "系统管理", "icon": "ion:settings-outline"},
},
{
"id": USER_MENU_ID,
"name": "User",
"type": MenuTypeEnum.MENU.value,
"path": "/system/user",
"component": "/system/user/list",
"sort": 1,
"parent_id": SYSTEM_MENU_ID,
"auth_code": "system:user:list",
"meta": {"title": "system.user.title", "icon": "carbon:user"},
},
{
"id": ROLE_MENU_ID,
"name": "Role",
"type": MenuTypeEnum.MENU.value,
"path": "/system/role",
"component": "/system/role/list",
"sort": 2,
"parent_id": SYSTEM_MENU_ID,
"auth_code": "system:role:list",
"meta": {"title": "角色管理", "icon": "carbon:user-role"},
},
{
"id": DEPT_MENU_ID,
"name": "Dept",
"type": MenuTypeEnum.MENU.value,
"path": "/system/dept",
"component": "/system/dept/list",
"sort": 3,
"parent_id": SYSTEM_MENU_ID,
"auth_code": "system:dept:list",
"meta": {"title": "部门管理", "icon": "carbon:container-services", "order": 3},
},
{
"id": MENU_MENU_ID,
"name": "Menu",
"type": MenuTypeEnum.MENU.value,
"path": "/system/menu",
"component": "/system/menu/list",
"sort": 4,
"parent_id": SYSTEM_MENU_ID,
"auth_code": "system:menu:list",
"meta": {"title": "菜单管理", "icon": "carbon:menu", "badge": ""},
},
{
"id": CONFIG_MENU_ID,
"name": "SysConfig",
"type": MenuTypeEnum.MENU.value,
"path": "/system/config",
"component": "/system/config/list",
"sort": 5,
"parent_id": SYSTEM_MENU_ID,
"auth_code": "system:config:list",
"meta": {"title": "系统配置", "icon": "carbon:document-configuration", "order": 5},
},
{
"id": DICT_MENU_ID,
"name": "SysDict",
"type": MenuTypeEnum.MENU.value,
"path": "/system/dict",
"component": "/system/dict/list",
"sort": 6,
"parent_id": SYSTEM_MENU_ID,
"auth_code": "system:dict:list",
"meta": {"title": "字典管理", "icon": "carbon:book"},
},
{
"id": LOG_MENU_ID,
"name": "SysLog",
"type": MenuTypeEnum.MENU.value,
"path": "/system/log",
"component": "/system/log/list",
"sort": 7,
"parent_id": SYSTEM_MENU_ID,
"auth_code": "system:log:list",
"meta": {"title": "日志管理", "icon": "carbon:cloud-logging"},
},
{
"id": "c5d1be767bce409bafa141ec8e5fc419",
"name": "SystemUserCreate",
"type": MenuTypeEnum.BUTTON.value,
"path": "",
"sort": 0,
"parent_id": USER_MENU_ID,
"auth_code": "system:user:create",
"meta": {"title": "common.create", "order": 0},
},
{
"id": "899b280630334766b01ff83f6f4ebacc",
"name": "SystemUserUpdate",
"type": MenuTypeEnum.BUTTON.value,
"path": "",
"sort": 1,
"parent_id": USER_MENU_ID,
"auth_code": "system:user:edit",
"meta": {"title": "common.edit"},
},
{
"id": "2431b03462a84f90ba15c29bca07d39e",
"name": "SystemUserDelete",
"type": MenuTypeEnum.BUTTON.value,
"path": "",
"sort": 2,
"parent_id": USER_MENU_ID,
"auth_code": "system:user:delete",
"meta": {"title": "common.delete"},
},
{
"id": "12cf74c410d044d986840c93fb70c397",
"name": "SystemUserResetPassword",
"type": MenuTypeEnum.BUTTON.value,
"path": "",
"sort": 3,
"parent_id": USER_MENU_ID,
"auth_code": "system:user:resetpwd",
"meta": {"title": "修改密码", "order": 3},
},
{
"id": "9f71686949c7410ea032956c52252a06",
"name": "SystemRoleCreate",
"type": MenuTypeEnum.BUTTON.value,
"path": "",
"sort": 0,
"parent_id": ROLE_MENU_ID,
"auth_code": "system:role:create",
"meta": {"title": "common.create", "order": 0},
},
{
"id": "bb271c537c604ee894a0339ced1b4d46",
"name": "SystemRoleEdit",
"type": MenuTypeEnum.BUTTON.value,
"path": "",
"sort": 1,
"parent_id": ROLE_MENU_ID,
"auth_code": "system:role:edit",
"meta": {"title": "common.edit", "order": 1},
},
{
"id": "a643f56b9a844c1eb8af7da1bd8e96da",
"name": "SystemRoleDelete",
"type": MenuTypeEnum.BUTTON.value,
"path": "",
"sort": 2,
"parent_id": ROLE_MENU_ID,
"auth_code": "system:role:delete",
"meta": {"title": "common.delete", "order": 2},
},
{
"id": "7f2913a04e1b47c8bc590eee4708a147",
"name": "SystemDeptCreate",
"type": MenuTypeEnum.BUTTON.value,
"path": "",
"sort": 0,
"parent_id": DEPT_MENU_ID,
"auth_code": "system:dept:create",
"meta": {"title": "common.create", "order": 0},
},
{
"id": "3e6e8ebda98c4e1aad27478d7bac595a",
"name": "SystemDeptEdit",
"type": MenuTypeEnum.BUTTON.value,
"path": "",
"sort": 1,
"parent_id": DEPT_MENU_ID,
"auth_code": "system:dept:edit",
"meta": {"title": "common.edit", "order": 1},
},
{
"id": "b52bb4045a434253ab0af21d03603458",
"name": "SystemDeptDelete",
"type": MenuTypeEnum.BUTTON.value,
"path": "",
"sort": 2,
"parent_id": DEPT_MENU_ID,
"auth_code": "system:dept:delete",
"meta": {"title": "common.delete", "order": 2},
},
{
"id": "18a8a2fcc8704c94baf47590dd7eb2e8",
"name": "SystemMenuCreate",
"type": MenuTypeEnum.BUTTON.value,
"path": None,
"sort": 0,
"parent_id": MENU_MENU_ID,
"auth_code": "system:menu:create",
"meta": {"title": "common.create"},
},
{
"id": "b41da6285e3f4bc4a39aa8ae13944b41",
"name": "SystemMenuEdit",
"type": MenuTypeEnum.BUTTON.value,
"path": "",
"sort": 1,
"parent_id": MENU_MENU_ID,
"auth_code": "system:menu:edit",
"meta": {"title": "common.edit", "order": 1},
},
{
"id": "a322ddada8ed4bcca1f5edcd9f3d33d0",
"name": "SystemMenuDelete",
"type": MenuTypeEnum.BUTTON.value,
"path": "",
"sort": 2,
"parent_id": MENU_MENU_ID,
"auth_code": "system:menu:delete",
"meta": {"title": "common.delete"},
},
{
"id": "57daf457de6546ab9e887233892e712e",
"name": "SystemConfigCreate",
"type": MenuTypeEnum.BUTTON.value,
"path": "",
"sort": 0,
"parent_id": CONFIG_MENU_ID,
"auth_code": "system:config:create",
"meta": {"title": "common.create", "order": 0},
},
{
"id": "5dc4a3d79f20496d82bc85f6eed1389b",
"name": "SystemConfigEdit",
"type": MenuTypeEnum.BUTTON.value,
"path": "",
"sort": 1,
"parent_id": CONFIG_MENU_ID,
"auth_code": "system:config:edit",
"meta": {"title": "common.edit", "order": 1},
},
{
"id": "bafe03a1da6c4224b69ecadd721cba0a",
"name": "SystemConfigDelete",
"type": MenuTypeEnum.BUTTON.value,
"path": "",
"sort": 2,
"parent_id": CONFIG_MENU_ID,
"auth_code": "system:config:delete",
"meta": {"title": "common.delete", "order": 2},
},
{
"id": "8c96ce0db0724139a9ab7fbcfcf52500",
"name": "SystemDictCreate",
"type": MenuTypeEnum.BUTTON.value,
"path": "",
"sort": 0,
"parent_id": DICT_MENU_ID,
"auth_code": "system:dict:create",
"meta": {"title": "common.create", "order": 0},
},
{
"id": "de5bcbdb81b34b02991614c13f12043a",
"name": "SystemDictEdit",
"type": MenuTypeEnum.BUTTON.value,
"path": "",
"sort": 1,
"parent_id": DICT_MENU_ID,
"auth_code": "system:dict:edit",
"meta": {"title": "common.edit", "order": 1},
},
{
"id": "3385698f84e44249ad5eb733d7232c96",
"name": "SystemDictDelete",
"type": MenuTypeEnum.BUTTON.value,
"path": "",
"sort": 2,
"parent_id": DICT_MENU_ID,
"auth_code": "system:dict:delete",
"meta": {"title": "common.delete", "order": 2},
},
{
"id": "74e4cff0de2b4532a187ed01714d6577",
"name": "SystemLogDelete",
"type": MenuTypeEnum.BUTTON.value,
"path": "",
"sort": 0,
"parent_id": LOG_MENU_ID,
"auth_code": "system:log:delete",
"meta": {"title": "common.delete", "order": 0},
},
]
DEFAULT_DICT_TYPES = [
{
"type_name": "状态",
"type_code": "status",
"desc": "通用启停状态",
"sort": 1,
"data": [
{"label": "启用", "code": "enabled", "value": "enabled", "sort": 1},
{"label": "停用", "code": "disabled", "value": "disabled", "sort": 2},
],
},
{
"type_name": "性别",
"type_code": "gender",
"desc": "用户性别",
"sort": 2,
"data": [
{"label": "", "code": "male", "value": "male", "sort": 1},
{"label": "", "code": "female", "value": "female", "sort": 2},
{"label": "保密", "code": "secure", "value": "secure", "sort": 3},
],
},
]
DEFAULT_CONFIGS = [
{
"type": SysConfigType.USER.value,
"name": "默认用户密码",
"code": "DEFAULT_USER_PASSWORD",
"value": "123456",
"desc": "系统自动注册时使用的默认用户密码",
"sort": 1,
},
{
"type": SysConfigType.USER.value,
"name": "默认用户角色",
"code": "DEFAULT_USER_ROLES",
"value": "COMMON",
"desc": "系统自动注册时使用的默认用户角色,多个角色用逗号分隔",
"sort": 2,
},
{
"type": SysConfigType.USER.value,
"name": "默认用户部门",
"code": "DEFAULT_USER_DEPTS",
"value": "",
"desc": "系统自动注册时使用的默认用户部门,多个部门用逗号分隔",
"sort": 3,
},
{
"type": SysConfigType.SYSTEM.value,
"name": "系统名称",
"code": "system.name",
"value": "iTi-Flask",
"desc": "默认系统名称",
"sort": 10,
},
{
"type": SysConfigType.SYSTEM.value,
"name": "后端访问地址",
"code": "BACKEND_URL",
"value": "http://localhost:5000",
"desc": "后端访问地址。应配置为前端可访问的后端地址",
"sort": 90,
},
{
"type": SysConfigType.SYSTEM.value,
"name": "文件回收站功能",
"code": "FILE_RECYCLE_ENABLED",
"value": "true",
"desc": "是否启用文件回收站功能",
"sort": 100,
},
{
"type": SysConfigType.SYSTEM.value,
"name": "回收站保留天数",
"code": "FILE_RECYCLE_DAYS",
"value": "30",
"desc": "回收站文件保留天数",
"sort": 101,
},
{
"type": SysConfigType.SYSTEM.value,
"name": "文件分享功能",
"code": "FILE_SHARE_ENABLED",
"value": "true",
"desc": "是否启用文件分享功能",
"sort": 102,
},
{
"type": SysConfigType.SYSTEM.value,
"name": "分享默认过期时间",
"code": "FILE_SHARE_DEFAULT_EXPIRE_HOURS",
"value": "168",
"desc": "文件分享默认过期时间(小时)",
"sort": 103,
},
{
"type": SysConfigType.SYSTEM.value,
"name": "分片上传阈值",
"code": "FILE_CHUNK_THRESHOLD",
"value": "104857600",
"desc": "文件大小超过此阈值时使用分片上传",
"sort": 104,
},
{
"type": SysConfigType.SYSTEM.value,
"name": "分片上传分片大小",
"code": "FILE_CHUNK_SIZE",
"value": "2097152",
"desc": "分片上传时每个分片的大小(字节)",
"sort": 105,
},
]
ROLE_MENU_BINDINGS = {
"ADMIN": [item["id"] for item in DEFAULT_MENUS],
"COMMON": [
SYSTEM_MENU_ID,
USER_MENU_ID,
ROLE_MENU_ID,
DEPT_MENU_ID,
MENU_MENU_ID,
CONFIG_MENU_ID,
DICT_MENU_ID,
],
}
def seed_system_data(
module_registry: ModuleRegistry | None = None,
) -> dict[str, dict[str, int]]:
counters = {
"roles": _seed_roles(),
"menus": _seed_menus(),
"module_menus": _seed_module_menus(module_registry),
"dicts": _seed_dicts(),
"configs": _seed_configs(),
"users": _seed_users(),
"user_roles": _seed_user_roles(),
"role_menus": _seed_role_menus(),
"module_role_menus": _seed_module_role_menus(module_registry),
}
db.session.commit()
return {name: counter.as_dict() for name, counter in counters.items()}
def _seed_roles() -> SeedCounter:
counter = SeedCounter()
for item in DEFAULT_ROLES:
role = db.session.scalar(select(Role).filter_by(code=item["code"]))
payload = dict(item)
payload.setdefault("status", StatusEnum.ENABLED.value)
if role is None:
db.session.add(Role(**_audit_safe(payload)))
counter.created += 1
continue
changed = _assign_if_changed(role, payload)
counter.updated += 1 if changed else 0
counter.skipped += 0 if changed else 1
return counter
def _seed_menus() -> SeedCounter:
counter = SeedCounter()
for item in DEFAULT_MENUS:
_seed_menu_payload(counter, item)
return counter
def _seed_module_menus(module_registry: ModuleRegistry | None) -> SeedCounter:
counter = SeedCounter()
for menu_seed in _module_menu_seeds(module_registry):
_seed_menu_payload(counter, menu_seed.as_menu_payload())
return counter
def _seed_menu_payload(counter: SeedCounter, item: dict[str, Any]) -> None:
menu = db.session.scalar(select(SysMenu).filter_by(id=item["id"]))
payload = dict(item)
payload.setdefault("status", StatusEnum.ENABLED.value)
if menu is None:
db.session.add(SysMenu(**payload))
counter.created += 1
return
changed = _assign_if_changed(menu, payload)
counter.updated += 1 if changed else 0
counter.skipped += 0 if changed else 1
def _seed_dicts() -> SeedCounter:
counter = SeedCounter()
for item in DEFAULT_DICT_TYPES:
data_items = item["data"]
if not isinstance(data_items, Iterable):
data_items = []
dict_type = db.session.scalar(
select(SysDictType).filter_by(type_code=item["type_code"])
)
payload = {key: value for key, value in item.items() if key != "data"}
payload.setdefault("status", StatusEnum.ENABLED.value)
if dict_type is None:
db.session.add(SysDictType(**_audit_safe(payload)))
counter.created += 1
else:
changed = _assign_if_changed(dict_type, payload)
counter.updated += 1 if changed else 0
counter.skipped += 0 if changed else 1
for data in data_items:
dict_data = db.session.scalar(
select(SysDictData).filter_by(
type_code=item["type_code"], code=data["code"]
)
)
data_payload = dict(data)
data_payload["type_code"] = item["type_code"]
data_payload.setdefault("status", StatusEnum.ENABLED.value)
if dict_data is None:
db.session.add(SysDictData(**_audit_safe(data_payload)))
counter.created += 1
else:
changed = _assign_if_changed(dict_data, data_payload)
counter.updated += 1 if changed else 0
counter.skipped += 0 if changed else 1
return counter
def _seed_configs() -> SeedCounter:
counter = SeedCounter()
for item in DEFAULT_CONFIGS:
config = db.session.scalar(
select(SysConfig).filter_by(type=item["type"], code=item["code"])
)
payload = dict(item)
payload.setdefault("status", StatusEnum.ENABLED.value)
if config is None:
db.session.add(SysConfig(**_audit_safe(payload)))
counter.created += 1
continue
changed = _assign_if_changed(config, payload)
counter.updated += 1 if changed else 0
counter.skipped += 0 if changed else 1
return counter
def _seed_users() -> SeedCounter:
counter = SeedCounter()
for item in DEFAULT_USERS:
user = db.session.scalar(select(User).filter_by(username=item["username"]))
payload = {key: value for key, value in item.items() if key != "role_codes"}
if user is None:
user = User(status=StatusEnum.ENABLED.value, **_audit_safe(payload))
db.session.add(user)
counter.created += 1
continue
counter.skipped += 1
return counter
def _seed_user_roles() -> SeedCounter:
counter = SeedCounter()
for item in DEFAULT_USERS:
user = db.session.scalar(select(User).filter_by(username=item["username"]))
if user is None:
counter.skipped += len(item["role_codes"])
continue
existing_codes = {role.code for role in user.roles}
for role_code in item["role_codes"]:
if role_code in existing_codes:
counter.skipped += 1
continue
role = db.session.scalar(select(Role).filter_by(code=role_code))
if role is None:
counter.skipped += 1
continue
user.roles.append(role)
counter.created += 1
return counter
def _seed_role_menus() -> SeedCounter:
counter = SeedCounter()
for role_code, menu_ids in ROLE_MENU_BINDINGS.items():
role = db.session.scalar(select(Role).filter_by(code=role_code))
if role is None:
counter.skipped += len(menu_ids)
continue
existing_ids = {menu.id for menu in role.menus}
for menu_id in menu_ids:
if menu_id in existing_ids:
counter.skipped += 1
continue
menu = db.session.scalar(select(SysMenu).filter_by(id=menu_id))
if menu is None:
counter.skipped += 1
continue
role.menus.append(menu)
counter.created += 1
return counter
def _seed_module_role_menus(module_registry: ModuleRegistry | None) -> SeedCounter:
counter = SeedCounter()
for menu_seed in _module_menu_seeds(module_registry):
for role_code in menu_seed.admin_roles:
role = db.session.scalar(select(Role).filter_by(code=role_code))
menu = db.session.scalar(select(SysMenu).filter_by(id=menu_seed.id))
if role is None or menu is None:
counter.skipped += 1
continue
if menu.id in {item.id for item in role.menus}:
counter.skipped += 1
continue
role.menus.append(menu)
counter.created += 1
return counter
def _module_menu_seeds(
module_registry: ModuleRegistry | None,
) -> list[ModuleMenuSeed]:
if module_registry is None:
return []
return module_registry.list_menu_seeds()
def _assign_if_changed(model: Any, values: dict) -> bool:
changed = False
for key, value in values.items():
if getattr(model, key) != value:
setattr(model, key, value)
changed = True
return changed
def _audit_safe(values: dict) -> dict:
payload = dict(values)
payload.setdefault("created_by", None)
payload.setdefault("updated_by", None)
return payload

@ -0,0 +1,22 @@
from .client import ServiceClient
from .config import RetryConfig, ServiceConfig, TimeoutConfig
from .errors import (
ServiceClientError,
ServiceConfigError,
ServiceHTTPError,
ServiceUnavailableError,
)
from .registry import init_service_clients, service_client
__all__ = [
"RetryConfig",
"ServiceClient",
"ServiceClientError",
"ServiceConfig",
"ServiceConfigError",
"ServiceHTTPError",
"ServiceUnavailableError",
"TimeoutConfig",
"init_service_clients",
"service_client",
]

@ -0,0 +1,203 @@
from __future__ import annotations
import time
import uuid
from typing import Any
import httpx
from flask import current_app, g, has_app_context, has_request_context, request
from .config import ServiceConfig
from .errors import ServiceHTTPError, ServiceUnavailableError
class ServiceClient:
"""Synchronous HTTP JSON service client."""
def __init__(
self,
config: ServiceConfig,
*,
transport: httpx.BaseTransport | None = None,
) -> None:
self.config = config
timeout = httpx.Timeout(
connect=config.timeout.connect,
read=config.timeout.read,
write=config.timeout.write,
pool=config.timeout.pool,
)
self._client = httpx.Client(
base_url=config.base_url,
timeout=timeout,
transport=transport,
)
self._fail_count = 0
self._opened_at: float | None = None
def get(self, endpoint: str, **kwargs: Any) -> Any:
return self.request("GET", endpoint, **kwargs)
def post(self, endpoint: str, **kwargs: Any) -> Any:
return self.request("POST", endpoint, **kwargs)
def put(self, endpoint: str, **kwargs: Any) -> Any:
return self.request("PUT", endpoint, **kwargs)
def delete(self, endpoint: str, **kwargs: Any) -> Any:
return self.request("DELETE", endpoint, **kwargs)
def request(
self,
method: str,
endpoint: str,
*,
path: dict[str, Any] | None = None,
path_params: dict[str, Any] | None = None,
path_values: dict[str, Any] | None = None,
path_: dict[str, Any] | None = None,
path_args: dict[str, Any] | None = None,
path_map: dict[str, Any] | None = None,
params: dict[str, Any] | None = None,
json: Any = None,
headers: dict[str, str] | None = None,
retry: bool | None = None,
expect_json: bool = True,
) -> Any:
method = method.upper()
values = path or path_params or path_values or path_ or path_args or path_map or {}
url = endpoint.format(**values)
self._raise_if_open()
attempts = self._attempts_for(method, retry)
last_error: Exception | None = None
for attempt in range(1, attempts + 1):
start = time.monotonic()
try:
response = self._client.request(
method,
url,
params=params,
json=json,
headers=self._headers(headers),
)
elapsed_ms = int((time.monotonic() - start) * 1000)
self._log_call(method, url, response.status_code, elapsed_ms, attempt)
if response.status_code >= 400:
if self._should_retry_status(method, response.status_code, attempt, attempts):
time.sleep(self.config.retry.backoff * attempt)
continue
self._record_failure()
raise ServiceHTTPError(
self.config.name, response.status_code, response.text
)
self._record_success()
if not expect_json:
return response
if not response.content:
return None
return response.json()
except (httpx.TimeoutException, httpx.TransportError) as exc:
last_error = exc
elapsed_ms = int((time.monotonic() - start) * 1000)
self._log_call(method, url, "transport_error", elapsed_ms, attempt)
if attempt < attempts:
time.sleep(self.config.retry.backoff * attempt)
continue
self._record_failure()
raise ServiceUnavailableError(
f"service {self.config.name} unavailable: {exc}"
) from exc
raise ServiceUnavailableError(
f"service {self.config.name} unavailable: {last_error}"
)
def close(self) -> None:
self._client.close()
def _headers(self, headers: dict[str, str] | None) -> dict[str, str]:
result = dict(headers or {})
result.setdefault("Accept", "application/json")
result.setdefault("Content-Type", "application/json")
if self.config.token:
result.setdefault("Authorization", f"Bearer {self.config.token}")
trace_id = self._trace_id()
result.setdefault("X-Trace-Id", trace_id)
return result
def _trace_id(self) -> str:
if has_request_context():
header_trace = request.headers.get("X-Trace-Id")
if header_trace:
return header_trace
if has_app_context():
trace_id = getattr(g, "trace_id", None)
if trace_id:
return trace_id
g.trace_id = uuid.uuid4().hex
return g.trace_id
return uuid.uuid4().hex
def _attempts_for(self, method: str, retry: bool | None) -> int:
if retry is False:
return 1
if retry is True:
return self.config.retry.attempts
if method in self.config.retry.methods:
return self.config.retry.attempts
return 1
def _should_retry_status(
self, method: str, status_code: int, attempt: int, attempts: int
) -> bool:
return (
attempt < attempts
and method in self.config.retry.methods
and status_code in self.config.retry.statuses
)
def _raise_if_open(self) -> None:
breaker = self.config.circuit_breaker
if not breaker.enabled or self._opened_at is None:
return
elapsed = time.monotonic() - self._opened_at
if elapsed < breaker.reset_timeout:
raise ServiceUnavailableError(
f"service {self.config.name} circuit breaker is open"
)
self._opened_at = None
self._fail_count = 0
def _record_success(self) -> None:
self._fail_count = 0
self._opened_at = None
def _record_failure(self) -> None:
breaker = self.config.circuit_breaker
if not breaker.enabled:
return
self._fail_count += 1
if self._fail_count >= breaker.fail_max:
self._opened_at = time.monotonic()
def _log_call(
self,
method: str,
url: str,
status: int | str,
elapsed_ms: int,
attempt: int,
) -> None:
if not has_app_context():
return
current_app.logger.info(
"service_call service=%s method=%s path=%s status=%s elapsed_ms=%s attempt=%s",
self.config.name,
method,
url,
status,
elapsed_ms,
attempt,
)

@ -0,0 +1,79 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass(frozen=True)
class TimeoutConfig:
connect: float = 1.0
read: float = 5.0
write: float = 5.0
pool: float = 1.0
@classmethod
def from_mapping(cls, value: dict[str, Any] | None) -> "TimeoutConfig":
value = value or {}
return cls(
connect=float(value.get("connect", cls.connect)),
read=float(value.get("read", cls.read)),
write=float(value.get("write", cls.write)),
pool=float(value.get("pool", cls.pool)),
)
@dataclass(frozen=True)
class RetryConfig:
attempts: int = 1
backoff: float = 0.2
statuses: tuple[int, ...] = field(default_factory=lambda: (502, 503, 504))
methods: tuple[str, ...] = field(default_factory=lambda: ("GET", "HEAD", "OPTIONS"))
@classmethod
def from_mapping(cls, value: dict[str, Any] | None) -> "RetryConfig":
value = value or {}
return cls(
attempts=max(int(value.get("attempts", cls.attempts)), 1),
backoff=float(value.get("backoff", cls.backoff)),
statuses=tuple(int(item) for item in value.get("statuses", cls().statuses)),
methods=tuple(str(item).upper() for item in value.get("methods", cls().methods)),
)
@dataclass(frozen=True)
class CircuitBreakerConfig:
enabled: bool = False
fail_max: int = 5
reset_timeout: int = 30
@classmethod
def from_mapping(cls, value: dict[str, Any] | None) -> "CircuitBreakerConfig":
value = value or {}
return cls(
enabled=bool(value.get("enabled", cls.enabled)),
fail_max=max(int(value.get("fail_max", cls.fail_max)), 1),
reset_timeout=max(int(value.get("reset_timeout", cls.reset_timeout)), 1),
)
@dataclass(frozen=True)
class ServiceConfig:
name: str
base_url: str
token: str | None = None
timeout: TimeoutConfig = field(default_factory=TimeoutConfig)
retry: RetryConfig = field(default_factory=RetryConfig)
circuit_breaker: CircuitBreakerConfig = field(default_factory=CircuitBreakerConfig)
@classmethod
def from_mapping(cls, name: str, value: dict[str, Any]) -> "ServiceConfig":
return cls(
name=name,
base_url=str(value["base_url"]).rstrip("/"),
token=value.get("token"),
timeout=TimeoutConfig.from_mapping(value.get("timeout")),
retry=RetryConfig.from_mapping(value.get("retry")),
circuit_breaker=CircuitBreakerConfig.from_mapping(
value.get("circuit_breaker")
),
)

@ -0,0 +1,23 @@
from __future__ import annotations
class ServiceClientError(RuntimeError):
"""Base service-client error."""
class ServiceConfigError(ServiceClientError):
"""Raised when service client configuration is missing or invalid."""
class ServiceUnavailableError(ServiceClientError):
"""Raised when a service cannot be reached or is temporarily blocked."""
class ServiceHTTPError(ServiceClientError):
"""Raised for non-2xx HTTP responses."""
def __init__(self, service: str, status_code: int, body: str) -> None:
self.service = service
self.status_code = status_code
self.body = body
super().__init__(f"service {service} returned HTTP {status_code}")

@ -0,0 +1,43 @@
from __future__ import annotations
from typing import Any
import httpx
from flask import current_app, has_app_context
from .client import ServiceClient
from .config import ServiceConfig
from .errors import ServiceConfigError
def init_service_clients(app) -> None:
configs = app.config.get("SERVICES", {})
clients: dict[str, ServiceClient] = {}
for name, value in configs.items():
if "base_url" not in value:
raise ServiceConfigError(f"service {name} missing base_url")
clients[name] = ServiceClient(ServiceConfig.from_mapping(name, value))
app.extensions["iti_service_clients"] = clients
def register_service_client(
app,
name: str,
config: dict[str, Any],
*,
transport: httpx.BaseTransport | None = None,
) -> ServiceClient:
client = ServiceClient(ServiceConfig.from_mapping(name, config), transport=transport)
clients = app.extensions.setdefault("iti_service_clients", {})
clients[name] = client
return client
def service_client(name: str) -> ServiceClient:
if not has_app_context():
raise ServiceConfigError("service_client() requires an app context")
clients = current_app.extensions.get("iti_service_clients", {})
client = clients.get(name)
if client is None:
raise ServiceConfigError(f"service client not configured: {name}")
return client

@ -0,0 +1,10 @@
from .registry import TaskDefinition, TaskRegistry, TaskRun, task_registry
from .runner import init_task_runner
__all__ = [
"TaskDefinition",
"TaskRegistry",
"TaskRun",
"init_task_runner",
"task_registry",
]

@ -0,0 +1,104 @@
from __future__ import annotations
import threading
import time
import traceback
import uuid
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
@dataclass(frozen=True)
class TaskDefinition:
name: str
handler: Callable[[], Any]
schedule: str | None = None
description: str | None = None
@dataclass
class TaskRun:
id: str
task_name: str
status: str
started_at: float
finished_at: float | None = None
result: Any = None
error: str | None = None
@dataclass
class TaskRegistry:
tasks: dict[str, TaskDefinition] = field(default_factory=dict)
runs: dict[str, TaskRun] = field(default_factory=dict)
_running: set[str] = field(default_factory=set)
_lock: threading.Lock = field(default_factory=threading.Lock)
def register(
self,
*,
name: str,
handler: Callable[[], Any],
schedule: str | None = None,
description: str | None = None,
) -> TaskDefinition:
if not name:
raise ValueError("task name is required")
if name in self.tasks:
raise ValueError(f"task already registered: {name}")
task = TaskDefinition(
name=name,
handler=handler,
schedule=schedule,
description=description,
)
self.tasks[name] = task
return task
def trigger(self, name: str) -> TaskRun:
task = self.tasks.get(name)
if task is None:
raise KeyError(f"task not registered: {name}")
with self._lock:
if name in self._running:
run = TaskRun(
id=uuid.uuid4().hex,
task_name=name,
status="skipped",
started_at=time.time(),
finished_at=time.time(),
error="task already running",
)
self.runs[run.id] = run
return run
self._running.add(name)
run = TaskRun(
id=uuid.uuid4().hex,
task_name=name,
status="running",
started_at=time.time(),
)
self.runs[run.id] = run
try:
run.result = task.handler()
run.status = "success"
except Exception:
run.error = traceback.format_exc()
run.status = "failed"
finally:
run.finished_at = time.time()
with self._lock:
self._running.discard(name)
return run
def list_runs(self, task_name: str | None = None) -> list[TaskRun]:
runs = list(self.runs.values())
if task_name is not None:
runs = [run for run in runs if run.task_name == task_name]
return sorted(runs, key=lambda run: run.started_at, reverse=True)
task_registry = TaskRegistry()

@ -0,0 +1,89 @@
from __future__ import annotations
import re
import threading
import time
import atexit
from .registry import TaskRegistry, task_registry
class TaskRunner:
"""Single-process task scheduler."""
def __init__(self, registry: TaskRegistry, *, tick_seconds: int = 1) -> None:
self.registry = registry
self.tick_seconds = tick_seconds
self._last_run: dict[str, float] = {}
self._stop = threading.Event()
self._thread: threading.Thread | None = None
def start(self) -> None:
if self._thread and self._thread.is_alive():
return
self._thread = threading.Thread(target=self._loop, daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop.set()
if self._thread:
self._thread.join(timeout=3)
def _loop(self) -> None:
while not self._stop.is_set():
now = time.time()
for task in list(self.registry.tasks.values()):
if self._due(task.schedule, task.name, now):
self._last_run[task.name] = now
threading.Thread(
target=self.registry.trigger,
args=(task.name,),
daemon=True,
).start()
self._stop.wait(self.tick_seconds)
def _due(self, schedule: str | None, name: str, now: float) -> bool:
if not schedule:
return False
interval = _parse_interval(schedule)
if interval is None:
interval = _parse_simple_cron(schedule)
if interval is None:
return False
last = self._last_run.get(name)
return last is None or now - last >= interval
def _parse_interval(schedule: str) -> int | None:
match = re.fullmatch(r"interval:(\d+)", schedule.strip())
if not match:
return None
return max(int(match.group(1)), 1)
def _parse_simple_cron(schedule: str) -> int | None:
value = schedule.strip()
if value.startswith("cron:"):
value = value[5:].strip()
parts = value.split()
if len(parts) != 5:
return None
minute = parts[0]
if minute == "*":
return 60
match = re.fullmatch(r"\*/(\d+)", minute)
if match:
return max(int(match.group(1)), 1) * 60
return None
def init_task_runner(app, registry: TaskRegistry | None = None) -> TaskRunner:
runner = TaskRunner(registry or task_registry)
app.extensions["iti_task_registry"] = registry or task_registry
app.extensions["iti_task_runner"] = runner
if app.config.get("TASKS_ENABLED", False):
app.logger.info("starting single-process task runner")
runner.start()
atexit.register(runner.stop)
return runner

@ -2,7 +2,7 @@
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate

@ -0,0 +1,359 @@
"""framework initial schema
Revision ID: 7de264f96a03
Revises:
Create Date: 2026-05-08 04:33:59.055459
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '7de264f96a03'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('sys_config',
sa.Column('type', sa.String(length=64), nullable=False, comment='配置类型'),
sa.Column('name', sa.String(length=255), nullable=False, comment='配置名称'),
sa.Column('code', sa.String(length=128), nullable=False, comment='配置编码'),
sa.Column('value', sa.Text(), nullable=True, comment='配置值'),
sa.Column('desc', sa.Text(), nullable=True, comment='配置描述'),
sa.Column('sort', sa.Integer(), nullable=False, comment='排序'),
sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'),
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'),
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'),
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_config')),
mysql_charset='utf8mb4',
mysql_collate='utf8mb4_general_ci'
)
with op.batch_alter_table('sys_config', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_sys_config_created_by'), ['created_by'], unique=False)
batch_op.create_index(batch_op.f('ix_sys_config_updated_by'), ['updated_by'], unique=False)
op.create_table('sys_dept',
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
sa.Column('name', sa.String(length=255), nullable=False, comment='部门名称'),
sa.Column('parent_id', sa.String(length=36), nullable=True, comment='父部门ID'),
sa.Column('desc', sa.Text(), nullable=True, comment='部门描述'),
sa.Column('sort', sa.Integer(), nullable=False, comment='排序'),
sa.Column('leader_id', sa.String(length=36), nullable=True, comment='负责人ID'),
sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'),
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_dept')),
mysql_charset='utf8mb4',
mysql_collate='utf8mb4_general_ci'
)
op.create_table('sys_dict_data',
sa.Column('type_code', sa.String(length=36), nullable=False, comment='类型编码'),
sa.Column('label', sa.String(length=255), nullable=False, comment='数据标签'),
sa.Column('code', sa.String(length=128), nullable=False, comment='数据编码'),
sa.Column('value', sa.Text(), nullable=True, comment='数据值'),
sa.Column('desc', sa.Text(), nullable=True, comment='数据描述'),
sa.Column('sort', sa.Integer(), nullable=False, comment='排序'),
sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'),
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'),
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'),
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_dict_data')),
mysql_charset='utf8mb4',
mysql_collate='utf8mb4_general_ci'
)
with op.batch_alter_table('sys_dict_data', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_sys_dict_data_created_by'), ['created_by'], unique=False)
batch_op.create_index(batch_op.f('ix_sys_dict_data_updated_by'), ['updated_by'], unique=False)
op.create_table('sys_dict_type',
sa.Column('type_name', sa.String(length=255), nullable=False, comment='类型名称'),
sa.Column('type_code', sa.String(length=128), nullable=False, comment='类型编码'),
sa.Column('desc', sa.Text(), nullable=True, comment='类型描述'),
sa.Column('sort', sa.Integer(), nullable=False, comment='排序'),
sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'),
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'),
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'),
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_dict_type')),
sa.UniqueConstraint('type_code', name=op.f('uq_sys_dict_type_type_code')),
mysql_charset='utf8mb4',
mysql_collate='utf8mb4_general_ci'
)
with op.batch_alter_table('sys_dict_type', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_sys_dict_type_created_by'), ['created_by'], unique=False)
batch_op.create_index(batch_op.f('ix_sys_dict_type_updated_by'), ['updated_by'], unique=False)
op.create_table('sys_file',
sa.Column('filename', sa.String(length=255), nullable=False, comment='原始文件名'),
sa.Column('file_key', sa.String(length=512), nullable=False, comment='存储路径'),
sa.Column('file_hash', sa.String(length=64), nullable=True, comment='文件哈希'),
sa.Column('mime_type', sa.String(length=128), nullable=True, comment='MIME类型'),
sa.Column('file_size', sa.BigInteger(), nullable=False, comment='文件大小(字节)'),
sa.Column('extension', sa.String(length=32), nullable=True, comment='文件扩展名'),
sa.Column('storage_type', sa.Enum('local', 'aliyun_oss', 'tencent_cos', 'qiniu_kodo', 'huawei_obs', 'aws_s3', 'minio', name='storagetypeenum'), nullable=False, comment='存储类型'),
sa.Column('storage_info', sa.JSON(), nullable=True, comment='存储信息(bucket/region/endpoint/meta等)'),
sa.Column('directory_id', sa.String(length=36), nullable=True, comment='所属目录ID'),
sa.Column('metadata', sa.JSON(), nullable=True, comment='扩展元数据'),
sa.Column('is_deleted', sa.Boolean(), nullable=False, comment='是否已删除(回收站)'),
sa.Column('deleted_at', sa.DateTime(), nullable=True, comment='删除时间'),
sa.Column('deleted_by', sa.String(length=36), nullable=True, comment='删除人ID'),
sa.Column('share_code', sa.String(length=64), nullable=True, comment='分享码'),
sa.Column('share_password', sa.String(length=64), nullable=True, comment='分享密码'),
sa.Column('share_expire_at', sa.DateTime(), nullable=True, comment='分享过期时间'),
sa.Column('share_count', sa.Integer(), nullable=False, comment='分享访问次数'),
sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'),
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'),
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'),
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_file')),
mysql_charset='utf8mb4',
mysql_collate='utf8mb4_general_ci'
)
with op.batch_alter_table('sys_file', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_sys_file_created_by'), ['created_by'], unique=False)
batch_op.create_index(batch_op.f('ix_sys_file_directory_id'), ['directory_id'], unique=False)
batch_op.create_index(batch_op.f('ix_sys_file_file_hash'), ['file_hash'], unique=False)
batch_op.create_index(batch_op.f('ix_sys_file_file_key'), ['file_key'], unique=True)
batch_op.create_index(batch_op.f('ix_sys_file_share_code'), ['share_code'], unique=True)
batch_op.create_index(batch_op.f('ix_sys_file_updated_by'), ['updated_by'], unique=False)
op.create_table('sys_file_directory',
sa.Column('name', sa.String(length=255), nullable=False, comment='目录名称'),
sa.Column('path', sa.String(length=1024), nullable=False, comment='完整路径'),
sa.Column('parent_id', sa.String(length=36), nullable=True, comment='父目录ID'),
sa.Column('level', sa.Integer(), nullable=True, comment='层级'),
sa.Column('sort', sa.Integer(), nullable=True, comment='排序'),
sa.Column('icon', sa.String(length=128), nullable=True, comment='目录图标'),
sa.Column('color', sa.String(length=32), nullable=True, comment='颜色标记'),
sa.Column('description', sa.Text(), nullable=True, comment='目录描述'),
sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'),
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'),
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'),
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_file_directory'))
)
with op.batch_alter_table('sys_file_directory', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_sys_file_directory_created_by'), ['created_by'], unique=False)
batch_op.create_index('ix_sys_file_directory_path', ['path'], unique=False, mysql_length=255)
batch_op.create_index(batch_op.f('ix_sys_file_directory_updated_by'), ['updated_by'], unique=False)
op.create_table('sys_log',
sa.Column('name', sa.String(length=100), nullable=True, comment='操作名称'),
sa.Column('method', sa.String(length=10), nullable=True, comment='请求方法'),
sa.Column('user_id', sa.String(length=36), nullable=True, comment='用户ID'),
sa.Column('path', sa.String(length=255), nullable=True, comment='请求路径'),
sa.Column('ip', sa.String(length=255), nullable=True, comment='IP地址'),
sa.Column('user_agent', sa.Text(), nullable=True, comment='用户代理'),
sa.Column('headers', sa.Text(), nullable=True, comment='请求头'),
sa.Column('query_params', sa.Text(), nullable=True, comment='请求参数'),
sa.Column('body_params', sa.Text(), nullable=True, comment='请求体参数'),
sa.Column('execution_time', sa.Float(), nullable=True, comment='执行时间(毫秒)'),
sa.Column('response', sa.Text(), nullable=True, comment='响应结果'),
sa.Column('exception', sa.Text(), nullable=True, comment='异常信息'),
sa.Column('success', sa.Boolean(), nullable=True, comment='是否成功'),
sa.Column('desc', sa.Text(), nullable=True, comment='描述'),
sa.Column('type', sa.Enum('SYSTEM', 'AUTH', 'OPERATION', 'AUDIT', 'SECURITY', 'JOB', 'API', 'DB', 'PAYMENT', 'MESSAGE', 'OSS', 'OTHER', name='logtype'), nullable=False, comment='日志类型'),
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'),
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'),
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_log')),
mysql_charset='utf8mb4',
mysql_collate='utf8mb4_general_ci'
)
with op.batch_alter_table('sys_log', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_sys_log_created_by'), ['created_by'], unique=False)
batch_op.create_index(batch_op.f('ix_sys_log_updated_by'), ['updated_by'], unique=False)
op.create_table('sys_menu',
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
sa.Column('name', sa.String(length=255), nullable=False, comment='菜单名称'),
sa.Column('type', sa.Enum('catalog', 'menu', 'button', 'embedded', 'link', name='menutypeenum'), nullable=False, comment='菜单类型'),
sa.Column('path', sa.String(length=255), nullable=True, comment='菜单路径'),
sa.Column('component', sa.String(length=255), nullable=True, comment='菜单组件'),
sa.Column('redirect', sa.String(length=255), nullable=True, comment='菜单重定向'),
sa.Column('sort', sa.Integer(), nullable=False, comment='排序'),
sa.Column('auth_code', sa.String(length=128), nullable=True, comment='权限编码'),
sa.Column('meta', sa.JSON(), nullable=True, comment='菜单元数据'),
sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'),
sa.Column('parent_id', sa.String(length=36), nullable=True, comment='父菜单ID'),
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_menu')),
sa.UniqueConstraint('name', name=op.f('uq_sys_menu_name')),
mysql_charset='utf8mb4',
mysql_collate='utf8mb4_general_ci'
)
op.create_table('sys_role',
sa.Column('name', sa.String(length=64), nullable=False, comment='名称'),
sa.Column('code', sa.String(length=64), nullable=False, comment='编码'),
sa.Column('desc', sa.Text(), nullable=True, comment='描述'),
sa.Column('sort', sa.Integer(), nullable=False, comment='排序'),
sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'),
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'),
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'),
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_role')),
sa.UniqueConstraint('code', name=op.f('uq_sys_role_code')),
mysql_charset='utf8mb4',
mysql_collate='utf8mb4_general_ci'
)
with op.batch_alter_table('sys_role', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_sys_role_created_by'), ['created_by'], unique=False)
batch_op.create_index(batch_op.f('ix_sys_role_updated_by'), ['updated_by'], unique=False)
op.create_table('sys_role_menu',
sa.Column('role_id', sa.String(length=36), nullable=False, comment='角色ID'),
sa.Column('menu_id', sa.String(length=36), nullable=False, comment='菜单ID'),
sa.PrimaryKeyConstraint('role_id', 'menu_id', name=op.f('pk_sys_role_menu'))
)
op.create_table('sys_user',
sa.Column('username', sa.String(length=64), nullable=False, comment='用户名'),
sa.Column('phone', sa.String(length=13), nullable=True, comment='手机号'),
sa.Column('email', sa.String(length=255), nullable=True, comment='邮箱'),
sa.Column('password', sa.String(length=255), nullable=False, comment='密码'),
sa.Column('realname', sa.String(length=32), nullable=True, comment='真实姓名'),
sa.Column('desc', sa.Text(), nullable=True, comment='描述'),
sa.Column('avatar', sa.String(length=255), nullable=True, comment='头像'),
sa.Column('gender', sa.Enum('male', 'female', 'secure', name='genderenum'), nullable=False, comment='性别'),
sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'),
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'),
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'),
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_user')),
mysql_charset='utf8mb4',
mysql_collate='utf8mb4_general_ci'
)
with op.batch_alter_table('sys_user', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_sys_user_created_by'), ['created_by'], unique=False)
batch_op.create_index(batch_op.f('ix_sys_user_updated_by'), ['updated_by'], unique=False)
op.create_table('sys_user_dept',
sa.Column('user_id', sa.String(length=36), nullable=False, comment='用户ID'),
sa.Column('dept_id', sa.String(length=36), nullable=False, comment='部门ID'),
sa.PrimaryKeyConstraint('user_id', 'dept_id', name=op.f('pk_sys_user_dept'))
)
op.create_table('sys_user_role',
sa.Column('user_id', sa.String(length=36), nullable=False, comment='用户ID'),
sa.Column('role_id', sa.String(length=36), nullable=False, comment='角色ID'),
sa.PrimaryKeyConstraint('user_id', 'role_id', name=op.f('pk_sys_user_role'))
)
op.create_table('sys_user_attribute',
sa.Column('user_id', sa.String(length=36), nullable=False, comment='用户ID'),
sa.Column('attr_group', sa.String(length=64), nullable=False, comment='属性分组(如: erp, custom)'),
sa.Column('attr_key', sa.String(length=128), nullable=False, comment='属性键'),
sa.Column('attr_value', sa.Text(), nullable=True, comment='属性值'),
sa.Column('attr_type', sa.String(length=32), nullable=False, comment='值类型(string/int/float/bool/json/encrypted)'),
sa.Column('description', sa.String(length=255), nullable=True, comment='属性描述'),
sa.Column('sort', sa.Integer(), nullable=False, comment='排序'),
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'),
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'),
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
sa.ForeignKeyConstraint(['user_id'], ['sys_user.id'], name=op.f('fk_sys_user_attribute_user_id_sys_user'), ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_user_attribute')),
sa.UniqueConstraint('user_id', 'attr_group', 'attr_key', name='uk_user_group_key')
)
with op.batch_alter_table('sys_user_attribute', schema=None) as batch_op:
batch_op.create_index('idx_user_group_key', ['user_id', 'attr_group', 'attr_key'], unique=False)
batch_op.create_index(batch_op.f('ix_sys_user_attribute_attr_group'), ['attr_group'], unique=False)
batch_op.create_index(batch_op.f('ix_sys_user_attribute_created_by'), ['created_by'], unique=False)
batch_op.create_index(batch_op.f('ix_sys_user_attribute_updated_by'), ['updated_by'], unique=False)
batch_op.create_index(batch_op.f('ix_sys_user_attribute_user_id'), ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('sys_user_attribute', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_sys_user_attribute_user_id'))
batch_op.drop_index(batch_op.f('ix_sys_user_attribute_updated_by'))
batch_op.drop_index(batch_op.f('ix_sys_user_attribute_created_by'))
batch_op.drop_index(batch_op.f('ix_sys_user_attribute_attr_group'))
batch_op.drop_index('idx_user_group_key')
op.drop_table('sys_user_attribute')
op.drop_table('sys_user_role')
op.drop_table('sys_user_dept')
with op.batch_alter_table('sys_user', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_sys_user_updated_by'))
batch_op.drop_index(batch_op.f('ix_sys_user_created_by'))
op.drop_table('sys_user')
op.drop_table('sys_role_menu')
with op.batch_alter_table('sys_role', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_sys_role_updated_by'))
batch_op.drop_index(batch_op.f('ix_sys_role_created_by'))
op.drop_table('sys_role')
op.drop_table('sys_menu')
with op.batch_alter_table('sys_log', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_sys_log_updated_by'))
batch_op.drop_index(batch_op.f('ix_sys_log_created_by'))
op.drop_table('sys_log')
with op.batch_alter_table('sys_file_directory', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_sys_file_directory_updated_by'))
batch_op.drop_index('ix_sys_file_directory_path', mysql_length=255)
batch_op.drop_index(batch_op.f('ix_sys_file_directory_created_by'))
op.drop_table('sys_file_directory')
with op.batch_alter_table('sys_file', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_sys_file_updated_by'))
batch_op.drop_index(batch_op.f('ix_sys_file_share_code'))
batch_op.drop_index(batch_op.f('ix_sys_file_file_key'))
batch_op.drop_index(batch_op.f('ix_sys_file_file_hash'))
batch_op.drop_index(batch_op.f('ix_sys_file_directory_id'))
batch_op.drop_index(batch_op.f('ix_sys_file_created_by'))
op.drop_table('sys_file')
with op.batch_alter_table('sys_dict_type', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_sys_dict_type_updated_by'))
batch_op.drop_index(batch_op.f('ix_sys_dict_type_created_by'))
op.drop_table('sys_dict_type')
with op.batch_alter_table('sys_dict_data', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_sys_dict_data_updated_by'))
batch_op.drop_index(batch_op.f('ix_sys_dict_data_created_by'))
op.drop_table('sys_dict_data')
op.drop_table('sys_dept')
with op.batch_alter_table('sys_config', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_sys_config_updated_by'))
batch_op.drop_index(batch_op.f('ix_sys_config_created_by'))
op.drop_table('sys_config')
# ### end Alembic commands ###

@ -1,39 +1,94 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "iTi-Flask"
name = "iti-flask"
dynamic = ["version"]
description = 'iTi-Flask is a Flask-based framework for building web applications.'
description = "iTi-Flask is a lightweight APIFlask backend foundation for business applications."
readme = "README.md"
requires-python = ">=3.8"
requires-python = ">=3.11"
license = "MIT"
keywords = []
keywords = ["apiflask", "flask", "backend", "framework"]
authors = [{ name = "NoahLan", email = "6995syu@163.com" }]
classifiers = [
"Development Status :: 4 - Beta",
"Programming Language :: Python",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
]
dependencies = []
dependencies = [
"flask>=3.1.0",
"apiflask>=2.4.0",
"flask-cors>=6.0.0",
"flask-sqlalchemy>=3.1.0",
"SQLAlchemy>=2.0.0",
"flask-migrate>=4.1.0",
"flask-marshmallow>=1.3.0",
"Flask-JWT-Extended>=4.7.0",
"Flask-Limiter>=4.0.0",
"flask-moment>=1.0.0",
"Flask-Caching>=2.3.0",
"validators>=0.35.0",
"marshmallow>=4.0.0",
"marshmallow-sqlalchemy>=1.4.0",
"marshmallow-dataclass>=8.7.0",
"Werkzeug>=3.1.0",
"python-dotenv>=1.0.0",
"httpx>=0.27.0",
"tenacity>=8.2.0",
]
[project.optional-dependencies]
mysql = ["PyMySQL>=1.1.0"]
storage-aliyun = ["oss2>=2.19.1"]
storage-tencent = ["cos-python-sdk-v5>=1.9.38"]
storage-qiniu = ["qiniu>=7.17.0"]
storage-huawei = ["esdk-obs-python>=3.25.8"]
storage-s3 = ["boto3>=1.40.62"]
storage-minio = ["minio>=7.2.18"]
erp = ["pyodbc>=5.3.0"]
excel = ["pandas>=2.3.3", "openpyxl>=3.1.5"]
image = ["Pillow>=12.0.0"]
prod = ["waitress>=2.1.0"]
dev = [
"mypy>=1.0.0",
"pytest>=7.0.0",
"pytest-cov>=4.0.0",
"pytest-mock>=3.0.0",
"pytest-asyncio>=0.21.0",
"watchdog>=6.0.0",
]
[project.urls]
Documentation = "https://github.com/NoahLan/iti-flask#readme"
Issues = "https://github.com/NoahLan/iti-flask/issues"
Source = "https://github.com/NoahLan/iti-flask"
[tool.hatch.version]
path = "iti/__about__.py"
[tool.setuptools.dynamic]
version = { attr = "iti.__about__.__version__" }
[tool.setuptools.packages.find]
include = ["iti*"]
[tool.setuptools.package-data]
iti = [
"framework_migrations/versions/*.py",
"templates/**/*.html",
"static/favicon.ico",
"static/images/*",
]
[tool.hatch.build.targets.wheel]
packages = ['iti']
[tool.setuptools.exclude-package-data]
iti = [
".env*",
"runtime/*",
"static/dist/*",
"**/__pycache__/*",
]
[tool.coverage.run]
source_pkgs = ["iti", "tests"]
@ -47,3 +102,24 @@ tests = ["tests", "*/tests"]
[tool.coverage.report]
exclude_lines = ["no cov", "if __name__ == .__main__.:", "if TYPE_CHECKING:"]
[tool.mypy]
python_version = "3.11"
ignore_missing_imports = true
warn_unused_configs = true
files = [
"iti/config.py",
"iti/cli.py",
"iti/modules",
"iti/service_client",
"iti/tasks",
]
[[tool.mypy.overrides]]
module = [
"iti.applications.*",
]
follow_imports = "skip"
[tool.pytest.ini_options]
testpaths = ["tests"]

@ -16,47 +16,47 @@
### 运行所有测试
```bash
hatch run test
uv run --extra dev pytest
```
### 运行特定测试文件
```bash
# 运行 HTTP 工具测试
hatch run test tests/test_http_utils.py
uv run --extra dev pytest tests/test_http_utils.py
# 运行配置测试
hatch run test tests/test_config.py
uv run --extra dev pytest tests/test_config.py
# 运行限流器测试
hatch run test tests/test_limiter.py
uv run --extra dev pytest tests/test_limiter.py
```
### 运行特定测试类
```bash
# 运行 success() 函数测试
hatch run test tests/test_http_utils.py::TestSuccessFunction
uv run --extra dev pytest tests/test_http_utils.py::TestSuccessFunction
# 运行分页构建器测试
hatch run test tests/test_http_utils.py::TestPaginationBuilder
uv run --extra dev pytest tests/test_http_utils.py::TestPaginationBuilder
```
### 运行特定测试用例
```bash
# 运行单个测试
hatch run test tests/test_http_utils.py::TestSuccessFunction::test_success_with_data
uv run --extra dev pytest tests/test_http_utils.py::TestSuccessFunction::test_success_with_data
```
### 带详细输出
```bash
# 详细模式
hatch run test -v
uv run --extra dev pytest -v
# 超详细模式
hatch run test -vv
uv run --extra dev pytest -vv
```
---
@ -67,7 +67,7 @@ hatch run test -vv
```bash
# 运行测试并生成覆盖率报告
hatch run cov
uv run --extra dev pytest --cov=iti --cov-report=html
```
### 查看覆盖率报告
@ -235,26 +235,26 @@ def test_with_fixture(client):
```bash
# 显示 print() 输出
hatch run test -s
uv run --extra dev pytest -s
# 显示局部变量
hatch run test -l
uv run --extra dev pytest -l
# 进入调试器
hatch run test --pdb
uv run --extra dev pytest --pdb
```
### 仅运行失败的测试
```bash
# 首次运行记录结果
hatch run test
uv run --extra dev pytest
# 仅运行上次失败的测试
hatch run test --lf
uv run --extra dev pytest --lf
# 先运行失败的,再运行其他的
hatch run test --ff
uv run --extra dev pytest --ff
```
---
@ -273,12 +273,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Install Hatch
run: pip install hatch
- name: Install uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh
- name: Run tests
run: hatch run test
run: uv run --extra dev pytest
- name: Generate coverage
run: hatch run cov
run: uv run --extra dev pytest --cov=iti --cov-report=html
```
---

@ -1,9 +1,11 @@
"""
配置测试
"""
import os
import pytest
from iti.applications import create_app
from config import DevConfig, TestConfig, ProdConfig, get_config
from iti.config import DevConfig, TestConfig, ProdConfig, _load_env_file, get_config
class TestConfig2:
@ -15,7 +17,7 @@ class TestConfig2:
assert app.config['DEBUG'] is True
assert app.config['TESTING'] is False
assert 'sqlite' in app.config['SQLALCHEMY_DATABASE_URI'].lower()
assert app.config['SQLALCHEMY_DATABASE_URI']
def test_test_config(self):
"""测试测试环境配置"""
@ -89,3 +91,20 @@ def test_app_context(app):
with app.app_context():
assert app.config['TESTING'] is True
def test_load_env_file_reads_project_directory(monkeypatch, tmp_path):
monkeypatch.delenv("DATABASE_URL", raising=False)
(tmp_path / ".env.dev").write_text("DATABASE_URL=sqlite:///project.db\n")
assert _load_env_file(tmp_path) is True
assert os.getenv("DATABASE_URL") == "sqlite:///project.db"
def test_load_env_file_does_not_read_framework_package_dir(monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("DATABASE_URL", raising=False)
monkeypatch.delenv("ITI_ENV_DIR", raising=False)
assert _load_env_file() is False
assert os.getenv("DATABASE_URL") is None

@ -0,0 +1,19 @@
from click.testing import CliRunner
from iti.cli import iti_cli
def test_sync_framework_migrations_is_idempotent(tmp_path):
target = tmp_path / "migrations" / "versions"
runner = CliRunner()
migration_name = "20260508_0433_7de264f96a03_framework_initial_schema.py"
first = runner.invoke(iti_cli, ["migrations", "sync", "--target", str(target)])
assert first.exit_code == 0
assert f"synced: {migration_name}" in first.output
assert (target / migration_name).exists()
assert not (target / "__init__.py").exists()
second = runner.invoke(iti_cli, ["migrations", "sync", "--target", str(target)])
assert second.exit_code == 0
assert f"skipped: {migration_name}" in second.output

@ -3,6 +3,8 @@ HTTP 响应工具测试
"""
import pytest
from apiflask import APIBlueprint, Schema
from apiflask.fields import Integer, String
from iti.applications import create_app
from iti.applications.common.utils import (
success,
@ -16,7 +18,40 @@ from iti.applications.common.utils import (
@pytest.fixture
def app():
"""创建测试应用"""
return create_app("test")
test_bp = APIBlueprint("test_http_utils", __name__)
@test_bp.get("/test/success")
def test_success():
return success({"message": "ok"}, message="操作成功")
@test_bp.get("/test/fail")
def test_fail():
return fail("这是一个错误示例", code=400)
@test_bp.get("/test/page")
def test_page():
return page(
[{"id": 1}, {"id": 2}, {"id": 3}],
pagination_builder(None, page=1, size=10, total=30),
message="分页测试成功",
)
class IndexSchema(Schema):
id = Integer()
name = String()
@test_bp.get("/")
@test_bp.output(IndexSchema)
def test_index():
return success({"id": 1, "name": "test"})
class TestModule:
name = "test_http_utils"
def register_routes(self, app):
app.register_blueprint(test_bp)
return create_app("test", modules=[TestModule()])
@pytest.fixture

@ -23,7 +23,8 @@ def test_limiter_disabled_in_test_env(app):
# 测试环境应该禁用限流
assert app.config.get('RATELIMIT_ENABLED') is False # 配置中禁用
assert limiter is None # limiter 实例应该为 None
assert limiter is not None
assert limiter.enabled is False
def test_limiter_config_in_dev_env():
@ -52,7 +53,8 @@ def test_limiter_initialization():
# 测试环境 - 应该禁用limiter 为 None
app_test = create_app('test')
from iti.applications.extensions.limit import limiter as test_limiter
assert test_limiter is None # 测试环境应该没有创建 limiter 实例
assert test_limiter is not None
assert test_limiter.enabled is False
# 开发环境 - 应该启用
app_dev = create_app('dev')
@ -103,4 +105,5 @@ def test_limiter_disabled_config():
# 验证限流器被禁用
from iti.applications.extensions.limit import limiter
assert limiter is None
assert limiter is not None
assert limiter.enabled is False

@ -0,0 +1,104 @@
from __future__ import annotations
import pytest
from iti.applications import create_app
from iti.applications.common.enums import MenuTypeEnum
from iti.applications.extensions import db
from iti.applications.models import Role, SysMenu
from iti.modules import ModuleMenuSeed, ModulePermission, get_module_registry
from iti.modules.registry import ModuleRegistry
from iti.seeds.system import seed_system_data
class RecordingModule:
name = "recording"
def __init__(self) -> None:
self.calls: list[str] = []
def init_app(self, app) -> None:
self.calls.append("init_app")
def register_commands(self, app) -> None:
self.calls.append("register_commands")
def register_routes(self, app) -> None:
self.calls.append("register_routes")
def register_permissions(self, app) -> None:
self.calls.append("register_permissions")
get_module_registry(app).register_permission(
ModulePermission(code="recording:list", name="录制列表")
)
def register_menu_seed(self, app) -> None:
self.calls.append("register_menu_seed")
get_module_registry(app).register_menu_seed(
ModuleMenuSeed(
id="recording-menu",
name="Recording",
type=MenuTypeEnum.MENU.value,
path="/recording",
component="/recording/list",
auth_code="recording:list",
meta={"title": "录制模块"},
sort=200,
)
)
def test_module_registry_rejects_duplicate_names():
registry = ModuleRegistry()
registry.register(RecordingModule())
with pytest.raises(ValueError, match="module already registered"):
registry.register(RecordingModule())
def test_create_app_runs_module_phases_and_collects_metadata():
module = RecordingModule()
imported: list[str] = []
app = create_app(
"test",
modules=[module],
config_mapping={"test": "iti.config.TestConfig"},
model_imports=[lambda: imported.append("models")],
)
registry = get_module_registry(app)
assert module.calls == [
"init_app",
"register_commands",
"register_routes",
"register_permissions",
"register_menu_seed",
]
assert imported == ["models"]
assert registry.list_permissions()[0].code == "recording:list"
assert registry.list_menu_seeds()[0].id == "recording-menu"
def test_module_menu_seed_is_written_by_system_seed():
module = RecordingModule()
app = create_app("test", modules=[module])
with app.app_context():
db.create_all()
summary = seed_system_data(get_module_registry(app))
menu = db.session.get(SysMenu, "recording-menu")
role = db.session.scalar(db.select(Role).filter_by(code="ADMIN"))
assert summary["module_menus"]["created"] == 1
assert summary["module_role_menus"]["created"] == 1
assert menu is not None
assert menu.auth_code == "recording:list"
assert role is not None
assert "recording-menu" in {item.id for item in role.menus}
db.session.remove()
db.drop_all()

@ -0,0 +1,86 @@
from sqlalchemy import select
from iti.applications import create_app
from iti.applications.common.enums import GenderEnum, StatusEnum
from iti.applications.common.permission import get_super_admin_role
from iti.applications.extensions import db
from iti.applications.models import Role, SysConfig, SysMenu, User
from iti.seeds.system import DEFAULT_MENUS, seed_system_data
def test_seed_system_data_is_idempotent():
app = create_app("test")
with app.app_context():
db.create_all()
first = seed_system_data()
admin_user = db.session.scalar(select(User).filter_by(username="admin"))
assert admin_user is not None
admin_user.realname = "已有管理员"
admin_user.email = "existing@example.com"
admin_user.password = "custom-password"
db.session.commit()
second = seed_system_data()
assert first["roles"]["created"] == 2
assert first["menus"]["created"] == len(DEFAULT_MENUS)
assert second["roles"]["created"] == 0
assert second["menus"]["created"] == 0
assert second["users"]["created"] == 0
admin_role = db.session.scalar(select(Role).filter_by(code="ADMIN"))
common_role = db.session.scalar(select(Role).filter_by(code="COMMON"))
admin_user = db.session.scalar(select(User).filter_by(username="admin"))
default_roles = db.session.scalar(
select(SysConfig).filter_by(type="USER", code="DEFAULT_USER_ROLES")
)
assert admin_role is not None
assert common_role is not None
assert admin_user is not None
assert admin_user.realname == "已有管理员"
assert admin_user.email == "existing@example.com"
assert admin_user.check_password("custom-password")
assert default_roles is not None
assert default_roles.value == "COMMON"
assert app.config["PERMISSION_CONFIG"]["SUPER_ADMIN_ROLE"] == "ADMIN"
assert get_super_admin_role() == "ADMIN"
assert {role.code for role in admin_user.roles} == {"ADMIN"}
assert len(admin_role.menus) == len(DEFAULT_MENUS)
assert db.session.scalar(select(SysMenu).filter_by(name="PunchAndReport")) is None
db.session.remove()
db.drop_all()
def test_seed_system_data_does_not_update_existing_admin_user():
app = create_app("test")
with app.app_context():
db.create_all()
existing = User(
username="admin",
password="custom-password",
realname="已有管理员",
email="existing@example.com",
gender=GenderEnum.SECURE.value,
status=StatusEnum.ENABLED.value,
)
db.session.add(existing)
db.session.commit()
summary = seed_system_data()
admin_user = db.session.scalar(select(User).filter_by(username="admin"))
assert summary["users"]["created"] == 0
assert summary["users"]["updated"] == 0
assert summary["users"]["skipped"] == 1
assert admin_user.realname == "已有管理员"
assert admin_user.email == "existing@example.com"
assert admin_user.check_password("custom-password")
assert {role.code for role in admin_user.roles} == {"ADMIN"}
db.session.remove()
db.drop_all()

@ -0,0 +1,108 @@
from __future__ import annotations
import httpx
import pytest
from iti.service_client import (
ServiceClient,
ServiceConfig,
ServiceHTTPError,
ServiceUnavailableError,
)
from iti.service_client.config import CircuitBreakerConfig, RetryConfig
def _json_response(status_code: int, payload: dict) -> httpx.Response:
return httpx.Response(status_code, json=payload)
def test_service_client_sends_json_headers_token_trace_and_path():
seen: dict[str, object] = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["url"] = str(request.url)
seen["authorization"] = request.headers.get("Authorization")
seen["trace_id"] = request.headers.get("X-Trace-Id")
return _json_response(200, {"ok": True})
client = ServiceClient(
ServiceConfig(name="erp", base_url="http://erp.local", token="token-a"),
transport=httpx.MockTransport(handler),
)
result = client.get("/users/{id}", path={"id": 12}, params={"active": "1"})
assert result == {"ok": True}
assert seen["url"] == "http://erp.local/users/12?active=1"
assert seen["authorization"] == "Bearer token-a"
assert isinstance(seen["trace_id"], str)
assert seen["trace_id"]
def test_service_client_retries_idempotent_statuses():
calls = {"count": 0}
def handler(request: httpx.Request) -> httpx.Response:
calls["count"] += 1
if calls["count"] == 1:
return _json_response(503, {"error": "busy"})
return _json_response(200, {"ok": True})
client = ServiceClient(
ServiceConfig(
name="erp",
base_url="http://erp.local",
retry=RetryConfig(attempts=2, backoff=0),
),
transport=httpx.MockTransport(handler),
)
assert client.get("/health") == {"ok": True}
assert calls["count"] == 2
def test_service_client_does_not_retry_post_by_default():
calls = {"count": 0}
def handler(request: httpx.Request) -> httpx.Response:
calls["count"] += 1
return _json_response(503, {"error": "busy"})
client = ServiceClient(
ServiceConfig(
name="erp",
base_url="http://erp.local",
retry=RetryConfig(attempts=3, backoff=0),
),
transport=httpx.MockTransport(handler),
)
with pytest.raises(ServiceHTTPError) as exc_info:
client.post("/jobs", json={"kind": "users"})
assert exc_info.value.status_code == 503
assert calls["count"] == 1
def test_service_client_opens_circuit_breaker_after_failures():
def handler(request: httpx.Request) -> httpx.Response:
return _json_response(500, {"error": "failed"})
client = ServiceClient(
ServiceConfig(
name="erp",
base_url="http://erp.local",
circuit_breaker=CircuitBreakerConfig(
enabled=True,
fail_max=1,
reset_timeout=30,
),
),
transport=httpx.MockTransport(handler),
)
with pytest.raises(ServiceHTTPError):
client.get("/health")
with pytest.raises(ServiceUnavailableError, match="circuit breaker is open"):
client.get("/health")

@ -0,0 +1,71 @@
from __future__ import annotations
import threading
import time
import pytest
from iti.tasks.registry import TaskRegistry
from iti.tasks.runner import TaskRunner, _parse_interval, _parse_simple_cron
def test_task_registry_triggers_success_and_failure_runs():
registry = TaskRegistry()
registry.register(name="success", handler=lambda: {"ok": True})
registry.register(name="failure", handler=lambda: 1 / 0)
success = registry.trigger("success")
failure = registry.trigger("failure")
assert success.status == "success"
assert success.result == {"ok": True}
assert success.finished_at is not None
assert failure.status == "failed"
assert "ZeroDivisionError" in failure.error
def test_task_registry_rejects_duplicate_names():
registry = TaskRegistry()
registry.register(name="sync", handler=lambda: None)
with pytest.raises(ValueError, match="task already registered"):
registry.register(name="sync", handler=lambda: None)
def test_task_registry_skips_when_same_task_is_running():
started = threading.Event()
release = threading.Event()
registry = TaskRegistry()
def blocking_handler():
started.set()
release.wait(timeout=2)
registry.register(name="sync", handler=blocking_handler)
worker = threading.Thread(target=registry.trigger, args=("sync",))
worker.start()
assert started.wait(timeout=1)
skipped = registry.trigger("sync")
release.set()
worker.join(timeout=2)
assert skipped.status == "skipped"
assert skipped.error == "task already running"
def test_task_schedule_parsers_and_due_check():
runner = TaskRunner(TaskRegistry())
now = time.time()
assert _parse_interval("interval:60") == 60
assert _parse_interval("interval:0") == 1
assert _parse_interval("bad") is None
assert _parse_simple_cron("cron:*/5 * * * *") == 300
assert _parse_simple_cron("* * * * *") == 60
assert _parse_simple_cron("0 * * * *") is None
assert runner._due("interval:10", "sync", now) is True
runner._last_run["sync"] = now
assert runner._due("interval:10", "sync", now + 5) is False
assert runner._due("interval:10", "sync", now + 10) is True
Loading…
Cancel
Save