Commit bcdd04b932beeeee3ee1eabea05158e608a97c3f
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) | ... | ... |