This commit is contained in:
王性驊 2026-07-09 03:45:28 +00:00
parent 7ed9f50a2c
commit 2e0b625735
1170 changed files with 8603 additions and 4877 deletions

26
.gitignore vendored
View File

@ -1,24 +1,32 @@
# --- secrets / env --- # --- secrets / env ---
deploy/.env
deploy/.env.dev
deploy/.env.prod
infra/.env
*.env *.env
**/.env **/.env
!.env.example !.env.example
!*.env.example !*.env.example
deploy/.env
deploy/.env.dev
deploy/.env.prod
infra/.env
old/deploy/.env
old/deploy/.env.dev
old/deploy/.env.prod
old/infra/.env
# --- runtime / logs --- # --- runtime / logs ---
.run/ .run/
old/.run/
*.log *.log
# --- build artifacts --- # --- build artifacts ---
backend/bin/
backend/web/dist/
backend/dev-console/dist/
frontend/dist/
dist/ dist/
backend/web/public/downloads/*.zip **/dist/
apps/*/dist/
apps/web/dist/
services/*/bin/
old/backend/bin/
old/backend/web/dist/
old/backend/dev-console/dist/
old/backend/web/public/downloads/*.zip
# --- dependencies --- # --- dependencies ---
node_modules/ node_modules/

View File

@ -0,0 +1,134 @@
---
name: spec-driven
description: >
Spec-driven product workflow: requirements → spec.md → plan.md → small task.md files
with explicit inputs/outputs (≈100200 line changes each). Use when the user wants to
restart cleanly, write requirements, draft a product spec, break a plan into tasks,
run /spec-driven, or says 重新開始 / 需求文件 / 拆 task / 從需求做到 task.
---
# Spec-Driven Workflow
Freeze **what we want**, then **how it behaves**, then **how we deliver**, then **one-step tasks**. Never invent code while a phase is unapproved.
## Artifact root
```text
docs/product/<slug>/
README.md # phase status
requirements.md
spec.md # after requirements approved
plan.md # after spec approved
tasks/
INDEX.md
T001-short-slug.md
```
Templates live in this skill:
```text
references/templates/{requirements,spec,plan,task}.md
references/workflow.md
```
Optional scaffold:
```bash
bash .grok/skills/spec-driven/scripts/init-run.sh <slug> "<one-line goal>"
```
## Invocation
| Form | Behavior |
|------|----------|
| `/spec-driven` | Ask slug + goal if missing; continue current phase |
| `/spec-driven <slug>` | Work on that run |
| `/spec-driven <slug> --status` | Report phase + file presence only |
| `/spec-driven <slug> --phase N` | Only if prior artifacts exist; still require user OK for *this* phase output |
Phases: `0=init`, `1=requirements`, `2=spec`, `3=plan`, `4=tasks`, `5=ready-to-implement`.
## Hard gates
1. **One phase per turn cycle.** Write the current phase document(s), summarize for the user, **stop**.
2. Advance only when the user clearly approves (e.g. 「需求 OK」「spec 通過」「可以拆 task」).
3. Do **not** implement product code until:
- `plan.md` approved, **and**
- `tasks/INDEX.md` exists with at least one `todo` task, **and**
- user says to implement a specific `T###` (or all).
4. Do **not** skip templates' required sections.
5. If prior phase file is missing → stop and say which file is needed.
## Step 0 — Init
1. Resolve `slug` (kebab-case, e.g. `haixun-console`).
2. If `docs/product/<slug>/` missing → run `init-run.sh` or create the same structure.
3. Write/update `README.md` with goal, phase=`requirements`, dates.
4. Ask user to confirm scope in one sentence if ambiguous.
## Step 1 — Requirements
1. Read templates + existing product context (`docs/architecture.md`, related plans, `AGENTS.md` constraints).
2. Write `requirements.md` using the template (product language only).
3. **Forbidden in requirements:** API paths, Go package paths, PR lists, implementation steps.
4. Present: In/Out scope, P0 vs later, success criteria, open questions.
5. Set README phase note: `awaiting-requirements-approval`.
6. **STOP.**
## Step 2 — Spec
Preconditions: `requirements.md` exists and user approved.
1. Write `spec.md` from requirements (behavior contracts).
2. Include Retain / Replace / Remove vs current codebase where relevant.
3. Follow project rules when specifying contracts: JSON envelope, `page/pageSize`, unix nanoseconds UTC, job guarded updates, auth header matrix.
4. **Forbidden in spec:** milestone calendars, task IDs, "we'll refactor later" without a concrete behavior.
5. Present summary → README `awaiting-spec-approval` → **STOP.**
## Step 3 — Plan
Preconditions: `spec.md` approved.
1. Write `plan.md` with milestones M0…Mn, dependencies, risks, verification commands.
2. Map each milestone to a **task id range** (e.g. M1 → T010T019) but do not write full task files yet unless user asked to combine plan+tasks in one approval (default: plan only).
3. Present summary → **STOP.**
## Step 4 — Tasks
Preconditions: `plan.md` approved.
1. Create `tasks/INDEX.md` and one file per task from `references/templates/task.md`.
2. **Task size rule:** estimate **100200 lines** of production code change. If larger, split. If smaller and independent, still OK but avoid micro-noise (<~30 lines unless critical fix).
3. Every task MUST have: Goal, Inputs, Outputs (paths + behavior), Out of scope, Acceptance checks (commands), Depends on.
4. No vague tasks ("improve UX", "cleanup backend").
5. Present INDEX + count → **STOP** until user approves task breakdown.
6. On approval: README phase=`ready-to-implement`.
## Step 5 — Implement (only when asked)
1. Pick exactly one `T###` (or user-specified set).
2. Mark INDEX `doing` → implement only that task's Outputs → acceptance → `done`.
3. Do not pull in neighbor tasks "while you're there" unless user expands scope.
4. Prefer `make test` / `go test ./...` / `make web-build` as listed in the task.
## Status reporting (`--status`)
Print:
- slug, goal, phase
- which of requirements/spec/plan/tasks exist
- task counts by status from INDEX
## Style
- Chinese or English matching the user; product docs for this repo default to **繁體中文**.
- Complete sentences in requirements/spec; tasks can be tighter checklists.
- Link related existing docs; do not duplicate architecture textbooks into requirements.
## Anti-patterns
- Writing all four layers in one go without approval
- Coding during requirements/spec
- Tasks without file paths or acceptance commands
- Treating `docs/post-project-plan.md` as approved requirements without copying into this pipeline
- Inflating a "whole product" requirements doc without P0/P1/P2 prioritization

View File

@ -0,0 +1,48 @@
# Plan: <名稱>
> Status: `draft` | `approved`
> Source: `docs/product/<slug>/spec.md`
> Last updated: `YYYY-MM-DD`
## 1. 交付策略
<先穩什麼再換什麼最後加什麼 36 >
## 2. 里程碑
### M0 — <名稱>
- **目標:**
- **完成定義:**
- **建議 task 區間:** T0xxT0yy
- **驗證:**
### M1 — …
## 3. 依賴
```text
M0 → M1 → M2
↘ M3
```
## 4. 風險與緩解
| 風險 | 緩解 |
|------|------|
| | |
## 5. 刻意不做(本 plan 週期內)
-
## 6. 全域驗證指令
```bash
cd backend && go test ./...
make web-build # 若動前端
```
## 7. 批准
- [ ] Plan 已批准:

View File

@ -0,0 +1,77 @@
# Requirements: <產品 / 功能名稱>
> Status: `draft` | `approved`
> Slug: `<slug>`
> Last updated: `YYYY-MM-DD`
## 1. 一句話
<用一句話說明這個產品或這次範圍要達成什麼>
## 2. 誰用、在什麼情境
| 角色 | 目標 |
|------|------|
| | |
## 3. 背景與動機As-is → 為什麼改)
- **現況問題:**
- **不改的後果:**
- **參考文件(非需求本體):**
## 4. 目標To-be
### 4.1 必須達成P0
1.
2.
### 4.2 應該有P1
1.
### 4.3 以後再說P2
1.
## 5. 範圍
### In scope
-
### Out of scope明確不做
-
## 6. 使用者故事(可驗收)
| ID | 作為… | 我想要… | 以便… | 驗收要點 |
|----|--------|---------|--------|----------|
| US-01 | | | | |
## 7. 成功標準
- [ ]
- [ ]
## 8. 約束與假設
- 技術 / 合規 / 時程約束:
- 假設(未驗證當真):
## 9. 風險
| 風險 | 影響 | 緩解(需求層,非實作細節) |
|------|------|---------------------------|
| | | |
## 10. 開放問題
1.
2.
## 11. 批准
- [ ] 需求已批准(日期 / 誰):

View File

@ -0,0 +1,85 @@
# Spec: <名稱>
> Status: `draft` | `approved`
> Source: `docs/product/<slug>/requirements.md`
> Last updated: `YYYY-MM-DD`
## 1. 摘要
<24 對齊需求 P0>
## 2. 名詞與實體
| 名詞 | 定義 |
|------|------|
| | |
## 3. 狀態機(若有)
```text
stateA --> stateB : 觸發條件
```
## 4. 使用者流程(行為)
### 流程 A
1. 使用者…
2. 系統…
3. 失敗時…
## 5. 介面契約
### 5.1 HTTP / API若適用
- Auth
- Envelope`code/message/data/error`
- 列表:`page` / `pageSize``pagination` + `list`
- 時間unix nanoseconds UTC
| Method | Path | 用途 | 主要 request/response 欄位 |
|--------|------|------|---------------------------|
| | | | |
### 5.2 前端頁面 / 導覽(若適用)
| 路由 / hash | 目的 | 主要操作 |
|-------------|------|----------|
| | | |
### 5.3 Job / 背景任務(若適用)
| Template type | Worker | 觸發 | 成功結果 |
|---------------|--------|------|----------|
| | | | |
## 6. 與現況對照
| 能力 | Retain | Replace | Remove | 備註 |
|------|--------|---------|--------|------|
| | | | | |
## 7. 非功能
- 安全 / 權限:
- 錯誤碼 scope
- 可觀測性job 進度、錯誤訊息):
- 效能邊界(可接受延遲 / 筆數上限):
## 8. 資料保存(邏輯層,非 Mongo 細節亦可)
| 實體 | 關鍵欄位 | 生命週期 |
|------|----------|----------|
| | | |
## 9. 驗收場景Given / When / Then
1. **Given****When****Then**
## 10. 開放規格問題
1.
## 11. 批准
- [ ] Spec 已批准:

View File

@ -0,0 +1,54 @@
# T### — <短標題>
> Status: `todo` | `doing` | `done` | `blocked`
> Milestone: `M#`
> Est. change: `~NNN lines` (target 100200)
## Goal
完成後系統應:<一句可驗證的 Done>
## Depends on
- T###(若無:`—`
## Inputs
- 讀取 / 依賴:
- 檔案:
- 既有能力 / API
- Spec 段落:
## Outputs
### 程式變更(預期路徑)
| 路徑 | 動作 | 說明 |
|------|------|------|
| | add/edit/delete | |
### 行為變更
-
### API / 契約(若有)
-
## Out of scope
-
## Acceptance
- [ ]
- [ ] 指令:
```bash
# e.g.
cd backend && go test ./path/...
```
## Notes
<可選實作提示非必做細節>

View File

@ -0,0 +1,54 @@
# Spec-Driven 四階段說明
## 為什麼要閘門
邊想邊寫碼會讓範圍漂掉。四份文件各解一個問題:
| 文件 | 回答的問題 | 讀者 |
|------|------------|------|
| requirements | 我們要什麼?不要什麼? | 產品 / 你自己 |
| spec | 系統對外行為是什麼? | 實作者 + 審查 |
| plan | 分幾段交付、依賴與風險? | 排程 |
| task | 下一小時具體改什麼? | 動手 agent / 人 |
## 閘門 checklist
### 需求通過
- [ ] 一句話產品目標清楚
- [ ] In / Out of scope 無互相打架
- [ ] P0 可獨立交付價值
- [ ] 成功標準可驗收(不是「更好用」)
- [ ] 開放問題已列出(可帶預設假設)
### Spec 通過
- [ ] 主要實體與狀態可畫出
- [ ] 關鍵使用者路徑有行為描述
- [ ] Retain / Replace / Remove 對現況有交代
- [ ] 錯誤、權限、時間、分頁等跨切約定有寫
- [ ] 沒有偷偷寫實作步驟當規格
### Plan 通過
- [ ] 里程碑順序合理、依賴無環
- [ ] 每里程碑有驗收方式
- [ ] 風險與不做清單明確
- [ ] 對應 task 區間可預估
### Tasks 通過
- [ ] 每 task 單一 Done
- [ ] 預估 100200 行級(過大已拆)
- [ ] Inputs / Outputs / 不做 / 驗收齊
- [ ] INDEX 可追蹤狀態
## 與現有技能的關係
| 技能 | 關係 |
|------|------|
| `/design` | 偏設計文件 + PR Plan本 skill 偏產品需求→小 task |
| `/execute-plan` | 可在 **plan/tasks 批准後** 用來並行實作,非必須 |
| `/check-work` | 單一 task 做完後驗證 |
第一版 **不必** 自動串 execute-plan使用者說「實作 T003」即可。

View File

@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Usage: init-run.sh <slug> "<one-line goal>"
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
SLUG="${1:-}"
GOAL="${2:-}"
if [[ -z "$SLUG" ]]; then
echo "usage: $0 <slug> \"<one-line goal>\"" >&2
exit 1
fi
DIR="$ROOT/docs/product/$SLUG"
mkdir -p "$DIR/tasks"
if [[ ! -f "$DIR/README.md" ]]; then
cat >"$DIR/README.md" <<EOF
# Product run: $SLUG
| Field | Value |
|-------|--------|
| Goal | ${GOAL:-TBD} |
| Phase | requirements |
| Status | draft |
| Updated | $(date -u +%Y-%m-%d) |
## Phase legend
\`requirements\`\`spec\`\`plan\`\`tasks\`\`ready-to-implement\`
## How to continue
\`\`\`text
/spec-driven $SLUG
/spec-driven $SLUG --status
\`\`\`
EOF
fi
if [[ ! -f "$DIR/tasks/INDEX.md" ]]; then
cat >"$DIR/tasks/INDEX.md" <<'EOF'
# Tasks INDEX
| ID | Title | Milestone | Status | Est. lines |
|----|-------|-----------|--------|------------|
|| (尚未拆 taskplan 批准後填入) | | | |
EOF
fi
echo "Initialized $DIR"

431
AGENTS.md
View File

@ -1,413 +1,46 @@
# Agent 規範(重構主線)
# Agent Handoff Notes ## 封存與主線
這個資料夾是新的巡樓後端核心,請優先維持乾淨邊界,不要把舊 Next.js 或 `template-monorepo` 的業務包袱搬進來。 - **`old/`**:舊系統整包封存,**只讀參考**。不要在 `old/` 修功能當交付。
- **新碼**:只寫在 repo 根下新目錄(`apps/`、`services/` 等,以 `docs/product` 為準)。
- **產品真相**`docs/product/<slug>/`requirements → spec → plan → tasks
- **流程**`/spec-driven``.grok/skills/spec-driven/`)。
## 核心原則 ## 交付順序(硬性)
- 全系統時間一律 **UTC+0**;寫入 Mongo / API 的時間欄位一律 **unix nanoseconds**`int64`)。排程的 `timezone` 只用於 cron 解讀與下發 payload不作為儲存時區。 1. Phase A 地基
- 複製模式,不複製舊業務。 2. Phase B 前端 + mock操作順再往下
- `logic` 做 API 編排,`model/usecase` 做可重複使用能力。 3. Phase C 接真 API
- provider adapter 不讀 setting、不碰 Mongo、不知道 HTTP。 4. Phase D 業務加深
- setting 是通用 key-value model不依賴 AI 或其他業務。
- token / API key 第一版每次 request 帶入,不寫入 config。
- SSE contract 由本服務 normalize前端不要讀 provider 原始 chunk。
- JSON API 必須使用 `code/message/data/error` envelope 與 `SSCCCDDD` 錯誤碼。
- 列表 API 必須使用 `page/pageSize` query並在 `data` 回傳 `pagination/list`
- Job 狀態轉移必須使用 guarded/conditional update不要在 API/worker 直接裸 `Update` 覆蓋 job 狀態。
- Redis job lock 的 value 是 `workerID`release / refresh 必須檢查 owner長任務必須 heartbeat。
- Auth 目前是 native email/password + JWT不包含 OAuth / OTP / MFA / Zitadel。不要為了相容 template-monorepo 把重依賴搬進來。
- AI provider token 與會員 JWT 是兩種不同 tokenAI token 每次 request header 帶入,會員 JWT 由 `/api/v1/auth/*` 簽發。
## 設計文件 B 未過關前,不大寫業務後端。
- `docs/job-system-plan.md`:通用 job system 規劃,包含 template、run、schedule、Redis queue/lock、取消語意與 API 草案。 ## 後端契約精神(實作時遵守)
- `docs/post-project-plan.md`:發文計畫(文案任務 Flow A 砍掉重做)— 主題發想、風格分析、矩陣產 N 篇草稿、定時發送。
- `docs/scan-placement-plan.md`:海巡獲客(流程 B— 知識圖譜、Brave 擴展、雙軌爬取7 天重點 / 30 天補充)、產品匹配、島民交接。
## 新增 API 流程 - 時間:**UTC**;儲存 **unix nanoseconds** `int64`
- JSON`code` / `message` / `data` / `error`;成功碼 `102000`
- 列表:`page` / `pageSize``pagination` + `list`
- Job狀態 **guarded** 更新Redis lock value = `workerID`
- 會員 JWT vs AI provider token 分離。
- **禁止**未實作 API 回成功空資料。
1. 修改 `generate/api/*.api` ## 前端精神Harbor Desk
2. 優先使用 `make gen-api` 重新產生 handler/logic/types。
3. 若手寫 handler仍需遵守 `response.Write` 與 validator 流程。
4. SSE endpoint 不使用 `response.Write`,直接輸出 `text/event-stream`
5. 更新 `README.md` 的 API 與架構說明。
## Response / Error Code - 新殼在 `apps/web`;不引入 MUI/Ant/Chakra。
- 不複製任天堂 / Nook UI不用 emoji 當主 icon。
- 字體Inter + Taipei Sans TC。
- mock | live 同一 repository 介面。
- 詳見 `docs/product/haixun-console/spec.md`
錯誤碼格式是 `SSCCCDDD` ## 參考舊碼
需要對照實作時:
```text ```text
SS = scope old/backend/ # 舊 Go / web / node workers
CCC = category old/AGENTS.md # 舊詳細規範
DDD = detail old/docs/ # 舊計劃文件
``` ```
目前 scope 複製模式可以,**不要**把半成品與死碼整包搬回主線。
```text
10 = Facade
32 = Setting
33 = AI
34 = Job
35 = Auth
36 = Member
37 = Permission
```
建立錯誤時使用:
```go
errs.For(code.AI).InputMissingRequired("缺少 AI provider token")
errs.For(code.Setting).ResNotFound("找不到設定")
errs.For(code.Job).ResInvalidState("job state changed; update rejected")
errs.For(code.Auth).AuthUnauthorized("missing bearer token")
```
不要直接手寫 `33104000` 這種數字,也不要回傳裸 `error` 給 handler 後讓使用者看到內部錯誤。
## Pagination
列表 API 使用:
```text
?page=1&pageSize=10
```
response
```json
{
"code": 102000,
"message": "SUCCESS",
"data": {
"pagination": {
"total": 100,
"page": 1,
"pageSize": 10,
"totalPages": 10
},
"list": []
}
}
```
`page/pageSize` 必須是 server 正規化後的值。不要使用 `offset/limit/items`
## 新增 Model 流程
模組放在:
```text
internal/model/<module>/
domain/entity
domain/repository
domain/usecase
repository
usecase
```
依賴方向:
```text
handler -> logic -> model/domain/usecase
model/usecase -> model/domain/repository
model/repository -> Mongo / Redis
```
不要讓 `logic` import `model/<module>/repository`
## Auth / Permission 擴充
目前已接:
```text
POST /api/v1/auth/register
POST /api/v1/auth/login
POST /api/v1/auth/refresh
POST /api/v1/auth/logout # requires member JWT
GET /api/v1/members/me
PATCH /api/v1/members/me
GET /api/v1/permissions/catalog
GET /api/v1/permissions/me
```
Auth matrix`internal/handler/routes.go`
| 路由 | 需要會員 JWT |
|------|----------------|
| `GET /api/v1/health` | 否 |
| `POST /api/v1/auth/register/login/refresh` | 否 |
| `POST /api/v1/auth/logout` | 是(`Authorization` |
| `GET /api/v1/ai/providers` | 否 |
| `POST /api/v1/ai/chat/stream/models` | 是(`X-Member-Authorization`+ provider token`Authorization` |
| `/api/v1/members/*`、`/api/v1/permissions/me` | 是(`Authorization` |
| `/api/v1/permissions/catalog`、`/api/v1/settings/*`、`/api/v1/jobs*`、`/api/v1/job/*` | 是(`Authorization` |
規則:
- 保護路由用 `internal/middleware.Auth``Authorization: Bearer <access_token>`AI 變更路由用 `middleware.MemberAuth``X-Member-Authorization`),因 `Authorization` 保留給 provider API key。
- logic 從 `authctx.ActorFromContext``tenant_id` / `uid`
- 不要在 handler 直接 parse JWTtoken 驗證集中在 `model/auth/usecase`
- 密碼只存 bcrypt hash不回傳、不寫 log。
- `members.roles` 第一版是簡化 role key。正式 RBAC 可逐步補 roles collection但不要破壞 `role_permissions` 的 tenant + role_key contract。
- `Auth.DevHeaderFallback` 只給本機開發,正式環境應關閉。
## AI Provider 擴充
新增 provider 時:
1. 在 `internal/model/ai/domain/enum` 新增 provider id。
2. 在 `internal/model/ai/provider` 新增 adapter。
3. 在 `internal/model/ai/usecase` registry 註冊 provider 與 models。
4. 確保 adapter 回傳統一 `StreamEvent`
5. 不要改 `logic/ai` 的 SSE 格式。
## Job Worker 擴充
新增 job step 時優先註冊 runner handler
```go
runner.RegisterStepHandler("analyze_8d", func(ctx context.Context, step job.StepContext) error {
if err := step.Heartbeat(ctx); err != nil {
return err
}
// do work, check cancel via job usecase if needed
return nil
})
```
規則:
- Handler 不要直接操作 Mongo / Redis透過 job usecase 更新進度、完成、失敗或取消。
- 長任務每個 checkpoint 呼叫 `StepContext.Heartbeat``RefreshRunLock`
- 收到 cancel signal 後呼叫 `AcknowledgeCancel(jobId, workerID)`,不要自行把狀態改成 `cancelled`
- release lock 時必須帶 `workerID`;不要新增無 owner 的 release helper。
## 前端設計規則(`web/`
巡樓 Console 前端在 `haixun-backend/web/`,視覺為**沉穩田園巡檢台**(動森感:天空、雲朵、奶油卡片、青綠 brand**不是**任天堂 UI 複製)。
**字體固定** Inter + Taipei Sans TC圖示僅 `AcIcon` / `AuthDecor` 內原創 SVG 線條圖。**禁止** emoji、貼圖 JPG、咖啡色木質頂欄、Nook / 任天堂命名。樣式集中在 `index.css``--hx-*` token + `hx-*` / `ac-*` / `auth-*` class
不要把舊 Next.js / `template-monorepo` UI 搬進來,也不要引入重型 UI 框架。
### 視覺架構(登入前後共用)
```text
全頁背景 .hx-scene 灰藍天空 → 淡草地單一漸層(淺/深各一套,見 index.css
裝飾層 SceneDecor 雲朵(多朵緩動)+ 淡光暈 + 小葉子;登入與 Layout 共用
奶油卡片 .auth-ticket 2px line 邊框、圓角 2rem、surface 底、可選 .ac-dialog-texture 點陣
頂部品牌列 圖示 .auth-ticket-iconbrand-soft 底)+ ink 標題;不用獨立色塊 ribbon
主內容 表單或 Outlet內文頁用 PageTitle / Card綠色 .ac-title-bar 僅內容區小標)
桌面側欄 .ac-pocket-device 掌上終端外框PATROL PAD 狀態列;固定尺寸 + .ac-pocket-scroll 內捲
手機 .ac-dock 底部最多 4 格 +「更多」sheet
```
| 區域 | 元件 | 關鍵 class |
|------|------|------------|
| 未登入 | `AuthShell` + `LoginPage` / `RegisterPage` | `hx-scene` `auth-scene` `auth-ticket` `auth-welcome` `auth-shell-form` |
| 已登入外殼 | `Layout` | `hx-scene` `ac-app-shell` `ac-app-header` `auth-ticket` `ac-app-main-inner` |
| 背景裝飾 | `AuthDecor.tsx``SceneDecor` | `hx-scene-deco` `auth-cloud--*` |
| 品牌小圖 | `AuthTicketIcon` | `auth-ticket-icon`(小屋+樹,原創 SVG |
| 側欄導覽 | `Layout` + `navApps` | `ac-pocket-device` `ac-app-tile` |
| 手機導覽 | `MobileBottomNav` | `ac-dock` |
**登入頁刻意不做的事**:上方不要獨立大色塊 header表單上方**不要**再放 `ac-title-bar`「登入」大牌(品牌已在 `auth-welcome`)。註冊頁同理可省略重複大標。
**已淘汰、勿加回**`ac-island`(改用 `hx-scene`)、`ac-wood-bar` / 咖啡色木質頂欄、`public/ac/` 貼圖、Nook Phone 文案。
### 技術棧與指令
```text
web/
src/
api/ # API clientenvelope、JWT refresh
auth/ # AuthContext
components/ # Layout、AuthShell、AuthDecor、ui、ThemeToggle、MobileBottomNav、AcIcon
theme/ # ThemeContext淺色 / 深色)
pages/ # 路由頁面
lib/ # acAssets導覽 icon key、jobStatus 等
index.css # 設計 token 與場景樣式唯一來源
```
```bash
make web-dev # dev server :5173proxy 到 :8890
make web-build # tsc + vite build
```
### 字型
| 語言 | 字型 | 載入方式 |
|------|------|----------|
| 繁體中文 | **台北黑體 Taipei Sans TC** | npm `taipei-sans-tc`,在 `index.css` `@import` Regular + Bold |
| 英文 | **Inter**(與 simular.co 相同Google Fonts 免費) | `web/index.html` link |
規則:
- `body` / 中文標題:`Inter` + `Taipei Sans TC` 混排(`--font-sans`)。
- 純英文裝飾字導覽副標、Hero 小字):加 class `display-en`,使用 `--font-en`
- 中文 `line-height` 維持 **1.7+**;不要用過細字重當標題(標題用 `font-bold` / `font-black`)。
- 只載入 Taipei Sans TC **Regular + Bold**,不要載入 Light避免小字過細。
- 不要改回 Noto Sans TC也不要手寫 `#333` 這類裸色碼當主色。
### 對比度與字級
- 內文、表頭、表單 label、卡片說明優先 `text-ink` / `text-ink-secondary`**不要**拿 `text-muted` 當主要閱讀文字。
- `text-muted` 只給次要提示筆數、hint、placeholder 用 `text-subtle`)。
- 表單輸入字級 **15px**`text-[15px]`),輸入框底用 `bg-surface` 白底,確保與背景拉開。
- 淺色 `muted``#5a6578`、深色約 `#b8c4d6`;改色時以「小字仍可舒適閱讀」為準,不要回到 `#94a3b8` 那種淡灰。
### 主題(淺色 / 深色)
- `ThemeProvider``src/theme/ThemeContext.tsx`)包住 App偏好存 `localStorage` key`haixun.theme``light` | `dark`)。
- `index.html` 內嵌 script 在 React 載入前設定 `data-theme`,避免閃爍。
- 所有顏色必須走 CSS 變數 `--hx-*`,再映射到 Tailwind `@theme``bg-canvas`、`text-brand` 等)。
- 切換按鈕用 `ThemeToggle``ac-btn-secondary` 樣式);`Layout` 頂欄與 `AuthShell` 右上角都要有。
- **禁止**在元件裡寫死 `bg-slate-*`、`text-emerald-*`、`bg-amber-*` 等 Tailwind 預設色;語意狀態用 `text-success` / `text-warning` / `text-danger``jobStatus.ts` 的 badge class。
淺色:低飽和灰藍天空 + 灰綠草地 + 奶油 `surface` + **brand 青綠**;深色:黃昏低對比、同一套 token 自動切換。頂欄與卡片內品牌區都用 **surface / ink / brand**,不要再用木色 `#c4a882` 當 header 底。
### 場景與卡片 class維護時對照
| Class | 用途 |
|-------|------|
| `.hx-scene` | 全頁天空→草地漸層(登入 + 已登入根節點) |
| `.hx-scene-deco` / `SceneDecor` | 背景雲、光暈、葉子(`pointer-events: none` |
| `.auth-ticket` | 奶油主卡片外框(登入卡、已登入主內容區) |
| `.auth-welcome` | 卡片內品牌列:圖示 + 標題 + 一句 tagline底部分隔線 |
| `.ac-app-header` | 已登入 sticky 頂欄:半透明 surface + blur**非**木色 |
| `.ac-title-bar` | 內容區綠色小標題(裝置色漸層);用於 `PageTitle` 等,**不**用於登入頁表單上方大牌 |
| `.ac-pocket-device` | 側欄掌上終端;`--pocket-width`28rem、`--pocket-screen-height` 固定,內容在 `.ac-pocket-scroll` 捲動 |
| `.ac-app-tile` / `.ac-dock` | App 格導覽、手機底欄 |
| `.auth-shell-form` | 登入/註冊表單放大字級(僅 auth 頁) |
側欄標示用 **PATROL PAD** 等中性英文裝飾字(`display-en`);圖示僅 `AcIcon` SVG。
### 色彩 token語意命名
開發時只用這些 Tailwind class值定義在 `web/src/index.css`
| Token | 用途 |
|-------|------|
| `canvas` | 全頁背景 |
| `surface` / `surface-muted` | 卡片、輸入框底 |
| `ink` / `ink-secondary` / `muted` | 主文 / 次文 / 輔助 |
| `line` | 邊框 |
| `brand` / `brand-hover` / `brand-soft` | 主 CTA、active 導覽、連結 hover |
| `glow` | 裝飾色塊(`.glow-blob-alt` |
| `success` / `warning` / `danger`(含 `*-soft` | 狀態、錯誤、Job badge |
主按鈕一律 `Button variant="primary"``bg-brand`,不要用全黑按鈕。
### 圓角與陰影
```text
--radius-sm 0.75rem 小元素、code
--radius-md 1.25rem Input / Textarea
--radius-lg 1.75rem Card
--radius-xl 2.25rem Hero、QuickLink、StatCard
--radius-pill 9999px Button、Badge、導覽 pill
```
陰影用 utility`shadow-card`(一般卡片)、`shadow-soft`主按鈕、Hero、`.auth-ticket`)。內容 Hero 可用 `ac-bulletin` + `ac-hero-gradient` token全頁裝飾雲朵走 `SceneDecor`,不要另加會打架的強色 blob。
### 共用元件(優先復用)
新頁面必須從 `src/components/ui.tsx` 組裝,不要另寫一套按鈕樣式:
| 元件 | 用途 |
|------|------|
| `PageTitle` | 頁面標題 + 副標 |
| `Card` | 內容區塊 |
| `Field` + `Input` / `Textarea` | 表單 |
| `Button` | `primary` / `ghost` / `danger` / `soft` |
| `Badge` | 標籤 pill`brand` / `sky` / `success` / `warning` / `danger` / `neutral` |
| `StatCard` / `QuickLinkCard` | 總覽統計與快捷入口 |
| `ErrorText` / `CopyableId` | 錯誤與可複製 ID |
`Button` 必須渲染 `{children}`;文案用**中文動詞**(例:「建立背景任務」「重新載入任務列表」),不要留空白小框。
### RWD手機
- `< lg`:隱藏左側欄;**底部固定導覽**最多 **4 格**(總覽 / 任務 / 排程 / **更多**),不要把漢堡或 ⋯ 選單放在左上角。
- 「更多」以底部 sheet 展開AI、模板、設定、會員、權限、主題切換、登出。
- 主內容加 `layout-main` 底部 padding避開 tab bar + `safe-area-inset-bottom`
- 寬表格包 `overflow-x-auto` + `min-w-*`,避免小螢幕擠爆版面。
### 版面與導覽
- 已登入(桌面):`Layout` = `hx-scene` 背景 + `SceneDecor` + `ac-app-header`(品牌 + 角色 chip + `ThemeToggle`+ 左 `ac-pocket-device` + 右 `auth-ticket` 主內容 `Outlet`
- 已登入(手機):同上頂欄;導覽走 `MobileBottomNav`(總覽/任務/排程/更多)。
- 側欄 App 來源:`src/lib/acAssets.ts` 的 `navApps`;圖示 key 對應 `AcIcon`
- Active 導覽:`ac-app-tile--active`brand-soft 底 + brand 字色hover`bg-brand-soft text-brand`。
- 未登入:`AuthShell` 置中 `auth-ticket` + 右上 `ThemeToggle``auth-welcome` 內品牌,表單緊接說明文字。
- 語氣:年輕、直接、短句;可帶「島民」「巡樓」等原創文案,避免企業八股與任天堂用語。
### API 與狀態
- JSON 一律走 `api/client.ts``code/message/data` envelope需登入加 `{ auth: true }`
- AI 路由用 `X-Member-Authorization`provider token 用 `Authorization`(見後端 Auth matrix
- Job 狀態中文與 badge 色:`src/lib/jobStatus.ts``jobStatusLabel` / `jobStatusBadgeClass`),列表有進行中任務時可每 3 秒 refresh。
- 不要在前端 parse JWT`uid` / `tenant_id``AuthContext` 讀。
### 新增頁面流程
1. 在 `App.tsx` 掛路由(需登入的放在 `Layout` 底下,自動享有 `hx-scene` + 頂欄 + 主內容 `auth-ticket`)。
2. 頁面內用 `PageTitle`(含 `.ac-title-bar` 小標)+ `Card` / `ac-bulletin` + `ui.tsx` 元件;色票只引用 semantic token。
3. 若需新語意色,**先**改 `index.css``--hx-*``@theme`,再改元件;不要頁面內硬編色碼。
4. 新導覽項:改 `acAssets.ts``navApps`,並在 `AcIcon` 補 SVG path。
5. 完成後執行 `make web-build`
### 島民頁面互動(可推廣 runtime
掛在 `Layout` 底下的新頁面**自動**支援島民操作,不需每頁手寫 executor。
模組入口:`web/src/lib/islander/index.ts`
| 層 | 職責 |
|----|------|
| `pageSnapshot` | 掃描 `.ac-app-shell` 內可互動元素,產生 `hx-*` ref |
| `islanderActions` | 解析/剝除 `islander-actions` JSON 區塊 |
| `actionExecutor` | 執行 navigate/click/fill/select/scroll 等;可 `registerIslanderActionHandler` 擴充 |
| `islanderAgent` | 串流回覆 → 執行 action → 回傳結果 → 自動 follow-up |
| `buildIslanderContext` | 組裝送給後端的頁面快照 |
**零設定(預設)**:路由掛在 `Layout` 即可;島民讀 DOM + `PageTitle` / `h1` 辨識頁面。預設**不**主動介紹這一頁;僅在使用者明確問頁面/操作時才附【可互動元素】(`userWantsPageContext`)。
**可選增強**(擇一):
1. `useIslanderPage({ title, purpose, hints, suggestions })` — 頁面內動態註冊說明
2. `registerIslanderPage(/^\/foo/, { title, ... })` — 在 `siteGuide.ts` 或模組 init 靜態註冊
3. HTML 慣例:`data-islander-label`(元素名稱)、`data-islander-kind`(類型)、`data-islander-ignore`(排除)、`data-islander-page-title`(頁名)
Action 協定AI 回覆末尾):
```islander-actions
[{ "type": "navigate", "path": "/settings" }, { "type": "click", "ref": "hx-3" }]
```
### 前端禁忌
- 不要引入 MUI / Ant Design / Chakra 等大型 UI 庫。
- 不要為單頁新增第三套配色、木質頂欄、或漸層彩虹按鈕。
- 不要在登入/註冊頁加回獨立大牌 `ac-title-bar` 或咖啡色 header ribbon。
- 不要讓 SSE / AI 直接吃 provider 原始 chunk後端已 normalize
- 不要用 `offset/limit` 呼叫列表 API`page` / `pageSize`
## 驗證
完成變更後至少執行:
```bash
cd haixun-backend
go mod tidy
make fmt
go test ./...
```
有動到前端時另執行:
```bash
make web-build
```

50
README.md Normal file
View File

@ -0,0 +1,50 @@
# Thread-Master / 巡樓(重構主線)
**2026-07-09** 起:舊程式已整包封存至 [`old/`](./old/)**從零重寫**。
## 現在讀哪裡
| 路徑 | 說明 |
|------|------|
| [`docs/product/haixun-console/`](./docs/product/haixun-console/) | 產品需求 → spec → plan → tasks真相來源 |
| [`old/`](./old/) | 舊 monorepo **只讀封存**,不在上面開新功能 |
| [`.grok/skills/spec-driven/`](./.grok/skills/spec-driven/) | `/spec-driven` 流程 skill |
## 目標結構(建立中)
```text
thread-master/
apps/web/ # Harbor Desk 前端Phase Bmock 優先)
services/api/ # 新後端Phase A/C名稱以 plan 為準)
docs/product/ # 產品文件
old/ # 舊碼封存
```
## 交付順序
```text
A 地基(登入 / 權限 / Threads / Job / 通知)
→ B 新前端 + Mock流程與風格先過關
→ C 接真後端
→ D 業務加深(海巡等)
```
## 前端apps/web
```bash
cd apps/web
npm install
npm run dev # 本機開發
npm run build # 型別檢查 + 生產建置
```
產品任務進度:`docs/product/haixun-console/tasks/INDEX.md`(目前從 T001 起)。
## 開發流程
```text
/spec-driven haixun-console
# 或依 tasks 逐題實作,例如「做 T002」
```
查舊行為請到 `old/backend/`(只讀)。

24
apps/web/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

8
apps/web/.oxlintrc.json Normal file
View File

@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}

32
apps/web/README.md Normal file
View File

@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.

13
apps/web/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Harbor Desk · 巡樓</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1365
apps/web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
apps/web/package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "@harbor/web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.7",
"react-dom": "^19.2.7"
},
"devDependencies": {
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"oxlint": "^1.71.0",
"typescript": "~6.0.2",
"vite": "^8.1.1"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
apps/web/public/icons.svg Normal file
View File

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

50
apps/web/src/App.css Normal file
View File

@ -0,0 +1,50 @@
.scaffold {
min-height: 100vh;
margin: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
gap: 0.75rem;
padding: 2rem;
box-sizing: border-box;
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
color: #1e293b;
background: #f8fafc;
}
.scaffold-kicker {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #2a9d8f;
}
.scaffold h1 {
margin: 0;
font-size: 1.75rem;
font-weight: 700;
}
.scaffold-lead {
margin: 0;
max-width: 28rem;
line-height: 1.6;
color: #475569;
}
.scaffold-meta {
margin: 0.5rem 0 0;
font-size: 0.875rem;
color: #64748b;
}
.scaffold code {
font-size: 0.85em;
padding: 0.1em 0.35em;
border-radius: 0.25rem;
background: #e2e8f0;
color: #0f172a;
}

19
apps/web/src/App.tsx Normal file
View File

@ -0,0 +1,19 @@
import "./App.css";
/**
* T001 scaffold placeholder. Routing / Harbor Desk shell start at T004T005.
*/
export default function App() {
return (
<main className="scaffold">
<p className="scaffold-kicker">Harbor Desk</p>
<h1>apps/web</h1>
<p className="scaffold-lead">
Console T001 token App Shell
</p>
<p className="scaffold-meta">
<code>npm run dev</code> · <code>npm run build</code>
</p>
</main>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

16
apps/web/src/index.css Normal file
View File

@ -0,0 +1,16 @@
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
#root {
margin: 0;
min-height: 100%;
}
body {
-webkit-font-smoothing: antialiased;
}

10
apps/web/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

7
apps/web/vite.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})

