Compare commits

..

15 Commits
main ... iot

Author SHA1 Message Date
DESKTOP-1JS6RSM\Admin e95d009982 使用消息中间件rocketmq,完善预警报警功能 3 months ago
DESKTOP-1JS6RSM\Admin 662d683813 修复告警等级优先bug 3 months ago
DESKTOP-1JS6RSM\Admin 94e5cd482b 告警逻辑添加优先级覆盖 3 months ago
DESKTOP-1JS6RSM\Admin 7c144a33be 采集端添加opc请求地址模板 3 months ago
DESKTOP-1JS6RSM\Admin 6a82e1fa8b 添加预警告警、消息通知模块相关逻辑及接口 3 months ago
DESKTOP-1JS6RSM\Admin 191ef60413 优化influxdb链接,修改采集端和设备关联,添加告警功能相关表 3 months ago
DESKTOP-1JS6RSM\Admin 675d2fad8f 添加influxdb重连 3 months ago
DESKTOP-1JS6RSM\Admin 5d58002118 车间,设备,采集端添加编号唯一过滤 3 months ago
DESKTOP-1JS6RSM\Admin 194dfcf2a3 添加influxdb数据读取接口 3 months ago
DESKTOP-1JS6RSM\Admin f98775a493 fix bug 3 months ago
DESKTOP-1JS6RSM\Admin 8a15f1990f 关联调整 3 months ago
DESKTOP-1JS6RSM\Admin 60bf0ffaf1 fix bug 3 months ago
DESKTOP-1JS6RSM\Admin e181e42c44 增加基础接口关联查询 3 months ago
DESKTOP-1JS6RSM\Admin 4647d9faa7 基础接口添加 3 months ago
DESKTOP-1JS6RSM\Admin 6753312961 添加物联网模块基础接口 3 months ago

@ -1,71 +0,0 @@
# 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).

@ -1,96 +0,0 @@
---
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.

@ -1,84 +0,0 @@
---
name: iti-flask-framework
description: iTi-Flask 框架 skill。用于当前 iTi-Flask 框架仓库内的框架代码、框架文档、Copier 模板行为、应用工厂、配置、模块协议、鉴权、响应 envelope、服务客户端、任务、迁移、审计、存储、日志、测试和发布打包。不要用于 iTi-System 系统域业务修改,也不要用于模板生成项目的具体业务逻辑。
---
# iTi-Flask 框架
只用于 iTi-Flask 框架仓库。
iTi-Flask 是 FastAPI 后端框架基座。
## 边界
- iTi-Flask 只写框架能力。
- 不把系统域业务放进框架。
- 不把具体模板生成项目写成框架行为。
- `iti-system` 是外部可选系统业务包。
- Copier 模板会为生成项目渲染独立项目 skill。
## 代码入口
- `iti/app.py``create_app`、中间件、错误处理、自动 envelope、生命周期。
- `iti/config.py`dataclass 配置、`.env` 加载、数据库默认连接串。
- `iti/db/*`SQLAlchemy 2 base/session、Alembic metadata。
- `iti/auth/*`JWT、Principal、Actor、权限依赖、服务 token 依赖。
- `iti/modules/*`:模块协议、权限元数据、菜单 seed 元数据。
- `iti/responses/*`envelope、raw response 逃逸。
- `iti/service_client/*`:同步 HTTP JSON 客户端和注册表。
- `iti/tasks/*`:单进程任务注册和 runner。
- `iti/audit.py`:审计事件发送器,不拥有系统日志表。
- `iti/storage/*`:存储后端接口和实现。
- `copier-template/`:业务项目模板。
- `docs/`:人类阅读的精简参考。
## 修改规则
- 先读现有代码,再改文档或行为。
- 配置继续使用当前 dataclass 风格。
- JSON API 默认兼容 envelope除非路由明确 raw。
- 保留 raw 默认值:`/health`、`/ready`、`/docs`、`/openapi.json`。
- `/docs` 是文档入口,按 `docs_ui_enabled` 展示 Swagger、Scalar、ReDoc 等已启用 UI。
- 模块元数据使用 `ModulePermission``ModuleMenuSeed`
- migration 归生成项目所有。框架不要静默接管业务项目 migration 流。
- 审计保持异步、非阻塞。框架只发事件,接收方在框架外。
- 不为未发生的需求加宽泛兼容层。
## 模板规则
- 模板变更在 `copier-template/`
- Copier 模板源入口是仓库根目录 `copier.yml`,实际模板目录由 `_subdirectory: copier-template` 指定。
- 模板输出保持通用 FastAPI 业务后端骨架。
- 模板生成项目固定使用 `app/` 作为 Python 顶层包ASGI 入口固定为 `main.py`
- 模板内不要写具体业务域知识。
- 模板结构、命令或生成文件变化时,同步更新:
- `copier.yml`
- `copier-template/README.md.jinja`
- `docs/COPIER_TEMPLATE.md`
- `copier-template/.codex/skills/{{ project_slug | lower | replace('_', '-') }}-project/SKILL.md.jinja`
- 需要保持一致的已生成项目 skill 副本
- 生成项目必须保留 `.copier-answers.yml`,否则不能用 `copier update` 同步模板。
- 已生成项目同步框架依赖用 `iticli update framework`
- 已生成项目检查和同步模板用 `iticli template check`、`iticli template update`。
- 模板项目的 Alembic 命令必须显式使用 `-c migrations/alembic.ini`
- 模板项目的测试命令使用 `uv run --extra dev pytest -q`,避免未安装 dev extra 时找不到 pytest。
## 命令
- 安装框架开发依赖:`iticli install`
- 运行框架测试:`iticli test`
- 启动最小应用:`iticli run dev 8000`
- 生成业务项目:`iticli create ../my-business-app`
- 生成带系统包的业务项目:`iticli create --with-system ../my-system-app`
- 发布框架:`iticli release`
框架仓库不再维护 `.sh` / `.cmd` 脚本。
模板生成业务项目不再包含 `app.sh`、`app.cmd`。
## 文档
人类文档保持短。
文档只放稳定事实和命令入口。
细节优先从代码确认。
代码、架构、命令、模块协议、模板输出或验证流程变化时,同步更新这个 skill。

@ -1,4 +0,0 @@
interface:
display_name: "iTi-Flask 框架"
short_description: "iTi-Flask 框架开发指南"
default_prompt: "使用 $iti-flask-framework 修改或审查 iTi-Flask 框架代码。"

@ -1,255 +0,0 @@
---
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无硬编码密钥

@ -1,277 +0,0 @@
---
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** - 用于并行会话而非同会话执行

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

@ -1,113 +0,0 @@
# 实现子智能体提示词模板
分派实现子智能体时使用此模板。
```
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。绝不默默产出你不确定的工作。
```

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

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

50
.gitignore vendored

