合并 master 更新到 AI 游戏创作分支
合入 master 的画布 Agent、精选素材审核、外部编辑器 API 与认证投影更新 保留当前分支的 AI 游戏创作壳、配置项和 LLM 运行接口改造 解决图片编辑器、LLM、去背景完成、外部生成排序和文档冲突 沿用 master 的 SpacetimeDB auth_store_projection 迁移与生成绑定
This commit is contained in:
@@ -18,6 +18,12 @@ const checks = [
|
||||
includes: '--restart-service-after genarrative-external-generation-controller.service',
|
||||
reason: '生产冷备份恢复 SpacetimeDB 后必须显式拉起外部生成 worker controller。',
|
||||
},
|
||||
{
|
||||
file: 'deploy/systemd/genarrative-api.service',
|
||||
includes: 'Wants=network-online.target genarrative-external-generation-controller.service',
|
||||
reason:
|
||||
'生产 API service 启动时必须弱依赖拉起外部生成 worker controller,避免只恢复 API 后队列无人消费。',
|
||||
},
|
||||
{
|
||||
file: 'deploy/systemd/genarrative-database-backup.service',
|
||||
includes: 'ExecStart=/usr/bin/node -- /opt/genarrative/current/scripts/database-backup-to-oss.mjs --env-file',
|
||||
|
||||
@@ -621,10 +621,12 @@ function main() {
|
||||
const compareResult = compareTables(baseResult.tables, currentResult.tables);
|
||||
const changedFiles = getChangedFiles(baseRef);
|
||||
const sidecarFailures = checkSchemaSidecars(changedFiles, compareResult.schemaChanged);
|
||||
const compareFailures =
|
||||
compareResult.breakingChanged && allowBreaking ? [] : compareResult.failures;
|
||||
const failures = [
|
||||
...currentResult.failures,
|
||||
...baseResult.failures,
|
||||
...compareResult.failures,
|
||||
...compareFailures,
|
||||
...sidecarFailures,
|
||||
];
|
||||
|
||||
|
||||
@@ -464,9 +464,9 @@ GENARRATIVE_SPACETIME_POOL_SIZE=2
|
||||
GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS=15
|
||||
|
||||
GENARRATIVE_LLM_PROVIDER=openai-compatible
|
||||
GENARRATIVE_LLM_BASE_URL=
|
||||
GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1
|
||||
GENARRATIVE_LLM_API_KEY=
|
||||
GENARRATIVE_LLM_MODEL=
|
||||
GENARRATIVE_LLM_MODEL=gpt-5.4-mini
|
||||
VECTOR_ENGINE_BASE_URL=
|
||||
VECTOR_ENGINE_API_KEY=
|
||||
ALIYUN_OSS_BUCKET=
|
||||
|
||||
@@ -396,16 +396,21 @@ async function uploadArchive({archivePath, bucket, endpoint, objectKey, accessKe
|
||||
});
|
||||
|
||||
console.log(`[database-backup] 上传 OSS: oss://${bucket}/${objectKey}`);
|
||||
const response = await fetch(targetUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...headers,
|
||||
authorization,
|
||||
'content-length': String(fileStat.size),
|
||||
},
|
||||
body: createReadStream(archivePath),
|
||||
duplex: 'half',
|
||||
});
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(targetUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...headers,
|
||||
authorization,
|
||||
'content-length': String(fileStat.size),
|
||||
},
|
||||
body: createReadStream(archivePath),
|
||||
duplex: 'half',
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`OSS 上传请求失败: oss://${bucket}/${objectKey}`, {cause: error});
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
if (!response.ok) {
|
||||
@@ -584,7 +589,49 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
function formatErrorDetails(error) {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return '';
|
||||
}
|
||||
return ['code', 'errno', 'syscall', 'hostname', 'host', 'port', 'address']
|
||||
.map((field) => {
|
||||
const value = error[field];
|
||||
return value === undefined || value === null || value === '' ? '' : `${field}=${String(value)}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function describeError(error) {
|
||||
const lines = [];
|
||||
let current = error;
|
||||
for (let depth = 0; current && depth < 5; depth += 1) {
|
||||
const label = depth === 0 ? 'error' : `cause[${depth}]`;
|
||||
if (!(current instanceof Error)) {
|
||||
lines.push(`${label}: ${String(current)}`);
|
||||
break;
|
||||
}
|
||||
|
||||
lines.push(`${label}: ${current.name}: ${current.message}`);
|
||||
const details = formatErrorDetails(current);
|
||||
if (details) {
|
||||
lines.push(`${label} details: ${details}`);
|
||||
}
|
||||
if (current instanceof AggregateError) {
|
||||
current.errors.slice(0, 3).forEach((item, index) => {
|
||||
const itemText = item instanceof Error ? `${item.name}: ${item.message}` : String(item);
|
||||
const itemDetails = formatErrorDetails(item);
|
||||
lines.push(`${label}.errors[${index}]: ${itemText}${itemDetails ? ` (${itemDetails})` : ''}`);
|
||||
});
|
||||
}
|
||||
current = current.cause;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[database-backup] ${error instanceof Error ? error.message : String(error)}`);
|
||||
for (const line of describeError(error)) {
|
||||
console.error(`[database-backup] ${line}`);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
if (isCliEntry()) {
|
||||
runCli(process.argv.slice(2)).catch((error) => {
|
||||
console.error(
|
||||
`[repair-auth-public-user-codes] ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
export function repairAuthPublicUserCodesInMigration(migration) {
|
||||
if (!migration || !Array.isArray(migration.tables)) {
|
||||
throw new Error('迁移 JSON 必须包含 tables 数组。');
|
||||
}
|
||||
|
||||
const userTable = migration.tables.find(
|
||||
(table) => table?.name === 'user_account',
|
||||
);
|
||||
if (!userTable || !Array.isArray(userTable.rows)) {
|
||||
throw new Error('迁移 JSON 缺少 user_account 表。');
|
||||
}
|
||||
|
||||
const usedCodes = new Set();
|
||||
const usedUsernames = new Set();
|
||||
let maxPublicSequence = 0;
|
||||
let maxPhoneUsernameSequence = 0;
|
||||
|
||||
for (const row of userTable.rows) {
|
||||
const sequence = parsePublicUserCode(row?.public_user_code);
|
||||
if (sequence !== null) {
|
||||
maxPublicSequence = Math.max(maxPublicSequence, sequence);
|
||||
}
|
||||
const phoneUsernameSequence = parsePhoneUsername(row?.username);
|
||||
if (phoneUsernameSequence !== null) {
|
||||
maxPhoneUsernameSequence = Math.max(
|
||||
maxPhoneUsernameSequence,
|
||||
phoneUsernameSequence,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const repairedRows = [];
|
||||
for (const row of userTable.rows) {
|
||||
if (!row || typeof row !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const originalCode =
|
||||
typeof row.public_user_code === 'string' ? row.public_user_code : '';
|
||||
const publicSequence = parsePublicUserCode(originalCode);
|
||||
const normalizedCode =
|
||||
publicSequence === null ? '' : formatPublicUserCode(publicSequence);
|
||||
if (publicSequence === null || usedCodes.has(normalizedCode)) {
|
||||
const nextSequence = allocatePublicSequence();
|
||||
row.public_user_code = formatPublicUserCode(nextSequence);
|
||||
repairedRows.push({
|
||||
userId: readUserId(row),
|
||||
field: 'public_user_code',
|
||||
from: originalCode,
|
||||
to: row.public_user_code,
|
||||
});
|
||||
} else {
|
||||
row.public_user_code = normalizedCode;
|
||||
usedCodes.add(normalizedCode);
|
||||
}
|
||||
|
||||
const originalUsername =
|
||||
typeof row.username === 'string' ? row.username.trim() : '';
|
||||
if (!originalUsername || usedUsernames.has(originalUsername)) {
|
||||
const nextUsername = allocateUsername(row, originalUsername);
|
||||
row.username = nextUsername;
|
||||
repairedRows.push({
|
||||
userId: readUserId(row),
|
||||
field: 'username',
|
||||
from: originalUsername,
|
||||
to: nextUsername,
|
||||
});
|
||||
} else {
|
||||
row.username = originalUsername;
|
||||
usedUsernames.add(originalUsername);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
migration: {
|
||||
schema_version: migration.schema_version,
|
||||
exported_at_micros: migration.exported_at_micros,
|
||||
tables: [
|
||||
{
|
||||
name: 'user_account',
|
||||
rows: userTable.rows,
|
||||
},
|
||||
],
|
||||
},
|
||||
repairedRows,
|
||||
nextPublicSequence: maxPublicSequence + 1,
|
||||
};
|
||||
|
||||
function allocatePublicSequence() {
|
||||
let sequence = maxPublicSequence + 1;
|
||||
while (usedCodes.has(formatPublicUserCode(sequence))) {
|
||||
sequence += 1;
|
||||
}
|
||||
maxPublicSequence = sequence;
|
||||
usedCodes.add(formatPublicUserCode(sequence));
|
||||
return sequence;
|
||||
}
|
||||
|
||||
function allocateUsername(row, originalUsername) {
|
||||
let username;
|
||||
if (shouldUsePhoneUsername(row, originalUsername)) {
|
||||
do {
|
||||
maxPhoneUsernameSequence += 1;
|
||||
username = `phone_${String(maxPhoneUsernameSequence).padStart(8, '0')}`;
|
||||
} while (usedUsernames.has(username));
|
||||
} else {
|
||||
const base = sanitizeUsernameBase(
|
||||
originalUsername || row.display_name || 'user',
|
||||
);
|
||||
let suffix =
|
||||
parsePublicUserCode(row.public_user_code) ?? maxPublicSequence;
|
||||
do {
|
||||
username = `${base}_${String(suffix).padStart(8, '0')}`;
|
||||
suffix += 1;
|
||||
} while (usedUsernames.has(username));
|
||||
}
|
||||
usedUsernames.add(username);
|
||||
return username;
|
||||
}
|
||||
}
|
||||
|
||||
function parsePublicUserCode(value) {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const match = value
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.match(/^SY-?([0-9]{1,8})$/u);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const sequence = Number.parseInt(match[1], 10);
|
||||
return Number.isSafeInteger(sequence) && sequence > 0 ? sequence : null;
|
||||
}
|
||||
|
||||
function formatPublicUserCode(sequence) {
|
||||
return `SY-${String(sequence).padStart(8, '0')}`;
|
||||
}
|
||||
|
||||
function parsePhoneUsername(value) {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const match = value.trim().match(/^phone_([0-9]{1,8})$/u);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const sequence = Number.parseInt(match[1], 10);
|
||||
return Number.isSafeInteger(sequence) && sequence > 0 ? sequence : null;
|
||||
}
|
||||
|
||||
function shouldUsePhoneUsername(row, originalUsername) {
|
||||
const loginMethod =
|
||||
typeof row.login_method === 'string' ? row.login_method.trim() : '';
|
||||
return (
|
||||
/^phone_[0-9]{1,8}$/u.test(originalUsername) ||
|
||||
loginMethod === 'phone' ||
|
||||
loginMethod === 'password'
|
||||
);
|
||||
}
|
||||
|
||||
function sanitizeUsernameBase(value) {
|
||||
const sanitized = String(value)
|
||||
.trim()
|
||||
.replace(/[^A-Za-z0-9_]+/gu, '_')
|
||||
.replace(/^_+|_+$/gu, '');
|
||||
return sanitized || 'user';
|
||||
}
|
||||
|
||||
function readUserId(row) {
|
||||
return typeof row.user_id === 'string' && row.user_id.trim()
|
||||
? row.user_id
|
||||
: '<unknown>';
|
||||
}
|
||||
|
||||
async function runCli(argv) {
|
||||
const options = parseCliArgs(argv);
|
||||
const inputPath = path.resolve(options.in);
|
||||
const outputPath = path.resolve(options.out);
|
||||
const migration = JSON.parse(await readFile(inputPath, 'utf8'));
|
||||
const result = repairAuthPublicUserCodesInMigration(migration);
|
||||
|
||||
if (!options.dryRun) {
|
||||
await writeFile(
|
||||
outputPath,
|
||||
`${JSON.stringify(result.migration, null, 2)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[repair-auth-public-user-codes] ${options.dryRun ? 'dry-run' : `已写入 ${outputPath}`},修复 ${result.repairedRows.length} 项`,
|
||||
);
|
||||
if (result.repairedRows.length > 0) {
|
||||
console.table(result.repairedRows.slice(0, 50));
|
||||
if (result.repairedRows.length > 50) {
|
||||
console.log(
|
||||
`[repair-auth-public-user-codes] 仅展示前 50 项,共 ${result.repairedRows.length} 项。`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseCliArgs(argv) {
|
||||
const options = {
|
||||
in: '',
|
||||
out: '',
|
||||
dryRun: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
const readValue = (name) => {
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith('--')) {
|
||||
throw new Error(`${name} 缺少参数值。`);
|
||||
}
|
||||
index += 1;
|
||||
return value;
|
||||
};
|
||||
|
||||
if (arg === '--in') {
|
||||
options.in = readValue(arg);
|
||||
} else if (arg === '--out') {
|
||||
options.out = readValue(arg);
|
||||
} else if (arg === '--dry-run') {
|
||||
options.dryRun = true;
|
||||
} else {
|
||||
throw new Error(`未知参数: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.in) {
|
||||
throw new Error('必须传入 --in。');
|
||||
}
|
||||
if (!options.out && !options.dryRun) {
|
||||
throw new Error('非 dry-run 必须传入 --out。');
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function isCliEntry() {
|
||||
const entry = process.argv[1];
|
||||
return entry
|
||||
? import.meta.url === `file://${entry.replace(/\\/gu, '/')}`
|
||||
: false;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { repairAuthPublicUserCodesInMigration } from './repair-auth-public-user-codes.mjs';
|
||||
|
||||
function table(name: string, rows: unknown[]) {
|
||||
return { name, rows };
|
||||
}
|
||||
|
||||
describe('repairAuthPublicUserCodesInMigration', () => {
|
||||
it('keeps the first public code and reassigns duplicate codes and usernames', () => {
|
||||
const migration = {
|
||||
schema_version: 7,
|
||||
exported_at_micros: 1,
|
||||
tables: [
|
||||
table('user_account', [
|
||||
{
|
||||
user_id: 'user_a',
|
||||
public_user_code: 'SY-00000007',
|
||||
username: 'phone_00000008',
|
||||
login_method: 'phone',
|
||||
},
|
||||
{
|
||||
user_id: 'user_b',
|
||||
public_user_code: 'SY-00000007',
|
||||
username: 'phone_00000008',
|
||||
login_method: 'phone',
|
||||
},
|
||||
{
|
||||
user_id: 'user_c',
|
||||
public_user_code: 'SY00000009',
|
||||
username: 'phone_00000010',
|
||||
login_method: 'phone',
|
||||
},
|
||||
]),
|
||||
table('auth_identity', [{ identity_id: 'authi_phone_1' }]),
|
||||
],
|
||||
};
|
||||
|
||||
const result = repairAuthPublicUserCodesInMigration(migration);
|
||||
|
||||
expect(result.migration.tables).toHaveLength(1);
|
||||
expect(result.migration.tables[0].name).toBe('user_account');
|
||||
expect(result.migration.tables[0].rows).toMatchObject([
|
||||
{
|
||||
user_id: 'user_a',
|
||||
public_user_code: 'SY-00000007',
|
||||
username: 'phone_00000008',
|
||||
},
|
||||
{
|
||||
user_id: 'user_b',
|
||||
public_user_code: 'SY-00000010',
|
||||
username: 'phone_00000011',
|
||||
},
|
||||
{
|
||||
user_id: 'user_c',
|
||||
public_user_code: 'SY-00000009',
|
||||
username: 'phone_00000010',
|
||||
},
|
||||
]);
|
||||
expect(result.repairedRows).toEqual([
|
||||
{
|
||||
userId: 'user_b',
|
||||
field: 'public_user_code',
|
||||
from: 'SY-00000007',
|
||||
to: 'SY-00000010',
|
||||
},
|
||||
{
|
||||
userId: 'user_b',
|
||||
field: 'username',
|
||||
from: 'phone_00000008',
|
||||
to: 'phone_00000011',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('assigns missing public codes without colliding with existing codes', () => {
|
||||
const migration = {
|
||||
schema_version: 7,
|
||||
exported_at_micros: 1,
|
||||
tables: [
|
||||
table('user_account', [
|
||||
{
|
||||
user_id: 'user_a',
|
||||
public_user_code: '',
|
||||
username: '',
|
||||
login_method: 'password',
|
||||
},
|
||||
{
|
||||
user_id: 'user_b',
|
||||
public_user_code: 'SY-00000002',
|
||||
username: 'phone_00000003',
|
||||
login_method: 'phone',
|
||||
},
|
||||
]),
|
||||
],
|
||||
};
|
||||
|
||||
const result = repairAuthPublicUserCodesInMigration(migration);
|
||||
|
||||
expect(result.migration.tables[0].rows).toMatchObject([
|
||||
{
|
||||
user_id: 'user_a',
|
||||
public_user_code: 'SY-00000003',
|
||||
username: 'phone_00000004',
|
||||
},
|
||||
{
|
||||
user_id: 'user_b',
|
||||
public_user_code: 'SY-00000002',
|
||||
username: 'phone_00000003',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -83,32 +83,32 @@ const tests = [
|
||||
method: 'POST',
|
||||
path: '/v1/chat/completions',
|
||||
body: {
|
||||
model: 'gpt-4o',
|
||||
model: 'gpt-5.4-mini',
|
||||
messages: [{ role: 'user', content: '回复 ok,不要解释' }],
|
||||
max_tokens: 10,
|
||||
},
|
||||
},
|
||||
|
||||
// 3. Responses - Apimart 当前使用的协议
|
||||
// 3. Responses - 仅作兼容探测,creative_agent 默认走 Chat Completions
|
||||
{
|
||||
name: 'POST /v1/responses (Responses)',
|
||||
method: 'POST',
|
||||
path: '/v1/responses',
|
||||
body: {
|
||||
model: 'gpt-4o',
|
||||
model: 'gpt-5.4-mini',
|
||||
input: [
|
||||
{ role: 'user', content: [{ type: 'input_text', text: '回复 ok,不要解释' }] },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// 4. 测试 gpt-5 (creative_agent 模型)
|
||||
// 4. 测试 gpt-5.4-mini (creative_agent 模型)
|
||||
{
|
||||
name: 'POST /v1/chat/completions (gpt-5, Chat)',
|
||||
name: 'POST /v1/chat/completions (gpt-5.4-mini, Chat)',
|
||||
method: 'POST',
|
||||
path: '/v1/chat/completions',
|
||||
body: {
|
||||
model: 'gpt-5',
|
||||
model: 'gpt-5.4-mini',
|
||||
messages: [{ role: 'user', content: '回复 ok' }],
|
||||
max_tokens: 10,
|
||||
},
|
||||
@@ -120,7 +120,7 @@ const tests = [
|
||||
method: 'POST',
|
||||
path: '/v1/chat/completions',
|
||||
body: {
|
||||
model: 'gpt-4o',
|
||||
model: 'gpt-5.4-mini',
|
||||
messages: [
|
||||
{ role: 'system', content: '你是抓大鹅游戏编辑,只返回 JSON。' },
|
||||
{ role: 'user', content: '题材:水果。请生成 JSON:{"gameName":"水果切切乐","items":[{"name":"苹果","itemSize":"中"},{"name":"西瓜","itemSize":"大"}]}' },
|
||||
@@ -155,7 +155,7 @@ console.log(`=== 结果: ${pass}/${tests.length} 通过, ${fail}/${tests.length}
|
||||
// 结论
|
||||
if (pass >= 3) {
|
||||
console.log('\n✅ VectorEngine 支持 LLM 文本调用,可替代 Apimart。');
|
||||
console.log(' 将 .env.secrets.local 中 APIMART_BASE_URL 改为 VectorEngine 地址即可。');
|
||||
console.log(' 将 .env.secrets.local 中 VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY 配好即可。');
|
||||
} else if (pass <= 1) {
|
||||
console.log('\n❌ VectorEngine 不支持 LLM 文本调用。');
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user