File diff suppressed because one or more lines are too long

View File

@ -1,225 +0,0 @@
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>巡樓 Dev Console</title>
<style>
:root {
--bg: #f4f6f8;
--card: #fff;
--ink: #1a2332;
--muted: #5a6578;
--line: #d8dee8;
--brand: #2a9d8f;
--danger: #c0392b;
--ok: #1e7a4c;
--mono: ui-monospace, 'SF Mono', Menlo, monospace;
--sans: system-ui, -apple-system, sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: var(--sans);
background: var(--bg);
color: var(--ink);
line-height: 1.5;
}
header {
padding: 1rem 1.25rem;
background: var(--card);
border-bottom: 1px solid var(--line);
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
header h1 { margin: 0; font-size: 1.1rem; }
header p { margin: 0.15rem 0 0; color: var(--muted); font-size: 0.85rem; }
main {
max-width: 960px;
margin: 0 auto;
padding: 1rem 1.25rem 3rem;
display: grid;
gap: 1rem;
}
section {
background: var(--card);
border: 1px solid var(--line);
border-radius: 12px;
padding: 1rem 1.1rem;
}
section h2 {
margin: 0 0 0.75rem;
font-size: 0.95rem;
letter-spacing: 0.02em;
}
.row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.5rem;
}
label { font-size: 0.82rem; color: var(--muted); min-width: 5.5rem; }
input, select, textarea {
font: inherit;
padding: 0.45rem 0.6rem;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
min-width: 0;
}
input { flex: 1; }
button {
font: inherit;
padding: 0.45rem 0.85rem;
border: 1px solid var(--line);
border-radius: 999px;
background: #fff;
cursor: pointer;
}
button:hover { border-color: var(--brand); color: var(--brand); }
button.primary {
background: var(--brand);
border-color: var(--brand);
color: #fff;
}
button.primary:hover { filter: brightness(1.05); color: #fff; }
button.danger { color: var(--danger); }
.status { font-size: 0.82rem; color: var(--muted); }
.status.ok { color: var(--ok); }
.status.err { color: var(--danger); }
.list {
margin: 0.5rem 0 0;
padding: 0;
list-style: none;
border: 1px solid var(--line);
border-radius: 8px;
overflow: hidden;
}
.list li {
padding: 0.55rem 0.75rem;
border-bottom: 1px solid var(--line);
cursor: pointer;
display: flex;
justify-content: space-between;
gap: 0.75rem;
font-size: 0.9rem;
}
.list li:last-child { border-bottom: none; }
.list li:hover { background: #f0faf8; }
.list li.active { background: #e6f5f2; font-weight: 600; }
.list li .meta { color: var(--muted); font-size: 0.78rem; }
pre, .log {
margin: 0.5rem 0 0;
padding: 0.75rem;
background: #0f1419;
color: #d6e2f0;
border-radius: 8px;
font-family: var(--mono);
font-size: 0.78rem;
overflow: auto;
max-height: 280px;
white-space: pre-wrap;
word-break: break-word;
}
.steps { margin: 0.5rem 0 0; font-size: 0.85rem; }
.steps div { padding: 0.2rem 0; }
.badge {
display: inline-block;
padding: 0.1rem 0.45rem;
border-radius: 999px;
font-size: 0.72rem;
background: #eef2f7;
}
.badge.running { background: #fff3cd; }
.badge.succeeded, .badge.completed { background: #d4edda; }
.badge.failed { background: #f8d7da; }
.badge.queued, .badge.pending { background: #e8eef5; }
textarea { width: 100%; min-height: 120px; font-family: var(--mono); font-size: 0.78rem; }
.hint { font-size: 0.8rem; color: var(--muted); margin-top: 0.35rem; }
</style>
<script type="module" crossorigin src="/assets/index-56hNfpGK.js"></script>
</head>
<body>
<header>
<div>
<h1>巡樓 Dev Console</h1>
<p>暫用開發面板 · 人設 + 8D 公開爬蟲 · 重做前端前夠用就好</p>
</div>
<div class="status" id="connStatus">尚未連線</div>
</header>
<main>
<section>
<h2>1. 登入</h2>
<div class="row">
<label for="tenantId">Tenant</label>
<input id="tenantId" value="default" />
</div>
<div class="row">
<label for="email">Email</label>
<input id="email" type="email" placeholder="you@example.com" />
</div>
<div class="row">
<label for="password">密碼</label>
<input id="password" type="password" placeholder="至少 8 碼" />
</div>
<div class="row">
<button class="primary" id="btnLogin">登入</button>
<button id="btnRegister">註冊</button>
<button id="btnLogout" class="danger">登出</button>
<span class="status" id="authStatus"></span>
</div>
</section>
<section>
<h2>2. 人設</h2>
<div class="row">
<label for="displayName">名稱</label>
<input id="displayName" placeholder="新人設" />
<button class="primary" id="btnCreatePersona">建立</button>
<button id="btnRefreshPersonas">重新載入</button>
</div>
<ul class="list" id="personaList"></ul>
<p class="hint">點選一筆人設後,下方可跑 8D 分析。</p>
</section>
<section>
<h2>3. 8D 風格分析(公開爬蟲,不需 session</h2>
<div class="row">
<label for="benchmark">對標帳號</label>
<input id="benchmark" placeholder="@username" />
<button class="primary" id="btnStart8d" disabled>開始 8D</button>
</div>
<p class="hint">需先啟動 Go API:8890與 Node workerstyle-8d。設定頁要有研究用 AI API key。</p>
<div class="status" id="jobHint"></div>
<div class="row" style="margin-top: 0.5rem">
<label for="jobId">Job ID</label>
<input id="jobId" placeholder="自動填入或手動貼上" />
<button id="btnPollJob">查詢一次</button>
<button id="btnAutoPoll">自動輪詢</button>
</div>
<div class="steps" id="jobSteps"></div>
<pre id="jobDetail">(尚無任務)</pre>
</section>
<section>
<h2>4. 人設詳情</h2>
<div class="row">
<button id="btnReloadPersona" disabled>重新載入選中人設</button>
<span class="status" id="personaMeta"></span>
</div>
<textarea id="personaDetail" readonly placeholder="選中人設後顯示 style_profile 等欄位"></textarea>
</section>
<section>
<h2>Log</h2>
<pre class="log" id="log"></pre>
</section>
</main>
</body>
</html>

View File

@ -1,396 +0,0 @@
syntax = "v1"
type (
CopySuggestedTagData {
Tag string `json:"tag"`
Reason string `json:"reason,omitempty"`
SearchIntent string `json:"search_intent,omitempty"`
SearchType string `json:"search_type,omitempty"`
}
CopySimilarAccountData {
Username string `json:"username"`
Reason string `json:"reason,omitempty"`
Source string `json:"source,omitempty"`
MatchedSource []string `json:"matched_source,omitempty"`
Confidence string `json:"confidence,omitempty"`
Status string `json:"status,omitempty"`
TopicRelevance float64 `json:"topic_relevance,omitempty"`
LastSeenAt int64 `json:"last_seen_at,omitempty"`
ProfileUrl string `json:"profile_url,omitempty"`
AuthorVerified bool `json:"author_verified,omitempty"`
FollowerCount int `json:"follower_count,omitempty"`
EngagementScore int `json:"engagement_score,omitempty"`
LikeCount int `json:"like_count,omitempty"`
ReplyCount int `json:"reply_count,omitempty"`
PostCount int `json:"post_count,omitempty"`
}
CopyAudienceSampleData {
Username string `json:"username"`
SamplePostId string `json:"sample_post_id,omitempty"`
SampleText string `json:"sample_text,omitempty"`
ReplyLikeCount int `json:"reply_like_count,omitempty"`
Appearances int `json:"appearances,omitempty"`
FirstSeenAt int64 `json:"first_seen_at"`
LastSeenAt int64 `json:"last_seen_at,omitempty"`
Status string `json:"status,omitempty"`
}
CopyMissionResearchItemData {
Title string `json:"title,omitempty"`
URL string `json:"url,omitempty"`
Snippet string `json:"snippet,omitempty"`
Query string `json:"query,omitempty"`
}
CopyMissionResearchMapData {
AudienceSummary string `json:"audience_summary,omitempty"`
ContentGoal string `json:"content_goal,omitempty"`
Questions []string `json:"questions,omitempty"`
Pillars []string `json:"pillars,omitempty"`
Exclusions []string `json:"exclusions,omitempty"`
KnowledgeItems []string `json:"knowledge_items,omitempty"`
SelectedKnowledgeItems []string `json:"selected_knowledge_items,omitempty"`
ResearchItems []CopyMissionResearchItemData `json:"research_items,omitempty"`
SuggestedTags []CopySuggestedTagData `json:"suggested_tags,omitempty"`
SimilarAccounts []CopySimilarAccountData `json:"similar_accounts,omitempty"`
AudienceSamples []CopyAudienceSampleData `json:"audience_samples,omitempty"`
BenchmarkNotes string `json:"benchmark_notes,omitempty"`
}
ExpandCopyMissionGraphHandlerReq {
CopyMissionPath
ExpandKnowledgeGraphReq
}
PatchCopyMissionGraphNodesHandlerReq {
CopyMissionPath
PatchKnowledgeGraphNodesReq
}
PatchSimilarAccountReq {
Status string `json:"status"`
}
PatchAudienceSampleReq {
Status string `json:"status"`
}
CopyMissionSimilarAccountPath {
PersonaID string `path:"personaId" validate:"required"`
ID string `path:"id" validate:"required"`
Username string `path:"username" validate:"required"`
}
PatchSimilarAccountHandlerReq {
CopyMissionSimilarAccountPath
PatchSimilarAccountReq
}
PatchAudienceSampleHandlerReq {
CopyMissionSimilarAccountPath
PatchAudienceSampleReq
}
CopyMissionData {
ID string `json:"id"`
PersonaID string `json:"persona_id"`
Label string `json:"label,omitempty"`
SeedQuery string `json:"seed_query,omitempty"`
Brief string `json:"brief,omitempty"`
ResearchMap CopyMissionResearchMapData `json:"research_map,omitempty"`
SelectedTags []string `json:"selected_tags,omitempty"`
LastScanJobID string `json:"last_scan_job_id,omitempty"`
Status string `json:"status,omitempty"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
}
ListCopyMissionsData {
List []CopyMissionData `json:"list"`
}
CreateCopyMissionReq {
Label string `json:"label" validate:"required"`
SeedQuery string `json:"seed_query" validate:"required"`
Brief string `json:"brief" validate:"required"`
}
UpdateCopyMissionReq {
Label *string `json:"label,optional"`
SeedQuery *string `json:"seed_query,optional"`
Brief *string `json:"brief,optional"`
AudienceSummary *string `json:"audience_summary,optional"`
ContentGoal *string `json:"content_goal,optional"`
Questions []string `json:"questions,optional"`
Pillars []string `json:"pillars,optional"`
Exclusions []string `json:"exclusions,optional"`
KnowledgeItems []string `json:"knowledge_items,optional"`
SelectedKnowledgeItems []string `json:"selected_knowledge_items,optional"`
BenchmarkNotes *string `json:"benchmark_notes,optional"`
SelectedTags []string `json:"selected_tags,optional"`
Status *string `json:"status,optional"`
}
CopyMissionScanScheduleData {
ID string `json:"id,omitempty"`
PersonaID string `json:"persona_id"`
MissionID string `json:"mission_id"`
Cron string `json:"cron"`
Timezone string `json:"timezone"`
Enabled bool `json:"enabled"`
NextRunAt int64 `json:"next_run_at,omitempty"`
LastRunAt int64 `json:"last_run_at,omitempty"`
}
UpsertCopyMissionScanScheduleReq {
Cron string `json:"cron,optional"`
Timezone string `json:"timezone,optional"`
Enabled bool `json:"enabled"`
}
UpsertCopyMissionScanScheduleHandlerReq {
CopyMissionPath
UpsertCopyMissionScanScheduleReq
}
CopyMissionPath {
PersonaID string `path:"personaId" validate:"required"`
ID string `path:"id" validate:"required"`
}
PersonaCopyMissionsPath {
PersonaID string `path:"personaId" validate:"required"`
}
CopyMissionInspirationReq {
Keyword string `json:"keyword,optional"`
ContentDirection string `json:"content_direction,optional"`
UseWebSearch bool `json:"use_web_search,optional"`
AvoidTopics []string `json:"avoid_topics,optional"`
}
CopyMissionInspirationHandlerReq {
PersonaCopyMissionsPath
CopyMissionInspirationReq
}
CreateCopyMissionHandlerReq {
PersonaCopyMissionsPath
CreateCopyMissionReq
}
UpdateCopyMissionHandlerReq {
CopyMissionPath
UpdateCopyMissionReq
}
StartCopyMissionAnalyzeJobData {
JobID string `json:"job_id"`
Status string `json:"status"`
Message string `json:"message"`
}
StartCopyMissionScanJobData {
JobID string `json:"job_id"`
Status string `json:"status"`
Message string `json:"message"`
}
StartCopyMissionMatrixJobReq {
Count int `json:"count,optional"`
}
StartCopyMissionMatrixJobHandlerReq {
CopyMissionPath
StartCopyMissionMatrixJobReq
}
StartCopyMissionMatrixJobData {
JobID string `json:"job_id"`
Status string `json:"status"`
Message string `json:"message"`
}
StartCopyMissionCopyDraftJobReq {
ScanPostID string `json:"scan_post_id" validate:"required"`
}
StartCopyMissionCopyDraftJobHandlerReq {
CopyMissionPath
StartCopyMissionCopyDraftJobReq
}
StartCopyMissionCopyDraftJobData {
JobID string `json:"job_id"`
Status string `json:"status"`
Message string `json:"message"`
}
ListCopyMissionScanPostsReq {
Limit int `form:"limit,optional"`
}
ListCopyMissionScanPostsHandlerReq {
CopyMissionPath
ListCopyMissionScanPostsReq
}
GenerateCopyMissionMatrixReq {
Count int `json:"count,optional"`
}
GenerateCopyMissionMatrixHandlerReq {
CopyMissionPath
GenerateCopyMissionMatrixReq
}
GenerateCopyMissionMatrixData {
Drafts []CopyDraftData `json:"drafts"`
Message string `json:"message"`
}
ListCopyMissionCopyDraftsData {
List []CopyDraftData `json:"list"`
Total int `json:"total"`
}
DeleteCopyMissionMatrixDraftsReq {
DraftIDs []string `json:"draft_ids,optional"`
}
DeleteCopyMissionMatrixDraftsHandlerReq {
CopyMissionPath
DeleteCopyMissionMatrixDraftsReq
}
DeleteCopyMissionMatrixDraftsData {
Deleted int `json:"deleted"`
Message string `json:"message"`
}
CopyMissionScheduleCopyDraftsReq {
AccountID string `json:"account_id" validate:"required"`
DraftIDs []string `json:"draft_ids" validate:"required,min=1"`
StartAt int64 `json:"start_at,optional"`
Timezone string `json:"timezone,optional"`
Slots []PublishSlotData `json:"slots,optional"`
Mode string `json:"mode,optional"`
}
ScheduleCopyMissionDraftsHandlerReq {
CopyMissionPath
CopyMissionScheduleCopyDraftsReq
}
CopyMissionScheduleCopyDraftsData {
Scheduled int `json:"scheduled"`
List []PublishQueueItemData `json:"list"`
Message string `json:"message"`
}
CopyMissionInspirationSourceData {
Query string `json:"query,omitempty"`
Title string `json:"title,omitempty"`
Snippet string `json:"snippet,omitempty"`
URL string `json:"url,omitempty"`
}
CopyMissionInspirationData {
TopicCandidateID string `json:"topic_candidate_id,omitempty"`
ContentPlanID string `json:"content_plan_id,omitempty"`
Label string `json:"label"`
SeedQuery string `json:"seed_query"`
Brief string `json:"brief"`
TrendReason string `json:"trend_reason,omitempty"`
TrendKeywords []string `json:"trend_keywords,omitempty"`
Angles []string `json:"angles,omitempty"`
Mission string `json:"mission,omitempty"`
TargetAudience string `json:"target_audience,omitempty"`
OpeningType string `json:"opening_type,omitempty"`
BodyType string `json:"body_type,omitempty"`
Emotion string `json:"emotion,omitempty"`
CtaType string `json:"cta_type,omitempty"`
RiskLevel string `json:"risk_level,omitempty"`
Avoid []string `json:"avoid,omitempty"`
Sources []CopyMissionInspirationSourceData `json:"sources,omitempty"`
WebSearchUsed bool `json:"web_search_used"`
Message string `json:"message"`
}
)
@server(
group: copy_mission
prefix: /api/v1/personas
middleware: AuthJWT
tags: "CopyMission"
summary: "Copy ninja missions (Flow A). Requires Bearer JWT."
)
service gateway {
@handler listCopyMissions
get /:personaId/copy-missions (PersonaCopyMissionsPath) returns (ListCopyMissionsData)
@handler inspireCopyMission
post /:personaId/copy-mission-inspiration (CopyMissionInspirationHandlerReq) returns (CopyMissionInspirationData)
@handler createCopyMission
post /:personaId/copy-missions (CreateCopyMissionHandlerReq) returns (CopyMissionData)
@handler getCopyMission
get /:personaId/copy-missions/:id (CopyMissionPath) returns (CopyMissionData)
@handler updateCopyMission
patch /:personaId/copy-missions/:id (UpdateCopyMissionHandlerReq) returns (CopyMissionData)
@handler deleteCopyMission
delete /:personaId/copy-missions/:id (CopyMissionPath)
@handler startCopyMissionAnalyzeJob
post /:personaId/copy-missions/:id/analyze-jobs (CopyMissionPath) returns (StartCopyMissionAnalyzeJobData)
@handler startCopyMissionScanJob
post /:personaId/copy-missions/:id/scan-jobs (CopyMissionPath) returns (StartCopyMissionScanJobData)
@handler listCopyMissionScanPosts
get /:personaId/copy-missions/:id/scan-posts (ListCopyMissionScanPostsHandlerReq) returns (ListPersonaViralScanPostsData)
@handler generateCopyMissionMatrix
post /:personaId/copy-missions/:id/matrix-drafts (GenerateCopyMissionMatrixHandlerReq) returns (GenerateCopyMissionMatrixData)
@handler startCopyMissionMatrixJob
post /:personaId/copy-missions/:id/matrix-jobs (StartCopyMissionMatrixJobHandlerReq) returns (StartCopyMissionMatrixJobData)
@handler startCopyMissionCopyDraftJob
post /:personaId/copy-missions/:id/copy-draft-jobs (StartCopyMissionCopyDraftJobHandlerReq) returns (StartCopyMissionCopyDraftJobData)
@handler listCopyMissionCopyDrafts
get /:personaId/copy-missions/:id/copy-drafts (CopyMissionPath) returns (ListCopyMissionCopyDraftsData)
@handler deleteCopyMissionMatrixDrafts
post /:personaId/copy-missions/:id/matrix-drafts/delete (DeleteCopyMissionMatrixDraftsHandlerReq) returns (DeleteCopyMissionMatrixDraftsData)
@handler scheduleCopyMissionDrafts
post /:personaId/copy-missions/:id/copy-drafts/schedule (ScheduleCopyMissionDraftsHandlerReq) returns (CopyMissionScheduleCopyDraftsData)
@handler getCopyMissionScanSchedule
get /:personaId/copy-missions/:id/scan-schedule (CopyMissionPath) returns (CopyMissionScanScheduleData)
@handler upsertCopyMissionScanSchedule
put /:personaId/copy-missions/:id/scan-schedule (UpsertCopyMissionScanScheduleHandlerReq) returns (CopyMissionScanScheduleData)
@handler patchCopyMissionSimilarAccount
patch /:personaId/copy-missions/:id/similar-accounts/:username (PatchSimilarAccountHandlerReq) returns (CopyMissionData)
@handler patchCopyMissionAudienceSample
patch /:personaId/copy-missions/:id/audience-samples/:username (PatchAudienceSampleHandlerReq) returns (CopyMissionData)
@handler expandCopyMissionGraph
post /:personaId/copy-missions/:id/knowledge-graph/expand (ExpandCopyMissionGraphHandlerReq) returns (ExpandKnowledgeGraphData)
@handler getCopyMissionGraph
get /:personaId/copy-missions/:id/knowledge-graph (CopyMissionPath) returns (KnowledgeGraphData)
@handler patchCopyMissionGraphNodes
patch /:personaId/copy-missions/:id/knowledge-graph/nodes (PatchCopyMissionGraphNodesHandlerReq) returns (KnowledgeGraphData)
}

View File

@ -1,32 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func CreateCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.CreateCopyMissionHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := copy_mission.NewCreateCopyMissionLogic(r.Context(), svcCtx)
data, err := l.CreateCopyMission(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,32 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func DeleteCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.CopyMissionPath
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := copy_mission.NewDeleteCopyMissionLogic(r.Context(), svcCtx)
err := l.DeleteCopyMission(&req)
response.Write(r.Context(), w, nil, err)
}
}

View File

@ -1,32 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func DeleteCopyMissionMatrixDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.DeleteCopyMissionMatrixDraftsHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := copy_mission.NewDeleteCopyMissionMatrixDraftsLogic(r.Context(), svcCtx)
data, err := l.DeleteCopyMissionMatrixDrafts(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,32 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func ExpandCopyMissionGraphHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ExpandCopyMissionGraphHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := copy_mission.NewExpandCopyMissionGraphLogic(r.Context(), svcCtx)
data, err := l.ExpandCopyMissionGraph(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,32 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func GenerateCopyMissionMatrixHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.GenerateCopyMissionMatrixHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := copy_mission.NewGenerateCopyMissionMatrixLogic(r.Context(), svcCtx)
data, err := l.GenerateCopyMissionMatrix(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,32 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func GetCopyMissionGraphHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.CopyMissionPath
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := copy_mission.NewGetCopyMissionGraphLogic(r.Context(), svcCtx)
data, err := l.GetCopyMissionGraph(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,32 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func GetCopyMissionScanScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.CopyMissionPath
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := copy_mission.NewGetCopyMissionScanScheduleLogic(r.Context(), svcCtx)
data, err := l.GetCopyMissionScanSchedule(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,32 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func ListCopyMissionCopyDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.CopyMissionPath
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := copy_mission.NewListCopyMissionCopyDraftsLogic(r.Context(), svcCtx)
data, err := l.ListCopyMissionCopyDrafts(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,32 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func ListCopyMissionScanPostsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ListCopyMissionScanPostsHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := copy_mission.NewListCopyMissionScanPostsLogic(r.Context(), svcCtx)
data, err := l.ListCopyMissionScanPosts(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,32 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func ListCopyMissionsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.PersonaCopyMissionsPath
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := copy_mission.NewListCopyMissionsLogic(r.Context(), svcCtx)
data, err := l.ListCopyMissions(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,32 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func PatchCopyMissionAudienceSampleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.PatchAudienceSampleHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := copy_mission.NewPatchCopyMissionAudienceSampleLogic(r.Context(), svcCtx)
data, err := l.PatchCopyMissionAudienceSample(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,32 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func PatchCopyMissionGraphNodesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.PatchCopyMissionGraphNodesHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := copy_mission.NewPatchCopyMissionGraphNodesLogic(r.Context(), svcCtx)
data, err := l.PatchCopyMissionGraphNodes(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,32 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func PatchCopyMissionSimilarAccountHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.PatchSimilarAccountHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := copy_mission.NewPatchCopyMissionSimilarAccountLogic(r.Context(), svcCtx)
data, err := l.PatchCopyMissionSimilarAccount(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,32 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func ScheduleCopyMissionDraftsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ScheduleCopyMissionDraftsHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := copy_mission.NewScheduleCopyMissionDraftsLogic(r.Context(), svcCtx)
data, err := l.ScheduleCopyMissionDrafts(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,32 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func StartCopyMissionAnalyzeJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.CopyMissionPath
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := copy_mission.NewStartCopyMissionAnalyzeJobLogic(r.Context(), svcCtx)
data, err := l.StartCopyMissionAnalyzeJob(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,32 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func StartCopyMissionCopyDraftJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.StartCopyMissionCopyDraftJobHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := copy_mission.NewStartCopyMissionCopyDraftJobLogic(r.Context(), svcCtx)
data, err := l.StartCopyMissionCopyDraftJob(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,32 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func StartCopyMissionMatrixJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.StartCopyMissionMatrixJobHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := copy_mission.NewStartCopyMissionMatrixJobLogic(r.Context(), svcCtx)
data, err := l.StartCopyMissionMatrixJob(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,32 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func StartCopyMissionScanJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.CopyMissionPath
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := copy_mission.NewStartCopyMissionScanJobLogic(r.Context(), svcCtx)
data, err := l.StartCopyMissionScanJob(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,32 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func UpdateCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.UpdateCopyMissionHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := copy_mission.NewUpdateCopyMissionLogic(r.Context(), svcCtx)
data, err := l.UpdateCopyMission(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,32 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func UpsertCopyMissionScanScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.UpsertCopyMissionScanScheduleHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := copy_mission.NewUpsertCopyMissionScanScheduleLogic(r.Context(), svcCtx)
data, err := l.UpsertCopyMissionScanSchedule(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,32 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package threads_account
import (
"net/http"
"github.com/zeromicro/go-zero/rest/httpx"
"haixun-backend/internal/logic/threads_account"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
func StartPublishInventoryRefillJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.StartPublishInventoryRefillJobHandlerReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := threads_account.NewStartPublishInventoryRefillJobLogic(r.Context(), svcCtx)
data, err := l.StartPublishInventoryRefillJob(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,388 +0,0 @@
package copymission
import (
"strings"
libkg "haixun-backend/internal/library/knowledge"
libmatrix "haixun-backend/internal/library/matrix"
missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
)
type MissionContext struct {
Label string
SeedQuery string
Brief string
ResearchMap missionentity.ResearchMap
}
type PersonaContext struct {
DisplayName string
Persona string
}
func PlanInput(
mission MissionContext,
seed string,
l1Labels []string,
supplemental bool,
strategy libkg.ExpandStrategy,
) libkg.PlanInput {
return libkg.PlanInput{
Seed: seed,
TargetAudience: strings.TrimSpace(mission.ResearchMap.AudienceSummary),
ProductBrief: strings.TrimSpace(mission.Brief),
Pillars: mission.ResearchMap.Pillars,
Questions: mission.ResearchMap.Questions,
L1Labels: l1Labels,
Supplemental: supplemental,
Strategy: strategy,
}
}
func SynthInput(mission MissionContext, persona PersonaContext, sources []libkg.BraveSource) libkg.SynthInput {
label := strings.TrimSpace(mission.Label)
if label == "" {
label = strings.TrimSpace(mission.SeedQuery)
}
return libkg.SynthInput{
BrandDisplayName: label,
TopicName: label,
Seed: strings.TrimSpace(mission.SeedQuery),
ProductBrief: strings.TrimSpace(mission.Brief),
TargetAudience: strings.TrimSpace(mission.ResearchMap.AudienceSummary),
Persona: strings.TrimSpace(persona.Persona),
ResearchPillars: mission.ResearchMap.Pillars,
ResearchQuestions: mission.ResearchMap.Questions,
Sources: sources,
}
}
func PatrolTagInput(mission MissionContext) libkg.PatrolTagInput {
return libkg.PatrolTagInput{
Questions: mission.ResearchMap.Questions,
Pillars: mission.ResearchMap.Pillars,
}
}
func FormatNodeKnowledge(node libkg.Node) string {
label := strings.TrimSpace(node.Label)
if label == "" {
return ""
}
detail := strings.TrimSpace(node.PlacementValue)
if detail == "" {
detail = strings.TrimSpace(node.Relation)
}
if detail != "" {
return label + "" + detail
}
return label
}
// FormatNodeKnowledgeForMatrix includes label, detail, patrol tags, and evidence for copy generation.
func FormatNodeKnowledgeForMatrix(node libkg.Node) string {
label := strings.TrimSpace(node.Label)
if label == "" {
return ""
}
var parts []string
if detail := strings.TrimSpace(node.PlacementValue); detail != "" {
parts = append(parts, detail)
} else if detail := strings.TrimSpace(node.Relation); detail != "" {
parts = append(parts, detail)
}
tags := append(append([]string{}, node.DerivedTags.Relevance...), node.DerivedTags.Recency...)
if len(tags) > 0 {
parts = append(parts, "標籤:"+strings.Join(tags, "、"))
}
if len(node.Evidence) > 0 {
if snippet := strings.TrimSpace(node.Evidence[0].Snippet); snippet != "" {
parts = append(parts, "參考:"+snippet)
}
}
if len(parts) == 0 {
return label
}
return label + "" + strings.Join(parts, "")
}
func KnowledgeItemsFromNodes(nodes []libkg.Node) (all []string, selected []string) {
seen := map[string]struct{}{}
for _, node := range nodes {
line := FormatNodeKnowledge(node)
if line == "" {
continue
}
key := strings.ToLower(line)
if _, ok := seen[key]; ok {
if node.SelectedForScan {
selected = mergeLine(selected, line)
}
continue
}
seen[key] = struct{}{}
all = append(all, line)
if node.SelectedForScan {
selected = append(selected, line)
}
}
return all, selected
}
func ResearchItemsFromSources(sources []libkg.BraveSource) []missionentity.ResearchItem {
items := make([]missionentity.ResearchItem, 0, len(sources))
for _, src := range sources {
if strings.TrimSpace(src.URL) == "" && strings.TrimSpace(src.Snippet) == "" {
continue
}
items = append(items, missionentity.ResearchItem{
Title: src.Title,
URL: src.URL,
Snippet: src.Snippet,
Query: src.Query,
})
}
return items
}
func ApplyDefaultNodeSelection(nodes []libkg.Node) {
ApplyDefaultNodeSelectionPreserving(nodes, nil)
}
func ApplyDefaultNodeSelectionPreserving(nodes []libkg.Node, preserve map[string]bool) {
for i := range nodes {
if preserve != nil {
if sel, ok := preserve[nodes[i].ID]; ok {
nodes[i].SelectedForScan = sel
continue
}
}
kind := strings.TrimSpace(nodes[i].NodeKind)
nodes[i].SelectedForScan = kind == "pain" ||
kind == "symptom" ||
kind == "cause" ||
nodes[i].ProductFitScore >= 70
}
}
func mergeLine(list []string, line string) []string {
for _, item := range list {
if item == line {
return list
}
}
return append(list, line)
}
func mergeKnowledgeLines(base, extra []string) []string {
out := append([]string(nil), base...)
seen := map[string]struct{}{}
for _, item := range out {
seen[strings.ToLower(strings.TrimSpace(item))] = struct{}{}
}
for _, item := range extra {
item = strings.TrimSpace(item)
if item == "" {
continue
}
key := strings.ToLower(item)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, item)
}
return out
}
func knowledgeItemsFromNodesForMatrix(nodes []libkg.Node) (all []string, selected []string) {
seen := map[string]struct{}{}
for _, node := range nodes {
line := FormatNodeKnowledgeForMatrix(node)
if line == "" {
continue
}
key := strings.ToLower(line)
if _, ok := seen[key]; ok {
if node.SelectedForScan {
selected = mergeLine(selected, line)
}
continue
}
seen[key] = struct{}{}
all = append(all, line)
if node.SelectedForScan {
selected = append(selected, line)
}
}
return all, selected
}
func knowledgeItemsFromSelectedTags(nodes []libkg.Node, tags []string) []string {
normalized := make([]string, 0, len(tags))
seen := map[string]struct{}{}
for _, tag := range tags {
tag = strings.TrimSpace(tag)
if tag == "" {
continue
}
key := strings.ToLower(tag)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
normalized = append(normalized, key)
}
if len(normalized) == 0 {
return nil
}
out := make([]string, 0)
lineSeen := map[string]struct{}{}
for _, node := range nodes {
if !nodeMatchesTags(node, normalized) {
continue
}
line := FormatNodeKnowledgeForMatrix(node)
if line == "" {
continue
}
if _, ok := lineSeen[line]; ok {
continue
}
lineSeen[line] = struct{}{}
out = append(out, line)
}
return out
}
func nodeMatchesTags(node libkg.Node, tags []string) bool {
candidates := []string{node.Label, node.PlacementValue, node.Relation}
candidates = append(candidates, node.DerivedTags.Relevance...)
candidates = append(candidates, node.DerivedTags.Recency...)
for _, raw := range candidates {
lower := strings.ToLower(strings.TrimSpace(raw))
if lower == "" {
continue
}
for _, tag := range tags {
if strings.Contains(lower, tag) {
return true
}
}
}
return false
}
// MatrixKnowledgeItems uses graph-selected nodes as the source of truth and only merges
// manual-only selected_knowledge_items that are not graph-derived duplicates.
func MatrixKnowledgeItems(selected, _ []string, graphNodes []libkg.Node, _ []string) []string {
_, fromGraph := knowledgeItemsFromNodesForMatrix(graphNodes)
graphKeys := map[string]struct{}{}
for _, node := range graphNodes {
line := FormatNodeKnowledge(node)
if line == "" {
continue
}
graphKeys[strings.ToLower(line)] = struct{}{}
}
manualSelected := make([]string, 0, len(selected))
for _, item := range selected {
item = strings.TrimSpace(item)
if item == "" {
continue
}
if _, fromGraph := graphKeys[strings.ToLower(item)]; fromGraph {
continue
}
manualSelected = append(manualSelected, item)
}
return libmatrix.MergeKnowledgeItems(fromGraph, manualSelected)
}
func manualResearchKnowledge(prev missionentity.ResearchMap, nodes []libkg.Node) (all []string, selected []string) {
graphKeys := map[string]struct{}{}
for _, node := range nodes {
line := FormatNodeKnowledge(node)
if line == "" {
continue
}
graphKeys[strings.ToLower(line)] = struct{}{}
}
selectedKeys := map[string]struct{}{}
for _, item := range prev.SelectedKnowledgeItems {
item = strings.TrimSpace(item)
if item == "" {
continue
}
selectedKeys[strings.ToLower(item)] = struct{}{}
}
for _, item := range prev.KnowledgeItems {
item = strings.TrimSpace(item)
if item == "" {
continue
}
lower := strings.ToLower(item)
if _, fromGraph := graphKeys[lower]; fromGraph {
continue
}
all = append(all, item)
if _, ok := selectedKeys[lower]; ok {
selected = append(selected, item)
}
}
return all, selected
}
func graphKnowledgeDeselected(prev missionentity.ResearchMap) map[string]struct{} {
selectedKeys := map[string]struct{}{}
for _, item := range prev.SelectedKnowledgeItems {
item = strings.TrimSpace(item)
if item == "" {
continue
}
selectedKeys[strings.ToLower(item)] = struct{}{}
}
deselected := map[string]struct{}{}
for _, item := range prev.KnowledgeItems {
item = strings.TrimSpace(item)
if item == "" {
continue
}
if _, ok := selectedKeys[strings.ToLower(item)]; !ok {
deselected[strings.ToLower(item)] = struct{}{}
}
}
return deselected
}
func BuildResearchMapFromGraph(prev missionentity.ResearchMap, nodes []libkg.Node, sources []libkg.BraveSource) missionentity.ResearchMap {
allItems, selectedItems := KnowledgeItemsFromNodes(nodes)
deselected := graphKnowledgeDeselected(prev)
if len(deselected) > 0 {
filtered := selectedItems[:0]
for _, line := range selectedItems {
if _, skip := deselected[strings.ToLower(line)]; skip {
continue
}
filtered = append(filtered, line)
}
selectedItems = filtered
}
manualAll, manualSelected := manualResearchKnowledge(prev, nodes)
allItems = mergeKnowledgeLines(allItems, manualAll)
selectedItems = mergeKnowledgeLines(selectedItems, manualSelected)
return missionentity.ResearchMap{
AudienceSummary: prev.AudienceSummary,
ContentGoal: prev.ContentGoal,
Questions: append([]string(nil), prev.Questions...),
Pillars: append([]string(nil), prev.Pillars...),
Exclusions: append([]string(nil), prev.Exclusions...),
SuggestedTags: append([]missionentity.SuggestedTag(nil), prev.SuggestedTags...),
SimilarAccounts: append([]missionentity.SimilarAccount(nil), prev.SimilarAccounts...),
AudienceSamples: append([]missionentity.AudienceSample(nil), prev.AudienceSamples...),
BenchmarkNotes: prev.BenchmarkNotes,
KnowledgeItems: allItems,
SelectedKnowledgeItems: selectedItems,
ResearchItems: ResearchItemsFromSources(sources),
}
}

View File

@ -1,121 +0,0 @@
package copymission
import (
"testing"
libkg "haixun-backend/internal/library/knowledge"
missionentity "haixun-backend/internal/model/copy_mission/domain/entity"
)
func TestMatrixKnowledgeItems_PrefersGraphSelection(t *testing.T) {
nodes := []libkg.Node{
{Label: "痛點A", NodeKind: "pain", SelectedForScan: true, PlacementValue: "細節A"},
{Label: "周邊B", NodeKind: "context", SelectedForScan: false},
}
items := MatrixKnowledgeItems(nil, nil, nodes, nil)
if len(items) != 1 {
t.Fatalf("items = %v, want 1 selected graph node", items)
}
if items[0] != "痛點A細節A" {
t.Fatalf("items[0] = %q", items[0])
}
}
func TestMatrixKnowledgeItems_UsesMissionSelectionOnly(t *testing.T) {
items := MatrixKnowledgeItems(
[]string{"受眾問題:怎麼選"},
[]string{"內容支柱:懶人包"},
nil,
nil,
)
if len(items) != 1 || items[0] != "受眾問題:怎麼選" {
t.Fatalf("items = %v", items)
}
}
func TestMatrixKnowledgeItems_MergesGraphAndMission(t *testing.T) {
nodes := []libkg.Node{
{
Label: "備孕營養",
PlacementValue: "葉酸與鋅很重要",
SelectedForScan: true,
},
{
Label: "未勾選",
PlacementValue: "不應出現",
SelectedForScan: false,
DerivedTags: libkg.DerivedTags{
Relevance: []string{"備孕飲食"},
},
},
}
items := MatrixKnowledgeItems(
[]string{"手動補充:睡前習慣"},
nil,
nodes,
[]string{"備孕飲食"},
)
if len(items) != 2 {
t.Fatalf("items = %v", items)
}
}
func TestMatrixKnowledgeItems_IgnoresUnselectedTagMatches(t *testing.T) {
nodes := []libkg.Node{
{
Label: "備孕營養",
PlacementValue: "葉酸與鋅很重要",
SelectedForScan: false,
DerivedTags: libkg.DerivedTags{
Relevance: []string{"備孕飲食"},
},
},
}
items := MatrixKnowledgeItems(nil, nil, nodes, []string{"備孕飲食"})
if len(items) != 0 {
t.Fatalf("items = %v, want none without explicit selection", items)
}
}
func TestBuildResearchMapFromGraph_RespectsManualDeselection(t *testing.T) {
prev := missionentity.ResearchMap{
KnowledgeItems: []string{"圖譜痛點:細節"},
SelectedKnowledgeItems: []string{},
}
out := BuildResearchMapFromGraph(prev, []libkg.Node{
{Label: "圖譜痛點", NodeKind: "pain", SelectedForScan: true, PlacementValue: "細節"},
}, nil)
if len(out.SelectedKnowledgeItems) != 0 {
t.Fatalf("selected = %v, want manual deselection honored", out.SelectedKnowledgeItems)
}
}
func TestBuildResearchMapFromGraph_PreservesManualKnowledge(t *testing.T) {
prev := missionentity.ResearchMap{
KnowledgeItems: []string{"手動知識:睡前"},
SelectedKnowledgeItems: []string{"手動知識:睡前"},
}
out := BuildResearchMapFromGraph(prev, []libkg.Node{
{Label: "圖譜痛點", NodeKind: "pain", SelectedForScan: true, PlacementValue: "細節"},
}, nil)
if len(out.KnowledgeItems) != 2 {
t.Fatalf("knowledge_items = %v", out.KnowledgeItems)
}
if len(out.SelectedKnowledgeItems) != 2 {
t.Fatalf("selected = %v", out.SelectedKnowledgeItems)
}
}
func TestApplyDefaultNodeSelectionPreserving(t *testing.T) {
nodes := []libkg.Node{
{ID: "keep", Label: "已勾", NodeKind: "context", SelectedForScan: false},
{ID: "new", Label: "新痛點", NodeKind: "pain", SelectedForScan: false},
}
ApplyDefaultNodeSelectionPreserving(nodes, map[string]bool{"keep": true})
if !nodes[0].SelectedForScan {
t.Fatal("expected preserved selection on existing node")
}
if !nodes[1].SelectedForScan {
t.Fatal("expected default selection on new pain node")
}
}

View File

@ -1,66 +0,0 @@
package copymission
import (
"strings"
libkg "haixun-backend/internal/library/knowledge"
)
// ReferenceURLsFromSelection collects evidence/source URLs tied to selected graph nodes.
func ReferenceURLsFromSelection(nodes []libkg.Node, sources []libkg.BraveSource) []string {
if len(nodes) == 0 {
return nil
}
selectedEvidence := map[string]struct{}{}
for _, node := range nodes {
if !node.SelectedForScan {
continue
}
for _, ev := range node.Evidence {
key := normalizeURLKey(ev.URL)
if key != "" {
selectedEvidence[key] = struct{}{}
}
}
}
if len(selectedEvidence) == 0 {
return nil
}
out := make([]string, 0, len(selectedEvidence))
seen := map[string]struct{}{}
appendURL := func(raw string) {
key := normalizeURLKey(raw)
if key == "" {
return
}
if _, ok := selectedEvidence[key]; !ok {
return
}
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
out = append(out, strings.TrimSpace(raw))
}
for _, node := range nodes {
if !node.SelectedForScan {
continue
}
for _, ev := range node.Evidence {
appendURL(ev.URL)
}
}
for _, src := range sources {
appendURL(src.URL)
}
return out
}
func normalizeURLKey(raw string) string {
u := strings.TrimSpace(raw)
if u == "" {
return ""
}
return strings.ToLower(strings.TrimRight(u, "/"))
}

View File

@ -1,34 +0,0 @@
package copymission
import (
"testing"
libkg "haixun-backend/internal/library/knowledge"
)
func TestReferenceURLsFromSelection(t *testing.T) {
nodes := []libkg.Node{
{
ID: "n1",
SelectedForScan: true,
Evidence: []libkg.Evidence{
{URL: "https://example.com/a"},
},
},
{
ID: "n2",
SelectedForScan: false,
Evidence: []libkg.Evidence{
{URL: "https://example.com/b"},
},
},
}
sources := []libkg.BraveSource{
{URL: "https://example.com/a"},
{URL: "https://example.com/c"},
}
urls := ReferenceURLsFromSelection(nodes, sources)
if len(urls) != 1 || urls[0] != "https://example.com/a" {
t.Fatalf("urls = %v", urls)
}
}

View File

@ -1,79 +0,0 @@
package copymission
import (
"strings"
)
type ResearchSource struct {
Title string
URL string
Snippet string
Query string
}
// FormatResearchItemsForMatrix renders Brave research snippets for the matrix prompt.
func FormatResearchItemsForMatrix(items []ResearchSource) string {
if len(items) == 0 {
return ""
}
lines := make([]string, 0, len(items))
seen := map[string]struct{}{}
for _, item := range items {
title := strings.TrimSpace(item.Title)
url := strings.TrimSpace(item.URL)
snippet := strings.TrimSpace(item.Snippet)
if title == "" && url == "" && snippet == "" {
continue
}
key := strings.ToLower(url)
if key == "" {
key = strings.ToLower(title + "|" + snippet)
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
var parts []string
if title != "" {
parts = append(parts, title)
}
if url != "" {
parts = append(parts, url)
}
if snippet != "" {
parts = append(parts, snippet)
}
lines = append(lines, strings.Join(parts, ""))
}
if len(lines) == 0 {
return ""
}
return strings.Join(lines, "\n")
}
// MergeReferenceURLs combines graph-selected URLs with mission research item URLs.
func MergeReferenceURLs(selected []string, researchItems []ResearchSource) []string {
if len(researchItems) == 0 {
return selected
}
out := append([]string(nil), selected...)
seen := map[string]struct{}{}
for _, raw := range selected {
if key := normalizeURLKey(raw); key != "" {
seen[key] = struct{}{}
}
}
for _, item := range researchItems {
raw := strings.TrimSpace(item.URL)
key := normalizeURLKey(raw)
if key == "" {
continue
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, raw)
}
return out
}

View File

@ -1,28 +0,0 @@
package copymission
import (
"strings"
"testing"
)
func TestMergeReferenceURLsIncludesResearchItems(t *testing.T) {
urls := MergeReferenceURLs(
[]string{"https://example.com/a"},
[]ResearchSource{
{URL: "https://example.com/b", Title: "B"},
{URL: "https://example.com/a", Title: "dup"},
},
)
if len(urls) != 2 {
t.Fatalf("urls = %v", urls)
}
}
func TestFormatResearchItemsForMatrix(t *testing.T) {
block := FormatResearchItemsForMatrix([]ResearchSource{
{Title: "備孕指南", URL: "https://example.com/guide", Snippet: "葉酸補充重點"},
})
if !strings.Contains(block, "備孕指南") || !strings.Contains(block, "葉酸補充重點") {
t.Fatalf("block = %q", block)
}
}

View File

@ -1,19 +0,0 @@
package copymission
import missiondomain "haixun-backend/internal/model/copy_mission/domain/usecase"
func ResearchSourcesFromSummaries(items []missiondomain.ResearchItemSummary) []ResearchSource {
if len(items) == 0 {
return nil
}
out := make([]ResearchSource, 0, len(items))
for _, item := range items {
out = append(out, ResearchSource{
Title: item.Title,
URL: item.URL,
Snippet: item.Snippet,
Query: item.Query,
})
}
return out
}

View File

@ -1,138 +0,0 @@
// Package embedding provides an optional OpenAI embeddings client.
// It is intentionally not wired into patrol/graph flows; relevance uses
// offline lexical intent scoring in knowledge/semantic_fit.go instead.
package embedding
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"math"
"net/http"
"strings"
)
const DefaultModel = "text-embedding-3-small"
type Client struct {
apiKey string
model string
http *http.Client
}
func NewClient(apiKey string) *Client {
return &Client{
apiKey: strings.TrimSpace(apiKey),
model: DefaultModel,
http: &http.Client{},
}
}
func (c *Client) Enabled() bool {
return c != nil && c.apiKey != ""
}
type EmbeddingRequest struct {
Input []string `json:"input"`
Model string `json:"model"`
}
type EmbeddingResponse struct {
Data []struct {
Embedding []float32 `json:"embedding"`
} `json:"data"`
Error *struct {
Message string `json:"message"`
} `json:"error,omitempty"`
Usage *struct {
TotalTokens int `json:"total_tokens"`
} `json:"usage,omitempty"`
}
func (c *Client) Embed(ctx context.Context, input string) ([]float32, error) {
if !c.Enabled() {
return nil, fmt.Errorf("embedding client not configured")
}
return c.EmbedBatch(ctx, []string{input})
}
func (c *Client) EmbedBatch(ctx context.Context, inputs []string) ([]float32, error) {
if !c.Enabled() {
return nil, fmt.Errorf("embedding client not configured")
}
if len(inputs) == 0 {
return nil, fmt.Errorf("no input text")
}
req := EmbeddingRequest{
Input: inputs,
Model: c.model,
}
body, err := json.Marshal(req)
if err != nil {
return nil, err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.openai.com/v1/embeddings", bytes.NewReader(body))
if err != nil {
return nil, err
}
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(httpReq)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
var errResp struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
}
if json.Unmarshal(respBody, &errResp) == nil && errResp.Error.Message != "" {
return nil, fmt.Errorf("openai embedding: %s", errResp.Error.Message)
}
return nil, fmt.Errorf("openai embedding: http %d", resp.StatusCode)
}
var result EmbeddingResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, err
}
if result.Error != nil && result.Error.Message != "" {
return nil, fmt.Errorf("openai embedding: %s", result.Error.Message)
}
if len(result.Data) == 0 {
return nil, fmt.Errorf("openai embedding: no data returned")
}
return result.Data[0].Embedding, nil
}
func CosineSimilarity(a, b []float32) float32 {
if len(a) == 0 || len(b) == 0 || len(a) != len(b) {
return 0
}
var dot, normA, normB float64
for i := 0; i < len(a); i++ {
dot += float64(a[i] * b[i])
normA += float64(a[i] * a[i])
normB += float64(b[i] * b[i])
}
if normA == 0 || normB == 0 {
return 0
}
return float32(dot / (math.Sqrt(normA) * math.Sqrt(normB)))
}

View File

@ -1,25 +0,0 @@
你是 Threads / Instagram 內容策略師,為人設帳號產出可發佈草稿。產文時必須優先使用人設的「語言指紋與寫作規則」,把新主題、參考貼文或外部資料翻譯成這個人設會講出口的話,不沿用任何預設爆款模板。
寫作目標:
- 先根據人設語言指紋判斷主題適合哪一種內容型態:心情陪伴、知識整理、清單工具、伴侶溝通、互動提問或輕幽默共鳴,再選對鬆結構。
- 每篇要有一個清楚情境或情緒入口,讓讀者覺得「這就是我」,不要寫成摘要報告或廣告文。
- 必須沿用人設的段落節奏、標點、換行、稱呼、口頭禪與收尾習慣;不要硬剪短成破碎金句。
- 只學參考樣本的節奏、取材方式、收尾習慣與互動方式;不可照抄句子。
- 避免 AI 腔:過度總結、過度正能量、每段都像教條、連續排比到不自然、硬塞「大家一起」式口號。
規則:
- 只回傳單一 JSON 物件 {"rows":[...]},不要用 markdown 程式碼區塊,不要加前言或結語。
- reference_notes、rationale 各 ≤ 40 字;優先把 token 用在 text 主文。
- 每篇必須角度不同,避免重複 hook。
- 人設定位決定「誰在說」;語言指紋決定「怎麼說」。兩者都要遵守,不可只拿主題資料直接改寫。
- 人設只用來控制語氣、詞彙密度、句型節奏、人稱、標點換行與禁忌;不可把人設介紹裡的物件或標籤硬塞進正文情境。
- 若人設含 Y2K、咖啡、穿搭、音樂等元素除非任務主題/Brief/勾選知識明確要求,禁止把這些元素寫成貼文內容或場景。
- 正文素材以任務主題、Brief、研究地圖與勾選延伸知識為準人設不得改變題材。
- 內容論點來自研究地圖與使用者勾選的延伸知識(含標籤與細節);爆款樣本只學結構與節奏,不抄原文。
- 繁體中文,口語自然,適合 Threads。
- 每篇 text 主文 ≤ 500 字Threads API 硬上限,含 #話題標籤)。
- 長度依帳號樣本與主題決定:心情陪伴可中長,知識工具文可完整列點,互動短文才需要短。
- text 排版要像真人手機發文:用自然換行分段,可使用標點、括號、頓號與少量 emoji不要整段擠成一塊也不要每句都硬換行。
- 若要改寫參考貼文,只保留意思與資訊,句型、段落、收尾必須換成人設語言指紋,不可像同義詞替換。
- 若使用外部知識,先轉譯成此人設會說的順序與語氣;不得直接貼百科、條列報告或 SEO 摘要。
- 醫療、備孕、試管、保健品相關內容必須保留風險語氣:不可保證效果、不可替代醫師建議、不可用數字審判讀者。

View File

@ -1,33 +0,0 @@
請為以下拷貝任務產出 {{count}} 篇 Threads 草稿。
任務主題:{{topic_label}}
Brief{{topic_brief}}
人設定位、語言指紋與禁忌(最高優先):
{{persona_block}}
研究地圖與勾選延伸知識:
{{research_map_block}}
搜尋查詢/人工參考方向:
{{selected_tags_block}}
參考網頁 Markdown 已併入上方研究地圖區塊(若有)。
爆款樣本(可選,只學故事感;若沒有樣本,請改用研究地圖與人設原創):
{{viral_samples_block}}
請先遵守:
- **最高優先:語言指紋**:先讀 persona_block 裡的「語言指紋與寫作規則」包含開頭、句型、段落長度、標點換行、知識轉譯、CTA 與禁忌。產出的文字必須像這個人設會講出口的話。
- **不要套舊規格**:不要套用短句爆款、固定三段式、固定強 hook、固定 CTA格式由人設語言指紋與主題型態決定。
- **語氣參考**:讀起來要像這個帳號在說話,但每篇敘事節奏與開場都要自然變化。
- **人設不是內容素材**:只學語氣、詞彙密度、句型節奏與審美,不要把人設裡的名詞、興趣、裝飾符號硬塞進正文。例:人設寫 Y2K/咖啡/穿搭只代表語感不代表每篇都要寫老歌、咖啡、穿搭、MV。
- **內容素材**研究地圖受眾、目標、問題、支柱與「勾選延伸知識」含標籤、細節決定論點與角度若有「參考網頁Markdown」區塊優先從中提取事實與論點再融入草稿。
- 搜尋查詢/標籤僅作方向參考,不可蓋過人設與勾選知識。
- 爆款樣本只學故事感、資訊密度、情緒力度與互動方式,不抄原文,不可為仿爆款偏離任務主題。
- 若樣本呈現「長 caption + 輪播圖卡重點」的內容型態Threads 草稿也要保留完整情緒承接與可收藏資訊,不要只輸出金句摘要。
- 若任務是改寫或參考別人的貼文,請用人設語言重新說一次,不是同義詞替換;保留觀點,不保留對方句型。
- 若任務需要找資料或引用外部知識,請先消化成此人設的說話順序:先安撫或帶入情境,再整理重點,最後用符合人設的方式收尾。
- 若沒有爆款樣本source_permalinks 回傳空陣列reference_notes 寫明使用了哪些人設特徵、研究地圖重點與勾選延伸知識。
回傳 JSON rows每筆含 sort_order、search_tag、angle、hook、text、reference_notes、source_permalinks、rationale。

View File

@ -1,84 +0,0 @@
package storage
import (
"context"
"fmt"
"io"
"mime"
"path"
"strings"
"haixun-backend/internal/config"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
type Uploader interface {
Upload(ctx context.Context, key, contentType string, body io.Reader) (string, error)
}
type S3Uploader struct {
client *s3.Client
bucket string
publicBaseURL string
bucketReady bool
}
func NewS3Uploader(ctx context.Context, cfg config.S3Conf) (*S3Uploader, error) {
if strings.TrimSpace(cfg.Bucket) == "" {
return nil, nil
}
awsCfg, err := awsconfig.LoadDefaultConfig(ctx,
awsconfig.WithRegion(cfg.Region),
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.AccessKey, cfg.SecretKey, "")),
)
if err != nil {
return nil, err
}
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
if cfg.Endpoint != "" {
o.BaseEndpoint = aws.String(cfg.Endpoint)
}
o.UsePathStyle = cfg.UsePathStyle
})
return &S3Uploader{client: client, bucket: cfg.Bucket, publicBaseURL: strings.TrimRight(cfg.PublicBaseURL, "/")}, nil
}
func (u *S3Uploader) Upload(ctx context.Context, key, contentType string, body io.Reader) (string, error) {
if u == nil || u.client == nil {
return "", fmt.Errorf("s3 uploader is not configured")
}
if !u.bucketReady {
if _, err := u.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(u.bucket)}); err != nil {
if _, createErr := u.client.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(u.bucket)}); createErr != nil {
return "", createErr
}
}
policy := fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":["*"]},"Action":["s3:GetObject"],"Resource":["arn:aws:s3:::%s/*"]}]}`, u.bucket)
_, _ = u.client.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{Bucket: aws.String(u.bucket), Policy: aws.String(policy)})
u.bucketReady = true
}
key = strings.TrimLeft(path.Clean("/"+key), "/")
if contentType == "" {
contentType = mime.TypeByExtension(path.Ext(key))
}
if contentType == "" {
contentType = "application/octet-stream"
}
_, err := u.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(u.bucket),
Key: aws.String(key),
Body: body,
ContentType: aws.String(contentType),
})
if err != nil {
return "", err
}
if u.publicBaseURL != "" {
return u.publicBaseURL + "/" + u.bucket + "/" + key, nil
}
return key, nil
}

View File

@ -1,33 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type CreateCopyMissionLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewCreateCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateCopyMissionLogic {
return &CreateCopyMissionLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *CreateCopyMissionLogic) CreateCopyMission(req *types.CreateCopyMissionHandlerReq) (resp *types.CopyMissionData, err error) {
// todo: add your logic here and delete this line
return
}

View File

@ -1,33 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type DeleteCopyMissionLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewDeleteCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteCopyMissionLogic {
return &DeleteCopyMissionLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *DeleteCopyMissionLogic) DeleteCopyMission(req *types.CopyMissionPath) error {
// todo: add your logic here and delete this line
return nil
}

View File

@ -1,33 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type DeleteCopyMissionMatrixDraftsLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewDeleteCopyMissionMatrixDraftsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DeleteCopyMissionMatrixDraftsLogic {
return &DeleteCopyMissionMatrixDraftsLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *DeleteCopyMissionMatrixDraftsLogic) DeleteCopyMissionMatrixDrafts(req *types.DeleteCopyMissionMatrixDraftsHandlerReq) (resp *types.DeleteCopyMissionMatrixDraftsData, err error) {
// todo: add your logic here and delete this line
return
}

View File

@ -1,33 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type ExpandCopyMissionGraphLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewExpandCopyMissionGraphLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ExpandCopyMissionGraphLogic {
return &ExpandCopyMissionGraphLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *ExpandCopyMissionGraphLogic) ExpandCopyMissionGraph(req *types.ExpandCopyMissionGraphHandlerReq) (resp *types.ExpandKnowledgeGraphData, err error) {
// todo: add your logic here and delete this line
return
}

View File

@ -1,33 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type GenerateCopyMissionMatrixLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGenerateCopyMissionMatrixLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GenerateCopyMissionMatrixLogic {
return &GenerateCopyMissionMatrixLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GenerateCopyMissionMatrixLogic) GenerateCopyMissionMatrix(req *types.GenerateCopyMissionMatrixHandlerReq) (resp *types.GenerateCopyMissionMatrixData, err error) {
// todo: add your logic here and delete this line
return
}

View File

@ -1,33 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type GetCopyMissionGraphLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGetCopyMissionGraphLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetCopyMissionGraphLogic {
return &GetCopyMissionGraphLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetCopyMissionGraphLogic) GetCopyMissionGraph(req *types.CopyMissionPath) (resp *types.KnowledgeGraphData, err error) {
// todo: add your logic here and delete this line
return
}

View File

@ -1,33 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type GetCopyMissionLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGetCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetCopyMissionLogic {
return &GetCopyMissionLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetCopyMissionLogic) GetCopyMission(req *types.CopyMissionPath) (resp *types.CopyMissionData, err error) {
// todo: add your logic here and delete this line
return
}

View File

@ -1,33 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type GetCopyMissionScanScheduleLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGetCopyMissionScanScheduleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetCopyMissionScanScheduleLogic {
return &GetCopyMissionScanScheduleLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetCopyMissionScanScheduleLogic) GetCopyMissionScanSchedule(req *types.CopyMissionPath) (resp *types.CopyMissionScanScheduleData, err error) {
// todo: add your logic here and delete this line
return
}

View File

@ -1,33 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type ListCopyMissionCopyDraftsLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewListCopyMissionCopyDraftsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListCopyMissionCopyDraftsLogic {
return &ListCopyMissionCopyDraftsLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *ListCopyMissionCopyDraftsLogic) ListCopyMissionCopyDrafts(req *types.CopyMissionPath) (resp *types.ListCopyMissionCopyDraftsData, err error) {
// todo: add your logic here and delete this line
return
}

View File

@ -1,33 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type ListCopyMissionScanPostsLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewListCopyMissionScanPostsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListCopyMissionScanPostsLogic {
return &ListCopyMissionScanPostsLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *ListCopyMissionScanPostsLogic) ListCopyMissionScanPosts(req *types.ListCopyMissionScanPostsHandlerReq) (resp *types.ListPersonaViralScanPostsData, err error) {
// todo: add your logic here and delete this line
return
}

View File

@ -1,33 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type ListCopyMissionsLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewListCopyMissionsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListCopyMissionsLogic {
return &ListCopyMissionsLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *ListCopyMissionsLogic) ListCopyMissions(req *types.PersonaCopyMissionsPath) (resp *types.ListCopyMissionsData, err error) {
// todo: add your logic here and delete this line
return
}

View File

@ -1,33 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type PatchCopyMissionAudienceSampleLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewPatchCopyMissionAudienceSampleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PatchCopyMissionAudienceSampleLogic {
return &PatchCopyMissionAudienceSampleLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *PatchCopyMissionAudienceSampleLogic) PatchCopyMissionAudienceSample(req *types.PatchAudienceSampleHandlerReq) (resp *types.CopyMissionData, err error) {
// todo: add your logic here and delete this line
return
}

View File

@ -1,33 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type PatchCopyMissionGraphNodesLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewPatchCopyMissionGraphNodesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PatchCopyMissionGraphNodesLogic {
return &PatchCopyMissionGraphNodesLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *PatchCopyMissionGraphNodesLogic) PatchCopyMissionGraphNodes(req *types.PatchCopyMissionGraphNodesHandlerReq) (resp *types.KnowledgeGraphData, err error) {
// todo: add your logic here and delete this line
return
}

View File

@ -1,33 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type PatchCopyMissionSimilarAccountLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewPatchCopyMissionSimilarAccountLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PatchCopyMissionSimilarAccountLogic {
return &PatchCopyMissionSimilarAccountLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *PatchCopyMissionSimilarAccountLogic) PatchCopyMissionSimilarAccount(req *types.PatchSimilarAccountHandlerReq) (resp *types.CopyMissionData, err error) {
// todo: add your logic here and delete this line
return
}

View File

@ -1,33 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type ScheduleCopyMissionDraftsLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewScheduleCopyMissionDraftsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ScheduleCopyMissionDraftsLogic {
return &ScheduleCopyMissionDraftsLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *ScheduleCopyMissionDraftsLogic) ScheduleCopyMissionDrafts(req *types.ScheduleCopyMissionDraftsHandlerReq) (resp *types.CopyMissionScheduleCopyDraftsData, err error) {
// todo: add your logic here and delete this line
return
}

View File

@ -1,33 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type StartCopyMissionAnalyzeJobLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewStartCopyMissionAnalyzeJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartCopyMissionAnalyzeJobLogic {
return &StartCopyMissionAnalyzeJobLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *StartCopyMissionAnalyzeJobLogic) StartCopyMissionAnalyzeJob(req *types.CopyMissionPath) (resp *types.StartCopyMissionAnalyzeJobData, err error) {
// todo: add your logic here and delete this line
return
}

View File

@ -1,33 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type StartCopyMissionCopyDraftJobLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewStartCopyMissionCopyDraftJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartCopyMissionCopyDraftJobLogic {
return &StartCopyMissionCopyDraftJobLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *StartCopyMissionCopyDraftJobLogic) StartCopyMissionCopyDraftJob(req *types.StartCopyMissionCopyDraftJobHandlerReq) (resp *types.StartCopyMissionCopyDraftJobData, err error) {
// todo: add your logic here and delete this line
return
}

View File

@ -1,33 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type StartCopyMissionMatrixJobLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewStartCopyMissionMatrixJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartCopyMissionMatrixJobLogic {
return &StartCopyMissionMatrixJobLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *StartCopyMissionMatrixJobLogic) StartCopyMissionMatrixJob(req *types.StartCopyMissionMatrixJobHandlerReq) (resp *types.StartCopyMissionMatrixJobData, err error) {
// todo: add your logic here and delete this line
return
}

View File

@ -1,33 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type StartCopyMissionScanJobLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewStartCopyMissionScanJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartCopyMissionScanJobLogic {
return &StartCopyMissionScanJobLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *StartCopyMissionScanJobLogic) StartCopyMissionScanJob(req *types.CopyMissionPath) (resp *types.StartCopyMissionScanJobData, err error) {
// todo: add your logic here and delete this line
return
}

View File

@ -1,33 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type UpdateCopyMissionLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewUpdateCopyMissionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateCopyMissionLogic {
return &UpdateCopyMissionLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *UpdateCopyMissionLogic) UpdateCopyMission(req *types.UpdateCopyMissionHandlerReq) (resp *types.CopyMissionData, err error) {
// todo: add your logic here and delete this line
return
}

View File

@ -1,33 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package copy_mission
import (
"context"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type UpsertCopyMissionScanScheduleLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewUpsertCopyMissionScanScheduleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpsertCopyMissionScanScheduleLogic {
return &UpsertCopyMissionScanScheduleLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *UpsertCopyMissionScanScheduleLogic) UpsertCopyMissionScanSchedule(req *types.UpsertCopyMissionScanScheduleHandlerReq) (resp *types.CopyMissionScanScheduleData, err error) {
// todo: add your logic here and delete this line
return
}

View File

@ -1,61 +0,0 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package threads_account
import (
"context"
jobdom "haixun-backend/internal/model/job/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type StartPublishInventoryRefillJobLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewStartPublishInventoryRefillJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *StartPublishInventoryRefillJobLogic {
return &StartPublishInventoryRefillJobLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *StartPublishInventoryRefillJobLogic) StartPublishInventoryRefillJob(req *types.StartPublishInventoryRefillJobHandlerReq) (resp *types.StartPublishInventoryRefillJobData, err error) {
tenantID, uid, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
if _, err := l.svcCtx.ThreadsAccount.Get(l.ctx, tenantID, uid, req.ID); err != nil {
return nil, err
}
count := req.Count
if count <= 0 {
count = 3
}
run, err := l.svcCtx.Job.CreateRun(l.ctx, jobdom.CreateRunRequest{
TemplateType: "refill-publish-inventory",
Scope: "threads_account",
ScopeID: req.ID,
TenantID: tenantID,
OwnerUID: uid,
Payload: map[string]any{
"tenant_id": tenantID,
"owner_uid": uid,
"account_id": req.ID,
"count": count,
},
})
if err != nil {
return nil, err
}
return &types.StartPublishInventoryRefillJobData{
JobID: run.ID.Hex(), Status: string(run.Status), Message: "補庫存任務已建立",
}, nil
}

View File

@ -1,30 +0,0 @@
package entity
const CollectionName = "crm_contacts"
const (
ContactStatusLead = "lead"
ContactStatusContacted = "contacted"
ContactStatusResponded = "responded"
ContactStatusConverted = "converted"
ContactStatusArchived = "archived"
)
type CRMContact struct {
ID string `bson:"_id"`
TenantID string `bson:"tenant_id"`
OwnerUID string `bson:"owner_uid"`
BrandID string `bson:"brand_id,omitempty"`
AuthorID string `bson:"author_id"`
AuthorName string `bson:"author_name"`
AuthorAvatar string `bson:"author_avatar,omitempty"`
AuthorFollowers int `bson:"author_followers,omitempty"`
ScanPostID string `bson:"scan_post_id,omitempty"`
Notes string `bson:"notes,omitempty"`
Tags []string `bson:"tags,omitempty"`
Status string `bson:"status"`
OutreachCount int `bson:"outreach_count,omitempty"`
LastContactedAt int64 `bson:"last_contacted_at,omitempty"`
CreateAt int64 `bson:"create_at"`
UpdateAt int64 `bson:"update_at"`
}

View File

@ -1,25 +0,0 @@
package repository
import (
"context"
"haixun-backend/internal/model/crm_contact/domain/entity"
)
type ListFilter struct {
BrandID string
Status string
Tag string
Limit int
Offset int
}
type Repository interface {
EnsureIndexes(ctx context.Context) error
Create(ctx context.Context, contact *entity.CRMContact) error
Get(ctx context.Context, tenantID, ownerUID, contactID string) (*entity.CRMContact, error)
GetByAuthor(ctx context.Context, tenantID, ownerUID, authorID string) (*entity.CRMContact, error)
Update(ctx context.Context, tenantID, ownerUID, contactID string, patch map[string]interface{}) (*entity.CRMContact, error)
Delete(ctx context.Context, tenantID, ownerUID, contactID string) error
List(ctx context.Context, tenantID, ownerUID string, filter ListFilter) ([]entity.CRMContact, int, error)
UpsertByAuthor(ctx context.Context, contact *entity.CRMContact) (*entity.CRMContact, error)
}

View File

@ -1,47 +0,0 @@
package usecase
import "context"
type CRMContactSummary struct {
ID string
BrandID string
AuthorID string
AuthorName string
AuthorAvatar string
AuthorFollowers int
ScanPostID string
Notes string
Tags []string
Status string
OutreachCount int
LastContactedAt int64
CreateAt int64
UpdateAt int64
}
type ListRequest struct {
TenantID string
OwnerUID string
BrandID string
Status string
Tag string
Page int
PageSize int
}
type UpdateRequest struct {
TenantID string
OwnerUID string
ContactID string
Notes *string
Tags *[]string
Status *string
}
type UseCase interface {
Create(ctx context.Context, tenantID, ownerUID, brandID, authorID, authorName string) (*CRMContactSummary, error)
Get(ctx context.Context, tenantID, ownerUID, contactID string) (*CRMContactSummary, error)
Update(ctx context.Context, req UpdateRequest) (*CRMContactSummary, error)
Delete(ctx context.Context, tenantID, ownerUID, contactID string) error
List(ctx context.Context, req ListRequest) ([]CRMContactSummary, int, error)
}

View File

@ -1,217 +0,0 @@
package repository
import (
"context"
"strings"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
"haixun-backend/internal/model/crm_contact/domain/entity"
domrepo "haixun-backend/internal/model/crm_contact/domain/repository"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
type mongoRepository struct {
collection *mongo.Collection
}
func NewMongoRepository(db *mongo.Database) domrepo.Repository {
if db == nil {
return &mongoRepository{}
}
return &mongoRepository{collection: db.Collection(entity.CollectionName)}
}
func baseFilter(tenantID, ownerUID string) bson.M {
return bson.M{
"tenant_id": strings.TrimSpace(tenantID),
"owner_uid": strings.TrimSpace(ownerUID),
}
}
func (r *mongoRepository) EnsureIndexes(ctx context.Context) error {
if r.collection == nil {
return nil
}
_, err := r.collection.Indexes().CreateMany(ctx, []mongo.IndexModel{
{
Keys: bson.D{
{Key: "tenant_id", Value: 1},
{Key: "owner_uid", Value: 1},
{Key: "brand_id", Value: 1},
{Key: "create_at", Value: -1},
},
},
{
Keys: bson.D{
{Key: "tenant_id", Value: 1},
{Key: "owner_uid", Value: 1},
{Key: "author_id", Value: 1},
},
Options: options.Index().SetUnique(true),
},
})
return err
}
func (r *mongoRepository) Create(ctx context.Context, contact *entity.CRMContact) error {
if r.collection == nil {
return app.For(code.Persona).DBUnavailable("Mongo is not configured")
}
if contact == nil {
return app.For(code.Persona).InputMissingRequired("contact is required")
}
_, err := r.collection.InsertOne(ctx, contact)
return err
}
func (r *mongoRepository) Get(ctx context.Context, tenantID, ownerUID, contactID string) (*entity.CRMContact, error) {
if r.collection == nil {
return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
}
contactID = strings.TrimSpace(contactID)
if contactID == "" {
return nil, app.For(code.Persona).InputMissingRequired("contact_id is required")
}
filter := baseFilter(tenantID, ownerUID)
filter["_id"] = contactID
var out entity.CRMContact
err := r.collection.FindOne(ctx, filter).Decode(&out)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, app.For(code.Persona).ResNotFound("crm contact not found")
}
return nil, err
}
return &out, nil
}
func (r *mongoRepository) GetByAuthor(ctx context.Context, tenantID, ownerUID, authorID string) (*entity.CRMContact, error) {
if r.collection == nil {
return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
}
authorID = strings.TrimSpace(authorID)
if authorID == "" {
return nil, app.For(code.Persona).InputMissingRequired("author_id is required")
}
filter := baseFilter(tenantID, ownerUID)
filter["author_id"] = authorID
var out entity.CRMContact
err := r.collection.FindOne(ctx, filter).Decode(&out)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, app.For(code.Persona).ResNotFound("crm contact not found")
}
return nil, err
}
return &out, nil
}
func (r *mongoRepository) Update(ctx context.Context, tenantID, ownerUID, contactID string, patch map[string]interface{}) (*entity.CRMContact, error) {
if r.collection == nil {
return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
}
if len(patch) == 0 {
return nil, app.For(code.Persona).InputMissingRequired("patch is required")
}
contactID = strings.TrimSpace(contactID)
filter := baseFilter(tenantID, ownerUID)
filter["_id"] = contactID
opts := options.FindOneAndUpdate().SetReturnDocument(options.After)
var out entity.CRMContact
err := r.collection.FindOneAndUpdate(ctx, filter, bson.M{"$set": patch}, opts).Decode(&out)
if err != nil {
if err == mongo.ErrNoDocuments {
return nil, app.For(code.Persona).ResNotFound("crm contact not found")
}
return nil, err
}
return &out, nil
}
func (r *mongoRepository) Delete(ctx context.Context, tenantID, ownerUID, contactID string) error {
if r.collection == nil {
return app.For(code.Persona).DBUnavailable("Mongo is not configured")
}
contactID = strings.TrimSpace(contactID)
if contactID == "" {
return app.For(code.Persona).InputMissingRequired("contact_id is required")
}
filter := baseFilter(tenantID, ownerUID)
filter["_id"] = contactID
_, err := r.collection.DeleteOne(ctx, filter)
return err
}
func (r *mongoRepository) List(ctx context.Context, tenantID, ownerUID string, filter domrepo.ListFilter) ([]entity.CRMContact, int, error) {
if r.collection == nil {
return nil, 0, app.For(code.Persona).DBUnavailable("Mongo is not configured")
}
query := baseFilter(tenantID, ownerUID)
if filter.BrandID != "" {
query["brand_id"] = filter.BrandID
}
if filter.Status != "" {
query["status"] = filter.Status
}
if filter.Tag != "" {
query["tags"] = filter.Tag
}
limit := filter.Limit
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
skip := filter.Offset
if skip < 0 {
skip = 0
}
total, err := r.collection.CountDocuments(ctx, query)
if err != nil {
return nil, 0, err
}
opts := options.Find().
SetSort(bson.D{{Key: "create_at", Value: -1}}).
SetSkip(int64(skip)).
SetLimit(int64(limit))
cur, err := r.collection.Find(ctx, query, opts)
if err != nil {
return nil, 0, err
}
defer cur.Close(ctx)
var out []entity.CRMContact
if err := cur.All(ctx, &out); err != nil {
return nil, 0, err
}
return out, int(total), nil
}
func (r *mongoRepository) UpsertByAuthor(ctx context.Context, contact *entity.CRMContact) (*entity.CRMContact, error) {
if r.collection == nil {
return nil, app.For(code.Persona).DBUnavailable("Mongo is not configured")
}
if contact == nil {
return nil, app.For(code.Persona).InputMissingRequired("contact is required")
}
filter := baseFilter(contact.TenantID, contact.OwnerUID)
filter["author_id"] = contact.AuthorID
update := bson.M{"$set": contact}
opts := options.FindOneAndUpdate().
SetUpsert(true).
SetReturnDocument(options.After)
var out entity.CRMContact
err := r.collection.FindOneAndUpdate(ctx, filter, update, opts).Decode(&out)
if err != nil {
return nil, err
}
return &out, nil
}

View File

@ -1,176 +0,0 @@
package usecase
import (
"context"
"strings"
"haixun-backend/internal/library/clock"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
"haixun-backend/internal/model/crm_contact/domain/entity"
domrepo "haixun-backend/internal/model/crm_contact/domain/repository"
domusecase "haixun-backend/internal/model/crm_contact/domain/usecase"
"github.com/google/uuid"
)
type crmContactUseCase struct {
repo domrepo.Repository
}
func NewUseCase(repo domrepo.Repository) domusecase.UseCase {
return &crmContactUseCase{repo: repo}
}
func (u *crmContactUseCase) Create(ctx context.Context, tenantID, ownerUID, brandID, authorID, authorName string) (*domusecase.CRMContactSummary, error) {
if err := requireActor(tenantID, ownerUID); err != nil {
return nil, err
}
authorID = strings.TrimSpace(authorID)
if authorID == "" {
return nil, app.For(code.Persona).InputMissingRequired("author_id is required")
}
authorName = strings.TrimSpace(authorName)
if authorName == "" {
return nil, app.For(code.Persona).InputMissingRequired("author_name is required")
}
now := clock.NowUnixNano()
contact := &entity.CRMContact{
ID: uuid.NewString(),
TenantID: tenantID,
OwnerUID: ownerUID,
BrandID: strings.TrimSpace(brandID),
AuthorID: authorID,
AuthorName: authorName,
Status: entity.ContactStatusLead,
CreateAt: now,
UpdateAt: now,
}
if err := u.repo.Create(ctx, contact); err != nil {
return nil, err
}
summary := toSummary(*contact)
return &summary, nil
}
func (u *crmContactUseCase) Get(ctx context.Context, tenantID, ownerUID, contactID string) (*domusecase.CRMContactSummary, error) {
if err := requireActor(tenantID, ownerUID); err != nil {
return nil, err
}
contactID = strings.TrimSpace(contactID)
if contactID == "" {
return nil, app.For(code.Persona).InputMissingRequired("contact_id is required")
}
item, err := u.repo.Get(ctx, tenantID, ownerUID, contactID)
if err != nil {
return nil, err
}
summary := toSummary(*item)
return &summary, nil
}
func (u *crmContactUseCase) Update(ctx context.Context, req domusecase.UpdateRequest) (*domusecase.CRMContactSummary, error) {
if err := requireActor(req.TenantID, req.OwnerUID); err != nil {
return nil, err
}
contactID := strings.TrimSpace(req.ContactID)
if contactID == "" {
return nil, app.For(code.Persona).InputMissingRequired("contact_id is required")
}
patch := map[string]interface{}{}
if req.Notes != nil {
patch["notes"] = strings.TrimSpace(*req.Notes)
}
if req.Tags != nil {
patch["tags"] = *req.Tags
}
if req.Status != nil {
status := strings.TrimSpace(*req.Status)
if status != "" && status != entity.ContactStatusLead && status != entity.ContactStatusContacted && status != entity.ContactStatusResponded && status != entity.ContactStatusConverted && status != entity.ContactStatusArchived {
return nil, app.For(code.Persona).InputInvalidFormat("invalid contact status")
}
if status != "" {
patch["status"] = status
}
}
if len(patch) == 0 {
return nil, app.For(code.Persona).InputMissingRequired("no fields to update")
}
patch["update_at"] = clock.NowUnixNano()
item, err := u.repo.Update(ctx, req.TenantID, req.OwnerUID, contactID, patch)
if err != nil {
return nil, err
}
summary := toSummary(*item)
return &summary, nil
}
func (u *crmContactUseCase) Delete(ctx context.Context, tenantID, ownerUID, contactID string) error {
if err := requireActor(tenantID, ownerUID); err != nil {
return err
}
contactID = strings.TrimSpace(contactID)
if contactID == "" {
return app.For(code.Persona).InputMissingRequired("contact_id is required")
}
return u.repo.Delete(ctx, tenantID, ownerUID, contactID)
}
func (u *crmContactUseCase) List(ctx context.Context, req domusecase.ListRequest) ([]domusecase.CRMContactSummary, int, error) {
if err := requireActor(req.TenantID, req.OwnerUID); err != nil {
return nil, 0, err
}
page := req.Page
pageSize := req.PageSize
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
filter := domrepo.ListFilter{
BrandID: strings.TrimSpace(req.BrandID),
Status: strings.TrimSpace(req.Status),
Tag: strings.TrimSpace(req.Tag),
Limit: pageSize,
Offset: (page - 1) * pageSize,
}
items, total, err := u.repo.List(ctx, req.TenantID, req.OwnerUID, filter)
if err != nil {
return nil, 0, err
}
out := make([]domusecase.CRMContactSummary, 0, len(items))
for _, item := range items {
out = append(out, toSummary(item))
}
return out, total, nil
}
func requireActor(tenantID, ownerUID string) error {
if strings.TrimSpace(tenantID) == "" || strings.TrimSpace(ownerUID) == "" {
return app.For(code.Auth).AuthUnauthorized("missing actor")
}
return nil
}
func toSummary(item entity.CRMContact) domusecase.CRMContactSummary {
return domusecase.CRMContactSummary{
ID: item.ID,
BrandID: item.BrandID,
AuthorID: item.AuthorID,
AuthorName: item.AuthorName,
AuthorAvatar: item.AuthorAvatar,
AuthorFollowers: item.AuthorFollowers,
ScanPostID: item.ScanPostID,
Notes: item.Notes,
Tags: item.Tags,
Status: item.Status,
OutreachCount: item.OutreachCount,
LastContactedAt: item.LastContactedAt,
CreateAt: item.CreateAt,
UpdateAt: item.UpdateAt,
}
}

View File

@ -1,15 +0,0 @@
package entity
const CollectionName = "publish_text_drafts"
type Draft struct {
ID string `bson:"_id"`
TenantID string `bson:"tenant_id"`
OwnerUID string `bson:"owner_uid"`
AccountID string `bson:"account_id"`
Title string `bson:"title,omitempty"`
Text string `bson:"text"`
Tags []string `bson:"tags,omitempty"`
CreateAt int64 `bson:"create_at"`
UpdateAt int64 `bson:"update_at"`
}

View File

@ -1,21 +0,0 @@
package repository
import (
"context"
"haixun-backend/internal/model/publish_draft/domain/entity"
)
type ListResult struct {
Items []entity.Draft
Total int64
}
type Repository interface {
EnsureIndexes(ctx context.Context) error
Create(ctx context.Context, draft *entity.Draft) error
Get(ctx context.Context, tenantID, ownerUID, accountID, draftID string) (*entity.Draft, error)
List(ctx context.Context, tenantID, ownerUID, accountID string, page, pageSize int) (*ListResult, error)
Update(ctx context.Context, draft *entity.Draft) (*entity.Draft, error)
Delete(ctx context.Context, tenantID, ownerUID, accountID, draftID string) error
}

View File

@ -1,48 +0,0 @@
package usecase
import "context"
type DraftSummary struct {
ID string
AccountID string
Title string
Text string
Tags []string
CreateAt int64
UpdateAt int64
}
type CreateRequest struct {
TenantID string
OwnerUID string
AccountID string
Title string
Text string
Tags []string
}
type UpdateRequest struct {
TenantID string
OwnerUID string
AccountID string
DraftID string
Title *string
Text *string
Tags *[]string
}
type ListResult struct {
Items []DraftSummary
Total int64
Page int
PageSize int
TotalPages int
}
type UseCase interface {
Create(ctx context.Context, req CreateRequest) (*DraftSummary, error)
Get(ctx context.Context, tenantID, ownerUID, accountID, draftID string) (*DraftSummary, error)
List(ctx context.Context, tenantID, ownerUID, accountID string, page, pageSize int) (*ListResult, error)
Update(ctx context.Context, req UpdateRequest) (*DraftSummary, error)
Delete(ctx context.Context, tenantID, ownerUID, accountID, draftID string) error
}

44
docs/product/README.md Normal file
View File

@ -0,0 +1,44 @@
# 產品文件Spec-Driven
本目錄是 **需求 → 規格 → 計劃 → 小任務** 的產物根,由專案 skill `/spec-driven` 驅動。
```text
docs/product/
README.md # 本說明
<slug>/
README.md # 目前 phase 與狀態
requirements.md
spec.md # 需求批准後
plan.md # spec 批准後
tasks/
INDEX.md
T001-....md
```
## 怎麼用
```text
/spec-driven # 開新的或繼續未完成
/spec-driven haixun-console # 指定產品 run
/spec-driven haixun-console --status # 只看進度
```
**規則:** 每階段寫完要等人批准,才能進下一階段;未批准 plan / tasks 前不寫業務程式。
模板與流程細節:`.grok/skills/spec-driven/`。
## 現有 run
| Slug | 說明 |
|------|------|
| [haixun-console](./haixun-console/) | 巡樓 Console **從零重寫**`old/` 封存;地基 → FE mock → 後端) |
## 與其他 docs 的關係
| 路徑 | 角色 |
|------|------|
| `docs/architecture.md` | 程式怎麼長(現況) |
| `docs/improvement-notes.md` | 技術改進建議(非產品需求) |
| `docs/post-project-plan.md` | 文案流重做構想(輸入,須併入 requirements 才算正式) |
| `AGENTS.md` | 實作約束 |
| `docs/product/*` | **產品決策真相來源**(本流水線) |

View File

@ -0,0 +1,42 @@
# Product run: haixun-console
| Field | Value |
|-------|--------|
| Goal | 從零重寫:地基 → 前端 mock → 接真多帳互回reply 含 lead |
| Phase | **tasks / ready-to-implement** |
| Requirements | **approved** |
| Spec | **approved** |
| Plan | **approved** |
| Legacy | `old/` 封存 |
| Updated | 2026-07-09 |
## Delivery order
```text
M0M9 (T001T022) → Phase B mock UI
M10 → 人審 B OK
M20+ (T100+) → Phase C live API尚未拆細
```
## Artifacts
| File | Status |
|------|--------|
| [requirements.md](./requirements.md) | approved |
| [spec.md](./spec.md) | approved |
| [plan.md](./plan.md) | approved |
| [tasks/INDEX.md](./tasks/INDEX.md) | **22 tasks ready** |
## Start implementing
```text
從 T001 開始
docs/product/haixun-console/tasks/T001-scaffold-apps-web.md
```
完成一題就把 INDEX 狀態改成 `done`,再做下一題。
## Defaults
- `apps/web` greenfield · Harbor Desk · Speakers = lead castreply 可選 lead
- B 可純前端 · mock localStorage · Scout 占位

View File

@ -0,0 +1,138 @@
# Plan: 巡樓 Console 從零重寫
> Status: **`approved`**(與 task 拆分一併啟動)
> Source: `docs/product/haixun-console/spec.md`
> Last updated: `2026-07-09`
## 1. 交付策略
舊碼已在 `old/` 封存。主線 **greenfield**
1. **先 B前端 mock** 讓 Harbor Desk 流程(含 lead 可回覆)你摸到滿意。
2. **A 的 live 地基**不阻塞 B可在 B 過關後或平行用 `services/api` 補。
3. **C** 同一 UI 接真;**D** 海巡等。
本 plan 的 **可立即開工區間是 M0M2tasks T001T022**,對應 Phase B 完整 mock。
Phase C/D 只定里程碑與 task 編號預留,細 task 待 B 過關再拆。
## 2. 里程碑
### M0 — 倉庫骨架
- **目標:**`npm run dev` 的空應用
- **完成定義:** `apps/web` Vite+React+TS 起來;根 README 有啟動指令
- **Tasks** T001
- **驗證:** `cd apps/web && npm install && npm run build`
### M1 — 設計系統 + App Shell
- **目標:** Harbor Desk 外殼可導覽
- **完成定義:** token、基礎元件、桌面側欄+手機底欄、5 入口路由占位
- **Tasks** T002T005
- **驗證:** 手動點 5 入口build 通過
### M2 — Auth mock + 路由守衛
- **目標:** 未登入進不了 `/app/**`
- **完成定義:** mock 登入/登出/重整保 session
- **Tasks** T006
- **驗證:** 登入後進 Todaylogout 回 login
### M3 — 資料層 mock
- **目標:** repository 介面 + localStorage + seed
- **完成定義:** accounts/plays/outbox/jobs/notifications 可讀寫;`mock|live` 切換點存在live 可 stub 丟錯)
- **Tasks** T007T008
- **驗證:** 重整後 seed/草稿仍在
### M4 — Crew帳號
- **目標:** 看帳號列表、mock 新增、不可用狀態
- **Tasks** T009
- **驗證:** 列表 ≥2 假帳;連線 error 不可當 lead/cast
### M5 — Studio 列表 + Wizard 船隊/劇本
- **目標:** 建立串場lead+cast步驟含 lead 發言
- **Tasks** T010T013
- **驗證:** root(A)→reply(B)→reply(A) 可編輯保存
### M6 — 預覽 / 船期 / 提交
- **目標:** 預覽泡泡、排程、進 Outbox
- **Tasks** T014
- **驗證:** submit 後 Outbox 有對應 bundle
### M7 — Outbox 發送模擬
- **目標:** 逐步狀態、主失敗不發後續、重試
- **Tasks** T015T016
- **驗證:** 模擬成功全 publishedroot fail 時 reply 不 published
### M8 — Job + 通知 + Today
- **目標:** 任務進度、鈴鐺、今日總覽
- **Tasks** T017T019
- **驗證:** 模擬 job 完成出通知Today 有捷徑
### M9 — Scout / Settings / 收尾
- **目標:** 占位頁、datasource 切換、RWD 基線、範例劇本
- **Tasks** T020T022
- **驗證:** Scout 無假成功手機底欄可用build 通過
### M10 — Phase B 過關(人審)
- **目標:** 你主觀認可流程與風格
- **Tasks** 無新碼;走查 checklistrequirements Phase B
- **驗證:** 文件勾選 + 你回「B OK」
### M20 — Phase C 接真預留B 後再拆細 task
- `services/api` 最小 auth/me/permissions
- Threads OAuth + accounts
- Jobs + notifications live
- ThreadPlay/Outbox live 垂直切片
預留 task 號:**T100+**(本輪不寫檔)
### M30 — Phase D預留
- Scout 海巡滿血等 → **T200+**
## 3. 依賴
```text
M0 → M1 → M2 → M3 → M4
↘ M5 → M6 → M7
↘ M8
↘ M9 → M10 (人審)
↘ M20 → M30
```
## 4. 風險與緩解
| 風險 | 緩解 |
|------|------|
| Task 膨脹超 200 行 | 實作時再拆子 task更新 INDEX |
| Mock 契約與 C 不一致 | T007 介面以 spec §5 為準C 只換 adapter |
| 過早寫 Go | M10 前禁止大範圍 `services/api` 業務 |
## 5. 刻意不做(本 plan 週期內 = 到 M10
- 海巡真功能、舊碼搬回、重型 UI 庫
- live Threads 發文(屬 M20
- email 驗證閘
## 6. 全域驗證指令
```bash
cd apps/web && npm install && npm run build
# 之後若有測試:
# npm test
```
## 7. 批准
- [x] Plan 已批准2026-07-09使用者好請幫我開始拆
- 實作順序:從 **T001** 起,完成一題勾 INDEX 再下一題

View File

@ -0,0 +1,331 @@
# Requirements: 巡樓 Console整包重構
> Status: **`approved`**
> Slug: `haixun-console`
> Last updated: `2026-07-09`
> 批准:使用者確認 OK2026-07-09
> 策略:舊碼整包封存 `old/`**主線從零重寫**;先地基 → 前端 mock → 再接後端;文案含多帳號互回
> 變更reply 步驟可選 **lead**(主帳可接續回覆)
---
## 1. 一句話
用 Grok 4.5 **重構**巡樓工作台先把登入、Threads 串接、Job、通知、權限等地基做對**前端用全新流程與視覺 + 假資料**把操作順到你滿意,再補真實後端與業務,避免在舊混亂上疊功能。
---
## 2. 誰用、在什麼情境
| 角色 | 目標 |
|------|------|
| 經營者 | 綁多個 Threads、產文案主帳+配角互回)、海巡外展、排程發文;看任務與通知 |
| 管理員 | 建會員、管權限(封閉 beta |
| 系統Worker | 長任務執行並回報進度 |
---
## 3. 背景與動機
### 為什麼要重構(不是小修)
- 現況前後端半成品交織:假成功 API、幽靈 worker、巨型頁、文件漂移 → **在舊樹上修會繼續亂**
- 你希望流程與風格都重想;業務好不好用要先**用手走完**,不是先堆後端。
- 交付方式:**舊 monorepo 已封存至 `old/`**,根目錄從零建立 `apps/web`、`services/*` 等;`old/` 只讀參考。
### 參考(非最終規格)
- 封存:`old/backend/`、`old/docs/`、`old/AGENTS.md`
- 產品:`docs/product/haixun-console/`
---
## 4. 交付策略(怎麼重構)— 硬性順序
```text
Phase A 地基(最小可用平台)
→ Phase B 新前端 + Mock流程與風格定稿
→ Phase C 後端接真(替換 mock同一套 UI
→ Phase D 業務加深(海巡、進階互回、成效…)
```
| Phase | 目標 | 完成定義(產品語言) |
|-------|------|----------------------|
| **A 地基** | 平台能站起來 | 登入會話可用權限可擋路由Threads 帳號能綁/能呼叫 API或明確 mock 開關Job 能建立/查狀態/通知;**無假成功** |
| **B 前端 Mock** | 操作順、好看 | 全主要流程用假資料可點完;你認可 IA + 視覺API 層可切 `mock | live` |
| **C 接後端** | 假資料變真 | 同一 UI逐頁/逐域替換 live契約與 mock 對齊 |
| **D 業務加深** | 護城河 | 海巡閉環、進階互回/AI 分角、護欄與成效等 |
**原則:**
1. **B 未通過前,不開大範圍業務後端**(避免又寫一堆用不到的 API
2. A 只做「所有業務都會用到的管子」,不做完整海巡/完整矩陣。
3. 新碼只在根目錄新結構;**不在 `old/` 上繼續開發**。
4. 舊 monorepo **已封存**於 `old/`2026-07-09
---
## 5. 目標分級
### 5.1 P0-A — 地基(平台)
1. **登入與會話**
email/password 登入、logout、refresh未登入不可進主殼。
封閉註冊admin 建號或 seed公開註冊非 P0。
2. **權限**
至少:已登入會員 vs admin前端路由與關鍵操作可依角色顯示/阻擋;後端同語意。
3. **Threads API 串接完成(基礎)**
- 綁定帳號OAuth 或文件化的連線方式)
- 列表已綁帳號、連線健康/錯誤可讀
- 具備「用指定帳號打 Threads API」的統一出口發文/回覆可先最小 smoke完整業務在 C/D
4. **Job 系統(最小)**
建立任務、查列表/詳情、狀態queued/running/succeeded/failed/cancelled、進度摘要。
5. **Notification任務通知**
任務完成/失敗時Console 內可感知(鈴鐺列表或 toast + 可點進 job不要求 email/push 推播為 P0。
6. **契約底線**
JSON envelope、錯誤可讀、時間 UTC nano後端、**禁止 stub 回成功**。
### 5.2 P0-B — 新前端 + Mock你要先滿意的部分
1. **全新資訊架構與視覺**(見 §8不沿用「掌機側欄 + 巨型單頁」當預設。
2. **Mock 驅動的完整主流程**(假資料也要真實感):
- 登入殼 → 總覽(今日待辦)
- 帳號(多 Threads
- **發文計畫 / 串場**(主題 → 主帳+cast → 草稿串 → 預覽 → 排程預覽)
- 出站佇列Outbox狀態
- 任務與通知中心
- 設定AI key 可先 UI only
3. **多帳號互回(流程先做對)**
- 主帳號 **lead** 1 個負責樓主貼root
- **cast** 多選:本串可出場的其他帳號
- **發言池 = lead cast**:每一則「回覆步驟」都可指定池內**任一帳號****包含 lead**
lead 發主貼 → cast 帳回 → **lead 再回那則** → 另一 cast 再接)
- 單線接龍步驟可編輯、可預覽「誰說什麼」
- Mock 發布過程:逐步狀態動畫/列表即可
4. **雙模式資料層**
`dataSource: mock | api`B 預設 mock切 api 時契約不變。
5. **RWD**:手機可走完核心流(不必完美,但不可壞掉)。
### 5.3 P0-C — 接後端(在 B 過關後)
1. Auth / me / permissions 接真。
2. Threads 帳號與 OAuth 接真。
3. Job + 站內通知接真。
4. 發文計畫 / 互回草稿 / 佇列逐步接真(可按垂直切片:先單帳發文,再互回)。
### 5.4 P1 — 業務加深
1. 海巡:品牌 → 主題 → 掃描 → 外展草稿。
2. 互回進階AI 分角色產整串、間隔/護欄 per-account、失敗單步重試完善。
3. 提及收件匣、成效儀表。
4. Job 可靠度硬化dedupe/heartbeat 等)。
5. Email 驗證閘道(若仍需要)。
### 5.5 P2
公開註冊、Islander agent、extension 安全同步、大型多租戶計費等。
---
## 6. 範圍
### In scope
- **從零重寫**(舊碼在 `old/` 封存,主線新樹)。
- 地基Auth、Permission、Threads 基礎串接、Job、站內 Notification。
- 全新前端體驗 + mock 業務流(含多帳互回)。
- 之後同一套 UI 接後端。
- 產品文件:`docs/product/haixun-console/` 為決策真相。
### 多帳號互回 — 行為邊界
| 項目 | P0 要 | P0 不要 |
|------|-------|---------|
| lead | 必選 1 個;**root 固定 lead****也可在 reply 再出場** | 主帳只能發主貼、不能回後續 |
| cast | 多選其他帳;與 lead 組成**發言池** | 跨會員借號 |
| reply 發言者 | **lead 或任一 cast**(主帳可回別人的回覆) | 僅 cast 能回 |
| 串結構 | 單線接龍:每步接上一步 | 樹狀多分支(以後再說) |
### Out of scope本重構週期明確不做
- 在 `old/` 或舊 UI 上繼續疊功能當最終產品。
- 把 `old/` 整包「搬回」當新主線而不重寫。
- 復原舊 copy_mission 五段 worker 管線。
- 自動 refill 庫存 bot、公開自助註冊、MFA、Zitadel。
- 重型 UI 框架MUI/Ant/Chakra
- 任天堂 / Nook 元素、emoji 當主圖示。
- 把海巡與互回做成同一套語意。
- 未綁定帳號、跨會員借號。
- B 階段沒過就大寫海巡/矩陣後端。
---
## 7. 使用者故事
### 地基
| ID | 作為… | 我想要… | 以便… | 驗收 |
|----|--------|---------|--------|------|
| US-A1 | 經營者 | 登入並維持會話 | 進工作台 | 登入進主殼logout 後不可進 |
| US-A2 | 經營者 | 依權限看到該看的選單 | 不誤操作 | 無 permission 時入口隱藏或 403 |
| US-A3 | 經營者 | 綁定並看到 Threads 帳號狀態 | 後續發文有對象 | 列表可見;錯誤可讀 |
| US-A4 | 經營者 | 看背景任務狀態與完成通知 | 知道跑完沒 | job 列表;完成/失敗有通知可點 |
### 前端 Mock / 文案流
| ID | 作為… | 我想要… | 以便… | 驗收 |
|----|--------|---------|--------|------|
| US-B1 | 經營者 | 在新 UI 上走完「今日 → 開一個發文計畫」 | 覺得流程順 | mock 下無卡死、步驟清楚 |
| US-B2 | 經營者 | 指定主帳 + 選其他帳互回,且 lead 也可再回後續留言 | 多號串場、主帳能接話 | 每句顯示帳號reply 步驟可選 lead 或 cast預覽順序正確 |
| US-B3 | 經營者 | 在 Outbox 看到這串的排程狀態 | 掌握發送 | mock 狀態流轉合理 |
| US-B4 | 經營者 | 從通知跳到對應任務或計畫 | 減少找頁 | 一鍵到達 |
### 接真後C
| ID | 作為… | 我想要… | 以便… | 驗收 |
|----|--------|---------|--------|------|
| US-C1 | 經營者 | 同一套畫面改打真 API 仍可用 | 上線 | mock/api 契約一致;關鍵流 live 通過 |
---
## 8. 新前端:流程與風格方向(提案,待你選/改)
> 現況問題(簡述):導覽像「很多 App 格」但主線不清;單頁過重;視覺偏玩具掌機,專業經營感不足;海巡/文案/發布心智模型混。
### 8.1 產品隱喻(建議)
**「泊岸工作台 Harbor Desk」** — 不是掌上遊戲機,而是**靠港調度台**
- 今天要出港的內容(發文/互回)
- 船隊帳號(多 Threads
- 遠航探查海巡P1
- 船期與回報Outbox + Jobs + 通知)
語氣:年輕、清楚、短句;可留「島」「巡」詞,但 UI **不要**雲朵滿版搶戲。
### 8.2 資訊架構(建議導覽)
最多 **5 個主入口**(桌面側欄 + 手機底欄):
| 入口 | 做什麼 |
|------|--------|
| **今日 Today** | 待發、失敗需處理、進行中 job、快捷「開新串」 |
| **帳號 Crew** | 綁定/健康/選預設主帳 |
| **創作 Studio** | 發文計畫 wizard + 草稿庫(多帳互回在這裡) |
| **探查 Scout** | 海巡P1 再滿血B 可放空狀態說明) |
| **發送 Outbox** | 佇列、歷史、護欄狀態 |
**全域:** 頂欄右側 = 通知鈴 + 主題 + 使用者選單(設定/登出)。
**不做:** 十幾個 App tile、Jobs/Gaps 當一級又難找又佔位Jobs 併入通知與 Today
### 8.3 創作主流程Studio wizardB 階段就要順)
```text
1. 主題 一句話 + 可選「幫我想」
2. 船隊 選 lead主帳+ cast其他可出場帳可多選
3. 劇本 單線步驟:第 0 步 root=lead之後每步 = **發言池內帳號(含 lead** + 文案
4. 預覽 像 Threads 對話泡泡;可見 lead 穿插回覆
5. 船期 主貼時間 + 回覆間隔
6. 提交 進 Outboxmock 狀態:排程中 → 發送中 → 完成/失敗)
```
任一兩可回上一步;每步自動存 draftmock localStorage 即可)。
### 8.4 視覺方向(建議,可整包替換舊 hx-scene
| 面向 | 建議 |
|------|------|
| 調性 | **安靜專業 + 一點溫度**:偏「設計工具 / 編輯台」,不是遊戲 HUD |
| 色彩 | 深 slate 字 + 暖白紙面 + **單一 brand**(建議霧青/海色 `#2A9D8F` 系);深淺主題都走 token |
| 字體 | 維持 **Inter + Taipei Sans TC**(閱讀與中文標題夠用) |
| 版面 | 左窄導覽icon+字)+ 右內容;**不要**固定超寬掌機外框 |
| 卡片 | 1px 細邊、中等圓角1216px、輕陰影少裝飾 blob |
| 動效 | 僅頁面切換與列表 150200ms不搶操作 |
| 圖示 | 原創線條 SVG 一套即可;禁止 emoji 當主 icon |
| 密度 | 表單與表格可讀優先;中文 line-height ≥ 1.6 |
**明確告別:** 舊「奶油票券 + 滿天雲 + PATROL PAD 掌機」作為預設殼若你仍想保留可作「懷舊主題」P2不當預設
### 8.5 Mock 資料原則
- 預置 23 個假 Threads 帳號(不同顯示名/頭像色)。
- 預置 1 條進行中 job、1 條失敗通知。
- 預置 1 份範例互回劇本root(lead) → reply(cast) → reply(lead)(示範主帳接話)。
- 所有列表可空狀態插畫/短文案,不空白死頁。
---
## 9. 成功標準
### Phase A
- [ ] US-A1A4 在「真後端或契約完整 mock server」下可演示。
- [ ] 無對外 stub 成功 API。
### Phase B**你主觀過關為準**
- [ ] 你親自走完 Studio 互回 wizard + Outbox + 通知,認為**流程順、風格可接受**。
- [ ] 能做出 root(lead) → reply(cast) → reply(lead) 並預覽/進 Outbox。
- [ ] 主 IA ≤ 5 個一級入口;無巨型單頁不可維護感(單檔建議有分頁/步驟元件)。
- [ ] `mock | api` 可切換架構已就位api 可暫時 501但不能亂成功
### Phase C
- [ ] 登入、帳號、job/通知、至少「單串發文或 mock 同等」一條 live 路徑。
- [ ] 同一 UI 不需重畫。
### 全域
- [ ] 產品決策以本文 + 後續 spec/plan/tasks 為準。
---
## 10. 約束與假設
- **重構工具:** 以 Grok 4.5 / 本 repo agent 為主;`/spec-driven` 管文件閘門。
- **可重寫 monorepo**;舊碼是零件庫與行為參考。
- **後端契約精神可保留**envelope、JWT 分離、job 狀態機),實作可新建。
- **前端:** 輕量 React + Vite或同等不引入重型 UI kit**新 design token 允許全新一輪命名**。
- **假設:** B 階段你會參與 12 輪流程/視覺拍板,否則 mock 白做。
- **假設:** Threads App 憑證在接 C 時可取得。
- **互回:** 僅自有已綁帳號P0-B 單線接龍;**reply 發言池含 lead**(主帳可回別人的回覆)。
---
## 11. 風險
| 風險 | 緩解 |
|------|------|
| 重寫範圍爆炸 | 死守 A→B→C→DB 過關才加深後端 |
| Mock 與真 API 契約漂移 | 先定 thin types / MSW 或 mock 模組與未來 API 同 shape |
| 視覺改了又改 | B 只定 **一套** 預設主題;大改須改 requirements/spec |
| 舊資料/舊 job 遷移 | C 再定B 不承諾遷移 |
| 多帳互回後端複雜 | B 只鎖 UXC 垂直切片接真 |
---
## 12. 開放問題(請你拍板)
1. **重寫落點:** ~~二選一~~**已決策:舊碼進 `old/`;新前端 `apps/web`;新後端另建(如 `services/api`),不從舊樹原地改。**
2. **登入是否要 email 驗證才能進?** **預設A 可先關P1 再開。**
3. **視覺提案「Harbor Desk」是否當預設** 若否,請給 3 個形容詞(例如:極簡黑白 / 編輯器感 / 更活潑)。
4. **Scout海巡在 B 階段:** 完全隱藏,還是放「即將推出」空頁?**預設:空頁占位,免導覽以後大改。**
5. **Mock 持久化:** 僅 memory還是 localStorage 跨重新整理?**預設localStorage。**
---
## 13. 附錄:與舊需求的差異(摘要)
| 舊 | 新 |
|----|-----|
| 不在舊樹上重寫 monorepo | **允許整包重構/重寫** |
| 海巡/文案/發布 P0 一次閉環 | **先 A 地基 + B 前端 mock再 C 接真** |
| 沿用田園掌機 UI | **新 IA + Harbor Desk 風格提案** |
| 多帳互回(已納入) | **保留,且 B 階段就要流程順** |
---
## 14. 批准
- [x] 需求已批准2026-07-09使用者 OK採預設`apps/web`、Harbor Desk、海巡空頁、email 驗證 A 先關、mock localStorage
- 下一步:審核 [spec.md](./spec.md) → 回 `spec OK` 後寫 plan

