diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 0322d9e9d..0cc109130 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -553,6 +553,8 @@ export interface AdminUpsertProfileRedeemCodeRequest { enabled: boolean; allowedUserIds: string[]; allowedPublicUserCodes: string[]; + startsAt?: string | null; + expiresAt?: string | null; } export interface AdminUpsertProfileInviteCodeRequest { @@ -614,6 +616,8 @@ export interface ProfileRedeemCodeAdminResponse { globalUsedCount: number; enabled: boolean; allowedUserIds: string[]; + startsAt?: string | null; + expiresAt?: string | null; createdBy: string; createdAt: string; updatedAt: string; diff --git a/apps/admin-web/src/pages/AdminRedeemCodePage.test.tsx b/apps/admin-web/src/pages/AdminRedeemCodePage.test.tsx new file mode 100644 index 000000000..2e3884bdd --- /dev/null +++ b/apps/admin-web/src/pages/AdminRedeemCodePage.test.tsx @@ -0,0 +1,166 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, expect, test, vi } from 'vitest'; + +import { + disableProfileRedeemCode, + listProfileRedeemCodes, + upsertProfileRedeemCode, +} from '../api/adminApiClient'; +import type { ProfileRedeemCodeAdminResponse } from '../api/adminApiTypes'; +import { AdminRedeemCodePage } from './AdminRedeemCodePage'; + +vi.mock('../api/adminApiClient', () => ({ + disableProfileRedeemCode: vi.fn(), + formatAdminApiError: vi.fn((error: unknown) => + error instanceof Error ? error.message : '请求失败', + ), + isAdminApiError: vi.fn(() => false), + listProfileRedeemCodes: vi.fn(), + upsertProfileRedeemCode: vi.fn(), +})); + +const baseEntry: ProfileRedeemCodeAdminResponse = { + code: 'LONG-LIVED', + mode: 'public', + rewardPoints: 100, + maxUses: 1, + globalUsedCount: 0, + enabled: true, + allowedUserIds: [], + startsAt: null, + expiresAt: null, + createdBy: 'admin', + createdAt: '2026-07-13T01:00:00Z', + updatedAt: '2026-07-13T01:00:00Z', +}; + +const entries: ProfileRedeemCodeAdminResponse[] = [ + baseEntry, + { + ...baseEntry, + code: 'PENDING', + startsAt: '2999-01-01T00:00:00Z', + }, + { + ...baseEntry, + code: 'EXPIRED', + expiresAt: '2000-01-01T00:00:00Z', + }, + { + ...baseEntry, + code: 'ACTIVE', + startsAt: '2000-01-01T00:00:00Z', + expiresAt: '2999-01-01T00:00:00Z', + }, + { + ...baseEntry, + code: 'DISABLED', + enabled: false, + startsAt: '2000-01-01T00:00:00Z', + expiresAt: '2999-01-01T00:00:00Z', + }, +]; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(listProfileRedeemCodes).mockResolvedValue({ + entries, + operations: [], + }); + vi.mocked(upsertProfileRedeemCode).mockResolvedValue(baseEntry); + vi.mocked(disableProfileRedeemCode).mockResolvedValue({ + ...baseEntry, + enabled: false, + }); +}); + +test('兑换码列表展示生效状态与日期范围', async () => { + render(); + + expect((await rowForCode('LONG-LIVED')).textContent).toContain('长期有效'); + expect((await rowForCode('LONG-LIVED')).textContent).toContain('立即 / 长期'); + expect((await rowForCode('PENDING')).textContent).toContain('未生效'); + expect((await rowForCode('EXPIRED')).textContent).toContain('已过期'); + expect((await rowForCode('ACTIVE')).textContent).toContain('有效'); + expect((await rowForCode('DISABLED')).textContent).toContain('停用'); +}); + +test('点击兑换码回填本地日期输入', async () => { + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByRole('button', { name: 'ACTIVE' })); + + expect((screen.getByLabelText('开始时间') as HTMLInputElement).value).toBe( + toLocalInputValue('2000-01-01T00:00:00Z'), + ); + expect((screen.getByLabelText('截止时间') as HTMLInputElement).value).toBe( + toLocalInputValue('2999-01-01T00:00:00Z'), + ); +}); + +test('兑换码拒绝截止时间不晚于开始时间的配置', async () => { + render(); + await screen.findByRole('button', { name: 'LONG-LIVED' }); + + fireEvent.change(screen.getByLabelText('Code'), { + target: { value: 'INVALID' }, + }); + fireEvent.change(screen.getByLabelText('开始时间'), { + target: { value: '2026-07-13T10:00' }, + }); + fireEvent.change(screen.getByLabelText('截止时间'), { + target: { value: '2026-07-13T10:00' }, + }); + + expect(screen.getByText('截止时间必须晚于开始时间')).toBeTruthy(); + expect( + (screen.getByRole('button', { name: '保存' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect(upsertProfileRedeemCode).not.toHaveBeenCalled(); +}); + +test('兑换码保存时把本地时间转换为 ISO 并保留空边界', async () => { + const user = userEvent.setup(); + render(); + await screen.findByRole('button', { name: 'LONG-LIVED' }); + + fireEvent.change(screen.getByLabelText('Code'), { + target: { value: 'WINDOWED' }, + }); + fireEvent.change(screen.getByLabelText('开始时间'), { + target: { value: '2026-07-13T10:30' }, + }); + await user.click(screen.getByRole('button', { name: '保存' })); + await user.click(screen.getByRole('button', { name: '确认' })); + + await waitFor(() => { + expect(upsertProfileRedeemCode).toHaveBeenCalledWith( + 'admin-token', + expect.objectContaining({ + code: 'WINDOWED', + startsAt: new Date('2026-07-13T10:30').toISOString(), + expiresAt: null, + }), + ); + }); +}); + +async function rowForCode(code: string) { + const button = await screen.findByRole('button', { name: code }); + const row = button.closest('tr'); + if (!row) { + throw new Error(`未找到兑换码 ${code} 所在行`); + } + return row; +} + +function toLocalInputValue(value: string) { + const date = new Date(value); + const offsetMs = date.getTimezoneOffset() * 60 * 1000; + return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16); +} diff --git a/apps/admin-web/src/pages/AdminRedeemCodePage.tsx b/apps/admin-web/src/pages/AdminRedeemCodePage.tsx index f1df9c315..6afe195d5 100644 --- a/apps/admin-web/src/pages/AdminRedeemCodePage.tsx +++ b/apps/admin-web/src/pages/AdminRedeemCodePage.tsx @@ -34,6 +34,8 @@ export function AdminRedeemCodePage({ const [rewardPoints, setRewardPoints] = useState('100'); const [maxUses, setMaxUses] = useState('1'); const [enabled, setEnabled] = useState(true); + const [startsAt, setStartsAt] = useState(''); + const [expiresAt, setExpiresAt] = useState(''); const [allowedUserIds, setAllowedUserIds] = useState(''); const [allowedPublicUserCodes, setAllowedPublicUserCodes] = useState(''); const [disableCode, setDisableCode] = useState(''); @@ -73,6 +75,12 @@ export function AdminRedeemCodePage({ } setErrorMessage(''); + const validityError = validateValidityWindow(startsAt, expiresAt); + if (validityError) { + setErrorMessage(validityError); + return; + } + const confirmed = await confirmWrite({ action: '保存兑换码', target: code.trim(), @@ -92,6 +100,8 @@ export function AdminRedeemCodePage({ allowedUserIds: mode === 'private' ? splitLines(allowedUserIds) : [], allowedPublicUserCodes: mode === 'private' ? splitLines(allowedPublicUserCodes) : [], + startsAt: startsAt ? toIsoDateTime(startsAt) : null, + expiresAt: expiresAt ? toIsoDateTime(expiresAt) : null, }); fillForm(response); await refreshRedeemCodes(); @@ -137,11 +147,15 @@ export function AdminRedeemCodePage({ setRewardPoints(String(entry.rewardPoints)); setMaxUses(String(entry.maxUses)); setEnabled(entry.enabled); + setStartsAt(toDateTimeLocalValue(entry.startsAt)); + setExpiresAt(toDateTimeLocalValue(entry.expiresAt)); setAllowedUserIds(entry.allowedUserIds.join('\n')); setAllowedPublicUserCodes(''); setDisableCode(entry.code); } + const validityError = validateValidityWindow(startsAt, expiresAt); + return (
@@ -222,6 +236,25 @@ export function AdminRedeemCodePage({
+
+ + +
+ {mode === 'private' ? (
) : null} + {validityError && validityError !== errorMessage ? ( +
+ {validityError} +
+ ) : null} + 前往首页 + + + + diff --git a/public/branding/taonier-404-character.png b/public/branding/taonier-404-character.png new file mode 100644 index 000000000..e12222aa4 Binary files /dev/null and b/public/branding/taonier-404-character.png differ diff --git a/public/branding/taonier-404-page.png b/public/branding/taonier-404-page.png new file mode 100644 index 000000000..184b6b939 Binary files /dev/null and b/public/branding/taonier-404-page.png differ diff --git a/public/branding/taonier-maintenance-page.png b/public/branding/taonier-maintenance-page.png new file mode 100644 index 000000000..5cbc970b4 Binary files /dev/null and b/public/branding/taonier-maintenance-page.png differ diff --git a/public/maintenance.html b/public/maintenance.html index 75799f377..6cca73d0f 100644 --- a/public/maintenance.html +++ b/public/maintenance.html @@ -4,44 +4,72 @@ - 陶泥儿停服升级公告 + + 服务维护中 - 陶泥儿 +
-
- - 服务维护中 +
+
+ + 系统维护中 +
+

服务维护中

+

我们正在更新系统,稍后请重新访问。

+
- -

陶泥儿美术工具停服升级

-

- 各位陶泥儿创作者,美术工具官网将在今天晚上 21:00~22:00 停服升级。
- 注意保存好自己的项目和下载好素材。 -

- -
- 维护时间 - 今天晚上 21:00~22:00 -
- -
-

本次更新修复内容

-
    -
  1. 修复多个影响画布使用的 BUG。
  2. -
  3. - 修复了 PC 端不能充值的恶性 BUG(bushi),PC - 网页端支持微信二维码充值。 -
  4. -
-
- -
-

新增功能

-

- 抠图与背景色功能升级:使用更强、更稳定的抠图技术,解决角色扣残问题,角色动作的逐帧抠图同步升级。 -

-
diff --git a/scripts/build-production-release.sh b/scripts/build-production-release.sh index fb368e3ce..2ced31812 100644 --- a/scripts/build-production-release.sh +++ b/scripts/build-production-release.sh @@ -446,6 +446,17 @@ if [[ "${BUILD_WEB}" -eq 1 ]]; then MAINTENANCE_HTML fi + if [[ ! -f "${WEB_DIR}/404.html" ]]; then + echo "[production-release] Web 发布包缺少品牌 404 页面: ${WEB_DIR}/404.html" >&2 + exit 1 + fi + + echo "[production-release] 校验默认维护页不包含临时公告" + ( + cd "${REPO_ROOT}" + node scripts/check-maintenance-page.mjs --file "${WEB_DIR}/maintenance.html" + ) + echo "[production-release] 规范 Web 静态资源权限" find "${WEB_DIR}" -type d -exec chmod 755 {} + find "${WEB_DIR}" -type f -exec chmod 644 {} + diff --git a/scripts/check-maintenance-page.mjs b/scripts/check-maintenance-page.mjs new file mode 100644 index 000000000..1109f99c0 --- /dev/null +++ b/scripts/check-maintenance-page.mjs @@ -0,0 +1,203 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const failures = []; +const requestedFiles = []; + +for (let index = 2; index < process.argv.length; index += 1) { + if (process.argv[index] !== '--file' || !process.argv[index + 1]) { + failures.push(`未知或不完整参数: ${process.argv[index]}`); + continue; + } + requestedFiles.push(path.resolve(process.argv[index + 1])); + index += 1; +} + +function fail(message) { + failures.push(message); +} + +function validateDefaultPage(filePath) { + if (!existsSync(filePath)) { + fail(`默认维护页不存在: ${filePath}`); + return; + } + + const source = readFileSync(filePath, 'utf8'); + if (!source.includes('服务维护中')) { + fail(`${filePath} 必须保留无日期的“服务维护中”默认文案。`); + } + for (const [pattern, label] of [ + [/(?:今天|今晚|明天|昨天|昨日)/u, '相对日期'], + [/(?:20\d{2}[-/.年]\d{1,2}(?:[-/.月]\d{1,2}日?)?|\d{1,2}月\d{1,2}日)/u, '具体日期'], + [/(?:[01]?\d|2[0-3]):[0-5]\d/u, '具体维护时间'], + ]) { + if (pattern.test(source)) { + fail(`${filePath} 不能包含${label};临时公告必须使用运行态覆盖页。`); + } + } +} + +function runScript(scriptPath, args, env) { + return spawnSync('bash', [scriptPath, ...args], { + cwd: repoRoot, + env: { ...process.env, ...env }, + encoding: 'utf8', + }); +} + +function validateRuntimePageLifecycle() { + const tempRoot = mkdtempSync(path.join(os.tmpdir(), 'genarrative-maintenance-')); + const markerFile = path.join(tempRoot, 'state', 'enabled'); + const runtimePageFile = path.join(tempRoot, 'state', 'page.html'); + const sourcePageFile = path.join(tempRoot, 'announcement.html'); + const onScript = path.join(repoRoot, 'scripts/deploy/maintenance-on.sh'); + const offScript = path.join(repoRoot, 'scripts/deploy/maintenance-off.sh'); + const env = { + GENARRATIVE_MAINTENANCE_FILE: markerFile, + GENARRATIVE_MAINTENANCE_PAGE_FILE: runtimePageFile, + }; + + try { + const announcement = 'planned maintenance\n'; + writeFileSync(sourcePageFile, announcement); + + const enable = runScript( + onScript, + ['--page-file', sourcePageFile, 'planned maintenance'], + env, + ); + if (enable.status !== 0) { + fail(`maintenance-on --page-file 执行失败: ${enable.stderr || enable.stdout}`); + return; + } + if (!existsSync(markerFile)) { + fail('maintenance-on --page-file 必须创建维护 marker。'); + } + if (!existsSync(runtimePageFile)) { + fail('maintenance-on --page-file 必须原子安装运行态公告页。'); + } else { + if (readFileSync(runtimePageFile, 'utf8') !== announcement) { + fail('运行态公告页内容与输入文件不一致。'); + } + if ((statSync(runtimePageFile).mode & 0o777) !== 0o644) { + fail('运行态公告页权限必须为 0644。'); + } + } + + const nestedEnable = runScript(onScript, ['api deploy'], env); + if (nestedEnable.status !== 0 || !existsSync(runtimePageFile)) { + fail('同一维护窗口内的后续 maintenance-on 必须保留已安装公告页。'); + } + + const disable = runScript(offScript, [], env); + if (disable.status !== 0) { + fail(`maintenance-off 执行失败: ${disable.stderr || disable.stdout}`); + } + if (existsSync(markerFile) || existsSync(runtimePageFile)) { + fail('maintenance-off 必须同时清理 marker 和运行态公告页。'); + } + + mkdirSync(path.dirname(runtimePageFile), { recursive: true }); + writeFileSync(runtimePageFile, announcement); + chmodSync(runtimePageFile, 0o644); + const genericEnable = runScript(onScript, ['generic maintenance'], env); + if (genericEnable.status !== 0) { + fail(`通用 maintenance-on 执行失败: ${genericEnable.stderr || genericEnable.stdout}`); + } + if (existsSync(runtimePageFile)) { + fail('新维护窗口未提供 --page-file 时必须清理残留公告页。'); + } + runScript(offScript, [], env); + + const missingPage = runScript( + onScript, + ['--page-file', path.join(tempRoot, 'missing.html')], + env, + ); + if (missingPage.status === 0 || existsSync(markerFile)) { + fail('不存在的 --page-file 必须在创建 marker 前失败。'); + } + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } +} + +function validateGatewayConfiguration() { + const nginxSnippet = readFileSync( + path.join(repoRoot, 'deploy/nginx/snippets/genarrative-maintenance.conf'), + 'utf8', + ); + for (const expected of [ + 'root /var/lib/genarrative/maintenance;', + 'try_files /page.html @genarrative_default_maintenance;', + 'location @genarrative_default_maintenance', + 'root /srv/genarrative/web;', + ]) { + if (!nginxSnippet.includes(expected)) { + fail(`Nginx 维护页配置缺少运行态覆盖约束: ${expected}`); + } + } + + const pingoraSource = readFileSync( + path.join(repoRoot, 'server-rs/crates/pingora-gateway/src/main.rs'), + 'utf8', + ); + for (const expected of [ + 'GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_PAGE_FILE', + 'maintenance_page_file', + ]) { + if (!pingoraSource.includes(expected)) { + fail(`Pingora 维护页配置缺少运行态覆盖约束: ${expected}`); + } + } + + const releaseBuildScript = readFileSync( + path.join(repoRoot, 'scripts/build-production-release.sh'), + 'utf8', + ); + if ( + !releaseBuildScript.includes( + 'node scripts/check-maintenance-page.mjs --file "${WEB_DIR}/maintenance.html"', + ) + ) { + fail('生产 Web 发布包构建必须校验最终 maintenance.html 不含临时公告。'); + } +} + +for (const filePath of + requestedFiles.length > 0 + ? requestedFiles + : [path.join(repoRoot, 'public/maintenance.html')]) { + validateDefaultPage(filePath); +} + +if (requestedFiles.length === 0) { + validateRuntimePageLifecycle(); + validateGatewayConfiguration(); +} + +if (failures.length > 0) { + console.error('[check:maintenance-page] FAILED'); + for (const failure of failures) { + console.error(`- ${failure}`); + } + process.exit(1); +} + +console.log('[check:maintenance-page] 通过'); diff --git a/scripts/check-nginx-spa-routes.mjs b/scripts/check-nginx-spa-routes.mjs index 3ab5682c0..3fc282c5a 100644 --- a/scripts/check-nginx-spa-routes.mjs +++ b/scripts/check-nginx-spa-routes.mjs @@ -172,6 +172,17 @@ function validateMaintenanceInternalBypass() { if (snippet.includes('$genarrative_admin_maintenance')) { fail(`${MAINTENANCE_SNIPPET_PATH} 不应保留仅后台使用的维护变量。`); } + for (const fragment of [ + 'location = /404.html {', + 'if ($http_accept !~* "text/html") {', + 'try_files /404.html =404;', + 'add_header Cache-Control "no-store";', + 'internal;', + ]) { + if (!snippet.includes(fragment)) { + fail(`${MAINTENANCE_SNIPPET_PATH} 缺少品牌 404 页面约束: ${fragment}`); + } + } for (const nginxPath of MAINTENANCE_NGINX_PATHS) { const source = readFileSync(nginxPath, 'utf8'); @@ -193,6 +204,9 @@ function validateMaintenanceInternalBypass() { if (source.includes('$genarrative_admin_maintenance')) { fail(`${nginxPath} 的维护入口必须统一使用全站维护变量。`); } + if (!source.includes('error_page 404 /404.html;')) { + fail(`${nginxPath} 的 Web 未知路由必须返回品牌 404 页面。`); + } const maintenanceChecks = source.match(/if \(\$genarrative_[a-z_]*maintenance\)/gu) ?? []; if (maintenanceChecks.length === 0) { diff --git a/scripts/check-pingora-gateway-smoke.mjs b/scripts/check-pingora-gateway-smoke.mjs index 96bf12f53..951b1998b 100644 --- a/scripts/check-pingora-gateway-smoke.mjs +++ b/scripts/check-pingora-gateway-smoke.mjs @@ -2,14 +2,14 @@ import { spawn, spawnSync } from 'node:child_process'; import { createHash, randomBytes } from 'node:crypto'; -import { gunzipSync } from 'node:zlib'; +import { existsSync } from 'node:fs'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import http from 'node:http'; import https from 'node:https'; import net from 'node:net'; -import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { gunzipSync } from 'node:zlib'; const repoRoot = process.cwd(); const failures = []; @@ -51,6 +51,7 @@ async function main() { const webRoot = path.join(tempRoot, 'web'); const acmeRoot = path.join(tempRoot, 'acme'); const maintenanceFile = path.join(tempRoot, 'maintenance', 'enabled'); + const maintenancePageFile = path.join(tempRoot, 'maintenance', 'page.html'); const accessLogFile = path.join(tempRoot, 'logs', 'pingora.access.log'); await prepareStaticRoots(webRoot, acmeRoot); @@ -158,6 +159,7 @@ async function main() { GENARRATIVE_PINGORA_GATEWAY_WEB_ROOT: webRoot, GENARRATIVE_PINGORA_GATEWAY_ACME_ROOT: acmeRoot, GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_FILE: maintenanceFile, + GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_PAGE_FILE: maintenancePageFile, GENARRATIVE_PINGORA_GATEWAY_ACCESS_LOG_FILE: accessLogFile, GENARRATIVE_PINGORA_GATEWAY_PROBE_TOKEN: probeToken, GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO: 'https', @@ -208,6 +210,7 @@ async function main() { redirectBaseUrl, probeToken, maintenanceFile, + maintenancePageFile, accessLogFile, api, spacetime, @@ -348,8 +351,9 @@ async function prepareStaticRoots(webRoot, acmeRoot) { ); await writeFile( path.join(webRoot, 'maintenance.html'), - '
maintenance
', + '
default-maintenance
', ); + await writeFile(path.join(webRoot, '404.html'), '
not-found-page
'); await writeFile( path.join(acmeRoot, '.well-known', 'acme-challenge', 'token'), 'acme-token', @@ -550,6 +554,7 @@ async function runSmokeCases( redirectBaseUrl, probeToken, maintenanceFile, + maintenancePageFile, accessLogFile, api, spacetime, @@ -598,6 +603,21 @@ async function runSmokeCases( `主站未知路径返回真实 404: ${unknownPath}`, ); } + await expectHttp( + baseUrl, + '/some/browser/navigation', + 404, + 'not-found-page', + '浏览器导航未知路径返回品牌 404 页面', + { + headers: { + Accept: 'text/html,application/xhtml+xml', + }, + validate: (response) => + response.headers['cache-control'] === 'no-store' && + response.headers['content-type']?.startsWith('text/html'), + }, + ); await expectHttp(baseUrl, '/admin', 301, '', '/admin 301 到 /admin/', { validate: (response) => response.headers.location === '/admin/', }); @@ -1124,6 +1144,7 @@ async function runSmokeCases( ]); await mkdir(path.dirname(maintenanceFile), { recursive: true }); + await writeFile(maintenancePageFile, '
runtime-maintenance
'); await writeFile(maintenanceFile, 'enabled'); const publicClientHeaders = { 'X-Real-IP': '203.0.113.50', @@ -1149,11 +1170,11 @@ async function runSmokeCases( '维护模式 Gitea Host 请求没有打到 Gitea mock', ); for (const [path, bodyNeedle, label] of [ - ['/', 'maintenance', '公网主站页面'], + ['/', 'runtime-maintenance', '公网主站页面'], ['/api/creation-entry/config', 'MAINTENANCE', '公网普通 API'], - ['/v1/identity', 'maintenance', '公网 SpacetimeDB 路由'], - ['/admin/settings', 'maintenance', '公网后台页面'], - ['/admin/assets/admin.js', 'maintenance', '公网后台静态资源'], + ['/v1/identity', 'runtime-maintenance', '公网 SpacetimeDB 路由'], + ['/admin/settings', 'runtime-maintenance', '公网后台页面'], + ['/admin/assets/admin.js', 'runtime-maintenance', '公网后台静态资源'], ['/admin/api/users', 'MAINTENANCE', '公网后台 API'], ]) { await expectHttp( @@ -1167,6 +1188,15 @@ async function runSmokeCases( }, ); } + await rm(maintenancePageFile, { force: true }); + await expectHttp( + baseUrl, + '/', + 503, + 'default-maintenance', + '运行态公告缺失时回退 Web 制品默认维护页', + { headers: publicClientHeaders }, + ); await expectHttp( baseUrl, '/', diff --git a/scripts/deploy/maintenance-off.sh b/scripts/deploy/maintenance-off.sh index c47ada80d..7a3648399 100644 --- a/scripts/deploy/maintenance-off.sh +++ b/scripts/deploy/maintenance-off.sh @@ -3,10 +3,16 @@ set -euo pipefail MAINTENANCE_FILE="${GENARRATIVE_MAINTENANCE_FILE:-/var/lib/genarrative/maintenance/enabled}" +MAINTENANCE_PAGE_FILE="${GENARRATIVE_MAINTENANCE_PAGE_FILE:-/var/lib/genarrative/maintenance/page.html}" if [[ -f "${MAINTENANCE_FILE}" ]]; then - rm -f "${MAINTENANCE_FILE}" + rm -f -- "${MAINTENANCE_FILE}" echo "[maintenance] 已退出维护模式: ${MAINTENANCE_FILE}" else echo "[maintenance] 当前未处于维护模式: ${MAINTENANCE_FILE}" fi + +if [[ -e "${MAINTENANCE_PAGE_FILE}" || -L "${MAINTENANCE_PAGE_FILE}" ]]; then + rm -f -- "${MAINTENANCE_PAGE_FILE}" + echo "[maintenance] 已清理本次运行态公告页: ${MAINTENANCE_PAGE_FILE}" +fi diff --git a/scripts/deploy/maintenance-on.sh b/scripts/deploy/maintenance-on.sh index ae9a54e3a..66df9ddb4 100644 --- a/scripts/deploy/maintenance-on.sh +++ b/scripts/deploy/maintenance-on.sh @@ -3,13 +3,59 @@ set -euo pipefail MAINTENANCE_FILE="${GENARRATIVE_MAINTENANCE_FILE:-/var/lib/genarrative/maintenance/enabled}" -REASON="${*:-manual}" +MAINTENANCE_PAGE_FILE="${GENARRATIVE_MAINTENANCE_PAGE_FILE:-/var/lib/genarrative/maintenance/page.html}" +PAGE_SOURCE="" +REASON_PARTS=() -mkdir -p "$(dirname "${MAINTENANCE_FILE}")" +while [[ $# -gt 0 ]]; do + case "$1" in + --page-file) + PAGE_SOURCE="${2:?缺少 --page-file 的值}" + shift 2 + ;; + -h|--help) + echo "用法: $0 [--page-file <临时公告 HTML>] [维护原因]" + exit 0 + ;; + --*) + echo "[maintenance] 未知参数: $1" >&2 + exit 1 + ;; + *) + REASON_PARTS+=("$1") + shift + ;; + esac +done + +REASON="${REASON_PARTS[*]:-manual}" + +if [[ -n "${PAGE_SOURCE}" && ( ! -f "${PAGE_SOURCE}" || ! -s "${PAGE_SOURCE}" ) ]]; then + echo "[maintenance] 临时公告页不存在或为空: ${PAGE_SOURCE}" >&2 + exit 1 +fi + +mkdir -p "$(dirname "${MAINTENANCE_FILE}")" "$(dirname "${MAINTENANCE_PAGE_FILE}")" + +if [[ -n "${PAGE_SOURCE}" ]]; then + page_temp="$(mktemp "${MAINTENANCE_PAGE_FILE}.tmp.XXXXXX")" + trap 'rm -f "${page_temp:-}" "${marker_temp:-}"' EXIT + install -m 0644 -- "${PAGE_SOURCE}" "${page_temp}" + mv -fT -- "${page_temp}" "${MAINTENANCE_PAGE_FILE}" + page_temp="" + echo "[maintenance] 已安装本次运行态公告页: ${MAINTENANCE_PAGE_FILE}" +elif [[ ! -f "${MAINTENANCE_FILE}" && ( -e "${MAINTENANCE_PAGE_FILE}" || -L "${MAINTENANCE_PAGE_FILE}" ) ]]; then + rm -f -- "${MAINTENANCE_PAGE_FILE}" +fi + +marker_temp="$(mktemp "${MAINTENANCE_FILE}.tmp.XXXXXX")" { printf "enabled_at=%s\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" printf "reason=%s\n" "${REASON}" -} >"${MAINTENANCE_FILE}" +} >"${marker_temp}" -chmod 0644 "${MAINTENANCE_FILE}" +chmod 0644 "${marker_temp}" +mv -fT -- "${marker_temp}" "${MAINTENANCE_FILE}" +marker_temp="" +trap - EXIT echo "[maintenance] 已进入维护模式: ${MAINTENANCE_FILE}" diff --git a/server-rs/crates/api-server/src/assets.rs b/server-rs/crates/api-server/src/assets.rs index f8479ab72..dfc6d90f0 100644 --- a/server-rs/crates/api-server/src/assets.rs +++ b/server-rs/crates/api-server/src/assets.rs @@ -50,6 +50,7 @@ const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 9] = [ // 中文注释:同源字节读取同时服务图片转 Data URL 与 Match3D 私有 GLB 预览,Rodin GLB 可能明显超过图片上限。 const ASSET_READ_BYTES_MAX_SIZE_BYTES: u64 = 120 * 1024 * 1024; const ASSET_READ_BYTES_DEFAULT_EXPIRE_SECONDS: u64 = 300; +const PUBLIC_ASSET_READ_MAX_EXPIRE_SECONDS: u64 = 600; #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum AssetReadAuthorization { @@ -58,6 +59,12 @@ pub(crate) enum AssetReadAuthorization { Admin, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AssetReadAccessScope { + Public, + Privileged, +} + #[derive(Clone, Debug, PartialEq, Eq)] struct AssetReadTarget { object_key: String, @@ -188,12 +195,17 @@ pub(crate) async fn get_asset_read_url_with_query( })?; let target = resolve_asset_read_target(&query)?; - authorize_asset_read_target(state, oss_client.config_bucket(), &target, &authorization).await?; + let access_scope = + authorize_asset_read_target(state, oss_client.config_bucket(), &target, &authorization) + .await?; let signed = oss_client .sign_get_object_url(OssSignedGetObjectUrlRequest { object_key: target.object_key, - expire_seconds: query.expire_seconds, + expire_seconds: clamp_public_asset_read_expire_seconds( + query.expire_seconds, + access_scope, + ), }) .map_err(|error| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ @@ -298,16 +310,20 @@ pub async fn get_asset_read_bytes( "/api/assets/read-bytes", ) .await?; - authorize_asset_read_target(&state, oss_client.config_bucket(), &target, &authorization) - .await?; + let access_scope = + authorize_asset_read_target(&state, oss_client.config_bucket(), &target, &authorization) + .await?; let signed = oss_client .sign_get_object_url(OssSignedGetObjectUrlRequest { object_key: target.object_key, - expire_seconds: Some( - query - .expire_seconds - .unwrap_or(ASSET_READ_BYTES_DEFAULT_EXPIRE_SECONDS), + expire_seconds: clamp_public_asset_read_expire_seconds( + Some( + query + .expire_seconds + .unwrap_or(ASSET_READ_BYTES_DEFAULT_EXPIRE_SECONDS), + ), + access_scope, ), }) .map_err(|error| map_oss_error(error, "aliyun-oss"))?; @@ -657,14 +673,17 @@ async fn authorize_asset_read_target( configured_bucket: &str, target: &AssetReadTarget, authorization: &AssetReadAuthorization, -) -> Result<(), AppError> { +) -> Result { if matches!(authorization, AssetReadAuthorization::Admin) { - return Ok(()); + return Ok(AssetReadAccessScope::Privileged); } - let asset_object = state + let (asset_object, public_work_granted) = state .spacetime_client() - .get_asset_object_by_object_key(target.object_key.clone()) + .get_asset_read_access_by_location(module_assets::AssetObjectLocationInput { + bucket: configured_bucket.to_string(), + object_key: target.object_key.clone(), + }) .await .map_err(map_asset_read_authorization_error)?; if let Some(asset_object) = asset_object.as_ref() { @@ -673,12 +692,13 @@ async fn authorize_asset_read_target( configured_bucket, target.object_key.as_str(), authorization, + public_work_granted, ); } // 已登记对象始终服从 metadata ACL;只有没有 metadata 的历史资源才走公开前缀兼容。 if target.is_legacy_public_path && is_supported_legacy_public_object_key(&target.object_key) { - return Ok(()); + return Ok(AssetReadAccessScope::Public); } Err(asset_read_not_found()) @@ -689,16 +709,49 @@ fn require_asset_object_read_access( configured_bucket: &str, object_key: &str, authorization: &AssetReadAuthorization, -) -> Result<(), AppError> { - let bucket_matches = asset_object.bucket.trim() == configured_bucket.trim(); - let key_matches = asset_object.object_key.trim().trim_start_matches('/') == object_key; - if !bucket_matches || !key_matches { + public_work_granted: bool, +) -> Result { + if !asset_object_storage_matches(asset_object, configured_bucket, object_key) { return Err(asset_read_not_found()); } - if asset_object.access_policy == AssetObjectAccessPolicy::PublicRead { - return Ok(()); + if asset_object_owner_matches(asset_object, authorization) { + return Ok(AssetReadAccessScope::Privileged); } - let owner_matches = match authorization { + if asset_object.access_policy == AssetObjectAccessPolicy::PublicRead || public_work_granted { + return Ok(AssetReadAccessScope::Public); + } + + Err(asset_read_not_found()) +} + +fn clamp_public_asset_read_expire_seconds( + requested_expire_seconds: Option, + access_scope: AssetReadAccessScope, +) -> Option { + match access_scope { + AssetReadAccessScope::Public => Some( + requested_expire_seconds + .unwrap_or(PUBLIC_ASSET_READ_MAX_EXPIRE_SECONDS) + .min(PUBLIC_ASSET_READ_MAX_EXPIRE_SECONDS), + ), + AssetReadAccessScope::Privileged => requested_expire_seconds, + } +} + +fn asset_object_storage_matches( + asset_object: &module_assets::AssetObjectRecord, + configured_bucket: &str, + object_key: &str, +) -> bool { + asset_object.bucket.trim() == configured_bucket.trim() + && asset_object.object_key.trim().trim_start_matches('/') == object_key +} + +fn asset_object_owner_matches( + asset_object: &module_assets::AssetObjectRecord, + authorization: &AssetReadAuthorization, +) -> bool { + match authorization { AssetReadAuthorization::Owner(owner_user_id) => asset_object .owner_user_id .as_deref() @@ -706,11 +759,6 @@ fn require_asset_object_read_access( .is_some_and(|value| value == owner_user_id.trim()), AssetReadAuthorization::Admin => true, AssetReadAuthorization::Anonymous => false, - }; - if owner_matches { - Ok(()) - } else { - Err(asset_read_not_found()) } } @@ -987,15 +1035,16 @@ mod tests { Some("user-owner"), ); - assert!( + assert!(matches!( super::require_asset_object_read_access( &record, "genarrative-assets", record.object_key.as_str(), &super::AssetReadAuthorization::Owner("user-owner".to_string()), - ) - .is_ok() - ); + false, + ), + Ok(super::AssetReadAccessScope::Privileged) + )); for authorization in [ super::AssetReadAuthorization::Anonymous, super::AssetReadAuthorization::Owner("user-other".to_string()), @@ -1005,6 +1054,7 @@ mod tests { "genarrative-assets", record.object_key.as_str(), &authorization, + false, ) .expect_err("private asset should reject non-owner"); assert_eq!(error.status_code(), StatusCode::NOT_FOUND); @@ -1015,21 +1065,23 @@ mod tests { fn public_asset_read_allows_anonymous_but_rejects_storage_mismatch() { let record = asset_object_record(module_assets::AssetObjectAccessPolicy::PublicRead, None); - assert!( + assert!(matches!( super::require_asset_object_read_access( &record, "genarrative-assets", record.object_key.as_str(), &super::AssetReadAuthorization::Anonymous, - ) - .is_ok() - ); + false, + ), + Ok(super::AssetReadAccessScope::Public) + )); assert_eq!( super::require_asset_object_read_access( &record, "another-bucket", record.object_key.as_str(), &super::AssetReadAuthorization::Anonymous, + false, ) .expect_err("bucket mismatch should be hidden") .status_code(), @@ -1037,6 +1089,62 @@ mod tests { ); } + #[test] + fn published_visible_work_reference_grants_private_asset_read() { + let record = asset_object_record( + module_assets::AssetObjectAccessPolicy::Private, + Some("user-owner"), + ); + + assert!(matches!( + super::require_asset_object_read_access( + &record, + "genarrative-assets", + record.object_key.as_str(), + &super::AssetReadAuthorization::Anonymous, + true, + ), + Ok(super::AssetReadAccessScope::Public) + )); + assert_eq!( + super::require_asset_object_read_access( + &record, + "another-bucket", + record.object_key.as_str(), + &super::AssetReadAuthorization::Anonymous, + true, + ) + .expect_err("public work grant must not bypass storage identity") + .status_code(), + StatusCode::NOT_FOUND + ); + } + + #[test] + fn public_asset_read_url_expiry_is_capped_but_privileged_expiry_is_preserved() { + assert_eq!( + super::clamp_public_asset_read_expire_seconds( + None, + super::AssetReadAccessScope::Public, + ), + Some(super::PUBLIC_ASSET_READ_MAX_EXPIRE_SECONDS) + ); + assert_eq!( + super::clamp_public_asset_read_expire_seconds( + Some(86_400), + super::AssetReadAccessScope::Public, + ), + Some(super::PUBLIC_ASSET_READ_MAX_EXPIRE_SECONDS) + ); + assert_eq!( + super::clamp_public_asset_read_expire_seconds( + Some(86_400), + super::AssetReadAccessScope::Privileged, + ), + Some(86_400) + ); + } + #[test] fn legacy_public_fallback_only_accepts_curated_prefixes() { assert!(super::is_supported_legacy_public_object_key( @@ -1229,7 +1337,7 @@ mod tests { } #[tokio::test] - async fn read_url_returns_signed_legacy_public_object_url_when_oss_configured() { + async fn read_url_fails_closed_when_asset_metadata_authority_is_unavailable() { let config = AppConfig { oss_bucket: Some("genarrative-assets".to_string()), oss_endpoint: Some("oss-cn-shanghai.aliyuncs.com".to_string()), @@ -1252,7 +1360,7 @@ mod tests { .await .expect("request should succeed"); - assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); let body = response .into_body() @@ -1263,20 +1371,14 @@ mod tests { let payload: Value = serde_json::from_slice(&body).expect("response body should be valid json"); - assert_eq!(payload["ok"], Value::Bool(true)); assert_eq!( - payload["data"]["read"]["objectKey"], - Value::String("generated-characters/hero_001/visual/asset_01/master.png".to_string()) - ); - assert!( - payload["data"]["read"]["signedUrl"] - .as_str() - .is_some_and(|value| value.contains("x-oss-signature-version=OSS4-HMAC-SHA256")) + payload["error"]["details"]["provider"], + Value::String("spacetimedb".to_string()) ); } #[tokio::test] - async fn read_url_accepts_legacy_public_path_for_transition() { + async fn legacy_transition_does_not_bypass_asset_metadata_authority() { let config = AppConfig { oss_bucket: Some("genarrative-assets".to_string()), oss_endpoint: Some("oss-cn-shanghai.aliyuncs.com".to_string()), @@ -1298,7 +1400,7 @@ mod tests { .await .expect("request should succeed"); - assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); let body = response .into_body() @@ -1310,10 +1412,8 @@ mod tests { serde_json::from_slice(&body).expect("response body should be valid json"); assert_eq!( - payload["read"]["objectKey"], - Value::String( - "generated-custom-world-scenes/profile_01/landmark_01/scene.png".to_string() - ) + payload["error"]["details"]["provider"], + Value::String("spacetimedb".to_string()) ); } diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs index e99ab0e46..391e33d95 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -41,9 +41,9 @@ use shared_contracts::assets::{ CharacterRoleAssetWorkflowResolveRequest, CharacterRoleAssetWorkflowResponse, CharacterVisualDraftPayload, CharacterWorkflowCacheGetResponse, CharacterWorkflowCachePayload, CharacterWorkflowCacheSaveRequest, CharacterWorkflowCacheSaveResponse, - EditorCharacterAnimationFramePayload, EditorCharacterAnimationGenerateRequest, - EditorCharacterAnimationGenerateResponse, EditorVideoGenerateRequest, - EditorVideoGenerateResponse, + EDITOR_ASSET_LABEL_MAX_CHARS, EditorCharacterAnimationFramePayload, + EditorCharacterAnimationGenerateRequest, EditorCharacterAnimationGenerateResponse, + EditorVideoGenerateRequest, EditorVideoGenerateResponse, }; use spacetime_client::SpacetimeClientError; @@ -165,7 +165,13 @@ struct PersistedEditorVideo { legacy_public_path: String, object_key: String, asset_object_id: String, - thumbnail_src: String, + thumbnail_src: Option, +} + +struct PersistedEditorCharacterAnimationPreview { + legacy_public_path: String, + object_key: String, + asset_object_id: String, } pub async fn list_character_animation_templates( @@ -627,6 +633,9 @@ pub(crate) async fn generate_editor_character_animation_for_owner( let canvas_completion = payload.canvas_completion.clone(); let generation_inputs = payload.generation_inputs.clone(); let source_resource_id = payload.source_resource_id.clone(); + let asset_folder_id = payload.asset_folder_id.clone(); + let asset_label = + resolve_editor_character_animation_asset_label(payload.asset_label.as_deref()); let pricing = state.editor_generation_pricing().await.map_err(|error| { character_animation_error_response( @@ -727,6 +736,33 @@ pub(crate) async fn generate_editor_character_animation_for_owner( source_data_url.as_str(), ) .await?; + persist_editor_generated_media_asset( + &state, + PersistEditorGeneratedAssetRequest { + project_id: project_id.clone(), + owner_user_id: owner_user_id.clone(), + folder_id: asset_folder_id + .clone() + .or_else(|| Some("project".to_string())), + label: editor_character_animation_source_asset_label(asset_label.as_str()), + image_src: generated.preview_video_path.clone(), + object_key: generated.object_key.clone(), + asset_object_id: generated.asset_object_id.clone(), + width: normalized.frame_width, + height: normalized.frame_height, + prompt: normalized.prompt.clone(), + actual_prompt: Some(generated.submitted_prompt.clone()), + model: EDITOR_CHARACTER_ANIMATION_MODEL.to_string(), + provider: "Ark".to_string(), + task_id: task_id.clone(), + source_resource_id: source_resource_id.clone(), + asset_kind: Some(EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND.to_string()), + generation_inputs: generation_inputs.clone(), + thumbnail_src: None, + generation_cost_mud_points: 0, + }, + ) + .await?; let frames = extract_and_persist_editor_character_animation_frames( &state, owner_user_id.as_str(), @@ -1819,6 +1855,53 @@ async fn put_generated_preview_video( Ok(put_result.legacy_public_path) } +async fn put_generated_editor_character_animation_preview( + state: &AppState, + owner_user_id: &str, + source_layer_id: &str, + task_id: &str, + preview_payload: MediaPayload, +) -> Result { + let content_type = preview_payload.mime_type.clone(); + let put_result = put_character_animation_object( + state, + LegacyAssetPrefix::CharacterDrafts, + vec![ + sanitize_storage_segment(source_layer_id, "layer"), + "animation".to_string(), + "editor-character-animation".to_string(), + task_id.to_string(), + ], + format!("preview.{}", preview_payload.extension), + preview_payload.mime_type, + preview_payload.bytes, + build_asset_metadata( + EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND, + owner_user_id, + "editor_layer", + source_layer_id, + EDITOR_GREEN_SCREEN_SOURCE_SLOT, + "editor-character-animation", + ), + ) + .await?; + let confirmed = confirm_editor_character_animation_source_asset_object( + state, + owner_user_id, + source_layer_id, + task_id, + put_result.object_key.clone(), + content_type, + ) + .await?; + + Ok(PersistedEditorCharacterAnimationPreview { + legacy_public_path: put_result.legacy_public_path, + object_key: put_result.object_key, + asset_object_id: confirmed.record.asset_object_id, + }) +} + async fn request_editor_character_animation_preview( state: &AppState, http_client: &reqwest::Client, @@ -1841,20 +1924,19 @@ async fn request_editor_character_animation_preview( "下载画板角色动画视频失败。", ) .await?; - let preview_video_path = put_generated_preview_video( + let persisted = put_generated_editor_character_animation_preview( state, owner_user_id, source_layer_id, - "editor-character-animation", task_id, preview_payload, ) .await?; Ok(GeneratedAnimationPreview { - preview_video_path, - object_key: None, - asset_object_id: None, + preview_video_path: persisted.legacy_public_path, + object_key: Some(persisted.object_key), + asset_object_id: Some(persisted.asset_object_id), thumbnail_src: None, upstream_task_id, submitted_prompt: prompt.to_string(), @@ -1884,7 +1966,7 @@ async fn request_editor_video_preview( preview_video_path: persisted.legacy_public_path, object_key: Some(persisted.object_key), asset_object_id: Some(persisted.asset_object_id), - thumbnail_src: Some(persisted.thumbnail_src), + thumbnail_src: persisted.thumbnail_src, upstream_task_id, submitted_prompt: request.prompt.clone(), moderation_fallback_applied: false, @@ -1942,9 +2024,6 @@ async fn put_generated_editor_video( preview_payload: MediaPayload, ) -> Result { let content_type = preview_payload.mime_type.clone(); - let thumbnail_src = - put_generated_editor_video_cover(state, owner_user_id, task_id, request, &preview_payload) - .await?; let put_result = put_character_animation_object( state, LegacyAssetPrefix::CharacterDrafts, @@ -1954,8 +2033,8 @@ async fn put_generated_editor_video( task_id.to_string(), ], format!("preview.{}", preview_payload.extension), - preview_payload.mime_type, - preview_payload.bytes, + preview_payload.mime_type.clone(), + preview_payload.bytes.clone(), build_asset_metadata( EDITOR_VIDEO_ASSET_KIND, owner_user_id, @@ -1974,6 +2053,27 @@ async fn put_generated_editor_video( content_type, ) .await?; + let thumbnail_src = match put_generated_editor_video_cover( + state, + owner_user_id, + task_id, + request, + &preview_payload, + ) + .await + { + Ok(thumbnail_src) => Some(thumbnail_src), + Err(error) => { + tracing::warn!( + provider = "editor-video", + operation = "persist_cover", + task_id = %task_id, + error = %error, + "画板视频主文件已持久化,但首帧封面生成失败" + ); + None + } + }; Ok(PersistedEditorVideo { legacy_public_path: put_result.legacy_public_path, object_key: put_result.object_key, @@ -2635,6 +2735,46 @@ async fn confirm_editor_video_asset_object( Ok(module_assets::ConfirmAssetObjectResult { record }) } +async fn confirm_editor_character_animation_source_asset_object( + state: &AppState, + owner_user_id: &str, + source_layer_id: &str, + task_id: &str, + object_key: String, + content_type: String, +) -> Result { + let oss_client = require_oss_client(state)?; + let head = oss_client + .head_object(&reqwest::Client::new(), OssHeadObjectRequest { object_key }) + .await + .map_err(map_character_animation_oss_error)?; + let now_micros = current_utc_micros(); + let record = state + .spacetime_client() + .confirm_asset_object( + build_asset_object_upsert_input( + generate_asset_object_id(now_micros), + head.bucket, + head.object_key, + AssetObjectAccessPolicy::Private, + head.content_type.or(Some(content_type)), + head.content_length, + head.etag, + EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND.to_string(), + Some(task_id.to_string()), + Some(owner_user_id.to_string()), + None, + Some(source_layer_id.to_string()), + now_micros, + ) + .map_err(map_asset_object_prepare_error)?, + ) + .await + .map_err(map_character_animation_spacetime_error)?; + + Ok(module_assets::ConfirmAssetObjectResult { record }) +} + async fn bind_character_animation_asset( state: &AppState, owner_user_id: &str, @@ -4545,6 +4685,25 @@ fn trim_optional_text(value: Option<&str>) -> Option { .map(ToOwned::to_owned) } +fn resolve_editor_character_animation_asset_label(value: Option<&str>) -> String { + trim_optional_text(value) + .unwrap_or_else(|| "角色动作".to_string()) + .chars() + .take(EDITOR_ASSET_LABEL_MAX_CHARS) + .collect() +} + +fn editor_character_animation_source_asset_label(asset_label: &str) -> String { + let suffix = "(原始视频)"; + let base_char_limit = EDITOR_ASSET_LABEL_MAX_CHARS.saturating_sub(suffix.chars().count()); + let base = asset_label + .trim() + .chars() + .take(base_char_limit) + .collect::(); + format!("{base}{suffix}") +} + fn clamp_prompt_seed_text(value: Option<&str>) -> String { trim_optional_text(value) .unwrap_or_default() @@ -5565,6 +5724,8 @@ mod tests { canvas_completion: None, generation_inputs: None, source_resource_id: None, + asset_folder_id: None, + asset_label: Some(" 勇者待机 ".to_string()), }) .expect("editor request should normalize"); @@ -5576,6 +5737,31 @@ mod tests { assert_eq!(normalized.frame_width, 540); assert_eq!(normalized.frame_height, 720); assert_eq!(normalized.ratio, "3:4"); + assert_eq!( + resolve_editor_character_animation_asset_label(Some(" 勇者待机 ")), + "勇者待机" + ); + assert_eq!( + resolve_editor_character_animation_asset_label(Some(" ")), + "角色动作" + ); + assert_eq!( + resolve_editor_character_animation_asset_label(Some("动".repeat(81).as_str())) + .chars() + .count(), + EDITOR_ASSET_LABEL_MAX_CHARS + ); + assert_eq!( + editor_character_animation_source_asset_label("勇者待机"), + "勇者待机(原始视频)" + ); + let long_source_label = + editor_character_animation_source_asset_label("动".repeat(80).as_str()); + assert_eq!( + long_source_label.chars().count(), + EDITOR_ASSET_LABEL_MAX_CHARS + ); + assert!(long_source_label.ends_with("(原始视频)")); } #[test] @@ -5604,6 +5790,8 @@ mod tests { canvas_completion: None, generation_inputs: None, source_resource_id: None, + asset_folder_id: None, + asset_label: None, }) .expect("editor request should normalize"); @@ -5632,6 +5820,8 @@ mod tests { canvas_completion: None, generation_inputs: None, source_resource_id: None, + asset_folder_id: None, + asset_label: None, }) .expect_err("invalid frame/duration pair should fail"); @@ -5742,6 +5932,50 @@ mod tests { ); } + #[test] + fn editor_video_intermediate_outputs_are_registered_before_derivatives() { + let source = include_str!("character_animation_assets.rs"); + assert_function_contains_in_order( + source, + "pub(crate) async fn generate_editor_character_animation_for_owner", + "pub async fn generate_editor_video", + &[ + "request_editor_character_animation_preview", + "persist_editor_generated_media_asset", + "extract_and_persist_editor_character_animation_frames", + ], + ); + assert_function_contains( + source, + "pub(crate) async fn generate_editor_character_animation_for_owner", + "pub async fn generate_editor_video", + &[ + "or_else(|| Some(\"project\".to_string()))", + "EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND", + ], + ); + assert_function_contains_in_order( + source, + "async fn put_generated_editor_character_animation_preview", + "async fn request_editor_character_animation_preview", + &[ + "put_character_animation_object", + "confirm_editor_character_animation_source_asset_object", + ], + ); + assert_function_contains_in_order( + source, + "async fn put_generated_editor_video", + "async fn put_generated_editor_video_cover", + &[ + "put_character_animation_object", + "confirm_editor_video_asset_object", + "match put_generated_editor_video_cover", + "None", + ], + ); + } + #[test] fn editor_character_animation_samples_last_frame_before_decode_tail() { let last_frame_seconds = compute_sample_time_seconds(4.0, 31, 32, 0.0, 1.0, false); diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 9969b55aa..39ec8a3c4 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -18,7 +18,11 @@ use module_assets::{ }; use platform_image::{ DownloadedImage, - generated_asset_sheets::slice_generated_icon_spritesheet_all_by_connected_components, + generated_asset_sheets::{ + GeneratedAssetSheetConnectedIcon, + slice_generated_icon_spritesheet_all_by_connected_components, + slice_generated_icon_spritesheet_by_connected_components, + }, }; use platform_oss::{ LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssSignedGetObjectUrlRequest, @@ -26,6 +30,7 @@ use platform_oss::{ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use shared_contracts::assets::{ + EDITOR_ASSET_LABEL_MAX_CHARS, EditorCanvasGenerationCompletionPayload as EditorCanvasGenerationCompletionRequest, EditorCanvasGenerationPlaceholderPayload, }; @@ -99,6 +104,9 @@ const EDITOR_IMAGE_MODEL_NANOBANANA2: &str = "gemini-3.1-flash-image-preview"; const EDITOR_IMAGE_MODEL_NANOBANANA2_DISPLAY_ALIAS: &str = "nanobanana2"; const EDITOR_IMAGE_MODEL_NANOBANANA_LEGACY_ALIAS: &str = "nano-banana"; const EDITOR_ICON_DESCRIPTION_LIMIT: usize = 100; +const EDITOR_ICON_SPRITESHEET_MANUAL_MAX_DIMENSION: u32 = 4096; +const EDITOR_ICON_SPRITESHEET_MANUAL_MAX_PIXELS: u64 = 2048 * 2048; +const EDITOR_ICON_SPRITESHEET_MANUAL_MAX_SLICES: usize = 64; const EDITOR_UI_DESIGN_ASSET_EXTRACTION_REFERENCE_LIMIT: usize = 5; const EDITOR_CHARACTER_IMAGE_ASSET_KIND: &str = "editor_character_image"; const EDITOR_CHARACTER_IMAGE_ENTITY_KIND: &str = "editor_project"; @@ -108,7 +116,11 @@ const EDITOR_SPEC_IMAGE_ASSET_KIND: &str = "editor_spec_image"; const EDITOR_QUICK_EDIT_IMAGE_ASSET_KIND: &str = "editor_quick_edit_image"; const EDITOR_UI_DESIGN_IMAGE_ASSET_KIND: &str = "editor_ui_design_image"; const EDITOR_IMAGE_EDIT_ASSET_KIND: &str = "editor_image_edit"; +const EDITOR_IMAGE_EDIT_SOURCE_ASSET_KIND: &str = "editor_image_edit_source"; const EDITOR_ICON_SPRITESHEET_ASSET_KIND: &str = "editor_icon_spritesheet"; +const EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND: &str = "editor_icon_spritesheet_slice"; +const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS: &str = "insufficient-connected-components"; +const EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE: &str = "slice-persistence-failed"; const EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND: &str = "editor_ui_design_spritesheet"; const EDITOR_UI_DESIGN_ASSET_IMAGE_KIND: &str = "editor_ui_design_asset"; const EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND: &str = "editor_green_screen_source"; @@ -287,9 +299,19 @@ pub struct EditorIconSpritesheetGenerationRequest { pub(crate) project_id: Option, pub(crate) generation_inputs: Option, pub(crate) asset_folder_id: Option, + pub(crate) asset_label: Option, pub(crate) canvas_completion: Option, } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorIconSpritesheetSliceRequest { + pub(crate) project_id: String, + pub(crate) source_resource_id: String, + pub(crate) asset_folder_id: Option, + pub(crate) canvas_completion: EditorCanvasGenerationCompletionRequest, +} + #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorUiDesignAssetExtractionRequest { @@ -550,6 +572,13 @@ pub struct EditorIconSpritesheetIconResponse { asset: Option, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorIconSpritesheetSliceWarningResponse { + code: &'static str, + reason: String, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorIconSpritesheetGenerationResponse { @@ -557,6 +586,8 @@ pub struct EditorIconSpritesheetGenerationResponse { spritesheet_width: u32, spritesheet_height: u32, icon_image_srcs: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + slice_warning: Option, prompt: String, actual_prompt: Option, model: String, @@ -568,6 +599,13 @@ pub struct EditorIconSpritesheetGenerationResponse { project: Option, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorIconSpritesheetSliceResponse { + icon_image_srcs: Vec, + project: EditorProjectPayload, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct EditorProjectPayload { @@ -1554,14 +1592,23 @@ pub(crate) async fn generate_editor_image_for_owner( let screen_color = screen_background_decision .as_ref() .map(|decision| decision.color); + let generation_inputs = apply_editor_screen_background_decision_to_generation_inputs( + payload.generation_inputs.clone(), + screen_background_decision.as_ref(), + ); + let asset_label = + resolve_editor_generated_asset_label(payload.asset_label.clone(), role_setting.as_str()); let mut image = generated.images.into_iter().next().ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", "message": "VectorEngine 未返回图片", })) })?; - if is_character_generation { - persist_editor_green_screen_source_image( + let source_record = if is_character_generation { + let (source_width, source_height) = image::load_from_memory(image.bytes.as_slice()) + .map(|image| (image.width(), image.height())) + .unwrap_or((1024, 1024)); + let source_persisted = persist_editor_green_screen_source_image( state, caller.owner_user_id.as_str(), generated.task_id.as_str(), @@ -1572,6 +1619,28 @@ pub(crate) async fn generate_editor_image_for_owner( "character-image", ) .await?; + let source_record = persist_editor_green_screen_source_resource( + state, + source_persisted, + PersistEditorGreenScreenSourceResourceInput { + project_id: payload.project_id.clone(), + owner_user_id: caller.owner_user_id.clone(), + folder_id: payload + .asset_folder_id + .clone() + .or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())), + label: editor_generated_asset_variant_label(asset_label.as_str(), "原图"), + width: source_width, + height: source_height, + prompt: role_setting.clone(), + actual_prompt: generated.actual_prompt.clone(), + model: generation_options.model.to_string(), + task_id: generated.task_id.clone(), + source_resource_id: payload.source_resource_id.clone(), + generation_inputs: generation_inputs.clone(), + }, + ) + .await?; let matting_audit = crate::external_api_audit::ExternalApiAuditContext { user_id: caller.audit_subject_user_id.clone(), profile_id: caller @@ -1588,7 +1657,10 @@ pub(crate) async fn generate_editor_image_for_owner( &matting_audit, ) .await?; - } + Some(source_record) + } else { + None + }; let (width, height) = image::load_from_memory(image.bytes.as_slice()) .map(|image| (image.width(), image.height())) @@ -1615,8 +1687,7 @@ pub(crate) async fn generate_editor_image_for_owner( project_id: payload.project_id.clone(), owner_user_id: caller.owner_user_id.clone(), folder_id: payload.asset_folder_id, - label: normalize_optional_string(payload.asset_label) - .unwrap_or_else(|| role_setting.clone()), + label: asset_label, image_src: image_src.clone(), object_key: Some(persisted.object_key.clone()), asset_object_id: Some(persisted.asset_object_id.clone()), @@ -1629,21 +1700,31 @@ pub(crate) async fn generate_editor_image_for_owner( task_id: generated.task_id.clone(), source_resource_id: payload.source_resource_id, asset_kind: payload.asset_kind, - generation_inputs: apply_editor_screen_background_decision_to_generation_inputs( - payload.generation_inputs, - screen_background_decision.as_ref(), - ), + generation_inputs, thumbnail_src: None, generation_cost_mud_points: u64::from(configured_price_mud_points), }, ) .await?; - let completed_project = complete_editor_canvas_generation( + let (canvas_items, primary_layer_id) = + if let Some(completion) = payload.canvas_completion.as_ref() { + build_multi_asset_canvas_layer_items( + completion, + generated_asset.resource.as_ref(), + source_record + .as_ref() + .and_then(|record| record.resource.as_ref()), + ) + } else { + (Vec::new(), None) + }; + let completed_project = complete_editor_canvas_generation_with_items( state, caller.owner_user_id.as_str(), payload.project_id.as_deref(), payload.canvas_completion.as_ref(), - generated_asset.resource.as_ref(), + canvas_items, + primary_layer_id, ) .await?; @@ -1922,6 +2003,93 @@ fn align_editor_image_edit_dimension(value: u32) -> u32 { value.saturating_add(15) / 16 * 16 } +fn ensure_editor_image_edit_asset_kind_allowed(asset_kind: Option<&str>) -> Result<(), AppError> { + let Some(asset_kind) = asset_kind.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(()); + }; + if !matches!(asset_kind, "icon" | "icon-spritesheet") { + return Ok(()); + } + + Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-image-edit", + "message": "图标与图标图集不支持快速编辑", + "assetKind": asset_kind, + })), + ) +} + +fn resolve_editor_image_edit_target_layer_asset_kind<'a>( + layers: &'a Value, + resources: &'a [EditorProjectResourcePayload], + target_layer_id: &str, +) -> Result, AppError> { + let target_layer = layers + .as_array() + .and_then(|items| { + items + .iter() + .find(|item| item.get("layerId").and_then(Value::as_str) == Some(target_layer_id)) + }) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-image-edit", + "message": "快速编辑目标图层不存在", + "targetLayerId": target_layer_id, + })) + })?; + if let Some(asset_kind) = target_layer.get("assetKind").and_then(Value::as_str) { + return Ok(Some(asset_kind)); + } + let resource_id = target_layer.get("resourceId").and_then(Value::as_str); + Ok(resource_id.and_then(|resource_id| { + resources + .iter() + .find(|resource| resource.resource_id == resource_id) + .and_then(|resource| resource.asset_kind.as_deref()) + })) +} + +async fn ensure_editor_image_edit_source_allowed( + state: &AppState, + owner_user_id: &str, + payload: &EditorImageEditRequest, +) -> Result<(), AppError> { + let Some(project_id) = payload + .project_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return ensure_editor_image_edit_asset_kind_allowed(payload.asset_kind.as_deref()); + }; + let Some(target_layer_id) = payload + .target_layer_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return ensure_editor_image_edit_asset_kind_allowed(payload.asset_kind.as_deref()); + }; + let project = state + .spacetime_client() + .get_editor_project(EditorProjectGetRecordInput { + project_id: project_id.to_string(), + owner_user_id: owner_user_id.to_string(), + }) + .await + .map_err(map_editor_project_error)?; + let project = editor_project_payload_from_record(project); + let asset_kind = resolve_editor_image_edit_target_layer_asset_kind( + &project.layers, + &project.resources, + target_layer_id, + )? + .or(payload.asset_kind.as_deref()); + ensure_editor_image_edit_asset_kind_allowed(asset_kind) +} + fn encode_editor_image_edit_png( image: image::DynamicImage, failure_status: StatusCode, @@ -2060,6 +2228,8 @@ pub async fn edit_editor_image( ) -> Result, AppError> { let caller = EditorGenerationCaller::from_authenticated(&authenticated); if !state.config.external_generation_mode.is_inline() { + ensure_editor_image_edit_source_allowed(&state, caller.owner_user_id.as_str(), &payload) + .await?; let generation_options = normalize_editor_generation_options(payload.model.as_deref(), None, None); let image_size = normalize_editor_image_generation_size(payload.size.as_deref()); @@ -2096,6 +2266,7 @@ pub(crate) async fn edit_editor_image_for_owner( caller: EditorGenerationCaller, payload: EditorImageEditRequest, ) -> Result, AppError> { + ensure_editor_image_edit_source_allowed(state, caller.owner_user_id.as_str(), &payload).await?; let prompt = payload.prompt.trim().to_string(); if prompt.is_empty() { return Err( @@ -2162,13 +2333,66 @@ pub(crate) async fn edit_editor_image_for_owner( ), ) .await?; - let image = generated.images.into_iter().next().ok_or_else(|| { + let provider_image = generated.images.into_iter().next().ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "vector-engine", "message": "VectorEngine 未返回图片", })) })?; - let image = restore_editor_image_edit_output_dimensions(image, &edit_dimensions)?; + let (provider_width, provider_height) = + image::load_from_memory(provider_image.bytes.as_slice()) + .map(|image| (image.width(), image.height())) + .unwrap_or(( + edit_dimensions.provider_width, + edit_dimensions.provider_height, + )); + let source_persisted = persist_editor_generated_image( + state, + caller.owner_user_id.as_str(), + generated.task_id.as_str(), + &provider_image, + prompt.as_str(), + generated.actual_prompt.as_deref(), + EDITOR_IMAGE_EDIT_SOURCE_ASSET_KIND, + "edit-images", + "image-provider-source", + "provider_source", + "vector-engine", + ) + .await?; + let asset_label = + resolve_editor_generated_asset_label(payload.asset_label.clone(), "图片修改结果"); + let source_label = + editor_generated_asset_variant_label(asset_label.as_str(), "原始输出"); + let _source_record = persist_editor_generated_asset( + state, + PersistEditorGeneratedAssetInput { + project_id: payload.project_id.clone(), + owner_user_id: caller.owner_user_id.clone(), + folder_id: payload + .asset_folder_id + .clone() + .or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())), + label: source_label, + image_src: editor_media_src_from_object_key(source_persisted.object_key.as_str()), + object_key: Some(source_persisted.object_key), + asset_object_id: Some(source_persisted.asset_object_id), + width: provider_width, + height: provider_height, + prompt: prompt.clone(), + actual_prompt: generated.actual_prompt.clone(), + model: generation_options.model.to_string(), + provider: "VectorEngine".to_string(), + task_id: generated.task_id.clone(), + source_resource_id: payload.source_resource_id.clone(), + asset_kind: Some(EDITOR_IMAGE_EDIT_SOURCE_ASSET_KIND.to_string()), + generation_inputs: payload.generation_inputs.clone(), + thumbnail_src: None, + generation_cost_mud_points: 0, + }, + ) + .await?; + let image = restore_editor_image_edit_output_dimensions(provider_image, &edit_dimensions)?; let (width, height) = image::load_from_memory(image.bytes.as_slice()) .map(|image| (image.width(), image.height())) .unwrap_or((1024, 1024)); @@ -2194,8 +2418,7 @@ pub(crate) async fn edit_editor_image_for_owner( project_id: payload.project_id.clone(), owner_user_id: owner_user_id.clone(), folder_id: payload.asset_folder_id, - label: normalize_optional_string(payload.asset_label) - .unwrap_or_else(|| "图片修改结果".to_string()), + label: asset_label.clone(), image_src: image_src.clone(), object_key: Some(persisted.object_key.clone()), asset_object_id: Some(persisted.asset_object_id.clone()), @@ -2230,6 +2453,8 @@ pub(crate) async fn edit_editor_image_for_owner( payload.project_id.as_deref(), payload.target_layer_id.as_deref(), generated_asset.resource.as_ref(), + Some(asset_label.as_str()), + true, ) .await? }; @@ -2316,14 +2541,15 @@ pub(crate) async fn remove_editor_image_background_for_owner( ) .await?; let image_src = editor_media_src_from_object_key(persisted.object_key.as_str()); + let asset_label = + resolve_editor_generated_asset_label(payload.asset_label.clone(), "去背景结果"); let generated_asset = persist_editor_generated_asset( state, PersistEditorGeneratedAssetInput { project_id: payload.project_id.clone(), owner_user_id: caller.owner_user_id.clone(), folder_id: payload.asset_folder_id, - label: normalize_optional_string(payload.asset_label) - .unwrap_or_else(|| "去背景结果".to_string()), + label: asset_label.clone(), image_src: image_src.clone(), object_key: Some(persisted.object_key.clone()), asset_object_id: Some(persisted.asset_object_id.clone()), @@ -2358,6 +2584,8 @@ pub(crate) async fn remove_editor_image_background_for_owner( payload.project_id.as_deref(), payload.target_layer_id.as_deref(), generated_asset.resource.as_ref(), + Some(asset_label.as_str()), + false, ) .await? }; @@ -3014,6 +3242,96 @@ fn decode_editor_removed_background_image( }) } +struct PersistEditorSpritesheetSlicesInput { + owner_user_id: String, + project_id: Option, + asset_folder_id: Option, + source_resource_id: Option, + task_id: String, + prompt: String, + actual_prompt: Option, + model: String, + provider: String, + generation_inputs: Option, + path_kind: &'static str, + asset_kind: &'static str, + persistence_provider: &'static str, +} + +async fn persist_editor_spritesheet_slices( + state: &AppState, + slices: Vec, + input: PersistEditorSpritesheetSlicesInput, +) -> Result, AppError> { + if slices.is_empty() { + return Err( + AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ + "provider": "editor-icon-spritesheet-slicing", + "message": "图集中未识别到可拆分的独立素材。", + })), + ); + } + + let mut results = Vec::with_capacity(slices.len()); + for (index, slice) in slices.into_iter().enumerate() { + let image = DownloadedOpenAiImage { + bytes: slice.bytes, + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + let file_stem = format!("asset-{:03}", index + 1); + let persisted = persist_editor_generated_image( + state, + input.owner_user_id.as_str(), + input.task_id.as_str(), + &image, + input.prompt.as_str(), + input.actual_prompt.as_deref(), + input.asset_kind, + input.path_kind, + file_stem.as_str(), + "asset", + input.persistence_provider, + ) + .await?; + let image_src = editor_media_src_from_object_key(persisted.object_key.as_str()); + let record = persist_editor_generated_asset( + state, + PersistEditorGeneratedAssetInput { + project_id: input.project_id.clone(), + owner_user_id: input.owner_user_id.clone(), + folder_id: input.asset_folder_id.clone(), + label: slice.name.clone(), + image_src: image_src.clone(), + object_key: Some(persisted.object_key), + asset_object_id: Some(persisted.asset_object_id), + width: slice.width, + height: slice.height, + prompt: input.prompt.clone(), + actual_prompt: input.actual_prompt.clone(), + model: input.model.clone(), + provider: input.provider.clone(), + task_id: input.task_id.clone(), + source_resource_id: input.source_resource_id.clone(), + asset_kind: Some("icon".to_string()), + generation_inputs: input.generation_inputs.clone(), + thumbnail_src: None, + generation_cost_mud_points: 0, + }, + ) + .await?; + results.push(EditorIconSpritesheetIconResponse { + name: slice.name, + image_src, + width: slice.width, + height: slice.height, + resource: record.resource, + asset: record.asset, + }); + } + Ok(results) +} + pub async fn generate_editor_icon_spritesheet( State(state): State, Extension(request_context): Extension, @@ -3199,7 +3517,20 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( "message": "VectorEngine 未返回图标 spritesheet", })) })?; - persist_editor_green_screen_source_image( + let (source_width, source_height) = image::load_from_memory(image.bytes.as_slice()) + .map(|image| (image.width(), image.height())) + .unwrap_or((512, 512)); + let generation_inputs = apply_editor_screen_background_decision_to_generation_inputs( + payload.generation_inputs.clone(), + Some(&screen_background_decision), + ); + let spritesheet_label = + resolve_editor_generated_asset_label(payload.asset_label.clone(), "图标素材图集"); + let asset_folder_id = payload + .asset_folder_id + .clone() + .or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())); + let source_persisted = persist_editor_green_screen_source_image( state, caller.owner_user_id.as_str(), generated.task_id.as_str(), @@ -3210,6 +3541,25 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( "spritesheet", ) .await?; + let source_record = persist_editor_green_screen_source_resource( + state, + source_persisted, + PersistEditorGreenScreenSourceResourceInput { + project_id: payload.project_id.clone(), + owner_user_id: caller.owner_user_id.clone(), + folder_id: asset_folder_id.clone(), + label: editor_generated_asset_variant_label(spritesheet_label.as_str(), "原图"), + width: source_width, + height: source_height, + prompt: prompt.clone(), + actual_prompt: generated.actual_prompt.clone(), + model: generation_options.model.to_string(), + task_id: generated.task_id.clone(), + source_resource_id: None, + generation_inputs: generation_inputs.clone(), + }, + ) + .await?; let matting_audit = crate::external_api_audit::ExternalApiAuditContext { user_id: caller.audit_subject_user_id.clone(), profile_id: caller @@ -3229,10 +3579,6 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( let (spritesheet_width, spritesheet_height) = image::load_from_memory(image.bytes.as_slice()) .map(|image| (image.width(), image.height())) .unwrap_or((512, 512)); - let generation_inputs = apply_editor_screen_background_decision_to_generation_inputs( - payload.generation_inputs.clone(), - Some(&screen_background_decision), - ); let owner_user_id = caller.owner_user_id; let spritesheet_persisted = persist_editor_generated_image( state, @@ -3255,8 +3601,8 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( PersistEditorGeneratedAssetInput { project_id: payload.project_id.clone(), owner_user_id: owner_user_id.clone(), - folder_id: payload.asset_folder_id.clone(), - label: "图标素材图集".to_string(), + folder_id: asset_folder_id.clone(), + label: spritesheet_label, image_src: spritesheet_image_src.clone(), object_key: Some(spritesheet_persisted.object_key.clone()), asset_object_id: Some(spritesheet_persisted.asset_object_id.clone()), @@ -3275,12 +3621,85 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( }, ) .await?; - let icon_image_srcs = Vec::new(); + let slice_source = DownloadedImage { + bytes: image.bytes.clone(), + mime_type: image.mime_type.clone(), + extension: image.extension.clone(), + }; + let (icon_image_srcs, slice_warning) = + match slice_generated_icon_spritesheet_by_connected_components( + &slice_source, + &icon_descriptions, + ) { + Ok(icon_slices) => { + match persist_editor_spritesheet_slices( + state, + icon_slices, + PersistEditorSpritesheetSlicesInput { + owner_user_id: owner_user_id.clone(), + project_id: payload.project_id.clone(), + asset_folder_id: asset_folder_id.clone(), + source_resource_id: spritesheet_record + .resource + .as_ref() + .map(|resource| resource.resource_id.clone()), + task_id: generated.task_id.clone(), + prompt: prompt.clone(), + actual_prompt: generated.actual_prompt.clone(), + model: generation_options.model.to_string(), + provider: "VectorEngine".to_string(), + generation_inputs: generation_inputs.clone(), + path_kind: "icon-spritesheet-assets", + asset_kind: EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND, + persistence_provider: "vector-engine", + }, + ) + .await + { + Ok(icon_image_srcs) => (icon_image_srcs, None), + Err(error) => { + let reason = error.body_text(); + tracing::warn!( + provider = "editor-icon-spritesheet", + operation = "persist_automatic_slices", + task_id = %generated.task_id, + reason = %reason, + "图标图集已持久化,但自动拆分素材持久化失败" + ); + ( + Vec::new(), + Some(EditorIconSpritesheetSliceWarningResponse { + code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE, + reason, + }), + ) + } + } + } + Err(error) => { + let reason = error.to_string(); + tracing::warn!( + provider = "editor-icon-spritesheet", + operation = "detect_automatic_slices", + task_id = %generated.task_id, + reason = %reason, + "图标图集已持久化,但自动拆分未识别到全部素材" + ); + ( + Vec::new(), + Some(EditorIconSpritesheetSliceWarningResponse { + code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS, + reason, + }), + ) + } + }; let (canvas_items, primary_layer_id) = if let Some(completion) = payload.canvas_completion.as_ref() { build_icon_spritesheet_canvas_layer_items( completion, spritesheet_record.resource.as_ref(), + source_record.resource.as_ref(), icon_image_srcs.as_slice(), ) } else { @@ -3303,6 +3722,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( spritesheet_width, spritesheet_height, icon_image_srcs, + slice_warning, prompt, actual_prompt: generated.actual_prompt, model: generation_options.model.to_string(), @@ -3316,6 +3736,196 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner( )) } +pub async fn split_editor_icon_spritesheet( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, AppError> { + let owner_user_id = authenticated.claims().user_id().to_string(); + let project_id = payload.project_id.trim().to_string(); + let source_resource_id = payload.source_resource_id.trim().to_string(); + if project_id.is_empty() || source_resource_id.is_empty() { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-icon-spritesheet-slicing", + "message": "projectId 和 sourceResourceId 不能为空。", + })), + ); + } + + let project = state + .spacetime_client() + .get_editor_project(EditorProjectGetRecordInput { + project_id: project_id.clone(), + owner_user_id: owner_user_id.clone(), + }) + .await + .map_err(map_editor_project_error)?; + let source_resource = project + .resources + .iter() + .find(|resource| resource.resource_id == source_resource_id) + .ok_or_else(|| { + AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({ + "provider": "editor-icon-spritesheet-slicing", + "message": "未找到要拆分的图集资源。", + })) + })?; + if source_resource.asset_kind.as_deref() != Some("icon-spritesheet") { + return Err( + AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ + "provider": "editor-icon-spritesheet-slicing", + "message": "只有图集素材可以使用拆分功能。", + })), + ); + } + + let reference = + parse_editor_reference_image(&state, owner_user_id.as_str(), source_resource_id.as_str()) + .await?; + let source = DownloadedImage { + bytes: reference.bytes, + mime_type: reference.mime_type, + extension: "png".to_string(), + }; + validate_editor_icon_spritesheet_manual_source(&source)?; + let slices = + slice_generated_icon_spritesheet_all_by_connected_components(&source).map_err(|error| { + AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ + "provider": "editor-icon-spritesheet-slicing", + "message": error.to_string(), + })) + })?; + validate_editor_icon_spritesheet_manual_slice_count(slices.len())?; + let task_id = build_prefixed_uuid_id("editor-atlas-split-"); + let prompt = source_resource + .prompt + .clone() + .unwrap_or_else(|| "拆分图集素材".to_string()); + let actual_prompt = source_resource.actual_prompt.clone(); + let model = source_resource + .model + .clone() + .unwrap_or_else(|| "connected-components".to_string()); + let provider = source_resource + .provider + .clone() + .unwrap_or_else(|| "Genarrative".to_string()); + let generation_inputs = source_resource.generation_inputs.clone(); + let icon_image_srcs = persist_editor_spritesheet_slices( + &state, + slices, + PersistEditorSpritesheetSlicesInput { + owner_user_id: owner_user_id.clone(), + project_id: Some(project_id.clone()), + asset_folder_id: payload + .asset_folder_id + .or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())), + source_resource_id: Some(source_resource_id), + task_id, + prompt, + actual_prompt, + model, + provider, + generation_inputs, + path_kind: "icon-spritesheet-slices", + asset_kind: EDITOR_ICON_SPRITESHEET_SLICE_ASSET_KIND, + persistence_provider: "genarrative", + }, + ) + .await?; + let canvas_items = build_icon_spritesheet_slice_canvas_layer_items( + &payload.canvas_completion, + icon_image_srcs.as_slice(), + ); + let project = complete_editor_canvas_generation_with_items( + &state, + owner_user_id.as_str(), + Some(project_id.as_str()), + Some(&payload.canvas_completion), + canvas_items, + icon_image_srcs + .first() + .and_then(|icon| icon.resource.as_ref()) + .map(|resource| generated_canvas_layer_id(resource.resource_id.as_str())), + ) + .await? + .ok_or_else(|| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-icon-spritesheet-slicing", + "message": "图集拆分结果未能写入画布。", + })) + })?; + + Ok(json_success_body( + Some(&request_context), + EditorIconSpritesheetSliceResponse { + icon_image_srcs, + project, + }, + )) +} + +fn validate_editor_icon_spritesheet_manual_source( + source: &DownloadedImage, +) -> Result<(), AppError> { + let reader = image::ImageReader::new(Cursor::new(source.bytes.as_slice())) + .with_guessed_format() + .map_err(|error| { + AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ + "provider": "editor-icon-spritesheet-slicing", + "message": format!("无法识别图集图片格式:{error}"), + })) + })?; + let (width, height) = reader.into_dimensions().map_err(|error| { + AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ + "provider": "editor-icon-spritesheet-slicing", + "message": format!("无法读取图集图片尺寸:{error}"), + })) + })?; + validate_editor_icon_spritesheet_manual_dimensions(width, height) +} + +fn validate_editor_icon_spritesheet_manual_dimensions( + width: u32, + height: u32, +) -> Result<(), AppError> { + let pixel_count = u64::from(width).saturating_mul(u64::from(height)); + if width == 0 + || height == 0 + || width > EDITOR_ICON_SPRITESHEET_MANUAL_MAX_DIMENSION + || height > EDITOR_ICON_SPRITESHEET_MANUAL_MAX_DIMENSION + || pixel_count > EDITOR_ICON_SPRITESHEET_MANUAL_MAX_PIXELS + { + return Err( + AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ + "provider": "editor-icon-spritesheet-slicing", + "message": "图集尺寸超过手动拆分限制。", + "width": width, + "height": height, + "maxDimension": EDITOR_ICON_SPRITESHEET_MANUAL_MAX_DIMENSION, + "maxPixels": EDITOR_ICON_SPRITESHEET_MANUAL_MAX_PIXELS, + })), + ); + } + Ok(()) +} + +fn validate_editor_icon_spritesheet_manual_slice_count(slice_count: usize) -> Result<(), AppError> { + if slice_count > EDITOR_ICON_SPRITESHEET_MANUAL_MAX_SLICES { + return Err( + AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({ + "provider": "editor-icon-spritesheet-slicing", + "message": "图集识别出的素材数量超过手动拆分限制。", + "sliceCount": slice_count, + "maxSliceCount": EDITOR_ICON_SPRITESHEET_MANUAL_MAX_SLICES, + })), + ); + } + Ok(()) +} + pub async fn extract_editor_ui_design_assets( State(state): State, Extension(request_context): Extension, @@ -3500,7 +4110,20 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( "message": "VectorEngine 未返回 UI 设计图素材 spritesheet", })) })?; - persist_editor_green_screen_source_image( + let (source_width, source_height) = image::load_from_memory(image.bytes.as_slice()) + .map(|image| (image.width(), image.height())) + .unwrap_or((512, 512)); + let generation_inputs = apply_editor_screen_background_decision_to_generation_inputs( + payload.generation_inputs.clone(), + Some(&screen_background_decision), + ); + let source_label = + resolve_editor_generated_asset_label(payload.spritesheet_label.clone(), "UI设计图素材图集"); + let asset_folder_id = payload + .asset_folder_id + .clone() + .or_else(|| Some(EDITOR_ASSET_DEFAULT_FOLDER_ID.to_string())); + let source_persisted = persist_editor_green_screen_source_image( state, caller.owner_user_id.as_str(), generated.task_id.as_str(), @@ -3511,6 +4134,25 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( "spritesheet", ) .await?; + let source_record = persist_editor_green_screen_source_resource( + state, + source_persisted, + PersistEditorGreenScreenSourceResourceInput { + project_id: payload.project_id.clone(), + owner_user_id: caller.owner_user_id.clone(), + folder_id: asset_folder_id.clone(), + label: editor_generated_asset_variant_label(source_label.as_str(), "原图"), + width: source_width, + height: source_height, + prompt: prompt.clone(), + actual_prompt: generated.actual_prompt.clone(), + model: generation_options.model.to_string(), + task_id: generated.task_id.clone(), + source_resource_id: None, + generation_inputs: generation_inputs.clone(), + }, + ) + .await?; let matting_audit = crate::external_api_audit::ExternalApiAuditContext { user_id: caller.audit_subject_user_id.clone(), profile_id: caller @@ -3530,22 +4172,6 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( let (spritesheet_width, spritesheet_height) = image::load_from_memory(image.bytes.as_slice()) .map(|image| (image.width(), image.height())) .unwrap_or((512, 512)); - let source = DownloadedImage { - bytes: image.bytes.clone(), - mime_type: image.mime_type.clone(), - extension: image.extension.clone(), - }; - let icon_slices = slice_generated_icon_spritesheet_all_by_connected_components(&source) - .map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "editor-ui-design-asset-extraction", - "message": error.to_string(), - })) - })?; - let generation_inputs = apply_editor_screen_background_decision_to_generation_inputs( - payload.generation_inputs.clone(), - Some(&screen_background_decision), - ); let owner_user_id = caller.owner_user_id; let spritesheet_persisted = persist_editor_generated_image( state, @@ -3568,9 +4194,8 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( PersistEditorGeneratedAssetInput { project_id: payload.project_id.clone(), owner_user_id: owner_user_id.clone(), - folder_id: payload.asset_folder_id.clone(), - label: normalize_optional_string(payload.spritesheet_label) - .unwrap_or_else(|| "UI设计图素材图集".to_string()), + folder_id: asset_folder_id.clone(), + label: source_label, image_src: spritesheet_image_src.clone(), object_key: Some(spritesheet_persisted.object_key.clone()), asset_object_id: Some(spritesheet_persisted.asset_object_id.clone()), @@ -3589,72 +4214,82 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( }, ) .await?; - let mut icon_image_srcs = Vec::new(); - for icon in icon_slices { - let icon_index = icon_image_srcs.len() + 1; - let icon_name = icon.name.clone(); - let icon_width = icon.width; - let icon_height = icon.height; - let icon_image = DownloadedOpenAiImage { - bytes: icon.bytes, - mime_type: "image/png".to_string(), - extension: "png".to_string(), + let slice_source = DownloadedImage { + bytes: image.bytes.clone(), + mime_type: image.mime_type.clone(), + extension: image.extension.clone(), + }; + let (icon_image_srcs, slice_warning) = + match slice_generated_icon_spritesheet_all_by_connected_components(&slice_source) { + Ok(icon_slices) => { + match persist_editor_spritesheet_slices( + state, + icon_slices, + PersistEditorSpritesheetSlicesInput { + owner_user_id: owner_user_id.clone(), + project_id: payload.project_id.clone(), + asset_folder_id: asset_folder_id.clone(), + source_resource_id: spritesheet_record + .resource + .as_ref() + .map(|resource| resource.resource_id.clone()), + task_id: generated.task_id.clone(), + prompt: prompt.clone(), + actual_prompt: generated.actual_prompt.clone(), + model: generation_options.model.to_string(), + provider: "VectorEngine".to_string(), + generation_inputs: generation_inputs.clone(), + path_kind: "ui-design-assets", + asset_kind: EDITOR_UI_DESIGN_ASSET_IMAGE_KIND, + persistence_provider: "vector-engine", + }, + ) + .await + { + Ok(icon_image_srcs) => (icon_image_srcs, None), + Err(error) => { + let reason = error.body_text(); + tracing::warn!( + provider = "editor-ui-design-asset-extraction", + operation = "persist_slices", + task_id = %generated.task_id, + reason = %reason, + "UI 设计图透明图集已持久化,但独立素材持久化失败" + ); + ( + Vec::new(), + Some(EditorIconSpritesheetSliceWarningResponse { + code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE, + reason, + }), + ) + } + } + } + Err(error) => { + let reason = error.to_string(); + tracing::warn!( + provider = "editor-ui-design-asset-extraction", + operation = "detect_slices", + task_id = %generated.task_id, + reason = %reason, + "UI 设计图透明图集已持久化,但未识别到可拆分素材" + ); + ( + Vec::new(), + Some(EditorIconSpritesheetSliceWarningResponse { + code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS, + reason, + }), + ) + } }; - let icon_file_stem = format!("asset-{icon_index:03}"); - let icon_persisted = persist_editor_generated_image( - state, - owner_user_id.as_str(), - generated.task_id.as_str(), - &icon_image, - prompt.as_str(), - generated.actual_prompt.as_deref(), - EDITOR_UI_DESIGN_ASSET_IMAGE_KIND, - "ui-design-assets", - icon_file_stem.as_str(), - "asset", - "vector-engine", - ) - .await?; - let icon_image_src = editor_media_src_from_object_key(icon_persisted.object_key.as_str()); - let icon_record = persist_editor_generated_asset( - state, - PersistEditorGeneratedAssetInput { - project_id: payload.project_id.clone(), - owner_user_id: owner_user_id.clone(), - folder_id: payload.asset_folder_id.clone(), - label: icon_name.clone(), - image_src: icon_image_src.clone(), - object_key: Some(icon_persisted.object_key.clone()), - asset_object_id: Some(icon_persisted.asset_object_id.clone()), - width: icon_width, - height: icon_height, - prompt: prompt.clone(), - actual_prompt: generated.actual_prompt.clone(), - model: generation_options.model.to_string(), - provider: "VectorEngine".to_string(), - task_id: generated.task_id.clone(), - source_resource_id: None, - asset_kind: Some("icon".to_string()), - generation_inputs: generation_inputs.clone(), - thumbnail_src: None, - generation_cost_mud_points: 0, - }, - ) - .await?; - icon_image_srcs.push(EditorIconSpritesheetIconResponse { - name: icon_name, - image_src: icon_image_src, - width: icon_width, - height: icon_height, - resource: icon_record.resource, - asset: icon_record.asset, - }); - } let (canvas_items, primary_layer_id) = if let Some(completion) = payload.canvas_completion.as_ref() { build_icon_spritesheet_canvas_layer_items( completion, spritesheet_record.resource.as_ref(), + source_record.resource.as_ref(), icon_image_srcs.as_slice(), ) } else { @@ -3677,6 +4312,7 @@ pub(crate) async fn extract_editor_ui_design_assets_for_owner( spritesheet_width, spritesheet_height, icon_image_srcs, + slice_warning, prompt, actual_prompt: generated.actual_prompt, model: generation_options.model.to_string(), @@ -4512,6 +5148,8 @@ pub(crate) async fn complete_editor_canvas_background_removal( project_id: Option<&str>, target_layer_id: Option<&str>, resource: Option<&EditorProjectResourcePayload>, + replacement_title: Option<&str>, + recenter_replacement: bool, ) -> Result, AppError> { let (Some(project_id), Some(target_layer_id), Some(resource)) = (project_id, target_layer_id, resource) @@ -4539,6 +5177,8 @@ pub(crate) async fn complete_editor_canvas_background_removal( project_payload.layers, target_layer_id, resource, + replacement_title, + recenter_replacement, ) else { return Ok(None); }; @@ -4562,6 +5202,8 @@ fn replace_editor_canvas_layer_with_resource( layers: Value, target_layer_id: &str, resource: &EditorProjectResourcePayload, + replacement_title: Option<&str>, + recenter_replacement: bool, ) -> Option { let mut items = match layers { Value::Array(items) => items, @@ -4580,11 +5222,40 @@ fn replace_editor_canvas_layer_with_resource( continue; } + let replacement_width = f64::from(resource.width.max(1)); + let replacement_height = f64::from(resource.height.max(1)); + let centered_position = recenter_replacement + .then(|| { + object + .get("x") + .and_then(Value::as_f64) + .zip(object.get("y").and_then(Value::as_f64)) + .zip(object.get("width").and_then(Value::as_f64)) + .zip(object.get("height").and_then(Value::as_f64)) + .map(|(((x, y), width), height)| { + ( + x + width / 2.0 - replacement_width / 2.0, + y + height / 2.0 - replacement_height / 2.0, + ) + }) + }) + .flatten(); + object.insert( "resourceId".to_string(), Value::String(resource.resource_id.clone()), ); + if let Some(title) = replacement_title + .map(str::trim) + .filter(|title| !title.is_empty()) + { + object.insert("title".to_string(), Value::String(title.to_string())); + } object.insert("src".to_string(), Value::String(resource.image_src.clone())); + if let Some((x, y)) = centered_position { + object.insert("x".to_string(), json!(x)); + object.insert("y".to_string(), json!(y)); + } object.insert("width".to_string(), json!(resource.width.max(1))); object.insert("height".to_string(), json!(resource.height.max(1))); object.insert("originalWidth".to_string(), json!(resource.width.max(1))); @@ -4919,38 +5590,113 @@ fn generated_canvas_media_type(asset_kind: Option<&str>) -> &'static str { } } +fn build_multi_asset_canvas_layer_items( + completion: &EditorCanvasGenerationCompletionRequest, + primary_resource: Option<&EditorProjectResourcePayload>, + source_resource: Option<&EditorProjectResourcePayload>, +) -> (Vec, Option) { + let Some(primary_resource) = primary_resource else { + return (Vec::new(), None); + }; + let placeholder = completion.placeholder.clone(); + let primary_layer_id = generated_canvas_layer_id(primary_resource.resource_id.as_str()); + let primary_item = build_generated_canvas_layer_item( + completion, + &placeholder, + primary_resource, + primary_layer_id.as_str(), + 0, + ); + let primary_right = primary_item + .get("x") + .and_then(Value::as_f64) + .unwrap_or(placeholder.x) + + primary_item + .get("width") + .and_then(Value::as_f64) + .unwrap_or(placeholder.width.max(1.0)); + let primary_y = primary_item + .get("y") + .and_then(Value::as_f64) + .unwrap_or(placeholder.y); + let mut items = vec![primary_item]; + + if let Some(source_resource) = source_resource { + let source_layer_id = generated_canvas_layer_id(source_resource.resource_id.as_str()); + let mut source_item = build_generated_canvas_layer_item( + completion, + &placeholder, + source_resource, + source_layer_id.as_str(), + 0, + ); + if let Some(object) = source_item.as_object_mut() { + object.insert("x".to_string(), json!(primary_right + 32.0)); + object.insert("y".to_string(), json!(primary_y)); + object.insert( + "title".to_string(), + json!(format!("{}(原图)", completion.title.trim())), + ); + } + items.push(source_item); + } + + (items, Some(primary_layer_id)) +} + fn build_icon_spritesheet_canvas_layer_items( completion: &EditorCanvasGenerationCompletionRequest, spritesheet_resource: Option<&EditorProjectResourcePayload>, + source_resource: Option<&EditorProjectResourcePayload>, icons: &[EditorIconSpritesheetIconResponse], ) -> (Vec, Option) { - let mut items = Vec::new(); - let mut primary_layer_id = None; let placeholder = completion.placeholder.clone(); - let Some(spritesheet_resource) = spritesheet_resource else { + let (mut items, primary_layer_id) = + build_multi_asset_canvas_layer_items(completion, spritesheet_resource, source_resource); + if items.is_empty() { return (items, primary_layer_id); - }; + } - let spritesheet_layer_id = generated_canvas_layer_id(spritesheet_resource.resource_id.as_str()); - primary_layer_id = Some(spritesheet_layer_id.clone()); - let spritesheet_item = build_generated_canvas_layer_item( + let start_x = items + .iter() + .filter_map(|item| Some(item.get("x")?.as_f64()? + item.get("width")?.as_f64()?)) + .fold(placeholder.x, f64::max) + + 32.0; + items.extend(build_icon_slice_canvas_layer_items_from_origin( completion, - &placeholder, - spritesheet_resource, - spritesheet_layer_id.as_str(), - 0, - ); - let sheet_width = spritesheet_item - .get("width") - .and_then(Value::as_f64) - .unwrap_or(placeholder.width.max(1.0)); - items.push(spritesheet_item); + icons, + start_x, + placeholder.y, + )); - let mut cursor_x = placeholder.x + sheet_width + 32.0; - let mut cursor_y = placeholder.y; + (items, primary_layer_id) +} + +fn build_icon_spritesheet_slice_canvas_layer_items( + completion: &EditorCanvasGenerationCompletionRequest, + icons: &[EditorIconSpritesheetIconResponse], +) -> Vec { + let placeholder = &completion.placeholder; + build_icon_slice_canvas_layer_items_from_origin( + completion, + icons, + placeholder.x + placeholder.width.max(1.0) + 32.0, + placeholder.y, + ) +} + +fn build_icon_slice_canvas_layer_items_from_origin( + completion: &EditorCanvasGenerationCompletionRequest, + icons: &[EditorIconSpritesheetIconResponse], + start_x: f64, + start_y: f64, +) -> Vec { + let placeholder = &completion.placeholder; + let mut items = Vec::new(); + let mut cursor_x = start_x; + let mut cursor_y = start_y; let mut row_height = 0.0_f64; let max_row_width = 560.0_f64; - let start_x = cursor_x; let spacing = 24.0_f64; for icon in icons { @@ -4973,6 +5719,7 @@ fn build_icon_spritesheet_canvas_layer_items( 0, ); if let Some(object) = item.as_object_mut() { + object.insert("title".to_string(), json!(icon.name)); object.insert("x".to_string(), json!(cursor_x)); object.insert("y".to_string(), json!(cursor_y)); object.insert("width".to_string(), json!(width)); @@ -4984,8 +5731,7 @@ fn build_icon_spritesheet_canvas_layer_items( row_height = row_height.max(height); items.push(item); } - - (items, primary_layer_id) + items } fn next_canvas_layer_z_index(items: &[Value]) -> i64 { @@ -5185,6 +5931,25 @@ pub(crate) fn normalize_optional_string(value: Option) -> Option .filter(|item| !item.is_empty()) } +fn resolve_editor_generated_asset_label(value: Option, fallback: &str) -> String { + normalize_optional_string(value) + .unwrap_or_else(|| fallback.to_string()) + .chars() + .take(EDITOR_ASSET_LABEL_MAX_CHARS) + .collect() +} + +fn editor_generated_asset_variant_label(label: &str, variant: &str) -> String { + let suffix = format!("({variant})"); + let base_char_limit = EDITOR_ASSET_LABEL_MAX_CHARS.saturating_sub(suffix.chars().count()); + let base = label + .trim() + .chars() + .take(base_char_limit) + .collect::(); + format!("{base}{suffix}") +} + pub(crate) fn sanitize_editor_storage_segment(value: &str, fallback: &str) -> String { let normalized = value .trim() @@ -5345,6 +6110,21 @@ pub(crate) struct PersistedEditorGeneratedImage { pub(crate) asset_object_id: String, } +struct PersistEditorGreenScreenSourceResourceInput { + project_id: Option, + owner_user_id: String, + folder_id: Option, + label: String, + width: u32, + height: u32, + prompt: String, + actual_prompt: Option, + model: String, + task_id: String, + source_resource_id: Option, + generation_inputs: Option, +} + pub(crate) async fn persist_editor_generated_image( state: &AppState, owner_user_id: &str, @@ -5474,6 +6254,38 @@ async fn persist_editor_green_screen_source_image( .await } +async fn persist_editor_green_screen_source_resource( + state: &AppState, + persisted: PersistedEditorGeneratedImage, + input: PersistEditorGreenScreenSourceResourceInput, +) -> Result { + persist_editor_generated_asset( + state, + PersistEditorGeneratedAssetInput { + project_id: input.project_id, + owner_user_id: input.owner_user_id, + folder_id: input.folder_id, + label: input.label, + image_src: editor_media_src_from_object_key(persisted.object_key.as_str()), + object_key: Some(persisted.object_key), + asset_object_id: Some(persisted.asset_object_id), + width: input.width, + height: input.height, + prompt: input.prompt, + actual_prompt: input.actual_prompt, + model: input.model, + provider: "VectorEngine".to_string(), + task_id: input.task_id, + source_resource_id: input.source_resource_id, + asset_kind: Some(EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND.to_string()), + generation_inputs: input.generation_inputs, + thumbnail_src: None, + generation_cost_mud_points: 0, + }, + ) + .await +} + const EDITOR_REFERENCE_IMAGE_READ_EXPIRE_SECONDS: u64 = 300; const EDITOR_REFERENCE_IMAGE_MAX_SIZE_BYTES: u64 = 32 * 1024 * 1024; const EDITOR_BACKGROUND_REMOVAL_MAX_RESPONSE_BYTES: usize = 32 * 1024 * 1024; @@ -5637,9 +6449,15 @@ async fn ensure_editor_reference_object_key_owned( }) { return Ok(()); } + let Some(oss_client) = state.oss_client() else { + return Err(editor_reference_image_forbidden(object_key)); + }; if let Some(asset_object) = state .spacetime_client() - .get_asset_object_by_object_key(object_key.to_string()) + .get_asset_object_by_location(module_assets::AssetObjectLocationInput { + bucket: oss_client.config_bucket().to_string(), + object_key: object_key.to_string(), + }) .await .map_err(map_editor_project_error)? { @@ -5922,6 +6740,49 @@ mod tests { use super::*; use crate::{config::AppConfig, editor_green_screen::parse_editor_screen_background_color}; + fn editor_project_resource_for_canvas_test( + resource_id: &str, + asset_kind: &str, + width: u32, + height: u32, + ) -> EditorProjectResourcePayload { + EditorProjectResourcePayload { + resource_id: resource_id.to_string(), + showcase_id: None, + asset_id: None, + label: None, + project_id: "editor-project-1".to_string(), + owner_user_id: "user-1".to_string(), + author_display_name: None, + author_public_user_code: None, + image_src: format!("/generated-character-drafts/editor/{resource_id}.png"), + object_key: Some(format!( + "generated-character-drafts/editor/{resource_id}.png" + )), + asset_object_id: Some(format!("asset-object-{resource_id}")), + width, + height, + source_type: "generated".to_string(), + prompt: Some("银发游侠".to_string()), + actual_prompt: Some("实际提示词".to_string()), + model: Some("gpt-image-2".to_string()), + provider: Some("VectorEngine".to_string()), + task_id: Some("task-character".to_string()), + source_resource_id: None, + asset_kind: Some(asset_kind.to_string()), + showcase_category: None, + generation_inputs: None, + public_showcase_enabled: true, + review_status: None, + display_enabled: None, + like_count: None, + generation_cost_mud_points: 0, + refund_mud_points: None, + created_at: "2026-07-13T00:00:00.000Z".to_string(), + updated_at: "2026-07-13T00:00:00.000Z".to_string(), + } + } + #[test] fn editor_project_procedure_errors_preserve_business_reason_as_bad_request() { let error = map_editor_project_error(SpacetimeClientError::Procedure( @@ -6963,8 +7824,14 @@ mod tests { updated_at: "2026-06-29T00:00:00.000Z".to_string(), }; - let replaced = replace_editor_canvas_layer_with_resource(layers, "layer-source", &resource) - .expect("target layer should be replaced"); + let replaced = replace_editor_canvas_layer_with_resource( + layers.clone(), + "layer-source", + &resource, + None, + false, + ) + .expect("target layer should be replaced"); let items = replaced.as_array().expect("layers should stay array"); let layer = items .iter() @@ -6986,8 +7853,27 @@ mod tests { assert_eq!(layer["taskId"], json!("extgen-cutout")); assert_eq!(layer["sourceResourceId"], json!("resource-source")); assert_eq!(layer["assetKind"], json!("character")); + assert_eq!(layer["title"], json!("源图")); assert!(layer.get("sourceAssetId").is_none()); assert!(layer.get("thumbnailSrc").is_none()); + + let recentered = replace_editor_canvas_layer_with_resource( + layers, + "layer-source", + &resource, + Some("勇者快速编辑"), + true, + ) + .expect("quick edit replacement should recenter the layer"); + let recentered_layer = recentered + .as_array() + .and_then(|items| items.iter().find(|item| item["layerId"] == "layer-source")) + .expect("quick edit source layer should remain"); + assert_eq!(recentered_layer["x"], json!(24.0)); + assert_eq!(recentered_layer["y"], json!(-124.0)); + assert_eq!(recentered_layer["title"], json!("勇者快速编辑")); + assert_eq!(recentered_layer["x"].as_f64().unwrap() + 512.0 / 2.0, 280.0); + assert_eq!(recentered_layer["y"].as_f64().unwrap() + 768.0 / 2.0, 260.0); } #[test] @@ -7229,6 +8115,200 @@ mod tests { assert_eq!(result_layer["y"], json!(290.0)); } + #[test] + fn editor_canvas_multi_asset_items_keep_source_and_anchor_processed_result() { + let completion = EditorCanvasGenerationCompletionRequest { + dialog_id: Some("dialog-character".to_string()), + title: "角色形象 1".to_string(), + placeholder: EditorCanvasGenerationPlaceholderPayload { + x: 100.0, + y: 80.0, + width: 200.0, + height: 300.0, + original_width: 400.0, + original_height: 600.0, + }, + }; + let processed = editor_project_resource_for_canvas_test( + "resource-character-cutout", + "character", + 400, + 600, + ); + let source = editor_project_resource_for_canvas_test( + "resource-character-source", + EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND, + 400, + 600, + ); + + let (items, primary_layer_id) = + build_multi_asset_canvas_layer_items(&completion, Some(&processed), Some(&source)); + + assert_eq!(items.len(), 2); + assert_eq!( + primary_layer_id.as_deref(), + Some("layer-editor-resource-resource-character-cutout") + ); + assert_eq!(items[0]["resourceId"], json!("resource-character-cutout")); + assert_eq!(items[0]["x"], json!(0.0)); + assert_eq!(items[0]["y"], json!(-70.0)); + assert_eq!(items[1]["resourceId"], json!("resource-character-source")); + assert_eq!(items[1]["title"], json!("角色形象 1(原图)")); + assert_eq!(items[1]["x"], json!(432.0)); + assert_eq!(items[1]["y"], json!(-70.0)); + + let layers = json!([{ + "itemType": "generation-dialog", + "layerId": "generation-dialog:dialog-character", + "resourceId": "generation-dialog:dialog-character", + "dialog": { + "id": "dialog-character", + "status": "generating", + "placeholder": completion.placeholder.clone() + } + }]); + let completed = apply_editor_canvas_generation_multi_items( + layers, + &completion, + items, + primary_layer_id, + ) + .expect("multi-asset completion should apply"); + let completed_items = completed + .layers + .as_array() + .expect("layers should stay array"); + assert_eq!( + completed_items + .iter() + .filter(|item| item + .get("resourceId") + .and_then(Value::as_str) + .is_some_and(|id| id.starts_with("resource-character-"))) + .count(), + 2 + ); + let dialog = completed_items + .iter() + .find(|item| item["itemType"] == "generation-dialog") + .and_then(|item| item.get("dialog")) + .expect("generation dialog should remain"); + assert_eq!( + dialog["generatedLayerId"], + json!("layer-editor-resource-resource-character-cutout") + ); + } + + #[test] + fn editor_canvas_spritesheet_items_include_source_sheet_and_slices() { + let completion = EditorCanvasGenerationCompletionRequest { + dialog_id: Some("dialog-ui-assets".to_string()), + title: "UI素材图集".to_string(), + placeholder: EditorCanvasGenerationPlaceholderPayload { + x: 100.0, + y: 80.0, + width: 256.0, + height: 256.0, + original_width: 512.0, + original_height: 512.0, + }, + }; + let processed = editor_project_resource_for_canvas_test( + "resource-ui-sheet", + "icon-spritesheet", + 512, + 512, + ); + let source = editor_project_resource_for_canvas_test( + "resource-ui-source", + EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND, + 512, + 512, + ); + let slice = editor_project_resource_for_canvas_test("resource-ui-slice", "icon", 128, 128); + let icons = vec![EditorIconSpritesheetIconResponse { + name: "开始按钮".to_string(), + image_src: slice.image_src.clone(), + width: 128, + height: 128, + resource: Some(slice), + asset: None, + }]; + + let (items, primary_layer_id) = build_icon_spritesheet_canvas_layer_items( + &completion, + Some(&processed), + Some(&source), + icons.as_slice(), + ); + + assert_eq!(items.len(), 3); + assert_eq!( + primary_layer_id.as_deref(), + Some("layer-editor-resource-resource-ui-sheet") + ); + assert_eq!(items[0]["resourceId"], json!("resource-ui-sheet")); + assert_eq!(items[1]["resourceId"], json!("resource-ui-source")); + assert_eq!(items[2]["resourceId"], json!("resource-ui-slice")); + let source_right = + items[1]["x"].as_f64().unwrap() + items[1]["width"].as_f64().unwrap(); + assert!(items[2]["x"].as_f64().unwrap() > source_right); + } + + #[test] + fn editor_canvas_spritesheet_items_keep_sheet_and_source_when_slices_are_empty() { + let completion = EditorCanvasGenerationCompletionRequest { + dialog_id: Some("dialog-icon-sheet".to_string()), + title: "图标素材图集".to_string(), + placeholder: EditorCanvasGenerationPlaceholderPayload { + x: 100.0, + y: 80.0, + width: 256.0, + height: 256.0, + original_width: 512.0, + original_height: 512.0, + }, + }; + let processed = editor_project_resource_for_canvas_test( + "resource-icon-sheet", + "icon-spritesheet", + 512, + 512, + ); + let source = editor_project_resource_for_canvas_test( + "resource-icon-source", + EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND, + 512, + 512, + ); + + let (items, primary_layer_id) = build_icon_spritesheet_canvas_layer_items( + &completion, + Some(&processed), + Some(&source), + &[], + ); + + assert_eq!(items.len(), 2); + assert_eq!(items[0]["resourceId"], json!("resource-icon-sheet")); + assert_eq!(items[1]["resourceId"], json!("resource-icon-source")); + assert_eq!( + primary_layer_id.as_deref(), + Some("layer-editor-resource-resource-icon-sheet") + ); + } + + #[test] + fn manual_icon_spritesheet_limits_reject_large_images_and_excessive_slices() { + assert!(validate_editor_icon_spritesheet_manual_dimensions(2048, 2048).is_ok()); + assert!(validate_editor_icon_spritesheet_manual_dimensions(4096, 1024).is_ok()); + assert!(validate_editor_icon_spritesheet_manual_dimensions(2049, 2048).is_err()); + assert!(validate_editor_icon_spritesheet_manual_dimensions(4097, 1).is_err()); + assert!(validate_editor_icon_spritesheet_manual_slice_count(64).is_ok()); + assert!(validate_editor_icon_spritesheet_manual_slice_count(65).is_err()); + } + #[test] fn editor_canvas_generation_completion_skips_when_dialog_was_removed() { let layers = json!([ @@ -7574,6 +8654,10 @@ mod tests { spritesheet_width: 512, spritesheet_height: 512, icon_image_srcs: Vec::new(), + slice_warning: Some(EditorIconSpritesheetSliceWarningResponse { + code: EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS, + reason: "图标 spritesheet 连通域数量不足:需要 2 个,实际 1 个。".to_string(), + }), prompt: "图标 prompt".to_string(), actual_prompt: Some("图标 prompt".to_string()), model: "gemini-3.1-flash-image-preview".to_string(), @@ -7592,6 +8676,15 @@ mod tests { assert_eq!(payload["priceMudPoints"], json!(12)); assert_eq!(payload["iconImageSrcs"], json!([])); + assert_eq!( + payload["sliceWarning"]["code"], + json!("insufficient-connected-components") + ); + assert!( + payload["sliceWarning"]["reason"] + .as_str() + .is_some_and(|reason| reason.contains("连通域数量不足")) + ); } #[tokio::test] @@ -7698,6 +8791,66 @@ mod tests { assert_eq!(request.target_layer_id.as_deref(), Some("layer-source")); } + #[test] + fn editor_image_edit_rejects_icons_but_allows_icon_specs() { + for asset_kind in ["icon", "icon-spritesheet"] { + let error = ensure_editor_image_edit_asset_kind_allowed(Some(asset_kind)) + .expect_err("icon assets should not support quick edit"); + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + assert_eq!( + error.details().and_then(|details| details.get("message")), + Some(&json!("图标与图标图集不支持快速编辑")), + ); + assert_eq!( + error.details().and_then(|details| details.get("assetKind")), + Some(&json!(asset_kind)), + ); + } + + assert!(ensure_editor_image_edit_asset_kind_allowed(Some("icon-spec")).is_ok()); + assert!(ensure_editor_image_edit_asset_kind_allowed(Some("image")).is_ok()); + assert!(ensure_editor_image_edit_asset_kind_allowed(None).is_ok()); + + let layers = json!([ + { "layerId": "layer-icon", "assetKind": "icon" }, + { "layerId": "layer-icon-spec", "assetKind": "icon-spec" } + ]); + let icon_kind = + resolve_editor_image_edit_target_layer_asset_kind(&layers, &[], "layer-icon") + .expect("icon layer should exist"); + assert!(ensure_editor_image_edit_asset_kind_allowed(icon_kind).is_err()); + let icon_spec_kind = + resolve_editor_image_edit_target_layer_asset_kind(&layers, &[], "layer-icon-spec") + .expect("icon spec layer should exist"); + assert!(ensure_editor_image_edit_asset_kind_allowed(icon_spec_kind).is_ok()); + assert!( + resolve_editor_image_edit_target_layer_asset_kind(&layers, &[], "missing").is_err() + ); + } + + #[test] + fn editor_image_edit_guards_before_queue_and_owner_execution() { + let source = include_str!("editor_project.rs"); + assert_function_contains_in_order( + source, + "pub async fn edit_editor_image(", + "pub(crate) async fn edit_editor_image_for_owner", + &[ + "ensure_editor_image_edit_source_allowed(", + "enqueue_editor_generation_job", + ], + ); + assert_function_contains_in_order( + source, + "pub(crate) async fn edit_editor_image_for_owner", + "pub async fn remove_editor_image_background", + &[ + "ensure_editor_image_edit_source_allowed(", + "parse_editor_reference_image", + ], + ); + } + #[test] fn editor_image_edit_can_complete_by_replacing_target_layer() { let source = include_str!("editor_project.rs"); @@ -7720,7 +8873,8 @@ mod tests { "referenceImageSrcs": ["data:image/png;base64,style"], "iconDescriptions": ["返回按钮"], "screenColor": "#7FB3FF", - "segModel": "anime-seg" + "segModel": "anime-seg", + "assetLabel": " 冒险界面图标 " })) .expect("icon spritesheet request should deserialize references"); @@ -7730,6 +8884,62 @@ mod tests { ); assert_eq!(request.screen_color.as_deref(), Some("#7FB3FF")); assert_eq!(request.seg_model.as_deref(), Some("anime-seg")); + assert_eq!(request.asset_label.as_deref(), Some(" 冒险界面图标 ")); + } + + #[test] + fn editor_generated_asset_label_trims_custom_name_and_falls_back_for_blank_input() { + assert_eq!( + resolve_editor_generated_asset_label( + Some(" 冒险界面图标 ".to_string()), + "图标素材图集" + ), + "冒险界面图标" + ); + assert_eq!( + resolve_editor_generated_asset_label(Some(" ".to_string()), "图标素材图集"), + "图标素材图集" + ); + assert_eq!( + resolve_editor_generated_asset_label(Some("图".repeat(81)), "图标素材图集") + .chars() + .count(), + EDITOR_ASSET_LABEL_MAX_CHARS + ); + assert_eq!( + editor_generated_asset_variant_label("冒险界面图标", "原图"), + "冒险界面图标(原图)" + ); + let long_variant = editor_generated_asset_variant_label("图".repeat(80).as_str(), "原图"); + assert_eq!(long_variant.chars().count(), EDITOR_ASSET_LABEL_MAX_CHARS); + assert!(long_variant.ends_with("(原图)")); + } + + #[test] + fn editor_icon_spritesheet_slice_request_requires_project_source_and_layout() { + let request: EditorIconSpritesheetSliceRequest = serde_json::from_value(json!({ + "projectId": "project-1", + "sourceResourceId": "resource-atlas-1", + "assetFolderId": "project", + "canvasCompletion": { + "title": "拆分图集", + "placeholder": { + "x": 120.0, + "y": 80.0, + "width": 360.0, + "height": 360.0, + "originalWidth": 1024.0, + "originalHeight": 1024.0 + } + } + })) + .expect("spritesheet slice request should deserialize"); + + assert_eq!(request.project_id, "project-1"); + assert_eq!(request.source_resource_id, "resource-atlas-1"); + assert_eq!(request.asset_folder_id.as_deref(), Some("project")); + assert_eq!(request.canvas_completion.title, "拆分图集"); + assert_eq!(request.canvas_completion.placeholder.width, 360.0); } #[test] @@ -7819,6 +9029,7 @@ mod tests { &[ "persist_editor_green_screen_source_image", "remove_editor_generated_screen_background_with_bgfilter", + "persist_editor_green_screen_source_resource", "if is_character_generation", ], ); @@ -7828,6 +9039,7 @@ mod tests { "fn normalize_editor_image_generation_size", &[ "persist_editor_green_screen_source_image", + "persist_editor_green_screen_source_resource", "remove_editor_generated_screen_background_with_bgfilter", ], ); @@ -7838,6 +9050,9 @@ mod tests { &[ "persist_editor_green_screen_source_image", "remove_editor_generated_screen_background_with_bgfilter", + "persist_editor_green_screen_source_resource", + "slice_generated_icon_spritesheet_by_connected_components", + "persist_editor_spritesheet_slices", ], ); assert_function_contains_in_order( @@ -7846,6 +9061,7 @@ mod tests { "pub async fn extract_editor_ui_design_assets", &[ "persist_editor_green_screen_source_image", + "persist_editor_green_screen_source_resource", "remove_editor_generated_screen_background_with_bgfilter", ], ); @@ -7856,6 +9072,7 @@ mod tests { &[ "persist_editor_green_screen_source_image", "remove_editor_generated_screen_background_with_bgfilter", + "persist_editor_green_screen_source_resource", "slice_generated_icon_spritesheet_all_by_connected_components", ], ); @@ -7865,6 +9082,7 @@ mod tests { "pub(crate) fn editor_project_payload_from_record", &[ "persist_editor_green_screen_source_image", + "persist_editor_green_screen_source_resource", "remove_editor_generated_screen_background_with_bgfilter", ], ); @@ -7973,6 +9191,51 @@ mod tests { ); } + #[test] + fn editor_paid_image_postprocess_keeps_provider_outputs_recoverable() { + let source = include_str!("editor_project.rs"); + assert_function_contains( + source, + "pub(crate) async fn generate_editor_image_for_owner", + "fn normalize_editor_image_generation_size", + &["EDITOR_ASSET_DEFAULT_FOLDER_ID"], + ); + assert_function_contains_in_order( + source, + "pub(crate) async fn edit_editor_image_for_owner", + "pub async fn generate_editor_icon_spritesheet", + &[ + "let provider_image", + "EDITOR_IMAGE_EDIT_SOURCE_ASSET_KIND", + "persist_editor_generated_asset", + "restore_editor_image_edit_output_dimensions", + ], + ); + assert_function_contains_in_order( + source, + "pub(crate) async fn extract_editor_ui_design_assets_for_owner", + "pub(crate) fn editor_project_payload_from_record", + &[ + "persist_editor_green_screen_source_resource", + "remove_editor_generated_screen_background_with_bgfilter", + "EDITOR_UI_DESIGN_SPRITESHEET_ASSET_KIND", + "persist_editor_generated_asset", + "slice_generated_icon_spritesheet_all_by_connected_components", + "persist_editor_spritesheet_slices", + ], + ); + assert_function_contains( + source, + "pub(crate) async fn extract_editor_ui_design_assets_for_owner", + "pub(crate) fn editor_project_payload_from_record", + &[ + "EDITOR_ASSET_DEFAULT_FOLDER_ID", + "EDITOR_ICON_SPRITESHEET_SLICE_WARNING_COMPONENTS", + "EDITOR_ICON_SPRITESHEET_SLICE_WARNING_PERSISTENCE", + ], + ); + } + #[test] fn generated_asset_folder_id_normalizes_project_and_legacy_local_folder() { assert_eq!( diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index eb5ac1a69..ee6628de6 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -875,6 +875,11 @@ mod tests { .get("/api/external/v1/editor/icon-spritesheets/generations") .is_some() ); + assert_eq!( + parsed["components"]["schemas"]["EditorIconSpritesheetGenerationResponse"]["properties"] + ["sliceWarning"]["anyOf"][0]["$ref"], + "#/components/schemas/EditorIconSpritesheetSliceWarning" + ); assert!( parsed["paths"] .get("/api/external/v1/editor/ui-designs/assets/extractions") diff --git a/server-rs/crates/api-server/src/external_generation.rs b/server-rs/crates/api-server/src/external_generation.rs index 5d3bacc4b..def534cf9 100644 --- a/server-rs/crates/api-server/src/external_generation.rs +++ b/server-rs/crates/api-server/src/external_generation.rs @@ -7,11 +7,11 @@ use axum::{ use serde::Deserialize; use serde_json::json; use shared_contracts::external_generation::{ - ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord, - ExternalGenerationJobStatusResponse, ExternalGenerationQueueOverview, - ExternalGenerationQueueOverviewResponse, ExternalGenerationTaskAcknowledgeRequest, - ExternalGenerationTaskAcknowledgeResponse, ExternalGenerationTaskListResponse, - ExternalGenerationTaskRecord, + ExternalGenerationJobStatus, ExternalGenerationJobStatusDetailRecord, + ExternalGenerationJobStatusRecord, ExternalGenerationJobStatusResponse, + ExternalGenerationQueueOverview, ExternalGenerationQueueOverviewResponse, + ExternalGenerationTaskAcknowledgeRequest, ExternalGenerationTaskAcknowledgeResponse, + ExternalGenerationTaskListResponse, ExternalGenerationTaskRecord, }; use spacetime_client::{ ExternalGenerationJobAcknowledgeRecordInput, ExternalGenerationJobGetRecordInput, @@ -146,7 +146,7 @@ pub async fn get_external_generation_job_status( Ok(json_success_body( Some(&request_context), ExternalGenerationJobStatusResponse { - job: map_external_generation_job_status(job), + job: map_external_generation_job_status_detail(job), }, )) } @@ -165,11 +165,31 @@ fn map_external_generation_queue_overview( fn map_external_generation_job_status( job: ExternalGenerationJobSummaryRecord, ) -> ExternalGenerationJobStatusRecord { - let (status, phase_detail, progress) = match job.status.as_str() { - "completed" => (ExternalGenerationJobStatus::Completed, "生成已完成。", 100), - "running" => (ExternalGenerationJobStatus::Running, "正在生成。", 35), - "failed" => (ExternalGenerationJobStatus::Failed, "生成失败。", 0), - _ => (ExternalGenerationJobStatus::Queued, "排队中。", 8), + let (status, phase_detail, progress, error) = match job.status.as_str() { + "completed" => ( + ExternalGenerationJobStatus::Completed, + "生成已完成。", + 100, + None, + ), + "running" => ( + ExternalGenerationJobStatus::Running, + "正在生成。", + 35, + None, + ), + "failed" => ( + ExternalGenerationJobStatus::Failed, + "生成失败。", + 0, + job.last_error_message.clone(), + ), + _ => ( + ExternalGenerationJobStatus::Queued, + "排队中。", + 8, + None, + ), }; ExternalGenerationJobStatusRecord { @@ -178,11 +198,21 @@ fn map_external_generation_job_status( phase_label: job.request_label.clone(), phase_detail: phase_detail.to_string(), progress, - error: job.last_error_message.clone(), + error, updated_at_micros: job.updated_at_micros, } } +fn map_external_generation_job_status_detail( + job: ExternalGenerationJobSummaryRecord, +) -> ExternalGenerationJobStatusDetailRecord { + let warning = job.warning_message.clone(); + ExternalGenerationJobStatusDetailRecord { + status: map_external_generation_job_status(job), + warning, + } +} + fn map_external_generation_task_record( job: ExternalGenerationJobSummaryRecord, ) -> ExternalGenerationTaskRecord { @@ -199,6 +229,7 @@ fn map_external_generation_task_record( phase_detail: status_record.phase_detail, progress: status_record.progress, error: status_record.error, + warning: job.warning_message, price_mud_points: job.price_mud_points, refund_ledger_id: job.refund_ledger_id, notification_acknowledged_at: job.notification_acknowledged_at, @@ -274,7 +305,7 @@ mod tests { #[test] fn maps_task_from_payload_free_summary_projection() { - let task = map_external_generation_task_record(ExternalGenerationJobSummaryRecord { + let summary = ExternalGenerationJobSummaryRecord { job_id: "task-1".to_string(), job_kind: "editor_image_generation".to_string(), owner_user_id: "user-1".to_string(), @@ -283,7 +314,7 @@ mod tests { request_label: "图片生成".to_string(), request_prompt: Some("发光主视觉".to_string()), status: "completed".to_string(), - last_error_message: None, + last_error_message: Some("上一次尝试失败".to_string()), created_at: "2026-07-10T08:00:00Z".to_string(), started_at: Some("2026-07-10T08:00:01Z".to_string()), completed_at: Some("2026-07-10T08:00:10Z".to_string()), @@ -293,9 +324,16 @@ mod tests { refund_ledger_id: None, notification_acknowledged_at: None, notification_acknowledged_at_micros: None, - }); + warning_message: Some("连通域数量不足".to_string()), + }; + let status = map_external_generation_job_status_detail(summary.clone()); + let task = map_external_generation_task_record(summary); + assert_eq!(status.warning.as_deref(), Some("连通域数量不足")); + assert!(status.status.error.is_none()); assert_eq!(task.request_prompt.as_deref(), Some("发光主视觉")); assert_eq!(task.status, ExternalGenerationJobStatus::Completed); + assert_eq!(task.warning.as_deref(), Some("连通域数量不足")); + assert!(task.error.is_none()); } } diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs index e8187f5c8..f7e4f8f21 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -1,7 +1,7 @@ use std::{future::Future, io, pin::Pin, time::Duration}; use axum::{Json, extract::FromRef}; -use serde_json::json; +use serde_json::{Value, json}; use shared_kernel::offset_datetime_to_unix_micros; use spacetime_client::{ ExternalGenerationJobClaimRecordInput, ExternalGenerationJobCompleteRecordInput, @@ -14,6 +14,10 @@ use tokio::{ }; use tracing::{error, info, warn}; +const MAX_EDITOR_GENERATION_WARNING_CHARS: usize = 2_048; +const EDITOR_GENERATION_WARNING_REDACTED_MESSAGE: &str = + "自动拆分未完成(告警详情含内联媒体引用,已省略)"; + use crate::{ asset_billing::with_external_generation_billing_attempt_context, character_animation_assets::{ @@ -762,8 +766,14 @@ async fn process_external_generation_job_once( ) .await { - Ok(result) => { - complete_editor_generation_job(&state, &worker_id, &job, result.0).await + Ok(response) => { + complete_editor_generation_job_with_response( + &state, + &worker_id, + &job, + &response.0, + ) + .await } Err(error) => { let message = error.body_text(); @@ -792,8 +802,14 @@ async fn process_external_generation_job_once( ) .await { - Ok(result) => { - complete_editor_generation_job(&state, &worker_id, &job, result.0).await + Ok(response) => { + complete_editor_generation_job_with_response( + &state, + &worker_id, + &job, + &response.0, + ) + .await } Err(error) => { let message = error.body_text(); @@ -1062,6 +1078,68 @@ fn compact_editor_generation_result(mut result: serde_json::Value) -> serde_json result } +async fn complete_editor_generation_job_with_response( + state: &AppState, + worker_id: &str, + job: &ExternalGenerationJobRecord, + response: &Value, +) -> Result<(), String> { + complete_job( + state, + worker_id, + job, + Some(editor_generation_result_payload_json(job, response)), + ) + .await +} + +fn editor_generation_result_payload_json( + job: &ExternalGenerationJobRecord, + response: &Value, +) -> String { + let mut payload = json!({ + "sourceModule": job.source_module.clone(), + "sourceEntityId": job.source_entity_id.clone(), + }); + if let Some(warning) = extract_editor_generation_slice_warning(response) + && let Some(object) = payload.as_object_mut() + { + object.insert("warning".to_string(), warning); + } + payload.to_string() +} + +fn extract_editor_generation_slice_warning(response: &Value) -> Option { + let data = response.get("data").unwrap_or(response); + let warning = data.get("sliceWarning")?; + let code = warning.get("code")?.as_str()?.trim(); + let reason = warning.get("reason")?.as_str()?.trim(); + if code.is_empty() || reason.is_empty() { + return None; + } + let reason = normalize_editor_generation_warning_reason(reason); + Some(json!({ + "code": code, + "reason": reason, + })) +} + +fn normalize_editor_generation_warning_reason(reason: &str) -> String { + let normalized = reason.to_ascii_lowercase(); + if normalized.contains("data:") || normalized.contains("blob:") { + return EDITOR_GENERATION_WARNING_REDACTED_MESSAGE.to_string(); + } + let mut chars = reason.chars(); + let mut bounded = chars + .by_ref() + .take(MAX_EDITOR_GENERATION_WARNING_CHARS) + .collect::(); + if chars.next().is_some() { + bounded.push('…'); + } + bounded +} + async fn complete_job( state: &AppState, worker_id: &str, @@ -1289,6 +1367,58 @@ mod tests { assert_eq!(message, "VECTOR_ENGINE_API_KEY 未配置"); } + #[test] + fn editor_generation_result_payload_keeps_only_lightweight_slice_warning() { + let job = external_generation_job_record_fixture(Some("lease-1")); + let response = json!({ + "spritesheetImageSrc": "data:image/png;base64,SHOULD_NOT_PERSIST", + "iconImageSrcs": [{"imageSrc": "data:image/png;base64,SHOULD_NOT_PERSIST"}], + "sliceWarning": { + "code": "insufficient-connected-components", + "reason": "连通域数量不足" + } + }); + + let payload: Value = + serde_json::from_str(&editor_generation_result_payload_json(&job, &response)) + .expect("worker 结果应是合法 JSON"); + + assert_eq!(payload["sourceModule"], json!("puzzle")); + assert_eq!(payload["sourceEntityId"], json!("session-1:puzzle-level-1")); + assert_eq!( + payload["warning"], + json!({ + "code": "insufficient-connected-components", + "reason": "连通域数量不足" + }) + ); + assert!(payload.get("spritesheetImageSrc").is_none()); + assert!(payload.get("iconImageSrcs").is_none()); + } + + #[test] + fn editor_generation_result_payload_accepts_envelope_and_redacts_inline_media() { + let job = external_generation_job_record_fixture(Some("lease-1")); + let response = json!({ + "data": { + "sliceWarning": { + "code": "slice-persistence-failed", + "reason": "provider returned data:image/png;base64,AAAA" + } + } + }); + + let payload: Value = + serde_json::from_str(&editor_generation_result_payload_json(&job, &response)) + .expect("worker 结果应是合法 JSON"); + + assert_eq!( + payload["warning"]["reason"], + json!(EDITOR_GENERATION_WARNING_REDACTED_MESSAGE) + ); + assert!(!payload.to_string().contains("data:image")); + } + #[test] fn worker_job_timeout_uses_long_budget_for_video_jobs() { let config = AppConfig { diff --git a/server-rs/crates/api-server/src/modules/editor_project.rs b/server-rs/crates/api-server/src/modules/editor_project.rs index 801fe0eb7..441fa999b 100644 --- a/server-rs/crates/api-server/src/modules/editor_project.rs +++ b/server-rs/crates/api-server/src/modules/editor_project.rs @@ -21,8 +21,9 @@ use crate::{ get_editor_generation_pricing, get_editor_project, list_editor_projects, list_public_editor_project_resources, load_recent_editor_project, remove_editor_image_background, rename_editor_project, save_editor_project_layout, - submit_editor_asset_showcase, toggle_editor_showcase_asset_like, update_editor_asset, - update_editor_asset_folder, update_editor_project_resource_showcase, + split_editor_icon_spritesheet, submit_editor_asset_showcase, + toggle_editor_showcase_asset_like, update_editor_asset, update_editor_asset_folder, + update_editor_project_resource_showcase, }, state::AppState, }; @@ -226,6 +227,13 @@ pub fn router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/editor/icon-spritesheets/slices", + post(split_editor_icon_spritesheet).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/editor/ui-designs/assets/extractions", post(extract_editor_ui_design_assets) diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 726d0f533..0d936e15e 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -1127,6 +1127,12 @@ pub async fn admin_upsert_profile_redeem_code( AppError::from_status(StatusCode::BAD_REQUEST).with_message(error), ) })?; + let starts_at_micros = + parse_admin_profile_code_time_field("兑换码", "startsAt", payload.starts_at) + .map_err(|error| runtime_profile_error_response(&request_context, error))?; + let expires_at_micros = + parse_admin_profile_code_time_field("兑换码", "expiresAt", payload.expires_at) + .map_err(|error| runtime_profile_error_response(&request_context, error))?; let record = state .spacetime_client() .admin_upsert_profile_redeem_code( @@ -1138,6 +1144,8 @@ pub async fn admin_upsert_profile_redeem_code( payload.enabled, payload.allowed_user_ids, payload.allowed_public_user_codes, + starts_at_micros, + expires_at_micros, updated_at_micros as i64, ) .await @@ -1223,10 +1231,12 @@ pub async fn admin_upsert_profile_invite_code( ) -> Result, Response> { let metadata_json = normalize_admin_invite_code_metadata(payload.metadata) .map_err(|error| runtime_profile_error_response(&request_context, error))?; - let starts_at_micros = parse_admin_invite_code_time_field("startsAt", payload.starts_at) - .map_err(|error| runtime_profile_error_response(&request_context, error))?; - let expires_at_micros = parse_admin_invite_code_time_field("expiresAt", payload.expires_at) - .map_err(|error| runtime_profile_error_response(&request_context, error))?; + let starts_at_micros = + parse_admin_profile_code_time_field("邀请码", "startsAt", payload.starts_at) + .map_err(|error| runtime_profile_error_response(&request_context, error))?; + let expires_at_micros = + parse_admin_profile_code_time_field("邀请码", "expiresAt", payload.expires_at) + .map_err(|error| runtime_profile_error_response(&request_context, error))?; let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; let record = state .spacetime_client() @@ -1294,15 +1304,40 @@ pub async fn get_profile_play_stats( } fn map_runtime_profile_client_error(error: SpacetimeClientError) -> AppError { - let (status, provider) = match error { + let is_redeem_code_domain_error = matches!( + &error, + SpacetimeClientError::Procedure(message) + if is_runtime_profile_redeem_code_domain_error(message) + ); + let (status, provider) = match &error { SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "runtime-profile"), + SpacetimeClientError::Procedure(_) if is_redeem_code_domain_error => { + (StatusCode::BAD_REQUEST, "runtime-profile") + } _ => (StatusCode::BAD_GATEWAY, "spacetimedb"), }; - AppError::from_status(status).with_details(json!({ + let app_error = AppError::from_status(status).with_details(json!({ "provider": provider, "message": error.to_string(), - })) + })); + if is_redeem_code_domain_error { + return app_error.with_message(error.to_string()); + } + app_error +} + +fn is_runtime_profile_redeem_code_domain_error(message: &str) -> bool { + matches!( + message, + "兑换码不存在" + | "兑换码已停用" + | "兑换码未生效" + | "兑换码已过期" + | "兑换次数已用完" + | "该兑换码不适用于当前账号" + | "兑换码奖励无效" + ) } fn runtime_profile_error_response(request_context: &RequestContext, error: AppError) -> Response { @@ -2112,7 +2147,8 @@ fn normalize_admin_invite_code_metadata(metadata: Option) -> Result, ) -> Result, AppError> { @@ -2126,7 +2162,7 @@ fn parse_admin_invite_code_time_field( let parsed = parse_rfc3339(value).map_err(|error| { AppError::from_status(StatusCode::BAD_REQUEST) - .with_message(format!("邀请码 {field} 必须是 RFC3339 时间字符串")) + .with_message(format!("{code_label} {field} 必须是 RFC3339 时间字符串")) .with_details(json!({ "field": field, "message": error })) })?; @@ -2271,6 +2307,8 @@ fn build_profile_redeem_code_admin_response( created_by: record.created_by, created_at: record.created_at, updated_at: record.updated_at, + starts_at: record.starts_at, + expires_at: record.expires_at, } } @@ -2305,9 +2343,67 @@ mod tests { calc_wechat_virtual_payment_user_signature_with_key, format_profile_wallet_ledger_source_type, is_wechat_profile_recharge_order_terminal_for_confirmation, - normalize_admin_invite_code_metadata, should_notify_virtual_payment_goods_delivery, + map_runtime_profile_client_error, normalize_admin_invite_code_metadata, + parse_admin_profile_code_time_field, should_notify_virtual_payment_goods_delivery, }; + #[test] + fn redeem_code_domain_errors_map_to_client_errors_without_hiding_infrastructure_failures() { + for message in ["兑换码未生效", "兑换码已过期"] { + let error = map_runtime_profile_client_error(SpacetimeClientError::Procedure( + message.to_string(), + )); + + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + assert_eq!(error.code(), "BAD_REQUEST"); + assert_eq!(error.message(), message); + assert_eq!( + error + .details() + .and_then(|details| details.get("provider")) + .and_then(Value::as_str), + Some("runtime-profile") + ); + } + + let infrastructure_error = map_runtime_profile_client_error( + SpacetimeClientError::Procedure("No such procedure: redeem_profile_reward_code".into()), + ); + assert_eq!(infrastructure_error.status_code(), StatusCode::BAD_GATEWAY); + assert_eq!( + infrastructure_error + .details() + .and_then(|details| details.get("provider")) + .and_then(Value::as_str), + Some("spacetimedb") + ); + } + + #[test] + fn admin_profile_code_time_parser_accepts_optional_rfc3339_values() { + assert_eq!( + parse_admin_profile_code_time_field("兑换码", "startsAt", None) + .expect("missing boundary should pass"), + None + ); + assert_eq!( + parse_admin_profile_code_time_field( + "兑换码", + "startsAt", + Some("1970-01-01T00:00:01Z".to_string()), + ) + .expect("RFC3339 boundary should pass"), + Some(1_000_000) + ); + let error = parse_admin_profile_code_time_field( + "兑换码", + "startsAt", + Some("2026-07-13 00:00:00".to_string()), + ) + .expect_err("non-RFC3339 boundary should fail"); + assert!(error.message().contains("兑换码 startsAt")); + } + use axum::{ body::Body, http::{Request, StatusCode}, @@ -2317,6 +2413,7 @@ mod tests { AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token, }; use serde_json::Value; + use spacetime_client::SpacetimeClientError; use std::time::Duration; use time::OffsetDateTime; use tower::ServiceExt; diff --git a/server-rs/crates/module-assets/src/application.rs b/server-rs/crates/module-assets/src/application.rs index bad6ddbea..7b3f8fdb7 100644 --- a/server-rs/crates/module-assets/src/application.rs +++ b/server-rs/crates/module-assets/src/application.rs @@ -19,6 +19,15 @@ pub struct AssetObjectProcedureResult { pub error_message: Option, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AssetObjectReadAccessProcedureResult { + pub ok: bool, + pub record: Option, + pub public_work_granted: bool, + pub error_message: Option, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct AssetHistoryListResult { diff --git a/server-rs/crates/module-assets/src/commands.rs b/server-rs/crates/module-assets/src/commands.rs index 0c3443f3b..5f58f676c 100644 --- a/server-rs/crates/module-assets/src/commands.rs +++ b/server-rs/crates/module-assets/src/commands.rs @@ -32,6 +32,13 @@ pub struct AssetHistoryListInput { pub limit: u32, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AssetObjectLocationInput { + pub bucket: String, + pub object_key: String, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct AssetObjectUpsertInput { diff --git a/server-rs/crates/module-assets/src/domain.rs b/server-rs/crates/module-assets/src/domain.rs index fba91cc0f..209943f20 100644 --- a/server-rs/crates/module-assets/src/domain.rs +++ b/server-rs/crates/module-assets/src/domain.rs @@ -99,6 +99,73 @@ pub struct AssetObjectRecord { pub updated_at: String, } +/// 已发布且可见作品派生出的精确资产读取授权。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PublicAssetReadGrant { + pub owner_user_id: String, + pub asset_object_id: Option, + pub object_key: Option, +} + +/// 为公开资产授权生成可由订阅缓存点查的 owner-scoped 主键。 +pub fn public_asset_read_grant_id(grant: &PublicAssetReadGrant) -> Option { + let owner_user_id = grant.owner_user_id.trim(); + if owner_user_id.is_empty() { + return None; + } + let owner_prefix = format!("owner:{}:{owner_user_id}", owner_user_id.len()); + + if let Some(asset_object_id) = grant + .asset_object_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Some(format!("{owner_prefix}|asset-object-id:{asset_object_id}")); + } + + grant + .object_key + .as_deref() + .map(str::trim) + .map(|value| value.trim_start_matches('/')) + .filter(|value| !value.is_empty()) + .map(|object_key| format!("{owner_prefix}|object-key:{object_key}")) +} + +/// 判断资产对象是否命中同 owner 公开作品派生出的精确授权。 +pub fn asset_object_matches_public_read_grant( + asset_object: &AssetObjectRecord, + grant: &PublicAssetReadGrant, +) -> bool { + let Some(asset_owner_user_id) = asset_object + .owner_user_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return false; + }; + let grant_owner_user_id = grant.owner_user_id.trim(); + if grant_owner_user_id.is_empty() || asset_owner_user_id != grant_owner_user_id { + return false; + } + + grant + .asset_object_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some_and(|value| value == asset_object.asset_object_id.trim()) + || grant + .object_key + .as_deref() + .map(str::trim) + .map(|value| value.trim_start_matches('/')) + .filter(|value| !value.is_empty()) + .is_some_and(|value| value == asset_object.object_key.trim().trim_start_matches('/')) +} + /// 面向 API 与前端展示的资产历史记录。 #[derive(Clone, Debug, PartialEq, Eq)] pub struct AssetHistoryEntryRecord { @@ -126,3 +193,117 @@ pub struct AssetEntityBindingRecord { pub created_at: String, pub updated_at: String, } + +#[cfg(test)] +mod tests { + use super::*; + + fn asset_object(owner_user_id: Option<&str>) -> AssetObjectRecord { + AssetObjectRecord { + asset_object_id: "assetobj_public_reference".to_string(), + bucket: "genarrative-assets".to_string(), + object_key: "generated-puzzle-assets/public-reference.png".to_string(), + access_policy: AssetObjectAccessPolicy::Private, + content_type: Some("image/png".to_string()), + content_length: 1, + content_hash: None, + version: 1, + source_job_id: None, + owner_user_id: owner_user_id.map(ToOwned::to_owned), + profile_id: None, + entity_id: Some("puzzle-session-001".to_string()), + asset_kind: "puzzle_cover_image".to_string(), + created_at: "2026-07-13T00:00:00Z".to_string(), + updated_at: "2026-07-13T00:00:00Z".to_string(), + } + } + + fn grant( + owner_user_id: &str, + asset_object_id: Option<&str>, + object_key: Option<&str>, + ) -> PublicAssetReadGrant { + PublicAssetReadGrant { + owner_user_id: owner_user_id.to_string(), + asset_object_id: asset_object_id.map(ToOwned::to_owned), + object_key: object_key.map(ToOwned::to_owned), + } + } + + #[test] + fn public_work_grant_authorizes_exact_historical_object_key() { + let asset = asset_object(Some("user-owner")); + + assert!(asset_object_matches_public_read_grant( + &asset, + &grant( + "user-owner", + None, + Some("generated-puzzle-assets/public-reference.png") + ) + )); + } + + #[test] + fn public_work_grant_authorizes_exact_asset_object_id() { + let asset = asset_object(Some("user-owner")); + + assert!(asset_object_matches_public_read_grant( + &asset, + &grant("user-owner", Some("assetobj_public_reference"), None) + )); + } + + #[test] + fn public_work_grant_rejects_cross_owner_and_unrelated_assets() { + let asset = asset_object(Some("user-owner")); + + assert!(!asset_object_matches_public_read_grant( + &asset, + &grant("user-other", Some("assetobj_public_reference"), None) + )); + assert!(!asset_object_matches_public_read_grant( + &asset, + &grant("user-owner", Some("assetobj_other"), None) + )); + assert!(!asset_object_matches_public_read_grant( + &asset_object(None), + &grant("user-owner", Some("assetobj_public_reference"), None) + )); + } + + #[test] + fn public_work_grant_id_is_owner_scoped_and_normalizes_object_key() { + assert_eq!( + public_asset_read_grant_id(&grant( + "user-owner", + Some("assetobj_public_reference"), + None + )), + Some("owner:10:user-owner|asset-object-id:assetobj_public_reference".to_string()) + ); + assert_eq!( + public_asset_read_grant_id(&grant( + "user-owner", + None, + Some("/generated-puzzle-assets/public-reference.png") + )), + Some( + "owner:10:user-owner|object-key:generated-puzzle-assets/public-reference.png" + .to_string() + ) + ); + assert_ne!( + public_asset_read_grant_id(&grant( + "user-owner", + Some("assetobj_public_reference"), + None + )), + public_asset_read_grant_id(&grant( + "user-other", + Some("assetobj_public_reference"), + None + )) + ); + } +} diff --git a/server-rs/crates/module-assets/src/lib.rs b/server-rs/crates/module-assets/src/lib.rs index 3c2e1ac27..410967b8f 100644 --- a/server-rs/crates/module-assets/src/lib.rs +++ b/server-rs/crates/module-assets/src/lib.rs @@ -10,20 +10,23 @@ mod asset_object_service; pub use application::{ AssetEntityBindingProcedureResult, AssetHistoryListResult, AssetObjectProcedureResult, - ConfirmAssetObjectResult, build_asset_entity_binding_input, build_asset_object_upsert_input, + AssetObjectReadAccessProcedureResult, ConfirmAssetObjectResult, + build_asset_entity_binding_input, build_asset_object_upsert_input, }; #[cfg(feature = "server-service")] pub use asset_object_service::{ AssetObjectService, ConfirmAssetObjectError, InMemoryAssetObjectStore, }; pub use commands::{ - AssetEntityBindingInput, AssetHistoryListInput, AssetObjectUpsertInput, ConfirmAssetObjectInput, + AssetEntityBindingInput, AssetHistoryListInput, AssetObjectLocationInput, + AssetObjectUpsertInput, ConfirmAssetObjectInput, }; pub use domain::{ ASSET_BINDING_ID_PREFIX, ASSET_OBJECT_ID_PREFIX, AssetEntityBindingRecord, AssetEntityBindingSnapshot, AssetHistoryEntryRecord, AssetHistoryEntrySnapshot, AssetObjectAccessPolicy, AssetObjectRecord, AssetObjectUpsertSnapshot, - INITIAL_ASSET_OBJECT_VERSION, + INITIAL_ASSET_OBJECT_VERSION, PublicAssetReadGrant, asset_object_matches_public_read_grant, + public_asset_read_grant_id, }; pub use errors::AssetObjectFieldError; pub use events::{AssetDomainEvent, AssetEntityBindingChangedEvent, AssetObjectConfirmedEvent}; diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index a123970e4..fc804bc82 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -1723,6 +1723,10 @@ pub fn build_runtime_profile_redeem_code_record( created_at_micros: snapshot.created_at_micros, updated_at: format_utc_micros(snapshot.updated_at_micros), updated_at_micros: snapshot.updated_at_micros, + starts_at: snapshot.starts_at_micros.map(format_utc_micros), + starts_at_micros: snapshot.starts_at_micros, + expires_at: snapshot.expires_at_micros.map(format_utc_micros), + expires_at_micros: snapshot.expires_at_micros, } } @@ -2040,6 +2044,7 @@ pub fn validate_runtime_profile_redeem_code_usage( code: &RuntimeProfileRedeemCodeSnapshot, user_id: &str, user_used_count: u32, + redeemed_at_micros: i64, ) -> Result<(), RuntimeProfileFieldError> { if !code.enabled { return Err(RuntimeProfileFieldError::RedeemCodeDisabled); @@ -2047,6 +2052,11 @@ pub fn validate_runtime_profile_redeem_code_usage( if code.reward_points == 0 { return Err(RuntimeProfileFieldError::InvalidRedeemCodeReward); } + crate::commands::validate_runtime_profile_redeem_code_redeem_time( + code.starts_at_micros, + code.expires_at_micros, + redeemed_at_micros, + )?; match code.mode { RuntimeProfileRedeemCodeMode::Public if user_used_count >= code.max_uses => { diff --git a/server-rs/crates/module-runtime/src/commands.rs b/server-rs/crates/module-runtime/src/commands.rs index d5d866d88..6de8a1c63 100644 --- a/server-rs/crates/module-runtime/src/commands.rs +++ b/server-rs/crates/module-runtime/src/commands.rs @@ -593,6 +593,8 @@ pub fn build_runtime_profile_redeem_code_admin_upsert_input( enabled: bool, allowed_user_ids: Vec, allowed_public_user_codes: Vec, + starts_at_micros: Option, + expires_at_micros: Option, updated_at_micros: i64, ) -> Result { let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?; @@ -603,6 +605,7 @@ pub fn build_runtime_profile_redeem_code_admin_upsert_input( if max_uses == 0 { return Err(RuntimeProfileFieldError::InvalidRedeemCodeMaxUses); } + validate_runtime_profile_redeem_code_validity_window(starts_at_micros, expires_at_micros)?; Ok(RuntimeProfileRedeemCodeAdminUpsertInput { admin_user_id, @@ -619,6 +622,8 @@ pub fn build_runtime_profile_redeem_code_admin_upsert_input( .into_iter() .filter_map(|value| normalize_optional_string(Some(value))) .collect(), + starts_at_micros, + expires_at_micros, updated_at_micros, }) } @@ -1047,14 +1052,53 @@ pub fn validate_runtime_profile_invite_code_validity_window( starts_at_micros: Option, expires_at_micros: Option, ) -> Result<(), RuntimeProfileFieldError> { - if matches!((starts_at_micros, expires_at_micros), (Some(starts_at), Some(expires_at)) if starts_at >= expires_at) - { + if is_invalid_runtime_profile_code_validity_window(starts_at_micros, expires_at_micros) { return Err(RuntimeProfileFieldError::InvalidInviteCodeValidityWindow); } Ok(()) } +pub fn validate_runtime_profile_redeem_code_validity_window( + starts_at_micros: Option, + expires_at_micros: Option, +) -> Result<(), RuntimeProfileFieldError> { + if is_invalid_runtime_profile_code_validity_window(starts_at_micros, expires_at_micros) { + return Err(RuntimeProfileFieldError::InvalidRedeemCodeValidityWindow); + } + + Ok(()) +} + +pub fn validate_runtime_profile_redeem_code_redeem_time( + starts_at_micros: Option, + expires_at_micros: Option, + now_micros: i64, +) -> Result<(), RuntimeProfileFieldError> { + if starts_at_micros + .map(|starts_at| now_micros < starts_at) + .unwrap_or(false) + { + return Err(RuntimeProfileFieldError::RedeemCodeNotStarted); + } + + if expires_at_micros + .map(|expires_at| now_micros >= expires_at) + .unwrap_or(false) + { + return Err(RuntimeProfileFieldError::RedeemCodeExpired); + } + + Ok(()) +} + +fn is_invalid_runtime_profile_code_validity_window( + starts_at_micros: Option, + expires_at_micros: Option, +) -> bool { + matches!((starts_at_micros, expires_at_micros), (Some(starts_at), Some(expires_at)) if starts_at >= expires_at) +} + pub fn resolve_runtime_profile_invite_code_status( starts_at_micros: Option, expires_at_micros: Option, diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 215b80ea1..5be01a58a 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -1575,6 +1575,8 @@ pub struct RuntimeProfileRedeemCodeAdminUpsertInput { pub enabled: bool, pub allowed_user_ids: Vec, pub allowed_public_user_codes: Vec, + pub starts_at_micros: Option, + pub expires_at_micros: Option, pub updated_at_micros: i64, } @@ -1605,6 +1607,8 @@ pub struct RuntimeProfileRedeemCodeSnapshot { pub created_by: String, pub created_at_micros: i64, pub updated_at_micros: i64, + pub starts_at_micros: Option, + pub expires_at_micros: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -2098,6 +2102,10 @@ pub struct RuntimeProfileRedeemCodeRecord { pub created_at_micros: i64, pub updated_at: String, pub updated_at_micros: i64, + pub starts_at: Option, + pub starts_at_micros: Option, + pub expires_at: Option, + pub expires_at_micros: Option, } #[derive(Clone, Debug, PartialEq)] diff --git a/server-rs/crates/module-runtime/src/errors.rs b/server-rs/crates/module-runtime/src/errors.rs index c98e43eb5..695f7f304 100644 --- a/server-rs/crates/module-runtime/src/errors.rs +++ b/server-rs/crates/module-runtime/src/errors.rs @@ -58,6 +58,9 @@ pub enum RuntimeProfileFieldError { RedeemCodeNotAllowedForUser, InvalidRedeemCodeReward, InvalidRedeemCodeMaxUses, + InvalidRedeemCodeValidityWindow, + RedeemCodeNotStarted, + RedeemCodeExpired, InvalidInviteCodeMetadata, InvalidUserTag, InvalidInviteCodeValidityWindow, @@ -130,6 +133,9 @@ impl std::fmt::Display for RuntimeProfileFieldError { Self::RedeemCodeNotAllowedForUser => f.write_str("该兑换码不适用于当前账号"), Self::InvalidRedeemCodeReward => f.write_str("兑换码奖励无效"), Self::InvalidRedeemCodeMaxUses => f.write_str("兑换次数必须大于 0"), + Self::InvalidRedeemCodeValidityWindow => f.write_str("兑换码开始时间不能晚于截止时间"), + Self::RedeemCodeNotStarted => f.write_str("兑换码未生效"), + Self::RedeemCodeExpired => f.write_str("兑换码已过期"), Self::InvalidInviteCodeMetadata => { f.write_str("邀请码 metadata 必须是合法 JSON object") } diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 409bf2356..2def15c25 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -1265,12 +1265,14 @@ mod tests { created_by: "admin".to_string(), created_at_micros: 1, updated_at_micros: 1, + starts_at_micros: None, + expires_at_micros: None, }; - validate_runtime_profile_redeem_code_usage(&base, "user-1", 1) + validate_runtime_profile_redeem_code_usage(&base, "user-1", 1, 1) .expect("public code under per-user limit should pass"); assert_eq!( - validate_runtime_profile_redeem_code_usage(&base, "user-1", 2) + validate_runtime_profile_redeem_code_usage(&base, "user-1", 2, 1) .expect_err("public code over per-user limit should fail"), RuntimeProfileFieldError::RedeemCodeUsesExhausted ); @@ -1280,10 +1282,10 @@ mod tests { global_used_count: 9, ..base.clone() }; - validate_runtime_profile_redeem_code_usage(&unique, "user-1", 0) + validate_runtime_profile_redeem_code_usage(&unique, "user-1", 0, 1) .expect("unique code should allow a user that has not redeemed it"); assert_eq!( - validate_runtime_profile_redeem_code_usage(&unique, "user-1", 1) + validate_runtime_profile_redeem_code_usage(&unique, "user-1", 1, 1) .expect_err("unique code should reject the same user twice"), RuntimeProfileFieldError::RedeemCodeUsesExhausted ); @@ -1295,14 +1297,14 @@ mod tests { ..base.clone() }; assert_eq!( - validate_runtime_profile_redeem_code_usage(&private, "user-1", 0) + validate_runtime_profile_redeem_code_usage(&private, "user-1", 0, 1) .expect_err("private code should check allow list"), RuntimeProfileFieldError::RedeemCodeNotAllowedForUser ); - validate_runtime_profile_redeem_code_usage(&private, "user-2", 0) + validate_runtime_profile_redeem_code_usage(&private, "user-2", 0, 1) .expect("private code should allow an allowed user that has not redeemed it"); assert_eq!( - validate_runtime_profile_redeem_code_usage(&private, "user-2", 1) + validate_runtime_profile_redeem_code_usage(&private, "user-2", 1, 1) .expect_err("private code should reject the same allowed user twice"), RuntimeProfileFieldError::RedeemCodeUsesExhausted ); @@ -1312,7 +1314,7 @@ mod tests { ..base }; assert_eq!( - validate_runtime_profile_redeem_code_usage(&disabled, "user-1", 0) + validate_runtime_profile_redeem_code_usage(&disabled, "user-1", 0, 1) .expect_err("disabled code should fail"), RuntimeProfileFieldError::RedeemCodeDisabled ); diff --git a/server-rs/crates/module-runtime/tests/redeem_code_validity.rs b/server-rs/crates/module-runtime/tests/redeem_code_validity.rs new file mode 100644 index 000000000..a430e90ce --- /dev/null +++ b/server-rs/crates/module-runtime/tests/redeem_code_validity.rs @@ -0,0 +1,89 @@ +use module_runtime::{ + RuntimeProfileFieldError, RuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeSnapshot, + build_runtime_profile_redeem_code_admin_upsert_input, build_runtime_profile_redeem_code_record, + validate_runtime_profile_redeem_code_usage, + validate_runtime_profile_redeem_code_validity_window, +}; + +#[test] +fn redeem_code_validity_window_allows_open_ended_and_requires_strict_order() { + assert!(validate_runtime_profile_redeem_code_validity_window(None, None).is_ok()); + assert!(validate_runtime_profile_redeem_code_validity_window(Some(10), None).is_ok()); + assert!(validate_runtime_profile_redeem_code_validity_window(None, Some(10)).is_ok()); + assert_eq!( + validate_runtime_profile_redeem_code_validity_window(Some(10), Some(10)), + Err(RuntimeProfileFieldError::InvalidRedeemCodeValidityWindow) + ); + assert_eq!( + validate_runtime_profile_redeem_code_validity_window(Some(20), Some(10)), + Err(RuntimeProfileFieldError::InvalidRedeemCodeValidityWindow) + ); +} + +#[test] +fn redeem_code_admin_upsert_carries_optional_validity_window() { + let input = build_runtime_profile_redeem_code_admin_upsert_input( + "admin-1".to_string(), + "GIFT".to_string(), + RuntimeProfileRedeemCodeMode::Public, + 30, + 1, + true, + Vec::new(), + Vec::new(), + Some(10), + Some(20), + 5, + ) + .expect("valid window should pass"); + + assert_eq!(input.starts_at_micros, Some(10)); + assert_eq!(input.expires_at_micros, Some(20)); +} + +#[test] +fn redeem_code_usage_uses_inclusive_start_and_exclusive_expire_boundary() { + let code = redeem_code_snapshot(Some(20), Some(30)); + + assert_eq!( + validate_runtime_profile_redeem_code_usage(&code, "user-1", 0, 19), + Err(RuntimeProfileFieldError::RedeemCodeNotStarted) + ); + validate_runtime_profile_redeem_code_usage(&code, "user-1", 0, 20) + .expect("start boundary should be valid"); + validate_runtime_profile_redeem_code_usage(&code, "user-1", 0, 29) + .expect("time before expire boundary should be valid"); + assert_eq!( + validate_runtime_profile_redeem_code_usage(&code, "user-1", 0, 30), + Err(RuntimeProfileFieldError::RedeemCodeExpired) + ); +} + +#[test] +fn redeem_code_record_formats_optional_validity_window() { + let record = + build_runtime_profile_redeem_code_record(redeem_code_snapshot(Some(0), Some(1_000_000))); + + assert_eq!(record.starts_at.as_deref(), Some("1970-01-01T00:00:00Z")); + assert_eq!(record.expires_at.as_deref(), Some("1970-01-01T00:00:01Z")); +} + +fn redeem_code_snapshot( + starts_at_micros: Option, + expires_at_micros: Option, +) -> RuntimeProfileRedeemCodeSnapshot { + RuntimeProfileRedeemCodeSnapshot { + code: "GIFT".to_string(), + mode: RuntimeProfileRedeemCodeMode::Public, + reward_points: 30, + max_uses: 1, + global_used_count: 0, + enabled: true, + allowed_user_ids: Vec::new(), + created_by: "admin".to_string(), + created_at_micros: 1, + updated_at_micros: 1, + starts_at_micros, + expires_at_micros, + } +} diff --git a/server-rs/crates/pingora-gateway/src/main.rs b/server-rs/crates/pingora-gateway/src/main.rs index 8c080f789..a38896340 100644 --- a/server-rs/crates/pingora-gateway/src/main.rs +++ b/server-rs/crates/pingora-gateway/src/main.rs @@ -36,6 +36,7 @@ const DEFAULT_SPACETIME_UPSTREAM: &str = "127.0.0.1:3101"; const DEFAULT_WEB_ROOT: &str = "/srv/genarrative/web"; const DEFAULT_ACME_ROOT: &str = "/var/www/html"; const DEFAULT_MAINTENANCE_FILE: &str = "/var/lib/genarrative/maintenance/enabled"; +const DEFAULT_MAINTENANCE_PAGE_FILE: &str = "/var/lib/genarrative/maintenance/page.html"; const DEFAULT_MAX_API_BODY_BYTES: u64 = 64 * 1024 * 1024; const DEFAULT_GZIP_LEVEL: u32 = 5; const DEFAULT_GZIP_MIN_LENGTH_BYTES: u64 = 1024; @@ -134,6 +135,7 @@ struct GatewayConfig { web_root: PathBuf, acme_root: PathBuf, maintenance_file: PathBuf, + maintenance_page_file: PathBuf, forwarded_proto: String, max_api_body_bytes: u64, gzip_enabled: bool, @@ -195,6 +197,10 @@ impl GatewayConfig { "GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_FILE", DEFAULT_MAINTENANCE_FILE, )), + maintenance_page_file: PathBuf::from(read_env_or_default( + "GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_PAGE_FILE", + DEFAULT_MAINTENANCE_PAGE_FILE, + )), forwarded_proto: read_env_or_default( "GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO", "http", @@ -1198,8 +1204,13 @@ impl ProxyHttp for GenarrativeGateway { let should_apply_maintenance = !internal_bypass && (is_admin_request_path(path) || ctx.route.applies_maintenance_gate()); if should_apply_maintenance { - respond_maintenance(session, ctx.route.is_api_like(), &self.config.web_root) - .await?; + respond_maintenance( + session, + ctx.route.is_api_like(), + &self.config.maintenance_page_file, + &self.config.web_root, + ) + .await?; return Ok(true); } } @@ -2083,7 +2094,10 @@ async fn serve_static( ) -> PingoraResult<()> { let path = session.req_header().uri.path(); let Some(candidate) = resolve_static_candidate(root, root_kind, path, mode).await else { - respond_not_found(session).await?; + match root_kind { + StaticRoot::Web => respond_page_not_found(session, root).await?, + StaticRoot::Acme => respond_not_found(session).await?, + } return Ok(()); }; if !is_static_read_method(&session.req_header().method) { @@ -2537,6 +2551,7 @@ async fn is_directory(path: &Path) -> bool { async fn respond_maintenance( session: &mut Session, api_like: bool, + maintenance_page_file: &Path, web_root: &Path, ) -> PingoraResult<()> { if api_like { @@ -2548,9 +2563,17 @@ async fn respond_maintenance( .await; } - let maintenance_page = web_root.join("maintenance.html"); - if is_regular_file(&maintenance_page).await { - let body = Bytes::from(tokio::fs::read(&maintenance_page).await.unwrap_or_default()); + let default_maintenance_page = web_root.join("maintenance.html"); + let maintenance_page = if is_regular_file(maintenance_page_file).await { + Some(maintenance_page_file) + } else if is_regular_file(&default_maintenance_page).await { + Some(default_maintenance_page.as_path()) + } else { + None + }; + + if let Some(maintenance_page) = maintenance_page { + let body = Bytes::from(tokio::fs::read(maintenance_page).await.unwrap_or_default()); respond_bytes(session, 503, Some("text/html; charset=utf-8"), body).await } else { respond_bytes( @@ -2613,6 +2636,33 @@ async fn respond_not_found(session: &mut Session) -> PingoraResult<()> { respond_bytes(session, 404, None, Bytes::new()).await } +async fn respond_page_not_found(session: &mut Session, web_root: &Path) -> PingoraResult<()> { + let accepts_html = session + .req_header() + .headers + .get("accept") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| { + value + .split(',') + .any(|entry| entry.trim().starts_with("text/html")) + }); + let not_found_page = web_root.join("404.html"); + if accepts_html && is_regular_file(¬_found_page).await { + let body = Bytes::from(tokio::fs::read(¬_found_page).await.unwrap_or_default()); + return respond_bytes_with_headers( + session, + 404, + Some("text/html; charset=utf-8"), + &[("Cache-Control".to_string(), "no-store".to_string())], + body, + ) + .await; + } + + respond_not_found(session).await +} + async fn respond_static_method_not_allowed(session: &mut Session) -> PingoraResult<()> { respond_head_with_headers( session, @@ -3225,6 +3275,7 @@ mod tests { web_root: PathBuf::from("/srv/genarrative/web"), acme_root: PathBuf::from("/var/www/html"), maintenance_file: PathBuf::from("/var/lib/genarrative/maintenance/enabled"), + maintenance_page_file: PathBuf::from("/var/lib/genarrative/maintenance/page.html"), forwarded_proto: "http".to_string(), max_api_body_bytes: DEFAULT_MAX_API_BODY_BYTES, gzip_enabled: true, diff --git a/server-rs/crates/platform-audio/src/client.rs b/server-rs/crates/platform-audio/src/client.rs index d993d1005..df8205746 100644 --- a/server-rs/crates/platform-audio/src/client.rs +++ b/server-rs/crates/platform-audio/src/client.rs @@ -171,40 +171,49 @@ pub async fn resolve_audio_task_download_urls( if matches!( kind, AudioTaskKind::BackgroundMusic | AudioTaskKind::SunoSoundEffect - ) && audio_urls.is_empty() - { - let clip_ids = extract_suno_clip_ids(&task_payload); + ) { + let (downloadable_audio_urls, clip_ids, has_audiopipe_url) = + resolve_suno_audio_candidates(&task_payload, audio_urls); + // Suno 查询结果还会混入 image_url 等非音频 URL。一旦存在 audiopipe, + // 不保留同一 payload 的其它直连候选,强制用 item_id 获取稳定 wav。 + audio_urls = if has_audiopipe_url { + Vec::new() + } else { + downloadable_audio_urls + }; let has_clip_ids = !clip_ids.is_empty(); let mut latest_wav_error = None; - for clip_id in clip_ids { - let wav_payload = match get_vector_engine_json( - http_client, - settings, - &format!("/suno/act/wav/{}", urlencoding::encode(clip_id.as_str())), - match kind { - AudioTaskKind::SunoSoundEffect => "获取 Suno 音效 wav 失败", - _ => "获取 Suno wav 音频失败", - }, - ) - .await - { - Ok(payload) => { - latest_wav_error = None; - payload + if should_wait_for_suno_wav_audio(&status, has_clip_ids, &audio_urls) { + for clip_id in clip_ids { + let wav_payload = match get_vector_engine_json( + http_client, + settings, + &format!("/suno/act/wav/{}", urlencoding::encode(clip_id.as_str())), + match kind { + AudioTaskKind::SunoSoundEffect => "获取 Suno 音效 wav 失败", + _ => "获取 Suno wav 音频失败", + }, + ) + .await + { + Ok(payload) => { + latest_wav_error = None; + payload + } + Err(error) => { + latest_wav_error = Some(error); + continue; + } + }; + audio_urls = extract_audio_urls(&wav_payload); + if !audio_urls.is_empty() { + break; } - Err(error) => { - latest_wav_error = Some(error); - continue; - } - }; - audio_urls = extract_audio_urls(&wav_payload); - if !audio_urls.is_empty() { - break; } } - // 中文注释:Suno 任务完成后可能先返回 clip id,/suno/act/wav/{clip_id} - // 可能返回 404/业务错误或 wav_file_url 仍为 null。此时不能把任务判成失败, - // 让上层轮询继续等待;真正失败仍交给 fetch 任务状态判断。 + // 中文注释:Suno 的 audiopipe 地址只是携带 item_id 的流式中转地址,线上可能 + // 返回 200 后不结束 chunked body。统一把 item_id 当 clip id 转走稳定 wav 地址; + // wav 地址尚未就绪或子请求暂时失败时保持 processing,让上层继续轮询。 if let Some(status) = resolve_suno_status_after_wav_probe( &status, has_clip_ids, @@ -217,6 +226,42 @@ pub async fn resolve_audio_task_download_urls( Ok((status, audio_urls)) } +fn resolve_suno_audio_candidates( + task_payload: &Value, + audio_urls: Vec, +) -> (Vec, Vec, bool) { + let mut clip_ids = extract_suno_clip_ids(task_payload); + let mut downloadable_audio_urls = Vec::new(); + let mut has_audiopipe_url = false; + for audio_url in audio_urls { + let Ok(parsed_url) = reqwest::Url::parse(audio_url.as_str()) else { + downloadable_audio_urls.push(audio_url); + continue; + }; + if !parsed_url + .host_str() + .is_some_and(|host| host.eq_ignore_ascii_case("audiopipe.suno.ai")) + { + downloadable_audio_urls.push(audio_url); + continue; + } + has_audiopipe_url = true; + + if let Some(clip_id) = parsed_url + .query_pairs() + .find(|(key, _)| key.eq_ignore_ascii_case("item_id")) + .map(|(_, value)| value.trim().to_string()) + .filter(|value| { + !value.is_empty() && value.len() <= 180 && !value.chars().any(char::is_whitespace) + }) + && !clip_ids.contains(&clip_id) + { + clip_ids.push(clip_id); + } + } + (downloadable_audio_urls, clip_ids, has_audiopipe_url) +} + fn should_wait_for_suno_wav_audio(status: &str, has_clip_ids: bool, audio_urls: &[String]) -> bool { audio_urls.is_empty() && has_clip_ids && !is_failed_task_status(status) } @@ -419,6 +464,13 @@ fn extract_upstream_payload_message(payload: &Value) -> Option { #[cfg(test)] mod tests { + use std::{ + io::{Read, Write}, + net::TcpListener, + thread, + time::Duration, + }; + use super::*; fn settings(base_url: &str) -> VectorEngineAudioSettings { @@ -527,4 +579,67 @@ mod tests { assert_eq!(status.as_deref(), Some("processing")); } + + #[test] + fn suno_audiopipe_url_uses_item_id_to_resolve_stable_wav_url() { + let listener = TcpListener::bind("127.0.0.1:0").expect("mock server should bind"); + let address = listener + .local_addr() + .expect("mock server address should be readable"); + let server = thread::spawn(move || { + let responses = [ + ( + "/suno/fetch/task-1", + r#"{"code":200,"data":{"status":"complete","audio_url":"https://audiopipe.suno.ai/?item_id=clip-1","image_url":"https://cdn.example.test/cover.jpg"}}"#, + ), + ( + "/suno/act/wav/clip-1", + r#"{"code":200,"data":{"url":"https://cdn.example.test/audio.wav"}}"#, + ), + ]; + for (expected_path, body) in responses { + let (mut stream, _) = listener.accept().expect("request should connect"); + let mut request = [0_u8; 2048]; + let size = stream + .read(&mut request) + .expect("request should be readable"); + let request = String::from_utf8_lossy(&request[..size]); + assert!( + request.starts_with(format!("GET {expected_path} HTTP/1.1").as_str()), + "unexpected request: {request}" + ); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream + .write_all(response.as_bytes()) + .expect("response should be writable"); + } + }); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + let http_client = reqwest::Client::builder() + .timeout(Duration::from_secs(2)) + .build() + .expect("http client should build"); + + let (status, audio_urls) = runtime + .block_on(resolve_audio_task_download_urls( + &http_client, + &settings(format!("http://{address}").as_str()), + AudioTaskKind::BackgroundMusic, + "task-1", + )) + .expect("audiopipe item id should resolve through the wav endpoint"); + + assert_eq!(status, "completed"); + assert_eq!( + audio_urls, + vec!["https://cdn.example.test/audio.wav".to_string()] + ); + server.join().expect("mock server should finish"); + } } diff --git a/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs b/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs index 1115dba86..4d4b46119 100644 --- a/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs +++ b/server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs @@ -212,7 +212,7 @@ fn slice_generated_icon_spritesheet_rgba_by_connected_components( icon_names.len(), auto_name_all_components, ); - components.sort_by_key(|bounds| (bounds.y0, bounds.x0)); + sort_generated_icon_components_in_visual_rows(&mut components); let icon_names = if auto_name_all_components { (1..=components.len()) .map(|index| format!("素材 {index}")) @@ -314,6 +314,76 @@ fn normalize_generated_icon_components( .collect() } +fn sort_generated_icon_components_in_visual_rows(components: &mut [GeneratedAssetSheetCellBounds]) { + if components.len() < 2 { + return; + } + + let mut component_heights = components + .iter() + .map(|bounds| bounds.height()) + .collect::>(); + component_heights.sort_unstable(); + let typical_height = component_heights[component_heights.len() / 2].max(1); + // 双倍中心坐标避免浮点;3/4 中位高度可容纳顶边不齐,同时保留正常行距。 + let row_center_tolerance_twice = typical_height.saturating_mul(3).div_ceil(2).max(8); + + components.sort_by_key(|bounds| (bounds.center_y_twice(), bounds.x0)); + let mut rows = Vec::::new(); + for bounds in components.iter().copied() { + let center_y_twice = bounds.center_y_twice(); + let closest_row = rows + .iter() + .enumerate() + .filter_map(|(index, row)| { + let distance = row.center_y_twice().abs_diff(center_y_twice); + (distance <= row_center_tolerance_twice).then_some((index, distance)) + }) + .min_by_key(|(_, distance)| *distance) + .map(|(index, _)| index); + if let Some(row_index) = closest_row { + rows[row_index].push(bounds); + } else { + rows.push(GeneratedIconVisualRow::new(bounds)); + } + } + + rows.sort_by_key(GeneratedIconVisualRow::center_y_twice); + let mut ordered = Vec::with_capacity(components.len()); + for mut row in rows { + row.components + .sort_by_key(|bounds| (bounds.x0, bounds.center_y_twice())); + ordered.extend(row.components); + } + components.copy_from_slice(&ordered); +} + +struct GeneratedIconVisualRow { + center_y_twice_sum: u64, + components: Vec, +} + +impl GeneratedIconVisualRow { + fn new(bounds: GeneratedAssetSheetCellBounds) -> Self { + Self { + center_y_twice_sum: u64::from(bounds.center_y_twice()), + components: vec![bounds], + } + } + + fn center_y_twice(&self) -> u32 { + let component_count = self.components.len().max(1) as u64; + u32::try_from(self.center_y_twice_sum / component_count).unwrap_or(u32::MAX) + } + + fn push(&mut self, bounds: GeneratedAssetSheetCellBounds) { + self.center_y_twice_sum = self + .center_y_twice_sum + .saturating_add(u64::from(bounds.center_y_twice())); + self.components.push(bounds); + } +} + fn merge_generated_icon_related_components( mut components: Vec, width: u32, @@ -621,6 +691,17 @@ mod tests { cursor.into_inner() } + fn assert_icon_contains_color(icon: &GeneratedAssetSheetConnectedIcon, color: [u8; 4]) { + let image = image::load_from_memory(icon.bytes.as_slice()) + .expect("icon should decode") + .to_rgba8(); + assert!( + image.pixels().any(|pixel| pixel.0 == color), + "{} should contain {color:?}", + icon.name + ); + } + #[test] fn slices_icon_spritesheet_by_connected_components_in_reading_order() { let mut sheet: image::RgbaImage = ImageBuffer::from_pixel(96, 64, Rgba([0, 255, 0, 255])); @@ -654,6 +735,88 @@ mod tests { assert!(image::load_from_memory(icons[0].bytes.as_slice()).is_ok()); } + #[test] + fn keeps_left_to_right_order_when_same_row_top_edges_differ() { + let mut sheet: image::RgbaImage = ImageBuffer::from_pixel(112, 56, Rgba([0, 255, 0, 255])); + let left_color = [240, 80, 80, 255]; + let right_color = [80, 120, 240, 255]; + for y in 18..38 { + for x in 10..30 { + sheet.put_pixel(x, y, Rgba(left_color)); + } + } + for y in 8..38 { + for x in 70..92 { + sheet.put_pixel(x, y, Rgba(right_color)); + } + } + + let source = crate::DownloadedImage { + bytes: encode_png(sheet), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + let icons = slice_generated_icon_spritesheet_by_connected_components( + &source, + &["左侧素材".to_string(), "右侧素材".to_string()], + ) + .expect("same-row icons should slice from left to right"); + + assert_icon_contains_color(&icons[0], left_color); + assert_icon_contains_color(&icons[1], right_color); + } + + #[test] + fn orders_multiple_visual_rows_top_to_bottom_and_left_to_right() { + let mut sheet: image::RgbaImage = ImageBuffer::from_pixel(128, 112, Rgba([0, 255, 0, 255])); + let colors = [ + [240, 80, 80, 255], + [80, 120, 240, 255], + [180, 80, 220, 255], + [250, 190, 40, 255], + ]; + for y in 18..38 { + for x in 10..30 { + sheet.put_pixel(x, y, Rgba(colors[0])); + } + } + for y in 8..38 { + for x in 78..100 { + sheet.put_pixel(x, y, Rgba(colors[1])); + } + } + for y in 72..94 { + for x in 12..34 { + sheet.put_pixel(x, y, Rgba(colors[2])); + } + } + for y in 62..94 { + for x in 80..104 { + sheet.put_pixel(x, y, Rgba(colors[3])); + } + } + + let source = crate::DownloadedImage { + bytes: encode_png(sheet), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + let icons = slice_generated_icon_spritesheet_by_connected_components( + &source, + &[ + "第一行左侧".to_string(), + "第一行右侧".to_string(), + "第二行左侧".to_string(), + "第二行右侧".to_string(), + ], + ) + .expect("visual rows should slice in reading order"); + + for (icon, color) in icons.iter().zip(colors) { + assert_icon_contains_color(icon, color); + } + } + #[test] fn rejects_when_connected_components_are_fewer_than_icon_names() { let mut sheet: image::RgbaImage = ImageBuffer::from_pixel(48, 48, Rgba([0, 255, 0, 255])); @@ -837,6 +1000,10 @@ impl GeneratedAssetSheetCellBounds { self.y1.saturating_sub(self.y0).max(1) } + fn center_y_twice(self) -> u32 { + self.y0.saturating_add(self.y1) + } + fn area(self) -> u32 { self.width().saturating_mul(self.height()) } diff --git a/server-rs/crates/shared-contracts/src/assets.rs b/server-rs/crates/shared-contracts/src/assets.rs index c30598dc1..58e0b3958 100644 --- a/server-rs/crates/shared-contracts/src/assets.rs +++ b/server-rs/crates/shared-contracts/src/assets.rs @@ -5,6 +5,8 @@ use serde_json::Value; use crate::external_generation::ExternalGenerationJobStatusRecord; +pub const EDITOR_ASSET_LABEL_MAX_CHARS: usize = 80; + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CreateDirectUploadTicketRequest { @@ -330,6 +332,10 @@ pub struct EditorCharacterAnimationGenerateRequest { pub generation_inputs: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub source_resource_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub asset_folder_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub asset_label: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -477,6 +483,13 @@ pub struct EditorIconSpritesheetIconPayload { pub height: u32, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct EditorIconSpritesheetSliceWarningPayload { + pub code: String, + pub reason: String, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct EditorIconSpritesheetGenerateResponse { @@ -484,6 +497,8 @@ pub struct EditorIconSpritesheetGenerateResponse { pub spritesheet_width: u32, pub spritesheet_height: u32, pub icon_image_srcs: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub slice_warning: Option, pub prompt: String, #[serde(skip_serializing_if = "Option::is_none")] pub actual_prompt: Option, @@ -1099,6 +1114,8 @@ mod tests { }), generation_inputs: None, source_resource_id: Some("resource-source".to_string()), + asset_folder_id: Some("project".to_string()), + asset_label: Some("勇者奔跑".to_string()), }) .expect("request should serialize"); @@ -1112,6 +1129,8 @@ mod tests { assert_eq!(payload["projectId"], json!("editor-project-1")); assert_eq!(payload["canvasCompletion"]["dialogId"], json!("dialog-1")); assert_eq!(payload["sourceResourceId"], json!("resource-source")); + assert_eq!(payload["assetFolderId"], json!("project")); + assert_eq!(payload["assetLabel"], json!("勇者奔跑")); } #[test] @@ -1293,6 +1312,10 @@ mod tests { spritesheet_width: 512, spritesheet_height: 512, icon_image_srcs: Vec::new(), + slice_warning: Some(EditorIconSpritesheetSliceWarningPayload { + code: "insufficient-connected-components".to_string(), + reason: "连通域数量不足".to_string(), + }), prompt: "图标 prompt".to_string(), actual_prompt: Some("图标 prompt".to_string()), model: "gemini-3.1-flash-image-preview".to_string(), @@ -1304,6 +1327,13 @@ mod tests { assert_eq!(response_payload["priceMudPoints"], json!(12)); assert_eq!(response_payload["iconImageSrcs"], json!([])); + assert_eq!( + response_payload["sliceWarning"], + json!({ + "code": "insufficient-connected-components", + "reason": "连通域数量不足", + }) + ); } #[test] diff --git a/server-rs/crates/shared-contracts/src/external_generation.rs b/server-rs/crates/shared-contracts/src/external_generation.rs index 004574c81..a93705986 100644 --- a/server-rs/crates/shared-contracts/src/external_generation.rs +++ b/server-rs/crates/shared-contracts/src/external_generation.rs @@ -36,10 +36,19 @@ pub struct ExternalGenerationJobStatusRecord { pub updated_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalGenerationJobStatusDetailRecord { + #[serde(flatten)] + pub status: ExternalGenerationJobStatusRecord, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub warning: Option, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExternalGenerationJobStatusResponse { - pub job: ExternalGenerationJobStatusRecord, + pub job: ExternalGenerationJobStatusDetailRecord, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -56,6 +65,8 @@ pub struct ExternalGenerationTaskRecord { pub phase_detail: String, pub progress: u8, pub error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub warning: Option, pub price_mud_points: u64, pub refund_ledger_id: Option, pub notification_acknowledged_at: Option, @@ -84,3 +95,33 @@ pub struct ExternalGenerationTaskAcknowledgeRequest { pub struct ExternalGenerationTaskAcknowledgeResponse { pub acknowledged_tasks: Vec, } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn status_detail_serializes_warning_without_reusing_error() { + let payload = serde_json::to_value(ExternalGenerationJobStatusResponse { + job: ExternalGenerationJobStatusDetailRecord { + status: ExternalGenerationJobStatusRecord { + operation_id: "job-1".to_string(), + status: ExternalGenerationJobStatus::Completed, + phase_label: "图标素材生成".to_string(), + phase_detail: "生成已完成。".to_string(), + progress: 100, + error: None, + updated_at_micros: 1, + }, + warning: Some("连通域数量不足".to_string()), + }, + }) + .expect("状态响应应可序列化"); + + assert_eq!(payload["job"]["warning"], json!("连通域数量不足")); + assert_eq!(payload["job"]["error"], json!(null)); + assert_eq!(payload["job"]["status"], json!("completed")); + } +} diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index 98674d92b..67afc5fa0 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -680,6 +680,10 @@ pub struct AdminUpsertProfileRedeemCodeRequest { pub allowed_user_ids: Vec, #[serde(default)] pub allowed_public_user_codes: Vec, + #[serde(default)] + pub starts_at: Option, + #[serde(default)] + pub expires_at: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -713,6 +717,8 @@ pub struct ProfileRedeemCodeAdminResponse { pub created_by: String, pub created_at: String, pub updated_at: String, + pub starts_at: Option, + pub expires_at: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -1219,6 +1225,39 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn redeem_code_admin_contract_uses_optional_camel_case_validity_window() { + let request: AdminUpsertProfileRedeemCodeRequest = serde_json::from_value(json!({ + "code": "GIFT", + "mode": "public", + "rewardPoints": 30, + "maxUses": 1, + "startsAt": "2026-07-13T00:00:00Z", + "expiresAt": null + })) + .expect("redeem code request should accept validity window"); + assert_eq!(request.starts_at.as_deref(), Some("2026-07-13T00:00:00Z")); + assert_eq!(request.expires_at, None); + + let response = ProfileRedeemCodeAdminResponse { + code: "GIFT".to_string(), + mode: "public".to_string(), + reward_points: 30, + max_uses: 1, + global_used_count: 0, + enabled: true, + allowed_user_ids: Vec::new(), + created_by: "admin".to_string(), + created_at: "2026-07-13T00:00:00Z".to_string(), + updated_at: "2026-07-13T00:00:00Z".to_string(), + starts_at: Some("2026-07-13T00:00:00Z".to_string()), + expires_at: None, + }; + let payload = serde_json::to_value(response).expect("response should serialize"); + assert_eq!(payload["startsAt"], json!("2026-07-13T00:00:00Z")); + assert_eq!(payload["expiresAt"], json!(null)); + } + fn empty_mud_point_balance() -> ProfileMudPointBalanceResponse { ProfileMudPointBalanceResponse { total_points: 0, diff --git a/server-rs/crates/spacetime-client/src/assets.rs b/server-rs/crates/spacetime-client/src/assets.rs index 004197d93..c33e050c3 100644 --- a/server-rs/crates/spacetime-client/src/assets.rs +++ b/server-rs/crates/spacetime-client/src/assets.rs @@ -50,29 +50,67 @@ impl SpacetimeClient { &self, asset_object_id: String, ) -> Result, SpacetimeClientError> { - self.read_after_connect("get_asset_object", move |connection| { - Ok(connection - .db() - .asset_object() - .asset_object_id() - .find(&asset_object_id) - .map(map_asset_object_row)) - }) + self.call_after_connect( + "get_asset_object_by_id_and_return", + move |connection, sender| { + connection + .procedures() + .get_asset_object_by_id_and_return_then(asset_object_id, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_optional_asset_object_procedure_result); + send_once(&sender, mapped); + }); + }, + ) .await } - pub async fn get_asset_object_by_object_key( + pub async fn get_asset_object_by_location( &self, - object_key: String, + input: module_assets::AssetObjectLocationInput, ) -> Result, SpacetimeClientError> { - self.read_after_connect("get_asset_object_by_object_key", move |connection| { - Ok(connection - .db() - .asset_object() - .iter() - .find(|row| row.object_key == object_key) - .map(map_asset_object_row)) - }) + let procedure_input = input.into(); + self.call_after_connect( + "get_asset_object_by_location_and_return", + move |connection, sender| { + connection + .procedures() + .get_asset_object_by_location_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_optional_asset_object_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn get_asset_read_access_by_location( + &self, + input: module_assets::AssetObjectLocationInput, + ) -> Result<(Option, bool), SpacetimeClientError> { + let procedure_input = input.into(); + self.call_after_connect( + "get_asset_read_access_by_location_and_return", + move |connection, sender| { + connection + .procedures() + .get_asset_read_access_by_location_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_asset_read_access_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) .await } diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 2ae6f57ca..304be0eb9 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -383,6 +383,35 @@ pub enum SpacetimeClientError { const DEFAULT_PROCEDURE_TIMEOUT: Duration = Duration::from_secs(30); const PUBLIC_WORK_PLAY_DAY_MICROS: i64 = 86_400_000_000; const PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS: i64 = 7; +const REQUIRED_CACHED_READ_MODEL_QUERIES: [&str; 12] = [ + "SELECT * FROM public_work_gallery_entry", + "SELECT * FROM public_work_detail_entry", + "SELECT * FROM bark_battle_gallery_view", + "SELECT * FROM puzzle_gallery_card_view", + "SELECT * FROM puzzle_clear_gallery_card_view", + "SELECT * FROM jump_hop_gallery_card_view", + "SELECT * FROM wooden_fish_gallery_card_view", + "SELECT * FROM custom_world_gallery_entry", + "SELECT * FROM match_3_d_gallery_view", + "SELECT * FROM square_hole_gallery_view", + "SELECT * FROM visual_novel_gallery_view", + "SELECT * FROM big_fish_gallery_view", +]; +const OPTIONAL_CACHED_READ_MODEL_QUERIES: [&str; 13] = [ + "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle'", + "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle-clear'", + "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'jump-hop'", + "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'wooden-fish'", + "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'custom-world'", + "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'match3d'", + "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'square-hole'", + "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'visual-novel'", + "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'big-fish'", + "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'bark-battle'", + "SELECT * FROM creation_entry_config", + "SELECT * FROM creation_entry_type_config", + "SELECT * FROM user_account", +]; type ProcedureResultSender = Arc>>>>; @@ -882,20 +911,7 @@ impl SpacetimeClient { operation_timeout: Duration, ) -> Result, SpacetimeStageError> { let mut subscriptions = Vec::new(); - for query in [ - "SELECT * FROM public_work_gallery_entry", - "SELECT * FROM public_work_detail_entry", - "SELECT * FROM bark_battle_gallery_view", - "SELECT * FROM puzzle_gallery_card_view", - "SELECT * FROM puzzle_clear_gallery_card_view", - "SELECT * FROM jump_hop_gallery_card_view", - "SELECT * FROM wooden_fish_gallery_card_view", - "SELECT * FROM custom_world_gallery_entry", - "SELECT * FROM match_3_d_gallery_view", - "SELECT * FROM square_hole_gallery_view", - "SELECT * FROM visual_novel_gallery_view", - "SELECT * FROM big_fish_gallery_view", - ] { + for query in REQUIRED_CACHED_READ_MODEL_QUERIES { let subscription = self .subscribe_cached_read_model_query( connection, @@ -908,22 +924,7 @@ impl SpacetimeClient { subscriptions.push(subscription); } - for query in [ - "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle'", - "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle-clear'", - "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'jump-hop'", - "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'wooden-fish'", - "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'custom-world'", - "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'match3d'", - "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'square-hole'", - "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'visual-novel'", - "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'big-fish'", - "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'bark-battle'", - "SELECT * FROM creation_entry_config", - "SELECT * FROM creation_entry_type_config", - "SELECT * FROM user_account", - "SELECT * FROM asset_object", - ] { + for query in OPTIONAL_CACHED_READ_MODEL_QUERIES { if let Ok(subscription) = self .subscribe_cached_read_model_query( connection, @@ -1202,6 +1203,16 @@ mod tests { )); } + #[test] + fn asset_acl_truth_is_not_read_from_cached_subscriptions() { + assert!( + !REQUIRED_CACHED_READ_MODEL_QUERIES + .contains(&"SELECT * FROM public_work_asset_read_grant") + ); + assert!(!REQUIRED_CACHED_READ_MODEL_QUERIES.contains(&"SELECT * FROM asset_object")); + assert!(!OPTIONAL_CACHED_READ_MODEL_QUERIES.contains(&"SELECT * FROM asset_object")); + } + fn test_client(pool_size: u32, procedure_timeout: Duration) -> SpacetimeClient { SpacetimeClient::new(SpacetimeClientConfig { // 指向本机不可达端口:测试只验证连接池行为,不需要真实 SpacetimeDB。 diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index c29d71cde..43c2d4b2a 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -191,7 +191,8 @@ pub use self::wooden_fish::{ pub(crate) use self::ai::map_ai_task_procedure_result; pub(crate) use self::assets::{ - map_asset_object_row, map_entity_binding_procedure_result, map_procedure_result, + map_asset_read_access_procedure_result, map_entity_binding_procedure_result, + map_optional_asset_object_procedure_result, map_procedure_result, }; pub(crate) use self::auth::{ map_auth_store_projection_procedure_result, map_auth_store_projection_sync_procedure_result, diff --git a/server-rs/crates/spacetime-client/src/mapper/assets.rs b/server-rs/crates/spacetime-client/src/mapper/assets.rs index 8c46ab82c..f9edd4900 100644 --- a/server-rs/crates/spacetime-client/src/mapper/assets.rs +++ b/server-rs/crates/spacetime-client/src/mapper/assets.rs @@ -46,6 +46,15 @@ impl From for AssetHistoryListInput { } } +impl From for AssetObjectLocationInput { + fn from(input: module_assets::AssetObjectLocationInput) -> Self { + Self { + bucket: input.bucket, + object_key: input.object_key, + } + } +} + pub(crate) fn map_procedure_result( result: AssetObjectProcedureResult, ) -> Result { @@ -60,6 +69,71 @@ pub(crate) fn map_procedure_result( Ok(build_asset_object_record(map_snapshot(snapshot))) } +pub(crate) fn map_optional_asset_object_procedure_result( + result: AssetObjectProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + Ok(result + .record + .map(map_snapshot) + .map(build_asset_object_record)) +} + +pub(crate) fn map_asset_read_access_procedure_result( + result: AssetObjectReadAccessProcedureResult, +) -> Result<(Option, bool), SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + Ok(( + result + .record + .map(map_snapshot) + .map(build_asset_object_record), + result.public_work_granted, + )) +} + +#[cfg(test)] +mod asset_object_procedure_result_tests { + use super::*; + + #[test] + fn optional_asset_lookup_distinguishes_missing_record_from_failure() { + let missing = map_optional_asset_object_procedure_result(AssetObjectProcedureResult { + ok: true, + record: None, + error_message: None, + }) + .expect("authoritative missing record should be a successful lookup"); + assert!(missing.is_none()); + + let failed = map_optional_asset_object_procedure_result(AssetObjectProcedureResult { + ok: false, + record: None, + error_message: Some("runtime identity 不匹配".to_string()), + }); + assert!(failed.is_err()); + } + + #[test] + fn read_access_lookup_preserves_authoritative_public_grant() { + let result = map_asset_read_access_procedure_result(AssetObjectReadAccessProcedureResult { + ok: true, + record: None, + public_work_granted: true, + error_message: None, + }) + .expect("authoritative read access lookup should succeed"); + + assert_eq!(result, (None, true)); + } +} + pub(crate) fn map_entity_binding_procedure_result( result: AssetEntityBindingProcedureResult, ) -> Result { @@ -115,26 +189,6 @@ pub(crate) fn map_snapshot( } } -pub(crate) fn map_asset_object_row(row: AssetObject) -> AssetObjectRecord { - build_asset_object_record(module_assets::AssetObjectUpsertSnapshot { - asset_object_id: row.asset_object_id, - bucket: row.bucket, - object_key: row.object_key, - access_policy: map_access_policy_back(row.access_policy), - content_type: row.content_type, - content_length: row.content_length, - content_hash: row.content_hash, - version: row.version, - source_job_id: row.source_job_id, - owner_user_id: row.owner_user_id, - profile_id: row.profile_id, - entity_id: row.entity_id, - asset_kind: row.asset_kind, - created_at_micros: row.created_at.to_micros_since_unix_epoch(), - updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - }) -} - pub(crate) fn map_access_policy( value: AssetObjectAccessPolicy, ) -> crate::module_bindings::AssetObjectAccessPolicy { diff --git a/server-rs/crates/spacetime-client/src/mapper/external_generation.rs b/server-rs/crates/spacetime-client/src/mapper/external_generation.rs index fa1901aac..e032f20c5 100644 --- a/server-rs/crates/spacetime-client/src/mapper/external_generation.rs +++ b/server-rs/crates/spacetime-client/src/mapper/external_generation.rs @@ -264,6 +264,7 @@ fn map_external_generation_job_summary_snapshot( .notification_acknowledged_at_micros .map(format_timestamp_micros), notification_acknowledged_at_micros: snapshot.notification_acknowledged_at_micros, + warning_message: snapshot.warning_message, } } @@ -400,6 +401,7 @@ pub struct ExternalGenerationJobSummaryRecord { pub refund_ledger_id: Option, pub notification_acknowledged_at: Option, pub notification_acknowledged_at_micros: Option, + pub warning_message: Option, } #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/server-rs/crates/spacetime-client/src/mapper/runtime_profile.rs b/server-rs/crates/spacetime-client/src/mapper/runtime_profile.rs index 74f898096..1294e945f 100644 --- a/server-rs/crates/spacetime-client/src/mapper/runtime_profile.rs +++ b/server-rs/crates/spacetime-client/src/mapper/runtime_profile.rs @@ -315,6 +315,8 @@ impl From enabled: input.enabled, allowed_user_ids: input.allowed_user_ids, allowed_public_user_codes: input.allowed_public_user_codes, + starts_at_micros: input.starts_at_micros, + expires_at_micros: input.expires_at_micros, updated_at_micros: input.updated_at_micros, } } @@ -1286,6 +1288,8 @@ pub(crate) fn map_runtime_profile_redeem_code_snapshot( created_by: snapshot.created_by, created_at_micros: snapshot.created_at_micros, updated_at_micros: snapshot.updated_at_micros, + starts_at_micros: snapshot.starts_at_micros, + expires_at_micros: snapshot.expires_at_micros, } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index c0db8c832..01e478d4f 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -94,7 +94,9 @@ pub mod asset_history_entry_snapshot_type; pub mod asset_history_list_input_type; pub mod asset_history_list_result_type; pub mod asset_object_access_policy_type; +pub mod asset_object_location_input_type; pub mod asset_object_procedure_result_type; +pub mod asset_object_read_access_procedure_result_type; pub mod asset_object_table; pub mod asset_object_type; pub mod asset_object_upsert_input_type; @@ -516,6 +518,9 @@ pub mod finish_match_3_d_time_up_procedure; pub mod finish_square_hole_time_up_procedure; pub mod finish_wooden_fish_run_procedure; pub mod generate_big_fish_asset_procedure; +pub mod get_asset_object_by_id_and_return_procedure; +pub mod get_asset_object_by_location_and_return_procedure; +pub mod get_asset_read_access_by_location_and_return_procedure; pub mod get_bark_battle_run_procedure; pub mod get_bark_battle_runtime_config_procedure; pub mod get_battle_state_procedure; @@ -775,6 +780,8 @@ pub mod profile_wallet_config_table; pub mod profile_wallet_config_type; pub mod profile_wallet_ledger_table; pub mod profile_wallet_ledger_type; +pub mod public_work_asset_read_grant_table; +pub mod public_work_asset_read_grant_type; pub mod public_work_detail_entry_table; pub mod public_work_detail_entry_type; pub mod public_work_gallery_entry_table; @@ -1432,7 +1439,9 @@ pub use asset_history_entry_snapshot_type::AssetHistoryEntrySnapshot; pub use asset_history_list_input_type::AssetHistoryListInput; pub use asset_history_list_result_type::AssetHistoryListResult; pub use asset_object_access_policy_type::AssetObjectAccessPolicy; +pub use asset_object_location_input_type::AssetObjectLocationInput; pub use asset_object_procedure_result_type::AssetObjectProcedureResult; +pub use asset_object_read_access_procedure_result_type::AssetObjectReadAccessProcedureResult; pub use asset_object_table::*; pub use asset_object_type::AssetObject; pub use asset_object_upsert_input_type::AssetObjectUpsertInput; @@ -1854,6 +1863,9 @@ pub use finish_match_3_d_time_up_procedure::finish_match_3_d_time_up; pub use finish_square_hole_time_up_procedure::finish_square_hole_time_up; pub use finish_wooden_fish_run_procedure::finish_wooden_fish_run; pub use generate_big_fish_asset_procedure::generate_big_fish_asset; +pub use get_asset_object_by_id_and_return_procedure::get_asset_object_by_id_and_return; +pub use get_asset_object_by_location_and_return_procedure::get_asset_object_by_location_and_return; +pub use get_asset_read_access_by_location_and_return_procedure::get_asset_read_access_by_location_and_return; pub use get_bark_battle_run_procedure::get_bark_battle_run; pub use get_bark_battle_runtime_config_procedure::get_bark_battle_runtime_config; pub use get_battle_state_procedure::get_battle_state; @@ -2113,6 +2125,8 @@ pub use profile_wallet_config_table::*; pub use profile_wallet_config_type::ProfileWalletConfig; pub use profile_wallet_ledger_table::*; pub use profile_wallet_ledger_type::ProfileWalletLedger; +pub use public_work_asset_read_grant_table::*; +pub use public_work_asset_read_grant_type::PublicWorkAssetReadGrant; pub use public_work_detail_entry_table::*; pub use public_work_detail_entry_type::PublicWorkDetailEntry; pub use public_work_gallery_entry_table::*; @@ -3058,6 +3072,7 @@ pub struct DbUpdate { profile_task_reward_claim: __sdk::TableUpdate, profile_wallet_config: __sdk::TableUpdate, profile_wallet_ledger: __sdk::TableUpdate, + public_work_asset_read_grant: __sdk::TableUpdate, public_work_detail_entry: __sdk::TableUpdate, public_work_gallery_entry: __sdk::TableUpdate, public_work_like: __sdk::TableUpdate, @@ -3415,6 +3430,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "profile_wallet_ledger" => db_update.profile_wallet_ledger.append( profile_wallet_ledger_table::parse_table_update(table_update)?, ), + "public_work_asset_read_grant" => db_update.public_work_asset_read_grant.append( + public_work_asset_read_grant_table::parse_table_update(table_update)?, + ), "public_work_detail_entry" => db_update.public_work_detail_entry.append( public_work_detail_entry_table::parse_table_update(table_update)?, ), @@ -4258,6 +4276,12 @@ impl __sdk::DbUpdate for DbUpdate { "match_3_d_gallery_view", &self.match_3_d_gallery_view, ); + diff.public_work_asset_read_grant = cache + .apply_diff_to_table::( + "public_work_asset_read_grant", + &self.public_work_asset_read_grant, + ) + .with_updates_by_pk(|row| &row.grant_id); diff.public_work_detail_entry = cache.apply_diff_to_table::( "public_work_detail_entry", &self.public_work_detail_entry, @@ -4577,6 +4601,9 @@ impl __sdk::DbUpdate for DbUpdate { "profile_wallet_ledger" => db_update .profile_wallet_ledger .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "public_work_asset_read_grant" => db_update + .public_work_asset_read_grant + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "public_work_detail_entry" => db_update .public_work_detail_entry .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -5007,6 +5034,9 @@ impl __sdk::DbUpdate for DbUpdate { "profile_wallet_ledger" => db_update .profile_wallet_ledger .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "public_work_asset_read_grant" => db_update + .public_work_asset_read_grant + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "public_work_detail_entry" => db_update .public_work_detail_entry .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -5264,6 +5294,7 @@ pub struct AppliedDiff<'r> { profile_task_reward_claim: __sdk::TableAppliedDiff<'r, ProfileTaskRewardClaim>, profile_wallet_config: __sdk::TableAppliedDiff<'r, ProfileWalletConfig>, profile_wallet_ledger: __sdk::TableAppliedDiff<'r, ProfileWalletLedger>, + public_work_asset_read_grant: __sdk::TableAppliedDiff<'r, PublicWorkAssetReadGrant>, public_work_detail_entry: __sdk::TableAppliedDiff<'r, PublicWorkDetailEntry>, public_work_gallery_entry: __sdk::TableAppliedDiff<'r, PublicWorkGalleryEntry>, public_work_like: __sdk::TableAppliedDiff<'r, PublicWorkLike>, @@ -5765,6 +5796,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.profile_wallet_ledger, event, ); + callbacks.invoke_table_row_callbacks::( + "public_work_asset_read_grant", + &self.public_work_asset_read_grant, + event, + ); callbacks.invoke_table_row_callbacks::( "public_work_detail_entry", &self.public_work_detail_entry, @@ -6752,6 +6788,7 @@ impl __sdk::SpacetimeModule for RemoteModule { profile_task_reward_claim_table::register_table(client_cache); profile_wallet_config_table::register_table(client_cache); profile_wallet_ledger_table::register_table(client_cache); + public_work_asset_read_grant_table::register_table(client_cache); public_work_detail_entry_table::register_table(client_cache); public_work_gallery_entry_table::register_table(client_cache); public_work_like_table::register_table(client_cache); @@ -6893,6 +6930,7 @@ impl __sdk::SpacetimeModule for RemoteModule { "profile_task_reward_claim", "profile_wallet_config", "profile_wallet_ledger", + "public_work_asset_read_grant", "public_work_detail_entry", "public_work_gallery_entry", "public_work_like", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/asset_object_location_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/asset_object_location_input_type.rs new file mode 100644 index 000000000..54319aea6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/asset_object_location_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AssetObjectLocationInput { + pub bucket: String, + pub object_key: String, +} + +impl __sdk::InModule for AssetObjectLocationInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/asset_object_read_access_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/asset_object_read_access_procedure_result_type.rs new file mode 100644 index 000000000..a5c63e445 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/asset_object_read_access_procedure_result_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::asset_object_upsert_snapshot_type::AssetObjectUpsertSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AssetObjectReadAccessProcedureResult { + pub ok: bool, + pub record: Option, + pub public_work_granted: bool, + pub error_message: Option, +} + +impl __sdk::InModule for AssetObjectReadAccessProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_summary_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_summary_snapshot_type.rs index 492102908..c802d64ae 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_summary_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_summary_snapshot_type.rs @@ -23,6 +23,7 @@ pub struct ExternalGenerationJobSummarySnapshot { pub price_mud_points: u64, pub refund_ledger_id: Option, pub notification_acknowledged_at_micros: Option, + pub warning_message: Option, } impl __sdk::InModule for ExternalGenerationJobSummarySnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_summary_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_summary_type.rs index 685be2b15..38c59d165 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_summary_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_summary_type.rs @@ -23,6 +23,7 @@ pub struct ExternalGenerationJobSummary { pub price_mud_points: u64, pub refund_ledger_id: Option, pub notification_acknowledged_at: Option<__sdk::Timestamp>, + pub warning_message: Option, } impl __sdk::InModule for ExternalGenerationJobSummary { @@ -53,6 +54,7 @@ pub struct ExternalGenerationJobSummaryCols { pub refund_ledger_id: __sdk::__query_builder::Col>, pub notification_acknowledged_at: __sdk::__query_builder::Col>, + pub warning_message: __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for ExternalGenerationJobSummary { @@ -78,6 +80,7 @@ impl __sdk::__query_builder::HasCols for ExternalGenerationJobSummary { table_name, "notification_acknowledged_at", ), + warning_message: __sdk::__query_builder::Col::new(table_name, "warning_message"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_asset_object_by_id_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_asset_object_by_id_and_return_procedure.rs new file mode 100644 index 000000000..b1d165b00 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_asset_object_by_id_and_return_procedure.rs @@ -0,0 +1,58 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::asset_object_procedure_result_type::AssetObjectProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetAssetObjectByIdAndReturnArgs { + pub asset_object_id: String, +} + +impl __sdk::InModule for GetAssetObjectByIdAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_asset_object_by_id_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_asset_object_by_id_and_return { + fn get_asset_object_by_id_and_return(&self, asset_object_id: String) { + self.get_asset_object_by_id_and_return_then(asset_object_id, |_, _| {}); + } + + fn get_asset_object_by_id_and_return_then( + &self, + asset_object_id: String, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_asset_object_by_id_and_return for super::RemoteProcedures { + fn get_asset_object_by_id_and_return_then( + &self, + asset_object_id: String, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, AssetObjectProcedureResult>( + "get_asset_object_by_id_and_return", + GetAssetObjectByIdAndReturnArgs { asset_object_id }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_asset_object_by_location_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_asset_object_by_location_and_return_procedure.rs new file mode 100644 index 000000000..9254cc5c9 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_asset_object_by_location_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::asset_object_location_input_type::AssetObjectLocationInput; +use super::asset_object_procedure_result_type::AssetObjectProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetAssetObjectByLocationAndReturnArgs { + pub input: AssetObjectLocationInput, +} + +impl __sdk::InModule for GetAssetObjectByLocationAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_asset_object_by_location_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_asset_object_by_location_and_return { + fn get_asset_object_by_location_and_return(&self, input: AssetObjectLocationInput) { + self.get_asset_object_by_location_and_return_then(input, |_, _| {}); + } + + fn get_asset_object_by_location_and_return_then( + &self, + input: AssetObjectLocationInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_asset_object_by_location_and_return for super::RemoteProcedures { + fn get_asset_object_by_location_and_return_then( + &self, + input: AssetObjectLocationInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, AssetObjectProcedureResult>( + "get_asset_object_by_location_and_return", + GetAssetObjectByLocationAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_asset_read_access_by_location_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_asset_read_access_by_location_and_return_procedure.rs new file mode 100644 index 000000000..bb8314cfa --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_asset_read_access_by_location_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::asset_object_location_input_type::AssetObjectLocationInput; +use super::asset_object_read_access_procedure_result_type::AssetObjectReadAccessProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetAssetReadAccessByLocationAndReturnArgs { + pub input: AssetObjectLocationInput, +} + +impl __sdk::InModule for GetAssetReadAccessByLocationAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_asset_read_access_by_location_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_asset_read_access_by_location_and_return { + fn get_asset_read_access_by_location_and_return(&self, input: AssetObjectLocationInput) { + self.get_asset_read_access_by_location_and_return_then(input, |_, _| {}); + } + + fn get_asset_read_access_by_location_and_return_then( + &self, + input: AssetObjectLocationInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_asset_read_access_by_location_and_return for super::RemoteProcedures { + fn get_asset_read_access_by_location_and_return_then( + &self, + input: AssetObjectLocationInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, AssetObjectReadAccessProcedureResult>( + "get_asset_read_access_by_location_and_return", + GetAssetReadAccessByLocationAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_redeem_code_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_redeem_code_type.rs index 9be48e6d9..a880d3ceb 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/profile_redeem_code_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_redeem_code_type.rs @@ -19,6 +19,8 @@ pub struct ProfileRedeemCode { pub created_by: String, pub created_at: __sdk::Timestamp, pub updated_at: __sdk::Timestamp, + pub starts_at: Option<__sdk::Timestamp>, + pub expires_at: Option<__sdk::Timestamp>, } impl __sdk::InModule for ProfileRedeemCode { @@ -39,6 +41,8 @@ pub struct ProfileRedeemCodeCols { pub created_by: __sdk::__query_builder::Col, pub created_at: __sdk::__query_builder::Col, pub updated_at: __sdk::__query_builder::Col, + pub starts_at: __sdk::__query_builder::Col>, + pub expires_at: __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for ProfileRedeemCode { @@ -55,6 +59,8 @@ impl __sdk::__query_builder::HasCols for ProfileRedeemCode { created_by: __sdk::__query_builder::Col::new(table_name, "created_by"), created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + starts_at: __sdk::__query_builder::Col::new(table_name, "starts_at"), + expires_at: __sdk::__query_builder::Col::new(table_name, "expires_at"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/public_work_asset_read_grant_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/public_work_asset_read_grant_table.rs new file mode 100644 index 000000000..d35245795 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/public_work_asset_read_grant_table.rs @@ -0,0 +1,166 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::public_work_asset_read_grant_type::PublicWorkAssetReadGrant; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `public_work_asset_read_grant`. +/// +/// Obtain a handle from the [`PublicWorkAssetReadGrantTableAccess::public_work_asset_read_grant`] method on [`super::RemoteTables`], +/// like `ctx.db.public_work_asset_read_grant()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.public_work_asset_read_grant().on_insert(...)`. +pub struct PublicWorkAssetReadGrantTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `public_work_asset_read_grant`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait PublicWorkAssetReadGrantTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`PublicWorkAssetReadGrantTableHandle`], which mediates access to the table `public_work_asset_read_grant`. + fn public_work_asset_read_grant(&self) -> PublicWorkAssetReadGrantTableHandle<'_>; +} + +impl PublicWorkAssetReadGrantTableAccess for super::RemoteTables { + fn public_work_asset_read_grant(&self) -> PublicWorkAssetReadGrantTableHandle<'_> { + PublicWorkAssetReadGrantTableHandle { + imp: self + .imp + .get_table::("public_work_asset_read_grant"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct PublicWorkAssetReadGrantInsertCallbackId(__sdk::CallbackId); +pub struct PublicWorkAssetReadGrantDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for PublicWorkAssetReadGrantTableHandle<'ctx> { + type Row = PublicWorkAssetReadGrant; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = PublicWorkAssetReadGrantInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PublicWorkAssetReadGrantInsertCallbackId { + PublicWorkAssetReadGrantInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: PublicWorkAssetReadGrantInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = PublicWorkAssetReadGrantDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> PublicWorkAssetReadGrantDeleteCallbackId { + PublicWorkAssetReadGrantDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: PublicWorkAssetReadGrantDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct PublicWorkAssetReadGrantUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for PublicWorkAssetReadGrantTableHandle<'ctx> { + type UpdateCallbackId = PublicWorkAssetReadGrantUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> PublicWorkAssetReadGrantUpdateCallbackId { + PublicWorkAssetReadGrantUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: PublicWorkAssetReadGrantUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `grant_id` unique index on the table `public_work_asset_read_grant`, +/// which allows point queries on the field of the same name +/// via the [`PublicWorkAssetReadGrantGrantIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.public_work_asset_read_grant().grant_id().find(...)`. +pub struct PublicWorkAssetReadGrantGrantIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> PublicWorkAssetReadGrantTableHandle<'ctx> { + /// Get a handle on the `grant_id` unique index on the table `public_work_asset_read_grant`. + pub fn grant_id(&self) -> PublicWorkAssetReadGrantGrantIdUnique<'ctx> { + PublicWorkAssetReadGrantGrantIdUnique { + imp: self.imp.get_unique_constraint::("grant_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> PublicWorkAssetReadGrantGrantIdUnique<'ctx> { + /// Find the subscribed row whose `grant_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = + client_cache.get_or_make_table::("public_work_asset_read_grant"); + _table.add_unique_constraint::("grant_id", |row| &row.grant_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `PublicWorkAssetReadGrant`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait public_work_asset_read_grantQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `PublicWorkAssetReadGrant`. + fn public_work_asset_read_grant( + &self, + ) -> __sdk::__query_builder::Table; +} + +impl public_work_asset_read_grantQueryTableAccess for __sdk::QueryTableAccessor { + fn public_work_asset_read_grant( + &self, + ) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("public_work_asset_read_grant") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/public_work_asset_read_grant_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/public_work_asset_read_grant_type.rs new file mode 100644 index 000000000..73710250f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/public_work_asset_read_grant_type.rs @@ -0,0 +1,46 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct PublicWorkAssetReadGrant { + pub grant_id: String, + pub source_type: String, + pub profile_id: String, + pub owner_user_id: String, + pub asset_object_id: Option, + pub object_key: Option, +} + +impl __sdk::InModule for PublicWorkAssetReadGrant { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `PublicWorkAssetReadGrant`. +/// +/// Provides typed access to columns for query building. +pub struct PublicWorkAssetReadGrantCols { + pub grant_id: __sdk::__query_builder::Col, + pub source_type: __sdk::__query_builder::Col, + pub profile_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub asset_object_id: __sdk::__query_builder::Col>, + pub object_key: __sdk::__query_builder::Col>, +} + +impl __sdk::__query_builder::HasCols for PublicWorkAssetReadGrant { + type Cols = PublicWorkAssetReadGrantCols; + fn cols(table_name: &'static str) -> Self::Cols { + PublicWorkAssetReadGrantCols { + grant_id: __sdk::__query_builder::Col::new(table_name, "grant_id"), + source_type: __sdk::__query_builder::Col::new(table_name, "source_type"), + profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + asset_object_id: __sdk::__query_builder::Col::new(table_name, "asset_object_id"), + object_key: __sdk::__query_builder::Col::new(table_name, "object_key"), + } + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_upsert_input_type.rs index 5f18a8751..1baeb5cbe 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_upsert_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_upsert_input_type.rs @@ -17,6 +17,8 @@ pub struct RuntimeProfileRedeemCodeAdminUpsertInput { pub enabled: bool, pub allowed_user_ids: Vec, pub allowed_public_user_codes: Vec, + pub starts_at_micros: Option, + pub expires_at_micros: Option, pub updated_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_snapshot_type.rs index aea09f25b..3580d911a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_snapshot_type.rs @@ -19,6 +19,8 @@ pub struct RuntimeProfileRedeemCodeSnapshot { pub created_by: String, pub created_at_micros: i64, pub updated_at_micros: i64, + pub starts_at_micros: Option, + pub expires_at_micros: Option, } impl __sdk::InModule for RuntimeProfileRedeemCodeSnapshot { diff --git a/server-rs/crates/spacetime-client/src/runtime.rs b/server-rs/crates/spacetime-client/src/runtime.rs index 2b158c0f9..561bc8f31 100644 --- a/server-rs/crates/spacetime-client/src/runtime.rs +++ b/server-rs/crates/spacetime-client/src/runtime.rs @@ -1351,6 +1351,8 @@ impl SpacetimeClient { enabled: bool, allowed_user_ids: Vec, allowed_public_user_codes: Vec, + starts_at_micros: Option, + expires_at_micros: Option, updated_at_micros: i64, ) -> Result { let procedure_input = build_runtime_profile_redeem_code_admin_upsert_input( @@ -1362,6 +1364,8 @@ impl SpacetimeClient { enabled, allowed_user_ids, allowed_public_user_codes, + starts_at_micros, + expires_at_micros, updated_at_micros, ) .map_err(SpacetimeClientError::validation_failed)? diff --git a/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs b/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs index 825e4059a..f5c14f6e2 100644 --- a/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs +++ b/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs @@ -96,6 +96,98 @@ pub fn confirm_asset_object_and_return( } } +// ACL 等安全判断必须读取事务内真相,不能把订阅 cache miss 当成对象不存在。 +#[spacetimedb::procedure] +pub fn get_asset_object_by_location_and_return( + ctx: &mut ProcedureContext, + input: AssetObjectLocationInput, +) -> AssetObjectProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + find_asset_object_by_location(tx, &input) + }) { + Ok(record) => AssetObjectProcedureResult { + ok: true, + record, + error_message: None, + }, + Err(message) => AssetObjectProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +// 公开授权与资产 metadata 必须在同一事务快照中判断,不能依赖 API 连接池的订阅水位。 +#[spacetimedb::procedure] +pub fn get_asset_read_access_by_location_and_return( + ctx: &mut ProcedureContext, + input: AssetObjectLocationInput, +) -> AssetObjectReadAccessProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + let record = find_asset_object_by_location(tx, &input)?; + let public_work_granted = record.as_ref().is_some_and(|asset_object| { + crate::public_asset_access::asset_object_has_public_work_read_grant(tx, asset_object) + }); + Ok((record, public_work_granted)) + }) { + Ok((record, public_work_granted)) => AssetObjectReadAccessProcedureResult { + ok: true, + record, + public_work_granted, + error_message: None, + }, + Err(message) => AssetObjectReadAccessProcedureResult { + ok: false, + record: None, + public_work_granted: false, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn get_asset_object_by_id_and_return( + ctx: &mut ProcedureContext, + asset_object_id: String, +) -> AssetObjectProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + let asset_object_id = asset_object_id.trim(); + if asset_object_id.is_empty() { + return Err("asset_object_id 不能为空".to_string()); + } + Ok(tx + .db + .asset_object() + .asset_object_id() + .find(&asset_object_id.to_string()) + .map(asset_object_snapshot)) + }) { + Ok(record) => AssetObjectProcedureResult { + ok: true, + record, + error_message: None, + }, + Err(message) => AssetObjectProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + // 历史素材只返回编辑器复用所需的脱敏字段,asset_object 本表继续保持 private。 #[spacetimedb::procedure] pub fn list_asset_history_and_return( @@ -197,6 +289,54 @@ pub(crate) fn upsert_asset_object( Ok(snapshot) } +fn find_asset_object_by_location( + ctx: &ReducerContext, + input: &AssetObjectLocationInput, +) -> Result, String> { + let bucket = input.bucket.trim(); + let object_key = input.object_key.trim().trim_start_matches('/'); + if bucket.is_empty() || object_key.is_empty() { + return Err("bucket 与 object_key 不能为空".to_string()); + } + + let mut matches = ctx + .db + .asset_object() + .by_bucket_object_key() + .filter((bucket, object_key)); + resolve_unique_asset_object_location(&mut matches) +} + +fn resolve_unique_asset_object_location( + matches: &mut impl Iterator, +) -> Result, String> { + let record = matches.next(); + if matches.next().is_some() { + return Err("asset_object 的 bucket + object_key 存在重复记录".to_string()); + } + Ok(record.map(asset_object_snapshot)) +} + +fn asset_object_snapshot(row: AssetObject) -> AssetObjectUpsertSnapshot { + AssetObjectUpsertSnapshot { + asset_object_id: row.asset_object_id, + bucket: row.bucket, + object_key: row.object_key, + access_policy: row.access_policy, + content_type: row.content_type, + content_length: row.content_length, + content_hash: row.content_hash, + version: row.version, + source_job_id: row.source_job_id, + owner_user_id: row.owner_user_id, + profile_id: row.profile_id, + entity_id: row.entity_id, + asset_kind: row.asset_kind, + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} + fn require_matching_asset_object_owner( existing_owner_user_id: Option<&str>, next_owner_user_id: Option<&str>, @@ -333,6 +473,46 @@ pub(crate) fn emit_asset_object_confirmed_event( mod tests { use super::*; + fn asset_object(asset_object_id: &str) -> AssetObject { + AssetObject { + asset_object_id: asset_object_id.to_string(), + bucket: "genarrative-assets".to_string(), + object_key: "generated-puzzle-assets/published.png".to_string(), + access_policy: AssetObjectAccessPolicy::Private, + content_type: Some("image/png".to_string()), + content_length: 1, + content_hash: None, + version: 1, + source_job_id: None, + owner_user_id: Some("owner-a".to_string()), + profile_id: Some("profile-a".to_string()), + entity_id: None, + asset_kind: "puzzle_cover_image".to_string(), + created_at: Timestamp::from_micros_since_unix_epoch(1), + updated_at: Timestamp::from_micros_since_unix_epoch(1), + } + } + + #[test] + fn authoritative_location_lookup_fails_closed_on_duplicate_rows() { + let record = asset_object("assetobj-first"); + let mut one = vec![record].into_iter(); + assert_eq!( + resolve_unique_asset_object_location(&mut one) + .expect("one exact row should resolve") + .expect("one exact row should exist") + .asset_object_id, + "assetobj-first" + ); + + let mut duplicates = vec![ + asset_object("assetobj-first"), + asset_object("assetobj-second"), + ] + .into_iter(); + assert!(resolve_unique_asset_object_location(&mut duplicates).is_err()); + } + #[test] fn registered_asset_object_owner_is_immutable() { assert!(require_matching_asset_object_owner(Some("owner-a"), Some("owner-a")).is_ok()); diff --git a/server-rs/crates/spacetime-module/src/external_generation.rs b/server-rs/crates/spacetime-module/src/external_generation.rs index c4bf6c665..105db0900 100644 --- a/server-rs/crates/spacetime-module/src/external_generation.rs +++ b/server-rs/crates/spacetime-module/src/external_generation.rs @@ -19,9 +19,12 @@ const EXTERNAL_GENERATION_FINAL_ATTEMPT_LEASE_EXPIRED_MESSAGE: &str = const MAX_EXTERNAL_GENERATION_PAYLOAD_BYTES: usize = 512 * 1024; const MAX_EXTERNAL_GENERATION_REQUEST_PROMPT_CHARS: usize = 2_048; const MAX_EXTERNAL_GENERATION_ERROR_MESSAGE_CHARS: usize = 2_048; +const MAX_EXTERNAL_GENERATION_WARNING_MESSAGE_CHARS: usize = 2_048; const MAX_EXTERNAL_GENERATION_MAINTENANCE_BATCH_SIZE: u32 = 25; const INLINE_MEDIA_REMOVED_PLACEHOLDER: &str = "[inline-media-removed]"; const INLINE_MEDIA_ERROR_REDACTED_MESSAGE: &str = "外部生成失败(错误详情含内联媒体引用,已省略)"; +const INLINE_MEDIA_WARNING_REDACTED_MESSAGE: &str = + "外部生成已完成(告警详情含内联媒体引用,已省略)"; #[spacetimedb::table( accessor = external_generation_job, @@ -134,6 +137,8 @@ pub struct ExternalGenerationJobSummary { pub(crate) price_mud_points: u64, pub(crate) refund_ledger_id: Option, pub(crate) notification_acknowledged_at: Option, + #[default(None::)] + pub(crate) warning_message: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -284,6 +289,7 @@ pub struct ExternalGenerationJobSummarySnapshot { pub price_mud_points: u64, pub refund_ledger_id: Option, pub notification_acknowledged_at_micros: Option, + pub warning_message: Option, } #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] @@ -842,6 +848,7 @@ fn complete_external_generation_job_tx( }; let completed_at = ctx.timestamp; row.status = EXTERNAL_GENERATION_STATUS_COMPLETED.to_string(); + row.last_error_message = None; row.result_payload_json = result_payload_json; row.lease_expires_at = None; row.completed_at = Some(completed_at); @@ -1760,9 +1767,23 @@ fn build_external_generation_job_summary_row( price_mud_points: row.price_mud_points, refund_ledger_id: row.refund_ledger_id.clone(), notification_acknowledged_at: row.notification_acknowledged_at, + warning_message: extract_external_generation_warning_message( + row.result_payload_json.as_deref(), + ), } } +fn extract_external_generation_warning_message( + result_payload_json: Option<&str>, +) -> Option { + let payload: serde_json::Value = serde_json::from_str(result_payload_json?.trim()).ok()?; + let warning = payload + .get("warning") + .or_else(|| payload.get("sliceWarning"))?; + let reason = warning.get("reason").and_then(serde_json::Value::as_str)?; + normalize_external_generation_warning_message(reason) +} + fn extract_external_generation_request_prompt(request_payload_json: &str) -> Option { let payload: serde_json::Value = serde_json::from_str(request_payload_json).ok()?; for key in ["prompt", "promptText", "spritesheetLabel"] { @@ -1845,6 +1866,25 @@ fn normalize_external_generation_error_message(value: &str) -> Option { Some(normalized) } +fn normalize_external_generation_warning_message(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + if contains_external_generation_inline_media_reference_text(trimmed) { + return Some(INLINE_MEDIA_WARNING_REDACTED_MESSAGE.to_string()); + } + let mut chars = trimmed.chars(); + let mut normalized = chars + .by_ref() + .take(MAX_EXTERNAL_GENERATION_WARNING_MESSAGE_CHARS) + .collect::(); + if chars.next().is_some() { + normalized.push('…'); + } + Some(normalized) +} + fn contains_external_generation_inline_media_reference_text(value: &str) -> bool { let normalized = value.to_ascii_lowercase(); ["data:", "blob:"].iter().any(|scheme| { @@ -2005,6 +2045,7 @@ fn map_external_generation_job_summary_row( notification_acknowledged_at_micros: row .notification_acknowledged_at .map(|value| value.to_micros_since_unix_epoch()), + warning_message: row.warning_message, } } @@ -2732,6 +2773,64 @@ mod tests { ); } + #[test] + fn job_summary_extracts_lightweight_completion_warning() { + let mut row = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_COMPLETED); + row.result_payload_json = Some( + r#"{"sourceModule":"editor-canvas","warning":{"code":"insufficient-connected-components","reason":"连通域数量不足"}}"# + .to_string(), + ); + + let summary = build_external_generation_job_summary_row(&row, None); + + assert_eq!(summary.warning_message.as_deref(), Some("连通域数量不足")); + assert!(summary.last_error_message.is_none()); + } + + #[test] + fn job_summary_bounds_and_redacts_completion_warnings() { + let mut row = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_COMPLETED); + row.result_payload_json = Some( + serde_json::json!({ + "warning": { + "code": "slice-persistence-failed", + "reason": "provider returned data:image/png;base64,AAAA" + } + }) + .to_string(), + ); + let redacted = build_external_generation_job_summary_row(&row, None); + assert_eq!( + redacted.warning_message.as_deref(), + Some(INLINE_MEDIA_WARNING_REDACTED_MESSAGE) + ); + + row.result_payload_json = Some( + serde_json::json!({ + "warning": { + "code": "slice-persistence-failed", + "reason": "x".repeat(MAX_EXTERNAL_GENERATION_WARNING_MESSAGE_CHARS + 10) + } + }) + .to_string(), + ); + let bounded = build_external_generation_job_summary_row(&row, None) + .warning_message + .expect("非空告警应保留有界摘要"); + assert_eq!( + bounded.chars().count(), + MAX_EXTERNAL_GENERATION_WARNING_MESSAGE_CHARS + 1 + ); + assert!(bounded.ends_with('…')); + + row.result_payload_json = Some(r#"{"warning":{"code":"missing-reason"}}"#.to_string()); + assert!( + build_external_generation_job_summary_row(&row, None) + .warning_message + .is_none() + ); + } + #[test] fn job_summary_top_n_keeps_memory_bounded_and_ordered() { let mut rows = Vec::new(); diff --git a/server-rs/crates/spacetime-module/src/lib.rs b/server-rs/crates/spacetime-module/src/lib.rs index a5f26d6fd..9f596ffcc 100644 --- a/server-rs/crates/spacetime-module/src/lib.rs +++ b/server-rs/crates/spacetime-module/src/lib.rs @@ -39,6 +39,7 @@ mod gameplay; mod jump_hop; mod match3d; mod migration; +mod public_asset_access; mod public_work; mod puzzle; mod puzzle_clear; @@ -63,6 +64,7 @@ pub use gameplay::*; pub use jump_hop::*; pub use match3d::*; pub use migration::*; +pub use public_asset_access::*; pub use public_work::*; pub use puzzle_clear::*; pub use runtime::*; diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 0dc48da85..9ecee3e12 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -1308,6 +1308,17 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde .or_insert_with(|| serde_json::Value::String("{}".to_string())); } } + if table_name == "profile_redeem_code" { + if let Some(object) = next_value.as_object_mut() { + // 中文注释:兑换码生效时间晚于兑换码表加入,旧迁移包按无时间边界兼容。 + object + .entry("starts_at".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("expires_at".to_string()) + .or_insert(serde_json::Value::Null); + } + } if table_name == "profile_recharge_order" { if let Some(object) = next_value.as_object_mut() { // 中文注释:真实微信支付接入后才有平台交易号,旧迁移包按未回填处理。 @@ -1328,6 +1339,14 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde .or_insert(serde_json::Value::Null); } } + if table_name == "external_generation_job_summary" { + if let Some(object) = next_value.as_object_mut() { + // 中文注释:非阻断完成告警晚于轻量任务摘要表加入,旧迁移包按无告警兼容。 + object + .entry("warning_message".to_string()) + .or_insert(serde_json::Value::Null); + } + } if table_name == "editor_showcase_asset" { if let Some(object) = next_value.as_object_mut() { // 中文注释:精选分类字段晚于审核表加入,旧迁移包按未设置分类兼容。 @@ -1616,6 +1635,27 @@ fn is_supported_migration_table(table_name: &str) -> bool { mod migration_bootstrap_secret_tests { use super::*; + #[test] + fn old_profile_redeem_code_rows_default_to_open_validity_window() { + let normalized = normalize_migration_row( + "profile_redeem_code", + &serde_json::json!({ "code": "GIFT" }), + ); + + assert_eq!(normalized["starts_at"], serde_json::Value::Null); + assert_eq!(normalized["expires_at"], serde_json::Value::Null); + } + + #[test] + fn old_external_generation_summary_rows_default_to_no_warning() { + let normalized = normalize_migration_row( + "external_generation_job_summary", + &serde_json::json!({ "job_id": "task-1" }), + ); + + assert_eq!(normalized["warning_message"], serde_json::Value::Null); + } + #[test] fn bootstrap_secret_sha256_is_stable_and_does_not_contain_plaintext() { let secret = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; diff --git a/server-rs/crates/spacetime-module/src/public_asset_access.rs b/server-rs/crates/spacetime-module/src/public_asset_access.rs new file mode 100644 index 000000000..ccdbd9d53 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/public_asset_access.rs @@ -0,0 +1,627 @@ +use crate::puzzle::puzzle_work_profile__view; +use crate::*; +use module_puzzle::PuzzlePublicationStatus; +use spacetimedb::AnonymousViewContext; +use std::collections::BTreeMap; + +const SOURCE_TYPE_PUZZLE: &str = "puzzle"; +const SOURCE_TYPE_PUZZLE_CLEAR: &str = "puzzle-clear"; +const SOURCE_TYPE_CUSTOM_WORLD: &str = "custom-world"; +const SOURCE_TYPE_JUMP_HOP: &str = "jump-hop"; +const SOURCE_TYPE_WOODEN_FISH: &str = "wooden-fish"; +const SOURCE_TYPE_MATCH3D: &str = "match3d"; +const SOURCE_TYPE_SQUARE_HOLE: &str = "square-hole"; +const SOURCE_TYPE_VISUAL_NOVEL: &str = "visual-novel"; +const SOURCE_TYPE_BIG_FISH: &str = "big-fish"; +const SOURCE_TYPE_BARK_BATTLE: &str = "bark-battle"; + +/// 已发布且可见作品派生出的精确资产读取授权。 +/// +/// 资产对象本身继续保持 private;view 只列出公开快照实际使用的对象 ID / key。 +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct PublicWorkAssetReadGrant { + pub grant_id: String, + pub source_type: String, + pub profile_id: String, + pub owner_user_id: String, + pub asset_object_id: Option, + pub object_key: Option, +} + +struct PublicWorkAssetGrantScope<'a> { + source_type: &'a str, + profile_id: &'a str, + owner_user_id: &'a str, +} + +/// 跨玩法公开资产授权投影。 +/// +/// 历史作品随 view 首次计算自动补齐;隐藏、删除或取消发布后对应授权自动消失。 +#[spacetimedb::view(accessor = public_work_asset_read_grant, public, primary_key = grant_id)] +pub fn public_work_asset_read_grant(ctx: &AnonymousViewContext) -> Vec { + let mut grants = BTreeMap::new(); + + for row in ctx + .db + .puzzle_work_profile() + .by_puzzle_work_publication_status() + .filter(PuzzlePublicationStatus::Published) + .filter(|row| row.visible) + { + let scope = PublicWorkAssetGrantScope { + source_type: SOURCE_TYPE_PUZZLE, + profile_id: &row.profile_id, + owner_user_id: &row.owner_user_id, + }; + insert_src_grant(&mut grants, &scope, row.cover_image_src.as_deref()); + collect_json_grants(&mut grants, &scope, &row.levels_json); + } + + for row in ctx + .db + .puzzle_clear_work_profile() + .by_puzzle_clear_work_publication_status() + .filter(PUZZLE_CLEAR_PUBLICATION_PUBLISHED) + .filter(|row| row.visible) + { + let scope = PublicWorkAssetGrantScope { + source_type: SOURCE_TYPE_PUZZLE_CLEAR, + profile_id: &row.profile_id, + owner_user_id: &row.owner_user_id, + }; + insert_src_grant(&mut grants, &scope, Some(&row.cover_image_src)); + insert_src_grant(&mut grants, &scope, Some(&row.card_back_image_src)); + for raw in [ + &row.board_background_asset_json, + &row.atlas_asset_json, + &row.card_assets_json, + ] { + collect_json_grants(&mut grants, &scope, raw); + } + } + + for row in ctx + .db + .custom_world_profile() + .by_custom_world_profile_publication_status() + .filter(CustomWorldPublicationStatus::Published) + .filter(|row| row.visible && row.deleted_at.is_none()) + { + let scope = PublicWorkAssetGrantScope { + source_type: SOURCE_TYPE_CUSTOM_WORLD, + profile_id: &row.profile_id, + owner_user_id: &row.owner_user_id, + }; + insert_src_grant(&mut grants, &scope, row.cover_image_src.as_deref()); + collect_custom_world_json_grants(&mut grants, &scope, &row.profile_payload_json); + } + + for row in ctx + .db + .jump_hop_work_profile() + .by_jump_hop_work_publication_status() + .filter(JUMP_HOP_PUBLICATION_PUBLISHED) + .filter(|row| row.visible) + { + let scope = PublicWorkAssetGrantScope { + source_type: SOURCE_TYPE_JUMP_HOP, + profile_id: &row.profile_id, + owner_user_id: &row.owner_user_id, + }; + insert_src_grant(&mut grants, &scope, Some(&row.cover_image_src)); + insert_src_grant(&mut grants, &scope, Some(&row.cover_composite)); + for raw in [ + Some(row.character_asset_json.as_str()), + Some(row.tile_atlas_asset_json.as_str()), + Some(row.tile_assets_json.as_str()), + row.back_button_asset_json.as_deref(), + ] + .into_iter() + .flatten() + { + collect_json_grants(&mut grants, &scope, raw); + } + } + + for row in ctx + .db + .wooden_fish_work_profile() + .by_wooden_fish_work_publication_status() + .filter(WOODEN_FISH_PUBLICATION_PUBLISHED) + .filter(|row| row.visible) + { + let scope = PublicWorkAssetGrantScope { + source_type: SOURCE_TYPE_WOODEN_FISH, + profile_id: &row.profile_id, + owner_user_id: &row.owner_user_id, + }; + insert_src_grant(&mut grants, &scope, Some(&row.cover_image_src)); + for raw in [ + Some(row.hit_object_asset_json.as_str()), + Some(row.hit_sound_asset_json.as_str()), + row.background_asset_json.as_deref(), + row.back_button_asset_json.as_deref(), + ] + .into_iter() + .flatten() + { + collect_json_grants(&mut grants, &scope, raw); + } + } + + for row in ctx + .db + .match_3_d_work_profile() + .by_match3d_work_publication_status() + .filter(MATCH3D_PUBLICATION_PUBLISHED) + .filter(|row| row.visible) + { + let scope = PublicWorkAssetGrantScope { + source_type: SOURCE_TYPE_MATCH3D, + profile_id: &row.profile_id, + owner_user_id: &row.owner_user_id, + }; + insert_src_grant(&mut grants, &scope, Some(&row.cover_image_src)); + collect_json_grants(&mut grants, &scope, &row.config_json); + if let Some(raw) = row.generated_item_assets_json.as_deref() { + collect_json_grants(&mut grants, &scope, raw); + } + } + + for row in ctx + .db + .square_hole_work_profile() + .by_square_hole_work_publication_status() + .filter(SQUARE_HOLE_PUBLICATION_PUBLISHED) + .filter(|row| row.visible) + { + let scope = PublicWorkAssetGrantScope { + source_type: SOURCE_TYPE_SQUARE_HOLE, + profile_id: &row.profile_id, + owner_user_id: &row.owner_user_id, + }; + insert_src_grant(&mut grants, &scope, Some(&row.cover_image_src)); + collect_json_grants(&mut grants, &scope, &row.config_json); + } + + for row in ctx + .db + .visual_novel_work_profile() + .by_visual_novel_work_publication_status() + .filter(VISUAL_NOVEL_PUBLICATION_PUBLISHED) + .filter(|row| row.visible) + { + let scope = PublicWorkAssetGrantScope { + source_type: SOURCE_TYPE_VISUAL_NOVEL, + profile_id: &row.profile_id, + owner_user_id: &row.owner_user_id, + }; + insert_src_grant(&mut grants, &scope, Some(&row.cover_image_src)); + collect_json_grants(&mut grants, &scope, &row.draft_json); + } + + for row in ctx + .db + .big_fish_creation_session() + .by_big_fish_session_stage() + .filter(BigFishCreationStage::Published) + .filter(|row| row.visible) + { + let scope = PublicWorkAssetGrantScope { + source_type: SOURCE_TYPE_BIG_FISH, + profile_id: &row.session_id, + owner_user_id: &row.owner_user_id, + }; + for slot in ctx + .db + .big_fish_asset_slot() + .by_big_fish_asset_session_id() + .filter(&row.session_id) + { + insert_src_grant(&mut grants, &scope, slot.asset_url.as_deref()); + } + } + + for row in ctx + .db + .bark_battle_published_config() + .by_bark_battle_published_owner_user_id() + .filter(""..) + .filter(|row| row.visible) + { + let scope = PublicWorkAssetGrantScope { + source_type: SOURCE_TYPE_BARK_BATTLE, + profile_id: &row.work_id, + owner_user_id: &row.owner_user_id, + }; + collect_json_grants(&mut grants, &scope, &row.published_snapshot_json); + } + + grants.into_values().collect() +} + +pub(crate) fn asset_object_has_public_work_read_grant( + ctx: &ReducerContext, + asset_object: &AssetObjectUpsertSnapshot, +) -> bool { + let asset_object = module_assets::build_asset_object_record(asset_object.clone()); + let view_context = ctx.as_anonymous_read_only(); + + public_work_asset_read_grant(&view_context) + .into_iter() + .any(|grant| { + module_assets::asset_object_matches_public_read_grant( + &asset_object, + &module_assets::PublicAssetReadGrant { + owner_user_id: grant.owner_user_id, + asset_object_id: grant.asset_object_id, + object_key: grant.object_key, + }, + ) + }) +} + +fn collect_json_grants( + grants: &mut BTreeMap, + scope: &PublicWorkAssetGrantScope<'_>, + raw: &str, +) { + let Ok(value) = serde_json::from_str::(raw) else { + return; + }; + collect_json_value_grants(grants, scope, &value); +} + +fn collect_custom_world_json_grants( + grants: &mut BTreeMap, + scope: &PublicWorkAssetGrantScope<'_>, + raw: &str, +) { + let Ok(JsonValue::Object(profile)) = serde_json::from_str::(raw) else { + return; + }; + + insert_src_grant( + grants, + scope, + profile.get("coverImageSrc").and_then(JsonValue::as_str), + ); + for key in [ + "cover", + "camp", + "playableNpcs", + "storyNpcs", + "landmarks", + "chapters", + "sceneChapters", + "sceneChapterBlueprints", + "openingCg", + ] { + if let Some(value) = profile.get(key) { + collect_json_value_grants(grants, scope, value); + } + } +} + +fn collect_json_value_grants( + grants: &mut BTreeMap, + scope: &PublicWorkAssetGrantScope<'_>, + value: &JsonValue, +) { + match value { + JsonValue::Array(items) => { + for item in items { + collect_json_value_grants(grants, scope, item); + } + } + JsonValue::Object(fields) => { + for (key, value) in fields { + let normalized_key = normalize_json_key(key); + if should_skip_public_asset_field(&normalized_key) { + continue; + } + if normalized_key.ends_with("assetobjectid") { + if let Some(value) = value.as_str() { + insert_asset_object_id_grant(grants, scope, Some(value)); + } + continue; + } + if normalized_key.ends_with("objectkey") { + if let Some(value) = value.as_str() { + insert_object_key_grant(grants, scope, Some(value)); + } + continue; + } + if is_public_asset_src_field(&normalized_key) { + if let Some(value) = value.as_str() { + insert_src_grant(grants, scope, Some(value)); + } + continue; + } + collect_json_value_grants(grants, scope, value); + } + } + _ => {} + } +} + +fn normalize_json_key(key: &str) -> String { + key.chars() + .filter(|character| character.is_ascii_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn should_skip_public_asset_field(normalized_key: &str) -> bool { + normalized_key.contains("reference") + || normalized_key == "candidates" + || normalized_key == "generationinputs" + || normalized_key == "sourceassetids" + || normalized_key.starts_with("sourceimage") +} + +fn is_public_asset_src_field(normalized_key: &str) -> bool { + normalized_key.ends_with("imagesrc") + || normalized_key.ends_with("audiosrc") + || normalized_key.ends_with("videosrc") + || normalized_key == "musicsrc" + || normalized_key == "ambientsoundsrc" + || normalized_key == "modelsrc" + || normalized_key.ends_with("asseturl") +} + +fn insert_asset_object_id_grant( + grants: &mut BTreeMap, + scope: &PublicWorkAssetGrantScope<'_>, + value: Option<&str>, +) { + let Some(asset_object_id) = normalize_non_empty(value) else { + return; + }; + insert_grant(grants, scope, Some(asset_object_id), None); +} + +fn insert_object_key_grant( + grants: &mut BTreeMap, + scope: &PublicWorkAssetGrantScope<'_>, + value: Option<&str>, +) { + let Some(object_key) = normalize_explicit_object_key(value) else { + return; + }; + insert_grant(grants, scope, None, Some(object_key)); +} + +fn insert_src_grant( + grants: &mut BTreeMap, + scope: &PublicWorkAssetGrantScope<'_>, + value: Option<&str>, +) { + let Some(object_key) = normalize_generated_asset_src(value) else { + return; + }; + insert_grant(grants, scope, None, Some(object_key)); +} + +fn insert_grant( + grants: &mut BTreeMap, + scope: &PublicWorkAssetGrantScope<'_>, + asset_object_id: Option, + object_key: Option, +) { + let domain_grant = module_assets::PublicAssetReadGrant { + owner_user_id: scope.owner_user_id.to_string(), + asset_object_id: asset_object_id.clone(), + object_key: object_key.clone(), + }; + let Some(grant_id) = module_assets::public_asset_read_grant_id(&domain_grant) else { + return; + }; + grants + .entry(grant_id.clone()) + .or_insert_with(|| PublicWorkAssetReadGrant { + grant_id, + source_type: scope.source_type.to_string(), + profile_id: scope.profile_id.to_string(), + owner_user_id: scope.owner_user_id.to_string(), + asset_object_id, + object_key, + }); +} + +fn normalize_non_empty(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +fn normalize_explicit_object_key(value: Option<&str>) -> Option { + let value = value?.trim().trim_start_matches('/'); + if value.is_empty() + || value.starts_with("data:") + || value.starts_with("blob:") + || value.starts_with("builtin:") + || value.starts_with("http://") + || value.starts_with("https://") + { + return None; + } + Some(value.to_string()) +} + +fn normalize_generated_asset_src(value: Option<&str>) -> Option { + let value = value?.trim(); + let object_key = if value.starts_with("http://") || value.starts_with("https://") { + let generated_path_index = value.find("/generated-")?; + &value[generated_path_index + 1..] + } else { + value.trim_start_matches('/') + }; + object_key + .starts_with("generated-") + .then(|| object_key.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn collect(raw: &str) -> Vec { + let mut grants = BTreeMap::new(); + collect_json_grants( + &mut grants, + &PublicWorkAssetGrantScope { + source_type: SOURCE_TYPE_PUZZLE, + profile_id: "puzzle-profile-001", + owner_user_id: "user-owner", + }, + raw, + ); + grants.into_values().collect() + } + + fn collect_custom_world(raw: &str) -> Vec { + let mut grants = BTreeMap::new(); + collect_custom_world_json_grants( + &mut grants, + &PublicWorkAssetGrantScope { + source_type: SOURCE_TYPE_CUSTOM_WORLD, + profile_id: "custom-world-profile-001", + owner_user_id: "user-owner", + }, + raw, + ); + grants.into_values().collect() + } + + #[test] + fn published_asset_collector_excludes_references_candidates_and_generation_inputs() { + let grants = collect( + r#"{ + "cover": { + "assetObjectId": "assetobj_cover", + "imageObjectKey": "generated-puzzle-assets/formal-cover.png" + }, + "backgroundMusic": { + "audioSrc": "/generated-audio/formal.mp3" + }, + "sceneAudio": { + "musicSrc": "/generated-audio/scene-music.mp3", + "ambientSoundSrc": "/generated-audio/scene-ambient.mp3" + }, + "modelSrc": "/generated-match3d-assets/formal-model.glb", + "pictureReference": { + "imageObjectKey": "generated-puzzle-assets/private-reference.png", + "musicSrc": "/generated-audio/private-reference.mp3" + }, + "candidates": [ + { "imageSrc": "/generated-puzzle-assets/unselected.png" } + ], + "generationInputs": { + "imageSrc": "/generated-puzzle-assets/private-input.png" + } + }"#, + ); + + assert!( + grants + .iter() + .any(|grant| { grant.asset_object_id.as_deref() == Some("assetobj_cover") }) + ); + assert!(grants.iter().any(|grant| { + grant.object_key.as_deref() == Some("generated-puzzle-assets/formal-cover.png") + })); + assert!( + grants + .iter() + .any(|grant| { grant.object_key.as_deref() == Some("generated-audio/formal.mp3") }) + ); + assert!(grants.iter().any(|grant| { + grant.object_key.as_deref() == Some("generated-audio/scene-music.mp3") + })); + assert!(grants.iter().any(|grant| { + grant.object_key.as_deref() == Some("generated-audio/scene-ambient.mp3") + })); + assert!(grants.iter().any(|grant| { + grant.object_key.as_deref() == Some("generated-match3d-assets/formal-model.glb") + })); + assert!(!grants.iter().any(|grant| { + grant + .object_key + .as_deref() + .is_some_and(|value| value.contains("private") || value.contains("unselected")) + })); + } + + #[test] + fn published_asset_collector_normalizes_generated_oss_urls() { + let grants = collect( + r#"{ + "imageSrc": "https://bucket.oss-cn.test.aliyuncs.com/generated-puzzle-assets/formal.png" + }"#, + ); + + assert_eq!( + grants[0].object_key.as_deref(), + Some("generated-puzzle-assets/formal.png") + ); + } + + #[test] + fn custom_world_collector_only_reads_known_published_structures() { + let grants = collect_custom_world( + r#"{ + "cover": { "imageSrc": "/generated-custom-world/formal-cover.png" }, + "playableNpcs": [ + { "imageSrc": "/generated-custom-world/formal-role.png" } + ], + "sceneChapterBlueprints": [ + { "acts": [{ "backgroundImageSrc": "/generated-custom-world/formal-scene.png" }] } + ], + "preview": { + "imageSrc": "/generated-custom-world/private-preview.png", + "objectKey": "generated-custom-world/private-preview-object.png" + }, + "editorState": { + "assetObjectId": "assetobj_private_editor_state" + } + }"#, + ); + + assert_eq!(grants.len(), 3); + assert!(grants.iter().all(|grant| { + grant + .object_key + .as_deref() + .is_some_and(|value| value.contains("formal")) + })); + } + + #[test] + fn published_asset_collector_deduplicates_reused_asset_per_owner() { + let mut grants = BTreeMap::new(); + let first_scope = PublicWorkAssetGrantScope { + source_type: SOURCE_TYPE_PUZZLE, + profile_id: "puzzle-profile-001", + owner_user_id: "user-owner", + }; + let second_scope = PublicWorkAssetGrantScope { + source_type: SOURCE_TYPE_PUZZLE, + profile_id: "puzzle-profile-002", + owner_user_id: "user-owner", + }; + insert_src_grant( + &mut grants, + &first_scope, + Some("/generated-puzzle-assets/reused.png"), + ); + insert_src_grant( + &mut grants, + &second_scope, + Some("/generated-puzzle-assets/reused.png"), + ); + + assert_eq!(grants.len(), 1); + } +} diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index 7dd00c720..4146a9c44 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -202,6 +202,10 @@ pub struct ProfileRedeemCode { pub(crate) created_by: String, pub(crate) created_at: Timestamp, pub(crate) updated_at: Timestamp, + #[default(None::)] + pub(crate) starts_at: Option, + #[default(None::)] + pub(crate) expires_at: Option, } #[spacetimedb::table( @@ -3753,6 +3757,7 @@ fn redeem_profile_reward_code_record( &build_profile_redeem_code_snapshot_from_row(&redeem_code), &user_id, user_used_count, + validated_input.redeemed_at_micros, ) .map_err(|error| error.to_string())?; @@ -3817,6 +3822,8 @@ fn admin_upsert_profile_redeem_code_record( input.enabled, input.allowed_user_ids, input.allowed_public_user_codes, + input.starts_at_micros, + input.expires_at_micros, input.updated_at_micros, ) .map_err(|error| error.to_string())?; @@ -3858,6 +3865,12 @@ fn admin_upsert_profile_redeem_code_record( created_by: validated_input.admin_user_id, created_at, updated_at, + starts_at: validated_input + .starts_at_micros + .map(Timestamp::from_micros_since_unix_epoch), + expires_at: validated_input + .expires_at_micros + .map(Timestamp::from_micros_since_unix_epoch), }; let inserted = ctx.db.profile_redeem_code().insert(row); insert_profile_code_operation( @@ -7193,6 +7206,12 @@ fn build_profile_redeem_code_snapshot_from_row( created_by: row.created_by.clone(), created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + starts_at_micros: row + .starts_at + .map(|value| value.to_micros_since_unix_epoch()), + expires_at_micros: row + .expires_at + .map(|value| value.to_micros_since_unix_epoch()), } } diff --git a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx index 90335a5c6..596ea1135 100644 --- a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx +++ b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.test.tsx @@ -58,6 +58,7 @@ function BasicGenerationHarness({ onSubmit={onSubmit} /> {dialog.prompt} + {dialog.assetLabel ?? '-'} {dialog.aspectRatio} {dialog.imageSize} {dialog.imageModel} @@ -86,17 +87,27 @@ describe('ImageCanvasBasicGenerationComposerView', () => { fireEvent.change(screen.getByLabelText('生成提示词'), { target: { value: '新的提示' }, }); + fireEvent.change(screen.getByLabelText('资源名称'), { + target: { value: '自定义主视觉' }, + }); const panel = screen.getByRole('dialog', { name: '生成图片' }); fireEvent.click(within(panel).getByRole('button', { name: '添加参考图' })); fireEvent.click(screen.getByRole('menuitem', { name: '上传图片' })); fireEvent.click(screen.getByRole('button', { name: '生成' })); expect(screen.getByLabelText('当前提示词').textContent).toBe('新的提示'); + expect(screen.getByLabelText('当前资源名称').textContent).toBe( + '自定义主视觉', + ); expect(screen.getByLabelText('当前状态').textContent).toBe('idle'); expect(screen.getByLabelText('当前错误').textContent).toBe('-'); expect(requestUpload).toHaveBeenCalledWith('generation-reference'); expect(submitGeneration).toHaveBeenCalledWith( - expect.objectContaining({ prompt: '新的提示', status: 'idle' }), + expect.objectContaining({ + prompt: '新的提示', + assetLabel: '自定义主视觉', + status: 'idle', + }), ); }); @@ -183,7 +194,7 @@ describe('ImageCanvasBasicGenerationComposerView', () => { expect(submitButton.textContent).toBe('生成5泥点'); }); - it('keeps quick edit to one prompt box and model selection', () => { + it('keeps quick edit to one prompt box with image parameters', () => { render( { ).toBeNull(); expect(within(panel).queryByText('旧参考图')).toBeNull(); expect( - within(panel).queryByRole('button', { - name: /快速编辑图片尺寸/u, + within(panel).getByRole('button', { + name: '快速编辑图片尺寸 1:1·1K', }), - ).toBeNull(); + ).toBeTruthy(); expect( within(panel).getByRole('button', { name: '快速编辑图片模型 gpt-image-2', @@ -234,22 +245,16 @@ describe('ImageCanvasBasicGenerationComposerView', () => { name: '生成图片尺寸 16:9·1K', }), ); - expect( - screen.getByRole('menu', { name: '生成图片尺寸选项' }), - ).toBeTruthy(); + expect(screen.getByRole('menu', { name: '生成图片尺寸选项' })).toBeTruthy(); fireEvent.click(screen.getByRole('textbox', { name: '生成提示词' })); - expect( - screen.queryByRole('menu', { name: '生成图片尺寸选项' }), - ).toBeNull(); + expect(screen.queryByRole('menu', { name: '生成图片尺寸选项' })).toBeNull(); }); it('does not render a standalone close button', () => { render(); - expect( - screen.queryByRole('button', { name: '关闭生成图片' }), - ).toBeNull(); + expect(screen.queryByRole('button', { name: '关闭生成图片' })).toBeNull(); }); }); diff --git a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx index 4a6a11b6f..e21d649cc 100644 --- a/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx +++ b/src/components/image-editor/ImageCanvasBasicGenerationComposerView.tsx @@ -18,6 +18,7 @@ import type { GenerateDialogState, UploadTarget, } from './ImageCanvasEditorTypes'; +import { ImageCanvasGenerationAssetNameField } from './ImageCanvasGenerationAssetNameField'; import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView'; import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot'; import { calculateEditorImageGenerationPrice } from './ImageCanvasGenerationModel'; @@ -140,19 +141,21 @@ export function ImageCanvasBasicGenerationComposerView({ const references = dialog.generationReferences ?? []; const isQuickEdit = dialog.mode === 'quick-edit'; const resolvedDialogLabel = - dialogLabel ?? optionLabelPrefix ?? (isQuickEdit ? '快速编辑图片' : '生成图片'); + dialogLabel ?? + optionLabelPrefix ?? + (isQuickEdit ? '快速编辑图片' : '生成图片'); const resolvedPromptLabel = promptLabel ?? (isQuickEdit ? '快速编辑提示词' : '生成提示词'); const resolvedPromptPlaceholder = promptPlaceholder ?? - (isQuickEdit ? '写下每个编号要怎么改' : '今天想生成什么画面?'); + (isQuickEdit ? '你希望素材如何修改?' : '今天想生成什么画面?'); const resolvedOptionLabelPrefix = optionLabelPrefix ?? (isQuickEdit ? '快速编辑图片' : '生成图片'); const resolvedReferenceButtonLabel = referenceButtonLabel; const resolvedReferenceButtonAriaLabel = referenceButtonAriaLabel ?? `添加${resolvedReferenceButtonLabel}`; const shouldIncludeReferences = isQuickEdit ? false : includeReferences; - const shouldIncludeDimensions = isQuickEdit ? false : includeDimensions; + const shouldIncludeDimensions = includeDimensions; const resolvedSubmitLabel = isQuickEdit && submitLabel === '生成' ? '修改' : submitLabel; const resolvedSubmitAriaLabel = @@ -265,6 +268,20 @@ export function ImageCanvasBasicGenerationComposerView({ ) } /> + + setGenerateDialog((currentDialog) => + currentDialog + ? { + ...resetFailedDialogStatus(currentDialog), + assetLabel, + } + : currentDialog, + ) + } + />
+ updatePanel({ assetLabel })} + />
{CHARACTER_ANIMATION_ACTION_PROMPTS.map((preset) => ( + {!isQuickEditUnsupportedAssetKind(imageContextMenuLayer) ? ( + + ) : null} @@ -234,7 +234,11 @@ describe('useImageCanvasAssetCanvasBridge', () => { }); expect(appendCanvasLayersWithResources).toHaveBeenCalledWith([ - expect.objectContaining({ sourceAssetId: 'asset-1' }), + expect.objectContaining({ + sourceAssetId: 'asset-1', + x: 75, + y: 190, + }), ]); expect(screen.getByTestId('drag').textContent).toBe('none'); expect(screen.getByTestId('drop-target').textContent).toBe('-'); diff --git a/src/components/image-editor/useImageCanvasAssetCanvasBridge.ts b/src/components/image-editor/useImageCanvasAssetCanvasBridge.ts index fd93a6006..73148f038 100644 --- a/src/components/image-editor/useImageCanvasAssetCanvasBridge.ts +++ b/src/components/image-editor/useImageCanvasAssetCanvasBridge.ts @@ -139,6 +139,7 @@ export function useImageCanvasAssetCanvasBridge({ x: position?.x ?? canvasSize.width / 2, y: position?.y ?? canvasSize.height / 2, }, + { applyCascadeOffset: position === undefined }, ); captureCanvasHistory(); appendCanvasLayersWithResources([nextLayer]); diff --git a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx index 2d548bc06..dadeda990 100644 --- a/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx @@ -166,6 +166,7 @@ function createQueueState( phaseDetail: string; progress: number; error: string | null; + warning: string | null; updatedAtMicros: number; }> = {}, ) { @@ -223,6 +224,9 @@ function SubmissionWorkflowHarness({ useState( initialCharacterAnimationPanel, ); + const [generationWarning, setGenerationWarning] = useState( + null, + ); const rememberedImageModelRef = useRef(null); const fitLayersMockRef = useRef(vi.fn()); const layerCounterRef = useRef(0); @@ -284,6 +288,7 @@ function SubmissionWorkflowHarness({ applyProjectSnapshot, onQueuedGenerationTask, onWalletBalanceMayHaveChanged, + onGenerationWarning: setGenerationWarning, }); return ( @@ -328,6 +333,7 @@ function SubmissionWorkflowHarness({ {rememberedImageModelRef.current ?? '-'} + {generationWarning ?? '-'} diff --git a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx index d4a5eacfa..0905ad930 100644 --- a/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx +++ b/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx @@ -29,6 +29,7 @@ const generateEditorSoundEffectMock = vi.hoisted(() => vi.fn()); const generateEditorBackgroundMusicMock = vi.hoisted(() => vi.fn()); const editEditorImageMock = vi.hoisted(() => vi.fn()); const createEditorProjectResourceMock = vi.hoisted(() => vi.fn()); +const splitEditorIconSpritesheetMock = vi.hoisted(() => vi.fn()); const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn()); const renderCropExpandImageMock = vi.hoisted(() => vi.fn()); const removeImageBackgroundMock = vi.hoisted(() => vi.fn()); @@ -60,6 +61,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => { generateEditorIconSpritesheet: generateEditorIconSpritesheetMock, generateEditorImage: generateEditorImageMock, generateEditorSoundEffect: generateEditorSoundEffectMock, + splitEditorIconSpritesheet: splitEditorIconSpritesheetMock, }; }); @@ -245,7 +247,7 @@ function GenerationWorkflowHarness({ {activeDialog?.uiDesignSpecReference?.label ?? '-'} - {workflow.referencePickWarning ?? '-'} + {workflow.generationWarning ?? '-'} {workflow.quickEditPanel @@ -280,6 +282,9 @@ function GenerationWorkflowHarness({ ? `${workflow.characterAnimationPanel.sourceLayerId}:${workflow.characterAnimationPanel.status}` : '-'} + + {`${workflow.characterAnimationPanel?.assetLabel ?? '-'}:${activeDialog?.assetLabel ?? '-'}`} + {workflow.characterAnimationPrice} @@ -507,6 +512,12 @@ function GenerationWorkflowHarness({ > 去除背景 + +
) : null} - +
{tabPanels} diff --git a/src/index.css b/src/index.css index 8df8a0ccb..d8c13ac58 100644 --- a/src/index.css +++ b/src/index.css @@ -5714,14 +5714,19 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__audio-card-playback { position: absolute; - inset: 0; + left: 50%; + top: 50%; z-index: 1; display: grid; + width: 2.75rem; + height: 2.75rem; place-items: center; border: 0; + border-radius: 999px; background: transparent; color: rgba(255, 255, 255, 0.9); cursor: pointer; + transform: translate(-50%, -50%); } .image-canvas-editor__audio-card-playback:hover { @@ -6335,6 +6340,11 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { box-shadow: 0 8px 18px rgba(13, 148, 136, 0.24); } +.image-canvas-editor__kind-badge--source-image { + background: rgba(71, 85, 105, 0.9); + box-shadow: 0 8px 18px rgba(71, 85, 105, 0.22); +} + .image-canvas-editor__kind-badge--ui-design { background: rgba(245, 158, 11, 0.94); box-shadow: 0 8px 18px rgba(245, 158, 11, 0.24); @@ -7236,6 +7246,18 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { color: #94a3b8; } +.image-canvas-editor__generation-asset-name-field { + display: grid; + grid-column: 1 / -1; + gap: 0.24rem; + min-width: 0; +} + +.image-canvas-editor__generation-asset-name-input { + width: 100%; + min-width: 0; +} + .image-canvas-editor__generation-prompt--borderless { border-color: transparent; background: transparent; @@ -8616,6 +8638,13 @@ button.image-canvas-editor__reference-chip:disabled { .image-canvas-editor__quick-edit-footer .image-canvas-editor__option-popover-anchor--model { grid-column: 3; + width: 9.75rem; +} + +.image-canvas-editor__quick-edit-footer + .image-canvas-editor__option-cluster--model { + width: 100%; + max-width: none; } .image-canvas-editor__quick-edit-footer @@ -9371,7 +9400,7 @@ button.image-canvas-editor__reference-chip:disabled { .image-canvas-editor__quick-edit-panel.image-canvas-editor__generation-composer { width: auto; - bottom: 3.85rem; + bottom: calc(9rem + env(safe-area-inset-bottom, 0px)); } .image-canvas-editor__generation-composer { @@ -9393,6 +9422,13 @@ button.image-canvas-editor__reference-chip:disabled { align-items: stretch; } + .image-canvas-editor__option-popover-anchor--dimensions, + .image-canvas-editor__option-popover-anchor--model { + grid-column: auto; + width: 100%; + justify-self: stretch; + } + .image-canvas-editor__option-cluster--dimensions, .image-canvas-editor__option-cluster--model, .image-canvas-editor__readonly-generation-option, @@ -9556,6 +9592,12 @@ button.image-canvas-editor__reference-chip:disabled { overflow-x: auto; } + .image-canvas-editor__task-sidebar { + position: fixed; + top: 7.25rem; + right: 0.75rem; + } + .image-canvas-editor__bottom-toolbar { left: 0.75rem; right: 0.75rem; diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index 1b8c34627..545d70efa 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -25,6 +25,7 @@ import { removeEditorImageBackground, renameEditorProject, saveEditorProjectLayout, + splitEditorIconSpritesheet, submitEditorAssetShowcase, toggleEditorShowcaseAssetLike, updateEditorAsset, @@ -896,6 +897,10 @@ describe('editorProjectClient', () => { spritesheetWidth: 512, spritesheetHeight: 512, iconImageSrcs: [], + sliceWarning: { + code: 'insufficient-connected-components', + reason: '连通域数量不足', + }, prompt: '图标素材 prompt', actualPrompt: '图标素材 prompt', model: 'gemini-3.1-flash-image-preview', @@ -907,9 +912,14 @@ describe('editorProjectClient', () => { referenceImageSrc: 'data:image/png;base64,spec', referenceImageSrcs: ['data:image/png;base64,icon-ref'], iconDescriptions: ['返回按钮', '设置按钮'], + assetLabel: '冒险游戏图标', }); expect(result.taskId).toBe('icon-spritesheet-task-1'); + expect(result.sliceWarning).toEqual({ + code: 'insufficient-connected-components', + reason: '连通域数量不足', + }); expect(requestJsonMock).toHaveBeenCalledWith( '/api/editor/icon-spritesheets/generations', expect.objectContaining({ @@ -920,6 +930,7 @@ describe('editorProjectClient', () => { referenceImageSrcs: ['data:image/png;base64,icon-ref'], iconDescriptions: ['返回按钮', '设置按钮'], model: 'gemini-3.1-flash-image-preview', + assetLabel: '冒险游戏图标', }), }), '生成图标素材失败', @@ -988,6 +999,79 @@ describe('editorProjectClient', () => { ); }); + it('splits an existing icon spritesheet into project assets', async () => { + const project = { + projectId: 'editor-project-1', + title: '未命名画布', + canvas: { + canvasId: 'editor-project-1:canvas:default', + projectId: 'editor-project-1', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + updatedAt: '2026-07-13T00:00:00.000Z', + }, + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-07-13T00:00:00.000Z', + }; + requestJsonMock.mockResolvedValueOnce({ + iconImageSrcs: [ + { + name: '素材 1', + imageSrc: '/generated/atlas/asset-001.png', + width: 128, + height: 128, + }, + ], + project, + }); + + const result = await splitEditorIconSpritesheet({ + projectId: 'editor-project-1', + sourceResourceId: 'resource-atlas-1', + assetFolderId: 'project', + canvasCompletion: { + title: '拆分图集', + placeholder: { + x: 120, + y: 80, + width: 360, + height: 360, + originalWidth: 1024, + originalHeight: 1024, + }, + }, + }); + + expect(result.project).toBe(project); + expect(requestJsonMock).toHaveBeenCalledWith( + '/api/editor/icon-spritesheets/slices', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + projectId: 'editor-project-1', + sourceResourceId: 'resource-atlas-1', + assetFolderId: 'project', + canvasCompletion: { + title: '拆分图集', + placeholder: { + x: 120, + y: 80, + width: 360, + height: 360, + originalWidth: 1024, + originalHeight: 1024, + }, + }, + }), + }), + '拆分图集失败', + ); + }); + it('extracts UI design assets through the dedicated backend BFF', async () => { requestJsonMock.mockResolvedValueOnce({ spritesheetImageSrc: 'data:image/png;base64,ui-sheet', @@ -1174,6 +1258,7 @@ describe('editorProjectClient', () => { frameCount: 32, durationSeconds: 4, model: 'seedance2.0-fast', + assetLabel: '勇者待机', }); expect(result.taskId).toBe('character-animation-1'); @@ -1193,6 +1278,7 @@ describe('editorProjectClient', () => { frameCount: 32, durationSeconds: 4, model: 'seedance2.0-fast', + assetLabel: '勇者待机', }), }), '生成角色动画失败', diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index 5c0ef5c53..d4d1e8360 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -12,6 +12,8 @@ const EDITOR_IMAGE_EDIT_API = '/api/editor/images/edits'; const EDITOR_BACKGROUND_REMOVAL_API = '/api/editor/images/background-removals'; const EDITOR_ICON_SPRITESHEET_GENERATION_API = '/api/editor/icon-spritesheets/generations'; +const EDITOR_ICON_SPRITESHEET_SLICE_API = + '/api/editor/icon-spritesheets/slices'; const EDITOR_UI_DESIGN_ASSET_EXTRACTION_API = '/api/editor/ui-designs/assets/extractions'; const EDITOR_CHARACTER_ANIMATION_GENERATION_API = @@ -201,9 +203,17 @@ export type EditorIconSpritesheetGenerationInput = { projectId?: string | null; generationInputs?: EditorAssetGenerationInputs | null; assetFolderId?: string | null; + assetLabel?: string | null; canvasCompletion?: EditorCanvasGenerationCompletionInput | null; }; +export type EditorIconSpritesheetSliceInput = { + projectId: string; + sourceResourceId: string; + assetFolderId?: string | null; + canvasCompletion: EditorCanvasGenerationCompletionInput; +}; + export type EditorUiDesignAssetExtractionInput = { sourceImageSrc: string; model?: string; @@ -291,11 +301,17 @@ export type EditorIconSpritesheetIconResult = { asset?: EditorAssetSnapshot | null; }; +export type EditorIconSpritesheetSliceWarning = { + code: string; + reason: string; +}; + export type EditorIconSpritesheetGenerationResult = { spritesheetImageSrc: string; spritesheetWidth: number; spritesheetHeight: number; iconImageSrcs: EditorIconSpritesheetIconResult[]; + sliceWarning?: EditorIconSpritesheetSliceWarning | null; prompt: string; actualPrompt?: string | null; model: string; @@ -308,6 +324,11 @@ export type EditorIconSpritesheetGenerationResult = { queueState?: ExternalGenerationJobStatusRecord | null; }; +export type EditorIconSpritesheetSliceResult = { + iconImageSrcs: EditorIconSpritesheetIconResult[]; + project: EditorProjectSnapshot; +}; + export type EditorCharacterAnimationResolution = '480p' | '720p'; export type EditorCharacterAnimationRatio = @@ -339,6 +360,8 @@ export type EditorCharacterAnimationGenerationInput = { canvasCompletion?: EditorCanvasGenerationCompletionInput | null; generationInputs?: EditorAssetGenerationInputs | null; sourceResourceId?: string | null; + assetFolderId?: string | null; + assetLabel?: string | null; }; export type EditorCharacterAnimationFrameResult = { @@ -914,6 +937,7 @@ export async function generateEditorIconSpritesheet( ? { generationInputs: input.generationInputs } : {}), ...(input.assetFolderId ? { assetFolderId: input.assetFolderId } : {}), + ...(input.assetLabel ? { assetLabel: input.assetLabel } : {}), ...(input.canvasCompletion ? { canvasCompletion: input.canvasCompletion } : {}), @@ -926,6 +950,21 @@ export async function generateEditorIconSpritesheet( ); } +export async function splitEditorIconSpritesheet( + input: EditorIconSpritesheetSliceInput, +) { + return requestJson( + EDITOR_ICON_SPRITESHEET_SLICE_API, + jsonRequest('POST', { + projectId: input.projectId, + sourceResourceId: input.sourceResourceId, + ...(input.assetFolderId ? { assetFolderId: input.assetFolderId } : {}), + canvasCompletion: input.canvasCompletion, + }), + '拆分图集失败', + ); +} + export async function extractEditorUiDesignAssets( input: EditorUiDesignAssetExtractionInput, ) {