Commit bcdd04b932beeeee3ee1eabea05158e608a97c3f

Authored by yibo
1 parent 57689a4e

Add T27: mode 2 backend — POST /api/dance/upload-garment

Requires login + emailVerified (M.10). Order matters and each step
undoes the previous one on failure: moderate (T26) before anything
else exists, so a rejected image leaves nothing behind; create the
garments row (source: user_paid) before spending, so the ledger entry
can carry relatedAssetId — a failed spend deletes it back out;
generation submission failure refunds and deletes it too. Reuses T8/
T9's submitGeneration whole, same M.7 dedupe cache, then rides the
same danceQueue/ensureDanceSchedule machinery /api/dance/pick (mode 1)
already uses — a paid entry only differs by carrying userId/
amountPaid, which mode 1's free entries don't set.

Async generation failure (job fails after this handler already
responded) is refunded from ensureDanceSchedule's existing "generating
-> failed" tick, gated on a findByRelatedAssetAndReason lookup so a
crash between that write and saveDanceState() persisting it can't
double-refund — a duplicate here is real money, not a cosmetic bug.

dance-mode2.test.js covers the full chain against a mock H3 Console:
login/verification/balance gates, a non-image upload rejected before
anything is spent, the happy path (spend -> auto-tag -> generate ->
lands in dance schedule, garments row has user_paid/ownerUserId/
appearanceDescriptor), and — via a second server pointed at a mock H3
that always fails submission — the async refund path, redelivered
twice to prove it's exactly-once.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DUiYs6UjxDMkfvnjtsKFo
Showing 2 changed files with 496 additions and 0 deletions
dance-mode2.test.js 0 → 100644
  1 +// Integration test for T27 (mode 2: 付费传衣服, POST /api/dance/upload-garment),
  2 +// dev-plan-4.md. Same mock-H3-Console approach as dance-channel.test.js so
  3 +// the full upload -> moderate -> spend -> auto-tag -> generate -> land-in-
  4 +// schedule path runs offline and fast.
  5 +const test = require("node:test");
  6 +const assert = require("node:assert/strict");
  7 +const fs = require("node:fs");
  8 +const path = require("node:path");
  9 +const os = require("node:os");
  10 +const http = require("node:http");
  11 +const { spawn } = require("node:child_process");
  12 +
  13 +// A real, valid 2x2 PNG (not just the 8-byte magic signature) — needed
  14 +// because sips has to actually decode it during stripMetadataAndSave.
  15 +const TINY_VALID_PNG = Buffer.from(
  16 + "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEklEQVR4nGPkEpFjYGBgYgADAALmAEAUQs4PAAAAAElFTkSuQmCC",
  17 + "base64"
  18 +);
  19 +
  20 +const PORT = 29934 + Math.floor(Math.random() * 1000);
  21 +const BASE = `http://localhost:${PORT}`;
  22 +const ADMIN_PASSWORD = "test-password-only-for-this-run";
  23 +let adminCookie, child, cwd, mockH3, mockH3Port, videoPollCounts;
  24 +
  25 +function waitForServer(url, timeoutMs = 10000) {
  26 + const deadline = Date.now() + timeoutMs;
  27 + return (async function poll() {
  28 + while (Date.now() < deadline) {
  29 + try {
  30 + await fetch(url);
  31 + return;
  32 + } catch {
  33 + await new Promise((r) => setTimeout(r, 100));
  34 + }
  35 + }
  36 + throw new Error("server did not come up in time");
  37 + })();
  38 +}
  39 +
  40 +function sessionCookie(res) {
  41 + const setCookie = res.headers.get("set-cookie") || "";
  42 + return setCookie.split(";")[0];
  43 +}
  44 +
  45 +function authed(cookie, opts = {}) {
  46 + return { ...opts, headers: { "Content-Type": "application/json", Cookie: cookie, ...opts.headers } };
  47 +}
  48 +
  49 +// Same shape as dance-channel.test.js's mock — enough of /v1/assets,
  50 +// /v1/videos and /v1/videos/:id for generateDanceVideo to run end to end.
  51 +// `failSubmit` lets one test force a "submit failed" so T27's async refund
  52 +// path (ensureDanceSchedule's failure branch) actually has something to
  53 +// trigger on.
  54 +// Returns {server, port} rather than mutating module-level mockH3/mockH3Port
  55 +// — the "generation failure" test below needs a second, independently
  56 +// closeable instance without leaking the first (test.before's) one, which
  57 +// would otherwise keep node --test's process alive forever after all tests
  58 +// finish (an unclosed http.Server keeps the event loop open).
  59 +function startMockH3(failSubmit = false) {
  60 + const pollCounts = new Map();
  61 + let nextId = 1;
  62 + return new Promise((resolve) => {
  63 + const server = http.createServer((req, res) => {
  64 + req.url = req.url.replace(/^\/+/, "/");
  65 + const chunks = [];
  66 + req.on("data", (c) => chunks.push(c));
  67 + req.on("end", () => {
  68 + if (req.method === "POST" && req.url === "/v1/assets") {
  69 + const id = `asset-${nextId++}`;
  70 + res.writeHead(200, { "Content-Type": "application/json" });
  71 + res.end(JSON.stringify({ id, url: `http://mock-h3/asset/${id}`, kind: "image" }));
  72 + return;
  73 + }
  74 + if (req.method === "POST" && req.url === "/v1/videos") {
  75 + if (failSubmit) {
  76 + res.writeHead(200, { "Content-Type": "application/json" });
  77 + res.end(JSON.stringify({ detail: "mock forced submit failure" }));
  78 + return;
  79 + }
  80 + const id = `job-${nextId++}`;
  81 + res.writeHead(200, { "Content-Type": "application/json" });
  82 + res.end(JSON.stringify({ id }));
  83 + return;
  84 + }
  85 + const pollMatch = req.method === "GET" && req.url.match(/^\/v1\/videos\/(.+)$/);
  86 + if (pollMatch) {
  87 + const id = pollMatch[1];
  88 + const seen = (pollCounts.get(id) || 0) + 1;
  89 + pollCounts.set(id, seen);
  90 + if (seen < 2) {
  91 + res.writeHead(200, { "Content-Type": "application/json" });
  92 + res.end(JSON.stringify({ id, status: "generating" }));
  93 + return;
  94 + }
  95 + res.writeHead(200, { "Content-Type": "application/json" });
  96 + res.end(JSON.stringify({ id, status: "done", partition: "ref2va", req: { width: 480, height: 832, frames: 240 } }));
  97 + return;
  98 + }
  99 + res.writeHead(404, { "Content-Type": "application/json" });
  100 + res.end(JSON.stringify({ error: "not found in mock" }));
  101 + });
  102 + });
  103 + server.listen(0, () => resolve({ server, port: server.address().port }));
  104 + });
  105 +}
  106 +
  107 +async function waitUntil(fn, timeoutMs = 8000, intervalMs = 100) {
  108 + const deadline = Date.now() + timeoutMs;
  109 + while (Date.now() < deadline) {
  110 + const v = await fn();
  111 + if (v) return v;
  112 + await new Promise((r) => setTimeout(r, intervalMs));
  113 + }
  114 + throw new Error("condition not met in time");
  115 +}
  116 +
  117 +async function registerVerifiedUser(base) {
  118 + const email = `mode2-${Date.now()}-${Math.random().toString(36).slice(2)}@example.com`;
  119 + const registerRes = await fetch(`${base}/api/auth/register`, {
  120 + method: "POST",
  121 + headers: { "Content-Type": "application/json" },
  122 + body: JSON.stringify({ email, password: "correct-horse-battery" }),
  123 + });
  124 + const { devVerifyUrl } = await registerRes.json();
  125 + const cookie = sessionCookie(registerRes);
  126 + await fetch(`${base}${devVerifyUrl}`);
  127 + return cookie;
  128 +}
  129 +
  130 +// Tops up a logged-in user via the real T24 mock-checkout loop (not by
  131 +// reaching into db.js directly — the spawned server has its own db.js/
  132 +// data.sqlite in its own tmp cwd, so this test process's own db.js import
  133 +// wouldn't even point at the same file).
  134 +async function topUp(base, cookie, tierKey = "topup_tier_1") {
  135 + const checkoutRes = await fetch(`${base}/api/credits/checkout`, authed(cookie, { method: "POST", body: JSON.stringify({ tierKey }) }));
  136 + const { url } = await checkoutRes.json();
  137 + await fetch(`${base}${url}`, { redirect: "manual" });
  138 +}
  139 +
  140 +function uploadGarment(base, cookie, png = TINY_VALID_PNG) {
  141 + const fd = new FormData();
  142 + fd.append("file", new Blob([png], { type: "image/png" }), "garment.png");
  143 + return fetch(`${base}/api/dance/upload-garment`, { method: "POST", headers: { Cookie: cookie }, body: fd });
  144 +}
  145 +
  146 +test.before(async () => {
  147 + ({ server: mockH3, port: mockH3Port } = await startMockH3());
  148 +
  149 + cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dance-mode2-"));
  150 + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js", "pricing.js"]) {
  151 + fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f));
  152 + }
  153 + fs.mkdirSync(path.join(cwd, "uploads"), { recursive: true });
  154 + fs.writeFileSync(path.join(cwd, "uploads", "person.png"), "fake-png-bytes");
  155 + fs.writeFileSync(path.join(cwd, "uploads", "ref.mp4"), "fake-mp4-bytes");
  156 +
  157 + child = spawn(process.execPath, ["server.js"], {
  158 + cwd,
  159 + env: {
  160 + ...process.env,
  161 + PORT: String(PORT),
  162 + H3_CONSOLE_BASE: `http://localhost:${mockH3Port}`,
  163 + H3_CONSOLE_TOKEN: "mock-token",
  164 + ADMIN_PASSWORD,
  165 + GENERATION_POLL_INTERVAL_MS: "400",
  166 + SCHEDULE_TICK_MS: "150",
  167 + },
  168 + stdio: "pipe",
  169 + });
  170 + await waitForServer(BASE);
  171 +
  172 + const loginRes = await fetch(`${BASE}/api/admin/login`, {
  173 + method: "POST",
  174 + headers: { "Content-Type": "application/json" },
  175 + body: JSON.stringify({ password: ADMIN_PASSWORD }),
  176 + });
  177 + adminCookie = sessionCookie(loginRes);
  178 +
  179 + await fetch(
  180 + `${BASE}/api/admin/characters`,
  181 + authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "衣柜小美", imageUrl: "/uploads/person.png", appearanceDescriptor: "black hair", isDefault: true }) })
  182 + );
  183 + await fetch(
  184 + `${BASE}/api/admin/dance-refs`,
  185 + authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "衣柜拉丁舞", videoUrl: "/uploads/ref.mp4", motionDescriptor: "hip sways" }) })
  186 + );
  187 +});
  188 +
  189 +test.after(() => {
  190 + child.kill();
  191 + mockH3.close();
  192 + fs.rmSync(cwd, { recursive: true, force: true });
  193 +});
  194 +
  195 +test("upload-garment requires login", async () => {
  196 + const res = await uploadGarment(BASE, "");
  197 + assert.equal(res.status, 401);
  198 +});
  199 +
  200 +test("upload-garment requires a verified email", async () => {
  201 + const email = `mode2-unverified-${Date.now()}@example.com`;
  202 + const registerRes = await fetch(`${BASE}/api/auth/register`, {
  203 + method: "POST",
  204 + headers: { "Content-Type": "application/json" },
  205 + body: JSON.stringify({ email, password: "correct-horse-battery" }),
  206 + });
  207 + const cookie = sessionCookie(registerRes);
  208 + const res = await uploadGarment(BASE, cookie);
  209 + assert.equal(res.status, 403);
  210 +});
  211 +
  212 +test("upload-garment with insufficient balance is rejected (402) and leaves no garment behind", async () => {
  213 + const cookie = await registerVerifiedUser(BASE);
  214 + const garmentsBefore = await fetch(`${BASE}/api/admin/garments`, authed(adminCookie)).then((r) => r.json());
  215 +
  216 + const res = await uploadGarment(BASE, cookie);
  217 + assert.equal(res.status, 402);
  218 +
  219 + const garmentsAfter = await fetch(`${BASE}/api/admin/garments`, authed(adminCookie)).then((r) => r.json());
  220 + assert.equal(garmentsAfter.length, garmentsBefore.length, "a rejected-for-balance upload must not leave a phantom garments row");
  221 +});
  222 +
  223 +test("a non-image file is rejected before anything is spent or created", async () => {
  224 + const cookie = await registerVerifiedUser(BASE);
  225 + await topUp(BASE, cookie);
  226 + const fd = new FormData();
  227 + fd.append("file", new Blob([Buffer.from("not an image")], { type: "text/plain" }), "not-an-image.txt");
  228 + const res = await fetch(`${BASE}/api/dance/upload-garment`, { method: "POST", headers: { Cookie: cookie }, body: fd });
  229 + assert.equal(res.status, 400);
  230 + const me = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json());
  231 + assert.ok(me.creditBalance > 0, "a rejected file upload must not spend credits");
  232 +});
  233 +
  234 +test("full happy path: upload -> moderated+auto-tagged -> spent -> generated -> lands in dance schedule, with characterId/garmentId/ownerUserId traceability", async () => {
  235 + const cookie = await registerVerifiedUser(BASE);
  236 + await topUp(BASE, cookie);
  237 + const meBefore = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json());
  238 + const { id: userId } = meBefore;
  239 +
  240 + const res = await uploadGarment(BASE, cookie);
  241 + assert.equal(res.status, 200);
  242 + const body = await res.json();
  243 + assert.ok(body.garmentId);
  244 + assert.ok(body.creditsSpent > 0);
  245 + assert.equal(["generating", "ready"].includes(body.status), true);
  246 +
  247 + const meAfter = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json());
  248 + assert.equal(meAfter.creditBalance, meBefore.creditBalance - body.creditsSpent);
  249 +
  250 + const garments = await fetch(`${BASE}/api/admin/garments`, authed(adminCookie)).then((r) => r.json());
  251 + const created = garments.find((g) => g.id === body.garmentId);
  252 + assert.ok(created, "the uploaded garment must be visible in the (now shared/public) garments library");
  253 + assert.equal(created.source, "user_paid");
  254 + assert.equal(created.ownerUserId, userId);
  255 + assert.equal(created.moderationStatus, "approved");
  256 + assert.ok(created.appearanceDescriptor && created.appearanceDescriptor.length > 0, "T25's auto-tagging must fill a non-empty appearanceDescriptor even with no vision vendor configured");
  257 +
  258 + // lands in the dance schedule and plays, same end state dance-channel.test.js checks for mode 1
  259 + await waitUntil(async () => {
  260 + const state = await fetch(`${BASE}/api/dance/live/state`).then((r) => r.json());
  261 + return state.current && state.current.clip && state.current.clip.garmentId === body.garmentId ? state : null;
  262 + });
  263 +});
  264 +
  265 +// ---- async generation failure -> refund (its own server + a mock H3 that
  266 +// always fails submission, so the refund path in ensureDanceSchedule's
  267 +// tick has something real to trigger on) ----
  268 +
  269 +test("a generation failure after upload refunds the spent credits, exactly once", async () => {
  270 + const { server: failingMockH3, port: failingMockH3Port } = await startMockH3(true);
  271 + const port = 30934 + Math.floor(Math.random() * 1000);
  272 + const base = `http://localhost:${port}`;
  273 + const failCwd = fs.mkdtempSync(path.join(os.tmpdir(), "dance-mode2-fail-"));
  274 + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js", "pricing.js"]) {
  275 + fs.copyFileSync(path.join(__dirname, f), path.join(failCwd, f));
  276 + }
  277 + fs.mkdirSync(path.join(failCwd, "uploads"), { recursive: true });
  278 + fs.writeFileSync(path.join(failCwd, "uploads", "person.png"), "fake-png-bytes");
  279 + fs.writeFileSync(path.join(failCwd, "uploads", "ref.mp4"), "fake-mp4-bytes");
  280 +
  281 + const failChild = spawn(process.execPath, ["server.js"], {
  282 + cwd: failCwd,
  283 + env: {
  284 + ...process.env,
  285 + PORT: String(port),
  286 + H3_CONSOLE_BASE: `http://localhost:${failingMockH3Port}`,
  287 + H3_CONSOLE_TOKEN: "mock-token",
  288 + ADMIN_PASSWORD,
  289 + GENERATION_POLL_INTERVAL_MS: "100",
  290 + SCHEDULE_TICK_MS: "100",
  291 + },
  292 + stdio: "pipe",
  293 + });
  294 + try {
  295 + await waitForServer(base);
  296 + const failAdminLogin = await fetch(`${base}/api/admin/login`, {
  297 + method: "POST",
  298 + headers: { "Content-Type": "application/json" },
  299 + body: JSON.stringify({ password: ADMIN_PASSWORD }),
  300 + });
  301 + const failAdminCookie = sessionCookie(failAdminLogin);
  302 + await fetch(
  303 + `${base}/api/admin/characters`,
  304 + authed(failAdminCookie, { method: "POST", body: JSON.stringify({ name: "衣柜小美", imageUrl: "/uploads/person.png", appearanceDescriptor: "black hair", isDefault: true }) })
  305 + );
  306 + await fetch(
  307 + `${base}/api/admin/dance-refs`,
  308 + authed(failAdminCookie, { method: "POST", body: JSON.stringify({ name: "衣柜拉丁舞", videoUrl: "/uploads/ref.mp4", motionDescriptor: "hip sways" }) })
  309 + );
  310 +
  311 + const cookie = await registerVerifiedUser(base);
  312 + await topUp(base, cookie);
  313 + const meBefore = await fetch(`${base}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json());
  314 +
  315 + const res = await uploadGarment(base, cookie);
  316 + assert.equal(res.status, 200);
  317 + const { creditsSpent } = await res.json();
  318 + const meAfterSpend = await fetch(`${base}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json());
  319 + assert.equal(meAfterSpend.creditBalance, meBefore.creditBalance - creditsSpent);
  320 +
  321 + // the mock always fails POST /v1/videos, so the generation job (and
  322 + // the danceQueue entry riding on it) should flip to "failed" shortly,
  323 + // and ensureDanceSchedule's tick should refund it back.
  324 + await waitUntil(async () => {
  325 + const me = await fetch(`${base}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json());
  326 + return me.creditBalance === meBefore.creditBalance ? me : null;
  327 + });
  328 +
  329 + // give a couple more ticks a chance to run — balance must not drift
  330 + // past a full refund (i.e. no double-refund)
  331 + await new Promise((r) => setTimeout(r, 500));
  332 + const meFinal = await fetch(`${base}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json());
  333 + assert.equal(meFinal.creditBalance, meBefore.creditBalance);
  334 + } finally {
  335 + failChild.kill();
  336 + failingMockH3.close();
  337 + fs.rmSync(failCwd, { recursive: true, force: true });
  338 + }
  339 +});
... ...
server.js
... ... @@ -1106,6 +1106,32 @@ function ensureDanceSchedule() {
1106 1106 q.status = "failed";
1107 1107 q.error = job.error;
1108 1108 q.failedAt = now;
  1109 + // T27's paid path (userId/amountPaid only set there — free mode-一
  1110 + // picks have neither): refund on generation failure, per "共同后置
  1111 + // 流程"'s stated floor. Guarded by an existing-refund lookup rather
  1112 + // than trusting this branch only ever runs once — it normally does
  1113 + // (danceQueue entries only transition "generating" -> "failed" a
  1114 + // single time, this loop's own `q.status !== "generating"` guard
  1115 + // above prevents re-entry), but a crash between this write and
  1116 + // saveDanceState() persisting it could in principle replay the same
  1117 + // transition once after restart, and a duplicate refund is real
  1118 + // money, not a cosmetic bug.
  1119 + if (q.userId && q.amountPaid) {
  1120 + const alreadyRefunded = creditLedger.findByRelatedAssetAndReason(q.garmentId, "refund_stage2_fail").length > 0;
  1121 + if (!alreadyRefunded) {
  1122 + try {
  1123 + creditLedger.refund({ userId: q.userId, amount: q.amountPaid, reason: "refund_stage2_fail", relatedAssetId: q.garmentId });
  1124 + } catch (err) {
  1125 + // NO_SUCH_USER (deleted account) is the only realistic failure
  1126 + // here — refund only ever adds, so it can't hit
  1127 + // INSUFFICIENT_BALANCE. This branch won't be revisited (the
  1128 + // guard above only enters on "generating" -> "failed", a
  1129 + // one-time transition), so this is a genuine miss, not a
  1130 + // deferred retry — logged rather than silently dropped.
  1131 + console.error(`[refund_stage2_fail] failed to refund userId=${q.userId} garmentId=${q.garmentId}: ${err.message}`);
  1132 + }
  1133 + }
  1134 + }
1109 1135 }
1110 1136 }
1111 1137  
... ... @@ -1658,6 +1684,137 @@ http
1658 1684 return;
1659 1685 }
1660 1686  
  1687 + // T27 模式二: 付费传衣服. 需登录 + emailVerified (M.10). Order matters —
  1688 + // moderation runs before any garments record or spend exists (a
  1689 + // rejected upload leaves nothing behind); the garment record is
  1690 + // created before spend so the ledger row can carry relatedAssetId, and
  1691 + // is rolled back (garments.remove) if the spend or the generation
  1692 + // submission itself fails, so a failed/rejected attempt never leaves a
  1693 + // phantom garment in the library. Async generation failures (after
  1694 + // this handler has already responded) are refunded from
  1695 + // ensureDanceSchedule's tick instead — see its own comment.
  1696 + if (pathname === "/api/dance/upload-garment" && req.method === "POST") {
  1697 + const user = requireUserSession(req);
  1698 + if (!user) {
  1699 + res.writeHead(401, { "Content-Type": "application/json" });
  1700 + res.end(JSON.stringify({ error: "login required" }));
  1701 + return;
  1702 + }
  1703 + if (!user.emailVerified) {
  1704 + res.writeHead(403, { "Content-Type": "application/json" });
  1705 + res.end(JSON.stringify({ error: "email verification required before uploading a garment" }));
  1706 + return;
  1707 + }
  1708 + const boundary = boundaryFromContentType(req.headers["content-type"] || "");
  1709 + if (!boundary) {
  1710 + res.writeHead(400, { "Content-Type": "application/json" });
  1711 + res.end(JSON.stringify({ error: "expected multipart/form-data" }));
  1712 + return;
  1713 + }
  1714 + readRawBody(req)
  1715 + .then(async (raw) => {
  1716 + const filePart = parseMultipart(raw, boundary).find((p) => p.name === "file");
  1717 + if (!filePart) {
  1718 + res.writeHead(400, { "Content-Type": "application/json" });
  1719 + res.end(JSON.stringify({ error: "missing file field" }));
  1720 + return;
  1721 + }
  1722 + const sniffed = sniffImageType(filePart.data);
  1723 + if (!sniffed) {
  1724 + res.writeHead(400, { "Content-Type": "application/json" });
  1725 + res.end(JSON.stringify({ error: "unrecognized image type (expected jpeg/png/webp)" }));
  1726 + return;
  1727 + }
  1728 + const defaultCharacter = characters.list().find((c) => c.isDefault);
  1729 + if (!defaultCharacter) {
  1730 + res.writeHead(400, { "Content-Type": "application/json" });
  1731 + res.end(JSON.stringify({ error: "no default character configured" }));
  1732 + return;
  1733 + }
  1734 +
  1735 + let relPath, absPath;
  1736 + try {
  1737 + ({ relPath } = stripMetadataAndSave(filePart.data, sniffed));
  1738 + absPath = path.join(__dirname, relPath);
  1739 + } catch {
  1740 + res.writeHead(400, { "Content-Type": "application/json" });
  1741 + res.end(JSON.stringify({ error: "failed to process image" }));
  1742 + return;
  1743 + }
  1744 +
  1745 + const moderation = await moderateGarmentImage(absPath, relPath);
  1746 + if (!moderation.approved) {
  1747 + res.writeHead(400, { "Content-Type": "application/json" });
  1748 + res.end(JSON.stringify({ error: "image rejected by moderation", reason: moderation.reason }));
  1749 + return;
  1750 + }
  1751 +
  1752 + const appearanceDescriptor = await describeGarmentImage(absPath);
  1753 + const garment = garments.create({
  1754 + name: `${user.nickname || user.email.split("@")[0]} 上传`,
  1755 + category: "",
  1756 + thumbnailUrl: relPath,
  1757 + imageUrl: relPath,
  1758 + appearanceDescriptor,
  1759 + source: "user_paid",
  1760 + ownerUserId: user.id,
  1761 + moderationStatus: "approved",
  1762 + isActive: true,
  1763 + });
  1764 +
  1765 + const spendGarment = pricing.get("spend_garment");
  1766 + const amount = spendGarment ? spendGarment.credits : 0;
  1767 + let spendRow;
  1768 + try {
  1769 + spendRow = creditLedger.spend({ userId: user.id, amount, reason: "spend_garment", relatedAssetId: garment.id });
  1770 + } catch (err) {
  1771 + garments.remove(garment.id);
  1772 + const status = err.code === "INSUFFICIENT_BALANCE" ? 402 : 500;
  1773 + res.writeHead(status, { "Content-Type": "application/json" });
  1774 + res.end(JSON.stringify({ error: err.message }));
  1775 + return;
  1776 + }
  1777 +
  1778 + const { status, body: respBody } = submitGeneration({ characterId: defaultCharacter.id, garmentId: garment.id });
  1779 + if (status !== 200) {
  1780 + creditLedger.refund({ userId: user.id, amount, reason: "refund_stage2_fail", relatedAssetId: garment.id });
  1781 + garments.remove(garment.id);
  1782 + res.writeHead(status, { "Content-Type": "application/json" });
  1783 + res.end(JSON.stringify(respBody));
  1784 + return;
  1785 + }
  1786 +
  1787 + const entry = {
  1788 + id: crypto.randomUUID(),
  1789 + garmentId: garment.id,
  1790 + characterId: defaultCharacter.id,
  1791 + nickname: user.nickname || null,
  1792 + userId: user.id,
  1793 + amountPaid: amount,
  1794 + submittedAt: Date.now(),
  1795 + votes: [],
  1796 + };
  1797 + if (respBody.cached) {
  1798 + entry.status = "ready";
  1799 + entry.videoId = respBody.clip.id;
  1800 + } else {
  1801 + entry.status = "generating";
  1802 + entry.jobId = respBody.jobId;
  1803 + }
  1804 + danceQueue.push(entry);
  1805 + saveDanceState();
  1806 + res.writeHead(200, { "Content-Type": "application/json" });
  1807 + res.end(JSON.stringify({ status: entry.status, id: entry.id, garmentId: garment.id, videoId: entry.videoId || null, creditsSpent: amount, balanceAfter: spendRow.balanceAfter }));
  1808 + })
  1809 + .catch((err) => {
  1810 + if (res.writableEnded) return;
  1811 + const status = err.code === "PAYLOAD_TOO_LARGE" ? 413 : 400;
  1812 + res.writeHead(status, { "Content-Type": "application/json" });
  1813 + res.end(JSON.stringify({ error: err.message || "upload failed" }));
  1814 + });
  1815 + return;
  1816 + }
  1817 +
1661 1818 if (pathname.startsWith("/api/dance/queue/vote/") && req.method === "POST") {
1662 1819 const id = pathname.slice("/api/dance/queue/vote/".length);
1663 1820 readJsonBody(req)
... ...