View File

@ -0,0 +1,463 @@
# Spec: 巡樓 Console 重構Harbor Desk
> Status: **`approved`**
> Source: `docs/product/haixun-console/requirements.md`approved 2026-07-09
> Last updated: `2026-07-09`
> 批准:使用者要求開始拆 plan/tasks2026-07-09
> 變更reply 步驟發言池含 lead主帳可回別人的回覆
> 範圍Phase A 地基契約 + Phase B 前端/Mock 行為Phase C 接真映射Phase D 僅邊界預留
---
## 1. 摘要
重構後的巡樓 Console 分四階段交付。本規格鎖定:
1. **平台地基**登入會話、角色權限、Threads 帳號基礎、Job、站內通知。
2. **新前端 Harbor Desk**5 入口 IA、Studio 多帳互回 wizard、Outbox、通知**預設 mock 資料**。
3. **同一契約**在 `mock | live` 切換B 未過關不開大型業務後端。
落點2026-07-09 已封存舊碼):
| 項目 | 決策 |
|------|------|
| 舊 monorepo | **`old/` 整包封存,只讀** |
| 新前端 | repo 根 `apps/web`(從零) |
| 新後端 | `services/api`從零A 可先 mock-only 前端) |
| 視覺 | Harbor Desk 為唯一預設主題 |
| 海巡 Scout | B 階段占位空頁 |
| Email 驗證閘 | A/B 關閉 |
| Mock 持久化 | `localStorage` |
---
## 2. 名詞與實體
| 名詞 | 定義 |
|------|------|
| Member | 登入會員email/password + JWT |
| Role | 簡化:`member` \| `admin`(可多值) |
| ThreadsAccount | 已綁定的 Threads 經營帳號OAuth token 由後端保管) |
| Lead | 本串**主帳號**:固定發 `root` 樓主貼;並屬於發言池,可在後續 `reply` 再出場 |
| Cast | 本串**額外可出場帳號**(通常不含 lead與 lead 組成發言池 |
| Speakers發言池 | `{ lead_account_id } cast_account_ids`**每一則 reply 步驟的 `account_id` 必須 ∈ Speakers****允許 = lead** |
| ThreadPlay | **發文計畫 / 串場劇本**(一則主貼 + 有序回覆步驟) |
| PlayStep | 劇本一步:`kind=root\|reply`、綁定 `account_id`、文案、可選排程偏移;`reply` 語意為接上一步(單線) |
| OutboxItem | 出站佇列項目:對應某 PlayStep 的一次發送意圖與狀態 |
| Job | 背景任務 run進度、狀態、結果摘要 |
| Notification | 站內通知(多由 Job 終態產生) |
| DataSource | `mock` \| `live`:前端資料來源模式 |
---
## 3. 狀態機
### 3.1 Job
```text
pending --> queued --> running --> succeeded
running --> failed
running --> cancel_requested --> cancelled
queued|pending --> cancelled (若支援提前取消)
* --> expired (lock 逾時後端語意mock 可簡化省略)
```
### 3.2 ThreadPlay
```text
draft --> ready (劇本與帳號齊全、通過預覽)
ready --> scheduling (寫入 Outbox)
scheduling --> active (至少一步 in flight 或 scheduled)
active --> completed (全部步驟 succeeded 或 skipped)
active --> partial_failed (有 failed可重試)
draft|ready --> archived
```
### 3.3 PlayStep / OutboxItem
```text
draft --> scheduled --> publishing --> published
publishing --> failed
scheduled --> cancelled
failed --> scheduled (retry)
```
**規則:**
- `root` 步驟未 `published` 前,後續 `reply` 不得進入 `publishing`
- `root``failed`,後續步驟保持 `scheduled` 或改 `blocked`(顯示「等待主貼」)。
- `root.account_id` **必須** = `lead_account_id`
- 任一 `reply.account_id` **必須** ∈ Speakers**含 lead**);校驗失敗不可 submit。
- P0 單線:第 n 步 reply 視為回覆第 n1 步已發出的那則(不另選 parentD 再談樹狀)。
### 3.4 ThreadsAccount.connection
```text
unknown --> connected
unknown --> error
connected --> error
error --> connected (重新授權/診斷成功)
```
---
## 4. 使用者流程
### 4.1 登入A/B
1. 使用者開 `/login`,輸入 email、password可選 tenant 若多租戶 UI 需要mock 固定 `default`)。
2. 成功 → 存 access/refreshlive或 session flagmock→ 進 `/app/today`
3. 失敗 → 表單錯誤,不進主殼。
4. 重新整理:有有效會話則進主殼,否則回登入。
5. 登出 → 清會話 → 登入頁。
### 4.2 帳號 CrewB mock / C live
1. 列表顯示已綁帳號顯示名、頭像色、connection、是否預設 lead 候選。
2. 「連接 Threads」live 走 OAuthmock 新增一個假帳號。
3. 標記不健康帳號不可被選為 Lead/Castdisabled + 原因)。
### 4.3 創作 Studio — 互回 wizardB 核心)
| 步驟 | 畫面行為 |
|------|----------|
| 1 主題 | 標題/一句主題;可「示範填入」 |
| 2 船隊 | 必選 1 Lead多選 Cast≥0UI 列表可預設排除 lead避免重複勾不可選 error 帳號 |
| 3 劇本 | 第 0 步固定 `root` + `account_id=lead`(不可改帳);其後每步 `reply`**下拉發言池 = lead + 全部 cast****含 lead**,以便主帳回別人的回覆)+ textarea可增刪排序root 固定頂) |
| 4 預覽 | 對話泡泡:帳號名 + 文案root 標記;可見 lead 出現在非第 0 步 |
| 5 船期 | 主貼時間datetime回覆間隔分鐘預設 5可預覽各步預計時間 |
| 6 提交 | 建/更新 ThreadPlay=`scheduling`,生成 OutboxItem導向 Outbox 或 Today |
任一步「上一步」保留表單狀態。草稿自動存mock`localStorage` key 見 §8
### 4.4 Outbox
1. 列表Play 標題、狀態、步驟進度2/5
2. 點進詳情:逐步狀態時間線。
3. Mock「模擬發送」依序 root→replies 推進狀態(可加速)。
4. 失敗步驟顯示錯誤文案 +「重試」mock 直接 succeeded
### 4.5 Today
1. 卡片:待處理失敗、今日排程、進行中 Job、快捷「開新串場」。
2. 點通知/失敗 → 對應 Outbox 或 Job 詳情。
### 4.6 通知
1. 頂欄鈴鐺:未讀數。
2. 下拉最近 N 筆;點擊標記已讀並 navigate。
3. Job `succeeded|failed` 產生通知mock 在模擬結束時寫入)。
### 4.7 ScoutB
占位頁:標題「探查」、短說明「海巡獲客將在下一階段開放」、無假成功操作。
### 4.8 設定
- 顯示目前 `DataSource` 切換dev 可見prod 預設 live 且可藏)。
- AI keyB 僅 UI 存 local不送出C 再定義。
---
## 5. 介面契約
### 5.0 共用規則
| 規則 | 規格 |
|------|------|
| Envelope | `{ code, message, data?, error? }`;成功 `code=102000` |
| 列表 | query `page`/`pageSize``data: { pagination, list }` |
| 時間 | API/DB**unix nanoseconds int64 UTC**UI 顯示本地時區 |
| Auth | `Authorization: Bearer <access>`401 → refresh 一次 → 仍失敗登出 |
| 禁止 | 未實作端點回 200+空成功;應 501 或明確業務錯誤 |
前端 repository 介面(概念):
```text
AuthRepo, MemberRepo, PermissionRepo,
ThreadsAccountRepo, ThreadPlayRepo, OutboxRepo,
JobRepo, NotificationRepo
```
`createRepos(dataSource)` → mock 或 live 實作同一 TypeScript interface。
### 5.1 HTTP / APIPhase A 最小 + C 對齊)
> 路徑為**目標契約**;可與舊 `/api/v1` 對齊以利搬碼。Mock 不經 HTTP 亦可,但 shape 相同。
#### Auth / Member / Permission
| Method | Path | 用途 | 要點 |
|--------|------|------|------|
| POST | `/api/v1/auth/login` | 登入 | body: email, password, tenant_id? → tokens |
| POST | `/api/v1/auth/refresh` | 換票 | refresh_token → tokens |
| POST | `/api/v1/auth/logout` | 登出 | auth |
| GET | `/api/v1/members/me` | 我 | tenant_id, uid, email, roles[] |
| GET | `/api/v1/permissions/me` | 我的權限 | keys[] 或 * |
Admin 建號可延後mock 內建一組使用者即可。
#### Threads accounts
| Method | Path | 用途 |
|--------|------|------|
| GET | `/api/v1/threads-accounts` | 列表pagination |
| POST | `/api/v1/threads-accounts/oauth/start` | 取得授權 URLlive |
| GET | `/api/v1/threads-accounts/:id` | 詳情 / 健康 |
| POST | `/api/v1/threads-accounts/:id/diagnostics` | 連線診斷(可選 A |
#### Jobs
| Method | Path | 用途 |
|--------|------|------|
| GET | `/api/v1/jobs` | 列表 |
| GET | `/api/v1/jobs/:id` | 詳情 |
| POST | `/api/v1/jobs/:id/cancel` | 取消 |
| POST | `/api/v1/jobs` | 建立A 可僅 demo 或內部) |
Job 資源欄位(最低):`id, template_type, status, progress_summary, progress_percent, error?, created_at, updated_at, completed_at?`
#### Notifications
| Method | Path | 用途 |
|--------|------|------|
| GET | `/api/v1/notifications` | 列表 |
| POST | `/api/v1/notifications/:id/read` | 已讀 |
| POST | `/api/v1/notifications/read-all` | 全已讀 |
欄位:`id, title, body, kind, ref_type, ref_id, read_at?, created_at`
#### ThreadPlay / OutboxB mock 完整C 再 live
| Method | Path | 用途 |
|--------|------|------|
| GET/POST | `/api/v1/thread-plays` | 列表 / 建立 |
| GET/PATCH | `/api/v1/thread-plays/:id` | 讀 / 更新劇本 |
| POST | `/api/v1/thread-plays/:id/submit` | 提交進 Outbox |
| GET | `/api/v1/outbox` | 佇列列表 |
| GET | `/api/v1/outbox/:id` | 詳情(含 steps |
| POST | `/api/v1/outbox/:id/steps/:stepId/retry` | 重試一步 |
**ThreadPlay 主體(邏輯 JSON**
```json
{
"id": "…",
"title": "週末咖啡話題",
"topic": "…",
"status": "draft",
"lead_account_id": "…",
"cast_account_ids": ["…"],
"steps": [
{
"id": "s0",
"sort_order": 0,
"kind": "root",
"account_id": "…",
"text": "主貼文案",
"delay_from_previous_sec": 0
},
{
"id": "s1",
"sort_order": 1,
"kind": "reply",
"account_id": "…",
"text": "回覆文案",
"delay_from_previous_sec": 300
}
],
"schedule_start_at": 0,
"created_at": 0,
"updated_at": 0
}
```
### 5.2 前端路由(`apps/web`
使用 **path router**(非 hashbasename 可 `/`
| Path | 名稱 | 需登入 | Phase |
|------|------|--------|-------|
| `/login` | 登入 | 否 | B |
| `/app` | 佈局殼 | 是 | B |
| `/app/today` | 今日 | 是 | B |
| `/app/crew` | 帳號 | 是 | B |
| `/app/studio` | 創作列表 | 是 | B |
| `/app/studio/new` | 新 wizard | 是 | B |
| `/app/studio/:playId` | 編輯 wizard | 是 | B |
| `/app/outbox` | 發送佇列 | 是 | B |
| `/app/outbox/:id` | 佇列詳情 | 是 | B |
| `/app/jobs` | 任務(可二級) | 是 | B |
| `/app/jobs/:id` | 任務詳情 | 是 | B |
| `/app/scout` | 探查占位 | 是 | B |
| `/app/settings` | 設定 | 是 | B |
**導覽一級≤5** Today · Crew · Studio · Scout · Outbox
Jobs 從 Today/通知進入Settings 在使用者選單。
### 5.3 Job typesA 最小)
| template_type | 用途 | Phase |
|---------------|------|-------|
| `demo_long_task` | 驗證 job+通知 | A/B mock 可模擬 |
| (業務 job | 發佈串場、8D… | C/D |
B mock 可不跑真 workerTimer 推進 job 狀態並寫 notification。
---
## 6. 與現況對照
| 能力 | 決策 | 備註 |
|------|------|------|
| 整個舊 monorepo | **Archive** | 實體路徑 `old/`;主線不引用為 package |
| 舊 Auth/JWT/envelope | **參考後重寫** | 契約精神可相容 |
| 舊 Job 核心 | **參考後重寫** | 可抄模式,不搬半成品 |
| copy_mission 管線 | **Discard** | 不復活 |
| 舊 web Console | **Archive** | 新 `apps/web` |
| 海巡 Patrol 全套 | **Defer D** | Scout 占位 |
| 田園掌機 UI | **Discard 預設** | Harbor Desk |
| 時間/envelope/job 規則 | **Keep as policy** | 見根目錄 `AGENTS.md` |
---
## 7. 非功能
### 安全 / 權限
- 未登入不可進 `/app/**`
- `admin` 專屬操作(若有)前後端雙檢。
- Livetoken 不進 URLrefresh 單飛。
- Mock不模擬權限繞過成功寫入 live。
### 錯誤
- UI 可讀中文 message。
- 區分 401登出vs 5xx保留會話顯示重試
### 可觀測
- Job 進度摘要必顯示。
- Outbox 步驟級狀態必顯示。
### 效能B
- 首屏可互動 < 3s本機 dev 網路
- 列表 mock ≤ 50 筆即可。
### 無障礙(基線)
- 表單 label 關聯;主按鈕對比足夠;焦點可見。
---
## 8. 資料保存(邏輯)
### 8.1 Mock localStorage keys
| Key | 內容 |
|-----|------|
| `harbor.datasource` | `mock` \| `live` |
| `harbor.session` | mock 登入態 |
| `harbor.accounts` | ThreadsAccount[] |
| `harbor.plays` | ThreadPlay[] |
| `harbor.outbox` | Outbox 聚合 |
| `harbor.jobs` | Job[] |
| `harbor.notifications` | Notification[] |
| `harbor.play_draft` | wizard 未提交草稿 |
### 8.2 實體欄位(最低)
| 實體 | 關鍵欄位 |
|------|----------|
| Member | uid, tenant_id, email, roles |
| ThreadsAccount | id, username, display_name, connection, is_usable |
| ThreadPlay | 見 §5.1 JSON |
| OutboxBundle | id, play_id, status, steps[] |
| OutboxStep | step_id, account_id, status, error?, published_at?, scheduled_at? |
| Job | 見 §5.1 |
| Notification | 見 §5.1 |
---
## 9. UI 規格Harbor Desk
### 9.1 Layout
```text
┌─────────────────────────────────────────────┐
│ Logo [今日]… 🔔 主題 使用者 ▾ │ topbar
├──────────┬──────────────────────────────────┤
│ Today │ Page title │
│ Crew │ Content │
│ Studio │ │
│ Scout │ │
│ Outbox │ │
└──────────┴──────────────────────────────────┘
```
- 桌面:左導覽 ~220px。
- 手機:底欄 5 格Today/Crew/Studio/Scout/Outbox設定進使用者選單。
- 內容最大寬 ~1080px表單閱讀寬 ~640720pxwizard
### 9.2 視覺 token建議名實作可微調色值
| Token | 用途 | 建議 |
|-------|------|------|
| `--hb-bg` | 頁背景 | 冷灰白 |
| `--hb-surface` | 卡片 | 白 |
| `--hb-ink` | 主文 | 近黑 slate |
| `--hb-muted` | 次文 | 可讀灰(勿過淡) |
| `--hb-line` | 邊框 | 淺灰 |
| `--hb-brand` | 主色/CTA | 霧海青 |
| `--hb-danger` | 錯誤 | 紅 |
| `--hb-success` | 成功 | 綠 |
| `--radius` | 卡片 | 1216px |
| `--font-sans` | 混排 | Inter, Taipei Sans TC, system |
禁止滿版動態雲、木質頂欄、emoji 主 icon、彩虹漸層按鈕。
### 9.3 元件基線
Buttonprimary/ghost/danger、Input、Textarea、Select、Card、Badge狀態、EmptyState、Stepperwizard、Toast、BellMenu。
---
## 10. 驗收場景Given / When / Then
1. **Given** 未登入 **When**`/app/today` **Then** 導向 `/login`
2. **Given** mock 帳密正確 **When** 登入 **Then** 進 Today 且看到至少一張待辦/空狀態。
3. **Given** 兩個 usable 帳號 A、B **When** Studiolead=A cast=B劇本為 root(A) → reply(B) → reply(A) **Then** 可預覽三句submit 後 Outbox 三步順序正確且第二則回覆發言者為 lead。
3b. **Given** 劇本某 reply 選了不在 Speakers 的帳號 **When** 提交 **Then** 阻擋並提示。
4. **Given** 已提交串 **When** 模擬發送 **Then** root→reply 依序 published通知出現成功。
5. **Given** root 模擬失敗 **When** 查看 **Then** reply 不標 published可重試 root。
6. **Given** Job 模擬完成 **When** 開鈴鐺 **Then** 未讀 +1點進 job 詳情。
7. **Given** Scout **When** 進入 **Then** 僅占位,無「成功建立海巡」類假操作。
8. **Given** live 模式且後端 5xx **When** 拉 me **Then** 不強制清 token與需求 US-09 對齊C 必須)。
---
## 11. Phase 邊界(實作不得越級)
| Phase | 做 | 不做 |
|-------|----|------|
| A | 契約+可選最小 live auth/job或純 mock 平台殼 | 海巡、完整互回後端 |
| B | `apps/web` 全 mock 體驗 | 大寫業務 API |
| C | 逐 repo 接 live | 改 IA 大翻(除非修 bug |
| D | 海巡等 | — |
---
## 12. 開放規格問題
1. **Tenant UI** login 是否顯示 tenant 欄?**預設隱藏mock/live 固定 default tenant。**
2. **Lead 能否出現在 reply****已決策:必須支援**主帳回別人的回覆。Cast 多選 UI 仍可不重複勾 lead發言下拉一定含 lead。
3. **A 是否必須先有可跑的 Go gateway還是 B 可完全純前端?** **預設B 可純前端 mockA 的 live 可與 B 平行,但不阻塞 B 過關。**
4. **Outbox 與舊 publish_queue 是否同 collection** **預設C 再定映射;規格層當新模型。**
---
## 13. 批准
- [x] Spec 已批准2026-07-09使用者好請幫我開始拆
- 下一步:依 [plan.md](./plan.md) 與 [tasks/](./tasks/) 實作

View File

@ -0,0 +1,35 @@
# Tasks INDEX — haixun-console
> Phase B 主線T001T022
> Phase C/DT100+ / T200+ 預留(未建檔)
| ID | Title | Milestone | Status | Est. lines |
|----|-------|-----------|--------|------------|
| T001 | Scaffold apps/web (Vite React TS) | M0 | done | ~120 |
| T002 | Harbor design tokens + global CSS + fonts | M1 | todo | ~150 |
| T003 | UI primitives (Button Input Card …) | M1 | todo | ~180 |
| T004 | App shell layout + desktop nav + mobile dock | M1 | todo | ~180 |
| T005 | Router + route table + placeholder pages | M1 | todo | ~150 |
| T006 | Mock auth session + login page + guard | M2 | todo | ~160 |
| T007 | Domain types + repo interfaces + createRepos | M3 | todo | ~180 |
| T008 | Mock repos + localStorage + seed data | M3 | todo | ~200 |
| T009 | Crew page (accounts list + mock connect) | M4 | todo | ~160 |
| T010 | Studio list page (plays) | M5 | todo | ~120 |
| T011 | Studio wizard shell + stepper + draft persist | M5 | todo | ~160 |
| T012 | Wizard steps: topic + crew (lead/cast) | M5 | todo | ~160 |
| T013 | Wizard script steps (speakers include lead) | M5 | todo | ~180 |
| T014 | Wizard preview + schedule + submit to outbox | M6 | todo | ~180 |
| T015 | Outbox list + detail timeline | M7 | todo | ~160 |
| T016 | Outbox simulate publish + retry rules | M7 | todo | ~180 |
| T017 | Mock jobs + progress timer | M8 | todo | ~150 |
| T018 | Notification center + bell menu | M8 | todo | ~160 |
| T019 | Today dashboard page | M8 | todo | ~150 |
| T020 | Scout placeholder + Settings (datasource) | M9 | todo | ~120 |
| T021 | Seed example play root→cast→lead | M9 | todo | ~100 |
| T022 | RWD polish + build smoke checklist | M9 | todo | ~120 |
## How to work
1. Pick next `todo` whose Depends on are `done`.
2. Set status `doing` → implement only that task → acceptance → `done`.
3. Do not start T100+ until Phase B human OK.

Some files were not shown because too many files have changed in this diff Show More