@wneng/create-keel 0.3.6 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +206 -13
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/templates/ci-gitee/files/pipeline.yml +62 -0
- package/src/templates/ci-github/files/ci.yml +160 -0
- package/src/templates/db-go-elasticsearch/files/Makefile +18 -0
- package/src/templates/db-go-elasticsearch/files/apply_templates.go +80 -0
- package/src/templates/db-go-elasticsearch/files/db-README.md +57 -0
- package/src/templates/db-go-elasticsearch/files/go.mod +7 -0
- package/src/templates/db-go-elasticsearch/files/index-template-init.json +25 -0
- package/src/templates/db-go-elasticsearch/fragment.yaml +22 -0
- package/src/templates/db-go-migrate-mysql/files/000001_init.down.sql +3 -0
- package/src/templates/db-go-migrate-mysql/files/000001_init.up.sql +35 -0
- package/src/templates/db-go-migrate-mysql/files/Makefile +33 -0
- package/src/templates/db-go-migrate-mysql/files/db-README.md +77 -0
- package/src/templates/db-go-migrate-mysql/files/go.mod +8 -0
- package/src/templates/db-go-migrate-mysql/fragment.yaml +22 -0
- package/src/templates/db-go-migrate-postgres/files/000001_init.down.sql +3 -0
- package/src/templates/db-go-migrate-postgres/files/000001_init.up.sql +32 -0
- package/src/templates/db-go-migrate-postgres/files/Makefile +31 -0
- package/src/templates/db-go-migrate-postgres/files/db-README.md +71 -0
- package/src/templates/db-go-migrate-postgres/files/go.mod +8 -0
- package/src/templates/db-go-migrate-postgres/fragment.yaml +22 -0
- package/src/templates/db-java-elasticsearch/files/EsTemplateApplier.java +86 -0
- package/src/templates/db-java-elasticsearch/files/db-README.md +63 -0
- package/src/templates/db-java-elasticsearch/files/index-template-init.json +25 -0
- package/src/templates/db-java-elasticsearch/files/pom.xml +134 -0
- package/src/templates/db-java-elasticsearch/fragment.yaml +19 -0
- package/src/templates/db-java-flyway-mysql/files/V1__init.sql +44 -0
- package/src/templates/db-java-flyway-mysql/files/application.yaml +39 -0
- package/src/templates/db-java-flyway-mysql/files/db-README.md +102 -0
- package/src/templates/db-java-flyway-mysql/files/pom.xml +172 -0
- package/src/templates/db-java-flyway-mysql/fragment.yaml +19 -0
- package/src/templates/db-java-flyway-postgres/files/V1__init.sql +40 -0
- package/src/templates/db-java-flyway-postgres/files/application.yaml +37 -0
- package/src/templates/db-java-flyway-postgres/files/db-README.md +75 -0
- package/src/templates/db-java-flyway-postgres/files/pom.xml +166 -0
- package/src/templates/db-java-flyway-postgres/fragment.yaml +19 -0
- package/src/templates/db-node-elasticsearch/files/apply-templates.cjs +60 -0
- package/src/templates/db-node-elasticsearch/files/db-README.md +76 -0
- package/src/templates/db-node-elasticsearch/files/index-template-init.json +26 -0
- package/src/templates/db-node-elasticsearch/files/package.json +26 -0
- package/src/templates/db-node-elasticsearch/fragment.yaml +19 -0
- package/src/templates/db-node-knex-mysql/files/db-README.md +90 -0
- package/src/templates/db-node-knex-mysql/files/knexfile.cjs +72 -0
- package/src/templates/db-node-knex-mysql/files/migrations-init.cjs +42 -0
- package/src/templates/db-node-knex-mysql/files/package.json +31 -0
- package/src/templates/db-node-knex-mysql/files/seeds-dev-fixtures.cjs +38 -0
- package/src/templates/db-node-knex-mysql/files/seeds-prod-dictionaries.cjs +25 -0
- package/src/templates/db-node-knex-mysql/fragment.yaml +25 -0
- package/src/templates/db-node-knex-postgres/files/db-README.md +81 -0
- package/src/templates/db-node-knex-postgres/files/knexfile.cjs +67 -0
- package/src/templates/db-node-knex-postgres/files/migrations-init.cjs +42 -0
- package/src/templates/db-node-knex-postgres/files/package.json +31 -0
- package/src/templates/db-node-knex-postgres/files/seeds-dev-fixtures.cjs +36 -0
- package/src/templates/db-node-knex-postgres/files/seeds-prod-dictionaries.cjs +26 -0
- package/src/templates/db-node-knex-postgres/fragment.yaml +25 -0
- package/src/templates/db-python-alembic-mysql/files/0001_init.py +70 -0
- package/src/templates/db-python-alembic-mysql/files/alembic.ini +47 -0
- package/src/templates/db-python-alembic-mysql/files/db-README.md +87 -0
- package/src/templates/db-python-alembic-mysql/files/env.py +71 -0
- package/src/templates/db-python-alembic-mysql/files/pyproject.toml +52 -0
- package/src/templates/db-python-alembic-mysql/files/script.py.mako +26 -0
- package/src/templates/db-python-alembic-mysql/fragment.yaml +25 -0
- package/src/templates/db-python-alembic-postgres/files/0001_init.py +62 -0
- package/src/templates/db-python-alembic-postgres/files/alembic.ini +45 -0
- package/src/templates/db-python-alembic-postgres/files/db-README.md +70 -0
- package/src/templates/db-python-alembic-postgres/files/env.py +62 -0
- package/src/templates/db-python-alembic-postgres/files/pyproject.toml +52 -0
- package/src/templates/db-python-alembic-postgres/files/script.py.mako +25 -0
- package/src/templates/db-python-alembic-postgres/fragment.yaml +25 -0
- package/src/templates/db-python-elasticsearch/files/apply_templates.py +55 -0
- package/src/templates/db-python-elasticsearch/files/db-README.md +57 -0
- package/src/templates/db-python-elasticsearch/files/index-template-init.json +25 -0
- package/src/templates/db-python-elasticsearch/files/pyproject.toml +50 -0
- package/src/templates/db-python-elasticsearch/fragment.yaml +19 -0
- package/src/templates/docs-skeleton/files/governance-database.md +150 -0
- package/src/templates/docs-skeleton/fragment.yaml +3 -0
- package/src/templates/root-files/files/AGENTS.md +6 -2
- package/src/templates/server-python/files/README.md +75 -2
- package/src/templates/server-python/files/app-init.py +11 -1
- package/src/templates/server-python/files/config.py +40 -0
- package/src/templates/server-python/files/main.py +62 -0
- package/src/templates/server-python/files/pyproject.toml +14 -1
- package/src/templates/server-python/files/test_healthz.py +36 -0
- package/src/templates/server-python/fragment.yaml +10 -1
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knex configuration for <%= it.options.projectName %>-server (PostgreSQL).
|
|
3
|
+
*
|
|
4
|
+
* Driver versions are pinned in package.json and mirrored in
|
|
5
|
+
* docs/03-工程规范与研发基础设施/tech-stack-server.md so governance-lint can
|
|
6
|
+
* detect drift. To upgrade Knex or pg, bump both files in the same PR.
|
|
7
|
+
*
|
|
8
|
+
* Connection comes from environment variables (.env.example documents
|
|
9
|
+
* the full list). Production deployments inject the same names via
|
|
10
|
+
* deploy/<target>/values.yaml or equivalent.
|
|
11
|
+
*/
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
|
|
14
|
+
const base = {
|
|
15
|
+
client: 'pg',
|
|
16
|
+
migrations: {
|
|
17
|
+
directory: path.join(__dirname, 'db', 'migrations'),
|
|
18
|
+
extension: 'cjs',
|
|
19
|
+
tableName: 'knex_migrations',
|
|
20
|
+
},
|
|
21
|
+
seeds: {
|
|
22
|
+
directory: path.join(__dirname, 'db', 'seeds', 'prod'),
|
|
23
|
+
extension: 'cjs',
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** @type {import('knex').Knex.Config} */
|
|
28
|
+
const development = {
|
|
29
|
+
...base,
|
|
30
|
+
connection: {
|
|
31
|
+
host: process.env.DB_HOST || '127.0.0.1',
|
|
32
|
+
port: Number(process.env.DB_PORT || 5432),
|
|
33
|
+
user: process.env.DB_USER || 'postgres',
|
|
34
|
+
password: process.env.DB_PASSWORD || '',
|
|
35
|
+
database: process.env.DB_NAME || '<%= it.options.projectName.replace(/-/g, "_") %>_dev',
|
|
36
|
+
},
|
|
37
|
+
pool: { min: 0, max: 10 },
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** @type {import('knex').Knex.Config} */
|
|
41
|
+
const production = {
|
|
42
|
+
...base,
|
|
43
|
+
connection: {
|
|
44
|
+
host: process.env.DB_HOST,
|
|
45
|
+
port: Number(process.env.DB_PORT || 5432),
|
|
46
|
+
user: process.env.DB_USER,
|
|
47
|
+
password: process.env.DB_PASSWORD,
|
|
48
|
+
database: process.env.DB_NAME,
|
|
49
|
+
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false,
|
|
50
|
+
},
|
|
51
|
+
pool: { min: 2, max: 20 },
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** @type {import('knex').Knex.Config} */
|
|
55
|
+
const test = {
|
|
56
|
+
...base,
|
|
57
|
+
connection: {
|
|
58
|
+
host: process.env.DB_HOST || '127.0.0.1',
|
|
59
|
+
port: Number(process.env.DB_PORT || 5432),
|
|
60
|
+
user: process.env.DB_USER || 'postgres',
|
|
61
|
+
password: process.env.DB_PASSWORD || '',
|
|
62
|
+
database: process.env.DB_NAME || '<%= it.options.projectName.replace(/-/g, "_") %>_test',
|
|
63
|
+
},
|
|
64
|
+
pool: { min: 0, max: 5 },
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
module.exports = { development, production, test };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 初始化迁移:创建 users 与 roles 两张样例表。
|
|
3
|
+
*
|
|
4
|
+
* 命名规则:<utc-timestamp>_<description>.cjs。新建迁移用 `npm run db:make -- <name>`。
|
|
5
|
+
* 修改既有迁移属于破坏性变更,应升 Tier 4(参见 docs/governance/change-tiers.md):
|
|
6
|
+
* 改字段类型、删字段、改约束都不允许直接覆盖已合入 main 的迁移;写一条新迁移。
|
|
7
|
+
*
|
|
8
|
+
* PostgreSQL 注意:
|
|
9
|
+
* - 主键统一用 BIGINT GENERATED ALWAYS AS IDENTITY;如需分布式 ID 改用 UUID 时一次性切换
|
|
10
|
+
* - 文本列默认用 TEXT(无长度限制),仅在确需限长时用 VARCHAR(n)
|
|
11
|
+
* - 时间列用 TIMESTAMPTZ(含时区),统一存 UTC,应用层负责本地化
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
exports.up = async function up(knex) {
|
|
15
|
+
await knex.schema.createTable('roles', (t) => {
|
|
16
|
+
t.bigIncrements('id').primary();
|
|
17
|
+
t.string('code', 64).notNullable().unique();
|
|
18
|
+
t.string('name', 128).notNullable();
|
|
19
|
+
t.text('description').nullable();
|
|
20
|
+
t.timestamp('created_at', { useTz: true }).defaultTo(knex.fn.now());
|
|
21
|
+
t.timestamp('updated_at', { useTz: true }).defaultTo(knex.fn.now());
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
await knex.schema.createTable('users', (t) => {
|
|
25
|
+
t.bigIncrements('id').primary();
|
|
26
|
+
t.string('email', 255).notNullable().unique();
|
|
27
|
+
t.string('password_hash', 255).notNullable();
|
|
28
|
+
t.string('display_name', 128).nullable();
|
|
29
|
+
t.bigInteger('role_id').notNullable().references('id').inTable('roles');
|
|
30
|
+
t.timestamp('created_at', { useTz: true }).defaultTo(knex.fn.now());
|
|
31
|
+
t.timestamp('updated_at', { useTz: true }).defaultTo(knex.fn.now());
|
|
32
|
+
t.timestamp('deleted_at', { useTz: true }).nullable();
|
|
33
|
+
|
|
34
|
+
t.index(['role_id'], 'idx_users_role_id');
|
|
35
|
+
t.index(['deleted_at'], 'idx_users_deleted_at');
|
|
36
|
+
});
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
exports.down = async function down(knex) {
|
|
40
|
+
await knex.schema.dropTableIfExists('users');
|
|
41
|
+
await knex.schema.dropTableIfExists('roles');
|
|
42
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "<%= it.options.projectName %>-server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"start": "node src/index.js",
|
|
8
|
+
"typecheck": "tsc --noEmit",
|
|
9
|
+
"lint": "eslint src --ext .ts",
|
|
10
|
+
"format": "prettier --write \"src/**/*.ts\"",
|
|
11
|
+
"format:check": "prettier --check \"src/**/*.ts\"",
|
|
12
|
+
"test": "echo \"add tests with vitest or jest\" && exit 0",
|
|
13
|
+
"db:migrate": "knex migrate:latest",
|
|
14
|
+
"db:rollback": "knex migrate:rollback",
|
|
15
|
+
"db:seed:prod": "knex seed:run --specific=db/seeds/prod",
|
|
16
|
+
"db:seed:dev": "knex seed:run --specific=db/seeds/dev",
|
|
17
|
+
"db:make": "knex migrate:make"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"knex": "3.1.0",
|
|
21
|
+
"pg": "8.13.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^20.14.10",
|
|
25
|
+
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
|
26
|
+
"@typescript-eslint/parser": "^7.18.0",
|
|
27
|
+
"eslint": "^8.57.0",
|
|
28
|
+
"prettier": "^3.3.3",
|
|
29
|
+
"typescript": "^5.5.4"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 开发种子:仅用于本地 / 测试环境的样例数据。
|
|
3
|
+
*
|
|
4
|
+
* **绝不**让这里的数据进入生产库。CI 流水线只跑 prod 种子,dev 种子由
|
|
5
|
+
* 开发者按需 `npm run db:seed:dev` 手动加载。
|
|
6
|
+
*
|
|
7
|
+
* 包含真实邮箱、姓名等也放在这里——前提是它们都是构造数据,不是真实 PII。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
exports.seed = async function seed(knex) {
|
|
11
|
+
// 依赖 prod 种子先跑过,roles 表里至少有 'user' 与 'admin'
|
|
12
|
+
const roles = await knex('roles').whereIn('code', ['admin', 'user']).select('id', 'code');
|
|
13
|
+
const adminRoleId = roles.find((r) => r.code === 'admin')?.id;
|
|
14
|
+
const userRoleId = roles.find((r) => r.code === 'user')?.id;
|
|
15
|
+
|
|
16
|
+
if (adminRoleId === undefined || userRoleId === undefined) {
|
|
17
|
+
throw new Error('prod seeds must run first; roles "admin" / "user" missing');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
await knex('users').whereIn('email', ['admin@example.com', 'alice@example.com']).delete();
|
|
21
|
+
|
|
22
|
+
await knex('users').insert([
|
|
23
|
+
{
|
|
24
|
+
email: 'admin@example.com',
|
|
25
|
+
password_hash: '$2b$10$placeholder.dev.only.do.not.use.in.production.hash',
|
|
26
|
+
display_name: '系统管理员',
|
|
27
|
+
role_id: adminRoleId,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
email: 'alice@example.com',
|
|
31
|
+
password_hash: '$2b$10$placeholder.dev.only.do.not.use.in.production.hash',
|
|
32
|
+
display_name: 'Alice',
|
|
33
|
+
role_id: userRoleId,
|
|
34
|
+
},
|
|
35
|
+
]);
|
|
36
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 生产种子:字典数据。
|
|
3
|
+
*
|
|
4
|
+
* 与 contracts/dictionaries/ 对齐——这些 code/name 是 contract 派生而来的,
|
|
5
|
+
* 修改 contracts/dictionaries/enums.yaml 的同一 PR 中必须同步更新这里。
|
|
6
|
+
* (0.4.0 仍是手动同步;自动派生留给后续 contract-derived-seeds 特性)
|
|
7
|
+
*
|
|
8
|
+
* Idempotent:用 ON CONFLICT DO UPDATE 让多次执行结果一致(PostgreSQL UPSERT)。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
exports.seed = async function seed(knex) {
|
|
12
|
+
const roles = [
|
|
13
|
+
{ code: 'admin', name: '系统管理员', description: '可执行所有管理操作' },
|
|
14
|
+
{ code: 'user', name: '普通用户', description: '默认注册角色' },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
for (const r of roles) {
|
|
18
|
+
await knex.raw(
|
|
19
|
+
`INSERT INTO roles (code, name, description)
|
|
20
|
+
VALUES (?, ?, ?)
|
|
21
|
+
ON CONFLICT (code) DO UPDATE
|
|
22
|
+
SET name = EXCLUDED.name, description = EXCLUDED.description`,
|
|
23
|
+
[r.code, r.name, r.description],
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
name: db-node-knex-postgres
|
|
2
|
+
version: 1.0.0
|
|
3
|
+
appliesWhen:
|
|
4
|
+
backend: node
|
|
5
|
+
database: postgres
|
|
6
|
+
priority: 40
|
|
7
|
+
files:
|
|
8
|
+
- from: files/package.json
|
|
9
|
+
to: server/package.json
|
|
10
|
+
render: true
|
|
11
|
+
- from: files/knexfile.cjs
|
|
12
|
+
to: server/knexfile.cjs
|
|
13
|
+
render: true
|
|
14
|
+
- from: files/migrations-init.cjs
|
|
15
|
+
to: server/db/migrations/20260101000000_init.cjs
|
|
16
|
+
render: false
|
|
17
|
+
- from: files/seeds-prod-dictionaries.cjs
|
|
18
|
+
to: server/db/seeds/prod/20260101000000_seed_dictionaries.cjs
|
|
19
|
+
render: false
|
|
20
|
+
- from: files/seeds-dev-fixtures.cjs
|
|
21
|
+
to: server/db/seeds/dev/20260101000000_seed_dev_fixtures.cjs
|
|
22
|
+
render: false
|
|
23
|
+
- from: files/db-README.md
|
|
24
|
+
to: server/db/README.md
|
|
25
|
+
render: true
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""init: roles + users tables.
|
|
2
|
+
|
|
3
|
+
Revision ID: 0001
|
|
4
|
+
Revises:
|
|
5
|
+
Create Date: 2026-01-01 00:00:00
|
|
6
|
+
|
|
7
|
+
命名规则:版本号文件 `<NNNN>_<description>.py`。修改既有迁移属于破坏性变更,
|
|
8
|
+
应升 Tier 4(参见 docs/governance/change-tiers.md);改 schema 写新 revision。
|
|
9
|
+
|
|
10
|
+
MySQL 注意:
|
|
11
|
+
- charset 用 utf8mb4 避免 emoji 截断
|
|
12
|
+
- 主键 BIGINT UNSIGNED + AUTO_INCREMENT
|
|
13
|
+
- 时间列 TIMESTAMP(6)
|
|
14
|
+
"""
|
|
15
|
+
from typing import Sequence, Union
|
|
16
|
+
|
|
17
|
+
from alembic import op
|
|
18
|
+
import sqlalchemy as sa
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
revision: str = "0001"
|
|
22
|
+
down_revision: Union[str, None] = None
|
|
23
|
+
branch_labels: Union[str, Sequence[str], None] = None
|
|
24
|
+
depends_on: Union[str, Sequence[str], None] = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def upgrade() -> None:
|
|
28
|
+
op.create_table(
|
|
29
|
+
"roles",
|
|
30
|
+
sa.Column("id", sa.BigInteger().with_variant(sa.dialects.mysql.BIGINT(unsigned=True), "mysql"), primary_key=True, autoincrement=True),
|
|
31
|
+
sa.Column("code", sa.String(64), nullable=False, unique=True),
|
|
32
|
+
sa.Column("name", sa.String(128), nullable=False),
|
|
33
|
+
sa.Column("description", sa.Text(), nullable=True),
|
|
34
|
+
sa.Column("created_at", sa.TIMESTAMP(timezone=False), server_default=sa.text("CURRENT_TIMESTAMP(6)"), nullable=False),
|
|
35
|
+
sa.Column("updated_at", sa.TIMESTAMP(timezone=False), server_default=sa.text("CURRENT_TIMESTAMP(6)"), nullable=False),
|
|
36
|
+
mysql_engine="InnoDB",
|
|
37
|
+
mysql_charset="utf8mb4",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
op.create_table(
|
|
41
|
+
"users",
|
|
42
|
+
sa.Column("id", sa.BigInteger().with_variant(sa.dialects.mysql.BIGINT(unsigned=True), "mysql"), primary_key=True, autoincrement=True),
|
|
43
|
+
sa.Column("email", sa.String(255), nullable=False, unique=True),
|
|
44
|
+
sa.Column("password_hash", sa.String(255), nullable=False),
|
|
45
|
+
sa.Column("display_name", sa.String(128), nullable=True),
|
|
46
|
+
sa.Column("role_id", sa.BigInteger().with_variant(sa.dialects.mysql.BIGINT(unsigned=True), "mysql"), sa.ForeignKey("roles.id"), nullable=False),
|
|
47
|
+
sa.Column("created_at", sa.TIMESTAMP(timezone=False), server_default=sa.text("CURRENT_TIMESTAMP(6)"), nullable=False),
|
|
48
|
+
sa.Column("updated_at", sa.TIMESTAMP(timezone=False), server_default=sa.text("CURRENT_TIMESTAMP(6)"), nullable=False),
|
|
49
|
+
sa.Column("deleted_at", sa.TIMESTAMP(timezone=False), nullable=True),
|
|
50
|
+
mysql_engine="InnoDB",
|
|
51
|
+
mysql_charset="utf8mb4",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
op.create_index("idx_users_role_id", "users", ["role_id"])
|
|
55
|
+
op.create_index("idx_users_deleted_at", "users", ["deleted_at"])
|
|
56
|
+
|
|
57
|
+
# 字典数据:与 contracts/dictionaries/enums.yaml 对齐。
|
|
58
|
+
# 改字典必须同步改这里 + contracts/CHANGELOG.md。
|
|
59
|
+
op.execute(
|
|
60
|
+
"INSERT IGNORE INTO roles (code, name, description) VALUES "
|
|
61
|
+
"('admin', '系统管理员', '可执行所有管理操作'), "
|
|
62
|
+
"('user', '普通用户', '默认注册角色')"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def downgrade() -> None:
|
|
67
|
+
op.drop_index("idx_users_deleted_at", table_name="users")
|
|
68
|
+
op.drop_index("idx_users_role_id", table_name="users")
|
|
69
|
+
op.drop_table("users")
|
|
70
|
+
op.drop_table("roles")
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
; Alembic configuration for <%= it.options.projectName %>-server (MySQL).
|
|
2
|
+
;
|
|
3
|
+
; Connection string is read from DATABASE_URL at runtime (see env.py).
|
|
4
|
+
; Override here only if your team has a different convention.
|
|
5
|
+
|
|
6
|
+
[alembic]
|
|
7
|
+
script_location = db/migrations
|
|
8
|
+
prepend_sys_path = .
|
|
9
|
+
version_path_separator = os
|
|
10
|
+
sqlalchemy.url =
|
|
11
|
+
|
|
12
|
+
; output_encoding 与 LF 行尾保持与项目其他配置一致
|
|
13
|
+
output_encoding = utf-8
|
|
14
|
+
|
|
15
|
+
[loggers]
|
|
16
|
+
keys = root,sqlalchemy,alembic
|
|
17
|
+
|
|
18
|
+
[handlers]
|
|
19
|
+
keys = console
|
|
20
|
+
|
|
21
|
+
[formatters]
|
|
22
|
+
keys = generic
|
|
23
|
+
|
|
24
|
+
[logger_root]
|
|
25
|
+
level = WARN
|
|
26
|
+
handlers = console
|
|
27
|
+
qualname =
|
|
28
|
+
|
|
29
|
+
[logger_sqlalchemy]
|
|
30
|
+
level = WARN
|
|
31
|
+
handlers =
|
|
32
|
+
qualname = sqlalchemy.engine
|
|
33
|
+
|
|
34
|
+
[logger_alembic]
|
|
35
|
+
level = INFO
|
|
36
|
+
handlers =
|
|
37
|
+
qualname = alembic
|
|
38
|
+
|
|
39
|
+
[handler_console]
|
|
40
|
+
class = StreamHandler
|
|
41
|
+
args = (sys.stderr,)
|
|
42
|
+
level = NOTSET
|
|
43
|
+
formatter = generic
|
|
44
|
+
|
|
45
|
+
[formatter_generic]
|
|
46
|
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
47
|
+
datefmt = %Y-%m-%d %H:%M:%S
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# server/db/
|
|
2
|
+
|
|
3
|
+
数据库迁移、种子数据、本地数据库工具的入口。
|
|
4
|
+
|
|
5
|
+
> 跨项目的数据库归属规则(哪类 SQL 放哪个目录、与 `contracts/` 如何对齐)见 [`docs/governance/database.md`](../../docs/governance/database.md)。
|
|
6
|
+
|
|
7
|
+
## 工具与版本
|
|
8
|
+
|
|
9
|
+
| 项 | 值 |
|
|
10
|
+
|---|---|
|
|
11
|
+
| 数据库 | MySQL 8 / MariaDB 10.6+ |
|
|
12
|
+
| 迁移工具 | [Alembic](https://alembic.sqlalchemy.org) `1.13.3` |
|
|
13
|
+
| ORM | SQLAlchemy `2.0.36` |
|
|
14
|
+
| 驱动 | `pymysql` `1.1.1` |
|
|
15
|
+
|
|
16
|
+
工具版本在 `pyproject.toml` 钉死,并镜像到 `docs/03-工程规范与研发基础设施/tech-stack-server.md`。governance-lint 检测两边漂移并阻断 PR。
|
|
17
|
+
|
|
18
|
+
## 目录布局
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
server/
|
|
22
|
+
├── pyproject.toml
|
|
23
|
+
├── alembic.ini # Alembic 配置入口
|
|
24
|
+
└── db/
|
|
25
|
+
├── README.md # 本文件
|
|
26
|
+
└── migrations/
|
|
27
|
+
├── env.py # 运行时 hook(读 DATABASE_URL、装载 metadata)
|
|
28
|
+
├── script.py.mako # 新建 revision 的模板
|
|
29
|
+
└── versions/ # 实际迁移文件
|
|
30
|
+
└── <NNNN>_<description>.py
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## 文件命名规则
|
|
34
|
+
|
|
35
|
+
- 迁移文件:`<NNNN>_<description>.py`,N 单调递增
|
|
36
|
+
- 修改既有迁移属于**破坏性变更**——已合入 main 不允许直接覆盖;改 schema 写新 revision
|
|
37
|
+
|
|
38
|
+
## 常用命令
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# 新建迁移
|
|
42
|
+
alembic revision -m "create orders table"
|
|
43
|
+
# 配合 SQLAlchemy 模型:
|
|
44
|
+
alembic revision --autogenerate -m "create orders table"
|
|
45
|
+
|
|
46
|
+
# 升到最新
|
|
47
|
+
alembic upgrade head
|
|
48
|
+
|
|
49
|
+
# 回滚到上一版
|
|
50
|
+
alembic downgrade -1
|
|
51
|
+
|
|
52
|
+
# 查看当前版本
|
|
53
|
+
alembic current
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
环境变量 `DATABASE_URL`(默认 `mysql+pymysql://root:@127.0.0.1:3306/<project>_dev`)。
|
|
57
|
+
|
|
58
|
+
## prod vs dev seeds
|
|
59
|
+
|
|
60
|
+
Alembic 没有专门的 seed 概念,本框架的约定:
|
|
61
|
+
|
|
62
|
+
| 类型 | 位置 |
|
|
63
|
+
|---|---|
|
|
64
|
+
| 生产字典(必装) | 写进 `versions/` 的 revision 中(INSERT IGNORE) |
|
|
65
|
+
| 开发样例 | `server/db/seeds/dev/` 下的独立 SQL / Python 脚本,手动执行 |
|
|
66
|
+
|
|
67
|
+
字典类生产种子的真值在 `contracts/dictionaries/enums.yaml`。当前是手动同步;自动派生留给后续 `contract-derived-seeds` 特性。
|
|
68
|
+
|
|
69
|
+
## 改动分级(参见 `docs/governance/change-tiers.md`)
|
|
70
|
+
|
|
71
|
+
| 改动 | Tier |
|
|
72
|
+
|---|---|
|
|
73
|
+
| 新建 revision 加表 / 加字段 | 3(contract 一致性 + CHANGELOG) |
|
|
74
|
+
| 修改既有 revision | **不允许**;写新 revision |
|
|
75
|
+
| 删字段 / 改类型(破坏性) | 4(ADR + 迁移预案) |
|
|
76
|
+
| 改 dev seed | 1/2 |
|
|
77
|
+
| 改字典数据(在 revision 里) | 3(同步 contracts/dictionaries/) |
|
|
78
|
+
| 升级 Alembic / SQLAlchemy / pymysql | 2,但同步改 tech-stack-server.md |
|
|
79
|
+
|
|
80
|
+
## CI 行为
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
alembic upgrade head
|
|
84
|
+
alembic downgrade base
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
只验证迁移**能跑通 + 能回滚**。
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Alembic environment.
|
|
2
|
+
|
|
3
|
+
Reads DATABASE_URL from env (or falls back to a localhost MySQL DSN);
|
|
4
|
+
imports the application's SQLAlchemy metadata for autogenerate support.
|
|
5
|
+
|
|
6
|
+
Edit `target_metadata` to point at your app's Base.metadata once you
|
|
7
|
+
introduce ORM models. Until then it is None, which still lets manual
|
|
8
|
+
migrations run.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from logging.config import fileConfig
|
|
13
|
+
|
|
14
|
+
from alembic import context
|
|
15
|
+
from sqlalchemy import engine_from_config, pool
|
|
16
|
+
|
|
17
|
+
config = context.config
|
|
18
|
+
|
|
19
|
+
# Configure logging from alembic.ini if present.
|
|
20
|
+
if config.config_file_name is not None:
|
|
21
|
+
fileConfig(config.config_file_name)
|
|
22
|
+
|
|
23
|
+
# Resolve connection from DATABASE_URL when present; otherwise use a sane
|
|
24
|
+
# localhost default for development. Production deployments must set the
|
|
25
|
+
# env var via deploy/values.yaml or equivalent.
|
|
26
|
+
config.set_main_option(
|
|
27
|
+
"sqlalchemy.url",
|
|
28
|
+
os.environ.get(
|
|
29
|
+
"DATABASE_URL",
|
|
30
|
+
"mysql+pymysql://root:@127.0.0.1:3306/" + "<%= it.options.projectName.replace(/-/g, '_') %>" + "_dev",
|
|
31
|
+
),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# When you add SQLAlchemy models, replace None with `app.models.Base.metadata`
|
|
35
|
+
# to enable `alembic revision --autogenerate`.
|
|
36
|
+
target_metadata = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run_migrations_offline() -> None:
|
|
40
|
+
"""Run migrations in 'offline' mode (emit SQL to stdout)."""
|
|
41
|
+
url = config.get_main_option("sqlalchemy.url")
|
|
42
|
+
context.configure(
|
|
43
|
+
url=url,
|
|
44
|
+
target_metadata=target_metadata,
|
|
45
|
+
literal_binds=True,
|
|
46
|
+
dialect_opts={"paramstyle": "named"},
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
with context.begin_transaction():
|
|
50
|
+
context.run_migrations()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def run_migrations_online() -> None:
|
|
54
|
+
"""Run migrations in 'online' mode (against a live DB)."""
|
|
55
|
+
connectable = engine_from_config(
|
|
56
|
+
config.get_section(config.config_ini_section, {}),
|
|
57
|
+
prefix="sqlalchemy.",
|
|
58
|
+
poolclass=pool.NullPool,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
with connectable.connect() as connection:
|
|
62
|
+
context.configure(connection=connection, target_metadata=target_metadata)
|
|
63
|
+
|
|
64
|
+
with context.begin_transaction():
|
|
65
|
+
context.run_migrations()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
if context.is_offline_mode():
|
|
69
|
+
run_migrations_offline()
|
|
70
|
+
else:
|
|
71
|
+
run_migrations_online()
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "<%= it.options.projectName %>-server"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
requires-python = ">=3.11"
|
|
5
|
+
dependencies = [
|
|
6
|
+
"fastapi==0.115.5",
|
|
7
|
+
"uvicorn[standard]==0.32.0",
|
|
8
|
+
"pydantic==2.9.2",
|
|
9
|
+
"pydantic-settings==2.6.1",
|
|
10
|
+
"alembic==1.13.3",
|
|
11
|
+
"sqlalchemy==2.0.36",
|
|
12
|
+
"pymysql==1.1.1",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
dev = [
|
|
17
|
+
"ruff>=0.6.8",
|
|
18
|
+
"mypy>=1.11.0",
|
|
19
|
+
"pytest>=8.3.0",
|
|
20
|
+
"pytest-asyncio>=0.24.0",
|
|
21
|
+
"httpx>=0.27.0",
|
|
22
|
+
"pip-audit>=2.7.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[tool.ruff]
|
|
26
|
+
line-length = 100
|
|
27
|
+
target-version = "py311"
|
|
28
|
+
extend-exclude = ["generated", ".venv", "db/migrations/versions"]
|
|
29
|
+
|
|
30
|
+
[tool.ruff.lint]
|
|
31
|
+
select = ["E", "F", "W", "I", "B", "UP", "N"]
|
|
32
|
+
ignore = []
|
|
33
|
+
|
|
34
|
+
[tool.ruff.format]
|
|
35
|
+
quote-style = "double"
|
|
36
|
+
indent-style = "space"
|
|
37
|
+
line-ending = "lf"
|
|
38
|
+
|
|
39
|
+
[tool.mypy]
|
|
40
|
+
python_version = "3.11"
|
|
41
|
+
strict_optional = true
|
|
42
|
+
disallow_untyped_defs = true
|
|
43
|
+
warn_unused_ignores = true
|
|
44
|
+
exclude = ["generated", ".venv", "db/migrations/versions"]
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
testpaths = ["tests"]
|
|
48
|
+
asyncio_mode = "auto"
|
|
49
|
+
|
|
50
|
+
[tool.setuptools.packages.find]
|
|
51
|
+
include = ["app*"]
|
|
52
|
+
exclude = ["tests*", "generated*", "db*"]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""${message}
|
|
2
|
+
|
|
3
|
+
Revision ID: ${up_revision}
|
|
4
|
+
Revises: ${down_revision | comma,n}
|
|
5
|
+
Create Date: ${create_date}
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
from typing import Sequence, Union
|
|
9
|
+
|
|
10
|
+
from alembic import op
|
|
11
|
+
import sqlalchemy as sa
|
|
12
|
+
${imports if imports else ""}
|
|
13
|
+
|
|
14
|
+
# revision identifiers, used by Alembic.
|
|
15
|
+
revision: str = ${repr(up_revision)}
|
|
16
|
+
down_revision: Union[str, None] = ${repr(down_revision)}
|
|
17
|
+
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
|
18
|
+
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def upgrade() -> None:
|
|
22
|
+
${upgrades if upgrades else "pass"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def downgrade() -> None:
|
|
26
|
+
${downgrades if downgrades else "pass"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
name: db-python-alembic-mysql
|
|
2
|
+
version: 1.0.0
|
|
3
|
+
appliesWhen:
|
|
4
|
+
backend: python
|
|
5
|
+
database: mysql
|
|
6
|
+
priority: 40
|
|
7
|
+
files:
|
|
8
|
+
- from: files/pyproject.toml
|
|
9
|
+
to: server/pyproject.toml
|
|
10
|
+
render: true
|
|
11
|
+
- from: files/alembic.ini
|
|
12
|
+
to: server/alembic.ini
|
|
13
|
+
render: true
|
|
14
|
+
- from: files/env.py
|
|
15
|
+
to: server/db/migrations/env.py
|
|
16
|
+
render: false
|
|
17
|
+
- from: files/script.py.mako
|
|
18
|
+
to: server/db/migrations/script.py.mako
|
|
19
|
+
render: false
|
|
20
|
+
- from: files/0001_init.py
|
|
21
|
+
to: server/db/migrations/versions/0001_init.py
|
|
22
|
+
render: false
|
|
23
|
+
- from: files/db-README.md
|
|
24
|
+
to: server/db/README.md
|
|
25
|
+
render: true
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""init: roles + users tables.
|
|
2
|
+
|
|
3
|
+
Revision ID: 0001
|
|
4
|
+
Revises:
|
|
5
|
+
Create Date: 2026-01-01 00:00:00
|
|
6
|
+
|
|
7
|
+
PostgreSQL 注意:
|
|
8
|
+
- 主键 BIGINT GENERATED ALWAYS AS IDENTITY
|
|
9
|
+
- 时间列 TIMESTAMPTZ,统一存 UTC
|
|
10
|
+
"""
|
|
11
|
+
from typing import Sequence, Union
|
|
12
|
+
|
|
13
|
+
from alembic import op
|
|
14
|
+
import sqlalchemy as sa
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
revision: str = "0001"
|
|
18
|
+
down_revision: Union[str, None] = None
|
|
19
|
+
branch_labels: Union[str, Sequence[str], None] = None
|
|
20
|
+
depends_on: Union[str, Sequence[str], None] = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def upgrade() -> None:
|
|
24
|
+
op.create_table(
|
|
25
|
+
"roles",
|
|
26
|
+
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
|
|
27
|
+
sa.Column("code", sa.String(64), nullable=False, unique=True),
|
|
28
|
+
sa.Column("name", sa.String(128), nullable=False),
|
|
29
|
+
sa.Column("description", sa.Text(), nullable=True),
|
|
30
|
+
sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
|
31
|
+
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
op.create_table(
|
|
35
|
+
"users",
|
|
36
|
+
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
|
|
37
|
+
sa.Column("email", sa.String(255), nullable=False, unique=True),
|
|
38
|
+
sa.Column("password_hash", sa.String(255), nullable=False),
|
|
39
|
+
sa.Column("display_name", sa.String(128), nullable=True),
|
|
40
|
+
sa.Column("role_id", sa.BigInteger(), sa.ForeignKey("roles.id"), nullable=False),
|
|
41
|
+
sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
|
42
|
+
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("NOW()"), nullable=False),
|
|
43
|
+
sa.Column("deleted_at", sa.TIMESTAMP(timezone=True), nullable=True),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
op.create_index("idx_users_role_id", "users", ["role_id"])
|
|
47
|
+
op.create_index("idx_users_deleted_at", "users", ["deleted_at"])
|
|
48
|
+
|
|
49
|
+
# 字典数据(与 contracts/dictionaries/enums.yaml 对齐)
|
|
50
|
+
op.execute(
|
|
51
|
+
"INSERT INTO roles (code, name, description) VALUES "
|
|
52
|
+
"('admin', '系统管理员', '可执行所有管理操作'), "
|
|
53
|
+
"('user', '普通用户', '默认注册角色') "
|
|
54
|
+
"ON CONFLICT (code) DO NOTHING"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def downgrade() -> None:
|
|
59
|
+
op.drop_index("idx_users_deleted_at", table_name="users")
|
|
60
|
+
op.drop_index("idx_users_role_id", table_name="users")
|
|
61
|
+
op.drop_table("users")
|
|
62
|
+
op.drop_table("roles")
|