From bcdd04b932beeeee3ee1eabea05158e608a97c3f Mon Sep 17 00:00:00 2001 From: yibo Date: Wed, 2 Sep 2026 21:44:21 +0800 Subject: [PATCH] Add T27: mode 2 backend — POST /api/dance/upload-garment --- dance-mode2.test.js | 339 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ server.js | 157 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 496 insertions(+), 0 deletions(-) create mode 100644 dance-mode2.test.js diff --git a/dance-mode2.test.js b/dance-mode2.test.js new file mode 100644 index 0000000..465d129 --- /dev/null +++ b/dance-mode2.test.js @@ -0,0 +1,339 @@ +// Integration test for T27 (mode 2: 付费传衣服, POST /api/dance/upload-garment), +// dev-plan-4.md. Same mock-H3-Console approach as dance-channel.test.js so +// the full upload -> moderate -> spend -> auto-tag -> generate -> land-in- +// schedule path runs offline and fast. +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const os = require("node:os"); +const http = require("node:http"); +const { spawn } = require("node:child_process"); + +// A real, valid 2x2 PNG (not just the 8-byte magic signature) — needed +// because sips has to actually decode it during stripMetadataAndSave. +const TINY_VALID_PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEklEQVR4nGPkEpFjYGBgYgADAALmAEAUQs4PAAAAAElFTkSuQmCC", + "base64" +); + +const PORT = 29934 + Math.floor(Math.random() * 1000); +const BASE = `http://localhost:${PORT}`; +const ADMIN_PASSWORD = "test-password-only-for-this-run"; +let adminCookie, child, cwd, mockH3, mockH3Port, videoPollCounts; + +function waitForServer(url, timeoutMs = 10000) { + const deadline = Date.now() + timeoutMs; + return (async function poll() { + while (Date.now() < deadline) { + try { + await fetch(url); + return; + } catch { + await new Promise((r) => setTimeout(r, 100)); + } + } + throw new Error("server did not come up in time"); + })(); +} + +function sessionCookie(res) { + const setCookie = res.headers.get("set-cookie") || ""; + return setCookie.split(";")[0]; +} + +function authed(cookie, opts = {}) { + return { ...opts, headers: { "Content-Type": "application/json", Cookie: cookie, ...opts.headers } }; +} + +// Same shape as dance-channel.test.js's mock — enough of /v1/assets, +// /v1/videos and /v1/videos/:id for generateDanceVideo to run end to end. +// `failSubmit` lets one test force a "submit failed" so T27's async refund +// path (ensureDanceSchedule's failure branch) actually has something to +// trigger on. +// Returns {server, port} rather than mutating module-level mockH3/mockH3Port +// — the "generation failure" test below needs a second, independently +// closeable instance without leaking the first (test.before's) one, which +// would otherwise keep node --test's process alive forever after all tests +// finish (an unclosed http.Server keeps the event loop open). +function startMockH3(failSubmit = false) { + const pollCounts = new Map(); + let nextId = 1; + return new Promise((resolve) => { + const server = http.createServer((req, res) => { + req.url = req.url.replace(/^\/+/, "/"); + const chunks = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", () => { + if (req.method === "POST" && req.url === "/v1/assets") { + const id = `asset-${nextId++}`; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ id, url: `http://mock-h3/asset/${id}`, kind: "image" })); + return; + } + if (req.method === "POST" && req.url === "/v1/videos") { + if (failSubmit) { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ detail: "mock forced submit failure" })); + return; + } + const id = `job-${nextId++}`; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ id })); + return; + } + const pollMatch = req.method === "GET" && req.url.match(/^\/v1\/videos\/(.+)$/); + if (pollMatch) { + const id = pollMatch[1]; + const seen = (pollCounts.get(id) || 0) + 1; + pollCounts.set(id, seen); + if (seen < 2) { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ id, status: "generating" })); + return; + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ id, status: "done", partition: "ref2va", req: { width: 480, height: 832, frames: 240 } })); + return; + } + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "not found in mock" })); + }); + }); + server.listen(0, () => resolve({ server, port: server.address().port })); + }); +} + +async function waitUntil(fn, timeoutMs = 8000, intervalMs = 100) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const v = await fn(); + if (v) return v; + await new Promise((r) => setTimeout(r, intervalMs)); + } + throw new Error("condition not met in time"); +} + +async function registerVerifiedUser(base) { + const email = `mode2-${Date.now()}-${Math.random().toString(36).slice(2)}@example.com`; + const registerRes = await fetch(`${base}/api/auth/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password: "correct-horse-battery" }), + }); + const { devVerifyUrl } = await registerRes.json(); + const cookie = sessionCookie(registerRes); + await fetch(`${base}${devVerifyUrl}`); + return cookie; +} + +// Tops up a logged-in user via the real T24 mock-checkout loop (not by +// reaching into db.js directly — the spawned server has its own db.js/ +// data.sqlite in its own tmp cwd, so this test process's own db.js import +// wouldn't even point at the same file). +async function topUp(base, cookie, tierKey = "topup_tier_1") { + const checkoutRes = await fetch(`${base}/api/credits/checkout`, authed(cookie, { method: "POST", body: JSON.stringify({ tierKey }) })); + const { url } = await checkoutRes.json(); + await fetch(`${base}${url}`, { redirect: "manual" }); +} + +function uploadGarment(base, cookie, png = TINY_VALID_PNG) { + const fd = new FormData(); + fd.append("file", new Blob([png], { type: "image/png" }), "garment.png"); + return fetch(`${base}/api/dance/upload-garment`, { method: "POST", headers: { Cookie: cookie }, body: fd }); +} + +test.before(async () => { + ({ server: mockH3, port: mockH3Port } = await startMockH3()); + + cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dance-mode2-")); + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js", "pricing.js"]) { + fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f)); + } + fs.mkdirSync(path.join(cwd, "uploads"), { recursive: true }); + fs.writeFileSync(path.join(cwd, "uploads", "person.png"), "fake-png-bytes"); + fs.writeFileSync(path.join(cwd, "uploads", "ref.mp4"), "fake-mp4-bytes"); + + child = spawn(process.execPath, ["server.js"], { + cwd, + env: { + ...process.env, + PORT: String(PORT), + H3_CONSOLE_BASE: `http://localhost:${mockH3Port}`, + H3_CONSOLE_TOKEN: "mock-token", + ADMIN_PASSWORD, + GENERATION_POLL_INTERVAL_MS: "400", + SCHEDULE_TICK_MS: "150", + }, + stdio: "pipe", + }); + await waitForServer(BASE); + + const loginRes = await fetch(`${BASE}/api/admin/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password: ADMIN_PASSWORD }), + }); + adminCookie = sessionCookie(loginRes); + + await fetch( + `${BASE}/api/admin/characters`, + authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "衣柜小美", imageUrl: "/uploads/person.png", appearanceDescriptor: "black hair", isDefault: true }) }) + ); + await fetch( + `${BASE}/api/admin/dance-refs`, + authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "衣柜拉丁舞", videoUrl: "/uploads/ref.mp4", motionDescriptor: "hip sways" }) }) + ); +}); + +test.after(() => { + child.kill(); + mockH3.close(); + fs.rmSync(cwd, { recursive: true, force: true }); +}); + +test("upload-garment requires login", async () => { + const res = await uploadGarment(BASE, ""); + assert.equal(res.status, 401); +}); + +test("upload-garment requires a verified email", async () => { + const email = `mode2-unverified-${Date.now()}@example.com`; + const registerRes = await fetch(`${BASE}/api/auth/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password: "correct-horse-battery" }), + }); + const cookie = sessionCookie(registerRes); + const res = await uploadGarment(BASE, cookie); + assert.equal(res.status, 403); +}); + +test("upload-garment with insufficient balance is rejected (402) and leaves no garment behind", async () => { + const cookie = await registerVerifiedUser(BASE); + const garmentsBefore = await fetch(`${BASE}/api/admin/garments`, authed(adminCookie)).then((r) => r.json()); + + const res = await uploadGarment(BASE, cookie); + assert.equal(res.status, 402); + + const garmentsAfter = await fetch(`${BASE}/api/admin/garments`, authed(adminCookie)).then((r) => r.json()); + assert.equal(garmentsAfter.length, garmentsBefore.length, "a rejected-for-balance upload must not leave a phantom garments row"); +}); + +test("a non-image file is rejected before anything is spent or created", async () => { + const cookie = await registerVerifiedUser(BASE); + await topUp(BASE, cookie); + const fd = new FormData(); + fd.append("file", new Blob([Buffer.from("not an image")], { type: "text/plain" }), "not-an-image.txt"); + const res = await fetch(`${BASE}/api/dance/upload-garment`, { method: "POST", headers: { Cookie: cookie }, body: fd }); + assert.equal(res.status, 400); + const me = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json()); + assert.ok(me.creditBalance > 0, "a rejected file upload must not spend credits"); +}); + +test("full happy path: upload -> moderated+auto-tagged -> spent -> generated -> lands in dance schedule, with characterId/garmentId/ownerUserId traceability", async () => { + const cookie = await registerVerifiedUser(BASE); + await topUp(BASE, cookie); + const meBefore = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json()); + const { id: userId } = meBefore; + + const res = await uploadGarment(BASE, cookie); + assert.equal(res.status, 200); + const body = await res.json(); + assert.ok(body.garmentId); + assert.ok(body.creditsSpent > 0); + assert.equal(["generating", "ready"].includes(body.status), true); + + const meAfter = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json()); + assert.equal(meAfter.creditBalance, meBefore.creditBalance - body.creditsSpent); + + const garments = await fetch(`${BASE}/api/admin/garments`, authed(adminCookie)).then((r) => r.json()); + const created = garments.find((g) => g.id === body.garmentId); + assert.ok(created, "the uploaded garment must be visible in the (now shared/public) garments library"); + assert.equal(created.source, "user_paid"); + assert.equal(created.ownerUserId, userId); + assert.equal(created.moderationStatus, "approved"); + assert.ok(created.appearanceDescriptor && created.appearanceDescriptor.length > 0, "T25's auto-tagging must fill a non-empty appearanceDescriptor even with no vision vendor configured"); + + // lands in the dance schedule and plays, same end state dance-channel.test.js checks for mode 1 + await waitUntil(async () => { + const state = await fetch(`${BASE}/api/dance/live/state`).then((r) => r.json()); + return state.current && state.current.clip && state.current.clip.garmentId === body.garmentId ? state : null; + }); +}); + +// ---- async generation failure -> refund (its own server + a mock H3 that +// always fails submission, so the refund path in ensureDanceSchedule's +// tick has something real to trigger on) ---- + +test("a generation failure after upload refunds the spent credits, exactly once", async () => { + const { server: failingMockH3, port: failingMockH3Port } = await startMockH3(true); + const port = 30934 + Math.floor(Math.random() * 1000); + const base = `http://localhost:${port}`; + const failCwd = fs.mkdtempSync(path.join(os.tmpdir(), "dance-mode2-fail-")); + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js", "pricing.js"]) { + fs.copyFileSync(path.join(__dirname, f), path.join(failCwd, f)); + } + fs.mkdirSync(path.join(failCwd, "uploads"), { recursive: true }); + fs.writeFileSync(path.join(failCwd, "uploads", "person.png"), "fake-png-bytes"); + fs.writeFileSync(path.join(failCwd, "uploads", "ref.mp4"), "fake-mp4-bytes"); + + const failChild = spawn(process.execPath, ["server.js"], { + cwd: failCwd, + env: { + ...process.env, + PORT: String(port), + H3_CONSOLE_BASE: `http://localhost:${failingMockH3Port}`, + H3_CONSOLE_TOKEN: "mock-token", + ADMIN_PASSWORD, + GENERATION_POLL_INTERVAL_MS: "100", + SCHEDULE_TICK_MS: "100", + }, + stdio: "pipe", + }); + try { + await waitForServer(base); + const failAdminLogin = await fetch(`${base}/api/admin/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password: ADMIN_PASSWORD }), + }); + const failAdminCookie = sessionCookie(failAdminLogin); + await fetch( + `${base}/api/admin/characters`, + authed(failAdminCookie, { method: "POST", body: JSON.stringify({ name: "衣柜小美", imageUrl: "/uploads/person.png", appearanceDescriptor: "black hair", isDefault: true }) }) + ); + await fetch( + `${base}/api/admin/dance-refs`, + authed(failAdminCookie, { method: "POST", body: JSON.stringify({ name: "衣柜拉丁舞", videoUrl: "/uploads/ref.mp4", motionDescriptor: "hip sways" }) }) + ); + + const cookie = await registerVerifiedUser(base); + await topUp(base, cookie); + const meBefore = await fetch(`${base}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json()); + + const res = await uploadGarment(base, cookie); + assert.equal(res.status, 200); + const { creditsSpent } = await res.json(); + const meAfterSpend = await fetch(`${base}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json()); + assert.equal(meAfterSpend.creditBalance, meBefore.creditBalance - creditsSpent); + + // the mock always fails POST /v1/videos, so the generation job (and + // the danceQueue entry riding on it) should flip to "failed" shortly, + // and ensureDanceSchedule's tick should refund it back. + await waitUntil(async () => { + const me = await fetch(`${base}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json()); + return me.creditBalance === meBefore.creditBalance ? me : null; + }); + + // give a couple more ticks a chance to run — balance must not drift + // past a full refund (i.e. no double-refund) + await new Promise((r) => setTimeout(r, 500)); + const meFinal = await fetch(`${base}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json()); + assert.equal(meFinal.creditBalance, meBefore.creditBalance); + } finally { + failChild.kill(); + failingMockH3.close(); + fs.rmSync(failCwd, { recursive: true, force: true }); + } +}); diff --git a/server.js b/server.js index 95376e0..1382252 100644 --- a/server.js +++ b/server.js @@ -1106,6 +1106,32 @@ function ensureDanceSchedule() { q.status = "failed"; q.error = job.error; q.failedAt = now; + // T27's paid path (userId/amountPaid only set there — free mode-一 + // picks have neither): refund on generation failure, per "共同后置 + // 流程"'s stated floor. Guarded by an existing-refund lookup rather + // than trusting this branch only ever runs once — it normally does + // (danceQueue entries only transition "generating" -> "failed" a + // single time, this loop's own `q.status !== "generating"` guard + // above prevents re-entry), but a crash between this write and + // saveDanceState() persisting it could in principle replay the same + // transition once after restart, and a duplicate refund is real + // money, not a cosmetic bug. + if (q.userId && q.amountPaid) { + const alreadyRefunded = creditLedger.findByRelatedAssetAndReason(q.garmentId, "refund_stage2_fail").length > 0; + if (!alreadyRefunded) { + try { + creditLedger.refund({ userId: q.userId, amount: q.amountPaid, reason: "refund_stage2_fail", relatedAssetId: q.garmentId }); + } catch (err) { + // NO_SUCH_USER (deleted account) is the only realistic failure + // here — refund only ever adds, so it can't hit + // INSUFFICIENT_BALANCE. This branch won't be revisited (the + // guard above only enters on "generating" -> "failed", a + // one-time transition), so this is a genuine miss, not a + // deferred retry — logged rather than silently dropped. + console.error(`[refund_stage2_fail] failed to refund userId=${q.userId} garmentId=${q.garmentId}: ${err.message}`); + } + } + } } } @@ -1658,6 +1684,137 @@ http return; } + // T27 模式二: 付费传衣服. 需登录 + emailVerified (M.10). Order matters — + // moderation runs before any garments record or spend exists (a + // rejected upload leaves nothing behind); the garment record is + // created before spend so the ledger row can carry relatedAssetId, and + // is rolled back (garments.remove) if the spend or the generation + // submission itself fails, so a failed/rejected attempt never leaves a + // phantom garment in the library. Async generation failures (after + // this handler has already responded) are refunded from + // ensureDanceSchedule's tick instead — see its own comment. + if (pathname === "/api/dance/upload-garment" && req.method === "POST") { + const user = requireUserSession(req); + if (!user) { + res.writeHead(401, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "login required" })); + return; + } + if (!user.emailVerified) { + res.writeHead(403, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "email verification required before uploading a garment" })); + return; + } + const boundary = boundaryFromContentType(req.headers["content-type"] || ""); + if (!boundary) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "expected multipart/form-data" })); + return; + } + readRawBody(req) + .then(async (raw) => { + const filePart = parseMultipart(raw, boundary).find((p) => p.name === "file"); + if (!filePart) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "missing file field" })); + return; + } + const sniffed = sniffImageType(filePart.data); + if (!sniffed) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "unrecognized image type (expected jpeg/png/webp)" })); + return; + } + const defaultCharacter = characters.list().find((c) => c.isDefault); + if (!defaultCharacter) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "no default character configured" })); + return; + } + + let relPath, absPath; + try { + ({ relPath } = stripMetadataAndSave(filePart.data, sniffed)); + absPath = path.join(__dirname, relPath); + } catch { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "failed to process image" })); + return; + } + + const moderation = await moderateGarmentImage(absPath, relPath); + if (!moderation.approved) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "image rejected by moderation", reason: moderation.reason })); + return; + } + + const appearanceDescriptor = await describeGarmentImage(absPath); + const garment = garments.create({ + name: `${user.nickname || user.email.split("@")[0]} 上传`, + category: "", + thumbnailUrl: relPath, + imageUrl: relPath, + appearanceDescriptor, + source: "user_paid", + ownerUserId: user.id, + moderationStatus: "approved", + isActive: true, + }); + + const spendGarment = pricing.get("spend_garment"); + const amount = spendGarment ? spendGarment.credits : 0; + let spendRow; + try { + spendRow = creditLedger.spend({ userId: user.id, amount, reason: "spend_garment", relatedAssetId: garment.id }); + } catch (err) { + garments.remove(garment.id); + const status = err.code === "INSUFFICIENT_BALANCE" ? 402 : 500; + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: err.message })); + return; + } + + const { status, body: respBody } = submitGeneration({ characterId: defaultCharacter.id, garmentId: garment.id }); + if (status !== 200) { + creditLedger.refund({ userId: user.id, amount, reason: "refund_stage2_fail", relatedAssetId: garment.id }); + garments.remove(garment.id); + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(respBody)); + return; + } + + const entry = { + id: crypto.randomUUID(), + garmentId: garment.id, + characterId: defaultCharacter.id, + nickname: user.nickname || null, + userId: user.id, + amountPaid: amount, + submittedAt: Date.now(), + votes: [], + }; + if (respBody.cached) { + entry.status = "ready"; + entry.videoId = respBody.clip.id; + } else { + entry.status = "generating"; + entry.jobId = respBody.jobId; + } + danceQueue.push(entry); + saveDanceState(); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ status: entry.status, id: entry.id, garmentId: garment.id, videoId: entry.videoId || null, creditsSpent: amount, balanceAfter: spendRow.balanceAfter })); + }) + .catch((err) => { + if (res.writableEnded) return; + const status = err.code === "PAYLOAD_TOO_LARGE" ? 413 : 400; + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: err.message || "upload failed" })); + }); + return; + } + if (pathname.startsWith("/api/dance/queue/vote/") && req.method === "POST") { const id = pathname.slice("/api/dance/queue/vote/".length); readJsonBody(req) -- libgit2 0.21.4