Files
ai-drama-platform/server/delivery-portal.mjs
T

548 lines
24 KiB
JavaScript

import { readFile } from "node:fs/promises";
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";
const projectRoot = resolve(import.meta.dirname, "..");
const defaultExpiryDays = 7;
const maxExpiryDays = 365;
const maxDownloadsLimit = 10000;
function now() {
return new Date().toISOString();
}
function parseJson(value, fallback) {
try {
return JSON.parse(value);
} catch {
return fallback;
}
}
function makeId(prefix) {
return `${prefix}-${Date.now()}-${randomBytes(7).toString("hex")}`;
}
function hashToken(token) {
return createHash("sha256").update(String(token)).digest("hex");
}
function tokenFingerprint(tokenHash) {
return String(tokenHash || "").slice(0, 16);
}
function safeStoragePath(value) {
const relativePath = String(value || "").trim();
if (!relativePath || relativePath.startsWith("/") || !relativePath.startsWith("storage/")) return null;
const segments = relativePath.split(/[\\/]+/);
if (segments.includes("..")) return null;
const storageRoot = resolve(projectRoot, "storage");
const absolute = resolve(projectRoot, relativePath);
const storageRelative = relative(storageRoot, absolute);
if (!storageRelative || storageRelative.startsWith("..") || storageRelative.includes("..")) return null;
return { relative: relativePath, absolute };
}
function safeFileSegment(value, fallback = "delivery") {
const normalized = String(value || "").trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
return normalized || fallback;
}
function requestInfoOf(requestInfo = {}) {
return {
ipAddress: String(requestInfo.ipAddress || "").slice(0, 200),
userAgent: String(requestInfo.userAgent || "").slice(0, 1000)
};
}
function requireDeliveryView(context) {
if (!hasPermission(context, "delivery:view") && !hasPermission(context, "delivery:approve")) {
throw httpError(403, "permission_denied", "当前角色没有交付门户查看权限", { permission: "delivery:view" });
}
}
function releaseForContext(context, releaseId) {
if (!context.project) throw httpError(400, "project_required", "交付门户必须绑定项目");
const release = dbGet(
`SELECT dr.*, d.version AS delivery_version, d.status AS delivery_status,
d.manifest_path AS delivery_manifest_path, d.project_id AS delivery_project_id,
p.name AS project_name
FROM delivery_releases dr
JOIN deliveries d ON d.id = dr.delivery_id
JOIN projects p ON p.id = dr.project_id
WHERE dr.id = ? AND dr.organization_id = ? AND dr.workspace_id = ? AND dr.project_id = ?
AND d.organization_id = dr.organization_id AND d.workspace_id = dr.workspace_id
AND d.project_id = dr.project_id`,
[releaseId, context.organization.id, context.workspace.id, context.project.id]
);
if (!release) throw httpError(404, "delivery_release_not_found", "发布记录不存在或不属于当前项目", { releaseId });
return release;
}
function linkForContext(context, linkId) {
if (!context.project) throw httpError(400, "project_required", "交付门户必须绑定项目");
const row = dbGet(
`SELECT dal.*, dr.status AS release_status, dr.output_path, dr.published_at,
dr.delivery_id, d.version AS delivery_version, p.name AS project_name,
creator.display_name AS created_by_name,
(SELECT COUNT(*) FROM delivery_access_events e WHERE e.link_id = dal.id AND e.event_type = 'view' AND e.result = 'success') AS view_count,
(SELECT COUNT(*) FROM delivery_access_events e WHERE e.link_id = dal.id AND e.event_type = 'download' AND e.result = 'success') AS download_event_count,
(SELECT MAX(e.created_at) FROM delivery_access_events e WHERE e.link_id = dal.id) AS last_access_at
FROM delivery_access_links dal
JOIN delivery_releases dr ON dr.id = dal.release_id
JOIN deliveries d ON d.id = dr.delivery_id
JOIN projects p ON p.id = dal.project_id
LEFT JOIN users creator ON creator.id = dal.created_by
WHERE dal.id = ? AND dal.organization_id = ? AND dal.workspace_id = ? AND dal.project_id = ?`,
[linkId, context.organization.id, context.workspace.id, context.project.id]
);
if (!row) throw httpError(404, "delivery_access_link_not_found", "交付访问链接不存在或不属于当前项目", { linkId });
return row;
}
function linkState(row, at = Date.now()) {
if (row.revoked_at || row.status === "revoked") return "revoked";
if (row.status === "expired" || new Date(row.expires_at).getTime() <= at) return "expired";
return "active";
}
function syncExpired(row) {
if (linkState(row) !== "expired" || row.status === "expired" || row.status === "revoked") return row;
const timestamp = now();
dbRun("UPDATE delivery_access_links SET status = 'expired', updated_at = ? WHERE id = ? AND status = 'active'", [timestamp, row.id]);
return { ...row, status: "expired", updated_at: timestamp };
}
function linkPayload(input) {
if (!input) return null;
const row = syncExpired(input);
const state = linkState(row);
const maxDownloads = Number(row.max_downloads || 0);
const downloadCount = Number(row.download_count || 0);
const feedback = feedbackSummaryForLink(row.id);
return {
id: row.id,
releaseId: row.release_id,
deliveryId: row.delivery_id,
deliveryVersion: row.delivery_version,
projectName: row.project_name,
recipientName: row.recipient_name,
recipientEmail: row.recipient_email,
status: state,
expiresAt: row.expires_at,
maxDownloads,
downloadCount,
downloadsRemaining: Math.max(0, maxDownloads - downloadCount),
viewCount: Number(row.view_count || 0),
downloadEventCount: Number(row.download_event_count || 0),
tokenHint: row.token_hint,
createdBy: row.created_by,
createdByName: row.created_by_name,
createdAt: row.created_at,
lastViewedAt: row.last_viewed_at,
lastDownloadedAt: row.last_downloaded_at,
lastAccessAt: row.last_access_at,
revokedAt: row.revoked_at,
clientReviewStatus: feedback.status,
clientReviewMessage: feedback.message,
clientReviewerName: feedback.reviewerName,
clientReviewerEmail: feedback.reviewerEmail,
clientReviewedAt: feedback.submittedAt,
clientReviewCount: feedback.count
};
}
function addAccessEvent(row, eventType, result, requestInfo, fileKind = "", metadata = {}) {
const info = requestInfoOf(requestInfo);
const tokenHash = row?.token_hash || String(metadata.tokenHash || "");
dbRun(
`INSERT INTO delivery_access_events(
id, link_id, release_id, organization_id, workspace_id, project_id,
event_type, result, file_kind, token_fingerprint, ip_address, user_agent,
metadata_json, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
makeId("delivery-access-event"),
row?.id || null,
row?.release_id || null,
row?.organization_id || null,
row?.workspace_id || null,
row?.project_id || null,
eventType,
result,
fileKind,
tokenFingerprint(tokenHash),
info.ipAddress,
info.userAgent,
JSON.stringify(metadata),
now()
]
);
}
function normalizeExpiry(value) {
const candidate = value ? new Date(String(value)) : new Date(Date.now() + defaultExpiryDays * 24 * 60 * 60 * 1000);
if (Number.isNaN(candidate.getTime())) throw httpError(400, "delivery_access_expiry_invalid", "访问链接有效期不是有效日期");
if (candidate.getTime() <= Date.now()) throw httpError(400, "delivery_access_expiry_past", "访问链接有效期必须晚于当前时间");
if (candidate.getTime() > Date.now() + maxExpiryDays * 24 * 60 * 60 * 1000) {
throw httpError(400, "delivery_access_expiry_too_long", `访问链接有效期不能超过 ${maxExpiryDays} 天`);
}
return candidate.toISOString();
}
function normalizeMaxDownloads(value) {
const number = Number(value ?? 10);
if (!Number.isInteger(number) || number < 1 || number > maxDownloadsLimit) {
throw httpError(400, "delivery_access_download_limit_invalid", `最大下载次数必须是 1-${maxDownloadsLimit} 的整数`);
}
return number;
}
function normalizeRecipient(value, maxLength, label) {
const result = String(value || "").trim();
if (result.length > maxLength) throw httpError(400, "delivery_access_recipient_invalid", `${label}不能超过 ${maxLength} 个字符`);
return result;
}
function normalizeFeedbackMessage(value, required = false) {
const result = String(value || "").trim();
if (result.length > 4000) throw httpError(400, "delivery_feedback_message_invalid", "客户反馈不能超过 4000 个字符");
if (required && !result) throw httpError(400, "delivery_feedback_message_required", "提出修改意见时必须填写具体反馈");
return result;
}
function feedbackPayload(row) {
if (!row) return null;
return {
id: row.id,
linkId: row.link_id,
releaseId: row.release_id,
decision: row.decision,
message: row.message || "",
reviewerName: row.reviewer_name || "",
reviewerEmail: row.reviewer_email || "",
createdAt: row.created_at,
updatedAt: row.updated_at
};
}
function latestFeedbackForLink(linkId) {
return dbGet(
"SELECT * FROM delivery_access_feedback WHERE link_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1",
[linkId]
);
}
function feedbackSummaryForLink(linkId) {
const latest = latestFeedbackForLink(linkId);
const count = dbGet("SELECT COUNT(*) AS count FROM delivery_access_feedback WHERE link_id = ?", [linkId]);
return {
status: latest?.decision || "pending",
message: latest?.message || "",
reviewerName: latest?.reviewer_name || "",
reviewerEmail: latest?.reviewer_email || "",
submittedAt: latest?.created_at || null,
count: Number(count?.count || 0)
};
}
export function listDeliveryAccessLinks(context, releaseId) {
requireDeliveryView(context);
const release = releaseForContext(context, releaseId);
return {
releaseId: release.id,
release: {
id: release.id,
status: release.status,
deliveryId: release.delivery_id,
deliveryVersion: release.delivery_version,
publishedAt: release.published_at,
outputPath: release.status === "published" ? release.output_path : ""
},
accessLinks: dbAll(
`SELECT dal.*, dr.status AS release_status, dr.output_path, dr.published_at,
dr.delivery_id, d.version AS delivery_version, p.name AS project_name,
creator.display_name AS created_by_name,
(SELECT COUNT(*) FROM delivery_access_events e WHERE e.link_id = dal.id AND e.event_type = 'view' AND e.result = 'success') AS view_count,
(SELECT COUNT(*) FROM delivery_access_events e WHERE e.link_id = dal.id AND e.event_type = 'download' AND e.result = 'success') AS download_event_count,
(SELECT MAX(e.created_at) FROM delivery_access_events e WHERE e.link_id = dal.id) AS last_access_at
FROM delivery_access_links dal
JOIN delivery_releases dr ON dr.id = dal.release_id
JOIN deliveries d ON d.id = dr.delivery_id
JOIN projects p ON p.id = dal.project_id
LEFT JOIN users creator ON creator.id = dal.created_by
WHERE dal.release_id = ? AND dal.organization_id = ? AND dal.workspace_id = ? AND dal.project_id = ?
ORDER BY dal.created_at DESC`,
[release.id, context.organization.id, context.workspace.id, context.project.id]
).map(linkPayload)
};
}
export function createDeliveryAccessLink(context, releaseId, body = {}, options = {}) {
requirePermission(context, "delivery:approve");
requireEntitlement(context, "feature.private_delivery_portal", 1);
const release = releaseForContext(context, releaseId);
if (release.status !== "published" || !release.output_path) {
throw httpError(409, "delivery_access_release_not_published", "只有已发布且存在发布产物的版本可以创建客户访问链接", { releaseId, status: release.status });
}
const releaseFile = safeStoragePath(release.output_path);
if (!releaseFile || basename(releaseFile.relative) !== "release.json") {
throw httpError(409, "delivery_access_release_file_invalid", "发布产物路径不满足交付门户安全约束");
}
const recipientName = normalizeRecipient(body.recipientName, 120, "收件人名称");
const recipientEmail = normalizeRecipient(body.recipientEmail, 240, "收件人邮箱");
if (recipientEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(recipientEmail)) {
throw httpError(400, "delivery_access_email_invalid", "收件人邮箱格式无效");
}
const expiresAt = normalizeExpiry(body.expiresAt);
const maxDownloads = normalizeMaxDownloads(body.maxDownloads);
const metadata = body.metadata && typeof body.metadata === "object" && !Array.isArray(body.metadata) ? body.metadata : {};
const token = randomBytes(32).toString("base64url");
const tokenHash = hashToken(token);
const timestamp = now();
const id = makeId("delivery-access");
dbRun(
`INSERT INTO delivery_access_links(
id, organization_id, workspace_id, project_id, release_id, token_hash, token_hint,
recipient_name, recipient_email, status, expires_at, max_downloads, download_count,
metadata_json, created_by, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, 0, ?, ?, ?, ?)`,
[
id,
context.organization.id,
context.workspace.id,
context.project.id,
release.id,
tokenHash,
`${token.slice(0, 10)}...`,
recipientName,
recipientEmail,
expiresAt,
maxDownloads,
JSON.stringify(metadata),
context.user.id,
timestamp,
timestamp
]
);
addAudit({ context, action: "delivery.access_link.created", targetType: "delivery_access_link", targetId: id, metadata: { releaseId, recipientName, recipientEmail, expiresAt, maxDownloads } });
const portalPath = `/portal/${encodeURIComponent(token)}`;
const portalOrigin = String(options.portalOrigin || "").replace(/\/$/, "");
return {
link: linkPayload(linkForContext(context, id)),
token,
tokenHint: `${token.slice(0, 10)}...`,
portalPath,
...(portalOrigin ? { portalUrl: `${portalOrigin}${portalPath}` } : {}),
accessLinks: listDeliveryAccessLinks(context, release.id).accessLinks
};
}
export function revokeDeliveryAccessLink(context, linkId) {
requirePermission(context, "delivery:approve");
const current = linkForContext(context, linkId);
const state = linkState(current);
if (state === "active") {
const timestamp = now();
dbRun("UPDATE delivery_access_links SET status = 'revoked', revoked_by = ?, revoked_at = ?, updated_at = ? WHERE id = ? AND status = 'active'", [context.user.id, timestamp, timestamp, linkId]);
addAudit({ context, action: "delivery.access_link.revoked", targetType: "delivery_access_link", targetId: linkId, metadata: { releaseId: current.release_id } });
}
const link = linkForContext(context, linkId);
return { link: linkPayload(link), accessLinks: listDeliveryAccessLinks(context, current.release_id).accessLinks };
}
export function listDeliveryAccessFeedback(context, releaseId) {
requireDeliveryView(context);
const release = releaseForContext(context, releaseId);
return {
releaseId: release.id,
feedback: dbAll(
`SELECT f.*, dal.recipient_name, dal.recipient_email, dal.token_hint,
creator.display_name AS created_by_name
FROM delivery_access_feedback f
JOIN delivery_access_links dal ON dal.id = f.link_id
LEFT JOIN users creator ON creator.id = dal.created_by
WHERE f.release_id = ? AND f.organization_id = ? AND f.workspace_id = ? AND f.project_id = ?
ORDER BY f.created_at DESC, f.rowid DESC`,
[release.id, context.organization.id, context.workspace.id, context.project.id]
).map((row) => ({
...feedbackPayload(row),
recipientName: row.recipient_name,
recipientEmail: row.recipient_email,
tokenHint: row.token_hint,
createdByName: row.created_by_name
}))
};
}
function publicLinkForToken(token, requestInfo, eventType = "view", fileKind = "") {
const normalizedToken = String(token || "").trim();
const tokenHash = hashToken(normalizedToken);
const row = dbGet(
`SELECT dal.*, dr.status AS release_status, dr.output_path, dr.published_at,
dr.delivery_id, d.version AS delivery_version, p.name AS project_name
FROM delivery_access_links dal
JOIN delivery_releases dr ON dr.id = dal.release_id
JOIN deliveries d ON d.id = dr.delivery_id
JOIN projects p ON p.id = dal.project_id
WHERE dal.token_hash = ?`,
[tokenHash]
);
if (!row) {
addAccessEvent({ token_hash: tokenHash }, eventType, "missing", requestInfo, fileKind, { reason: "token_not_found" });
throw httpError(404, "delivery_access_not_found", "交付访问链接不存在");
}
const state = linkState(row);
if (state === "revoked") {
addAccessEvent(row, eventType, "denied", requestInfo, fileKind, { reason: "revoked" });
throw httpError(404, "delivery_access_not_found", "交付访问链接不存在");
}
if (state === "expired") {
syncExpired(row);
addAccessEvent(row, eventType, "denied", requestInfo, fileKind, { reason: "expired" });
throw httpError(410, "delivery_access_expired", "交付访问链接已过期");
}
if (row.release_status !== "published" || !row.output_path) {
addAccessEvent(row, eventType, "missing", requestInfo, fileKind, { reason: "release_not_published" });
throw httpError(404, "delivery_access_not_found", "交付版本不可用");
}
return row;
}
function publicPortalPayload(row, token) {
const maxDownloads = Number(row.max_downloads || 0);
const downloadCount = Number(row.download_count || 0);
const latestFeedback = latestFeedbackForLink(row.id);
const feedbackCount = dbGet("SELECT COUNT(*) AS count FROM delivery_access_feedback WHERE link_id = ?", [row.id]);
return {
portal: {
status: "active",
recipientName: row.recipient_name,
expiresAt: row.expires_at,
maxDownloads,
downloadCount,
downloadsRemaining: Math.max(0, maxDownloads - downloadCount),
createdAt: row.created_at,
lastAccessAt: row.last_viewed_at || null
},
delivery: {
version: row.delivery_version,
projectName: row.project_name,
publishedAt: row.published_at
},
review: {
status: latestFeedback?.decision || "pending",
message: latestFeedback?.message || "",
reviewerName: latestFeedback?.reviewer_name || "",
submittedAt: latestFeedback?.created_at || null,
count: Number(feedbackCount?.count || 0)
},
files: [
{ kind: "release", label: "发布元数据", fileName: "release.json", path: `/api/public/delivery/${encodeURIComponent(token)}/file?kind=release` },
{ kind: "manifest", label: "交付清单", fileName: "delivery-manifest.json", path: `/api/public/delivery/${encodeURIComponent(token)}/file?kind=manifest` }
]
};
}
export function resolvePublicDeliveryPortal(token, requestInfo = {}) {
const row = publicLinkForToken(token, requestInfo, "view");
const timestamp = now();
dbRun("UPDATE delivery_access_links SET last_viewed_at = ?, updated_at = ? WHERE id = ?", [timestamp, timestamp, row.id]);
addAccessEvent(row, "view", "success", requestInfo, "", {});
return publicPortalPayload({ ...row, last_viewed_at: timestamp }, token);
}
export function submitPublicDeliveryFeedback(token, body = {}, requestInfo = {}) {
const row = publicLinkForToken(token, requestInfo, "view", "feedback");
const decision = String(body.decision || "").trim();
if (!["approved", "changes_requested"].includes(decision)) {
throw httpError(400, "delivery_feedback_decision_invalid", "客户反馈状态只能是 approved 或 changes_requested");
}
const message = normalizeFeedbackMessage(body.message, decision === "changes_requested");
const reviewerName = normalizeRecipient(body.reviewerName || row.recipient_name, 120, "反馈人名称");
const reviewerEmail = normalizeRecipient(body.reviewerEmail || row.recipient_email, 240, "反馈人邮箱");
if (reviewerEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(reviewerEmail)) {
throw httpError(400, "delivery_feedback_email_invalid", "反馈人邮箱格式无效");
}
const info = requestInfoOf(requestInfo);
const timestamp = now();
dbRun(
`INSERT INTO delivery_access_feedback(
id, link_id, release_id, organization_id, workspace_id, project_id,
decision, message, reviewer_name, reviewer_email, ip_address, user_agent,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
makeId("delivery-feedback"),
row.id,
row.release_id,
row.organization_id,
row.workspace_id,
row.project_id,
decision,
message,
reviewerName,
reviewerEmail,
info.ipAddress,
info.userAgent,
timestamp,
timestamp
]
);
return publicPortalPayload(row, token);
}
function publicFileTarget(row, kind) {
if (!['release', 'manifest'].includes(kind)) throw httpError(400, "delivery_access_file_kind_invalid", "只允许读取 release 或 manifest 文件");
const releaseTarget = safeStoragePath(row.output_path);
if (!releaseTarget || basename(releaseTarget.relative) !== "release.json") return null;
const relativePath = kind === "release" ? releaseTarget.relative : `${dirname(releaseTarget.relative)}/delivery-manifest.json`;
return safeStoragePath(relativePath);
}
export async function readPublicDeliveryFile(token, kind, requestInfo = {}) {
const row = publicLinkForToken(token, requestInfo, "download", kind);
const target = publicFileTarget(row, kind);
if (!target) {
addAccessEvent(row, "download", "missing", requestInfo, kind, { reason: "unsafe_path" });
throw httpError(404, "delivery_access_file_not_found", "交付文件不可用");
}
let content;
try {
content = await readFile(target.absolute);
} catch {
addAccessEvent(row, "download", "missing", requestInfo, kind, { reason: "file_missing" });
throw httpError(404, "delivery_access_file_not_found", "交付文件不存在");
}
const timestamp = now();
const claimed = dbRun(
`UPDATE delivery_access_links
SET download_count = download_count + 1, last_downloaded_at = ?, updated_at = ?
WHERE id = ? AND status = 'active' AND expires_at > ? AND download_count < max_downloads`,
[timestamp, timestamp, row.id, timestamp]
);
if (Number(claimed?.changes || 0) !== 1) {
const latest = dbGet("SELECT * FROM delivery_access_links WHERE id = ?", [row.id]);
const state = latest ? linkState(latest) : "missing";
if (state === "expired") {
syncExpired(latest);
addAccessEvent(row, "download", "denied", requestInfo, kind, { reason: "expired" });
throw httpError(410, "delivery_access_expired", "交付访问链接已过期");
}
if (state === "revoked" || !latest) {
addAccessEvent(row, "download", "denied", requestInfo, kind, { reason: "revoked" });
throw httpError(404, "delivery_access_not_found", "交付访问链接不存在");
}
addAccessEvent(row, "download", "denied", requestInfo, kind, { reason: "download_limit" });
throw httpError(429, "delivery_access_download_limit", "该交付访问链接已达到最大下载次数");
}
addAccessEvent({ ...row, token_hash: row.token_hash }, "download", "success", requestInfo, kind, { downloadCount: Number(row.download_count || 0) + 1 });
return {
content,
contentType: "application/json; charset=utf-8",
fileName: kind === "release" ? `${safeFileSegment(row.delivery_version)}-release.json` : `${safeFileSegment(row.delivery_version)}-delivery-manifest.json`
};
}