ai-project-manage-cli 6.0.53 → 6.0.55

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.
@@ -0,0 +1,564 @@
1
+ #!/usr/bin/env python3
2
+ """自动部署脚本"""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import posixpath
8
+ import re
9
+ import shlex
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ import time
14
+ import zipfile
15
+ from dataclasses import dataclass
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ import paramiko
21
+
22
+ SCRIPT_DIR = Path(__file__).resolve().parent
23
+ PROJECT_ROOT = SCRIPT_DIR.parent.parent
24
+ APM_CONFIG_PATH = PROJECT_ROOT / ".apm" / "apm.config.json"
25
+ DEPLOY_CACHE_DIR = SCRIPT_DIR / ".deploy_cache"
26
+ MANIFEST_FILE = DEPLOY_CACHE_DIR / "manifest.json"
27
+ SPRINGBOOT_SCRIPT = "springboot.sh"
28
+ MAVEN_MODULE = "jeecg-module-system/jeecg-system-start"
29
+ MAVEN_PROFILE = "dev"
30
+
31
+ if sys.platform == "win32" and hasattr(sys.stdout, "reconfigure"):
32
+ sys.stdout.reconfigure(encoding="utf-8")
33
+ sys.stderr.reconfigure(encoding="utf-8")
34
+
35
+
36
+ def log(message: str) -> None:
37
+ print(f"[{datetime.now().strftime('%H:%M:%S')}] {message}")
38
+
39
+
40
+ def fail(message: str, code: int = 1) -> None:
41
+ log(f"ERROR: {message}")
42
+ sys.exit(code)
43
+
44
+
45
+ def expand_path(path_str: str) -> Path:
46
+ return Path(path_str).expanduser().resolve()
47
+
48
+
49
+ def load_apm_config(apm_path: Path) -> dict[str, Any]:
50
+ with apm_path.open("r", encoding="utf-8") as handle:
51
+ data = json.load(handle)
52
+
53
+ name = str(data["name"]).strip()
54
+ deploy = data["wisdomDeploy"]
55
+ health = data["healthCheck"]
56
+
57
+ jar_path = str(deploy["jarPath"]).strip()
58
+ remote_app_dir = posixpath.dirname(jar_path)
59
+
60
+ return {
61
+ "project_name": name,
62
+ "host": deploy["host"],
63
+ "port": deploy["port"],
64
+ "username": deploy["username"],
65
+ "password": deploy["password"],
66
+ "remote_vue_dist_dir": str(deploy["remotePath"]).strip(),
67
+ "remote_app_dir": remote_app_dir,
68
+ "remote_lib_dir": posixpath.join(remote_app_dir, "lib"),
69
+ "startup_jar": posixpath.basename(jar_path),
70
+ "package_name": f"{name}.jar.zip",
71
+ "maven_local_repo": str(deploy["mavenLocalRepo"]).strip(),
72
+ "health_check_port": health["port"],
73
+ "health_check_context": health["context"],
74
+ "health_check_timeout": health["timeout"],
75
+ }
76
+
77
+
78
+ def load_config() -> dict[str, Any]:
79
+ return load_apm_config(APM_CONFIG_PATH)
80
+
81
+
82
+ def get_target_dir() -> Path:
83
+ return PROJECT_ROOT / MAVEN_MODULE / "target"
84
+
85
+
86
+ def get_maven_local_repo(config: dict[str, Any]) -> Path:
87
+ return expand_path(str(config["maven_local_repo"]).strip())
88
+
89
+
90
+ @dataclass
91
+ class UpdateEntry:
92
+ path: Path
93
+ arcname: str
94
+ reason: str
95
+
96
+
97
+ def relative_key(path: Path) -> str:
98
+ return path.resolve().relative_to(PROJECT_ROOT.resolve()).as_posix()
99
+
100
+
101
+ def file_signature(path: Path) -> dict[str, float | int]:
102
+ stat = path.stat()
103
+ return {"size": int(stat.st_size), "mtime": float(stat.st_mtime)}
104
+
105
+
106
+ def load_manifest() -> dict[str, dict[str, float | int]]:
107
+ if not MANIFEST_FILE.exists():
108
+ return {}
109
+ with MANIFEST_FILE.open("r", encoding="utf-8") as handle:
110
+ return json.load(handle)
111
+
112
+
113
+ def save_manifest(manifest: dict[str, dict[str, float | int]]) -> None:
114
+ DEPLOY_CACHE_DIR.mkdir(parents=True, exist_ok=True)
115
+ with MANIFEST_FILE.open("w", encoding="utf-8") as handle:
116
+ json.dump(manifest, handle, ensure_ascii=False, indent=2)
117
+
118
+
119
+ def is_project_lib_jar(jar_name: str) -> bool:
120
+ return jar_name.startswith("jeecg-")
121
+
122
+
123
+ def list_lib_files_to_upload(
124
+ local_lib_dir: Path,
125
+ remote_stats: dict[str, paramiko.SFTPAttributes],
126
+ *,
127
+ manifest: dict[str, dict[str, float | int]] | None = None,
128
+ ) -> list[UpdateEntry]:
129
+ entries: list[UpdateEntry] = []
130
+ for jar_file in sorted(local_lib_dir.glob("*.jar")):
131
+ remote_attr = remote_stats.get(jar_file.name)
132
+ should_upload, reason = should_upload_lib_file(
133
+ jar_file,
134
+ remote_attr,
135
+ manifest,
136
+ )
137
+ if should_upload:
138
+ entries.append(
139
+ UpdateEntry(
140
+ path=jar_file,
141
+ arcname=jar_file.name,
142
+ reason=reason,
143
+ )
144
+ )
145
+ return entries
146
+
147
+
148
+ def create_update_package(entries: list[UpdateEntry], package_name: str) -> Path:
149
+ DEPLOY_CACHE_DIR.mkdir(parents=True, exist_ok=True)
150
+ zip_path = DEPLOY_CACHE_DIR / package_name
151
+
152
+ log(f"创建更新包: {zip_path.name}({len(entries)} 个文件)")
153
+ with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
154
+ for entry in entries:
155
+ archive.write(entry.path, arcname=entry.arcname)
156
+ log(f" 打包: {entry.arcname} ({entry.reason})")
157
+
158
+ return zip_path
159
+
160
+
161
+ def upload_update_package(
162
+ sftp: paramiko.SFTPClient,
163
+ zip_path: Path,
164
+ config: dict[str, Any],
165
+ ) -> str:
166
+ remote_dir = config["remote_vue_dist_dir"]
167
+ remote_path = f"{remote_dir}/{zip_path.name}"
168
+
169
+ log(f"上传更新包 -> {remote_path}")
170
+
171
+ try:
172
+ sftp.put(str(zip_path), remote_path)
173
+ log("更新包上传成功")
174
+ except OSError as exc:
175
+ fail(f"更新包上传失败: {exc}")
176
+
177
+ return remote_path
178
+
179
+
180
+ def update_manifest_entries(
181
+ manifest: dict[str, dict[str, float | int]],
182
+ entries: list[UpdateEntry],
183
+ ) -> dict[str, dict[str, float | int]]:
184
+ for entry in entries:
185
+ manifest[relative_key(entry.path)] = file_signature(entry.path)
186
+ return manifest
187
+
188
+
189
+ def get_remote_file_stats(
190
+ sftp: paramiko.SFTPClient,
191
+ remote_dir: str,
192
+ ) -> dict[str, paramiko.SFTPAttributes]:
193
+ stats: dict[str, paramiko.SFTPAttributes] = {}
194
+ try:
195
+ for attr in sftp.listdir_attr(remote_dir):
196
+ if attr.filename.endswith(".jar"):
197
+ stats[attr.filename] = attr
198
+ except FileNotFoundError:
199
+ pass
200
+ return stats
201
+
202
+
203
+ def should_upload_lib_file(
204
+ local_path: Path,
205
+ remote_attr: paramiko.SFTPAttributes | None,
206
+ manifest: dict[str, dict[str, float | int]] | None = None,
207
+ ) -> tuple[bool, str]:
208
+ if remote_attr is None:
209
+ return False, "远程不存在,跳过"
210
+
211
+ local_size = int(local_path.stat().st_size)
212
+ remote_size = int(remote_attr.st_size)
213
+ if local_size != remote_size:
214
+ return True, f"大小变化 {remote_size} -> {local_size}"
215
+
216
+ if is_project_lib_jar(local_path.name) and manifest is not None:
217
+ key = relative_key(local_path)
218
+ current = file_signature(local_path)
219
+ previous = manifest.get(key)
220
+ if previous is None:
221
+ return True, "项目模块未记录"
222
+ if int(previous["size"]) != current["size"]:
223
+ return True, "项目模块大小变化"
224
+ if float(previous["mtime"]) < current["mtime"]:
225
+ return True, "项目模块重新构建"
226
+
227
+ return False, "大小一致,跳过"
228
+
229
+
230
+ def get_mvn_executable() -> str:
231
+ candidates = ("mvn.cmd", "mvn.bat", "mvn") if sys.platform == "win32" else ("mvn",)
232
+ for name in candidates:
233
+ found = shutil.which(name)
234
+ if found:
235
+ return found
236
+ fail("未找到 mvn 命令,请确认 Maven 已安装并加入 PATH")
237
+
238
+
239
+ def run_maven_build(config: dict[str, Any]) -> None:
240
+ profile = MAVEN_PROFILE
241
+ maven_repo = get_maven_local_repo(config)
242
+ cmd = [
243
+ get_mvn_executable(),
244
+ "clean",
245
+ "package",
246
+ f"-P{profile}",
247
+ f"-Dmaven.repo.local={maven_repo}",
248
+ "-DskipTests",
249
+ ]
250
+
251
+ log(f"开始 Maven 构建: {' '.join(cmd)}")
252
+ log(f"Maven 本地仓库: {maven_repo}")
253
+ result = subprocess.run(
254
+ cmd,
255
+ cwd=PROJECT_ROOT,
256
+ )
257
+ if result.returncode != 0:
258
+ fail(f"Maven 构建失败,退出码: {result.returncode}")
259
+
260
+
261
+ def locate_lib_dir() -> Path:
262
+ target_dir = get_target_dir()
263
+ if not target_dir.exists():
264
+ fail(f"构建产物目录不存在: {target_dir}")
265
+
266
+ lib_dir = target_dir / "lib"
267
+ if not lib_dir.is_dir():
268
+ fail(f"lib 目录不存在: {lib_dir}")
269
+
270
+ lib_jars = list(lib_dir.glob("*.jar"))
271
+ if not lib_jars:
272
+ fail(f"lib 目录下没有依赖 JAR: {lib_dir}")
273
+
274
+ log(f"定位 lib 产物: {len(lib_jars)} 个")
275
+ return lib_dir
276
+
277
+
278
+ def connect_ssh(config: dict[str, Any]) -> paramiko.SSHClient:
279
+ client = paramiko.SSHClient()
280
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
281
+
282
+ log(f"连接服务器 {config['username']}@{config['host']}:{config['port']}")
283
+ connect_kwargs: dict[str, Any] = {
284
+ "hostname": config["host"],
285
+ "port": int(config["port"]),
286
+ "username": config["username"],
287
+ "password": config["password"],
288
+ "timeout": 30,
289
+ "allow_agent": False,
290
+ "look_for_keys": False,
291
+ }
292
+
293
+ try:
294
+ client.connect(**connect_kwargs)
295
+ except Exception as exc:
296
+ fail(f"SSH 连接失败: {exc}")
297
+
298
+ return client
299
+
300
+
301
+ def read_channel_stream(channel: paramiko.Channel) -> str:
302
+ """流式读取 SSH 通道输出,避免长时间命令无回显。"""
303
+ chunks: list[str] = []
304
+ while not channel.closed:
305
+ if channel.recv_ready():
306
+ data = channel.recv(4096)
307
+ if not data:
308
+ break
309
+ text = data.decode("utf-8", errors="replace")
310
+ chunks.append(text)
311
+ print(text, end="", flush=True)
312
+ elif channel.exit_status_ready():
313
+ while channel.recv_ready():
314
+ data = channel.recv(4096)
315
+ if data:
316
+ text = data.decode("utf-8", errors="replace")
317
+ chunks.append(text)
318
+ print(text, end="", flush=True)
319
+ break
320
+ else:
321
+ time.sleep(0.2)
322
+
323
+ combined = "".join(chunks)
324
+ if combined and not combined.endswith("\n"):
325
+ print()
326
+ return combined.strip()
327
+
328
+
329
+ def run_remote_command(
330
+ client: paramiko.SSHClient,
331
+ command: str,
332
+ *,
333
+ check: bool = True,
334
+ get_pty: bool = False,
335
+ stream: bool = False,
336
+ timeout_secs: float | None = None,
337
+ ) -> tuple[int, str, str]:
338
+ log(f"远程执行: {command}")
339
+ _, stdout, stderr = client.exec_command(command, get_pty=get_pty)
340
+ channel = stdout.channel
341
+ if timeout_secs is not None:
342
+ channel.settimeout(timeout_secs)
343
+
344
+ if stream:
345
+ out = read_channel_stream(channel)
346
+ err = ""
347
+ else:
348
+ out = stdout.read().decode("utf-8", errors="replace").strip()
349
+ err = "" if get_pty else stderr.read().decode("utf-8", errors="replace").strip()
350
+
351
+ exit_code = channel.recv_exit_status()
352
+
353
+ if not stream:
354
+ if out:
355
+ print(out)
356
+ if err:
357
+ print(err, file=sys.stderr)
358
+
359
+ if check and exit_code != 0:
360
+ fail(f"远程命令失败 (exit {exit_code}): {command}")
361
+
362
+ return exit_code, out, err
363
+
364
+
365
+ def extract_update_package_on_remote(
366
+ client: paramiko.SSHClient,
367
+ config: dict[str, Any],
368
+ remote_zip_path: str,
369
+ ) -> int:
370
+ """解压更新包到 lib 目录,仅覆盖远程已存在的 JAR。"""
371
+ remote_lib_dir = config["remote_lib_dir"]
372
+ quoted_zip = shlex.quote(remote_zip_path)
373
+ quoted_lib = shlex.quote(remote_lib_dir)
374
+
375
+ script = f"""
376
+ set -e
377
+ TMP=$(mktemp -d)
378
+ trap 'rm -rf "$TMP"' EXIT
379
+ unzip -oq {quoted_zip} -d "$TMP"
380
+ updated=0
381
+ while IFS= read -r -d '' src; do
382
+ name=$(basename "$src")
383
+ dest={quoted_lib}/"$name"
384
+ if [ -f "$dest" ]; then
385
+ cp -f "$src" "$dest"
386
+ echo "覆盖: $name"
387
+ updated=$((updated + 1))
388
+ else
389
+ echo "跳过(远程不存在): $name"
390
+ fi
391
+ done < <(find "$TMP" -name '*.jar' -type f -print0)
392
+ echo "UPDATED_COUNT=$updated"
393
+ """
394
+
395
+ _, out, _ = run_remote_command(client, script)
396
+ match = re.search(r"UPDATED_COUNT=(\d+)", out)
397
+ if not match:
398
+ fail(f"远程解压失败,未获取更新数量\n输出: {out or '(空)'}")
399
+
400
+ updated = int(match.group(1))
401
+ log(f"lib 解压完成: 覆盖 {updated} 个")
402
+ return updated
403
+
404
+
405
+ def springboot_output_indicates_success(action: str, combined: str) -> bool:
406
+ """springboot.sh 经 SSH 执行时 exit code 不可靠,需结合输出判断。"""
407
+ lower = combined.lower()
408
+ if action == "health":
409
+ return "健康检查通过" in combined
410
+ if action in {"start", "restart"}:
411
+ return "is starting" in combined or "is running" in lower
412
+ if action == "stop":
413
+ return (
414
+ "is stopping" in combined
415
+ or "not running" in lower
416
+ or "please check it" in lower
417
+ )
418
+ if action == "status":
419
+ return "running" in lower or "not running" in lower
420
+ return True
421
+
422
+
423
+ def run_springboot_action(
424
+ client: paramiko.SSHClient,
425
+ config: dict[str, Any],
426
+ action: str,
427
+ *args: str,
428
+ ) -> str:
429
+ if action in {"start", "restart"}:
430
+ if not args:
431
+ fail(f"远程 {action} 缺少 jar 参数")
432
+ jar = args[0].strip().splitlines()[0].strip()
433
+ if not jar:
434
+ fail(f"无效的 jar 名称: {args[0]!r}")
435
+
436
+ command = " && ".join(
437
+ [
438
+ f"cd {shlex.quote(config['remote_app_dir'])}",
439
+ " ".join(
440
+ [f"./{SPRINGBOOT_SCRIPT}", *map(shlex.quote, (action, *args))]
441
+ ),
442
+ ]
443
+ )
444
+ exit_code, out, err = run_remote_command(
445
+ client,
446
+ command,
447
+ check=False,
448
+ )
449
+ combined = f"{out}\n{err}".strip()
450
+ output_ok = springboot_output_indicates_success(action, combined)
451
+ if action == "health":
452
+ if exit_code != 0 or not output_ok:
453
+ fail(
454
+ f"健康检查失败\n"
455
+ f"命令: {command}\n输出: {combined or '(空)'}"
456
+ )
457
+ return combined
458
+ if exit_code != 0 and not output_ok:
459
+ fail(f"远程 {action} 失败: {' '.join(args)}\n{combined}")
460
+ if action in {"start", "restart"} and not output_ok:
461
+ fail(
462
+ f"远程 {action} 未成功: {' '.join(args)}\n"
463
+ f"命令: {command}\n输出: {combined or '(空)'}"
464
+ )
465
+ return combined
466
+
467
+
468
+ def get_running_jar(
469
+ client: paramiko.SSHClient,
470
+ app_dir: str,
471
+ config: dict[str, Any],
472
+ ) -> str | None:
473
+ combined = run_springboot_action(
474
+ client, config, "status", config["startup_jar"]
475
+ )
476
+ text = re.sub(r"\x1b\[[0-?]*[ -/]*[@-~]", "", combined).strip().lower()
477
+ if "not running" in text:
478
+ return None
479
+ if "running" in text:
480
+ return config["startup_jar"]
481
+ return None
482
+
483
+
484
+ def health_check_service(
485
+ client: paramiko.SSHClient,
486
+ config: dict[str, Any],
487
+ ) -> None:
488
+ port = str(int(config["health_check_port"]))
489
+ context = str(config["health_check_context"]).strip()
490
+ timeout = str(int(config["health_check_timeout"]))
491
+ log(f"健康检查: springboot.sh health {port} {context} {timeout}")
492
+ run_springboot_action(client, config, "health", port, context, timeout)
493
+
494
+
495
+ def main() -> None:
496
+ config = load_config()
497
+
498
+ log(f"=== 自动部署: {config['project_name']} ===")
499
+ log(f"配置文件: {APM_CONFIG_PATH}")
500
+ log(f"项目根目录: {PROJECT_ROOT}")
501
+
502
+ run_maven_build(config)
503
+
504
+ lib_dir = locate_lib_dir()
505
+ manifest = load_manifest()
506
+
507
+ client = connect_ssh(config)
508
+ need_restart = False
509
+ lib_upload_entries: list[UpdateEntry] = []
510
+ try:
511
+ sftp = client.open_sftp()
512
+ try:
513
+ app_dir = config["remote_app_dir"]
514
+ remote_lib_stats = get_remote_file_stats(sftp, config["remote_lib_dir"])
515
+
516
+ log("收集 JAR 更新...")
517
+ lib_upload_entries = list_lib_files_to_upload(
518
+ lib_dir,
519
+ remote_lib_stats,
520
+ manifest=manifest,
521
+ )
522
+
523
+ updated = 0
524
+ if lib_upload_entries:
525
+ zip_path = create_update_package(
526
+ lib_upload_entries,
527
+ config["package_name"],
528
+ )
529
+ remote_zip_path = upload_update_package(sftp, zip_path, config)
530
+ log("远程解压 lib 目录(仅覆盖已有 JAR)...")
531
+ updated = extract_update_package_on_remote(
532
+ client,
533
+ config,
534
+ remote_zip_path,
535
+ )
536
+ else:
537
+ log("无 JAR 需要更新,跳过更新包上传")
538
+
539
+ running_jar = get_running_jar(client, app_dir, config)
540
+ need_restart = updated > 0
541
+ if not need_restart and not running_jar:
542
+ log("服务未运行,需要启动")
543
+ need_restart = True
544
+ elif not need_restart:
545
+ log("没有文件需要更新,跳过重启")
546
+
547
+ if need_restart:
548
+ log("重启服务...")
549
+ run_springboot_action(client, config, "restart", config["startup_jar"])
550
+
551
+ health_check_service(client, config)
552
+
553
+ if lib_upload_entries:
554
+ save_manifest(update_manifest_entries(manifest, lib_upload_entries))
555
+ finally:
556
+ sftp.close()
557
+ finally:
558
+ client.close()
559
+
560
+ log("部署完成")
561
+
562
+
563
+ if __name__ == "__main__":
564
+ main()
@@ -21,7 +21,10 @@
21
21
  固定文件名: `SQL.md`
22
22
  保存位置: `.apm/sessions/<会话 ID>/docs/SQL.md`
23
23
  适用场景: 后端开发涉及 SQL 改动(DDL/DML、表结构、Mapper/XML 中 SQL 等)时必须产出或追加更新,详见 `.apm/skills/apm-dev/SKILL.md` 中「后端 SQL 变更文档」章节。
24
- 格式要求: 待执行的 SQL 语句须放在 ` ```sql ` 代码块中,按执行顺序逐条列出。
24
+ 格式要求:
25
+
26
+ - 须标注**目标数据库**(库名/实例名、类型如 MySQL;同一变更涉及多库时分别标注)
27
+ - 待执行的 SQL 语句须放在 ` ```sql ` 代码块中,按执行顺序逐条列出
25
28
 
26
29
  ## 文档同步
27
30
 
@@ -5,7 +5,7 @@
5
5
  ### 步骤 1: 获取实现计划与协作内容
6
6
 
7
7
  1. 用 **Read** 工具阅读本端计划:前端读 `.apm/sessions/<会话ID>/docs/FRONTEND-PLAN.md`,后端读 `docs/BACKEND-PLAN.md`;计划不存在则退出流程并回复说明(兼容旧流程:若存在 `PRD.md` + `FRONTEND.md` / `BACKEND.md` + `API.md`,按旧文档执行)。
8
- 2. 前端涉及接口对接时,以后端计划中的「API 契约」章节为准,**不等后端部署完成**;契约没写清的字段 `@后端` 确认,禁止自行猜测。
8
+ 2. 前端涉及接口对接时,以 `docs/API.md` 为唯一契约来源,**不等后端部署完成**;`API.md` 不存在或字段没写清时 `@后端` 补充,禁止自行猜测或在计划中重复编写接口定义。
9
9
  3. **假设门禁(开发前必须检查)**:查看计划「依据与假设」章节——
10
10
  - 「假设」仍有未确认项:**Read** `.apm/sessions/<会话ID>/messages.xml`,查找项目经理是否已回复确认;
11
11
  - 项目经理已回复:先按 `.apm/skills/apm-write-plan/SKILL.md` 步骤 5 把确认结果**回填进计划文档并同步**(确认的假设移入「依据」,否定的修订实现步骤与白名单),然后再开发;
@@ -50,6 +50,7 @@
50
50
 
51
51
  若本次改动涉及 SQL(DDL/DML、表结构、索引、数据修复、Mapper/XML 中新增或修改 SQL 语句等),开发阶段必须 **Write** `.apm/sessions/<会话ID>/docs/SQL.md`,内容包括:
52
52
 
53
+ - **目标数据库**(库名/实例名、类型如 MySQL;同一变更涉及多库时分别标注)
53
54
  - 变更摘要(改了什么表/数据、为什么)
