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