Commit 21dc02c33326927338dc6ce42f41a3ee3fc2366f
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
Showing
3 changed files
with
396 additions
and
11 deletions
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, "generation-jobs.json")) | @@ -18,4 +18,9 @@ const generationJobs = createStore(path.join(__dirname, "generation-jobs.json")) | ||
| 18 | // what the automated gate did even before a real vendor is picked. | 18 | // what the automated gate did even before a real vendor is picked. |
| 19 | const moderationLog = createStore(path.join(__dirname, "moderation-log.json")); | 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,7 +74,7 @@ const { | ||
| 74 | forwardToH3Assets, | 74 | forwardToH3Assets, |
| 75 | forwardToFalAssets, | 75 | forwardToFalAssets, |
| 76 | } = require("./uploads.js"); | 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 | const { buildStage2Prompt } = require("./prompt-template.js"); | 78 | const { buildStage2Prompt } = require("./prompt-template.js"); |
| 79 | const { users: userStore, sessions: userSessions, creditLedger } = require("./db.js"); | 79 | const { users: userStore, sessions: userSessions, creditLedger } = require("./db.js"); |
| 80 | const { pricing } = require("./pricing.js"); | 80 | const { pricing } = require("./pricing.js"); |
| @@ -1030,6 +1030,14 @@ async function falFetchResult(requestId) { | @@ -1030,6 +1030,14 @@ async function falFetchResult(requestId) { | ||
| 1030 | return body; // { video: { url, ... }, expanded_prompt } | 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 | function pickRefDanceVideo() { | 1041 | function pickRefDanceVideo() { |
| 1034 | const pool = danceRefs.list().filter((d) => d.isActive); | 1042 | const pool = danceRefs.list().filter((d) => d.isActive); |
| 1035 | if (pool.length === 0) throw new Error("no active dance-refs available"); | 1043 | if (pool.length === 0) throw new Error("no active dance-refs available"); |
| @@ -1322,6 +1330,26 @@ function resumeDressupGenerations() { | @@ -1322,6 +1330,26 @@ function resumeDressupGenerations() { | ||
| 1322 | // danceQueue/danceSchedule instead, and picks its random-fill pool from | 1330 | // danceQueue/danceSchedule instead, and picks its random-fill pool from |
| 1323 | // channel:"dance" clips only. | 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 | function pickNextDanceClip() { | 1353 | function pickNextDanceClip() { |
| 1326 | const readyIdx = pickBestIndexByStatus(danceQueue, "ready"); | 1354 | const readyIdx = pickBestIndexByStatus(danceQueue, "ready"); |
| 1327 | if (readyIdx !== -1) { | 1355 | if (readyIdx !== -1) { |
| @@ -1332,8 +1360,14 @@ function pickNextDanceClip() { | @@ -1332,8 +1360,14 @@ function pickNextDanceClip() { | ||
| 1332 | // (status:"expired") drops out of random-fill selection — the record | 1360 | // (status:"expired") drops out of random-fill selection — the record |
| 1333 | // and its videos stay in library.json (M.3/M.7: "记录保留"), just no | 1361 | // and its videos stay in library.json (M.3/M.7: "记录保留"), just no |
| 1334 | // longer eligible to be picked here. | 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 | const pool = getLibrary().filter((c) => { | 1366 | const pool = getLibrary().filter((c) => { |
| 1336 | if (c.channel !== "dance") return false; | 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 | if (!c.characterId) return true; | 1371 | if (!c.characterId) return true; |
| 1338 | const character = characters.get(c.characterId); | 1372 | const character = characters.get(c.characterId); |
| 1339 | return !character || character.status !== "expired"; | 1373 | return !character || character.status !== "expired"; |
| @@ -1419,16 +1453,10 @@ function ensureDanceSchedule() { | @@ -1419,16 +1453,10 @@ function ensureDanceSchedule() { | ||
| 1419 | if (!clip) continue; // stale/removed videoId — drop rather than schedule a dead entry | 1453 | if (!clip) continue; // stale/removed videoId — drop rather than schedule a dead entry |
| 1420 | markPlayed(clip.id); | 1454 | markPlayed(clip.id); |
| 1421 | consumeCharacterSegment(clip); | 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 | const picked = pickNextDanceClip(); | 1460 | const picked = pickNextDanceClip(); |
| 1433 | if (!picked || !picked.videoId) break; | 1461 | if (!picked || !picked.videoId) break; |
| 1434 | const clip = getLibrary().find((c) => c.id === picked.videoId); | 1462 | const clip = getLibrary().find((c) => c.id === picked.videoId); |
| @@ -1796,6 +1824,91 @@ http | @@ -1796,6 +1824,91 @@ http | ||
| 1796 | return; | 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 | if (pathname === "/api/admin/generate" && req.method === "GET") { | 1912 | if (pathname === "/api/admin/generate" && req.method === "GET") { |
| 1800 | res.writeHead(200, { "Content-Type": "application/json" }); | 1913 | res.writeHead(200, { "Content-Type": "application/json" }); |
| 1801 | res.end(JSON.stringify(generationJobs.list())); | 1914 | res.end(JSON.stringify(generationJobs.list())); |
| @@ -1884,7 +1997,7 @@ http | @@ -1884,7 +1997,7 @@ http | ||
| 1884 | 1997 | ||
| 1885 | if (pathname === "/api/dance/wardrobe" && req.method === "GET") { | 1998 | if (pathname === "/api/dance/wardrobe" && req.method === "GET") { |
| 1886 | res.writeHead(200, { "Content-Type": "application/json" }); | 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 | return; | 2001 | return; |
| 1889 | } | 2002 | } |
| 1890 | 2003 | ||
| @@ -1947,6 +2060,14 @@ http | @@ -1947,6 +2060,14 @@ http | ||
| 1947 | res.end(JSON.stringify({ error: "garmentId must reference an active garment" })); | 2060 | res.end(JSON.stringify({ error: "garmentId must reference an active garment" })); |
| 1948 | return; | 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 | const defaultCharacter = characters.list().find((c) => c.isDefault); | 2071 | const defaultCharacter = characters.list().find((c) => c.isDefault); |
| 1951 | if (!defaultCharacter) { | 2072 | if (!defaultCharacter) { |
| 1952 | res.writeHead(400, { "Content-Type": "application/json" }); | 2073 | res.writeHead(400, { "Content-Type": "application/json" }); |
| @@ -2032,6 +2153,13 @@ http | @@ -2032,6 +2153,13 @@ http | ||
| 2032 | res.end(JSON.stringify({ error: "email verification required before uploading a garment" })); | 2153 | res.end(JSON.stringify({ error: "email verification required before uploading a garment" })); |
| 2033 | return; | 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 | const boundary = boundaryFromContentType(req.headers["content-type"] || ""); | 2163 | const boundary = boundaryFromContentType(req.headers["content-type"] || ""); |
| 2036 | if (!boundary) { | 2164 | if (!boundary) { |
| 2037 | res.writeHead(400, { "Content-Type": "application/json" }); | 2165 | res.writeHead(400, { "Content-Type": "application/json" }); |
| @@ -2170,6 +2298,11 @@ http | @@ -2170,6 +2298,11 @@ http | ||
| 2170 | res.end(JSON.stringify({ error: "email verification required before uploading a character" })); | 2298 | res.end(JSON.stringify({ error: "email verification required before uploading a character" })); |
| 2171 | return; | 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 | const boundary = boundaryFromContentType(req.headers["content-type"] || ""); | 2306 | const boundary = boundaryFromContentType(req.headers["content-type"] || ""); |
| 2174 | if (!boundary) { | 2307 | if (!boundary) { |
| 2175 | res.writeHead(400, { "Content-Type": "application/json" }); | 2308 | res.writeHead(400, { "Content-Type": "application/json" }); |