@wdyy/skills 0.1.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/.well-known/skills/index.json +24 -0
- package/.well-known/skills/wdyy-database-standard/SKILL.md +72 -0
- package/.well-known/skills/wdyy-database-standard/agents/openai.yaml +4 -0
- package/.well-known/skills/wdyy-database-standard/reference/database-rules.md +65 -0
- package/.well-known/skills/wdyy-database-standard/scripts/apply-migrations.sh +68 -0
- package/.well-known/skills/wdyy-database-standard/scripts/apply-migrations.test.mjs +47 -0
- package/.well-known/skills/wdyy-database-standard/scripts/validate-migration-layout.mjs +35 -0
- package/.well-known/skills/wdyy-database-standard/scripts/validate-migration-layout.test.mjs +73 -0
- package/.well-known/skills/wdyy-database-standard/scripts/validate-table-design.mjs +49 -0
- package/.well-known/skills/wdyy-database-standard/templates/table-design.template.md +44 -0
- package/.well-known/skills/wdyy-deployment-standard/SKILL.md +68 -0
- package/.well-known/skills/wdyy-deployment-standard/agents/openai.yaml +4 -0
- package/.well-known/skills/wdyy-deployment-standard/reference/linux-deployment-rules.md +9 -0
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.mjs +48 -0
- package/.well-known/skills/wdyy-deployment-standard/scripts/validate-deployment-package.test.mjs +72 -0
- package/.well-known/skills/wdyy-deployment-standard/templates/Dockerfile.template +15 -0
- package/.well-known/skills/wdyy-deployment-standard/templates/deploy.sh.template +171 -0
- package/.well-known/skills/wdyy-deployment-standard/templates/docker-compose.blue-green.yml +34 -0
- package/.well-known/skills/wdyy-deployment-standard/templates/nginx-upstream.template.conf +26 -0
- package/.well-known/skills/wdyy-internal-api-standard/SKILL.md +60 -0
- package/.well-known/skills/wdyy-internal-api-standard/agents/openai.yaml +4 -0
- package/.well-known/skills/wdyy-internal-api-standard/reference/internal-api-rules.md +5 -0
- package/.well-known/skills/wdyy-internal-api-standard/scripts/check-raw-http-calls.mjs +10 -0
- package/.well-known/skills/wdyy-internal-api-standard/templates/api-client.template.ts +20 -0
- package/.well-known/skills/wdyy-internal-api-standard/templates/api-error.template.ts +7 -0
- package/.well-known/skills/wdyy-internal-api-standard/templates/api-mock.template.ts +8 -0
- package/.well-known/skills/wdyy-logging-standard/SKILL.md +65 -0
- package/.well-known/skills/wdyy-logging-standard/agents/openai.yaml +4 -0
- package/.well-known/skills/wdyy-logging-standard/reference/logging-rules.md +12 -0
- package/.well-known/skills/wdyy-logging-standard/scripts/validate-log-entry.mjs +36 -0
- package/.well-known/skills/wdyy-logging-standard/scripts/validate-log-entry.test.mjs +133 -0
- package/.well-known/skills/wdyy-logging-standard/templates/frontend-error-report.template.ts +22 -0
- package/.well-known/skills/wdyy-logging-standard/templates/logger.template.ts +74 -0
- package/README.md +65 -0
- package/bin/wdyy.js +6 -0
- package/lib/wdyy-cli.js +124 -0
- package/package.json +24 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"skills": [
|
|
3
|
+
{
|
|
4
|
+
"name": "wdyy-database-standard",
|
|
5
|
+
"description": "将已确认的 PRD 与 DDL 转为 PostgreSQL 18 无外键逻辑关联设计,约束业务域表名前缀、自增主键 {前缀}_id、统一状态字段 {前缀}_status、timestamp(0) 时间类型、字段可空策略、注释、幂等、审计与向前兼容。Use when 审查 DDL、建立 V001 基线、增加后续迁移或验证生产数据库兼容性时。",
|
|
6
|
+
"files": ["SKILL.md", "agents/openai.yaml", "reference/database-rules.md", "scripts/apply-migrations.sh", "scripts/apply-migrations.test.mjs", "scripts/validate-migration-layout.mjs", "scripts/validate-migration-layout.test.mjs", "scripts/validate-table-design.mjs", "templates/table-design.template.md"]
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"name": "wdyy-deployment-standard",
|
|
10
|
+
"description": "为无 CI/CD 的 Linux 生产环境生成本地发布门禁、人工 SCP 交付约定、前端版本化 URL 和后端 Docker 蓝绿发布脚本。Use when 规划、实现、审查或执行手工生产部署时。",
|
|
11
|
+
"files": ["SKILL.md", "agents/openai.yaml", "reference/linux-deployment-rules.md", "scripts/validate-deployment-package.mjs", "scripts/validate-deployment-package.test.mjs", "templates/Dockerfile.template", "templates/deploy.sh.template", "templates/docker-compose.blue-green.yml", "templates/nginx-upstream.template.conf"]
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"name": "wdyy-internal-api-standard",
|
|
15
|
+
"description": "规范企业内部 REST API 客户端的认证头、traceId、超时、受控重试、熔断、错误映射、日志和响应校验。Use when DEFINE 阶段识别接口边界、BUILD 阶段调用内部服务或 REVIEW 阶段发现散落 fetch、axios、httpClient 调用时。",
|
|
16
|
+
"files": ["SKILL.md", "agents/openai.yaml", "reference/internal-api-rules.md", "scripts/check-raw-http-calls.mjs", "templates/api-client.template.ts", "templates/api-error.template.ts", "templates/api-mock.template.ts"]
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"name": "wdyy-logging-standard",
|
|
20
|
+
"description": "为 NestJS 和 Vue 项目实现前端异常上报、后端统一结构化 JSON 日志、蓝绿实例隔离、traceId 传递、敏感信息脱敏与 2MB 文件轮转。Use when 编写日志、错误处理、前端异常上报或审查生产可观测性时。",
|
|
21
|
+
"files": ["SKILL.md", "agents/openai.yaml", "reference/logging-rules.md", "scripts/validate-log-entry.mjs", "scripts/validate-log-entry.test.mjs", "templates/frontend-error-report.template.ts", "templates/logger.template.ts"]
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: wdyy-database-standard
|
|
3
|
+
description: 将已确认的 PRD 与 DDL 转为 PostgreSQL 18 无外键逻辑关联设计,约束业务域表名前缀、自增主键 {前缀}_id、统一状态字段 {前缀}_status、timestamp(0) 时间类型、字段可空策略、注释、幂等、审计与向前兼容。Use when 审查 DDL、建立 V001 基线、增加后续迁移或验证生产数据库兼容性时。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 企业数据库规范
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
将已确认的业务实体、状态和约束转化为可审查、可追踪、可重复部署的 PostgreSQL 18 数据模型。
|
|
11
|
+
|
|
12
|
+
## When to Use
|
|
13
|
+
|
|
14
|
+
- 分析 PRD、新增表、编写迁移或审查数据库设计时。
|
|
15
|
+
|
|
16
|
+
## 协作边界
|
|
17
|
+
|
|
18
|
+
先确认业务需求、接口边界、实现计划与测试要求。本 skill 独立规定 PostgreSQL 18 的设计约束,不得替代需求澄清或迁移验证。
|
|
19
|
+
|
|
20
|
+
## 输入与输出
|
|
21
|
+
|
|
22
|
+
- 输入:已确认的 PRD、完整 DDL、业务术语、状态流转、接口契约、已有模式与迁移历史。
|
|
23
|
+
- 输出:数据库设计说明、`V001__baseline.sql`、后续版本化迁移、功能-数据表映射初稿与评审记录。
|
|
24
|
+
- 使用 [数据库规则](reference/database-rules.md) 和 [表设计模板](templates/table-design.template.md)。
|
|
25
|
+
|
|
26
|
+
## 执行步骤
|
|
27
|
+
|
|
28
|
+
1. 确认 PRD 与 DDL 已通过就绪检查,识别实体、聚合关系、状态机、唯一约束、幂等键和审计责任人。
|
|
29
|
+
2. 为每个表设计小写下划线名称,表名必须添加业务域英文简写前缀(见业务域前缀映射表);若业务实体无对应前缀,须提示用户并给出建议,不得自行决定。首字段固定为 `{业务域前缀}_id`,类型使用自增主键,不得使用 `uuid`;所有时间类型字段统一使用 `timestamp(0)`;统一状态字段 `{业务域前缀}_status`(varchar(32),默认空值,取值自 `mdm_dictionary_item.item_code`)。除 `created_at`、`updated_at` 和主键外,所有字段默认允许空值,`deleted_flag` 默认为空。
|
|
30
|
+
3. 建立逻辑关联和必要索引,不创建数据库外键;明确每个枚举、默认值和字段业务含义。
|
|
31
|
+
4. 为每张表编写表注释,为每个字段编写字段注释;迁移 SQL 必须包含 `COMMENT ON TABLE` 与 `COMMENT ON COLUMN`。
|
|
32
|
+
5. 新项目将工程师确认的完整 DDL 固化为 `database/migrations/V001__baseline.sql`;后续变更只新增连续编号的 `VNNN__lower_snake_name.sql`,不得修改已执行文件。
|
|
33
|
+
6. 使用原生 `psql` 和 `schema_migrations` 记录表执行迁移;每个迁移由执行器包裹在同一事务中并记录校验值。
|
|
34
|
+
7. 为接口写操作定义幂等与并发处理策略,并审查新旧后端并存、应用回滚和历史数据兼容性。
|
|
35
|
+
8. 运行 `scripts/validate-table-design.mjs` 和 `scripts/validate-migration-layout.mjs`。
|
|
36
|
+
|
|
37
|
+
## 禁止事项
|
|
38
|
+
|
|
39
|
+
- 不得使用 `remark1`、`remark2`、`remark3` 承载未定义业务含义。
|
|
40
|
+
- 不得将 `id` 设计为 `uuid`;主键必须使用自增类型,字段名为 `{业务域前缀}_id`。
|
|
41
|
+
- 不得创建无表注释、无字段注释、物理外键或缺少审计字段的表。
|
|
42
|
+
- 不得在一次发布中删除旧字段、改变字段语义或破坏旧接口。
|
|
43
|
+
- 不得重写、重命名或删除已经执行的迁移文件。
|
|
44
|
+
- 不得每次部署重复执行完整 DDL,或以大量 `IF EXISTS` 掩盖迁移状态错误。
|
|
45
|
+
- 不得创建不带业务域前缀的业务表(除非确认为非业务表并已说明理由)。
|
|
46
|
+
- 不得将任何字段设为 NOT NULL(主键、`created_at`、`updated_at` 除外),所有字段默认允许空值。
|
|
47
|
+
- 时间类型字段不得使用 `timestamptz` 或无精度 `timestamp`,统一使用 `timestamp(0)`。
|
|
48
|
+
|
|
49
|
+
## Red Flags
|
|
50
|
+
|
|
51
|
+
- 表使用 remark 字段、无表注释、无字段注释、物理外键或缺少固定审计字段。
|
|
52
|
+
- 主键使用 `uuid`、业务键替代主键、缺少自增策略或字段名不符合 `{前缀}_id` 格式。
|
|
53
|
+
- 表名缺少业务域前缀。
|
|
54
|
+
- 时间类型字段使用 `timestamptz` 或无精度 `timestamp`。
|
|
55
|
+
- 发布迁移删除字段或改变旧字段语义。
|
|
56
|
+
|
|
57
|
+
## Verification
|
|
58
|
+
|
|
59
|
+
- [ ] 每张表均有表注释,每个字段均有字段注释,迁移 SQL 包含对应 `COMMENT ON TABLE` 与 `COMMENT ON COLUMN`。
|
|
60
|
+
- [ ] 所有业务表名均带有业务域前缀,前缀来自映射表或已获得用户确认。
|
|
61
|
+
- [ ] 首字段为 `{前缀}_id`,自增主键,未使用 uuid。
|
|
62
|
+
- [ ] `{前缀}_status` 字段类型为 varchar(32),默认空值,取值来源为 `mdm_dictionary_item.item_code`。
|
|
63
|
+
- [ ] 除主键、`created_at`、`updated_at` 外,所有字段默认允许空值,`deleted_flag` 默认为 NULL。
|
|
64
|
+
- [ ] 所有时间类型字段均使用 `timestamp(0)`,未出现 `timestamptz`。
|
|
65
|
+
- [ ] 每张表的字段、类型、必填、默认值、索引、唯一约束、逻辑关联和枚举均有说明。
|
|
66
|
+
- [ ] 表尾固定字段顺序符合要求,逻辑关联无外键。
|
|
67
|
+
- [ ] 写路径具有幂等与状态流转测试。
|
|
68
|
+
- [ ] `V001__baseline.sql` 存在,后续迁移编号连续且通过校验。
|
|
69
|
+
- [ ] 迁移通过原生 `psql` 在目标 PostgreSQL 18 实例执行,`schema_migrations` 记录版本和校验值。
|
|
70
|
+
- [ ] 新旧应用版本并存与回滚时数据库结构保持兼容。
|
|
71
|
+
|
|
72
|
+
业务语义、关联基数或保留策略不清楚时,停止建模并回到 PRD 澄清;不得凭习惯创造字段。
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# PostgreSQL 18 设计规则
|
|
2
|
+
|
|
3
|
+
## 业务域前缀
|
|
4
|
+
|
|
5
|
+
所有业务表名必须添加业务域英文简写前缀,映射如下:
|
|
6
|
+
|
|
7
|
+
| 业务域 | 前缀 |
|
|
8
|
+
|---|---|
|
|
9
|
+
| 患者 | `pat_` |
|
|
10
|
+
| 就诊 | `enc_` |
|
|
11
|
+
| 医嘱 | `ord_` |
|
|
12
|
+
| 病历文书 | `emr_` |
|
|
13
|
+
| 药品 | `drug_` |
|
|
14
|
+
| 员工 | `emp_` |
|
|
15
|
+
| 部门 | `dept_` |
|
|
16
|
+
| 物资耗材 | `material_` |
|
|
17
|
+
| 费用 | `charge_` |
|
|
18
|
+
| 结算 | `settle_` |
|
|
19
|
+
| 护理记录 | `nurse_` |
|
|
20
|
+
| 规则库 | `rule_` |
|
|
21
|
+
| 授权与审计 | `auth_` |
|
|
22
|
+
| 检验/检查报告 | `report_` |
|
|
23
|
+
| 诊断 | `diag_` |
|
|
24
|
+
| 手术 | `proce_` |
|
|
25
|
+
| 医疗服务项目 | `medser_` |
|
|
26
|
+
| 医保保险 | `insu_` |
|
|
27
|
+
| 检查 | `exam_` |
|
|
28
|
+
| 化验 | `lab_` |
|
|
29
|
+
|
|
30
|
+
如果业务实体无法匹配上述域,必须提示用户并给出建议前缀,不得自行决定。
|
|
31
|
+
|
|
32
|
+
## 表与字段命名
|
|
33
|
+
|
|
34
|
+
- 表名、字段名采用小写下划线,表名必须带业务域前缀(如 `pat_patient`、`enc_encounter`)。
|
|
35
|
+
- 首字段为 `{业务域前缀}_id`(如 `pat_id`),类型使用自增主键,建议采用 `bigint generated by default as identity`,不得使用 `uuid`。
|
|
36
|
+
- 所有时间类型字段统一使用 `timestamp(0)`,不使用 `timestamptz` 或无精度 `timestamp`。
|
|
37
|
+
- 所有字段默认允许空值(nullable),`created_at` 和 `updated_at` 除外(不可为空)。
|
|
38
|
+
- `deleted_flag` 默认为空(`DEFAULT NULL`),允许空值。
|
|
39
|
+
- `created_by`、`updated_by` 允许空值,不设必填约束。
|
|
40
|
+
- `created_at`、`updated_at` 设为 `NOT NULL DEFAULT now()`。
|
|
41
|
+
- 末尾固定字段顺序:`{前缀}_status`、`created_by`、`updated_by`、`created_at`、`updated_at`、`deleted_flag`。
|
|
42
|
+
|
|
43
|
+
## 统一状态字段
|
|
44
|
+
|
|
45
|
+
- 字段名 `{业务域前缀}_status`。
|
|
46
|
+
- 类型 `varchar(32)`,允许空值,默认为空。
|
|
47
|
+
- 状态值通过字典表 `mdm_dictionary_item.item_code` 获取。
|
|
48
|
+
|
|
49
|
+
## 关联与约束
|
|
50
|
+
|
|
51
|
+
- 使用逻辑关联和应用层校验,不建立外键。
|
|
52
|
+
- 写接口应说明唯一约束、幂等键与状态迁移条件。
|
|
53
|
+
|
|
54
|
+
## 注释
|
|
55
|
+
|
|
56
|
+
- 每张表必须有表注释,每个字段必须有字段注释;迁移 SQL 必须包含 `COMMENT ON TABLE` 与 `COMMENT ON COLUMN`。
|
|
57
|
+
- 每个字段必须有业务说明;禁止通用 remark 字段。
|
|
58
|
+
|
|
59
|
+
## 迁移管理
|
|
60
|
+
|
|
61
|
+
- 新项目完整 DDL 固化为 `database/migrations/V001__baseline.sql`;后续只新增 `VNNN__lower_snake_name.sql`。
|
|
62
|
+
- 已执行迁移不可修改、重命名或删除,版本号必须连续且唯一。
|
|
63
|
+
- 使用原生 `psql` 执行迁移,以 `schema_migrations` 记录版本、文件名、校验值和执行时间。
|
|
64
|
+
- 迁移执行器负责事务;迁移文件不得自行执行 `BEGIN`、`COMMIT` 或修改 `schema_migrations`。
|
|
65
|
+
- 发布迁移仅允许向前兼容的新增或扩展;删除字段、改变语义和破坏旧接口必须单独经历弃用流程。
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
: "${DATABASE_URL:?DATABASE_URL is required}"
|
|
5
|
+
MIGRATIONS_DIR="${MIGRATIONS_DIR:-database/migrations}"
|
|
6
|
+
|
|
7
|
+
if command -v sha256sum >/dev/null 2>&1; then
|
|
8
|
+
checksum_command=(sha256sum)
|
|
9
|
+
elif command -v shasum >/dev/null 2>&1; then
|
|
10
|
+
checksum_command=(shasum -a 256)
|
|
11
|
+
else
|
|
12
|
+
echo "sha256sum or shasum is required" >&2
|
|
13
|
+
exit 1
|
|
14
|
+
fi
|
|
15
|
+
|
|
16
|
+
files=("$MIGRATIONS_DIR"/V[0-9][0-9][0-9]__*.sql)
|
|
17
|
+
test -f "${files[0]}" || { echo "no migration files found in $MIGRATIONS_DIR" >&2; exit 1; }
|
|
18
|
+
expected_version=1
|
|
19
|
+
for file in "${files[@]}"; do
|
|
20
|
+
filename="${file##*/}"
|
|
21
|
+
[[ "$filename" =~ ^V([0-9]{3})__([a-z0-9]+(_[a-z0-9]+)*)\.sql$ ]] || {
|
|
22
|
+
echo "invalid migration filename: $filename" >&2
|
|
23
|
+
exit 1
|
|
24
|
+
}
|
|
25
|
+
version_number="$((10#${BASH_REMATCH[1]}))"
|
|
26
|
+
[[ "$version_number" -eq "$expected_version" ]] || {
|
|
27
|
+
printf 'expected migration V%03d, found %s\n' "$expected_version" "$filename" >&2
|
|
28
|
+
exit 1
|
|
29
|
+
}
|
|
30
|
+
if grep -Eiq '(^|[^[:alnum:]_])(BEGIN|COMMIT|ROLLBACK)[[:space:]]*;|schema_migrations' "$file"; then
|
|
31
|
+
echo "migration transaction and history are managed by the runner: $filename" >&2
|
|
32
|
+
exit 1
|
|
33
|
+
fi
|
|
34
|
+
expected_version=$((expected_version + 1))
|
|
35
|
+
done
|
|
36
|
+
|
|
37
|
+
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 <<'SQL'
|
|
38
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
39
|
+
version integer PRIMARY KEY,
|
|
40
|
+
filename text NOT NULL UNIQUE,
|
|
41
|
+
checksum text NOT NULL,
|
|
42
|
+
applied_at timestamptz NOT NULL DEFAULT now()
|
|
43
|
+
);
|
|
44
|
+
SQL
|
|
45
|
+
|
|
46
|
+
for file in "${files[@]}"; do
|
|
47
|
+
filename="${file##*/}"
|
|
48
|
+
version="${filename:1:3}"
|
|
49
|
+
checksum="$("${checksum_command[@]}" "$file" | awk '{print $1}')"
|
|
50
|
+
applied="$(
|
|
51
|
+
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -Atc \
|
|
52
|
+
"SELECT filename || '|' || checksum FROM schema_migrations WHERE version = $((10#$version))"
|
|
53
|
+
)"
|
|
54
|
+
if [[ -n "$applied" ]]; then
|
|
55
|
+
test "$applied" = "$filename|$checksum" || {
|
|
56
|
+
echo "applied migration name or checksum changed: $filename" >&2
|
|
57
|
+
exit 1
|
|
58
|
+
}
|
|
59
|
+
continue
|
|
60
|
+
fi
|
|
61
|
+
{
|
|
62
|
+
printf 'BEGIN;\n'
|
|
63
|
+
printf '\\i %s\n' "$file"
|
|
64
|
+
printf "INSERT INTO schema_migrations(version, filename, checksum) VALUES (%d, '%s', '%s');\n" \
|
|
65
|
+
"$((10#$version))" "$filename" "$checksum"
|
|
66
|
+
printf 'COMMIT;\n'
|
|
67
|
+
} | psql "$DATABASE_URL" -v ON_ERROR_STOP=1
|
|
68
|
+
done
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { chmod, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { afterEach, test } from 'node:test';
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
|
|
9
|
+
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const runner = join(scriptDirectory, 'apply-migrations.sh');
|
|
11
|
+
const temporaryDirectories = [];
|
|
12
|
+
|
|
13
|
+
afterEach(async () => {
|
|
14
|
+
await Promise.all(
|
|
15
|
+
temporaryDirectories.splice(0).map((directory) =>
|
|
16
|
+
rm(directory, { force: true, recursive: true }),
|
|
17
|
+
),
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('执行前拒绝迁移版本断档', async () => {
|
|
22
|
+
const root = await mkdtemp(join(tmpdir(), 'apply-migrations-'));
|
|
23
|
+
temporaryDirectories.push(root);
|
|
24
|
+
const migrations = join(root, 'database', 'migrations');
|
|
25
|
+
const bin = join(root, 'bin');
|
|
26
|
+
await mkdir(migrations, { recursive: true });
|
|
27
|
+
await mkdir(bin);
|
|
28
|
+
await writeFile(join(migrations, 'V001__baseline.sql'), 'SELECT 1;\n');
|
|
29
|
+
await writeFile(join(migrations, 'V003__gap.sql'), 'SELECT 3;\n');
|
|
30
|
+
await writeFile(join(bin, 'psql'), '#!/usr/bin/env bash\nexit 0\n');
|
|
31
|
+
await writeFile(join(bin, 'sha256sum'), '#!/usr/bin/env bash\nprintf "abc %s\\n" "$1"\n');
|
|
32
|
+
await chmod(join(bin, 'psql'), 0o755);
|
|
33
|
+
await chmod(join(bin, 'sha256sum'), 0o755);
|
|
34
|
+
|
|
35
|
+
const result = spawnSync(runner, {
|
|
36
|
+
cwd: root,
|
|
37
|
+
encoding: 'utf8',
|
|
38
|
+
env: {
|
|
39
|
+
...process.env,
|
|
40
|
+
DATABASE_URL: 'postgresql://test',
|
|
41
|
+
PATH: `${bin}:${process.env.PATH}`,
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
assert.notEqual(result.status, 0);
|
|
46
|
+
assert.match(result.stderr, /expected migration V002/);
|
|
47
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
3
|
+
|
|
4
|
+
const migrationDirectory = process.argv[2] ?? 'database/migrations';
|
|
5
|
+
const files = (await readdir(migrationDirectory))
|
|
6
|
+
.filter((name) => name.endsWith('.sql'))
|
|
7
|
+
.sort();
|
|
8
|
+
|
|
9
|
+
if (!files.includes('V001__baseline.sql')) {
|
|
10
|
+
throw new Error('V001__baseline.sql is required');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const parsed = files.map((name) => {
|
|
14
|
+
const match = name.match(/^V(\d{3})__([a-z0-9]+(?:_[a-z0-9]+)*)\.sql$/);
|
|
15
|
+
if (!match) throw new Error(`Invalid migration filename: ${name}`);
|
|
16
|
+
return { name, version: Number(match[1]) };
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
for (const [index, migration] of parsed.entries()) {
|
|
20
|
+
const expected = index + 1;
|
|
21
|
+
if (migration.version !== expected) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
`Migration versions must be continuous: expected V${String(expected).padStart(3, '0')}, found ${migration.name}`,
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
const content = await readFile(`${migrationDirectory}/${migration.name}`, 'utf8');
|
|
27
|
+
if (/\b(BEGIN|COMMIT|ROLLBACK)\s*;/i.test(content)) {
|
|
28
|
+
throw new Error(`Migration transaction is managed by the runner: ${migration.name}`);
|
|
29
|
+
}
|
|
30
|
+
if (/\bschema_migrations\b/i.test(content)) {
|
|
31
|
+
throw new Error(`Migration history is managed by the runner: ${migration.name}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
process.stdout.write(`validated ${parsed.length} migrations\n`);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { afterEach, test } from 'node:test';
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
|
|
9
|
+
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const validator = join(scriptDirectory, 'validate-migration-layout.mjs');
|
|
11
|
+
const temporaryDirectories = [];
|
|
12
|
+
|
|
13
|
+
afterEach(async () => {
|
|
14
|
+
await Promise.all(
|
|
15
|
+
temporaryDirectories.splice(0).map((directory) =>
|
|
16
|
+
rm(directory, { force: true, recursive: true }),
|
|
17
|
+
),
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
async function createMigrationDirectory(files) {
|
|
22
|
+
const root = await mkdtemp(join(tmpdir(), 'migration-layout-'));
|
|
23
|
+
temporaryDirectories.push(root);
|
|
24
|
+
const directory = join(root, 'database', 'migrations');
|
|
25
|
+
await mkdir(directory, { recursive: true });
|
|
26
|
+
await Promise.all(
|
|
27
|
+
Object.entries(files).map(([name, content]) =>
|
|
28
|
+
writeFile(join(directory, name), content),
|
|
29
|
+
),
|
|
30
|
+
);
|
|
31
|
+
return directory;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test('接受从 V001 baseline 开始的连续版本化 SQL 迁移', async () => {
|
|
35
|
+
const directory = await createMigrationDirectory({
|
|
36
|
+
'V001__baseline.sql': 'CREATE TABLE demo (id bigint);\n',
|
|
37
|
+
'V002__add_demo_name.sql': 'ALTER TABLE demo ADD COLUMN name text;\n',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const result = spawnSync(process.execPath, [validator, directory], {
|
|
41
|
+
encoding: 'utf8',
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
assert.equal(result.status, 0, result.stderr);
|
|
45
|
+
assert.match(result.stdout, /validated 2 migrations/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('拒绝缺少 V001 baseline 的迁移目录', async () => {
|
|
49
|
+
const directory = await createMigrationDirectory({
|
|
50
|
+
'V002__add_demo_name.sql': 'SELECT 1;\n',
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const result = spawnSync(process.execPath, [validator, directory], {
|
|
54
|
+
encoding: 'utf8',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
assert.notEqual(result.status, 0);
|
|
58
|
+
assert.match(result.stderr, /V001__baseline.sql is required/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('拒绝版本断档', async () => {
|
|
62
|
+
const directory = await createMigrationDirectory({
|
|
63
|
+
'V001__baseline.sql': 'SELECT 1;\n',
|
|
64
|
+
'V003__skip_version.sql': 'SELECT 2;\n',
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const result = spawnSync(process.execPath, [validator, directory], {
|
|
68
|
+
encoding: 'utf8',
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
assert.notEqual(result.status, 0);
|
|
72
|
+
assert.match(result.stderr, /Migration versions must be continuous/);
|
|
73
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
|
|
4
|
+
const content = await readFile(process.argv[2], 'utf8');
|
|
5
|
+
const required = [
|
|
6
|
+
'created_by',
|
|
7
|
+
'updated_by',
|
|
8
|
+
'created_at',
|
|
9
|
+
'updated_at',
|
|
10
|
+
'deleted_flag',
|
|
11
|
+
'逻辑关联',
|
|
12
|
+
'表注释',
|
|
13
|
+
'字段注释',
|
|
14
|
+
'COMMENT ON TABLE',
|
|
15
|
+
'COMMENT ON COLUMN',
|
|
16
|
+
'自增',
|
|
17
|
+
];
|
|
18
|
+
const missing = required.filter((text) => !content.includes(text));
|
|
19
|
+
if (missing.length) throw new Error(`Missing table design requirements: ${missing.join(', ')}`);
|
|
20
|
+
|
|
21
|
+
// 首字段必须为 {业务域前缀}_id 格式
|
|
22
|
+
if (!/\|\s*1\s*\|\s*`?[^|`\n]+_id`?\s*\|/.test(content)) {
|
|
23
|
+
throw new Error('First field must be a domain-prefixed id (e.g. pat_id) in format {prefix}_id');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 主键不得使用 uuid
|
|
27
|
+
if (/(^|\n)\s*\|\s*1\s*\|\s*id\s*\|\s*uuid\b/i.test(content) || /\bid\s+uuid\b/i.test(content)) {
|
|
28
|
+
throw new Error('Primary key must use an auto-increment type, not uuid');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 必须包含 _status 统一状态字段
|
|
32
|
+
if (!/_status\b/.test(content)) {
|
|
33
|
+
throw new Error('Table must include a domain-prefixed status field (e.g. pat_status)');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// deleted_flag 默认值必须为 NULL,不得为 false
|
|
37
|
+
if (/\|\s*deleted_flag\s*\|[^|\n]*\|[^|\n]*\|\s*false\s*\|/i.test(content)) {
|
|
38
|
+
throw new Error('deleted_flag default must be NULL, not false');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 时间类型字段不得使用 timestamptz
|
|
42
|
+
if (/\btimestamptz\b/i.test(content)) {
|
|
43
|
+
throw new Error('Time fields must use timestamp(0), not timestamptz');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 禁止通用 remark 字段
|
|
47
|
+
if (/remark[123]/i.test(content)) throw new Error('Generic remark fields are prohibited');
|
|
48
|
+
|
|
49
|
+
process.stdout.write('valid table design\n');
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# 表设计:`{前缀}_{表名}`
|
|
2
|
+
|
|
3
|
+
> 将 `{前缀}` 替换为业务域英文简写(见业务域前缀映射表),如患者域为 `pat_`。
|
|
4
|
+
|
|
5
|
+
## 业务目的与逻辑关联
|
|
6
|
+
|
|
7
|
+
- 业务域:
|
|
8
|
+
- 业务实体:
|
|
9
|
+
- 表注释:
|
|
10
|
+
- 聚合根:
|
|
11
|
+
- 关联表及关联键:
|
|
12
|
+
- 状态流转:
|
|
13
|
+
- 幂等键:
|
|
14
|
+
|
|
15
|
+
## 字段
|
|
16
|
+
|
|
17
|
+
| 顺序 | 字段 | 类型 | 必填 | 默认值 | 说明 | 字段注释/COMMENT | 索引/唯一约束 |
|
|
18
|
+
|---:|---|---|---|---|---|---|---|
|
|
19
|
+
| 1 | `{前缀}_id` | bigint | 是 | generated by default as identity | 自增主标识 | 自增主标识 | 主键索引 |
|
|
20
|
+
| ... | (业务字段) | | 否 | | | | |
|
|
21
|
+
| n-5 | `{前缀}_status` | varchar(32) | 否 | NULL | 业务状态,取值自 mdm_dictionary_item.item_code | 业务状态码 | |
|
|
22
|
+
| n-4 | created_by | varchar(64) | 否 | NULL | 创建人 | 创建人标识 | |
|
|
23
|
+
| n-3 | updated_by | varchar(64) | 否 | NULL | 更新人 | 更新人标识 | |
|
|
24
|
+
| n-2 | created_at | timestamp(0) | 是 | now() | 创建时间 | 记录创建时间 | |
|
|
25
|
+
| n-1 | updated_at | timestamp(0) | 是 | now() | 更新时间 | 记录最后更新时间 | |
|
|
26
|
+
| n | deleted_flag | boolean | 否 | NULL | 删除标记 | 逻辑删除标记 | |
|
|
27
|
+
|
|
28
|
+
## 注释 SQL
|
|
29
|
+
|
|
30
|
+
```sql
|
|
31
|
+
COMMENT ON TABLE {前缀}_{表名} IS '<表注释>';
|
|
32
|
+
COMMENT ON COLUMN {前缀}_{表名}.{前缀}_id IS '主标识';
|
|
33
|
+
-- 每个字段均必须有 COMMENT ON COLUMN
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## 枚举与兼容性
|
|
37
|
+
|
|
38
|
+
- 枚举:
|
|
39
|
+
- 状态字典:`mdm_dictionary_item.item_code`
|
|
40
|
+
- 迁移向前兼容策略:
|
|
41
|
+
- 基线迁移:`database/migrations/V001__baseline.sql`
|
|
42
|
+
- 当前迁移版本:
|
|
43
|
+
- 新旧应用并存验证:
|
|
44
|
+
- 应用回滚兼容性:
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: wdyy-deployment-standard
|
|
3
|
+
description: 为无 CI/CD 的 Linux 生产环境生成本地发布门禁、人工 SCP 交付约定、前端版本化 URL 和后端 Docker 蓝绿发布脚本。Use when 规划、实现、审查或执行手工生产部署时。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 企业部署规范
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
将 Linux 发布约束落实为前端不可变 release 与后端可健康检查、可回滚的 Docker 发布。
|
|
11
|
+
|
|
12
|
+
## When to Use
|
|
13
|
+
|
|
14
|
+
- 规划、实现、审查或发布生产部署时。
|
|
15
|
+
|
|
16
|
+
## 协作边界
|
|
17
|
+
|
|
18
|
+
生产发布必须具备本地发布门禁、可观测性、健康检查、回滚和 GO/NO-GO 决策证据。工程师负责人工 SCP 上传并手动执行脚本;本 skill 不执行上传,也不取代发布决策。
|
|
19
|
+
|
|
20
|
+
## 输入与输出
|
|
21
|
+
|
|
22
|
+
- 输入:`YYYYMMDD-NNN` 发布版本、前端压缩包、Docker 镜像归档、版本化迁移、健康检查 URL、Nginx 权限与生产配置。
|
|
23
|
+
- 输出:Dockerfile、蓝绿 compose、版本化前端 Nginx 配置、`deploy.sh` 和部署说明。
|
|
24
|
+
- 读取 [Linux 部署规则](reference/linux-deployment-rules.md),使用 templates 与 shell scripts。
|
|
25
|
+
|
|
26
|
+
## 执行步骤
|
|
27
|
+
|
|
28
|
+
1. 在本地依次通过测试、lint、类型检查、构建、镜像检查和发布包校验;任一步失败不得生成可发布结论。
|
|
29
|
+
2. 工程师将 `incoming/<version>/frontend.tar.gz`、`backend-image.tar` 和 `database/migrations` 人工 SCP 到服务器。
|
|
30
|
+
3. 前端构建使用 `/releases/<version>/` 资源基路径,解压到不可变 `releases/<version>`;`current` 仅用于入口页原子切换。
|
|
31
|
+
4. 配置版本化 hash 资源长期缓存、`index.html` 禁止强缓存;保留最近五个版本且不得早于约定观测窗口清理。
|
|
32
|
+
5. 使用原生 `psql` 执行尚未执行的向前兼容迁移。
|
|
33
|
+
6. 在非活动颜色启动新后端容器;健康检查和版本接口通过后切换 Nginx upstream,再切换前端 `current`。
|
|
34
|
+
7. 保留旧颜色容器作为上一版本;回滚只允许切回已保留并验证健康的上一版本。
|
|
35
|
+
8. 生成 `start <version>`、`stop`、`restart`、`status`、`rollback <version>`,并运行部署包校验器。
|
|
36
|
+
|
|
37
|
+
## 禁止事项
|
|
38
|
+
|
|
39
|
+
- 不得直接覆盖正在服务的前端 `dist`。
|
|
40
|
+
- 不得先停止旧后端容器再启动新容器。
|
|
41
|
+
- 不得以 PM2 或 systemd 作为后端主部署方案。
|
|
42
|
+
- 不得在无健康检查、版本标识或可演练回滚的情况下发布。
|
|
43
|
+
- 不得在 `deploy.sh` 中执行 SCP、拉取未确认代码或静默安装依赖。
|
|
44
|
+
- 不得把“保留旧 release 目录”等同于旧资源可访问;资源 URL 必须包含版本。
|
|
45
|
+
|
|
46
|
+
## Red Flags
|
|
47
|
+
|
|
48
|
+
- 发布脚本在新版本通过健康检查前修改 upstream。
|
|
49
|
+
- 前端目录被直接覆盖,或发布计划包含破坏性迁移。
|
|
50
|
+
|
|
51
|
+
## Verification
|
|
52
|
+
|
|
53
|
+
- [ ] 新容器通过健康检查并返回目标版本。
|
|
54
|
+
- [ ] upstream 切换后新请求命中新版本,旧资源在保留窗口不返回 404。
|
|
55
|
+
- [ ] 回滚脚本在演练环境恢复旧版本。
|
|
56
|
+
- [ ] 数据库变更的兼容性已审查。
|
|
57
|
+
- [ ] `deploy.sh` 五个命令语义明确且不包含上传行为。
|
|
58
|
+
- [ ] 人工上传目录包含前端包、后端镜像归档和版本化迁移。
|
|
59
|
+
|
|
60
|
+
任一步骤失败时保持或恢复旧 upstream,保留失败容器日志并报告失败环节;不得强制切流或删除旧版本掩盖问题。
|
|
61
|
+
|
|
62
|
+
## Common Rationalizations
|
|
63
|
+
|
|
64
|
+
| 合理化说法 | 事实 |
|
|
65
|
+
|---|---|
|
|
66
|
+
| “停几秒没关系” | 发布规范要求后端无先停后启的中断窗口。 |
|
|
67
|
+
| “覆盖 dist 最简单” | 旧页面会引用旧 hash 文件,覆盖会造成资源 404。 |
|
|
68
|
+
| “小字段改动无需兼容” | 滚动与回滚期间新旧版本会并存。 |
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Linux 部署规则
|
|
2
|
+
|
|
3
|
+
1. 无 CI/CD 时必须在本地完成测试、lint、类型检查、构建、镜像和发布包验证,失败不得上传。
|
|
4
|
+
2. SCP 由工程师人工执行;`deploy.sh` 只消费 `incoming/<version>`,不得上传、拉取代码或安装未确认依赖。
|
|
5
|
+
3. 前端资源 URL 使用 `/releases/<version>/`,构建输出写入不可变 release;确认资源存在后才原子更新 `current`。
|
|
6
|
+
4. 后端固定使用 blue/green 两容器;非活动颜色启动、健康检查和版本接口通过后才能切换 upstream。
|
|
7
|
+
5. PostgreSQL 使用原生 `psql` 版本化迁移;迁移仅允许向前兼容的新增或扩展。
|
|
8
|
+
6. 旧颜色和旧前端 release 至少保留到观测窗口结束;默认保留最近五个版本。
|
|
9
|
+
7. 回滚只切换到已保留、健康且数据库兼容的上一版本。
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
|
|
4
|
+
const [deployPath, nginxPath, composePath] = process.argv.slice(2);
|
|
5
|
+
if (!deployPath || !nginxPath || !composePath) {
|
|
6
|
+
throw new Error('Pass deploy.sh, nginx config and compose file paths');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const [deploy, nginx, compose] = await Promise.all([
|
|
10
|
+
readFile(deployPath, 'utf8'),
|
|
11
|
+
readFile(nginxPath, 'utf8'),
|
|
12
|
+
readFile(composePath, 'utf8'),
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
const deployRequirements = [
|
|
16
|
+
'start)',
|
|
17
|
+
'stop)',
|
|
18
|
+
'restart)',
|
|
19
|
+
'status)',
|
|
20
|
+
'rollback)',
|
|
21
|
+
'version="$2"',
|
|
22
|
+
'HEALTH_URL',
|
|
23
|
+
'VERSION_URL',
|
|
24
|
+
'database/migrations',
|
|
25
|
+
];
|
|
26
|
+
const nginxRequirements = ['location /releases/', '/current'];
|
|
27
|
+
const composeRequirements = [
|
|
28
|
+
'backend-blue:',
|
|
29
|
+
'backend-green:',
|
|
30
|
+
'INSTANCE_ID',
|
|
31
|
+
'blue',
|
|
32
|
+
'green',
|
|
33
|
+
'LOG_DIR',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const missing = [
|
|
37
|
+
...deployRequirements.filter((value) => !deploy.includes(value)),
|
|
38
|
+
...nginxRequirements.filter((value) => !nginx.includes(value)),
|
|
39
|
+
...composeRequirements.filter((value) => !compose.includes(value)),
|
|
40
|
+
];
|
|
41
|
+
if (missing.length) {
|
|
42
|
+
throw new Error(`Missing deployment requirements: ${missing.join(', ')}`);
|
|
43
|
+
}
|
|
44
|
+
if (/\bscp\b/.test(deploy)) {
|
|
45
|
+
throw new Error('deploy.sh must not upload artifacts; SCP is a manual engineer action');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
process.stdout.write('valid deployment package\n');
|