@ -1,10 +1,12 @@
# Python bytecode
# Python 字节码与缓存
__pycache__/
*.py[cod]
*$py.class
# C 扩展
*.so
# Build / package artifacts
# 打包/发布产物
.Python
build/
dist/
@ -22,25 +24,22 @@ lib64/
.installed.cfg
*.egg
# Virtual environments
# Hatch
.hatch/
shell/
# 虚拟环境(你的仓库中有 env/virtual/iti-flask
.venv/
venv/
env/
ENV/
# uv
uv.lock
# Flask / local env
# Flask 实例与本地配置
instance/
.flaskenv
.env
.env.*
!.env.example
!*.env.example.jinja
.envrc
# Runtime data
# 运行期与数据库文件(你的项目含 iti/runtime/iti-flask_dev.db
*.db
*.sqlite
*.sqlite3
@ -48,15 +47,11 @@ instance/
*.db-wal
*.db-shm
runtime/
iti/runtime/
logs/
*.log
*.pid
# Frontend build output
iti/static/dist/
# Test / coverage / static analysis
# 测试与覆盖率
.pytest_cache/
.coverage
.coverage.*
@ -67,22 +62,29 @@ coverage.xml
*.cover
*.py,cover
.hypothesis/
# 类型检查/静态分析缓存
.mypy_cache/
.ruff_cache/
.pytype/
.pyre/
dmypy.json
# IDE / OS
.idea/
*.iml
# 编辑器/IDE
# .vscode/
# .idea/
# *.iml
# Jupyter
.ipynb_checkpoints/
# 操作系统杂项
.DS_Store
Thumbs.db
Desktop.ini
.ipynb_checkpoints/
# Local tool pins
# 其他
/.python-version
# Local AI session notes
.grill/
# migrations
migrations/versions/*.py

@ -1,134 +1,21 @@
# iTi-Flask
# back
iTi-Flask 是 FastAPI 后端框架基座。
[![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)
AI 修改框架代码或文档时优先读:
-----
```text
.codex/skills/iti-flask-framework/SKILL.md
```
它提供业务项目常用的通用能力:
- FastAPI 应用工厂。
- dataclass 配置和 `.env` 加载。
- MySQL 默认数据库配置PostgreSQL 可选。
- SQLAlchemy 2 和 Alembic。
- JWT、权限依赖、错误处理、响应包装。
- 用户 token / 服务 token 的统一 Actor 依赖。
- 缓存、限流、事件总线。
- 模块注册、权限元数据、菜单 seed 元数据。
- 同步 HTTP 服务客户端。
- 运行日志和审计事件 sender。
- 单机轻量任务 runner。
- `/health``/ready` 健康检查。
- Copier 业务项目模板。
系统业务不在框架内。
需要用户、角色、菜单、字典、文件、日志等能力时,业务项目额外注册 `iti-system`
## 安装
框架本地开发:
```bash
iticli install
```
Windows
```bat
iticli install
```
业务项目依赖:
```toml
dependencies = [
"iti-flask @ git+https://git.noahlan.cn/iti-framework/iTi-Flask.git@v0.3.0",
]
```
## 应用工厂
```python
from iti import create_app
from config import config
from my_app.modules.example import ExampleModule
app = create_app(
config_mapping=config,
modules=[ExampleModule()],
)
```
## Table of Contents
运行:
```bash
iticli run dev 8000
```
该命令会启动框架最小应用,可用于验证 `/health`、`/ready`。
## 业务项目生成
```bash
iticli create ../my-business-app
iticli create --database postgresql ../my-postgres-app
cd ../my-business-app
iticli init
iticli run dev 8000
```
同步框架依赖和模板骨架:
```bash
iticli update framework
iticli template check
iticli template update
```
`iti-system`
```bash
iticli create --with-system ../my-system-app
cd ../my-system-app
iticli init system
```
新生成的业务项目不再生成 `app.sh`、`app.cmd`。
命令入口:
```bash
iticli help
```
发布框架:
```bash
iticli release
iticli release v0.3.0
```
- [Installation](#installation)
- [License](#license)
Windows:
## Installation
```bat
iticli release
iticli release v0.3.0
```console
pip install back
```
## 文档
## License
- [文档索引](docs/README.md)
- [架构](docs/ARCHITECTURE.md)
- [配置](docs/CONFIGURATION.md)
- [模块协议](docs/MODULES.md)
- [模板与导入导出](docs/EXCHANGE.md)
- [服务客户端](docs/SERVICE_CLIENT.md)
- [任务运行器](docs/TASKS.md)
- [数据库迁移](docs/MIGRATIONS.md)
- [种子数据](docs/SEEDS.md)
- [Copier 模板](docs/COPIER_TEMPLATE.md)
- [前端管理端接口契约](docs/FRONTEND_ADMIN_API_CONTRACT.md)
- [测试与部署方案](docs/TESTING_DEPLOYMENT.md)
`back` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.

@ -1,88 +0,0 @@
---
name: {{ project_slug | lower | replace('_', '-') }}-project
description: "{{ project_name }} 业务项目 skill。用于当前由 iTi-Flask Copier 模板生成的业务后端项目,包括 main.py、config.py、pyproject.toml、migrations、tests、modules、models、服务客户端、iTi-Flask 集成{% if include_system %}、iTi-System 注册{% endif %}和项目本地文档。不要写入具体业务域知识。"
---
# {{ project_name }} 业务项目
用于当前业务项目。
本项目由 iTi-Flask Copier 模板生成。
这个 skill 只描述当前项目的通用工程结构。
具体业务知识写在项目 README/docs 或对应业务模块文档里。
## 边界
- 保持为当前业务后端项目的工程规则。
- 不加入具体业务域或客户专属业务知识。
- 框架行为看 iTi-Flask 依赖和项目当前代码。
{% if include_system -%}
- 系统域能力来自 iTi-System当前项目已注册系统包。
{% else -%}
- 当前项目未默认注册 iTi-System。需要系统域能力时再显式引入。
{% endif -%}
- 修改代码、架构、目录结构、脚本命令或测试方式后,同步更新这个 skill。
- 同步框架依赖用 `iticli update framework`。
- 检查和同步框架模板用 `iticli template check`、`iticli template update`。
## 项目结构
- `main.py`ASGI 入口,导出 `app`。
- `app/app_factory.py`:导入 `config`,注册模块,创建 FastAPI app。
- `config.py`:项目本地配置映射。
- `app/modules/`:业务模块。
- `app/models/`:项目 SQLAlchemy 模型。
- `migrations/`:项目自己的 Alembic migration 流。
- `tests/`pytest 路由和行为测试。
- `Dockerfile`:容器镜像构建入口。
- `docker-compose.yml`:本地 Compose 部署入口。
- `docker-compose.with-db.yml`:本地数据库叠加部署入口。
- `.env.example`:本地和 Compose 环境变量样例。
- `.vscode/launch.json`VSCode FastAPI 调试配置。
- `.dockerignore`Docker 构建排除规则。
- `pyproject.toml`:包信息和依赖。
- `.copier-answers.yml`Copier 模板更新锚点。
## 模块模式
业务模块通过 iTi 模块协议注册:
- `register_routes(app)`:挂载 FastAPI router。
- `register_permissions(app)`:用 `ModulePermission` 声明权限元数据。
- `register_menu_seed(app)`:用 `ModuleMenuSeed` 声明后台菜单 seed 元数据。
- `register_tasks(app)`:按需注册本地任务。
- `init_app(app)`:按需接入配置或服务客户端。
业务模块优先放在 `app/modules/<module_name>/`。
## 运行规则
- 业务路由放在项目模块内。
- 项目表和 migration 留在当前项目。
- 默认使用框架 envelope除非路由明确 raw。
- 服务间内部 API 使用 service token。
- 项目级测试使用 `fastapi.testclient.TestClient`。
- 请求体使用 Pydantic schema。
{{ "- seed 前先同步 iTi-System migration。\n" if include_system else "" }}
## 命令
- 安装开发依赖:`iticli install`
- 同步框架依赖:`iticli update framework`
- 检查模板更新:`iticli template check`
- 同步模板骨架:`iticli template update`
- 运行测试:`iticli test`
- 本地启动:`iticli run dev 8000`
- 构建 Docker 镜像:`iticli docker build`
- 启动 Docker Compose`iticli docker up`
- 启动 Docker Compose 和数据库:`iticli docker up --db`
- 停止 Docker Compose`iticli docker down`
- 停止 Docker Compose 和数据库:`iticli docker down --db`
- 查看应用容器日志:`iticli docker logs`
- 创建 migration`iticli migrate revision "alice add order table"`
- 执行 migration`iticli migrate`
- 查看 Alembic heads`iticli migrate heads`
- 查看当前 Alembic 版本:`iticli migrate current`
- 初始化项目:`iticli init`
{{ "系统包相关命令:\n\n- 更新系统依赖:`iticli update system`\n- 初始化系统项目:`iticli init system`\n\n" if include_system else "" -}}

@ -1,4 +0,0 @@
interface:
display_name: "{{ project_name }} 项目"
short_description: "{{ project_name }} 业务项目指南"
default_prompt: "使用 ${{ project_slug | lower | replace('_', '-') }}-project 修改或审查 {{ project_name }} 业务项目。"

@ -1,19 +0,0 @@
.git
.gitignore
.venv
__pycache__
*.py[cod]
.pytest_cache
.mypy_cache
.ruff_cache
*.egg-info
build
dist
runtime
logs
.env
.env.*
!.env.example
*.db
*.sqlite
*.sqlite3

@ -1,20 +0,0 @@
APP_ENV=prod
APP_PORT=8000
DATABASE_DIALECT={{ database_dialect }}
MYSQL_HOST=host.docker.internal
MYSQL_PORT=3306
MYSQL_ROOT_PASSWORD=root-password
MYSQL_DATABASE=app
MYSQL_USER=app
MYSQL_PASSWORD=change-me
POSTGRES_HOST=host.docker.internal
POSTGRES_PORT=5432
POSTGRES_DB=app
POSTGRES_USER=app
POSTGRES_PASSWORD=change-me
SECRET_KEY=change-me
JWT_SECRET_KEY=change-me
LOG_FILE_ENABLED=true

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

@ -1,26 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "{{ project_name }}: FastAPI",
"type": "debugpy",
"request": "launch",
"module": "uvicorn",
"args": [
"main:app",
"--reload",
"--host",
"127.0.0.1",
"--port",
"8000"
],
"cwd": "${workspaceFolder}",
"env": {
"APP_ENV": "dev",
"APP_ENV_DIR": "${workspaceFolder}"
},
"jinja": true,
"justMyCode": true
}
]
}

@ -1,24 +0,0 @@
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
UV_LINK_MODE=copy \
UV_COMPILE_BYTECODE=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl git \
&& rm -rf /var/lib/apt/lists/*
COPY . .
RUN pip install --no-cache-dir uv \
&& if [ -f uv.lock ]; then \
uv sync --frozen --no-dev; \
else \
uv sync --no-dev; \
fi
EXPOSE 8000
CMD ["uv", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

@ -1,128 +0,0 @@
# {{ project_name }}
FastAPI 业务后端项目。
由 iTi-Flask Copier 模板生成。
AI 修改本项目时优先读:
```text
.codex/skills/{{ project_slug | lower | replace('_', '-') }}-project/SKILL.md
```
该 skill 只描述本项目的通用工程规则。
具体业务知识写在本项目自己的 README/docs。
## 依赖
默认使用私有 Git 依赖。
推荐固定 tag也允许 branch。
`file://` 只建议框架开发者本机验证模板时使用。
## 初始化
```bash
cp .env.example .env
iticli init
```
Windows
```bat
iticli init
```
{% if include_system %}
同步系统 migration 和 seed
```bash
iticli init system
```
Windows
```bat
iticli init system
```
{% endif %}
## 开发
```bash
iticli run dev
iticli test
```
不同环境直接在命令前设 `APP_ENV`,或把环境名作为 `run` 的第一个参数:
```bash
iticli run dev
iticli run test 8000
APP_ENV=prod iticli migrate
```
## Docker
```bash
cp .env.example .env
iticli docker up
iticli docker logs
iticli docker down
```
{% if database_dialect == "postgresql" %}
`docker-compose.yml` 默认只启动应用,数据库使用外部 PostgreSQL。
需要本地 PostgreSQL 时使用:
{% else %}
`docker-compose.yml` 默认只启动应用,数据库使用外部 MySQL。
需要本地 MySQL 时使用:
{% endif %}
```bash
iticli docker up --db
iticli docker down --db
```
应用容器启动时会先执行 migration{% if include_system %} 和系统 seed{% endif %}。
本地运行数据写入 `runtime/`,数据库数据写入 Compose volume。
## 同步更新
同步框架包:
```bash
iticli update framework
```
检查模板:
```bash
iticli template check
```
按模板更新项目骨架:
```bash
iticli template update
```
模板更新会改 `main.py`、`app/app_factory.py`、`config.py`、`Dockerfile`、`docker-compose.yml`、`docker-compose.with-db.yml`、`.dockerignore`、`.env.example`、`.vscode/launch.json`、示例模块、测试和项目 skill 等模板拥有的文件。
该命令跟随模板仓库 `HEAD`。
执行前先提交或暂存当前项目改动,执行后检查 diff。
## VSCode 调试
模板内置 `.vscode/launch.json`。
选择 `{{ project_name }}: FastAPI` 即可用 `uvicorn main:app --reload` 调试。
## 数据库迁移
```bash
iticli migrate revision "alice add example table"
iticli migrate
```
规则:
- `migrations/versions` 必须提交。
- migration message 第一个词写作者名。
- 生产只从 `main` 执行 `alembic -c migrations/alembic.ini upgrade head`。

@ -1,3 +0,0 @@
from .app_factory import create_app
__all__ = ["create_app"]

@ -1,27 +0,0 @@
from __future__ import annotations
import os
from iti import create_app as create_framework_app
{% if include_system %}
from iti_system import create_system_module
{% endif %}
from config import config
from app.modules.example.module import ExampleModule
def create_app(config_name: str | None = None, config_overrides: dict | None = None):
config_name = config_name or os.getenv("APP_ENV", "dev")
modules = [ExampleModule()]
{% if include_system %}
modules.append(create_system_module())
{% endif %}
app = create_framework_app(
config_name=config_name,
config_mapping=config,
modules=modules,
)
if config_overrides:
for key, value in config_overrides.items():
setattr(app.state.config, key.lower(), value)
return app

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

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

@ -1,10 +0,0 @@
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from iti.db import Base, IdMixin, TimestampMixin
class Example(IdMixin, TimestampMixin, Base):
__tablename__ = "biz_example"
name: Mapped[str] = mapped_column(String(128), nullable=False, comment="名称")

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

@ -1,33 +0,0 @@
from iti.modules import ModuleMenuSeed, ModulePermission
from .routes import router
class ExampleModule:
name = "example"
def register_routes(self, app):
app.include_router(router)
def register_permissions(self, app):
app.state.iti_modules.register_permission(
ModulePermission(
code="example:item:list",
name="示例列表",
description="查看示例模块数据",
)
)
def register_menu_seed(self, app):
app.state.iti_modules.register_menu_seed(
ModuleMenuSeed(
id="example-menu-root",
name="Example",
type="menu",
path="/example",
component="/example/list",
auth_code="example:item:list",
meta={"title": "示例模块", "icon": "carbon:application"},
sort=100,
)
)

@ -1 +0,0 @@
"""Public facade for other modules."""

@ -1,12 +0,0 @@
from fastapi import APIRouter, Depends
from iti.auth import require_permission
from iti.responses import ok
router = APIRouter(prefix="/example", tags=["example"])
@router.get("/ping", dependencies=[Depends(require_permission("example:item:list"))])
def ping():
return ok({"pong": True})

@ -1,81 +0,0 @@
from __future__ import annotations
{% if database_dialect == "postgresql" -%}
import os
{% endif -%}
from pathlib import Path
from iti.config import (
DevConfig as BaseDevConfig,
TestConfig as BaseTestConfig,
ProdConfig as BaseProdConfig,
)
BASE_DIR = Path(__file__).resolve().parent
APP_NAME = "{{ project_name }}"
def runtime_path(name: str) -> str:
return str(BASE_DIR / "runtime" / name)
def apply_project_config(config) -> None:
config.app_name = APP_NAME
config.base_dir = BASE_DIR
config.file_storage["LOCAL"]["base_path"] = runtime_path("uploads")
config.log_dir = runtime_path("logs")
{% if database_dialect == "postgresql" %}
apply_database_config(config)
def apply_database_config(config) -> None:
if os.getenv("DATABASE_URL"):
return
database = os.getenv("POSTGRES_DB", default_database_name(config.app_env))
config.database_url = default_postgresql_url(database)
def default_postgresql_url(database: str) -> str:
return (
f"postgresql+psycopg://{os.getenv('POSTGRES_USER', 'postgres')}:"
f"{os.getenv('POSTGRES_PASSWORD', 'password')}@"
f"{os.getenv('POSTGRES_HOST', '127.0.0.1')}:"
f"{os.getenv('POSTGRES_PORT', '5432')}/{database}"
)
def default_database_name(app_env: str) -> str:
if app_env == "test":
return "{{ project_slug | lower | replace('-', '_') }}_test"
if app_env == "prod":
return "{{ project_slug | lower | replace('-', '_') }}"
return "{{ project_slug | lower | replace('-', '_') }}_dev"
{% endif %}
class DevConfig(BaseDevConfig):
def __init__(self) -> None:
super().__init__()
apply_project_config(self)
class TestConfig(BaseTestConfig):
def __init__(self) -> None:
super().__init__()
apply_project_config(self)
class ProdConfig(BaseProdConfig):
def __init__(self) -> None:
super().__init__()
apply_project_config(self)
config = {
"dev": DevConfig,
"test": TestConfig,
"prod": ProdConfig,
"default": DevConfig,
}

@ -1,58 +0,0 @@
services:
app:
environment:
DATABASE_DIALECT: {{ database_dialect }}
{% if database_dialect == "postgresql" %}
POSTGRES_HOST: db
POSTGRES_PORT: 5432
{% else %}
MYSQL_HOST: db
MYSQL_PORT: 3306
{% endif %}
depends_on:
db:
condition: service_healthy
db:
{% if database_dialect == "postgresql" %}
image: postgres:16
environment:
POSTGRES_DB: ${POSTGRES_DB:-app}
POSTGRES_USER: ${POSTGRES_USER:-app}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me}
ports:
- "${POSTGRES_PORT:-5432}:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 10
{% else %}
image: mysql:8.4
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root-password}
MYSQL_DATABASE: ${MYSQL_DATABASE:-app}
MYSQL_USER: ${MYSQL_USER:-app}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-change-me}
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
ports:
- "${MYSQL_PORT:-3306}:3306"
volumes:
- mysql-data:/var/lib/mysql
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u$${MYSQL_USER} -p$${MYSQL_PASSWORD} --silent"]
interval: 10s
timeout: 5s
retries: 10
{% endif %}
volumes:
{% if database_dialect == "postgresql" %}
postgres-data:
{% else %}
mysql-data:
{% endif %}

@ -1,32 +0,0 @@
services:
app:
build:
context: .
image: {{ project_slug | replace('_', '-') }}:local
environment:
APP_ENV: ${APP_ENV:-prod}
SECRET_KEY: ${SECRET_KEY:-change-me}
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-change-me}
DATABASE_DIALECT: ${DATABASE_DIALECT:-{{ database_dialect }}}
MYSQL_HOST: ${MYSQL_HOST:-host.docker.internal}
MYSQL_PORT: ${MYSQL_PORT:-3306}
MYSQL_DATABASE: ${MYSQL_DATABASE:-app}
MYSQL_USER: ${MYSQL_USER:-app}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-change-me}
POSTGRES_HOST: ${POSTGRES_HOST:-host.docker.internal}
POSTGRES_PORT: ${POSTGRES_PORT:-5432}
POSTGRES_DB: ${POSTGRES_DB:-app}
POSTGRES_USER: ${POSTGRES_USER:-app}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me}
LOG_FILE_ENABLED: ${LOG_FILE_ENABLED:-true}
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "${APP_PORT:-8000}:8000"
volumes:
- ./runtime:/app/runtime
command: >
sh -c "{% if include_system %}uv run iti-system migrations sync --target migrations/versions &&
{% endif %}uv run python -m alembic -c migrations/alembic.ini upgrade head &&
{% if include_system %}PYTHONPATH=. uv run iti-system seed system app:create_app &&
{% endif %}uv run uvicorn main:app --host 0.0.0.0 --port 8000"

@ -1,4 +0,0 @@
from app import create_app
app = create_app()

@ -1,18 +0,0 @@
# 数据库迁移
本目录是业务项目唯一的 Alembic migration 流。
```bash
uv run python -m alembic -c migrations/alembic.ini revision --autogenerate -m "alice add example table"
uv run python -m alembic -c migrations/alembic.ini upgrade head
```
`versions/` 下的 migration 文件必须提交到 Git。
{% if include_system %}
系统 migration 同步:
```bash
uv run iti-system migrations sync --target migrations/versions
```
{% endif %}

@ -1,40 +0,0 @@
# A generic, single database configuration.
[alembic]
script_location = migrations
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
[loggers]
keys = root,sqlalchemy,alembic
[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
[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

@ -1,62 +0,0 @@
from __future__ import annotations
import os
import sys
from logging.config import fileConfig
from pathlib import Path
from alembic import context
from sqlalchemy import engine_from_config, pool
ROOT_DIR = Path(__file__).resolve().parents[1]
if str(ROOT_DIR) not in sys.path:
sys.path.insert(0, str(ROOT_DIR))
from config import config as app_config
from iti.db import Base
from iti.exchange import models as _exchange_models
from app.models import import_models
import_models()
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def get_url() -> str:
env_name = os.getenv("APP_ENV", "dev")
config_cls = app_config.get(env_name, app_config["default"])
return os.getenv("DATABASE_URL") or config_cls().database_url
def run_migrations_offline() -> None:
context.configure(
url=get_url(),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
configuration = config.get_section(config.config_ini_section, {})
configuration["sqlalchemy.url"] = get_url()
connectable = engine_from_config(configuration, prefix="sqlalchemy.", poolclass=pool.NullPool)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

@ -1,24 +0,0 @@
"""${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"}

@ -1,33 +0,0 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "{{ project_slug | replace('_', '-') }}"
version = "0.3.0"
description = "{{ project_name }}"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"iti-flask @ git+{{ framework_git }}{% if framework_tag %}@{{ framework_tag }}{% endif %}",
{% if include_system %} "iti-system @ git+{{ system_git }}{% if system_tag %}@{{ system_tag }}{% endif %}",
{% endif %}{% if database_dialect == "postgresql" %} "psycopg[binary]>=3.2.0",
{% endif -%}
]
[project.optional-dependencies]
dev = [
"pytest>=7.0.0",
"httpx>=0.27.0",
]
[tool.setuptools.packages.find]
include = ["app*"]
[tool.pytest.ini_options]
pythonpath = ["."]
[tool.uv.sources]
iti-flask = { git = "{{ framework_git }}"{% if framework_tag %}, tag = "{{ framework_tag }}"{% endif %} }
{% if include_system %}iti-system = { git = "{{ system_git }}"{% if system_tag %}, tag = "{{ system_tag }}"{% endif %} }
{% endif -%}

@ -1,10 +0,0 @@
from fastapi.testclient import TestClient
from main import app
def test_health():
response = TestClient(app).get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}

@ -1 +0,0 @@
{{ _copier_answers|to_nice_yaml -}}

@ -1,52 +0,0 @@
_subdirectory: copier-template
_exclude:
- copier.yml
- "~*"
- "*.py[co]"
- __pycache__
- .git
- .DS_Store
- .svn
project_name:
type: str
help: 业务项目显示名称
default: iTi Business App
project_slug:
type: str
help: 业务项目发行包名来源
default: iti-business-app
framework_git:
type: str
help: iTi-Flask Git 地址
default: https://git.noahlan.cn/iti-framework/iTi-Flask.git
framework_tag:
type: str
help: iTi-Flask Git tag
default: v0.3.0
include_system:
type: bool
help: 是否引入 iTi-System 系统业务包
default: false
database_dialect:
type: str
help: 默认数据库类型
default: mysql
choices:
MySQL: mysql
PostgreSQL: postgresql
system_git:
type: str
help: iTi-System Git 地址
default: https://git.noahlan.cn/iti-framework/iTi-System.git
system_tag:
type: str
help: iTi-System Git tag
default: v0.3.0

415
db.sql

@ -0,0 +1,415 @@
/*
Navicat Premium Dump SQL
Source Server : iti-flask
Source Server Type : SQLite
Source Server Version : 3045000 (3.45.0)
Source Schema : main
Target Server Type : SQLite
Target Server Version : 3045000 (3.45.0)
File Encoding : 65001
Date: 06/11/2025 09:42:35
*/
PRAGMA foreign_keys = false;
-- ----------------------------
-- Table structure for alembic_version
-- ----------------------------
DROP TABLE IF EXISTS "alembic_version";
CREATE TABLE "alembic_version" (
"version_num" VARCHAR(32) NOT NULL,
CONSTRAINT "alembic_version_pkc" PRIMARY KEY ("version_num")
);
-- ----------------------------
-- Records of alembic_version
-- ----------------------------
INSERT INTO "alembic_version" VALUES ('bfa0b0c7c62f');
-- ----------------------------
-- Table structure for sys_config
-- ----------------------------
DROP TABLE IF EXISTS "sys_config";
CREATE TABLE "sys_config" (
"type" VARCHAR(64) NOT NULL,
"name" VARCHAR(255) NOT NULL,
"code" VARCHAR(128) NOT NULL,
"value" TEXT,
"desc" TEXT,
"sort" INTEGER NOT NULL,
"status" VARCHAR(8) NOT NULL,
"id" VARCHAR(36) NOT NULL,
"created_at" DATETIME NOT NULL,
"updated_at" DATETIME NOT NULL,
"remark" VARCHAR(255),
CONSTRAINT "pk_sys_config" PRIMARY KEY ("id")
);
-- ----------------------------
-- Records of sys_config
-- ----------------------------
INSERT INTO "sys_config" VALUES ('SYSTEM', '默认用户密码', 'DEFAULT_USER_PASSWORD', '123456', '系统自动注册时使用的默认用户密码', 0, 'enabled', '268280549e0b4eca876d88f2cdb4829d', '2025-10-24 15:31:11.240970', '2025-10-24 15:40:25.996066', NULL);
INSERT INTO "sys_config" VALUES ('SYSTEM', '默认用户角色', 'DEFAULT_USER_ROLE', 'COMMON', '注册时使用的默认用户角色', 0, 'enabled', '07c661403d2b4bd58cf94111cd27fcb6', '2025-10-24 19:33:19.143726', '2025-10-24 19:33:49.664347', NULL);
-- ----------------------------
-- Table structure for sys_dept
-- ----------------------------
DROP TABLE IF EXISTS "sys_dept";
CREATE TABLE "sys_dept" (
"name" VARCHAR(255) NOT NULL,
"parent_id" VARCHAR(36),
"desc" TEXT,
"sort" INTEGER NOT NULL,
"leader_id" VARCHAR(36),
"status" VARCHAR(8) NOT NULL,
"id" VARCHAR(36) NOT NULL,
"created_at" DATETIME NOT NULL,
"updated_at" DATETIME NOT NULL,
"remark" VARCHAR(255),
CONSTRAINT "pk_sys_dept" PRIMARY KEY ("id")
);
-- ----------------------------
-- Records of sys_dept
-- ----------------------------
-- ----------------------------
-- Table structure for sys_dict_data
-- ----------------------------
DROP TABLE IF EXISTS "sys_dict_data";
CREATE TABLE "sys_dict_data" (
"type_code" VARCHAR(36) NOT NULL,
"label" VARCHAR(255) NOT NULL,
"code" VARCHAR(128) NOT NULL,
"value" TEXT,
"desc" TEXT,
"sort" INTEGER NOT NULL,
"status" VARCHAR(8) NOT NULL,
"id" VARCHAR(36) NOT NULL,
"created_at" DATETIME NOT NULL,
"updated_at" DATETIME NOT NULL,
"remark" VARCHAR(255),
CONSTRAINT "pk_sys_dict_data" PRIMARY KEY ("id")
);
-- ----------------------------
-- Records of sys_dict_data
-- ----------------------------
-- ----------------------------
-- Table structure for sys_dict_type
-- ----------------------------
DROP TABLE IF EXISTS "sys_dict_type";
CREATE TABLE "sys_dict_type" (
"type_name" VARCHAR(255) NOT NULL,
"type_code" VARCHAR(128) NOT NULL,
"desc" TEXT,
"sort" INTEGER NOT NULL,
"status" VARCHAR(8) NOT NULL,
"id" VARCHAR(36) NOT NULL,
"created_at" DATETIME NOT NULL,
"updated_at" DATETIME NOT NULL,
"remark" VARCHAR(255),
CONSTRAINT "pk_sys_dict_type" PRIMARY KEY ("id"),
CONSTRAINT "uq_sys_dict_type_type_code" UNIQUE ("type_code" ASC)
);
-- ----------------------------
-- Records of sys_dict_type
-- ----------------------------
-- ----------------------------
-- Table structure for sys_file
-- ----------------------------
DROP TABLE IF EXISTS "sys_file";
CREATE TABLE "sys_file" (
"filename" VARCHAR(255) NOT NULL,
"file_key" VARCHAR(512) NOT NULL,
"file_hash" VARCHAR(64),
"mime_type" VARCHAR(128),
"file_size" BIGINT NOT NULL,
"extension" VARCHAR(32),
"storage_type" VARCHAR(11) NOT NULL,
"directory_id" VARCHAR(36),
"metadata" JSON,
"status" VARCHAR(8) NOT NULL,
"id" VARCHAR(36) NOT NULL,
"created_at" DATETIME NOT NULL,
"updated_at" DATETIME NOT NULL,
"remark" VARCHAR(255),
"storage_info" JSON,
CONSTRAINT "pk_sys_file" PRIMARY KEY ("id")
);
-- ----------------------------
-- Records of sys_file
-- ----------------------------
-- ----------------------------
-- Table structure for sys_file_directory
-- ----------------------------
DROP TABLE IF EXISTS "sys_file_directory";
CREATE TABLE "sys_file_directory" (
"name" VARCHAR(255) NOT NULL,
"path" VARCHAR(1024) NOT NULL,
"parent_id" VARCHAR(36),
"level" INTEGER,
"sort" INTEGER,
"icon" VARCHAR(128),
"color" VARCHAR(32),
"description" TEXT,
"default_storage_type" VARCHAR(32),
"status" VARCHAR(8) NOT NULL,
"id" VARCHAR(36) NOT NULL,
"created_at" DATETIME NOT NULL,
"updated_at" DATETIME NOT NULL,
"remark" VARCHAR(255),
CONSTRAINT "pk_sys_file_directory" PRIMARY KEY ("id")
);
-- ----------------------------
-- Records of sys_file_directory
-- ----------------------------
-- ----------------------------
-- Table structure for sys_log
-- ----------------------------
DROP TABLE IF EXISTS "sys_log";
CREATE TABLE "sys_log" (
"name" VARCHAR(100),
"method" VARCHAR(10),
"user_id" VARCHAR(36),
"path" VARCHAR(255),
"ip" VARCHAR(255),
"user_agent" TEXT,
"headers" TEXT,
"query_params" TEXT,
"body_params" TEXT,
"execution_time" FLOAT,
"response" TEXT,
"exception" TEXT,
"success" BOOLEAN,
"desc" TEXT,
"type" VARCHAR(9) NOT NULL,
"id" VARCHAR(36) NOT NULL,
"created_at" DATETIME NOT NULL,
"updated_at" DATETIME NOT NULL,
"remark" VARCHAR(255),
CONSTRAINT "pk_sys_log" PRIMARY KEY ("id")
);
-- ----------------------------
-- Records of sys_log
-- ----------------------------
-- ----------------------------
-- Table structure for sys_menu
-- ----------------------------
DROP TABLE IF EXISTS "sys_menu";
CREATE TABLE "sys_menu" (
"name" VARCHAR(255) NOT NULL,
"type" VARCHAR(8) NOT NULL,
"path" VARCHAR(255),
"component" VARCHAR(255),
"redirect" VARCHAR(255),
"sort" INTEGER NOT NULL,
"meta" JSON,
"status" VARCHAR(8) NOT NULL,
"parent_id" VARCHAR(36),
"id" VARCHAR(36) NOT NULL,
"created_at" DATETIME NOT NULL,
"updated_at" DATETIME NOT NULL,
"remark" VARCHAR(255),
"auth_code" VARCHAR(128),
CONSTRAINT "pk_sys_menu" PRIMARY KEY ("id"),
CONSTRAINT "uq_sys_menu_name" UNIQUE ("name" ASC)
);
-- ----------------------------
-- Records of sys_menu
-- ----------------------------
INSERT INTO "sys_menu" VALUES ('System', 'catalog', '/system', NULL, NULL, 0, '{"title": "系统管理", "icon": "ion:settings-outline"}', 'enabled', NULL, 'b3af308711954e62ba7471891b82f721', '2025-10-21 23:55:43.860046', '2025-10-22 22:17:21.485867', NULL, NULL);
INSERT INTO "sys_menu" VALUES ('Menu', 'menu', '/system/menu', '/system/menu/list', NULL, 4, '{"title": "菜单管理", "icon": "carbon:menu", "badge": ""}', 'enabled', 'b3af308711954e62ba7471891b82f721', 'b3f689cf6d594f94aeed912bd5c7dd80', '2025-10-22 00:57:37.597497', '2025-10-23 20:53:20.815915', NULL, 'system:menu:list');
INSERT INTO "sys_menu" VALUES ('SystemMenuCreate', 'button', NULL, NULL, NULL, 0, '{"title": "common.create"}', 'enabled', 'b3f689cf6d594f94aeed912bd5c7dd80', '18a8a2fcc8704c94baf47590dd7eb2e8', '2025-10-22 01:02:49.027655', '2025-10-22 01:06:26.495546', NULL, 'system:menu:create');
INSERT INTO "sys_menu" VALUES ('SystemMenuEdit', 'button', '', NULL, NULL, 1, '{"title": "common.edit", "order": 1}', 'enabled', 'b3f689cf6d594f94aeed912bd5c7dd80', 'b41da6285e3f4bc4a39aa8ae13944b41', '2025-10-22 01:09:56.328675', '2025-10-22 01:09:56.328675', NULL, 'system:menu:edit');
INSERT INTO "sys_menu" VALUES ('SystemMenuDelete', 'button', '', NULL, NULL, 2, '{"title": "common.delete"}', 'enabled', 'b3f689cf6d594f94aeed912bd5c7dd80', 'a322ddada8ed4bcca1f5edcd9f3d33d0', '2025-10-22 17:22:43.797001', '2025-10-22 22:25:20.304793', NULL, 'system:menu:delete');
INSERT INTO "sys_menu" VALUES ('Role', 'menu', '/system/role', '/system/role/list', NULL, 2, '{"title": "角色管理", "icon": "carbon:user-role"}', 'enabled', 'b3af308711954e62ba7471891b82f721', '8ac71de8a14c413997f7f81f5fcf343c', '2025-10-22 18:17:00.515607', '2025-10-22 22:12:12.657640', NULL, 'system:role:list');
INSERT INTO "sys_menu" VALUES ('Dept', 'menu', '/system/dept', '/system/dept/list', NULL, 3, '{"title": "部门管理", "icon": "carbon:container-services", "order": 3}', 'enabled', 'b3af308711954e62ba7471891b82f721', '5d76b276594349b5bfbed42da656bd53', '2025-10-22 22:34:17.856575', '2025-10-22 22:34:17.856575', NULL, 'system:dept:list');
INSERT INTO "sys_menu" VALUES ('SystemRoleCreate', 'button', '', NULL, NULL, 0, '{"title": "common.create", "order": 0}', 'enabled', '8ac71de8a14c413997f7f81f5fcf343c', '9f71686949c7410ea032956c52252a06', '2025-10-23 19:29:59.390174', '2025-10-23 19:29:59.390174', NULL, 'system:role:create');
INSERT INTO "sys_menu" VALUES ('User', 'menu', '/system/user', '/system/user/list', NULL, 1, '{"title": "system.user.title", "icon": "carbon:user"}', 'enabled', 'b3af308711954e62ba7471891b82f721', '93e1c7448c144f60875c4725dfa93b5a', '2025-10-23 20:54:21.018914', '2025-10-23 20:54:26.845573', NULL, 'system:user:list');
INSERT INTO "sys_menu" VALUES ('SystemUserCreate', 'button', '', NULL, NULL, 0, '{"title": "common.create", "order": 0}', 'enabled', '93e1c7448c144f60875c4725dfa93b5a', 'c5d1be767bce409bafa141ec8e5fc419', '2025-10-23 20:55:06.630806', '2025-10-23 20:55:06.630806', NULL, 'system:user:create');
INSERT INTO "sys_menu" VALUES ('SysConfig', 'menu', '/system/config', '/system/config/list', NULL, 5, '{"title": "系统配置", "icon": "carbon:document-configuration", "order": 5}', 'enabled', 'b3af308711954e62ba7471891b82f721', '42a830108d9c49bca4e0108aba27a3cf', '2025-10-24 15:27:36.117071', '2025-10-24 15:27:36.117071', NULL, 'system:config:list');
INSERT INTO "sys_menu" VALUES ('SysLog', 'menu', '/system/log', '/system/log/list', NULL, 7, '{"title": "日志管理", "icon": "carbon:cloud-logging"}', 'enabled', 'b3af308711954e62ba7471891b82f721', '774da56753514b4eafc913162970f4f1', '2025-10-24 20:29:36.346378', '2025-10-25 01:35:29.107723', NULL, 'system:log:list');
INSERT INTO "sys_menu" VALUES ('SysDict', 'menu', '/system/dict', '/system/dict/list', NULL, 6, '{"title": "字典管理", "icon": "carbon:book"}', 'enabled', 'b3af308711954e62ba7471891b82f721', '50d583e8c8584d43ab94939420dce0cb', '2025-10-25 01:36:21.900891', '2025-10-25 01:36:31.834020', NULL, 'system:dict:list');
INSERT INTO "sys_menu" VALUES ('SystemUserUpdate', 'button', '', NULL, NULL, 1, '{"title": "common.edit"}', 'enabled', '93e1c7448c144f60875c4725dfa93b5a', '899b280630334766b01ff83f6f4ebacc', '2025-10-27 01:12:12.515044', '2025-10-27 01:25:16.091611', NULL, 'system:user:edit');
INSERT INTO "sys_menu" VALUES ('SystemUserDelete', 'button', '', NULL, NULL, 2, '{"title": "common.delete"}', 'enabled', '93e1c7448c144f60875c4725dfa93b5a', '2431b03462a84f90ba15c29bca07d39e', '2025-10-27 01:23:25.053116', '2025-10-27 01:25:08.135712', NULL, 'system:user:delete');
INSERT INTO "sys_menu" VALUES ('SystemUserResetPassword', 'button', '', NULL, NULL, 3, '{"title": "修改密码", "order": 3}', 'enabled', '93e1c7448c144f60875c4725dfa93b5a', '12cf74c410d044d986840c93fb70c397', '2025-10-27 01:24:07.291768', '2025-10-27 01:24:07.291768', NULL, 'system:user:resetpwd');
INSERT INTO "sys_menu" VALUES ('SystemRoleEdit', 'button', '', NULL, NULL, 1, '{"title": "common.edit", "order": 1}', 'enabled', '8ac71de8a14c413997f7f81f5fcf343c', 'bb271c537c604ee894a0339ced1b4d46', '2025-10-27 01:24:59.205557', '2025-10-27 01:24:59.205557', NULL, 'system:role:edit');
INSERT INTO "sys_menu" VALUES ('SystemRoleDelete', 'button', '', NULL, NULL, 2, '{"title": "common.delete", "order": 2}', 'enabled', '8ac71de8a14c413997f7f81f5fcf343c', 'a643f56b9a844c1eb8af7da1bd8e96da', '2025-10-27 01:57:43.514838', '2025-10-27 01:57:43.514838', NULL, 'system:role:delete');
INSERT INTO "sys_menu" VALUES ('SystemDeptCreate', 'button', '', NULL, NULL, 0, '{"title": "common.create", "order": 0}', 'enabled', '5d76b276594349b5bfbed42da656bd53', '7f2913a04e1b47c8bc590eee4708a147', '2025-10-27 01:58:39.635115', '2025-10-27 01:58:39.635115', NULL, 'system:dept:create');
INSERT INTO "sys_menu" VALUES ('SystemDeptEdit', 'button', '', NULL, NULL, 1, '{"title": "common.edit", "order": 1}', 'enabled', '5d76b276594349b5bfbed42da656bd53', '3e6e8ebda98c4e1aad27478d7bac595a', '2025-10-27 01:59:01.015198', '2025-10-27 01:59:01.015198', NULL, 'system:dept:edit');
INSERT INTO "sys_menu" VALUES ('SystemDeptDelete', 'button', '', NULL, NULL, 2, '{"title": "common.delete", "order": 2}', 'enabled', '5d76b276594349b5bfbed42da656bd53', 'b52bb4045a434253ab0af21d03603458', '2025-10-27 01:59:19.554391', '2025-10-27 01:59:19.554391', NULL, 'system:dept:delete');
INSERT INTO "sys_menu" VALUES ('SystemConfigCreate', 'button', '', NULL, NULL, 0, '{"title": "common.create", "order": 0}', 'enabled', '42a830108d9c49bca4e0108aba27a3cf', '57daf457de6546ab9e887233892e712e', '2025-10-27 02:00:20.184611', '2025-10-27 02:00:20.184611', NULL, 'system:config:create');
INSERT INTO "sys_menu" VALUES ('SystemConfigEdit', 'button', '', NULL, NULL, 1, '{"title": "common.edit", "order": 1}', 'enabled', '42a830108d9c49bca4e0108aba27a3cf', '5dc4a3d79f20496d82bc85f6eed1389b', '2025-10-27 02:00:32.153972', '2025-10-27 02:00:32.153972', NULL, 'system:config:edit');
INSERT INTO "sys_menu" VALUES ('SystemConfigDelete', 'button', '', NULL, NULL, 2, '{"title": "common.delete", "order": 2}', 'enabled', '42a830108d9c49bca4e0108aba27a3cf', 'bafe03a1da6c4224b69ecadd721cba0a', '2025-10-27 02:00:47.054089', '2025-10-27 02:00:47.054089', NULL, 'system:config:delete');
INSERT INTO "sys_menu" VALUES ('SystemDictCreate', 'button', '', NULL, NULL, 0, '{"title": "common.create", "order": 0}', 'enabled', '50d583e8c8584d43ab94939420dce0cb', '8c96ce0db0724139a9ab7fbcfcf52500', '2025-10-27 02:01:37.933764', '2025-10-27 02:01:37.933764', NULL, 'system:dict:create');
INSERT INTO "sys_menu" VALUES ('SystemDictEdit', 'button', '', NULL, NULL, 1, '{"title": "common.edit", "order": 1}', 'enabled', '50d583e8c8584d43ab94939420dce0cb', 'de5bcbdb81b34b02991614c13f12043a', '2025-10-27 02:01:51.081366', '2025-10-27 02:01:51.081366', NULL, 'system:dict:edit');
INSERT INTO "sys_menu" VALUES ('SystemDictDelete', 'button', '', NULL, NULL, 2, '{"title": "common.delete", "order": 2}', 'enabled', '50d583e8c8584d43ab94939420dce0cb', '3385698f84e44249ad5eb733d7232c96', '2025-10-27 02:02:08.500200', '2025-10-27 02:02:08.500200', NULL, 'system:dict:delete');
INSERT INTO "sys_menu" VALUES ('SystemLogDelete', 'button', '', NULL, NULL, 0, '{"title": "common.delete", "order": 0}', 'enabled', '774da56753514b4eafc913162970f4f1', '74e4cff0de2b4532a187ed01714d6577', '2025-10-27 02:02:42.658086', '2025-10-27 02:02:42.658086', NULL, 'system:log:delete');
-- ----------------------------
-- Table structure for sys_role
-- ----------------------------
DROP TABLE IF EXISTS "sys_role";
CREATE TABLE "sys_role" (
"name" VARCHAR(64) NOT NULL,
"code" VARCHAR(64) NOT NULL,
"desc" TEXT,
"sort" INTEGER NOT NULL,
"status" VARCHAR(8) NOT NULL,
"id" VARCHAR(36) NOT NULL,
"created_at" DATETIME NOT NULL,
"updated_at" DATETIME NOT NULL,
"remark" VARCHAR(255),
CONSTRAINT "pk_sys_role" PRIMARY KEY ("id"),
CONSTRAINT "uq_sys_role_code" UNIQUE ("code" ASC)
);
-- ----------------------------
-- Records of sys_role
-- ----------------------------
INSERT INTO "sys_role" VALUES ('管理员', 'ADMIN', '系统默认管理员', 0, 'enabled', 'a004a998dc534df5871938c9b6ef49b4', '2025-10-21 16:33:14.814688', '2025-10-22 21:56:31.505216', NULL);
INSERT INTO "sys_role" VALUES ('普通角色', 'COMMON', '一般角色', 0, 'enabled', 'df2dd55653b9486681e9369d48e1c833', '2025-10-24 14:25:18.661302', '2025-10-24 19:33:29.397382', NULL);
-- ----------------------------
-- Table structure for sys_role_menu
-- ----------------------------
DROP TABLE IF EXISTS "sys_role_menu";
CREATE TABLE "sys_role_menu" (
"role_id" VARCHAR(36) NOT NULL,
"menu_id" VARCHAR(36) NOT NULL,
CONSTRAINT "pk_sys_role_menu" PRIMARY KEY ("role_id", "menu_id")
);
-- ----------------------------
-- Records of sys_role_menu
-- ----------------------------
INSERT INTO "sys_role_menu" VALUES ('a004a998dc534df5871938c9b6ef49b4', 'b3f689cf6d594f94aeed912bd5c7dd80');
INSERT INTO "sys_role_menu" VALUES ('a004a998dc534df5871938c9b6ef49b4', '18a8a2fcc8704c94baf47590dd7eb2e8');
INSERT INTO "sys_role_menu" VALUES ('a004a998dc534df5871938c9b6ef49b4', 'b3af308711954e62ba7471891b82f721');
INSERT INTO "sys_role_menu" VALUES ('a004a998dc534df5871938c9b6ef49b4', 'b41da6285e3f4bc4a39aa8ae13944b41');
INSERT INTO "sys_role_menu" VALUES ('a004a998dc534df5871938c9b6ef49b4', '5d76b276594349b5bfbed42da656bd53');
INSERT INTO "sys_role_menu" VALUES ('a004a998dc534df5871938c9b6ef49b4', '8ac71de8a14c413997f7f81f5fcf343c');
INSERT INTO "sys_role_menu" VALUES ('a004a998dc534df5871938c9b6ef49b4', 'a322ddada8ed4bcca1f5edcd9f3d33d0');
INSERT INTO "sys_role_menu" VALUES ('a004a998dc534df5871938c9b6ef49b4', '93e1c7448c144f60875c4725dfa93b5a');
INSERT INTO "sys_role_menu" VALUES ('a004a998dc534df5871938c9b6ef49b4', '9f71686949c7410ea032956c52252a06');
INSERT INTO "sys_role_menu" VALUES ('a004a998dc534df5871938c9b6ef49b4', 'c5d1be767bce409bafa141ec8e5fc419');
INSERT INTO "sys_role_menu" VALUES ('a004a998dc534df5871938c9b6ef49b4', '899b280630334766b01ff83f6f4ebacc');
INSERT INTO "sys_role_menu" VALUES ('a004a998dc534df5871938c9b6ef49b4', '12cf74c410d044d986840c93fb70c397');
INSERT INTO "sys_role_menu" VALUES ('a004a998dc534df5871938c9b6ef49b4', '2431b03462a84f90ba15c29bca07d39e');
INSERT INTO "sys_role_menu" VALUES ('a004a998dc534df5871938c9b6ef49b4', '3e6e8ebda98c4e1aad27478d7bac595a');
INSERT INTO "sys_role_menu" VALUES ('a004a998dc534df5871938c9b6ef49b4', '7f2913a04e1b47c8bc590eee4708a147');
INSERT INTO "sys_role_menu" VALUES ('a004a998dc534df5871938c9b6ef49b4', 'a643f56b9a844c1eb8af7da1bd8e96da');
INSERT INTO "sys_role_menu" VALUES ('a004a998dc534df5871938c9b6ef49b4', 'b52bb4045a434253ab0af21d03603458');
INSERT INTO "sys_role_menu" VALUES ('a004a998dc534df5871938c9b6ef49b4', 'bb271c537c604ee894a0339ced1b4d46');
INSERT INTO "sys_role_menu" VALUES ('a004a998dc534df5871938c9b6ef49b4', '50d583e8c8584d43ab94939420dce0cb');
INSERT INTO "sys_role_menu" VALUES ('a004a998dc534df5871938c9b6ef49b4', '8c96ce0db0724139a9ab7fbcfcf52500');
INSERT INTO "sys_role_menu" VALUES ('a004a998dc534df5871938c9b6ef49b4', 'de5bcbdb81b34b02991614c13f12043a');
INSERT INTO "sys_role_menu" VALUES ('a004a998dc534df5871938c9b6ef49b4', '3385698f84e44249ad5eb733d7232c96');
INSERT INTO "sys_role_menu" VALUES ('a004a998dc534df5871938c9b6ef49b4', '74e4cff0de2b4532a187ed01714d6577');
INSERT INTO "sys_role_menu" VALUES ('a004a998dc534df5871938c9b6ef49b4', '774da56753514b4eafc913162970f4f1');
INSERT INTO "sys_role_menu" VALUES ('a004a998dc534df5871938c9b6ef49b4', '42a830108d9c49bca4e0108aba27a3cf');
INSERT INTO "sys_role_menu" VALUES ('a004a998dc534df5871938c9b6ef49b4', '57daf457de6546ab9e887233892e712e');
INSERT INTO "sys_role_menu" VALUES ('a004a998dc534df5871938c9b6ef49b4', '5dc4a3d79f20496d82bc85f6eed1389b');
INSERT INTO "sys_role_menu" VALUES ('a004a998dc534df5871938c9b6ef49b4', 'bafe03a1da6c4224b69ecadd721cba0a');
INSERT INTO "sys_role_menu" VALUES ('df2dd55653b9486681e9369d48e1c833', '12cf74c410d044d986840c93fb70c397');
INSERT INTO "sys_role_menu" VALUES ('df2dd55653b9486681e9369d48e1c833', '42a830108d9c49bca4e0108aba27a3cf');
INSERT INTO "sys_role_menu" VALUES ('df2dd55653b9486681e9369d48e1c833', '50d583e8c8584d43ab94939420dce0cb');
INSERT INTO "sys_role_menu" VALUES ('df2dd55653b9486681e9369d48e1c833', '93e1c7448c144f60875c4725dfa93b5a');
INSERT INTO "sys_role_menu" VALUES ('df2dd55653b9486681e9369d48e1c833', 'b3f689cf6d594f94aeed912bd5c7dd80');
INSERT INTO "sys_role_menu" VALUES ('df2dd55653b9486681e9369d48e1c833', '5d76b276594349b5bfbed42da656bd53');
INSERT INTO "sys_role_menu" VALUES ('df2dd55653b9486681e9369d48e1c833', '8ac71de8a14c413997f7f81f5fcf343c');
-- ----------------------------
-- Table structure for sys_user
-- ----------------------------
DROP TABLE IF EXISTS "sys_user";
CREATE TABLE "sys_user" (
"username" VARCHAR(64) NOT NULL,
"phone" VARCHAR(13),
"email" VARCHAR(255),
"password" VARCHAR(255) NOT NULL,
"realname" VARCHAR(32),
"avatar" VARCHAR(255),
"gender" VARCHAR(6) NOT NULL,
"status" VARCHAR(8) NOT NULL,
"id" VARCHAR(36) NOT NULL,
"created_at" DATETIME NOT NULL,
"updated_at" DATETIME NOT NULL,
"desc" TEXT,
"remark" VARCHAR(255),
CONSTRAINT "pk_sys_user" PRIMARY KEY ("id")
);
-- ----------------------------
-- Records of sys_user
-- ----------------------------
INSERT INTO "sys_user" VALUES ('admin', '18888888888', 'a@a.com', 'pbkdf2:sha256:1000000$Jj8MpincNbxUZEW2$6314c44844a54a984fe7e029433b0b9a64ca03f0765a8e95abaf49270a8aaa27', '管理员', '', 'secure', 'enabled', 'a0edff513103461aa2dfd62b9351b8c2', '2025-10-20 13:39:51.423488', '2025-10-23 23:54:44.902540', '啊啊啊啊啊啊', NULL);
-- ----------------------------
-- Table structure for sys_user_dept
-- ----------------------------
DROP TABLE IF EXISTS "sys_user_dept";
CREATE TABLE "sys_user_dept" (
"user_id" VARCHAR(36) NOT NULL,
"dept_id" VARCHAR(36) NOT NULL,
CONSTRAINT "pk_sys_user_dept" PRIMARY KEY ("user_id", "dept_id")
);
-- ----------------------------
-- Records of sys_user_dept
-- ----------------------------
-- ----------------------------
-- Table structure for sys_user_role
-- ----------------------------
DROP TABLE IF EXISTS "sys_user_role";
CREATE TABLE "sys_user_role" (
"user_id" VARCHAR(36) NOT NULL,
"role_id" VARCHAR(36) NOT NULL,
CONSTRAINT "pk_sys_user_role" PRIMARY KEY ("user_id", "role_id")
);
-- ----------------------------
-- Records of sys_user_role
-- ----------------------------
INSERT INTO "sys_user_role" VALUES ('a0edff513103461aa2dfd62b9351b8c2', 'a004a998dc534df5871938c9b6ef49b4');
-- ----------------------------
-- Indexes structure for table sys_file
-- ----------------------------
CREATE INDEX "ix_sys_file_directory_id"
ON "sys_file" (
"directory_id" ASC
);
CREATE INDEX "ix_sys_file_file_hash"
ON "sys_file" (
"file_hash" ASC
);
CREATE UNIQUE INDEX "ix_sys_file_file_key"
ON "sys_file" (
"file_key" ASC
);
-- ----------------------------
-- Indexes structure for table sys_file_directory
-- ----------------------------
CREATE INDEX "ix_sys_file_directory_path"
ON "sys_file_directory" (
"path" ASC
);
PRAGMA foreign_keys = true;

