工程規格與改動清單
以下對應現有 internx-me/frontend 的檔案,是建議的最小改動路徑、不是要求重寫。資料模型、狀態機、API、權限、通知、話題牆都列在下面;更完整的整合全貌與流程圖見 交接首頁的 INTEGRATION.md。
本頁 17 節: 前端 1 票券模型・2 表單驗證・3 拖曳・4 FormField; 後端 5 報名狀態機・6 金流・8 API・10 Firestore 規則・9 通知; 身分/整合 7 帳號認證並行・11 話題牆; 收尾 12 改動檔案清單・13 驗收標準; 增補 14 撥款結算+社團・15 帳號選單・16 活動個人化; 職涯學院 17 Academy 課程平台(原型已完成:入口地圖・預留事項・資料流・字幕・整併風險速查 17.6)。
1 · 票券資料模型(核心)
目前票券只有名稱與價格,缺少「販售時間」與「數量」。建議把 feeItems 從 { name, price } 擴充為完整票券物件。
現況 · lib/form-schema/activity-form-schema.ts:328
feeItems: Array<{ name: string; price: number }> — 無時間、無數量,故早鳥過期仍可選、無法限量。
// 建議的票券型別(新增 data/ticket.ts,或擴充 activity.ts) interface Ticket { id: string; name: string; // 票種名稱 price: number; // 0 = 免費 quantity: number | null; // + 數量上限,null = 不限 sold: number; // + 已售(後端維護,前端唯讀) saleStart: Timestamp; // + 販售開始 saleEnd: Timestamp; // + 販售結束 description?: string; order: number; // 排序用 }
狀態由系統計算,不另存欄位
報名端與編輯端共用同一個函式,避免主辦方手動上下架。
function ticketStatus(t: Ticket, now = new Date()) { if (t.quantity != null && t.sold >= t.quantity) return 'soldout'; // 已售完 if (now < t.saleStart) return 'soon'; // 尚未開賣 if (now > t.saleEnd) return 'ended'; // 販售已截止 return 'live'; // 販售中(唯一可購買) }
報名送出時後端需再驗一次 ticketStatus === 'live' 且 sold < quantity,避免前端被繞過或併發超賣。
2 · 表單驗證調整
activity-form-schema.ts 的 feeItems.validation 需加上時間與數量檢查:
| 規則 | 訊息 |
|---|---|
saleStart < saleEnd | 販售開始需早於結束時間 |
quantity == null || quantity >= 1 | 數量上限需 ≥ 1,或留空表示不限 |
price >= 0 | (現有)價格不可為負 |
| 付費活動至少 1 張票 | 請至少新增一個票種 |
原本獨立的 feeAmount(單一費用)與 參加名額上限(schema:371)可由票券模型取代;保留與否視既有報名資料相容性決定。
3 · 報名表單排序:用拖曳(@dnd-kit)
現況 · components/Activities/FormBuilder/FormBuilder.tsx:88
已有 handleReorderFields(fromIndex, toIndex),但拖曳是自製的、不穩、手機難用,所以才難實作。
改法:保留既有的 handleReorderFields,改用成熟套件 @dnd-kit 來驅動拖曳。內建指標 / 觸控 / 鍵盤支援,工程端不用自己處理拖曳事件,抓把手就能順暢拖曳。
npm i @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
// FormPreview:用 SortableContext 包欄位,handleReorderFields 不用改 <DndContext onDragEnd={({ active, over }) => { if (over && active.id !== over.id) { const from = fields.findIndex(f => f.id === active.id); const to = fields.findIndex(f => f.id === over.id); handleReorderFields(from, to); // 既有函式直接重用 } }}> <SortableContext items={fields.map(f => f.id)}> {fields.map(f => <SortableFieldRow key={f.id} field={f} />)} </SortableContext> </DndContext> // SortableFieldRow 用 useSortable({id}) 取得把手 listeners,套到 grip 圖示上。
系統必填欄位(姓名 / Email)標記 locked:不給拖曳把手、固定最上方,並用 dnd-kit 的 modifiers 限制不可被拖到其上方。完整步驟見 INTEGRATION.md。
4 · 報名表單欄位型別(FormField)
報名者端依此 schema 渲染與驗證。對應 components/Activities/FormBuilder/types.ts。
type FieldType = 'text' | 'textarea' | 'email' | 'phone' | 'number' | 'select' | 'radio' | 'checkbox' | 'date' | 'file' | 'agreement'; interface FormField { id: string; type: FieldType; label: string; required: boolean; locked?: boolean; // 系統必填(姓名/Email):不可刪/拖、固定最上 order: number; placeholder?: string; helperText?: string; options?: string[]; allowOther?: boolean; // 選項型 accept?: string; maxFileSize?: number; maxFiles?: number; // 檔案型 allowedDomains?: string[]; // Email 限定網域 min?: number; max?: number; // 數字 agreementText?: string; // 同意條款 }
| 型別 | 前端驗證 | 後端必驗(前端可被繞過) |
|---|---|---|
file | 副檔名 ∈ accept、size ≤ maxFileSize、數量 ≤ maxFiles | 同左 + 類型/病毒掃描 |
email | 格式 + 網域 ∈ allowedDomains | 同左 |
number | min ≤ v ≤ max | 同左 |
select/radio/checkbox | 值 ∈ options(或 allowOther) | 同左 |
5 · 報名資料模型與狀態機
「報名者審核」是這次新增的概念,與「活動發布審核」(approvalStatus) 不同:前者主辦方審個別報名者,後者平台審活動能否上架。對應 data/registration.ts。
type RegistrationStatus = 'pending' // 待主辦方審核 | 'approved' // 已通過,待繳費(系統已寄通知) | 'paid' // 已繳費(付款 webhook 回寫) | 'rejected' | 'cancelled' | 'refunded'; interface Registration { id: string; activityId: string; userUid: string; ticketId: string; qty: number; amount: number; // 送出當下金額快照 answers: Record<string, unknown>; // key = FormField.id status: RegistrationStatus; createdAt: Timestamp; approvedAt?: Timestamp; paidAt?: Timestamp; rejectedAt?: Timestamp; rejectReason?: string; paymentId?: string; reviewerUid?: string; }
// 狀態機
送出 ──▶ pending ──(主辦通過)──▶ approved ──(線上繳費)──▶ paid
│ (主辦拒絕) │ (逾期/取消)
▼ ▼
rejected cancelled paid ──(退款)──▶ refunded
| 轉換 | 觸發者 | 後端動作 / 副作用 |
|---|---|---|
| → pending | 報名者送出 | 建 registration;sold += qty(佔位);通知主辦方 |
| pending → approved | 主辦方 | 寫 approvedAt;寄信 + 站內通知繳費 |
| pending → rejected | 主辦方 | sold -= qty(釋放);通知報名者 |
| approved → paid | 付款 webhook | 寫 paidAt / paymentId;後台自動顯示已付款;寄收據 |
| paid → refunded | 平台退款 | sold -= qty;退款記錄 |
佔位策略:pending 即扣 sold,避免審核期間名額被搶光。若不希望待審佔名額,改 approved 才扣,但要在當下重驗 ticketStatus==='live' 且未超賣。
6 · 金流:平台代收代付(主辦方不對帳)
金流由實習通統一處理,主辦方只審核、不碰錢。沿用 components/Payments/Payments.tsx。
| 步驟 | 動作 |
|---|---|
| 1 | 報名者送出 → registration 建為 pending |
| 2 | 主辦方「通過」→ approved,系統自動寄信通知可繳費 |
| 3 | 報名者於實習通線上繳費(點數折抵 / 轉帳;金額 = 票券 × 張數) |
| 4 | 付款 webhook → paid,後台自動顯示已付款(無人工標記、無末五碼對帳) |
| 5 | 退款同樣由平台處理 |
主辦方端對付款狀態唯讀;registration 存 ticketId / qty / status / paidAt。
7 · 帳號與認證:並行邏輯
沒有「創作者帳號」這種東西。一個帳號 + 一個 verified-creator 標章;「主辦單位 = 認證創作者 = 同一標章」。取得標章有兩個可並行的入口,共用同一套審核(verifiedRoleApplications)。
// 入口 B:onboarding 選身分(新增一步) function onOnboardingIdentitySelected(role) { createBasicAccount(); // 一律先建一般帳號 if (role === 'creator') goTo('/dashboard/verified-role-apply'); else finishOnboarding(); } // 入口 A:事後任意時間(設定頁、或點建立活動/撰寫文章時) function onClickApplyVerification() { goTo('/dashboard/verified-role-apply'); } // 兩入口都送同一支 → verifiedRoleApplications(pending) → 審核 → grantVerifiedBadgeToUser submitVerifiedRoleApplication(payload); // 能力判斷:只看標章 / admining,不看「是不是創作者帳號」 const isCreator = profile.badges.includes('verified-creator'); const canHostActivity = profile.admining || isCreator; const canPublishBlog = isCreator;
審核期間(pending)仍可正常使用平台;通過後才解鎖辦活動/發文/主頁。建議預設走入口 A(事後申請),註冊零阻力。完整見 交接首頁 §10.1。
8 · API / Cloud Functions 介面(建議)
審核與金流走後端,前端只呼叫、不直接改 status,避免被繞過。
// 主辦方審核(驗 caller 是該 activity 的 admining 成員) approveRegistration({ registrationId }) // pending→approved + 寄繳費通知;非 pending 回 409 rejectRegistration({ registrationId, reason? }) // pending→rejected + 釋放 sold // 報名者繳費 createPaymentSession({ registrationId }) // 限 approved;金額 = registration.amount → { paymentUrl } onPaymentSucceeded(webhook) // approved→paid(後端,前端不可呼叫) refundRegistration({ registrationId }) // paid→refunded // 報名送出(務必用 transaction,見 §1 提示) submitRegistration({ activityId, ticketId, qty, answers }) // → { registrationId }
錯誤碼:TICKET_NOT_ON_SALE / SOLD_OUT / NOT_PENDING / NOT_APPROVED / FORBIDDEN / VALIDATION_FAILED。
9 · 通知事件(Email + 站內)
| 事件 | 對象 | 管道 |
|---|---|---|
| 新報名待審 | 主辦方 | 站內(可選 Email) |
| 審核通過・待繳費 | 報名者 | Email + 站內 |
| 審核未通過 | 報名者 | Email + 站內(含 rejectReason) |
| 繳費完成(收據) | 報名者 | |
| 繳費完成 | 主辦方 | 站內(後台自動轉已付款) |
| 退款完成 | 報名者 |
10 · Firestore 安全規則(草稿)
sold / status / paidAt 全部只由後端寫,前端唯讀;報名者只能建自己的 pending。
match /activities/{aid} { allow read: if true; // 公開活動頁 allow write: if isActivityAdmin(aid); // 僅該官方帳號成員 match /tickets/{tid} { allow read: if true; allow write: if false; // sold 只由後端 transaction 改 } } match /registrations/{rid} { allow create: if request.auth.uid == request.resource.data.userUid && request.resource.data.status == 'pending'; allow read: if isOwner(rid) || isActivityAdmin(resource.data.activityId); allow update, delete: if false; // status 轉換走 Cloud Functions }
11 · 話題牆(NeedsWall)串接
話題牆=既有 data/needs-wall-live.js(依行業 forumId 分版的話題;含 branches / poll)+論壇 data/chat.ts ChatRoom。Post / ChatMessage 已顯示 verified-creator 標章。這次只新增「活動 / 創作者 ↔ 話題牆」的關聯。
| 串接 | 機制 | 欄位 / 檔案 |
|---|---|---|
| 活動 → 話題牆 | 活動上架(approved) 依行業 forumId 自動建/連話題;活動頁顯示「社群討論」 | 新增 activity.needsWallTopicId;createNeedsWallTopic() |
| 活動 → 論壇討論區 | 付費 / internx_form 活動開專屬討論區 | 新增 activity.chatRoomId;ChatRoom.create() |
| 創作者 → 話題牆 | 話題頁顯示「行業認證專家」 | 沿用 verifiedRolePitch.expertiseForumIds 命中 forumId |
活動對到哪個行業版:activity.ts 無行業欄位 → 由主辦公司產業推導,或建立活動時選一個 forumId(建議)。完整見 交接首頁 §17。
12 · 改動檔案清單
| 檔案 | 動作 | 說明 |
|---|---|---|
data/ticket.ts | 新增 | Ticket 型別 + ticketStatus(),編輯/報名端共用 |
data/activity.ts | 擴充 | 活動關聯 tickets[](取代或相容 feeItems) |
lib/form-schema/activity-form-schema.ts | 改 | feeItems 改為票券物件、新增驗證(:328、:371) |
components/Activities/AddActivity.tsx | 沿用 | 維持步驟式結構;票券步驟換成新的編輯器卡片 |
components/Activities/FormBuilder/FormPreview.tsx | 改 | 改用 @dnd-kit 拖曳排序(沿用 handleReorderFields) |
components/Activities/FormBuilder/types.ts | 沿用 | FormField 型別已存在(accept / maxFileSize / options / allowedDomains…),§4 |
components/Payments/Payments.tsx | 沿用 | 平台代收代付:通過→通知→線上繳費→自動回寫 paid;主辦方不對帳 |
data/registration.ts | 擴充 | 加 status 狀態機 + ticketId / qty / amount / paidAt(§5) |
| Cloud Functions(後端) | 新增 | approve / reject / pay webhook / refund / submit(§8);status 只由後端改 |
| 通知服務 | 新增 / 接 | 審核 / 繳費 / 退款事件 Email + 站內(§9) |
lib/config.js PROFESSIONAL_SERVICES | 改 | 側欄加「報名名單」「創作內容」;進入條件放寬為 admining OR verified-creator(§7) |
pages/[lang]/professional/registrations | 新增 | 報名名單 + 審核 UI(§5) |
data/blog.ts + BlockEditor | 沿用 | 部落格發佈權放寬給帶 verified-creator 標章者(一行條件) |
Profile.tsx | 改 | activeSectionTab 加「部落格 / 主辦活動 / 活動紀錄」分頁 |
data/verified-role-application.ts | 沿用 | 身分申請 + 審核(§7);兩入口共用 |
data/needs-wall-live.js / data/chat.ts | 接 | 活動上架建話題(needsWallTopicId)/ 專屬討論區(chatRoomId)(§11) |
data/settlement.ts / data/payout.ts | 新增 | 結算與撥款模型;後台「撥款結算」分頁(/backstage);對應金流規格 §10(§14.1) |
data/club.ts / data/clubMember.ts | 新增 | 社團=組織型主辦帳號(多管理員 / 換屆);activity.ownerType:'user'|'club'(§14.2) |
13 · 驗收標準
14 · 撥款結算 + 社團(本次新增)
14.1 撥款 / 結算(代收代付的「撥款」那半)
主辦方後台(/backstage → 撥款結算)把單一「票券收入」拆成 總收款 / 已扣手續費 / 待撥款 / 已撥款 / 已退款,並列每場結算明細與撥款記錄。完整邏輯見內部規格 金流邏輯_平台代收代付.md §10。
// data/payout.ts(新)— 撥款給主辦方(一活動一批) interface Payout { id; organizerId; activityId; orderIds: string[]; gross; platformFeeTotal; gatewayFeeTotal; net; // net = gross − 平台抽成 − 金流手續費 status: 'pending'|'paid'|'failed'; bankAccountRef; paidAt?; } // 結算週期:活動結束 T+N(如 T+7)→ settleAndPayout() 建 payout 撥款
主辦方端對金額 / 狀態唯讀;結算與撥款只在 Cloud Functions(settleAndPayout)計算。認證帳號 / 設定需新增銀行帳戶欄位供撥款。
14.2 社團=組織型主辦帳號(多管理員 / 換屆)
把「主辦方」從個人升級為可選的組織(社團):活動掛在 clubId 而非建立者個人。主頁見 /club、註冊見 /club-register。
interface Club { id; name; school; type; verified; foundedYear; currentTerm; // 屆數 } interface ClubMember { clubId; userId; role: '社長'|'副社長'|'財務長'|'活動長'|'公關長'; term; isOwner; status: 'active'|'invited'|'alumni'; } // 活動歸屬:activity.ownerType: 'user' | 'club' + ownerId // 換屆 = 換 owner,不換 club:移轉只更新 isOwner 與當期 term, // 舊成員轉 alumni;活動 / 追蹤者 / 結算全掛 clubId,永久保留。
權限以 role 控管(owner 可移轉擁有權與刪社團;財務看撥款結算;活動長開活動;公關改主頁)。多管理員讓社團不卡在一個人、社長畢業也不中斷。
15 · 帳號選單 / 導覽(依角色)
關鍵原則:選單不是三套,而是「共用的個人選單 + 依旗標展開的條件區塊」。本次改版是往既有選單「加」東西,不是把里程碑、心得那些換掉。能力旗標存在 appStates.userData,選單定義在 lib/config.js(TOP_BAR_TABS / DASHBOARD_SERVICES / PROFESSIONAL_SERVICES / BADGES_CONFIG)與 components/TopBar/TopBar.jsx。
15.1 能力旗標(userData)
| 旗標 | 意義 | 解鎖 |
|---|---|---|
(已登入) | 一般會員(學生) | 個人選單全套:里程碑徽章、分享心得、收藏公司/活動、我的小夥伴、帳號設定、申請認證標章、Coffee Chat 講師後台 |
badges: 'verified-creator' | 認證創作者(另有 industry-expert / collaboration-partner) | 撰寫文章、創作者主頁(canPublishBlog) |
admining | 官方帳號 / 主辦單位成員 | 官方帳號平台(後台):儀表板、新增活動、活動成果,+本次新增「報名名單審核」「撥款結算」 |
adminingIsOwner | 官方帳號擁有者 | 共同管理員(多管理員)+ 移轉擁有權(=社團換屆) |
admin | 平台管理員 | 管理員工具 /admin |
15.2 帳號下拉選單 = 共用 + 條件展開
// 一律顯示(已登入)— 對齊 TopBar.jsx UserDropdownPanel 的「我的 / 功能選項」 我的:里程碑徽章 /dashboard/milestones · 分享心得 /share · 收藏公司/活動 · 我的小夥伴 · 帳號設定 · 申請認證標章 · Coffee Chat // 依旗標展開 if (badges.includes('verified-creator')) ➜ 撰寫文章、創作者主頁 if (admining) ➜ 官方帳號平台後台(活動管理 / 報名審核 / 撥款結算 / 活動成果) if (adminingIsOwner) ➜ 共同管理員(多管理員)+ 移轉擁有權(換屆) if (admin) ➜ 管理員工具
不要做的事:改版時把「里程碑徽章 / 分享心得 / 收藏 / 我的小夥伴」從選單拿掉。這些是平台既有的個人功能,主辦 / 創作者 / 社團只是額外的條件區塊,不是取代。
15.3 三種視角(旗標組合的結果)
| 學生 | 創作者 | 主辦單位(常為社團) | |
|---|---|---|---|
| 頭像點進去 | 個人檔案 | 創作者主頁 | 社團 / 官方帳號主頁 |
| 個人選單(里程碑/心得/收藏/設定) | ✓ | ✓ | ✓ |
| 撰寫文章 / 創作者主頁 | — | ✓ | 視是否帶標章 |
| 官方帳號後台(活動/報名/撥款) | — | — | ✓ |
| 共同管理員 / 換屆 | — | — | ✓(擁有者) |
| 申請認證標章 | ✓ 升級入口 | 已認證 | 已認證 |
15.4 社團 = 官方帳號的一種(重用,不是新系統)
本次的「社團多管理員 / 換屆」直接掛在既有機制上,不要另開一套:
| 本次概念 | = 既有平台 | 檔案 / 條件 |
|---|---|---|
| 社團 = 組織型主辦帳號 | 官方帳號(admining) | /professional |
| 多管理員 | 共同管理員 | PROFESSIONAL_SERVICES.members · /professional/members(adminingIsOwner 才見) |
| 換屆交接 | 移轉 adminingIsOwner | 新增「移轉擁有權」action;舊成員轉 alumni(§14.2) |
| 撥款結算 | 官方帳號後台新分頁 | /backstage(§14.1) |
真實選單定義:components/TopBar/TopBar.jsx(UserDropdownPanel)、components/SideNav/SideNav.jsx、lib/config.js。本 mockup 對應外殼:assets/cz/app.js(已補回里程碑 / 分享心得 / 收藏 / 我的小夥伴等既有項目)。
16 · 活動個人化(領域 / 推薦 / 追蹤)
建立在既有活動頁上,不重做。現有 /dashboard/activities 已有完整篩選(搜尋 + 類型 / 地區 / 方式 / 費用 / 日期)與三種排序(綜合 / 最新 / 報名截止日)。個人化只補三塊:領域篩選、「綜合」排序接上推薦、追蹤主辦,並重用既有 INDUSTRY_CATEGORIES(17 類,目前只用在公司評價)。
16.1 三個接點(最小改動)
| 接點 | 現況 | 改法 |
|---|---|---|
| 「綜合」排序 | UI 已有按鈕,但 activities.jsx:702 對 comprehensive 是 return 0(未實作) | 把推薦分數放這裡(16.3) |
| 領域篩選 | ActivitiesFilters.tsx 有 類型/地區/方式/費用/日期,無領域 | 加一列「領域」chips(多選) |
| 活動標記 | data/activity.ts 無行業欄位 | 加 industryTags: string[](建立活動時選) |
| 使用者偏好 | userData 有 interests[] 但沒用於活動;onboarding 不問領域 | 加 preferredIndustries[](onboarding 選 2–3 個) |
| 追蹤 | 沒有追蹤主辦 | followedOrganizers[] +「你追蹤的主辦」區 |
16.2 資料模型
// data/activity.ts — 擴充 interface ActivityData { /* …現有… */ industryTags?: string[]; } // 對應 INDUSTRY_CATEGORIES 的 key // userData / profile — 擴充 preferredIndustries?: string[]; // onboarding 選;同一組 key followedOrganizers?: string[]; // 追蹤的官方帳號 / 社團 / 創作者 id // DashboardActivitiesFilters — 與 ActivitiesFilters 一致 industries: string[]; // 空陣列 = 全部
16.3 「綜合」排序 = 推薦分數(接在既有 sort)
// activities.jsx:comprehensive 分支從 return 0 換成 matchScore if (sortBy === 'comprehensive') return score(b) - score(a); function score(act) { let s = 0; const ind = userData.preferredIndustries || []; if (ind.length && act.industryTags?.some(t => ind.includes(t))) s += 100; // 領域命中 if (userData.followedOrganizers?.includes(act.ownerId)) s += 10; // 追蹤的主辦 s += deadlineHeat(act); // 報名熱度 / 截止將近(沿用現有 deadline 桶邏輯) return s; }
推薦要標出原因(「因為你選了金融」「你追蹤的 ●●」):命中哪個條件就顯示哪個 —— 透明的推薦比黑箱更被信任。原型見 /activities。偏好只影響排序與預設,不隱藏任何活動(既有篩選照常運作)。
17 · InternX Academy 職涯課程平台(原型已完成;主平台整併後續排程)
本 repo 已含完整可操作原型:探索 /academy → 詳情+Mock 購買 /course → 教室播放器(含字幕 CC)/classroom → /my-courses;創作者後台 /academy-studio(4 步開課精靈/字幕與教材上傳/收益/留言管理)、管理後台 /academy-admin(審核/訂單/收益/分潤)。資料層在 assets/academy/academy.js:localStorage 持久化+PaymentProvider 抽象層(FakePay),Order → Payment → Enrollment → RevenueRecord 全流程真的會跑。整併進 internx.me 主平台仍屬後續排程(低優先),完整需求見 ACADEMY-PRD.md(交接首頁可線上閱讀);下面的預留事項仍然成立。
17.1 入口地圖(各頁彼此怎麼進;詳版見 INTEGRATION.md §20.1)
| 頁面 | 誰用 | 進入方式(全部列出) |
|---|---|---|
| /academy 探索 | 學生 | 主導覽「職涯學院」(每頁都有,手機=漢堡選單)· Footer「職涯學院」· 活動頁「延伸學習資源→看更多」· 各頁麵包屑 |
| /course?id=… 詳情 | 學生 | 探索頁課程卡 · 活動頁推薦卡 · 其他課程「推薦搭配」· 我的課程「課程頁」· 教室頂欄「課程介紹」· 完課 modal 推薦 |
| /classroom?id=…&lesson=… 教室 | 學生(未購=試看模式) | 購買成功 modal「進入教室」· 課程頁「開始/繼續上課」· 章節列表「免費試看」單元 · 探索頁「繼續上課」條 · 我的課程「繼續上課/複習」(自動接上次單元) |
| /my-courses | 學生 | 帳號選單「我的課程」· 手機選單 · Footer · 課程頁(已購)· 購買成功 modal |
| /academy-studio?tab=… | 創作者(verified-creator) | 帳號選單「課程後台(開課)」· 手機選單;預覽未上架課 → /course(創作者預覽 banner) |
| /academy-admin?tab=… | Admin | 帳號選單「課程管理(Admin)」;審核動線:Studio 送審 → 待審 badge → 通過即上架前台/退回理由回寫 Studio |
URL 約定:/course?id=(無效自動回探索)、/classroom?id=&lesson=(lesson 省略=接上次進度)、/academy?career=pm,finance(給職缺頁/履歷健檢帶入預選)、後台 ?tab= 直達分頁。PRD 整合章 §3 的 8 個入口:Navbar/帳號選單/Footer/活動頁 demo 已做,個人頁/職缺頁/履歷健檢/公司頁/面試心得/AI 推薦頁預留(指向同一組 URL 即可)。
一句話定位:職涯導向課程平台(對標 Hahow 但不做泛知識)。課程綁五類標籤(職涯方向 / 技能 / 年級 / 求職階段 / 成果),與職缺、履歷健檢、諮詢、活動互相導流;講師=既有認證創作者,V1 金流用 Mock Payment 但資料流完整(Order → Payment → Enrollment → RevenueRecord)。
17.2 現在實作就該預留的 6 件事(重點)
Academy 本體晚點做沒關係,但下面這些「現在正在做的系統」設計時多想一步,未來就能直接共用:
| 預留點 | 現況(本次交接) | 預留做法 |
|---|---|---|
| 金流資料模型 | §6 / §14.1 代收代付、結算欄位以「活動」為單位設計 | Order / Payment / 結算紀錄加 itemType: 'activity' | 'course'(+ itemId),欄位命名不要綁死 activity —— 未來課程訂單直接進同一套帳 |
| Payment Provider 抽象層 | 活動金流待串接(§6) | 先定 interface:createPayment / verifyPayment / refundPayment / getPaymentStatus。活動金流照這個寫,未來 Academy 只加 FakePaymentProvider→正式(ECPay / NewebPay / Stripe / TapPay)換 Provider、不重寫購買流程 |
| 標籤系統 | §16 剛加 industryTags(活動)、重用 INDUSTRY_CATEGORIES | 課程的職涯方向標籤用同一組 key;技能 / 年級 / 求職階段 / 成果標籤設計成通用 Tag,職缺・履歷健檢・課程共用(推薦匹配的基礎) |
| 講師身分 | verified-role 申請+審核既有(§7) | 課程講師=verified-creator,同一帳號申請 creator role、共用審核,不另開帳號系統 —— 現行設計已滿足,不要分岔 |
| Navbar / 入口 | 現有 6 個主導覽 tab(§15) | 預留「職涯學院」入口位;職缺頁 / 履歷健檢結果頁 / 活動頁預留「推薦課程」區塊(V1 可先空著) |
| 通知 | 站內+Email 通知基建既有(§9) | 課程公告 / 購買 / 審核通知直接接同一 Notification,不另建 |
17.3 新增資料(全部是新 collection,不動既有)
// Academy 專用(未來新增;User/Auth/Order/Payment/Notification/Tag 共用主平台) Courses / CourseSections / CourseLessons / LessonResources Enrollments / CourseProgress / CourseComments / CourseAnnouncements RevenueRecords / CreatorPayouts / CreatorProfiles / CourseReviews / AdminReviewLogs // 字幕(V1 單語中文;2026-07-02 由「不做」改為 V1 需求) lesson.subtitles: { t: number /*秒*/, text: string }[] // 原型 cue 模型 // 正式版:講師逐單元上傳 SRT/VTT → 解析為 cue;LessonVideos.subtitleTracks[](多語預留);CC 開關偏好存使用者設定 // 分潤紀錄(每筆付款成功自動建立;預設抽成 15%,可依創作者 / 課程覆寫) RevenueRecord { orderId, courseId, creatorId, grossAmount: 3000, // 售價 platformFeeRate: 0.15, // → platformFeeAmount: 450 creatorIncomeAmount: 2550, paymentStatus, payoutStatus, paidAt, payoutAt }
17.4 V1 購買資料流(Mock 金流、資料先完整)
點購買 → Order(pending) → Payment(pending, FakeProvider) → 「模擬付款成功」→ Payment(paid) → Order(paid) → 建 Enrollment(取得觀看權限)→ 建 RevenueRecord(分潤)
和 §6 活動金流是同一套代收代付思路:學生付款 → 平台代收 → 依分潤撥給創作者。所以排程上建議等活動金流(§6 / §14.1)穩定後再排 Academy,屆時課程金流直接重用同一套 Provider 抽象與結算後台。
17.5 推薦 API(跨模組整合的核心)
// 職缺頁「申請前建議補強」/ 履歷健檢「下一步建議」/ 活動頁「延伸學習資源」共用一支 GET /recommendations/courses?source_type=job|company|interview_review|resume_review|event|consultation|profile|career_map&source_id=… // 回傳 { courseId, title, reason, matchedTags, priorityScore } // 匹配:Job.requiredSkills ↔ Course.skillTags;Job.category ↔ Course.careerTags;ResumeReview.weaknessTags ↔ Course.skillTags/outcomeTags
推薦一樣要標出原因(「此職缺要求產品企劃、使用者訪談…該課程可補足」)—— 與 §16 活動個人化同一個透明推薦原則。
17.6 整併風險速查(詳版必讀:INTEGRATION.md §21)
原則:產品邏輯照抄 demo(欄位/狀態機/文案),工程實作全部重做(demo 是 localStorage 假後端)。🔴=不解決不能上線。
| 風險 | 一句話 | 解法方向 |
|---|---|---|
| 🔴 訂單模型衝突 | 活動金流若先上線且沒有 itemType/itemId,Academy 併入要 backfill 或雙軌帳 | 現在就把活動金流做成通用 commerce record(唯一不等 Academy 排程的事) |
| 🔴 狀態所有權反轉 | demo 全部前端寫;正式版 status/paidAt/enrollment/payout 只能後端寫 | Cloud Functions + Firestore rules + rules simulator 測試 |
| 🔴 Webhook 髒現實 | FakePay 掩蓋重複回調/亂序/偽造/雙訂單 | 驗簽+冪等鍵+對帳 job;入帳後才建 Enrollment |
| 🔴 權限分支沒被看到 | demo 一人三角色,看不到「非本人課程/被停權/未購買」等錯誤路徑 | 後端逐條檢查(藏選單不算);講師要補收款帳戶核實 |
| 🔴 媒體管線全模擬 | 影片儲存/轉檔/防盜連/上傳狀態機 demo 都是按鈕 | 選型(Stream/Mux 類)+ signed URL 授權+轉檔狀態機 |
| 🟡 退款遇已撥款 | 直接改狀態會弄壞歷史帳 | 調整紀錄(adjustment)從下期撥款扣回 |
| 🟡 i18n/SEO | demo 繁中寫死、無 SSR;課程頁是天然 landing page | UI 字串進翻譯系統;課程頁 SSR+OG+Course schema |
| 🟡 法務三件事 | 發票義務、退款鑑賞期政策、創作者收款個資 | 上線前要有書面答案(見 INTEGRATION §21.7) |
上線策略:契約先行(commerce record+API contract+rules 草稿)→ 四條線並行(後端/學生端/創作者端/媒體)→ feature flag 內測(1–2 位真實創作者跑完整流程含一筆真退款)→ 公開。最小測試清單見 INTEGRATION.md §21.8。
17.7 範圍速記
| V1 做 | V1 不做(結構預留) |
|---|---|
| 課程 CRUD+審核上架、五類職涯標籤、課程詳情頁(適合誰 / 學完獲得什麼 / 對應職缺 / 可產出履歷成果)、Mock 購買全流程、播放器+進度、教材下載、留言、創作者後台(Dashboard / 我的課程 / 建立課程)、Admin(課程審核 / 訂單 / 收益 / 分潤設定) | 正式金流、自動撥款、發票、優惠碼、證書、作業批改、Quiz、直播、字幕、AI 課程推薦、評價系統、退款自動化、組合包(課程+諮詢 / 活動)、企業內訓、學校合作 |
本交接檔為獨立 repo,與 internx.me 程式碼分離;可作為 PR 描述與設計依據附在工程任務上。