54
55
  - 完整 SQL 语句(按执行顺序排列;**每条须放在 ` ```sql ` 代码块中**,便于复制执行)
55
56
  - 执行环境说明(测试/生产是否一致、是否需人工执行)
@@ -58,6 +59,10 @@
58
59
  示例:
59
60
 
60
61
  ````markdown
62
+ ## 目标数据库
63
+
64
+ `oxc_platform`(MySQL 8.x)
65
+
61
66
  ## 变更摘要
62
67
 
63
68
  为 inspection_class 表新增 is_project_add 字段。
@@ -84,6 +89,6 @@ ALTER TABLE inspection_class ADD COLUMN is_project_add VARCHAR(1) DEFAULT '0';
84
89
  2. **发布测试环境**:**Read** `.apm/skills/apm-deploy/SKILL.md` 并按其流程部署;**后端**涉及 SQL 变更时,须在回复中引用 `docs/SQL.md`,并写明待执行的 SQL 文件名或执行顺序。
85
90
  3. **白名单对账**:在回复中逐文件列出本次改动与计划白名单的对应关系。
86
91
 
87
- **注意:不做联调。** 前后端各自按 API 契约交付,接口对不上属于契约或实现问题,由 diff 评审与人工验收暴露后打回修复;禁止自行发起「联调」「接口实测」类的开放式动作。
92
+ **注意:不做联调。** 前后端各自按 `API.md` 交付,接口对不上属于契约或实现问题,由 diff 评审与人工验收暴露后打回修复;禁止自行发起「联调」「接口实测」类的开放式动作。
88
93
 
89
94
  完成后用 `append_message` 回复:改动概述 + 白名单对账 + 构建结果 + 测试环境地址,并 `@` 评审角色进行 diff 评审。
@@ -18,12 +18,12 @@
18
18
 
19
19
  ### 步骤 2:四项检查
20
20
 
21
- | 检查项 | 判定 |
22
- | -------------- | ------------------------------------------------------------------------------------------------------------------------- |
23
- | **白名单对账** | diff 中出现白名单之外的文件,且计划未更新说明 → **不通过** |
24
- | **需求相关性** | 存在与本需求无关的改动(顺手重构、改格式、动了无关逻辑)→ **不通过** |
25
- | **计划落实** | 计划「实现步骤」中的关键点在 diff 中找不到对应实现 → **不通过** |
26
- | **SQL 文档** | **仅后端**:diff 涉及 SQL 改动(DDL/DML、表结构、Mapper/XML 中 SQL 等),但 `docs/SQL.md` 缺失或与改动不一致 → **不通过** |
21
+ | 检查项 | 判定 |
22
+ | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
23
+ | **白名单对账** | diff 中出现白名单之外的文件,且计划未更新说明 → **不通过** |
24
+ | **需求相关性** | 存在与本需求无关的改动(顺手重构、改格式、动了无关逻辑)→ **不通过** |
25
+ | **计划落实** | 计划「实现步骤」中的关键点在 diff 中找不到对应实现 → **不通过** |
26
+ | **SQL 文档** | **仅后端**:diff 涉及 SQL 改动(DDL/DML、表结构、Mapper/XML 中 SQL 等),但 `docs/SQL.md` 缺失、未标注目标数据库、或与改动不一致 → **不通过** |
27
27
 
28
28
  注意事项:
29
29