@ -1,124 +0,0 @@
# 架构
iTi-Flask 是 FastAPI 框架基座。
它约束业务项目的工程方式,但不接管业务代码。
## 分层
- `iti.app`:应用工厂,组装 FastAPI、错误处理、模块、服务客户端、任务 runner、健康检查。
- `iti.config`dataclass 配置,默认 MySQL可通过 `DATABASE_DIALECT=postgresql` 使用 PostgreSQL。
- `iti.db`SQLAlchemy 2 `Base`、session、Alembic metadata。
- `iti.auth`JWT、Principal、Actor、用户权限依赖、服务 token 依赖。
- `iti.responses`:自动 envelope、`@raw_response`、响应工具。
- `iti.modules`:模块协议、权限元数据、菜单 seed 元数据。
- `iti.service_client`:同步 HTTP JSON 服务客户端。
- `iti.tasks`:单进程轻量任务注册和调度。
- `iti.audit`审计事件、diff、脱敏和异步 HTTP sender不拥有日志表。
- `iti.logging_config`:运行日志配置。
- `iti.cache`、`iti.limiter`、`iti.events`:常用通用能力。
- `iti.health``/health` 和 `/ready`
## 应用创建流程
`create_app()` 按顺序执行:
1. 解析配置。
2. 创建 `FastAPI`
3. 写入 `app.state.config/cache/limiter/permission_provider`
4. 配置 SQLAlchemy engine 和 sessionmaker。
5. 注册中间件、错误处理和运行日志。
6. 初始化服务客户端。
7. 初始化任务 runner。
8. 初始化审计 sender。
9. 初始化模块并运行 `init_app`、`register_tasks`。
10. 注册健康检查。
11. 注册模块路由、权限元数据和菜单 seed 元数据。
12. 安装自动 envelope。
## API 约定
业务 JSON API 默认返回 HTTP 200 envelope
```json
{"data": {}, "code": 200, "message": "成功"}
```
业务错误也包装进 envelope
```json
{"data": null, "code": 403, "message": "权限不足"}
```
成功时 `code` 固定为 `200`
业务错误、参数错误、框架 HTTP 错误都保留原始业务码,例如 `403`、`404`、`405`、`429`。
框架默认提供 JSON 错误响应,不提供服务端 HTML 错误页。
服务间 API 也默认使用 envelope。
框架服务客户端会自动识别 envelope 和 HTTP status。
如果响应体是 envelope则优先按 `code` 判断业务结果。
框架仍允许 raw 响应:
- `@raw_response`
- `raw_response_paths`
- `Response` / `FileResponse` / `StreamingResponse`
默认 raw
- `/health`
- `/ready`
- `/docs`
- `/openapi.json`
`/docs` 是文档入口。
它按 `docs_ui_enabled` 展示已启用的文档 UI。
默认包含 Swagger、Scalar 和 ReDoc。
`/openapi.json` 会按 tag 前缀生成 `x-tagGroups`
例如 `system.user` 会归入 `system` 分组。
## 鉴权
用户接口使用 JWT。
服务间调用使用静态服务 token。
双入口接口使用 `require_actor()`
- 用户 token 走权限码。
- 服务 token 只校验可信服务。
- 任一通过即可。
## 审计
`iti-flask` 只产生和发送审计事件。
它不写 `sys_log`
审计事件通过 `service_client` 发到配置的 `audit_service_name`
当前常见接收方是注册了 `iti-system` 的主业务项目。
审计发送默认异步、不阻塞业务。
diff 由业务显式提供 before / after 快照。
## 运行日志
运行日志由框架配置。
默认控制台输出。
生产可开启滚动文件:
- `runtime/logs/app.log`
- `runtime/logs/error.log`
请求摘要默认写入 app log。
摘要包含 method、path、HTTP status、业务 code、duration、actor、ip 和 trace id。
## 系统包边界
`iti-system` 只承载系统域业务:
- auth。
- user / role / menu / dept。
- config / dict。
- file / upload。
- log 查询和审计接收。
- user attributes。
业务项目单独使用 `iti-flask` 时,仍可使用鉴权依赖、权限 provider、数据库、迁移、缓存、限流、事件和任务。
加入 `iti-system` 时,只替换权限 provider 并增加系统域路由。

@ -1,76 +0,0 @@
# 审计
iTi-Flask 只提供审计事件工具和异步发送器。
它不写 `sys_log`
`sys_log` 由注册了 `iti-system` 的业务项目接收并入库。
## 配置
```python
class DevConfig(BaseDevConfig):
def __init__(self) -> None:
super().__init__()
self.audit_enabled = True
self.audit_service_name = "audit"
self.services = {
"audit": {
"base_url": "http://business-app.local",
"token": "change-me",
}
}
```
接收方需要把同一个 token 配进 `service_tokens`
```python
self.service_tokens = {"internal": "change-me"}
```
## 操作日志
业务显式提供 before / after 快照。
框架负责 diff、脱敏和异步发送。
```python
from fastapi import Request
from iti.audit import audit_operation
def update_order(order_id: str, request: Request):
before = {"qty": 1}
after = {"qty": 2}
audit_operation(
request,
title="修改生产订单",
target_type="mo",
target_id=order_id,
before=before,
after=after,
)
```
## 登录日志
系统包登录接口已调用 `audit_login()`
普通业务项目如需自定义登录,也使用同一个工具。
```python
from iti.audit import audit_login
def login(request):
audit_login(request, success=True, desc="admin")
```
## 接收入口
`iti-system` 提供:
```http
POST /internal/audit/events
POST /internal/audit/login
POST /internal/audit/operation
```
这些接口使用框架服务 token 鉴权。

@ -1,112 +0,0 @@
# 配置
配置类是 dataclass。
默认环境是 `dev`
## 环境选择
```bash
APP_ENV=dev uv run uvicorn main:app --reload
APP_ENV=prod uv run uvicorn main:app
```
也可以显式传入:
```python
from iti import create_app
app = create_app(config_name="test", config_mapping=config)
```
## 默认数据库
dev/test/prod 默认都使用 MySQL。
设置 `DATABASE_DIALECT=postgresql` 时,默认连接串改为 PostgreSQL。
`DATABASE_URL` 始终优先。
```text
mysql+pymysql://root:password@127.0.0.1:3306/iti_dev?charset=utf8mb4
mysql+pymysql://root:password@127.0.0.1:3306/iti_test?charset=utf8mb4
mysql+pymysql://root:password@127.0.0.1:3306/iti_prod?charset=utf8mb4
postgresql+psycopg://postgres:password@127.0.0.1:5432/iti_dev
```
可用环境变量覆盖:
```bash
DATABASE_URL=mysql+pymysql://user:pass@host:3306/app?charset=utf8mb4
DATABASE_DIALECT=mysql
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_USER=root
MYSQL_PASSWORD=password
MYSQL_DATABASE=iti_dev
DATABASE_DIALECT=postgresql
POSTGRES_HOST=127.0.0.1
POSTGRES_PORT=5432
POSTGRES_USER=postgres
POSTGRES_PASSWORD=password
POSTGRES_DB=iti_dev
```
单元测试可以传入 SQLite 配置。
这不改变默认配置。
## env 文件
框架会从当前目录加载第一个存在的文件:
1. `.env.local`
2. `.env.<APP_ENV>`
3. `.env`
`APP_ENV_DIR` 可指定查找目录。
## 常用字段
| 字段 | 说明 |
| --- | --- |
| `app_name` | FastAPI 标题 |
| `debug` | debug 标记 |
| `secret_key` | 框架密钥 |
| `jwt_secret_key` | JWT 密钥 |
| `database_url` | SQLAlchemy URL |
| `cors_origins` | CORS origin 列表 |
| `health_enabled` | 是否注册 `/health``/ready` |
| `ready_check_db` | `/ready` 是否执行 DB ping |
| `response_envelope_http_status` | envelope 响应使用的 HTTP 状态码,默认 200 |
| `response_envelope_enabled` | 是否启用自动 envelope |
| `raw_response_paths` | 跳过自动 envelope 的路径,支持 `*`,默认包含 `/health`、`/ready`、`/docs`、`/openapi.json` |
| `docs_ui_enabled` | `/docs` 选择页展示的文档 UI默认 `swagger`、`scalar`、`redoc` |
| `ratelimit_enabled` | 是否启用内存限流 |
| `cache_default_timeout` | 默认缓存秒数 |
| `file_storage` | 文件存储配置 |
| `services` | 服务客户端配置 |
| `service_tokens` | 可信服务 token |
| `tasks_enabled` | 是否启动单机任务调度线程 |
| `exchange_enabled` | 是否默认自动挂载本地交换模块 |
| `exchange_default_storage` | 导入导出默认文件存储类型 |
| `exchange_storage` | 导入导出文件存储配置 |
| `log_dir` | 运行日志目录 |
| `log_file_enabled` | 是否写滚动日志文件 |
| `audit_enabled` | 是否发送审计事件 |
| `audit_service_name` | 审计接收服务名,默认 `audit` |
## 业务项目覆盖
```python
from dataclasses import dataclass
from iti.config import DevConfig as BaseDevConfig
class DevConfig(BaseDevConfig):
def __init__(self) -> None:
super().__init__()
self.app_name = "my-app"
self.ready_check_db = True
config = {"dev": DevConfig, "default": DevConfig}
```

