Commit 21dc02c33326927338dc6ce42f41a3ee3fc2366f

Authored by yibo
1 parent b4056f4b

Add T39: playback management + operator blacklist (M.11)

New blacklist store (dressup-store.js) — explicitly distinct from T32's
automated face-blacklist model and T26's garment moderation, per M.11's
own warning. isBlacklisted(targetType, targetId) is checked at every
submission entry point (/api/dance/pick, both paid upload endpoints —
rejected before any spend, matching M.11's "不进流水线、不扣积分") and
filters GET /api/dance/wardrobe and pickNextDanceClip's random-fill
pool (extending T36's expired-character filter to also drop
blacklisted garments/characters/generated_videos) — records are never
deleted, just excluded from future selection, same "记录保留" spirit as
T36.

Admin CRUD: GET/POST /api/admin/blacklist, DELETE
/api/admin/blacklist/:id — creation attributed via adminActorUserId
(T30), same null-over-the-legacy-ADMIN_PASSWORD-channel behavior as
credit/adjust.

Playback management (需求文档 6.5 节): POST
/api/admin/dance/schedule/pin inserts an existing clip into the very
next slot (factored T34's priorityInbox-insert logic into a shared
insertClipNext() so both call sites move in lockstep); POST
.../schedule/skip advances past whatever's currently playing; POST
/api/admin/dance/pause freezes only the random-fill loop (paid_boost
content still drains immediately even while paused — 需求文档 6.5 节
frames "暂停自动填充" as filler-specific, not a full stop).

dance-admin-ops.test.js covers blacklist CRUD/attribution, entry-point
rejection for both a blacklisted garment (pick) and a blacklisted
user (upload), pin/skip changing the schedule, and pause/resume
against a deliberately drained-to-stable-size schedule (comparing exact
lengths — comparing clip ids/count directly was flaky since a small
mock pool can legitimately re-pick the same clip after a skip).

This completes dev-plan-5.md (T31-T39) — 需求文档 9 节's fifth and final
planned phase.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DUiYs6UjxDMkfvnjtsKFo
dance-admin-ops.test.js 0 → 100644
  1 +// Integration test for T39 (playback management + operator blacklist),
  2 +// dev-plan-5.md. Same mock-H3-Console approach as dance-mode2/3.test.js.
  3 +const test = require("node:test");
  4 +const assert = require("node:assert/strict");
  5 +const fs = require("node:fs");
  6 +const path = require("node:path");
  7 +const os = require("node:os");
  8 +const http = require("node:http");
  9 +const { spawn } = require("node:child_process");
  10 +
  11 +const PORT = 34934 + Math.floor(Math.random() * 1000);
  12 +const BASE = `http://localhost:${PORT}`;
  13 +const ADMIN_PASSWORD = "test-password-only-for-this-run";
  14 +let adminCookie, child, cwd, mockH3, mockH3Port, videoPollCounts, defaultCharacter;
  15 +
  16 +function waitForServer(url, timeoutMs = 10000) {
  17 + const deadline = Date.now() + timeoutMs;
  18 + return (async function poll() {
  19 + while (Date.now() < deadline) {
  20 + try {
  21 + await fetch(url);
  22 + return;
  23 + } catch {
  24 + await new Promise((r) => setTimeout(r, 100));
  25 + }
  26 + }
  27 + throw new Error("server did not come up in time");
  28 + })();
  29 +}
  30 +function sessionCookie(res) {
  31 + return (res.headers.get("set-cookie") || "").split(";")[0];
  32 +}
  33 +function authed(cookie, opts = {}) {
  34 + return { ...opts, headers: { "Content-Type": "application/json", Cookie: cookie, ...opts.headers } };
  35 +}
  36 +
  37 +function startMockH3() {
  38 + videoPollCounts = new Map();
  39 + let nextId = 1;
  40 + return new Promise((resolve) => {
  41 + mockH3 = http.createServer((req, res) => {
  42 + req.url = req.url.replace(/^\/+/, "/");
  43 + const chunks = [];
  44 + req.on("data", (c) => chunks.push(c));
  45 + req.on("end", () => {
  46 + if (req.method === "POST" && req.url === "/v1/assets") {
  47 + const id = `asset-${nextId++}`;
  48 + res.writeHead(200, { "Content-Type": "application/json" });
  49 + res.end(JSON.stringify({ id, url: `http://mock-h3/asset/${id}`, kind: "image" }));
  50 + return;
  51 + }
  52 + if (req.method === "POST" && req.url === "/v1/videos") {
  53 + const id = `job-${nextId++}`;
  54 + res.writeHead(200, { "Content-Type": "application/json" });
  55 + res.end(JSON.stringify({ id }));
  56 + return;
  57 + }
  58 + const pollMatch = req.method === "GET" && req.url.match(/^\/v1\/videos\/(.+)$/);
  59 + if (pollMatch) {
  60 + const id = pollMatch[1];
  61 + const seen = (videoPollCounts.get(id) || 0) + 1;
  62 + videoPollCounts.set(id, seen);
  63 + if (seen < 2) {
  64 + res.writeHead(200, { "Content-Type": "application/json" });
  65 + res.end(JSON.stringify({ id, status: "generating" }));
  66 + return;
  67 + }
  68 + res.writeHead(200, { "Content-Type": "application/json" });
  69 + res.end(JSON.stringify({ id, status: "done", partition: "ref2va", req: { width: 480, height: 832, frames: 240 } }));
  70 + return;
  71 + }
  72 + res.writeHead(404, { "Content-Type": "application/json" });
  73 + res.end(JSON.stringify({ error: "not found in mock" }));
  74 + });
  75 + });
  76 + mockH3.listen(0, () => {
  77 + mockH3Port = mockH3.address().port;
  78 + resolve();
  79 + });
  80 + });
  81 +}
  82 +
  83 +async function waitUntil(fn, timeoutMs = 8000, intervalMs = 100) {
  84 + const deadline = Date.now() + timeoutMs;
  85 + while (Date.now() < deadline) {
  86 + const v = await fn();
  87 + if (v) return v;
  88 + await new Promise((r) => setTimeout(r, intervalMs));
  89 + }
  90 + throw new Error("condition not met in time");
  91 +}
  92 +
  93 +async function registerVerifiedUser(base) {
  94 + const email = `adminops-${Date.now()}-${Math.random().toString(36).slice(2)}@example.com`;
  95 + const registerRes = await fetch(`${base}/api/auth/register`, {
  96 + method: "POST",
  97 + headers: { "Content-Type": "application/json" },
  98 + body: JSON.stringify({ email, password: "correct-horse-battery" }),
  99 + });
  100 + const { devVerifyUrl } = await registerRes.json();
  101 + const cookie = sessionCookie(registerRes);
  102 + await fetch(`${base}${devVerifyUrl}`);
  103 + return cookie;
  104 +}
  105 +
  106 +test.before(async () => {
  107 + await startMockH3();
  108 + cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dance-admin-ops-"));
  109 + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js", "pricing.js"]) {
  110 + fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f));
  111 + }
  112 + fs.mkdirSync(path.join(cwd, "uploads"), { recursive: true });
  113 + fs.writeFileSync(path.join(cwd, "uploads", "person.png"), "fake-png-bytes");
  114 + fs.writeFileSync(path.join(cwd, "uploads", "garment.png"), "fake-png-bytes");
  115 + fs.writeFileSync(path.join(cwd, "uploads", "ref.mp4"), "fake-mp4-bytes");
  116 +
  117 + child = spawn(process.execPath, ["server.js"], {
  118 + cwd,
  119 + env: { ...process.env, PORT: String(PORT), H3_CONSOLE_BASE: `http://localhost:${mockH3Port}`, H3_CONSOLE_TOKEN: "mock-token", ADMIN_PASSWORD, GENERATION_POLL_INTERVAL_MS: "150", SCHEDULE_TICK_MS: "100" },
  120 + stdio: "pipe",
  121 + });
  122 + await waitForServer(BASE);
  123 +
  124 + const loginRes = await fetch(`${BASE}/api/admin/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: ADMIN_PASSWORD }) });
  125 + adminCookie = sessionCookie(loginRes);
  126 + defaultCharacter = await fetch(
  127 + `${BASE}/api/admin/characters`,
  128 + authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "衣柜小美", imageUrl: "/uploads/person.png", appearanceDescriptor: "black hair", isDefault: true }) })
  129 + ).then((r) => r.json());
  130 + await fetch(`${BASE}/api/admin/dance-refs`, authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "拉丁舞", videoUrl: "/uploads/ref.mp4", motionDescriptor: "hip sways" }) }));
  131 +});
  132 +test.after(() => {
  133 + child.kill();
  134 + mockH3.close();
  135 + fs.rmSync(cwd, { recursive: true, force: true });
  136 +});
  137 +
  138 +test("blacklist CRUD: create/list/delete, and creation is attributed to the acting admin", async () => {
  139 + const created = await fetch(`${BASE}/api/admin/blacklist`, authed(adminCookie, { method: "POST", body: JSON.stringify({ targetType: "garment", targetId: "some-garment-id", reason: "测试" }) })).then((r) => r.json());
  140 + assert.ok(created.id);
  141 + assert.equal(created.targetType, "garment");
  142 + assert.equal(created.createdBy, null); // legacy ADMIN_PASSWORD channel has no per-admin identity, same as T30's credit/adjust
  143 +
  144 + const list = await fetch(`${BASE}/api/admin/blacklist`, authed(adminCookie)).then((r) => r.json());
  145 + assert.ok(list.some((b) => b.id === created.id));
  146 +
  147 + const del = await fetch(`${BASE}/api/admin/blacklist/${created.id}`, authed(adminCookie, { method: "DELETE" })).then((r) => r.json());
  148 + assert.equal(del.deleted, true);
  149 + const listAfter = await fetch(`${BASE}/api/admin/blacklist`, authed(adminCookie)).then((r) => r.json());
  150 + assert.ok(!listAfter.some((b) => b.id === created.id));
  151 +});
  152 +
  153 +test("blacklisting a garment removes it from the wardrobe and rejects /api/dance/pick, without deleting the record", async () => {
  154 + const garment = await fetch(
  155 + `${BASE}/api/admin/garments`,
  156 + authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "待拉黑的裙子", imageUrl: "/uploads/garment.png", appearanceDescriptor: "black dress" }) })
  157 + ).then((r) => r.json());
  158 +
  159 + let wardrobe = await fetch(`${BASE}/api/dance/wardrobe`).then((r) => r.json());
  160 + assert.ok(wardrobe.some((g) => g.id === garment.id));
  161 +
  162 + const entry = await fetch(`${BASE}/api/admin/blacklist`, authed(adminCookie, { method: "POST", body: JSON.stringify({ targetType: "garment", targetId: garment.id, reason: "举报" }) })).then((r) => r.json());
  163 +
  164 + wardrobe = await fetch(`${BASE}/api/dance/wardrobe`).then((r) => r.json());
  165 + assert.ok(!wardrobe.some((g) => g.id === garment.id), "a blacklisted garment must not appear in the wardrobe");
  166 +
  167 + const pickRes = await fetch(`${BASE}/api/dance/pick`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ garmentId: garment.id }) });
  168 + assert.equal(pickRes.status, 403);
  169 +
  170 + // record itself is untouched
  171 + const stillThere = await fetch(`${BASE}/api/admin/garments`, authed(adminCookie)).then((r) => r.json());
  172 + assert.ok(stillThere.some((g) => g.id === garment.id));
  173 +
  174 + await fetch(`${BASE}/api/admin/blacklist/${entry.id}`, authed(adminCookie, { method: "DELETE" })); // cleanup for later tests
  175 +});
  176 +
  177 +test("blacklisting a user rejects their paid uploads at the entry point (403, before any spend)", async () => {
  178 + const cookie = await registerVerifiedUser(BASE);
  179 + const me = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json());
  180 + await fetch(`${BASE}/api/admin/blacklist`, authed(adminCookie, { method: "POST", body: JSON.stringify({ targetType: "user", targetId: me.id, reason: "测试" }) }));
  181 +
  182 + const fd = new FormData();
  183 + fd.append("file", new Blob([Buffer.from("not-really-an-image")], { type: "image/png" }), "x.png");
  184 + const res = await fetch(`${BASE}/api/dance/upload-garment`, { method: "POST", headers: { Cookie: cookie }, body: fd });
  185 + assert.equal(res.status, 403);
  186 +});
  187 +
  188 +test("admin can pin an existing clip to play next", async () => {
  189 + const garment = await fetch(
  190 + `${BASE}/api/admin/garments`,
  191 + authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "置顶测试裙", imageUrl: "/uploads/garment.png", appearanceDescriptor: "pink dress" }) })
  192 + ).then((r) => r.json());
  193 + const genRes = await fetch(
  194 + `${BASE}/api/admin/generate`,
  195 + authed(adminCookie, { method: "POST", body: JSON.stringify({ characterId: defaultCharacter.id, garmentId: garment.id }) })
  196 + ).then((r) => r.json());
  197 + const clip = await waitUntil(async () => {
  198 + const job = await fetch(`${BASE}/api/admin/generate/${genRes.jobId}`, authed(adminCookie)).then((r) => r.json());
  199 + return job.status === "done" ? job : null;
  200 + }).then((job) => fetch(`${BASE}/api/library`).then((r) => r.json()).then((lib) => lib.find((c) => c.id === job.generatedVideoId)));
  201 +
  202 + const pinRes = await fetch(`${BASE}/api/admin/dance/schedule/pin`, authed(adminCookie, { method: "POST", body: JSON.stringify({ videoId: clip.id }) }));
  203 + assert.equal(pinRes.status, 200);
  204 + const { schedule } = await pinRes.json();
  205 + assert.ok(schedule.length >= 2);
  206 + assert.equal(schedule[1].videoId, clip.id, "the pinned clip must land in the very next slot");
  207 +});
  208 +
  209 +test("admin can skip the currently-playing clip", async () => {
  210 + const before = await fetch(`${BASE}/api/dance/live/state`).then((r) => r.json());
  211 + const beforeStartAt = before.current ? before.current.startAt : null;
  212 +
  213 + const skipRes = await fetch(`${BASE}/api/admin/dance/schedule/skip`, authed(adminCookie, { method: "POST" }));
  214 + assert.equal(skipRes.status, 200);
  215 +
  216 + const after = await fetch(`${BASE}/api/dance/live/state`).then((r) => r.json());
  217 + // Compare startAt, not clip id — with a small candidate pool, skip's
  218 + // replacement can legitimately be the same clip content again; what
  219 + // must change is that it's a *freshly scheduled* entry, not the same
  220 + // stale one.
  221 + assert.notEqual(after.current.startAt, beforeStartAt, "skip must replace the current schedule entry with a freshly scheduled one");
  222 +});
  223 +
  224 +test("pausing auto-fill stops the schedule from growing, resuming lets it grow again", async () => {
  225 + await fetch(`${BASE}/api/admin/dance/pause`, authed(adminCookie, { method: "POST", body: JSON.stringify({ paused: true }) }));
  226 +
  227 + // Drain down to a stable size while paused — skip() only removes/expires
  228 + // entries; with the fill loop gated by dancePaused, nothing refills it,
  229 + // so repeated skips converge on length 1 (skip's own "only one entry
  230 + // left" branch just re-stamps it in place rather than removing it).
  231 + let drainedLength = null;
  232 + for (let i = 0; i < 6; i++) {
  233 + const { schedule } = await fetch(`${BASE}/api/admin/dance/schedule/skip`, authed(adminCookie, { method: "POST" })).then((r) => r.json());
  234 + if (schedule.length === drainedLength) break;
  235 + drainedLength = schedule.length;
  236 + }
  237 +
  238 + await new Promise((r) => setTimeout(r, 500)); // several SCHEDULE_TICK_MS, still paused
  239 + const stillPaused = await fetch(`${BASE}/api/dance/live/state`).then((r) => r.json());
  240 + assert.equal((stillPaused.upNext || []).length + 1, drainedLength, "schedule must not grow while paused");
  241 +
  242 + await fetch(`${BASE}/api/admin/dance/pause`, authed(adminCookie, { method: "POST", body: JSON.stringify({ paused: false }) }));
  243 + await waitUntil(async () => {
  244 + const s = await fetch(`${BASE}/api/dance/live/state`).then((r) => r.json());
  245 + return (s.upNext || []).length + 1 > drainedLength ? true : null;
  246 + });
  247 +});
... ...
dressup-store.js
... ... @@ -18,4 +18,9 @@ const generationJobs = createStore(path.join(__dirname, &quot;generation-jobs.json&quot;))
18 18 // what the automated gate did even before a real vendor is picked.
19 19 const moderationLog = createStore(path.join(__dirname, "moderation-log.json"));
20 20  
21   -module.exports = { characters, garments, danceRefs, generationJobs, moderationLog };
  21 +// Operator hand-curated blocklist (dev-plan-5.md T39; schema M.9's near
  22 +// neighbor is M.11 — a *different* table: this is manual takedowns, not
  23 +// the automated face-blacklist model (T32) or garment moderation (T26).
  24 +const blacklist = createStore(path.join(__dirname, "blacklist.json"));
  25 +
  26 +module.exports = { characters, garments, danceRefs, generationJobs, moderationLog, blacklist };
... ...
server.js
... ... @@ -74,7 +74,7 @@ const {
74 74 forwardToH3Assets,
75 75 forwardToFalAssets,
76 76 } = require("./uploads.js");
77   -const { characters, garments, danceRefs, generationJobs, moderationLog } = require("./dressup-store.js");
  77 +const { characters, garments, danceRefs, generationJobs, moderationLog, blacklist } = require("./dressup-store.js");
78 78 const { buildStage2Prompt } = require("./prompt-template.js");
79 79 const { users: userStore, sessions: userSessions, creditLedger } = require("./db.js");
80 80 const { pricing } = require("./pricing.js");
... ... @@ -1030,6 +1030,14 @@ async function falFetchResult(requestId) {
1030 1030 return body; // { video: { url, ... }, expanded_prompt }
1031 1031 }
1032 1032  
  1033 +// T39 (dev-plan-5.md; schema M.11) — operator hand-curated blocklist,
  1034 +// distinct from T32's automated face-blacklist model and T26's garment
  1035 +// moderation (see blacklist store's own doc comment, dressup-store.js).
  1036 +function isBlacklisted(targetType, targetId) {
  1037 + if (!targetId) return false;
  1038 + return blacklist.list().some((b) => b.targetType === targetType && b.targetId === targetId);
  1039 +}
  1040 +
1033 1041 function pickRefDanceVideo() {
1034 1042 const pool = danceRefs.list().filter((d) => d.isActive);
1035 1043 if (pool.length === 0) throw new Error("no active dance-refs available");
... ... @@ -1322,6 +1330,26 @@ function resumeDressupGenerations() {
1322 1330 // danceQueue/danceSchedule instead, and picks its random-fill pool from
1323 1331 // channel:"dance" clips only.
1324 1332  
  1333 +// Shared by T34's priorityInbox drain and T39's admin "置顶下一条" pin —
  1334 +// inserts right after whatever's currently playing (danceSchedule[0]),
  1335 +// pushing every later entry's timing back by this clip's duration rather
  1336 +// than appending at the tail like the normal fill loop does.
  1337 +function insertClipNext(clip, requestedBy, now) {
  1338 + const insertAt = danceSchedule.length ? 1 : 0;
  1339 + const prevEndAt = insertAt > 0 ? danceSchedule[insertAt - 1].endAt : now;
  1340 + danceSchedule.splice(insertAt, 0, { videoId: clip.id, startAt: prevEndAt, endAt: prevEndAt + clip.duration * 1000, requestedBy: requestedBy || null });
  1341 + for (let i = insertAt + 1; i < danceSchedule.length; i++) {
  1342 + danceSchedule[i].startAt += clip.duration * 1000;
  1343 + danceSchedule[i].endAt += clip.duration * 1000;
  1344 + }
  1345 +}
  1346 +
  1347 +// T39: admin "暂停自动填充" — freezes only the random-fill loop below (free
  1348 +// mode-一 content competing for slots); priorityInbox (paid_boost) still
  1349 +// drains immediately even while paused, matching 需求文档 6.5 节's own
  1350 +// framing of "暂停自动填充" as a filler-specific control, not a full stop.
  1351 +let dancePaused = false;
  1352 +
1325 1353 function pickNextDanceClip() {
1326 1354 const readyIdx = pickBestIndexByStatus(danceQueue, "ready");
1327 1355 if (readyIdx !== -1) {
... ... @@ -1332,8 +1360,14 @@ function pickNextDanceClip() {
1332 1360 // (status:"expired") drops out of random-fill selection — the record
1333 1361 // and its videos stay in library.json (M.3/M.7: "记录保留"), just no
1334 1362 // longer eligible to be picked here.
  1363 + // T39: a blacklisted generated_video/character/garment (M.11) is filtered
  1364 + // out of the candidate pool the same way — "从排播候选和衣柜列表里过滤
  1365 + // 掉(不删除记录)".
1335 1366 const pool = getLibrary().filter((c) => {
1336 1367 if (c.channel !== "dance") return false;
  1368 + if (isBlacklisted("generated_video", c.id)) return false;
  1369 + if (c.characterId && isBlacklisted("character", c.characterId)) return false;
  1370 + if (c.garmentId && isBlacklisted("garment", c.garmentId)) return false;
1337 1371 if (!c.characterId) return true;
1338 1372 const character = characters.get(c.characterId);
1339 1373 return !character || character.status !== "expired";
... ... @@ -1419,16 +1453,10 @@ function ensureDanceSchedule() {
1419 1453 if (!clip) continue; // stale/removed videoId — drop rather than schedule a dead entry
1420 1454 markPlayed(clip.id);
1421 1455 consumeCharacterSegment(clip);
1422   - const insertAt = danceSchedule.length ? 1 : 0;
1423   - const prevEndAt = insertAt > 0 ? danceSchedule[insertAt - 1].endAt : now;
1424   - danceSchedule.splice(insertAt, 0, { videoId: next.videoId, startAt: prevEndAt, endAt: prevEndAt + clip.duration * 1000, requestedBy: next.requestedBy });
1425   - for (let i = insertAt + 1; i < danceSchedule.length; i++) {
1426   - danceSchedule[i].startAt += clip.duration * 1000;
1427   - danceSchedule[i].endAt += clip.duration * 1000;
1428   - }
  1456 + insertClipNext(clip, next.requestedBy, now);
1429 1457 }
1430 1458  
1431   - while (danceSchedule.length === 0 || danceSchedule[danceSchedule.length - 1].endAt < now + FILLER_DURATION_MS) {
  1459 + while (!dancePaused && (danceSchedule.length === 0 || danceSchedule[danceSchedule.length - 1].endAt < now + FILLER_DURATION_MS)) {
1432 1460 const picked = pickNextDanceClip();
1433 1461 if (!picked || !picked.videoId) break;
1434 1462 const clip = getLibrary().find((c) => c.id === picked.videoId);
... ... @@ -1796,6 +1824,91 @@ http
1796 1824 return;
1797 1825 }
1798 1826  
  1827 + // T39 (M.11): 运营手工拉黑. 拦截点在提交类接口自身
  1828 + // (/api/dance/pick、两个 upload——见各自的 isBlacklisted 调用),这里
  1829 + // 只是维护这张表.
  1830 + if (pathname === "/api/admin/blacklist" && req.method === "GET") {
  1831 + res.writeHead(200, { "Content-Type": "application/json" });
  1832 + res.end(JSON.stringify(blacklist.list()));
  1833 + return;
  1834 + }
  1835 + if (pathname === "/api/admin/blacklist" && req.method === "POST") {
  1836 + readJsonBody(req)
  1837 + .then((body) => {
  1838 + const targetType = (body.targetType || "").trim();
  1839 + const targetId = (body.targetId || "").trim();
  1840 + const validTypes = ["user", "character", "garment", "generated_video", "ip"];
  1841 + if (!validTypes.includes(targetType) || !targetId) {
  1842 + res.writeHead(400, { "Content-Type": "application/json" });
  1843 + res.end(JSON.stringify({ error: `targetType must be one of ${validTypes.join("/")}, targetId is required` }));
  1844 + return;
  1845 + }
  1846 + const entry = blacklist.create({ targetType, targetId, reason: (body.reason || "").trim(), createdBy: adminActorUserId(req) });
  1847 + res.writeHead(200, { "Content-Type": "application/json" });
  1848 + res.end(JSON.stringify(entry));
  1849 + })
  1850 + .catch(() => {
  1851 + res.writeHead(400, { "Content-Type": "application/json" });
  1852 + res.end(JSON.stringify({ error: "invalid JSON body" }));
  1853 + });
  1854 + return;
  1855 + }
  1856 + if (pathname.startsWith("/api/admin/blacklist/") && req.method === "DELETE") {
  1857 + const removed = blacklist.remove(pathname.slice("/api/admin/blacklist/".length));
  1858 + res.writeHead(removed ? 200 : 404, { "Content-Type": "application/json" });
  1859 + res.end(JSON.stringify({ deleted: removed }));
  1860 + return;
  1861 + }
  1862 +
  1863 + // T39 (需求文档 6.5 节): dance.html 播放管理——插播/置顶、跳过当前、
  1864 + // 暂停/恢复自动填充.
  1865 + if (pathname === "/api/admin/dance/schedule/pin" && req.method === "POST") {
  1866 + readJsonBody(req)
  1867 + .then((body) => {
  1868 + const clip = getLibrary().find((c) => c.id === body.videoId && c.channel === "dance");
  1869 + if (!clip) {
  1870 + res.writeHead(400, { "Content-Type": "application/json" });
  1871 + res.end(JSON.stringify({ error: "videoId must reference an existing dance clip" }));
  1872 + return;
  1873 + }
  1874 + ensureDanceSchedule(); // settle any pending state before splicing
  1875 + insertClipNext(clip, "管理员置顶", Date.now());
  1876 + saveDanceState();
  1877 + res.writeHead(200, { "Content-Type": "application/json" });
  1878 + res.end(JSON.stringify({ ok: true, schedule: danceSchedule }));
  1879 + })
  1880 + .catch(() => {
  1881 + res.writeHead(400, { "Content-Type": "application/json" });
  1882 + res.end(JSON.stringify({ error: "invalid JSON body" }));
  1883 + });
  1884 + return;
  1885 + }
  1886 + if (pathname === "/api/admin/dance/schedule/skip" && req.method === "POST") {
  1887 + ensureDanceSchedule();
  1888 + if (danceSchedule.length > 1) {
  1889 + danceSchedule.shift(); // next tick's own "shift expired entries" step will pick up the new current from here
  1890 + } else if (danceSchedule.length === 1) {
  1891 + danceSchedule[0].endAt = Date.now(); // nothing queued behind it — force it to expire now, next tick fills a replacement
  1892 + }
  1893 + ensureDanceSchedule(); // fill immediately rather than waiting up to SCHEDULE_TICK_MS
  1894 + res.writeHead(200, { "Content-Type": "application/json" });
  1895 + res.end(JSON.stringify({ ok: true, schedule: danceSchedule }));
  1896 + return;
  1897 + }
  1898 + if (pathname === "/api/admin/dance/pause" && req.method === "POST") {
  1899 + readJsonBody(req)
  1900 + .then((body) => {
  1901 + dancePaused = Boolean(body.paused);
  1902 + res.writeHead(200, { "Content-Type": "application/json" });
  1903 + res.end(JSON.stringify({ paused: dancePaused }));
  1904 + })
  1905 + .catch(() => {
  1906 + res.writeHead(400, { "Content-Type": "application/json" });
  1907 + res.end(JSON.stringify({ error: "invalid JSON body" }));
  1908 + });
  1909 + return;
  1910 + }
  1911 +
1799 1912 if (pathname === "/api/admin/generate" && req.method === "GET") {
1800 1913 res.writeHead(200, { "Content-Type": "application/json" });
1801 1914 res.end(JSON.stringify(generationJobs.list()));
... ... @@ -1884,7 +1997,7 @@ http
1884 1997  
1885 1998 if (pathname === "/api/dance/wardrobe" && req.method === "GET") {
1886 1999 res.writeHead(200, { "Content-Type": "application/json" });
1887   - res.end(JSON.stringify(garments.list().filter((g) => g.isActive)));
  2000 + res.end(JSON.stringify(garments.list().filter((g) => g.isActive && !isBlacklisted("garment", g.id))));
1888 2001 return;
1889 2002 }
1890 2003  
... ... @@ -1947,6 +2060,14 @@ http
1947 2060 res.end(JSON.stringify({ error: "garmentId must reference an active garment" }));
1948 2061 return;
1949 2062 }
  2063 + // T39 (M.11): a blacklisted garment is rejected at the submission
  2064 + // entry point itself — "不进流水线、不扣积分" (moot here, mode 1
  2065 + // is free, but the entry-point rejection still applies).
  2066 + if (isBlacklisted("garment", garment.id)) {
  2067 + res.writeHead(403, { "Content-Type": "application/json" });
  2068 + res.end(JSON.stringify({ error: "this garment is unavailable" }));
  2069 + return;
  2070 + }
1950 2071 const defaultCharacter = characters.list().find((c) => c.isDefault);
1951 2072 if (!defaultCharacter) {
1952 2073 res.writeHead(400, { "Content-Type": "application/json" });
... ... @@ -2032,6 +2153,13 @@ http
2032 2153 res.end(JSON.stringify({ error: "email verification required before uploading a garment" }));
2033 2154 return;
2034 2155 }
  2156 + // T39 (M.11): a blacklisted account is rejected at the submission
  2157 + // entry point — "不进流水线、不扣积分".
  2158 + if (isBlacklisted("user", user.id)) {
  2159 + res.writeHead(403, { "Content-Type": "application/json" });
  2160 + res.end(JSON.stringify({ error: "account unavailable" }));
  2161 + return;
  2162 + }
2035 2163 const boundary = boundaryFromContentType(req.headers["content-type"] || "");
2036 2164 if (!boundary) {
2037 2165 res.writeHead(400, { "Content-Type": "application/json" });
... ... @@ -2170,6 +2298,11 @@ http
2170 2298 res.end(JSON.stringify({ error: "email verification required before uploading a character" }));
2171 2299 return;
2172 2300 }
  2301 + if (isBlacklisted("user", user.id)) {
  2302 + res.writeHead(403, { "Content-Type": "application/json" });
  2303 + res.end(JSON.stringify({ error: "account unavailable" }));
  2304 + return;
  2305 + }
2173 2306 const boundary = boundaryFromContentType(req.headers["content-type"] || "");
2174 2307 if (!boundary) {
2175 2308 res.writeHead(400, { "Content-Type": "application/json" });
... ...