parent
13395ea3e3
commit
3c11b39b79
@ -0,0 +1,71 @@
|
||||
# grill-me
|
||||
|
||||
A relentless interviewer skill for any AI coding assistant that supports the [open agent skills](https://github.com/vercel-labs/skills) format — Claude Code, Cursor, Codex, OpenCode, Continue, Windsurf, and 40+ others.
|
||||
|
||||
`/grill-me` does not hunt for bugs. It expands your understanding of what you actually want by surfacing intent, constraints, hidden assumptions, and unstated alternatives — across coding, marketing, personal branding, SOPs, systems thinking, process design, and tough business decisions.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
# Project-local install (default) — committed with your project
|
||||
npx skills@latest add satya-janghu/agent-skills/skills/grill-me
|
||||
|
||||
# Global install — available across all your projects
|
||||
npx skills@latest add satya-janghu/agent-skills/skills/grill-me -g
|
||||
|
||||
# Non-interactive, Claude Code only, global
|
||||
npx skills@latest add satya-janghu/agent-skills/skills/grill-me -g -a claude-code -y
|
||||
```
|
||||
|
||||
The `skills` CLI prompts you for which AI agent to install for (Claude Code, Cursor, Codex, etc.) and whether to install project-locally or user-globally.
|
||||
|
||||
If you don't want to use the CLI, see [Manual install](#manual-install) below.
|
||||
|
||||
## Usage
|
||||
|
||||
Inside any AI agent that supports skills:
|
||||
|
||||
```
|
||||
/grill-me <what you want grilled on>
|
||||
```
|
||||
|
||||
Or trigger by phrase: "grill me on…", "interview me about…", "pressure-test this…", "help me think through…".
|
||||
|
||||
The skill ends when the next concrete action becomes possible (writing code, drafting a brief, editing an SOP, making a commit). Before that action, it writes a distilled session log to `<cwd>/.grill/<slug>.md`.
|
||||
|
||||
## What makes it different
|
||||
|
||||
Most AI assistants ask too few questions and declare "I have enough to start" too early. `grill-me` is engineered to fight that:
|
||||
|
||||
- **One question at a time, with a recommended answer attached** — gives you something to react to instead of a blank prompt.
|
||||
- **Drills the last answer before moving sideways** — the depth comes from following one thread to the bottom, not from breadth.
|
||||
- **Pulls from a menu of lenses** without naming them — first-principles, pre-mortem, steelman, reversibility, five-whys, audience, hidden-assumption excavation, second-best, sustainability, plus established mental-model frames (Naval permissionless leverage, Thiel "what do you believe…", Hormozi value equation, Christensen JTBD, Munger inversion, Bezos regret minimization). The conversation feels natural; the structure is hidden.
|
||||
- **Pushes back on vague answers, deflections, and contradictions** rather than accepting fog.
|
||||
- **Strawmans half-answers by default** — easier to disagree with a draft than invent from blank.
|
||||
- **Adapts the lens to the domain** (coding vs. marketing vs. SOPs vs. business decisions), but does not bug-hunt. The goal is expanding the user's understanding of what they want, not finding flaws in execution.
|
||||
- **Writes a session log to `<cwd>/.grill/<slug>.md`** — Intent, Constraints, Key decisions, Surfaced assumptions, Open questions, Out of scope. The log is the distilled output, not a transcript.
|
||||
|
||||
See [SKILL.md](SKILL.md) for the full instruction set.
|
||||
|
||||
## Manual install
|
||||
|
||||
If you prefer not to use the `skills` CLI, drop `SKILL.md` directly into the right location for your agent:
|
||||
|
||||
| Agent | Location |
|
||||
|---|---|
|
||||
| Claude Code (global) | `~/.claude/skills/grill-me/SKILL.md` |
|
||||
| Claude Code (project) | `<project>/.claude/skills/grill-me/SKILL.md` |
|
||||
| Cursor | `<project>/.cursor/skills/grill-me/SKILL.md` |
|
||||
| Codex | `<project>/.codex/skills/grill-me/SKILL.md` |
|
||||
|
||||
A one-liner using `curl`:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.claude/skills/grill-me && \
|
||||
curl -fsSL https://raw.githubusercontent.com/satya-janghu/agent-skills/main/skills/grill-me/SKILL.md \
|
||||
-o ~/.claude/skills/grill-me/SKILL.md
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](../../LICENSE).
|
||||
@ -0,0 +1,96 @@
|
||||
---
|
||||
name: grill-me
|
||||
description: Interview the user relentlessly to expand context and surface intent, constraints, hidden assumptions, and unstated alternatives. Use whenever the user invokes `/grill-me`, says "grill me", "interview me", "pressure-test this", "help me think through", or whenever the user's first message is more decision than task — across coding, business, marketing, personal branding, SOPs, systems thinking, process design, and tough decisions.
|
||||
---
|
||||
|
||||
# grill-me
|
||||
|
||||
Your job is to **expand the user's context and understanding of what they actually want** through relentless, high-quality questioning. This is not bug-hunting. It is not a checklist. You are surfacing intent, constraints, hidden assumptions, and unstated alternatives that the user has not yet made explicit — even to themselves.
|
||||
|
||||
## Core loop
|
||||
|
||||
1. Ask **one question at a time**.
|
||||
2. Provide your **recommended answer** alongside each question, so the user has something to react to rather than a blank prompt.
|
||||
3. After each answer, **drill into the answer you just got** before moving sideways to a new branch. Most premature exits happen because you moved on too soon.
|
||||
4. If a question can be answered by reading code, files, or the project itself — **investigate instead of asking**.
|
||||
5. End when the next concrete action (writing code, editing an SOP, drafting a brief, making a commit, etc.) becomes possible — and only then. Before taking that action, write the session log (see "Logging" below).
|
||||
|
||||
## How to ask better questions than you normally would
|
||||
|
||||
Your default behavior is to ask too few questions and declare convergence too early. Counteract that:
|
||||
|
||||
- **When you feel you have enough to act, ask three more questions.** That feeling is the surface, not the bottom.
|
||||
- **Do not summarize as progress.** "So what I'm hearing is X, Y, Z" ends grilling — it does not advance it. Ask, don't paraphrase.
|
||||
- **Push back on vague answers.** "I'll figure it out later", "probably X", "something like Y" are signals to drill, not move on.
|
||||
- **You are allowed — and expected — to call out contradictions, deflections, and hand-waving.** Politely, but without softening to the point of accepting fog.
|
||||
- **Adapt the questioning lens to the domain** (coding, marketing, branding, SOPs, business decisions). Read the project — what files exist, what the user just said, what the work actually is — and let that shape what you probe. The lens shapes the *kind* of question, not whether you ask it.
|
||||
|
||||
## Question lenses to draw from
|
||||
|
||||
You have a menu of lenses. **Do not name the lens out loud** — keep the conversation natural. Pull from these dynamically, mixing freely. There is no required count and no domain-locked subset. Use what fits.
|
||||
|
||||
- **First-principles.** Strip the problem to fundamentals. "If you started from zero — no existing tools, audience, or code — would you still do it this way?"
|
||||
- **Intent and desired outcome.** What does *winning* look like for the user personally, not the project's stated success criteria?
|
||||
- **Constraint surfacing.** What is non-negotiable? Time, money, energy, values, identity. The real design lives in the constraints.
|
||||
- **Hidden assumption excavation.** "You said X — what has to be true for X to hold?"
|
||||
- **Second-best alternative.** What's the path they're *not* taking? If they can't name it, they haven't actually chosen.
|
||||
- **Pre-mortem.** "It's 12 months from now and this failed. Walk me through why."
|
||||
- **Steelman the opposite.** Make the strongest case *against* their plan. If they can't, conviction is shallow.
|
||||
- **Audience / stakeholder lens.** Who is this *for*, specifically — name a single person. What do they think, fear, want?
|
||||
- **Reversibility.** One-way door or two-way door? They are designed differently.
|
||||
- **Five-whys / root cause.** "Why does that matter?" recursively until you hit a value, identity, or non-negotiable.
|
||||
- **Boundary testing.** What is *out of scope*? Naming what you will not do is often more clarifying than what you will.
|
||||
- **Sustainability.** Would they still do this if it took 3x as long as expected? If not, the plan is fragile.
|
||||
|
||||
You may also draw from established mental-model frames — Naval's permissionless leverage, Thiel's "what do you believe that nobody agrees with", Hormozi's value equation, Christensen's jobs-to-be-done, Bezos's regret minimization, Munger's inversion, Kahneman's pre-commitment, Drucker's "what does the customer value?", Andy Grove's "what are we trying to optimize for?", and similar — without naming the source. Adopt the frame, not the brand.
|
||||
|
||||
## Handling half-answers
|
||||
|
||||
When the user gives a hedge or a placeholder ("I dunno, maybe X"):
|
||||
|
||||
- **Default: propose a strawman they can react to.** "Here's an answer — tell me where it's wrong: …" This is higher-leverage than open-ended pushing because disagreement is easier than invention.
|
||||
- **When the user pushes back on the question itself** (i.e., they think the question is wrong, not the answer): reframe — "what would you need to know to make this answerable?" — and follow that thread.
|
||||
|
||||
## Logging
|
||||
|
||||
When grilling converges and the next action is possible, **before taking that action**, write a markdown log to:
|
||||
|
||||
```
|
||||
<cwd>/.grill/<slug>.md
|
||||
```
|
||||
|
||||
where `<slug>` is a kebab-case summary of the topic. Create the directory if it does not exist.
|
||||
|
||||
Use this structure. **Delete any section that ended up empty** — do not leave "TBD" placeholders.
|
||||
|
||||
```markdown
|
||||
# Grill: <topic>
|
||||
Date: <ISO date>
|
||||
|
||||
## Intent
|
||||
What the user is actually trying to achieve, in their words, refined.
|
||||
|
||||
## Constraints
|
||||
Non-negotiables surfaced during grilling.
|
||||
|
||||
## Key decisions
|
||||
- Decision: <what was decided>. Reason: <why>. Alternative considered: <what was rejected>.
|
||||
|
||||
## Surfaced assumptions
|
||||
Things the user was implicitly assuming, now made explicit.
|
||||
|
||||
## Open questions
|
||||
Things the user could not answer yet, deferred for later.
|
||||
|
||||
## Out of scope
|
||||
Things the user explicitly chose not to do.
|
||||
```
|
||||
|
||||
The log is the *distilled* output, not a transcript. Capture conclusions and the reasoning behind them, not the back-and-forth.
|
||||
|
||||
## What this skill is not
|
||||
|
||||
- **Not a bug hunt.** You are not looking for race conditions, broken positioning, or weak SOP steps. You are expanding the user's understanding of what they want and why.
|
||||
- **Not a checklist.** No mandatory questions, no required count, no fixed order. Adapt to what the user just said.
|
||||
- **Not a summary tool.** Summarizing is the opposite of grilling. Save synthesis for the log at the end.
|
||||
- **Not a coach.** Don't motivate. Don't validate. Probe.
|
||||
@ -0,0 +1,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,18 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
.hatch/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
runtime/
|
||||
logs/
|
||||
.env
|
||||
.env.local
|
||||
!migrations/
|
||||
!migrations/versions/
|
||||
!migrations/versions/*.py
|
||||
@ -0,0 +1,16 @@
|
||||
from iti.applications import create_app
|
||||
|
||||
from config import config
|
||||
from {{ project_slug }}.models import import_models
|
||||
from {{ project_slug }}.modules.example.module import ExampleModule
|
||||
|
||||
|
||||
app = create_app(
|
||||
config_mapping=config,
|
||||
model_imports=[import_models],
|
||||
modules=[ExampleModule()],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=True)
|
||||
@ -0,0 +1,35 @@
|
||||
from pathlib import Path
|
||||
|
||||
from iti.config import DevConfig as BaseDevConfig
|
||||
from iti.config import ProdConfig as BaseProdConfig
|
||||
from iti.config import TestConfig as BaseTestConfig
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
class DevConfig(BaseDevConfig):
|
||||
SQLALCHEMY_DATABASE_URI = f"sqlite:///{BASE_DIR / 'runtime' / '{{ project_slug }}_dev.db'}"
|
||||
FILE_STORAGE = {
|
||||
**BaseDevConfig.FILE_STORAGE,
|
||||
"LOCAL": {
|
||||
**BaseDevConfig.FILE_STORAGE.get("LOCAL", {}),
|
||||
"base_path": str(BASE_DIR / "runtime" / "uploads"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestConfig(BaseTestConfig):
|
||||
SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
|
||||
|
||||
|
||||
class ProdConfig(BaseProdConfig):
|
||||
pass
|
||||
|
||||
|
||||
config = {
|
||||
"dev": DevConfig,
|
||||
"test": TestConfig,
|
||||
"prod": ProdConfig,
|
||||
"default": DevConfig,
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
project_name:
|
||||
type: str
|
||||
help: Business project display name
|
||||
default: iTi Business App
|
||||
|
||||
project_slug:
|
||||
type: str
|
||||
help: Python package name for the business project
|
||||
default: iti_business_app
|
||||
|
||||
framework_git:
|
||||
type: str
|
||||
help: iTi-Flask Git URL
|
||||
default: git+ssh://git@example.com/iTi-Flask.git
|
||||
|
||||
framework_tag:
|
||||
type: str
|
||||
help: iTi-Flask Git tag
|
||||
default: v0.1.0
|
||||
@ -0,0 +1,43 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic,flask_migrate
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[logger_flask_migrate]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = flask_migrate
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@ -0,0 +1,72 @@
|
||||
import logging
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from flask import current_app
|
||||
|
||||
config = context.config
|
||||
fileConfig(config.config_file_name)
|
||||
logger = logging.getLogger("alembic.env")
|
||||
|
||||
|
||||
def get_engine():
|
||||
try:
|
||||
return current_app.extensions["migrate"].db.get_engine()
|
||||
except (TypeError, AttributeError):
|
||||
return current_app.extensions["migrate"].db.engine
|
||||
|
||||
|
||||
def get_engine_url():
|
||||
try:
|
||||
return get_engine().url.render_as_string(hide_password=False).replace("%", "%%")
|
||||
except AttributeError:
|
||||
return str(get_engine().url).replace("%", "%%")
|
||||
|
||||
|
||||
config.set_main_option("sqlalchemy.url", get_engine_url())
|
||||
target_db = current_app.extensions["migrate"].db
|
||||
|
||||
|
||||
def get_metadata():
|
||||
if hasattr(target_db, "metadatas"):
|
||||
return target_db.metadatas[None]
|
||||
return target_db.metadata
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(url=url, target_metadata=get_metadata(), literal_binds=True)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online():
|
||||
def process_revision_directives(context, revision, directives):
|
||||
if getattr(config.cmd_opts, "autogenerate", False):
|
||||
script = directives[0]
|
||||
if script.upgrade_ops.is_empty():
|
||||
directives[:] = []
|
||||
logger.info("No changes in schema detected.")
|
||||
|
||||
conf_args = current_app.extensions["migrate"].configure_args
|
||||
if conf_args.get("process_revision_directives") is None:
|
||||
conf_args["process_revision_directives"] = process_revision_directives
|
||||
|
||||
connectable = get_engine()
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=get_metadata(),
|
||||
**conf_args,
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
@ -0,0 +1 @@
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=69", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "{{ project_slug | replace('_', '-') }}"
|
||||
version = "0.1.0"
|
||||
description = "{{ project_name }}"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
{% if framework_tag %}
|
||||
"iti-flask @ {{ framework_git }}@{{ framework_tag }}",
|
||||
{% else %}
|
||||
"iti-flask @ {{ framework_git }}",
|
||||
{% endif %}
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0.0",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["{{ project_slug }}*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["."]
|
||||
@ -0,0 +1,8 @@
|
||||
from app import app
|
||||
|
||||
|
||||
def test_example_ping():
|
||||
client = app.test_client()
|
||||
response = client.get("/example/ping")
|
||||
assert response.status_code == 200
|
||||
assert response.json["data"]["pong"] is True
|
||||
@ -0,0 +1 @@
|
||||
"""{{ project_name }} package."""
|
||||
@ -0,0 +1,2 @@
|
||||
def import_models() -> None:
|
||||
from .example import Example # noqa: F401
|
||||
@ -0,0 +1,3 @@
|
||||
from .example import Example
|
||||
|
||||
__all__ = ["Example"]
|
||||
@ -0,0 +1,8 @@
|
||||
from iti.applications.common.crud import BaseModelMixin
|
||||
from iti.applications.extensions import db
|
||||
|
||||
|
||||
class Example(BaseModelMixin):
|
||||
__tablename__ = "biz_example"
|
||||
|
||||
name = db.Column(db.String(128), nullable=False, comment="名称")
|
||||
@ -0,0 +1 @@
|
||||
"""Business modules."""
|
||||
@ -0,0 +1,3 @@
|
||||
from .module import ExampleModule
|
||||
|
||||
__all__ = ["ExampleModule"]
|
||||
@ -0,0 +1,35 @@
|
||||
from .routes import bp
|
||||
from iti.applications.common.enums import MenuTypeEnum
|
||||
from iti.modules import ModuleMenuSeed, ModulePermission, get_module_registry
|
||||
|
||||
|
||||
class ExampleModule:
|
||||
name = "example"
|
||||
|
||||
def register_routes(self, app):
|
||||
app.register_blueprint(bp, url_prefix="/example")
|
||||
|
||||
def register_permissions(self, app):
|
||||
registry = get_module_registry(app)
|
||||
registry.register_permission(
|
||||
ModulePermission(
|
||||
code="example:item:list",
|
||||
name="示例列表",
|
||||
description="查看示例模块数据",
|
||||
)
|
||||
)
|
||||
|
||||
def register_menu_seed(self, app):
|
||||
registry = get_module_registry(app)
|
||||
registry.register_menu_seed(
|
||||
ModuleMenuSeed(
|
||||
id="example-menu-root",
|
||||
name="Example",
|
||||
type=MenuTypeEnum.MENU.value,
|
||||
path="/example",
|
||||
component="/example/list",
|
||||
auth_code="example:item:list",
|
||||
meta={"title": "示例模块", "icon": "carbon:application"},
|
||||
sort=100,
|
||||
)
|
||||
)
|
||||
@ -0,0 +1 @@
|
||||
"""Public facade for other modules."""
|
||||
@ -0,0 +1,10 @@
|
||||
from apiflask import APIBlueprint
|
||||
|
||||
from iti.applications.common.utils import success
|
||||
|
||||
bp = APIBlueprint("example", __name__, tag="Example")
|
||||
|
||||
|
||||
@bp.get("/ping")
|
||||
def ping():
|
||||
return success({"pong": True})
|
||||
@ -0,0 +1 @@
|
||||
"""Example schemas."""
|
||||
@ -0,0 +1 @@
|
||||
"""Example service layer."""
|
||||
@ -0,0 +1,64 @@
|
||||
# 任务
|
||||
|
||||
iTi-Flask 提供单进程任务注册表和运行器。
|
||||
|
||||
它用于轻量定时任务或手动触发任务。
|
||||
它不是分布式任务平台。
|
||||
|
||||
## 范围
|
||||
|
||||
支持:
|
||||
|
||||
- 任务注册。
|
||||
- 手动触发。
|
||||
- interval 调度。
|
||||
- 简单 cron-like 调度。
|
||||
- 单进程内防重复执行。
|
||||
- 内存中的运行状态。
|
||||
- 结构化日志。
|
||||
|
||||
不支持:
|
||||
|
||||
- 分布式锁。
|
||||
- 多实例 exactly-once。
|
||||
- 默认 Celery 或 RQ。
|
||||
- 持久化队列存储。
|
||||
|
||||
多进程生产部署时,只在一个专用进程中启用 scheduler。
|
||||
|
||||
## 使用
|
||||
|
||||
```python
|
||||
from iti.tasks import task_registry
|
||||
|
||||
|
||||
def sync_users():
|
||||
return {"synced": 10}
|
||||
|
||||
|
||||
task_registry.register(
|
||||
name="erp.sync.users",
|
||||
handler=sync_users,
|
||||
schedule="interval:600",
|
||||
)
|
||||
```
|
||||
|
||||
手动触发:
|
||||
|
||||
```python
|
||||
from iti.tasks import task_registry
|
||||
|
||||
run = task_registry.trigger("erp.sync.users")
|
||||
```
|
||||
|
||||
## 调度格式
|
||||
|
||||
第一版支持的 schedule 格式:
|
||||
|
||||
```text
|
||||
interval:60
|
||||
cron:*/10 * * * *
|
||||
```
|
||||
|
||||
内置 cron 支持刻意保持很小。
|
||||
复杂调度后续接专用 scheduler 集成。
|
||||
@ -1,40 +0,0 @@
|
||||
FLASK_ENV=dev
|
||||
SECRET_KEY=iti-flask
|
||||
JWT_SECRET_KEY=iti-flask
|
||||
DATABASE_URL=sqlite:///./../runtime/iti-flask_dev.db
|
||||
|
||||
# 前端相关
|
||||
# FRONTEND_ENABLED=False # 是否启用前端渲染
|
||||
# FRONTEND_PATH=dist # 前端文件所在位置,若static则无需填写
|
||||
|
||||
# ============================================
|
||||
# 文件存储 - 阿里云OSS
|
||||
# ============================================
|
||||
# ALIYUN_OSS_ACCESS_KEY_ID=LTAI5t...
|
||||
# ALIYUN_OSS_ACCESS_KEY_SECRET=your_access_key_secret
|
||||
# ALIYUN_OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
|
||||
# ALIYUN_OSS_BUCKET=your-bucket-name
|
||||
|
||||
# ============================================
|
||||
# 文件存储 - 腾讯云COS
|
||||
# ============================================
|
||||
# TENCENT_COS_SECRET_ID=AKIDxxx
|
||||
# TENCENT_COS_SECRET_KEY=your_secret_key
|
||||
# TENCENT_COS_REGION=ap-guangzhou
|
||||
# TENCENT_COS_BUCKET=your-bucket-1234567890
|
||||
|
||||
# ============================================
|
||||
# 文件存储 - 七牛云Kodo
|
||||
# ============================================
|
||||
# QINIU_KODO_ACCESS_KEY=your_access_key
|
||||
# QINIU_KODO_SECRET_KEY=your_secret_key
|
||||
# QINIU_KODO_BUCKET=your-bucket-name
|
||||
# QINIU_KODO_DOMAIN=cdn.example.com
|
||||
|
||||
# ============================================
|
||||
# 文件存储 - 华为云OBS
|
||||
# ============================================
|
||||
# HUAWEI_OBS_ACCESS_KEY_ID=your_access_key_id
|
||||
# HUAWEI_OBS_SECRET_ACCESS_KEY=your_secret_access_key
|
||||
# HUAWEI_OBS_SERVER=obs.cn-north-4.myhuaweicloud.com
|
||||
# HUAWEI_OBS_BUCKET=your-bucket-name
|
||||
@ -1,15 +0,0 @@
|
||||
FLASK_ENV=dev
|
||||
SECRET_KEY=iti-flask
|
||||
JWT_SECRET_KEY=iti-flask
|
||||
DATABASE_URL=mysql+pymysql://root:root@127.0.0.1:3307/iti-flask?charset=utf8mb4
|
||||
# 前端相关
|
||||
FRONTEND_ENABLED=False # 是否启用前端渲染
|
||||
FRONTEND_PATH=dist # 前端文件所在位置,若static则无需填写
|
||||
|
||||
# ============================================
|
||||
# 文件存储 - 阿里云OSS
|
||||
# ============================================
|
||||
ALIYUN_OSS_ACCESS_KEY_ID=LTAI5t9cymUAWHVEo36yygaT
|
||||
ALIYUN_OSS_ACCESS_KEY_SECRET=FaaUsxadRYyshbYeAV8ypZNYVOx3tE
|
||||
ALIYUN_OSS_ENDPOINT=oss-cn-chengdu.aliyuncs.com
|
||||
ALIYUN_OSS_BUCKET=maintaince-dev
|
||||
@ -1,7 +0,0 @@
|
||||
FLASK_ENV=prod
|
||||
SECRET_KEY=zhSYJn577LgxyWDuQboM9wX3j2BHEFUP
|
||||
JWT_SECRET_KEY=8YD37VvM3WgdpmKNt7kVFNbKnya4hBRh
|
||||
DATABASE_URL=mysql+pymysql://root:root@127.0.0.1:3307/iti-flask?charset=utf8mb4
|
||||
# 前端相关
|
||||
FRONTEND_ENABLED=False # 是否启用前端渲染
|
||||
FRONTEND_PATH=dist # 前端文件所在位置,若static则无需填写
|
||||
@ -1,4 +1,4 @@
|
||||
# SPDX-FileCopyrightText: 2025-present NoahLan <6995syu@163.com>
|
||||
#
|
||||
# SPDX-License-Identifier: MIT
|
||||
__version__ = "1.0.0"
|
||||
__version__ = "0.1.0"
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
|
||||
def init_services(app) -> None:
|
||||
"""初始化Services"""
|
||||
if not app.config.get("INIT_SYSTEM_SERVICES_ON_STARTUP", False):
|
||||
return
|
||||
|
||||
# 初始化文件系统配置
|
||||
from iti.applications.service.sys.sys_file_config import init_app as init_file_config
|
||||
init_file_config(app)
|
||||
|
||||
# 初始化文件目录
|
||||
from iti.applications.service.sys.sys_file_directory import init_app as init_file_directory
|
||||
init_file_directory(app)
|
||||
init_file_directory(app)
|
||||
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.resources
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
|
||||
@click.group()
|
||||
def iti_cli() -> None:
|
||||
"""iTi-Flask framework commands."""
|
||||
|
||||
|
||||
@iti_cli.group()
|
||||
def seed() -> None:
|
||||
"""Seed framework data."""
|
||||
|
||||
|
||||
@seed.command("system")
|
||||
def seed_system() -> None:
|
||||
"""Seed system-domain data."""
|
||||
from flask import current_app
|
||||
|
||||
from iti.modules import get_module_registry
|
||||
|
||||
from .seeds.system import seed_system_data
|
||||
|
||||
summary = seed_system_data(get_module_registry(current_app))
|
||||
for name, result in summary.items():
|
||||
click.echo(
|
||||
f"{name}: created {result['created']}, updated {result['updated']}, skipped {result['skipped']}"
|
||||
)
|
||||
|
||||
|
||||
@iti_cli.group()
|
||||
def migrations() -> None:
|
||||
"""Framework migration helpers."""
|
||||
|
||||
|
||||
@migrations.command("sync")
|
||||
@click.option(
|
||||
"--target",
|
||||
default="migrations/versions",
|
||||
show_default=True,
|
||||
help="业务项目 migration versions 目录",
|
||||
)
|
||||
def sync_migrations(target: str) -> None:
|
||||
"""同步框架系统迁移到当前项目 migration 流。"""
|
||||
target_dir = Path(target)
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
synced: list[str] = []
|
||||
skipped: list[str] = []
|
||||
source_root = importlib.resources.files("iti.framework_migrations").joinpath(
|
||||
"versions"
|
||||
)
|
||||
for source in source_root.iterdir():
|
||||
if not source.name.endswith(".py") or source.name == "__init__.py":
|
||||
continue
|
||||
target_file = target_dir / source.name
|
||||
if target_file.exists():
|
||||
skipped.append(source.name)
|
||||
continue
|
||||
with importlib.resources.as_file(source) as source_path:
|
||||
shutil.copy2(source_path, target_file)
|
||||
synced.append(source.name)
|
||||
|
||||
for name in synced:
|
||||
click.echo(f"synced: {name}")
|
||||
for name in skipped:
|
||||
click.echo(f"skipped: {name}")
|
||||
click.echo(f"summary: synced {len(synced)}, skipped {len(skipped)}")
|
||||
@ -0,0 +1 @@
|
||||
"""Framework-owned migration source files."""
|
||||
@ -0,0 +1,359 @@
|
||||
"""framework initial schema
|
||||
|
||||
Revision ID: 7de264f96a03
|
||||
Revises:
|
||||
Create Date: 2026-05-08 04:33:59.055459
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '7de264f96a03'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('sys_config',
|
||||
sa.Column('type', sa.String(length=64), nullable=False, comment='配置类型'),
|
||||
sa.Column('name', sa.String(length=255), nullable=False, comment='配置名称'),
|
||||
sa.Column('code', sa.String(length=128), nullable=False, comment='配置编码'),
|
||||
sa.Column('value', sa.Text(), nullable=True, comment='配置值'),
|
||||
sa.Column('desc', sa.Text(), nullable=True, comment='配置描述'),
|
||||
sa.Column('sort', sa.Integer(), nullable=False, comment='排序'),
|
||||
sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'),
|
||||
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
|
||||
sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
|
||||
sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'),
|
||||
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_config')),
|
||||
mysql_charset='utf8mb4',
|
||||
mysql_collate='utf8mb4_general_ci'
|
||||
)
|
||||
with op.batch_alter_table('sys_config', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_sys_config_created_by'), ['created_by'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_sys_config_updated_by'), ['updated_by'], unique=False)
|
||||
|
||||
op.create_table('sys_dept',
|
||||
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
|
||||
sa.Column('name', sa.String(length=255), nullable=False, comment='部门名称'),
|
||||
sa.Column('parent_id', sa.String(length=36), nullable=True, comment='父部门ID'),
|
||||
sa.Column('desc', sa.Text(), nullable=True, comment='部门描述'),
|
||||
sa.Column('sort', sa.Integer(), nullable=False, comment='排序'),
|
||||
sa.Column('leader_id', sa.String(length=36), nullable=True, comment='负责人ID'),
|
||||
sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
|
||||
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_dept')),
|
||||
mysql_charset='utf8mb4',
|
||||
mysql_collate='utf8mb4_general_ci'
|
||||
)
|
||||
op.create_table('sys_dict_data',
|
||||
sa.Column('type_code', sa.String(length=36), nullable=False, comment='类型编码'),
|
||||
sa.Column('label', sa.String(length=255), nullable=False, comment='数据标签'),
|
||||
sa.Column('code', sa.String(length=128), nullable=False, comment='数据编码'),
|
||||
sa.Column('value', sa.Text(), nullable=True, comment='数据值'),
|
||||
sa.Column('desc', sa.Text(), nullable=True, comment='数据描述'),
|
||||
sa.Column('sort', sa.Integer(), nullable=False, comment='排序'),
|
||||
sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'),
|
||||
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
|
||||
sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
|
||||
sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'),
|
||||
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_dict_data')),
|
||||
mysql_charset='utf8mb4',
|
||||
mysql_collate='utf8mb4_general_ci'
|
||||
)
|
||||
with op.batch_alter_table('sys_dict_data', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_sys_dict_data_created_by'), ['created_by'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_sys_dict_data_updated_by'), ['updated_by'], unique=False)
|
||||
|
||||
op.create_table('sys_dict_type',
|
||||
sa.Column('type_name', sa.String(length=255), nullable=False, comment='类型名称'),
|
||||
sa.Column('type_code', sa.String(length=128), nullable=False, comment='类型编码'),
|
||||
sa.Column('desc', sa.Text(), nullable=True, comment='类型描述'),
|
||||
sa.Column('sort', sa.Integer(), nullable=False, comment='排序'),
|
||||
sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'),
|
||||
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
|
||||
sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
|
||||
sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'),
|
||||
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_dict_type')),
|
||||
sa.UniqueConstraint('type_code', name=op.f('uq_sys_dict_type_type_code')),
|
||||
mysql_charset='utf8mb4',
|
||||
mysql_collate='utf8mb4_general_ci'
|
||||
)
|
||||
with op.batch_alter_table('sys_dict_type', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_sys_dict_type_created_by'), ['created_by'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_sys_dict_type_updated_by'), ['updated_by'], unique=False)
|
||||
|
||||
op.create_table('sys_file',
|
||||
sa.Column('filename', sa.String(length=255), nullable=False, comment='原始文件名'),
|
||||
sa.Column('file_key', sa.String(length=512), nullable=False, comment='存储路径'),
|
||||
sa.Column('file_hash', sa.String(length=64), nullable=True, comment='文件哈希'),
|
||||
sa.Column('mime_type', sa.String(length=128), nullable=True, comment='MIME类型'),
|
||||
sa.Column('file_size', sa.BigInteger(), nullable=False, comment='文件大小(字节)'),
|
||||
sa.Column('extension', sa.String(length=32), nullable=True, comment='文件扩展名'),
|
||||
sa.Column('storage_type', sa.Enum('local', 'aliyun_oss', 'tencent_cos', 'qiniu_kodo', 'huawei_obs', 'aws_s3', 'minio', name='storagetypeenum'), nullable=False, comment='存储类型'),
|
||||
sa.Column('storage_info', sa.JSON(), nullable=True, comment='存储信息(bucket/region/endpoint/meta等)'),
|
||||
sa.Column('directory_id', sa.String(length=36), nullable=True, comment='所属目录ID'),
|
||||
sa.Column('metadata', sa.JSON(), nullable=True, comment='扩展元数据'),
|
||||
sa.Column('is_deleted', sa.Boolean(), nullable=False, comment='是否已删除(回收站)'),
|
||||
sa.Column('deleted_at', sa.DateTime(), nullable=True, comment='删除时间'),
|
||||
sa.Column('deleted_by', sa.String(length=36), nullable=True, comment='删除人ID'),
|
||||
sa.Column('share_code', sa.String(length=64), nullable=True, comment='分享码'),
|
||||
sa.Column('share_password', sa.String(length=64), nullable=True, comment='分享密码'),
|
||||
sa.Column('share_expire_at', sa.DateTime(), nullable=True, comment='分享过期时间'),
|
||||
sa.Column('share_count', sa.Integer(), nullable=False, comment='分享访问次数'),
|
||||
sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'),
|
||||
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
|
||||
sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
|
||||
sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'),
|
||||
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_file')),
|
||||
mysql_charset='utf8mb4',
|
||||
mysql_collate='utf8mb4_general_ci'
|
||||
)
|
||||
with op.batch_alter_table('sys_file', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_sys_file_created_by'), ['created_by'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_sys_file_directory_id'), ['directory_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_sys_file_file_hash'), ['file_hash'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_sys_file_file_key'), ['file_key'], unique=True)
|
||||
batch_op.create_index(batch_op.f('ix_sys_file_share_code'), ['share_code'], unique=True)
|
||||
batch_op.create_index(batch_op.f('ix_sys_file_updated_by'), ['updated_by'], unique=False)
|
||||
|
||||
op.create_table('sys_file_directory',
|
||||
sa.Column('name', sa.String(length=255), nullable=False, comment='目录名称'),
|
||||
sa.Column('path', sa.String(length=1024), nullable=False, comment='完整路径'),
|
||||
sa.Column('parent_id', sa.String(length=36), nullable=True, comment='父目录ID'),
|
||||
sa.Column('level', sa.Integer(), nullable=True, comment='层级'),
|
||||
sa.Column('sort', sa.Integer(), nullable=True, comment='排序'),
|
||||
sa.Column('icon', sa.String(length=128), nullable=True, comment='目录图标'),
|
||||
sa.Column('color', sa.String(length=32), nullable=True, comment='颜色标记'),
|
||||
sa.Column('description', sa.Text(), nullable=True, comment='目录描述'),
|
||||
sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'),
|
||||
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
|
||||
sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
|
||||
sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'),
|
||||
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_file_directory'))
|
||||
)
|
||||
with op.batch_alter_table('sys_file_directory', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_sys_file_directory_created_by'), ['created_by'], unique=False)
|
||||
batch_op.create_index('ix_sys_file_directory_path', ['path'], unique=False, mysql_length=255)
|
||||
batch_op.create_index(batch_op.f('ix_sys_file_directory_updated_by'), ['updated_by'], unique=False)
|
||||
|
||||
op.create_table('sys_log',
|
||||
sa.Column('name', sa.String(length=100), nullable=True, comment='操作名称'),
|
||||
sa.Column('method', sa.String(length=10), nullable=True, comment='请求方法'),
|
||||
sa.Column('user_id', sa.String(length=36), nullable=True, comment='用户ID'),
|
||||
sa.Column('path', sa.String(length=255), nullable=True, comment='请求路径'),
|
||||
sa.Column('ip', sa.String(length=255), nullable=True, comment='IP地址'),
|
||||
sa.Column('user_agent', sa.Text(), nullable=True, comment='用户代理'),
|
||||
sa.Column('headers', sa.Text(), nullable=True, comment='请求头'),
|
||||
sa.Column('query_params', sa.Text(), nullable=True, comment='请求参数'),
|
||||
sa.Column('body_params', sa.Text(), nullable=True, comment='请求体参数'),
|
||||
sa.Column('execution_time', sa.Float(), nullable=True, comment='执行时间(毫秒)'),
|
||||
sa.Column('response', sa.Text(), nullable=True, comment='响应结果'),
|
||||
sa.Column('exception', sa.Text(), nullable=True, comment='异常信息'),
|
||||
sa.Column('success', sa.Boolean(), nullable=True, comment='是否成功'),
|
||||
sa.Column('desc', sa.Text(), nullable=True, comment='描述'),
|
||||
sa.Column('type', sa.Enum('SYSTEM', 'AUTH', 'OPERATION', 'AUDIT', 'SECURITY', 'JOB', 'API', 'DB', 'PAYMENT', 'MESSAGE', 'OSS', 'OTHER', name='logtype'), nullable=False, comment='日志类型'),
|
||||
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
|
||||
sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
|
||||
sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'),
|
||||
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_log')),
|
||||
mysql_charset='utf8mb4',
|
||||
mysql_collate='utf8mb4_general_ci'
|
||||
)
|
||||
with op.batch_alter_table('sys_log', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_sys_log_created_by'), ['created_by'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_sys_log_updated_by'), ['updated_by'], unique=False)
|
||||
|
||||
op.create_table('sys_menu',
|
||||
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
|
||||
sa.Column('name', sa.String(length=255), nullable=False, comment='菜单名称'),
|
||||
sa.Column('type', sa.Enum('catalog', 'menu', 'button', 'embedded', 'link', name='menutypeenum'), nullable=False, comment='菜单类型'),
|
||||
sa.Column('path', sa.String(length=255), nullable=True, comment='菜单路径'),
|
||||
sa.Column('component', sa.String(length=255), nullable=True, comment='菜单组件'),
|
||||
sa.Column('redirect', sa.String(length=255), nullable=True, comment='菜单重定向'),
|
||||
sa.Column('sort', sa.Integer(), nullable=False, comment='排序'),
|
||||
sa.Column('auth_code', sa.String(length=128), nullable=True, comment='权限编码'),
|
||||
sa.Column('meta', sa.JSON(), nullable=True, comment='菜单元数据'),
|
||||
sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'),
|
||||
sa.Column('parent_id', sa.String(length=36), nullable=True, comment='父菜单ID'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
|
||||
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_menu')),
|
||||
sa.UniqueConstraint('name', name=op.f('uq_sys_menu_name')),
|
||||
mysql_charset='utf8mb4',
|
||||
mysql_collate='utf8mb4_general_ci'
|
||||
)
|
||||
op.create_table('sys_role',
|
||||
sa.Column('name', sa.String(length=64), nullable=False, comment='名称'),
|
||||
sa.Column('code', sa.String(length=64), nullable=False, comment='编码'),
|
||||
sa.Column('desc', sa.Text(), nullable=True, comment='描述'),
|
||||
sa.Column('sort', sa.Integer(), nullable=False, comment='排序'),
|
||||
sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'),
|
||||
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
|
||||
sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
|
||||
sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'),
|
||||
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_role')),
|
||||
sa.UniqueConstraint('code', name=op.f('uq_sys_role_code')),
|
||||
mysql_charset='utf8mb4',
|
||||
mysql_collate='utf8mb4_general_ci'
|
||||
)
|
||||
with op.batch_alter_table('sys_role', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_sys_role_created_by'), ['created_by'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_sys_role_updated_by'), ['updated_by'], unique=False)
|
||||
|
||||
op.create_table('sys_role_menu',
|
||||
sa.Column('role_id', sa.String(length=36), nullable=False, comment='角色ID'),
|
||||
sa.Column('menu_id', sa.String(length=36), nullable=False, comment='菜单ID'),
|
||||
sa.PrimaryKeyConstraint('role_id', 'menu_id', name=op.f('pk_sys_role_menu'))
|
||||
)
|
||||
op.create_table('sys_user',
|
||||
sa.Column('username', sa.String(length=64), nullable=False, comment='用户名'),
|
||||
sa.Column('phone', sa.String(length=13), nullable=True, comment='手机号'),
|
||||
sa.Column('email', sa.String(length=255), nullable=True, comment='邮箱'),
|
||||
sa.Column('password', sa.String(length=255), nullable=False, comment='密码'),
|
||||
sa.Column('realname', sa.String(length=32), nullable=True, comment='真实姓名'),
|
||||
sa.Column('desc', sa.Text(), nullable=True, comment='描述'),
|
||||
sa.Column('avatar', sa.String(length=255), nullable=True, comment='头像'),
|
||||
sa.Column('gender', sa.Enum('male', 'female', 'secure', name='genderenum'), nullable=False, comment='性别'),
|
||||
sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'),
|
||||
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
|
||||
sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
|
||||
sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'),
|
||||
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_user')),
|
||||
mysql_charset='utf8mb4',
|
||||
mysql_collate='utf8mb4_general_ci'
|
||||
)
|
||||
with op.batch_alter_table('sys_user', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_sys_user_created_by'), ['created_by'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_sys_user_updated_by'), ['updated_by'], unique=False)
|
||||
|
||||
op.create_table('sys_user_dept',
|
||||
sa.Column('user_id', sa.String(length=36), nullable=False, comment='用户ID'),
|
||||
sa.Column('dept_id', sa.String(length=36), nullable=False, comment='部门ID'),
|
||||
sa.PrimaryKeyConstraint('user_id', 'dept_id', name=op.f('pk_sys_user_dept'))
|
||||
)
|
||||
op.create_table('sys_user_role',
|
||||
sa.Column('user_id', sa.String(length=36), nullable=False, comment='用户ID'),
|
||||
sa.Column('role_id', sa.String(length=36), nullable=False, comment='角色ID'),
|
||||
sa.PrimaryKeyConstraint('user_id', 'role_id', name=op.f('pk_sys_user_role'))
|
||||
)
|
||||
op.create_table('sys_user_attribute',
|
||||
sa.Column('user_id', sa.String(length=36), nullable=False, comment='用户ID'),
|
||||
sa.Column('attr_group', sa.String(length=64), nullable=False, comment='属性分组(如: erp, custom)'),
|
||||
sa.Column('attr_key', sa.String(length=128), nullable=False, comment='属性键'),
|
||||
sa.Column('attr_value', sa.Text(), nullable=True, comment='属性值'),
|
||||
sa.Column('attr_type', sa.String(length=32), nullable=False, comment='值类型(string/int/float/bool/json/encrypted)'),
|
||||
sa.Column('description', sa.String(length=255), nullable=True, comment='属性描述'),
|
||||
sa.Column('sort', sa.Integer(), nullable=False, comment='排序'),
|
||||
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
|
||||
sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
|
||||
sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'),
|
||||
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['sys_user.id'], name=op.f('fk_sys_user_attribute_user_id_sys_user'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_user_attribute')),
|
||||
sa.UniqueConstraint('user_id', 'attr_group', 'attr_key', name='uk_user_group_key')
|
||||
)
|
||||
with op.batch_alter_table('sys_user_attribute', schema=None) as batch_op:
|
||||
batch_op.create_index('idx_user_group_key', ['user_id', 'attr_group', 'attr_key'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_sys_user_attribute_attr_group'), ['attr_group'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_sys_user_attribute_created_by'), ['created_by'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_sys_user_attribute_updated_by'), ['updated_by'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_sys_user_attribute_user_id'), ['user_id'], unique=False)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('sys_user_attribute', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_sys_user_attribute_user_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_sys_user_attribute_updated_by'))
|
||||
batch_op.drop_index(batch_op.f('ix_sys_user_attribute_created_by'))
|
||||
batch_op.drop_index(batch_op.f('ix_sys_user_attribute_attr_group'))
|
||||
batch_op.drop_index('idx_user_group_key')
|
||||
|
||||
op.drop_table('sys_user_attribute')
|
||||
op.drop_table('sys_user_role')
|
||||
op.drop_table('sys_user_dept')
|
||||
with op.batch_alter_table('sys_user', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_sys_user_updated_by'))
|
||||
batch_op.drop_index(batch_op.f('ix_sys_user_created_by'))
|
||||
|
||||
op.drop_table('sys_user')
|
||||
op.drop_table('sys_role_menu')
|
||||
with op.batch_alter_table('sys_role', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_sys_role_updated_by'))
|
||||
batch_op.drop_index(batch_op.f('ix_sys_role_created_by'))
|
||||
|
||||
op.drop_table('sys_role')
|
||||
op.drop_table('sys_menu')
|
||||
with op.batch_alter_table('sys_log', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_sys_log_updated_by'))
|
||||
batch_op.drop_index(batch_op.f('ix_sys_log_created_by'))
|
||||
|
||||
op.drop_table('sys_log')
|
||||
with op.batch_alter_table('sys_file_directory', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_sys_file_directory_updated_by'))
|
||||
batch_op.drop_index('ix_sys_file_directory_path', mysql_length=255)
|
||||
batch_op.drop_index(batch_op.f('ix_sys_file_directory_created_by'))
|
||||
|
||||
op.drop_table('sys_file_directory')
|
||||
with op.batch_alter_table('sys_file', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_sys_file_updated_by'))
|
||||
batch_op.drop_index(batch_op.f('ix_sys_file_share_code'))
|
||||
batch_op.drop_index(batch_op.f('ix_sys_file_file_key'))
|
||||
batch_op.drop_index(batch_op.f('ix_sys_file_file_hash'))
|
||||
batch_op.drop_index(batch_op.f('ix_sys_file_directory_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_sys_file_created_by'))
|
||||
|
||||
op.drop_table('sys_file')
|
||||
with op.batch_alter_table('sys_dict_type', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_sys_dict_type_updated_by'))
|
||||
batch_op.drop_index(batch_op.f('ix_sys_dict_type_created_by'))
|
||||
|
||||
op.drop_table('sys_dict_type')
|
||||
with op.batch_alter_table('sys_dict_data', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_sys_dict_data_updated_by'))
|
||||
batch_op.drop_index(batch_op.f('ix_sys_dict_data_created_by'))
|
||||
|
||||
op.drop_table('sys_dict_data')
|
||||
op.drop_table('sys_dept')
|
||||
with op.batch_alter_table('sys_config', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_sys_config_updated_by'))
|
||||
batch_op.drop_index(batch_op.f('ix_sys_config_created_by'))
|
||||
|
||||
op.drop_table('sys_config')
|
||||
# ### end Alembic commands ###
|
||||
@ -0,0 +1 @@
|
||||
"""Framework migration versions."""
|
||||
@ -0,0 +1,11 @@
|
||||
from .base import ItiModule, ModuleMenuSeed, ModulePermission
|
||||
from .registry import ModuleRegistry, get_module_registry, init_modules
|
||||
|
||||
__all__ = [
|
||||
"ItiModule",
|
||||
"ModuleMenuSeed",
|
||||
"ModulePermission",
|
||||
"ModuleRegistry",
|
||||
"get_module_registry",
|
||||
"init_modules",
|
||||
]
|
||||
@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModulePermission:
|
||||
"""业务模块声明的权限码。"""
|
||||
|
||||
code: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModuleMenuSeed:
|
||||
"""业务模块声明的菜单 seed 数据。"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
type: str
|
||||
path: str | None = None
|
||||
component: str | None = None
|
||||
redirect: str | None = None
|
||||
parent_id: str | None = None
|
||||
auth_code: str | None = None
|
||||
meta: dict[str, Any] = field(default_factory=dict)
|
||||
sort: int = 0
|
||||
status: str = "enabled"
|
||||
admin_roles: tuple[str, ...] = ("ADMIN",)
|
||||
|
||||
def as_menu_payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"type": self.type,
|
||||
"path": self.path,
|
||||
"component": self.component,
|
||||
"redirect": self.redirect,
|
||||
"parent_id": self.parent_id,
|
||||
"auth_code": self.auth_code,
|
||||
"meta": self.meta,
|
||||
"sort": self.sort,
|
||||
"status": self.status,
|
||||
}
|
||||
|
||||
|
||||
class ItiModule(Protocol):
|
||||
"""Protocol for in-process business modules."""
|
||||
|
||||
name: str
|
||||
|
||||
def init_app(self, app) -> None:
|
||||
"""Initialize the module against the Flask app."""
|
||||
|
||||
def register_routes(self, app) -> None:
|
||||
"""Register module routes."""
|
||||
|
||||
def register_permissions(self, app) -> None:
|
||||
"""Register permission metadata."""
|
||||
|
||||
def register_menu_seed(self, app) -> None:
|
||||
"""Register menu seed metadata."""
|
||||
|
||||
def register_commands(self, app) -> None:
|
||||
"""Register CLI commands."""
|
||||
@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from .base import ModuleMenuSeed, ModulePermission
|
||||
|
||||
|
||||
ModulePhase = str
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModuleRegistry:
|
||||
"""Registry for in-process modules."""
|
||||
|
||||
modules: list[Any] = field(default_factory=list)
|
||||
permissions: dict[str, ModulePermission] = field(default_factory=dict)
|
||||
menu_seeds: dict[str, ModuleMenuSeed] = field(default_factory=dict)
|
||||
|
||||
def register(self, module: Any) -> None:
|
||||
name = getattr(module, "name", None)
|
||||
if not name:
|
||||
raise ValueError("module must define a non-empty name")
|
||||
if self.get(name) is not None:
|
||||
raise ValueError(f"module already registered: {name}")
|
||||
self.modules.append(module)
|
||||
|
||||
def extend(self, modules: Iterable[Any] | None) -> None:
|
||||
for module in modules or []:
|
||||
self.register(module)
|
||||
|
||||
def get(self, name: str) -> Any | None:
|
||||
for module in self.modules:
|
||||
if getattr(module, "name", None) == name:
|
||||
return module
|
||||
return None
|
||||
|
||||
def run_phase(self, phase: ModulePhase, app) -> None:
|
||||
for module in self.modules:
|
||||
callback = getattr(module, phase, None)
|
||||
if callback is not None:
|
||||
callback(app)
|
||||
|
||||
def register_permission(self, permission: ModulePermission) -> ModulePermission:
|
||||
if not permission.code:
|
||||
raise ValueError("permission code is required")
|
||||
if permission.code in self.permissions:
|
||||
raise ValueError(f"permission already registered: {permission.code}")
|
||||
self.permissions[permission.code] = permission
|
||||
return permission
|
||||
|
||||
def register_menu_seed(self, menu_seed: ModuleMenuSeed) -> ModuleMenuSeed:
|
||||
if not menu_seed.id:
|
||||
raise ValueError("menu seed id is required")
|
||||
if not menu_seed.name:
|
||||
raise ValueError("menu seed name is required")
|
||||
if menu_seed.id in self.menu_seeds:
|
||||
raise ValueError(f"menu seed already registered: {menu_seed.id}")
|
||||
self.menu_seeds[menu_seed.id] = menu_seed
|
||||
return menu_seed
|
||||
|
||||
def list_permissions(self) -> list[ModulePermission]:
|
||||
return list(self.permissions.values())
|
||||
|
||||
def list_menu_seeds(self) -> list[ModuleMenuSeed]:
|
||||
return sorted(
|
||||
self.menu_seeds.values(),
|
||||
key=lambda menu_seed: (menu_seed.sort, menu_seed.name),
|
||||
)
|
||||
|
||||
|
||||
def get_module_registry(app) -> ModuleRegistry:
|
||||
registry = app.extensions.get("iti_modules")
|
||||
if registry is None:
|
||||
registry = ModuleRegistry()
|
||||
app.extensions["iti_modules"] = registry
|
||||
return registry
|
||||
|
||||
|
||||
def init_modules(app, modules: Iterable[Any] | None = None) -> ModuleRegistry:
|
||||
registry = get_module_registry(app)
|
||||
registry.extend(modules)
|
||||
registry.run_phase("init_app", app)
|
||||
registry.run_phase("register_commands", app)
|
||||
return registry
|
||||
@ -0,0 +1,3 @@
|
||||
from .system import seed_system_data
|
||||
|
||||
__all__ = ["seed_system_data"]
|
||||
@ -0,0 +1,697 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from iti.applications.common.enums import GenderEnum, MenuTypeEnum, StatusEnum, SysConfigType
|
||||
from iti.applications.extensions import db
|
||||
from iti.applications.models import Role, SysConfig, SysDictData, SysDictType, SysMenu, User
|
||||
from iti.modules import ModuleMenuSeed
|
||||
from iti.modules.registry import ModuleRegistry
|
||||
|
||||
|
||||
@dataclass
|
||||
class SeedCounter:
|
||||
created: int = 0
|
||||
updated: int = 0
|
||||
skipped: int = 0
|
||||
|
||||
def as_dict(self) -> dict[str, int]:
|
||||
return {
|
||||
"created": self.created,
|
||||
"updated": self.updated,
|
||||
"skipped": self.skipped,
|
||||
}
|
||||
|
||||
|
||||
SYSTEM_MENU_ID = "b3af308711954e62ba7471891b82f721"
|
||||
USER_MENU_ID = "93e1c7448c144f60875c4725dfa93b5a"
|
||||
ROLE_MENU_ID = "8ac71de8a14c413997f7f81f5fcf343c"
|
||||
DEPT_MENU_ID = "5d76b276594349b5bfbed42da656bd53"
|
||||
MENU_MENU_ID = "b3f689cf6d594f94aeed912bd5c7dd80"
|
||||
CONFIG_MENU_ID = "42a830108d9c49bca4e0108aba27a3cf"
|
||||
DICT_MENU_ID = "50d583e8c8584d43ab94939420dce0cb"
|
||||
LOG_MENU_ID = "774da56753514b4eafc913162970f4f1"
|
||||
|
||||
|
||||
DEFAULT_ROLES = [
|
||||
{
|
||||
"name": "管理员",
|
||||
"code": "ADMIN",
|
||||
"desc": "系统默认管理员",
|
||||
"sort": 0,
|
||||
},
|
||||
{
|
||||
"name": "普通角色",
|
||||
"code": "COMMON",
|
||||
"desc": "一般角色",
|
||||
"sort": 100,
|
||||
},
|
||||
]
|
||||
|
||||
DEFAULT_USERS = [
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "123456",
|
||||
"realname": "管理员",
|
||||
"phone": "18888888888",
|
||||
"email": "a@a.com",
|
||||
"avatar": "",
|
||||
"gender": GenderEnum.SECURE.value,
|
||||
"desc": "系统默认管理员",
|
||||
"role_codes": ["ADMIN"],
|
||||
},
|
||||
]
|
||||
|
||||
DEFAULT_MENUS = [
|
||||
{
|
||||
"id": SYSTEM_MENU_ID,
|
||||
"name": "System",
|
||||
"type": MenuTypeEnum.CATALOG.value,
|
||||
"path": "/system",
|
||||
"sort": 0,
|
||||
"meta": {"title": "系统管理", "icon": "ion:settings-outline"},
|
||||
},
|
||||
{
|
||||
"id": USER_MENU_ID,
|
||||
"name": "User",
|
||||
"type": MenuTypeEnum.MENU.value,
|
||||
"path": "/system/user",
|
||||
"component": "/system/user/list",
|
||||
"sort": 1,
|
||||
"parent_id": SYSTEM_MENU_ID,
|
||||
"auth_code": "system:user:list",
|
||||
"meta": {"title": "system.user.title", "icon": "carbon:user"},
|
||||
},
|
||||
{
|
||||
"id": ROLE_MENU_ID,
|
||||
"name": "Role",
|
||||
"type": MenuTypeEnum.MENU.value,
|
||||
"path": "/system/role",
|
||||
"component": "/system/role/list",
|
||||
"sort": 2,
|
||||
"parent_id": SYSTEM_MENU_ID,
|
||||
"auth_code": "system:role:list",
|
||||
"meta": {"title": "角色管理", "icon": "carbon:user-role"},
|
||||
},
|
||||
{
|
||||
"id": DEPT_MENU_ID,
|
||||
"name": "Dept",
|
||||
"type": MenuTypeEnum.MENU.value,
|
||||
"path": "/system/dept",
|
||||
"component": "/system/dept/list",
|
||||
"sort": 3,
|
||||
"parent_id": SYSTEM_MENU_ID,
|
||||
"auth_code": "system:dept:list",
|
||||
"meta": {"title": "部门管理", "icon": "carbon:container-services", "order": 3},
|
||||
},
|
||||
{
|
||||
"id": MENU_MENU_ID,
|
||||
"name": "Menu",
|
||||
"type": MenuTypeEnum.MENU.value,
|
||||
"path": "/system/menu",
|
||||
"component": "/system/menu/list",
|
||||
"sort": 4,
|
||||
"parent_id": SYSTEM_MENU_ID,
|
||||
"auth_code": "system:menu:list",
|
||||
"meta": {"title": "菜单管理", "icon": "carbon:menu", "badge": ""},
|
||||
},
|
||||
{
|
||||
"id": CONFIG_MENU_ID,
|
||||
"name": "SysConfig",
|
||||
"type": MenuTypeEnum.MENU.value,
|
||||
"path": "/system/config",
|
||||
"component": "/system/config/list",
|
||||
"sort": 5,
|
||||
"parent_id": SYSTEM_MENU_ID,
|
||||
"auth_code": "system:config:list",
|
||||
"meta": {"title": "系统配置", "icon": "carbon:document-configuration", "order": 5},
|
||||
},
|
||||
{
|
||||
"id": DICT_MENU_ID,
|
||||
"name": "SysDict",
|
||||
"type": MenuTypeEnum.MENU.value,
|
||||
"path": "/system/dict",
|
||||
"component": "/system/dict/list",
|
||||
"sort": 6,
|
||||
"parent_id": SYSTEM_MENU_ID,
|
||||
"auth_code": "system:dict:list",
|
||||
"meta": {"title": "字典管理", "icon": "carbon:book"},
|
||||
},
|
||||
{
|
||||
"id": LOG_MENU_ID,
|
||||
"name": "SysLog",
|
||||
"type": MenuTypeEnum.MENU.value,
|
||||
"path": "/system/log",
|
||||
"component": "/system/log/list",
|
||||
"sort": 7,
|
||||
"parent_id": SYSTEM_MENU_ID,
|
||||
"auth_code": "system:log:list",
|
||||
"meta": {"title": "日志管理", "icon": "carbon:cloud-logging"},
|
||||
},
|
||||
{
|
||||
"id": "c5d1be767bce409bafa141ec8e5fc419",
|
||||
"name": "SystemUserCreate",
|
||||
"type": MenuTypeEnum.BUTTON.value,
|
||||
"path": "",
|
||||
"sort": 0,
|
||||
"parent_id": USER_MENU_ID,
|
||||
"auth_code": "system:user:create",
|
||||
"meta": {"title": "common.create", "order": 0},
|
||||
},
|
||||
{
|
||||
"id": "899b280630334766b01ff83f6f4ebacc",
|
||||
"name": "SystemUserUpdate",
|
||||
"type": MenuTypeEnum.BUTTON.value,
|
||||
"path": "",
|
||||
"sort": 1,
|
||||
"parent_id": USER_MENU_ID,
|
||||
"auth_code": "system:user:edit",
|
||||
"meta": {"title": "common.edit"},
|
||||
},
|
||||
{
|
||||
"id": "2431b03462a84f90ba15c29bca07d39e",
|
||||
"name": "SystemUserDelete",
|
||||
"type": MenuTypeEnum.BUTTON.value,
|
||||
"path": "",
|
||||
"sort": 2,
|
||||
"parent_id": USER_MENU_ID,
|
||||
"auth_code": "system:user:delete",
|
||||
"meta": {"title": "common.delete"},
|
||||
},
|
||||
{
|
||||
"id": "12cf74c410d044d986840c93fb70c397",
|
||||
"name": "SystemUserResetPassword",
|
||||
"type": MenuTypeEnum.BUTTON.value,
|
||||
"path": "",
|
||||
"sort": 3,
|
||||
"parent_id": USER_MENU_ID,
|
||||
"auth_code": "system:user:resetpwd",
|
||||
"meta": {"title": "修改密码", "order": 3},
|
||||
},
|
||||
{
|
||||
"id": "9f71686949c7410ea032956c52252a06",
|
||||
"name": "SystemRoleCreate",
|
||||
"type": MenuTypeEnum.BUTTON.value,
|
||||
"path": "",
|
||||
"sort": 0,
|
||||
"parent_id": ROLE_MENU_ID,
|
||||
"auth_code": "system:role:create",
|
||||
"meta": {"title": "common.create", "order": 0},
|
||||
},
|
||||
{
|
||||
"id": "bb271c537c604ee894a0339ced1b4d46",
|
||||
"name": "SystemRoleEdit",
|
||||
"type": MenuTypeEnum.BUTTON.value,
|
||||
"path": "",
|
||||
"sort": 1,
|
||||
"parent_id": ROLE_MENU_ID,
|
||||
"auth_code": "system:role:edit",
|
||||
"meta": {"title": "common.edit", "order": 1},
|
||||
},
|
||||
{
|
||||
"id": "a643f56b9a844c1eb8af7da1bd8e96da",
|
||||
"name": "SystemRoleDelete",
|
||||
"type": MenuTypeEnum.BUTTON.value,
|
||||
"path": "",
|
||||
"sort": 2,
|
||||
"parent_id": ROLE_MENU_ID,
|
||||
"auth_code": "system:role:delete",
|
||||
"meta": {"title": "common.delete", "order": 2},
|
||||
},
|
||||
{
|
||||
"id": "7f2913a04e1b47c8bc590eee4708a147",
|
||||
"name": "SystemDeptCreate",
|
||||
"type": MenuTypeEnum.BUTTON.value,
|
||||
"path": "",
|
||||
"sort": 0,
|
||||
"parent_id": DEPT_MENU_ID,
|
||||
"auth_code": "system:dept:create",
|
||||
"meta": {"title": "common.create", "order": 0},
|
||||
},
|
||||
{
|
||||
"id": "3e6e8ebda98c4e1aad27478d7bac595a",
|
||||
"name": "SystemDeptEdit",
|
||||
"type": MenuTypeEnum.BUTTON.value,
|
||||
"path": "",
|
||||
"sort": 1,
|
||||
"parent_id": DEPT_MENU_ID,
|
||||
"auth_code": "system:dept:edit",
|
||||
"meta": {"title": "common.edit", "order": 1},
|
||||
},
|
||||
{
|
||||
"id": "b52bb4045a434253ab0af21d03603458",
|
||||
"name": "SystemDeptDelete",
|
||||
"type": MenuTypeEnum.BUTTON.value,
|
||||
"path": "",
|
||||
"sort": 2,
|
||||
"parent_id": DEPT_MENU_ID,
|
||||
"auth_code": "system:dept:delete",
|
||||
"meta": {"title": "common.delete", "order": 2},
|
||||
},
|
||||
{
|
||||
"id": "18a8a2fcc8704c94baf47590dd7eb2e8",
|
||||
"name": "SystemMenuCreate",
|
||||
"type": MenuTypeEnum.BUTTON.value,
|
||||
"path": None,
|
||||
"sort": 0,
|
||||
"parent_id": MENU_MENU_ID,
|
||||
"auth_code": "system:menu:create",
|
||||
"meta": {"title": "common.create"},
|
||||
},
|
||||
{
|
||||
"id": "b41da6285e3f4bc4a39aa8ae13944b41",
|
||||
"name": "SystemMenuEdit",
|
||||
"type": MenuTypeEnum.BUTTON.value,
|
||||
"path": "",
|
||||
"sort": 1,
|
||||
"parent_id": MENU_MENU_ID,
|
||||
"auth_code": "system:menu:edit",
|
||||
"meta": {"title": "common.edit", "order": 1},
|
||||
},
|
||||
{
|
||||
"id": "a322ddada8ed4bcca1f5edcd9f3d33d0",
|
||||
"name": "SystemMenuDelete",
|
||||
"type": MenuTypeEnum.BUTTON.value,
|
||||
"path": "",
|
||||
"sort": 2,
|
||||
"parent_id": MENU_MENU_ID,
|
||||
"auth_code": "system:menu:delete",
|
||||
"meta": {"title": "common.delete"},
|
||||
},
|
||||
{
|
||||
"id": "57daf457de6546ab9e887233892e712e",
|
||||
"name": "SystemConfigCreate",
|
||||
"type": MenuTypeEnum.BUTTON.value,
|
||||
"path": "",
|
||||
"sort": 0,
|
||||
"parent_id": CONFIG_MENU_ID,
|
||||
"auth_code": "system:config:create",
|
||||
"meta": {"title": "common.create", "order": 0},
|
||||
},
|
||||
{
|
||||
"id": "5dc4a3d79f20496d82bc85f6eed1389b",
|
||||
"name": "SystemConfigEdit",
|
||||
"type": MenuTypeEnum.BUTTON.value,
|
||||
"path": "",
|
||||
"sort": 1,
|
||||
"parent_id": CONFIG_MENU_ID,
|
||||
"auth_code": "system:config:edit",
|
||||
"meta": {"title": "common.edit", "order": 1},
|
||||
},
|
||||
{
|
||||
"id": "bafe03a1da6c4224b69ecadd721cba0a",
|
||||
"name": "SystemConfigDelete",
|
||||
"type": MenuTypeEnum.BUTTON.value,
|
||||
"path": "",
|
||||
"sort": 2,
|
||||
"parent_id": CONFIG_MENU_ID,
|
||||
"auth_code": "system:config:delete",
|
||||
"meta": {"title": "common.delete", "order": 2},
|
||||
},
|
||||
{
|
||||
"id": "8c96ce0db0724139a9ab7fbcfcf52500",
|
||||
"name": "SystemDictCreate",
|
||||
"type": MenuTypeEnum.BUTTON.value,
|
||||
"path": "",
|
||||
"sort": 0,
|
||||
"parent_id": DICT_MENU_ID,
|
||||
"auth_code": "system:dict:create",
|
||||
"meta": {"title": "common.create", "order": 0},
|
||||
},
|
||||
{
|
||||
"id": "de5bcbdb81b34b02991614c13f12043a",
|
||||
"name": "SystemDictEdit",
|
||||
"type": MenuTypeEnum.BUTTON.value,
|
||||
"path": "",
|
||||
"sort": 1,
|
||||
"parent_id": DICT_MENU_ID,
|
||||
"auth_code": "system:dict:edit",
|
||||
"meta": {"title": "common.edit", "order": 1},
|
||||
},
|
||||
{
|
||||
"id": "3385698f84e44249ad5eb733d7232c96",
|
||||
"name": "SystemDictDelete",
|
||||
"type": MenuTypeEnum.BUTTON.value,
|
||||
"path": "",
|
||||
"sort": 2,
|
||||
"parent_id": DICT_MENU_ID,
|
||||
"auth_code": "system:dict:delete",
|
||||
"meta": {"title": "common.delete", "order": 2},
|
||||
},
|
||||
{
|
||||
"id": "74e4cff0de2b4532a187ed01714d6577",
|
||||
"name": "SystemLogDelete",
|
||||
"type": MenuTypeEnum.BUTTON.value,
|
||||
"path": "",
|
||||
"sort": 0,
|
||||
"parent_id": LOG_MENU_ID,
|
||||
"auth_code": "system:log:delete",
|
||||
"meta": {"title": "common.delete", "order": 0},
|
||||
},
|
||||
]
|
||||
|
||||
DEFAULT_DICT_TYPES = [
|
||||
{
|
||||
"type_name": "状态",
|
||||
"type_code": "status",
|
||||
"desc": "通用启停状态",
|
||||
"sort": 1,
|
||||
"data": [
|
||||
{"label": "启用", "code": "enabled", "value": "enabled", "sort": 1},
|
||||
{"label": "停用", "code": "disabled", "value": "disabled", "sort": 2},
|
||||
],
|
||||
},
|
||||
{
|
||||
"type_name": "性别",
|
||||
"type_code": "gender",
|
||||
"desc": "用户性别",
|
||||
"sort": 2,
|
||||
"data": [
|
||||
{"label": "男", "code": "male", "value": "male", "sort": 1},
|
||||
{"label": "女", "code": "female", "value": "female", "sort": 2},
|
||||
{"label": "保密", "code": "secure", "value": "secure", "sort": 3},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
DEFAULT_CONFIGS = [
|
||||
{
|
||||
"type": SysConfigType.USER.value,
|
||||
"name": "默认用户密码",
|
||||
"code": "DEFAULT_USER_PASSWORD",
|
||||
"value": "123456",
|
||||
"desc": "系统自动注册时使用的默认用户密码",
|
||||
"sort": 1,
|
||||
},
|
||||
{
|
||||
"type": SysConfigType.USER.value,
|
||||
"name": "默认用户角色",
|
||||
"code": "DEFAULT_USER_ROLES",
|
||||
"value": "COMMON",
|
||||
"desc": "系统自动注册时使用的默认用户角色,多个角色用逗号分隔",
|
||||
"sort": 2,
|
||||
},
|
||||
{
|
||||
"type": SysConfigType.USER.value,
|
||||
"name": "默认用户部门",
|
||||
"code": "DEFAULT_USER_DEPTS",
|
||||
"value": "",
|
||||
"desc": "系统自动注册时使用的默认用户部门,多个部门用逗号分隔",
|
||||
"sort": 3,
|
||||
},
|
||||
{
|
||||
"type": SysConfigType.SYSTEM.value,
|
||||
"name": "系统名称",
|
||||
"code": "system.name",
|
||||
"value": "iTi-Flask",
|
||||
"desc": "默认系统名称",
|
||||
"sort": 10,
|
||||
},
|
||||
{
|
||||
"type": SysConfigType.SYSTEM.value,
|
||||
"name": "后端访问地址",
|
||||
"code": "BACKEND_URL",
|
||||
"value": "http://localhost:5000",
|
||||
"desc": "后端访问地址。应配置为前端可访问的后端地址",
|
||||
"sort": 90,
|
||||
},
|
||||
{
|
||||
"type": SysConfigType.SYSTEM.value,
|
||||
"name": "文件回收站功能",
|
||||
"code": "FILE_RECYCLE_ENABLED",
|
||||
"value": "true",
|
||||
"desc": "是否启用文件回收站功能",
|
||||
"sort": 100,
|
||||
},
|
||||
{
|
||||
"type": SysConfigType.SYSTEM.value,
|
||||
"name": "回收站保留天数",
|
||||
"code": "FILE_RECYCLE_DAYS",
|
||||
"value": "30",
|
||||
"desc": "回收站文件保留天数",
|
||||
"sort": 101,
|
||||
},
|
||||
{
|
||||
"type": SysConfigType.SYSTEM.value,
|
||||
"name": "文件分享功能",
|
||||
"code": "FILE_SHARE_ENABLED",
|
||||
"value": "true",
|
||||
"desc": "是否启用文件分享功能",
|
||||
"sort": 102,
|
||||
},
|
||||
{
|
||||
"type": SysConfigType.SYSTEM.value,
|
||||
"name": "分享默认过期时间",
|
||||
"code": "FILE_SHARE_DEFAULT_EXPIRE_HOURS",
|
||||
"value": "168",
|
||||
"desc": "文件分享默认过期时间(小时)",
|
||||
"sort": 103,
|
||||
},
|
||||
{
|
||||
"type": SysConfigType.SYSTEM.value,
|
||||
"name": "分片上传阈值",
|
||||
"code": "FILE_CHUNK_THRESHOLD",
|
||||
"value": "104857600",
|
||||
"desc": "文件大小超过此阈值时使用分片上传",
|
||||
"sort": 104,
|
||||
},
|
||||
{
|
||||
"type": SysConfigType.SYSTEM.value,
|
||||
"name": "分片上传分片大小",
|
||||
"code": "FILE_CHUNK_SIZE",
|
||||
"value": "2097152",
|
||||
"desc": "分片上传时每个分片的大小(字节)",
|
||||
"sort": 105,
|
||||
},
|
||||
]
|
||||
|
||||
ROLE_MENU_BINDINGS = {
|
||||
"ADMIN": [item["id"] for item in DEFAULT_MENUS],
|
||||
"COMMON": [
|
||||
SYSTEM_MENU_ID,
|
||||
USER_MENU_ID,
|
||||
ROLE_MENU_ID,
|
||||
DEPT_MENU_ID,
|
||||
MENU_MENU_ID,
|
||||
CONFIG_MENU_ID,
|
||||
DICT_MENU_ID,
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def seed_system_data(
|
||||
module_registry: ModuleRegistry | None = None,
|
||||
) -> dict[str, dict[str, int]]:
|
||||
counters = {
|
||||
"roles": _seed_roles(),
|
||||
"menus": _seed_menus(),
|
||||
"module_menus": _seed_module_menus(module_registry),
|
||||
"dicts": _seed_dicts(),
|
||||
"configs": _seed_configs(),
|
||||
"users": _seed_users(),
|
||||
"user_roles": _seed_user_roles(),
|
||||
"role_menus": _seed_role_menus(),
|
||||
"module_role_menus": _seed_module_role_menus(module_registry),
|
||||
}
|
||||
db.session.commit()
|
||||
return {name: counter.as_dict() for name, counter in counters.items()}
|
||||
|
||||
|
||||
def _seed_roles() -> SeedCounter:
|
||||
counter = SeedCounter()
|
||||
for item in DEFAULT_ROLES:
|
||||
role = db.session.scalar(select(Role).filter_by(code=item["code"]))
|
||||
payload = dict(item)
|
||||
payload.setdefault("status", StatusEnum.ENABLED.value)
|
||||
if role is None:
|
||||
db.session.add(Role(**_audit_safe(payload)))
|
||||
counter.created += 1
|
||||
continue
|
||||
changed = _assign_if_changed(role, payload)
|
||||
counter.updated += 1 if changed else 0
|
||||
counter.skipped += 0 if changed else 1
|
||||
return counter
|
||||
|
||||
|
||||
def _seed_menus() -> SeedCounter:
|
||||
counter = SeedCounter()
|
||||
for item in DEFAULT_MENUS:
|
||||
_seed_menu_payload(counter, item)
|
||||
return counter
|
||||
|
||||
|
||||
def _seed_module_menus(module_registry: ModuleRegistry | None) -> SeedCounter:
|
||||
counter = SeedCounter()
|
||||
for menu_seed in _module_menu_seeds(module_registry):
|
||||
_seed_menu_payload(counter, menu_seed.as_menu_payload())
|
||||
return counter
|
||||
|
||||
|
||||
def _seed_menu_payload(counter: SeedCounter, item: dict[str, Any]) -> None:
|
||||
menu = db.session.scalar(select(SysMenu).filter_by(id=item["id"]))
|
||||
payload = dict(item)
|
||||
payload.setdefault("status", StatusEnum.ENABLED.value)
|
||||
if menu is None:
|
||||
db.session.add(SysMenu(**payload))
|
||||
counter.created += 1
|
||||
return
|
||||
changed = _assign_if_changed(menu, payload)
|
||||
counter.updated += 1 if changed else 0
|
||||
counter.skipped += 0 if changed else 1
|
||||
|
||||
|
||||
def _seed_dicts() -> SeedCounter:
|
||||
counter = SeedCounter()
|
||||
for item in DEFAULT_DICT_TYPES:
|
||||
data_items = item["data"]
|
||||
if not isinstance(data_items, Iterable):
|
||||
data_items = []
|
||||
dict_type = db.session.scalar(
|
||||
select(SysDictType).filter_by(type_code=item["type_code"])
|
||||
)
|
||||
payload = {key: value for key, value in item.items() if key != "data"}
|
||||
payload.setdefault("status", StatusEnum.ENABLED.value)
|
||||
if dict_type is None:
|
||||
db.session.add(SysDictType(**_audit_safe(payload)))
|
||||
counter.created += 1
|
||||
else:
|
||||
changed = _assign_if_changed(dict_type, payload)
|
||||
counter.updated += 1 if changed else 0
|
||||
counter.skipped += 0 if changed else 1
|
||||
|
||||
for data in data_items:
|
||||
dict_data = db.session.scalar(
|
||||
select(SysDictData).filter_by(
|
||||
type_code=item["type_code"], code=data["code"]
|
||||
)
|
||||
)
|
||||
data_payload = dict(data)
|
||||
data_payload["type_code"] = item["type_code"]
|
||||
data_payload.setdefault("status", StatusEnum.ENABLED.value)
|
||||
if dict_data is None:
|
||||
db.session.add(SysDictData(**_audit_safe(data_payload)))
|
||||
counter.created += 1
|
||||
else:
|
||||
changed = _assign_if_changed(dict_data, data_payload)
|
||||
counter.updated += 1 if changed else 0
|
||||
counter.skipped += 0 if changed else 1
|
||||
return counter
|
||||
|
||||
|
||||
def _seed_configs() -> SeedCounter:
|
||||
counter = SeedCounter()
|
||||
for item in DEFAULT_CONFIGS:
|
||||
config = db.session.scalar(
|
||||
select(SysConfig).filter_by(type=item["type"], code=item["code"])
|
||||
)
|
||||
payload = dict(item)
|
||||
payload.setdefault("status", StatusEnum.ENABLED.value)
|
||||
if config is None:
|
||||
db.session.add(SysConfig(**_audit_safe(payload)))
|
||||
counter.created += 1
|
||||
continue
|
||||
changed = _assign_if_changed(config, payload)
|
||||
counter.updated += 1 if changed else 0
|
||||
counter.skipped += 0 if changed else 1
|
||||
return counter
|
||||
|
||||
|
||||
def _seed_users() -> SeedCounter:
|
||||
counter = SeedCounter()
|
||||
for item in DEFAULT_USERS:
|
||||
user = db.session.scalar(select(User).filter_by(username=item["username"]))
|
||||
payload = {key: value for key, value in item.items() if key != "role_codes"}
|
||||
if user is None:
|
||||
user = User(status=StatusEnum.ENABLED.value, **_audit_safe(payload))
|
||||
db.session.add(user)
|
||||
counter.created += 1
|
||||
continue
|
||||
counter.skipped += 1
|
||||
return counter
|
||||
|
||||
|
||||
def _seed_user_roles() -> SeedCounter:
|
||||
counter = SeedCounter()
|
||||
for item in DEFAULT_USERS:
|
||||
user = db.session.scalar(select(User).filter_by(username=item["username"]))
|
||||
if user is None:
|
||||
counter.skipped += len(item["role_codes"])
|
||||
continue
|
||||
existing_codes = {role.code for role in user.roles}
|
||||
for role_code in item["role_codes"]:
|
||||
if role_code in existing_codes:
|
||||
counter.skipped += 1
|
||||
continue
|
||||
role = db.session.scalar(select(Role).filter_by(code=role_code))
|
||||
if role is None:
|
||||
counter.skipped += 1
|
||||
continue
|
||||
user.roles.append(role)
|
||||
counter.created += 1
|
||||
return counter
|
||||
|
||||
|
||||
def _seed_role_menus() -> SeedCounter:
|
||||
counter = SeedCounter()
|
||||
for role_code, menu_ids in ROLE_MENU_BINDINGS.items():
|
||||
role = db.session.scalar(select(Role).filter_by(code=role_code))
|
||||
if role is None:
|
||||
counter.skipped += len(menu_ids)
|
||||
continue
|
||||
existing_ids = {menu.id for menu in role.menus}
|
||||
for menu_id in menu_ids:
|
||||
if menu_id in existing_ids:
|
||||
counter.skipped += 1
|
||||
continue
|
||||
menu = db.session.scalar(select(SysMenu).filter_by(id=menu_id))
|
||||
if menu is None:
|
||||
counter.skipped += 1
|
||||
continue
|
||||
role.menus.append(menu)
|
||||
counter.created += 1
|
||||
return counter
|
||||
|
||||
|
||||
def _seed_module_role_menus(module_registry: ModuleRegistry | None) -> SeedCounter:
|
||||
counter = SeedCounter()
|
||||
for menu_seed in _module_menu_seeds(module_registry):
|
||||
for role_code in menu_seed.admin_roles:
|
||||
role = db.session.scalar(select(Role).filter_by(code=role_code))
|
||||
menu = db.session.scalar(select(SysMenu).filter_by(id=menu_seed.id))
|
||||
if role is None or menu is None:
|
||||
counter.skipped += 1
|
||||
continue
|
||||
if menu.id in {item.id for item in role.menus}:
|
||||
counter.skipped += 1
|
||||
continue
|
||||
role.menus.append(menu)
|
||||
counter.created += 1
|
||||
return counter
|
||||
|
||||
|
||||
def _module_menu_seeds(
|
||||
module_registry: ModuleRegistry | None,
|
||||
) -> list[ModuleMenuSeed]:
|
||||
if module_registry is None:
|
||||
return []
|
||||
return module_registry.list_menu_seeds()
|
||||
|
||||
|
||||
def _assign_if_changed(model: Any, values: dict) -> bool:
|
||||
changed = False
|
||||
for key, value in values.items():
|
||||
if getattr(model, key) != value:
|
||||
setattr(model, key, value)
|
||||
changed = True
|
||||
return changed
|
||||
|
||||
|
||||
def _audit_safe(values: dict) -> dict:
|
||||
payload = dict(values)
|
||||
payload.setdefault("created_by", None)
|
||||
payload.setdefault("updated_by", None)
|
||||
return payload
|
||||
@ -0,0 +1,22 @@
|
||||
from .client import ServiceClient
|
||||
from .config import RetryConfig, ServiceConfig, TimeoutConfig
|
||||
from .errors import (
|
||||
ServiceClientError,
|
||||
ServiceConfigError,
|
||||
ServiceHTTPError,
|
||||
ServiceUnavailableError,
|
||||
)
|
||||
from .registry import init_service_clients, service_client
|
||||
|
||||
__all__ = [
|
||||
"RetryConfig",
|
||||
"ServiceClient",
|
||||
"ServiceClientError",
|
||||
"ServiceConfig",
|
||||
"ServiceConfigError",
|
||||
"ServiceHTTPError",
|
||||
"ServiceUnavailableError",
|
||||
"TimeoutConfig",
|
||||
"init_service_clients",
|
||||
"service_client",
|
||||
]
|
||||
@ -0,0 +1,203 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from flask import current_app, g, has_app_context, has_request_context, request
|
||||
|
||||
from .config import ServiceConfig
|
||||
from .errors import ServiceHTTPError, ServiceUnavailableError
|
||||
|
||||
|
||||
class ServiceClient:
|
||||
"""Synchronous HTTP JSON service client."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: ServiceConfig,
|
||||
*,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
) -> None:
|
||||
self.config = config
|
||||
timeout = httpx.Timeout(
|
||||
connect=config.timeout.connect,
|
||||
read=config.timeout.read,
|
||||
write=config.timeout.write,
|
||||
pool=config.timeout.pool,
|
||||
)
|
||||
self._client = httpx.Client(
|
||||
base_url=config.base_url,
|
||||
timeout=timeout,
|
||||
transport=transport,
|
||||
)
|
||||
self._fail_count = 0
|
||||
self._opened_at: float | None = None
|
||||
|
||||
def get(self, endpoint: str, **kwargs: Any) -> Any:
|
||||
return self.request("GET", endpoint, **kwargs)
|
||||
|
||||
def post(self, endpoint: str, **kwargs: Any) -> Any:
|
||||
return self.request("POST", endpoint, **kwargs)
|
||||
|
||||
def put(self, endpoint: str, **kwargs: Any) -> Any:
|
||||
return self.request("PUT", endpoint, **kwargs)
|
||||
|
||||
def delete(self, endpoint: str, **kwargs: Any) -> Any:
|
||||
return self.request("DELETE", endpoint, **kwargs)
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
*,
|
||||
path: dict[str, Any] | None = None,
|
||||
path_params: dict[str, Any] | None = None,
|
||||
path_values: dict[str, Any] | None = None,
|
||||
path_: dict[str, Any] | None = None,
|
||||
path_args: dict[str, Any] | None = None,
|
||||
path_map: dict[str, Any] | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
json: Any = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
retry: bool | None = None,
|
||||
expect_json: bool = True,
|
||||
) -> Any:
|
||||
method = method.upper()
|
||||
values = path or path_params or path_values or path_ or path_args or path_map or {}
|
||||
url = endpoint.format(**values)
|
||||
self._raise_if_open()
|
||||
|
||||
attempts = self._attempts_for(method, retry)
|
||||
last_error: Exception | None = None
|
||||
|
||||
for attempt in range(1, attempts + 1):
|
||||
start = time.monotonic()
|
||||
try:
|
||||
response = self._client.request(
|
||||
method,
|
||||
url,
|
||||
params=params,
|
||||
json=json,
|
||||
headers=self._headers(headers),
|
||||
)
|
||||
elapsed_ms = int((time.monotonic() - start) * 1000)
|
||||
self._log_call(method, url, response.status_code, elapsed_ms, attempt)
|
||||
if response.status_code >= 400:
|
||||
if self._should_retry_status(method, response.status_code, attempt, attempts):
|
||||
time.sleep(self.config.retry.backoff * attempt)
|
||||
continue
|
||||
self._record_failure()
|
||||
raise ServiceHTTPError(
|
||||
self.config.name, response.status_code, response.text
|
||||
)
|
||||
self._record_success()
|
||||
if not expect_json:
|
||||
return response
|
||||
if not response.content:
|
||||
return None
|
||||
return response.json()
|
||||
except (httpx.TimeoutException, httpx.TransportError) as exc:
|
||||
last_error = exc
|
||||
elapsed_ms = int((time.monotonic() - start) * 1000)
|
||||
self._log_call(method, url, "transport_error", elapsed_ms, attempt)
|
||||
if attempt < attempts:
|
||||
time.sleep(self.config.retry.backoff * attempt)
|
||||
continue
|
||||
self._record_failure()
|
||||
raise ServiceUnavailableError(
|
||||
f"service {self.config.name} unavailable: {exc}"
|
||||
) from exc
|
||||
|
||||
raise ServiceUnavailableError(
|
||||
f"service {self.config.name} unavailable: {last_error}"
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
def _headers(self, headers: dict[str, str] | None) -> dict[str, str]:
|
||||
result = dict(headers or {})
|
||||
result.setdefault("Accept", "application/json")
|
||||
result.setdefault("Content-Type", "application/json")
|
||||
if self.config.token:
|
||||
result.setdefault("Authorization", f"Bearer {self.config.token}")
|
||||
trace_id = self._trace_id()
|
||||
result.setdefault("X-Trace-Id", trace_id)
|
||||
return result
|
||||
|
||||
def _trace_id(self) -> str:
|
||||
if has_request_context():
|
||||
header_trace = request.headers.get("X-Trace-Id")
|
||||
if header_trace:
|
||||
return header_trace
|
||||
if has_app_context():
|
||||
trace_id = getattr(g, "trace_id", None)
|
||||
if trace_id:
|
||||
return trace_id
|
||||
g.trace_id = uuid.uuid4().hex
|
||||
return g.trace_id
|
||||
return uuid.uuid4().hex
|
||||
|
||||
def _attempts_for(self, method: str, retry: bool | None) -> int:
|
||||
if retry is False:
|
||||
return 1
|
||||
if retry is True:
|
||||
return self.config.retry.attempts
|
||||
if method in self.config.retry.methods:
|
||||
return self.config.retry.attempts
|
||||
return 1
|
||||
|
||||
def _should_retry_status(
|
||||
self, method: str, status_code: int, attempt: int, attempts: int
|
||||
) -> bool:
|
||||
return (
|
||||
attempt < attempts
|
||||
and method in self.config.retry.methods
|
||||
and status_code in self.config.retry.statuses
|
||||
)
|
||||
|
||||
def _raise_if_open(self) -> None:
|
||||
breaker = self.config.circuit_breaker
|
||||
if not breaker.enabled or self._opened_at is None:
|
||||
return
|
||||
elapsed = time.monotonic() - self._opened_at
|
||||
if elapsed < breaker.reset_timeout:
|
||||
raise ServiceUnavailableError(
|
||||
f"service {self.config.name} circuit breaker is open"
|
||||
)
|
||||
self._opened_at = None
|
||||
self._fail_count = 0
|
||||
|
||||
def _record_success(self) -> None:
|
||||
self._fail_count = 0
|
||||
self._opened_at = None
|
||||
|
||||
def _record_failure(self) -> None:
|
||||
breaker = self.config.circuit_breaker
|
||||
if not breaker.enabled:
|
||||
return
|
||||
self._fail_count += 1
|
||||
if self._fail_count >= breaker.fail_max:
|
||||
self._opened_at = time.monotonic()
|
||||
|
||||
def _log_call(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
status: int | str,
|
||||
elapsed_ms: int,
|
||||
attempt: int,
|
||||
) -> None:
|
||||
if not has_app_context():
|
||||
return
|
||||
current_app.logger.info(
|
||||
"service_call service=%s method=%s path=%s status=%s elapsed_ms=%s attempt=%s",
|
||||
self.config.name,
|
||||
method,
|
||||
url,
|
||||
status,
|
||||
elapsed_ms,
|
||||
attempt,
|
||||
)
|
||||
@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TimeoutConfig:
|
||||
connect: float = 1.0
|
||||
read: float = 5.0
|
||||
write: float = 5.0
|
||||
pool: float = 1.0
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: dict[str, Any] | None) -> "TimeoutConfig":
|
||||
value = value or {}
|
||||
return cls(
|
||||
connect=float(value.get("connect", cls.connect)),
|
||||
read=float(value.get("read", cls.read)),
|
||||
write=float(value.get("write", cls.write)),
|
||||
pool=float(value.get("pool", cls.pool)),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RetryConfig:
|
||||
attempts: int = 1
|
||||
backoff: float = 0.2
|
||||
statuses: tuple[int, ...] = field(default_factory=lambda: (502, 503, 504))
|
||||
methods: tuple[str, ...] = field(default_factory=lambda: ("GET", "HEAD", "OPTIONS"))
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: dict[str, Any] | None) -> "RetryConfig":
|
||||
value = value or {}
|
||||
return cls(
|
||||
attempts=max(int(value.get("attempts", cls.attempts)), 1),
|
||||
backoff=float(value.get("backoff", cls.backoff)),
|
||||
statuses=tuple(int(item) for item in value.get("statuses", cls().statuses)),
|
||||
methods=tuple(str(item).upper() for item in value.get("methods", cls().methods)),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CircuitBreakerConfig:
|
||||
enabled: bool = False
|
||||
fail_max: int = 5
|
||||
reset_timeout: int = 30
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: dict[str, Any] | None) -> "CircuitBreakerConfig":
|
||||
value = value or {}
|
||||
return cls(
|
||||
enabled=bool(value.get("enabled", cls.enabled)),
|
||||
fail_max=max(int(value.get("fail_max", cls.fail_max)), 1),
|
||||
reset_timeout=max(int(value.get("reset_timeout", cls.reset_timeout)), 1),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServiceConfig:
|
||||
name: str
|
||||
base_url: str
|
||||
token: str | None = None
|
||||
timeout: TimeoutConfig = field(default_factory=TimeoutConfig)
|
||||
retry: RetryConfig = field(default_factory=RetryConfig)
|
||||
circuit_breaker: CircuitBreakerConfig = field(default_factory=CircuitBreakerConfig)
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, name: str, value: dict[str, Any]) -> "ServiceConfig":
|
||||
return cls(
|
||||
name=name,
|
||||
base_url=str(value["base_url"]).rstrip("/"),
|
||||
token=value.get("token"),
|
||||
timeout=TimeoutConfig.from_mapping(value.get("timeout")),
|
||||
retry=RetryConfig.from_mapping(value.get("retry")),
|
||||
circuit_breaker=CircuitBreakerConfig.from_mapping(
|
||||
value.get("circuit_breaker")
|
||||
),
|
||||
)
|
||||
@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class ServiceClientError(RuntimeError):
|
||||
"""Base service-client error."""
|
||||
|
||||
|
||||
class ServiceConfigError(ServiceClientError):
|
||||
"""Raised when service client configuration is missing or invalid."""
|
||||
|
||||
|
||||
class ServiceUnavailableError(ServiceClientError):
|
||||
"""Raised when a service cannot be reached or is temporarily blocked."""
|
||||
|
||||
|
||||
class ServiceHTTPError(ServiceClientError):
|
||||
"""Raised for non-2xx HTTP responses."""
|
||||
|
||||
def __init__(self, service: str, status_code: int, body: str) -> None:
|
||||
self.service = service
|
||||
self.status_code = status_code
|
||||
self.body = body
|
||||
super().__init__(f"service {service} returned HTTP {status_code}")
|
||||
@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from flask import current_app, has_app_context
|
||||
|
||||
from .client import ServiceClient
|
||||
from .config import ServiceConfig
|
||||
from .errors import ServiceConfigError
|
||||
|
||||
|
||||
def init_service_clients(app) -> None:
|
||||
configs = app.config.get("SERVICES", {})
|
||||
clients: dict[str, ServiceClient] = {}
|
||||
for name, value in configs.items():
|
||||
if "base_url" not in value:
|
||||
raise ServiceConfigError(f"service {name} missing base_url")
|
||||
clients[name] = ServiceClient(ServiceConfig.from_mapping(name, value))
|
||||
app.extensions["iti_service_clients"] = clients
|
||||
|
||||
|
||||
def register_service_client(
|
||||
app,
|
||||
name: str,
|
||||
config: dict[str, Any],
|
||||
*,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
) -> ServiceClient:
|
||||
client = ServiceClient(ServiceConfig.from_mapping(name, config), transport=transport)
|
||||
clients = app.extensions.setdefault("iti_service_clients", {})
|
||||
clients[name] = client
|
||||
return client
|
||||
|
||||
|
||||
def service_client(name: str) -> ServiceClient:
|
||||
if not has_app_context():
|
||||
raise ServiceConfigError("service_client() requires an app context")
|
||||
clients = current_app.extensions.get("iti_service_clients", {})
|
||||
client = clients.get(name)
|
||||
if client is None:
|
||||
raise ServiceConfigError(f"service client not configured: {name}")
|
||||
return client
|
||||
@ -0,0 +1,10 @@
|
||||
from .registry import TaskDefinition, TaskRegistry, TaskRun, task_registry
|
||||
from .runner import init_task_runner
|
||||
|
||||
__all__ = [
|
||||
"TaskDefinition",
|
||||
"TaskRegistry",
|
||||
"TaskRun",
|
||||
"init_task_runner",
|
||||
"task_registry",
|
||||
]
|
||||
@ -0,0 +1,104 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskDefinition:
|
||||
name: str
|
||||
handler: Callable[[], Any]
|
||||
schedule: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskRun:
|
||||
id: str
|
||||
task_name: str
|
||||
status: str
|
||||
started_at: float
|
||||
finished_at: float | None = None
|
||||
result: Any = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskRegistry:
|
||||
tasks: dict[str, TaskDefinition] = field(default_factory=dict)
|
||||
runs: dict[str, TaskRun] = field(default_factory=dict)
|
||||
_running: set[str] = field(default_factory=set)
|
||||
_lock: threading.Lock = field(default_factory=threading.Lock)
|
||||
|
||||
def register(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
handler: Callable[[], Any],
|
||||
schedule: str | None = None,
|
||||
description: str | None = None,
|
||||
) -> TaskDefinition:
|
||||
if not name:
|
||||
raise ValueError("task name is required")
|
||||
if name in self.tasks:
|
||||
raise ValueError(f"task already registered: {name}")
|
||||
task = TaskDefinition(
|
||||
name=name,
|
||||
handler=handler,
|
||||
schedule=schedule,
|
||||
description=description,
|
||||
)
|
||||
self.tasks[name] = task
|
||||
return task
|
||||
|
||||
def trigger(self, name: str) -> TaskRun:
|
||||
task = self.tasks.get(name)
|
||||
if task is None:
|
||||
raise KeyError(f"task not registered: {name}")
|
||||
|
||||
with self._lock:
|
||||
if name in self._running:
|
||||
run = TaskRun(
|
||||
id=uuid.uuid4().hex,
|
||||
task_name=name,
|
||||
status="skipped",
|
||||
started_at=time.time(),
|
||||
finished_at=time.time(),
|
||||
error="task already running",
|
||||
)
|
||||
self.runs[run.id] = run
|
||||
return run
|
||||
self._running.add(name)
|
||||
run = TaskRun(
|
||||
id=uuid.uuid4().hex,
|
||||
task_name=name,
|
||||
status="running",
|
||||
started_at=time.time(),
|
||||
)
|
||||
self.runs[run.id] = run
|
||||
|
||||
try:
|
||||
run.result = task.handler()
|
||||
run.status = "success"
|
||||
except Exception:
|
||||
run.error = traceback.format_exc()
|
||||
run.status = "failed"
|
||||
finally:
|
||||
run.finished_at = time.time()
|
||||
with self._lock:
|
||||
self._running.discard(name)
|
||||
return run
|
||||
|
||||
def list_runs(self, task_name: str | None = None) -> list[TaskRun]:
|
||||
runs = list(self.runs.values())
|
||||
if task_name is not None:
|
||||
runs = [run for run in runs if run.task_name == task_name]
|
||||
return sorted(runs, key=lambda run: run.started_at, reverse=True)
|
||||
|
||||
|
||||
task_registry = TaskRegistry()
|
||||
@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import atexit
|
||||
|
||||
from .registry import TaskRegistry, task_registry
|
||||
|
||||
|
||||
class TaskRunner:
|
||||
"""Single-process task scheduler."""
|
||||
|
||||
def __init__(self, registry: TaskRegistry, *, tick_seconds: int = 1) -> None:
|
||||
self.registry = registry
|
||||
self.tick_seconds = tick_seconds
|
||||
self._last_run: dict[str, float] = {}
|
||||
self._stop = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
self._thread = threading.Thread(target=self._loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
if self._thread:
|
||||
self._thread.join(timeout=3)
|
||||
|
||||
def _loop(self) -> None:
|
||||
while not self._stop.is_set():
|
||||
now = time.time()
|
||||
for task in list(self.registry.tasks.values()):
|
||||
if self._due(task.schedule, task.name, now):
|
||||
self._last_run[task.name] = now
|
||||
threading.Thread(
|
||||
target=self.registry.trigger,
|
||||
args=(task.name,),
|
||||
daemon=True,
|
||||
).start()
|
||||
self._stop.wait(self.tick_seconds)
|
||||
|
||||
def _due(self, schedule: str | None, name: str, now: float) -> bool:
|
||||
if not schedule:
|
||||
return False
|
||||
interval = _parse_interval(schedule)
|
||||
if interval is None:
|
||||
interval = _parse_simple_cron(schedule)
|
||||
if interval is None:
|
||||
return False
|
||||
last = self._last_run.get(name)
|
||||
return last is None or now - last >= interval
|
||||
|
||||
|
||||
def _parse_interval(schedule: str) -> int | None:
|
||||
match = re.fullmatch(r"interval:(\d+)", schedule.strip())
|
||||
if not match:
|
||||
return None
|
||||
return max(int(match.group(1)), 1)
|
||||
|
||||
|
||||
def _parse_simple_cron(schedule: str) -> int | None:
|
||||
value = schedule.strip()
|
||||
if value.startswith("cron:"):
|
||||
value = value[5:].strip()
|
||||
parts = value.split()
|
||||
if len(parts) != 5:
|
||||
return None
|
||||
minute = parts[0]
|
||||
if minute == "*":
|
||||
return 60
|
||||
match = re.fullmatch(r"\*/(\d+)", minute)
|
||||
if match:
|
||||
return max(int(match.group(1)), 1) * 60
|
||||
return None
|
||||
|
||||
|
||||
def init_task_runner(app, registry: TaskRegistry | None = None) -> TaskRunner:
|
||||
runner = TaskRunner(registry or task_registry)
|
||||
app.extensions["iti_task_registry"] = registry or task_registry
|
||||
app.extensions["iti_task_runner"] = runner
|
||||
if app.config.get("TASKS_ENABLED", False):
|
||||
app.logger.info("starting single-process task runner")
|
||||
runner.start()
|
||||
atexit.register(runner.stop)
|
||||
|
||||
return runner
|
||||
@ -0,0 +1,359 @@
|
||||
"""framework initial schema
|
||||
|
||||
Revision ID: 7de264f96a03
|
||||
Revises:
|
||||
Create Date: 2026-05-08 04:33:59.055459
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '7de264f96a03'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('sys_config',
|
||||
sa.Column('type', sa.String(length=64), nullable=False, comment='配置类型'),
|
||||
sa.Column('name', sa.String(length=255), nullable=False, comment='配置名称'),
|
||||
sa.Column('code', sa.String(length=128), nullable=False, comment='配置编码'),
|
||||
sa.Column('value', sa.Text(), nullable=True, comment='配置值'),
|
||||
sa.Column('desc', sa.Text(), nullable=True, comment='配置描述'),
|
||||
sa.Column('sort', sa.Integer(), nullable=False, comment='排序'),
|
||||
sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'),
|
||||
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
|
||||
sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
|
||||
sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'),
|
||||
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_config')),
|
||||
mysql_charset='utf8mb4',
|
||||
mysql_collate='utf8mb4_general_ci'
|
||||
)
|
||||
with op.batch_alter_table('sys_config', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_sys_config_created_by'), ['created_by'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_sys_config_updated_by'), ['updated_by'], unique=False)
|
||||
|
||||
op.create_table('sys_dept',
|
||||
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
|
||||
sa.Column('name', sa.String(length=255), nullable=False, comment='部门名称'),
|
||||
sa.Column('parent_id', sa.String(length=36), nullable=True, comment='父部门ID'),
|
||||
sa.Column('desc', sa.Text(), nullable=True, comment='部门描述'),
|
||||
sa.Column('sort', sa.Integer(), nullable=False, comment='排序'),
|
||||
sa.Column('leader_id', sa.String(length=36), nullable=True, comment='负责人ID'),
|
||||
sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
|
||||
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_dept')),
|
||||
mysql_charset='utf8mb4',
|
||||
mysql_collate='utf8mb4_general_ci'
|
||||
)
|
||||
op.create_table('sys_dict_data',
|
||||
sa.Column('type_code', sa.String(length=36), nullable=False, comment='类型编码'),
|
||||
sa.Column('label', sa.String(length=255), nullable=False, comment='数据标签'),
|
||||
sa.Column('code', sa.String(length=128), nullable=False, comment='数据编码'),
|
||||
sa.Column('value', sa.Text(), nullable=True, comment='数据值'),
|
||||
sa.Column('desc', sa.Text(), nullable=True, comment='数据描述'),
|
||||
sa.Column('sort', sa.Integer(), nullable=False, comment='排序'),
|
||||
sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'),
|
||||
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
|
||||
sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
|
||||
sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'),
|
||||
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_dict_data')),
|
||||
mysql_charset='utf8mb4',
|
||||
mysql_collate='utf8mb4_general_ci'
|
||||
)
|
||||
with op.batch_alter_table('sys_dict_data', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_sys_dict_data_created_by'), ['created_by'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_sys_dict_data_updated_by'), ['updated_by'], unique=False)
|
||||
|
||||
op.create_table('sys_dict_type',
|
||||
sa.Column('type_name', sa.String(length=255), nullable=False, comment='类型名称'),
|
||||
sa.Column('type_code', sa.String(length=128), nullable=False, comment='类型编码'),
|
||||
sa.Column('desc', sa.Text(), nullable=True, comment='类型描述'),
|
||||
sa.Column('sort', sa.Integer(), nullable=False, comment='排序'),
|
||||
sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'),
|
||||
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
|
||||
sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
|
||||
sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'),
|
||||
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_dict_type')),
|
||||
sa.UniqueConstraint('type_code', name=op.f('uq_sys_dict_type_type_code')),
|
||||
mysql_charset='utf8mb4',
|
||||
mysql_collate='utf8mb4_general_ci'
|
||||
)
|
||||
with op.batch_alter_table('sys_dict_type', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_sys_dict_type_created_by'), ['created_by'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_sys_dict_type_updated_by'), ['updated_by'], unique=False)
|
||||
|
||||
op.create_table('sys_file',
|
||||
sa.Column('filename', sa.String(length=255), nullable=False, comment='原始文件名'),
|
||||
sa.Column('file_key', sa.String(length=512), nullable=False, comment='存储路径'),
|
||||
sa.Column('file_hash', sa.String(length=64), nullable=True, comment='文件哈希'),
|
||||
sa.Column('mime_type', sa.String(length=128), nullable=True, comment='MIME类型'),
|
||||
sa.Column('file_size', sa.BigInteger(), nullable=False, comment='文件大小(字节)'),
|
||||
sa.Column('extension', sa.String(length=32), nullable=True, comment='文件扩展名'),
|
||||
sa.Column('storage_type', sa.Enum('local', 'aliyun_oss', 'tencent_cos', 'qiniu_kodo', 'huawei_obs', 'aws_s3', 'minio', name='storagetypeenum'), nullable=False, comment='存储类型'),
|
||||
sa.Column('storage_info', sa.JSON(), nullable=True, comment='存储信息(bucket/region/endpoint/meta等)'),
|
||||
sa.Column('directory_id', sa.String(length=36), nullable=True, comment='所属目录ID'),
|
||||
sa.Column('metadata', sa.JSON(), nullable=True, comment='扩展元数据'),
|
||||
sa.Column('is_deleted', sa.Boolean(), nullable=False, comment='是否已删除(回收站)'),
|
||||
sa.Column('deleted_at', sa.DateTime(), nullable=True, comment='删除时间'),
|
||||
sa.Column('deleted_by', sa.String(length=36), nullable=True, comment='删除人ID'),
|
||||
sa.Column('share_code', sa.String(length=64), nullable=True, comment='分享码'),
|
||||
sa.Column('share_password', sa.String(length=64), nullable=True, comment='分享密码'),
|
||||
sa.Column('share_expire_at', sa.DateTime(), nullable=True, comment='分享过期时间'),
|
||||
sa.Column('share_count', sa.Integer(), nullable=False, comment='分享访问次数'),
|
||||
sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'),
|
||||
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
|
||||
sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
|
||||
sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'),
|
||||
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_file')),
|
||||
mysql_charset='utf8mb4',
|
||||
mysql_collate='utf8mb4_general_ci'
|
||||
)
|
||||
with op.batch_alter_table('sys_file', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_sys_file_created_by'), ['created_by'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_sys_file_directory_id'), ['directory_id'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_sys_file_file_hash'), ['file_hash'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_sys_file_file_key'), ['file_key'], unique=True)
|
||||
batch_op.create_index(batch_op.f('ix_sys_file_share_code'), ['share_code'], unique=True)
|
||||
batch_op.create_index(batch_op.f('ix_sys_file_updated_by'), ['updated_by'], unique=False)
|
||||
|
||||
op.create_table('sys_file_directory',
|
||||
sa.Column('name', sa.String(length=255), nullable=False, comment='目录名称'),
|
||||
sa.Column('path', sa.String(length=1024), nullable=False, comment='完整路径'),
|
||||
sa.Column('parent_id', sa.String(length=36), nullable=True, comment='父目录ID'),
|
||||
sa.Column('level', sa.Integer(), nullable=True, comment='层级'),
|
||||
sa.Column('sort', sa.Integer(), nullable=True, comment='排序'),
|
||||
sa.Column('icon', sa.String(length=128), nullable=True, comment='目录图标'),
|
||||
sa.Column('color', sa.String(length=32), nullable=True, comment='颜色标记'),
|
||||
sa.Column('description', sa.Text(), nullable=True, comment='目录描述'),
|
||||
sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'),
|
||||
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
|
||||
sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
|
||||
sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'),
|
||||
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_file_directory'))
|
||||
)
|
||||
with op.batch_alter_table('sys_file_directory', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_sys_file_directory_created_by'), ['created_by'], unique=False)
|
||||
batch_op.create_index('ix_sys_file_directory_path', ['path'], unique=False, mysql_length=255)
|
||||
batch_op.create_index(batch_op.f('ix_sys_file_directory_updated_by'), ['updated_by'], unique=False)
|
||||
|
||||
op.create_table('sys_log',
|
||||
sa.Column('name', sa.String(length=100), nullable=True, comment='操作名称'),
|
||||
sa.Column('method', sa.String(length=10), nullable=True, comment='请求方法'),
|
||||
sa.Column('user_id', sa.String(length=36), nullable=True, comment='用户ID'),
|
||||
sa.Column('path', sa.String(length=255), nullable=True, comment='请求路径'),
|
||||
sa.Column('ip', sa.String(length=255), nullable=True, comment='IP地址'),
|
||||
sa.Column('user_agent', sa.Text(), nullable=True, comment='用户代理'),
|
||||
sa.Column('headers', sa.Text(), nullable=True, comment='请求头'),
|
||||
sa.Column('query_params', sa.Text(), nullable=True, comment='请求参数'),
|
||||
sa.Column('body_params', sa.Text(), nullable=True, comment='请求体参数'),
|
||||
sa.Column('execution_time', sa.Float(), nullable=True, comment='执行时间(毫秒)'),
|
||||
sa.Column('response', sa.Text(), nullable=True, comment='响应结果'),
|
||||
sa.Column('exception', sa.Text(), nullable=True, comment='异常信息'),
|
||||
sa.Column('success', sa.Boolean(), nullable=True, comment='是否成功'),
|
||||
sa.Column('desc', sa.Text(), nullable=True, comment='描述'),
|
||||
sa.Column('type', sa.Enum('SYSTEM', 'AUTH', 'OPERATION', 'AUDIT', 'SECURITY', 'JOB', 'API', 'DB', 'PAYMENT', 'MESSAGE', 'OSS', 'OTHER', name='logtype'), nullable=False, comment='日志类型'),
|
||||
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
|
||||
sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
|
||||
sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'),
|
||||
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_log')),
|
||||
mysql_charset='utf8mb4',
|
||||
mysql_collate='utf8mb4_general_ci'
|
||||
)
|
||||
with op.batch_alter_table('sys_log', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_sys_log_created_by'), ['created_by'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_sys_log_updated_by'), ['updated_by'], unique=False)
|
||||
|
||||
op.create_table('sys_menu',
|
||||
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
|
||||
sa.Column('name', sa.String(length=255), nullable=False, comment='菜单名称'),
|
||||
sa.Column('type', sa.Enum('catalog', 'menu', 'button', 'embedded', 'link', name='menutypeenum'), nullable=False, comment='菜单类型'),
|
||||
sa.Column('path', sa.String(length=255), nullable=True, comment='菜单路径'),
|
||||
sa.Column('component', sa.String(length=255), nullable=True, comment='菜单组件'),
|
||||
sa.Column('redirect', sa.String(length=255), nullable=True, comment='菜单重定向'),
|
||||
sa.Column('sort', sa.Integer(), nullable=False, comment='排序'),
|
||||
sa.Column('auth_code', sa.String(length=128), nullable=True, comment='权限编码'),
|
||||
sa.Column('meta', sa.JSON(), nullable=True, comment='菜单元数据'),
|
||||
sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'),
|
||||
sa.Column('parent_id', sa.String(length=36), nullable=True, comment='父菜单ID'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
|
||||
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_menu')),
|
||||
sa.UniqueConstraint('name', name=op.f('uq_sys_menu_name')),
|
||||
mysql_charset='utf8mb4',
|
||||
mysql_collate='utf8mb4_general_ci'
|
||||
)
|
||||
op.create_table('sys_role',
|
||||
sa.Column('name', sa.String(length=64), nullable=False, comment='名称'),
|
||||
sa.Column('code', sa.String(length=64), nullable=False, comment='编码'),
|
||||
sa.Column('desc', sa.Text(), nullable=True, comment='描述'),
|
||||
sa.Column('sort', sa.Integer(), nullable=False, comment='排序'),
|
||||
sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'),
|
||||
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
|
||||
sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
|
||||
sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'),
|
||||
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_role')),
|
||||
sa.UniqueConstraint('code', name=op.f('uq_sys_role_code')),
|
||||
mysql_charset='utf8mb4',
|
||||
mysql_collate='utf8mb4_general_ci'
|
||||
)
|
||||
with op.batch_alter_table('sys_role', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_sys_role_created_by'), ['created_by'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_sys_role_updated_by'), ['updated_by'], unique=False)
|
||||
|
||||
op.create_table('sys_role_menu',
|
||||
sa.Column('role_id', sa.String(length=36), nullable=False, comment='角色ID'),
|
||||
sa.Column('menu_id', sa.String(length=36), nullable=False, comment='菜单ID'),
|
||||
sa.PrimaryKeyConstraint('role_id', 'menu_id', name=op.f('pk_sys_role_menu'))
|
||||
)
|
||||
op.create_table('sys_user',
|
||||
sa.Column('username', sa.String(length=64), nullable=False, comment='用户名'),
|
||||
sa.Column('phone', sa.String(length=13), nullable=True, comment='手机号'),
|
||||
sa.Column('email', sa.String(length=255), nullable=True, comment='邮箱'),
|
||||
sa.Column('password', sa.String(length=255), nullable=False, comment='密码'),
|
||||
sa.Column('realname', sa.String(length=32), nullable=True, comment='真实姓名'),
|
||||
sa.Column('desc', sa.Text(), nullable=True, comment='描述'),
|
||||
sa.Column('avatar', sa.String(length=255), nullable=True, comment='头像'),
|
||||
sa.Column('gender', sa.Enum('male', 'female', 'secure', name='genderenum'), nullable=False, comment='性别'),
|
||||
sa.Column('status', sa.Enum('enabled', 'disabled', name='statusenum'), nullable=False, comment='状态'),
|
||||
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
|
||||
sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
|
||||
sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'),
|
||||
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_user')),
|
||||
mysql_charset='utf8mb4',
|
||||
mysql_collate='utf8mb4_general_ci'
|
||||
)
|
||||
with op.batch_alter_table('sys_user', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('ix_sys_user_created_by'), ['created_by'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_sys_user_updated_by'), ['updated_by'], unique=False)
|
||||
|
||||
op.create_table('sys_user_dept',
|
||||
sa.Column('user_id', sa.String(length=36), nullable=False, comment='用户ID'),
|
||||
sa.Column('dept_id', sa.String(length=36), nullable=False, comment='部门ID'),
|
||||
sa.PrimaryKeyConstraint('user_id', 'dept_id', name=op.f('pk_sys_user_dept'))
|
||||
)
|
||||
op.create_table('sys_user_role',
|
||||
sa.Column('user_id', sa.String(length=36), nullable=False, comment='用户ID'),
|
||||
sa.Column('role_id', sa.String(length=36), nullable=False, comment='角色ID'),
|
||||
sa.PrimaryKeyConstraint('user_id', 'role_id', name=op.f('pk_sys_user_role'))
|
||||
)
|
||||
op.create_table('sys_user_attribute',
|
||||
sa.Column('user_id', sa.String(length=36), nullable=False, comment='用户ID'),
|
||||
sa.Column('attr_group', sa.String(length=64), nullable=False, comment='属性分组(如: erp, custom)'),
|
||||
sa.Column('attr_key', sa.String(length=128), nullable=False, comment='属性键'),
|
||||
sa.Column('attr_value', sa.Text(), nullable=True, comment='属性值'),
|
||||
sa.Column('attr_type', sa.String(length=32), nullable=False, comment='值类型(string/int/float/bool/json/encrypted)'),
|
||||
sa.Column('description', sa.String(length=255), nullable=True, comment='属性描述'),
|
||||
sa.Column('sort', sa.Integer(), nullable=False, comment='排序'),
|
||||
sa.Column('id', sa.String(length=36), nullable=False, comment='标识'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, comment='创建时间'),
|
||||
sa.Column('created_by', sa.String(length=36), nullable=True, comment='创建人'),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, comment='更新时间'),
|
||||
sa.Column('updated_by', sa.String(length=36), nullable=True, comment='更新人'),
|
||||
sa.Column('remark', sa.String(length=255), nullable=True, comment='备注'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['sys_user.id'], name=op.f('fk_sys_user_attribute_user_id_sys_user'), ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('pk_sys_user_attribute')),
|
||||
sa.UniqueConstraint('user_id', 'attr_group', 'attr_key', name='uk_user_group_key')
|
||||
)
|
||||
with op.batch_alter_table('sys_user_attribute', schema=None) as batch_op:
|
||||
batch_op.create_index('idx_user_group_key', ['user_id', 'attr_group', 'attr_key'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_sys_user_attribute_attr_group'), ['attr_group'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_sys_user_attribute_created_by'), ['created_by'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_sys_user_attribute_updated_by'), ['updated_by'], unique=False)
|
||||
batch_op.create_index(batch_op.f('ix_sys_user_attribute_user_id'), ['user_id'], unique=False)
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('sys_user_attribute', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_sys_user_attribute_user_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_sys_user_attribute_updated_by'))
|
||||
batch_op.drop_index(batch_op.f('ix_sys_user_attribute_created_by'))
|
||||
batch_op.drop_index(batch_op.f('ix_sys_user_attribute_attr_group'))
|
||||
batch_op.drop_index('idx_user_group_key')
|
||||
|
||||
op.drop_table('sys_user_attribute')
|
||||
op.drop_table('sys_user_role')
|
||||
op.drop_table('sys_user_dept')
|
||||
with op.batch_alter_table('sys_user', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_sys_user_updated_by'))
|
||||
batch_op.drop_index(batch_op.f('ix_sys_user_created_by'))
|
||||
|
||||
op.drop_table('sys_user')
|
||||
op.drop_table('sys_role_menu')
|
||||
with op.batch_alter_table('sys_role', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_sys_role_updated_by'))
|
||||
batch_op.drop_index(batch_op.f('ix_sys_role_created_by'))
|
||||
|
||||
op.drop_table('sys_role')
|
||||
op.drop_table('sys_menu')
|
||||
with op.batch_alter_table('sys_log', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_sys_log_updated_by'))
|
||||
batch_op.drop_index(batch_op.f('ix_sys_log_created_by'))
|
||||
|
||||
op.drop_table('sys_log')
|
||||
with op.batch_alter_table('sys_file_directory', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_sys_file_directory_updated_by'))
|
||||
batch_op.drop_index('ix_sys_file_directory_path', mysql_length=255)
|
||||
batch_op.drop_index(batch_op.f('ix_sys_file_directory_created_by'))
|
||||
|
||||
op.drop_table('sys_file_directory')
|
||||
with op.batch_alter_table('sys_file', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_sys_file_updated_by'))
|
||||
batch_op.drop_index(batch_op.f('ix_sys_file_share_code'))
|
||||
batch_op.drop_index(batch_op.f('ix_sys_file_file_key'))
|
||||
batch_op.drop_index(batch_op.f('ix_sys_file_file_hash'))
|
||||
batch_op.drop_index(batch_op.f('ix_sys_file_directory_id'))
|
||||
batch_op.drop_index(batch_op.f('ix_sys_file_created_by'))
|
||||
|
||||
op.drop_table('sys_file')
|
||||
with op.batch_alter_table('sys_dict_type', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_sys_dict_type_updated_by'))
|
||||
batch_op.drop_index(batch_op.f('ix_sys_dict_type_created_by'))
|
||||
|
||||
op.drop_table('sys_dict_type')
|
||||
with op.batch_alter_table('sys_dict_data', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_sys_dict_data_updated_by'))
|
||||
batch_op.drop_index(batch_op.f('ix_sys_dict_data_created_by'))
|
||||
|
||||
op.drop_table('sys_dict_data')
|
||||
op.drop_table('sys_dept')
|
||||
with op.batch_alter_table('sys_config', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('ix_sys_config_updated_by'))
|
||||
batch_op.drop_index(batch_op.f('ix_sys_config_created_by'))
|
||||
|
||||
op.drop_table('sys_config')
|
||||
# ### end Alembic commands ###
|
||||
@ -0,0 +1,19 @@
|
||||
from click.testing import CliRunner
|
||||
|
||||
from iti.cli import iti_cli
|
||||
|
||||
|
||||
def test_sync_framework_migrations_is_idempotent(tmp_path):
|
||||
target = tmp_path / "migrations" / "versions"
|
||||
runner = CliRunner()
|
||||
migration_name = "20260508_0433_7de264f96a03_framework_initial_schema.py"
|
||||
|
||||
first = runner.invoke(iti_cli, ["migrations", "sync", "--target", str(target)])
|
||||
assert first.exit_code == 0
|
||||
assert f"synced: {migration_name}" in first.output
|
||||
assert (target / migration_name).exists()
|
||||
assert not (target / "__init__.py").exists()
|
||||
|
||||
second = runner.invoke(iti_cli, ["migrations", "sync", "--target", str(target)])
|
||||
assert second.exit_code == 0
|
||||
assert f"skipped: {migration_name}" in second.output
|
||||
@ -0,0 +1,104 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from iti.applications import create_app
|
||||
from iti.applications.common.enums import MenuTypeEnum
|
||||
from iti.applications.extensions import db
|
||||
from iti.applications.models import Role, SysMenu
|
||||
from iti.modules import ModuleMenuSeed, ModulePermission, get_module_registry
|
||||
from iti.modules.registry import ModuleRegistry
|
||||
from iti.seeds.system import seed_system_data
|
||||
|
||||
|
||||
class RecordingModule:
|
||||
name = "recording"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[str] = []
|
||||
|
||||
def init_app(self, app) -> None:
|
||||
self.calls.append("init_app")
|
||||
|
||||
def register_commands(self, app) -> None:
|
||||
self.calls.append("register_commands")
|
||||
|
||||
def register_routes(self, app) -> None:
|
||||
self.calls.append("register_routes")
|
||||
|
||||
def register_permissions(self, app) -> None:
|
||||
self.calls.append("register_permissions")
|
||||
get_module_registry(app).register_permission(
|
||||
ModulePermission(code="recording:list", name="录制列表")
|
||||
)
|
||||
|
||||
def register_menu_seed(self, app) -> None:
|
||||
self.calls.append("register_menu_seed")
|
||||
get_module_registry(app).register_menu_seed(
|
||||
ModuleMenuSeed(
|
||||
id="recording-menu",
|
||||
name="Recording",
|
||||
type=MenuTypeEnum.MENU.value,
|
||||
path="/recording",
|
||||
component="/recording/list",
|
||||
auth_code="recording:list",
|
||||
meta={"title": "录制模块"},
|
||||
sort=200,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_module_registry_rejects_duplicate_names():
|
||||
registry = ModuleRegistry()
|
||||
registry.register(RecordingModule())
|
||||
|
||||
with pytest.raises(ValueError, match="module already registered"):
|
||||
registry.register(RecordingModule())
|
||||
|
||||
|
||||
def test_create_app_runs_module_phases_and_collects_metadata():
|
||||
module = RecordingModule()
|
||||
imported: list[str] = []
|
||||
|
||||
app = create_app(
|
||||
"test",
|
||||
modules=[module],
|
||||
config_mapping={"test": "iti.config.TestConfig"},
|
||||
model_imports=[lambda: imported.append("models")],
|
||||
)
|
||||
|
||||
registry = get_module_registry(app)
|
||||
|
||||
assert module.calls == [
|
||||
"init_app",
|
||||
"register_commands",
|
||||
"register_routes",
|
||||
"register_permissions",
|
||||
"register_menu_seed",
|
||||
]
|
||||
assert imported == ["models"]
|
||||
assert registry.list_permissions()[0].code == "recording:list"
|
||||
assert registry.list_menu_seeds()[0].id == "recording-menu"
|
||||
|
||||
|
||||
def test_module_menu_seed_is_written_by_system_seed():
|
||||
module = RecordingModule()
|
||||
app = create_app("test", modules=[module])
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
summary = seed_system_data(get_module_registry(app))
|
||||
|
||||
menu = db.session.get(SysMenu, "recording-menu")
|
||||
role = db.session.scalar(db.select(Role).filter_by(code="ADMIN"))
|
||||
|
||||
assert summary["module_menus"]["created"] == 1
|
||||
assert summary["module_role_menus"]["created"] == 1
|
||||
assert menu is not None
|
||||
assert menu.auth_code == "recording:list"
|
||||
assert role is not None
|
||||
assert "recording-menu" in {item.id for item in role.menus}
|
||||
|
||||
db.session.remove()
|
||||
db.drop_all()
|
||||
@ -0,0 +1,86 @@
|
||||
from sqlalchemy import select
|
||||
|
||||
from iti.applications import create_app
|
||||
from iti.applications.common.enums import GenderEnum, StatusEnum
|
||||
from iti.applications.common.permission import get_super_admin_role
|
||||
from iti.applications.extensions import db
|
||||
from iti.applications.models import Role, SysConfig, SysMenu, User
|
||||
from iti.seeds.system import DEFAULT_MENUS, seed_system_data
|
||||
|
||||
|
||||
def test_seed_system_data_is_idempotent():
|
||||
app = create_app("test")
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
first = seed_system_data()
|
||||
admin_user = db.session.scalar(select(User).filter_by(username="admin"))
|
||||
assert admin_user is not None
|
||||
admin_user.realname = "已有管理员"
|
||||
admin_user.email = "existing@example.com"
|
||||
admin_user.password = "custom-password"
|
||||
db.session.commit()
|
||||
|
||||
second = seed_system_data()
|
||||
|
||||
assert first["roles"]["created"] == 2
|
||||
assert first["menus"]["created"] == len(DEFAULT_MENUS)
|
||||
assert second["roles"]["created"] == 0
|
||||
assert second["menus"]["created"] == 0
|
||||
assert second["users"]["created"] == 0
|
||||
|
||||
admin_role = db.session.scalar(select(Role).filter_by(code="ADMIN"))
|
||||
common_role = db.session.scalar(select(Role).filter_by(code="COMMON"))
|
||||
admin_user = db.session.scalar(select(User).filter_by(username="admin"))
|
||||
default_roles = db.session.scalar(
|
||||
select(SysConfig).filter_by(type="USER", code="DEFAULT_USER_ROLES")
|
||||
)
|
||||
|
||||
assert admin_role is not None
|
||||
assert common_role is not None
|
||||
assert admin_user is not None
|
||||
assert admin_user.realname == "已有管理员"
|
||||
assert admin_user.email == "existing@example.com"
|
||||
assert admin_user.check_password("custom-password")
|
||||
assert default_roles is not None
|
||||
assert default_roles.value == "COMMON"
|
||||
assert app.config["PERMISSION_CONFIG"]["SUPER_ADMIN_ROLE"] == "ADMIN"
|
||||
assert get_super_admin_role() == "ADMIN"
|
||||
assert {role.code for role in admin_user.roles} == {"ADMIN"}
|
||||
assert len(admin_role.menus) == len(DEFAULT_MENUS)
|
||||
assert db.session.scalar(select(SysMenu).filter_by(name="PunchAndReport")) is None
|
||||
|
||||
db.session.remove()
|
||||
db.drop_all()
|
||||
|
||||
|
||||
def test_seed_system_data_does_not_update_existing_admin_user():
|
||||
app = create_app("test")
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
existing = User(
|
||||
username="admin",
|
||||
password="custom-password",
|
||||
realname="已有管理员",
|
||||
email="existing@example.com",
|
||||
gender=GenderEnum.SECURE.value,
|
||||
status=StatusEnum.ENABLED.value,
|
||||
)
|
||||
db.session.add(existing)
|
||||
db.session.commit()
|
||||
|
||||
summary = seed_system_data()
|
||||
|
||||
admin_user = db.session.scalar(select(User).filter_by(username="admin"))
|
||||
assert summary["users"]["created"] == 0
|
||||
assert summary["users"]["updated"] == 0
|
||||
assert summary["users"]["skipped"] == 1
|
||||
assert admin_user.realname == "已有管理员"
|
||||
assert admin_user.email == "existing@example.com"
|
||||
assert admin_user.check_password("custom-password")
|
||||
assert {role.code for role in admin_user.roles} == {"ADMIN"}
|
||||
|
||||
db.session.remove()
|
||||
db.drop_all()
|
||||
@ -0,0 +1,108 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from iti.service_client import (
|
||||
ServiceClient,
|
||||
ServiceConfig,
|
||||
ServiceHTTPError,
|
||||
ServiceUnavailableError,
|
||||
)
|
||||
from iti.service_client.config import CircuitBreakerConfig, RetryConfig
|
||||
|
||||
|
||||
def _json_response(status_code: int, payload: dict) -> httpx.Response:
|
||||
return httpx.Response(status_code, json=payload)
|
||||
|
||||
|
||||
def test_service_client_sends_json_headers_token_trace_and_path():
|
||||
seen: dict[str, object] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["url"] = str(request.url)
|
||||
seen["authorization"] = request.headers.get("Authorization")
|
||||
seen["trace_id"] = request.headers.get("X-Trace-Id")
|
||||
return _json_response(200, {"ok": True})
|
||||
|
||||
client = ServiceClient(
|
||||
ServiceConfig(name="erp", base_url="http://erp.local", token="token-a"),
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
|
||||
result = client.get("/users/{id}", path={"id": 12}, params={"active": "1"})
|
||||
|
||||
assert result == {"ok": True}
|
||||
assert seen["url"] == "http://erp.local/users/12?active=1"
|
||||
assert seen["authorization"] == "Bearer token-a"
|
||||
assert isinstance(seen["trace_id"], str)
|
||||
assert seen["trace_id"]
|
||||
|
||||
|
||||
def test_service_client_retries_idempotent_statuses():
|
||||
calls = {"count": 0}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls["count"] += 1
|
||||
if calls["count"] == 1:
|
||||
return _json_response(503, {"error": "busy"})
|
||||
return _json_response(200, {"ok": True})
|
||||
|
||||
client = ServiceClient(
|
||||
ServiceConfig(
|
||||
name="erp",
|
||||
base_url="http://erp.local",
|
||||
retry=RetryConfig(attempts=2, backoff=0),
|
||||
),
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
|
||||
assert client.get("/health") == {"ok": True}
|
||||
assert calls["count"] == 2
|
||||
|
||||
|
||||
def test_service_client_does_not_retry_post_by_default():
|
||||
calls = {"count": 0}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
calls["count"] += 1
|
||||
return _json_response(503, {"error": "busy"})
|
||||
|
||||
client = ServiceClient(
|
||||
ServiceConfig(
|
||||
name="erp",
|
||||
base_url="http://erp.local",
|
||||
retry=RetryConfig(attempts=3, backoff=0),
|
||||
),
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
|
||||
with pytest.raises(ServiceHTTPError) as exc_info:
|
||||
client.post("/jobs", json={"kind": "users"})
|
||||
|
||||
assert exc_info.value.status_code == 503
|
||||
assert calls["count"] == 1
|
||||
|
||||
|
||||
def test_service_client_opens_circuit_breaker_after_failures():
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return _json_response(500, {"error": "failed"})
|
||||
|
||||
client = ServiceClient(
|
||||
ServiceConfig(
|
||||
name="erp",
|
||||
base_url="http://erp.local",
|
||||
circuit_breaker=CircuitBreakerConfig(
|
||||
enabled=True,
|
||||
fail_max=1,
|
||||
reset_timeout=30,
|
||||
),
|
||||
),
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
|
||||
with pytest.raises(ServiceHTTPError):
|
||||
client.get("/health")
|
||||
|
||||
with pytest.raises(ServiceUnavailableError, match="circuit breaker is open"):
|
||||
client.get("/health")
|
||||
@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from iti.tasks.registry import TaskRegistry
|
||||
from iti.tasks.runner import TaskRunner, _parse_interval, _parse_simple_cron
|
||||
|
||||
|
||||
def test_task_registry_triggers_success_and_failure_runs():
|
||||
registry = TaskRegistry()
|
||||
registry.register(name="success", handler=lambda: {"ok": True})
|
||||
registry.register(name="failure", handler=lambda: 1 / 0)
|
||||
|
||||
success = registry.trigger("success")
|
||||
failure = registry.trigger("failure")
|
||||
|
||||
assert success.status == "success"
|
||||
assert success.result == {"ok": True}
|
||||
assert success.finished_at is not None
|
||||
assert failure.status == "failed"
|
||||
assert "ZeroDivisionError" in failure.error
|
||||
|
||||
|
||||
def test_task_registry_rejects_duplicate_names():
|
||||
registry = TaskRegistry()
|
||||
registry.register(name="sync", handler=lambda: None)
|
||||
|
||||
with pytest.raises(ValueError, match="task already registered"):
|
||||
registry.register(name="sync", handler=lambda: None)
|
||||
|
||||
|
||||
def test_task_registry_skips_when_same_task_is_running():
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
registry = TaskRegistry()
|
||||
|
||||
def blocking_handler():
|
||||
started.set()
|
||||
release.wait(timeout=2)
|
||||
|
||||
registry.register(name="sync", handler=blocking_handler)
|
||||
worker = threading.Thread(target=registry.trigger, args=("sync",))
|
||||
worker.start()
|
||||
assert started.wait(timeout=1)
|
||||
|
||||
skipped = registry.trigger("sync")
|
||||
release.set()
|
||||
worker.join(timeout=2)
|
||||
|
||||
assert skipped.status == "skipped"
|
||||
assert skipped.error == "task already running"
|
||||
|
||||
|
||||
def test_task_schedule_parsers_and_due_check():
|
||||
runner = TaskRunner(TaskRegistry())
|
||||
now = time.time()
|
||||
|
||||
assert _parse_interval("interval:60") == 60
|
||||
assert _parse_interval("interval:0") == 1
|
||||
assert _parse_interval("bad") is None
|
||||
assert _parse_simple_cron("cron:*/5 * * * *") == 300
|
||||
assert _parse_simple_cron("* * * * *") == 60
|
||||
assert _parse_simple_cron("0 * * * *") is None
|
||||
|
||||
assert runner._due("interval:10", "sync", now) is True
|
||||
runner._last_run["sync"] = now
|
||||
assert runner._due("interval:10", "sync", now + 5) is False
|
||||
assert runner._due("interval:10", "sync", now + 10) is True
|
||||
Loading…
Reference in New Issue