Compare commits

..

No commits in common. "feat/frontend" and "main" have entirely different histories.

1854 changed files with 105415 additions and 98930 deletions

15
.dockerignore Normal file
View File

@ -0,0 +1,15 @@
.git
.run
.cursor
**/node_modules
**/.env
backend/bin
backend/web/dist
backend/web/public/downloads/*.zip
backend/dev-console/dist
frontend/dist
**/*_test.go
**/.DS_Store
infra
*.md
!deploy/**

31
.gitignore vendored
View File

@ -1,34 +1,24 @@
# --- secrets / env --- # --- secrets / env ---
*.env
**/.env
!.env.example
!*.env.example
deploy/.env deploy/.env
deploy/.env.dev deploy/.env.dev
deploy/.env.prod deploy/.env.prod
infra/.env infra/.env
# Local runtime credentials. Commit the adjacent gateway.example.yaml only. *.env
apps/backend/etc/gateway.yaml **/.env
old/deploy/.env !.env.example
old/deploy/.env.dev !*.env.example
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/
**/dist/ backend/web/public/downloads/*.zip
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/
@ -37,6 +27,3 @@ node_modules/
# --- OS / editor --- # --- OS / editor ---
.DS_Store .DS_Store
.cursor/ .cursor/
# deploy runtime
deploy/run/

View File

@ -1,134 +0,0 @@
---
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

@ -1,48 +0,0 @@
# 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

@ -1,77 +0,0 @@
# 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

@ -1,85 +0,0 @@
# 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

@ -1,54 +0,0 @@
# 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

@ -1,54 +0,0 @@
# 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

@ -1,51 +0,0 @@
#!/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"

433
AGENTS.md
View File

@ -1,48 +1,413 @@
# Agent 規範(重構主線)
## 封存與主線 # Agent Handoff Notes
- **`old/`**:舊系統整包封存,**只讀參考**。不要在 `old/` 修功能當交付。 這個資料夾是新的巡樓後端核心,請優先維持乾淨邊界,不要把舊 Next.js 或 `template-monorepo` 的業務包袱搬進來。
- **新碼**:只寫在 **`apps/`** 下(前端 `apps/web`、後端 `apps/backend` 等,以 `docs/product` 為準)。
- **後端落點**Phase C 巡樓 APIworker 在 **`apps/backend`**go-zero`stand-alone-service` 遷入改造)。
- **後端寫法(硬性)**:一律 **goctl**:改 `generate/api/*.api``make gen-api` → 業務只寫 `internal/logic/**``internal/module/**`。**禁止**手寫 `handler``routes.go` 當主路徑。
- **產品真相**`docs/product/<slug>/`requirements → spec → plan → tasks
- **流程**`/spec-driven``.grok/skills/spec-driven/`)。
## 交付順序(硬性) ## 核心原則
1. Phase A 地基 - 全系統時間一律 **UTC+0**;寫入 Mongo / API 的時間欄位一律 **unix nanoseconds**`int64`)。排程的 `timezone` 只用於 cron 解讀與下發 payload不作為儲存時區。
2. Phase B 前端 + mock操作順再往下 - 複製模式,不複製舊業務。
3. Phase C 接真 API - `logic` 做 API 編排,`model/usecase` 做可重複使用能力。
4. Phase D 業務加深 - provider adapter 不讀 setting、不碰 Mongo、不知道 HTTP。
- 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 天補充)、產品匹配、島民交接。
- 時間:**UTC**;儲存 **unix nanoseconds** `int64` ## 新增 API 流程
- JSON`code` / `message` / `data` / `error`;成功碼 `102000`
- 列表:`page` / `pageSize``pagination` + `list`
- Job狀態 **guarded** 更新Redis lock value = `workerID`
- 會員 JWT vs AI provider token 分離。
- **禁止**未實作 API 回成功空資料。
## 前端精神Harbor Desk 1. 修改 `generate/api/*.api`
2. 優先使用 `make gen-api` 重新產生 handler/logic/types。
3. 若手寫 handler仍需遵守 `response.Write` 與 validator 流程。
4. SSE endpoint 不使用 `response.Write`,直接輸出 `text/event-stream`
5. 更新 `README.md` 的 API 與架構說明。
- 新殼在 `apps/web`;不引入 MUI/Ant/Chakra。 ## Response / Error Code
- 不複製任天堂 / Nook UI不用 emoji 當主 icon。
- 字體Inter + Taipei Sans TC。
- mock | live 同一 repository 介面。
- 詳見 `docs/product/haixun-console/spec.md`
## 參考舊碼 錯誤碼格式是 `SSCCCDDD`
需要對照實作時:
```text ```text
old/backend/ # 舊 Go / web / node workers SS = scope
old/AGENTS.md # 舊詳細規範 CCC = category
old/docs/ # 舊計劃文件 DDD = detail
``` ```
複製模式可以,**不要**把半成品與死碼整包搬回主線。 目前 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
```

128
Makefile Normal file
View File

@ -0,0 +1,128 @@
SHELL := /bin/bash
BACKEND_DIR := backend
WEB_DIR := backend/web
INFRA_DIR := infra
DEPLOY_DIR := deploy
INFRA_DEV_COMPOSE := docker compose --env-file $(DEPLOY_DIR)/.env.dev -f $(INFRA_DIR)/docker-compose.yml
INFRA_PROD_COMPOSE := docker compose --env-file $(DEPLOY_DIR)/.env.prod -f $(INFRA_DIR)/docker-compose.yml
DEPLOY_COMPOSE := docker compose --env-file $(DEPLOY_DIR)/.env.prod -f $(DEPLOY_DIR)/docker-compose.yml
.DEFAULT_GOAL := help
.PHONY: help
help: ## 顯示可用指令
@grep -E '^[a-zA-Z0-9_-]+:.*## .*$$' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}'
.PHONY: setup-env
setup-env: ## 互動式產生 deploy/.env.prod自動 URL-encode MongoDB 密碼)
@$(DEPLOY_DIR)/setup.sh
.PHONY: gen-api
gen-api: ## 重新產生後端 API handler / logic / types
$(MAKE) -C $(BACKEND_DIR) gen-api
.PHONY: fmt
fmt: ## 格式化後端 Go 程式
$(MAKE) -C $(BACKEND_DIR) fmt
.PHONY: test
test: ## 執行後端測試
$(MAKE) -C $(BACKEND_DIR) test
.PHONY: init
init: ## 初始化系統(建索引、種權限、建立 admin讀 deploy/.env.prod
$(MAKE) -C $(BACKEND_DIR) ENV_FILE=../deploy/.env.prod init
.PHONY: run
run: ## 啟動後端 API
$(MAKE) -C $(BACKEND_DIR) run
.PHONY: dev-all
dev-all: ## 啟動本機完整 dev stackAPI、Go workers、Node workers、web
./scripts/dev-all.sh
.PHONY: dev-all-stop
dev-all-stop: ## 停止本機 dev stack釋放 8890 / 9101 / 9102 / web 等埠)
./scripts/dev-all-stop.sh
.PHONY: web-dev
web-dev: ## 啟動正式前端 dev server
cd $(WEB_DIR) && npm install && npm run dev
.PHONY: web-build
web-build: ## 建置正式前端
cd $(WEB_DIR) && npm install && npm run build
.PHONY: verify
verify: test web-build ## 後端測試與前端建置
.PHONY: dev-infra
dev-infra: ## 啟動本機 dev 資料服務Mongo / Redis 容器,讀 deploy/.env.dev
@test -f $(DEPLOY_DIR)/.env.dev || cp $(DEPLOY_DIR)/.env.dev.example $(DEPLOY_DIR)/.env.dev
@echo ">>> 請先編輯 $(DEPLOY_DIR)/.env.dev 再執行後續指令" >&2
$(INFRA_DEV_COMPOSE) up -d
$(INFRA_DEV_COMPOSE) ps
.PHONY: dev-infra-down
dev-infra-down: ## 關閉本機 dev 資料服務(保留資料 volume
$(INFRA_DEV_COMPOSE) down
.PHONY: dev-infra-recreate
dev-infra-recreate: ## 清除 dev 資料 volume 並重啟Mongo / Redis 全新狀態)
$(INFRA_DEV_COMPOSE) down -v
$(INFRA_DEV_COMPOSE) up -d
$(INFRA_DEV_COMPOSE) ps
.PHONY: prod-infra
prod-infra: ## 啟動 prod 資料服務Mongo / Redis讀 deploy/.env.prod
@test -f $(DEPLOY_DIR)/.env.prod || cp $(DEPLOY_DIR)/.env.prod.example $(DEPLOY_DIR)/.env.prod
@echo ">>> 請先編輯 $(DEPLOY_DIR)/.env.prod 再執行後續指令" >&2
$(INFRA_PROD_COMPOSE) up -d
$(INFRA_PROD_COMPOSE) ps
.PHONY: prod-infra-down
prod-infra-down: ## 關閉 prod 資料服務(保留資料 volume
$(INFRA_PROD_COMPOSE) down
.PHONY: prod-infra-recreate
prod-infra-recreate: ## 清除 prod 資料 volume 並重啟Mongo / Redis 全新狀態)
$(INFRA_PROD_COMPOSE) down -v
$(INFRA_PROD_COMPOSE) up -d
$(INFRA_PROD_COMPOSE) ps
.PHONY: prod-install
prod-install: ## 一鍵部署 prod複製 .env.prod、build 本地 image、啟動 app、init需先啟動 infra
@test -f $(DEPLOY_DIR)/.env.prod || cp $(DEPLOY_DIR)/.env.prod.example $(DEPLOY_DIR)/.env.prod
@echo ">>> 請先編輯 $(DEPLOY_DIR)/.env.prod 再執行後續指令" >&2
@echo ">>> 請確認 infra 已啟動make prod-infra" >&2
$(DEPLOY_COMPOSE) build
$(DEPLOY_COMPOSE) up -d
$(DEPLOY_COMPOSE) --profile init run --rm init
$(DEPLOY_COMPOSE) ps
.PHONY: prod-rebuild
prod-rebuild: ## 重新 build 本地 image 並重建 prod app stackinfra 不影響)
$(DEPLOY_COMPOSE) build
$(DEPLOY_COMPOSE) up -d
$(DEPLOY_COMPOSE) ps
.PHONY: prod-stop
prod-stop: ## 關閉 prod app stack保留 infra 與資料)
$(DEPLOY_COMPOSE) down
.PHONY: prod-stop-all
prod-stop-all: ## 關閉 prod app + infra保留資料 volume
$(DEPLOY_COMPOSE) down
$(INFRA_PROD_COMPOSE) down
.PHONY: prod-recreate
prod-recreate: ## 完整清除 prod 全部資料 volume + 重建啟動app + infra + init
$(DEPLOY_COMPOSE) down -v
$(INFRA_PROD_COMPOSE) down -v
$(INFRA_PROD_COMPOSE) up -d
$(DEPLOY_COMPOSE) up -d
$(DEPLOY_COMPOSE) --profile init run --rm init
$(DEPLOY_COMPOSE) ps

View File

@ -1,60 +0,0 @@
# 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 # 本機開發,監聽 0.0.0.0:5173區網可連
npm run build # 型別檢查 + 生產建置
```
## 部署dev 一鍵清乾淨重裝)
見 [`deploy/README.md`](./deploy/README.md)。
```bash
cp deploy/.env.example deploy/.env.dev # 首次:填密鑰
./deploy/scripts/redeploy.sh --yes --kill-orphan
# 可選 nginx./deploy/scripts/install-nginx.sh
```
產品任務進度:`docs/product/haixun-console/tasks/INDEX.md`(目前從 T001 起)。
## 開發流程
```text
/spec-driven haixun-console
# 或依 tasks 逐題實作,例如「做 T002」
```
查舊行為請到 `old/backend/`(只讀)。

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +0,0 @@
/bin/
*.exe
*.test
coverage.out
.data/
uploads/

View File

@ -1,7 +0,0 @@
# 遷入說明(精簡)
- 骨架參考 `stand-alone-service`go-zero module 分層、lib/mongo 精神)。
- **不**整包 rsync 電商與 `code.30cm.net` 私有依賴。
- API`generate/api` → goctl。
- DB`generate/database/mongo` → golang-migrate。
- 執行體:`gateway` + `cmd/worker` only。

View File

@ -1,83 +0,0 @@
# 只保留會重複打的指令;其餘直接 go / migrate。
.PHONY: build test test-integration gen-api tidy migrate-up migrate-down init seeder \
start stop restart crawler-install crawler crawler-start crawler-stop crawler-restart
RUNTIME_DIR ?= /tmp/haixun
build:
go build -o bin/gateway .
go build -o bin/worker ./cmd/worker
go build -o bin/init ./cmd/init
go build -o bin/seeder ./cmd/seeder
test:
go test ./...
# M1M5 integration gates
test-integration:
go test ./internal/module/member/usecase/ ./internal/middleware/ \
./internal/module/threads/... ./internal/module/job/... ./internal/module/appnotif/... \
./internal/module/usage/... ./internal/module/ai/... ./internal/module/search/... \
./internal/module/permission/... ./internal/module/studio/... \
./internal/module/inspire/... ./internal/module/scout/... \
./internal/logic/extension/ ./internal/logic/media/ \
-count=1 -timeout 180s
# 改 generate/api/*.api 後執行handler 勿手寫
gen-api:
goctl api go -api generate/api/gateway.api -dir . -style go_zero --home generate/goctl
tidy:
go mod tidy
# DB檔案在 generate/database/mongo需本機已裝 migratemongodb tag
# 例MONGO_URL=mongodb://127.0.0.1:27017/haixun
MONGO_URL ?= mongodb://127.0.0.1:27017/haixun
migrate-up:
migrate -path generate/database/mongo -database "$(MONGO_URL)" up
migrate-down:
migrate -path generate/database/mongo -database "$(MONGO_URL)" down 1
init:
go run ./cmd/init -f etc/gateway.yaml
seeder:
go run ./cmd/seeder -f etc/gateway.yaml
crawler-install:
cd crawler && npm install && npx playwright install --with-deps chromium
# Requires SCOUT_CRAWLER_TOKEN equal to Scout.CrawlerToken in gateway.yaml.
crawler:
cd crawler && npm run start
# Local service lifecycle. Logs and PID files stay outside the worktree.
start: build
@mkdir -p "$(RUNTIME_DIR)"
@nohup "$(CURDIR)/bin/gateway" -f "$(CURDIR)/etc/gateway.yaml" >"$(RUNTIME_DIR)/gateway.log" 2>&1 & printf '%s\n' $$! >"$(RUNTIME_DIR)/gateway.pid"
@nohup "$(CURDIR)/bin/worker" -f "$(CURDIR)/etc/gateway.yaml" >"$(RUNTIME_DIR)/worker.log" 2>&1 & printf '%s\n' $$! >"$(RUNTIME_DIR)/worker.pid"
stop:
@for service in gateway worker; do \
pidfile="$(RUNTIME_DIR)/$$service.pid"; \
if [ -f "$$pidfile" ] && kill -0 "$$(cat "$$pidfile")" 2>/dev/null; then kill "$$(cat "$$pidfile")"; fi; \
rm -f "$$pidfile"; \
done
restart: stop start
# The crawler token must come from the private runtime environment, never this file.
crawler-start:
@test -n "$(SCOUT_CRAWLER_TOKEN)" || (printf '%s\n' 'SCOUT_CRAWLER_TOKEN is required' >&2; exit 1)
@mkdir -p "$(RUNTIME_DIR)"
@cd crawler && SCOUT_CRAWLER_TOKEN="$(SCOUT_CRAWLER_TOKEN)" nohup npm run start >"$(RUNTIME_DIR)/crawler.log" 2>&1 & printf '%s\n' $$! >"$(RUNTIME_DIR)/crawler.pid"
crawler-stop:
@pidfile="$(RUNTIME_DIR)/crawler.pid"; \
if [ -f "$$pidfile" ] && kill -0 "$$(cat "$$pidfile")" 2>/dev/null; then kill "$$(cat "$$pidfile")"; fi; \
rm -f "$$pidfile"
crawler-restart: crawler-stop crawler-start

View File

@ -1,213 +0,0 @@
# apps/backend
Harbor Desk Phase Cgo-zero
## 原則
| 做 | 不做 |
|----|------|
| `generate/api` + `make gen-api` | 手寫 handlerroutes |
| `generate/database/mongo` + `make migrate-up` | 在 gateway 啟動時隱式建 indexseed |
| `monc` + `CacheRedis` | 自己半套 mongo cache |
| `internal/logic` + `module/*/domain|repository|usecase` | 業務塞進 handler |
## 指令
```bash
cd apps/backend
make build # gateway + worker
make start # build 後背景啟動 gateway + workerPID/log 在 /tmp/haixun
make stop
make restart
make test
make gen-api # 改 .api 後
make migrate-up # 改 generate/database 後
make init # 只建立可重複執行的 operational indexes
make seeder # 讀 etc/gateway.yaml 的 Seed.AdminPassword
make crawler-install # 安裝 dev_mode Chrome crawler 的 Playwright/Chromium
SCOUT_CRAWLER_TOKEN='...' make crawler-start
SCOUT_CRAWLER_TOKEN='...' make crawler-restart
make tidy
```
### Scout Chrome Crawler
`dev_mode=true`Scout 只走私有 Playwright crawler沒有同步有效 Chrome session 就會要求使用者先同步。
1. 在私有 `etc/gateway.yaml``Scout.SessionSecret`、`Scout.CrawlerEndpoint`、`Scout.CrawlerToken`。
2. 執行 `make crawler-install` 一次。
3. 執行 `SCOUT_CRAWLER_TOKEN='與 Scout.CrawlerToken 相同的值' make crawler-start`
4. crawler 必須維持 `127.0.0.1` 私網綁定,禁止公開反向代理。
詳見 [`crawler/README.md`](crawler/README.md)。
```bash
# 需要 Mongo + Redisgo-zero monc / redis
# 私有設定只放在 etc/gateway.yaml此檔不會被 git 追蹤):
# cp etc/gateway.example.yaml etc/gateway.yaml
# 填入 Mongo/Redis/MinIO 密碼與兩組不同的 JWT secret。
# Mongo 若有帳密(本機 infra 常見),一定要帶進 URI
MONGO_URL='mongodb://USER:PASS@127.0.0.1:27017/haixun?authSource=admin' make migrate-up
go run . -f etc/gateway.yaml
# Seed.AdminPassword 填好後執行 make seederuid=1000000 → 顯示 01000000
```
### 日誌與密碼(安全)
| 禁止出現在 log | 說明 |
|----------------|------|
| `password` / `new_password` / `current_password` / `temporary_password` | 明碼 |
| `password_hash` | bcrypt 也不可打 log |
| access/refresh token、api_key | 憑證 |
實作:
- 啟動時 `securelog.SilenceMongo()` → 關閉 go-zero **mon** 文件 dump否則 ReplaceOne 會印出整份 member
- `RestConf.Verbose = false` → 不印 request body
- 自寫 log 請用 `securelog.RedactAny(v)` / 勿 `logx.Infof("%+v", req)`
HTTP access log 只記 method/path/status/duration不含 body。
### 物件儲存(頭像)— **僅 MinIO/S3**
不落本機磁碟(無 localstore / `/files`)。未設定 Endpoint 時 upload 直接失敗。
| 項目 | 說明 |
|------|------|
| API | `POST /api/v1/media/upload`(需 JWT`{ content: dataURL, kind: "avatar" }` |
| 回傳 | `{ url, key }` — Mongo member **只存 url** |
| 後端 | MinIOdev或 S3prodS3 API + path-style |
| env | `HAIXUN_STORAGE_S3_*``MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` |
```yaml
ObjectStorage:
Endpoint: "http://127.0.0.1:9000" # 必填
Bucket: "haixun-assets"
AccessKey: "haixun"
SecretKey: "..."
UsePathStyle: true
PublicBaseURL: "" # 或 CDN / 反向代理公開前綴
```
FE選頭像 → upload → URL → `PATCH profile`(禁止 data URL
### 密碼政策(前後端一致)
| 規則 | 說明 |
|------|------|
| 長度 | **≥ 12** 字元 |
| 組成 | **大寫 + 小寫 + 數字 + 符號** 皆須至少一個 |
| 後端 | `ValidatePasswordPolicy` → 不符回 **400003** + `domain.PasswordPolicyEN` |
| 前端 | `passwordPolicy.ts` + 語言包 `password.policy.hint` / `api.err.400003` |
**同一句說明:**
- EN: `Password must be at least 12 characters with upper, lower, digit, and symbol`
- ZH: `密碼須至少 12 碼,且含大寫、小寫、數字與符號`
前端依 code 400003 顯示語系文案placeholderhint錯誤都用同一 key勿另寫一句。
### 寄信(信箱驗證 / 重設密碼)
業務在 `internal/logic/auth/*`:產碼 → `Notification.RenderVerify``SendEmail`
寄送實作在 `internal/module/notification`SMTPMailgun 相容)。
#### 模板與多語
- 引擎與 stand-alone 相同:**hermes**`github.com/matcornic/hermes/v2`
- 語系:`zh-TW`(預設)/`en`;來源為會員 `preferred_language`,忘記密碼則用請求 `language`FE 當前 locale
- Logo`Brand.LogoURL` 或預設 `{PublicWebBase}/brand-mark.jpg``apps/web/public/brand-mark.jpg`
```yaml
PublicWebBase: "http://127.0.0.1:5173"
Brand:
Name: "Harbor Desk"
LogoURL: "" # 空 = PublicWebBase/brand-mark.jpg
Copyright: "© Harbor Desk"
```
#### 本機Mailpit推薦
```bash
# 已含在 old/infra docker-composemongo/redis/mailpit
cd old/infra && docker compose --env-file ../deploy/.env.dev up -d mailpit
# UI 看信
open http://127.0.0.1:8025
```
| 項目 | 值 |
|------|-----|
| SMTP | `127.0.0.1:1025`(無密碼也可;可開 AUTH accept-any 模擬 Mailgun |
| Web UI | http://127.0.0.1:8025 |
| gateway 預設 | `Mail.SMTP.Host=127.0.0.1` `Port=1025` |
呼叫 `POST /api/v1/auth/email/send-code``password/request-reset` 後,到 Mailpit UI 看 HTML 信。
#### 正式Mailgun SMTP
```yaml
Mail:
Sender: "Harbor Desk <noreply@your-domain.com>"
SMTP:
Host: "smtp.mailgun.org"
Port: 587
User: "postmaster@YOUR_DOMAIN"
Password: "<Mailgun SMTP password>"
DevExposeCode: false
```
同一套 `Mail.SMTP` 設定;本機指 Mailpit、上線指 Mailgun**code 不用改**。
env 覆寫:`MAIL_SMTP_*` 或 `HAIXUN_SMTP_*`(與 `old/deploy/.env.dev` 相容)。
| SMTP Host | 行為 |
|-----------|------|
| **空** | log-only不真寄 |
| **Mailpit / Mailgun** | 真走 SMTP 投遞 |
### 前後端串不起來?檢查這幾項
1. **gateway 有在跑** `ss -tlnp | grep 8888`;沒有就 `go run . -f etc/gateway.yaml`
2. **Mongo URI 有帳密**`export MONGO_URI=...`;沒帶會查不到 seed
3. **Redis 有密碼**`export REDIS_PASSWORD=...`;沒帶 monc 回 `NOAUTH` → API 500
4. **已跑 migrate**`make migrate-up`seed `admin@haixun.local`
5. **FE 切到 live**:設定 → 資料來源 → Live預設是 **mock**,根本不會打後端)
6. **API base**`apps/web/.env` 建議 `VITE_API_BASE=`(空),走 Vite proxy `/api``:8888`
遠端開 `*.30cm.net` 時**不要**寫死 `http://127.0.0.1:8888`(那會打到使用者自己電腦)
7. 切 live 後**重新登入** seed`admin@haixun.local` / `admin123`
| 資料 | 存哪 | 關機 |
|------|------|------|
| 會員 / settings | **Mongo**monc | 保留 |
| refresh token / 驗證碼 | **Mongo**TTL index | 保留(過期自動清) |
| monc 實體快取 | RedisCacheRedis | 可丟,會從 Mongo 回填 |
## 目錄
```text
generate/api/ # goctl 契約
generate/database/mongo # golang-migrate JSON
generate/goctl/ # handler 模板102000
cmd/worker/ # 獨立 worker
internal/handler|logic # goctl 產 / 薄邏輯
internal/module/ # domain · repository · usecase
internal/lib/mongo # monc URI 小工具
etc/gateway.yaml # 唯一設定
```
## 設定摘要
```yaml
CacheRedis: # monc Redis 快取
- Host: 127.0.0.1:6379
Type: node
Mongo:
URI: "mongodb://127.0.0.1:27017" # 有 auth 用 MONGO_URI 覆寫
Database: "haixun"
```
### 會員 uid
- 從 **1_000_000**seed admin = `1000000`
- 顯示補成 **8 位**`01000000``%08d` / FE `padStart(8,"0")`
- 新註冊:`1000001`, `1000002`, …

View File

@ -1,56 +0,0 @@
package main
import (
"context"
"flag"
"fmt"
"time"
"apps/backend/internal/config"
"github.com/zeromicro/go-zero/core/conf"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
// init creates idempotent operational indexes only. It intentionally never
// inserts members or other business records.
func main() {
configFile := flag.String("f", "etc/gateway.yaml", "config file")
flag.Parse()
var c config.Config
conf.MustLoad(*configFile, &c)
c.ApplyEnv()
if c.Mongo.URI == "" || c.Mongo.Database == "" {
panic("Mongo.URI and Mongo.Database are required")
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
client, err := mongo.Connect(ctx, options.Client().ApplyURI(c.Mongo.URI))
if err != nil {
panic(err)
}
defer client.Disconnect(context.Background())
db := client.Database(c.Mongo.Database)
indexes := map[string][]mongo.IndexModel{
"jobs": {
{Keys: bson.D{{Key: "status", Value: 1}, {Key: "run_after", Value: 1}, {Key: "created_at", Value: 1}}, Options: options.Index().SetName("claim_due_jobs")},
{Keys: bson.D{{Key: "status", Value: 1}, {Key: "completed_at", Value: 1}}, Options: options.Index().SetName("purge_terminal_jobs")},
},
"studio_outbox": {
{Keys: bson.D{{Key: "status", Value: 1}, {Key: "updated_at", Value: 1}}, Options: options.Index().SetName("worker_outbox_status_updated")},
{Keys: bson.D{{Key: "steps.status", Value: 1}, {Key: "steps.scheduled_at", Value: 1}, {Key: "steps.lease_expires_at", Value: 1}}, Options: options.Index().SetName("claim_due_outbox_steps")},
{Keys: bson.D{{Key: "owner_uid", Value: 1}, {Key: "updated_at", Value: -1}}, Options: options.Index().SetName("owner_outbox_updated")},
},
}
for collection, models := range indexes {
if _, err := db.Collection(collection).Indexes().CreateMany(ctx, models); err != nil {
panic(fmt.Errorf("create %s indexes: %w", collection, err))
}
}
fmt.Println("database indexes initialized")
}

View File

@ -1,70 +0,0 @@
package main
import (
"context"
"flag"
"fmt"
"strings"
"time"
"apps/backend/internal/config"
"github.com/zeromicro/go-zero/core/conf"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"golang.org/x/crypto/bcrypt"
)
// seeder is intentionally explicit: a deployment must provide its own admin
// password rather than receiving a committed default credential.
func main() {
configFile := flag.String("f", "etc/gateway.yaml", "config file")
flag.Parse()
var c config.Config
conf.MustLoad(*configFile, &c)
c.ApplyEnv()
password := c.Seed.AdminPassword
if len(password) < 12 || strings.Contains(password, "REPLACE_WITH") {
panic("Seed.AdminPassword in gateway.yaml must be at least 12 characters")
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
client, err := mongo.Connect(ctx, options.Client().ApplyURI(c.Mongo.URI))
if err != nil {
panic(err)
}
defer client.Disconnect(context.Background())
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
panic(err)
}
now := time.Now().UTC().UnixNano()
admin := bson.M{
"uid": int64(1_000_000),
"tenant_id": "default",
"email": "admin@haixun.local",
"display_name": "Harbor Admin",
"roles": []string{"admin", "member"},
"status": "active",
"email_verified": true,
"email_verified_at": now,
"invite_code": "SEEDADMIN",
"password_hash": string(hash),
"timezone": "Asia/Taipei",
"created_at": now,
"updated_at": now,
}
db := client.Database(c.Mongo.Database)
_, err = db.Collection("members").UpdateOne(ctx, bson.M{"uid": admin["uid"]}, bson.M{"$setOnInsert": admin}, options.Update().SetUpsert(true))
if err != nil {
panic(err)
}
_, err = db.Collection("counters").UpdateOne(ctx, bson.M{"_id": "member_uid"}, bson.M{"$setOnInsert": bson.M{"seq": int64(1_000_000)}}, options.Update().SetUpsert(true))
if err != nil {
panic(err)
}
fmt.Printf("seeded admin %s\n", strings.TrimSpace(admin["email"].(string)))
}

View File

@ -1,481 +0,0 @@
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"
"apps/backend/internal/config"
redislock "apps/backend/internal/lib/redislock"
"apps/backend/internal/lib/securelog"
"apps/backend/internal/module/ai"
appnotifRepo "apps/backend/internal/module/appnotif/repository"
appnotifUC "apps/backend/internal/module/appnotif/usecase"
fsDomain "apps/backend/internal/module/filestorage/domain"
"apps/backend/internal/module/filestorage/noop"
"apps/backend/internal/module/filestorage/s3store"
jobDomain "apps/backend/internal/module/job/domain"
jobRepo "apps/backend/internal/module/job/repository"
jobUC "apps/backend/internal/module/job/usecase"
memberDomain "apps/backend/internal/module/member/domain"
memberRepo "apps/backend/internal/module/member/repository"
scoutDomain "apps/backend/internal/module/scout/domain"
scoutRepo "apps/backend/internal/module/scout/repository"
scoutUC "apps/backend/internal/module/scout/usecase"
studioPublish "apps/backend/internal/module/studio/publish"
studioRepo "apps/backend/internal/module/studio/repository"
studioUC "apps/backend/internal/module/studio/usecase"
threadsDomain "apps/backend/internal/module/threads/domain"
threadsProv "apps/backend/internal/module/threads/provider"
threadsRepo "apps/backend/internal/module/threads/repository"
threadsUC "apps/backend/internal/module/threads/usecase"
usageDomain "apps/backend/internal/module/usage/domain"
usageRepo "apps/backend/internal/module/usage/repository"
usageUC "apps/backend/internal/module/usage/usecase"
"github.com/zeromicro/go-zero/core/conf"
"github.com/zeromicro/go-zero/core/logx"
"github.com/zeromicro/go-zero/core/stores/redis"
)
// Workerdemo + threads_token_renew + persona_analyze_* + outbox publish
//
// cd apps/backend && go build -o bin/worker ./cmd/worker
// ./bin/worker -f etc/gateway.yaml
func main() {
securelog.SilenceMongo()
configFile := flag.String("f", "etc/gateway.yaml", "config file")
flag.Parse()
var c config.Config
conf.MustLoad(*configFile, &c)
c.ApplyEnv()
if err := c.ValidateProductionDependencies(); err != nil {
logx.Must(err)
}
redisClient := redis.MustNewRedis(c.CacheRedis[0].RedisConf)
pingCtx, cancelPing := context.WithTimeout(context.Background(), 3*time.Second)
redisReady := redisClient.PingCtx(pingCtx)
cancelPing()
if !redisReady {
logx.Must(fmt.Errorf("redis startup ping failed"))
}
workerID := c.Worker.ID
if workerID == "" || workerID == "worker-1" || workerID == "worker-local-1" || workerID == "demo-worker-1" {
host, _ := os.Hostname()
if host == "" {
host = "worker"
}
workerID = fmt.Sprintf("%s-%d", host, os.Getpid())
}
interval := time.Duration(c.Worker.PollIntervalMs) * time.Millisecond
if interval <= 0 {
interval = 2 * time.Second
}
appN := appnotifUC.New(appnotifRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database))
jobs := jobUC.New(jobRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database))
jobs.Notifier = appN
var provider threadsDomain.Provider
if strings.TrimSpace(c.Platform.ThreadsAppId) != "" && strings.TrimSpace(c.Platform.ThreadsAppSecret) != "" {
provider = threadsProv.NewMeta(c.Platform.ThreadsAppId, c.Platform.ThreadsAppSecret)
logx.Info("worker threads: meta provider")
} else {
provider = threadsProv.NewUnavailable("set THREADS_APP_ID and THREADS_APP_SECRET")
logx.Info("worker threads: Threads operations disabled until credentials are configured")
}
threadsSvc := threadsUC.New(threadsRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database), provider, c.Auth.AccessSecret)
threadsSvc.Renew = jobs
// usage + AI keys與 gateway 對齊persona LLM 需真 key
settingsCodec, err := c.NewMemberSettingsCodec()
if err != nil {
logx.Must(err)
}
members := memberRepo.NewMoncStore(c.Mongo.URI, c.Mongo.Database, c.CacheRedis, c.CacheRedis[0].RedisConf, c.Redis.Namespace, settingsCodec)
keyRes := &usageUC.SettingsResolver{
Members: members,
PlatformAI: c.Platform.AIKey,
PlatformXAI: c.Platform.XAIKey,
PlatformOpenCode: c.Platform.OpenCodeKey,
PlatformExa: c.Platform.ExaKey,
}
usageSvc := usageUC.New(usageRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database), keyRes)
usageSvc.Gate = usageUC.NewRedisPlatformGate(c.CacheRedis[0].RedisConf, 120, 60, c.Redis.Namespace)
modelsCache := ai.NewRedisModelsCache(c.CacheRedis[0].RedisConf, c.Redis.Namespace)
aiRegistry := ai.NewRegistry(modelsCache, c.Redis.AIModelCacheFingerprintSecret)
var pub studioPublish.Transport
if strings.TrimSpace(c.Platform.ThreadsAppId) != "" && strings.TrimSpace(c.Platform.ThreadsAppSecret) != "" {
pub = studioPublish.NewMeta("https://graph.threads.net")
logx.Info("worker studio: meta publish transport (real Threads)")
} else {
pub = studioPublish.NewUnavailable("set THREADS_APP_ID and THREADS_APP_SECRET")
}
studio := studioUC.New(studioRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database), pub)
studio.OutboxWorkerID = workerID
studio.Accounts = threadsSvc
studio.Usage = usageSvc
studio.AI = &ai.FakeClient{}
studio.AIRegistry = aiRegistry
studio.Keys = &workerAIKeys{Members: members, Resolver: keyRes}
// 發文暫存圖Meta 發成功後刪 temp/*
store := workerStorage(c)
studio.Storage = store
studio.StoragePublicBase = strings.TrimSpace(c.ObjectStorage.PublicBaseURL)
scoutSvc := scoutUC.New(scoutRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database))
scoutSvc.Settings = &workerDevMode{Members: members}
scoutSvc.Provider = scoutUC.NewExaThreadsProvider(c.Platform.ExaKey)
scoutSvc.SessionSecret = c.Scout.SessionSecret
scoutSvc.Crawler = scoutUC.NewHTTPCrawlerProvider(c.Scout.CrawlerEndpoint, c.Scout.CrawlerToken)
studio.Crawler = scoutSvc
// worker 執行 job 本體,不經 API 再入列
studio.Jobs = nil
logx.Infof("haixun worker started id=%s interval=%s (demo + token_renew + persona_analyze + compose_mimic + outbox)", workerID, interval)
fmt.Printf("worker running id=%s interval=%s (Ctrl+C to stop)\n", workerID, interval)
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
tick := time.NewTicker(interval)
defer tick.Stop()
ctx := context.Background()
const outboxLockTTL = 3 * time.Minute
outboxLockKey := strings.Trim(c.Redis.Namespace, ":") + ":worker:outbox"
outboxLock := redislock.NewWithClient(redisClient, outboxLockKey, workerID, outboxLockTTL)
for {
select {
case <-sig:
logx.Infof("worker %s shutting down", workerID)
return
case <-tick.C:
// 1) claim one due job
j, err := jobs.ClaimNext(ctx, workerID)
if err == nil {
switch j.TemplateType {
case "", jobDomain.TemplateDemo:
logx.Infof("worker %s demo job %s", workerID, j.ID)
if _, err := jobs.RunDemoToSuccess(ctx, j.ID); err != nil {
logx.Errorf("worker %s job %s failed: %v", workerID, j.ID, err)
_, _ = jobs.FailJob(ctx, j.ID, err.Error())
} else {
logx.Infof("worker %s job %s succeeded", workerID, j.ID)
}
case jobDomain.TemplateThreadsTokenRenew:
logx.Infof("worker %s token renew job %s account=%s", workerID, j.ID, j.RefID)
if err := runTokenRenew(ctx, jobs, threadsSvc, j); err != nil {
logx.Errorf("worker %s renew %s failed: %v", workerID, j.ID, err)
_, _ = jobs.FailJob(ctx, j.ID, err.Error())
} else {
logx.Infof("worker %s renew %s ok", workerID, j.ID)
}
case jobDomain.TemplatePersonaAnalyzeAccount, jobDomain.TemplatePersonaAnalyzeText:
logx.Infof("worker %s persona analyze job %s type=%s persona=%s", workerID, j.ID, j.TemplateType, j.RefID)
if err := runPersonaAnalyze(ctx, jobs, studio, j); err != nil {
logx.Errorf("worker %s persona %s failed: %v", workerID, j.ID, err)
_, _ = jobs.FailJob(ctx, j.ID, err.Error())
} else {
logx.Infof("worker %s persona %s ok", workerID, j.ID)
}
case jobDomain.TemplateComposeMimic:
logx.Infof("worker %s compose_mimic job %s", workerID, j.ID)
if err := runComposeMimic(ctx, jobs, studio, j); err != nil {
logx.Errorf("worker %s compose_mimic %s failed: %v", workerID, j.ID, err)
_, _ = jobs.FailJob(ctx, j.ID, err.Error())
} else {
logx.Infof("worker %s compose_mimic %s ok", workerID, j.ID)
}
case jobDomain.TemplatePlayGenerateScript:
logx.Infof("worker %s play_generate_script job %s play=%s", workerID, j.ID, j.RefID)
if err := runPlayGenerateScript(ctx, jobs, studio, j); err != nil {
logx.Errorf("worker %s play_generate_script %s failed: %v", workerID, j.ID, err)
_, _ = jobs.FailJob(ctx, j.ID, err.Error())
} else {
logx.Infof("worker %s play_generate_script %s ok", workerID, j.ID)
}
case jobDomain.TemplateScoutScan:
if err := runScoutScan(ctx, jobs, scoutSvc, j); err != nil {
logx.Errorf("worker %s scout scan %s failed: %v", workerID, j.ID, err)
_, _ = jobs.FailJob(ctx, j.ID, err.Error())
}
default:
logx.Infof("worker %s unknown template %s job %s — fail", workerID, j.TemplateType, j.ID)
_, _ = jobs.FailJob(ctx, j.ID, "unknown template: "+j.TemplateType)
}
}
// 2) One worker owns an outbox tick and renews its lease while claims run.
processOutbox(ctx, studio, outboxLock, workerID, outboxLockTTL)
// 3) 終態任務超過 2 天自動清除
if purged, err := jobs.PurgeExpiredTerminal(ctx); err != nil {
logx.Errorf("worker %s purge jobs: %v", workerID, err)
} else if purged > 0 {
logx.Infof("worker %s purged %d expired terminal job(s)", workerID, purged)
}
}
}
}
func processOutbox(ctx context.Context, studio *studioUC.Service, lock *redislock.Lock, workerID string, ttl time.Duration) {
locked, err := lock.Acquire(ctx)
if err != nil {
logx.Errorf("worker %s outbox lock acquire: %v", workerID, err)
return
}
if !locked {
return
}
runCtx, cancel := context.WithCancel(ctx)
renewFailure := make(chan error, 1)
renewStopped := make(chan struct{})
go func() {
defer close(renewStopped)
ticker := time.NewTicker(ttl / 3)
defer ticker.Stop()
for {
select {
case <-runCtx.Done():
return
case <-ticker.C:
renewed, renewErr := lock.Renew(runCtx)
if renewErr != nil {
renewFailure <- fmt.Errorf("renew: %w", renewErr)
cancel()
return
}
if !renewed {
renewFailure <- errors.New("lease ownership lost")
cancel()
return
}
}
}
}()
defer func() {
cancel()
<-renewStopped
releaseCtx, releaseCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer releaseCancel()
if err := lock.Release(releaseCtx); err != nil {
logx.Errorf("worker %s outbox lock release: %v", workerID, err)
}
}()
n, processErr := studio.ProcessDueSteps(runCtx, 0)
select {
case renewErr := <-renewFailure:
logx.Errorf("worker %s outbox lock renewal failed; claims canceled: %v", workerID, renewErr)
return
default:
}
if processErr != nil {
logx.Errorf("worker %s outbox tick: %v", workerID, processErr)
} else if n > 0 {
logx.Infof("worker %s outbox published %d step(s)", workerID, n)
}
}
func runScoutScan(ctx context.Context, jobs *jobUC.Service, scout *scoutUC.Service, j *jobDomain.Job) error {
var brief scoutDomain.RunBrief
if err := json.Unmarshal([]byte(j.Payload), &brief); err != nil {
return fmt.Errorf("invalid scout scan payload: %w", err)
}
if _, err := jobs.MarkRunningProgress(ctx, j.ID, 15, "海巡 · 準備搜尋來源"); err != nil {
return err
}
posts, err := scout.RunScanFromBrief(ctx, j.OwnerUID, &brief)
if err != nil {
return err
}
if _, err := jobs.MarkRunningProgress(ctx, j.ID, 90, fmt.Sprintf("海巡 · 已寫入 %d 筆候選", len(posts))); err != nil {
return err
}
_, err = jobs.SucceedJob(ctx, j.ID, fmt.Sprintf("海巡完成 · 命中 %d 筆", len(posts)))
return err
}
func runTokenRenew(ctx context.Context, jobs *jobUC.Service, threads *threadsUC.Service, j *jobDomain.Job) error {
if j.RefID == "" {
return fmt.Errorf("缺少帳號 ref_id")
}
_, _ = jobs.MarkRunningProgress(ctx, j.ID, 15, "定期延長 Threads token · 準備中")
_, _ = jobs.MarkRunningProgress(ctx, j.ID, 40, "定期延長 Threads token · 向平台刷新中")
acc, err := threads.Refresh(ctx, j.OwnerUID, j.RefID)
if err != nil {
return err
}
_, _ = jobs.MarkRunningProgress(ctx, j.ID, 75, "定期延長 Threads token · 排程下一次")
// 下次再約 30 天
next := jobDomain.NowNano() + jobDomain.DefaultTokenRenewDelayNs
if acc != nil && acc.SessionExpiresAt > 0 {
early := acc.SessionExpiresAt - int64(30*24*time.Hour)
if early > jobDomain.NowNano() {
next = early
}
}
_ = jobs.ScheduleTokenRenew(ctx, j.OwnerUID, j.RefID, next)
_, _ = jobs.MarkRunningProgress(ctx, j.ID, 90, "定期延長 Threads token · 寫入完成")
_, err = jobs.SucceedJob(ctx, j.ID, "定期延長完成 · 已排程約 30 天後再執行")
return err
}
func runPersonaAnalyze(ctx context.Context, jobs *jobUC.Service, studio *studioUC.Service, j *jobDomain.Job) error {
if j.RefID == "" {
return fmt.Errorf("缺少人設 ref_id")
}
startSum := "人設分析 · 文字分析中…"
if j.TemplateType == jobDomain.TemplatePersonaAnalyzeAccount {
startSum = "人設分析 · 爬取公開貼文 + AI 分析中…(可離開頁面)"
}
_, _ = jobs.MarkRunningProgress(ctx, j.ID, 10, startSum)
err := studio.ExecutePersonaAnalyzeJob(ctx, j.TemplateType, j.OwnerUID, j.RefID, j.Payload, func(pct int, sum string) {
if pct < 10 {
pct = 10
}
if pct > 95 {
pct = 95
}
_, _ = jobs.MarkRunningProgress(ctx, j.ID, pct, sum)
})
if err != nil {
return err
}
_, err = jobs.SucceedJob(ctx, j.ID, "人設分析完成 · 已寫入指紋/範本")
return err
}
func runPlayGenerateScript(ctx context.Context, jobs *jobUC.Service, studio *studioUC.Service, j *jobDomain.Job) error {
var pl jobUC.PlayGenerateScriptPayload
if err := json.Unmarshal([]byte(j.Payload), &pl); err != nil {
return fmt.Errorf("invalid play_generate_script payload: %w", err)
}
playID := strings.TrimSpace(pl.PlayID)
if playID == "" {
playID = strings.TrimSpace(j.RefID)
}
if playID == "" {
return fmt.Errorf("empty play_id")
}
_, _ = jobs.MarkRunningProgress(ctx, j.ID, 15, "劇本產文 · 準備 AI一次產全部…")
llmCtx, cancel := context.WithTimeout(ctx, 4*time.Minute)
defer cancel()
_, _ = jobs.MarkRunningProgress(ctx, j.ID, 40, "劇本產文 · 呼叫模型中(可離開頁面)…")
n, err := studio.GeneratePlayScript(llmCtx, j.OwnerUID, playID, pl.OnlyEmpty)
if err != nil {
return err
}
pl.Filled = n
body, _ := json.Marshal(pl)
_, _ = jobs.MarkRunningProgress(ctx, j.ID, 90, fmt.Sprintf("劇本產文 · 已寫入 %d 步…", n))
_, err = jobs.SucceedJobWithPayload(ctx, j.ID, fmt.Sprintf("劇本產文完成 · 已填 %d 步,請回互回方案查看", n), string(body))
return err
}
func runComposeMimic(ctx context.Context, jobs *jobUC.Service, studio *studioUC.Service, j *jobDomain.Job) error {
var pl jobUC.ComposeMimicPayload
if err := json.Unmarshal([]byte(j.Payload), &pl); err != nil {
return fmt.Errorf("invalid compose_mimic payload: %w", err)
}
if strings.TrimSpace(pl.SourceText) == "" {
return fmt.Errorf("empty source_text")
}
_, _ = jobs.MarkRunningProgress(ctx, j.ID, 15, "仿寫貼文 · 準備 AI…")
// worker 不綁 gateway 120s給模型較長時間reasoning 模型需要)
llmCtx, cancel := context.WithTimeout(ctx, 4*time.Minute)
defer cancel()
_, _ = jobs.MarkRunningProgress(ctx, j.ID, 35, "仿寫貼文 · 呼叫模型中(可離開頁面)…")
text, err := studio.Mimic(llmCtx, j.OwnerUID, pl.SourceText, pl.PersonaID, pl.StructureNotes)
if err != nil {
return err
}
_, _ = jobs.MarkRunningProgress(ctx, j.ID, 90, "仿寫貼文 · 寫入結果…")
pl.ResultText = text
body, _ := json.Marshal(pl)
_, err = jobs.SucceedJobWithPayload(ctx, j.ID, "仿寫完成 · 已可套用到正文", string(body))
return err
}
// workerAIKeys — 與 gateway studioAIKeys 對齊
type workerAIKeys struct {
Members memberDomain.Repository
Resolver *usageUC.SettingsResolver
}
func (k *workerAIKeys) ResolveAI(ctx context.Context, ownerUID int64) (provider, model, apiKey string, err error) {
st, err := k.Members.GetSettings(ctx, ownerUID)
if err != nil {
return "", "", "", err
}
if st == nil {
st = memberDomain.DefaultSettings(ownerUID)
}
// 完全依會員 AI 設定provider / model / key不寫死模型
provider = ai.NormalizeProvider(st.Provider)
model = strings.TrimSpace(st.Model)
if model == "" {
return provider, "", "", fmt.Errorf("請到設定選擇 AI 模型")
}
if k.Resolver != nil {
_, key, rerr := k.Resolver.ResolveKey(ctx, ownerUID, usageDomain.MeterAICopy)
if rerr != nil && !errors.Is(rerr, usageDomain.ErrNoKey) {
return provider, model, "", rerr
}
if rerr == nil && strings.TrimSpace(key) != "" {
return provider, model, key, nil
}
}
if key := st.KeyForProvider(provider); key != "" {
return provider, model, key, nil
}
return provider, model, "", fmt.Errorf("no AI key請到設定填寫 Key或確認平台已配置")
}
type workerDevMode struct {
Members memberDomain.Repository
}
func (d *workerDevMode) DevModeEnabled(ctx context.Context, uid int64) (bool, error) {
if d == nil || d.Members == nil {
return false, nil
}
st, err := d.Members.GetSettings(ctx, uid)
if err != nil {
return false, err
}
if st == nil {
return false, nil
}
return st.DevModeEnabled, nil
}
func workerStorage(c config.Config) fsDomain.Storage {
os := c.ObjectStorage
if strings.TrimSpace(os.Endpoint) == "" {
logx.Info("worker storage: disabled (no endpoint)")
return noop.New()
}
s, err := s3store.New(s3store.Config{
Endpoint: os.Endpoint, Region: os.Region, Bucket: os.Bucket,
AccessKey: os.AccessKey, SecretKey: os.SecretKey,
UsePathStyle: os.UsePathStyle, PublicBaseURL: os.PublicBaseURL,
})
if err != nil {
logx.Errorf("worker storage init failed: %v", err)
return noop.New()
}
logx.Infof("worker storage: minio/s3 bucket=%s (ephemeral publish images cleanup enabled)", os.Bucket)
return s
}

View File

@ -1,48 +0,0 @@
# Scout Chrome Crawler
Private Playwright service for Scout `dev_mode=true`. It is not a public API.
It binds to `127.0.0.1` only, requires a bearer token, and receives decrypted
Chrome storage state only from the backend worker/gateway process.
## Install
```bash
cd apps/backend/crawler
npm install
npx playwright install --with-deps chromium
```
## Configure
Set the same private token in the local-only `apps/backend/etc/gateway.yaml`:
```yaml
Scout:
SessionSecret: "a dedicated long random secret"
CrawlerEndpoint: "http://127.0.0.1:8891"
CrawlerToken: "a different long random token"
```
Start the crawler with the matching token:
```bash
cd apps/backend/crawler
SCOUT_CRAWLER_TOKEN='the CrawlerToken value' npm run start
```
Optional: `SCOUT_CRAWLER_PORT=8891` changes the local port.
## Runtime Rules
- Never expose this port through nginx, Docker host publishing, or the public internet.
- Never place `CrawlerToken`, `SessionSecret`, or storage state in a tracked file or log.
- `dev_mode=false` does not use this service; it uses the configured API provider.
- `dev_mode=true` requires a freshly synchronized Chrome session. Missing or expired sessions fail with a user-actionable error.
- The service supports `/v1/threads/search` and `/v1/threads/resolve`; both require `Authorization: Bearer <CrawlerToken>`.
## Operations
1. Start Mongo, Redis, gateway, worker, and this crawler service.
2. In Harbor Desk settings, enable dev mode and synchronize Chrome Threads session.
3. Start a Scout run. The worker chooses crawler mode and contacts only `CrawlerEndpoint`.
4. If Threads redirects to login, sync the session again. No fallback to API occurs in dev mode.

View File

@ -1,13 +0,0 @@
{
"name": "@harbor/scout-crawler",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx src/server.ts",
"start": "tsx src/server.ts"
},
"dependencies": {
"playwright": "^1.58.0",
"tsx": "^4.20.6"
}
}

View File

@ -1,159 +0,0 @@
import { chromium, type Page } from "playwright";
import { createServer } from "node:http";
type SearchRequest = { storage_state?: string; terms?: string[]; limit?: number };
type ResolveRequest = { storage_state?: string; permalink?: string };
type Post = { permalink: string; author: string; text: string };
const port = Number(process.env.SCOUT_CRAWLER_PORT || 8891);
const token = process.env.SCOUT_CRAWLER_TOKEN || "";
function isThreadsURL(value: string): boolean {
try {
const host = new URL(value).hostname.toLowerCase();
return host === "threads.com" || host.endsWith(".threads.com") || host === "threads.net" || host.endsWith(".threads.net");
} catch {
return false;
}
}
async function readPosts(page: Page, limit: number): Promise<Post[]> {
const posts = new Map<string, Post>();
const links = page.locator('a[href*="/post/"]');
const count = Math.min(await links.count(), 50);
for (let i = 0; i < count && posts.size < limit; i++) {
const link = links.nth(i);
const href = await link.getAttribute("href").catch(() => null);
if (!href) continue;
const permalink = href.startsWith("http") ? href : `https://www.threads.com${href}`;
if (!isThreadsURL(permalink)) continue;
const author = href.match(/@([^/]+)\/post/)?.[1] || "";
const scope = link.locator("xpath=ancestor::div[position()<=6]").first();
const text = (await scope.innerText().catch(() => "")).trim();
if (text.length < 5) continue;
posts.set(permalink, { permalink, author, text: text.slice(0, 2000) });
}
return [...posts.values()];
}
async function search(storageState: string, terms: string[], limit: number): Promise<Post[]> {
const state = JSON.parse(storageState) as { cookies?: unknown[] };
if (!Array.isArray(state.cookies) || state.cookies.length === 0) throw new Error("crawler session is invalid");
const browser = await chromium.launch({ headless: true });
try {
const context = await browser.newContext({ storageState: state, locale: "zh-TW", timezoneId: "Asia/Taipei" });
const page = await context.newPage();
const query = terms.filter(Boolean).join(" ").slice(0, 180);
await page.goto(`https://www.threads.com/search?q=${encodeURIComponent(query)}&serp_type=default`, { waitUntil: "domcontentloaded", timeout: 45_000 });
const body = await page.locator("body").innerText().catch(() => "");
if (page.url().includes("/login") || body.includes("登入")) throw new Error("crawler session expired");
await page.waitForSelector('a[href*="/post/"]', { timeout: 12_000 }).catch(() => undefined);
await page.mouse.wheel(0, 900);
await page.waitForTimeout(1000);
const posts = await readPosts(page, limit);
await context.close();
return posts;
} finally {
await browser.close();
}
}
function shortcodeFromPermalink(permalink: string): string {
return permalink.match(/\/post\/([^/?]+)/)?.[1] || "";
}
function findMediaID(value: unknown, shortcode: string): string {
if (!value || typeof value !== "object") return "";
if (Array.isArray(value)) { for (const item of value) { const id = findMediaID(item, shortcode); if (id) return id; } return ""; }
const record = value as Record<string, unknown>;
const code = String(record.code || record.shortcode || "");
const id = String(record.id || record.pk || record.media_id || "");
if (code === shortcode && /^\d{10,}$/.test(id)) return id;
for (const child of Object.values(record)) { const found = findMediaID(child, shortcode); if (found) return found; }
return "";
}
function findMediaIDInText(raw: string, shortcode: string): string {
const escaped = shortcode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const patterns = [
new RegExp(`(?:code|shortcode)["']?\\s*[:=]\\s*["']${escaped}["'][\\s\\S]{0,800}?(?:id|pk|media_id)["']?\\s*[:=]\\s*["']?(\\d{10,})`, "i"),
new RegExp(`(?:id|pk|media_id)["']?\\s*[:=]\\s*["']?(\\d{10,})[\\s\\S]{0,800}?(?:code|shortcode)["']?\\s*[:=]\\s*["']${escaped}["']`, "i"),
];
for (const pattern of patterns) {
const match = raw.match(pattern);
if (match?.[1]) return match[1];
}
return "";
}
async function resolve(storageState: string, permalink: string): Promise<string> {
if (!isThreadsURL(permalink)) throw new Error("invalid Threads permalink");
const state = JSON.parse(storageState) as { cookies?: unknown[] };
if (!Array.isArray(state.cookies) || state.cookies.length === 0) throw new Error("crawler session is invalid");
const shortcode = shortcodeFromPermalink(permalink);
if (!shortcode) throw new Error("permalink has no Threads shortcode");
const browser = await chromium.launch({ headless: true });
try {
const context = await browser.newContext({ storageState: state, locale: "zh-TW", timezoneId: "Asia/Taipei" });
const page = await context.newPage();
let mediaID = "";
const responseReads: Promise<void>[] = [];
page.on("response", async (response) => {
if (mediaID || !/graphql|threads|instagram/.test(response.url())) return;
responseReads.push((async () => {
try {
const raw = await response.text();
if (!mediaID) {
try { mediaID = findMediaID(JSON.parse(raw), shortcode); } catch { /* non-JSON response */ }
}
if (!mediaID) mediaID = findMediaIDInText(raw, shortcode);
} catch { /* ignored */ }
})());
});
await page.goto(permalink, { waitUntil: "domcontentloaded", timeout: 45_000 });
const body = await page.locator("body").innerText().catch(() => "");
if (page.url().includes("/login") || body.includes("登入")) throw new Error("crawler session expired");
await page.waitForTimeout(2500);
await Promise.allSettled(responseReads);
if (!mediaID) {
mediaID = findMediaIDInText(await page.content(), shortcode);
}
await context.close();
if (!mediaID) throw new Error("Threads media ID could not be resolved")
return mediaID;
} finally { await browser.close(); }
}
if (!token) throw new Error("SCOUT_CRAWLER_TOKEN is required");
createServer(async (req, res) => {
const path = new URL(req.url || "/", "http://127.0.0.1").pathname;
if (req.method !== "POST" || (path !== "/v1/threads/search" && path !== "/v1/threads/resolve")) {
res.writeHead(404).end();
return;
}
if (req.headers.authorization !== `Bearer ${token}`) {
res.writeHead(401).end();
return;
}
const body = await new Promise<string>((resolve, reject) => {
let raw = "";
req.setEncoding("utf8");
req.on("data", (chunk) => { raw += chunk; if (raw.length > 300_000) req.destroy(); });
req.on("end", () => resolve(raw));
req.on("error", reject);
});
try {
if (path === "/v1/threads/resolve") {
const input = JSON.parse(body) as ResolveRequest;
const mediaID = await resolve(String(input.storage_state || ""), String(input.permalink || ""));
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ media_id: mediaID }));
} else {
const input = JSON.parse(body) as SearchRequest;
const posts = await search(String(input.storage_state || ""), Array.isArray(input.terms) ? input.terms.slice(0, 12) : [], Math.min(Math.max(Number(input.limit) || 10, 1), 30));
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ posts }));
}
} catch (error) {
res.writeHead(422, { "content-type": "application/json" }).end(JSON.stringify({ error: error instanceof Error ? error.message : "crawler failed" }));
}
}).listen(port, "127.0.0.1");

