fix dockerfile unhealth problem

This commit is contained in:
王性驊 2026-06-23 17:54:27 +08:00
parent 1168d49178
commit 4cd221af5e
227 changed files with 17959 additions and 0 deletions

10
haixun-backend/.idea/.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GoImports">
<option name="excludedPackages">
<array>
<option value="golang.org/x/net/context" />
</array>
</option>
</component>
</project>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MaterialThemeProjectNewConfig">
<option name="metadata">
<MTProjectMetadataState>
<option name="userId" value="73c9beac:19eed3331be:-7ebe" />
</MTProjectMetadataState>
</option>
</component>
</project>

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/haixun-backend.iml" filepath="$PROJECT_DIR$/.idea/haixun-backend.iml" />
</modules>
</component>
</project>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

335
haixun-backend/AGENTS.md Normal file
View File

@ -0,0 +1,335 @@
# 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 草案。
## 新增 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/`,風格參考 [simular.co](https://simular.co/)**明亮、年輕、圓角多、配色克制**。不要把舊 Next.js / `template-monorepo` UI 搬進來,也不要引入重型 UI 框架。
### 技術棧與指令
```text
web/
src/
api/ # API clientenvelope、JWT refresh
auth/ # AuthContext
components/ # Layout、ui、ThemeToggle、AuthShell
theme/ # ThemeContext淺色 / 深色)
pages/ # 路由頁面
lib/ # 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` Light / 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`Layout 頂欄與 `AuthShell` 都要有。
- **禁止**在元件裡寫死 `bg-slate-*`、`text-emerald-*`、`bg-amber-*` 等 Tailwind 預設色;語意狀態用 `text-success` / `text-warning` / `text-danger``jobStatus.ts` 的 badge class。
淺色預設明亮藍白底;深色為深藍黑底。兩套都只允許 **一個主色 brand靛藍** + success / warning / danger 語意色,不要再加褐色、墨綠、多種 accent 亂配。
### 色彩 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。Hero 背景用 class `hero-panel`;裝飾 blob 用 `glow-blob` / `glow-blob-alt`
### 共用元件(優先復用)
新頁面必須從 `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` = 左側欄 + 頂部 sticky barUID + `ThemeToggle`+ `Outlet`
- 已登入(手機):頂欄品牌 + 主題切換;導覽走 `MobileBottomNav`
- 側欄分組:**工作區**總覽、背景任務、排程、AI、**管理**(模板、設定、會員、權限)。
- Active 導覽:`bg-brand text-white shadow-soft`hover`bg-brand-soft text-brand`。
- 未登入:`AuthShell` 置中卡片 + 右上主題切換;背景用柔和 blob不要花俏插圖牆。
- 語氣年輕、直接、短句Hero 可有一句主標 + brand 色強調詞,避免長篇企業八股。
### 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` 底下)。
2. 頁面用 `PageTitle` + `Card` + 既有元件;色票只引用 semantic token。
3. 若需新語意色,**先**改 `index.css``--hx-*``@theme`,再改元件;不要頁面內硬編色碼。
4. 完成後執行 `make web-build`
### 前端禁忌
- 不要引入 MUI / Ant Design / Chakra 等大型 UI 庫。
- 不要為單頁新增第三套配色或漸層彩虹按鈕。
- 不要讓 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
```

52
haixun-backend/Makefile Normal file
View File

@ -0,0 +1,52 @@
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
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
web-build: web-install ## 建置前端靜態檔
cd web && npm run build
check: fmt test ## 格式化並測試

365
haixun-backend/README.md Normal file
View File

@ -0,0 +1,365 @@
# 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
```
## 專案結構
```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。

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")
}

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,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,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.');

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 與文件。

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

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
haixun-backend/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()
}

View File

@ -0,0 +1,67 @@
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"`
}
)
@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)
}

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)
}

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,24 @@
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"
)

View File

@ -0,0 +1,245 @@
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"` // 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"` // 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)
}

View File

@ -0,0 +1,46 @@
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"`
}
)
@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)
}

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)
}

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,31 @@
// Code scaffolded by goctl. Safe to edit.
// goctl {{.version}}
package {{.PkgName}}
import (
"net/http"
"haixun-backend/internal/response"
"github.com/zeromicro/go-zero/rest/httpx"
{{.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
haixun-backend/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
haixun-backend/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,116 @@
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) 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)
}
}

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
}

View File

@ -0,0 +1,48 @@
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 Config struct {
rest.RestConf
Mongo MongoConf `json:",optional"`
Redis RedisConf `json:",optional"`
Auth AuthConf `json:",optional"`
JobWorker JobWorkerConf `json:",optional"`
JobScheduler JobSchedulerConf `json:",optional"`
JobReaper JobReaperConf `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,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 job
import (
"net/http"
"haixun-backend/internal/logic/job"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func CancelJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.CancelJobReq
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 := job.NewCancelJobLogic(r.Context(), svcCtx)
data, err := l.CancelJob(&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 job
import (
"net/http"
"haixun-backend/internal/logic/job"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func CreateJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.CreateJobReq
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 := job.NewCreateJobLogic(r.Context(), svcCtx)
data, err := l.CreateJob(&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 job
import (
"net/http"
"haixun-backend/internal/logic/job"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func CreateJobScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.CreateJobScheduleReq
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 := job.NewCreateJobScheduleLogic(r.Context(), svcCtx)
data, err := l.CreateJobSchedule(&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 job
import (
"net/http"
"haixun-backend/internal/logic/job"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func DisableJobScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.JobScheduleIDPath
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 := job.NewDisableJobScheduleLogic(r.Context(), svcCtx)
data, err := l.DisableJobSchedule(&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 job
import (
"net/http"
"haixun-backend/internal/logic/job"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func EnableJobScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.JobScheduleIDPath
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 := job.NewEnableJobScheduleLogic(r.Context(), svcCtx)
data, err := l.EnableJobSchedule(&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 job
import (
"net/http"
"haixun-backend/internal/logic/job"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func GetJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.JobIDPath
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 := job.NewGetJobLogic(r.Context(), svcCtx)
data, err := l.GetJob(&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 job
import (
"net/http"
"haixun-backend/internal/logic/job"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func GetJobTemplateHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.JobTemplatePath
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 := job.NewGetJobTemplateLogic(r.Context(), svcCtx)
data, err := l.GetJobTemplate(&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 job
import (
"net/http"
"haixun-backend/internal/logic/job"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func ListJobEventsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ListJobEventsReq
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 := job.NewListJobEventsLogic(r.Context(), svcCtx)
data, err := l.ListJobEvents(&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 job
import (
"net/http"
"haixun-backend/internal/logic/job"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func ListJobSchedulesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ListJobSchedulesReq
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 := job.NewListJobSchedulesLogic(r.Context(), svcCtx)
data, err := l.ListJobSchedules(&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 job
import (
"net/http"
"haixun-backend/internal/logic/job"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
)
func ListJobTemplatesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := job.NewListJobTemplatesLogic(r.Context(), svcCtx)
data, err := l.ListJobTemplates()
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 job
import (
"net/http"
"haixun-backend/internal/logic/job"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func ListJobsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.ListJobsReq
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 := job.NewListJobsLogic(r.Context(), svcCtx)
data, err := l.ListJobs(&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 job
import (
"net/http"
"haixun-backend/internal/logic/job"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func RetryJobHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.JobIDPath
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 := job.NewRetryJobLogic(r.Context(), svcCtx)
data, err := l.RetryJob(&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 job
import (
"net/http"
"haixun-backend/internal/logic/job"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func UpdateJobScheduleHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.UpdateJobScheduleReq
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 := job.NewUpdateJobScheduleLogic(r.Context(), svcCtx)
data, err := l.UpdateJobSchedule(&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 job
import (
"net/http"
"haixun-backend/internal/logic/job"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func UpsertJobTemplateHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.UpsertJobTemplateReq
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 := job.NewUpsertJobTemplateLogic(r.Context(), svcCtx)
data, err := l.UpsertJobTemplate(&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 member
import (
"net/http"
"haixun-backend/internal/logic/member"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
)
func GetMemberMeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := member.NewGetMemberMeLogic(r.Context(), svcCtx)
data, err := l.GetMemberMe()
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 member
import (
"net/http"
"haixun-backend/internal/logic/member"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func UpdateMemberMeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.UpdateMemberMeReq
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 := member.NewUpdateMemberMeLogic(r.Context(), svcCtx)
data, err := l.UpdateMemberMe(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,17 @@
package normal
import (
"net/http"
"haixun-backend/internal/logic/normal"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
)
func HealthHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := normal.NewHealthLogic(r.Context(), svcCtx)
data, err := l.Health()
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 permission
import (
"net/http"
"haixun-backend/internal/logic/permission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func GetMePermissionsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.MePermissionsQuery
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 := permission.NewGetMePermissionsLogic(r.Context(), svcCtx)
data, err := l.GetMePermissions(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,29 @@
package permission
import (
"net/http"
"haixun-backend/internal/logic/permission"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func GetPermissionCatalogHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.PermissionCatalogQuery
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 := permission.NewGetPermissionCatalogLogic(r.Context(), svcCtx)
data, err := l.GetPermissionCatalog(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,248 @@
// Code generated by goctl. DO NOT EDIT.
// goctl 1.10.1
package handler
import (
"net/http"
ai "haixun-backend/internal/handler/ai"
auth "haixun-backend/internal/handler/auth"
job "haixun-backend/internal/handler/job"
member "haixun-backend/internal/handler/member"
normal "haixun-backend/internal/handler/normal"
permission "haixun-backend/internal/handler/permission"
setting "haixun-backend/internal/handler/setting"
"haixun-backend/internal/svc"
"github.com/zeromicro/go-zero/rest"
)
func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
server.AddRoutes(
[]rest.Route{
{
Method: http.MethodGet,
Path: "/providers",
Handler: ai.ListAiProvidersHandler(serverCtx),
},
},
rest.WithPrefix("/api/v1/ai"),
)
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.MemberAuth},
[]rest.Route{
{
Method: http.MethodPost,
Path: "/chat",
Handler: ai.ChatHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/chat/stream",
Handler: ai.ChatStreamHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/providers/:provider/models",
Handler: ai.ListAiProviderModelsHandler(serverCtx),
},
}...,
),
rest.WithPrefix("/api/v1/ai"),
)
server.AddRoutes(
[]rest.Route{
{
Method: http.MethodPost,
Path: "/login",
Handler: auth.LoginHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/refresh",
Handler: auth.RefreshHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/register",
Handler: auth.RegisterHandler(serverCtx),
},
},
rest.WithPrefix("/api/v1/auth"),
)
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT},
[]rest.Route{
{
Method: http.MethodPost,
Path: "/logout",
Handler: auth.LogoutHandler(serverCtx),
},
}...,
),
rest.WithPrefix("/api/v1/auth"),
)
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT},
[]rest.Route{
{
Method: http.MethodGet,
Path: "/job/schedules",
Handler: job.ListJobSchedulesHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/job/schedules",
Handler: job.CreateJobScheduleHandler(serverCtx),
},
{
Method: http.MethodPut,
Path: "/job/schedules/:id",
Handler: job.UpdateJobScheduleHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/job/schedules/:id/disable",
Handler: job.DisableJobScheduleHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/job/schedules/:id/enable",
Handler: job.EnableJobScheduleHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/job/templates",
Handler: job.ListJobTemplatesHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/job/templates/:type",
Handler: job.GetJobTemplateHandler(serverCtx),
},
{
Method: http.MethodPut,
Path: "/job/templates/:type",
Handler: job.UpsertJobTemplateHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/jobs",
Handler: job.CreateJobHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/jobs",
Handler: job.ListJobsHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/jobs/:id",
Handler: job.GetJobHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/jobs/:id/cancel",
Handler: job.CancelJobHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/jobs/:id/events",
Handler: job.ListJobEventsHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/jobs/:id/retry",
Handler: job.RetryJobHandler(serverCtx),
},
}...,
),
rest.WithPrefix("/api/v1"),
)
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT},
[]rest.Route{
{
Method: http.MethodGet,
Path: "/me",
Handler: member.GetMemberMeHandler(serverCtx),
},
{
Method: http.MethodPatch,
Path: "/me",
Handler: member.UpdateMemberMeHandler(serverCtx),
},
}...,
),
rest.WithPrefix("/api/v1/members"),
)
server.AddRoutes(
[]rest.Route{
{
Method: http.MethodGet,
Path: "/health",
Handler: normal.HealthHandler(serverCtx),
},
},
rest.WithPrefix("/api/v1"),
)
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT},
[]rest.Route{
{
Method: http.MethodGet,
Path: "/catalog",
Handler: permission.GetPermissionCatalogHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/me",
Handler: permission.GetMePermissionsHandler(serverCtx),
},
}...,
),
rest.WithPrefix("/api/v1/permissions"),
)
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT},
[]rest.Route{
{
Method: http.MethodGet,
Path: "/:scope/:scope_id",
Handler: setting.ListSettingsHandler(serverCtx),
},
{
Method: http.MethodGet,
Path: "/:scope/:scope_id/:key",
Handler: setting.GetSettingHandler(serverCtx),
},
{
Method: http.MethodPut,
Path: "/:scope/:scope_id/:key",
Handler: setting.UpsertSettingHandler(serverCtx),
},
{
Method: http.MethodDelete,
Path: "/:scope/:scope_id/:key",
Handler: setting.DeleteSettingHandler(serverCtx),
},
}...,
),
rest.WithPrefix("/api/v1/settings"),
)
}

View File

@ -0,0 +1,29 @@
package setting
import (
"net/http"
"haixun-backend/internal/logic/setting"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func DeleteSettingHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.SettingKeyPath
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 := setting.NewDeleteSettingLogic(r.Context(), svcCtx)
err := l.DeleteSetting(&req)
response.Write(r.Context(), w, nil, err)
}
}

View File

@ -0,0 +1,29 @@
package setting
import (
"net/http"
"haixun-backend/internal/logic/setting"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func GetSettingHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.SettingKeyPath
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 := setting.NewGetSettingLogic(r.Context(), svcCtx)
data, err := l.GetSetting(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,29 @@
package setting
import (
"net/http"
"haixun-backend/internal/logic/setting"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func ListSettingsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.SettingPath
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 := setting.NewListSettingsLogic(r.Context(), svcCtx)
data, err := l.ListSettings(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,29 @@
package setting
import (
"net/http"
"haixun-backend/internal/logic/setting"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
func UpsertSettingHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req types.SettingUpsertReq
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 := setting.NewUpsertSettingLogic(r.Context(), svcCtx)
data, err := l.UpsertSetting(&req)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,20 @@
package authctx
import "context"
type Actor struct {
TenantID string
UID string
JTI string
}
type actorKey struct{}
func WithActor(ctx context.Context, actor Actor) context.Context {
return context.WithValue(ctx, actorKey{}, actor)
}
func ActorFromContext(ctx context.Context) (Actor, bool) {
actor, ok := ctx.Value(actorKey{}).(Actor)
return actor, ok
}

View File

@ -0,0 +1,36 @@
package clock
import "time"
// StorageTimezone is the only timezone used for persisted timestamps.
const StorageTimezone = "UTC"
// Now returns the current instant in UTC.
func Now() time.Time {
return time.Now().UTC()
}
// NowUnixNano returns the current instant as UTC unix nanoseconds.
func NowUnixNano() int64 {
return Now().UnixNano()
}
// UnixNano converts an instant to UTC unix nanoseconds.
func UnixNano(t time.Time) int64 {
return t.UTC().UnixNano()
}
// FromUnixNano parses UTC unix nanoseconds.
func FromUnixNano(nano int64) time.Time {
return time.Unix(0, nano).UTC()
}
// AddSecondsFromNow returns UTC unix nanoseconds after the given seconds.
func AddSecondsFromNow(seconds int) int64 {
return Now().Add(time.Duration(seconds) * time.Second).UnixNano()
}
// SecondsToNanos converts whole seconds to nanoseconds.
func SecondsToNanos(seconds int) int64 {
return int64(seconds) * int64(time.Second)
}

View File

@ -0,0 +1,24 @@
package clock
import (
"testing"
"time"
)
func TestNow_IsUTC(t *testing.T) {
now := Now()
if now.Location() != time.UTC {
t.Fatalf("location = %v, want UTC", now.Location())
}
}
func TestFromUnixNano_RoundTrip(t *testing.T) {
nano := NowUnixNano()
parsed := FromUnixNano(nano)
if parsed.UnixNano() != nano {
t.Fatalf("parsed = %d, want %d", parsed.UnixNano(), nano)
}
if parsed.Location() != time.UTC {
t.Fatalf("location = %v, want UTC", parsed.Location())
}
}

View File

@ -0,0 +1,39 @@
package code
type Scope uint32
type Category uint32
type Detail uint32
const (
Unset Scope = 0
Facade Scope = 10
Setting Scope = 32
AI Scope = 33
Job Scope = 34
Auth Scope = 35
Member Scope = 36
Permission Scope = 37
CategoryMultiplier = 1000
ScopeMultiplier = 1000000
DefaultDetail Detail = 0
)
const (
DefaultSuccessFullCodeInt int64 = 102000
DefaultSuccessMessage string = "SUCCESS"
)
const (
InputInvalidFormat Category = 101
InputMissingRequired Category = 104
DBError Category = 201
DBUnavailable Category = 204
ResNotFound Category = 301
ResConflict Category = 303
ResInvalidState Category = 309
AuthUnauthorized Category = 401
AuthForbidden Category = 505
SysInternal Category = 601
SysNotImplemented Category = 605
SvcThirdParty Category = 802
)

View File

@ -0,0 +1,169 @@
package errors
import (
"errors"
"fmt"
"net/http"
"haixun-backend/internal/library/errors/code"
)
type Error struct {
scope code.Scope
category code.Category
detail code.Detail
message string
cause error
}
func New(scope code.Scope, category code.Category, detail code.Detail, message string) *Error {
return &Error{scope: scope, category: category, detail: detail, message: message}
}
func FromError(err error) *Error {
var e *Error
if errors.As(err, &e) {
return e
}
return nil
}
func (e *Error) Error() string {
if e == nil {
return ""
}
return e.message
}
func (e *Error) Unwrap() error {
if e == nil {
return nil
}
return e.cause
}
func (e *Error) WithCause(cause error) *Error {
if e == nil {
return nil
}
cp := *e
cp.cause = cause
return &cp
}
func (e *Error) Scope() code.Scope {
if e == nil {
return code.Unset
}
return e.scope
}
func (e *Error) Category() code.Category {
if e == nil {
return code.SysInternal
}
return e.category
}
func (e *Error) Detail() code.Detail {
if e == nil {
return code.DefaultDetail
}
return e.detail
}
func (e *Error) Code() uint32 {
if e == nil {
return 0
}
return uint32(e.scope)*code.ScopeMultiplier + uint32(e.category)*code.CategoryMultiplier + uint32(e.detail)
}
func (e *Error) DisplayCode() string {
if e == nil {
return "00000000"
}
return fmt.Sprintf("%08d", e.Code())
}
func (e *Error) HTTPStatus() int {
if e == nil {
return http.StatusInternalServerError
}
switch e.category {
case code.InputInvalidFormat, code.InputMissingRequired:
return http.StatusBadRequest
case code.DBUnavailable:
return http.StatusServiceUnavailable
case code.ResNotFound:
return http.StatusNotFound
case code.ResConflict:
return http.StatusConflict
case code.ResInvalidState:
return http.StatusConflict
case code.AuthUnauthorized:
return http.StatusUnauthorized
case code.AuthForbidden:
return http.StatusForbidden
case code.SvcThirdParty:
return http.StatusBadGateway
default:
return http.StatusInternalServerError
}
}
type Builder struct {
scope code.Scope
}
func For(scope code.Scope) Builder {
return Builder{scope: scope}
}
func (b Builder) InputInvalidFormat(message string) *Error {
return New(b.scope, code.InputInvalidFormat, 0, message)
}
func (b Builder) InputMissingRequired(message string) *Error {
return New(b.scope, code.InputMissingRequired, 0, message)
}
func (b Builder) DBError(message string) *Error {
return New(b.scope, code.DBError, 0, message)
}
func (b Builder) DBUnavailable(message string) *Error {
return New(b.scope, code.DBUnavailable, 0, message)
}
func (b Builder) ResNotFound(message string) *Error {
return New(b.scope, code.ResNotFound, 0, message)
}
func (b Builder) ResConflict(message string) *Error {
return New(b.scope, code.ResConflict, 0, message)
}
func (b Builder) ResInvalidState(message string) *Error {
return New(b.scope, code.ResInvalidState, 0, message)
}
func (b Builder) AuthUnauthorized(message string) *Error {
return New(b.scope, code.AuthUnauthorized, 0, message)
}
func (b Builder) AuthForbidden(message string) *Error {
return New(b.scope, code.AuthForbidden, 0, message)
}
func (b Builder) SysInternal(message string) *Error {
return New(b.scope, code.SysInternal, 0, message)
}
func (b Builder) SysNotImplemented(message string) *Error {
return New(b.scope, code.SysNotImplemented, 0, message)
}
func (b Builder) SvcThirdParty(message string) *Error {
return New(b.scope, code.SvcThirdParty, 0, message)
}

View File

@ -0,0 +1,51 @@
package mongo
import (
"context"
"time"
"haixun-backend/internal/config"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
type Client struct {
raw *mongo.Client
db *mongo.Database
}
func NewClient(ctx context.Context, conf config.MongoConf) (*Client, error) {
if conf.URI == "" || conf.Database == "" {
return nil, nil
}
timeout := time.Duration(conf.TimeoutSeconds) * time.Second
if timeout <= 0 {
timeout = 10 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
raw, err := mongo.Connect(ctx, options.Client().ApplyURI(conf.URI))
if err != nil {
return nil, err
}
if err := raw.Ping(ctx, nil); err != nil {
return nil, err
}
return &Client{raw: raw, db: raw.Database(conf.Database)}, nil
}
func (c *Client) Database() *mongo.Database {
if c == nil {
return nil
}
return c.db
}
func (c *Client) Close(ctx context.Context) error {
if c == nil || c.raw == nil {
return nil
}
return c.raw.Disconnect(ctx)
}

View File

@ -0,0 +1,17 @@
package redact
import "regexp"
var (
bearerPattern = regexp.MustCompile(`(?i)Bearer\s+[A-Za-z0-9._\-]+`)
tokenPattern = regexp.MustCompile(`(?i)(api[_-]?key|token|authorization)\s*[:=]\s*["']?[^"'\s,}]+`)
)
func Message(message string) string {
if message == "" {
return message
}
message = bearerPattern.ReplaceAllString(message, "Bearer [REDACTED]")
message = tokenPattern.ReplaceAllString(message, "$1=[REDACTED]")
return message
}

View File

@ -0,0 +1,12 @@
package redact
import "testing"
func TestMessage_RedactsBearerToken(t *testing.T) {
input := `Authorization Bearer sk-abc123xyz failed`
got := Message(input)
want := `Authorization Bearer [REDACTED] failed`
if got != want {
t.Fatalf("Message() = %q, want %q", got, want)
}
}

View File

@ -0,0 +1,17 @@
package redis
import (
"haixun-backend/internal/config"
goredis "github.com/redis/go-redis/v9"
)
func NewClient(conf config.RedisConf) *goredis.Client {
if conf.Addr == "" {
return nil
}
return goredis.NewClient(&goredis.Options{
Addr: conf.Addr,
DB: conf.DB,
})
}

View File

@ -0,0 +1,18 @@
package validate
import "github.com/go-playground/validator/v10"
type Validate struct {
validator *validator.Validate
}
func New() *Validate {
return &Validate{validator: validator.New()}
}
func (v *Validate) ValidateAll(value any) error {
if v == nil || v.validator == nil {
return nil
}
return v.validator.Struct(value)
}

View File

@ -0,0 +1,26 @@
package ai
import (
"context"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
type ChatLogic struct {
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewChatLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ChatLogic {
return &ChatLogic{ctx: ctx, svcCtx: svcCtx}
}
func (l *ChatLogic) Chat(req *types.AIChatReq, token string) (*types.AIChatData, error) {
result, err := l.svcCtx.AI.GenerateText(l.ctx, toGenerateRequest(req, token))
if err != nil {
return nil, err
}
return &types.AIChatData{Text: result.Text, FinishReason: result.FinishReason}, nil
}

View File

@ -0,0 +1,22 @@
package ai
import (
"context"
domai "haixun-backend/internal/model/ai/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
type ChatStreamLogic struct {
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewChatStreamLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ChatStreamLogic {
return &ChatStreamLogic{ctx: ctx, svcCtx: svcCtx}
}
func (l *ChatStreamLogic) ChatStream(req *types.AIChatReq, token string) (<-chan domai.StreamEvent, error) {
return l.svcCtx.AI.StreamText(l.ctx, toGenerateRequest(req, token))
}

View File

@ -0,0 +1,27 @@
package ai
import (
"net/http"
"strings"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
)
func BearerToken(r *http.Request) (string, error) {
auth := strings.TrimSpace(r.Header.Get("Authorization"))
if auth == "" {
return "", app.For(code.AI).InputMissingRequired("missing Authorization header")
}
const prefix = "Bearer "
if !strings.HasPrefix(auth, prefix) {
return "", app.For(code.Facade).InputInvalidFormat("Authorization must be a Bearer token")
}
token := strings.TrimSpace(strings.TrimPrefix(auth, prefix))
if token == "" {
return "", app.For(code.AI).InputMissingRequired("missing AI provider token")
}
return token, nil
}

View File

@ -0,0 +1,30 @@
package ai
import (
"context"
"haixun-backend/internal/model/ai/domain/enum"
aiuc "haixun-backend/internal/model/ai/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
type ListAIProviderModelsLogic struct {
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewListAIProviderModelsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListAIProviderModelsLogic {
return &ListAIProviderModelsLogic{ctx: ctx, svcCtx: svcCtx}
}
func (l *ListAIProviderModelsLogic) ListAIProviderModels(req *types.AIProviderPath, token string) (*types.AIProviderModelsData, error) {
result := l.svcCtx.AI.ListProviderModels(l.ctx, enum.ProviderID(req.Provider), aiuc.Credential{APIKey: token})
return &types.AIProviderModelsData{
ID: result.ID,
Label: result.Label,
Models: result.Models,
Streams: result.Streams,
Error: result.Error,
}, nil
}

View File

@ -0,0 +1,30 @@
package ai
import (
"context"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
type ListAIProvidersLogic struct {
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewListAIProvidersLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListAIProvidersLogic {
return &ListAIProvidersLogic{ctx: ctx, svcCtx: svcCtx}
}
func (l *ListAIProvidersLogic) ListAIProviders() (*types.AIProvidersData, error) {
options := l.svcCtx.AI.ListProviders(l.ctx)
items := make([]types.AIProviderOption, 0, len(options))
for _, option := range options {
items = append(items, types.AIProviderOption{
ID: option.ID,
Label: option.Label,
Streams: option.Streams,
})
}
return &types.AIProvidersData{Providers: items}, nil
}

View File

@ -0,0 +1,28 @@
package ai
import (
"haixun-backend/internal/model/ai/domain/enum"
domai "haixun-backend/internal/model/ai/domain/usecase"
"haixun-backend/internal/types"
)
func toGenerateRequest(req *types.AIChatReq, token string) domai.GenerateRequest {
messages := make([]domai.Message, 0, len(req.Messages))
for _, msg := range req.Messages {
messages = append(messages, domai.Message{
Role: msg.Role,
Content: msg.Content,
})
}
return domai.GenerateRequest{
Provider: enum.ProviderID(req.Provider),
Model: req.Model,
Credential: domai.Credential{
APIKey: token,
},
System: req.System,
Messages: messages,
Temperature: req.Temperature,
MaxTokens: req.MaxTokens,
}
}

View File

@ -0,0 +1,27 @@
package auth
import (
"context"
memberusecase "haixun-backend/internal/model/member/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
type LoginLogic struct {
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LoginLogic {
return &LoginLogic{ctx: ctx, svcCtx: svcCtx}
}
func (l *LoginLogic) Login(req *types.AuthLoginReq) (*types.AuthTokenData, error) {
_, token, err := l.svcCtx.Member.Login(l.ctx, memberusecase.LoginRequest{
TenantID: req.TenantID,
Email: req.Email,
Password: req.Password,
})
return toAuthTokenData(token), err
}

View File

@ -0,0 +1,29 @@
package auth
import (
"context"
"haixun-backend/internal/middleware"
authusecase "haixun-backend/internal/model/auth/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
type LogoutLogic struct {
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewLogoutLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LogoutLogic {
return &LogoutLogic{ctx: ctx, svcCtx: svcCtx}
}
func (l *LogoutLogic) Logout(authorization string) (*types.LogoutData, error) {
err := l.svcCtx.AuthToken.Logout(l.ctx, authusecase.LogoutRequest{
AccessToken: middleware.BearerToken(authorization),
})
if err != nil {
return nil, err
}
return &types.LogoutData{OK: true}, nil
}

View File

@ -0,0 +1,19 @@
package auth
import (
memberusecase "haixun-backend/internal/model/member/domain/usecase"
"haixun-backend/internal/types"
)
func toAuthTokenData(token *memberusecase.AuthToken) *types.AuthTokenData {
if token == nil {
return nil
}
return &types.AuthTokenData{
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
ExpiresIn: token.ExpiresIn,
UID: token.UID,
TokenType: token.TokenType,
}
}

View File

@ -0,0 +1,31 @@
package auth
import (
"context"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
type RefreshLogic struct {
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewRefreshLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RefreshLogic {
return &RefreshLogic{ctx: ctx, svcCtx: svcCtx}
}
func (l *RefreshLogic) Refresh(req *types.AuthRefreshReq) (*types.AuthTokenData, error) {
pair, err := l.svcCtx.AuthToken.Refresh(l.ctx, req.RefreshToken)
if err != nil {
return nil, err
}
return &types.AuthTokenData{
AccessToken: pair.AccessToken,
RefreshToken: pair.RefreshToken,
ExpiresIn: pair.ExpiresIn,
UID: pair.UID,
TokenType: pair.TokenType,
}, nil
}

View File

@ -0,0 +1,29 @@
package auth
import (
"context"
memberusecase "haixun-backend/internal/model/member/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
type RegisterLogic struct {
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewRegisterLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RegisterLogic {
return &RegisterLogic{ctx: ctx, svcCtx: svcCtx}
}
func (l *RegisterLogic) Register(req *types.AuthRegisterReq) (*types.AuthTokenData, error) {
_, token, err := l.svcCtx.Member.Register(l.ctx, memberusecase.RegisterRequest{
TenantID: req.TenantID,
Email: req.Email,
Password: req.Password,
DisplayName: req.DisplayName,
Language: req.Language,
})
return toAuthTokenData(token), err
}

View File

@ -0,0 +1,30 @@
package job
import (
"context"
domusecase "haixun-backend/internal/model/job/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
type CancelJobLogic struct {
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewCancelJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CancelJobLogic {
return &CancelJobLogic{ctx: ctx, svcCtx: svcCtx}
}
func (l *CancelJobLogic) CancelJob(req *types.CancelJobReq) (*types.JobData, error) {
run, err := l.svcCtx.Job.RequestCancel(l.ctx, domusecase.CancelRunRequest{
JobID: req.ID,
Reason: req.Reason,
})
if err != nil {
return nil, err
}
data := toJobData(run)
return &data, nil
}

View File

@ -0,0 +1,32 @@
package job
import (
"context"
domusecase "haixun-backend/internal/model/job/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
type CreateJobLogic struct {
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewCreateJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateJobLogic {
return &CreateJobLogic{ctx: ctx, svcCtx: svcCtx}
}
func (l *CreateJobLogic) CreateJob(req *types.CreateJobReq) (*types.JobData, error) {
run, err := l.svcCtx.Job.CreateRun(l.ctx, domusecase.CreateRunRequest{
TemplateType: req.TemplateType,
Scope: req.Scope,
ScopeID: req.ScopeID,
Payload: req.Payload,
})
if err != nil {
return nil, err
}
data := toJobData(run)
return &data, nil
}

View File

@ -0,0 +1,35 @@
package job
import (
"context"
domusecase "haixun-backend/internal/model/job/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
type CreateJobScheduleLogic struct {
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewCreateJobScheduleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateJobScheduleLogic {
return &CreateJobScheduleLogic{ctx: ctx, svcCtx: svcCtx}
}
func (l *CreateJobScheduleLogic) CreateJobSchedule(req *types.CreateJobScheduleReq) (*types.JobScheduleData, error) {
schedule, err := l.svcCtx.Job.CreateSchedule(l.ctx, domusecase.CreateScheduleRequest{
TemplateType: req.TemplateType,
Scope: req.Scope,
ScopeID: req.ScopeID,
Cron: req.Cron,
Timezone: req.Timezone,
PayloadTemplate: req.PayloadTemplate,
Enabled: req.Enabled,
})
if err != nil {
return nil, err
}
data := toJobScheduleData(schedule)
return &data, nil
}

View File

@ -0,0 +1,26 @@
package job
import (
"context"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
type DisableJobScheduleLogic struct {
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewDisableJobScheduleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DisableJobScheduleLogic {
return &DisableJobScheduleLogic{ctx: ctx, svcCtx: svcCtx}
}
func (l *DisableJobScheduleLogic) DisableJobSchedule(req *types.JobScheduleIDPath) (*types.JobScheduleData, error) {
schedule, err := l.svcCtx.Job.DisableSchedule(l.ctx, req.ID)
if err != nil {
return nil, err
}
data := toJobScheduleData(schedule)
return &data, nil
}

View File

@ -0,0 +1,26 @@
package job
import (
"context"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
type EnableJobScheduleLogic struct {
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewEnableJobScheduleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *EnableJobScheduleLogic {
return &EnableJobScheduleLogic{ctx: ctx, svcCtx: svcCtx}
}
func (l *EnableJobScheduleLogic) EnableJobSchedule(req *types.JobScheduleIDPath) (*types.JobScheduleData, error) {
schedule, err := l.svcCtx.Job.EnableSchedule(l.ctx, req.ID)
if err != nil {
return nil, err
}
data := toJobScheduleData(schedule)
return &data, nil
}

View File

@ -0,0 +1,26 @@
package job
import (
"context"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
type GetJobLogic struct {
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGetJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetJobLogic {
return &GetJobLogic{ctx: ctx, svcCtx: svcCtx}
}
func (l *GetJobLogic) GetJob(req *types.JobIDPath) (*types.JobData, error) {
run, err := l.svcCtx.Job.GetRun(l.ctx, req.ID)
if err != nil {
return nil, err
}
data := toJobData(run)
return &data, nil
}

View File

@ -0,0 +1,26 @@
package job
import (
"context"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
type GetJobTemplateLogic struct {
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGetJobTemplateLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetJobTemplateLogic {
return &GetJobTemplateLogic{ctx: ctx, svcCtx: svcCtx}
}
func (l *GetJobTemplateLogic) GetJobTemplate(req *types.JobTemplatePath) (*types.JobTemplateData, error) {
template, err := l.svcCtx.Job.GetTemplate(l.ctx, req.Type)
if err != nil {
return nil, err
}
data := toJobTemplateData(template)
return &data, nil
}

View File

@ -0,0 +1,33 @@
package job
import (
"context"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
type ListJobEventsLogic struct {
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewListJobEventsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListJobEventsLogic {
return &ListJobEventsLogic{ctx: ctx, svcCtx: svcCtx}
}
func (l *ListJobEventsLogic) ListJobEvents(req *types.ListJobEventsReq) (*types.JobEventListData, error) {
limit := req.Limit
if limit <= 0 {
limit = 50
}
events, err := l.svcCtx.Job.ListJobEvents(l.ctx, req.ID, limit)
if err != nil {
return nil, err
}
list := make([]types.JobEventData, 0, len(events))
for _, event := range events {
list = append(list, toJobEventData(event))
}
return &types.JobEventListData{List: list}, nil
}

View File

@ -0,0 +1,37 @@
package job
import (
"context"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
type ListJobSchedulesLogic struct {
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewListJobSchedulesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListJobSchedulesLogic {
return &ListJobSchedulesLogic{ctx: ctx, svcCtx: svcCtx}
}
func (l *ListJobSchedulesLogic) ListJobSchedules(req *types.ListJobSchedulesReq) (*types.JobScheduleListData, error) {
items, total, page, pageSize, totalPages, err := l.svcCtx.Job.ListSchedules(l.ctx, req.Scope, req.ScopeID, req.Page, req.PageSize)
if err != nil {
return nil, err
}
list := make([]types.JobScheduleData, 0, len(items))
for _, item := range items {
list = append(list, toJobScheduleData(item))
}
return &types.JobScheduleListData{
Pagination: types.PaginationData{
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: totalPages,
},
List: list,
}, nil
}

View File

@ -0,0 +1,29 @@
package job
import (
"context"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
type ListJobTemplatesLogic struct {
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewListJobTemplatesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListJobTemplatesLogic {
return &ListJobTemplatesLogic{ctx: ctx, svcCtx: svcCtx}
}
func (l *ListJobTemplatesLogic) ListJobTemplates() (*types.JobTemplateListData, error) {
templates, err := l.svcCtx.Job.ListTemplates(l.ctx)
if err != nil {
return nil, err
}
list := make([]types.JobTemplateData, 0, len(templates))
for _, template := range templates {
list = append(list, toJobTemplateData(template))
}
return &types.JobTemplateListData{List: list}, nil
}

View File

@ -0,0 +1,37 @@
package job
import (
"context"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
type ListJobsLogic struct {
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewListJobsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListJobsLogic {
return &ListJobsLogic{ctx: ctx, svcCtx: svcCtx}
}
func (l *ListJobsLogic) ListJobs(req *types.ListJobsReq) (*types.JobListData, error) {
runs, total, page, pageSize, totalPages, err := l.svcCtx.Job.ListRuns(l.ctx, req.Scope, req.ScopeID, req.Page, req.PageSize)
if err != nil {
return nil, err
}
list := make([]types.JobData, 0, len(runs))
for _, run := range runs {
list = append(list, toJobData(run))
}
return &types.JobListData{
Pagination: types.PaginationData{
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: totalPages,
},
List: list,
}, nil
}

View File

@ -0,0 +1,123 @@
package job
import (
"haixun-backend/internal/model/job/domain/entity"
"haixun-backend/internal/types"
)
func toJobTemplateData(template *entity.Template) types.JobTemplateData {
steps := make([]types.JobTemplateStepData, 0, len(template.Steps))
for _, step := range template.Steps {
steps = append(steps, types.JobTemplateStepData{
ID: step.ID,
Name: step.Name,
WorkerType: step.WorkerType,
TimeoutSeconds: step.TimeoutSeconds,
Cancelable: step.Cancelable,
})
}
return types.JobTemplateData{
Type: template.Type,
Version: template.Version,
Name: template.Name,
Description: template.Description,
Enabled: template.Enabled,
Repeatable: template.Repeatable,
ConcurrencyPolicy: template.ConcurrencyPolicy,
DedupeKeys: template.DedupeKeys,
TimeoutSeconds: template.TimeoutSeconds,
CancelPolicy: types.JobCancelPolicyData{
Supported: template.CancelPolicy.Supported,
Mode: template.CancelPolicy.Mode,
GraceSeconds: template.CancelPolicy.GraceSeconds,
},
RetryPolicy: types.JobRetryPolicyData{
MaxAttempts: template.RetryPolicy.MaxAttempts,
BackoffSeconds: template.RetryPolicy.BackoffSeconds,
},
Steps: steps,
}
}
func toJobData(run *entity.Run) types.JobData {
steps := make([]types.JobStepProgressData, 0, len(run.Progress.Steps))
for _, step := range run.Progress.Steps {
steps = append(steps, types.JobStepProgressData{
ID: step.ID,
Status: string(step.Status),
StartedAt: step.StartedAt,
EndedAt: step.EndedAt,
Message: step.Message,
})
}
return types.JobData{
ID: run.ID.Hex(),
TemplateType: run.TemplateType,
TemplateVersion: run.TemplateVersion,
Scope: run.Scope,
ScopeID: run.ScopeID,
Status: string(run.Status),
Phase: run.Phase,
WorkerType: run.WorkerType,
Payload: run.Payload,
Progress: types.JobProgressData{
Summary: run.Progress.Summary,
Percentage: run.Progress.Percentage,
Steps: steps,
},
Result: run.Result,
Error: run.Error,
Attempt: run.Attempt,
MaxAttempts: run.MaxAttempts,
CancelRequestedAt: run.CancelRequestedAt,
CancelReason: run.CancelReason,
StartedAt: run.StartedAt,
CompletedAt: run.CompletedAt,
CreateAt: run.CreateAt,
UpdateAt: run.UpdateAt,
}
}
func toJobScheduleData(schedule *entity.Schedule) types.JobScheduleData {
return types.JobScheduleData{
ID: schedule.ID.Hex(),
TemplateType: schedule.TemplateType,
Scope: schedule.Scope,
ScopeID: schedule.ScopeID,
Enabled: schedule.Enabled,
Cron: schedule.Cron,
Timezone: schedule.Timezone,
PayloadTemplate: schedule.PayloadTemplate,
LastRunAt: schedule.LastRunAt,
NextRunAt: schedule.NextRunAt,
CreateAt: schedule.CreateAt,
UpdateAt: schedule.UpdateAt,
}
}
func toJobEventData(event *entity.Event) types.JobEventData {
return types.JobEventData{
ID: event.ID.Hex(),
JobID: event.JobID,
Type: event.Type,
From: event.From,
To: event.To,
Message: event.Message,
Metadata: event.Metadata,
CreateAt: event.CreateAt,
}
}
func toTemplateSteps(steps []types.JobTemplateStepData) []entity.TemplateStep {
out := make([]entity.TemplateStep, 0, len(steps))
for _, step := range steps {
out = append(out, entity.TemplateStep{
ID: step.ID,
Name: step.Name,
WorkerType: step.WorkerType,
TimeoutSeconds: step.TimeoutSeconds,
Cancelable: step.Cancelable,
})
}
return out
}

View File

@ -0,0 +1,26 @@
package job
import (
"context"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
type RetryJobLogic struct {
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewRetryJobLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RetryJobLogic {
return &RetryJobLogic{ctx: ctx, svcCtx: svcCtx}
}
func (l *RetryJobLogic) RetryJob(req *types.JobIDPath) (*types.JobData, error) {
run, err := l.svcCtx.Job.RetryRun(l.ctx, req.ID)
if err != nil {
return nil, err
}
data := toJobData(run)
return &data, nil
}

View File

@ -0,0 +1,33 @@
package job
import (
"context"
domusecase "haixun-backend/internal/model/job/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
)
type UpdateJobScheduleLogic struct {
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewUpdateJobScheduleLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateJobScheduleLogic {
return &UpdateJobScheduleLogic{ctx: ctx, svcCtx: svcCtx}
}
func (l *UpdateJobScheduleLogic) UpdateJobSchedule(req *types.UpdateJobScheduleReq) (*types.JobScheduleData, error) {
schedule, err := l.svcCtx.Job.UpdateSchedule(l.ctx, domusecase.UpdateScheduleRequest{
ID: req.ID,
Cron: req.Cron,
Timezone: req.Timezone,
PayloadTemplate: req.PayloadTemplate,
Enabled: req.Enabled,
})
if err != nil {
return nil, err
}
data := toJobScheduleData(schedule)
return &data, nil
}

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