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 seatLimit = Number(billing?.seat_limit || 0); 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 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); 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), 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]); 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((model) => ({ ...model, capability: parseJson(model.capabilities_json, []), protocol: parseJson(model.protocol_json, {}), approvalRequired: Boolean(model.approval_required), costMode: model.cost_mode })); } 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); } 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, 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, {}), 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 }; } 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 metadata = { subtitle: String(body.subtitle || "本地项目资产"), initial: String(body.initial || name.slice(0, 1)), tags: Array.isArray(body.tags) ? body.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 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(); 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, metadata_json, created_by, created_at) VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [versionId, id, storagePath, fileName, mimeType, fileSize, contentSha256, String(body.rightsStatus || "needs-evidence"), 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 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(); dbRun("INSERT INTO asset_versions(id, asset_id, version_number, storage_path, file_name, mime_type, file_size, content_sha256, rights_status, metadata_json, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [versionId, assetId, versionNumber, storagePath, fileName, mimeType, fileSize, contentSha256, String(body.rightsStatus || "needs-evidence"), 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 updateAssetRights(context, assetId, body) { requirePermission(context, "voice:approve"); const asset = ensureAssetContext(context, assetId); if (asset.kind !== "voice") throw httpError(400, "voice_asset_required", "只有声音资产需要走声音授权审批"); 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 (!["needs-evidence", "submitted", "approved", "rejected", "expired"].includes(rightsStatus)) { throw httpError(400, "rights_status_invalid", "声音授权状态无效", { rightsStatus }); } const evidence = body.evidence && typeof body.evidence === "object" ? body.evidence : {}; if (rightsStatus === "approved" && !String(evidence.reference || evidence.consentRef || "").trim()) { throw httpError(400, "rights_evidence_required", "批准声音资产前必须填写授权证据引用"); } const metadata = parseJson(currentVersion.metadata_json, {}); const timestamp = new Date().toISOString(); const nextMetadata = { ...metadata, rightsEvidence: { ...((metadata && metadata.rightsEvidence) || {}), ...evidence, reviewedBy: context.user.id, reviewedAt: timestamp } }; dbRun("UPDATE asset_versions SET rights_status = ?, metadata_json = ? WHERE id = ? AND asset_id = ?", [rightsStatus, JSON.stringify(nextMetadata), currentVersion.id, assetId]); dbRun("UPDATE assets SET lock_status = ?, updated_at = ? WHERE id = ?", [rightsStatus === "approved" ? "locked" : rightsStatus === "rejected" ? "review" : asset.lock_status, timestamp, assetId]); addAudit({ context, action: "voice.rights.updated", targetType: "asset_version", targetId: currentVersion.id, result: rightsStatus === "approved" ? "pass" : rightsStatus, metadata: { assetId, rightsStatus, evidence: nextMetadata.rightsEvidence } }); 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"); const canUseGenerationAdapters = hasPermission(context, "job:create") || canViewModels; const modelRows = scopedModels(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 : [], 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); } export function parseModelRow(model) { return { ...model, capability: parseJson(model.capabilities_json, []), protocol: parseJson(model.protocol_json, {}), approvalRequired: Boolean(model.approval_required), costMode: model.cost_mode }; } 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") }; }