Commit b4056f4b66b896ac58b55d56dabea14c4731b85e
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
Showing
4 changed files
with
338 additions
and
3 deletions
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 | ... | ... |