@ -1,167 +0,0 @@
# Copier 模板
当前框架仓库根目录是 Copier 模板源。
`copier.yml` 通过 `_subdirectory: copier-template` 指向实际模板目录。
`copier-template` 生成 FastAPI 业务后端骨架。
模板只引用框架包,不复制框架源码。
模板会为生成项目渲染项目 skill。
生成后的路径是:
```text
.codex/skills/<project-slug>-project/SKILL.md
```
模板源文件是:
```text
copier-template/.codex/skills/{{ project_slug | lower | replace('_', '-') }}-project/SKILL.md.jinja
```
模板结构、脚本命令或模块约定变化时,同步更新该模板 skill。
现有模板生成项目需要按当前项目名刷新自己的 skill。
## 生成
```bash
iticli create ../my-business-app
iticli create --database postgresql ../my-postgres-app
cd ../my-business-app
cp .env.example .env
iticli init
iticli run dev 8000
```
Windows
```bat
iticli create ..\my-business-app
cd ..\my-business-app
iticli init
iticli run dev 8000
```
## 参数
| 参数 | 说明 |
| --- | --- |
| `project_name` | 业务项目显示名称 |
| `project_slug` | 业务项目发行包名来源 |
| `framework_git` | iTi-Flask Git 地址 |
| `framework_tag` | iTi-Flask Git tag |
| `include_system` | 是否引入 iTi-System |
| `database_dialect` | 默认数据库类型,`mysql` 或 `postgresql` |
| `system_git` | iTi-System Git 地址 |
| `system_tag` | iTi-System Git tag |
默认推荐私有 Git tag。
允许改成 branch 或 `file://`,但多人协作项目不建议依赖本机路径。
## 生成内容
- `main.py`
- `app/app_factory.py`
- `config.py`
- `pyproject.toml`
- `migrations/`
- `Dockerfile`
- `docker-compose.yml`
- `docker-compose.with-db.yml`
- `.dockerignore`
- `.vscode/launch.json`
- `.env.example`
- 示例 FastAPI 模块
- 示例 SQLAlchemy 模型
- 示例测试
## 系统业务
选择 `include_system=true` 时,模板会注册 `create_system_module()`
生成后执行:
```bash
iticli init system
```
Windows
```bat
iticli init system
```
## 命令
常用命令:
```bash
iticli help
iticli install
iticli test
iticli run dev 8000
iticli docker up
iticli docker up --db
iticli docker logs
iticli docker down
iticli docker down --db
iticli migrate revision "alice add order table"
iticli migrate
```
`iticli migrate` 在业务项目内使用项目自己的 `migrations/alembic.ini`
`iti-system` 的项目还会有:
```bash
iticli update system
iticli init system
```
## 同步更新
业务项目同步框架依赖:
```bash
iticli update framework
```
该命令会更新 `pyproject.toml` 中的框架 tag并执行 `uv sync --upgrade-package iti-flask`
需要同时升级框架和系统包时使用 `iticli update all`
业务项目检查模板版本:
```bash
iticli template check
```
该命令执行 `uvx copier update --defaults --pretend --vcs-ref HEAD`
业务项目按模板更新项目骨架:
```bash
iticli template update
```
该命令执行 `uvx copier update --defaults --vcs-ref HEAD`
它依赖 `.copier-answers.yml` 里的 `_src_path``_commit`
模板更新前,业务项目工作区必须干净。
执行后检查 diff再运行测试。
模板拥有的文件包括 `main.py`、`app/app_factory.py`、`config.py`、`Dockerfile`、`docker-compose.yml`、`docker-compose.with-db.yml`、`.dockerignore`、`.env.example`、`.vscode/launch.json`、`pyproject.toml`、`migrations/`、示例模块、示例测试、README 和项目 skill。
业务项目自己的模块、模型、API 文档和业务 README 由业务项目维护。
## Docker
模板生成:
- `Dockerfile`:基于 `python:3.11-slim`,使用 `uv sync --frozen --no-dev` 安装运行依赖。
- `docker-compose.yml`:启动应用,默认连接外部数据库。
- `docker-compose.with-db.yml`:按 `database_dialect` 叠加启动 MySQL 8.4 或 PostgreSQL 16。
- `.env.example`Compose 和本地运行共用的环境变量样例。
- `.dockerignore`:排除虚拟环境、运行数据和本地密钥。
应用容器启动时会执行业务 migration。
`iti-system` 的项目还会先同步 system migration并执行 system seed。
## VSCode
模板生成 `.vscode/launch.json`
默认调试配置以 `uvicorn main:app --reload` 启动,使用 `APP_ENV=dev`

@ -1,121 +0,0 @@
# 模板与导入导出
iTi-Flask 提供模板中心、模板文件、导入导出任务和业务规格注册能力。
具体业务字段、变量含义和执行逻辑由业务模块提供。
## 业务范围
一个导入导出能力由三段业务键定位:
- `biz_domain`:业务域,例如 `system`、`mes`、`qos`。
- `biz_obj`:业务对象,例如 `user`、`work_order`。
- `operation`:操作,目前是 `import``export`
模板编码默认由这三段生成:
```text
system.user.import
```
前端可以调用 `/exchange/templates/code` 生成,也可以创建模板时不传 `code`,后端自动生成。
## 业务规格
模板变量不是运维动态维护的字典。
变量由业务模块注册,模板中心只展示和保存版本快照。
```python
from iti.exchange import (
ExchangeBusinessSpec,
ExchangeOperation,
ExchangeScope,
ExchangeTemplateLayout,
ExchangeTaskResult,
ExchangeVariable,
register_exchange_spec,
)
def import_users(context):
return ExchangeTaskResult(success_count=1)
def register_tasks(self, app):
register_exchange_spec(
app,
ExchangeBusinessSpec(
scope=ExchangeScope("system", "user", ExchangeOperation.IMPORT),
name="用户导入",
description="导入系统用户",
layout=ExchangeTemplateLayout(title="用户导入", sheet_name="用户", header_row=2),
variables=(
ExchangeVariable(key="username", label="用户名", required=True, example="alice"),
ExchangeVariable(key="mobile", label="手机号"),
),
),
handler=import_users,
)
```
前端维护模板时,先从 `/exchange/catalog` 聚合查询业务范围。
选定范围后,页面展示该范围的变量、示例和使用方式。
## 模板
模板记录保存:
- `code`
- `name`
- `biz_domain`
- `biz_obj`
- `operation`
- `layout`
- `current_version`
- `status`
`layout` 只保存解析需要的标记,例如 `title_row`、`header_row`、`data_start_row`。
样式属于 Excel 文件本身,不进入导入导出数据模型。
版本记录保存:
- 模板文件位置
- 校验值
- 布局快照
- 变量快照
变量快照用于保证历史版本可复现。
业务模块改了变量定义,不会影响已发布版本。
## 执行
创建任务时传业务范围和可选模板版本。
框架解析模板计划后创建 `exchange_tasks`
调用 `/exchange/tasks/{task_id}/run` 时,框架按业务范围找到注册的 handler并把 `ExchangeTaskContext` 交给业务模块。
框架只管理任务状态、行结果和文件读写。
业务模块负责真实导入、导出、校验、回执和业务事务。
## Source
模板计划可来自:
- 本地模板中心:默认。
- 远程模板中心:`sourceKind=remote`。
- 自定义 source注册 `register_exchange_source()`
- 纯映射输入:显式 `sourceKind=mapping`
## 主要对象
- `ExchangeBusinessSpec`
- `ExchangeScope`
- `ExchangeOperation`
- `ExchangeVariable`
- `ExchangeTemplateLayout`
- `ExchangeTemplatePlan`
- `ExchangeTemplateSnapshot`
- `ExchangeTaskContext`
- `ExchangeTaskResult`
- `ExchangeSource`
Excel 数据处理走 `pandas`
模板文件生成和上传解析走 `openpyxl`

@ -1,189 +0,0 @@
# 前端管理端接口契约
本轮不改前端。
这份文档用于后续管理端接口适配。
## 响应包装
管理端 API 默认 HTTP 200。
成功:
```json
{"data": {}, "code": 200, "message": "成功"}
```
失败:
```json
{"data": null, "code": 403, "message": "权限不足"}
```
字段输出使用 camelCase。
## 认证
登录:
```http
POST /auth/loginByPassword
POST /auth/loginByCode
POST /auth/register
POST /auth/refresh
POST /auth/logout
GET /auth/codes
```
登录响应核心字段:
```json
{
"accessToken": "...",
"tokenType": "Bearer",
"expiresIn": 86400,
"refreshToken": "...",
"refreshExpiresIn": 2592000,
"user": {}
}
```
后续请求:
```http
Authorization: Bearer <accessToken>
```
## 系统接口
用户:
```http
GET /sys/user/current
GET /sys/user/list
GET /sys/user/page
POST /sys/user
PUT /sys/user/{id}
DELETE /sys/user/{id}
PUT /sys/user/password
```
角色:
```http
GET /sys/role/list
GET /sys/role/page
POST /sys/role
PUT /sys/role/{id}
DELETE /sys/role/{id}
```
菜单:
```http
GET /sys/menu/list
GET /sys/menu/tree
GET /sys/menu/exists
POST /sys/menu
PUT /sys/menu/{id}
DELETE /sys/menu/{id}
```
部门:
```http
GET /sys/dept/list
GET /sys/dept/page
GET /sys/dept/tree
POST /sys/dept
PUT /sys/dept/{id}
DELETE /sys/dept/{id}
```
配置:
```http
GET /sys/config/list
GET /sys/config/page
POST /sys/config
PUT /sys/config/{id}
DELETE /sys/config/{id}
```
字典:
```http
GET /sys/dict/type/page
GET /sys/dict/type
POST /sys/dict/type
PUT /sys/dict/type/{id}
DELETE /sys/dict/type/{id}
GET /sys/dict/data/page
GET /sys/dict/data/list
GET /sys/dict/data/{id}
GET /sys/dict/data
POST /sys/dict/data
PUT /sys/dict/data/{id}
DELETE /sys/dict/data/{id}
DELETE /sys/dict/data/batch
```
日志:
```http
GET /sys/log/page
DELETE /sys/log/{id}
DELETE /sys/log/batch
```
文件:
```http
POST /upload
POST /upload/chunk/init
POST /upload/chunk/upload
POST /upload/chunk/merge
DELETE /upload/chunk/{uploadId}
GET /upload/chunk/{uploadId}/progress
POST /upload/chunk/cleanup
GET /sys/file/{fileId}
DELETE /sys/file/{fileId}
POST /sys/file/{fileId}/restore
DELETE /sys/file/{fileId}/permanent
POST /sys/file/{fileId}/share
DELETE /sys/file/{fileId}/share
GET /file/{fileId}/download
GET /file/{fileId}/preview
GET /file/{fileId}/thumbnail
GET /file/share/{shareCode}
GET /file/share/{shareCode}/download
```
用户扩展属性:
```http
GET /sys/user-attributes/current
PUT /sys/user-attributes/current
GET /sys/user-attributes/{userId}
PUT /sys/user-attributes/{userId}
GET /sys/user-attributes/{userId}/{group}/{key}
PUT /sys/user-attributes/{userId}/{group}/{key}
DELETE /sys/user-attributes/{userId}/{group}
DELETE /sys/user-attributes/{userId}/{group}/{key}
POST /sys/user-attributes/{userId}/batch
```
## 分页
分页响应:
```json
{
"items": [],
"page": {
"page": 1,
"size": 10,
"pages": 1,
"total": 0
}
}
```

