677 lines
26 KiB
TypeScript
677 lines
26 KiB
TypeScript
import type {
|
||
CommitPlanInput,
|
||
ComponentWorkflowInput,
|
||
DbSyncPlanInput,
|
||
ExecResult,
|
||
FinishTaskInput,
|
||
PushPlanInput,
|
||
RemoteHealthCheckInput,
|
||
RepoType,
|
||
ResolvedProject,
|
||
} from "../types.js";
|
||
import { tryExecFile, tryPowerShell } from "../core/exec.js";
|
||
import { detectRepoType, parseGitStatusFiles } from "../core/project.js";
|
||
import {
|
||
getGlobalReviewCommand,
|
||
resolveProject,
|
||
toPosix,
|
||
workspaceRoot,
|
||
} from "../core/workspace.js";
|
||
import { cleanupHistoryArtifacts } from "./cleanup.js";
|
||
import {
|
||
karpathyChangedFileChecklistItem,
|
||
karpathyFinishNote,
|
||
karpathyStopCondition,
|
||
} from "./karpathy.js";
|
||
import { buildGlobalCodeReview, defaultReviewProjects } from "./review.js";
|
||
import { buildVerificationPlan } from "./verification.js";
|
||
import { buildWorkstreamCloseout } from "./closeout.js";
|
||
interface RepoSnapshot {
|
||
branch: string;
|
||
changedFiles: string[];
|
||
project: ResolvedProject;
|
||
remote: string;
|
||
repoType: RepoType;
|
||
status: string;
|
||
}
|
||
|
||
function normalizeProjectKeys(projects?: string[]): string[] {
|
||
const selected =
|
||
(projects?.length ?? 0) > 0 ? projects! : [...defaultReviewProjects];
|
||
return Array.from(new Set(selected.filter((project) => project !== "root")));
|
||
}
|
||
|
||
function formatShanghaiTimestamp(date = new Date()): string {
|
||
const parts = new Intl.DateTimeFormat("en-CA", {
|
||
day: "2-digit",
|
||
hour: "2-digit",
|
||
hour12: false,
|
||
minute: "2-digit",
|
||
month: "2-digit",
|
||
second: "2-digit",
|
||
timeZone: "Asia/Shanghai",
|
||
year: "numeric",
|
||
}).formatToParts(date);
|
||
const map = Object.fromEntries(parts.map((part) => [part.type, part.value]));
|
||
return `${map.year}${map.month}${map.day}_${map.hour}${map.minute}${map.second}`;
|
||
}
|
||
|
||
function normalizeDbIdentifier(value: string): string {
|
||
return value
|
||
.replace(/[^a-zA-Z0-9_]/g, "_")
|
||
.replace(/_+/g, "_")
|
||
.slice(0, 64);
|
||
}
|
||
|
||
async function collectRepoSnapshot(projectKey: string): Promise<RepoSnapshot> {
|
||
const project = resolveProject(projectKey);
|
||
const repoType = detectRepoType(project.path);
|
||
const status =
|
||
repoType === "git"
|
||
? await tryExecFile("git", ["status", "--short"], project.path)
|
||
: null;
|
||
const branch =
|
||
repoType === "git"
|
||
? await tryExecFile("git", ["branch", "--show-current"], project.path)
|
||
: null;
|
||
const remote =
|
||
repoType === "git"
|
||
? await tryExecFile("git", ["remote", "-v"], project.path)
|
||
: null;
|
||
|
||
return {
|
||
branch: branch?.ok ? branch.stdout : "",
|
||
changedFiles: status?.ok ? parseGitStatusFiles(status.stdout) : [],
|
||
project,
|
||
remote: remote?.ok ? remote.stdout : "",
|
||
repoType,
|
||
status: status?.ok ? status.stdout : status?.stderr || "",
|
||
};
|
||
}
|
||
|
||
function suggestCommitMessage(snapshot: RepoSnapshot): string {
|
||
const files = snapshot.changedFiles.join("\n");
|
||
|
||
if (snapshot.project.alias === "mcp") {
|
||
return "feat(workflow): 扩展KT工作流能力";
|
||
}
|
||
if (snapshot.project.alias === "api" && /qqbot/i.test(files)) {
|
||
return "fix(qqbot): 修复QQBot运行链路";
|
||
}
|
||
if (
|
||
snapshot.project.alias === "admin" &&
|
||
/\.env|authentication/.test(files)
|
||
) {
|
||
return "fix(admin): 收敛环境配置和登录页";
|
||
}
|
||
if (
|
||
snapshot.project.alias === "web" &&
|
||
/deploy\.json|\.gitignore/.test(files)
|
||
) {
|
||
return "chore(web): 清理旧部署配置";
|
||
}
|
||
if (snapshot.project.alias === "blog") {
|
||
return "fix(blog): 优化博客前台体验";
|
||
}
|
||
if (snapshot.project.alias === "knife4j") {
|
||
return "feat(knife4j): 升级接口文档插件能力";
|
||
}
|
||
return `chore(${snapshot.project.alias || "repo"}): 更新项目改动`;
|
||
}
|
||
|
||
export async function buildFinishTask(
|
||
input: FinishTaskInput = {},
|
||
): Promise<Record<string, unknown>> {
|
||
const projectKeys = normalizeProjectKeys(input.projects);
|
||
const snapshots = await Promise.all(projectKeys.map(collectRepoSnapshot));
|
||
const changedSnapshots = snapshots.filter(
|
||
(snapshot) => snapshot.changedFiles.length > 0,
|
||
);
|
||
const verificationPlans = changedSnapshots.map((snapshot) =>
|
||
buildVerificationPlan({
|
||
changeType: input.changeType || "general",
|
||
project: snapshot.project.alias || snapshot.project.relativePath,
|
||
}),
|
||
);
|
||
const validationRuns: Array<{
|
||
command: string;
|
||
project: ResolvedProject;
|
||
result: ExecResult;
|
||
}> = [];
|
||
|
||
if (input.runValidation) {
|
||
for (const plan of verificationPlans) {
|
||
for (const command of plan.commands.filter(
|
||
(item) => !item.includes("global-review"),
|
||
)) {
|
||
validationRuns.push({
|
||
command,
|
||
project: plan.project,
|
||
result: await tryPowerShell(command, plan.project.path),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
const review = await buildGlobalCodeReview({
|
||
contentScanMode: input.contentScanMode ?? "changed",
|
||
includeContentScan: true,
|
||
includeRootScan: input.includeRootScan ?? true,
|
||
maxFindingsPerProject: input.maxFindingsPerProject ?? 20,
|
||
projects: projectKeys,
|
||
});
|
||
const cleanup = cleanupHistoryArtifacts({
|
||
dryRun: !(input.cleanupHistory ?? false),
|
||
keep: input.keepHistory ?? 3,
|
||
});
|
||
const cleanupDeleted = Array.isArray(cleanup.deleted) ? cleanup.deleted : [];
|
||
const cleanupPendingCount = cleanup.dryRun ? cleanupDeleted.length : 0;
|
||
|
||
return {
|
||
cleanup,
|
||
closeout: buildWorkstreamCloseout({
|
||
changedProjects: changedSnapshots.map(
|
||
(snapshot) => snapshot.project.relativePath,
|
||
),
|
||
problemRecords: [],
|
||
reusablePatterns: ["none"],
|
||
stableSolutions: [],
|
||
title: "kt_finish_task 收尾审计",
|
||
verificationEvidence: validationRuns
|
||
.filter((item) => item.result.ok)
|
||
.map((item) => `${item.project.relativePath}: ${item.command}`),
|
||
}),
|
||
mode: input.runValidation ? "validation_executed" : "plan_only",
|
||
notes: [
|
||
"默认只生成收尾结果;runValidation=true 时才执行验证命令。",
|
||
"cleanupHistory=true 时才真实清理历史产物,否则仅预览;预览中 deleted>0 说明测试/验证历史还未满足最近 3 轮规则。",
|
||
"提交和推送仍需用户明确要求后执行。",
|
||
karpathyFinishNote,
|
||
],
|
||
review,
|
||
snapshots,
|
||
stopConditions: [
|
||
"review.findings 存在 P1/P2 时先判定真实风险或工具误报;真实风险修代码,工具误报修 ktWorkflow,再复跑 kt_global_code_review。",
|
||
"validationRuns 中任一 result.ok=false 时先处理失败命令。",
|
||
"cleanup.dryRun=true 且 cleanup.deleted 非空时,先执行 kt_cleanup_history dryRun=false 或 pnpm run cleanup-history -- --execute,再报告测试/验证历史已合规。",
|
||
"发现真实 env、生产 Secret 或非本次范围文件时停止提交。",
|
||
karpathyStopCondition,
|
||
],
|
||
summary: {
|
||
changedProjectCount: changedSnapshots.length,
|
||
changedProjects: changedSnapshots.map(
|
||
(snapshot) => snapshot.project.relativePath,
|
||
),
|
||
cleanupPendingCount,
|
||
validationRunCount: validationRuns.length,
|
||
},
|
||
validationRuns,
|
||
verificationPlans,
|
||
};
|
||
}
|
||
|
||
export async function buildCommitPlan(
|
||
input: CommitPlanInput = {},
|
||
): Promise<Record<string, unknown>> {
|
||
const snapshots = await Promise.all(
|
||
normalizeProjectKeys(input.projects).map(collectRepoSnapshot),
|
||
);
|
||
const changedSnapshots = snapshots.filter(
|
||
(snapshot) => snapshot.changedFiles.length > 0,
|
||
);
|
||
|
||
return {
|
||
checks: [
|
||
"每个独立 Git 仓库单独提交。",
|
||
karpathyChangedFileChecklistItem,
|
||
"提交前先跑对应轻量验证和 kt_global_code_review。",
|
||
"kt_global_code_review findings 必须为 0;确认误报时先修 ktWorkflow 规则,不把误报当作长期上下文。",
|
||
"后端真实 .env.development/.env.production 不提交;前端 env 可提交但只能包含客户端公开配置。",
|
||
"KT 根目录是本地治理仓库;如没有 remote,只本地提交并在最终回复说明。",
|
||
],
|
||
includePush: input.includePush ?? false,
|
||
projects: changedSnapshots.map((snapshot) => {
|
||
const message = input.message || suggestCommitMessage(snapshot);
|
||
return {
|
||
branch: snapshot.branch,
|
||
changedFiles: snapshot.changedFiles,
|
||
commands: [
|
||
"git status --short",
|
||
"git add -A",
|
||
`git commit -m "${message}"`,
|
||
...(input.includePush
|
||
? [`git push origin ${snapshot.branch || "main"}`]
|
||
: []),
|
||
],
|
||
message,
|
||
project: snapshot.project,
|
||
status: snapshot.status,
|
||
};
|
||
}),
|
||
reviewCommand: getGlobalReviewCommand(),
|
||
summary: {
|
||
changedProjectCount: changedSnapshots.length,
|
||
totalChangedFiles: changedSnapshots.reduce(
|
||
(count, snapshot) => count + snapshot.changedFiles.length,
|
||
0,
|
||
),
|
||
},
|
||
};
|
||
}
|
||
|
||
export async function buildPushPlan(
|
||
input: PushPlanInput = {},
|
||
): Promise<Record<string, unknown>> {
|
||
const snapshots = await Promise.all(
|
||
normalizeProjectKeys(input.projects).map(collectRepoSnapshot),
|
||
);
|
||
const remote = input.remote || "origin";
|
||
|
||
return {
|
||
checks: [
|
||
"推送前工作区应干净;如仍有改动,先提交或说明为什么暂不提交。",
|
||
"推送前复跑 kt_global_code_review,findings 必须为 0;如出现误报先升级 ktWorkflow 规则。",
|
||
"如果 Jenkins/Gitea host key、mirror、hooks 异常,先修远程仓库状态再重试。",
|
||
],
|
||
projects: snapshots
|
||
.filter((snapshot) => snapshot.repoType === "git")
|
||
.map((snapshot) => {
|
||
const branch = input.branch || snapshot.branch || "main";
|
||
return {
|
||
branch,
|
||
commands: [
|
||
"git status --short",
|
||
`git log --oneline ${remote}/${branch}..${branch}`,
|
||
`git push ${remote} ${branch}`,
|
||
],
|
||
project: snapshot.project,
|
||
remote: snapshot.remote,
|
||
status: snapshot.status,
|
||
};
|
||
}),
|
||
reviewCommand: getGlobalReviewCommand(),
|
||
};
|
||
}
|
||
|
||
function buildRemoteHealthCommands(input: RemoteHealthCheckInput = {}): Array<{
|
||
command: string;
|
||
service: string;
|
||
}> {
|
||
const host =
|
||
input.sshTarget ||
|
||
input.host ||
|
||
process.env.KT_NAS_SSH_TARGET ||
|
||
"nas.kwitsukasa.top";
|
||
const port = input.port ?? Number(process.env.KT_NAS_SSH_PORT || 2202);
|
||
const ssh = (remoteCommand: string) =>
|
||
`ssh -p ${port} ${host} "${remoteCommand}"`;
|
||
const allCommands: Record<string, string> = {
|
||
docker: ssh("docker ps --format '{{.Names}} {{.Status}} {{.Ports}}'"),
|
||
gitea: ssh(
|
||
"docker ps --format '{{.Names}} {{.Status}}' | grep -i gitea || true",
|
||
),
|
||
jenkins: ssh(
|
||
"docker ps --format '{{.Names}} {{.Status}}' | grep -i jenkins || true",
|
||
),
|
||
k3d: ssh(
|
||
"kubectl get pods -A -o wide 2>/dev/null || k3d cluster list 2>/dev/null || true",
|
||
),
|
||
minio: ssh(
|
||
"docker ps --format '{{.Names}} {{.Status}} {{.Ports}}' | grep -i minio || true",
|
||
),
|
||
mosquitto: ssh(
|
||
"docker ps --format '{{.Names}} {{.Status}} {{.Ports}}' | grep -i mosquitto || true",
|
||
),
|
||
mysql: ssh(
|
||
"docker ps --format '{{.Names}} {{.Status}} {{.Ports}}' | grep -Ei 'mysql|mariadb' || true",
|
||
),
|
||
napcat: ssh(
|
||
"docker ps --format '{{.Names}} {{.Status}} {{.Ports}}' | grep -Ei 'napcat|qqbot' || true",
|
||
),
|
||
ssh: ssh("hostname && date"),
|
||
};
|
||
const services =
|
||
(input.services?.length ?? 0) > 0
|
||
? input.services!
|
||
: Object.keys(allCommands);
|
||
|
||
return services
|
||
.filter((service) => allCommands[service])
|
||
.map((service) => ({
|
||
command: allCommands[service],
|
||
service,
|
||
}));
|
||
}
|
||
|
||
export async function buildRemoteHealthCheck(
|
||
input: RemoteHealthCheckInput = {},
|
||
): Promise<Record<string, unknown>> {
|
||
const commands = buildRemoteHealthCommands(input);
|
||
const results = input.execute
|
||
? await Promise.all(
|
||
commands.map(async (item) => ({
|
||
...item,
|
||
result: await tryPowerShell(item.command, workspaceRoot),
|
||
})),
|
||
)
|
||
: [];
|
||
|
||
return {
|
||
commands,
|
||
execute: input.execute ?? false,
|
||
notes: [
|
||
"默认 execute=false,只生成只读探测命令。",
|
||
"远程命令只做 hostname/date/docker ps/kubectl get 等只读检查。",
|
||
"真实密码、token、Secret 不从仓库读取,也不写入输出。",
|
||
],
|
||
results,
|
||
};
|
||
}
|
||
|
||
export function buildDbSyncPlan(
|
||
input: DbSyncPlanInput = {},
|
||
): Record<string, unknown> {
|
||
const source = input.source || "local";
|
||
const target = input.target || "remote";
|
||
const backupName = normalizeDbIdentifier(
|
||
input.backupName ||
|
||
`kt_template_backup_${formatShanghaiTimestamp()}_${Math.random().toString(36).slice(2, 8)}`,
|
||
);
|
||
const dumpPath = `.kt-workspace/db-sync/${backupName}/kt_template.sql`;
|
||
|
||
return {
|
||
backupName,
|
||
commands: [
|
||
"从 Node/kt-template-online-api/.env.development 和 .env.production 读取连接信息,只在本机 shell 环境使用,不写入 Git。",
|
||
"KT 当前约定:127.0.0.1:3306 是本地 MySQL,127.0.0.1:3307 是远程 NAS 打洞连接;3307 不通时先建立 ssh -N -L 127.0.0.1:3307:127.0.0.1:3306 nas。",
|
||
`mkdir -p .kt-workspace/db-sync/${backupName}`,
|
||
`mysqldump --set-gtid-purged=OFF --single-transaction --routines --triggers <source-conn> kt_template > ${dumpPath}`,
|
||
`mysql <target-conn> -e "CREATE DATABASE ${backupName} DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"`,
|
||
`mysqldump --set-gtid-purged=OFF --single-transaction --routines --triggers <target-conn> kt_template | mysql <target-conn> ${backupName}`,
|
||
`mysql <target-conn> kt_template < ${dumpPath}`,
|
||
"对比源库/目标库表数量、视图/函数/过程数量和关键表行数。",
|
||
`验证通过后再执行:mysql <target-conn> -e "DROP DATABASE ${backupName};"`,
|
||
],
|
||
guardrails: [
|
||
"默认只生成计划,不执行覆盖、drop、truncate。",
|
||
"同步前必须确认 source/target 方向,不能靠变量名猜。",
|
||
"3306/3307 端口方向先用只读 SELECT 预检确认,避免把远程库误当成本地库。",
|
||
"目标库备份校验通过前不得删除备份库。",
|
||
"涉及生产库时优先只读验证,批量删除必须二次确认。",
|
||
],
|
||
source,
|
||
target,
|
||
verification: [
|
||
"SHOW TABLES 数量一致。",
|
||
"admin_user、admin_menu、qqbot_account、qqbot_message_log 等关键表行数符合预期。",
|
||
"应用登录和核心接口 smoke 通过。",
|
||
"全局 review findings=0。",
|
||
],
|
||
};
|
||
}
|
||
|
||
export function buildComponentWorkflow(
|
||
input: ComponentWorkflowInput = {},
|
||
): Record<string, unknown> {
|
||
const target = input.target || "KtTable";
|
||
const workflows = {
|
||
AdminAuth: {
|
||
antiPatterns: [
|
||
"无 token 时主动 refresh",
|
||
"跨端回跳时清空 Admin 已有授权态",
|
||
"把 redirect 写丢或重复编码",
|
||
],
|
||
files: [
|
||
"Vue/kt-template-admin/apps/web-antdv-next/src/store/auth.ts",
|
||
"Vue/kt-template-online-web/src/api/auth.ts",
|
||
"Vue/kt-template-online-playground/src/api/auth.ts",
|
||
],
|
||
guardrails: [
|
||
"先画清楚 Admin/API/Web/Playground token 链路",
|
||
"401 时先判断旧 token 是否存在",
|
||
"Admin 已登录时直接带回授权态",
|
||
],
|
||
verification: [
|
||
"清空 token 测无循环",
|
||
"Admin 已登录测直接回跳",
|
||
"刷新页面测持久化",
|
||
],
|
||
},
|
||
BlogArgon: {
|
||
antiPatterns: [
|
||
"使用具名 id 控制 DOM",
|
||
"SCSS 类名逃出 kt-blog 命名空间",
|
||
"主题变量散落在 scss 文件",
|
||
"亮色模式仍套暗色遮罩",
|
||
],
|
||
files: [
|
||
"Vue/kt-blog-web/src/hooks/useBlogTheme.ts",
|
||
"Vue/kt-blog-web/src/styles",
|
||
"Vue/kt-blog-web/src/components/blog",
|
||
],
|
||
guardrails: [
|
||
"样式统一 kt-blog BEM 命名空间",
|
||
"主题变量收敛到 useBlogTheme",
|
||
"动画用 Vue Transition",
|
||
"和线上 Argon DOM/视觉逐项对照",
|
||
],
|
||
verification: [
|
||
"亮/暗模式切换",
|
||
"搜索 Enter 和动画",
|
||
"文章切换动画",
|
||
"无感知滚动加载",
|
||
"背景图滚动到底部不断层",
|
||
],
|
||
},
|
||
Knife4jSwagger: {
|
||
antiPatterns: [
|
||
"视觉只大致相似",
|
||
"TSX 里用 function render helper",
|
||
"TabPane 默认插槽不显式传 default",
|
||
"把请求入参编辑拆成无法复用的零散状态",
|
||
],
|
||
files: [
|
||
"Plugins/knife4j-swagger-vue3/packages/knife4j-vue3-ui",
|
||
"Plugins/knife4j-swagger-vue3/packages/openapi-parser",
|
||
"Plugins/knife4j-swagger-vue3/apps/playground",
|
||
],
|
||
guardrails: [
|
||
"以 Knife4j 原始 UI 为 1:1 视觉源",
|
||
"全部 Vue 视图使用 TSX",
|
||
"嵌套组件 slot 显式 default",
|
||
"路由使用 hash path 参数,不用 query 兼容旧逻辑",
|
||
"调试台按 OpenAPI 标准合并 path/query/header/body/auth/global 参数",
|
||
],
|
||
verification: [
|
||
"pnpm run typecheck",
|
||
"真实后端 48085 文档页面",
|
||
"控制台 warning/error 检查",
|
||
"Body/Headers/Auth/全局参数发送链路",
|
||
"小宽度 @media 与左侧调试栏固定",
|
||
],
|
||
},
|
||
KtTable: {
|
||
antiPatterns: [
|
||
"自定义纵向滚动条覆盖 Antdv 原生滚动",
|
||
"scroll/resize 高频写响应式状态",
|
||
"表格高度参与展开收起动画",
|
||
"固定列透明导致透视",
|
||
],
|
||
files: [
|
||
"Vue/kt-template-admin/apps/web-antdv-next/src/components/ktTable",
|
||
],
|
||
guardrails: [
|
||
"优先使用 antdv-next 原生 table 能力",
|
||
"统计列固定到底部但不参与表单动画",
|
||
"序号列和操作列固定最小/最大宽",
|
||
"resize 用 RAF 节流和非深度监听",
|
||
],
|
||
verification: [
|
||
"横向滚动性能",
|
||
"纵向滚动表头对齐",
|
||
"列宽/行高 resize",
|
||
"summary 固定底部",
|
||
"宽屏无无意义横向滚动条",
|
||
],
|
||
},
|
||
QQBot: {
|
||
antiPatterns: [
|
||
"把 NapCat token 写进仓库",
|
||
"删除账号不清理专属容器",
|
||
"白名单/黑名单只做前端过滤",
|
||
"远程服务不可达时伪装成业务成功",
|
||
"更新登录时用旧 isLogin 状态短路",
|
||
],
|
||
files: [
|
||
"Node/kt-template-online-api/src/qqbot",
|
||
"Vue/kt-template-admin/apps/web-antdv-next/src/views/qqbot",
|
||
"ci/fnos-k8s/deploy-qqbot-runtime.ps1",
|
||
],
|
||
guardrails: [
|
||
"OneBot 反向 WS token 校验",
|
||
"权限名单底层先走 QQ号过滤",
|
||
"群/频道精确 QQ 过滤由开关控制",
|
||
"扫码取消/过期/失败清理未绑定容器",
|
||
"更新登录走 SSE 进度,后台重置登录态后再生成二维码",
|
||
],
|
||
verification: [
|
||
"账号在线",
|
||
"扫码新增/更新登录",
|
||
"scan/events 每步进度",
|
||
"自动回复规则命中",
|
||
"权限名单三类过滤",
|
||
"消息日志和发送日志闭环",
|
||
],
|
||
},
|
||
FF14Plugin: {
|
||
antiPatterns: [
|
||
"把 boss/服务器/职业映射硬编码到插件里",
|
||
"FFLogs 英文任务名直接回显给中文用户",
|
||
"外部 API 失败时吞掉错误上下文",
|
||
"字典缺失时返回空白结果",
|
||
],
|
||
files: [
|
||
"Node/kt-template-online-api/src/qqbot/plugins/ff14Market",
|
||
"Node/kt-template-online-api/src/qqbot/plugins/fflogs",
|
||
"Node/kt-template-online-api/src/admin/dict",
|
||
"Vue/kt-template-admin/apps/web-antdv-next/src/views/system/dict",
|
||
],
|
||
guardrails: [
|
||
"FF14 查价和 FFLogs 映射优先走字典表维护",
|
||
"中文任务名查询和回显都要经过字典",
|
||
"命令注册表和在线命令列表同步验证",
|
||
"保留最近 logs 的颜色/评分/dps/adps/rdps/ndps/hps 字段语义",
|
||
],
|
||
verification: [
|
||
"/logs 角色 服务器 高难任务中文名",
|
||
"/logs 角色 服务器 最近10次",
|
||
"FF14 查价中文物品名",
|
||
"字典管理页面可维护映射",
|
||
],
|
||
},
|
||
FnosK8s: {
|
||
antiPatterns: [
|
||
"把 fnOS 原生面板改成 Docker 应用",
|
||
"在 ui/config 显式配置公网端口",
|
||
"提交 _build/_dist 打包产物",
|
||
"只测桌面视口不测 fnOS 内嵌窗口和窄屏",
|
||
"用非官方或旧缓存图标替代 Kubernetes 官方图标",
|
||
],
|
||
files: [
|
||
"Plugins/fnos-k8s-dashboard-fpk/src/server.ts",
|
||
"Plugins/fnos-k8s-dashboard-fpk/src/ui",
|
||
"Plugins/fnos-k8s-dashboard-fpk/apps/kt-k8s-dashboard/fnos",
|
||
"Plugins/fnos-k8s-dashboard-fpk/scripts",
|
||
],
|
||
guardrails: [
|
||
"保持非 Docker .fpk 形态",
|
||
"Web 面板源码只放 src/ui,由打包脚本复制进 app.tgz",
|
||
"fnOS 桌面入口使用 ui/config iframe 和 /kt-k8s-dashboard/ 代理路径",
|
||
"应用标题与图标保持 Kubernetes 官方语义",
|
||
"集群配置文件只作为运行时数据,不提交真实 kubeconfig",
|
||
],
|
||
verification: [
|
||
"bun run typecheck",
|
||
"bun run build:fpk",
|
||
"bun run verify:fpk",
|
||
"本地 /api/health 和首页请求",
|
||
"Playwright 桌面与 390px 窄屏截图",
|
||
"线上应用中心/桌面入口只在用户允许远程测试时验证",
|
||
],
|
||
},
|
||
NapCatLogin: {
|
||
antiPatterns: [
|
||
"更新登录复用已过期二维码 URL",
|
||
"前端等待长接口导致超时",
|
||
"手动刷新每次重启容器",
|
||
"后端拿旧 login state 判断成功",
|
||
"把 ACCOUNT 当成登录凭据,忽略 QQ 历史登录态、账号密码或扫码兜底",
|
||
"在请求日志、Loki 或列表接口里暴露 QQ 登录密码",
|
||
"把 proofWaterUrl 当作验证码完成链接,或伪造 ticket/randstr 继续登录",
|
||
],
|
||
files: [
|
||
"Node/kt-template-online-api/src/qqbot/account",
|
||
"Node/kt-template-online-api/src/qqbot/napcat",
|
||
"Vue/kt-template-admin/apps/web-antdv-next/src/views/qqbot",
|
||
],
|
||
guardrails: [
|
||
"创建账号和更新登录共用同一套二维码生成事实源",
|
||
"更新登录顺序固定为 ACCOUNT/-q 快速登录 -> 账号保存的密码登录 -> reset runtime login state 并生成二维码",
|
||
"看门狗自动登录只跑 quick -> password,扫码必须由管理员手动触发",
|
||
"账号登录密码前端只传 RSA-OAEP 加密字段 encryptedLoginPassword,后端用 QQBOT_ACCOUNT_SECRET_KEY 二次加密落库,列表不回显",
|
||
"ACCOUNT 只指定目标账号;免扫码优先依赖数据卷历史登录态,密码登录失败或触发强制扫码时再进二维码",
|
||
"密码登录触发验证码时保持 scan session pending,Admin 通过 TencentCaptcha 拿 ticket/randstr/sid,再由 API 调用同一 NapCat 容器 /api/QQLogin/CaptchaLogin",
|
||
"密码登录验证码日志窗口必须从 NAPCAT_QUICK_PASSWORD 环境重建前开始,覆盖重建容器和后续 restart,避免 proofWaterUrl 被清理前漏抓",
|
||
"如果验证码状态先于 Docker 日志 URL 到达,必须短窗口轮询当前容器日志拿 proofWaterUrl,再进入清理或失败兜底",
|
||
"API Pod SSH 读取验证码 Docker 日志不能复用 5 秒运行态快检超时,默认读取超时不得低于 15 秒",
|
||
"验证码 pending、cancel、expire、success 和失败终态都必须清理临时密码环境变量,清理失败要阻断成功或二维码兜底",
|
||
"SSE 返回 step/status/qrcode/error",
|
||
"容器重启只在必要时触发,优先直接通信 NapCat API",
|
||
],
|
||
verification: [
|
||
"账号被踢下线后点击更新登录",
|
||
"docker inspect 确认已绑定容器存在 ACCOUNT=<selfId>",
|
||
"docker restart 后 WebUI CheckLoginStatus/GetQQLoginInfo 验证 isLogin/online/uin;失败时检查 NapCat 日志是否为无历史登录记录、密码登录失败或需扫码验证",
|
||
"密码验证码触发时用 watcher 或 scan/status 证明 API 返回 captcha pending 且不暴露完整 proofWaterUrl",
|
||
"验证码状态先到但 URL 滞后时,Jest 需要证明短轮询后仍保持 captcha pending,不进入二维码或 error",
|
||
"NAS 本地日志已有验证码但 API 仍无 captchaUrl 时,必须检查 API Pod SSH 日志读取是否超时",
|
||
"scan/events 能看到 quick-login-start/quick-login-fallback/password-login-start/password-login-fallback/重置/生成二维码/等待扫码/成功或失败",
|
||
"目标 Jest 覆盖 quick 成功、password 成功、password 失败进二维码、验证码 CaptchaLogin 回传、密文保存、容器 env 注入和清理失败阻断",
|
||
"手动刷新不超时",
|
||
"过期二维码不再复用",
|
||
],
|
||
},
|
||
SystemLog: {
|
||
antiPatterns: [
|
||
"把真实 Loki 凭据写入仓库",
|
||
"只改 Dockerfile 不校验线上 env/Secret",
|
||
"日志时间格式前后端不一致",
|
||
"global-review 把历史脱敏占位符或中文显示标签当作真实凭据输出",
|
||
],
|
||
files: [
|
||
"Node/kt-template-online-api/src/common/logger",
|
||
"Node/kt-template-online-api/src/admin/system-log",
|
||
"Vue/kt-template-admin/apps/web-antdv-next/src/views/system/log",
|
||
"mcp/ktWorkflow/src/tools/review.ts",
|
||
],
|
||
guardrails: [
|
||
"pino + Loki env 走 Jenkins 私有 env 或 K8s Secret",
|
||
"后端时间字段经 DTO/Entity 装饰器统一 YYYY-MM-DD HH:mm:ss",
|
||
"系统日志页面走 /system/logs 系列接口",
|
||
"review 默认只对变更文件做 P2/P3 内容扫描,确认误报要沉淀到 review.ts",
|
||
],
|
||
verification: [
|
||
"API typecheck/lint/test",
|
||
"Loki /ready 和 query_range",
|
||
"Admin #/system/logs 无 console error",
|
||
"global-review findings 不含已知脱敏占位符或中文显示标签误报",
|
||
],
|
||
},
|
||
} satisfies Record<string, Record<string, unknown>>;
|
||
|
||
return {
|
||
target,
|
||
workflow: workflows[target],
|
||
};
|
||
}
|