Commit b4056f4b66b896ac58b55d56dabea14c4731b85e

Authored by yibo
1 parent 903e6fef

Add T38: "我的作品" page (GET /api/dance/my-works + my-works.html)

需求文档 7.4 节's stated floor: an expired user_paid character (T36)
or its videos must stay individually retrievable by their owner —
"下线=不再出现在公开直播间和其他人的衣柜/随机池里",不是"下线=消失".

GET /api/dance/my-works (login required, no emailVerified gate — this
reads your own past purchases, doesn't spend anything) returns the
requesting user's own characters/garments (ownerUserId match) each
joined with their generated videos from library.json. New standalone
page my-works.html (matches live.css's visual language) renders two
sections with inline video players (same preload="none" pattern as
admin.html), a status badge (播放中/已下线 for characters, 衣柜中/已下架
for garments), and a login gate for logged-out visitors — reusing
GET /api/dance/my-works's 401 rather than duplicating dance.html's auth
modal in a second page. Linked from dance.html's header once logged in.

my-works.test.js: 401 without a session; a real user who uploaded one
garment (T27) and one 1-segment character (T35, deliberately expired
by test time) sees both with their videos still attached; a second,
unrelated user sees neither.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DUiYs6UjxDMkfvnjtsKFo
dance.html
... ... @@ -20,6 +20,7 @@
20 20 <div class="spacer"></div>
21 21 <div class="watching" id="watchingCount" hidden></div>
22 22 <button class="topbar-pill" id="muteBtn">🔇 静音</button>
  23 + <a class="topbar-pill" id="myWorksLink" href="my-works.html" hidden>📁 我的作品</a>
23 24 <button class="topbar-pill" id="topupBtn" hidden>💰 充值</button>
24 25 <button class="topbar-pill" id="authBtn">登录</button>
25 26 <a class="topbar-pill" href="live.html">AI 短剧频道 →</a>
... ... @@ -203,6 +204,7 @@ function ensureIdentity() {
203 204 // cookies doesn't silently start a fresh anonymous identity.
204 205 const authBtn = document.getElementById("authBtn");
205 206 const topupBtn = document.getElementById("topupBtn");
  207 +const myWorksLink = document.getElementById("myWorksLink");
206 208 const topupBanner = document.getElementById("topupBanner");
207 209 const authModal = document.getElementById("authModal");
208 210 const authTitle = document.getElementById("authTitle");
... ... @@ -220,6 +222,7 @@ function renderAuthButton(user) {
220 222 authBtn.textContent = user ? `${user.nickname || user.email.split("@")[0]} · 💰${user.creditBalance} · 退出` : "登录";
221 223 authBtn.dataset.loggedIn = user ? "1" : "";
222 224 topupBtn.hidden = !user;
  225 + myWorksLink.hidden = !user;
223 226 if (leftTab === "garment" || leftTab === "character") renderWardrobe(); // price/balance-gated panels depend on login state
224 227 }
225 228  
... ...
my-works.html 0 → 100644
  1 +<!DOCTYPE html>
  2 +<html lang="zh-CN">
  3 +<head>
  4 +<meta charset="UTF-8" />
  5 +<meta name="viewport" content="width=device-width, initial-scale=1" />
  6 +<title>我的作品</title>
  7 +<link rel="stylesheet" href="live.css" />
  8 +<style>
  9 + body { background: var(--bg, #fdf6e3); }
  10 + .works-wrap { max-width: 900px; margin: 0 auto; padding: 18px 16px 60px; }
  11 + .works-section-title { font: bold 16px Tahoma, sans-serif; margin: 22px 0 10px; }
  12 + .works-card {
  13 + background: #fff;
  14 + border: 3px solid #000;
  15 + border-radius: 14px;
  16 + padding: 12px;
  17 + margin-bottom: 14px;
  18 + box-shadow: var(--shadow, 3px 3px 0 #000);
  19 + }
  20 + .works-card-head { display: flex; gap: 12px; align-items: flex-start; }
  21 + .works-card-head img { width: 64px; height: 92px; object-fit: cover; border-radius: 8px; border: 1px solid var(--border, #ddd); flex: none; }
  22 + .works-card-title { font: bold 14px Tahoma, sans-serif; }
  23 + .works-badge { display: inline-block; font-size: 11px; font-weight: bold; padding: 2px 8px; border-radius: 10px; margin-left: 6px; }
  24 + .works-badge.active { background: #d4f7d4; color: #1e7d1e; }
  25 + .works-badge.expired { background: #eee; color: #777; }
  26 + .works-meta { font-size: 12px; color: var(--muted, #888); margin-top: 4px; }
  27 + .works-videos { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; }
  28 + .works-videos video { width: 140px; max-height: 190px; background: #000; border-radius: 6px; display: block; }
  29 + .works-empty { color: var(--muted, #888); text-align: center; margin-top: 60px; font-size: 13px; }
  30 + .works-login-gate { text-align: center; margin-top: 80px; }
  31 + .works-login-gate a { font-weight: bold; }
  32 +</style>
  33 +</head>
  34 +<body>
  35 +<header class="topbar">
  36 + <h1 class="logo">我的作品</h1>
  37 + <div class="spacer"></div>
  38 + <a class="topbar-pill" href="dance.html">← 返回换装跳舞直播间</a>
  39 +</header>
  40 +
  41 +<main class="works-wrap" id="main">
  42 + <div class="works-empty">加载中…</div>
  43 +</main>
  44 +
  45 +<script>
  46 +function escapeHtml(s) {
  47 + const div = document.createElement("div");
  48 + div.textContent = s || "";
  49 + return div.innerHTML;
  50 +}
  51 +
  52 +function videoHref(c) {
  53 + return `/lib/video/${c.id}`;
  54 +}
  55 +
  56 +function renderVideos(videos) {
  57 + if (!videos.length) return `<div class="works-meta">还没有生成过视频</div>`;
  58 + return `<div class="works-videos">${videos
  59 + .map((c) => `<video controls preload="none" src="${videoHref(c)}"></video>`)
  60 + .join("")}</div>`;
  61 +}
  62 +
  63 +function renderCharacterCard(c) {
  64 + const badge = c.status === "expired" ? `<span class="works-badge expired">已下线</span>` : `<span class="works-badge active">播放中</span>`;
  65 + const segLine = c.totalSegments != null ? `已播 ${c.totalSegments - (c.remainingSegments ?? 0)} / 购买 ${c.totalSegments} 个 10 秒片段` : "";
  66 + return `
  67 + <div class="works-card">
  68 + <div class="works-card-head">
  69 + <img src="${escapeHtml(c.imageUrl)}" />
  70 + <div>
  71 + <div class="works-card-title">${escapeHtml(c.name)}${badge}</div>
  72 + <div class="works-meta">${segLine}</div>
  73 + <div class="works-meta">上传于 ${new Date(c.createdAt).toLocaleString()}</div>
  74 + </div>
  75 + </div>
  76 + ${renderVideos(c.videos)}
  77 + </div>`;
  78 +}
  79 +
  80 +function renderGarmentCard(g) {
  81 + const badge = g.isActive ? `<span class="works-badge active">衣柜中</span>` : `<span class="works-badge expired">已下架</span>`;
  82 + return `
  83 + <div class="works-card">
  84 + <div class="works-card-head">
  85 + <img src="${escapeHtml(g.thumbnailUrl || g.imageUrl)}" />
  86 + <div>
  87 + <div class="works-card-title">${escapeHtml(g.name)}${badge}</div>
  88 + <div class="works-meta">上传于 ${new Date(g.createdAt).toLocaleString()}</div>
  89 + </div>
  90 + </div>
  91 + ${renderVideos(g.videos)}
  92 + </div>`;
  93 +}
  94 +
  95 +async function load() {
  96 + const main = document.getElementById("main");
  97 + const res = await fetch("/api/dance/my-works");
  98 + if (res.status === 401) {
  99 + main.innerHTML = `<div class="works-login-gate">请先在<br /><a href="dance.html">换装跳舞直播间</a><br />登录后查看你的作品</div>`;
  100 + return;
  101 + }
  102 + if (!res.ok) {
  103 + main.innerHTML = `<div class="works-empty">加载失败,请刷新重试</div>`;
  104 + return;
  105 + }
  106 + const { characters, garments } = await res.json();
  107 + if (!characters.length && !garments.length) {
  108 + main.innerHTML = `<div class="works-empty">还没有传过衣服或人物——去<a href="dance.html">换装跳舞直播间</a>试试吧</div>`;
  109 + return;
  110 + }
  111 + main.innerHTML =
  112 + (characters.length ? `<div class="works-section-title">我的人物</div>${characters.map(renderCharacterCard).join("")}` : "") +
  113 + (garments.length ? `<div class="works-section-title">我的衣服</div>${garments.map(renderGarmentCard).join("")}` : "");
  114 +}
  115 +
  116 +load();
  117 +</script>
  118 +</body>
  119 +</html>
... ...
my-works.test.js 0 → 100644
  1 +// Integration test for T38 (GET /api/dance/my-works), dev-plan-5.md.
  2 +// 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 TINY_VALID_PNG = Buffer.from(
  12 + "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEklEQVR4nGPkEpFjYGBgYgADAALmAEAUQs4PAAAAAElFTkSuQmCC",
  13 + "base64"
  14 +);
  15 +
  16 +const PORT = 33934 + Math.floor(Math.random() * 1000);
  17 +const BASE = `http://localhost:${PORT}`;
  18 +const ADMIN_PASSWORD = "test-password-only-for-this-run";
  19 +let adminCookie, child, cwd, mockH3, mockH3Port, videoPollCounts, defaultCharacter;
  20 +
  21 +function waitForServer(url, timeoutMs = 10000) {
  22 + const deadline = Date.now() + timeoutMs;
  23 + return (async function poll() {
  24 + while (Date.now() < deadline) {
  25 + try {
  26 + await fetch(url);
  27 + return;
  28 + } catch {
  29 + await new Promise((r) => setTimeout(r, 100));
  30 + }
  31 + }
  32 + throw new Error("server did not come up in time");
  33 + })();
  34 +}
  35 +function sessionCookie(res) {
  36 + return (res.headers.get("set-cookie") || "").split(";")[0];
  37 +}
  38 +function authed(cookie, opts = {}) {
  39 + return { ...opts, headers: { "Content-Type": "application/json", Cookie: cookie, ...opts.headers } };
  40 +}
  41 +
  42 +function startMockH3() {
  43 + videoPollCounts = new Map();
  44 + let nextId = 1;
  45 + return new Promise((resolve) => {
  46 + mockH3 = http.createServer((req, res) => {
  47 + req.url = req.url.replace(/^\/+/, "/");
  48 + const chunks = [];
  49 + req.on("data", (c) => chunks.push(c));
  50 + req.on("end", () => {
  51 + if (req.method === "POST" && req.url === "/v1/assets") {
  52 + const id = `asset-${nextId++}`;
  53 + res.writeHead(200, { "Content-Type": "application/json" });
  54 + res.end(JSON.stringify({ id, url: `http://mock-h3/asset/${id}`, kind: "image" }));
  55 + return;
  56 + }
  57 + if (req.method === "POST" && req.url === "/v1/videos") {
  58 + const id = `job-${nextId++}`;
  59 + res.writeHead(200, { "Content-Type": "application/json" });
  60 + res.end(JSON.stringify({ id }));
  61 + return;
  62 + }
  63 + const pollMatch = req.method === "GET" && req.url.match(/^\/v1\/videos\/(.+)$/);
  64 + if (pollMatch) {
  65 + const id = pollMatch[1];
  66 + const seen = (videoPollCounts.get(id) || 0) + 1;
  67 + videoPollCounts.set(id, seen);
  68 + if (seen < 2) {
  69 + res.writeHead(200, { "Content-Type": "application/json" });
  70 + res.end(JSON.stringify({ id, status: "generating" }));
  71 + return;
  72 + }
  73 + res.writeHead(200, { "Content-Type": "application/json" });
  74 + res.end(JSON.stringify({ id, status: "done", partition: "ref2va", req: { width: 480, height: 832, frames: 240 } }));
  75 + return;
  76 + }
  77 + res.writeHead(404, { "Content-Type": "application/json" });
  78 + res.end(JSON.stringify({ error: "not found in mock" }));
  79 + });
  80 + });
  81 + mockH3.listen(0, () => {
  82 + mockH3Port = mockH3.address().port;
  83 + resolve();
  84 + });
  85 + });
  86 +}
  87 +
  88 +async function waitUntil(fn, timeoutMs = 8000, intervalMs = 100) {
  89 + const deadline = Date.now() + timeoutMs;
  90 + while (Date.now() < deadline) {
  91 + const v = await fn();
  92 + if (v) return v;
  93 + await new Promise((r) => setTimeout(r, intervalMs));
  94 + }
  95 + throw new Error("condition not met in time");
  96 +}
  97 +
  98 +async function registerVerifiedUser(base) {
  99 + const email = `mywork-${Date.now()}-${Math.random().toString(36).slice(2)}@example.com`;
  100 + const registerRes = await fetch(`${base}/api/auth/register`, {
  101 + method: "POST",
  102 + headers: { "Content-Type": "application/json" },
  103 + body: JSON.stringify({ email, password: "correct-horse-battery" }),
  104 + });
  105 + const { devVerifyUrl, id } = await registerRes.json();
  106 + const cookie = sessionCookie(registerRes);
  107 + await fetch(`${base}${devVerifyUrl}`);
  108 + return { cookie, userId: id };
  109 +}
  110 +async function topUp(base, cookie, tierKey = "topup_tier_2") {
  111 + const checkoutRes = await fetch(`${base}/api/credits/checkout`, authed(cookie, { method: "POST", body: JSON.stringify({ tierKey }) }));
  112 + const { url } = await checkoutRes.json();
  113 + await fetch(`${base}${url}`, { redirect: "manual" });
  114 +}
  115 +
  116 +test.before(async () => {
  117 + await startMockH3();
  118 + cwd = fs.mkdtempSync(path.join(os.tmpdir(), "my-works-"));
  119 + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js", "pricing.js"]) {
  120 + fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f));
  121 + }
  122 + fs.mkdirSync(path.join(cwd, "uploads"), { recursive: true });
  123 + fs.writeFileSync(path.join(cwd, "uploads", "person.png"), "fake-png-bytes");
  124 + fs.writeFileSync(path.join(cwd, "uploads", "ref.mp4"), "fake-mp4-bytes");
  125 +
  126 + child = spawn(process.execPath, ["server.js"], {
  127 + cwd,
  128 + 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" },
  129 + stdio: "pipe",
  130 + });
  131 + await waitForServer(BASE);
  132 +
  133 + const loginRes = await fetch(`${BASE}/api/admin/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: ADMIN_PASSWORD }) });
  134 + adminCookie = sessionCookie(loginRes);
  135 + defaultCharacter = await fetch(
  136 + `${BASE}/api/admin/characters`,
  137 + authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "衣柜小美", imageUrl: "/uploads/person.png", appearanceDescriptor: "black hair", isDefault: true }) })
  138 + ).then((r) => r.json());
  139 + await fetch(`${BASE}/api/admin/dance-refs`, authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "拉丁舞", videoUrl: "/uploads/ref.mp4", motionDescriptor: "hip sways" }) }));
  140 +});
  141 +test.after(() => {
  142 + child.kill();
  143 + mockH3.close();
  144 + fs.rmSync(cwd, { recursive: true, force: true });
  145 +});
  146 +
  147 +test("my-works requires login", async () => {
  148 + const res = await fetch(`${BASE}/api/dance/my-works`);
  149 + assert.equal(res.status, 401);
  150 +});
  151 +
  152 +test("my-works returns only this user's own garments/characters, with their generated videos, and stays visible after a character expires", async () => {
  153 + const { cookie, userId } = await registerVerifiedUser(BASE);
  154 + await topUp(BASE, cookie);
  155 +
  156 + // Mode 2: upload a garment
  157 + const fd = new FormData();
  158 + fd.append("file", new Blob([TINY_VALID_PNG], { type: "image/png" }), "garment.png");
  159 + const garmentRes = await fetch(`${BASE}/api/dance/upload-garment`, { method: "POST", headers: { Cookie: cookie }, body: fd }).then((r) => r.json());
  160 +
  161 + // Mode 3: upload a character with exactly 1 segment, so it's expired by the time we check
  162 + const fd2 = new FormData();
  163 + fd2.append("file", new Blob([TINY_VALID_PNG], { type: "image/png" }), "character.png");
  164 + fd2.append("garmentId", garmentRes.garmentId);
  165 + fd2.append("segments", "1");
  166 + const characterRes = await fetch(`${BASE}/api/dance/upload-character`, { method: "POST", headers: { Cookie: cookie }, body: fd2 }).then((r) => r.json());
  167 +
  168 + // A second, unrelated user — must not see the first user's stuff
  169 + const { cookie: otherCookie } = await registerVerifiedUser(BASE);
  170 +
  171 + const mine = await waitUntil(async () => {
  172 + const body = await fetch(`${BASE}/api/dance/my-works`, { headers: { Cookie: cookie } }).then((r) => r.json());
  173 + const character = body.characters.find((c) => c.id === characterRes.characterId);
  174 + return character && character.status === "expired" ? body : null;
  175 + });
  176 +
  177 + assert.equal(mine.characters.length, 1);
  178 + assert.equal(mine.characters[0].ownerUserId, userId);
  179 + assert.equal(mine.characters[0].status, "expired", "an expired character must still be listed, not hidden");
  180 + assert.ok(mine.characters[0].videos.length >= 1, "the expired character's generated video(s) must still be attached");
  181 +
  182 + assert.equal(mine.garments.length, 1);
  183 + assert.equal(mine.garments[0].id, garmentRes.garmentId);
  184 + assert.ok(mine.garments[0].videos.length >= 1);
  185 +
  186 + const others = await fetch(`${BASE}/api/dance/my-works`, { headers: { Cookie: otherCookie } }).then((r) => r.json());
  187 + assert.equal(others.characters.length, 0);
  188 + assert.equal(others.garments.length, 0);
  189 +});
... ...
server.js
... ... @@ -1879,9 +1879,8 @@ http
1879 1879  
1880 1880 // ---- dance.html: public viewer-facing endpoints (T11/T12/T15/T17) ----
1881 1881 // No admin auth (T6 only gates /api/admin/*) — these are the free,
1882   - // anonymous, 模式一 (衣柜选衣服) path. 模式二 (付费传衣服) is T27,
1883   - // below, and does require login+emailVerified on its own. 模式三 isn't
1884   - // built yet (第五期).
  1882 + // anonymous, 模式一 (衣柜选衣服) path. 模式二/三 (T27/T35, below) each
  1883 + // require login+emailVerified on their own.
1885 1884  
1886 1885 if (pathname === "/api/dance/wardrobe" && req.method === "GET") {
1887 1886 res.writeHead(200, { "Content-Type": "application/json" });
... ... @@ -1987,6 +1986,31 @@ http
1987 1986 return;
1988 1987 }
1989 1988  
  1989 + // T38 (dev-plan-5.md): "我的作品" — 需求文档 7.4 节: expired/下线的
  1990 + // user_paid characters (T36) and their videos must stay individually
  1991 + // retrievable by their owner, "下线=不再出现在公开直播间和其他人的
  1992 + // 衣柜/随机池里",不是"下线=消失". Login required, no emailVerified
  1993 + // gate — this is a read of your own past purchases, not a new spend.
  1994 + if (pathname === "/api/dance/my-works" && req.method === "GET") {
  1995 + const user = requireUserSession(req);
  1996 + if (!user) {
  1997 + res.writeHead(401, { "Content-Type": "application/json" });
  1998 + res.end(JSON.stringify({ error: "login required" }));
  1999 + return;
  2000 + }
  2001 + const lib = getLibrary();
  2002 + const myCharacters = characters.list().filter((c) => c.ownerUserId === user.id);
  2003 + const myGarments = garments.list().filter((g) => g.ownerUserId === user.id);
  2004 + res.writeHead(200, { "Content-Type": "application/json" });
  2005 + res.end(
  2006 + JSON.stringify({
  2007 + characters: myCharacters.map((c) => ({ ...c, videos: lib.filter((clip) => clip.characterId === c.id) })),
  2008 + garments: myGarments.map((g) => ({ ...g, videos: lib.filter((clip) => clip.garmentId === g.id) })),
  2009 + })
  2010 + );
  2011 + return;
  2012 + }
  2013 +
1990 2014 // T27 模式二: 付费传衣服. 需登录 + emailVerified (M.10). Order matters —
1991 2015 // moderation runs before any garments record or spend exists (a
1992 2016 // rejected upload leaves nothing behind); the garment record is
... ...