@ -0,0 +1,871 @@
# HTTP 响应包装工具使用指南
## 📖 概述
本工具库提供统一的 API 响应格式包装函数和分页工具,基于 APIFlask 框架设计,简化 API 开发流程。
**核心特性:**
- ✅ 统一返回格式:`{data, code, message}`
- ✅ 智能分页支持:自动识别 SQLAlchemy Pagination 对象
- ✅ 灵活调用方式:支持多种参数传递方式
- ✅ 完整类型提示TypeScript 级别的类型安全
- ✅ 参考 APIFlask 标准:与框架保持一致
---
## 📦 安装位置
```
src/applications/common/utils/http.py
```
---
## 🎯 核心函数
### 1. `success()` - 成功响应
**签名:**
```python
def success(data: Any = None, message: str = '成功', code: int = 200) -> dict
```
**参数:**
- `data`:返回数据(任意类型)
- `message`:提示信息(默认 `'成功'`
- `code`:业务状态码(默认 `200`
**返回格式:**
```json
{
"data": <返回数据>,
"code": 200,
"message": "成功"
}
```
**示例:**
```python
from iti.applications.common.utils import success
from iti.applications.extensions.http import BaseResponse
@app.get('/api/users/<int:user_id>')
@app.output(BaseResponse)
def get_user(user_id):
user = User.query.get(user_id)
if not user:
return fail('用户不存在', code=404)
# 返回单个对象
return success({
'id': user.id,
'name': user.name,
'email': user.email
})
@app.get('/api/products')
@app.output(BaseResponse)
def get_products():
products = Product.query.limit(10).all()
# 返回列表
return success(
[{'id': p.id, 'name': p.name} for p in products],
message='获取产品列表成功'
)
```
---
### 2. `fail()` - 失败响应
**签名:**
```python
def fail(message: str = '操作失败', code: int = 500, data: Any = None) -> dict
```
**参数:**
- `message`:错误信息
- `code`:业务错误码(默认 `500`
- `data`:额外数据(如验证错误详情)
**特点:**
- ⚠️ **HTTP 状态码保持 200**,由前端根据 `code` 字段判断业务状态
- 💡 适用于统一错误处理,避免 HTTP 层面的错误拦截
**返回格式:**
```json
{
"data": null,
"code": 500,
"message": "操作失败"
}
```
**示例:**
```python
from iti.applications.common.utils import fail
@app.post('/api/users')
@app.output(BaseResponse)
def create_user():
username = request.json.get('username')
# 参数验证失败
if not username:
return fail('用户名不能为空', code=400)
# 资源不存在
if User.query.filter_by(username=username).first():
return fail('用户名已存在', code=409)
# 服务器错误
try:
user = User(username=username)
db.session.add(user)
db.session.commit()
except Exception as e:
return fail(f'创建失败: {str(e)}', code=500)
return success(user_to_dict(user), message='创建成功')
@app.post('/api/login')
@app.output(BaseResponse)
def login():
data = request.json
# 验证失败,返回详细错误
errors = validate_login(data)
if errors:
return fail(
message='验证失败',
code=422,
data=errors # {'username': ['必填项'], 'password': ['长度不足']}
)
# ... 登录逻辑
```
---
### 3. `page()` - 分页响应
**签名:**
```python
def page(
items: Union[list, Any],
pagination: Union[dict, Any, None] = None,
message: str = '成功',
code: int = 200
) -> dict
```
**支持三种调用方式:**
#### **方式 1传入 SQLAlchemy Pagination 对象(最简)**
```python
from iti.applications.common.utils import page
@app.get('/api/users')
@app.output(BaseResponse)
def get_users():
page_num = request.args.get('page', 1, type=int)
page_size = request.args.get('size', 10, type=int)
# SQLAlchemy 分页查询
db_pagination = User.query.paginate(page=page_num, per_page=page_size)
# ✅ 直接传入 Pagination 对象,自动解析
return page(db_pagination, message='获取用户列表成功')
```
**返回格式:**
```json
{
"data": {
"items": [
{"id": 1, "name": "Alice"},
{"id": 2, "name": "Bob"}
],
"page": {
"page": 1,
"size": 10,
"pages": 10,
"total": 100,
"current": "http://api.example.com/api/users?page=1&size=10",
"next": "http://api.example.com/api/users?page=2&size=10",
"prev": null,
"first": "http://api.example.com/api/users?page=1&size=10",
"last": "http://api.example.com/api/users?page=10&size=10"
}
},
"code": 200,
"message": "获取用户列表成功"
}
```
---
#### **方式 2传入数据列表 + Pagination 对象**
适用于需要先序列化数据的场景:
```python
from iti.applications.common.utils import page
from iti.applications.schemas.user import UserSchema
@app.get('/api/users')
@app.output(BaseResponse)
def get_users():
db_pagination = User.query.paginate(page=1, per_page=10)
# 先序列化数据(使用 Marshmallow Schema
users = UserSchema(many=True).dump(db_pagination.items)
# ✅ 传入序列化后的数据 + Pagination 对象
return page(users, db_pagination, message='获取用户列表成功')
```
---
#### **方式 3手动构建分页信息**
适用于非 SQLAlchemy 数据源(如 Redis、外部 API
```python
from iti.applications.common.utils import page, pagination_builder
@app.get('/api/stats')
@app.output(BaseResponse)
def get_stats():
# 从 Redis 获取数据
all_data = redis_client.lrange('stats', 0, -1)
page_num = request.args.get('page', 1, type=int)
page_size = request.args.get('size', 20, type=int)
# 手动分页
start = (page_num - 1) * page_size
end = start + page_size
items = all_data[start:end]
# ✅ 手动构建分页信息
pagination_info = pagination_builder(
None,
page=page_num,
size=page_size,
total=len(all_data)
)
return page(items, pagination_info, message='获取统计数据成功')
```
---
## 🔧 辅助函数
### `pagination_builder()` - 分页信息构建器
**签名:**
```python
def pagination_builder(
pagination: Any,
*,
page: Optional[int] = None,
size: Optional[int] = None,
total: Optional[int] = None,
pages: Optional[int] = None,
) -> dict
```
**参数说明:**
- `pagination`SQLAlchemy Pagination 对象 或 `None`
- `page`:当前页码(手动模式)
- `size`:每页数量(手动模式)
- `total`:总记录数(手动模式)
- `pages`:总页数(可选,自动计算)
**注意:**
- 第一个参数后使用 `*`,强制后续参数必须使用关键字传参
- 参考 APIFlask 的 `helpers.py` 实现
**示例 1自动模式SQLAlchemy Pagination**
```python
from iti.applications.common.utils import pagination_builder
@app.get('/api/products')
def get_products():
db_pagination = Product.query.paginate(page=1, per_page=10)
# ✅ 自动从 Pagination 对象提取信息
pagination_info = pagination_builder(db_pagination)
return {
'items': db_pagination.items,
'pagination': pagination_info
}
```
**示例 2手动模式自定义数据源**
```python
from iti.applications.common.utils import pagination_builder
@app.get('/api/external-data')
def get_external_data():
# 从外部 API 获取数据
response = requests.get('https://api.example.com/data')
total_count = response.headers.get('X-Total-Count', 0)
items = response.json()
# ✅ 手动构建分页信息
pagination_info = pagination_builder(
None, # 第一个参数传 None
page=1,
size=20,
total=int(total_count)
)
return page(items, pagination_info)
```
---
## 📊 Schema 定义
### `PaginationSchema` - 分页信息 Schema
```python
class PaginationSchema(Schema):
"""自定义分页信息 Schema"""
page = Integer() # 当前页码
size = Integer() # 每页数量(重命名自 per_page
pages = Integer() # 总页数
total = Integer() # 总记录数
current = URL() # 当前页URL
next = URL() # 下一页URL
prev = URL() # 上一页URL
first = URL() # 首页URL
last = URL() # 末页URL
```
**特点:**
- ✅ 与 APIFlask 原生 `PaginationSchema` 保持一致
- ✅ 仅将 `per_page` 重命名为 `size`
---
### `PageDataSchema` - 分页数据 Schema
```python
class PageDataSchema(Schema):
"""分页数据包装 Schema"""
items = List(Nested(Field())) # 数据列表
page = Nested(PaginationSchema) # 分页信息(字段名为 page
```
**使用示例:**
```python
from iti.applications.common.utils import PageDataSchema
# 在路由中使用(可选,主要用于文档生成)
@app.get('/api/users')
@app.doc(responses={200: PageDataSchema})
def get_users():
...
```
---
## 🎨 完整示例
### 示例 1用户管理 CRUD
```python
from flask import request
from iti.applications.common.utils import success, fail, page
from iti.applications.extensions.http import BaseResponse
from iti.applications.models.user import User
from iti.applications.schemas.user import UserSchema, UserCreateSchema
# ========== 列表(分页) ==========
@app.get('/api/users')
@app.output(BaseResponse)
def get_users():
"""获取用户列表"""
page_num = request.args.get('page', 1, type=int)
page_size = request.args.get('size', 10, type=int)
db_pagination = User.query.paginate(page=page_num, per_page=page_size)
return page(db_pagination, message='获取用户列表成功')
# ========== 详情 ==========
@app.get('/api/users/<int:user_id>')
@app.output(BaseResponse)
def get_user(user_id):
"""获取用户详情"""
user = User.query.get(user_id)
if not user:
return fail('用户不存在', code=404)
return success(UserSchema().dump(user))
# ========== 创建 ==========
@app.post('/api/users')
@app.input(UserCreateSchema)
@app.output(BaseResponse)
def create_user(data):
"""创建用户"""
# 检查用户名是否存在
if User.query.filter_by(username=data['username']).first():
return fail('用户名已存在', code=409)
try:
user = User(**data)
db.session.add(user)
db.session.commit()
return success(
UserSchema().dump(user),
message='创建成功',
code=201
)
except Exception as e:
db.session.rollback()
return fail(f'创建失败: {str(e)}', code=500)
# ========== 更新 ==========
@app.patch('/api/users/<int:user_id>')
@app.input(UserCreateSchema)
@app.output(BaseResponse)
def update_user(user_id, data):
"""更新用户"""
user = User.query.get(user_id)
if not user:
return fail('用户不存在', code=404)
try:
for key, value in data.items():
setattr(user, key, value)
db.session.commit()
return success(UserSchema().dump(user), message='更新成功')
except Exception as e:
db.session.rollback()
return fail(f'更新失败: {str(e)}', code=500)
# ========== 删除 ==========
@app.delete('/api/users/<int:user_id>')
@app.output(BaseResponse)
def delete_user(user_id):
"""删除用户"""
user = User.query.get(user_id)
if not user:
return fail('用户不存在', code=404)
try:
db.session.delete(user)
db.session.commit()
return success(None, message='删除成功')
except Exception as e:
db.session.rollback()
return fail(f'删除失败: {str(e)}', code=500)
```
---
### 示例 2复杂查询与筛选
```python
from sqlalchemy import and_, or_
from iti.applications.common.utils import page, pagination_builder
@app.get('/api/products')
@app.output(BaseResponse)
def get_products():
"""获取产品列表(支持筛选、搜索、排序)"""
# 获取查询参数
page_num = request.args.get('page', 1, type=int)
page_size = request.args.get('size', 20, type=int)
category = request.args.get('category')
search = request.args.get('search')
sort_by = request.args.get('sort', 'created_at')
order = request.args.get('order', 'desc')
# 构建查询
query = Product.query
# 筛选条件
if category:
query = query.filter(Product.category == category)
# 搜索条件
if search:
query = query.filter(
or_(
Product.name.contains(search),
Product.description.contains(search)
)
)
# 排序
if order == 'desc':
query = query.order_by(getattr(Product, sort_by).desc())
else:
query = query.order_by(getattr(Product, sort_by).asc())
# 分页
db_pagination = query.paginate(page=page_num, per_page=page_size)
# 序列化
products = ProductSchema(many=True).dump(db_pagination.items)
return page(products, db_pagination, message='获取产品列表成功')
```
---
### 示例 3聚合统计非 ORM 分页)
```python
from sqlalchemy import func
from iti.applications.common.utils import page, pagination_builder
@app.get('/api/stats/daily')
@app.output(BaseResponse)
def get_daily_stats():
"""获取每日统计数据"""
page_num = request.args.get('page', 1, type=int)
page_size = request.args.get('size', 30, type=int)
# 聚合查询(不使用 ORM 分页)
query = db.session.query(
func.date(Order.created_at).label('date'),
func.count(Order.id).label('order_count'),
func.sum(Order.amount).label('total_amount')
).group_by(func.date(Order.created_at))
# 获取总数
total = query.count()
# 手动分页
offset = (page_num - 1) * page_size
items = query.offset(offset).limit(page_size).all()
# 格式化数据
stats = [
{
'date': str(item.date),
'order_count': item.order_count,
'total_amount': float(item.total_amount or 0)
}
for item in items
]
# 手动构建分页信息
pagination_info = pagination_builder(
None,
page=page_num,
size=page_size,
total=total
)
return page(stats, pagination_info, message='获取统计数据成功')
```
---
## ⚙️ 配置说明
### BaseResponse Schema
`applications/extensions/http.py` 中定义:
```python
from apiflask import Schema
from apiflask.fields import Integer, String, Field
class BaseResponse(Schema):
"""统一响应格式 Schema"""
data = Field()
code = Integer()
message = String()
def init_http(app):
# 配置 APIFlask 使用自定义响应格式
app.config["BASE_RESPONSE_SCHEMA"] = BaseResponse
app.config["BASE_RESPONSE_DATA_KEY"] = "data"
```
---
## 🔍 URL 生成规则
分页 URL 自动生成逻辑:
1. **获取当前请求的 `base_url`**`http://api.example.com/api/users`
2. **复制查询参数**`request.args.copy()`
3. **更新分页参数**
- `page`: 页码
- `size`: 每页数量(注意:使用 `size` 而非 `per_page`
4. **构建完整 URL**`base_url + ? + urlencode(args)`
**示例:**
请求 `/api/users?category=admin&page=2&size=10` 时生成的 URL
```json
{
"current": "http://api.example.com/api/users?category=admin&page=2&size=10",
"next": "http://api.example.com/api/users?category=admin&page=3&size=10",
"prev": "http://api.example.com/api/users?category=admin&page=1&size=10",
"first": "http://api.example.com/api/users?category=admin&page=1&size=10",
"last": "http://api.example.com/api/users?category=admin&page=10&size=10"
}
```
---
## 📝 最佳实践
### 1. 统一错误码规范
建议定义错误码常量:
```python
# applications/common/constants.py
class ErrorCode:
"""业务错误码"""
SUCCESS = 200
BAD_REQUEST = 400
UNAUTHORIZED = 401
FORBIDDEN = 403
NOT_FOUND = 404
CONFLICT = 409
UNPROCESSABLE_ENTITY = 422
INTERNAL_SERVER_ERROR = 500
# 使用
from iti.applications.common.utils import fail
from iti.applications.common.constants import ErrorCode
return fail('用户不存在', code=ErrorCode.NOT_FOUND)
```
---
### 2. 结合 Marshmallow Schema
```python
from iti.applications.schemas.user import UserSchema
from iti.applications.common.utils import success
@app.get('/api/users/<int:user_id>')
@app.output(BaseResponse)
def get_user(user_id):
user = User.query.get_or_404(user_id)
# ✅ 使用 Schema 序列化
user_data = UserSchema().dump(user)
return success(user_data)
```
---
### 3. 分页参数验证
```python
from iti.applications.common.utils import fail, page
@app.get('/api/users')
@app.output(BaseResponse)
def get_users():
page_num = request.args.get('page', 1, type=int)
page_size = request.args.get('size', 10, type=int)
# 验证分页参数
if page_num < 1:
return fail('页码必须大于 0', code=400)
if page_size < 1 or page_size > 100:
return fail('每页数量必须在 1-100 之间', code=400)
db_pagination = User.query.paginate(page=page_num, per_page=page_size)
return page(db_pagination)
```
---
### 4. 异常统一处理
```python
from iti.applications.common.utils import fail
@app.errorhandler(404)
def handle_404(error):
return fail('资源不存在', code=404)
@app.errorhandler(500)
def handle_500(error):
return fail('服务器内部错误', code=500)
@app.errorhandler(Exception)
def handle_exception(error):
app.logger.error(f'未处理的异常: {error}')
return fail(str(error), code=500)
```
---
## 🆚 对比 APIFlask 原生实现
| 特性 | APIFlask 原生 | 本工具库 |
|------|--------------|---------|
| 分页参数名 | `per_page` | `size` ✅ |
| 返回格式 | 灵活 | 统一 `{data, code, message}` ✅ |
| 错误处理 | HTTP 状态码 | 业务 `code` 字段 ✅ |
| 智能识别 | 需手动处理 | 自动识别 Pagination 对象 ✅ |
| URL 生成 | 手动 | 自动生成 ✅ |
---
## 🧪 测试
### 运行测试
```bash
# 运行 HTTP 工具测试
hatch run test tests/test_http_utils.py -v
# 运行所有测试
hatch run test
# 测试覆盖率
hatch run cov
```
### 测试统计
- **测试用例数量**: 33 个
- **测试分类**:
- `success()` 函数测试: 6 个
- `fail()` 函数测试: 5 个
- `pagination_builder()` 测试: 6 个
- `page()` 函数测试: 7 个
- Schema 定义测试: 2 个
- Flask 集成测试: 4 个
- 边界情况测试: 5 个
### 测试覆盖范围
- ✅ 基础功能测试
- ✅ 参数验证测试
- ✅ 默认值测试
- ✅ 边界情况测试
- ✅ Flask 应用集成测试
- ✅ Schema 定义测试
- ✅ 请求上下文处理测试
### 添加自定义测试
`tests/test_http_utils.py` 中添加测试:
```python
import pytest
from iti.applications.common.utils import success
def test_my_custom_case():
"""测试自定义场景"""
result = success({'key': 'value'})
assert result['code'] == 200
```
---
## 🐛 常见问题
### Q1: 为什么错误也返回 HTTP 200
**A:** 这是一种常见的 API 设计模式,优点:
- 前端统一处理,不需要捕获 HTTP 异常
- 避免浏览器/代理对非 200 状态码的拦截
- 业务状态由 `code` 字段表示,更清晰
如需返回 HTTP 错误状态码,可以手动返回元组:
```python
return fail('未找到', code=404), 404 # HTTP 404
```
---
### Q2: 如何自定义分页 URL 生成逻辑?
**A:** 修改 `_generate_page_url()` 函数:
```python
def _generate_page_url(page_num, page_size):
if page_num is None:
return None
# 自定义逻辑
return f"https://custom-domain.com/api?p={page_num}&s={page_size}"
```
---
### Q3: 分页信息中的 URL 字段可以去掉吗?
**A:** 可以。修改 `pagination_builder()` 返回值:
```python
return {
'page': page,
'size': size,
'pages': pages,
'total': total,
# 注释掉 URL 字段
# 'current': current_url,
# 'next': next_url,
# ...
}
```
---
## 📚 参考资料
- [APIFlask 官方文档](https://apiflask.com/)
- [APIFlask helpers.py 源码](https://github.com/apiflask/apiflask/blob/main/src/apiflask/helpers.py)
- [Flask-SQLAlchemy Pagination](https://flask-sqlalchemy.palletsprojects.com/en/3.0.x/pagination/)
---
## 📅 更新日志
- **v1.0.0** (2024-10-14)
- 初始版本
- 实现 `success()`, `fail()`, `page()` 函数
- 实现 `pagination_builder()` 分页构建器
- 支持智能识别 SQLAlchemy Pagination 对象
- 自动生成分页 URL
- 参数重命名:`per_page` → `size`
---
**编写人员:** AI Assistant
**最后更新:** 2024-10-14
**版本:** 1.0.0

@ -0,0 +1,305 @@
# 限流器配置说明
## 📋 概述
项目使用 Flask-Limiter 实现 API 限流功能,支持基于 Flask 配置的动态设置。
## 🔧 配置项
### 基础配置
| 配置项 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| `RATELIMIT_ENABLED` | bool | `True` | 是否启用限流 |
| `RATELIMIT_STORAGE_URL` | str | `"memory://"` | 存储后端 URI |
| `RATELIMIT_DEFAULT` | str | `"200 per hour"` | 默认限流规则 |
### 环境配置
#### 开发环境 (DevConfig)
```python
RATELIMIT_ENABLED = True
RATELIMIT_STORAGE_URL = "memory://"
RATELIMIT_DEFAULT = "1000 per hour" # 宽松限制
```
#### 测试环境 (TestConfig)
```python
RATELIMIT_ENABLED = False # 禁用限流
```
#### 生产环境 (ProdConfig)
```python
RATELIMIT_ENABLED = True
RATELIMIT_STORAGE_URL = os.getenv("REDIS_URL", "memory://")
RATELIMIT_DEFAULT = "100 per hour" # 严格限制
```
## 🚀 使用方式
### 1. 基本使用
```python
from flask import Flask, jsonify
from iti.applications.extensions.limit import limiter
app = Flask(__name__)
@app.route('/api/data')
@limiter.limit("10 per minute") # 每分钟10次
def get_data():
return jsonify({'data': 'some data'})
```
### 2. 使用默认限流
```python
@app.route('/api/public')
def public_api():
# 使用配置中的默认限流规则
return jsonify({'message': 'Public API'})
```
### 3. 豁免限流
```python
@app.route('/api/health')
@limiter.exempt # 豁免限流
def health_check():
return jsonify({'status': 'healthy'})
```
### 4. 自定义键函数
```python
@app.route('/api/user-data')
@limiter.limit("50 per hour", key_func=lambda: "user_123")
def user_data():
return jsonify({'user': 'user_123', 'data': 'user data'})
```
## 🔧 配置示例
### 使用 .env 文件
```env
# .env
RATELIMIT_ENABLED=true
RATELIMIT_STORAGE_URL=memory://
RATELIMIT_DEFAULT=200 per hour
# .env.prod
RATELIMIT_ENABLED=true
RATELIMIT_STORAGE_URL=redis://localhost:6379/0
RATELIMIT_DEFAULT=100 per hour
```
### 使用环境变量
```bash
# Windows
set RATELIMIT_ENABLED=true
set RATELIMIT_STORAGE_URL=redis://localhost:6379/0
set RATELIMIT_DEFAULT=100 per hour
# Linux/macOS
export RATELIMIT_ENABLED=true
export RATELIMIT_STORAGE_URL=redis://localhost:6379/0
export RATELIMIT_DEFAULT=100 per hour
```
## 📊 存储后端
### 内存存储 (开发环境)
```python
RATELIMIT_STORAGE_URL = "memory://"
```
- ✅ 简单快速
- ❌ 重启后数据丢失
- ❌ 不支持多进程
### Redis 存储 (生产环境)
```python
RATELIMIT_STORAGE_URL = "redis://localhost:6379/0"
```
- ✅ 持久化存储
- ✅ 支持多进程
- ✅ 高性能
- ⚠️ 需要 Redis 服务
### 其他存储选项
```python
# Memcached
RATELIMIT_STORAGE_URL = "memcached://localhost:11211"
# 文件存储
RATELIMIT_STORAGE_URL = "file:///tmp/limiter.db"
```
## 🎯 限流规则语法
### 时间单位
- `second` / `sec` - 秒
- `minute` / `min` - 分钟
- `hour` - 小时
- `day` - 天
- `month` - 月
- `year` - 年
### 示例规则
```python
"10 per second" # 每秒10次
"100 per minute" # 每分钟100次
"1000 per hour" # 每小时1000次
"10000 per day" # 每天10000次
```
### 复杂规则
```python
"10 per minute; 100 per hour" # 每分钟10次每小时100次
"5 per minute; 50 per hour; 500 per day" # 多级限流
```
## 🔍 调试和监控
### 检查限流状态
```python
@app.route('/api/limiter-status')
def limiter_status():
if limiter is None:
return jsonify({'enabled': False})
return jsonify({
'enabled': True,
'default_limits': limiter.default_limits,
'storage_uri': limiter.storage_uri,
'key_func': limiter.key_func.__name__
})
```
### 查看限流信息
```python
from flask_limiter.util import get_remote_address
# 获取当前用户的限流信息
limits = limiter.get_window_stats(get_remote_address())
print(f"剩余请求: {limits[1] - limits[0]}")
```
## 🛠️ 故障排查
### 问题 1: 限流不生效
**检查步骤:**
1. 确认 `RATELIMIT_ENABLED=True`
2. 检查存储后端是否可用
3. 验证限流规则语法
**调试方法:**
```python
print(f"限流启用: {app.config.get('RATELIMIT_ENABLED')}")
print(f"存储URI: {app.config.get('RATELIMIT_STORAGE_URL')}")
print(f"默认限制: {app.config.get('RATELIMIT_DEFAULT')}")
```
### 问题 2: Redis 连接失败
**检查步骤:**
1. 确认 Redis 服务运行
2. 检查连接字符串格式
3. 验证网络连接
**解决方案:**
```python
# 使用内存存储作为后备
RATELIMIT_STORAGE_URL = os.getenv("REDIS_URL", "memory://")
```
### 问题 3: 限流过于严格
**调整方法:**
```python
# 在配置中调整
RATELIMIT_DEFAULT = "1000 per hour" # 更宽松的限制
# 或在路由中覆盖
@limiter.limit("500 per hour")
def my_endpoint():
pass
```
## 📚 最佳实践
### 1. 环境配置
```python
# 开发环境 - 宽松限制
RATELIMIT_DEFAULT = "1000 per hour"
# 测试环境 - 禁用限流
RATELIMIT_ENABLED = False
# 生产环境 - 严格限制
RATELIMIT_DEFAULT = "100 per hour"
RATELIMIT_STORAGE_URL = "redis://localhost:6379/0"
```
### 2. 路由设计
```python
# 公共 API - 宽松限制
@app.route('/api/public')
@limiter.limit("100 per hour")
def public_api():
pass
# 私有 API - 严格限制
@app.route('/api/private')
@limiter.limit("10 per hour")
def private_api():
pass
# 健康检查 - 豁免限流
@app.route('/health')
@limiter.exempt
def health():
pass
```
### 3. 错误处理
```python
from flask_limiter.errors import RateLimitExceeded
@app.errorhandler(RateLimitExceeded)
def handle_rate_limit_exceeded(e):
return jsonify({
'error': 'Rate limit exceeded',
'message': 'Too many requests',
'retry_after': e.retry_after
}), 429
```
## 🎉 总结
现在您的项目:
1. ✅ **支持基于配置的限流** - 从 Flask 配置中读取设置
2. ✅ **环境特定配置** - 不同环境使用不同的限流策略
3. ✅ **灵活的存储后端** - 支持内存、Redis 等
4. ✅ **易于调试** - 提供状态检查和错误处理
5. ✅ **生产就绪** - 支持高并发和持久化存储
**开始使用:**
```python
from iti.applications.extensions.limit import limiter
@app.route('/api/data')
@limiter.limit("10 per minute")
def get_data():
return jsonify({'data': 'some data'})
```
就这么简单!🚀

@ -1,48 +0,0 @@
# 数据库迁移
iTi-Flask 使用 Alembic 管理数据库 schema。
## 规则
- 每个业务项目只有一条 Alembic migration 流。
- `migrations/versions` 必须提交。
- 已发布 migration 不回改。
- 生产只执行 `python -m alembic upgrade head`
- `iti-system` 的 migration 通过 CLI 同步进业务项目 migration 流。
## 命令
```bash
uv run python -m alembic revision --autogenerate -m "alice add workorder priority"
uv run python -m alembic upgrade head
uv run python -m alembic current
uv run python -m alembic heads
```
多个 head
```bash
uv run python -m alembic merge heads -m "alice merge heads before release"
uv run python -m alembic upgrade head
```
## 模型发现
Alembic `env.py` 使用 `iti.db.Base.metadata`
业务模型只要继承 `iti.db.Base`,并在 `env.py` 或应用导入链中被 import即可参与 autogenerate。
```python
from iti.db import Base
class Example(Base):
__tablename__ = "example"
```
## 同步 iTi-System
```bash
uv run iti-system migrations sync --target migrations/versions
uv run python -m alembic upgrade head
```
业务项目可在同步后继续新增自己的 migration。

@ -1,91 +0,0 @@
# 模块协议
模块是同一个 FastAPI 进程内的业务边界。
它不是独立服务。
## 注册
```python
from iti import create_app
from my_app.modules.example import ExampleModule
app = create_app(modules=[ExampleModule()])
```
## 模块类
```python
class ExampleModule:
name = "example"
def init_app(self, app):
pass
def register_routes(self, app):
app.include_router(router)
def register_permissions(self, app):
pass
def register_menu_seed(self, app):
pass
def register_tasks(self, app):
pass
```
执行顺序:
1. `init_app`
2. `register_tasks`
3. `register_routes`
4. `register_permissions`
5. `register_menu_seed`
## 路由
使用 FastAPI 原生 `APIRouter`
```python
from fastapi import APIRouter
from iti.responses import ok
router = APIRouter(prefix="/example", tags=["example"])
@router.get("/ping")
def ping():
return ok({"pong": True})
```
API 文档会按 tag 前缀生成分组。
例如 `tags=["system.user"]` 会归入 `system` 分组,显示名为 `user`
## 权限元数据
```python
from iti.modules import ModulePermission
def register_permissions(self, app):
app.state.iti_modules.register_permission(
ModulePermission("example:item:list", "示例列表")
)
```
框架负责收集元数据。
具体授权由 `PermissionProvider` 决定。
单独使用 `iti-flask` 时可注入自己的 provider。
使用 `iti-system` 时由系统包提供数据库 provider。
## 模板与导入导出
框架内置的交换能力由 `iti.exchange.module.create_exchange_module()` 提供。
业务模块通过 `register_exchange_spec()` 注册 `biz_domain`、`biz_obj`、`operation`、模板变量和 handler。
模板中心可以聚合这些规格,前端据此展示业务范围和变量说明。
模板中心可以由 `system` 承载,也可以由业务模块自建。框架侧能力不依赖 `system` 是否存在。
```python
from iti.exchange.module import create_exchange_module
app = create_app(modules=[create_exchange_module()])
```

@ -1,24 +1,190 @@
# iTi-Flask 文档
# iTi-Flask 项目文档
iTi-Flask 文档只描述框架自身。
AI 修改框架时优先读 `.codex/skills/iti-flask-framework/SKILL.md`
## 📚 文档索引
## 人类入口
### 核心文档
- [README](../README.md)
- [架构](ARCHITECTURE.md)
- [配置](CONFIGURATION.md)
- [模块协议](MODULES.md)
- [模板与导入导出](EXCHANGE.md)
- [Copier 模板](COPIER_TEMPLATE.md)
- [测试与部署](TESTING_DEPLOYMENT.md)
- **[HTTP_RESPONSE_UTILS.md](./HTTP_RESPONSE_UTILS.md)** - HTTP 响应包装工具使用指南
## 命令
---
## 🎯 快速开始
### 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
iticli install
iticli test
iticli create ../my-business-app
iticli create --with-system ../my-system-app
iticli release
# 生成覆盖率报告
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

@ -1,34 +0,0 @@
# 种子数据
框架不写业务 seed。
业务项目和 `iti-system` 各自维护自己的 seed。
## 规则
- 幂等。
- 按唯一键 upsert。
- 不删除用户数据。
- 不替代 migration。
- 可重复执行。
## iTi-System
系统包提供:
```bash
PYTHONPATH=. uv run iti-system seed system app:create_app
```
它会写入默认角色、管理员、系统菜单、字典和配置。
## 业务项目
业务项目可以写普通 Python 函数:
```python
def seed_example(db):
...
```
也可以暴露 click 命令。
框架不要求固定 seed DSL。

@ -1,112 +0,0 @@
# 服务客户端
iTi-Flask 提供同步 HTTP JSON 服务客户端。
它用于业务项目调用独立服务。
## 能力
支持:
- base URL 配置。
- service token 鉴权。
- 超时。
- 按方法和状态码重试。
- 可选熔断。
- `X-Trace-Id` 透传。
- 结构化调用日志。
- envelope 自动识别。
- HTTP status 自动识别。
- 测试用 mock transport。
不支持:
- 服务发现。
- 负载均衡。
- gRPC。
- streaming。
- async client。
- OpenAPI client 生成。
## 配置
```python
class DevConfig(BaseDevConfig):
def __init__(self) -> None:
super().__init__()
self.services = {
"inventory": {
"base_url": "http://inventory.local",
"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],
"methods": ["GET", "HEAD", "OPTIONS"],
},
"circuit_breaker": {
"enabled": False,
"fail_max": 5,
"reset_timeout": 30,
},
}
}
```
`base_url` 必填。
`token` 非空时,客户端会发送:
```http
Authorization: Bearer change-me
```
## 使用
```python
from iti.service_client import service_client
inventory = service_client(app, "inventory")
item = inventory.get("/items/{id}", path={"id": "A001"})
created = inventory.post("/items", json={"name": "demo"})
```
`GET`、`HEAD`、`OPTIONS` 默认可按配置重试。
`POST` 默认不重试。
需要强制重试:
```python
inventory.post("/jobs", json={"kind": "sync"}, retry=True)
```
## Trace ID
客户端会为每次调用生成 `X-Trace-Id`
## 错误
客户端会抛出这些异常:
- `ServiceConfigError`
- `ServiceUnavailableError`
- `ServiceHTTPError`
- `ServiceBusinessError`
响应体如果是 envelope
- `code == 200` 返回 `data`
- `code != 200` 抛出 `ServiceBusinessError`
这条规则不依赖 HTTP status。
也就是说HTTP 401 + envelope `code=401` 会抛 `ServiceBusinessError`,调用方可统一处理业务 code。
非 envelope 的非 2xx 响应会抛出 `ServiceHTTPError`
envelope 只认同时包含 `data`、`code`、`message` 的 JSON object。
响应体为空时返回 `None`
`expect_json=False` 时返回原始 `httpx.Response`

@ -1,76 +0,0 @@
# 任务运行器
iTi-Flask 提供单进程任务注册表和运行器。
它适合轻量定时任务和手动任务。
## 能力
支持:
- 任务注册。
- 手动触发。
- `interval` 调度。
- 简单 cron-like 调度。
- 单进程内防重复执行。
- 内存运行记录。
不支持:
- 分布式锁。
- 多实例 exactly-once。
- 持久化队列。
- Celery 或 RQ 集成。
多进程部署时,只在一个专用进程启用调度。
## 配置
默认不启动调度线程。
```python
class DevConfig(BaseDevConfig):
def __init__(self) -> None:
super().__init__()
self.tasks_enabled = True
```
## 注册任务
```python
from iti.tasks import task_registry
def rebuild_reports():
return {"rebuilt": 10}
task_registry.register(
name="reports.rebuild",
handler=rebuild_reports,
schedule="interval:600",
description="重建报表缓存",
)
```
## 手动触发
```python
from iti.tasks import task_registry
run = task_registry.trigger("reports.rebuild")
print(run.status)
print(run.result)
```
同一任务正在运行时,再次触发会返回 `skipped`
## 调度格式
```text
interval:60
cron:*/10 * * * *
cron:* * * * *
```
cron 解析只读取分钟字段。
`cron:* * * * *` 等价于每 60 秒。

@ -1,111 +0,0 @@
# 测试与部署方案
本轮开发验证不依赖 Docker不依赖真实数据库。
真实 MySQL/PostgreSQL 和 Docker Compose 属于集成验证阶段。
## 本地轻量验证
```bash
cd <workspace>/iTi-Flask
uv run pytest -q
cd <workspace>/iTi-System
uv run pytest -q
cd <workspace>/my-business-app
uv run pytest -q
```
这些测试允许使用 SQLite 内存库,只验证框架行为、路由契约和模块组装。
## MySQL 集成验证
准备独立测试库:
```sql
CREATE DATABASE iti_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE DATABASE my_business_app_test CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
```
执行:
```bash
export DATABASE_URL='mysql+pymysql://root:password@127.0.0.1:3306/iti_test?charset=utf8mb4'
uv run python -m alembic upgrade head
uv run iti-system migrations sync --target migrations/versions
PYTHONPATH=. uv run iti-system seed system app:create_app
uv run uvicorn main:app --reload
```
## PostgreSQL 集成验证
准备独立测试库:
```sql
CREATE DATABASE iti_test;
CREATE DATABASE my_business_app_test;
```
执行:
```bash
export DATABASE_URL='postgresql+psycopg://postgres:password@127.0.0.1:5432/iti_test'
uv run python -m alembic upgrade head
uv run uvicorn main:app --reload
```
验证:
```bash
curl http://127.0.0.1:8000/health
curl http://127.0.0.1:8000/ready
```
如启用 `ready_check_db=True``/ready` 会执行 `SELECT 1`
## Docker Compose 健康检查
模板生成项目已经包含 Docker Compose
```bash
cp .env.example .env
iticli docker up
iticli docker logs
iticli docker down
```
如需本地数据库:
```bash
iticli docker up --db
iticli docker down --db
```
服务容器使用:
```yaml
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:8000/health"]
interval: 10s
timeout: 3s
retries: 3
```
依赖数据库就绪的服务可检查 `/ready`
## 生产运行
简单部署:
```bash
uv run uvicorn main:app --host 0.0.0.0 --port 8000
```
多 worker
```bash
uv run gunicorn main:app -k uvicorn.workers.UvicornWorker -w 4 -b 0.0.0.0:8000
```
`tasks_enabled=True` 时不要在多个 worker 同时启用调度。
轻量任务只保证单进程内正常运行。

@ -0,0 +1,76 @@
"""
限流器使用示例
"""
from flask import jsonify
from iti.applications.extensions.limit import limiter
def create_example_app():
"""创建示例应用"""
from iti.applications import create_app
return create_app('dev')
app = create_example_app()
@app.route('/')
def index():
"""首页 - 使用默认限流"""
return jsonify({'message': 'Hello, World!'})
@app.route('/api/public')
@limiter.limit("10 per minute") # 自定义限流每分钟10次
def public_api():
"""公共 API - 自定义限流"""
return jsonify({'message': 'Public API', 'data': 'some data'})
@app.route('/api/private')
@limiter.limit("5 per minute") # 更严格的限流每分钟5次
def private_api():
"""私有 API - 更严格的限流"""
return jsonify({'message': 'Private API', 'data': 'sensitive data'})
@app.route('/api/unlimited')
@limiter.exempt # 豁免限流
def unlimited_api():
"""无限制 API - 豁免限流"""
return jsonify({'message': 'Unlimited API', 'data': 'no limits'})
@app.route('/api/conditional')
@limiter.limit("20 per hour", per_method=True) # 按方法限流
def conditional_api():
"""条件限流 API - 按方法限流"""
return jsonify({'message': 'Conditional API', 'data': 'method specific'})
@app.route('/api/user-specific')
@limiter.limit("100 per hour", key_func=lambda: "user_123") # 自定义键函数
def user_specific_api():
"""用户特定 API - 自定义键函数"""
return jsonify({'message': 'User specific API', 'user': 'user_123'})
@app.route('/api/rate-limit-info')
def rate_limit_info():
"""获取限流信息"""
if limiter is None:
return jsonify({'message': 'Rate limiting is disabled'})
return jsonify({
'message': 'Rate limiting is enabled',
'default_limits': limiter.default_limits,
'storage_uri': limiter.storage_uri,
'key_func': limiter.key_func.__name__ if limiter.key_func else None
})
if __name__ == '__main__':
print("🚀 启动限流器示例应用")
print("📊 访问 http://localhost:5000/api/rate-limit-info 查看限流配置")
print("🔒 访问其他端点测试限流功能")
app.run(debug=True)

@ -0,0 +1,162 @@
# 开发环境(默认)
[envs.default]
type = "virtual"
dependencies = [
"rocketmq-client-python",
"simpleeval",
"influxdb-client",
"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'"

@ -0,0 +1,40 @@
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

@ -0,0 +1,35 @@
FLASK_ENV=dev
SECRET_KEY=iti-flask
JWT_SECRET_KEY=iti-flask
DATABASE_URL=mysql+pymysql://root:chen222262@124.223.195.237:13306/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
# ============================================
# influxdb 配置
# ============================================
INFLUXDB_URL="http://124.223.195.237:8086"
INFLUXDB_TOKEN="HdHOox3RqEjJ--Ma9_dFcf-Iv8wu2u0FyD_sV4MT4EIoQoT7h4eZLR_n_yGgmiSLAGiIaUgaH6x-cILNGV8W4g=="
INFLUXDB_ORG="noface"
INFLUXDB_BUCKET="yh-iot"
INFLUXDB_MAX_RETRIES=3
INFLUXDB_RETRY_DELAY=1.0
INFLUXDB_MAX_RETRY_DELAY=30.0
# ============================================
# rocketmq 配置
# ============================================
ROCKETMQ_NAMESRV_ADDR="124.223.195.237:9876"
ROCKETMQ_PRODUCER_GROUP="iot-collect-group"
ROCKETMQ_CONSUMER_GROUP="iot-collect-group"
ROCKETMQ_TOPIC="iot-collect-topic"
ROCKETMQ_TAGS="*"

@ -0,0 +1,7 @@
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__ = "0.3.0"
__version__ = "1.0.0"

@ -1,3 +1,3 @@
from iti.app import create_app
__all__ = ["create_app"]
# SPDX-FileCopyrightText: 2025-present NoahLan <6995syu@163.com>
#
# SPDX-License-Identifier: MIT

@ -1,553 +1,6 @@
from __future__ import annotations
from iti.applications import create_app
import logging
import time
import uuid
from html import escape
from importlib import resources
from http import HTTPStatus
from collections.abc import Iterable, Mapping
from contextvars import ContextVar
from contextlib import asynccontextmanager
from dataclasses import asdict, is_dataclass
from functools import wraps
from inspect import isawaitable
from typing import Any
app = create_app()
from fastapi import FastAPI, Request
from fastapi.dependencies.utils import (
_should_embed_body_fields,
get_body_field,
get_dependant,
get_flat_dependant,
get_parameterless_sub_dependant,
)
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
from fastapi.routing import APIRoute
from fastapi.routing import request_response
from fastapi.responses import HTMLResponse, JSONResponse
from pydantic import BaseModel
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.responses import Response
from sqlalchemy.exc import SQLAlchemyError
from iti.auth.permissions import StaticPermissionProvider
from iti.audit import init_audit
from iti.cache import CacheManager
from iti.config import BaseConfig, get_config, get_env_name
from iti.db import configure_db
from iti.exceptions import ItiError
from iti.health import router as health_router
from iti.limiter import SimpleLimiter
from iti.logging_config import configure_logging, log_extra
from iti.modules import init_modules
from iti.exchange import get_exchange_registry
from iti.exchange import models as _exchange_models
from iti.responses.auto import is_envelope_payload, is_raw_response_request
from iti.responses import fail
from iti.service_client import init_service_clients
from iti.tasks import init_task_runner
logger = logging.getLogger("iti")
error_logger = logging.getLogger("iti.error")
_current_request: ContextVar[Request | None] = ContextVar("iti_current_request", default=None)
DOCS_PICKER_TEMPLATE = "docs-picker.html"
SCALAR_TEMPLATE = "scalar.html"
OPENAPI_HTTP_METHODS = {"get", "put", "post", "delete", "options", "head", "patch", "trace"}
def create_app(
config_name: str | None = None,
*,
modules: Iterable[Any] | None = None,
config_mapping: Mapping[str, type[BaseConfig] | BaseConfig] | type[BaseConfig] | BaseConfig | None = None,
permission_provider: Any | None = None,
) -> FastAPI:
config = _resolve_config(config_name, config_mapping)
configure_logging(config)
@asynccontextmanager
async def lifespan(app: FastAPI):
runner = getattr(app.state, "iti_task_runner", None)
audit_dispatcher = getattr(app.state, "audit_dispatcher", None)
if audit_dispatcher:
audit_dispatcher.start()
if runner and config.tasks_enabled:
runner.start()
yield
if runner:
runner.stop()
if audit_dispatcher:
audit_dispatcher.stop()
for client in getattr(app.state, "iti_service_clients", {}).values():
client.close()
app = FastAPI(
title=config.app_name,
debug=config.debug,
lifespan=lifespan,
docs_url=None,
redoc_url=None,
)
install_docs(app)
install_openapi_tag_groups(app)
app.state.config = config
app.state.cache = CacheManager(default_timeout=config.cache_default_timeout)
app.state.limiter = SimpleLimiter(enabled=config.ratelimit_enabled)
app.state.permission_provider = permission_provider or StaticPermissionProvider()
app.state.exchange_enabled = config.exchange_enabled
init_middlewares(app)
if config.cors_origins:
app.add_middleware(
CORSMiddleware,
allow_origins=config.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
engine, sessionmaker = configure_db(
config.database_url,
echo=config.sqlalchemy_echo,
pool_pre_ping=config.sqlalchemy_pool_pre_ping,
)
app.state.db_engine = engine
app.state.db_sessionmaker = sessionmaker
init_error_handlers(app)
init_service_clients(app, config.services)
init_task_runner(app)
get_exchange_registry(app)
init_audit(app)
module_list = list(modules or [])
if config.exchange_enabled and not any(
getattr(module, "name", None) == "exchange" for module in module_list
):
from iti.exchange.module import create_exchange_module
module_list.append(create_exchange_module())
module_registry = init_modules(app, module_list)
app.state.iti_modules = module_registry
if config.health_enabled:
app.include_router(health_router)
module_registry.run_phase("register_routes", app)
module_registry.run_phase("register_permissions", app)
module_registry.run_phase("register_menu_seed", app)
install_auto_envelope(app)
return app
def install_docs(app: FastAPI) -> None:
@app.get("/docs", include_in_schema=False)
def docs(ui: str | None = None) -> HTMLResponse:
doc_options = _enabled_doc_options(app)
if ui == "swagger" and "swagger" in doc_options:
return get_swagger_ui_html(
openapi_url=app.openapi_url or "/openapi.json",
title=f"{app.title} - Swagger UI",
)
if ui == "scalar" and "scalar" in doc_options:
return _scalar_docs_html(app)
if ui == "redoc" and "redoc" in doc_options:
return get_redoc_html(
openapi_url=app.openapi_url or "/openapi.json",
title=f"{app.title} - ReDoc",
)
return _docs_picker_html(app, doc_options)
def install_openapi_tag_groups(app: FastAPI) -> None:
default_openapi = app.openapi
def openapi_with_tag_groups() -> dict[str, Any]:
schema = default_openapi()
_apply_openapi_tag_groups(schema)
return schema
app.openapi = openapi_with_tag_groups
def _enabled_doc_options(app: FastAPI) -> dict[str, dict[str, str]]:
configured = getattr(app.state.config, "docs_ui_enabled", ["swagger", "scalar", "redoc"])
all_options = {
"swagger": {
"class": "swagger",
"label": "Swagger",
"abbr": "SW",
"description": "传统交互文档,适合快速试接口。",
},
"scalar": {
"class": "scalar",
"label": "Scalar",
"abbr": "SC",
"description": "现代接口参考,适合阅读和调试。",
},
"redoc": {
"class": "redoc",
"label": "ReDoc",
"abbr": "RD",
"description": "结构化阅读文档,适合查看模型关系。",
},
}
return {name: all_options[name] for name in configured if name in all_options}
def _docs_picker_html(app: FastAPI, doc_options: Mapping[str, dict[str, str]]) -> HTMLResponse:
title = escape(app.title)
option_cards = "\n".join(
_doc_option_card(name, option) for name, option in doc_options.items()
)
html = _render_template(
DOCS_PICKER_TEMPLATE,
{
"title": title,
"option_cards": option_cards,
},
)
return HTMLResponse(html)
def _doc_option_card(name: str, option: Mapping[str, str]) -> str:
label = escape(option["label"])
class_name = escape(option["class"])
abbr = escape(option["abbr"])
description = escape(option["description"])
href = escape(f"?ui={name}")
return f"""<a class="doc-option {class_name}" href="{href}" aria-label="打开 {label} 文档">
<span class="mark" aria-hidden="true">{abbr}</span>
<span class="copy">
<strong>{label}</strong>
<span>{description}</span>
</span>
<span class="arrow" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path d="M5 12h14"></path>
<path d="m13 6 6 6-6 6"></path>
</svg>
</span>
</a>"""
def _scalar_docs_html(app: FastAPI) -> HTMLResponse:
html = _render_template(
SCALAR_TEMPLATE,
{
"title": escape(app.title),
"openapi_url": escape(app.openapi_url or "/openapi.json"),
},
)
return HTMLResponse(html)
def _render_template(name: str, values: Mapping[str, str]) -> str:
template = resources.files("iti.templates").joinpath(name).read_text(encoding="utf-8")
for key, value in values.items():
template = template.replace("{{ " + key + " }}", value)
return template
def _apply_openapi_tag_groups(schema: dict[str, Any]) -> None:
tag_names = _openapi_tag_names(schema)
if not tag_names or not any(_openapi_tag_display_name(tag) for tag in tag_names):
return
schema["tags"] = _openapi_tag_objects(schema, tag_names)
groups: list[dict[str, Any]] = []
group_index: dict[str, dict[str, Any]] = {}
for tag in tag_names:
group_name = _openapi_tag_group_name(tag)
group = group_index.get(group_name)
if group is None:
group = {"name": group_name, "tags": []}
groups.append(group)
group_index[group_name] = group
group["tags"].append(tag)
schema["x-tagGroups"] = groups
def _openapi_tag_names(schema: Mapping[str, Any]) -> list[str]:
names: list[str] = []
seen: set[str] = set()
def append_tag(value: Any) -> None:
if isinstance(value, str) and value not in seen:
names.append(value)
seen.add(value)
for tag in schema.get("tags") or []:
if isinstance(tag, Mapping):
append_tag(tag.get("name"))
paths = schema.get("paths") or {}
if not isinstance(paths, Mapping):
return names
for path_item in paths.values():
if not isinstance(path_item, Mapping):
continue
for method, operation in path_item.items():
if method.lower() not in OPENAPI_HTTP_METHODS or not isinstance(operation, Mapping):
continue
for tag in operation.get("tags") or []:
append_tag(tag)
return names
def _openapi_tag_objects(schema: Mapping[str, Any], tag_names: list[str]) -> list[dict[str, Any]]:
existing: dict[str, dict[str, Any]] = {}
for tag in schema.get("tags") or []:
if not isinstance(tag, Mapping) or not isinstance(tag.get("name"), str):
continue
existing.setdefault(tag["name"], dict(tag))
tag_objects: list[dict[str, Any]] = []
for tag_name in tag_names:
tag = dict(existing.get(tag_name, {"name": tag_name}))
tag["name"] = tag_name
display_name = _openapi_tag_display_name(tag_name)
if display_name and "x-displayName" not in tag:
tag["x-displayName"] = display_name
tag_objects.append(tag)
return tag_objects
def _openapi_tag_group_name(tag: str) -> str:
prefix, separator, suffix = tag.partition(".")
if separator and prefix and suffix:
return prefix
return tag
def _openapi_tag_display_name(tag: str) -> str | None:
prefix, separator, suffix = tag.partition(".")
if separator and prefix and suffix:
return suffix
return None
def init_middlewares(app: FastAPI) -> None:
@app.middleware("http")
async def request_context_middleware(request: Request, call_next):
token = _current_request.set(request)
trace_id = request.headers.get("X-Trace-Id") or uuid.uuid4().hex
request_id = request.headers.get("X-Request-Id") or uuid.uuid4().hex
request.state.trace_id = trace_id
request.state.request_id = request_id
started_at = time.perf_counter()
response: Response | None
try:
response = await call_next(request)
except Exception:
request.state.response_code = getattr(request.state, "response_code", 500)
_log_request(request, started_at, 500)
_current_request.reset(token)
raise
response.headers.setdefault("X-Trace-Id", trace_id)
response.headers.setdefault("X-Request-Id", request_id)
_log_request(request, started_at, response.status_code)
_current_request.reset(token)
return response
def _log_request(request: Request, started_at: float, status_code: int) -> None:
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
logger.info(
"request method=%s path=%s status=%s code=%s durationMs=%s ip=%s",
request.method,
request.url.path,
status_code,
getattr(request.state, "response_code", "-"),
duration_ms,
request.client.host if request.client else "-",
extra=log_extra(request),
)
def _resolve_config(
config_name: str | None,
config_mapping: Mapping[str, type[BaseConfig] | BaseConfig] | type[BaseConfig] | BaseConfig | None,
) -> BaseConfig:
if config_mapping is None:
return get_config(config_name)
if isinstance(config_mapping, Mapping):
env_name = config_name or get_env_name()
value = config_mapping.get(env_name, config_mapping.get("default"))
if value is None:
return get_config(config_name)
return value() if isinstance(value, type) else value
return config_mapping() if isinstance(config_mapping, type) else config_mapping
def init_error_handlers(app: FastAPI) -> None:
@app.exception_handler(ItiError)
async def handle_iti_error(request: Request, exc: ItiError):
request.state.response_code = exc.code
return JSONResponse(
status_code=request.app.state.config.response_envelope_http_status,
content=fail(exc.message, code=exc.code, data=exc.data),
)
@app.exception_handler(RequestValidationError)
async def handle_validation_error(request: Request, exc: RequestValidationError):
request.state.response_code = 422
return JSONResponse(
status_code=request.app.state.config.response_envelope_http_status,
content=fail("参数验证错误", code=422, data=exc.errors()),
)
@app.exception_handler(StarletteHTTPException)
async def handle_http_error(request: Request, exc: StarletteHTTPException):
request.state.response_code = exc.status_code
message, data = _http_error_payload(exc)
return JSONResponse(
status_code=request.app.state.config.response_envelope_http_status,
content=fail(message, code=exc.status_code, data=data),
headers=exc.headers,
)
@app.exception_handler(SQLAlchemyError)
async def handle_db_error(request: Request, exc: SQLAlchemyError):
request.state.response_code = 500
error_logger.exception("database error", extra=log_extra(request))
return JSONResponse(
status_code=request.app.state.config.response_envelope_http_status,
content=fail("数据库错误", code=500, data=str(exc)),
)
@app.exception_handler(Exception)
async def handle_exception(request: Request, exc: Exception):
request.state.response_code = 500
error_logger.exception("server error", extra=log_extra(request))
return JSONResponse(
status_code=request.app.state.config.response_envelope_http_status,
content=fail("服务器错误", code=500, data=str(exc)),
)
def _http_error_payload(exc: StarletteHTTPException) -> tuple[str, Any]:
detail = exc.detail
if isinstance(detail, str):
return detail, None
if detail is None:
return _http_status_phrase(exc.status_code), None
if isinstance(detail, Mapping):
for key in ("message", "detail", "error"):
value = detail.get(key)
if isinstance(value, str) and value:
return value, detail
return _http_status_phrase(exc.status_code), detail
def _http_status_phrase(status_code: int) -> str:
try:
return HTTPStatus(status_code).phrase
except ValueError:
return "HTTP Error"
def to_plain_data(value: Any) -> Any:
if is_dataclass(value):
return asdict(value)
return value
def install_auto_envelope(app: FastAPI) -> None:
config = app.state.config
if not config.response_envelope_enabled:
return
raw_paths = tuple(config.raw_response_paths)
for route in app.routes:
if not isinstance(route, APIRoute):
continue
if getattr(route, "__iti_envelope_installed__", False):
continue
if _is_route_raw(route, raw_paths):
continue
original_call = route.dependant.call
if original_call is None:
continue
route.endpoint = _wrap_endpoint_with_envelope(original_call)
_rebuild_route_dependant(route)
setattr(route, "__iti_envelope_installed__", True)
def _is_route_raw(route: APIRoute, raw_paths: Iterable[str]) -> bool:
endpoint = route.endpoint
if getattr(endpoint, "__iti_raw_response__", False):
return True
for path in route.path_format, route.path:
request = _PathOnlyRequest(path)
if is_raw_response_request(request, raw_paths):
return True
return False
def _wrap_endpoint_with_envelope(func):
@wraps(func)
async def wrapper(*args, **kwargs):
result = func(*args, **kwargs)
if isawaitable(result):
result = await result
if isinstance(result, Response):
return result
payload = _to_jsonable(result)
if is_envelope_payload(payload):
_mark_response_code(args, kwargs, payload["code"])
return payload
_mark_response_code(args, kwargs, 200)
return {"data": payload, "code": 200, "message": "成功"}
return wrapper
def _to_jsonable(value: Any) -> Any:
if value is None:
return None
if isinstance(value, BaseModel):
return value.model_dump(by_alias=True)
if is_dataclass(value):
return asdict(value)
return value
def _mark_response_code(args: tuple[Any, ...], kwargs: dict[str, Any], code: int) -> None:
request = _request_from_call(args, kwargs) or _current_request.get()
if request is not None:
request.state.response_code = code
def _request_from_call(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Request | None:
for value in list(args) + list(kwargs.values()):
if isinstance(value, Request):
return value
return None
def _rebuild_route_dependant(route: APIRoute) -> None:
route.dependant = get_dependant(
path=route.path_format,
call=route.endpoint,
scope="function",
)
for depends in route.dependencies[::-1]:
route.dependant.dependencies.insert(
0,
get_parameterless_sub_dependant(depends=depends, path=route.path_format),
)
route._flat_dependant = get_flat_dependant(route.dependant)
route._embed_body_fields = _should_embed_body_fields(route._flat_dependant.body_params)
route.body_field = get_body_field(
flat_dependant=route._flat_dependant,
name=route.unique_id,
embed_body_fields=route._embed_body_fields,
)
route.app = request_response(route.get_route_handler())
class _PathOnlyRequest:
def __init__(self, path: str) -> None:
self.url = type("URL", (), {"path": path})()
self.scope = {"endpoint": None}
if __name__ == "__main__":
app.run(debug=True)

@ -0,0 +1,103 @@
import os
import warnings
from apiflask import APIFlask
from iti.applications.common.utils.schema import custom_schema_name_resolver
from iti.applications.service import init_services
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):
"""
应用工厂函数
Args:
config_name: 配置名称 ('dev', 'test', 'prod')
如果为 None则从环境变量 FLASK_ENV 读取
Returns:
Flask 应用实例
docs_ui: The UI of API documentation, one of `swagger-ui` (default), `redoc`,
`elements`, `rapidoc`, and `rapipdf`.
"""
# 忽略 apispec 的 schema 名称冲突警告
warnings.filterwarnings(
"ignore",
message="Multiple schemas resolved to the name",
category=UserWarning,
module="apispec.ext.marshmallow.openapi",
)
app = APIFlask(
__name__.split(".")[0],
title="iTi-Flask",
version="1.0.0",
json_errors=True,
docs_ui="elements",
)
# 加载配置
config_obj = get_config(config_name)
app.config.from_object(config_obj)
# 配置自定义 schema 名称解析器
# 参考https://zh.apiflask.com/schema/#%E6%A8%A1%E5%BC%8F%E5%90%8D%E7%A7%B0%E8%A7%A3%E6%9E%90%E5%99%A8
# 用于解决循环引用和嵌套 schema 导致的命名冲突警告
app.schema_name_resolver = custom_schema_name_resolver
# 确保必要的目录存在
_ensure_directories(app)
# 使用第三方JWT自定义Security避免doc无法传递header
# 等同于 SECURITY_SCHEMES 配置
app.security_schemes = {
"JWT": {
"type": "apiKey",
"in": "header",
"name": "Authorization",
}
}
# 保护doc文档鉴权后才可访问
# app.config['SPEC_DECORATORS'] = [jwt_required()]
# app.config['DOCS_DECORATORS'] = [jwt_required()]
# 初始化扩展
init_exts(app)
# 初始化事件处理器
init_event_handlers(app)
# 初始化路由
init_routes(app)
# 初始化Services
init_services(app)
# 打印当前环境信息
env = config_name or os.getenv("FLASK_ENV", "dev")
print(f"🚀 应用启动 - 环境: {env}")
print(f"📊 数据库: {app.config.get('SQLALCHEMY_DATABASE_URI')}")
return app
def _ensure_directories(app):
"""确保必要的目录存在"""
# 数据库目录SQLite
db_uri = app.config.get("SQLALCHEMY_DATABASE_URI", "")
if "sqlite:///" in db_uri and not db_uri.endswith(":memory:"):
db_path = db_uri.replace("sqlite:///", "")
db_dir = os.path.dirname(db_path)
if db_dir and not os.path.exists(db_dir):
os.makedirs(db_dir, exist_ok=True)
file_storage_config = app.config.get("FILE_STORAGE", {})
local_config = file_storage_config.get("LOCAL", {})
local_path = local_config.get("base_path")
if local_path:
os.makedirs(local_path, exist_ok=True)

@ -0,0 +1,3 @@
from .logger import setup_logger
from .filter import ModelFilter
from .permission import permission, check_permission, check_permission_or_raise

@ -0,0 +1,226 @@
from sqlalchemy.orm import Mapped, mapped_column
from iti.applications.extensions import db, ma
import datetime
from marshmallow import Schema
from marshmallow_sqlalchemy import SQLAlchemyAutoSchema
import uuid
from flask_jwt_extended import current_user, verify_jwt_in_request
from typing import Optional
class IdModelMixin(object):
"""
ID模型混入类自动生成36位UUID作为主键
"""
id: Mapped[str] = mapped_column(
db.String(36),
primary_key=True,
default=lambda: str(uuid.uuid4().hex),
comment="标识",
sort_order=1,
)
class AuditModelMixin(object):
"""
审计模型混入类
"""
def get_current_user_identity():
verify_jwt_in_request(True)
return current_user.id if current_user else None
created_by: Mapped[Optional[str]] = mapped_column(
db.String(36),
comment="创建人",
sort_order=51,
nullable=True,
index=True,
default=get_current_user_identity,
)
updated_by: Mapped[Optional[str]] = mapped_column(
db.String(36),
comment="更新人",
sort_order=61,
nullable=True,
index=True,
default=get_current_user_identity,
onupdate=get_current_user_identity,
)
class TimeModelMixin(object):
"""
时间模型混入类
"""
created_at: Mapped[datetime.datetime] = mapped_column(
db.DateTime,
default=datetime.datetime.now,
comment="创建时间",
sort_order=50,
)
updated_at: Mapped[datetime.datetime] = mapped_column(
db.DateTime,
default=datetime.datetime.now,
onupdate=datetime.datetime.now,
comment="更新时间",
sort_order=60,
)
class RemarkModelMixin(object):
"""
备注模型混入类
"""
remark: Mapped[Optional[str]] = mapped_column(
db.String(255),
comment="备注",
sort_order=100,
nullable=True,
)
class BaseModelMixin(db.Model, IdModelMixin, TimeModelMixin, RemarkModelMixin, AuditModelMixin):
"""
基础模型混入类
"""
__abstract__ = True
__table_args__ = {
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_general_ci",
}
class BaseWithoutIdModelMixin(db.Model, TimeModelMixin, RemarkModelMixin):
"""
基础模型混入类不包含ID
"""
__abstract__ = True
__table_args__ = {
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_general_ci",
}
class LogicalDeleteModelMixin(object):
"""
逻辑删除混入类
示例
class Test(db.Model, LogicalDeleteMixin):
__tablename__ = 'admin_test'
id = db.Column(db.Integer, primary_key=True, comment='角色ID')
# 软删除
Test.query.filter_by(id=1).soft_delete()
# 查询所有未删除的记录
Test.query.logic_all()
"""
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(
db.DateTime,
comment="删除时间",
sort_order=70,
nullable=True,
)
def auto_model_jsonify(data, model: db.Model):
"""
自动序列化模型数据为 JSON 格式无需手动定义 Schema
示例
power_data = curd.auto_model_jsonify(data=dept, model=Dept)
:param data: 需要序列化的 SQLAlchemy 查询结果
:param model: SQLAlchemy 模型类
:return: 返回序列化后的 JSON 数据
"""
def get_model():
return model
class AutoSchema(SQLAlchemyAutoSchema):
class Meta(Schema):
model = get_model()
include_fk = True # 包含外键
include_relationships = True # 包含关联关系
load_instance = True # 反序列化时加载为模型实例
common_schema = AutoSchema(many=True) # 支持序列化多个对象
output = common_schema.dump(data)
return output
def model_to_dicts(schema: ma.Schema, data):
"""
使用指定的 Schema 序列化 SQLAlchemy 查询结果
:param schema: Marshmallow Schema
:param data: SQLAlchemy 查询结果
:return: 返回序列化后的数据返回字典
"""
common_schema = schema(many=True) # 支持序列化多个对象
output = common_schema.dump(data)
return output
def get_one_by_id(model: db.Model, id):
"""
根据 ID 查询单个记录
:param model: SQLAlchemy 模型类
:param id: 记录的主键 ID
:return: 返回查询到的记录如果未找到则返回 None
"""
return model.query.filter_by(id=id).first()
def delete_one_by_id(model: db.Model, id):
"""
根据 ID 删除单个记录
:param model: SQLAlchemy 模型类
:param id: 记录的主键 ID
:return: 返回删除操作影响的行数
"""
r = model.query.filter_by(id=id).delete()
db.session.commit()
return r
def enable_status(model: db.Model, id):
"""
启用指定 ID 的记录
:param model: SQLAlchemy 模型类
:param id: 记录的主键 ID
:return: 如果操作成功返回 True否则返回 False
"""
enable = 1
role = model.query.filter_by(id=id).update({"enable": enable})
if role:
db.session.commit()
return True
return False
def disable_status(model: db.Model, id):
"""
停用指定 ID 的记录
:param model: SQLAlchemy 模型类
:param id: 记录的主键 ID
:return: 如果操作成功返回 True否则返回 False
"""
enable = 0
role = model.query.filter_by(id=id).update({"enable": enable})
if role:
db.session.commit()
return True
return False

@ -0,0 +1,50 @@
from enum import StrEnum
class UserEvents(StrEnum):
"""
用户事件
"""
USER_UPDATED = "user.updated" # 用户信息更新事件
USER_DELETED = "user.deleted" # 用户删除事件
USER_LOGGED_IN = "user.logged_in" # 用户登录事件
USER_LOGOUT = "user.logout" # 用户注销事件
USER_AUTH_REFRESHED = "user.auth.refreshed" # 用户刷新令牌事件
USER_REGISTERED = "user.registered" # 用户注册事件
USER_PASSWORD_UPDATED = "user.password.updated" # 用户密码更新事件
class UserRelEvents(StrEnum):
"""
用户关联关系事件
"""
USER_ROLES_UPDATED = "user.roles.updated" # 用户角色关联关系更新事件
USER_DEPTS_UPDATED = "user.depts.updated" # 用户部门关联关系更新事件
class RoleEvents(StrEnum):
"""
角色事件
"""
ROLE_UPDATED = "role.updated" # 角色信息更新事件
ROLE_DELETED = "role.deleted" # 角色删除事件
class RoleRelEvents(StrEnum):
"""
角色关联关系事件
"""
ROLE_MENUS_UPDATED = "role.menus.updated" # 角色菜单关联关系更新事件
class MenuEvents(StrEnum):
"""
菜单事件
"""
MENU_UPDATED = "menu.updated" # 菜单信息更新事件
MENU_DELETED = "menu.deleted" # 菜单删除事件

@ -1,7 +1,7 @@
from iti.exceptions import BizError
from werkzeug.exceptions import HTTPException
class BizException(BizError):
class BizException(HTTPException):
def __init__(self, message: str = "操作失败", code: int = 500, data=None):
self.message = message
self.code = code

@ -1,7 +1,4 @@
from iti.exceptions import PermissionDenied
class PermissionDeniedException(PermissionDenied):
class PermissionDeniedException(Exception):
"""
权限拒绝异常
"""

@ -1,4 +1,5 @@
from sqlalchemy import and_
from iti.applications.extensions.db import db
class ModelFilter:
@ -168,7 +169,7 @@ class ModelFilter:
"type": self.type_between,
}
def get_filter(self, model):
def get_filter(self, model: db.Model):
"""
生成安全的SQLAlchemy过滤条件

@ -0,0 +1,459 @@
from typing import Union, List, Callable, Optional
from functools import wraps
from flask_jwt_extended import jwt_required, current_user
from flask import current_app
from iti.applications.common.exceptions.permission import PermissionDeniedException
# ==================== 配置读取工具 ====================
def get_permission_config(key: str, default=None):
"""
获取权限配置项
Args:
key: 配置键名
default: 默认值
Returns:
配置值如果不存在则返回默认值
"""
try:
permission_config = current_app.config.get("PERMISSION_CONFIG", {})
return permission_config.get(key, default)
except RuntimeError:
# 不在 Flask 应用上下文中,返回默认值
return default
def get_super_admin_role() -> str:
"""获取超级管理员角色代码"""
return get_permission_config("SUPER_ADMIN_ROLE", "SUPER_ADMIN")
def get_default_error_message() -> str:
"""获取默认错误消息"""
return get_permission_config("DEFAULT_ERROR_MESSAGE", "权限不足")
def get_default_error_code() -> int:
"""获取默认错误代码"""
return get_permission_config("DEFAULT_ERROR_CODE", 403)
def get_skip_super_admin_default() -> bool:
"""获取是否跳过超级管理员检查的默认值"""
return get_permission_config("SKIP_SUPER_ADMIN_DEFAULT", True)
class PermissionChecker:
"""
权限检查器基类策略模式
"""
def check(self, user, value) -> bool:
"""
检查用户是否满足权限要求
Args:
user: 用户对象
value: 权限值权限码角色码部门ID等
Returns:
bool: 是否满足权限要求
"""
raise NotImplementedError("子类必须实现 check 方法")
class PermissionCodeChecker(PermissionChecker):
"""
权限字符检查器
检查用户是否拥有指定的权限码
"""
def check(self, user, permissions) -> bool:
"""
检查用户权限
Args:
user: 用户对象
permissions: 单个权限码或权限码列表
Returns:
bool: 用户是否拥有指定权限
"""
if not user or not hasattr(user, "permissions"):
return False
# 统一转为列表处理
if isinstance(permissions, str):
permissions = [permissions]
user_permissions = set(user.permissions or [])
return any(perm in user_permissions for perm in permissions)
class RoleChecker(PermissionChecker):
"""
角色检查器
检查用户是否拥有指定的角色
"""
def check(self, user, roles: Union[str, List[str]]) -> bool:
"""
检查用户角色
Args:
user: 用户对象
roles: 单个角色码或角色码列表
Returns:
bool: 用户是否拥有指定角色
"""
if not user or not hasattr(user, "roles"):
return False
# 统一转为列表处理
if isinstance(roles, str):
roles = [roles]
user_role_codes = set(role.code for role in user.roles if hasattr(role, "code"))
return any(role in user_role_codes for role in roles)
class FunctionChecker(PermissionChecker):
"""
函数检查器
执行用户提供的自定义验证函数
"""
def check(self, user, func: Callable) -> bool:
"""
执行自定义检查函数
Args:
user: 用户对象
func: 自定义检查函数接收 user 参数返回 bool
Returns:
bool: 自定义函数的返回值
"""
if not callable(func):
return False
try:
return bool(func(user))
except Exception:
# 自定义函数执行异常时,视为权限检查失败
return False
class PermissionValidator:
"""
权限验证器
组合多个检查器根据逻辑规则AND/OR进行综合判断
"""
# 检查器类型映射
CHECKER_MAP = {
"permissions": PermissionCodeChecker,
"roles": RoleChecker,
"function": FunctionChecker,
}
def __init__(self, logic: str = "AND", skip_super_admin: bool = True):
"""
初始化验证器
Args:
logic: 逻辑组合方式"AND" "OR"
skip_super_admin: 是否跳过超级管理员的权限检查
"""
self.logic = logic.upper()
self.skip_super_admin = skip_super_admin
self.checkers = [] # [(checker_instance, value), ...]
def add_checker(self, checker_type: str, value):
"""
添加检查器
Args:
checker_type: 检查器类型permissions/roles/function
value: 检查值
"""
if checker_type not in self.CHECKER_MAP:
raise ValueError(f"不支持的检查器类型: {checker_type}")
checker_class = self.CHECKER_MAP[checker_type]
checker_instance = checker_class()
self.checkers.append((checker_instance, value))
def validate(self, user) -> bool:
"""
执行权限验证
Args:
user: 用户对象
Returns:
bool: 是否通过验证
"""
# 用户不存在,验证失败
if not user:
return False
# 超级管理员跳过权限检查
if self.skip_super_admin and self._is_super_admin(user):
return True
# 没有添加任何检查器,默认通过
if not self.checkers:
return True
# 执行所有检查器
results = [checker.check(user, value) for checker, value in self.checkers]
# 根据逻辑规则返回结果
if self.logic == "AND":
return all(results) # 全部通过
elif self.logic == "OR":
return any(results) # 任一通过
else:
raise ValueError(f"不支持的逻辑类型: {self.logic}")
def _is_super_admin(self, user) -> bool:
"""
判断是否为超级管理员
Args:
user: 用户对象
Returns:
bool: 是否为超级管理员
"""
if not hasattr(user, "roles"):
return False
# 从配置中获取超级管理员角色代码
super_admin_role = get_super_admin_role()
# 检查是否有超级管理员角色
return any(
role.code == super_admin_role
for role in user.roles
if hasattr(role, "code")
)
def permission(
permissions: Optional[Union[str, List[str]]] = None,
roles: Optional[Union[str, List[str]]] = None,
func: Optional[Callable] = None,
logic: str = "AND",
error_message: Optional[str] = None,
error_code: Optional[int] = None,
skip_super_admin: Optional[bool] = None,
):
"""
权限检查装饰器
用于路由函数在函数执行前进行权限验证
Args:
permissions: 权限码单个或列表
roles: 角色码单个或列表
depts: 部门ID单个或列表
func: 自定义检查函数
logic: 逻辑组合方式"AND"全部满足 "OR"满足任一
error_message: 权限不足时的错误消息
error_code: 权限不足时的错误代码
skip_super_admin: 是否跳过超级管理员的权限检查
Returns:
装饰器函数
Examples:
>>> @require_permission(permissions="user:create")
>>> def create_user():
>>> pass
>>> @require_permission(roles=["admin", "manager"], logic="OR")
>>> def manage_system():
>>> pass
>>> @require_permission(
>>> permissions="data:export",
>>> roles="admin",
>>> logic="AND"
>>> )
>>> def export_data():
>>> pass
"""
def decorator(ff):
@jwt_required()
@wraps(ff)
def wrapper(*args, **kwargs):
# 从配置读取默认值(如果参数未提供)
_error_message = (
error_message
if error_message is not None
else get_default_error_message()
)
_error_code = (
error_code if error_code is not None else get_default_error_code()
)
_skip_super_admin = (
skip_super_admin
if skip_super_admin is not None
else get_skip_super_admin_default()
)
# 获取当前用户
user = current_user
# 创建验证器
validator = PermissionValidator(
logic=logic, skip_super_admin=_skip_super_admin
)
# 添加检查器
if permissions is not None:
validator.add_checker("permissions", permissions)
if roles is not None:
validator.add_checker("roles", roles)
if func is not None:
validator.add_checker("function", func)
# 执行验证
if not validator.validate(user):
raise PermissionDeniedException(_error_message, _error_code)
# 权限验证通过,执行原函数
return ff(*args, **kwargs)
return wrapper
return decorator
def check_permission(
user=None,
permissions: Optional[Union[str, List[str]]] = None,
roles: Optional[Union[str, List[str]]] = None,
func: Optional[Callable] = None,
logic: str = "AND",
skip_super_admin: Optional[bool] = None,
) -> bool:
"""
快捷权限检查方法
用于在函数内部进行权限判断返回布尔值
Args:
user: 用户对象不传则使用 current_user
permissions: 权限码单个或列表
roles: 角色码单个或列表
func: 自定义检查函数
logic: 逻辑组合方式"AND"全部满足 "OR"满足任一
skip_super_admin: 是否跳过超级管理员的权限检查
Returns:
bool: 是否通过权限检查
Examples:
>>> if check_permission(permissions="user:delete"):
>>> # 执行删除逻辑
>>> pass
>>> if check_permission(roles=["admin", "manager"], logic="OR"):
>>> # 管理员或经理可见
>>> pass
>>> if check_permission(
>>> permissions="data:export",
>>> roles="admin",
>>> logic="AND"
>>> ):
>>> # 导出数据
>>> pass
"""
# 从配置读取默认值(如果参数未提供)
_skip_super_admin = (
skip_super_admin
if skip_super_admin is not None
else get_skip_super_admin_default()
)
# 如果未传入用户,使用当前用户
if user is None:
user = current_user
# 创建验证器
validator = PermissionValidator(logic=logic, skip_super_admin=_skip_super_admin)
# 添加检查器
if permissions is not None:
validator.add_checker("permissions", permissions)
if roles is not None:
validator.add_checker("roles", roles)
if func is not None:
validator.add_checker("function", func)
# 执行验证并返回结果
return validator.validate(user)
def check_permission_or_raise(
user=None,
permissions: Optional[Union[str, List[str]]] = None,
roles: Optional[Union[str, List[str]]] = None,
func: Optional[Callable] = None,
logic: str = "AND",
error_message: Optional[str] = None,
error_code: Optional[int] = None,
skip_super_admin: Optional[bool] = None,
):
"""
快捷权限检查方法失败时抛出异常
用于在函数内部进行权限判断不通过时直接抛出异常
Args:
user: 用户对象不传则使用 current_user
permissions: 权限码单个或列表
roles: 角色码单个或列表
func: 自定义检查函数
logic: 逻辑组合方式"AND"全部满足 "OR"满足任一
error_message: 权限不足时的错误消息
error_code: 权限不足时的错误代码
skip_super_admin: 是否跳过超级管理员的权限检查
Raises:
PermissionDeniedException: 权限不足时抛出
Examples:
>>> check_permission_or_raise(permissions="user:delete")
>>> # 继续执行删除逻辑
"""
# 从配置读取默认值(如果参数未提供)
_error_message = (
error_message if error_message is not None else get_default_error_message()
)
_error_code = error_code if error_code is not None else get_default_error_code()
_skip_super_admin = (
skip_super_admin
if skip_super_admin is not None
else get_skip_super_admin_default()
)
if not check_permission(
user=user,
permissions=permissions,
roles=roles,
func=func,
logic=logic,
skip_super_admin=_skip_super_admin,
):
raise PermissionDeniedException(_error_message, _error_code)

@ -0,0 +1,154 @@
# 文件存储系统
## 概述
本存储系统采用策略模式设计,支持多种存储后端,包括本地存储和各大云服务商的对象存储服务。
## 支持的存储类型
- **本地存储 (local)** - 默认,无需额外依赖
- **阿里云OSS (aliyun_oss)** - 需要安装 `oss2`
- **腾讯云COS (tencent_cos)** - 需要安装 `cos-python-sdk-v5`
- **七牛云Kodo (qiniu_kodo)** - 需要安装 `qiniu`
- **华为云OBS (huawei_obs)** - 需要安装 `esdk-obs-python`
- **AWS S3 (aws_s3)** - 待实现
- **MinIO (minio)** - 待实现
## 安装依赖
根据需要安装对应的SDK
```bash
# 阿里云OSS
pip install oss2
# 腾讯云COS
pip install cos-python-sdk-v5
# 七牛云Kodo
pip install qiniu
# 华为云OBS
pip install esdk-obs-python
```
或一次性安装所有依赖:
```bash
pip install oss2 cos-python-sdk-v5 qiniu esdk-obs-python
```
## 配置说明
### 环境变量配置
`.env` 文件中配置对应的云存储凭证:
```bash
# 阿里云OSS
ALIYUN_OSS_ACCESS_KEY_ID=your_access_key_id
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=your_secret_id
TENCENT_COS_SECRET_KEY=your_secret_key
TENCENT_COS_REGION=ap-guangzhou
TENCENT_COS_BUCKET=your_bucket_name
# 七牛云Kodo
QINIU_KODO_ACCESS_KEY=your_access_key
QINIU_KODO_SECRET_KEY=your_secret_key
QINIU_KODO_BUCKET=your_bucket_name
QINIU_KODO_DOMAIN=your_cdn_domain.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
```
### 应用配置
`config.py` 中修改默认存储类型:
```python
FILE_STORAGE = {
"DEFAULT_STORAGE_TYPE": "aliyun_oss", # 修改为你想要的默认存储
"LOCAL": {
"base_path": "/path/to/uploads",
},
"ALIYUN_OSS": {
# ...
},
}
```
## 使用方式
### 1. 全局默认存储
在配置文件中设置 `DEFAULT_STORAGE_TYPE`,所有未指定存储类型的文件将使用此存储。
### 2. 目录级存储
为特定目录设置 `default_storage_type`
```python
directory = SysFileDirectory(
name="用户头像",
path="/avatars",
default_storage_type="aliyun_oss" # 该目录下的文件使用阿里云OSS
)
```
### 3. 请求级存储
上传文件时通过参数指定:
```javascript
// 普通上传
formData.append('storageType', 'tencent_cos');
// TUS上传
uppy.setMeta({
storageType: 'qiniu_kodo'
});
```
## 存储优先级
请求参数 > 目录默认 > 全局默认
## 接口说明
所有存储适配器实现了统一的 `StorageInterface` 接口:
- `upload()` - 上传文件
- `append_chunk()` - 追加数据块用于TUS协议
- `download()` - 下载文件
- `delete()` - 删除文件
- `exists()` - 检查文件是否存在
- `get_url()` - 获取访问URL支持签名URL
## 注意事项
1. **TUS协议支持**
- 阿里云OSS、华为云OBS 支持原生追加写入
- 腾讯云COS、七牛云Kodo 使用读-改-写策略模拟追加(性能较低,不建议用于大文件)
2. **URL签名**
- 所有云存储都支持生成带签名的临时URL
- 设置 `expires=0` 可生成永久URL仅适用于公共读bucket
3. **秒传机制**
- 系统会检查文件哈希,相同存储类型下的重复文件自动复用
- 不同存储类型的相同文件会分别存储
4. **错误处理**
- SDK未安装时会抛出 `ImportError`
- 配置不完整时会抛出 `ValueError`
- 文件操作失败时会抛出相应异常

@ -0,0 +1,110 @@
from __future__ import annotations
import os
from typing import Dict, Optional, Union
from flask import current_app
from .interface import StorageInterface
from .local import LocalStorage
from ..enums import StorageTypeEnum
class StorageManager:
"""存储管理器,负责根据类型创建存储实例"""
_instances: Dict[str, StorageInterface] = {}
@classmethod
def get_storage(cls, storage_type: Optional[Union[str, StorageTypeEnum]] = None) -> StorageInterface:
"""
获取存储实例单例模式
Args:
storage_type: 存储类型支持字符串或 StorageTypeEnum为None时使用默认类型
Returns:
存储实例
"""
# 标准化存储类型为字符串
storage_type_str = cls._normalize_storage_type(storage_type)
if storage_type_str not in cls._instances:
config = current_app.config.get("FILE_STORAGE", {})
cls._instances[storage_type_str] = cls._create_storage(storage_type_str, config)
return cls._instances[storage_type_str]
@staticmethod
def _normalize_storage_type(storage_type: Optional[Union[str, StorageTypeEnum]]) -> str:
"""
标准化存储类型为字符串
Args:
storage_type: 存储类型字符串或 StorageTypeEnum
Returns:
存储类型字符串
"""
# 如果未指定,使用默认类型
if storage_type is None:
config = current_app.config.get("FILE_STORAGE", {})
return config.get("DEFAULT_STORAGE_TYPE", StorageTypeEnum.LOCAL.value)
# 如果是 enum转换为字符串
if isinstance(storage_type, StorageTypeEnum):
return storage_type.value
# 已经是字符串,直接返回
return storage_type
@staticmethod
def _create_storage(storage_type: str, config: dict) -> StorageInterface:
"""
创建存储实例
Args:
storage_type: 存储类型
config: 配置字典
Returns:
存储实例
"""
if storage_type == StorageTypeEnum.LOCAL.value:
local_config = config.get("LOCAL", {})
if not local_config.get("base_path"):
local_config["base_path"] = os.path.join(
current_app.config.get("BASE_DIR", current_app.root_path), "runtime", "uploads"
)
return LocalStorage(local_config)
elif storage_type == StorageTypeEnum.ALIYUN_OSS.value:
from .aliyun_oss import AliyunOSSStorage
oss_config = config.get("ALIYUN_OSS", {})
return AliyunOSSStorage(oss_config)
elif storage_type == StorageTypeEnum.TENCENT_COS.value:
from .tencent_cos import TencentCOSStorage
cos_config = config.get("TENCENT_COS", {})
return TencentCOSStorage(cos_config)
elif storage_type == StorageTypeEnum.QINIU_KODO.value:
from .qiniu_kodo import QiniuKodoStorage
kodo_config = config.get("QINIU_KODO", {})
return QiniuKodoStorage(kodo_config)
elif storage_type == StorageTypeEnum.AWS_S3.value:
# AWS S3 可以后续添加
raise NotImplementedError("AWS S3 适配器尚未实现")
elif storage_type == StorageTypeEnum.HUAWEI_OBS.value:
from .huawei_obs import HuaweiOBSStorage
obs_config = config.get("HUAWEI_OBS", {})
return HuaweiOBSStorage(obs_config)
elif storage_type == StorageTypeEnum.MINIO.value:
from .minio_storage import MinIOStorage
minio_config = config.get("MINIO", {})
return MinIOStorage(minio_config)
raise ValueError(f"未支持的存储类型: {storage_type}")

@ -0,0 +1,27 @@
"""
通用工具模块
"""
from .http import success, fail, page, pagination_builder
from .schema import (
Pagination,
PaginationSchema,
pagination_fields,
pagination_schema_fields,
page_schema,
condition_schema,
BaseSchema,
custom_schema_name_resolver
)
from .tree import (
build_tree_from_list,
flatten_tree,
find_node_by_id,
get_node_path,
filter_tree_by_condition,
get_tree_depth,
TreeKeyConfig,
default_key_config,
)
from .str import camel_case
from .time import parse_datetime_string

@ -0,0 +1,54 @@
import time
cache_dict = {}
def cache_set_internal(key, value, expired=5):
"""
程序内部实现的记录缓存用于简单体量不大的缓存记录在程序结束后销毁对于高速体量大的环境请配置 Redis 等服务自行记录
记录缓存存储键值对并记录当前时间作为缓存的时间戳
:param key:
:param value:
:param expired: 过期时间默认5秒
"""
if value is None:
return
cache_dict[key] = {"value": value, "expired_time": time.time() + expired}
def cache_get_internal(key):
"""
获取缓存根据键从缓存中获取值并检查是否过期
:param key:
:return: 如果缓存存在且未过期返回缓存的值否则返回 None
"""
if key in cache_dict:
cache_item = cache_dict[key]
if time.time() < cache_item["expired_time"]:
return cache_item["value"]
else:
# 如果缓存已过期,删除该缓存
del cache_dict[key]
return None
def cache_auto_internal(key, call, expired=5):
"""
如果缓存存在直接返回缓存内容缓存不存在或者过期执行 call 函数并取得返回值记录并返回
:param key:
:param call: 获取新值的地方
:param expired: 过期时间默认5秒
"""
data = cache_get_internal(key)
if data is not None:
return data
data = call()
cache_set_internal(key, data, expired)
return data

@ -0,0 +1,268 @@
"""
HTTP 响应包装工具
提供统一的 API 响应格式包装函数和分页工具
"""
from typing import Any, Optional, Union
from flask import request
from urllib.parse import urlencode
import math
# ==================== 包装函数 ====================
def success(data: Any = None, message: str = "成功", code: int = 200) -> dict:
"""
成功响应包装
Args:
data: 返回数据
message: 提示信息默认 "成功"
code: 业务状态码默认 200
Returns:
符合 BaseResponse 格式的字典
Example:
>>> return success({'id': 1, 'name': 'test'})
{'data': {'id': 1, 'name': 'test'}, 'code': 200, 'message': '成功'}
>>> return success([1, 2, 3], message='查询成功')
{'data': [1, 2, 3], 'code': 200, 'message': '查询成功'}
"""
return {"data": data, "code": code, "message": message}
def fail(message: str = "操作失败", code: int = 500, data: Any = None) -> dict:
"""
失败响应包装HTTP 状态码保持 200由前端根据 code 判断
Args:
message: 错误信息
code: 业务错误码默认 500
data: 额外数据如验证错误详情
Returns:
符合 BaseResponse 格式的字典
Example:
>>> return fail('参数错误', code=400)
{'data': None, 'code': 400, 'message': '参数错误'}
>>> return fail('未找到资源', code=404)
{'data': None, 'code': 404, 'message': '未找到资源'}
>>> return fail('验证失败', code=422, data={'username': ['必填项']})
{'data': {'username': ['必填项']}, 'code': 422, 'message': '验证失败'}
"""
return {"data": data, "code": code, "message": message}
def page(
items: Union[list, Any],
pagination: Union[dict, Any, None] = None,
message: str = "成功",
code: int = 200,
) -> dict:
"""
分页响应包装智能识别多种输入方式
支持三种调用方式
1. 传入 SQLAlchemy Pagination 对象自动解析
>>> db_pagination = User.query.paginate(page=1, per_page=10)
>>> return page(db_pagination)
2. 传入数据列表 + 分页信息字典
>>> users = [{'id': 1}, {'id': 2}]
>>> pagination_info = pagination_builder(None, page=1, size=10, total=100)
>>> return page(users, pagination_info)
3. 传入数据列表 + SQLAlchemy Pagination 对象
>>> db_pagination = User.query.paginate(page=1, per_page=10)
>>> users = UserSchema(many=True).dump(db_pagination.items)
>>> return page(users, db_pagination)
Args:
items: 数据列表 SQLAlchemy Pagination 对象
pagination: 分页信息字典 SQLAlchemy Pagination 对象 None
message: 提示信息
code: 状态码
Returns:
符合 BaseResponse 格式的分页数据
Example:
{
'data': {
'items': [...],
'page': {
'page': 1,
'size': 10,
'pages': 10,
'total': 100,
'current': '...',
'next': '...',
'prev': None,
'first': '...',
'last': '...'
}
},
'code': 200,
'message': '成功'
}
"""
# 方式1: items 是 SQLAlchemy Pagination 对象
if pagination is None and hasattr(items, "items") and hasattr(items, "total"):
pagination_obj = items
items = pagination_obj.items
pagination = pagination_builder(pagination_obj)
# 方式2: pagination 是 SQLAlchemy Pagination 对象
elif (
pagination is not None
and hasattr(pagination, "items")
and hasattr(pagination, "total")
):
pagination_obj = pagination
pagination = pagination_builder(pagination_obj)
# 方式3: pagination 已经是字典,直接使用
# items 已经是列表,直接使用
return {
"data": {"items": items, "page": pagination},
"code": code,
"message": message,
}
# ==================== 分页构建器(参考 APIFlask helpers.py====================
def pagination_builder(
pagination: Any,
*,
page: Optional[int] = None,
size: Optional[int] = None,
total: Optional[int] = None,
pages: Optional[int] = None,
) -> dict:
"""
构建分页信息参考 APIFlask 实现
支持两种调用方式
1. 传入 SQLAlchemy Pagination 对象自动解析
>>> db_pagination = User.query.paginate(page=1, per_page=10)
>>> pagination_info = pagination_builder(db_pagination)
2. 传入自定义参数手动构建
>>> pagination_info = pagination_builder(
... None,
... page=1,
... size=10,
... total=100
... )
Args:
pagination: SQLAlchemy Pagination 对象 None
page: 当前页码手动模式
size: 每页数量手动模式
total: 总记录数手动模式
pages: 总页数可选自动计算
Returns:
符合 PaginationSchema 的字典
Example:
# 自动模式
>>> db_pagination = User.query.paginate(page=1, per_page=10)
>>> info = pagination_builder(db_pagination)
{'page': 1, 'size': 10, 'pages': 10, 'total': 100, ...}
# 手动模式
>>> info = pagination_builder(None, page=1, size=20, total=200)
{'page': 1, 'size': 20, 'pages': 10, 'total': 200, ...}
"""
# 自动模式:从 SQLAlchemy Pagination 对象提取
if pagination is not None and hasattr(pagination, "page"):
page = pagination.page
size = pagination.per_page
pages = pagination.pages
total = pagination.total
has_prev = pagination.has_prev
has_next = pagination.has_next
prev_num = pagination.prev_num if has_prev else None
next_num = pagination.next_num if has_next else None
# 手动模式:使用传入的参数
else:
page = page or 1
size = size or 10
total = total or 0
# 自动计算总页数
if pages is None:
pages = math.ceil(total / size) if size > 0 else 0
# 计算上下页
has_prev = page > 1
has_next = page < pages
prev_num = page - 1 if has_prev else None
next_num = page + 1 if has_next else None
# 生成分页 URL参考 APIFlask 实现)
current_url = _generate_page_url(page, size)
next_url = _generate_page_url(next_num, size) if has_next else None
prev_url = _generate_page_url(prev_num, size) if has_prev else None
first_url = _generate_page_url(1, size)
last_url = _generate_page_url(pages, size) if pages > 0 else None
return {
"page": page,
"size": size, # ✅ 使用 size 代替 per_page
"pages": pages,
"total": total,
"current": current_url,
"next": next_url,
"prev": prev_url,
"first": first_url,
"last": last_url,
}
def _generate_page_url(page_num: Optional[int], page_size: int) -> Optional[str]:
"""
生成分页 URL内部辅助函数
参考 APIFlask URL 生成逻辑
Args:
page_num: 页码
page_size: 每页数量
Returns:
完整的分页 URL None
"""
if page_num is None:
return None
try:
# 获取当前请求的 URL 和查询参数
base_url = request.base_url
args = request.args.copy()
# 更新分页参数
args["page"] = page_num
args["size"] = page_size # ✅ 使用 size 参数名
# 构建完整 URL
if args:
return f"{base_url}?{urlencode(args)}"
return base_url
except RuntimeError:
# 在请求上下文之外调用时返回 None
return None

@ -0,0 +1,189 @@
from dataclasses import field
from marshmallow_dataclass import dataclass
from apiflask.fields import Integer, URL, Field
from marshmallow import fields
from apiflask import Schema
from .str import camel_case
# ==================== Schema 定义 ====================
class BaseSchema(Schema):
"""
基础 Schema 扩展
1. 有序返回
2. 未知字段不报错
"""
class Meta:
ordered = True
unknown = "INCLUDE"
def on_bind_field(self, field_name: str, field_obj: Field) -> None:
"""
绑定字段时处理
1.统一驼峰命名返回(小驼峰)
"""
if field_obj.data_key is None:
field_obj.data_key = camel_case(field_name)
pass
@dataclass(base_schema=Schema)
class Pagination:
page: int = field(default=1, metadata={"metadata": {"description": "当前页码"}})
size: int = field(default=10, metadata={"metadata": {"description": "每页数量"}})
pages: int = field(default=0, metadata={"metadata": {"description": "总页数"}})
total: int = field(default=0, metadata={"metadata": {"description": "总记录数"}})
current: str = field(
default="",
metadata={"metadata": {"description": "当前页URL"}, "dump_only": True},
)
next: str = field(
default="",
metadata={"metadata": {"description": "下一页URL"}, "dump_only": True},
)
prev: str = field(
default="",
metadata={"metadata": {"description": "上一页URL"}, "dump_only": True},
)
first: str = field(
default="", metadata={"metadata": {"description": "首页URL"}, "dump_only": True}
)
last: str = field(
default="", metadata={"metadata": {"description": "末页URL"}, "dump_only": True}
)
# 获取 Pagination 类中的所有字段
pagination_fields = [field.name for field in Pagination.__dataclass_fields__.values()]
class PaginationSchema(Schema):
"""自定义分页信息 Schema与 APIFlask 保持一致)"""
page = Integer(metadata={"description": "当前页码"})
size = Integer(metadata={"description": "每页数量"}) # per_page → size
pages = Integer(metadata={"description": "总页数"})
total = Integer(metadata={"description": "总记录数"})
current = URL(metadata={"description": "当前页URL"}, dump_only=True)
next = URL(metadata={"description": "下一页URL"}, dump_only=True)
prev = URL(metadata={"description": "上一页URL"}, dump_only=True)
first = URL(metadata={"description": "首页URL"}, dump_only=True)
last = URL(metadata={"description": "末页URL"}, dump_only=True)
# 获取 PaginationSchema 类中的所有字段
pagination_schema_fields = list(PaginationSchema._declared_fields.keys())
def page_schema(item_schema_cls: type, *, schema_name: str | None = None) -> type:
"""
根据传入的 Item OutSchema 生成通用分页负载 Schema
{ items: [Item], page: Pagination }
- item_schema_cls: 基础 Schema
- schema_name: 可选自定义 schema 名称
"""
if not schema_name:
schema_name = f"PageItems[{getattr(item_schema_cls, '__name__', 'Item')}]"
class _PageItemsSchema(Schema):
items = fields.Nested(item_schema_cls, many=True, required=True)
page = fields.Nested(PaginationSchema, required=True)
_PageItemsSchema.__name__ = schema_name
return _PageItemsSchema
def condition_schema(base_schema_cls: type, control_config):
"""
多字段控制动态Schema创建
Args:
base_schema_class: 基础 Schema
control_config: 控制配置字典
{
"withDataList": ["data_list"], # 当 withDataList=true 时包含 data_list
"withTimestamps": ["created_at", "updated_at"], # 当 withTimestamps=true 时包含时间字段
"withStatus": ["status"], # 当 withStatus=true 时包含状态字段
}
"""
class _ConditionSchema(base_schema_cls):
def __init__(self, *args, **kwargs):
self._control_config = control_config
super().__init__(*args, **kwargs)
def dump(self, obj, many=None, **kwargs):
from flask import request
# 收集排除字段列表
exclude_fields = []
for control_field, target_fields in self._control_config.items():
try:
control_value = (
request.args.get(control_field, "false").lower() == "true"
)
except Exception:
# 没有请求上下文时,默认不排除字段
control_value = True
if not control_value:
exclude_fields.extend(target_fields)
# 如果有字段需要排除,创建临时 Schema
if exclude_fields:
temp_schema = base_schema_cls(exclude=exclude_fields)
return temp_schema.dump(obj, many=many, **kwargs)
else:
return super().dump(obj, many=many, **kwargs)
return _ConditionSchema
def custom_schema_name_resolver(schema):
"""
自定义 schema 名称解析器解决循环引用导致的命名冲突
根据 APIFlask 官方文档
- 函数接收一个 schema 对象作为参数
- 返回一个字符串作为 schema 的名称
- 用于解决多个 schema 解析为相同名称的问题
处理策略
1. 优先使用 Meta.name如果定义
2. 自动移除 Schema 后缀
3. 为带有 exclude 参数的嵌套 schema 生成唯一名称
"""
schema_class = schema.__class__
# 1. 优先检查是否在 Meta 中定义了 name
if hasattr(schema_class, "Meta") and hasattr(schema_class.Meta, "name"):
base_name = schema_class.Meta.name
else:
# 2. 使用类名,移除 Schema 后缀
base_name = schema_class.__name__
if base_name.endswith("Schema"):
base_name = base_name[:-6]
if schema.partial: # 为部分模式添加 "Update" 后缀
base_name += "Update"
# 3. 处理嵌套时的 exclude 参数
# 当使用 Nested("SomeSchema", exclude=["field1", "field2"]) 时
# apispec 会创建新的 schema 实例,需要为其生成唯一名称
if hasattr(schema, "exclude") and schema.exclude:
# 将 exclude 的字段排序,确保相同的 exclude 组合生成相同的名称
excluded_fields = sorted(schema.exclude)
# 生成简洁的后缀:首字母大写拼接
# 例如exclude=["children", "parent"] -> "ChildrenParent"
suffix = "".join([field.capitalize() for field in excluded_fields])
return f"{base_name}Exclude{suffix}"
# 4. 处理 only 参数(如果使用)
if hasattr(schema, "only") and schema.only:
only_fields = sorted(schema.only)
suffix = "".join([field.capitalize() for field in only_fields])
return f"{base_name}Only{suffix}"
return base_name

@ -0,0 +1,29 @@
from sqlalchemy.ext.declarative import DeclarativeBase
def is_sqlalchemy_model(obj):
"""
判断对象是否为 SQLAlchemy 模型
"""
if isinstance(obj, DeclarativeBase):
return True
if hasattr(obj, "_sa_instance_state"):
return True
if hasattr(obj, "__mapper__"):
return True
return False
def is_orm_result(data):
"""
判断数据是否为 ORM 查询结果
"""
if isinstance(data, list):
if not data:
return False
return is_sqlalchemy_model(data[0])
return is_sqlalchemy_model(data)

@ -0,0 +1,50 @@
from __future__ import annotations
import json
from typing import Any, Optional
def summarize_response_json(obj: dict, items_sample_size: int) -> dict:
"""
{data:{items,page}} 结构中的 items 做摘要保留数量与前 N 条示例
其他结构原样返回
"""
try:
if isinstance(obj, dict) and isinstance(obj.get("data"), dict):
data = obj["data"]
if isinstance(data.get("items"), list):
items = data["items"]
return {
**obj,
"data": {
**data,
"items": {
"count": len(items),
"sample": items[:items_sample_size],
},
},
}
return obj
except Exception:
return obj
def safe_json_dumps(value: Any, ensure_ascii: bool = False, max_chars: Optional[int] = None) -> str:
"""
安全转字符串优先 JSON 编码不可编码时退回 str()可选截断
"""
try:
text = json.dumps(value, ensure_ascii=ensure_ascii)
except Exception:
text = str(value)
if max_chars is not None and max_chars > 0:
return text[:max_chars]
return text
def truncate_text(text: Optional[str], max_chars: int) -> str:
if not text:
return ""
return text[:max_chars]

@ -0,0 +1,240 @@
# XSS 过滤
import validators
from markupsafe import escape
from validators import validator
def str_escape(s):
"""
对字符串进行 XSS 过滤返回转义后的安全字符串
:param s: 需要转义的字符串
:return: 返回转义后的字符串如果输入为空则返回 None
"""
if not s:
return None
return str(escape(s))
def between(*args, **kwargs):
"""
验证数字是否介于最小值和最大值之间
适用于整数浮点数小数和日期等类型
:param value: 需要验证的数字
:param min: 数字的最小值可选
:param max: 数字的最大值可选
:return: 如果验证成功返回 True否则返回 ValidationFailure
示例
>>> between(5, min=2)
True
>>> between(13.2, min=13, max=14)
True
>>> between(500, max=400)
ValidationFailure(func=between, args=...)
"""
return validators.between(*args, **kwargs)
def domain(*args, **kwargs):
"""
验证给定值是否为有效的域名
:param value: 需要验证的域名字符串
:return: 如果验证成功返回 True否则返回 ValidationFailure
示例
>>> domain('example.com')
True
>>> domain('example.com/')
ValidationFailure(func=domain, ...)
"""
return validators.domain(*args, **kwargs)
def email(*args, **kwargs):
"""
验证给定值是否为有效的电子邮件地址
:param value: 需要验证的电子邮件地址
:return: 如果验证成功返回 True否则返回 ValidationFailure
示例
>>> email('someone@example.com')
True
>>> email('bogus@@')
ValidationFailure(func=email, ...)
"""
return validators.email(*args, **kwargs)
def iban(*args, **kwargs):
"""
验证给定值是否为有效的 IBAN 代码
:param value: 需要验证的 IBAN 代码
:return: 如果验证成功返回 True否则返回 ValidationFailure
示例
>>> iban('DE29100500001061045672')
True
>>> iban('123456')
ValidationFailure(func=iban, ...)
"""
return validators.iban(*args, **kwargs)
def ipv4(*args, **kwargs):
"""
验证给定值是否为有效的 IPv4 地址
:param value: 需要验证的 IPv4 地址
:return: 如果验证成功返回 True否则返回 ValidationFailure
示例
>>> ipv4('123.0.0.7')
True
>>> ipv4('900.80.70.11')
ValidationFailure(func=ipv4, args={'value': '900.80.70.11'})
"""
return validators.ipv4(*args, **kwargs)
def ipv6(*args, **kwargs):
"""
验证给定值是否为有效的 IPv6 地址
:param value: 需要验证的 IPv6 地址
:return: 如果验证成功返回 True否则返回 ValidationFailure
示例
>>> ipv6('abcd:ef::42:1')
True
>>> ipv6('abc.0.0.1')
ValidationFailure(func=ipv6, args={'value': 'abc.0.0.1'})
"""
return validators.ipv6(*args, **kwargs)
def length(*args, **kwargs):
"""
验证给定字符串的长度是否在指定范围内
:param value: 需要验证的字符串
:param min: 字符串的最小长度可选
:param max: 字符串的最大长度可选
:return: 如果验证成功返回 True否则返回 ValidationFailure
示例
>>> length('something', min=2)
True
>>> length('something', min=9, max=9)
True
>>> length('something', max=5)
ValidationFailure(func=length, ...)
"""
return validators.length(*args, **kwargs)
def mac_address(*args, **kwargs):
"""
验证给定值是否为有效的 MAC 地址
:param value: 需要验证的 MAC 地址
:return: 如果验证成功返回 True否则返回 ValidationFailure
示例
>>> mac_address('01:23:45:67:ab:CD')
True
>>> mac_address('00:00:00:00:00')
ValidationFailure(func=mac_address, args={'value': '00:00:00:00:00'})
"""
return validators.mac_address(*args, **kwargs)
def slug(*args, **kwargs):
"""
验证给定值是否为有效的 Slug 格式
有效的 Slug 只能包含字母数字字符连字符和下划线
:param value: 需要验证的字符串
:return: 如果验证成功返回 True否则返回 ValidationFailure
示例
>>> slug('my.slug')
ValidationFailure(func=slug, args={'value': 'my.slug'})
>>> slug('my-slug-2134')
True
"""
return validators.slug(*args, **kwargs)
def url(*args, **kwargs):
"""
验证给定值是否为有效的 URL
:param value: 需要验证的 URL
:param public: 是否仅允许公共 URL可选
:return: 如果验证成功返回 True否则返回 ValidationFailure
示例
>>> url('http://foobar.dk')
True
>>> url('http://10.0.0.1')
True
>>> url('http://foobar.d')
ValidationFailure(func=url, ...)
>>> url('http://10.0.0.1', public=True)
ValidationFailure(func=url, ...)
"""
return validators.url(*args, **kwargs)
def uuid(*args, **kwargs):
"""
验证给定值是否为有效的 UUID
:param value: 需要验证的 UUID
:return: 如果验证成功返回 True否则返回 ValidationFailure
示例
>>> uuid('2bc1c94f-0deb-43e9-92a1-4775189ec9f8')
True
>>> uuid('2bc1c94f 0deb-43e9-92a1-4775189ec9f8')
ValidationFailure(func=uuid, ...)
"""
return validators.uuid(*args, **kwargs)
@validator
def even(value):
"""
验证给定值是否为偶数
:param value: 需要验证的数字
:return: 如果是偶数返回 True否则返回 ValidationFailure
示例
>>> even(4)
True
>>> even(5)
ValidationFailure(func=even, args={'value': 5})
"""
return not (value % 2)

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save