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