Files

410 lines
18 KiB
JavaScript

import { dbAll, dbGet, dbRun } from "./db.mjs";
const makeId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
function parseJson(value, fallback) {
try {
return JSON.parse(value);
} catch {
return fallback;
}
}
function privateHost(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);
}
async function fetchWithTimeout(url, options, timeoutMs = 8000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...options, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
function eventMatches(channel, eventKey) {
const events = parseJson(channel.events_json, []);
return events.includes("*") || events.includes(eventKey);
}
function notificationError(status, code, message, details = {}) {
const error = new Error(message);
error.status = status;
error.code = code;
error.details = details;
return error;
}
const NOTIFICATION_PREFERENCE_CATALOG = [
{ category: "job", label: "生成任务", description: "本地任务完成、失败和重试状态" },
{ category: "review", label: "审片流程", description: "质检通过、驳回、修改和新评论" },
{ category: "delivery", label: "交付运营", description: "交付版本创建、批准和回滚" },
{ category: "task", label: "协作任务", description: "任务分派、状态变更和截止提醒" },
{ category: "access", label: "组织访问", description: "邀请、成员和权限相关事件" },
{ category: "billing", label: "用量与配额", description: "配额预警和成本边界" },
{ category: "system", label: "系统通知", description: "平台策略和系统范围变更" }
];
function notificationDefinition(eventKey, payload = {}) {
const kind = String(payload.kind || "生成任务");
const shot = payload.shotTitle || payload.shotId || "当前镜头";
const error = String(payload.error || "模型连接器返回了失败结果").slice(0, 180);
const lane = payload.lane || "连续性检查";
const version = payload.version || "交付版本";
const definitions = {
"job.completed": {
category: "job",
severity: "success",
title: `${kind}已完成`,
body: `${shot} 已完成本地执行,产出 ${Number(payload.artifactCount || 0)} 个可检查文件。`,
targetTab: "jobs"
},
"job.failed": {
category: "job",
severity: "error",
title: `${kind}生成失败`,
body: `${shot} 的本地任务失败:${error}`,
targetTab: "jobs"
},
"review.approved": {
category: "review",
severity: "success",
title: `${lane}已通过`,
body: `${shot} 的审片门已通过,可以继续下一步生产。`,
targetTab: "qa"
},
"review.changes_requested": {
category: "review",
severity: "warning",
title: `${lane}需要修改`,
body: `${shot} 的审片门要求补充证据或修改后再提交。`,
targetTab: "qa"
},
"review.rejected": {
category: "review",
severity: "error",
title: `${lane}已驳回`,
body: `${shot} 未通过审片,请查看阻断项并重新提交。`,
targetTab: "qa"
},
"review.comment": {
category: "review",
severity: "info",
title: "审片中心有新评论",
body: `${shot} 收到新的审片意见,请回到审片中心查看。`,
targetTab: "qa"
},
"delivery.created": {
category: "delivery",
severity: "info",
title: `${version} 已创建`,
body: "新的内部交付版本已建立,等待批次证据和审片结果。",
targetTab: "export"
},
"delivery.approved": {
category: "delivery",
severity: "success",
title: `${version} 已批准交付`,
body: "交付版本已通过所有阻断门,可以进入本地发布或导出流程。",
targetTab: "export"
},
"task.assigned": {
category: "task",
severity: "info",
title: "你收到一个协作任务",
body: `任务“${payload.taskTitle || "未命名任务"}”已分配给你,请在任务中心确认负责人和截止时间。`,
targetTab: "tasks"
},
"task.updated": {
category: "task",
severity: payload.status === "blocked" ? "warning" : payload.status === "done" ? "success" : "info",
title: "协作任务状态已更新",
body: `任务“${payload.taskTitle || "未命名任务"}”当前状态:${payload.status || "已更新"}。`,
targetTab: "tasks"
},
"task.commented": {
category: "task",
severity: "info",
title: "协作任务有新讨论",
body: `任务“${payload.taskTitle || "未命名任务"}”收到新的评论或 @提醒,请打开任务详情查看。`,
targetTab: "tasks"
},
"invitation.created": {
category: "access",
severity: "info",
title: "你收到新的组织邀请",
body: `${payload.organizationName || "一个生产组织"} 邀请你以 ${payload.roleName || payload.roleKey || "成员"} 身份加入。`,
targetTab: "account"
},
"system.changed": {
category: "system",
severity: "info",
title: "系统配置已更新",
body: payload.detail || "系统范围配置发生了变化,请确认当前生产策略。",
targetTab: payload.targetTab || "system-overview"
},
"quota.warning": {
category: "billing",
severity: "warning",
title: "组织配额即将达到上限",
body: payload.detail || "请联系组织管理员调整配额或清理不再需要的产物。",
targetTab: "admin-usage"
}
};
return definitions[eventKey] || {
category: String(payload.category || "system"),
severity: String(payload.severity || "info"),
title: String(payload.title || eventKey || "平台通知"),
body: String(payload.body || "生产平台收到一条新的事件通知。"),
targetTab: String(payload.targetTab || "creator-home")
};
}
function scopedRecipientIds(context, eventKey, payload = {}) {
const requested = [
...(Array.isArray(payload.recipientUserIds) ? payload.recipientUserIds : []),
...(payload.recipientUserId ? [payload.recipientUserId] : [])
].map((value) => String(value || "").trim()).filter(Boolean);
if (requested.length) {
const placeholders = requested.map(() => "?").join(",");
return dbAll(
`SELECT DISTINCT u.id
FROM users u
JOIN organization_members om ON om.user_id = u.id
WHERE u.status = 'active' AND om.organization_id = ? AND om.status = 'active'
AND u.id IN (${placeholders})`,
[context.organization.id, ...requested]
).map((row) => row.id);
}
if (!context?.organization?.id || !context?.user?.id) return [];
if (eventKey.startsWith("system.")) return [context.user.id];
if (eventKey === "invitation.created") return payload.recipientUserId ? [String(payload.recipientUserId)] : [context.user.id];
return dbAll(
`SELECT DISTINCT u.id
FROM users u
JOIN organization_members om ON om.user_id = u.id
LEFT JOIN workspace_members wm ON wm.user_id = u.id AND wm.workspace_id = ? AND wm.status = 'active'
LEFT JOIN project_members pm ON pm.user_id = u.id AND pm.project_id = ? AND pm.status = 'active'
WHERE u.status = 'active' AND om.organization_id = ? AND om.status = 'active'
AND (om.role_key IN ('org_owner', 'org_admin') OR wm.user_id IS NOT NULL OR pm.user_id IS NOT NULL)`,
[context.workspace?.id || "", context.project?.id || "", context.organization.id]
).map((row) => row.id);
}
function userNotificationPayload(row) {
return {
id: row.id,
category: row.category,
eventKey: row.event_key,
severity: row.severity,
title: row.title,
body: row.body,
targetTab: row.target_tab,
targetId: row.target_id || "",
metadata: parseJson(row.metadata_json, {}),
createdAt: row.created_at,
readAt: row.read_at,
read: Boolean(row.read_at),
organizationId: row.organization_id,
workspaceId: row.workspace_id,
projectId: row.project_id
};
}
function preferenceCatalogItem(row) {
const definition = NOTIFICATION_PREFERENCE_CATALOG.find((item) => item.category === row.category) || { category: row.category, label: row.category, description: "" };
return {
...definition,
enabled: row.in_app_enabled === undefined ? true : Boolean(row.in_app_enabled),
customized: row.in_app_enabled !== undefined
};
}
function inAppScope(context) {
if (!context?.user?.id || !context?.organization?.id) throw notificationError(401, "auth_required", "请先登录");
return {
where: "user_id = ? AND organization_id = ? AND (workspace_id IS NULL OR workspace_id = ?)",
params: [context.user.id, context.organization.id, context.workspace?.id || ""]
};
}
function createUserNotifications({ context, eventKey, payload = {}, definition }) {
const recipientIds = scopedRecipientIds(context, eventKey, payload);
if (!recipientIds.length) return [];
const timestamp = new Date().toISOString();
const metadata = { ...payload };
delete metadata.recipientUserId;
delete metadata.recipientUserIds;
const created = [];
for (const userId of recipientIds) {
const preference = dbGet("SELECT in_app_enabled FROM user_notification_preferences WHERE user_id = ? AND organization_id = ? AND category = ?", [userId, context.organization.id, definition.category]);
if (preference && !Boolean(preference.in_app_enabled)) continue;
const id = makeId("user-notification");
dbRun(
"INSERT INTO user_notifications(id, user_id, organization_id, workspace_id, project_id, category, event_key, severity, title, body, target_tab, target_id, metadata_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[
id,
userId,
context.organization.id,
context.workspace?.id || null,
context.project?.id || null,
definition.category,
eventKey,
definition.severity,
definition.title,
definition.body,
definition.targetTab,
String(payload.targetId || payload.reviewId || payload.jobId || payload.deliveryId || ""),
JSON.stringify(metadata),
timestamp
]
);
created.push(dbGet("SELECT * FROM user_notifications WHERE id = ?", [id]));
}
return created;
}
export function listUserNotificationPreferences(context) {
const scope = inAppScope(context);
const rows = dbAll("SELECT category, in_app_enabled FROM user_notification_preferences WHERE user_id = ? AND organization_id = ?", [scope.params[0], scope.params[1]]);
const byCategory = new Map(rows.map((row) => [row.category, row]));
return {
preferences: NOTIFICATION_PREFERENCE_CATALOG.map((definition) => preferenceCatalogItem(byCategory.get(definition.category) || { category: definition.category })),
scope: { organizationId: context.organization.id },
generatedAt: new Date().toISOString()
};
}
export function updateUserNotificationPreference(context, category, enabled) {
inAppScope(context);
const definition = NOTIFICATION_PREFERENCE_CATALOG.find((item) => item.category === String(category || "").trim());
if (!definition) throw notificationError(400, "notification_category_invalid", "通知类别不存在", { category });
dbRun(
"INSERT INTO user_notification_preferences(user_id, organization_id, category, in_app_enabled, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(user_id, organization_id, category) DO UPDATE SET in_app_enabled = excluded.in_app_enabled, updated_at = excluded.updated_at",
[context.user.id, context.organization.id, definition.category, enabled ? 1 : 0, new Date().toISOString()]
);
return listUserNotificationPreferences(context);
}
export function listUserNotifications(context, { limit = 60, unreadOnly = false } = {}) {
const scope = inAppScope(context);
const normalizedLimit = Math.max(1, Math.min(200, Number(limit || 60)));
const unreadClause = unreadOnly ? " AND read_at IS NULL" : "";
const rows = dbAll(
`SELECT * FROM user_notifications
WHERE ${scope.where}${unreadClause}
ORDER BY CASE WHEN read_at IS NULL THEN 0 ELSE 1 END, created_at DESC
LIMIT ?`,
[...scope.params, normalizedLimit]
);
const unreadCount = Number(dbGet(`SELECT COUNT(*) AS count FROM user_notifications WHERE ${scope.where} AND read_at IS NULL`, scope.params)?.count || 0);
return {
notifications: rows.map(userNotificationPayload),
unreadCount,
total: rows.length,
scope: { organizationId: context.organization.id, workspaceId: context.workspace?.id || "" },
generatedAt: new Date().toISOString()
};
}
export function markUserNotificationRead(context, notificationId, read = true) {
const scope = inAppScope(context);
const current = dbGet(`SELECT * FROM user_notifications WHERE id = ? AND ${scope.where}`, [notificationId, ...scope.params]);
if (!current) throw notificationError(404, "notification_not_found", "通知不存在或不属于当前工作区");
dbRun(`UPDATE user_notifications SET read_at = ? WHERE id = ? AND ${scope.where}`, [read ? new Date().toISOString() : null, notificationId, ...scope.params]);
return listUserNotifications(context, { limit: 60 });
}
export function markAllUserNotificationsRead(context) {
const scope = inAppScope(context);
dbRun(`UPDATE user_notifications SET read_at = COALESCE(read_at, ?) WHERE ${scope.where} AND read_at IS NULL`, [new Date().toISOString(), ...scope.params]);
return listUserNotifications(context, { limit: 60 });
}
async function deliver(channel, eventKey, requestBody) {
const id = makeId("delivery");
const timestamp = new Date().toISOString();
dbRun("INSERT INTO notification_deliveries(id, channel_id, event_key, organization_id, workspace_id, project_id, status, attempt_count, request_json, created_at) VALUES (?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?)", [id, channel.id, eventKey, requestBody.organizationId || null, requestBody.workspaceId || null, requestBody.projectId || null, JSON.stringify(requestBody), timestamp]);
let status = "delivered";
let response = { kind: channel.kind };
let errorMessage = "";
let httpStatus = null;
try {
if (channel.kind === "local-log") {
response = { logged: true, event: eventKey };
} else if (channel.kind === "webhook") {
const endpoint = new URL(String(channel.endpoint || ""));
if (endpoint.protocol !== "http:" || !privateHost(endpoint.hostname)) {
status = "blocked";
errorMessage = "本地策略只允许投递到 HTTP 私有地址";
} else {
const result = await fetchWithTimeout(endpoint.toString(), {
method: "POST",
headers: { "content-type": "application/json", "x-ai-drama-event": eventKey },
body: JSON.stringify(requestBody)
});
httpStatus = result.status;
const raw = await result.text();
response = { httpStatus, body: raw.slice(0, 1000) };
if (!result.ok) {
status = "failed";
errorMessage = `Webhook 返回 HTTP ${result.status}`;
}
}
} else {
status = "blocked";
errorMessage = `通知渠道类型 ${channel.kind} 暂不支持`;
}
} catch (error) {
status = "failed";
errorMessage = error.name === "AbortError" ? "通知投递超时" : String(error.message || error).slice(0, 500);
}
const deliveredAt = status === "delivered" ? new Date().toISOString() : null;
dbRun("UPDATE notification_deliveries SET status = ?, attempt_count = 1, response_json = ?, error_message = ?, delivered_at = ? WHERE id = ?", [status, JSON.stringify({ ...response, httpStatus }), errorMessage, deliveredAt, id]);
return dbGet("SELECT * FROM notification_deliveries WHERE id = ?", [id]);
}
export async function dispatchNotificationEvent({ context, eventKey, payload = {} }) {
const definition = notificationDefinition(eventKey, payload);
const userNotifications = createUserNotifications({ context, eventKey, payload, definition });
const channels = dbAll("SELECT * FROM notification_channels WHERE enabled = 1 ORDER BY created_at").filter((channel) => eventMatches(channel, eventKey));
const requestBody = {
schema: "ai-drama.notification.v1",
event: eventKey,
occurredAt: new Date().toISOString(),
actor: context?.user ? { id: context.user.id, name: context.user.display_name } : null,
organizationId: context?.organization?.id || null,
workspaceId: context?.workspace?.id || null,
projectId: context?.project?.id || null,
payload
};
const deliveries = [];
for (const channel of channels) deliveries.push(await deliver(channel, eventKey, requestBody));
return { deliveries, userNotifications: userNotifications.map(userNotificationPayload) };
}
export function notificationDeliveries(context, limit = 80) {
return dbAll(
`SELECT d.*, c.name AS channel_name, c.kind AS channel_kind
FROM notification_deliveries d
JOIN notification_channels c ON c.id = d.channel_id
WHERE d.organization_id = ?
AND (d.workspace_id IS NULL OR d.workspace_id = ?)
AND (d.project_id IS NULL OR d.project_id = ?)
ORDER BY d.created_at DESC LIMIT ?`,
[context.organization.id, context.workspace.id, context.project?.id || "", Math.max(1, Math.min(200, Number(limit || 80)))]
).map((row) => ({
...row,
request: parseJson(row.request_json, {}),
response: parseJson(row.response_json, {})
}));
}