Compare commits
No commits in common. "feat/frontend" and "main" have entirely different histories.
feat/front
...
main
|
|
@ -0,0 +1,15 @@
|
||||||
|
.git
|
||||||
|
.run
|
||||||
|
.cursor
|
||||||
|
**/node_modules
|
||||||
|
**/.env
|
||||||
|
backend/bin
|
||||||
|
backend/web/dist
|
||||||
|
backend/web/public/downloads/*.zip
|
||||||
|
backend/dev-console/dist
|
||||||
|
frontend/dist
|
||||||
|
**/*_test.go
|
||||||
|
**/.DS_Store
|
||||||
|
infra
|
||||||
|
*.md
|
||||||
|
!deploy/**
|
||||||
|
|
@ -1,34 +1,24 @@
|
||||||
# --- secrets / env ---
|
# --- secrets / env ---
|
||||||
*.env
|
|
||||||
**/.env
|
|
||||||
!.env.example
|
|
||||||
!*.env.example
|
|
||||||
deploy/.env
|
deploy/.env
|
||||||
deploy/.env.dev
|
deploy/.env.dev
|
||||||
deploy/.env.prod
|
deploy/.env.prod
|
||||||
infra/.env
|
infra/.env
|
||||||
# Local runtime credentials. Commit the adjacent gateway.example.yaml only.
|
*.env
|
||||||
apps/backend/etc/gateway.yaml
|
**/.env
|
||||||
old/deploy/.env
|
!.env.example
|
||||||
old/deploy/.env.dev
|
!*.env.example
|
||||||
old/deploy/.env.prod
|
|
||||||
old/infra/.env
|
|
||||||
|
|
||||||
# --- runtime / logs ---
|
# --- runtime / logs ---
|
||||||
.run/
|
.run/
|
||||||
old/.run/
|
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# --- build artifacts ---
|
# --- build artifacts ---
|
||||||
|
backend/bin/
|
||||||
|
backend/web/dist/
|
||||||
|
backend/dev-console/dist/
|
||||||
|
frontend/dist/
|
||||||
dist/
|
dist/
|
||||||
**/dist/
|
backend/web/public/downloads/*.zip
|
||||||
apps/*/dist/
|
|
||||||
apps/web/dist/
|
|
||||||
services/*/bin/
|
|
||||||
old/backend/bin/
|
|
||||||
old/backend/web/dist/
|
|
||||||
old/backend/dev-console/dist/
|
|
||||||
old/backend/web/public/downloads/*.zip
|
|
||||||
|
|
||||||
# --- dependencies ---
|
# --- dependencies ---
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
@ -37,6 +27,3 @@ node_modules/
|
||||||
# --- OS / editor ---
|
# --- OS / editor ---
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.cursor/
|
.cursor/
|
||||||
|
|
||||||
# deploy runtime
|
|
||||||
deploy/run/
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
433
AGENTS.md
433
AGENTS.md
|
|
@ -1,48 +1,413 @@
|
||||||
# Agent 規範(重構主線)
|
|
||||||
|
|
||||||
## 封存與主線
|
# Agent Handoff Notes
|
||||||
|
|
||||||
- **`old/`**:舊系統整包封存,**只讀參考**。不要在 `old/` 修功能當交付。
|
這個資料夾是新的巡樓後端核心,請優先維持乾淨邊界,不要把舊 Next.js 或 `template-monorepo` 的業務包袱搬進來。
|
||||||
- **新碼**:只寫在 **`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/`)。
|
|
||||||
|
|
||||||
## 交付順序(硬性)
|
## 核心原則
|
||||||
|
|
||||||
1. Phase A 地基
|
- 全系統時間一律 **UTC+0**;寫入 Mongo / API 的時間欄位一律 **unix nanoseconds**(`int64`)。排程的 `timezone` 只用於 cron 解讀與下發 payload,不作為儲存時區。
|
||||||
2. Phase B 前端 + mock(操作順再往下)
|
- 複製模式,不複製舊業務。
|
||||||
3. Phase C 接真 API
|
- `logic` 做 API 編排,`model/usecase` 做可重複使用能力。
|
||||||
4. Phase D 業務加深
|
- provider adapter 不讀 setting、不碰 Mongo、不知道 HTTP。
|
||||||
|
- setting 是通用 key-value model,不依賴 AI 或其他業務。
|
||||||
|
- token / API key 第一版每次 request 帶入,不寫入 config。
|
||||||
|
- SSE contract 由本服務 normalize,前端不要讀 provider 原始 chunk。
|
||||||
|
- JSON API 必須使用 `code/message/data/error` envelope 與 `SSCCCDDD` 錯誤碼。
|
||||||
|
- 列表 API 必須使用 `page/pageSize` query,並在 `data` 回傳 `pagination/list`。
|
||||||
|
- Job 狀態轉移必須使用 guarded/conditional update;不要在 API/worker 直接裸 `Update` 覆蓋 job 狀態。
|
||||||
|
- Redis job lock 的 value 是 `workerID`;release / refresh 必須檢查 owner,長任務必須 heartbeat。
|
||||||
|
- Auth 目前是 native email/password + JWT,不包含 OAuth / OTP / MFA / Zitadel。不要為了相容 template-monorepo 把重依賴搬進來。
|
||||||
|
- AI provider token 與會員 JWT 是兩種不同 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/post-project-plan.md`:發文計畫(文案任務 Flow A 砍掉重做)— 主題發想、風格分析、矩陣產 N 篇草稿、定時發送。
|
||||||
|
- `docs/scan-placement-plan.md`:海巡獲客(流程 B)— 知識圖譜、Brave 擴展、雙軌爬取(7 天重點 / 30 天補充)、產品匹配、島民交接。
|
||||||
|
|
||||||
- 時間:**UTC**;儲存 **unix nanoseconds** `int64`。
|
## 新增 API 流程
|
||||||
- JSON:`code` / `message` / `data` / `error`;成功碼 `102000`。
|
|
||||||
- 列表:`page` / `pageSize` → `pagination` + `list`。
|
|
||||||
- Job:狀態 **guarded** 更新;Redis lock value = `workerID`。
|
|
||||||
- 會員 JWT vs AI provider token 分離。
|
|
||||||
- **禁止**未實作 API 回成功空資料。
|
|
||||||
|
|
||||||
## 前端精神(Harbor Desk)
|
1. 修改 `generate/api/*.api`。
|
||||||
|
2. 優先使用 `make gen-api` 重新產生 handler/logic/types。
|
||||||
|
3. 若手寫 handler,仍需遵守 `response.Write` 與 validator 流程。
|
||||||
|
4. SSE endpoint 不使用 `response.Write`,直接輸出 `text/event-stream`。
|
||||||
|
5. 更新 `README.md` 的 API 與架構說明。
|
||||||
|
|
||||||
- 新殼在 `apps/web`;不引入 MUI/Ant/Chakra。
|
## Response / Error Code
|
||||||
- 不複製任天堂 / Nook UI;不用 emoji 當主 icon。
|
|
||||||
- 字體:Inter + Taipei Sans TC。
|
|
||||||
- mock | live 同一 repository 介面。
|
|
||||||
- 詳見 `docs/product/haixun-console/spec.md`。
|
|
||||||
|
|
||||||
## 參考舊碼
|
錯誤碼格式是 `SSCCCDDD`:
|
||||||
|
|
||||||
需要對照實作時:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
old/backend/ # 舊 Go / web / node workers
|
SS = scope
|
||||||
old/AGENTS.md # 舊詳細規範
|
CCC = category
|
||||||
old/docs/ # 舊計劃文件
|
DDD = detail
|
||||||
```
|
```
|
||||||
|
|
||||||
複製模式可以,**不要**把半成品與死碼整包搬回主線。
|
目前 scope:
|
||||||
|
|
||||||
|
```text
|
||||||
|
10 = Facade
|
||||||
|
32 = Setting
|
||||||
|
33 = AI
|
||||||
|
34 = Job
|
||||||
|
35 = Auth
|
||||||
|
36 = Member
|
||||||
|
37 = Permission
|
||||||
|
```
|
||||||
|
|
||||||
|
建立錯誤時使用:
|
||||||
|
|
||||||
|
```go
|
||||||
|
errs.For(code.AI).InputMissingRequired("缺少 AI provider token")
|
||||||
|
errs.For(code.Setting).ResNotFound("找不到設定")
|
||||||
|
errs.For(code.Job).ResInvalidState("job state changed; update rejected")
|
||||||
|
errs.For(code.Auth).AuthUnauthorized("missing bearer token")
|
||||||
|
```
|
||||||
|
|
||||||
|
不要直接手寫 `33104000` 這種數字,也不要回傳裸 `error` 給 handler 後讓使用者看到內部錯誤。
|
||||||
|
|
||||||
|
## Pagination
|
||||||
|
|
||||||
|
列表 API 使用:
|
||||||
|
|
||||||
|
```text
|
||||||
|
?page=1&pageSize=10
|
||||||
|
```
|
||||||
|
|
||||||
|
response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 102000,
|
||||||
|
"message": "SUCCESS",
|
||||||
|
"data": {
|
||||||
|
"pagination": {
|
||||||
|
"total": 100,
|
||||||
|
"page": 1,
|
||||||
|
"pageSize": 10,
|
||||||
|
"totalPages": 10
|
||||||
|
},
|
||||||
|
"list": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`page/pageSize` 必須是 server 正規化後的值。不要使用 `offset/limit/items`。
|
||||||
|
|
||||||
|
## 新增 Model 流程
|
||||||
|
|
||||||
|
模組放在:
|
||||||
|
|
||||||
|
```text
|
||||||
|
internal/model/<module>/
|
||||||
|
domain/entity
|
||||||
|
domain/repository
|
||||||
|
domain/usecase
|
||||||
|
repository
|
||||||
|
usecase
|
||||||
|
```
|
||||||
|
|
||||||
|
依賴方向:
|
||||||
|
|
||||||
|
```text
|
||||||
|
handler -> logic -> model/domain/usecase
|
||||||
|
model/usecase -> model/domain/repository
|
||||||
|
model/repository -> Mongo / Redis
|
||||||
|
```
|
||||||
|
|
||||||
|
不要讓 `logic` import `model/<module>/repository`。
|
||||||
|
|
||||||
|
## Auth / Permission 擴充
|
||||||
|
|
||||||
|
目前已接:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/v1/auth/register
|
||||||
|
POST /api/v1/auth/login
|
||||||
|
POST /api/v1/auth/refresh
|
||||||
|
POST /api/v1/auth/logout # requires member JWT
|
||||||
|
GET /api/v1/members/me
|
||||||
|
PATCH /api/v1/members/me
|
||||||
|
GET /api/v1/permissions/catalog
|
||||||
|
GET /api/v1/permissions/me
|
||||||
|
```
|
||||||
|
|
||||||
|
Auth matrix(`internal/handler/routes.go`):
|
||||||
|
|
||||||
|
| 路由 | 需要會員 JWT |
|
||||||
|
|------|----------------|
|
||||||
|
| `GET /api/v1/health` | 否 |
|
||||||
|
| `POST /api/v1/auth/register/login/refresh` | 否 |
|
||||||
|
| `POST /api/v1/auth/logout` | 是(`Authorization`) |
|
||||||
|
| `GET /api/v1/ai/providers` | 否 |
|
||||||
|
| `POST /api/v1/ai/chat/stream/models` | 是(`X-Member-Authorization`)+ provider token(`Authorization`) |
|
||||||
|
| `/api/v1/members/*`、`/api/v1/permissions/me` | 是(`Authorization`) |
|
||||||
|
| `/api/v1/permissions/catalog`、`/api/v1/settings/*`、`/api/v1/jobs*`、`/api/v1/job/*` | 是(`Authorization`) |
|
||||||
|
|
||||||
|
規則:
|
||||||
|
|
||||||
|
- 保護路由用 `internal/middleware.Auth`(`Authorization: Bearer <access_token>`);AI 變更路由用 `middleware.MemberAuth`(`X-Member-Authorization`),因 `Authorization` 保留給 provider API key。
|
||||||
|
- logic 從 `authctx.ActorFromContext` 讀 `tenant_id` / `uid`。
|
||||||
|
- 不要在 handler 直接 parse 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,128 @@
|
||||||
|
SHELL := /bin/bash
|
||||||
|
|
||||||
|
BACKEND_DIR := backend
|
||||||
|
WEB_DIR := backend/web
|
||||||
|
INFRA_DIR := infra
|
||||||
|
DEPLOY_DIR := deploy
|
||||||
|
|
||||||
|
INFRA_DEV_COMPOSE := docker compose --env-file $(DEPLOY_DIR)/.env.dev -f $(INFRA_DIR)/docker-compose.yml
|
||||||
|
INFRA_PROD_COMPOSE := docker compose --env-file $(DEPLOY_DIR)/.env.prod -f $(INFRA_DIR)/docker-compose.yml
|
||||||
|
DEPLOY_COMPOSE := docker compose --env-file $(DEPLOY_DIR)/.env.prod -f $(DEPLOY_DIR)/docker-compose.yml
|
||||||
|
|
||||||
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
|
.PHONY: help
|
||||||
|
help: ## 顯示可用指令
|
||||||
|
@grep -E '^[a-zA-Z0-9_-]+:.*## .*$$' $(MAKEFILE_LIST) \
|
||||||
|
| awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}'
|
||||||
|
|
||||||
|
.PHONY: setup-env
|
||||||
|
setup-env: ## 互動式產生 deploy/.env.prod(自動 URL-encode MongoDB 密碼)
|
||||||
|
@$(DEPLOY_DIR)/setup.sh
|
||||||
|
|
||||||
|
.PHONY: gen-api
|
||||||
|
gen-api: ## 重新產生後端 API handler / logic / types
|
||||||
|
$(MAKE) -C $(BACKEND_DIR) gen-api
|
||||||
|
|
||||||
|
.PHONY: fmt
|
||||||
|
fmt: ## 格式化後端 Go 程式
|
||||||
|
$(MAKE) -C $(BACKEND_DIR) fmt
|
||||||
|
|
||||||
|
.PHONY: test
|
||||||
|
test: ## 執行後端測試
|
||||||
|
$(MAKE) -C $(BACKEND_DIR) test
|
||||||
|
|
||||||
|
.PHONY: init
|
||||||
|
init: ## 初始化系統(建索引、種權限、建立 admin,讀 deploy/.env.prod)
|
||||||
|
$(MAKE) -C $(BACKEND_DIR) ENV_FILE=../deploy/.env.prod init
|
||||||
|
|
||||||
|
.PHONY: run
|
||||||
|
run: ## 啟動後端 API
|
||||||
|
$(MAKE) -C $(BACKEND_DIR) run
|
||||||
|
|
||||||
|
.PHONY: dev-all
|
||||||
|
dev-all: ## 啟動本機完整 dev stack(API、Go workers、Node workers、web)
|
||||||
|
./scripts/dev-all.sh
|
||||||
|
|
||||||
|
.PHONY: dev-all-stop
|
||||||
|
dev-all-stop: ## 停止本機 dev stack(釋放 8890 / 9101 / 9102 / web 等埠)
|
||||||
|
./scripts/dev-all-stop.sh
|
||||||
|
|
||||||
|
.PHONY: web-dev
|
||||||
|
web-dev: ## 啟動正式前端 dev server
|
||||||
|
cd $(WEB_DIR) && npm install && npm run dev
|
||||||
|
|
||||||
|
.PHONY: web-build
|
||||||
|
web-build: ## 建置正式前端
|
||||||
|
cd $(WEB_DIR) && npm install && npm run build
|
||||||
|
|
||||||
|
.PHONY: verify
|
||||||
|
verify: test web-build ## 後端測試與前端建置
|
||||||
|
|
||||||
|
.PHONY: dev-infra
|
||||||
|
dev-infra: ## 啟動本機 dev 資料服務(Mongo / Redis 容器,讀 deploy/.env.dev)
|
||||||
|
@test -f $(DEPLOY_DIR)/.env.dev || cp $(DEPLOY_DIR)/.env.dev.example $(DEPLOY_DIR)/.env.dev
|
||||||
|
@echo ">>> 請先編輯 $(DEPLOY_DIR)/.env.dev 再執行後續指令" >&2
|
||||||
|
$(INFRA_DEV_COMPOSE) up -d
|
||||||
|
$(INFRA_DEV_COMPOSE) ps
|
||||||
|
|
||||||
|
.PHONY: dev-infra-down
|
||||||
|
dev-infra-down: ## 關閉本機 dev 資料服務(保留資料 volume)
|
||||||
|
$(INFRA_DEV_COMPOSE) down
|
||||||
|
|
||||||
|
.PHONY: dev-infra-recreate
|
||||||
|
dev-infra-recreate: ## 清除 dev 資料 volume 並重啟(Mongo / Redis 全新狀態)
|
||||||
|
$(INFRA_DEV_COMPOSE) down -v
|
||||||
|
$(INFRA_DEV_COMPOSE) up -d
|
||||||
|
$(INFRA_DEV_COMPOSE) ps
|
||||||
|
|
||||||
|
.PHONY: prod-infra
|
||||||
|
prod-infra: ## 啟動 prod 資料服務(Mongo / Redis,讀 deploy/.env.prod)
|
||||||
|
@test -f $(DEPLOY_DIR)/.env.prod || cp $(DEPLOY_DIR)/.env.prod.example $(DEPLOY_DIR)/.env.prod
|
||||||
|
@echo ">>> 請先編輯 $(DEPLOY_DIR)/.env.prod 再執行後續指令" >&2
|
||||||
|
$(INFRA_PROD_COMPOSE) up -d
|
||||||
|
$(INFRA_PROD_COMPOSE) ps
|
||||||
|
|
||||||
|
.PHONY: prod-infra-down
|
||||||
|
prod-infra-down: ## 關閉 prod 資料服務(保留資料 volume)
|
||||||
|
$(INFRA_PROD_COMPOSE) down
|
||||||
|
|
||||||
|
.PHONY: prod-infra-recreate
|
||||||
|
prod-infra-recreate: ## 清除 prod 資料 volume 並重啟(Mongo / Redis 全新狀態)
|
||||||
|
$(INFRA_PROD_COMPOSE) down -v
|
||||||
|
$(INFRA_PROD_COMPOSE) up -d
|
||||||
|
$(INFRA_PROD_COMPOSE) ps
|
||||||
|
|
||||||
|
.PHONY: prod-install
|
||||||
|
prod-install: ## 一鍵部署 prod:複製 .env.prod、build 本地 image、啟動 app、init(需先啟動 infra)
|
||||||
|
@test -f $(DEPLOY_DIR)/.env.prod || cp $(DEPLOY_DIR)/.env.prod.example $(DEPLOY_DIR)/.env.prod
|
||||||
|
@echo ">>> 請先編輯 $(DEPLOY_DIR)/.env.prod 再執行後續指令" >&2
|
||||||
|
@echo ">>> 請確認 infra 已啟動(make prod-infra)" >&2
|
||||||
|
$(DEPLOY_COMPOSE) build
|
||||||
|
$(DEPLOY_COMPOSE) up -d
|
||||||
|
$(DEPLOY_COMPOSE) --profile init run --rm init
|
||||||
|
$(DEPLOY_COMPOSE) ps
|
||||||
|
|
||||||
|
.PHONY: prod-rebuild
|
||||||
|
prod-rebuild: ## 重新 build 本地 image 並重建 prod app stack(infra 不影響)
|
||||||
|
$(DEPLOY_COMPOSE) build
|
||||||
|
$(DEPLOY_COMPOSE) up -d
|
||||||
|
$(DEPLOY_COMPOSE) ps
|
||||||
|
|
||||||
|
.PHONY: prod-stop
|
||||||
|
prod-stop: ## 關閉 prod app stack(保留 infra 與資料)
|
||||||
|
$(DEPLOY_COMPOSE) down
|
||||||
|
|
||||||
|
.PHONY: prod-stop-all
|
||||||
|
prod-stop-all: ## 關閉 prod app + infra(保留資料 volume)
|
||||||
|
$(DEPLOY_COMPOSE) down
|
||||||
|
$(INFRA_PROD_COMPOSE) down
|
||||||
|
|
||||||
|
.PHONY: prod-recreate
|
||||||
|
prod-recreate: ## 完整清除 prod 全部資料 volume + 重建啟動(app + infra + init)
|
||||||
|
$(DEPLOY_COMPOSE) down -v
|
||||||
|
$(INFRA_PROD_COMPOSE) down -v
|
||||||
|
$(INFRA_PROD_COMPOSE) up -d
|
||||||
|
$(DEPLOY_COMPOSE) up -d
|
||||||
|
$(DEPLOY_COMPOSE) --profile init run --rm init
|
||||||
|
$(DEPLOY_COMPOSE) ps
|
||||||
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/`(只讀)。
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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,56 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"apps/backend/internal/config"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/core/conf"
|
|
||||||
"go.mongodb.org/mongo-driver/bson"
|
|
||||||
"go.mongodb.org/mongo-driver/mongo"
|
|
||||||
"go.mongodb.org/mongo-driver/mongo/options"
|
|
||||||
)
|
|
||||||
|
|
||||||
// init creates idempotent operational indexes only. It intentionally never
|
|
||||||
// inserts members or other business records.
|
|
||||||
func main() {
|
|
||||||
configFile := flag.String("f", "etc/gateway.yaml", "config file")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
var c config.Config
|
|
||||||
conf.MustLoad(*configFile, &c)
|
|
||||||
c.ApplyEnv()
|
|
||||||
if c.Mongo.URI == "" || c.Mongo.Database == "" {
|
|
||||||
panic("Mongo.URI and Mongo.Database are required")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
client, err := mongo.Connect(ctx, options.Client().ApplyURI(c.Mongo.URI))
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
defer client.Disconnect(context.Background())
|
|
||||||
|
|
||||||
db := client.Database(c.Mongo.Database)
|
|
||||||
indexes := map[string][]mongo.IndexModel{
|
|
||||||
"jobs": {
|
|
||||||
{Keys: bson.D{{Key: "status", Value: 1}, {Key: "run_after", Value: 1}, {Key: "created_at", Value: 1}}, Options: options.Index().SetName("claim_due_jobs")},
|
|
||||||
{Keys: bson.D{{Key: "status", Value: 1}, {Key: "completed_at", Value: 1}}, Options: options.Index().SetName("purge_terminal_jobs")},
|
|
||||||
},
|
|
||||||
"studio_outbox": {
|
|
||||||
{Keys: bson.D{{Key: "status", Value: 1}, {Key: "updated_at", Value: 1}}, Options: options.Index().SetName("worker_outbox_status_updated")},
|
|
||||||
{Keys: bson.D{{Key: "steps.status", Value: 1}, {Key: "steps.scheduled_at", Value: 1}, {Key: "steps.lease_expires_at", Value: 1}}, Options: options.Index().SetName("claim_due_outbox_steps")},
|
|
||||||
{Keys: bson.D{{Key: "owner_uid", Value: 1}, {Key: "updated_at", Value: -1}}, Options: options.Index().SetName("owner_outbox_updated")},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for collection, models := range indexes {
|
|
||||||
if _, err := db.Collection(collection).Indexes().CreateMany(ctx, models); err != nil {
|
|
||||||
panic(fmt.Errorf("create %s indexes: %w", collection, err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Println("database indexes initialized")
|
|
||||||
}
|
|
||||||
|
|
@ -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,481 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"apps/backend/internal/config"
|
|
||||||
redislock "apps/backend/internal/lib/redislock"
|
|
||||||
"apps/backend/internal/lib/securelog"
|
|
||||||
"apps/backend/internal/module/ai"
|
|
||||||
appnotifRepo "apps/backend/internal/module/appnotif/repository"
|
|
||||||
appnotifUC "apps/backend/internal/module/appnotif/usecase"
|
|
||||||
fsDomain "apps/backend/internal/module/filestorage/domain"
|
|
||||||
"apps/backend/internal/module/filestorage/noop"
|
|
||||||
"apps/backend/internal/module/filestorage/s3store"
|
|
||||||
jobDomain "apps/backend/internal/module/job/domain"
|
|
||||||
jobRepo "apps/backend/internal/module/job/repository"
|
|
||||||
jobUC "apps/backend/internal/module/job/usecase"
|
|
||||||
memberDomain "apps/backend/internal/module/member/domain"
|
|
||||||
memberRepo "apps/backend/internal/module/member/repository"
|
|
||||||
scoutDomain "apps/backend/internal/module/scout/domain"
|
|
||||||
scoutRepo "apps/backend/internal/module/scout/repository"
|
|
||||||
scoutUC "apps/backend/internal/module/scout/usecase"
|
|
||||||
studioPublish "apps/backend/internal/module/studio/publish"
|
|
||||||
studioRepo "apps/backend/internal/module/studio/repository"
|
|
||||||
studioUC "apps/backend/internal/module/studio/usecase"
|
|
||||||
threadsDomain "apps/backend/internal/module/threads/domain"
|
|
||||||
threadsProv "apps/backend/internal/module/threads/provider"
|
|
||||||
threadsRepo "apps/backend/internal/module/threads/repository"
|
|
||||||
threadsUC "apps/backend/internal/module/threads/usecase"
|
|
||||||
usageDomain "apps/backend/internal/module/usage/domain"
|
|
||||||
usageRepo "apps/backend/internal/module/usage/repository"
|
|
||||||
usageUC "apps/backend/internal/module/usage/usecase"
|
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/core/conf"
|
|
||||||
"github.com/zeromicro/go-zero/core/logx"
|
|
||||||
"github.com/zeromicro/go-zero/core/stores/redis"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 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
|
|
||||||
|
|
||||||
logx.Infof("haixun worker started id=%s interval=%s (demo + token_renew + persona_analyze + compose_mimic + outbox)", workerID, interval)
|
|
||||||
fmt.Printf("worker running id=%s interval=%s (Ctrl+C to stop)\n", workerID, interval)
|
|
||||||
|
|
||||||
sig := make(chan os.Signal, 1)
|
|
||||||
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
|
||||||
tick := time.NewTicker(interval)
|
|
||||||
defer tick.Stop()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
const outboxLockTTL = 3 * time.Minute
|
|
||||||
outboxLockKey := strings.Trim(c.Redis.Namespace, ":") + ":worker:outbox"
|
|
||||||
outboxLock := redislock.NewWithClient(redisClient, outboxLockKey, workerID, outboxLockTTL)
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-sig:
|
|
||||||
logx.Infof("worker %s shutting down", workerID)
|
|
||||||
return
|
|
||||||
case <-tick.C:
|
|
||||||
// 1) claim one due job
|
|
||||||
j, err := jobs.ClaimNext(ctx, workerID)
|
|
||||||
if err == nil {
|
|
||||||
switch j.TemplateType {
|
|
||||||
case "", jobDomain.TemplateDemo:
|
|
||||||
logx.Infof("worker %s demo job %s", workerID, j.ID)
|
|
||||||
if _, err := jobs.RunDemoToSuccess(ctx, j.ID); err != nil {
|
|
||||||
logx.Errorf("worker %s job %s failed: %v", workerID, j.ID, err)
|
|
||||||
_, _ = jobs.FailJob(ctx, j.ID, err.Error())
|
|
||||||
} else {
|
|
||||||
logx.Infof("worker %s job %s succeeded", workerID, j.ID)
|
|
||||||
}
|
|
||||||
case jobDomain.TemplateThreadsTokenRenew:
|
|
||||||
logx.Infof("worker %s token renew job %s account=%s", workerID, j.ID, j.RefID)
|
|
||||||
if err := runTokenRenew(ctx, jobs, threadsSvc, j); err != nil {
|
|
||||||
logx.Errorf("worker %s renew %s failed: %v", workerID, j.ID, err)
|
|
||||||
_, _ = jobs.FailJob(ctx, j.ID, err.Error())
|
|
||||||
} else {
|
|
||||||
logx.Infof("worker %s renew %s ok", workerID, j.ID)
|
|
||||||
}
|
|
||||||
case jobDomain.TemplatePersonaAnalyzeAccount, jobDomain.TemplatePersonaAnalyzeText:
|
|
||||||
logx.Infof("worker %s persona analyze job %s type=%s persona=%s", workerID, j.ID, j.TemplateType, j.RefID)
|
|
||||||
if err := runPersonaAnalyze(ctx, jobs, studio, j); err != nil {
|
|
||||||
logx.Errorf("worker %s persona %s failed: %v", workerID, j.ID, err)
|
|
||||||
_, _ = jobs.FailJob(ctx, j.ID, err.Error())
|
|
||||||
} else {
|
|
||||||
logx.Infof("worker %s persona %s ok", workerID, j.ID)
|
|
||||||
}
|
|
||||||
case jobDomain.TemplateComposeMimic:
|
|
||||||
logx.Infof("worker %s compose_mimic job %s", workerID, j.ID)
|
|
||||||
if err := runComposeMimic(ctx, jobs, studio, j); err != nil {
|
|
||||||
logx.Errorf("worker %s compose_mimic %s failed: %v", workerID, j.ID, err)
|
|
||||||
_, _ = jobs.FailJob(ctx, j.ID, err.Error())
|
|
||||||
} else {
|
|
||||||
logx.Infof("worker %s compose_mimic %s ok", workerID, j.ID)
|
|
||||||
}
|
|
||||||
case jobDomain.TemplatePlayGenerateScript:
|
|
||||||
logx.Infof("worker %s play_generate_script job %s play=%s", workerID, j.ID, j.RefID)
|
|
||||||
if err := runPlayGenerateScript(ctx, jobs, studio, j); err != nil {
|
|
||||||
logx.Errorf("worker %s play_generate_script %s failed: %v", workerID, j.ID, err)
|
|
||||||
_, _ = jobs.FailJob(ctx, j.ID, err.Error())
|
|
||||||
} else {
|
|
||||||
logx.Infof("worker %s play_generate_script %s ok", workerID, j.ID)
|
|
||||||
}
|
|
||||||
case jobDomain.TemplateScoutScan:
|
|
||||||
if err := runScoutScan(ctx, jobs, scoutSvc, j); err != nil {
|
|
||||||
logx.Errorf("worker %s scout scan %s failed: %v", workerID, j.ID, err)
|
|
||||||
_, _ = jobs.FailJob(ctx, j.ID, err.Error())
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
logx.Infof("worker %s unknown template %s job %s — fail", workerID, j.TemplateType, j.ID)
|
|
||||||
_, _ = jobs.FailJob(ctx, j.ID, "unknown template: "+j.TemplateType)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 2) One worker owns an outbox tick and renews its lease while claims run.
|
|
||||||
processOutbox(ctx, studio, outboxLock, workerID, outboxLockTTL)
|
|
||||||
// 3) 終態任務超過 2 天自動清除
|
|
||||||
if purged, err := jobs.PurgeExpiredTerminal(ctx); err != nil {
|
|
||||||
logx.Errorf("worker %s purge jobs: %v", workerID, err)
|
|
||||||
} else if purged > 0 {
|
|
||||||
logx.Infof("worker %s purged %d expired terminal job(s)", workerID, purged)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func processOutbox(ctx context.Context, studio *studioUC.Service, lock *redislock.Lock, workerID string, ttl time.Duration) {
|
|
||||||
locked, err := lock.Acquire(ctx)
|
|
||||||
if err != nil {
|
|
||||||
logx.Errorf("worker %s outbox lock acquire: %v", workerID, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !locked {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
runCtx, cancel := context.WithCancel(ctx)
|
|
||||||
renewFailure := make(chan error, 1)
|
|
||||||
renewStopped := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
defer close(renewStopped)
|
|
||||||
ticker := time.NewTicker(ttl / 3)
|
|
||||||
defer ticker.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-runCtx.Done():
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
renewed, renewErr := lock.Renew(runCtx)
|
|
||||||
if renewErr != nil {
|
|
||||||
renewFailure <- fmt.Errorf("renew: %w", renewErr)
|
|
||||||
cancel()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !renewed {
|
|
||||||
renewFailure <- errors.New("lease ownership lost")
|
|
||||||
cancel()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
defer func() {
|
|
||||||
cancel()
|
|
||||||
<-renewStopped
|
|
||||||
releaseCtx, releaseCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer releaseCancel()
|
|
||||||
if err := lock.Release(releaseCtx); err != nil {
|
|
||||||
logx.Errorf("worker %s outbox lock release: %v", workerID, err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
n, processErr := studio.ProcessDueSteps(runCtx, 0)
|
|
||||||
select {
|
|
||||||
case renewErr := <-renewFailure:
|
|
||||||
logx.Errorf("worker %s outbox lock renewal failed; claims canceled: %v", workerID, renewErr)
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
if processErr != nil {
|
|
||||||
logx.Errorf("worker %s outbox tick: %v", workerID, processErr)
|
|
||||||
} else if n > 0 {
|
|
||||||
logx.Infof("worker %s outbox published %d step(s)", workerID, n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func runScoutScan(ctx context.Context, jobs *jobUC.Service, scout *scoutUC.Service, j *jobDomain.Job) error {
|
|
||||||
var brief scoutDomain.RunBrief
|
|
||||||
if err := json.Unmarshal([]byte(j.Payload), &brief); err != nil {
|
|
||||||
return fmt.Errorf("invalid scout scan payload: %w", err)
|
|
||||||
}
|
|
||||||
if _, err := jobs.MarkRunningProgress(ctx, j.ID, 15, "海巡 · 準備搜尋來源"); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
posts, err := scout.RunScanFromBrief(ctx, j.OwnerUID, &brief)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := jobs.MarkRunningProgress(ctx, j.ID, 90, fmt.Sprintf("海巡 · 已寫入 %d 筆候選", len(posts))); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = jobs.SucceedJob(ctx, j.ID, fmt.Sprintf("海巡完成 · 命中 %d 筆", len(posts)))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func runTokenRenew(ctx context.Context, jobs *jobUC.Service, threads *threadsUC.Service, j *jobDomain.Job) error {
|
|
||||||
if j.RefID == "" {
|
|
||||||
return fmt.Errorf("缺少帳號 ref_id")
|
|
||||||
}
|
|
||||||
_, _ = jobs.MarkRunningProgress(ctx, j.ID, 15, "定期延長 Threads token · 準備中")
|
|
||||||
_, _ = jobs.MarkRunningProgress(ctx, j.ID, 40, "定期延長 Threads token · 向平台刷新中")
|
|
||||||
acc, err := threads.Refresh(ctx, j.OwnerUID, j.RefID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, _ = jobs.MarkRunningProgress(ctx, j.ID, 75, "定期延長 Threads token · 排程下一次")
|
|
||||||
// 下次再約 30 天
|
|
||||||
next := jobDomain.NowNano() + jobDomain.DefaultTokenRenewDelayNs
|
|
||||||
if acc != nil && acc.SessionExpiresAt > 0 {
|
|
||||||
early := acc.SessionExpiresAt - int64(30*24*time.Hour)
|
|
||||||
if early > jobDomain.NowNano() {
|
|
||||||
next = early
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ = jobs.ScheduleTokenRenew(ctx, j.OwnerUID, j.RefID, next)
|
|
||||||
_, _ = jobs.MarkRunningProgress(ctx, j.ID, 90, "定期延長 Threads token · 寫入完成")
|
|
||||||
_, err = jobs.SucceedJob(ctx, j.ID, "定期延長完成 · 已排程約 30 天後再執行")
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func runPersonaAnalyze(ctx context.Context, jobs *jobUC.Service, studio *studioUC.Service, j *jobDomain.Job) error {
|
|
||||||
if j.RefID == "" {
|
|
||||||
return fmt.Errorf("缺少人設 ref_id")
|
|
||||||
}
|
|
||||||
startSum := "人設分析 · 文字分析中…"
|
|
||||||
if j.TemplateType == jobDomain.TemplatePersonaAnalyzeAccount {
|
|
||||||
startSum = "人設分析 · 爬取公開貼文 + AI 分析中…(可離開頁面)"
|
|
||||||
}
|
|
||||||
_, _ = jobs.MarkRunningProgress(ctx, j.ID, 10, startSum)
|
|
||||||
|
|
||||||
err := studio.ExecutePersonaAnalyzeJob(ctx, j.TemplateType, j.OwnerUID, j.RefID, j.Payload, func(pct int, sum string) {
|
|
||||||
if pct < 10 {
|
|
||||||
pct = 10
|
|
||||||
}
|
|
||||||
if pct > 95 {
|
|
||||||
pct = 95
|
|
||||||
}
|
|
||||||
_, _ = jobs.MarkRunningProgress(ctx, j.ID, pct, sum)
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = jobs.SucceedJob(ctx, j.ID, "人設分析完成 · 已寫入指紋/範本")
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func runPlayGenerateScript(ctx context.Context, jobs *jobUC.Service, studio *studioUC.Service, j *jobDomain.Job) error {
|
|
||||||
var pl jobUC.PlayGenerateScriptPayload
|
|
||||||
if err := json.Unmarshal([]byte(j.Payload), &pl); err != nil {
|
|
||||||
return fmt.Errorf("invalid play_generate_script payload: %w", err)
|
|
||||||
}
|
|
||||||
playID := strings.TrimSpace(pl.PlayID)
|
|
||||||
if playID == "" {
|
|
||||||
playID = strings.TrimSpace(j.RefID)
|
|
||||||
}
|
|
||||||
if playID == "" {
|
|
||||||
return fmt.Errorf("empty play_id")
|
|
||||||
}
|
|
||||||
_, _ = jobs.MarkRunningProgress(ctx, j.ID, 15, "劇本產文 · 準備 AI(一次產全部)…")
|
|
||||||
llmCtx, cancel := context.WithTimeout(ctx, 4*time.Minute)
|
|
||||||
defer cancel()
|
|
||||||
_, _ = jobs.MarkRunningProgress(ctx, j.ID, 40, "劇本產文 · 呼叫模型中(可離開頁面)…")
|
|
||||||
n, err := studio.GeneratePlayScript(llmCtx, j.OwnerUID, playID, pl.OnlyEmpty)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
pl.Filled = n
|
|
||||||
body, _ := json.Marshal(pl)
|
|
||||||
_, _ = jobs.MarkRunningProgress(ctx, j.ID, 90, fmt.Sprintf("劇本產文 · 已寫入 %d 步…", n))
|
|
||||||
_, err = jobs.SucceedJobWithPayload(ctx, j.ID, fmt.Sprintf("劇本產文完成 · 已填 %d 步,請回互回方案查看", n), string(body))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func runComposeMimic(ctx context.Context, jobs *jobUC.Service, studio *studioUC.Service, j *jobDomain.Job) error {
|
|
||||||
var pl jobUC.ComposeMimicPayload
|
|
||||||
if err := json.Unmarshal([]byte(j.Payload), &pl); err != nil {
|
|
||||||
return fmt.Errorf("invalid compose_mimic payload: %w", err)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(pl.SourceText) == "" {
|
|
||||||
return fmt.Errorf("empty source_text")
|
|
||||||
}
|
|
||||||
_, _ = jobs.MarkRunningProgress(ctx, j.ID, 15, "仿寫貼文 · 準備 AI…")
|
|
||||||
// worker 不綁 gateway 120s;給模型較長時間(reasoning 模型需要)
|
|
||||||
llmCtx, cancel := context.WithTimeout(ctx, 4*time.Minute)
|
|
||||||
defer cancel()
|
|
||||||
_, _ = jobs.MarkRunningProgress(ctx, j.ID, 35, "仿寫貼文 · 呼叫模型中(可離開頁面)…")
|
|
||||||
text, err := studio.Mimic(llmCtx, j.OwnerUID, pl.SourceText, pl.PersonaID, pl.StructureNotes)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, _ = jobs.MarkRunningProgress(ctx, j.ID, 90, "仿寫貼文 · 寫入結果…")
|
|
||||||
pl.ResultText = text
|
|
||||||
body, _ := json.Marshal(pl)
|
|
||||||
_, err = jobs.SucceedJobWithPayload(ctx, j.ID, "仿寫完成 · 已可套用到正文", string(body))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// workerAIKeys — 與 gateway studioAIKeys 對齊
|
|
||||||
type workerAIKeys struct {
|
|
||||||
Members memberDomain.Repository
|
|
||||||
Resolver *usageUC.SettingsResolver
|
|
||||||
}
|
|
||||||
|
|
||||||
func (k *workerAIKeys) ResolveAI(ctx context.Context, ownerUID int64) (provider, model, apiKey string, err error) {
|
|
||||||
st, err := k.Members.GetSettings(ctx, ownerUID)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", "", err
|
|
||||||
}
|
|
||||||
if st == nil {
|
|
||||||
st = memberDomain.DefaultSettings(ownerUID)
|
|
||||||
}
|
|
||||||
// 完全依會員 AI 設定(provider / model / key),不寫死模型
|
|
||||||
provider = ai.NormalizeProvider(st.Provider)
|
|
||||||
model = strings.TrimSpace(st.Model)
|
|
||||||
if model == "" {
|
|
||||||
return provider, "", "", fmt.Errorf("請到設定選擇 AI 模型")
|
|
||||||
}
|
|
||||||
if k.Resolver != nil {
|
|
||||||
_, key, rerr := k.Resolver.ResolveKey(ctx, ownerUID, usageDomain.MeterAICopy)
|
|
||||||
if rerr != nil && !errors.Is(rerr, usageDomain.ErrNoKey) {
|
|
||||||
return provider, model, "", rerr
|
|
||||||
}
|
|
||||||
if rerr == nil && strings.TrimSpace(key) != "" {
|
|
||||||
return provider, model, key, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if key := st.KeyForProvider(provider); key != "" {
|
|
||||||
return provider, model, key, nil
|
|
||||||
}
|
|
||||||
return provider, model, "", fmt.Errorf("no AI key(請到設定填寫 Key,或確認平台已配置)")
|
|
||||||
}
|
|
||||||
|
|
||||||
type workerDevMode struct {
|
|
||||||
Members memberDomain.Repository
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *workerDevMode) DevModeEnabled(ctx context.Context, uid int64) (bool, error) {
|
|
||||||
if d == nil || d.Members == nil {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
st, err := d.Members.GetSettings(ctx, uid)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if st == nil {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
return st.DevModeEnabled, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func workerStorage(c config.Config) fsDomain.Storage {
|
|
||||||
os := c.ObjectStorage
|
|
||||||
if strings.TrimSpace(os.Endpoint) == "" {
|
|
||||||
logx.Info("worker storage: disabled (no endpoint)")
|
|
||||||
return noop.New()
|
|
||||||
}
|
|
||||||
s, err := s3store.New(s3store.Config{
|
|
||||||
Endpoint: os.Endpoint, Region: os.Region, Bucket: os.Bucket,
|
|
||||||
AccessKey: os.AccessKey, SecretKey: os.SecretKey,
|
|
||||||
UsePathStyle: os.UsePathStyle, PublicBaseURL: os.PublicBaseURL,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
logx.Errorf("worker storage init failed: %v", err)
|
|
||||||
return noop.New()
|
|
||||||
}
|
|
||||||
logx.Infof("worker storage: minio/s3 bucket=%s (ephemeral publish images cleanup enabled)", os.Bucket)
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
@ -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,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)
|
|
||||||
# 遠端 dev:https://threads-tool-dev.30cm.net
|
|
||||||
PublicWebBase: "https://threads-tool-dev.30cm.net"
|
|
||||||
# 瀏覽器直連 gateway 時的 allowlist;同源 proxy 不需要列在此處。
|
|
||||||
# CORS_ALLOWED_ORIGINS 可用逗號附加額外的明確 origin,不能使用 *。
|
|
||||||
CORSAllowedOrigins:
|
|
||||||
- "https://threads-tool-dev.30cm.net"
|
|
||||||
- "http://127.0.0.1:5173"
|
|
||||||
- "http://localhost:5173"
|
|
||||||
|
|
||||||
# 郵件品牌(hermes 模板;LogoURL 空 = PublicWebBase/brand-mark.jpg)
|
|
||||||
Brand:
|
|
||||||
Name: "Harbor Desk"
|
|
||||||
LogoURL: ""
|
|
||||||
Copyright: "© Harbor Desk"
|
|
||||||
|
|
||||||
# 物件儲存(頭像等)— **只接 MinIO/S3**
|
|
||||||
#
|
|
||||||
# Endpoint = gateway 上傳用(本機 MinIO,一定要 :9000)
|
|
||||||
# PublicBaseURL = 瀏覽器讀圖用(走 nginx :80,不要寫 port、不要加 /bucket)
|
|
||||||
# 成品網址 = PublicBaseURL + "/" + key
|
|
||||||
# 例:http://10.0.0.5/haixun-assets/avatar/1000000/xxx.png
|
|
||||||
#
|
|
||||||
# Secret 建議用 env,不要 commit:
|
|
||||||
# export HAIXUN_STORAGE_S3_ACCESS_KEY=haixun
|
|
||||||
# export HAIXUN_STORAGE_S3_SECRET_KEY='你的密碼'
|
|
||||||
# 或 MINIO_ROOT_USER / MINIO_ROOT_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-dev.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,25 +0,0 @@
|
||||||
syntax = "v1"
|
|
||||||
|
|
||||||
info (
|
|
||||||
title: "haixun-gateway"
|
|
||||||
desc: "Harbor Desk Phase C live API (goctl)"
|
|
||||||
author: "haixun"
|
|
||||||
version: "0.2.0"
|
|
||||||
)
|
|
||||||
|
|
||||||
import "common.api"
|
|
||||||
import "ping.api"
|
|
||||||
import "auth.api"
|
|
||||||
import "admin.api"
|
|
||||||
import "settings.api"
|
|
||||||
import "media.api"
|
|
||||||
import "threads.api"
|
|
||||||
import "jobs.api"
|
|
||||||
import "notifications.api"
|
|
||||||
import "usage.api"
|
|
||||||
import "billing.api"
|
|
||||||
import "proxy.api"
|
|
||||||
import "extension.api"
|
|
||||||
import "studio.api"
|
|
||||||
import "m5.api"
|
|
||||||
import "invite.api"
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
syntax = "v1"
|
|
||||||
|
|
||||||
// M6 邀請網絡:會員補填碼 + admin 樹/移動(防環)
|
|
||||||
|
|
||||||
type (
|
|
||||||
InviteMemberBrief {
|
|
||||||
Uid int64 `json:"uid"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
DisplayName string `json:"display_name"`
|
|
||||||
InviteCode string `json:"invite_code"`
|
|
||||||
ParentUid int64 `json:"parent_uid,optional"` // 0 = 無上線
|
|
||||||
Roles []string `json:"roles"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
DownlineCount int `json:"downline_count"`
|
|
||||||
TotalDownlineCount int `json:"total_downline_count"`
|
|
||||||
Depth int `json:"depth"`
|
|
||||||
JoinedAt int64 `json:"joined_at"`
|
|
||||||
AvatarUrl string `json:"avatar_url,optional"`
|
|
||||||
}
|
|
||||||
|
|
||||||
InviteTreeNode {
|
|
||||||
Uid int64 `json:"uid"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
DisplayName string `json:"display_name"`
|
|
||||||
InviteCode string `json:"invite_code"`
|
|
||||||
ParentUid int64 `json:"parent_uid,optional"`
|
|
||||||
Roles []string `json:"roles"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
DownlineCount int `json:"downline_count"`
|
|
||||||
TotalDownlineCount int `json:"total_downline_count"`
|
|
||||||
Depth int `json:"depth"`
|
|
||||||
JoinedAt int64 `json:"joined_at"`
|
|
||||||
AvatarUrl string `json:"avatar_url,optional"`
|
|
||||||
Children []InviteTreeNode `json:"children"`
|
|
||||||
}
|
|
||||||
|
|
||||||
MyInviteNetworkData {
|
|
||||||
Me InviteMemberBrief `json:"me"`
|
|
||||||
Upline *InviteMemberBrief `json:"upline,optional"`
|
|
||||||
Downlines []InviteMemberBrief `json:"downlines"`
|
|
||||||
TotalDownlineCount int `json:"total_downline_count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
ApplyInviteCodeReq {
|
|
||||||
Code string `json:"code"`
|
|
||||||
}
|
|
||||||
|
|
||||||
InviteTreeData {
|
|
||||||
List []InviteTreeNode `json:"list"`
|
|
||||||
}
|
|
||||||
|
|
||||||
MoveInviteMemberReq {
|
|
||||||
Uid int64 `json:"uid"`
|
|
||||||
ParentUid int64 `json:"parent_uid,optional"` // 0 = 獨立根
|
|
||||||
}
|
|
||||||
|
|
||||||
ListParentCandidatesReq {
|
|
||||||
ExcludeSubtreeRootUid int64 `form:"exclude_subtree_root_uid,optional"`
|
|
||||||
}
|
|
||||||
|
|
||||||
InviteParentCandidatesData {
|
|
||||||
List []InviteMemberBrief `json:"list"`
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@server (
|
|
||||||
group: invite
|
|
||||||
prefix: /api/v1/invite
|
|
||||||
middleware: AuthJWT
|
|
||||||
)
|
|
||||||
service gateway {
|
|
||||||
@handler GetMyInviteNetwork
|
|
||||||
get /me returns (MyInviteNetworkData)
|
|
||||||
|
|
||||||
@handler ApplyInviteCode
|
|
||||||
post /apply (ApplyInviteCodeReq) returns (MyInviteNetworkData)
|
|
||||||
}
|
|
||||||
|
|
||||||
@server (
|
|
||||||
group: invite
|
|
||||||
prefix: /api/v1/invite
|
|
||||||
middleware: AuthJWT,AdminAuth
|
|
||||||
)
|
|
||||||
service gateway {
|
|
||||||
@handler GetInviteTree
|
|
||||||
get /tree returns (InviteTreeData)
|
|
||||||
|
|
||||||
@handler MoveInviteMember
|
|
||||||
post /move (MoveInviteMemberReq) returns (InviteMemberBrief)
|
|
||||||
|
|
||||||
@handler ListInviteParentCandidates
|
|
||||||
get /parent-candidates (ListParentCandidatesReq) returns (InviteParentCandidatesData)
|
|
||||||
}
|
|
||||||
|
|
@ -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,470 +0,0 @@
|
||||||
syntax = "v1"
|
|
||||||
|
|
||||||
// M5: inspire + research + media image + scout
|
|
||||||
|
|
||||||
type (
|
|
||||||
// --- Inspire ---
|
|
||||||
TrendPublic {
|
|
||||||
Id string `json:"id"`
|
|
||||||
Kind string `json:"kind"`
|
|
||||||
Label string `json:"label"`
|
|
||||||
Summary string `json:"summary"`
|
|
||||||
Heat int `json:"heat"`
|
|
||||||
Keywords []string `json:"keywords"`
|
|
||||||
Samples []string `json:"samples"`
|
|
||||||
SourceLabel string `json:"source_label"`
|
|
||||||
ObservedAt int64 `json:"observed_at"`
|
|
||||||
}
|
|
||||||
TrendListData {
|
|
||||||
List []TrendPublic `json:"list"`
|
|
||||||
}
|
|
||||||
InspireElementPublic {
|
|
||||||
Id string `json:"id"`
|
|
||||||
Kind string `json:"kind"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Body string `json:"body"`
|
|
||||||
RefId string `json:"ref_id,optional"`
|
|
||||||
Reusable bool `json:"reusable"`
|
|
||||||
CreatedAt int64 `json:"created_at"`
|
|
||||||
UpdatedAt int64 `json:"updated_at"`
|
|
||||||
}
|
|
||||||
InspireElementListData {
|
|
||||||
List []InspireElementPublic `json:"list"`
|
|
||||||
}
|
|
||||||
InspireElementSaveReq {
|
|
||||||
Id string `json:"id,optional"`
|
|
||||||
Kind string `json:"kind,optional"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Body string `json:"body"`
|
|
||||||
RefId string `json:"ref_id,optional"`
|
|
||||||
Reusable bool `json:"reusable,optional"`
|
|
||||||
}
|
|
||||||
InspireElementIdPath {
|
|
||||||
Id string `path:"id"`
|
|
||||||
}
|
|
||||||
InspireDraftPublic {
|
|
||||||
Title string `json:"title,optional"`
|
|
||||||
Body string `json:"body"`
|
|
||||||
}
|
|
||||||
InspireMessagePublic {
|
|
||||||
Id string `json:"id"`
|
|
||||||
Role string `json:"role"`
|
|
||||||
Text string `json:"text"`
|
|
||||||
Draft InspireDraftPublic `json:"draft,optional"`
|
|
||||||
CreatedAt int64 `json:"created_at"`
|
|
||||||
}
|
|
||||||
InspireSessionPublic {
|
|
||||||
Id string `json:"id"`
|
|
||||||
Title string `json:"title,optional"`
|
|
||||||
Messages []InspireMessagePublic `json:"messages"`
|
|
||||||
PinnedElementIds []string `json:"pinned_element_ids"`
|
|
||||||
CreatedAt int64 `json:"created_at,optional"`
|
|
||||||
UpdatedAt int64 `json:"updated_at"`
|
|
||||||
}
|
|
||||||
// 列表摘要(不含完整 messages)
|
|
||||||
InspireSessionSummaryPublic {
|
|
||||||
Id string `json:"id"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Preview string `json:"preview"`
|
|
||||||
MessageCount int `json:"message_count"`
|
|
||||||
UpdatedAt int64 `json:"updated_at"`
|
|
||||||
CreatedAt int64 `json:"created_at"`
|
|
||||||
Active bool `json:"active"`
|
|
||||||
}
|
|
||||||
InspireSessionListData {
|
|
||||||
List []InspireSessionSummaryPublic `json:"list"`
|
|
||||||
}
|
|
||||||
InspireSessionIdPath {
|
|
||||||
Id string `path:"id"`
|
|
||||||
}
|
|
||||||
InspireSessionCreateReq {
|
|
||||||
Title string `json:"title,optional"`
|
|
||||||
}
|
|
||||||
InspireChatReq {
|
|
||||||
Message string `json:"message"`
|
|
||||||
PinnedIds []string `json:"pinned_ids,optional"`
|
|
||||||
Mode string `json:"mode,optional"` // chat|generate
|
|
||||||
PersonaId string `json:"persona_id,optional"`
|
|
||||||
SessionId string `json:"session_id,optional"` // 空 = active
|
|
||||||
// generate 專用:待改寫素材(鎖定內容);空則拒絕產文
|
|
||||||
Material string `json:"material,optional"`
|
|
||||||
}
|
|
||||||
InspireChatData {
|
|
||||||
Session InspireSessionPublic `json:"session"`
|
|
||||||
Messages []InspireMessagePublic `json:"messages"`
|
|
||||||
}
|
|
||||||
// 觀測:與送出相同的 prompt 組裝(不呼叫 AI、不計費)
|
|
||||||
InspirePromptPreviewReq {
|
|
||||||
Message string `json:"message,optional"`
|
|
||||||
PinnedIds []string `json:"pinned_ids,optional"`
|
|
||||||
Mode string `json:"mode,optional"` // chat|generate
|
|
||||||
PersonaId string `json:"persona_id,optional"`
|
|
||||||
SessionId string `json:"session_id,optional"`
|
|
||||||
Material string `json:"material,optional"`
|
|
||||||
}
|
|
||||||
InspirePromptPinnedPublic {
|
|
||||||
Id string `json:"id"`
|
|
||||||
Kind string `json:"kind"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Body string `json:"body"`
|
|
||||||
}
|
|
||||||
InspirePromptBlockPublic {
|
|
||||||
Title string `json:"title"`
|
|
||||||
Body string `json:"body"`
|
|
||||||
}
|
|
||||||
InspirePromptPreviewData {
|
|
||||||
Prompt string `json:"prompt"`
|
|
||||||
Mode string `json:"mode"`
|
|
||||||
Sections []string `json:"sections"`
|
|
||||||
Blocks []InspirePromptBlockPublic `json:"blocks"`
|
|
||||||
Fingerprint string `json:"fingerprint"`
|
|
||||||
CharCount int `json:"char_count"`
|
|
||||||
RuneCount int `json:"rune_count"`
|
|
||||||
Pinned []InspirePromptPinnedPublic `json:"pinned"`
|
|
||||||
Note string `json:"note,optional"`
|
|
||||||
}
|
|
||||||
ResearchSearchReq {
|
|
||||||
Query string `json:"query"`
|
|
||||||
}
|
|
||||||
ResearchHitPublic {
|
|
||||||
Id string `json:"id"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Snippet string `json:"snippet"`
|
|
||||||
Url string `json:"url"`
|
|
||||||
Summary string `json:"summary,optional"`
|
|
||||||
LearnPoints []string `json:"learn_points,optional"`
|
|
||||||
ReplyHooks []string `json:"reply_hooks,optional"`
|
|
||||||
SourceLabel string `json:"source_label,optional"`
|
|
||||||
}
|
|
||||||
ResearchSearchData {
|
|
||||||
List []ResearchHitPublic `json:"list"`
|
|
||||||
}
|
|
||||||
GenerateImageReq {
|
|
||||||
Prompt string `json:"prompt"`
|
|
||||||
}
|
|
||||||
GenerateImageData {
|
|
||||||
Id string `json:"id"`
|
|
||||||
Url string `json:"url"`
|
|
||||||
Prompt string `json:"prompt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Scout ---
|
|
||||||
BrandPublic {
|
|
||||||
Id string `json:"id"`
|
|
||||||
DisplayName string `json:"display_name"`
|
|
||||||
Brief string `json:"brief"`
|
|
||||||
TargetAudience string `json:"target_audience,optional"`
|
|
||||||
Goals string `json:"goals,optional"`
|
|
||||||
}
|
|
||||||
BrandListData {
|
|
||||||
List []BrandPublic `json:"list"`
|
|
||||||
}
|
|
||||||
BrandSaveReq {
|
|
||||||
Id string `json:"id,optional"`
|
|
||||||
DisplayName string `json:"display_name"`
|
|
||||||
Brief string `json:"brief,optional"`
|
|
||||||
TargetAudience string `json:"target_audience,optional"`
|
|
||||||
Goals string `json:"goals,optional"`
|
|
||||||
}
|
|
||||||
BrandIdPath {
|
|
||||||
Id string `path:"id"`
|
|
||||||
}
|
|
||||||
BrandActiveData {
|
|
||||||
Id string `json:"id"`
|
|
||||||
}
|
|
||||||
BrandSetActiveReq {
|
|
||||||
Id string `json:"id"`
|
|
||||||
}
|
|
||||||
ProductPublic {
|
|
||||||
Id string `json:"id"`
|
|
||||||
BrandId string `json:"brand_id"`
|
|
||||||
Label string `json:"label"`
|
|
||||||
ProductContext string `json:"product_context"`
|
|
||||||
MatchTags []string `json:"match_tags"`
|
|
||||||
PainPoints []string `json:"pain_points"`
|
|
||||||
PlacementUrl string `json:"placement_url,optional"`
|
|
||||||
CreatedAt int64 `json:"created_at"`
|
|
||||||
UpdatedAt int64 `json:"updated_at"`
|
|
||||||
}
|
|
||||||
ProductListData {
|
|
||||||
List []ProductPublic `json:"list"`
|
|
||||||
}
|
|
||||||
ProductSaveReq {
|
|
||||||
Id string `json:"id,optional"`
|
|
||||||
BrandId string `json:"brand_id"`
|
|
||||||
Label string `json:"label"`
|
|
||||||
ProductContext string `json:"product_context,optional"`
|
|
||||||
MatchTags []string `json:"match_tags,optional"`
|
|
||||||
PainPoints []string `json:"pain_points,optional"`
|
|
||||||
PlacementUrl string `json:"placement_url,optional"`
|
|
||||||
}
|
|
||||||
ProductIdPath {
|
|
||||||
Id string `path:"id"`
|
|
||||||
}
|
|
||||||
ProductListReq {
|
|
||||||
BrandId string `form:"brand_id,optional"`
|
|
||||||
}
|
|
||||||
ImportProductReq {
|
|
||||||
Url string `json:"url"`
|
|
||||||
}
|
|
||||||
ImportProductData {
|
|
||||||
Label string `json:"label"`
|
|
||||||
ProductContext string `json:"product_context"`
|
|
||||||
PainPoints []string `json:"pain_points"`
|
|
||||||
MatchTags []string `json:"match_tags"`
|
|
||||||
PlacementUrl string `json:"placement_url"`
|
|
||||||
SourceNote string `json:"source_note"`
|
|
||||||
}
|
|
||||||
ScoutBriefReq {
|
|
||||||
Intent string `json:"intent"`
|
|
||||||
BrandId string `json:"brand_id,optional"`
|
|
||||||
ProductId string `json:"product_id,optional"`
|
|
||||||
Purpose string `json:"purpose,optional"`
|
|
||||||
Deep bool `json:"deep,optional"`
|
|
||||||
}
|
|
||||||
ScoutBriefPublic {
|
|
||||||
Intent string `json:"intent"`
|
|
||||||
Mode string `json:"mode"`
|
|
||||||
BrandId string `json:"brand_id,optional"`
|
|
||||||
ProductId string `json:"product_id,optional"`
|
|
||||||
ProductLabel string `json:"product_label,optional"`
|
|
||||||
Pains []string `json:"pains"`
|
|
||||||
Tags []string `json:"tags"`
|
|
||||||
Periphery []string `json:"periphery"`
|
|
||||||
ScanTerms []string `json:"scan_terms"`
|
|
||||||
PlacementNote string `json:"placement_note,optional"`
|
|
||||||
ResponseStance string `json:"response_stance,optional"`
|
|
||||||
ThemeKey string `json:"theme_key,optional"`
|
|
||||||
ThemeLabel string `json:"theme_label,optional"`
|
|
||||||
ProductContext string `json:"product_context,optional"`
|
|
||||||
}
|
|
||||||
ScoutScanReq {
|
|
||||||
Brief ScoutBriefPublic `json:"brief"`
|
|
||||||
}
|
|
||||||
ScoutScanJobData {
|
|
||||||
Job JobPublic `json:"job"`
|
|
||||||
}
|
|
||||||
ScoutPostPublic {
|
|
||||||
Id string `json:"id"`
|
|
||||||
BrandId string `json:"brand_id,optional"`
|
|
||||||
Author string `json:"author"`
|
|
||||||
Text string `json:"text"`
|
|
||||||
SearchTag string `json:"search_tag"`
|
|
||||||
Opportunity string `json:"opportunity"`
|
|
||||||
OutreachStatus string `json:"outreach_status"`
|
|
||||||
DraftText string `json:"draft_text,optional"`
|
|
||||||
Score int `json:"score"`
|
|
||||||
MatchedProductId string `json:"matched_product_id,optional"`
|
|
||||||
MatchedProductLabel string `json:"matched_product_label,optional"`
|
|
||||||
MatchReason string `json:"match_reason,optional"`
|
|
||||||
ScoutMode string `json:"scout_mode,optional"`
|
|
||||||
IntentSnippet string `json:"intent_snippet,optional"`
|
|
||||||
ThemeKey string `json:"theme_key,optional"`
|
|
||||||
ThemeLabel string `json:"theme_label,optional"`
|
|
||||||
ScanPath string `json:"scan_path,optional"`
|
|
||||||
Classification string `json:"classification,optional"`
|
|
||||||
Permalink string `json:"permalink,optional"`
|
|
||||||
CreatedAt int64 `json:"created_at"`
|
|
||||||
}
|
|
||||||
ScoutPostListData {
|
|
||||||
List []ScoutPostPublic `json:"list"`
|
|
||||||
}
|
|
||||||
ScoutPostListReq {
|
|
||||||
BrandId string `form:"brand_id,optional"`
|
|
||||||
}
|
|
||||||
ScoutPostIdPath {
|
|
||||||
Id string `path:"id"`
|
|
||||||
}
|
|
||||||
ScoutDraftPathReq {
|
|
||||||
Id string `path:"id"`
|
|
||||||
PersonaId string `json:"persona_id,optional"`
|
|
||||||
}
|
|
||||||
ScoutSendPathReq {
|
|
||||||
Id string `path:"id"`
|
|
||||||
Text string `json:"text,optional"`
|
|
||||||
AccountId string `json:"account_id,optional"`
|
|
||||||
}
|
|
||||||
ScoutThemePath {
|
|
||||||
ThemeKey string `path:"themeKey"`
|
|
||||||
}
|
|
||||||
ScoutHomeworkPublic {
|
|
||||||
ThemeKey string `json:"theme_key"`
|
|
||||||
ThemeLabel string `json:"theme_label"`
|
|
||||||
Purpose string `json:"purpose"`
|
|
||||||
Brief ScoutBriefPublic `json:"brief"`
|
|
||||||
CreatedAt int64 `json:"created_at"`
|
|
||||||
}
|
|
||||||
ScoutHomeworkListData {
|
|
||||||
List []ScoutHomeworkPublic `json:"list"`
|
|
||||||
}
|
|
||||||
ScoutHomeworkSaveReq {
|
|
||||||
ThemeKey string `json:"theme_key"`
|
|
||||||
ThemeLabel string `json:"theme_label"`
|
|
||||||
Purpose string `json:"purpose,optional"`
|
|
||||||
Brief ScoutBriefPublic `json:"brief"`
|
|
||||||
}
|
|
||||||
ScoutCrawlerSessionReq {
|
|
||||||
Token string `json:"token"`
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@server (
|
|
||||||
jwt: Auth
|
|
||||||
group: inspire
|
|
||||||
prefix: /api/v1/inspire
|
|
||||||
middleware: AuthJWT
|
|
||||||
)
|
|
||||||
service gateway {
|
|
||||||
@handler ListTrends
|
|
||||||
get /trends returns (TrendListData)
|
|
||||||
|
|
||||||
@handler RefreshTrends
|
|
||||||
post /trends/refresh returns (TrendListData)
|
|
||||||
|
|
||||||
@handler ListInspireElements
|
|
||||||
get /elements returns (InspireElementListData)
|
|
||||||
|
|
||||||
@handler SaveInspireElement
|
|
||||||
post /elements (InspireElementSaveReq) returns (InspireElementPublic)
|
|
||||||
|
|
||||||
@handler RemoveInspireElement
|
|
||||||
delete /elements/:id (InspireElementIdPath) returns (OkData)
|
|
||||||
|
|
||||||
@handler GetInspireSession
|
|
||||||
get /session returns (InspireSessionPublic)
|
|
||||||
|
|
||||||
@handler ClearInspireSession
|
|
||||||
post /session/clear returns (InspireSessionPublic)
|
|
||||||
|
|
||||||
@handler ListInspireSessions
|
|
||||||
get /sessions returns (InspireSessionListData)
|
|
||||||
|
|
||||||
@handler CreateInspireSession
|
|
||||||
post /sessions (InspireSessionCreateReq) returns (InspireSessionPublic)
|
|
||||||
|
|
||||||
@handler GetInspireSessionById
|
|
||||||
get /sessions/:id (InspireSessionIdPath) returns (InspireSessionPublic)
|
|
||||||
|
|
||||||
@handler ActivateInspireSession
|
|
||||||
post /sessions/:id/activate (InspireSessionIdPath) returns (InspireSessionPublic)
|
|
||||||
|
|
||||||
@handler DeleteInspireSession
|
|
||||||
delete /sessions/:id (InspireSessionIdPath) returns (InspireSessionPublic)
|
|
||||||
|
|
||||||
@handler InspireChat
|
|
||||||
post /chat (InspireChatReq) returns (InspireChatData)
|
|
||||||
|
|
||||||
@handler InspirePromptPreview
|
|
||||||
post /prompt-preview (InspirePromptPreviewReq) returns (InspirePromptPreviewData)
|
|
||||||
}
|
|
||||||
|
|
||||||
@server (
|
|
||||||
jwt: Auth
|
|
||||||
group: research
|
|
||||||
prefix: /api/v1/research
|
|
||||||
middleware: AuthJWT
|
|
||||||
)
|
|
||||||
service gateway {
|
|
||||||
@handler ResearchSearch
|
|
||||||
post /search (ResearchSearchReq) returns (ResearchSearchData)
|
|
||||||
}
|
|
||||||
|
|
||||||
@server (
|
|
||||||
jwt: Auth
|
|
||||||
group: media
|
|
||||||
prefix: /api/v1/media
|
|
||||||
middleware: AuthJWT
|
|
||||||
)
|
|
||||||
service gateway {
|
|
||||||
@handler GenerateImage
|
|
||||||
post /generate-image (GenerateImageReq) returns (GenerateImageData)
|
|
||||||
}
|
|
||||||
|
|
||||||
@server (
|
|
||||||
jwt: Auth
|
|
||||||
group: scout
|
|
||||||
prefix: /api/v1/scout
|
|
||||||
middleware: AuthJWT
|
|
||||||
)
|
|
||||||
service gateway {
|
|
||||||
@handler ListBrands
|
|
||||||
get /brands returns (BrandListData)
|
|
||||||
|
|
||||||
@handler SaveBrand
|
|
||||||
post /brands (BrandSaveReq) returns (BrandPublic)
|
|
||||||
|
|
||||||
@handler GetBrand
|
|
||||||
get /brands/:id (BrandIdPath) returns (BrandPublic)
|
|
||||||
|
|
||||||
@handler RemoveBrand
|
|
||||||
delete /brands/:id (BrandIdPath) returns (OkData)
|
|
||||||
|
|
||||||
@handler GetActiveBrand
|
|
||||||
get /brands-active returns (BrandActiveData)
|
|
||||||
|
|
||||||
@handler SetActiveBrand
|
|
||||||
post /brands-active (BrandSetActiveReq) returns (BrandActiveData)
|
|
||||||
|
|
||||||
@handler ListProducts
|
|
||||||
get /products (ProductListReq) returns (ProductListData)
|
|
||||||
|
|
||||||
@handler ListAllProducts
|
|
||||||
get /products/all returns (ProductListData)
|
|
||||||
|
|
||||||
@handler SaveProduct
|
|
||||||
post /products (ProductSaveReq) returns (ProductPublic)
|
|
||||||
|
|
||||||
@handler GetProduct
|
|
||||||
get /products/:id (ProductIdPath) returns (ProductPublic)
|
|
||||||
|
|
||||||
@handler RemoveProduct
|
|
||||||
delete /products/:id (ProductIdPath) returns (OkData)
|
|
||||||
|
|
||||||
@handler ImportProduct
|
|
||||||
post /products/import (ImportProductReq) returns (ImportProductData)
|
|
||||||
|
|
||||||
@handler PrepareBrief
|
|
||||||
post /brief (ScoutBriefReq) returns (ScoutBriefPublic)
|
|
||||||
|
|
||||||
@handler RunScan
|
|
||||||
post /scan (ScoutScanReq) returns (ScoutScanJobData)
|
|
||||||
|
|
||||||
@handler ListScoutPosts
|
|
||||||
get /posts (ScoutPostListReq) returns (ScoutPostListData)
|
|
||||||
|
|
||||||
@handler DraftOutreach
|
|
||||||
post /posts/:id/draft (ScoutDraftPathReq) returns (ScoutPostPublic)
|
|
||||||
|
|
||||||
@handler SkipOutreach
|
|
||||||
post /posts/:id/skip (ScoutPostIdPath) returns (ScoutPostPublic)
|
|
||||||
|
|
||||||
@handler MarkPublished
|
|
||||||
post /posts/:id/mark-published (ScoutPostIdPath) returns (ScoutPostPublic)
|
|
||||||
|
|
||||||
@handler SendOutreach
|
|
||||||
post /posts/:id/send (ScoutSendPathReq) returns (ScoutPostPublic)
|
|
||||||
|
|
||||||
@handler RemoveScoutPost
|
|
||||||
delete /posts/:id (ScoutPostIdPath) returns (OkData)
|
|
||||||
|
|
||||||
@handler RemoveScoutTheme
|
|
||||||
delete /themes/:themeKey (ScoutThemePath) returns (OkData)
|
|
||||||
|
|
||||||
@handler ListHomework
|
|
||||||
get /homework returns (ScoutHomeworkListData)
|
|
||||||
|
|
||||||
@handler SaveHomework
|
|
||||||
post /homework (ScoutHomeworkSaveReq) returns (ScoutHomeworkPublic)
|
|
||||||
|
|
||||||
@handler GetHomework
|
|
||||||
get /homework/:themeKey (ScoutThemePath) returns (ScoutHomeworkPublic)
|
|
||||||
|
|
||||||
@handler RemoveHomework
|
|
||||||
delete /homework/:themeKey (ScoutThemePath) returns (OkData)
|
|
||||||
|
|
||||||
@handler SetCrawlerSession
|
|
||||||
post /crawler-session (ScoutCrawlerSessionReq) returns (OkData)
|
|
||||||
|
|
||||||
@handler ClearCrawlerSession
|
|
||||||
delete /crawler-session returns (OkData)
|
|
||||||
}
|
|
||||||
|
|
@ -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,568 +0,0 @@
|
||||||
syntax = "v1"
|
|
||||||
|
|
||||||
// M4 Studio: personas / plays / outbox / compose / own-posts / mentions
|
|
||||||
|
|
||||||
type (
|
|
||||||
PersonaDraftPublic {
|
|
||||||
Identity string `json:"identity"`
|
|
||||||
Tone string `json:"tone"`
|
|
||||||
Audience string `json:"audience"`
|
|
||||||
Hooks string `json:"hooks"`
|
|
||||||
LanguageFingerprint string `json:"language_fingerprint"`
|
|
||||||
Rhythm string `json:"rhythm"`
|
|
||||||
Punctuation string `json:"punctuation"`
|
|
||||||
ContentPatterns string `json:"content_patterns"`
|
|
||||||
KnowledgeTranslation string `json:"knowledge_translation"`
|
|
||||||
CtaStyle string `json:"cta_style"`
|
|
||||||
Examples string `json:"examples"`
|
|
||||||
Avoid string `json:"avoid"`
|
|
||||||
}
|
|
||||||
|
|
||||||
StyleDimensionPublic {
|
|
||||||
Summary string `json:"summary"`
|
|
||||||
Evidence []string `json:"evidence,optional"`
|
|
||||||
}
|
|
||||||
|
|
||||||
PersonaStylePublic {
|
|
||||||
Draft PersonaDraftPublic `json:"draft"`
|
|
||||||
DraftText string `json:"draft_text"`
|
|
||||||
Source string `json:"source"`
|
|
||||||
BenchmarkUsername string `json:"benchmark_username,optional"`
|
|
||||||
SourceLabel string `json:"source_label,optional"`
|
|
||||||
SampleCount int `json:"sample_count"`
|
|
||||||
AnalyzedAt int64 `json:"analyzed_at,optional"`
|
|
||||||
SamplePreviews []string `json:"sample_previews,optional"`
|
|
||||||
// 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"`
|
|
||||||
CreatedAt int64 `json:"created_at"`
|
|
||||||
UpdatedAt int64 `json:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
PersonaListData {
|
|
||||||
List []PersonaPublic `json:"list"`
|
|
||||||
}
|
|
||||||
|
|
||||||
PersonaSaveReq {
|
|
||||||
Id string `json:"id,optional"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Brief string `json:"brief,optional"`
|
|
||||||
Status string `json:"status,optional"`
|
|
||||||
Style PersonaStylePublic `json:"style,optional"`
|
|
||||||
Guard PersonaGuardPublic `json:"guard,optional"`
|
|
||||||
Voice string `json:"voice,optional"`
|
|
||||||
Notes string `json:"notes,optional"`
|
|
||||||
}
|
|
||||||
|
|
||||||
PersonaIdPath {
|
|
||||||
Id string `path:"id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
PersonaActiveData {
|
|
||||||
Id string `json:"id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
PersonaSetActiveReq {
|
|
||||||
Id string `json:"id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
PersonaAnalyzeTextReq {
|
|
||||||
Id string `path:"id"`
|
|
||||||
RawText string `json:"raw_text"`
|
|
||||||
SourceLabel string `json:"source_label,optional"`
|
|
||||||
}
|
|
||||||
|
|
||||||
PersonaAnalyzeAccountReq {
|
|
||||||
Id string `path:"id"`
|
|
||||||
Username string `json:"username"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Plays ---
|
|
||||||
ExternalTargetPublic {
|
|
||||||
Url string `json:"url"`
|
|
||||||
RawUrl string `json:"raw_url,optional"`
|
|
||||||
Shortcode string `json:"shortcode,optional"`
|
|
||||||
AuthorUsername string `json:"author_username,optional"`
|
|
||||||
TextPreview string `json:"text_preview,optional"`
|
|
||||||
MediaId string `json:"media_id,optional"`
|
|
||||||
ResolvedAt int64 `json:"resolved_at,optional"`
|
|
||||||
}
|
|
||||||
|
|
||||||
PlayStepPublic {
|
|
||||||
Id string `json:"id"`
|
|
||||||
SortOrder int `json:"sort_order"`
|
|
||||||
Kind string `json:"kind"`
|
|
||||||
AccountId string `json:"account_id"`
|
|
||||||
Text string `json:"text"`
|
|
||||||
DelayFromPreviousSec int `json:"delay_from_previous_sec"`
|
|
||||||
PersonaId string `json:"persona_id,optional"`
|
|
||||||
BrandId string `json:"brand_id,optional"`
|
|
||||||
ImageUrls []string `json:"image_urls,optional"`
|
|
||||||
}
|
|
||||||
|
|
||||||
PlayPublic {
|
|
||||||
Id string `json:"id"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Topic string `json:"topic"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
LeadAccountId string `json:"lead_account_id"`
|
|
||||||
CastAccountIds []string `json:"cast_account_ids"`
|
|
||||||
TargetOwnPostId string `json:"target_own_post_id,optional"`
|
|
||||||
TargetExternal ExternalTargetPublic `json:"target_external,optional"`
|
|
||||||
Steps []PlayStepPublic `json:"steps"`
|
|
||||||
ScheduleStartAt int64 `json:"schedule_start_at"`
|
|
||||||
IntervalMinutes int `json:"interval_minutes,optional"`
|
|
||||||
CreatedAt int64 `json:"created_at"`
|
|
||||||
UpdatedAt int64 `json:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
PlayListData {
|
|
||||||
List []PlayPublic `json:"list"`
|
|
||||||
}
|
|
||||||
|
|
||||||
PlaySaveReq {
|
|
||||||
Id string `json:"id,optional"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Topic string `json:"topic,optional"`
|
|
||||||
Status string `json:"status,optional"`
|
|
||||||
LeadAccountId string `json:"lead_account_id"`
|
|
||||||
CastAccountIds []string `json:"cast_account_ids,optional"`
|
|
||||||
TargetOwnPostId string `json:"target_own_post_id,optional"`
|
|
||||||
TargetExternal ExternalTargetPublic `json:"target_external,optional"`
|
|
||||||
Steps []PlayStepPublic `json:"steps"`
|
|
||||||
ScheduleStartAt int64 `json:"schedule_start_at,optional"`
|
|
||||||
IntervalMinutes int `json:"interval_minutes,optional"`
|
|
||||||
}
|
|
||||||
|
|
||||||
PlayIdPath {
|
|
||||||
Id string `path:"id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
PlayByPostReq {
|
|
||||||
OwnPostId string `form:"own_post_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
PlayByExternalReq {
|
|
||||||
Url string `form:"url"`
|
|
||||||
}
|
|
||||||
|
|
||||||
PlayResolveReq {
|
|
||||||
Url string `json:"url"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AI 產劇本一步(真 LLM;互回/串場共用)
|
|
||||||
PlayGenerateStepReq {
|
|
||||||
PersonaId string `json:"persona_id,optional"`
|
|
||||||
Context string `json:"context"` // 主貼/上一則/目標文
|
|
||||||
Topic string `json:"topic,optional"`
|
|
||||||
SpeakerLabel string `json:"speaker_label,optional"`
|
|
||||||
IsLead bool `json:"is_lead,optional"`
|
|
||||||
// mode: reply(預設)| root(串場主貼)
|
|
||||||
Mode string `json:"mode,optional"`
|
|
||||||
}
|
|
||||||
|
|
||||||
PlayGenerateStepData {
|
|
||||||
Text string `json:"text"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 一次產完整劇本(背景 job)
|
|
||||||
PlayGenerateScriptData {
|
|
||||||
JobId string `json:"job_id"`
|
|
||||||
Async bool `json:"async"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Outbox ---
|
|
||||||
OutboxStepPublic {
|
|
||||||
Id string `json:"id"`
|
|
||||||
StepId string `json:"step_id"`
|
|
||||||
SortOrder int `json:"sort_order"`
|
|
||||||
Kind string `json:"kind"`
|
|
||||||
AccountId string `json:"account_id"`
|
|
||||||
Text string `json:"text"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Error string `json:"error,optional"`
|
|
||||||
ScheduledAt int64 `json:"scheduled_at,optional"`
|
|
||||||
PublishedAt int64 `json:"published_at,optional"`
|
|
||||||
}
|
|
||||||
|
|
||||||
OutboxPublic {
|
|
||||||
Id string `json:"id"`
|
|
||||||
PlayId string `json:"play_id"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Steps []OutboxStepPublic `json:"steps"`
|
|
||||||
CreatedAt int64 `json:"created_at"`
|
|
||||||
UpdatedAt int64 `json:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
OutboxListData {
|
|
||||||
List []OutboxPublic `json:"list"`
|
|
||||||
}
|
|
||||||
|
|
||||||
OutboxIdPath {
|
|
||||||
Id string `path:"id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
OutboxRetryPath {
|
|
||||||
Id string `path:"id"`
|
|
||||||
StepId string `path:"stepId"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Compose ---
|
|
||||||
ComposeMimicReq {
|
|
||||||
SourceText string `json:"source_text"`
|
|
||||||
PersonaId string `json:"persona_id,optional"`
|
|
||||||
// 可選:從「我的貼文」結構分析帶過來的備註(鉤子/結構/可複製點)
|
|
||||||
StructureNotes string `json:"structure_notes,optional"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 仿寫改背景 Job:立刻回 job_id;完成後從 job.payload.result_text 取文
|
|
||||||
ComposeMimicData {
|
|
||||||
// 相容:同步路徑才有;Job 路徑為空
|
|
||||||
Text string `json:"text,optional"`
|
|
||||||
JobId string `json:"job_id,optional"`
|
|
||||||
Async bool `json:"async,optional"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 人設試產:依指紋真 LLM 產主貼 + 回文(可帶新聞話題)
|
|
||||||
ComposePersonaPreviewReq {
|
|
||||||
PersonaId string `json:"persona_id"`
|
|
||||||
// 手動話題;空則嘗試上網取新聞靈感
|
|
||||||
Topic string `json:"topic,optional"`
|
|
||||||
// 預設 true:無 topic 時抓即時新聞標題當靈感
|
|
||||||
UseNews bool `json:"use_news,optional"`
|
|
||||||
}
|
|
||||||
|
|
||||||
ComposePersonaPreviewData {
|
|
||||||
Topic string `json:"topic"`
|
|
||||||
TopicSource string `json:"topic_source"` // news | manual | fallback
|
|
||||||
PostText string `json:"post_text"`
|
|
||||||
ReplyText string `json:"reply_text"`
|
|
||||||
Notes string `json:"notes,optional"`
|
|
||||||
}
|
|
||||||
|
|
||||||
ComposeViralReq {
|
|
||||||
Text string `json:"text"`
|
|
||||||
}
|
|
||||||
|
|
||||||
ViralAnalysisPublic {
|
|
||||||
Hooks string `json:"hooks"`
|
|
||||||
Structure string `json:"structure"`
|
|
||||||
Emotion string `json:"emotion"`
|
|
||||||
Summary string `json:"summary"`
|
|
||||||
Cta string `json:"cta,optional"`
|
|
||||||
Copyable string `json:"copyable,optional"`
|
|
||||||
Risks string `json:"risks,optional"`
|
|
||||||
}
|
|
||||||
|
|
||||||
ComposePublishReq {
|
|
||||||
AccountId string `json:"account_id"`
|
|
||||||
Text string `json:"text"`
|
|
||||||
Title string `json:"title,optional"`
|
|
||||||
ImageUrls []string `json:"image_urls,optional"`
|
|
||||||
ScheduleStartAt int64 `json:"schedule_start_at,optional"`
|
|
||||||
// Threads 話題標籤(topic_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,192 +0,0 @@
|
||||||
syntax = "v1"
|
|
||||||
|
|
||||||
type (
|
|
||||||
UsageEventPublic {
|
|
||||||
Id string `json:"id"`
|
|
||||||
Uid int64 `json:"uid"`
|
|
||||||
Meter string `json:"meter"`
|
|
||||||
Credits int `json:"credits"`
|
|
||||||
KeyMode string `json:"key_mode"`
|
|
||||||
Label string `json:"label"`
|
|
||||||
Source string `json:"source"`
|
|
||||||
CreatedAt int64 `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
UsageMeterCount {
|
|
||||||
Count int `json:"count"`
|
|
||||||
Credits int `json:"credits"`
|
|
||||||
}
|
|
||||||
|
|
||||||
UsagePlatformBlock {
|
|
||||||
CreditsUsed int `json:"credits_used"`
|
|
||||||
CreditsRemaining int `json:"credits_remaining"`
|
|
||||||
CreditsTotal int `json:"credits_total"`
|
|
||||||
CallCount int `json:"call_count"`
|
|
||||||
ByMeter map[string]UsageMeterCount `json:"by_meter,optional"`
|
|
||||||
}
|
|
||||||
|
|
||||||
UsageByokBlock {
|
|
||||||
CallCount int `json:"call_count"`
|
|
||||||
ByMeter map[string]UsageMeterCount `json:"by_meter,optional"`
|
|
||||||
}
|
|
||||||
|
|
||||||
UsageSummaryData {
|
|
||||||
MonthKey string `json:"month_key"`
|
|
||||||
Uid int64 `json:"uid"`
|
|
||||||
PlanId string `json:"plan_id"`
|
|
||||||
Unlimited bool `json:"unlimited"`
|
|
||||||
Platform UsagePlatformBlock `json:"platform"`
|
|
||||||
Byok UsageByokBlock `json:"byok"`
|
|
||||||
// FE progress bar (platform only)
|
|
||||||
TotalCredits int `json:"total_credits"`
|
|
||||||
RemainingCredits int `json:"remaining_credits"`
|
|
||||||
}
|
|
||||||
|
|
||||||
UsageSummaryReq {
|
|
||||||
MonthKey string `form:"month_key,optional"`
|
|
||||||
}
|
|
||||||
|
|
||||||
UsageEventsReq {
|
|
||||||
MonthKey string `form:"month_key,optional"`
|
|
||||||
KeyMode string `form:"key_mode,optional"` // platform|byok|all
|
|
||||||
Limit int `form:"limit,optional"`
|
|
||||||
}
|
|
||||||
|
|
||||||
UsageEventsData {
|
|
||||||
List []UsageEventPublic `json:"list"`
|
|
||||||
}
|
|
||||||
|
|
||||||
UsagePrefsData {
|
|
||||||
PlanId string `json:"plan_id"`
|
|
||||||
Unlimited bool `json:"unlimited"`
|
|
||||||
}
|
|
||||||
|
|
||||||
UsageSetPrefsReq {
|
|
||||||
Uid string `json:"uid"`
|
|
||||||
PlanId string `json:"plan_id,optional"`
|
|
||||||
Unlimited *bool `json:"unlimited,optional"`
|
|
||||||
}
|
|
||||||
|
|
||||||
UsagePurchaseReq {
|
|
||||||
PlanId string `json:"plan_id"`
|
|
||||||
MockRef string `json:"mock_ref,optional"`
|
|
||||||
}
|
|
||||||
|
|
||||||
UsagePurchasePublic {
|
|
||||||
Id string `json:"id"`
|
|
||||||
PlanId string `json:"plan_id"`
|
|
||||||
MockRef string `json:"mock_ref,optional"`
|
|
||||||
CreatedAt int64 `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
UsagePurchasesData {
|
|
||||||
List []UsagePurchasePublic `json:"list"`
|
|
||||||
}
|
|
||||||
|
|
||||||
UsageTenantRow {
|
|
||||||
Uid int64 `json:"uid"`
|
|
||||||
PlanId string `json:"plan_id"`
|
|
||||||
Unlimited bool `json:"unlimited"`
|
|
||||||
PlatformCreditsUsed int `json:"platform_credits_used"`
|
|
||||||
ByokCallCount int `json:"byok_call_count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
UsageTenantSummaryData {
|
|
||||||
MonthKey string `json:"month_key"`
|
|
||||||
PlatformCreditsUsed int `json:"platform_credits_used"`
|
|
||||||
ByokCallCount int `json:"byok_call_count"`
|
|
||||||
Members []UsageTenantRow `json:"members"`
|
|
||||||
}
|
|
||||||
|
|
||||||
UsageTenantSummaryReq {
|
|
||||||
MonthKey string `form:"month_key,optional"`
|
|
||||||
}
|
|
||||||
|
|
||||||
UsageTenantAnalyticsReq {
|
|
||||||
Granularity string `form:"granularity"` // day|month|year
|
|
||||||
From string `form:"from"` // inclusive YYYY-MM-DD UTC
|
|
||||||
To string `form:"to"` // inclusive YYYY-MM-DD UTC
|
|
||||||
}
|
|
||||||
|
|
||||||
UsageTenantAnalyticsBucket {
|
|
||||||
Key string `json:"key"`
|
|
||||||
Label string `json:"label"`
|
|
||||||
PurchasedCredits int `json:"purchased_credits"`
|
|
||||||
ConsumedCredits int `json:"consumed_credits"`
|
|
||||||
AiCalls int `json:"ai_calls"`
|
|
||||||
SearchCalls int `json:"search_calls"`
|
|
||||||
ByokCallCount int `json:"byok_call_count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
UsageTenantAnalyticsMember {
|
|
||||||
Uid int64 `json:"uid"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
DisplayName string `json:"display_name"`
|
|
||||||
Unlimited bool `json:"unlimited"`
|
|
||||||
PlanId string `json:"plan_id"`
|
|
||||||
PurchasedCredits int `json:"purchased_credits"`
|
|
||||||
TotalCredits int `json:"total_credits"`
|
|
||||||
AiCalls int `json:"ai_calls"`
|
|
||||||
SearchCalls int `json:"search_calls"`
|
|
||||||
ByokCallCount int `json:"byok_call_count"`
|
|
||||||
RemainingCredits int `json:"remaining_credits"`
|
|
||||||
Pct int `json:"pct"`
|
|
||||||
}
|
|
||||||
|
|
||||||
UsageTenantAnalyticsData {
|
|
||||||
Granularity string `json:"granularity"`
|
|
||||||
From string `json:"from"`
|
|
||||||
To string `json:"to"`
|
|
||||||
RangeLabel string `json:"range_label"`
|
|
||||||
PurchasedCredits int `json:"purchased_credits"`
|
|
||||||
ConsumedCredits int `json:"consumed_credits"`
|
|
||||||
RemainingCredits int `json:"remaining_credits"`
|
|
||||||
Pct int `json:"pct"`
|
|
||||||
AiCalls int `json:"ai_calls"`
|
|
||||||
SearchCalls int `json:"search_calls"`
|
|
||||||
ByMeter map[string]UsageMeterCount `json:"by_meter"`
|
|
||||||
Byok UsageByokBlock `json:"byok"`
|
|
||||||
Series []UsageTenantAnalyticsBucket `json:"series"`
|
|
||||||
Members []UsageTenantAnalyticsMember `json:"members"`
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@server (
|
|
||||||
jwt: Auth
|
|
||||||
group: usage
|
|
||||||
prefix: /api/v1/usage
|
|
||||||
middleware: AuthJWT
|
|
||||||
)
|
|
||||||
service gateway {
|
|
||||||
@handler GetUsageSummary
|
|
||||||
get /summary (UsageSummaryReq) returns (UsageSummaryData)
|
|
||||||
|
|
||||||
@handler ListUsageEvents
|
|
||||||
get /events (UsageEventsReq) returns (UsageEventsData)
|
|
||||||
|
|
||||||
@handler GetUsagePrefs
|
|
||||||
get /prefs returns (UsagePrefsData)
|
|
||||||
|
|
||||||
@handler PurchasePlan
|
|
||||||
post /purchase (UsagePurchaseReq) returns (UsagePurchasePublic)
|
|
||||||
|
|
||||||
@handler ListMyPurchases
|
|
||||||
get /purchases returns (UsagePurchasesData)
|
|
||||||
}
|
|
||||||
|
|
||||||
@server (
|
|
||||||
jwt: Auth
|
|
||||||
group: usage
|
|
||||||
prefix: /api/v1/usage
|
|
||||||
middleware: AuthJWT,AdminAuth
|
|
||||||
)
|
|
||||||
service gateway {
|
|
||||||
@handler SetUsagePrefs
|
|
||||||
post /prefs (UsageSetPrefsReq) returns (UsagePrefsData)
|
|
||||||
|
|
||||||
@handler GetTenantUsageSummary
|
|
||||||
get /tenant-summary (UsageTenantSummaryReq) returns (UsageTenantSummaryData)
|
|
||||||
|
|
||||||
@handler GetTenantUsageAnalytics
|
|
||||||
get /tenant-analytics (UsageTenantAnalyticsReq) returns (UsageTenantAnalyticsData)
|
|
||||||
}
|
|
||||||
|
|
@ -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,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-dev.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-dev.30cm.net
|
|
||||||
PublicAPIBase string `json:",optional"`
|
|
||||||
// Brand — 郵件 chrome(hermes);LogoURL 空則用 PublicWebBase + /brand-mark.jpg
|
|
||||||
Brand struct {
|
|
||||||
Name string `json:",optional"`
|
|
||||||
LogoURL string `json:",optional"` // absolute URL
|
|
||||||
Copyright string `json:",optional"`
|
|
||||||
} `json:",optional"`
|
|
||||||
// ObjectStorage — 頭像等二進位;**僅 MinIO / S3**(不落本機磁碟)
|
|
||||||
ObjectStorage struct {
|
|
||||||
Endpoint string `json:",optional"` // 必填,e.g. http://127.0.0.1:9000
|
|
||||||
Region string `json:",optional,default=us-east-1"`
|
|
||||||
Bucket string `json:",optional,default=haixun-assets"`
|
|
||||||
AccessKey string `json:",optional"`
|
|
||||||
SecretKey string `json:",optional"`
|
|
||||||
UsePathStyle bool `json:",optional,default=true"`
|
|
||||||
PublicBaseURL string `json:",optional"` // 公開讀取前綴,勿尾斜線
|
|
||||||
} `json:",optional"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplyEnv overlays secrets from environment.
|
|
||||||
func (c *Config) ApplyEnv() {
|
|
||||||
if strings.TrimSpace(c.Redis.Namespace) == "" {
|
|
||||||
c.Redis.Namespace = "haixun:dev:v1"
|
|
||||||
}
|
|
||||||
if c.Brand.Name == "" {
|
|
||||||
c.Brand.Name = "Harbor Desk"
|
|
||||||
}
|
|
||||||
if c.Brand.Copyright == "" {
|
|
||||||
c.Brand.Copyright = "© Harbor Desk"
|
|
||||||
}
|
|
||||||
if v := os.Getenv("MONGO_URI"); v != "" {
|
|
||||||
c.Mongo.URI = v
|
|
||||||
}
|
|
||||||
if v := os.Getenv("MONGO_DATABASE"); v != "" {
|
|
||||||
c.Mongo.Database = v
|
|
||||||
}
|
|
||||||
if v := os.Getenv("REDIS_HOST"); v != "" && len(c.CacheRedis) > 0 {
|
|
||||||
c.CacheRedis[0].RedisConf.Host = v
|
|
||||||
}
|
|
||||||
if len(c.CacheRedis) > 0 {
|
|
||||||
// monc 快取 Redis 帳密(本機 infra 常開 requirepass)
|
|
||||||
if v := os.Getenv("REDIS_PASSWORD"); v != "" {
|
|
||||||
c.CacheRedis[0].RedisConf.Pass = v
|
|
||||||
} else if v := os.Getenv("HAIXUN_REDIS_PASSWORD"); v != "" {
|
|
||||||
c.CacheRedis[0].RedisConf.Pass = v
|
|
||||||
}
|
|
||||||
if v := os.Getenv("HAIXUN_REDIS_ADDR"); v != "" {
|
|
||||||
c.CacheRedis[0].RedisConf.Host = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if v := firstEnv("REDIS_NAMESPACE", "HAIXUN_REDIS_NAMESPACE"); v != "" {
|
|
||||||
c.Redis.Namespace = strings.Trim(v, ":")
|
|
||||||
}
|
|
||||||
if v := firstEnv("AI_MODEL_CACHE_FINGERPRINT_SECRET", "HAIXUN_AI_MODEL_CACHE_FINGERPRINT_SECRET"); v != "" {
|
|
||||||
c.Redis.AIModelCacheFingerprintSecret = v
|
|
||||||
}
|
|
||||||
if v := os.Getenv("AUTH_ACCESS_SECRET"); v != "" {
|
|
||||||
c.Auth.AccessSecret = v
|
|
||||||
}
|
|
||||||
if v := os.Getenv("AUTH_REFRESH_SECRET"); v != "" {
|
|
||||||
c.Auth.RefreshSecret = v
|
|
||||||
}
|
|
||||||
if v := os.Getenv("STRIPE_ENABLED"); v != "" {
|
|
||||||
c.Stripe.Enabled = v == "1" || strings.EqualFold(v, "true")
|
|
||||||
}
|
|
||||||
if v := os.Getenv("STRIPE_SECRET_KEY"); v != "" {
|
|
||||||
c.Stripe.SecretKey = v
|
|
||||||
}
|
|
||||||
if v := os.Getenv("STRIPE_WEBHOOK_SECRET"); v != "" {
|
|
||||||
c.Stripe.WebhookSecret = v
|
|
||||||
}
|
|
||||||
if v := os.Getenv("STRIPE_STARTER_PRICE_ID"); v != "" {
|
|
||||||
c.Stripe.StarterPriceID = v
|
|
||||||
}
|
|
||||||
if v := os.Getenv("STRIPE_PRO_PRICE_ID"); v != "" {
|
|
||||||
c.Stripe.ProPriceID = v
|
|
||||||
}
|
|
||||||
if v := os.Getenv("STRIPE_SUCCESS_URL"); v != "" {
|
|
||||||
c.Stripe.SuccessURL = v
|
|
||||||
}
|
|
||||||
if v := os.Getenv("STRIPE_CANCEL_URL"); v != "" {
|
|
||||||
c.Stripe.CancelURL = v
|
|
||||||
}
|
|
||||||
if v := os.Getenv("STRIPE_PORTAL_RETURN_URL"); v != "" {
|
|
||||||
c.Stripe.PortalReturnURL = v
|
|
||||||
}
|
|
||||||
if v := os.Getenv("MEMBER_SETTINGS_ENCRYPTION_KEY_ID"); v != "" {
|
|
||||||
c.MemberSettingsEncryption.CurrentKeyID = v
|
|
||||||
}
|
|
||||||
if v := os.Getenv("MEMBER_SETTINGS_ENCRYPTION_KEY"); v != "" {
|
|
||||||
c.MemberSettingsEncryption.Key = v
|
|
||||||
}
|
|
||||||
if v := os.Getenv("PLATFORM_AI_KEY"); v != "" {
|
|
||||||
c.Platform.AIKey = v
|
|
||||||
}
|
|
||||||
if v := firstEnv("PLATFORM_XAI_KEY", "XAI_API_KEY"); v != "" {
|
|
||||||
c.Platform.XAIKey = v
|
|
||||||
}
|
|
||||||
if v := firstEnv("PLATFORM_OPENCODE_KEY", "OPENCODE_API_KEY", "OPENCODE_GO_API_KEY"); v != "" {
|
|
||||||
c.Platform.OpenCodeKey = v
|
|
||||||
}
|
|
||||||
// legacy AIKey → XAI when XAI empty
|
|
||||||
if c.Platform.XAIKey == "" && c.Platform.AIKey != "" {
|
|
||||||
c.Platform.XAIKey = c.Platform.AIKey
|
|
||||||
}
|
|
||||||
if v := os.Getenv("PLATFORM_EXA_KEY"); v != "" {
|
|
||||||
c.Platform.ExaKey = v
|
|
||||||
}
|
|
||||||
if v := firstEnv("THREADS_APP_ID", "HAIXUN_THREADS_APP_ID"); v != "" {
|
|
||||||
c.Platform.ThreadsAppId = v
|
|
||||||
}
|
|
||||||
if v := firstEnv("THREADS_APP_SECRET", "HAIXUN_THREADS_APP_SECRET"); v != "" {
|
|
||||||
c.Platform.ThreadsAppSecret = v
|
|
||||||
}
|
|
||||||
if v := os.Getenv("SCOUT_SESSION_SECRET"); v != "" {
|
|
||||||
c.Scout.SessionSecret = v
|
|
||||||
}
|
|
||||||
if v := os.Getenv("SCOUT_CRAWLER_ENDPOINT"); v != "" {
|
|
||||||
c.Scout.CrawlerEndpoint = v
|
|
||||||
}
|
|
||||||
if v := os.Getenv("SCOUT_CRAWLER_TOKEN"); v != "" {
|
|
||||||
c.Scout.CrawlerToken = v
|
|
||||||
}
|
|
||||||
if v := os.Getenv("SEED_ADMIN_PASSWORD"); v != "" {
|
|
||||||
c.Seed.AdminPassword = v
|
|
||||||
}
|
|
||||||
if v := os.Getenv("WORKER_ID"); v != "" {
|
|
||||||
c.Worker.ID = v
|
|
||||||
}
|
|
||||||
if v := os.Getenv("PORT"); v != "" {
|
|
||||||
var p int
|
|
||||||
valid := true
|
|
||||||
for _, ch := range v {
|
|
||||||
if ch < '0' || ch > '9' {
|
|
||||||
valid = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
p = p*10 + int(ch-'0')
|
|
||||||
}
|
|
||||||
if valid && p > 0 {
|
|
||||||
c.Port = p
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Mail / 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
// Code generated by goctl. DO NOT EDIT.
|
|
||||||
// goctl <no value>
|
|
||||||
|
|
||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"apps/backend/internal/logic/auth"
|
|
||||||
"apps/backend/internal/response"
|
|
||||||
"apps/backend/internal/svc"
|
|
||||||
"apps/backend/internal/types"
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ResetPasswordHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.AuthResetPasswordReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
l := auth.NewResetPasswordLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.ResetPassword(&req)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
// Code generated by goctl. DO NOT EDIT.
|
|
||||||
// goctl <no value>
|
|
||||||
|
|
||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"apps/backend/internal/logic/auth"
|
|
||||||
"apps/backend/internal/response"
|
|
||||||
"apps/backend/internal/svc"
|
|
||||||
)
|
|
||||||
|
|
||||||
func SendEmailCodeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
l := auth.NewSendEmailCodeLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.SendEmailCode()
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
// Code generated by goctl. DO NOT EDIT.
|
|
||||||
// goctl <no value>
|
|
||||||
|
|
||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"apps/backend/internal/logic/auth"
|
|
||||||
"apps/backend/internal/response"
|
|
||||||
"apps/backend/internal/svc"
|
|
||||||
"apps/backend/internal/types"
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
)
|
|
||||||
|
|
||||||
func UnbindAccountHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.AuthUnbindReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
l := auth.NewUnbindAccountLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.UnbindAccount(&req)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
// Code generated by goctl. DO NOT EDIT.
|
|
||||||
// goctl <no value>
|
|
||||||
|
|
||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"apps/backend/internal/logic/auth"
|
|
||||||
"apps/backend/internal/response"
|
|
||||||
"apps/backend/internal/svc"
|
|
||||||
"apps/backend/internal/types"
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
)
|
|
||||||
|
|
||||||
func UpdateProfileHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.AuthProfilePatchReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
l := auth.NewUpdateProfileLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.UpdateProfile(&req)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
// Code generated by goctl. DO NOT EDIT.
|
|
||||||
// goctl <no value>
|
|
||||||
|
|
||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"apps/backend/internal/logic/auth"
|
|
||||||
"apps/backend/internal/response"
|
|
||||||
"apps/backend/internal/svc"
|
|
||||||
"apps/backend/internal/types"
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
)
|
|
||||||
|
|
||||||
func UpdateProfilePutHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.AuthProfilePatchReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
l := auth.NewUpdateProfilePutLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.UpdateProfilePut(&req)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
// Code generated by goctl. DO NOT EDIT.
|
|
||||||
// goctl <no value>
|
|
||||||
|
|
||||||
package auth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"apps/backend/internal/logic/auth"
|
|
||||||
"apps/backend/internal/response"
|
|
||||||
"apps/backend/internal/svc"
|
|
||||||
"apps/backend/internal/types"
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
)
|
|
||||||
|
|
||||||
func VerifyEmailHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.AuthVerifyEmailReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
l := auth.NewVerifyEmailLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.VerifyEmail(&req)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
// Code generated by goctl. DO NOT EDIT.
|
|
||||||
// goctl <no value>
|
|
||||||
|
|
||||||
package billing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"apps/backend/internal/logic/billing"
|
|
||||||
"apps/backend/internal/response"
|
|
||||||
"apps/backend/internal/svc"
|
|
||||||
"apps/backend/internal/types"
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
)
|
|
||||||
|
|
||||||
func CreateBillingCheckoutSessionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.BillingCheckoutCreateReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
l := billing.NewCreateBillingCheckoutSessionLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.CreateBillingCheckoutSession(&req)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
// Code generated by goctl. DO NOT EDIT.
|
|
||||||
// goctl <no value>
|
|
||||||
|
|
||||||
package billing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"apps/backend/internal/logic/billing"
|
|
||||||
"apps/backend/internal/response"
|
|
||||||
"apps/backend/internal/svc"
|
|
||||||
)
|
|
||||||
|
|
||||||
func CreateBillingPortalSessionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
l := billing.NewCreateBillingPortalSessionLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.CreateBillingPortalSession()
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
// Code generated by goctl. DO NOT EDIT.
|
|
||||||
// goctl <no value>
|
|
||||||
|
|
||||||
package billing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"apps/backend/internal/logic/billing"
|
|
||||||
"apps/backend/internal/response"
|
|
||||||
"apps/backend/internal/svc"
|
|
||||||
"apps/backend/internal/types"
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
)
|
|
||||||
|
|
||||||
func GetBillingCheckoutSessionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.BillingCheckoutGetReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
l := billing.NewGetBillingCheckoutSessionLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.GetBillingCheckoutSession(&req)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
// Code generated by goctl. DO NOT EDIT.
|
|
||||||
// goctl <no value>
|
|
||||||
|
|
||||||
package billing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"apps/backend/internal/logic/billing"
|
|
||||||
"apps/backend/internal/response"
|
|
||||||
"apps/backend/internal/svc"
|
|
||||||
)
|
|
||||||
|
|
||||||
func GetBillingSubscriptionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
l := billing.NewGetBillingSubscriptionLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.GetBillingSubscription()
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
// Code generated by goctl. DO NOT EDIT.
|
|
||||||
// goctl <no value>
|
|
||||||
|
|
||||||
package billing
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"apps/backend/internal/logic/billing"
|
|
||||||
"apps/backend/internal/response"
|
|
||||||
"apps/backend/internal/svc"
|
|
||||||
)
|
|
||||||
|
|
||||||
func HandleStripeWebhookHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
l := billing.NewHandleStripeWebhookLogic(r.Context(), svcCtx)
|
|
||||||
err := l.HandleStripeWebhook()
|
|
||||||
response.Write(r.Context(), w, nil, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
// Code generated by goctl. DO NOT EDIT.
|
|
||||||
// goctl <no value>
|
|
||||||
|
|
||||||
package compose
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"apps/backend/internal/logic/compose"
|
|
||||||
"apps/backend/internal/response"
|
|
||||||
"apps/backend/internal/svc"
|
|
||||||
"apps/backend/internal/types"
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ComposeAnalyzeViralHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.ComposeViralReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
l := compose.NewComposeAnalyzeViralLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.ComposeAnalyzeViral(&req)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
// Code generated by goctl. DO NOT EDIT.
|
|
||||||
// goctl <no value>
|
|
||||||
|
|
||||||
package compose
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"apps/backend/internal/logic/compose"
|
|
||||||
"apps/backend/internal/response"
|
|
||||||
"apps/backend/internal/svc"
|
|
||||||
"apps/backend/internal/types"
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
|
||||||
)
|
|
||||||
|
|
||||||
func ComposeMimicHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req types.ComposeMimicReq
|
|
||||||
if err := httpx.Parse(r, &req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
l := compose.NewComposeMimicLogic(r.Context(), svcCtx)
|
|
||||||
data, err := l.ComposeMimic(&req)
|
|
||||||
response.Write(r.Context(), w, data, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue