Compare commits
No commits in common. "main" and "fix/bug" have entirely different histories.
|
|
@ -0,0 +1,9 @@
|
|||
.run
|
||||
.git
|
||||
web/node_modules
|
||||
# web/dist 保留給 deploy/Dockerfile.web.static(本機 make web-build 後 COPY)
|
||||
worker/node_modules
|
||||
**/*_test.go
|
||||
**/.DS_Store
|
||||
*.md
|
||||
!deploy/**
|
||||
|
|
@ -1,42 +1,15 @@
|
|||
# --- secrets / env ---
|
||||
*.env
|
||||
**/.env
|
||||
!.env.example
|
||||
!*.env.example
|
||||
deploy/.env
|
||||
deploy/.env.dev
|
||||
deploy/.env.prod
|
||||
# --- secrets / env(不要 commit 實值)---
|
||||
infra/.env
|
||||
# Local runtime credentials. Commit the adjacent gateway.example.yaml only.
|
||||
apps/backend/etc/gateway.yaml
|
||||
old/deploy/.env
|
||||
old/deploy/.env.dev
|
||||
old/deploy/.env.prod
|
||||
old/infra/.env
|
||||
infra/etc/haixun.env
|
||||
/opt/haixun/
|
||||
|
||||
# --- runtime / logs ---
|
||||
.run/
|
||||
old/.run/
|
||||
*.log
|
||||
# --- build 產物 ---
|
||||
backend/bin/
|
||||
frontend/dist/
|
||||
|
||||
# --- build artifacts ---
|
||||
dist/
|
||||
**/dist/
|
||||
apps/*/dist/
|
||||
apps/web/dist/
|
||||
services/*/bin/
|
||||
old/backend/bin/
|
||||
old/backend/web/dist/
|
||||
old/backend/dev-console/dist/
|
||||
old/backend/web/public/downloads/*.zip
|
||||
|
||||
# --- dependencies ---
|
||||
# --- 依賴 ---
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
|
||||
# --- OS / editor ---
|
||||
# --- 其他 ---
|
||||
*.log
|
||||
.DS_Store
|
||||
.cursor/
|
||||
|
||||
# deploy runtime
|
||||
deploy/run/
|
||||
|
|
|
|||
|
|
@ -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 (≈100–200 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 → T010–T019) 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 **100–200 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
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
# Plan: <名稱>
|
||||
|
||||
> Status: `draft` | `approved`
|
||||
> Source: `docs/product/<slug>/spec.md`
|
||||
> Last updated: `YYYY-MM-DD`
|
||||
|
||||
## 1. 交付策略
|
||||
|
||||
<先穩什麼、再換什麼、最後加什麼 — 3–6 句>
|
||||
|
||||
## 2. 里程碑
|
||||
|
||||
### M0 — <名稱>
|
||||
|
||||
- **目標:**
|
||||
- **完成定義:**
|
||||
- **建議 task 區間:** T0xx–T0yy
|
||||
- **驗證:**
|
||||
|
||||
### M1 — …
|
||||
|
||||
## 3. 依賴
|
||||
|
||||
```text
|
||||
M0 → M1 → M2
|
||||
↘ M3
|
||||
```
|
||||
|
||||
## 4. 風險與緩解
|
||||
|
||||
| 風險 | 緩解 |
|
||||
|------|------|
|
||||
| | |
|
||||
|
||||
## 5. 刻意不做(本 plan 週期內)
|
||||
|
||||
-
|
||||
|
||||
## 6. 全域驗證指令
|
||||
|
||||
```bash
|
||||
cd backend && go test ./...
|
||||
make web-build # 若動前端
|
||||
```
|
||||
|
||||
## 7. 批准
|
||||
|
||||
- [ ] Plan 已批准:
|
||||
|
|
@ -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. 批准
|
||||
|
||||
- [ ] 需求已批准(日期 / 誰):
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
# Spec: <名稱>
|
||||
|
||||
> Status: `draft` | `approved`
|
||||
> Source: `docs/product/<slug>/requirements.md`
|
||||
> Last updated: `YYYY-MM-DD`
|
||||
|
||||
## 1. 摘要
|
||||
|
||||
<2–4 句,對齊需求 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 已批准:
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
# T### — <短標題>
|
||||
|
||||
> Status: `todo` | `doing` | `done` | `blocked`
|
||||
> Milestone: `M#`
|
||||
> Est. change: `~NNN lines` (target 100–200)
|
||||
|
||||
## 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
|
||||
|
||||
<可選:實作提示,非必做細節>
|
||||
|
|
@ -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
|
||||
- [ ] 預估 100–200 行級(過大已拆)
|
||||
- [ ] Inputs / Outputs / 不做 / 驗收齊
|
||||
- [ ] INDEX 可追蹤狀態
|
||||
|
||||
## 與現有技能的關係
|
||||
|
||||
| 技能 | 關係 |
|
||||
|------|------|
|
||||
| `/design` | 偏設計文件 + PR Plan;本 skill 偏產品需求→小 task |
|
||||
| `/execute-plan` | 可在 **plan/tasks 批准後** 用來並行實作,非必須 |
|
||||
| `/check-work` | 單一 task 做完後驗證 |
|
||||
|
||||
第一版 **不必** 自動串 execute-plan;使用者說「實作 T003」即可。
|
||||
|
|
@ -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 |
|
||||
|----|-------|-----------|--------|------------|
|
||||
| — | (尚未拆 task;plan 批准後填入) | | | |
|
||||
EOF
|
||||
fi
|
||||
|
||||
echo "Initialized $DIR"
|
||||
|
|
@ -0,0 +1 @@
|
|||
72708
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,9 @@
|
|||
|
||||
> haixun-web@0.1.0 dev
|
||||
> vite
|
||||
|
||||
|
||||
VITE v6.4.3 ready in 187 ms
|
||||
|
||||
➜ Local: http://localhost:5173/
|
||||
➜ Network: use --host to expose
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1 @@
|
|||
72713
|
||||
|
|
@ -0,0 +1 @@
|
|||
72714
|
||||
432
AGENTS.md
432
AGENTS.md
|
|
@ -1,48 +1,412 @@
|
|||
# Agent 規範(重構主線)
|
||||
|
||||
## 封存與主線
|
||||
# Agent Handoff Notes
|
||||
|
||||
- **`old/`**:舊系統整包封存,**只讀參考**。不要在 `old/` 修功能當交付。
|
||||
- **新碼**:只寫在 **`apps/`** 下(前端 `apps/web`、後端 `apps/backend` 等,以 `docs/product` 為準)。
|
||||
- **後端落點**:Phase C 巡樓 API/worker 在 **`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/`)。
|
||||
這個資料夾是新的巡樓後端核心,請優先維持乾淨邊界,不要把舊 Next.js 或 `template-monorepo` 的業務包袱搬進來。
|
||||
|
||||
## 交付順序(硬性)
|
||||
## 核心原則
|
||||
|
||||
1. Phase A 地基
|
||||
2. Phase B 前端 + mock(操作順再往下)
|
||||
3. Phase C 接真 API
|
||||
4. Phase D 業務加深
|
||||
- 全系統時間一律 **UTC+0**;寫入 Mongo / API 的時間欄位一律 **unix nanoseconds**(`int64`)。排程的 `timezone` 只用於 cron 解讀與下發 payload,不作為儲存時區。
|
||||
- 複製模式,不複製舊業務。
|
||||
- `logic` 做 API 編排,`model/usecase` 做可重複使用能力。
|
||||
- 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 是兩種不同 token;AI token 每次 request header 帶入,會員 JWT 由 `/api/v1/auth/*` 簽發。
|
||||
|
||||
B 未過關前,不大寫業務後端。
|
||||
## 設計文件
|
||||
|
||||
## 後端契約精神(實作時遵守)
|
||||
- `docs/job-system-plan.md`:通用 job system 規劃,包含 template、run、schedule、Redis queue/lock、取消語意與 API 草案。
|
||||
- `docs/scan-placement-plan.md`:海巡獲客(流程 B)— 知識圖譜、Brave 擴展、雙軌爬取(7 天重點 / 30 天補充)、產品匹配、島民交接。
|
||||
|
||||
- 時間:**UTC**;儲存 **unix nanoseconds** `int64`。
|
||||
- JSON:`code` / `message` / `data` / `error`;成功碼 `102000`。
|
||||
- 列表:`page` / `pageSize` → `pagination` + `list`。
|
||||
- Job:狀態 **guarded** 更新;Redis lock value = `workerID`。
|
||||
- 會員 JWT vs AI provider token 分離。
|
||||
- **禁止**未實作 API 回成功空資料。
|
||||
## 新增 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。
|
||||
- 不複製任天堂 / Nook UI;不用 emoji 當主 icon。
|
||||
- 字體:Inter + Taipei Sans TC。
|
||||
- mock | live 同一 repository 介面。
|
||||
- 詳見 `docs/product/haixun-console/spec.md`。
|
||||
## Response / Error Code
|
||||
|
||||
## 參考舊碼
|
||||
|
||||
需要對照實作時:
|
||||
錯誤碼格式是 `SSCCCDDD`:
|
||||
|
||||
```text
|
||||
old/backend/ # 舊 Go / web / node workers
|
||||
old/AGENTS.md # 舊詳細規範
|
||||
old/docs/ # 舊計劃文件
|
||||
SS = scope
|
||||
CCC = category
|
||||
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 JWT;token 驗證集中在 `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-icon(brand-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 client(envelope、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 :5173,proxy 到 :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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -0,0 +1,229 @@
|
|||
# 巡樓 monorepo Makefile
|
||||
# 兩種模式:
|
||||
# dev - 本機開發(docker 起 Mongo/Redis,go run / vite dev)
|
||||
# prod - 建置產物(前端 dist + linux Go binary),並可部署成 systemd 服務 + nginx
|
||||
#
|
||||
# 常用:
|
||||
# make dev-infra # 起本機 Mongo/Redis
|
||||
# make dev-backend # 跑 gateway (:8890)
|
||||
# make dev-frontend # 跑前端 (:5173,proxy 到 :8890)
|
||||
# make build # 產出 frontend/dist + backend/bin/{gateway,worker}
|
||||
# sudo make install # 部署到目標主機(見 infra/README.md)
|
||||
|
||||
SHELL := /bin/bash
|
||||
|
||||
# --- 路徑 ---
|
||||
BACKEND_DIR := backend
|
||||
FRONTEND_DIR := frontend
|
||||
INFRA_DIR := infra
|
||||
BIN_DIR := $(BACKEND_DIR)/bin
|
||||
|
||||
# --- 部署目標(install 用)---
|
||||
DEPLOY_ROOT := /opt/haixun
|
||||
WEB_ROOT := /var/www/haixun
|
||||
|
||||
# --- 交叉編譯目標(在 mac 上 build 給 linux 主機)---
|
||||
GOOS ?= linux
|
||||
GOARCH ?= amd64
|
||||
|
||||
# --- docker compose ---
|
||||
COMPOSE := docker compose -f $(INFRA_DIR)/docker-compose.yml --env-file $(INFRA_DIR)/.env
|
||||
|
||||
# --- dev 用 worker secret(對應 etc/gateway.yaml 的 InternalWorker.Secret)---
|
||||
DEV_WORKER_SECRET := haixun-dev-worker-secret
|
||||
DEV_BACKEND_URL := http://127.0.0.1:8890
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
.PHONY: help
|
||||
help: ## 顯示可用指令
|
||||
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
|
||||
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
# ============================================================
|
||||
# DEV
|
||||
# ============================================================
|
||||
|
||||
$(INFRA_DIR)/.env:
|
||||
@test -f $(INFRA_DIR)/.env || (cp $(INFRA_DIR)/.env.example $(INFRA_DIR)/.env && echo "已從 .env.example 建立 $(INFRA_DIR)/.env,請視需要修改密碼")
|
||||
|
||||
.PHONY: dev-infra
|
||||
dev-infra: $(INFRA_DIR)/.env ## [dev] 起本機 Mongo + Redis (docker)
|
||||
$(COMPOSE) up -d
|
||||
$(COMPOSE) ps
|
||||
|
||||
.PHONY: dev-infra-down
|
||||
dev-infra-down: ## [dev] 停掉本機 Mongo + Redis
|
||||
$(COMPOSE) down
|
||||
|
||||
.PHONY: dev-backend
|
||||
dev-backend: ## [dev] 跑 gateway API (:8890)
|
||||
cd $(BACKEND_DIR) && go run . -f etc/gateway.yaml
|
||||
|
||||
.PHONY: dev-worker
|
||||
dev-worker: ## [dev] 跑 Go job worker (:8891)
|
||||
cd $(BACKEND_DIR) && go run ./cmd/worker -f etc/gateway.worker.yaml
|
||||
|
||||
.PHONY: dev-node-worker
|
||||
dev-node-worker: ## [dev] 跑 Node playwright worker (style-8d)
|
||||
cd $(BACKEND_DIR)/worker && npm install && \
|
||||
HAIXUN_WORKER_SECRET=$(DEV_WORKER_SECRET) HAIXUN_BACKEND_URL=$(DEV_BACKEND_URL) npm run style-8d
|
||||
|
||||
.PHONY: dev-frontend
|
||||
dev-frontend: ## [dev] 跑前端 dev server (:5173)
|
||||
cd $(FRONTEND_DIR) && npm install && npm run dev
|
||||
|
||||
.PHONY: dev-init
|
||||
dev-init: ## [dev] 初始化 DB(索引/權限)並建立 admin 帳號(可用 INIT_ADMIN_EMAIL / INIT_ADMIN_PASSWORD 覆寫)
|
||||
cd $(BACKEND_DIR) && go run ./cmd/tool init -f etc/gateway.yaml
|
||||
|
||||
.PHONY: dev
|
||||
dev: ## [dev] 顯示本機開發要開的終端
|
||||
@echo "本機開發請分開幾個終端執行:"
|
||||
@echo " 1) make dev-infra # Mongo/Redis"
|
||||
@echo " 2) make dev-backend # gateway :8890"
|
||||
@echo " 3) make dev-worker # go worker(可選)"
|
||||
@echo " 4) make dev-node-worker # node worker(需 style-8d 時)"
|
||||
@echo " 5) make dev-frontend # 前端 :5173"
|
||||
|
||||
# ============================================================
|
||||
# 安裝依賴(新機器 clone 後執行一次)
|
||||
# ============================================================
|
||||
|
||||
.PHONY: bootstrap-sys
|
||||
bootstrap-sys: ## [需 sudo] 安裝系統依賴(Go, Node.js, Docker, Nginx)— Ubuntu 專用
|
||||
@echo "=== 安裝系統套件: nginx, curl, gnupg ==="
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq nginx curl gnupg ca-certificates
|
||||
@echo ""
|
||||
@echo "=== 安裝 Go 1.22+ ==="
|
||||
@GO_VER=$$(curl -sL https://go.dev/VERSION?m=text 2>/dev/null | head -1 || echo "go1.22"); \
|
||||
echo "下載 $$GO_VER.linux-amd64.tar.gz"; \
|
||||
curl -sL "https://go.dev/dl/$$GO_VER.linux-amd64.tar.gz" -o /tmp/go.tar.gz && \
|
||||
sudo rm -rf /usr/local/go && \
|
||||
sudo tar -C /usr/local -xzf /tmp/go.tar.gz && \
|
||||
rm /tmp/go.tar.gz; \
|
||||
echo 'export PATH=/usr/local/go/bin:$$PATH' | sudo tee /etc/profile.d/go.sh
|
||||
@echo ""
|
||||
@echo "=== 安裝 Node.js LTS ==="
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo apt-get install -y -qq nodejs
|
||||
@echo ""
|
||||
@echo "=== 安裝 Docker Engine + Compose ==="
|
||||
curl -fsSL https://get.docker.com | sudo sh
|
||||
sudo usermod -aG docker $(USER) 2>/dev/null || true
|
||||
@echo ""
|
||||
@echo "=== 安裝 Nginx ==="
|
||||
sudo apt-get install -y -qq nginx
|
||||
@echo ""
|
||||
@echo "✓ 系統依賴安裝完成。"
|
||||
@echo " 登出再登入後,Go/Node/Docker 即可使用(group 變更生效)。"
|
||||
@echo " 接著執行 make bootstrap 安裝專案層依賴。"
|
||||
@echo ""
|
||||
|
||||
.PHONY: bootstrap
|
||||
bootstrap: ## 安裝專案層依賴(Go modules + npm + Playwright),需先執行 make bootstrap-sys
|
||||
@echo "=== [1/4] Go modules ==="
|
||||
cd $(BACKEND_DIR) && go mod tidy
|
||||
@echo ""
|
||||
@echo "=== [2/4] 前端 npm ==="
|
||||
cd $(FRONTEND_DIR) && npm install
|
||||
@echo ""
|
||||
@echo "=== [3/4] Node worker npm ==="
|
||||
cd $(BACKEND_DIR)/worker && npm install
|
||||
@echo ""
|
||||
@echo "=== [4/4] Playwright 瀏覽器(chromium)+ 系統依賴 ==="
|
||||
cd $(BACKEND_DIR)/worker && sudo npx playwright install --with-deps chromium
|
||||
@echo ""
|
||||
@echo "✓ 全裝完成。後續步驟:"
|
||||
@echo " 1) make dev-infra # 起 Mongo/Redis (Docker)"
|
||||
@echo " 2) make dev-init # 初始化 DB + 建立 admin"
|
||||
@echo " 3) make dev-backend # 啟動 gateway (:8890)"
|
||||
@echo " 4) sudo make install # 正式部署(含 systemd + nginx)"
|
||||
|
||||
# ============================================================
|
||||
# BUILD (prod 產物)
|
||||
# ============================================================
|
||||
|
||||
.PHONY: build
|
||||
build: build-frontend build-backend ## [prod] 建置前端 dist 與 Go binary
|
||||
|
||||
.PHONY: build-frontend
|
||||
build-frontend: ## [prod] 前端靜態建置 (tsc + vite) -> frontend/dist
|
||||
cd $(FRONTEND_DIR) && npm ci && npm run build
|
||||
|
||||
.PHONY: build-backend
|
||||
build-backend: ## [prod] 交叉編譯 gateway + worker -> backend/bin (linux)
|
||||
cd $(BACKEND_DIR) && CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) \
|
||||
go build -trimpath -ldflags "-s -w" -o bin/gateway .
|
||||
cd $(BACKEND_DIR) && CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) \
|
||||
go build -trimpath -ldflags "-s -w" -o bin/worker ./cmd/worker
|
||||
cd $(BACKEND_DIR) && CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) \
|
||||
go build -trimpath -ldflags "-s -w" -o bin/tool ./cmd/tool
|
||||
@echo "binary 已輸出到 $(BIN_DIR)/(gateway / worker / tool)"
|
||||
|
||||
# ============================================================
|
||||
# PROD (部署)
|
||||
# ============================================================
|
||||
|
||||
.PHONY: prod-infra
|
||||
prod-infra: $(INFRA_DIR)/.env ## [prod] 起 Mongo + Redis (docker,背景)
|
||||
$(COMPOSE) up -d
|
||||
|
||||
.PHONY: prod-infra-down
|
||||
prod-infra-down: ## [prod] 停掉 Mongo + Redis
|
||||
$(COMPOSE) down
|
||||
|
||||
.PHONY: prod-init
|
||||
prod-init: ## [prod] 目標主機初始化 DB + 建立 admin(讀 /opt/haixun/etc/haixun.env;可加 INIT_ADMIN_EMAIL/PASSWORD)
|
||||
@test -x $(DEPLOY_ROOT)/bin/tool || (echo "缺少 $(DEPLOY_ROOT)/bin/tool,請先 make install" && exit 1)
|
||||
set -a; . $(DEPLOY_ROOT)/etc/haixun.env; set +a; \
|
||||
$(DEPLOY_ROOT)/bin/tool init -f $(DEPLOY_ROOT)/etc/gateway.prod.yaml
|
||||
|
||||
.PHONY: install
|
||||
install: ## [prod] 安裝 binary/前端/設定/systemd/nginx(需 root,在目標主機執行)
|
||||
@test -f $(BIN_DIR)/gateway || (echo "缺少 $(BIN_DIR)/gateway,請先在能 build 的機器執行 make build" && exit 1)
|
||||
@test -d $(FRONTEND_DIR)/dist || (echo "缺少 $(FRONTEND_DIR)/dist,請先 make build-frontend" && exit 1)
|
||||
id haixun >/dev/null 2>&1 || useradd --system --home $(DEPLOY_ROOT) --shell /usr/sbin/nologin haixun
|
||||
install -d $(DEPLOY_ROOT)/bin $(DEPLOY_ROOT)/etc $(DEPLOY_ROOT)/node-worker $(WEB_ROOT)
|
||||
install -m 0755 $(BIN_DIR)/gateway $(DEPLOY_ROOT)/bin/gateway
|
||||
install -m 0755 $(BIN_DIR)/worker $(DEPLOY_ROOT)/bin/worker
|
||||
install -m 0755 $(BIN_DIR)/tool $(DEPLOY_ROOT)/bin/tool
|
||||
install -m 0644 $(BACKEND_DIR)/etc/gateway.prod.yaml $(DEPLOY_ROOT)/etc/gateway.prod.yaml
|
||||
install -m 0644 $(BACKEND_DIR)/etc/gateway.worker.prod.yaml $(DEPLOY_ROOT)/etc/gateway.worker.prod.yaml
|
||||
rm -rf $(WEB_ROOT)/* && cp -r $(FRONTEND_DIR)/dist/* $(WEB_ROOT)/
|
||||
cp -r $(BACKEND_DIR)/worker/* $(DEPLOY_ROOT)/node-worker/
|
||||
cd $(DEPLOY_ROOT)/node-worker && npm ci && npx playwright install --with-deps chromium
|
||||
install -m 0644 $(INFRA_DIR)/systemd/haixun-gateway.service /etc/systemd/system/haixun-gateway.service
|
||||
install -m 0644 $(INFRA_DIR)/systemd/haixun-worker.service /etc/systemd/system/haixun-worker.service
|
||||
install -m 0644 $(INFRA_DIR)/systemd/haixun-node-worker.service /etc/systemd/system/haixun-node-worker.service
|
||||
install -m 0644 $(INFRA_DIR)/nginx/haixun.conf /etc/nginx/conf.d/haixun.conf
|
||||
chown -R haixun:haixun $(DEPLOY_ROOT) $(WEB_ROOT)
|
||||
@echo "----"
|
||||
@echo "接著(只做一次)建立 secret 檔:"
|
||||
@echo " cp $(INFRA_DIR)/etc/haixun.env.example $(DEPLOY_ROOT)/etc/haixun.env && chmod 600 $(DEPLOY_ROOT)/etc/haixun.env && sudoedit $(DEPLOY_ROOT)/etc/haixun.env"
|
||||
@echo "再啟用服務:"
|
||||
@echo " systemctl daemon-reload && systemctl enable --now haixun-gateway haixun-worker haixun-node-worker"
|
||||
@echo " nginx -t && systemctl reload nginx"
|
||||
|
||||
# ============================================================
|
||||
# 驗證 / 維護
|
||||
# ============================================================
|
||||
|
||||
.PHONY: tidy
|
||||
tidy: ## go mod tidy
|
||||
cd $(BACKEND_DIR) && go mod tidy
|
||||
|
||||
.PHONY: fmt
|
||||
fmt: ## gofmt 後端
|
||||
cd $(BACKEND_DIR) && gofmt -w .
|
||||
|
||||
.PHONY: test
|
||||
test: ## 跑後端測試
|
||||
cd $(BACKEND_DIR) && go test ./...
|
||||
|
||||
.PHONY: verify
|
||||
verify: ## 後端 build/test + 前端 build + compose 語法
|
||||
cd $(BACKEND_DIR) && go build ./... && go test ./...
|
||||
cd $(FRONTEND_DIR) && npm ci && npm run build
|
||||
$(COMPOSE) config >/dev/null && echo "docker compose config OK"
|
||||
60
README.md
60
README.md
|
|
@ -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 B,mock 優先)
|
||||
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/`(只讀)。
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
/bin/
|
||||
*.exe
|
||||
*.test
|
||||
coverage.out
|
||||
.data/
|
||||
uploads/
|
||||
|
|
@ -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。
|
||||
|
|
@ -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 ./...
|
||||
|
||||
# M1–M5 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;需本機已裝 migrate(mongodb 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
|
||||
|
|
@ -1,213 +0,0 @@
|
|||
# apps/backend
|
||||
|
||||
Harbor Desk Phase C(go-zero)。
|
||||
|
||||
## 原則
|
||||
|
||||
| 做 | 不做 |
|
||||
|----|------|
|
||||
| `generate/api` + `make gen-api` | 手寫 handler/routes |
|
||||
| `generate/database/mongo` + `make migrate-up` | 在 gateway 啟動時隱式建 index/seed |
|
||||
| `monc` + `CacheRedis` | 自己半套 mongo cache |
|
||||
| `internal/logic` + `module/*/domain|repository|usecase` | 業務塞進 handler |
|
||||
|
||||
## 指令
|
||||
|
||||
```bash
|
||||
cd apps/backend
|
||||
make build # gateway + worker
|
||||
make start # build 後背景啟動 gateway + worker(PID/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 + Redis(go-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 seeder(uid=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** |
|
||||
| 後端 | MinIO(dev)或 S3(prod),S3 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 顯示語系文案;placeholder/hint/錯誤都用同一 key,勿另寫一句。
|
||||
|
||||
### 寄信(信箱驗證 / 重設密碼)
|
||||
|
||||
業務在 `internal/logic/auth/*`:產碼 → `Notification.RenderVerify` → `SendEmail`。
|
||||
寄送實作在 `internal/module/notification`(SMTP,Mailgun 相容)。
|
||||
|
||||
#### 模板與多語
|
||||
|
||||
- 引擎與 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-compose(mongo/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 實體快取 | Redis(CacheRedis) | 可丟,會從 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`, …
|
||||
|
|
@ -1,163 +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)
|
||||
for collection, models := range indexModels() {
|
||||
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")
|
||||
}
|
||||
|
||||
// ownerIndex covers the near-universal "list one member's rows, newest first" shape. The sort key
|
||||
// belongs in the index too, otherwise Mongo fetches every matching row before sorting.
|
||||
func ownerIndex(sortField, name string) mongo.IndexModel {
|
||||
return mongo.IndexModel{
|
||||
Keys: bson.D{{Key: "owner_uid", Value: 1}, {Key: sortField, Value: -1}},
|
||||
Options: options.Index().SetName(name),
|
||||
}
|
||||
}
|
||||
|
||||
// indexModels lists every index the running code depends on. Owner-scoped collections were all
|
||||
// unindexed, so each list endpoint was a full collection scan that grew with total rows across
|
||||
// every member rather than with the member's own data.
|
||||
//
|
||||
// These are deliberately all non-unique. Adding a uniqueness constraint to existing data can make
|
||||
// CreateMany fail and take a deploy down, so that belongs in its own migration with a duplicate
|
||||
// pre-check rather than here.
|
||||
func indexModels() map[string][]mongo.IndexModel {
|
||||
return map[string][]mongo.IndexModel{
|
||||
// members.uid is read on every authenticated request via the auth middleware, and it is a
|
||||
// field lookup rather than _id, so without this it scanned the whole collection whenever
|
||||
// the Redis cache missed.
|
||||
"members": {
|
||||
{Keys: bson.D{{Key: "uid", Value: 1}}, Options: options.Index().SetName("member_uid")},
|
||||
{Keys: bson.D{{Key: "email", Value: 1}}, Options: options.Index().SetName("member_email")},
|
||||
{Keys: bson.D{{Key: "phone", Value: 1}}, Options: options.Index().SetName("member_phone")},
|
||||
},
|
||||
"identities": {
|
||||
{Keys: bson.D{{Key: "login_id", Value: 1}, {Key: "platform", Value: 1}}, Options: options.Index().SetName("identity_login_platform")},
|
||||
},
|
||||
"member_settings": {
|
||||
{Keys: bson.D{{Key: "uid", Value: 1}}, Options: options.Index().SetName("settings_uid")},
|
||||
},
|
||||
"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")},
|
||||
// The jobs list is polled continuously by the UI and runs a count plus a find per poll.
|
||||
ownerIndex("updated_at", "owner_jobs_updated"),
|
||||
},
|
||||
// One notification is written per job state change and nothing purges them, so this
|
||||
// collection grows without bound and its scan cost grows with it.
|
||||
"notifications": {
|
||||
ownerIndex("created_at", "owner_notifications_created"),
|
||||
},
|
||||
"threads_accounts": {
|
||||
ownerIndex("created_at", "owner_accounts_created"),
|
||||
{Keys: bson.D{{Key: "owner_uid", Value: 1}, {Key: "threads_user_id", Value: 1}}, Options: options.Index().SetName("owner_threads_user")},
|
||||
},
|
||||
"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")},
|
||||
ownerIndex("updated_at", "owner_outbox_updated"),
|
||||
},
|
||||
"studio_personas": {ownerIndex("updated_at", "owner_personas_updated")},
|
||||
"studio_plays": {ownerIndex("updated_at", "owner_plays_updated")},
|
||||
"studio_own_posts": {
|
||||
ownerIndex("published_at", "owner_own_posts_published"),
|
||||
{Keys: bson.D{{Key: "owner_uid", Value: 1}, {Key: "account_id", Value: 1}, {Key: "published_at", Value: -1}}, Options: options.Index().SetName("owner_account_own_posts")},
|
||||
},
|
||||
"studio_mentions": {
|
||||
ownerIndex("created_at", "owner_mentions_created"),
|
||||
{Keys: bson.D{{Key: "owner_uid", Value: 1}, {Key: "account_id", Value: 1}, {Key: "created_at", Value: -1}}, Options: options.Index().SetName("owner_account_mentions")},
|
||||
},
|
||||
"scout_brands": {{Keys: bson.D{{Key: "owner_uid", Value: 1}}, Options: options.Index().SetName("owner_brands")}},
|
||||
"scout_products": {{Keys: bson.D{{Key: "owner_uid", Value: 1}}, Options: options.Index().SetName("owner_products")}},
|
||||
"scout_posts": {
|
||||
ownerIndex("created_at", "owner_posts_created"),
|
||||
{Keys: bson.D{{Key: "owner_uid", Value: 1}, {Key: "brand_id", Value: 1}, {Key: "created_at", Value: -1}}, Options: options.Index().SetName("owner_brand_posts")},
|
||||
},
|
||||
"scout_homework": {{Keys: bson.D{{Key: "owner_uid", Value: 1}}, Options: options.Index().SetName("owner_homework")}},
|
||||
"inspire_elements": {ownerIndex("updated_at", "owner_elements_updated")},
|
||||
"inspire_sessions": {ownerIndex("updated_at", "owner_sessions_updated")},
|
||||
"usage_events": {
|
||||
{Keys: bson.D{{Key: "uid", Value: 1}, {Key: "month_key", Value: 1}, {Key: "created_at", Value: -1}}, Options: options.Index().SetName("uid_month_events")},
|
||||
{Keys: bson.D{{Key: "month_key", Value: 1}, {Key: "created_at", Value: -1}}, Options: options.Index().SetName("tenant_month_events")},
|
||||
{Keys: bson.D{{Key: "created_at", Value: 1}}, Options: options.Index().SetName("events_created_range")},
|
||||
},
|
||||
"usage_prefs": {
|
||||
{Keys: bson.D{{Key: "uid", Value: 1}}, Options: options.Index().SetName("prefs_uid")},
|
||||
// Stripe webhooks arrive knowing only the customer, so this is the reverse lookup
|
||||
// back to a member and it runs on every billing event.
|
||||
{Keys: bson.D{{Key: "stripe_customer_id", Value: 1}}, Options: options.Index().SetName("prefs_stripe_customer")},
|
||||
},
|
||||
"usage_purchases": {
|
||||
{Keys: bson.D{{Key: "uid", Value: 1}, {Key: "created_at", Value: -1}}, Options: options.Index().SetName("uid_purchases_created")},
|
||||
{Keys: bson.D{{Key: "stripe_event_id", Value: 1}}, Options: options.Index().SetName("purchase_stripe_event")},
|
||||
},
|
||||
"growth_outcomes": {
|
||||
// The observe worker scans by status across all tenants on every tick.
|
||||
{Keys: bson.D{{Key: "status", Value: 1}}, Options: options.Index().SetName("observe_status")},
|
||||
{Keys: bson.D{{Key: "owner_uid", Value: 1}, {Key: "source_type", Value: 1}, {Key: "source_id", Value: 1}}, Options: options.Index().SetName("owner_outcome_source")},
|
||||
ownerIndex("created_at", "owner_outcomes_created"),
|
||||
},
|
||||
"growth_checkups": {ownerIndex("created_at", "owner_checkups_created")},
|
||||
"growth_account_health": {{Keys: bson.D{{Key: "owner_uid", Value: 1}}, Options: options.Index().SetName("owner_health")}},
|
||||
"growth_workspaces": {{Keys: bson.D{{Key: "owner_uid", Value: 1}, {Key: "archived", Value: 1}}, Options: options.Index().SetName("owner_workspaces_archived")}},
|
||||
"growth_draft_reviews": {
|
||||
{Keys: bson.D{{Key: "owner_uid", Value: 1}, {Key: "ref_type", Value: 1}, {Key: "ref_id", Value: 1}, {Key: "updated_at", Value: -1}}, Options: options.Index().SetName("owner_review_ref")},
|
||||
},
|
||||
"growth_invite_rewards": {
|
||||
{Keys: bson.D{{Key: "inviter_uid", Value: 1}, {Key: "created_at", Value: -1}}, Options: options.Index().SetName("inviter_rewards_created")},
|
||||
{Keys: bson.D{{Key: "invitee_uid", Value: 1}}, Options: options.Index().SetName("invitee_reward")},
|
||||
},
|
||||
"growth_utm_links": {
|
||||
// Looked up by code on the unauthenticated public redirect.
|
||||
{Keys: bson.D{{Key: "code", Value: 1}}, Options: options.Index().SetName("utm_code")},
|
||||
ownerIndex("created_at", "owner_utm_created"),
|
||||
},
|
||||
"growth_ws_members": {
|
||||
{Keys: bson.D{{Key: "workspace_id", Value: 1}, {Key: "uid", Value: 1}}, Options: options.Index().SetName("workspace_member")},
|
||||
},
|
||||
"billing_checkout_attempts": {
|
||||
{Keys: bson.D{{Key: "uid", Value: 1}, {Key: "request_id", Value: 1}}, Options: options.Index().SetName("uid_request")},
|
||||
{Keys: bson.D{{Key: "session_id", Value: 1}}, Options: options.Index().SetName("checkout_session")},
|
||||
{Keys: bson.D{{Key: "customer_id", Value: 1}}, Options: options.Index().SetName("checkout_customer")},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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)))
|
||||
}
|
||||
|
|
@ -1,538 +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"
|
||||
growthRepo "apps/backend/internal/module/growth/repository"
|
||||
growthUC "apps/backend/internal/module/growth/usecase"
|
||||
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"
|
||||
)
|
||||
|
||||
// Worker:demo + 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
|
||||
|
||||
growthSvc := growthUC.New(growthRepo.NewMonStore(c.Mongo.URI, c.Mongo.Database))
|
||||
growthSvc.Bonus = usageSvc
|
||||
scoutSvc.OnOutreachPublished = func(ctx context.Context, ownerUID int64, postID, accountID string) {
|
||||
_, _ = growthSvc.RecordPublished(ctx, ownerUID, "scout_outreach", postID, accountID, 0)
|
||||
}
|
||||
studio.OnStepPublished = func(ctx context.Context, ownerUID int64, bundleID, stepID, accountID string) {
|
||||
_, _ = growthSvc.RecordPublished(ctx, ownerUID, "outbox_step", bundleID+":"+stepID, accountID, 0)
|
||||
}
|
||||
|
||||
logx.Infof("haixun worker started id=%s interval=%s (demo + token_renew + persona_analyze + compose_mimic + outbox + growth)", 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
|
||||
ns := strings.Trim(c.Redis.Namespace, ":")
|
||||
outboxLock := redislock.NewWithClient(redisClient, ns+":worker:outbox", workerID, outboxLockTTL)
|
||||
|
||||
// 巡場維護(過期 outcome、清終態 job)是全域掃描,不像 job 領取有 guarded update 擋重複。
|
||||
// 每個 worker 每 tick 各跑一次的話,N 台就等於同一份掃描做 N 次。改成一次一台、且拉長間隔。
|
||||
const maintenanceLockTTL = 2 * time.Minute
|
||||
const maintenanceEvery = time.Minute
|
||||
maintenanceLock := redislock.NewWithClient(redisClient, ns+":worker:maintenance", workerID, maintenanceLockTTL)
|
||||
lastMaintenance := time.Time{}
|
||||
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 && !errors.Is(err, jobDomain.ErrNotFound) {
|
||||
logx.Errorf("worker %s claim job: %v", workerID, err)
|
||||
}
|
||||
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) 巡場維護:過期 outcome + 清終態 job(單一 worker、低頻)
|
||||
if time.Since(lastMaintenance) >= maintenanceEvery {
|
||||
if runMaintenance(ctx, growthSvc, jobs, maintenanceLock, workerID) {
|
||||
lastMaintenance = time.Now()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runMaintenance reports whether this worker actually did the sweep, so a worker that lost the
|
||||
// lock retries on the next tick instead of waiting out the full interval.
|
||||
func runMaintenance(
|
||||
ctx context.Context,
|
||||
growthSvc *growthUC.Service,
|
||||
jobs *jobUC.Service,
|
||||
lock *redislock.Lock,
|
||||
workerID string,
|
||||
) bool {
|
||||
locked, err := lock.Acquire(ctx)
|
||||
if err != nil {
|
||||
logx.Errorf("worker %s maintenance lock acquire: %v", workerID, err)
|
||||
return false
|
||||
}
|
||||
if !locked {
|
||||
return false
|
||||
}
|
||||
defer func() {
|
||||
if rerr := lock.Release(ctx); rerr != nil {
|
||||
logx.Errorf("worker %s maintenance lock release: %v", workerID, rerr)
|
||||
}
|
||||
}()
|
||||
|
||||
if n, err := growthSvc.ObserveTick(ctx, 0); err != nil {
|
||||
logx.Errorf("worker %s outcome observe: %v", workerID, err)
|
||||
} else if n > 0 {
|
||||
logx.Infof("worker %s outcome observe updated %d", workerID, n)
|
||||
}
|
||||
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)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
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.Direction, 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
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
# Call Home 會議重點與 Action Items
|
||||
|
||||
**日期:** 2026/07/28
|
||||
|
||||
## 會議重點
|
||||
|
||||
### 1. 整合方向
|
||||
|
||||
Call Home 可拆成三個部分:
|
||||
|
||||
1. **Trigger/State**:事件收集、分類、去重與 Recovery。
|
||||
2. **Delivery**:Email、SMS、Phone、ServiceNow、Salesforce。
|
||||
3. **Service Process**:自動開單、Ticket 狀態與結案責任。
|
||||
|
||||
### 2. 可以直接重用的能力
|
||||
|
||||
- **SSM 團隊提供:** Event Mapping、事件白名單、資料收集、去重與 Recovery 邏輯。
|
||||
- **SCC-FLEX 提供:** Policy Engine、統一 Webhook、多通路通知與 Salesforce API。
|
||||
- **Local Administrator:** 沿用 SCC-FLEX 現有 Recipient/Receiver 設定,不另外開發獨立模組。
|
||||
|
||||
### 3. 目前主要缺口
|
||||
|
||||
- 尚未定義統一 Event Payload 與 Correlation Key。
|
||||
- SCC-FLEX 尚缺完整 Deduplication、Soft/Hard State 與 Event Lifecycle。
|
||||
- Service Team 可接受的 Event 白名單尚未確認。
|
||||
- Device Recovered 與 Ticket Closed 的關係尚未定義。
|
||||
- Salesforce Ticket 的 Owner、Close 權限與 Ask-to-Close 流程尚未確認。
|
||||
- Call Home License 模式尚未確認。
|
||||
|
||||
### 4. 初步共識
|
||||
|
||||
- Supermicro Service 情境傾向**自動建立 Ticket**。
|
||||
- 自動開單前必須先完成 Event 白名單、去重及 Idempotency。
|
||||
- 初期先支援 Compute/Supermicro Hardware。
|
||||
- CDU、PDU、Liquid Cooling 等產品後續再評估。
|
||||
|
||||
## Action Items
|
||||
|
||||
| Owner | Action Item | Status |
|
||||
|---|---|---|
|
||||
| Daniel | 依 Trigger、Delivery、Service Process 三部分整理整合需求與責任邊界。 | 待整理 |
|
||||
| Daniel | 建立跨團隊討論群組並發布會議記錄。 | 待處理 |
|
||||
| Daniel/PM | 確認 DCM Single Key、Service Key、每台 Host 計費及 Support Advisor 的關係。 | 待確認 |
|
||||
| PM/Service Team | 提供可自動開單的 Event 白名單、必要 Payload/附件及維護 Owner。 | 待確認 |
|
||||
| SSM 團隊 | 提供 Event Mapping、Trigger List、Deduplication、Recovery 與資料收集規則。 | 待指派 |
|
||||
| SCC-FLEX 團隊 | 定義 Event Payload、Correlation Key、Soft/Hard State、Deduplication 與 Idempotency。 | 待排程 |
|
||||
| Daniel/IT/Service Team | 確認 Salesforce API Owner、Ticket Update、Close 與 Ask-to-Close 規則。 | 待確認 |
|
||||
| PM/產品團隊 | 決定 Event 選擇方式及 Ticket List/Ask-to-Close 是否列入產品範圍。 | 待討論 |
|
||||
|
||||
## 下一步
|
||||
|
||||
下一次會議只需要確認四件事:
|
||||
|
||||
1. Service Team Event 白名單。
|
||||
2. SSM 可提供的資料與介面。
|
||||
3. Salesforce Ticket Lifecycle 與責任人。
|
||||
4. License 與產品範圍。
|
||||
|
|
@ -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:
|
||||
# xAI(api.x.ai)— 人設 LLM、文案等
|
||||
XAIKey: ""
|
||||
# 相容舊名:XAIKey 空時會用 AIKey
|
||||
AIKey: ""
|
||||
# OpenCode Go(opencode.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: ""
|
||||
|
||||
# 驗證碼/重設密碼寄信
|
||||
#
|
||||
# 本機(Mailpit,Mailgun 相容 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 回傳驗證碼;碼只在郵件(Mailpit/SMTP)
|
||||
DevExposeCode: false
|
||||
|
||||
# 前端 origin:重設信連結 + 預設 logo URL(/brand-mark.jpg)
|
||||
# 正式遠端:https://threads-tool.30cm.net
|
||||
PublicWebBase: "https://threads-tool.30cm.net"
|
||||
# 瀏覽器直連 gateway 時的 allowlist;同源 proxy 不需要列在此處。
|
||||
# CORS_ALLOWED_ORIGINS 可用逗號附加額外的明確 origin,不能使用 *。
|
||||
CORSAllowedOrigins:
|
||||
- "https://threads-tool.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_PASSWORD(ApplyEnv 會讀)
|
||||
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.30cm.net/haixun-assets"
|
||||
|
||||
|
|
@ -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 stream(goctl 不生成 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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -1,27 +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"
|
||||
import "growth.api"
|
||||
import "growth_p2.api"
|
||||
|
|
@ -1,315 +0,0 @@
|
|||
syntax = "v1"
|
||||
|
||||
// growth-loop: outcomes / checkups / account health / workspaces / invite rewards
|
||||
|
||||
type (
|
||||
OutcomeEventPublic {
|
||||
Id string `json:"id"`
|
||||
SourceType string `json:"source_type"`
|
||||
SourceId string `json:"source_id"`
|
||||
ThreadsAccountId string `json:"threads_account_id,optional"`
|
||||
Kind string `json:"kind"`
|
||||
Confidence string `json:"confidence"`
|
||||
Status string `json:"status"`
|
||||
ReplyCountDelta int `json:"reply_count_delta,optional"`
|
||||
LikeCountDelta int `json:"like_count_delta,optional"`
|
||||
FollowersDelta int `json:"followers_delta,optional"`
|
||||
ConversionAmount float64 `json:"conversion_amount,optional"`
|
||||
ConversionNote string `json:"conversion_note,optional"`
|
||||
ConversionCurrency string `json:"conversion_currency,optional"`
|
||||
WindowEndsAt int64 `json:"window_ends_at"`
|
||||
SentAt int64 `json:"sent_at"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
OutcomeSummaryData {
|
||||
From int64 `json:"from"`
|
||||
To int64 `json:"to"`
|
||||
Reach int `json:"reach"`
|
||||
Conversations int `json:"conversations"`
|
||||
FollowsPossible int `json:"follows_possible"`
|
||||
FollowsConfirmed int `json:"follows_confirmed"`
|
||||
Conversions int `json:"conversions"`
|
||||
ConversionAmount float64 `json:"conversion_amount"`
|
||||
}
|
||||
|
||||
ListOutcomesReq {
|
||||
Page int `form:"page,default=1"`
|
||||
PageSize int `form:"pageSize,default=20"`
|
||||
Kind string `form:"kind,optional"`
|
||||
Confidence string `form:"confidence,optional"`
|
||||
SourceType string `form:"source_type,optional"`
|
||||
From int64 `form:"from,optional"`
|
||||
To int64 `form:"to,optional"`
|
||||
}
|
||||
|
||||
OutcomeListData {
|
||||
List []OutcomeEventPublic `json:"list"`
|
||||
Pagination Pagination `json:"pagination"`
|
||||
}
|
||||
|
||||
OutcomeSummaryReq {
|
||||
Range string `form:"range,default=week"` // week | month
|
||||
From int64 `form:"from,optional"`
|
||||
To int64 `form:"to,optional"`
|
||||
}
|
||||
|
||||
GetOutcomeReq {
|
||||
Id string `path:"id"`
|
||||
}
|
||||
|
||||
ReportConversionReq {
|
||||
Id string `path:"id"`
|
||||
Amount float64 `json:"amount,optional"`
|
||||
Note string `json:"note,optional"`
|
||||
Currency string `json:"currency,optional"`
|
||||
}
|
||||
|
||||
UpdateConversionReq {
|
||||
Id string `path:"id"`
|
||||
Amount float64 `json:"amount,optional"`
|
||||
Note string `json:"note,optional"`
|
||||
Currency string `json:"currency,optional"`
|
||||
}
|
||||
|
||||
DeleteConversionReq {
|
||||
Id string `path:"id"`
|
||||
}
|
||||
|
||||
CheckupFindingPublic {
|
||||
Claim string `json:"claim"`
|
||||
Evidence []string `json:"evidence"`
|
||||
}
|
||||
|
||||
CheckupActionPublic {
|
||||
Title string `json:"title"`
|
||||
Reason string `json:"reason"`
|
||||
Deeplink string `json:"deeplink"`
|
||||
}
|
||||
|
||||
WeeklyCheckupPublic {
|
||||
Id string `json:"id"`
|
||||
WeekKey string `json:"week_key"`
|
||||
Status string `json:"status"`
|
||||
Summary string `json:"summary"`
|
||||
Findings []CheckupFindingPublic `json:"findings"`
|
||||
Actions []CheckupActionPublic `json:"actions"`
|
||||
Error string `json:"error,optional"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
ListCheckupsReq {
|
||||
Page int `form:"page,default=1"`
|
||||
PageSize int `form:"pageSize,default=20"`
|
||||
}
|
||||
|
||||
CheckupListData {
|
||||
List []WeeklyCheckupPublic `json:"list"`
|
||||
Pagination Pagination `json:"pagination"`
|
||||
}
|
||||
|
||||
GetCheckupReq {
|
||||
Id string `path:"id"`
|
||||
}
|
||||
|
||||
AccountHealthPublic {
|
||||
ThreadsAccountId string `json:"threads_account_id"`
|
||||
Score int `json:"score"`
|
||||
Level string `json:"level"`
|
||||
Factors []string `json:"factors"`
|
||||
Advice string `json:"advice"`
|
||||
ComputedAt int64 `json:"computed_at"`
|
||||
}
|
||||
|
||||
AccountHealthListData {
|
||||
List []AccountHealthPublic `json:"list"`
|
||||
}
|
||||
|
||||
GetAccountHealthReq {
|
||||
AccountId string `path:"accountId"`
|
||||
}
|
||||
|
||||
WorkspacePublic {
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ReviewRequired bool `json:"review_required"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
Archived bool `json:"archived"`
|
||||
FooterText string `json:"footer_text,optional"`
|
||||
HidePoweredBy bool `json:"hide_powered_by,optional"`
|
||||
BrandName string `json:"brand_name,optional"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
WorkspaceListData {
|
||||
List []WorkspacePublic `json:"list"`
|
||||
CurrentWorkspaceId string `json:"current_workspace_id"`
|
||||
}
|
||||
|
||||
CreateWorkspaceReq {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
UpdateWorkspaceReq {
|
||||
Id string `path:"id"`
|
||||
Name string `json:"name,optional"`
|
||||
ReviewRequired *bool `json:"review_required,optional"`
|
||||
}
|
||||
|
||||
WorkspaceIdReq {
|
||||
Id string `path:"id"`
|
||||
}
|
||||
|
||||
SwitchWorkspaceReq {
|
||||
Id string `path:"id"`
|
||||
}
|
||||
|
||||
DraftReviewPublic {
|
||||
Id string `json:"id"`
|
||||
RefType string `json:"ref_type"`
|
||||
RefId string `json:"ref_id"`
|
||||
WorkspaceId string `json:"workspace_id"`
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason,optional"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
SubmitDraftReviewReq {
|
||||
RefType string `json:"ref_type"`
|
||||
RefId string `json:"ref_id"`
|
||||
WorkspaceId string `json:"workspace_id,optional"`
|
||||
}
|
||||
|
||||
ReviewDecisionReq {
|
||||
Id string `path:"id"`
|
||||
Reason string `json:"reason,optional"`
|
||||
}
|
||||
|
||||
ListDraftReviewsReq {
|
||||
Page int `form:"page,default=1"`
|
||||
PageSize int `form:"pageSize,default=20"`
|
||||
Status string `form:"status,optional"`
|
||||
}
|
||||
|
||||
DraftReviewListData {
|
||||
List []DraftReviewPublic `json:"list"`
|
||||
Pagination Pagination `json:"pagination"`
|
||||
}
|
||||
|
||||
ExportReportReq {
|
||||
WorkspaceId string `form:"workspace_id"`
|
||||
YearMonth string `form:"year_month"` // YYYY-MM
|
||||
Format string `form:"format,optional"` // md | pdf
|
||||
}
|
||||
|
||||
ExportReportData {
|
||||
Url string `json:"url"`
|
||||
ExpiresAt int64 `json:"expires_at,optional"`
|
||||
Filename string `json:"filename"`
|
||||
// ContentType: text/markdown or application/pdf
|
||||
ContentType string `json:"content_type,optional"`
|
||||
// Format: md | pdf
|
||||
Format string `json:"format,optional"`
|
||||
}
|
||||
|
||||
// branding fields on workspace (P2 white-label report)
|
||||
// extended via UpdateWorkspaceBranding in growth_p2.api
|
||||
)
|
||||
|
||||
@server (
|
||||
group: outcomes
|
||||
prefix: /api/v1/outcomes
|
||||
middleware: AuthJWT
|
||||
)
|
||||
service gateway {
|
||||
@handler GetOutcomeSummary
|
||||
get /summary (OutcomeSummaryReq) returns (OutcomeSummaryData)
|
||||
|
||||
@handler ListOutcomes
|
||||
get / (ListOutcomesReq) returns (OutcomeListData)
|
||||
|
||||
@handler GetOutcome
|
||||
get /:id (GetOutcomeReq) returns (OutcomeEventPublic)
|
||||
|
||||
@handler ReportConversion
|
||||
post /:id/conversion (ReportConversionReq) returns (OutcomeEventPublic)
|
||||
|
||||
@handler UpdateConversion
|
||||
put /:id/conversion (UpdateConversionReq) returns (OutcomeEventPublic)
|
||||
|
||||
@handler DeleteConversion
|
||||
delete /:id/conversion (DeleteConversionReq) returns (OkData)
|
||||
}
|
||||
|
||||
@server (
|
||||
group: checkups
|
||||
prefix: /api/v1/checkups
|
||||
middleware: AuthJWT
|
||||
)
|
||||
service gateway {
|
||||
@handler GetLatestCheckup
|
||||
get /latest returns (WeeklyCheckupPublic)
|
||||
|
||||
@handler ListCheckups
|
||||
get / (ListCheckupsReq) returns (CheckupListData)
|
||||
|
||||
@handler GetCheckup
|
||||
get /:id (GetCheckupReq) returns (WeeklyCheckupPublic)
|
||||
|
||||
@handler GenerateCheckupNow
|
||||
post /generate returns (WeeklyCheckupPublic)
|
||||
}
|
||||
|
||||
@server (
|
||||
group: health
|
||||
prefix: /api/v1/account-health
|
||||
middleware: AuthJWT
|
||||
)
|
||||
service gateway {
|
||||
@handler ListAccountHealth
|
||||
get / returns (AccountHealthListData)
|
||||
|
||||
@handler GetAccountHealth
|
||||
get /:accountId (GetAccountHealthReq) returns (AccountHealthPublic)
|
||||
}
|
||||
|
||||
@server (
|
||||
group: workspaces
|
||||
prefix: /api/v1/workspaces
|
||||
middleware: AuthJWT
|
||||
)
|
||||
service gateway {
|
||||
@handler ListWorkspaces
|
||||
get / returns (WorkspaceListData)
|
||||
|
||||
@handler CreateWorkspace
|
||||
post / (CreateWorkspaceReq) returns (WorkspacePublic)
|
||||
|
||||
@handler UpdateWorkspace
|
||||
put /:id (UpdateWorkspaceReq) returns (WorkspacePublic)
|
||||
|
||||
@handler ArchiveWorkspace
|
||||
post /:id/archive (WorkspaceIdReq) returns (OkData)
|
||||
|
||||
@handler SwitchWorkspace
|
||||
post /:id/switch (SwitchWorkspaceReq) returns (WorkspaceListData)
|
||||
|
||||
@handler SubmitDraftReview
|
||||
post /reviews (SubmitDraftReviewReq) returns (DraftReviewPublic)
|
||||
|
||||
@handler ApproveDraftReview
|
||||
post /reviews/:id/approve (ReviewDecisionReq) returns (DraftReviewPublic)
|
||||
|
||||
@handler RejectDraftReview
|
||||
post /reviews/:id/reject (ReviewDecisionReq) returns (DraftReviewPublic)
|
||||
|
||||
@handler ListDraftReviews
|
||||
get /reviews (ListDraftReviewsReq) returns (DraftReviewListData)
|
||||
|
||||
@handler ExportWorkspaceReport
|
||||
get /report/export (ExportReportReq) returns (ExportReportData)
|
||||
}
|
||||
|
|
@ -1,287 +0,0 @@
|
|||
syntax = "v1"
|
||||
|
||||
// growth-loop P2: playbook / free tools / benchmark / utm / insights / workspace members
|
||||
|
||||
type (
|
||||
PlaybookPublic {
|
||||
Id string `json:"id"`
|
||||
Kind string `json:"kind"` // brief | persona | play
|
||||
Title string `json:"title"`
|
||||
Niche string `json:"niche"`
|
||||
Body string `json:"body"`
|
||||
Anonymous bool `json:"anonymous"`
|
||||
AuthorLabel string `json:"author_label"`
|
||||
ImportCount int `json:"import_count"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
Mine bool `json:"mine,optional"`
|
||||
}
|
||||
|
||||
PlaybookListData {
|
||||
List []PlaybookPublic `json:"list"`
|
||||
Pagination Pagination `json:"pagination"`
|
||||
}
|
||||
|
||||
ListPlaybooksReq {
|
||||
Page int `form:"page,default=1"`
|
||||
PageSize int `form:"pageSize,default=20"`
|
||||
Kind string `form:"kind,optional"`
|
||||
Niche string `form:"niche,optional"`
|
||||
Mine bool `form:"mine,optional"`
|
||||
}
|
||||
|
||||
PublishPlaybookReq {
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Niche string `json:"niche,optional"`
|
||||
Body string `json:"body"`
|
||||
Anonymous bool `json:"anonymous,optional"`
|
||||
}
|
||||
|
||||
PlaybookIdPath {
|
||||
Id string `path:"id"`
|
||||
}
|
||||
|
||||
ImportPlaybookData {
|
||||
Id string `json:"id"`
|
||||
Copied bool `json:"copied"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
StyleQuizReq {
|
||||
Samples string `json:"samples"`
|
||||
}
|
||||
|
||||
StyleQuizData {
|
||||
Tone string `json:"tone"`
|
||||
Rhythm string `json:"rhythm"`
|
||||
Hooks string `json:"hooks"`
|
||||
Avoid string `json:"avoid"`
|
||||
Summary string `json:"summary"`
|
||||
Tags []string `json:"tags"`
|
||||
SignupHint string `json:"signup_hint"`
|
||||
}
|
||||
|
||||
PainKeywordsReq {
|
||||
ProductBrief string `json:"product_brief"`
|
||||
Audience string `json:"audience,optional"`
|
||||
}
|
||||
|
||||
PainKeywordsData {
|
||||
Keywords []string `json:"keywords"`
|
||||
Pains []string `json:"pains"`
|
||||
ScanTerms []string `json:"scan_terms"`
|
||||
Summary string `json:"summary"`
|
||||
SignupHint string `json:"signup_hint"`
|
||||
}
|
||||
|
||||
BenchmarkReq {
|
||||
Niche string `form:"niche,optional"`
|
||||
AccountId string `form:"account_id,optional"`
|
||||
}
|
||||
|
||||
BenchmarkData {
|
||||
Available bool `json:"available"`
|
||||
SampleSize int `json:"sample_size"`
|
||||
Niche string `json:"niche"`
|
||||
MedianEngRate float64 `json:"median_eng_rate"`
|
||||
MedianViews float64 `json:"median_views"`
|
||||
YourEngRate float64 `json:"your_eng_rate,optional"`
|
||||
YourViews float64 `json:"your_views,optional"`
|
||||
PercentileHint string `json:"percentile_hint,optional"`
|
||||
Message string `json:"message,optional"`
|
||||
}
|
||||
|
||||
CreateUtmLinkReq {
|
||||
DestinationUrl string `json:"destination_url"`
|
||||
OutcomeId string `json:"outcome_id,optional"`
|
||||
Label string `json:"label,optional"`
|
||||
}
|
||||
|
||||
UtmLinkPublic {
|
||||
Id string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
// TrackPath 保留給同源前端;TrackUrl 是後端算好的絕對網址,貼到 Threads 用這個。
|
||||
TrackPath string `json:"track_path"`
|
||||
TrackUrl string `json:"track_url"`
|
||||
DestinationUrl string `json:"destination_url"`
|
||||
OutcomeId string `json:"outcome_id,optional"`
|
||||
Label string `json:"label,optional"`
|
||||
Clicks int `json:"clicks"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
UtmLinkListData {
|
||||
List []UtmLinkPublic `json:"list"`
|
||||
}
|
||||
|
||||
UtmRedirectReq {
|
||||
Code string `path:"code"`
|
||||
}
|
||||
|
||||
InsightsSummaryReq {
|
||||
AccountId string `form:"account_id,optional"`
|
||||
Months int `form:"months,default=3"`
|
||||
}
|
||||
|
||||
InsightsPostPublic {
|
||||
Id string `json:"id"`
|
||||
TextPreview string `json:"text_preview"`
|
||||
PublishedAt int64 `json:"published_at"`
|
||||
ViewCount int `json:"view_count"`
|
||||
LikeCount int `json:"like_count"`
|
||||
ReplyCount int `json:"reply_count"`
|
||||
EngRate float64 `json:"eng_rate"`
|
||||
}
|
||||
|
||||
InsightsSummaryData {
|
||||
AccountId string `json:"account_id,optional"`
|
||||
PostCount int `json:"post_count"`
|
||||
TotalViews int `json:"total_views"`
|
||||
TotalLikes int `json:"total_likes"`
|
||||
TotalReplies int `json:"total_replies"`
|
||||
AvgEngRate float64 `json:"avg_eng_rate"`
|
||||
TopPosts []InsightsPostPublic `json:"top_posts"`
|
||||
MonthBuckets []InsightsMonthBucket `json:"month_buckets"`
|
||||
Conclusion string `json:"conclusion"`
|
||||
Suggestions []string `json:"suggestions"`
|
||||
}
|
||||
|
||||
InsightsMonthBucket {
|
||||
MonthKey string `json:"month_key"`
|
||||
Posts int `json:"posts"`
|
||||
Views int `json:"views"`
|
||||
Likes int `json:"likes"`
|
||||
Replies int `json:"replies"`
|
||||
AvgEngRate float64 `json:"avg_eng_rate"`
|
||||
}
|
||||
|
||||
AddWorkspaceMemberReq {
|
||||
WorkspaceId string `path:"id"`
|
||||
MemberUid int64 `json:"member_uid,optional"`
|
||||
Email string `json:"email,optional"`
|
||||
Role string `json:"role,optional"` // editor | reviewer | owner
|
||||
}
|
||||
|
||||
WorkspaceMemberPublic {
|
||||
Uid int64 `json:"uid"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Role string `json:"role"`
|
||||
JoinedAt int64 `json:"joined_at"`
|
||||
}
|
||||
|
||||
WorkspaceMemberListData {
|
||||
List []WorkspaceMemberPublic `json:"list"`
|
||||
}
|
||||
|
||||
ListWorkspaceMembersReq {
|
||||
Id string `path:"id"`
|
||||
}
|
||||
|
||||
RemoveWorkspaceMemberReq {
|
||||
Id string `path:"id"`
|
||||
Uid int64 `path:"uid"`
|
||||
}
|
||||
|
||||
WorkspaceBrandingReq {
|
||||
Id string `path:"id"`
|
||||
FooterText string `json:"footer_text,optional"`
|
||||
HidePoweredBy bool `json:"hide_powered_by,optional"`
|
||||
BrandName string `json:"brand_name,optional"`
|
||||
}
|
||||
)
|
||||
|
||||
@server (
|
||||
group: playbooks
|
||||
prefix: /api/v1/playbooks
|
||||
middleware: AuthJWT
|
||||
)
|
||||
service gateway {
|
||||
@handler ListPlaybooks
|
||||
get / (ListPlaybooksReq) returns (PlaybookListData)
|
||||
|
||||
@handler PublishPlaybook
|
||||
post / (PublishPlaybookReq) returns (PlaybookPublic)
|
||||
|
||||
@handler GetPlaybook
|
||||
get /:id (PlaybookIdPath) returns (PlaybookPublic)
|
||||
|
||||
@handler ImportPlaybook
|
||||
post /:id/import (PlaybookIdPath) returns (ImportPlaybookData)
|
||||
|
||||
@handler RemovePlaybook
|
||||
delete /:id (PlaybookIdPath) returns (OkData)
|
||||
}
|
||||
|
||||
@server (
|
||||
group: publictools
|
||||
prefix: /api/v1/public/tools
|
||||
)
|
||||
service gateway {
|
||||
@handler PublicStyleQuiz
|
||||
post /style-quiz (StyleQuizReq) returns (StyleQuizData)
|
||||
|
||||
@handler PublicPainKeywords
|
||||
post /pain-keywords (PainKeywordsReq) returns (PainKeywordsData)
|
||||
}
|
||||
|
||||
@server (
|
||||
group: benchmark
|
||||
prefix: /api/v1/benchmark
|
||||
middleware: AuthJWT
|
||||
)
|
||||
service gateway {
|
||||
@handler GetBenchmark
|
||||
get / (BenchmarkReq) returns (BenchmarkData)
|
||||
}
|
||||
|
||||
@server (
|
||||
group: utm
|
||||
prefix: /api/v1/utm
|
||||
middleware: AuthJWT
|
||||
)
|
||||
service gateway {
|
||||
@handler CreateUtmLink
|
||||
post /links (CreateUtmLinkReq) returns (UtmLinkPublic)
|
||||
|
||||
@handler ListUtmLinks
|
||||
get /links returns (UtmLinkListData)
|
||||
}
|
||||
|
||||
@server (
|
||||
group: publicutm
|
||||
prefix: /api/v1/public/u
|
||||
)
|
||||
service gateway {
|
||||
@handler UtmRedirect
|
||||
get /:code (UtmRedirectReq) returns (OkData)
|
||||
}
|
||||
|
||||
@server (
|
||||
group: insightsapi
|
||||
prefix: /api/v1/insights
|
||||
middleware: AuthJWT
|
||||
)
|
||||
service gateway {
|
||||
@handler GetInsightsSummary
|
||||
get /summary (InsightsSummaryReq) returns (InsightsSummaryData)
|
||||
}
|
||||
|
||||
@server (
|
||||
group: workspaces
|
||||
prefix: /api/v1/workspaces
|
||||
middleware: AuthJWT
|
||||
)
|
||||
service gateway {
|
||||
@handler ListWorkspaceMembers
|
||||
get /:id/members (ListWorkspaceMembersReq) returns (WorkspaceMemberListData)
|
||||
|
||||
@handler AddWorkspaceMember
|
||||
post /:id/members (AddWorkspaceMemberReq) returns (WorkspaceMemberPublic)
|
||||
|
||||
@handler RemoveWorkspaceMember
|
||||
delete /:id/members/:uid (RemoveWorkspaceMemberReq) returns (OkData)
|
||||
|
||||
@handler UpdateWorkspaceBranding
|
||||
put /:id/branding (WorkspaceBrandingReq) returns (WorkspacePublic)
|
||||
}
|
||||
|
|
@ -1,110 +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"`
|
||||
}
|
||||
|
||||
InviteRewardPublic {
|
||||
Id string `json:"id"`
|
||||
InviteeUid int64 `json:"invitee_uid"`
|
||||
PlanId string `json:"plan_id"`
|
||||
Points int `json:"points"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
InviteRewardsSummary {
|
||||
TotalPoints int `json:"total_points"`
|
||||
MonthPoints int `json:"month_points"`
|
||||
MonthCap int `json:"month_cap"`
|
||||
Recent []InviteRewardPublic `json:"recent"`
|
||||
}
|
||||
|
||||
MyInviteNetworkData {
|
||||
Me InviteMemberBrief `json:"me"`
|
||||
Upline *InviteMemberBrief `json:"upline,optional"`
|
||||
Downlines []InviteMemberBrief `json:"downlines"`
|
||||
TotalDownlineCount int `json:"total_downline_count"`
|
||||
Rewards *InviteRewardsSummary `json:"rewards,optional"`
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
@ -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 ns;0=立刻可領
|
||||
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)
|
||||
}
|
||||
|
|
@ -1,476 +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
|
||||
// chat 專用:本輪先用 Exa 查資料,再交給 AI 討論
|
||||
UseWeb bool `json:"use_web,optional"`
|
||||
// generate 可選:額外指定素材;空時直接整理整段 session
|
||||
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"`
|
||||
LearningSummary string `json:"learning_summary,optional"`
|
||||
LearningVersion int `json:"learning_version,optional"`
|
||||
LearnedFromPostsCount int `json:"learned_from_posts_count,optional"`
|
||||
LastLearnedAt int64 `json:"last_learned_at,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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -1,574 +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"`
|
||||
// 8D:d1Tone…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"`
|
||||
LearningSummary string `json:"learning_summary,optional"`
|
||||
LearningVersion int `json:"learning_version,optional"`
|
||||
LearnedFromPostsCount int `json:"learned_from_posts_count,optional"`
|
||||
LastLearnedAt int64 `json:"last_learned_at,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"`
|
||||
// 新貼文的主題、觀點或素材;空白時由 AI 從參考文延伸不同角度
|
||||
Direction string `json:"direction,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_tag,1~50 字,可不加 #)
|
||||
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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -1,197 +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"`
|
||||
}
|
||||
|
||||
UsagePrefsReq {
|
||||
// Uid reads another member's plan; admin only. Empty means the caller's own prefs.
|
||||
Uid string `form:"uid,optional"`
|
||||
}
|
||||
|
||||
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 (UsagePrefsReq) 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)
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
# Database migrations(golang-migrate + MongoDB)
|
||||
|
||||
**第一性原理:** index/seed 用 **檔案** 宣告,交給 `migrate`;沒有 Go seeder/init 程式。
|
||||
|
||||
工具:[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)
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
@ -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_"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
[
|
||||
{
|
||||
"dropIndexes": "jobs",
|
||||
"index": "owner_created"
|
||||
},
|
||||
{
|
||||
"dropIndexes": "jobs",
|
||||
"index": "status_1"
|
||||
}
|
||||
]
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
[
|
||||
{
|
||||
"createIndexes": "jobs",
|
||||
"indexes": [
|
||||
{
|
||||
"key": { "owner_uid": 1, "created_at": -1 },
|
||||
"name": "owner_created"
|
||||
},
|
||||
{
|
||||
"key": { "status": 1 },
|
||||
"name": "status_1"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -1 +0,0 @@
|
|||
[]
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
[
|
||||
{
|
||||
"update": "counters",
|
||||
"updates": [
|
||||
{
|
||||
"q": { "_id": "member_uid" },
|
||||
"u": { "$setOnInsert": { "seq": 1000000 } },
|
||||
"upsert": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
[
|
||||
{
|
||||
"dropIndexes": "refresh_tokens",
|
||||
"index": "refresh_uid_1"
|
||||
},
|
||||
{
|
||||
"dropIndexes": "refresh_tokens",
|
||||
"index": "refresh_ttl"
|
||||
},
|
||||
{
|
||||
"dropIndexes": "auth_codes",
|
||||
"index": "auth_code_ttl"
|
||||
}
|
||||
]
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -1 +0,0 @@
|
|||
[]
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -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" }
|
||||
]
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -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" }
|
||||
]
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
[
|
||||
{ "dropIndexes": "studio_outbox", "index": "claim_due_outbox_steps" }
|
||||
]
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -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" }
|
||||
]
|
||||
|
|
@ -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 }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
[
|
||||
{ "dropIndexes": "notifications", "index": "owner_notifications_recent" },
|
||||
{ "dropIndexes": "notifications", "index": "owner_notifications_unread" },
|
||||
{ "dropIndexes": "notifications", "index": "owner_notification_ref_recent" }
|
||||
]
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
[
|
||||
{
|
||||
"createIndexes": "notifications",
|
||||
"indexes": [
|
||||
{ "key": { "owner_uid": 1, "created_at": -1 }, "name": "owner_notifications_recent" },
|
||||
{ "key": { "owner_uid": 1, "read_at": 1 }, "name": "owner_notifications_unread" },
|
||||
{ "key": { "owner_uid": 1, "kind": 1, "ref_type": 1, "ref_id": 1, "created_at": -1 }, "name": "owner_notification_ref_recent" }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
[
|
||||
{ "dropIndexes": "growth_outcomes", "index": "owner_outcomes_recent" },
|
||||
{ "dropIndexes": "growth_outcomes", "index": "owner_outcome_source" },
|
||||
{ "dropIndexes": "growth_outcomes", "index": "outcome_observe" },
|
||||
{ "dropIndexes": "growth_checkups", "index": "owner_checkups_recent" },
|
||||
{ "dropIndexes": "growth_checkups", "index": "owner_checkup_week" },
|
||||
{ "dropIndexes": "growth_account_health", "index": "owner_health" },
|
||||
{ "dropIndexes": "growth_workspaces", "index": "owner_workspaces" },
|
||||
{ "dropIndexes": "growth_draft_reviews", "index": "owner_review_ref" },
|
||||
{ "dropIndexes": "growth_invite_rewards", "index": "inviter_rewards" },
|
||||
{ "dropIndexes": "growth_invite_rewards", "index": "invitee_reward_once" }
|
||||
]
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
[
|
||||
{
|
||||
"createIndexes": "growth_outcomes",
|
||||
"indexes": [
|
||||
{ "key": { "owner_uid": 1, "sent_at": -1 }, "name": "owner_outcomes_recent" },
|
||||
{ "key": { "owner_uid": 1, "source_type": 1, "source_id": 1 }, "name": "owner_outcome_source", "unique": true },
|
||||
{ "key": { "status": 1, "window_ends_at": 1 }, "name": "outcome_observe" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"createIndexes": "growth_checkups",
|
||||
"indexes": [
|
||||
{ "key": { "owner_uid": 1, "created_at": -1 }, "name": "owner_checkups_recent" },
|
||||
{ "key": { "owner_uid": 1, "week_key": 1 }, "name": "owner_checkup_week" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"createIndexes": "growth_account_health",
|
||||
"indexes": [
|
||||
{ "key": { "owner_uid": 1 }, "name": "owner_health" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"createIndexes": "growth_workspaces",
|
||||
"indexes": [
|
||||
{ "key": { "owner_uid": 1, "archived": 1 }, "name": "owner_workspaces" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"createIndexes": "growth_draft_reviews",
|
||||
"indexes": [
|
||||
{ "key": { "owner_uid": 1, "ref_type": 1, "ref_id": 1, "updated_at": -1 }, "name": "owner_review_ref" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"createIndexes": "growth_invite_rewards",
|
||||
"indexes": [
|
||||
{ "key": { "inviter_uid": 1, "created_at": -1 }, "name": "inviter_rewards" },
|
||||
{ "key": { "invitee_uid": 1 }, "name": "invitee_reward_once" }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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.30cm.net
|
||||
PublicWebBase string `json:",optional,default=http://127.0.0.1:5173"`
|
||||
// PublicAPIBase — 對外 API origin(Threads OAuth redirect_uri 必須 https 公開網址)
|
||||
// 空則:若 PublicWebBase 為 https 則同 host;否則回落 http://127.0.0.1:Port
|
||||
// 例:https://threads-tool.30cm.net
|
||||
PublicAPIBase string `json:",optional"`
|
||||
// Brand — 郵件 chrome(hermes);LogoURL 空則用 PublicWebBase + /brand-mark.svg
|
||||
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 / SMTP(MAIL_* 優先;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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue