name: Purge Twins (删测试分身) # 按名字/slug 删掉指定分身(测试号留下的垃圾数据)。 # # 为什么要走工作流:生产库在共享 ECS 上、不对外开放,本地连不上。 # 为什么不手写 deleteOne:分身的数据散在 12+ 个集合里(知识/记忆/消息/会话/分享/ # 作品/问答卡/课堂参与/排期报名/付费解锁/钱包/大师档案…)。这里【复用后端自己的 # deleteTwin()】,跟用户在页面上点"删除分身"走完全同一条级联,不留孤儿数据。 # # 默认 dry_run=true:先只打印"会删掉谁、连带多少条数据",确认无误再关掉 dry_run 真删。 on: workflow_dispatch: inputs: targets: # 已预填老板确认过的 5 个测试分身(竹子=测试号4 的私密分身;其余 4 个是挂在广场上的垃圾)。 # ⚠ 不要往里加 夏晚/陈九/林医生/阿May/阿正/大鹏/林小满/小野/苏晴/老周 —— # 那 10 个是 scripts/seed-masters.js 种的【演示分身】,是广场的门面,删了广场就空了。 description: '要删的分身,填 slug 或显示名,逗号分隔' required: true default: '竹子,1234,社交测试89tuu7ds,11-2,12' type: string dry_run: description: '试运行:只打印会删什么,不真删(想先看一眼就勾上)' required: false default: false type: boolean jobs: purge: runs-on: ubuntu-latest steps: - name: Purge uses: appleboy/ssh-action@v1 with: host: ${{ secrets.ECS_HOST }} username: ${{ secrets.ECS_USER }} password: ${{ secrets.ECS_PASSWORD }} command_timeout: 10m envs: TARGETS,DRY_RUN script: | set -e cd /var/www/digitalme-api.invoratec.cn [ -f .env ] || { echo "❌ 生产 .env 缺失,中止"; exit 1; } TARGETS="${{ inputs.targets }}" DRY_RUN="${{ inputs.dry_run }}" node --input-type=module -e ' import { connectDb, getDb } from "./src/db.js"; import { deleteTwin } from "./src/services/twin.js"; const dry = String(process.env.DRY_RUN) !== "false"; const names = String(process.env.TARGETS || "").split(",").map(s => s.trim()).filter(Boolean); if (!names.length) { console.log("没填 targets,什么都不做"); process.exit(0); } await connectDb(); const db = getDb(); // slug 或 displayName 精确匹配(不做模糊,免得误伤同名的真实用户)。 const twins = await db.collection("twins") .find({ $or: [{ slug: { $in: names } }, { displayName: { $in: names } }] }) .toArray(); const found = new Set(twins.flatMap(t => [t.slug, t.displayName])); for (const n of names) if (!found.has(n)) console.log(`⚠ 没找到:「${n}」(跳过)`); if (!twins.length) { console.log("一个都没匹配上,退出"); process.exit(0); } console.log(dry ? "\n=== 试运行(不会真删) ===\n" : "\n=== 真删 ===\n"); for (const t of twins) { const owner = t.ownerId ? await db.collection("users").findOne({ _id: t.ownerId }) : null; // 顺带报一下这个分身身上挂了多少数据,好确认"确实是个空壳测试号"。 const [kn, msg, mem] = await Promise.all([ db.collection("knowledge").countDocuments({ twinId: t._id }), db.collection("messages").countDocuments({ twinId: t._id }), db.collection("memories").countDocuments({ twinId: t._id }), ]); console.log(`【${t.displayName}】slug=${t.slug} 公开=${!!t.isPublic}`); console.log(` 主人: ${owner?.email || owner?.phone || "(无主/已注销)"} 建于: ${t.createdAt?.toISOString?.().slice(0, 10) || "?"}`); console.log(` 知识 ${kn} 条 / 对话 ${msg} 条 / 记忆 ${mem} 条`); if (dry) { console.log(" → 试运行,未删\n"); continue; } await deleteTwin(t._id.toString(), t.ownerId); console.log(" → 已删(级联清干净)\n"); } console.log(dry ? "试运行结束。确认无误后,把 dry_run 关掉再跑一次即可真删。" : `完成,删除 ${twins.length} 个分身。`); process.exit(0); '