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