diff --git a/dance.html b/dance.html index f716d34..ce2435c 100644 --- a/dance.html +++ b/dance.html @@ -20,6 +20,7 @@
+ AI 短剧频道 → @@ -203,6 +204,7 @@ function ensureIdentity() { // cookies doesn't silently start a fresh anonymous identity. const authBtn = document.getElementById("authBtn"); const topupBtn = document.getElementById("topupBtn"); +const myWorksLink = document.getElementById("myWorksLink"); const topupBanner = document.getElementById("topupBanner"); const authModal = document.getElementById("authModal"); const authTitle = document.getElementById("authTitle"); @@ -220,6 +222,7 @@ function renderAuthButton(user) { authBtn.textContent = user ? `${user.nickname || user.email.split("@")[0]} · 💰${user.creditBalance} · 退出` : "登录"; authBtn.dataset.loggedIn = user ? "1" : ""; topupBtn.hidden = !user; + myWorksLink.hidden = !user; if (leftTab === "garment" || leftTab === "character") renderWardrobe(); // price/balance-gated panels depend on login state } diff --git a/my-works.html b/my-works.html new file mode 100644 index 0000000..bfdc697 --- /dev/null +++ b/my-works.html @@ -0,0 +1,119 @@ + + + + + +我的作品 + + + + +
+

我的作品

+
+ ← 返回换装跳舞直播间 +
+ +
+
加载中…
+
+ + + + diff --git a/my-works.test.js b/my-works.test.js new file mode 100644 index 0000000..71ea039 --- /dev/null +++ b/my-works.test.js @@ -0,0 +1,189 @@ +// Integration test for T38 (GET /api/dance/my-works), dev-plan-5.md. +// Same mock-H3-Console approach as dance-mode2/3.test.js. +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"); + +const TINY_VALID_PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEklEQVR4nGPkEpFjYGBgYgADAALmAEAUQs4PAAAAAElFTkSuQmCC", + "base64" +); + +const PORT = 33934 + 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, defaultCharacter; + +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) { + return (res.headers.get("set-cookie") || "").split(";")[0]; +} +function authed(cookie, opts = {}) { + return { ...opts, headers: { "Content-Type": "application/json", Cookie: cookie, ...opts.headers } }; +} + +function startMockH3() { + videoPollCounts = new Map(); + let nextId = 1; + return new Promise((resolve) => { + mockH3 = 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") { + 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 = (videoPollCounts.get(id) || 0) + 1; + videoPollCounts.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" })); + }); + }); + mockH3.listen(0, () => { + mockH3Port = mockH3.address().port; + resolve(); + }); + }); +} + +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 = `mywork-${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, id } = await registerRes.json(); + const cookie = sessionCookie(registerRes); + await fetch(`${base}${devVerifyUrl}`); + return { cookie, userId: id }; +} +async function topUp(base, cookie, tierKey = "topup_tier_2") { + 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" }); +} + +test.before(async () => { + await startMockH3(); + cwd = fs.mkdtempSync(path.join(os.tmpdir(), "my-works-")); + 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: "150", SCHEDULE_TICK_MS: "100" }, + 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); + defaultCharacter = await fetch( + `${BASE}/api/admin/characters`, + authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "衣柜小美", imageUrl: "/uploads/person.png", appearanceDescriptor: "black hair", isDefault: true }) }) + ).then((r) => r.json()); + 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("my-works requires login", async () => { + const res = await fetch(`${BASE}/api/dance/my-works`); + assert.equal(res.status, 401); +}); + +test("my-works returns only this user's own garments/characters, with their generated videos, and stays visible after a character expires", async () => { + const { cookie, userId } = await registerVerifiedUser(BASE); + await topUp(BASE, cookie); + + // Mode 2: upload a garment + const fd = new FormData(); + fd.append("file", new Blob([TINY_VALID_PNG], { type: "image/png" }), "garment.png"); + const garmentRes = await fetch(`${BASE}/api/dance/upload-garment`, { method: "POST", headers: { Cookie: cookie }, body: fd }).then((r) => r.json()); + + // Mode 3: upload a character with exactly 1 segment, so it's expired by the time we check + const fd2 = new FormData(); + fd2.append("file", new Blob([TINY_VALID_PNG], { type: "image/png" }), "character.png"); + fd2.append("garmentId", garmentRes.garmentId); + fd2.append("segments", "1"); + const characterRes = await fetch(`${BASE}/api/dance/upload-character`, { method: "POST", headers: { Cookie: cookie }, body: fd2 }).then((r) => r.json()); + + // A second, unrelated user — must not see the first user's stuff + const { cookie: otherCookie } = await registerVerifiedUser(BASE); + + const mine = await waitUntil(async () => { + const body = await fetch(`${BASE}/api/dance/my-works`, { headers: { Cookie: cookie } }).then((r) => r.json()); + const character = body.characters.find((c) => c.id === characterRes.characterId); + return character && character.status === "expired" ? body : null; + }); + + assert.equal(mine.characters.length, 1); + assert.equal(mine.characters[0].ownerUserId, userId); + assert.equal(mine.characters[0].status, "expired", "an expired character must still be listed, not hidden"); + assert.ok(mine.characters[0].videos.length >= 1, "the expired character's generated video(s) must still be attached"); + + assert.equal(mine.garments.length, 1); + assert.equal(mine.garments[0].id, garmentRes.garmentId); + assert.ok(mine.garments[0].videos.length >= 1); + + const others = await fetch(`${BASE}/api/dance/my-works`, { headers: { Cookie: otherCookie } }).then((r) => r.json()); + assert.equal(others.characters.length, 0); + assert.equal(others.garments.length, 0); +}); diff --git a/server.js b/server.js index 9df53a2..3b39990 100644 --- a/server.js +++ b/server.js @@ -1879,9 +1879,8 @@ http // ---- dance.html: public viewer-facing endpoints (T11/T12/T15/T17) ---- // No admin auth (T6 only gates /api/admin/*) — these are the free, - // anonymous, 模式一 (衣柜选衣服) path. 模式二 (付费传衣服) is T27, - // below, and does require login+emailVerified on its own. 模式三 isn't - // built yet (第五期). + // anonymous, 模式一 (衣柜选衣服) path. 模式二/三 (T27/T35, below) each + // require login+emailVerified on their own. if (pathname === "/api/dance/wardrobe" && req.method === "GET") { res.writeHead(200, { "Content-Type": "application/json" }); @@ -1987,6 +1986,31 @@ http return; } + // T38 (dev-plan-5.md): "我的作品" — 需求文档 7.4 节: expired/下线的 + // user_paid characters (T36) and their videos must stay individually + // retrievable by their owner, "下线=不再出现在公开直播间和其他人的 + // 衣柜/随机池里",不是"下线=消失". Login required, no emailVerified + // gate — this is a read of your own past purchases, not a new spend. + if (pathname === "/api/dance/my-works" && req.method === "GET") { + const user = requireUserSession(req); + if (!user) { + res.writeHead(401, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "login required" })); + return; + } + const lib = getLibrary(); + const myCharacters = characters.list().filter((c) => c.ownerUserId === user.id); + const myGarments = garments.list().filter((g) => g.ownerUserId === user.id); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + characters: myCharacters.map((c) => ({ ...c, videos: lib.filter((clip) => clip.characterId === c.id) })), + garments: myGarments.map((g) => ({ ...g, videos: lib.filter((clip) => clip.garmentId === g.id) })), + }) + ); + 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