feat: bootstrap commercial AI drama platform
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
|
||||
const KEY_PREFIX = "local-";
|
||||
|
||||
export function hashApiClientKey(value) {
|
||||
return createHash("sha256").update(String(value || "")).digest("hex");
|
||||
}
|
||||
|
||||
export function issueApiClientKey() {
|
||||
const secret = `${KEY_PREFIX}${randomBytes(24).toString("base64url")}`;
|
||||
return {
|
||||
secret,
|
||||
hash: hashApiClientKey(secret),
|
||||
prefix: secret.slice(0, 12)
|
||||
};
|
||||
}
|
||||
|
||||
export function apiClientStorageMarker(hash) {
|
||||
return `hash:${String(hash || "")}`;
|
||||
}
|
||||
|
||||
export function apiClientPreview(prefix) {
|
||||
const value = String(prefix || "");
|
||||
return value ? `${value}****` : "仅创建或轮换时显示一次";
|
||||
}
|
||||
+760
@@ -0,0 +1,760 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
||||
import { createPasswordRecord, dbAll, dbGet, dbRun, passwordHash } from "./db.mjs";
|
||||
import { hashApiClientKey } from "./api-client-secrets.mjs";
|
||||
|
||||
const SESSION_TTL_MS = 8 * 60 * 60 * 1000;
|
||||
const MAX_FAILED_ATTEMPTS = 5;
|
||||
const LOCKOUT_MS = 15 * 60 * 1000;
|
||||
const MFA_STEP_MS = 30 * 1000;
|
||||
const MFA_CHALLENGE_TTL_MS = 5 * 60 * 1000;
|
||||
const MFA_MAX_CHALLENGE_ATTEMPTS = 5;
|
||||
const MFA_STORAGE_KEY = scryptSync(process.env.AI_DRAMA_MFA_ENCRYPTION_KEY || process.env.AI_DRAMA_SESSION_SECRET || "ai-drama-local-mfa-key-v1", "ai-drama-mfa", 32);
|
||||
const DEVICE_HASH_KEY = process.env.AI_DRAMA_DEVICE_HASH_KEY || process.env.AI_DRAMA_SESSION_SECRET || "ai-drama-local-device-key-v1";
|
||||
const MAX_DEVICE_ID_LENGTH = 256;
|
||||
const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
|
||||
function authError(status, code, message, details = {}) {
|
||||
const error = new Error(message);
|
||||
error.status = status;
|
||||
error.code = code;
|
||||
error.details = details;
|
||||
return error;
|
||||
}
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function boundedText(value, maxLength) {
|
||||
return String(value || "").slice(0, maxLength);
|
||||
}
|
||||
|
||||
function requestMetadata(metadata = {}) {
|
||||
return {
|
||||
ipAddress: boundedText(metadata.ipAddress, 128),
|
||||
userAgent: boundedText(metadata.userAgent, 512),
|
||||
deviceId: boundedText(metadata.deviceId, MAX_DEVICE_ID_LENGTH),
|
||||
deviceLabel: boundedText(metadata.deviceLabel, 120)
|
||||
};
|
||||
}
|
||||
|
||||
function hashDeviceValue(value) {
|
||||
return createHmac("sha256", DEVICE_HASH_KEY).update(String(value || "")).digest("hex");
|
||||
}
|
||||
|
||||
function normalizedDeviceId(value) {
|
||||
const candidate = String(value || "").trim();
|
||||
return candidate.length >= 8 && candidate.length <= MAX_DEVICE_ID_LENGTH ? candidate : "";
|
||||
}
|
||||
|
||||
function deviceFingerprint(metadata = {}) {
|
||||
const request = requestMetadata(metadata);
|
||||
return hashDeviceValue(request.userAgent);
|
||||
}
|
||||
|
||||
function riskLevelForScore(score) {
|
||||
if (score >= 75) return "high";
|
||||
if (score >= 45) return "medium";
|
||||
return "low";
|
||||
}
|
||||
|
||||
function devicePayload(row) {
|
||||
if (!row?.auth_device_id && !row?.device_id) return null;
|
||||
const trusted = Boolean(row.trusted_at);
|
||||
const revoked = Boolean(row.revoked_at);
|
||||
return {
|
||||
id: row.auth_device_id || row.device_id,
|
||||
label: row.device_label || row.label || "浏览器设备",
|
||||
firstSeenAt: row.device_first_seen_at || row.first_seen_at || null,
|
||||
lastSeenAt: row.device_last_seen_at || row.last_seen_at || null,
|
||||
lastIpAddress: row.device_last_ip_address || row.last_ip_address || "",
|
||||
userAgent: row.device_last_user_agent || row.last_user_agent || "",
|
||||
trustedAt: row.trusted_at || null,
|
||||
revokedAt: row.revoked_at || null,
|
||||
status: revoked ? "revoked" : trusted ? "trusted" : "known",
|
||||
activeSessionCount: Number(row.active_session_count || 0),
|
||||
latestRiskLevel: row.latest_risk_level || "medium",
|
||||
latestRiskScore: Number(row.latest_risk_score ?? 50)
|
||||
};
|
||||
}
|
||||
|
||||
function registerAuthDevice(userId, metadata = {}) {
|
||||
const request = requestMetadata(metadata);
|
||||
const rawDeviceId = normalizedDeviceId(request.deviceId);
|
||||
if (!rawDeviceId) {
|
||||
return {
|
||||
device: null,
|
||||
riskLevel: "high",
|
||||
riskScore: 85,
|
||||
firstSeen: false,
|
||||
ipChanged: false,
|
||||
fingerprintChanged: false
|
||||
};
|
||||
}
|
||||
|
||||
const deviceKeyHash = hashDeviceValue(rawDeviceId);
|
||||
const fingerprintHash = deviceFingerprint(request);
|
||||
const existing = dbGet("SELECT * FROM auth_devices WHERE user_id = ? AND device_key_hash = ?", [userId, deviceKeyHash]);
|
||||
const timestamp = nowIso();
|
||||
const ipChanged = Boolean(existing?.last_ip_address && request.ipAddress && existing.last_ip_address !== request.ipAddress);
|
||||
const fingerprintChanged = Boolean(existing?.fingerprint_hash && existing.fingerprint_hash !== fingerprintHash);
|
||||
const firstSeen = !existing;
|
||||
let device;
|
||||
|
||||
if (existing) {
|
||||
dbRun(
|
||||
"UPDATE auth_devices SET label = ?, fingerprint_hash = ?, last_seen_at = ?, last_ip_address = ?, last_user_agent = ?, updated_at = ? WHERE id = ? AND user_id = ?",
|
||||
[request.deviceLabel || existing.label || "浏览器设备", fingerprintHash, timestamp, request.ipAddress, request.userAgent, timestamp, existing.id, userId]
|
||||
);
|
||||
device = dbGet("SELECT * FROM auth_devices WHERE id = ?", [existing.id]);
|
||||
} else {
|
||||
const id = `device-${Date.now()}-${randomBytes(4).toString("hex")}`;
|
||||
dbRun(
|
||||
"INSERT INTO auth_devices(id, user_id, device_key_hash, fingerprint_hash, label, first_seen_at, last_seen_at, last_ip_address, last_user_agent, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
[id, userId, deviceKeyHash, fingerprintHash, request.deviceLabel || "浏览器设备", timestamp, timestamp, request.ipAddress, request.userAgent, timestamp, timestamp]
|
||||
);
|
||||
device = dbGet("SELECT * FROM auth_devices WHERE id = ?", [id]);
|
||||
}
|
||||
|
||||
let riskScore = firstSeen ? 75 : device.trusted_at ? 10 : 50;
|
||||
if (device.revoked_at) riskScore = 90;
|
||||
if (ipChanged) riskScore += 20;
|
||||
if (fingerprintChanged) riskScore += 15;
|
||||
riskScore = Math.min(100, riskScore);
|
||||
|
||||
if (firstSeen) {
|
||||
recordSecurityEvent({
|
||||
userId,
|
||||
eventType: "device.first_seen",
|
||||
result: "success",
|
||||
...request,
|
||||
metadata: { deviceRecordId: device.id, label: device.label, riskLevel: riskLevelForScore(riskScore), riskScore }
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
device,
|
||||
riskLevel: riskLevelForScore(riskScore),
|
||||
riskScore,
|
||||
firstSeen,
|
||||
ipChanged,
|
||||
fingerprintChanged
|
||||
};
|
||||
}
|
||||
|
||||
function safeSecurityMetadata(metadata = {}) {
|
||||
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return {};
|
||||
return Object.fromEntries(Object.entries(metadata).filter(([key]) => !/(password|passcode|otp|code|token|secret|api.?key)/i.test(key)));
|
||||
}
|
||||
|
||||
export function recordSecurityEvent({ userId = null, eventType, result = "success", ipAddress = "", userAgent = "", metadata = {} } = {}) {
|
||||
if (!eventType) return null;
|
||||
const timestamp = nowIso();
|
||||
const id = `sec-${Date.now()}-${randomBytes(5).toString("hex")}`;
|
||||
try {
|
||||
dbRun(
|
||||
"INSERT INTO auth_security_events(id, user_id, event_type, result, ip_address, user_agent, metadata_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
[id, userId || null, String(eventType), String(result || "success"), boundedText(ipAddress, 128), boundedText(userAgent, 512), JSON.stringify(safeSecurityMetadata(metadata)), timestamp]
|
||||
);
|
||||
return id;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseSecurityMetadata(value) {
|
||||
try {
|
||||
return JSON.parse(value || "{}");
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function listSecurityEvents(userId = null, options = {}) {
|
||||
const limit = Math.min(200, Math.max(1, Number(options.limit || 80)));
|
||||
const eventType = String(options.eventType || "").trim();
|
||||
const clauses = [];
|
||||
const params = [];
|
||||
if (userId) {
|
||||
clauses.push("e.user_id = ?");
|
||||
params.push(userId);
|
||||
}
|
||||
if (eventType) {
|
||||
clauses.push("e.event_type = ?");
|
||||
params.push(eventType);
|
||||
}
|
||||
const rows = dbAll(
|
||||
`SELECT e.id, e.user_id, e.event_type, e.result, e.ip_address, e.user_agent, e.metadata_json, e.created_at,
|
||||
u.display_name AS user_display_name, u.email AS user_email
|
||||
FROM auth_security_events e
|
||||
LEFT JOIN users u ON u.id = e.user_id
|
||||
${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""}
|
||||
ORDER BY e.created_at DESC LIMIT ?`,
|
||||
[...params, limit]
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
userDisplayName: row.user_display_name || "",
|
||||
userEmail: row.user_email || "",
|
||||
eventType: row.event_type,
|
||||
result: row.result,
|
||||
ipAddress: row.ip_address || "",
|
||||
userAgent: row.user_agent || "",
|
||||
metadata: parseSecurityMetadata(row.metadata_json),
|
||||
createdAt: row.created_at
|
||||
}));
|
||||
}
|
||||
|
||||
function hashToken(token) {
|
||||
return createHash("sha256").update(token).digest("hex");
|
||||
}
|
||||
|
||||
function readToken(headers) {
|
||||
const authorization = headers.authorization || headers.Authorization;
|
||||
if (authorization && /^Bearer\s+/i.test(authorization)) return authorization.replace(/^Bearer\s+/i, "").trim();
|
||||
const sessionToken = headers["x-session-token"];
|
||||
return Array.isArray(sessionToken) ? sessionToken[0] : sessionToken;
|
||||
}
|
||||
|
||||
export function safeUser(user) {
|
||||
if (!user) return null;
|
||||
return {
|
||||
id: user.id,
|
||||
display_name: user.display_name,
|
||||
email: user.email,
|
||||
avatar_color: user.avatar_color,
|
||||
status: user.status
|
||||
};
|
||||
}
|
||||
|
||||
function encodeBase32(buffer) {
|
||||
let value = 0;
|
||||
let bits = 0;
|
||||
let output = "";
|
||||
for (const byte of buffer) {
|
||||
value = (value << 8) | byte;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
bits -= 5;
|
||||
output += BASE32_ALPHABET[(value >> bits) & 31];
|
||||
}
|
||||
}
|
||||
if (bits > 0) output += BASE32_ALPHABET[(value << (5 - bits)) & 31];
|
||||
return output;
|
||||
}
|
||||
|
||||
function decodeBase32(value) {
|
||||
const normalized = String(value || "").toUpperCase().replace(/=+$/g, "").replace(/[^A-Z2-7]/g, "");
|
||||
let bits = 0;
|
||||
let buffer = 0;
|
||||
const output = [];
|
||||
for (const character of normalized) {
|
||||
buffer = (buffer << 5) | BASE32_ALPHABET.indexOf(character);
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
output.push((buffer >> bits) & 0xff);
|
||||
}
|
||||
}
|
||||
return Buffer.from(output);
|
||||
}
|
||||
|
||||
function encryptMfaSecret(secret) {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv("aes-256-gcm", MFA_STORAGE_KEY, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(String(secret), "utf8"), cipher.final()]);
|
||||
return [iv, cipher.getAuthTag(), encrypted].map((part) => part.toString("base64url")).join(".");
|
||||
}
|
||||
|
||||
function decryptMfaSecret(payload) {
|
||||
const [ivValue, tagValue, encryptedValue] = String(payload || "").split(".");
|
||||
if (!ivValue || !tagValue || !encryptedValue) throw authError(500, "mfa_secret_invalid", "MFA 密钥存储记录无效");
|
||||
const decipher = createDecipheriv("aes-256-gcm", MFA_STORAGE_KEY, Buffer.from(ivValue, "base64url"));
|
||||
decipher.setAuthTag(Buffer.from(tagValue, "base64url"));
|
||||
return Buffer.concat([decipher.update(Buffer.from(encryptedValue, "base64url")), decipher.final()]).toString("utf8");
|
||||
}
|
||||
|
||||
function hotp(secret, counter) {
|
||||
const counterBuffer = Buffer.alloc(8);
|
||||
counterBuffer.writeBigUInt64BE(BigInt(counter));
|
||||
const digest = createHmac("sha1", decodeBase32(secret)).update(counterBuffer).digest();
|
||||
const offset = digest[digest.length - 1] & 0x0f;
|
||||
const value = ((digest[offset] & 0x7f) << 24) | ((digest[offset + 1] & 0xff) << 16) | ((digest[offset + 2] & 0xff) << 8) | (digest[offset + 3] & 0xff);
|
||||
return String(value % 1000000).padStart(6, "0");
|
||||
}
|
||||
|
||||
function validTotp(secret, code, timestamp = Date.now()) {
|
||||
const normalizedCode = String(code || "").replace(/\s/g, "");
|
||||
if (!/^\d{6}$/.test(normalizedCode)) return false;
|
||||
const counter = Math.floor(timestamp / MFA_STEP_MS);
|
||||
return [-1, 0, 1].some((offset) => hotp(secret, counter + offset) === normalizedCode);
|
||||
}
|
||||
|
||||
function identityPolicy() {
|
||||
return dbGet("SELECT * FROM identity_policies WHERE id = 'default'") || {
|
||||
password_login_enabled: 1,
|
||||
mfa_required_for_admins: 0,
|
||||
mfa_required_for_all: 0,
|
||||
session_ttl_hours: 8,
|
||||
max_sessions_per_user: 10
|
||||
};
|
||||
}
|
||||
|
||||
function userIsPrivileged(userId) {
|
||||
if (dbGet("SELECT user_id FROM system_admins WHERE user_id = ? AND status = 'active'", [userId])) return true;
|
||||
return Boolean(dbGet("SELECT user_id FROM organization_members WHERE user_id = ? AND role_key IN ('org_owner', 'org_admin') AND status = 'active' LIMIT 1", [userId]));
|
||||
}
|
||||
|
||||
export function mfaRequiredForUser(userId) {
|
||||
const policy = identityPolicy();
|
||||
return Boolean(policy.mfa_required_for_all || (policy.mfa_required_for_admins && userIsPrivileged(userId)));
|
||||
}
|
||||
|
||||
function passwordMatches(userId, password) {
|
||||
const credential = dbGet("SELECT * FROM user_credentials WHERE user_id = ?", [userId]);
|
||||
if (!credential) return false;
|
||||
const expected = Buffer.from(credential.password_hash, "hex");
|
||||
const actual = Buffer.from(passwordHash(password, credential.password_salt), "hex");
|
||||
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
||||
}
|
||||
|
||||
function clientIdentity(token) {
|
||||
const client = dbGet(
|
||||
`SELECT c.*, u.id AS user_id, u.display_name, u.email, u.avatar_color, u.status AS user_status
|
||||
FROM api_clients c
|
||||
JOIN users u ON u.id = c.created_by
|
||||
WHERE c.client_key_hash = ? AND c.status = 'active'`,
|
||||
[hashApiClientKey(token)]
|
||||
);
|
||||
if (!client) return null;
|
||||
if (client.user_status !== "active") throw authError(403, "api_client_owner_not_active", "API 客户端所属用户已停用");
|
||||
dbRun("UPDATE api_clients SET last_used_at = ?, updated_at = ? WHERE id = ?", [nowIso(), nowIso(), client.id]);
|
||||
let scopes = [];
|
||||
try { scopes = JSON.parse(client.scopes_json || "[]"); } catch { scopes = []; }
|
||||
return {
|
||||
userId: client.user_id,
|
||||
sessionId: null,
|
||||
apiClientId: client.id,
|
||||
apiClient: { id: client.id, organizationId: client.organization_id, workspaceId: client.workspace_id, scopes },
|
||||
user: safeUser({ ...client, id: client.user_id, status: client.user_status }),
|
||||
expiresAt: null
|
||||
};
|
||||
}
|
||||
|
||||
export function sessionIdentity(headers) {
|
||||
const token = readToken(headers);
|
||||
if (!token) return null;
|
||||
const tokenHash = hashToken(token);
|
||||
const session = dbGet(
|
||||
`SELECT s.*, u.id AS user_id, u.display_name, u.email, u.avatar_color, u.status AS user_status,
|
||||
d.id AS auth_device_id, d.label AS device_label, d.first_seen_at AS device_first_seen_at,
|
||||
d.last_seen_at AS device_last_seen_at, d.last_ip_address AS device_last_ip_address,
|
||||
d.last_user_agent AS device_last_user_agent, d.trusted_at, d.revoked_at
|
||||
FROM auth_sessions s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
LEFT JOIN auth_devices d ON d.id = s.device_id
|
||||
WHERE s.token_hash = ? AND s.revoked_at IS NULL`,
|
||||
[tokenHash]
|
||||
);
|
||||
if (!session) {
|
||||
const apiClient = clientIdentity(token);
|
||||
if (apiClient) return apiClient;
|
||||
throw authError(401, "session_invalid", "登录会话无效,请重新登录");
|
||||
}
|
||||
if (session.user_status !== "active") throw authError(403, "user_not_active", "当前用户已被停用");
|
||||
if (new Date(session.expires_at).getTime() <= Date.now()) {
|
||||
dbRun("UPDATE auth_sessions SET revoked_at = ?, last_seen_at = ? WHERE id = ?", [nowIso(), nowIso(), session.id]);
|
||||
throw authError(401, "session_expired", "登录会话已过期,请重新登录");
|
||||
}
|
||||
dbRun("UPDATE auth_sessions SET last_seen_at = ? WHERE id = ?", [nowIso(), session.id]);
|
||||
return {
|
||||
userId: session.user_id,
|
||||
sessionId: session.id,
|
||||
user: safeUser({ ...session, id: session.user_id, status: session.user_status }),
|
||||
expiresAt: session.expires_at,
|
||||
riskLevel: session.risk_level || "medium",
|
||||
riskScore: Number(session.risk_score ?? 50),
|
||||
device: devicePayload(session)
|
||||
};
|
||||
}
|
||||
|
||||
export function authenticate(email, password, metadata = {}) {
|
||||
const normalizedEmail = String(email || "").trim().toLowerCase();
|
||||
const request = requestMetadata(metadata);
|
||||
const user = dbGet("SELECT * FROM users WHERE lower(email) = ?", [normalizedEmail]);
|
||||
if (!user) {
|
||||
recordSecurityEvent({ eventType: "login.failure", result: "failure", ...request, metadata: { email: normalizedEmail, reason: "unknown_email" } });
|
||||
throw authError(401, "invalid_credentials", "邮箱或密码不正确");
|
||||
}
|
||||
const credential = dbGet("SELECT * FROM user_credentials WHERE user_id = ?", [user.id]);
|
||||
if (!credential) {
|
||||
recordSecurityEvent({ userId: user.id, eventType: "login.failure", result: "failure", ...request, metadata: { email: normalizedEmail, reason: "credential_missing" } });
|
||||
throw authError(401, "invalid_credentials", "邮箱或密码不正确");
|
||||
}
|
||||
if (credential.locked_until && new Date(credential.locked_until).getTime() > Date.now()) {
|
||||
recordSecurityEvent({ userId: user.id, eventType: "account.locked", result: "blocked", ...request, metadata: { email: normalizedEmail, reason: "lockout_active", lockedUntil: credential.locked_until } });
|
||||
throw authError(429, "account_locked", "登录失败次数过多,请稍后再试");
|
||||
}
|
||||
const valid = passwordMatches(user.id, password);
|
||||
const timestamp = nowIso();
|
||||
if (!valid) {
|
||||
const failedAttempts = Number(credential.failed_attempts || 0) + 1;
|
||||
const lockedUntil = failedAttempts >= MAX_FAILED_ATTEMPTS ? new Date(Date.now() + LOCKOUT_MS).toISOString() : null;
|
||||
dbRun("UPDATE user_credentials SET failed_attempts = ?, locked_until = ?, updated_at = ? WHERE user_id = ?", [failedAttempts, lockedUntil, timestamp, user.id]);
|
||||
recordSecurityEvent({ userId: user.id, eventType: "login.failure", result: "failure", ...request, metadata: { email: normalizedEmail, reason: "invalid_credentials", failedAttempts, lockoutTriggered: Boolean(lockedUntil) } });
|
||||
if (lockedUntil) recordSecurityEvent({ userId: user.id, eventType: "account.locked", result: "blocked", ...request, metadata: { email: normalizedEmail, reason: "failed_login_threshold", failedAttempts, lockedUntil } });
|
||||
throw authError(401, "invalid_credentials", "邮箱或密码不正确");
|
||||
}
|
||||
if (user.status !== "active") {
|
||||
recordSecurityEvent({ userId: user.id, eventType: "login.blocked", result: "blocked", ...request, metadata: { reason: "user_not_active", status: user.status } });
|
||||
throw authError(403, "user_not_active", "当前用户未激活或已停用");
|
||||
}
|
||||
if (!identityPolicy().password_login_enabled) {
|
||||
recordSecurityEvent({ userId: user.id, eventType: "login.blocked", result: "blocked", ...request, metadata: { reason: "password_login_disabled" } });
|
||||
throw authError(403, "password_login_disabled", "平台已关闭本地密码登录,请使用企业身份登录");
|
||||
}
|
||||
dbRun("UPDATE user_credentials SET failed_attempts = 0, locked_until = NULL, last_login_at = ?, updated_at = ? WHERE user_id = ?", [timestamp, timestamp, user.id]);
|
||||
return user;
|
||||
}
|
||||
|
||||
export function mfaStatus(userId) {
|
||||
const method = dbGet("SELECT id, method_type, label, enabled, setup_expires_at, last_used_at, created_at FROM user_mfa_methods WHERE user_id = ?", [userId]);
|
||||
return {
|
||||
enabled: Boolean(method?.enabled),
|
||||
method: method ? {
|
||||
id: method.id,
|
||||
type: method.method_type,
|
||||
label: method.label,
|
||||
enabled: Boolean(method.enabled),
|
||||
setupExpiresAt: method.setup_expires_at,
|
||||
lastUsedAt: method.last_used_at,
|
||||
createdAt: method.created_at
|
||||
} : null
|
||||
};
|
||||
}
|
||||
|
||||
export function startMfaSetup(userId, metadata = {}) {
|
||||
const current = dbGet("SELECT id, enabled FROM user_mfa_methods WHERE user_id = ?", [userId]);
|
||||
if (current?.enabled) throw authError(409, "mfa_already_enabled", "MFA 已经启用");
|
||||
const user = dbGet("SELECT email FROM users WHERE id = ?", [userId]);
|
||||
if (!user) throw authError(404, "user_not_found", "当前用户不存在");
|
||||
const id = current?.id || `mfa-${Date.now()}-${randomBytes(4).toString("hex")}`;
|
||||
const secret = encodeBase32(randomBytes(20));
|
||||
const timestamp = nowIso();
|
||||
const expiresAt = new Date(Date.now() + 10 * 60 * 1000).toISOString();
|
||||
if (current) {
|
||||
dbRun("UPDATE user_mfa_methods SET secret_ciphertext = ?, label = ?, enabled = 0, setup_expires_at = ?, updated_at = ? WHERE id = ? AND user_id = ?", [encryptMfaSecret(secret), "身份验证器", expiresAt, timestamp, id, userId]);
|
||||
} else {
|
||||
dbRun("INSERT INTO user_mfa_methods(id, user_id, method_type, label, secret_ciphertext, enabled, setup_expires_at, created_at, updated_at) VALUES (?, ?, 'totp', ?, ?, 0, ?, ?, ?)", [id, userId, "身份验证器", encryptMfaSecret(secret), expiresAt, timestamp, timestamp]);
|
||||
}
|
||||
const request = requestMetadata(metadata);
|
||||
recordSecurityEvent({ userId, eventType: "mfa.setup.started", result: "success", ...request, metadata: { methodId: id, expiresAt } });
|
||||
return {
|
||||
methodId: id,
|
||||
type: "totp",
|
||||
label: "身份验证器",
|
||||
secret,
|
||||
otpauthUrl: `otpauth://totp/${encodeURIComponent(user.email)}?secret=${secret}&issuer=${encodeURIComponent("AI短剧生产平台")}`,
|
||||
expiresAt
|
||||
};
|
||||
}
|
||||
|
||||
export function enableMfa(userId, methodId, code, metadata = {}) {
|
||||
const request = requestMetadata(metadata);
|
||||
const method = dbGet("SELECT * FROM user_mfa_methods WHERE id = ? AND user_id = ?", [methodId, userId]);
|
||||
if (!method) throw authError(404, "mfa_method_not_found", "MFA 初始化记录不存在");
|
||||
if (method.enabled) return mfaStatus(userId);
|
||||
if (method.setup_expires_at && new Date(method.setup_expires_at).getTime() <= Date.now()) {
|
||||
recordSecurityEvent({ userId, eventType: "mfa.failure", result: "failure", ...request, metadata: { reason: "setup_expired", methodId } });
|
||||
throw authError(410, "mfa_setup_expired", "MFA 初始化已过期,请重新生成密钥");
|
||||
}
|
||||
if (!validTotp(decryptMfaSecret(method.secret_ciphertext), code)) {
|
||||
recordSecurityEvent({ userId, eventType: "mfa.failure", result: "failure", ...request, metadata: { reason: "invalid_setup_code", methodId } });
|
||||
throw authError(400, "mfa_code_invalid", "验证码不正确");
|
||||
}
|
||||
const timestamp = nowIso();
|
||||
dbRun("UPDATE user_mfa_methods SET enabled = 1, setup_expires_at = NULL, last_used_at = ?, updated_at = ? WHERE id = ? AND user_id = ?", [timestamp, timestamp, methodId, userId]);
|
||||
recordSecurityEvent({ userId, eventType: "mfa.enabled", result: "success", ...request, metadata: { methodId, methodType: "totp" } });
|
||||
return mfaStatus(userId);
|
||||
}
|
||||
|
||||
export function cancelMfaSetup(userId, methodId, metadata = {}) {
|
||||
const method = dbGet("SELECT id, enabled FROM user_mfa_methods WHERE id = ? AND user_id = ?", [methodId, userId]);
|
||||
if (!method) throw authError(404, "mfa_method_not_found", "MFA 初始化记录不存在");
|
||||
if (method.enabled) throw authError(409, "mfa_already_enabled", "已启用的 MFA 不能通过取消初始化关闭");
|
||||
dbRun("DELETE FROM user_mfa_methods WHERE id = ? AND user_id = ? AND enabled = 0", [methodId, userId]);
|
||||
const request = requestMetadata(metadata);
|
||||
recordSecurityEvent({ userId, eventType: "mfa.setup.cancelled", result: "success", ...request, metadata: { methodId } });
|
||||
return mfaStatus(userId);
|
||||
}
|
||||
|
||||
export function disableMfa(userId, currentPassword, code, metadata = {}) {
|
||||
const request = requestMetadata(metadata);
|
||||
const method = dbGet("SELECT * FROM user_mfa_methods WHERE user_id = ? AND enabled = 1", [userId]);
|
||||
if (!method) return mfaStatus(userId);
|
||||
if (!passwordMatches(userId, currentPassword)) {
|
||||
recordSecurityEvent({ userId, eventType: "mfa.failure", result: "failure", ...request, metadata: { reason: "invalid_current_password", methodId: method.id } });
|
||||
throw authError(400, "current_password_invalid", "当前密码不正确");
|
||||
}
|
||||
if (!validTotp(decryptMfaSecret(method.secret_ciphertext), code)) {
|
||||
recordSecurityEvent({ userId, eventType: "mfa.failure", result: "failure", ...request, metadata: { reason: "invalid_disable_code", methodId: method.id } });
|
||||
throw authError(400, "mfa_code_invalid", "验证码不正确");
|
||||
}
|
||||
dbRun("DELETE FROM user_mfa_methods WHERE id = ? AND user_id = ?", [method.id, userId]);
|
||||
recordSecurityEvent({ userId, eventType: "mfa.disabled", result: "success", ...request, metadata: { methodId: method.id, methodType: "totp" } });
|
||||
return mfaStatus(userId);
|
||||
}
|
||||
|
||||
export function createMfaChallenge(userId, metadata = {}) {
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
const timestamp = nowIso();
|
||||
const expiresAt = new Date(Date.now() + MFA_CHALLENGE_TTL_MS).toISOString();
|
||||
dbRun("INSERT INTO auth_mfa_challenges(id, challenge_hash, user_id, expires_at, attempts, created_at) VALUES (?, ?, ?, ?, 0, ?)", [`mfa-challenge-${Date.now()}-${randomBytes(4).toString("hex")}`, hashToken(token), userId, expiresAt, timestamp]);
|
||||
const request = requestMetadata(metadata);
|
||||
recordSecurityEvent({ userId, eventType: "mfa.challenge.created", result: "challenge", ...request, metadata: { expiresAt } });
|
||||
return { token, expiresAt };
|
||||
}
|
||||
|
||||
export function createMfaEnrollmentChallenge(userId, metadata = {}) {
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
const timestamp = nowIso();
|
||||
const expiresAt = new Date(Date.now() + 10 * 60 * 1000).toISOString();
|
||||
dbRun("INSERT INTO auth_mfa_enrollment_challenges(id, challenge_hash, user_id, expires_at, created_at) VALUES (?, ?, ?, ?, ?)", [`mfa-enroll-${Date.now()}-${randomBytes(4).toString("hex")}`, hashToken(token), userId, expiresAt, timestamp]);
|
||||
const request = requestMetadata(metadata);
|
||||
recordSecurityEvent({ userId, eventType: "mfa.enrollment.challenge.created", result: "challenge", ...request, metadata: { expiresAt } });
|
||||
return { token, expiresAt };
|
||||
}
|
||||
|
||||
function enrollmentChallenge(token) {
|
||||
const challenge = dbGet("SELECT c.*, u.email, u.display_name, u.avatar_color, u.status FROM auth_mfa_enrollment_challenges c JOIN users u ON u.id = c.user_id WHERE c.challenge_hash = ?", [hashToken(token)]);
|
||||
if (!challenge || challenge.consumed_at) throw authError(401, "mfa_enrollment_invalid", "MFA 绑定挑战无效,请重新登录");
|
||||
if (new Date(challenge.expires_at).getTime() <= Date.now()) throw authError(401, "mfa_enrollment_expired", "MFA 绑定挑战已过期,请重新登录");
|
||||
if (challenge.status !== "active") throw authError(403, "user_not_active", "当前账号已停用");
|
||||
return challenge;
|
||||
}
|
||||
|
||||
export function startMfaEnrollment(token, metadata = {}) {
|
||||
const challenge = enrollmentChallenge(token);
|
||||
return { setup: startMfaSetup(challenge.user_id, metadata), user: safeUser(challenge) };
|
||||
}
|
||||
|
||||
export function completeMfaEnrollment(token, methodId, code, metadata = {}) {
|
||||
const challenge = enrollmentChallenge(token);
|
||||
const status = enableMfa(challenge.user_id, methodId, code, metadata);
|
||||
const timestamp = nowIso();
|
||||
dbRun("UPDATE auth_mfa_enrollment_challenges SET consumed_at = ? WHERE id = ?", [timestamp, challenge.id]);
|
||||
const request = requestMetadata(metadata);
|
||||
recordSecurityEvent({ userId: challenge.user_id, eventType: "mfa.enrollment.completed", result: "success", ...request, metadata: { methodId } });
|
||||
return { session: createSession(challenge.user_id, metadata), status };
|
||||
}
|
||||
|
||||
export function completeMfaChallenge(challengeToken, code, metadata = {}) {
|
||||
const request = requestMetadata(metadata);
|
||||
const challenge = dbGet("SELECT c.*, m.id AS method_id, m.secret_ciphertext FROM auth_mfa_challenges c JOIN user_mfa_methods m ON m.user_id = c.user_id AND m.enabled = 1 WHERE c.challenge_hash = ?", [hashToken(challengeToken)]);
|
||||
if (!challenge || challenge.consumed_at) {
|
||||
recordSecurityEvent({ eventType: "mfa.challenge.failure", result: "failure", ...request, metadata: { reason: "challenge_invalid" } });
|
||||
throw authError(401, "mfa_challenge_invalid", "MFA 验证挑战无效,请重新登录");
|
||||
}
|
||||
if (new Date(challenge.expires_at).getTime() <= Date.now()) {
|
||||
recordSecurityEvent({ userId: challenge.user_id, eventType: "mfa.challenge.failure", result: "failure", ...request, metadata: { reason: "challenge_expired" } });
|
||||
throw authError(401, "mfa_challenge_expired", "MFA 验证挑战已过期,请重新登录");
|
||||
}
|
||||
if (Number(challenge.attempts || 0) >= MFA_MAX_CHALLENGE_ATTEMPTS) {
|
||||
recordSecurityEvent({ userId: challenge.user_id, eventType: "mfa.challenge.failure", result: "blocked", ...request, metadata: { reason: "challenge_locked", attempts: challenge.attempts } });
|
||||
throw authError(429, "mfa_challenge_locked", "MFA 验证失败次数过多,请重新登录");
|
||||
}
|
||||
if (!validTotp(decryptMfaSecret(challenge.secret_ciphertext), code)) {
|
||||
const attempts = Number(challenge.attempts || 0) + 1;
|
||||
dbRun("UPDATE auth_mfa_challenges SET attempts = ?, consumed_at = CASE WHEN ? >= ? THEN ? ELSE consumed_at END WHERE id = ?", [attempts, attempts, MFA_MAX_CHALLENGE_ATTEMPTS, nowIso(), challenge.id]);
|
||||
recordSecurityEvent({ userId: challenge.user_id, eventType: "mfa.challenge.failure", result: attempts >= MFA_MAX_CHALLENGE_ATTEMPTS ? "blocked" : "failure", ...request, metadata: { reason: "invalid_code", attempts } });
|
||||
throw authError(attempts >= MFA_MAX_CHALLENGE_ATTEMPTS ? 429 : 401, "mfa_code_invalid", "验证码不正确");
|
||||
}
|
||||
const timestamp = nowIso();
|
||||
dbRun("UPDATE auth_mfa_challenges SET consumed_at = ? WHERE id = ?", [timestamp, challenge.id]);
|
||||
dbRun("UPDATE user_mfa_methods SET last_used_at = ?, updated_at = ? WHERE id = ?", [timestamp, timestamp, challenge.method_id]);
|
||||
recordSecurityEvent({ userId: challenge.user_id, eventType: "mfa.challenge.success", result: "success", ...request, metadata: { methodId: challenge.method_id } });
|
||||
return createSession(challenge.user_id, metadata);
|
||||
}
|
||||
|
||||
export function createSession(userId, metadata = {}) {
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
const timestamp = nowIso();
|
||||
const policy = identityPolicy();
|
||||
const ttlMs = Math.max(1, Number(policy.session_ttl_hours || SESSION_TTL_MS / 3600000)) * 60 * 60 * 1000;
|
||||
const expiresAt = new Date(Date.now() + ttlMs).toISOString();
|
||||
const id = `session-${Date.now()}-${randomBytes(4).toString("hex")}`;
|
||||
const device = registerAuthDevice(userId, metadata);
|
||||
dbRun("INSERT INTO auth_sessions(id, token_hash, user_id, expires_at, ip_address, user_agent, created_at, last_seen_at, device_id, risk_level, risk_score) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [
|
||||
id,
|
||||
hashToken(token),
|
||||
userId,
|
||||
expiresAt,
|
||||
String(metadata.ipAddress || ""),
|
||||
String(metadata.userAgent || ""),
|
||||
timestamp,
|
||||
timestamp,
|
||||
device.device?.id || null,
|
||||
device.riskLevel,
|
||||
device.riskScore
|
||||
]);
|
||||
const maxSessions = Math.max(1, Number(policy.max_sessions_per_user || 10));
|
||||
const staleSessions = dbAll("SELECT id FROM auth_sessions WHERE user_id = ? AND revoked_at IS NULL ORDER BY last_seen_at DESC", [userId]).slice(maxSessions);
|
||||
if (staleSessions.length) dbRun(`UPDATE auth_sessions SET revoked_at = ?, last_seen_at = ? WHERE id IN (${staleSessions.map(() => "?").join(",")})`, [timestamp, timestamp, ...staleSessions.map((row) => row.id)]);
|
||||
const request = requestMetadata(metadata);
|
||||
recordSecurityEvent({ userId, eventType: "session.created", result: "success", ...request, metadata: { sessionId: id, expiresAt, authMethod: metadata.authMethod || "session", deviceRecordId: device.device?.id || null, riskLevel: device.riskLevel, riskScore: device.riskScore, evictedSessionCount: staleSessions.length } });
|
||||
if (staleSessions.length) recordSecurityEvent({ userId, eventType: "session.revoked", result: "success", ...request, metadata: { reason: "session_limit", revokedSessionCount: staleSessions.length } });
|
||||
return { id, token, expiresAt, deviceId: device.device?.id || null, riskLevel: device.riskLevel, riskScore: device.riskScore };
|
||||
}
|
||||
|
||||
export function revokeSession(headers, metadata = {}) {
|
||||
const token = readToken(headers);
|
||||
if (!token) return false;
|
||||
const timestamp = nowIso();
|
||||
const session = dbGet("SELECT id, user_id FROM auth_sessions WHERE token_hash = ? AND revoked_at IS NULL", [hashToken(token)]);
|
||||
const result = dbRun("UPDATE auth_sessions SET revoked_at = ?, last_seen_at = ? WHERE token_hash = ? AND revoked_at IS NULL", [timestamp, timestamp, hashToken(token)]);
|
||||
const revoked = Number(result.changes || 0) > 0;
|
||||
if (revoked && session) recordSecurityEvent({ userId: session.user_id, eventType: "session.revoked", result: "success", ...requestMetadata(metadata), metadata: { sessionId: session.id, reason: metadata.reason || "logout" } });
|
||||
return revoked;
|
||||
}
|
||||
|
||||
function sessionPayload(row, currentSessionId) {
|
||||
const expired = new Date(row.expires_at).getTime() <= Date.now();
|
||||
return {
|
||||
id: row.id,
|
||||
createdAt: row.created_at,
|
||||
lastSeenAt: row.last_seen_at,
|
||||
expiresAt: row.expires_at,
|
||||
ipAddress: row.ip_address || "",
|
||||
userAgent: row.user_agent || "",
|
||||
revokedAt: row.revoked_at || null,
|
||||
deviceId: row.device_id || null,
|
||||
device: devicePayload(row),
|
||||
riskLevel: row.risk_level || "medium",
|
||||
riskScore: Number(row.risk_score ?? 50),
|
||||
current: row.id === currentSessionId,
|
||||
status: row.revoked_at ? "revoked" : expired ? "expired" : row.id === currentSessionId ? "current" : "active"
|
||||
};
|
||||
}
|
||||
|
||||
export function listUserSessions(userId, currentSessionId) {
|
||||
const rows = dbAll(
|
||||
`SELECT s.id, s.created_at, s.last_seen_at, s.expires_at, s.ip_address, s.user_agent, s.revoked_at,
|
||||
s.device_id, s.risk_level, s.risk_score,
|
||||
d.id AS auth_device_id, d.label AS device_label, d.first_seen_at AS device_first_seen_at,
|
||||
d.last_seen_at AS device_last_seen_at, d.last_ip_address AS device_last_ip_address,
|
||||
d.last_user_agent AS device_last_user_agent, d.trusted_at, d.revoked_at
|
||||
FROM auth_sessions s
|
||||
LEFT JOIN auth_devices d ON d.id = s.device_id
|
||||
WHERE s.user_id = ? ORDER BY s.last_seen_at DESC LIMIT 50`,
|
||||
[userId]
|
||||
);
|
||||
return rows.map((row) => sessionPayload(row, currentSessionId));
|
||||
}
|
||||
|
||||
export function listUserDevices(userId) {
|
||||
const timestamp = nowIso();
|
||||
const rows = dbAll(
|
||||
`SELECT d.*,
|
||||
(SELECT COUNT(*) FROM auth_sessions s WHERE s.device_id = d.id AND s.revoked_at IS NULL AND s.expires_at > ?) AS active_session_count,
|
||||
(SELECT s.risk_level FROM auth_sessions s WHERE s.device_id = d.id ORDER BY s.created_at DESC LIMIT 1) AS latest_risk_level,
|
||||
(SELECT s.risk_score FROM auth_sessions s WHERE s.device_id = d.id ORDER BY s.created_at DESC LIMIT 1) AS latest_risk_score
|
||||
FROM auth_devices d
|
||||
WHERE d.user_id = ?
|
||||
ORDER BY d.last_seen_at DESC`,
|
||||
[timestamp, userId]
|
||||
);
|
||||
return rows.map((row) => devicePayload({ ...row, auth_device_id: row.id }));
|
||||
}
|
||||
|
||||
function getUserDevice(userId, deviceId) {
|
||||
return dbGet("SELECT * FROM auth_devices WHERE id = ? AND user_id = ?", [deviceId, userId]);
|
||||
}
|
||||
|
||||
export function trustUserDevice(userId, deviceId, metadata = {}) {
|
||||
const device = getUserDevice(userId, deviceId);
|
||||
if (!device) throw authError(404, "device_not_found", "设备记录不存在");
|
||||
if (device.revoked_at) throw authError(409, "device_revoked", "已撤销的设备不能标记为信任");
|
||||
const timestamp = nowIso();
|
||||
dbRun("UPDATE auth_devices SET trusted_at = ?, updated_at = ? WHERE id = ? AND user_id = ?", [timestamp, timestamp, deviceId, userId]);
|
||||
recordSecurityEvent({ userId, eventType: "device.trusted", result: "success", ...requestMetadata(metadata), metadata: { deviceRecordId: deviceId } });
|
||||
return listUserDevices(userId).find((item) => item.id === deviceId) || null;
|
||||
}
|
||||
|
||||
export function untrustUserDevice(userId, deviceId, metadata = {}) {
|
||||
const device = getUserDevice(userId, deviceId);
|
||||
if (!device) throw authError(404, "device_not_found", "设备记录不存在");
|
||||
const timestamp = nowIso();
|
||||
dbRun("UPDATE auth_devices SET trusted_at = NULL, updated_at = ? WHERE id = ? AND user_id = ?", [timestamp, deviceId, userId]);
|
||||
recordSecurityEvent({ userId, eventType: "device.untrusted", result: "success", ...requestMetadata(metadata), metadata: { deviceRecordId: deviceId } });
|
||||
return listUserDevices(userId).find((item) => item.id === deviceId) || null;
|
||||
}
|
||||
|
||||
export function revokeUserSession(userId, sessionId, metadata = {}) {
|
||||
const result = dbRun("UPDATE auth_sessions SET revoked_at = ?, last_seen_at = ? WHERE id = ? AND user_id = ? AND revoked_at IS NULL", [nowIso(), nowIso(), sessionId, userId]);
|
||||
const revoked = Number(result.changes || 0) > 0;
|
||||
if (revoked) recordSecurityEvent({ userId, eventType: "session.revoked", result: "success", ...requestMetadata(metadata), metadata: { sessionId, reason: metadata.reason || "self_service" } });
|
||||
return revoked;
|
||||
}
|
||||
|
||||
export function revokeOtherUserSessions(userId, currentSessionId, metadata = {}) {
|
||||
const timestamp = nowIso();
|
||||
const result = dbRun("UPDATE auth_sessions SET revoked_at = ?, last_seen_at = ? WHERE user_id = ? AND id <> ? AND revoked_at IS NULL", [timestamp, timestamp, userId, currentSessionId]);
|
||||
const revokedCount = Number(result.changes || 0);
|
||||
recordSecurityEvent({ userId, eventType: "session.revoked", result: "success", ...requestMetadata(metadata), metadata: { reason: "revoke_others", revokedSessionCount: revokedCount } });
|
||||
return revokedCount;
|
||||
}
|
||||
|
||||
export function revokeAllUserSessions(userId, metadata = {}) {
|
||||
const timestamp = nowIso();
|
||||
const result = dbRun("UPDATE auth_sessions SET revoked_at = ?, last_seen_at = ? WHERE user_id = ? AND revoked_at IS NULL", [timestamp, timestamp, userId]);
|
||||
const revokedCount = Number(result.changes || 0);
|
||||
recordSecurityEvent({ userId, eventType: "session.revoked", result: "success", ...requestMetadata(metadata), metadata: { reason: metadata.reason || "revoke_all", revokedSessionCount: revokedCount, actorUserId: metadata.actorUserId || null } });
|
||||
return revokedCount;
|
||||
}
|
||||
|
||||
export function changePassword(userId, currentPassword, nextPassword, metadata = {}) {
|
||||
const request = requestMetadata(metadata);
|
||||
const user = dbGet("SELECT * FROM users WHERE id = ?", [userId]);
|
||||
const credential = dbGet("SELECT * FROM user_credentials WHERE user_id = ?", [userId]);
|
||||
if (!user || !credential) throw authError(404, "user_not_found", "当前用户不存在");
|
||||
const expected = Buffer.from(credential.password_hash, "hex");
|
||||
const actual = Buffer.from(passwordHash(currentPassword, credential.password_salt), "hex");
|
||||
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
|
||||
recordSecurityEvent({ userId, eventType: "password.failure", result: "failure", ...request, metadata: { reason: "current_password_invalid" } });
|
||||
throw authError(400, "current_password_invalid", "当前密码不正确");
|
||||
}
|
||||
if (String(nextPassword || "").length < 12) throw authError(400, "password_too_short", "新密码至少需要 12 个字符");
|
||||
const record = createPasswordRecord(nextPassword);
|
||||
const changedAt = nowIso();
|
||||
dbRun("UPDATE user_credentials SET password_salt = ?, password_hash = ?, failed_attempts = 0, locked_until = NULL, updated_at = ? WHERE user_id = ?", [record.salt, record.hash, changedAt, userId]);
|
||||
recordSecurityEvent({ userId, eventType: "password.changed", result: "success", ...request, metadata: { changedAt } });
|
||||
return { changedAt };
|
||||
}
|
||||
|
||||
export function resetPassword(userId, nextPassword, metadata = {}) {
|
||||
const user = dbGet("SELECT id FROM users WHERE id = ?", [userId]);
|
||||
if (!user) throw authError(404, "user_not_found", "目标用户不存在");
|
||||
if (String(nextPassword || "").length < 12) throw authError(400, "password_too_short", "新密码至少需要 12 个字符");
|
||||
const record = createPasswordRecord(nextPassword);
|
||||
const timestamp = nowIso();
|
||||
dbRun("INSERT INTO user_credentials(user_id, password_salt, password_hash, failed_attempts, locked_until, created_at, updated_at) VALUES (?, ?, ?, 0, NULL, ?, ?) ON CONFLICT(user_id) DO UPDATE SET password_salt = excluded.password_salt, password_hash = excluded.password_hash, failed_attempts = 0, locked_until = NULL, updated_at = excluded.updated_at", [userId, record.salt, record.hash, timestamp, timestamp]);
|
||||
const revokedSessionCount = revokeAllUserSessions(userId, metadata);
|
||||
recordSecurityEvent({ userId, eventType: "password.reset", result: "success", ...requestMetadata(metadata), metadata: { revokedSessionCount, actorUserId: metadata.actorUserId || null } });
|
||||
return { resetAt: timestamp, revokedSessionCount };
|
||||
}
|
||||
|
||||
export function resetMfa(userId, metadata = {}) {
|
||||
const user = dbGet("SELECT id FROM users WHERE id = ?", [userId]);
|
||||
if (!user) throw authError(404, "user_not_found", "目标用户不存在");
|
||||
dbRun("DELETE FROM user_mfa_methods WHERE user_id = ?", [userId]);
|
||||
dbRun("DELETE FROM auth_mfa_challenges WHERE user_id = ?", [userId]);
|
||||
dbRun("DELETE FROM auth_mfa_enrollment_challenges WHERE user_id = ?", [userId]);
|
||||
const revokedSessionCount = revokeAllUserSessions(userId, metadata);
|
||||
recordSecurityEvent({ userId, eventType: "mfa.reset", result: "success", ...requestMetadata(metadata), metadata: { revokedSessionCount, actorUserId: metadata.actorUserId || null } });
|
||||
return { resetAt: nowIso(), revokedSessionCount };
|
||||
}
|
||||
|
||||
export function authMode() {
|
||||
return process.env.AI_DRAMA_ALLOW_DEV_CONTEXT === "1" ? "session-plus-scoped-api-client-plus-local-dev-context" : "session-or-scoped-api-client";
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { mkdir, readdir, stat, unlink } from "node:fs/promises";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { resolve } from "node:path";
|
||||
import { db, dbPath } from "./db.mjs";
|
||||
|
||||
const projectRoot = resolve(import.meta.dirname, "..");
|
||||
const backupRoot = resolve(projectRoot, "data", "backups");
|
||||
const relativeBackupRoot = "data/backups";
|
||||
|
||||
function backupEntry(name, fileStat) {
|
||||
return {
|
||||
name,
|
||||
relativePath: `${relativeBackupRoot}/${name}`,
|
||||
bytes: Number(fileStat.size || 0),
|
||||
createdAt: fileStat.birthtime?.toISOString?.() || fileStat.mtime?.toISOString?.() || null,
|
||||
modifiedAt: fileStat.mtime?.toISOString?.() || null
|
||||
};
|
||||
}
|
||||
|
||||
export async function listDatabaseBackups() {
|
||||
let names = [];
|
||||
try {
|
||||
names = await readdir(backupRoot);
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
}
|
||||
const entries = await Promise.all(names.filter((name) => /^platform-[A-Z0-9-]+\.sqlite$/i.test(name)).map(async (name) => {
|
||||
try {
|
||||
return backupEntry(name, await stat(resolve(backupRoot, name)));
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return null;
|
||||
throw error;
|
||||
}
|
||||
}));
|
||||
return entries.filter(Boolean).sort((left, right) => String(right.modifiedAt || "").localeCompare(String(left.modifiedAt || "")));
|
||||
}
|
||||
|
||||
export async function backupSummary() {
|
||||
const backups = await listDatabaseBackups();
|
||||
return {
|
||||
root: relativeBackupRoot,
|
||||
source: dbPath,
|
||||
count: backups.length,
|
||||
latest: backups[0] || null,
|
||||
backups
|
||||
};
|
||||
}
|
||||
|
||||
export async function createDatabaseBackup() {
|
||||
await mkdir(backupRoot, { recursive: true });
|
||||
const stamp = new Date().toISOString().replace(/[^0-9TZ]/g, "-");
|
||||
const name = `platform-${stamp}-${randomBytes(4).toString("hex")}.sqlite`;
|
||||
const target = resolve(backupRoot, name);
|
||||
const escapedTarget = target.replaceAll("'", "''");
|
||||
try {
|
||||
db.exec(`VACUUM INTO '${escapedTarget}'`);
|
||||
const entry = backupEntry(name, await stat(target));
|
||||
return { backup: entry, ...(await backupSummary()) };
|
||||
} catch (error) {
|
||||
await unlink(target).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import { promisify } from "node:util";
|
||||
import { resolve } from "node:path";
|
||||
import { dbAll, dbGet, dbRun } from "./db.mjs";
|
||||
import { addAudit, httpError, requirePermission } from "./tenant.mjs";
|
||||
import { registerCompositionArtifact } from "./media-artifacts.mjs";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const projectRoot = resolve(import.meta.dirname, "..");
|
||||
const ffmpegBinary = process.env.FFMPEG_BIN || "ffmpeg";
|
||||
const makeId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
|
||||
function safeRelativePath(value, label) {
|
||||
const relative = String(value || "").replace(/^[/\\]+/, "");
|
||||
if (!relative || relative.includes("..") || !relative.startsWith("storage/")) throw httpError(400, "composition_path_invalid", `${label}必须是 storage/ 下的安全相对路径`);
|
||||
const absolute = resolve(projectRoot, relative);
|
||||
if (!absolute.startsWith(`${projectRoot}/storage/`)) throw httpError(400, "composition_path_invalid", `${label}越过了本地存储根目录`);
|
||||
return { relative, absolute };
|
||||
}
|
||||
|
||||
async function exists(filePath) {
|
||||
try {
|
||||
const info = await stat(filePath);
|
||||
return info.isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function concatLine(pathname) {
|
||||
return `file '${pathname.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
|
||||
function compositionPayload(row) {
|
||||
if (!row) return null;
|
||||
let source = {};
|
||||
let result = {};
|
||||
try { source = JSON.parse(row.source_json); } catch { source = {}; }
|
||||
try { result = JSON.parse(row.result_json); } catch { result = {}; }
|
||||
return { ...row, dryRun: Boolean(row.dry_run), source, result };
|
||||
}
|
||||
|
||||
async function ffmpegVersion() {
|
||||
try {
|
||||
const result = await execFileAsync(ffmpegBinary, ["-version"], { timeout: 5000 });
|
||||
return String(result.stdout || "").split("\n")[0] || "ffmpeg";
|
||||
} catch (error) {
|
||||
throw httpError(503, "ffmpeg_unavailable", `本地 FFmpeg 不可用:${String(error.message || error).slice(0, 300)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function scopedEpisode(context, episodeId) {
|
||||
if (!episodeId) return null;
|
||||
const row = dbGet(
|
||||
`SELECT e.id FROM episodes e
|
||||
JOIN seasons se ON se.id = e.season_id
|
||||
JOIN series sr ON sr.id = se.series_id
|
||||
WHERE e.id = ? AND sr.project_id = ?`,
|
||||
[episodeId, context.project.id]
|
||||
);
|
||||
if (!row) throw httpError(400, "episode_invalid", "合成分集不属于当前项目");
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function composeProject(context, body = {}) {
|
||||
requirePermission(context, "delivery:approve");
|
||||
if (!context.project) throw httpError(400, "project_required", "本地合成必须绑定项目");
|
||||
const clips = Array.isArray(body.clips) ? body.clips.map((item) => String(item || "").trim()).filter(Boolean) : [];
|
||||
if (!clips.length || clips.length > 200) throw httpError(400, "composition_clips_invalid", "本地合成需要 1 到 200 个片段");
|
||||
const clipFiles = clips.map((clip) => safeRelativePath(clip, "片段路径"));
|
||||
const audio = body.audioPath ? safeRelativePath(body.audioPath, "音频路径") : null;
|
||||
const version = String(body.version || `local-${new Date().toISOString().slice(0, 10)}-${Date.now()}`).trim();
|
||||
const dryRun = Boolean(body.dryRun);
|
||||
const compositionId = makeId("composition");
|
||||
const compositionRoot = resolve(projectRoot, "storage", "compositions", compositionId);
|
||||
const manifestPath = `storage/compositions/${compositionId}/concat.txt`;
|
||||
const outputPath = safeRelativePath(body.outputPath || `storage/compositions/${compositionId}/final.mp4`, "输出路径");
|
||||
const inputStatus = [];
|
||||
for (const clip of clipFiles) inputStatus.push({ path: clip.relative, exists: await exists(clip.absolute) });
|
||||
if (audio) inputStatus.push({ path: audio.relative, kind: "audio", exists: await exists(audio.absolute) });
|
||||
const versionLabel = await ffmpegVersion();
|
||||
const source = { clips: clipFiles.map((item) => item.relative), audioPath: audio?.relative || null, version, inputStatus };
|
||||
const timestamp = new Date().toISOString();
|
||||
dbRun("INSERT INTO media_compositions(id, organization_id, workspace_id, project_id, episode_id, version, status, tool, dry_run, output_path, manifest_path, source_json, result_json, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, 'ffmpeg', ?, ?, ?, ?, '{}', ?, ?, ?)", [compositionId, context.organization.id, context.workspace.id, context.project.id, scopedEpisode(context, body.episodeId)?.id || null, version, dryRun ? "planned" : "running", dryRun ? 1 : 0, outputPath.relative, manifestPath, JSON.stringify(source), context.user.id, timestamp, timestamp]);
|
||||
|
||||
if (dryRun) {
|
||||
const result = { dryRun: true, tool: "ffmpeg", version: versionLabel, manifestPath, outputPath: outputPath.relative, command: [ffmpegBinary, "-f", "concat", "-safe", "0", "-i", manifestPath, "-c:v", "libx264", "-pix_fmt", "yuv420p", outputPath.relative], inputStatus };
|
||||
dbRun("UPDATE media_compositions SET result_json = ?, updated_at = ? WHERE id = ?", [JSON.stringify(result), timestamp, compositionId]);
|
||||
addAudit({ context, action: "media.composition.planned", targetType: "media_composition", targetId: compositionId, metadata: result });
|
||||
return { composition: { ...compositionPayload(dbGet("SELECT * FROM media_compositions WHERE id = ?", [compositionId])), ...result } };
|
||||
}
|
||||
|
||||
const missing = inputStatus.filter((item) => !item.exists);
|
||||
if (missing.length) {
|
||||
const message = `输入片段不存在:${missing.map((item) => item.path).join(", ")}`;
|
||||
dbRun("UPDATE media_compositions SET status = 'failed', error_message = ?, updated_at = ? WHERE id = ?", [message, now(), compositionId]);
|
||||
throw httpError(422, "composition_input_missing", message, { compositionId, missing });
|
||||
}
|
||||
await mkdir(compositionRoot, { recursive: true });
|
||||
await mkdir(resolve(outputPath.absolute, ".."), { recursive: true });
|
||||
await writeFile(resolve(projectRoot, manifestPath), `${clipFiles.map((item) => concatLine(item.absolute)).join("\n")}\n`, "utf8");
|
||||
const args = ["-y", "-f", "concat", "-safe", "0", "-i", resolve(projectRoot, manifestPath)];
|
||||
if (audio) args.push("-i", audio.absolute);
|
||||
args.push("-map", "0:v:0", "-map", audio ? "1:a:0?" : "0:a:0?", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", "-movflags", "+faststart", "-shortest", outputPath.absolute);
|
||||
try {
|
||||
const result = await execFileAsync(ffmpegBinary, args, { timeout: 20 * 60 * 1000, maxBuffer: 2 * 1024 * 1024 });
|
||||
const outputInfo = await stat(outputPath.absolute);
|
||||
const composition = dbGet("SELECT * FROM media_compositions WHERE id = ?", [compositionId]);
|
||||
const artifact = await registerCompositionArtifact(context, composition, outputPath.relative);
|
||||
const payload = { dryRun: false, tool: "ffmpeg", version: versionLabel, outputPath: outputPath.relative, outputBytes: outputInfo.size, artifact, stderr: String(result.stderr || "").slice(-2000) };
|
||||
const finishedAt = new Date().toISOString();
|
||||
dbRun("UPDATE media_compositions SET status = 'completed', result_json = ?, updated_at = ? WHERE id = ?", [JSON.stringify(payload), finishedAt, compositionId]);
|
||||
addAudit({ context, action: "media.composition.completed", targetType: "media_composition", targetId: compositionId, metadata: payload });
|
||||
return { composition: { ...compositionPayload(dbGet("SELECT * FROM media_compositions WHERE id = ?", [compositionId])), ...payload } };
|
||||
} catch (error) {
|
||||
const message = String(error.stderr || error.message || error).slice(-2000);
|
||||
dbRun("UPDATE media_compositions SET status = 'failed', error_message = ?, updated_at = ? WHERE id = ?", [message, new Date().toISOString(), compositionId]);
|
||||
addAudit({ context, action: "media.composition.failed", targetType: "media_composition", targetId: compositionId, result: "error", metadata: { error: message } });
|
||||
throw httpError(502, "composition_failed", message, { compositionId });
|
||||
}
|
||||
}
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export function listCompositions(context, limit = 40) {
|
||||
if (!context.project) return [];
|
||||
return dbAll("SELECT * FROM media_compositions WHERE organization_id = ? AND workspace_id = ? AND project_id = ? ORDER BY created_at DESC LIMIT ?", [context.organization.id, context.workspace.id, context.project.id, Math.max(1, Math.min(100, Number(limit || 40))) ]).map(compositionPayload);
|
||||
}
|
||||
+595
@@ -0,0 +1,595 @@
|
||||
import { mkdir, readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { randomBytes, scryptSync } from "node:crypto";
|
||||
import { sampleProject } from "../src/data/sampleProject.js";
|
||||
import { apiClientStorageMarker, hashApiClientKey } from "./api-client-secrets.mjs";
|
||||
|
||||
const serverRoot = resolve(import.meta.dirname);
|
||||
const projectRoot = resolve(serverRoot, "..");
|
||||
const dataRoot = resolve(projectRoot, "data");
|
||||
export const dbPath = resolve(process.env.AI_DRAMA_DB_PATH || dataRoot, process.env.AI_DRAMA_DB_PATH ? "" : "platform.sqlite");
|
||||
|
||||
await mkdir(process.env.AI_DRAMA_DB_PATH ? resolve(dbPath, "..") : dataRoot, { recursive: true });
|
||||
|
||||
const schema = await readFile(resolve(serverRoot, "schema.sql"), "utf8");
|
||||
export const db = new DatabaseSync(dbPath);
|
||||
db.exec("PRAGMA busy_timeout = 5000");
|
||||
db.exec("PRAGMA journal_mode = WAL");
|
||||
db.exec(schema);
|
||||
|
||||
function ensureColumn(table, column, definition) {
|
||||
const columns = db.prepare(`PRAGMA table_info(${table})`).all();
|
||||
if (!columns.some((item) => item.name === column)) {
|
||||
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the seeded local SQLite database compatible with new platform features.
|
||||
ensureColumn("model_connectors", "last_probe_at", "TEXT");
|
||||
ensureColumn("model_connectors", "latency_ms", "INTEGER");
|
||||
ensureColumn("model_connectors", "error_message", "TEXT NOT NULL DEFAULT ''");
|
||||
ensureColumn("model_connectors", "protocol_json", "TEXT NOT NULL DEFAULT '{}'");
|
||||
ensureColumn("model_connectors", "auth_env", "TEXT NOT NULL DEFAULT ''");
|
||||
ensureColumn("generation_jobs", "request_json", "TEXT NOT NULL DEFAULT '{}'");
|
||||
ensureColumn("generation_jobs", "result_json", "TEXT NOT NULL DEFAULT '{}'");
|
||||
ensureColumn("generation_jobs", "error_message", "TEXT NOT NULL DEFAULT ''");
|
||||
ensureColumn("generation_jobs", "max_attempts", "INTEGER NOT NULL DEFAULT 3");
|
||||
ensureColumn("generation_jobs", "next_run_at", "TEXT");
|
||||
ensureColumn("generation_jobs", "leased_by", "TEXT");
|
||||
ensureColumn("generation_jobs", "leased_at", "TEXT");
|
||||
ensureColumn("generation_jobs", "started_at", "TEXT");
|
||||
ensureColumn("generation_jobs", "finished_at", "TEXT");
|
||||
ensureColumn("billing_accounts", "billing_cycle", "TEXT NOT NULL DEFAULT 'monthly'");
|
||||
ensureColumn("billing_accounts", "currency", "TEXT NOT NULL DEFAULT 'CNY'");
|
||||
ensureColumn("billing_accounts", "base_fee", "REAL NOT NULL DEFAULT 0");
|
||||
ensureColumn("billing_accounts", "seat_unit_price", "REAL NOT NULL DEFAULT 0");
|
||||
ensureColumn("billing_accounts", "storage_unit_price", "REAL NOT NULL DEFAULT 0");
|
||||
ensureColumn("billing_accounts", "clip_unit_price", "REAL NOT NULL DEFAULT 0");
|
||||
ensureColumn("billing_accounts", "quota_warning_percent", "INTEGER NOT NULL DEFAULT 80");
|
||||
ensureColumn("billing_accounts", "current_period_start", "TEXT");
|
||||
ensureColumn("billing_accounts", "current_period_end", "TEXT");
|
||||
ensureColumn("billing_accounts", "next_invoice_at", "TEXT");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_jobs_dispatch ON generation_jobs(status, next_run_at, priority, created_at)");
|
||||
ensureColumn("series", "show_engine", "TEXT NOT NULL DEFAULT ''");
|
||||
ensureColumn("episodes", "target_duration_sec", "REAL NOT NULL DEFAULT 0");
|
||||
ensureColumn("episodes", "hook", "TEXT NOT NULL DEFAULT ''");
|
||||
ensureColumn("episodes", "cliffhanger", "TEXT NOT NULL DEFAULT ''");
|
||||
ensureColumn("api_clients", "organization_id", "TEXT");
|
||||
ensureColumn("api_clients", "workspace_id", "TEXT");
|
||||
ensureColumn("api_clients", "client_key_hash", "TEXT");
|
||||
ensureColumn("api_clients", "client_key_prefix", "TEXT NOT NULL DEFAULT ''");
|
||||
ensureColumn("api_clients", "key_version", "INTEGER NOT NULL DEFAULT 1");
|
||||
ensureColumn("invitations", "token_hash", "TEXT");
|
||||
ensureColumn("invitations", "token_hint", "TEXT");
|
||||
ensureColumn("invitations", "accepted_user_id", "TEXT");
|
||||
ensureColumn("invitations", "accepted_at", "TEXT");
|
||||
ensureColumn("invitations", "revoked_at", "TEXT");
|
||||
ensureColumn("identity_providers", "organization_id", "TEXT");
|
||||
ensureColumn("identity_providers", "workspace_id", "TEXT");
|
||||
ensureColumn("identity_providers", "jwks_url", "TEXT NOT NULL DEFAULT ''");
|
||||
ensureColumn("identity_providers", "entry_point", "TEXT NOT NULL DEFAULT ''");
|
||||
ensureColumn("identity_providers", "idp_cert_ref", "TEXT NOT NULL DEFAULT ''");
|
||||
ensureColumn("identity_providers", "sp_issuer", "TEXT NOT NULL DEFAULT ''");
|
||||
ensureColumn("identity_providers", "audience", "TEXT NOT NULL DEFAULT ''");
|
||||
ensureColumn("identity_providers", "saml_name_id_format", "TEXT NOT NULL DEFAULT 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'");
|
||||
ensureColumn("identity_providers", "want_assertions_signed", "INTEGER NOT NULL DEFAULT 1");
|
||||
ensureColumn("identity_providers", "want_authn_response_signed", "INTEGER NOT NULL DEFAULT 1");
|
||||
ensureColumn("identity_providers", "validate_in_response_to", "TEXT NOT NULL DEFAULT 'ifPresent'");
|
||||
ensureColumn("identity_providers", "auto_provision", "INTEGER NOT NULL DEFAULT 1");
|
||||
ensureColumn("identity_providers", "default_role_key", "TEXT NOT NULL DEFAULT 'org_member'");
|
||||
ensureColumn("identity_providers", "default_workspace_role_key", "TEXT NOT NULL DEFAULT 'writer'");
|
||||
ensureColumn("asset_versions", "file_name", "TEXT NOT NULL DEFAULT ''");
|
||||
ensureColumn("asset_versions", "mime_type", "TEXT NOT NULL DEFAULT 'application/octet-stream'");
|
||||
ensureColumn("asset_versions", "file_size", "INTEGER NOT NULL DEFAULT 0");
|
||||
ensureColumn("asset_versions", "content_sha256", "TEXT NOT NULL DEFAULT ''");
|
||||
ensureColumn("shots", "current_version_id", "TEXT");
|
||||
ensureColumn("deliveries", "active_batch_id", "TEXT");
|
||||
ensureColumn("delivery_releases", "idempotency_key", "TEXT NOT NULL DEFAULT ''");
|
||||
ensureColumn("delivery_releases", "preflight_json", "TEXT NOT NULL DEFAULT '{}'");
|
||||
ensureColumn("delivery_releases", "result_json", "TEXT NOT NULL DEFAULT '{}'");
|
||||
ensureColumn("projects", "archived_at", "TEXT");
|
||||
ensureColumn("projects", "archived_by", "TEXT");
|
||||
ensureColumn("projects", "archived_from_status", "TEXT");
|
||||
ensureColumn("projects", "template_id", "TEXT NOT NULL DEFAULT 'ai-manhua-drama'");
|
||||
ensureColumn("auth_sessions", "device_id", "TEXT");
|
||||
ensureColumn("auth_sessions", "risk_level", "TEXT NOT NULL DEFAULT 'medium'");
|
||||
ensureColumn("auth_sessions", "risk_score", "INTEGER NOT NULL DEFAULT 50");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_projects_lifecycle ON projects(workspace_id, status, updated_at)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_invites_token ON invitations(token_hash, status)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_identity_providers_org ON identity_providers(organization_id, enabled, status)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_oidc_login_states_hash ON oidc_login_states(state_hash, expires_at, consumed_at)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_saml_login_states_relay ON saml_login_states(relay_state_hash, expires_at, consumed_at)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_saml_login_states_request ON saml_login_states(request_id, expires_at, consumed_at)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_external_identities_user ON external_identities(user_id, provider_id)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_auth_sso_tickets_hash ON auth_sso_tickets(ticket_hash, expires_at, consumed_at)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_api_clients_key_hash ON api_clients(client_key_hash, status)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_auth_security_events_user_created ON auth_security_events(user_id, created_at DESC)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_auth_security_events_type_created ON auth_security_events(event_type, created_at DESC)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_auth_devices_user_last_seen ON auth_devices(user_id, last_seen_at DESC)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_auth_devices_key_hash ON auth_devices(user_id, device_key_hash)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_auth_sessions_device ON auth_sessions(device_id, created_at DESC)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_channels_scope ON delivery_channels(organization_id, workspace_id, project_id, enabled, created_at)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_releases_scope ON delivery_releases(organization_id, workspace_id, project_id, delivery_id, status, created_at DESC)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_releases_idempotency ON delivery_releases(organization_id, project_id, idempotency_key)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_access_links_token ON delivery_access_links(token_hash, status, expires_at)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_access_links_scope ON delivery_access_links(organization_id, workspace_id, project_id, release_id, status, created_at DESC)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_access_events_link ON delivery_access_events(link_id, created_at DESC)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_access_events_release ON delivery_access_events(release_id, created_at DESC)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_access_feedback_link ON delivery_access_feedback(link_id, created_at DESC)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_access_feedback_release ON delivery_access_feedback(release_id, created_at DESC)");
|
||||
|
||||
function migrateApiClientSecrets() {
|
||||
const rows = dbAll("SELECT id, client_key, client_key_hash, client_key_prefix, key_version FROM api_clients");
|
||||
for (const row of rows) {
|
||||
if (row.client_key_hash && row.client_key_prefix && Number(row.key_version || 0) >= 2) continue;
|
||||
const storedValue = String(row.client_key || "");
|
||||
const existingHash = String(row.client_key_hash || "");
|
||||
const hash = /^[a-f0-9]{64}$/i.test(existingHash)
|
||||
? existingHash.toLowerCase()
|
||||
: storedValue.startsWith("hash:") && /^[a-f0-9]{64}$/i.test(storedValue.slice(5))
|
||||
? storedValue.slice(5).toLowerCase()
|
||||
: hashApiClientKey(storedValue);
|
||||
const prefix = row.client_key_prefix || (storedValue.startsWith("hash:") ? "local-" : storedValue.slice(0, 12));
|
||||
dbRun("UPDATE api_clients SET client_key = ?, client_key_hash = ?, client_key_prefix = ?, key_version = 2 WHERE id = ?", [apiClientStorageMarker(hash), hash, prefix, row.id]);
|
||||
}
|
||||
}
|
||||
|
||||
migrateApiClientSecrets();
|
||||
|
||||
const now = () => new Date().toISOString();
|
||||
const isoDaysFromNow = (days) => new Date(Date.now() + days * 86400000).toISOString();
|
||||
const monthStart = () => {
|
||||
const date = new Date();
|
||||
date.setUTCDate(1);
|
||||
date.setUTCHours(0, 0, 0, 0);
|
||||
return date.toISOString();
|
||||
};
|
||||
const monthEnd = () => {
|
||||
const date = new Date();
|
||||
date.setUTCMonth(date.getUTCMonth() + 1, 0);
|
||||
date.setUTCHours(23, 59, 59, 999);
|
||||
return date.toISOString();
|
||||
};
|
||||
|
||||
export function dbGet(sql, params = []) {
|
||||
return db.prepare(sql).get(...params) || null;
|
||||
}
|
||||
|
||||
export function dbAll(sql, params = []) {
|
||||
return db.prepare(sql).all(...params);
|
||||
}
|
||||
|
||||
export function dbRun(sql, params = []) {
|
||||
return db.prepare(sql).run(...params);
|
||||
}
|
||||
|
||||
export function createPasswordRecord(password) {
|
||||
const salt = randomBytes(16).toString("hex");
|
||||
const hash = scryptSync(String(password), salt, 64).toString("hex");
|
||||
return { salt, hash };
|
||||
}
|
||||
|
||||
export function passwordHash(password, salt) {
|
||||
return scryptSync(String(password), salt, 64).toString("hex");
|
||||
}
|
||||
|
||||
export function withTransaction(callback) {
|
||||
db.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
const result = callback();
|
||||
db.exec("COMMIT");
|
||||
return result;
|
||||
} catch (error) {
|
||||
db.exec("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function insertIgnore(sql, params) {
|
||||
dbRun(sql, params);
|
||||
}
|
||||
|
||||
function seedRoles() {
|
||||
const roles = [
|
||||
["org_owner", "organization", "组织所有者", "组织全局管理、账单、成员和模型策略"],
|
||||
["org_admin", "organization", "组织管理员", "组织成员、工作区、模型和审计管理"],
|
||||
["org_member", "organization", "组织成员", "组织内的基础成员身份,具体能力由工作区/项目角色决定"],
|
||||
["producer", "workspace", "制片", "项目、批次、队列、成本和交付管理"],
|
||||
["writer", "workspace", "编剧", "剧本、分集、对白和镜头草稿"],
|
||||
["art_director", "workspace", "资产美术", "角色、场景、道具、prompt 和连续性"],
|
||||
["voice_editor", "workspace", "配音/字幕", "固定声线、TTS、字幕和 ASR"],
|
||||
["reviewer", "workspace", "审片", "QA、审片意见和通过/驳回"],
|
||||
["project_editor", "project", "项目编辑", "指定项目的内容编辑"],
|
||||
["project_viewer", "project", "项目查看者", "只读查看和下载授权交付物"]
|
||||
];
|
||||
for (const role of roles) {
|
||||
insertIgnore("INSERT OR IGNORE INTO roles(key, scope, name, description) VALUES (?, ?, ?, ?)", role);
|
||||
}
|
||||
|
||||
const permissions = [
|
||||
["organization:manage", "修改组织设置"],
|
||||
["organization:members:invite", "邀请组织成员"],
|
||||
["organization:roles:manage", "管理组织角色权限策略"],
|
||||
["workspace:create", "创建工作区"],
|
||||
["workspace:manage", "管理工作区设置"],
|
||||
["workspace:members:manage", "管理工作区成员"],
|
||||
["project:create", "创建项目"],
|
||||
["project:manage", "管理项目设置"],
|
||||
["project:members:manage", "管理项目成员"],
|
||||
["task:view", "查看项目协作任务"],
|
||||
["task:manage", "创建、分派和管理项目协作任务"],
|
||||
["task:complete", "更新本人负责的协作任务状态"],
|
||||
["script:edit", "编辑剧本和对白"],
|
||||
["script:read", "查看剧本、分集和镜头"],
|
||||
["asset:edit", "编辑角色、场景和道具锁"],
|
||||
["prompt:edit", "编辑生成提示"],
|
||||
["voice:edit", "编辑固定声线和字幕"],
|
||||
["voice:approve", "审批角色参考音频和声音授权"],
|
||||
["job:create", "创建生成任务"],
|
||||
["job:prioritize", "调整任务优先级"],
|
||||
["model:manage", "注册和管理模型连接器"],
|
||||
["usage:view", "查看用量和成本"],
|
||||
["billing:manage", "管理套餐和账单"],
|
||||
["quota:manage", "管理组织席位与工作区配额"],
|
||||
["qa:review", "执行审片和 QA"],
|
||||
["delivery:approve", "批准交付版本"],
|
||||
["delivery:view", "查看交付版本和导出物"],
|
||||
["compliance:manage", "管理合规策略和证据"],
|
||||
["audit:view", "查看审计日志"],
|
||||
["system:settings:view", "查看系统配置"],
|
||||
["system:settings:edit", "修改系统配置"],
|
||||
["feature_flag:manage", "管理功能开关"],
|
||||
["service:health:view", "查看服务健康状态"],
|
||||
["api_client:manage", "管理 API 客户端"],
|
||||
["notification:manage", "管理通知渠道"],
|
||||
["queue:manage", "管理全局任务队列"],
|
||||
["runner:manage", "管理本地 Runner"]
|
||||
];
|
||||
for (const permission of permissions) {
|
||||
insertIgnore("INSERT OR IGNORE INTO permissions(key, description) VALUES (?, ?)", permission);
|
||||
}
|
||||
|
||||
const rolePermissions = {
|
||||
org_owner: permissions.map(([key]) => key),
|
||||
org_admin: [
|
||||
"organization:manage", "organization:members:invite", "workspace:create", "workspace:manage",
|
||||
"workspace:members:manage", "project:create", "project:manage", "project:members:manage",
|
||||
"task:view", "task:manage", "task:complete", "model:manage", "usage:view", "billing:manage", "quota:manage", "qa:review", "delivery:approve", "delivery:view", "compliance:manage", "audit:view", "queue:manage", "voice:approve",
|
||||
"system:settings:view", "service:health:view", "organization:roles:manage"
|
||||
],
|
||||
producer: ["project:create", "project:manage", "project:members:manage", "task:view", "task:manage", "task:complete", "script:read", "job:create", "job:prioritize", "usage:view", "delivery:approve", "delivery:view", "voice:approve"],
|
||||
writer: ["script:read", "script:edit", "task:view", "task:complete", "job:create"],
|
||||
art_director: ["asset:edit", "prompt:edit", "task:view", "task:complete", "job:create"],
|
||||
voice_editor: ["voice:edit", "voice:approve", "task:view", "task:complete", "job:create"],
|
||||
reviewer: ["script:read", "task:view", "task:complete", "qa:review", "voice:approve", "delivery:view"],
|
||||
project_editor: ["script:read", "script:edit", "asset:edit", "prompt:edit", "voice:edit", "task:view", "task:manage", "task:complete", "job:create", "delivery:view"],
|
||||
project_viewer: ["script:read", "task:view", "delivery:view"]
|
||||
};
|
||||
for (const [roleKey, permissionKeys] of Object.entries(rolePermissions)) {
|
||||
for (const permissionKey of permissionKeys) {
|
||||
insertIgnore("INSERT OR IGNORE INTO role_permissions(role_key, permission_key) VALUES (?, ?)", [roleKey, permissionKey]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function seedUsers() {
|
||||
const timestamp = now();
|
||||
const users = [
|
||||
["u-owner", "林制片", "producer@local.test", "#d97757", "active"],
|
||||
["u-producer", "周制片", "producer2@local.test", "#3f7f87", "active"],
|
||||
["u-writer", "白编剧", "writer@local.test", "#a77646", "active"],
|
||||
["u-art", "沈美术", "art@local.test", "#8e6a9f", "active"],
|
||||
["u-voice", "许配音", "voice@local.test", "#477d69", "active"],
|
||||
["u-review", "顾审片", "review@local.test", "#9a6b51", "active"],
|
||||
["u-local-worker", "本地 Worker", "local-worker@system.invalid", "#58656b", "suspended"]
|
||||
];
|
||||
for (const [id, displayName, email, color, status] of users) {
|
||||
insertIgnore("INSERT OR IGNORE INTO users(id, display_name, email, avatar_color, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", [id, displayName, email, color, status, timestamp, timestamp]);
|
||||
}
|
||||
}
|
||||
|
||||
function seedCredentials() {
|
||||
const timestamp = now();
|
||||
const demoPassword = "Demo@123456";
|
||||
const users = dbAll("SELECT id FROM users WHERE status = 'active'");
|
||||
for (const user of users) {
|
||||
const existing = dbGet("SELECT user_id FROM user_credentials WHERE user_id = ?", [user.id]);
|
||||
if (!existing) {
|
||||
const record = createPasswordRecord(demoPassword);
|
||||
insertIgnore("INSERT INTO user_credentials(user_id, password_salt, password_hash, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", [user.id, record.salt, record.hash, timestamp, timestamp]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function seedOrganization({ id, name, slug, ownerUserId, description, workspaceId, workspaceName, workspaceSlug, projectId, projectName, projectType, readiness, risk }) {
|
||||
const timestamp = now();
|
||||
insertIgnore("INSERT OR IGNORE INTO organizations(id, name, slug, owner_user_id, deployment_mode, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'private-local', 'active', ?, ?)", [id, name, slug, ownerUserId, timestamp, timestamp]);
|
||||
insertIgnore("INSERT OR IGNORE INTO organization_members(id, organization_id, user_id, role_key, status, joined_at, created_at, updated_at) VALUES (?, ?, ?, 'org_owner', 'active', ?, ?, ?)", [`om-${id}`, id, ownerUserId, timestamp, timestamp, timestamp]);
|
||||
insertIgnore("INSERT OR IGNORE INTO workspaces(id, organization_id, name, slug, description, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'active', ?, ?)", [workspaceId, id, workspaceName, workspaceSlug, description, timestamp, timestamp]);
|
||||
insertIgnore("INSERT OR IGNORE INTO workspace_members(id, workspace_id, user_id, role_key, status, created_at, updated_at) VALUES (?, ?, ?, 'producer', 'active', ?, ?)", [`wm-${workspaceId}-${ownerUserId}`, workspaceId, ownerUserId, timestamp, timestamp]);
|
||||
insertIgnore("INSERT OR IGNORE INTO projects(id, workspace_id, name, type, status, owner_user_id, visibility, readiness, risk, created_at, updated_at) VALUES (?, ?, ?, ?, 'production', ?, 'workspace', ?, ?, ?, ?)", [projectId, workspaceId, projectName, projectType, ownerUserId, readiness, risk, timestamp, timestamp]);
|
||||
insertIgnore("INSERT OR IGNORE INTO project_members(id, project_id, user_id, role_key, status, created_at, updated_at) VALUES (?, ?, ?, 'project_editor', 'active', ?, ?)", [`pm-${projectId}-${ownerUserId}`, projectId, ownerUserId, timestamp, timestamp]);
|
||||
insertIgnore("INSERT OR IGNORE INTO billing_accounts(id, organization_id, plan_name, billing_cycle, currency, seat_limit, storage_gb, monthly_clip_quota, quota_warning_percent, local_runner_only, cloud_connectors_require_approval, current_period_start, current_period_end, next_invoice_at, created_at, updated_at) VALUES (?, ?, 'Studio Local', 'monthly', 'CNY', 12, 1024, 2400, 80, 1, 1, ?, ?, ?, ?, ?)", [`bill-${id}`, id, monthStart(), monthEnd(), monthEnd(), timestamp, timestamp]);
|
||||
dbRun("UPDATE billing_accounts SET current_period_start = COALESCE(current_period_start, ?), current_period_end = COALESCE(current_period_end, ?), next_invoice_at = COALESCE(next_invoice_at, ?) WHERE organization_id = ?", [monthStart(), monthEnd(), monthEnd(), id]);
|
||||
const costCenters = [
|
||||
[`cc-${id}-gpu`, "local-gpu", "本地 GPU / 电力", "本地推理与视频生成的运营成本", 500],
|
||||
[`cc-${id}-storage`, "storage", "本地存储", "素材、缓存和交付归档存储", 200],
|
||||
[`cc-${id}-ops`, "operations", "平台运营", "通知、审计和运维工作量", 300]
|
||||
];
|
||||
for (const [costCenterId, code, name, description, budget] of costCenters) {
|
||||
insertIgnore("INSERT OR IGNORE INTO cost_centers(id, organization_id, code, name, description, monthly_budget, currency, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'CNY', 'active', ?, ?)", [costCenterId, id, code, name, description, budget, timestamp, timestamp]);
|
||||
}
|
||||
insertIgnore("INSERT OR IGNORE INTO quota_allocations(id, organization_id, workspace_id, metric, limit_value, used_value, unit, period_start, period_end, created_at, updated_at) VALUES (?, ?, ?, 'clip', 2400, 36, 'clips', ?, ?, ?, ?)", [`quota-${workspaceId}-clip`, id, workspaceId, monthStart(), monthEnd(), timestamp, timestamp]);
|
||||
insertIgnore("INSERT OR IGNORE INTO quota_allocations(id, organization_id, workspace_id, metric, limit_value, used_value, unit, period_start, period_end, created_at, updated_at) VALUES (?, ?, ?, 'storage', 1024, 128, 'GB', ?, ?, ?, ?)", [`quota-${workspaceId}-storage`, id, workspaceId, monthStart(), monthEnd(), timestamp, timestamp]);
|
||||
}
|
||||
|
||||
function seedMembersAndProjects() {
|
||||
const timestamp = now();
|
||||
const membershipRows = [
|
||||
["om-studio-writer", "org-studio-lab", "u-writer", "org_admin"],
|
||||
["om-studio-art", "org-studio-lab", "u-art", "org_admin"],
|
||||
["om-studio-voice", "org-studio-lab", "u-voice", "org_admin"],
|
||||
["om-studio-review", "org-studio-lab", "u-review", "org_admin"],
|
||||
["om-northstar-producer", "org-northstar", "u-producer", "org_admin"]
|
||||
];
|
||||
for (const row of membershipRows) {
|
||||
insertIgnore("INSERT OR IGNORE INTO organization_members(id, organization_id, user_id, role_key, status, joined_at, created_at, updated_at) VALUES (?, ?, ?, ?, 'active', ?, ?, ?)", [...row, timestamp, timestamp, timestamp]);
|
||||
}
|
||||
dbRun("UPDATE organization_members SET role_key = 'org_member' WHERE organization_id = 'org-studio-lab' AND user_id IN ('u-writer', 'u-art', 'u-voice', 'u-review')");
|
||||
|
||||
const workspaceRows = [
|
||||
["wm-local-writer", "ws-local-aidrama", "u-writer", "writer"],
|
||||
["wm-local-art", "ws-local-aidrama", "u-art", "art_director"],
|
||||
["wm-local-voice", "ws-local-aidrama", "u-voice", "voice_editor"],
|
||||
["wm-local-review", "ws-local-aidrama", "u-review", "reviewer"],
|
||||
["wm-pilot-owner", "ws-pilot", "u-owner", "producer"],
|
||||
["wm-northstar-producer", "ws-northstar-main", "u-producer", "producer"]
|
||||
];
|
||||
for (const row of workspaceRows) {
|
||||
insertIgnore("INSERT OR IGNORE INTO workspace_members(id, workspace_id, user_id, role_key, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'active', ?, ?)", [...row, timestamp, timestamp]);
|
||||
}
|
||||
|
||||
insertIgnore("INSERT OR IGNORE INTO projects(id, workspace_id, name, type, status, owner_user_id, visibility, readiness, risk, created_at, updated_at) VALUES (?, ?, ?, '模板', 'template', ?, 'workspace', 48, 'low', ?, ?)", ["template-original-manhua", "ws-pilot", "原创国漫短剧模板", "u-owner", timestamp, timestamp]);
|
||||
insertIgnore("INSERT OR IGNORE INTO project_members(id, project_id, user_id, role_key, status, created_at, updated_at) VALUES (?, ?, ?, 'project_editor', 'active', ?, ?)", ["pm-template-owner", "template-original-manhua", "u-owner", timestamp, timestamp]);
|
||||
insertIgnore("INSERT OR IGNORE INTO projects(id, workspace_id, name, type, status, owner_user_id, visibility, readiness, risk, created_at, updated_at) VALUES (?, ?, ?, 'AI 漫剧', 'production', ?, 'workspace', 35, 'medium', ?, ?)", ["northstar-pilot", "ws-northstar-main", "山海志异·试播集", "u-producer", timestamp, timestamp]);
|
||||
insertIgnore("INSERT OR IGNORE INTO project_members(id, project_id, user_id, role_key, status, created_at, updated_at) VALUES (?, ?, ?, 'project_editor', 'active', ?, ?)", ["pm-northstar-producer", "northstar-pilot", "u-producer", timestamp, timestamp]);
|
||||
}
|
||||
|
||||
function seedModels() {
|
||||
const timestamp = now();
|
||||
const models = [
|
||||
["owned-i2v", "org-studio-lab", "ws-local-aidrama", "自有图生视频平台", "http-json", ["image-to-video", "first-last-frame", "vertical-video"], "http://127.0.0.1:7860/api/generate/i2v", "not-connected", "local", 0, "u-owner"],
|
||||
["owned-image", "org-studio-lab", "ws-local-aidrama", "自有图片/改图平台", "http-json", ["text-to-image", "image-edit", "single-frame"], "http://127.0.0.1:7860/api/generate/image", "not-connected", "local", 0, "u-owner"],
|
||||
["local-tts", "org-studio-lab", "ws-local-aidrama", "本地固定声线 TTS", "http-json", ["tts", "voice-lock", "subtitle-timing"], "http://127.0.0.1:7861/api/tts", "planned", "local", 0, "u-owner"],
|
||||
["newapi-audio-production", "org-studio-lab", "ws-local-aidrama", "NewAPI 音频中转", "openai-compatible-audio", ["tts", "voice-lock", "emotion-control", "asr", "subtitle-timing"], "https://newapi.ysblack.com/v1", "ready", "mixed", 1, "u-owner"],
|
||||
["comfyui-optional", "org-studio-lab", "ws-local-aidrama", "ComfyUI 工作流桥接", "comfyui", ["workflow", "qwen-image", "qwen-edit"], "http://127.0.0.1:8188", "optional", "mixed", 1, "u-owner"],
|
||||
["northstar-image", "org-northstar", "ws-northstar-main", "北辰本地图像 Runner", "http-json", ["text-to-image", "single-frame"], "http://127.0.0.1:7960/api/image", "ready", "local", 0, "u-producer"]
|
||||
];
|
||||
for (const [id, organizationId, workspaceId, label, kind, capabilities, endpoint, status, costMode, approvalRequired, createdBy] of models) {
|
||||
insertIgnore("INSERT OR IGNORE INTO model_connectors(id, organization_id, workspace_id, label, kind, capabilities_json, endpoint, status, cost_mode, approval_required, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [id, organizationId, workspaceId, label, kind, JSON.stringify(capabilities), endpoint, status, costMode, approvalRequired, createdBy, timestamp, timestamp]);
|
||||
}
|
||||
}
|
||||
|
||||
function seedSystemGovernance() {
|
||||
const timestamp = now();
|
||||
insertIgnore("INSERT OR IGNORE INTO system_admins(user_id, role_key, status, created_at, updated_at) VALUES ('u-owner', 'system_admin', 'active', ?, ?)", [timestamp, timestamp]);
|
||||
insertIgnore("INSERT OR IGNORE INTO identity_policies(id, password_login_enabled, mfa_required_for_admins, mfa_required_for_all, sso_enabled, local_login_fallback, session_ttl_hours, max_sessions_per_user, updated_by, created_at, updated_at) VALUES ('default', 1, 0, 0, 0, 1, 12, 10, 'u-owner', ?, ?)", [timestamp, timestamp]);
|
||||
const settings = [
|
||||
["app.name", "general", "AI 短剧生产平台", "string", "平台显示名称", 0],
|
||||
["app.locale", "general", "zh-CN", "string", "默认界面语言", 0],
|
||||
["app.timezone", "general", "Asia/Shanghai", "string", "默认时区", 0],
|
||||
["deployment.mode", "deployment", "private-local", "string", "部署模式", 0],
|
||||
["deployment.local_runner_only", "deployment", true, "boolean", "默认只允许本地 Runner", 0],
|
||||
["storage.root_path", "storage", resolve(projectRoot, "storage"), "path", "资产和生成产物根目录", 0],
|
||||
["storage.max_upload_mb", "storage", 1024, "number", "单文件最大上传大小", 0],
|
||||
["queue.max_concurrency", "queue", 2, "number", "全局并发任务数", 0],
|
||||
["generation.single_frame_only", "generation", true, "boolean", "一图一画面策略", 0],
|
||||
["generation.require_actual_last_frame", "generation", true, "boolean", "视频片段必须使用真实末帧连续", 0],
|
||||
["generation.comfyui_optional", "generation", true, "boolean", "ComfyUI 仅作为可选适配器", 0],
|
||||
["voice.fixed_voice_required", "voice", true, "boolean", "角色必须绑定固定声线", 0],
|
||||
["qa.require_asr_alignment", "qa", true, "boolean", "配音任务必须通过 ASR/字幕对齐", 0],
|
||||
["qa.require_continuity_lock", "qa", true, "boolean", "镜头生成必须引用连续性锁", 0],
|
||||
["notifications.email_enabled", "notifications", false, "boolean", "启用邮件通知", 0],
|
||||
["api.rate_limit_per_minute", "api", 120, "number", "API 每分钟请求上限", 0]
|
||||
];
|
||||
for (const [key, category, value, valueType, description, sensitive] of settings) {
|
||||
insertIgnore("INSERT OR IGNORE INTO system_settings(key, category, value_json, value_type, description, is_sensitive, updated_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'u-owner', ?, ?)", [key, category, JSON.stringify(value), valueType, description, sensitive, timestamp, timestamp]);
|
||||
}
|
||||
|
||||
const flags = [
|
||||
["creator_portal", "用户创作空间", "项目、剧本、资产和交付工作台", 1],
|
||||
["admin_console", "管理员后台", "组织、成员、模型、队列和计量治理", 1],
|
||||
["system_settings", "系统配置中心", "部署、生成策略、存储和通知配置", 1],
|
||||
["batch_generation", "批量生成", "允许制片人创建批量生成批次", 1],
|
||||
["review_workflow", "审片审批流", "启用多人审片和交付审批", 1],
|
||||
["comfyui_adapter", "ComfyUI 适配器", "可选的本地 ComfyUI 工作流桥接", 0],
|
||||
["external_cloud_connectors", "外部云连接器", "允许接入付费或云端模型", 0]
|
||||
];
|
||||
for (const [key, label, description, enabled] of flags) {
|
||||
insertIgnore("INSERT OR IGNORE INTO feature_flags(key, label, description, enabled, scope, updated_by, created_at, updated_at) VALUES (?, ?, ?, ?, 'system', 'u-owner', ?, ?)", [key, label, description, enabled, timestamp, timestamp]);
|
||||
}
|
||||
|
||||
insertIgnore("INSERT OR IGNORE INTO notification_channels(id, name, kind, endpoint, enabled, events_json, secret_ref, created_by, created_at, updated_at) VALUES ('notify-local-log', '本地事件日志', 'local-log', '', 1, ?, '', 'u-owner', ?, ?)", [JSON.stringify(["job.failed", "review.blocked", "quota.warning", "system.changed"]), timestamp, timestamp]);
|
||||
insertIgnore("INSERT OR IGNORE INTO notification_channels(id, name, kind, endpoint, enabled, events_json, secret_ref, created_by, created_at, updated_at) VALUES ('notify-webhook-optional', '本地 Webhook(可选)', 'webhook', 'http://127.0.0.1:8790/hooks/ai-drama', 0, ?, '', 'u-owner', ?, ?)", [JSON.stringify(["job.completed", "delivery.approved"]), timestamp, timestamp]);
|
||||
const seedApiKeyHash = hashApiClientKey("local-runner-demo-key");
|
||||
insertIgnore("INSERT OR IGNORE INTO api_clients(id, name, client_key, client_key_hash, client_key_prefix, key_version, status, scopes_json, created_by, created_at, updated_at) VALUES ('client-local-runner', '本地 Runner 客户端', ?, ?, 'local-runner-', 2, 'active', ?, 'u-owner', ?, ?)", [apiClientStorageMarker(seedApiKeyHash), seedApiKeyHash, JSON.stringify(["jobs:read", "jobs:write", "models:read"]), timestamp, timestamp]);
|
||||
dbRun("UPDATE api_clients SET organization_id = COALESCE(organization_id, 'org-studio-lab'), workspace_id = COALESCE(workspace_id, 'ws-local-aidrama') WHERE id = 'client-local-runner'");
|
||||
|
||||
const services = [
|
||||
["service-local-api", "local-api", "本地 API 服务", "local-service", "ready", "http://127.0.0.1:8787/api/health", 2, 0, "node-24"],
|
||||
["service-sqlite", "sqlite", "SQLite 租户数据库", "database", "ready", dbPath, 1, 0, "node:sqlite"],
|
||||
["service-script-runner", "script-runner", "剧本拆解 Runner", "runner", "ready", "local://script", 4, 0, "local"],
|
||||
["service-image-runner", "image-runner", "单画面 Runner", "runner", "waiting-model", "local://image", null, 3, "local"],
|
||||
["service-video-runner", "video-runner", "视频片段 Runner", "runner", "waiting-model", "local://video", null, 2, "local"],
|
||||
["service-voice-runner", "voice-runner", "固定配音 Runner", "runner", "planned", "local://voice", null, 6, "local"],
|
||||
["service-local-worker", "local-worker", "本地后台 Worker", "runner", "ready", "local://worker", 0, 0, "node-24"]
|
||||
];
|
||||
for (const [id, serviceKey, label, kind, status, endpoint, latency, queueDepth, version] of services) {
|
||||
insertIgnore("INSERT OR IGNORE INTO service_health(id, service_key, label, kind, status, endpoint, latency_ms, queue_depth, version, last_heartbeat, metadata_json, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '{}', ?)", [id, serviceKey, label, kind, status, endpoint, latency, queueDepth, version, status === "ready" ? timestamp : null, timestamp]);
|
||||
}
|
||||
}
|
||||
|
||||
function seedUserNotifications() {
|
||||
const timestamp = now();
|
||||
insertIgnore(
|
||||
"INSERT OR IGNORE INTO user_notifications(id, user_id, organization_id, workspace_id, project_id, category, event_key, severity, title, body, target_tab, target_id, metadata_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
[
|
||||
"user-notification-seed-review",
|
||||
"u-owner",
|
||||
"org-studio-lab",
|
||||
"ws-local-aidrama",
|
||||
"thunder-mouth",
|
||||
"review",
|
||||
"review.changes_requested",
|
||||
"warning",
|
||||
"E01-S02 连续性检查需要确认",
|
||||
"角色服装与上一段实际末帧的连续性证据已提交,请在审片中心确认后再进入下一轮生成。",
|
||||
"qa",
|
||||
"review-thunder-mouth-continuity",
|
||||
JSON.stringify({ source: "seed", lane: "continuity-lock" }),
|
||||
timestamp
|
||||
]
|
||||
);
|
||||
insertIgnore(
|
||||
"INSERT OR IGNORE INTO user_notifications(id, user_id, organization_id, workspace_id, project_id, category, event_key, severity, title, body, target_tab, target_id, metadata_json, created_at, read_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
[
|
||||
"user-notification-seed-policy",
|
||||
"u-owner",
|
||||
"org-studio-lab",
|
||||
"ws-local-aidrama",
|
||||
null,
|
||||
"system",
|
||||
"system.changed",
|
||||
"info",
|
||||
"本地生产策略已生效",
|
||||
"平台当前保持 local-only:ComfyUI 仅作为可选适配器,外部云连接器默认关闭。",
|
||||
"system-generation",
|
||||
"generation.single_frame_only",
|
||||
JSON.stringify({ source: "seed", policy: "local-only" }),
|
||||
timestamp,
|
||||
timestamp
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
function seedProductionGraph() {
|
||||
const timestamp = now();
|
||||
insertIgnore("INSERT OR IGNORE INTO series(id, project_id, title, logline, format, visual_style, continuity_rule, show_engine, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ["series-thunder-mouth", "thunder-mouth", sampleProject.series.title, sampleProject.series.logline, sampleProject.series.format, sampleProject.series.visualStyle, sampleProject.series.continuityRule, sampleProject.series.showEngine, timestamp, timestamp]);
|
||||
insertIgnore("INSERT OR IGNORE INTO seasons(id, series_id, season_number, title, created_at, updated_at) VALUES (?, ?, 1, '第一季', ?, ?)", ["season-thunder-mouth-1", "series-thunder-mouth", timestamp, timestamp]);
|
||||
insertIgnore("INSERT OR IGNORE INTO episodes(id, season_id, episode_number, title, status, target_duration_sec, hook, cliffhanger, created_at, updated_at) VALUES (?, ?, 1, ?, 'production', ?, ?, ?, ?, ?)", ["episode-thunder-mouth-01", "season-thunder-mouth-1", sampleProject.episode.title, sampleProject.episode.targetDurationSec, sampleProject.episode.hook, sampleProject.episode.cliffhanger, timestamp, timestamp]);
|
||||
dbRun("UPDATE series SET show_engine = COALESCE(NULLIF(show_engine, ''), ?) WHERE id = ?", [sampleProject.series.showEngine, "series-thunder-mouth"]);
|
||||
dbRun("UPDATE episodes SET target_duration_sec = CASE WHEN target_duration_sec = 0 THEN ? ELSE target_duration_sec END, hook = COALESCE(NULLIF(hook, ''), ?), cliffhanger = COALESCE(NULLIF(cliffhanger, ''), ?) WHERE id = ?", [sampleProject.episode.targetDurationSec, sampleProject.episode.hook, sampleProject.episode.cliffhanger, "episode-thunder-mouth-01"]);
|
||||
for (const shot of sampleProject.shots) {
|
||||
insertIgnore("INSERT OR IGNORE INTO shots(id, episode_id, shot_number, title, status, first_frame_path, last_frame_path, continuity_json, created_at, updated_at) VALUES (?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?)", [shot.id, "episode-thunder-mouth-01", Number(shot.id.split("-").pop()) || 1, shot.title, shot.firstFrame, shot.lastFrame, JSON.stringify({ transition: shot.transitionFromPrevious, camera: shot.camera }), timestamp, timestamp]);
|
||||
insertIgnore("INSERT OR IGNORE INTO shot_versions(id, shot_id, version_number, payload_json, status, created_by, created_at) VALUES (?, ?, 1, ?, 'locked', 'u-owner', ?)", [`${shot.id}-v1`, shot.id, JSON.stringify(shot), timestamp]);
|
||||
for (let index = 0; index < (shot.voiceLines || []).length; index += 1) {
|
||||
const line = shot.voiceLines[index];
|
||||
insertIgnore("INSERT OR IGNORE INTO voice_lines(id, shot_id, line_number, character_key, text, emotion, mouth_plan, target_duration_sec, voice_id, audio_path, status, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', 'u-owner', ?, ?)", [line.id, shot.id, index + 1, line.characterId, line.text, line.emotion || "", line.mouthPlan || "规避嘴形", line.targetDurationSec || 2, "", line.audioFile || "", timestamp, timestamp]);
|
||||
}
|
||||
}
|
||||
insertIgnore("INSERT OR IGNORE INTO script_documents(id, organization_id, workspace_id, project_id, episode_id, version_number, title, source_type, content, status, analysis_json, created_by, created_at, updated_at) VALUES (?, 'org-studio-lab', 'ws-local-aidrama', 'thunder-mouth', 'episode-thunder-mouth-01', 1, ?, '原创短剧剧本', ?, 'analyzed', ?, 'u-owner', ?, ?)", [
|
||||
"script-thunder-mouth-v1",
|
||||
`第 1 集《${sampleProject.episode.title}》`,
|
||||
`第 1 集《${sampleProject.episode.title}》\n\n${sampleProject.episode.hook}\n\n陈宇:等一下,先别出去。雷暴来了,树下和金属牌旁边都不安全。\n唐夏:那我现在该往哪走?\n陈宇:看左边,积水旁可能有落地电线。别靠近,也别跨警戒线。\n唐夏:我给家里报个平安。`,
|
||||
JSON.stringify({ chapters: sampleProject.scriptStudio.chapters, extracted: sampleProject.scriptStudio.extracted, parser: "local-rule-v1" }),
|
||||
timestamp,
|
||||
timestamp
|
||||
]);
|
||||
const assetSeeds = [
|
||||
["asset-character-chen-yu", "character", "陈宇", "青年志愿者", "locked", "approved", "assets/thunder-mouth/characters/chen-yu/v1/portrait.png", { initial: "陈", tags: ["角色锁", "衣橱", "声线"], usage: "S01 全季", detail: sampleProject.characters[0].visualLock, lock: sampleProject.characters[0].costumeState }],
|
||||
["asset-character-tang-xia", "character", "唐夏", "高中女生", "locked", "approved", "assets/thunder-mouth/characters/tang-xia/v1/portrait.png", { initial: "唐", tags: ["角色锁", "衣橱", "声线"], usage: "S01 前 6 集", detail: sampleProject.characters[1].visualLock, lock: sampleProject.characters[1].costumeState }],
|
||||
["asset-location-metro-canopy-rain", "location", "地铁口玻璃连廊", "傍晚雷暴场景", "locked", "approved", "assets/thunder-mouth/locations/metro-canopy-rain/v1/location.png", { initial: "景", tags: ["场景锁", "天气", "机位"], usage: "E01 全部镜头", detail: sampleProject.locations[0].lock, lock: sampleProject.locations[0].cameraLock }],
|
||||
["asset-prop-blue-umbrella", "prop", "折叠蓝伞", "连续性道具", "locked", "approved", "assets/thunder-mouth/props/blue-umbrella/v1/prop.png", { initial: "伞", tags: ["道具锁", "陈宇"], usage: "shot-01 ~ shot-03", detail: sampleProject.props[0].lock, lock: "右手低握,未打开" }],
|
||||
["asset-prop-yellow-poncho", "prop", "黄色雨披", "连续性道具", "locked", "approved", "assets/thunder-mouth/props/yellow-poncho/v1/prop.png", { initial: "披", tags: ["道具锁", "唐夏"], usage: "shot-01 ~ shot-03", detail: sampleProject.props[1].lock, lock: "折在双臂上,不能突然穿上" }],
|
||||
["asset-prop-warning-cones", "prop", "路锥和警戒线", "安全道具", "locked", "approved", "assets/thunder-mouth/props/warning-cones/v1/prop.png", { initial: "警", tags: ["道具锁", "安全"], usage: "shot-02", detail: sampleProject.props[2].lock, lock: "角色不跨越" }],
|
||||
["asset-prop-phone", "prop", "手机", "连续性道具", "locked", "approved", "assets/thunder-mouth/props/phone/v1/prop.png", { initial: "机", tags: ["道具锁", "shot-03"], usage: "shot-03", detail: sampleProject.props[3].lock, lock: "低位拿出,不遮挡脸" }],
|
||||
["asset-voice-chen-yu", "voice", "陈宇固定参考音频", "自然男声 · 已授权", "locked", "approved", "assets/thunder-mouth/voices/chen-yu/v1/reference.wav", { initial: "声", tags: ["声线锁", "IndexTTS-2.5", "已授权"], usage: "S01 全季", detail: "自然、克制、年轻男声;只作为陈宇正式参考音频。", lock: "必须保留授权证据引用,不使用随机原生声线。", voiceId: "voice-chen-yu", ttsModel: "IndexTTS-2.5", consentRef: "local-consent/chen-yu-v1" }],
|
||||
["asset-voice-tang-xia", "voice", "唐夏固定参考音频", "自然女声 · 待补证据", "review", "needs-evidence", "assets/thunder-mouth/voices/tang-xia/v1/reference.wav", { initial: "声", tags: ["声线锁", "待授权证据"], usage: "S01 前 6 集", detail: "自然、清晰、带轻微紧张感的女声;正式批量配音前必须补齐授权证据。", lock: "单句试听可以排队,未审批前禁止批量 TTS。", voiceId: "voice-tang-xia", ttsModel: "IndexTTS-2.5", consentRef: "" }],
|
||||
["asset-style-cel-shading", "style", "国漫 2D 赛璐璐基线", "项目视觉基线", "locked", "approved", "assets/thunder-mouth/styles/cel-shading/v1/style.json", { initial: "风", tags: ["视觉锁", "9:16", "雨景"], usage: "全季复用", detail: sampleProject.series.visualStyle, lock: "禁止多格、拼图、多时间点" }],
|
||||
["asset-lora-rain-local", "lora", "雷雨口 · 本地一致性适配", "自有模型平台资产", "draft", "needs-evidence", "assets/thunder-mouth/lora/rain-local/v1/model.safetensors", { initial: "L", tags: ["local-only", "角色", "场景"], usage: "shot-01 ~ shot-03", detail: "仅作为自有模型平台可选增强,不替代角色、场景、道具锁。", lock: "美术负责人确认后才能进入生产" }]
|
||||
];
|
||||
for (const [id, kind, name, subtitle, lockStatus, rightsStatus, storagePath, metadata] of assetSeeds) {
|
||||
insertIgnore("INSERT OR IGNORE INTO assets(id, project_id, kind, name, lock_status, created_by, created_at, updated_at) VALUES (?, 'thunder-mouth', ?, ?, ?, 'u-owner', ?, ?)", [id, kind, name, lockStatus, timestamp, timestamp]);
|
||||
insertIgnore("INSERT OR IGNORE INTO asset_versions(id, asset_id, version_number, storage_path, rights_status, metadata_json, created_by, created_at) VALUES (?, ?, 1, ?, ?, ?, 'u-owner', ?)", [`${id}-v1`, id, storagePath, rightsStatus, JSON.stringify({ subtitle, ...metadata }), timestamp]);
|
||||
dbRun("UPDATE assets SET current_version_id = COALESCE(current_version_id, ?), updated_at = ? WHERE id = ?", [`${id}-v1`, timestamp, id]);
|
||||
}
|
||||
const bindingSeeds = [
|
||||
["asset-character-chen-yu", ["shot-01", "shot-02", "shot-03"], "character"],
|
||||
["asset-character-tang-xia", ["shot-01", "shot-02", "shot-03"], "character"],
|
||||
["asset-location-metro-canopy-rain", ["shot-01", "shot-02", "shot-03"], "location"],
|
||||
["asset-prop-blue-umbrella", ["shot-01", "shot-02", "shot-03"], "prop"],
|
||||
["asset-prop-yellow-poncho", ["shot-01", "shot-02", "shot-03"], "prop"],
|
||||
["asset-prop-warning-cones", ["shot-02"], "prop"],
|
||||
["asset-prop-phone", ["shot-03"], "prop"]
|
||||
];
|
||||
for (const [assetId, shotIds, usageRole] of bindingSeeds) {
|
||||
for (const shotId of shotIds) insertIgnore("INSERT OR IGNORE INTO asset_bindings(id, asset_id, shot_id, usage_role, created_by, created_at) VALUES (?, ?, ?, ?, 'u-owner', ?)", [`binding-${assetId}-${shotId}`, assetId, shotId, usageRole, timestamp]);
|
||||
}
|
||||
for (const job of sampleProject.productionJobs) {
|
||||
const shotId = sampleProject.shots.some((shot) => shot.id === job.shotId) ? job.shotId : null;
|
||||
insertIgnore("INSERT OR IGNORE INTO generation_jobs(id, organization_id, workspace_id, project_id, episode_id, shot_id, kind, adapter_id, status, priority, cost_policy, output_path, qa_status, created_by, created_at, updated_at) VALUES (?, 'org-studio-lab', 'ws-local-aidrama', 'thunder-mouth', 'episode-thunder-mouth-01', ?, ?, ?, ?, 50, ?, ?, ?, 'u-owner', ?, ?)", [job.id, shotId, job.kind, job.adapter, job.status, job.costPolicy, job.output, job.qa, timestamp, timestamp]);
|
||||
}
|
||||
insertIgnore("INSERT OR IGNORE INTO usage_events(id, organization_id, workspace_id, project_id, user_id, kind, units, unit_name, estimated_cost, metadata_json, created_at) VALUES ('usage-seed-001', 'org-studio-lab', 'ws-local-aidrama', 'thunder-mouth', 'u-owner', 'image-generation', 36, 'clips', 86, '{\"source\":\"seed\"}', ?)", [timestamp]);
|
||||
const audits = [
|
||||
["aud-001", "org-studio-lab", "ws-local-aidrama", "thunder-mouth", "u-owner", "project.created", "project", "thunder-mouth", "ok", "{}"],
|
||||
["aud-002", "org-studio-lab", "ws-local-aidrama", "thunder-mouth", "u-owner", "policy.single-frame.checked", "shot", "shot-01", "pass", "{\"gate\":\"single_frame_only\"}"],
|
||||
["aud-003", "org-studio-lab", "ws-local-aidrama", "thunder-mouth", "u-owner", "exports.write", "delivery", "project-template", "ok", "{}"],
|
||||
["aud-004", "org-studio-lab", "ws-local-aidrama", "thunder-mouth", "u-owner", "cloud.connector.blocked", "model_connector", "paid-node", "requires-approval", "{\"policy\":\"local_runner_only\"}"]
|
||||
];
|
||||
for (const audit of audits) {
|
||||
insertIgnore("INSERT OR IGNORE INTO audit_logs(id, organization_id, workspace_id, project_id, actor_user_id, action, target_type, target_id, result, metadata_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [...audit, timestamp]);
|
||||
}
|
||||
}
|
||||
|
||||
withTransaction(() => {
|
||||
seedRoles();
|
||||
seedUsers();
|
||||
seedCredentials();
|
||||
seedOrganization({
|
||||
id: "org-studio-lab",
|
||||
name: "星河短剧实验室",
|
||||
slug: "xinghe-studio",
|
||||
ownerUserId: "u-owner",
|
||||
description: "本地 AI 漫剧与短剧主生产空间",
|
||||
workspaceId: "ws-local-aidrama",
|
||||
workspaceName: "短剧生产中心",
|
||||
workspaceSlug: "drama-production",
|
||||
projectId: "thunder-mouth",
|
||||
projectName: "雷雨口",
|
||||
projectType: "AI 漫剧",
|
||||
readiness: 72,
|
||||
risk: "medium"
|
||||
});
|
||||
seedOrganization({
|
||||
id: "org-northstar",
|
||||
name: "北辰内容厂牌",
|
||||
slug: "northstar-content",
|
||||
ownerUserId: "u-owner",
|
||||
description: "第二个客户组织,用于验证跨组织隔离",
|
||||
workspaceId: "ws-northstar-main",
|
||||
workspaceName: "北辰试制部",
|
||||
workspaceSlug: "pilot-room",
|
||||
projectId: "northstar-pilot",
|
||||
projectName: "山海志异·试播集",
|
||||
projectType: "AI 漫剧",
|
||||
readiness: 35,
|
||||
risk: "medium"
|
||||
});
|
||||
insertIgnore("INSERT OR IGNORE INTO workspaces(id, organization_id, name, slug, description, status, created_at, updated_at) VALUES ('ws-pilot', 'org-studio-lab', '素材实验室', 'asset-lab', '角色、场景和模型试验空间', 'active', ?, ?)", [now(), now()]);
|
||||
seedMembersAndProjects();
|
||||
seedModels();
|
||||
seedSystemGovernance();
|
||||
seedProductionGraph();
|
||||
seedUserNotifications();
|
||||
});
|
||||
|
||||
export function resetDatabaseForTests() {
|
||||
db.exec("DELETE FROM audit_logs; DELETE FROM usage_events; DELETE FROM generation_jobs;");
|
||||
}
|
||||
|
||||
export const databaseInfo = {
|
||||
path: dbPath,
|
||||
engine: "node:sqlite",
|
||||
seededAt: now()
|
||||
};
|
||||
@@ -0,0 +1,546 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { basename, dirname, relative, resolve } from "node:path";
|
||||
import { dbAll, dbGet, dbRun } from "./db.mjs";
|
||||
import { addAudit, hasPermission, httpError, requirePermission } from "./tenant.mjs";
|
||||
|
||||
const projectRoot = resolve(import.meta.dirname, "..");
|
||||
const defaultExpiryDays = 7;
|
||||
const maxExpiryDays = 365;
|
||||
const maxDownloadsLimit = 10000;
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function parseJson(value, fallback) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function makeId(prefix) {
|
||||
return `${prefix}-${Date.now()}-${randomBytes(7).toString("hex")}`;
|
||||
}
|
||||
|
||||
function hashToken(token) {
|
||||
return createHash("sha256").update(String(token)).digest("hex");
|
||||
}
|
||||
|
||||
function tokenFingerprint(tokenHash) {
|
||||
return String(tokenHash || "").slice(0, 16);
|
||||
}
|
||||
|
||||
function safeStoragePath(value) {
|
||||
const relativePath = String(value || "").trim();
|
||||
if (!relativePath || relativePath.startsWith("/") || !relativePath.startsWith("storage/")) return null;
|
||||
const segments = relativePath.split(/[\\/]+/);
|
||||
if (segments.includes("..")) return null;
|
||||
const storageRoot = resolve(projectRoot, "storage");
|
||||
const absolute = resolve(projectRoot, relativePath);
|
||||
const storageRelative = relative(storageRoot, absolute);
|
||||
if (!storageRelative || storageRelative.startsWith("..") || storageRelative.includes("..")) return null;
|
||||
return { relative: relativePath, absolute };
|
||||
}
|
||||
|
||||
function safeFileSegment(value, fallback = "delivery") {
|
||||
const normalized = String(value || "").trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
function requestInfoOf(requestInfo = {}) {
|
||||
return {
|
||||
ipAddress: String(requestInfo.ipAddress || "").slice(0, 200),
|
||||
userAgent: String(requestInfo.userAgent || "").slice(0, 1000)
|
||||
};
|
||||
}
|
||||
|
||||
function requireDeliveryView(context) {
|
||||
if (!hasPermission(context, "delivery:view") && !hasPermission(context, "delivery:approve")) {
|
||||
throw httpError(403, "permission_denied", "当前角色没有交付门户查看权限", { permission: "delivery:view" });
|
||||
}
|
||||
}
|
||||
|
||||
function releaseForContext(context, releaseId) {
|
||||
if (!context.project) throw httpError(400, "project_required", "交付门户必须绑定项目");
|
||||
const release = dbGet(
|
||||
`SELECT dr.*, d.version AS delivery_version, d.status AS delivery_status,
|
||||
d.manifest_path AS delivery_manifest_path, d.project_id AS delivery_project_id,
|
||||
p.name AS project_name
|
||||
FROM delivery_releases dr
|
||||
JOIN deliveries d ON d.id = dr.delivery_id
|
||||
JOIN projects p ON p.id = dr.project_id
|
||||
WHERE dr.id = ? AND dr.organization_id = ? AND dr.workspace_id = ? AND dr.project_id = ?
|
||||
AND d.organization_id = dr.organization_id AND d.workspace_id = dr.workspace_id
|
||||
AND d.project_id = dr.project_id`,
|
||||
[releaseId, context.organization.id, context.workspace.id, context.project.id]
|
||||
);
|
||||
if (!release) throw httpError(404, "delivery_release_not_found", "发布记录不存在或不属于当前项目", { releaseId });
|
||||
return release;
|
||||
}
|
||||
|
||||
function linkForContext(context, linkId) {
|
||||
if (!context.project) throw httpError(400, "project_required", "交付门户必须绑定项目");
|
||||
const row = dbGet(
|
||||
`SELECT dal.*, dr.status AS release_status, dr.output_path, dr.published_at,
|
||||
dr.delivery_id, d.version AS delivery_version, p.name AS project_name,
|
||||
creator.display_name AS created_by_name,
|
||||
(SELECT COUNT(*) FROM delivery_access_events e WHERE e.link_id = dal.id AND e.event_type = 'view' AND e.result = 'success') AS view_count,
|
||||
(SELECT COUNT(*) FROM delivery_access_events e WHERE e.link_id = dal.id AND e.event_type = 'download' AND e.result = 'success') AS download_event_count,
|
||||
(SELECT MAX(e.created_at) FROM delivery_access_events e WHERE e.link_id = dal.id) AS last_access_at
|
||||
FROM delivery_access_links dal
|
||||
JOIN delivery_releases dr ON dr.id = dal.release_id
|
||||
JOIN deliveries d ON d.id = dr.delivery_id
|
||||
JOIN projects p ON p.id = dal.project_id
|
||||
LEFT JOIN users creator ON creator.id = dal.created_by
|
||||
WHERE dal.id = ? AND dal.organization_id = ? AND dal.workspace_id = ? AND dal.project_id = ?`,
|
||||
[linkId, context.organization.id, context.workspace.id, context.project.id]
|
||||
);
|
||||
if (!row) throw httpError(404, "delivery_access_link_not_found", "交付访问链接不存在或不属于当前项目", { linkId });
|
||||
return row;
|
||||
}
|
||||
|
||||
function linkState(row, at = Date.now()) {
|
||||
if (row.revoked_at || row.status === "revoked") return "revoked";
|
||||
if (row.status === "expired" || new Date(row.expires_at).getTime() <= at) return "expired";
|
||||
return "active";
|
||||
}
|
||||
|
||||
function syncExpired(row) {
|
||||
if (linkState(row) !== "expired" || row.status === "expired" || row.status === "revoked") return row;
|
||||
const timestamp = now();
|
||||
dbRun("UPDATE delivery_access_links SET status = 'expired', updated_at = ? WHERE id = ? AND status = 'active'", [timestamp, row.id]);
|
||||
return { ...row, status: "expired", updated_at: timestamp };
|
||||
}
|
||||
|
||||
function linkPayload(input) {
|
||||
if (!input) return null;
|
||||
const row = syncExpired(input);
|
||||
const state = linkState(row);
|
||||
const maxDownloads = Number(row.max_downloads || 0);
|
||||
const downloadCount = Number(row.download_count || 0);
|
||||
const feedback = feedbackSummaryForLink(row.id);
|
||||
return {
|
||||
id: row.id,
|
||||
releaseId: row.release_id,
|
||||
deliveryId: row.delivery_id,
|
||||
deliveryVersion: row.delivery_version,
|
||||
projectName: row.project_name,
|
||||
recipientName: row.recipient_name,
|
||||
recipientEmail: row.recipient_email,
|
||||
status: state,
|
||||
expiresAt: row.expires_at,
|
||||
maxDownloads,
|
||||
downloadCount,
|
||||
downloadsRemaining: Math.max(0, maxDownloads - downloadCount),
|
||||
viewCount: Number(row.view_count || 0),
|
||||
downloadEventCount: Number(row.download_event_count || 0),
|
||||
tokenHint: row.token_hint,
|
||||
createdBy: row.created_by,
|
||||
createdByName: row.created_by_name,
|
||||
createdAt: row.created_at,
|
||||
lastViewedAt: row.last_viewed_at,
|
||||
lastDownloadedAt: row.last_downloaded_at,
|
||||
lastAccessAt: row.last_access_at,
|
||||
revokedAt: row.revoked_at,
|
||||
clientReviewStatus: feedback.status,
|
||||
clientReviewMessage: feedback.message,
|
||||
clientReviewerName: feedback.reviewerName,
|
||||
clientReviewerEmail: feedback.reviewerEmail,
|
||||
clientReviewedAt: feedback.submittedAt,
|
||||
clientReviewCount: feedback.count
|
||||
};
|
||||
}
|
||||
|
||||
function addAccessEvent(row, eventType, result, requestInfo, fileKind = "", metadata = {}) {
|
||||
const info = requestInfoOf(requestInfo);
|
||||
const tokenHash = row?.token_hash || String(metadata.tokenHash || "");
|
||||
dbRun(
|
||||
`INSERT INTO delivery_access_events(
|
||||
id, link_id, release_id, organization_id, workspace_id, project_id,
|
||||
event_type, result, file_kind, token_fingerprint, ip_address, user_agent,
|
||||
metadata_json, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
makeId("delivery-access-event"),
|
||||
row?.id || null,
|
||||
row?.release_id || null,
|
||||
row?.organization_id || null,
|
||||
row?.workspace_id || null,
|
||||
row?.project_id || null,
|
||||
eventType,
|
||||
result,
|
||||
fileKind,
|
||||
tokenFingerprint(tokenHash),
|
||||
info.ipAddress,
|
||||
info.userAgent,
|
||||
JSON.stringify(metadata),
|
||||
now()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeExpiry(value) {
|
||||
const candidate = value ? new Date(String(value)) : new Date(Date.now() + defaultExpiryDays * 24 * 60 * 60 * 1000);
|
||||
if (Number.isNaN(candidate.getTime())) throw httpError(400, "delivery_access_expiry_invalid", "访问链接有效期不是有效日期");
|
||||
if (candidate.getTime() <= Date.now()) throw httpError(400, "delivery_access_expiry_past", "访问链接有效期必须晚于当前时间");
|
||||
if (candidate.getTime() > Date.now() + maxExpiryDays * 24 * 60 * 60 * 1000) {
|
||||
throw httpError(400, "delivery_access_expiry_too_long", `访问链接有效期不能超过 ${maxExpiryDays} 天`);
|
||||
}
|
||||
return candidate.toISOString();
|
||||
}
|
||||
|
||||
function normalizeMaxDownloads(value) {
|
||||
const number = Number(value ?? 10);
|
||||
if (!Number.isInteger(number) || number < 1 || number > maxDownloadsLimit) {
|
||||
throw httpError(400, "delivery_access_download_limit_invalid", `最大下载次数必须是 1-${maxDownloadsLimit} 的整数`);
|
||||
}
|
||||
return number;
|
||||
}
|
||||
|
||||
function normalizeRecipient(value, maxLength, label) {
|
||||
const result = String(value || "").trim();
|
||||
if (result.length > maxLength) throw httpError(400, "delivery_access_recipient_invalid", `${label}不能超过 ${maxLength} 个字符`);
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeFeedbackMessage(value, required = false) {
|
||||
const result = String(value || "").trim();
|
||||
if (result.length > 4000) throw httpError(400, "delivery_feedback_message_invalid", "客户反馈不能超过 4000 个字符");
|
||||
if (required && !result) throw httpError(400, "delivery_feedback_message_required", "提出修改意见时必须填写具体反馈");
|
||||
return result;
|
||||
}
|
||||
|
||||
function feedbackPayload(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
linkId: row.link_id,
|
||||
releaseId: row.release_id,
|
||||
decision: row.decision,
|
||||
message: row.message || "",
|
||||
reviewerName: row.reviewer_name || "",
|
||||
reviewerEmail: row.reviewer_email || "",
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
function latestFeedbackForLink(linkId) {
|
||||
return dbGet(
|
||||
"SELECT * FROM delivery_access_feedback WHERE link_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1",
|
||||
[linkId]
|
||||
);
|
||||
}
|
||||
|
||||
function feedbackSummaryForLink(linkId) {
|
||||
const latest = latestFeedbackForLink(linkId);
|
||||
const count = dbGet("SELECT COUNT(*) AS count FROM delivery_access_feedback WHERE link_id = ?", [linkId]);
|
||||
return {
|
||||
status: latest?.decision || "pending",
|
||||
message: latest?.message || "",
|
||||
reviewerName: latest?.reviewer_name || "",
|
||||
reviewerEmail: latest?.reviewer_email || "",
|
||||
submittedAt: latest?.created_at || null,
|
||||
count: Number(count?.count || 0)
|
||||
};
|
||||
}
|
||||
|
||||
export function listDeliveryAccessLinks(context, releaseId) {
|
||||
requireDeliveryView(context);
|
||||
const release = releaseForContext(context, releaseId);
|
||||
return {
|
||||
releaseId: release.id,
|
||||
release: {
|
||||
id: release.id,
|
||||
status: release.status,
|
||||
deliveryId: release.delivery_id,
|
||||
deliveryVersion: release.delivery_version,
|
||||
publishedAt: release.published_at,
|
||||
outputPath: release.status === "published" ? release.output_path : ""
|
||||
},
|
||||
accessLinks: dbAll(
|
||||
`SELECT dal.*, dr.status AS release_status, dr.output_path, dr.published_at,
|
||||
dr.delivery_id, d.version AS delivery_version, p.name AS project_name,
|
||||
creator.display_name AS created_by_name,
|
||||
(SELECT COUNT(*) FROM delivery_access_events e WHERE e.link_id = dal.id AND e.event_type = 'view' AND e.result = 'success') AS view_count,
|
||||
(SELECT COUNT(*) FROM delivery_access_events e WHERE e.link_id = dal.id AND e.event_type = 'download' AND e.result = 'success') AS download_event_count,
|
||||
(SELECT MAX(e.created_at) FROM delivery_access_events e WHERE e.link_id = dal.id) AS last_access_at
|
||||
FROM delivery_access_links dal
|
||||
JOIN delivery_releases dr ON dr.id = dal.release_id
|
||||
JOIN deliveries d ON d.id = dr.delivery_id
|
||||
JOIN projects p ON p.id = dal.project_id
|
||||
LEFT JOIN users creator ON creator.id = dal.created_by
|
||||
WHERE dal.release_id = ? AND dal.organization_id = ? AND dal.workspace_id = ? AND dal.project_id = ?
|
||||
ORDER BY dal.created_at DESC`,
|
||||
[release.id, context.organization.id, context.workspace.id, context.project.id]
|
||||
).map(linkPayload)
|
||||
};
|
||||
}
|
||||
|
||||
export function createDeliveryAccessLink(context, releaseId, body = {}, options = {}) {
|
||||
requirePermission(context, "delivery:approve");
|
||||
const release = releaseForContext(context, releaseId);
|
||||
if (release.status !== "published" || !release.output_path) {
|
||||
throw httpError(409, "delivery_access_release_not_published", "只有已发布且存在发布产物的版本可以创建客户访问链接", { releaseId, status: release.status });
|
||||
}
|
||||
const releaseFile = safeStoragePath(release.output_path);
|
||||
if (!releaseFile || basename(releaseFile.relative) !== "release.json") {
|
||||
throw httpError(409, "delivery_access_release_file_invalid", "发布产物路径不满足交付门户安全约束");
|
||||
}
|
||||
const recipientName = normalizeRecipient(body.recipientName, 120, "收件人名称");
|
||||
const recipientEmail = normalizeRecipient(body.recipientEmail, 240, "收件人邮箱");
|
||||
if (recipientEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(recipientEmail)) {
|
||||
throw httpError(400, "delivery_access_email_invalid", "收件人邮箱格式无效");
|
||||
}
|
||||
const expiresAt = normalizeExpiry(body.expiresAt);
|
||||
const maxDownloads = normalizeMaxDownloads(body.maxDownloads);
|
||||
const metadata = body.metadata && typeof body.metadata === "object" && !Array.isArray(body.metadata) ? body.metadata : {};
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
const tokenHash = hashToken(token);
|
||||
const timestamp = now();
|
||||
const id = makeId("delivery-access");
|
||||
dbRun(
|
||||
`INSERT INTO delivery_access_links(
|
||||
id, organization_id, workspace_id, project_id, release_id, token_hash, token_hint,
|
||||
recipient_name, recipient_email, status, expires_at, max_downloads, download_count,
|
||||
metadata_json, created_by, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, 0, ?, ?, ?, ?)`,
|
||||
[
|
||||
id,
|
||||
context.organization.id,
|
||||
context.workspace.id,
|
||||
context.project.id,
|
||||
release.id,
|
||||
tokenHash,
|
||||
`${token.slice(0, 10)}...`,
|
||||
recipientName,
|
||||
recipientEmail,
|
||||
expiresAt,
|
||||
maxDownloads,
|
||||
JSON.stringify(metadata),
|
||||
context.user.id,
|
||||
timestamp,
|
||||
timestamp
|
||||
]
|
||||
);
|
||||
addAudit({ context, action: "delivery.access_link.created", targetType: "delivery_access_link", targetId: id, metadata: { releaseId, recipientName, recipientEmail, expiresAt, maxDownloads } });
|
||||
const portalPath = `/portal/${encodeURIComponent(token)}`;
|
||||
const portalOrigin = String(options.portalOrigin || "").replace(/\/$/, "");
|
||||
return {
|
||||
link: linkPayload(linkForContext(context, id)),
|
||||
token,
|
||||
tokenHint: `${token.slice(0, 10)}...`,
|
||||
portalPath,
|
||||
...(portalOrigin ? { portalUrl: `${portalOrigin}${portalPath}` } : {}),
|
||||
accessLinks: listDeliveryAccessLinks(context, release.id).accessLinks
|
||||
};
|
||||
}
|
||||
|
||||
export function revokeDeliveryAccessLink(context, linkId) {
|
||||
requirePermission(context, "delivery:approve");
|
||||
const current = linkForContext(context, linkId);
|
||||
const state = linkState(current);
|
||||
if (state === "active") {
|
||||
const timestamp = now();
|
||||
dbRun("UPDATE delivery_access_links SET status = 'revoked', revoked_by = ?, revoked_at = ?, updated_at = ? WHERE id = ? AND status = 'active'", [context.user.id, timestamp, timestamp, linkId]);
|
||||
addAudit({ context, action: "delivery.access_link.revoked", targetType: "delivery_access_link", targetId: linkId, metadata: { releaseId: current.release_id } });
|
||||
}
|
||||
const link = linkForContext(context, linkId);
|
||||
return { link: linkPayload(link), accessLinks: listDeliveryAccessLinks(context, current.release_id).accessLinks };
|
||||
}
|
||||
|
||||
export function listDeliveryAccessFeedback(context, releaseId) {
|
||||
requireDeliveryView(context);
|
||||
const release = releaseForContext(context, releaseId);
|
||||
return {
|
||||
releaseId: release.id,
|
||||
feedback: dbAll(
|
||||
`SELECT f.*, dal.recipient_name, dal.recipient_email, dal.token_hint,
|
||||
creator.display_name AS created_by_name
|
||||
FROM delivery_access_feedback f
|
||||
JOIN delivery_access_links dal ON dal.id = f.link_id
|
||||
LEFT JOIN users creator ON creator.id = dal.created_by
|
||||
WHERE f.release_id = ? AND f.organization_id = ? AND f.workspace_id = ? AND f.project_id = ?
|
||||
ORDER BY f.created_at DESC, f.rowid DESC`,
|
||||
[release.id, context.organization.id, context.workspace.id, context.project.id]
|
||||
).map((row) => ({
|
||||
...feedbackPayload(row),
|
||||
recipientName: row.recipient_name,
|
||||
recipientEmail: row.recipient_email,
|
||||
tokenHint: row.token_hint,
|
||||
createdByName: row.created_by_name
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function publicLinkForToken(token, requestInfo, eventType = "view", fileKind = "") {
|
||||
const normalizedToken = String(token || "").trim();
|
||||
const tokenHash = hashToken(normalizedToken);
|
||||
const row = dbGet(
|
||||
`SELECT dal.*, dr.status AS release_status, dr.output_path, dr.published_at,
|
||||
dr.delivery_id, d.version AS delivery_version, p.name AS project_name
|
||||
FROM delivery_access_links dal
|
||||
JOIN delivery_releases dr ON dr.id = dal.release_id
|
||||
JOIN deliveries d ON d.id = dr.delivery_id
|
||||
JOIN projects p ON p.id = dal.project_id
|
||||
WHERE dal.token_hash = ?`,
|
||||
[tokenHash]
|
||||
);
|
||||
if (!row) {
|
||||
addAccessEvent({ token_hash: tokenHash }, eventType, "missing", requestInfo, fileKind, { reason: "token_not_found" });
|
||||
throw httpError(404, "delivery_access_not_found", "交付访问链接不存在");
|
||||
}
|
||||
const state = linkState(row);
|
||||
if (state === "revoked") {
|
||||
addAccessEvent(row, eventType, "denied", requestInfo, fileKind, { reason: "revoked" });
|
||||
throw httpError(404, "delivery_access_not_found", "交付访问链接不存在");
|
||||
}
|
||||
if (state === "expired") {
|
||||
syncExpired(row);
|
||||
addAccessEvent(row, eventType, "denied", requestInfo, fileKind, { reason: "expired" });
|
||||
throw httpError(410, "delivery_access_expired", "交付访问链接已过期");
|
||||
}
|
||||
if (row.release_status !== "published" || !row.output_path) {
|
||||
addAccessEvent(row, eventType, "missing", requestInfo, fileKind, { reason: "release_not_published" });
|
||||
throw httpError(404, "delivery_access_not_found", "交付版本不可用");
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
function publicPortalPayload(row, token) {
|
||||
const maxDownloads = Number(row.max_downloads || 0);
|
||||
const downloadCount = Number(row.download_count || 0);
|
||||
const latestFeedback = latestFeedbackForLink(row.id);
|
||||
const feedbackCount = dbGet("SELECT COUNT(*) AS count FROM delivery_access_feedback WHERE link_id = ?", [row.id]);
|
||||
return {
|
||||
portal: {
|
||||
status: "active",
|
||||
recipientName: row.recipient_name,
|
||||
expiresAt: row.expires_at,
|
||||
maxDownloads,
|
||||
downloadCount,
|
||||
downloadsRemaining: Math.max(0, maxDownloads - downloadCount),
|
||||
createdAt: row.created_at,
|
||||
lastAccessAt: row.last_viewed_at || null
|
||||
},
|
||||
delivery: {
|
||||
version: row.delivery_version,
|
||||
projectName: row.project_name,
|
||||
publishedAt: row.published_at
|
||||
},
|
||||
review: {
|
||||
status: latestFeedback?.decision || "pending",
|
||||
message: latestFeedback?.message || "",
|
||||
reviewerName: latestFeedback?.reviewer_name || "",
|
||||
submittedAt: latestFeedback?.created_at || null,
|
||||
count: Number(feedbackCount?.count || 0)
|
||||
},
|
||||
files: [
|
||||
{ kind: "release", label: "发布元数据", fileName: "release.json", path: `/api/public/delivery/${encodeURIComponent(token)}/file?kind=release` },
|
||||
{ kind: "manifest", label: "交付清单", fileName: "delivery-manifest.json", path: `/api/public/delivery/${encodeURIComponent(token)}/file?kind=manifest` }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePublicDeliveryPortal(token, requestInfo = {}) {
|
||||
const row = publicLinkForToken(token, requestInfo, "view");
|
||||
const timestamp = now();
|
||||
dbRun("UPDATE delivery_access_links SET last_viewed_at = ?, updated_at = ? WHERE id = ?", [timestamp, timestamp, row.id]);
|
||||
addAccessEvent(row, "view", "success", requestInfo, "", {});
|
||||
return publicPortalPayload({ ...row, last_viewed_at: timestamp }, token);
|
||||
}
|
||||
|
||||
export function submitPublicDeliveryFeedback(token, body = {}, requestInfo = {}) {
|
||||
const row = publicLinkForToken(token, requestInfo, "view", "feedback");
|
||||
const decision = String(body.decision || "").trim();
|
||||
if (!["approved", "changes_requested"].includes(decision)) {
|
||||
throw httpError(400, "delivery_feedback_decision_invalid", "客户反馈状态只能是 approved 或 changes_requested");
|
||||
}
|
||||
const message = normalizeFeedbackMessage(body.message, decision === "changes_requested");
|
||||
const reviewerName = normalizeRecipient(body.reviewerName || row.recipient_name, 120, "反馈人名称");
|
||||
const reviewerEmail = normalizeRecipient(body.reviewerEmail || row.recipient_email, 240, "反馈人邮箱");
|
||||
if (reviewerEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(reviewerEmail)) {
|
||||
throw httpError(400, "delivery_feedback_email_invalid", "反馈人邮箱格式无效");
|
||||
}
|
||||
const info = requestInfoOf(requestInfo);
|
||||
const timestamp = now();
|
||||
dbRun(
|
||||
`INSERT INTO delivery_access_feedback(
|
||||
id, link_id, release_id, organization_id, workspace_id, project_id,
|
||||
decision, message, reviewer_name, reviewer_email, ip_address, user_agent,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
makeId("delivery-feedback"),
|
||||
row.id,
|
||||
row.release_id,
|
||||
row.organization_id,
|
||||
row.workspace_id,
|
||||
row.project_id,
|
||||
decision,
|
||||
message,
|
||||
reviewerName,
|
||||
reviewerEmail,
|
||||
info.ipAddress,
|
||||
info.userAgent,
|
||||
timestamp,
|
||||
timestamp
|
||||
]
|
||||
);
|
||||
return publicPortalPayload(row, token);
|
||||
}
|
||||
|
||||
function publicFileTarget(row, kind) {
|
||||
if (!['release', 'manifest'].includes(kind)) throw httpError(400, "delivery_access_file_kind_invalid", "只允许读取 release 或 manifest 文件");
|
||||
const releaseTarget = safeStoragePath(row.output_path);
|
||||
if (!releaseTarget || basename(releaseTarget.relative) !== "release.json") return null;
|
||||
const relativePath = kind === "release" ? releaseTarget.relative : `${dirname(releaseTarget.relative)}/delivery-manifest.json`;
|
||||
return safeStoragePath(relativePath);
|
||||
}
|
||||
|
||||
export async function readPublicDeliveryFile(token, kind, requestInfo = {}) {
|
||||
const row = publicLinkForToken(token, requestInfo, "download", kind);
|
||||
const target = publicFileTarget(row, kind);
|
||||
if (!target) {
|
||||
addAccessEvent(row, "download", "missing", requestInfo, kind, { reason: "unsafe_path" });
|
||||
throw httpError(404, "delivery_access_file_not_found", "交付文件不可用");
|
||||
}
|
||||
let content;
|
||||
try {
|
||||
content = await readFile(target.absolute);
|
||||
} catch {
|
||||
addAccessEvent(row, "download", "missing", requestInfo, kind, { reason: "file_missing" });
|
||||
throw httpError(404, "delivery_access_file_not_found", "交付文件不存在");
|
||||
}
|
||||
const timestamp = now();
|
||||
const claimed = dbRun(
|
||||
`UPDATE delivery_access_links
|
||||
SET download_count = download_count + 1, last_downloaded_at = ?, updated_at = ?
|
||||
WHERE id = ? AND status = 'active' AND expires_at > ? AND download_count < max_downloads`,
|
||||
[timestamp, timestamp, row.id, timestamp]
|
||||
);
|
||||
if (Number(claimed?.changes || 0) !== 1) {
|
||||
const latest = dbGet("SELECT * FROM delivery_access_links WHERE id = ?", [row.id]);
|
||||
const state = latest ? linkState(latest) : "missing";
|
||||
if (state === "expired") {
|
||||
syncExpired(latest);
|
||||
addAccessEvent(row, "download", "denied", requestInfo, kind, { reason: "expired" });
|
||||
throw httpError(410, "delivery_access_expired", "交付访问链接已过期");
|
||||
}
|
||||
if (state === "revoked" || !latest) {
|
||||
addAccessEvent(row, "download", "denied", requestInfo, kind, { reason: "revoked" });
|
||||
throw httpError(404, "delivery_access_not_found", "交付访问链接不存在");
|
||||
}
|
||||
addAccessEvent(row, "download", "denied", requestInfo, kind, { reason: "download_limit" });
|
||||
throw httpError(429, "delivery_access_download_limit", "该交付访问链接已达到最大下载次数");
|
||||
}
|
||||
addAccessEvent({ ...row, token_hash: row.token_hash }, "download", "success", requestInfo, kind, { downloadCount: Number(row.download_count || 0) + 1 });
|
||||
return {
|
||||
content,
|
||||
contentType: "application/json; charset=utf-8",
|
||||
fileName: kind === "release" ? `${safeFileSegment(row.delivery_version)}-release.json` : `${safeFileSegment(row.delivery_version)}-delivery-manifest.json`
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,605 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { basename } from "node:path";
|
||||
import { resolve } from "node:path";
|
||||
import { dbAll, dbGet, dbRun, withTransaction } from "./db.mjs";
|
||||
import {
|
||||
addAudit,
|
||||
addUsage,
|
||||
hasPermission,
|
||||
httpError,
|
||||
parseModelRow,
|
||||
requireQuota,
|
||||
requirePermission,
|
||||
requireProjectWritable
|
||||
} from "./tenant.mjs";
|
||||
import { productionGraph } from "./production.mjs";
|
||||
import { dispatchNotificationEvent } from "./notifications.mjs";
|
||||
import { registerJobArtifacts } from "./media-artifacts.mjs";
|
||||
|
||||
const projectRoot = resolve(import.meta.dirname, "..");
|
||||
const jobStorageRoot = resolve(projectRoot, "storage", "jobs");
|
||||
const now = () => new Date().toISOString();
|
||||
const makeId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
|
||||
function parseJson(value, fallback) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function privateHost(hostname) {
|
||||
const host = String(hostname || "").toLowerCase();
|
||||
if (["localhost", "127.0.0.1", "::1"].includes(host) || host.endsWith(".local")) return true;
|
||||
const octets = host.split(".").map(Number);
|
||||
if (octets.length !== 4 || octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) return false;
|
||||
return octets[0] === 10 || octets[0] === 127 || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) || (octets[0] === 192 && octets[1] === 168);
|
||||
}
|
||||
|
||||
export function endpointInfo(endpoint) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(String(endpoint || ""));
|
||||
} catch {
|
||||
throw httpError(400, "endpoint_invalid", "模型连接器地址不是有效 HTTP URL");
|
||||
}
|
||||
if (!["http:", "https:"].includes(url.protocol)) throw httpError(400, "endpoint_protocol_invalid", "模型连接器只支持 HTTP/HTTPS");
|
||||
return { url, local: privateHost(url.hostname) };
|
||||
}
|
||||
|
||||
function resolveAdapterId(context, adapterId, kind = "") {
|
||||
if (adapterId !== "owned-model-platform") return adapterId;
|
||||
const normalizedKind = String(kind).toLowerCase();
|
||||
if (normalizedKind.includes("视频") || normalizedKind.includes("i2v") || normalizedKind.includes("video")) return "owned-i2v";
|
||||
if (normalizedKind.includes("asr") || normalizedKind.includes("字幕")) {
|
||||
const preferred = dbGet("SELECT id FROM model_connectors WHERE id = 'newapi-audio-production' AND organization_id = ? AND (workspace_id IS NULL OR workspace_id = ?)", [context.organization.id, context.workspace.id]);
|
||||
if (preferred) return preferred.id;
|
||||
}
|
||||
if (normalizedKind.includes("tts") || normalizedKind.includes("配音")) return "local-tts";
|
||||
return "owned-image";
|
||||
}
|
||||
|
||||
function modelForContext(context, adapterId, kind = "") {
|
||||
const resolvedAdapterId = resolveAdapterId(context, adapterId, kind);
|
||||
const row = dbGet(
|
||||
`SELECT * FROM model_connectors
|
||||
WHERE id = ? AND organization_id = ? AND (workspace_id IS NULL OR workspace_id = ?)`,
|
||||
[resolvedAdapterId, context.organization.id, context.workspace.id]
|
||||
);
|
||||
if (!row) {
|
||||
throw httpError(404, "adapter_not_found", "模型连接器不存在或不属于当前工作区", { adapterId: resolvedAdapterId });
|
||||
}
|
||||
return parseModelRow(row);
|
||||
}
|
||||
|
||||
function jobRow(context, jobId) {
|
||||
if (!context.project) throw httpError(400, "project_required", "任务操作必须绑定项目");
|
||||
const row = dbGet(
|
||||
`SELECT j.*, p.name AS project_name, u.display_name AS creator_name
|
||||
FROM generation_jobs j
|
||||
JOIN projects p ON p.id = j.project_id
|
||||
LEFT JOIN users u ON u.id = j.created_by
|
||||
WHERE j.id = ? AND j.organization_id = ? AND j.workspace_id = ? AND j.project_id = ?`,
|
||||
[jobId, context.organization.id, context.workspace.id, context.project.id]
|
||||
);
|
||||
if (!row) throw httpError(404, "job_not_found", "任务不存在或不属于当前项目", { jobId });
|
||||
return row;
|
||||
}
|
||||
|
||||
function jobPayload(row) {
|
||||
const attempts = dbAll(
|
||||
"SELECT id, attempt_number, runner_id, status, error_message, started_at, finished_at, created_at FROM job_attempts WHERE job_id = ? ORDER BY attempt_number DESC",
|
||||
[row.id]
|
||||
);
|
||||
const dependencies = dbAll(
|
||||
`SELECT d.job_id, d.depends_on_job_id, d.dependency_type, d.created_at,
|
||||
j.kind, j.status, j.output_path
|
||||
FROM job_dependencies d
|
||||
JOIN generation_jobs j ON j.id = d.depends_on_job_id
|
||||
WHERE d.job_id = ?
|
||||
ORDER BY d.created_at`,
|
||||
[row.id]
|
||||
);
|
||||
return {
|
||||
...row,
|
||||
shotId: row.shot_id || "E01",
|
||||
adapter: row.adapter_id,
|
||||
costPolicy: row.cost_policy,
|
||||
output: row.output_path,
|
||||
qa: row.qa_status,
|
||||
request: parseJson(row.request_json, {}),
|
||||
result: parseJson(row.result_json, {}),
|
||||
errorMessage: row.error_message || "",
|
||||
startedAt: row.started_at,
|
||||
finishedAt: row.finished_at,
|
||||
attempts: Number(row.attempts || attempts.length || 0),
|
||||
attemptLog: attempts,
|
||||
dependencies
|
||||
};
|
||||
}
|
||||
|
||||
function dependencyRows(context, dependencyIds) {
|
||||
if (!dependencyIds.length) return [];
|
||||
const placeholders = dependencyIds.map(() => "?").join(",");
|
||||
const rows = dbAll(
|
||||
`SELECT id, kind, status, organization_id, workspace_id, project_id
|
||||
FROM generation_jobs
|
||||
WHERE id IN (${placeholders})`,
|
||||
dependencyIds
|
||||
);
|
||||
if (rows.length !== dependencyIds.length || rows.some((row) => row.organization_id !== context.organization.id || row.workspace_id !== context.workspace.id || row.project_id !== context.project.id)) {
|
||||
throw httpError(422, "job_dependency_scope_invalid", "任务前置依赖必须属于同一组织、工作区和项目");
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function assertNoDependencyCycle(jobId, dependencyIds) {
|
||||
const visiting = new Set();
|
||||
const visited = new Set();
|
||||
function walk(currentId) {
|
||||
if (currentId === jobId) throw httpError(422, "job_dependency_cycle", "任务前置依赖不能形成循环");
|
||||
if (visited.has(currentId)) return;
|
||||
if (visiting.has(currentId)) throw httpError(422, "job_dependency_cycle", "任务前置依赖不能形成循环");
|
||||
visiting.add(currentId);
|
||||
const parents = dbAll("SELECT depends_on_job_id FROM job_dependencies WHERE job_id = ?", [currentId]);
|
||||
for (const parent of parents) walk(parent.depends_on_job_id);
|
||||
visiting.delete(currentId);
|
||||
visited.add(currentId);
|
||||
}
|
||||
for (const dependencyId of dependencyIds) walk(dependencyId);
|
||||
}
|
||||
|
||||
function unresolvedDependencies(jobId) {
|
||||
return dbAll(
|
||||
`SELECT d.depends_on_job_id, j.kind, j.status
|
||||
FROM job_dependencies d
|
||||
JOIN generation_jobs j ON j.id = d.depends_on_job_id
|
||||
WHERE d.job_id = ? AND j.status <> 'completed'
|
||||
ORDER BY d.created_at`,
|
||||
[jobId]
|
||||
);
|
||||
}
|
||||
|
||||
function releaseReadyDependents(jobId) {
|
||||
const dependents = dbAll("SELECT DISTINCT job_id FROM job_dependencies WHERE depends_on_job_id = ?", [jobId]);
|
||||
for (const dependent of dependents) {
|
||||
const unresolved = unresolvedDependencies(dependent.job_id);
|
||||
if (unresolved.length) continue;
|
||||
const row = dbGet("SELECT j.*, m.status AS adapter_status FROM generation_jobs j LEFT JOIN model_connectors m ON m.id = j.adapter_id WHERE j.id = ?", [dependent.job_id]);
|
||||
if (row?.status === "blocked" && row.adapter_status === "ready" && /等待前置任务/.test(row.error_message || "")) {
|
||||
const timestamp = now();
|
||||
dbRun("UPDATE generation_jobs SET status = 'queued', error_message = '', updated_at = ? WHERE id = ?", [timestamp, dependent.job_id]);
|
||||
dbRun("UPDATE job_attempts SET status = 'queued', error_message = NULL WHERE job_id = ? AND attempt_number = (SELECT MAX(attempt_number) FROM job_attempts WHERE job_id = ?)", [dependent.job_id, dependent.job_id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function shotForJob(context, shotId) {
|
||||
if (!shotId) return null;
|
||||
const graph = productionGraph(context);
|
||||
return graph.shots.find((shot) => shot.id === shotId) || null;
|
||||
}
|
||||
|
||||
function buildContract(context, body, adapter) {
|
||||
const shot = shotForJob(context, body.shotId);
|
||||
const kind = String(body.kind || "自定义生成任务").trim();
|
||||
const blockedTerms = ["split-screen", "comic panel", "collage", "contact sheet", "storyboard", "多格", "拼图", "分屏", "故事板拼图"];
|
||||
// Negative prompts intentionally name the forbidden layouts; only inspect positive generation text here.
|
||||
const promptText = [shot?.prompt, shot?.videoPrompt, shot?.action, shot?.camera, body.prompt].filter(Boolean).join(" ").toLowerCase();
|
||||
const blocked = blockedTerms.filter((term) => promptText.includes(term.toLowerCase()));
|
||||
if (blocked.length) throw httpError(422, "single_frame_contract_violation", "请求包含禁止的一图多画面表达", { blocked });
|
||||
if (shot && kind.includes("视频") && shot.transitionFromPrevious !== "episode-start" && (!shot.firstFrame || /pending|auto_previous/i.test(shot.firstFrame))) {
|
||||
throw httpError(422, "actual_last_frame_required", "连续视频镜头必须先登记上一段实际末帧作为首帧输入", { shotId: shot.id });
|
||||
}
|
||||
return {
|
||||
schema: "ai-drama.job.v1",
|
||||
createdAt: now(),
|
||||
project: { id: context.project.id, name: context.project.name },
|
||||
job: { kind, shotId: body.shotId || null, adapterId: adapter.id, costPolicy: "local-only" },
|
||||
shot,
|
||||
inputs: body.inputs || {},
|
||||
constraints: {
|
||||
singleFrameOnly: true,
|
||||
imageOutputCount: 1,
|
||||
requireActualLastFrame: true,
|
||||
localOnly: adapter.costMode === "local",
|
||||
blockedTerms
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function writeJobRequest(jobId, contract) {
|
||||
const directory = resolve(jobStorageRoot, jobId);
|
||||
await mkdir(directory, { recursive: true });
|
||||
const relative = `storage/jobs/${jobId}/request.json`;
|
||||
await writeFile(resolve(projectRoot, relative), `${JSON.stringify(contract, null, 2)}\n`, "utf8");
|
||||
return relative;
|
||||
}
|
||||
|
||||
function assertExternalAllowed(context, adapter, body = {}) {
|
||||
if (adapter.costMode === "local") return;
|
||||
if (!adapter.approvalRequired) return;
|
||||
if (!body.approveExternal || !hasPermission(context, "model:manage")) {
|
||||
throw httpError(403, "external_connector_requires_approval", "外部或混合连接器必须由具备模型管理权限的用户显式批准后才能执行", { adapterId: adapter.id });
|
||||
}
|
||||
}
|
||||
|
||||
export async function createGenerationJob(context, body) {
|
||||
requirePermission(context, "job:create");
|
||||
if (!context.project) throw httpError(400, "project_required", "生成任务必须绑定项目");
|
||||
requireQuota(context, "clip", 1);
|
||||
const requestedAdapterId = String(body.adapter || "").trim();
|
||||
if (!requestedAdapterId) throw httpError(400, "adapter_required", "生成任务必须选择模型连接器");
|
||||
const adapter = modelForContext(context, requestedAdapterId, body.kind);
|
||||
assertExternalAllowed(context, adapter, body);
|
||||
const dependencyIds = [...new Set((Array.isArray(body.dependsOnJobIds) ? body.dependsOnJobIds : []).map((id) => String(id || "").trim()).filter(Boolean))].slice(0, 12);
|
||||
dependencyRows(context, dependencyIds);
|
||||
assertNoDependencyCycle("__new_job__", dependencyIds);
|
||||
const selectedShot = body.shotId ? shotForJob(context, body.shotId) : null;
|
||||
if (body.shotId && !selectedShot) throw httpError(404, "shot_not_found", "镜头不存在或不属于当前项目", { shotId: body.shotId });
|
||||
const contract = buildContract(context, { ...body, shotId: selectedShot?.id || null }, adapter);
|
||||
const jobId = makeId("job");
|
||||
const timestamp = now();
|
||||
const outputPath = String(body.output || `storage/jobs/${jobId}/output`);
|
||||
if (outputPath.startsWith("/") || outputPath.includes("..")) throw httpError(400, "output_path_invalid", "输出路径必须是项目目录内的相对路径");
|
||||
const maxAttempts = Math.max(1, Math.min(10, Number(body.maxAttempts || 3)));
|
||||
const dependencyBlocked = dependencyIds.some((dependencyId) => dbGet("SELECT status FROM generation_jobs WHERE id = ?", [dependencyId])?.status !== "completed");
|
||||
const initialStatus = dependencyBlocked ? "blocked" : adapter.status === "ready" && adapter.costMode === "local" ? "queued" : "blocked";
|
||||
const errorMessage = dependencyBlocked
|
||||
? `等待前置任务完成:${dependencyIds.join(", ")}`
|
||||
: initialStatus === "blocked" ? `连接器当前状态为 ${adapter.status},请先在模型中台检测并启用连接器` : "";
|
||||
await writeJobRequest(jobId, contract);
|
||||
withTransaction(() => {
|
||||
dbRun(
|
||||
`INSERT INTO generation_jobs(
|
||||
id, organization_id, workspace_id, project_id, episode_id, shot_id, kind, adapter_id,
|
||||
status, priority, cost_policy, output_path, qa_status, request_json, result_json,
|
||||
error_message, max_attempts, next_run_at, leased_by, leased_at, created_by, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, (SELECT id FROM episodes WHERE season_id IN (SELECT id FROM seasons WHERE series_id = (SELECT id FROM series WHERE project_id = ?)) LIMIT 1), ?, ?, ?, ?, ?, 'local-only', ?, 'wait', ?, '{}', ?, ?, NULL, NULL, NULL, ?, ?, ?)`,
|
||||
[jobId, context.organization.id, context.workspace.id, context.project.id, context.project.id, selectedShot?.id || null, body.kind || "自定义生成任务", adapter.id, initialStatus, Math.max(1, Math.min(100, Number(body.priority || 50))), outputPath, JSON.stringify(contract), errorMessage, maxAttempts, context.user.id, timestamp, timestamp]
|
||||
);
|
||||
dbRun("INSERT INTO job_attempts(id, job_id, attempt_number, runner_id, status, error_message, created_at) VALUES (?, ?, 1, ?, ?, ?, ?)", [makeId("attempt"), jobId, adapter.id, initialStatus === "queued" ? "queued" : "blocked", errorMessage || null, timestamp]);
|
||||
for (const dependencyId of dependencyIds) {
|
||||
dbRun("INSERT INTO job_dependencies(job_id, depends_on_job_id, dependency_type, created_at) VALUES (?, ?, 'blocking', ?)", [jobId, dependencyId, timestamp]);
|
||||
}
|
||||
});
|
||||
addUsage({ context, kind: body.kind || "generation", units: 1, unitName: "job", estimatedCost: 0, metadata: { jobId, adapter: adapter.id, status: initialStatus } });
|
||||
addAudit({ context, action: "generation_job.created", targetType: "generation_job", targetId: jobId, result: initialStatus === "queued" ? "ok" : "blocked", metadata: { kind: body.kind, shotId: body.shotId || null, adapter: adapter.id, status: initialStatus } });
|
||||
return { job: jobPayload(dbGet("SELECT * FROM generation_jobs WHERE id = ?", [jobId])), jobs: listGenerationJobs(context) };
|
||||
}
|
||||
|
||||
export function listGenerationJobs(context, options = {}) {
|
||||
if (!context.project) return [];
|
||||
const status = String(options.status || "").trim();
|
||||
const params = [context.organization.id, context.workspace.id, context.project.id];
|
||||
let where = "j.organization_id = ? AND j.workspace_id = ? AND j.project_id = ?";
|
||||
if (status) { where += " AND j.status = ?"; params.push(status); }
|
||||
const rows = dbAll(
|
||||
`SELECT j.*, p.name AS project_name, u.display_name AS creator_name,
|
||||
(SELECT COUNT(*) FROM job_attempts a WHERE a.job_id = j.id) AS attempts
|
||||
FROM generation_jobs j
|
||||
JOIN projects p ON p.id = j.project_id
|
||||
LEFT JOIN users u ON u.id = j.created_by
|
||||
WHERE ${where}
|
||||
ORDER BY j.created_at DESC LIMIT ?`,
|
||||
[...params, Math.max(1, Math.min(500, Number(options.limit || 100)))]
|
||||
);
|
||||
return rows.map(jobPayload);
|
||||
}
|
||||
|
||||
export function getGenerationJob(context, jobId) {
|
||||
return jobPayload(jobRow(context, jobId));
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url, options, timeoutMs = 20000) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, { ...options, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeResult(value, fallbackPath) {
|
||||
if (!value || typeof value !== "object") return { raw: value, outputPath: fallbackPath };
|
||||
const outputPath = value.outputPath || value.output_path || value.path || value.file || fallbackPath;
|
||||
return { ...value, outputPath };
|
||||
}
|
||||
|
||||
function adapterProtocol(adapter) {
|
||||
const configured = adapter.protocol && typeof adapter.protocol === "object"
|
||||
? adapter.protocol
|
||||
: parseJson(adapter.protocol_json, {});
|
||||
return configured && typeof configured === "object" ? configured : {};
|
||||
}
|
||||
|
||||
function joinEndpoint(baseEndpoint, route) {
|
||||
if (!route) return new URL(baseEndpoint);
|
||||
if (/^https?:\/\//i.test(route)) return new URL(route);
|
||||
const base = String(baseEndpoint || "").replace(/\/+$/, "");
|
||||
const suffix = `/${String(route).replace(/^\/+/, "")}`;
|
||||
return new URL(`${base}${suffix}`);
|
||||
}
|
||||
|
||||
function operationForJob(job) {
|
||||
const kind = String(job.kind || "").toLowerCase();
|
||||
if (kind.includes("tts") || kind.includes("配音") || kind.includes("声音")) return "tts";
|
||||
if (kind.includes("asr") || kind.includes("字幕") || kind.includes("对齐")) return "asr";
|
||||
if (kind.includes("视频") || kind.includes("i2v") || kind.includes("video")) return "video";
|
||||
if (kind.includes("图") || kind.includes("关键帧") || kind.includes("image")) return "image";
|
||||
return "chat";
|
||||
}
|
||||
|
||||
function contractText(contract) {
|
||||
return [
|
||||
contract?.shot?.prompt,
|
||||
contract?.shot?.videoPrompt,
|
||||
contract?.shot?.action,
|
||||
contract?.inputs?.prompt,
|
||||
contract?.inputs?.text,
|
||||
contract?.inputs?.input
|
||||
].filter(Boolean).join("\n");
|
||||
}
|
||||
|
||||
function authHeaders(adapter) {
|
||||
const envKey = String(adapter.auth_env || adapter.authEnv || adapter.protocol?.authEnv || "").trim();
|
||||
const token = envKey ? process.env[envKey] : "";
|
||||
return token ? { authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
async function openAiMultipartRequest(url, headers, protocol, contract, job, timestamp, attemptNumber) {
|
||||
const inputPath = String(contract?.inputs?.inputFilePath || contract?.inputs?.audioPath || "").trim();
|
||||
if (!inputPath) {
|
||||
return {
|
||||
url,
|
||||
options: {
|
||||
method: "POST",
|
||||
headers: { ...headers, "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: protocol.models?.asr || protocol.model || contract?.inputs?.model || "local-asr",
|
||||
language: contract?.inputs?.language || "zh",
|
||||
response_format: contract?.inputs?.response_format || "verbose_json",
|
||||
metadata: { jobId: job.id, attemptNumber, requestedAt: timestamp },
|
||||
input: contractText(contract)
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
if (inputPath.startsWith("/") || inputPath.includes("..")) throw httpError(400, "input_path_invalid", "音频输入必须是 storage/ 下的相对路径");
|
||||
const absolutePath = resolve(projectRoot, inputPath);
|
||||
const audio = await readFile(absolutePath);
|
||||
const form = new FormData();
|
||||
form.append("model", protocol.models?.asr || protocol.model || contract?.inputs?.model || "local-asr");
|
||||
form.append("language", contract?.inputs?.language || "zh");
|
||||
form.append("response_format", contract?.inputs?.response_format || "verbose_json");
|
||||
form.append("file", new Blob([audio], { type: contract?.inputs?.mimeType || "audio/wav" }), basename(absolutePath));
|
||||
return { url, options: { method: "POST", headers: { ...headers, accept: "application/json" }, body: form } };
|
||||
}
|
||||
|
||||
async function buildAdapterRequest(adapter, job, contract, attemptNumber, timestamp) {
|
||||
const protocol = adapterProtocol(adapter);
|
||||
const kind = String(adapter.kind || "http-json").toLowerCase();
|
||||
const operation = operationForJob(job);
|
||||
const headers = { ...authHeaders(adapter), accept: "application/json", "x-ai-drama-job-id": job.id };
|
||||
const execution = { jobId: job.id, attemptNumber, requestedAt: timestamp, operation };
|
||||
|
||||
if (kind === "comfyui") {
|
||||
const url = joinEndpoint(adapter.endpoint, protocol.promptRoute || "prompt");
|
||||
return {
|
||||
url,
|
||||
options: {
|
||||
method: "POST",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
prompt: contract?.inputs?.workflow || contract?.workflow || contract,
|
||||
client_id: protocol.clientId || "ai-drama-platform",
|
||||
extra_data: { aiDrama: execution }
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (kind.includes("openai-compatible")) {
|
||||
const defaultRoutes = {
|
||||
tts: "audio/speech",
|
||||
asr: "audio/transcriptions",
|
||||
image: "images/generations",
|
||||
video: "videos/generations",
|
||||
chat: "chat/completions"
|
||||
};
|
||||
const url = joinEndpoint(adapter.endpoint, protocol.routes?.[operation] || defaultRoutes[operation]);
|
||||
if (operation === "asr") return openAiMultipartRequest(url, headers, protocol, contract, job, timestamp, attemptNumber);
|
||||
const model = protocol.models?.[operation] || protocol.models?.default || protocol.model || contract?.inputs?.model || "local-model";
|
||||
let body;
|
||||
if (operation === "tts") {
|
||||
body = {
|
||||
model,
|
||||
input: contract?.inputs?.text || contract?.inputs?.input || contractText(contract),
|
||||
voice: contract?.inputs?.voice || contract?.inputs?.voiceId || "locked-voice",
|
||||
response_format: contract?.inputs?.response_format || "wav",
|
||||
speed: contract?.inputs?.speed || 1,
|
||||
metadata: execution
|
||||
};
|
||||
} else if (operation === "image") {
|
||||
body = {
|
||||
model,
|
||||
prompt: contract?.shot?.prompt || contract?.inputs?.prompt || contractText(contract),
|
||||
negative_prompt: contract?.shot?.negativePrompt || contract?.inputs?.negativePrompt || "",
|
||||
n: 1,
|
||||
size: contract?.inputs?.size || "928x1664",
|
||||
response_format: contract?.inputs?.response_format || "b64_json",
|
||||
metadata: execution
|
||||
};
|
||||
} else if (operation === "video") {
|
||||
body = {
|
||||
model,
|
||||
prompt: contract?.shot?.videoPrompt || contract?.inputs?.prompt || contractText(contract),
|
||||
image: contract?.inputs?.firstFrame || contract?.shot?.firstFrame || undefined,
|
||||
first_frame: contract?.inputs?.firstFrame || contract?.shot?.firstFrame || undefined,
|
||||
last_frame: contract?.inputs?.lastFrame || contract?.shot?.lastFrame || undefined,
|
||||
duration: contract?.shot?.durationSec,
|
||||
n: 1,
|
||||
metadata: execution
|
||||
};
|
||||
} else {
|
||||
body = {
|
||||
model,
|
||||
messages: [{ role: "user", content: contractText(contract) || JSON.stringify(contract) }],
|
||||
temperature: 0.2,
|
||||
metadata: execution
|
||||
};
|
||||
}
|
||||
return { url, options: { method: "POST", headers: { ...headers, "content-type": "application/json" }, body: JSON.stringify(body) } };
|
||||
}
|
||||
|
||||
return {
|
||||
url: new URL(adapter.endpoint),
|
||||
options: {
|
||||
method: "POST",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ ...contract, execution })
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function readAdapterResponse(response, jobId) {
|
||||
const contentType = String(response.headers.get("content-type") || "").toLowerCase();
|
||||
if (contentType.includes("json") || contentType.startsWith("text/")) {
|
||||
const raw = await response.text();
|
||||
let parsed;
|
||||
try { parsed = raw ? JSON.parse(raw) : {}; } catch { parsed = { raw }; }
|
||||
return { parsed, contentType };
|
||||
}
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
const resultRelative = `storage/jobs/${jobId}/output${contentType.includes("wav") ? ".wav" : contentType.includes("mp4") ? ".mp4" : ".bin"}`;
|
||||
await mkdir(resolve(projectRoot, "storage", "jobs", jobId), { recursive: true });
|
||||
await writeFile(resolve(projectRoot, resultRelative), buffer);
|
||||
return { parsed: { outputPath: resultRelative, mimeType: contentType || "application/octet-stream", bytes: buffer.length, binary: true }, contentType };
|
||||
}
|
||||
|
||||
function assertSingleFrameResult(job, result) {
|
||||
const kind = String(job.kind || "").toLowerCase();
|
||||
if (!(kind.includes("图") || kind.includes("关键帧") || kind.includes("image"))) return;
|
||||
const candidates = [result?.data, result?.images, result?.outputs].filter(Array.isArray);
|
||||
for (const values of candidates) {
|
||||
if (values.length !== 1) throw new Error(`一图一画面校验失败:模型返回了 ${values.length} 个图像结果,要求恰好 1 个`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeGenerationJob(context, jobId, body = {}) {
|
||||
const job = jobRow(context, jobId);
|
||||
if (!hasPermission(context, "job:create") && !hasPermission(context, "queue:manage")) throw httpError(403, "permission_denied", "没有执行生成任务的权限");
|
||||
requireProjectWritable(context);
|
||||
if (!["queued", "blocked", "failed", "cancelled"].includes(job.status)) throw httpError(409, "job_not_runnable", "当前任务状态不能执行", { status: job.status });
|
||||
const unresolved = unresolvedDependencies(jobId);
|
||||
if (unresolved.length) {
|
||||
const message = `等待前置任务完成:${unresolved.map((item) => item.depends_on_job_id).join(", ")}`;
|
||||
dbRun("UPDATE generation_jobs SET status = 'blocked', error_message = ?, updated_at = ? WHERE id = ?", [message, now(), jobId]);
|
||||
addAudit({ context, action: "generation_job.blocked_by_dependencies", targetType: "generation_job", targetId: jobId, result: "blocked", metadata: { dependencies: unresolved } });
|
||||
throw httpError(409, "job_dependencies_unresolved", message, { dependencies: unresolved });
|
||||
}
|
||||
const adapter = modelForContext(context, job.adapter_id);
|
||||
assertExternalAllowed(context, adapter, body);
|
||||
if (body.approveExternal && adapter.costMode !== "local") {
|
||||
addAudit({ context, action: "generation_job.external_approved", targetType: "generation_job", targetId: jobId, metadata: { adapter: adapter.id, costMode: adapter.costMode, approvalRequired: adapter.approvalRequired } });
|
||||
}
|
||||
const { local } = endpointInfo(adapter.endpoint);
|
||||
if (!local && adapter.costMode === "local") throw httpError(403, "local_only_endpoint_required", "local-only 任务只能调用本机或私有局域网 HTTP 连接器");
|
||||
if (adapter.status !== "ready") {
|
||||
const message = `连接器当前状态为 ${adapter.status},请先完成探活并启用连接器`;
|
||||
dbRun("UPDATE generation_jobs SET status = 'blocked', error_message = ?, updated_at = ? WHERE id = ?", [message, now(), jobId]);
|
||||
throw httpError(409, "adapter_not_ready", message, { adapterId: adapter.id });
|
||||
}
|
||||
const timestamp = now();
|
||||
const attemptNumber = Number(dbGet("SELECT MAX(attempt_number) AS attempt_number FROM job_attempts WHERE job_id = ?", [jobId])?.attempt_number || 0) + 1;
|
||||
const attemptId = makeId("attempt");
|
||||
dbRun("UPDATE generation_jobs SET status = 'running', error_message = '', started_at = ?, finished_at = NULL, updated_at = ? WHERE id = ?", [timestamp, timestamp, jobId]);
|
||||
dbRun("INSERT INTO job_attempts(id, job_id, attempt_number, runner_id, status, started_at, created_at) VALUES (?, ?, ?, ?, 'running', ?, ?)", [attemptId, jobId, attemptNumber, adapter.id, timestamp, timestamp]);
|
||||
const contract = parseJson(job.request_json, {});
|
||||
try {
|
||||
const request = await buildAdapterRequest(adapter, job, contract, attemptNumber, timestamp);
|
||||
const response = await fetchWithTimeout(request.url.toString(), request.options);
|
||||
const { parsed } = await readAdapterResponse(response, jobId);
|
||||
if (!response.ok) throw new Error(`模型连接器返回 HTTP ${response.status}: ${String(parsed?.detail || parsed?.error || parsed?.raw || "无响应内容").slice(0, 500)}`);
|
||||
const result = normalizeResult(parsed, job.output_path);
|
||||
assertSingleFrameResult(job, result);
|
||||
const finishedAt = now();
|
||||
const resultRelative = `storage/jobs/${jobId}/result.json`;
|
||||
await mkdir(resolve(projectRoot, "storage", "jobs", jobId), { recursive: true });
|
||||
const artifacts = await registerJobArtifacts(context, job, result);
|
||||
const storedResult = { ...result, resultFile: resultRelative, artifacts };
|
||||
await writeFile(resolve(projectRoot, resultRelative), `${JSON.stringify(storedResult, null, 2)}\n`, "utf8");
|
||||
dbRun("UPDATE generation_jobs SET status = 'completed', result_json = ?, output_path = ?, finished_at = ?, updated_at = ? WHERE id = ?", [JSON.stringify(storedResult), result.outputPath || job.output_path, finishedAt, finishedAt, jobId]);
|
||||
dbRun("UPDATE job_attempts SET status = 'completed', finished_at = ? WHERE id = ?", [finishedAt, attemptId]);
|
||||
dbRun("UPDATE model_connectors SET status = 'ready', last_probe_at = ?, latency_ms = ?, error_message = '' WHERE id = ?", [finishedAt, Math.max(0, Date.parse(finishedAt) - Date.parse(timestamp)), adapter.id]);
|
||||
releaseReadyDependents(jobId);
|
||||
addUsage({ context, kind: `${job.kind}:executed`, units: 1, unitName: "execution", estimatedCost: 0, metadata: { jobId, adapter: adapter.id, attemptNumber } });
|
||||
addAudit({ context, action: "generation_job.completed", targetType: "generation_job", targetId: jobId, metadata: { adapter: adapter.id, attemptNumber, resultFile: resultRelative, artifactCount: artifacts.length } });
|
||||
void dispatchNotificationEvent({ context, eventKey: "job.completed", payload: { jobId, kind: job.kind, adapterId: adapter.id, resultFile: resultRelative, artifactCount: artifacts.length } });
|
||||
return { job: jobPayload(dbGet("SELECT * FROM generation_jobs WHERE id = ?", [jobId])), jobs: listGenerationJobs(context) };
|
||||
} catch (error) {
|
||||
const finishedAt = now();
|
||||
const message = error.name === "AbortError" ? "模型连接器请求超时" : String(error.message || error).slice(0, 1000);
|
||||
dbRun("UPDATE generation_jobs SET status = 'failed', error_message = ?, finished_at = ?, updated_at = ? WHERE id = ?", [message, finishedAt, finishedAt, jobId]);
|
||||
dbRun("UPDATE job_attempts SET status = 'failed', error_message = ?, finished_at = ? WHERE id = ?", [message, finishedAt, attemptId]);
|
||||
dbRun("UPDATE model_connectors SET status = 'error', last_probe_at = ?, error_message = ? WHERE id = ?", [finishedAt, message, adapter.id]);
|
||||
addAudit({ context, action: "generation_job.failed", targetType: "generation_job", targetId: jobId, result: "error", metadata: { adapter: adapter.id, attemptNumber, error: message } });
|
||||
void dispatchNotificationEvent({ context, eventKey: "job.failed", payload: { jobId, kind: job.kind, adapterId: adapter.id, attemptNumber, error: message } });
|
||||
throw httpError(502, "adapter_execution_failed", message, { jobId, adapterId: adapter.id });
|
||||
}
|
||||
}
|
||||
|
||||
export async function probeModelConnector(context, modelId) {
|
||||
requirePermission(context, "model:manage");
|
||||
const adapter = modelForContext(context, modelId);
|
||||
const { local } = endpointInfo(adapter.endpoint);
|
||||
if (!local && adapter.costMode === "local") throw httpError(403, "local_only_endpoint_required", "local-only 连接器只能指向本机或私有局域网地址");
|
||||
const protocol = adapterProtocol(adapter);
|
||||
const probeRoute = protocol.healthRoute || (String(adapter.kind || "").toLowerCase().includes("openai-compatible") ? "models" : String(adapter.kind || "").toLowerCase() === "comfyui" ? "system_stats" : "");
|
||||
const probeUrl = joinEndpoint(adapter.endpoint, probeRoute);
|
||||
const started = Date.now();
|
||||
let status = "error";
|
||||
let message = "";
|
||||
let httpStatus = null;
|
||||
try {
|
||||
const response = await fetchWithTimeout(probeUrl.toString(), { method: "GET", headers: { ...authHeaders(adapter), accept: "application/json" } }, 8000);
|
||||
httpStatus = response.status;
|
||||
if (response.ok || response.status === 401 || response.status === 403 || response.status === 405) status = "ready";
|
||||
else message = `探活返回 HTTP ${response.status}`;
|
||||
} catch (error) {
|
||||
message = error.name === "AbortError" ? "探活超时" : String(error.message || error).slice(0, 500);
|
||||
}
|
||||
const timestamp = now();
|
||||
dbRun("UPDATE model_connectors SET status = ?, last_probe_at = ?, latency_ms = ?, error_message = ?, updated_at = ? WHERE id = ?", [status, timestamp, Date.now() - started, message, timestamp, modelId]);
|
||||
addAudit({ context, action: "model_connector.probed", targetType: "model_connector", targetId: modelId, result: status === "ready" ? "ok" : "error", metadata: { status, httpStatus, latencyMs: Date.now() - started, message } });
|
||||
return { model: parseModelRow(dbGet("SELECT * FROM model_connectors WHERE id = ?", [modelId])), httpStatus, message };
|
||||
}
|
||||
|
||||
export function updateModelConnector(context, modelId, body) {
|
||||
requirePermission(context, "model:manage");
|
||||
const current = dbGet("SELECT * FROM model_connectors WHERE id = ? AND organization_id = ? AND (workspace_id IS NULL OR workspace_id = ?)", [modelId, context.organization.id, context.workspace.id]);
|
||||
if (!current) throw httpError(404, "adapter_not_found", "模型连接器不存在或不属于当前工作区");
|
||||
const label = String(body.label ?? current.label).trim();
|
||||
const endpoint = String(body.endpoint ?? current.endpoint).trim();
|
||||
const kind = String(body.kind ?? current.kind).trim();
|
||||
const costMode = String(body.costMode ?? current.cost_mode).trim();
|
||||
const status = String(body.status ?? current.status).trim();
|
||||
if (!label || !endpoint) throw httpError(400, "adapter_fields_required", "连接器名称和地址不能为空");
|
||||
if (!["local", "mixed", "cloud"].includes(costMode)) throw httpError(400, "cost_mode_invalid", "成本策略无效");
|
||||
if (!["ready", "not-connected", "planned", "optional", "paused", "error"].includes(status)) throw httpError(400, "adapter_status_invalid", "连接器状态无效");
|
||||
const { local } = endpointInfo(endpoint);
|
||||
if (costMode === "local" && !local) throw httpError(403, "local_only_endpoint_required", "local-only 连接器只能指向本机或私有局域网地址");
|
||||
const requestedApproval = body.approvalRequired === undefined ? Boolean(current.approval_required) : Boolean(body.approvalRequired);
|
||||
if (costMode !== "local" && !requestedApproval) throw httpError(400, "external_connector_approval_required", "混合或外部连接器必须开启审批策略");
|
||||
const approvalRequired = costMode === "local" ? requestedApproval : true;
|
||||
const capabilities = Array.isArray(body.capability) ? body.capability : parseJson(current.capabilities_json, []);
|
||||
const currentProtocol = parseJson(current.protocol_json, {});
|
||||
const protocol = body.protocol && typeof body.protocol === "object" ? body.protocol : currentProtocol;
|
||||
const authEnv = String(body.authEnv ?? current.auth_env ?? protocol.authEnv ?? "").trim();
|
||||
const timestamp = now();
|
||||
dbRun("UPDATE model_connectors SET label = ?, kind = ?, capabilities_json = ?, endpoint = ?, status = ?, cost_mode = ?, approval_required = ?, protocol_json = ?, auth_env = ?, error_message = ?, updated_at = ? WHERE id = ?", [label, kind, JSON.stringify(capabilities), endpoint, status, costMode, approvalRequired ? 1 : 0, JSON.stringify(protocol), authEnv, status === "error" ? current.error_message : "", timestamp, modelId]);
|
||||
addAudit({ context, action: "model_connector.updated", targetType: "model_connector", targetId: modelId, metadata: { label, endpoint, status, costMode, approvalRequired } });
|
||||
return parseModelRow(dbGet("SELECT * FROM model_connectors WHERE id = ?", [modelId]));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,291 @@
|
||||
import { access, mkdir, writeFile } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { execFile } from "node:child_process";
|
||||
import { resolve, extname } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { dbAll, dbGet, dbRun } from "./db.mjs";
|
||||
import { inspectStoragePath } from "./media-qa.mjs";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const projectRoot = resolve(import.meta.dirname, "..");
|
||||
const ffmpegBinary = process.env.FFMPEG_BIN || "ffmpeg";
|
||||
|
||||
function parseJson(value, fallback) {
|
||||
try { return JSON.parse(value); } catch { return fallback; }
|
||||
}
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function makeId(prefix, value = "") {
|
||||
const digest = createHash("sha1").update(String(value)).digest("hex").slice(0, 12);
|
||||
return `${prefix}-${digest}`;
|
||||
}
|
||||
|
||||
function kindForJob(job) {
|
||||
const kind = String(job?.kind || "").toLowerCase();
|
||||
if (kind.includes("视频") || kind.includes("i2v") || kind.includes("video")) return "video";
|
||||
if (kind.includes("图") || kind.includes("关键帧") || kind.includes("image")) return "image";
|
||||
if (kind.includes("tts") || kind.includes("配音") || kind.includes("声音") || kind.includes("audio")) return "audio";
|
||||
if (kind.includes("asr") || kind.includes("字幕") || kind.includes("对齐")) return "json";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function extensionFor(kind, mimeType = "") {
|
||||
const value = `${mimeType} ${kind}`.toLowerCase();
|
||||
if (value.includes("wav") || value.includes("audio")) return ".wav";
|
||||
if (value.includes("mp4") || value.includes("video")) return ".mp4";
|
||||
if (value.includes("webp")) return ".webp";
|
||||
if (value.includes("jpeg") || value.includes("jpg")) return ".jpg";
|
||||
if (value.includes("json")) return ".json";
|
||||
return ".png";
|
||||
}
|
||||
|
||||
function candidatePath(value) {
|
||||
if (typeof value === "string") return value.trim();
|
||||
if (!value || typeof value !== "object") return "";
|
||||
return String(value.outputPath || value.output_path || value.path || value.file || value.url || "").trim();
|
||||
}
|
||||
|
||||
function candidateMime(value, fallback = "") {
|
||||
if (!value || typeof value !== "object") return fallback;
|
||||
return String(value.mimeType || value.mime_type || value.contentType || value.content_type || fallback).trim();
|
||||
}
|
||||
|
||||
function safeOutputPath(value) {
|
||||
const path = String(value || "").trim();
|
||||
if (!path || path.startsWith("/") || path.includes("..") || !path.startsWith("storage/")) return "";
|
||||
return path;
|
||||
}
|
||||
|
||||
async function materializeBase64(job, value, index, kind, mimeType = "") {
|
||||
const encoded = typeof value === "string" ? value : value?.b64_json || value?.base64 || "";
|
||||
if (!encoded) return "";
|
||||
const payload = encoded.includes(",") && encoded.startsWith("data:") ? encoded.split(",", 2)[1] : encoded;
|
||||
let buffer;
|
||||
try { buffer = Buffer.from(payload, "base64"); } catch { return ""; }
|
||||
if (!buffer.length) return "";
|
||||
const relative = `storage/jobs/${job.id}/outputs/${kind}-${index + 1}${extensionFor(kind, mimeType)}`;
|
||||
await mkdir(resolve(projectRoot, `storage/jobs/${job.id}/outputs`), { recursive: true });
|
||||
await writeFile(resolve(projectRoot, relative), buffer);
|
||||
return relative;
|
||||
}
|
||||
|
||||
async function extractVideoFrames(owner, inputPath, kind) {
|
||||
if (kind !== "video") return { first: "", last: "" };
|
||||
const prefix = owner.jobId ? `storage/jobs/${owner.jobId}` : `storage/compositions/${owner.compositionId}`;
|
||||
if (!prefix || !inputPath) return { first: "", last: "" };
|
||||
const frameDirectory = resolve(projectRoot, prefix, "frames");
|
||||
await mkdir(frameDirectory, { recursive: true });
|
||||
const first = `${prefix}/frames/first.jpg`;
|
||||
const last = `${prefix}/frames/last.jpg`;
|
||||
const jobs = [
|
||||
["first", ["-y", "-v", "error", "-i", resolve(projectRoot, inputPath), "-frames:v", "1", "-q:v", "2", resolve(projectRoot, first)]],
|
||||
["last", ["-y", "-v", "error", "-sseof", "-0.1", "-i", resolve(projectRoot, inputPath), "-frames:v", "1", "-q:v", "2", resolve(projectRoot, last)]]
|
||||
];
|
||||
const result = { first: "", last: "" };
|
||||
for (const [name, args] of jobs) {
|
||||
try {
|
||||
await execFileAsync(ffmpegBinary, args, { timeout: 20_000, maxBuffer: 1024 * 1024 });
|
||||
await access(resolve(projectRoot, name === "first" ? first : last));
|
||||
result[name] = name === "first" ? first : last;
|
||||
} catch {
|
||||
result[name] = "";
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function artifactPayload(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
...row,
|
||||
metadata: parseJson(row.metadata_json, {}),
|
||||
fileSize: Number(row.file_size || 0),
|
||||
durationSec: Number(row.duration_sec || 0),
|
||||
width: Number(row.width || 0),
|
||||
height: Number(row.height || 0),
|
||||
hasVideo: Boolean(row.has_video),
|
||||
hasAudio: Boolean(row.has_audio)
|
||||
};
|
||||
}
|
||||
|
||||
async function registerPath(context, descriptor) {
|
||||
const path = safeOutputPath(descriptor.path);
|
||||
if (!path) return null;
|
||||
const inspected = await inspectStoragePath(path, { mimeType: descriptor.mimeType || "" });
|
||||
const timestamp = now();
|
||||
const identity = `${descriptor.jobId || "no-job"}:${descriptor.compositionId || "no-composition"}:${descriptor.role || "output"}:${path}`;
|
||||
const id = makeId("artifact", identity);
|
||||
const metadata = {
|
||||
...(descriptor.metadata || {}),
|
||||
inspectedAt: timestamp,
|
||||
probeError: inspected.probeError || inspected.reason || ""
|
||||
};
|
||||
dbRun(
|
||||
`INSERT INTO media_artifacts(
|
||||
id, organization_id, workspace_id, project_id, episode_id, shot_id, job_id, composition_id,
|
||||
kind, role, status, path, mime_type, file_size, sha256, duration_sec, width, height,
|
||||
has_video, has_audio, first_frame_path, last_frame_path, metadata_json, created_by, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
mime_type = excluded.mime_type,
|
||||
file_size = excluded.file_size,
|
||||
sha256 = excluded.sha256,
|
||||
duration_sec = excluded.duration_sec,
|
||||
width = excluded.width,
|
||||
height = excluded.height,
|
||||
has_video = excluded.has_video,
|
||||
has_audio = excluded.has_audio,
|
||||
first_frame_path = excluded.first_frame_path,
|
||||
last_frame_path = excluded.last_frame_path,
|
||||
metadata_json = excluded.metadata_json,
|
||||
updated_at = excluded.updated_at`,
|
||||
[
|
||||
id,
|
||||
context.organization.id,
|
||||
context.workspace.id,
|
||||
context.project.id,
|
||||
descriptor.episodeId || null,
|
||||
descriptor.shotId || null,
|
||||
descriptor.jobId || null,
|
||||
descriptor.compositionId || null,
|
||||
descriptor.kind || "unknown",
|
||||
descriptor.role || "output",
|
||||
inspected.status,
|
||||
path,
|
||||
descriptor.mimeType || "",
|
||||
Number(inspected.bytes || 0),
|
||||
inspected.sha256 || "",
|
||||
Number(inspected.durationSec || 0),
|
||||
Number(inspected.width || 0),
|
||||
Number(inspected.height || 0),
|
||||
inspected.hasVideo ? 1 : 0,
|
||||
inspected.hasAudio ? 1 : 0,
|
||||
descriptor.firstFramePath || "",
|
||||
descriptor.lastFramePath || "",
|
||||
JSON.stringify(metadata),
|
||||
descriptor.createdBy || context.user?.id || null,
|
||||
timestamp,
|
||||
timestamp
|
||||
]
|
||||
);
|
||||
const row = dbGet("SELECT * FROM media_artifacts WHERE id = ?", [id]) || dbGet("SELECT * FROM media_artifacts WHERE job_id IS ? AND composition_id IS ? AND path = ? AND role = ? ORDER BY updated_at DESC LIMIT 1", [descriptor.jobId || null, descriptor.compositionId || null, path, descriptor.role || "output"]);
|
||||
return { row, inspected };
|
||||
}
|
||||
|
||||
export async function registerJobArtifacts(context, job, result = {}) {
|
||||
const kind = kindForJob(job);
|
||||
const candidates = [];
|
||||
const seen = new Set();
|
||||
const add = async (value, role = "output", mimeType = "") => {
|
||||
const path = candidatePath(value);
|
||||
if (path) {
|
||||
const safe = safeOutputPath(path);
|
||||
if (!safe || seen.has(`${role}:${safe}`)) return;
|
||||
seen.add(`${role}:${safe}`);
|
||||
candidates.push({ path: safe, role, mimeType: candidateMime(value, mimeType) });
|
||||
return;
|
||||
}
|
||||
const encoded = typeof value === "string" ? value : value?.b64_json || value?.base64 || "";
|
||||
if (!encoded) return;
|
||||
const materialized = await materializeBase64(job, value, candidates.length, kind, candidateMime(value, mimeType));
|
||||
if (materialized && !seen.has(`${role}:${materialized}`)) {
|
||||
seen.add(`${role}:${materialized}`);
|
||||
candidates.push({ path: materialized, role, mimeType: candidateMime(value, mimeType) });
|
||||
}
|
||||
};
|
||||
|
||||
const hasArrayOutputs = [result.data, result.images, result.outputs].some(Array.isArray);
|
||||
const normalizedOutput = result.outputPath || result.output_path || result.path || result.file;
|
||||
if (!hasArrayOutputs || normalizedOutput !== job.output_path) {
|
||||
await add(normalizedOutput, "output", result.mimeType || result.contentType || "");
|
||||
}
|
||||
await add(result.audioPath || result.audio_path, "audio", "audio/wav");
|
||||
await add(result.videoPath || result.video_path, "video", "video/mp4");
|
||||
await add(result.imagePath || result.image_path, "image", "image/png");
|
||||
for (const key of ["data", "images", "outputs"]) {
|
||||
if (!Array.isArray(result[key])) continue;
|
||||
for (const value of result[key]) await add(value, kind === "image" ? "image" : "output", candidateMime(value, kind === "image" ? "image/png" : ""));
|
||||
}
|
||||
if (!candidates.length && job.output_path) await add(job.output_path, "output", "");
|
||||
|
||||
const artifacts = [];
|
||||
for (const candidate of candidates) {
|
||||
const registered = await registerPath(context, {
|
||||
...candidate,
|
||||
kind,
|
||||
jobId: job.id,
|
||||
episodeId: job.episode_id,
|
||||
shotId: job.shot_id,
|
||||
createdBy: job.created_by,
|
||||
metadata: { source: "generation-job", resultKeys: Object.keys(result || {}) }
|
||||
});
|
||||
if (!registered?.row) continue;
|
||||
let row = registered.row;
|
||||
if (kind === "video" && registered.inspected.status === "inspected") {
|
||||
const frames = await extractVideoFrames({ jobId: job.id }, candidate.path, kind);
|
||||
if (frames.first || frames.last) {
|
||||
dbRun("UPDATE media_artifacts SET first_frame_path = ?, last_frame_path = ?, updated_at = ? WHERE id = ?", [frames.first, frames.last, now(), row.id]);
|
||||
row = dbGet("SELECT * FROM media_artifacts WHERE id = ?", [row.id]);
|
||||
if (job.shot_id && frames.last) {
|
||||
const currentShot = dbGet("SELECT last_frame_path FROM shots WHERE id = ?", [job.shot_id]);
|
||||
const currentLastFrame = await inspectStoragePath(currentShot?.last_frame_path || "");
|
||||
const canKeepCurrentLastFrame = currentLastFrame.status === "inspected" && !/pending|auto_previous/i.test(currentShot?.last_frame_path || "");
|
||||
if (!canKeepCurrentLastFrame) dbRun("UPDATE shots SET last_frame_path = ?, updated_at = ? WHERE id = ?", [frames.last, now(), job.shot_id]);
|
||||
const nextShot = dbGet("SELECT next.id, next.first_frame_path FROM shots current_shot JOIN shots next ON next.episode_id = current_shot.episode_id AND next.shot_number = current_shot.shot_number + 1 WHERE current_shot.id = ?", [job.shot_id]);
|
||||
const nextFirstFrame = await inspectStoragePath(nextShot?.first_frame_path || "");
|
||||
if (nextShot && (nextFirstFrame.status !== "inspected" || /pending|auto_previous|actual-last-frame/i.test(nextShot.first_frame_path || ""))) {
|
||||
dbRun("UPDATE shots SET first_frame_path = ?, updated_at = ? WHERE id = ?", [frames.last, now(), nextShot.id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
artifacts.push(artifactPayload(row));
|
||||
}
|
||||
return artifacts;
|
||||
}
|
||||
|
||||
export async function registerCompositionArtifact(context, composition, path) {
|
||||
const registered = await registerPath(context, {
|
||||
path,
|
||||
kind: "video",
|
||||
role: "composition-output",
|
||||
compositionId: composition.id,
|
||||
episodeId: composition.episode_id,
|
||||
createdBy: composition.created_by,
|
||||
metadata: { source: "ffmpeg-composition", version: composition.version }
|
||||
});
|
||||
if (!registered?.row) return null;
|
||||
const frames = await extractVideoFrames({ compositionId: composition.id }, path, "video");
|
||||
if (frames.first || frames.last) {
|
||||
dbRun("UPDATE media_artifacts SET first_frame_path = ?, last_frame_path = ?, updated_at = ? WHERE id = ?", [frames.first, frames.last, now(), registered.row.id]);
|
||||
}
|
||||
return artifactPayload(dbGet("SELECT * FROM media_artifacts WHERE id = ?", [registered.row.id]));
|
||||
}
|
||||
|
||||
export async function syncProjectJobArtifacts(context) {
|
||||
const jobs = dbAll("SELECT * FROM generation_jobs WHERE organization_id = ? AND workspace_id = ? AND project_id = ? AND status = 'completed' ORDER BY finished_at DESC", [context.organization.id, context.workspace.id, context.project.id]);
|
||||
const artifacts = [];
|
||||
for (const job of jobs) {
|
||||
const result = parseJson(job.result_json, {});
|
||||
artifacts.push(...await registerJobArtifacts(context, job, result));
|
||||
}
|
||||
return artifacts;
|
||||
}
|
||||
|
||||
export function listProjectArtifacts(context, options = {}) {
|
||||
const params = [context.organization.id, context.workspace.id, context.project.id];
|
||||
let where = "organization_id = ? AND workspace_id = ? AND project_id = ?";
|
||||
if (options.shotId) { where += " AND shot_id = ?"; params.push(options.shotId); }
|
||||
if (options.jobId) { where += " AND job_id = ?"; params.push(options.jobId); }
|
||||
const limit = Math.max(1, Math.min(500, Number(options.limit || 200)));
|
||||
return dbAll(`SELECT * FROM media_artifacts WHERE ${where} ORDER BY created_at DESC LIMIT ?`, [...params, limit]).map(artifactPayload);
|
||||
}
|
||||
|
||||
export function latestArtifactForShot(context, shotId, kind = "video") {
|
||||
const row = dbGet("SELECT * FROM media_artifacts WHERE organization_id = ? AND workspace_id = ? AND project_id = ? AND shot_id = ? AND kind = ? ORDER BY CASE WHEN status = 'inspected' THEN 0 ELSE 1 END, created_at DESC LIMIT 1", [context.organization.id, context.workspace.id, context.project.id, shotId, kind]);
|
||||
return artifactPayload(row);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { access, stat } from "node:fs/promises";
|
||||
import { promisify } from "node:util";
|
||||
import { resolve } from "node:path";
|
||||
import { dbAll, dbGet } from "./db.mjs";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const projectRoot = resolve(import.meta.dirname, "..");
|
||||
const ffmpegBinary = process.env.FFMPEG_BIN || "ffmpeg";
|
||||
const ffprobeBinary = process.env.FFPROBE_BIN || "ffprobe";
|
||||
|
||||
function parseJson(value, fallback) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function safeStoragePath(value) {
|
||||
const relative = String(value || "").trim();
|
||||
if (!relative || relative.startsWith("/") || relative.includes("..") || !relative.startsWith("storage/")) return null;
|
||||
const absolute = resolve(projectRoot, relative);
|
||||
if (!absolute.startsWith(`${projectRoot}/storage/`)) return null;
|
||||
return { relative, absolute };
|
||||
}
|
||||
|
||||
function mediaKind(pathname, mimeType = "") {
|
||||
const value = `${pathname} ${mimeType}`.toLowerCase();
|
||||
if (value.includes("audio") || /\.(wav|mp3|m4a|aac|flac|ogg)$/.test(value)) return "audio";
|
||||
if (value.includes("video") || /\.(mp4|mov|webm|mkv|avi)$/.test(value)) return "video";
|
||||
if (value.includes("image") || /\.(png|jpg|jpeg|webp|gif)$/.test(value)) return "image";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
async function fileSha256(pathname) {
|
||||
return new Promise((resolveHash, reject) => {
|
||||
const hash = createHash("sha256");
|
||||
const stream = createReadStream(pathname);
|
||||
stream.on("data", (chunk) => hash.update(chunk));
|
||||
stream.on("error", reject);
|
||||
stream.on("end", () => resolveHash(hash.digest("hex")));
|
||||
});
|
||||
}
|
||||
|
||||
async function probeMedia(pathname) {
|
||||
try {
|
||||
const result = await execFileAsync(ffprobeBinary, [
|
||||
"-v", "error",
|
||||
"-show_streams",
|
||||
"-show_format",
|
||||
"-of", "json",
|
||||
pathname
|
||||
], { timeout: 12_000, maxBuffer: 2 * 1024 * 1024 });
|
||||
return JSON.parse(String(result.stdout || "{}"));
|
||||
} catch (error) {
|
||||
return { error: String(error.stderr || error.message || error).slice(0, 500) };
|
||||
}
|
||||
}
|
||||
|
||||
async function averageFrame(pathname, kind, position = "first") {
|
||||
try {
|
||||
const args = ["-v", "error"];
|
||||
if (kind === "video" && position === "last") args.push("-sseof", "-0.1");
|
||||
args.push("-i", pathname, "-vf", "scale=1:1,format=rgb24", "-frames:v", "1", "-f", "rawvideo", "pipe:1");
|
||||
const result = await execFileAsync(ffmpegBinary, args, { timeout: 12_000, maxBuffer: 1024, encoding: "buffer" });
|
||||
const bytes = Buffer.from(result.stdout || []);
|
||||
if (bytes.length < 3) return null;
|
||||
return [bytes[0], bytes[1], bytes[2]];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function colorDistance(left, right) {
|
||||
if (!left || !right) return null;
|
||||
const distance = Math.sqrt(left.reduce((sum, value, index) => sum + ((value - right[index]) ** 2), 0)) / (255 * Math.sqrt(3));
|
||||
return Number(distance.toFixed(4));
|
||||
}
|
||||
|
||||
export async function inspectStoragePath(value, options = {}) {
|
||||
const target = safeStoragePath(value);
|
||||
if (!target) return { path: String(value || ""), status: "unverified", reason: "路径不是 storage/ 下的安全相对路径" };
|
||||
try {
|
||||
await access(target.absolute);
|
||||
const info = await stat(target.absolute);
|
||||
const kind = mediaKind(target.relative, options.mimeType);
|
||||
const probe = await probeMedia(target.absolute);
|
||||
const streams = Array.isArray(probe.streams) ? probe.streams : [];
|
||||
const video = streams.find((stream) => stream.codec_type === "video");
|
||||
const audio = streams.find((stream) => stream.codec_type === "audio");
|
||||
return {
|
||||
path: target.relative,
|
||||
status: probe.error ? "unverified" : "inspected",
|
||||
bytes: info.size,
|
||||
sha256: await fileSha256(target.absolute),
|
||||
kind,
|
||||
durationSec: Number(probe.format?.duration || video?.duration || audio?.duration || 0),
|
||||
width: Number(video?.width || 0),
|
||||
height: Number(video?.height || 0),
|
||||
hasVideo: Boolean(video),
|
||||
hasAudio: Boolean(audio),
|
||||
probeError: probe.error || "",
|
||||
firstFingerprint: kind === "image" || kind === "video" ? await averageFrame(target.absolute, kind, "first") : null,
|
||||
lastFingerprint: kind === "image" || kind === "video" ? await averageFrame(target.absolute, kind, "last") : null
|
||||
};
|
||||
} catch (error) {
|
||||
return { path: target.relative, status: "missing", reason: String(error.message || error).slice(0, 300) };
|
||||
}
|
||||
}
|
||||
|
||||
function completedJobForShot(shotId, matcher) {
|
||||
const rows = dbAll("SELECT * FROM generation_jobs WHERE shot_id = ? AND status = 'completed' ORDER BY finished_at DESC, created_at DESC", [shotId]);
|
||||
return rows.find((row) => matcher(String(row.kind || "").toLowerCase())) || null;
|
||||
}
|
||||
|
||||
function resultOutput(row) {
|
||||
if (!row) return "";
|
||||
const result = parseJson(row.result_json, {});
|
||||
return String(row.output_path || result.outputPath || result.output_path || result.path || "").trim();
|
||||
}
|
||||
|
||||
async function inspectShot(shot, previousShot) {
|
||||
const imageJob = completedJobForShot(shot.id, (kind) => kind.includes("图") || kind.includes("关键帧") || kind.includes("image"));
|
||||
const videoJob = completedJobForShot(shot.id, (kind) => kind.includes("视频") || kind.includes("i2v") || kind.includes("video"));
|
||||
const asrJob = completedJobForShot(shot.id, (kind) => kind.includes("asr") || kind.includes("字幕") || kind.includes("对齐"));
|
||||
const imageResult = parseJson(imageJob?.result_json, {});
|
||||
const imageArrays = [imageResult.data, imageResult.images, imageResult.outputs].filter(Array.isArray);
|
||||
const videoPath = resultOutput(videoJob);
|
||||
const imageArtifacts = imageJob
|
||||
? dbAll("SELECT * FROM media_artifacts WHERE job_id = ? ORDER BY created_at DESC", [imageJob.id])
|
||||
: [];
|
||||
const videoArtifacts = videoJob
|
||||
? dbAll("SELECT * FROM media_artifacts WHERE job_id = ? ORDER BY created_at DESC", [videoJob.id])
|
||||
: [];
|
||||
const imageArtifactCount = imageArtifacts.filter((artifact) => artifact.kind === "image" && artifact.status === "inspected").length;
|
||||
const imageOutputCount = imageArtifactCount || (imageArrays.length ? imageArrays[0].length : null);
|
||||
const selectedVideoArtifact = videoArtifacts.find((artifact) => artifact.kind === "video") || null;
|
||||
const selectedVideoPath = selectedVideoArtifact?.path || videoPath;
|
||||
const video = selectedVideoPath
|
||||
? await inspectStoragePath(selectedVideoPath, { mimeType: selectedVideoArtifact?.mime_type || "" })
|
||||
: { status: "unverified", reason: "没有已完成的视频任务" };
|
||||
const lastFrame = await inspectStoragePath(shot.lastFrame);
|
||||
const firstFrame = await inspectStoragePath(shot.firstFrame);
|
||||
const previousLastFrame = previousShot ? await inspectStoragePath(previousShot.lastFrame) : null;
|
||||
const currentFirstFingerprint = firstFrame?.firstFingerprint || firstFrame?.lastFingerprint;
|
||||
const previousLastFingerprint = previousLastFrame?.lastFingerprint || previousLastFrame?.firstFingerprint;
|
||||
const bridgeDistance = colorDistance(previousLastFingerprint, currentFirstFingerprint);
|
||||
const lines = Array.isArray(shot.voiceLines) ? shot.voiceLines : [];
|
||||
const voiceEvidence = await Promise.all(lines.map(async (line) => ({
|
||||
...line,
|
||||
media: line.audioFile ? await inspectStoragePath(line.audioFile, { mimeType: "audio/wav" }) : { status: "missing", reason: "对白没有音频路径" }
|
||||
})));
|
||||
const lockedVoice = lines.length === 0 || voiceEvidence.every((line) => line.voiceId && line.media.status === "inspected");
|
||||
const asrResult = parseJson(asrJob?.result_json, {});
|
||||
const segments = Array.isArray(asrResult.segments) ? asrResult.segments : Array.isArray(asrResult.result?.segments) ? asrResult.result.segments : [];
|
||||
const hasAsrEvidence = segments.length > 0 || Boolean(asrJob?.result_json && asrJob.result_json !== "{}");
|
||||
const targetDialogueSec = lines.reduce((sum, line) => sum + Number(line.targetDurationSec || 0), 0);
|
||||
|
||||
const singleFrame = imageOutputCount === null
|
||||
? { status: "pending", blockers: ["没有已完成的单画面图像任务,等待真实模型输出"], evidence: { imageJobId: imageJob?.id || null, imageOutputCount: null } }
|
||||
: { status: imageOutputCount === 1 ? "approved" : "changes_requested", blockers: imageOutputCount === 1 ? [] : [`图像输出数量为 ${imageOutputCount},要求恰好 1 张`], evidence: { imageJobId: imageJob.id, imageOutputCount } };
|
||||
const continuity = lastFrame.status === "inspected" && (shot.shotNumber <= 1 || shot.transitionFromPrevious === "episode-start" || (bridgeDistance !== null && bridgeDistance <= 0.35))
|
||||
? { status: "approved", blockers: [], evidence: { lastFrame, firstFrame, bridgeDistance, source: "frame-inspection" } }
|
||||
: { status: "pending", blockers: [shot.shotNumber > 1 ? "无法确认上一段实际末帧与当前首帧的文件级衔接" : "当前镜头尚未产生可检查的实际末帧"], evidence: { lastFrame, firstFrame, previousLastFrame, bridgeDistance, source: "frame-inspection" } };
|
||||
const voice = lockedVoice && (lines.length === 0 || (hasAsrEvidence && segments.length > 0))
|
||||
? { status: "approved", blockers: [], evidence: { lineCount: lines.length, lockedVoice, asrJobId: asrJob?.id || null, segmentCount: segments.length, targetDialogueSec, voiceEvidence } }
|
||||
: { status: "pending", blockers: [lockedVoice ? "缺少可验证的 ASR/字幕对齐证据" : "对白缺少固定 voiceId 或可读取的音频文件"], evidence: { lineCount: lines.length, lockedVoice, asrJobId: asrJob?.id || null, segmentCount: segments.length, targetDialogueSec, voiceEvidence } };
|
||||
const clip = video.status === "inspected" && video.hasVideo && Number(video.durationSec || 0) > 0
|
||||
? { status: "approved", blockers: [], evidence: { videoJobId: videoJob?.id || null, video } }
|
||||
: { status: "pending", blockers: ["没有可验证的视频文件或 FFprobe 无法读取视频流"], evidence: { videoJobId: videoJob?.id || null, video } };
|
||||
return {
|
||||
shotId: shot.id,
|
||||
gates: [
|
||||
{ lane: "single-frame", ...singleFrame },
|
||||
{ lane: "continuity-lock", ...continuity },
|
||||
{ lane: "voice-subtitle-asr", ...voice },
|
||||
{ lane: "clip-bridge", ...clip }
|
||||
],
|
||||
media: { imageJobId: imageJob?.id || null, videoJobId: videoJob?.id || null, asrJobId: asrJob?.id || null, imageArtifacts, videoArtifact: selectedVideoArtifact, video, firstFrame, lastFrame, bridgeDistance }
|
||||
};
|
||||
}
|
||||
|
||||
export async function inspectMediaForProject(context, shots) {
|
||||
const reports = [];
|
||||
for (let index = 0; index < shots.length; index += 1) reports.push(await inspectShot(shots[index], shots[index - 1] || null));
|
||||
const gates = reports.flatMap((report) => report.gates);
|
||||
return {
|
||||
reports,
|
||||
totals: {
|
||||
shots: reports.length,
|
||||
gates: gates.length,
|
||||
approved: gates.filter((gate) => gate.status === "approved").length,
|
||||
changesRequested: gates.filter((gate) => gate.status === "changes_requested").length,
|
||||
pending: gates.filter((gate) => gate.status === "pending").length
|
||||
},
|
||||
checkedAt: new Date().toISOString(),
|
||||
tools: { ffmpeg: ffmpegBinary, ffprobe: ffprobeBinary },
|
||||
scope: { organizationId: context.organization?.id, workspaceId: context.workspace?.id, projectId: context.project?.id }
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
import { dbAll, dbGet, dbRun } from "./db.mjs";
|
||||
|
||||
const makeId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
|
||||
function parseJson(value, fallback) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function privateHost(hostname) {
|
||||
const host = String(hostname || "").toLowerCase();
|
||||
if (["localhost", "127.0.0.1", "::1"].includes(host) || host.endsWith(".local")) return true;
|
||||
const octets = host.split(".").map(Number);
|
||||
if (octets.length !== 4 || octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) return false;
|
||||
return octets[0] === 10 || octets[0] === 127 || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) || (octets[0] === 192 && octets[1] === 168);
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url, options, timeoutMs = 8000) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, { ...options, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function eventMatches(channel, eventKey) {
|
||||
const events = parseJson(channel.events_json, []);
|
||||
return events.includes("*") || events.includes(eventKey);
|
||||
}
|
||||
|
||||
function notificationError(status, code, message, details = {}) {
|
||||
const error = new Error(message);
|
||||
error.status = status;
|
||||
error.code = code;
|
||||
error.details = details;
|
||||
return error;
|
||||
}
|
||||
|
||||
const NOTIFICATION_PREFERENCE_CATALOG = [
|
||||
{ category: "job", label: "生成任务", description: "本地任务完成、失败和重试状态" },
|
||||
{ category: "review", label: "审片流程", description: "质检通过、驳回、修改和新评论" },
|
||||
{ category: "delivery", label: "交付运营", description: "交付版本创建、批准和回滚" },
|
||||
{ category: "task", label: "协作任务", description: "任务分派、状态变更和截止提醒" },
|
||||
{ category: "access", label: "组织访问", description: "邀请、成员和权限相关事件" },
|
||||
{ category: "billing", label: "用量与配额", description: "配额预警和成本边界" },
|
||||
{ category: "system", label: "系统通知", description: "平台策略和系统范围变更" }
|
||||
];
|
||||
|
||||
function notificationDefinition(eventKey, payload = {}) {
|
||||
const kind = String(payload.kind || "生成任务");
|
||||
const shot = payload.shotTitle || payload.shotId || "当前镜头";
|
||||
const error = String(payload.error || "模型连接器返回了失败结果").slice(0, 180);
|
||||
const lane = payload.lane || "连续性检查";
|
||||
const version = payload.version || "交付版本";
|
||||
const definitions = {
|
||||
"job.completed": {
|
||||
category: "job",
|
||||
severity: "success",
|
||||
title: `${kind}已完成`,
|
||||
body: `${shot} 已完成本地执行,产出 ${Number(payload.artifactCount || 0)} 个可检查文件。`,
|
||||
targetTab: "jobs"
|
||||
},
|
||||
"job.failed": {
|
||||
category: "job",
|
||||
severity: "error",
|
||||
title: `${kind}生成失败`,
|
||||
body: `${shot} 的本地任务失败:${error}`,
|
||||
targetTab: "jobs"
|
||||
},
|
||||
"review.approved": {
|
||||
category: "review",
|
||||
severity: "success",
|
||||
title: `${lane}已通过`,
|
||||
body: `${shot} 的审片门已通过,可以继续下一步生产。`,
|
||||
targetTab: "qa"
|
||||
},
|
||||
"review.changes_requested": {
|
||||
category: "review",
|
||||
severity: "warning",
|
||||
title: `${lane}需要修改`,
|
||||
body: `${shot} 的审片门要求补充证据或修改后再提交。`,
|
||||
targetTab: "qa"
|
||||
},
|
||||
"review.rejected": {
|
||||
category: "review",
|
||||
severity: "error",
|
||||
title: `${lane}已驳回`,
|
||||
body: `${shot} 未通过审片,请查看阻断项并重新提交。`,
|
||||
targetTab: "qa"
|
||||
},
|
||||
"review.comment": {
|
||||
category: "review",
|
||||
severity: "info",
|
||||
title: "审片中心有新评论",
|
||||
body: `${shot} 收到新的审片意见,请回到审片中心查看。`,
|
||||
targetTab: "qa"
|
||||
},
|
||||
"delivery.created": {
|
||||
category: "delivery",
|
||||
severity: "info",
|
||||
title: `${version} 已创建`,
|
||||
body: "新的内部交付版本已建立,等待批次证据和审片结果。",
|
||||
targetTab: "export"
|
||||
},
|
||||
"delivery.approved": {
|
||||
category: "delivery",
|
||||
severity: "success",
|
||||
title: `${version} 已批准交付`,
|
||||
body: "交付版本已通过所有阻断门,可以进入本地发布或导出流程。",
|
||||
targetTab: "export"
|
||||
},
|
||||
"task.assigned": {
|
||||
category: "task",
|
||||
severity: "info",
|
||||
title: "你收到一个协作任务",
|
||||
body: `任务“${payload.taskTitle || "未命名任务"}”已分配给你,请在任务中心确认负责人和截止时间。`,
|
||||
targetTab: "tasks"
|
||||
},
|
||||
"task.updated": {
|
||||
category: "task",
|
||||
severity: payload.status === "blocked" ? "warning" : payload.status === "done" ? "success" : "info",
|
||||
title: "协作任务状态已更新",
|
||||
body: `任务“${payload.taskTitle || "未命名任务"}”当前状态:${payload.status || "已更新"}。`,
|
||||
targetTab: "tasks"
|
||||
},
|
||||
"task.commented": {
|
||||
category: "task",
|
||||
severity: "info",
|
||||
title: "协作任务有新讨论",
|
||||
body: `任务“${payload.taskTitle || "未命名任务"}”收到新的评论或 @提醒,请打开任务详情查看。`,
|
||||
targetTab: "tasks"
|
||||
},
|
||||
"invitation.created": {
|
||||
category: "access",
|
||||
severity: "info",
|
||||
title: "你收到新的组织邀请",
|
||||
body: `${payload.organizationName || "一个生产组织"} 邀请你以 ${payload.roleName || payload.roleKey || "成员"} 身份加入。`,
|
||||
targetTab: "account"
|
||||
},
|
||||
"system.changed": {
|
||||
category: "system",
|
||||
severity: "info",
|
||||
title: "系统配置已更新",
|
||||
body: payload.detail || "系统范围配置发生了变化,请确认当前生产策略。",
|
||||
targetTab: payload.targetTab || "system-overview"
|
||||
},
|
||||
"quota.warning": {
|
||||
category: "billing",
|
||||
severity: "warning",
|
||||
title: "组织配额即将达到上限",
|
||||
body: payload.detail || "请联系组织管理员调整配额或清理不再需要的产物。",
|
||||
targetTab: "admin-usage"
|
||||
}
|
||||
};
|
||||
return definitions[eventKey] || {
|
||||
category: String(payload.category || "system"),
|
||||
severity: String(payload.severity || "info"),
|
||||
title: String(payload.title || eventKey || "平台通知"),
|
||||
body: String(payload.body || "生产平台收到一条新的事件通知。"),
|
||||
targetTab: String(payload.targetTab || "creator-home")
|
||||
};
|
||||
}
|
||||
|
||||
function scopedRecipientIds(context, eventKey, payload = {}) {
|
||||
const requested = [
|
||||
...(Array.isArray(payload.recipientUserIds) ? payload.recipientUserIds : []),
|
||||
...(payload.recipientUserId ? [payload.recipientUserId] : [])
|
||||
].map((value) => String(value || "").trim()).filter(Boolean);
|
||||
if (requested.length) {
|
||||
const placeholders = requested.map(() => "?").join(",");
|
||||
return dbAll(
|
||||
`SELECT DISTINCT u.id
|
||||
FROM users u
|
||||
JOIN organization_members om ON om.user_id = u.id
|
||||
WHERE u.status = 'active' AND om.organization_id = ? AND om.status = 'active'
|
||||
AND u.id IN (${placeholders})`,
|
||||
[context.organization.id, ...requested]
|
||||
).map((row) => row.id);
|
||||
}
|
||||
|
||||
if (!context?.organization?.id || !context?.user?.id) return [];
|
||||
if (eventKey.startsWith("system.")) return [context.user.id];
|
||||
if (eventKey === "invitation.created") return payload.recipientUserId ? [String(payload.recipientUserId)] : [context.user.id];
|
||||
|
||||
return dbAll(
|
||||
`SELECT DISTINCT u.id
|
||||
FROM users u
|
||||
JOIN organization_members om ON om.user_id = u.id
|
||||
LEFT JOIN workspace_members wm ON wm.user_id = u.id AND wm.workspace_id = ? AND wm.status = 'active'
|
||||
LEFT JOIN project_members pm ON pm.user_id = u.id AND pm.project_id = ? AND pm.status = 'active'
|
||||
WHERE u.status = 'active' AND om.organization_id = ? AND om.status = 'active'
|
||||
AND (om.role_key IN ('org_owner', 'org_admin') OR wm.user_id IS NOT NULL OR pm.user_id IS NOT NULL)`,
|
||||
[context.workspace?.id || "", context.project?.id || "", context.organization.id]
|
||||
).map((row) => row.id);
|
||||
}
|
||||
|
||||
function userNotificationPayload(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
category: row.category,
|
||||
eventKey: row.event_key,
|
||||
severity: row.severity,
|
||||
title: row.title,
|
||||
body: row.body,
|
||||
targetTab: row.target_tab,
|
||||
targetId: row.target_id || "",
|
||||
metadata: parseJson(row.metadata_json, {}),
|
||||
createdAt: row.created_at,
|
||||
readAt: row.read_at,
|
||||
read: Boolean(row.read_at),
|
||||
organizationId: row.organization_id,
|
||||
workspaceId: row.workspace_id,
|
||||
projectId: row.project_id
|
||||
};
|
||||
}
|
||||
|
||||
function preferenceCatalogItem(row) {
|
||||
const definition = NOTIFICATION_PREFERENCE_CATALOG.find((item) => item.category === row.category) || { category: row.category, label: row.category, description: "" };
|
||||
return {
|
||||
...definition,
|
||||
enabled: row.in_app_enabled === undefined ? true : Boolean(row.in_app_enabled),
|
||||
customized: row.in_app_enabled !== undefined
|
||||
};
|
||||
}
|
||||
|
||||
function inAppScope(context) {
|
||||
if (!context?.user?.id || !context?.organization?.id) throw notificationError(401, "auth_required", "请先登录");
|
||||
return {
|
||||
where: "user_id = ? AND organization_id = ? AND (workspace_id IS NULL OR workspace_id = ?)",
|
||||
params: [context.user.id, context.organization.id, context.workspace?.id || ""]
|
||||
};
|
||||
}
|
||||
|
||||
function createUserNotifications({ context, eventKey, payload = {}, definition }) {
|
||||
const recipientIds = scopedRecipientIds(context, eventKey, payload);
|
||||
if (!recipientIds.length) return [];
|
||||
const timestamp = new Date().toISOString();
|
||||
const metadata = { ...payload };
|
||||
delete metadata.recipientUserId;
|
||||
delete metadata.recipientUserIds;
|
||||
const created = [];
|
||||
for (const userId of recipientIds) {
|
||||
const preference = dbGet("SELECT in_app_enabled FROM user_notification_preferences WHERE user_id = ? AND organization_id = ? AND category = ?", [userId, context.organization.id, definition.category]);
|
||||
if (preference && !Boolean(preference.in_app_enabled)) continue;
|
||||
const id = makeId("user-notification");
|
||||
dbRun(
|
||||
"INSERT INTO user_notifications(id, user_id, organization_id, workspace_id, project_id, category, event_key, severity, title, body, target_tab, target_id, metadata_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
[
|
||||
id,
|
||||
userId,
|
||||
context.organization.id,
|
||||
context.workspace?.id || null,
|
||||
context.project?.id || null,
|
||||
definition.category,
|
||||
eventKey,
|
||||
definition.severity,
|
||||
definition.title,
|
||||
definition.body,
|
||||
definition.targetTab,
|
||||
String(payload.targetId || payload.reviewId || payload.jobId || payload.deliveryId || ""),
|
||||
JSON.stringify(metadata),
|
||||
timestamp
|
||||
]
|
||||
);
|
||||
created.push(dbGet("SELECT * FROM user_notifications WHERE id = ?", [id]));
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
export function listUserNotificationPreferences(context) {
|
||||
const scope = inAppScope(context);
|
||||
const rows = dbAll("SELECT category, in_app_enabled FROM user_notification_preferences WHERE user_id = ? AND organization_id = ?", [scope.params[0], scope.params[1]]);
|
||||
const byCategory = new Map(rows.map((row) => [row.category, row]));
|
||||
return {
|
||||
preferences: NOTIFICATION_PREFERENCE_CATALOG.map((definition) => preferenceCatalogItem(byCategory.get(definition.category) || { category: definition.category })),
|
||||
scope: { organizationId: context.organization.id },
|
||||
generatedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
export function updateUserNotificationPreference(context, category, enabled) {
|
||||
inAppScope(context);
|
||||
const definition = NOTIFICATION_PREFERENCE_CATALOG.find((item) => item.category === String(category || "").trim());
|
||||
if (!definition) throw notificationError(400, "notification_category_invalid", "通知类别不存在", { category });
|
||||
dbRun(
|
||||
"INSERT INTO user_notification_preferences(user_id, organization_id, category, in_app_enabled, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(user_id, organization_id, category) DO UPDATE SET in_app_enabled = excluded.in_app_enabled, updated_at = excluded.updated_at",
|
||||
[context.user.id, context.organization.id, definition.category, enabled ? 1 : 0, new Date().toISOString()]
|
||||
);
|
||||
return listUserNotificationPreferences(context);
|
||||
}
|
||||
|
||||
export function listUserNotifications(context, { limit = 60, unreadOnly = false } = {}) {
|
||||
const scope = inAppScope(context);
|
||||
const normalizedLimit = Math.max(1, Math.min(200, Number(limit || 60)));
|
||||
const unreadClause = unreadOnly ? " AND read_at IS NULL" : "";
|
||||
const rows = dbAll(
|
||||
`SELECT * FROM user_notifications
|
||||
WHERE ${scope.where}${unreadClause}
|
||||
ORDER BY CASE WHEN read_at IS NULL THEN 0 ELSE 1 END, created_at DESC
|
||||
LIMIT ?`,
|
||||
[...scope.params, normalizedLimit]
|
||||
);
|
||||
const unreadCount = Number(dbGet(`SELECT COUNT(*) AS count FROM user_notifications WHERE ${scope.where} AND read_at IS NULL`, scope.params)?.count || 0);
|
||||
return {
|
||||
notifications: rows.map(userNotificationPayload),
|
||||
unreadCount,
|
||||
total: rows.length,
|
||||
scope: { organizationId: context.organization.id, workspaceId: context.workspace?.id || "" },
|
||||
generatedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
export function markUserNotificationRead(context, notificationId, read = true) {
|
||||
const scope = inAppScope(context);
|
||||
const current = dbGet(`SELECT * FROM user_notifications WHERE id = ? AND ${scope.where}`, [notificationId, ...scope.params]);
|
||||
if (!current) throw notificationError(404, "notification_not_found", "通知不存在或不属于当前工作区");
|
||||
dbRun(`UPDATE user_notifications SET read_at = ? WHERE id = ? AND ${scope.where}`, [read ? new Date().toISOString() : null, notificationId, ...scope.params]);
|
||||
return listUserNotifications(context, { limit: 60 });
|
||||
}
|
||||
|
||||
export function markAllUserNotificationsRead(context) {
|
||||
const scope = inAppScope(context);
|
||||
dbRun(`UPDATE user_notifications SET read_at = COALESCE(read_at, ?) WHERE ${scope.where} AND read_at IS NULL`, [new Date().toISOString(), ...scope.params]);
|
||||
return listUserNotifications(context, { limit: 60 });
|
||||
}
|
||||
|
||||
async function deliver(channel, eventKey, requestBody) {
|
||||
const id = makeId("delivery");
|
||||
const timestamp = new Date().toISOString();
|
||||
dbRun("INSERT INTO notification_deliveries(id, channel_id, event_key, organization_id, workspace_id, project_id, status, attempt_count, request_json, created_at) VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)", [id, channel.id, eventKey, requestBody.organizationId || null, requestBody.workspaceId || null, requestBody.projectId || null, JSON.stringify(requestBody), timestamp]);
|
||||
let status = "delivered";
|
||||
let response = { kind: channel.kind };
|
||||
let errorMessage = "";
|
||||
let httpStatus = null;
|
||||
try {
|
||||
if (channel.kind === "local-log") {
|
||||
response = { logged: true, event: eventKey };
|
||||
} else if (channel.kind === "webhook") {
|
||||
const endpoint = new URL(String(channel.endpoint || ""));
|
||||
if (endpoint.protocol !== "http:" || !privateHost(endpoint.hostname)) {
|
||||
status = "blocked";
|
||||
errorMessage = "本地策略只允许投递到 HTTP 私有地址";
|
||||
} else {
|
||||
const result = await fetchWithTimeout(endpoint.toString(), {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", "x-ai-drama-event": eventKey },
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
httpStatus = result.status;
|
||||
const raw = await result.text();
|
||||
response = { httpStatus, body: raw.slice(0, 1000) };
|
||||
if (!result.ok) {
|
||||
status = "failed";
|
||||
errorMessage = `Webhook 返回 HTTP ${result.status}`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
status = "blocked";
|
||||
errorMessage = `通知渠道类型 ${channel.kind} 暂不支持`;
|
||||
}
|
||||
} catch (error) {
|
||||
status = "failed";
|
||||
errorMessage = error.name === "AbortError" ? "通知投递超时" : String(error.message || error).slice(0, 500);
|
||||
}
|
||||
const deliveredAt = status === "delivered" ? new Date().toISOString() : null;
|
||||
dbRun("UPDATE notification_deliveries SET status = ?, attempt_count = 1, response_json = ?, error_message = ?, delivered_at = ? WHERE id = ?", [status, JSON.stringify({ ...response, httpStatus }), errorMessage, deliveredAt, id]);
|
||||
return dbGet("SELECT * FROM notification_deliveries WHERE id = ?", [id]);
|
||||
}
|
||||
|
||||
export async function dispatchNotificationEvent({ context, eventKey, payload = {} }) {
|
||||
const definition = notificationDefinition(eventKey, payload);
|
||||
const userNotifications = createUserNotifications({ context, eventKey, payload, definition });
|
||||
const channels = dbAll("SELECT * FROM notification_channels WHERE enabled = 1 ORDER BY created_at").filter((channel) => eventMatches(channel, eventKey));
|
||||
const requestBody = {
|
||||
schema: "ai-drama.notification.v1",
|
||||
event: eventKey,
|
||||
occurredAt: new Date().toISOString(),
|
||||
actor: context?.user ? { id: context.user.id, name: context.user.display_name } : null,
|
||||
organizationId: context?.organization?.id || null,
|
||||
workspaceId: context?.workspace?.id || null,
|
||||
projectId: context?.project?.id || null,
|
||||
payload
|
||||
};
|
||||
const deliveries = [];
|
||||
for (const channel of channels) deliveries.push(await deliver(channel, eventKey, requestBody));
|
||||
return { deliveries, userNotifications: userNotifications.map(userNotificationPayload) };
|
||||
}
|
||||
|
||||
export function notificationDeliveries(context, limit = 80) {
|
||||
return dbAll(
|
||||
`SELECT d.*, c.name AS channel_name, c.kind AS channel_kind
|
||||
FROM notification_deliveries d
|
||||
JOIN notification_channels c ON c.id = d.channel_id
|
||||
WHERE d.organization_id = ?
|
||||
AND (d.workspace_id IS NULL OR d.workspace_id = ?)
|
||||
AND (d.project_id IS NULL OR d.project_id = ?)
|
||||
ORDER BY d.created_at DESC LIMIT ?`,
|
||||
[context.organization.id, context.workspace.id, context.project?.id || "", Math.max(1, Math.min(200, Number(limit || 80)))]
|
||||
).map((row) => ({
|
||||
...row,
|
||||
request: parseJson(row.request_json, {}),
|
||||
response: parseJson(row.response_json, {})
|
||||
}));
|
||||
}
|
||||
+397
@@ -0,0 +1,397 @@
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
createHash,
|
||||
createPublicKey,
|
||||
createVerify,
|
||||
constants,
|
||||
randomBytes,
|
||||
scryptSync
|
||||
} from "node:crypto";
|
||||
import { createPasswordRecord, dbAll, dbGet, dbRun } from "./db.mjs";
|
||||
import { createMfaChallenge, createMfaEnrollmentChallenge, createSession, mfaRequiredForUser, mfaStatus, safeUser } from "./auth.mjs";
|
||||
|
||||
const LOGIN_STATE_TTL_MS = 10 * 60 * 1000;
|
||||
const TICKET_TTL_MS = 60 * 1000;
|
||||
const CLOCK_SKEW_MS = 60 * 1000;
|
||||
const OIDC_STORAGE_KEY = scryptSync(process.env.AI_DRAMA_OIDC_STORAGE_KEY || process.env.AI_DRAMA_SESSION_SECRET || "ai-drama-local-oidc-key-v1", "ai-drama-oidc-storage", 32);
|
||||
const OIDC_JWKS_CACHE = new Map();
|
||||
const { RSA_PKCS1_PSS_PADDING, RSA_PSS_SALTLEN_DIGEST } = constants;
|
||||
|
||||
function oidcError(status, code, message, details = {}) {
|
||||
const error = new Error(message);
|
||||
error.status = status;
|
||||
error.code = code;
|
||||
error.details = details;
|
||||
return error;
|
||||
}
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function hashValue(value) {
|
||||
return createHash("sha256").update(String(value || "")).digest("hex");
|
||||
}
|
||||
|
||||
function base64url(buffer) {
|
||||
return Buffer.from(buffer).toString("base64url");
|
||||
}
|
||||
|
||||
function decodeBase64url(value) {
|
||||
return Buffer.from(String(value || ""), "base64url");
|
||||
}
|
||||
|
||||
function encryptOpaque(value) {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv("aes-256-gcm", OIDC_STORAGE_KEY, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(String(value), "utf8"), cipher.final()]);
|
||||
return [iv, cipher.getAuthTag(), encrypted].map((part) => part.toString("base64url")).join(".");
|
||||
}
|
||||
|
||||
function decryptOpaque(payload) {
|
||||
const [ivValue, tagValue, encryptedValue] = String(payload || "").split(".");
|
||||
if (!ivValue || !tagValue || !encryptedValue) throw oidcError(500, "oidc_state_invalid", "OIDC 登录状态存储记录无效");
|
||||
const decipher = createDecipheriv("aes-256-gcm", OIDC_STORAGE_KEY, decodeBase64url(ivValue));
|
||||
decipher.setAuthTag(decodeBase64url(tagValue));
|
||||
return Buffer.concat([decipher.update(decodeBase64url(encryptedValue)), decipher.final()]).toString("utf8");
|
||||
}
|
||||
|
||||
function parseJson(value, fallback) {
|
||||
try { return JSON.parse(value); } catch { return fallback; }
|
||||
}
|
||||
|
||||
function normalizeIssuer(value) {
|
||||
try {
|
||||
return new URL(String(value || "")).toString().replace(/\/+$/, "");
|
||||
} catch {
|
||||
return String(value || "").replace(/\/+$/, "");
|
||||
}
|
||||
}
|
||||
|
||||
function claimValue(claims, path) {
|
||||
const keys = String(path || "").split(".").filter(Boolean);
|
||||
let current = claims;
|
||||
for (const key of keys) {
|
||||
if (!current || typeof current !== "object") return "";
|
||||
current = current[key];
|
||||
}
|
||||
return Array.isArray(current) ? current[0] : current;
|
||||
}
|
||||
|
||||
function providerRow(providerId) {
|
||||
const provider = dbGet("SELECT * FROM identity_providers WHERE id = ? AND enabled = 1", [providerId]);
|
||||
if (!provider) throw oidcError(404, "sso_provider_not_found", "企业身份提供商不存在或未启用");
|
||||
if (provider.kind !== "oidc") throw oidcError(400, "sso_provider_kind_unsupported", "当前登录链路只支持 OIDC,SAML 仍需部署层断言消费适配");
|
||||
if (!provider.issuer_url || !provider.client_id) throw oidcError(400, "sso_provider_incomplete", "OIDC 提供商缺少 Issuer 或 Client ID");
|
||||
return provider;
|
||||
}
|
||||
|
||||
function identityPolicy() {
|
||||
return dbGet("SELECT * FROM identity_policies WHERE id = 'default'") || {};
|
||||
}
|
||||
|
||||
function ensureSsoEnabled() {
|
||||
if (!identityPolicy().sso_enabled) throw oidcError(403, "sso_disabled", "平台当前未启用企业 SSO");
|
||||
}
|
||||
|
||||
function cleanupExpired() {
|
||||
const timestamp = nowIso();
|
||||
dbRun("DELETE FROM oidc_login_states WHERE expires_at <= ? OR consumed_at IS NOT NULL", [timestamp]);
|
||||
dbRun("DELETE FROM auth_sso_tickets WHERE expires_at <= ? OR consumed_at IS NOT NULL", [timestamp]);
|
||||
}
|
||||
|
||||
async function fetchJson(url, options = {}, label = "OIDC 请求") {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||
try {
|
||||
const response = await fetch(url, { ...options, signal: controller.signal });
|
||||
const raw = await response.text();
|
||||
const payload = raw ? parseJson(raw, {}) : {};
|
||||
if (!response.ok) throw oidcError(502, "oidc_upstream_error", `${label}失败:HTTP ${response.status}`, { status: response.status, response: payload });
|
||||
return payload;
|
||||
} catch (error) {
|
||||
if (error.status) throw error;
|
||||
throw oidcError(502, "oidc_upstream_unreachable", `${label}失败:${error.message}`);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function discover(provider) {
|
||||
const discoveryUrl = `${normalizeIssuer(provider.issuer_url)}/.well-known/openid-configuration`;
|
||||
const discovery = await fetchJson(discoveryUrl, { headers: { accept: "application/json" } }, "OIDC discovery");
|
||||
return {
|
||||
issuer: discovery.issuer || provider.issuer_url,
|
||||
authorizationEndpoint: provider.authorization_url || discovery.authorization_endpoint,
|
||||
tokenEndpoint: provider.token_url || discovery.token_endpoint,
|
||||
userinfoEndpoint: provider.userinfo_url || discovery.userinfo_endpoint || "",
|
||||
jwksUri: provider.jwks_url || discovery.jwks_uri || ""
|
||||
};
|
||||
}
|
||||
|
||||
export function startOidcLogin(providerId, { redirectUri, returnTo = "/", selection = {}, ipAddress = "", userAgent = "" } = {}) {
|
||||
ensureSsoEnabled();
|
||||
const provider = providerRow(providerId);
|
||||
const authorizationUrl = String(provider.authorization_url || "").trim();
|
||||
if (!authorizationUrl) throw oidcError(400, "sso_authorization_endpoint_missing", "OIDC 提供商尚未配置 Authorization Endpoint,请先探测");
|
||||
const state = base64url(randomBytes(32));
|
||||
const nonce = base64url(randomBytes(32));
|
||||
const codeVerifier = base64url(randomBytes(48));
|
||||
const codeChallenge = base64url(createHash("sha256").update(codeVerifier).digest());
|
||||
const timestamp = nowIso();
|
||||
const expiresAt = new Date(Date.now() + LOGIN_STATE_TTL_MS).toISOString();
|
||||
const id = `oidc-state-${Date.now()}-${randomBytes(4).toString("hex")}`;
|
||||
dbRun("INSERT INTO oidc_login_states(id, state_hash, provider_id, nonce_hash, code_verifier_ciphertext, redirect_uri, return_to, selection_json, ip_address, user_agent, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [
|
||||
id,
|
||||
hashValue(state),
|
||||
provider.id,
|
||||
hashValue(nonce),
|
||||
encryptOpaque(codeVerifier),
|
||||
redirectUri,
|
||||
String(returnTo || "/"),
|
||||
JSON.stringify(selection || {}),
|
||||
String(ipAddress || ""),
|
||||
String(userAgent || ""),
|
||||
expiresAt,
|
||||
timestamp
|
||||
]);
|
||||
const url = new URL(authorizationUrl);
|
||||
const scopes = parseJson(provider.scopes_json, ["openid", "profile", "email"]);
|
||||
url.searchParams.set("response_type", "code");
|
||||
url.searchParams.set("client_id", provider.client_id);
|
||||
url.searchParams.set("redirect_uri", redirectUri);
|
||||
url.searchParams.set("scope", Array.isArray(scopes) ? scopes.join(" ") : "openid profile email");
|
||||
url.searchParams.set("state", state);
|
||||
url.searchParams.set("nonce", nonce);
|
||||
url.searchParams.set("code_challenge", codeChallenge);
|
||||
url.searchParams.set("code_challenge_method", "S256");
|
||||
return { provider: { id: provider.id, name: provider.name }, authorizationUrl: url.toString(), expiresAt };
|
||||
}
|
||||
|
||||
function consumeLoginState(state) {
|
||||
cleanupExpired();
|
||||
const timestamp = nowIso();
|
||||
const result = dbRun("UPDATE oidc_login_states SET consumed_at = ? WHERE state_hash = ? AND consumed_at IS NULL AND expires_at > ?", [timestamp, hashValue(state), timestamp]);
|
||||
if (!Number(result.changes || 0)) throw oidcError(401, "oidc_state_invalid", "OIDC 登录状态无效、已使用或已过期");
|
||||
const row = dbGet("SELECT * FROM oidc_login_states WHERE state_hash = ?", [hashValue(state)]);
|
||||
if (!row) throw oidcError(401, "oidc_state_invalid", "OIDC 登录状态不存在");
|
||||
return row;
|
||||
}
|
||||
|
||||
function parseJwt(token) {
|
||||
const parts = String(token || "").split(".");
|
||||
if (parts.length !== 3) throw oidcError(502, "oidc_id_token_invalid", "OIDC 返回的 ID Token 格式无效");
|
||||
try {
|
||||
return { header: JSON.parse(decodeBase64url(parts[0]).toString("utf8")), claims: JSON.parse(decodeBase64url(parts[1]).toString("utf8")), encodedHeader: parts[0], encodedPayload: parts[1], signature: decodeBase64url(parts[2]) };
|
||||
} catch {
|
||||
throw oidcError(502, "oidc_id_token_invalid", "OIDC 返回的 ID Token 不是有效 JWT");
|
||||
}
|
||||
}
|
||||
|
||||
function ecdsaJoseToDer(signature) {
|
||||
const half = Math.floor(signature.length / 2);
|
||||
const encodeInteger = (value) => {
|
||||
let output = Buffer.from(value);
|
||||
while (output.length > 1 && output[0] === 0) output = output.subarray(1);
|
||||
if (output[0] & 0x80) output = Buffer.concat([Buffer.from([0]), output]);
|
||||
return Buffer.concat([Buffer.from([0x02, output.length]), output]);
|
||||
};
|
||||
const sequence = Buffer.concat([encodeInteger(signature.subarray(0, half)), encodeInteger(signature.subarray(half))]);
|
||||
if (sequence.length >= 128) return Buffer.concat([Buffer.from([0x30, 0x81, sequence.length]), sequence]);
|
||||
return Buffer.concat([Buffer.from([0x30, sequence.length]), sequence]);
|
||||
}
|
||||
|
||||
async function verifyIdToken(token, provider, discovery) {
|
||||
const parsed = parseJwt(token);
|
||||
const { header, claims, encodedHeader, encodedPayload, signature } = parsed;
|
||||
const algorithms = {
|
||||
RS256: { hash: "SHA256", type: "rsa" },
|
||||
RS384: { hash: "SHA384", type: "rsa" },
|
||||
RS512: { hash: "SHA512", type: "rsa" },
|
||||
PS256: { hash: "SHA256", type: "pss" },
|
||||
PS384: { hash: "SHA384", type: "pss" },
|
||||
PS512: { hash: "SHA512", type: "pss" },
|
||||
ES256: { hash: "SHA256", type: "ecdsa" },
|
||||
ES384: { hash: "SHA384", type: "ecdsa" },
|
||||
ES512: { hash: "SHA512", type: "ecdsa" }
|
||||
};
|
||||
const algorithm = algorithms[header.alg];
|
||||
if (!algorithm) throw oidcError(502, "oidc_algorithm_unsupported", `OIDC ID Token 签名算法不受支持:${header.alg || "未声明"}`);
|
||||
if (!discovery.jwksUri) throw oidcError(502, "oidc_jwks_missing", "OIDC 提供商未返回 JWKS 地址");
|
||||
let cache = OIDC_JWKS_CACHE.get(discovery.jwksUri);
|
||||
if (!cache || cache.expiresAt <= Date.now()) {
|
||||
cache = { keys: (await fetchJson(discovery.jwksUri, { headers: { accept: "application/json" } }, "OIDC JWKS")).keys || [], expiresAt: Date.now() + 5 * 60 * 1000 };
|
||||
OIDC_JWKS_CACHE.set(discovery.jwksUri, cache);
|
||||
}
|
||||
let jwk = cache.keys.find((item) => item.kid && item.kid === header.kid);
|
||||
if (!jwk && !header.kid && cache.keys.length === 1) jwk = cache.keys[0];
|
||||
if (!jwk) {
|
||||
OIDC_JWKS_CACHE.delete(discovery.jwksUri);
|
||||
const refreshed = (await fetchJson(discovery.jwksUri, { headers: { accept: "application/json" } }, "OIDC JWKS 刷新")).keys || [];
|
||||
OIDC_JWKS_CACHE.set(discovery.jwksUri, { keys: refreshed, expiresAt: Date.now() + 5 * 60 * 1000 });
|
||||
jwk = refreshed.find((item) => item.kid && item.kid === header.kid) || (!header.kid && refreshed.length === 1 ? refreshed[0] : null);
|
||||
}
|
||||
if (!jwk) throw oidcError(502, "oidc_signing_key_not_found", "OIDC ID Token 的签名密钥不在 JWKS 中");
|
||||
let publicKey;
|
||||
try { publicKey = createPublicKey({ key: jwk, format: "jwk" }); } catch (error) { throw oidcError(502, "oidc_jwk_invalid", `OIDC JWKS 公钥无效:${error.message}`); }
|
||||
const verify = createVerify(algorithm.hash);
|
||||
verify.update(`${encodedHeader}.${encodedPayload}`);
|
||||
verify.end();
|
||||
const normalizedSignature = algorithm.type === "ecdsa" ? ecdsaJoseToDer(signature) : signature;
|
||||
const verified = algorithm.type === "pss"
|
||||
? verify.verify({ key: publicKey, padding: RSA_PKCS1_PSS_PADDING, saltLength: RSA_PSS_SALTLEN_DIGEST }, normalizedSignature)
|
||||
: verify.verify(publicKey, normalizedSignature);
|
||||
if (!verified) throw oidcError(401, "oidc_signature_invalid", "OIDC ID Token 签名校验失败");
|
||||
const now = Date.now();
|
||||
if (normalizeIssuer(claims.iss) !== normalizeIssuer(provider.issuer_url)) throw oidcError(401, "oidc_issuer_invalid", "OIDC ID Token 的 Issuer 不匹配");
|
||||
const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
|
||||
if (!audiences.includes(provider.client_id)) throw oidcError(401, "oidc_audience_invalid", "OIDC ID Token 的 Audience 不匹配");
|
||||
if (claims.azp && claims.azp !== provider.client_id) throw oidcError(401, "oidc_authorized_party_invalid", "OIDC ID Token 的 azp 不匹配");
|
||||
if (!claims.nonce) throw oidcError(401, "oidc_nonce_missing", "OIDC ID Token 缺少 nonce");
|
||||
if (!claims.sub) throw oidcError(401, "oidc_subject_missing", "OIDC ID Token 缺少 subject");
|
||||
if (!claims.exp || Number(claims.exp) * 1000 + CLOCK_SKEW_MS < now) throw oidcError(401, "oidc_token_expired", "OIDC ID Token 已过期");
|
||||
if (claims.iat && Number(claims.iat) * 1000 - CLOCK_SKEW_MS > now) throw oidcError(401, "oidc_token_issued_in_future", "OIDC ID Token 的签发时间无效");
|
||||
return claims;
|
||||
}
|
||||
|
||||
async function exchangeCode(provider, stateRow, code) {
|
||||
const discovery = await discover(provider);
|
||||
if (!discovery.tokenEndpoint) throw oidcError(400, "sso_token_endpoint_missing", "OIDC 提供商尚未配置 Token Endpoint");
|
||||
const clientSecret = process.env[provider.client_secret_ref];
|
||||
if (!clientSecret) throw oidcError(503, "sso_client_secret_missing", `服务端环境变量 ${provider.client_secret_ref} 未配置`);
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: String(code || ""),
|
||||
redirect_uri: stateRow.redirect_uri,
|
||||
client_id: provider.client_id,
|
||||
client_secret: clientSecret,
|
||||
code_verifier: decryptOpaque(stateRow.code_verifier_ciphertext)
|
||||
});
|
||||
const token = await fetchJson(discovery.tokenEndpoint, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" }, body }, "OIDC code exchange");
|
||||
if (!token.id_token) throw oidcError(502, "oidc_id_token_missing", "OIDC Token Endpoint 未返回 ID Token");
|
||||
const claims = await verifyIdToken(token.id_token, provider, discovery);
|
||||
if (hashValue(claims.nonce) !== stateRow.nonce_hash) throw oidcError(401, "oidc_nonce_invalid", "OIDC ID Token 的 nonce 不匹配");
|
||||
if (discovery.userinfoEndpoint && token.access_token) {
|
||||
try {
|
||||
const userInfo = await fetchJson(discovery.userinfoEndpoint, { headers: { authorization: `Bearer ${token.access_token}`, accept: "application/json" } }, "OIDC UserInfo");
|
||||
for (const [key, value] of Object.entries(userInfo || {})) if (claims[key] === undefined) claims[key] = value;
|
||||
} catch {
|
||||
// ID Token claims remain authoritative when an optional UserInfo call fails.
|
||||
}
|
||||
}
|
||||
return { claims, token, discovery };
|
||||
}
|
||||
|
||||
function avatarColorFor(email) {
|
||||
const colors = ["#d97757", "#3f7f87", "#a77646", "#8e6a9f", "#477d69", "#9a6b51"];
|
||||
const digest = createHash("sha256").update(email).digest().readUInt16BE(0);
|
||||
return colors[digest % colors.length];
|
||||
}
|
||||
|
||||
function organizationFor(provider, existingUser) {
|
||||
if (provider.organization_id) {
|
||||
const organization = dbGet("SELECT * FROM organizations WHERE id = ? AND status = 'active'", [provider.organization_id]);
|
||||
if (!organization) throw oidcError(403, "sso_organization_unavailable", "SSO 提供商绑定的组织不存在或已停用");
|
||||
return organization;
|
||||
}
|
||||
if (existingUser) {
|
||||
const membership = dbGet("SELECT o.* FROM organizations o JOIN organization_members om ON om.organization_id = o.id WHERE om.user_id = ? AND om.status = 'active' AND o.status = 'active' ORDER BY om.joined_at ASC LIMIT 1", [existingUser.id]);
|
||||
if (membership) return membership;
|
||||
}
|
||||
throw oidcError(403, "sso_organization_required", "该 SSO 提供商尚未绑定组织,不能自动创建企业成员");
|
||||
}
|
||||
|
||||
function ensureOrganizationMembership(user, organization, provider) {
|
||||
const current = dbGet("SELECT * FROM organization_members WHERE organization_id = ? AND user_id = ?", [organization.id, user.id]);
|
||||
if (current?.status === "active") return current;
|
||||
if (current && current.status !== "active" && !provider.auto_provision) throw oidcError(403, "sso_membership_inactive", "当前用户在目标组织中已被停用");
|
||||
if (!current && !provider.auto_provision) throw oidcError(403, "sso_membership_required", "当前企业账号尚未加入目标组织");
|
||||
const timestamp = nowIso();
|
||||
const roleKey = provider.default_role_key || "org_member";
|
||||
dbRun("INSERT INTO organization_members(id, organization_id, user_id, role_key, status, joined_at, created_at, updated_at) VALUES (?, ?, ?, ?, 'active', ?, ?, ?) ON CONFLICT(organization_id, user_id) DO UPDATE SET role_key = excluded.role_key, status = 'active', joined_at = excluded.joined_at, updated_at = excluded.updated_at", [`om-sso-${organization.id}-${user.id}`, organization.id, user.id, roleKey, timestamp, timestamp, timestamp]);
|
||||
return dbGet("SELECT * FROM organization_members WHERE organization_id = ? AND user_id = ?", [organization.id, user.id]);
|
||||
}
|
||||
|
||||
function ensureWorkspaceMembership(user, organization, provider) {
|
||||
const workspace = provider.workspace_id
|
||||
? dbGet("SELECT * FROM workspaces WHERE id = ? AND organization_id = ? AND status = 'active'", [provider.workspace_id, organization.id])
|
||||
: dbGet("SELECT * FROM workspaces WHERE organization_id = ? AND status = 'active' ORDER BY created_at ASC LIMIT 1", [organization.id]);
|
||||
if (!workspace) throw oidcError(403, "sso_workspace_required", "SSO 提供商没有可用的默认工作区");
|
||||
const current = dbGet("SELECT * FROM workspace_members WHERE workspace_id = ? AND user_id = ?", [workspace.id, user.id]);
|
||||
if (current?.status === "active") return { workspace, membership: current };
|
||||
if (current && current.status !== "active" && !provider.auto_provision) throw oidcError(403, "sso_workspace_membership_inactive", "当前用户在默认工作区中已被停用");
|
||||
if (!current && !provider.auto_provision) throw oidcError(403, "sso_workspace_membership_required", "当前企业账号尚未加入默认工作区");
|
||||
const timestamp = nowIso();
|
||||
const roleKey = provider.default_workspace_role_key || "writer";
|
||||
dbRun("INSERT INTO workspace_members(id, workspace_id, user_id, role_key, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'active', ?, ?) ON CONFLICT(workspace_id, user_id) DO UPDATE SET role_key = excluded.role_key, status = 'active', updated_at = excluded.updated_at", [`wm-sso-${workspace.id}-${user.id}`, workspace.id, user.id, roleKey, timestamp, timestamp]);
|
||||
return { workspace, membership: dbGet("SELECT * FROM workspace_members WHERE workspace_id = ? AND user_id = ?", [workspace.id, user.id]) };
|
||||
}
|
||||
|
||||
export function resolveOidcUser(provider, claims) {
|
||||
const mapping = parseJson(provider.claim_mapping_json, { email: "email", displayName: "name", externalId: "sub" });
|
||||
const subject = String(claimValue(claims, mapping.externalId || "sub") || claims.sub || "").trim();
|
||||
const email = String(claimValue(claims, mapping.email || "email") || claims.preferred_username || "").trim().toLowerCase();
|
||||
const displayName = String(claimValue(claims, mapping.displayName || "name") || claims.preferred_username || email || subject).trim();
|
||||
if (!subject) throw oidcError(401, "sso_subject_missing", "企业身份没有返回可用的 subject");
|
||||
if (!email || !email.includes("@")) throw oidcError(401, "sso_email_missing", "企业身份没有返回可用邮箱,无法完成平台账号绑定");
|
||||
const existingIdentity = dbGet("SELECT ei.*, u.* FROM external_identities ei JOIN users u ON u.id = ei.user_id WHERE ei.provider_id = ? AND ei.subject = ?", [provider.id, subject]);
|
||||
let user = existingIdentity ? dbGet("SELECT * FROM users WHERE id = ?", [existingIdentity.user_id]) : dbGet("SELECT * FROM users WHERE lower(email) = ?", [email]);
|
||||
const organization = organizationFor(provider, user);
|
||||
if (existingIdentity && existingIdentity.email_at_login && existingIdentity.email_at_login !== email && user?.email !== email) throw oidcError(403, "sso_identity_mismatch", "企业身份 subject 与邮箱绑定不一致,需要管理员处理");
|
||||
if (!user) {
|
||||
if (!provider.auto_provision) throw oidcError(403, "sso_auto_provision_disabled", "该企业账号尚未注册,且当前 SSO 提供商关闭了自动入组");
|
||||
const timestamp = nowIso();
|
||||
const id = `u-sso-${Date.now()}-${randomBytes(5).toString("hex")}`;
|
||||
dbRun("INSERT INTO users(id, display_name, email, avatar_color, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'active', ?, ?)", [id, displayName.slice(0, 120) || email, email, avatarColorFor(email), timestamp, timestamp]);
|
||||
user = dbGet("SELECT * FROM users WHERE id = ?", [id]);
|
||||
}
|
||||
if (!user || user.status !== "active") throw oidcError(403, "user_not_active", "当前企业账号已停用");
|
||||
ensureOrganizationMembership(user, organization, provider);
|
||||
ensureWorkspaceMembership(user, organization, provider);
|
||||
const timestamp = nowIso();
|
||||
if (!existingIdentity) {
|
||||
const identityId = `ext-${Date.now()}-${randomBytes(5).toString("hex")}`;
|
||||
try {
|
||||
dbRun("INSERT INTO external_identities(id, provider_id, user_id, subject, issuer, email_at_login, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [identityId, provider.id, user.id, subject, normalizeIssuer(claims.iss || provider.issuer_url), email, timestamp, timestamp]);
|
||||
} catch (error) {
|
||||
const conflict = dbGet("SELECT user_id FROM external_identities WHERE provider_id = ? AND subject = ?", [provider.id, subject]);
|
||||
if (!conflict || conflict.user_id !== user.id) throw oidcError(409, "sso_identity_already_bound", "该企业身份已经绑定其他平台用户");
|
||||
}
|
||||
} else {
|
||||
dbRun("UPDATE external_identities SET email_at_login = ?, issuer = ?, updated_at = ? WHERE id = ?", [email, normalizeIssuer(claims.iss || provider.issuer_url), timestamp, existingIdentity.id]);
|
||||
}
|
||||
return { user: dbGet("SELECT * FROM users WHERE id = ?", [user.id]), organization, subject, email };
|
||||
}
|
||||
|
||||
export async function handleOidcCallback({ code, state }) {
|
||||
const stateRow = consumeLoginState(state);
|
||||
const provider = providerRow(stateRow.provider_id);
|
||||
const exchanged = await exchangeCode(provider, stateRow, code);
|
||||
return { ...resolveOidcUser(provider, exchanged.claims), selection: parseJson(stateRow.selection_json, {}), returnTo: stateRow.return_to, provider };
|
||||
}
|
||||
|
||||
export function createSsoTicket(userId, selection = {}, metadata = {}) {
|
||||
cleanupExpired();
|
||||
const ticket = base64url(randomBytes(32));
|
||||
const timestamp = nowIso();
|
||||
const expiresAt = new Date(Date.now() + TICKET_TTL_MS).toISOString();
|
||||
const id = `sso-ticket-${Date.now()}-${randomBytes(4).toString("hex")}`;
|
||||
dbRun("INSERT INTO auth_sso_tickets(id, ticket_hash, user_id, selection_json, ip_address, user_agent, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)", [id, hashValue(ticket), userId, JSON.stringify(selection || {}), String(metadata.ipAddress || ""), String(metadata.userAgent || ""), expiresAt, timestamp]);
|
||||
return { ticket, expiresAt };
|
||||
}
|
||||
|
||||
export function redeemSsoTicket(ticket, metadata = {}) {
|
||||
cleanupExpired();
|
||||
const timestamp = nowIso();
|
||||
const result = dbRun("UPDATE auth_sso_tickets SET consumed_at = ? WHERE ticket_hash = ? AND consumed_at IS NULL AND expires_at > ?", [timestamp, hashValue(ticket), timestamp]);
|
||||
if (!Number(result.changes || 0)) throw oidcError(401, "sso_ticket_invalid", "SSO 登录票据无效、已使用或已过期");
|
||||
const row = dbGet("SELECT * FROM auth_sso_tickets WHERE ticket_hash = ?", [hashValue(ticket)]);
|
||||
if (!row) throw oidcError(401, "sso_ticket_invalid", "SSO 登录票据不存在");
|
||||
const user = dbGet("SELECT * FROM users WHERE id = ?", [row.user_id]);
|
||||
if (!user || user.status !== "active") throw oidcError(403, "user_not_active", "当前企业账号已停用");
|
||||
const selection = parseJson(row.selection_json, {});
|
||||
if (mfaStatus(user.id).enabled) return { mfaRequired: true, challenge: createMfaChallenge(user.id), user: safeUser(user), selection };
|
||||
if (mfaRequiredForUser(user.id)) return { mfaRequired: true, mfaEnrollmentRequired: true, enrollment: createMfaEnrollmentChallenge(user.id), user: safeUser(user), selection };
|
||||
return { session: createSession(user.id, metadata), user: safeUser(user), selection };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
const WINDOW_MS = 60 * 1000;
|
||||
const buckets = new Map();
|
||||
|
||||
function prune(now) {
|
||||
if (buckets.size < 2048) return;
|
||||
for (const [key, bucket] of buckets) {
|
||||
if (bucket.resetAt <= now) buckets.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export function rateLimitIdentity({ token = "", ipAddress = "" } = {}) {
|
||||
const normalizedToken = String(token || "").trim();
|
||||
if (normalizedToken) return `token:${createHash("sha256").update(normalizedToken).digest("hex")}`;
|
||||
return `ip:${String(ipAddress || "unknown").trim() || "unknown"}`;
|
||||
}
|
||||
|
||||
export function consumeRateLimit({ key, limit, now = Date.now() } = {}) {
|
||||
const normalizedLimit = Math.max(0, Math.floor(Number(limit || 0)));
|
||||
if (!normalizedLimit) return null;
|
||||
const windowStart = Math.floor(now / WINDOW_MS) * WINDOW_MS;
|
||||
const bucketKey = `${String(key || "unknown")}:${windowStart}`;
|
||||
const resetAt = windowStart + WINDOW_MS;
|
||||
const current = buckets.get(bucketKey) || { count: 0, resetAt };
|
||||
current.count += 1;
|
||||
buckets.set(bucketKey, current);
|
||||
prune(now);
|
||||
return {
|
||||
allowed: current.count <= normalizedLimit,
|
||||
count: current.count,
|
||||
limit: normalizedLimit,
|
||||
remaining: Math.max(0, normalizedLimit - current.count),
|
||||
resetAt,
|
||||
retryAfter: Math.max(1, Math.ceil((resetAt - now) / 1000))
|
||||
};
|
||||
}
|
||||
|
||||
export function rateLimitHeaders(result) {
|
||||
if (!result) return {};
|
||||
return {
|
||||
"x-ratelimit-limit": String(result.limit),
|
||||
"x-ratelimit-remaining": String(result.remaining),
|
||||
"x-ratelimit-reset": String(Math.ceil(result.resetAt / 1000)),
|
||||
...(result.allowed ? {} : { "retry-after": String(result.retryAfter) })
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { stat } from "node:fs/promises";
|
||||
import { dbPath } from "./db.mjs";
|
||||
import { backupSummary } from "./backup.mjs";
|
||||
|
||||
function envValue(name) {
|
||||
return String(process.env[name] || "").trim();
|
||||
}
|
||||
|
||||
function configuredCheck(key, label, envName, detail) {
|
||||
const value = envValue(envName);
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
status: value ? "configured" : "not-configured",
|
||||
severity: value ? "info" : "warning",
|
||||
blocking: false,
|
||||
detail: value ? `${detail}:已读取 ${envName}` : `${detail}:未设置 ${envName}`,
|
||||
envName
|
||||
};
|
||||
}
|
||||
|
||||
async function databaseCheck() {
|
||||
try {
|
||||
const file = await stat(dbPath);
|
||||
return {
|
||||
key: "business-database",
|
||||
label: "业务数据库",
|
||||
status: "active-local",
|
||||
severity: "warning",
|
||||
blocking: false,
|
||||
provider: "Node 24 node:sqlite",
|
||||
detail: "当前业务真源是本地 SQLite;尚未切换 PostgreSQL 高可用运行时。",
|
||||
path: dbPath,
|
||||
bytes: Number(file.size || 0),
|
||||
modifiedAt: file.mtime?.toISOString?.() || null
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
key: "business-database",
|
||||
label: "业务数据库",
|
||||
status: "failed",
|
||||
severity: "critical",
|
||||
blocking: true,
|
||||
provider: "Node 24 node:sqlite",
|
||||
detail: `数据库文件不可读:${error.message}`,
|
||||
path: dbPath
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function systemReadiness() {
|
||||
const backups = await backupSummary();
|
||||
const allowDevContext = process.env.AI_DRAMA_ALLOW_DEV_CONTEXT === "1";
|
||||
const sessionSecret = envValue("AI_DRAMA_SESSION_SECRET");
|
||||
const mfaKey = envValue("AI_DRAMA_MFA_ENCRYPTION_KEY");
|
||||
const oidcKey = envValue("AI_DRAMA_OIDC_STORAGE_KEY");
|
||||
const checks = [
|
||||
await databaseCheck(),
|
||||
configuredCheck("postgres-target", "PostgreSQL 目标", "PLATFORM_POSTGRES_URL", "目标数据库连接"),
|
||||
configuredCheck("redis-target", "Redis 目标", "PLATFORM_REDIS_URL", "目标队列/分布式锁连接"),
|
||||
configuredCheck("object-storage-target", "对象存储目标", "PLATFORM_OBJECT_STORAGE_ENDPOINT", "S3-compatible 存储端点"),
|
||||
{
|
||||
key: "security-secrets",
|
||||
label: "生产密钥",
|
||||
status: sessionSecret.length >= 32 && mfaKey.length >= 16 && oidcKey.length >= 16 ? "ready" : "needs-config",
|
||||
severity: sessionSecret.length >= 32 && mfaKey.length >= 16 && oidcKey.length >= 16 ? "info" : "critical",
|
||||
blocking: sessionSecret.length < 32 || mfaKey.length < 16 || oidcKey.length < 16,
|
||||
detail: "Session、MFA 和 OIDC 存储密钥必须通过环境变量注入,不写入数据库。",
|
||||
configured: { sessionSecret: sessionSecret.length >= 32, mfaKey: mfaKey.length >= 16, oidcKey: oidcKey.length >= 16 }
|
||||
},
|
||||
{
|
||||
key: "dev-context",
|
||||
label: "开发上下文旁路",
|
||||
status: allowDevContext ? "unsafe" : "ready",
|
||||
severity: allowDevContext ? "critical" : "info",
|
||||
blocking: allowDevContext,
|
||||
detail: allowDevContext ? "AI_DRAMA_ALLOW_DEV_CONTEXT=1,生产部署禁止启用。" : "请求头上下文旁路已关闭,使用真实 session/API client。"
|
||||
},
|
||||
{
|
||||
key: "database-backup",
|
||||
label: "数据库快照",
|
||||
status: backups.count ? "ready" : "missing",
|
||||
severity: backups.count ? "info" : "warning",
|
||||
blocking: false,
|
||||
detail: backups.count ? `已有 ${backups.count} 个本地快照,最近一次 ${backups.latest?.modifiedAt || "未知"}。` : "还没有本地 SQLite 快照;上线前应先创建并验证备份。"
|
||||
}
|
||||
];
|
||||
const blocking = checks.filter((check) => check.blocking && !["ready", "configured"].includes(check.status)).length;
|
||||
const attention = checks.filter((check) => check.severity === "warning" || check.severity === "critical").length;
|
||||
return {
|
||||
profile: process.env.NODE_ENV === "production" ? "production" : "local-development",
|
||||
activeRuntime: { database: "node:sqlite", queue: "database-lease-worker", objectStorage: "local-filesystem" },
|
||||
summary: { status: blocking ? "blocked" : attention ? "attention" : "ready", blocking, attention, ready: checks.length - attention },
|
||||
checks,
|
||||
backups,
|
||||
checkedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { SAML } from "@node-saml/node-saml";
|
||||
import { dbGet, dbRun } from "./db.mjs";
|
||||
import { resolveOidcUser } from "./oidc.mjs";
|
||||
|
||||
const LOGIN_STATE_TTL_MS = 10 * 60 * 1000;
|
||||
const CLOCK_SKEW_MS = 5000;
|
||||
const DEFAULT_NAME_ID_FORMAT = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress";
|
||||
|
||||
function samlError(status, code, message, details = {}) {
|
||||
const error = new Error(message);
|
||||
error.status = status;
|
||||
error.code = code;
|
||||
error.details = details;
|
||||
return error;
|
||||
}
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function hashValue(value) {
|
||||
return createHash("sha256").update(String(value || "")).digest("hex");
|
||||
}
|
||||
|
||||
function base64url(buffer) {
|
||||
return Buffer.from(buffer).toString("base64url");
|
||||
}
|
||||
|
||||
function parseJson(value, fallback) {
|
||||
try { return JSON.parse(value); } catch { return fallback; }
|
||||
}
|
||||
|
||||
function ensureSsoEnabled() {
|
||||
const policy = dbGet("SELECT sso_enabled FROM identity_policies WHERE id = 'default'");
|
||||
if (!policy?.sso_enabled) throw samlError(403, "sso_disabled", "平台当前未启用企业 SSO");
|
||||
}
|
||||
|
||||
function providerRow(providerId, { requireSso = true, allowDisabled = false } = {}) {
|
||||
if (requireSso) ensureSsoEnabled();
|
||||
const provider = dbGet(`SELECT * FROM identity_providers WHERE id = ?${allowDisabled ? "" : " AND enabled = 1"}`, [providerId]);
|
||||
if (!provider) throw samlError(404, "sso_provider_not_found", "企业身份提供商不存在或未启用");
|
||||
if (provider.kind !== "saml") throw samlError(400, "sso_provider_kind_unsupported", "当前登录链路不是 SAML 提供商");
|
||||
if (requireSso && (!provider.entry_point || !provider.idp_cert_ref || !provider.sp_issuer)) {
|
||||
throw samlError(400, "saml_provider_incomplete", "SAML 提供商缺少 IdP Entry Point、证书环境变量名或 SP Issuer");
|
||||
}
|
||||
if (!["never", "ifPresent", "always"].includes(provider.validate_in_response_to || "ifPresent")) {
|
||||
throw samlError(400, "saml_validate_in_response_to_invalid", "SAML InResponseTo 校验策略无效");
|
||||
}
|
||||
if (!allowDisabled && !["configured", "ready"].includes(provider.status)) {
|
||||
throw samlError(400, "saml_provider_not_ready", "SAML 提供商尚未完成探测或已停用");
|
||||
}
|
||||
if (allowDisabled && !["disabled", "configured", "ready"].includes(provider.status)) {
|
||||
throw samlError(400, "saml_provider_not_ready", "SAML 提供商尚未完成探测或已停用");
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
function envCertificate(ref) {
|
||||
const key = String(ref || "").trim();
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
||||
throw samlError(400, "saml_certificate_ref_invalid", "SAML IdP 证书环境变量名无效");
|
||||
}
|
||||
const value = String(process.env[key] || "").trim().replace(/\\n/g, "\n");
|
||||
if (!value) throw samlError(503, "saml_certificate_missing", `服务端环境变量 ${key} 未配置 IdP 证书`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeValidateInResponseTo(value) {
|
||||
return ["never", "ifPresent", "always"].includes(value) ? value : "ifPresent";
|
||||
}
|
||||
|
||||
function providerOptions(provider, { callbackUrl, cacheProvider, requireIdpCert = true }) {
|
||||
const issuer = String(provider.sp_issuer || process.env.AI_DRAMA_SAML_SP_ISSUER || `${process.env.AI_DRAMA_API_ORIGIN || "http://127.0.0.1:8787"}/api/auth/sso/saml/metadata`).trim();
|
||||
return {
|
||||
entryPoint: provider.entry_point,
|
||||
idpCert: requireIdpCert ? envCertificate(provider.idp_cert_ref) : "AA==",
|
||||
issuer,
|
||||
callbackUrl,
|
||||
audience: provider.audience || issuer,
|
||||
idpIssuer: provider.issuer_url || undefined,
|
||||
identifierFormat: provider.saml_name_id_format || DEFAULT_NAME_ID_FORMAT,
|
||||
wantAssertionsSigned: provider.want_assertions_signed !== 0,
|
||||
wantAuthnResponseSigned: provider.want_authn_response_signed !== 0,
|
||||
validateInResponseTo: normalizeValidateInResponseTo(provider.validate_in_response_to),
|
||||
acceptedClockSkewMs: CLOCK_SKEW_MS,
|
||||
requestIdExpirationPeriodMs: LOGIN_STATE_TTL_MS,
|
||||
cacheProvider,
|
||||
disableRequestedAuthnContext: true,
|
||||
signatureAlgorithm: "sha256",
|
||||
providerName: "AI 短剧生产平台"
|
||||
};
|
||||
}
|
||||
|
||||
class DatabaseSamlCacheProvider {
|
||||
constructor(relayStateHash) {
|
||||
this.relayStateHash = relayStateHash;
|
||||
}
|
||||
|
||||
async saveAsync(key, value) {
|
||||
const result = dbRun(
|
||||
"UPDATE saml_login_states SET request_id = ?, request_issue_instant = ? WHERE relay_state_hash = ? AND consumed_at IS NULL AND expires_at > ?",
|
||||
[String(key), String(value), this.relayStateHash, nowIso()]
|
||||
);
|
||||
if (!Number(result.changes || 0)) throw samlError(401, "saml_state_invalid", "SAML 登录状态不存在或已过期");
|
||||
return { value: String(value), createdAt: Date.now() };
|
||||
}
|
||||
|
||||
async getAsync(key) {
|
||||
const row = dbGet(
|
||||
"SELECT request_issue_instant FROM saml_login_states WHERE relay_state_hash = ? AND request_id = ? AND consumed_at IS NULL AND expires_at > ?",
|
||||
[this.relayStateHash, String(key), nowIso()]
|
||||
);
|
||||
return row?.request_issue_instant || null;
|
||||
}
|
||||
|
||||
// node-saml calls removeAsync during assertion processing. Final consumption is
|
||||
// handled atomically after the whole signed response has passed validation.
|
||||
async removeAsync(key) {
|
||||
const row = dbGet("SELECT request_issue_instant FROM saml_login_states WHERE relay_state_hash = ? AND request_id = ?", [this.relayStateHash, String(key)]);
|
||||
return row?.request_issue_instant || null;
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupExpiredSamlStates() {
|
||||
const timestamp = nowIso();
|
||||
dbRun("DELETE FROM saml_login_states WHERE expires_at <= ? OR consumed_at IS NOT NULL", [timestamp]);
|
||||
}
|
||||
|
||||
function profileToClaims(profile, provider) {
|
||||
const claims = { ...(profile?.attributes || {}), ...(profile || {}) };
|
||||
const first = (...values) => values.flatMap((value) => Array.isArray(value) ? value : [value]).find((value) => typeof value === "string" && value.trim()) || "";
|
||||
const nameId = first(profile?.nameID, profile?.nameId, profile?.uid, profile?.subject);
|
||||
const email = first(profile?.email, profile?.mail, profile?.userPrincipalName, profile?.preferred_username, claims.email, claims.mail, claims["urn:oid:0.9.2342.19200300.100.1.3"]);
|
||||
const displayName = first(profile?.displayName, profile?.name, profile?.cn, profile?.givenName, claims.displayName, claims.name, email, nameId);
|
||||
claims.sub = first(claims.sub, nameId);
|
||||
claims.email = first(claims.email, email);
|
||||
claims.name = first(claims.name, displayName);
|
||||
claims.iss = first(claims.iss, profile?.issuer, provider.issuer_url);
|
||||
return claims;
|
||||
}
|
||||
|
||||
export function isSamlProvider(providerId) {
|
||||
return Boolean(dbGet("SELECT id FROM identity_providers WHERE id = ? AND kind = 'saml'", [providerId]));
|
||||
}
|
||||
|
||||
export async function startSamlLogin(providerId, { callbackUrl, apiOrigin, returnTo = "/", selection = {}, ipAddress = "", userAgent = "", host = "" } = {}) {
|
||||
cleanupExpiredSamlStates();
|
||||
const provider = providerRow(providerId);
|
||||
const relayState = base64url(randomBytes(32));
|
||||
const timestamp = nowIso();
|
||||
const expiresAt = new Date(Date.now() + LOGIN_STATE_TTL_MS).toISOString();
|
||||
const id = `saml-state-${Date.now()}-${randomBytes(4).toString("hex")}`;
|
||||
dbRun(
|
||||
"INSERT INTO saml_login_states(id, relay_state_hash, provider_id, return_to, selection_json, ip_address, user_agent, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
[id, hashValue(relayState), provider.id, String(returnTo || "/"), JSON.stringify(selection || {}), String(ipAddress || ""), String(userAgent || ""), expiresAt, timestamp]
|
||||
);
|
||||
try {
|
||||
const cacheProvider = new DatabaseSamlCacheProvider(hashValue(relayState));
|
||||
const saml = new SAML(providerOptions(provider, { callbackUrl, cacheProvider }));
|
||||
const authorizationUrl = await saml.getAuthorizeUrlAsync(relayState, host || apiOrigin, {});
|
||||
return { authorizationUrl, relayState, expiresAt, provider };
|
||||
} catch (error) {
|
||||
dbRun("DELETE FROM saml_login_states WHERE relay_state_hash = ?", [hashValue(relayState)]);
|
||||
if (error.status) throw error;
|
||||
throw samlError(502, "saml_request_failed", `SAML 登录请求生成失败:${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleSamlCallback({ samlResponse, relayState, callbackUrl } = {}) {
|
||||
cleanupExpiredSamlStates();
|
||||
if (!samlResponse || !relayState) throw samlError(401, "saml_response_state_required", "SAML ACS 必须包含断言和 RelayState");
|
||||
const stateRow = dbGet(
|
||||
"SELECT * FROM saml_login_states WHERE relay_state_hash = ? AND consumed_at IS NULL AND expires_at > ?",
|
||||
[hashValue(relayState), nowIso()]
|
||||
);
|
||||
if (!stateRow) throw samlError(401, "saml_state_invalid", "SAML 登录状态无效、已使用或已过期");
|
||||
const provider = providerRow(stateRow.provider_id);
|
||||
const cacheProvider = new DatabaseSamlCacheProvider(hashValue(relayState));
|
||||
const saml = new SAML(providerOptions(provider, { callbackUrl, cacheProvider }));
|
||||
let result;
|
||||
try {
|
||||
result = await saml.validatePostResponseAsync({ SAMLResponse: String(samlResponse) });
|
||||
} catch (error) {
|
||||
throw samlError(401, "saml_response_invalid", `SAML 断言校验失败:${error.message}`);
|
||||
}
|
||||
if (result.loggedOut || !result.profile) throw samlError(401, "saml_profile_missing", "SAML 响应没有可用的登录身份");
|
||||
const consumed = dbRun("UPDATE saml_login_states SET consumed_at = ? WHERE id = ? AND consumed_at IS NULL AND expires_at > ?", [nowIso(), stateRow.id, nowIso()]);
|
||||
if (!Number(consumed.changes || 0)) throw samlError(401, "saml_state_replayed", "SAML 登录状态已被使用");
|
||||
const claims = profileToClaims(result.profile, provider);
|
||||
return {
|
||||
...resolveOidcUser(provider, claims),
|
||||
selection: parseJson(stateRow.selection_json, {}),
|
||||
returnTo: stateRow.return_to,
|
||||
provider,
|
||||
profile: result.profile
|
||||
};
|
||||
}
|
||||
|
||||
export function samlServiceProviderMetadata(providerId, { callbackUrl } = {}) {
|
||||
const provider = providerRow(providerId, { requireSso: false, allowDisabled: true });
|
||||
const cacheProvider = new DatabaseSamlCacheProvider("metadata");
|
||||
const saml = new SAML(providerOptions(provider, { callbackUrl, cacheProvider, requireIdpCert: false }));
|
||||
return saml.generateServiceProviderMetadata(null, null);
|
||||
}
|
||||
+1197
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,105 @@
|
||||
import { lstat, readdir } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { dbAll, dbGet, dbRun } from "./db.mjs";
|
||||
import { httpError } from "./tenant.mjs";
|
||||
|
||||
const projectRoot = resolve(import.meta.dirname, "..");
|
||||
const storageRoot = resolve(projectRoot, "storage");
|
||||
const GB = 1024 ** 3;
|
||||
|
||||
async function walk(directory, entries = []) {
|
||||
let children;
|
||||
try {
|
||||
children = await readdir(directory, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return entries;
|
||||
throw error;
|
||||
}
|
||||
for (const child of children) {
|
||||
const path = resolve(directory, child.name);
|
||||
if (child.isDirectory()) {
|
||||
await walk(path, entries);
|
||||
continue;
|
||||
}
|
||||
if (!child.isFile()) continue;
|
||||
const info = await lstat(path);
|
||||
entries.push({ path, bytes: info.size, updatedAt: info.mtime.toISOString() });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function quotaRow(context) {
|
||||
return dbGet(
|
||||
`SELECT * FROM quota_allocations
|
||||
WHERE organization_id = ? AND metric = 'storage'
|
||||
AND (workspace_id = ? OR workspace_id IS NULL)
|
||||
AND julianday(period_start) <= julianday('now')
|
||||
AND julianday(period_end) >= julianday('now')
|
||||
ORDER BY CASE WHEN workspace_id = ? THEN 0 ELSE 1 END
|
||||
LIMIT 1`,
|
||||
[context.organization.id, context.workspace.id, context.workspace.id]
|
||||
);
|
||||
}
|
||||
|
||||
function scopedProjectIds(context) {
|
||||
if (context.project?.id) return [context.project.id];
|
||||
return (context.projects || []).map((project) => project.id);
|
||||
}
|
||||
|
||||
async function projectUsage(projectId) {
|
||||
const roots = [resolve(storageRoot, "assets", projectId)];
|
||||
const jobs = dbAll("SELECT id FROM generation_jobs WHERE project_id = ?", [projectId]);
|
||||
roots.push(...jobs.map((job) => resolve(storageRoot, "jobs", job.id)));
|
||||
const compositions = dbAll("SELECT id FROM media_compositions WHERE project_id = ?", [projectId]);
|
||||
roots.push(...compositions.map((composition) => resolve(storageRoot, "compositions", composition.id)));
|
||||
const entries = [];
|
||||
for (const root of roots) await walk(root, entries);
|
||||
const files = entries.map((entry) => ({ ...entry, projectId, relativePath: entry.path.replace(`${projectRoot}/`, "") }));
|
||||
return { projectId, bytes: files.reduce((sum, file) => sum + file.bytes, 0), files };
|
||||
}
|
||||
|
||||
export async function storageSummary(context) {
|
||||
const projects = [];
|
||||
for (const projectId of scopedProjectIds(context)) projects.push(await projectUsage(projectId));
|
||||
const usedBytes = projects.reduce((sum, project) => sum + project.bytes, 0);
|
||||
const quota = quotaRow(context);
|
||||
const billingLimitGb = Number(dbGet("SELECT storage_gb FROM billing_accounts WHERE organization_id = ?", [context.organization.id])?.storage_gb || 0);
|
||||
const quotaLimitGb = Number(quota?.limit_value || 0);
|
||||
const limitGb = billingLimitGb && quotaLimitGb ? Math.min(billingLimitGb, quotaLimitGb) : billingLimitGb || quotaLimitGb;
|
||||
const limitBytes = limitGb * GB;
|
||||
const timestamp = new Date().toISOString();
|
||||
if (quota) dbRun("UPDATE quota_allocations SET used_value = ?, updated_at = ? WHERE id = ?", [usedBytes / GB, timestamp, quota.id]);
|
||||
const files = projects.flatMap((project) => project.files).sort((a, b) => b.bytes - a.bytes);
|
||||
return {
|
||||
usedBytes,
|
||||
usedGb: Number((usedBytes / GB).toFixed(4)),
|
||||
limitBytes,
|
||||
limitGb,
|
||||
remainingBytes: Math.max(0, limitBytes - usedBytes),
|
||||
percent: limitBytes ? Math.min(100, Number(((usedBytes / limitBytes) * 100).toFixed(2))) : 0,
|
||||
projects: projects.map(({ projectId, bytes }) => ({ projectId, bytes, usedGb: Number((bytes / GB).toFixed(4)) })),
|
||||
largestFiles: files.slice(0, 12)
|
||||
};
|
||||
}
|
||||
|
||||
export async function requireStorageQuota(context, bytes) {
|
||||
const requestedBytes = Math.max(0, Number(bytes || 0));
|
||||
const summary = await storageSummary(context);
|
||||
if (summary.limitBytes && summary.usedBytes + requestedBytes > summary.limitBytes) {
|
||||
throw httpError(429, "storage_quota_exceeded", "当前组织存储空间不足", {
|
||||
usedBytes: summary.usedBytes,
|
||||
limitBytes: summary.limitBytes,
|
||||
requestedBytes,
|
||||
remainingBytes: summary.remainingBytes
|
||||
});
|
||||
}
|
||||
return { ...summary, remainingBytes: Math.max(0, summary.remainingBytes - requestedBytes) };
|
||||
}
|
||||
|
||||
export function bytesToHuman(bytes) {
|
||||
const value = Number(bytes || 0);
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KB`;
|
||||
if (value < GB) return `${(value / 1024 ** 2).toFixed(1)} MB`;
|
||||
return `${(value / GB).toFixed(2)} GB`;
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
import { dbAll, dbGet, dbRun } from "./db.mjs";
|
||||
import { dispatchNotificationEvent } from "./notifications.mjs";
|
||||
import { addAudit, hasPermission, requirePermission, requireProjectWritable } from "./tenant.mjs";
|
||||
|
||||
const TASK_STATUSES = new Set(["open", "in_progress", "blocked", "done", "cancelled"]);
|
||||
const TASK_PRIORITIES = new Set(["high", "medium", "low"]);
|
||||
const TARGET_TABS = new Set(["creator-home", "tasks", "script", "casting", "director", "jobs", "qa", "export", "assistant"]);
|
||||
const TASK_LINK_TYPES = new Set(["shot", "asset", "job", "review", "artifact", "delivery", "file"]);
|
||||
|
||||
const makeId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
|
||||
function parseJson(value, fallback) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function taskError(status, code, message, details = {}) {
|
||||
return Object.assign(new Error(message), { status, code, details });
|
||||
}
|
||||
|
||||
function taskScope(context) {
|
||||
if (!context?.organization?.id || !context?.workspace?.id || !context?.project?.id || !context?.user?.id) {
|
||||
throw Object.assign(new Error("协作任务必须绑定当前组织、工作区和项目"), { status: 400, code: "task_scope_required" });
|
||||
}
|
||||
return {
|
||||
organizationId: context.organization.id,
|
||||
workspaceId: context.workspace.id,
|
||||
projectId: context.project.id
|
||||
};
|
||||
}
|
||||
|
||||
function parseDueAt(value) {
|
||||
if (value === undefined || value === null || value === "") return null;
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
throw Object.assign(new Error("截止时间不是有效日期"), { status: 400, code: "task_due_at_invalid" });
|
||||
}
|
||||
return parsed.toISOString();
|
||||
}
|
||||
|
||||
function validateTaskFields(body = {}) {
|
||||
const title = String(body.title || "").trim();
|
||||
if (!title) throw Object.assign(new Error("任务标题不能为空"), { status: 400, code: "task_title_required" });
|
||||
if (title.length > 180) throw Object.assign(new Error("任务标题不能超过 180 个字符"), { status: 400, code: "task_title_too_long" });
|
||||
const description = String(body.description || "").trim();
|
||||
if (description.length > 4000) throw Object.assign(new Error("任务说明不能超过 4000 个字符"), { status: 400, code: "task_description_too_long" });
|
||||
const kind = String(body.kind || "production").trim().slice(0, 60) || "production";
|
||||
const priority = String(body.priority || "medium").trim();
|
||||
if (!TASK_PRIORITIES.has(priority)) throw Object.assign(new Error("任务优先级无效"), { status: 400, code: "task_priority_invalid" });
|
||||
const targetTab = String(body.targetTab || "creator-home").trim();
|
||||
if (!TARGET_TABS.has(targetTab)) throw Object.assign(new Error("任务关联页面无效"), { status: 400, code: "task_target_invalid" });
|
||||
return { title, description, kind, priority, targetTab, targetId: String(body.targetId || "").trim().slice(0, 160), dueAt: parseDueAt(body.dueAt) };
|
||||
}
|
||||
|
||||
function projectMember(context, userId) {
|
||||
if (!userId) return null;
|
||||
return dbGet(
|
||||
`SELECT u.id, u.display_name, u.email
|
||||
FROM users u
|
||||
JOIN organization_members om ON om.user_id = u.id AND om.organization_id = ? AND om.status = 'active'
|
||||
JOIN workspace_members wm ON wm.user_id = u.id AND wm.workspace_id = ? AND wm.status = 'active'
|
||||
LEFT JOIN project_members pm ON pm.user_id = u.id AND pm.project_id = ? AND pm.status = 'active'
|
||||
WHERE u.id = ? AND u.status = 'active'
|
||||
AND (wm.id IS NOT NULL OR pm.id IS NOT NULL OR om.role_key IN ('org_owner', 'org_admin'))`,
|
||||
[context.organization.id, context.workspace.id, context.project.id, userId]
|
||||
);
|
||||
}
|
||||
|
||||
function ensureAssignee(context, userId) {
|
||||
if (!userId) return null;
|
||||
const member = projectMember(context, userId);
|
||||
if (!member) throw Object.assign(new Error("负责人不是当前项目的有效成员"), { status: 400, code: "task_assignee_invalid" });
|
||||
return member;
|
||||
}
|
||||
|
||||
function taskPayload(row) {
|
||||
const now = Date.now();
|
||||
const dueAt = row.due_at || null;
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
kind: row.kind,
|
||||
status: row.status,
|
||||
priority: row.priority,
|
||||
dueAt,
|
||||
overdue: Boolean(dueAt && !["done", "cancelled"].includes(row.status) && new Date(dueAt).getTime() < now),
|
||||
completedAt: row.completed_at,
|
||||
targetTab: row.target_tab,
|
||||
targetId: row.target_id || "",
|
||||
assignee: row.assignee_user_id ? { id: row.assignee_user_id, displayName: row.assignee_name || row.assignee_user_id, email: row.assignee_email || "" } : null,
|
||||
createdBy: { id: row.created_by, displayName: row.creator_name || row.created_by },
|
||||
commentCount: Number(row.comment_count || 0),
|
||||
linkCount: Number(row.link_count || 0),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
function getTask(context, taskId) {
|
||||
const scope = taskScope(context);
|
||||
return dbGet(
|
||||
`SELECT t.*, au.display_name AS assignee_name, au.email AS assignee_email, cu.display_name AS creator_name,
|
||||
(SELECT COUNT(*) FROM task_comments tc WHERE tc.task_id = t.id) AS comment_count,
|
||||
(SELECT COUNT(*) FROM task_links tl WHERE tl.task_id = t.id) AS link_count
|
||||
FROM project_tasks t
|
||||
LEFT JOIN users au ON au.id = t.assignee_user_id
|
||||
LEFT JOIN users cu ON cu.id = t.created_by
|
||||
WHERE t.id = ? AND t.organization_id = ? AND t.workspace_id = ? AND t.project_id = ?`,
|
||||
[taskId, scope.organizationId, scope.workspaceId, scope.projectId]
|
||||
);
|
||||
}
|
||||
|
||||
export function listProjectTasks(context, { status = "", assignedTo = "", limit = 100 } = {}) {
|
||||
requirePermission(context, "task:view");
|
||||
const scope = taskScope(context);
|
||||
const clauses = ["t.organization_id = ?", "t.workspace_id = ?", "t.project_id = ?"];
|
||||
const params = [scope.organizationId, scope.workspaceId, scope.projectId];
|
||||
if (status && status !== "all") {
|
||||
if (!TASK_STATUSES.has(status)) throw Object.assign(new Error("任务状态无效"), { status: 400, code: "task_status_invalid" });
|
||||
clauses.push("t.status = ?");
|
||||
params.push(status);
|
||||
}
|
||||
if (assignedTo === "me") {
|
||||
clauses.push("t.assignee_user_id = ?");
|
||||
params.push(context.user.id);
|
||||
} else if (assignedTo) {
|
||||
clauses.push("t.assignee_user_id = ?");
|
||||
params.push(String(assignedTo));
|
||||
}
|
||||
const normalizedLimit = Math.max(1, Math.min(200, Number(limit || 100)));
|
||||
const rows = dbAll(
|
||||
`SELECT t.*, au.display_name AS assignee_name, au.email AS assignee_email, cu.display_name AS creator_name,
|
||||
(SELECT COUNT(*) FROM task_comments tc WHERE tc.task_id = t.id) AS comment_count,
|
||||
(SELECT COUNT(*) FROM task_links tl WHERE tl.task_id = t.id) AS link_count
|
||||
FROM project_tasks t
|
||||
LEFT JOIN users au ON au.id = t.assignee_user_id
|
||||
LEFT JOIN users cu ON cu.id = t.created_by
|
||||
WHERE ${clauses.join(" AND ")}
|
||||
ORDER BY CASE t.status WHEN 'blocked' THEN 0 WHEN 'open' THEN 1 WHEN 'in_progress' THEN 2 WHEN 'done' THEN 3 ELSE 4 END,
|
||||
CASE t.priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END,
|
||||
COALESCE(t.due_at, '9999-12-31T23:59:59.999Z'), t.updated_at DESC
|
||||
LIMIT ?`,
|
||||
[...params, normalizedLimit]
|
||||
);
|
||||
const tasks = rows.map(taskPayload);
|
||||
const byStatus = {};
|
||||
for (const task of tasks) byStatus[task.status] = (byStatus[task.status] || 0) + 1;
|
||||
return {
|
||||
tasks,
|
||||
summary: {
|
||||
total: tasks.length,
|
||||
open: tasks.filter((task) => ["open", "in_progress"].includes(task.status)).length,
|
||||
blocked: tasks.filter((task) => task.status === "blocked").length,
|
||||
done: tasks.filter((task) => task.status === "done").length,
|
||||
overdue: tasks.filter((task) => task.overdue).length,
|
||||
byStatus
|
||||
},
|
||||
scope,
|
||||
generatedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
export function createProjectTask(context, body = {}) {
|
||||
requirePermission(context, "task:manage");
|
||||
const project = requireProjectWritable(context);
|
||||
const fields = validateTaskFields(body);
|
||||
const assigneeUserId = body.assigneeUserId === undefined ? context.user.id : String(body.assigneeUserId || "");
|
||||
const assignee = ensureAssignee(context, assigneeUserId);
|
||||
const status = String(body.status || "open").trim();
|
||||
if (!["open", "in_progress", "blocked"].includes(status)) throw Object.assign(new Error("新任务只能创建为打开、进行中或阻塞"), { status: 400, code: "task_create_status_invalid" });
|
||||
const timestamp = new Date().toISOString();
|
||||
const id = makeId("task");
|
||||
dbRun(
|
||||
`INSERT INTO project_tasks(id, organization_id, workspace_id, project_id, title, description, kind, status, priority, assignee_user_id, target_tab, target_id, due_at, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[id, context.organization.id, context.workspace.id, project.id, fields.title, fields.description, fields.kind, status, fields.priority, assignee?.id || null, fields.targetTab, fields.targetId, fields.dueAt, context.user.id, timestamp, timestamp]
|
||||
);
|
||||
addAudit({ context, action: "project.task.created", targetType: "project_task", targetId: id, metadata: { title: fields.title, assigneeUserId: assignee?.id || null, priority: fields.priority, dueAt: fields.dueAt } });
|
||||
if (assignee?.id) {
|
||||
void dispatchNotificationEvent({ context, eventKey: "task.assigned", payload: { recipientUserId: assignee.id, taskId: id, taskTitle: fields.title, targetId: id, targetTab: "tasks" } });
|
||||
}
|
||||
return { task: taskPayload(getTask(context, id)) };
|
||||
}
|
||||
|
||||
export function updateProjectTask(context, taskId, body = {}) {
|
||||
const current = getTask(context, taskId);
|
||||
if (!current) throw Object.assign(new Error("任务不存在或不属于当前项目"), { status: 404, code: "task_not_found" });
|
||||
const canManage = hasPermission(context, "task:manage");
|
||||
if (canManage) requireProjectWritable(context);
|
||||
else {
|
||||
requirePermission(context, "task:complete");
|
||||
requireProjectWritable(context);
|
||||
if (current.assignee_user_id !== context.user.id) throw Object.assign(new Error("只能更新自己负责的任务"), { status: 403, code: "task_assignee_only" });
|
||||
const disallowed = ["title", "description", "kind", "priority", "assigneeUserId", "targetTab", "targetId", "dueAt"].some((key) => Object.prototype.hasOwnProperty.call(body, key));
|
||||
if (disallowed) throw Object.assign(new Error("当前角色只能更新本人任务状态"), { status: 403, code: "task_update_scope_denied" });
|
||||
}
|
||||
const nextStatus = body.status === undefined ? current.status : String(body.status).trim();
|
||||
if (!TASK_STATUSES.has(nextStatus)) throw Object.assign(new Error("任务状态无效"), { status: 400, code: "task_status_invalid" });
|
||||
const next = canManage ? validateTaskFields({
|
||||
title: body.title === undefined ? current.title : body.title,
|
||||
description: body.description === undefined ? current.description : body.description,
|
||||
kind: body.kind === undefined ? current.kind : body.kind,
|
||||
priority: body.priority === undefined ? current.priority : body.priority,
|
||||
targetTab: body.targetTab === undefined ? current.target_tab : body.targetTab,
|
||||
targetId: body.targetId === undefined ? current.target_id : body.targetId,
|
||||
dueAt: body.dueAt === undefined ? current.due_at : body.dueAt
|
||||
}) : { title: current.title, description: current.description, kind: current.kind, priority: current.priority, targetTab: current.target_tab, targetId: current.target_id, dueAt: current.due_at };
|
||||
let assigneeUserId = current.assignee_user_id;
|
||||
if (canManage && Object.prototype.hasOwnProperty.call(body, "assigneeUserId")) assigneeUserId = body.assigneeUserId ? String(body.assigneeUserId) : null;
|
||||
const assignee = ensureAssignee(context, assigneeUserId);
|
||||
const timestamp = new Date().toISOString();
|
||||
const completedAt = nextStatus === "done" ? (current.completed_at || timestamp) : null;
|
||||
dbRun(
|
||||
`UPDATE project_tasks
|
||||
SET title = ?, description = ?, kind = ?, status = ?, priority = ?, assignee_user_id = ?, target_tab = ?, target_id = ?, due_at = ?, completed_at = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
[next.title, next.description, next.kind, nextStatus, next.priority, assignee?.id || null, next.targetTab, next.targetId, next.dueAt, completedAt, timestamp, taskId]
|
||||
);
|
||||
addAudit({ context, action: "project.task.updated", targetType: "project_task", targetId: taskId, metadata: { previousStatus: current.status, status: nextStatus, assigneeUserId: assignee?.id || null, changedByAssignee: !canManage } });
|
||||
if (assignee?.id && (assignee.id !== context.user.id || current.assignee_user_id !== assignee.id)) {
|
||||
void dispatchNotificationEvent({ context, eventKey: current.assignee_user_id === assignee.id ? "task.updated" : "task.assigned", payload: { recipientUserId: assignee.id, taskId, taskTitle: next.title, status: nextStatus, targetId: taskId, targetTab: "tasks" } });
|
||||
}
|
||||
return { task: taskPayload(getTask(context, taskId)) };
|
||||
}
|
||||
|
||||
function ensureTask(context, taskId) {
|
||||
const task = getTask(context, taskId);
|
||||
if (!task) throw taskError(404, "task_not_found", "任务不存在或不属于当前项目");
|
||||
return task;
|
||||
}
|
||||
|
||||
function commentPayload(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
taskId: row.task_id,
|
||||
parentCommentId: row.parent_comment_id || null,
|
||||
body: row.body,
|
||||
mentions: parseJson(row.mentions_json, []),
|
||||
author: { id: row.author_user_id, displayName: row.author_name || row.author_user_id, email: row.author_email || "" },
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
function linkPayload(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
taskId: row.task_id,
|
||||
type: row.link_type,
|
||||
targetId: row.target_id,
|
||||
label: row.label || row.target_id,
|
||||
metadata: parseJson(row.metadata_json, {}),
|
||||
createdBy: { id: row.created_by, displayName: row.created_by_name || row.created_by },
|
||||
createdAt: row.created_at
|
||||
};
|
||||
}
|
||||
|
||||
function taskComments(context, taskId) {
|
||||
ensureTask(context, taskId);
|
||||
return dbAll(
|
||||
`SELECT c.*, u.display_name AS author_name, u.email AS author_email
|
||||
FROM task_comments c
|
||||
LEFT JOIN users u ON u.id = c.author_user_id
|
||||
WHERE c.task_id = ? AND c.organization_id = ? AND c.workspace_id = ? AND c.project_id = ?
|
||||
ORDER BY c.created_at ASC`,
|
||||
[taskId, context.organization.id, context.workspace.id, context.project.id]
|
||||
).map(commentPayload);
|
||||
}
|
||||
|
||||
function taskLinks(context, taskId) {
|
||||
ensureTask(context, taskId);
|
||||
return dbAll(
|
||||
`SELECT l.*, u.display_name AS created_by_name
|
||||
FROM task_links l
|
||||
LEFT JOIN users u ON u.id = l.created_by
|
||||
WHERE l.task_id = ? AND l.organization_id = ? AND l.workspace_id = ? AND l.project_id = ?
|
||||
ORDER BY l.created_at DESC`,
|
||||
[taskId, context.organization.id, context.workspace.id, context.project.id]
|
||||
).map(linkPayload);
|
||||
}
|
||||
|
||||
function taskDetail(context, taskId) {
|
||||
const task = ensureTask(context, taskId);
|
||||
return { task: taskPayload(task), comments: taskComments(context, taskId), links: taskLinks(context, taskId) };
|
||||
}
|
||||
|
||||
function activeProjectMember(context, userId) {
|
||||
return projectMember(context, String(userId || "").trim());
|
||||
}
|
||||
|
||||
function mentionUserIds(context, bodyText, requestedIds = []) {
|
||||
const candidateIds = Array.isArray(requestedIds) ? requestedIds.map((id) => String(id || "").trim()).filter(Boolean) : [];
|
||||
const valid = new Map();
|
||||
for (const id of candidateIds) {
|
||||
const member = activeProjectMember(context, id);
|
||||
if (member) valid.set(member.id, member);
|
||||
}
|
||||
const text = String(bodyText || "");
|
||||
const tokens = text.match(/@[\u4e00-\u9fa5A-Za-z0-9_.-]+/g) || [];
|
||||
const members = dbAll(
|
||||
`SELECT DISTINCT u.id, u.display_name, u.email
|
||||
FROM users u
|
||||
JOIN organization_members om ON om.user_id = u.id AND om.organization_id = ? AND om.status = 'active'
|
||||
LEFT JOIN workspace_members wm ON wm.user_id = u.id AND wm.workspace_id = ? AND wm.status = 'active'
|
||||
LEFT JOIN project_members pm ON pm.user_id = u.id AND pm.project_id = ? AND pm.status = 'active'
|
||||
WHERE u.status = 'active' AND (wm.user_id IS NOT NULL OR pm.user_id IS NOT NULL OR om.role_key IN ('org_owner', 'org_admin'))`,
|
||||
[context.organization.id, context.workspace.id, context.project.id]
|
||||
);
|
||||
for (const token of tokens) {
|
||||
const needle = token.slice(1).toLowerCase();
|
||||
const member = members.find((item) => [item.id, item.display_name, item.email].some((value) => String(value || "").toLowerCase() === needle));
|
||||
if (member) valid.set(member.id, member);
|
||||
}
|
||||
return [...valid.values()];
|
||||
}
|
||||
|
||||
export function getProjectTask(context, taskId) {
|
||||
requirePermission(context, "task:view");
|
||||
return taskDetail(context, taskId);
|
||||
}
|
||||
|
||||
export function listTaskComments(context, taskId) {
|
||||
requirePermission(context, "task:view");
|
||||
return { taskId, comments: taskComments(context, taskId), generatedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
export function createTaskComment(context, taskId, body = {}) {
|
||||
requirePermission(context, "task:view");
|
||||
requireProjectWritable(context);
|
||||
const task = ensureTask(context, taskId);
|
||||
const bodyText = String(body.body || "").trim();
|
||||
if (!bodyText) throw taskError(400, "task_comment_required", "评论内容不能为空");
|
||||
if (bodyText.length > 4000) throw taskError(400, "task_comment_too_long", "评论不能超过 4000 个字符");
|
||||
const parentCommentId = body.parentCommentId ? String(body.parentCommentId).trim() : null;
|
||||
if (parentCommentId) {
|
||||
const parent = dbGet("SELECT id FROM task_comments WHERE id = ? AND task_id = ? AND project_id = ?", [parentCommentId, taskId, context.project.id]);
|
||||
if (!parent) throw taskError(400, "task_comment_parent_invalid", "回复目标不存在或不属于当前任务");
|
||||
}
|
||||
const mentions = mentionUserIds(context, bodyText, body.mentionUserIds);
|
||||
const timestamp = new Date().toISOString();
|
||||
const id = makeId("task-comment");
|
||||
dbRun(
|
||||
`INSERT INTO task_comments(id, organization_id, workspace_id, project_id, task_id, parent_comment_id, author_user_id, body, mentions_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[id, context.organization.id, context.workspace.id, context.project.id, taskId, parentCommentId, context.user.id, bodyText, JSON.stringify(mentions.map((member) => ({ id: member.id, displayName: member.display_name, email: member.email }))), timestamp, timestamp]
|
||||
);
|
||||
addAudit({ context, action: "project.task.comment.created", targetType: "task_comment", targetId: id, metadata: { taskId, parentCommentId, mentionUserIds: mentions.map((member) => member.id) } });
|
||||
const recipientIds = [...new Set([task.assignee_user_id, task.created_by, ...mentions.map((member) => member.id)].filter(Boolean))].filter((idValue) => idValue !== context.user.id);
|
||||
if (recipientIds.length) {
|
||||
void dispatchNotificationEvent({ context, eventKey: "task.commented", payload: { recipientUserIds: recipientIds, taskId, taskTitle: task.title, commentId: id, targetId: taskId, targetTab: "tasks", mentionUserIds: mentions.map((member) => member.id) } });
|
||||
}
|
||||
return { ...taskDetail(context, taskId), comment: commentPayload(dbGet("SELECT c.*, u.display_name AS author_name, u.email AS author_email FROM task_comments c LEFT JOIN users u ON u.id = c.author_user_id WHERE c.id = ?", [id])) };
|
||||
}
|
||||
|
||||
function resolveTaskLink(context, linkType, targetId, label, metadata = {}) {
|
||||
const id = String(targetId || "").trim();
|
||||
if (!TASK_LINK_TYPES.has(linkType)) throw taskError(400, "task_link_type_invalid", "任务关联类型无效", { linkType });
|
||||
if (!id) throw taskError(400, "task_link_target_required", "关联对象不能为空");
|
||||
let resolvedLabel = String(label || "").trim().slice(0, 180);
|
||||
let resolvedMetadata = metadata && typeof metadata === "object" ? metadata : {};
|
||||
if (linkType === "shot") {
|
||||
const row = dbGet(`SELECT s.id, s.title, s.shot_number, e.episode_number FROM shots s JOIN episodes e ON e.id = s.episode_id JOIN seasons se ON se.id = e.season_id JOIN series sr ON sr.id = se.series_id WHERE s.id = ? AND sr.project_id = ?`, [id, context.project.id]);
|
||||
if (!row) throw taskError(400, "task_link_shot_invalid", "镜头不存在或不属于当前项目");
|
||||
resolvedLabel ||= `E${String(row.episode_number).padStart(2, "0")}-S${String(row.shot_number).padStart(2, "0")} ${row.title}`;
|
||||
resolvedMetadata = { ...resolvedMetadata, shotId: row.id, episodeNumber: row.episode_number, shotNumber: row.shot_number };
|
||||
} else if (linkType === "asset") {
|
||||
const row = dbGet("SELECT id, kind, name, lock_status FROM assets WHERE id = ? AND project_id = ?", [id, context.project.id]);
|
||||
if (!row) throw taskError(400, "task_link_asset_invalid", "资产不存在或不属于当前项目");
|
||||
resolvedLabel ||= `${row.name} · ${row.kind}`;
|
||||
resolvedMetadata = { ...resolvedMetadata, assetId: row.id, kind: row.kind, lockStatus: row.lock_status };
|
||||
} else if (linkType === "job") {
|
||||
const row = dbGet("SELECT id, kind, status, shot_id FROM generation_jobs WHERE id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?", [id, context.organization.id, context.workspace.id, context.project.id]);
|
||||
if (!row) throw taskError(400, "task_link_job_invalid", "生成任务不存在或不属于当前项目");
|
||||
resolvedLabel ||= `${row.kind} · ${row.status}`;
|
||||
resolvedMetadata = { ...resolvedMetadata, jobId: row.id, status: row.status, shotId: row.shot_id || null };
|
||||
} else if (linkType === "review") {
|
||||
const row = dbGet("SELECT id, lane, status, shot_id FROM reviews WHERE id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?", [id, context.organization.id, context.workspace.id, context.project.id]);
|
||||
if (!row) throw taskError(400, "task_link_review_invalid", "审片记录不存在或不属于当前项目");
|
||||
resolvedLabel ||= `${row.lane} · ${row.status}`;
|
||||
resolvedMetadata = { ...resolvedMetadata, reviewId: row.id, lane: row.lane, status: row.status, shotId: row.shot_id || null };
|
||||
} else if (linkType === "artifact") {
|
||||
const row = dbGet("SELECT id, kind, path, shot_id FROM media_artifacts WHERE id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?", [id, context.organization.id, context.workspace.id, context.project.id]);
|
||||
if (!row) throw taskError(400, "task_link_artifact_invalid", "媒体证据不存在或不属于当前项目");
|
||||
resolvedLabel ||= `${row.kind} · ${row.path}`;
|
||||
resolvedMetadata = { ...resolvedMetadata, artifactId: row.id, kind: row.kind, path: row.path, shotId: row.shot_id || null };
|
||||
} else if (linkType === "delivery") {
|
||||
const row = dbGet("SELECT id, version_label, status FROM deliveries WHERE id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?", [id, context.organization.id, context.workspace.id, context.project.id]);
|
||||
if (!row) throw taskError(400, "task_link_delivery_invalid", "交付版本不存在或不属于当前项目");
|
||||
resolvedLabel ||= `${row.version_label || row.id} · ${row.status}`;
|
||||
resolvedMetadata = { ...resolvedMetadata, deliveryId: row.id, status: row.status };
|
||||
} else if (linkType === "file") {
|
||||
if (/^https?:\/\//i.test(id)) throw taskError(400, "task_link_external_blocked", "本地平台不允许把外部云端 URL 当作任务附件");
|
||||
resolvedLabel ||= id.split("/").pop() || id;
|
||||
resolvedMetadata = { ...resolvedMetadata, path: id, localOnly: true };
|
||||
}
|
||||
return { targetId: id, label: resolvedLabel || id, metadata: resolvedMetadata };
|
||||
}
|
||||
|
||||
export function addTaskLink(context, taskId, body = {}) {
|
||||
requirePermission(context, "task:manage");
|
||||
requireProjectWritable(context);
|
||||
const task = ensureTask(context, taskId);
|
||||
const linkType = String(body.linkType || body.type || "").trim();
|
||||
const resolved = resolveTaskLink(context, linkType, body.targetId, body.label, body.metadata);
|
||||
const timestamp = new Date().toISOString();
|
||||
const id = makeId("task-link");
|
||||
dbRun(
|
||||
`INSERT INTO task_links(id, organization_id, workspace_id, project_id, task_id, link_type, target_id, label, metadata_json, created_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(task_id, link_type, target_id) DO UPDATE SET label = excluded.label, metadata_json = excluded.metadata_json`,
|
||||
[id, context.organization.id, context.workspace.id, context.project.id, taskId, linkType, resolved.targetId, resolved.label, JSON.stringify(resolved.metadata), context.user.id, timestamp]
|
||||
);
|
||||
const saved = dbGet("SELECT l.*, u.display_name AS created_by_name FROM task_links l LEFT JOIN users u ON u.id = l.created_by WHERE l.task_id = ? AND l.link_type = ? AND l.target_id = ?", [taskId, linkType, resolved.targetId]);
|
||||
addAudit({ context, action: "project.task.link.created", targetType: "task_link", targetId: saved.id, metadata: { taskId, linkType, targetId: resolved.targetId } });
|
||||
return { ...taskDetail(context, taskId), link: linkPayload(saved) };
|
||||
}
|
||||
|
||||
export function removeTaskLink(context, taskId, linkId) {
|
||||
requirePermission(context, "task:manage");
|
||||
requireProjectWritable(context);
|
||||
ensureTask(context, taskId);
|
||||
const link = dbGet("SELECT * FROM task_links WHERE id = ? AND task_id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?", [linkId, taskId, context.organization.id, context.workspace.id, context.project.id]);
|
||||
if (!link) throw taskError(404, "task_link_not_found", "任务关联不存在或不属于当前项目");
|
||||
dbRun("DELETE FROM task_links WHERE id = ?", [linkId]);
|
||||
addAudit({ context, action: "project.task.link.removed", targetType: "task_link", targetId: linkId, metadata: { taskId, linkType: link.link_type, targetId: link.target_id } });
|
||||
return taskDetail(context, taskId);
|
||||
}
|
||||
|
||||
const ACTIVITY_LABELS = {
|
||||
"project.task.created": "创建协作任务",
|
||||
"project.task.updated": "更新协作任务",
|
||||
"project.task.comment.created": "添加任务评论",
|
||||
"project.task.link.created": "关联生产对象",
|
||||
"project.task.link.removed": "移除任务关联",
|
||||
"shot.created": "创建镜头",
|
||||
"shot.updated": "更新镜头",
|
||||
"shot.prompt.version.created": "保存镜头提示版本",
|
||||
"shot.version.restored": "恢复镜头版本",
|
||||
"asset.created": "创建资产",
|
||||
"asset.version.created": "创建资产版本",
|
||||
"asset.version.restored": "恢复资产版本",
|
||||
"asset.lock.updated": "更新资产锁",
|
||||
"asset.bound": "绑定资产到镜头",
|
||||
"asset.content.verified": "验证资产内容",
|
||||
"generation_job.created": "创建生成任务",
|
||||
"generation_job.completed": "生成任务完成",
|
||||
"generation_job.failed": "生成任务失败",
|
||||
"generation_job.retry": "重试生成任务",
|
||||
"media.composition.planned": "规划合成版本",
|
||||
"media.composition.completed": "合成版本完成",
|
||||
"media.composition.failed": "合成版本失败",
|
||||
"qa.media_inspection.run": "执行媒体深检",
|
||||
"review.approved": "审片通过",
|
||||
"review.changes_requested": "审片要求修改",
|
||||
"review.rejected": "审片驳回",
|
||||
"qa.comment.created": "添加审片评论",
|
||||
"series_bible.updated": "更新系列 Bible",
|
||||
"assistant.query": "使用项目助手",
|
||||
"organization.invitation.created": "创建组织邀请",
|
||||
"project.created": "创建项目",
|
||||
"delivery.created": "创建交付版本",
|
||||
"delivery.approved": "批准交付版本",
|
||||
"delivery.batch.created": "创建交付批次",
|
||||
"delivery.batch.activated": "激活交付批次",
|
||||
"delivery.batch.rolled_back": "回滚交付批次"
|
||||
};
|
||||
|
||||
function activityTarget(context, row, metadata) {
|
||||
const type = row.target_type;
|
||||
if (type === "project_task") return dbGet("SELECT title FROM project_tasks WHERE id = ? AND project_id = ?", [row.target_id, context.project.id])?.title || row.target_id;
|
||||
if (type === "task_comment") return dbGet("SELECT t.title FROM task_comments c JOIN project_tasks t ON t.id = c.task_id WHERE c.id = ? AND c.project_id = ?", [row.target_id, context.project.id])?.title || row.target_id;
|
||||
if (type === "task_link") return `${metadata.linkType || "关联"} · ${metadata.targetId || row.target_id}`;
|
||||
if (type === "shot" || type === "shot_version") return dbGet("SELECT s.title FROM shots s JOIN episodes e ON e.id = s.episode_id JOIN seasons se ON se.id = e.season_id JOIN series sr ON sr.id = se.series_id WHERE s.id = ? AND sr.project_id = ?", [metadata.shotId || row.target_id, context.project.id])?.title || metadata.shotId || row.target_id;
|
||||
if (type === "asset" || type === "asset_version") return dbGet("SELECT name FROM assets WHERE id = ? AND project_id = ?", [metadata.assetId || row.target_id, context.project.id])?.name || metadata.assetId || row.target_id;
|
||||
if (type === "generation_job") return dbGet("SELECT kind FROM generation_jobs WHERE id = ? AND project_id = ?", [row.target_id, context.project.id])?.kind || row.target_id;
|
||||
if (type === "review") return `${metadata.lane || "审片"} · ${metadata.shotId || row.target_id}`;
|
||||
if (type === "delivery" || type === "delivery_batch") return metadata.version || metadata.deliveryId || row.target_id;
|
||||
return row.target_id;
|
||||
}
|
||||
|
||||
function activityTab(targetType) {
|
||||
if (["project_task", "task_comment", "task_link"].includes(targetType)) return "tasks";
|
||||
if (["shot", "shot_version", "script_document"].includes(targetType)) return "director";
|
||||
if (["asset", "asset_version", "asset_binding"].includes(targetType)) return "casting";
|
||||
if (["generation_job", "media_composition"].includes(targetType)) return "jobs";
|
||||
if (targetType === "review" || targetType === "review_comment") return "qa";
|
||||
if (["delivery", "delivery_batch"].includes(targetType)) return "export";
|
||||
if (targetType === "invitation") return "admin-members";
|
||||
if (["series", "episode"].includes(targetType)) return "bible";
|
||||
return "creator-home";
|
||||
}
|
||||
|
||||
export function listProjectActivity(context, { limit = 80 } = {}) {
|
||||
requirePermission(context, "task:view");
|
||||
const scope = taskScope(context);
|
||||
const normalizedLimit = Math.max(1, Math.min(200, Number(limit || 80)));
|
||||
const rows = dbAll(
|
||||
`SELECT a.*, u.display_name AS actor_name
|
||||
FROM audit_logs a
|
||||
LEFT JOIN users u ON u.id = a.actor_user_id
|
||||
WHERE a.organization_id = ? AND a.workspace_id = ? AND a.project_id = ?
|
||||
AND a.action NOT LIKE 'auth.%' AND a.action NOT LIKE 'system.%' AND a.action NOT LIKE 'user.%'
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT ?`,
|
||||
[scope.organizationId, scope.workspaceId, scope.projectId, normalizedLimit]
|
||||
);
|
||||
return {
|
||||
activities: rows.map((row) => {
|
||||
const metadata = parseJson(row.metadata_json, {});
|
||||
return {
|
||||
id: row.id,
|
||||
action: row.action,
|
||||
label: ACTIVITY_LABELS[row.action] || row.action,
|
||||
result: row.result,
|
||||
targetType: row.target_type,
|
||||
targetId: row.target_id,
|
||||
targetLabel: activityTarget(context, row, metadata),
|
||||
targetTab: activityTab(row.target_type),
|
||||
actor: { id: row.actor_user_id, displayName: row.actor_name || row.actor_user_id || "系统" },
|
||||
metadata,
|
||||
createdAt: row.created_at
|
||||
};
|
||||
}),
|
||||
scope,
|
||||
generatedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
+2738
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,302 @@
|
||||
import { dbAll } from "./db.mjs";
|
||||
import { hasPermission } from "./tenant.mjs";
|
||||
|
||||
const REVIEW_LABELS = {
|
||||
"single-frame": "一图一画面",
|
||||
"continuity-lock": "连续性证据",
|
||||
"voice-subtitle-asr": "声音 / 字幕 / ASR 对齐",
|
||||
"clip-bridge": "片段衔接 / 实际末帧"
|
||||
};
|
||||
|
||||
const PRIORITY_ORDER = { high: 0, medium: 1, low: 2 };
|
||||
const ACTIONABLE_JOB_STATUSES = new Set(["queued", "running", "blocked", "failed"]);
|
||||
const ACTIONABLE_TASK_STATUSES = new Set(["open", "in_progress", "blocked"]);
|
||||
const ACTIONABLE_ASSET_RIGHTS = new Set(["needs-evidence", "pending", "review", "rejected"]);
|
||||
const ACTIONABLE_DELIVERY_STATUSES = new Set(["draft", "prepared", "pending", "review"]);
|
||||
|
||||
function parseJson(value, fallback) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function scope(context) {
|
||||
return {
|
||||
organizationId: context.organization?.id || "",
|
||||
workspaceId: context.workspace?.id || "",
|
||||
projectId: context.project?.id || ""
|
||||
};
|
||||
}
|
||||
|
||||
function priorityForStatus(status) {
|
||||
if (["blocked", "failed", "changes_requested", "rejected"].includes(status)) return "high";
|
||||
if (["queued", "pending", "draft", "prepared", "review"].includes(status)) return "medium";
|
||||
return "low";
|
||||
}
|
||||
|
||||
function reviewTitle(row) {
|
||||
const episode = row.episode_number ? `E${String(row.episode_number).padStart(2, "0")}` : "当前集";
|
||||
const shot = row.shot_number ? `-S${String(row.shot_number).padStart(2, "0")}` : "";
|
||||
const label = REVIEW_LABELS[row.lane] || row.lane;
|
||||
if (row.status === "changes_requested") return `${episode}${shot} ${label}需修改`;
|
||||
if (row.status === "rejected") return `${episode}${shot} ${label}已驳回`;
|
||||
return `${episode}${shot} ${label}待确认`;
|
||||
}
|
||||
|
||||
function jobTitle(row) {
|
||||
const shot = row.shot_number ? ` · E${String(row.episode_number || 1).padStart(2, "0")}-S${String(row.shot_number).padStart(2, "0")}` : "";
|
||||
if (row.status === "blocked") return `${row.kind}${shot} 等待前置任务`;
|
||||
if (row.status === "failed") return `${row.kind}${shot} 生成失败,等待重试`;
|
||||
if (row.status === "running") return `${row.kind}${shot} 正在执行`;
|
||||
return `${row.kind}${shot} 等待本地 Worker`;
|
||||
}
|
||||
|
||||
function assetTitle(row) {
|
||||
if (row.kind === "voice") return `${row.name} · v${row.version_number || 1} 声音授权证据待补`;
|
||||
return `${row.name} · v${row.version_number || 1} 版权证据待补`;
|
||||
}
|
||||
|
||||
function actionableReviews(context) {
|
||||
if (!context.project) return [];
|
||||
const canReview = hasPermission(context, "qa:review");
|
||||
const canFixProduction = ["script:edit", "asset:edit", "prompt:edit", "voice:edit"].some((permission) => hasPermission(context, permission));
|
||||
if (!canReview && !canFixProduction) return [];
|
||||
const rows = dbAll(
|
||||
`SELECT r.id, r.shot_id, r.lane, r.status, r.evidence_json, r.updated_at,
|
||||
s.title AS shot_title, s.shot_number,
|
||||
e.episode_number, e.title AS episode_title
|
||||
FROM reviews r
|
||||
LEFT JOIN shots s ON s.id = r.shot_id
|
||||
LEFT JOIN episodes e ON e.id = s.episode_id
|
||||
WHERE r.organization_id = ? AND r.workspace_id = ? AND r.project_id = ?
|
||||
AND r.status IN ('pending', 'changes_requested', 'rejected')
|
||||
ORDER BY r.updated_at DESC
|
||||
LIMIT 100`,
|
||||
[context.organization.id, context.workspace.id, context.project.id]
|
||||
);
|
||||
return rows
|
||||
.filter((row) => canReview || ["changes_requested", "rejected"].includes(row.status))
|
||||
.map((row) => {
|
||||
const evidence = parseJson(row.evidence_json, {});
|
||||
const blockers = Array.isArray(evidence.blockers) ? evidence.blockers : [];
|
||||
return {
|
||||
id: `review-${row.id}`,
|
||||
kind: "review",
|
||||
title: reviewTitle(row),
|
||||
detail: blockers[0] || REVIEW_LABELS[row.lane] || row.lane,
|
||||
status: row.status,
|
||||
priority: priorityForStatus(row.status),
|
||||
targetTab: "qa",
|
||||
targetId: row.id,
|
||||
updatedAt: row.updated_at,
|
||||
metadata: {
|
||||
reviewId: row.id,
|
||||
shotId: row.shot_id,
|
||||
shotTitle: row.shot_title || "",
|
||||
lane: row.lane,
|
||||
episodeTitle: row.episode_title || ""
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function actionableJobs(context) {
|
||||
if (!context.project || (!hasPermission(context, "job:create") && !hasPermission(context, "queue:manage"))) return [];
|
||||
const rows = dbAll(
|
||||
`SELECT j.id, j.kind, j.status, j.adapter_id, j.error_message, j.updated_at,
|
||||
j.shot_id, s.title AS shot_title, s.shot_number, e.episode_number
|
||||
FROM generation_jobs j
|
||||
LEFT JOIN shots s ON s.id = j.shot_id
|
||||
LEFT JOIN episodes e ON e.id = s.episode_id
|
||||
WHERE j.organization_id = ? AND j.workspace_id = ? AND j.project_id = ?
|
||||
AND j.status IN ('queued', 'running', 'blocked', 'failed')
|
||||
ORDER BY CASE j.status WHEN 'failed' THEN 0 WHEN 'blocked' THEN 1 WHEN 'queued' THEN 2 ELSE 3 END, j.updated_at DESC
|
||||
LIMIT 100`,
|
||||
[context.organization.id, context.workspace.id, context.project.id]
|
||||
);
|
||||
return rows.filter((row) => ACTIONABLE_JOB_STATUSES.has(row.status)).map((row) => ({
|
||||
id: `job-${row.id}`,
|
||||
kind: "job",
|
||||
title: jobTitle(row),
|
||||
detail: row.error_message || row.shot_title || `适配器:${row.adapter_id}`,
|
||||
status: row.status,
|
||||
priority: priorityForStatus(row.status),
|
||||
targetTab: "jobs",
|
||||
targetId: row.id,
|
||||
updatedAt: row.updated_at,
|
||||
metadata: { jobId: row.id, shotId: row.shot_id || null, adapterId: row.adapter_id }
|
||||
}));
|
||||
}
|
||||
|
||||
function actionableAssets(context) {
|
||||
if (!context.project) return [];
|
||||
const canEditAssets = hasPermission(context, "asset:edit") || hasPermission(context, "compliance:manage");
|
||||
const canApproveVoice = hasPermission(context, "voice:approve");
|
||||
if (!canEditAssets && !canApproveVoice) return [];
|
||||
const rows = dbAll(
|
||||
`SELECT a.id, a.kind, a.name, a.lock_status, a.updated_at,
|
||||
av.version_number, av.rights_status, av.metadata_json
|
||||
FROM assets a
|
||||
JOIN projects p ON p.id = a.project_id
|
||||
JOIN workspaces w ON w.id = p.workspace_id
|
||||
LEFT JOIN asset_versions av ON av.id = a.current_version_id
|
||||
WHERE p.id = ? AND p.workspace_id = ? AND w.organization_id = ?
|
||||
ORDER BY a.updated_at DESC
|
||||
LIMIT 100`,
|
||||
[context.project.id, context.workspace.id, context.organization.id]
|
||||
);
|
||||
return rows
|
||||
.filter((row) => ACTIONABLE_ASSET_RIGHTS.has(row.rights_status) && (row.kind === "voice" ? canApproveVoice : canEditAssets))
|
||||
.map((row) => {
|
||||
const metadata = parseJson(row.metadata_json, {});
|
||||
return {
|
||||
id: `asset-${row.id}`,
|
||||
kind: "asset",
|
||||
title: assetTitle(row),
|
||||
detail: metadata.subtitle || (row.kind === "voice" ? "固定声线未完成授权证据确认" : "连续性资产未完成版权证据确认"),
|
||||
status: row.rights_status,
|
||||
priority: row.kind === "voice" ? "high" : "medium",
|
||||
targetTab: "casting",
|
||||
targetId: row.id,
|
||||
updatedAt: row.updated_at,
|
||||
metadata: { assetId: row.id, assetKind: row.kind, lockStatus: row.lock_status, versionNumber: row.version_number || 1 }
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function actionableDeliveries(context) {
|
||||
if (!context.project || (!hasPermission(context, "delivery:view") && !hasPermission(context, "delivery:approve"))) return [];
|
||||
const rows = dbAll(
|
||||
`SELECT id, version, channel, status, active_batch_id, updated_at
|
||||
FROM deliveries
|
||||
WHERE organization_id = ? AND workspace_id = ? AND project_id = ?
|
||||
AND status IN ('draft', 'prepared', 'pending', 'review')
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 50`,
|
||||
[context.organization.id, context.workspace.id, context.project.id]
|
||||
);
|
||||
return rows.filter((row) => ACTIONABLE_DELIVERY_STATUSES.has(row.status)).map((row) => ({
|
||||
id: `delivery-${row.id}`,
|
||||
kind: "delivery",
|
||||
title: `${row.version} 内部交付版本待审阅`,
|
||||
detail: row.active_batch_id ? `${row.channel} · 已有活动批次` : `${row.channel} · 尚未激活交付批次`,
|
||||
status: row.status,
|
||||
priority: priorityForStatus(row.status),
|
||||
targetTab: "export",
|
||||
targetId: row.id,
|
||||
updatedAt: row.updated_at,
|
||||
metadata: { deliveryId: row.id, version: row.version, channel: row.channel }
|
||||
}));
|
||||
}
|
||||
|
||||
function actionableInvitations(context) {
|
||||
if (!context.user?.email || !context.organization?.id) return [];
|
||||
const now = new Date().toISOString();
|
||||
const rows = dbAll(
|
||||
`SELECT i.id, i.organization_id, i.workspace_id, i.project_id, i.role_key, i.expires_at, i.created_at,
|
||||
o.name AS organization_name, w.name AS workspace_name, p.name AS project_name,
|
||||
u.display_name AS inviter_name, r.name AS role_name
|
||||
FROM invitations i
|
||||
JOIN organizations o ON o.id = i.organization_id
|
||||
LEFT JOIN workspaces w ON w.id = i.workspace_id
|
||||
LEFT JOIN projects p ON p.id = i.project_id
|
||||
LEFT JOIN users u ON u.id = i.invited_by
|
||||
LEFT JOIN roles r ON r.key = i.role_key
|
||||
WHERE i.organization_id = ?
|
||||
AND lower(i.email) = lower(?)
|
||||
AND i.status = 'pending'
|
||||
AND i.expires_at > ?
|
||||
AND (i.workspace_id IS NULL OR i.workspace_id = ?)
|
||||
AND (i.project_id IS NULL OR i.project_id = ?)
|
||||
ORDER BY i.created_at DESC
|
||||
LIMIT 50`,
|
||||
[context.organization.id, context.user.email, now, context.workspace?.id || "", context.project?.id || ""]
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
id: `invitation-${row.id}`,
|
||||
kind: "invitation",
|
||||
title: `接受加入${row.organization_name}的邀请`,
|
||||
detail: `${row.workspace_name || "组织级"}${row.project_name ? ` · ${row.project_name}` : ""} · ${row.role_name || row.role_key}`,
|
||||
status: "pending",
|
||||
priority: "medium",
|
||||
targetTab: "account",
|
||||
targetId: row.id,
|
||||
updatedAt: row.created_at,
|
||||
metadata: { invitationId: row.id, organizationId: row.organization_id, workspaceId: row.workspace_id, projectId: row.project_id, inviterName: row.inviter_name || "" }
|
||||
}));
|
||||
}
|
||||
|
||||
function actionableTasks(context) {
|
||||
if (!context.project || !hasPermission(context, "task:view")) return [];
|
||||
const canSeeAll = hasPermission(context, "task:manage");
|
||||
const clauses = [
|
||||
"t.organization_id = ?",
|
||||
"t.workspace_id = ?",
|
||||
"t.project_id = ?",
|
||||
"t.status IN ('open', 'in_progress', 'blocked')"
|
||||
];
|
||||
const params = [context.organization.id, context.workspace.id, context.project.id];
|
||||
if (!canSeeAll) {
|
||||
clauses.push("t.assignee_user_id = ?");
|
||||
params.push(context.user.id);
|
||||
}
|
||||
const rows = dbAll(
|
||||
`SELECT t.id, t.title, t.kind, t.status, t.priority, t.due_at, t.updated_at, u.display_name AS assignee_name
|
||||
FROM project_tasks t
|
||||
LEFT JOIN users u ON u.id = t.assignee_user_id
|
||||
WHERE ${clauses.join(" AND ")}
|
||||
ORDER BY CASE t.status WHEN 'blocked' THEN 0 WHEN 'open' THEN 1 ELSE 2 END,
|
||||
CASE t.priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 ELSE 2 END,
|
||||
COALESCE(t.due_at, '9999-12-31T23:59:59.999Z'), t.updated_at DESC
|
||||
LIMIT 100`,
|
||||
params
|
||||
);
|
||||
return rows.filter((row) => ACTIONABLE_TASK_STATUSES.has(row.status)).map((row) => ({
|
||||
id: `task-${row.id}`,
|
||||
kind: "task",
|
||||
title: row.title,
|
||||
detail: `${row.kind} · ${row.assignee_name || "未分派"}${row.due_at ? ` · 截止 ${row.due_at.slice(0, 10)}` : ""}`,
|
||||
status: row.status,
|
||||
priority: row.priority,
|
||||
targetTab: "tasks",
|
||||
targetId: row.id,
|
||||
updatedAt: row.updated_at,
|
||||
metadata: { taskId: row.id, assigneeUserId: row.assignee_user_id || null, dueAt: row.due_at || null }
|
||||
}));
|
||||
}
|
||||
|
||||
export function listWorkItems(context, options = {}) {
|
||||
const items = [
|
||||
...actionableReviews(context),
|
||||
...actionableJobs(context),
|
||||
...actionableAssets(context),
|
||||
...actionableDeliveries(context),
|
||||
...actionableInvitations(context),
|
||||
...actionableTasks(context)
|
||||
].sort((left, right) => {
|
||||
const priority = (PRIORITY_ORDER[left.priority] ?? 9) - (PRIORITY_ORDER[right.priority] ?? 9);
|
||||
if (priority !== 0) return priority;
|
||||
return String(right.updatedAt || "").localeCompare(String(left.updatedAt || ""));
|
||||
});
|
||||
const limit = Math.max(1, Math.min(200, Number(options.limit || 100)));
|
||||
const visibleItems = items.slice(0, limit);
|
||||
const byKind = {};
|
||||
const byStatus = {};
|
||||
for (const item of visibleItems) {
|
||||
byKind[item.kind] = (byKind[item.kind] || 0) + 1;
|
||||
byStatus[item.status] = (byStatus[item.status] || 0) + 1;
|
||||
}
|
||||
return {
|
||||
items: visibleItems,
|
||||
summary: {
|
||||
total: visibleItems.length,
|
||||
high: visibleItems.filter((item) => item.priority === "high").length,
|
||||
byKind,
|
||||
byStatus
|
||||
},
|
||||
scope: scope(context),
|
||||
generatedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { dbAll, dbGet, dbRun, withTransaction } from "./db.mjs";
|
||||
import { executeGenerationJob } from "./execution.mjs";
|
||||
|
||||
const workerId = process.env.AI_DRAMA_WORKER_ID || `local-worker-${process.pid}`;
|
||||
const pollMs = Math.max(250, Number(process.env.AI_DRAMA_WORKER_POLL_MS || 1200));
|
||||
const maxConcurrency = Math.max(1, Math.min(8, Number(process.env.AI_DRAMA_WORKER_CONCURRENCY || 2)));
|
||||
const leaseMs = Math.max(30_000, Number(process.env.AI_DRAMA_WORKER_LEASE_MS || 300_000));
|
||||
const staleAfterMs = Math.max(15_000, Number(process.env.AI_DRAMA_WORKER_STALE_MS || Math.max(30_000, pollMs * 5)));
|
||||
const enabled = process.env.AI_DRAMA_WORKER_ENABLED !== "0";
|
||||
|
||||
const state = {
|
||||
workerId,
|
||||
enabled,
|
||||
pollMs,
|
||||
maxConcurrency,
|
||||
inFlight: new Set(),
|
||||
startedAt: enabled ? new Date().toISOString() : null,
|
||||
lastPollAt: null,
|
||||
lastClaimAt: null,
|
||||
lastCompletedAt: null,
|
||||
lastFailedAt: null,
|
||||
lastHeartbeatAt: null,
|
||||
lastReclaimAt: null,
|
||||
lastReclaimedCount: 0,
|
||||
lastRetryAt: null,
|
||||
lastRetryCount: 0,
|
||||
lastError: "",
|
||||
timer: null,
|
||||
pumping: false
|
||||
};
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function workerContext(job) {
|
||||
const organization = dbGet("SELECT * FROM organizations WHERE id = ?", [job.organization_id]);
|
||||
const workspace = dbGet("SELECT * FROM workspaces WHERE id = ?", [job.workspace_id]);
|
||||
const project = dbGet("SELECT * FROM projects WHERE id = ?", [job.project_id]);
|
||||
const user = dbGet("SELECT * FROM users WHERE id = 'u-local-worker'") || dbGet("SELECT * FROM users WHERE id = ?", [job.created_by]);
|
||||
return {
|
||||
user,
|
||||
organization,
|
||||
workspace,
|
||||
project,
|
||||
permissions: ["job:create", "queue:manage", "model:manage", "usage:view", "audit:view"],
|
||||
roles: [{ key: "local_worker", name: "本地 Worker", scope: "system" }],
|
||||
systemAdmin: false,
|
||||
orgElevated: true,
|
||||
workspaces: [],
|
||||
projects: []
|
||||
};
|
||||
}
|
||||
|
||||
function localQueueDepth() {
|
||||
return Number(dbGet(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM generation_jobs j
|
||||
JOIN model_connectors m ON m.id = j.adapter_id
|
||||
WHERE j.status = 'queued' AND m.cost_mode = 'local' AND m.status = 'ready'`,
|
||||
[]
|
||||
)?.count || 0);
|
||||
}
|
||||
|
||||
function queueAlert() {
|
||||
const oldest = dbGet("SELECT MIN(created_at) AS oldest FROM generation_jobs WHERE status = 'queued'", []);
|
||||
const queueDepth = localQueueDepth();
|
||||
const oldestAt = oldest?.oldest || null;
|
||||
const oldestAgeMs = oldestAt ? Math.max(0, Date.now() - Date.parse(oldestAt)) : 0;
|
||||
return {
|
||||
level: queueDepth === 0 ? "none" : oldestAgeMs >= 10 * 60 * 1000 ? "critical" : oldestAgeMs >= 3 * 60 * 1000 ? "warning" : "normal",
|
||||
queueDepth,
|
||||
oldestAt,
|
||||
oldestAgeMs
|
||||
};
|
||||
}
|
||||
|
||||
function updateWorkerHealth(status = enabled ? "ready" : "paused") {
|
||||
const timestamp = now();
|
||||
state.lastHeartbeatAt = timestamp;
|
||||
const alert = queueAlert();
|
||||
const metadata = {
|
||||
workerId,
|
||||
enabled,
|
||||
concurrency: maxConcurrency,
|
||||
inFlight: state.inFlight.size,
|
||||
pollMs,
|
||||
lastPollAt: state.lastPollAt,
|
||||
lastClaimAt: state.lastClaimAt,
|
||||
lastCompletedAt: state.lastCompletedAt,
|
||||
lastFailedAt: state.lastFailedAt,
|
||||
lastHeartbeatAt: state.lastHeartbeatAt,
|
||||
staleAfterMs,
|
||||
heartbeatAgeMs: 0,
|
||||
lastReclaimAt: state.lastReclaimAt,
|
||||
lastReclaimedCount: state.lastReclaimedCount,
|
||||
lastRetryAt: state.lastRetryAt,
|
||||
lastRetryCount: state.lastRetryCount,
|
||||
queueAlert: alert,
|
||||
lastError: state.lastError
|
||||
};
|
||||
dbRun(
|
||||
`UPDATE service_health
|
||||
SET status = ?, queue_depth = ?, last_heartbeat = ?, version = ?, metadata_json = ?, updated_at = ?
|
||||
WHERE service_key = 'local-worker'`,
|
||||
[status, localQueueDepth(), timestamp, "node-24-local-worker", JSON.stringify(metadata), timestamp]
|
||||
);
|
||||
}
|
||||
|
||||
function reclaimStaleLeases() {
|
||||
const timestamp = now();
|
||||
const staleBefore = new Date(Date.now() - leaseMs).toISOString();
|
||||
const staleJobs = dbAll("SELECT id, max_attempts FROM generation_jobs WHERE status = 'running' AND leased_at IS NOT NULL AND leased_at < ?", [staleBefore]);
|
||||
if (!staleJobs.length) return 0;
|
||||
withTransaction(() => {
|
||||
for (const job of staleJobs) {
|
||||
const attempt = Number(dbGet("SELECT MAX(attempt_number) AS attempt_number FROM job_attempts WHERE job_id = ?", [job.id])?.attempt_number || 0);
|
||||
const message = `Worker 租约在 ${leaseMs}ms 后失效,已回收第 ${attempt} 次执行`;
|
||||
const exhausted = attempt >= Number(job.max_attempts || 3);
|
||||
dbRun("UPDATE job_attempts SET status = 'failed', error_message = ?, finished_at = ? WHERE job_id = ? AND attempt_number = ? AND status = 'running'", [message, timestamp, job.id, attempt]);
|
||||
dbRun("UPDATE generation_jobs SET status = ?, error_message = ?, next_run_at = ?, leased_by = NULL, leased_at = NULL, finished_at = CASE WHEN ? THEN ? ELSE finished_at END, updated_at = ? WHERE id = ?", [exhausted ? "failed" : "queued", message, exhausted ? null : timestamp, exhausted ? 1 : 0, exhausted ? timestamp : null, timestamp, job.id]);
|
||||
}
|
||||
});
|
||||
state.lastReclaimAt = timestamp;
|
||||
state.lastReclaimedCount = staleJobs.length;
|
||||
state.lastError = `${staleJobs.length} 个过期任务租约已回收`;
|
||||
return staleJobs.length;
|
||||
}
|
||||
|
||||
function scheduleRetry(jobId, message) {
|
||||
const job = dbGet("SELECT max_attempts FROM generation_jobs WHERE id = ?", [jobId]);
|
||||
const attempt = Number(dbGet("SELECT MAX(attempt_number) AS attempt_number FROM job_attempts WHERE job_id = ?", [jobId])?.attempt_number || 0);
|
||||
const maxAttempts = Number(job?.max_attempts || 3);
|
||||
if (!job || attempt >= maxAttempts) return false;
|
||||
const delayMs = Math.min(120_000, 2 ** Math.max(0, attempt - 1) * 2_000);
|
||||
const nextRunAt = new Date(Date.now() + delayMs).toISOString();
|
||||
dbRun("UPDATE generation_jobs SET status = 'queued', next_run_at = ?, error_message = ?, leased_by = NULL, leased_at = NULL, updated_at = ? WHERE id = ? AND status = 'failed'", [`${message};将在 ${Math.ceil(delayMs / 1000)} 秒后自动重试(${attempt}/${maxAttempts})`, nextRunAt, now(), jobId]);
|
||||
state.lastRetryAt = now();
|
||||
state.lastRetryCount += 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
function claimNextJob() {
|
||||
const timestamp = now();
|
||||
const staleBefore = new Date(Date.now() - leaseMs).toISOString();
|
||||
return withTransaction(() => {
|
||||
const row = dbGet(
|
||||
`SELECT j.*
|
||||
FROM generation_jobs j
|
||||
JOIN model_connectors m ON m.id = j.adapter_id
|
||||
WHERE j.status = 'queued'
|
||||
AND (j.next_run_at IS NULL OR j.next_run_at <= ?)
|
||||
AND (j.leased_by IS NULL OR j.leased_at < ?)
|
||||
AND m.cost_mode = 'local'
|
||||
AND m.status = 'ready'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM job_dependencies d
|
||||
JOIN generation_jobs dependency ON dependency.id = d.depends_on_job_id
|
||||
WHERE d.job_id = j.id AND dependency.status <> 'completed'
|
||||
)
|
||||
ORDER BY j.priority DESC, j.created_at ASC
|
||||
LIMIT 1`,
|
||||
[timestamp, staleBefore]
|
||||
);
|
||||
if (!row) return null;
|
||||
const result = dbRun(
|
||||
`UPDATE generation_jobs
|
||||
SET leased_by = ?, leased_at = ?, updated_at = ?
|
||||
WHERE id = ? AND status = 'queued' AND (leased_by IS NULL OR leased_at < ?)`,
|
||||
[workerId, timestamp, timestamp, row.id, staleBefore]
|
||||
);
|
||||
if (!Number(result?.changes || 0)) return null;
|
||||
state.lastClaimAt = timestamp;
|
||||
return { ...row, leased_by: workerId, leased_at: timestamp };
|
||||
});
|
||||
}
|
||||
|
||||
function clearLease(jobId) {
|
||||
dbRun(
|
||||
"UPDATE generation_jobs SET leased_by = NULL, leased_at = NULL, updated_at = ? WHERE id = ? AND leased_by = ?",
|
||||
[now(), jobId, workerId]
|
||||
);
|
||||
}
|
||||
|
||||
async function processClaimedJob(job) {
|
||||
try {
|
||||
const result = await executeGenerationJob(workerContext(job), job.id, { workerId, source: "local-worker" });
|
||||
state.lastCompletedAt = now();
|
||||
state.lastError = "";
|
||||
return { jobId: job.id, status: result.job?.status || "completed" };
|
||||
} catch (error) {
|
||||
state.lastFailedAt = now();
|
||||
state.lastError = String(error.message || error).slice(0, 500);
|
||||
const retryScheduled = scheduleRetry(job.id, state.lastError);
|
||||
return { jobId: job.id, status: retryScheduled ? "queued" : "failed", retryScheduled, error: state.lastError };
|
||||
} finally {
|
||||
clearLease(job.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function pump() {
|
||||
if (!enabled || state.pumping) return;
|
||||
state.pumping = true;
|
||||
state.lastPollAt = now();
|
||||
try {
|
||||
reclaimStaleLeases();
|
||||
while (state.inFlight.size < maxConcurrency) {
|
||||
const job = claimNextJob();
|
||||
if (!job) break;
|
||||
state.inFlight.add(job.id);
|
||||
const task = processClaimedJob(job);
|
||||
void task.finally(() => {
|
||||
state.inFlight.delete(job.id);
|
||||
updateWorkerHealth();
|
||||
});
|
||||
}
|
||||
updateWorkerHealth();
|
||||
} catch (error) {
|
||||
// SQLite can briefly reject BEGIN IMMEDIATE while another local process commits.
|
||||
// Keep the worker alive and let the next poll retry instead of taking down the API.
|
||||
state.lastError = String(error?.message || error).slice(0, 500);
|
||||
try {
|
||||
updateWorkerHealth("degraded");
|
||||
} catch {
|
||||
// A second lock while reporting health is still transient; the next poll retries.
|
||||
}
|
||||
} finally {
|
||||
state.pumping = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function workerStatus() {
|
||||
const heartbeatAt = state.lastHeartbeatAt || state.lastPollAt || state.startedAt;
|
||||
const heartbeatAgeMs = heartbeatAt ? Math.max(0, Date.now() - Date.parse(heartbeatAt)) : null;
|
||||
const stale = enabled && (heartbeatAgeMs === null || heartbeatAgeMs > staleAfterMs);
|
||||
return {
|
||||
...state,
|
||||
inFlight: state.inFlight.size,
|
||||
queueDepth: localQueueDepth(),
|
||||
staleAfterMs,
|
||||
lastHeartbeatAt: state.lastHeartbeatAt,
|
||||
heartbeatAgeMs,
|
||||
healthStatus: stale ? "stale" : enabled ? "ready" : "paused",
|
||||
stale,
|
||||
queueAlert: queueAlert(),
|
||||
timer: undefined,
|
||||
pumping: undefined
|
||||
};
|
||||
}
|
||||
|
||||
export async function runWorkerOnce() {
|
||||
if (!enabled) return { ...workerStatus(), dispatched: 0, disabled: true };
|
||||
reclaimStaleLeases();
|
||||
const job = claimNextJob();
|
||||
if (!job) {
|
||||
updateWorkerHealth();
|
||||
return { ...workerStatus(), dispatched: 0 };
|
||||
}
|
||||
state.inFlight.add(job.id);
|
||||
const result = await processClaimedJob(job);
|
||||
state.inFlight.delete(job.id);
|
||||
updateWorkerHealth();
|
||||
return { ...workerStatus(), dispatched: 1, result };
|
||||
}
|
||||
|
||||
export function startLocalWorker() {
|
||||
if (!enabled || state.timer) {
|
||||
updateWorkerHealth(enabled ? "ready" : "paused");
|
||||
return workerStatus();
|
||||
}
|
||||
updateWorkerHealth("ready");
|
||||
state.timer = setInterval(() => { void pump(); }, pollMs);
|
||||
void pump();
|
||||
return workerStatus();
|
||||
}
|
||||
|
||||
export function stopLocalWorker() {
|
||||
if (state.timer) clearInterval(state.timer);
|
||||
state.timer = null;
|
||||
updateWorkerHealth("paused");
|
||||
}
|
||||
Reference in New Issue
Block a user