View File

@ -1,162 +0,0 @@
# 巡樓 gateway — 可提交的設定範本。
# 複製為 gateway.yaml 後填入私有密鑰gateway.yaml 已被 .gitignore 排除。
Name: haixun-gateway
Host: 0.0.0.0
Port: 8888
# AI 產文/試產可能超過 30s過短會 503 且前端拿不到結果
Timeout: 120000
MaxBytes: 104857600
Log:
Mode: console
Level: info
# go-zero monc 快取Redis— 與 monc.MustNewModel / CacheRedis 對接
# 若 Redis 有 requirepass設 Pass 或環境變數 REDIS_PASSWORD / HAIXUN_REDIS_PASSWORD
# (沒帶密碼會 NOAUTH登入 API 回 500
CacheRedis:
- Host: 127.0.0.1:6379
Type: node
Pass: ""
# Shared key prefix for this environment and a dedicated HMAC secret for
# credential-safe AI model-cache fingerprints. Prefer env REDIS_NAMESPACE and
# AI_MODEL_CACHE_FINGERPRINT_SECRET; do not reuse JWT, Scout, or encryption keys.
Redis:
Namespace: "haixun:dev:v1"
AIModelCacheFingerprintSecret: "REPLACE_WITH_A_DEDICATED_LONG_RANDOM_SECRET"
# Mongo — mon/monc 使用完整 URI含帳密時寫在 URI可用環境變數 MONGO_URI 覆寫)
# 本機有 auth 的例子:
# export MONGO_URI='mongodb://USER:PASS@127.0.0.1:27017/?authSource=admin'
# 沒設帳密但 Mongo 開了 auth → 查詢會失敗,登入會 500 而不是「密碼錯」
Mongo:
URI: "mongodb://127.0.0.1:27017"
Database: "haixun"
Auth:
# 至少 32 bytes且不得與 RefreshSecret 相同。
AccessSecret: "REPLACE_WITH_A_LONG_RANDOM_ACCESS_SECRET"
AccessExpire: 86400
RefreshSecret: "REPLACE_WITH_A_DIFFERENT_LONG_RANDOM_REFRESH_SECRET"
RefreshExpire: 604800
# AES-256-GCM for member BYOK at rest. Generate Key with: openssl rand -base64 32
# Prefer env MEMBER_SETTINGS_ENCRYPTION_KEY_ID / MEMBER_SETTINGS_ENCRYPTION_KEY.
# Keep this key ID/key unchanged until persisted values have been migrated to a replacement.
MemberSettingsEncryption:
CurrentKeyID: "v1"
Key: "REPLACE_WITH_BASE64_32_BYTE_KEY"
# 平台預設 key所有會員可用會員在設定頁填的 BYOK 優先)
# 也可用環境變數覆寫PLATFORM_XAI_KEY / PLATFORM_OPENCODE_KEY / PLATFORM_EXA_KEY / PLATFORM_AI_KEY
Platform:
# xAIapi.x.ai— 人設 LLM、文案等
XAIKey: ""
# 相容舊名XAIKey 空時會用 AIKey
AIKey: ""
# OpenCode Goopencode.ai/zen/go
OpenCodeKey: ""
# Exa 網搜
ExaKey: ""
ThreadsAppId: ""
ThreadsAppSecret: ""
Scout:
# Dedicated encryption key for Chrome storage state; do not reuse JWT keys.
SessionSecret: "REPLACE_WITH_A_DEDICATED_SCOUT_SESSION_SECRET"
# Private Playwright crawler service, called only by cmd/worker in dev_mode.
CrawlerEndpoint: ""
CrawlerToken: ""
Worker:
# 留空時 worker 會用 hostname + pid 產生唯一 owner id多 worker 不可共用固定值。
ID: ""
PollIntervalMs: 2000
# 僅 cmd/seeder 使用gateway/worker 不會讀取或寫入 log。
Seed:
AdminPassword: "REPLACE_WITH_A_STRONG_SEED_ADMIN_PASSWORD"
Bcrypt:
Cost: 10
# OAuth未設定 provider 時相關 API 明確拒絕,不提供 mock 路徑)
OAuth:
GoogleClientID: ""
LineClientID: ""
LineClientSecret: ""
LineRedirectURI: ""
# Prefer STRIPE_* environment variables. Disabled/missing configuration never
# blocks startup; Stripe-dependent billing operations explicitly return 503.
Stripe:
Enabled: false
SecretKey: ""
WebhookSecret: ""
StarterPriceID: ""
ProPriceID: ""
SuccessURL: ""
CancelURL: ""
PortalReturnURL: ""
# 驗證碼/重設密碼寄信
#
# 本機MailpitMailgun 相容 SMTP 介面):
# Host: 127.0.0.1 Port: 1025 User/Password 可空
# UI: http://127.0.0.1:8025
# 容器old/infra docker-compose 的 mailpit 服務
#
# 正式 Mailgun SMTP 例:
# Host: smtp.mailgun.org Port: 587
# User: postmaster@YOUR_DOMAIN Password: <API SMTP password>
# DevExposeCode: false試產
#
# env 覆寫MAIL_SMTP_* 或 HAIXUN_SMTP_*(舊名相容)
Mail:
Sender: "Harbor Desk <noreply@haixun.local>"
SMTP:
Host: "127.0.0.1"
Port: 1025
User: ""
Password: ""
# 不再於 API 回傳驗證碼碼只在郵件MailpitSMTP
DevExposeCode: false
# 前端 origin重設信連結 + 預設 logo URL/brand-mark.jpg
# 遠端 devhttps://threads-tool-dev.30cm.net
PublicWebBase: "https://threads-tool-dev.30cm.net"
# 瀏覽器直連 gateway 時的 allowlist同源 proxy 不需要列在此處。
# CORS_ALLOWED_ORIGINS 可用逗號附加額外的明確 origin不能使用 *。
CORSAllowedOrigins:
- "https://threads-tool-dev.30cm.net"
- "http://127.0.0.1:5173"
- "http://localhost:5173"
# 郵件品牌hermes 模板LogoURL 空 = PublicWebBase/brand-mark.jpg
Brand:
Name: "Harbor Desk"
LogoURL: ""
Copyright: "© Harbor Desk"
# 物件儲存(頭像等)— **只接 MinIO/S3**
#
# Endpoint = gateway 上傳用(本機 MinIO一定要 :9000
# PublicBaseURL = 瀏覽器讀圖用(走 nginx :80不要寫 port、不要加 /bucket
# 成品網址 = PublicBaseURL + "/" + key
# 例http://10.0.0.5/haixun-assets/avatar/1000000/xxx.png
#
# Secret 建議用 env不要 commit
# export HAIXUN_STORAGE_S3_ACCESS_KEY=haixun
# export HAIXUN_STORAGE_S3_SECRET_KEY='你的密碼'
# 或 MINIO_ROOT_USER / MINIO_ROOT_PASSWORDApplyEnv 會讀)
ObjectStorage:
Endpoint: "http://127.0.0.1:9000"
Region: "us-east-1"
Bucket: "haixun-assets"
AccessKey: "haixun"
SecretKey: ""
UsePathStyle: true
# env HAIXUN_STORAGE_S3_PUBLIC_BASE_URL 會覆寫;勿用 /api/v1/public/assets新後端無此 route
PublicBaseURL: "https://threads-tool-dev.30cm.net/haixun-assets"

