Compare commits
10 Commits
main
...
feat/front
| Author | SHA1 | Date |
|---|---|---|
|
|
fb8100825f | |
|
|
377b7f06ea | |
|
|
8ef6fe470b | |
|
|
ef6322b635 | |
|
|
e41c1f96a2 | |
|
|
fcc76911bb | |
|
|
c57987f6a8 | |
|
|
2e0b625735 | |
|
|
7ed9f50a2c | |
|
|
5acd1b6567 |
|
|
@ -1,15 +0,0 @@
|
||||||
.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,24 +1,34 @@
|
||||||
# --- secrets / env ---
|
# --- secrets / env ---
|
||||||
deploy/.env
|
|
||||||
deploy/.env.dev
|
|
||||||
deploy/.env.prod
|
|
||||||
infra/.env
|
|
||||||
*.env
|
*.env
|
||||||
**/.env
|
**/.env
|
||||||
!.env.example
|
!.env.example
|
||||||
!*.env.example
|
!*.env.example
|
||||||
|
deploy/.env
|
||||||
|
deploy/.env.dev
|
||||||
|
deploy/.env.prod
|
||||||
|
infra/.env
|
||||||
|
# Local runtime credentials. Commit the adjacent gateway.example.yaml only.
|
||||||
|
apps/backend/etc/gateway.yaml
|
||||||
|
old/deploy/.env
|
||||||
|
old/deploy/.env.dev
|
||||||
|
old/deploy/.env.prod
|
||||||
|
old/infra/.env
|
||||||
|
|
||||||
# --- runtime / logs ---
|
# --- runtime / logs ---
|
||||||
.run/
|
.run/
|
||||||
|
old/.run/
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# --- build artifacts ---
|
# --- build artifacts ---
|
||||||
backend/bin/
|
|
||||||
backend/web/dist/
|
|
||||||
backend/dev-console/dist/
|
|
||||||
frontend/dist/
|
|
||||||
dist/
|
dist/
|
||||||
backend/web/public/downloads/*.zip
|
**/dist/
|
||||||
|
apps/*/dist/
|
||||||
|
apps/web/dist/
|
||||||
|
services/*/bin/
|
||||||
|
old/backend/bin/
|
||||||
|
old/backend/web/dist/
|
||||||
|
old/backend/dev-console/dist/
|
||||||
|
old/backend/web/public/downloads/*.zip
|
||||||
|
|
||||||
# --- dependencies ---
|
# --- dependencies ---
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
@ -27,3 +37,6 @@ node_modules/
|
||||||
# --- OS / editor ---
|
# --- OS / editor ---
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.cursor/
|
.cursor/
|
||||||
|
|
||||||
|
# deploy runtime
|
||||||
|
deploy/run/
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
---
|
||||||
|
name: spec-driven
|
||||||
|
description: >
|
||||||
|
Spec-driven product workflow: requirements → spec.md → plan.md → small task.md files
|
||||||
|
with explicit inputs/outputs (≈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
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
# 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 已批准:
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
# Requirements: <產品 / 功能名稱>
|
||||||
|
|
||||||
|
> Status: `draft` | `approved`
|
||||||
|
> Slug: `<slug>`
|
||||||
|
> Last updated: `YYYY-MM-DD`
|
||||||
|
|
||||||
|
## 1. 一句話
|
||||||
|
|
||||||
|
<用一句話說明這個產品或這次範圍要達成什麼>
|
||||||
|
|
||||||
|
## 2. 誰用、在什麼情境
|
||||||
|
|
||||||
|
| 角色 | 目標 |
|
||||||
|
|------|------|
|
||||||
|
| | |
|
||||||
|
|
||||||
|
## 3. 背景與動機(As-is → 為什麼改)
|
||||||
|
|
||||||
|
- **現況問題:**
|
||||||
|
- **不改的後果:**
|
||||||
|
- **參考文件(非需求本體):**
|
||||||
|
|
||||||
|
## 4. 目標(To-be)
|
||||||
|
|
||||||
|
### 4.1 必須達成(P0)
|
||||||
|
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
|
||||||
|
### 4.2 應該有(P1)
|
||||||
|
|
||||||
|
1.
|
||||||
|
|
||||||
|
### 4.3 以後再說(P2)
|
||||||
|
|
||||||
|
1.
|
||||||
|
|
||||||
|
## 5. 範圍
|
||||||
|
|
||||||
|
### In scope
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
### Out of scope(明確不做)
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
## 6. 使用者故事(可驗收)
|
||||||
|
|
||||||
|
| ID | 作為… | 我想要… | 以便… | 驗收要點 |
|
||||||
|
|----|--------|---------|--------|----------|
|
||||||
|
| US-01 | | | | |
|
||||||
|
|
||||||
|
## 7. 成功標準
|
||||||
|
|
||||||
|
- [ ]
|
||||||
|
- [ ]
|
||||||
|
|
||||||
|
## 8. 約束與假設
|
||||||
|
|
||||||
|
- 技術 / 合規 / 時程約束:
|
||||||
|
- 假設(未驗證當真):
|
||||||
|
|
||||||
|
## 9. 風險
|
||||||
|
|
||||||
|
| 風險 | 影響 | 緩解(需求層,非實作細節) |
|
||||||
|
|------|------|---------------------------|
|
||||||
|
| | | |
|
||||||
|
|
||||||
|
## 10. 開放問題
|
||||||
|
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
|
||||||
|
## 11. 批准
|
||||||
|
|
||||||
|
- [ ] 需求已批准(日期 / 誰):
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
# 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 已批准:
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
# 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
|
||||||
|
|
||||||
|
<可選:實作提示,非必做細節>
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
# Spec-Driven 四階段說明
|
||||||
|
|
||||||
|
## 為什麼要閘門
|
||||||
|
|
||||||
|
邊想邊寫碼會讓範圍漂掉。四份文件各解一個問題:
|
||||||
|
|
||||||
|
| 文件 | 回答的問題 | 讀者 |
|
||||||
|
|------|------------|------|
|
||||||
|
| requirements | 我們要什麼?不要什麼? | 產品 / 你自己 |
|
||||||
|
| spec | 系統對外行為是什麼? | 實作者 + 審查 |
|
||||||
|
| plan | 分幾段交付、依賴與風險? | 排程 |
|
||||||
|
| task | 下一小時具體改什麼? | 動手 agent / 人 |
|
||||||
|
|
||||||
|
## 閘門 checklist
|
||||||
|
|
||||||
|
### 需求通過
|
||||||
|
|
||||||
|
- [ ] 一句話產品目標清楚
|
||||||
|
- [ ] In / Out of scope 無互相打架
|
||||||
|
- [ ] P0 可獨立交付價值
|
||||||
|
- [ ] 成功標準可驗收(不是「更好用」)
|
||||||
|
- [ ] 開放問題已列出(可帶預設假設)
|
||||||
|
|
||||||
|
### Spec 通過
|
||||||
|
|
||||||
|
- [ ] 主要實體與狀態可畫出
|
||||||
|
- [ ] 關鍵使用者路徑有行為描述
|
||||||
|
- [ ] Retain / Replace / Remove 對現況有交代
|
||||||
|
- [ ] 錯誤、權限、時間、分頁等跨切約定有寫
|
||||||
|
- [ ] 沒有偷偷寫實作步驟當規格
|
||||||
|
|
||||||
|
### Plan 通過
|
||||||
|
|
||||||
|
- [ ] 里程碑順序合理、依賴無環
|
||||||
|
- [ ] 每里程碑有驗收方式
|
||||||
|
- [ ] 風險與不做清單明確
|
||||||
|
- [ ] 對應 task 區間可預估
|
||||||
|
|
||||||
|
### Tasks 通過
|
||||||
|
|
||||||
|
- [ ] 每 task 單一 Done
|
||||||
|
- [ ] 預估 100–200 行級(過大已拆)
|
||||||
|
- [ ] Inputs / Outputs / 不做 / 驗收齊
|
||||||
|
- [ ] INDEX 可追蹤狀態
|
||||||
|
|
||||||
|
## 與現有技能的關係
|
||||||
|
|
||||||
|
| 技能 | 關係 |
|
||||||
|
|------|------|
|
||||||
|
| `/design` | 偏設計文件 + PR Plan;本 skill 偏產品需求→小 task |
|
||||||
|
| `/execute-plan` | 可在 **plan/tasks 批准後** 用來並行實作,非必須 |
|
||||||
|
| `/check-work` | 單一 task 做完後驗證 |
|
||||||
|
|
||||||
|
第一版 **不必** 自動串 execute-plan;使用者說「實作 T003」即可。
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Usage: init-run.sh <slug> "<one-line goal>"
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)"
|
||||||
|
SLUG="${1:-}"
|
||||||
|
GOAL="${2:-}"
|
||||||
|
|
||||||
|
if [[ -z "$SLUG" ]]; then
|
||||||
|
echo "usage: $0 <slug> \"<one-line goal>\"" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
DIR="$ROOT/docs/product/$SLUG"
|
||||||
|
mkdir -p "$DIR/tasks"
|
||||||
|
|
||||||
|
if [[ ! -f "$DIR/README.md" ]]; then
|
||||||
|
cat >"$DIR/README.md" <<EOF
|
||||||
|
# Product run: $SLUG
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|--------|
|
||||||
|
| Goal | ${GOAL:-TBD} |
|
||||||
|
| Phase | requirements |
|
||||||
|
| Status | draft |
|
||||||
|
| Updated | $(date -u +%Y-%m-%d) |
|
||||||
|
|
||||||
|
## Phase legend
|
||||||
|
|
||||||
|
\`requirements\` → \`spec\` → \`plan\` → \`tasks\` → \`ready-to-implement\`
|
||||||
|
|
||||||
|
## How to continue
|
||||||
|
|
||||||
|
\`\`\`text
|
||||||
|
/spec-driven $SLUG
|
||||||
|
/spec-driven $SLUG --status
|
||||||
|
\`\`\`
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$DIR/tasks/INDEX.md" ]]; then
|
||||||
|
cat >"$DIR/tasks/INDEX.md" <<'EOF'
|
||||||
|
# Tasks INDEX
|
||||||
|
|
||||||
|
| ID | Title | Milestone | Status | Est. lines |
|
||||||
|
|----|-------|-----------|--------|------------|
|
||||||
|
| — | (尚未拆 task;plan 批准後填入) | | | |
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Initialized $DIR"
|
||||||
433
AGENTS.md
433
AGENTS.md
|
|
@ -1,413 +1,48 @@
|
||||||
|
# Agent 規範(重構主線)
|
||||||
|
|
||||||
# Agent Handoff Notes
|
## 封存與主線
|
||||||
|
|
||||||
這個資料夾是新的巡樓後端核心,請優先維持乾淨邊界,不要把舊 Next.js 或 `template-monorepo` 的業務包袱搬進來。
|
- **`old/`**:舊系統整包封存,**只讀參考**。不要在 `old/` 修功能當交付。
|
||||||
|
- **新碼**:只寫在 **`apps/`** 下(前端 `apps/web`、後端 `apps/backend` 等,以 `docs/product` 為準)。
|
||||||
|
- **後端落點**:Phase C 巡樓 API/worker 在 **`apps/backend`**(go-zero;由 `stand-alone-service` 遷入改造)。
|
||||||
|
- **後端寫法(硬性)**:一律 **goctl**:改 `generate/api/*.api` → `make gen-api` → 業務只寫 `internal/logic/**` 與 `internal/module/**`。**禁止**手寫 `handler`/`routes.go` 當主路徑。
|
||||||
|
- **產品真相**:`docs/product/<slug>/`(requirements → spec → plan → tasks)。
|
||||||
|
- **流程**:`/spec-driven`(`.grok/skills/spec-driven/`)。
|
||||||
|
|
||||||
## 核心原則
|
## 交付順序(硬性)
|
||||||
|
|
||||||
- 全系統時間一律 **UTC+0**;寫入 Mongo / API 的時間欄位一律 **unix nanoseconds**(`int64`)。排程的 `timezone` 只用於 cron 解讀與下發 payload,不作為儲存時區。
|
1. Phase A 地基
|
||||||
- 複製模式,不複製舊業務。
|
2. Phase B 前端 + mock(操作順再往下)
|
||||||
- `logic` 做 API 編排,`model/usecase` 做可重複使用能力。
|
3. Phase C 接真 API
|
||||||
- provider adapter 不讀 setting、不碰 Mongo、不知道 HTTP。
|
4. Phase D 業務加深
|
||||||
- setting 是通用 key-value model,不依賴 AI 或其他業務。
|
|
||||||
- token / API key 第一版每次 request 帶入,不寫入 config。
|
|
||||||
- SSE contract 由本服務 normalize,前端不要讀 provider 原始 chunk。
|
|
||||||
- JSON API 必須使用 `code/message/data/error` envelope 與 `SSCCCDDD` 錯誤碼。
|
|
||||||
- 列表 API 必須使用 `page/pageSize` query,並在 `data` 回傳 `pagination/list`。
|
|
||||||
- Job 狀態轉移必須使用 guarded/conditional update;不要在 API/worker 直接裸 `Update` 覆蓋 job 狀態。
|
|
||||||
- Redis job lock 的 value 是 `workerID`;release / refresh 必須檢查 owner,長任務必須 heartbeat。
|
|
||||||
- Auth 目前是 native email/password + JWT,不包含 OAuth / OTP / MFA / Zitadel。不要為了相容 template-monorepo 把重依賴搬進來。
|
|
||||||
- AI provider token 與會員 JWT 是兩種不同 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 天補充)、產品匹配、島民交接。
|
|
||||||
|
|
||||||
## 新增 API 流程
|
- 時間:**UTC**;儲存 **unix nanoseconds** `int64`。
|
||||||
|
- JSON:`code` / `message` / `data` / `error`;成功碼 `102000`。
|
||||||
|
- 列表:`page` / `pageSize` → `pagination` + `list`。
|
||||||
|
- Job:狀態 **guarded** 更新;Redis lock value = `workerID`。
|
||||||
|
- 會員 JWT vs AI provider token 分離。
|
||||||
|
- **禁止**未實作 API 回成功空資料。
|
||||||
|
|
||||||
1. 修改 `generate/api/*.api`。
|
## 前端精神(Harbor Desk)
|
||||||
2. 優先使用 `make gen-api` 重新產生 handler/logic/types。
|
|
||||||
3. 若手寫 handler,仍需遵守 `response.Write` 與 validator 流程。
|
|
||||||
4. SSE endpoint 不使用 `response.Write`,直接輸出 `text/event-stream`。
|
|
||||||
5. 更新 `README.md` 的 API 與架構說明。
|
|
||||||
|
|
||||||
## Response / Error Code
|
- 新殼在 `apps/web`;不引入 MUI/Ant/Chakra。
|
||||||
|
- 不複製任天堂 / Nook UI;不用 emoji 當主 icon。
|
||||||
|
- 字體:Inter + Taipei Sans TC。
|
||||||
|
- mock | live 同一 repository 介面。
|
||||||
|
- 詳見 `docs/product/haixun-console/spec.md`。
|
||||||
|
|
||||||
錯誤碼格式是 `SSCCCDDD`:
|
## 參考舊碼
|
||||||
|
|
||||||
|
需要對照實作時:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
SS = scope
|
old/backend/ # 舊 Go / web / node workers
|
||||||
CCC = category
|
old/AGENTS.md # 舊詳細規範
|
||||||
DDD = detail
|
old/docs/ # 舊計劃文件
|
||||||
```
|
```
|
||||||
|
|
||||||
目前 scope:
|
複製模式可以,**不要**把半成品與死碼整包搬回主線。
|
||||||
|
|
||||||
```text
|
|
||||||
10 = Facade
|
|
||||||
32 = Setting
|
|
||||||
33 = AI
|
|
||||||
34 = Job
|
|
||||||
35 = Auth
|
|
||||||
36 = Member
|
|
||||||
37 = Permission
|
|
||||||
```
|
|
||||||
|
|
||||||
建立錯誤時使用:
|
|
||||||
|
|
||||||
```go
|
|
||||||
errs.For(code.AI).InputMissingRequired("缺少 AI provider token")
|
|
||||||
errs.For(code.Setting).ResNotFound("找不到設定")
|
|
||||||
errs.For(code.Job).ResInvalidState("job state changed; update rejected")
|
|
||||||
errs.For(code.Auth).AuthUnauthorized("missing bearer token")
|
|
||||||
```
|
|
||||||
|
|
||||||
不要直接手寫 `33104000` 這種數字,也不要回傳裸 `error` 給 handler 後讓使用者看到內部錯誤。
|
|
||||||
|
|
||||||
## Pagination
|
|
||||||
|
|
||||||
列表 API 使用:
|
|
||||||
|
|
||||||
```text
|
|
||||||
?page=1&pageSize=10
|
|
||||||
```
|
|
||||||
|
|
||||||
response:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 102000,
|
|
||||||
"message": "SUCCESS",
|
|
||||||
"data": {
|
|
||||||
"pagination": {
|
|
||||||
"total": 100,
|
|
||||||
"page": 1,
|
|
||||||
"pageSize": 10,
|
|
||||||
"totalPages": 10
|
|
||||||
},
|
|
||||||
"list": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`page/pageSize` 必須是 server 正規化後的值。不要使用 `offset/limit/items`。
|
|
||||||
|
|
||||||
## 新增 Model 流程
|
|
||||||
|
|
||||||
模組放在:
|
|
||||||
|
|
||||||
```text
|
|
||||||
internal/model/<module>/
|
|
||||||
domain/entity
|
|
||||||
domain/repository
|
|
||||||
domain/usecase
|
|
||||||
repository
|
|
||||||
usecase
|
|
||||||
```
|
|
||||||
|
|
||||||
依賴方向:
|
|
||||||
|
|
||||||
```text
|
|
||||||
handler -> logic -> model/domain/usecase
|
|
||||||
model/usecase -> model/domain/repository
|
|
||||||
model/repository -> Mongo / Redis
|
|
||||||
```
|
|
||||||
|
|
||||||
不要讓 `logic` import `model/<module>/repository`。
|
|
||||||
|
|
||||||
## Auth / Permission 擴充
|
|
||||||
|
|
||||||
目前已接:
|
|
||||||
|
|
||||||
```text
|
|
||||||
POST /api/v1/auth/register
|
|
||||||
POST /api/v1/auth/login
|
|
||||||
POST /api/v1/auth/refresh
|
|
||||||
POST /api/v1/auth/logout # requires member JWT
|
|
||||||
GET /api/v1/members/me
|
|
||||||
PATCH /api/v1/members/me
|
|
||||||
GET /api/v1/permissions/catalog
|
|
||||||
GET /api/v1/permissions/me
|
|
||||||
```
|
|
||||||
|
|
||||||
Auth matrix(`internal/handler/routes.go`):
|
|
||||||
|
|
||||||
| 路由 | 需要會員 JWT |
|
|
||||||
|------|----------------|
|
|
||||||
| `GET /api/v1/health` | 否 |
|
|
||||||
| `POST /api/v1/auth/register/login/refresh` | 否 |
|
|
||||||
| `POST /api/v1/auth/logout` | 是(`Authorization`) |
|
|
||||||
| `GET /api/v1/ai/providers` | 否 |
|
|
||||||
| `POST /api/v1/ai/chat/stream/models` | 是(`X-Member-Authorization`)+ provider token(`Authorization`) |
|
|
||||||
| `/api/v1/members/*`、`/api/v1/permissions/me` | 是(`Authorization`) |
|
|
||||||
| `/api/v1/permissions/catalog`、`/api/v1/settings/*`、`/api/v1/jobs*`、`/api/v1/job/*` | 是(`Authorization`) |
|
|
||||||
|
|
||||||
規則:
|
|
||||||
|
|
||||||
- 保護路由用 `internal/middleware.Auth`(`Authorization: Bearer <access_token>`);AI 變更路由用 `middleware.MemberAuth`(`X-Member-Authorization`),因 `Authorization` 保留給 provider API key。
|
|
||||||
- logic 從 `authctx.ActorFromContext` 讀 `tenant_id` / `uid`。
|
|
||||||
- 不要在 handler 直接 parse 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
|
|
||||||
```
|
|
||||||
|
|
|
||||||
128
Makefile
128
Makefile
|
|
@ -1,128 +0,0 @@
|
||||||
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
|
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
# 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
|
|
@ -0,0 +1,6 @@
|
||||||
|
/bin/
|
||||||
|
*.exe
|
||||||
|
*.test
|
||||||
|
coverage.out
|
||||||
|
.data/
|
||||||
|
uploads/
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
# 遷入說明(精簡)
|
||||||
|
|
||||||
|
- 骨架參考 `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。
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
# 只保留會重複打的指令;其餘直接 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
|
||||||
|
|
@ -0,0 +1,213 @@
|
||||||
|
# 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`, …
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
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)))
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,481 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
# 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,17 +1,13 @@
|
||||||
{
|
{
|
||||||
"name": "haixun-node-worker",
|
"name": "@harbor/scout-crawler",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "haixun-node-worker",
|
"name": "@harbor/scout-crawler",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "^1.61.1"
|
"playwright": "^1.58.0",
|
||||||
},
|
"tsx": "^4.20.6"
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^22.10.2",
|
|
||||||
"tsx": "^4.19.2",
|
|
||||||
"typescript": "^5.7.2"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
|
@ -21,7 +17,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -38,7 +33,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -55,7 +49,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -72,7 +65,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -89,7 +81,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -106,7 +97,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -123,7 +113,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -140,7 +129,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -157,7 +145,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -174,7 +161,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -191,7 +177,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -208,7 +193,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -225,7 +209,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -242,7 +225,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -259,7 +241,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -276,7 +257,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -293,7 +273,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -310,7 +289,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -327,7 +305,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -344,7 +321,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -361,7 +337,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -378,7 +353,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -395,7 +369,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -412,7 +385,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -429,7 +401,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -446,7 +417,6 @@
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|
@ -456,21 +426,10 @@
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
|
||||||
"version": "22.20.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz",
|
|
||||||
"integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"undici-types": "~6.21.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
@ -553,10 +512,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tsx": {
|
"node_modules/tsx": {
|
||||||
"version": "4.22.4",
|
"version": "4.23.1",
|
||||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz",
|
||||||
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
|
"integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "~0.28.0"
|
"esbuild": "~0.28.0"
|
||||||
|
|
@ -575,7 +533,6 @@
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
|
|
@ -585,27 +542,6 @@
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"node_modules/typescript": {
|
|
||||||
"version": "5.9.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
|
||||||
"tsc": "bin/tsc",
|
|
||||||
"tsserver": "bin/tsserver"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.17"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/undici-types": {
|
|
||||||
"version": "6.21.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
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");
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
# 巡樓 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"
|
||||||
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
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"
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,470 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,568 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,192 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
# 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)
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"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_"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"dropIndexes": "jobs",
|
||||||
|
"index": "owner_created"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dropIndexes": "jobs",
|
||||||
|
"index": "status_1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"createIndexes": "jobs",
|
||||||
|
"indexes": [
|
||||||
|
{
|
||||||
|
"key": { "owner_uid": 1, "created_at": -1 },
|
||||||
|
"name": "owner_created"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": { "status": 1 },
|
||||||
|
"name": "status_1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
[]
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"update": "counters",
|
||||||
|
"updates": [
|
||||||
|
{
|
||||||
|
"q": { "_id": "member_uid" },
|
||||||
|
"u": { "$setOnInsert": { "seq": 1000000 } },
|
||||||
|
"upsert": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"dropIndexes": "refresh_tokens",
|
||||||
|
"index": "refresh_uid_1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dropIndexes": "refresh_tokens",
|
||||||
|
"index": "refresh_ttl"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dropIndexes": "auth_codes",
|
||||||
|
"index": "auth_code_ttl"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"dropIndexes": "identities",
|
||||||
|
"index": "login_platform_unique"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dropIndexes": "identities",
|
||||||
|
"index": "identities_uid_1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dropIndexes": "identities",
|
||||||
|
"index": "platform_account_type"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"dropIndexes": "members",
|
||||||
|
"index": "phone_1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
[]
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
[]
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
[
|
||||||
|
{ "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" }
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
[
|
||||||
|
{ "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" }
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
[
|
||||||
|
{ "dropIndexes": "studio_outbox", "index": "claim_due_outbox_steps" }
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"createIndexes": "studio_outbox",
|
||||||
|
"indexes": [
|
||||||
|
{
|
||||||
|
"key": { "steps.status": 1, "steps.scheduled_at": 1, "steps.lease_expires_at": 1 },
|
||||||
|
"name": "claim_due_outbox_steps"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
[
|
||||||
|
{ "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" }
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"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,4 +1,4 @@
|
||||||
// Code scaffolded by goctl. Safe to edit.
|
// Code generated by goctl. DO NOT EDIT.
|
||||||
// goctl {{.version}}
|
// goctl {{.version}}
|
||||||
|
|
||||||
package {{.PkgName}}
|
package {{.PkgName}}
|
||||||
|
|
@ -6,7 +6,7 @@ package {{.PkgName}}
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"haixun-backend/internal/response"
|
"apps/backend/internal/response"
|
||||||
{{if .HasRequest}}"github.com/zeromicro/go-zero/rest/httpx"
|
{{if .HasRequest}}"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
{{end}}{{.ImportPackages}}
|
{{end}}{{.ImportPackages}}
|
||||||
)
|
)
|
||||||
|
|
@ -19,10 +19,6 @@ func {{.HandlerName}}(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
{{end}}l := {{.LogicName}}.New{{.LogicType}}(r.Context(), svcCtx)
|
{{end}}l := {{.LogicName}}.New{{.LogicType}}(r.Context(), svcCtx)
|
||||||
{{if .HasResp}}data, {{end}}err := l.{{.Call}}({{if .HasRequest}}&req{{end}})
|
{{if .HasResp}}data, {{end}}err := l.{{.Call}}({{if .HasRequest}}&req{{end}})
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
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,7 +1,41 @@
|
||||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
github.com/Masterminds/semver v1.4.2 h1:WBLTQ37jOCzSLtXNdoo8bNM8876KhNqOKvrlGITgsTc=
|
||||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
github.com/Masterminds/semver v1.4.2/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
|
||||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
|
github.com/Masterminds/sprig v2.16.0+incompatible h1:QZbMUPxRQ50EKAq3LFMnxddMu88/EUUG3qmxwtDmPsY=
|
||||||
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
|
github.com/Masterminds/sprig v2.16.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o=
|
||||||
|
github.com/PuerkitoBio/goquery v1.5.0 h1:uGvmFXOA73IKluu/F84Xd1tt/z07GYm8X49XKHP7EJk=
|
||||||
|
github.com/PuerkitoBio/goquery v1.5.0/go.mod h1:qD2PgZ9lccMbQlc7eEOjaeRlFQON7xY8kdmcsrnKqMg=
|
||||||
|
github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 h1:uvdUDbHQHO85qeSydJtItA4T55Pw6BtAejd0APRJOCE=
|
||||||
|
github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
|
||||||
|
github.com/alicebob/miniredis/v2 v2.34.0 h1:mBFWMaJSNL9RwdGRyEDoAAv8OQc5UlEhLDQggTglU/0=
|
||||||
|
github.com/alicebob/miniredis/v2 v2.34.0/go.mod h1:kWShP4b58T1CW0Y5dViCd5ztzrDqRWqM3nksiyXk5s8=
|
||||||
|
github.com/andybalholm/cascadia v1.0.0 h1:hOCXnnZ5A+3eVDX8pvgl4kofXv2ELss0bKcqRySc45o=
|
||||||
|
github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y=
|
||||||
|
github.com/aokoli/goutils v1.0.1 h1:7fpzNGoJ3VA8qcrm++XEE1QUe0mIwNeLa02Nwq7RDkg=
|
||||||
|
github.com/aokoli/goutils v1.0.1/go.mod h1:SijmP0QR8LtwsmDs8Yii5Z/S4trXFGFC2oO5g9DP+DQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDkhGgQFqkBy+uT28AAYpLu+uFMlPl+rCg/73E=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.28 h1:zTXJSsNcoO91/mTXsZoYf0AK8dvNPiA58/VtyGXR+wM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.19.28/go.mod h1:Kd9E0JzDBW/q1xbsHFrev/GnbAf5J0Ng8xoyc7HZ91Q=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30/go.mod h1:WueJeNDZvK1fMYEWJIkcivBfEzUkTpBhzlrUKKY8EuA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 h1:jn46zC9LdsVR/ZpMIJqMqb8hHv31BlLx3ulVqNspUOk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30/go.mod h1:1hTMsAgbdS/AtUi4bw8+gUuh1pceo+eXRLfpSuSQj3M=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 h1:3GUprIsfmGcC5SACIyB0e7E0BM1O1b3Erl5CePYIAeQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31/go.mod h1:7PuV1yl5e2xnUbm+RqvVg5i2iBM8EyijZNoI9wsOoOc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 h1:9Fjh6fi/U5JEStVZijmaMpUwE/gvBJj7x2B/PjbO9To=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23/go.mod h1:iMoT2f1tClxrWAAnKCXjZQ6LOmfLrMG14wmnWpM+F14=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrKsSD7EmDjzAPsm/3L9IuOkzaynklJZ1qX7S4=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 h1:uao4A3QZ5UmB326V6KF+qRpv9Tjz7IlnlnTbbANntlU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31/go.mod h1:I/1+z0VwL1GhQyLgkoHDlygpUZ+iTAwOQ/NsftiUL2I=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0 h1:XptwLL+UHXgafYMIHTy59IRovLbhz3znkxY2uS/pbXU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0/go.mod h1:zdmCoFO/dSI7GlrwsPqFJI+WlFnSU4Tc8TJnlXrM1Do=
|
||||||
|
github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY=
|
||||||
|
github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
|
|
@ -19,63 +53,64 @@ github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/r
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
github.com/go-gomail/gomail v0.0.0-20160411212932-81ebce5c23df/go.mod h1:GJr+FCSXshIwgHBtLglIg9M2l2kQSi6QjVAngtzI08Y=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
|
||||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
|
||||||
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c h1:wpkoddUomPfHiOziHZixGO5ZBS73cKqVzZipfrLmO1w=
|
|
||||||
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c/go.mod h1:oVDCh3qjJMLVUSILBRwrm+Bc6RNXGZYtoh9xdvf1ffM=
|
|
||||||
github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0 h1:A3B75Yp163FAIf9nLlFMl4pwIj+T3uKxfI7mbvvY2Ls=
|
|
||||||
github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0/go.mod h1:suxK0Wpz4BM3/2+z1mnOVTIWHDiMCIOGoKDCRumSsk0=
|
|
||||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
|
|
||||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
|
||||||
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
|
||||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb1T8ac=
|
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
|
||||||
github.com/grafana/pyroscope-go v1.2.7/go.mod h1:o/bpSLiJYYP6HQtvcoVKiE9s5RiNgjYTj1DhiddP2Pc=
|
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
|
||||||
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og=
|
|
||||||
github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU=
|
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 h1:bkypFPDjIYGfCYD5mRBvpqxfYX1YCS1PXdKYWi8FsN0=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0/go.mod h1:P+Lt/0by1T8bfcF3z737NnSbmxQAppXMRziHUxPOC8k=
|
||||||
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
|
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
|
||||||
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
|
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
|
||||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
github.com/huandu/xstrings v1.2.0 h1:yPeWdRnmynF7p+lLYz0H2tthW9lqhMJrQV/U7yy4wX0=
|
||||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63UyNX5k4=
|
||||||
|
github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28=
|
||||||
|
github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
|
||||||
|
github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0 h1:xqgexXAGQgY3HAjNPSaCqn5Aahbo5TKsmhp8VRfr1iQ=
|
||||||
|
github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk=
|
||||||
|
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||||
|
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
github.com/matcornic/hermes/v2 v2.1.0 h1:9TDYFBPFv6mcXanaDmRDEp/RTWj0dTTi+LpFnnnfNWc=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/matcornic/hermes/v2 v2.1.0/go.mod h1:2+ziJeoyRfaLiATIL8VZ7f9hpzH4oDHqTmn0bhrsgVI=
|
||||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
|
github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
|
||||||
|
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||||
|
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||||
|
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
|
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
|
||||||
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
|
github.com/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
|
||||||
|
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||||
|
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||||
github.com/openzipkin/zipkin-go v0.4.3 h1:9EGwpqkgnwdEIJ+Od7QVSEIH+ocmm5nPat0G7sjsSdg=
|
github.com/openzipkin/zipkin-go v0.4.3 h1:9EGwpqkgnwdEIJ+Od7QVSEIH+ocmm5nPat0G7sjsSdg=
|
||||||
github.com/openzipkin/zipkin-go v0.4.3/go.mod h1:M9wCJZFWCo2RiY+o1eBCEMe0Dp2S5LDHcMZmk3RmK7c=
|
github.com/openzipkin/zipkin-go v0.4.3/go.mod h1:M9wCJZFWCo2RiY+o1eBCEMe0Dp2S5LDHcMZmk3RmK7c=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||||
|
|
@ -84,38 +119,46 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
|
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
|
||||||
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
|
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
|
||||||
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
|
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
|
||||||
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
|
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
||||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||||
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
|
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
|
||||||
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
|
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
|
||||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||||
github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE=
|
github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E=
|
||||||
github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
|
github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw=
|
||||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
|
||||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||||
github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg=
|
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
|
||||||
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
|
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
|
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
|
||||||
|
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||||
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
|
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
|
||||||
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||||
|
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
|
||||||
|
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stripe/stripe-go/v82 v82.5.1 h1:05q6ZDKoe8PLMpQV072obF74HCgP4XJeJYoNuRSX2+8=
|
||||||
|
github.com/stripe/stripe-go/v82 v82.5.1/go.mod h1:majCQX6AfObAvJiHraPi/5udwHi4ojRvJnnxckvHrX8=
|
||||||
|
github.com/vanng822/css v0.0.0-20190504095207-a21e860bcd04 h1:L0rPdfzq43+NV8rfIx2kA4iSSLRj2jN5ijYHoeXRwvQ=
|
||||||
|
github.com/vanng822/css v0.0.0-20190504095207-a21e860bcd04/go.mod h1:tcnB1voG49QhCrwq1W0w5hhGasvOg+VQp9i9H1rCM1w=
|
||||||
|
github.com/vanng822/go-premailer v0.0.0-20191214114701-be27abe028fe h1:9YnI5plmy+ad6BM+JCLJb2ZV7/TNiE5l7SNKfumYKgc=
|
||||||
|
github.com/vanng822/go-premailer v0.0.0-20191214114701-be27abe028fe/go.mod h1:JTFJA/t820uFDoyPpErFQ3rb3amdZoPtxcKervG0OE4=
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||||
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
||||||
|
|
@ -125,10 +168,12 @@ github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gi
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/zeromicro/go-zero v1.9.2 h1:ZXOXBIcazZ1pWAMiHyVnDQ3Sxwy7DYPzjE89Qtj9vqM=
|
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||||
github.com/zeromicro/go-zero v1.9.2/go.mod h1:k8YBMEFZKjTd4q/qO5RCW+zDgUlNyAs5vue3P4/Kmn0=
|
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||||
go.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFXVw=
|
github.com/zeromicro/go-zero v1.7.6 h1:SArK4xecdrpVY3ZFJcbc0IZCx+NuWyHNjCv9f1+Gwrc=
|
||||||
go.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
|
github.com/zeromicro/go-zero v1.7.6/go.mod h1:SmGykRm5e0Z4CGNj+GaSKDffaHzQV56fel0FkymTLlE=
|
||||||
|
go.mongodb.org/mongo-driver v1.17.1 h1:Wic5cJIwJgSpBhe3lx3+/RybR5PiYRMpVFgO7cOHyIM=
|
||||||
|
go.mongodb.org/mongo-driver v1.17.1/go.mod h1:wwWm/+BuOddhcq3n68LKRmgk2wXzmF6s0SFOa0GINL4=
|
||||||
go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
|
go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
|
||||||
go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
|
go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
|
||||||
go.opentelemetry.io/otel/exporters/jaeger v1.17.0 h1:D7UpUy2Xc2wsi1Ras6V40q806WM07rqoCWzXu7Sqy+4=
|
go.opentelemetry.io/otel/exporters/jaeger v1.17.0 h1:D7UpUy2Xc2wsi1Ras6V40q806WM07rqoCWzXu7Sqy+4=
|
||||||
|
|
@ -155,81 +200,44 @@ go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
golang.org/x/crypto v0.0.0-20181029175232-7e6ffbd03851/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
|
||||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
|
||||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
|
||||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
|
||||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
|
||||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
|
||||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
|
||||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
|
||||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
|
||||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
|
||||||
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
|
||||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190225065934-cc5685c2db12/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
|
||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
|
||||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
|
||||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
|
||||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
|
||||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
|
||||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
|
||||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
|
||||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
|
||||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
|
||||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
|
||||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
|
||||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
|
||||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d h1:kHjw/5UfflP/L5EbledDrcG4C2597RtymmGRZvHiCuY=
|
google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d h1:kHjw/5UfflP/L5EbledDrcG4C2597RtymmGRZvHiCuY=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d/go.mod h1:mw8MG/Qz5wfgYr6VqVCiZcHe/GJEfI+oGGDCohaVgB0=
|
google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d/go.mod h1:mw8MG/Qz5wfgYr6VqVCiZcHe/GJEfI+oGGDCohaVgB0=
|
||||||
|
|
@ -237,13 +245,15 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 h1:
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=
|
||||||
google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc=
|
google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc=
|
||||||
google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ=
|
google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ=
|
||||||
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
|
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
|
||||||
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY=
|
gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY=
|
||||||
gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0=
|
gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0=
|
||||||
|
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|
@ -0,0 +1,458 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
// 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,13 +1,15 @@
|
||||||
|
// Code generated by goctl. DO NOT EDIT.
|
||||||
|
// goctl <no value>
|
||||||
|
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"haixun-backend/internal/logic/auth"
|
"apps/backend/internal/logic/auth"
|
||||||
"haixun-backend/internal/response"
|
"apps/backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"apps/backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"apps/backend/internal/types"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -18,10 +20,7 @@ func LoginHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
l := auth.NewLoginLogic(r.Context(), svcCtx)
|
l := auth.NewLoginLogic(r.Context(), svcCtx)
|
||||||
data, err := l.Login(&req)
|
data, err := l.Login(&req)
|
||||||
response.Write(r.Context(), w, data, err)
|
response.Write(r.Context(), w, data, err)
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
// 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,13 +1,15 @@
|
||||||
|
// Code generated by goctl. DO NOT EDIT.
|
||||||
|
// goctl <no value>
|
||||||
|
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"haixun-backend/internal/logic/auth"
|
"apps/backend/internal/logic/auth"
|
||||||
"haixun-backend/internal/response"
|
"apps/backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"apps/backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"apps/backend/internal/types"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -18,10 +20,7 @@ func RefreshHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
l := auth.NewRefreshLogic(r.Context(), svcCtx)
|
l := auth.NewRefreshLogic(r.Context(), svcCtx)
|
||||||
data, err := l.Refresh(&req)
|
data, err := l.Refresh(&req)
|
||||||
response.Write(r.Context(), w, data, err)
|
response.Write(r.Context(), w, data, err)
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
|
// Code generated by goctl. DO NOT EDIT.
|
||||||
|
// goctl <no value>
|
||||||
|
|
||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"haixun-backend/internal/logic/auth"
|
"apps/backend/internal/logic/auth"
|
||||||
"haixun-backend/internal/response"
|
"apps/backend/internal/response"
|
||||||
"haixun-backend/internal/svc"
|
"apps/backend/internal/svc"
|
||||||
"haixun-backend/internal/types"
|
"apps/backend/internal/types"
|
||||||
|
|
||||||
"github.com/zeromicro/go-zero/rest/httpx"
|
"github.com/zeromicro/go-zero/rest/httpx"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -18,10 +20,7 @@ func RegisterHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
|
|
||||||
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
l := auth.NewRegisterLogic(r.Context(), svcCtx)
|
l := auth.NewRegisterLogic(r.Context(), svcCtx)
|
||||||
data, err := l.Register(&req)
|
data, err := l.Register(&req)
|
||||||
response.Write(r.Context(), w, data, err)
|
response.Write(r.Context(), w, data, err)
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue