first commit

This commit is contained in:
王性驊 2026-06-26 16:37:04 +08:00
commit 232111712d
828 changed files with 86711 additions and 0 deletions

9
.dockerignore Normal file
View File

@ -0,0 +1,9 @@
.run
.git
web/node_modules
# web/dist 保留給 deploy/Dockerfile.web.static本機 make web-build 後 COPY
worker/node_modules
**/*_test.go
**/.DS_Store
*.md
!deploy/**

1
.run/api.pid Normal file
View File

@ -0,0 +1 @@
72708

2857
.run/logs/api.log Normal file

File diff suppressed because it is too large Load Diff

9
.run/logs/web.log Normal file
View File

@ -0,0 +1,9 @@
> haixun-web@0.1.0 dev
> vite
VITE v6.4.3 ready in 187 ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose

1217
.run/logs/worker.log Normal file

File diff suppressed because it is too large Load Diff

1
.run/web.pid Normal file
View File

@ -0,0 +1 @@
72713

1
.run/worker.pid Normal file
View File

@ -0,0 +1 @@
72714

412
AGENTS.md Normal file
View File

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

98
Makefile Normal file
View File

@ -0,0 +1,98 @@
GO ?= go
GOFMT ?= gofmt
GOCTL ?= goctl
GO_ZERO_STYLE := go_zero
API_ENTRY := ./generate/api/gateway.api
GOFILES := $(shell find . -name '*.go')
.DEFAULT_GOAL := help
help: ## 顯示可用指令
@echo "Haixun Backend"
@echo ""
@grep -E '^[a-zA-Z0-9_-]+:.*## ' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*## "}; {printf " make %-12s %s\n", $$1, $$2}'
tools: ## 安裝 goctl / goimports
@command -v $(GOCTL) >/dev/null 2>&1 || (echo ">> installing goctl" && $(GO) install github.com/zeromicro/go-zero/tools/goctl@latest)
@command -v goimports >/dev/null 2>&1 || (echo ">> installing goimports" && $(GO) install golang.org/x/tools/cmd/goimports@latest)
gen-api: tools ## 由 .api 生成 handler / logic / types
$(GOCTL) api go -api $(API_ENTRY) -dir . -style $(GO_ZERO_STYLE) -home generate/goctl
fmt: ## gofmt + goimports
$(GOFMT) -s -w $(GOFILES)
@command -v goimports >/dev/null 2>&1 && goimports -w . || true
test: ## 執行測試
$(GO) test ./...
run: ## 啟動 API前景
$(GO) run ./gateway.go -f etc/gateway.yaml
dev-all: ## 一鍵啟動 Mongo/Redis + API + 前端 + 8D worker背景
bash scripts/start-all.sh
stop-all: ## 一鍵停止全部開發服務
bash scripts/stop-all.sh
restart-all: ## 一鍵重啟全部開發服務
bash scripts/restart-all.sh
status-all: ## 查看全部開發服務狀態
bash scripts/status-all.sh
stop: stop-all ## 同 stop-all
restart: restart-all ## 同 restart-all
dev-8d: ## 一鍵啟動 API + Node 8D worker前景Ctrl+C 結束)
bash scripts/dev-with-style-8d.sh
CONFIG ?= etc/gateway.yaml
INIT_TENANT ?= default
INIT_EMAIL ?= admin@30cm.net
INIT_PASSWORD ?= Fafafa54088
tool-init: ## 初始化 Mongo indexes、預設權限與 admin 帳號
$(GO) run ./cmd/tool init -f $(CONFIG) -tenant $(INIT_TENANT) -email $(INIT_EMAIL) -password '$(INIT_PASSWORD)'
tool: ## 執行 cmd/toolmake tool ARGS="init -f etc/gateway.yaml"
$(GO) run ./cmd/tool $(ARGS)
web-install: ## 安裝前端依賴
cd web && npm install
web-dev: web-install ## 啟動前端 dev serverproxy 到 :8890
cd web && npm run dev
extension-pack: ## 打包 Chrome 擴充為 web/public/downloads/*.zip
bash scripts/package-extension.sh
web-build: web-install extension-pack ## 建置前端靜態檔
cd web && npm run build
node-worker-style-8d: ## 啟動 Node 8D 爬蟲 worker
cd .. && npm run worker:style-8d
check: fmt test ## 格式化並測試
prod: ## 一鍵啟動 production DockerAPI + Web + workers分身數見 deploy/.env
bash scripts/prod-up.sh
prod-update: ## 只重建/重啟 API+Web+Workersmongo/redis 不重啟,資料留在 volume
bash scripts/prod-update.sh
prod-deps: ## 只啟動 mongo+redisnamed volume 持久化)
bash scripts/prod-deps.sh
prod-down: ## 停止 stack不刪 volumeMongo/Redis 資料保留)
bash scripts/prod-down.sh
prod-wipe-data: ## 停止並刪除 mongo/redis volume危險需輸入 yes
bash scripts/prod-wipe-data.sh
prod-logs: ## 追蹤 production logs可傳 service 名make prod-logs ARGS=api
bash scripts/prod-logs.sh $(ARGS)
prod-build: web-build ## 建置靜態前端 + production images不啟動
cd deploy && docker compose -f docker-compose.prod.yml build

396
README.md Normal file
View File

@ -0,0 +1,396 @@
# Haixun Backend
新的巡樓後端核心。這個資料夾刻意不直接複製 `template-monorepo` 的產物碼只沿用它的架構模式、goctl handler template 概念與必要 runtime library讓後續可以用更乾淨的邊界重建服務。
## 目前範圍
第一版先放六個核心能力:
- `setting`:通用設定模型,支援 `scope + scope_id + key` 儲存不同類型設定。
- `ai`:可替換 AI provider interface第一版支援 OpenCode Go 與 Grok/xAI並提供 SSE 串流回應。
- `job`:通用背景任務系統,支援 template/run/schedule/event、Redis queue/lock、進度、retry 與 cooperative cancel。
- `auth`native email/password 登入、JWT access/refresh token、logout revoke。
- `member`:目前登入會員的 profile 讀寫。
- `permission`permission catalog 與目前會員權限查詢。
暫時不包含 template-monorepo 裡較重的 OAuth / OTP / MFA / Zitadel 整合,也不包含 notification、Playwright worker。這些之後要接時再按服務邊界新增。
## 快速開始
```bash
cd haixun-backend
go mod download
make run
```
預設服務:
```text
http://127.0.0.1:8890
```
健康檢查:
```bash
curl http://127.0.0.1:8890/api/v1/health
```
### 8D Node 爬蟲 worker 驗證
`style-8d` job 由 `worker_type=node` 消費。啟動 Gateway 與 Redis 後,另開一個終端:
```bash
make node-worker-style-8d
```
也可以在 repo 根目錄執行:
```bash
npm run worker:style-8d
```
常用環境變數:
```text
HAIXUN_BACKEND_URL=http://127.0.0.1:8890
HAIXUN_WORKER_SECRET=... # 若 etc/gateway.yaml 設了 InternalWorker.Secretworker 需帶同一把
HAIXUN_NODE_WORKER_ID=local-8d # 可選,方便辨識 lock holder
HAIXUN_8D_MIN_SAMPLES=1 # 驗證期預設 1要嚴格一點可調高
```
前端在人設詳情頁按「開始 8D 分析」後,任務會進入:
```text
確認連線 -> 抓取樣本 -> AI 8D -> 儲存策略
```
目前 Node worker 先用 Playwright 抓 Threads 公開頁樣本並產生可驗證的 8D 結構若公開頁無法讀到足夠樣本job 會標記為 `failed` 並顯示原因,不會停在等待狀態。
## 專案結構
```text
haixun-backend/
gateway.go # go-zero server 入口
Makefile # gen-api / fmt / test / run
etc/ # runtime config
generate/
api/ # goctl .api 定義
goctl/api/handler.tpl # 從 template-monorepo 精簡改來的 handler 模板
internal/
config/ # config struct
handler/ # HTTP handler目前手寫之後可由 goctl 生成
logic/ # API 編排層
model/
setting/ # 通用設定 model
ai/ # AI provider interface + adapter
job/ # Job template/run/schedule/event usecase + repository
auth/ # JWT token issue/refresh/logout + Redis revoke store
member/ # Native member profile + password hash
permission/ # Permission catalog + role permission mapping
worker/ # 常駐背景 worker / scheduler / reaper
library/ # 最小 runtime library
response/ # 統一 JSON response envelope
svc/ # ServiceContext 組裝依賴
types/ # API request/response types
```
## 分層規則
## Response 與錯誤碼標準
所有一般 JSON API 都必須回傳同一層 envelope
```json
{
"code": 102000,
"message": "SUCCESS",
"data": {}
}
```
成功固定:
```text
HTTP 200
code = 102000
message = SUCCESS
```
失敗格式:
```json
{
"code": 33101000,
"message": "缺少 AI provider token",
"error": {
"biz_code": "33101000",
"scope": 33,
"category": 104,
"detail": 0
}
}
```
錯誤碼採 `SSCCCDDD`
```text
SS = scope服務或模組範圍
CCC = category錯誤分類
DDD = detail細分錯誤碼未細分時為 000
```
目前 scope
```text
10 = Facade / request parse / validation
32 = Setting
33 = AI
34 = Job
35 = Auth
36 = Member
37 = Permission
```
常用 category
```text
101 = InputInvalidFormat
104 = InputMissingRequired
204 = DBUnavailable
301 = ResourceNotFound
303 = ResourceConflict
401 = AuthUnauthorized
505 = AuthForbidden
601 = SystemInternal
802 = ServiceThirdParty
```
實作規則:
- Handler 成功/失敗都用 `internal/response.Write`SSE endpoint 例外。
- Request parse / validation 錯誤用 `response.WrapRequestError`,會落在 Facade scope。
- Model/usecase 內建立錯誤時使用 `errors.For(code.<Scope>)` builder不要手刻數字。
- 不要把 provider 原始錯誤完整洩漏到前端;必要時只保留可排查的摘要。
### 分頁標準
列表型 API 的 query 使用 `page` / `pageSize`
```text
GET /api/v1/settings/user/user_123?page=1&pageSize=10
```
回應的分頁資訊放在 `data.pagination`,資料陣列放在 `data.list`
```json
{
"code": 102000,
"message": "SUCCESS",
"data": {
"pagination": {
"total": 42,
"page": 1,
"pageSize": 10,
"totalPages": 5
},
"list": []
}
}
```
規則:
- `page` 從 1 開始。
- `pageSize <= 0` 時由 server 套用預設值。
- `pageSize` 超過 server 上限時由 server 截斷。
- `totalPages = ceil(total / pageSize)`
- response 內的 `page/pageSize` 必須回傳 server 正規化後的值。
### logic
`internal/logic/*` 只負責一次 API 請求的流程編排:
- 轉換 HTTP types 與 usecase DTO
- 呼叫一個或多個 model usecase
- 不直接操作 Mongo / Redis
- 不放 provider HTTP 細節
### model
`internal/model/*` 放可重複使用的業務能力:
- `domain/entity`:資料結構
- `domain/repository`repository interface
- `domain/usecase`usecase interface 與 DTO
- `repository`Mongo / Redis 實作
- `usecase`:業務能力實作
### provider
`internal/model/ai/provider` 只負責外部 AI API adapter
- 不讀 setting
- 不碰 HTTP handler
- 不存 token
- token 每次由 request 帶入
## Setting Model
設定使用 typed setting 形式:
```json
{
"scope": "user",
"scope_id": "user_123",
"key": "ai.default",
"value": {
"provider": "opencode-go",
"model": "deepseek-v4-pro",
"temperature": 0.7,
"max_tokens": 2000
},
"version": 1
}
```
API
```text
GET /api/v1/settings/:scope/:scope_id?page=1&pageSize=10
GET /api/v1/settings/:scope/:scope_id/:key
PUT /api/v1/settings/:scope/:scope_id/:key
DELETE /api/v1/settings/:scope/:scope_id/:key
```
`setting` model 不知道 AI、Threads、crawler 等業務含義。各業務 model 自己解讀對應 key 的 value。
## Auth / Member / Permission
這版從 `template-monorepo` 精簡搬入會員、權限與 token 的核心概念,但不搬 OAuth / OTP / MFA / Zitadel 依賴。
Auth 採 native email/password
```text
POST /api/v1/auth/register
POST /api/v1/auth/login
POST /api/v1/auth/refresh
POST /api/v1/auth/logout
```
`register` / `login` 回傳:
```json
{
"access_token": "...",
"refresh_token": "...",
"expires_in": 900,
"uid": "user_uid",
"token_type": "Bearer"
}
```
保護路由使用:
```http
Authorization: Bearer <access_token>
```
本機開發可以開啟 `Auth.DevHeaderFallback`,用 header 模擬登入:
```http
X-Tenant-ID: default
X-UID: user_uid
```
Member API
```text
GET /api/v1/members/me
PATCH /api/v1/members/me
```
Permission API
```text
GET /api/v1/permissions/catalog?tree=true
GET /api/v1/permissions/me?include_tree=true
```
資料模型:
- `members`tenant-scoped profile、email、bcrypt password hash、roles。
- `permissions`:平台 permission catalog。
- `role_permissions`tenant + role_key 對 permission catalog 的綁定。
- Redis `auth:jwt:*`access/refresh pair 與 blacklist。Redis 未配置時仍可簽發 token但 refresh/logout revoke 不會持久化。
## AI Provider
AI token 不存在 config呼叫時每次帶入且**只放 HTTP header**,不要放 JSON body避免 log / 回應洩漏):
```http
Authorization: Bearer sk-...
Content-Type: application/json
{
"provider": "opencode-go",
"model": "deepseek-v4-pro",
"messages": [
{ "role": "user", "content": "請幫我寫一段文案" }
]
}
```
API
```text
GET /api/v1/ai/providers
POST /api/v1/ai/providers/:provider/models
POST /api/v1/ai/chat
POST /api/v1/ai/chat/stream
```
- `GET /providers`:只回傳 catalogid、label、streams不含 models、不含 token。
- `POST /providers/:provider/models`:向 provider 的 `/models` 動態拉清單,需帶 `Authorization: Bearer <token>`
- 回應與錯誤訊息不會 echo tokenprovider 原始錯誤 body 也不會直接回傳給前端。
串流 endpoint 使用 SSE
```text
event: delta
data: {"type":"delta","text":"..."}
event: done
data: {"type":"done","finish_reason":"stop"}
```
## Job System
Job 系統的詳細設計在 `docs/job-system-plan.md`。目前 runtime 原則:
- MongoDB 的 `job_runs` 是狀態真相來源claim、cancel、complete、fail、retry 必須使用 conditional update避免 worker 與 API 互相覆蓋狀態。
- Redis `jobs:lock:<jobId>` 的 value 是 `workerID`release / refresh 必須檢查 owner只能由持有 lock 的 worker 操作。
- Worker 執行長任務時要定期呼叫 `RefreshRunLock(jobId, workerID, ttlSeconds)`,避免 reaper 誤判過期。
- Runner 支援 `RegisterStepHandler(stepID, handler)` 註冊自訂 step handler未註冊時會走 demo handler。自訂 handler 可用 `StepContext.Heartbeat` 續約 lock。
- 取消採 cooperative cancellationAPI 先寫 `cancel_requested` 與 Redis cancel signalworker checkpoint 讀取後呼叫 `AcknowledgeCancel(jobId, workerID)`
## OpenCode Go 注意事項
第一版 OpenCode Go 先走 OpenAI-compatible `/chat/completions`
```text
https://opencode.ai/zen/go/v1/chat/completions
```
目前已處理 Kimi 模型 `temperature = 1` 的特殊規則。部分 OpenCode Go 模型官方文件標示為 Anthropic-compatible `/messages`,後續可在 `internal/model/ai/provider` 新增 messages adapter不需要改 logic 或前端 SSE contract。
## 下一步建議
1. 用 `goctl` 重新生成 handler / logic / types確認 `.api` 與手寫版本對齊。
2. 補 `setting` repository 測試與 Mongo integration 測試。
3. 補 AI provider mock`logic/ai` 不需要真的打 provider 也能測。
4. 新增 credential service 或 Vault/KMS 整合,但不要把 token 放進 provider config。
5. 新增 worker/job model讓 Go worker 與 Node Playwright worker 共用同一套 job contract。
## 設計文件
- [Job 核心系統規劃](docs/job-system-plan.md):通用 job template、run、schedule、事件、取消語意與 worker contract。

87
cmd/tool/main.go Normal file
View File

@ -0,0 +1,87 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"strings"
"time"
"haixun-backend/internal/bootstrap"
"haixun-backend/internal/config"
"github.com/zeromicro/go-zero/core/conf"
)
func main() {
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
}
switch os.Args[1] {
case "init":
if err := runInit(os.Args[2:]); err != nil {
fmt.Fprintf(os.Stderr, "[tool] error: %v\n", err)
os.Exit(1)
}
default:
fmt.Fprintf(os.Stderr, "[tool] unknown command: %s\n", os.Args[1])
printUsage()
os.Exit(1)
}
}
func runInit(args []string) error {
fs := flag.NewFlagSet("init", flag.ExitOnError)
configFile := fs.String("f", "etc/gateway.yaml", "config file")
tenantID := fs.String("tenant", envOr("INIT_TENANT_ID", "default"), "tenant id for admin and role permissions")
email := fs.String("email", envOr("INIT_ADMIN_EMAIL", "admin@haixun.local"), "bootstrap admin email")
password := fs.String("password", envOr("INIT_ADMIN_PASSWORD", "Admin-Pass-1!"), "bootstrap admin password")
displayName := fs.String("display-name", envOr("INIT_ADMIN_DISPLAY_NAME", "Admin"), "bootstrap admin display name")
if err := fs.Parse(args); err != nil {
return err
}
var cfg config.Config
conf.MustLoad(*configFile, &cfg)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
report, err := bootstrap.Init(ctx, cfg, bootstrap.InitOptions{
TenantID: strings.TrimSpace(*tenantID),
AdminEmail: strings.TrimSpace(*email),
AdminPass: *password,
DisplayName: strings.TrimSpace(*displayName),
})
if err != nil {
return err
}
fmt.Fprintf(os.Stderr, "[tool] indexes ensured\n")
fmt.Fprintf(os.Stderr, "[tool] permissions catalog seeded\n")
fmt.Fprintf(os.Stderr, "[tool] role_permissions seeded (admin=all, user=default)\n")
if report.AdminCreated {
fmt.Fprintf(os.Stderr, "[tool] admin created uid=%s email=%s tenant=%s\n", report.AdminUID, *email, *tenantID)
} else {
fmt.Fprintf(os.Stderr, "[tool] admin exists uid=%s email=%s tenant=%s (roles ensured admin)\n", report.AdminUID, *email, *tenantID)
}
fmt.Printf("export INIT_TENANT_ID=%s\n", *tenantID)
fmt.Printf("export INIT_ADMIN_EMAIL=%s\n", *email)
fmt.Printf("export INIT_ADMIN_PASSWORD=%s\n", *password)
fmt.Printf("export INIT_ADMIN_UID=%s\n", report.AdminUID)
return nil
}
func envOr(key, fallback string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return fallback
}
func printUsage() {
fmt.Fprintf(os.Stderr, "usage:\n")
fmt.Fprintf(os.Stderr, " tool init [-f etc/gateway.yaml] [-tenant default] [-email admin@haixun.local] [-password ...]\n")
}

43
cmd/worker/main.go Normal file
View File

@ -0,0 +1,43 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"syscall"
"haixun-backend/internal/config"
"haixun-backend/internal/svc"
"github.com/zeromicro/go-zero/core/conf"
)
var configFile = flag.String("f", "etc/gateway.worker.yaml", "config file")
func main() {
flag.Parse()
var c config.Config
conf.MustLoad(*configFile, &c)
if !c.JobWorker.Enabled {
fmt.Fprintln(os.Stderr, "[worker] JobWorker.Enabled must be true")
os.Exit(1)
}
sc := svc.NewServiceContext(c)
defer sc.Close(context.Background())
fmt.Printf(
"[worker] started type=%s (scheduler=%v reaper=%v)\n",
c.JobWorker.WorkerType,
c.JobScheduler.Enabled,
c.JobReaper.Enabled,
)
ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
<-ch
fmt.Println("[worker] shutting down")
}

40
deploy/.env Normal file
View File

@ -0,0 +1,40 @@
# 複製為 deploy/.env 後再啟動cp deploy/.env.example deploy/.env
# ── 對外埠 ──
HAIXUN_WEB_PORT=8080
# ── 前端打包模式 ──
# static = 本機 make web-build 後 nginx 只 COPY dist預設最快
# docker = 在 Docker 內跑 npm build需改 compose 用 Dockerfile.web
# HAIXUN_WEB_BUILD_MODE=static
# ── Worker 分身數make prod 會帶入 docker compose --scale──
GO_WORKER_REPLICAS=5
NODE_STYLE8D_WORKER_REPLICAS=5
# ── Mongo / Redis容器內預設通常不用改──
# 資料存在 Docker named volumehaixun-prod_mongo_data、haixun-prod_redis_data
# prod-down 不會刪 volume重啟 container 資料仍在。
# 只改版程式make prod-update不碰 mongo/redis
HAIXUN_MONGO_URI=mongodb://mongo:27017
HAIXUN_MONGO_DATABASE=haixun
HAIXUN_REDIS_ADDR=redis:6379
# ── 安全金鑰(正式環境務必更換)──
HAIXUN_AUTH_ACCESS_SECRET=change-me-access-secret
HAIXUN_AUTH_REFRESH_SECRET=change-me-refresh-secret
HAIXUN_WORKER_SECRET=change-me-worker-secret
# ── 首次初始化管理員make prod 會自動跑 init已存在則跳過建立──
INIT_TENANT_ID=default
INIT_ADMIN_EMAIL=admin@30cm.net
INIT_ADMIN_PASSWORD=Fafafa54088
# ── Node 8D worker 選項 ──
# HAIXUN_NODE_WORKER_ID=custom-node-worker-1
# HAIXUN_WORKER_POLL_MS=3000
# ── 略過自動 init ──
# 預設:若 Mongo 已有 members 會自動跳過 init。
# 強制重跑 initPROD_FORCE_INIT=1 make prod
# HAIXUN_SKIP_INIT=1

40
deploy/.env.example Normal file
View File

@ -0,0 +1,40 @@
# 複製為 deploy/.env 後再啟動cp deploy/.env.example deploy/.env
# ── 對外埠 ──
HAIXUN_WEB_PORT=8080
# ── 前端打包模式 ──
# static = 本機 make web-build 後 nginx 只 COPY dist預設最快
# docker = 在 Docker 內跑 npm build需改 compose 用 Dockerfile.web
# HAIXUN_WEB_BUILD_MODE=static
# ── Worker 分身數make prod 會帶入 docker compose --scale──
GO_WORKER_REPLICAS=1
NODE_STYLE8D_WORKER_REPLICAS=1
# ── Mongo / Redis容器內預設通常不用改──
# 資料存在 Docker named volumehaixun-prod_mongo_data、haixun-prod_redis_data
# prod-down 不會刪 volume重啟 container 資料仍在。
# 只改版程式make prod-update不碰 mongo/redis
HAIXUN_MONGO_URI=mongodb://mongo:27017
HAIXUN_MONGO_DATABASE=haixun
HAIXUN_REDIS_ADDR=redis:6379
# ── 安全金鑰(正式環境務必更換)──
HAIXUN_AUTH_ACCESS_SECRET=change-me-access-secret
HAIXUN_AUTH_REFRESH_SECRET=change-me-refresh-secret
HAIXUN_WORKER_SECRET=change-me-worker-secret
# ── 首次初始化管理員make prod 會自動跑 init已存在則跳過建立──
INIT_TENANT_ID=default
INIT_ADMIN_EMAIL=admin@haixun.local
INIT_ADMIN_PASSWORD=Admin-Pass-1!
# ── Node 8D worker 選項 ──
# HAIXUN_NODE_WORKER_ID=custom-node-worker-1
# HAIXUN_WORKER_POLL_MS=3000
# ── 略過自動 init ──
# 預設:若 Mongo 已有 members 會自動跳過 init。
# 強制重跑 initPROD_FORCE_INIT=1 make prod
# HAIXUN_SKIP_INIT=1

22
deploy/Dockerfile.api Normal file
View File

@ -0,0 +1,22 @@
# syntax=docker/dockerfile:1
FROM golang:1.22-bookworm AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/haixun-api .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/haixun-worker ./cmd/worker
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/haixun-tool ./cmd/tool
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates gettext-base curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /out/haixun-api /out/haixun-worker /out/haixun-tool /app/
COPY deploy/config/gateway.runtime.yaml.tpl deploy/config/gateway.worker.runtime.yaml.tpl /app/deploy/config/
COPY deploy/docker/entrypoint-api.sh deploy/docker/entrypoint-worker.sh deploy/docker/entrypoint-init.sh /app/deploy/docker/
RUN chmod +x /app/deploy/docker/entrypoint-api.sh /app/deploy/docker/entrypoint-worker.sh /app/deploy/docker/entrypoint-init.sh
EXPOSE 8890
ENTRYPOINT ["/app/deploy/docker/entrypoint-api.sh"]

View File

@ -0,0 +1,9 @@
# syntax=docker/dockerfile:1
FROM mcr.microsoft.com/playwright:v1.49.1-noble AS base
WORKDIR /app
COPY worker/package.json ./
RUN npm install
COPY worker/ ./
ENV NODE_ENV=production
CMD ["npx", "tsx", "style-8d-worker.ts"]

14
deploy/Dockerfile.web Normal file
View File

@ -0,0 +1,14 @@
# syntax=docker/dockerfile:1
# 備用:無本機 Node 時在 Docker 內編譯。預設請用 Dockerfile.web.static + make web-build。
FROM node:22-bookworm AS web-builder
WORKDIR /src/web
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
FROM nginx:1.27-alpine
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=web-builder /src/web/dist /usr/share/nginx/html
EXPOSE 80

View File

@ -0,0 +1,7 @@
# syntax=docker/dockerfile:1
# 本機先執行 make web-build再打包純靜態檔 + nginx無 Node 編譯,建置最快)
FROM nginx:1.27-alpine
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
COPY web/dist /usr/share/nginx/html
EXPOSE 80

64
deploy/README.md Normal file
View File

@ -0,0 +1,64 @@
# 本機依賴Docker Compose
Gateway 啟用 **Notification** / **Member OTP** 需要:
| 服務 | 用途 | 預設埠 |
|------|------|--------|
| **MongoDB** | `notifications`、`notification_dlq` collections | 27017 |
| **Redis** | 冪等、配額、異步重試佇列、member OTP challenge | 6379 |
| MailHog選用 | 本機 SMTP 測試 | 1025 / 8025 |
| OpenLDAP`make ldap-up` / `make k6-up` | ZITADEL LDAP IdP 本機目錄 | 389 |
| ZITADEL`make k6-up` | OIDC / Social / LDAP 登入 | 8080 |
Mongo **不需要**事先手動建 collection應用程式寫入時會自動建立。索引由 init script 或 `make mongo-index` 建立。
## 快速開始
```bash
# 1. 啟動 Mongo + Redis
make deps-up
# 2.(選用)含 MailHog
make deps-up-smtp
# 3. 確認索引(首次 docker volume 通常已由 init 建立;可再跑一次保險)
make mongo-index
# 4. 啟動 Gateway使用 etc/gateway.dev.yaml
make run-dev
```
## Mongo collections
| Collection | 模組 | 說明 |
|------------|------|------|
| `notifications` | notification | 發送紀錄、冪等 |
| `notification_dlq` | notification | 超過 MaxRetry 的死信 |
索引定義見 [`deploy/mongo/init/01-gateway-indexes.js`](mongo/init/01-gateway-indexes.js),與 Go 的 `Index20260520001UP` 一致。
## 常用指令
```bash
make deps-up # docker compose up -d mongo redis
make deps-up-smtp # 再加上 mailhogprofile smtp
make ldap-up # 只起 OpenLDAPprofile ldap
make k6-up # 全棧含 OpenLDAP + ZITADEL見 deploy/zitadel、deploy/openldap README
make ldap-test # 確認 LDAP 測試帳號 alice/bob
make deps-down # 停止並移除容器(保留 volume
make deps-down-v # 停止並刪除 volume會清掉 Mongo 資料)
make deps-logs # 查看 log
make mongo-index # 手動建立/補齊索引
```
LDAP 本機測試:[deploy/openldap/README.md](openldap/README.md)
## 連線設定
設定說明:[`etc/README.md`](../etc/README.md)
| 檔案 | 用途 |
|------|------|
| [`etc/gateway.yaml`](../etc/gateway.yaml) | 預設,無需 Docker |
| [`etc/gateway.dev.example.yaml`](../etc/gateway.dev.example.yaml) | 範例(可提交) |
| `etc/gateway.dev.yaml` | 本機專用(**勿提交**,見 `.gitignore` |

View File

@ -0,0 +1,35 @@
Name: haixun-backend
Host: 0.0.0.0
Port: 8890
Timeout: 120000
Mongo:
URI: ${HAIXUN_MONGO_URI}
Database: ${HAIXUN_MONGO_DATABASE}
TimeoutSeconds: 10
Redis:
Addr: ${HAIXUN_REDIS_ADDR}
DB: 0
Auth:
AccessSecret: ${HAIXUN_AUTH_ACCESS_SECRET}
RefreshSecret: ${HAIXUN_AUTH_REFRESH_SECRET}
AccessExpireSeconds: 900
RefreshExpireSeconds: 2592000
DevHeaderFallback: false
InternalWorker:
Secret: ${HAIXUN_WORKER_SECRET}
JobWorker:
Enabled: false
WorkerType: go
JobScheduler:
Enabled: true
IntervalSeconds: 60
JobReaper:
Enabled: true
IntervalSeconds: 30

View File

@ -0,0 +1,35 @@
Name: haixun-worker
Host: 0.0.0.0
Port: 8891
Timeout: 120000
Mongo:
URI: ${HAIXUN_MONGO_URI}
Database: ${HAIXUN_MONGO_DATABASE}
TimeoutSeconds: 10
Redis:
Addr: ${HAIXUN_REDIS_ADDR}
DB: 0
Auth:
AccessSecret: ${HAIXUN_AUTH_ACCESS_SECRET}
RefreshSecret: ${HAIXUN_AUTH_REFRESH_SECRET}
AccessExpireSeconds: 900
RefreshExpireSeconds: 2592000
DevHeaderFallback: false
InternalWorker:
Secret: ${HAIXUN_WORKER_SECRET}
JobWorker:
Enabled: true
WorkerType: go
JobScheduler:
Enabled: false
IntervalSeconds: 60
JobReaper:
Enabled: false
IntervalSeconds: 30

View File

@ -0,0 +1,110 @@
name: haixun-prod
services:
mongo:
image: mongo:7
restart: unless-stopped
environment:
MONGO_INITDB_DATABASE: haixun
# named volume重啟/改版不會清資料(只有 prod-wipe-data 或 docker volume rm 才會)
volumes:
- mongo_data:/data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 5s
timeout: 5s
retries: 12
start_period: 15s
redis:
image: redis:7-alpine
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
# AOF + named volume重啟後 queue/lock 狀態可從磁碟恢復
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 12
api:
build:
context: ..
dockerfile: deploy/Dockerfile.api
restart: unless-stopped
env_file:
- .env
depends_on:
mongo:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8890/api/v1/health >/dev/null || exit 1"]
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
go-worker:
build:
context: ..
dockerfile: deploy/Dockerfile.api
restart: unless-stopped
entrypoint: ["/app/deploy/docker/entrypoint-worker.sh"]
env_file:
- .env
depends_on:
mongo:
condition: service_healthy
redis:
condition: service_healthy
api:
condition: service_healthy
node-worker-style-8d:
build:
context: ..
dockerfile: deploy/Dockerfile.node-worker
restart: unless-stopped
env_file:
- .env
environment:
HAIXUN_BACKEND_URL: http://api:8890
HAIXUN_WORKER_SECRET: ${HAIXUN_WORKER_SECRET}
HAIXUN_NODE_WORKER_ID: ${HAIXUN_NODE_WORKER_ID:-}
HAIXUN_WORKER_POLL_MS: ${HAIXUN_WORKER_POLL_MS:-3000}
depends_on:
api:
condition: service_healthy
web:
build:
context: ..
dockerfile: deploy/Dockerfile.web.static
restart: unless-stopped
ports:
- "${HAIXUN_WEB_PORT:-8080}:80"
depends_on:
api:
condition: service_healthy
init:
profiles: ["init"]
build:
context: ..
dockerfile: deploy/Dockerfile.api
entrypoint: ["/app/deploy/docker/entrypoint-init.sh"]
env_file:
- .env
depends_on:
mongo:
condition: service_healthy
redis:
condition: service_healthy
volumes:
mongo_data:
redis_data:

37
deploy/docker-compose.yml Normal file
View File

@ -0,0 +1,37 @@
services:
mongo:
image: mongo:7
container_name: gateway-mongo
restart: unless-stopped
ports:
- "27017:27017"
environment:
MONGO_INITDB_DATABASE: gateway
volumes:
- mongo_data:/data/db
- ./mongo/init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
redis:
image: redis:7-alpine
container_name: gateway-redis
restart: unless-stopped
ports:
- "6379:6379"
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
volumes:
mongo_data:
redis_data:

View File

@ -0,0 +1,15 @@
#!/bin/sh
set -eu
: "${HAIXUN_MONGO_URI:=mongodb://mongo:27017}"
: "${HAIXUN_MONGO_DATABASE:=haixun}"
: "${HAIXUN_REDIS_ADDR:=redis:6379}"
: "${HAIXUN_AUTH_ACCESS_SECRET:?HAIXUN_AUTH_ACCESS_SECRET is required}"
: "${HAIXUN_AUTH_REFRESH_SECRET:?HAIXUN_AUTH_REFRESH_SECRET is required}"
: "${HAIXUN_WORKER_SECRET:?HAIXUN_WORKER_SECRET is required}"
export HAIXUN_MONGO_URI HAIXUN_MONGO_DATABASE HAIXUN_REDIS_ADDR
export HAIXUN_AUTH_ACCESS_SECRET HAIXUN_AUTH_REFRESH_SECRET HAIXUN_WORKER_SECRET
envsubst < /app/deploy/config/gateway.runtime.yaml.tpl > /tmp/gateway.runtime.yaml
exec /app/haixun-api -f /tmp/gateway.runtime.yaml

View File

@ -0,0 +1,22 @@
#!/bin/sh
set -eu
: "${HAIXUN_MONGO_URI:=mongodb://mongo:27017}"
: "${HAIXUN_MONGO_DATABASE:=haixun}"
: "${HAIXUN_REDIS_ADDR:=redis:6379}"
: "${HAIXUN_AUTH_ACCESS_SECRET:?HAIXUN_AUTH_ACCESS_SECRET is required}"
: "${HAIXUN_AUTH_REFRESH_SECRET:?HAIXUN_AUTH_REFRESH_SECRET is required}"
: "${HAIXUN_WORKER_SECRET:?HAIXUN_WORKER_SECRET is required}"
: "${INIT_TENANT_ID:=default}"
: "${INIT_ADMIN_EMAIL:=admin@haixun.local}"
: "${INIT_ADMIN_PASSWORD:?INIT_ADMIN_PASSWORD is required}"
export HAIXUN_MONGO_URI HAIXUN_MONGO_DATABASE HAIXUN_REDIS_ADDR
export HAIXUN_AUTH_ACCESS_SECRET HAIXUN_AUTH_REFRESH_SECRET HAIXUN_WORKER_SECRET
envsubst < /app/deploy/config/gateway.runtime.yaml.tpl > /tmp/gateway.runtime.yaml
exec /app/haixun-tool init \
-f /tmp/gateway.runtime.yaml \
-tenant "$INIT_TENANT_ID" \
-email "$INIT_ADMIN_EMAIL" \
-password "$INIT_ADMIN_PASSWORD"

View File

@ -0,0 +1,15 @@
#!/bin/sh
set -eu
: "${HAIXUN_MONGO_URI:=mongodb://mongo:27017}"
: "${HAIXUN_MONGO_DATABASE:=haixun}"
: "${HAIXUN_REDIS_ADDR:=redis:6379}"
: "${HAIXUN_AUTH_ACCESS_SECRET:?HAIXUN_AUTH_ACCESS_SECRET is required}"
: "${HAIXUN_AUTH_REFRESH_SECRET:?HAIXUN_AUTH_REFRESH_SECRET is required}"
: "${HAIXUN_WORKER_SECRET:?HAIXUN_WORKER_SECRET is required}"
export HAIXUN_MONGO_URI HAIXUN_MONGO_DATABASE HAIXUN_REDIS_ADDR
export HAIXUN_AUTH_ACCESS_SECRET HAIXUN_AUTH_REFRESH_SECRET HAIXUN_WORKER_SECRET
envsubst < /app/deploy/config/gateway.worker.runtime.yaml.tpl > /tmp/gateway.worker.runtime.yaml
exec /app/haixun-worker -f /tmp/gateway.worker.runtime.yaml

View File

@ -0,0 +1,31 @@
// Gateway MongoDB 初始化(僅在 data volume 首次建立時執行)
// 與 internal/model/notification/repository/* Index20260520001UP 對齊
// 既有 volume 請執行make mongo-index
db = db.getSiblingDB('gateway');
print('Creating indexes on notifications...');
db.notifications.createIndex(
{ tenant_id: 1, kind: 1, idempotency_key: 1 },
{ unique: true, name: 'idx_notifications_tenant_kind_idempotency' }
);
db.notifications.createIndex(
{ tenant_id: 1, uid: 1, occurred_at: -1 },
{ name: 'idx_notifications_tenant_uid_occurred' }
);
db.notifications.createIndex(
{ status: 1, attempts: 1, occurred_at: 1 },
{ name: 'idx_notifications_status_attempts_occurred' }
);
print('Creating indexes on notification_dlq...');
db.notification_dlq.createIndex(
{ tenant_id: 1, occurred_at: -1 },
{ name: 'idx_notification_dlq_tenant_occurred' }
);
print('Gateway Mongo init done.');

55
deploy/nginx.conf Normal file
View File

@ -0,0 +1,55 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_comp_level 5;
gzip_min_length 256;
gzip_types
text/css
text/javascript
application/javascript
application/json
application/xml
image/svg+xml;
# Vite 產物:檔名含 hash可長期快取
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location /downloads/ {
add_header Cache-Control "public, max-age=86400";
try_files $uri =404;
}
location /illustrations/ {
add_header Cache-Control "public, max-age=86400";
try_files $uri =404;
}
# SPA 入口與路由:不快取,避免部署後仍載入舊版 shell
location = /index.html {
add_header Cache-Control "no-cache";
try_files $uri =404;
}
location /api/ {
proxy_pass http://api:8890;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location / {
try_files $uri $uri/ /index.html;
}
}

381
docs/job-system-plan.md Normal file
View File

@ -0,0 +1,381 @@
# Job 核心系統規劃
## 目標
建立一套通用 job system讓任何長任務、流程任務、定時任務未來都能共用。Job 不只是背景任務,而是「有模板、有設定、有狀態、有進度、有取消能力、有重跑策略、有排程能力」的工作單元。
## 核心設計
採用:
```text
Mongo = job/template/run/history 的真相來源
Redis = queue、distributed lock、schedule tick、短期 lease
```
```mermaid
flowchart LR
Api[GoAPI] --> Template[JobTemplate]
Api --> JobRun[JobRunMongo]
JobRun --> RedisQueue[RedisQueue]
Scheduler[SchedulerTick] --> RedisQueue
Worker[Worker] --> RedisQueue
Worker --> JobRun
Worker --> Step[JobStep]
```
## 核心概念
### JobTemplate
Template 定義「這種 job 要怎麼做」。例如:
```text
demo_long_task
external_worker_task
scheduled_report
multi_step_pipeline
```
Template 要回答:
- 這個 job 的輸入 payload schema 是什麼
- 有哪些 steps
- 最終狀態是什麼
- 可不可以重複執行
- 是否允許同 account / 同 target 同時跑
- retry policy 是什麼
- timeout 是多少
- 是否可被排程
- 是否支援取消,以及取消時 worker 要如何收斂
### JobRun
JobRun 是每一次執行實例。它引用 template保存當次 payload、狀態、進度、結果、錯誤與執行歷史。
### JobSchedule
JobSchedule 是「何時建立 JobRun」。支援
- cron
- enabled / disabled
- timezone
- payload template
- target scope例如 user/account/system
- nextRunAt / lastRunAt
### JobFlow
Flow 是多步驟流程。第一版不用做完整 DAG先支援線性 steps
```text
multi_step_pipeline:
1. prepare
2. execute
3. finalize
```
之後再擴成 DAG 或 conditional branch。
## Mongo Collections
### `job_templates`
```json
{
"_id": "...",
"type": "demo_long_task",
"version": 1,
"name": "示範長任務",
"description": "展示 job template、進度、取消、重跑與排程能力",
"enabled": true,
"repeatable": true,
"concurrencyPolicy": "reject_same_scope",
"dedupeKeys": ["scope_id", "target"],
"timeoutSeconds": 600,
"cancelPolicy": {
"supported": true,
"mode": "cooperative",
"graceSeconds": 30
},
"retryPolicy": {
"maxAttempts": 2,
"backoffSeconds": [30, 120]
},
"steps": [
{ "id": "prepare", "name": "準備資料", "workerType": "go", "timeoutSeconds": 60, "cancelable": true },
{ "id": "execute", "name": "執行任務", "workerType": "go", "timeoutSeconds": 300, "cancelable": true },
{ "id": "finalize", "name": "整理結果", "workerType": "go", "timeoutSeconds": 30, "cancelable": false }
],
"createAt": 0,
"updateAt": 0
}
```
### `job_runs`
```json
{
"_id": "...",
"templateType": "demo_long_task",
"templateVersion": 1,
"scope": "user",
"scopeId": "user_123",
"status": "pending",
"phase": "prepare",
"payload": {},
"progress": {
"summary": "等待 worker 執行",
"percentage": 20,
"steps": []
},
"result": null,
"error": null,
"attempt": 0,
"maxAttempts": 2,
"lockedBy": null,
"lockedUntil": null,
"cancelRequestedAt": null,
"cancelReason": null,
"scheduledAt": null,
"startedAt": null,
"completedAt": null,
"createAt": 0,
"updateAt": 0
}
```
### `job_schedules`
```json
{
"_id": "...",
"templateType": "demo_long_task",
"scope": "user",
"scopeId": "user_123",
"enabled": true,
"cron": "0 9 * * *",
"timezone": "Asia/Taipei",
"payloadTemplate": {},
"lastRunAt": null,
"nextRunAt": 0,
"createAt": 0,
"updateAt": 0
}
```
### `job_events`
用來觀察與 audit
```json
{
"_id": "...",
"jobId": "...",
"type": "status_changed",
"from": "pending",
"to": "running",
"message": "worker claimed job",
"metadata": {},
"createAt": 0
}
```
## Status Model
```text
pending = 已建立,等待 queue
queued = 已推進 Redis queue
running = worker 執行中
waiting_worker = 等外部 worker 回寫
cancel_requested = 使用者已要求取消,等待 worker cooperative stop
succeeded = 成功完成
failed = 最終失敗
cancelled = 使用者取消
expired = lock/timeout 過期後無法恢復
```
Step status
```text
pending | running | succeeded | failed | skipped | cancelled
```
## 取消語意
取消是第一版必做能力,採 cooperative cancellation
```mermaid
flowchart LR
User[User] --> ApiCancel[CancelAPI]
ApiCancel --> Run[JobRun cancel_requested]
Run --> RedisCancel[RedisCancelSignal]
Worker[Worker] -->|"poll cancel flag"| Run
Worker --> Stop[StopCurrentStep]
Stop --> Final[JobRun cancelled]
```
規則:
- `pending` / `queued`:取消後直接變 `cancelled`,並盡量從 Redis queue 移除若無法移除worker claim 時必須檢查狀態並跳過。
- `running`:狀態改為 `cancel_requested`,寫入 `cancelRequestedAt` / `cancelReason`worker 必須在 step 間或長任務 checkpoint 檢查取消旗標。
- `waiting_worker`:狀態改為 `cancel_requested`,同時寫 Redis cancel signal外部 worker 回寫前要檢查 job 狀態。
- `succeeded` / `failed` / `cancelled` / `expired`:不可取消,回傳 ResourceInvalidState。
- worker 收到取消後呼叫 `AcknowledgeCancel(jobId, workerId)`,釋放 lock寫入 `job_events`,狀態變 `cancelled`
- 若 `cancel_requested` 超過 template 的 `cancelPolicy.graceSeconds`scheduler/reaper 可標記為 `cancelled``expired`,第一版建議標記 `cancelled` 並記錄 timeout event。
## 狀態與 Lock 安全規則
第一版已把最容易出問題的 race condition 收斂在 repository / usecase
- `ClaimNext` 只能從 `pending` / `queued` conditional update 成 `running`。如果 API 同時取消Mongo update 會被拒絕。
- `RequestCancel` 只能從 cancellable 狀態 conditional update`pending` / `queued` 直接變 `cancelled``running` / `waiting_worker``cancel_requested`
- `CompleteRun` / `FailRun` / `UpdateProgress` 必須帶 `workerID`,並且只能更新 `lockedBy == workerID` 的 job。
- Redis `jobs:lock:<jobId>` 的 value 是 `workerID``ReleaseLock` / `RefreshLock` 使用 owner check避免舊 worker 誤刪新 worker 的 lock。
- Worker 長任務要定期 heartbeat呼叫 `RefreshRunLock(jobId, workerID, ttlSeconds)`。自訂 step handler 可用 `StepContext.Heartbeat`
之後新增狀態轉移時,不要直接使用裸 `Update`;若是生命週期狀態,應新增明確的 guarded repository 方法或使用現有 conditional update。
## Redis Keys
```text
jobs:queue:<workerType> # list 或 streamworker 消費
jobs:lock:<jobId> # lease lock
jobs:scheduler:lock # scheduler singleton lock
jobs:dedupe:<template>:<hash> # 防止同 scope 重複跑
jobs:cancel:<jobId> # cancel signalworker checkpoint 讀取
```
第一版建議用 Redis List 即可,之後需要 ack/replay 再升級 Redis Streams。
## API 規劃
```text
GET /api/v1/job/templates
GET /api/v1/job/templates/:type
PUT /api/v1/job/templates/:type
POST /api/v1/jobs
GET /api/v1/jobs/:id
GET /api/v1/jobs?page=1&pageSize=20
POST /api/v1/jobs/:id/cancel
POST /api/v1/jobs/:id/retry
GET /api/v1/job/schedules?page=1&pageSize=20
POST /api/v1/job/schedules
PUT /api/v1/job/schedules/:id
POST /api/v1/job/schedules/:id/enable
POST /api/v1/job/schedules/:id/disable
```
所有列表回應使用目前標準:
```json
{
"code": 102000,
"message": "SUCCESS",
"data": {
"pagination": {
"total": 42,
"page": 1,
"pageSize": 10,
"totalPages": 5
},
"list": []
}
}
```
## 分層規劃
```text
internal/model/job/
domain/entity/template.go
domain/entity/run.go
domain/entity/schedule.go
domain/entity/event.go
domain/enum/status.go
domain/repository/*.go
domain/usecase/*.go
repository/mongo_*.go
repository/redis_queue.go
usecase/template_usecase.go
usecase/run_usecase.go
usecase/schedule_usecase.go
usecase/progress_usecase.go
usecase/worker_usecase.go
internal/logic/job/
create_job_logic.go
get_job_logic.go
list_jobs_logic.go
cancel_job_logic.go
retry_job_logic.go
template_logic.go
schedule_logic.go
internal/worker/job/
runner.go
scheduler.go
dispatcher.go
```
## Template 執行規則
### 可不可以重複做
`repeatable``concurrencyPolicy` 控制:
```text
repeatable=false
同 dedupe key 完成過就不再建立
repeatable=true + reject_same_scope
已有 running/pending job 時拒絕建立
repeatable=true + allow_parallel
允許平行跑
repeatable=true + replace_existing
取消舊 job建立新 job
```
### 最終狀態
Template 可定義成功條件:
```text
successWhen = all_steps_succeeded
```
第一版只支援 `all_steps_succeeded`。之後再加 `any_step_succeeded` 或 conditional flow。
### 取消能力
Template 用 `cancelPolicy` 控制取消:
```text
supported=false
API 不允許取消此 job
mode=cooperative
worker checkpoint 檢查取消旗標後收斂
graceSeconds=30
cancel_requested 超過 grace 後由 reaper 收斂
```
Step 用 `cancelable` 控制目前步驟是否可立即停止。若目前 step 不可取消worker 需要在 step 結束後停止後續 steps最後狀態仍為 `cancelled`
## 第一版建議實作順序
1. 建 `model/job` 的 enum/entity/repository interface。
2. 實作 Mongo repositoriestemplate/run/schedule/event。
3. 實作 Redis queue/lock repository。
4. 實作 `CreateRun`:讀 template、檢查 repeat/concurrency、建立 run、push queue。
5. 實作 `ClaimNext`worker 從 Redis 取 jobMongo 設 lock/status。
6. 實作 `RequestCancel`:狀態轉 `cancel_requested` 或直接 `cancelled`,寫 Redis cancel signal 與 event。
7. 實作 `AcknowledgeCancel`worker 收斂後釋放 lock狀態轉 `cancelled`
8. 實作 `UpdateProgress`、`Complete`、`Fail`、`Retry`。
9. 實作 schedule tick`job_schedules.nextRunAt <= now`,建立 JobRun。
10. 實作 API 與文件。

512
docs/scan-placement-plan.md Normal file
View File

@ -0,0 +1,512 @@
# 海巡獲客計畫:知識圖譜 + 雙軌爬取 + 島民交接
> 在既有「背景 Job + 島民交接」上,新增 Topic Knowledge GraphBrave 驅動)與**雙維度 Tag**(相關 + 近期)+ **雙軌海巡**,強化流程 B 的痛點發現、關鍵字精準度與**產品-痛點匹配**驗證。
## 北極星
海巡找到的貼文/留言,**你的產品是否真的解得了那個問題**(可置入、可回覆、可追蹤)。
流程 B 主線:
```text
Brave 知識圖譜擴散(周邊延伸)
→ 衍生雙維度搜尋 tag相關詞 + 近期求助詞)
→ 使用者手動勾選
→ 每個 tag 雙軌爬 Threads相關軌 + 近期軌7d 優先 / 30d 補充)
→ productFitScore 篩選
→ 島民協助撰寫獲客留言
```
## 已拍板決策
| 項目 | 決策 |
|------|------|
| 圖譜深度 | **3 層、範圍廣**:核心 → 成因/症狀 → 相鄰情境 |
| 進海巡的 tag | **使用者手動勾選**(圖譜 UI 多選;島民可 toggle不預設全選 |
| 近期窗口 | **7 天內為重點**;不足時補充至 **30 天**;超過 30 天排除 |
| Brave 預算 | 中等,每輪知識擴展 **1015 次查詢**(不足可 supplemental 1 輪) |
| 痛點 tag 候選 | 圖譜衍生 **≥12 候選**,其中痛點/求助類 **≥8** |
| 流程 A | 保留 `style-8d` 捷徑matrix + 留言收集疊加於 Phase 2 |
---
## 根因診斷(舊系統痛點少、關鍵字不準)
對照舊 Next.js[`lib/ai/prompts/research-map-placement.ts`](../../lib/ai/prompts/research-map-placement.ts)、[`lib/services/scan-tasks.ts`](../../lib/services/scan-tasks.ts)、[`lib/ai/analyze-topic.ts`](../../lib/ai/analyze-topic.ts)
| 現象 | 根因 | 新系統對策 |
|------|------|------------|
| 痛點只抓到 12 個 | Placement 壓 `suggestedTags`**24**`PLACEMENT_QUERY_MAX = 8` | 圖譜衍生 ≥8 痛點 tag + supplemental 補充迴圈 |
| 關鍵字不夠精準 | AI 憑種子詞推測,無外部知識 | Brave `knowledge_expand` 建 TKG節點附 `evidence[]` |
| 只有「最相關」 | Recency 只是加分,無獨立近期軌 | **Tag 層**分 `relevance` / `recency`**Crawl 層**雙軌必跑 |
| 沒有周邊延伸 | Brave 只做 `site:threads.net` | `knowledge_expand` 做領域知識(成因、懷孕、換季…)再衍生 tag |
新後端原則見 [`AGENTS.md`](../AGENTS.md)**複製模式,不複製舊業務**——移植 Playwright/過濾規則,用 Mongo + Job 重建。
---
## 架構總覽
```mermaid
flowchart TB
subgraph input [輸入]
Seed["種子詞"]
ProductBrief["product_brief"]
Persona["人設"]
end
subgraph tkg [知識圖譜]
ExpandJob["expand-graph job"]
BraveK["Brave knowledge_expand"]
TKG["topic_knowledge_graphs"]
end
subgraph derive [Tag衍生]
DeriveFn["deriveSearchTagsFromGraph"]
RelQ["relevanceQueries"]
RecQ["recencyQueries"]
end
subgraph select [使用者選擇]
GraphUI["圖譜 UI 勾選節點/tag"]
end
subgraph scan [海巡]
ScanJob["scan job 每tag雙軌"]
Posts["scan_posts"]
end
subgraph outcome [驗收]
Fit["productFitScore"]
Outreach["outreach + 島民留言"]
end
Seed --> ExpandJob
ProductBrief --> ExpandJob
Persona --> ExpandJob
ExpandJob --> BraveK --> TKG
TKG --> DeriveFn
DeriveFn --> RelQ
DeriveFn --> RecQ
RelQ --> GraphUI
RecQ --> GraphUI
GraphUI --> ScanJob --> Posts --> Fit --> Outreach
```
---
## Tag 產生完整流水線
Tag **不是** AI 一次吐 24 個,而是五段流水線產出:
```mermaid
flowchart LR
S["1 種子詞+brief"] --> A["2 AI核心地圖"]
A --> B["3 Brave knowledge_expand"]
B --> G["4 合成TKG三層"]
G --> D["5 deriveSearchTagsFromGraph"]
D --> R["relevanceQueries"]
D --> C["recencyQueries"]
```
| 步驟 | 做什麼 | 產出 |
|------|--------|------|
| 1 | 讀 `seed_query`、`product_brief`、`target_audience` | 輸入包 |
| 2 | AI 產核心 questions/pillars/exclusions | 研究地圖骨架 |
| 3 | Brave 1015 次**一般網搜**(非 threadsOnly | snippets → 候選節點 |
| 4 | AI 合成 TKGL0/L1/L2+ `productFitScore` + `evidence[]` | `topic_knowledge_graphs` |
| 5 | 每節點壓成 28 字真人搜尋詞,分兩套 | `derivedTags` |
### 雙維度 Tag相關 + 近期都要)
每個圖譜節點衍生:
| 維度 | 用途 | 寫法範例 |
|------|------|----------|
| **`relevanceQueries`** | 相關軌:短詞、高命中 | `敏感肌`、`屏障受損` |
| **`recencyQueries`** | 近期軌:求助語境 + 時間窗 | `敏感肌 請問`、`換季泛紅 推薦` |
- `recencyQueries` 在 Brave `threads_discover` 時加 `after:{7天前日期}`(參考舊 [`scan-web-discover.ts`](../../lib/services/scan-web-discover.ts) `buildPlacementKeywordQueries`
- 候選總量:**≥12 tag**(痛點/求助類 **≥8****使用者勾選後才 crawl**
---
## 痛點 Tag 保底機制
解決「只抓到一兩個痛點」:
```text
expand-graph 完成 → deriveSearchTagsFromGraph
IF 痛點/求助類 tag 數 < 8:
→ supplemental_round最多 1 次Brave +5 查詢)
→ 追加查詢例:{seed} 困擾、{seed} 求助、{L2節點} 請問、{seed} 推薦
→ AI 補節點 + 補 derivedTags
IF 仍 < 8:
→ job 標 warningUI + 島民提示「可重跑 expand 或手動加種子詞」
```
| 指標 | 舊系統 | 新系統 |
|------|--------|--------|
| Placement suggestedTags | 24 | 不沿用此上限 |
| 搜尋任務上限 | 8 | 候選 ≥12實 crawl = 勾選數 |
| 痛點類最低 | 無保證 | **≥8**(含 supplemental |
---
## Topic Knowledge GraphTKG
### Mongo collection`topic_knowledge_graphs`
`persona_id` + `seed_query`
```json
{
"seed": "敏感肌",
"nodes": [
{
"id": "n1",
"label": "敏感肌",
"nodeKind": "pain",
"type": "core",
"layer": 0,
"placementValue": "high",
"productFitScore": 95,
"selectedForScan": false,
"evidence": [],
"derivedTags": {
"relevance": ["敏感肌"],
"recency": ["敏感肌 請問", "敏感肌 推薦"]
}
},
{
"id": "n2",
"label": "懷孕嗅覺敏感",
"nodeKind": "cause",
"type": "cause",
"layer": 2,
"relation": "可能成因",
"placementValue": "medium",
"productFitScore": 40,
"selectedForScan": false,
"evidence": [{ "url": "...", "snippet": "..." }],
"derivedTags": {
"relevance": ["懷孕皮膚癢", "嗅覺敏感"],
"recency": ["懷孕 皮膚 癢 請益"]
}
},
{
"id": "n3",
"label": "屏障修復原理",
"nodeKind": "knowledge",
"type": "mechanism",
"layer": 1,
"productFitScore": 70,
"selectedForScan": false,
"derivedTags": {
"relevance": ["屏障受損"],
"recency": ["屏障受損 怎麼辦"]
}
}
],
"edges": [
{ "from": "n1", "to": "n2", "relation": "可能因" },
{ "from": "n1", "to": "n3", "relation": "機制" }
],
"braveSources": [{ "query": "敏感肌 懷孕 原因", "snippet": "...", "url": "..." }],
"painTagCount": 9,
"generatedAt": 0
}
```
### 三層擴散
```text
L0 核心:敏感肌
L1 直接相關:屏障受損、換季泛紅、刺癢
L2 周邊情境:懷孕荷爾蒙、嗅覺敏感、壓力熬夜、換洗臉產品過敏 …
```
### 節點語意
| 欄位 | 說明 |
|------|------|
| `nodeKind` | `pain`(痛點/求助)、`knowledge`(科普延伸)、`cause`、`symptom` |
| `placementValue` | 建議優先級,**不決定是否海巡** |
| `selectedForScan` | 使用者勾選後 `true`,才進 `scan` payload |
| `productFitScore` | 依 `product_brief`:產品解不解得了 |
| `derivedTags` | `relevance` + `recency` 兩套查詢詞 |
| `evidence[]` | L1/L2 必填Brave snippet 可追溯) |
- `knowledge` 節點:延伸話題/科普靈感,**預設不勾選**;若 snippet 含求助語境可升級為 `pain`
- `knowledge` 不強制進 placement crawl除非使用者勾選且 `productFitScore` 達標
---
## Brave 雙模式
| 模式 | `threadsOnly` | 用途 |
|------|---------------|------|
| `knowledge_expand` | `false` | 建 TKG找成因/周邊/知識 |
| `threads_discover` | `true` | 海巡時找 Threads 貼文 |
### L0/L1 查詢模板plan_queries上限 15/輪)
```text
{seed} 常見原因
{seed} 什麼情況會
{seed} 初期 症狀
{seed} 怎麼改善 困擾
{seed} 求助 推薦
```
### L2 周邊擴散查詢池(從 brief/受眾推導)
```text
{seed} 懷孕 相關
{seed} 壓力 熬夜
{seed} 換產品 過敏
{seed} 與 {受眾場景} 的關係
{L1節點} 原因
{L1節點} 困擾
```
Brave 回傳 title/snippet/url → AI 萃取節點與邊 → 寫入 TKG。實作`internal/library/knowledge/` + Brave adapter`BRAVE_SEARCH_API_KEY`;參考舊 [`lib/services/web-search.ts`](../../lib/services/web-search.ts))。
---
## 完整範例:敏感肌 Walkthrough
**輸入**
- 種子詞:`敏感肌`
- product_brief溫和修護、無香料、適合敏感/屏障受損肌
**Brave knowledge_expand節錄**
| 查詢 | snippet 線索 | 圖譜節點 |
|------|--------------|----------|
| `敏感肌 常見原因` | 屏障受損、過度清潔 | L1 symptom `屏障受損` |
| `敏感肌 懷孕` | 荷爾蒙、嗅覺/皮膚變敏感 | L2 cause `懷孕嗅覺敏感` |
| `換季 皮膚 泛紅` | 季節性刺激 | L1 symptom `換季泛紅` |
**衍生 tag候選勾選前不 crawl**
| 節點 | relevanceQuery | recencyQuery | productFit |
|------|----------------|--------------|------------|
| 敏感肌 | `敏感肌` | `敏感肌 請問` | 95 |
| 屏障受損 | `屏障受損` | `屏障受損 推薦` | 90 |
| 換季泛紅 | `換季泛紅` | `換季泛紅 請問` | 88 |
| 懷孕皮膚癢 | `懷孕皮膚癢` | `懷孕 皮膚 癢 請益` | 視產品而定 |
**使用者**:勾選 productFit 高的 4 個節點(可不勾懷孕若產品不適用)
**startScan**:每個勾選節點的 relevance + recency 詞都跑雙軌
| 軌道 | 行為 | 本例預期 |
|------|------|----------|
| 相關軌 | sort=relevance, limit≈12 | 高互動痛點貼文 |
| 近期軌 | 7d 優先,不足補 30d | 一週內求助帖 |
**合併** → gold / recent / relevant → `productFitScore` → 獲客台 → 島民 `generateOutreachReply` + fill
---
## 近期窗口
| 窗口 | 天數 | 行為 |
|------|------|------|
| **重點** | 7 天內 | 優先爬取、優先顯示、排序最高 |
| **補充** | 830 天 | 7 天內不足時才補,排序較低 |
| **排除** | >30 天 | 不進海巡與獲客清單 |
策略:
1. 每個勾選 tag 的**近期軌**先抓滿 7 天名額
2. 全輪痛點貼文不足目標時,自動放寬至 30 天
3. 獲客台預設篩「7 天內」,可切「含 30 天內補充」
---
## 雙軌海巡Tag + Crawl + UI 三層對齊)
**近期軌不是相關軌的副產品**——每個勾選 tag 的 relevance 與 recency 查詢都**必跑**。
| 層級 | 相關 | 近期 |
|------|------|------|
| **Tag** | `derivedTags.relevance` 短詞高命中 | `derivedTags.recency` 求助語境 + after 日期 |
| **Crawl** | 相關軌 sort=relevance, limit≈12 | 近期軌 7d 滿額 → 30d 補 |
| **UI** | 可篩 `priority=relevant` | 預設 7d + `priority=gold` 置頂 |
合併優先級:
1. 兩軌皆有 → `gold`
2. 僅近期軌 → `recent`
3. 僅相關軌 → `relevant`
過濾:移植 `hasPlacementIntent`、`looksLikeCasualChat`(舊 [`lib/topic-anchor.ts`](../../lib/topic-anchor.ts)、[`lib/scan-recency.ts`](../../lib/scan-recency.ts))。
### scan_posts 擴充欄位
- `placement_score`、`priority`gold/recent/relevant
- `product_fit_score`、`solved_by_product`
- `posted_at`、`search_tag`、`query_dimension`relevance/recency
- `graph_node_id`
- `replies[]`(可選,`scrape_replies: true`
---
## 產品匹配驗收
每篇海巡結果:
- **`productFitScore`**:痛點 vs `product_brief`
- **`solvedByProduct`**:獲客留言是否對應產品能力(生成時強制檢查)
獲客台 UI
- 預設排序7 天內 + 產品能解決
- 標示:可置入 / 需人工 / 超出產品範圍
- 獲客留言:**島民 fill 全文,不自動送出**
---
## 島民交接
### job.result.handoff
```json
{
"handoff": {
"flow": "placement",
"persona_id": "...",
"pain_tag_count": 9,
"summary": "12 候選 tag → 勾選 6 節點 → 38 篇;痛點 10核心 6 + 周邊 47 天內 8 篇",
"pain_breakdown": { "core": 6, "peripheral": 4, "recent_7d": 8 },
"top_peripheral_hits": ["懷孕皮膚癢", "換季泛紅"],
"next_route": "/personas/:id/outreach",
"needs_supplemental_expand": false,
"connection_required": false
}
}
```
JobMonitor → `islanderHandoffStore`[`buildIslanderContext`](../web/src/lib/islander/buildIslanderContext.ts) 注入【近期海巡交接】。
### Custom actions
| Action | 用途 |
|--------|------|
| `expandKnowledgeGraph` | 觸發 `expand-graph``supplemental=true` 補充迴圈 |
| `toggleGraphNode` | 勾選/取消節點 |
| `startScan` | `dual_track=true`,只爬 `selectedForScan` 節點 |
| `generateOutreachReply` | 產獲客留言 |
| `applyDraft` | fill 留言欄位 |
### 對話路徑(流程 B
```text
「幫我找敏感肌的痛點」
→ expand-graphBrave knowledge_expand + AI 合成 TKG
→ IF pain_tag_count < 8 島民要再補一輪 Brave supplemental_round
→ 研究頁:圖譜 + 雙維度 tag + productFitScore
→ 使用者手動勾選節點
→ startScan每詞雙軌相關 + 近期7d/30d
→ outreach → highlight gold/recent → generateOutreachReply + fill
```
---
## Job 模板
| Template | Steps | worker |
|----------|-------|--------|
| `expand-graph` | plan_queries → brave_knowledge → ai_synth → derive_tags → [supplemental?] → persist_tkg | go |
| `scan` | session → crawl_dual_track → replies? → store → filter → ai_fit → persist | node + go |
| `style-8d` | (既有) | node + go |
執行順序:**expand-graph → 勾選 tag → scan**。
---
## API 草案
```text
POST /api/v1/personas/:id/knowledge-graph/expand # ?supplemental=true
GET /api/v1/personas/:id/knowledge-graph
PATCH /api/v1/personas/:id/knowledge-graph/nodes # selectedForScan
POST /api/v1/personas/:id/scan-jobs # graph_id, selected_node_ids, dual_track
GET /api/v1/personas/:id/scan-posts # recent_7d, product_fit_min, priority
POST /api/v1/personas/:id/outreach-drafts/generate
```
Internal worker`POST /workers/scan-posts/batch`、Brave/AI 內部端點。
---
## 前端頁面
| 路徑 | 用途 | 島民 label |
|------|------|------------|
| `/personas/:id/research` | 圖譜、雙維度 tag、勾選 | 加入海巡、Brave 再擴展 |
| `/personas/:id/outreach` | 獲客貼文 + 留言 | 獲客留言、標記已處理 |
| `/personas/:id/matrix` | 流程 APhase 2 | 草稿內容 |
研究頁每節點展示:`relevanceQueries`、`recencyQueries`、`productFitScore`、勾選框、上次命中數。
---
## 實作分期
### Phase 0a — 知識圖譜 + Tag 流水線
- [ ] Go Brave adapter`knowledge_expand` / `threads_discover`
- [ ] `expand-graph` jobplan_queries → brave → ai_synth → derive_tags
- [ ] `supplemental_round`(痛點 tag < 8
- [ ] Mongo `topic_knowledge_graphs`(含 `derivedTags`、`painTagCount`
- [ ] `deriveSearchTagsFromGraph`relevance + recency 雙陣列)
- [ ] API expand / get / patch nodes
### Phase 0b — 島民 handoff
- [ ] handoff`pain_tag_count`、`needs_supplemental_expand`
- [ ] JobMonitor bridge
- [ ] custom actions + `ai.islander.system.md` 海巡專章
### Phase 1 — 雙軌 scan + 流程 B
- [ ] Node `crawl_dual_track`(每 tag 相關+近期7d/30d
- [ ] `productFitScore` + outreach UI
- [ ] **驗收**:敏感肌 → L2懷孕等→ 候選痛點 tag ≥8 → 勾選後貼文痛點 ≥8 → 7d 內 ≥5
### Phase 2 — 流程 A
- [ ] matrix + 留言收集 + 島民 fill
### Phase 3 — 自動化
- [ ] job_schedules、Brave 熔斷、Meta API 發留言
---
## 風險
| 議題 | 對策 |
|------|------|
| Brave 幻覺 | 節點必須有 `evidence[]` |
| 圖譜跑題 | exclusions + `productFitScore` |
| 查詢爆炸 | Brave ≤15/輪supplemental ≤5衍生 ≤20只爬勾選 |
| 醫療敏感 | `disclaimer`;留言不自動發 |
| 周邊節點產品不符 | 低 productFit 預設不勾;獲客台標 ✗ |
---
## 參考
- 舊海巡:[`lib/services/scan.ts`](../../lib/services/scan.ts)
- 舊網搜:[`lib/services/scan-web-discover.ts`](../../lib/services/scan-web-discover.ts)
- 舊研究地圖:[`lib/ai/analyze-topic.ts`](../../lib/ai/analyze-topic.ts)
- Job 系統:[`docs/job-system-plan.md`](./job-system-plan.md)
- 島民:[`internal/library/prompt/files/ai.islander.system.md`](../internal/library/prompt/files/ai.islander.system.md)
- 既有 8D[`worker/style-8d-worker.ts`](../worker/style-8d-worker.ts)

View File

@ -0,0 +1,20 @@
Name: haixun-backend
Host: 0.0.0.0
Port: 8890
Timeout: 120000
Mongo:
URI: mongodb://127.0.0.1:27017
Database: haixun_dev
TimeoutSeconds: 10
Redis:
Addr: 127.0.0.1:6379
DB: 0
Auth:
AccessSecret: haixun-dev-access-secret-change-me
RefreshSecret: haixun-dev-refresh-secret-change-me
AccessExpireSeconds: 900
RefreshExpireSeconds: 2592000
DevHeaderFallback: true

35
etc/gateway.prod.yaml Normal file
View File

@ -0,0 +1,35 @@
Name: haixun-backend
Host: 0.0.0.0
Port: 8890
Timeout: 120000
Mongo:
URI: mongodb://mongo:27017
Database: haixun
TimeoutSeconds: 10
Redis:
Addr: redis:6379
DB: 0
Auth:
AccessSecret: change-me-in-prod
RefreshSecret: change-me-in-prod-too
AccessExpireSeconds: 900
RefreshExpireSeconds: 2592000
DevHeaderFallback: false
InternalWorker:
Secret: change-me-worker-secret
JobWorker:
Enabled: false
WorkerType: go
JobScheduler:
Enabled: true
IntervalSeconds: 60
JobReaper:
Enabled: true
IntervalSeconds: 30

35
etc/gateway.worker.yaml Normal file
View File

@ -0,0 +1,35 @@
Name: haixun-worker
Host: 0.0.0.0
Port: 8891
Timeout: 120000
Mongo:
URI: mongodb://mongo:27017
Database: haixun
TimeoutSeconds: 10
Redis:
Addr: redis:6379
DB: 0
Auth:
AccessSecret: change-me-in-prod
RefreshSecret: change-me-in-prod-too
AccessExpireSeconds: 900
RefreshExpireSeconds: 2592000
DevHeaderFallback: false
InternalWorker:
Secret: change-me-worker-secret
JobWorker:
Enabled: true
WorkerType: go
JobScheduler:
Enabled: false
IntervalSeconds: 60
JobReaper:
Enabled: false
IntervalSeconds: 30

32
etc/gateway.yaml Normal file
View File

@ -0,0 +1,32 @@
Name: haixun-backend
Host: 0.0.0.0
Port: 8890
Timeout: 120000
Mongo:
URI: mongodb://127.0.0.1:27017
Database: haixun
TimeoutSeconds: 10
Redis:
Addr: 127.0.0.1:6379
DB: 0
Auth:
AccessSecret: haixun-dev-access-secret-change-me
RefreshSecret: haixun-dev-refresh-secret-change-me
AccessExpireSeconds: 900
RefreshExpireSeconds: 2592000
DevHeaderFallback: true
JobWorker:
Enabled: true
WorkerType: go
JobScheduler:
Enabled: true
IntervalSeconds: 60
JobReaper:
Enabled: true
IntervalSeconds: 30

34
gateway.go Normal file
View File

@ -0,0 +1,34 @@
package main
import (
"context"
"flag"
"fmt"
"haixun-backend/internal/config"
"haixun-backend/internal/handler"
"haixun-backend/internal/svc"
"github.com/zeromicro/go-zero/core/conf"
"github.com/zeromicro/go-zero/rest"
)
var configFile = flag.String("f", "etc/gateway.yaml", "config file")
func main() {
flag.Parse()
var c config.Config
conf.MustLoad(*configFile, &c)
server := rest.MustNewServer(c.RestConf)
defer server.Stop()
sc := svc.NewServiceContext(c)
defer sc.Close(context.Background())
handler.RegisterHandlers(server, sc)
fmt.Printf("Starting backend backend at %s:%d...\n", c.Host, c.Port)
server.Start()
}

83
generate/api/ai.api Normal file
View File

@ -0,0 +1,83 @@
syntax = "v1"
type (
AIMessage {
Role string `json:"role" validate:"required,oneof=system user assistant"` // 訊息角色
Content string `json:"content" validate:"required"` // 訊息內容
}
AIChatReq {
Provider string `json:"provider" validate:"required,oneof=opencode-go xai"` // AI provider
Model string `json:"model" validate:"required"` // 模型 ID
System string `json:"system,optional"` // system prompt
Messages []AIMessage `json:"messages" validate:"required,min=1,dive"` // 對話訊息
Temperature *float64 `json:"temperature,optional"` // 溫度
MaxTokens *int `json:"max_tokens,optional"` // 最大輸出 token
}
AIProviderOption {
ID string `json:"id"`
Label string `json:"label"`
Streams bool `json:"streams"`
}
AIProvidersData {
Providers []AIProviderOption `json:"providers"`
}
AIProviderPath {
Provider string `path:"provider" validate:"required,oneof=opencode-go xai"` // 要查模型的 provider
}
AIProviderModelsData {
ID string `json:"id"`
Label string `json:"label"`
Models []string `json:"models"`
Streams bool `json:"streams"`
Error string `json:"error,optional"` // 拉 models 失敗時的摘要
}
AIChatData {
Text string `json:"text"`
FinishReason string `json:"finish_reason,optional"`
}
IslanderChatReq {
Messages []AIMessage `json:"messages" validate:"required,min=1,dive"` // 對話訊息
Context string `json:"context,optional"` // 目前頁面與站內導覽快照
}
)
@server (
group: ai
prefix: /api/v1/ai
tags: "AI - Provider Streaming"
summary: "Public AI provider catalog"
)
service gateway {
@handler listAiProviders
get /providers returns (AIProvidersData)
}
@server (
group: ai
prefix: /api/v1/ai
middleware: MemberAuth
tags: "AI - Provider Streaming (member)"
summary: "Chat/stream/models; member JWT via X-Member-Authorization; provider API key via Authorization"
)
service gateway {
@handler listAiProviderModels
post /providers/:provider/models (AIProviderPath) returns (AIProviderModelsData)
@handler chat
post /chat (AIChatReq) returns (AIChatData)
@handler chatStream
post /chat/stream (AIChatReq)
}
@server (
group: ai
prefix: /api/v1/ai
middleware: AuthJWT
tags: "AI - Islander Guide"
summary: "Floating islander chat; member JWT via Authorization; AI key from member settings"
)
service gateway {
@handler islanderChatStream
post /islander/chat/stream (IslanderChatReq)
}

62
generate/api/auth.api Normal file
View File

@ -0,0 +1,62 @@
syntax = "v1"
type (
AuthRegisterReq {
TenantID string `json:"tenant_id" validate:"required"`
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=8,max=128"`
DisplayName string `json:"display_name,optional"`
Language string `json:"language,optional"`
}
AuthLoginReq {
TenantID string `json:"tenant_id" validate:"required"`
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=8,max=128"`
}
AuthRefreshReq {
RefreshToken string `json:"refresh_token" validate:"required"`
}
AuthTokenData {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"`
UID string `json:"uid"`
TokenType string `json:"token_type"`
}
LogoutData {
OK bool `json:"ok"`
}
)
@server(
group: auth
prefix: /api/v1/auth
tags: "Auth"
summary: "Native member auth and JWT token endpoints"
)
service gateway {
@handler register
post /register (AuthRegisterReq) returns (AuthTokenData)
@handler login
post /login (AuthLoginReq) returns (AuthTokenData)
@handler refresh
post /refresh (AuthRefreshReq) returns (AuthTokenData)
}
@server(
group: auth
prefix: /api/v1/auth
middleware: AuthJWT
tags: "Auth"
summary: "Logout requires member Bearer JWT"
)
service gateway {
@handler logout
post /logout returns (LogoutData)
}

445
generate/api/brand.api Normal file
View File

@ -0,0 +1,445 @@
syntax = "v1"
type (
ResearchItemData {
Title string `json:"title,omitempty"`
URL string `json:"url,omitempty"`
Snippet string `json:"snippet,omitempty"`
Query string `json:"query,omitempty"`
}
ResearchMapData {
AudienceSummary string `json:"audience_summary,omitempty"`
ContentGoal string `json:"content_goal,omitempty"`
Questions []string `json:"questions,omitempty"`
Pillars []string `json:"pillars,omitempty"`
Exclusions []string `json:"exclusions,omitempty"`
ResearchItems []ResearchItemData `json:"research_items,omitempty"`
ExpandStrategy string `json:"expand_strategy,omitempty"`
PatrolKeywords []string `json:"patrol_keywords,omitempty"`
}
KnowledgeGraphEvidenceData {
URL string `json:"url,omitempty"`
Snippet string `json:"snippet,omitempty"`
Query string `json:"query,omitempty"`
}
BraveSourceData {
Query string `json:"query,omitempty"`
Title string `json:"title,omitempty"`
URL string `json:"url,omitempty"`
Snippet string `json:"snippet,omitempty"`
}
BrandProductData {
ID string `json:"id"`
Label string `json:"label"`
ProductContext string `json:"product_context"`
MatchTags []string `json:"match_tags,omitempty"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
}
BrandData {
ID string `json:"id"`
DisplayName string `json:"display_name,omitempty"`
TopicName string `json:"topic_name,omitempty"`
SeedQuery string `json:"seed_query,omitempty"`
Brief string `json:"brief,omitempty"`
ProductBrief string `json:"product_brief,omitempty"`
ProductContext string `json:"product_context,omitempty"`
ProductID string `json:"product_id,omitempty"`
Products []BrandProductData `json:"products,omitempty"`
TargetAudience string `json:"target_audience,omitempty"`
Goals string `json:"goals,omitempty"`
ResearchMap ResearchMapData `json:"research_map,omitempty"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
}
ListBrandProductsData {
List []BrandProductData `json:"list"`
}
CreateBrandProductReq {
Label string `json:"label" validate:"required"`
ProductContext string `json:"product_context" validate:"required"`
MatchTags []string `json:"match_tags,optional"`
}
UpdateBrandProductReq {
Label *string `json:"label,optional"`
ProductContext *string `json:"product_context,optional"`
MatchTags []string `json:"match_tags,optional"`
}
BrandProductPath {
ID string `path:"id" validate:"required"`
ProductID string `path:"productId" validate:"required"`
}
CreateBrandProductHandlerReq {
BrandPath
CreateBrandProductReq
}
UpdateBrandProductHandlerReq {
BrandProductPath
UpdateBrandProductReq
}
ListBrandsData {
List []BrandData `json:"list"`
}
CreateBrandReq {
DisplayName string `json:"display_name,optional"`
}
BrandPath {
ID string `path:"id" validate:"required"`
}
UpdateBrandReq {
DisplayName *string `json:"display_name,optional"`
TopicName *string `json:"topic_name,optional"`
SeedQuery *string `json:"seed_query,optional"`
Brief *string `json:"brief,optional"`
ProductBrief *string `json:"product_brief,optional"`
ProductContext *string `json:"product_context,optional"`
ProductID *string `json:"product_id,optional"`
TargetAudience *string `json:"target_audience,optional"`
Goals *string `json:"goals,optional"`
AudienceSummary *string `json:"audience_summary,optional"`
ContentGoal *string `json:"content_goal,optional"`
Questions []string `json:"questions,optional"`
Pillars []string `json:"pillars,optional"`
Exclusions []string `json:"exclusions,optional"`
PatrolKeywords []string `json:"patrol_keywords,optional"`
}
KnowledgeGraphNodeData {
ID string `json:"id"`
Label string `json:"label"`
NodeKind string `json:"node_kind"`
Type string `json:"type"`
Layer int `json:"layer"`
Relation string `json:"relation,omitempty"`
PlacementValue string `json:"placement_value,omitempty"`
ProductFitScore int `json:"product_fit_score"`
SelectedForScan bool `json:"selected_for_scan"`
RelevanceTags []string `json:"relevance_tags"`
RecencyTags []string `json:"recency_tags"`
Evidence []KnowledgeGraphEvidenceData `json:"evidence,omitempty"`
}
KnowledgeGraphEdgeData {
From string `json:"from"`
To string `json:"to"`
Relation string `json:"relation"`
}
KnowledgeGraphData {
ID string `json:"id"`
BrandID string `json:"brand_id"`
Seed string `json:"seed"`
Nodes []KnowledgeGraphNodeData `json:"nodes"`
Edges []KnowledgeGraphEdgeData `json:"edges"`
BraveSources []BraveSourceData `json:"brave_sources,omitempty"`
ExpandStrategy string `json:"expand_strategy,omitempty"`
PainTagCount int `json:"pain_tag_count"`
GeneratedAt int64 `json:"generated_at"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
}
ExpandKnowledgeGraphReq {
SeedQuery string `json:"seed_query" validate:"required"`
Supplemental bool `json:"supplemental,optional"`
RegenerateMap bool `json:"regenerate_map,optional"`
ExpandStrategy string `json:"expand_strategy,optional"` // brave | llm | hybrid
}
ExpandKnowledgeGraphData {
JobID string `json:"job_id"`
Status string `json:"status"`
Message string `json:"message"`
}
KnowledgeGraphNodeUpdate {
NodeID string `json:"node_id" validate:"required"`
SelectedForScan *bool `json:"selected_for_scan,optional"`
RelevanceTags []string `json:"relevance_tags,optional"`
RecencyTags []string `json:"recency_tags,optional"`
}
PatchKnowledgeGraphNodesReq {
Updates []KnowledgeGraphNodeUpdate `json:"updates" validate:"required"`
}
StartBrandScanJobReq {
GraphID string `json:"graph_id,optional"`
NodeIDs []string `json:"node_ids,optional"`
DualTrack bool `json:"dual_track,optional"`
PatrolMode bool `json:"patrol_mode,optional"`
PatrolKeywords []string `json:"patrol_keywords,optional"`
}
StartBrandScanJobData {
JobID string `json:"job_id"`
Status string `json:"status"`
Message string `json:"message"`
}
ListBrandScanPostsReq {
Priority string `form:"priority,optional"`
Recent7d bool `form:"recent_7d,optional"`
ProductFitMin int `form:"product_fit_min,optional"`
Limit int `form:"limit,optional"`
}
ScanPostData {
ID string `json:"id"`
GraphNodeID string `json:"graph_node_id"`
SearchTag string `json:"search_tag"`
QueryDimension string `json:"query_dimension"`
ExternalID string `json:"external_id"`
Permalink string `json:"permalink"`
Author string `json:"author"`
Text string `json:"text"`
Priority string `json:"priority"`
PlacementScore int `json:"placement_score"`
ProductFitScore int `json:"product_fit_score"`
SolvedByProduct bool `json:"solved_by_product"`
Source string `json:"source"`
ScanJobID string `json:"scan_job_id"`
OutreachStatus string `json:"outreach_status,omitempty"`
PublishedReplyID string `json:"published_reply_id,omitempty"`
PublishedPermalink string `json:"published_permalink,omitempty"`
OutreachUpdateAt int64 `json:"outreach_update_at,omitempty"`
PostedAt string `json:"posted_at,omitempty"`
Replies []ScanReplyData `json:"replies,omitempty"`
LatestDraft *GenerateOutreachDraftsData `json:"latest_draft,omitempty"`
CreateAt int64 `json:"create_at"`
}
ListBrandScanPostsData {
List []ScanPostData `json:"list"`
Total int `json:"total"`
}
GenerateOutreachDraftsReq {
ScanPostID string `json:"scan_post_id" validate:"required"`
TopicID string `json:"topic_id,optional"`
Count int `json:"count,optional"`
VoicePersonaID string `json:"voice_persona_id,optional"`
ProductID string `json:"product_id,optional"`
}
OutreachDraftItemData {
Text string `json:"text"`
Angle string `json:"angle"`
Rationale string `json:"rationale"`
}
GenerateOutreachDraftsData {
ID string `json:"id"`
ScanPostID string `json:"scan_post_id"`
Relevance float64 `json:"relevance"`
Reason string `json:"reason"`
Drafts []OutreachDraftItemData `json:"drafts"`
CreateAt int64 `json:"create_at"`
}
PublishOutreachDraftReq {
ScanPostID string `json:"scan_post_id" validate:"required"`
Text string `json:"text" validate:"required"`
Confirm bool `json:"confirm"`
}
PublishOutreachDraftData {
ScanPostID string `json:"scan_post_id"`
ReplyID string `json:"reply_id"`
Permalink string `json:"permalink"`
OutreachStatus string `json:"outreach_status"`
PublishedPermalink string `json:"published_permalink"`
Message string `json:"message"`
}
PatchScanPostOutreachReq {
OutreachStatus *string `json:"outreach_status,optional"`
}
ContentMatrixRowData {
SortOrder int `json:"sort_order"`
SearchTag string `json:"search_tag"`
Angle string `json:"angle"`
Hook string `json:"hook"`
Text string `json:"text"`
ReferenceNotes string `json:"reference_notes"`
SourcePermalinks []string `json:"source_permalinks"`
Rationale string `json:"rationale"`
}
ContentMatrixData {
ID string `json:"id,omitempty"`
BrandID string `json:"brand_id"`
Rows []ContentMatrixRowData `json:"rows"`
GeneratedAt int64 `json:"generated_at"`
CreateAt int64 `json:"create_at,omitempty"`
UpdateAt int64 `json:"update_at,omitempty"`
}
GenerateContentMatrixReq {
Count int `json:"count,optional"`
}
ScanReplyData {
ExternalID string `json:"external_id,omitempty"`
Author string `json:"author,omitempty"`
Text string `json:"text"`
Permalink string `json:"permalink,omitempty"`
LikeCount int `json:"like_count,omitempty"`
PostedAt string `json:"posted_at,omitempty"`
}
BrandScanScheduleData {
ID string `json:"id,omitempty"`
BrandID string `json:"brand_id"`
Cron string `json:"cron"`
Timezone string `json:"timezone"`
Enabled bool `json:"enabled"`
NextRunAt int64 `json:"next_run_at,omitempty"`
LastRunAt int64 `json:"last_run_at,omitempty"`
}
UpsertBrandScanScheduleReq {
Cron string `json:"cron,optional"`
Timezone string `json:"timezone,optional"`
Enabled bool `json:"enabled"`
}
UpdateBrandHandlerReq {
BrandPath
UpdateBrandReq
}
ExpandKnowledgeGraphHandlerReq {
BrandPath
ExpandKnowledgeGraphReq
}
PatchKnowledgeGraphNodesHandlerReq {
BrandPath
PatchKnowledgeGraphNodesReq
}
StartBrandScanJobHandlerReq {
BrandPath
StartBrandScanJobReq
}
ListBrandScanPostsHandlerReq {
BrandPath
ListBrandScanPostsReq
}
GenerateOutreachDraftsHandlerReq {
BrandPath
GenerateOutreachDraftsReq
}
PublishOutreachDraftHandlerReq {
BrandPath
PublishOutreachDraftReq
}
PatchScanPostOutreachHandlerReq {
BrandPath
PostID string `path:"postId"`
PatchScanPostOutreachReq
}
GenerateContentMatrixHandlerReq {
BrandPath
GenerateContentMatrixReq
}
UpsertBrandScanScheduleHandlerReq {
BrandPath
UpsertBrandScanScheduleReq
}
)
@server(
group: brand
prefix: /api/v1/brands
middleware: AuthJWT
tags: "Brand"
summary: "Brand profiles for placement workflow. Requires Bearer JWT."
)
service gateway {
@handler listBrands
get / returns (ListBrandsData)
@handler createBrand
post / (CreateBrandReq) returns (BrandData)
@handler getBrand
get /:id (BrandPath) returns (BrandData)
@handler updateBrand
patch /:id (UpdateBrandHandlerReq) returns (BrandData)
@handler deleteBrand
delete /:id (BrandPath)
@handler listBrandProducts
get /:id/products (BrandPath) returns (ListBrandProductsData)
@handler createBrandProduct
post /:id/products (CreateBrandProductHandlerReq) returns (BrandProductData)
@handler updateBrandProduct
patch /:id/products/:productId (UpdateBrandProductHandlerReq) returns (BrandProductData)
@handler deleteBrandProduct
delete /:id/products/:productId (BrandProductPath)
@handler expandKnowledgeGraph
post /:id/knowledge-graph/expand (ExpandKnowledgeGraphHandlerReq) returns (ExpandKnowledgeGraphData)
@handler getKnowledgeGraph
get /:id/knowledge-graph (BrandPath) returns (KnowledgeGraphData)
@handler patchKnowledgeGraphNodes
patch /:id/knowledge-graph/nodes (PatchKnowledgeGraphNodesHandlerReq) returns (KnowledgeGraphData)
@handler startBrandScanJob
post /:id/scan-jobs (StartBrandScanJobHandlerReq) returns (StartBrandScanJobData)
@handler listBrandScanPosts
get /:id/scan-posts (ListBrandScanPostsHandlerReq) returns (ListBrandScanPostsData)
@handler generateOutreachDrafts
post /:id/outreach-drafts/generate (GenerateOutreachDraftsHandlerReq) returns (GenerateOutreachDraftsData)
@handler publishOutreachDraft
post /:id/outreach-drafts/publish (PublishOutreachDraftHandlerReq) returns (PublishOutreachDraftData)
@handler patchScanPostOutreach
patch /:id/scan-posts/:postId (PatchScanPostOutreachHandlerReq) returns (ScanPostData)
@handler getBrandContentMatrix
get /:id/content-matrix (BrandPath) returns (ContentMatrixData)
@handler generateBrandContentMatrix
post /:id/content-matrix/generate (GenerateContentMatrixHandlerReq) returns (ContentMatrixData)
@handler getBrandScanSchedule
get /:id/scan-schedule (BrandPath) returns (BrandScanScheduleData)
@handler upsertBrandScanSchedule
put /:id/scan-schedule (UpsertBrandScanScheduleHandlerReq) returns (BrandScanScheduleData)
}

15
generate/api/common.api Normal file
View File

@ -0,0 +1,15 @@
syntax = "v1"
type ErrorDetail {
BizCode string `json:"biz_code,optional"`
Scope int64 `json:"scope,optional"`
Category int64 `json:"category,optional"`
Detail int64 `json:"detail,optional"`
}
type Status {
Code int64 `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,optional"`
Error ErrorDetail `json:"error,optional"`
}

View File

@ -0,0 +1,256 @@
syntax = "v1"
type (
CopySuggestedTagData {
Tag string `json:"tag"`
Reason string `json:"reason,omitempty"`
SearchIntent string `json:"search_intent,omitempty"`
SearchType string `json:"search_type,omitempty"`
}
CopySimilarAccountData {
Username string `json:"username"`
Reason string `json:"reason,omitempty"`
Source string `json:"source,omitempty"`
Confidence string `json:"confidence,omitempty"`
ProfileUrl string `json:"profile_url,omitempty"`
AuthorVerified bool `json:"author_verified,omitempty"`
FollowerCount int `json:"follower_count,omitempty"`
EngagementScore int `json:"engagement_score,omitempty"`
LikeCount int `json:"like_count,omitempty"`
ReplyCount int `json:"reply_count,omitempty"`
PostCount int `json:"post_count,omitempty"`
}
CopyMissionResearchMapData {
AudienceSummary string `json:"audience_summary,omitempty"`
ContentGoal string `json:"content_goal,omitempty"`
Questions []string `json:"questions,omitempty"`
Pillars []string `json:"pillars,omitempty"`
Exclusions []string `json:"exclusions,omitempty"`
SuggestedTags []CopySuggestedTagData `json:"suggested_tags,omitempty"`
SimilarAccounts []CopySimilarAccountData `json:"similar_accounts,omitempty"`
BenchmarkNotes string `json:"benchmark_notes,omitempty"`
}
CopyMissionData {
ID string `json:"id"`
PersonaID string `json:"persona_id"`
Label string `json:"label,omitempty"`
SeedQuery string `json:"seed_query,omitempty"`
Brief string `json:"brief,omitempty"`
ResearchMap CopyMissionResearchMapData `json:"research_map,omitempty"`
SelectedTags []string `json:"selected_tags,omitempty"`
LastScanJobID string `json:"last_scan_job_id,omitempty"`
Status string `json:"status,omitempty"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
}
ListCopyMissionsData {
List []CopyMissionData `json:"list"`
}
CreateCopyMissionReq {
Label string `json:"label" validate:"required"`
SeedQuery string `json:"seed_query" validate:"required"`
Brief string `json:"brief" validate:"required"`
}
UpdateCopyMissionReq {
Label *string `json:"label,optional"`
SeedQuery *string `json:"seed_query,optional"`
Brief *string `json:"brief,optional"`
AudienceSummary *string `json:"audience_summary,optional"`
ContentGoal *string `json:"content_goal,optional"`
Questions []string `json:"questions,optional"`
Pillars []string `json:"pillars,optional"`
Exclusions []string `json:"exclusions,optional"`
BenchmarkNotes *string `json:"benchmark_notes,optional"`
SelectedTags []string `json:"selected_tags,optional"`
Status *string `json:"status,optional"`
}
CopyMissionScanScheduleData {
ID string `json:"id,omitempty"`
PersonaID string `json:"persona_id"`
MissionID string `json:"mission_id"`
Cron string `json:"cron"`
Timezone string `json:"timezone"`
Enabled bool `json:"enabled"`
NextRunAt int64 `json:"next_run_at,omitempty"`
LastRunAt int64 `json:"last_run_at,omitempty"`
}
UpsertCopyMissionScanScheduleReq {
Cron string `json:"cron,optional"`
Timezone string `json:"timezone,optional"`
Enabled bool `json:"enabled"`
}
UpsertCopyMissionScanScheduleHandlerReq {
CopyMissionPath
UpsertCopyMissionScanScheduleReq
}
CopyMissionPath {
PersonaID string `path:"personaId" validate:"required"`
ID string `path:"id" validate:"required"`
}
PersonaCopyMissionsPath {
PersonaID string `path:"personaId" validate:"required"`
}
CreateCopyMissionHandlerReq {
PersonaCopyMissionsPath
CreateCopyMissionReq
}
UpdateCopyMissionHandlerReq {
CopyMissionPath
UpdateCopyMissionReq
}
StartCopyMissionAnalyzeJobData {
JobID string `json:"job_id"`
Status string `json:"status"`
Message string `json:"message"`
}
StartCopyMissionScanJobData {
JobID string `json:"job_id"`
Status string `json:"status"`
Message string `json:"message"`
}
StartCopyMissionMatrixJobReq {
Count int `json:"count,optional"`
}
StartCopyMissionMatrixJobHandlerReq {
CopyMissionPath
StartCopyMissionMatrixJobReq
}
StartCopyMissionMatrixJobData {
JobID string `json:"job_id"`
Status string `json:"status"`
Message string `json:"message"`
}
StartCopyMissionCopyDraftJobReq {
ScanPostID string `json:"scan_post_id" validate:"required"`
}
StartCopyMissionCopyDraftJobHandlerReq {
CopyMissionPath
StartCopyMissionCopyDraftJobReq
}
StartCopyMissionCopyDraftJobData {
JobID string `json:"job_id"`
Status string `json:"status"`
Message string `json:"message"`
}
ListCopyMissionScanPostsReq {
Limit int `form:"limit,optional"`
}
ListCopyMissionScanPostsHandlerReq {
CopyMissionPath
ListCopyMissionScanPostsReq
}
GenerateCopyMissionMatrixReq {
Count int `json:"count,optional"`
}
GenerateCopyMissionMatrixHandlerReq {
CopyMissionPath
GenerateCopyMissionMatrixReq
}
GenerateCopyMissionMatrixData {
Drafts []CopyDraftData `json:"drafts"`
Message string `json:"message"`
}
ListCopyMissionCopyDraftsData {
List []CopyDraftData `json:"list"`
Total int `json:"total"`
}
CopyMissionInspirationSourceData {
Query string `json:"query,omitempty"`
Title string `json:"title,omitempty"`
Snippet string `json:"snippet,omitempty"`
URL string `json:"url,omitempty"`
}
CopyMissionInspirationData {
Label string `json:"label"`
SeedQuery string `json:"seed_query"`
Brief string `json:"brief"`
TrendReason string `json:"trend_reason,omitempty"`
TrendKeywords []string `json:"trend_keywords,omitempty"`
Sources []CopyMissionInspirationSourceData `json:"sources,omitempty"`
WebSearchUsed bool `json:"web_search_used"`
Message string `json:"message"`
}
)
@server(
group: copy_mission
prefix: /api/v1/personas
middleware: AuthJWT
tags: "CopyMission"
summary: "Copy ninja missions (Flow A). Requires Bearer JWT."
)
service gateway {
@handler listCopyMissions
get /:personaId/copy-missions (PersonaCopyMissionsPath) returns (ListCopyMissionsData)
@handler inspireCopyMission
post /:personaId/copy-mission-inspiration (PersonaCopyMissionsPath) returns (CopyMissionInspirationData)
@handler createCopyMission
post /:personaId/copy-missions (CreateCopyMissionHandlerReq) returns (CopyMissionData)
@handler getCopyMission
get /:personaId/copy-missions/:id (CopyMissionPath) returns (CopyMissionData)
@handler updateCopyMission
patch /:personaId/copy-missions/:id (UpdateCopyMissionHandlerReq) returns (CopyMissionData)
@handler deleteCopyMission
delete /:personaId/copy-missions/:id (CopyMissionPath)
@handler startCopyMissionAnalyzeJob
post /:personaId/copy-missions/:id/analyze-jobs (CopyMissionPath) returns (StartCopyMissionAnalyzeJobData)
@handler startCopyMissionScanJob
post /:personaId/copy-missions/:id/scan-jobs (CopyMissionPath) returns (StartCopyMissionScanJobData)
@handler listCopyMissionScanPosts
get /:personaId/copy-missions/:id/scan-posts (ListCopyMissionScanPostsHandlerReq) returns (ListPersonaViralScanPostsData)
@handler generateCopyMissionMatrix
post /:personaId/copy-missions/:id/matrix-drafts (GenerateCopyMissionMatrixHandlerReq) returns (GenerateCopyMissionMatrixData)
@handler startCopyMissionMatrixJob
post /:personaId/copy-missions/:id/matrix-jobs (StartCopyMissionMatrixJobHandlerReq) returns (StartCopyMissionMatrixJobData)
@handler startCopyMissionCopyDraftJob
post /:personaId/copy-missions/:id/copy-draft-jobs (StartCopyMissionCopyDraftJobHandlerReq) returns (StartCopyMissionCopyDraftJobData)
@handler listCopyMissionCopyDrafts
get /:personaId/copy-missions/:id/copy-drafts (CopyMissionPath) returns (ListCopyMissionCopyDraftsData)
@handler getCopyMissionScanSchedule
get /:personaId/copy-missions/:id/scan-schedule (CopyMissionPath) returns (CopyMissionScanScheduleData)
@handler upsertCopyMissionScanSchedule
put /:personaId/copy-missions/:id/scan-schedule (UpsertCopyMissionScanScheduleHandlerReq) returns (CopyMissionScanScheduleData)
}

30
generate/api/gateway.api Normal file
View File

@ -0,0 +1,30 @@
syntax = "v1"
info (
title: "Haixun Backend"
desc: "Haixun service-oriented backend core"
author: "haixun"
version: "0.1.0"
host: "127.0.0.1:8890"
schemes: "http,https"
consumes: "application/json"
produces: "application/json"
)
import (
"common.api"
"normal.api"
"setting.api"
"ai.api"
"job.api"
"auth.api"
"member.api"
"permission.api"
"threads_account.api"
"persona.api"
"copy_mission.api"
"brand.api"
"placement_topic.api"
"worker_internal.api"
)

248
generate/api/job.api Normal file
View File

@ -0,0 +1,248 @@
syntax = "v1"
type (
JobTemplatePath {
Type string `path:"type" validate:"required"` // template type
}
JobIDPath {
ID string `path:"id" validate:"required"` // job run id
}
JobScheduleIDPath {
ID string `path:"id" validate:"required"` // schedule id
}
ListJobsReq {
Scope string `form:"scope,optional"` // filter by scope
ScopeID string `form:"scope_id,optional"` // filter by scope id
Page int64 `form:"page,optional"` // page number starting at 1
PageSize int64 `form:"pageSize,optional"` // page size
}
ListJobSchedulesReq {
Scope string `form:"scope,optional"` // filter by scope
ScopeID string `form:"scope_id,optional"` // filter by scope id
Page int64 `form:"page,optional"` // page number starting at 1
PageSize int64 `form:"pageSize,optional"` // page size
}
ListJobEventsReq {
ID string `path:"id" validate:"required"` // job run id
Limit int64 `form:"limit,optional"` // max events
}
CancelJobReq {
ID string `path:"id" validate:"required"` // job run id
Reason string `json:"reason,optional"` // cancel reason
}
CreateJobReq {
TemplateType string `json:"template_type" validate:"required"` // job template type
Scope string `json:"scope" validate:"required,oneof=user account system persona brand"` // job scope
ScopeID string `json:"scope_id" validate:"required"` // scope id
Payload map[string]interface{} `json:"payload,optional"` // job payload
}
UpsertJobTemplateReq {
Type string `path:"type" validate:"required"` // template type
Version int `json:"version,optional"` // template version
Name string `json:"name" validate:"required"` // display name
Description string `json:"description,optional"` // description
Enabled bool `json:"enabled"` // enabled flag
Repeatable bool `json:"repeatable"` // repeatable flag
ConcurrencyPolicy string `json:"concurrency_policy,optional"` // concurrency policy
DedupeKeys []string `json:"dedupe_keys,optional"` // dedupe keys
TimeoutSeconds int `json:"timeout_seconds,optional"` // timeout seconds
CancelPolicy JobCancelPolicyData `json:"cancel_policy,optional"` // cancel policy
RetryPolicy JobRetryPolicyData `json:"retry_policy,optional"` // retry policy
Steps []JobTemplateStepData `json:"steps" validate:"required,min=1,dive"` // steps
}
CreateJobScheduleReq {
TemplateType string `json:"template_type" validate:"required"` // template type
Scope string `json:"scope" validate:"required,oneof=user account system persona brand"` // scope
ScopeID string `json:"scope_id" validate:"required"` // scope id
Cron string `json:"cron" validate:"required"` // cron expression
Timezone string `json:"timezone,optional"` // timezone
PayloadTemplate map[string]interface{} `json:"payload_template,optional"` // payload template
Enabled bool `json:"enabled"` // enabled flag
}
UpdateJobScheduleReq {
ID string `path:"id" validate:"required"` // schedule id
Cron string `json:"cron,optional"` // cron expression
Timezone string `json:"timezone,optional"` // timezone
PayloadTemplate map[string]interface{} `json:"payload_template,optional"` // payload template
Enabled *bool `json:"enabled,optional"` // enabled flag
}
JobCancelPolicyData {
Supported bool `json:"supported"`
Mode string `json:"mode"`
GraceSeconds int `json:"grace_seconds"`
}
JobRetryPolicyData {
MaxAttempts int `json:"max_attempts"`
BackoffSeconds []int `json:"backoff_seconds"`
}
JobTemplateStepData {
ID string `json:"id"`
Name string `json:"name"`
WorkerType string `json:"worker_type"`
TimeoutSeconds int `json:"timeout_seconds"`
Cancelable bool `json:"cancelable"`
}
JobTemplateData {
Type string `json:"type"`
Version int `json:"version"`
Name string `json:"name"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
Repeatable bool `json:"repeatable"`
ConcurrencyPolicy string `json:"concurrency_policy"`
DedupeKeys []string `json:"dedupe_keys"`
TimeoutSeconds int `json:"timeout_seconds"`
CancelPolicy JobCancelPolicyData `json:"cancel_policy"`
RetryPolicy JobRetryPolicyData `json:"retry_policy"`
Steps []JobTemplateStepData `json:"steps"`
}
JobTemplateListData {
List []JobTemplateData `json:"list"`
}
JobStepProgressData {
ID string `json:"id"`
Status string `json:"status"`
StartedAt *int64 `json:"started_at,optional"`
EndedAt *int64 `json:"ended_at,optional"`
Message string `json:"message,optional"`
}
JobProgressData {
Summary string `json:"summary"`
Percentage int `json:"percentage"`
Steps []JobStepProgressData `json:"steps"`
}
JobData {
ID string `json:"id"`
TemplateType string `json:"template_type"`
TemplateVersion int `json:"template_version"`
Scope string `json:"scope"`
ScopeID string `json:"scope_id"`
Status string `json:"status"`
Phase string `json:"phase"`
WorkerType string `json:"worker_type"`
Payload map[string]interface{} `json:"payload"`
Progress JobProgressData `json:"progress"`
Result map[string]interface{} `json:"result,optional"`
Error string `json:"error,optional"`
Attempt int `json:"attempt"`
MaxAttempts int `json:"max_attempts"`
CancelRequestedAt *int64 `json:"cancel_requested_at,optional"`
CancelReason string `json:"cancel_reason,optional"`
StartedAt *int64 `json:"started_at,optional"`
CompletedAt *int64 `json:"completed_at,optional"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
}
JobListData {
Pagination PaginationData `json:"pagination"`
List []JobData `json:"list"`
}
JobScheduleData {
ID string `json:"id"`
TemplateType string `json:"template_type"`
Scope string `json:"scope"`
ScopeID string `json:"scope_id"`
Enabled bool `json:"enabled"`
Cron string `json:"cron"`
Timezone string `json:"timezone"`
PayloadTemplate map[string]interface{} `json:"payload_template"`
LastRunAt *int64 `json:"last_run_at,optional"`
NextRunAt int64 `json:"next_run_at"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
}
JobScheduleListData {
Pagination PaginationData `json:"pagination"`
List []JobScheduleData `json:"list"`
}
JobEventData {
ID string `json:"id"`
JobID string `json:"job_id"`
Type string `json:"type"`
From string `json:"from,optional"`
To string `json:"to,optional"`
Message string `json:"message"`
Metadata map[string]interface{} `json:"metadata,optional"`
CreateAt int64 `json:"create_at"`
}
JobEventListData {
List []JobEventData `json:"list"`
}
)
@server(
group: job
prefix: /api/v1
middleware: AuthJWT
tags: "Job - Core"
summary: "Generic job templates, runs, schedules, cancel, and retry. Requires Bearer JWT."
)
service gateway {
@handler listJobTemplates
get /job/templates returns (JobTemplateListData)
@handler getJobTemplate
get /job/templates/:type (JobTemplatePath) returns (JobTemplateData)
@handler upsertJobTemplate
put /job/templates/:type (UpsertJobTemplateReq) returns (JobTemplateData)
@handler createJob
post /jobs (CreateJobReq) returns (JobData)
@handler getJob
get /jobs/:id (JobIDPath) returns (JobData)
@handler listJobs
get /jobs (ListJobsReq) returns (JobListData)
@handler listJobEvents
get /jobs/:id/events (ListJobEventsReq) returns (JobEventListData)
@handler cancelJob
post /jobs/:id/cancel (CancelJobReq) returns (JobData)
@handler retryJob
post /jobs/:id/retry (JobIDPath) returns (JobData)
@handler listJobSchedules
get /job/schedules (ListJobSchedulesReq) returns (JobScheduleListData)
@handler createJobSchedule
post /job/schedules (CreateJobScheduleReq) returns (JobScheduleData)
@handler updateJobSchedule
put /job/schedules/:id (UpdateJobScheduleReq) returns (JobScheduleData)
@handler enableJobSchedule
post /job/schedules/:id/enable (JobScheduleIDPath) returns (JobScheduleData)
@handler disableJobSchedule
post /job/schedules/:id/disable (JobScheduleIDPath) returns (JobScheduleData)
@handler deleteJobSchedule
delete /job/schedules/:id (JobScheduleIDPath)
}

74
generate/api/member.api Normal file
View File

@ -0,0 +1,74 @@
syntax = "v1"
type (
MemberMeData {
TenantID string `json:"tenant_id"`
UID string `json:"uid"`
Email string `json:"email"`
DisplayName string `json:"display_name,omitempty"`
Avatar string `json:"avatar,omitempty"`
Phone string `json:"phone,omitempty"`
Language string `json:"language,omitempty"`
Currency string `json:"currency,omitempty"`
Status string `json:"status"`
Origin string `json:"origin"`
Roles []string `json:"roles,omitempty"`
BusinessEmail string `json:"business_email,omitempty"`
BusinessEmailVerified bool `json:"business_email_verified"`
BusinessPhone string `json:"business_phone,omitempty"`
BusinessPhoneVerified bool `json:"business_phone_verified"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
}
UpdateMemberMeReq {
DisplayName string `json:"display_name,optional"`
Avatar string `json:"avatar,optional"`
Language string `json:"language,optional"`
Currency string `json:"currency,optional"`
Phone string `json:"phone,optional"`
}
MemberPlacementSettingsData {
WebSearchProvider string `json:"web_search_provider"` // brave | exa
BraveAPIKey string `json:"brave_api_key,omitempty"`
BraveAPIKeyConfigured bool `json:"brave_api_key_configured"`
ExaAPIKey string `json:"exa_api_key,omitempty"`
ExaAPIKeyConfigured bool `json:"exa_api_key_configured"`
BraveCountry string `json:"brave_country"`
BraveSearchLang string `json:"brave_search_lang"`
ExaUserLocation string `json:"exa_user_location"`
ExpandStrategy string `json:"expand_strategy"` // brave | llm | hybrid
}
UpdateMemberPlacementSettingsReq {
WebSearchProvider *string `json:"web_search_provider,optional"`
BraveAPIKey *string `json:"brave_api_key,optional"`
ExaAPIKey *string `json:"exa_api_key,optional"`
BraveCountry *string `json:"brave_country,optional"`
BraveSearchLang *string `json:"brave_search_lang,optional"`
ExaUserLocation *string `json:"exa_user_location,optional"`
ExpandStrategy *string `json:"expand_strategy,optional"`
}
)
@server(
group: member
prefix: /api/v1/members
middleware: AuthJWT
tags: "Member"
summary: "Current member profile endpoints. Requires Bearer JWT or dev headers."
)
service gateway {
@handler getMemberMe
get /me returns (MemberMeData)
@handler updateMemberMe
patch /me (UpdateMemberMeReq) returns (MemberMeData)
@handler getMemberPlacementSettings
get /me/placement-settings returns (MemberPlacementSettingsData)
@handler updateMemberPlacementSettings
patch /me/placement-settings (UpdateMemberPlacementSettingsReq) returns (MemberPlacementSettingsData)
}

16
generate/api/normal.api Normal file
View File

@ -0,0 +1,16 @@
syntax = "v1"
type HealthData {
Pong string `json:"pong"`
}
@server(
group: normal
prefix: /api/v1
tags: "Normal - Public"
summary: "Health check"
)
service gateway {
@handler health
get /health () returns (HealthData)
}

View File

@ -0,0 +1,52 @@
syntax = "v1"
type (
PermissionCatalogQuery {
Status string `form:"status,optional" validate:"omitempty,oneof=open close"`
Type string `form:"type,optional" validate:"omitempty,oneof=backend_user frontend_user"`
Tree bool `form:"tree,optional"`
}
PermissionNode {
ID string `json:"id"`
Parent string `json:"parent,omitempty"`
Name string `json:"name"`
HTTPMethods string `json:"http_methods,omitempty"`
HTTPPath string `json:"http_path,omitempty"`
Status string `json:"status"`
Type string `json:"type"`
Children []PermissionNode `json:"children,omitempty"`
}
PermissionCatalogData {
Tree []PermissionNode `json:"tree,omitempty"`
List []PermissionNode `json:"list,omitempty"`
}
MePermissionsQuery {
IncludeTree bool `form:"include_tree,optional"`
}
MePermissionsData {
UID string `json:"uid"`
TenantID string `json:"tenant_id"`
Roles []string `json:"roles"`
Permissions map[string]string `json:"permissions"`
Tree []PermissionNode `json:"tree,omitempty"`
}
)
@server(
group: permission
prefix: /api/v1/permissions
middleware: AuthJWT
tags: "Permission"
summary: "Permission catalog and current member permissions. Requires Bearer JWT."
)
service gateway {
@handler getPermissionCatalog
get /catalog (PermissionCatalogQuery) returns (PermissionCatalogData)
@handler getMePermissions
get /me (MePermissionsQuery) returns (MePermissionsData)
}

231
generate/api/persona.api Normal file
View File

@ -0,0 +1,231 @@
syntax = "v1"
type (
CopyResearchMapData {
AudienceSummary string `json:"audience_summary,omitempty"`
ContentGoal string `json:"content_goal,omitempty"`
Questions []string `json:"questions,omitempty"`
Pillars []string `json:"pillars,omitempty"`
Exclusions []string `json:"exclusions,omitempty"`
SuggestedTags []string `json:"suggested_tags,omitempty"`
BenchmarkNotes string `json:"benchmark_notes,omitempty"`
}
PersonaData {
ID string `json:"id"`
DisplayName string `json:"display_name,omitempty"`
Persona string `json:"persona,omitempty"`
Brief string `json:"brief,omitempty"`
StyleProfile string `json:"style_profile,omitempty"`
StyleBenchmark string `json:"style_benchmark,omitempty"`
SeedQuery string `json:"seed_query,omitempty"`
CopyResearchMap CopyResearchMapData `json:"copy_research_map,omitempty"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
}
ListPersonasData {
List []PersonaData `json:"list"`
}
CreatePersonaReq {
DisplayName string `json:"display_name,optional"`
}
PersonaPath {
ID string `path:"id" validate:"required"`
}
UpdatePersonaReq {
DisplayName *string `json:"display_name,optional"`
Persona *string `json:"persona,optional"`
Brief *string `json:"brief,optional"`
StyleProfile *string `json:"style_profile,optional"`
StyleBenchmark *string `json:"style_benchmark,optional"`
}
StartPersonaStyleAnalysisReq {
BenchmarkUsername string `json:"benchmark_username" validate:"required"`
}
StartPersonaStyleAnalysisData {
JobID string `json:"job_id"`
Status string `json:"status"`
Message string `json:"message"`
}
UpdatePersonaHandlerReq {
PersonaPath
UpdatePersonaReq
}
StartPersonaStyleAnalysisHandlerReq {
PersonaPath
StartPersonaStyleAnalysisReq
}
StartPersonaViralScanJobReq {
Keywords []string `json:"keywords,optional"`
}
StartPersonaViralScanJobData {
JobID string `json:"job_id"`
Status string `json:"status"`
Message string `json:"message"`
}
StartPersonaViralScanJobHandlerReq {
PersonaPath
StartPersonaViralScanJobReq
}
ListPersonaViralScanPostsReq {
Limit int `form:"limit,optional"`
}
ViralScanPostData {
ID string `json:"id"`
SearchTag string `json:"search_tag"`
Permalink string `json:"permalink"`
Author string `json:"author"`
AuthorVerified bool `json:"author_verified,omitempty"`
FollowerCount int `json:"follower_count,omitempty"`
Text string `json:"text"`
LikeCount int `json:"like_count"`
ReplyCount int `json:"reply_count"`
EngagementScore int `json:"engagement_score"`
Source string `json:"source"`
ScanJobID string `json:"scan_job_id"`
Replies []ScanReplyData `json:"replies,omitempty"`
CreateAt int64 `json:"create_at"`
}
ListPersonaViralScanPostsData {
List []ViralScanPostData `json:"list"`
Total int `json:"total"`
}
ListPersonaViralScanPostsHandlerReq {
PersonaPath
ListPersonaViralScanPostsReq
}
CopyDraftData {
ID string `json:"id"`
PersonaID string `json:"persona_id"`
CopyMissionID string `json:"copy_mission_id,omitempty"`
ScanPostID string `json:"scan_post_id,omitempty"`
DraftType string `json:"draft_type"`
SortOrder int `json:"sort_order,omitempty"`
Text string `json:"text"`
Angle string `json:"angle,omitempty"`
Hook string `json:"hook,omitempty"`
Rationale string `json:"rationale,omitempty"`
ReferenceNotes string `json:"reference_notes,omitempty"`
Sources []string `json:"sources,omitempty"`
Status string `json:"status,omitempty"`
PublishedMediaID string `json:"published_media_id,omitempty"`
PublishedPermalink string `json:"published_permalink,omitempty"`
PublishedAt int64 `json:"published_at,omitempty"`
CreateAt int64 `json:"create_at"`
}
ListPersonaCopyDraftsData {
List []CopyDraftData `json:"list"`
Total int `json:"total"`
}
GeneratePersonaCopyDraftReq {
ScanPostID string `json:"scan_post_id" validate:"required"`
}
GeneratePersonaCopyDraftHandlerReq {
PersonaPath
GeneratePersonaCopyDraftReq
}
GeneratePersonaCopyDraftData {
Draft CopyDraftData `json:"draft"`
Message string `json:"message"`
}
CopyDraftPath {
ID string `path:"id" validate:"required"`
DraftID string `path:"draftId" validate:"required"`
}
UpdateCopyDraftReq {
Text *string `json:"text,optional"`
Hook *string `json:"hook,optional"`
Angle *string `json:"angle,optional"`
Status *string `json:"status,optional"`
}
UpdateCopyDraftHandlerReq {
CopyDraftPath
UpdateCopyDraftReq
}
PublishCopyDraftReq {
Text string `json:"text,optional"`
Confirm bool `json:"confirm"`
}
PublishCopyDraftHandlerReq {
CopyDraftPath
PublishCopyDraftReq
}
PublishCopyDraftData {
DraftID string `json:"draft_id"`
MediaID string `json:"media_id"`
Permalink string `json:"permalink,omitempty"`
Status string `json:"status"`
Message string `json:"message"`
}
)
@server(
group: persona
prefix: /api/v1/personas
middleware: AuthJWT
tags: "Persona"
summary: "Reusable persona profiles with 8D style strategy. Requires Bearer JWT."
)
service gateway {
@handler listPersonas
get / returns (ListPersonasData)
@handler createPersona
post / (CreatePersonaReq) returns (PersonaData)
@handler getPersona
get /:id (PersonaPath) returns (PersonaData)
@handler updatePersona
patch /:id (UpdatePersonaHandlerReq) returns (PersonaData)
@handler deletePersona
delete /:id (PersonaPath)
@handler startPersonaStyleAnalysis
post /:id/style-analysis (StartPersonaStyleAnalysisHandlerReq) returns (StartPersonaStyleAnalysisData)
@handler startPersonaViralScanJob
post /:id/viral-scan-jobs (StartPersonaViralScanJobHandlerReq) returns (StartPersonaViralScanJobData)
@handler listPersonaViralScanPosts
get /:id/viral-scan-posts (ListPersonaViralScanPostsHandlerReq) returns (ListPersonaViralScanPostsData)
@handler listPersonaCopyDrafts
get /:id/copy-drafts (PersonaPath) returns (ListPersonaCopyDraftsData)
@handler generatePersonaCopyDraft
post /:id/copy-drafts/generate (GeneratePersonaCopyDraftHandlerReq) returns (GeneratePersonaCopyDraftData)
@handler updatePersonaCopyDraft
patch /:id/copy-drafts/:draftId (UpdateCopyDraftHandlerReq) returns (CopyDraftData)
@handler publishPersonaCopyDraft
post /:id/copy-drafts/:draftId/publish (PublishCopyDraftHandlerReq) returns (PublishCopyDraftData)
}

View File

@ -0,0 +1,185 @@
syntax = "v1"
type (
PlacementTopicData {
ID string `json:"id"`
BrandID string `json:"brand_id"`
BrandDisplayName string `json:"brand_display_name,omitempty"`
TopicName string `json:"topic_name,omitempty"`
SeedQuery string `json:"seed_query,omitempty"`
Brief string `json:"brief,omitempty"`
ProductID string `json:"product_id,omitempty"`
ResearchMap ResearchMapData `json:"research_map,omitempty"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
}
ListPlacementTopicsData {
List []PlacementTopicData `json:"list"`
}
CreatePlacementTopicReq {
BrandID string `json:"brand_id" validate:"required"`
TopicName string `json:"topic_name" validate:"required"`
SeedQuery string `json:"seed_query" validate:"required"`
Brief string `json:"brief" validate:"required"`
ProductID string `json:"product_id,optional"`
}
UpdatePlacementTopicReq {
BrandID *string `json:"brand_id,optional"`
TopicName *string `json:"topic_name,optional"`
SeedQuery *string `json:"seed_query,optional"`
Brief *string `json:"brief,optional"`
ProductID *string `json:"product_id,optional"`
AudienceSummary *string `json:"audience_summary,optional"`
ContentGoal *string `json:"content_goal,optional"`
Questions []string `json:"questions,optional"`
Pillars []string `json:"pillars,optional"`
Exclusions []string `json:"exclusions,optional"`
PatrolKeywords []string `json:"patrol_keywords,optional"`
}
PlacementTopicPath {
ID string `path:"id" validate:"required"`
}
UpdatePlacementTopicHandlerReq {
PlacementTopicPath
UpdatePlacementTopicReq
}
CreatePlacementTopicHandlerReq {
CreatePlacementTopicReq
}
ExpandPlacementTopicGraphHandlerReq {
PlacementTopicPath
ExpandKnowledgeGraphReq
}
PatchPlacementTopicGraphNodesHandlerReq {
PlacementTopicPath
PatchKnowledgeGraphNodesReq
}
StartPlacementTopicScanJobHandlerReq {
PlacementTopicPath
StartBrandScanJobReq
}
ListPlacementTopicScanPostsHandlerReq {
PlacementTopicPath
ListBrandScanPostsReq
}
GeneratePlacementTopicOutreachDraftsHandlerReq {
PlacementTopicPath
GenerateOutreachDraftsReq
}
PublishPlacementTopicOutreachDraftHandlerReq {
PlacementTopicPath
PublishOutreachDraftReq
}
PatchPlacementTopicScanPostOutreachHandlerReq {
PlacementTopicPath
PostID string `path:"postId"`
PatchScanPostOutreachReq
}
DeletePlacementTopicScanPostHandlerReq {
PlacementTopicPath
PostID string `path:"postId" validate:"required"`
}
BatchDeletePlacementTopicScanPostsReq {
PostIDs []string `json:"post_ids" validate:"required"`
}
BatchDeletePlacementTopicScanPostsData {
DeletedCount int `json:"deleted_count"`
}
BatchDeletePlacementTopicScanPostsHandlerReq {
PlacementTopicPath
BatchDeletePlacementTopicScanPostsReq
}
GeneratePlacementTopicContentMatrixHandlerReq {
PlacementTopicPath
GenerateContentMatrixReq
}
UpsertPlacementTopicScanScheduleHandlerReq {
PlacementTopicPath
UpsertBrandScanScheduleReq
}
)
@server(
group: placement_topic
prefix: /api/v1/placement/topics
middleware: AuthJWT
tags: "Placement Topic"
summary: "找 TA 主題每個主題關聯一個品牌一個品牌可有多個主題。Requires Bearer JWT."
)
service gateway {
@handler listPlacementTopics
get / returns (ListPlacementTopicsData)
@handler createPlacementTopic
post / (CreatePlacementTopicHandlerReq) returns (PlacementTopicData)
@handler getPlacementTopic
get /:id (PlacementTopicPath) returns (PlacementTopicData)
@handler updatePlacementTopic
patch /:id (UpdatePlacementTopicHandlerReq) returns (PlacementTopicData)
@handler deletePlacementTopic
delete /:id (PlacementTopicPath)
@handler expandPlacementTopicGraph
post /:id/knowledge-graph/expand (ExpandPlacementTopicGraphHandlerReq) returns (ExpandKnowledgeGraphData)
@handler getPlacementTopicGraph
get /:id/knowledge-graph (PlacementTopicPath) returns (KnowledgeGraphData)
@handler patchPlacementTopicGraphNodes
patch /:id/knowledge-graph/nodes (PatchPlacementTopicGraphNodesHandlerReq) returns (KnowledgeGraphData)
@handler startPlacementTopicScanJob
post /:id/scan-jobs (StartPlacementTopicScanJobHandlerReq) returns (StartBrandScanJobData)
@handler listPlacementTopicScanPosts
get /:id/scan-posts (ListPlacementTopicScanPostsHandlerReq) returns (ListBrandScanPostsData)
@handler generatePlacementTopicOutreachDrafts
post /:id/outreach-drafts/generate (GeneratePlacementTopicOutreachDraftsHandlerReq) returns (GenerateOutreachDraftsData)
@handler publishPlacementTopicOutreachDraft
post /:id/outreach-drafts/publish (PublishPlacementTopicOutreachDraftHandlerReq) returns (PublishOutreachDraftData)
@handler patchPlacementTopicScanPostOutreach
patch /:id/scan-posts/:postId (PatchPlacementTopicScanPostOutreachHandlerReq) returns (ScanPostData)
@handler deletePlacementTopicScanPost
delete /:id/scan-posts/:postId (DeletePlacementTopicScanPostHandlerReq)
@handler batchDeletePlacementTopicScanPosts
post /:id/scan-posts/batch-delete (BatchDeletePlacementTopicScanPostsHandlerReq) returns (BatchDeletePlacementTopicScanPostsData)
@handler getPlacementTopicContentMatrix
get /:id/content-matrix (PlacementTopicPath) returns (ContentMatrixData)
@handler generatePlacementTopicContentMatrix
post /:id/content-matrix/generate (GeneratePlacementTopicContentMatrixHandlerReq) returns (ContentMatrixData)
@handler getPlacementTopicScanSchedule
get /:id/scan-schedule (PlacementTopicPath) returns (BrandScanScheduleData)
@handler upsertPlacementTopicScanSchedule
put /:id/scan-schedule (UpsertPlacementTopicScanScheduleHandlerReq) returns (BrandScanScheduleData)
}

68
generate/api/setting.api Normal file
View File

@ -0,0 +1,68 @@
syntax = "v1"
type (
SettingPath {
Scope string `path:"scope" validate:"required,oneof=user account system"` // 設定範圍,可選 user / account / system
ScopeID string `path:"scope_id" validate:"required"` // 範圍 ID例如 user_id、account_id 或 global
Page int64 `form:"page,optional"` // 頁碼,從 1 開始
PageSize int64 `form:"pageSize,optional"` // 每頁筆數server 會限制最大值
}
SettingKeyPath {
Scope string `path:"scope" validate:"required,oneof=user account system"` // 設定範圍,可選 user / account / system
ScopeID string `path:"scope_id" validate:"required"` // 範圍 ID例如 user_id、account_id 或 global
Key string `path:"key" validate:"required"` // 設定 key例如 ai.default
}
SettingUpsertReq {
Scope string `path:"scope" validate:"required,oneof=user account system"` // 設定範圍,可選 user / account / system
ScopeID string `path:"scope_id" validate:"required"` // 範圍 ID例如 user_id、account_id 或 global
Key string `path:"key" validate:"required"` // 設定 key例如 ai.default
Value map[string]interface{} `json:"value" validate:"required"` // 設定內容 JSON object
Version int `json:"version,optional"` // schema version未帶入時預設 1
}
SettingData {
ID string `json:"id"`
Scope string `json:"scope"`
ScopeID string `json:"scope_id"`
Key string `json:"key"`
Value map[string]interface{} `json:"value"`
Version int `json:"version"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
}
SettingListData {
Pagination PaginationData `json:"pagination"`
List []SettingData `json:"list"`
}
PaginationData {
Total int64 `json:"total"`
Page int64 `json:"page"`
PageSize int64 `json:"pageSize"`
TotalPages int64 `json:"totalPages"`
}
)
@server(
group: setting
prefix: /api/v1/settings
middleware: AuthJWT
tags: "Setting - General"
summary: "Manage settings by scope, scope_id, and key. Requires Bearer JWT."
)
service gateway {
@handler listSettings
get /:scope/:scope_id (SettingPath) returns (SettingListData)
@handler getSetting
get /:scope/:scope_id/:key (SettingKeyPath) returns (SettingData)
@handler upsertSetting
put /:scope/:scope_id/:key (SettingUpsertReq) returns (SettingData)
@handler deleteSetting
delete /:scope/:scope_id/:key (SettingKeyPath)
}

View File

@ -0,0 +1,157 @@
syntax = "v1"
type (
ThreadsAccountData {
ID string `json:"id"`
DisplayName string `json:"display_name,omitempty"`
Username string `json:"username,omitempty"`
ThreadsUserID string `json:"threads_user_id,omitempty"`
PersonaID string `json:"persona_id,omitempty"` // deprecated: persona is chosen per publish, not bound to account
BrowserConnected bool `json:"browser_connected"`
ApiConnected bool `json:"api_connected"`
Status string `json:"status"`
CreateAt int64 `json:"create_at"`
UpdateAt int64 `json:"update_at"`
}
ListThreadsAccountsData {
List []ThreadsAccountData `json:"list"`
ActiveAccountID string `json:"active_account_id"`
}
CreateThreadsAccountReq {
DisplayName string `json:"display_name,optional"`
Activate *bool `json:"activate,optional"`
}
UpdateThreadsAccountReq {
DisplayName *string `json:"display_name,optional"`
PersonaID *string `json:"persona_id,optional"` // deprecated: use persona_id in publish payload instead
}
ThreadsAccountPath {
ID string `path:"id" validate:"required"`
}
ThreadsAccountConnectionPrefs {
SearchViaApi bool `json:"search_via_api"`
SearchSourceMode string `json:"search_source_mode"`
PublishViaApi bool `json:"publish_via_api"`
DevMode bool `json:"dev_mode"`
ScrapeReplies bool `json:"scrape_replies"`
RepliesPerPost int `json:"replies_per_post"`
PublishHeaded bool `json:"publish_headed"`
PlaywrightDebug bool `json:"playwright_debug"`
}
ThreadsAccountConnectionData {
AccountID string `json:"account_id"`
AccountName string `json:"account_name"`
Username string `json:"username,omitempty"`
BrowserConnected bool `json:"browser_connected"`
ApiConnected bool `json:"api_connected"`
Prefs ThreadsAccountConnectionPrefs `json:"prefs"`
}
UpdateThreadsAccountConnectionReq {
SearchViaApi *bool `json:"search_via_api,optional"`
SearchSourceMode *string `json:"search_source_mode,optional"`
PublishViaApi *bool `json:"publish_via_api,optional"`
DevMode *bool `json:"dev_mode,optional"`
ScrapeReplies *bool `json:"scrape_replies,optional"`
RepliesPerPost *int `json:"replies_per_post,optional"`
PublishHeaded *bool `json:"publish_headed,optional"`
PlaywrightDebug *bool `json:"playwright_debug,optional"`
}
ImportThreadsAccountSessionReq {
StorageState string `json:"storageState" validate:"required"`
}
ImportThreadsAccountSessionData {
Success bool `json:"success"`
Valid bool `json:"valid"`
Synced bool `json:"synced"`
AccountID string `json:"account_id"`
Username string `json:"username,omitempty"`
Message string `json:"message"`
UpdateAt int64 `json:"update_at"`
}
ThreadsAccountAiSettingsData {
AccountID string `json:"account_id"`
Provider string `json:"provider"`
Model string `json:"model"`
ResearchProvider string `json:"research_provider,omitempty"`
ResearchModel string `json:"research_model,omitempty"`
ApiKeys map[string]string `json:"api_keys"`
ApiKeysConfigured map[string]interface{} `json:"api_keys_configured"`
}
UpdateThreadsAccountAiSettingsReq {
Provider *string `json:"provider,optional"`
Model *string `json:"model,optional"`
ResearchProvider *string `json:"research_provider,optional"`
ResearchModel *string `json:"research_model,optional"`
ApiKeys map[string]string `json:"api_keys,optional"`
}
UpdateThreadsAccountHandlerReq {
ThreadsAccountPath
UpdateThreadsAccountReq
}
UpdateThreadsAccountConnectionHandlerReq {
ThreadsAccountPath
UpdateThreadsAccountConnectionReq
}
ImportThreadsAccountSessionHandlerReq {
ThreadsAccountPath
ImportThreadsAccountSessionReq
}
UpdateThreadsAccountAiSettingsHandlerReq {
ThreadsAccountPath
UpdateThreadsAccountAiSettingsReq
}
)
@server(
group: threads_account
prefix: /api/v1/threads-accounts
middleware: AuthJWT
tags: "ThreadsAccount"
summary: "Threads operating account endpoints. Requires Bearer JWT."
)
service gateway {
@handler listThreadsAccounts
get / returns (ListThreadsAccountsData)
@handler createThreadsAccount
post / (CreateThreadsAccountReq) returns (ThreadsAccountData)
@handler getThreadsAccount
get /:id (ThreadsAccountPath) returns (ThreadsAccountData)
@handler updateThreadsAccount
patch /:id (UpdateThreadsAccountHandlerReq) returns (ThreadsAccountData)
@handler activateThreadsAccount
post /:id/activate (ThreadsAccountPath)
@handler getThreadsAccountConnection
get /:id/connection (ThreadsAccountPath) returns (ThreadsAccountConnectionData)
@handler updateThreadsAccountConnection
patch /:id/connection (UpdateThreadsAccountConnectionHandlerReq) returns (ThreadsAccountConnectionData)
@handler importThreadsAccountSession
post /:id/session/import (ImportThreadsAccountSessionHandlerReq) returns (ImportThreadsAccountSessionData)
@handler getThreadsAccountAiSettings
get /:id/ai-settings (ThreadsAccountPath) returns (ThreadsAccountAiSettingsData)
@handler updateThreadsAccountAiSettings
put /:id/ai-settings (UpdateThreadsAccountAiSettingsHandlerReq) returns (ThreadsAccountAiSettingsData)
}

View File

@ -0,0 +1,143 @@
syntax = "v1"
type (
ClaimWorkerJobReq {
WorkerType string `json:"worker_type" validate:"required"`
WorkerID string `json:"worker_id" validate:"required"`
}
WorkerJobPath {
ID string `path:"id" validate:"required"`
}
WorkerJobReq {
WorkerJobPath
WorkerID string `json:"worker_id" validate:"required"`
}
WorkerHeartbeatReq {
WorkerJobPath
WorkerID string `json:"worker_id" validate:"required"`
TTLSeconds int `json:"ttl_seconds,optional"`
}
WorkerProgressReq {
WorkerJobPath
WorkerID string `json:"worker_id" validate:"required"`
Phase string `json:"phase,optional"`
Summary string `json:"summary,optional"`
Percentage int `json:"percentage,optional"`
Steps []JobStepProgressData `json:"steps,optional"`
}
WorkerCompleteReq {
WorkerJobPath
WorkerID string `json:"worker_id" validate:"required"`
Result map[string]interface{} `json:"result,optional"`
}
WorkerFailReq {
WorkerJobPath
WorkerID string `json:"worker_id" validate:"required"`
Error string `json:"error" validate:"required"`
Phase string `json:"phase,optional"`
}
WorkerCancelCheckData {
Cancelled bool `json:"cancelled"`
}
WorkerOKData {
OK bool `json:"ok"`
}
StorePersonaStyleProfileReq {
ID string `path:"id" validate:"required"`
TenantID string `json:"tenant_id" validate:"required"`
OwnerUID string `json:"owner_uid" validate:"required"`
StyleProfile string `json:"style_profile" validate:"required"`
StyleBenchmark string `json:"style_benchmark,optional"`
}
StorePersonaStyleProfileData {
ID string `json:"id"`
UpdateAt int64 `json:"update_at"`
}
WorkerThreadsAccountSessionReq {
ID string `path:"id" validate:"required"`
TenantID string `json:"tenant_id" validate:"required"`
OwnerUID string `json:"owner_uid" validate:"required"`
}
WorkerThreadsAccountSessionData {
AccountID string `json:"account_id"`
StorageState string `json:"storage_state"`
UpdateAt int64 `json:"update_at"`
}
AnalyzeStyle8DPostReq {
Text string `json:"text"`
Permalink string `json:"permalink,optional"`
LikeCount int `json:"like_count,optional"`
ReplyCount int `json:"reply_count,optional"`
}
AnalyzeStyle8DReq {
ID string `path:"id" validate:"required"`
WorkerID string `json:"worker_id" validate:"required"`
TenantID string `json:"tenant_id" validate:"required"`
OwnerUID string `json:"owner_uid" validate:"required"`
PersonaID string `json:"persona_id" validate:"required"`
ThreadsAccountID string `json:"threads_account_id" validate:"required"`
Username string `json:"username" validate:"required"`
Posts []AnalyzeStyle8DPostReq `json:"posts" validate:"required"`
Steps []JobStepProgressData `json:"steps,optional"`
}
AnalyzeStyle8DData {
PersonaID string `json:"persona_id"`
PostCount int `json:"post_count"`
StyleProfile string `json:"style_profile"`
StyleBenchmark string `json:"style_benchmark"`
}
)
@server(
group: job
prefix: /api/v1/internal
middleware: WorkerSecret
tags: "Internal Worker"
summary: "Internal worker endpoints protected by X-Worker-Secret when InternalWorker.Secret is configured."
)
service gateway {
@handler claimWorkerJob
post /workers/jobs/claim (ClaimWorkerJobReq) returns (JobData)
@handler refreshWorkerJobLock
post /workers/jobs/:id/heartbeat (WorkerHeartbeatReq) returns (WorkerOKData)
@handler checkWorkerJobCancel
post /workers/jobs/:id/cancel-check (WorkerJobReq) returns (WorkerCancelCheckData)
@handler ackWorkerJobCancel
post /workers/jobs/:id/cancel-ack (WorkerJobReq) returns (JobData)
@handler updateWorkerJobProgress
post /workers/jobs/:id/progress (WorkerProgressReq) returns (JobData)
@handler completeWorkerJob
post /workers/jobs/:id/complete (WorkerCompleteReq) returns (JobData)
@handler failWorkerJob
post /workers/jobs/:id/fail (WorkerFailReq) returns (JobData)
@handler storePersonaStyleProfileFromWorker
patch /workers/personas/:id/style-profile (StorePersonaStyleProfileReq) returns (StorePersonaStyleProfileData)
@handler getWorkerThreadsAccountSession
post /workers/threads-accounts/:id/session (WorkerThreadsAccountSessionReq) returns (WorkerThreadsAccountSessionData)
@handler analyzeStyle8DFromWorker
post /workers/jobs/:id/analyze-style8d (AnalyzeStyle8DReq) returns (AnalyzeStyle8DData)
}

View File

@ -0,0 +1,31 @@
// Code scaffolded by goctl. Safe to edit.
// goctl {{.version}}
package {{.PkgName}}
import (
"net/http"
"haixun-backend/internal/response"
{{if .HasRequest}}"github.com/zeromicro/go-zero/rest/httpx"
{{end}}{{.ImportPackages}}
)
{{if .HasDoc}}{{.Doc}}{{end}}
func {{.HandlerName}}(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
{{if .HasRequest}}var req types.{{.RequestType}}
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
{{end}}l := {{.LogicName}}.New{{.LogicType}}(r.Context(), svcCtx)
{{if .HasResp}}data, {{end}}err := l.{{.Call}}({{if .HasRequest}}&req{{end}})
{{if .HasResp}}response.Write(r.Context(), w, data, err){{else}}response.Write(r.Context(), w, nil, err){{end}}
}
}

69
go.mod Normal file
View File

@ -0,0 +1,69 @@
module haixun-backend
go 1.22
require (
github.com/go-playground/validator/v10 v10.27.0
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/uuid v1.6.0
github.com/redis/go-redis/v9 v9.14.0
github.com/robfig/cron/v3 v3.0.1
github.com/zeromicro/go-zero v1.9.2
go.mongodb.org/mongo-driver v1.17.4
golang.org/x/crypto v0.33.0
)
require (
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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/grafana/pyroscope-go v1.2.7 // indirect
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/openzipkin/zipkin-go v0.4.3 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/prometheus/client_golang v1.21.1 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/spaolacci/murmur3 v1.1.0 // 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.35.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.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.5 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)

196
go.sum Normal file
View File

@ -0,0 +1,196 @@
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/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
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/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
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.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
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/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
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/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/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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/grafana/pyroscope-go v1.2.7 h1:VWBBlqxjyR0Cwk2W6UrE8CdcdD80GOFNutj0Kb1T8ac=
github.com/grafana/pyroscope-go v1.2.7/go.mod h1:o/bpSLiJYYP6HQtvcoVKiE9s5RiNgjYTj1DhiddP2Pc=
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/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/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
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/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
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-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/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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/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/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/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
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/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
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/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
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/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE=
github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
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/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
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.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/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
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.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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
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/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
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/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeromicro/go-zero v1.9.2 h1:ZXOXBIcazZ1pWAMiHyVnDQ3Sxwy7DYPzjE89Qtj9vqM=
github.com/zeromicro/go-zero v1.9.2/go.mod h1:k8YBMEFZKjTd4q/qO5RCW+zDgUlNyAs5vue3P4/Kmn0=
go.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFXVw=
go.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
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/exporters/jaeger v1.17.0 h1:D7UpUy2Xc2wsi1Ras6V40q806WM07rqoCWzXu7Sqy+4=
go.opentelemetry.io/otel/exporters/jaeger v1.17.0/go.mod h1:nPCqOnEH9rNLKqH/+rrUjiMzHJdV1BlpKcTwRTyKkKI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0/go.mod h1:iSDOcsnSA5INXzZtwaBPrKp/lWu/V14Dd+llD0oI2EA=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.24.0 h1:Mw5xcxMwlqoJd97vwPxA8isEaIoxsta9/Q51+TTJLGE=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.24.0/go.mod h1:CQNu9bj7o7mC6U7+CA/schKEYakYXWr79ucDHTMGhCM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 h1:Xw8U6u2f8DK2XAkGRFV7BBLENgnTGX9i4rQRxJf+/vs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0/go.mod h1:6KW1Fm6R/s6Z3PGXwSJN2K4eT6wQB3vXX6CVnYX9NmM=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.24.0 h1:s0PHtIkN+3xrbDOpt2M8OTG92cWqUESvzh2MxiR5xY8=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.24.0/go.mod h1:hZlFbDbRt++MMPCCfSJfmhkGIWnX1h3XjkfxZUjLrIA=
go.opentelemetry.io/otel/exporters/zipkin v1.24.0 h1:3evrL5poBuh1KF51D9gO/S+N/1msnm4DaBqs/rpXUqY=
go.opentelemetry.io/otel/exporters/zipkin v1.24.0/go.mod h1:0EHgD8R0+8yRhUYJOGR8Hfg2dpiJQxDOszd5smVO9wM=
go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI=
go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco=
go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw=
go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg=
go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
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/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
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.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/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-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
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-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
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-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-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-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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/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/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.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
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-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/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/go.mod h1:mw8MG/Qz5wfgYr6VqVCiZcHe/GJEfI+oGGDCohaVgB0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 h1:BwIjyKYGsK9dMCBOorzRri8MQwmi7mT9rGHsCEinZkA=
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/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/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 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
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/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0=
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.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A=
k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=

View File

@ -0,0 +1,72 @@
package bootstrap
import (
"context"
"strings"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
"haixun-backend/internal/model/member/domain/entity"
domrepo "haixun-backend/internal/model/member/domain/repository"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
type AdminOptions struct {
TenantID string
Email string
Password string
DisplayName string
}
func EnsureAdminMember(ctx context.Context, repo domrepo.Repository, opts AdminOptions) (*entity.Member, bool, error) {
tenantID := strings.TrimSpace(opts.TenantID)
email := normalizeEmail(opts.Email)
if tenantID == "" || email == "" || opts.Password == "" {
return nil, false, app.For(code.Member).InputMissingRequired("tenant_id, email, and password are required")
}
if len(opts.Password) < 8 {
return nil, false, app.For(code.Member).InputInvalidFormat("password must be at least 8 characters")
}
existing, err := repo.FindByEmail(ctx, tenantID, email)
if err == nil {
if err := repo.SetRoles(ctx, tenantID, existing.UID, []string{"admin"}); err != nil {
return nil, false, err
}
existing.Roles = []string{"admin"}
return existing, false, nil
}
if e := app.FromError(err); e == nil || e.Category() != code.ResNotFound {
return nil, false, err
}
hash, err := bcrypt.GenerateFromPassword([]byte(opts.Password), bcrypt.DefaultCost)
if err != nil {
return nil, false, app.For(code.Member).SysInternal("hash password failed").WithCause(err)
}
displayName := strings.TrimSpace(opts.DisplayName)
if displayName == "" {
displayName = "Admin"
}
member, err := repo.Create(ctx, &entity.Member{
TenantID: tenantID,
UID: uuid.NewString(),
Email: email,
DisplayName: displayName,
Language: "zh-TW",
Status: entity.StatusOpen,
Origin: entity.OriginNative,
PasswordHash: string(hash),
Roles: []string{"admin"},
})
if err != nil {
return nil, false, err
}
return member, true, nil
}
func normalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}

View File

@ -0,0 +1,126 @@
package bootstrap
import (
"context"
"testing"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
"haixun-backend/internal/model/member/domain/entity"
domrepo "haixun-backend/internal/model/member/domain/repository"
"golang.org/x/crypto/bcrypt"
)
type memoryMemberRepo struct {
byEmail map[string]*entity.Member
}
func memberKey(tenantID, email string) string {
return tenantID + ":" + normalizeEmail(email)
}
func (m *memoryMemberRepo) EnsureIndexes(context.Context) error { return nil }
func (m *memoryMemberRepo) Create(_ context.Context, member *entity.Member) (*entity.Member, error) {
if m.byEmail == nil {
m.byEmail = map[string]*entity.Member{}
}
key := memberKey(member.TenantID, member.Email)
if _, ok := m.byEmail[key]; ok {
return nil, app.For(code.Member).ResConflict("member already exists")
}
cp := *member
m.byEmail[key] = &cp
return &cp, nil
}
func (m *memoryMemberRepo) FindByUID(_ context.Context, tenantID, uid string) (*entity.Member, error) {
for _, item := range m.byEmail {
if item.TenantID == tenantID && item.UID == uid {
cp := *item
return &cp, nil
}
}
return nil, app.For(code.Member).ResNotFound("member not found")
}
func (m *memoryMemberRepo) FindByEmail(_ context.Context, tenantID, email string) (*entity.Member, error) {
item, ok := m.byEmail[memberKey(tenantID, email)]
if !ok {
return nil, app.For(code.Member).ResNotFound("member not found")
}
cp := *item
return &cp, nil
}
func (m *memoryMemberRepo) UpdateProfile(context.Context, string, string, domrepo.ProfileUpdate) (*entity.Member, error) {
return nil, app.For(code.Member).SysNotImplemented("not implemented")
}
func (m *memoryMemberRepo) SetActiveThreadsAccountID(_ context.Context, tenantID, uid, accountID string) error {
for _, item := range m.byEmail {
if item.TenantID == tenantID && item.UID == uid {
item.ActiveThreadsAccountID = accountID
return nil
}
}
return app.For(code.Member).ResNotFound("member not found")
}
func (m *memoryMemberRepo) SetRoles(_ context.Context, tenantID, uid string, roles []string) error {
for _, item := range m.byEmail {
if item.TenantID == tenantID && item.UID == uid {
item.Roles = append([]string(nil), roles...)
return nil
}
}
return app.For(code.Member).ResNotFound("member not found")
}
func TestEnsureAdminMemberCreatesAdmin(t *testing.T) {
repo := &memoryMemberRepo{byEmail: map[string]*entity.Member{}}
member, created, err := EnsureAdminMember(context.Background(), repo, AdminOptions{
TenantID: "default",
Email: "admin@haixun.local",
Password: "Admin-Pass-1!",
})
if err != nil {
t.Fatalf("EnsureAdminMember: %v", err)
}
if !created {
t.Fatal("expected created=true")
}
if member.Roles[0] != "admin" {
t.Fatalf("roles=%v", member.Roles)
}
if err := bcrypt.CompareHashAndPassword([]byte(member.PasswordHash), []byte("Admin-Pass-1!")); err != nil {
t.Fatalf("password hash mismatch: %v", err)
}
}
func TestEnsureAdminMemberUpgradesExisting(t *testing.T) {
repo := &memoryMemberRepo{byEmail: map[string]*entity.Member{
memberKey("default", "admin@haixun.local"): {
TenantID: "default",
UID: "uid-1",
Email: "admin@haixun.local",
Roles: []string{"user"},
},
}}
_, created, err := EnsureAdminMember(context.Background(), repo, AdminOptions{
TenantID: "default",
Email: "admin@haixun.local",
Password: "Admin-Pass-1!",
})
if err != nil {
t.Fatalf("EnsureAdminMember: %v", err)
}
if created {
t.Fatal("expected created=false")
}
member := repo.byEmail[memberKey("default", "admin@haixun.local")]
if len(member.Roles) != 1 || member.Roles[0] != "admin" {
t.Fatalf("roles=%v", member.Roles)
}
}

104
internal/bootstrap/init.go Normal file
View File

@ -0,0 +1,104 @@
package bootstrap
import (
"context"
"fmt"
"haixun-backend/internal/config"
libmongo "haixun-backend/internal/library/mongo"
jobrepo "haixun-backend/internal/model/job/repository"
memberrepo "haixun-backend/internal/model/member/repository"
permissionrepo "haixun-backend/internal/model/permission/repository"
permissionuc "haixun-backend/internal/model/permission/usecase"
settingrepo "haixun-backend/internal/model/setting/repository"
)
type InitOptions struct {
TenantID string
AdminEmail string
AdminPass string
DisplayName string
}
type InitReport struct {
IndexesEnsured bool
PermissionsSeeded bool
RolePermissionsSeeded bool
AdminUID string
AdminCreated bool
}
func Init(ctx context.Context, cfg config.Config, opts InitOptions) (*InitReport, error) {
if cfg.Mongo.URI == "" || cfg.Mongo.Database == "" {
return nil, fmt.Errorf("mongo URI and database are required")
}
if opts.TenantID == "" {
return nil, fmt.Errorf("tenant_id is required")
}
if opts.AdminEmail == "" || opts.AdminPass == "" {
return nil, fmt.Errorf("admin email and password are required")
}
mongoClient, err := libmongo.NewClient(ctx, cfg.Mongo)
if err != nil {
return nil, fmt.Errorf("connect mongo: %w", err)
}
defer func() { _ = mongoClient.Close(ctx) }()
db := mongoClient.Database()
report := &InitReport{}
settingRepository := settingrepo.NewMongoRepository(db)
memberRepository := memberrepo.NewMongoRepository(db)
permissionRepository := permissionrepo.NewMongoPermissionRepository(db)
rolePermissionRepository := permissionrepo.NewMongoRolePermissionRepository(db)
jobTemplateRepository := jobrepo.NewMongoTemplateRepository(db)
jobRunRepository := jobrepo.NewMongoRunRepository(db)
jobScheduleRepository := jobrepo.NewMongoScheduleRepository(db)
jobEventRepository := jobrepo.NewMongoEventRepository(db)
repos := []struct {
name string
fn func(context.Context) error
}{
{"settings", settingRepository.EnsureIndexes},
{"members", memberRepository.EnsureIndexes},
{"permissions", permissionRepository.EnsureIndexes},
{"role_permissions", rolePermissionRepository.EnsureIndexes},
{"job_templates", jobTemplateRepository.EnsureIndexes},
{"job_runs", jobRunRepository.EnsureIndexes},
{"job_schedules", jobScheduleRepository.EnsureIndexes},
{"job_events", jobEventRepository.EnsureIndexes},
}
for _, repo := range repos {
if err := repo.fn(ctx); err != nil {
return nil, fmt.Errorf("ensure %s indexes: %w", repo.name, err)
}
}
report.IndexesEnsured = true
permissionUseCase := permissionuc.NewUseCase(permissionRepository, rolePermissionRepository)
if err := permissionUseCase.EnsureDefaultPermissions(ctx); err != nil {
return nil, fmt.Errorf("seed permissions catalog: %w", err)
}
report.PermissionsSeeded = true
if err := permissionUseCase.EnsureDefaultRolePermissions(ctx, opts.TenantID); err != nil {
return nil, fmt.Errorf("seed role permissions: %w", err)
}
report.RolePermissionsSeeded = true
admin, created, err := EnsureAdminMember(ctx, memberRepository, AdminOptions{
TenantID: opts.TenantID,
Email: opts.AdminEmail,
Password: opts.AdminPass,
DisplayName: opts.DisplayName,
})
if err != nil {
return nil, err
}
report.AdminUID = admin.UID
report.AdminCreated = created
return report, nil
}

58
internal/config/config.go Normal file
View File

@ -0,0 +1,58 @@
package config
import "github.com/zeromicro/go-zero/rest"
type MongoConf struct {
URI string
Database string
TimeoutSeconds int `json:",default=10"`
}
type RedisConf struct {
Addr string `json:",optional"`
DB int `json:",optional"`
}
type JobWorkerConf struct {
Enabled bool `json:",default=true"`
WorkerType string `json:",default=go"`
WorkerID string `json:",optional"`
}
type JobSchedulerConf struct {
Enabled bool `json:",default=true"`
IntervalSeconds int `json:",default=60"`
}
type JobReaperConf struct {
Enabled bool `json:",default=true"`
IntervalSeconds int `json:",default=30"`
}
type AuthConf struct {
AccessSecret string `json:",optional"`
RefreshSecret string `json:",optional"`
AccessExpireSeconds int64 `json:",default=900"`
RefreshExpireSeconds int64 `json:",default=2592000"`
DevHeaderFallback bool `json:",default=true"`
}
type InternalWorkerConf struct {
Secret string `json:",optional"`
}
type BraveConf struct {
APIKey string `json:",optional"`
}
type Config struct {
rest.RestConf
Mongo MongoConf `json:",optional"`
Redis RedisConf `json:",optional"`
Auth AuthConf `json:",optional"`
InternalWorker InternalWorkerConf `json:",optional"`
JobWorker JobWorkerConf `json:",optional"`
JobScheduler JobSchedulerConf `json:",optional"`
JobReaper JobReaperConf `json:",optional"`
Brave BraveConf `json:",optional"`
}

View File

@ -0,0 +1,36 @@
package ai
import (
"net/http"
"haixun-backend/internal/logic/ai"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func ChatHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.AIChatReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
token, err := ai.BearerToken(r)
if err != nil {
response.Write(r.Context(), w, nil, err)
return
}
l := ai.NewChatLogic(r.Context(), svcCtx)
data, err := l.Chat(&req, token)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,72 @@
package ai
import (
"encoding/json"
"fmt"
"net/http"
"haixun-backend/internal/logic/ai"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func ChatStreamHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.AIChatReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
token, err := ai.BearerToken(r)
if err != nil {
response.Write(r.Context(), w, nil, err)
return
}
l := ai.NewChatStreamLogic(r.Context(), svcCtx)
stream, err := l.ChatStream(&req, token)
if err != nil {
response.Write(r.Context(), w, nil, err)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
response.Write(r.Context(), w, nil, response.WrapRequestError(fmt.Errorf("server does not support streaming")))
return
}
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
for event := range stream {
writeSSE(w, event.Type, event)
flusher.Flush()
if event.Type == "done" || event.Type == "error" {
return
}
}
writeSSE(w, "done", map[string]string{"finish_reason": "stop"})
flusher.Flush()
}
}
func writeSSE(w http.ResponseWriter, eventName string, data any) {
payload, err := json.Marshal(data)
if err != nil {
payload = []byte(`{"type":"error","error":"failed to serialize SSE payload"}`)
eventName = "error"
}
_, _ = fmt.Fprintf(w, "event: %s\n", eventName)
_, _ = fmt.Fprintf(w, "data: %s\n\n", payload)
}

View File

@ -0,0 +1,55 @@
package ai
import (
"fmt"
"net/http"
"haixun-backend/internal/logic/ai"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func IslanderChatStreamHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.IslanderChatReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := ai.NewIslanderChatStreamLogic(r.Context(), svcCtx)
stream, err := l.ChatStream(&req)
if err != nil {
response.Write(r.Context(), w, nil, err)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
response.Write(r.Context(), w, nil, response.WrapRequestError(fmt.Errorf("server does not support streaming")))
return
}
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
for event := range stream {
writeSSE(w, event.Type, event)
flusher.Flush()
if event.Type == "done" || event.Type == "error" {
return
}
}
writeSSE(w, "done", map[string]string{"finish_reason": "stop"})
flusher.Flush()
}
}

View File

@ -0,0 +1,36 @@
package ai
import (
"net/http"
"haixun-backend/internal/logic/ai"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func ListAiProviderModelsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.AIProviderPath
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
token, err := ai.BearerToken(r)
if err != nil {
response.Write(r.Context(), w, nil, err)
return
}
l := ai.NewListAIProviderModelsLogic(r.Context(), svcCtx)
data, err := l.ListAIProviderModels(&req, token)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,17 @@
package ai
import (
"net/http"
"haixun-backend/internal/logic/ai"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
)
func ListAiProvidersHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := ai.NewListAIProvidersLogic(r.Context(), svcCtx)
data, err := l.ListAIProviders()
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,29 @@
package auth
import (
"net/http"
"haixun-backend/internal/logic/auth"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func LoginHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.AuthLoginReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := auth.NewLoginLogic(r.Context(), svcCtx)
data, err := l.Login(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,17 @@
package auth
import (
"net/http"
"haixun-backend/internal/logic/auth"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
)
func LogoutHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := auth.NewLogoutLogic(r.Context(), svcCtx)
data, err := l.Logout(r.Header.Get("Authorization"))
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,29 @@
package auth
import (
"net/http"
"haixun-backend/internal/logic/auth"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func RefreshHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.AuthRefreshReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := auth.NewRefreshLogic(r.Context(), svcCtx)
data, err := l.Refresh(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,29 @@
package auth
import (
"net/http"
"haixun-backend/internal/logic/auth"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func RegisterHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.AuthRegisterReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := auth.NewRegisterLogic(r.Context(), svcCtx)
data, err := l.Register(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,20 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package brand
import (
"net/http"
"haixun-backend/internal/logic/brand"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
)
func ListBrandsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := brand.NewListBrandsLogic(r.Context(), svcCtx)
data, err := l.ListBrands()
response.Write(r.Context(), w, data, err)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,30 @@
package copy_mission
import (
"net/http"
"haixun-backend/internal/logic/copy_mission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func InspireCopyMissionHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.PersonaCopyMissionsPath
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
if err := svcCtx.Validator.ValidateAll(&req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
l := copy_mission.NewInspireCopyMissionLogic(r.Context(), svcCtx)
data, err := l.InspireCopyMission(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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