View File

@ -1,59 +0,0 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"time"
"apps/backend/internal/config"
"apps/backend/internal/handler"
"apps/backend/internal/lib/securelog"
"apps/backend/internal/middleware"
"apps/backend/internal/svc"
"github.com/zeromicro/go-zero/core/conf"
"github.com/zeromicro/go-zero/core/logx"
"github.com/zeromicro/go-zero/rest"
)
// Gateway entry (go-zero). API from goctl; DB schema from generate/database migrate.
func main() {
// 必須在 mon/monc 建立前呼叫:否則 Mongo ReplaceOne 會把 password_hash 整包打進 log
securelog.SilenceMongo()
configFile := flag.String("f", "etc/gateway.yaml", "config file")
flag.Parse()
var c config.Config
conf.MustLoad(*configFile, &c)
c.ApplyEnv()
if err := c.ValidateProductionDependencies(); err != nil {
logx.Must(err)
}
// 禁止 Verbose否則可能印 request body 含 password
c.RestConf.Verbose = false
server := rest.MustNewServer(c.RestConf,
rest.WithNotAllowedHandler(middleware.NewCorsMiddleware(c.CORSAllowedOrigins).Handler()),
)
defer server.Stop()
server.Use(middleware.NewCorsMiddleware(c.CORSAllowedOrigins).Handle)
ctx := svc.NewServiceContext(c)
pingCtx, cancelPing := context.WithTimeout(context.Background(), 3*time.Second)
redisReady := ctx.Redis != nil && ctx.Redis.PingCtx(pingCtx)
cancelPing()
if !redisReady {
logx.Must(fmt.Errorf("redis startup ping failed"))
}
handler.RegisterHandlers(server, ctx)
// 靈感 SSE streamgoctl 不生成 streaming handler
handler.RegisterInspireStream(server, ctx)
logx.Infof("haixun gateway %s:%d pid=%d", c.Host, c.Port, os.Getpid())
fmt.Printf("Starting server at %s:%d...\n", c.Host, c.Port)
server.Start()
}

