Files
ai-drama-platform/server/tenant.mjs
T

4038 lines
239 KiB
JavaScript
Raw Blame History

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