forked from iti-framework/iTi-Flask
Compare commits
29 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
26ffde8db3 | 4 days ago |
|
|
3f41f4a2d1 | 1 week ago |
|
|
d914151b19 | 1 week ago |
|
|
189cf733ce | 1 week ago |
|
|
8b8a249d1b | 1 week ago |
|
|
f64b361d3b | 1 week ago |
|
|
30f477550a | 1 week ago |
|
|
c3d2ab56a9 | 1 week ago |
|
|
afbd2b2e21 | 1 week ago |
|
|
667a71f01a | 1 week ago |
|
|
980c791dbf | 1 week ago |
|
|
162a08c7b9 | 1 week ago |
|
|
f8aa9c29c1 | 1 week ago |
|
|
761446665d | 1 week ago |
|
|
241f1d9575 | 2 weeks ago |
|
|
d3a37ce145 | 2 weeks ago |
|
|
6aec02acb6 | 2 weeks ago |
|
|
1525f35036 | 2 weeks ago |
|
|
fde94665fb | 2 weeks ago |
|
|
b4cba77401 | 2 weeks ago |
|
|
15fa544bca | 2 weeks ago |
|
|
49a0a491e8 | 2 weeks ago |
|
|
3eb3cae909 | 2 weeks ago |
|
|
079e1ffb98 | 2 weeks ago |
|
|
5ef6da5b53 | 2 weeks ago |
|
|
9a71aa8c93 | 2 weeks ago |
|
|
69c845aacd | 3 weeks ago |
|
|
47355a0507 | 3 weeks ago |
|
|
3c11b39b79 | 3 weeks ago |
@ -0,0 +1,71 @@
|
||||
# grill-me
|
||||
|
||||
A relentless interviewer skill for any AI coding assistant that supports the [open agent skills](https://github.com/vercel-labs/skills) format — Claude Code, Cursor, Codex, OpenCode, Continue, Windsurf, and 40+ others.
|
||||
|
||||
`/grill-me` does not hunt for bugs. It expands your understanding of what you actually want by surfacing intent, constraints, hidden assumptions, and unstated alternatives — across coding, marketing, personal branding, SOPs, systems thinking, process design, and tough business decisions.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
# Project-local install (default) — committed with your project
|
||||
npx skills@latest add satya-janghu/agent-skills/skills/grill-me
|
||||
|
||||
# Global install — available across all your projects
|
||||
npx skills@latest add satya-janghu/agent-skills/skills/grill-me -g
|
||||
|
||||
# Non-interactive, Claude Code only, global
|
||||
npx skills@latest add satya-janghu/agent-skills/skills/grill-me -g -a claude-code -y
|
||||
```
|
||||
|
||||
The `skills` CLI prompts you for which AI agent to install for (Claude Code, Cursor, Codex, etc.) and whether to install project-locally or user-globally.
|
||||
|
||||
If you don't want to use the CLI, see [Manual install](#manual-install) below.
|
||||
|
||||
## Usage
|
||||
|
||||
Inside any AI agent that supports skills:
|
||||
|
||||
```
|
||||
/grill-me <what you want grilled on>
|
||||
```
|
||||
|
||||
Or trigger by phrase: "grill me on…", "interview me about…", "pressure-test this…", "help me think through…".
|
||||
|
||||
The skill ends when the next concrete action becomes possible (writing code, drafting a brief, editing an SOP, making a commit). Before that action, it writes a distilled session log to `<cwd>/.grill/<slug>.md`.
|
||||
|
||||
## What makes it different
|
||||
|
||||
Most AI assistants ask too few questions and declare "I have enough to start" too early. `grill-me` is engineered to fight that:
|
||||
|
||||
- **One question at a time, with a recommended answer attached** — gives you something to react to instead of a blank prompt.
|
||||
- **Drills the last answer before moving sideways** — the depth comes from following one thread to the bottom, not from breadth.
|
||||
- **Pulls from a menu of lenses** without naming them — first-principles, pre-mortem, steelman, reversibility, five-whys, audience, hidden-assumption excavation, second-best, sustainability, plus established mental-model frames (Naval permissionless leverage, Thiel "what do you believe…", Hormozi value equation, Christensen JTBD, Munger inversion, Bezos regret minimization). The conversation feels natural; the structure is hidden.
|
||||
- **Pushes back on vague answers, deflections, and contradictions** rather than accepting fog.
|
||||
- **Strawmans half-answers by default** — easier to disagree with a draft than invent from blank.
|
||||
- **Adapts the lens to the domain** (coding vs. marketing vs. SOPs vs. business decisions), but does not bug-hunt. The goal is expanding the user's understanding of what they want, not finding flaws in execution.
|
||||
- **Writes a session log to `<cwd>/.grill/<slug>.md`** — Intent, Constraints, Key decisions, Surfaced assumptions, Open questions, Out of scope. The log is the distilled output, not a transcript.
|
||||
|
||||
See [SKILL.md](SKILL.md) for the full instruction set.
|
||||
|
||||
## Manual install
|
||||
|
||||
If you prefer not to use the `skills` CLI, drop `SKILL.md` directly into the right location for your agent:
|
||||
|
||||
| Agent | Location |
|
||||
|---|---|
|
||||
| Claude Code (global) | `~/.claude/skills/grill-me/SKILL.md` |
|
||||
| Claude Code (project) | `<project>/.claude/skills/grill-me/SKILL.md` |
|
||||
| Cursor | `<project>/.cursor/skills/grill-me/SKILL.md` |
|
||||
| Codex | `<project>/.codex/skills/grill-me/SKILL.md` |
|
||||
|
||||
A one-liner using `curl`:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.claude/skills/grill-me && \
|
||||
curl -fsSL https://raw.githubusercontent.com/satya-janghu/agent-skills/main/skills/grill-me/SKILL.md \
|
||||
-o ~/.claude/skills/grill-me/SKILL.md
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](../../LICENSE).
|
||||
@ -0,0 +1,96 @@
|
||||
---
|
||||
name: grill-me
|
||||
description: Interview the user relentlessly to expand context and surface intent, constraints, hidden assumptions, and unstated alternatives. Use whenever the user invokes `/grill-me`, says "grill me", "interview me", "pressure-test this", "help me think through", or whenever the user's first message is more decision than task — across coding, business, marketing, personal branding, SOPs, systems thinking, process design, and tough decisions.
|
||||
---
|
||||
|
||||
# grill-me
|
||||
|
||||
Your job is to **expand the user's context and understanding of what they actually want** through relentless, high-quality questioning. This is not bug-hunting. It is not a checklist. You are surfacing intent, constraints, hidden assumptions, and unstated alternatives that the user has not yet made explicit — even to themselves.
|
||||
|
||||
## Core loop
|
||||
|
||||
1. Ask **one question at a time**.
|
||||
2. Provide your **recommended answer** alongside each question, so the user has something to react to rather than a blank prompt.
|
||||
3. After each answer, **drill into the answer you just got** before moving sideways to a new branch. Most premature exits happen because you moved on too soon.
|
||||
4. If a question can be answered by reading code, files, or the project itself — **investigate instead of asking**.
|
||||
5. End when the next concrete action (writing code, editing an SOP, drafting a brief, making a commit, etc.) becomes possible — and only then. Before taking that action, write the session log (see "Logging" below).
|
||||
|
||||
## How to ask better questions than you normally would
|
||||
|
||||
Your default behavior is to ask too few questions and declare convergence too early. Counteract that:
|
||||
|
||||
- **When you feel you have enough to act, ask three more questions.** That feeling is the surface, not the bottom.
|
||||
- **Do not summarize as progress.** "So what I'm hearing is X, Y, Z" ends grilling — it does not advance it. Ask, don't paraphrase.
|
||||
- **Push back on vague answers.** "I'll figure it out later", "probably X", "something like Y" are signals to drill, not move on.
|
||||
- **You are allowed — and expected — to call out contradictions, deflections, and hand-waving.** Politely, but without softening to the point of accepting fog.
|
||||
- **Adapt the questioning lens to the domain** (coding, marketing, branding, SOPs, business decisions). Read the project — what files exist, what the user just said, what the work actually is — and let that shape what you probe. The lens shapes the *kind* of question, not whether you ask it.
|
||||
|
||||
## Question lenses to draw from
|
||||
|
||||
You have a menu of lenses. **Do not name the lens out loud** — keep the conversation natural. Pull from these dynamically, mixing freely. There is no required count and no domain-locked subset. Use what fits.
|
||||
|
||||
- **First-principles.** Strip the problem to fundamentals. "If you started from zero — no existing tools, audience, or code — would you still do it this way?"
|
||||
- **Intent and desired outcome.** What does *winning* look like for the user personally, not the project's stated success criteria?
|
||||
- **Constraint surfacing.** What is non-negotiable? Time, money, energy, values, identity. The real design lives in the constraints.
|
||||
- **Hidden assumption excavation.** "You said X — what has to be true for X to hold?"
|
||||
- **Second-best alternative.** What's the path they're *not* taking? If they can't name it, they haven't actually chosen.
|
||||
- **Pre-mortem.** "It's 12 months from now and this failed. Walk me through why."
|
||||
- **Steelman the opposite.** Make the strongest case *against* their plan. If they can't, conviction is shallow.
|
||||
- **Audience / stakeholder lens.** Who is this *for*, specifically — name a single person. What do they think, fear, want?
|
||||
- **Reversibility.** One-way door or two-way door? They are designed differently.
|
||||
- **Five-whys / root cause.** "Why does that matter?" recursively until you hit a value, identity, or non-negotiable.
|
||||
- **Boundary testing.** What is *out of scope*? Naming what you will not do is often more clarifying than what you will.
|
||||
- **Sustainability.** Would they still do this if it took 3x as long as expected? If not, the plan is fragile.
|
||||
|
||||
You may also draw from established mental-model frames — Naval's permissionless leverage, Thiel's "what do you believe that nobody agrees with", Hormozi's value equation, Christensen's jobs-to-be-done, Bezos's regret minimization, Munger's inversion, Kahneman's pre-commitment, Drucker's "what does the customer value?", Andy Grove's "what are we trying to optimize for?", and similar — without naming the source. Adopt the frame, not the brand.
|
||||
|
||||
## Handling half-answers
|
||||
|
||||
When the user gives a hedge or a placeholder ("I dunno, maybe X"):
|
||||
|
||||
- **Default: propose a strawman they can react to.** "Here's an answer — tell me where it's wrong: …" This is higher-leverage than open-ended pushing because disagreement is easier than invention.
|
||||
- **When the user pushes back on the question itself** (i.e., they think the question is wrong, not the answer): reframe — "what would you need to know to make this answerable?" — and follow that thread.
|
||||
|
||||
## Logging
|
||||
|
||||
When grilling converges and the next action is possible, **before taking that action**, write a markdown log to:
|
||||
|
||||
```
|
||||
<cwd>/.grill/<slug>.md
|
||||
```
|
||||
|
||||
where `<slug>` is a kebab-case summary of the topic. Create the directory if it does not exist.
|
||||
|
||||
Use this structure. **Delete any section that ended up empty** — do not leave "TBD" placeholders.
|
||||
|
||||
```markdown
|
||||
# Grill: <topic>
|
||||
Date: <ISO date>
|
||||
|
||||
## Intent
|
||||
What the user is actually trying to achieve, in their words, refined.
|
||||
|
||||
## Constraints
|
||||
Non-negotiables surfaced during grilling.
|
||||
|
||||
## Key decisions
|
||||
- Decision: <what was decided>. Reason: <why>. Alternative considered: <what was rejected>.
|
||||
|
||||
## Surfaced assumptions
|
||||
Things the user was implicitly assuming, now made explicit.
|
||||
|
||||
## Open questions
|
||||
Things the user could not answer yet, deferred for later.
|
||||
|
||||
## Out of scope
|
||||
Things the user explicitly chose not to do.
|
||||
```
|
||||
|
||||
The log is the *distilled* output, not a transcript. Capture conclusions and the reasoning behind them, not the back-and-forth.
|
||||
|
||||
## What this skill is not
|
||||
|
||||
- **Not a bug hunt.** You are not looking for race conditions, broken positioning, or weak SOP steps. You are expanding the user's understanding of what they want and why.
|
||||
- **Not a checklist.** No mandatory questions, no required count, no fixed order. Adapt to what the user just said.
|
||||
- **Not a summary tool.** Summarizing is the opposite of grilling. Save synthesis for the log at the end.
|
||||
- **Not a coach.** Don't motivate. Don't validate. Probe.
|
||||
@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "iTi-Flask 框架"
|
||||
short_description: "iTi-Flask 框架开发指南"
|
||||
default_prompt: "使用 $iti-flask-framework 修改或审查 iTi-Flask 框架代码。"
|
||||
@ -0,0 +1,26 @@
|
||||
# 代码质量审查者提示词模板
|
||||
|
||||
分派代码质量审查子智能体时使用此模板。
|
||||
|
||||
**目的:** 验证实现是否构建良好(整洁、有测试、可维护)
|
||||
|
||||
**仅在规格合规性审查通过后才分派。**
|
||||
|
||||
```
|
||||
Task tool (superpowers:code-reviewer):
|
||||
使用模板 requesting-code-review/code-reviewer.md
|
||||
|
||||
WHAT_WAS_IMPLEMENTED: [来自实现者的报告]
|
||||
PLAN_OR_REQUIREMENTS: [plan-file] 中的任务 N
|
||||
BASE_SHA: [任务开始前的提交]
|
||||
HEAD_SHA: [当前提交]
|
||||
DESCRIPTION: [任务摘要]
|
||||
```
|
||||
|
||||
**除标准代码质量关注点外,审查者还应检查:**
|
||||
- 每个文件是否有单一明确的职责和定义清晰的接口?
|
||||
- 各单元是否拆分得足以独立理解和测试?
|
||||
- 实现是否遵循了计划中的文件结构?
|
||||
- 本次实现是否创建了已经很大的新文件,或显著增大了现有文件?(不要标记已有的文件大小问题——聚焦于本次变更带来的影响。)
|
||||
|
||||
**代码审查者返回:** 优点、问题(关键/重要/次要)、评估结论
|
||||
@ -0,0 +1,61 @@
|
||||
# 规格合规审查者提示词模板
|
||||
|
||||
分派规格合规审查子智能体时使用此模板。
|
||||
|
||||
**目的:** 验证实现者是否构建了所要求的内容(不多不少)
|
||||
|
||||
```
|
||||
Task tool (general-purpose):
|
||||
description: "审查任务 N 的规格合规性"
|
||||
prompt: |
|
||||
你正在审查一个实现是否与其规格匹配。
|
||||
|
||||
## 要求的内容
|
||||
|
||||
[任务需求的完整文本]
|
||||
|
||||
## 实现者声称构建了什么
|
||||
|
||||
[来自实现者的报告]
|
||||
|
||||
## 关键:不要信任报告
|
||||
|
||||
实现者完成得疑似过快。他们的报告可能不完整、
|
||||
不准确或过于乐观。你必须独立验证所有内容。
|
||||
|
||||
**不要:**
|
||||
- 相信他们关于实现内容的说法
|
||||
- 信任他们关于完整性的声明
|
||||
- 接受他们对需求的解读
|
||||
|
||||
**要做的:**
|
||||
- 阅读他们写的实际代码
|
||||
- 逐行对比实际实现和需求
|
||||
- 检查他们声称已实现但实际遗漏的部分
|
||||
- 寻找他们未提及的多余功能
|
||||
|
||||
## 你的工作
|
||||
|
||||
阅读实现代码并验证:
|
||||
|
||||
**缺失的需求:**
|
||||
- 他们是否实现了所有被要求的内容?
|
||||
- 是否有他们跳过或遗漏的需求?
|
||||
- 是否有他们声称可用但实际未实现的功能?
|
||||
|
||||
**多余/不需要的工作:**
|
||||
- 他们是否构建了未被要求的内容?
|
||||
- 他们是否过度工程化或添加了不必要的功能?
|
||||
- 他们是否添加了规格中没有的"锦上添花"功能?
|
||||
|
||||
**理解偏差:**
|
||||
- 他们是否以不同于预期的方式解读了需求?
|
||||
- 他们是否解决了错误的问题?
|
||||
- 他们是否实现了正确的功能但方式不对?
|
||||
|
||||
**通过阅读代码来验证,而非信任报告。**
|
||||
|
||||
报告:
|
||||
- ✅ 符合规格(如果经过代码检查后一切匹配)
|
||||
- ❌ 发现问题:[具体列出缺失或多余的内容,附带 file:line 引用]
|
||||
```
|
||||
@ -0,0 +1,4 @@
|
||||
FLASK_ENV=dev
|
||||
SECRET_KEY=change-me
|
||||
JWT_SECRET_KEY=change-me
|
||||
DATABASE_URL=sqlite:///runtime/iti-flask_dev.db
|
||||
@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "{{ project_name }} 项目"
|
||||
short_description: "{{ project_name }} 业务项目指南"
|
||||
default_prompt: "使用 ${{ project_slug | lower | replace('_', '-') }}-project 修改或审查 {{ project_name }} 业务项目。"
|
||||
@ -0,0 +1,19 @@
|
||||
.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
|
||||
@ -0,0 +1,20 @@
|
||||
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
|
||||
@ -0,0 +1,19 @@
|
||||
__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
|
||||
@ -0,0 +1,26 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
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"]
|
||||
@ -0,0 +1,3 @@
|
||||
from .app_factory import create_app
|
||||
|
||||
__all__ = ["create_app"]
|
||||
@ -0,0 +1,27 @@
|
||||
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
|
||||
@ -0,0 +1,2 @@
|
||||
def import_models() -> None:
|
||||
from .example import Example # noqa: F401
|
||||
@ -0,0 +1,3 @@
|
||||
from .example import Example
|
||||
|
||||
__all__ = ["Example"]
|
||||
@ -0,0 +1,10 @@
|
||||
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="名称")
|
||||
@ -0,0 +1 @@
|
||||
"""Business modules."""
|
||||
@ -0,0 +1,3 @@
|
||||
from .module import ExampleModule
|
||||
|
||||
__all__ = ["ExampleModule"]
|
||||
@ -0,0 +1,33 @@
|
||||
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,
|
||||
)
|
||||
)
|
||||
@ -0,0 +1 @@
|
||||
"""Public facade for other modules."""
|
||||
@ -0,0 +1,12 @@
|
||||
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})
|
||||
@ -0,0 +1 @@
|
||||
"""Example schemas."""
|
||||
@ -0,0 +1 @@
|
||||
"""Example service layer."""
|
||||
@ -0,0 +1,81 @@
|
||||
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,
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
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 %}
|
||||
@ -0,0 +1,32 @@
|
||||
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"
|
||||
@ -0,0 +1,4 @@
|
||||
from app import create_app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@ -0,0 +1,18 @@
|
||||
# 数据库迁移
|
||||
|
||||
本目录是业务项目唯一的 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 %}
|
||||
@ -0,0 +1,40 @@
|
||||
# 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
|
||||
@ -0,0 +1,62 @@
|
||||
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()
|
||||
@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
@ -0,0 +1 @@
|
||||
|
||||
@ -0,0 +1,33 @@
|
||||
[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 -%}
|
||||
@ -0,0 +1,10 @@
|
||||
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"}
|
||||
@ -0,0 +1 @@
|
||||
{{ _copier_answers|to_nice_yaml -}}
|
||||
@ -0,0 +1,52 @@
|
||||
_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
|
||||
@ -1,415 +0,0 @@
|
||||
/*
|
||||
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;
|
||||
@ -0,0 +1,76 @@
|
||||
# 审计
|
||||
|
||||
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 鉴权。
|
||||
@ -0,0 +1,189 @@
|
||||
# 前端管理端接口契约
|
||||
|
||||
本轮不改前端。
|
||||
这份文档用于后续管理端接口适配。
|
||||
|
||||
## 响应包装
|
||||
|
||||
管理端 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,91 @@
|
||||
# 模块协议
|
||||
|
||||
模块是同一个 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()])
|
||||
```
|
||||
@ -0,0 +1,34 @@
|
||||
# 种子数据
|
||||
|
||||
框架不写业务 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。
|
||||
@ -0,0 +1,76 @@
|
||||
# 任务运行器
|
||||
|
||||
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,40 +0,0 @@
|
||||
FLASK_ENV=dev
|
||||
SECRET_KEY=iti-flask
|
||||
JWT_SECRET_KEY=iti-flask
|
||||
DATABASE_URL=sqlite:///./../runtime/iti-flask_dev.db
|
||||
|
||||
# 前端相关
|
||||
# FRONTEND_ENABLED=False # 是否启用前端渲染
|
||||
# FRONTEND_PATH=dist # 前端文件所在位置,若static则无需填写
|
||||
|
||||
# ============================================
|
||||
# 文件存储 - 阿里云OSS
|
||||
# ============================================
|
||||
# ALIYUN_OSS_ACCESS_KEY_ID=LTAI5t...
|
||||
# ALIYUN_OSS_ACCESS_KEY_SECRET=your_access_key_secret
|
||||
# ALIYUN_OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
|
||||
# ALIYUN_OSS_BUCKET=your-bucket-name
|
||||
|
||||
# ============================================
|
||||
# 文件存储 - 腾讯云COS
|
||||
# ============================================
|
||||
# TENCENT_COS_SECRET_ID=AKIDxxx
|
||||
# TENCENT_COS_SECRET_KEY=your_secret_key
|
||||
# TENCENT_COS_REGION=ap-guangzhou
|
||||
# TENCENT_COS_BUCKET=your-bucket-1234567890
|
||||
|
||||
# ============================================
|
||||
# 文件存储 - 七牛云Kodo
|
||||
# ============================================
|
||||
# QINIU_KODO_ACCESS_KEY=your_access_key
|
||||
# QINIU_KODO_SECRET_KEY=your_secret_key
|
||||
# QINIU_KODO_BUCKET=your-bucket-name
|
||||
# QINIU_KODO_DOMAIN=cdn.example.com
|
||||
|
||||
# ============================================
|
||||
# 文件存储 - 华为云OBS
|
||||
# ============================================
|
||||
# HUAWEI_OBS_ACCESS_KEY_ID=your_access_key_id
|
||||
# HUAWEI_OBS_SECRET_ACCESS_KEY=your_secret_access_key
|
||||
# HUAWEI_OBS_SERVER=obs.cn-north-4.myhuaweicloud.com
|
||||
# HUAWEI_OBS_BUCKET=your-bucket-name
|
||||
@ -1,15 +0,0 @@
|
||||
FLASK_ENV=dev
|
||||
SECRET_KEY=iti-flask
|
||||
JWT_SECRET_KEY=iti-flask
|
||||
DATABASE_URL=mysql+pymysql://root:root@127.0.0.1:3307/iti-flask?charset=utf8mb4
|
||||
# 前端相关
|
||||
FRONTEND_ENABLED=False # 是否启用前端渲染
|
||||
FRONTEND_PATH=dist # 前端文件所在位置,若static则无需填写
|
||||
|
||||
# ============================================
|
||||
# 文件存储 - 阿里云OSS
|
||||
# ============================================
|
||||
ALIYUN_OSS_ACCESS_KEY_ID=LTAI5t9cymUAWHVEo36yygaT
|
||||
ALIYUN_OSS_ACCESS_KEY_SECRET=FaaUsxadRYyshbYeAV8ypZNYVOx3tE
|
||||
ALIYUN_OSS_ENDPOINT=oss-cn-chengdu.aliyuncs.com
|
||||
ALIYUN_OSS_BUCKET=maintaince-dev
|
||||
@ -1,7 +0,0 @@
|
||||
FLASK_ENV=prod
|
||||
SECRET_KEY=zhSYJn577LgxyWDuQboM9wX3j2BHEFUP
|
||||
JWT_SECRET_KEY=8YD37VvM3WgdpmKNt7kVFNbKnya4hBRh
|
||||
DATABASE_URL=mysql+pymysql://root:root@127.0.0.1:3307/iti-flask?charset=utf8mb4
|
||||
# 前端相关
|
||||
FRONTEND_ENABLED=False # 是否启用前端渲染
|
||||
FRONTEND_PATH=dist # 前端文件所在位置,若static则无需填写
|
||||
@ -1,4 +1,4 @@
|
||||
# SPDX-FileCopyrightText: 2025-present NoahLan <6995syu@163.com>
|
||||
#
|
||||
# SPDX-License-Identifier: MIT
|
||||
__version__ = "1.0.0"
|
||||
__version__ = "0.3.0"
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
# SPDX-FileCopyrightText: 2025-present NoahLan <6995syu@163.com>
|
||||
#
|
||||
# SPDX-License-Identifier: MIT
|
||||
from iti.app import create_app
|
||||
|
||||
__all__ = ["create_app"]
|
||||
|
||||
@ -1,6 +1,553 @@
|
||||
from iti.applications import create_app
|
||||
from __future__ import annotations
|
||||
|
||||
app = 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
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=True)
|
||||
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}
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
from .logger import setup_logger
|
||||
from .filter import ModelFilter
|
||||
from .permission import permission, check_permission, check_permission_or_raise
|
||||
@ -1,50 +0,0 @@
|
||||
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,27 +0,0 @@
|
||||
"""
|
||||
通用工具模块
|
||||
"""
|
||||
|
||||
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
|
||||
@ -1,29 +0,0 @@
|
||||
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)
|
||||
@ -1,9 +0,0 @@
|
||||
from .user_cache_event_handler import UserCacheEventHandler
|
||||
|
||||
|
||||
def init_event_handlers(app):
|
||||
"""
|
||||
事件初始化
|
||||
"""
|
||||
|
||||
UserCacheEventHandler(app)
|
||||
@ -1,142 +0,0 @@
|
||||
from iti.applications.extensions import eventbus
|
||||
from iti.applications.common.events import (
|
||||
UserEvents,
|
||||
UserRelEvents,
|
||||
RoleEvents,
|
||||
MenuEvents,
|
||||
RoleRelEvents,
|
||||
)
|
||||
from iti.applications.models import User, Role, SysMenu, sys_role_menu, sys_user_role
|
||||
from iti.applications.extensions import cache_simple, db
|
||||
from sqlalchemy import select, distinct
|
||||
|
||||
|
||||
class UserCacheEventHandler:
|
||||
"""
|
||||
用户数据缓存 事件处理器
|
||||
"""
|
||||
|
||||
def __init__(self, app=None):
|
||||
if app:
|
||||
self.init_app(app)
|
||||
|
||||
def init_app(self, app):
|
||||
self.app = app
|
||||
self._register_handlers()
|
||||
|
||||
def _register_handlers(self):
|
||||
"""
|
||||
注册事件处理器
|
||||
"""
|
||||
|
||||
# 用户
|
||||
eventbus.register_handler(
|
||||
UserEvents.USER_UPDATED,
|
||||
self._handle_user_updated,
|
||||
order=-1,
|
||||
async_mode=True,
|
||||
)
|
||||
eventbus.register_handler(
|
||||
UserEvents.USER_DELETED,
|
||||
self._handle_user_deleted,
|
||||
order=-1,
|
||||
async_mode=True,
|
||||
)
|
||||
eventbus.register_handler(
|
||||
UserEvents.USER_AUTH_REFRESHED,
|
||||
self._handle_user_updated,
|
||||
order=-1,
|
||||
async_mode=True,
|
||||
)
|
||||
eventbus.register_handler(
|
||||
UserEvents.USER_LOGOUT,
|
||||
self._handle_user_updated,
|
||||
order=-1,
|
||||
async_mode=True,
|
||||
)
|
||||
eventbus.register_handler(
|
||||
UserEvents.USER_PASSWORD_UPDATED,
|
||||
self._handle_user_updated,
|
||||
order=-1,
|
||||
async_mode=True,
|
||||
)
|
||||
|
||||
# 用户关系
|
||||
eventbus.register_handler(
|
||||
UserRelEvents.USER_ROLES_UPDATED,
|
||||
self._handle_user_updated,
|
||||
order=-1,
|
||||
async_mode=True,
|
||||
)
|
||||
eventbus.register_handler(
|
||||
UserRelEvents.USER_DEPTS_UPDATED,
|
||||
self._handle_user_updated,
|
||||
order=-1,
|
||||
async_mode=True,
|
||||
)
|
||||
|
||||
# 角色删除
|
||||
eventbus.register_handler(
|
||||
RoleEvents.ROLE_DELETED,
|
||||
self._handle_role_changed,
|
||||
order=-1,
|
||||
async_mode=True,
|
||||
)
|
||||
eventbus.register_handler(
|
||||
RoleRelEvents.ROLE_MENUS_UPDATED.value,
|
||||
self._handle_role_changed,
|
||||
order=-1,
|
||||
async_mode=True,
|
||||
)
|
||||
|
||||
# 菜单
|
||||
eventbus.register_handler(
|
||||
MenuEvents.MENU_UPDATED,
|
||||
self._handle_menu_updated,
|
||||
order=-1,
|
||||
async_mode=True,
|
||||
)
|
||||
eventbus.register_handler(
|
||||
MenuEvents.MENU_DELETED,
|
||||
self._handle_menu_deleted,
|
||||
order=-1,
|
||||
async_mode=True,
|
||||
)
|
||||
|
||||
def _invalidate_user_cache(self, user_id):
|
||||
"""
|
||||
清理用户缓存
|
||||
"""
|
||||
|
||||
if user_id:
|
||||
cache_simple.delete(f"user_{user_id}")
|
||||
|
||||
def _handle_user_updated(self, user: User):
|
||||
self._invalidate_user_cache(user.id)
|
||||
|
||||
def _handle_user_deleted(self, user_id):
|
||||
self._invalidate_user_cache(user_id)
|
||||
|
||||
def _handle_role_changed(self, role: Role):
|
||||
# 角色关联的用户缓存都应该被删除
|
||||
if role.users:
|
||||
for user in role.users:
|
||||
self._invalidate_user_cache(user.id)
|
||||
|
||||
def _handle_menu_updated(self, menu: SysMenu, old_menu: SysMenu):
|
||||
# 若菜单更新了权限字段
|
||||
if old_menu and old_menu.auth_code != menu.auth_code:
|
||||
# 与该菜单相关的角色的用户缓存都应该被删除
|
||||
# 查询菜单相关的角色关联的所有用户ID
|
||||
self._handle_menu_deleted(menu)
|
||||
|
||||
def _handle_menu_deleted(self, menu: SysMenu):
|
||||
# 与该菜单相关的角色的用户缓存都应该被删除
|
||||
# 查询菜单相关的角色关联的所有用户ID
|
||||
user_ids = db.session.scalars(
|
||||
select(distinct(sys_user_role.c.user_id))
|
||||
.join(sys_user_role, sys_role_menu.c.role_id == sys_user_role.c.role_id)
|
||||
.where(sys_role_menu.c.menu_id == menu.id)
|
||||
).all()
|
||||
for user_id in user_ids:
|
||||
self._invalidate_user_cache(user_id)
|
||||
@ -1,43 +0,0 @@
|
||||
from .error_views import init_error_views
|
||||
from .limit import init_limiter, limiter
|
||||
from .jwt import init_jwt, jwt
|
||||
from .db import init_db, db, ma
|
||||
from .migrate import init_migrate, migrate
|
||||
from .plugins import init_plugin, broadcast_execute
|
||||
from .encoder import init_encoder
|
||||
from .moment import init_moment, moment
|
||||
from .http import init_http
|
||||
from .error_handler import init_error_handler
|
||||
from .cache import init_cache, cache_simple, cache_redis
|
||||
from .sys_log import init_sys_log, sys_log
|
||||
from .event_bus import init_eventbus, eventbus
|
||||
from iti.applications.common.logger import init_logger
|
||||
|
||||
|
||||
def init_exts(app) -> None:
|
||||
# 日志
|
||||
init_logger(app)
|
||||
|
||||
# 插件
|
||||
init_plugin(app)
|
||||
broadcast_execute(app, "event_begin")
|
||||
|
||||
# http
|
||||
init_http(app)
|
||||
|
||||
# json
|
||||
init_encoder(app)
|
||||
init_moment(app)
|
||||
|
||||
# flask 扩展
|
||||
init_db(app)
|
||||
init_jwt(app)
|
||||
init_migrate(app)
|
||||
init_limiter(app)
|
||||
init_cache(app)
|
||||
init_sys_log(app)
|
||||
init_eventbus(app)
|
||||
|
||||
# 系统蓝图相关
|
||||
init_error_views(app)
|
||||
init_error_handler(app)
|
||||
@ -1,15 +0,0 @@
|
||||
from flask_caching import Cache
|
||||
|
||||
cache_simple = Cache()
|
||||
cache_redis = Cache()
|
||||
|
||||
|
||||
def init_cache(app):
|
||||
simpleConfig = app.config.get("CACHE_SIMPLE", None)
|
||||
redisConfig = app.config.get("CACHE_REDIS", None)
|
||||
if simpleConfig is not None and simpleConfig.get("ENABLED", False):
|
||||
app.logger.info(f"初始化简单缓存: {simpleConfig}")
|
||||
cache_simple.init_app(app, config=simpleConfig)
|
||||
if redisConfig is not None and redisConfig.get("ENABLED", False):
|
||||
app.logger.info(f"初始化 Redis 缓存: {redisConfig}")
|
||||
cache_redis.init_app(app, config=redisConfig)
|
||||
@ -1,124 +0,0 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_sqlalchemy.query import Query as BaseQuery
|
||||
from flask_marshmallow import Marshmallow
|
||||
import datetime
|
||||
import os
|
||||
from marshmallow import fields
|
||||
from marshmallow.validate import (
|
||||
URL,
|
||||
Email,
|
||||
Range,
|
||||
Length,
|
||||
Equal,
|
||||
Regexp,
|
||||
Predicate,
|
||||
NoneOf,
|
||||
OneOf,
|
||||
ContainsOnly,
|
||||
)
|
||||
from iti.applications.common.utils import fail
|
||||
from sqlalchemy import MetaData
|
||||
|
||||
URL.default_message = "无效的链接"
|
||||
Email.default_message = "无效的邮箱地址"
|
||||
Range.message_min = "不能小于{min}"
|
||||
Range.message_max = "不能小于{max}"
|
||||
Range.message_all = "不能超过{min}和{max}这个范围"
|
||||
Length.message_min = "长度不得小于{min}位"
|
||||
Length.message_max = "长度不得大于{max}位"
|
||||
Length.message_all = "长度不能超过{min}和{max}这个范围"
|
||||
Length.message_equal = "长度必须等于{equal}位"
|
||||
Equal.default_message = "必须等于{other}"
|
||||
Regexp.default_message = "非法输入"
|
||||
Predicate.default_message = "非法输入"
|
||||
NoneOf.default_message = "非法输入"
|
||||
OneOf.default_message = "无效的选择"
|
||||
ContainsOnly.default_message = "一个或多个无效的选择"
|
||||
|
||||
fields.Field.default_error_messages = {
|
||||
"required": "缺少必要数据",
|
||||
"null": "数据不能为空",
|
||||
"validator_failed": "非法数据",
|
||||
}
|
||||
|
||||
fields.Str.default_error_messages = {"invalid": "不是合法文本"}
|
||||
fields.Int.default_error_messages = {"invalid": "不是合法整数"}
|
||||
fields.Number.default_error_messages = {"invalid": "不是合法数字"}
|
||||
fields.Boolean.default_error_messages = {"invalid": "不是合法布尔值"}
|
||||
|
||||
|
||||
class Query(BaseQuery):
|
||||
def soft_delete(self):
|
||||
"""
|
||||
软删除查询
|
||||
"""
|
||||
return self.update({"deleted_at": datetime.datetime.now()})
|
||||
|
||||
def logic_all(self):
|
||||
"""
|
||||
逻辑未删除查询
|
||||
"""
|
||||
return self.filter_by(deleted_at=None).all()
|
||||
|
||||
def all_json(self, schema: Marshmallow().Schema):
|
||||
"""
|
||||
查询结果转换为 JSON
|
||||
"""
|
||||
return schema(many=True).dump(self.all())
|
||||
|
||||
|
||||
naming_convention = {
|
||||
"ix": "ix_%(column_0_label)s",
|
||||
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
||||
"ck": "ck_%(table_name)s_%(column_0_name)s",
|
||||
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
||||
"pk": "pk_%(table_name)s",
|
||||
}
|
||||
|
||||
db = SQLAlchemy(
|
||||
query_class=Query, metadata=MetaData(naming_convention=naming_convention),
|
||||
)
|
||||
ma = Marshmallow()
|
||||
|
||||
|
||||
def init_db(app) -> None:
|
||||
"""
|
||||
初始化数据库
|
||||
"""
|
||||
db.init_app(app)
|
||||
ma.init_app(app)
|
||||
|
||||
# db错误处理
|
||||
_handle_db_error(app)
|
||||
|
||||
if os.environ.get("WERKZEUG_RUN_MAIN") == "true":
|
||||
with app.app_context():
|
||||
try:
|
||||
db.engine.connect()
|
||||
except Exception as e:
|
||||
exit(f"数据库连接失败: {e}")
|
||||
|
||||
|
||||
def _handle_db_error(app):
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
show_error_details = app.config.get("SQLALCHEMY_SHOW_ERROR_DETAILS", False)
|
||||
|
||||
@app.errorhandler(SQLAlchemyError)
|
||||
def handle_sqlalchemy_db_error(error):
|
||||
"""
|
||||
SQLAlchemy 数据库错误处理
|
||||
"""
|
||||
app.logger.error(f"数据库错误: {error}")
|
||||
data = {
|
||||
"code": error.code if hasattr(error, "code") else 500,
|
||||
}
|
||||
if show_error_details:
|
||||
data["args"] = error.args if hasattr(error, "args") else None
|
||||
data["statement"] = error.statement if hasattr(error, "statement") else None
|
||||
data["params"] = error.params if hasattr(error, "params") else None
|
||||
return fail(
|
||||
"数据库错误",
|
||||
code=500,
|
||||
data=data,
|
||||
)
|
||||
@ -1,19 +0,0 @@
|
||||
from flask import render_template
|
||||
|
||||
|
||||
def init_error_views(app):
|
||||
# @app.errorhandler(400)
|
||||
# def bad_request(error):
|
||||
# return render_template("errors/400.html"), 400
|
||||
|
||||
@app.errorhandler(403)
|
||||
def forbidden(error):
|
||||
return render_template("errors/403.html"), 403
|
||||
|
||||
@app.errorhandler(404)
|
||||
def page_not_found(error):
|
||||
return render_template("errors/404.html"), 404
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_server_error(error):
|
||||
return render_template("errors/500.html"), 500
|
||||
@ -1,10 +0,0 @@
|
||||
from .eventbus import EventBus
|
||||
|
||||
eventbus = EventBus()
|
||||
|
||||
|
||||
def init_eventbus(app):
|
||||
"""
|
||||
初始化事件总线
|
||||
"""
|
||||
eventbus.init_app(app)
|
||||
@ -1,6 +0,0 @@
|
||||
from .event_bus import EventBus
|
||||
from .event_middleware import EventMiddleware
|
||||
from .event_handler import (
|
||||
BaseEventHandler,
|
||||
FlaskEventHandler,
|
||||
)
|
||||
@ -1,70 +0,0 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from iti.applications.common import setup_logger
|
||||
from flask import current_app
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
class BaseEventHandler(ABC):
|
||||
"""
|
||||
事件处理器基类
|
||||
"""
|
||||
|
||||
def __init__(self, order: int = 0, async_mode: bool = False):
|
||||
self.order = order
|
||||
self.async_mode = async_mode
|
||||
|
||||
@abstractmethod
|
||||
def handle(self, data: any) -> any:
|
||||
"""
|
||||
处理事件
|
||||
"""
|
||||
pass
|
||||
|
||||
def before_handle(self, data: any) -> any:
|
||||
"""
|
||||
处理事件前
|
||||
"""
|
||||
return data
|
||||
|
||||
def after_handle(self, data: any) -> any:
|
||||
"""
|
||||
处理事件后
|
||||
"""
|
||||
return data
|
||||
|
||||
def on_error(self, error: Exception, data: any) -> None:
|
||||
"""
|
||||
处理事件错误
|
||||
"""
|
||||
logger.error(f"事件处理错误: {error}, 数据: {data}", exc_info=True)
|
||||
|
||||
|
||||
class FlaskEventHandler(BaseEventHandler):
|
||||
"""Flask 事件处理器基类"""
|
||||
|
||||
def __init__(self, order: int = 0, async_mode: bool = False):
|
||||
super().__init__(order, async_mode)
|
||||
self._app = None
|
||||
|
||||
@property
|
||||
def app(self):
|
||||
"""获取 Flask 应用实例"""
|
||||
if self._app is None:
|
||||
self._app = current_app
|
||||
return self._app
|
||||
|
||||
def handle(self, data: any) -> any:
|
||||
"""处理事件"""
|
||||
try:
|
||||
data = self.before_handle(data)
|
||||
result = self._do_handle(data)
|
||||
result = self.after_handle(result)
|
||||
return result
|
||||
except Exception as e:
|
||||
self.on_error(e, data)
|
||||
raise
|
||||
|
||||
def _do_handle(self, data: any) -> any:
|
||||
"""实际处理逻辑"""
|
||||
pass
|
||||
@ -1,19 +0,0 @@
|
||||
class EventMiddleware:
|
||||
"""
|
||||
事件中间件基类
|
||||
"""
|
||||
|
||||
def __call__(self, event_name: str, args: tuple, kwargs: dict) -> tuple:
|
||||
"""
|
||||
处理事件
|
||||
返回处理后的 (args, kwargs)
|
||||
"""
|
||||
return args, kwargs
|
||||
|
||||
def on_error(
|
||||
self, error: Exception, event_name: str, args: tuple, kwargs: dict
|
||||
) -> None:
|
||||
"""
|
||||
处理事件错误
|
||||
"""
|
||||
pass
|
||||
@ -1,30 +0,0 @@
|
||||
from flask_jwt_extended import JWTManager
|
||||
from iti.applications.common.utils import fail
|
||||
|
||||
jwt = JWTManager()
|
||||
|
||||
|
||||
def init_jwt(app) -> None:
|
||||
"""
|
||||
初始化 JWT
|
||||
"""
|
||||
jwt.init_app(app)
|
||||
|
||||
# 自定义错误消息
|
||||
@jwt.unauthorized_loader
|
||||
def unauthorized_loader(_callback):
|
||||
return fail("缺少令牌参数 Authorization Bearer", code=401), 401
|
||||
|
||||
@jwt.invalid_token_loader
|
||||
def invalid_token_loader(_callback):
|
||||
return fail("无效的令牌", code=401, data=str(_callback)), 401
|
||||
|
||||
@jwt.expired_token_loader
|
||||
def expired_token_loader(_header, _payload):
|
||||
return fail("令牌已过期", code=401), 401
|
||||
|
||||
@jwt.user_identity_loader
|
||||
def user_identity_loader(user):
|
||||
if user is None or not hasattr(user, "id"):
|
||||
return None
|
||||
return user.id
|
||||
@ -1,11 +0,0 @@
|
||||
from flask_migrate import Migrate
|
||||
from .db import db
|
||||
|
||||
migrate = Migrate()
|
||||
|
||||
|
||||
def init_migrate(app) -> None:
|
||||
"""
|
||||
初始化迁移
|
||||
"""
|
||||
migrate.init_app(app, db)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue