ALIEZ 1 месяц назад
Родитель
Сommit
6ed56d5493
6 измененных файлов с 153 добавлено и 47 удалено
  1. 2 0
      .env.development
  2. 3 0
      .env.production
  3. 20 46
      src/data/courses.ts
  4. 8 0
      src/lib/env.ts
  5. 3 1
      src/lib/goods-order-api.ts
  6. 117 0
      src/lib/media-url.ts

+ 2 - 0
.env.development

@@ -1,6 +1,8 @@
 
 NEXT_PUBLIC_APP_ENV=local
 
+# 本地不设 NEXT_PUBLIC_MEDIA_ORIGIN,资源走 /api-backend 反代;若需直连可填如 https://secure.jinclab.com
+
 # 主业务 :8005 | 汇款/出款/存款支付 :8504(与 src/lib/remittance-client、withdrawal-api、checkout-api 一致)
 NEXT_PUBLIC_API_BASE_URL_LOCAL=/api-backend
 # API_PROXY_TARGET=http://192.168.0.33:8005

+ 3 - 0
.env.production

@@ -3,6 +3,9 @@ NEXT_PUBLIC_APP_ENV=production
 # 线上站点根地址(与浏览器访问域名一致,无尾斜杠);SSR / 服务端 axios 解析 __ORIGIN__ 时必需
 NEXT_PUBLIC_SITE_URL=https://jinclab.com
 
+# 封面、视频、课件等媒体直连主业务域名(与 API_PROXY_TARGET 一致);不设则仍用 /api-backend 同源反写
+NEXT_PUBLIC_MEDIA_ORIGIN=https://secure.jinclab.com
+
 NEXT_PUBLIC_API_BASE_URL_LOCAL=http://127.0.0.1:8000
 # 动态域名:浏览器为「当前域 + /api-backend」;SSR 需 NEXT_PUBLIC_SITE_URL。端口写在下面两条代理里(8005 / 8504),勿写进公网 URL。
 NEXT_PUBLIC_API_BASE_URL_TEST=__ORIGIN__

+ 20 - 46
src/data/courses.ts

@@ -1,5 +1,5 @@
 import { apiPost } from "@/lib/api";
-import { getApiBaseUrl } from "@/lib/env";
+import { normalizeAssetUrl, toAbsoluteFileUrl } from "@/lib/media-url";
 
 export type GoodsType = 1 | 2 | 3 | 4 | 5;
 export type CustomType = 0 | 1 | 2;
@@ -296,10 +296,11 @@ function normalizeCourse(item: GoodsSearchListItem): Course | null {
     goodsPriceParsed !== undefined && Number.isFinite(goodsPriceParsed)
       ? goodsPriceParsed
       : undefined;
-  const downloadUrl =
+  const rawDownload =
     typeof item.download === "string" && item.download.trim()
       ? item.download.trim()
       : undefined;
+  const downloadUrl = normalizeAssetUrl(rawDownload);
 
   return {
     id,
@@ -313,7 +314,9 @@ function normalizeCourse(item: GoodsSearchListItem): Course | null {
     goodsPrice,
     price: Number.isFinite(price) ? price : 0,
     currency,
-    coverUrl: typeof item.frontUrl === "string" ? item.frontUrl : undefined,
+    coverUrl: normalizeAssetUrl(
+      typeof item.frontUrl === "string" ? item.frontUrl : undefined,
+    ),
     downloadUrl,
     coverGradient: item.coverGradient ?? "from-slate-700 via-slate-800 to-slate-900",
   };
@@ -370,7 +373,7 @@ function normalizeVideo(item: GoodsVideoListItem): CourseVideo | null {
     typeof item.fileUrl === "string" && item.fileUrl.trim()
       ? item.fileUrl.trim()
       : undefined;
-  const playUrl =
+  const playUrlRaw =
     typeof item.playUrl === "string" && item.playUrl.trim()
       ? item.playUrl.trim()
       : undefined;
@@ -378,13 +381,17 @@ function normalizeVideo(item: GoodsVideoListItem): CourseVideo | null {
 
   if (!videoName && !title && !introduction) return null;
 
+  const resolvedPlay = normalizeAssetUrl(
+    toAbsoluteFileUrl(linkUrl ?? fileUrl ?? playUrlRaw ?? ""),
+  );
+
   return {
     id,
     videoName: videoName || title || "未命名视频",
     title: title || videoName || "未命名视频",
     introduction,
-    frontUrl,
-    playUrl: toAbsoluteFileUrl(linkUrl ?? fileUrl ?? playUrl ?? ""),
+    frontUrl: normalizeAssetUrl(frontUrl),
+    playUrl: resolvedPlay ?? "",
     payType,
   };
 }
@@ -462,56 +469,23 @@ function parseDownloadUrls(rawDownload: unknown): string[] {
     .filter(Boolean);
 }
 
-function toAbsoluteFileUrl(input: string): string {
-  const value = input.trim();
-  if (!value) return "";
-  const apiBaseUrl = getApiBaseUrl().replace(/\/$/, "");
-  const preferredOrigin = (() => {
-    try {
-      if (/^https?:\/\//i.test(apiBaseUrl)) return new URL(apiBaseUrl).origin;
-      return "";
-    } catch {
-      return "";
-    }
-  })();
-
-  if (/^https?:\/\//i.test(value)) {
-    try {
-      const current = new URL(value);
-      const isLocalHost = current.hostname === "localhost" || current.hostname === "127.0.0.1";
-      if (isLocalHost && preferredOrigin) {
-        return `${preferredOrigin}${current.pathname}${current.search}${current.hash}`;
-      }
-      return value;
-    } catch {
-      return value;
-    }
-  }
-  if (value.startsWith("//")) {
-    if (typeof window !== "undefined" && window.location?.protocol) {
-      return `${window.location.protocol}${value}`;
-    }
-    return `https:${value}`;
-  }
-
-  const path = value.startsWith("/") ? value : `/${value}`;
-  // 优先走同源反向代理,避免本地/线上域名不一致导致资源不可达。
-  return `/api-backend${path}`;
-}
-
 function normalizeFileFromVideo(item: GoodsVideoListItem): CourseFile[] {
   const downloadUrls = parseDownloadUrls(item.fileUrl);
-  const normalizedUrls = downloadUrls.map(toAbsoluteFileUrl).filter(Boolean);
+  const normalizedUrls = downloadUrls
+    .map((u) => toAbsoluteFileUrl(u))
+    .filter(Boolean)
+    .map((u) => normalizeAssetUrl(u) ?? u);
   const title = String(item.title ?? item.videoName ?? item.videoTitle ?? item.name ?? "").trim() || "未命名文件";
   const rawId = String(item.videoId ?? item.id ?? "").trim();
   const idBase = rawId || normalizedUrls[0] || title;
   const introduction = String(
     item.introduction ?? item.description ?? item.intro ?? "",
   ).trim();
-  const frontUrl =
+  const frontUrl = normalizeAssetUrl(
     typeof item.frontUrl === "string" && item.frontUrl.trim()
       ? item.frontUrl.trim()
-      : undefined;
+      : undefined,
+  );
   const payType: 0 | 1 = item.payType === 1 ? 1 : 0;
   const finalUrls = normalizedUrls.length > 0 ? normalizedUrls : [""];
   return finalUrls.map((downloadUrl, index) => ({

+ 8 - 0
src/lib/env.ts

@@ -69,6 +69,14 @@ export function getApiBaseUrl(): string {
   return resolveApiBaseUrlValue(map[env]?.trim() ?? "").replace(/\/$/, "");
 }
 
+/**
+ * 静态资源(封面、视频、课件等)在浏览器中使用的公网源站。
+ * 生产可设为 `https://secure.jinclab.com`,与主业务 API 域名一致;不设则继续走 `/api-backend` 同源反代。
+ */
+export function getMediaOrigin(): string {
+  return process.env.NEXT_PUBLIC_MEDIA_ORIGIN?.trim().replace(/\/$/, "") ?? "";
+}
+
 export function getRemittanceApiBaseUrl(): string {
   const explicit = process.env.NEXT_PUBLIC_REMITTANCE_API_BASE_URL?.trim();
   if (explicit) return explicit.replace(/\/$/, "");

+ 3 - 1
src/lib/goods-order-api.ts

@@ -1,4 +1,5 @@
 import { apiPost } from "@/lib/api";
+import { normalizeAssetUrl } from "@/lib/media-url";
 
 export type PurchasedCourse = {
   id: string;
@@ -52,7 +53,8 @@ export async function fetchPurchasedCourses(page: {
     const title = String(o.title ?? o.goodsName ?? o.courseName ?? o.details ?? "-").trim() || "-";
     const introduction =
       String(o.introduction ?? o.intro ?? o.subtitle ?? o.desc ?? "-").trim() || "-";
-    const coverUrl = String(o.frontUrl ?? o.coverUrl ?? o.download ?? "").trim();
+    const coverRaw = String(o.frontUrl ?? o.coverUrl ?? o.download ?? "").trim();
+    const coverUrl = normalizeAssetUrl(coverRaw) ?? coverRaw;
     out.push({ id, goodsId, goodsType, title, introduction, coverUrl });
   }
   return out;

+ 117 - 0
src/lib/media-url.ts

@@ -0,0 +1,117 @@
+import { getApiBaseUrl, getMediaOrigin } from "@/lib/env";
+
+/**
+ * 将接口返回的路径/URL 转为可请求的地址:本地/未配 MEDIA_ORIGIN 时用同源 `/api-backend`;
+ * 生产配置 NEXT_PUBLIC_MEDIA_ORIGIN 后改为直连 secure 域名。
+ */
+export function toAbsoluteFileUrl(input: string): string {
+  const value = input.trim();
+  if (!value) return "";
+  const apiBaseUrl = getApiBaseUrl().replace(/\/$/, "");
+  const preferredOrigin = (() => {
+    try {
+      if (/^https?:\/\//i.test(apiBaseUrl)) return new URL(apiBaseUrl).origin;
+      return "";
+    } catch {
+      return "";
+    }
+  })();
+
+  if (/^https?:\/\//i.test(value)) {
+    try {
+      const current = new URL(value);
+      const isLocalHost =
+        current.hostname === "localhost" || current.hostname === "127.0.0.1";
+      if (isLocalHost && preferredOrigin) {
+        return `${preferredOrigin}${current.pathname}${current.search}${current.hash}`;
+      }
+      return value;
+    } catch {
+      return value;
+    }
+  }
+  if (value.startsWith("//")) {
+    if (typeof window !== "undefined" && window.location?.protocol) {
+      return `${window.location.protocol}${value}`;
+    }
+    return `https:${value}`;
+  }
+
+  const path = value.startsWith("/") ? value : `/${value}`;
+  return `/api-backend${path}`;
+}
+
+function toPublicMediaUrl(resolved: string): string {
+  const origin = getMediaOrigin().replace(/\/$/, "");
+  if (!origin || !resolved.trim()) return resolved;
+
+  const value = resolved.trim();
+
+  if (value.startsWith("/api-backend")) {
+    const path = value.slice("/api-backend".length) || "/";
+    return `${origin}${path.startsWith("/") ? path : `/${path}`}`;
+  }
+
+  if (/^https?:\/\//i.test(value)) {
+    try {
+      const u = new URL(value);
+      if (u.origin === origin) return value;
+      return `${origin}${u.pathname}${u.search}${u.hash}`;
+    } catch {
+      return value;
+    }
+  }
+
+  if (value.startsWith("//")) {
+    try {
+      const u = new URL(`https:${value}`);
+      return `${origin}${u.pathname}${u.search}${u.hash}`;
+    } catch {
+      return value;
+    }
+  }
+
+  const path = value.startsWith("/") ? value : `/${value}`;
+  return `${origin}${path}`;
+}
+
+/** 封面、视频缩略图、播放/下载地址等:线上可统一为 NEXT_PUBLIC_MEDIA_ORIGIN。 */
+export function normalizeAssetUrl(
+  raw: string | undefined | null,
+): string | undefined {
+  if (raw == null) return undefined;
+  const s = String(raw).trim();
+  if (!s) return undefined;
+
+  const resolved =
+    /^https?:\/\//i.test(s) || s.startsWith("//") ? s : toAbsoluteFileUrl(s);
+
+  const mediaOrigin = getMediaOrigin().replace(/\/$/, "");
+  if (!mediaOrigin) {
+    if (/^https?:\/\//i.test(resolved)) {
+      try {
+        const current = new URL(resolved);
+        const isLocalHost =
+          current.hostname === "localhost" || current.hostname === "127.0.0.1";
+        const apiBaseUrl = getApiBaseUrl().replace(/\/$/, "");
+        const preferredOrigin = (() => {
+          try {
+            if (/^https?:\/\//i.test(apiBaseUrl))
+              return new URL(apiBaseUrl).origin;
+            return "";
+          } catch {
+            return "";
+          }
+        })();
+        if (isLocalHost && preferredOrigin) {
+          return `${preferredOrigin}${current.pathname}${current.search}${current.hash}`;
+        }
+      } catch {
+        /* ignore */
+      }
+    }
+    return resolved;
+  }
+
+  return toPublicMediaUrl(resolved);
+}