index.ts 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. import { isoDateString, type ExportFile } from "@pipeline/shared";
  2. import { getTimezone, createLogger, describeError } from "@pipeline/shared/node";
  3. import { uploadToOss, type OssConfig } from "./oss.js";
  4. import { sendFeishuMessage, type FeishuConfig } from "./feishu.js";
  5. const log = createLogger("publish");
  6. export type { OssConfig, FeishuConfig };
  7. export interface PublishConfig {
  8. oss?: OssConfig;
  9. feishu?: FeishuConfig;
  10. }
  11. export interface PublishContext {
  12. jobId: string;
  13. template: string;
  14. platforms: string[];
  15. }
  16. export interface UploadedFile {
  17. platform: string;
  18. filePath: string;
  19. ossUrl: string;
  20. }
  21. export interface PublishOutcome {
  22. /** true when there is no OSS configured, or every file uploaded successfully. */
  23. ok: boolean;
  24. uploaded: UploadedFile[];
  25. /** Present when an upload failed (the render itself still succeeded). */
  26. error?: string;
  27. }
  28. function parseBool(value: string | undefined, fallback: boolean): boolean {
  29. if (value === undefined || value === "") return fallback;
  30. return value === "1" || value.toLowerCase() === "true";
  31. }
  32. /**
  33. * Build a PublishConfig from the raw YAML config merged with environment
  34. * variables. Env vars take precedence and are the recommended place for secrets.
  35. * Returns undefined when neither OSS nor Feishu is usable.
  36. */
  37. export function resolvePublishConfig(
  38. raw?: Record<string, any> | null
  39. ): PublishConfig | undefined {
  40. const ossRaw = raw?.oss ?? {};
  41. const accessKeyId = process.env.OSS_ACCESS_KEY_ID || ossRaw.accessKeyId || "";
  42. const accessKeySecret =
  43. process.env.OSS_ACCESS_KEY_SECRET || ossRaw.accessKeySecret || "";
  44. const region = process.env.OSS_REGION || ossRaw.region || "";
  45. const bucket = process.env.OSS_BUCKET || ossRaw.bucket || "";
  46. const oss: OssConfig = {
  47. region,
  48. bucket,
  49. accessKeyId,
  50. accessKeySecret,
  51. endpoint: process.env.OSS_ENDPOINT || ossRaw.endpoint,
  52. path: process.env.OSS_PATH || ossRaw.path,
  53. publicDomain: process.env.OSS_PUBLIC_DOMAIN || ossRaw.publicDomain,
  54. secure: parseBool(process.env.OSS_SECURE, ossRaw.secure ?? true),
  55. };
  56. const ossEnabled = Boolean(oss.region && oss.bucket && oss.accessKeyId && oss.accessKeySecret);
  57. const feishuRaw = raw?.feishu ?? {};
  58. const webhook = process.env.FEISHU_WEBHOOK_URL || feishuRaw.webhook || "";
  59. const feishu: FeishuConfig = {
  60. webhook,
  61. secret: process.env.FEISHU_WEBHOOK_SECRET || feishuRaw.secret,
  62. };
  63. const feishuEnabled = Boolean(feishu.webhook);
  64. if (!ossEnabled && !feishuEnabled) return undefined;
  65. return {
  66. oss: ossEnabled ? oss : undefined,
  67. feishu: feishuEnabled ? feishu : undefined,
  68. };
  69. }
  70. async function safeNotify(config: FeishuConfig, text: string): Promise<boolean> {
  71. try {
  72. await sendFeishuMessage(config, text);
  73. return true;
  74. } catch (err) {
  75. // Notification is best-effort (must not mask the real outcome), but log it
  76. // so a misconfigured webhook / signing failure / network error is visible
  77. // (including the fetch cause: DNS / connection / TLS) instead of silent.
  78. log.error(`feishu send failed: ${describeError(err)}`);
  79. return false;
  80. }
  81. }
  82. function buildSuccessText(
  83. ctx: PublishContext,
  84. uploaded: UploadedFile[],
  85. files: ExportFile[]
  86. ): string {
  87. const lines = uploaded.length
  88. ? uploaded.map((u) => `- [${u.platform}] ${u.ossUrl}`)
  89. : files.map((f) => `- [${f.platform}] ${f.filePath}`);
  90. return [
  91. "✅ 视频生成成功",
  92. `模板: ${ctx.template}`,
  93. `平台: ${ctx.platforms.join(", ")}`,
  94. `任务: ${ctx.jobId}`,
  95. "资源:",
  96. ...lines,
  97. ].join("\n");
  98. }
  99. function buildUploadFailureText(
  100. ctx: PublishContext,
  101. files: ExportFile[],
  102. error: string
  103. ): string {
  104. return [
  105. "⚠️ 视频已生成,但 OSS 上传失败",
  106. `模板: ${ctx.template}`,
  107. `平台: ${ctx.platforms.join(", ")}`,
  108. `任务: ${ctx.jobId}`,
  109. `本地文件: ${files.map((f) => f.filePath).join(", ")}`,
  110. `错误: ${error}`,
  111. ].join("\n");
  112. }
  113. function buildGenerationFailureText(ctx: PublishContext, error: string): string {
  114. return [
  115. "❌ 视频生成失败",
  116. `模板: ${ctx.template}`,
  117. `平台: ${ctx.platforms.join(", ")}`,
  118. `任务: ${ctx.jobId}`,
  119. `错误: ${error}`,
  120. ].join("\n");
  121. }
  122. /**
  123. * Upload all exported files to OSS (if configured) and notify Feishu.
  124. * - Upload success → push OSS resource links.
  125. * - Upload failure → push failure info (render still succeeded locally).
  126. * - No OSS configured → push success with local file paths.
  127. */
  128. export async function runPublish(
  129. files: ExportFile[],
  130. publish: PublishConfig,
  131. ctx: PublishContext
  132. ): Promise<PublishOutcome> {
  133. const dateDir = isoDateString(new Date(), getTimezone());
  134. const uploaded: UploadedFile[] = [];
  135. if (publish.oss) {
  136. log.info(`oss upload start files=${files.length} prefix=${publish.oss.path ?? "(root)"}`);
  137. try {
  138. for (const file of files) {
  139. const result = await uploadToOss(file.filePath, publish.oss, { dateDir });
  140. uploaded.push({ platform: file.platform, filePath: file.filePath, ossUrl: result.url });
  141. log.info(`oss uploaded [${file.platform}] key=${result.key} url=${result.url}`);
  142. }
  143. } catch (err) {
  144. const error = describeError(err);
  145. log.error(`oss upload failed: ${error}`);
  146. if (publish.feishu) {
  147. const ok = await safeNotify(publish.feishu, buildUploadFailureText(ctx, files, error));
  148. log.info(`feishu upload-failure notice ${ok ? "sent" : "failed"}`);
  149. }
  150. return { ok: false, uploaded, error };
  151. }
  152. }
  153. if (publish.feishu) {
  154. const ok = await safeNotify(publish.feishu, buildSuccessText(ctx, uploaded, files));
  155. log.info(`feishu success notice ${ok ? "sent" : "failed"}`);
  156. }
  157. return { ok: true, uploaded };
  158. }
  159. /** Notify Feishu that the render itself failed before any file was produced. */
  160. export async function notifyGenerationFailure(
  161. publish: PublishConfig,
  162. ctx: PublishContext,
  163. error: string
  164. ): Promise<void> {
  165. if (!publish.feishu) return;
  166. await safeNotify(publish.feishu, buildGenerationFailureText(ctx, error));
  167. }