View File

@ -1,100 +0,0 @@
syntax = "v1"
type (
AdminListMembersReq {
Page int `form:"page,optional"`
PageSize int `form:"pageSize,optional"`
Query string `form:"query,optional"`
Status string `form:"status,optional"`
}
AdminUidPath {
Uid string `path:"uid"`
}
AdminCreateMemberReq {
DisplayName string `json:"display_name"`
Email string `json:"email"`
Phone string `json:"phone,optional"`
Password string `json:"password,optional"`
Roles []string `json:"roles,optional"`
EmailVerified bool `json:"email_verified,optional"`
Bio string `json:"bio,optional"`
Nickname string `json:"nickname,optional"`
FullName string `json:"full_name,optional"`
}
AdminCreateMemberData {
User MemberPublic `json:"user"`
TemporaryPassword string `json:"temporary_password"`
}
AdminSuspendReq {
Uid string `path:"uid"`
Suspended bool `json:"suspended"`
}
AdminRolesReq {
Uid string `path:"uid"`
Roles []string `json:"roles"`
}
AdminEmailVerifiedReq {
Uid string `path:"uid"`
Verified bool `json:"verified"`
}
AdminResetPasswordReq {
Uid string `path:"uid"`
NewPassword string `json:"new_password,optional"`
Password string `json:"password,optional"`
}
AdminStatusReq {
Uid string `path:"uid"`
Status string `json:"status"`
}
AdminListData {
List []MemberPublic `json:"list"`
Pagination Pagination `json:"pagination"`
}
AdminMemberIdentitiesData {
List []IdentityPublic `json:"list"`
}
)
@server (
group: admin
prefix: /api/v1/admin
middleware: AuthJWT,AdminAuth
)
service gateway {
@handler ListMembers
get /members (AdminListMembersReq) returns (AdminListData)
@handler CreateMember
post /members (AdminCreateMemberReq) returns (AdminCreateMemberData)
@handler GetMember
get /members/:uid (AdminUidPath) returns (MemberPublic)
@handler SetSuspended
post /members/:uid/suspend (AdminSuspendReq) returns (MemberPublic)
@handler SetRoles
post /members/:uid/roles (AdminRolesReq) returns (MemberPublic)
@handler SetEmailVerified
post /members/:uid/email-verified (AdminEmailVerifiedReq) returns (MemberPublic)
@handler AdminResetPassword
post /members/:uid/reset-password (AdminResetPasswordReq) returns (AdminCreateMemberData)
@handler SetStatus
post /members/:uid/status (AdminStatusReq) returns (MemberPublic)
@handler ListMemberIdentities
get /members/:uid/identities (AdminUidPath) returns (AdminMemberIdentitiesData)
}

View File

@ -1,195 +0,0 @@
syntax = "v1"
type (
AuthLoginReq {
Email string `json:"email,optional"`
LoginId string `json:"login_id,optional"`
Password string `json:"password"`
Platform string `json:"platform,optional"` // platform|google|line (default platform)
}
AuthRegisterReq {
Email string `json:"email"`
Password string `json:"password"`
DisplayName string `json:"display_name,optional"`
Phone string `json:"phone,optional"`
}
AuthRefreshReq {
RefreshToken string `json:"refresh_token"`
}
AuthLogoutReq {
RefreshToken string `json:"refresh_token,optional"`
}
TokenPair {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in"`
}
AuthSessionData {
Tokens TokenPair `json:"tokens"`
Member MemberPublic `json:"member"`
}
AuthTokensOnly {
Tokens TokenPair `json:"tokens"`
}
AuthProfilePatchReq {
DisplayName string `json:"display_name,optional"`
Nickname string `json:"nickname,optional"`
FullName string `json:"full_name,optional"`
Email string `json:"email,optional"`
Phone string `json:"phone,optional"`
Bio string `json:"bio,optional"`
Timezone string `json:"timezone,optional"`
NotifyEmail *bool `json:"notify_email,optional"`
// *string省略=不改;""=清除頭像http(s)=設定(禁止 data URL
AvatarUrl *string `json:"avatar_url,optional"`
GenderCode *int `json:"gender_code,optional"`
Birthdate *int64 `json:"birthdate,optional"`
National string `json:"national,optional"`
Address string `json:"address,optional"`
PostCode string `json:"post_code,optional"`
PreferredLanguage string `json:"preferred_language,optional"`
Currency string `json:"currency,optional"`
CurrentPassword string `json:"current_password,optional"`
NewPassword string `json:"new_password,optional"`
}
AuthRequestResetReq {
Email string `json:"email"`
// FE locale: zh-TW | en | en-US未登入時用有會員則優先 preferred_language
Language string `json:"language,optional"`
}
AuthRequestResetData {
Ok bool `json:"ok"`
Message string `json:"message"`
MockCode string `json:"mock_code,optional"`
MockResetPath string `json:"mock_reset_path,optional"`
}
AuthResetPasswordReq {
Email string `json:"email"`
Token string `json:"token,optional"`
Code string `json:"code,optional"`
NewPassword string `json:"new_password"`
}
AuthSendCodeData {
Ok bool `json:"ok"`
Message string `json:"message"`
MockCode string `json:"mock_code,optional"`
}
AuthVerifyEmailReq {
Code string `json:"code"`
}
AuthOAuthProviderPath {
Provider string `path:"provider"`
}
AuthOAuthCallbackReq {
Provider string `path:"provider"`
Code string `json:"code,optional"`
IdToken string `json:"id_token,optional"`
AccessToken string `json:"access_token,optional"`
RedirectUri string `json:"redirect_uri,optional"`
}
AuthOAuthStartData {
AuthorizeUrl string `json:"authorize_url"`
Provider string `json:"provider"`
State string `json:"state"`
}
AuthBindReq {
LoginId string `json:"login_id"`
Platform string `json:"platform"`
AccountType string `json:"account_type,optional"`
Password string `json:"password,optional"`
}
AuthUnbindReq {
LoginId string `json:"login_id"`
Platform string `json:"platform"`
}
AuthIdentitiesData {
List []IdentityPublic `json:"list"`
}
AuthLogoutAllData {
Ok bool `json:"ok"`
}
)
@server (
group: auth
prefix: /api/v1/auth
)
service gateway {
@handler Login
post /login (AuthLoginReq) returns (AuthSessionData)
@handler Logout
post /logout (AuthLogoutReq) returns (OkData)
@handler Refresh
post /refresh (AuthRefreshReq) returns (AuthTokensOnly)
@handler Register
post /register (AuthRegisterReq) returns (AuthSessionData)
@handler RequestPasswordReset
post /password/request-reset (AuthRequestResetReq) returns (AuthRequestResetData)
@handler ResetPassword
post /password/reset (AuthResetPasswordReq) returns (OkData)
@handler OAuthStart
post /oauth/:provider/start (AuthOAuthProviderPath) returns (AuthOAuthStartData)
@handler OAuthCallback
post /oauth/:provider/callback (AuthOAuthCallbackReq) returns (AuthSessionData)
}
@server (
group: auth
prefix: /api/v1/auth
middleware: AuthJWT
)
service gateway {
@handler Me
get /me returns (MemberPublic)
@handler UpdateProfile
patch /profile (AuthProfilePatchReq) returns (MemberPublic)
@handler UpdateProfilePut
put /profile (AuthProfilePatchReq) returns (MemberPublic)
@handler SendEmailCode
post /email/send-code returns (AuthSendCodeData)
@handler VerifyEmail
post /email/verify (AuthVerifyEmailReq) returns (MemberPublic)
@handler ListIdentities
get /identities returns (AuthIdentitiesData)
@handler BindAccount
post /bind (AuthBindReq) returns (IdentityPublic)
@handler UnbindAccount
post /unbind (AuthUnbindReq) returns (OkData)
@handler LogoutAll
post /logout-all returns (AuthLogoutAllData)
}

View File

@ -1,69 +0,0 @@
syntax = "v1"
type (
BillingCheckoutCreateReq {
PlanId string `json:"plan_id"`
RequestId string `json:"request_id"`
}
BillingCheckoutData {
Id string `json:"id"`
SessionId string `json:"session_id"`
Url string `json:"url"`
ExpiresAt int64 `json:"expires_at"`
Status string `json:"status,optional"`
PaymentStatus string `json:"payment_status,optional"`
FulfillmentStatus string `json:"fulfillment_status,optional"`
PlanId string `json:"plan_id,optional"`
}
BillingCheckoutGetReq {
Id string `path:"id"`
}
BillingSubscriptionData {
PlanId string `json:"plan_id"`
PaidPlanId string `json:"paid_plan_id,optional"`
Status string `json:"status"`
CustomerId string `json:"customer_id,optional"`
SubscriptionId string `json:"subscription_id,optional"`
CurrentPeriodStart int64 `json:"current_period_start,optional"`
CurrentPeriodEnd int64 `json:"current_period_end,optional"`
CancelAtPeriodEnd bool `json:"cancel_at_period_end"`
AdminOverride bool `json:"admin_override"`
}
BillingPortalData {
Url string `json:"url"`
}
)
@server (
jwt: Auth
group: billing
prefix: /api/v1/billing
middleware: AuthJWT
)
service gateway {
@handler CreateBillingCheckoutSession
post /checkout-sessions (BillingCheckoutCreateReq) returns (BillingCheckoutData)
@handler GetBillingCheckoutSession
get /checkout-sessions/:id (BillingCheckoutGetReq) returns (BillingCheckoutData)
@handler GetBillingSubscription
get /subscription returns (BillingSubscriptionData)
@handler CreateBillingPortalSession
post /portal-sessions returns (BillingPortalData)
}
@server (
group: billing
prefix: /api/v1/billing
middleware: StripeRawBody
)
service gateway {
@handler HandleStripeWebhook
post /stripe/webhook
}

View File

@ -1,57 +0,0 @@
syntax = "v1"
type Empty {}
type OkData {
Ok bool `json:"ok"`
Message string `json:"message,optional"`
}
type Pagination {
Page int `json:"page"`
PageSize int `json:"pageSize"`
Total int64 `json:"total"`
TotalPages int `json:"totalPages"`
}
// IdentityPublic — login channel bound to member
type IdentityPublic {
LoginId string `json:"login_id"`
Platform string `json:"platform"`
AccountType string `json:"account_type"`
CreatedAt int64 `json:"created_at,optional"`
}
// MemberPublic — full profile (stand-alone user_info + Harbor Desk fields)
type MemberPublic {
TenantId string `json:"tenant_id"`
Uid int64 `json:"uid"`
Email string `json:"email"`
Phone string `json:"phone,optional"`
DisplayName string `json:"display_name"`
Nickname string `json:"nickname,optional"`
FullName string `json:"full_name,optional"`
Roles []string `json:"roles"`
Status string `json:"status"`
EmailVerified bool `json:"email_verified"`
EmailVerifiedAt int64 `json:"email_verified_at,optional"`
PhoneVerified bool `json:"phone_verified,optional"`
PhoneVerifiedAt int64 `json:"phone_verified_at,optional"`
InviteCode string `json:"invite_code,optional"`
ParentUid int64 `json:"parent_uid,optional"`
Bio string `json:"bio,optional"`
Timezone string `json:"timezone,optional"`
AvatarUrl string `json:"avatar_url,optional"`
NotifyEmail bool `json:"notify_email,optional"`
GenderCode int `json:"gender_code,optional"`
Birthdate int64 `json:"birthdate,optional"`
National string `json:"national,optional"`
Address string `json:"address,optional"`
PostCode string `json:"post_code,optional"`
PreferredLanguage string `json:"preferred_language,optional"`
Currency string `json:"currency,optional"`
Identities []IdentityPublic `json:"identities,optional"`
JoinedAt int64 `json:"joined_at,optional"`
CreatedAt int64 `json:"created_at,optional"`
UpdatedAt int64 `json:"updated_at,optional"`
}

View File

@ -1,13 +0,0 @@
syntax = "v1"
// ST-16: extension zip download (authenticated member)
@server (
jwt: Auth
group: extension
prefix: /api/v1/extension
middleware: AuthJWT
)
service gateway {
@handler DownloadExtensionZip
get /download returns (Empty)
}

View File

@ -1,25 +0,0 @@
syntax = "v1"
info (
title: "haixun-gateway"
desc: "Harbor Desk Phase C live API (goctl)"
author: "haixun"
version: "0.2.0"
)
import "common.api"
import "ping.api"
import "auth.api"
import "admin.api"
import "settings.api"
import "media.api"
import "threads.api"
import "jobs.api"
import "notifications.api"
import "usage.api"
import "billing.api"
import "proxy.api"
import "extension.api"
import "studio.api"
import "m5.api"
import "invite.api"

View File

@ -1,93 +0,0 @@
syntax = "v1"
// M6 邀請網絡:會員補填碼 + admin 樹/移動(防環)
type (
InviteMemberBrief {
Uid int64 `json:"uid"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
InviteCode string `json:"invite_code"`
ParentUid int64 `json:"parent_uid,optional"` // 0 = 無上線
Roles []string `json:"roles"`
Status string `json:"status"`
DownlineCount int `json:"downline_count"`
TotalDownlineCount int `json:"total_downline_count"`
Depth int `json:"depth"`
JoinedAt int64 `json:"joined_at"`
AvatarUrl string `json:"avatar_url,optional"`
}
InviteTreeNode {
Uid int64 `json:"uid"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
InviteCode string `json:"invite_code"`
ParentUid int64 `json:"parent_uid,optional"`
Roles []string `json:"roles"`
Status string `json:"status"`
DownlineCount int `json:"downline_count"`
TotalDownlineCount int `json:"total_downline_count"`
Depth int `json:"depth"`
JoinedAt int64 `json:"joined_at"`
AvatarUrl string `json:"avatar_url,optional"`
Children []InviteTreeNode `json:"children"`
}
MyInviteNetworkData {
Me InviteMemberBrief `json:"me"`
Upline *InviteMemberBrief `json:"upline,optional"`
Downlines []InviteMemberBrief `json:"downlines"`
TotalDownlineCount int `json:"total_downline_count"`
}
ApplyInviteCodeReq {
Code string `json:"code"`
}
InviteTreeData {
List []InviteTreeNode `json:"list"`
}
MoveInviteMemberReq {
Uid int64 `json:"uid"`
ParentUid int64 `json:"parent_uid,optional"` // 0 = 獨立根
}
ListParentCandidatesReq {
ExcludeSubtreeRootUid int64 `form:"exclude_subtree_root_uid,optional"`
}
InviteParentCandidatesData {
List []InviteMemberBrief `json:"list"`
}
)
@server (
group: invite
prefix: /api/v1/invite
middleware: AuthJWT
)
service gateway {
@handler GetMyInviteNetwork
get /me returns (MyInviteNetworkData)
@handler ApplyInviteCode
post /apply (ApplyInviteCodeReq) returns (MyInviteNetworkData)
}
@server (
group: invite
prefix: /api/v1/invite
middleware: AuthJWT,AdminAuth
)
service gateway {
@handler GetInviteTree
get /tree returns (InviteTreeData)
@handler MoveInviteMember
post /move (MoveInviteMemberReq) returns (InviteMemberBrief)
@handler ListInviteParentCandidates
get /parent-candidates (ListParentCandidatesReq) returns (InviteParentCandidatesData)
}

View File

@ -1,67 +0,0 @@
syntax = "v1"
type (
JobPublic {
Id string `json:"id"`
TemplateType string `json:"template_type"`
Status string `json:"status"`
ProgressSummary string `json:"progress_summary"`
ProgressPercent int `json:"progress_percent"`
Error string `json:"error,optional"`
// 業務關聯threads 帳號 id、人設 id
RefId string `json:"ref_id,optional"`
// 任務 payload JSON{"username":"x"}
Payload string `json:"payload,optional"`
// 排程執行時間 unix ns0立刻可領
RunAfter int64 `json:"run_after,optional"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
CompletedAt int64 `json:"completed_at,optional"`
}
// tab: recurring=遠期定期排程 | active=執行中/待領取 | history=終態歷史
JobListReq {
Tab string `form:"tab,optional"`
Page int `form:"page,optional"`
PageSize int `form:"pageSize,optional"`
}
JobListData {
List []JobPublic `json:"list"`
Pagination Pagination `json:"pagination"`
}
JobIdPath {
Id string `path:"id"`
}
)
@server (
jwt: Auth
group: jobs
prefix: /api/v1/jobs
middleware: AuthJWT
)
service gateway {
@handler ListJobs
get / (JobListReq) returns (JobListData)
@handler GetJob
get /:id (JobIdPath) returns (JobPublic)
// 手動刪除任務(執行中不可刪;終態/已排程可刪)
@handler DeleteJob
delete /:id (JobIdPath) returns (OkData)
}
// 管理員:產生 demo測試任務由 demo-worker 消費)
@server (
jwt: Auth
group: jobs
prefix: /api/v1/jobs
middleware: AuthJWT,AdminAuth
)
service gateway {
@handler StartDemoJob
post /demo returns (JobPublic)
}

View File

@ -1,470 +0,0 @@
syntax = "v1"
// M5: inspire + research + media image + scout
type (
// --- Inspire ---
TrendPublic {
Id string `json:"id"`
Kind string `json:"kind"`
Label string `json:"label"`
Summary string `json:"summary"`
Heat int `json:"heat"`
Keywords []string `json:"keywords"`
Samples []string `json:"samples"`
SourceLabel string `json:"source_label"`
ObservedAt int64 `json:"observed_at"`
}
TrendListData {
List []TrendPublic `json:"list"`
}
InspireElementPublic {
Id string `json:"id"`
Kind string `json:"kind"`
Title string `json:"title"`
Body string `json:"body"`
RefId string `json:"ref_id,optional"`
Reusable bool `json:"reusable"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
InspireElementListData {
List []InspireElementPublic `json:"list"`
}
InspireElementSaveReq {
Id string `json:"id,optional"`
Kind string `json:"kind,optional"`
Title string `json:"title"`
Body string `json:"body"`
RefId string `json:"ref_id,optional"`
Reusable bool `json:"reusable,optional"`
}
InspireElementIdPath {
Id string `path:"id"`
}
InspireDraftPublic {
Title string `json:"title,optional"`
Body string `json:"body"`
}
InspireMessagePublic {
Id string `json:"id"`
Role string `json:"role"`
Text string `json:"text"`
Draft InspireDraftPublic `json:"draft,optional"`
CreatedAt int64 `json:"created_at"`
}
InspireSessionPublic {
Id string `json:"id"`
Title string `json:"title,optional"`
Messages []InspireMessagePublic `json:"messages"`
PinnedElementIds []string `json:"pinned_element_ids"`
CreatedAt int64 `json:"created_at,optional"`
UpdatedAt int64 `json:"updated_at"`
}
// 列表摘要(不含完整 messages
InspireSessionSummaryPublic {
Id string `json:"id"`
Title string `json:"title"`
Preview string `json:"preview"`
MessageCount int `json:"message_count"`
UpdatedAt int64 `json:"updated_at"`
CreatedAt int64 `json:"created_at"`
Active bool `json:"active"`
}
InspireSessionListData {
List []InspireSessionSummaryPublic `json:"list"`
}
InspireSessionIdPath {
Id string `path:"id"`
}
InspireSessionCreateReq {
Title string `json:"title,optional"`
}
InspireChatReq {
Message string `json:"message"`
PinnedIds []string `json:"pinned_ids,optional"`
Mode string `json:"mode,optional"` // chat|generate
PersonaId string `json:"persona_id,optional"`
SessionId string `json:"session_id,optional"` // 空 = active
// generate 專用:待改寫素材(鎖定內容);空則拒絕產文
Material string `json:"material,optional"`
}
InspireChatData {
Session InspireSessionPublic `json:"session"`
Messages []InspireMessagePublic `json:"messages"`
}
// 觀測:與送出相同的 prompt 組裝(不呼叫 AI、不計費
InspirePromptPreviewReq {
Message string `json:"message,optional"`
PinnedIds []string `json:"pinned_ids,optional"`
Mode string `json:"mode,optional"` // chat|generate
PersonaId string `json:"persona_id,optional"`
SessionId string `json:"session_id,optional"`
Material string `json:"material,optional"`
}
InspirePromptPinnedPublic {
Id string `json:"id"`
Kind string `json:"kind"`
Title string `json:"title"`
Body string `json:"body"`
}
InspirePromptBlockPublic {
Title string `json:"title"`
Body string `json:"body"`
}
InspirePromptPreviewData {
Prompt string `json:"prompt"`
Mode string `json:"mode"`
Sections []string `json:"sections"`
Blocks []InspirePromptBlockPublic `json:"blocks"`
Fingerprint string `json:"fingerprint"`
CharCount int `json:"char_count"`
RuneCount int `json:"rune_count"`
Pinned []InspirePromptPinnedPublic `json:"pinned"`
Note string `json:"note,optional"`
}
ResearchSearchReq {
Query string `json:"query"`
}
ResearchHitPublic {
Id string `json:"id"`
Title string `json:"title"`
Snippet string `json:"snippet"`
Url string `json:"url"`
Summary string `json:"summary,optional"`
LearnPoints []string `json:"learn_points,optional"`
ReplyHooks []string `json:"reply_hooks,optional"`
SourceLabel string `json:"source_label,optional"`
}
ResearchSearchData {
List []ResearchHitPublic `json:"list"`
}
GenerateImageReq {
Prompt string `json:"prompt"`
}
GenerateImageData {
Id string `json:"id"`
Url string `json:"url"`
Prompt string `json:"prompt"`
}
// --- Scout ---
BrandPublic {
Id string `json:"id"`
DisplayName string `json:"display_name"`
Brief string `json:"brief"`
TargetAudience string `json:"target_audience,optional"`
Goals string `json:"goals,optional"`
}
BrandListData {
List []BrandPublic `json:"list"`
}
BrandSaveReq {
Id string `json:"id,optional"`
DisplayName string `json:"display_name"`
Brief string `json:"brief,optional"`
TargetAudience string `json:"target_audience,optional"`
Goals string `json:"goals,optional"`
}
BrandIdPath {
Id string `path:"id"`
}
BrandActiveData {
Id string `json:"id"`
}
BrandSetActiveReq {
Id string `json:"id"`
}
ProductPublic {
Id string `json:"id"`
BrandId string `json:"brand_id"`
Label string `json:"label"`
ProductContext string `json:"product_context"`
MatchTags []string `json:"match_tags"`
PainPoints []string `json:"pain_points"`
PlacementUrl string `json:"placement_url,optional"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
ProductListData {
List []ProductPublic `json:"list"`
}
ProductSaveReq {
Id string `json:"id,optional"`
BrandId string `json:"brand_id"`
Label string `json:"label"`
ProductContext string `json:"product_context,optional"`
MatchTags []string `json:"match_tags,optional"`
PainPoints []string `json:"pain_points,optional"`
PlacementUrl string `json:"placement_url,optional"`
}
ProductIdPath {
Id string `path:"id"`
}
ProductListReq {
BrandId string `form:"brand_id,optional"`
}
ImportProductReq {
Url string `json:"url"`
}
ImportProductData {
Label string `json:"label"`
ProductContext string `json:"product_context"`
PainPoints []string `json:"pain_points"`
MatchTags []string `json:"match_tags"`
PlacementUrl string `json:"placement_url"`
SourceNote string `json:"source_note"`
}
ScoutBriefReq {
Intent string `json:"intent"`
BrandId string `json:"brand_id,optional"`
ProductId string `json:"product_id,optional"`
Purpose string `json:"purpose,optional"`
Deep bool `json:"deep,optional"`
}
ScoutBriefPublic {
Intent string `json:"intent"`
Mode string `json:"mode"`
BrandId string `json:"brand_id,optional"`
ProductId string `json:"product_id,optional"`
ProductLabel string `json:"product_label,optional"`
Pains []string `json:"pains"`
Tags []string `json:"tags"`
Periphery []string `json:"periphery"`
ScanTerms []string `json:"scan_terms"`
PlacementNote string `json:"placement_note,optional"`
ResponseStance string `json:"response_stance,optional"`
ThemeKey string `json:"theme_key,optional"`
ThemeLabel string `json:"theme_label,optional"`
ProductContext string `json:"product_context,optional"`
}
ScoutScanReq {
Brief ScoutBriefPublic `json:"brief"`
}
ScoutScanJobData {
Job JobPublic `json:"job"`
}
ScoutPostPublic {
Id string `json:"id"`
BrandId string `json:"brand_id,optional"`
Author string `json:"author"`
Text string `json:"text"`
SearchTag string `json:"search_tag"`
Opportunity string `json:"opportunity"`
OutreachStatus string `json:"outreach_status"`
DraftText string `json:"draft_text,optional"`
Score int `json:"score"`
MatchedProductId string `json:"matched_product_id,optional"`
MatchedProductLabel string `json:"matched_product_label,optional"`
MatchReason string `json:"match_reason,optional"`
ScoutMode string `json:"scout_mode,optional"`
IntentSnippet string `json:"intent_snippet,optional"`
ThemeKey string `json:"theme_key,optional"`
ThemeLabel string `json:"theme_label,optional"`
ScanPath string `json:"scan_path,optional"`
Classification string `json:"classification,optional"`
Permalink string `json:"permalink,optional"`
CreatedAt int64 `json:"created_at"`
}
ScoutPostListData {
List []ScoutPostPublic `json:"list"`
}
ScoutPostListReq {
BrandId string `form:"brand_id,optional"`
}
ScoutPostIdPath {
Id string `path:"id"`
}
ScoutDraftPathReq {
Id string `path:"id"`
PersonaId string `json:"persona_id,optional"`
}
ScoutSendPathReq {
Id string `path:"id"`
Text string `json:"text,optional"`
AccountId string `json:"account_id,optional"`
}
ScoutThemePath {
ThemeKey string `path:"themeKey"`
}
ScoutHomeworkPublic {
ThemeKey string `json:"theme_key"`
ThemeLabel string `json:"theme_label"`
Purpose string `json:"purpose"`
Brief ScoutBriefPublic `json:"brief"`
CreatedAt int64 `json:"created_at"`
}
ScoutHomeworkListData {
List []ScoutHomeworkPublic `json:"list"`
}
ScoutHomeworkSaveReq {
ThemeKey string `json:"theme_key"`
ThemeLabel string `json:"theme_label"`
Purpose string `json:"purpose,optional"`
Brief ScoutBriefPublic `json:"brief"`
}
ScoutCrawlerSessionReq {
Token string `json:"token"`
}
)
@server (
jwt: Auth
group: inspire
prefix: /api/v1/inspire
middleware: AuthJWT
)
service gateway {
@handler ListTrends
get /trends returns (TrendListData)
@handler RefreshTrends
post /trends/refresh returns (TrendListData)
@handler ListInspireElements
get /elements returns (InspireElementListData)
@handler SaveInspireElement
post /elements (InspireElementSaveReq) returns (InspireElementPublic)
@handler RemoveInspireElement
delete /elements/:id (InspireElementIdPath) returns (OkData)
@handler GetInspireSession
get /session returns (InspireSessionPublic)
@handler ClearInspireSession
post /session/clear returns (InspireSessionPublic)
@handler ListInspireSessions
get /sessions returns (InspireSessionListData)
@handler CreateInspireSession
post /sessions (InspireSessionCreateReq) returns (InspireSessionPublic)
@handler GetInspireSessionById
get /sessions/:id (InspireSessionIdPath) returns (InspireSessionPublic)
@handler ActivateInspireSession
post /sessions/:id/activate (InspireSessionIdPath) returns (InspireSessionPublic)
@handler DeleteInspireSession
delete /sessions/:id (InspireSessionIdPath) returns (InspireSessionPublic)
@handler InspireChat
post /chat (InspireChatReq) returns (InspireChatData)
@handler InspirePromptPreview
post /prompt-preview (InspirePromptPreviewReq) returns (InspirePromptPreviewData)
}
@server (
jwt: Auth
group: research
prefix: /api/v1/research
middleware: AuthJWT
)
service gateway {
@handler ResearchSearch
post /search (ResearchSearchReq) returns (ResearchSearchData)
}
@server (
jwt: Auth
group: media
prefix: /api/v1/media
middleware: AuthJWT
)
service gateway {
@handler GenerateImage
post /generate-image (GenerateImageReq) returns (GenerateImageData)
}
@server (
jwt: Auth
group: scout
prefix: /api/v1/scout
middleware: AuthJWT
)
service gateway {
@handler ListBrands
get /brands returns (BrandListData)
@handler SaveBrand
post /brands (BrandSaveReq) returns (BrandPublic)
@handler GetBrand
get /brands/:id (BrandIdPath) returns (BrandPublic)
@handler RemoveBrand
delete /brands/:id (BrandIdPath) returns (OkData)
@handler GetActiveBrand
get /brands-active returns (BrandActiveData)
@handler SetActiveBrand
post /brands-active (BrandSetActiveReq) returns (BrandActiveData)
@handler ListProducts
get /products (ProductListReq) returns (ProductListData)
@handler ListAllProducts
get /products/all returns (ProductListData)
@handler SaveProduct
post /products (ProductSaveReq) returns (ProductPublic)
@handler GetProduct
get /products/:id (ProductIdPath) returns (ProductPublic)
@handler RemoveProduct
delete /products/:id (ProductIdPath) returns (OkData)
@handler ImportProduct
post /products/import (ImportProductReq) returns (ImportProductData)
@handler PrepareBrief
post /brief (ScoutBriefReq) returns (ScoutBriefPublic)
@handler RunScan
post /scan (ScoutScanReq) returns (ScoutScanJobData)
@handler ListScoutPosts
get /posts (ScoutPostListReq) returns (ScoutPostListData)
@handler DraftOutreach
post /posts/:id/draft (ScoutDraftPathReq) returns (ScoutPostPublic)
@handler SkipOutreach
post /posts/:id/skip (ScoutPostIdPath) returns (ScoutPostPublic)
@handler MarkPublished
post /posts/:id/mark-published (ScoutPostIdPath) returns (ScoutPostPublic)
@handler SendOutreach
post /posts/:id/send (ScoutSendPathReq) returns (ScoutPostPublic)
@handler RemoveScoutPost
delete /posts/:id (ScoutPostIdPath) returns (OkData)
@handler RemoveScoutTheme
delete /themes/:themeKey (ScoutThemePath) returns (OkData)
@handler ListHomework
get /homework returns (ScoutHomeworkListData)
@handler SaveHomework
post /homework (ScoutHomeworkSaveReq) returns (ScoutHomeworkPublic)
@handler GetHomework
get /homework/:themeKey (ScoutThemePath) returns (ScoutHomeworkPublic)
@handler RemoveHomework
delete /homework/:themeKey (ScoutThemePath) returns (OkData)
@handler SetCrawlerSession
post /crawler-session (ScoutCrawlerSessionReq) returns (OkData)
@handler ClearCrawlerSession
delete /crawler-session returns (OkData)
}

View File

@ -1,25 +0,0 @@
syntax = "v1"
type (
MediaUploadReq {
// content: data URL (data:image/jpeg;base64,...) 或純 base64
Content string `json:"content"`
// kind: avatar | other決定 object path 前綴)
Kind string `json:"kind,optional"`
}
MediaUploadData {
Url string `json:"url"`
Key string `json:"key,optional"`
}
)
@server (
group: media
prefix: /api/v1/media
middleware: AuthJWT
)
service gateway {
@handler Upload
post /upload (MediaUploadReq) returns (MediaUploadData)
}

View File

@ -1,46 +0,0 @@
syntax = "v1"
type (
NotificationPublic {
Id string `json:"id"`
Title string `json:"title"`
Body string `json:"body"`
Kind string `json:"kind"` // job|system
RefType string `json:"ref_type"`
RefId string `json:"ref_id,optional"`
ReadAt int64 `json:"read_at,optional"`
CreatedAt int64 `json:"created_at"`
}
NotificationListData {
List []NotificationPublic `json:"list"`
}
UnreadCountData {
Count int64 `json:"count"`
}
NotificationIdPath {
Id string `path:"id"`
}
)
@server (
jwt: Auth
group: notifications
prefix: /api/v1/notifications
middleware: AuthJWT
)
service gateway {
@handler ListNotifications
get / returns (NotificationListData)
@handler UnreadNotificationCount
get /unread-count returns (UnreadCountData)
@handler MarkNotificationRead
post /:id/read (NotificationIdPath) returns (OkData)
@handler MarkAllNotificationsRead
post /read-all returns (OkData)
}

View File

@ -1,18 +0,0 @@
syntax = "v1"
type HealthData {
Status string `json:"status"`
Service string `json:"service"`
}
@server (
group: ping
prefix: /api/v1
)
service gateway {
@handler Ping
get /ping returns (HealthData)
@handler Health
get /health returns (HealthData)
}

View File

@ -1,45 +0,0 @@
syntax = "v1"
type (
AICompleteReq {
Prompt string `json:"prompt"`
Model string `json:"model,optional"`
Meter string `json:"meter,optional"` // default ai_copy
}
AICompleteData {
Text string `json:"text"`
KeyMode string `json:"key_mode"`
Model string `json:"model"`
}
SearchReq {
Query string `json:"query"`
Limit int `json:"limit,optional"`
}
SearchHit {
Title string `json:"title"`
Url string `json:"url"`
Snippet string `json:"snippet"`
}
SearchData {
List []SearchHit `json:"list"`
KeyMode string `json:"key_mode"`
}
)
@server (
jwt: Auth
group: proxy
prefix: /api/v1/proxy
middleware: AuthJWT
)
service gateway {
@handler AIComplete
post /ai/complete (AICompleteReq) returns (AICompleteData)
@handler ExaSearch
post /search (SearchReq) returns (SearchData)
}

View File

@ -1,108 +0,0 @@
syntax = "v1"
type (
// GetAi設定 + 模型清單一併回(避免前端分開打兩支)
GetAiReq {
// 可選:預覽另一個 provider 的模型清單(不改存檔)
Provider string `form:"provider,optional"`
}
AiSettingsData {
Provider string `json:"provider"`
Model string `json:"model"`
ResearchProvider string `json:"research_provider"`
ResearchModel string `json:"research_model"`
ApiKeyConfigured bool `json:"api_key_configured"`
ResearchApiKeyConfigured bool `json:"research_api_key_configured"`
PlatformKeyAvailable bool `json:"platform_key_available"`
// Models — 目前 models_provider 的可用模型(真拉 + 5 分鐘快取)
Models []string `json:"models"`
ModelsProvider string `json:"models_provider"`
// SelectedModel — 與 Model 相同;明確給前端「上次設定的模型」
SelectedModel string `json:"selected_model"`
// ModelsFromCache — 這次模型清單是否來自快取
ModelsFromCache bool `json:"models_from_cache,optional"`
// ModelsError — 真拉失敗時提示(仍回 fallback models
ModelsError string `json:"models_error,optional"`
}
SaveAiReq {
Provider string `json:"provider,optional"`
Model string `json:"model,optional"`
ResearchProvider string `json:"research_provider,optional"`
ResearchModel string `json:"research_model,optional"`
ApiKey string `json:"api_key,optional"`
ResearchApiKey string `json:"research_api_key,optional"`
ApiKeyConfigured *bool `json:"api_key_configured,optional"`
ResearchApiKeyConfigured *bool `json:"research_api_key_configured,optional"`
}
PlacementSettingsData {
WebSearchProvider string `json:"web_search_provider"`
ExpandStrategy string `json:"expand_strategy"`
BraveApiKeyConfigured bool `json:"brave_api_key_configured"`
ExaApiKeyConfigured bool `json:"exa_api_key_configured"`
DevModeEnabled bool `json:"dev_mode_enabled"`
PlatformKeyAvailable bool `json:"platform_key_available"`
}
SavePlacementReq {
ExpandStrategy string `json:"expand_strategy,optional"`
DevModeEnabled *bool `json:"dev_mode_enabled,optional"`
ExaApiKey string `json:"exa_api_key,optional"`
ExaApiKeyConfigured *bool `json:"exa_api_key_configured,optional"`
}
ListModelsReq {
Provider string `form:"provider,optional"`
}
ListModelsData {
List []string `json:"list"`
Provider string `json:"provider"`
// SelectedModel — 若該 provider 為使用者目前存檔 provider回傳已選 model
SelectedModel string `json:"selected_model,optional"`
FromCache bool `json:"from_cache,optional"`
ModelsError string `json:"models_error,optional"`
}
// Threads OAuth 平台狀態App Secret 永不回傳)
ThreadsSettingsData {
Provider string `json:"provider"` // fake | meta
Configured bool `json:"configured"`
OauthReady bool `json:"oauth_ready"`
ConnectPath string `json:"connect_path"` // 前端連帳入口
// 給 Meta 後台「Valid OAuth Redirect URIs」貼上用
CallbackUrl string `json:"callback_url"`
// 前端公開 origin應 https
PublicWebBase string `json:"public_web_base"`
// App Id 遮罩,例 2733…9930未設定空字串
AppIdMasked string `json:"app_id_masked,optional"`
Hint string `json:"hint"`
}
)
@server (
group: settings
prefix: /api/v1/settings
middleware: AuthJWT
)
service gateway {
@handler GetAi
get /ai (GetAiReq) returns (AiSettingsData)
@handler SaveAi
put /ai (SaveAiReq) returns (AiSettingsData)
@handler GetPlacement
get /placement returns (PlacementSettingsData)
@handler SavePlacement
put /placement (SavePlacementReq) returns (PlacementSettingsData)
@handler ListModels
get /models (ListModelsReq) returns (ListModelsData)
@handler GetThreads
get /threads returns (ThreadsSettingsData)
}

View File

@ -1,568 +0,0 @@
syntax = "v1"
// M4 Studio: personas / plays / outbox / compose / own-posts / mentions
type (
PersonaDraftPublic {
Identity string `json:"identity"`
Tone string `json:"tone"`
Audience string `json:"audience"`
Hooks string `json:"hooks"`
LanguageFingerprint string `json:"language_fingerprint"`
Rhythm string `json:"rhythm"`
Punctuation string `json:"punctuation"`
ContentPatterns string `json:"content_patterns"`
KnowledgeTranslation string `json:"knowledge_translation"`
CtaStyle string `json:"cta_style"`
Examples string `json:"examples"`
Avoid string `json:"avoid"`
}
StyleDimensionPublic {
Summary string `json:"summary"`
Evidence []string `json:"evidence,optional"`
}
PersonaStylePublic {
Draft PersonaDraftPublic `json:"draft"`
DraftText string `json:"draft_text"`
Source string `json:"source"`
BenchmarkUsername string `json:"benchmark_username,optional"`
SourceLabel string `json:"source_label,optional"`
SampleCount int `json:"sample_count"`
AnalyzedAt int64 `json:"analyzed_at,optional"`
SamplePreviews []string `json:"sample_previews,optional"`
// 8Dd1Tone…d8Risk → { summary, evidence }
Dimensions map[string]StyleDimensionPublic `json:"dimensions,optional"`
}
PersonaGuardPublic {
Avoid []string `json:"avoid"`
MaxChars int `json:"max_chars"`
BanAiTone bool `json:"ban_ai_tone"`
}
PersonaPublic {
Id string `json:"id"`
Name string `json:"name"`
Brief string `json:"brief"`
Status string `json:"status"`
Style PersonaStylePublic `json:"style"`
Guard PersonaGuardPublic `json:"guard"`
Voice string `json:"voice,optional"`
Notes string `json:"notes,optional"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
PersonaListData {
List []PersonaPublic `json:"list"`
}
PersonaSaveReq {
Id string `json:"id,optional"`
Name string `json:"name"`
Brief string `json:"brief,optional"`
Status string `json:"status,optional"`
Style PersonaStylePublic `json:"style,optional"`
Guard PersonaGuardPublic `json:"guard,optional"`
Voice string `json:"voice,optional"`
Notes string `json:"notes,optional"`
}
PersonaIdPath {
Id string `path:"id"`
}
PersonaActiveData {
Id string `json:"id"`
}
PersonaSetActiveReq {
Id string `json:"id"`
}
PersonaAnalyzeTextReq {
Id string `path:"id"`
RawText string `json:"raw_text"`
SourceLabel string `json:"source_label,optional"`
}
PersonaAnalyzeAccountReq {
Id string `path:"id"`
Username string `json:"username"`
}
// --- Plays ---
ExternalTargetPublic {
Url string `json:"url"`
RawUrl string `json:"raw_url,optional"`
Shortcode string `json:"shortcode,optional"`
AuthorUsername string `json:"author_username,optional"`
TextPreview string `json:"text_preview,optional"`
MediaId string `json:"media_id,optional"`
ResolvedAt int64 `json:"resolved_at,optional"`
}
PlayStepPublic {
Id string `json:"id"`
SortOrder int `json:"sort_order"`
Kind string `json:"kind"`
AccountId string `json:"account_id"`
Text string `json:"text"`
DelayFromPreviousSec int `json:"delay_from_previous_sec"`
PersonaId string `json:"persona_id,optional"`
BrandId string `json:"brand_id,optional"`
ImageUrls []string `json:"image_urls,optional"`
}
PlayPublic {
Id string `json:"id"`
Title string `json:"title"`
Topic string `json:"topic"`
Status string `json:"status"`
LeadAccountId string `json:"lead_account_id"`
CastAccountIds []string `json:"cast_account_ids"`
TargetOwnPostId string `json:"target_own_post_id,optional"`
TargetExternal ExternalTargetPublic `json:"target_external,optional"`
Steps []PlayStepPublic `json:"steps"`
ScheduleStartAt int64 `json:"schedule_start_at"`
IntervalMinutes int `json:"interval_minutes,optional"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
PlayListData {
List []PlayPublic `json:"list"`
}
PlaySaveReq {
Id string `json:"id,optional"`
Title string `json:"title"`
Topic string `json:"topic,optional"`
Status string `json:"status,optional"`
LeadAccountId string `json:"lead_account_id"`
CastAccountIds []string `json:"cast_account_ids,optional"`
TargetOwnPostId string `json:"target_own_post_id,optional"`
TargetExternal ExternalTargetPublic `json:"target_external,optional"`
Steps []PlayStepPublic `json:"steps"`
ScheduleStartAt int64 `json:"schedule_start_at,optional"`
IntervalMinutes int `json:"interval_minutes,optional"`
}
PlayIdPath {
Id string `path:"id"`
}
PlayByPostReq {
OwnPostId string `form:"own_post_id"`
}
PlayByExternalReq {
Url string `form:"url"`
}
PlayResolveReq {
Url string `json:"url"`
}
// AI 產劇本一步(真 LLM互回串場共用
PlayGenerateStepReq {
PersonaId string `json:"persona_id,optional"`
Context string `json:"context"` // 主貼/上一則/目標文
Topic string `json:"topic,optional"`
SpeakerLabel string `json:"speaker_label,optional"`
IsLead bool `json:"is_lead,optional"`
// mode: reply預設| root串場主貼
Mode string `json:"mode,optional"`
}
PlayGenerateStepData {
Text string `json:"text"`
}
// 一次產完整劇本(背景 job
PlayGenerateScriptData {
JobId string `json:"job_id"`
Async bool `json:"async"`
}
// --- Outbox ---
OutboxStepPublic {
Id string `json:"id"`
StepId string `json:"step_id"`
SortOrder int `json:"sort_order"`
Kind string `json:"kind"`
AccountId string `json:"account_id"`
Text string `json:"text"`
Status string `json:"status"`
Error string `json:"error,optional"`
ScheduledAt int64 `json:"scheduled_at,optional"`
PublishedAt int64 `json:"published_at,optional"`
}
OutboxPublic {
Id string `json:"id"`
PlayId string `json:"play_id"`
Title string `json:"title"`
Status string `json:"status"`
Steps []OutboxStepPublic `json:"steps"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
OutboxListData {
List []OutboxPublic `json:"list"`
}
OutboxIdPath {
Id string `path:"id"`
}
OutboxRetryPath {
Id string `path:"id"`
StepId string `path:"stepId"`
}
// --- Compose ---
ComposeMimicReq {
SourceText string `json:"source_text"`
PersonaId string `json:"persona_id,optional"`
// 可選:從「我的貼文」結構分析帶過來的備註(鉤子/結構/可複製點)
StructureNotes string `json:"structure_notes,optional"`
}
// 仿寫改背景 Job立刻回 job_id完成後從 job.payload.result_text 取文
ComposeMimicData {
// 相容同步路徑才有Job 路徑為空
Text string `json:"text,optional"`
JobId string `json:"job_id,optional"`
Async bool `json:"async,optional"`
}
// 人設試產:依指紋真 LLM 產主貼 + 回文(可帶新聞話題)
ComposePersonaPreviewReq {
PersonaId string `json:"persona_id"`
// 手動話題;空則嘗試上網取新聞靈感
Topic string `json:"topic,optional"`
// 預設 true無 topic 時抓即時新聞標題當靈感
UseNews bool `json:"use_news,optional"`
}
ComposePersonaPreviewData {
Topic string `json:"topic"`
TopicSource string `json:"topic_source"` // news | manual | fallback
PostText string `json:"post_text"`
ReplyText string `json:"reply_text"`
Notes string `json:"notes,optional"`
}
ComposeViralReq {
Text string `json:"text"`
}
ViralAnalysisPublic {
Hooks string `json:"hooks"`
Structure string `json:"structure"`
Emotion string `json:"emotion"`
Summary string `json:"summary"`
Cta string `json:"cta,optional"`
Copyable string `json:"copyable,optional"`
Risks string `json:"risks,optional"`
}
ComposePublishReq {
AccountId string `json:"account_id"`
Text string `json:"text"`
Title string `json:"title,optional"`
ImageUrls []string `json:"image_urls,optional"`
ScheduleStartAt int64 `json:"schedule_start_at,optional"`
// Threads 話題標籤topic_tag150 字,可不加 #
TopicTag string `json:"topic_tag,optional"`
}
// --- Own posts ---
OwnPostReplyPublic {
Id string `json:"id"`
Username string `json:"username"`
Text string `json:"text"`
CreatedAt int64 `json:"created_at"`
LikeCount int `json:"like_count,optional"`
ReplyStatus string `json:"reply_status,optional"`
RepliedBy string `json:"replied_by,optional"`
RepliedAt int64 `json:"replied_at,optional"`
ParentReplyId string `json:"parent_reply_id,optional"`
IsMine bool `json:"is_mine,optional"`
}
OwnPostPublic {
Id string `json:"id"`
AccountId string `json:"account_id"`
MediaId string `json:"media_id,optional"`
Text string `json:"text"`
MediaType string `json:"media_type"`
MediaUrl string `json:"media_url,optional"`
ThumbnailUrl string `json:"thumbnail_url,optional"`
Permalink string `json:"permalink,optional"`
Shortcode string `json:"shortcode,optional"`
TopicTag string `json:"topic_tag,optional"`
LikeCount int `json:"like_count"`
ReplyCount int `json:"reply_count"`
RepostCount int `json:"repost_count"`
QuoteCount int `json:"quote_count"`
ViewCount int `json:"view_count"`
ShareCount int `json:"share_count"`
InsightsStatus string `json:"insights_status,optional"`
FormulaSummary string `json:"formula_summary,optional"`
Insight string `json:"insight,optional"`
FormulaDetail string `json:"formula_detail,optional"`
Replies []OwnPostReplyPublic `json:"replies"`
PublishedAt int64 `json:"published_at"`
}
OwnPostListData {
List []OwnPostPublic `json:"list"`
}
OwnPostListReq {
AccountId string `form:"account_id,optional"`
}
OwnPostSyncReq {
AccountId string `json:"account_id"`
}
OwnPostLastSyncedData {
LastSyncedAt int64 `json:"last_synced_at"`
}
OwnPostGenerateReplyReq {
PostId string `json:"post_id"`
ReplyId string `json:"reply_id,optional"`
PersonaId string `json:"persona_id,optional"`
}
OwnPostGenerateReplyData {
Text string `json:"text"`
}
OwnPostSendReplyReq {
PostId string `json:"post_id"`
ReplyId string `json:"reply_id,optional"`
Text string `json:"text"`
AccountId string `json:"account_id,optional"`
ImageUrls []string `json:"image_urls,optional"`
}
OwnPostAnalyzeReq {
PostId string `json:"post_id"`
}
// 點開貼文後再載留言(避免 sync 一次打爆 conversation API
OwnPostLoadRepliesReq {
PostId string `json:"post_id"`
}
// --- Mentions ---
MentionPublic {
Id string `json:"id"`
AccountId string `json:"account_id"`
MediaId string `json:"media_id,optional"`
FromUsername string `json:"from_username"`
Text string `json:"text"`
ContextSnippet string `json:"context_snippet"`
Permalink string `json:"permalink,optional"`
Status string `json:"status"`
DraftText string `json:"draft_text,optional"`
CreatedAt int64 `json:"created_at"`
}
MentionListData {
List []MentionPublic `json:"list"`
}
MentionListReq {
AccountId string `form:"account_id,optional"`
}
MentionSyncReq {
AccountId string `json:"account_id"`
}
MentionIdPath {
Id string `path:"id"`
}
MentionGenerateReq {
Id string `path:"id"`
PersonaId string `json:"persona_id,optional"`
}
MentionMarkRepliedReq {
Id string `path:"id"`
Text string `json:"text,optional"`
ImageUrls []string `json:"image_urls,optional"`
}
)
@server (
jwt: Auth
group: personas
prefix: /api/v1/personas
middleware: AuthJWT
)
service gateway {
@handler ListPersonas
get / returns (PersonaListData)
@handler GetActivePersona
get /active returns (PersonaActiveData)
@handler SetActivePersona
post /active (PersonaSetActiveReq) returns (PersonaActiveData)
@handler GetPersona
get /:id (PersonaIdPath) returns (PersonaPublic)
@handler SavePersona
post / (PersonaSaveReq) returns (PersonaPublic)
@handler RemovePersona
delete /:id (PersonaIdPath) returns (OkData)
@handler AnalyzePersonaText
post /:id/analyze-text (PersonaAnalyzeTextReq) returns (PersonaPublic)
@handler AnalyzePersonaAccount
post /:id/analyze-account (PersonaAnalyzeAccountReq) returns (PersonaPublic)
}
@server (
jwt: Auth
group: plays
prefix: /api/v1/plays
middleware: AuthJWT
)
service gateway {
@handler ListPlays
get / returns (PlayListData)
@handler ListPlaysByPost
get /by-post (PlayByPostReq) returns (PlayListData)
@handler ListPlaysByExternal
get /by-external (PlayByExternalReq) returns (PlayListData)
@handler ResolveExternalLink
post /resolve-external (PlayResolveReq) returns (ExternalTargetPublic)
@handler GetPlay
get /:id (PlayIdPath) returns (PlayPublic)
@handler SavePlay
post / (PlaySaveReq) returns (PlayPublic)
@handler RemovePlay
delete /:id (PlayIdPath) returns (OkData)
@handler SubmitPlay
post /:id/submit (PlayIdPath) returns (OutboxPublic)
@handler GeneratePlayStep
post /generate-step (PlayGenerateStepReq) returns (PlayGenerateStepData)
@handler GeneratePlayScript
post /:id/generate-script (PlayIdPath) returns (PlayGenerateScriptData)
}
@server (
jwt: Auth
group: outbox
prefix: /api/v1/outbox
middleware: AuthJWT
)
service gateway {
@handler ListOutbox
get / returns (OutboxListData)
@handler GetOutbox
get /:id (OutboxIdPath) returns (OutboxPublic)
@handler RemoveOutbox
delete /:id (OutboxIdPath) returns (OkData)
@handler RetryOutboxStep
post /:id/steps/:stepId/retry (OutboxRetryPath) returns (OutboxPublic)
}
@server (
jwt: Auth
group: compose
prefix: /api/v1/compose
middleware: AuthJWT
)
service gateway {
@handler ComposeMimic
post /mimic (ComposeMimicReq) returns (ComposeMimicData)
@handler ComposePersonaPreview
post /persona-preview (ComposePersonaPreviewReq) returns (ComposePersonaPreviewData)
@handler ComposeAnalyzeViral
post /analyze-viral (ComposeViralReq) returns (ViralAnalysisPublic)
@handler ComposePublishSingle
post /publish-single (ComposePublishReq) returns (OutboxPublic)
}
@server (
jwt: Auth
group: ownposts
prefix: /api/v1/own-posts
middleware: AuthJWT
)
service gateway {
@handler ListOwnPosts
get / (OwnPostListReq) returns (OwnPostListData)
@handler OwnPostsLastSynced
get /last-synced-at returns (OwnPostLastSyncedData)
@handler SyncOwnPosts
post /sync (OwnPostSyncReq) returns (OwnPostListData)
@handler OwnPostGenerateReply
post /generate-reply (OwnPostGenerateReplyReq) returns (OwnPostGenerateReplyData)
@handler OwnPostSendReply
post /send-reply (OwnPostSendReplyReq) returns (OwnPostPublic)
@handler OwnPostAnalyze
post /analyze (OwnPostAnalyzeReq) returns (OwnPostPublic)
@handler OwnPostLoadReplies
post /load-replies (OwnPostLoadRepliesReq) returns (OwnPostPublic)
}
@server (
jwt: Auth
group: mentions
prefix: /api/v1/mentions
middleware: AuthJWT
)
service gateway {
@handler ListMentions
get / (MentionListReq) returns (MentionListData)
@handler SyncMentions
post /sync (MentionSyncReq) returns (MentionListData)
@handler MentionGenerateReply
post /:id/generate-reply (MentionGenerateReq) returns (MentionPublic)
@handler MentionMarkReplied
post /:id/mark-replied (MentionMarkRepliedReq) returns (MentionPublic)
@handler MentionSkip
post /:id/skip (MentionIdPath) returns (MentionPublic)
}

View File

@ -1,97 +0,0 @@
syntax = "v1"
type (
ThreadsAccountPublic {
Id string `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Connection string `json:"connection"` // connected|error|disconnected
IsUsable bool `json:"is_usable"`
AvatarColor string `json:"avatar_color,optional"`
AvatarUrl string `json:"avatar_url,optional"`
ErrorMessage string `json:"error_message,optional"`
SessionExpiresAt int64 `json:"session_expires_at,optional"`
SessionRefreshedAt int64 `json:"session_refreshed_at,optional"`
// never expose access_token / refresh_token
}
ThreadsAccountListData {
List []ThreadsAccountPublic `json:"list"`
}
ThreadsOAuthStartData {
AuthorizeUrl string `json:"authorize_url"`
State string `json:"state"`
}
// Callback is query-string from Meta / fake provider (public).
ThreadsOAuthCallbackReq {
Code string `form:"code,optional"`
State string `form:"state,optional"`
Error string `form:"error,optional"`
ErrorReason string `form:"error_reason,optional"`
ErrorDescription string `form:"error_description,optional"`
}
ThreadsOAuthCallbackData {
Ok bool `json:"ok"`
Message string `json:"message,optional"`
// RedirectURL for custom handler (browser flow)
RedirectUrl string `json:"redirect_url,optional"`
}
ThreadsAccountIdPath {
Id string `path:"id"`
}
// 擴充(含舊版)匯入 Chrome storageState寫入 scout crawler-session 供 dev 模式海巡
ImportThreadsAccountSessionReq {
// path id + body
Id string `path:"id"`
StorageState string `json:"storageState"`
}
ImportThreadsAccountSessionData {
Success bool `json:"success"`
Valid bool `json:"valid"`
Synced bool `json:"synced"`
AccountId string `json:"account_id"`
Username string `json:"username,optional"`
Message string `json:"message"`
UpdateAt int64 `json:"update_at,optional"`
}
)
// Public OAuth callback (no JWT)
@server (
group: threads
prefix: /api/v1/threads-accounts
)
service gateway {
@handler ThreadsOAuthCallback
get /oauth/callback (ThreadsOAuthCallbackReq) returns (ThreadsOAuthCallbackData)
}
@server (
jwt: Auth
group: threads
prefix: /api/v1/threads-accounts
middleware: AuthJWT
)
service gateway {
@handler ThreadsOAuthStart
get /oauth/start returns (ThreadsOAuthStartData)
@handler ListThreadsAccounts
get / returns (ThreadsAccountListData)
@handler RefreshThreadsAccount
post /:id/refresh (ThreadsAccountIdPath) returns (ThreadsAccountPublic)
// Chrome 擴充:匯入 Playwright storageState舊路徑相容實際存 crawler session
@handler ImportThreadsAccountSession
post /:id/session/import (ImportThreadsAccountSessionReq) returns (ImportThreadsAccountSessionData)
@handler DisconnectThreadsAccount
delete /:id (ThreadsAccountIdPath) returns (OkData)
}

View File

@ -1,192 +0,0 @@
syntax = "v1"
type (
UsageEventPublic {
Id string `json:"id"`
Uid int64 `json:"uid"`
Meter string `json:"meter"`
Credits int `json:"credits"`
KeyMode string `json:"key_mode"`
Label string `json:"label"`
Source string `json:"source"`
CreatedAt int64 `json:"created_at"`
}
UsageMeterCount {
Count int `json:"count"`
Credits int `json:"credits"`
}
UsagePlatformBlock {
CreditsUsed int `json:"credits_used"`
CreditsRemaining int `json:"credits_remaining"`
CreditsTotal int `json:"credits_total"`
CallCount int `json:"call_count"`
ByMeter map[string]UsageMeterCount `json:"by_meter,optional"`
}
UsageByokBlock {
CallCount int `json:"call_count"`
ByMeter map[string]UsageMeterCount `json:"by_meter,optional"`
}
UsageSummaryData {
MonthKey string `json:"month_key"`
Uid int64 `json:"uid"`
PlanId string `json:"plan_id"`
Unlimited bool `json:"unlimited"`
Platform UsagePlatformBlock `json:"platform"`
Byok UsageByokBlock `json:"byok"`
// FE progress bar (platform only)
TotalCredits int `json:"total_credits"`
RemainingCredits int `json:"remaining_credits"`
}
UsageSummaryReq {
MonthKey string `form:"month_key,optional"`
}
UsageEventsReq {
MonthKey string `form:"month_key,optional"`
KeyMode string `form:"key_mode,optional"` // platform|byok|all
Limit int `form:"limit,optional"`
}
UsageEventsData {
List []UsageEventPublic `json:"list"`
}
UsagePrefsData {
PlanId string `json:"plan_id"`
Unlimited bool `json:"unlimited"`
}
UsageSetPrefsReq {
Uid string `json:"uid"`
PlanId string `json:"plan_id,optional"`
Unlimited *bool `json:"unlimited,optional"`
}
UsagePurchaseReq {
PlanId string `json:"plan_id"`
MockRef string `json:"mock_ref,optional"`
}
UsagePurchasePublic {
Id string `json:"id"`
PlanId string `json:"plan_id"`
MockRef string `json:"mock_ref,optional"`
CreatedAt int64 `json:"created_at"`
}
UsagePurchasesData {
List []UsagePurchasePublic `json:"list"`
}
UsageTenantRow {
Uid int64 `json:"uid"`
PlanId string `json:"plan_id"`
Unlimited bool `json:"unlimited"`
PlatformCreditsUsed int `json:"platform_credits_used"`
ByokCallCount int `json:"byok_call_count"`
}
UsageTenantSummaryData {
MonthKey string `json:"month_key"`
PlatformCreditsUsed int `json:"platform_credits_used"`
ByokCallCount int `json:"byok_call_count"`
Members []UsageTenantRow `json:"members"`
}
UsageTenantSummaryReq {
MonthKey string `form:"month_key,optional"`
}
UsageTenantAnalyticsReq {
Granularity string `form:"granularity"` // day|month|year
From string `form:"from"` // inclusive YYYY-MM-DD UTC
To string `form:"to"` // inclusive YYYY-MM-DD UTC
}
UsageTenantAnalyticsBucket {
Key string `json:"key"`
Label string `json:"label"`
PurchasedCredits int `json:"purchased_credits"`
ConsumedCredits int `json:"consumed_credits"`
AiCalls int `json:"ai_calls"`
SearchCalls int `json:"search_calls"`
ByokCallCount int `json:"byok_call_count"`
}
UsageTenantAnalyticsMember {
Uid int64 `json:"uid"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
Unlimited bool `json:"unlimited"`
PlanId string `json:"plan_id"`
PurchasedCredits int `json:"purchased_credits"`
TotalCredits int `json:"total_credits"`
AiCalls int `json:"ai_calls"`
SearchCalls int `json:"search_calls"`
ByokCallCount int `json:"byok_call_count"`
RemainingCredits int `json:"remaining_credits"`
Pct int `json:"pct"`
}
UsageTenantAnalyticsData {
Granularity string `json:"granularity"`
From string `json:"from"`
To string `json:"to"`
RangeLabel string `json:"range_label"`
PurchasedCredits int `json:"purchased_credits"`
ConsumedCredits int `json:"consumed_credits"`
RemainingCredits int `json:"remaining_credits"`
Pct int `json:"pct"`
AiCalls int `json:"ai_calls"`
SearchCalls int `json:"search_calls"`
ByMeter map[string]UsageMeterCount `json:"by_meter"`
Byok UsageByokBlock `json:"byok"`
Series []UsageTenantAnalyticsBucket `json:"series"`
Members []UsageTenantAnalyticsMember `json:"members"`
}
)
@server (
jwt: Auth
group: usage
prefix: /api/v1/usage
middleware: AuthJWT
)
service gateway {
@handler GetUsageSummary
get /summary (UsageSummaryReq) returns (UsageSummaryData)
@handler ListUsageEvents
get /events (UsageEventsReq) returns (UsageEventsData)
@handler GetUsagePrefs
get /prefs returns (UsagePrefsData)
@handler PurchasePlan
post /purchase (UsagePurchaseReq) returns (UsagePurchasePublic)
@handler ListMyPurchases
get /purchases returns (UsagePurchasesData)
}
@server (
jwt: Auth
group: usage
prefix: /api/v1/usage
middleware: AuthJWT,AdminAuth
)
service gateway {
@handler SetUsagePrefs
post /prefs (UsageSetPrefsReq) returns (UsagePrefsData)
@handler GetTenantUsageSummary
get /tenant-summary (UsageTenantSummaryReq) returns (UsageTenantSummaryData)
@handler GetTenantUsageAnalytics
get /tenant-analytics (UsageTenantAnalyticsReq) returns (UsageTenantAnalyticsData)
}

View File

@ -1,64 +0,0 @@
# Database migrationsgolang-migrate + MongoDB
**第一性原理:** indexseed 用 **檔案** 宣告,交給 `migrate`;沒有 Go seederinit 程式。
工具:[golang-migrate](https://github.com/golang-migrate/migrate)
Mongo driver 讀的是 **JSON**`db.runCommand` 陣列),不是 shell `.js`
## 目錄
```text
generate/database/mongo/
000001_members_indexes.up.json
000001_members_indexes.down.json
000002_jobs_indexes.up.json
...
000003_seed_admin.up.json # 可選種子
```
命名:`{version}_{name}.up.json` / `.down.json`
## 指令
```bash
# 安裝 CLI一次
go install -tags 'mongodb' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
# 連線字串要含 database 名
export MONGO_URL='mongodb://127.0.0.1:27017/haixun'
# 套用全部
migrate -path generate/database/mongo -database "$MONGO_URL" up
# 回滾一步
migrate -path generate/database/mongo -database "$MONGO_URL" down 1
# 看版本
migrate -path generate/database/mongo -database "$MONGO_URL" version
```
Makefile
```bash
make migrate-up
make migrate-down
```
## 何時寫什麼
| 類型 | 放哪 | 範例 |
|------|------|------|
| Index / collection 約束 | `*.up.json` createIndexes | members.uid unique |
| 種子資料 | 不放 migration | 使用 `cmd/seeder` 與 runtime secret |
| 業務邏輯、密碼 runtime 產生 | **不要**寫進 migration | 用 API / 測試 helper |
Migration 不建立任何預設帳號。首次管理員必須透過 `make seeder`,並由環境提供強密碼。
## 與 gateway
`make migrate-up`,再 `go run . -f etc/gateway.yaml`(需 Mongo + Redis
## 不要
- 在 migration 寫死帳號或密碼
- 手寫 migration runner
- 把 stand-alone 電商 `.txt` 當 migrate 來源(格式不同;要用 JSON runCommand

View File

@ -1,22 +0,0 @@
[
{
"dropIndexes": "members",
"index": "uid_unique"
},
{
"dropIndexes": "members",
"index": "email_unique"
},
{
"dropIndexes": "members",
"index": "invite_code_1"
},
{
"dropIndexes": "members",
"index": "status_roles"
},
{
"dropIndexes": "member_settings",
"index": "settings_uid_unique"
}
]

View File

@ -1,45 +0,0 @@
[
{
"createIndexes": "members",
"indexes": [
{
"key": { "uid": 1 },
"name": "uid_unique",
"unique": true
},
{
"key": { "email": 1 },
"name": "email_unique",
"unique": true
},
{
"key": { "invite_code": 1 },
"name": "invite_code_1",
"sparse": true
},
{
"key": { "status": 1, "roles": 1 },
"name": "status_roles"
}
]
},
{
"createIndexes": "member_settings",
"indexes": [
{
"key": { "uid": 1 },
"name": "settings_uid_unique",
"unique": true
}
]
},
{
"createIndexes": "counters",
"indexes": [
{
"key": { "_id": 1 },
"name": "_id_"
}
]
}
]

View File

@ -1,10 +0,0 @@
[
{
"dropIndexes": "jobs",
"index": "owner_created"
},
{
"dropIndexes": "jobs",
"index": "status_1"
}
]

View File

@ -1,15 +0,0 @@
[
{
"createIndexes": "jobs",
"indexes": [
{
"key": { "owner_uid": 1, "created_at": -1 },
"name": "owner_created"
},
{
"key": { "status": 1 },
"name": "status_1"
}
]
}
]

View File

@ -1,12 +0,0 @@
[
{
"update": "counters",
"updates": [
{
"q": { "_id": "member_uid" },
"u": { "$setOnInsert": { "seq": 1000000 } },
"upsert": true
}
]
}
]

View File

@ -1,14 +0,0 @@
[
{
"dropIndexes": "refresh_tokens",
"index": "refresh_uid_1"
},
{
"dropIndexes": "refresh_tokens",
"index": "refresh_ttl"
},
{
"dropIndexes": "auth_codes",
"index": "auth_code_ttl"
}
]

View File

@ -1,26 +0,0 @@
[
{
"createIndexes": "refresh_tokens",
"indexes": [
{
"key": { "uid": 1 },
"name": "refresh_uid_1"
},
{
"key": { "expires_at": 1 },
"name": "refresh_ttl",
"expireAfterSeconds": 0
}
]
},
{
"createIndexes": "auth_codes",
"indexes": [
{
"key": { "expires_at": 1 },
"name": "auth_code_ttl",
"expireAfterSeconds": 0
}
]
}
]

View File

@ -1,18 +0,0 @@
[
{
"dropIndexes": "identities",
"index": "login_platform_unique"
},
{
"dropIndexes": "identities",
"index": "identities_uid_1"
},
{
"dropIndexes": "identities",
"index": "platform_account_type"
},
{
"dropIndexes": "members",
"index": "phone_1"
}
]

View File

@ -1,30 +0,0 @@
[
{
"createIndexes": "identities",
"indexes": [
{
"key": { "login_id": 1, "platform": 1 },
"name": "login_platform_unique",
"unique": true
},
{
"key": { "uid": 1 },
"name": "identities_uid_1"
},
{
"key": { "platform": 1, "account_type": 1 },
"name": "platform_account_type"
}
]
},
{
"createIndexes": "members",
"indexes": [
{
"key": { "phone": 1 },
"name": "phone_1",
"sparse": true
}
]
}
]

View File

@ -1,22 +0,0 @@
[
{
"update": "members",
"updates": [
{
"q": { "uid": 1000000, "email": "admin@haixun.local" },
"u": { "$set": { "uid": 1 } },
"multi": false
}
]
},
{
"update": "identities",
"updates": [
{
"q": { "uid": 1000000, "login_id": "admin@haixun.local" },
"u": { "$set": { "uid": 1 } },
"multi": true
}
]
}
]

View File

@ -1,57 +0,0 @@
[
{
"update": "counters",
"updates": [
{
"q": { "_id": "member_uid", "seq": { "$lt": 1000000 } },
"u": { "$set": { "seq": 1000000 } },
"upsert": false
},
{
"q": { "_id": "member_uid" },
"u": { "$setOnInsert": { "seq": 1000000 } },
"upsert": true
}
]
},
{
"update": "members",
"updates": [
{
"q": { "uid": 1, "email": "admin@haixun.local" },
"u": { "$set": { "uid": 1000000 } },
"multi": false
}
]
},
{
"update": "identities",
"updates": [
{
"q": { "uid": 1, "login_id": "admin@haixun.local" },
"u": { "$set": { "uid": 1000000 } },
"multi": true
}
]
},
{
"update": "member_settings",
"updates": [
{
"q": { "uid": 1 },
"u": { "$set": { "uid": 1000000 } },
"multi": false
}
]
},
{
"update": "refresh_tokens",
"updates": [
{
"q": { "uid": 1 },
"u": { "$set": { "uid": 1000000 } },
"multi": true
}
]
}
]

View File

@ -1,6 +0,0 @@
[
{ "dropIndexes": "jobs", "index": "claim_due_jobs" },
{ "dropIndexes": "jobs", "index": "purge_terminal_jobs" },
{ "dropIndexes": "studio_outbox", "index": "worker_outbox_status_updated" },
{ "dropIndexes": "studio_outbox", "index": "owner_outbox_updated" }
]

View File

@ -1,28 +0,0 @@
[
{
"createIndexes": "jobs",
"indexes": [
{
"key": { "status": 1, "run_after": 1, "created_at": 1 },
"name": "claim_due_jobs"
},
{
"key": { "status": 1, "completed_at": 1 },
"name": "purge_terminal_jobs"
}
]
},
{
"createIndexes": "studio_outbox",
"indexes": [
{
"key": { "status": 1, "updated_at": 1 },
"name": "worker_outbox_status_updated"
},
{
"key": { "owner_uid": 1, "updated_at": -1 },
"name": "owner_outbox_updated"
}
]
}
]

View File

@ -1,5 +0,0 @@
[
{ "dropIndexes": "usage_monthly_counters", "index": "usage_counter_uid_month" },
{ "dropIndexes": "usage_events", "index": "usage_events_member_month_mode" },
{ "dropIndexes": "usage_events", "index": "usage_events_month" }
]

View File

@ -1,25 +0,0 @@
[
{
"createIndexes": "usage_monthly_counters",
"indexes": [
{
"key": { "uid": 1, "month_key": 1 },
"name": "usage_counter_uid_month",
"unique": true
}
]
},
{
"createIndexes": "usage_events",
"indexes": [
{
"key": { "uid": 1, "month_key": 1, "key_mode": 1, "created_at": -1 },
"name": "usage_events_member_month_mode"
},
{
"key": { "month_key": 1, "created_at": -1 },
"name": "usage_events_month"
}
]
}
]

View File

@ -1,3 +0,0 @@
[
{ "dropIndexes": "studio_outbox", "index": "claim_due_outbox_steps" }
]

View File

@ -1,11 +0,0 @@
[
{
"createIndexes": "studio_outbox",
"indexes": [
{
"key": { "steps.status": 1, "steps.scheduled_at": 1, "steps.lease_expires_at": 1 },
"name": "claim_due_outbox_steps"
}
]
}
]

View File

@ -1,11 +0,0 @@
[
{ "dropIndexes": "usage_prefs", "index": "unique_usage_prefs_uid" },
{ "dropIndexes": "usage_prefs", "index": "unique_stripe_customer" },
{ "dropIndexes": "usage_prefs", "index": "unique_stripe_subscription" },
{ "dropIndexes": "billing_checkout_attempts", "index": "unique_checkout_request" },
{ "dropIndexes": "billing_checkout_attempts", "index": "unique_checkout_session" },
{ "dropIndexes": "billing_webhook_events", "index": "stripe_webhook_event_id" },
{ "dropIndexes": "usage_purchases", "index": "unique_purchase_stripe_event" },
{ "dropIndexes": "usage_purchases", "index": "purchase_stripe_session" },
{ "dropIndexes": "usage_purchases", "index": "purchase_stripe_subscription" }
]

View File

@ -1,31 +0,0 @@
[
{
"createIndexes": "usage_prefs",
"indexes": [
{ "key": { "uid": 1 }, "name": "unique_usage_prefs_uid", "unique": true },
{ "key": { "stripe_customer_id": 1 }, "name": "unique_stripe_customer", "unique": true, "sparse": true },
{ "key": { "stripe_subscription_id": 1 }, "name": "unique_stripe_subscription", "unique": true, "sparse": true }
]
},
{
"createIndexes": "billing_checkout_attempts",
"indexes": [
{ "key": { "uid": 1, "request_id": 1 }, "name": "unique_checkout_request", "unique": true },
{ "key": { "session_id": 1 }, "name": "unique_checkout_session", "unique": true, "sparse": true }
]
},
{
"createIndexes": "billing_webhook_events",
"indexes": [
{ "key": { "stripe_event_id": 1 }, "name": "stripe_webhook_event_id", "unique": true }
]
},
{
"createIndexes": "usage_purchases",
"indexes": [
{ "key": { "stripe_event_id": 1 }, "name": "unique_purchase_stripe_event", "unique": true, "sparse": true },
{ "key": { "stripe_session_id": 1 }, "name": "purchase_stripe_session", "sparse": true },
{ "key": { "stripe_subscription_id": 1 }, "name": "purchase_stripe_subscription", "sparse": true }
]
}
]

View File

@ -1,97 +0,0 @@
module apps/backend
go 1.25.0
require (
github.com/aws/aws-sdk-go-v2 v1.42.1
github.com/aws/aws-sdk-go-v2/credentials v1.19.28
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/matcornic/hermes/v2 v2.1.0
github.com/stretchr/testify v1.10.0
github.com/stripe/stripe-go/v82 v82.5.1
github.com/zeromicro/go-zero v1.7.6
go.mongodb.org/mongo-driver v1.17.1
golang.org/x/crypto v0.54.0
)
require (
github.com/Masterminds/semver v1.4.2 // indirect
github.com/Masterminds/sprig v2.16.0+incompatible // indirect
github.com/PuerkitoBio/goquery v1.5.0 // indirect
github.com/andybalholm/cascadia v1.0.0 // indirect
github.com/aokoli/goutils v1.0.1 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 // indirect
github.com/aws/smithy-go v1.27.3 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.1 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect
github.com/huandu/xstrings v1.2.0 // indirect
github.com/imdario/mergo v0.3.6 // indirect
github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/openzipkin/zipkin-go v0.4.3 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.20.5 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/redis/go-redis/v9 v9.7.0 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/russross/blackfriday/v2 v2.0.1 // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
github.com/vanng822/css v0.0.0-20190504095207-a21e860bcd04 // indirect
github.com/vanng822/go-premailer v0.0.0-20191214114701-be27abe028fe // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.1.2 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.opentelemetry.io/otel v1.24.0 // indirect
go.opentelemetry.io/otel/exporters/jaeger v1.17.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.24.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.24.0 // indirect
go.opentelemetry.io/otel/exporters/zipkin v1.24.0 // indirect
go.opentelemetry.io/otel/metric v1.24.0 // indirect
go.opentelemetry.io/otel/sdk v1.24.0 // indirect
go.opentelemetry.io/otel/trace v1.24.0 // indirect
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 // indirect
google.golang.org/grpc v1.65.0 // indirect
google.golang.org/protobuf v1.36.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@ -1,458 +0,0 @@
package config
import (
"fmt"
"net/url"
"os"
"strings"
"apps/backend/internal/lib/secretbox"
"github.com/zeromicro/go-zero/core/stores/cache"
"github.com/zeromicro/go-zero/rest"
)
// Config — go-zero 風格RestConf + CacheRedis + Mongo URI.
type Config struct {
rest.RestConf
// CacheRedis is go-zero cache.CacheConf (feeds monc Redis 快取).
CacheRedis cache.CacheConf
Redis struct {
Namespace string `json:",optional,default=haixun:dev:v1"`
AIModelCacheFingerprintSecret string `json:",optional"`
}
Mongo struct {
URI string
Database string
}
Auth struct {
AccessSecret string
AccessExpire int64
RefreshSecret string
RefreshExpire int64
}
// MemberSettingsEncryption is dedicated to member BYOK secrets. Never reuse
// Auth or Scout secrets here.
MemberSettingsEncryption struct {
CurrentKeyID string `json:",optional"`
Key string `json:",optional"`
}
// CORSAllowedOrigins is an explicit browser-origin allowlist.
CORSAllowedOrigins []string `json:",optional"`
Platform struct {
// AIKey — legacy alias for XAIKey
AIKey string `json:",optional"`
// XAIKey — platform BYOK for xAI (api.x.ai)
XAIKey string `json:",optional"`
// OpenCodeKey — platform BYOK for OpenCode Go (opencode.ai/zen/go)
OpenCodeKey string `json:",optional"`
ExaKey string `json:",optional"`
ThreadsAppId string `json:",optional"`
ThreadsAppSecret string `json:",optional"`
}
// Scout keeps browser-session credentials separate from JWT and public APIs.
Scout struct {
SessionSecret string `json:",optional"`
CrawlerEndpoint string `json:",optional"`
CrawlerToken string `json:",optional"`
}
Worker struct {
ID string `json:",default=worker-1"`
PollIntervalMs int `json:",default=2000"`
}
// Seed is used only by cmd/seeder; gateway and worker never read or log it.
Seed struct {
AdminPassword string `json:",optional"`
}
// Bcrypt cost (stand-alone Bcrypt.Cost)
Bcrypt struct {
Cost int `json:",default=10"`
}
// OAuth third-party. Empty provider config disables that provider.
OAuth struct {
GoogleClientID string `json:",optional"`
LineClientID string `json:",optional"`
LineClientSecret string `json:",optional"`
LineRedirectURI string `json:",optional"`
}
Stripe struct {
Enabled bool `json:",optional"`
SecretKey string `json:",optional"`
WebhookSecret string `json:",optional"`
StarterPriceID string `json:",optional"`
ProPriceID string `json:",optional"`
SuccessURL string `json:",optional"`
CancelURL string `json:",optional"`
PortalReturnURL string `json:",optional"`
}
// Mail — 驗證碼重設密碼寄信SMTP空 Host 則只打 log + 可回 mock_code
Mail struct {
Sender string `json:",optional"` // e.g. "Harbor Desk <noreply@example.com>"
SMTP struct {
Host string `json:",optional"`
Port int `json:",default=587"`
User string `json:",optional"`
Password string `json:",optional"`
} `json:",optional"`
// DevExposeCode: API 回 mock_code本機沒信箱時用。正式請 false。
DevExposeCode bool `json:",default=true"`
} `json:",optional"`
// PublicWebBase — 前端 origin用於重設密碼信內連結logo勿尾斜線
// 例http://127.0.0.1:5173 或 https://threads-tool-dev.30cm.net
PublicWebBase string `json:",optional,default=http://127.0.0.1:5173"`
// PublicAPIBase — 對外 API originThreads OAuth redirect_uri 必須 https 公開網址)
// 空則:若 PublicWebBase 為 https 則同 host否則回落 http://127.0.0.1:Port
// 例https://threads-tool-dev.30cm.net
PublicAPIBase string `json:",optional"`
// Brand — 郵件 chromehermesLogoURL 空則用 PublicWebBase + /brand-mark.jpg
Brand struct {
Name string `json:",optional"`
LogoURL string `json:",optional"` // absolute URL
Copyright string `json:",optional"`
} `json:",optional"`
// ObjectStorage — 頭像等二進位;**僅 MinIO / S3**(不落本機磁碟)
ObjectStorage struct {
Endpoint string `json:",optional"` // 必填e.g. http://127.0.0.1:9000
Region string `json:",optional,default=us-east-1"`
Bucket string `json:",optional,default=haixun-assets"`
AccessKey string `json:",optional"`
SecretKey string `json:",optional"`
UsePathStyle bool `json:",optional,default=true"`
PublicBaseURL string `json:",optional"` // 公開讀取前綴,勿尾斜線
} `json:",optional"`
}
// ApplyEnv overlays secrets from environment.
func (c *Config) ApplyEnv() {
if strings.TrimSpace(c.Redis.Namespace) == "" {
c.Redis.Namespace = "haixun:dev:v1"
}
if c.Brand.Name == "" {
c.Brand.Name = "Harbor Desk"
}
if c.Brand.Copyright == "" {
c.Brand.Copyright = "© Harbor Desk"
}
if v := os.Getenv("MONGO_URI"); v != "" {
c.Mongo.URI = v
}
if v := os.Getenv("MONGO_DATABASE"); v != "" {
c.Mongo.Database = v
}
if v := os.Getenv("REDIS_HOST"); v != "" && len(c.CacheRedis) > 0 {
c.CacheRedis[0].RedisConf.Host = v
}
if len(c.CacheRedis) > 0 {
// monc 快取 Redis 帳密(本機 infra 常開 requirepass
if v := os.Getenv("REDIS_PASSWORD"); v != "" {
c.CacheRedis[0].RedisConf.Pass = v
} else if v := os.Getenv("HAIXUN_REDIS_PASSWORD"); v != "" {
c.CacheRedis[0].RedisConf.Pass = v
}
if v := os.Getenv("HAIXUN_REDIS_ADDR"); v != "" {
c.CacheRedis[0].RedisConf.Host = v
}
}
if v := firstEnv("REDIS_NAMESPACE", "HAIXUN_REDIS_NAMESPACE"); v != "" {
c.Redis.Namespace = strings.Trim(v, ":")
}
if v := firstEnv("AI_MODEL_CACHE_FINGERPRINT_SECRET", "HAIXUN_AI_MODEL_CACHE_FINGERPRINT_SECRET"); v != "" {
c.Redis.AIModelCacheFingerprintSecret = v
}
if v := os.Getenv("AUTH_ACCESS_SECRET"); v != "" {
c.Auth.AccessSecret = v
}
if v := os.Getenv("AUTH_REFRESH_SECRET"); v != "" {
c.Auth.RefreshSecret = v
}
if v := os.Getenv("STRIPE_ENABLED"); v != "" {
c.Stripe.Enabled = v == "1" || strings.EqualFold(v, "true")
}
if v := os.Getenv("STRIPE_SECRET_KEY"); v != "" {
c.Stripe.SecretKey = v
}
if v := os.Getenv("STRIPE_WEBHOOK_SECRET"); v != "" {
c.Stripe.WebhookSecret = v
}
if v := os.Getenv("STRIPE_STARTER_PRICE_ID"); v != "" {
c.Stripe.StarterPriceID = v
}
if v := os.Getenv("STRIPE_PRO_PRICE_ID"); v != "" {
c.Stripe.ProPriceID = v
}
if v := os.Getenv("STRIPE_SUCCESS_URL"); v != "" {
c.Stripe.SuccessURL = v
}
if v := os.Getenv("STRIPE_CANCEL_URL"); v != "" {
c.Stripe.CancelURL = v
}
if v := os.Getenv("STRIPE_PORTAL_RETURN_URL"); v != "" {
c.Stripe.PortalReturnURL = v
}
if v := os.Getenv("MEMBER_SETTINGS_ENCRYPTION_KEY_ID"); v != "" {
c.MemberSettingsEncryption.CurrentKeyID = v
}
if v := os.Getenv("MEMBER_SETTINGS_ENCRYPTION_KEY"); v != "" {
c.MemberSettingsEncryption.Key = v
}
if v := os.Getenv("PLATFORM_AI_KEY"); v != "" {
c.Platform.AIKey = v
}
if v := firstEnv("PLATFORM_XAI_KEY", "XAI_API_KEY"); v != "" {
c.Platform.XAIKey = v
}
if v := firstEnv("PLATFORM_OPENCODE_KEY", "OPENCODE_API_KEY", "OPENCODE_GO_API_KEY"); v != "" {
c.Platform.OpenCodeKey = v
}
// legacy AIKey → XAI when XAI empty
if c.Platform.XAIKey == "" && c.Platform.AIKey != "" {
c.Platform.XAIKey = c.Platform.AIKey
}
if v := os.Getenv("PLATFORM_EXA_KEY"); v != "" {
c.Platform.ExaKey = v
}
if v := firstEnv("THREADS_APP_ID", "HAIXUN_THREADS_APP_ID"); v != "" {
c.Platform.ThreadsAppId = v
}
if v := firstEnv("THREADS_APP_SECRET", "HAIXUN_THREADS_APP_SECRET"); v != "" {
c.Platform.ThreadsAppSecret = v
}
if v := os.Getenv("SCOUT_SESSION_SECRET"); v != "" {
c.Scout.SessionSecret = v
}
if v := os.Getenv("SCOUT_CRAWLER_ENDPOINT"); v != "" {
c.Scout.CrawlerEndpoint = v
}
if v := os.Getenv("SCOUT_CRAWLER_TOKEN"); v != "" {
c.Scout.CrawlerToken = v
}
if v := os.Getenv("SEED_ADMIN_PASSWORD"); v != "" {
c.Seed.AdminPassword = v
}
if v := os.Getenv("WORKER_ID"); v != "" {
c.Worker.ID = v
}
if v := os.Getenv("PORT"); v != "" {
var p int
valid := true
for _, ch := range v {
if ch < '0' || ch > '9' {
valid = false
break
}
p = p*10 + int(ch-'0')
}
if valid && p > 0 {
c.Port = p
}
}
// Mail / SMTPMAIL_* 優先HAIXUN_SMTP_* 與 old/infra 相容)
if v := firstEnv("MAIL_SENDER", "HAIXUN_SMTP_FROM"); v != "" {
c.Mail.Sender = v
}
if v := firstEnv("MAIL_SMTP_HOST", "HAIXUN_SMTP_HOST"); v != "" {
c.Mail.SMTP.Host = v
}
if v := firstEnv("MAIL_SMTP_USER", "HAIXUN_SMTP_USERNAME"); v != "" {
c.Mail.SMTP.User = v
}
if v := firstEnv("MAIL_SMTP_PASSWORD", "HAIXUN_SMTP_PASSWORD"); v != "" {
c.Mail.SMTP.Password = v
}
if v := firstEnv("MAIL_SMTP_PORT", "HAIXUN_SMTP_PORT"); v != "" {
if p := parsePort(v); p > 0 {
c.Mail.SMTP.Port = p
}
}
if v := os.Getenv("MAIL_DEV_EXPOSE_CODE"); v == "0" || v == "false" || v == "FALSE" {
c.Mail.DevExposeCode = false
} else if v == "1" || v == "true" || v == "TRUE" {
c.Mail.DevExposeCode = true
}
if v := firstEnv("PUBLIC_WEB_BASE", "HAIXUN_PUBLIC_WEB_BASE"); v != "" {
c.PublicWebBase = strings.TrimRight(v, "/")
}
if v := firstEnv("PUBLIC_API_BASE", "HAIXUN_PUBLIC_API_BASE"); v != "" {
c.PublicAPIBase = strings.TrimRight(v, "/")
}
if v := os.Getenv("CORS_ALLOWED_ORIGINS"); v != "" {
c.CORSAllowedOrigins = appendUnique(c.CORSAllowedOrigins, splitCSV(v))
}
if v := os.Getenv("MAIL_BRAND_NAME"); v != "" {
c.Brand.Name = v
}
if v := firstEnv("MAIL_LOGO_URL", "HAIXUN_MAIL_LOGO_URL"); v != "" {
c.Brand.LogoURL = v
}
if v := os.Getenv("MAIL_COPYRIGHT"); v != "" {
c.Brand.Copyright = v
}
// Object storage (HAIXUN_STORAGE_S3_* 與 old/infra 相容)
if v := firstEnv("OBJECT_STORAGE_ENDPOINT", "HAIXUN_STORAGE_S3_ENDPOINT"); v != "" {
c.ObjectStorage.Endpoint = v
}
if v := firstEnv("OBJECT_STORAGE_REGION", "HAIXUN_STORAGE_S3_REGION"); v != "" {
c.ObjectStorage.Region = v
}
if v := firstEnv("OBJECT_STORAGE_BUCKET", "HAIXUN_STORAGE_S3_BUCKET"); v != "" {
c.ObjectStorage.Bucket = v
}
if v := firstEnv("OBJECT_STORAGE_ACCESS_KEY", "HAIXUN_STORAGE_S3_ACCESS_KEY", "MINIO_ROOT_USER"); v != "" {
c.ObjectStorage.AccessKey = v
}
if v := firstEnv("OBJECT_STORAGE_SECRET_KEY", "HAIXUN_STORAGE_S3_SECRET_KEY", "MINIO_ROOT_PASSWORD"); v != "" {
c.ObjectStorage.SecretKey = v
}
if v := firstEnv("OBJECT_STORAGE_PUBLIC_BASE_URL", "HAIXUN_STORAGE_S3_PUBLIC_BASE_URL"); v != "" {
c.ObjectStorage.PublicBaseURL = strings.TrimRight(v, "/")
}
if v := os.Getenv("HAIXUN_STORAGE_S3_USE_PATH_STYLE"); v == "0" || v == "false" {
c.ObjectStorage.UsePathStyle = false
} else if v == "1" || v == "true" {
c.ObjectStorage.UsePathStyle = true
}
}
// ValidateProductionDependencies prevents a successful-looking deployment that
// silently falls back to test credentials or fake external integrations.
func (c Config) ValidateProductionDependencies() error {
if invalidSecret(c.Auth.AccessSecret) {
return fmt.Errorf("AUTH_ACCESS_SECRET must be at least 32 bytes")
}
if invalidSecret(c.Auth.RefreshSecret) {
return fmt.Errorf("AUTH_REFRESH_SECRET must be at least 32 bytes")
}
if c.Auth.AccessSecret == c.Auth.RefreshSecret {
return fmt.Errorf("AUTH_ACCESS_SECRET and AUTH_REFRESH_SECRET must differ")
}
fingerprintSecret := strings.TrimSpace(c.Redis.AIModelCacheFingerprintSecret)
if invalidSecret(fingerprintSecret) {
return fmt.Errorf("AI_MODEL_CACHE_FINGERPRINT_SECRET must be at least 32 bytes")
}
if fingerprintSecret == strings.TrimSpace(c.Auth.AccessSecret) ||
fingerprintSecret == strings.TrimSpace(c.Auth.RefreshSecret) ||
fingerprintSecret == strings.TrimSpace(c.Scout.SessionSecret) ||
fingerprintSecret == strings.TrimSpace(c.MemberSettingsEncryption.Key) {
return fmt.Errorf("AI model cache fingerprint secret must be dedicated")
}
if namespace := strings.TrimSpace(c.Redis.Namespace); namespace == "" || strings.ContainsAny(namespace, "{} \t\r\n") {
return fmt.Errorf("Redis.Namespace must be non-empty and must not contain whitespace or braces")
}
if _, err := c.NewMemberSettingsCodec(); err != nil {
return fmt.Errorf("member settings encryption: %w", err)
}
encryptionKey := strings.TrimSpace(c.MemberSettingsEncryption.Key)
if encryptionKey == strings.TrimSpace(c.Auth.AccessSecret) ||
encryptionKey == strings.TrimSpace(c.Auth.RefreshSecret) ||
encryptionKey == strings.TrimSpace(c.Scout.SessionSecret) {
return fmt.Errorf("member settings encryption key must not reuse Auth or Scout secrets")
}
if len(c.CacheRedis) == 0 || strings.TrimSpace(c.CacheRedis[0].Host) == "" {
return fmt.Errorf("CacheRedis is required")
}
if strings.TrimSpace(c.Mongo.URI) == "" || strings.TrimSpace(c.Mongo.Database) == "" {
return fmt.Errorf("Mongo.URI and Mongo.Database are required")
}
if len(c.CORSAllowedOrigins) == 0 {
return fmt.Errorf("CORSAllowedOrigins is required")
}
if err := validateStripe(c.Stripe.Enabled, c.Stripe.SecretKey, c.Stripe.WebhookSecret, c.Stripe.StarterPriceID, c.Stripe.ProPriceID, c.Stripe.SuccessURL, c.Stripe.CancelURL, c.Stripe.PortalReturnURL); err != nil {
return err
}
return nil
}
// NewMemberSettingsCodec constructs the dedicated BYOK-at-rest codec.
func (c Config) NewMemberSettingsCodec() (*secretbox.Codec, error) {
return secretbox.New(c.MemberSettingsEncryption.CurrentKeyID, c.MemberSettingsEncryption.Key)
}
func invalidSecret(value string) bool {
value = strings.TrimSpace(value)
return len(value) < 32 || strings.Contains(value, "REPLACE_WITH") || strings.Contains(value, "CHANGE_ME")
}
func validateStripe(enabled bool, secretKey, webhookSecret, starterPriceID, proPriceID, successURL, cancelURL, portalReturnURL string) error {
if !enabled {
return nil
}
if !strings.HasPrefix(strings.TrimSpace(secretKey), "sk_") {
return fmt.Errorf("STRIPE_SECRET_KEY is required when Stripe is enabled")
}
if !strings.HasPrefix(strings.TrimSpace(webhookSecret), "whsec_") {
return fmt.Errorf("STRIPE_WEBHOOK_SECRET is required when Stripe is enabled")
}
starterPriceID = strings.TrimSpace(starterPriceID)
proPriceID = strings.TrimSpace(proPriceID)
if !strings.HasPrefix(starterPriceID, "price_") || !strings.HasPrefix(proPriceID, "price_") {
return fmt.Errorf("STRIPE_STARTER_PRICE_ID and STRIPE_PRO_PRICE_ID are required when Stripe is enabled")
}
if starterPriceID == proPriceID {
return fmt.Errorf("Stripe Starter and Pro Price IDs must differ")
}
if !strings.Contains(successURL, "{CHECKOUT_ID}") {
return fmt.Errorf("STRIPE_SUCCESS_URL must contain {CHECKOUT_ID}")
}
for name, value := range map[string]string{
"STRIPE_SUCCESS_URL": successURL,
"STRIPE_CANCEL_URL": cancelURL,
"STRIPE_PORTAL_RETURN_URL": portalReturnURL,
} {
parsed, err := url.ParseRequestURI(strings.TrimSpace(value))
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil {
return fmt.Errorf("%s must be an absolute HTTPS URL", name)
}
}
return nil
}
func firstEnv(keys ...string) string {
for _, k := range keys {
if v := os.Getenv(k); v != "" {
return v
}
}
return ""
}
func appendUnique(existing, values []string) []string {
seen := make(map[string]struct{}, len(existing)+len(values))
out := make([]string, 0, len(existing)+len(values))
for _, value := range append(existing, values...) {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
func splitCSV(v string) []string {
parts := strings.Split(v, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
if part = strings.TrimSpace(part); part != "" {
out = append(out, strings.TrimRight(part, "/"))
}
}
return out
}
func parsePort(v string) int {
var p int
for _, ch := range v {
if ch < '0' || ch > '9' {
return 0
}
p = p*10 + int(ch-'0')
}
return p
}

View File

@ -1,109 +0,0 @@
package config
import (
"strings"
"testing"
)
func TestApplyEnvRedisCacheSettings(t *testing.T) {
t.Setenv("REDIS_NAMESPACE", "tenant:prod:v2")
t.Setenv("HAIXUN_REDIS_NAMESPACE", "")
t.Setenv("AI_MODEL_CACHE_FINGERPRINT_SECRET", "dedicated-model-cache-secret-at-least-32-bytes")
t.Setenv("HAIXUN_AI_MODEL_CACHE_FINGERPRINT_SECRET", "")
var c Config
c.ApplyEnv()
if c.Redis.Namespace != "tenant:prod:v2" {
t.Fatalf("namespace=%q", c.Redis.Namespace)
}
if c.Redis.AIModelCacheFingerprintSecret != "dedicated-model-cache-secret-at-least-32-bytes" {
t.Fatalf("fingerprint secret environment override was not applied")
}
}
func TestApplyEnvDefaultsRedisNamespace(t *testing.T) {
t.Setenv("REDIS_NAMESPACE", "")
t.Setenv("HAIXUN_REDIS_NAMESPACE", "")
var c Config
c.ApplyEnv()
if c.Redis.Namespace != "haixun:dev:v1" {
t.Fatalf("namespace=%q", c.Redis.Namespace)
}
}
func TestApplyEnvStripeSettings(t *testing.T) {
t.Setenv("STRIPE_ENABLED", "true")
t.Setenv("STRIPE_SECRET_KEY", "sk_test")
t.Setenv("STRIPE_WEBHOOK_SECRET", "whsec_test")
t.Setenv("STRIPE_STARTER_PRICE_ID", "price_starter")
t.Setenv("STRIPE_PRO_PRICE_ID", "price_pro")
t.Setenv("STRIPE_SUCCESS_URL", "https://app.test/success")
t.Setenv("STRIPE_CANCEL_URL", "https://app.test/cancel")
t.Setenv("STRIPE_PORTAL_RETURN_URL", "https://app.test/billing")
var c Config
c.ApplyEnv()
if !c.Stripe.Enabled || c.Stripe.SecretKey != "sk_test" || c.Stripe.WebhookSecret != "whsec_test" || c.Stripe.StarterPriceID != "price_starter" || c.Stripe.ProPriceID != "price_pro" || c.Stripe.SuccessURL != "https://app.test/success" || c.Stripe.CancelURL != "https://app.test/cancel" || c.Stripe.PortalReturnURL != "https://app.test/billing" {
t.Fatalf("Stripe environment was not fully applied: %+v", c.Stripe)
}
}
func TestApplyEnvScoutAndSeedSecrets(t *testing.T) {
t.Setenv("SCOUT_SESSION_SECRET", "dedicated-scout-secret")
t.Setenv("SCOUT_CRAWLER_ENDPOINT", "http://127.0.0.1:3010")
t.Setenv("SCOUT_CRAWLER_TOKEN", "crawler-token")
t.Setenv("SEED_ADMIN_PASSWORD", "seed-password")
var c Config
c.ApplyEnv()
if c.Scout.SessionSecret != "dedicated-scout-secret" || c.Scout.CrawlerEndpoint != "http://127.0.0.1:3010" || c.Scout.CrawlerToken != "crawler-token" || c.Seed.AdminPassword != "seed-password" {
t.Fatalf("Scout or seed environment was not fully applied")
}
}
func TestApplyEnvInvalidPortDoesNotSkipLaterOverrides(t *testing.T) {
t.Setenv("PORT", "invalid")
t.Setenv("PUBLIC_WEB_BASE", "https://app.test")
var c Config
c.ApplyEnv()
if c.PublicWebBase != "https://app.test" {
t.Fatalf("invalid PORT skipped later environment overrides")
}
}
func TestValidateProductionDependenciesRequiresDedicatedCacheSecret(t *testing.T) {
var c Config
c.Auth.AccessSecret = strings.Repeat("a", 40)
c.Auth.RefreshSecret = strings.Repeat("b", 40)
err := c.ValidateProductionDependencies()
if err == nil || !strings.Contains(err.Error(), "AI_MODEL_CACHE_FINGERPRINT_SECRET") {
t.Fatalf("unexpected validation error %v", err)
}
c.Redis.AIModelCacheFingerprintSecret = c.Auth.AccessSecret
err = c.ValidateProductionDependencies()
if err == nil || !strings.Contains(err.Error(), "must be dedicated") {
t.Fatalf("unexpected reused-secret validation error %v", err)
}
}
func TestValidateStripeEnabledConfiguration(t *testing.T) {
valid := func(successURL string) error {
return validateStripe(true, "sk_test_key", "whsec_test_secret", "price_starter", "price_pro", successURL, "https://app.test/app/usage/checkout?result=cancel", "https://app.test/app/usage/plans")
}
if err := valid("https://app.test/app/usage/checkout?result=success&checkout_id={CHECKOUT_ID}"); err != nil {
t.Fatalf("valid Stripe configuration rejected: %v", err)
}
if err := valid("https://app.test/app/usage/checkout?result=success"); err == nil || !strings.Contains(err.Error(), "{CHECKOUT_ID}") {
t.Fatalf("missing checkout token validation error = %v", err)
}
if err := validateStripe(true, "sk_test_key", "whsec_test_secret", "price_same", "price_same", "https://app.test/success?checkout_id={CHECKOUT_ID}", "https://app.test/cancel", "https://app.test/plans"); err == nil || !strings.Contains(err.Error(), "must differ") {
t.Fatalf("duplicate Price ID validation error = %v", err)
}
if err := validateStripe(true, "sk_test_key", "whsec_test_secret", "price_starter", "price_pro", "http://app.test/success?checkout_id={CHECKOUT_ID}", "https://app.test/cancel", "https://app.test/plans"); err == nil || !strings.Contains(err.Error(), "HTTPS") {
t.Fatalf("insecure URL validation error = %v", err)
}
}

View File

@ -1,20 +0,0 @@
package domain
// SuccessCode is the project success envelope code (AGENTS.md / spec A-07).
// Migrated from stand-alone-service 10200 → unified 102000.
const SuccessCode int64 = 102000
const SuccessMessage = "success"
const TokenTypeBearer = "Bearer"
type ContextKey string
func (c ContextKey) String() string { return string(c) }
const (
UIDCode ContextKey = "uid"
RoleCode ContextKey = "role"
ScopeCode ContextKey = "scope"
DeviceIDCode ContextKey = "device_id"
)

View File

@ -1,9 +0,0 @@
package domain
import "testing"
func TestSuccessCode_Is102000(t *testing.T) {
if SuccessCode != 102000 {
t.Fatalf("SuccessCode=%d want 102000 (migrated from stand-alone 10200)", SuccessCode)
}
}

View File

@ -1,28 +0,0 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package admin
import (
"net/http"
"apps/backend/internal/logic/admin"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func AdminResetPasswordHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.AdminResetPasswordReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := admin.NewAdminResetPasswordLogic(r.Context(), svcCtx)
data, err := l.AdminResetPassword(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,28 +0,0 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package admin
import (
"net/http"
"apps/backend/internal/logic/admin"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func CreateMemberHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.AdminCreateMemberReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := admin.NewCreateMemberLogic(r.Context(), svcCtx)
data, err := l.CreateMember(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,28 +0,0 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package admin
import (
"net/http"
"apps/backend/internal/logic/admin"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func GetMemberHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.AdminUidPath
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := admin.NewGetMemberLogic(r.Context(), svcCtx)
data, err := l.GetMember(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,28 +0,0 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package admin
import (
"net/http"
"apps/backend/internal/logic/admin"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func ListMemberIdentitiesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.AdminUidPath
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := admin.NewListMemberIdentitiesLogic(r.Context(), svcCtx)
data, err := l.ListMemberIdentities(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,28 +0,0 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package admin
import (
"net/http"
"apps/backend/internal/logic/admin"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func ListMembersHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.AdminListMembersReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := admin.NewListMembersLogic(r.Context(), svcCtx)
data, err := l.ListMembers(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,28 +0,0 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package admin
import (
"net/http"
"apps/backend/internal/logic/admin"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func SetEmailVerifiedHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.AdminEmailVerifiedReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := admin.NewSetEmailVerifiedLogic(r.Context(), svcCtx)
data, err := l.SetEmailVerified(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,28 +0,0 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package admin
import (
"net/http"
"apps/backend/internal/logic/admin"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func SetRolesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.AdminRolesReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := admin.NewSetRolesLogic(r.Context(), svcCtx)
data, err := l.SetRoles(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,28 +0,0 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package admin
import (
"net/http"
"apps/backend/internal/logic/admin"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func SetStatusHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.AdminStatusReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := admin.NewSetStatusLogic(r.Context(), svcCtx)
data, err := l.SetStatus(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,28 +0,0 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package admin
import (
"net/http"
"apps/backend/internal/logic/admin"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func SetSuspendedHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.AdminSuspendReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := admin.NewSetSuspendedLogic(r.Context(), svcCtx)
data, err := l.SetSuspended(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,28 +0,0 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package auth
import (
"net/http"
"apps/backend/internal/logic/auth"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func BindAccountHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.AuthBindReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := auth.NewBindAccountLogic(r.Context(), svcCtx)
data, err := l.BindAccount(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,20 +0,0 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package auth
import (
"net/http"
"apps/backend/internal/logic/auth"
"apps/backend/internal/response"
"apps/backend/internal/svc"
)
func ListIdentitiesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := auth.NewListIdentitiesLogic(r.Context(), svcCtx)
data, err := l.ListIdentities()
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,20 +0,0 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package auth
import (
"net/http"
"apps/backend/internal/logic/auth"
"apps/backend/internal/response"
"apps/backend/internal/svc"
)
func LogoutAllHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := auth.NewLogoutAllLogic(r.Context(), svcCtx)
data, err := l.LogoutAll()
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,28 +0,0 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package auth
import (
"net/http"
"apps/backend/internal/logic/auth"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func LogoutHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.AuthLogoutReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := auth.NewLogoutLogic(r.Context(), svcCtx)
data, err := l.Logout(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,20 +0,0 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package auth
import (
"net/http"
"apps/backend/internal/logic/auth"
"apps/backend/internal/response"
"apps/backend/internal/svc"
)
func MeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := auth.NewMeLogic(r.Context(), svcCtx)
data, err := l.Me()
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,28 +0,0 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package auth
import (
"net/http"
"apps/backend/internal/logic/auth"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func OAuthCallbackHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.AuthOAuthCallbackReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := auth.NewOAuthCallbackLogic(r.Context(), svcCtx)
data, err := l.OAuthCallback(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,28 +0,0 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package auth
import (
"net/http"
"apps/backend/internal/logic/auth"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func OAuthStartHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.AuthOAuthProviderPath
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := auth.NewOAuthStartLogic(r.Context(), svcCtx)
data, err := l.OAuthStart(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,28 +0,0 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package auth
import (
"net/http"
"apps/backend/internal/logic/auth"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func RequestPasswordResetHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.AuthRequestResetReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := auth.NewRequestPasswordResetLogic(r.Context(), svcCtx)
data, err := l.RequestPasswordReset(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,28 +0,0 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package auth
import (
"net/http"
"apps/backend/internal/logic/auth"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func ResetPasswordHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.AuthResetPasswordReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := auth.NewResetPasswordLogic(r.Context(), svcCtx)
data, err := l.ResetPassword(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,20 +0,0 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package auth
import (
"net/http"
"apps/backend/internal/logic/auth"
"apps/backend/internal/response"
"apps/backend/internal/svc"
)
func SendEmailCodeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := auth.NewSendEmailCodeLogic(r.Context(), svcCtx)
data, err := l.SendEmailCode()
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,28 +0,0 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package auth
import (
"net/http"
"apps/backend/internal/logic/auth"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func UnbindAccountHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.AuthUnbindReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := auth.NewUnbindAccountLogic(r.Context(), svcCtx)
data, err := l.UnbindAccount(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,28 +0,0 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package auth
import (
"net/http"
"apps/backend/internal/logic/auth"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func UpdateProfileHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.AuthProfilePatchReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := auth.NewUpdateProfileLogic(r.Context(), svcCtx)
data, err := l.UpdateProfile(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,28 +0,0 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package auth
import (
"net/http"
"apps/backend/internal/logic/auth"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func UpdateProfilePutHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.AuthProfilePatchReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := auth.NewUpdateProfilePutLogic(r.Context(), svcCtx)
data, err := l.UpdateProfilePut(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,28 +0,0 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package auth
import (
"net/http"
"apps/backend/internal/logic/auth"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func VerifyEmailHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.AuthVerifyEmailReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := auth.NewVerifyEmailLogic(r.Context(), svcCtx)
data, err := l.VerifyEmail(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,28 +0,0 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package billing
import (
"net/http"
"apps/backend/internal/logic/billing"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func CreateBillingCheckoutSessionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.BillingCheckoutCreateReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := billing.NewCreateBillingCheckoutSessionLogic(r.Context(), svcCtx)
data, err := l.CreateBillingCheckoutSession(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,20 +0,0 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package billing
import (
"net/http"
"apps/backend/internal/logic/billing"
"apps/backend/internal/response"
"apps/backend/internal/svc"
)
func CreateBillingPortalSessionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := billing.NewCreateBillingPortalSessionLogic(r.Context(), svcCtx)
data, err := l.CreateBillingPortalSession()
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,28 +0,0 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package billing
import (
"net/http"
"apps/backend/internal/logic/billing"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func GetBillingCheckoutSessionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.BillingCheckoutGetReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := billing.NewGetBillingCheckoutSessionLogic(r.Context(), svcCtx)
data, err := l.GetBillingCheckoutSession(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,20 +0,0 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package billing
import (
"net/http"
"apps/backend/internal/logic/billing"
"apps/backend/internal/response"
"apps/backend/internal/svc"
)
func GetBillingSubscriptionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := billing.NewGetBillingSubscriptionLogic(r.Context(), svcCtx)
data, err := l.GetBillingSubscription()
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,20 +0,0 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package billing
import (
"net/http"
"apps/backend/internal/logic/billing"
"apps/backend/internal/response"
"apps/backend/internal/svc"
)
func HandleStripeWebhookHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := billing.NewHandleStripeWebhookLogic(r.Context(), svcCtx)
err := l.HandleStripeWebhook()
response.Write(r.Context(), w, nil, err)
}
}

View File

@ -1,28 +0,0 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package compose
import (
"net/http"
"apps/backend/internal/logic/compose"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func ComposeAnalyzeViralHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ComposeViralReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := compose.NewComposeAnalyzeViralLogic(r.Context(), svcCtx)
data, err := l.ComposeAnalyzeViral(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -1,28 +0,0 @@
// Code generated by goctl. DO NOT EDIT.
// goctl <no value>
package compose
import (
"net/http"
"apps/backend/internal/logic/compose"
"apps/backend/internal/response"
"apps/backend/internal/svc"
"apps/backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func ComposeMimicHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ComposeMimicReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := compose.NewComposeMimicLogic(r.Context(), svcCtx)
data, err := l.ComposeMimic(&req)
response.Write(r.Context(), w, data, err)
}
}

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