Add customer contract clearance console

This commit is contained in:
xz
2026-09-01 14:38:42 +08:00
parent 1d4154dd20
commit 5849705d1e
19 changed files with 2431 additions and 25 deletions
+873
View File
@@ -0,0 +1,873 @@
import { dbAll, dbGet, dbRun, withTransaction } from "./db.mjs";
import { addAudit, hasPermission, httpError, requireEntitlement } from "./tenant.mjs";
const now = () => new Date().toISOString();
const makeId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
const CUSTOMER_TYPES = ["platform", "brand", "agency", "distributor", "internal"];
const CUSTOMER_STATUSES = ["prospect", "active", "paused", "archived"];
const CONTRACT_TYPES = ["production_license", "distribution", "work_for_hire", "revenue_share", "internal"];
const CONTRACT_STATUSES = ["draft", "submitted", "active", "expiring", "expired", "suspended", "terminated"];
const APPROVAL_STATUSES = ["draft", "legal_review", "approved", "blocked"];
const BINDING_USAGE_MODES = ["primary-license", "supplemental-rights", "delivery-only", "internal-review"];
const BINDING_STATUSES = ["active", "review", "blocked", "archived"];
const DEFAULT_LICENSE_SCOPE = {
channels: ["local-file", "private-delivery-portal"],
territories: ["CN"],
deliverables: ["vertical-video", "delivery-manifest"],
platforms: ["private-preview", "internal-review"],
exclusive: false,
commercialUse: true,
language: ["zh-CN"]
};
const DEFAULT_RIGHTS = {
originalStory: true,
derivativeProduction: true,
aiGeneratedAssetsAllowed: true,
voiceCloneAllowed: false,
fixedVoiceRequired: true,
singleFramePolicyRequired: true
};
function parseJson(value, fallback) {
try {
const parsed = JSON.parse(value);
return parsed === undefined || parsed === null ? fallback : parsed;
} catch {
return fallback;
}
}
function jsonObject(value, fallback = {}) {
if (value === undefined) return fallback;
if (typeof value === "string") {
const parsed = parseJson(value, fallback);
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : fallback;
}
return value && typeof value === "object" && !Array.isArray(value) ? value : fallback;
}
function uniqueList(value = []) {
const values = Array.isArray(value) ? value : String(value || "").split(/[,\n,、]/);
return [...new Set(values.map((item) => String(item || "").trim()).filter(Boolean))];
}
function normalizedChoice(value, allowed, fallback, code, message) {
const normalized = String(value || fallback || "").trim();
if (!allowed.includes(normalized)) throw httpError(400, code, message, { allowed });
return normalized;
}
function normalizeDate(value, fallback = null) {
if (value === undefined) return fallback;
const text = String(value || "").trim();
if (!text) return null;
const date = new Date(text);
if (Number.isNaN(date.getTime())) throw httpError(400, "date_invalid", "日期格式无效", { value: text });
return date.toISOString();
}
function hasAnyPermission(context, permissions = []) {
return Boolean(context?.systemAdmin || permissions.some((permission) => hasPermission(context, permission)));
}
function requireCustomerRead(context) {
if (hasAnyPermission(context, ["customer:read", "customer:manage", "contract:read", "contract:manage", "compliance:manage"])) return;
throw httpError(403, "permission_denied", "当前角色没有客户/合同查看权限");
}
function requireCustomerManage(context) {
if (hasAnyPermission(context, ["customer:manage"])) return;
throw httpError(403, "permission_denied", "当前角色没有客户主档管理权限");
}
function requireContractRead(context, options = {}) {
const permissions = ["contract:read", "contract:manage", "compliance:manage"];
if (options.allowDeliveryGate) permissions.push("delivery:view", "delivery:approve");
if (hasAnyPermission(context, permissions)) return;
throw httpError(403, "permission_denied", "当前角色没有合同授权查看权限");
}
function requireContractManage(context) {
if (hasAnyPermission(context, ["contract:manage"])) return;
throw httpError(403, "permission_denied", "当前角色没有合同授权管理权限");
}
function assertOrganizationScope(context, organizationId) {
if (context?.organization?.id === organizationId) return;
throw httpError(403, "organization_forbidden", "不能访问其他组织的客户合同数据", { organizationId });
}
function customerPayload(row, contacts = []) {
return {
id: row.id,
organizationId: row.organization_id,
name: row.name,
legalName: row.legal_name || "",
code: row.code,
customerType: row.customer_type,
status: row.status,
industry: row.industry || "",
region: row.region || "",
billingEmail: row.billing_email || "",
taxId: row.tax_id || "",
notes: row.notes || "",
tags: parseJson(row.tags_json, []),
metadata: parseJson(row.metadata_json, {}),
contactCount: Number(row.contact_count || contacts.length || 0),
contractCount: Number(row.contract_count || 0),
activeContractCount: Number(row.active_contract_count || 0),
contacts,
createdBy: row.created_by,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
function contactPayload(row) {
return {
id: row.id,
organizationId: row.organization_id,
customerId: row.customer_id,
name: row.name,
role: row.role || "",
email: row.email || "",
phone: row.phone || "",
isPrimary: Boolean(row.is_primary),
status: row.status,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
function bindingPayload(row) {
return {
id: row.id,
organizationId: row.organization_id,
workspaceId: row.workspace_id,
workspaceName: row.workspace_name || "",
projectId: row.project_id,
projectName: row.project_name || row.project_id,
contractId: row.contract_id,
customerId: row.customer_id,
usageMode: row.usage_mode,
status: row.status,
notes: row.notes || "",
createdBy: row.created_by,
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
function contractPayload(row, bindings = []) {
return {
id: row.id,
organizationId: row.organization_id,
customerId: row.customer_id,
customerName: row.customer_name || "",
customerCode: row.customer_code || "",
customerStatus: row.customer_status || "",
contractNumber: row.contract_number,
title: row.title,
contractType: row.contract_type,
status: row.status,
approvalStatus: row.approval_status,
effectiveAt: row.effective_at || "",
expiresAt: row.expires_at || "",
signedAt: row.signed_at || "",
currency: row.currency,
amount: Number(row.amount || 0),
licenseScope: parseJson(row.license_scope_json, {}),
rights: parseJson(row.rights_json, {}),
deliveryTerms: parseJson(row.delivery_terms_json, {}),
evidence: parseJson(row.evidence_json, {}),
risk: parseJson(row.risk_json, {}),
approvalNote: row.approval_note || "",
bindingCount: Number(row.binding_count || bindings.length || 0),
activeBindingCount: Number(row.active_binding_count || bindings.filter((binding) => binding.status === "active").length || 0),
bindings,
createdBy: row.created_by,
updatedBy: row.updated_by || "",
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
function eventPayload(row) {
return {
id: row.id,
organizationId: row.organization_id,
contractId: row.contract_id || "",
contractNumber: row.contract_number || "",
customerId: row.customer_id || "",
customerName: row.customer_name || "",
workspaceId: row.workspace_id || "",
projectId: row.project_id || "",
projectName: row.project_name || "",
eventType: row.event_type,
status: row.status,
actorUserId: row.actor_user_id || "",
actorName: row.actor_name || "",
metadata: parseJson(row.metadata_json, {}),
createdAt: row.created_at
};
}
function normalizeCustomerBody(body = {}, current = null) {
const name = String(body.name ?? current?.name ?? "").trim().slice(0, 160);
if (name.length < 2) throw httpError(400, "customer_name_required", "客户名称至少需要 2 个字符");
const code = String(body.code ?? current?.code ?? `CUST-${Date.now()}`)
.trim()
.replace(/[^a-zA-Z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "")
.toUpperCase()
.slice(0, 64);
if (code.length < 2) throw httpError(400, "customer_code_required", "客户编码至少需要 2 个字符");
return {
name,
code,
legalName: String(body.legalName ?? body.legal_name ?? current?.legal_name ?? "").trim().slice(0, 200),
customerType: normalizedChoice(body.customerType ?? body.customer_type ?? current?.customer_type, CUSTOMER_TYPES, "platform", "customer_type_invalid", "客户类型无效"),
status: normalizedChoice(body.status ?? current?.status, CUSTOMER_STATUSES, "active", "customer_status_invalid", "客户状态无效"),
industry: String(body.industry ?? current?.industry ?? "").trim().slice(0, 120),
region: String(body.region ?? current?.region ?? "").trim().slice(0, 80),
billingEmail: String(body.billingEmail ?? body.billing_email ?? current?.billing_email ?? "").trim().slice(0, 240),
taxId: String(body.taxId ?? body.tax_id ?? current?.tax_id ?? "").trim().slice(0, 80),
notes: String(body.notes ?? current?.notes ?? "").trim().slice(0, 2000),
tags: uniqueList(body.tags ?? parseJson(current?.tags_json, [])),
metadata: jsonObject(body.metadata ?? body.metadata_json, parseJson(current?.metadata_json, {}))
};
}
function normalizeContactBody(body = {}, current = null) {
const name = String(body.name ?? current?.name ?? "").trim().slice(0, 120);
if (name.length < 2) throw httpError(400, "contact_name_required", "联系人名称至少需要 2 个字符");
const status = normalizedChoice(body.status ?? current?.status, ["active", "inactive"], "active", "contact_status_invalid", "联系人状态无效");
return {
name,
status,
role: String(body.role ?? current?.role ?? "").trim().slice(0, 120),
email: String(body.email ?? current?.email ?? "").trim().slice(0, 240),
phone: String(body.phone ?? current?.phone ?? "").trim().slice(0, 80),
isPrimary: Boolean(body.isPrimary ?? body.is_primary ?? current?.is_primary ?? false)
};
}
function normalizeLicenseScope(value, current = null) {
const source = jsonObject(value, current ? parseJson(current.license_scope_json, DEFAULT_LICENSE_SCOPE) : DEFAULT_LICENSE_SCOPE);
return {
...DEFAULT_LICENSE_SCOPE,
...source,
channels: uniqueList(source.channels || DEFAULT_LICENSE_SCOPE.channels),
territories: uniqueList(source.territories || DEFAULT_LICENSE_SCOPE.territories),
deliverables: uniqueList(source.deliverables || DEFAULT_LICENSE_SCOPE.deliverables),
platforms: uniqueList(source.platforms || DEFAULT_LICENSE_SCOPE.platforms),
language: uniqueList(source.language || DEFAULT_LICENSE_SCOPE.language),
exclusive: Boolean(source.exclusive),
commercialUse: source.commercialUse !== false
};
}
function normalizeRights(value, current = null) {
return {
...DEFAULT_RIGHTS,
...jsonObject(value, current ? parseJson(current.rights_json, DEFAULT_RIGHTS) : DEFAULT_RIGHTS)
};
}
function normalizeContractBody(body = {}, current = null) {
const title = String(body.title ?? current?.title ?? "").trim().slice(0, 180);
if (title.length < 2) throw httpError(400, "contract_title_required", "合同标题至少需要 2 个字符");
const contractNumber = String(body.contractNumber ?? body.contract_number ?? current?.contract_number ?? `CTR-${Date.now()}`)
.trim()
.replace(/[^a-zA-Z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "")
.toUpperCase()
.slice(0, 80);
if (contractNumber.length < 2) throw httpError(400, "contract_number_required", "合同编号至少需要 2 个字符");
const amount = Number(body.amount ?? current?.amount ?? 0);
if (!Number.isFinite(amount) || amount < 0) throw httpError(400, "contract_amount_invalid", "合同金额必须是大于或等于 0 的数字");
return {
customerId: String(body.customerId ?? body.customer_id ?? current?.customer_id ?? "").trim(),
contractNumber,
title,
contractType: normalizedChoice(body.contractType ?? body.contract_type ?? current?.contract_type, CONTRACT_TYPES, "production_license", "contract_type_invalid", "合同类型无效"),
status: normalizedChoice(body.status ?? current?.status, CONTRACT_STATUSES, "draft", "contract_status_invalid", "合同状态无效"),
approvalStatus: normalizedChoice(body.approvalStatus ?? body.approval_status ?? current?.approval_status, APPROVAL_STATUSES, "draft", "contract_approval_status_invalid", "合同审批状态无效"),
effectiveAt: normalizeDate(body.effectiveAt ?? body.effective_at, current?.effective_at || null),
expiresAt: normalizeDate(body.expiresAt ?? body.expires_at, current?.expires_at || null),
signedAt: normalizeDate(body.signedAt ?? body.signed_at, current?.signed_at || null),
currency: String(body.currency ?? current?.currency ?? "CNY").trim().slice(0, 12) || "CNY",
amount,
licenseScope: normalizeLicenseScope(body.licenseScope ?? body.license_scope_json, current),
rights: normalizeRights(body.rights ?? body.rights_json, current),
deliveryTerms: jsonObject(body.deliveryTerms ?? body.delivery_terms_json, current ? parseJson(current.delivery_terms_json, {}) : {}),
evidence: jsonObject(body.evidence ?? body.evidence_json, current ? parseJson(current.evidence_json, {}) : {}),
risk: jsonObject(body.risk ?? body.risk_json, current ? parseJson(current.risk_json, {}) : {}),
approvalNote: String(body.approvalNote ?? body.approval_note ?? current?.approval_note ?? "").trim().slice(0, 2000)
};
}
function normalizeBindingBody(body = {}) {
return {
projectId: String(body.projectId ?? body.project_id ?? "").trim(),
usageMode: normalizedChoice(body.usageMode ?? body.usage_mode, BINDING_USAGE_MODES, "primary-license", "contract_usage_mode_invalid", "合同项目用途无效"),
status: normalizedChoice(body.status, BINDING_STATUSES, "active", "contract_binding_status_invalid", "合同绑定状态无效"),
notes: String(body.notes || "").trim().slice(0, 1000)
};
}
function ensureCustomer(organizationId, customerId) {
const row = dbGet("SELECT * FROM customers WHERE organization_id = ? AND id = ?", [organizationId, customerId]);
if (!row) throw httpError(404, "customer_not_found", "客户不存在或不属于当前组织", { customerId });
return row;
}
function ensureContract(organizationId, contractId) {
const row = dbGet(
`SELECT pc.*, c.name AS customer_name, c.code AS customer_code, c.status AS customer_status
FROM production_contracts pc
JOIN customers c ON c.id = pc.customer_id
WHERE pc.organization_id = ? AND pc.id = ?`,
[organizationId, contractId]
);
if (!row) throw httpError(404, "contract_not_found", "合同不存在或不属于当前组织", { contractId });
return row;
}
function accessibleProjectRows(context, organizationId) {
if (context?.organization?.id !== organizationId) return [];
if (context.systemAdmin || context.orgElevated) {
return dbAll(
`SELECT p.*, w.name AS workspace_name, w.id AS workspace_id
FROM projects p
JOIN workspaces w ON w.id = p.workspace_id
WHERE w.organization_id = ?
ORDER BY CASE WHEN p.status = 'archived' THEN 1 ELSE 0 END, w.name, p.updated_at DESC`,
[organizationId]
);
}
return dbAll(
`SELECT DISTINCT p.*, w.name AS workspace_name, w.id AS workspace_id
FROM projects p
JOIN workspaces w ON w.id = p.workspace_id
JOIN workspace_members wm ON wm.workspace_id = w.id AND wm.user_id = ? AND wm.status = 'active'
LEFT JOIN project_members pm ON pm.project_id = p.id AND pm.user_id = ? AND pm.status = 'active'
WHERE w.organization_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, w.name, p.updated_at DESC`,
[context.user.id, context.user.id, organizationId]
);
}
function ensureProjectInOrganization(context, projectId) {
const project = dbGet(
`SELECT p.*, w.organization_id, w.name AS workspace_name, w.id AS 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(404, "project_not_found", "项目不存在或不属于当前组织", { projectId });
const visible = accessibleProjectRows(context, context.organization.id).some((item) => item.id === project.id);
if (!visible) throw httpError(404, "project_not_found", "项目不存在或当前角色不可访问", { projectId });
return project;
}
function customerContacts(organizationId, customerId) {
return dbAll(
"SELECT * FROM customer_contacts WHERE organization_id = ? AND customer_id = ? ORDER BY is_primary DESC, status, updated_at DESC",
[organizationId, customerId]
).map(contactPayload);
}
function contractBindings(organizationId, contractId = "") {
const params = [organizationId];
const contractClause = contractId ? "AND cpb.contract_id = ?" : "";
if (contractId) params.push(contractId);
return dbAll(
`SELECT cpb.*, p.name AS project_name, w.name AS workspace_name
FROM contract_project_bindings cpb
JOIN projects p ON p.id = cpb.project_id
JOIN workspaces w ON w.id = cpb.workspace_id
WHERE cpb.organization_id = ? ${contractClause}
ORDER BY CASE cpb.status WHEN 'active' THEN 0 WHEN 'review' THEN 1 ELSE 2 END, cpb.updated_at DESC`,
params
).map(bindingPayload);
}
function recentContractEvents(organizationId, limit = 40) {
return dbAll(
`SELECT e.*, pc.contract_number, c.name AS customer_name, p.name AS project_name, u.display_name AS actor_name
FROM contract_license_events e
LEFT JOIN production_contracts pc ON pc.id = e.contract_id
LEFT JOIN customers c ON c.id = e.customer_id
LEFT JOIN projects p ON p.id = e.project_id
LEFT JOIN users u ON u.id = e.actor_user_id
WHERE e.organization_id = ?
ORDER BY e.created_at DESC, e.rowid DESC
LIMIT ?`,
[organizationId, Number(limit || 40)]
).map(eventPayload);
}
export function listCustomers(context, organizationId, options = {}) {
assertOrganizationScope(context, organizationId);
requireCustomerRead(context);
const includeArchived = options.includeArchived === true || options.includeArchived === "1";
const status = String(options.status || "").trim();
const search = String(options.search || options.q || "").trim().toLowerCase();
const clauses = ["c.organization_id = ?"];
const params = [organizationId];
if (!includeArchived) clauses.push("c.status != 'archived'");
if (status) {
clauses.push("c.status = ?");
params.push(status);
}
if (search) {
clauses.push("(lower(c.name) LIKE ? OR lower(c.code) LIKE ? OR lower(c.legal_name) LIKE ?)");
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
}
const rows = dbAll(
`SELECT c.*,
(SELECT COUNT(*) FROM customer_contacts cc WHERE cc.customer_id = c.id) AS contact_count,
(SELECT COUNT(*) FROM production_contracts pc WHERE pc.customer_id = c.id) AS contract_count,
(SELECT COUNT(*) FROM production_contracts pc WHERE pc.customer_id = c.id AND pc.status IN ('active', 'expiring')) AS active_contract_count
FROM customers c
WHERE ${clauses.join(" AND ")}
ORDER BY CASE c.status WHEN 'active' THEN 0 WHEN 'prospect' THEN 1 WHEN 'paused' THEN 2 ELSE 3 END, c.updated_at DESC`,
params
);
const customers = rows.map((row) => customerPayload(row, customerContacts(organizationId, row.id)));
return {
customers,
summary: {
total: customers.length,
active: customers.filter((item) => item.status === "active").length,
prospects: customers.filter((item) => item.status === "prospect").length,
archived: customers.filter((item) => item.status === "archived").length
}
};
}
export function createCustomer(context, organizationId, body = {}) {
assertOrganizationScope(context, organizationId);
requireCustomerManage(context);
requireEntitlement(context, "limit.customers", 1);
const customer = normalizeCustomerBody(body);
const exists = dbGet("SELECT id FROM customers WHERE organization_id = ? AND code = ?", [organizationId, customer.code]);
if (exists) throw httpError(409, "customer_code_exists", "客户编码已存在", { code: customer.code });
const timestamp = now();
const id = makeId("customer");
withTransaction(() => {
dbRun(
`INSERT INTO customers(
id, organization_id, name, legal_name, code, customer_type, status, industry, region,
billing_email, tax_id, notes, tags_json, metadata_json, created_by, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[id, organizationId, customer.name, customer.legalName, customer.code, customer.customerType, customer.status, customer.industry, customer.region, customer.billingEmail, customer.taxId, customer.notes, JSON.stringify(customer.tags), JSON.stringify(customer.metadata), context.user.id, timestamp, timestamp]
);
addAudit({ context, action: "customer.created", targetType: "customer", targetId: id, metadata: { code: customer.code, status: customer.status } });
});
const payload = customerPayload(ensureCustomer(organizationId, id), customerContacts(organizationId, id));
return { customer: payload, ...listCustomers(context, organizationId, { includeArchived: true }) };
}
export function updateCustomer(context, organizationId, customerId, body = {}) {
assertOrganizationScope(context, organizationId);
requireCustomerManage(context);
const current = ensureCustomer(organizationId, customerId);
const customer = normalizeCustomerBody(body, current);
const collision = dbGet("SELECT id FROM customers WHERE organization_id = ? AND code = ? AND id != ?", [organizationId, customer.code, customerId]);
if (collision) throw httpError(409, "customer_code_exists", "客户编码已存在", { code: customer.code });
const timestamp = now();
dbRun(
`UPDATE customers
SET name = ?, legal_name = ?, code = ?, customer_type = ?, status = ?, industry = ?, region = ?,
billing_email = ?, tax_id = ?, notes = ?, tags_json = ?, metadata_json = ?, updated_at = ?
WHERE organization_id = ? AND id = ?`,
[customer.name, customer.legalName, customer.code, customer.customerType, customer.status, customer.industry, customer.region, customer.billingEmail, customer.taxId, customer.notes, JSON.stringify(customer.tags), JSON.stringify(customer.metadata), timestamp, organizationId, customerId]
);
addAudit({ context, action: "customer.updated", targetType: "customer", targetId: customerId, metadata: { code: customer.code, status: customer.status } });
const payload = customerPayload(ensureCustomer(organizationId, customerId), customerContacts(organizationId, customerId));
return { customer: payload, ...listCustomers(context, organizationId, { includeArchived: true }) };
}
export function upsertCustomerContact(context, organizationId, customerId, body = {}) {
assertOrganizationScope(context, organizationId);
requireCustomerManage(context);
ensureCustomer(organizationId, customerId);
const contactId = String(body.id || "").trim();
const current = contactId ? dbGet("SELECT * FROM customer_contacts WHERE organization_id = ? AND customer_id = ? AND id = ?", [organizationId, customerId, contactId]) : null;
if (contactId && !current) throw httpError(404, "customer_contact_not_found", "联系人不存在或不属于当前客户", { contactId });
const contact = normalizeContactBody(body, current);
const timestamp = now();
const id = contactId || makeId("contact");
withTransaction(() => {
if (contact.isPrimary) {
dbRun("UPDATE customer_contacts SET is_primary = 0, updated_at = ? WHERE organization_id = ? AND customer_id = ?", [timestamp, organizationId, customerId]);
}
if (current) {
dbRun(
"UPDATE customer_contacts SET name = ?, role = ?, email = ?, phone = ?, is_primary = ?, status = ?, updated_at = ? WHERE id = ? AND organization_id = ? AND customer_id = ?",
[contact.name, contact.role, contact.email, contact.phone, contact.isPrimary ? 1 : 0, contact.status, timestamp, id, organizationId, customerId]
);
} else {
dbRun(
"INSERT INTO customer_contacts(id, organization_id, customer_id, name, role, email, phone, is_primary, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[id, organizationId, customerId, contact.name, contact.role, contact.email, contact.phone, contact.isPrimary ? 1 : 0, contact.status, timestamp, timestamp]
);
}
addAudit({ context, action: current ? "customer.contact.updated" : "customer.contact.created", targetType: "customer_contact", targetId: id, metadata: { customerId, isPrimary: contact.isPrimary } });
});
const payload = customerPayload(ensureCustomer(organizationId, customerId), customerContacts(organizationId, customerId));
return { contact: payload.contacts.find((item) => item.id === id), customer: payload, ...listCustomers(context, organizationId, { includeArchived: true }) };
}
export function listContracts(context, organizationId, options = {}) {
assertOrganizationScope(context, organizationId);
requireContractRead(context);
const includeArchived = options.includeArchived === true || options.includeArchived === "1";
const status = String(options.status || "").trim();
const customerId = String(options.customerId || options.customer_id || "").trim();
const projectId = String(options.projectId || options.project_id || "").trim();
const clauses = ["pc.organization_id = ?"];
const params = [organizationId];
if (!includeArchived) clauses.push("pc.status != 'terminated'");
if (status) {
clauses.push("pc.status = ?");
params.push(status);
}
if (customerId) {
clauses.push("pc.customer_id = ?");
params.push(customerId);
}
if (projectId) {
clauses.push("EXISTS (SELECT 1 FROM contract_project_bindings cpb WHERE cpb.contract_id = pc.id AND cpb.project_id = ? AND cpb.organization_id = pc.organization_id)");
params.push(projectId);
}
const rows = dbAll(
`SELECT pc.*, c.name AS customer_name, c.code AS customer_code, c.status AS customer_status,
(SELECT COUNT(*) FROM contract_project_bindings cpb WHERE cpb.contract_id = pc.id) AS binding_count,
(SELECT COUNT(*) FROM contract_project_bindings cpb WHERE cpb.contract_id = pc.id AND cpb.status = 'active') AS active_binding_count
FROM production_contracts pc
JOIN customers c ON c.id = pc.customer_id
WHERE ${clauses.join(" AND ")}
ORDER BY CASE pc.status WHEN 'active' THEN 0 WHEN 'expiring' THEN 1 WHEN 'submitted' THEN 2 WHEN 'draft' THEN 3 ELSE 4 END, pc.updated_at DESC`,
params
);
const allBindings = contractBindings(organizationId);
const contracts = rows.map((row) => contractPayload(row, allBindings.filter((binding) => binding.contractId === row.id)));
const customers = listCustomers(context, organizationId, { includeArchived: true }).customers;
const projects = accessibleProjectRows(context, organizationId).map((project) => ({
id: project.id,
name: project.name,
status: project.status,
workspaceId: project.workspace_id,
workspaceName: project.workspace_name,
readiness: Number(project.readiness || 0),
risk: project.risk || ""
}));
const clearance = projectId ? evaluateProjectContractClearance(context, projectId, { channelKind: options.channelKind || options.channel_kind || "" }) : null;
return {
contracts,
customers,
projects,
events: recentContractEvents(organizationId, Number(options.limit || 40)),
clearance,
summary: {
total: contracts.length,
active: contracts.filter((item) => item.status === "active").length,
approved: contracts.filter((item) => item.approvalStatus === "approved").length,
boundProjects: new Set(allBindings.filter((binding) => binding.status === "active").map((binding) => binding.projectId)).size,
blockers: clearance?.blockers?.length || 0
}
};
}
export function createContract(context, organizationId, body = {}) {
assertOrganizationScope(context, organizationId);
requireContractManage(context);
requireEntitlement(context, "limit.contracts", 1);
const contract = normalizeContractBody(body);
const customer = ensureCustomer(organizationId, contract.customerId);
const exists = dbGet("SELECT id FROM production_contracts WHERE organization_id = ? AND contract_number = ?", [organizationId, contract.contractNumber]);
if (exists) throw httpError(409, "contract_number_exists", "合同编号已存在", { contractNumber: contract.contractNumber });
const timestamp = now();
const id = makeId("contract");
withTransaction(() => {
dbRun(
`INSERT INTO production_contracts(
id, organization_id, customer_id, contract_number, title, contract_type, status,
approval_status, effective_at, expires_at, signed_at, currency, amount,
license_scope_json, rights_json, delivery_terms_json, evidence_json, risk_json,
approval_note, created_by, updated_by, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[id, organizationId, customer.id, contract.contractNumber, contract.title, contract.contractType, contract.status, contract.approvalStatus, contract.effectiveAt, contract.expiresAt, contract.signedAt, contract.currency, contract.amount, JSON.stringify(contract.licenseScope), JSON.stringify(contract.rights), JSON.stringify(contract.deliveryTerms), JSON.stringify(contract.evidence), JSON.stringify(contract.risk), contract.approvalNote, context.user.id, context.user.id, timestamp, timestamp]
);
writeContractEvent(context, {
contractId: id,
customerId: customer.id,
eventType: "contract.created",
metadata: { contractNumber: contract.contractNumber, status: contract.status, approvalStatus: contract.approvalStatus }
});
addAudit({ context, action: "contract.created", targetType: "production_contract", targetId: id, metadata: { customerId: customer.id, contractNumber: contract.contractNumber } });
});
return { contract: contractPayload(ensureContract(organizationId, id), []), ...listContracts(context, organizationId, { includeArchived: true }) };
}
export function updateContract(context, organizationId, contractId, body = {}) {
assertOrganizationScope(context, organizationId);
requireContractManage(context);
const current = ensureContract(organizationId, contractId);
const contract = normalizeContractBody(body, current);
const customer = ensureCustomer(organizationId, contract.customerId);
const collision = dbGet("SELECT id FROM production_contracts WHERE organization_id = ? AND contract_number = ? AND id != ?", [organizationId, contract.contractNumber, contractId]);
if (collision) throw httpError(409, "contract_number_exists", "合同编号已存在", { contractNumber: contract.contractNumber });
const timestamp = now();
withTransaction(() => {
dbRun(
`UPDATE production_contracts
SET customer_id = ?, contract_number = ?, title = ?, contract_type = ?, status = ?, approval_status = ?,
effective_at = ?, expires_at = ?, signed_at = ?, currency = ?, amount = ?,
license_scope_json = ?, rights_json = ?, delivery_terms_json = ?, evidence_json = ?, risk_json = ?,
approval_note = ?, updated_by = ?, updated_at = ?
WHERE organization_id = ? AND id = ?`,
[customer.id, contract.contractNumber, contract.title, contract.contractType, contract.status, contract.approvalStatus, contract.effectiveAt, contract.expiresAt, contract.signedAt, contract.currency, contract.amount, JSON.stringify(contract.licenseScope), JSON.stringify(contract.rights), JSON.stringify(contract.deliveryTerms), JSON.stringify(contract.evidence), JSON.stringify(contract.risk), contract.approvalNote, context.user.id, timestamp, organizationId, contractId]
);
dbRun("UPDATE contract_project_bindings SET customer_id = ?, updated_at = ? WHERE organization_id = ? AND contract_id = ?", [customer.id, timestamp, organizationId, contractId]);
writeContractEvent(context, {
contractId,
customerId: customer.id,
eventType: "contract.updated",
metadata: { previous: { status: current.status, approvalStatus: current.approval_status }, next: { status: contract.status, approvalStatus: contract.approvalStatus } }
});
addAudit({ context, action: "contract.updated", targetType: "production_contract", targetId: contractId, metadata: { customerId: customer.id, contractNumber: contract.contractNumber } });
});
return { contract: contractPayload(ensureContract(organizationId, contractId), contractBindings(organizationId, contractId)), ...listContracts(context, organizationId, { includeArchived: true }) };
}
export function bindContractProject(context, organizationId, contractId, body = {}) {
assertOrganizationScope(context, organizationId);
requireContractManage(context);
const contract = ensureContract(organizationId, contractId);
const binding = normalizeBindingBody(body);
if (!binding.projectId) throw httpError(400, "project_required", "合同绑定必须选择项目");
const project = ensureProjectInOrganization(context, binding.projectId);
const timestamp = now();
let bindingId = "";
withTransaction(() => {
const current = dbGet("SELECT * FROM contract_project_bindings WHERE organization_id = ? AND project_id = ? AND contract_id = ?", [organizationId, project.id, contract.id]);
if (current) {
bindingId = current.id;
dbRun(
"UPDATE contract_project_bindings SET customer_id = ?, usage_mode = ?, status = ?, notes = ?, updated_at = ? WHERE id = ?",
[contract.customer_id, binding.usageMode, binding.status, binding.notes, timestamp, current.id]
);
} else {
bindingId = makeId("contract-binding");
dbRun(
"INSERT INTO contract_project_bindings(id, organization_id, workspace_id, project_id, contract_id, customer_id, usage_mode, status, notes, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[bindingId, organizationId, project.workspace_id, project.id, contract.id, contract.customer_id, binding.usageMode, binding.status, binding.notes, context.user.id, timestamp, timestamp]
);
}
writeContractEvent(context, {
contractId: contract.id,
customerId: contract.customer_id,
workspaceId: project.workspace_id,
projectId: project.id,
eventType: current ? "contract.project_binding.updated" : "contract.project_binding.created",
metadata: { bindingId, usageMode: binding.usageMode, status: binding.status }
});
addAudit({ context, action: current ? "contract.project_binding.updated" : "contract.project_binding.created", targetType: "contract_project_binding", targetId: bindingId, metadata: { contractId, projectId: project.id, status: binding.status } });
});
return {
binding: contractBindings(organizationId, contractId).find((item) => item.id === bindingId),
clearance: evaluateProjectContractClearance(context, project.id),
...listContracts(context, organizationId, { includeArchived: true, projectId: project.id })
};
}
function listAllows(list, value) {
if (!value) return true;
const normalized = String(value || "").trim().toLowerCase();
const allowed = uniqueList(list).map((item) => item.toLowerCase());
return allowed.includes(normalized) || allowed.includes("all") || allowed.includes("global") || allowed.includes("*");
}
function hasEvidence(evidence = {}) {
return Boolean(evidence.contractRef || evidence.rightsEvidenceRef || evidence.signedCopyPath || evidence.signedCopySha256);
}
function contractIssueSet(contract, binding, options = {}) {
const blockers = [];
const reviewItems = [];
const licenseScope = contract.licenseScope || {};
const rights = contract.rights || {};
const evidence = contract.evidence || {};
const checkedAt = Date.now();
const channelKind = String(options.channelKind || options.channel_kind || options.channel?.kind || "").trim();
const territory = String(options.territory || "CN").trim();
const requiredDeliverables = uniqueList(options.deliverables || ["vertical-video"]);
if (binding.status !== "active") blockers.push({ type: "contract", code: "binding_not_active", status: binding.status, reason: "项目合同绑定未启用", contractId: contract.id, bindingId: binding.id });
if (!["active", "expiring"].includes(contract.status)) blockers.push({ type: "contract", code: "contract_not_active", status: contract.status, reason: "合同不是可交付状态", contractId: contract.id });
if (contract.approvalStatus !== "approved") blockers.push({ type: "contract", code: "contract_not_approved", status: contract.approvalStatus, reason: "合同尚未通过法务/商务审批", contractId: contract.id });
if (contract.customerStatus && contract.customerStatus !== "active") blockers.push({ type: "contract", code: "customer_not_active", status: contract.customerStatus, reason: "客户主档未处于启用状态", contractId: contract.id, customerId: contract.customerId });
if (contract.effectiveAt && new Date(contract.effectiveAt).getTime() > checkedAt) blockers.push({ type: "contract", code: "contract_not_effective", reason: "合同尚未到生效时间", contractId: contract.id, effectiveAt: contract.effectiveAt });
if (contract.expiresAt && new Date(contract.expiresAt).getTime() < checkedAt) blockers.push({ type: "contract", code: "contract_expired", reason: "合同已过期", contractId: contract.id, expiresAt: contract.expiresAt });
if (contract.expiresAt) {
const daysLeft = Math.ceil((new Date(contract.expiresAt).getTime() - checkedAt) / 86400000);
if (daysLeft >= 0 && daysLeft <= 30) reviewItems.push({ type: "contract", code: "contract_expiring_soon", reason: `合同将在 ${daysLeft} 天内到期`, contractId: contract.id, expiresAt: contract.expiresAt });
}
if (!hasEvidence(evidence)) blockers.push({ type: "contract", code: "contract_evidence_missing", reason: "合同缺少签署/原创/授权证据引用", contractId: contract.id });
if (channelKind && !listAllows(licenseScope.channels, channelKind)) blockers.push({ type: "contract", code: "channel_not_licensed", reason: "交付渠道不在合同授权范围内", contractId: contract.id, channelKind, allowed: licenseScope.channels || [] });
if (territory && !listAllows(licenseScope.territories, territory)) blockers.push({ type: "contract", code: "territory_not_licensed", reason: "交付地域不在合同授权范围内", contractId: contract.id, territory, allowed: licenseScope.territories || [] });
for (const deliverable of requiredDeliverables) {
if (!listAllows(licenseScope.deliverables, deliverable)) blockers.push({ type: "contract", code: "deliverable_not_licensed", reason: "交付物类型不在合同授权范围内", contractId: contract.id, deliverable, allowed: licenseScope.deliverables || [] });
}
if (licenseScope.commercialUse !== true) blockers.push({ type: "contract", code: "commercial_use_not_allowed", reason: "合同未明确允许商业使用", contractId: contract.id });
if (rights.originalStory !== true) blockers.push({ type: "contract", code: "original_story_missing", reason: "合同未确认原创故事权利", contractId: contract.id });
if (rights.derivativeProduction !== true) blockers.push({ type: "contract", code: "derivative_right_missing", reason: "合同未确认改编/制作授权", contractId: contract.id });
if (rights.aiGeneratedAssetsAllowed !== true) blockers.push({ type: "contract", code: "ai_assets_not_allowed", reason: "合同未允许 AI 生成资产用于制作", contractId: contract.id });
if (rights.fixedVoiceRequired !== true) reviewItems.push({ type: "contract", code: "fixed_voice_not_required_by_contract", reason: "合同未显式要求固定声线,建议补充以匹配平台策略", contractId: contract.id });
if (rights.singleFramePolicyRequired !== true) blockers.push({ type: "contract", code: "single_frame_policy_not_required", reason: "合同未纳入一图一完整单画面生产约束", contractId: contract.id });
if (channelKind === "private-delivery-portal" && contract.deliveryTerms?.customerPortalAllowed === false) blockers.push({ type: "contract", code: "portal_not_allowed", reason: "合同交付条款未允许客户门户访问", contractId: contract.id });
return { blockers, reviewItems };
}
export function evaluateProjectContractClearance(context, projectId = "", options = {}) {
requireContractRead(context, { allowDeliveryGate: true });
const targetProjectId = String(projectId || context.project?.id || "").trim();
if (!targetProjectId) throw httpError(400, "project_required", "合同授权清算必须绑定项目");
const project = ensureProjectInOrganization(context, targetProjectId);
const rows = dbAll(
`SELECT cpb.*, p.name AS project_name, w.name AS workspace_name,
pc.contract_number, pc.title, pc.contract_type, pc.status AS contract_status, pc.approval_status,
pc.effective_at, pc.expires_at, pc.signed_at, pc.currency, pc.amount,
pc.license_scope_json, pc.rights_json, pc.delivery_terms_json, pc.evidence_json, pc.risk_json,
c.name AS customer_name, c.code AS customer_code, c.status AS customer_status
FROM contract_project_bindings cpb
JOIN projects p ON p.id = cpb.project_id
JOIN workspaces w ON w.id = cpb.workspace_id
JOIN production_contracts pc ON pc.id = cpb.contract_id
JOIN customers c ON c.id = cpb.customer_id
WHERE cpb.organization_id = ? AND cpb.workspace_id = ? AND cpb.project_id = ?
ORDER BY CASE cpb.status WHEN 'active' THEN 0 WHEN 'review' THEN 1 ELSE 2 END, cpb.updated_at DESC`,
[context.organization.id, project.workspace_id, project.id]
);
const blockers = [];
const reviewItems = [];
const bindings = rows.map((row) => {
const binding = bindingPayload(row);
const contract = {
id: row.contract_id,
organizationId: row.organization_id,
customerId: row.customer_id,
customerName: row.customer_name || "",
customerCode: row.customer_code || "",
customerStatus: row.customer_status || "",
contractNumber: row.contract_number,
title: row.title,
contractType: row.contract_type,
status: row.contract_status,
approvalStatus: row.approval_status,
effectiveAt: row.effective_at || "",
expiresAt: row.expires_at || "",
signedAt: row.signed_at || "",
currency: row.currency,
amount: Number(row.amount || 0),
licenseScope: parseJson(row.license_scope_json, {}),
rights: parseJson(row.rights_json, {}),
deliveryTerms: parseJson(row.delivery_terms_json, {}),
evidence: parseJson(row.evidence_json, {}),
risk: parseJson(row.risk_json, {})
};
const issues = contractIssueSet(contract, binding, options);
blockers.push(...issues.blockers);
reviewItems.push(...issues.reviewItems);
return { ...binding, contract };
});
if (!bindings.some((binding) => binding.status === "active")) {
blockers.push({ type: "contract", code: "project_contract_missing", reason: "项目没有启用中的客户/合同授权绑定", projectId: project.id });
}
const status = blockers.length ? "blocked" : reviewItems.length ? "review" : "pass";
return {
schema: "ai-drama-platform.contract-clearance.v1",
checkedAt: now(),
status,
organizationId: context.organization.id,
workspaceId: project.workspace_id,
projectId: project.id,
projectName: project.name,
requested: {
channelKind: options.channelKind || options.channel_kind || options.channel?.kind || "",
territory: options.territory || "CN",
deliverables: uniqueList(options.deliverables || ["vertical-video"])
},
blockers,
reviewItems,
bindings,
counts: {
bindings: bindings.length,
activeBindings: bindings.filter((binding) => binding.status === "active").length,
blockers: blockers.length,
reviewItems: reviewItems.length
}
};
}
function writeContractEvent(context, body = {}) {
const timestamp = now();
const id = body.id || makeId("contract-event");
dbRun(
"INSERT INTO contract_license_events(id, organization_id, contract_id, customer_id, workspace_id, project_id, event_type, status, actor_user_id, metadata_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[
id,
context.organization.id,
body.contractId || null,
body.customerId || null,
body.workspaceId || null,
body.projectId || null,
String(body.eventType || "contract.event").trim(),
String(body.status || "recorded").trim(),
context.user.id,
JSON.stringify(body.metadata || {}),
timestamp
]
);
return eventPayload(dbGet(
`SELECT e.*, pc.contract_number, c.name AS customer_name, p.name AS project_name, u.display_name AS actor_name
FROM contract_license_events e
LEFT JOIN production_contracts pc ON pc.id = e.contract_id
LEFT JOIN customers c ON c.id = e.customer_id
LEFT JOIN projects p ON p.id = e.project_id
LEFT JOIN users u ON u.id = e.actor_user_id
WHERE e.id = ?`,
[id]
));
}
export function recordContractLicenseEvent(context, body = {}) {
requireContractRead(context);
const contractId = String(body.contractId || body.contract_id || "").trim();
const customerId = String(body.customerId || body.customer_id || "").trim();
const projectId = String(body.projectId || body.project_id || "").trim();
const contract = contractId ? ensureContract(context.organization.id, contractId) : null;
const customer = customerId ? ensureCustomer(context.organization.id, customerId) : contract ? ensureCustomer(context.organization.id, contract.customer_id) : null;
const project = projectId ? ensureProjectInOrganization(context, projectId) : null;
const event = writeContractEvent(context, {
contractId: contract?.id || null,
customerId: customer?.id || null,
workspaceId: project?.workspace_id || body.workspaceId || body.workspace_id || null,
projectId: project?.id || null,
eventType: body.eventType || body.event_type || "contract.clearance.checked",
status: body.status || "recorded",
metadata: body.metadata || {}
});
addAudit({ context, action: "contract.license_event.recorded", targetType: "contract_license_event", targetId: event.id, metadata: { contractId: event.contractId, projectId: event.projectId, eventType: event.eventType } });
return { event, events: recentContractEvents(context.organization.id) };
}
+244 -2
View File
@@ -142,6 +142,13 @@ db.exec("CREATE INDEX IF NOT EXISTS idx_commercial_approvals_requester ON commer
db.exec("CREATE INDEX IF NOT EXISTS idx_commercial_approvals_target ON commercial_approval_requests(organization_id, request_type, target_key, status)");
db.exec("CREATE INDEX IF NOT EXISTS idx_organization_policies_org_category ON organization_policies(organization_id, category, policy_key)");
db.exec("CREATE INDEX IF NOT EXISTS idx_organization_policy_evaluations_scope ON organization_policy_evaluations(organization_id, workspace_id, project_id, result, created_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_customers_org_status ON customers(organization_id, status, updated_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_customer_contacts_customer ON customer_contacts(customer_id, is_primary DESC, status)");
db.exec("CREATE INDEX IF NOT EXISTS idx_production_contracts_org_status ON production_contracts(organization_id, status, approval_status, updated_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_production_contracts_customer ON production_contracts(customer_id, status, updated_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_contract_project_bindings_project ON contract_project_bindings(organization_id, workspace_id, project_id, status)");
db.exec("CREATE INDEX IF NOT EXISTS idx_contract_project_bindings_contract ON contract_project_bindings(contract_id, status, updated_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_contract_license_events_scope ON contract_license_events(organization_id, contract_id, project_id, created_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_asset_versions_governance ON asset_versions(asset_id, rights_status, expires_at)");
db.exec("CREATE INDEX IF NOT EXISTS idx_asset_governance_reviews_asset ON asset_governance_reviews(asset_id, created_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_asset_governance_reviews_scope ON asset_governance_reviews(organization_id, workspace_id, project_id, risk_status, created_at DESC)");
@@ -253,6 +260,10 @@ function seedRoles() {
["task:complete", "更新本人负责的协作任务状态"],
["style:read", "查看组织、工作区和项目风格生产标准"],
["style:manage", "管理风格标准、品牌规范和项目绑定"],
["customer:read", "查看客户主档、联系人和项目归属"],
["customer:manage", "管理客户主档、联系人和客户状态"],
["contract:read", "查看合同授权、项目绑定和商业放行"],
["contract:manage", "管理合同授权、项目绑定和商业放行"],
["script:edit", "编辑剧本和对白"],
["script:read", "查看剧本、分集和镜头"],
["asset:edit", "编辑角色、场景和道具锁"],
@@ -289,10 +300,10 @@ function seedRoles() {
org_admin: [
"organization:manage", "organization:members:invite", "workspace:create", "workspace:manage",
"workspace:members:manage", "project:create", "project:manage", "project:members:manage",
"workflow:manage", "task:view", "task:manage", "task:complete", "policy:read", "policy:manage", "style:read", "style:manage", "model:manage", "model:approve", "usage:view", "billing:manage", "quota:manage", "qa:review", "delivery:approve", "delivery:view", "compliance:manage", "audit:view", "queue:manage", "voice:approve",
"workflow:manage", "task:view", "task:manage", "task:complete", "policy:read", "policy:manage", "style:read", "style:manage", "customer:read", "customer:manage", "contract:read", "contract:manage", "model:manage", "model:approve", "usage:view", "billing:manage", "quota:manage", "qa:review", "delivery:approve", "delivery:view", "compliance:manage", "audit:view", "queue:manage", "voice:approve",
"system:settings:view", "service:health:view", "organization:roles:manage"
],
producer: ["project:create", "project:manage", "project:members:manage", "workflow:manage", "task:view", "task:manage", "task:complete", "policy:read", "style:read", "style:manage", "script:read", "job:create", "job:prioritize", "usage:view", "delivery:approve", "delivery:view", "voice:approve"],
producer: ["project:create", "project:manage", "project:members:manage", "workflow:manage", "task:view", "task:manage", "task:complete", "policy:read", "style:read", "style:manage", "customer:read", "contract:read", "script:read", "job:create", "job:prioritize", "usage:view", "delivery:approve", "delivery:view", "voice:approve"],
writer: ["script:read", "script:edit", "task:view", "task:complete", "policy:read", "style:read", "job:create"],
art_director: ["asset:edit", "prompt:edit", "task:view", "task:complete", "policy:read", "style:read", "style:manage", "job:create"],
voice_editor: ["voice:edit", "voice:approve", "task:view", "task:complete", "policy:read", "style:read", "job:create"],
@@ -395,6 +406,8 @@ function commercialEntitlementDefaults(billing = {}) {
["limit.projects", "项目数量", "production", 36, "个", 1, "block", { commercialGate: "project:create" }],
["limit.generation_jobs_monthly", "月度生成任务", "production", clipLimit, "job", 1, "block", { commercialGate: "generation_job:create" }],
["limit.style_kits", "风格生产标准", "production", 24, "套", 1, "block", { commercialGate: "style_kit:create" }],
["limit.customers", "客户主档", "commercial", 120, "个", 1, "block", { commercialGate: "customer:create" }],
["limit.contracts", "合同授权", "commercial", 240, "份", 1, "block", { commercialGate: "contract:create" }],
["limit.storage_gb", "存储容量", "storage", storageGb, "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" }],
@@ -551,6 +564,234 @@ function seedCommercialApprovals() {
);
}
function seedCustomersAndContracts() {
const timestamp = now();
const customers = [
{
id: "cust-xinghe-release",
organizationId: "org-studio-lab",
name: "星河发行合作方",
legalName: "星河内容发行(模拟)有限公司",
code: "XH-RELEASE",
customerType: "distributor",
status: "active",
industry: "短剧发行",
region: "CN",
billingEmail: "ops@example.local",
taxId: "",
notes: "用于验证《雷雨口》商用交付、客户门户和授权范围清算。",
tags: ["发行", "本地交付", "商用清算"],
metadata: { source: "seed://customers/xinghe-release", preferredDelivery: "private-delivery-portal" },
createdBy: "u-owner"
},
{
id: "cust-northstar-platform",
organizationId: "org-northstar",
name: "北辰内部发行台",
legalName: "北辰内容厂牌(模拟)",
code: "NS-INTERNAL",
customerType: "internal",
status: "active",
industry: "内容厂牌",
region: "CN",
billingEmail: "northstar@example.local",
taxId: "",
notes: "用于验证北辰组织的独立合同和跨组织隔离。",
tags: ["内部试播", "租户隔离"],
metadata: { source: "seed://customers/northstar-internal" },
createdBy: "u-producer"
}
];
for (const customer of customers) {
insertIgnore(
`INSERT OR IGNORE INTO customers(
id, organization_id, name, legal_name, code, customer_type, status, industry, region,
billing_email, tax_id, notes, tags_json, metadata_json, created_by, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
customer.id,
customer.organizationId,
customer.name,
customer.legalName,
customer.code,
customer.customerType,
customer.status,
customer.industry,
customer.region,
customer.billingEmail,
customer.taxId,
customer.notes,
JSON.stringify(customer.tags),
JSON.stringify(customer.metadata),
customer.createdBy,
timestamp,
timestamp
]
);
}
const contacts = [
["contact-xinghe-release-producer", "org-studio-lab", "cust-xinghe-release", "星河发行制片经理", "交付验收", "producer@example.local", "", 1],
["contact-xinghe-release-legal", "org-studio-lab", "cust-xinghe-release", "星河法务窗口", "合同授权复核", "legal@example.local", "", 0],
["contact-northstar-owner", "org-northstar", "cust-northstar-platform", "北辰试制负责人", "内部验收", "pilot@example.local", "", 1]
];
for (const contact of contacts) {
insertIgnore(
"INSERT OR IGNORE INTO customer_contacts(id, organization_id, customer_id, name, role, email, phone, is_primary, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)",
[...contact, timestamp, timestamp]
);
}
const contracts = [
{
id: "contract-xh-rain-night-2026",
organizationId: "org-studio-lab",
customerId: "cust-xinghe-release",
contractNumber: "XH-AIDRAMA-2026-001",
title: "《雷雨口》AI 漫剧本地化制作与交付授权",
contractType: "production_license",
status: "active",
approvalStatus: "approved",
effectiveAt: "2026-08-01T00:00:00.000Z",
expiresAt: "2028-12-31T23:59:59.000Z",
signedAt: "2026-08-15T09:30:00.000Z",
amount: 0,
licenseScope: {
channels: ["local-file", "local-webhook", "private-delivery-portal"],
territories: ["CN", "GLOBAL"],
deliverables: ["vertical-video", "prompt-pack", "subtitle", "delivery-manifest"],
platforms: ["private-preview", "internal-review", "owned-channel"],
exclusive: false,
commercialUse: true,
language: ["zh-CN"]
},
rights: {
originalStory: true,
derivativeProduction: true,
aiGeneratedAssetsAllowed: true,
voiceCloneAllowed: false,
fixedVoiceRequired: true,
singleFramePolicyRequired: true
},
deliveryTerms: {
maxAccessDays: 90,
customerPortalAllowed: true,
watermarkRequiredBeforeApproval: true,
requireClearanceCertificate: true
},
evidence: {
contractRef: "seed://contract/xh-2026-aidrama",
rightsEvidenceRef: "seed://original/rain-night",
signedCopyPath: "storage/contracts/xh-2026-aidrama/contract-summary.json"
},
risk: { paidCloudAllowed: false, publicModelAllowed: false, rightsReview: "passed" },
approvalNote: "种子合同用于本地商用闭环验证:固定声线、一图一画面、原创权利证据均为强制项。",
createdBy: "u-owner"
},
{
id: "contract-ns-pilot-2026",
organizationId: "org-northstar",
customerId: "cust-northstar-platform",
contractNumber: "NS-PILOT-2026-001",
title: "《山海志异·试播集》内部试制授权",
contractType: "internal",
status: "active",
approvalStatus: "approved",
effectiveAt: "2026-08-10T00:00:00.000Z",
expiresAt: "2027-12-31T23:59:59.000Z",
signedAt: "2026-08-10T10:00:00.000Z",
amount: 0,
licenseScope: {
channels: ["local-file", "private-delivery-portal"],
territories: ["CN"],
deliverables: ["vertical-video", "delivery-manifest"],
platforms: ["internal-review"],
exclusive: false,
commercialUse: true,
language: ["zh-CN"]
},
rights: {
originalStory: true,
derivativeProduction: true,
aiGeneratedAssetsAllowed: true,
voiceCloneAllowed: false,
fixedVoiceRequired: true,
singleFramePolicyRequired: true
},
deliveryTerms: {
maxAccessDays: 30,
customerPortalAllowed: true,
watermarkRequiredBeforeApproval: true,
requireClearanceCertificate: true
},
evidence: {
contractRef: "seed://contract/ns-pilot-2026",
rightsEvidenceRef: "seed://original/northstar-pilot",
signedCopyPath: "storage/contracts/ns-pilot-2026/contract-summary.json"
},
risk: { paidCloudAllowed: false, publicModelAllowed: false, rightsReview: "passed" },
approvalNote: "北辰组织独立授权,不允许被星河组织项目绑定。",
createdBy: "u-producer"
}
];
for (const contract of contracts) {
insertIgnore(
`INSERT OR IGNORE INTO production_contracts(
id, organization_id, customer_id, contract_number, title, contract_type, status,
approval_status, effective_at, expires_at, signed_at, currency, amount,
license_scope_json, rights_json, delivery_terms_json, evidence_json, risk_json,
approval_note, created_by, updated_by, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'CNY', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
contract.id,
contract.organizationId,
contract.customerId,
contract.contractNumber,
contract.title,
contract.contractType,
contract.status,
contract.approvalStatus,
contract.effectiveAt,
contract.expiresAt,
contract.signedAt,
contract.amount,
JSON.stringify(contract.licenseScope),
JSON.stringify(contract.rights),
JSON.stringify(contract.deliveryTerms),
JSON.stringify(contract.evidence),
JSON.stringify(contract.risk),
contract.approvalNote,
contract.createdBy,
contract.createdBy,
timestamp,
timestamp
]
);
}
const bindings = [
["contract-binding-thunder-mouth-xh", "org-studio-lab", "ws-local-aidrama", "thunder-mouth", "contract-xh-rain-night-2026", "cust-xinghe-release", "primary-license", "active", "《雷雨口》默认商业交付授权。", "u-owner"],
["contract-binding-northstar-pilot", "org-northstar", "ws-northstar-main", "northstar-pilot", "contract-ns-pilot-2026", "cust-northstar-platform", "internal-review", "active", "北辰内部试播项目授权。", "u-producer"]
];
for (const binding of bindings) {
insertIgnore(
"INSERT OR IGNORE INTO contract_project_bindings(id, organization_id, workspace_id, project_id, contract_id, customer_id, usage_mode, status, notes, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[...binding, timestamp, timestamp]
);
}
const events = [
["contract-event-xh-seed-created", "org-studio-lab", "contract-xh-rain-night-2026", "cust-xinghe-release", "ws-local-aidrama", "thunder-mouth", "seeded", "recorded", "u-owner", { action: "seed", bindingId: "contract-binding-thunder-mouth-xh", channelKinds: ["local-file", "private-delivery-portal"] }],
["contract-event-ns-seed-created", "org-northstar", "contract-ns-pilot-2026", "cust-northstar-platform", "ws-northstar-main", "northstar-pilot", "seeded", "recorded", "u-producer", { action: "seed", bindingId: "contract-binding-northstar-pilot" }]
];
for (const event of events) {
insertIgnore(
"INSERT OR IGNORE INTO contract_license_events(id, organization_id, contract_id, customer_id, workspace_id, project_id, event_type, status, actor_user_id, metadata_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[...event.slice(0, 9), JSON.stringify(event[9]), timestamp]
);
}
}
function seedMembersAndProjects() {
const timestamp = now();
const membershipRows = [
@@ -1271,6 +1512,7 @@ withTransaction(() => {
seedOrganizationEntitlements();
rollQuotaPeriods();
seedCommercialApprovals();
seedCustomersAndContracts();
seedMembersAndProjects();
seedModels();
seedModelCatalog();
+20
View File
@@ -3,6 +3,7 @@ import { createHash, randomBytes } from "node:crypto";
import { basename, dirname, relative, resolve } from "node:path";
import { dbAll, dbGet, dbRun } from "./db.mjs";
import { addAudit, hasPermission, httpError, requireEntitlement, requirePermission } from "./tenant.mjs";
import { evaluateProjectContractClearance, recordContractLicenseEvent } from "./contracts.mjs";
const projectRoot = resolve(import.meta.dirname, "..");
const defaultExpiryDays = 7;
@@ -287,6 +288,25 @@ export function createDeliveryAccessLink(context, releaseId, body = {}, options
if (release.status !== "published" || !release.output_path) {
throw httpError(409, "delivery_access_release_not_published", "只有已发布且存在发布产物的版本可以创建客户访问链接", { releaseId, status: release.status });
}
const contractClearance = evaluateProjectContractClearance(context, context.project.id, {
channelKind: "private-delivery-portal",
deliverables: ["vertical-video", "delivery-manifest"],
territory: body.territory || "CN"
});
recordContractLicenseEvent(context, {
projectId: context.project.id,
eventType: "contract.delivery_access.preflight",
status: contractClearance.status,
metadata: {
releaseId,
channelKind: "private-delivery-portal",
blockers: contractClearance.blockers.length,
reviewItems: contractClearance.reviewItems.length
}
});
if (contractClearance.status === "blocked") {
throw httpError(409, "delivery_access_contract_blocked", "客户访问链接未通过合同授权清算", { clearance: contractClearance, blockers: contractClearance.blockers });
}
const releaseFile = safeStoragePath(release.output_path);
if (!releaseFile || basename(releaseFile.relative) !== "release.json") {
throw httpError(409, "delivery_access_release_file_invalid", "发布产物路径不满足交付门户安全约束");
+63
View File
@@ -160,6 +160,7 @@ import { backupSummary, createDatabaseBackup } from "./backup.mjs";
import { systemReadiness } from "./readiness.mjs";
import { bindProjectStyleKit, createStyleKit, getProjectStyleKit, getStyleKit, listStyleKits, updateStyleKit } from "./style-kits.mjs";
import { evaluateAndRecordOrganizationPolicies, listOrganizationPolicies, updateOrganizationPolicy } from "./organization-policies.mjs";
import { bindContractProject, createContract, createCustomer, evaluateProjectContractClearance, listContracts, listCustomers, updateContract, updateCustomer, upsertCustomerContact } from "./contracts.mjs";
import { dispatchNotificationEvent, listUserNotificationPreferences, listUserNotifications, markAllUserNotificationsRead, markUserNotificationRead, notificationDeliveries, updateUserNotificationPreference } from "./notifications.mjs";
import { composeProject, listCompositions } from "./composition.mjs";
import { listProjectArtifacts, readArtifactContent } from "./media-artifacts.mjs";
@@ -1717,6 +1718,68 @@ createServer(async (req, res) => {
return send(res, 200, organizationCommercial(context));
}
const organizationCustomersMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/customers$/);
if (req.method === "GET" && organizationCustomersMatch) {
const organizationId = decodeURIComponent(organizationCustomersMatch[1]);
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
return send(res, 200, listCustomers(context, organizationId, Object.fromEntries(url.searchParams.entries())));
}
if (req.method === "POST" && organizationCustomersMatch) {
const organizationId = decodeURIComponent(organizationCustomersMatch[1]);
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
return send(res, 201, createCustomer(context, organizationId, await readBody(req)));
}
const organizationCustomerMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/customers\/([^/]+)$/);
if (req.method === "PATCH" && organizationCustomerMatch) {
const organizationId = decodeURIComponent(organizationCustomerMatch[1]);
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
return send(res, 200, updateCustomer(context, organizationId, decodeURIComponent(organizationCustomerMatch[2]), await readBody(req)));
}
const organizationCustomerContactsMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/customers\/([^/]+)\/contacts$/);
if (req.method === "POST" && organizationCustomerContactsMatch) {
const organizationId = decodeURIComponent(organizationCustomerContactsMatch[1]);
const customerId = decodeURIComponent(organizationCustomerContactsMatch[2]);
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
return send(res, 201, upsertCustomerContact(context, organizationId, customerId, await readBody(req)));
}
const organizationContractClearanceMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/contracts\/clearance$/);
if (req.method === "GET" && organizationContractClearanceMatch) {
const organizationId = decodeURIComponent(organizationContractClearanceMatch[1]);
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
return send(res, 200, { clearance: evaluateProjectContractClearance(context, url.searchParams.get("projectId") || url.searchParams.get("project_id") || "", Object.fromEntries(url.searchParams.entries())) });
}
const organizationContractsMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/contracts$/);
if (req.method === "GET" && organizationContractsMatch) {
const organizationId = decodeURIComponent(organizationContractsMatch[1]);
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
return send(res, 200, listContracts(context, organizationId, Object.fromEntries(url.searchParams.entries())));
}
if (req.method === "POST" && organizationContractsMatch) {
const organizationId = decodeURIComponent(organizationContractsMatch[1]);
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
return send(res, 201, createContract(context, organizationId, await readBody(req)));
}
const organizationContractBindMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/contracts\/([^/]+)\/bind-project$/);
if (req.method === "POST" && organizationContractBindMatch) {
const organizationId = decodeURIComponent(organizationContractBindMatch[1]);
const contractId = decodeURIComponent(organizationContractBindMatch[2]);
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
return send(res, 200, bindContractProject(context, organizationId, contractId, await readBody(req)));
}
const organizationContractMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/contracts\/([^/]+)$/);
if (req.method === "PATCH" && organizationContractMatch) {
const organizationId = decodeURIComponent(organizationContractMatch[1]);
const contractId = decodeURIComponent(organizationContractMatch[2]);
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
return send(res, 200, updateContract(context, organizationId, contractId, await readBody(req)));
}
const organizationEntitlementsMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/entitlements$/);
if (req.method === "GET" && organizationEntitlementsMatch) {
const organizationId = decodeURIComponent(organizationEntitlementsMatch[1]);
+14 -4
View File
@@ -3,6 +3,7 @@ import { addAudit, addUsage, hasPermission, httpError, requireEntitlement, requi
import { inspectMediaForProject } from "./media-qa.mjs";
import { latestArtifactForShot, listProjectArtifacts, syncProjectJobArtifacts } from "./media-artifacts.mjs";
import { dispatchNotificationEvent } from "./notifications.mjs";
import { evaluateProjectContractClearance } from "./contracts.mjs";
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
@@ -908,8 +909,14 @@ function buildDeliveryClearanceReport(context, delivery, options = {}) {
const knowledge = knowledgeClearanceSection(context);
const qa = qaClearanceSection(context);
const jobs = jobClearanceSection(context);
const blockers = [...media.blockers, ...assets.blockers, ...voices.blockers, ...knowledge.blockers, ...qa.blockers, ...jobs.blockers];
const reviewItems = [...media.reviewItems, ...assets.reviewItems, ...voices.reviewItems, ...knowledge.reviewItems, ...qa.reviewItems, ...jobs.reviewItems];
const contracts = evaluateProjectContractClearance(context, project.id, {
channel: options.channel || null,
channelKind: options.channel?.kind || "",
deliverables: ["vertical-video", "delivery-manifest"],
territory: options.territory || "CN"
});
const blockers = [...media.blockers, ...assets.blockers, ...voices.blockers, ...knowledge.blockers, ...qa.blockers, ...jobs.blockers, ...contracts.blockers];
const reviewItems = [...media.reviewItems, ...assets.reviewItems, ...voices.reviewItems, ...knowledge.reviewItems, ...qa.reviewItems, ...jobs.reviewItems, ...contracts.reviewItems];
const status = clearanceStatus(blockers, reviewItems);
const checkedAt = now();
const baseCertificate = {
@@ -931,6 +938,7 @@ function buildDeliveryClearanceReport(context, delivery, options = {}) {
approvedKnowledgeRequired: true,
fixedVoiceEvidenceRequired: true,
actualLastFrameRequired: true,
contractAuthorizationRequired: true,
paidCloudPublishDisabled: true
},
counts: {
@@ -941,7 +949,8 @@ function buildDeliveryClearanceReport(context, delivery, options = {}) {
knowledgeMaterials: knowledge.materials.length,
qaReviews: qa.reviews.length,
mediaItems: media.items.length,
pendingJobs: jobs.jobs.length
pendingJobs: jobs.jobs.length,
contractBindings: contracts.counts?.activeBindings || 0
}
};
const certificateId = `clearance-${jsonHash(baseCertificate).slice(0, 16)}`;
@@ -966,7 +975,8 @@ function buildDeliveryClearanceReport(context, delivery, options = {}) {
voices: voices.voices,
knowledge,
qa,
jobs
jobs,
contracts
},
certificate
};
+99
View File
@@ -561,6 +561,98 @@ CREATE TABLE IF NOT EXISTS commercial_approval_requests (
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS customers (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
legal_name TEXT NOT NULL DEFAULT '',
code TEXT NOT NULL,
customer_type TEXT NOT NULL DEFAULT 'platform' CHECK (customer_type IN ('platform', 'brand', 'agency', 'distributor', 'internal')),
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('prospect', 'active', 'paused', 'archived')),
industry TEXT NOT NULL DEFAULT '',
region TEXT NOT NULL DEFAULT '',
billing_email TEXT NOT NULL DEFAULT '',
tax_id TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
tags_json TEXT NOT NULL DEFAULT '[]',
metadata_json TEXT NOT NULL DEFAULT '{}',
created_by TEXT NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (organization_id, code)
);
CREATE TABLE IF NOT EXISTS customer_contacts (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
customer_id TEXT NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '',
is_primary INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS production_contracts (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
customer_id TEXT NOT NULL REFERENCES customers(id) ON DELETE RESTRICT,
contract_number TEXT NOT NULL,
title TEXT NOT NULL,
contract_type TEXT NOT NULL DEFAULT 'production_license' CHECK (contract_type IN ('production_license', 'distribution', 'work_for_hire', 'revenue_share', 'internal')),
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'submitted', 'active', 'expiring', 'expired', 'suspended', 'terminated')),
approval_status TEXT NOT NULL DEFAULT 'draft' CHECK (approval_status IN ('draft', 'legal_review', 'approved', 'blocked')),
effective_at TEXT,
expires_at TEXT,
signed_at TEXT,
currency TEXT NOT NULL DEFAULT 'CNY',
amount REAL NOT NULL DEFAULT 0,
license_scope_json TEXT NOT NULL DEFAULT '{}',
rights_json TEXT NOT NULL DEFAULT '{}',
delivery_terms_json TEXT NOT NULL DEFAULT '{}',
evidence_json TEXT NOT NULL DEFAULT '{}',
risk_json TEXT NOT NULL DEFAULT '{}',
approval_note TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL REFERENCES users(id),
updated_by TEXT REFERENCES users(id),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (organization_id, contract_number)
);
CREATE TABLE IF NOT EXISTS contract_project_bindings (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
contract_id TEXT NOT NULL REFERENCES production_contracts(id) ON DELETE CASCADE,
customer_id TEXT NOT NULL REFERENCES customers(id) ON DELETE RESTRICT,
usage_mode TEXT NOT NULL DEFAULT 'primary-license' CHECK (usage_mode IN ('primary-license', 'supplemental-rights', 'delivery-only', 'internal-review')),
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'review', 'blocked', 'archived')),
notes TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (organization_id, project_id, contract_id)
);
CREATE TABLE IF NOT EXISTS contract_license_events (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
contract_id TEXT REFERENCES production_contracts(id) ON DELETE CASCADE,
customer_id TEXT REFERENCES customers(id) ON DELETE SET NULL,
workspace_id TEXT REFERENCES workspaces(id) ON DELETE SET NULL,
project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
event_type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'recorded',
actor_user_id TEXT REFERENCES users(id),
metadata_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL
);
-- Local invoice ledger. This is intentionally payment-provider agnostic: the
-- platform records billing snapshots and lifecycle state, while an external
-- accounting or payment system can be connected later through an adapter.
@@ -1594,6 +1686,13 @@ CREATE INDEX IF NOT EXISTS idx_delivery_access_events_link ON delivery_access_ev
CREATE INDEX IF NOT EXISTS idx_delivery_access_events_release ON delivery_access_events(release_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_delivery_access_feedback_link ON delivery_access_feedback(link_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_delivery_access_feedback_release ON delivery_access_feedback(release_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_customers_org_status ON customers(organization_id, status, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_customer_contacts_customer ON customer_contacts(customer_id, is_primary DESC, status);
CREATE INDEX IF NOT EXISTS idx_production_contracts_org_status ON production_contracts(organization_id, status, approval_status, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_production_contracts_customer ON production_contracts(customer_id, status, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_contract_project_bindings_project ON contract_project_bindings(organization_id, workspace_id, project_id, status);
CREATE INDEX IF NOT EXISTS idx_contract_project_bindings_contract ON contract_project_bindings(contract_id, status, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_contract_license_events_scope ON contract_license_events(organization_id, contract_id, project_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_service_health_status ON service_health(status, updated_at);
CREATE INDEX IF NOT EXISTS idx_auth_sessions_user ON auth_sessions(user_id, expires_at, revoked_at);
CREATE INDEX IF NOT EXISTS idx_mfa_challenges_hash ON auth_mfa_challenges(challenge_hash, expires_at, consumed_at);
+12 -7
View File
@@ -824,6 +824,8 @@ function entitlementUsageValue(organizationId, key) {
if (key === "limit.workspaces") return Number(dbGet("SELECT COUNT(*) AS count FROM workspaces WHERE organization_id = ? AND status = 'active'", [organizationId])?.count || 0);
if (key === "limit.projects") return Number(dbGet("SELECT COUNT(*) AS count FROM projects p JOIN workspaces w ON w.id = p.workspace_id WHERE w.organization_id = ? AND p.status != 'archived'", [organizationId])?.count || 0);
if (key === "limit.style_kits") return Number(dbGet("SELECT COUNT(*) AS count FROM style_kits WHERE organization_id = ? AND status != 'archived'", [organizationId])?.count || 0);
if (key === "limit.customers") return Number(dbGet("SELECT COUNT(*) AS count FROM customers WHERE organization_id = ? AND status != 'archived'", [organizationId])?.count || 0);
if (key === "limit.contracts") return Number(dbGet("SELECT COUNT(*) AS count FROM production_contracts WHERE organization_id = ? AND status != 'terminated'", [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);
@@ -876,6 +878,8 @@ function defaultEntitlementRows(organizationId) {
["limit.projects", "项目数量", "production", 36, "个", 1, "block", { commercialGate: "project:create" }],
["limit.generation_jobs_monthly", "月度生成任务", "production", Number(billing.monthly_clip_quota || 2400), "job", 1, "block", { commercialGate: "generation_job:create" }],
["limit.style_kits", "风格生产标准", "production", 24, "套", 1, "block", { commercialGate: "style_kit:create" }],
["limit.customers", "客户主档", "commercial", 120, "个", 1, "block", { commercialGate: "customer:create" }],
["limit.contracts", "合同授权", "commercial", 240, "份", 1, "block", { commercialGate: "contract: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" }],
@@ -910,13 +914,14 @@ export function organizationEntitlements(organizationId) {
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
WHEN 'commercial' THEN 2
WHEN 'knowledge' THEN 3
WHEN 'modelops' THEN 4
WHEN 'delivery' THEN 5
WHEN 'storage' THEN 6
WHEN 'system' THEN 7
WHEN 'feature' THEN 8
ELSE 9
END, entitlement_key`,
[organizationId]
).map((row) => entitlementPayload(row, organizationId));