import { dbAll, dbGet, dbRun, withTransaction } from "./db.mjs"; import { listSecurityEvents, listUserDevices, listUserSessions, resetMfa, resetPassword, revokeAllUserSessions, sessionIdentity } from "./auth.mjs"; import { createHash, randomBytes } from "node:crypto"; import { createPasswordRecord } from "./db.mjs"; export const DEFAULT_CONTEXT = { userId: "u-owner", organizationId: "org-studio-lab", workspaceId: "ws-local-aidrama", projectId: "" }; const SYSTEM_ONLY_PERMISSIONS = new Set([ "system:settings:view", "system:settings:edit", "feature_flag:manage", "api_client:manage", "notification:manage", "service:health:view" ]); const PROJECT_MUTATION_PERMISSIONS = new Set([ "script:edit", "asset:edit", "prompt:edit", "voice:edit", "voice:approve", "job:create", "job:prioritize", "delivery:approve" ]); export const API_CLIENT_SCOPE_CATALOG = [ { key: "jobs:read", label: "读取生成任务", description: "查看当前项目的生成任务和任务详情" }, { key: "jobs:write", label: "写入生成任务", description: "创建、执行、重试、取消和调整生成任务" }, { key: "models:read", label: "读取模型连接器", description: "查看当前组织工作区的模型连接器和 Runner" }, { key: "models:write", label: "管理模型连接器", description: "登记、修改和探测模型连接器" }, { key: "audit:read", label: "读取审计日志", description: "查看和导出当前组织范围的审计记录" } ]; export function normalizeApiClientScopes(value, fallback = ["jobs:read"]) { const requested = Array.isArray(value) && value.length ? value : fallback; const scopes = [...new Set(requested.map((scope) => String(scope || "").trim()).filter(Boolean))]; const allowed = new Set(API_CLIENT_SCOPE_CATALOG.map((scope) => scope.key)); return { scopes, invalid: scopes.filter((scope) => !allowed.has(scope)) }; } export function hasApiScope(context, scope) { return !context?.apiClient || context.apiClient.scopes.includes(scope); } export function requireApiScope(context, scope) { if (!context?.apiClient || context.apiClient.scopes.includes(scope)) return; throw httpError(403, "api_client_scope_denied", `API 客户端缺少 scope:${scope}`, { apiClientId: context.apiClient.id, requiredScope: scope, grantedScopes: context.apiClient.scopes }); } export function httpError(status, code, message, details = {}) { const error = new Error(message); error.status = status; error.code = code; error.details = details; return error; } function getHeader(headers, name) { const value = headers[name]; return Array.isArray(value) ? value[0] : value; } function firstOrNull(rows) { return rows[0] || null; } function parseJson(value, fallback) { try { return JSON.parse(value); } catch { return fallback; } } function canSeeAllWorkspaceData(organizationId, userId) { const row = dbGet( "SELECT role_key FROM organization_members WHERE organization_id = ? AND user_id = ? AND status = 'active'", [organizationId, userId] ); return row?.role_key === "org_owner" || row?.role_key === "org_admin"; } function accessibleWorkspaces(organizationId, userId) { if (canSeeAllWorkspaceData(organizationId, userId)) { return dbAll("SELECT * FROM workspaces WHERE organization_id = ? AND status = 'active' ORDER BY created_at ASC", [organizationId]); } return dbAll( `SELECT w.* FROM workspaces w JOIN workspace_members wm ON wm.workspace_id = w.id WHERE w.organization_id = ? AND wm.user_id = ? AND wm.status = 'active' AND w.status = 'active' ORDER BY w.created_at ASC`, [organizationId, userId] ); } function accessibleProjects(workspaceId, userId, organizationId) { if (canSeeAllWorkspaceData(organizationId, userId)) { return dbAll("SELECT * FROM projects WHERE workspace_id = ? ORDER BY CASE WHEN status = 'archived' THEN 1 ELSE 0 END, updated_at DESC", [workspaceId]); } return dbAll( `SELECT DISTINCT p.* FROM projects p LEFT JOIN project_members pm ON pm.project_id = p.id AND pm.user_id = ? AND pm.status = 'active' JOIN workspace_members wm ON wm.workspace_id = p.workspace_id AND wm.user_id = ? AND wm.status = 'active' WHERE p.workspace_id = ? AND (wm.access_mode IS NULL OR wm.access_mode = 'all' OR (wm.access_mode = 'project-only' AND pm.user_id IS NOT NULL)) ORDER BY CASE WHEN p.status = 'archived' THEN 1 ELSE 0 END, p.updated_at DESC`, [userId, userId, workspaceId] ); } function ensureUser(userId) { const user = dbGet("SELECT * FROM users WHERE id = ?", [userId]); if (!user || user.status !== "active") { throw httpError(401, "user_not_active", "当前用户不存在或未激活", { userId }); } return user; } function ensureOrganization(userId, organizationId) { const organization = dbGet("SELECT * FROM organizations WHERE id = ? AND status = 'active'", [organizationId]); if (!organization) { throw httpError(404, "organization_not_found", "组织不存在或已停用", { organizationId }); } const membership = dbGet( `SELECT om.*, r.name AS role_name, r.scope AS role_scope FROM organization_members om LEFT JOIN roles r ON r.key = om.role_key WHERE om.organization_id = ? AND om.user_id = ? AND om.status = 'active'`, [organizationId, userId] ); if (!membership) { throw httpError(403, "organization_forbidden", "当前用户不是该组织成员", { organizationId, userId }); } return { organization, membership }; } function ensureWorkspace(userId, organization, workspaceId) { const workspace = dbGet("SELECT * FROM workspaces WHERE id = ? AND organization_id = ? AND status = 'active'", [workspaceId, organization.id]); if (!workspace) { throw httpError(404, "workspace_not_found", "工作区不存在或不属于当前组织", { workspaceId }); } const membership = dbGet( `SELECT wm.*, r.name AS role_name, r.scope AS role_scope FROM workspace_members wm LEFT JOIN roles r ON r.key = wm.role_key WHERE wm.workspace_id = ? AND wm.user_id = ? AND wm.status = 'active'`, [workspaceId, userId] ); const orgElevated = canSeeAllWorkspaceData(organization.id, userId); if (!membership && !orgElevated) { throw httpError(403, "workspace_forbidden", "当前用户没有该工作区访问权", { workspaceId, userId }); } return { workspace, membership, orgElevated }; } function ensureProject(userId, organization, workspace, projectId) { if (!projectId) return { project: null, membership: null }; const project = dbGet("SELECT * FROM projects WHERE id = ? AND workspace_id = ?", [projectId, workspace.id]); if (!project) { throw httpError(404, "project_not_found", "项目不存在或不属于当前工作区", { projectId }); } const membership = dbGet( `SELECT pm.*, r.name AS role_name, r.scope AS role_scope FROM project_members pm LEFT JOIN roles r ON r.key = pm.role_key WHERE pm.project_id = ? AND pm.user_id = ? AND pm.status = 'active'`, [projectId, userId] ); const workspaceMember = dbGet("SELECT * FROM workspace_members WHERE workspace_id = ? AND user_id = ? AND status = 'active'", [workspace.id, userId]); const workspaceCanSeeProject = workspaceMember && (workspaceMember.access_mode !== "project-only" || Boolean(membership)); if (!membership && !workspaceCanSeeProject && !canSeeAllWorkspaceData(organization.id, userId)) { throw httpError(403, "project_forbidden", "当前用户没有该项目访问权", { projectId, userId }); } return { project, membership }; } export function resolveContext(headers, searchParams = new URLSearchParams()) { const identity = sessionIdentity(headers); const hasSessionToken = Boolean(getHeader(headers, "authorization") || getHeader(headers, "x-session-token")); const allowDevContext = process.env.AI_DRAMA_ALLOW_DEV_CONTEXT === "1"; if (hasSessionToken && !identity) throw httpError(401, "auth_required", "需要有效登录会话"); const userId = identity?.userId || (allowDevContext ? getHeader(headers, "x-user-id") || searchParams.get("userId") || DEFAULT_CONTEXT.userId : null); if (!userId) throw httpError(401, "auth_required", "请先登录"); const organizationHint = getHeader(headers, "x-organization-id") || searchParams.get("organizationId"); const clientOrganizationId = identity?.apiClient?.organizationId || null; const clientWorkspaceId = identity?.apiClient?.workspaceId || null; if (clientOrganizationId && organizationHint && clientOrganizationId !== organizationHint) throw httpError(403, "api_client_scope_mismatch", "API 客户端不能访问其他组织"); if (clientWorkspaceId && (getHeader(headers, "x-workspace-id") || searchParams.get("workspaceId")) && clientWorkspaceId !== (getHeader(headers, "x-workspace-id") || searchParams.get("workspaceId"))) throw httpError(403, "api_client_scope_mismatch", "API 客户端不能访问其他工作区"); const organizationId = clientOrganizationId || organizationHint || dbGet("SELECT organization_id FROM organization_members WHERE user_id = ? AND status = 'active' ORDER BY joined_at ASC LIMIT 1", [userId])?.organization_id || DEFAULT_CONTEXT.organizationId; const workspaceHint = getHeader(headers, "x-workspace-id") || searchParams.get("workspaceId"); const projectHint = getHeader(headers, "x-project-id") || searchParams.get("projectId"); const user = ensureUser(userId); const { organization, membership: organizationMembership } = ensureOrganization(userId, organizationId); const workspaces = accessibleWorkspaces(organization.id, userId); const workspaceId = clientWorkspaceId || workspaceHint || firstOrNull(workspaces)?.id; if (!workspaceId) { throw httpError(403, "workspace_missing", "当前组织没有可访问的工作区", { organizationId }); } const { workspace, membership: workspaceMembership, orgElevated } = ensureWorkspace(userId, organization, workspaceId); const projects = accessibleProjects(workspace.id, userId, organization.id); const projectId = projectHint || firstOrNull(projects)?.id || null; const { project, membership: projectMembership } = ensureProject(userId, organization, workspace, projectId); const roles = [organizationMembership, workspaceMembership, projectMembership].filter(Boolean).map((item) => ({ key: item.role_key, name: item.role_name || item.role_key, scope: item.role_scope || "unknown" })); const systemAdmin = !identity?.apiClient && Boolean(dbGet("SELECT user_id FROM system_admins WHERE user_id = ? AND status = 'active'", [user.id])); const permissionRows = dbAll( `SELECT DISTINCT rp.permission_key FROM role_permissions rp JOIN roles r ON r.key = rp.role_key WHERE rp.role_key IN (${roles.length ? roles.map(() => "?").join(",") : "''"})`, roles.map((role) => role.key) ); const effectivePermissionKeys = new Set(permissionRows.map((row) => row.permission_key)); if (!systemAdmin && roles.length) { const overrides = dbAll( `SELECT role_key, permission_key, effect FROM organization_role_permissions WHERE organization_id = ? AND role_key IN (${roles.map(() => "?").join(",")})`, [organization.id, ...roles.map((role) => role.key)] ); for (const override of overrides) { if (override.effect === "grant") effectivePermissionKeys.add(override.permission_key); if (override.effect === "revoke") effectivePermissionKeys.delete(override.permission_key); } } let permissions = [...effectivePermissionKeys].filter((permission) => systemAdmin || !SYSTEM_ONLY_PERMISSIONS.has(permission)); if (identity?.apiClient) { const scopes = new Set(identity.apiClient.scopes || []); const apiPermissions = new Set(); if (scopes.has("jobs:write")) { apiPermissions.add("job:create"); apiPermissions.add("job:prioritize"); } if (scopes.has("models:write")) apiPermissions.add("model:manage"); if (scopes.has("audit:read")) apiPermissions.add("audit:view"); permissions = permissions.filter((permission) => apiPermissions.has(permission)); } return { user, organization, workspace, project, organizationMembership, workspaceMembership, projectMembership, roles, permissions, orgElevated, workspaces, projects, systemAdmin, apiClient: identity?.apiClient || null }; } export function hasPermission(context, permission) { return context.permissions.includes(permission); } export function requirePermission(context, permission) { if (!hasPermission(context, permission)) { throw httpError(403, "permission_denied", `缺少权限:${permission}`, { permission, roles: context.roles }); } if (PROJECT_MUTATION_PERMISSIONS.has(permission)) requireProjectWritable(context); } export function requireProjectWritable(context) { if (!context?.project) throw httpError(400, "project_required", "该操作必须绑定项目"); if (context.project.status === "archived") { throw httpError(409, "project_archived", "项目已归档,当前仅支持查看、审计和恢复项目", { projectId: context.project.id, archivedAt: context.project.archived_at || null, archivedFromStatus: context.project.archived_from_status || null }); } return context.project; } export function orgMembers(organizationId) { return dbAll( `SELECT om.id, om.user_id, u.display_name, u.email, u.avatar_color, om.role_key, r.name AS role_name, om.status, om.joined_at FROM organization_members om JOIN users u ON u.id = om.user_id LEFT JOIN roles r ON r.key = om.role_key WHERE om.organization_id = ? ORDER BY CASE om.status WHEN 'active' THEN 0 ELSE 1 END, u.display_name`, [organizationId] ); } export function workspaceMembers(workspaceId) { return dbAll( `SELECT wm.id, wm.user_id, u.display_name, u.email, u.avatar_color, wm.role_key, r.name AS role_name, wm.access_mode, wm.status FROM workspace_members wm JOIN users u ON u.id = wm.user_id LEFT JOIN roles r ON r.key = wm.role_key WHERE wm.workspace_id = ? ORDER BY u.display_name`, [workspaceId] ); } export function projectMembers(projectId) { return dbAll( `SELECT pm.id, pm.user_id, u.display_name, u.email, u.avatar_color, pm.role_key, r.name AS role_name, pm.status FROM project_members pm JOIN users u ON u.id = pm.user_id LEFT JOIN roles r ON r.key = pm.role_key WHERE pm.project_id = ? ORDER BY u.display_name`, [projectId] ); } export function pendingInvitations(organizationId) { const rows = dbAll( `SELECT i.id, i.organization_id, i.workspace_id, i.project_id, i.email, i.role_key, i.invited_by, i.status, i.expires_at, i.created_at, i.token_hint, i.accepted_user_id, i.accepted_at, i.revoked_at, w.name AS workspace_name, p.name AS project_name, u.display_name AS inviter_name, r.name AS role_name FROM invitations i 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 i.status = 'pending' ORDER BY i.created_at DESC`, [organizationId] ); const active = []; for (const row of rows) { if (new Date(row.expires_at).getTime() <= Date.now()) { dbRun("UPDATE invitations SET status = 'expired' WHERE id = ? AND status = 'pending'", [row.id]); } else { active.push(row); } } return active; } function adminInvitationRow(organizationId, invitationId) { return dbGet( `SELECT i.id, i.organization_id, i.workspace_id, i.project_id, i.email, i.role_key, i.invited_by, i.status, i.expires_at, i.created_at, i.token_hint, i.accepted_user_id, i.accepted_at, i.revoked_at, w.name AS workspace_name, p.name AS project_name, u.display_name AS inviter_name, r.name AS role_name FROM invitations i 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 i.id = ?`, [organizationId, invitationId] ); } function adminInvitationPayload(row, inviteToken = "") { if (!row) return null; const payload = { ...row }; if (inviteToken) { payload.inviteToken = inviteToken; payload.acceptUrl = `/register?invite=${encodeURIComponent(inviteToken)}`; } return payload; } export function resendOrganizationInvitation(context, organizationId, invitationId) { if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能操作其他组织的邀请", { organizationId }); requirePermission(context, "organization:members:invite"); const current = adminInvitationRow(organizationId, invitationId); if (!current) throw httpError(404, "invitation_not_found", "组织邀请不存在", { invitationId }); if (!["pending", "expired"].includes(current.status)) throw httpError(409, "invitation_not_pending", "只有待处理或已过期邀请可以重新发送", { status: current.status }); const activeMember = dbGet( `SELECT om.user_id FROM organization_members om JOIN users u ON u.id = om.user_id WHERE om.organization_id = ? AND lower(u.email) = lower(?) AND om.status = 'active'`, [organizationId, current.email] ); if (activeMember) throw httpError(409, "invitation_recipient_already_member", "该邮箱已经是组织成员", { email: current.email }); const inviteToken = `invite-${randomBytes(24).toString("base64url")}`; const timestamp = new Date().toISOString(); const expiresAt = new Date(Date.now() + 7 * 86400000).toISOString(); dbRun( "UPDATE invitations SET status = 'pending', expires_at = ?, token_hash = ?, token_hint = ?, revoked_at = NULL WHERE id = ? AND organization_id = ?", [expiresAt, invitationTokenHash(inviteToken), inviteToken.slice(0, 14), invitationId, organizationId] ); addAudit({ context, action: "organization.invitation.resent", targetType: "invitation", targetId: invitationId, metadata: { email: current.email, previousStatus: current.status, expiresAt, sentAt: timestamp } }); return { invitation: adminInvitationPayload(adminInvitationRow(organizationId, invitationId), inviteToken) }; } export function revokeOrganizationInvitation(context, organizationId, invitationId) { if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能操作其他组织的邀请", { organizationId }); requirePermission(context, "organization:members:invite"); const current = adminInvitationRow(organizationId, invitationId); if (!current) throw httpError(404, "invitation_not_found", "组织邀请不存在", { invitationId }); if (current.status !== "pending") throw httpError(409, "invitation_not_pending", "只有待处理邀请可以撤销", { status: current.status }); const timestamp = new Date().toISOString(); dbRun("UPDATE invitations SET status = 'revoked', revoked_at = ?, token_hash = NULL WHERE id = ? AND organization_id = ? AND status = 'pending'", [timestamp, invitationId, organizationId]); addAudit({ context, action: "organization.invitation.revoked", targetType: "invitation", targetId: invitationId, metadata: { email: current.email, previousStatus: current.status, revokedAt: timestamp } }); return { invitation: adminInvitationPayload(adminInvitationRow(organizationId, invitationId)) }; } export function userInvitations(email) { const rows = dbAll( `SELECT i.id, i.organization_id, i.workspace_id, i.project_id, i.email, i.role_key, i.invited_by, i.status, i.expires_at, i.created_at, i.token_hint, i.accepted_user_id, i.accepted_at, i.revoked_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 lower(i.email) = lower(?) AND i.status = 'pending' ORDER BY i.created_at DESC`, [email] ); const active = []; for (const row of rows) { if (new Date(row.expires_at).getTime() <= Date.now()) { dbRun("UPDATE invitations SET status = 'expired' WHERE id = ? AND status = 'pending'", [row.id]); } else { active.push(row); } } return active; } function invitationTokenHash(token) { return createHash("sha256").update(String(token || "")).digest("hex"); } function invitationPayload(row) { if (!row) return null; return { id: row.id, organizationId: row.organization_id, organizationName: row.organization_name, workspaceId: row.workspace_id, workspaceName: row.workspace_name, projectId: row.project_id, projectName: row.project_name, email: row.email, roleKey: row.role_key, roleName: row.role_name, expiresAt: row.expires_at, inviterName: row.inviter_name }; } export function previewInvitation(token) { const tokenValue = String(token || "").trim(); if (tokenValue.length < 24) throw httpError(400, "invitation_token_invalid", "邀请注册链接无效"); const row = dbGet( `SELECT i.*, o.name AS organization_name, w.name AS workspace_name, p.name AS project_name, r.name AS role_name, u.display_name AS inviter_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 roles r ON r.key = i.role_key LEFT JOIN users u ON u.id = i.invited_by WHERE i.token_hash = ? AND i.status = 'pending'`, [invitationTokenHash(tokenValue)] ); if (!row) throw httpError(404, "invitation_not_found", "邀请注册链接不存在、已使用或已撤销"); if (new Date(row.expires_at).getTime() <= Date.now()) { dbRun("UPDATE invitations SET status = 'expired' WHERE id = ? AND status = 'pending'", [row.id]); throw httpError(410, "invitation_expired", "邀请注册链接已过期"); } return invitationPayload(row); } function invitationRoleForWorkspace(roleKey) { if (["producer", "writer", "art_director", "voice_editor", "reviewer"].includes(roleKey)) return roleKey; // Project invitations need a workspace membership for context resolution, // but must not inherit workspace-wide production permissions. return "project_guest"; } function applyInvitationMembership(invitation, userId, timestamp) { assertOrganizationSeatAvailable(invitation.organization_id, { userId }); const organizationRole = ["org_owner", "org_admin", "org_member"].includes(invitation.role_key) ? invitation.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-${invitation.organization_id}-${userId}`, invitation.organization_id, userId, organizationRole, timestamp, timestamp, timestamp] ); if (invitation.workspace_id) { const accessMode = invitation.project_id ? "project-only" : "all"; dbRun( "INSERT INTO workspace_members(id, workspace_id, user_id, role_key, access_mode, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'active', ?, ?) ON CONFLICT(workspace_id, user_id) DO UPDATE SET role_key = excluded.role_key, access_mode = CASE WHEN workspace_members.access_mode = 'all' THEN 'all' ELSE excluded.access_mode END, status = 'active', updated_at = excluded.updated_at", [`wm-${invitation.workspace_id}-${userId}`, invitation.workspace_id, userId, invitationRoleForWorkspace(invitation.role_key), accessMode, timestamp, timestamp] ); } if (invitation.project_id) { const projectRole = ["project_editor", "project_viewer"].includes(invitation.role_key) ? invitation.role_key : "project_editor"; dbRun( "INSERT INTO project_members(id, project_id, user_id, role_key, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'active', ?, ?) ON CONFLICT(project_id, user_id) DO UPDATE SET role_key = excluded.role_key, status = 'active', updated_at = excluded.updated_at", [`pm-${invitation.project_id}-${userId}`, invitation.project_id, userId, projectRole, timestamp, timestamp] ); } } export function registerInvitedUser({ inviteToken, displayName, password }) { const tokenValue = String(inviteToken || "").trim(); if (tokenValue.length < 24) throw httpError(400, "invitation_token_invalid", "邀请注册链接无效"); const name = String(displayName || "").trim(); if (name.length < 2 || name.length > 80) throw httpError(400, "display_name_invalid", "姓名长度应为 2 到 80 个字符"); const passwordValue = String(password || ""); if (passwordValue.length < 10) throw httpError(400, "password_weak", "密码至少需要 10 个字符"); const invitation = dbGet("SELECT * FROM invitations WHERE token_hash = ? AND status = 'pending'", [invitationTokenHash(tokenValue)]); if (!invitation) throw httpError(404, "invitation_not_found", "邀请注册链接不存在、已使用或已撤销"); if (new Date(invitation.expires_at).getTime() <= Date.now()) { dbRun("UPDATE invitations SET status = 'expired' WHERE id = ? AND status = 'pending'", [invitation.id]); throw httpError(410, "invitation_expired", "邀请注册链接已过期"); } const normalizedEmail = invitation.email.toLowerCase(); const existing = dbGet("SELECT * FROM users WHERE lower(email) = lower(?)", [normalizedEmail]); if (existing?.status === "active") throw httpError(409, "account_exists_use_login", "该邮箱已有账号,请先登录后在账号安全页接受邀请"); if (existing?.status === "suspended") throw httpError(403, "user_suspended", "该邮箱对应的账号已被停用"); const userId = existing?.id || `u-${Date.now()}-${randomBytes(4).toString("hex")}`; const timestamp = new Date().toISOString(); const credentials = createPasswordRecord(passwordValue); withTransaction(() => { if (existing) { dbRun("UPDATE users SET display_name = ?, status = 'active', updated_at = ? WHERE id = ?", [name, timestamp, existing.id]); dbRun("INSERT INTO user_credentials(user_id, password_salt, password_hash, created_at, updated_at) VALUES (?, ?, ?, ?, ?) 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", [existing.id, credentials.salt, credentials.hash, timestamp, timestamp]); } else { dbRun("INSERT INTO users(id, display_name, email, avatar_color, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'active', ?, ?)", [userId, name, normalizedEmail, "#3f7f87", timestamp, timestamp]); dbRun("INSERT INTO user_credentials(user_id, password_salt, password_hash, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", [userId, credentials.salt, credentials.hash, timestamp, timestamp]); } applyInvitationMembership(invitation, userId, timestamp); dbRun("UPDATE invitations SET status = 'accepted', accepted_user_id = ?, accepted_at = ?, token_hash = NULL WHERE id = ?", [userId, timestamp, invitation.id]); }); return { user: dbGet("SELECT id, display_name, email, avatar_color, status, created_at, updated_at FROM users WHERE id = ?", [userId]), invitation: dbGet("SELECT id, organization_id, workspace_id, project_id, email, role_key, status, expires_at, accepted_user_id, accepted_at FROM invitations WHERE id = ?", [invitation.id]) }; } export function acceptInvitation(context, invitationId) { const invitation = dbGet("SELECT * FROM invitations WHERE id = ? AND lower(email) = lower(?) AND status = 'pending'", [invitationId, context.user.email]); if (!invitation) throw httpError(404, "invitation_not_found", "邀请不存在、已处理或邮箱不匹配"); if (new Date(invitation.expires_at).getTime() <= Date.now()) { dbRun("UPDATE invitations SET status = 'expired' WHERE id = ?", [invitationId]); throw httpError(410, "invitation_expired", "邀请已过期"); } const timestamp = new Date().toISOString(); const organizationRole = ["org_owner", "org_admin", "org_member"].includes(invitation.role_key) ? invitation.role_key : "org_member"; withTransaction(() => { 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-${invitation.organization_id}-${context.user.id}`, invitation.organization_id, context.user.id, organizationRole, timestamp, timestamp, timestamp]); applyInvitationMembership(invitation, context.user.id, timestamp); dbRun("UPDATE invitations SET status = 'accepted', accepted_user_id = ?, accepted_at = ?, token_hash = NULL WHERE id = ?", [context.user.id, timestamp, invitationId]); }); addAudit({ context: { ...context, organization: { id: invitation.organization_id }, workspace: invitation.workspace_id ? { id: invitation.workspace_id } : null, project: invitation.project_id ? { id: invitation.project_id } : null }, action: "organization.invitation.accepted", targetType: "invitation", targetId: invitationId, metadata: { roleKey: invitation.role_key } }); return dbGet("SELECT * FROM invitations WHERE id = ?", [invitationId]); } export function billingAccount(organizationId) { const row = dbGet("SELECT * FROM billing_accounts WHERE organization_id = ?", [organizationId]); if (!row) return null; return { ...row, billing_cycle: row.billing_cycle || "monthly", currency: row.currency || "CNY", base_fee: Number(row.base_fee || 0), seat_unit_price: Number(row.seat_unit_price || 0), storage_unit_price: Number(row.storage_unit_price || 0), clip_unit_price: Number(row.clip_unit_price || 0), quota_warning_percent: Number(row.quota_warning_percent || 80), local_runner_only: Boolean(row.local_runner_only), cloud_connectors_require_approval: Boolean(row.cloud_connectors_require_approval) }; } function billingHistory(organizationId) { return dbAll( `SELECT e.*, u.display_name AS actor_name FROM billing_account_events e LEFT JOIN users u ON u.id = e.actor_user_id WHERE e.organization_id = ? ORDER BY e.created_at DESC LIMIT 24`, [organizationId] ).map((row) => ({ ...row, previous: parseJson(row.previous_json, {}), next: parseJson(row.next_json, {}) })); } function costCenterCodeForUsage(row) { const metadata = parseJson(row.metadata_json, {}); if (metadata.costCenter) return String(metadata.costCenter); const kind = String(row.kind || "").toLowerCase(); if (kind.includes("storage") || kind.includes("asset") || kind.includes("delivery") || kind.includes("export")) return "storage"; if (kind.includes("image") || kind.includes("video") || kind.includes("i2v") || kind.includes("tts") || kind.includes("asr") || kind.includes("generation")) return "local-gpu"; return "operations"; } function organizationCostCenters(organizationId) { const centers = dbAll("SELECT * FROM cost_centers WHERE organization_id = ? ORDER BY status, name", [organizationId]); const workspaces = Object.fromEntries(dbAll("SELECT id, name FROM workspaces WHERE organization_id = ?", [organizationId]).map((row) => [row.id, row.name])); const rows = dbAll("SELECT workspace_id, project_id, kind, units, estimated_cost, metadata_json, created_at FROM usage_events WHERE organization_id = ? AND created_at >= datetime('now', 'start of month') ORDER BY created_at DESC", [organizationId]); const grouped = new Map(); for (const row of rows) { const code = costCenterCodeForUsage(row); const key = `${code}:${row.workspace_id || "organization"}`; const current = grouped.get(key) || { code, workspaceId: row.workspace_id || null, workspaceName: workspaces[row.workspace_id] || "组织级", units: 0, cost: 0, events: 0 }; current.units += Number(row.units || 0); current.cost += Number(row.estimated_cost || 0); current.events += 1; grouped.set(key, current); } return { centers: centers.map((center) => { const details = [...grouped.values()].filter((item) => item.code === center.code); const used = details.reduce((sum, item) => sum + item.cost, 0); const budget = Number(center.monthly_budget || 0); return { ...center, monthly_budget: budget, used: used, remaining: Math.max(0, budget - used), utilization: budget ? Number(((used / budget) * 100).toFixed(2)) : 0 }; }), detail: [...grouped.values()].sort((a, b) => b.cost - a.cost) }; } function organizationUsageTrend(organizationId, days = 31) { const limit = Math.min(90, Math.max(7, Number(days || 31))); return dbAll( `SELECT substr(created_at, 1, 10) AS day, SUM(units) AS units, SUM(estimated_cost) AS estimated_cost, COUNT(*) AS events FROM usage_events WHERE organization_id = ? AND created_at >= datetime('now', ?) GROUP BY substr(created_at, 1, 10) ORDER BY day ASC`, [organizationId, `-${limit} days`] ).map((row) => ({ day: row.day, units: Number(row.units || 0), estimatedCost: Number(row.estimated_cost || 0), events: Number(row.events || 0) })); } function quotaWarnings(organizationId, billing, quotas, seat) { const threshold = Math.min(99, Math.max(50, Number(billing?.quota_warning_percent || 80))); const warnings = []; if (seat.limit && seat.utilization >= threshold) warnings.push({ scope: "organization", metric: "seat", label: "组织席位", used: seat.reserved, limit: seat.limit, utilization: seat.utilization, threshold, status: seat.reserved >= seat.limit ? "critical" : "warning" }); for (const quota of quotas) { const limit = Number(quota.limitValue || 0); const used = Number(quota.usedValue || 0); const utilization = limit ? Number(((used / limit) * 100).toFixed(2)) : 0; if (limit && utilization >= threshold) warnings.push({ scope: quota.workspaceId ? "workspace" : "organization", workspaceId: quota.workspaceId, workspaceName: quota.workspaceName, metric: quota.metric, label: quota.metric === "clip" ? "生成片段" : quota.metric === "storage" ? "存储" : quota.metric, used, limit, utilization, threshold, status: used >= limit ? "critical" : "warning" }); } return warnings; } export function organizationSeatSummary(organizationId) { const billing = billingAccount(organizationId); const activeMembers = Number(dbGet("SELECT COUNT(DISTINCT user_id) AS count FROM organization_members WHERE organization_id = ? AND status = 'active'", [organizationId])?.count || 0); const pendingInvitations = Number(dbGet("SELECT COUNT(*) AS count FROM invitations WHERE organization_id = ? AND status = 'pending' AND julianday(expires_at) > julianday('now')", [organizationId])?.count || 0); const entitlement = dbGet("SELECT limit_value, enabled, enforcement FROM organization_entitlements WHERE organization_id = ? AND entitlement_key = 'limit.seats'", [organizationId]); const billingLimit = Number(billing?.seat_limit || 0); const entitlementLimit = entitlement && Boolean(entitlement.enabled) && entitlement.enforcement === "block" ? Number(entitlement.limit_value || 0) : 0; const seatLimit = billingLimit && entitlementLimit ? Math.min(billingLimit, entitlementLimit) : billingLimit || entitlementLimit; return { limit: seatLimit, active: activeMembers, pending: pendingInvitations, reserved: activeMembers + pendingInvitations, remaining: Math.max(0, seatLimit - activeMembers - pendingInvitations), utilization: seatLimit ? Number(((activeMembers / seatLimit) * 100).toFixed(2)) : 0 }; } export function assertOrganizationSeatAvailable(organizationId, { userId = "", email = "" } = {}) { const existingUserId = userId || dbGet("SELECT id FROM users WHERE lower(email) = lower(?)", [String(email || "").trim()])?.id || ""; if (existingUserId && dbGet("SELECT 1 FROM organization_members WHERE organization_id = ? AND user_id = ? AND status = 'active'", [organizationId, existingUserId])) return organizationSeatSummary(organizationId); const summary = organizationSeatSummary(organizationId); if (summary.limit && summary.reserved >= summary.limit) { throw httpError(409, "seat_limit_reached", "组织席位已用尽,请先提升席位额度或清理待处理邀请", { seatLimit: summary.limit, activeMembers: summary.active, pendingInvitations: summary.pending }); } return summary; } function organizationQuotaRows(organizationId) { return dbAll( `SELECT q.*, w.name AS workspace_name, w.slug AS workspace_slug FROM quota_allocations q LEFT JOIN workspaces w ON w.id = q.workspace_id WHERE q.organization_id = ? ORDER BY CASE q.metric WHEN 'clip' THEN 0 WHEN 'storage' THEN 1 ELSE 2 END, w.name, q.metric`, [organizationId] ).map((row) => ({ ...row, workspaceId: row.workspace_id, workspaceName: row.workspace_name || "组织级", metric: row.metric, limitValue: Number(row.limit_value || 0), usedValue: Number(row.used_value || 0), remainingValue: Math.max(0, Number(row.limit_value || 0) - Number(row.used_value || 0)) })); } export function subscriptionPlanTemplates(options = {}) { const includeArchived = Boolean(options.includeArchived); return dbAll( `SELECT * FROM subscription_plan_templates ${includeArchived ? "" : "WHERE status = 'active'"} ORDER BY CASE tier_key WHEN 'starter-local' THEN 0 WHEN 'studio-local' THEN 1 WHEN 'enterprise-private' THEN 2 ELSE 3 END, name` ).map((row) => ({ id: row.id, tierKey: row.tier_key, name: row.name, description: row.description || "", billingCycle: row.billing_cycle || "monthly", currency: row.currency || "CNY", baseFee: Number(row.base_fee || 0), seatLimit: Number(row.seat_limit || 0), storageGb: Number(row.storage_gb || 0), monthlyClipQuota: Number(row.monthly_clip_quota || 0), limits: parseJson(row.limits_json, {}), features: parseJson(row.features_json, {}), connectorPolicy: parseJson(row.connector_policy_json, {}), supportSla: row.support_sla || "", status: row.status || "active", updatedAt: row.updated_at })); } function normalizePlanTemplateBody(body = {}, current = null) { const tierKey = String(body.tierKey ?? body.tier_key ?? current?.tier_key ?? "").trim().toLowerCase(); if (!tierKey || !/^[a-z0-9][a-z0-9-]{1,60}$/.test(tierKey)) throw httpError(400, "plan_tier_key_invalid", "套餐 tierKey 只能使用小写字母、数字和连字符"); const name = String(body.name ?? current?.name ?? "").trim().slice(0, 80); if (name.length < 2) throw httpError(400, "plan_name_required", "套餐名称至少需要 2 个字符"); const billingCycle = String(body.billingCycle ?? body.billing_cycle ?? current?.billing_cycle ?? "monthly").trim(); if (!["monthly", "quarterly", "annual"].includes(billingCycle)) throw httpError(400, "plan_billing_cycle_invalid", "账单周期必须是 monthly、quarterly 或 annual"); const status = String(body.status ?? current?.status ?? "active").trim(); if (!["active", "archived"].includes(status)) throw httpError(400, "plan_status_invalid", "套餐状态必须是 active 或 archived"); const numberField = (camelKey, snakeKey, fallback, { integer = true, min = 0 } = {}) => { const raw = body[camelKey] ?? body[snakeKey] ?? fallback; const value = integer ? Math.floor(Number(raw)) : Number(raw); if (!Number.isFinite(value) || value < min) throw httpError(400, `plan_${snakeKey}_invalid`, `${camelKey} 必须是大于或等于 ${min} 的数字`); return value; }; const objectField = (camelKey, snakeKey, fallback) => { const raw = body[camelKey] ?? body[snakeKey]; if (raw === undefined) return parseJson(fallback || "{}", {}); if (typeof raw === "string") return parseJson(raw, {}); return objectValue(raw); }; return { tierKey, name, description: String(body.description ?? current?.description ?? "").trim().slice(0, 500), billingCycle, currency: String(body.currency ?? current?.currency ?? "CNY").trim().toUpperCase().slice(0, 3) || "CNY", baseFee: numberField("baseFee", "base_fee", current?.base_fee || 0, { integer: false, min: 0 }), seatLimit: numberField("seatLimit", "seat_limit", current?.seat_limit || 1, { integer: true, min: 1 }), storageGb: numberField("storageGb", "storage_gb", current?.storage_gb || 1, { integer: true, min: 1 }), monthlyClipQuota: numberField("monthlyClipQuota", "monthly_clip_quota", current?.monthly_clip_quota || 1, { integer: true, min: 1 }), limits: objectField("limits", "limits_json", current?.limits_json), features: objectField("features", "features_json", current?.features_json), connectorPolicy: objectField("connectorPolicy", "connector_policy_json", current?.connector_policy_json), supportSla: String(body.supportSla ?? body.support_sla ?? current?.support_sla ?? "").trim().slice(0, 120), status }; } export function createSubscriptionPlanTemplate(context, body = {}) { requirePermission(context, "system:settings:edit"); const plan = normalizePlanTemplateBody(body); const timestamp = new Date().toISOString(); const id = `plan-${plan.tierKey}`; dbRun( `INSERT INTO subscription_plan_templates(id, tier_key, name, description, billing_cycle, currency, base_fee, seat_limit, storage_gb, monthly_clip_quota, limits_json, features_json, connector_policy_json, support_sla, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(tier_key) DO UPDATE SET name = excluded.name, description = excluded.description, billing_cycle = excluded.billing_cycle, currency = excluded.currency, base_fee = excluded.base_fee, seat_limit = excluded.seat_limit, storage_gb = excluded.storage_gb, monthly_clip_quota = excluded.monthly_clip_quota, limits_json = excluded.limits_json, features_json = excluded.features_json, connector_policy_json = excluded.connector_policy_json, support_sla = excluded.support_sla, status = excluded.status, updated_at = excluded.updated_at`, [id, plan.tierKey, plan.name, plan.description, plan.billingCycle, plan.currency, plan.baseFee, plan.seatLimit, plan.storageGb, plan.monthlyClipQuota, JSON.stringify(plan.limits), JSON.stringify(plan.features), JSON.stringify(plan.connectorPolicy), plan.supportSla, plan.status, timestamp, timestamp] ); addAudit({ context, action: "system.plan_template.upserted", targetType: "subscription_plan_template", targetId: plan.tierKey, metadata: { plan } }); const planTemplates = subscriptionPlanTemplates({ includeArchived: true }); return { planTemplate: planTemplates.find((item) => item.tierKey === plan.tierKey), planTemplates }; } export function updateSubscriptionPlanTemplate(context, planId, body = {}) { requirePermission(context, "system:settings:edit"); const current = dbGet("SELECT * FROM subscription_plan_templates WHERE id = ? OR tier_key = ?", [planId, planId]); if (!current) throw httpError(404, "plan_template_not_found", "套餐模板不存在", { planId }); const plan = normalizePlanTemplateBody(body, current); const collision = dbGet("SELECT id FROM subscription_plan_templates WHERE tier_key = ? AND id != ?", [plan.tierKey, current.id]); if (collision) throw httpError(409, "plan_tier_key_exists", "套餐 tierKey 已被其他模板使用", { tierKey: plan.tierKey }); const timestamp = new Date().toISOString(); dbRun( `UPDATE subscription_plan_templates SET tier_key = ?, name = ?, description = ?, billing_cycle = ?, currency = ?, base_fee = ?, seat_limit = ?, storage_gb = ?, monthly_clip_quota = ?, limits_json = ?, features_json = ?, connector_policy_json = ?, support_sla = ?, status = ?, updated_at = ? WHERE id = ?`, [plan.tierKey, plan.name, plan.description, plan.billingCycle, plan.currency, plan.baseFee, plan.seatLimit, plan.storageGb, plan.monthlyClipQuota, JSON.stringify(plan.limits), JSON.stringify(plan.features), JSON.stringify(plan.connectorPolicy), plan.supportSla, plan.status, timestamp, current.id] ); addAudit({ context, action: "system.plan_template.updated", targetType: "subscription_plan_template", targetId: current.id, metadata: { previous: { tierKey: current.tier_key, name: current.name, status: current.status }, next: plan } }); const planTemplates = subscriptionPlanTemplates({ includeArchived: true }); return { planTemplate: planTemplates.find((item) => item.id === current.id || item.tierKey === plan.tierKey), planTemplates }; } function entitlementUsageValue(organizationId, key) { if (key === "limit.seats") return organizationSeatSummary(organizationId).reserved; if (key === "limit.workspaces") return Number(dbGet("SELECT COUNT(*) AS count FROM workspaces WHERE organization_id = ? AND status = 'active'", [organizationId])?.count || 0); if (key === "limit.projects") return Number(dbGet("SELECT COUNT(*) AS count FROM projects p JOIN workspaces w ON w.id = p.workspace_id WHERE w.organization_id = ? AND p.status != 'archived'", [organizationId])?.count || 0); if (key === "limit.style_kits") return Number(dbGet("SELECT COUNT(*) AS count FROM style_kits WHERE organization_id = ? AND status != 'archived'", [organizationId])?.count || 0); if (key === "limit.model_connectors") return Number(dbGet("SELECT COUNT(*) AS count FROM model_connectors WHERE organization_id = ?", [organizationId])?.count || 0); if (key === "limit.api_clients") return Number(dbGet("SELECT COUNT(*) AS count FROM api_clients WHERE organization_id = ? AND status = 'active'", [organizationId])?.count || 0); if (key === "limit.knowledge_documents") return Number(dbGet("SELECT COUNT(*) AS count FROM knowledge_documents WHERE organization_id = ? AND status != 'archived'", [organizationId])?.count || 0); if (key === "limit.knowledge_context_packs") return Number(dbGet("SELECT COUNT(*) AS count FROM knowledge_context_packs WHERE organization_id = ? AND status = 'active'", [organizationId])?.count || 0); if (key === "limit.delivery_channels") return Number(dbGet("SELECT COUNT(*) AS count FROM delivery_channels WHERE organization_id = ?", [organizationId])?.count || 0); if (key === "limit.storage_gb") return Number(dbGet("SELECT COALESCE(MAX(used_value), 0) AS used_value FROM quota_allocations WHERE organization_id = ? AND metric = 'storage'", [organizationId])?.used_value || 0); if (key === "limit.generation_jobs_monthly") { return Number(dbGet( "SELECT COALESCE(SUM(units), 0) AS units FROM usage_events WHERE organization_id = ? AND unit_name IN ('job', 'clip', 'clips') AND created_at >= datetime('now', 'start of month')", [organizationId] )?.units || 0); } return 0; } function entitlementPayload(row, organizationId) { const limitValue = Number(row.limit_value || 0); const usedValue = entitlementUsageValue(organizationId, row.entitlement_key); const enabled = Boolean(row.enabled); const utilization = limitValue ? Number(((usedValue / limitValue) * 100).toFixed(2)) : 0; const remainingValue = limitValue ? Math.max(0, limitValue - usedValue) : null; const blocked = enabled && row.enforcement === "block" && limitValue > 0 && usedValue >= limitValue; const warning = enabled && !blocked && limitValue > 0 && utilization >= 80; const isFeature = String(row.entitlement_key || "").startsWith("feature."); return { id: row.id, key: row.entitlement_key, label: row.label, category: row.category, limitValue, usedValue, remainingValue, utilization, unit: row.unit, enabled, enforcement: row.enforcement, source: row.source, overrideReason: row.override_reason || "", metadata: parseJson(row.metadata_json, {}), status: !enabled ? "disabled" : blocked ? "blocked" : warning ? "warning" : isFeature ? "enabled" : "ok", updatedAt: row.updated_at }; } function defaultEntitlementRows(organizationId) { const billing = billingAccount(organizationId) || {}; const defaults = [ ["limit.seats", "组织席位", "tenant", Number(billing.seat_limit || 12), "人", 1, "block", { commercialGate: "member-invite-and-sso" }], ["limit.workspaces", "工作区数量", "tenant", 8, "个", 1, "block", { commercialGate: "workspace:create" }], ["limit.projects", "项目数量", "production", 36, "个", 1, "block", { commercialGate: "project:create" }], ["limit.generation_jobs_monthly", "月度生成任务", "production", Number(billing.monthly_clip_quota || 2400), "job", 1, "block", { commercialGate: "generation_job:create" }], ["limit.style_kits", "风格生产标准", "production", 24, "套", 1, "block", { commercialGate: "style_kit:create" }], ["limit.storage_gb", "存储容量", "storage", Number(billing.storage_gb || 1024), "GB", 1, "block", { commercialGate: "storage:write" }], ["limit.model_connectors", "模型连接器", "modelops", 16, "个", 1, "block", { commercialGate: "model_connector:create" }], ["limit.api_clients", "API 客户端", "system", 8, "个", 1, "block", { commercialGate: "api_client:create" }], ["limit.knowledge_documents", "知识库素材", "knowledge", 300, "篇", 1, "block", { commercialGate: "knowledge_document:ingest" }], ["limit.knowledge_context_packs", "知识上下文包", "knowledge", 180, "包", 1, "block", { commercialGate: "knowledge_context_pack:create" }], ["limit.delivery_channels", "交付渠道", "delivery", 12, "个", 1, "block", { commercialGate: "delivery_channel:create" }], ["feature.batch_generation", "批量生产", "feature", 1, "开关", 1, "block", { description: "允许创建批量生成与流水线任务" }], ["feature.private_delivery_portal", "客户交付门户", "feature", 1, "开关", 1, "block", { description: "允许创建带令牌的客户预览/下载门户" }], ["feature.comfyui_adapter", "ComfyUI 可选桥接", "feature", 1, "开关", 0, "block", { description: "默认关闭,明确启用后才可作为适配器" }], ["feature.external_cloud_connectors", "外部云连接器", "feature", 1, "开关", 0, "block", { description: "默认关闭,付费/公网模型必须显式审批" }] ]; const timestamp = new Date().toISOString(); for (const [key, label, category, limitValue, unit, enabled, enforcement, metadata] of defaults) { dbRun( "INSERT OR IGNORE INTO organization_entitlements(id, organization_id, entitlement_key, label, category, limit_value, unit, enabled, enforcement, source, metadata_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'plan', ?, ?, ?)", [`ent-${organizationId}-${key.replace(/[^a-z0-9]+/gi, "-")}`, organizationId, key, label, category, limitValue, unit, enabled, enforcement, JSON.stringify(metadata), timestamp, timestamp] ); } } export function ensureOrganizationEntitlements(organizationId) { defaultEntitlementRows(organizationId); return organizationEntitlements(organizationId); } export function organizationEntitlements(organizationId) { defaultEntitlementRows(organizationId); const rows = dbAll( `SELECT * FROM organization_entitlements WHERE organization_id = ? ORDER BY CASE category WHEN 'tenant' THEN 0 WHEN 'production' THEN 1 WHEN 'knowledge' THEN 2 WHEN 'modelops' THEN 3 WHEN 'delivery' THEN 4 WHEN 'storage' THEN 5 WHEN 'system' THEN 6 WHEN 'feature' THEN 7 ELSE 8 END, entitlement_key`, [organizationId] ).map((row) => entitlementPayload(row, organizationId)); return { entitlements: rows, summary: { total: rows.length, enabled: rows.filter((item) => item.enabled).length, blocked: rows.filter((item) => item.status === "blocked").length, warning: rows.filter((item) => item.status === "warning").length, overridden: rows.filter((item) => item.source === "override").length }, planTemplates: subscriptionPlanTemplates() }; } function syncBillingEntitlements(organizationId, { seatLimit, storageGb, monthlyClipQuota }, timestamp) { const values = [ ["limit.seats", seatLimit], ["limit.storage_gb", storageGb], ["limit.generation_jobs_monthly", monthlyClipQuota] ]; for (const [key, limitValue] of values) { dbRun( "UPDATE organization_entitlements SET limit_value = ?, updated_at = ? WHERE organization_id = ? AND entitlement_key = ? AND source = 'plan'", [Number(limitValue || 0), timestamp, organizationId, key] ); } } export function updateOrganizationEntitlement(context, organizationId, entitlementKey, body = {}) { if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能修改其他组织的套餐权益", { organizationId }); requirePermission(context, "quota:manage"); defaultEntitlementRows(organizationId); const key = String(entitlementKey || "").trim(); const current = dbGet("SELECT * FROM organization_entitlements WHERE organization_id = ? AND entitlement_key = ?", [organizationId, key]); if (!current) throw httpError(404, "entitlement_not_found", "组织权益不存在", { entitlementKey: key }); const limitValue = body.limitValue === undefined ? Number(current.limit_value || 0) : Number(body.limitValue); if (!Number.isFinite(limitValue) || limitValue < 0) throw httpError(400, "entitlement_limit_invalid", "权益额度必须是大于或等于 0 的数字"); const enabled = body.enabled === undefined ? Boolean(current.enabled) : Boolean(body.enabled); const enforcement = String(body.enforcement || current.enforcement || "block").trim(); if (!["block", "warn", "off"].includes(enforcement)) throw httpError(400, "entitlement_enforcement_invalid", "权益执行策略只能是 block、warn 或 off"); const usedValue = entitlementUsageValue(organizationId, key); if (enabled && enforcement === "block" && limitValue > 0 && usedValue > limitValue) { throw httpError(409, "entitlement_below_usage", "权益额度不能低于当前已用量", { entitlementKey: key, usedValue, limitValue }); } const overrideReason = String(body.overrideReason ?? body.override_reason ?? current.override_reason ?? "").trim().slice(0, 240); const metadata = body.metadata && typeof body.metadata === "object" ? { ...parseJson(current.metadata_json, {}), ...body.metadata } : parseJson(current.metadata_json, {}); const timestamp = new Date().toISOString(); dbRun( "UPDATE organization_entitlements SET limit_value = ?, enabled = ?, enforcement = ?, source = 'override', override_reason = ?, metadata_json = ?, updated_at = ? WHERE organization_id = ? AND entitlement_key = ?", [limitValue, enabled ? 1 : 0, enforcement, overrideReason, JSON.stringify(metadata), timestamp, organizationId, key] ); addAudit({ context, action: "billing.entitlement.updated", targetType: "organization_entitlement", targetId: `${organizationId}:${key}`, metadata: { previous: { limitValue: Number(current.limit_value || 0), enabled: Boolean(current.enabled), enforcement: current.enforcement, source: current.source }, next: { limitValue, enabled, enforcement, source: "override", overrideReason }, usedValue } }); return organizationCommercial(context); } export function requireEntitlement(context, entitlementKey, units = 1) { if (!context?.organization?.id) return null; defaultEntitlementRows(context.organization.id); const key = String(entitlementKey || "").trim(); const row = dbGet("SELECT * FROM organization_entitlements WHERE organization_id = ? AND entitlement_key = ?", [context.organization.id, key]); if (!row) return null; const payload = entitlementPayload(row, context.organization.id); const requested = Math.max(0, Number(units || 0)); if (!payload.enabled) { throw httpError(403, "entitlement_disabled", `当前组织未开通${payload.label}`, { entitlementKey: key, label: payload.label }); } if (payload.enforcement !== "block" || !payload.limitValue) return { ...payload, requested }; if (payload.usedValue + requested > payload.limitValue) { throw httpError(429, "entitlement_limit_exceeded", `当前组织的${payload.label}权益额度不足`, { entitlementKey: key, label: payload.label, unit: payload.unit, used: payload.usedValue, limit: payload.limitValue, requested }); } return { ...payload, requested, remainingAfterRequest: payload.limitValue - payload.usedValue - requested }; } const COMMERCIAL_APPROVAL_TYPES = { entitlement_overage: { label: "套餐超额 / 扩容", description: "申请临时提高席位、项目、任务、知识库、存储等组织权益。", reviewerPermissions: ["quota:manage", "billing:manage"], autoEffect: "entitlement_override" }, feature_enablement: { label: "功能开通", description: "申请开通批量生产、客户交付门户、ComfyUI 可选桥接等组织功能。", reviewerPermissions: ["quota:manage", "billing:manage"], autoEffect: "feature_override" }, budget_increase: { label: "成本中心预算", description: "申请提高本地 GPU、存储或运营成本中心的月度预算。", reviewerPermissions: ["billing:manage"], autoEffect: "cost_center_budget" }, external_connector: { label: "外部模型连接器", description: "申请启用外部或混合成本模型连接器;不会自动调用付费云端。", reviewerPermissions: ["model:approve", "billing:manage"], autoEffect: "external_connector_gate" }, compliance_review: { label: "法务 / 版权复核", description: "申请对小说素材、角色、声音、交付物进行商用证据复核。", reviewerPermissions: ["compliance:manage"], autoEffect: "compliance_record" }, delivery_exception: { label: "交付例外", description: "申请在特定版本中放行交付策略例外,仍保留审计证据。", reviewerPermissions: ["delivery:approve", "compliance:manage"], autoEffect: "audit_only" }, storage_retention: { label: "留存 / 归档策略", description: "申请延长素材、任务证据、交付包或客户门户访问留存期。", reviewerPermissions: ["billing:manage", "compliance:manage"], autoEffect: "audit_only" } }; function hasAnyPermission(context, permissions = []) { return Boolean(context?.systemAdmin || permissions.some((permission) => hasPermission(context, permission))); } function normalizedApprovalType(value) { const type = String(value || "").trim(); if (!COMMERCIAL_APPROVAL_TYPES[type]) { throw httpError(400, "commercial_approval_type_invalid", "不支持的商业治理申请类型", { allowedTypes: Object.keys(COMMERCIAL_APPROVAL_TYPES) }); } return type; } function normalizedApprovalPriority(value) { const priority = String(value || "medium").trim(); return ["low", "medium", "high", "urgent"].includes(priority) ? priority : "medium"; } function canReviewCommercialApproval(context, requestType) { const definition = COMMERCIAL_APPROVAL_TYPES[requestType]; return Boolean(definition && hasAnyPermission(context, definition.reviewerPermissions)); } function canViewCommercialApprovalQueue(context) { return hasAnyPermission(context, [ "usage:view", "billing:manage", "quota:manage", "model:approve", "compliance:manage", "delivery:approve", "audit:view" ]); } function commercialApprovalCatalog() { return Object.entries(COMMERCIAL_APPROVAL_TYPES).map(([key, value]) => ({ key, ...value })); } function commercialApprovalTargetSnapshot(context, requestType, body = {}) { const targetKey = String(body.targetKey || body.target_key || "").trim(); if (["entitlement_overage", "feature_enablement"].includes(requestType)) { defaultEntitlementRows(context.organization.id); const entitlement = dbGet("SELECT * FROM organization_entitlements WHERE organization_id = ? AND entitlement_key = ?", [context.organization.id, targetKey]); if (!entitlement) throw httpError(400, "entitlement_target_invalid", "申请目标权益不存在", { targetKey }); const payload = entitlementPayload(entitlement, context.organization.id); return { targetKey, targetLabel: payload.label, currentValue: requestType === "feature_enablement" ? (payload.enabled ? 1 : 0) : payload.limitValue, requestedValue: requestType === "feature_enablement" ? 1 : Number(body.requestedValue ?? body.requested_value ?? payload.limitValue + 1), unit: payload.unit || "项", metadata: { entitlement: payload } }; } if (requestType === "external_connector") { const connector = targetKey ? dbGet("SELECT * FROM model_connectors WHERE organization_id = ? AND id = ?", [context.organization.id, targetKey]) : null; if (connector) { return { targetKey: connector.id, targetLabel: connector.label, currentValue: connector.cost_mode === "local" ? 0 : 1, requestedValue: Number(body.requestedValue ?? body.requested_value ?? 1), unit: connector.cost_mode || "连接器", metadata: { connectorId: connector.id, costMode: connector.cost_mode, endpoint: connector.endpoint, secretStored: false } }; } const featureKey = targetKey || "feature.external_cloud_connectors"; const entitlement = dbGet("SELECT * FROM organization_entitlements WHERE organization_id = ? AND entitlement_key = ?", [context.organization.id, featureKey]); if (!entitlement) throw httpError(400, "external_connector_target_invalid", "外部连接器申请目标不存在", { targetKey: featureKey }); const payload = entitlementPayload(entitlement, context.organization.id); return { targetKey: featureKey, targetLabel: payload.label, currentValue: payload.enabled ? 1 : 0, requestedValue: 1, unit: payload.unit || "开关", metadata: { entitlement: payload, paidCloudRequiresApproval: true } }; } if (requestType === "budget_increase") { const costCenter = dbGet("SELECT * FROM cost_centers WHERE organization_id = ? AND (id = ? OR code = ?)", [context.organization.id, targetKey, targetKey]); if (!costCenter) throw httpError(400, "cost_center_target_invalid", "成本中心不存在", { targetKey }); return { targetKey: costCenter.code, targetLabel: costCenter.name, currentValue: Number(costCenter.monthly_budget || 0), requestedValue: Number(body.requestedValue ?? body.requested_value ?? Number(costCenter.monthly_budget || 0) + 100), unit: costCenter.currency || "CNY", metadata: { costCenterId: costCenter.id, code: costCenter.code } }; } const fallbackProjectId = body.projectId || body.project_id || context.project?.id || ""; return { targetKey: targetKey || body.policyKey || body.policy_key || (requestType === "compliance_review" ? "commercial-rights-review" : requestType), targetLabel: body.subjectLabel || body.subject_label || fallbackProjectId || "组织策略", currentValue: Number(body.currentValue ?? body.current_value ?? 0), requestedValue: Number(body.requestedValue ?? body.requested_value ?? 1), unit: body.unit || "次", metadata: { subjectType: body.subjectType || body.subject_type || (requestType === "compliance_review" ? "project" : "policy"), subjectId: body.subjectId || body.subject_id || fallbackProjectId } }; } function commercialApprovalScope(context, body = {}) { let workspaceId = body.workspaceId || body.workspace_id || context.workspace?.id || null; let projectId = body.projectId || body.project_id || context.project?.id || null; if (projectId) { const project = dbGet( `SELECT p.id, p.workspace_id FROM projects p JOIN workspaces w ON w.id = p.workspace_id WHERE p.id = ? AND w.organization_id = ?`, [projectId, context.organization.id] ); if (!project) throw httpError(403, "approval_project_scope_mismatch", "申请绑定项目不属于当前组织", { projectId }); projectId = project.id; workspaceId = workspaceId || project.workspace_id; } if (workspaceId) { const workspace = dbGet("SELECT id FROM workspaces WHERE id = ? AND organization_id = ?", [workspaceId, context.organization.id]); if (!workspace) throw httpError(403, "approval_workspace_scope_mismatch", "申请绑定工作区不属于当前组织", { workspaceId }); } return { workspaceId, projectId }; } function commercialApprovalPayload(row) { if (!row) return null; return { id: row.id, organizationId: row.organization_id, workspaceId: row.workspace_id, workspaceName: row.workspace_name || "", projectId: row.project_id, projectName: row.project_name || "", requestType: row.request_type, requestTypeLabel: COMMERCIAL_APPROVAL_TYPES[row.request_type]?.label || row.request_type, status: row.status, priority: row.priority, targetKey: row.target_key, currentValue: Number(row.current_value || 0), requestedValue: Number(row.requested_value || 0), unit: row.unit || "", businessReason: row.business_reason || "", riskAssessment: parseJson(row.risk_assessment_json, {}), evidence: parseJson(row.evidence_json, {}), decisionNote: row.decision_note || "", effect: parseJson(row.effect_json, {}), requester: { id: row.requester_user_id, name: row.requester_name || row.requester_user_id, email: row.requester_email || "" }, reviewer: row.reviewer_user_id ? { id: row.reviewer_user_id, name: row.reviewer_name || row.reviewer_user_id, email: row.reviewer_email || "" } : null, reviewedAt: row.reviewed_at, expiresAt: row.expires_at, createdAt: row.created_at, updatedAt: row.updated_at }; } function commercialApprovalRows(organizationId, clauses = [], params = [], limit = 80) { return dbAll( `SELECT car.*, w.name AS workspace_name, p.name AS project_name, requester.display_name AS requester_name, requester.email AS requester_email, reviewer.display_name AS reviewer_name, reviewer.email AS reviewer_email FROM commercial_approval_requests car LEFT JOIN workspaces w ON w.id = car.workspace_id LEFT JOIN projects p ON p.id = car.project_id LEFT JOIN users requester ON requester.id = car.requester_user_id LEFT JOIN users reviewer ON reviewer.id = car.reviewer_user_id WHERE car.organization_id = ? ${clauses.length ? `AND ${clauses.join(" AND ")}` : ""} ORDER BY CASE car.status WHEN 'submitted' THEN 0 ELSE 1 END, CASE car.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END, car.updated_at DESC LIMIT ?`, [organizationId, ...params, Math.min(200, Math.max(1, Number(limit || 80)))] ).map(commercialApprovalPayload); } function commercialApprovalSummary(requests = []) { return { total: requests.length, submitted: requests.filter((request) => request.status === "submitted").length, approved: requests.filter((request) => request.status === "approved").length, rejected: requests.filter((request) => request.status === "rejected").length, urgent: requests.filter((request) => request.priority === "urgent" && request.status === "submitted").length }; } export function listCommercialApprovalRequests(context, organizationId, options = {}) { if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能查看其他组织的商业审批", { organizationId }); const clauses = []; const params = []; const status = String(options.status || "").trim(); if (status) { if (!["submitted", "approved", "rejected", "cancelled"].includes(status)) throw httpError(400, "approval_status_invalid", "审批状态参数无效"); clauses.push("car.status = ?"); params.push(status); } if (String(options.type || "").trim()) { clauses.push("car.request_type = ?"); params.push(normalizedApprovalType(options.type)); } const viewAll = canViewCommercialApprovalQueue(context) && options.mine !== true && String(options.mine || "") !== "1"; if (!viewAll) { clauses.push("car.requester_user_id = ?"); params.push(context.user.id); } const requests = commercialApprovalRows(organizationId, clauses, params, options.limit || 80); return { requests, summary: commercialApprovalSummary(requests), catalog: commercialApprovalCatalog(), canReviewTypes: Object.keys(COMMERCIAL_APPROVAL_TYPES).filter((type) => canReviewCommercialApproval(context, type)) }; } export function createCommercialApprovalRequest(context, organizationId, body = {}) { if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能向其他组织提交商业审批", { organizationId }); const requestType = normalizedApprovalType(body.requestType || body.request_type); const target = commercialApprovalTargetSnapshot(context, requestType, body); const requestedValue = Number(target.requestedValue); if (!Number.isFinite(requestedValue) || requestedValue < 0) throw httpError(400, "approval_requested_value_invalid", "申请目标值必须是非负数字"); const businessReason = String(body.businessReason || body.business_reason || "").trim().slice(0, 1200); if (businessReason.length < 6) throw httpError(400, "approval_reason_required", "请填写申请原因,至少 6 个字符"); const title = String(body.title || `${COMMERCIAL_APPROVAL_TYPES[requestType].label} · ${target.targetLabel}`).trim().slice(0, 160); const { workspaceId, projectId } = commercialApprovalScope(context, body); const riskAssessment = { localModelFirst: true, paidCloudRequiresExplicitApproval: true, singleFrameOnly: true, continuityLedgerRequired: true, fixedVoiceEvidenceRequired: true, ...target.metadata, ...objectValue(body.riskAssessment || body.risk_assessment) }; const evidence = { source: "manual-request", ...objectValue(body.evidence), targetLabel: target.targetLabel }; const timestamp = new Date().toISOString(); const id = `commercial-approval-${Date.now()}-${randomBytes(4).toString("hex")}`; dbRun( `INSERT INTO commercial_approval_requests( id, organization_id, workspace_id, project_id, request_type, title, status, priority, target_key, current_value, requested_value, unit, business_reason, risk_assessment_json, evidence_json, requester_user_id, created_at, updated_at, expires_at ) VALUES (?, ?, ?, ?, ?, ?, 'submitted', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ id, organizationId, workspaceId, projectId, requestType, title, normalizedApprovalPriority(body.priority), target.targetKey, Number(target.currentValue || 0), requestedValue, target.unit, businessReason, JSON.stringify(riskAssessment), JSON.stringify(evidence), context.user.id, timestamp, timestamp, body.expiresAt || body.expires_at || null ] ); addAudit({ context, action: "commercial.approval.submitted", targetType: "commercial_approval_request", targetId: id, metadata: { requestType, targetKey: target.targetKey, requestedValue, unit: target.unit } }); return { request: commercialApprovalRows(organizationId, ["car.id = ?"], [id], 1)[0], ...listCommercialApprovalRequests(context, organizationId, { limit: 80 }) }; } function applyCommercialApprovalEffect(context, row, decisionNote) { const requestType = row.request_type; const targetKey = row.target_key; const requestedValue = Number(row.requested_value || 0); const timestamp = new Date().toISOString(); if (["entitlement_overage", "feature_enablement"].includes(requestType)) { const current = dbGet("SELECT * FROM organization_entitlements WHERE organization_id = ? AND entitlement_key = ?", [row.organization_id, targetKey]); if (!current) throw httpError(404, "entitlement_not_found", "审批目标权益不存在", { targetKey }); const isFeature = targetKey.startsWith("feature."); const usedValue = entitlementUsageValue(row.organization_id, targetKey); const nextLimit = isFeature ? Math.max(1, requestedValue || Number(current.limit_value || 1)) : Math.max(requestedValue, Number(current.limit_value || 0), usedValue); dbRun( "UPDATE organization_entitlements SET limit_value = ?, enabled = 1, source = 'override', override_reason = ?, updated_at = ? WHERE organization_id = ? AND entitlement_key = ?", [nextLimit, decisionNote || `审批通过:${row.title}`, timestamp, row.organization_id, targetKey] ); return { applied: true, kind: isFeature ? "feature_enabled" : "entitlement_overridden", entitlementKey: targetKey, previousLimit: Number(current.limit_value || 0), nextLimit, usedValue }; } if (requestType === "external_connector") { const connector = dbGet("SELECT * FROM model_connectors WHERE organization_id = ? AND id = ?", [row.organization_id, targetKey]); if (connector) { dbRun("UPDATE model_connectors SET approval_required = 1, updated_at = ? WHERE id = ? AND organization_id = ?", [timestamp, connector.id, row.organization_id]); return { applied: true, kind: "connector_marked_approval_required", connectorId: connector.id, costMode: connector.cost_mode }; } const current = dbGet("SELECT * FROM organization_entitlements WHERE organization_id = ? AND entitlement_key = ?", [row.organization_id, targetKey || "feature.external_cloud_connectors"]); if (current) { dbRun( "UPDATE organization_entitlements SET enabled = 1, limit_value = MAX(limit_value, 1), source = 'override', override_reason = ?, updated_at = ? WHERE organization_id = ? AND entitlement_key = ?", [decisionNote || `审批通过:${row.title}`, timestamp, row.organization_id, current.entitlement_key] ); return { applied: true, kind: "external_connector_feature_enabled", entitlementKey: current.entitlement_key, paidCloudRequiresApproval: true }; } return { applied: false, kind: "external_connector_audit_only", targetKey }; } if (requestType === "budget_increase") { const current = dbGet("SELECT * FROM cost_centers WHERE organization_id = ? AND (id = ? OR code = ?)", [row.organization_id, targetKey, targetKey]); if (!current) throw httpError(404, "cost_center_not_found", "审批目标成本中心不存在", { targetKey }); const nextBudget = Math.max(Number(current.monthly_budget || 0), requestedValue); dbRun("UPDATE cost_centers SET monthly_budget = ?, updated_at = ? WHERE id = ? AND organization_id = ?", [nextBudget, timestamp, current.id, row.organization_id]); return { applied: true, kind: "cost_center_budget_updated", costCenterId: current.id, code: current.code, previousBudget: Number(current.monthly_budget || 0), nextBudget }; } if (requestType === "compliance_review") { const evidence = parseJson(row.evidence_json, {}); const risk = parseJson(row.risk_assessment_json, {}); const subjectType = evidence.subjectType || risk.subjectType || "project"; const subjectId = evidence.subjectId || risk.subjectId || row.project_id || row.workspace_id || row.organization_id; const recordId = `commercial-compliance-${row.id}`; dbRun( `INSERT INTO compliance_records(id, organization_id, workspace_id, project_id, subject_type, subject_id, policy_key, status, evidence_json, reviewed_by, reviewed_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, 'approved', ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET status = 'approved', evidence_json = excluded.evidence_json, reviewed_by = excluded.reviewed_by, reviewed_at = excluded.reviewed_at, updated_at = excluded.updated_at`, [recordId, row.organization_id, row.workspace_id, row.project_id, subjectType, subjectId, targetKey || "commercial-rights-review", JSON.stringify({ ...evidence, approvalRequestId: row.id }), context.user.id, timestamp, timestamp, timestamp] ); return { applied: true, kind: "compliance_record_approved", complianceRecordId: recordId, subjectType, subjectId }; } return { applied: true, kind: "audit_only", requestType, targetKey }; } export function decideCommercialApprovalRequest(context, organizationId, approvalId, body = {}) { if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能审批其他组织的商业申请", { organizationId }); const current = dbGet("SELECT * FROM commercial_approval_requests WHERE id = ? AND organization_id = ?", [approvalId, organizationId]); if (!current) throw httpError(404, "commercial_approval_not_found", "商业治理申请不存在", { approvalId }); const decision = String(body.decision || body.status || "").trim(); if (!["approved", "rejected", "cancelled"].includes(decision)) throw httpError(400, "commercial_approval_decision_invalid", "审批结论只能是 approved、rejected 或 cancelled"); const requesterCancelling = decision === "cancelled" && current.requester_user_id === context.user.id; if (!requesterCancelling && !canReviewCommercialApproval(context, current.request_type)) { throw httpError(403, "commercial_approval_review_denied", "当前角色没有审批此类商业治理申请的权限", { requestType: current.request_type, requiredPermissions: COMMERCIAL_APPROVAL_TYPES[current.request_type]?.reviewerPermissions || [] }); } if (current.status !== "submitted") throw httpError(409, "commercial_approval_already_decided", "该商业治理申请已经处理,不能重复审批", { status: current.status }); const decisionNote = String(body.decisionNote || body.decision_note || "").trim().slice(0, 1200); const timestamp = new Date().toISOString(); const effect = decision === "approved" ? applyCommercialApprovalEffect(context, current, decisionNote) : { applied: false, kind: decision }; dbRun( "UPDATE commercial_approval_requests SET status = ?, reviewer_user_id = ?, reviewed_at = ?, decision_note = ?, effect_json = ?, updated_at = ? WHERE id = ? AND organization_id = ?", [decision, requesterCancelling ? null : context.user.id, requesterCancelling ? null : timestamp, decisionNote, JSON.stringify(effect), timestamp, approvalId, organizationId] ); addAudit({ context, action: `commercial.approval.${decision}`, targetType: "commercial_approval_request", targetId: approvalId, metadata: { requestType: current.request_type, targetKey: current.target_key, effect } }); const payload = { request: commercialApprovalRows(organizationId, ["car.id = ?"], [approvalId], 1)[0], ...listCommercialApprovalRequests(context, organizationId, { limit: 80 }) }; if (hasAnyPermission(context, ["usage:view", "billing:manage", "quota:manage"])) payload.commercial = organizationCommercial(context); return payload; } export function organizationCommercial(context) { if (!hasPermission(context, "usage:view") && !hasPermission(context, "billing:manage") && !hasPermission(context, "quota:manage")) { throw httpError(403, "permission_denied", "当前角色没有查看组织商业运营数据的权限"); } const billing = billingAccount(context.organization.id); const seat = organizationSeatSummary(context.organization.id); const quotas = organizationQuotaRows(context.organization.id); const usage = usageSummary(context); usage.quotas = quotas; const costCenters = organizationCostCenters(context.organization.id); const entitlementState = organizationEntitlements(context.organization.id); const commercialApprovalState = listCommercialApprovalRequests(context, context.organization.id, { limit: 80 }); return { organization: context.organization, billing, seat, quotas, usage, billingHistory: billingHistory(context.organization.id), costCenters: costCenters.centers, costCenterDetail: costCenters.detail, usageTrend: organizationUsageTrend(context.organization.id), quotaWarnings: quotaWarnings(context.organization.id, billing, quotas, seat), entitlements: entitlementState.entitlements, entitlementSummary: entitlementState.summary, planTemplates: entitlementState.planTemplates, commercialApprovals: commercialApprovalState.requests, commercialApprovalSummary: commercialApprovalState.summary, commercialApprovalCatalog: commercialApprovalState.catalog, commercialApprovalReviewTypes: commercialApprovalState.canReviewTypes, workspaces: dbAll("SELECT id, name, slug, status FROM workspaces WHERE organization_id = ? ORDER BY name", [context.organization.id]) }; } export function updateOrganizationBilling(context, organizationId, body = {}) { if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能修改其他组织的套餐配置", { organizationId }); requirePermission(context, "billing:manage"); const current = dbGet("SELECT * FROM billing_accounts WHERE organization_id = ?", [organizationId]); if (!current) throw httpError(404, "billing_not_found", "组织套餐记录不存在", { organizationId }); const seatLimit = Math.max(1, Math.floor(Number(body.seatLimit ?? current.seat_limit))); const storageGb = Math.max(1, Number(body.storageGb ?? current.storage_gb)); const monthlyClipQuota = Math.max(1, Math.floor(Number(body.monthlyClipQuota ?? current.monthly_clip_quota))); const planName = String(body.planName ?? current.plan_name).trim(); const billingCycle = String(body.billingCycle ?? current.billing_cycle ?? "monthly").trim(); const currency = String(body.currency ?? current.currency ?? "CNY").trim().toUpperCase(); const baseFee = Math.max(0, Number(body.baseFee ?? current.base_fee ?? 0)); const seatUnitPrice = Math.max(0, Number(body.seatUnitPrice ?? current.seat_unit_price ?? 0)); const storageUnitPrice = Math.max(0, Number(body.storageUnitPrice ?? current.storage_unit_price ?? 0)); const clipUnitPrice = Math.max(0, Number(body.clipUnitPrice ?? current.clip_unit_price ?? 0)); const quotaWarningPercent = Math.min(99, Math.max(50, Math.floor(Number(body.quotaWarningPercent ?? current.quota_warning_percent ?? 80)))); if (!planName) throw httpError(400, "plan_name_required", "套餐名称不能为空"); if (!["monthly", "quarterly", "annual"].includes(billingCycle)) throw httpError(400, "billing_cycle_invalid", "账单周期只能是 monthly、quarterly 或 annual"); if (!/^[A-Z]{3}$/.test(currency)) throw httpError(400, "currency_invalid", "货币必须是三位字母代码"); for (const [value, label] of [[baseFee, "套餐固定费"], [seatUnitPrice, "席位单价"], [storageUnitPrice, "存储单价"], [clipUnitPrice, "片段单价"]]) { if (!Number.isFinite(value) || value < 0) throw httpError(400, "billing_price_invalid", `${label}必须是大于或等于 0 的数字`); } const seats = organizationSeatSummary(organizationId); if (seatLimit < seats.reserved) throw httpError(409, "seat_limit_below_reserved", "席位额度不能低于已占用和待处理邀请", { reserved: seats.reserved, seatLimit }); const clipUsage = Number(dbGet("SELECT COALESCE(SUM(units), 0) AS units FROM usage_events WHERE organization_id = ? AND unit_name IN ('job', 'clip', 'clips') AND created_at >= datetime('now', 'start of month')", [organizationId])?.units || 0); if (monthlyClipQuota < clipUsage) throw httpError(409, "clip_quota_below_usage", "月度片段额度不能低于本月已用量", { used: clipUsage, monthlyClipQuota }); const maxStorageUsed = Number(dbGet("SELECT COALESCE(MAX(used_value), 0) AS used_value FROM quota_allocations WHERE organization_id = ? AND metric = 'storage'", [organizationId])?.used_value || 0); if (storageGb < maxStorageUsed) throw httpError(409, "storage_quota_below_usage", "存储额度不能低于已记录用量", { usedGb: maxStorageUsed, storageGb }); const localRunnerOnly = body.localRunnerOnly === undefined ? Boolean(current.local_runner_only) : Boolean(body.localRunnerOnly); const cloudApproval = body.cloudConnectorsRequireApproval === undefined ? Boolean(current.cloud_connectors_require_approval) : Boolean(body.cloudConnectorsRequireApproval); const timestamp = new Date().toISOString(); const previous = { planName: current.plan_name, billingCycle: current.billing_cycle || "monthly", currency: current.currency || "CNY", baseFee: Number(current.base_fee || 0), seatUnitPrice: Number(current.seat_unit_price || 0), storageUnitPrice: Number(current.storage_unit_price || 0), clipUnitPrice: Number(current.clip_unit_price || 0), seatLimit: current.seat_limit, storageGb: current.storage_gb, monthlyClipQuota: current.monthly_clip_quota, quotaWarningPercent: Number(current.quota_warning_percent || 80), localRunnerOnly: Boolean(current.local_runner_only), cloudApproval: Boolean(current.cloud_connectors_require_approval) }; const next = { planName, billingCycle, currency, baseFee, seatUnitPrice, storageUnitPrice, clipUnitPrice, seatLimit, storageGb, monthlyClipQuota, quotaWarningPercent, localRunnerOnly, cloudApproval }; dbRun("UPDATE billing_accounts SET plan_name = ?, billing_cycle = ?, currency = ?, base_fee = ?, seat_unit_price = ?, storage_unit_price = ?, clip_unit_price = ?, seat_limit = ?, storage_gb = ?, monthly_clip_quota = ?, quota_warning_percent = ?, local_runner_only = ?, cloud_connectors_require_approval = ?, updated_at = ? WHERE organization_id = ?", [planName, billingCycle, currency, baseFee, seatUnitPrice, storageUnitPrice, clipUnitPrice, seatLimit, storageGb, monthlyClipQuota, quotaWarningPercent, localRunnerOnly ? 1 : 0, cloudApproval ? 1 : 0, timestamp, organizationId]); syncBillingEntitlements(organizationId, { seatLimit, storageGb, monthlyClipQuota }, timestamp); dbRun("INSERT INTO billing_account_events(id, organization_id, billing_account_id, event_type, previous_json, next_json, actor_user_id, created_at) VALUES (?, ?, ?, 'plan.updated', ?, ?, ?, ?)", [`bill-event-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, organizationId, current.id, JSON.stringify(previous), JSON.stringify(next), context.user.id, timestamp]); addAudit({ context, action: "billing.account.updated", targetType: "billing_account", targetId: current.id, metadata: { previous, next } }); return organizationCommercial(context); } export function updateQuotaAllocation(context, organizationId, quotaId, body = {}) { if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能修改其他组织的配额", { organizationId }); requirePermission(context, "quota:manage"); const current = dbGet("SELECT * FROM quota_allocations WHERE id = ? AND organization_id = ?", [quotaId, organizationId]); if (!current) throw httpError(404, "quota_not_found", "工作区配额不存在", { quotaId }); const limitValue = Number(body.limitValue); if (!Number.isFinite(limitValue) || limitValue <= 0) throw httpError(400, "quota_limit_invalid", "配额上限必须是大于 0 的数字"); if (limitValue < Number(current.used_value || 0)) throw httpError(409, "quota_below_usage", "配额上限不能低于已用量", { used: Number(current.used_value || 0), limitValue }); const billing = billingAccount(organizationId); if (current.metric === "clip" && limitValue > Number(billing?.monthly_clip_quota || 0)) throw httpError(409, "quota_above_plan", "工作区片段配额不能超过组织月度套餐上限", { planLimit: Number(billing?.monthly_clip_quota || 0) }); if (current.metric === "storage" && limitValue > Number(billing?.storage_gb || 0)) throw httpError(409, "quota_above_plan", "工作区存储配额不能超过组织套餐上限", { planLimit: Number(billing?.storage_gb || 0) }); const timestamp = new Date().toISOString(); dbRun("UPDATE quota_allocations SET limit_value = ?, updated_at = ? WHERE id = ? AND organization_id = ?", [limitValue, timestamp, quotaId, organizationId]); addAudit({ context, action: "quota.allocation.updated", targetType: "quota_allocation", targetId: quotaId, metadata: { metric: current.metric, workspaceId: current.workspace_id, previousLimit: Number(current.limit_value), limitValue } }); return organizationCommercial(context); } export function updateCostCenter(context, organizationId, costCenterId, body = {}) { if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能修改其他组织的成本中心", { organizationId }); requirePermission(context, "billing:manage"); const current = dbGet("SELECT * FROM cost_centers WHERE id = ? AND organization_id = ?", [costCenterId, organizationId]); if (!current) throw httpError(404, "cost_center_not_found", "成本中心不存在", { costCenterId }); const name = String(body.name ?? current.name).trim(); const description = String(body.description ?? current.description ?? "").trim(); const budget = Number(body.monthlyBudget ?? current.monthly_budget); const status = String(body.status ?? current.status).trim(); if (!name) throw httpError(400, "cost_center_name_required", "成本中心名称不能为空"); if (!Number.isFinite(budget) || budget < 0) throw httpError(400, "cost_center_budget_invalid", "成本中心月度预算不能小于 0"); if (!["active", "archived"].includes(status)) throw httpError(400, "cost_center_status_invalid", "成本中心状态无效"); const timestamp = new Date().toISOString(); dbRun("UPDATE cost_centers SET name = ?, description = ?, monthly_budget = ?, status = ?, updated_at = ? WHERE id = ? AND organization_id = ?", [name, description, budget, status, timestamp, costCenterId, organizationId]); addAudit({ context, action: "billing.cost_center.updated", targetType: "cost_center", targetId: costCenterId, metadata: { previous: current, next: { name, description, monthlyBudget: budget, status } } }); return organizationCommercial(context); } function roundMoney(value) { return Number((Number(value || 0)).toFixed(2)); } function invoiceDate(value, field, endOfDay = false) { const raw = String(value || "").trim(); if (!raw) return ""; const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? `${raw}T${endOfDay ? "23:59:59.999" : "00:00:00.000"}Z` : raw; const date = new Date(normalized); if (Number.isNaN(date.getTime())) throw httpError(400, "invoice_date_invalid", `${field} 不是有效日期`, { field, value }); return date.toISOString(); } function invoicePeriodForCycle(cycle, reference = new Date()) { const date = new Date(reference); date.setUTCDate(1); date.setUTCHours(0, 0, 0, 0); if (cycle === "annual") { date.setUTCMonth(0, 1); const end = new Date(date); end.setUTCFullYear(end.getUTCFullYear() + 1, 0, 1); end.setUTCMilliseconds(-1); return { start: date.toISOString(), end: end.toISOString() }; } if (cycle === "quarterly") { date.setUTCMonth(Math.floor(date.getUTCMonth() / 3) * 3, 1); const end = new Date(date); end.setUTCMonth(end.getUTCMonth() + 3, 1); end.setUTCMilliseconds(-1); return { start: date.toISOString(), end: end.toISOString() }; } const end = new Date(date); end.setUTCMonth(end.getUTCMonth() + 1, 1); end.setUTCMilliseconds(-1); return { start: date.toISOString(), end: end.toISOString() }; } function invoiceStatusLabel(status) { return { draft: "草稿", issued: "已开票", paid: "已支付", overdue: "已逾期", void: "已作废" }[status] || status; } function invoicePayload(row) { if (!row) return null; return { id: row.id, organizationId: row.organization_id, billingAccountId: row.billing_account_id, invoiceNumber: row.invoice_number, status: row.status, statusLabel: invoiceStatusLabel(row.status), currency: row.currency, billingCycle: row.billing_cycle, periodStart: row.period_start, periodEnd: row.period_end, issuedAt: row.issued_at, dueAt: row.due_at, paidAt: row.paid_at, voidedAt: row.voided_at, subtotal: Number(row.subtotal || 0), taxRate: Number(row.tax_rate || 0), taxAmount: Number(row.tax_amount || 0), totalAmount: Number(row.total_amount || 0), snapshot: parseJson(row.snapshot_json, {}), createdBy: row.created_by, createdByName: row.created_by_name || row.created_by || "system", createdAt: row.created_at, updatedAt: row.updated_at, lineCount: Number(row.line_count || 0) }; } function invoiceLines(invoiceId) { return dbAll("SELECT * FROM invoice_lines WHERE invoice_id = ? ORDER BY sort_order, created_at, id", [invoiceId]).map((row) => ({ id: row.id, invoiceId: row.invoice_id, lineType: row.line_type, description: row.description, quantity: Number(row.quantity || 0), unitName: row.unit_name, unitPrice: Number(row.unit_price || 0), amount: Number(row.amount || 0), metadata: parseJson(row.metadata_json, {}), sortOrder: Number(row.sort_order || 0), createdAt: row.created_at })); } function requireInvoiceRead(context, organizationId) { if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能查看其他组织的账单", { organizationId }); if (!hasPermission(context, "usage:view") && !hasPermission(context, "billing:manage")) throw httpError(403, "permission_denied", "当前角色没有查看组织账单的权限"); } function invoiceListRows(organizationId, options = {}) { const clauses = ["i.organization_id = ?"]; const params = [organizationId]; const status = String(options.status || "").trim(); if (status) { if (!["draft", "issued", "paid", "overdue", "void"].includes(status)) throw httpError(400, "invoice_status_invalid", "账单状态无效", { status }); clauses.push("i.status = ?"); params.push(status); } const query = String(options.query || "").trim().slice(0, 120); if (query) { clauses.push("(LOWER(i.invoice_number) LIKE ? OR LOWER(i.status) LIKE ?)"); const like = `%${query.toLowerCase()}%`; params.push(like, like); } const exportMode = Boolean(options.exportMode); const rawPage = Number(options.page || 1); const rawPageSize = Number(options.pageSize || (exportMode ? 10000 : 25)); const page = Number.isFinite(rawPage) ? Math.min(100000, Math.max(1, Math.floor(rawPage))) : 1; const pageSize = Number.isFinite(rawPageSize) ? Math.min(exportMode ? 10000 : 100, Math.max(1, Math.floor(rawPageSize))) : exportMode ? 10000 : 25; const where = `WHERE ${clauses.join(" AND ")}`; const total = Number(dbGet(`SELECT COUNT(*) AS count FROM organization_invoices i ${where}`, params)?.count || 0); const rows = dbAll( `SELECT i.*, u.display_name AS created_by_name, COUNT(il.id) AS line_count FROM organization_invoices i LEFT JOIN users u ON u.id = i.created_by LEFT JOIN invoice_lines il ON il.invoice_id = i.id ${where} GROUP BY i.id ORDER BY i.period_end DESC, i.created_at DESC LIMIT ? OFFSET ?`, [...params, pageSize, (page - 1) * pageSize] ).map(invoicePayload); const summaryRows = dbAll("SELECT status, COUNT(*) AS count, COALESCE(SUM(total_amount), 0) AS amount FROM organization_invoices WHERE organization_id = ? GROUP BY status", [organizationId]); const summary = { count: 0, amount: 0, draft: 0, issued: 0, paid: 0, overdue: 0, void: 0 }; for (const row of summaryRows) { summary[row.status] = Number(row.count || 0); summary.count += Number(row.count || 0); if (row.status !== "void") summary.amount += Number(row.amount || 0); } return { invoices: rows, pagination: { page, pageSize, total, totalPages: total ? Math.ceil(total / pageSize) : 0 }, summary }; } function invoiceDetailRow(organizationId, invoiceId) { const row = dbGet( `SELECT i.*, u.display_name AS created_by_name, COUNT(il.id) AS line_count FROM organization_invoices i LEFT JOIN users u ON u.id = i.created_by LEFT JOIN invoice_lines il ON il.invoice_id = i.id WHERE i.organization_id = ? AND i.id = ? GROUP BY i.id`, [organizationId, invoiceId] ); if (!row) throw httpError(404, "invoice_not_found", "账单不存在或不属于当前组织", { invoiceId }); return { invoice: invoicePayload(row), lines: invoiceLines(invoiceId) }; } export function organizationInvoices(context, organizationId, options = {}) { requireInvoiceRead(context, organizationId); return { organizationId, ...invoiceListRows(organizationId, options) }; } export function organizationInvoice(context, organizationId, invoiceId) { requireInvoiceRead(context, organizationId); return invoiceDetailRow(organizationId, invoiceId); } export function exportOrganizationInvoices(context, organizationId, options = {}) { requireInvoiceRead(context, organizationId); const result = invoiceListRows(organizationId, { ...options, page: 1, pageSize: 10000, exportMode: true }); return { exportedAt: new Date().toISOString(), organizationId, ...result }; } export function generateOrganizationInvoice(context, organizationId, body = {}) { if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能为其他组织生成账单", { organizationId }); requirePermission(context, "billing:manage"); const billing = dbGet("SELECT * FROM billing_accounts WHERE organization_id = ?", [organizationId]); if (!billing) throw httpError(404, "billing_not_found", "组织套餐记录不存在", { organizationId }); const cycle = billing.billing_cycle || "monthly"; const defaults = invoicePeriodForCycle(cycle); const hasStart = body.periodStart !== undefined && String(body.periodStart).trim() !== ""; const hasEnd = body.periodEnd !== undefined && String(body.periodEnd).trim() !== ""; if (hasStart !== hasEnd) throw httpError(400, "invoice_period_incomplete", "账期开始和结束日期必须同时提供"); const periodStart = hasStart ? invoiceDate(body.periodStart, "账期开始", false) : defaults.start; const periodEnd = hasEnd ? invoiceDate(body.periodEnd, "账期结束", true) : defaults.end; if (new Date(periodStart).getTime() >= new Date(periodEnd).getTime()) throw httpError(400, "invoice_period_invalid", "账期结束必须晚于账期开始"); const existing = dbGet("SELECT id FROM organization_invoices WHERE organization_id = ? AND period_start = ? AND period_end = ?", [organizationId, periodStart, periodEnd]); if (existing) return { ...invoiceDetailRow(organizationId, existing.id), idempotent: true }; const dueDays = Math.min(365, Math.max(0, Math.floor(Number(body.dueDays ?? 30)))); const taxRate = Number(body.taxRate ?? 0); if (!Number.isFinite(taxRate) || taxRate < 0 || taxRate > 100) throw httpError(400, "invoice_tax_rate_invalid", "税率必须在 0 到 100 之间"); const dueDate = new Date(new Date(periodEnd).getTime() + dueDays * 86400000).toISOString(); const events = dbAll("SELECT * FROM usage_events WHERE organization_id = ? AND created_at >= ? AND created_at <= ? ORDER BY created_at ASC, id ASC", [organizationId, periodStart, periodEnd]); const workspaces = Object.fromEntries(dbAll("SELECT id, name FROM workspaces WHERE organization_id = ?", [organizationId]).map((row) => [row.id, row.name])); const costCenters = new Map(); let estimatedCost = 0; let totalUnits = 0; for (const event of events) { const code = costCenterCodeForUsage(event); const current = costCenters.get(code) || { code, workspaceIds: new Set(), units: 0, events: 0, amount: 0 }; current.workspaceIds.add(event.workspace_id || "organization"); current.units += Number(event.units || 0); current.events += 1; current.amount += Number(event.estimated_cost || 0); costCenters.set(code, current); estimatedCost += Number(event.estimated_cost || 0); totalUnits += Number(event.units || 0); } const activeSeats = Number(dbGet("SELECT COUNT(*) AS count FROM organization_members WHERE organization_id = ? AND status = 'active'", [organizationId])?.count || 0); const clipUnits = Number(dbGet("SELECT COALESCE(SUM(units), 0) AS units FROM usage_events WHERE organization_id = ? AND unit_name IN ('job', 'clip', 'clips') AND created_at >= ? AND created_at <= ?", [organizationId, periodStart, periodEnd])?.units || 0); const storageUsed = Number(dbGet("SELECT COALESCE(MAX(used_value), 0) AS used_value FROM quota_allocations WHERE organization_id = ? AND metric = 'storage'", [organizationId])?.used_value || 0); const lines = [ { lineType: "base_fee", description: `${billing.plan_name} · 套餐固定费`, quantity: 1, unitName: "账期", unitPrice: Number(billing.base_fee || 0), metadata: { planName: billing.plan_name } } ]; if (Number(billing.seat_unit_price || 0) > 0) lines.push({ lineType: "seat", description: "组织活跃席位", quantity: activeSeats, unitName: "席位", unitPrice: Number(billing.seat_unit_price || 0), metadata: { activeSeats } }); if (Number(billing.storage_unit_price || 0) > 0 && storageUsed > 0) lines.push({ lineType: "storage", description: "组织存储用量", quantity: storageUsed, unitName: "GB", unitPrice: Number(billing.storage_unit_price || 0), metadata: { storageUsed } }); if (Number(billing.clip_unit_price || 0) > 0 && clipUnits > 0) lines.push({ lineType: "clip", description: "生成片段用量", quantity: clipUnits, unitName: "片段", unitPrice: Number(billing.clip_unit_price || 0), metadata: { clipUnits } }); let sortOrder = lines.length; for (const detail of [...costCenters.values()].sort((a, b) => b.amount - a.amount || a.code.localeCompare(b.code))) { const workspaceNames = [...detail.workspaceIds].map((id) => workspaces[id] || "组织级"); lines.push({ lineType: "usage", description: `计量用量 · ${detail.code}`, quantity: detail.units, unitName: "单位", unitPrice: detail.units ? roundMoney(detail.amount / detail.units) : 0, amountOverride: roundMoney(detail.amount), metadata: { costCenter: detail.code, events: detail.events, workspaces: workspaceNames } }); } lines.forEach((line) => { line.amount = line.amountOverride === undefined ? roundMoney(line.quantity * line.unitPrice) : roundMoney(line.amountOverride); }); const subtotal = roundMoney(lines.reduce((sum, line) => sum + line.amount, 0)); const taxAmount = roundMoney(subtotal * taxRate / 100); const totalAmount = roundMoney(subtotal + taxAmount); const timestamp = new Date().toISOString(); const invoiceId = `invoice-${organizationId}-${periodStart.slice(0, 10).replaceAll("-", "")}-${periodEnd.slice(0, 10).replaceAll("-", "")}`; const invoiceNumber = `INV-${organizationId.replace(/[^A-Za-z0-9]+/g, "-").toUpperCase()}-${periodStart.slice(0, 10).replaceAll("-", "")}-${periodEnd.slice(0, 10).replaceAll("-", "")}`; const snapshot = { billing: { planName: billing.plan_name, billingCycle: cycle, currency: billing.currency || "CNY", baseFee: Number(billing.base_fee || 0), seatUnitPrice: Number(billing.seat_unit_price || 0), storageUnitPrice: Number(billing.storage_unit_price || 0), clipUnitPrice: Number(billing.clip_unit_price || 0) }, period: { start: periodStart, end: periodEnd }, usage: { eventCount: events.length, totalUnits: roundMoney(totalUnits), estimatedCost: roundMoney(estimatedCost), clipUnits, storageUsed, activeSeats }, taxRate }; withTransaction(() => { dbRun("INSERT INTO organization_invoices(id, organization_id, billing_account_id, invoice_number, status, currency, billing_cycle, period_start, period_end, due_at, subtotal, tax_rate, tax_amount, total_amount, snapshot_json, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [invoiceId, organizationId, billing.id, invoiceNumber, billing.currency || "CNY", cycle, periodStart, periodEnd, dueDate, subtotal, taxRate, taxAmount, totalAmount, JSON.stringify(snapshot), context.user.id, timestamp, timestamp]); for (const [index, line] of lines.entries()) { dbRun("INSERT INTO invoice_lines(id, invoice_id, line_type, description, quantity, unit_name, unit_price, amount, metadata_json, sort_order, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [`invoice-line-${invoiceId}-${index + 1}`, invoiceId, line.lineType, line.description, line.quantity, line.unitName, line.unitPrice, line.amount, JSON.stringify(line.metadata || {}), index, timestamp]); } dbRun("INSERT INTO billing_account_events(id, organization_id, billing_account_id, event_type, previous_json, next_json, actor_user_id, created_at) VALUES (?, ?, ?, 'invoice.generated', '{}', ?, ?, ?)", [`bill-event-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, organizationId, billing.id, JSON.stringify({ invoiceId, invoiceNumber, periodStart, periodEnd, totalAmount }), context.user.id, timestamp]); }); addAudit({ context, action: "billing.invoice.generated", targetType: "organization_invoice", targetId: invoiceId, metadata: { invoiceNumber, periodStart, periodEnd, totalAmount, idempotent: false } }); return { ...invoiceDetailRow(organizationId, invoiceId), idempotent: false }; } export function updateOrganizationInvoiceStatus(context, organizationId, invoiceId, body = {}) { if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能修改其他组织的账单", { organizationId }); requirePermission(context, "billing:manage"); const current = dbGet("SELECT * FROM organization_invoices WHERE organization_id = ? AND id = ?", [organizationId, invoiceId]); if (!current) throw httpError(404, "invoice_not_found", "账单不存在或不属于当前组织", { invoiceId }); const nextStatus = String(body.status || "").trim(); if (!["draft", "issued", "paid", "overdue", "void"].includes(nextStatus)) throw httpError(400, "invoice_status_invalid", "账单状态无效", { status: nextStatus }); if (nextStatus === current.status) return { ...invoiceDetailRow(organizationId, invoiceId), idempotent: true }; const transitions = { draft: new Set(["issued", "void"]), issued: new Set(["paid", "overdue", "void"]), overdue: new Set(["paid", "void"]), paid: new Set(), void: new Set() }; if (!transitions[current.status]?.has(nextStatus)) throw httpError(409, "invoice_transition_invalid", `账单不能从${invoiceStatusLabel(current.status)}变更为${invoiceStatusLabel(nextStatus)}`, { from: current.status, to: nextStatus }); const timestamp = new Date().toISOString(); const issuedAt = nextStatus === "issued" && !current.issued_at ? timestamp : current.issued_at; const paidAt = nextStatus === "paid" ? (current.paid_at || timestamp) : current.paid_at; const voidedAt = nextStatus === "void" ? (current.voided_at || timestamp) : current.voided_at; dbRun("UPDATE organization_invoices SET status = ?, issued_at = ?, paid_at = ?, voided_at = ?, updated_at = ? WHERE id = ? AND organization_id = ?", [nextStatus, issuedAt, paidAt, voidedAt, timestamp, invoiceId, organizationId]); dbRun("INSERT INTO billing_account_events(id, organization_id, billing_account_id, event_type, previous_json, next_json, actor_user_id, created_at) VALUES (?, ?, ?, 'invoice.status.updated', ?, ?, ?, ?)", [`bill-event-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, organizationId, current.billing_account_id, JSON.stringify({ status: current.status }), JSON.stringify({ status: nextStatus }), context.user.id, timestamp]); addAudit({ context, action: "billing.invoice.status.updated", targetType: "organization_invoice", targetId: invoiceId, metadata: { invoiceNumber: current.invoice_number, previousStatus: current.status, status: nextStatus } }); return { ...invoiceDetailRow(organizationId, invoiceId), idempotent: false }; } export function exportOrganizationCommercial(context, organizationId) { if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能导出其他组织的商业运营数据", { organizationId }); if (!hasPermission(context, "usage:view") && !hasPermission(context, "billing:manage") && !hasPermission(context, "quota:manage")) throw httpError(403, "permission_denied", "当前角色没有导出组织商业运营数据的权限"); return { exportedAt: new Date().toISOString(), organizationId, ...organizationCommercial(context) }; } function usageOrganizationScope(context, options = {}) { const organizationId = context.organization.id; const orgWide = Boolean(context.systemAdmin || context.orgElevated || ["org_owner", "org_admin"].includes(context.organizationMembership?.role_key)); const clauses = ["ue.organization_id = ?"]; const params = [organizationId]; const requestedWorkspaceId = String(options.workspaceId || "").trim(); const requestedProjectId = String(options.projectId || "").trim(); if (requestedWorkspaceId) { const workspace = dbGet("SELECT id, organization_id FROM workspaces WHERE id = ? AND status = 'active'", [requestedWorkspaceId]); if (!workspace || workspace.organization_id !== organizationId) throw httpError(404, "workspace_not_found", "用量筛选工作区不存在或不属于当前组织", { workspaceId: requestedWorkspaceId }); if (!orgWide && requestedWorkspaceId !== context.workspace?.id) throw httpError(403, "usage_scope_forbidden", "当前角色只能查看当前工作区的用量", { workspaceId: requestedWorkspaceId }); clauses.push("ue.workspace_id = ?"); params.push(requestedWorkspaceId); } else if (!orgWide) { clauses.push("ue.workspace_id = ?"); params.push(context.workspace.id); } if (requestedProjectId) { const project = dbGet( `SELECT p.id, p.workspace_id, w.organization_id FROM projects p JOIN workspaces w ON w.id = p.workspace_id WHERE p.id = ?`, [requestedProjectId] ); if (!project || project.organization_id !== organizationId) throw httpError(404, "project_not_found", "用量筛选项目不存在或不属于当前组织", { projectId: requestedProjectId }); if (!orgWide && project.workspace_id !== context.workspace?.id) throw httpError(403, "usage_scope_forbidden", "当前角色不能查看其他工作区项目用量", { projectId: requestedProjectId }); clauses.push("ue.project_id = ?"); params.push(requestedProjectId); } else if (!orgWide && context.project?.id) { clauses.push("(ue.project_id IS NULL OR ue.project_id = ?)"); params.push(context.project.id); } return { clauses, params, orgWide }; } function usageFilterQuery(options = {}) { const clauses = []; const params = []; const query = String(options.query || "").trim().slice(0, 160); if (query) { const like = `%${query.toLowerCase()}%`; clauses.push("(LOWER(ue.kind) LIKE ? OR LOWER(ue.unit_name) LIKE ? OR LOWER(COALESCE(ue.metadata_json, '')) LIKE ? OR LOWER(COALESCE(w.name, '')) LIKE ? OR LOWER(COALESCE(p.name, '')) LIKE ? OR LOWER(COALESCE(u.display_name, '')) LIKE ? OR LOWER(COALESCE(u.email, '')) LIKE ?)"); params.push(like, like, like, like, like, like, like); } for (const [key, column] of [["kind", "ue.kind"], ["unitName", "ue.unit_name"], ["userId", "ue.user_id"]]) { const value = String(options[key] || "").trim(); if (value) { clauses.push(`${column} = ?`); params.push(value); } } const costCenter = String(options.costCenter || "").trim().toLowerCase(); if (costCenter) { const metadataCostCenter = "LOWER(COALESCE(json_extract(ue.metadata_json, '$.costCenter'), ''))"; const eventKind = "LOWER(ue.kind)"; const defaultLocalGpu = `(${eventKind} LIKE '%image%' OR ${eventKind} LIKE '%video%' OR ${eventKind} LIKE '%i2v%' OR ${eventKind} LIKE '%tts%' OR ${eventKind} LIKE '%asr%' OR ${eventKind} LIKE '%generation%')`; const defaultStorage = `(${eventKind} LIKE '%storage%' OR ${eventKind} LIKE '%asset%' OR ${eventKind} LIKE '%delivery%' OR ${eventKind} LIKE '%export%')`; const defaultOperations = `NOT ${defaultLocalGpu} AND NOT ${defaultStorage}`; if (costCenter === "local-gpu") clauses.push(`(${metadataCostCenter} = ? OR (${metadataCostCenter} = '' AND ${defaultLocalGpu}))`); else if (costCenter === "storage") clauses.push(`(${metadataCostCenter} = ? OR (${metadataCostCenter} = '' AND ${defaultStorage}))`); else if (costCenter === "operations") clauses.push(`(${metadataCostCenter} = ? OR (${metadataCostCenter} = '' AND ${defaultOperations}))`); else clauses.push(`${metadataCostCenter} = ?`); params.push(costCenter); } const from = auditDate(options.from, "开始时间"); const to = auditDate(options.to, "结束时间", true); if (from) { clauses.push("ue.created_at >= ?"); params.push(from); } else { clauses.push("ue.created_at >= datetime('now', 'start of month')"); } if (to) { clauses.push("ue.created_at <= ?"); params.push(to); } return { clauses, params, from, to }; } function usagePageOptions(options = {}) { const exportMode = Boolean(options.exportMode); const rawPage = Number(options.page || 1); const rawPageSize = Number(options.pageSize || (exportMode ? 10000 : 25)); const page = Number.isFinite(rawPage) ? Math.min(100000, Math.max(1, Math.floor(rawPage))) : 1; const pageSize = Number.isFinite(rawPageSize) ? Math.min(exportMode ? 10000 : 100, Math.max(1, Math.floor(rawPageSize))) : exportMode ? 10000 : 25; return { page, pageSize, offset: (page - 1) * pageSize }; } function usageEventPayload(row) { const metadata = parseJson(row.metadata_json, {}); return { id: row.id, createdAt: row.created_at, organizationId: row.organization_id, workspaceId: row.workspace_id, workspaceName: row.workspace_name || "组织级", projectId: row.project_id, projectName: row.project_name || "组织级", userId: row.user_id, userName: row.user_name || row.user_id || "系统", userEmail: row.user_email || "", kind: row.kind, units: Number(row.units || 0), unitName: row.unit_name, estimatedCost: Number(row.estimated_cost || 0), costCenter: costCenterCodeForUsage(row), metadata }; } function usageFilterFacets(context, organizationId, orgWide) { const workspaces = orgWide ? dbAll("SELECT id, name, slug FROM workspaces WHERE organization_id = ? AND status = 'active' ORDER BY name", [organizationId]) : dbAll("SELECT id, name, slug FROM workspaces WHERE id = ? AND organization_id = ? AND status = 'active'", [context.workspace.id, organizationId]); const workspaceIds = workspaces.map((workspace) => workspace.id); const projects = workspaceIds.length ? dbAll( `SELECT p.id, p.name, p.workspace_id, w.name AS workspace_name FROM projects p JOIN workspaces w ON w.id = p.workspace_id WHERE p.workspace_id IN (${workspaceIds.map(() => "?").join(",")}) ORDER BY w.name, p.name`, workspaceIds ) : []; const users = dbAll( `SELECT DISTINCT u.id, u.display_name, u.email FROM usage_events ue JOIN users u ON u.id = ue.user_id WHERE ue.organization_id = ?${orgWide ? "" : " AND ue.workspace_id = ?"} ORDER BY u.display_name`, orgWide ? [organizationId] : [organizationId, context.workspace.id] ); const kinds = dbAll( `SELECT DISTINCT ue.kind FROM usage_events ue WHERE ue.organization_id = ?${orgWide ? "" : " AND ue.workspace_id = ?"} ORDER BY ue.kind`, orgWide ? [organizationId] : [organizationId, context.workspace.id] ).map((row) => row.kind); const costCenters = dbAll("SELECT id, code, name, currency FROM cost_centers WHERE organization_id = ? AND status = 'active' ORDER BY name", [organizationId]); return { workspaces, projects, users, kinds, costCenters }; } export function organizationUsage(context, organizationId, options = {}) { if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能查看其他组织的用量明细", { organizationId }); if (!hasPermission(context, "usage:view") && !hasPermission(context, "billing:manage") && !hasPermission(context, "quota:manage")) throw httpError(403, "permission_denied", "当前角色没有查看组织用量明细的权限"); const scope = usageOrganizationScope(context, options); const filters = usageFilterQuery(options); const where = [...scope.clauses, ...filters.clauses]; const whereParams = [...scope.params, ...filters.params]; const pageOptions = usagePageOptions(options); const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : ""; const countRow = dbGet(`SELECT COUNT(*) AS event_count, COALESCE(SUM(ue.units), 0) AS total_units, COALESCE(SUM(ue.estimated_cost), 0) AS total_cost FROM usage_events ue LEFT JOIN workspaces w ON w.id = ue.workspace_id LEFT JOIN projects p ON p.id = ue.project_id LEFT JOIN users u ON u.id = ue.user_id ${whereSql}`, whereParams); const byKind = dbAll( `SELECT ue.kind, ue.unit_name, SUM(ue.units) AS units, SUM(ue.estimated_cost) AS estimated_cost, COUNT(*) AS events FROM usage_events ue LEFT JOIN workspaces w ON w.id = ue.workspace_id LEFT JOIN projects p ON p.id = ue.project_id LEFT JOIN users u ON u.id = ue.user_id ${whereSql} GROUP BY ue.kind, ue.unit_name ORDER BY estimated_cost DESC, events DESC`, whereParams ).map((row) => ({ kind: row.kind, unitName: row.unit_name, units: Number(row.units || 0), estimatedCost: Number(row.estimated_cost || 0), events: Number(row.events || 0) })); const rows = dbAll( `SELECT ue.*, w.name AS workspace_name, p.name AS project_name, u.display_name AS user_name, u.email AS user_email FROM usage_events ue LEFT JOIN workspaces w ON w.id = ue.workspace_id LEFT JOIN projects p ON p.id = ue.project_id LEFT JOIN users u ON u.id = ue.user_id ${whereSql} ORDER BY ue.created_at DESC, ue.id DESC LIMIT ? OFFSET ?`, [...whereParams, pageOptions.pageSize, pageOptions.offset] ).map(usageEventPayload); const total = Number(countRow?.event_count || 0); return { organizationId, filters: { query: String(options.query || "").trim(), workspaceId: String(options.workspaceId || "").trim(), projectId: String(options.projectId || "").trim(), userId: String(options.userId || "").trim(), kind: String(options.kind || "").trim(), unitName: String(options.unitName || "").trim(), costCenter: String(options.costCenter || "").trim(), from: filters.from || "month-start", to: filters.to || "" }, items: rows, summary: { eventCount: total, totalUnits: Number(countRow?.total_units || 0), totalCost: Number(countRow?.total_cost || 0), byKind }, pagination: { page: pageOptions.page, pageSize: pageOptions.pageSize, total, totalPages: total ? Math.ceil(total / pageOptions.pageSize) : 0 }, facets: usageFilterFacets(context, organizationId, scope.orgWide) }; } export function exportOrganizationUsage(context, organizationId, options = {}) { return { exportedAt: new Date().toISOString(), ...organizationUsage(context, organizationId, { ...options, page: 1, pageSize: 10000, exportMode: true }) }; } export function usageSummary(context) { const usage = dbAll( `SELECT kind, unit_name, SUM(units) AS units, SUM(estimated_cost) AS estimated_cost FROM usage_events WHERE organization_id = ? AND created_at >= datetime('now', 'start of month') GROUP BY kind, unit_name ORDER BY estimated_cost DESC`, [context.organization.id] ); const quotas = dbAll( `SELECT metric, limit_value, used_value, unit, period_start, period_end FROM quota_allocations WHERE organization_id = ? AND (workspace_id = ? OR workspace_id IS NULL) ORDER BY metric`, [context.organization.id, context.workspace.id] ); const totalCost = usage.reduce((sum, item) => sum + Number(item.estimated_cost || 0), 0); return { usage, quotas, totalCost }; } function quotaRow(context, metric) { return dbGet( `SELECT * FROM quota_allocations WHERE organization_id = ? AND metric = ? 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, metric, context.workspace.id, context.workspace.id] ); } export function requireQuota(context, metric, units = 1) { const row = quotaRow(context, metric); if (!row) return null; const requested = Number(units || 0); const rowUsed = Number(row.used_value || 0); const rowLimit = Number(row.limit_value || 0); const billing = billingAccount(context.organization.id); const planLimit = metric === "clip" ? Number(billing?.monthly_clip_quota || 0) : 0; const planUsed = metric === "clip" ? Number(dbGet("SELECT COALESCE(SUM(units), 0) AS units FROM usage_events WHERE organization_id = ? AND unit_name IN ('job', 'clip', 'clips') AND created_at >= datetime('now', 'start of month')", [context.organization.id])?.units || 0) : 0; const effectiveLimit = planLimit ? Math.min(rowLimit, planLimit) : rowLimit; const effectiveUsed = Math.max(rowUsed, planUsed); if (effectiveUsed + requested > effectiveLimit) { throw httpError(429, "quota_exceeded", `当前组织的${row.unit || metric}额度不足`, { metric, unit: row.unit, used: effectiveUsed, limit: effectiveLimit, requested }); } return { ...row, used_value: effectiveUsed, limit_value: effectiveLimit, remaining: effectiveLimit - effectiveUsed - requested }; } function auditInteger(value, fallback, minimum, maximum) { const parsed = Number(value); if (!Number.isFinite(parsed)) return fallback; return Math.min(maximum, Math.max(minimum, Math.floor(parsed))); } function auditDate(value, field, endOfDay = false) { const raw = String(value || "").trim(); if (!raw) return ""; const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw) ? `${raw}T${endOfDay ? "23:59:59.999" : "00:00:00.000"}Z` : raw; const date = new Date(normalized); if (Number.isNaN(date.getTime())) throw httpError(400, "audit_date_invalid", `${field} 不是有效日期`, { field, value }); return date.toISOString(); } function auditPayload(row) { if (!row) return null; return { ...row, organizationName: row.organization_name || "", workspaceName: row.workspace_name || "", projectName: row.project_name || "", actorName: row.actor_name || row.actor_user_id || "system", actorEmail: row.actor_email || "", metadata: parseJson(row.metadata_json, {}) }; } function auditSecurityPayload(row) { return { ...row, userName: row.user_name || row.user_id || "未知用户", metadata: parseJson(row.metadata_json, {}) }; } function auditScope(context, options = {}) { const requestedOrganizationId = String(options.organizationId || "").trim(); const requestedWorkspaceId = String(options.workspaceId || "").trim(); const requestedProjectId = String(options.projectId || "").trim(); const clauses = []; const params = []; const orgWide = Boolean(context.orgElevated || ["org_owner", "org_admin"].includes(context.organizationMembership?.role_key)); const scope = { mode: context.systemAdmin ? "global" : orgWide ? "organization" : context.project ? "project" : "workspace", organizationId: context.systemAdmin ? requestedOrganizationId || null : context.organization.id, workspaceId: requestedWorkspaceId || (!context.systemAdmin && !orgWide ? context.workspace?.id || null : null), projectId: requestedProjectId || (!context.systemAdmin && !orgWide ? context.project?.id || null : null) }; if (context.systemAdmin) { if (requestedOrganizationId) { const organization = dbGet("SELECT id FROM organizations WHERE id = ?", [requestedOrganizationId]); if (!organization) throw httpError(404, "organization_not_found", "审计筛选组织不存在", { organizationId: requestedOrganizationId }); clauses.push("a.organization_id = ?"); params.push(requestedOrganizationId); } if (requestedWorkspaceId) { const workspace = dbGet("SELECT id, organization_id FROM workspaces WHERE id = ?", [requestedWorkspaceId]); if (!workspace) throw httpError(404, "workspace_not_found", "审计筛选工作区不存在", { workspaceId: requestedWorkspaceId }); if (requestedOrganizationId && workspace.organization_id !== requestedOrganizationId) throw httpError(400, "audit_scope_invalid", "工作区不属于筛选组织"); clauses.push("a.workspace_id = ?"); params.push(requestedWorkspaceId); scope.organizationId ||= workspace.organization_id; } if (requestedProjectId) { const project = dbGet( `SELECT p.id, p.workspace_id, w.organization_id FROM projects p JOIN workspaces w ON w.id = p.workspace_id WHERE p.id = ?`, [requestedProjectId] ); if (!project) throw httpError(404, "project_not_found", "审计筛选项目不存在", { projectId: requestedProjectId }); if (requestedWorkspaceId && project.workspace_id !== requestedWorkspaceId) throw httpError(400, "audit_scope_invalid", "项目不属于筛选工作区"); if (requestedOrganizationId && project.organization_id !== requestedOrganizationId) throw httpError(400, "audit_scope_invalid", "项目不属于筛选组织"); clauses.push("a.project_id = ?"); params.push(requestedProjectId); scope.organizationId ||= project.organization_id; scope.workspaceId ||= project.workspace_id; } return { clauses, params, scope }; } if (requestedOrganizationId && requestedOrganizationId !== context.organization.id) { throw httpError(403, "audit_scope_forbidden", "不能查看其他组织的审计记录", { organizationId: requestedOrganizationId }); } clauses.push("a.organization_id = ?"); params.push(context.organization.id); if (requestedWorkspaceId) { const workspace = dbGet("SELECT id FROM workspaces WHERE id = ? AND organization_id = ?", [requestedWorkspaceId, context.organization.id]); if (!workspace) throw httpError(404, "workspace_not_found", "审计筛选工作区不存在或不属于当前组织", { workspaceId: requestedWorkspaceId }); if (!orgWide && requestedWorkspaceId !== context.workspace.id) throw httpError(403, "audit_scope_forbidden", "当前角色只能查看当前工作区审计记录", { workspaceId: requestedWorkspaceId }); clauses.push("a.workspace_id = ?"); params.push(requestedWorkspaceId); scope.workspaceId = requestedWorkspaceId; } else if (!orgWide) { clauses.push("(a.workspace_id IS NULL OR a.workspace_id = ?)"); params.push(context.workspace.id); } if (requestedProjectId) { const project = dbGet( `SELECT p.id, p.workspace_id FROM projects p JOIN workspaces w ON w.id = p.workspace_id WHERE p.id = ? AND w.organization_id = ?`, [requestedProjectId, context.organization.id] ); if (!project) throw httpError(404, "project_not_found", "审计筛选项目不存在或不属于当前组织", { projectId: requestedProjectId }); if (!orgWide && project.workspace_id !== context.workspace.id) throw httpError(403, "audit_scope_forbidden", "当前角色不能查看其他工作区项目审计记录", { projectId: requestedProjectId }); clauses.push("a.project_id = ?"); params.push(requestedProjectId); scope.projectId = requestedProjectId; } else if (!orgWide) { clauses.push(context.project?.id ? "(a.project_id IS NULL OR a.project_id = ?)" : "a.project_id IS NULL"); if (context.project?.id) params.push(context.project.id); } return { clauses, params, scope }; } const AUDIT_SELECT = ` SELECT a.*, o.name AS organization_name, w.name AS workspace_name, p.name AS project_name, u.display_name AS actor_name, u.email AS actor_email FROM audit_logs a LEFT JOIN organizations o ON o.id = a.organization_id LEFT JOIN workspaces w ON w.id = a.workspace_id LEFT JOIN projects p ON p.id = a.project_id LEFT JOIN users u ON u.id = a.actor_user_id`; function auditFilterQuery(options = {}) { const clauses = []; const params = []; const query = String(options.query || "").trim().slice(0, 160); if (query) { const like = `%${query.toLowerCase()}%`; clauses.push("(LOWER(a.action) LIKE ? OR LOWER(a.target_type) LIKE ? OR LOWER(a.target_id) LIKE ? OR LOWER(COALESCE(u.display_name, '')) LIKE ? OR LOWER(COALESCE(u.email, '')) LIKE ? OR LOWER(a.metadata_json) LIKE ?)"); params.push(like, like, like, like, like, like); } for (const [key, column] of [["action", "a.action"], ["targetType", "a.target_type"], ["targetId", "a.target_id"], ["result", "a.result"], ["actorUserId", "a.actor_user_id"]]) { const value = String(options[key] || "").trim(); if (value) { clauses.push(`${column} = ?`); params.push(value); } } const from = auditDate(options.from, "开始时间"); const to = auditDate(options.to, "结束时间", true); if (from) { clauses.push("a.created_at >= ?"); params.push(from); } if (to) { clauses.push("a.created_at <= ?"); params.push(to); } return { clauses, params }; } function auditFacets(scopeClauses, scopeParams) { const where = scopeClauses.length ? `WHERE ${scopeClauses.join(" AND ")}` : ""; const base = (column) => dbAll(`SELECT ${column} AS value, COUNT(*) AS count FROM audit_logs a LEFT JOIN users u ON u.id = a.actor_user_id ${where} GROUP BY ${column} ORDER BY count DESC, value LIMIT 100`, scopeParams); return { actions: base("a.action").filter((item) => item.value).map((item) => ({ value: item.value, count: Number(item.count || 0) })), targetTypes: base("a.target_type").filter((item) => item.value).map((item) => ({ value: item.value, count: Number(item.count || 0) })), results: base("a.result").filter((item) => item.value).map((item) => ({ value: item.value, count: Number(item.count || 0) })), actors: dbAll(`SELECT a.actor_user_id AS value, COALESCE(u.display_name, a.actor_user_id, 'system') AS label, COUNT(*) AS count FROM audit_logs a LEFT JOIN users u ON u.id = a.actor_user_id ${where} GROUP BY a.actor_user_id, u.display_name ORDER BY count DESC, label LIMIT 100`, scopeParams) .filter((item) => item.value) .map((item) => ({ value: item.value, label: item.label, count: Number(item.count || 0) })) }; } export function listAuditEvents(context, options = {}) { requirePermission(context, "audit:view"); const { clauses: scopeClauses, params: scopeParams, scope } = auditScope(context, options); const filters = auditFilterQuery(options); const clauses = [...scopeClauses, ...filters.clauses]; const params = [...scopeParams, ...filters.params]; const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; const page = auditInteger(options.page, 1, 1, 100000); const maxPageSize = options.forExport ? 5000 : 200; const pageSize = auditInteger(options.pageSize ?? options.limit, options.forExport ? 500 : 25, 1, maxPageSize); const total = Number(dbGet(`SELECT COUNT(*) AS count FROM audit_logs a LEFT JOIN users u ON u.id = a.actor_user_id ${where}`, params)?.count || 0); const auditLog = dbAll(`${AUDIT_SELECT} ${where} ORDER BY a.created_at DESC, a.id DESC LIMIT ? OFFSET ?`, [...params, pageSize, (page - 1) * pageSize]).map(auditPayload); return { auditLog, pagination: { page, pageSize, total, totalPages: Math.max(1, Math.ceil(total / pageSize)), hasMore: page * pageSize < total }, filters: { query: String(options.query || "").trim(), action: String(options.action || "").trim(), targetType: String(options.targetType || "").trim(), targetId: String(options.targetId || "").trim(), result: String(options.result || "").trim(), actorUserId: String(options.actorUserId || "").trim(), from: String(options.from || "").trim(), to: String(options.to || "").trim() }, facets: auditFacets(scopeClauses, scopeParams), scope }; } export function getAuditEvent(context, auditId) { requirePermission(context, "audit:view"); const id = String(auditId || "").trim(); if (!id) throw httpError(400, "audit_id_required", "审计事件 ID 不能为空"); const { clauses, params, scope } = auditScope(context); clauses.push("a.id = ?"); params.push(id); const row = dbGet(`${AUDIT_SELECT} WHERE ${clauses.join(" AND ")}`, params); if (!row) throw httpError(404, "audit_not_found", "审计事件不存在或不在当前权限范围内", { auditId: id }); const event = auditPayload(row); const relatedAudit = dbAll( `${AUDIT_SELECT} WHERE ${[...auditScope(context).clauses, "a.target_type = ?", "a.target_id = ?", "a.id <> ?"].join(" AND ")} ORDER BY a.created_at DESC, a.id DESC LIMIT 12`, [...auditScope(context).params, row.target_type, row.target_id, id] ).map(auditPayload); const metadata = event.metadata || {}; const userIds = [...new Set([row.actor_user_id, metadata.userId, metadata.user_id, metadata.actorUserId].map((value) => String(value || "").trim()).filter(Boolean))]; const references = [...new Set([metadata.sessionId, metadata.deviceRecordId, metadata.deviceId].map((value) => String(value || "").trim()).filter(Boolean))]; let relatedSecurityEvents = []; if (userIds.length || references.length) { const securityClauses = []; const securityParams = []; if (userIds.length) { securityClauses.push(`e.user_id IN (${userIds.map(() => "?").join(",")})`); securityParams.push(...userIds); } for (const reference of references) { securityClauses.push("e.metadata_json LIKE ?"); securityParams.push(`%${reference}%`); } relatedSecurityEvents = dbAll( `SELECT e.*, u.display_name AS user_name FROM auth_security_events e LEFT JOIN users u ON u.id = e.user_id WHERE ${securityClauses.map((item) => `(${item})`).join(" OR ")} ORDER BY e.created_at DESC LIMIT 20`, securityParams ).map(auditSecurityPayload); } return { event, relatedAudit, relatedSecurityEvents, scope }; } export function exportAuditEvents(context, options = {}) { const result = listAuditEvents(context, { ...options, page: 1, forExport: true, pageSize: options.pageSize || options.limit || 500 }); return { exportedAt: new Date().toISOString(), scope: result.scope, filters: result.filters, total: result.pagination.total, auditLog: result.auditLog }; } export function scopedAuditLog(context, limit = 40) { return listAuditEvents(context, { page: 1, pageSize: limit }).auditLog; } export function scopedModels(context) { return dbAll( `SELECT * FROM model_connectors WHERE organization_id = ? AND (workspace_id IS NULL OR workspace_id = ?) ORDER BY created_at DESC`, [context.organization.id, context.workspace.id] ).map(parseModelRow); } function parseModelCatalogRow(row) { if (!row) return null; return { ...row, connectorId: row.connector_id || "", connectorLabel: row.connector_label || "", connectorKind: row.connector_kind || "", connectorStatus: row.connector_status || "", capabilities: parseJson(row.capabilities_json, []), contextWindow: Number(row.context_window || 0), maxOutputTokens: Number(row.max_output_tokens || 0), cost: parseJson(row.cost_json, {}), metadata: parseJson(row.metadata_json, {}) }; } function scopedModelCatalogRows(context) { return dbAll( `SELECT mce.*, mc.label AS connector_label, mc.kind AS connector_kind, mc.status AS connector_status FROM model_catalog_entries mce LEFT JOIN model_connectors mc ON mc.id = mce.connector_id WHERE mce.organization_id = ? AND (mce.workspace_id IS NULL OR mce.workspace_id = ?) ORDER BY mce.updated_at DESC, mce.created_at DESC`, [context.organization.id, context.workspace.id] ); } export function scopedModelCatalog(context) { return scopedModelCatalogRows(context).map(parseModelCatalogRow); } function ensureScopedConnector(context, connectorId, { allowEmpty = false } = {}) { const normalized = String(connectorId || "").trim(); if (!normalized) { if (allowEmpty) return null; throw httpError(400, "connector_required", "模型目录必须关联一个连接器"); } const connector = dbGet( `SELECT * FROM model_connectors WHERE id = ? AND organization_id = ? AND (workspace_id IS NULL OR workspace_id = ?)`, [normalized, context.organization.id, context.workspace.id] ); if (!connector) throw httpError(404, "model_connector_not_found", "连接器不存在或不属于当前工作区", { connectorId: normalized }); return connector; } function ensureScopedModelCatalogEntry(context, modelId, { allowEmpty = false } = {}) { const normalized = String(modelId || "").trim(); if (!normalized) { if (allowEmpty) return null; throw httpError(400, "model_catalog_entry_required", "必须指定模型目录条目"); } const row = dbGet( `SELECT * FROM model_catalog_entries WHERE id = ? AND organization_id = ? AND (workspace_id IS NULL OR workspace_id = ?)`, [normalized, context.organization.id, context.workspace.id] ); if (!row) throw httpError(404, "model_catalog_entry_not_found", "模型目录条目不存在或不属于当前工作区", { modelId: normalized }); return row; } function normalizeCatalogCapabilities(value) { if (Array.isArray(value)) return value.map((item) => String(item || "").trim()).filter(Boolean); return String(value || "").split(",").map((item) => item.trim()).filter(Boolean); } export function createModelCatalogEntry(context, body = {}) { requirePermission(context, "model:manage"); const connector = ensureScopedConnector(context, body.connectorId || body.connector_id); const displayName = String(body.displayName || body.display_name || "").trim(); const modelKey = String(body.modelKey || body.model_key || "").trim(); if (!displayName || !modelKey) throw httpError(400, "model_catalog_fields_required", "模型名称和模型 Key 不能为空"); const family = String(body.family || "").trim(); const capabilities = normalizeCatalogCapabilities(body.capabilities || body.capability); const status = String(body.status || "active").trim(); const approvalStatus = String(body.approvalStatus || body.approval_status || "approved").trim(); if (!["draft", "active", "paused", "review", "archived"].includes(status)) throw httpError(400, "model_catalog_status_invalid", "模型目录状态无效"); if (!["approved", "review", "blocked", "pending"].includes(approvalStatus)) throw httpError(400, "model_catalog_approval_invalid", "模型审批状态无效"); const contextWindow = Math.max(0, Number(body.contextWindow || body.context_window || 0)); const maxOutputTokens = Math.max(0, Number(body.maxOutputTokens || body.max_output_tokens || 0)); const cost = body.cost && typeof body.cost === "object" ? body.cost : {}; const metadata = body.metadata && typeof body.metadata === "object" ? body.metadata : {}; const timestamp = new Date().toISOString(); const id = String(body.id || `catalog-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`); dbRun( `INSERT INTO model_catalog_entries( id, organization_id, workspace_id, connector_id, model_key, display_name, family, capabilities_json, context_window, max_output_tokens, cost_json, status, approval_status, metadata_json, created_by, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [id, context.organization.id, context.workspace.id, connector.id, modelKey, displayName, family, JSON.stringify(capabilities), contextWindow, maxOutputTokens, JSON.stringify(cost), status, approvalStatus, JSON.stringify(metadata), context.user.id, timestamp, timestamp] ); addAudit({ context, action: "model.catalog.created", targetType: "model_catalog_entry", targetId: id, metadata: { connectorId: connector.id, modelKey, displayName } }); return parseModelCatalogRow(dbGet( `SELECT mce.*, mc.label AS connector_label, mc.kind AS connector_kind, mc.status AS connector_status FROM model_catalog_entries mce LEFT JOIN model_connectors mc ON mc.id = mce.connector_id WHERE mce.id = ?`, [id] )); } export function updateModelCatalogEntry(context, entryId, body = {}) { requirePermission(context, "model:manage"); const current = ensureScopedModelCatalogEntry(context, entryId); const connector = body.connectorId === undefined && body.connector_id === undefined ? ensureScopedConnector(context, current.connector_id) : ensureScopedConnector(context, body.connectorId || body.connector_id); const displayName = String(body.displayName ?? body.display_name ?? current.display_name).trim(); const modelKey = String(body.modelKey ?? body.model_key ?? current.model_key).trim(); if (!displayName || !modelKey) throw httpError(400, "model_catalog_fields_required", "模型名称和模型 Key 不能为空"); const family = String(body.family ?? current.family ?? "").trim(); const capabilities = body.capabilities === undefined && body.capability === undefined ? parseJson(current.capabilities_json, []) : normalizeCatalogCapabilities(body.capabilities || body.capability); const status = String(body.status ?? current.status ?? "active").trim(); const approvalStatus = String(body.approvalStatus ?? body.approval_status ?? current.approval_status ?? "approved").trim(); if (!["draft", "active", "paused", "review", "archived"].includes(status)) throw httpError(400, "model_catalog_status_invalid", "模型目录状态无效"); if (!["approved", "review", "blocked", "pending"].includes(approvalStatus)) throw httpError(400, "model_catalog_approval_invalid", "模型审批状态无效"); const contextWindow = Math.max(0, Number(body.contextWindow ?? body.context_window ?? current.context_window ?? 0)); const maxOutputTokens = Math.max(0, Number(body.maxOutputTokens ?? body.max_output_tokens ?? current.max_output_tokens ?? 0)); const cost = body.cost === undefined ? parseJson(current.cost_json, {}) : (body.cost && typeof body.cost === "object" ? body.cost : {}); const metadata = body.metadata === undefined ? parseJson(current.metadata_json, {}) : (body.metadata && typeof body.metadata === "object" ? body.metadata : {}); const timestamp = new Date().toISOString(); dbRun( `UPDATE model_catalog_entries SET connector_id = ?, model_key = ?, display_name = ?, family = ?, capabilities_json = ?, context_window = ?, max_output_tokens = ?, cost_json = ?, status = ?, approval_status = ?, metadata_json = ?, updated_at = ? WHERE id = ?`, [connector.id, modelKey, displayName, family, JSON.stringify(capabilities), contextWindow, maxOutputTokens, JSON.stringify(cost), status, approvalStatus, JSON.stringify(metadata), timestamp, entryId] ); addAudit({ context, action: "model.catalog.updated", targetType: "model_catalog_entry", targetId: entryId, metadata: { connectorId: connector.id, modelKey, displayName } }); return parseModelCatalogRow(dbGet( `SELECT mce.*, mc.label AS connector_label, mc.kind AS connector_kind, mc.status AS connector_status FROM model_catalog_entries mce LEFT JOIN model_connectors mc ON mc.id = mce.connector_id WHERE mce.id = ?`, [entryId] )); } function parseModelRouteRow(row) { if (!row) return null; return { ...row, primaryModelId: row.primary_model_id || "", fallbackModelId: row.fallback_model_id || "", budgetLimitCny: Number(row.budget_limit_cny || 0), policy: parseJson(row.policy_json, {}), primaryModelLabel: row.primary_model_label || "", fallbackModelLabel: row.fallback_model_label || "" }; } export function scopedModelRoutes(context) { return dbAll( `SELECT mrp.*, primary_model.display_name AS primary_model_label, fallback_model.display_name AS fallback_model_label FROM model_routing_policies mrp LEFT JOIN model_catalog_entries primary_model ON primary_model.id = mrp.primary_model_id LEFT JOIN model_catalog_entries fallback_model ON fallback_model.id = mrp.fallback_model_id WHERE mrp.organization_id = ? AND (mrp.workspace_id IS NULL OR mrp.workspace_id = ?) ORDER BY mrp.updated_at DESC, mrp.created_at DESC`, [context.organization.id, context.workspace.id] ).map(parseModelRouteRow); } export function createModelRoute(context, body = {}) { requirePermission(context, "model:manage"); const name = String(body.name || "").trim(); const workflowKey = String(body.workflowKey || body.workflow_key || "").trim(); const operationKey = String(body.operationKey || body.operation_key || "").trim(); if (!name || !workflowKey || !operationKey) throw httpError(400, "model_route_fields_required", "路由名称、工作流和操作不能为空"); const primary = ensureScopedModelCatalogEntry(context, body.primaryModelId || body.primary_model_id); const fallback = ensureScopedModelCatalogEntry(context, body.fallbackModelId || body.fallback_model_id, { allowEmpty: true }); const policyMode = String(body.policyMode || body.policy_mode || "prefer-local").trim(); const approvalMode = String(body.approvalMode || body.approval_mode || "follow-model").trim(); const status = String(body.status || "active").trim(); if (!["prefer-local", "local-only", "prefer-approved", "manual-select"].includes(policyMode)) throw httpError(400, "model_route_policy_mode_invalid", "路由策略无效"); if (!["follow-model", "explicit-review", "always-allow"].includes(approvalMode)) throw httpError(400, "model_route_approval_mode_invalid", "审批模式无效"); if (!["draft", "active", "paused", "archived"].includes(status)) throw httpError(400, "model_route_status_invalid", "路由状态无效"); const budgetLimitCny = Math.max(0, Number(body.budgetLimitCny || body.budget_limit_cny || 0)); const policy = body.policy && typeof body.policy === "object" ? body.policy : {}; const timestamp = new Date().toISOString(); const id = String(body.id || `route-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`); dbRun( `INSERT INTO model_routing_policies( id, organization_id, workspace_id, name, workflow_key, operation_key, primary_model_id, fallback_model_id, policy_mode, approval_mode, budget_limit_cny, status, policy_json, created_by, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [id, context.organization.id, context.workspace.id, name, workflowKey, operationKey, primary.id, fallback?.id || null, policyMode, approvalMode, budgetLimitCny, status, JSON.stringify(policy), context.user.id, timestamp, timestamp] ); addAudit({ context, action: "model.route.created", targetType: "model_routing_policy", targetId: id, metadata: { name, workflowKey, operationKey, primaryModelId: primary.id, fallbackModelId: fallback?.id || null } }); return scopedModelRoutes(context).find((item) => item.id === id) || null; } export function updateModelRoute(context, routeId, body = {}) { requirePermission(context, "model:manage"); const current = dbGet( `SELECT * FROM model_routing_policies WHERE id = ? AND organization_id = ? AND (workspace_id IS NULL OR workspace_id = ?)`, [routeId, context.organization.id, context.workspace.id] ); if (!current) throw httpError(404, "model_route_not_found", "路由策略不存在或不属于当前工作区", { routeId }); const name = String(body.name ?? current.name ?? "").trim(); const workflowKey = String(body.workflowKey ?? body.workflow_key ?? current.workflow_key ?? "").trim(); const operationKey = String(body.operationKey ?? body.operation_key ?? current.operation_key ?? "").trim(); if (!name || !workflowKey || !operationKey) throw httpError(400, "model_route_fields_required", "路由名称、工作流和操作不能为空"); const primary = body.primaryModelId === undefined && body.primary_model_id === undefined ? ensureScopedModelCatalogEntry(context, current.primary_model_id) : ensureScopedModelCatalogEntry(context, body.primaryModelId || body.primary_model_id); const fallback = body.fallbackModelId === undefined && body.fallback_model_id === undefined ? ensureScopedModelCatalogEntry(context, current.fallback_model_id, { allowEmpty: true }) : ensureScopedModelCatalogEntry(context, body.fallbackModelId || body.fallback_model_id, { allowEmpty: true }); const policyMode = String(body.policyMode ?? body.policy_mode ?? current.policy_mode ?? "prefer-local").trim(); const approvalMode = String(body.approvalMode ?? body.approval_mode ?? current.approval_mode ?? "follow-model").trim(); const status = String(body.status ?? current.status ?? "active").trim(); if (!["prefer-local", "local-only", "prefer-approved", "manual-select"].includes(policyMode)) throw httpError(400, "model_route_policy_mode_invalid", "路由策略无效"); if (!["follow-model", "explicit-review", "always-allow"].includes(approvalMode)) throw httpError(400, "model_route_approval_mode_invalid", "审批模式无效"); if (!["draft", "active", "paused", "archived"].includes(status)) throw httpError(400, "model_route_status_invalid", "路由状态无效"); const budgetLimitCny = Math.max(0, Number(body.budgetLimitCny ?? body.budget_limit_cny ?? current.budget_limit_cny ?? 0)); const policy = body.policy === undefined ? parseJson(current.policy_json, {}) : (body.policy && typeof body.policy === "object" ? body.policy : {}); const timestamp = new Date().toISOString(); dbRun( `UPDATE model_routing_policies SET name = ?, workflow_key = ?, operation_key = ?, primary_model_id = ?, fallback_model_id = ?, policy_mode = ?, approval_mode = ?, budget_limit_cny = ?, status = ?, policy_json = ?, updated_at = ? WHERE id = ?`, [name, workflowKey, operationKey, primary.id, fallback?.id || null, policyMode, approvalMode, budgetLimitCny, status, JSON.stringify(policy), timestamp, routeId] ); addAudit({ context, action: "model.route.updated", targetType: "model_routing_policy", targetId: routeId, metadata: { name, workflowKey, operationKey, primaryModelId: primary.id, fallbackModelId: fallback?.id || null } }); return scopedModelRoutes(context).find((item) => item.id === routeId) || null; } function rolePolicyRows(organizationId) { const roles = dbAll("SELECT key, scope, name, description FROM roles ORDER BY scope, name"); const permissions = dbAll("SELECT key, description FROM permissions ORDER BY key").map((permission) => ({ ...permission, systemOnly: SYSTEM_ONLY_PERMISSIONS.has(permission.key) })); const overrides = dbAll( `SELECT organization_id, role_key, permission_key, effect, updated_by, created_at, updated_at FROM organization_role_permissions WHERE organization_id = ? ORDER BY role_key, permission_key`, [organizationId] ); return roles.map((role) => { const basePermissions = dbAll("SELECT permission_key FROM role_permissions WHERE role_key = ? ORDER BY permission_key", [role.key]).map((row) => row.permission_key); const roleOverrides = overrides.filter((override) => override.role_key === role.key); const effective = new Set(basePermissions); for (const override of roleOverrides) { if (override.effect === "grant") effective.add(override.permission_key); if (override.effect === "revoke") effective.delete(override.permission_key); } return { ...role, basePermissions, permissions: [...effective].sort(), overrides: roleOverrides.map((override) => ({ permissionKey: override.permission_key, effect: override.effect, updatedAt: override.updated_at })) }; }); } export function organizationRolePolicies(organizationId) { return { roles: rolePolicyRows(organizationId), permissions: dbAll("SELECT key, description FROM permissions ORDER BY key").map((permission) => ({ ...permission, systemOnly: SYSTEM_ONLY_PERMISSIONS.has(permission.key) })) }; } export function updateOrganizationRolePolicy(context, organizationId, roleKey, body = {}) { requirePermission(context, "organization:roles:manage"); if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能修改其他组织的角色策略", { organizationId }); const role = dbGet("SELECT key, scope, name FROM roles WHERE key = ?", [roleKey]); if (!role) throw httpError(404, "role_not_found", "角色不存在", { roleKey }); if (role.key === "org_owner") throw httpError(400, "owner_policy_locked", "组织所有者策略不可被组织级覆盖"); const permissionKey = String(body.permissionKey || "").trim(); const permission = dbGet("SELECT key FROM permissions WHERE key = ?", [permissionKey]); if (!permission) throw httpError(400, "permission_not_found", "权限不存在", { permissionKey }); if (SYSTEM_ONLY_PERMISSIONS.has(permissionKey)) throw httpError(400, "system_permission_locked", "系统级权限不能通过组织策略覆盖", { permissionKey }); if (typeof body.enabled !== "boolean") throw httpError(400, "policy_enabled_invalid", "enabled 必须是布尔值"); const baseline = Boolean(dbGet("SELECT 1 FROM role_permissions WHERE role_key = ? AND permission_key = ?", [roleKey, permissionKey])); const timestamp = new Date().toISOString(); if (body.enabled === baseline) { dbRun("DELETE FROM organization_role_permissions WHERE organization_id = ? AND role_key = ? AND permission_key = ?", [organizationId, roleKey, permissionKey]); } else { dbRun( `INSERT INTO organization_role_permissions(organization_id, role_key, permission_key, effect, updated_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(organization_id, role_key, permission_key) DO UPDATE SET effect = excluded.effect, updated_by = excluded.updated_by, updated_at = excluded.updated_at`, [organizationId, roleKey, permissionKey, body.enabled ? "grant" : "revoke", context.user.id, timestamp, timestamp] ); } addAudit({ context, action: "organization.role_policy.updated", targetType: "organization_role_permission", targetId: `${organizationId}:${roleKey}:${permissionKey}`, metadata: { roleKey, permissionKey, enabled: body.enabled, baseline } }); return organizationRolePolicies(organizationId); } const ASSET_RIGHTS_STATUSES = new Set(["needs-evidence", "submitted", "approved", "rejected", "expired"]); const ASSET_LAYOUT_TERMS = ["split-screen", "split screen", "comic panel", "comic panels", "collage", "contact sheet", "storyboard", "story board", "多格", "拼图", "分屏", "故事板", "故事板拼图", "九宫格"]; const ASSET_IP_TERMS = ["迪士尼", "漫威", "蜘蛛侠", "蝙蝠侠", "米老鼠", "奥特曼", "火影", "海贼王", "柯南", "原神", "王者荣耀", "三体", "流浪地球", "哈利波特", "宫崎骏", "吉卜力"]; const ASSET_LIKENESS_TERMS = ["明星", "名人", "演员本人", "真人肖像", "真人照片", "网红", "博主本人", "face clone", "deepfake", "voice clone", "声音克隆", "复刻声线"]; function objectValue(value, fallback = {}) { return value && typeof value === "object" && !Array.isArray(value) ? value : fallback; } function stringArray(value, fallback = []) { const source = Array.isArray(value) ? value : fallback; return [...new Set(source.map((item) => String(item || "").trim()).filter(Boolean))].slice(0, 24); } function mergeAssetTags(...sources) { return [...new Set(sources.flatMap((source) => stringArray(source)))].slice(0, 24); } function evidenceReference(evidence = {}, metadata = {}, provenance = {}) { const source = objectValue(evidence); const currentEvidence = objectValue(metadata?.rightsEvidence); return String( source.reference || source.consentRef || source.contractRef || source.licenseRef || source.evidenceRef || currentEvidence.reference || currentEvidence.consentRef || currentEvidence.contractRef || currentEvidence.licenseRef || metadata?.consentRef || provenance.evidenceRef || "" ).trim(); } function assetGovernanceText(asset, version, metadata, provenance, tags) { return [ asset?.kind, asset?.name, version?.storage_path, version?.file_name, metadata.subtitle, metadata.usage, metadata.detail, metadata.lock, metadata.visualLock, metadata.continuityLock, provenance.sourceType, provenance.sourceName, provenance.sourceRef, provenance.creator, tags.join(" ") ].filter(Boolean).join("\n"); } function contextNegatesTerm(text, term) { const normalized = text.toLowerCase(); const target = term.toLowerCase(); let index = normalized.indexOf(target); while (index >= 0) { const before = normalized.slice(Math.max(0, index - 16), index); if (!/(禁止|不得|不要|不能|避免|严禁|排除|negative|block|no\s*$|without\s*$)/i.test(before)) return false; index = normalized.indexOf(target, index + target.length); } return true; } function matchingRiskTerms(text, terms, { ignoreNegated = false } = {}) { return terms.filter((term) => { const present = text.toLowerCase().includes(term.toLowerCase()); if (!present) return false; return ignoreNegated ? !contextNegatesTerm(text, term) : true; }); } function buildAssetGovernanceScan(asset, version, proposal = {}) { const metadata = { ...parseJson(version?.metadata_json, {}), ...objectValue(proposal.metadata) }; const provenance = { ...objectValue(parseJson(version?.provenance_json, {})), ...objectValue(metadata.provenance), ...objectValue(proposal.provenance) }; const tags = mergeAssetTags(parseJson(version?.tags_json, []), metadata.tags, proposal.tags); const evidence = objectValue(proposal.evidence); const rightsStatus = String(proposal.rightsStatus || proposal.rights_status || version?.rights_status || "needs-evidence").trim(); const evidenceRef = evidenceReference(evidence, metadata, provenance); const text = assetGovernanceText(asset, version, metadata, provenance, tags); const issues = []; const missingSource = !String(provenance.sourceType || provenance.sourceName || provenance.sourceRef || "").trim(); if (missingSource) { issues.push({ code: "provenance_missing", severity: "review", label: "缺少来源", message: "资产未登记原创、委托、授权或本地导入来源。" }); } if (!evidenceRef) { issues.push({ code: "rights_evidence_missing", severity: rightsStatus === "approved" ? "blocking" : "review", label: "缺少授权证据", message: "商业使用前必须绑定合同、授权书、同意书或本地证据路径。" }); } if (asset?.kind === "voice" && !evidenceRef) { issues.push({ code: "voice_consent_missing", severity: rightsStatus === "approved" ? "blocking" : "review", label: "声音同意书缺失", message: "固定参考音频必须有可追溯授权,不使用随机原生声线作为最终方案。" }); } const layoutHits = matchingRiskTerms(text, ASSET_LAYOUT_TERMS, { ignoreNegated: true }); if (layoutHits.length) { issues.push({ code: "single_frame_layout_risk", severity: "blocking", label: "一图多画面风险", message: `检测到 ${layoutHits.join("、")},生产资产不得是分屏、多格、拼图或故事板。` }); } const ipHits = matchingRiskTerms(text, ASSET_IP_TERMS); if (ipHits.length) { issues.push({ code: "known_ip_similarity", severity: "blocking", label: "疑似既有 IP", message: `检测到 ${ipHits.join("、")} 等既有 IP/品牌关键词,不能作为原创商用资产直接批准。` }); } const likenessHits = matchingRiskTerms(text, ASSET_LIKENESS_TERMS); if (likenessHits.length) { issues.push({ code: "likeness_or_voice_clone", severity: "blocking", label: "肖像/声线风险", message: `检测到 ${likenessHits.join("、")},需要独立授权与伦理审核。` }); } const externalSourceRisk = /网络下载|截图|搬运|Pinterest|ArtStation|小红书|微博|抖音|B站|YouTube|素材站/i.test(text); if (externalSourceRisk && !evidenceRef) { issues.push({ code: "third_party_source_without_license", severity: "blocking", label: "第三方来源未授权", message: "外部来源素材必须先补齐授权证据,不能直接进入生成或交付。" }); } const blockingCount = issues.filter((issue) => issue.severity === "blocking").length; const reviewCount = issues.filter((issue) => issue.severity === "review").length; const status = blockingCount ? "blocked" : reviewCount ? "review" : "clear"; const score = Math.max(0, 100 - blockingCount * 35 - reviewCount * 12); return { schema: "ai-drama.asset-governance-scan.v1", status, score, rightsStatus, scannedAt: new Date().toISOString(), checks: { provenancePresent: !missingSource, rightsEvidencePresent: Boolean(evidenceRef), singleFrameSafe: layoutHits.length === 0, knownIpSafe: ipHits.length === 0, likenessSafe: likenessHits.length === 0 }, issues, policy: { singleFrameOnly: true, originalCommercialUse: true, fixedVoiceEvidenceRequired: asset?.kind === "voice" } }; } function assetGovernanceReviews(assetId) { return dbAll( `SELECT agr.*, u.display_name AS reviewer_name, u.email AS reviewer_email FROM asset_governance_reviews agr LEFT JOIN users u ON u.id = agr.reviewer_user_id WHERE agr.asset_id = ? ORDER BY agr.created_at DESC LIMIT 30`, [assetId] ).map((row) => ({ ...row, reviewerName: row.reviewer_name || row.reviewer_user_id, reviewerEmail: row.reviewer_email || "", provenance: parseJson(row.provenance_json, {}), risk: parseJson(row.risk_json, {}) })); } function canAccessAssetLibrary(context) { return hasPermission(context, "asset:edit") || hasPermission(context, "voice:edit") || hasPermission(context, "compliance:manage"); } function assertAssetGovernanceAccess(context, asset) { if (canAccessAssetLibrary(context)) return; if (asset?.kind === "voice" && hasPermission(context, "voice:approve")) return; throw httpError(403, "permission_denied", "当前角色没有资产治理权限"); } function ensureAssetContext(context, assetId) { if (!context.project) throw httpError(400, "project_required", "资产操作必须绑定项目"); const asset = dbGet("SELECT * FROM assets WHERE id = ? AND project_id = ?", [assetId, context.project.id]); if (!asset) throw httpError(404, "asset_not_found", "资产不存在或不属于当前项目", { assetId }); return asset; } function assetDetail(assetId, projectId) { const asset = dbGet("SELECT * FROM assets WHERE id = ? AND project_id = ?", [assetId, projectId]); if (!asset) return null; const versions = dbAll("SELECT id, version_number, storage_path, file_name, mime_type, file_size, content_sha256, rights_status, provenance_json, risk_json, tags_json, license_scope, expires_at, metadata_json, created_by, created_at FROM asset_versions WHERE asset_id = ? ORDER BY version_number DESC", [assetId]).map((version) => ({ ...version, metadata: parseJson(version.metadata_json, {}), provenance: parseJson(version.provenance_json, {}), risk: parseJson(version.risk_json, {}), tags: parseJson(version.tags_json, []), licenseScope: version.license_scope || "", expiresAt: version.expires_at || "", fileName: version.file_name || "", mimeType: version.mime_type || "application/octet-stream", fileSize: Number(version.file_size || 0), contentSha256: version.content_sha256 || "" })); const bindings = dbAll( `SELECT b.id, b.shot_id, b.usage_role, b.created_at, s.title AS shot_title FROM asset_bindings b JOIN shots s ON s.id = b.shot_id WHERE b.asset_id = ? ORDER BY b.created_at DESC`, [assetId] ); const currentVersion = versions.find((version) => version.id === asset.current_version_id) || versions[0] || null; return { ...asset, lockStatus: asset.lock_status, currentVersionId: asset.current_version_id, currentVersion, versions, bindings, governanceReviews: assetGovernanceReviews(assetId) }; } export function scopedAssets(context) { if (!context.project) return []; return dbAll("SELECT id FROM assets WHERE project_id = ? ORDER BY updated_at DESC, created_at DESC", [context.project.id]) .map((row) => assetDetail(row.id, context.project.id)) .filter(Boolean); } export function getAsset(context, assetId) { return assetDetail(assetId, context.project?.id); } export function createAsset(context, body) { requirePermission(context, "asset:edit"); if (!context.project) throw httpError(400, "project_required", "创建资产必须绑定项目"); const name = String(body.name || "").trim(); if (!name) throw httpError(400, "name_required", "资产名称不能为空"); const id = String(body.id || `asset-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`); const timestamp = new Date().toISOString(); const versionId = `${id}-v1`; const kind = String(body.kind || "reference").trim(); const lockStatus = String(body.lockStatus || "draft").trim(); const storagePath = String(body.storagePath || `assets/${context.project.id}/${id}/v1/metadata.json`).trim(); const bodyMetadata = objectValue(body.metadata); const metadata = { ...bodyMetadata, subtitle: String(body.subtitle || "本地项目资产"), initial: String(body.initial || name.slice(0, 1)), tags: Array.isArray(body.tags) ? body.tags : bodyMetadata.tags || [], usage: String(body.usage || "当前项目"), detail: String(body.detail || "待补充资产描述"), lock: String(body.lock || "待补充连续性备注"), mimeType: String(body.mimeType || "application/octet-stream"), size: Number(body.size || 0) }; const provenance = objectValue(body.provenance); const tags = mergeAssetTags(metadata.tags, body.tags); const licenseScope = String(body.licenseScope || body.license_scope || "").trim(); const expiresAt = String(body.expiresAt || body.expires_at || "").trim() || null; const fileName = String(body.fileName || storagePath.split("/").pop() || "").trim(); const mimeType = String(body.mimeType || "application/octet-stream"); const fileSize = Number(body.fileSize ?? body.size ?? 0); const contentSha256 = String(body.contentSha256 || "").trim(); const risk = Object.keys(objectValue(body.risk)).length ? objectValue(body.risk) : buildAssetGovernanceScan({ id, kind, name }, { metadata_json: JSON.stringify(metadata), provenance_json: JSON.stringify(provenance), tags_json: JSON.stringify(tags), rights_status: String(body.rightsStatus || "needs-evidence"), storage_path: storagePath, file_name: fileName }, body); dbRun("INSERT INTO assets(id, project_id, kind, name, lock_status, current_version_id, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", [id, context.project.id, kind, name, lockStatus, versionId, context.user.id, timestamp, timestamp]); dbRun("INSERT INTO asset_versions(id, asset_id, version_number, storage_path, file_name, mime_type, file_size, content_sha256, rights_status, provenance_json, risk_json, tags_json, license_scope, expires_at, metadata_json, created_by, created_at) VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [versionId, id, storagePath, fileName, mimeType, fileSize, contentSha256, String(body.rightsStatus || "needs-evidence"), JSON.stringify(provenance), JSON.stringify(risk), JSON.stringify(tags), licenseScope, expiresAt, JSON.stringify(metadata), context.user.id, timestamp]); addAudit({ context, action: "asset.created", targetType: "asset", targetId: id, metadata: { kind, name, storagePath } }); return assetDetail(id, context.project.id); } export function createAssetVersion(context, assetId, body) { requirePermission(context, "asset:edit"); const asset = ensureAssetContext(context, assetId); const currentVersion = dbGet("SELECT * FROM asset_versions WHERE id = ? AND asset_id = ?", [asset.current_version_id, assetId]); const currentMetadata = parseJson(currentVersion?.metadata_json, {}); const latest = dbGet("SELECT MAX(version_number) AS version_number FROM asset_versions WHERE asset_id = ?", [assetId]); const versionNumber = Number(latest?.version_number || 0) + 1; const versionId = `${assetId}-v${versionNumber}`; const timestamp = new Date().toISOString(); const storagePath = String(body.storagePath || currentVersion?.storage_path || `assets/${context.project.id}/${assetId}/v${versionNumber}/metadata.json`).trim(); const metadata = { ...currentMetadata, ...(body.metadata && typeof body.metadata === "object" ? body.metadata : {}), versionNote: String(body.versionNote || "版本更新"), mimeType: String(body.mimeType || currentMetadata.mimeType || "application/octet-stream"), size: Number(body.size ?? currentMetadata.size ?? 0) }; const currentProvenance = parseJson(currentVersion?.provenance_json, {}); const currentTags = parseJson(currentVersion?.tags_json, []); const provenance = { ...objectValue(currentProvenance), ...objectValue(body.provenance) }; const tags = mergeAssetTags(currentTags, metadata.tags, body.tags); const licenseScope = String(body.licenseScope || body.license_scope || currentVersion?.license_scope || "").trim(); const expiresAt = String(body.expiresAt || body.expires_at || currentVersion?.expires_at || "").trim() || null; const fileName = String(body.fileName || currentVersion?.file_name || storagePath.split("/").pop() || "").trim(); const mimeType = String(body.mimeType || currentVersion?.mime_type || currentMetadata.mimeType || "application/octet-stream"); const fileSize = Number(body.fileSize ?? body.size ?? currentVersion?.file_size ?? currentMetadata.size ?? 0); const contentSha256 = String(body.contentSha256 || currentVersion?.content_sha256 || "").trim(); const provisionalVersion = { ...currentVersion, metadata_json: JSON.stringify(metadata), provenance_json: JSON.stringify(provenance), tags_json: JSON.stringify(tags), rights_status: String(body.rightsStatus || "needs-evidence"), storage_path: storagePath, file_name: fileName }; const risk = Object.keys(objectValue(body.risk)).length ? objectValue(body.risk) : buildAssetGovernanceScan(asset, provisionalVersion, body); dbRun("INSERT INTO asset_versions(id, asset_id, version_number, storage_path, file_name, mime_type, file_size, content_sha256, rights_status, provenance_json, risk_json, tags_json, license_scope, expires_at, metadata_json, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [versionId, assetId, versionNumber, storagePath, fileName, mimeType, fileSize, contentSha256, String(body.rightsStatus || "needs-evidence"), JSON.stringify(provenance), JSON.stringify(risk), JSON.stringify(tags), licenseScope, expiresAt, JSON.stringify(metadata), context.user.id, timestamp]); dbRun("UPDATE assets SET current_version_id = ?, updated_at = ? WHERE id = ?", [versionId, timestamp, assetId]); addAudit({ context, action: "asset.version.created", targetType: "asset_version", targetId: versionId, metadata: { assetId, versionNumber, storagePath } }); return assetDetail(asset.id, context.project.id); } export function restoreAssetVersion(context, assetId, versionId) { requirePermission(context, "asset:edit"); const asset = ensureAssetContext(context, assetId); const version = dbGet("SELECT * FROM asset_versions WHERE id = ? AND asset_id = ?", [versionId, assetId]); if (!version) throw httpError(404, "asset_version_not_found", "要恢复的资产版本不存在"); if (asset.current_version_id === version.id) return assetDetail(assetId, context.project.id); const timestamp = new Date().toISOString(); dbRun("UPDATE assets SET current_version_id = ?, updated_at = ? WHERE id = ?", [version.id, timestamp, assetId]); addAudit({ context, action: "asset.version.restored", targetType: "asset_version", targetId: version.id, metadata: { assetId, previousVersionId: asset.current_version_id, restoredVersionNumber: version.version_number } }); return assetDetail(assetId, context.project.id); } export function updateAssetLock(context, assetId, body) { requirePermission(context, "asset:edit"); const asset = ensureAssetContext(context, assetId); const lockStatus = String(body.lockStatus || "draft").trim(); if (!["draft", "review", "locked", "archived"].includes(lockStatus)) throw httpError(400, "lock_status_invalid", "资产锁定状态无效", { lockStatus }); const timestamp = new Date().toISOString(); dbRun("UPDATE assets SET lock_status = ?, updated_at = ? WHERE id = ?", [lockStatus, timestamp, assetId]); addAudit({ context, action: "asset.lock.updated", targetType: "asset", targetId: assetId, metadata: { previous: asset.lock_status, lockStatus } }); return assetDetail(assetId, context.project.id); } export function listAssetGovernanceReviews(context, assetId) { const asset = ensureAssetContext(context, assetId); assertAssetGovernanceAccess(context, asset); return { reviews: assetGovernanceReviews(assetId) }; } export function scanAssetGovernance(context, assetId, body = {}) { const asset = ensureAssetContext(context, assetId); assertAssetGovernanceAccess(context, asset); requireProjectWritable(context); const currentVersion = dbGet("SELECT * FROM asset_versions WHERE id = ? AND asset_id = ?", [asset.current_version_id, assetId]); if (!currentVersion) throw httpError(404, "asset_version_not_found", "当前资产版本不存在"); const metadata = parseJson(currentVersion.metadata_json, {}); const provenance = { ...objectValue(parseJson(currentVersion.provenance_json, {})), ...objectValue(body.provenance) }; const tags = mergeAssetTags(parseJson(currentVersion.tags_json, []), metadata.tags, body.tags); const risk = buildAssetGovernanceScan(asset, currentVersion, { ...body, provenance, tags }); dbRun( "UPDATE asset_versions SET provenance_json = ?, risk_json = ?, tags_json = ?, license_scope = COALESCE(NULLIF(?, ''), license_scope), expires_at = COALESCE(?, expires_at) WHERE id = ? AND asset_id = ?", [JSON.stringify(provenance), JSON.stringify(risk), JSON.stringify(tags), String(body.licenseScope || body.license_scope || "").trim(), String(body.expiresAt || body.expires_at || "").trim() || null, currentVersion.id, assetId] ); dbRun("UPDATE assets SET updated_at = ? WHERE id = ?", [risk.scannedAt, assetId]); addAudit({ context, action: "asset.governance.scanned", targetType: "asset_version", targetId: currentVersion.id, result: risk.status === "blocked" ? "blocked" : "ok", metadata: { assetId, riskStatus: risk.status, score: risk.score, issues: risk.issues.map((issue) => issue.code) } }); return { scan: risk, asset: assetDetail(assetId, context.project.id) }; } export function updateAssetRights(context, assetId, body) { const asset = ensureAssetContext(context, assetId); const canSubmit = hasPermission(context, "asset:edit") || (asset.kind === "voice" && hasPermission(context, "voice:edit")); const canDecide = hasPermission(context, "compliance:manage") || (asset.kind === "voice" && hasPermission(context, "voice:approve")); if (!canSubmit && !canDecide) throw httpError(403, "permission_denied", "当前角色没有资产授权治理权限"); requireProjectWritable(context); const currentVersion = dbGet("SELECT * FROM asset_versions WHERE id = ? AND asset_id = ?", [asset.current_version_id, assetId]); if (!currentVersion) throw httpError(404, "asset_version_not_found", "当前资产版本不存在"); const rightsStatus = String(body.rightsStatus || "needs-evidence").trim(); if (!ASSET_RIGHTS_STATUSES.has(rightsStatus)) { throw httpError(400, "rights_status_invalid", "资产授权状态无效", { rightsStatus }); } if (["approved", "rejected", "expired"].includes(rightsStatus) && !canDecide) { throw httpError(403, "asset_rights_decision_forbidden", "只有合规管理员或声音审批人可以做最终授权决定", { rightsStatus, assetKind: asset.kind }); } const evidence = objectValue(body.evidence); const metadata = parseJson(currentVersion.metadata_json, {}); const timestamp = new Date().toISOString(); const provenance = { ...objectValue(parseJson(currentVersion.provenance_json, {})), ...objectValue(metadata.provenance), ...objectValue(body.provenance) }; const tags = mergeAssetTags(parseJson(currentVersion.tags_json, []), metadata.tags, body.tags); const reference = evidenceReference(evidence, metadata, provenance); if (rightsStatus === "approved" && !reference) { throw httpError(400, "rights_evidence_required", asset.kind === "voice" ? "批准声音资产前必须填写授权证据引用" : "批准商用资产前必须填写来源和授权证据引用"); } if (rightsStatus === "approved" && asset.kind !== "voice" && !String(provenance.sourceType || provenance.sourceName || provenance.sourceRef || "").trim()) { throw httpError(400, "asset_provenance_required", "批准商用资产前必须登记来源类型、来源名称或来源引用"); } const expiresAt = String(body.expiresAt || body.expires_at || currentVersion.expires_at || "").trim() || null; if (rightsStatus === "approved" && expiresAt && Date.parse(expiresAt) <= Date.now()) { throw httpError(400, "asset_license_expired", "授权到期时间不能早于当前时间", { expiresAt }); } const risk = buildAssetGovernanceScan(asset, currentVersion, { ...body, provenance, tags, rightsStatus, evidence }); if (rightsStatus === "approved" && risk.status === "blocked") { throw httpError(422, "asset_governance_blocked", "资产风险扫描未通过,不能批准商用使用", { assetId, risk }); } const nextMetadata = { ...metadata, tags, provenance, rightsEvidence: { ...objectValue(metadata.rightsEvidence), ...evidence, reference, reviewedBy: canDecide ? context.user.id : objectValue(metadata.rightsEvidence).reviewedBy || "", reviewedAt: canDecide ? timestamp : objectValue(metadata.rightsEvidence).reviewedAt || "", submittedBy: context.user.id, submittedAt: timestamp } }; const licenseScope = String(body.licenseScope || body.license_scope || currentVersion.license_scope || "").trim(); const nextLockStatus = rightsStatus === "approved" && asset.kind === "voice" ? "locked" : rightsStatus === "rejected" || rightsStatus === "expired" ? "review" : asset.lock_status; const reviewId = `asset-review-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; withTransaction(() => { dbRun( "UPDATE asset_versions SET rights_status = ?, provenance_json = ?, risk_json = ?, tags_json = ?, license_scope = ?, expires_at = ?, metadata_json = ? WHERE id = ? AND asset_id = ?", [rightsStatus, JSON.stringify(provenance), JSON.stringify(risk), JSON.stringify(tags), licenseScope, expiresAt, JSON.stringify(nextMetadata), currentVersion.id, assetId] ); dbRun("UPDATE assets SET lock_status = ?, updated_at = ? WHERE id = ?", [nextLockStatus, timestamp, assetId]); dbRun( `INSERT INTO asset_governance_reviews( id, organization_id, workspace_id, project_id, asset_id, version_id, reviewer_user_id, decision, rights_status, risk_status, notes, evidence_ref, provenance_json, risk_json, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ reviewId, context.organization.id, context.workspace.id, context.project.id, assetId, currentVersion.id, context.user.id, canDecide ? rightsStatus : "submitted", rightsStatus, risk.status, String(body.notes || evidence.notes || "").trim(), reference, JSON.stringify(provenance), JSON.stringify(risk), timestamp ] ); }); addAudit({ context, action: asset.kind === "voice" ? "voice.rights.updated" : "asset.rights.updated", targetType: "asset_version", targetId: currentVersion.id, result: rightsStatus === "approved" ? "pass" : rightsStatus, metadata: { assetId, assetKind: asset.kind, rightsStatus, riskStatus: risk.status, evidenceRef: reference, reviewId } }); return assetDetail(assetId, context.project.id); } export function bindAssetToShot(context, assetId, body) { requirePermission(context, "asset:edit"); const asset = ensureAssetContext(context, assetId); const shotId = String(body.shotId || "").trim(); const shot = dbGet( `SELECT s.id, 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 = ?`, [shotId, context.project.id] ); if (!shot) throw httpError(400, "shot_invalid", "镜头不存在或不属于当前项目", { shotId }); const usageRole = String(body.usageRole || asset.kind || "continuity").trim(); const timestamp = new Date().toISOString(); dbRun("INSERT INTO asset_bindings(id, asset_id, shot_id, usage_role, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(asset_id, shot_id, usage_role) DO UPDATE SET created_by = excluded.created_by", [`binding-${assetId}-${shotId}-${usageRole}`, assetId, shotId, usageRole, context.user.id, timestamp]); addAudit({ context, action: "asset.bound", targetType: "asset_binding", targetId: `${assetId}:${shotId}`, metadata: { assetId, shotId, usageRole } }); return assetDetail(asset.id, context.project.id); } export function buildContextPayload(context) { const canManageOrganization = hasPermission(context, "organization:manage") || hasPermission(context, "organization:members:invite"); const canManageWorkspace = hasPermission(context, "workspace:manage") || hasPermission(context, "workspace:create") || hasPermission(context, "workspace:members:manage"); const canManageProjectMembers = hasPermission(context, "project:members:manage"); const canViewUsage = hasPermission(context, "usage:view") || hasPermission(context, "billing:manage"); const canViewAudit = hasPermission(context, "audit:view"); const canViewModels = hasPermission(context, "model:manage") || hasPermission(context, "model:approve"); const canUseGenerationAdapters = hasPermission(context, "job:create") || canViewModels; const modelRows = scopedModels(context); const modelCatalogRows = canViewModels ? scopedModelCatalog(context) : []; const modelRouteRows = canViewModels ? scopedModelRoutes(context) : []; const adapterCatalog = canViewModels ? modelRows : modelRows.map((model) => ({ id: model.id, label: model.label, kind: model.kind, capability: model.capability, status: model.status, costMode: model.costMode, approvalRequired: model.approvalRequired })); const members = canManageOrganization ? orgMembers(context.organization.id) : []; const invitations = hasPermission(context, "organization:members:invite") ? pendingInvitations(context.organization.id) : []; const billing = canViewUsage ? billingAccount(context.organization.id) : null; const usage = canViewUsage ? usageSummary(context) : null; const auditLog = canViewAudit ? scopedAuditLog(context) : []; const workspaceMemberRows = canManageWorkspace ? workspaceMembers(context.workspace.id) : []; const projectMemberRows = canManageProjectMembers && context.project ? projectMembers(context.project.id) : []; const organizationRows = dbAll( `SELECT o.id, o.name, o.slug, o.deployment_mode, o.status, om.role_key, r.name AS role_name, (SELECT COUNT(*) FROM workspaces w WHERE w.organization_id = o.id) AS workspace_count FROM organizations o JOIN organization_members om ON om.organization_id = o.id AND om.user_id = ? AND om.status = 'active' LEFT JOIN roles r ON r.key = om.role_key WHERE o.status = 'active' ORDER BY o.name`, [context.user.id] ); const workspaces = context.workspaces.map((workspace) => ({ ...workspace, memberCount: Number(dbGet("SELECT COUNT(*) AS count FROM workspace_members WHERE workspace_id = ? AND status = 'active'", [workspace.id])?.count || 0), projectCount: Number(dbGet("SELECT COUNT(*) AS count FROM projects WHERE workspace_id = ?", [workspace.id])?.count || 0) })); const projects = context.projects.map((project) => ({ ...project, owner: dbGet("SELECT display_name FROM users WHERE id = ?", [project.owner_user_id])?.display_name || project.owner_user_id, memberCount: Number(dbGet("SELECT COUNT(*) AS count FROM project_members WHERE project_id = ? AND status = 'active'", [project.id])?.count || 0) })); const rolePolicyCatalog = canManageOrganization || context.systemAdmin ? organizationRolePolicies(context.organization.id) : { roles: [], permissions: [] }; const roleRows = rolePolicyCatalog.roles; const permissionRows = rolePolicyCatalog.permissions; const rolePermissionRows = rolePolicyCatalog.roles; return { context: { currentUser: context.user, currentOrganization: context.organization, currentWorkspace: context.workspace, currentProject: context.project, organizationRole: context.organizationMembership.role_key, workspaceRole: context.workspaceMembership?.role_key || (context.orgElevated ? "org_admin" : null), projectRole: context.projectMembership?.role_key || null, roles: context.roles, permissions: context.permissions, systemAdmin: context.systemAdmin }, platform: { organizations: organizationRows, workspaces, projects, members, workspaceMembers: workspaceMemberRows, projectMembers: projectMemberRows, invitations, billing, usage, auditLog, modelRegistry: canViewModels ? modelRows : [], modelCatalog: modelCatalogRows, modelRouting: modelRouteRows, adapterCatalog: canUseGenerationAdapters ? adapterCatalog : [], rolePolicies: rolePolicyCatalog, organization: context.organization, workspace: context.workspace }, roles: roleRows, permissions: permissionRows, rolePermissions: rolePermissionRows }; } export function addAudit({ context, action, targetType, targetId, result = "ok", metadata = {} }) { dbRun( "INSERT INTO audit_logs(id, organization_id, workspace_id, project_id, actor_user_id, action, target_type, target_id, result, metadata_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [`aud-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, context.organization.id, context.workspace?.id || null, context.project?.id || null, context.user.id, action, targetType, targetId, result, JSON.stringify(metadata), new Date().toISOString()] ); } export function addUsage({ context, kind, units = 1, unitName = "event", estimatedCost = 0, metadata = {} }) { const timestamp = new Date().toISOString(); dbRun( "INSERT INTO usage_events(id, organization_id, workspace_id, project_id, user_id, kind, units, unit_name, estimated_cost, metadata_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [`usage-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, context.organization.id, context.workspace?.id || null, context.project?.id || null, context.user.id, kind, units, unitName, estimatedCost, JSON.stringify(metadata), timestamp] ); if (["job", "clip", "clips"].includes(String(unitName).toLowerCase())) { const row = quotaRow(context, "clip"); if (row) dbRun("UPDATE quota_allocations SET used_value = used_value + ?, updated_at = ? WHERE id = ?", [Number(units || 0), timestamp, row.id]); } } function requireSystemAdmin(context) { if (!context?.systemAdmin) throw httpError(403, "system_admin_required", "只有系统管理员可以访问全局用户目录"); } function systemUserOrganizations(userId) { return dbAll( `SELECT o.id, o.name, o.slug, om.role_key, r.name AS role_name, om.status, om.joined_at FROM organization_members om JOIN organizations o ON o.id = om.organization_id LEFT JOIN roles r ON r.key = om.role_key WHERE om.user_id = ? ORDER BY CASE om.status WHEN 'active' THEN 0 ELSE 1 END, o.name`, [userId] ); } function systemUserWorkspaces(userId) { return dbAll( `SELECT w.id, w.name, w.slug, w.organization_id, o.name AS organization_name, wm.role_key, r.name AS role_name, wm.status FROM workspace_members wm JOIN workspaces w ON w.id = wm.workspace_id JOIN organizations o ON o.id = w.organization_id LEFT JOIN roles r ON r.key = wm.role_key WHERE wm.user_id = ? ORDER BY CASE wm.status WHEN 'active' THEN 0 ELSE 1 END, o.name, w.name`, [userId] ); } function systemUserProjects(userId) { return dbAll( `SELECT p.id, p.name, p.workspace_id, w.name AS workspace_name, o.name AS organization_name, pm.role_key, r.name AS role_name, pm.status FROM project_members pm JOIN projects p ON p.id = pm.project_id JOIN workspaces w ON w.id = p.workspace_id JOIN organizations o ON o.id = w.organization_id LEFT JOIN roles r ON r.key = pm.role_key WHERE pm.user_id = ? ORDER BY CASE pm.status WHEN 'active' THEN 0 ELSE 1 END, o.name, w.name, p.name`, [userId] ); } function systemUserRow(row) { const organizations = systemUserOrganizations(row.id); const workspaces = systemUserWorkspaces(row.id); const projects = systemUserProjects(row.id); return { id: row.id, displayName: row.display_name, email: row.email, avatarColor: row.avatar_color, status: row.status, createdAt: row.created_at, updatedAt: row.updated_at, lastLoginAt: row.last_login_at || null, mfaEnabled: Boolean(row.mfa_enabled), activeSessionCount: Number(row.active_session_count || 0), lastSessionSeenAt: row.last_session_seen_at || null, systemAdmin: row.system_admin_status === "active", systemAdminStatus: row.system_admin_status || null, systemRoleKey: row.system_role_key || null, organizationCount: organizations.length, workspaceCount: workspaces.length, projectCount: projects.length, organizations, workspaces, projects }; } function systemUserSelect() { return `SELECT u.*, sa.role_key AS system_role_key, sa.status AS system_admin_status, c.last_login_at, COALESCE(m.enabled, 0) AS mfa_enabled, (SELECT COUNT(*) FROM auth_sessions s WHERE s.user_id = u.id AND s.revoked_at IS NULL AND s.expires_at > datetime('now')) AS active_session_count, (SELECT MAX(s.last_seen_at) FROM auth_sessions s WHERE s.user_id = u.id AND s.revoked_at IS NULL) AS last_session_seen_at FROM users u LEFT JOIN system_admins sa ON sa.user_id = u.id LEFT JOIN user_credentials c ON c.user_id = u.id LEFT JOIN user_mfa_methods m ON m.user_id = u.id`; } function roleForScope(roleKey, scope) { const role = dbGet("SELECT key, scope, name FROM roles WHERE key = ? AND scope = ?", [roleKey, scope]); if (!role) throw httpError(400, "role_invalid", `无效的${scope}角色`, { roleKey, scope }); return role; } function applySystemMemberships(userId, body = {}, actorContext) { const timestamp = new Date().toISOString(); const organizations = Array.isArray(body.organizationMemberships) ? body.organizationMemberships : body.organizationId ? [{ organizationId: body.organizationId, roleKey: body.organizationRoleKey || "org_member", status: body.organizationStatus || "active" }] : []; const workspaces = Array.isArray(body.workspaceMemberships) ? body.workspaceMemberships : body.workspaceId ? [{ workspaceId: body.workspaceId, roleKey: body.workspaceRoleKey || "writer", status: body.workspaceStatus || "active" }] : []; const projects = Array.isArray(body.projectMemberships) ? body.projectMemberships : body.projectId ? [{ projectId: body.projectId, roleKey: body.projectRoleKey || "project_editor", status: body.projectStatus || "active" }] : []; for (const assignment of organizations) { const organizationId = String(assignment.organizationId || "").trim(); const organization = dbGet("SELECT id FROM organizations WHERE id = ? AND status = 'active'", [organizationId]); if (!organization) throw httpError(404, "organization_not_found", "目标组织不存在或已停用", { organizationId }); roleForScope(String(assignment.roleKey || "org_member"), "organization"); const existing = dbGet("SELECT status FROM organization_members WHERE organization_id = ? AND user_id = ?", [organizationId, userId]); if (!existing || existing.status !== "active") assertOrganizationSeatAvailable(organizationId, { userId }); const status = ["active", "suspended"].includes(assignment.status) ? assignment.status : "active"; dbRun("INSERT INTO organization_members(id, organization_id, user_id, role_key, status, joined_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(organization_id, user_id) DO UPDATE SET role_key = excluded.role_key, status = excluded.status, joined_at = CASE WHEN excluded.status = 'active' THEN excluded.joined_at ELSE organization_members.joined_at END, updated_at = excluded.updated_at", [`om-${organizationId}-${userId}`, organizationId, userId, assignment.roleKey || "org_member", status, status === "active" ? timestamp : null, timestamp, timestamp]); } for (const assignment of workspaces) { const workspaceId = String(assignment.workspaceId || "").trim(); const workspace = dbGet("SELECT id, organization_id FROM workspaces WHERE id = ? AND status = 'active'", [workspaceId]); if (!workspace) throw httpError(404, "workspace_not_found", "目标工作区不存在或已停用", { workspaceId }); roleForScope(String(assignment.roleKey || "writer"), "workspace"); if (!dbGet("SELECT 1 FROM organization_members WHERE organization_id = ? AND user_id = ? AND status = 'active'", [workspace.organization_id, userId])) { throw httpError(409, "organization_membership_required", "加入工作区前必须先加入所属组织", { workspaceId, organizationId: workspace.organization_id }); } dbRun("INSERT INTO workspace_members(id, workspace_id, user_id, role_key, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(workspace_id, user_id) DO UPDATE SET role_key = excluded.role_key, status = excluded.status, updated_at = excluded.updated_at", [`wm-${workspaceId}-${userId}`, workspaceId, userId, assignment.roleKey || "writer", ["active", "suspended"].includes(assignment.status) ? assignment.status : "active", timestamp, timestamp]); } for (const assignment of projects) { const projectId = String(assignment.projectId || "").trim(); const project = dbGet("SELECT id, workspace_id FROM projects WHERE id = ?", [projectId]); if (!project) throw httpError(404, "project_not_found", "目标项目不存在", { projectId }); roleForScope(String(assignment.roleKey || "project_editor"), "project"); const workspace = dbGet("SELECT organization_id FROM workspaces WHERE id = ?", [project.workspace_id]); if (!dbGet("SELECT 1 FROM organization_members WHERE organization_id = ? AND user_id = ? AND status = 'active'", [workspace.organization_id, userId])) throw httpError(409, "organization_membership_required", "加入项目之前必须先加入所属组织", { projectId }); if (!dbGet("SELECT 1 FROM workspace_members WHERE workspace_id = ? AND user_id = ? AND status = 'active'", [project.workspace_id, userId])) throw httpError(409, "workspace_membership_required", "加入项目之前必须先加入所属工作区", { projectId, workspaceId: project.workspace_id }); dbRun("INSERT INTO project_members(id, project_id, user_id, role_key, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(project_id, user_id) DO UPDATE SET role_key = excluded.role_key, status = excluded.status, updated_at = excluded.updated_at", [`pm-${projectId}-${userId}`, projectId, userId, assignment.roleKey || "project_editor", ["active", "suspended"].includes(assignment.status) ? assignment.status : "active", timestamp, timestamp]); } if (organizations.length || workspaces.length || projects.length) addAudit({ context: actorContext, action: "system.user.memberships.updated", targetType: "user", targetId: userId, metadata: { organizations, workspaces, projects } }); return { organizations, workspaces, projects }; } function setSystemAdminStatus(context, userId, enabled) { const current = dbGet("SELECT status FROM system_admins WHERE user_id = ?", [userId]); if (!enabled) { const activeCount = Number(dbGet("SELECT COUNT(*) AS count FROM system_admins WHERE status = 'active'")?.count || 0); if (current?.status === "active" && activeCount <= 1) throw httpError(409, "last_system_admin", "不能移除最后一个系统管理员"); if (current) dbRun("UPDATE system_admins SET status = 'suspended', updated_at = ? WHERE user_id = ?", [new Date().toISOString(), userId]); } else { const timestamp = new Date().toISOString(); dbRun("INSERT INTO system_admins(user_id, role_key, status, created_at, updated_at) VALUES (?, 'system_admin', 'active', ?, ?) ON CONFLICT(user_id) DO UPDATE SET status = 'active', updated_at = excluded.updated_at", [userId, timestamp, timestamp]); } } export function systemUsers(context, options = {}) { requireSystemAdmin(context); const query = String(options.query || "").trim().toLowerCase(); const status = String(options.status || "").trim(); const limit = Math.min(250, Math.max(1, Number(options.limit || 100))); const clauses = []; const params = []; if (query) { clauses.push("(lower(u.display_name) LIKE ? OR lower(u.email) LIKE ? OR lower(u.id) LIKE ?)"); params.push(`%${query}%`, `%${query}%`, `%${query}%`); } if (["active", "suspended", "invited"].includes(status)) { clauses.push("u.status = ?"); params.push(status); } const rows = dbAll(`${systemUserSelect()} ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} ORDER BY CASE u.status WHEN 'active' THEN 0 WHEN 'invited' THEN 1 ELSE 2 END, u.display_name LIMIT ?`, [...params, limit]).map(systemUserRow); const allUsers = dbAll("SELECT status FROM users"); const allAdmins = dbAll("SELECT status FROM system_admins"); return { users: rows, filters: { query, status, limit }, summary: { total: allUsers.length, active: allUsers.filter((user) => user.status === "active").length, suspended: allUsers.filter((user) => user.status === "suspended").length, invited: allUsers.filter((user) => user.status === "invited").length, systemAdmins: allAdmins.filter((admin) => admin.status === "active").length, activeSessions: Number(dbGet("SELECT COUNT(*) AS count FROM auth_sessions WHERE revoked_at IS NULL AND expires_at > datetime('now')")?.count || 0) } }; } export function systemUserDetail(context, userId) { requireSystemAdmin(context); const row = dbGet(`${systemUserSelect()} WHERE u.id = ?`, [userId]); if (!row) throw httpError(404, "system_user_not_found", "全局用户不存在", { userId }); const user = systemUserRow(row); return { user, devices: listUserDevices(userId), sessions: listUserSessions(userId, null), securityEvents: listSecurityEvents(userId, { limit: 40 }), recentAudit: dbAll( `SELECT a.id, a.action, a.target_type, a.target_id, a.result, a.metadata_json, a.created_at, u.display_name AS actor_name FROM audit_logs a LEFT JOIN users u ON u.id = a.actor_user_id WHERE a.target_type = 'user' AND a.target_id = ? ORDER BY a.created_at DESC LIMIT 30`, [userId] ).map((audit) => ({ ...audit, metadata: parseJson(audit.metadata_json, {}) })) }; } export function updateSystemUser(context, userId, body = {}) { requireSystemAdmin(context); const current = dbGet(`${systemUserSelect()} WHERE u.id = ?`, [userId]); if (!current) throw httpError(404, "system_user_not_found", "全局用户不存在", { userId }); const nextStatus = body.status === undefined ? current.status : String(body.status || "").trim(); if (!['active', 'suspended'].includes(nextStatus)) throw httpError(400, "system_user_status_invalid", "全局用户状态只能是 active 或 suspended"); const nextDisplayName = body.displayName === undefined ? current.display_name : String(body.displayName || "").trim(); if (!nextDisplayName) throw httpError(400, "display_name_required", "显示名称不能为空"); if (userId === context.user.id && nextStatus === "suspended") throw httpError(400, "cannot_suspend_self", "不能停用当前登录的系统管理员账号"); const targetIsSystemAdmin = current.system_admin_status === "active"; if (targetIsSystemAdmin && nextStatus === "suspended") { const activeAdminCount = Number(dbGet("SELECT COUNT(*) AS count FROM system_admins WHERE status = 'active'")?.count || 0); if (activeAdminCount <= 1) throw httpError(409, "last_system_admin", "不能停用最后一个系统管理员,请先指定其他系统管理员"); } if (nextStatus === "suspended") { const ownerOrganizations = dbAll( `SELECT o.id, o.name FROM organizations o JOIN organization_members om ON om.organization_id = o.id AND om.user_id = ? AND om.role_key = 'org_owner' AND om.status = 'active' WHERE o.status = 'active' AND (SELECT COUNT(*) FROM organization_members other WHERE other.organization_id = o.id AND other.role_key = 'org_owner' AND other.status = 'active') <= 1`, [userId] ); if (ownerOrganizations.length) throw httpError(409, "sole_organization_owner", "该用户是组织唯一所有者,请先转移组织所有权", { organizations: ownerOrganizations }); } const timestamp = new Date().toISOString(); let revokedCount = 0; withTransaction(() => { dbRun("UPDATE users SET display_name = ?, status = ?, updated_at = ? WHERE id = ?", [nextDisplayName, nextStatus, timestamp, userId]); if (nextStatus === "suspended" && current.status !== "suspended") revokedCount = revokeAllUserSessions(userId, { reason: "system_user_suspended", actorUserId: context.user.id }); if (typeof body.systemAdmin === "boolean") setSystemAdminStatus(context, userId, body.systemAdmin); }); addAudit({ context, action: nextStatus === "suspended" ? "system.user.suspended" : current.status === "suspended" ? "system.user.reactivated" : "system.user.updated", targetType: "user", targetId: userId, result: "ok", metadata: { previous: { displayName: current.display_name, status: current.status }, next: { displayName: nextDisplayName, status: nextStatus, systemAdmin: typeof body.systemAdmin === "boolean" ? body.systemAdmin : current.system_admin_status === "active" }, revokedSessionCount: revokedCount } }); return { ...systemUserDetail(context, userId), revokedSessionCount: revokedCount }; } export function createSystemUser(context, body = {}) { requireSystemAdmin(context); const email = String(body.email || "").trim().toLowerCase(); const displayName = String(body.displayName || "").trim(); if (!/^\S+@\S+\.\S+$/.test(email)) throw httpError(400, "email_invalid", "请输入有效邮箱"); if (!displayName) throw httpError(400, "display_name_required", "显示名称不能为空"); if (dbGet("SELECT id FROM users WHERE lower(email) = ?", [email])) throw httpError(409, "email_exists", "该邮箱已经存在"); const password = String(body.password || `Temp-${randomBytes(12).toString("base64url")}`); if (password.length < 12) throw httpError(400, "password_too_short", "初始密码至少需要 12 个字符"); const userId = String(body.id || `u-${Date.now()}-${randomBytes(4).toString("hex")}`).replace(/[^a-zA-Z0-9_-]/g, "-").slice(0, 80); const timestamp = new Date().toISOString(); const record = createPasswordRecord(password); const status = body.status === "suspended" ? "suspended" : "active"; withTransaction(() => { dbRun("INSERT INTO users(id, display_name, email, avatar_color, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", [userId, displayName, email, body.avatarColor || "#477d69", status, timestamp, timestamp]); dbRun("INSERT INTO user_credentials(user_id, password_salt, password_hash, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", [userId, record.salt, record.hash, timestamp, timestamp]); if (body.systemAdmin) setSystemAdminStatus(context, userId, true); applySystemMemberships(userId, body, context); }); addAudit({ context, action: "system.user.created", targetType: "user", targetId: userId, metadata: { email, displayName, status, systemAdmin: Boolean(body.systemAdmin), temporaryPasswordIssued: !body.password } }); return { ...(systemUserDetail(context, userId)), temporaryPassword: body.password ? null : password }; } export function resetSystemUserPassword(context, userId, body = {}, metadata = {}) { requireSystemAdmin(context); const user = dbGet("SELECT id, email FROM users WHERE id = ?", [userId]); if (!user) throw httpError(404, "system_user_not_found", "全局用户不存在", { userId }); const temporaryPassword = String(body.password || `Temp-${randomBytes(12).toString("base64url")}`); const result = resetPassword(userId, temporaryPassword, { ...metadata, actorUserId: context.user.id, reason: "system_admin_reset" }); addAudit({ context, action: "system.user.password_reset", targetType: "user", targetId: userId, metadata: { email: user.email, revokedSessionCount: result.revokedSessionCount, temporaryPasswordIssued: !body.password } }); return { ...systemUserDetail(context, userId), temporaryPassword: body.password ? null : temporaryPassword, revokedSessionCount: result.revokedSessionCount }; } export function resetSystemUserMfa(context, userId, metadata = {}) { requireSystemAdmin(context); const user = dbGet("SELECT id, email FROM users WHERE id = ?", [userId]); if (!user) throw httpError(404, "system_user_not_found", "全局用户不存在", { userId }); const result = resetMfa(userId, { ...metadata, actorUserId: context.user.id, reason: "system_admin_reset" }); addAudit({ context, action: "system.user.mfa_reset", targetType: "user", targetId: userId, metadata: { email: user.email, revokedSessionCount: result.revokedSessionCount } }); return { ...systemUserDetail(context, userId), revokedSessionCount: result.revokedSessionCount }; } export function updateSystemUserMemberships(context, userId, body = {}) { requireSystemAdmin(context); const user = dbGet("SELECT id FROM users WHERE id = ?", [userId]); if (!user) throw httpError(404, "system_user_not_found", "全局用户不存在", { userId }); withTransaction(() => applySystemMemberships(userId, body, context)); addAudit({ context, action: "system.user.memberships.updated", targetType: "user", targetId: userId, metadata: { requested: body } }); return systemUserDetail(context, userId); } function privateConnectorHost(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); } function connectorEndpointPolicy(endpoint, costMode = "local") { let url; try { url = new URL(String(endpoint || "")); } catch { return { status: "invalid", locality: "invalid", protocol: "", hostname: "", localOnlySafe: false }; } const local = privateConnectorHost(url.hostname); const status = String(costMode || "local") === "local" && !local ? "blocked" : local ? "private-local" : "public-network"; return { status, locality: local ? "local-or-lan" : "public", protocol: url.protocol.replace(":", ""), hostname: url.hostname, localOnlySafe: local }; } function connectorSecretPolicy(model, protocol, costMode = "local") { const authEnv = String(model.auth_env || protocol?.authEnv || "").trim(); const required = Boolean(authEnv) || String(costMode || "local") !== "local" || Boolean(protocol?.authRequired); const present = required && Object.prototype.hasOwnProperty.call(process.env, authEnv) && String(process.env[authEnv] || "").length > 0; return { authEnv, required, present, status: !required ? "not-required" : present ? "configured" : "missing" }; } export function parseModelRow(model) { const protocol = parseJson(model.protocol_json, {}); const costMode = model.cost_mode || "local"; return { ...model, capability: parseJson(model.capabilities_json, []), protocol, approvalRequired: Boolean(model.approval_required), costMode, authEnv: model.auth_env || protocol?.authEnv || "", secretPolicy: connectorSecretPolicy(model, protocol, costMode), endpointPolicy: connectorEndpointPolicy(model.endpoint, costMode) }; } export function systemSettings() { return dbAll("SELECT key, category, value_json, value_type, description, is_sensitive, updated_by, created_at, updated_at FROM system_settings ORDER BY category, key").map((row) => ({ ...row, value: parseJson(row.value_json, null), is_sensitive: Boolean(row.is_sensitive) })); } export function featureFlags() { return dbAll("SELECT key, label, description, enabled, scope, updated_by, created_at, updated_at FROM feature_flags ORDER BY key").map((row) => ({ ...row, enabled: Boolean(row.enabled) })); } export function notificationChannels() { return dbAll("SELECT id, name, kind, endpoint, enabled, events_json, secret_ref, created_by, created_at, updated_at FROM notification_channels ORDER BY created_at ASC").map((row) => ({ ...row, enabled: Boolean(row.enabled), events: parseJson(row.events_json, []) })); } export function apiClients() { return dbAll("SELECT id, name, client_key_prefix, key_version, organization_id, workspace_id, status, scopes_json, last_used_at, created_by, created_at, updated_at FROM api_clients ORDER BY created_at DESC").map((row) => ({ ...row, client_key: row.client_key_prefix ? `${row.client_key_prefix}****` : "仅创建或轮换时显示一次", client_key_preview: row.client_key_prefix ? `${row.client_key_prefix}****` : "仅创建或轮换时显示一次", scopes: parseJson(row.scopes_json, []) })); } function identityTokenHash(token) { return createHash("sha256").update(String(token || "")).digest("hex"); } function safeUrl(value, label, { allowEmpty = true } = {}) { const input = String(value || "").trim(); if (!input && allowEmpty) return ""; try { const url = new URL(input); if (!['http:', 'https:'].includes(url.protocol)) throw new Error("protocol"); return url.toString().replace(/\/$/, ""); } catch { throw httpError(400, "identity_url_invalid", `${label}必须是 HTTP(S) 地址`); } } function safeEntityId(value, label, { allowEmpty = true } = {}) { const input = String(value || "").trim(); if (!input && allowEmpty) return ""; if (!input || /[\r\n\s]/.test(input) || !/^(https?:\/\/|urn:)/i.test(input)) { throw httpError(400, "identity_entity_id_invalid", `${label}必须是 HTTP(S) 或 URN 标识`); } return input; } function identityPolicyPayload(row) { const current = row || dbGet("SELECT * FROM identity_policies WHERE id = 'default'"); return { id: current?.id || "default", passwordLoginEnabled: Boolean(current?.password_login_enabled), mfaRequiredForAdmins: Boolean(current?.mfa_required_for_admins), mfaRequiredForAll: Boolean(current?.mfa_required_for_all), ssoEnabled: Boolean(current?.sso_enabled), localLoginFallback: Boolean(current?.local_login_fallback), sessionTtlHours: Number(current?.session_ttl_hours || 12), maxSessionsPerUser: Number(current?.max_sessions_per_user || 10), updatedBy: current?.updated_by || null, updatedAt: current?.updated_at || null }; } function identityProviderPayload(row) { return { id: row.id, name: row.name, kind: row.kind, organizationId: row.organization_id || null, workspaceId: row.workspace_id || null, issuerUrl: row.issuer_url, authorizationUrl: row.authorization_url, tokenUrl: row.token_url, userinfoUrl: row.userinfo_url, jwksUrl: row.jwks_url || "", entryPoint: row.entry_point || "", idpCertRef: row.idp_cert_ref || "", spIssuer: row.sp_issuer || "", audience: row.audience || "", samlNameIdFormat: row.saml_name_id_format || "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", wantAssertionsSigned: row.want_assertions_signed !== 0, wantAuthnResponseSigned: row.want_authn_response_signed !== 0, validateInResponseTo: row.validate_in_response_to || "ifPresent", clientId: row.client_id, clientSecretRef: row.client_secret_ref, scopes: parseJson(row.scopes_json, ["openid", "profile", "email"]), claimMapping: parseJson(row.claim_mapping_json, { email: "email", displayName: "name", externalId: "sub" }), autoProvision: row.auto_provision !== 0, defaultRoleKey: row.default_role_key || "org_member", defaultWorkspaceRoleKey: row.default_workspace_role_key || "writer", enabled: Boolean(row.enabled), status: row.status, lastProbeAt: row.last_probe_at, errorMessage: row.error_message || "", createdAt: row.created_at, updatedAt: row.updated_at }; } function directorySyncPayload(row) { return { id: row.id, name: row.name, kind: row.kind, organizationId: row.organization_id, organizationName: row.organization_name || null, providerId: row.provider_id, providerName: row.provider_name || null, endpoint: row.endpoint, endpointPath: `/scim/v2.0/${encodeURIComponent(row.id)}`, tokenHint: row.token_hint, enabled: Boolean(row.enabled), syncMode: row.sync_mode, schedule: row.schedule, lastSyncAt: row.last_sync_at, lastStatus: row.last_status, lastSyncedCount: Number(row.last_synced_count || 0), errorMessage: row.error_message || "", createdAt: row.created_at, updatedAt: row.updated_at }; } export function identityCenter() { const providers = dbAll("SELECT * FROM identity_providers ORDER BY created_at ASC").map(identityProviderPayload); const directorySyncs = dbAll( `SELECT d.*, o.name AS organization_name, p.name AS provider_name FROM directory_syncs d LEFT JOIN organizations o ON o.id = d.organization_id LEFT JOIN identity_providers p ON p.id = d.provider_id ORDER BY d.created_at ASC` ).map(directorySyncPayload); return { policy: identityPolicyPayload(), providers, directorySyncs, organizationOptions: dbAll("SELECT id, name FROM organizations WHERE status = 'active' ORDER BY name"), workspaceOptions: dbAll("SELECT id, organization_id AS organizationId, name FROM workspaces WHERE status = 'active' ORDER BY name"), summary: { enabledProviders: providers.filter((item) => item.enabled).length, readyProviders: providers.filter((item) => item.status === "ready").length, enabledDirectorySyncs: directorySyncs.filter((item) => item.enabled).length, managedOrganizations: new Set(directorySyncs.map((item) => item.organizationId).filter(Boolean)).size } }; } export function publicIdentityProviders() { return dbAll("SELECT id, name, kind, organization_id, enabled, status FROM identity_providers WHERE enabled = 1 AND status IN ('configured', 'ready') ORDER BY name").map((row) => ({ id: row.id, name: row.name, kind: row.kind, organizationId: row.organization_id || null, enabled: Boolean(row.enabled), status: row.status })); } export function updateIdentityPolicy(context, body = {}) { requirePermission(context, "system:settings:edit"); const current = dbGet("SELECT * FROM identity_policies WHERE id = 'default'"); if (!current) throw httpError(500, "identity_policy_missing", "身份策略尚未初始化"); const next = { passwordLoginEnabled: body.passwordLoginEnabled === undefined ? Boolean(current.password_login_enabled) : Boolean(body.passwordLoginEnabled), mfaRequiredForAdmins: body.mfaRequiredForAdmins === undefined ? Boolean(current.mfa_required_for_admins) : Boolean(body.mfaRequiredForAdmins), mfaRequiredForAll: body.mfaRequiredForAll === undefined ? Boolean(current.mfa_required_for_all) : Boolean(body.mfaRequiredForAll), ssoEnabled: body.ssoEnabled === undefined ? Boolean(current.sso_enabled) : Boolean(body.ssoEnabled), localLoginFallback: body.localLoginFallback === undefined ? Boolean(current.local_login_fallback) : Boolean(body.localLoginFallback), sessionTtlHours: body.sessionTtlHours === undefined ? Number(current.session_ttl_hours) : Number(body.sessionTtlHours), maxSessionsPerUser: body.maxSessionsPerUser === undefined ? Number(current.max_sessions_per_user) : Number(body.maxSessionsPerUser) }; if (!next.passwordLoginEnabled && !next.ssoEnabled) throw httpError(400, "identity_login_method_required", "至少保留一种登录方式"); if (next.ssoEnabled && !dbGet("SELECT id FROM identity_providers WHERE enabled = 1 AND status IN ('configured', 'ready')")) throw httpError(400, "identity_sso_provider_required", "启用 SSO 前请先配置一个可用的身份提供商"); if (!Number.isInteger(next.sessionTtlHours) || next.sessionTtlHours < 1 || next.sessionTtlHours > 168) throw httpError(400, "identity_session_ttl_invalid", "会话时长必须是 1 到 168 小时"); if (!Number.isInteger(next.maxSessionsPerUser) || next.maxSessionsPerUser < 1 || next.maxSessionsPerUser > 50) throw httpError(400, "identity_session_limit_invalid", "单用户会话数必须是 1 到 50"); const timestamp = new Date().toISOString(); dbRun("UPDATE identity_policies SET 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 = ?, updated_at = ? WHERE id = 'default'", [next.passwordLoginEnabled ? 1 : 0, next.mfaRequiredForAdmins ? 1 : 0, next.mfaRequiredForAll ? 1 : 0, next.ssoEnabled ? 1 : 0, next.localLoginFallback ? 1 : 0, next.sessionTtlHours, next.maxSessionsPerUser, context.user.id, timestamp]); addAudit({ context, action: "system.identity.policy.updated", targetType: "identity_policy", targetId: "default", metadata: { previous: identityPolicyPayload(current), value: next } }); return identityCenter(); } export function saveIdentityProvider(context, body = {}, providerId = null) { requirePermission(context, "system:settings:edit"); const existing = providerId ? dbGet("SELECT * FROM identity_providers WHERE id = ?", [providerId]) : null; if (providerId && !existing) throw httpError(404, "identity_provider_not_found", "身份提供商不存在"); const name = String(body.name ?? existing?.name ?? "").trim(); const kind = String(body.kind ?? existing?.kind ?? "oidc").trim().toLowerCase(); if (!name) throw httpError(400, "identity_provider_name_required", "身份提供商名称不能为空"); if (!['oidc', 'saml'].includes(kind)) throw httpError(400, "identity_provider_kind_invalid", "只支持 OIDC 或 SAML 配置"); const issuerUrl = safeUrl(body.issuerUrl ?? existing?.issuer_url, "Issuer URL"); const authorizationUrl = safeUrl(body.authorizationUrl ?? existing?.authorization_url, "Authorization URL"); const tokenUrl = safeUrl(body.tokenUrl ?? existing?.token_url, "Token URL"); const userinfoUrl = safeUrl(body.userinfoUrl ?? existing?.userinfo_url, "UserInfo URL"); const jwksUrl = safeUrl(body.jwksUrl ?? existing?.jwks_url, "JWKS URL"); const entryPoint = safeUrl(body.entryPoint ?? existing?.entry_point, "SAML Entry Point"); const idpCertRef = String(body.idpCertRef ?? existing?.idp_cert_ref ?? "").trim(); const spIssuer = safeEntityId(body.spIssuer ?? existing?.sp_issuer, "SAML SP Issuer"); const audience = safeEntityId(body.audience ?? existing?.audience, "SAML Audience"); const samlNameIdFormat = safeEntityId(body.samlNameIdFormat ?? existing?.saml_name_id_format ?? "", "SAML NameID Format"); const wantAssertionsSigned = body.wantAssertionsSigned === undefined ? existing?.want_assertions_signed !== 0 : Boolean(body.wantAssertionsSigned); const wantAuthnResponseSigned = body.wantAuthnResponseSigned === undefined ? existing?.want_authn_response_signed !== 0 : Boolean(body.wantAuthnResponseSigned); const validateInResponseTo = String(body.validateInResponseTo ?? existing?.validate_in_response_to ?? "ifPresent").trim(); if (!["never", "ifPresent", "always"].includes(validateInResponseTo)) throw httpError(400, "identity_provider_validate_in_response_to_invalid", "SAML InResponseTo 校验策略必须是 never、ifPresent 或 always"); const clientId = String(body.clientId ?? existing?.client_id ?? "").trim(); const clientSecretRef = String(body.clientSecretRef ?? existing?.client_secret_ref ?? "").trim(); const organizationId = body.organizationId === undefined ? (existing?.organization_id || null) : (body.organizationId ? String(body.organizationId).trim() : null); if (organizationId && !dbGet("SELECT id FROM organizations WHERE id = ? AND status = 'active'", [organizationId])) throw httpError(404, "identity_provider_organization_not_found", "身份提供商归属组织不存在或已停用"); const workspaceId = body.workspaceId === undefined ? (existing?.workspace_id || null) : (body.workspaceId ? String(body.workspaceId).trim() : null); if (workspaceId && !dbGet("SELECT id FROM workspaces WHERE id = ? AND status = 'active' AND (? IS NULL OR organization_id = ?)", [workspaceId, organizationId, organizationId])) throw httpError(404, "identity_provider_workspace_not_found", "身份提供商默认工作区不存在、已停用或不属于绑定组织"); const scopes = Array.isArray(body.scopes) ? body.scopes.map((item) => String(item).trim()).filter(Boolean) : parseJson(existing?.scopes_json || "[]", ["openid", "profile", "email"]); const claimMapping = body.claimMapping && typeof body.claimMapping === "object" ? body.claimMapping : parseJson(existing?.claim_mapping_json || "{}", { email: "email", displayName: "name", externalId: "sub" }); const autoProvision = body.autoProvision === undefined ? existing?.auto_provision !== 0 : Boolean(body.autoProvision); const defaultRoleKey = String(body.defaultRoleKey ?? existing?.default_role_key ?? "org_member").trim() || "org_member"; if (!dbGet("SELECT key FROM roles WHERE key = ? AND scope = 'organization'", [defaultRoleKey])) throw httpError(400, "identity_provider_role_invalid", "SSO 默认组织角色无效"); const defaultWorkspaceRoleKey = String(body.defaultWorkspaceRoleKey ?? existing?.default_workspace_role_key ?? "writer").trim() || "writer"; if (!dbGet("SELECT key FROM roles WHERE key = ? AND scope = 'workspace'", [defaultWorkspaceRoleKey])) throw httpError(400, "identity_provider_workspace_role_invalid", "SSO 默认工作区角色无效"); const enabled = body.enabled === undefined ? Boolean(existing?.enabled) : Boolean(body.enabled); if (enabled && kind === "oidc" && (!issuerUrl || !clientId || !clientSecretRef)) throw httpError(400, "identity_provider_incomplete", "启用 OIDC 提供商前需要 Issuer、Client ID 和密钥环境变量名"); if (enabled && kind === "saml" && (!entryPoint || !idpCertRef || !spIssuer)) throw httpError(400, "identity_provider_incomplete", "启用 SAML 提供商前需要 IdP Entry Point、证书环境变量名和 SP Issuer"); const id = existing?.id || providerId || `idp-${Date.now()}-${randomBytes(4).toString("hex")}`; const timestamp = new Date().toISOString(); const status = enabled ? (existing?.status === "ready" && existing?.kind === kind ? existing.status : "configured") : "disabled"; if (existing) { dbRun("UPDATE identity_providers SET name = ?, kind = ?, organization_id = ?, workspace_id = ?, issuer_url = ?, authorization_url = ?, token_url = ?, userinfo_url = ?, jwks_url = ?, entry_point = ?, idp_cert_ref = ?, sp_issuer = ?, audience = ?, saml_name_id_format = ?, want_assertions_signed = ?, want_authn_response_signed = ?, validate_in_response_to = ?, client_id = ?, client_secret_ref = ?, scopes_json = ?, claim_mapping_json = ?, auto_provision = ?, default_role_key = ?, default_workspace_role_key = ?, enabled = ?, status = ?, error_message = '', updated_at = ? WHERE id = ?", [name, kind, organizationId, workspaceId, issuerUrl, authorizationUrl, tokenUrl, userinfoUrl, jwksUrl, entryPoint, idpCertRef, spIssuer, audience, samlNameIdFormat, wantAssertionsSigned ? 1 : 0, wantAuthnResponseSigned ? 1 : 0, validateInResponseTo, clientId, clientSecretRef, JSON.stringify(scopes), JSON.stringify(claimMapping), autoProvision ? 1 : 0, defaultRoleKey, defaultWorkspaceRoleKey, enabled ? 1 : 0, status, timestamp, id]); } else { const identityProviderPlaceholders = Array(30).fill("?").join(", "); dbRun(`INSERT INTO identity_providers(id, name, kind, organization_id, workspace_id, issuer_url, authorization_url, token_url, userinfo_url, jwks_url, entry_point, idp_cert_ref, sp_issuer, audience, saml_name_id_format, want_assertions_signed, want_authn_response_signed, validate_in_response_to, client_id, client_secret_ref, scopes_json, claim_mapping_json, auto_provision, default_role_key, default_workspace_role_key, enabled, status, created_by, created_at, updated_at) VALUES (${identityProviderPlaceholders})`, [id, name, kind, organizationId, workspaceId, issuerUrl, authorizationUrl, tokenUrl, userinfoUrl, jwksUrl, entryPoint, idpCertRef, spIssuer, audience, samlNameIdFormat, wantAssertionsSigned ? 1 : 0, wantAuthnResponseSigned ? 1 : 0, validateInResponseTo, clientId, clientSecretRef, JSON.stringify(scopes), JSON.stringify(claimMapping), autoProvision ? 1 : 0, defaultRoleKey, defaultWorkspaceRoleKey, enabled ? 1 : 0, status, context.user.id, timestamp, timestamp]); } addAudit({ context, action: existing ? "system.identity.provider.updated" : "system.identity.provider.created", targetType: "identity_provider", targetId: id, metadata: { name, kind, enabled } }); return identityCenter(); } export async function probeIdentityProvider(context, providerId) { requirePermission(context, "system:settings:edit"); const provider = dbGet("SELECT * FROM identity_providers WHERE id = ?", [providerId]); if (!provider) throw httpError(404, "identity_provider_not_found", "身份提供商不存在"); const timestamp = new Date().toISOString(); if (provider.kind !== "oidc") { const ref = String(provider.idp_cert_ref || "").trim(); const missing = []; if (!provider.entry_point) missing.push("Entry Point"); if (!provider.sp_issuer) missing.push("SP Issuer"); if (!ref || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(ref)) missing.push("证书环境变量名"); if (ref && !process.env[ref]) missing.push(`环境变量 ${ref}`); if (missing.length) { const message = `SAML 配置不完整:缺少 ${missing.join("、")}`; dbRun("UPDATE identity_providers SET status = 'error', error_message = ?, last_probe_at = ?, updated_at = ? WHERE id = ?", [message, timestamp, timestamp, providerId]); addAudit({ context, action: "system.identity.provider.probed", targetType: "identity_provider", targetId: providerId, result: "error", metadata: { kind: provider.kind, status: "error", missing } }); throw httpError(400, "identity_provider_probe_failed", message); } dbRun("UPDATE identity_providers SET status = 'ready', error_message = '', last_probe_at = ?, updated_at = ? WHERE id = ?", [timestamp, timestamp, providerId]); addAudit({ context, action: "system.identity.provider.probed", targetType: "identity_provider", targetId: providerId, metadata: { kind: provider.kind, status: "ready" } }); return { provider: identityCenter().providers.find((item) => item.id === providerId), discovery: { entryPoint: provider.entry_point, spIssuer: provider.sp_issuer, callbackPath: "/api/auth/sso/saml/acs" } }; } if (!provider.issuer_url) throw httpError(400, "identity_provider_issuer_required", "OIDC 提供商缺少 Issuer URL"); const discoveryUrl = `${provider.issuer_url.replace(/\/$/, "")}/.well-known/openid-configuration`; try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); const response = await fetch(discoveryUrl, { headers: { accept: "application/json" }, signal: controller.signal }); clearTimeout(timeout); const discovery = await response.json().catch(() => ({})); if (!response.ok || !discovery.authorization_endpoint || !discovery.token_endpoint) throw new Error(`OIDC discovery HTTP ${response.status}`); dbRun("UPDATE identity_providers SET authorization_url = ?, token_url = ?, userinfo_url = ?, jwks_url = ?, status = 'ready', error_message = '', last_probe_at = ?, updated_at = ? WHERE id = ?", [discovery.authorization_endpoint, discovery.token_endpoint, discovery.userinfo_endpoint || provider.userinfo_url, discovery.jwks_uri || provider.jwks_url || '', timestamp, timestamp, providerId]); addAudit({ context, action: "system.identity.provider.probed", targetType: "identity_provider", targetId: providerId, metadata: { kind: provider.kind, status: "ready", discoveryUrl } }); return { provider: identityCenter().providers.find((item) => item.id === providerId), discovery: { issuer: discovery.issuer || provider.issuer_url, authorizationEndpoint: discovery.authorization_endpoint, tokenEndpoint: discovery.token_endpoint, userinfoEndpoint: discovery.userinfo_endpoint || "", jwksUri: discovery.jwks_uri || provider.jwks_url || "" } }; } catch (error) { dbRun("UPDATE identity_providers SET status = 'error', error_message = ?, last_probe_at = ?, updated_at = ? WHERE id = ?", [error.message, timestamp, timestamp, providerId]); addAudit({ context, action: "system.identity.provider.probed", targetType: "identity_provider", targetId: providerId, result: "error", metadata: { error: error.message } }); throw httpError(502, "identity_provider_probe_failed", `OIDC 发现文档检查失败:${error.message}`); } } export function createDirectorySync(context, body = {}) { requirePermission(context, "system:settings:edit"); const name = String(body.name || "企业目录").trim(); if (!name) throw httpError(400, "directory_sync_name_required", "目录同步名称不能为空"); const providerId = body.providerId ? String(body.providerId) : null; if (providerId && !dbGet("SELECT id FROM identity_providers WHERE id = ?", [providerId])) throw httpError(404, "identity_provider_not_found", "关联的身份提供商不存在"); const id = `dir-${Date.now()}-${randomBytes(4).toString("hex")}`; const token = `scim_${randomBytes(24).toString("base64url")}`; const timestamp = new Date().toISOString(); const organizationId = context.organization.id; dbRun("INSERT INTO directory_syncs(id, name, kind, organization_id, provider_id, endpoint, token_hint, enabled, sync_mode, schedule, created_by, created_at, updated_at) VALUES (?, ?, 'scim', ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)", [id, name, organizationId, providerId, String(body.endpoint || "").trim(), `${token.slice(0, 10)}****${token.slice(-4)}`, String(body.syncMode || "provision-and-deprovision"), String(body.schedule || "manual"), context.user.id, timestamp, timestamp]); dbRun("INSERT INTO directory_sync_tokens(id, directory_sync_id, token_hash, token_hint, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?)", [`dirtok-${Date.now()}-${randomBytes(4).toString("hex")}`, id, identityTokenHash(token), `${token.slice(0, 10)}****${token.slice(-4)}`, context.user.id, timestamp]); addAudit({ context, action: "system.identity.directory.created", targetType: "directory_sync", targetId: id, metadata: { organizationId, providerId, syncMode: body.syncMode || "provision-and-deprovision" } }); return { directorySync: identityCenter().directorySyncs.find((item) => item.id === id), token }; } export function updateDirectorySync(context, directorySyncId, body = {}) { requirePermission(context, "system:settings:edit"); const current = dbGet("SELECT * FROM directory_syncs WHERE id = ?", [directorySyncId]); if (!current) throw httpError(404, "directory_sync_not_found", "目录同步不存在"); if (current.organization_id !== context.organization.id && !context.systemAdmin) throw httpError(403, "directory_sync_forbidden", "不能管理其他组织的目录同步"); const enabled = body.enabled === undefined ? Boolean(current.enabled) : Boolean(body.enabled); const name = String(body.name ?? current.name).trim(); if (!name) throw httpError(400, "directory_sync_name_required", "目录同步名称不能为空"); const timestamp = new Date().toISOString(); dbRun("UPDATE directory_syncs SET name = ?, endpoint = ?, enabled = ?, sync_mode = ?, schedule = ?, updated_at = ? WHERE id = ?", [name, String(body.endpoint ?? current.endpoint).trim(), enabled ? 1 : 0, String(body.syncMode ?? current.sync_mode), String(body.schedule ?? current.schedule), timestamp, directorySyncId]); addAudit({ context, action: "system.identity.directory.updated", targetType: "directory_sync", targetId: directorySyncId, metadata: { enabled, name } }); return identityCenter(); } export function rotateDirectorySyncToken(context, directorySyncId) { requirePermission(context, "system:settings:edit"); const current = dbGet("SELECT * FROM directory_syncs WHERE id = ?", [directorySyncId]); if (!current) throw httpError(404, "directory_sync_not_found", "目录同步不存在"); if (current.organization_id !== context.organization.id && !context.systemAdmin) throw httpError(403, "directory_sync_forbidden", "不能管理其他组织的目录同步"); const token = `scim_${randomBytes(24).toString("base64url")}`; const timestamp = new Date().toISOString(); const hint = `${token.slice(0, 10)}****${token.slice(-4)}`; dbRun("UPDATE directory_sync_tokens SET revoked_at = ? WHERE directory_sync_id = ? AND revoked_at IS NULL", [timestamp, directorySyncId]); dbRun("INSERT INTO directory_sync_tokens(id, directory_sync_id, token_hash, token_hint, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?)", [`dirtok-${Date.now()}-${randomBytes(4).toString("hex")}`, directorySyncId, identityTokenHash(token), hint, context.user.id, timestamp]); dbRun("UPDATE directory_syncs SET token_hint = ?, updated_at = ? WHERE id = ?", [hint, timestamp, directorySyncId]); addAudit({ context, action: "system.identity.directory.token_rotated", targetType: "directory_sync", targetId: directorySyncId, metadata: { tokenHint: hint } }); return { directorySync: identityCenter().directorySyncs.find((item) => item.id === directorySyncId), token }; } function directoryForScim(directorySyncId, token) { const directory = dbGet("SELECT d.*, o.name AS organization_name FROM directory_syncs d LEFT JOIN organizations o ON o.id = d.organization_id WHERE d.id = ? AND d.enabled = 1", [directorySyncId]); if (!directory) throw httpError(404, "scim_directory_not_found", "SCIM 目录不存在或未启用"); const tokenRow = dbGet("SELECT id FROM directory_sync_tokens WHERE directory_sync_id = ? AND token_hash = ? AND revoked_at IS NULL", [directorySyncId, identityTokenHash(token)]); if (!tokenRow) throw httpError(401, "scim_token_invalid", "SCIM 令牌无效"); dbRun("UPDATE directory_sync_tokens SET last_used_at = ? WHERE id = ?", [new Date().toISOString(), tokenRow.id]); return directory; } function scimUserPayload(user) { return { schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"], id: user.id, userName: user.email, displayName: user.display_name, active: user.status === "active", emails: [{ value: user.email, primary: true }], meta: { resourceType: "User", created: user.created_at, lastModified: user.updated_at } }; } export function scimListUsers(directorySyncId, token, startIndex = 1, count = 100) { const directory = directoryForScim(directorySyncId, token); const rows = dbAll("SELECT u.* FROM users u JOIN organization_members om ON om.user_id = u.id WHERE om.organization_id = ? ORDER BY u.created_at LIMIT ? OFFSET ?", [directory.organization_id, Math.min(Math.max(Number(count) || 100, 1), 200), Math.max((Number(startIndex) || 1) - 1, 0)]); const total = Number(dbGet("SELECT COUNT(*) AS count FROM users u JOIN organization_members om ON om.user_id = u.id WHERE om.organization_id = ?", [directory.organization_id])?.count || 0); return { schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], totalResults: total, startIndex: Number(startIndex) || 1, itemsPerPage: rows.length, Resources: rows.map(scimUserPayload) }; } export function scimCreateUser(directorySyncId, token, body = {}) { const directory = directoryForScim(directorySyncId, token); const email = String(body.userName || body.emails?.[0]?.value || "").trim().toLowerCase(); const displayName = String(body.displayName || body.name?.formatted || email.split("@")[0] || "企业用户").trim(); if (!email || !email.includes("@")) throw httpError(400, "scim_email_invalid", "SCIM 用户必须提供有效邮箱"); const timestamp = new Date().toISOString(); const active = body.active !== false; const existing = dbGet("SELECT * FROM users WHERE lower(email) = lower(?)", [email]); const userId = existing?.id || `u-scim-${Date.now()}-${randomBytes(4).toString("hex")}`; withTransaction(() => { if (existing) dbRun("UPDATE users SET display_name = ?, status = ?, updated_at = ? WHERE id = ?", [displayName, active ? "active" : "suspended", timestamp, userId]); else dbRun("INSERT INTO users(id, display_name, email, avatar_color, status, created_at, updated_at) VALUES (?, ?, ?, '#477d69', ?, ?, ?)", [userId, displayName, email, active ? "active" : "suspended", timestamp, timestamp]); dbRun("INSERT INTO organization_members(id, organization_id, user_id, role_key, status, joined_at, created_at, updated_at) VALUES (?, ?, ?, 'org_member', ?, ?, ?, ?) ON CONFLICT(organization_id, user_id) DO UPDATE SET status = excluded.status, updated_at = excluded.updated_at", [`om-${directory.organization_id}-${userId}`, directory.organization_id, userId, active ? "active" : "suspended", timestamp, timestamp, timestamp]); }); return { status: existing ? 200 : 201, user: scimUserPayload(dbGet("SELECT * FROM users WHERE id = ?", [userId])) }; } export function scimPatchUser(directorySyncId, token, userId, body = {}) { const directory = directoryForScim(directorySyncId, token); const user = dbGet("SELECT u.* FROM users u JOIN organization_members om ON om.user_id = u.id WHERE u.id = ? AND om.organization_id = ?", [userId, directory.organization_id]); if (!user) throw httpError(404, "scim_user_not_found", "SCIM 用户不存在"); let displayName = user.display_name; let email = user.email; let active = user.status === "active"; for (const operation of Array.isArray(body.Operations) ? body.Operations : []) { const path = String(operation.path || "").toLowerCase(); if (path === "active") active = Boolean(operation.value); if (path === "displayname" || path === "name.formatted") displayName = String(operation.value || displayName); if (path === "username" || path === "emails[type eq \"work\"].value") email = String(operation.value || email).toLowerCase(); } const timestamp = new Date().toISOString(); dbRun("UPDATE users SET display_name = ?, email = ?, status = ?, updated_at = ? WHERE id = ?", [displayName, email, active ? "active" : "suspended", timestamp, userId]); dbRun("UPDATE organization_members SET status = ?, updated_at = ? WHERE organization_id = ? AND user_id = ?", [active ? "active" : "suspended", timestamp, directory.organization_id, userId]); return scimUserPayload(dbGet("SELECT * FROM users WHERE id = ?", [userId])); } export function scimDeleteUser(directorySyncId, token, userId) { const directory = directoryForScim(directorySyncId, token); const user = dbGet("SELECT u.id FROM users u JOIN organization_members om ON om.user_id = u.id WHERE u.id = ? AND om.organization_id = ?", [userId, directory.organization_id]); if (!user) throw httpError(404, "scim_user_not_found", "SCIM 用户不存在"); const timestamp = new Date().toISOString(); dbRun("UPDATE organization_members SET status = 'suspended', updated_at = ? WHERE organization_id = ? AND user_id = ?", [timestamp, directory.organization_id, userId]); dbRun("UPDATE users SET status = 'suspended', updated_at = ? WHERE id = ?", [timestamp, userId]); return { ok: true }; } export function serviceHealth() { return dbAll("SELECT id, service_key, label, kind, status, endpoint, latency_ms, queue_depth, version, last_heartbeat, metadata_json, updated_at FROM service_health ORDER BY kind, label").map((row) => ({ ...row, metadata: parseJson(row.metadata_json, {}) })); } export function updateServiceHealth(context, serviceKey, body) { requirePermission(context, "runner:manage"); const service = dbGet("SELECT * FROM service_health WHERE service_key = ?", [serviceKey]); if (!service) throw httpError(404, "runner_not_found", "Runner 服务不存在", { serviceKey }); if (service.kind !== "runner") throw httpError(400, "runner_operation_invalid", "只有 Runner 服务支持运维动作"); const action = String(body.action || "").trim(); const allowed = new Set(["pause", "resume", "drain", "restart", "heartbeat"]); if (!allowed.has(action)) throw httpError(400, "runner_action_invalid", "Runner 运维动作无效", { action }); const metadata = parseJson(service.metadata_json, {}); const timestamp = new Date().toISOString(); const previousStatus = service.status; let status = service.status; let lastHeartbeat = service.last_heartbeat; if (action === "pause") status = "paused"; if (action === "drain") status = "draining"; if (action === "restart") { status = "starting"; lastHeartbeat = null; } if (action === "resume") status = metadata.resumeStatus || (service.version ? "ready" : "waiting-model"); if (action === "heartbeat") { status = metadata.resumeStatus || (service.version ? "ready" : "waiting-model"); lastHeartbeat = timestamp; } const nextMetadata = { ...metadata, lastAction: action, lastActionBy: context.user.id, lastActionAt: timestamp, previousStatus, resumeStatus: status === "paused" || status === "draining" ? (service.version ? "ready" : "waiting-model") : metadata.resumeStatus }; dbRun("UPDATE service_health SET status = ?, last_heartbeat = ?, metadata_json = ?, updated_at = ? WHERE service_key = ?", [status, lastHeartbeat, JSON.stringify(nextMetadata), timestamp, serviceKey]); addAudit({ context, action: `runner.${action}`, targetType: "service_health", targetId: serviceKey, metadata: { previousStatus, status } }); const updated = serviceHealth().find((item) => item.service_key === serviceKey); return { runner: updated, runners: serviceHealth().filter((item) => item.kind === "runner") }; }