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

2739 lines
164 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 (pm.user_id IS NOT NULL OR wm.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]);
if (!membership && !workspaceMember && !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.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) {
return roleKey === "writer" || roleKey === "voice_editor" || roleKey === "art_director" ? roleKey : "producer";
}
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) {
dbRun(
"INSERT INTO workspace_members(id, workspace_id, user_id, role_key, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'active', ?, ?) ON CONFLICT(workspace_id, user_id) DO UPDATE SET role_key = excluded.role_key, status = 'active', updated_at = excluded.updated_at",
[`wm-${invitation.workspace_id}-${userId}`, invitation.workspace_id, userId, invitationRoleForWorkspace(invitation.role_key), timestamp, timestamp]
);
}
if (invitation.project_id) {
dbRun(
"INSERT INTO project_members(id, project_id, user_id, role_key, status, created_at, updated_at) VALUES (?, ?, ?, 'project_editor', 'active', ?, ?) ON CONFLICT(project_id, user_id) DO UPDATE SET status = 'active', updated_at = excluded.updated_at",
[`pm-${invitation.project_id}-${userId}`, invitation.project_id, userId, timestamp, timestamp]
);
}
}
export function registerInvitedUser({ inviteToken, displayName, password }) {
const tokenValue = String(inviteToken || "").trim();
if (tokenValue.length < 24) throw httpError(400, "invitation_token_invalid", "邀请注册链接无效");
const name = String(displayName || "").trim();
if (name.length < 2 || name.length > 80) throw httpError(400, "display_name_invalid", "姓名长度应为 2 到 80 个字符");
const passwordValue = String(password || "");
if (passwordValue.length < 10) throw httpError(400, "password_weak", "密码至少需要 10 个字符");
const invitation = dbGet("SELECT * FROM invitations WHERE token_hash = ? AND status = 'pending'", [invitationTokenHash(tokenValue)]);
if (!invitation) throw httpError(404, "invitation_not_found", "邀请注册链接不存在、已使用或已撤销");
if (new Date(invitation.expires_at).getTime() <= Date.now()) {
dbRun("UPDATE invitations SET status = 'expired' WHERE id = ? AND status = 'pending'", [invitation.id]);
throw httpError(410, "invitation_expired", "邀请注册链接已过期");
}
const normalizedEmail = invitation.email.toLowerCase();
const existing = dbGet("SELECT * FROM users WHERE lower(email) = lower(?)", [normalizedEmail]);
if (existing?.status === "active") throw httpError(409, "account_exists_use_login", "该邮箱已有账号,请先登录后在账号安全页接受邀请");
if (existing?.status === "suspended") throw httpError(403, "user_suspended", "该邮箱对应的账号已被停用");
const userId = existing?.id || `u-${Date.now()}-${randomBytes(4).toString("hex")}`;
const timestamp = new Date().toISOString();
const credentials = createPasswordRecord(passwordValue);
withTransaction(() => {
if (existing) {
dbRun("UPDATE users SET display_name = ?, status = 'active', updated_at = ? WHERE id = ?", [name, timestamp, existing.id]);
dbRun("INSERT INTO user_credentials(user_id, password_salt, password_hash, created_at, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET password_salt = excluded.password_salt, password_hash = excluded.password_hash, failed_attempts = 0, locked_until = NULL, updated_at = excluded.updated_at", [existing.id, credentials.salt, credentials.hash, timestamp, timestamp]);
} else {
dbRun("INSERT INTO users(id, display_name, email, avatar_color, status, created_at, updated_at) VALUES (?, ?, ?, ?, 'active', ?, ?)", [userId, name, normalizedEmail, "#3f7f87", timestamp, timestamp]);
dbRun("INSERT INTO user_credentials(user_id, password_salt, password_hash, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", [userId, credentials.salt, credentials.hash, timestamp, timestamp]);
}
applyInvitationMembership(invitation, userId, timestamp);
dbRun("UPDATE invitations SET status = 'accepted', accepted_user_id = ?, accepted_at = ?, token_hash = NULL WHERE id = ?", [userId, timestamp, invitation.id]);
});
return {
user: dbGet("SELECT id, display_name, email, avatar_color, status, created_at, updated_at FROM users WHERE id = ?", [userId]),
invitation: dbGet("SELECT id, organization_id, workspace_id, project_id, email, role_key, status, expires_at, accepted_user_id, accepted_at FROM invitations WHERE id = ?", [invitation.id])
};
}
export function acceptInvitation(context, invitationId) {
const invitation = dbGet("SELECT * FROM invitations WHERE id = ? AND lower(email) = lower(?) AND status = 'pending'", [invitationId, context.user.email]);
if (!invitation) throw httpError(404, "invitation_not_found", "邀请不存在、已处理或邮箱不匹配");
if (new Date(invitation.expires_at).getTime() <= Date.now()) {
dbRun("UPDATE invitations SET status = 'expired' WHERE id = ?", [invitationId]);
throw httpError(410, "invitation_expired", "邀请已过期");
}
const timestamp = new Date().toISOString();
const organizationRole = ["org_owner", "org_admin", "org_member"].includes(invitation.role_key) ? invitation.role_key : "org_member";
withTransaction(() => {
dbRun("INSERT INTO organization_members(id, organization_id, user_id, role_key, status, joined_at, created_at, updated_at) VALUES (?, ?, ?, ?, 'active', ?, ?, ?) ON CONFLICT(organization_id, user_id) DO UPDATE SET role_key = excluded.role_key, status = 'active', joined_at = excluded.joined_at, updated_at = excluded.updated_at", [`om-${invitation.organization_id}-${context.user.id}`, invitation.organization_id, context.user.id, organizationRole, timestamp, timestamp, timestamp]);
applyInvitationMembership(invitation, context.user.id, timestamp);
dbRun("UPDATE invitations SET status = 'accepted', accepted_user_id = ?, accepted_at = ?, token_hash = NULL WHERE id = ?", [context.user.id, timestamp, invitationId]);
});
addAudit({ context: { ...context, organization: { id: invitation.organization_id }, workspace: invitation.workspace_id ? { id: invitation.workspace_id } : null, project: invitation.project_id ? { id: invitation.project_id } : null }, action: "organization.invitation.accepted", targetType: "invitation", targetId: invitationId, metadata: { roleKey: invitation.role_key } });
return dbGet("SELECT * FROM invitations WHERE id = ?", [invitationId]);
}
export function billingAccount(organizationId) {
const row = dbGet("SELECT * FROM billing_accounts WHERE organization_id = ?", [organizationId]);
if (!row) return null;
return {
...row,
billing_cycle: row.billing_cycle || "monthly",
currency: row.currency || "CNY",
base_fee: Number(row.base_fee || 0),
seat_unit_price: Number(row.seat_unit_price || 0),
storage_unit_price: Number(row.storage_unit_price || 0),
clip_unit_price: Number(row.clip_unit_price || 0),
quota_warning_percent: Number(row.quota_warning_percent || 80),
local_runner_only: Boolean(row.local_runner_only),
cloud_connectors_require_approval: Boolean(row.cloud_connectors_require_approval)
};
}
function billingHistory(organizationId) {
return dbAll(
`SELECT e.*, u.display_name AS actor_name
FROM billing_account_events e
LEFT JOIN users u ON u.id = e.actor_user_id
WHERE e.organization_id = ?
ORDER BY e.created_at DESC LIMIT 24`,
[organizationId]
).map((row) => ({
...row,
previous: parseJson(row.previous_json, {}),
next: parseJson(row.next_json, {})
}));
}
function costCenterCodeForUsage(row) {
const metadata = parseJson(row.metadata_json, {});
if (metadata.costCenter) return String(metadata.costCenter);
const kind = String(row.kind || "").toLowerCase();
if (kind.includes("storage") || kind.includes("asset") || kind.includes("delivery") || kind.includes("export")) return "storage";
if (kind.includes("image") || kind.includes("video") || kind.includes("i2v") || kind.includes("tts") || kind.includes("asr") || kind.includes("generation")) return "local-gpu";
return "operations";
}
function organizationCostCenters(organizationId) {
const centers = dbAll("SELECT * FROM cost_centers WHERE organization_id = ? ORDER BY status, name", [organizationId]);
const workspaces = Object.fromEntries(dbAll("SELECT id, name FROM workspaces WHERE organization_id = ?", [organizationId]).map((row) => [row.id, row.name]));
const rows = dbAll("SELECT workspace_id, project_id, kind, units, estimated_cost, metadata_json, created_at FROM usage_events WHERE organization_id = ? AND created_at >= datetime('now', 'start of month') ORDER BY created_at DESC", [organizationId]);
const grouped = new Map();
for (const row of rows) {
const code = costCenterCodeForUsage(row);
const key = `${code}:${row.workspace_id || "organization"}`;
const current = grouped.get(key) || { code, workspaceId: row.workspace_id || null, workspaceName: workspaces[row.workspace_id] || "组织级", units: 0, cost: 0, events: 0 };
current.units += Number(row.units || 0);
current.cost += Number(row.estimated_cost || 0);
current.events += 1;
grouped.set(key, current);
}
return {
centers: centers.map((center) => {
const details = [...grouped.values()].filter((item) => item.code === center.code);
const used = details.reduce((sum, item) => sum + item.cost, 0);
const budget = Number(center.monthly_budget || 0);
return { ...center, monthly_budget: budget, used: used, remaining: Math.max(0, budget - used), utilization: budget ? Number(((used / budget) * 100).toFixed(2)) : 0 };
}),
detail: [...grouped.values()].sort((a, b) => b.cost - a.cost)
};
}
function organizationUsageTrend(organizationId, days = 31) {
const limit = Math.min(90, Math.max(7, Number(days || 31)));
return dbAll(
`SELECT substr(created_at, 1, 10) AS day,
SUM(units) AS units,
SUM(estimated_cost) AS estimated_cost,
COUNT(*) AS events
FROM usage_events
WHERE organization_id = ? AND created_at >= datetime('now', ?)
GROUP BY substr(created_at, 1, 10)
ORDER BY day ASC`,
[organizationId, `-${limit} days`]
).map((row) => ({ day: row.day, units: Number(row.units || 0), estimatedCost: Number(row.estimated_cost || 0), events: Number(row.events || 0) }));
}
function quotaWarnings(organizationId, billing, quotas, seat) {
const threshold = Math.min(99, Math.max(50, Number(billing?.quota_warning_percent || 80)));
const warnings = [];
if (seat.limit && seat.utilization >= threshold) warnings.push({ scope: "organization", metric: "seat", label: "组织席位", used: seat.reserved, limit: seat.limit, utilization: seat.utilization, threshold, status: seat.reserved >= seat.limit ? "critical" : "warning" });
for (const quota of quotas) {
const limit = Number(quota.limitValue || 0);
const used = Number(quota.usedValue || 0);
const utilization = limit ? Number(((used / limit) * 100).toFixed(2)) : 0;
if (limit && utilization >= threshold) warnings.push({ scope: quota.workspaceId ? "workspace" : "organization", workspaceId: quota.workspaceId, workspaceName: quota.workspaceName, metric: quota.metric, label: quota.metric === "clip" ? "生成片段" : quota.metric === "storage" ? "存储" : quota.metric, used, limit, utilization, threshold, status: used >= limit ? "critical" : "warning" });
}
return warnings;
}
export function organizationSeatSummary(organizationId) {
const billing = billingAccount(organizationId);
const activeMembers = Number(dbGet("SELECT COUNT(DISTINCT user_id) AS count FROM organization_members WHERE organization_id = ? AND status = 'active'", [organizationId])?.count || 0);
const pendingInvitations = Number(dbGet("SELECT COUNT(*) AS count FROM invitations WHERE organization_id = ? AND status = 'pending' AND julianday(expires_at) > julianday('now')", [organizationId])?.count || 0);
const seatLimit = Number(billing?.seat_limit || 0);
return {
limit: seatLimit,
active: activeMembers,
pending: pendingInvitations,
reserved: activeMembers + pendingInvitations,
remaining: Math.max(0, seatLimit - activeMembers - pendingInvitations),
utilization: seatLimit ? Number(((activeMembers / seatLimit) * 100).toFixed(2)) : 0
};
}
export function assertOrganizationSeatAvailable(organizationId, { userId = "", email = "" } = {}) {
const existingUserId = userId || dbGet("SELECT id FROM users WHERE lower(email) = lower(?)", [String(email || "").trim()])?.id || "";
if (existingUserId && dbGet("SELECT 1 FROM organization_members WHERE organization_id = ? AND user_id = ? AND status = 'active'", [organizationId, existingUserId])) return organizationSeatSummary(organizationId);
const summary = organizationSeatSummary(organizationId);
if (summary.limit && summary.reserved >= summary.limit) {
throw httpError(409, "seat_limit_reached", "组织席位已用尽,请先提升席位额度或清理待处理邀请", { seatLimit: summary.limit, activeMembers: summary.active, pendingInvitations: summary.pending });
}
return summary;
}
function organizationQuotaRows(organizationId) {
return dbAll(
`SELECT q.*, w.name AS workspace_name, w.slug AS workspace_slug
FROM quota_allocations q
LEFT JOIN workspaces w ON w.id = q.workspace_id
WHERE q.organization_id = ?
ORDER BY CASE q.metric WHEN 'clip' THEN 0 WHEN 'storage' THEN 1 ELSE 2 END, w.name, q.metric`,
[organizationId]
).map((row) => ({
...row,
workspaceId: row.workspace_id,
workspaceName: row.workspace_name || "组织级",
metric: row.metric,
limitValue: Number(row.limit_value || 0),
usedValue: Number(row.used_value || 0),
remainingValue: Math.max(0, Number(row.limit_value || 0) - Number(row.used_value || 0))
}));
}
export function organizationCommercial(context) {
if (!hasPermission(context, "usage:view") && !hasPermission(context, "billing:manage") && !hasPermission(context, "quota:manage")) {
throw httpError(403, "permission_denied", "当前角色没有查看组织商业运营数据的权限");
}
const billing = billingAccount(context.organization.id);
const seat = organizationSeatSummary(context.organization.id);
const quotas = organizationQuotaRows(context.organization.id);
const usage = usageSummary(context);
usage.quotas = quotas;
const costCenters = organizationCostCenters(context.organization.id);
return {
organization: context.organization,
billing,
seat,
quotas,
usage,
billingHistory: billingHistory(context.organization.id),
costCenters: costCenters.centers,
costCenterDetail: costCenters.detail,
usageTrend: organizationUsageTrend(context.organization.id),
quotaWarnings: quotaWarnings(context.organization.id, billing, quotas, seat),
workspaces: dbAll("SELECT id, name, slug, status FROM workspaces WHERE organization_id = ? ORDER BY name", [context.organization.id])
};
}
export function updateOrganizationBilling(context, organizationId, body = {}) {
if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能修改其他组织的套餐配置", { organizationId });
requirePermission(context, "billing:manage");
const current = dbGet("SELECT * FROM billing_accounts WHERE organization_id = ?", [organizationId]);
if (!current) throw httpError(404, "billing_not_found", "组织套餐记录不存在", { organizationId });
const seatLimit = Math.max(1, Math.floor(Number(body.seatLimit ?? current.seat_limit)));
const storageGb = Math.max(1, Number(body.storageGb ?? current.storage_gb));
const monthlyClipQuota = Math.max(1, Math.floor(Number(body.monthlyClipQuota ?? current.monthly_clip_quota)));
const planName = String(body.planName ?? current.plan_name).trim();
const billingCycle = String(body.billingCycle ?? current.billing_cycle ?? "monthly").trim();
const currency = String(body.currency ?? current.currency ?? "CNY").trim().toUpperCase();
const baseFee = Math.max(0, Number(body.baseFee ?? current.base_fee ?? 0));
const seatUnitPrice = Math.max(0, Number(body.seatUnitPrice ?? current.seat_unit_price ?? 0));
const storageUnitPrice = Math.max(0, Number(body.storageUnitPrice ?? current.storage_unit_price ?? 0));
const clipUnitPrice = Math.max(0, Number(body.clipUnitPrice ?? current.clip_unit_price ?? 0));
const quotaWarningPercent = Math.min(99, Math.max(50, Math.floor(Number(body.quotaWarningPercent ?? current.quota_warning_percent ?? 80))));
if (!planName) throw httpError(400, "plan_name_required", "套餐名称不能为空");
if (!["monthly", "quarterly", "annual"].includes(billingCycle)) throw httpError(400, "billing_cycle_invalid", "账单周期只能是 monthly、quarterly 或 annual");
if (!/^[A-Z]{3}$/.test(currency)) throw httpError(400, "currency_invalid", "货币必须是三位字母代码");
for (const [value, label] of [[baseFee, "套餐固定费"], [seatUnitPrice, "席位单价"], [storageUnitPrice, "存储单价"], [clipUnitPrice, "片段单价"]]) {
if (!Number.isFinite(value) || value < 0) throw httpError(400, "billing_price_invalid", `${label}必须是大于或等于 0 的数字`);
}
const seats = organizationSeatSummary(organizationId);
if (seatLimit < seats.reserved) throw httpError(409, "seat_limit_below_reserved", "席位额度不能低于已占用和待处理邀请", { reserved: seats.reserved, seatLimit });
const clipUsage = Number(dbGet("SELECT COALESCE(SUM(units), 0) AS units FROM usage_events WHERE organization_id = ? AND unit_name IN ('job', 'clip', 'clips') AND created_at >= datetime('now', 'start of month')", [organizationId])?.units || 0);
if (monthlyClipQuota < clipUsage) throw httpError(409, "clip_quota_below_usage", "月度片段额度不能低于本月已用量", { used: clipUsage, monthlyClipQuota });
const maxStorageUsed = Number(dbGet("SELECT COALESCE(MAX(used_value), 0) AS used_value FROM quota_allocations WHERE organization_id = ? AND metric = 'storage'", [organizationId])?.used_value || 0);
if (storageGb < maxStorageUsed) throw httpError(409, "storage_quota_below_usage", "存储额度不能低于已记录用量", { usedGb: maxStorageUsed, storageGb });
const localRunnerOnly = body.localRunnerOnly === undefined ? Boolean(current.local_runner_only) : Boolean(body.localRunnerOnly);
const cloudApproval = body.cloudConnectorsRequireApproval === undefined ? Boolean(current.cloud_connectors_require_approval) : Boolean(body.cloudConnectorsRequireApproval);
const timestamp = new Date().toISOString();
const previous = { planName: current.plan_name, billingCycle: current.billing_cycle || "monthly", currency: current.currency || "CNY", baseFee: Number(current.base_fee || 0), seatUnitPrice: Number(current.seat_unit_price || 0), storageUnitPrice: Number(current.storage_unit_price || 0), clipUnitPrice: Number(current.clip_unit_price || 0), seatLimit: current.seat_limit, storageGb: current.storage_gb, monthlyClipQuota: current.monthly_clip_quota, quotaWarningPercent: Number(current.quota_warning_percent || 80), localRunnerOnly: Boolean(current.local_runner_only), cloudApproval: Boolean(current.cloud_connectors_require_approval) };
const next = { planName, billingCycle, currency, baseFee, seatUnitPrice, storageUnitPrice, clipUnitPrice, seatLimit, storageGb, monthlyClipQuota, quotaWarningPercent, localRunnerOnly, cloudApproval };
dbRun("UPDATE billing_accounts SET plan_name = ?, billing_cycle = ?, currency = ?, base_fee = ?, seat_unit_price = ?, storage_unit_price = ?, clip_unit_price = ?, seat_limit = ?, storage_gb = ?, monthly_clip_quota = ?, quota_warning_percent = ?, local_runner_only = ?, cloud_connectors_require_approval = ?, updated_at = ? WHERE organization_id = ?", [planName, billingCycle, currency, baseFee, seatUnitPrice, storageUnitPrice, clipUnitPrice, seatLimit, storageGb, monthlyClipQuota, quotaWarningPercent, localRunnerOnly ? 1 : 0, cloudApproval ? 1 : 0, timestamp, organizationId]);
dbRun("INSERT INTO billing_account_events(id, organization_id, billing_account_id, event_type, previous_json, next_json, actor_user_id, created_at) VALUES (?, ?, ?, 'plan.updated', ?, ?, ?, ?)", [`bill-event-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, organizationId, current.id, JSON.stringify(previous), JSON.stringify(next), context.user.id, timestamp]);
addAudit({ context, action: "billing.account.updated", targetType: "billing_account", targetId: current.id, metadata: { previous, next } });
return organizationCommercial(context);
}
export function updateQuotaAllocation(context, organizationId, quotaId, body = {}) {
if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能修改其他组织的配额", { organizationId });
requirePermission(context, "quota:manage");
const current = dbGet("SELECT * FROM quota_allocations WHERE id = ? AND organization_id = ?", [quotaId, organizationId]);
if (!current) throw httpError(404, "quota_not_found", "工作区配额不存在", { quotaId });
const limitValue = Number(body.limitValue);
if (!Number.isFinite(limitValue) || limitValue <= 0) throw httpError(400, "quota_limit_invalid", "配额上限必须是大于 0 的数字");
if (limitValue < Number(current.used_value || 0)) throw httpError(409, "quota_below_usage", "配额上限不能低于已用量", { used: Number(current.used_value || 0), limitValue });
const billing = billingAccount(organizationId);
if (current.metric === "clip" && limitValue > Number(billing?.monthly_clip_quota || 0)) throw httpError(409, "quota_above_plan", "工作区片段配额不能超过组织月度套餐上限", { planLimit: Number(billing?.monthly_clip_quota || 0) });
if (current.metric === "storage" && limitValue > Number(billing?.storage_gb || 0)) throw httpError(409, "quota_above_plan", "工作区存储配额不能超过组织套餐上限", { planLimit: Number(billing?.storage_gb || 0) });
const timestamp = new Date().toISOString();
dbRun("UPDATE quota_allocations SET limit_value = ?, updated_at = ? WHERE id = ? AND organization_id = ?", [limitValue, timestamp, quotaId, organizationId]);
addAudit({ context, action: "quota.allocation.updated", targetType: "quota_allocation", targetId: quotaId, metadata: { metric: current.metric, workspaceId: current.workspace_id, previousLimit: Number(current.limit_value), limitValue } });
return organizationCommercial(context);
}
export function updateCostCenter(context, organizationId, costCenterId, body = {}) {
if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能修改其他组织的成本中心", { organizationId });
requirePermission(context, "billing:manage");
const current = dbGet("SELECT * FROM cost_centers WHERE id = ? AND organization_id = ?", [costCenterId, organizationId]);
if (!current) throw httpError(404, "cost_center_not_found", "成本中心不存在", { costCenterId });
const name = String(body.name ?? current.name).trim();
const description = String(body.description ?? current.description ?? "").trim();
const budget = Number(body.monthlyBudget ?? current.monthly_budget);
const status = String(body.status ?? current.status).trim();
if (!name) throw httpError(400, "cost_center_name_required", "成本中心名称不能为空");
if (!Number.isFinite(budget) || budget < 0) throw httpError(400, "cost_center_budget_invalid", "成本中心月度预算不能小于 0");
if (!["active", "archived"].includes(status)) throw httpError(400, "cost_center_status_invalid", "成本中心状态无效");
const timestamp = new Date().toISOString();
dbRun("UPDATE cost_centers SET name = ?, description = ?, monthly_budget = ?, status = ?, updated_at = ? WHERE id = ? AND organization_id = ?", [name, description, budget, status, timestamp, costCenterId, organizationId]);
addAudit({ context, action: "billing.cost_center.updated", targetType: "cost_center", targetId: costCenterId, metadata: { previous: current, next: { name, description, monthlyBudget: budget, status } } });
return organizationCommercial(context);
}
function roundMoney(value) {
return Number((Number(value || 0)).toFixed(2));
}
function invoiceDate(value, field, endOfDay = false) {
const raw = String(value || "").trim();
if (!raw) return "";
const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw)
? `${raw}T${endOfDay ? "23:59:59.999" : "00:00:00.000"}Z`
: raw;
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) throw httpError(400, "invoice_date_invalid", `${field} 不是有效日期`, { field, value });
return date.toISOString();
}
function invoicePeriodForCycle(cycle, reference = new Date()) {
const date = new Date(reference);
date.setUTCDate(1);
date.setUTCHours(0, 0, 0, 0);
if (cycle === "annual") {
date.setUTCMonth(0, 1);
const end = new Date(date);
end.setUTCFullYear(end.getUTCFullYear() + 1, 0, 1);
end.setUTCMilliseconds(-1);
return { start: date.toISOString(), end: end.toISOString() };
}
if (cycle === "quarterly") {
date.setUTCMonth(Math.floor(date.getUTCMonth() / 3) * 3, 1);
const end = new Date(date);
end.setUTCMonth(end.getUTCMonth() + 3, 1);
end.setUTCMilliseconds(-1);
return { start: date.toISOString(), end: end.toISOString() };
}
const end = new Date(date);
end.setUTCMonth(end.getUTCMonth() + 1, 1);
end.setUTCMilliseconds(-1);
return { start: date.toISOString(), end: end.toISOString() };
}
function invoiceStatusLabel(status) {
return {
draft: "草稿",
issued: "已开票",
paid: "已支付",
overdue: "已逾期",
void: "已作废"
}[status] || status;
}
function invoicePayload(row) {
if (!row) return null;
return {
id: row.id,
organizationId: row.organization_id,
billingAccountId: row.billing_account_id,
invoiceNumber: row.invoice_number,
status: row.status,
statusLabel: invoiceStatusLabel(row.status),
currency: row.currency,
billingCycle: row.billing_cycle,
periodStart: row.period_start,
periodEnd: row.period_end,
issuedAt: row.issued_at,
dueAt: row.due_at,
paidAt: row.paid_at,
voidedAt: row.voided_at,
subtotal: Number(row.subtotal || 0),
taxRate: Number(row.tax_rate || 0),
taxAmount: Number(row.tax_amount || 0),
totalAmount: Number(row.total_amount || 0),
snapshot: parseJson(row.snapshot_json, {}),
createdBy: row.created_by,
createdByName: row.created_by_name || row.created_by || "system",
createdAt: row.created_at,
updatedAt: row.updated_at,
lineCount: Number(row.line_count || 0)
};
}
function invoiceLines(invoiceId) {
return dbAll("SELECT * FROM invoice_lines WHERE invoice_id = ? ORDER BY sort_order, created_at, id", [invoiceId]).map((row) => ({
id: row.id,
invoiceId: row.invoice_id,
lineType: row.line_type,
description: row.description,
quantity: Number(row.quantity || 0),
unitName: row.unit_name,
unitPrice: Number(row.unit_price || 0),
amount: Number(row.amount || 0),
metadata: parseJson(row.metadata_json, {}),
sortOrder: Number(row.sort_order || 0),
createdAt: row.created_at
}));
}
function requireInvoiceRead(context, organizationId) {
if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能查看其他组织的账单", { organizationId });
if (!hasPermission(context, "usage:view") && !hasPermission(context, "billing:manage")) throw httpError(403, "permission_denied", "当前角色没有查看组织账单的权限");
}
function invoiceListRows(organizationId, options = {}) {
const clauses = ["i.organization_id = ?"];
const params = [organizationId];
const status = String(options.status || "").trim();
if (status) {
if (!["draft", "issued", "paid", "overdue", "void"].includes(status)) throw httpError(400, "invoice_status_invalid", "账单状态无效", { status });
clauses.push("i.status = ?");
params.push(status);
}
const query = String(options.query || "").trim().slice(0, 120);
if (query) {
clauses.push("(LOWER(i.invoice_number) LIKE ? OR LOWER(i.status) LIKE ?)");
const like = `%${query.toLowerCase()}%`;
params.push(like, like);
}
const exportMode = Boolean(options.exportMode);
const rawPage = Number(options.page || 1);
const rawPageSize = Number(options.pageSize || (exportMode ? 10000 : 25));
const page = Number.isFinite(rawPage) ? Math.min(100000, Math.max(1, Math.floor(rawPage))) : 1;
const pageSize = Number.isFinite(rawPageSize) ? Math.min(exportMode ? 10000 : 100, Math.max(1, Math.floor(rawPageSize))) : exportMode ? 10000 : 25;
const where = `WHERE ${clauses.join(" AND ")}`;
const total = Number(dbGet(`SELECT COUNT(*) AS count FROM organization_invoices i ${where}`, params)?.count || 0);
const rows = dbAll(
`SELECT i.*, u.display_name AS created_by_name, COUNT(il.id) AS line_count
FROM organization_invoices i
LEFT JOIN users u ON u.id = i.created_by
LEFT JOIN invoice_lines il ON il.invoice_id = i.id
${where}
GROUP BY i.id
ORDER BY i.period_end DESC, i.created_at DESC
LIMIT ? OFFSET ?`,
[...params, pageSize, (page - 1) * pageSize]
).map(invoicePayload);
const summaryRows = dbAll("SELECT status, COUNT(*) AS count, COALESCE(SUM(total_amount), 0) AS amount FROM organization_invoices WHERE organization_id = ? GROUP BY status", [organizationId]);
const summary = { count: 0, amount: 0, draft: 0, issued: 0, paid: 0, overdue: 0, void: 0 };
for (const row of summaryRows) {
summary[row.status] = Number(row.count || 0);
summary.count += Number(row.count || 0);
if (row.status !== "void") summary.amount += Number(row.amount || 0);
}
return { invoices: rows, pagination: { page, pageSize, total, totalPages: total ? Math.ceil(total / pageSize) : 0 }, summary };
}
function invoiceDetailRow(organizationId, invoiceId) {
const row = dbGet(
`SELECT i.*, u.display_name AS created_by_name, COUNT(il.id) AS line_count
FROM organization_invoices i
LEFT JOIN users u ON u.id = i.created_by
LEFT JOIN invoice_lines il ON il.invoice_id = i.id
WHERE i.organization_id = ? AND i.id = ?
GROUP BY i.id`,
[organizationId, invoiceId]
);
if (!row) throw httpError(404, "invoice_not_found", "账单不存在或不属于当前组织", { invoiceId });
return { invoice: invoicePayload(row), lines: invoiceLines(invoiceId) };
}
export function organizationInvoices(context, organizationId, options = {}) {
requireInvoiceRead(context, organizationId);
return { organizationId, ...invoiceListRows(organizationId, options) };
}
export function organizationInvoice(context, organizationId, invoiceId) {
requireInvoiceRead(context, organizationId);
return invoiceDetailRow(organizationId, invoiceId);
}
export function exportOrganizationInvoices(context, organizationId, options = {}) {
requireInvoiceRead(context, organizationId);
const result = invoiceListRows(organizationId, { ...options, page: 1, pageSize: 10000, exportMode: true });
return { exportedAt: new Date().toISOString(), organizationId, ...result };
}
export function generateOrganizationInvoice(context, organizationId, body = {}) {
if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能为其他组织生成账单", { organizationId });
requirePermission(context, "billing:manage");
const billing = dbGet("SELECT * FROM billing_accounts WHERE organization_id = ?", [organizationId]);
if (!billing) throw httpError(404, "billing_not_found", "组织套餐记录不存在", { organizationId });
const cycle = billing.billing_cycle || "monthly";
const defaults = invoicePeriodForCycle(cycle);
const hasStart = body.periodStart !== undefined && String(body.periodStart).trim() !== "";
const hasEnd = body.periodEnd !== undefined && String(body.periodEnd).trim() !== "";
if (hasStart !== hasEnd) throw httpError(400, "invoice_period_incomplete", "账期开始和结束日期必须同时提供");
const periodStart = hasStart ? invoiceDate(body.periodStart, "账期开始", false) : defaults.start;
const periodEnd = hasEnd ? invoiceDate(body.periodEnd, "账期结束", true) : defaults.end;
if (new Date(periodStart).getTime() >= new Date(periodEnd).getTime()) throw httpError(400, "invoice_period_invalid", "账期结束必须晚于账期开始");
const existing = dbGet("SELECT id FROM organization_invoices WHERE organization_id = ? AND period_start = ? AND period_end = ?", [organizationId, periodStart, periodEnd]);
if (existing) return { ...invoiceDetailRow(organizationId, existing.id), idempotent: true };
const dueDays = Math.min(365, Math.max(0, Math.floor(Number(body.dueDays ?? 30))));
const taxRate = Number(body.taxRate ?? 0);
if (!Number.isFinite(taxRate) || taxRate < 0 || taxRate > 100) throw httpError(400, "invoice_tax_rate_invalid", "税率必须在 0 到 100 之间");
const dueDate = new Date(new Date(periodEnd).getTime() + dueDays * 86400000).toISOString();
const events = dbAll("SELECT * FROM usage_events WHERE organization_id = ? AND created_at >= ? AND created_at <= ? ORDER BY created_at ASC, id ASC", [organizationId, periodStart, periodEnd]);
const workspaces = Object.fromEntries(dbAll("SELECT id, name FROM workspaces WHERE organization_id = ?", [organizationId]).map((row) => [row.id, row.name]));
const costCenters = new Map();
let estimatedCost = 0;
let totalUnits = 0;
for (const event of events) {
const code = costCenterCodeForUsage(event);
const current = costCenters.get(code) || { code, workspaceIds: new Set(), units: 0, events: 0, amount: 0 };
current.workspaceIds.add(event.workspace_id || "organization");
current.units += Number(event.units || 0);
current.events += 1;
current.amount += Number(event.estimated_cost || 0);
costCenters.set(code, current);
estimatedCost += Number(event.estimated_cost || 0);
totalUnits += Number(event.units || 0);
}
const activeSeats = Number(dbGet("SELECT COUNT(*) AS count FROM organization_members WHERE organization_id = ? AND status = 'active'", [organizationId])?.count || 0);
const clipUnits = Number(dbGet("SELECT COALESCE(SUM(units), 0) AS units FROM usage_events WHERE organization_id = ? AND unit_name IN ('job', 'clip', 'clips') AND created_at >= ? AND created_at <= ?", [organizationId, periodStart, periodEnd])?.units || 0);
const storageUsed = Number(dbGet("SELECT COALESCE(MAX(used_value), 0) AS used_value FROM quota_allocations WHERE organization_id = ? AND metric = 'storage'", [organizationId])?.used_value || 0);
const lines = [
{ lineType: "base_fee", description: `${billing.plan_name} · 套餐固定费`, quantity: 1, unitName: "账期", unitPrice: Number(billing.base_fee || 0), metadata: { planName: billing.plan_name } }
];
if (Number(billing.seat_unit_price || 0) > 0) lines.push({ lineType: "seat", description: "组织活跃席位", quantity: activeSeats, unitName: "席位", unitPrice: Number(billing.seat_unit_price || 0), metadata: { activeSeats } });
if (Number(billing.storage_unit_price || 0) > 0 && storageUsed > 0) lines.push({ lineType: "storage", description: "组织存储用量", quantity: storageUsed, unitName: "GB", unitPrice: Number(billing.storage_unit_price || 0), metadata: { storageUsed } });
if (Number(billing.clip_unit_price || 0) > 0 && clipUnits > 0) lines.push({ lineType: "clip", description: "生成片段用量", quantity: clipUnits, unitName: "片段", unitPrice: Number(billing.clip_unit_price || 0), metadata: { clipUnits } });
let sortOrder = lines.length;
for (const detail of [...costCenters.values()].sort((a, b) => b.amount - a.amount || a.code.localeCompare(b.code))) {
const workspaceNames = [...detail.workspaceIds].map((id) => workspaces[id] || "组织级");
lines.push({ lineType: "usage", description: `计量用量 · ${detail.code}`, quantity: detail.units, unitName: "单位", unitPrice: detail.units ? roundMoney(detail.amount / detail.units) : 0, amountOverride: roundMoney(detail.amount), metadata: { costCenter: detail.code, events: detail.events, workspaces: workspaceNames } });
}
lines.forEach((line) => {
line.amount = line.amountOverride === undefined ? roundMoney(line.quantity * line.unitPrice) : roundMoney(line.amountOverride);
});
const subtotal = roundMoney(lines.reduce((sum, line) => sum + line.amount, 0));
const taxAmount = roundMoney(subtotal * taxRate / 100);
const totalAmount = roundMoney(subtotal + taxAmount);
const timestamp = new Date().toISOString();
const invoiceId = `invoice-${organizationId}-${periodStart.slice(0, 10).replaceAll("-", "")}-${periodEnd.slice(0, 10).replaceAll("-", "")}`;
const invoiceNumber = `INV-${organizationId.replace(/[^A-Za-z0-9]+/g, "-").toUpperCase()}-${periodStart.slice(0, 10).replaceAll("-", "")}-${periodEnd.slice(0, 10).replaceAll("-", "")}`;
const snapshot = {
billing: { planName: billing.plan_name, billingCycle: cycle, currency: billing.currency || "CNY", baseFee: Number(billing.base_fee || 0), seatUnitPrice: Number(billing.seat_unit_price || 0), storageUnitPrice: Number(billing.storage_unit_price || 0), clipUnitPrice: Number(billing.clip_unit_price || 0) },
period: { start: periodStart, end: periodEnd },
usage: { eventCount: events.length, totalUnits: roundMoney(totalUnits), estimatedCost: roundMoney(estimatedCost), clipUnits, storageUsed, activeSeats },
taxRate
};
withTransaction(() => {
dbRun("INSERT INTO organization_invoices(id, organization_id, billing_account_id, invoice_number, status, currency, billing_cycle, period_start, period_end, due_at, subtotal, tax_rate, tax_amount, total_amount, snapshot_json, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [invoiceId, organizationId, billing.id, invoiceNumber, billing.currency || "CNY", cycle, periodStart, periodEnd, dueDate, subtotal, taxRate, taxAmount, totalAmount, JSON.stringify(snapshot), context.user.id, timestamp, timestamp]);
for (const [index, line] of lines.entries()) {
dbRun("INSERT INTO invoice_lines(id, invoice_id, line_type, description, quantity, unit_name, unit_price, amount, metadata_json, sort_order, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [`invoice-line-${invoiceId}-${index + 1}`, invoiceId, line.lineType, line.description, line.quantity, line.unitName, line.unitPrice, line.amount, JSON.stringify(line.metadata || {}), index, timestamp]);
}
dbRun("INSERT INTO billing_account_events(id, organization_id, billing_account_id, event_type, previous_json, next_json, actor_user_id, created_at) VALUES (?, ?, ?, 'invoice.generated', '{}', ?, ?, ?)", [`bill-event-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, organizationId, billing.id, JSON.stringify({ invoiceId, invoiceNumber, periodStart, periodEnd, totalAmount }), context.user.id, timestamp]);
});
addAudit({ context, action: "billing.invoice.generated", targetType: "organization_invoice", targetId: invoiceId, metadata: { invoiceNumber, periodStart, periodEnd, totalAmount, idempotent: false } });
return { ...invoiceDetailRow(organizationId, invoiceId), idempotent: false };
}
export function updateOrganizationInvoiceStatus(context, organizationId, invoiceId, body = {}) {
if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能修改其他组织的账单", { organizationId });
requirePermission(context, "billing:manage");
const current = dbGet("SELECT * FROM organization_invoices WHERE organization_id = ? AND id = ?", [organizationId, invoiceId]);
if (!current) throw httpError(404, "invoice_not_found", "账单不存在或不属于当前组织", { invoiceId });
const nextStatus = String(body.status || "").trim();
if (!["draft", "issued", "paid", "overdue", "void"].includes(nextStatus)) throw httpError(400, "invoice_status_invalid", "账单状态无效", { status: nextStatus });
if (nextStatus === current.status) return { ...invoiceDetailRow(organizationId, invoiceId), idempotent: true };
const transitions = { draft: new Set(["issued", "void"]), issued: new Set(["paid", "overdue", "void"]), overdue: new Set(["paid", "void"]), paid: new Set(), void: new Set() };
if (!transitions[current.status]?.has(nextStatus)) throw httpError(409, "invoice_transition_invalid", `账单不能从${invoiceStatusLabel(current.status)}变更为${invoiceStatusLabel(nextStatus)}`, { from: current.status, to: nextStatus });
const timestamp = new Date().toISOString();
const issuedAt = nextStatus === "issued" && !current.issued_at ? timestamp : current.issued_at;
const paidAt = nextStatus === "paid" ? (current.paid_at || timestamp) : current.paid_at;
const voidedAt = nextStatus === "void" ? (current.voided_at || timestamp) : current.voided_at;
dbRun("UPDATE organization_invoices SET status = ?, issued_at = ?, paid_at = ?, voided_at = ?, updated_at = ? WHERE id = ? AND organization_id = ?", [nextStatus, issuedAt, paidAt, voidedAt, timestamp, invoiceId, organizationId]);
dbRun("INSERT INTO billing_account_events(id, organization_id, billing_account_id, event_type, previous_json, next_json, actor_user_id, created_at) VALUES (?, ?, ?, 'invoice.status.updated', ?, ?, ?, ?)", [`bill-event-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, organizationId, current.billing_account_id, JSON.stringify({ status: current.status }), JSON.stringify({ status: nextStatus }), context.user.id, timestamp]);
addAudit({ context, action: "billing.invoice.status.updated", targetType: "organization_invoice", targetId: invoiceId, metadata: { invoiceNumber: current.invoice_number, previousStatus: current.status, status: nextStatus } });
return { ...invoiceDetailRow(organizationId, invoiceId), idempotent: false };
}
export function exportOrganizationCommercial(context, organizationId) {
if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能导出其他组织的商业运营数据", { organizationId });
if (!hasPermission(context, "usage:view") && !hasPermission(context, "billing:manage") && !hasPermission(context, "quota:manage")) throw httpError(403, "permission_denied", "当前角色没有导出组织商业运营数据的权限");
return { exportedAt: new Date().toISOString(), organizationId, ...organizationCommercial(context) };
}
function usageOrganizationScope(context, options = {}) {
const organizationId = context.organization.id;
const orgWide = Boolean(context.systemAdmin || context.orgElevated || ["org_owner", "org_admin"].includes(context.organizationMembership?.role_key));
const clauses = ["ue.organization_id = ?"];
const params = [organizationId];
const requestedWorkspaceId = String(options.workspaceId || "").trim();
const requestedProjectId = String(options.projectId || "").trim();
if (requestedWorkspaceId) {
const workspace = dbGet("SELECT id, organization_id FROM workspaces WHERE id = ? AND status = 'active'", [requestedWorkspaceId]);
if (!workspace || workspace.organization_id !== organizationId) throw httpError(404, "workspace_not_found", "用量筛选工作区不存在或不属于当前组织", { workspaceId: requestedWorkspaceId });
if (!orgWide && requestedWorkspaceId !== context.workspace?.id) throw httpError(403, "usage_scope_forbidden", "当前角色只能查看当前工作区的用量", { workspaceId: requestedWorkspaceId });
clauses.push("ue.workspace_id = ?");
params.push(requestedWorkspaceId);
} else if (!orgWide) {
clauses.push("ue.workspace_id = ?");
params.push(context.workspace.id);
}
if (requestedProjectId) {
const project = dbGet(
`SELECT p.id, p.workspace_id, w.organization_id
FROM projects p JOIN workspaces w ON w.id = p.workspace_id
WHERE p.id = ?`,
[requestedProjectId]
);
if (!project || project.organization_id !== organizationId) throw httpError(404, "project_not_found", "用量筛选项目不存在或不属于当前组织", { projectId: requestedProjectId });
if (!orgWide && project.workspace_id !== context.workspace?.id) throw httpError(403, "usage_scope_forbidden", "当前角色不能查看其他工作区项目用量", { projectId: requestedProjectId });
clauses.push("ue.project_id = ?");
params.push(requestedProjectId);
} else if (!orgWide && context.project?.id) {
clauses.push("(ue.project_id IS NULL OR ue.project_id = ?)");
params.push(context.project.id);
}
return { clauses, params, orgWide };
}
function usageFilterQuery(options = {}) {
const clauses = [];
const params = [];
const query = String(options.query || "").trim().slice(0, 160);
if (query) {
const like = `%${query.toLowerCase()}%`;
clauses.push("(LOWER(ue.kind) LIKE ? OR LOWER(ue.unit_name) LIKE ? OR LOWER(COALESCE(ue.metadata_json, '')) LIKE ? OR LOWER(COALESCE(w.name, '')) LIKE ? OR LOWER(COALESCE(p.name, '')) LIKE ? OR LOWER(COALESCE(u.display_name, '')) LIKE ? OR LOWER(COALESCE(u.email, '')) LIKE ?)");
params.push(like, like, like, like, like, like, like);
}
for (const [key, column] of [["kind", "ue.kind"], ["unitName", "ue.unit_name"], ["userId", "ue.user_id"]]) {
const value = String(options[key] || "").trim();
if (value) {
clauses.push(`${column} = ?`);
params.push(value);
}
}
const costCenter = String(options.costCenter || "").trim().toLowerCase();
if (costCenter) {
const metadataCostCenter = "LOWER(COALESCE(json_extract(ue.metadata_json, '$.costCenter'), ''))";
const eventKind = "LOWER(ue.kind)";
const defaultLocalGpu = `(${eventKind} LIKE '%image%' OR ${eventKind} LIKE '%video%' OR ${eventKind} LIKE '%i2v%' OR ${eventKind} LIKE '%tts%' OR ${eventKind} LIKE '%asr%' OR ${eventKind} LIKE '%generation%')`;
const defaultStorage = `(${eventKind} LIKE '%storage%' OR ${eventKind} LIKE '%asset%' OR ${eventKind} LIKE '%delivery%' OR ${eventKind} LIKE '%export%')`;
const defaultOperations = `NOT ${defaultLocalGpu} AND NOT ${defaultStorage}`;
if (costCenter === "local-gpu") clauses.push(`(${metadataCostCenter} = ? OR (${metadataCostCenter} = '' AND ${defaultLocalGpu}))`);
else if (costCenter === "storage") clauses.push(`(${metadataCostCenter} = ? OR (${metadataCostCenter} = '' AND ${defaultStorage}))`);
else if (costCenter === "operations") clauses.push(`(${metadataCostCenter} = ? OR (${metadataCostCenter} = '' AND ${defaultOperations}))`);
else clauses.push(`${metadataCostCenter} = ?`);
params.push(costCenter);
}
const from = auditDate(options.from, "开始时间");
const to = auditDate(options.to, "结束时间", true);
if (from) {
clauses.push("ue.created_at >= ?");
params.push(from);
} else {
clauses.push("ue.created_at >= datetime('now', 'start of month')");
}
if (to) {
clauses.push("ue.created_at <= ?");
params.push(to);
}
return { clauses, params, from, to };
}
function usagePageOptions(options = {}) {
const exportMode = Boolean(options.exportMode);
const rawPage = Number(options.page || 1);
const rawPageSize = Number(options.pageSize || (exportMode ? 10000 : 25));
const page = Number.isFinite(rawPage) ? Math.min(100000, Math.max(1, Math.floor(rawPage))) : 1;
const pageSize = Number.isFinite(rawPageSize) ? Math.min(exportMode ? 10000 : 100, Math.max(1, Math.floor(rawPageSize))) : exportMode ? 10000 : 25;
return { page, pageSize, offset: (page - 1) * pageSize };
}
function usageEventPayload(row) {
const metadata = parseJson(row.metadata_json, {});
return {
id: row.id,
createdAt: row.created_at,
organizationId: row.organization_id,
workspaceId: row.workspace_id,
workspaceName: row.workspace_name || "组织级",
projectId: row.project_id,
projectName: row.project_name || "组织级",
userId: row.user_id,
userName: row.user_name || row.user_id || "系统",
userEmail: row.user_email || "",
kind: row.kind,
units: Number(row.units || 0),
unitName: row.unit_name,
estimatedCost: Number(row.estimated_cost || 0),
costCenter: costCenterCodeForUsage(row),
metadata
};
}
function usageFilterFacets(context, organizationId, orgWide) {
const workspaces = orgWide
? dbAll("SELECT id, name, slug FROM workspaces WHERE organization_id = ? AND status = 'active' ORDER BY name", [organizationId])
: dbAll("SELECT id, name, slug FROM workspaces WHERE id = ? AND organization_id = ? AND status = 'active'", [context.workspace.id, organizationId]);
const workspaceIds = workspaces.map((workspace) => workspace.id);
const projects = workspaceIds.length
? dbAll(
`SELECT p.id, p.name, p.workspace_id, w.name AS workspace_name
FROM projects p JOIN workspaces w ON w.id = p.workspace_id
WHERE p.workspace_id IN (${workspaceIds.map(() => "?").join(",")})
ORDER BY w.name, p.name`,
workspaceIds
)
: [];
const users = dbAll(
`SELECT DISTINCT u.id, u.display_name, u.email
FROM usage_events ue
JOIN users u ON u.id = ue.user_id
WHERE ue.organization_id = ?${orgWide ? "" : " AND ue.workspace_id = ?"}
ORDER BY u.display_name`,
orgWide ? [organizationId] : [organizationId, context.workspace.id]
);
const kinds = dbAll(
`SELECT DISTINCT ue.kind
FROM usage_events ue
WHERE ue.organization_id = ?${orgWide ? "" : " AND ue.workspace_id = ?"}
ORDER BY ue.kind`,
orgWide ? [organizationId] : [organizationId, context.workspace.id]
).map((row) => row.kind);
const costCenters = dbAll("SELECT id, code, name, currency FROM cost_centers WHERE organization_id = ? AND status = 'active' ORDER BY name", [organizationId]);
return { workspaces, projects, users, kinds, costCenters };
}
export function organizationUsage(context, organizationId, options = {}) {
if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能查看其他组织的用量明细", { organizationId });
if (!hasPermission(context, "usage:view") && !hasPermission(context, "billing:manage") && !hasPermission(context, "quota:manage")) throw httpError(403, "permission_denied", "当前角色没有查看组织用量明细的权限");
const scope = usageOrganizationScope(context, options);
const filters = usageFilterQuery(options);
const where = [...scope.clauses, ...filters.clauses];
const whereParams = [...scope.params, ...filters.params];
const pageOptions = usagePageOptions(options);
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
const countRow = dbGet(`SELECT COUNT(*) AS event_count, COALESCE(SUM(ue.units), 0) AS total_units, COALESCE(SUM(ue.estimated_cost), 0) AS total_cost FROM usage_events ue LEFT JOIN workspaces w ON w.id = ue.workspace_id LEFT JOIN projects p ON p.id = ue.project_id LEFT JOIN users u ON u.id = ue.user_id ${whereSql}`, whereParams);
const byKind = dbAll(
`SELECT ue.kind, ue.unit_name, SUM(ue.units) AS units, SUM(ue.estimated_cost) AS estimated_cost, COUNT(*) AS events
FROM usage_events ue
LEFT JOIN workspaces w ON w.id = ue.workspace_id
LEFT JOIN projects p ON p.id = ue.project_id
LEFT JOIN users u ON u.id = ue.user_id
${whereSql}
GROUP BY ue.kind, ue.unit_name
ORDER BY estimated_cost DESC, events DESC`,
whereParams
).map((row) => ({ kind: row.kind, unitName: row.unit_name, units: Number(row.units || 0), estimatedCost: Number(row.estimated_cost || 0), events: Number(row.events || 0) }));
const rows = dbAll(
`SELECT ue.*, w.name AS workspace_name, p.name AS project_name, u.display_name AS user_name, u.email AS user_email
FROM usage_events ue
LEFT JOIN workspaces w ON w.id = ue.workspace_id
LEFT JOIN projects p ON p.id = ue.project_id
LEFT JOIN users u ON u.id = ue.user_id
${whereSql}
ORDER BY ue.created_at DESC, ue.id DESC
LIMIT ? OFFSET ?`,
[...whereParams, pageOptions.pageSize, pageOptions.offset]
).map(usageEventPayload);
const total = Number(countRow?.event_count || 0);
return {
organizationId,
filters: {
query: String(options.query || "").trim(),
workspaceId: String(options.workspaceId || "").trim(),
projectId: String(options.projectId || "").trim(),
userId: String(options.userId || "").trim(),
kind: String(options.kind || "").trim(),
unitName: String(options.unitName || "").trim(),
costCenter: String(options.costCenter || "").trim(),
from: filters.from || "month-start",
to: filters.to || ""
},
items: rows,
summary: {
eventCount: total,
totalUnits: Number(countRow?.total_units || 0),
totalCost: Number(countRow?.total_cost || 0),
byKind
},
pagination: {
page: pageOptions.page,
pageSize: pageOptions.pageSize,
total,
totalPages: total ? Math.ceil(total / pageOptions.pageSize) : 0
},
facets: usageFilterFacets(context, organizationId, scope.orgWide)
};
}
export function exportOrganizationUsage(context, organizationId, options = {}) {
return {
exportedAt: new Date().toISOString(),
...organizationUsage(context, organizationId, { ...options, page: 1, pageSize: 10000, exportMode: true })
};
}
export function usageSummary(context) {
const usage = dbAll(
`SELECT kind, unit_name, SUM(units) AS units, SUM(estimated_cost) AS estimated_cost
FROM usage_events
WHERE organization_id = ? AND created_at >= datetime('now', 'start of month')
GROUP BY kind, unit_name
ORDER BY estimated_cost DESC`,
[context.organization.id]
);
const quotas = dbAll(
`SELECT metric, limit_value, used_value, unit, period_start, period_end
FROM quota_allocations
WHERE organization_id = ? AND (workspace_id = ? OR workspace_id IS NULL)
ORDER BY metric`,
[context.organization.id, context.workspace.id]
);
const totalCost = usage.reduce((sum, item) => sum + Number(item.estimated_cost || 0), 0);
return { usage, quotas, totalCost };
}
function quotaRow(context, metric) {
return dbGet(
`SELECT * FROM quota_allocations
WHERE organization_id = ?
AND metric = ?
AND (workspace_id = ? OR workspace_id IS NULL)
AND julianday(period_start) <= julianday('now')
AND julianday(period_end) >= julianday('now')
ORDER BY CASE WHEN workspace_id = ? THEN 0 ELSE 1 END
LIMIT 1`,
[context.organization.id, metric, context.workspace.id, context.workspace.id]
);
}
export function requireQuota(context, metric, units = 1) {
const row = quotaRow(context, metric);
if (!row) return null;
const requested = Number(units || 0);
const rowUsed = Number(row.used_value || 0);
const rowLimit = Number(row.limit_value || 0);
const billing = billingAccount(context.organization.id);
const planLimit = metric === "clip" ? Number(billing?.monthly_clip_quota || 0) : 0;
const planUsed = metric === "clip"
? Number(dbGet("SELECT COALESCE(SUM(units), 0) AS units FROM usage_events WHERE organization_id = ? AND unit_name IN ('job', 'clip', 'clips') AND created_at >= datetime('now', 'start of month')", [context.organization.id])?.units || 0)
: 0;
const effectiveLimit = planLimit ? Math.min(rowLimit, planLimit) : rowLimit;
const effectiveUsed = Math.max(rowUsed, planUsed);
if (effectiveUsed + requested > effectiveLimit) {
throw httpError(429, "quota_exceeded", `当前组织的${row.unit || metric}额度不足`, {
metric,
unit: row.unit,
used: effectiveUsed,
limit: effectiveLimit,
requested
});
}
return { ...row, used_value: effectiveUsed, limit_value: effectiveLimit, remaining: effectiveLimit - effectiveUsed - requested };
}
function auditInteger(value, fallback, minimum, maximum) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(maximum, Math.max(minimum, Math.floor(parsed)));
}
function auditDate(value, field, endOfDay = false) {
const raw = String(value || "").trim();
if (!raw) return "";
const normalized = /^\d{4}-\d{2}-\d{2}$/.test(raw)
? `${raw}T${endOfDay ? "23:59:59.999" : "00:00:00.000"}Z`
: raw;
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) throw httpError(400, "audit_date_invalid", `${field} 不是有效日期`, { field, value });
return date.toISOString();
}
function auditPayload(row) {
if (!row) return null;
return {
...row,
organizationName: row.organization_name || "",
workspaceName: row.workspace_name || "",
projectName: row.project_name || "",
actorName: row.actor_name || row.actor_user_id || "system",
actorEmail: row.actor_email || "",
metadata: parseJson(row.metadata_json, {})
};
}
function auditSecurityPayload(row) {
return {
...row,
userName: row.user_name || row.user_id || "未知用户",
metadata: parseJson(row.metadata_json, {})
};
}
function auditScope(context, options = {}) {
const requestedOrganizationId = String(options.organizationId || "").trim();
const requestedWorkspaceId = String(options.workspaceId || "").trim();
const requestedProjectId = String(options.projectId || "").trim();
const clauses = [];
const params = [];
const orgWide = Boolean(context.orgElevated || ["org_owner", "org_admin"].includes(context.organizationMembership?.role_key));
const scope = {
mode: context.systemAdmin ? "global" : orgWide ? "organization" : context.project ? "project" : "workspace",
organizationId: context.systemAdmin ? requestedOrganizationId || null : context.organization.id,
workspaceId: requestedWorkspaceId || (!context.systemAdmin && !orgWide ? context.workspace?.id || null : null),
projectId: requestedProjectId || (!context.systemAdmin && !orgWide ? context.project?.id || null : null)
};
if (context.systemAdmin) {
if (requestedOrganizationId) {
const organization = dbGet("SELECT id FROM organizations WHERE id = ?", [requestedOrganizationId]);
if (!organization) throw httpError(404, "organization_not_found", "审计筛选组织不存在", { organizationId: requestedOrganizationId });
clauses.push("a.organization_id = ?");
params.push(requestedOrganizationId);
}
if (requestedWorkspaceId) {
const workspace = dbGet("SELECT id, organization_id FROM workspaces WHERE id = ?", [requestedWorkspaceId]);
if (!workspace) throw httpError(404, "workspace_not_found", "审计筛选工作区不存在", { workspaceId: requestedWorkspaceId });
if (requestedOrganizationId && workspace.organization_id !== requestedOrganizationId) throw httpError(400, "audit_scope_invalid", "工作区不属于筛选组织");
clauses.push("a.workspace_id = ?");
params.push(requestedWorkspaceId);
scope.organizationId ||= workspace.organization_id;
}
if (requestedProjectId) {
const project = dbGet(
`SELECT p.id, p.workspace_id, w.organization_id
FROM projects p JOIN workspaces w ON w.id = p.workspace_id
WHERE p.id = ?`,
[requestedProjectId]
);
if (!project) throw httpError(404, "project_not_found", "审计筛选项目不存在", { projectId: requestedProjectId });
if (requestedWorkspaceId && project.workspace_id !== requestedWorkspaceId) throw httpError(400, "audit_scope_invalid", "项目不属于筛选工作区");
if (requestedOrganizationId && project.organization_id !== requestedOrganizationId) throw httpError(400, "audit_scope_invalid", "项目不属于筛选组织");
clauses.push("a.project_id = ?");
params.push(requestedProjectId);
scope.organizationId ||= project.organization_id;
scope.workspaceId ||= project.workspace_id;
}
return { clauses, params, scope };
}
if (requestedOrganizationId && requestedOrganizationId !== context.organization.id) {
throw httpError(403, "audit_scope_forbidden", "不能查看其他组织的审计记录", { organizationId: requestedOrganizationId });
}
clauses.push("a.organization_id = ?");
params.push(context.organization.id);
if (requestedWorkspaceId) {
const workspace = dbGet("SELECT id FROM workspaces WHERE id = ? AND organization_id = ?", [requestedWorkspaceId, context.organization.id]);
if (!workspace) throw httpError(404, "workspace_not_found", "审计筛选工作区不存在或不属于当前组织", { workspaceId: requestedWorkspaceId });
if (!orgWide && requestedWorkspaceId !== context.workspace.id) throw httpError(403, "audit_scope_forbidden", "当前角色只能查看当前工作区审计记录", { workspaceId: requestedWorkspaceId });
clauses.push("a.workspace_id = ?");
params.push(requestedWorkspaceId);
scope.workspaceId = requestedWorkspaceId;
} else if (!orgWide) {
clauses.push("(a.workspace_id IS NULL OR a.workspace_id = ?)");
params.push(context.workspace.id);
}
if (requestedProjectId) {
const project = dbGet(
`SELECT p.id, p.workspace_id
FROM projects p JOIN workspaces w ON w.id = p.workspace_id
WHERE p.id = ? AND w.organization_id = ?`,
[requestedProjectId, context.organization.id]
);
if (!project) throw httpError(404, "project_not_found", "审计筛选项目不存在或不属于当前组织", { projectId: requestedProjectId });
if (!orgWide && project.workspace_id !== context.workspace.id) throw httpError(403, "audit_scope_forbidden", "当前角色不能查看其他工作区项目审计记录", { projectId: requestedProjectId });
clauses.push("a.project_id = ?");
params.push(requestedProjectId);
scope.projectId = requestedProjectId;
} else if (!orgWide) {
clauses.push(context.project?.id ? "(a.project_id IS NULL OR a.project_id = ?)" : "a.project_id IS NULL");
if (context.project?.id) params.push(context.project.id);
}
return { clauses, params, scope };
}
const AUDIT_SELECT = `
SELECT a.*, o.name AS organization_name, w.name AS workspace_name, p.name AS project_name,
u.display_name AS actor_name, u.email AS actor_email
FROM audit_logs a
LEFT JOIN organizations o ON o.id = a.organization_id
LEFT JOIN workspaces w ON w.id = a.workspace_id
LEFT JOIN projects p ON p.id = a.project_id
LEFT JOIN users u ON u.id = a.actor_user_id`;
function auditFilterQuery(options = {}) {
const clauses = [];
const params = [];
const query = String(options.query || "").trim().slice(0, 160);
if (query) {
const like = `%${query.toLowerCase()}%`;
clauses.push("(LOWER(a.action) LIKE ? OR LOWER(a.target_type) LIKE ? OR LOWER(a.target_id) LIKE ? OR LOWER(COALESCE(u.display_name, '')) LIKE ? OR LOWER(COALESCE(u.email, '')) LIKE ? OR LOWER(a.metadata_json) LIKE ?)");
params.push(like, like, like, like, like, like);
}
for (const [key, column] of [["action", "a.action"], ["targetType", "a.target_type"], ["targetId", "a.target_id"], ["result", "a.result"], ["actorUserId", "a.actor_user_id"]]) {
const value = String(options[key] || "").trim();
if (value) {
clauses.push(`${column} = ?`);
params.push(value);
}
}
const from = auditDate(options.from, "开始时间");
const to = auditDate(options.to, "结束时间", true);
if (from) {
clauses.push("a.created_at >= ?");
params.push(from);
}
if (to) {
clauses.push("a.created_at <= ?");
params.push(to);
}
return { clauses, params };
}
function auditFacets(scopeClauses, scopeParams) {
const where = scopeClauses.length ? `WHERE ${scopeClauses.join(" AND ")}` : "";
const base = (column) => dbAll(`SELECT ${column} AS value, COUNT(*) AS count FROM audit_logs a LEFT JOIN users u ON u.id = a.actor_user_id ${where} GROUP BY ${column} ORDER BY count DESC, value LIMIT 100`, scopeParams);
return {
actions: base("a.action").filter((item) => item.value).map((item) => ({ value: item.value, count: Number(item.count || 0) })),
targetTypes: base("a.target_type").filter((item) => item.value).map((item) => ({ value: item.value, count: Number(item.count || 0) })),
results: base("a.result").filter((item) => item.value).map((item) => ({ value: item.value, count: Number(item.count || 0) })),
actors: dbAll(`SELECT a.actor_user_id AS value, COALESCE(u.display_name, a.actor_user_id, 'system') AS label, COUNT(*) AS count FROM audit_logs a LEFT JOIN users u ON u.id = a.actor_user_id ${where} GROUP BY a.actor_user_id, u.display_name ORDER BY count DESC, label LIMIT 100`, scopeParams)
.filter((item) => item.value)
.map((item) => ({ value: item.value, label: item.label, count: Number(item.count || 0) }))
};
}
export function listAuditEvents(context, options = {}) {
requirePermission(context, "audit:view");
const { clauses: scopeClauses, params: scopeParams, scope } = auditScope(context, options);
const filters = auditFilterQuery(options);
const clauses = [...scopeClauses, ...filters.clauses];
const params = [...scopeParams, ...filters.params];
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
const page = auditInteger(options.page, 1, 1, 100000);
const maxPageSize = options.forExport ? 5000 : 200;
const pageSize = auditInteger(options.pageSize ?? options.limit, options.forExport ? 500 : 25, 1, maxPageSize);
const total = Number(dbGet(`SELECT COUNT(*) AS count FROM audit_logs a LEFT JOIN users u ON u.id = a.actor_user_id ${where}`, params)?.count || 0);
const auditLog = dbAll(`${AUDIT_SELECT} ${where} ORDER BY a.created_at DESC, a.id DESC LIMIT ? OFFSET ?`, [...params, pageSize, (page - 1) * pageSize]).map(auditPayload);
return {
auditLog,
pagination: {
page,
pageSize,
total,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
hasMore: page * pageSize < total
},
filters: {
query: String(options.query || "").trim(),
action: String(options.action || "").trim(),
targetType: String(options.targetType || "").trim(),
targetId: String(options.targetId || "").trim(),
result: String(options.result || "").trim(),
actorUserId: String(options.actorUserId || "").trim(),
from: String(options.from || "").trim(),
to: String(options.to || "").trim()
},
facets: auditFacets(scopeClauses, scopeParams),
scope
};
}
export function getAuditEvent(context, auditId) {
requirePermission(context, "audit:view");
const id = String(auditId || "").trim();
if (!id) throw httpError(400, "audit_id_required", "审计事件 ID 不能为空");
const { clauses, params, scope } = auditScope(context);
clauses.push("a.id = ?");
params.push(id);
const row = dbGet(`${AUDIT_SELECT} WHERE ${clauses.join(" AND ")}`, params);
if (!row) throw httpError(404, "audit_not_found", "审计事件不存在或不在当前权限范围内", { auditId: id });
const event = auditPayload(row);
const relatedAudit = dbAll(
`${AUDIT_SELECT} WHERE ${[...auditScope(context).clauses, "a.target_type = ?", "a.target_id = ?", "a.id <> ?"].join(" AND ")} ORDER BY a.created_at DESC, a.id DESC LIMIT 12`,
[...auditScope(context).params, row.target_type, row.target_id, id]
).map(auditPayload);
const metadata = event.metadata || {};
const userIds = [...new Set([row.actor_user_id, metadata.userId, metadata.user_id, metadata.actorUserId].map((value) => String(value || "").trim()).filter(Boolean))];
const references = [...new Set([metadata.sessionId, metadata.deviceRecordId, metadata.deviceId].map((value) => String(value || "").trim()).filter(Boolean))];
let relatedSecurityEvents = [];
if (userIds.length || references.length) {
const securityClauses = [];
const securityParams = [];
if (userIds.length) {
securityClauses.push(`e.user_id IN (${userIds.map(() => "?").join(",")})`);
securityParams.push(...userIds);
}
for (const reference of references) {
securityClauses.push("e.metadata_json LIKE ?");
securityParams.push(`%${reference}%`);
}
relatedSecurityEvents = dbAll(
`SELECT e.*, u.display_name AS user_name
FROM auth_security_events e LEFT JOIN users u ON u.id = e.user_id
WHERE ${securityClauses.map((item) => `(${item})`).join(" OR ")}
ORDER BY e.created_at DESC LIMIT 20`,
securityParams
).map(auditSecurityPayload);
}
return { event, relatedAudit, relatedSecurityEvents, scope };
}
export function exportAuditEvents(context, options = {}) {
const result = listAuditEvents(context, { ...options, page: 1, forExport: true, pageSize: options.pageSize || options.limit || 500 });
return {
exportedAt: new Date().toISOString(),
scope: result.scope,
filters: result.filters,
total: result.pagination.total,
auditLog: result.auditLog
};
}
export function scopedAuditLog(context, limit = 40) {
return listAuditEvents(context, { page: 1, pageSize: limit }).auditLog;
}
export function scopedModels(context) {
return dbAll(
`SELECT * FROM model_connectors
WHERE organization_id = ? AND (workspace_id IS NULL OR workspace_id = ?)
ORDER BY created_at DESC`,
[context.organization.id, context.workspace.id]
).map((model) => ({
...model,
capability: parseJson(model.capabilities_json, []),
protocol: parseJson(model.protocol_json, {}),
approvalRequired: Boolean(model.approval_required),
costMode: model.cost_mode
}));
}
function rolePolicyRows(organizationId) {
const roles = dbAll("SELECT key, scope, name, description FROM roles ORDER BY scope, name");
const permissions = dbAll("SELECT key, description FROM permissions ORDER BY key").map((permission) => ({
...permission,
systemOnly: SYSTEM_ONLY_PERMISSIONS.has(permission.key)
}));
const overrides = dbAll(
`SELECT organization_id, role_key, permission_key, effect, updated_by, created_at, updated_at
FROM organization_role_permissions
WHERE organization_id = ?
ORDER BY role_key, permission_key`,
[organizationId]
);
return roles.map((role) => {
const basePermissions = dbAll("SELECT permission_key FROM role_permissions WHERE role_key = ? ORDER BY permission_key", [role.key]).map((row) => row.permission_key);
const roleOverrides = overrides.filter((override) => override.role_key === role.key);
const effective = new Set(basePermissions);
for (const override of roleOverrides) {
if (override.effect === "grant") effective.add(override.permission_key);
if (override.effect === "revoke") effective.delete(override.permission_key);
}
return {
...role,
basePermissions,
permissions: [...effective].sort(),
overrides: roleOverrides.map((override) => ({ permissionKey: override.permission_key, effect: override.effect, updatedAt: override.updated_at }))
};
});
}
export function organizationRolePolicies(organizationId) {
return {
roles: rolePolicyRows(organizationId),
permissions: dbAll("SELECT key, description FROM permissions ORDER BY key").map((permission) => ({
...permission,
systemOnly: SYSTEM_ONLY_PERMISSIONS.has(permission.key)
}))
};
}
export function updateOrganizationRolePolicy(context, organizationId, roleKey, body = {}) {
requirePermission(context, "organization:roles:manage");
if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能修改其他组织的角色策略", { organizationId });
const role = dbGet("SELECT key, scope, name FROM roles WHERE key = ?", [roleKey]);
if (!role) throw httpError(404, "role_not_found", "角色不存在", { roleKey });
if (role.key === "org_owner") throw httpError(400, "owner_policy_locked", "组织所有者策略不可被组织级覆盖");
const permissionKey = String(body.permissionKey || "").trim();
const permission = dbGet("SELECT key FROM permissions WHERE key = ?", [permissionKey]);
if (!permission) throw httpError(400, "permission_not_found", "权限不存在", { permissionKey });
if (SYSTEM_ONLY_PERMISSIONS.has(permissionKey)) throw httpError(400, "system_permission_locked", "系统级权限不能通过组织策略覆盖", { permissionKey });
if (typeof body.enabled !== "boolean") throw httpError(400, "policy_enabled_invalid", "enabled 必须是布尔值");
const baseline = Boolean(dbGet("SELECT 1 FROM role_permissions WHERE role_key = ? AND permission_key = ?", [roleKey, permissionKey]));
const timestamp = new Date().toISOString();
if (body.enabled === baseline) {
dbRun("DELETE FROM organization_role_permissions WHERE organization_id = ? AND role_key = ? AND permission_key = ?", [organizationId, roleKey, permissionKey]);
} else {
dbRun(
`INSERT INTO organization_role_permissions(organization_id, role_key, permission_key, effect, updated_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(organization_id, role_key, permission_key) DO UPDATE SET effect = excluded.effect, updated_by = excluded.updated_by, updated_at = excluded.updated_at`,
[organizationId, roleKey, permissionKey, body.enabled ? "grant" : "revoke", context.user.id, timestamp, timestamp]
);
}
addAudit({ context, action: "organization.role_policy.updated", targetType: "organization_role_permission", targetId: `${organizationId}:${roleKey}:${permissionKey}`, metadata: { roleKey, permissionKey, enabled: body.enabled, baseline } });
return organizationRolePolicies(organizationId);
}
function ensureAssetContext(context, assetId) {
if (!context.project) throw httpError(400, "project_required", "资产操作必须绑定项目");
const asset = dbGet("SELECT * FROM assets WHERE id = ? AND project_id = ?", [assetId, context.project.id]);
if (!asset) throw httpError(404, "asset_not_found", "资产不存在或不属于当前项目", { assetId });
return asset;
}
function assetDetail(assetId, projectId) {
const asset = dbGet("SELECT * FROM assets WHERE id = ? AND project_id = ?", [assetId, projectId]);
if (!asset) return null;
const versions = dbAll("SELECT id, version_number, storage_path, file_name, mime_type, file_size, content_sha256, rights_status, metadata_json, created_by, created_at FROM asset_versions WHERE asset_id = ? ORDER BY version_number DESC", [assetId]).map((version) => ({
...version,
metadata: parseJson(version.metadata_json, {}),
fileName: version.file_name || "",
mimeType: version.mime_type || "application/octet-stream",
fileSize: Number(version.file_size || 0),
contentSha256: version.content_sha256 || ""
}));
const bindings = dbAll(
`SELECT b.id, b.shot_id, b.usage_role, b.created_at, s.title AS shot_title
FROM asset_bindings b
JOIN shots s ON s.id = b.shot_id
WHERE b.asset_id = ?
ORDER BY b.created_at DESC`,
[assetId]
);
const currentVersion = versions.find((version) => version.id === asset.current_version_id) || versions[0] || null;
return {
...asset,
lockStatus: asset.lock_status,
currentVersionId: asset.current_version_id,
currentVersion,
versions,
bindings
};
}
export function scopedAssets(context) {
if (!context.project) return [];
return dbAll("SELECT id FROM assets WHERE project_id = ? ORDER BY updated_at DESC, created_at DESC", [context.project.id])
.map((row) => assetDetail(row.id, context.project.id))
.filter(Boolean);
}
export function getAsset(context, assetId) {
return assetDetail(assetId, context.project?.id);
}
export function createAsset(context, body) {
requirePermission(context, "asset:edit");
if (!context.project) throw httpError(400, "project_required", "创建资产必须绑定项目");
const name = String(body.name || "").trim();
if (!name) throw httpError(400, "name_required", "资产名称不能为空");
const id = String(body.id || `asset-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`);
const timestamp = new Date().toISOString();
const versionId = `${id}-v1`;
const kind = String(body.kind || "reference").trim();
const lockStatus = String(body.lockStatus || "draft").trim();
const storagePath = String(body.storagePath || `assets/${context.project.id}/${id}/v1/metadata.json`).trim();
const metadata = {
subtitle: String(body.subtitle || "本地项目资产"),
initial: String(body.initial || name.slice(0, 1)),
tags: Array.isArray(body.tags) ? body.tags : [],
usage: String(body.usage || "当前项目"),
detail: String(body.detail || "待补充资产描述"),
lock: String(body.lock || "待补充连续性备注"),
mimeType: String(body.mimeType || "application/octet-stream"),
size: Number(body.size || 0)
};
const fileName = String(body.fileName || storagePath.split("/").pop() || "").trim();
const mimeType = String(body.mimeType || "application/octet-stream");
const fileSize = Number(body.fileSize ?? body.size ?? 0);
const contentSha256 = String(body.contentSha256 || "").trim();
dbRun("INSERT INTO assets(id, project_id, kind, name, lock_status, current_version_id, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", [id, context.project.id, kind, name, lockStatus, versionId, context.user.id, timestamp, timestamp]);
dbRun("INSERT INTO asset_versions(id, asset_id, version_number, storage_path, file_name, mime_type, file_size, content_sha256, rights_status, metadata_json, created_by, created_at) VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [versionId, id, storagePath, fileName, mimeType, fileSize, contentSha256, String(body.rightsStatus || "needs-evidence"), JSON.stringify(metadata), context.user.id, timestamp]);
addAudit({ context, action: "asset.created", targetType: "asset", targetId: id, metadata: { kind, name, storagePath } });
return assetDetail(id, context.project.id);
}
export function createAssetVersion(context, assetId, body) {
requirePermission(context, "asset:edit");
const asset = ensureAssetContext(context, assetId);
const currentVersion = dbGet("SELECT * FROM asset_versions WHERE id = ? AND asset_id = ?", [asset.current_version_id, assetId]);
const currentMetadata = parseJson(currentVersion?.metadata_json, {});
const latest = dbGet("SELECT MAX(version_number) AS version_number FROM asset_versions WHERE asset_id = ?", [assetId]);
const versionNumber = Number(latest?.version_number || 0) + 1;
const versionId = `${assetId}-v${versionNumber}`;
const timestamp = new Date().toISOString();
const storagePath = String(body.storagePath || currentVersion?.storage_path || `assets/${context.project.id}/${assetId}/v${versionNumber}/metadata.json`).trim();
const metadata = {
...currentMetadata,
...(body.metadata && typeof body.metadata === "object" ? body.metadata : {}),
versionNote: String(body.versionNote || "版本更新"),
mimeType: String(body.mimeType || currentMetadata.mimeType || "application/octet-stream"),
size: Number(body.size ?? currentMetadata.size ?? 0)
};
const fileName = String(body.fileName || currentVersion?.file_name || storagePath.split("/").pop() || "").trim();
const mimeType = String(body.mimeType || currentVersion?.mime_type || currentMetadata.mimeType || "application/octet-stream");
const fileSize = Number(body.fileSize ?? body.size ?? currentVersion?.file_size ?? currentMetadata.size ?? 0);
const contentSha256 = String(body.contentSha256 || currentVersion?.content_sha256 || "").trim();
dbRun("INSERT INTO asset_versions(id, asset_id, version_number, storage_path, file_name, mime_type, file_size, content_sha256, rights_status, metadata_json, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [versionId, assetId, versionNumber, storagePath, fileName, mimeType, fileSize, contentSha256, String(body.rightsStatus || "needs-evidence"), JSON.stringify(metadata), context.user.id, timestamp]);
dbRun("UPDATE assets SET current_version_id = ?, updated_at = ? WHERE id = ?", [versionId, timestamp, assetId]);
addAudit({ context, action: "asset.version.created", targetType: "asset_version", targetId: versionId, metadata: { assetId, versionNumber, storagePath } });
return assetDetail(asset.id, context.project.id);
}
export function restoreAssetVersion(context, assetId, versionId) {
requirePermission(context, "asset:edit");
const asset = ensureAssetContext(context, assetId);
const version = dbGet("SELECT * FROM asset_versions WHERE id = ? AND asset_id = ?", [versionId, assetId]);
if (!version) throw httpError(404, "asset_version_not_found", "要恢复的资产版本不存在");
if (asset.current_version_id === version.id) return assetDetail(assetId, context.project.id);
const timestamp = new Date().toISOString();
dbRun("UPDATE assets SET current_version_id = ?, updated_at = ? WHERE id = ?", [version.id, timestamp, assetId]);
addAudit({ context, action: "asset.version.restored", targetType: "asset_version", targetId: version.id, metadata: { assetId, previousVersionId: asset.current_version_id, restoredVersionNumber: version.version_number } });
return assetDetail(assetId, context.project.id);
}
export function updateAssetLock(context, assetId, body) {
requirePermission(context, "asset:edit");
const asset = ensureAssetContext(context, assetId);
const lockStatus = String(body.lockStatus || "draft").trim();
if (!["draft", "review", "locked", "archived"].includes(lockStatus)) throw httpError(400, "lock_status_invalid", "资产锁定状态无效", { lockStatus });
const timestamp = new Date().toISOString();
dbRun("UPDATE assets SET lock_status = ?, updated_at = ? WHERE id = ?", [lockStatus, timestamp, assetId]);
addAudit({ context, action: "asset.lock.updated", targetType: "asset", targetId: assetId, metadata: { previous: asset.lock_status, lockStatus } });
return assetDetail(assetId, context.project.id);
}
export function updateAssetRights(context, assetId, body) {
requirePermission(context, "voice:approve");
const asset = ensureAssetContext(context, assetId);
if (asset.kind !== "voice") throw httpError(400, "voice_asset_required", "只有声音资产需要走声音授权审批");
const currentVersion = dbGet("SELECT * FROM asset_versions WHERE id = ? AND asset_id = ?", [asset.current_version_id, assetId]);
if (!currentVersion) throw httpError(404, "asset_version_not_found", "当前声音资产版本不存在");
const rightsStatus = String(body.rightsStatus || "needs-evidence").trim();
if (!["needs-evidence", "submitted", "approved", "rejected", "expired"].includes(rightsStatus)) {
throw httpError(400, "rights_status_invalid", "声音授权状态无效", { rightsStatus });
}
const evidence = body.evidence && typeof body.evidence === "object" ? body.evidence : {};
if (rightsStatus === "approved" && !String(evidence.reference || evidence.consentRef || "").trim()) {
throw httpError(400, "rights_evidence_required", "批准声音资产前必须填写授权证据引用");
}
const metadata = parseJson(currentVersion.metadata_json, {});
const timestamp = new Date().toISOString();
const nextMetadata = {
...metadata,
rightsEvidence: {
...((metadata && metadata.rightsEvidence) || {}),
...evidence,
reviewedBy: context.user.id,
reviewedAt: timestamp
}
};
dbRun("UPDATE asset_versions SET rights_status = ?, metadata_json = ? WHERE id = ? AND asset_id = ?", [rightsStatus, JSON.stringify(nextMetadata), currentVersion.id, assetId]);
dbRun("UPDATE assets SET lock_status = ?, updated_at = ? WHERE id = ?", [rightsStatus === "approved" ? "locked" : rightsStatus === "rejected" ? "review" : asset.lock_status, timestamp, assetId]);
addAudit({ context, action: "voice.rights.updated", targetType: "asset_version", targetId: currentVersion.id, result: rightsStatus === "approved" ? "pass" : rightsStatus, metadata: { assetId, rightsStatus, evidence: nextMetadata.rightsEvidence } });
return assetDetail(assetId, context.project.id);
}
export function bindAssetToShot(context, assetId, body) {
requirePermission(context, "asset:edit");
const asset = ensureAssetContext(context, assetId);
const shotId = String(body.shotId || "").trim();
const shot = dbGet(
`SELECT s.id, s.title FROM shots s
JOIN episodes e ON e.id = s.episode_id
JOIN seasons se ON se.id = e.season_id
JOIN series sr ON sr.id = se.series_id
WHERE s.id = ? AND sr.project_id = ?`,
[shotId, context.project.id]
);
if (!shot) throw httpError(400, "shot_invalid", "镜头不存在或不属于当前项目", { shotId });
const usageRole = String(body.usageRole || asset.kind || "continuity").trim();
const timestamp = new Date().toISOString();
dbRun("INSERT INTO asset_bindings(id, asset_id, shot_id, usage_role, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(asset_id, shot_id, usage_role) DO UPDATE SET created_by = excluded.created_by", [`binding-${assetId}-${shotId}-${usageRole}`, assetId, shotId, usageRole, context.user.id, timestamp]);
addAudit({ context, action: "asset.bound", targetType: "asset_binding", targetId: `${assetId}:${shotId}`, metadata: { assetId, shotId, usageRole } });
return assetDetail(asset.id, context.project.id);
}
export function buildContextPayload(context) {
const canManageOrganization = hasPermission(context, "organization:manage") || hasPermission(context, "organization:members:invite");
const canManageWorkspace = hasPermission(context, "workspace:manage") || hasPermission(context, "workspace:create") || hasPermission(context, "workspace:members:manage");
const canManageProjectMembers = hasPermission(context, "project:members:manage");
const canViewUsage = hasPermission(context, "usage:view") || hasPermission(context, "billing:manage");
const canViewAudit = hasPermission(context, "audit:view");
const canViewModels = hasPermission(context, "model:manage");
const canUseGenerationAdapters = hasPermission(context, "job:create") || canViewModels;
const modelRows = scopedModels(context);
const adapterCatalog = canViewModels ? modelRows : modelRows.map((model) => ({
id: model.id,
label: model.label,
kind: model.kind,
capability: model.capability,
status: model.status,
costMode: model.costMode,
approvalRequired: model.approvalRequired
}));
const members = canManageOrganization ? orgMembers(context.organization.id) : [];
const invitations = hasPermission(context, "organization:members:invite") ? pendingInvitations(context.organization.id) : [];
const billing = canViewUsage ? billingAccount(context.organization.id) : null;
const usage = canViewUsage ? usageSummary(context) : null;
const auditLog = canViewAudit ? scopedAuditLog(context) : [];
const workspaceMemberRows = canManageWorkspace ? workspaceMembers(context.workspace.id) : [];
const projectMemberRows = canManageProjectMembers && context.project ? projectMembers(context.project.id) : [];
const organizationRows = dbAll(
`SELECT o.id, o.name, o.slug, o.deployment_mode, o.status, om.role_key, r.name AS role_name,
(SELECT COUNT(*) FROM workspaces w WHERE w.organization_id = o.id) AS workspace_count
FROM organizations o
JOIN organization_members om ON om.organization_id = o.id AND om.user_id = ? AND om.status = 'active'
LEFT JOIN roles r ON r.key = om.role_key
WHERE o.status = 'active'
ORDER BY o.name`,
[context.user.id]
);
const workspaces = context.workspaces.map((workspace) => ({
...workspace,
memberCount: Number(dbGet("SELECT COUNT(*) AS count FROM workspace_members WHERE workspace_id = ? AND status = 'active'", [workspace.id])?.count || 0),
projectCount: Number(dbGet("SELECT COUNT(*) AS count FROM projects WHERE workspace_id = ?", [workspace.id])?.count || 0)
}));
const projects = context.projects.map((project) => ({
...project,
owner: dbGet("SELECT display_name FROM users WHERE id = ?", [project.owner_user_id])?.display_name || project.owner_user_id,
memberCount: Number(dbGet("SELECT COUNT(*) AS count FROM project_members WHERE project_id = ? AND status = 'active'", [project.id])?.count || 0)
}));
const rolePolicyCatalog = canManageOrganization || context.systemAdmin ? organizationRolePolicies(context.organization.id) : { roles: [], permissions: [] };
const roleRows = rolePolicyCatalog.roles;
const permissionRows = rolePolicyCatalog.permissions;
const rolePermissionRows = rolePolicyCatalog.roles;
return {
context: {
currentUser: context.user,
currentOrganization: context.organization,
currentWorkspace: context.workspace,
currentProject: context.project,
organizationRole: context.organizationMembership.role_key,
workspaceRole: context.workspaceMembership?.role_key || (context.orgElevated ? "org_admin" : null),
projectRole: context.projectMembership?.role_key || null,
roles: context.roles,
permissions: context.permissions,
systemAdmin: context.systemAdmin
},
platform: {
organizations: organizationRows,
workspaces,
projects,
members,
workspaceMembers: workspaceMemberRows,
projectMembers: projectMemberRows,
invitations,
billing,
usage,
auditLog,
modelRegistry: canViewModels ? modelRows : [],
adapterCatalog: canUseGenerationAdapters ? adapterCatalog : [],
rolePolicies: rolePolicyCatalog,
organization: context.organization,
workspace: context.workspace
},
roles: roleRows,
permissions: permissionRows,
rolePermissions: rolePermissionRows
};
}
export function addAudit({ context, action, targetType, targetId, result = "ok", metadata = {} }) {
dbRun(
"INSERT INTO audit_logs(id, organization_id, workspace_id, project_id, actor_user_id, action, target_type, target_id, result, metadata_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[`aud-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, context.organization.id, context.workspace?.id || null, context.project?.id || null, context.user.id, action, targetType, targetId, result, JSON.stringify(metadata), new Date().toISOString()]
);
}
export function addUsage({ context, kind, units = 1, unitName = "event", estimatedCost = 0, metadata = {} }) {
const timestamp = new Date().toISOString();
dbRun(
"INSERT INTO usage_events(id, organization_id, workspace_id, project_id, user_id, kind, units, unit_name, estimated_cost, metadata_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[`usage-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, context.organization.id, context.workspace?.id || null, context.project?.id || null, context.user.id, kind, units, unitName, estimatedCost, JSON.stringify(metadata), timestamp]
);
if (["job", "clip", "clips"].includes(String(unitName).toLowerCase())) {
const row = quotaRow(context, "clip");
if (row) dbRun("UPDATE quota_allocations SET used_value = used_value + ?, updated_at = ? WHERE id = ?", [Number(units || 0), timestamp, row.id]);
}
}
function requireSystemAdmin(context) {
if (!context?.systemAdmin) throw httpError(403, "system_admin_required", "只有系统管理员可以访问全局用户目录");
}
function systemUserOrganizations(userId) {
return dbAll(
`SELECT o.id, o.name, o.slug, om.role_key, r.name AS role_name, om.status, om.joined_at
FROM organization_members om
JOIN organizations o ON o.id = om.organization_id
LEFT JOIN roles r ON r.key = om.role_key
WHERE om.user_id = ?
ORDER BY CASE om.status WHEN 'active' THEN 0 ELSE 1 END, o.name`,
[userId]
);
}
function systemUserWorkspaces(userId) {
return dbAll(
`SELECT w.id, w.name, w.slug, w.organization_id, o.name AS organization_name, wm.role_key, r.name AS role_name, wm.status
FROM workspace_members wm
JOIN workspaces w ON w.id = wm.workspace_id
JOIN organizations o ON o.id = w.organization_id
LEFT JOIN roles r ON r.key = wm.role_key
WHERE wm.user_id = ?
ORDER BY CASE wm.status WHEN 'active' THEN 0 ELSE 1 END, o.name, w.name`,
[userId]
);
}
function systemUserProjects(userId) {
return dbAll(
`SELECT p.id, p.name, p.workspace_id, w.name AS workspace_name, o.name AS organization_name, pm.role_key, r.name AS role_name, pm.status
FROM project_members pm
JOIN projects p ON p.id = pm.project_id
JOIN workspaces w ON w.id = p.workspace_id
JOIN organizations o ON o.id = w.organization_id
LEFT JOIN roles r ON r.key = pm.role_key
WHERE pm.user_id = ?
ORDER BY CASE pm.status WHEN 'active' THEN 0 ELSE 1 END, o.name, w.name, p.name`,
[userId]
);
}
function systemUserRow(row) {
const organizations = systemUserOrganizations(row.id);
const workspaces = systemUserWorkspaces(row.id);
const projects = systemUserProjects(row.id);
return {
id: row.id,
displayName: row.display_name,
email: row.email,
avatarColor: row.avatar_color,
status: row.status,
createdAt: row.created_at,
updatedAt: row.updated_at,
lastLoginAt: row.last_login_at || null,
mfaEnabled: Boolean(row.mfa_enabled),
activeSessionCount: Number(row.active_session_count || 0),
lastSessionSeenAt: row.last_session_seen_at || null,
systemAdmin: row.system_admin_status === "active",
systemAdminStatus: row.system_admin_status || null,
systemRoleKey: row.system_role_key || null,
organizationCount: organizations.length,
workspaceCount: workspaces.length,
projectCount: projects.length,
organizations,
workspaces,
projects
};
}
function systemUserSelect() {
return `SELECT u.*, sa.role_key AS system_role_key, sa.status AS system_admin_status,
c.last_login_at,
COALESCE(m.enabled, 0) AS mfa_enabled,
(SELECT COUNT(*) FROM auth_sessions s WHERE s.user_id = u.id AND s.revoked_at IS NULL AND s.expires_at > datetime('now')) AS active_session_count,
(SELECT MAX(s.last_seen_at) FROM auth_sessions s WHERE s.user_id = u.id AND s.revoked_at IS NULL) AS last_session_seen_at
FROM users u
LEFT JOIN system_admins sa ON sa.user_id = u.id
LEFT JOIN user_credentials c ON c.user_id = u.id
LEFT JOIN user_mfa_methods m ON m.user_id = u.id`;
}
function roleForScope(roleKey, scope) {
const role = dbGet("SELECT key, scope, name FROM roles WHERE key = ? AND scope = ?", [roleKey, scope]);
if (!role) throw httpError(400, "role_invalid", `无效的${scope}角色`, { roleKey, scope });
return role;
}
function applySystemMemberships(userId, body = {}, actorContext) {
const timestamp = new Date().toISOString();
const organizations = Array.isArray(body.organizationMemberships)
? body.organizationMemberships
: body.organizationId
? [{ organizationId: body.organizationId, roleKey: body.organizationRoleKey || "org_member", status: body.organizationStatus || "active" }]
: [];
const workspaces = Array.isArray(body.workspaceMemberships)
? body.workspaceMemberships
: body.workspaceId
? [{ workspaceId: body.workspaceId, roleKey: body.workspaceRoleKey || "writer", status: body.workspaceStatus || "active" }]
: [];
const projects = Array.isArray(body.projectMemberships)
? body.projectMemberships
: body.projectId
? [{ projectId: body.projectId, roleKey: body.projectRoleKey || "project_editor", status: body.projectStatus || "active" }]
: [];
for (const assignment of organizations) {
const organizationId = String(assignment.organizationId || "").trim();
const organization = dbGet("SELECT id FROM organizations WHERE id = ? AND status = 'active'", [organizationId]);
if (!organization) throw httpError(404, "organization_not_found", "目标组织不存在或已停用", { organizationId });
roleForScope(String(assignment.roleKey || "org_member"), "organization");
const existing = dbGet("SELECT status FROM organization_members WHERE organization_id = ? AND user_id = ?", [organizationId, userId]);
if (!existing || existing.status !== "active") assertOrganizationSeatAvailable(organizationId, { userId });
const status = ["active", "suspended"].includes(assignment.status) ? assignment.status : "active";
dbRun("INSERT INTO organization_members(id, organization_id, user_id, role_key, status, joined_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(organization_id, user_id) DO UPDATE SET role_key = excluded.role_key, status = excluded.status, joined_at = CASE WHEN excluded.status = 'active' THEN excluded.joined_at ELSE organization_members.joined_at END, updated_at = excluded.updated_at", [`om-${organizationId}-${userId}`, organizationId, userId, assignment.roleKey || "org_member", status, status === "active" ? timestamp : null, timestamp, timestamp]);
}
for (const assignment of workspaces) {
const workspaceId = String(assignment.workspaceId || "").trim();
const workspace = dbGet("SELECT id, organization_id FROM workspaces WHERE id = ? AND status = 'active'", [workspaceId]);
if (!workspace) throw httpError(404, "workspace_not_found", "目标工作区不存在或已停用", { workspaceId });
roleForScope(String(assignment.roleKey || "writer"), "workspace");
if (!dbGet("SELECT 1 FROM organization_members WHERE organization_id = ? AND user_id = ? AND status = 'active'", [workspace.organization_id, userId])) {
throw httpError(409, "organization_membership_required", "加入工作区前必须先加入所属组织", { workspaceId, organizationId: workspace.organization_id });
}
dbRun("INSERT INTO workspace_members(id, workspace_id, user_id, role_key, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(workspace_id, user_id) DO UPDATE SET role_key = excluded.role_key, status = excluded.status, updated_at = excluded.updated_at", [`wm-${workspaceId}-${userId}`, workspaceId, userId, assignment.roleKey || "writer", ["active", "suspended"].includes(assignment.status) ? assignment.status : "active", timestamp, timestamp]);
}
for (const assignment of projects) {
const projectId = String(assignment.projectId || "").trim();
const project = dbGet("SELECT id, workspace_id FROM projects WHERE id = ?", [projectId]);
if (!project) throw httpError(404, "project_not_found", "目标项目不存在", { projectId });
roleForScope(String(assignment.roleKey || "project_editor"), "project");
const workspace = dbGet("SELECT organization_id FROM workspaces WHERE id = ?", [project.workspace_id]);
if (!dbGet("SELECT 1 FROM organization_members WHERE organization_id = ? AND user_id = ? AND status = 'active'", [workspace.organization_id, userId])) throw httpError(409, "organization_membership_required", "加入项目之前必须先加入所属组织", { projectId });
if (!dbGet("SELECT 1 FROM workspace_members WHERE workspace_id = ? AND user_id = ? AND status = 'active'", [project.workspace_id, userId])) throw httpError(409, "workspace_membership_required", "加入项目之前必须先加入所属工作区", { projectId, workspaceId: project.workspace_id });
dbRun("INSERT INTO project_members(id, project_id, user_id, role_key, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(project_id, user_id) DO UPDATE SET role_key = excluded.role_key, status = excluded.status, updated_at = excluded.updated_at", [`pm-${projectId}-${userId}`, projectId, userId, assignment.roleKey || "project_editor", ["active", "suspended"].includes(assignment.status) ? assignment.status : "active", timestamp, timestamp]);
}
if (organizations.length || workspaces.length || projects.length) addAudit({ context: actorContext, action: "system.user.memberships.updated", targetType: "user", targetId: userId, metadata: { organizations, workspaces, projects } });
return { organizations, workspaces, projects };
}
function setSystemAdminStatus(context, userId, enabled) {
const current = dbGet("SELECT status FROM system_admins WHERE user_id = ?", [userId]);
if (!enabled) {
const activeCount = Number(dbGet("SELECT COUNT(*) AS count FROM system_admins WHERE status = 'active'")?.count || 0);
if (current?.status === "active" && activeCount <= 1) throw httpError(409, "last_system_admin", "不能移除最后一个系统管理员");
if (current) dbRun("UPDATE system_admins SET status = 'suspended', updated_at = ? WHERE user_id = ?", [new Date().toISOString(), userId]);
} else {
const timestamp = new Date().toISOString();
dbRun("INSERT INTO system_admins(user_id, role_key, status, created_at, updated_at) VALUES (?, 'system_admin', 'active', ?, ?) ON CONFLICT(user_id) DO UPDATE SET status = 'active', updated_at = excluded.updated_at", [userId, timestamp, timestamp]);
}
}
export function systemUsers(context, options = {}) {
requireSystemAdmin(context);
const query = String(options.query || "").trim().toLowerCase();
const status = String(options.status || "").trim();
const limit = Math.min(250, Math.max(1, Number(options.limit || 100)));
const clauses = [];
const params = [];
if (query) {
clauses.push("(lower(u.display_name) LIKE ? OR lower(u.email) LIKE ? OR lower(u.id) LIKE ?)");
params.push(`%${query}%`, `%${query}%`, `%${query}%`);
}
if (["active", "suspended", "invited"].includes(status)) {
clauses.push("u.status = ?");
params.push(status);
}
const rows = dbAll(`${systemUserSelect()} ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} ORDER BY CASE u.status WHEN 'active' THEN 0 WHEN 'invited' THEN 1 ELSE 2 END, u.display_name LIMIT ?`, [...params, limit]).map(systemUserRow);
const allUsers = dbAll("SELECT status FROM users");
const allAdmins = dbAll("SELECT status FROM system_admins");
return {
users: rows,
filters: { query, status, limit },
summary: {
total: allUsers.length,
active: allUsers.filter((user) => user.status === "active").length,
suspended: allUsers.filter((user) => user.status === "suspended").length,
invited: allUsers.filter((user) => user.status === "invited").length,
systemAdmins: allAdmins.filter((admin) => admin.status === "active").length,
activeSessions: Number(dbGet("SELECT COUNT(*) AS count FROM auth_sessions WHERE revoked_at IS NULL AND expires_at > datetime('now')")?.count || 0)
}
};
}
export function systemUserDetail(context, userId) {
requireSystemAdmin(context);
const row = dbGet(`${systemUserSelect()} WHERE u.id = ?`, [userId]);
if (!row) throw httpError(404, "system_user_not_found", "全局用户不存在", { userId });
const user = systemUserRow(row);
return {
user,
devices: listUserDevices(userId),
sessions: listUserSessions(userId, null),
securityEvents: listSecurityEvents(userId, { limit: 40 }),
recentAudit: dbAll(
`SELECT a.id, a.action, a.target_type, a.target_id, a.result, a.metadata_json, a.created_at, u.display_name AS actor_name
FROM audit_logs a
LEFT JOIN users u ON u.id = a.actor_user_id
WHERE a.target_type = 'user' AND a.target_id = ?
ORDER BY a.created_at DESC LIMIT 30`,
[userId]
).map((audit) => ({ ...audit, metadata: parseJson(audit.metadata_json, {}) }))
};
}
export function updateSystemUser(context, userId, body = {}) {
requireSystemAdmin(context);
const current = dbGet(`${systemUserSelect()} WHERE u.id = ?`, [userId]);
if (!current) throw httpError(404, "system_user_not_found", "全局用户不存在", { userId });
const nextStatus = body.status === undefined ? current.status : String(body.status || "").trim();
if (!['active', 'suspended'].includes(nextStatus)) throw httpError(400, "system_user_status_invalid", "全局用户状态只能是 active 或 suspended");
const nextDisplayName = body.displayName === undefined ? current.display_name : String(body.displayName || "").trim();
if (!nextDisplayName) throw httpError(400, "display_name_required", "显示名称不能为空");
if (userId === context.user.id && nextStatus === "suspended") throw httpError(400, "cannot_suspend_self", "不能停用当前登录的系统管理员账号");
const targetIsSystemAdmin = current.system_admin_status === "active";
if (targetIsSystemAdmin && nextStatus === "suspended") {
const activeAdminCount = Number(dbGet("SELECT COUNT(*) AS count FROM system_admins WHERE status = 'active'")?.count || 0);
if (activeAdminCount <= 1) throw httpError(409, "last_system_admin", "不能停用最后一个系统管理员,请先指定其他系统管理员");
}
if (nextStatus === "suspended") {
const ownerOrganizations = dbAll(
`SELECT o.id, o.name
FROM organizations o
JOIN organization_members om ON om.organization_id = o.id AND om.user_id = ? AND om.role_key = 'org_owner' AND om.status = 'active'
WHERE o.status = 'active' AND (SELECT COUNT(*) FROM organization_members other WHERE other.organization_id = o.id AND other.role_key = 'org_owner' AND other.status = 'active') <= 1`,
[userId]
);
if (ownerOrganizations.length) throw httpError(409, "sole_organization_owner", "该用户是组织唯一所有者,请先转移组织所有权", { organizations: ownerOrganizations });
}
const timestamp = new Date().toISOString();
let revokedCount = 0;
withTransaction(() => {
dbRun("UPDATE users SET display_name = ?, status = ?, updated_at = ? WHERE id = ?", [nextDisplayName, nextStatus, timestamp, userId]);
if (nextStatus === "suspended" && current.status !== "suspended") revokedCount = revokeAllUserSessions(userId, { reason: "system_user_suspended", actorUserId: context.user.id });
if (typeof body.systemAdmin === "boolean") setSystemAdminStatus(context, userId, body.systemAdmin);
});
addAudit({
context,
action: nextStatus === "suspended" ? "system.user.suspended" : current.status === "suspended" ? "system.user.reactivated" : "system.user.updated",
targetType: "user",
targetId: userId,
result: "ok",
metadata: {
previous: { displayName: current.display_name, status: current.status },
next: { displayName: nextDisplayName, status: nextStatus, systemAdmin: typeof body.systemAdmin === "boolean" ? body.systemAdmin : current.system_admin_status === "active" },
revokedSessionCount: revokedCount
}
});
return { ...systemUserDetail(context, userId), revokedSessionCount: revokedCount };
}
export function createSystemUser(context, body = {}) {
requireSystemAdmin(context);
const email = String(body.email || "").trim().toLowerCase();
const displayName = String(body.displayName || "").trim();
if (!/^\S+@\S+\.\S+$/.test(email)) throw httpError(400, "email_invalid", "请输入有效邮箱");
if (!displayName) throw httpError(400, "display_name_required", "显示名称不能为空");
if (dbGet("SELECT id FROM users WHERE lower(email) = ?", [email])) throw httpError(409, "email_exists", "该邮箱已经存在");
const password = String(body.password || `Temp-${randomBytes(12).toString("base64url")}`);
if (password.length < 12) throw httpError(400, "password_too_short", "初始密码至少需要 12 个字符");
const userId = String(body.id || `u-${Date.now()}-${randomBytes(4).toString("hex")}`).replace(/[^a-zA-Z0-9_-]/g, "-").slice(0, 80);
const timestamp = new Date().toISOString();
const record = createPasswordRecord(password);
const status = body.status === "suspended" ? "suspended" : "active";
withTransaction(() => {
dbRun("INSERT INTO users(id, display_name, email, avatar_color, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)", [userId, displayName, email, body.avatarColor || "#477d69", status, timestamp, timestamp]);
dbRun("INSERT INTO user_credentials(user_id, password_salt, password_hash, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", [userId, record.salt, record.hash, timestamp, timestamp]);
if (body.systemAdmin) setSystemAdminStatus(context, userId, true);
applySystemMemberships(userId, body, context);
});
addAudit({ context, action: "system.user.created", targetType: "user", targetId: userId, metadata: { email, displayName, status, systemAdmin: Boolean(body.systemAdmin), temporaryPasswordIssued: !body.password } });
return { ...(systemUserDetail(context, userId)), temporaryPassword: body.password ? null : password };
}
export function resetSystemUserPassword(context, userId, body = {}, metadata = {}) {
requireSystemAdmin(context);
const user = dbGet("SELECT id, email FROM users WHERE id = ?", [userId]);
if (!user) throw httpError(404, "system_user_not_found", "全局用户不存在", { userId });
const temporaryPassword = String(body.password || `Temp-${randomBytes(12).toString("base64url")}`);
const result = resetPassword(userId, temporaryPassword, { ...metadata, actorUserId: context.user.id, reason: "system_admin_reset" });
addAudit({ context, action: "system.user.password_reset", targetType: "user", targetId: userId, metadata: { email: user.email, revokedSessionCount: result.revokedSessionCount, temporaryPasswordIssued: !body.password } });
return { ...systemUserDetail(context, userId), temporaryPassword: body.password ? null : temporaryPassword, revokedSessionCount: result.revokedSessionCount };
}
export function resetSystemUserMfa(context, userId, metadata = {}) {
requireSystemAdmin(context);
const user = dbGet("SELECT id, email FROM users WHERE id = ?", [userId]);
if (!user) throw httpError(404, "system_user_not_found", "全局用户不存在", { userId });
const result = resetMfa(userId, { ...metadata, actorUserId: context.user.id, reason: "system_admin_reset" });
addAudit({ context, action: "system.user.mfa_reset", targetType: "user", targetId: userId, metadata: { email: user.email, revokedSessionCount: result.revokedSessionCount } });
return { ...systemUserDetail(context, userId), revokedSessionCount: result.revokedSessionCount };
}
export function updateSystemUserMemberships(context, userId, body = {}) {
requireSystemAdmin(context);
const user = dbGet("SELECT id FROM users WHERE id = ?", [userId]);
if (!user) throw httpError(404, "system_user_not_found", "全局用户不存在", { userId });
withTransaction(() => applySystemMemberships(userId, body, context));
addAudit({ context, action: "system.user.memberships.updated", targetType: "user", targetId: userId, metadata: { requested: body } });
return systemUserDetail(context, userId);
}
export function parseModelRow(model) {
return {
...model,
capability: parseJson(model.capabilities_json, []),
protocol: parseJson(model.protocol_json, {}),
approvalRequired: Boolean(model.approval_required),
costMode: model.cost_mode
};
}
export function systemSettings() {
return dbAll("SELECT key, category, value_json, value_type, description, is_sensitive, updated_by, created_at, updated_at FROM system_settings ORDER BY category, key").map((row) => ({
...row,
value: parseJson(row.value_json, null),
is_sensitive: Boolean(row.is_sensitive)
}));
}
export function featureFlags() {
return dbAll("SELECT key, label, description, enabled, scope, updated_by, created_at, updated_at FROM feature_flags ORDER BY key").map((row) => ({
...row,
enabled: Boolean(row.enabled)
}));
}
export function notificationChannels() {
return dbAll("SELECT id, name, kind, endpoint, enabled, events_json, secret_ref, created_by, created_at, updated_at FROM notification_channels ORDER BY created_at ASC").map((row) => ({
...row,
enabled: Boolean(row.enabled),
events: parseJson(row.events_json, [])
}));
}
export function apiClients() {
return dbAll("SELECT id, name, client_key_prefix, key_version, organization_id, workspace_id, status, scopes_json, last_used_at, created_by, created_at, updated_at FROM api_clients ORDER BY created_at DESC").map((row) => ({
...row,
client_key: row.client_key_prefix ? `${row.client_key_prefix}****` : "仅创建或轮换时显示一次",
client_key_preview: row.client_key_prefix ? `${row.client_key_prefix}****` : "仅创建或轮换时显示一次",
scopes: parseJson(row.scopes_json, [])
}));
}
function identityTokenHash(token) {
return createHash("sha256").update(String(token || "")).digest("hex");
}
function safeUrl(value, label, { allowEmpty = true } = {}) {
const input = String(value || "").trim();
if (!input && allowEmpty) return "";
try {
const url = new URL(input);
if (!['http:', 'https:'].includes(url.protocol)) throw new Error("protocol");
return url.toString().replace(/\/$/, "");
} catch {
throw httpError(400, "identity_url_invalid", `${label}必须是 HTTP(S) 地址`);
}
}
function safeEntityId(value, label, { allowEmpty = true } = {}) {
const input = String(value || "").trim();
if (!input && allowEmpty) return "";
if (!input || /[\r\n\s]/.test(input) || !/^(https?:\/\/|urn:)/i.test(input)) {
throw httpError(400, "identity_entity_id_invalid", `${label}必须是 HTTP(S) 或 URN 标识`);
}
return input;
}
function identityPolicyPayload(row) {
const current = row || dbGet("SELECT * FROM identity_policies WHERE id = 'default'");
return {
id: current?.id || "default",
passwordLoginEnabled: Boolean(current?.password_login_enabled),
mfaRequiredForAdmins: Boolean(current?.mfa_required_for_admins),
mfaRequiredForAll: Boolean(current?.mfa_required_for_all),
ssoEnabled: Boolean(current?.sso_enabled),
localLoginFallback: Boolean(current?.local_login_fallback),
sessionTtlHours: Number(current?.session_ttl_hours || 12),
maxSessionsPerUser: Number(current?.max_sessions_per_user || 10),
updatedBy: current?.updated_by || null,
updatedAt: current?.updated_at || null
};
}
function identityProviderPayload(row) {
return {
id: row.id,
name: row.name,
kind: row.kind,
organizationId: row.organization_id || null,
workspaceId: row.workspace_id || null,
issuerUrl: row.issuer_url,
authorizationUrl: row.authorization_url,
tokenUrl: row.token_url,
userinfoUrl: row.userinfo_url,
jwksUrl: row.jwks_url || "",
entryPoint: row.entry_point || "",
idpCertRef: row.idp_cert_ref || "",
spIssuer: row.sp_issuer || "",
audience: row.audience || "",
samlNameIdFormat: row.saml_name_id_format || "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
wantAssertionsSigned: row.want_assertions_signed !== 0,
wantAuthnResponseSigned: row.want_authn_response_signed !== 0,
validateInResponseTo: row.validate_in_response_to || "ifPresent",
clientId: row.client_id,
clientSecretRef: row.client_secret_ref,
scopes: parseJson(row.scopes_json, ["openid", "profile", "email"]),
claimMapping: parseJson(row.claim_mapping_json, { email: "email", displayName: "name", externalId: "sub" }),
autoProvision: row.auto_provision !== 0,
defaultRoleKey: row.default_role_key || "org_member",
defaultWorkspaceRoleKey: row.default_workspace_role_key || "writer",
enabled: Boolean(row.enabled),
status: row.status,
lastProbeAt: row.last_probe_at,
errorMessage: row.error_message || "",
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
function directorySyncPayload(row) {
return {
id: row.id,
name: row.name,
kind: row.kind,
organizationId: row.organization_id,
organizationName: row.organization_name || null,
providerId: row.provider_id,
providerName: row.provider_name || null,
endpoint: row.endpoint,
endpointPath: `/scim/v2.0/${encodeURIComponent(row.id)}`,
tokenHint: row.token_hint,
enabled: Boolean(row.enabled),
syncMode: row.sync_mode,
schedule: row.schedule,
lastSyncAt: row.last_sync_at,
lastStatus: row.last_status,
lastSyncedCount: Number(row.last_synced_count || 0),
errorMessage: row.error_message || "",
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
export function identityCenter() {
const providers = dbAll("SELECT * FROM identity_providers ORDER BY created_at ASC").map(identityProviderPayload);
const directorySyncs = dbAll(
`SELECT d.*, o.name AS organization_name, p.name AS provider_name
FROM directory_syncs d
LEFT JOIN organizations o ON o.id = d.organization_id
LEFT JOIN identity_providers p ON p.id = d.provider_id
ORDER BY d.created_at ASC`
).map(directorySyncPayload);
return {
policy: identityPolicyPayload(),
providers,
directorySyncs,
organizationOptions: dbAll("SELECT id, name FROM organizations WHERE status = 'active' ORDER BY name"),
workspaceOptions: dbAll("SELECT id, organization_id AS organizationId, name FROM workspaces WHERE status = 'active' ORDER BY name"),
summary: {
enabledProviders: providers.filter((item) => item.enabled).length,
readyProviders: providers.filter((item) => item.status === "ready").length,
enabledDirectorySyncs: directorySyncs.filter((item) => item.enabled).length,
managedOrganizations: new Set(directorySyncs.map((item) => item.organizationId).filter(Boolean)).size
}
};
}
export function publicIdentityProviders() {
return dbAll("SELECT id, name, kind, organization_id, enabled, status FROM identity_providers WHERE enabled = 1 AND status IN ('configured', 'ready') ORDER BY name").map((row) => ({
id: row.id,
name: row.name,
kind: row.kind,
organizationId: row.organization_id || null,
enabled: Boolean(row.enabled),
status: row.status
}));
}
export function updateIdentityPolicy(context, body = {}) {
requirePermission(context, "system:settings:edit");
const current = dbGet("SELECT * FROM identity_policies WHERE id = 'default'");
if (!current) throw httpError(500, "identity_policy_missing", "身份策略尚未初始化");
const next = {
passwordLoginEnabled: body.passwordLoginEnabled === undefined ? Boolean(current.password_login_enabled) : Boolean(body.passwordLoginEnabled),
mfaRequiredForAdmins: body.mfaRequiredForAdmins === undefined ? Boolean(current.mfa_required_for_admins) : Boolean(body.mfaRequiredForAdmins),
mfaRequiredForAll: body.mfaRequiredForAll === undefined ? Boolean(current.mfa_required_for_all) : Boolean(body.mfaRequiredForAll),
ssoEnabled: body.ssoEnabled === undefined ? Boolean(current.sso_enabled) : Boolean(body.ssoEnabled),
localLoginFallback: body.localLoginFallback === undefined ? Boolean(current.local_login_fallback) : Boolean(body.localLoginFallback),
sessionTtlHours: body.sessionTtlHours === undefined ? Number(current.session_ttl_hours) : Number(body.sessionTtlHours),
maxSessionsPerUser: body.maxSessionsPerUser === undefined ? Number(current.max_sessions_per_user) : Number(body.maxSessionsPerUser)
};
if (!next.passwordLoginEnabled && !next.ssoEnabled) throw httpError(400, "identity_login_method_required", "至少保留一种登录方式");
if (next.ssoEnabled && !dbGet("SELECT id FROM identity_providers WHERE enabled = 1 AND status IN ('configured', 'ready')")) throw httpError(400, "identity_sso_provider_required", "启用 SSO 前请先配置一个可用的身份提供商");
if (!Number.isInteger(next.sessionTtlHours) || next.sessionTtlHours < 1 || next.sessionTtlHours > 168) throw httpError(400, "identity_session_ttl_invalid", "会话时长必须是 1 到 168 小时");
if (!Number.isInteger(next.maxSessionsPerUser) || next.maxSessionsPerUser < 1 || next.maxSessionsPerUser > 50) throw httpError(400, "identity_session_limit_invalid", "单用户会话数必须是 1 到 50");
const timestamp = new Date().toISOString();
dbRun("UPDATE identity_policies SET password_login_enabled = ?, mfa_required_for_admins = ?, mfa_required_for_all = ?, sso_enabled = ?, local_login_fallback = ?, session_ttl_hours = ?, max_sessions_per_user = ?, updated_by = ?, updated_at = ? WHERE id = 'default'", [next.passwordLoginEnabled ? 1 : 0, next.mfaRequiredForAdmins ? 1 : 0, next.mfaRequiredForAll ? 1 : 0, next.ssoEnabled ? 1 : 0, next.localLoginFallback ? 1 : 0, next.sessionTtlHours, next.maxSessionsPerUser, context.user.id, timestamp]);
addAudit({ context, action: "system.identity.policy.updated", targetType: "identity_policy", targetId: "default", metadata: { previous: identityPolicyPayload(current), value: next } });
return identityCenter();
}
export function saveIdentityProvider(context, body = {}, providerId = null) {
requirePermission(context, "system:settings:edit");
const existing = providerId ? dbGet("SELECT * FROM identity_providers WHERE id = ?", [providerId]) : null;
if (providerId && !existing) throw httpError(404, "identity_provider_not_found", "身份提供商不存在");
const name = String(body.name ?? existing?.name ?? "").trim();
const kind = String(body.kind ?? existing?.kind ?? "oidc").trim().toLowerCase();
if (!name) throw httpError(400, "identity_provider_name_required", "身份提供商名称不能为空");
if (!['oidc', 'saml'].includes(kind)) throw httpError(400, "identity_provider_kind_invalid", "只支持 OIDC 或 SAML 配置");
const issuerUrl = safeUrl(body.issuerUrl ?? existing?.issuer_url, "Issuer URL");
const authorizationUrl = safeUrl(body.authorizationUrl ?? existing?.authorization_url, "Authorization URL");
const tokenUrl = safeUrl(body.tokenUrl ?? existing?.token_url, "Token URL");
const userinfoUrl = safeUrl(body.userinfoUrl ?? existing?.userinfo_url, "UserInfo URL");
const jwksUrl = safeUrl(body.jwksUrl ?? existing?.jwks_url, "JWKS URL");
const entryPoint = safeUrl(body.entryPoint ?? existing?.entry_point, "SAML Entry Point");
const idpCertRef = String(body.idpCertRef ?? existing?.idp_cert_ref ?? "").trim();
const spIssuer = safeEntityId(body.spIssuer ?? existing?.sp_issuer, "SAML SP Issuer");
const audience = safeEntityId(body.audience ?? existing?.audience, "SAML Audience");
const samlNameIdFormat = safeEntityId(body.samlNameIdFormat ?? existing?.saml_name_id_format ?? "", "SAML NameID Format");
const wantAssertionsSigned = body.wantAssertionsSigned === undefined ? existing?.want_assertions_signed !== 0 : Boolean(body.wantAssertionsSigned);
const wantAuthnResponseSigned = body.wantAuthnResponseSigned === undefined ? existing?.want_authn_response_signed !== 0 : Boolean(body.wantAuthnResponseSigned);
const validateInResponseTo = String(body.validateInResponseTo ?? existing?.validate_in_response_to ?? "ifPresent").trim();
if (!["never", "ifPresent", "always"].includes(validateInResponseTo)) throw httpError(400, "identity_provider_validate_in_response_to_invalid", "SAML InResponseTo 校验策略必须是 never、ifPresent 或 always");
const clientId = String(body.clientId ?? existing?.client_id ?? "").trim();
const clientSecretRef = String(body.clientSecretRef ?? existing?.client_secret_ref ?? "").trim();
const organizationId = body.organizationId === undefined ? (existing?.organization_id || null) : (body.organizationId ? String(body.organizationId).trim() : null);
if (organizationId && !dbGet("SELECT id FROM organizations WHERE id = ? AND status = 'active'", [organizationId])) throw httpError(404, "identity_provider_organization_not_found", "身份提供商归属组织不存在或已停用");
const workspaceId = body.workspaceId === undefined ? (existing?.workspace_id || null) : (body.workspaceId ? String(body.workspaceId).trim() : null);
if (workspaceId && !dbGet("SELECT id FROM workspaces WHERE id = ? AND status = 'active' AND (? IS NULL OR organization_id = ?)", [workspaceId, organizationId, organizationId])) throw httpError(404, "identity_provider_workspace_not_found", "身份提供商默认工作区不存在、已停用或不属于绑定组织");
const scopes = Array.isArray(body.scopes) ? body.scopes.map((item) => String(item).trim()).filter(Boolean) : parseJson(existing?.scopes_json || "[]", ["openid", "profile", "email"]);
const claimMapping = body.claimMapping && typeof body.claimMapping === "object" ? body.claimMapping : parseJson(existing?.claim_mapping_json || "{}", { email: "email", displayName: "name", externalId: "sub" });
const autoProvision = body.autoProvision === undefined ? existing?.auto_provision !== 0 : Boolean(body.autoProvision);
const defaultRoleKey = String(body.defaultRoleKey ?? existing?.default_role_key ?? "org_member").trim() || "org_member";
if (!dbGet("SELECT key FROM roles WHERE key = ? AND scope = 'organization'", [defaultRoleKey])) throw httpError(400, "identity_provider_role_invalid", "SSO 默认组织角色无效");
const defaultWorkspaceRoleKey = String(body.defaultWorkspaceRoleKey ?? existing?.default_workspace_role_key ?? "writer").trim() || "writer";
if (!dbGet("SELECT key FROM roles WHERE key = ? AND scope = 'workspace'", [defaultWorkspaceRoleKey])) throw httpError(400, "identity_provider_workspace_role_invalid", "SSO 默认工作区角色无效");
const enabled = body.enabled === undefined ? Boolean(existing?.enabled) : Boolean(body.enabled);
if (enabled && kind === "oidc" && (!issuerUrl || !clientId || !clientSecretRef)) throw httpError(400, "identity_provider_incomplete", "启用 OIDC 提供商前需要 Issuer、Client ID 和密钥环境变量名");
if (enabled && kind === "saml" && (!entryPoint || !idpCertRef || !spIssuer)) throw httpError(400, "identity_provider_incomplete", "启用 SAML 提供商前需要 IdP Entry Point、证书环境变量名和 SP Issuer");
const id = existing?.id || providerId || `idp-${Date.now()}-${randomBytes(4).toString("hex")}`;
const timestamp = new Date().toISOString();
const status = enabled ? (existing?.status === "ready" && existing?.kind === kind ? existing.status : "configured") : "disabled";
if (existing) {
dbRun("UPDATE identity_providers SET name = ?, kind = ?, organization_id = ?, workspace_id = ?, issuer_url = ?, authorization_url = ?, token_url = ?, userinfo_url = ?, jwks_url = ?, entry_point = ?, idp_cert_ref = ?, sp_issuer = ?, audience = ?, saml_name_id_format = ?, want_assertions_signed = ?, want_authn_response_signed = ?, validate_in_response_to = ?, client_id = ?, client_secret_ref = ?, scopes_json = ?, claim_mapping_json = ?, auto_provision = ?, default_role_key = ?, default_workspace_role_key = ?, enabled = ?, status = ?, error_message = '', updated_at = ? WHERE id = ?", [name, kind, organizationId, workspaceId, issuerUrl, authorizationUrl, tokenUrl, userinfoUrl, jwksUrl, entryPoint, idpCertRef, spIssuer, audience, samlNameIdFormat, wantAssertionsSigned ? 1 : 0, wantAuthnResponseSigned ? 1 : 0, validateInResponseTo, clientId, clientSecretRef, JSON.stringify(scopes), JSON.stringify(claimMapping), autoProvision ? 1 : 0, defaultRoleKey, defaultWorkspaceRoleKey, enabled ? 1 : 0, status, timestamp, id]);
} else {
const identityProviderPlaceholders = Array(30).fill("?").join(", ");
dbRun(`INSERT INTO identity_providers(id, name, kind, organization_id, workspace_id, issuer_url, authorization_url, token_url, userinfo_url, jwks_url, entry_point, idp_cert_ref, sp_issuer, audience, saml_name_id_format, want_assertions_signed, want_authn_response_signed, validate_in_response_to, client_id, client_secret_ref, scopes_json, claim_mapping_json, auto_provision, default_role_key, default_workspace_role_key, enabled, status, created_by, created_at, updated_at) VALUES (${identityProviderPlaceholders})`, [id, name, kind, organizationId, workspaceId, issuerUrl, authorizationUrl, tokenUrl, userinfoUrl, jwksUrl, entryPoint, idpCertRef, spIssuer, audience, samlNameIdFormat, wantAssertionsSigned ? 1 : 0, wantAuthnResponseSigned ? 1 : 0, validateInResponseTo, clientId, clientSecretRef, JSON.stringify(scopes), JSON.stringify(claimMapping), autoProvision ? 1 : 0, defaultRoleKey, defaultWorkspaceRoleKey, enabled ? 1 : 0, status, context.user.id, timestamp, timestamp]);
}
addAudit({ context, action: existing ? "system.identity.provider.updated" : "system.identity.provider.created", targetType: "identity_provider", targetId: id, metadata: { name, kind, enabled } });
return identityCenter();
}
export async function probeIdentityProvider(context, providerId) {
requirePermission(context, "system:settings:edit");
const provider = dbGet("SELECT * FROM identity_providers WHERE id = ?", [providerId]);
if (!provider) throw httpError(404, "identity_provider_not_found", "身份提供商不存在");
const timestamp = new Date().toISOString();
if (provider.kind !== "oidc") {
const ref = String(provider.idp_cert_ref || "").trim();
const missing = [];
if (!provider.entry_point) missing.push("Entry Point");
if (!provider.sp_issuer) missing.push("SP Issuer");
if (!ref || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(ref)) missing.push("证书环境变量名");
if (ref && !process.env[ref]) missing.push(`环境变量 ${ref}`);
if (missing.length) {
const message = `SAML 配置不完整:缺少 ${missing.join("、")}`;
dbRun("UPDATE identity_providers SET status = 'error', error_message = ?, last_probe_at = ?, updated_at = ? WHERE id = ?", [message, timestamp, timestamp, providerId]);
addAudit({ context, action: "system.identity.provider.probed", targetType: "identity_provider", targetId: providerId, result: "error", metadata: { kind: provider.kind, status: "error", missing } });
throw httpError(400, "identity_provider_probe_failed", message);
}
dbRun("UPDATE identity_providers SET status = 'ready', error_message = '', last_probe_at = ?, updated_at = ? WHERE id = ?", [timestamp, timestamp, providerId]);
addAudit({ context, action: "system.identity.provider.probed", targetType: "identity_provider", targetId: providerId, metadata: { kind: provider.kind, status: "ready" } });
return { provider: identityCenter().providers.find((item) => item.id === providerId), discovery: { entryPoint: provider.entry_point, spIssuer: provider.sp_issuer, callbackPath: "/api/auth/sso/saml/acs" } };
}
if (!provider.issuer_url) throw httpError(400, "identity_provider_issuer_required", "OIDC 提供商缺少 Issuer URL");
const discoveryUrl = `${provider.issuer_url.replace(/\/$/, "")}/.well-known/openid-configuration`;
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const response = await fetch(discoveryUrl, { headers: { accept: "application/json" }, signal: controller.signal });
clearTimeout(timeout);
const discovery = await response.json().catch(() => ({}));
if (!response.ok || !discovery.authorization_endpoint || !discovery.token_endpoint) throw new Error(`OIDC discovery HTTP ${response.status}`);
dbRun("UPDATE identity_providers SET authorization_url = ?, token_url = ?, userinfo_url = ?, jwks_url = ?, status = 'ready', error_message = '', last_probe_at = ?, updated_at = ? WHERE id = ?", [discovery.authorization_endpoint, discovery.token_endpoint, discovery.userinfo_endpoint || provider.userinfo_url, discovery.jwks_uri || provider.jwks_url || '', timestamp, timestamp, providerId]);
addAudit({ context, action: "system.identity.provider.probed", targetType: "identity_provider", targetId: providerId, metadata: { kind: provider.kind, status: "ready", discoveryUrl } });
return { provider: identityCenter().providers.find((item) => item.id === providerId), discovery: { issuer: discovery.issuer || provider.issuer_url, authorizationEndpoint: discovery.authorization_endpoint, tokenEndpoint: discovery.token_endpoint, userinfoEndpoint: discovery.userinfo_endpoint || "", jwksUri: discovery.jwks_uri || provider.jwks_url || "" } };
} catch (error) {
dbRun("UPDATE identity_providers SET status = 'error', error_message = ?, last_probe_at = ?, updated_at = ? WHERE id = ?", [error.message, timestamp, timestamp, providerId]);
addAudit({ context, action: "system.identity.provider.probed", targetType: "identity_provider", targetId: providerId, result: "error", metadata: { error: error.message } });
throw httpError(502, "identity_provider_probe_failed", `OIDC 发现文档检查失败:${error.message}`);
}
}
export function createDirectorySync(context, body = {}) {
requirePermission(context, "system:settings:edit");
const name = String(body.name || "企业目录").trim();
if (!name) throw httpError(400, "directory_sync_name_required", "目录同步名称不能为空");
const providerId = body.providerId ? String(body.providerId) : null;
if (providerId && !dbGet("SELECT id FROM identity_providers WHERE id = ?", [providerId])) throw httpError(404, "identity_provider_not_found", "关联的身份提供商不存在");
const id = `dir-${Date.now()}-${randomBytes(4).toString("hex")}`;
const token = `scim_${randomBytes(24).toString("base64url")}`;
const timestamp = new Date().toISOString();
const organizationId = context.organization.id;
dbRun("INSERT INTO directory_syncs(id, name, kind, organization_id, provider_id, endpoint, token_hint, enabled, sync_mode, schedule, created_by, created_at, updated_at) VALUES (?, ?, 'scim', ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)", [id, name, organizationId, providerId, String(body.endpoint || "").trim(), `${token.slice(0, 10)}****${token.slice(-4)}`, String(body.syncMode || "provision-and-deprovision"), String(body.schedule || "manual"), context.user.id, timestamp, timestamp]);
dbRun("INSERT INTO directory_sync_tokens(id, directory_sync_id, token_hash, token_hint, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?)", [`dirtok-${Date.now()}-${randomBytes(4).toString("hex")}`, id, identityTokenHash(token), `${token.slice(0, 10)}****${token.slice(-4)}`, context.user.id, timestamp]);
addAudit({ context, action: "system.identity.directory.created", targetType: "directory_sync", targetId: id, metadata: { organizationId, providerId, syncMode: body.syncMode || "provision-and-deprovision" } });
return { directorySync: identityCenter().directorySyncs.find((item) => item.id === id), token };
}
export function updateDirectorySync(context, directorySyncId, body = {}) {
requirePermission(context, "system:settings:edit");
const current = dbGet("SELECT * FROM directory_syncs WHERE id = ?", [directorySyncId]);
if (!current) throw httpError(404, "directory_sync_not_found", "目录同步不存在");
if (current.organization_id !== context.organization.id && !context.systemAdmin) throw httpError(403, "directory_sync_forbidden", "不能管理其他组织的目录同步");
const enabled = body.enabled === undefined ? Boolean(current.enabled) : Boolean(body.enabled);
const name = String(body.name ?? current.name).trim();
if (!name) throw httpError(400, "directory_sync_name_required", "目录同步名称不能为空");
const timestamp = new Date().toISOString();
dbRun("UPDATE directory_syncs SET name = ?, endpoint = ?, enabled = ?, sync_mode = ?, schedule = ?, updated_at = ? WHERE id = ?", [name, String(body.endpoint ?? current.endpoint).trim(), enabled ? 1 : 0, String(body.syncMode ?? current.sync_mode), String(body.schedule ?? current.schedule), timestamp, directorySyncId]);
addAudit({ context, action: "system.identity.directory.updated", targetType: "directory_sync", targetId: directorySyncId, metadata: { enabled, name } });
return identityCenter();
}
export function rotateDirectorySyncToken(context, directorySyncId) {
requirePermission(context, "system:settings:edit");
const current = dbGet("SELECT * FROM directory_syncs WHERE id = ?", [directorySyncId]);
if (!current) throw httpError(404, "directory_sync_not_found", "目录同步不存在");
if (current.organization_id !== context.organization.id && !context.systemAdmin) throw httpError(403, "directory_sync_forbidden", "不能管理其他组织的目录同步");
const token = `scim_${randomBytes(24).toString("base64url")}`;
const timestamp = new Date().toISOString();
const hint = `${token.slice(0, 10)}****${token.slice(-4)}`;
dbRun("UPDATE directory_sync_tokens SET revoked_at = ? WHERE directory_sync_id = ? AND revoked_at IS NULL", [timestamp, directorySyncId]);
dbRun("INSERT INTO directory_sync_tokens(id, directory_sync_id, token_hash, token_hint, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?)", [`dirtok-${Date.now()}-${randomBytes(4).toString("hex")}`, directorySyncId, identityTokenHash(token), hint, context.user.id, timestamp]);
dbRun("UPDATE directory_syncs SET token_hint = ?, updated_at = ? WHERE id = ?", [hint, timestamp, directorySyncId]);
addAudit({ context, action: "system.identity.directory.token_rotated", targetType: "directory_sync", targetId: directorySyncId, metadata: { tokenHint: hint } });
return { directorySync: identityCenter().directorySyncs.find((item) => item.id === directorySyncId), token };
}
function directoryForScim(directorySyncId, token) {
const directory = dbGet("SELECT d.*, o.name AS organization_name FROM directory_syncs d LEFT JOIN organizations o ON o.id = d.organization_id WHERE d.id = ? AND d.enabled = 1", [directorySyncId]);
if (!directory) throw httpError(404, "scim_directory_not_found", "SCIM 目录不存在或未启用");
const tokenRow = dbGet("SELECT id FROM directory_sync_tokens WHERE directory_sync_id = ? AND token_hash = ? AND revoked_at IS NULL", [directorySyncId, identityTokenHash(token)]);
if (!tokenRow) throw httpError(401, "scim_token_invalid", "SCIM 令牌无效");
dbRun("UPDATE directory_sync_tokens SET last_used_at = ? WHERE id = ?", [new Date().toISOString(), tokenRow.id]);
return directory;
}
function scimUserPayload(user) {
return {
schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
id: user.id,
userName: user.email,
displayName: user.display_name,
active: user.status === "active",
emails: [{ value: user.email, primary: true }],
meta: { resourceType: "User", created: user.created_at, lastModified: user.updated_at }
};
}
export function scimListUsers(directorySyncId, token, startIndex = 1, count = 100) {
const directory = directoryForScim(directorySyncId, token);
const rows = dbAll("SELECT u.* FROM users u JOIN organization_members om ON om.user_id = u.id WHERE om.organization_id = ? ORDER BY u.created_at LIMIT ? OFFSET ?", [directory.organization_id, Math.min(Math.max(Number(count) || 100, 1), 200), Math.max((Number(startIndex) || 1) - 1, 0)]);
const total = Number(dbGet("SELECT COUNT(*) AS count FROM users u JOIN organization_members om ON om.user_id = u.id WHERE om.organization_id = ?", [directory.organization_id])?.count || 0);
return { schemas: ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], totalResults: total, startIndex: Number(startIndex) || 1, itemsPerPage: rows.length, Resources: rows.map(scimUserPayload) };
}
export function scimCreateUser(directorySyncId, token, body = {}) {
const directory = directoryForScim(directorySyncId, token);
const email = String(body.userName || body.emails?.[0]?.value || "").trim().toLowerCase();
const displayName = String(body.displayName || body.name?.formatted || email.split("@")[0] || "企业用户").trim();
if (!email || !email.includes("@")) throw httpError(400, "scim_email_invalid", "SCIM 用户必须提供有效邮箱");
const timestamp = new Date().toISOString();
const active = body.active !== false;
const existing = dbGet("SELECT * FROM users WHERE lower(email) = lower(?)", [email]);
const userId = existing?.id || `u-scim-${Date.now()}-${randomBytes(4).toString("hex")}`;
withTransaction(() => {
if (existing) dbRun("UPDATE users SET display_name = ?, status = ?, updated_at = ? WHERE id = ?", [displayName, active ? "active" : "suspended", timestamp, userId]);
else dbRun("INSERT INTO users(id, display_name, email, avatar_color, status, created_at, updated_at) VALUES (?, ?, ?, '#477d69', ?, ?, ?)", [userId, displayName, email, active ? "active" : "suspended", timestamp, timestamp]);
dbRun("INSERT INTO organization_members(id, organization_id, user_id, role_key, status, joined_at, created_at, updated_at) VALUES (?, ?, ?, 'org_member', ?, ?, ?, ?) ON CONFLICT(organization_id, user_id) DO UPDATE SET status = excluded.status, updated_at = excluded.updated_at", [`om-${directory.organization_id}-${userId}`, directory.organization_id, userId, active ? "active" : "suspended", timestamp, timestamp, timestamp]);
});
return { status: existing ? 200 : 201, user: scimUserPayload(dbGet("SELECT * FROM users WHERE id = ?", [userId])) };
}
export function scimPatchUser(directorySyncId, token, userId, body = {}) {
const directory = directoryForScim(directorySyncId, token);
const user = dbGet("SELECT u.* FROM users u JOIN organization_members om ON om.user_id = u.id WHERE u.id = ? AND om.organization_id = ?", [userId, directory.organization_id]);
if (!user) throw httpError(404, "scim_user_not_found", "SCIM 用户不存在");
let displayName = user.display_name;
let email = user.email;
let active = user.status === "active";
for (const operation of Array.isArray(body.Operations) ? body.Operations : []) {
const path = String(operation.path || "").toLowerCase();
if (path === "active") active = Boolean(operation.value);
if (path === "displayname" || path === "name.formatted") displayName = String(operation.value || displayName);
if (path === "username" || path === "emails[type eq \"work\"].value") email = String(operation.value || email).toLowerCase();
}
const timestamp = new Date().toISOString();
dbRun("UPDATE users SET display_name = ?, email = ?, status = ?, updated_at = ? WHERE id = ?", [displayName, email, active ? "active" : "suspended", timestamp, userId]);
dbRun("UPDATE organization_members SET status = ?, updated_at = ? WHERE organization_id = ? AND user_id = ?", [active ? "active" : "suspended", timestamp, directory.organization_id, userId]);
return scimUserPayload(dbGet("SELECT * FROM users WHERE id = ?", [userId]));
}
export function scimDeleteUser(directorySyncId, token, userId) {
const directory = directoryForScim(directorySyncId, token);
const user = dbGet("SELECT u.id FROM users u JOIN organization_members om ON om.user_id = u.id WHERE u.id = ? AND om.organization_id = ?", [userId, directory.organization_id]);
if (!user) throw httpError(404, "scim_user_not_found", "SCIM 用户不存在");
const timestamp = new Date().toISOString();
dbRun("UPDATE organization_members SET status = 'suspended', updated_at = ? WHERE organization_id = ? AND user_id = ?", [timestamp, directory.organization_id, userId]);
dbRun("UPDATE users SET status = 'suspended', updated_at = ? WHERE id = ?", [timestamp, userId]);
return { ok: true };
}
export function serviceHealth() {
return dbAll("SELECT id, service_key, label, kind, status, endpoint, latency_ms, queue_depth, version, last_heartbeat, metadata_json, updated_at FROM service_health ORDER BY kind, label").map((row) => ({
...row,
metadata: parseJson(row.metadata_json, {})
}));
}
export function updateServiceHealth(context, serviceKey, body) {
requirePermission(context, "runner:manage");
const service = dbGet("SELECT * FROM service_health WHERE service_key = ?", [serviceKey]);
if (!service) throw httpError(404, "runner_not_found", "Runner 服务不存在", { serviceKey });
if (service.kind !== "runner") throw httpError(400, "runner_operation_invalid", "只有 Runner 服务支持运维动作");
const action = String(body.action || "").trim();
const allowed = new Set(["pause", "resume", "drain", "restart", "heartbeat"]);
if (!allowed.has(action)) throw httpError(400, "runner_action_invalid", "Runner 运维动作无效", { action });
const metadata = parseJson(service.metadata_json, {});
const timestamp = new Date().toISOString();
const previousStatus = service.status;
let status = service.status;
let lastHeartbeat = service.last_heartbeat;
if (action === "pause") status = "paused";
if (action === "drain") status = "draining";
if (action === "restart") {
status = "starting";
lastHeartbeat = null;
}
if (action === "resume") status = metadata.resumeStatus || (service.version ? "ready" : "waiting-model");
if (action === "heartbeat") {
status = metadata.resumeStatus || (service.version ? "ready" : "waiting-model");
lastHeartbeat = timestamp;
}
const nextMetadata = { ...metadata, lastAction: action, lastActionBy: context.user.id, lastActionAt: timestamp, previousStatus, resumeStatus: status === "paused" || status === "draining" ? (service.version ? "ready" : "waiting-model") : metadata.resumeStatus };
dbRun("UPDATE service_health SET status = ?, last_heartbeat = ?, metadata_json = ?, updated_at = ? WHERE service_key = ?", [status, lastHeartbeat, JSON.stringify(nextMetadata), timestamp, serviceKey]);
addAudit({ context, action: `runner.${action}`, targetType: "service_health", targetId: serviceKey, metadata: { previousStatus, status } });
const updated = serviceHealth().find((item) => item.service_key === serviceKey);
return { runner: updated, runners: serviceHealth().filter((item) => item.kind === "runner") };
}