diff --git a/.gitignore b/.gitignore index d78f071..2242334 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ node_modules/ # --- OS / editor --- .DS_Store .cursor/ + +# deploy runtime +deploy/run/ diff --git a/AGENTS.md b/AGENTS.md index 2f707f9..1029d47 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,9 @@ ## 封存與主線 - **`old/`**:舊系統整包封存,**只讀參考**。不要在 `old/` 修功能當交付。 -- **新碼**:只寫在 repo 根下新目錄(`apps/`、`services/` 等,以 `docs/product` 為準)。 +- **新碼**:只寫在 **`apps/`** 下(前端 `apps/web`、後端 `apps/backend` 等,以 `docs/product` 為準)。 +- **後端落點**:Phase C 巡樓 API/worker 在 **`apps/backend`**(go-zero;由 `stand-alone-service` 遷入改造)。 +- **後端寫法(硬性)**:一律 **goctl**:改 `generate/api/*.api` → `make gen-api` → 業務只寫 `internal/logic/**` 與 `internal/module/**`。**禁止**手寫 `handler`/`routes.go` 當主路徑。 - **產品真相**:`docs/product//`(requirements → spec → plan → tasks)。 - **流程**:`/spec-driven`(`.grok/skills/spec-driven/`)。 diff --git a/README.md b/README.md index d6fc5ec..9fcfa9e 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,16 @@ npm run dev # 本機開發,監聽 0.0.0.0:5173(區網可連) npm run build # 型別檢查 + 生產建置 ``` +## 部署(dev 一鍵清乾淨重裝) + +見 [`deploy/README.md`](./deploy/README.md)。 + +```bash +cp deploy/.env.example deploy/.env.dev # 首次:填密鑰 +./deploy/scripts/redeploy.sh --yes --kill-orphan +# 可選 nginx:./deploy/scripts/install-nginx.sh +``` + 產品任務進度:`docs/product/haixun-console/tasks/INDEX.md`(目前從 T001 起)。 ## 開發流程 diff --git a/apps/backend/.gitignore b/apps/backend/.gitignore new file mode 100644 index 0000000..f9f2371 --- /dev/null +++ b/apps/backend/.gitignore @@ -0,0 +1,6 @@ +/bin/ +*.exe +*.test +coverage.out +.data/ +uploads/ diff --git a/apps/backend/MIGRATION.md b/apps/backend/MIGRATION.md new file mode 100644 index 0000000..fa2c55d --- /dev/null +++ b/apps/backend/MIGRATION.md @@ -0,0 +1,7 @@ +# 遷入說明(精簡) + +- 骨架參考 `stand-alone-service`(go-zero module 分層、lib/mongo 精神)。 +- **不**整包 rsync 電商與 `code.30cm.net` 私有依賴。 +- API:`generate/api` → goctl。 +- DB:`generate/database/mongo` → golang-migrate。 +- 執行體:`gateway` + `cmd/worker` only。 diff --git a/apps/backend/Makefile b/apps/backend/Makefile new file mode 100644 index 0000000..6f8d88a --- /dev/null +++ b/apps/backend/Makefile @@ -0,0 +1,27 @@ +# 只保留會重複打的指令;其餘直接 go / migrate。 + +.PHONY: build test gen-api tidy migrate-up migrate-down + +build: + go build -o bin/gateway . + go build -o bin/worker ./cmd/worker + +test: + go test ./... + +# 改 generate/api/*.api 後執行;handler 勿手寫 +gen-api: + goctl api go -api generate/api/gateway.api -dir . -style go_zero --home generate/goctl + +tidy: + go mod tidy + +# DB:檔案在 generate/database/mongo;需本機已裝 migrate(mongodb tag) +# 例:MONGO_URL=mongodb://127.0.0.1:27017/haixun +MONGO_URL ?= mongodb://127.0.0.1:27017/haixun + +migrate-up: + migrate -path generate/database/mongo -database "$(MONGO_URL)" up + +migrate-down: + migrate -path generate/database/mongo -database "$(MONGO_URL)" down 1 diff --git a/apps/backend/README.md b/apps/backend/README.md new file mode 100644 index 0000000..138a10a --- /dev/null +++ b/apps/backend/README.md @@ -0,0 +1,193 @@ +# apps/backend + +Harbor Desk Phase C(go-zero)。 + +## 原則 + +| 做 | 不做 | +|----|------| +| `generate/api` + `make gen-api` | 手寫 handler/routes | +| `generate/database/mongo` + `make migrate-up` | `cmd/init`、`cmd/seeder` 程式遷移 | +| `monc` + `CacheRedis` | 自己半套 mongo cache | +| `internal/logic` + `module/*/domain|repository|usecase` | 業務塞進 handler | + +## 指令 + +```bash +cd apps/backend +make build # gateway + worker +make test +make gen-api # 改 .api 後 +make migrate-up # 改 generate/database 後 +make tidy +``` + +```bash +# 需要 Mongo + Redis(go-zero monc / redis) +# Mongo 若有帳密(本機 infra 常見),一定要帶進 URI: +export MONGO_URI='mongodb://USER:PASS@127.0.0.1:27017/?authSource=admin' +export MONGO_URL="$MONGO_URI" # migrate 用 +make migrate-up +go run . -f etc/gateway.yaml +# admin@haixun.local / admin123(seed,uid=1000000 → 顯示 01000000) +``` + +### 日誌與密碼(安全) + +| 禁止出現在 log | 說明 | +|----------------|------| +| `password` / `new_password` / `current_password` / `temporary_password` | 明碼 | +| `password_hash` | bcrypt 也不可打 log | +| access/refresh token、api_key | 憑證 | + +實作: + +- 啟動時 `securelog.SilenceMongo()` → 關閉 go-zero **mon** 文件 dump(否則 ReplaceOne 會印出整份 member) +- `RestConf.Verbose = false` → 不印 request body +- 自寫 log 請用 `securelog.RedactAny(v)` / 勿 `logx.Infof("%+v", req)` + +HTTP access log 只記 method/path/status/duration,不含 body。 + +### 物件儲存(頭像)— **僅 MinIO/S3** + +不落本機磁碟(無 localstore / `/files`)。未設定 Endpoint 時 upload 直接失敗。 + +| 項目 | 說明 | +|------|------| +| API | `POST /api/v1/media/upload`(需 JWT)`{ content: dataURL, kind: "avatar" }` | +| 回傳 | `{ url, key }` — Mongo member **只存 url** | +| 後端 | MinIO(dev)或 S3(prod),S3 API + path-style | +| env | `HAIXUN_STORAGE_S3_*` 或 `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` | + +```yaml +ObjectStorage: + Endpoint: "http://127.0.0.1:9000" # 必填 + Bucket: "haixun-assets" + AccessKey: "haixun" + SecretKey: "..." + UsePathStyle: true + PublicBaseURL: "" # 或 CDN / 反向代理公開前綴 +``` + +FE:選頭像 → upload → URL → `PATCH profile`(禁止 data URL)。 + +### 密碼政策(前後端一致) + +| 規則 | 說明 | +|------|------| +| 長度 | **≥ 12** 字元 | +| 組成 | **大寫 + 小寫 + 數字 + 符號** 皆須至少一個 | +| 後端 | `ValidatePasswordPolicy` → 不符回 **400003** + `domain.PasswordPolicyEN` | +| 前端 | `passwordPolicy.ts` + 語言包 `password.policy.hint` / `api.err.400003` | + +**同一句說明:** + +- EN: `Password must be at least 12 characters with upper, lower, digit, and symbol` +- ZH: `密碼須至少 12 碼,且含大寫、小寫、數字與符號` + +前端依 code 400003 顯示語系文案;placeholder/hint/錯誤都用同一 key,勿另寫一句。 + +### 寄信(信箱驗證 / 重設密碼) + +業務在 `internal/logic/auth/*`:產碼 → `Notification.RenderVerify` → `SendEmail`。 +寄送實作在 `internal/module/notification`(SMTP,Mailgun 相容)。 + +#### 模板與多語 + +- 引擎與 stand-alone 相同:**hermes**(`github.com/matcornic/hermes/v2`) +- 語系:`zh-TW`(預設)/`en`;來源為會員 `preferred_language`,忘記密碼則用請求 `language`(FE 當前 locale) +- Logo:`Brand.LogoURL` 或預設 `{PublicWebBase}/brand-mark.jpg`(`apps/web/public/brand-mark.jpg`) + +```yaml +PublicWebBase: "http://127.0.0.1:5173" +Brand: + Name: "Harbor Desk" + LogoURL: "" # 空 = PublicWebBase/brand-mark.jpg + Copyright: "© Harbor Desk" +``` + +#### 本機:Mailpit(推薦) + +```bash +# 已含在 old/infra docker-compose(mongo/redis/mailpit) +cd old/infra && docker compose --env-file ../deploy/.env.dev up -d mailpit + +# UI 看信 +open http://127.0.0.1:8025 +``` + +| 項目 | 值 | +|------|-----| +| SMTP | `127.0.0.1:1025`(無密碼也可;可開 AUTH accept-any 模擬 Mailgun) | +| Web UI | http://127.0.0.1:8025 | +| gateway 預設 | `Mail.SMTP.Host=127.0.0.1` `Port=1025` | + +呼叫 `POST /api/v1/auth/email/send-code` 或 `password/request-reset` 後,到 Mailpit UI 看 HTML 信。 +#### 正式:Mailgun SMTP + +```yaml +Mail: + Sender: "Harbor Desk " + SMTP: + Host: "smtp.mailgun.org" + Port: 587 + User: "postmaster@YOUR_DOMAIN" + Password: "" + DevExposeCode: false +``` + +同一套 `Mail.SMTP` 設定;本機指 Mailpit、上線指 Mailgun,**code 不用改**。 + +env 覆寫:`MAIL_SMTP_*` 或 `HAIXUN_SMTP_*`(與 `old/deploy/.env.dev` 相容)。 + +| SMTP Host | 行為 | +|-----------|------| +| **空** | log-only,不真寄 | +| **Mailpit / Mailgun** | 真走 SMTP 投遞 | + +### 前後端串不起來?檢查這幾項 + +1. **gateway 有在跑** `ss -tlnp | grep 8888`;沒有就 `go run . -f etc/gateway.yaml` +2. **Mongo URI 有帳密**(`export MONGO_URI=...`;沒帶會查不到 seed) +3. **Redis 有密碼**(`export REDIS_PASSWORD=...`;沒帶 monc 回 `NOAUTH` → API 500) +4. **已跑 migrate**(`make migrate-up`,seed `admin@haixun.local`) +5. **FE 切到 live**:設定 → 資料來源 → Live(預設是 **mock**,根本不會打後端) +6. **API base**:`apps/web/.env` 建議 `VITE_API_BASE=`(空),走 Vite proxy `/api` → `:8888` + 遠端開 `*.30cm.net` 時**不要**寫死 `http://127.0.0.1:8888`(那會打到使用者自己電腦) +7. 切 live 後**重新登入** seed:`admin@haixun.local` / `admin123` + +| 資料 | 存哪 | 關機 | +|------|------|------| +| 會員 / settings | **Mongo**(monc) | 保留 | +| refresh token / 驗證碼 | **Mongo**(TTL index) | 保留(過期自動清) | +| monc 實體快取 | Redis(CacheRedis) | 可丟,會從 Mongo 回填 | + +## 目錄 + +```text +generate/api/ # goctl 契約 +generate/database/mongo # golang-migrate JSON +generate/goctl/ # handler 模板(102000) +cmd/worker/ # 獨立 worker +internal/handler|logic # goctl 產 / 薄邏輯 +internal/module/ # domain · repository · usecase +internal/lib/mongo # monc URI 小工具 +etc/gateway.yaml # 唯一設定 +``` + +## 設定摘要 + +```yaml +CacheRedis: # monc Redis 快取 + - Host: 127.0.0.1:6379 + Type: node +Mongo: + URI: "mongodb://127.0.0.1:27017" # 有 auth 用 MONGO_URI 覆寫 + Database: "haixun" +``` + +### 會員 uid + +- 從 **1_000_000** 起(seed admin = `1000000`) +- 顯示補成 **8 位**:`01000000`(`%08d` / FE `padStart(8,"0")`) +- 新註冊:`1000001`, `1000002`, … diff --git a/apps/backend/cmd/worker/main.go b/apps/backend/cmd/worker/main.go new file mode 100644 index 0000000..9a0f567 --- /dev/null +++ b/apps/backend/cmd/worker/main.go @@ -0,0 +1,55 @@ +package main + +import ( + "flag" + "fmt" + "os" + "os/signal" + "syscall" + "time" + + "apps/backend/internal/config" + + "github.com/zeromicro/go-zero/core/conf" + "github.com/zeromicro/go-zero/core/logx" +) + +// Independent worker binary (A-03). M0 stub: config load + heartbeat log only. +// Outbox/job runners land in M2/M4 (T133/T169). +func main() { + configFile := flag.String("f", "etc/gateway.yaml", "config file") + flag.Parse() + + var c config.Config + conf.MustLoad(*configFile, &c) + c.ApplyEnv() + + workerID := c.Worker.ID + if workerID == "" { + workerID = "worker-1" + } + interval := time.Duration(c.Worker.PollIntervalMs) * time.Millisecond + if interval <= 0 { + interval = 2 * time.Second + } + + logx.Infof("haixun worker stub started id=%s interval=%s (no publish yet)", workerID, interval) + fmt.Printf("worker stub running id=%s (Ctrl+C to stop)\n", workerID) + + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + + tick := time.NewTicker(interval) + defer tick.Stop() + + for { + select { + case <-sig: + logx.Infof("worker %s shutting down", workerID) + return + case t := <-tick.C: + // Health heartbeat only — no job/outbox processing in M0. + logx.Infof("worker %s heartbeat at %s", workerID, t.UTC().Format(time.RFC3339)) + } + } +} diff --git a/apps/backend/etc/gateway.yaml b/apps/backend/etc/gateway.yaml new file mode 100644 index 0000000..b4858bc --- /dev/null +++ b/apps/backend/etc/gateway.yaml @@ -0,0 +1,108 @@ +# 巡樓 gateway — 唯一主設定檔(A-02) +# Secret 請用環境變數覆寫。 +Name: haixun-gateway +Host: 0.0.0.0 +Port: 8888 +Timeout: 30000 +MaxBytes: 104857600 + +Log: + Mode: console + Level: info + +# go-zero monc 快取(Redis)— 與 monc.MustNewModel / CacheRedis 對接 +# 若 Redis 有 requirepass:設 Pass 或環境變數 REDIS_PASSWORD / HAIXUN_REDIS_PASSWORD +# (沒帶密碼會 NOAUTH,登入 API 回 500) +CacheRedis: + - Host: 127.0.0.1:6379 + Type: node + Pass: "" + +# Mongo — mon/monc 使用完整 URI(含帳密時寫在 URI;可用環境變數 MONGO_URI 覆寫) +# 本機有 auth 的例子: +# export MONGO_URI='mongodb://USER:PASS@127.0.0.1:27017/?authSource=admin' +# 沒設帳密但 Mongo 開了 auth → 查詢會失敗,登入會 500 而不是「密碼錯」 +Mongo: + URI: "mongodb://127.0.0.1:27017" + Database: "haixun" + +Auth: + AccessSecret: "change-me-access-secret" + AccessExpire: 86400 + RefreshSecret: "change-me-refresh-secret" + RefreshExpire: 604800 + +Platform: + AIKey: "" + ExaKey: "" + ThreadsAppId: "" + ThreadsAppSecret: "" + +Worker: + ID: "worker-local-1" + PollIntervalMs: 2000 + +Bcrypt: + Cost: 10 + +# OAuth(空則 callback 走 mock / 開發路徑) +OAuth: + GoogleClientID: "" + LineClientID: "" + LineClientSecret: "" + LineRedirectURI: "" + +# 驗證碼/重設密碼寄信 +# +# 本機(Mailpit,Mailgun 相容 SMTP 介面): +# Host: 127.0.0.1 Port: 1025 User/Password 可空 +# UI: http://127.0.0.1:8025 +# 容器:old/infra docker-compose 的 mailpit 服務 +# +# 正式 Mailgun SMTP 例: +# Host: smtp.mailgun.org Port: 587 +# User: postmaster@YOUR_DOMAIN Password: +# DevExposeCode: false +# +# env 覆寫:MAIL_SMTP_* 或 HAIXUN_SMTP_*(舊名相容) +Mail: + Sender: "Harbor Desk " + SMTP: + Host: "127.0.0.1" + Port: 1025 + User: "" + Password: "" + # 不再於 API 回傳驗證碼;碼只在郵件(Mailpit/SMTP) + DevExposeCode: false + +# 前端 origin:重設信連結 + 預設 logo URL(/brand-mark.jpg) +# 遠端 dev:https://threads-tool-dev.30cm.net +PublicWebBase: "http://127.0.0.1:5173" + +# 郵件品牌(hermes 模板;LogoURL 空 = PublicWebBase/brand-mark.jpg) +Brand: + Name: "Harbor Desk" + LogoURL: "" + Copyright: "© Harbor Desk" + +# 物件儲存(頭像等)— **只接 MinIO/S3** +# +# Endpoint = gateway 上傳用(本機 MinIO,一定要 :9000) +# PublicBaseURL = 瀏覽器讀圖用(走 nginx :80,不要寫 port、不要加 /bucket) +# 成品網址 = PublicBaseURL + "/" + key +# 例:http://10.0.0.5/haixun-assets/avatar/1000000/xxx.png +# +# Secret 建議用 env,不要 commit: +# export HAIXUN_STORAGE_S3_ACCESS_KEY=haixun +# export HAIXUN_STORAGE_S3_SECRET_KEY='你的密碼' +# 或 MINIO_ROOT_USER / MINIO_ROOT_PASSWORD(ApplyEnv 會讀) +ObjectStorage: + Endpoint: "http://127.0.0.1:9000" + Region: "us-east-1" + Bucket: "haixun-assets" + AccessKey: "haixun" + SecretKey: "" + UsePathStyle: true + # env HAIXUN_STORAGE_S3_PUBLIC_BASE_URL 會覆寫;勿用 /api/v1/public/assets(新後端無此 route) + PublicBaseURL: "https://threads-tool-dev.30cm.net/haixun-assets" + diff --git a/apps/backend/gateway.go b/apps/backend/gateway.go new file mode 100644 index 0000000..ac27a9d --- /dev/null +++ b/apps/backend/gateway.go @@ -0,0 +1,46 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "apps/backend/internal/config" + "apps/backend/internal/handler" + "apps/backend/internal/lib/securelog" + "apps/backend/internal/middleware" + "apps/backend/internal/svc" + + "github.com/zeromicro/go-zero/core/conf" + "github.com/zeromicro/go-zero/core/logx" + "github.com/zeromicro/go-zero/rest" +) + +// Gateway entry (go-zero). API from goctl; DB schema from generate/database migrate. +func main() { + // 必須在 mon/monc 建立前呼叫:否則 Mongo ReplaceOne 會把 password_hash 整包打進 log + securelog.SilenceMongo() + + configFile := flag.String("f", "etc/gateway.yaml", "config file") + flag.Parse() + + var c config.Config + conf.MustLoad(*configFile, &c) + c.ApplyEnv() + + // 禁止 Verbose(否則可能印 request body 含 password) + c.RestConf.Verbose = false + + server := rest.MustNewServer(c.RestConf, + rest.WithNotAllowedHandler(middleware.NewCorsMiddleware().Handler()), + ) + defer server.Stop() + server.Use(middleware.NewCorsMiddleware().Handle) + + ctx := svc.NewServiceContext(c) + handler.RegisterHandlers(server, ctx) + + logx.Infof("haixun gateway %s:%d pid=%d", c.Host, c.Port, os.Getpid()) + fmt.Printf("Starting server at %s:%d...\n", c.Host, c.Port) + server.Start() +} diff --git a/apps/backend/generate/api/admin.api b/apps/backend/generate/api/admin.api new file mode 100644 index 0000000..0edf7d3 --- /dev/null +++ b/apps/backend/generate/api/admin.api @@ -0,0 +1,100 @@ +syntax = "v1" + +type ( + AdminListMembersReq { + Page int `form:"page,optional"` + PageSize int `form:"pageSize,optional"` + Query string `form:"query,optional"` + Status string `form:"status,optional"` + } + + AdminUidPath { + Uid string `path:"uid"` + } + + AdminCreateMemberReq { + DisplayName string `json:"display_name"` + Email string `json:"email"` + Phone string `json:"phone,optional"` + Password string `json:"password,optional"` + Roles []string `json:"roles,optional"` + EmailVerified bool `json:"email_verified,optional"` + Bio string `json:"bio,optional"` + Nickname string `json:"nickname,optional"` + FullName string `json:"full_name,optional"` + } + + AdminCreateMemberData { + User MemberPublic `json:"user"` + TemporaryPassword string `json:"temporary_password"` + } + + AdminSuspendReq { + Uid string `path:"uid"` + Suspended bool `json:"suspended"` + } + + AdminRolesReq { + Uid string `path:"uid"` + Roles []string `json:"roles"` + } + + AdminEmailVerifiedReq { + Uid string `path:"uid"` + Verified bool `json:"verified"` + } + + AdminResetPasswordReq { + Uid string `path:"uid"` + NewPassword string `json:"new_password,optional"` + Password string `json:"password,optional"` + } + + AdminStatusReq { + Uid string `path:"uid"` + Status string `json:"status"` + } + + AdminListData { + List []MemberPublic `json:"list"` + Pagination Pagination `json:"pagination"` + } + + AdminMemberIdentitiesData { + List []IdentityPublic `json:"list"` + } +) + +@server ( + group: admin + prefix: /api/v1/admin + middleware: AuthJWT,AdminAuth +) +service gateway { + @handler ListMembers + get /members (AdminListMembersReq) returns (AdminListData) + + @handler CreateMember + post /members (AdminCreateMemberReq) returns (AdminCreateMemberData) + + @handler GetMember + get /members/:uid (AdminUidPath) returns (MemberPublic) + + @handler SetSuspended + post /members/:uid/suspend (AdminSuspendReq) returns (MemberPublic) + + @handler SetRoles + post /members/:uid/roles (AdminRolesReq) returns (MemberPublic) + + @handler SetEmailVerified + post /members/:uid/email-verified (AdminEmailVerifiedReq) returns (MemberPublic) + + @handler AdminResetPassword + post /members/:uid/reset-password (AdminResetPasswordReq) returns (AdminCreateMemberData) + + @handler SetStatus + post /members/:uid/status (AdminStatusReq) returns (MemberPublic) + + @handler ListMemberIdentities + get /members/:uid/identities (AdminUidPath) returns (AdminMemberIdentitiesData) +} diff --git a/apps/backend/generate/api/auth.api b/apps/backend/generate/api/auth.api new file mode 100644 index 0000000..063398c --- /dev/null +++ b/apps/backend/generate/api/auth.api @@ -0,0 +1,195 @@ +syntax = "v1" + +type ( + AuthLoginReq { + Email string `json:"email,optional"` + LoginId string `json:"login_id,optional"` + Password string `json:"password"` + Platform string `json:"platform,optional"` // platform|google|line (default platform) + } + + AuthRegisterReq { + Email string `json:"email"` + Password string `json:"password"` + DisplayName string `json:"display_name,optional"` + Phone string `json:"phone,optional"` + } + + AuthRefreshReq { + RefreshToken string `json:"refresh_token"` + } + + AuthLogoutReq { + RefreshToken string `json:"refresh_token,optional"` + } + + TokenPair { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + } + + AuthSessionData { + Tokens TokenPair `json:"tokens"` + Member MemberPublic `json:"member"` + } + + AuthTokensOnly { + Tokens TokenPair `json:"tokens"` + } + + AuthProfilePatchReq { + DisplayName string `json:"display_name,optional"` + Nickname string `json:"nickname,optional"` + FullName string `json:"full_name,optional"` + Email string `json:"email,optional"` + Phone string `json:"phone,optional"` + Bio string `json:"bio,optional"` + Timezone string `json:"timezone,optional"` + NotifyEmail *bool `json:"notify_email,optional"` + // *string:省略=不改;""=清除頭像;http(s)=設定(禁止 data URL) + AvatarUrl *string `json:"avatar_url,optional"` + GenderCode *int `json:"gender_code,optional"` + Birthdate *int64 `json:"birthdate,optional"` + National string `json:"national,optional"` + Address string `json:"address,optional"` + PostCode string `json:"post_code,optional"` + PreferredLanguage string `json:"preferred_language,optional"` + Currency string `json:"currency,optional"` + CurrentPassword string `json:"current_password,optional"` + NewPassword string `json:"new_password,optional"` + } + + AuthRequestResetReq { + Email string `json:"email"` + // FE locale: zh-TW | en | en-US(未登入時用;有會員則優先 preferred_language) + Language string `json:"language,optional"` + } + + AuthRequestResetData { + Ok bool `json:"ok"` + Message string `json:"message"` + MockCode string `json:"mock_code,optional"` + MockResetPath string `json:"mock_reset_path,optional"` + } + + AuthResetPasswordReq { + Email string `json:"email"` + Token string `json:"token,optional"` + Code string `json:"code,optional"` + NewPassword string `json:"new_password"` + } + + AuthSendCodeData { + Ok bool `json:"ok"` + Message string `json:"message"` + MockCode string `json:"mock_code,optional"` + } + + AuthVerifyEmailReq { + Code string `json:"code"` + } + + AuthOAuthProviderPath { + Provider string `path:"provider"` + } + + AuthOAuthCallbackReq { + Provider string `path:"provider"` + Code string `json:"code,optional"` + IdToken string `json:"id_token,optional"` + AccessToken string `json:"access_token,optional"` + RedirectUri string `json:"redirect_uri,optional"` + } + + AuthOAuthStartData { + AuthorizeUrl string `json:"authorize_url"` + Provider string `json:"provider"` + State string `json:"state"` + } + + AuthBindReq { + LoginId string `json:"login_id"` + Platform string `json:"platform"` + AccountType string `json:"account_type,optional"` + Password string `json:"password,optional"` + } + + AuthUnbindReq { + LoginId string `json:"login_id"` + Platform string `json:"platform"` + } + + AuthIdentitiesData { + List []IdentityPublic `json:"list"` + } + + AuthLogoutAllData { + Ok bool `json:"ok"` + } +) + +@server ( + group: auth + prefix: /api/v1/auth +) +service gateway { + @handler Login + post /login (AuthLoginReq) returns (AuthSessionData) + + @handler Logout + post /logout (AuthLogoutReq) returns (OkData) + + @handler Refresh + post /refresh (AuthRefreshReq) returns (AuthTokensOnly) + + @handler Register + post /register (AuthRegisterReq) returns (AuthSessionData) + + @handler RequestPasswordReset + post /password/request-reset (AuthRequestResetReq) returns (AuthRequestResetData) + + @handler ResetPassword + post /password/reset (AuthResetPasswordReq) returns (OkData) + + @handler OAuthStart + post /oauth/:provider/start (AuthOAuthProviderPath) returns (AuthOAuthStartData) + + @handler OAuthCallback + post /oauth/:provider/callback (AuthOAuthCallbackReq) returns (AuthSessionData) +} + +@server ( + group: auth + prefix: /api/v1/auth + middleware: AuthJWT +) +service gateway { + @handler Me + get /me returns (MemberPublic) + + @handler UpdateProfile + patch /profile (AuthProfilePatchReq) returns (MemberPublic) + + @handler UpdateProfilePut + put /profile (AuthProfilePatchReq) returns (MemberPublic) + + @handler SendEmailCode + post /email/send-code returns (AuthSendCodeData) + + @handler VerifyEmail + post /email/verify (AuthVerifyEmailReq) returns (MemberPublic) + + @handler ListIdentities + get /identities returns (AuthIdentitiesData) + + @handler BindAccount + post /bind (AuthBindReq) returns (IdentityPublic) + + @handler UnbindAccount + post /unbind (AuthUnbindReq) returns (OkData) + + @handler LogoutAll + post /logout-all returns (AuthLogoutAllData) +} diff --git a/apps/backend/generate/api/common.api b/apps/backend/generate/api/common.api new file mode 100644 index 0000000..1e6bc27 --- /dev/null +++ b/apps/backend/generate/api/common.api @@ -0,0 +1,57 @@ +syntax = "v1" + +type Empty {} + +type OkData { + Ok bool `json:"ok"` + Message string `json:"message,optional"` +} + +type Pagination { + Page int `json:"page"` + PageSize int `json:"pageSize"` + Total int64 `json:"total"` + TotalPages int `json:"totalPages"` +} + +// IdentityPublic — login channel bound to member +type IdentityPublic { + LoginId string `json:"login_id"` + Platform string `json:"platform"` + AccountType string `json:"account_type"` + CreatedAt int64 `json:"created_at,optional"` +} + +// MemberPublic — full profile (stand-alone user_info + Harbor Desk fields) +type MemberPublic { + TenantId string `json:"tenant_id"` + Uid int64 `json:"uid"` + Email string `json:"email"` + Phone string `json:"phone,optional"` + DisplayName string `json:"display_name"` + Nickname string `json:"nickname,optional"` + FullName string `json:"full_name,optional"` + Roles []string `json:"roles"` + Status string `json:"status"` + EmailVerified bool `json:"email_verified"` + EmailVerifiedAt int64 `json:"email_verified_at,optional"` + PhoneVerified bool `json:"phone_verified,optional"` + PhoneVerifiedAt int64 `json:"phone_verified_at,optional"` + InviteCode string `json:"invite_code,optional"` + ParentUid int64 `json:"parent_uid,optional"` + Bio string `json:"bio,optional"` + Timezone string `json:"timezone,optional"` + AvatarUrl string `json:"avatar_url,optional"` + NotifyEmail bool `json:"notify_email,optional"` + GenderCode int `json:"gender_code,optional"` + Birthdate int64 `json:"birthdate,optional"` + National string `json:"national,optional"` + Address string `json:"address,optional"` + PostCode string `json:"post_code,optional"` + PreferredLanguage string `json:"preferred_language,optional"` + Currency string `json:"currency,optional"` + Identities []IdentityPublic `json:"identities,optional"` + JoinedAt int64 `json:"joined_at,optional"` + CreatedAt int64 `json:"created_at,optional"` + UpdatedAt int64 `json:"updated_at,optional"` +} diff --git a/apps/backend/generate/api/gateway.api b/apps/backend/generate/api/gateway.api new file mode 100644 index 0000000..77049f5 --- /dev/null +++ b/apps/backend/generate/api/gateway.api @@ -0,0 +1,15 @@ +syntax = "v1" + +info ( + title: "haixun-gateway" + desc: "Harbor Desk Phase C live API (goctl)" + author: "haixun" + version: "0.2.0" +) + +import "common.api" +import "ping.api" +import "auth.api" +import "admin.api" +import "settings.api" +import "media.api" diff --git a/apps/backend/generate/api/media.api b/apps/backend/generate/api/media.api new file mode 100644 index 0000000..18be943 --- /dev/null +++ b/apps/backend/generate/api/media.api @@ -0,0 +1,25 @@ +syntax = "v1" + +type ( + MediaUploadReq { + // content: data URL (data:image/jpeg;base64,...) 或純 base64 + Content string `json:"content"` + // kind: avatar | other(決定 object path 前綴) + Kind string `json:"kind,optional"` + } + + MediaUploadData { + Url string `json:"url"` + Key string `json:"key,optional"` + } +) + +@server ( + group: media + prefix: /api/v1/media + middleware: AuthJWT +) +service gateway { + @handler Upload + post /upload (MediaUploadReq) returns (MediaUploadData) +} diff --git a/apps/backend/generate/api/ping.api b/apps/backend/generate/api/ping.api new file mode 100644 index 0000000..1fed7d5 --- /dev/null +++ b/apps/backend/generate/api/ping.api @@ -0,0 +1,18 @@ +syntax = "v1" + +type HealthData { + Status string `json:"status"` + Service string `json:"service"` +} + +@server ( + group: ping + prefix: /api/v1 +) +service gateway { + @handler Ping + get /ping returns (HealthData) + + @handler Health + get /health returns (HealthData) +} diff --git a/apps/backend/generate/api/settings.api b/apps/backend/generate/api/settings.api new file mode 100644 index 0000000..6716ab1 --- /dev/null +++ b/apps/backend/generate/api/settings.api @@ -0,0 +1,71 @@ +syntax = "v1" + +type ( + AiSettingsData { + Provider string `json:"provider"` + Model string `json:"model"` + ResearchProvider string `json:"research_provider"` + ResearchModel string `json:"research_model"` + ApiKeyConfigured bool `json:"api_key_configured"` + ResearchApiKeyConfigured bool `json:"research_api_key_configured"` + PlatformKeyAvailable bool `json:"platform_key_available"` + } + + SaveAiReq { + Provider string `json:"provider,optional"` + Model string `json:"model,optional"` + ResearchProvider string `json:"research_provider,optional"` + ResearchModel string `json:"research_model,optional"` + ApiKey string `json:"api_key,optional"` + ResearchApiKey string `json:"research_api_key,optional"` + ApiKeyConfigured *bool `json:"api_key_configured,optional"` + ResearchApiKeyConfigured *bool `json:"research_api_key_configured,optional"` + } + + PlacementSettingsData { + WebSearchProvider string `json:"web_search_provider"` + ExpandStrategy string `json:"expand_strategy"` + BraveApiKeyConfigured bool `json:"brave_api_key_configured"` + ExaApiKeyConfigured bool `json:"exa_api_key_configured"` + DevModeEnabled bool `json:"dev_mode_enabled"` + PlatformKeyAvailable bool `json:"platform_key_available"` + } + + SavePlacementReq { + ExpandStrategy string `json:"expand_strategy,optional"` + DevModeEnabled *bool `json:"dev_mode_enabled,optional"` + ExaApiKey string `json:"exa_api_key,optional"` + ExaApiKeyConfigured *bool `json:"exa_api_key_configured,optional"` + } + + ListModelsReq { + Provider string `form:"provider,optional"` + } + + ListModelsData { + List []string `json:"list"` + Provider string `json:"provider"` + } +) + +@server ( + group: settings + prefix: /api/v1/settings + middleware: AuthJWT +) +service gateway { + @handler GetAi + get /ai returns (AiSettingsData) + + @handler SaveAi + put /ai (SaveAiReq) returns (AiSettingsData) + + @handler GetPlacement + get /placement returns (PlacementSettingsData) + + @handler SavePlacement + put /placement (SavePlacementReq) returns (PlacementSettingsData) + + @handler ListModels + get /models (ListModelsReq) returns (ListModelsData) +} diff --git a/apps/backend/generate/database/README.md b/apps/backend/generate/database/README.md new file mode 100644 index 0000000..1087f6d --- /dev/null +++ b/apps/backend/generate/database/README.md @@ -0,0 +1,64 @@ +# Database migrations(golang-migrate + MongoDB) + +**第一性原理:** index/seed 用 **檔案** 宣告,交給 `migrate`;沒有 Go seeder/init 程式。 + +工具:[golang-migrate](https://github.com/golang-migrate/migrate) +Mongo driver 讀的是 **JSON**(`db.runCommand` 陣列),不是 shell `.js`。 + +## 目錄 + +```text +generate/database/mongo/ + 000001_members_indexes.up.json + 000001_members_indexes.down.json + 000002_jobs_indexes.up.json + ... + 000003_seed_admin.up.json # 可選種子 +``` + +命名:`{version}_{name}.up.json` / `.down.json` + +## 指令 + +```bash +# 安裝 CLI(一次) +go install -tags 'mongodb' github.com/golang-migrate/migrate/v4/cmd/migrate@latest + +# 連線字串要含 database 名 +export MONGO_URL='mongodb://127.0.0.1:27017/haixun' + +# 套用全部 +migrate -path generate/database/mongo -database "$MONGO_URL" up + +# 回滾一步 +migrate -path generate/database/mongo -database "$MONGO_URL" down 1 + +# 看版本 +migrate -path generate/database/mongo -database "$MONGO_URL" version +``` + +Makefile: + +```bash +make migrate-up +make migrate-down +``` + +## 何時寫什麼 + +| 類型 | 放哪 | 範例 | +|------|------|------| +| Index / collection 約束 | `*.up.json` createIndexes | members.uid unique | +| 種子資料 | 另開 version(如 000003_seed_*) | admin 帳 | +| 業務邏輯、密碼 runtime 產生 | **不要**寫進 migration | 用 API / 測試 helper | + +種子 admin 預設:`admin@haixun.local` / `admin123`(hash 寫死在 up 檔;正式環境請改或不要跑 seed migration)。 + +## 與 gateway + +先 `make migrate-up`,再 `go run . -f etc/gateway.yaml`(需 Mongo + Redis)。 +## 不要 + +- 手寫 Go seeder/init +- 手寫 migration runner +- 把 stand-alone 電商 `.txt` 當 migrate 來源(格式不同;要用 JSON runCommand) diff --git a/apps/backend/generate/database/mongo/000001_members_indexes.down.json b/apps/backend/generate/database/mongo/000001_members_indexes.down.json new file mode 100644 index 0000000..73868b1 --- /dev/null +++ b/apps/backend/generate/database/mongo/000001_members_indexes.down.json @@ -0,0 +1,22 @@ +[ + { + "dropIndexes": "members", + "index": "uid_unique" + }, + { + "dropIndexes": "members", + "index": "email_unique" + }, + { + "dropIndexes": "members", + "index": "invite_code_1" + }, + { + "dropIndexes": "members", + "index": "status_roles" + }, + { + "dropIndexes": "member_settings", + "index": "settings_uid_unique" + } +] diff --git a/apps/backend/generate/database/mongo/000001_members_indexes.up.json b/apps/backend/generate/database/mongo/000001_members_indexes.up.json new file mode 100644 index 0000000..8d68576 --- /dev/null +++ b/apps/backend/generate/database/mongo/000001_members_indexes.up.json @@ -0,0 +1,45 @@ +[ + { + "createIndexes": "members", + "indexes": [ + { + "key": { "uid": 1 }, + "name": "uid_unique", + "unique": true + }, + { + "key": { "email": 1 }, + "name": "email_unique", + "unique": true + }, + { + "key": { "invite_code": 1 }, + "name": "invite_code_1", + "sparse": true + }, + { + "key": { "status": 1, "roles": 1 }, + "name": "status_roles" + } + ] + }, + { + "createIndexes": "member_settings", + "indexes": [ + { + "key": { "uid": 1 }, + "name": "settings_uid_unique", + "unique": true + } + ] + }, + { + "createIndexes": "counters", + "indexes": [ + { + "key": { "_id": 1 }, + "name": "_id_" + } + ] + } +] diff --git a/apps/backend/generate/database/mongo/000002_jobs_indexes.down.json b/apps/backend/generate/database/mongo/000002_jobs_indexes.down.json new file mode 100644 index 0000000..cbd96c8 --- /dev/null +++ b/apps/backend/generate/database/mongo/000002_jobs_indexes.down.json @@ -0,0 +1,10 @@ +[ + { + "dropIndexes": "jobs", + "index": "owner_created" + }, + { + "dropIndexes": "jobs", + "index": "status_1" + } +] diff --git a/apps/backend/generate/database/mongo/000002_jobs_indexes.up.json b/apps/backend/generate/database/mongo/000002_jobs_indexes.up.json new file mode 100644 index 0000000..d19633b --- /dev/null +++ b/apps/backend/generate/database/mongo/000002_jobs_indexes.up.json @@ -0,0 +1,15 @@ +[ + { + "createIndexes": "jobs", + "indexes": [ + { + "key": { "owner_uid": 1, "created_at": -1 }, + "name": "owner_created" + }, + { + "key": { "status": 1 }, + "name": "status_1" + } + ] + } +] diff --git a/apps/backend/generate/database/mongo/000003_seed_admin.down.json b/apps/backend/generate/database/mongo/000003_seed_admin.down.json new file mode 100644 index 0000000..faddfb0 --- /dev/null +++ b/apps/backend/generate/database/mongo/000003_seed_admin.down.json @@ -0,0 +1,11 @@ +[ + { + "delete": "members", + "deletes": [ + { + "q": { "uid": 1, "email": "admin@30cm.net" }, + "limit": 1 + } + ] + } +] diff --git a/apps/backend/generate/database/mongo/000003_seed_admin.up.json b/apps/backend/generate/database/mongo/000003_seed_admin.up.json new file mode 100644 index 0000000..7aedd33 --- /dev/null +++ b/apps/backend/generate/database/mongo/000003_seed_admin.up.json @@ -0,0 +1,36 @@ +[ + { + "insert": "members", + "documents": [ + { + "uid": 1000000, + "tenant_id": "default", + "email": "admin@30cm.net", + "display_name": "Harbor Admin", + "roles": ["admin", "member"], + "status": "active", + "email_verified": true, + "email_verified_at": 1700000000000000000, + "invite_code": "SEEDADMIN", + "password_hash": "$2a$10$iGGFNC5rM8MFhU8Wj6a7TOcQCsTeD03bkEODvQBh2ZxvCH.PcLl3O", + "timezone": "Asia/Taipei", + "notify_email": false, + "bio": "", + "created_at": 1700000000000000000, + "updated_at": 1700000000000000000, + "seed_note": "password is admin123 — change in production; uid=1000000 (8 digits from 1M)" + } + ], + "ordered": false + }, + { + "update": "counters", + "updates": [ + { + "q": { "_id": "member_uid" }, + "u": { "$setOnInsert": { "seq": 1000000 } }, + "upsert": true + } + ] + } +] diff --git a/apps/backend/generate/database/mongo/000004_auth_session_indexes.down.json b/apps/backend/generate/database/mongo/000004_auth_session_indexes.down.json new file mode 100644 index 0000000..ec05f77 --- /dev/null +++ b/apps/backend/generate/database/mongo/000004_auth_session_indexes.down.json @@ -0,0 +1,14 @@ +[ + { + "dropIndexes": "refresh_tokens", + "index": "refresh_uid_1" + }, + { + "dropIndexes": "refresh_tokens", + "index": "refresh_ttl" + }, + { + "dropIndexes": "auth_codes", + "index": "auth_code_ttl" + } +] diff --git a/apps/backend/generate/database/mongo/000004_auth_session_indexes.up.json b/apps/backend/generate/database/mongo/000004_auth_session_indexes.up.json new file mode 100644 index 0000000..73bba64 --- /dev/null +++ b/apps/backend/generate/database/mongo/000004_auth_session_indexes.up.json @@ -0,0 +1,26 @@ +[ + { + "createIndexes": "refresh_tokens", + "indexes": [ + { + "key": { "uid": 1 }, + "name": "refresh_uid_1" + }, + { + "key": { "expires_at": 1 }, + "name": "refresh_ttl", + "expireAfterSeconds": 0 + } + ] + }, + { + "createIndexes": "auth_codes", + "indexes": [ + { + "key": { "expires_at": 1 }, + "name": "auth_code_ttl", + "expireAfterSeconds": 0 + } + ] + } +] diff --git a/apps/backend/generate/database/mongo/000005_identities_indexes.down.json b/apps/backend/generate/database/mongo/000005_identities_indexes.down.json new file mode 100644 index 0000000..7f8e41d --- /dev/null +++ b/apps/backend/generate/database/mongo/000005_identities_indexes.down.json @@ -0,0 +1,18 @@ +[ + { + "dropIndexes": "identities", + "index": "login_platform_unique" + }, + { + "dropIndexes": "identities", + "index": "identities_uid_1" + }, + { + "dropIndexes": "identities", + "index": "platform_account_type" + }, + { + "dropIndexes": "members", + "index": "phone_1" + } +] diff --git a/apps/backend/generate/database/mongo/000005_identities_indexes.up.json b/apps/backend/generate/database/mongo/000005_identities_indexes.up.json new file mode 100644 index 0000000..01c6f7a --- /dev/null +++ b/apps/backend/generate/database/mongo/000005_identities_indexes.up.json @@ -0,0 +1,30 @@ +[ + { + "createIndexes": "identities", + "indexes": [ + { + "key": { "login_id": 1, "platform": 1 }, + "name": "login_platform_unique", + "unique": true + }, + { + "key": { "uid": 1 }, + "name": "identities_uid_1" + }, + { + "key": { "platform": 1, "account_type": 1 }, + "name": "platform_account_type" + } + ] + }, + { + "createIndexes": "members", + "indexes": [ + { + "key": { "phone": 1 }, + "name": "phone_1", + "sparse": true + } + ] + } +] diff --git a/apps/backend/generate/database/mongo/000006_seed_admin_identity.down.json b/apps/backend/generate/database/mongo/000006_seed_admin_identity.down.json new file mode 100644 index 0000000..17d9551 --- /dev/null +++ b/apps/backend/generate/database/mongo/000006_seed_admin_identity.down.json @@ -0,0 +1,11 @@ +[ + { + "delete": "identities", + "deletes": [ + { + "q": { "uid": 1, "login_id": "admin@haixun.local", "platform": "platform" }, + "limit": 1 + } + ] + } +] diff --git a/apps/backend/generate/database/mongo/000006_seed_admin_identity.up.json b/apps/backend/generate/database/mongo/000006_seed_admin_identity.up.json new file mode 100644 index 0000000..f3dc0c0 --- /dev/null +++ b/apps/backend/generate/database/mongo/000006_seed_admin_identity.up.json @@ -0,0 +1,17 @@ +[ + { + "insert": "identities", + "documents": [ + { + "uid": 1000000, + "login_id": "admin@haixun.local", + "platform": "platform", + "account_type": "email", + "password_hash": "$2a$10$iGGFNC5rM8MFhU8Wj6a7TOcQCsTeD03bkEODvQBh2ZxvCH.PcLl3O", + "created_at": 1700000000000000000, + "updated_at": 1700000000000000000 + } + ], + "ordered": false + } +] diff --git a/apps/backend/generate/database/mongo/000007_uid_floor_1m.down.json b/apps/backend/generate/database/mongo/000007_uid_floor_1m.down.json new file mode 100644 index 0000000..4e29902 --- /dev/null +++ b/apps/backend/generate/database/mongo/000007_uid_floor_1m.down.json @@ -0,0 +1,22 @@ +[ + { + "update": "members", + "updates": [ + { + "q": { "uid": 1000000, "email": "admin@haixun.local" }, + "u": { "$set": { "uid": 1 } }, + "multi": false + } + ] + }, + { + "update": "identities", + "updates": [ + { + "q": { "uid": 1000000, "login_id": "admin@haixun.local" }, + "u": { "$set": { "uid": 1 } }, + "multi": true + } + ] + } +] diff --git a/apps/backend/generate/database/mongo/000007_uid_floor_1m.up.json b/apps/backend/generate/database/mongo/000007_uid_floor_1m.up.json new file mode 100644 index 0000000..05bdf07 --- /dev/null +++ b/apps/backend/generate/database/mongo/000007_uid_floor_1m.up.json @@ -0,0 +1,57 @@ +[ + { + "update": "counters", + "updates": [ + { + "q": { "_id": "member_uid", "seq": { "$lt": 1000000 } }, + "u": { "$set": { "seq": 1000000 } }, + "upsert": false + }, + { + "q": { "_id": "member_uid" }, + "u": { "$setOnInsert": { "seq": 1000000 } }, + "upsert": true + } + ] + }, + { + "update": "members", + "updates": [ + { + "q": { "uid": 1, "email": "admin@haixun.local" }, + "u": { "$set": { "uid": 1000000 } }, + "multi": false + } + ] + }, + { + "update": "identities", + "updates": [ + { + "q": { "uid": 1, "login_id": "admin@haixun.local" }, + "u": { "$set": { "uid": 1000000 } }, + "multi": true + } + ] + }, + { + "update": "member_settings", + "updates": [ + { + "q": { "uid": 1 }, + "u": { "$set": { "uid": 1000000 } }, + "multi": false + } + ] + }, + { + "update": "refresh_tokens", + "updates": [ + { + "q": { "uid": 1 }, + "u": { "$set": { "uid": 1000000 } }, + "multi": true + } + ] + } +] diff --git a/apps/backend/generate/goctl/api/handler.tpl b/apps/backend/generate/goctl/api/handler.tpl new file mode 100644 index 0000000..46f505c --- /dev/null +++ b/apps/backend/generate/goctl/api/handler.tpl @@ -0,0 +1,27 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl {{.version}} + +package {{.PkgName}} + +import ( + "net/http" + + "apps/backend/internal/response" + {{if .HasRequest}}"github.com/zeromicro/go-zero/rest/httpx" + {{end}}{{.ImportPackages}} +) + +{{if .HasDoc}}{{.Doc}}{{end}} +func {{.HandlerName}}(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + {{if .HasRequest}}var req types.{{.RequestType}} + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + {{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}} + } +} diff --git a/apps/backend/go.mod b/apps/backend/go.mod new file mode 100644 index 0000000..19a7885 --- /dev/null +++ b/apps/backend/go.mod @@ -0,0 +1,96 @@ +module apps/backend + +go 1.25.0 + +require ( + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/stretchr/testify v1.10.0 + github.com/zeromicro/go-zero v1.7.6 + go.mongodb.org/mongo-driver v1.17.1 + golang.org/x/crypto v0.54.0 +) + +require ( + github.com/Masterminds/semver v1.4.2 // indirect + github.com/Masterminds/sprig v2.16.0+incompatible // indirect + github.com/PuerkitoBio/goquery v1.5.0 // indirect + github.com/andybalholm/cascadia v1.0.0 // indirect + github.com/aokoli/goutils v1.0.1 // indirect + github.com/aws/aws-sdk-go-v2 v1.42.1 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.28 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0 // indirect + github.com/aws/smithy-go v1.27.3 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang-jwt/jwt/v4 v4.5.1 // indirect + github.com/golang/snappy v0.0.4 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/css v1.0.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect + github.com/huandu/xstrings v1.2.0 // indirect + github.com/imdario/mergo v0.3.6 // indirect + github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/matcornic/hermes/v2 v2.1.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/montanaflynn/stats v0.7.1 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/olekukonko/tablewriter v0.0.5 // indirect + github.com/openzipkin/zipkin-go v0.4.3 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_golang v1.20.5 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.55.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + github.com/redis/go-redis/v9 v9.7.0 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/russross/blackfriday/v2 v2.0.1 // indirect + github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect + github.com/spaolacci/murmur3 v1.1.0 // indirect + github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect + github.com/vanng822/css v0.0.0-20190504095207-a21e860bcd04 // indirect + github.com/vanng822/go-premailer v0.0.0-20191214114701-be27abe028fe // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.1.2 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + go.opentelemetry.io/otel v1.24.0 // indirect + go.opentelemetry.io/otel/exporters/jaeger v1.17.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.24.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.24.0 // indirect + go.opentelemetry.io/otel/exporters/zipkin v1.24.0 // indirect + go.opentelemetry.io/otel/metric v1.24.0 // indirect + go.opentelemetry.io/otel/sdk v1.24.0 // indirect + go.opentelemetry.io/otel/trace v1.24.0 // indirect + go.opentelemetry.io/proto/otlp v1.3.1 // indirect + go.uber.org/automaxprocs v1.6.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240711142825-46eb208f015d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240701130421-f6361c86f094 // indirect + google.golang.org/grpc v1.65.0 // indirect + google.golang.org/protobuf v1.36.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/apps/backend/go.sum b/apps/backend/go.sum new file mode 100644 index 0000000..0f182b7 --- /dev/null +++ b/apps/backend/go.sum @@ -0,0 +1,261 @@ +github.com/Masterminds/semver v1.4.2 h1:WBLTQ37jOCzSLtXNdoo8bNM8876KhNqOKvrlGITgsTc= +github.com/Masterminds/semver v1.4.2/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= +github.com/Masterminds/sprig v2.16.0+incompatible h1:QZbMUPxRQ50EKAq3LFMnxddMu88/EUUG3qmxwtDmPsY= +github.com/Masterminds/sprig v2.16.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= +github.com/PuerkitoBio/goquery v1.5.0 h1:uGvmFXOA73IKluu/F84Xd1tt/z07GYm8X49XKHP7EJk= +github.com/PuerkitoBio/goquery v1.5.0/go.mod h1:qD2PgZ9lccMbQlc7eEOjaeRlFQON7xY8kdmcsrnKqMg= +github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 h1:uvdUDbHQHO85qeSydJtItA4T55Pw6BtAejd0APRJOCE= +github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= +github.com/alicebob/miniredis/v2 v2.34.0 h1:mBFWMaJSNL9RwdGRyEDoAAv8OQc5UlEhLDQggTglU/0= +github.com/alicebob/miniredis/v2 v2.34.0/go.mod h1:kWShP4b58T1CW0Y5dViCd5ztzrDqRWqM3nksiyXk5s8= +github.com/andybalholm/cascadia v1.0.0 h1:hOCXnnZ5A+3eVDX8pvgl4kofXv2ELss0bKcqRySc45o= +github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= +github.com/aokoli/goutils v1.0.1 h1:7fpzNGoJ3VA8qcrm++XEE1QUe0mIwNeLa02Nwq7RDkg= +github.com/aokoli/goutils v1.0.1/go.mod h1:SijmP0QR8LtwsmDs8Yii5Z/S4trXFGFC2oO5g9DP+DQ= +github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek= +github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDkhGgQFqkBy+uT28AAYpLu+uFMlPl+rCg/73E= +github.com/aws/aws-sdk-go-v2/credentials v1.19.28 h1:zTXJSsNcoO91/mTXsZoYf0AK8dvNPiA58/VtyGXR+wM= +github.com/aws/aws-sdk-go-v2/credentials v1.19.28/go.mod h1:Kd9E0JzDBW/q1xbsHFrev/GnbAf5J0Ng8xoyc7HZ91Q= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30/go.mod h1:WueJeNDZvK1fMYEWJIkcivBfEzUkTpBhzlrUKKY8EuA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 h1:jn46zC9LdsVR/ZpMIJqMqb8hHv31BlLx3ulVqNspUOk= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30/go.mod h1:1hTMsAgbdS/AtUi4bw8+gUuh1pceo+eXRLfpSuSQj3M= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 h1:3GUprIsfmGcC5SACIyB0e7E0BM1O1b3Erl5CePYIAeQ= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31/go.mod h1:7PuV1yl5e2xnUbm+RqvVg5i2iBM8EyijZNoI9wsOoOc= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 h1:9Fjh6fi/U5JEStVZijmaMpUwE/gvBJj7x2B/PjbO9To= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23/go.mod h1:iMoT2f1tClxrWAAnKCXjZQ6LOmfLrMG14wmnWpM+F14= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrKsSD7EmDjzAPsm/3L9IuOkzaynklJZ1qX7S4= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 h1:uao4A3QZ5UmB326V6KF+qRpv9Tjz7IlnlnTbbANntlU= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31/go.mod h1:I/1+z0VwL1GhQyLgkoHDlygpUZ+iTAwOQ/NsftiUL2I= +github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0 h1:XptwLL+UHXgafYMIHTy59IRovLbhz3znkxY2uS/pbXU= +github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0/go.mod h1:zdmCoFO/dSI7GlrwsPqFJI+WlFnSU4Tc8TJnlXrM1Do= +github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY= +github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/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/go-gomail/gomail v0.0.0-20160411212932-81ebce5c23df/go.mod h1:GJr+FCSXshIwgHBtLglIg9M2l2kQSi6QjVAngtzI08Y= +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/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= +github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/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.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= +github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= +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/huandu/xstrings v1.2.0 h1:yPeWdRnmynF7p+lLYz0H2tthW9lqhMJrQV/U7yy4wX0= +github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63UyNX5k4= +github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= +github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0 h1:xqgexXAGQgY3HAjNPSaCqn5Aahbo5TKsmhp8VRfr1iQ= +github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/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/matcornic/hermes/v2 v2.1.0 h1:9TDYFBPFv6mcXanaDmRDEp/RTWj0dTTi+LpFnnnfNWc= +github.com/matcornic/hermes/v2 v2.1.0/go.mod h1:2+ziJeoyRfaLiATIL8VZ7f9hpzH4oDHqTmn0bhrsgVI= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/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/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= +github.com/montanaflynn/stats v0.7.1/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/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/openzipkin/zipkin-go v0.4.3 h1:9EGwpqkgnwdEIJ+Od7QVSEIH+ocmm5nPat0G7sjsSdg= +github.com/openzipkin/zipkin-go v0.4.3/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.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= +github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/redis/go-redis/v9 v9.7.0 h1:HhLSs+B6O021gwzl+locl0zEDnyNkxMtf/Z3NNBMa9E= +github.com/redis/go-redis/v9 v9.7.0/go.mod h1:f6zhXITC7JUJIlPEiBOTXxJgPLdZcA93GewI7inzyWw= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +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/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo= +github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.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.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/vanng822/css v0.0.0-20190504095207-a21e860bcd04 h1:L0rPdfzq43+NV8rfIx2kA4iSSLRj2jN5ijYHoeXRwvQ= +github.com/vanng822/css v0.0.0-20190504095207-a21e860bcd04/go.mod h1:tcnB1voG49QhCrwq1W0w5hhGasvOg+VQp9i9H1rCM1w= +github.com/vanng822/go-premailer v0.0.0-20191214114701-be27abe028fe h1:9YnI5plmy+ad6BM+JCLJb2ZV7/TNiE5l7SNKfumYKgc= +github.com/vanng822/go-premailer v0.0.0-20191214114701-be27abe028fe/go.mod h1:JTFJA/t820uFDoyPpErFQ3rb3amdZoPtxcKervG0OE4= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/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/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +github.com/zeromicro/go-zero v1.7.6 h1:SArK4xecdrpVY3ZFJcbc0IZCx+NuWyHNjCv9f1+Gwrc= +github.com/zeromicro/go-zero v1.7.6/go.mod h1:SmGykRm5e0Z4CGNj+GaSKDffaHzQV56fel0FkymTLlE= +go.mongodb.org/mongo-driver v1.17.1 h1:Wic5cJIwJgSpBhe3lx3+/RybR5PiYRMpVFgO7cOHyIM= +go.mongodb.org/mongo-driver v1.17.1/go.mod h1:wwWm/+BuOddhcq3n68LKRmgk2wXzmF6s0SFOa0GINL4= +go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= +go.opentelemetry.io/otel v1.24.0/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-20181029175232-7e6ffbd03851/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +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.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +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.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190225065934-cc5685c2db12/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +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.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +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.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= +google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/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.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/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= diff --git a/apps/backend/internal/config/config.go b/apps/backend/internal/config/config.go new file mode 100644 index 0000000..629b65b --- /dev/null +++ b/apps/backend/internal/config/config.go @@ -0,0 +1,211 @@ +package config + +import ( + "os" + "strings" + + "github.com/zeromicro/go-zero/core/stores/cache" + "github.com/zeromicro/go-zero/rest" +) + +// Config — go-zero 風格:RestConf + CacheRedis + Mongo URI. +type Config struct { + rest.RestConf + + // CacheRedis is go-zero cache.CacheConf (feeds monc Redis 快取). + CacheRedis cache.CacheConf + + Mongo struct { + URI string + Database string + } + + Auth struct { + AccessSecret string + AccessExpire int64 + RefreshSecret string + RefreshExpire int64 + } + Platform struct { + AIKey string `json:",optional"` + ExaKey string `json:",optional"` + ThreadsAppId string `json:",optional"` + ThreadsAppSecret string `json:",optional"` + } + Worker struct { + ID string `json:",default=worker-1"` + PollIntervalMs int `json:",default=2000"` + } + // Bcrypt cost (stand-alone Bcrypt.Cost) + Bcrypt struct { + Cost int `json:",default=10"` + } + // OAuth third-party (optional; empty → mock path in callback) + OAuth struct { + GoogleClientID string `json:",optional"` + LineClientID string `json:",optional"` + LineClientSecret string `json:",optional"` + LineRedirectURI string `json:",optional"` + } + // Mail — 驗證碼/重設密碼寄信(SMTP;空 Host 則只打 log + 可回 mock_code) + Mail struct { + Sender string `json:",optional"` // e.g. "Harbor Desk " + SMTP struct { + Host string `json:",optional"` + Port int `json:",default=587"` + User string `json:",optional"` + Password string `json:",optional"` + } `json:",optional"` + // DevExposeCode: API 回 mock_code(本機沒信箱時用)。正式請 false。 + DevExposeCode bool `json:",default=true"` + } `json:",optional"` + // PublicWebBase — 前端 origin,用於重設密碼信內連結/logo(勿尾斜線) + // 例:http://127.0.0.1:5173 或 https://threads-tool-dev.30cm.net + PublicWebBase string `json:",optional,default=http://127.0.0.1:5173"` + // Brand — 郵件 chrome(hermes);LogoURL 空則用 PublicWebBase + /brand-mark.jpg + Brand struct { + Name string `json:",optional,default=Harbor Desk"` + LogoURL string `json:",optional"` // absolute URL + Copyright string `json:",optional,default=© Harbor Desk"` + } `json:",optional"` + // ObjectStorage — 頭像等二進位;**僅 MinIO / S3**(不落本機磁碟) + ObjectStorage struct { + Endpoint string `json:",optional"` // 必填,e.g. http://127.0.0.1:9000 + Region string `json:",optional,default=us-east-1"` + Bucket string `json:",optional,default=haixun-assets"` + AccessKey string `json:",optional"` + SecretKey string `json:",optional"` + UsePathStyle bool `json:",optional,default=true"` + PublicBaseURL string `json:",optional"` // 公開讀取前綴,勿尾斜線 + } `json:",optional"` +} + +// ApplyEnv overlays secrets from environment. +func (c *Config) ApplyEnv() { + if v := os.Getenv("MONGO_URI"); v != "" { + c.Mongo.URI = v + } + if v := os.Getenv("MONGO_DATABASE"); v != "" { + c.Mongo.Database = v + } + if v := os.Getenv("REDIS_HOST"); v != "" && len(c.CacheRedis) > 0 { + c.CacheRedis[0].RedisConf.Host = v + } + if len(c.CacheRedis) > 0 { + // monc 快取 Redis 帳密(本機 infra 常開 requirepass) + if v := os.Getenv("REDIS_PASSWORD"); v != "" { + c.CacheRedis[0].RedisConf.Pass = v + } else if v := os.Getenv("HAIXUN_REDIS_PASSWORD"); v != "" { + c.CacheRedis[0].RedisConf.Pass = v + } + if v := os.Getenv("HAIXUN_REDIS_ADDR"); v != "" { + c.CacheRedis[0].RedisConf.Host = v + } + } + if v := os.Getenv("AUTH_ACCESS_SECRET"); v != "" { + c.Auth.AccessSecret = v + } + if v := os.Getenv("AUTH_REFRESH_SECRET"); v != "" { + c.Auth.RefreshSecret = v + } + if v := os.Getenv("PLATFORM_AI_KEY"); v != "" { + c.Platform.AIKey = v + } + if v := os.Getenv("PLATFORM_EXA_KEY"); v != "" { + c.Platform.ExaKey = v + } + if v := os.Getenv("WORKER_ID"); v != "" { + c.Worker.ID = v + } + if v := os.Getenv("PORT"); v != "" { + var p int + for _, ch := range v { + if ch < '0' || ch > '9' { + return + } + p = p*10 + int(ch-'0') + } + if p > 0 { + c.Port = p + } + } + // Mail / SMTP(MAIL_* 優先;HAIXUN_SMTP_* 與 old/infra 相容) + if v := firstEnv("MAIL_SENDER", "HAIXUN_SMTP_FROM"); v != "" { + c.Mail.Sender = v + } + if v := firstEnv("MAIL_SMTP_HOST", "HAIXUN_SMTP_HOST"); v != "" { + c.Mail.SMTP.Host = v + } + if v := firstEnv("MAIL_SMTP_USER", "HAIXUN_SMTP_USERNAME"); v != "" { + c.Mail.SMTP.User = v + } + if v := firstEnv("MAIL_SMTP_PASSWORD", "HAIXUN_SMTP_PASSWORD"); v != "" { + c.Mail.SMTP.Password = v + } + if v := firstEnv("MAIL_SMTP_PORT", "HAIXUN_SMTP_PORT"); v != "" { + if p := parsePort(v); p > 0 { + c.Mail.SMTP.Port = p + } + } + if v := os.Getenv("MAIL_DEV_EXPOSE_CODE"); v == "0" || v == "false" || v == "FALSE" { + c.Mail.DevExposeCode = false + } else if v == "1" || v == "true" || v == "TRUE" { + c.Mail.DevExposeCode = true + } + if v := firstEnv("PUBLIC_WEB_BASE", "HAIXUN_PUBLIC_WEB_BASE"); v != "" { + c.PublicWebBase = strings.TrimRight(v, "/") + } + if v := os.Getenv("MAIL_BRAND_NAME"); v != "" { + c.Brand.Name = v + } + if v := firstEnv("MAIL_LOGO_URL", "HAIXUN_MAIL_LOGO_URL"); v != "" { + c.Brand.LogoURL = v + } + if v := os.Getenv("MAIL_COPYRIGHT"); v != "" { + c.Brand.Copyright = v + } + // Object storage (HAIXUN_STORAGE_S3_* 與 old/infra 相容) + if v := firstEnv("OBJECT_STORAGE_ENDPOINT", "HAIXUN_STORAGE_S3_ENDPOINT"); v != "" { + c.ObjectStorage.Endpoint = v + } + if v := firstEnv("OBJECT_STORAGE_REGION", "HAIXUN_STORAGE_S3_REGION"); v != "" { + c.ObjectStorage.Region = v + } + if v := firstEnv("OBJECT_STORAGE_BUCKET", "HAIXUN_STORAGE_S3_BUCKET"); v != "" { + c.ObjectStorage.Bucket = v + } + if v := firstEnv("OBJECT_STORAGE_ACCESS_KEY", "HAIXUN_STORAGE_S3_ACCESS_KEY", "MINIO_ROOT_USER"); v != "" { + c.ObjectStorage.AccessKey = v + } + if v := firstEnv("OBJECT_STORAGE_SECRET_KEY", "HAIXUN_STORAGE_S3_SECRET_KEY", "MINIO_ROOT_PASSWORD"); v != "" { + c.ObjectStorage.SecretKey = v + } + if v := firstEnv("OBJECT_STORAGE_PUBLIC_BASE_URL", "HAIXUN_STORAGE_S3_PUBLIC_BASE_URL"); v != "" { + c.ObjectStorage.PublicBaseURL = strings.TrimRight(v, "/") + } + if v := os.Getenv("HAIXUN_STORAGE_S3_USE_PATH_STYLE"); v == "0" || v == "false" { + c.ObjectStorage.UsePathStyle = false + } else if v == "1" || v == "true" { + c.ObjectStorage.UsePathStyle = true + } +} + +func firstEnv(keys ...string) string { + for _, k := range keys { + if v := os.Getenv(k); v != "" { + return v + } + } + return "" +} + +func parsePort(v string) int { + var p int + for _, ch := range v { + if ch < '0' || ch > '9' { + return 0 + } + p = p*10 + int(ch-'0') + } + return p +} diff --git a/apps/backend/internal/domain/const.go b/apps/backend/internal/domain/const.go new file mode 100644 index 0000000..79050e7 --- /dev/null +++ b/apps/backend/internal/domain/const.go @@ -0,0 +1,20 @@ +package domain + +// SuccessCode is the project success envelope code (AGENTS.md / spec A-07). +// Migrated from stand-alone-service 10200 → unified 102000. +const SuccessCode int64 = 102000 + +const SuccessMessage = "success" + +const TokenTypeBearer = "Bearer" + +type ContextKey string + +func (c ContextKey) String() string { return string(c) } + +const ( + UIDCode ContextKey = "uid" + RoleCode ContextKey = "role" + ScopeCode ContextKey = "scope" + DeviceIDCode ContextKey = "device_id" +) diff --git a/apps/backend/internal/domain/const_test.go b/apps/backend/internal/domain/const_test.go new file mode 100644 index 0000000..ea22ffd --- /dev/null +++ b/apps/backend/internal/domain/const_test.go @@ -0,0 +1,9 @@ +package domain + +import "testing" + +func TestSuccessCode_Is102000(t *testing.T) { + if SuccessCode != 102000 { + t.Fatalf("SuccessCode=%d want 102000 (migrated from stand-alone 10200)", SuccessCode) + } +} diff --git a/apps/backend/internal/handler/admin/admin_reset_password_handler.go b/apps/backend/internal/handler/admin/admin_reset_password_handler.go new file mode 100644 index 0000000..ee1de72 --- /dev/null +++ b/apps/backend/internal/handler/admin/admin_reset_password_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package admin + +import ( + "net/http" + + "apps/backend/internal/logic/admin" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func AdminResetPasswordHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.AdminResetPasswordReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := admin.NewAdminResetPasswordLogic(r.Context(), svcCtx) + data, err := l.AdminResetPassword(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/admin/create_member_handler.go b/apps/backend/internal/handler/admin/create_member_handler.go new file mode 100644 index 0000000..a1c70be --- /dev/null +++ b/apps/backend/internal/handler/admin/create_member_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package admin + +import ( + "net/http" + + "apps/backend/internal/logic/admin" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func CreateMemberHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.AdminCreateMemberReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := admin.NewCreateMemberLogic(r.Context(), svcCtx) + data, err := l.CreateMember(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/admin/get_member_handler.go b/apps/backend/internal/handler/admin/get_member_handler.go new file mode 100644 index 0000000..1a95d1e --- /dev/null +++ b/apps/backend/internal/handler/admin/get_member_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package admin + +import ( + "net/http" + + "apps/backend/internal/logic/admin" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func GetMemberHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.AdminUidPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := admin.NewGetMemberLogic(r.Context(), svcCtx) + data, err := l.GetMember(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/admin/list_member_identities_handler.go b/apps/backend/internal/handler/admin/list_member_identities_handler.go new file mode 100644 index 0000000..3d7d5ad --- /dev/null +++ b/apps/backend/internal/handler/admin/list_member_identities_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package admin + +import ( + "net/http" + + "apps/backend/internal/logic/admin" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ListMemberIdentitiesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.AdminUidPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := admin.NewListMemberIdentitiesLogic(r.Context(), svcCtx) + data, err := l.ListMemberIdentities(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/admin/list_members_handler.go b/apps/backend/internal/handler/admin/list_members_handler.go new file mode 100644 index 0000000..39cecb2 --- /dev/null +++ b/apps/backend/internal/handler/admin/list_members_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package admin + +import ( + "net/http" + + "apps/backend/internal/logic/admin" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ListMembersHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.AdminListMembersReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := admin.NewListMembersLogic(r.Context(), svcCtx) + data, err := l.ListMembers(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/admin/set_email_verified_handler.go b/apps/backend/internal/handler/admin/set_email_verified_handler.go new file mode 100644 index 0000000..491fad1 --- /dev/null +++ b/apps/backend/internal/handler/admin/set_email_verified_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package admin + +import ( + "net/http" + + "apps/backend/internal/logic/admin" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func SetEmailVerifiedHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.AdminEmailVerifiedReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := admin.NewSetEmailVerifiedLogic(r.Context(), svcCtx) + data, err := l.SetEmailVerified(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/admin/set_roles_handler.go b/apps/backend/internal/handler/admin/set_roles_handler.go new file mode 100644 index 0000000..5279b08 --- /dev/null +++ b/apps/backend/internal/handler/admin/set_roles_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package admin + +import ( + "net/http" + + "apps/backend/internal/logic/admin" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func SetRolesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.AdminRolesReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := admin.NewSetRolesLogic(r.Context(), svcCtx) + data, err := l.SetRoles(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/admin/set_status_handler.go b/apps/backend/internal/handler/admin/set_status_handler.go new file mode 100644 index 0000000..4144ec0 --- /dev/null +++ b/apps/backend/internal/handler/admin/set_status_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package admin + +import ( + "net/http" + + "apps/backend/internal/logic/admin" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func SetStatusHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.AdminStatusReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := admin.NewSetStatusLogic(r.Context(), svcCtx) + data, err := l.SetStatus(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/admin/set_suspended_handler.go b/apps/backend/internal/handler/admin/set_suspended_handler.go new file mode 100644 index 0000000..f637dba --- /dev/null +++ b/apps/backend/internal/handler/admin/set_suspended_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package admin + +import ( + "net/http" + + "apps/backend/internal/logic/admin" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func SetSuspendedHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.AdminSuspendReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := admin.NewSetSuspendedLogic(r.Context(), svcCtx) + data, err := l.SetSuspended(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/auth/bind_account_handler.go b/apps/backend/internal/handler/auth/bind_account_handler.go new file mode 100644 index 0000000..a8c260e --- /dev/null +++ b/apps/backend/internal/handler/auth/bind_account_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package auth + +import ( + "net/http" + + "apps/backend/internal/logic/auth" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func BindAccountHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.AuthBindReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := auth.NewBindAccountLogic(r.Context(), svcCtx) + data, err := l.BindAccount(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/auth/list_identities_handler.go b/apps/backend/internal/handler/auth/list_identities_handler.go new file mode 100644 index 0000000..d2e95c3 --- /dev/null +++ b/apps/backend/internal/handler/auth/list_identities_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package auth + +import ( + "net/http" + + "apps/backend/internal/logic/auth" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func ListIdentitiesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := auth.NewListIdentitiesLogic(r.Context(), svcCtx) + data, err := l.ListIdentities() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/auth/login_handler.go b/apps/backend/internal/handler/auth/login_handler.go new file mode 100644 index 0000000..7fdd497 --- /dev/null +++ b/apps/backend/internal/handler/auth/login_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package auth + +import ( + "net/http" + + "apps/backend/internal/logic/auth" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func 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 + } + + l := auth.NewLoginLogic(r.Context(), svcCtx) + data, err := l.Login(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/auth/logout_all_handler.go b/apps/backend/internal/handler/auth/logout_all_handler.go new file mode 100644 index 0000000..6846823 --- /dev/null +++ b/apps/backend/internal/handler/auth/logout_all_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package auth + +import ( + "net/http" + + "apps/backend/internal/logic/auth" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func LogoutAllHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := auth.NewLogoutAllLogic(r.Context(), svcCtx) + data, err := l.LogoutAll() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/auth/logout_handler.go b/apps/backend/internal/handler/auth/logout_handler.go new file mode 100644 index 0000000..fb291b3 --- /dev/null +++ b/apps/backend/internal/handler/auth/logout_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package auth + +import ( + "net/http" + + "apps/backend/internal/logic/auth" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func LogoutHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.AuthLogoutReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := auth.NewLogoutLogic(r.Context(), svcCtx) + data, err := l.Logout(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/auth/me_handler.go b/apps/backend/internal/handler/auth/me_handler.go new file mode 100644 index 0000000..020f1bb --- /dev/null +++ b/apps/backend/internal/handler/auth/me_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package auth + +import ( + "net/http" + + "apps/backend/internal/logic/auth" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func MeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := auth.NewMeLogic(r.Context(), svcCtx) + data, err := l.Me() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/auth/o_auth_callback_handler.go b/apps/backend/internal/handler/auth/o_auth_callback_handler.go new file mode 100644 index 0000000..2d06f79 --- /dev/null +++ b/apps/backend/internal/handler/auth/o_auth_callback_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package auth + +import ( + "net/http" + + "apps/backend/internal/logic/auth" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func OAuthCallbackHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.AuthOAuthCallbackReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := auth.NewOAuthCallbackLogic(r.Context(), svcCtx) + data, err := l.OAuthCallback(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/auth/o_auth_start_handler.go b/apps/backend/internal/handler/auth/o_auth_start_handler.go new file mode 100644 index 0000000..cef0479 --- /dev/null +++ b/apps/backend/internal/handler/auth/o_auth_start_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package auth + +import ( + "net/http" + + "apps/backend/internal/logic/auth" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func OAuthStartHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.AuthOAuthProviderPath + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := auth.NewOAuthStartLogic(r.Context(), svcCtx) + data, err := l.OAuthStart(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/auth/refresh_handler.go b/apps/backend/internal/handler/auth/refresh_handler.go new file mode 100644 index 0000000..d41e3ce --- /dev/null +++ b/apps/backend/internal/handler/auth/refresh_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package auth + +import ( + "net/http" + + "apps/backend/internal/logic/auth" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func 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 + } + + l := auth.NewRefreshLogic(r.Context(), svcCtx) + data, err := l.Refresh(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/auth/register_handler.go b/apps/backend/internal/handler/auth/register_handler.go new file mode 100644 index 0000000..7005bf7 --- /dev/null +++ b/apps/backend/internal/handler/auth/register_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package auth + +import ( + "net/http" + + "apps/backend/internal/logic/auth" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func 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 + } + + l := auth.NewRegisterLogic(r.Context(), svcCtx) + data, err := l.Register(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/auth/request_password_reset_handler.go b/apps/backend/internal/handler/auth/request_password_reset_handler.go new file mode 100644 index 0000000..1c26b29 --- /dev/null +++ b/apps/backend/internal/handler/auth/request_password_reset_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package auth + +import ( + "net/http" + + "apps/backend/internal/logic/auth" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func RequestPasswordResetHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.AuthRequestResetReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := auth.NewRequestPasswordResetLogic(r.Context(), svcCtx) + data, err := l.RequestPasswordReset(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/auth/reset_password_handler.go b/apps/backend/internal/handler/auth/reset_password_handler.go new file mode 100644 index 0000000..89c1b43 --- /dev/null +++ b/apps/backend/internal/handler/auth/reset_password_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package auth + +import ( + "net/http" + + "apps/backend/internal/logic/auth" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ResetPasswordHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.AuthResetPasswordReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := auth.NewResetPasswordLogic(r.Context(), svcCtx) + data, err := l.ResetPassword(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/auth/send_email_code_handler.go b/apps/backend/internal/handler/auth/send_email_code_handler.go new file mode 100644 index 0000000..6b9bf6c --- /dev/null +++ b/apps/backend/internal/handler/auth/send_email_code_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package auth + +import ( + "net/http" + + "apps/backend/internal/logic/auth" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func SendEmailCodeHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := auth.NewSendEmailCodeLogic(r.Context(), svcCtx) + data, err := l.SendEmailCode() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/auth/unbind_account_handler.go b/apps/backend/internal/handler/auth/unbind_account_handler.go new file mode 100644 index 0000000..1625f0a --- /dev/null +++ b/apps/backend/internal/handler/auth/unbind_account_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package auth + +import ( + "net/http" + + "apps/backend/internal/logic/auth" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func UnbindAccountHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.AuthUnbindReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := auth.NewUnbindAccountLogic(r.Context(), svcCtx) + data, err := l.UnbindAccount(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/auth/update_profile_handler.go b/apps/backend/internal/handler/auth/update_profile_handler.go new file mode 100644 index 0000000..70718bb --- /dev/null +++ b/apps/backend/internal/handler/auth/update_profile_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package auth + +import ( + "net/http" + + "apps/backend/internal/logic/auth" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func UpdateProfileHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.AuthProfilePatchReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := auth.NewUpdateProfileLogic(r.Context(), svcCtx) + data, err := l.UpdateProfile(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/auth/update_profile_put_handler.go b/apps/backend/internal/handler/auth/update_profile_put_handler.go new file mode 100644 index 0000000..7a2007c --- /dev/null +++ b/apps/backend/internal/handler/auth/update_profile_put_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package auth + +import ( + "net/http" + + "apps/backend/internal/logic/auth" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func UpdateProfilePutHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.AuthProfilePatchReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := auth.NewUpdateProfilePutLogic(r.Context(), svcCtx) + data, err := l.UpdateProfilePut(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/auth/verify_email_handler.go b/apps/backend/internal/handler/auth/verify_email_handler.go new file mode 100644 index 0000000..28f51ed --- /dev/null +++ b/apps/backend/internal/handler/auth/verify_email_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package auth + +import ( + "net/http" + + "apps/backend/internal/logic/auth" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func VerifyEmailHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.AuthVerifyEmailReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := auth.NewVerifyEmailLogic(r.Context(), svcCtx) + data, err := l.VerifyEmail(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/media/upload_handler.go b/apps/backend/internal/handler/media/upload_handler.go new file mode 100644 index 0000000..3d7f9c7 --- /dev/null +++ b/apps/backend/internal/handler/media/upload_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package media + +import ( + "net/http" + + "apps/backend/internal/logic/media" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func UploadHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.MediaUploadReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := media.NewUploadLogic(r.Context(), svcCtx) + data, err := l.Upload(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/ping/health_handler.go b/apps/backend/internal/handler/ping/health_handler.go new file mode 100644 index 0000000..e4fb970 --- /dev/null +++ b/apps/backend/internal/handler/ping/health_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package ping + +import ( + "net/http" + + "apps/backend/internal/logic/ping" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func HealthHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := ping.NewHealthLogic(r.Context(), svcCtx) + data, err := l.Health() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/ping/ping_handler.go b/apps/backend/internal/handler/ping/ping_handler.go new file mode 100644 index 0000000..20e071c --- /dev/null +++ b/apps/backend/internal/handler/ping/ping_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package ping + +import ( + "net/http" + + "apps/backend/internal/logic/ping" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func PingHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := ping.NewPingLogic(r.Context(), svcCtx) + data, err := l.Ping() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/routes.go b/apps/backend/internal/handler/routes.go new file mode 100644 index 0000000..ea3d6c8 --- /dev/null +++ b/apps/backend/internal/handler/routes.go @@ -0,0 +1,237 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl 1.7.6 + +package handler + +import ( + "net/http" + + admin "apps/backend/internal/handler/admin" + auth "apps/backend/internal/handler/auth" + media "apps/backend/internal/handler/media" + ping "apps/backend/internal/handler/ping" + settings "apps/backend/internal/handler/settings" + "apps/backend/internal/svc" + + "github.com/zeromicro/go-zero/rest" +) + +func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT, serverCtx.AdminAuth}, + []rest.Route{ + { + Method: http.MethodGet, + Path: "/members", + Handler: admin.ListMembersHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/members", + Handler: admin.CreateMemberHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/members/:uid", + Handler: admin.GetMemberHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/members/:uid/email-verified", + Handler: admin.SetEmailVerifiedHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/members/:uid/identities", + Handler: admin.ListMemberIdentitiesHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/members/:uid/reset-password", + Handler: admin.AdminResetPasswordHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/members/:uid/roles", + Handler: admin.SetRolesHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/members/:uid/status", + Handler: admin.SetStatusHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/members/:uid/suspend", + Handler: admin.SetSuspendedHandler(serverCtx), + }, + }..., + ), + rest.WithPrefix("/api/v1/admin"), + ) + + server.AddRoutes( + []rest.Route{ + { + Method: http.MethodPost, + Path: "/login", + Handler: auth.LoginHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/logout", + Handler: auth.LogoutHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/oauth/:provider/callback", + Handler: auth.OAuthCallbackHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/oauth/:provider/start", + Handler: auth.OAuthStartHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/password/request-reset", + Handler: auth.RequestPasswordResetHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/password/reset", + Handler: auth.ResetPasswordHandler(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: "/bind", + Handler: auth.BindAccountHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/email/send-code", + Handler: auth.SendEmailCodeHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/email/verify", + Handler: auth.VerifyEmailHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/identities", + Handler: auth.ListIdentitiesHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/logout-all", + Handler: auth.LogoutAllHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/me", + Handler: auth.MeHandler(serverCtx), + }, + { + Method: http.MethodPatch, + Path: "/profile", + Handler: auth.UpdateProfileHandler(serverCtx), + }, + { + Method: http.MethodPut, + Path: "/profile", + Handler: auth.UpdateProfilePutHandler(serverCtx), + }, + { + Method: http.MethodPost, + Path: "/unbind", + Handler: auth.UnbindAccountHandler(serverCtx), + }, + }..., + ), + rest.WithPrefix("/api/v1/auth"), + ) + + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT}, + []rest.Route{ + { + Method: http.MethodPost, + Path: "/upload", + Handler: media.UploadHandler(serverCtx), + }, + }..., + ), + rest.WithPrefix("/api/v1/media"), + ) + + server.AddRoutes( + []rest.Route{ + { + Method: http.MethodGet, + Path: "/health", + Handler: ping.HealthHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/ping", + Handler: ping.PingHandler(serverCtx), + }, + }, + rest.WithPrefix("/api/v1"), + ) + + server.AddRoutes( + rest.WithMiddlewares( + []rest.Middleware{serverCtx.AuthJWT}, + []rest.Route{ + { + Method: http.MethodGet, + Path: "/ai", + Handler: settings.GetAiHandler(serverCtx), + }, + { + Method: http.MethodPut, + Path: "/ai", + Handler: settings.SaveAiHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/models", + Handler: settings.ListModelsHandler(serverCtx), + }, + { + Method: http.MethodGet, + Path: "/placement", + Handler: settings.GetPlacementHandler(serverCtx), + }, + { + Method: http.MethodPut, + Path: "/placement", + Handler: settings.SavePlacementHandler(serverCtx), + }, + }..., + ), + rest.WithPrefix("/api/v1/settings"), + ) +} diff --git a/apps/backend/internal/handler/settings/get_ai_handler.go b/apps/backend/internal/handler/settings/get_ai_handler.go new file mode 100644 index 0000000..3258c30 --- /dev/null +++ b/apps/backend/internal/handler/settings/get_ai_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package settings + +import ( + "net/http" + + "apps/backend/internal/logic/settings" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func GetAiHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := settings.NewGetAiLogic(r.Context(), svcCtx) + data, err := l.GetAi() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/settings/get_placement_handler.go b/apps/backend/internal/handler/settings/get_placement_handler.go new file mode 100644 index 0000000..995c70a --- /dev/null +++ b/apps/backend/internal/handler/settings/get_placement_handler.go @@ -0,0 +1,20 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package settings + +import ( + "net/http" + + "apps/backend/internal/logic/settings" + "apps/backend/internal/response" + "apps/backend/internal/svc" +) + +func GetPlacementHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + l := settings.NewGetPlacementLogic(r.Context(), svcCtx) + data, err := l.GetPlacement() + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/settings/list_models_handler.go b/apps/backend/internal/handler/settings/list_models_handler.go new file mode 100644 index 0000000..e4d6bbb --- /dev/null +++ b/apps/backend/internal/handler/settings/list_models_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package settings + +import ( + "net/http" + + "apps/backend/internal/logic/settings" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func ListModelsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.ListModelsReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := settings.NewListModelsLogic(r.Context(), svcCtx) + data, err := l.ListModels(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/settings/save_ai_handler.go b/apps/backend/internal/handler/settings/save_ai_handler.go new file mode 100644 index 0000000..d18a1d9 --- /dev/null +++ b/apps/backend/internal/handler/settings/save_ai_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package settings + +import ( + "net/http" + + "apps/backend/internal/logic/settings" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func SaveAiHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.SaveAiReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := settings.NewSaveAiLogic(r.Context(), svcCtx) + data, err := l.SaveAi(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/handler/settings/save_placement_handler.go b/apps/backend/internal/handler/settings/save_placement_handler.go new file mode 100644 index 0000000..e2f52e2 --- /dev/null +++ b/apps/backend/internal/handler/settings/save_placement_handler.go @@ -0,0 +1,28 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl + +package settings + +import ( + "net/http" + + "apps/backend/internal/logic/settings" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + "github.com/zeromicro/go-zero/rest/httpx" +) + +func SavePlacementHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req types.SavePlacementReq + if err := httpx.Parse(r, &req); err != nil { + response.Write(r.Context(), w, nil, response.WrapRequestError(err)) + return + } + + l := settings.NewSavePlacementLogic(r.Context(), svcCtx) + data, err := l.SavePlacement(&req) + response.Write(r.Context(), w, data, err) + } +} diff --git a/apps/backend/internal/lib/mongo/uri.go b/apps/backend/internal/lib/mongo/uri.go new file mode 100644 index 0000000..8729f31 --- /dev/null +++ b/apps/backend/internal/lib/mongo/uri.go @@ -0,0 +1,14 @@ +package mongo + +import "os" + +// MustMongoURI returns Mongo URI from arg or MONGO_URI env (for mon/monc). +func MustMongoURI(uri string) string { + if v := os.Getenv("MONGO_URI"); v != "" { + return v + } + if uri == "" { + panic("mongo: empty URI (set Mongo.URI or MONGO_URI)") + } + return uri +} diff --git a/apps/backend/internal/lib/securelog/securelog.go b/apps/backend/internal/lib/securelog/securelog.go new file mode 100644 index 0000000..0fdfadb --- /dev/null +++ b/apps/backend/internal/lib/securelog/securelog.go @@ -0,0 +1,64 @@ +// Package securelog centralizes log hygiene for secrets. +// +// Policy: +// - Never log password / new_password / current_password / temporary_password +// - Never log password_hash, refresh_token, access_token, api keys +// - go-zero mon dumps full Mongo docs at info — call SilenceMongo() at process start +package securelog + +import ( + "encoding/json" + "regexp" + "strings" + + "github.com/zeromicro/go-zero/core/stores/mon" +) + +// sensitiveJSONKeys matched case-insensitively as JSON object keys. +var sensitiveKeyPattern = regexp.MustCompile( + `(?i)("(?:password|new_password|current_password|temporary_password|password_hash|` + + `access_token|refresh_token|api_key|secret|authorization|cookie)"\s*:\s*)` + + `(?:"(?:\\.|[^"\\])*"|null|true|false|-?\d+(?:\.\d+)?)`, +) + +// SilenceMongo disables go-zero mon command logging (including document dumps). +// monc uses mon under the hood; without this, ReplaceOne logs password_hash. +func SilenceMongo() { + mon.DisableLog() +} + +// RedactJSON replaces known secret fields in a JSON blob with "[REDACTED]". +func RedactJSON(raw string) string { + if raw == "" { + return raw + } + return sensitiveKeyPattern.ReplaceAllString(raw, `${1}"[REDACTED]"`) +} + +// RedactAny marshals v then redacts; for ad-hoc logx of structs. +func RedactAny(v any) string { + if v == nil { + return "null" + } + b, err := json.Marshal(v) + if err != nil { + return "[unserializable]" + } + return RedactJSON(string(b)) +} + +// MaskPassword returns a fixed placeholder (never the input). +func MaskPassword(_ string) string { return "[REDACTED]" } + +// IsSensitiveKey reports if a field name must never be logged. +func IsSensitiveKey(name string) bool { + n := strings.ToLower(strings.TrimSpace(name)) + switch n { + case "password", "new_password", "current_password", "temporary_password", + "password_hash", "access_token", "refresh_token", "api_key", "secret", + "authorization", "cookie", "content": // media content is base64 blob + return true + default: + return false + } +} diff --git a/apps/backend/internal/lib/securelog/securelog_test.go b/apps/backend/internal/lib/securelog/securelog_test.go new file mode 100644 index 0000000..2c8d269 --- /dev/null +++ b/apps/backend/internal/lib/securelog/securelog_test.go @@ -0,0 +1,29 @@ +package securelog + +import "testing" + +func TestRedactJSON(t *testing.T) { + in := `{"email":"a@b.com","password":"Secret12!@#a","password_hash":"$2a$10$abc","new_password":"x","ok":true}` + out := RedactJSON(in) + if contains(out, "Secret12") || contains(out, "$2a$10$abc") || contains(out, `"x"`) { + t.Fatalf("still has secret: %s", out) + } + if !contains(out, "[REDACTED]") { + t.Fatalf("expected redacted: %s", out) + } + if !contains(out, "a@b.com") { + t.Fatalf("email should remain: %s", out) + } +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || len(sub) == 0 || + (len(s) > 0 && (func() bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false + })())) +} diff --git a/apps/backend/internal/logic/admin/admin_reset_password_logic.go b/apps/backend/internal/logic/admin/admin_reset_password_logic.go new file mode 100644 index 0000000..1730966 --- /dev/null +++ b/apps/backend/internal/logic/admin/admin_reset_password_logic.go @@ -0,0 +1,70 @@ +package admin + +import ( + "context" + "strconv" + + "apps/backend/internal/module/member/domain" + memberUC "apps/backend/internal/module/member/usecase" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type AdminResetPasswordLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewAdminResetPasswordLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AdminResetPasswordLogic { + return &AdminResetPasswordLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *AdminResetPasswordLogic) AdminResetPassword(req *types.AdminResetPasswordReq) (resp *types.AdminCreateMemberData, err error) { + uid, err := strconv.ParseInt(req.Uid, 10, 64) + if err != nil { + return nil, err + } + tmp := req.NewPassword + if tmp == "" { + tmp = req.Password + } + if tmp == "" { + tmp = memberUC.GenerateTempPassword() + } else if err := memberUC.ValidatePasswordPolicy(tmp); err != nil { + return nil, err + } + m, err := l.svcCtx.Members.FindByUID(l.ctx, uid) + if err != nil { + return nil, err + } + loginID := m.Email + if loginID == "" { + loginID = m.Phone + } + if loginID == "" { + return nil, domain.ErrNotFound + } + // prefer identity path; fall back to member hash + create identity + if err := l.svcCtx.Auth.UpdateUserToken(l.ctx, loginID, domain.PlatformPassword, tmp); err != nil { + hash, herr := memberUC.HashPassword(tmp, 10) + if herr != nil { + return nil, herr + } + m.PasswordHash = hash + m.UpdatedAt = domain.NowNano() + if err := l.svcCtx.Members.UpdateMember(l.ctx, m); err != nil { + return nil, err + } + _ = l.svcCtx.Members.CreateIdentity(l.ctx, &domain.Identity{ + UID: uid, LoginID: loginID, Platform: domain.PlatformPassword, + AccountType: domain.AccountTypeEmail, PasswordHash: hash, + CreatedAt: domain.NowNano(), UpdatedAt: domain.NowNano(), + }) + } else { + m, _ = l.svcCtx.Members.FindByUID(l.ctx, uid) + } + return &types.AdminCreateMemberData{User: *types.MemberFromModel(m), TemporaryPassword: tmp}, nil +} diff --git a/apps/backend/internal/logic/admin/create_member_logic.go b/apps/backend/internal/logic/admin/create_member_logic.go new file mode 100644 index 0000000..1f56733 --- /dev/null +++ b/apps/backend/internal/logic/admin/create_member_logic.go @@ -0,0 +1,68 @@ +package admin + +import ( + "context" + "strings" + + "apps/backend/internal/module/member/domain" + memberUC "apps/backend/internal/module/member/usecase" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type CreateMemberLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewCreateMemberLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateMemberLogic { + return &CreateMemberLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *CreateMemberLogic) CreateMember(req *types.AdminCreateMemberReq) (resp *types.AdminCreateMemberData, err error) { + tmp := req.Password + if tmp == "" { + tmp = memberUC.GenerateTempPassword() + } else if err := memberUC.ValidatePasswordPolicy(tmp); err != nil { + return nil, err + } + email := strings.ToLower(strings.TrimSpace(req.Email)) + m, _, err := l.svcCtx.Auth.CreateUserAccount(l.ctx, domain.CreateLoginUserRequest{ + LoginID: email, Platform: domain.PlatformPassword, AccountType: domain.AccountTypeEmail, + Password: tmp, DisplayName: req.DisplayName, Email: email, Phone: req.Phone, + }) + if err != nil { + return nil, err + } + // admin-created: apply roles / verified / extra profile + now := domain.NowNano() + if len(req.Roles) > 0 { + m.Roles = req.Roles + } + if req.EmailVerified { + m.EmailVerified = true + m.EmailVerifiedAt = &now + m.Status = domain.StatusActive + } + if req.Bio != "" { + m.Bio = req.Bio + } + if req.Nickname != "" { + m.Nickname = req.Nickname + } + if req.FullName != "" { + m.FullName = req.FullName + } + m.UpdatedAt = now + if err := l.svcCtx.Members.UpdateMember(l.ctx, m); err != nil { + return nil, err + } + ids, _ := l.svcCtx.Auth.ListIdentities(l.ctx, m.UID) + return &types.AdminCreateMemberData{ + User: *types.MemberFromModelWithIdentities(m, ids), + TemporaryPassword: tmp, + }, nil +} diff --git a/apps/backend/internal/logic/admin/get_member_logic.go b/apps/backend/internal/logic/admin/get_member_logic.go new file mode 100644 index 0000000..a6a4430 --- /dev/null +++ b/apps/backend/internal/logic/admin/get_member_logic.go @@ -0,0 +1,33 @@ +package admin + +import ( + "context" + "strconv" + + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetMemberLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetMemberLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetMemberLogic { + return &GetMemberLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetMemberLogic) GetMember(req *types.AdminUidPath) (resp *types.MemberPublic, err error) { + uid, err := strconv.ParseInt(req.Uid, 10, 64) + if err != nil { + return nil, err + } + m, err := l.svcCtx.Members.FindByUID(l.ctx, uid) + if err != nil { + return nil, err + } + return types.MemberFromModel(m), nil +} diff --git a/apps/backend/internal/logic/admin/list_member_identities_logic.go b/apps/backend/internal/logic/admin/list_member_identities_logic.go new file mode 100644 index 0000000..e20181b --- /dev/null +++ b/apps/backend/internal/logic/admin/list_member_identities_logic.go @@ -0,0 +1,37 @@ +package admin + +import ( + "context" + "strconv" + + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListMemberIdentitiesLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListMemberIdentitiesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListMemberIdentitiesLogic { + return &ListMemberIdentitiesLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListMemberIdentitiesLogic) ListMemberIdentities(req *types.AdminUidPath) (resp *types.AdminMemberIdentitiesData, err error) { + uid, err := strconv.ParseInt(req.Uid, 10, 64) + if err != nil { + return nil, err + } + ids, err := l.svcCtx.Auth.ListIdentities(l.ctx, uid) + if err != nil { + return nil, err + } + list := make([]types.IdentityPublic, 0, len(ids)) + for _, id := range ids { + list = append(list, types.IdentityFromModel(id)) + } + return &types.AdminMemberIdentitiesData{List: list}, nil +} diff --git a/apps/backend/internal/logic/admin/list_members_logic.go b/apps/backend/internal/logic/admin/list_members_logic.go new file mode 100644 index 0000000..48b400c --- /dev/null +++ b/apps/backend/internal/logic/admin/list_members_logic.go @@ -0,0 +1,49 @@ +package admin + +import ( + "context" + + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListMembersLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListMembersLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListMembersLogic { + return &ListMembersLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListMembersLogic) ListMembers(req *types.AdminListMembersReq) (resp *types.AdminListData, err error) { + page, pageSize := req.Page, req.PageSize + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + list, total, err := l.svcCtx.Members.ListPage(l.ctx, page, pageSize, req.Query, req.Status) + if err != nil { + return nil, err + } + items := make([]types.MemberPublic, 0, len(list)) + for _, m := range list { + items = append(items, *types.MemberFromModel(m)) + } + totalPages := int(total) / pageSize + if int(total)%pageSize != 0 { + totalPages++ + } + if totalPages == 0 { + totalPages = 1 + } + return &types.AdminListData{ + List: items, + Pagination: types.Pagination{Page: page, PageSize: pageSize, Total: total, TotalPages: totalPages}, + }, nil +} diff --git a/apps/backend/internal/logic/admin/set_email_verified_logic.go b/apps/backend/internal/logic/admin/set_email_verified_logic.go new file mode 100644 index 0000000..506dcd5 --- /dev/null +++ b/apps/backend/internal/logic/admin/set_email_verified_logic.go @@ -0,0 +1,48 @@ +package admin + +import ( + "context" + "strconv" + + "apps/backend/internal/module/member/domain" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type SetEmailVerifiedLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewSetEmailVerifiedLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SetEmailVerifiedLogic { + return &SetEmailVerifiedLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *SetEmailVerifiedLogic) SetEmailVerified(req *types.AdminEmailVerifiedReq) (resp *types.MemberPublic, err error) { + uid, err := strconv.ParseInt(req.Uid, 10, 64) + if err != nil { + return nil, err + } + m, err := l.svcCtx.Members.FindByUID(l.ctx, uid) + if err != nil { + return nil, err + } + m.EmailVerified = req.Verified + if req.Verified { + now := domain.NowNano() + m.EmailVerifiedAt = &now + if m.Status == domain.StatusUnverified { + m.Status = domain.StatusActive + } + } else { + m.EmailVerifiedAt = nil + } + m.UpdatedAt = domain.NowNano() + if err := l.svcCtx.Members.UpdateMember(l.ctx, m); err != nil { + return nil, err + } + return types.MemberFromModel(m), nil +} diff --git a/apps/backend/internal/logic/admin/set_roles_logic.go b/apps/backend/internal/logic/admin/set_roles_logic.go new file mode 100644 index 0000000..7163e9f --- /dev/null +++ b/apps/backend/internal/logic/admin/set_roles_logic.go @@ -0,0 +1,51 @@ +package admin + +import ( + "context" + "strconv" + + "apps/backend/internal/module/member/domain" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type SetRolesLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewSetRolesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SetRolesLogic { + return &SetRolesLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *SetRolesLogic) SetRoles(req *types.AdminRolesReq) (resp *types.MemberPublic, err error) { + uid, err := strconv.ParseInt(req.Uid, 10, 64) + if err != nil { + return nil, err + } + m, err := l.svcCtx.Members.FindByUID(l.ctx, uid) + if err != nil { + return nil, err + } + wasAdmin, willAdmin := m.IsAdmin(), false + for _, r := range req.Roles { + if r == domain.RoleAdmin { + willAdmin = true + } + } + if wasAdmin && !willAdmin { + n, _ := l.svcCtx.Members.CountActiveAdmins(l.ctx) + if n <= 1 { + return nil, domain.ErrLastAdmin + } + } + m.Roles = req.Roles + m.UpdatedAt = domain.NowNano() + if err := l.svcCtx.Members.UpdateMember(l.ctx, m); err != nil { + return nil, err + } + return types.MemberFromModel(m), nil +} diff --git a/apps/backend/internal/logic/admin/set_status_logic.go b/apps/backend/internal/logic/admin/set_status_logic.go new file mode 100644 index 0000000..1ff9db7 --- /dev/null +++ b/apps/backend/internal/logic/admin/set_status_logic.go @@ -0,0 +1,40 @@ +package admin + +import ( + "context" + "strconv" + + "apps/backend/internal/module/member/domain" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type SetStatusLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewSetStatusLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SetStatusLogic { + return &SetStatusLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *SetStatusLogic) SetStatus(req *types.AdminStatusReq) (resp *types.MemberPublic, err error) { + uid, err := strconv.ParseInt(req.Uid, 10, 64) + if err != nil { + return nil, err + } + status := req.Status + switch status { + case domain.StatusActive, domain.StatusSuspended, domain.StatusUnverified, domain.StatusUninitialized: + default: + return nil, domain.ErrNotFound // treat invalid as not found-ish; better weak password reuse? use ErrNotFound ok for now + } + m, err := l.svcCtx.Auth.UpdateStatus(l.ctx, uid, status) + if err != nil { + return nil, err + } + return types.MemberFromModel(m), nil +} diff --git a/apps/backend/internal/logic/admin/set_suspended_logic.go b/apps/backend/internal/logic/admin/set_suspended_logic.go new file mode 100644 index 0000000..ed508cc --- /dev/null +++ b/apps/backend/internal/logic/admin/set_suspended_logic.go @@ -0,0 +1,53 @@ +package admin + +import ( + "context" + "strconv" + + "apps/backend/internal/middleware" + "apps/backend/internal/module/member/domain" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type SetSuspendedLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewSetSuspendedLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SetSuspendedLogic { + return &SetSuspendedLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *SetSuspendedLogic) SetSuspended(req *types.AdminSuspendReq) (resp *types.MemberPublic, err error) { + actor, _ := middleware.UIDFrom(l.ctx) + uid, err := strconv.ParseInt(req.Uid, 10, 64) + if err != nil { + return nil, err + } + if req.Suspended && uid == actor { + return nil, domain.ErrSelfSuspend + } + m, err := l.svcCtx.Members.FindByUID(l.ctx, uid) + if err != nil { + return nil, err + } + if req.Suspended && m.IsAdmin() { + n, _ := l.svcCtx.Members.CountActiveAdmins(l.ctx) + if n <= 1 { + return nil, domain.ErrLastAdmin + } + } + status := domain.StatusActive + if req.Suspended { + status = domain.StatusSuspended + } + m, err = l.svcCtx.Auth.UpdateStatus(l.ctx, uid, status) + if err != nil { + return nil, err + } + return types.MemberFromModel(m), nil +} diff --git a/apps/backend/internal/logic/auth/bind_account_logic.go b/apps/backend/internal/logic/auth/bind_account_logic.go new file mode 100644 index 0000000..9f2a9fd --- /dev/null +++ b/apps/backend/internal/logic/auth/bind_account_logic.go @@ -0,0 +1,31 @@ +package auth + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type BindAccountLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewBindAccountLogic(ctx context.Context, svcCtx *svc.ServiceContext) *BindAccountLogic { + return &BindAccountLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *BindAccountLogic) BindAccount(req *types.AuthBindReq) (resp *types.IdentityPublic, err error) { + uid, _ := middleware.UIDFrom(l.ctx) + id, err := l.svcCtx.Auth.BindAccount(l.ctx, uid, req.LoginId, req.Platform, req.AccountType, req.Password) + if err != nil { + return nil, err + } + out := types.IdentityFromModel(id) + return &out, nil +} diff --git a/apps/backend/internal/logic/auth/list_identities_logic.go b/apps/backend/internal/logic/auth/list_identities_logic.go new file mode 100644 index 0000000..52a2453 --- /dev/null +++ b/apps/backend/internal/logic/auth/list_identities_logic.go @@ -0,0 +1,34 @@ +package auth + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListIdentitiesLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListIdentitiesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListIdentitiesLogic { + return &ListIdentitiesLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListIdentitiesLogic) ListIdentities() (resp *types.AuthIdentitiesData, err error) { + uid, _ := middleware.UIDFrom(l.ctx) + ids, err := l.svcCtx.Auth.ListIdentities(l.ctx, uid) + if err != nil { + return nil, err + } + list := make([]types.IdentityPublic, 0, len(ids)) + for _, id := range ids { + list = append(list, types.IdentityFromModel(id)) + } + return &types.AuthIdentitiesData{List: list}, nil +} diff --git a/apps/backend/internal/logic/auth/login_logic.go b/apps/backend/internal/logic/auth/login_logic.go new file mode 100644 index 0000000..78a42c1 --- /dev/null +++ b/apps/backend/internal/logic/auth/login_logic.go @@ -0,0 +1,42 @@ +package auth + +import ( + "context" + "strings" + + "apps/backend/internal/module/member/domain" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type LoginLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LoginLogic { + return &LoginLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *LoginLogic) Login(req *types.AuthLoginReq) (resp *types.AuthSessionData, err error) { + loginID := strings.TrimSpace(req.LoginId) + if loginID == "" { + loginID = strings.TrimSpace(req.Email) + } + platform := req.Platform + if platform == "" { + platform = domain.PlatformPassword + } + m, pair, err := l.svcCtx.Auth.LoginPassword(l.ctx, loginID, req.Password, platform) + if err != nil { + return nil, err + } + ids, _ := l.svcCtx.Auth.ListIdentities(l.ctx, m.UID) + return &types.AuthSessionData{ + Tokens: types.TokenPairFromAuth(pair.AccessToken, pair.RefreshToken, pair.TokenType, pair.ExpiresIn), + Member: *types.MemberFromModelWithIdentities(m, ids), + }, nil +} diff --git a/apps/backend/internal/logic/auth/logout_all_logic.go b/apps/backend/internal/logic/auth/logout_all_logic.go new file mode 100644 index 0000000..fd33138 --- /dev/null +++ b/apps/backend/internal/logic/auth/logout_all_logic.go @@ -0,0 +1,29 @@ +package auth + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type LogoutAllLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewLogoutAllLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LogoutAllLogic { + return &LogoutAllLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *LogoutAllLogic) LogoutAll() (resp *types.AuthLogoutAllData, err error) { + uid, _ := middleware.UIDFrom(l.ctx) + if err := l.svcCtx.Auth.LogoutAll(l.ctx, uid); err != nil { + return nil, err + } + return &types.AuthLogoutAllData{Ok: true}, nil +} diff --git a/apps/backend/internal/logic/auth/logout_logic.go b/apps/backend/internal/logic/auth/logout_logic.go new file mode 100644 index 0000000..85c7b58 --- /dev/null +++ b/apps/backend/internal/logic/auth/logout_logic.go @@ -0,0 +1,25 @@ +package auth + +import ( + "context" + + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type LogoutLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewLogoutLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LogoutLogic { + return &LogoutLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *LogoutLogic) Logout(req *types.AuthLogoutReq) (resp *types.OkData, err error) { + l.svcCtx.Auth.Logout(req.RefreshToken) + return &types.OkData{Ok: true, Message: "logged out"}, nil +} diff --git a/apps/backend/internal/logic/auth/me_logic.go b/apps/backend/internal/logic/auth/me_logic.go new file mode 100644 index 0000000..a409b35 --- /dev/null +++ b/apps/backend/internal/logic/auth/me_logic.go @@ -0,0 +1,31 @@ +package auth + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type MeLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewMeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *MeLogic { + return &MeLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *MeLogic) Me() (resp *types.MemberPublic, err error) { + uid, _ := middleware.UIDFrom(l.ctx) + m, err := l.svcCtx.Auth.Me(l.ctx, uid) + if err != nil { + return nil, err + } + ids, _ := l.svcCtx.Auth.ListIdentities(l.ctx, m.UID) + return types.MemberFromModelWithIdentities(m, ids), nil +} diff --git a/apps/backend/internal/logic/auth/o_auth_callback_logic.go b/apps/backend/internal/logic/auth/o_auth_callback_logic.go new file mode 100644 index 0000000..d51fb62 --- /dev/null +++ b/apps/backend/internal/logic/auth/o_auth_callback_logic.go @@ -0,0 +1,94 @@ +package auth + +import ( + "context" + "strings" + + "apps/backend/internal/module/member/domain" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type OAuthCallbackLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewOAuthCallbackLogic(ctx context.Context, svcCtx *svc.ServiceContext) *OAuthCallbackLogic { + return &OAuthCallbackLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *OAuthCallbackLogic) OAuthCallback(req *types.AuthOAuthCallbackReq) (resp *types.AuthSessionData, err error) { + provider := strings.ToLower(strings.TrimSpace(req.Provider)) + if provider == "" { + provider = domain.PlatformGoogle + } + + var subject, email, name string + switch provider { + case domain.PlatformGoogle: + token := req.AccessToken + if token == "" { + token = req.IdToken + } + if token == "" { + token = req.Code + } + // allow mock: code or token "mock:sub:email" + if token == "" { + return nil, domain.ErrOAuthFailed + } + if !strings.HasPrefix(token, "mock:") && req.Code != "" && strings.HasPrefix(req.Code, "mock:") { + token = req.Code + } + subject, email, name, err = l.svcCtx.Auth.VerifyGoogleIDToken(l.ctx, token) + if err != nil { + // dev fallback when Google not configured: treat code as mock subject + if req.Code != "" { + subject = provider + "_" + req.Code + email = subject + "@oauth.local" + name = provider + " user" + err = nil + } else { + return nil, err + } + } + case domain.PlatformLine: + code := req.Code + if code == "" { + code = req.AccessToken + } + subject, name, email, err = l.svcCtx.Auth.LineCodeToProfile(l.ctx, code, req.RedirectUri) + if err != nil { + if code != "" { + subject = provider + "_" + code + name = provider + " user" + err = nil + } else { + return nil, err + } + } + default: + // generic mock oauth + code := req.Code + if code == "" { + code = "anon" + } + subject = provider + "_" + code + email = subject + "@oauth.local" + name = provider + " user" + } + + m, pair, err := l.svcCtx.Auth.LoginOAuth(l.ctx, provider, subject, email, name) + if err != nil { + return nil, err + } + ids, _ := l.svcCtx.Auth.ListIdentities(l.ctx, m.UID) + return &types.AuthSessionData{ + Tokens: types.TokenPairFromAuth(pair.AccessToken, pair.RefreshToken, pair.TokenType, pair.ExpiresIn), + Member: *types.MemberFromModelWithIdentities(m, ids), + }, nil +} diff --git a/apps/backend/internal/logic/auth/o_auth_start_logic.go b/apps/backend/internal/logic/auth/o_auth_start_logic.go new file mode 100644 index 0000000..793dbef --- /dev/null +++ b/apps/backend/internal/logic/auth/o_auth_start_logic.go @@ -0,0 +1,32 @@ +package auth + +import ( + "context" + + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type OAuthStartLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewOAuthStartLogic(ctx context.Context, svcCtx *svc.ServiceContext) *OAuthStartLogic { + return &OAuthStartLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *OAuthStartLogic) OAuthStart(req *types.AuthOAuthProviderPath) (resp *types.AuthOAuthStartData, err error) { + p := req.Provider + if p == "" { + p = "google" + } + return &types.AuthOAuthStartData{ + AuthorizeUrl: "https://example.com/oauth/" + p + "/authorize?state=dev", + Provider: p, + State: "dev", + }, nil +} diff --git a/apps/backend/internal/logic/auth/refresh_logic.go b/apps/backend/internal/logic/auth/refresh_logic.go new file mode 100644 index 0000000..e07eabc --- /dev/null +++ b/apps/backend/internal/logic/auth/refresh_logic.go @@ -0,0 +1,30 @@ +package auth + +import ( + "context" + + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type RefreshLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewRefreshLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RefreshLogic { + return &RefreshLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *RefreshLogic) Refresh(req *types.AuthRefreshReq) (resp *types.AuthTokensOnly, err error) { + pair, err := l.svcCtx.Auth.Refresh(l.ctx, req.RefreshToken) + if err != nil { + return nil, err + } + return &types.AuthTokensOnly{ + Tokens: types.TokenPairFromAuth(pair.AccessToken, pair.RefreshToken, pair.TokenType, pair.ExpiresIn), + }, nil +} diff --git a/apps/backend/internal/logic/auth/register_logic.go b/apps/backend/internal/logic/auth/register_logic.go new file mode 100644 index 0000000..3f9c5a9 --- /dev/null +++ b/apps/backend/internal/logic/auth/register_logic.go @@ -0,0 +1,36 @@ +package auth + +import ( + "context" + + "apps/backend/internal/module/member/domain" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type RegisterLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewRegisterLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RegisterLogic { + return &RegisterLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *RegisterLogic) Register(req *types.AuthRegisterReq) (resp *types.AuthSessionData, err error) { + m, pair, err := l.svcCtx.Auth.CreateUserAccount(l.ctx, domain.CreateLoginUserRequest{ + LoginID: req.Email, Platform: domain.PlatformPassword, AccountType: domain.AccountTypeEmail, + Password: req.Password, DisplayName: req.DisplayName, Email: req.Email, Phone: req.Phone, + }) + if err != nil { + return nil, err + } + ids, _ := l.svcCtx.Auth.ListIdentities(l.ctx, m.UID) + return &types.AuthSessionData{ + Tokens: types.TokenPairFromAuth(pair.AccessToken, pair.RefreshToken, pair.TokenType, pair.ExpiresIn), + Member: *types.MemberFromModelWithIdentities(m, ids), + }, nil +} diff --git a/apps/backend/internal/logic/auth/request_password_reset_logic.go b/apps/backend/internal/logic/auth/request_password_reset_logic.go new file mode 100644 index 0000000..23ed356 --- /dev/null +++ b/apps/backend/internal/logic/auth/request_password_reset_logic.go @@ -0,0 +1,94 @@ +package auth + +import ( + "context" + "net/url" + "strings" + + notifDomain "apps/backend/internal/module/notification/domain" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type RequestPasswordResetLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewRequestPasswordResetLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RequestPasswordResetLogic { + return &RequestPasswordResetLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +// RequestPasswordReset 產生重設碼 → 寄信。信箱不存在回錯誤、不寄。 +func (l *RequestPasswordResetLogic) RequestPasswordReset(req *types.AuthRequestResetReq) (resp *types.AuthRequestResetData, err error) { + email := strings.ToLower(strings.TrimSpace(req.Email)) + code, err := l.svcCtx.Auth.RequestPasswordReset(l.ctx, email) + if err != nil { + return nil, err + } + + relPath := resetPath(email, code) + absURL := absoluteWebURL(l.svcCtx.Config.PublicWebBase, relPath) + + name := strings.Split(email, "@")[0] + langRaw := req.Language + if m, findErr := l.svcCtx.Members.FindByEmail(l.ctx, email); findErr == nil && m != nil { + if m.DisplayName != "" { + name = m.DisplayName + } + if langRaw == "" && m.PreferredLanguage != "" { + langRaw = m.PreferredLanguage + } + } + lang := notifDomain.NormalizeLanguage(langRaw) + + subject, html, err := l.svcCtx.Notification.RenderVerify( + notifDomain.TemplatePasswordReset, + lang, + notifDomain.VerifyTemplateVars{ + Username: name, + VerifyCode: code, + ResetURL: absURL, + }, + ) + if err != nil { + l.Errorf("render password reset template: %v", err) + return nil, err + } + + if err := l.svcCtx.Notification.SendEmail(l.ctx, notifDomain.EmailRequest{ + RecipientEmail: email, + SenderEmail: l.svcCtx.Notification.SenderFrom(), + Subject: subject, + HTMLBody: html, + }); err != nil { + l.Errorf("send password reset to %s: %v", email, err) + return nil, err + } + + return &types.AuthRequestResetData{ + Ok: true, + Message: "reset code sent", + }, nil +} + +func resetPath(email, code string) string { + q := url.Values{} + q.Set("email", email) + q.Set("token", code) + return "/reset-password?" + q.Encode() +} + +func absoluteWebURL(base, path string) string { + base = strings.TrimRight(strings.TrimSpace(base), "/") + if base == "" { + return path + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return base + path +} diff --git a/apps/backend/internal/logic/auth/reset_password_logic.go b/apps/backend/internal/logic/auth/reset_password_logic.go new file mode 100644 index 0000000..f9733f1 --- /dev/null +++ b/apps/backend/internal/logic/auth/reset_password_logic.go @@ -0,0 +1,31 @@ +package auth + +import ( + "context" + + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ResetPasswordLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewResetPasswordLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ResetPasswordLogic { + return &ResetPasswordLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ResetPasswordLogic) ResetPassword(req *types.AuthResetPasswordReq) (resp *types.OkData, err error) { + code := req.Code + if code == "" { + code = req.Token + } + if err := l.svcCtx.Auth.ResetPassword(l.ctx, req.Email, code, req.NewPassword); err != nil { + return nil, err + } + return &types.OkData{Ok: true, Message: "password updated"}, nil +} diff --git a/apps/backend/internal/logic/auth/send_email_code_logic.go b/apps/backend/internal/logic/auth/send_email_code_logic.go new file mode 100644 index 0000000..bc1ade5 --- /dev/null +++ b/apps/backend/internal/logic/auth/send_email_code_logic.go @@ -0,0 +1,72 @@ +package auth + +import ( + "context" + "strings" + + "apps/backend/internal/middleware" + notifDomain "apps/backend/internal/module/notification/domain" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type SendEmailCodeLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewSendEmailCodeLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SendEmailCodeLogic { + return &SendEmailCodeLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +// SendEmailCode 產生驗證碼 → hermes 多語模板 → 寄信。不回傳驗證碼給前端。 +func (l *SendEmailCodeLogic) SendEmailCode() (resp *types.AuthSendCodeData, err error) { + uid, _ := middleware.UIDFrom(l.ctx) + code, err := l.svcCtx.Auth.SendEmailCode(l.ctx, uid) + if err != nil { + return nil, err + } + + m, err := l.svcCtx.Members.FindByUID(l.ctx, uid) + if err != nil { + return nil, err + } + to := strings.TrimSpace(m.Email) + if to == "" { + return &types.AuthSendCodeData{Ok: true, Message: "verification code issued"}, nil + } + + name := m.DisplayName + if name == "" { + name = strings.Split(to, "@")[0] + } + lang := notifDomain.NormalizeLanguage(m.PreferredLanguage) + + subject, html, err := l.svcCtx.Notification.RenderVerify( + notifDomain.TemplateEmailVerify, + lang, + notifDomain.VerifyTemplateVars{Username: name, VerifyCode: code}, + ) + if err != nil { + l.Errorf("render email verify template: %v", err) + return nil, err + } + + if err := l.svcCtx.Notification.SendEmail(l.ctx, notifDomain.EmailRequest{ + RecipientEmail: to, + SenderEmail: l.svcCtx.Notification.SenderFrom(), + Subject: subject, + HTMLBody: html, + }); err != nil { + l.Errorf("send email verify to %s: %v", to, err) + return nil, err + } + + return &types.AuthSendCodeData{ + Ok: true, + Message: "verification code sent", + }, nil +} diff --git a/apps/backend/internal/logic/auth/unbind_account_logic.go b/apps/backend/internal/logic/auth/unbind_account_logic.go new file mode 100644 index 0000000..c130b04 --- /dev/null +++ b/apps/backend/internal/logic/auth/unbind_account_logic.go @@ -0,0 +1,29 @@ +package auth + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type UnbindAccountLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewUnbindAccountLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UnbindAccountLogic { + return &UnbindAccountLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *UnbindAccountLogic) UnbindAccount(req *types.AuthUnbindReq) (resp *types.OkData, err error) { + uid, _ := middleware.UIDFrom(l.ctx) + if err := l.svcCtx.Auth.UnbindAccount(l.ctx, uid, req.LoginId, req.Platform); err != nil { + return nil, err + } + return &types.OkData{Ok: true, Message: "unbound"}, nil +} diff --git a/apps/backend/internal/logic/auth/update_profile_logic.go b/apps/backend/internal/logic/auth/update_profile_logic.go new file mode 100644 index 0000000..e95d903 --- /dev/null +++ b/apps/backend/internal/logic/auth/update_profile_logic.go @@ -0,0 +1,47 @@ +package auth + +import ( + "context" + "strings" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type UpdateProfileLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewUpdateProfileLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateProfileLogic { + return &UpdateProfileLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *UpdateProfileLogic) UpdateProfile(req *types.AuthProfilePatchReq) (resp *types.MemberPublic, err error) { + // 頭像:nil=不改;""=清除;非空須為 media upload 回傳的 http(s) URL(禁止 data URL) + if req.AvatarUrl != nil { + u := strings.TrimSpace(*req.AvatarUrl) + if u != "" { + if strings.HasPrefix(u, "data:") || len(u) > 2048 { + return nil, response.Biz(400, 400001, "avatar_url must be a short http(s) URL from media upload, not data URL") + } + if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") { + return nil, response.Biz(400, 400001, "avatar_url must be http(s)") + } + } + // 正規化空白 + *req.AvatarUrl = u + } + uid, _ := middleware.UIDFrom(l.ctx) + m, err := l.svcCtx.Auth.UpdateUserInfo(l.ctx, uid, types.PatchFromAuthProfile(req)) + if err != nil { + return nil, err + } + ids, _ := l.svcCtx.Auth.ListIdentities(l.ctx, m.UID) + return types.MemberFromModelWithIdentities(m, ids), nil +} diff --git a/apps/backend/internal/logic/auth/update_profile_put_logic.go b/apps/backend/internal/logic/auth/update_profile_put_logic.go new file mode 100644 index 0000000..168f7e9 --- /dev/null +++ b/apps/backend/internal/logic/auth/update_profile_put_logic.go @@ -0,0 +1,25 @@ +package auth + +import ( + "context" + + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type UpdateProfilePutLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewUpdateProfilePutLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateProfilePutLogic { + return &UpdateProfilePutLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *UpdateProfilePutLogic) UpdateProfilePut(req *types.AuthProfilePatchReq) (resp *types.MemberPublic, err error) { + // same as PATCH + return NewUpdateProfileLogic(l.ctx, l.svcCtx).UpdateProfile(req) +} diff --git a/apps/backend/internal/logic/auth/verify_email_logic.go b/apps/backend/internal/logic/auth/verify_email_logic.go new file mode 100644 index 0000000..4a91ff9 --- /dev/null +++ b/apps/backend/internal/logic/auth/verify_email_logic.go @@ -0,0 +1,30 @@ +package auth + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type VerifyEmailLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewVerifyEmailLogic(ctx context.Context, svcCtx *svc.ServiceContext) *VerifyEmailLogic { + return &VerifyEmailLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *VerifyEmailLogic) VerifyEmail(req *types.AuthVerifyEmailReq) (resp *types.MemberPublic, err error) { + uid, _ := middleware.UIDFrom(l.ctx) + m, err := l.svcCtx.Auth.VerifyEmail(l.ctx, uid, req.Code) + if err != nil { + return nil, err + } + return types.MemberFromModel(m), nil +} diff --git a/apps/backend/internal/logic/media/upload_logic.go b/apps/backend/internal/logic/media/upload_logic.go new file mode 100644 index 0000000..94a82d4 --- /dev/null +++ b/apps/backend/internal/logic/media/upload_logic.go @@ -0,0 +1,135 @@ +package media + +import ( + "context" + "encoding/base64" + "fmt" + "path" + "strings" + + "apps/backend/internal/middleware" + "apps/backend/internal/response" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/google/uuid" + "github.com/zeromicro/go-zero/core/logx" +) + +const maxUploadBytes = 5 * 1024 * 1024 + +var allowedImageTypes = map[string]string{ + "image/jpeg": ".jpg", + "image/jpg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", + "image/gif": ".gif", +} + +type UploadLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewUploadLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UploadLogic { + return &UploadLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +// Upload 解 data URL → object storage → 回公開 URL(Mongo 只存此 URL)。 +func (l *UploadLogic) Upload(req *types.MediaUploadReq) (*types.MediaUploadData, error) { + if l.svcCtx.Storage == nil || !l.svcCtx.Storage.Enabled() { + return nil, response.Biz(503, 503001, "object storage not configured") + } + uid, ok := middleware.UIDFrom(l.ctx) + if !ok || uid <= 0 { + return nil, response.Biz(401, 401001, "missing authorization") + } + + contentType, raw, err := decodeImageContent(req.Content) + if err != nil { + return nil, response.Biz(400, 400001, err.Error()) + } + if len(raw) > maxUploadBytes { + return nil, response.Biz(400, 400001, "file size exceeds 5MB limit") + } + ext, ok := allowedImageTypes[contentType] + if !ok { + return nil, response.Biz(400, 400001, "unsupported image type (jpeg/png/webp/gif only)") + } + + kind := strings.ToLower(strings.TrimSpace(req.Kind)) + if kind == "" { + kind = "avatar" + } + if kind != "avatar" && kind != "other" { + kind = "other" + } + + id := uuid.NewString() + key := path.Join(kind, fmt.Sprintf("%d", uid), id+ext) + + url, err := l.svcCtx.Storage.Upload(l.ctx, key, raw, contentType) + if err != nil { + l.Errorf("storage upload: %v", err) + return nil, response.Biz(500, 500000, "file upload failed") + } + return &types.MediaUploadData{Url: url, Key: key}, nil +} + +func decodeImageContent(content string) (contentType string, data []byte, err error) { + content = strings.TrimSpace(content) + if content == "" { + return "", nil, fmt.Errorf("empty content") + } + // data:image/jpeg;base64,.... + if strings.HasPrefix(content, "data:") { + parts := strings.SplitN(content, ",", 2) + if len(parts) != 2 { + return "", nil, fmt.Errorf("invalid data url") + } + meta := parts[0] // data:image/jpeg;base64 + meta = strings.TrimPrefix(meta, "data:") + semi := strings.Index(meta, ";") + if semi < 0 { + return "", nil, fmt.Errorf("invalid data url meta") + } + contentType = strings.TrimSpace(meta[:semi]) + payload := parts[1] + data, err = base64.StdEncoding.DecodeString(payload) + if err != nil { + // try raw std without padding issues + data, err = base64.RawStdEncoding.DecodeString(payload) + } + if err != nil { + return "", nil, fmt.Errorf("invalid base64") + } + return contentType, data, nil + } + // pure base64 — sniff + data, err = base64.StdEncoding.DecodeString(content) + if err != nil { + return "", nil, fmt.Errorf("invalid base64") + } + contentType = sniffImage(data) + if contentType == "" { + return "", nil, fmt.Errorf("cannot detect image type") + } + return contentType, data, nil +} + +func sniffImage(b []byte) string { + if len(b) >= 3 && b[0] == 0xff && b[1] == 0xd8 && b[2] == 0xff { + return "image/jpeg" + } + if len(b) >= 8 && b[0] == 0x89 && b[1] == 0x50 && b[2] == 0x4e && b[3] == 0x47 { + return "image/png" + } + if len(b) >= 6 && (string(b[:6]) == "GIF87a" || string(b[:6]) == "GIF89a") { + return "image/gif" + } + if len(b) >= 12 && string(b[0:4]) == "RIFF" && string(b[8:12]) == "WEBP" { + return "image/webp" + } + return "" +} diff --git a/apps/backend/internal/logic/ping/health_logic.go b/apps/backend/internal/logic/ping/health_logic.go new file mode 100644 index 0000000..8f9f238 --- /dev/null +++ b/apps/backend/internal/logic/ping/health_logic.go @@ -0,0 +1,24 @@ +package ping + +import ( + "context" + + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type HealthLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewHealthLogic(ctx context.Context, svcCtx *svc.ServiceContext) *HealthLogic { + return &HealthLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *HealthLogic) Health() (resp *types.HealthData, err error) { + return &types.HealthData{Status: "ok", Service: "haixun-gateway"}, nil +} diff --git a/apps/backend/internal/logic/ping/ping_logic.go b/apps/backend/internal/logic/ping/ping_logic.go new file mode 100644 index 0000000..8591ef3 --- /dev/null +++ b/apps/backend/internal/logic/ping/ping_logic.go @@ -0,0 +1,24 @@ +package ping + +import ( + "context" + + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type PingLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewPingLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PingLogic { + return &PingLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *PingLogic) Ping() (resp *types.HealthData, err error) { + return &types.HealthData{Status: "ok", Service: "haixun-gateway"}, nil +} diff --git a/apps/backend/internal/logic/settings/get_ai_logic.go b/apps/backend/internal/logic/settings/get_ai_logic.go new file mode 100644 index 0000000..59a1367 --- /dev/null +++ b/apps/backend/internal/logic/settings/get_ai_logic.go @@ -0,0 +1,40 @@ +package settings + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/module/member/domain" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetAiLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetAiLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetAiLogic { + return &GetAiLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetAiLogic) GetAi() (resp *types.AiSettingsData, err error) { + uid, _ := middleware.UIDFrom(l.ctx) + st, err := l.svcCtx.Members.GetSettings(l.ctx, uid) + if err != nil { + return nil, err + } + if st == nil { + st = domain.DefaultSettings(uid) + } + return &types.AiSettingsData{ + Provider: st.Provider, Model: st.Model, + ResearchProvider: st.Provider, ResearchModel: st.Model, + ApiKeyConfigured: st.APIKeyConfigured, + ResearchApiKeyConfigured: st.ResearchAPIKeyConfigured || st.APIKeyConfigured, + PlatformKeyAvailable: l.svcCtx.Config.Platform.AIKey != "", + }, nil +} diff --git a/apps/backend/internal/logic/settings/get_placement_logic.go b/apps/backend/internal/logic/settings/get_placement_logic.go new file mode 100644 index 0000000..9daf402 --- /dev/null +++ b/apps/backend/internal/logic/settings/get_placement_logic.go @@ -0,0 +1,38 @@ +package settings + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/module/member/domain" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type GetPlacementLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewGetPlacementLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetPlacementLogic { + return &GetPlacementLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *GetPlacementLogic) GetPlacement() (resp *types.PlacementSettingsData, err error) { + uid, _ := middleware.UIDFrom(l.ctx) + st, _ := l.svcCtx.Members.GetSettings(l.ctx, uid) + if st == nil { + st = domain.DefaultSettings(uid) + } + return &types.PlacementSettingsData{ + WebSearchProvider: "exa", + ExpandStrategy: st.ExpandStrategy, + BraveApiKeyConfigured: false, + ExaApiKeyConfigured: st.ExaAPIKeyConfigured, + DevModeEnabled: st.DevModeEnabled, + PlatformKeyAvailable: l.svcCtx.Config.Platform.ExaKey != "", + }, nil +} diff --git a/apps/backend/internal/logic/settings/list_models_logic.go b/apps/backend/internal/logic/settings/list_models_logic.go new file mode 100644 index 0000000..b51bfaa --- /dev/null +++ b/apps/backend/internal/logic/settings/list_models_logic.go @@ -0,0 +1,32 @@ +package settings + +import ( + "context" + + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type ListModelsLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewListModelsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListModelsLogic { + return &ListModelsLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *ListModelsLogic) ListModels(req *types.ListModelsReq) (resp *types.ListModelsData, err error) { + provider := req.Provider + if provider == "" { + provider = "xai" + } + list := modelsByProvider[provider] + if list == nil { + list = modelsByProvider["xai"] + } + return &types.ListModelsData{List: list, Provider: provider}, nil +} diff --git a/apps/backend/internal/logic/settings/save_ai_logic.go b/apps/backend/internal/logic/settings/save_ai_logic.go new file mode 100644 index 0000000..a20db58 --- /dev/null +++ b/apps/backend/internal/logic/settings/save_ai_logic.go @@ -0,0 +1,75 @@ +package settings + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/module/member/domain" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +var modelsByProvider = map[string][]string{ + "xai": {"grok-3", "grok-3-mini", "grok-2"}, + "opencode-go": {"default", "fast"}, +} + +type SaveAiLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewSaveAiLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SaveAiLogic { + return &SaveAiLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *SaveAiLogic) SaveAi(req *types.SaveAiReq) (resp *types.AiSettingsData, err error) { + uid, _ := middleware.UIDFrom(l.ctx) + st, _ := l.svcCtx.Members.GetSettings(l.ctx, uid) + if st == nil { + st = domain.DefaultSettings(uid) + } + if req.Provider != "" { + st.Provider = req.Provider + } else if req.ResearchProvider != "" { + st.Provider = req.ResearchProvider + } + if req.Model != "" { + st.Model = req.Model + } else if req.ResearchModel != "" { + st.Model = req.ResearchModel + } + if list, ok := modelsByProvider[st.Provider]; ok { + found := false + for _, m := range list { + if m == st.Model { + found = true + break + } + } + if !found && len(list) > 0 { + st.Model = list[0] + } + } + if req.ApiKey != "" { + st.APIKey = req.ApiKey + st.APIKeyConfigured = true + st.ResearchAPIKeyConfigured = true + } + if req.ResearchApiKey != "" { + st.APIKey = req.ResearchApiKey + st.APIKeyConfigured = true + st.ResearchAPIKeyConfigured = true + } + if req.ApiKeyConfigured != nil { + st.APIKeyConfigured = *req.ApiKeyConfigured + if !*req.ApiKeyConfigured { + st.APIKey = "" + } + } + _ = l.svcCtx.Members.SaveSettings(l.ctx, uid, st) + return NewGetAiLogic(l.ctx, l.svcCtx).GetAi() +} diff --git a/apps/backend/internal/logic/settings/save_placement_logic.go b/apps/backend/internal/logic/settings/save_placement_logic.go new file mode 100644 index 0000000..ed0d427 --- /dev/null +++ b/apps/backend/internal/logic/settings/save_placement_logic.go @@ -0,0 +1,46 @@ +package settings + +import ( + "context" + + "apps/backend/internal/middleware" + "apps/backend/internal/module/member/domain" + "apps/backend/internal/svc" + "apps/backend/internal/types" + + "github.com/zeromicro/go-zero/core/logx" +) + +type SavePlacementLogic struct { + logx.Logger + ctx context.Context + svcCtx *svc.ServiceContext +} + +func NewSavePlacementLogic(ctx context.Context, svcCtx *svc.ServiceContext) *SavePlacementLogic { + return &SavePlacementLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx} +} + +func (l *SavePlacementLogic) SavePlacement(req *types.SavePlacementReq) (resp *types.PlacementSettingsData, err error) { + uid, _ := middleware.UIDFrom(l.ctx) + st, _ := l.svcCtx.Members.GetSettings(l.ctx, uid) + if st == nil { + st = domain.DefaultSettings(uid) + } + if req.ExpandStrategy != "" && req.ExpandStrategy != "brave" { + st.ExpandStrategy = req.ExpandStrategy + } + if req.DevModeEnabled != nil { + st.DevModeEnabled = *req.DevModeEnabled + } + if req.ExaApiKey != "" { + st.ExaAPIKey = req.ExaApiKey + st.ExaAPIKeyConfigured = true + } + if req.ExaApiKeyConfigured != nil && !*req.ExaApiKeyConfigured { + st.ExaAPIKey = "" + st.ExaAPIKeyConfigured = false + } + _ = l.svcCtx.Members.SaveSettings(l.ctx, uid, st) + return NewGetPlacementLogic(l.ctx, l.svcCtx).GetPlacement() +} diff --git a/apps/backend/internal/middleware/adminauth_middleware.go b/apps/backend/internal/middleware/adminauth_middleware.go new file mode 100644 index 0000000..5ac6995 --- /dev/null +++ b/apps/backend/internal/middleware/adminauth_middleware.go @@ -0,0 +1,14 @@ +package middleware + +import "net/http" + +// AdminAuthMiddleware is goctl middleware name AdminAuth (after AuthJWT). +type AdminAuthMiddleware struct{} + +func NewAdminAuthMiddleware() *AdminAuthMiddleware { + return &AdminAuthMiddleware{} +} + +func (m *AdminAuthMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc { + return AdminMiddleware(next) +} diff --git a/apps/backend/internal/middleware/auth.go b/apps/backend/internal/middleware/auth.go new file mode 100644 index 0000000..4291d72 --- /dev/null +++ b/apps/backend/internal/middleware/auth.go @@ -0,0 +1,81 @@ +package middleware + +import ( + "context" + "net/http" + "strings" + + "apps/backend/internal/domain" + memberDomain "apps/backend/internal/module/member/domain" + tokenDomain "apps/backend/internal/module/token/domain" + "apps/backend/internal/response" +) + +type ctxKey int + +const ( + ctxUID ctxKey = iota + 1 + ctxRoles + ctxMember +) + +func UIDFrom(ctx context.Context) (int64, bool) { + v, ok := ctx.Value(ctxUID).(int64) + return v, ok +} + +func RolesFrom(ctx context.Context) []string { + v, _ := ctx.Value(ctxRoles).([]string) + return v +} + +// AuthMiddleware validates Bearer access JWT. +func AuthMiddleware(issuer tokenDomain.Issuer, store memberDomain.Repository) func(http.HandlerFunc) http.HandlerFunc { + return func(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + h := r.Header.Get("Authorization") + if h == "" || !strings.HasPrefix(strings.ToLower(h), "bearer ") { + response.Fail(w, http.StatusUnauthorized, 401001, "missing authorization", nil) + return + } + token := strings.TrimSpace(h[7:]) + claims, err := issuer.ParseAccess(token) + if err != nil { + response.Fail(w, http.StatusUnauthorized, 401002, "invalid or expired token", nil) + return + } + m, err := store.FindByUID(r.Context(), claims.UID) + if err != nil { + response.Fail(w, http.StatusUnauthorized, 401003, "member not found", nil) + return + } + if m.IsSuspended() { + response.Fail(w, http.StatusForbidden, 403001, "account suspended", nil) + return + } + ctx := context.WithValue(r.Context(), ctxUID, m.UID) + ctx = context.WithValue(ctx, ctxRoles, m.Roles) + ctx = context.WithValue(ctx, ctxMember, m) + ctx = context.WithValue(ctx, domain.UIDCode, m.UID) + next(w, r.WithContext(ctx)) + } + } +} + +// AdminMiddleware requires admin role (after AuthMiddleware). +func AdminMiddleware(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + for _, role := range RolesFrom(r.Context()) { + if role == memberDomain.RoleAdmin { + next(w, r) + return + } + } + response.Fail(w, http.StatusForbidden, 403002, "admin required", nil) + } +} + +func MemberFrom(ctx context.Context) (*memberDomain.Member, bool) { + m, ok := ctx.Value(ctxMember).(*memberDomain.Member) + return m, ok +} diff --git a/apps/backend/internal/middleware/authjwt_middleware.go b/apps/backend/internal/middleware/authjwt_middleware.go new file mode 100644 index 0000000..c52784e --- /dev/null +++ b/apps/backend/internal/middleware/authjwt_middleware.go @@ -0,0 +1,22 @@ +package middleware + +import ( + "net/http" + + memberDomain "apps/backend/internal/module/member/domain" + tokenDomain "apps/backend/internal/module/token/domain" +) + +// AuthJWTMiddleware is goctl middleware name AuthJWT. +type AuthJWTMiddleware struct { + issuer tokenDomain.Issuer + store memberDomain.Repository +} + +func NewAuthJWTMiddleware(issuer tokenDomain.Issuer, store memberDomain.Repository) *AuthJWTMiddleware { + return &AuthJWTMiddleware{issuer: issuer, store: store} +} + +func (m *AuthJWTMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc { + return AuthMiddleware(m.issuer, m.store)(next) +} diff --git a/apps/backend/internal/middleware/cors.go b/apps/backend/internal/middleware/cors.go new file mode 100644 index 0000000..ebdcd68 --- /dev/null +++ b/apps/backend/internal/middleware/cors.go @@ -0,0 +1,48 @@ +package middleware + +import "net/http" + +// CorsMiddleware — browser CORS for Harbor Desk FE. +// Note: cannot combine Access-Control-Allow-Origin: * with Allow-Credentials: true +// (browsers reject). FE uses Bearer token (no cookies), so no credentials header. +type CorsMiddleware struct{} + +func NewCorsMiddleware() *CorsMiddleware { + return &CorsMiddleware{} +} + +func (m *CorsMiddleware) Handle(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + setHeader(w, r) + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next(w, r) + } +} + +func (m *CorsMiddleware) Handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + setHeader(w, r) + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + w.WriteHeader(http.StatusNotFound) + }) +} + +func setHeader(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + if origin == "" { + origin = "*" + } + // Echo request origin (dev-friendly). Production can whitelist later. + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Vary", "Origin") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Request-Id, device_id") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH") + w.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Type") + // Do NOT set Allow-Credentials with wildcard; FE uses Authorization header only. +} diff --git a/apps/backend/internal/module/filestorage/domain/storage.go b/apps/backend/internal/module/filestorage/domain/storage.go new file mode 100644 index 0000000..c01a02f --- /dev/null +++ b/apps/backend/internal/module/filestorage/domain/storage.go @@ -0,0 +1,15 @@ +package domain + +import "context" + +// Storage — object storage (MinIO / S3 / local). +type Storage interface { + // Upload writes bytes; returns public URL and object key. + Upload(ctx context.Context, key string, data []byte, contentType string) (publicURL string, err error) + // Delete removes object by key (best-effort). + Delete(ctx context.Context, key string) error + // Enabled is false when no backend configured (caller may reject uploads). + Enabled() bool + // Name for logs: s3 | local | noop + Name() string +} diff --git a/apps/backend/internal/module/filestorage/noop/store.go b/apps/backend/internal/module/filestorage/noop/store.go new file mode 100644 index 0000000..511d928 --- /dev/null +++ b/apps/backend/internal/module/filestorage/noop/store.go @@ -0,0 +1,24 @@ +package noop + +import ( + "context" + "fmt" + + "apps/backend/internal/module/filestorage/domain" +) + +// Store rejects uploads (storage not configured). +type Store struct{} + +func New() *Store { return &Store{} } + +func (s *Store) Name() string { return "noop" } +func (s *Store) Enabled() bool { return false } + +func (s *Store) Upload(context.Context, string, []byte, string) (string, error) { + return "", fmt.Errorf("object storage not configured: set ObjectStorage.Endpoint to MinIO/S3 (no local disk)") +} + +func (s *Store) Delete(context.Context, string) error { return nil } + +var _ domain.Storage = (*Store)(nil) diff --git a/apps/backend/internal/module/filestorage/s3store/store.go b/apps/backend/internal/module/filestorage/s3store/store.go new file mode 100644 index 0000000..9e08dab --- /dev/null +++ b/apps/backend/internal/module/filestorage/s3store/store.go @@ -0,0 +1,132 @@ +package s3store + +import ( + "bytes" + "context" + "fmt" + "net/url" + "strings" + + "apps/backend/internal/module/filestorage/domain" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/zeromicro/go-zero/core/logx" +) + +// Config for MinIO / S3-compatible storage. +type Config struct { + Endpoint string // e.g. http://127.0.0.1:9000 + Region string + Bucket string + AccessKey string + SecretKey string + UsePathStyle bool + PublicBaseURL string // e.g. http://127.0.0.1:9000/haixun-assets (no trailing slash) +} + +type Store struct { + client *s3.Client + cfg Config +} + +func New(cfg Config) (*Store, error) { + if cfg.Endpoint == "" || cfg.Bucket == "" { + return nil, fmt.Errorf("s3store: empty endpoint or bucket") + } + if cfg.Region == "" { + cfg.Region = "us-east-1" + } + cfg.Endpoint = strings.TrimRight(cfg.Endpoint, "/") + cfg.PublicBaseURL = strings.TrimRight(cfg.PublicBaseURL, "/") + + client := s3.New(s3.Options{ + Region: cfg.Region, + Credentials: credentials.NewStaticCredentialsProvider( + cfg.AccessKey, cfg.SecretKey, "", + ), + BaseEndpoint: aws.String(cfg.Endpoint), + UsePathStyle: cfg.UsePathStyle, + }) + + s := &Store{client: client, cfg: cfg} + if err := s.ensureBucket(context.Background()); err != nil { + logx.Errorf("s3store ensure bucket %s: %v (will retry on upload)", cfg.Bucket, err) + } + return s, nil +} + +func (s *Store) Name() string { return "s3" } +func (s *Store) Enabled() bool { return true } + +func (s *Store) ensureBucket(ctx context.Context) error { + _, err := s.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(s.cfg.Bucket)}) + if err == nil { + return nil + } + _, err = s.client.CreateBucket(ctx, &s3.CreateBucketInput{ + Bucket: aws.String(s.cfg.Bucket), + }) + if err != nil { + // race: already exists + var exists *types.BucketAlreadyOwnedByYou + var exists2 *types.BucketAlreadyExists + if strings.Contains(err.Error(), "BucketAlready") || + strings.Contains(err.Error(), "already own") { + return nil + } + _ = exists + _ = exists2 + return err + } + // public-read policy optional — MinIO often uses public bucket or public base URL proxy + return nil +} + +func (s *Store) Upload(ctx context.Context, key string, data []byte, contentType string) (string, error) { + key = strings.TrimPrefix(key, "/") + if contentType == "" { + contentType = "application/octet-stream" + } + _, err := s.client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(s.cfg.Bucket), + Key: aws.String(key), + Body: bytes.NewReader(data), + ContentType: aws.String(contentType), + // MinIO: ACL may be disabled; skip ACL for compatibility + }) + if err != nil { + return "", fmt.Errorf("s3 put: %w", err) + } + return s.publicURL(key), nil +} + +func (s *Store) Delete(ctx context.Context, key string) error { + key = strings.TrimPrefix(key, "/") + _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(s.cfg.Bucket), + Key: aws.String(key), + }) + return err +} + +func (s *Store) publicURL(key string) string { + key = strings.TrimPrefix(key, "/") + if s.cfg.PublicBaseURL != "" { + return s.cfg.PublicBaseURL + "/" + key + } + // path-style: http://endpoint/bucket/key + return s.cfg.Endpoint + "/" + s.cfg.Bucket + "/" + escapePath(key) +} + +func escapePath(key string) string { + parts := strings.Split(key, "/") + for i, p := range parts { + parts[i] = url.PathEscape(p) + } + return strings.Join(parts, "/") +} + +var _ domain.Storage = (*Store)(nil) diff --git a/apps/backend/internal/module/member/domain/const.go b/apps/backend/internal/module/member/domain/const.go new file mode 100644 index 0000000..c324e0b --- /dev/null +++ b/apps/backend/internal/module/member/domain/const.go @@ -0,0 +1,40 @@ +package domain + +// Platform — stand-alone const_platform 精神(字串 API,內部可對照) +const ( + PlatformPassword = "platform" // email/phone + password + PlatformGoogle = "google" + PlatformLine = "line" + PlatformApple = "apple" +) + +// AccountType — stand-alone account type +const ( + AccountTypeEmail = "email" + AccountTypePhone = "phone" + AccountTypePlatform = "platform" // third-party subject +) + +// Status — stand-alone member status(字串,對齊 FE) +const ( + StatusUninitialized = "uninitialized" + StatusUnverified = "unverified" + StatusActive = "active" + StatusSuspended = "suspended" +) + +// CodeType for generate/verify codes +const ( + CodeTypeEmail = "email" + CodeTypePhone = "phone" + CodeTypeForgetPassword = "forget_password" +) + +const ( + RoleMember = "member" + RoleAdmin = "admin" +) + +// MinMemberUID — 會員 uid 從一百萬起,固定 8 位數(1000000–99999999)。 +// 顯示可用 %08d;內部 JSON 仍是 number。 +const MinMemberUID int64 = 1_000_000 diff --git a/apps/backend/internal/module/member/domain/errors.go b/apps/backend/internal/module/member/domain/errors.go new file mode 100644 index 0000000..88fb348 --- /dev/null +++ b/apps/backend/internal/module/member/domain/errors.go @@ -0,0 +1,30 @@ +package domain + +import "errors" + +// 密碼政策文案(與 FE password.policy.hint / api.err.400003 一致) +const ( + PasswordMinLen = 12 + PasswordPolicyEN = "Password must be at least 12 characters with upper, lower, digit, and symbol" + PasswordPolicyZH = "密碼須至少 12 碼,且含大寫、小寫、數字與符號" +) + +var ( + ErrNotFound = errors.New("member not found") + ErrEmailNotRegistered = errors.New("email not registered") + ErrEmailTaken = errors.New("email already exists") + ErrPhoneTaken = errors.New("phone already exists") + ErrIdentityTaken = errors.New("login identity already bound") + ErrIdentityNotFound = errors.New("login identity not found") + ErrBadPassword = errors.New("invalid credentials") + ErrSuspended = errors.New("account suspended") + ErrUnverified = errors.New("account not verified") + ErrLastAdmin = errors.New("cannot demote or suspend the last active admin") + ErrSelfSuspend = errors.New("cannot suspend yourself") + // ErrWeakPassword — API 400003;Message = PasswordPolicyEN + ErrWeakPassword = errors.New(PasswordPolicyEN) + ErrInvalidCode = errors.New("invalid or expired code") + ErrAlreadyBound = errors.New("already bound") + ErrInvalidPlatform = errors.New("invalid platform") + ErrOAuthFailed = errors.New("oauth verification failed") +) diff --git a/apps/backend/internal/module/member/domain/member.go b/apps/backend/internal/module/member/domain/member.go new file mode 100644 index 0000000..799150e --- /dev/null +++ b/apps/backend/internal/module/member/domain/member.go @@ -0,0 +1,90 @@ +package domain + +import "time" + +// Member is the person profile (stand-alone user_info),uid 為數字主鍵。 +type Member struct { + UID int64 `bson:"uid" json:"uid"` + TenantID string `bson:"tenant_id" json:"tenant_id"` + Email string `bson:"email" json:"email"` + Phone string `bson:"phone,omitempty" json:"phone,omitempty"` + DisplayName string `bson:"display_name" json:"display_name"` + Nickname string `bson:"nickname,omitempty" json:"nickname,omitempty"` + FullName string `bson:"full_name,omitempty" json:"full_name,omitempty"` + Roles []string `bson:"roles" json:"roles"` + Status string `bson:"status" json:"status"` + EmailVerified bool `bson:"email_verified" json:"email_verified"` + EmailVerifiedAt *int64 `bson:"email_verified_at,omitempty" json:"email_verified_at,omitempty"` + PhoneVerified bool `bson:"phone_verified" json:"phone_verified"` + PhoneVerifiedAt *int64 `bson:"phone_verified_at,omitempty" json:"phone_verified_at,omitempty"` + InviteCode string `bson:"invite_code,omitempty" json:"invite_code,omitempty"` + ParentUID *int64 `bson:"parent_uid,omitempty" json:"parent_uid,omitempty"` + Bio string `bson:"bio,omitempty" json:"bio,omitempty"` + Timezone string `bson:"timezone,omitempty" json:"timezone,omitempty"` + AvatarURL string `bson:"avatar_url,omitempty" json:"avatar_url,omitempty"` + NotifyEmail bool `bson:"notify_email,omitempty" json:"notify_email,omitempty"` + GenderCode int8 `bson:"gender_code,omitempty" json:"gender_code,omitempty"` + Birthdate int64 `bson:"birthdate,omitempty" json:"birthdate,omitempty"` // YYYYMMDD + National string `bson:"national,omitempty" json:"national,omitempty"` + Address string `bson:"address,omitempty" json:"address,omitempty"` + PostCode string `bson:"post_code,omitempty" json:"post_code,omitempty"` + PreferredLanguage string `bson:"preferred_language,omitempty" json:"preferred_language,omitempty"` + Currency string `bson:"currency,omitempty" json:"currency,omitempty"` + // Primary password for platform email login (also mirrored on Identity). + // Must be JSON-tagged: monc Redis cache marshals with encoding/json; + // json:"-" strips the hash and makes warm-cache login always fail. + PasswordHash string `bson:"password_hash,omitempty" json:"password_hash,omitempty"` + CreatedAt int64 `bson:"created_at" json:"created_at"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` +} + +func (m Member) IsAdmin() bool { + for _, r := range m.Roles { + if r == RoleAdmin { + return true + } + } + return false +} + +func (m Member) IsSuspended() bool { + return m.Status == StatusSuspended +} + +// Identity binds a login channel to a member (stand-alone account + account_uid). +type Identity struct { + ID string `bson:"_id,omitempty" json:"id,omitempty"` + UID int64 `bson:"uid" json:"uid"` + LoginID string `bson:"login_id" json:"login_id"` // email / phone / oauth subject + Platform string `bson:"platform" json:"platform"` // platform|google|line|apple + AccountType string `bson:"account_type" json:"account_type"` + // json tag required for monc cache (see Member.PasswordHash). + PasswordHash string `bson:"password_hash,omitempty" json:"password_hash,omitempty"` + CreatedAt int64 `bson:"created_at" json:"created_at"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` +} + +// UserSettings — AI/placement overrides +type UserSettings struct { + UID int64 `bson:"uid" json:"uid"` + Provider string `bson:"provider" json:"provider"` + Model string `bson:"model" json:"model"` + APIKeyConfigured bool `bson:"api_key_configured" json:"api_key_configured"` + ResearchAPIKeyConfigured bool `bson:"research_api_key_configured" json:"research_api_key_configured"` + APIKey string `bson:"api_key,omitempty" json:"-"` + ExaAPIKey string `bson:"exa_api_key,omitempty" json:"-"` + WebSearchProvider string `bson:"web_search_provider" json:"web_search_provider"` + ExpandStrategy string `bson:"expand_strategy" json:"expand_strategy"` + ExaAPIKeyConfigured bool `bson:"exa_api_key_configured" json:"exa_api_key_configured"` + DevModeEnabled bool `bson:"dev_mode_enabled" json:"dev_mode_enabled"` + UpdatedAt int64 `bson:"updated_at" json:"updated_at"` +} + +func DefaultSettings(uid int64) *UserSettings { + return &UserSettings{ + UID: uid, Provider: "xai", Model: "grok-3", + WebSearchProvider: "exa", ExpandStrategy: "hybrid", + } +} + +func NowNano() int64 { return time.Now().UTC().UnixNano() } diff --git a/apps/backend/internal/module/member/domain/repository.go b/apps/backend/internal/module/member/domain/repository.go new file mode 100644 index 0000000..7f2a418 --- /dev/null +++ b/apps/backend/internal/module/member/domain/repository.go @@ -0,0 +1,42 @@ +package domain + +import ( + "context" + "time" +) + +// Repository — members + multi-platform identities + session helpers. +type Repository interface { + NextUID(ctx context.Context) (int64, error) + + // Member profile + CreateMember(ctx context.Context, m *Member) error + FindByUID(ctx context.Context, uid int64) (*Member, error) + FindByEmail(ctx context.Context, email string) (*Member, error) + FindByPhone(ctx context.Context, phone string) (*Member, error) + UpdateMember(ctx context.Context, m *Member) error + ListPage(ctx context.Context, page, pageSize int, query string, status string) (list []*Member, total int64, err error) + CountActiveAdmins(ctx context.Context) (int64, error) + + // Identities (login channels) + CreateIdentity(ctx context.Context, id *Identity) error + FindIdentity(ctx context.Context, loginID, platform string) (*Identity, error) + ListIdentitiesByUID(ctx context.Context, uid int64) ([]*Identity, error) + DeleteIdentity(ctx context.Context, loginID, platform string) error + UpdateIdentityPassword(ctx context.Context, loginID, platform, passwordHash string) error + + // Codes / refresh (Mongo durable) + SetCode(kind, key, code string, ttl time.Duration) + // 驗證碼共用語意(信箱驗證 / 重設密碼相同): + // CheckCode:碼正確才刪(成功消費);錯誤不刪;過期才刪 + // PeekCode:只檢查不消費(業務前先 Peek,成功後再 Check) + CheckCode(kind, key, code string) bool + PeekCode(kind, key, code string) bool + SaveRefresh(jti string, uid int64, exp time.Time) + ConsumeRefresh(jti string) (uid int64, ok bool) + RevokeRefresh(jti string) + RevokeAllRefreshByUID(ctx context.Context, uid int64) error + + GetSettings(ctx context.Context, uid int64) (*UserSettings, error) + SaveSettings(ctx context.Context, uid int64, s *UserSettings) error +} diff --git a/apps/backend/internal/module/member/domain/usecase.go b/apps/backend/internal/module/member/domain/usecase.go new file mode 100644 index 0000000..add6169 --- /dev/null +++ b/apps/backend/internal/module/member/domain/usecase.go @@ -0,0 +1,92 @@ +package domain + +import ( + "context" + + tokenDomain "apps/backend/internal/module/token/domain" +) + +// AccountUseCase — stand-alone AccountUseCase 完整能力(數字 uid)。 +type AccountUseCase interface { + // 建立 / 登入 + CreateUserAccount(ctx context.Context, req CreateLoginUserRequest) (*Member, *tokenDomain.TokenPair, error) + LoginPassword(ctx context.Context, loginID, password, platform string) (*Member, *tokenDomain.TokenPair, error) + LoginOAuth(ctx context.Context, platform, subject, email, displayName string) (*Member, *tokenDomain.TokenPair, error) + + // 查詢 + GetUIDByAccount(ctx context.Context, account, platform string) (uid int64, err error) + GetUserAccountInfo(ctx context.Context, account, platform string) (*Identity, error) + GetUserInfo(ctx context.Context, uid int64) (*Member, error) + ListMember(ctx context.Context, page, pageSize int, query, status string) ([]*Member, int64, error) + ListIdentities(ctx context.Context, uid int64) ([]*Identity, error) + FindLoginIDsByUID(ctx context.Context, uid int64) ([]*Identity, error) + + // 更新 + UpdateUserToken(ctx context.Context, loginID, platform, newPassword string) error + UpdateUserInfo(ctx context.Context, uid int64, patch *UpdateUserInfoPatch) (*Member, error) + UpdateStatus(ctx context.Context, uid int64, status string) (*Member, error) + + // Session + Refresh(ctx context.Context, refreshToken string) (*tokenDomain.TokenPair, error) + Logout(refreshToken string) + LogoutAll(ctx context.Context, uid int64) error + + // Binding + BindAccount(ctx context.Context, uid int64, loginID, platform, accountType, password string) (*Identity, error) + UnbindAccount(ctx context.Context, uid int64, loginID, platform string) error + BindVerifyEmail(ctx context.Context, uid int64, email string) error + BindVerifyPhone(ctx context.Context, uid int64, phone string) error + + // Verify codes + GenerateCode(ctx context.Context, loginID, codeType string) (code string, err error) + VerifyCode(ctx context.Context, loginID, codeType, code string, consume bool) error + + // Platform password check (email/phone password) + VerifyPlatformAuthResult(ctx context.Context, loginID, platform, password string) (bool, error) + + // OAuth providers + VerifyGoogleIDToken(ctx context.Context, idToken string) (subject, email, name string, err error) + LineCodeToProfile(ctx context.Context, code, redirectURI string) (subject, displayName, email string, err error) + + // Convenience Harbor Desk paths + RegisterEmail(ctx context.Context, email, password, displayName string) (*Member, *tokenDomain.TokenPair, error) + Me(ctx context.Context, uid int64) (*Member, error) + // RequestPasswordReset returns code or ErrEmailNotRegistered if unknown email. + RequestPasswordReset(ctx context.Context, email string) (code string, err error) + ResetPassword(ctx context.Context, email, code, newPassword string) error + SendEmailCode(ctx context.Context, uid int64) (string, error) + VerifyEmailCode(ctx context.Context, uid int64, code string) (*Member, error) +} + +// CreateLoginUserRequest — stand-alone create account +type CreateLoginUserRequest struct { + LoginID string + Platform string // platform|google|line|apple + AccountType string // email|phone|platform + Password string // for platform + DisplayName string + Email string // profile email if oauth + Phone string +} + +// UpdateUserInfoPatch — stand-alone UpdateUserInfoRequest +type UpdateUserInfoPatch struct { + DisplayName *string + Nickname *string + FullName *string + AvatarURL *string + Bio *string + Timezone *string + NotifyEmail *bool + GenderCode *int8 + Birthdate *int64 + National *string + Address *string + PostCode *string + PreferredLanguage *string + Currency *string + Email *string + Phone *string + CurrentPassword *string + NewPassword *string +} diff --git a/apps/backend/internal/module/member/repository/mongo_store.go b/apps/backend/internal/module/member/repository/mongo_store.go new file mode 100644 index 0000000..99e5008 --- /dev/null +++ b/apps/backend/internal/module/member/repository/mongo_store.go @@ -0,0 +1,442 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + libmongo "apps/backend/internal/lib/mongo" + "apps/backend/internal/module/member/domain" + + "github.com/zeromicro/go-zero/core/stores/cache" + "github.com/zeromicro/go-zero/core/stores/mon" + "github.com/zeromicro/go-zero/core/stores/monc" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +const ( + colMembers = "members" + colIdentities = "identities" + colSettings = "member_settings" + colCounters = "counters" + colRefresh = "refresh_tokens" + colAuthCodes = "auth_codes" +) + +// MoncStore: members/identities/settings via monc; refresh/codes durable Mongo. +type MoncStore struct { + uri string + db string + member *monc.Model + plain *mon.Model + identity *monc.Model + idPlain *mon.Model + settings *monc.Model + refresh *mon.Model + codes *mon.Model +} + +func NewMoncStore(uri, database string, cacheConf cache.CacheConf) *MoncStore { + uri = libmongo.MustMongoURI(uri) + return &MoncStore{ + uri: uri, + db: database, + member: monc.MustNewModel(uri, database, colMembers, cacheConf), + plain: mon.MustNewModel(uri, database, colMembers), + identity: monc.MustNewModel(uri, database, colIdentities, cacheConf), + idPlain: mon.MustNewModel(uri, database, colIdentities), + settings: monc.MustNewModel(uri, database, colSettings, cacheConf), + refresh: mon.MustNewModel(uri, database, colRefresh), + codes: mon.MustNewModel(uri, database, colAuthCodes), + } +} + +func cacheKeyUID(uid int64) string { return fmt.Sprintf("cache:member:uid:%d", uid) } +func cacheKeyEmail(email string) string { + return fmt.Sprintf("cache:member:email:%s", strings.ToLower(email)) +} +func cacheKeyPhone(phone string) string { + return fmt.Sprintf("cache:member:phone:%s", strings.TrimSpace(phone)) +} +func cacheKeySettings(uid int64) string { return fmt.Sprintf("cache:member:settings:%d", uid) } +func cacheKeyIdentity(loginID, platform string) string { + return fmt.Sprintf("cache:identity:%s:%s", platform, strings.ToLower(loginID)) +} +func cacheKeyIdentitiesUID(uid int64) string { + return fmt.Sprintf("cache:identities:uid:%d", uid) +} + +func (s *MoncStore) NextUID(ctx context.Context) (int64, error) { + counters := mon.MustNewModel(s.uri, s.db, colCounters) + filter := bson.M{"_id": "member_uid"} + update := bson.M{"$inc": bson.M{"seq": int64(1)}} + opts := options.FindOneAndUpdate().SetUpsert(true).SetReturnDocument(options.After) + var doc struct { + Seq int64 `bson:"seq"` + } + err := counters.FindOneAndUpdate(ctx, &doc, filter, update, opts) + if err != nil { + return 0, err + } + // Floor at MinMemberUID (1_000_000 → 8 digits). First free uid after seed admin. + if doc.Seq < domain.MinMemberUID { + // Jump counter to MinMemberUID; return next free after reserved seed band. + // Seed admin uses MinMemberUID; auto-issue starts at MinMemberUID+1. + next := domain.MinMemberUID + 1 + _, _ = counters.UpdateOne(ctx, filter, bson.M{"$set": bson.M{"seq": next}}) + return next, nil + } + return doc.Seq, nil +} + +func (s *MoncStore) CreateMember(ctx context.Context, m *domain.Member) error { + m.Email = strings.ToLower(strings.TrimSpace(m.Email)) + m.Phone = strings.TrimSpace(m.Phone) + _, err := s.member.InsertOne(ctx, cacheKeyUID(m.UID), m) + if err != nil { + if mongo.IsDuplicateKeyError(err) { + return domain.ErrEmailTaken + } + return err + } + _ = s.member.DelCache(ctx, cacheKeyEmail(m.Email)) + if m.Phone != "" { + _ = s.member.DelCache(ctx, cacheKeyPhone(m.Phone)) + } + return nil +} + +func (s *MoncStore) FindByUID(ctx context.Context, uid int64) (*domain.Member, error) { + var m domain.Member + err := s.member.FindOne(ctx, cacheKeyUID(uid), &m, bson.M{"uid": uid}) + if errors.Is(err, monc.ErrNotFound) || errors.Is(err, mon.ErrNotFound) { + return nil, domain.ErrNotFound + } + if err != nil { + return nil, err + } + return &m, nil +} + +func (s *MoncStore) FindByEmail(ctx context.Context, email string) (*domain.Member, error) { + email = strings.ToLower(strings.TrimSpace(email)) + var m domain.Member + err := s.member.FindOne(ctx, cacheKeyEmail(email), &m, bson.M{"email": email}) + if errors.Is(err, monc.ErrNotFound) || errors.Is(err, mon.ErrNotFound) { + return nil, domain.ErrNotFound + } + if err != nil { + return nil, err + } + return &m, nil +} + +func (s *MoncStore) FindByPhone(ctx context.Context, phone string) (*domain.Member, error) { + phone = strings.TrimSpace(phone) + if phone == "" { + return nil, domain.ErrNotFound + } + var m domain.Member + err := s.member.FindOne(ctx, cacheKeyPhone(phone), &m, bson.M{"phone": phone}) + if errors.Is(err, monc.ErrNotFound) || errors.Is(err, mon.ErrNotFound) { + return nil, domain.ErrNotFound + } + if err != nil { + return nil, err + } + return &m, nil +} + +func (s *MoncStore) UpdateMember(ctx context.Context, m *domain.Member) error { + m.Email = strings.ToLower(strings.TrimSpace(m.Email)) + m.Phone = strings.TrimSpace(m.Phone) + m.UpdatedAt = time.Now().UTC().UnixNano() + filter := bson.M{"uid": m.UID} + res, err := s.plain.ReplaceOne(ctx, filter, m) + if err != nil { + if mongo.IsDuplicateKeyError(err) { + return domain.ErrEmailTaken + } + return err + } + if res.MatchedCount == 0 { + return domain.ErrNotFound + } + _ = s.member.DelCache(ctx, cacheKeyUID(m.UID), cacheKeyEmail(m.Email)) + if m.Phone != "" { + _ = s.member.DelCache(ctx, cacheKeyPhone(m.Phone)) + } + return nil +} + +func (s *MoncStore) ListPage(ctx context.Context, page, pageSize int, query, status string) ([]*domain.Member, int64, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + if pageSize > 50 { + pageSize = 50 + } + // Harbor members use numeric int64 uid. Exclude legacy docs (e.g. UUID string uid + // from older systems in the same Mongo DB) so decode cannot blow up admin list. + filter := bson.M{ + "uid": bson.M{"$type": bson.A{"int", "long", "double"}}, + } + q := strings.TrimSpace(query) + if q != "" { + filter["$or"] = []bson.M{ + {"email": bson.M{"$regex": q, "$options": "i"}}, + {"display_name": bson.M{"$regex": q, "$options": "i"}}, + {"phone": bson.M{"$regex": q, "$options": "i"}}, + {"nickname": bson.M{"$regex": q, "$options": "i"}}, + } + } + if st := strings.TrimSpace(status); st != "" { + filter["status"] = st + } + total, err := s.plain.CountDocuments(ctx, filter) + if err != nil { + return nil, 0, err + } + var list []*domain.Member + err = s.plain.Find(ctx, &list, filter, + options.Find().SetSkip(int64((page-1)*pageSize)).SetLimit(int64(pageSize)).SetSort(bson.D{{Key: "uid", Value: 1}}), + ) + if err != nil { + return nil, 0, err + } + return list, total, nil +} + +func (s *MoncStore) CountActiveAdmins(ctx context.Context) (int64, error) { + return s.plain.CountDocuments(ctx, bson.M{"status": domain.StatusActive, "roles": domain.RoleAdmin}) +} + +// --- identities --- + +func (s *MoncStore) CreateIdentity(ctx context.Context, id *domain.Identity) error { + id.LoginID = normalizeLoginID(id.LoginID, id.Platform, id.AccountType) + id.Platform = strings.ToLower(strings.TrimSpace(id.Platform)) + if id.CreatedAt == 0 { + id.CreatedAt = domain.NowNano() + } + id.UpdatedAt = id.CreatedAt + key := cacheKeyIdentity(id.LoginID, id.Platform) + _, err := s.identity.InsertOne(ctx, key, id) + if err != nil { + if mongo.IsDuplicateKeyError(err) { + return domain.ErrIdentityTaken + } + return err + } + _ = s.identity.DelCache(ctx, cacheKeyIdentitiesUID(id.UID)) + return nil +} + +func (s *MoncStore) FindIdentity(ctx context.Context, loginID, platform string) (*domain.Identity, error) { + platform = strings.ToLower(strings.TrimSpace(platform)) + loginID = normalizeLoginID(loginID, platform, "") + var id domain.Identity + err := s.identity.FindOne(ctx, cacheKeyIdentity(loginID, platform), &id, bson.M{ + "login_id": loginID, "platform": platform, + }) + if errors.Is(err, monc.ErrNotFound) || errors.Is(err, mon.ErrNotFound) { + return nil, domain.ErrIdentityNotFound + } + if err != nil { + return nil, err + } + return &id, nil +} + +func (s *MoncStore) ListIdentitiesByUID(ctx context.Context, uid int64) ([]*domain.Identity, error) { + var list []*domain.Identity + // list is multi-doc; use plain mon (no monc multi-get) + err := s.idPlain.Find(ctx, &list, bson.M{"uid": uid}, options.Find().SetSort(bson.D{{Key: "created_at", Value: 1}})) + if err != nil { + return nil, err + } + return list, nil +} + +func (s *MoncStore) DeleteIdentity(ctx context.Context, loginID, platform string) error { + platform = strings.ToLower(strings.TrimSpace(platform)) + loginID = normalizeLoginID(loginID, platform, "") + var existing domain.Identity + err := s.idPlain.FindOne(ctx, &existing, bson.M{"login_id": loginID, "platform": platform}) + if errors.Is(err, mon.ErrNotFound) { + return domain.ErrIdentityNotFound + } + if err != nil { + return err + } + _, err = s.idPlain.DeleteOne(ctx, bson.M{"login_id": loginID, "platform": platform}) + if err != nil { + return err + } + _ = s.identity.DelCache(ctx, cacheKeyIdentity(loginID, platform), cacheKeyIdentitiesUID(existing.UID)) + return nil +} + +func (s *MoncStore) UpdateIdentityPassword(ctx context.Context, loginID, platform, passwordHash string) error { + platform = strings.ToLower(strings.TrimSpace(platform)) + loginID = normalizeLoginID(loginID, platform, "") + res, err := s.idPlain.UpdateOne(ctx, + bson.M{"login_id": loginID, "platform": platform}, + bson.M{"$set": bson.M{"password_hash": passwordHash, "updated_at": domain.NowNano()}}, + ) + if err != nil { + return err + } + if res.MatchedCount == 0 { + return domain.ErrIdentityNotFound + } + _ = s.identity.DelCache(ctx, cacheKeyIdentity(loginID, platform)) + return nil +} + +func normalizeLoginID(loginID, platform, accountType string) string { + loginID = strings.TrimSpace(loginID) + if platform == domain.PlatformPassword || accountType == domain.AccountTypeEmail { + return strings.ToLower(loginID) + } + if strings.Contains(loginID, "@") { + return strings.ToLower(loginID) + } + return loginID +} + +// --- durable auth_codes --- + +type authCodeDoc struct { + ID string `bson:"_id"` + Kind string `bson:"kind"` + Key string `bson:"key"` + Code string `bson:"code"` + ExpiresAt time.Time `bson:"expires_at"` +} + +func (s *MoncStore) SetCode(kind, key, code string, ttl time.Duration) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + id := kind + ":" + key + doc := authCodeDoc{ + ID: id, Kind: kind, Key: key, Code: code, + ExpiresAt: time.Now().UTC().Add(ttl), + } + _, _ = s.codes.ReplaceOne(ctx, bson.M{"_id": id}, doc, options.Replace().SetUpsert(true)) +} + +// CheckCode 比對驗證碼: +// - 正確 → 刪除(一次性消費)並回 true +// - 錯誤 → 不刪,TTL 內可重試 +// - 過期 → 刪除(清垃圾)並回 false +func (s *MoncStore) CheckCode(kind, key, code string) bool { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + id := kind + ":" + key + var doc authCodeDoc + err := s.codes.FindOne(ctx, &doc, bson.M{"_id": id}) + if err != nil { + return false + } + if time.Now().UTC().After(doc.ExpiresAt) { + _, _ = s.codes.DeleteOne(ctx, bson.M{"_id": id}) + return false + } + if doc.Code != code { + // 輸錯不銷毀:N 分鐘內可繼續試,不必重寄 + return false + } + _, _ = s.codes.DeleteOne(ctx, bson.M{"_id": id}) + return true +} + +// PeekCode 只檢查、不消費;過期時順便刪除。 +func (s *MoncStore) PeekCode(kind, key, code string) bool { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + id := kind + ":" + key + var doc authCodeDoc + err := s.codes.FindOne(ctx, &doc, bson.M{"_id": id}) + if err != nil { + return false + } + if time.Now().UTC().After(doc.ExpiresAt) { + _, _ = s.codes.DeleteOne(ctx, bson.M{"_id": id}) + return false + } + return doc.Code == code +} + +// --- durable refresh_tokens --- + +type refreshDoc struct { + JTI string `bson:"_id"` + UID int64 `bson:"uid"` + ExpiresAt time.Time `bson:"expires_at"` +} + +func (s *MoncStore) SaveRefresh(jti string, uid int64, exp time.Time) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + doc := refreshDoc{JTI: jti, UID: uid, ExpiresAt: exp.UTC()} + _, _ = s.refresh.ReplaceOne(ctx, bson.M{"_id": jti}, doc, options.Replace().SetUpsert(true)) +} + +func (s *MoncStore) ConsumeRefresh(jti string) (int64, bool) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + var doc refreshDoc + err := s.refresh.FindOneAndDelete(ctx, &doc, bson.M{"_id": jti}) + if err != nil { + return 0, false + } + if time.Now().UTC().After(doc.ExpiresAt) { + return 0, false + } + return doc.UID, true +} + +func (s *MoncStore) RevokeRefresh(jti string) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, _ = s.refresh.DeleteOne(ctx, bson.M{"_id": jti}) +} + +func (s *MoncStore) RevokeAllRefreshByUID(ctx context.Context, uid int64) error { + _, err := s.refresh.DeleteMany(ctx, bson.M{"uid": uid}) + return err +} + +// --- settings --- + +func (s *MoncStore) GetSettings(ctx context.Context, uid int64) (*domain.UserSettings, error) { + var st domain.UserSettings + err := s.settings.FindOne(ctx, cacheKeySettings(uid), &st, bson.M{"uid": uid}) + if errors.Is(err, monc.ErrNotFound) || errors.Is(err, mon.ErrNotFound) { + return domain.DefaultSettings(uid), nil + } + if err != nil { + return nil, err + } + return &st, nil +} + +func (s *MoncStore) SaveSettings(ctx context.Context, uid int64, st *domain.UserSettings) error { + st.UID = uid + st.UpdatedAt = time.Now().UTC().UnixNano() + settingsPlain := mon.MustNewModel(s.uri, s.db, colSettings) + _, err := settingsPlain.ReplaceOne(ctx, bson.M{"uid": uid}, st, options.Replace().SetUpsert(true)) + if err != nil { + return err + } + _ = s.settings.DelCache(ctx, cacheKeySettings(uid)) + return nil +} diff --git a/apps/backend/internal/module/member/usecase/account.go b/apps/backend/internal/module/member/usecase/account.go new file mode 100644 index 0000000..f435577 --- /dev/null +++ b/apps/backend/internal/module/member/usecase/account.go @@ -0,0 +1,800 @@ +package usecase + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "math/big" + "strings" + "time" + + "apps/backend/internal/module/member/domain" + tokenDomain "apps/backend/internal/module/token/domain" +) + +// AccountService implements domain.AccountUseCase (stand-alone full member surface). +// AuthService is an alias so existing callers keep working. +type AccountService struct { + Repo domain.Repository + Issuer tokenDomain.Issuer + BcryptCost int + // OAuth optional config (empty = dev mock path) + GoogleClientID string + LineClientID string + LineClientSecret string + LineRedirectURI string +} + +// AuthService kept as type alias for svc/logic compatibility. +type AuthService = AccountService + +func NewAuthService(repo domain.Repository, issuer tokenDomain.Issuer, bcryptCost int) *AccountService { + return NewAccountService(repo, issuer, bcryptCost) +} + +func NewAccountService(repo domain.Repository, issuer tokenDomain.Issuer, bcryptCost int) *AccountService { + if bcryptCost <= 0 { + bcryptCost = 10 + } + return &AccountService{Repo: repo, Issuer: issuer, BcryptCost: bcryptCost} +} + +// ---------- create / login ---------- + +func (s *AccountService) CreateUserAccount(ctx context.Context, req domain.CreateLoginUserRequest) (*domain.Member, *tokenDomain.TokenPair, error) { + platform := strings.ToLower(strings.TrimSpace(req.Platform)) + if platform == "" { + platform = domain.PlatformPassword + } + loginID := strings.TrimSpace(req.LoginID) + if loginID == "" { + return nil, nil, domain.ErrBadPassword + } + if platform == domain.PlatformPassword { + loginID = strings.ToLower(loginID) + } + + // already bound? + if _, err := s.Repo.FindIdentity(ctx, loginID, platform); err == nil { + return nil, nil, domain.ErrIdentityTaken + } + + uid, err := s.Repo.NextUID(ctx) + if err != nil { + return nil, nil, err + } + now := domain.NowNano() + display := strings.TrimSpace(req.DisplayName) + email := strings.ToLower(strings.TrimSpace(req.Email)) + phone := strings.TrimSpace(req.Phone) + if platform == domain.PlatformPassword { + if req.AccountType == domain.AccountTypePhone || looksLikePhone(loginID) { + if phone == "" { + phone = loginID + } + req.AccountType = domain.AccountTypePhone + } else { + if email == "" { + email = loginID + } + req.AccountType = domain.AccountTypeEmail + } + } + if display == "" { + if email != "" { + display = strings.Split(email, "@")[0] + } else { + display = loginID + } + } + + var passHash string + if platform == domain.PlatformPassword { + if req.Password == "" { + return nil, nil, domain.ErrWeakPassword + } + passHash, err = HashPassword(req.Password, s.BcryptCost) + if err != nil { + return nil, nil, err + } + } + + status := domain.StatusActive + if platform == domain.PlatformPassword { + status = domain.StatusUnverified + } + + m := &domain.Member{ + UID: uid, TenantID: "default", Email: email, Phone: phone, + DisplayName: display, Roles: []string{domain.RoleMember}, Status: status, + EmailVerified: false, InviteCode: NewInviteCode(), PasswordHash: passHash, + Timezone: "Asia/Taipei", PreferredLanguage: "zh-TW", Currency: "TWD", + CreatedAt: now, UpdatedAt: now, + } + if err := s.Repo.CreateMember(ctx, m); err != nil { + return nil, nil, err + } + + accType := req.AccountType + if accType == "" { + if platform == domain.PlatformPassword { + if phone != "" && email == "" { + accType = domain.AccountTypePhone + } else { + accType = domain.AccountTypeEmail + } + } else { + accType = domain.AccountTypePlatform + } + } + ident := &domain.Identity{ + UID: uid, LoginID: loginID, Platform: platform, + AccountType: accType, PasswordHash: passHash, CreatedAt: now, UpdatedAt: now, + } + if err := s.Repo.CreateIdentity(ctx, ident); err != nil { + return nil, nil, err + } + + pair, err := s.issueSession(m) + if err != nil { + return m, nil, err + } + return m, pair, nil +} + +func (s *AccountService) LoginPassword(ctx context.Context, loginID, password, platform string) (*domain.Member, *tokenDomain.TokenPair, error) { + if platform == "" { + platform = domain.PlatformPassword + } + loginID = strings.TrimSpace(loginID) + if platform == domain.PlatformPassword { + loginID = strings.ToLower(loginID) + } + + // Prefer identity; fallback member email for seed admin without identity row + ident, err := s.Repo.FindIdentity(ctx, loginID, platform) + var m *domain.Member + switch { + case err == nil: + hash := ident.PasswordHash + if hash == "" || !CheckPasswordHash(password, hash) { + // also try member primary password + m, err = s.Repo.FindByUID(ctx, ident.UID) + if err != nil { + return nil, nil, err + } + if m.PasswordHash == "" || !CheckPasswordHash(password, m.PasswordHash) { + return nil, nil, domain.ErrBadPassword + } + } else { + m, err = s.Repo.FindByUID(ctx, ident.UID) + if err != nil { + return nil, nil, err + } + } + case errors.Is(err, domain.ErrIdentityNotFound): + // legacy: email login without identity + m, err = s.Repo.FindByEmail(ctx, loginID) + if errors.Is(err, domain.ErrNotFound) { + return nil, nil, domain.ErrBadPassword + } + if err != nil { + return nil, nil, err // DB/auth errors must not look like bad password + } + if m.PasswordHash == "" || !CheckPasswordHash(password, m.PasswordHash) { + return nil, nil, domain.ErrBadPassword + } + // auto-heal identity for seed users + _ = s.Repo.CreateIdentity(ctx, &domain.Identity{ + UID: m.UID, LoginID: m.Email, Platform: domain.PlatformPassword, + AccountType: domain.AccountTypeEmail, PasswordHash: m.PasswordHash, + CreatedAt: domain.NowNano(), UpdatedAt: domain.NowNano(), + }) + default: + // infrastructure failure (e.g. Mongo auth) + return nil, nil, err + } + if m.IsSuspended() { + return nil, nil, domain.ErrSuspended + } + pair, err := s.issueSession(m) + if err != nil { + return nil, nil, err + } + return m, pair, nil +} + +// Login — Harbor Desk email login shorthand +func (s *AccountService) Login(ctx context.Context, email, password string) (*domain.Member, *tokenDomain.TokenPair, error) { + return s.LoginPassword(ctx, email, password, domain.PlatformPassword) +} + +func (s *AccountService) LoginOAuth(ctx context.Context, platform, subject, email, displayName string) (*domain.Member, *tokenDomain.TokenPair, error) { + platform = strings.ToLower(strings.TrimSpace(platform)) + subject = strings.TrimSpace(subject) + if platform == "" || subject == "" { + return nil, nil, domain.ErrOAuthFailed + } + email = strings.ToLower(strings.TrimSpace(email)) + + if ident, err := s.Repo.FindIdentity(ctx, subject, platform); err == nil { + m, err := s.Repo.FindByUID(ctx, ident.UID) + if err != nil { + return nil, nil, err + } + if m.IsSuspended() { + return nil, nil, domain.ErrSuspended + } + pair, err := s.issueSession(m) + return m, pair, err + } + + // new member via oauth + req := domain.CreateLoginUserRequest{ + LoginID: subject, Platform: platform, AccountType: domain.AccountTypePlatform, + DisplayName: displayName, Email: email, + } + m, pair, err := s.CreateUserAccount(ctx, req) + if err != nil { + return nil, nil, err + } + // oauth accounts treated as verified email when provider gives email + if email != "" { + now := domain.NowNano() + m.EmailVerified = true + m.EmailVerifiedAt = &now + m.Status = domain.StatusActive + m.UpdatedAt = now + _ = s.Repo.UpdateMember(ctx, m) + } + return m, pair, nil +} + +// ---------- query ---------- + +func (s *AccountService) GetUIDByAccount(ctx context.Context, account, platform string) (int64, error) { + ident, err := s.Repo.FindIdentity(ctx, account, platform) + if err != nil { + return 0, err + } + return ident.UID, nil +} + +func (s *AccountService) GetUserAccountInfo(ctx context.Context, account, platform string) (*domain.Identity, error) { + return s.Repo.FindIdentity(ctx, account, platform) +} + +func (s *AccountService) GetUserInfo(ctx context.Context, uid int64) (*domain.Member, error) { + return s.Repo.FindByUID(ctx, uid) +} + +func (s *AccountService) ListMember(ctx context.Context, page, pageSize int, query, status string) ([]*domain.Member, int64, error) { + return s.Repo.ListPage(ctx, page, pageSize, query, status) +} + +func (s *AccountService) ListIdentities(ctx context.Context, uid int64) ([]*domain.Identity, error) { + return s.Repo.ListIdentitiesByUID(ctx, uid) +} + +func (s *AccountService) FindLoginIDsByUID(ctx context.Context, uid int64) ([]*domain.Identity, error) { + return s.Repo.ListIdentitiesByUID(ctx, uid) +} + +// ---------- update ---------- + +func (s *AccountService) UpdateUserToken(ctx context.Context, loginID, platform, newPassword string) error { + hash, err := HashPassword(newPassword, s.BcryptCost) + if err != nil { + return err + } + if err := s.Repo.UpdateIdentityPassword(ctx, loginID, platform, hash); err != nil { + return err + } + // sync primary password on member if platform password identity + ident, err := s.Repo.FindIdentity(ctx, loginID, platform) + if err != nil { + return nil + } + m, err := s.Repo.FindByUID(ctx, ident.UID) + if err != nil { + return nil + } + m.PasswordHash = hash + m.UpdatedAt = domain.NowNano() + _ = s.Repo.UpdateMember(ctx, m) + return nil +} + +func (s *AccountService) UpdateUserInfo(ctx context.Context, uid int64, patch *domain.UpdateUserInfoPatch) (*domain.Member, error) { + m, err := s.Repo.FindByUID(ctx, uid) + if err != nil { + return nil, err + } + if patch == nil { + return m, nil + } + if patch.DisplayName != nil { + m.DisplayName = *patch.DisplayName + } + if patch.Nickname != nil { + m.Nickname = *patch.Nickname + } + if patch.FullName != nil { + m.FullName = *patch.FullName + } + if patch.AvatarURL != nil { + m.AvatarURL = *patch.AvatarURL + } + if patch.Bio != nil { + m.Bio = *patch.Bio + } + if patch.Timezone != nil { + m.Timezone = *patch.Timezone + } + if patch.NotifyEmail != nil { + m.NotifyEmail = *patch.NotifyEmail + } + if patch.GenderCode != nil { + m.GenderCode = *patch.GenderCode + } + if patch.Birthdate != nil { + m.Birthdate = *patch.Birthdate + } + if patch.National != nil { + m.National = *patch.National + } + if patch.Address != nil { + m.Address = *patch.Address + } + if patch.PostCode != nil { + m.PostCode = *patch.PostCode + } + if patch.PreferredLanguage != nil { + m.PreferredLanguage = *patch.PreferredLanguage + } + if patch.Currency != nil { + m.Currency = *patch.Currency + } + if patch.Email != nil { + newEmail := strings.ToLower(strings.TrimSpace(*patch.Email)) + if newEmail != "" && newEmail != m.Email { + m.Email = newEmail + m.EmailVerified = false + m.EmailVerifiedAt = nil + } + } + if patch.Phone != nil { + m.Phone = strings.TrimSpace(*patch.Phone) + m.PhoneVerified = false + m.PhoneVerifiedAt = nil + } + if patch.NewPassword != nil && *patch.NewPassword != "" { + if patch.CurrentPassword == nil || !CheckPasswordHash(*patch.CurrentPassword, m.PasswordHash) { + return nil, domain.ErrBadPassword + } + hash, err := HashPassword(*patch.NewPassword, s.BcryptCost) + if err != nil { + return nil, err + } + m.PasswordHash = hash + // update platform identity password if exists + if m.Email != "" { + _ = s.Repo.UpdateIdentityPassword(ctx, m.Email, domain.PlatformPassword, hash) + } + } + m.UpdatedAt = domain.NowNano() + if err := s.Repo.UpdateMember(ctx, m); err != nil { + return nil, err + } + return m, nil +} + +func (s *AccountService) UpdateStatus(ctx context.Context, uid int64, status string) (*domain.Member, error) { + m, err := s.Repo.FindByUID(ctx, uid) + if err != nil { + return nil, err + } + m.Status = status + m.UpdatedAt = domain.NowNano() + if err := s.Repo.UpdateMember(ctx, m); err != nil { + return nil, err + } + return m, nil +} + +// ---------- session ---------- + +func (s *AccountService) Refresh(ctx context.Context, refreshToken string) (*tokenDomain.TokenPair, error) { + claims, err := s.Issuer.ParseRefresh(refreshToken) + if err != nil { + return nil, err + } + uid, ok := s.Repo.ConsumeRefresh(claims.JTI) + if !ok || uid != claims.UID { + return nil, tokenDomain.ErrInvalidToken + } + m, err := s.Repo.FindByUID(ctx, uid) + if err != nil { + return nil, err + } + if m.IsSuspended() { + return nil, domain.ErrSuspended + } + return s.issueSession(m) +} + +func (s *AccountService) Logout(refreshToken string) { + if refreshToken == "" { + return + } + if claims, err := s.Issuer.ParseRefresh(refreshToken); err == nil { + s.Repo.RevokeRefresh(claims.JTI) + } +} + +func (s *AccountService) LogoutAll(ctx context.Context, uid int64) error { + return s.Repo.RevokeAllRefreshByUID(ctx, uid) +} + +// ---------- binding ---------- + +func (s *AccountService) BindAccount(ctx context.Context, uid int64, loginID, platform, accountType, password string) (*domain.Identity, error) { + if _, err := s.Repo.FindByUID(ctx, uid); err != nil { + return nil, err + } + platform = strings.ToLower(strings.TrimSpace(platform)) + if platform == "" { + platform = domain.PlatformPassword + } + loginID = strings.TrimSpace(loginID) + if platform == domain.PlatformPassword { + loginID = strings.ToLower(loginID) + } + if _, err := s.Repo.FindIdentity(ctx, loginID, platform); err == nil { + return nil, domain.ErrIdentityTaken + } + var hash string + if platform == domain.PlatformPassword && password != "" { + var err error + hash, err = HashPassword(password, s.BcryptCost) + if err != nil { + return nil, err + } + } + if accountType == "" { + if platform == domain.PlatformPassword { + accountType = domain.AccountTypeEmail + } else { + accountType = domain.AccountTypePlatform + } + } + now := domain.NowNano() + ident := &domain.Identity{ + UID: uid, LoginID: loginID, Platform: platform, + AccountType: accountType, PasswordHash: hash, CreatedAt: now, UpdatedAt: now, + } + if err := s.Repo.CreateIdentity(ctx, ident); err != nil { + return nil, err + } + return ident, nil +} + +func (s *AccountService) UnbindAccount(ctx context.Context, uid int64, loginID, platform string) error { + ident, err := s.Repo.FindIdentity(ctx, loginID, platform) + if err != nil { + return err + } + if ident.UID != uid { + return domain.ErrIdentityNotFound + } + // refuse unbind last identity + list, err := s.Repo.ListIdentitiesByUID(ctx, uid) + if err != nil { + return err + } + if len(list) <= 1 { + return domain.ErrAlreadyBound // reuse: cannot unbind last + } + return s.Repo.DeleteIdentity(ctx, loginID, platform) +} + +func (s *AccountService) BindVerifyEmail(ctx context.Context, uid int64, email string) error { + email = strings.ToLower(strings.TrimSpace(email)) + m, err := s.Repo.FindByUID(ctx, uid) + if err != nil { + return err + } + now := domain.NowNano() + m.Email = email + m.EmailVerified = true + m.EmailVerifiedAt = &now + if m.Status == domain.StatusUnverified { + m.Status = domain.StatusActive + } + m.UpdatedAt = now + if err := s.Repo.UpdateMember(ctx, m); err != nil { + return err + } + // ensure identity + if _, err := s.Repo.FindIdentity(ctx, email, domain.PlatformPassword); err != nil { + _ = s.Repo.CreateIdentity(ctx, &domain.Identity{ + UID: uid, LoginID: email, Platform: domain.PlatformPassword, + AccountType: domain.AccountTypeEmail, PasswordHash: m.PasswordHash, + CreatedAt: now, UpdatedAt: now, + }) + } + return nil +} + +func (s *AccountService) BindVerifyPhone(ctx context.Context, uid int64, phone string) error { + phone = strings.TrimSpace(phone) + m, err := s.Repo.FindByUID(ctx, uid) + if err != nil { + return err + } + now := domain.NowNano() + m.Phone = phone + m.PhoneVerified = true + m.PhoneVerifiedAt = &now + m.UpdatedAt = now + return s.Repo.UpdateMember(ctx, m) +} + +// ---------- codes ---------- + +func (s *AccountService) GenerateCode(ctx context.Context, loginID, codeType string) (string, error) { + code := randomDigits(6) + s.Repo.SetCode(codeType, strings.ToLower(loginID), code, 10*time.Minute) + return code, nil +} + +func (s *AccountService) VerifyCode(ctx context.Context, loginID, codeType, code string, consume bool) error { + key := strings.ToLower(loginID) + ok := false + if consume { + ok = s.Repo.CheckCode(codeType, key, code) + } else { + ok = s.Repo.PeekCode(codeType, key, code) + } + if !ok { + return domain.ErrInvalidCode + } + return nil +} + +func (s *AccountService) VerifyPlatformAuthResult(ctx context.Context, loginID, platform, password string) (bool, error) { + ident, err := s.Repo.FindIdentity(ctx, loginID, platform) + if err != nil { + return false, err + } + if ident.PasswordHash == "" { + return false, nil + } + return CheckPasswordHash(password, ident.PasswordHash), nil +} + +// ---------- OAuth providers (real when configured; else ErrOAuthFailed) ---------- + +func (s *AccountService) VerifyGoogleIDToken(ctx context.Context, idToken string) (subject, email, name string, err error) { + // Dev/mock: accept "mock::" when no Google client configured + if strings.HasPrefix(idToken, "mock:") { + parts := strings.SplitN(idToken, ":", 3) + if len(parts) >= 3 { + return parts[1], parts[2], parts[1], nil + } + if len(parts) == 2 { + return parts[1], parts[1] + "@oauth.local", parts[1], nil + } + } + info, e := googleUserInfo(ctx, idToken) + if e != nil { + return "", "", "", domain.ErrOAuthFailed + } + return info.ID, info.Email, info.Name, nil +} + +func (s *AccountService) LineCodeToProfile(ctx context.Context, code, redirectURI string) (subject, displayName, email string, err error) { + if strings.HasPrefix(code, "mock:") { + sub := strings.TrimPrefix(code, "mock:") + return sub, "line " + sub, "", nil + } + if s.LineClientID == "" || s.LineClientSecret == "" { + return "", "", "", domain.ErrOAuthFailed + } + if redirectURI == "" { + redirectURI = s.LineRedirectURI + } + tok, e := lineExchangeCode(ctx, code, redirectURI, s.LineClientID, s.LineClientSecret) + if e != nil { + return "", "", "", domain.ErrOAuthFailed + } + prof, e := lineProfile(ctx, tok) + if e != nil { + return "", "", "", domain.ErrOAuthFailed + } + return prof.UserID, prof.DisplayName, "", nil +} + +// ---------- Harbor Desk convenience ---------- + +func (s *AccountService) RegisterEmail(ctx context.Context, email, password, displayName string) (*domain.Member, *tokenDomain.TokenPair, error) { + return s.CreateUserAccount(ctx, domain.CreateLoginUserRequest{ + LoginID: email, Platform: domain.PlatformPassword, AccountType: domain.AccountTypeEmail, + Password: password, DisplayName: displayName, Email: email, + }) +} + +// Register — alias for logic layer +func (s *AccountService) Register(ctx context.Context, email, password, displayName string) (*domain.Member, *tokenDomain.TokenPair, error) { + return s.RegisterEmail(ctx, email, password, displayName) +} + +func (s *AccountService) Me(ctx context.Context, uid int64) (*domain.Member, error) { + m, err := s.Repo.FindByUID(ctx, uid) + if err != nil { + return nil, err + } + if m.IsSuspended() { + return nil, domain.ErrSuspended + } + return m, nil +} + +func (s *AccountService) RequestPasswordReset(ctx context.Context, email string) (string, error) { + email = strings.ToLower(strings.TrimSpace(email)) + if email == "" || !strings.Contains(email, "@") { + return "", domain.ErrEmailNotRegistered + } + code := randomDigits(6) + // try identity or member — unknown email must error (product: 無此信箱不寄) + if _, err := s.Repo.FindByEmail(ctx, email); err == nil { + s.Repo.SetCode(domain.CodeTypeForgetPassword, email, code, 30*time.Minute) + return code, nil + } + if _, err := s.Repo.FindIdentity(ctx, email, domain.PlatformPassword); err == nil { + s.Repo.SetCode(domain.CodeTypeForgetPassword, email, code, 30*time.Minute) + return code, nil + } + return "", domain.ErrEmailNotRegistered +} + +func (s *AccountService) ResetPassword(ctx context.Context, email, code, newPassword string) error { + email = strings.ToLower(strings.TrimSpace(email)) + code = strings.TrimSpace(code) + // 1) 先驗密碼政策(失敗不消費驗證碼) + if err := ValidatePasswordPolicy(newPassword); err != nil { + return err + } + // 2) Peek:輸錯/未過期都不刪(與信箱驗證同一套) + match := s.Repo.PeekCode(domain.CodeTypeForgetPassword, email, code) || + s.Repo.PeekCode("reset", email, code) + if !match { + return domain.ErrInvalidCode + } + // 3) 業務先寫入;成功才消費碼 + if err := s.UpdateUserToken(ctx, email, domain.PlatformPassword, newPassword); err != nil { + // seed / legacy member without identity row + hash, herr := HashPassword(newPassword, s.BcryptCost) + if herr != nil { + return herr + } + m, err := s.Repo.FindByEmail(ctx, email) + if err != nil { + return domain.ErrNotFound + } + m.PasswordHash = hash + m.UpdatedAt = domain.NowNano() + if err := s.Repo.UpdateMember(ctx, m); err != nil { + return err + } + _ = s.Repo.CreateIdentity(ctx, &domain.Identity{ + UID: m.UID, LoginID: email, Platform: domain.PlatformPassword, + AccountType: domain.AccountTypeEmail, PasswordHash: hash, + CreatedAt: domain.NowNano(), UpdatedAt: domain.NowNano(), + }) + } + // 4) 成功才刪碼(兩種 kind 都清) + _ = s.Repo.CheckCode(domain.CodeTypeForgetPassword, email, code) + _ = s.Repo.CheckCode("reset", email, code) + return nil +} + +func (s *AccountService) SendEmailCode(ctx context.Context, uid int64) (string, error) { + m, err := s.Repo.FindByUID(ctx, uid) + if err != nil { + return "", err + } + code := randomDigits(6) + key := m.Email + if key == "" { + key = fmt.Sprintf("%d", uid) + } + s.Repo.SetCode(domain.CodeTypeEmail, strings.ToLower(key), code, 30*time.Minute) + // also store by uid for VerifyEmailCode + s.Repo.SetCode(domain.CodeTypeEmail, fmt.Sprintf("%d", uid), code, 30*time.Minute) + return code, nil +} + +func (s *AccountService) VerifyEmailCode(ctx context.Context, uid int64, code string) (*domain.Member, error) { + m, err := s.Repo.FindByUID(ctx, uid) + if err != nil { + return nil, err + } + uidKey := fmt.Sprintf("%d", uid) + emailKey := "" + if m.Email != "" { + emailKey = strings.ToLower(m.Email) + } + code = strings.TrimSpace(code) + // 與重設密碼同一套:Peek(輸錯不刪)→ 業務成功 → CheckCode 消費 + match := s.Repo.PeekCode(domain.CodeTypeEmail, uidKey, code) + if !match && emailKey != "" { + match = s.Repo.PeekCode(domain.CodeTypeEmail, emailKey, code) + } + if !match { + return nil, domain.ErrInvalidCode + } + now := domain.NowNano() + m.EmailVerified = true + m.EmailVerifiedAt = &now + if m.Status == domain.StatusUnverified { + m.Status = domain.StatusActive + } + m.UpdatedAt = now + if err := s.Repo.UpdateMember(ctx, m); err != nil { + // 業務失敗:碼仍有效,可重試 + return nil, err + } + // 成功才消費兩把 key + _ = s.Repo.CheckCode(domain.CodeTypeEmail, uidKey, code) + if emailKey != "" { + _ = s.Repo.CheckCode(domain.CodeTypeEmail, emailKey, code) + } + return m, nil +} + +// VerifyEmail — alias +func (s *AccountService) VerifyEmail(ctx context.Context, uid int64, code string) (*domain.Member, error) { + return s.VerifyEmailCode(ctx, uid, code) +} + +// ---------- helpers ---------- + +func (s *AccountService) issueSession(m *domain.Member) (*tokenDomain.TokenPair, error) { + pair, jti, exp, err := s.Issuer.Issue(m.UID, m.Roles) + if err != nil { + return nil, err + } + s.Repo.SaveRefresh(jti, m.UID, exp) + return pair, nil +} + +func NewInviteCode() string { + var b [4]byte + _, _ = rand.Read(b[:]) + return strings.ToUpper(hex.EncodeToString(b[:])) +} + +func randomDigits(n int) string { + var b strings.Builder + for i := 0; i < n; i++ { + v, _ := rand.Int(rand.Reader, big.NewInt(10)) + b.WriteByte(byte('0' + v.Int64())) + } + return b.String() +} + +func looksLikePhone(s string) bool { + if len(s) < 8 { + return false + } + for _, c := range s { + if c >= '0' && c <= '9' || c == '+' { + continue + } + return false + } + return true +} diff --git a/apps/backend/internal/module/member/usecase/auth_test.go b/apps/backend/internal/module/member/usecase/auth_test.go new file mode 100644 index 0000000..082adad --- /dev/null +++ b/apps/backend/internal/module/member/usecase/auth_test.go @@ -0,0 +1,324 @@ +package usecase_test + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + "apps/backend/internal/module/member/domain" + "apps/backend/internal/module/member/usecase" + tokenUC "apps/backend/internal/module/token/usecase" + + "github.com/stretchr/testify/require" +) + +// fakeRepo is test-only (not a production memory store). +type fakeRepo struct { + mu sync.Mutex + byUID map[int64]*domain.Member + byEmail map[string]int64 + byPhone map[string]int64 + identities map[string]*domain.Identity // platform:login_id + byUIDIds map[int64][]string + seq int64 + codes map[string]string + refresh map[string]int64 + settings map[int64]*domain.UserSettings +} + +func newFake() *fakeRepo { + return &fakeRepo{ + byUID: map[int64]*domain.Member{}, byEmail: map[string]int64{}, byPhone: map[string]int64{}, + identities: map[string]*domain.Identity{}, byUIDIds: map[int64][]string{}, + seq: domain.MinMemberUID, codes: map[string]string{}, refresh: map[string]int64{}, + settings: map[int64]*domain.UserSettings{}, + } +} + +func idKey(loginID, platform string) string { return platform + ":" + loginID } + +func (f *fakeRepo) NextUID(ctx context.Context) (int64, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.seq++ + if f.seq < domain.MinMemberUID { + f.seq = domain.MinMemberUID + 1 + } + return f.seq, nil +} +func (f *fakeRepo) CreateMember(ctx context.Context, m *domain.Member) error { + f.mu.Lock() + defer f.mu.Unlock() + if m.Email != "" { + if _, ok := f.byEmail[m.Email]; ok { + return domain.ErrEmailTaken + } + f.byEmail[m.Email] = m.UID + } + if m.Phone != "" { + f.byPhone[m.Phone] = m.UID + } + cp := *m + f.byUID[m.UID] = &cp + return nil +} +func (f *fakeRepo) FindByUID(ctx context.Context, uid int64) (*domain.Member, error) { + f.mu.Lock() + defer f.mu.Unlock() + m, ok := f.byUID[uid] + if !ok { + return nil, domain.ErrNotFound + } + cp := *m + return &cp, nil +} +func (f *fakeRepo) FindByEmail(ctx context.Context, email string) (*domain.Member, error) { + f.mu.Lock() + defer f.mu.Unlock() + uid, ok := f.byEmail[email] + if !ok { + return nil, domain.ErrNotFound + } + cp := *f.byUID[uid] + return &cp, nil +} +func (f *fakeRepo) FindByPhone(ctx context.Context, phone string) (*domain.Member, error) { + f.mu.Lock() + defer f.mu.Unlock() + uid, ok := f.byPhone[phone] + if !ok { + return nil, domain.ErrNotFound + } + cp := *f.byUID[uid] + return &cp, nil +} +func (f *fakeRepo) UpdateMember(ctx context.Context, m *domain.Member) error { + f.mu.Lock() + defer f.mu.Unlock() + if _, ok := f.byUID[m.UID]; !ok { + return domain.ErrNotFound + } + cp := *m + f.byUID[m.UID] = &cp + if m.Email != "" { + f.byEmail[m.Email] = m.UID + } + return nil +} +func (f *fakeRepo) ListPage(ctx context.Context, page, pageSize int, query, status string) ([]*domain.Member, int64, error) { + return nil, 0, nil +} +func (f *fakeRepo) CountActiveAdmins(ctx context.Context) (int64, error) { return 1, nil } + +func (f *fakeRepo) CreateIdentity(ctx context.Context, id *domain.Identity) error { + f.mu.Lock() + defer f.mu.Unlock() + k := idKey(id.LoginID, id.Platform) + if _, ok := f.identities[k]; ok { + return domain.ErrIdentityTaken + } + cp := *id + f.identities[k] = &cp + f.byUIDIds[id.UID] = append(f.byUIDIds[id.UID], k) + return nil +} +func (f *fakeRepo) FindIdentity(ctx context.Context, loginID, platform string) (*domain.Identity, error) { + f.mu.Lock() + defer f.mu.Unlock() + id, ok := f.identities[idKey(loginID, platform)] + if !ok { + return nil, domain.ErrIdentityNotFound + } + cp := *id + return &cp, nil +} +func (f *fakeRepo) ListIdentitiesByUID(ctx context.Context, uid int64) ([]*domain.Identity, error) { + f.mu.Lock() + defer f.mu.Unlock() + var out []*domain.Identity + for _, k := range f.byUIDIds[uid] { + if id, ok := f.identities[k]; ok { + cp := *id + out = append(out, &cp) + } + } + return out, nil +} +func (f *fakeRepo) DeleteIdentity(ctx context.Context, loginID, platform string) error { + f.mu.Lock() + defer f.mu.Unlock() + k := idKey(loginID, platform) + id, ok := f.identities[k] + if !ok { + return domain.ErrIdentityNotFound + } + delete(f.identities, k) + var rest []string + for _, x := range f.byUIDIds[id.UID] { + if x != k { + rest = append(rest, x) + } + } + f.byUIDIds[id.UID] = rest + return nil +} +func (f *fakeRepo) UpdateIdentityPassword(ctx context.Context, loginID, platform, passwordHash string) error { + f.mu.Lock() + defer f.mu.Unlock() + id, ok := f.identities[idKey(loginID, platform)] + if !ok { + return domain.ErrIdentityNotFound + } + id.PasswordHash = passwordHash + return nil +} + +func (f *fakeRepo) SetCode(kind, key, code string, ttl time.Duration) { + f.mu.Lock() + defer f.mu.Unlock() + f.codes[kind+":"+key] = code +} +func (f *fakeRepo) CheckCode(kind, key, code string) bool { + f.mu.Lock() + defer f.mu.Unlock() + k := kind + ":" + key + v, ok := f.codes[k] + if !ok || v != code { + return false + } + delete(f.codes, k) + return true +} +func (f *fakeRepo) PeekCode(kind, key, code string) bool { + f.mu.Lock() + defer f.mu.Unlock() + return f.codes[kind+":"+key] == code +} +func (f *fakeRepo) SaveRefresh(jti string, uid int64, exp time.Time) { + f.mu.Lock() + defer f.mu.Unlock() + f.refresh[jti] = uid +} +func (f *fakeRepo) ConsumeRefresh(jti string) (int64, bool) { + f.mu.Lock() + defer f.mu.Unlock() + uid, ok := f.refresh[jti] + if !ok { + return 0, false + } + delete(f.refresh, jti) + return uid, true +} +func (f *fakeRepo) RevokeRefresh(jti string) { + f.mu.Lock() + defer f.mu.Unlock() + delete(f.refresh, jti) +} +func (f *fakeRepo) RevokeAllRefreshByUID(ctx context.Context, uid int64) error { + f.mu.Lock() + defer f.mu.Unlock() + for j, u := range f.refresh { + if u == uid { + delete(f.refresh, j) + } + } + return nil +} +func (f *fakeRepo) GetSettings(ctx context.Context, uid int64) (*domain.UserSettings, error) { + return domain.DefaultSettings(uid), nil +} +func (f *fakeRepo) SaveSettings(ctx context.Context, uid int64, s *domain.UserSettings) error { + return nil +} + +func TestAU_01_LoginOk(t *testing.T) { + store := newFake() + svc := usecase.NewAuthService(store, tokenUC.NewJWTIssuer("a", "b", 3600, 86400), 10) + const strong = "Admin123!@#x" + m, tokens, err := svc.Register(context.Background(), "admin@haixun.local", strong, "Admin") + require.NoError(t, err) + m.Roles = []string{"admin", "member"} + m.Status = domain.StatusActive + m.EmailVerified = true + require.NoError(t, store.UpdateMember(context.Background(), m)) + + m2, tokens2, err := svc.Login(context.Background(), "admin@haixun.local", strong) + require.NoError(t, err) + require.Equal(t, m.UID, m2.UID) + require.True(t, m.UID >= domain.MinMemberUID) + require.NotEmpty(t, tokens.AccessToken) + require.NotEmpty(t, tokens2.AccessToken) +} + +func TestAU_02_LoginBadPassword(t *testing.T) { + store := newFake() + svc := usecase.NewAuthService(store, tokenUC.NewJWTIssuer("a", "b", 3600, 86400), 10) + const strong = "Admin123!@#x" + _, _, err := svc.Register(context.Background(), "admin@haixun.local", strong, "Admin") + require.NoError(t, err) + _, _, err = svc.Login(context.Background(), "admin@haixun.local", "wrong") + require.ErrorIs(t, err, domain.ErrBadPassword) +} + +func TestAR_02_RegisterOk(t *testing.T) { + store := newFake() + svc := usecase.NewAuthService(store, tokenUC.NewJWTIssuer("a", "b", 3600, 86400), 10) + m, tokens, err := svc.Register(context.Background(), "new@example.com", "Secret12!@#a", "New") + require.NoError(t, err) + require.True(t, m.UID >= domain.MinMemberUID) + require.NotEmpty(t, tokens.AccessToken) + ids, err := svc.ListIdentities(context.Background(), m.UID) + require.NoError(t, err) + require.Len(t, ids, 1) +} + +func TestAU_07_Refresh(t *testing.T) { + store := newFake() + svc := usecase.NewAuthService(store, tokenUC.NewJWTIssuer("sec-a", "sec-r", 3600, 86400), 10) + _, tokens, err := svc.Register(context.Background(), "r@example.com", "Secret12!@#a", "R") + require.NoError(t, err) + next, err := svc.Refresh(context.Background(), tokens.RefreshToken) + require.NoError(t, err) + require.NotEmpty(t, next.AccessToken) + _, err = svc.Refresh(context.Background(), tokens.RefreshToken) + require.Error(t, err) +} + +func TestBind_Unbind(t *testing.T) { + store := newFake() + svc := usecase.NewAuthService(store, tokenUC.NewJWTIssuer("a", "b", 3600, 86400), 10) + m, _, err := svc.Register(context.Background(), "b@example.com", "Secret12!@#a", "B") + require.NoError(t, err) + id, err := svc.BindAccount(context.Background(), m.UID, "g-sub-1", domain.PlatformGoogle, domain.AccountTypePlatform, "") + require.NoError(t, err) + require.Equal(t, "g-sub-1", id.LoginID) + ids, _ := svc.ListIdentities(context.Background(), m.UID) + require.Len(t, ids, 2) + require.NoError(t, svc.UnbindAccount(context.Background(), m.UID, "g-sub-1", domain.PlatformGoogle)) + err = svc.UnbindAccount(context.Background(), m.UID, "b@example.com", domain.PlatformPassword) + require.Error(t, err) +} + +func TestLoginOAuth_Mock(t *testing.T) { + store := newFake() + svc := usecase.NewAuthService(store, tokenUC.NewJWTIssuer("a", "b", 3600, 86400), 10) + sub, email, name, err := svc.VerifyGoogleIDToken(context.Background(), "mock:g123:u@g.com") + require.NoError(t, err) + require.Equal(t, "g123", sub) + m, pair, err := svc.LoginOAuth(context.Background(), domain.PlatformGoogle, sub, email, name) + require.NoError(t, err) + require.NotEmpty(t, pair.AccessToken) + require.True(t, m.UID >= domain.MinMemberUID) + m2, _, err := svc.LoginOAuth(context.Background(), domain.PlatformGoogle, sub, email, name) + require.NoError(t, err) + require.Equal(t, m.UID, m2.UID) +} + +func TestMember_UidIs8DigitsFrom1M(t *testing.T) { + require.Equal(t, int64(1_000_000), domain.MinMemberUID) + // 1_000_000 是 7 位;顯示補零成 8 位:01000000 + require.Equal(t, "01000000", fmt.Sprintf("%08d", domain.MinMemberUID)) + require.Equal(t, "01000001", fmt.Sprintf("%08d", domain.MinMemberUID+1)) +} diff --git a/apps/backend/internal/module/member/usecase/oauth_providers.go b/apps/backend/internal/module/member/usecase/oauth_providers.go new file mode 100644 index 0000000..4eb9880 --- /dev/null +++ b/apps/backend/internal/module/member/usecase/oauth_providers.go @@ -0,0 +1,113 @@ +package usecase + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +type googleUserInfoResp struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` +} + +func googleUserInfo(ctx context.Context, accessOrIDToken string) (*googleUserInfoResp, error) { + // Prefer userinfo with Bearer access token (stand-alone style) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://www.googleapis.com/oauth2/v2/userinfo", nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+accessOrIDToken) + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("google userinfo status %d: %s", resp.StatusCode, string(body)) + } + var info googleUserInfoResp + if err := json.Unmarshal(body, &info); err != nil { + return nil, err + } + if info.ID == "" { + return nil, fmt.Errorf("google userinfo missing id") + } + return &info, nil +} + +type lineTokenResp struct { + AccessToken string `json:"access_token"` +} + +type lineProfileResp struct { + UserID string `json:"userId"` + DisplayName string `json:"displayName"` +} + +func lineExchangeCode(ctx context.Context, code, redirectURI, clientID, clientSecret string) (string, error) { + data := url.Values{} + data.Set("grant_type", "authorization_code") + data.Set("code", code) + data.Set("redirect_uri", redirectURI) + data.Set("client_id", clientID) + data.Set("client_secret", clientSecret) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.line.me/oauth2/v2.1/token", + strings.NewReader(data.Encode())) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("line token status %d: %s", resp.StatusCode, string(body)) + } + var tok lineTokenResp + if err := json.Unmarshal(body, &tok); err != nil { + return "", err + } + if tok.AccessToken == "" { + return "", fmt.Errorf("line empty access_token") + } + return tok.AccessToken, nil +} + +func lineProfile(ctx context.Context, accessToken string) (*lineProfileResp, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.line.me/v2/profile", nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("line profile status %d: %s", resp.StatusCode, string(body)) + } + var p lineProfileResp + if err := json.Unmarshal(body, &p); err != nil { + return nil, err + } + if p.UserID == "" { + return nil, fmt.Errorf("line profile missing userId") + } + return &p, nil +} diff --git a/apps/backend/internal/module/member/usecase/password.go b/apps/backend/internal/module/member/usecase/password.go new file mode 100644 index 0000000..a4780a9 --- /dev/null +++ b/apps/backend/internal/module/member/usecase/password.go @@ -0,0 +1,98 @@ +package usecase + +import ( + "crypto/rand" + "math/big" + "unicode" + + "apps/backend/internal/module/member/domain" + + "golang.org/x/crypto/bcrypt" +) + +// ValidatePasswordPolicy:≥12、含大寫、小寫、數字、符號。 +// 規則與 apps/web/src/lib/passwordPolicy.ts 一致;文案見 domain.PasswordPolicyEN/ZH。 +func ValidatePasswordPolicy(password string) error { + if password == "" || len([]rune(password)) < domain.PasswordMinLen { + return domain.ErrWeakPassword + } + var upper, lower, digit, symbol bool + for _, r := range password { + switch { + case unicode.IsUpper(r): + upper = true + case unicode.IsLower(r): + lower = true + case unicode.IsDigit(r): + digit = true + case unicode.IsPunct(r) || unicode.IsSymbol(r): + symbol = true + default: + // 空白等非字母數字也算符號類 + if !unicode.IsLetter(r) && !unicode.IsDigit(r) { + symbol = true + } + } + } + if !upper || !lower || !digit || !symbol { + return domain.ErrWeakPassword + } + return nil +} + +// HashPassword validates policy then bcrypt. +func HashPassword(password string, cost int) (string, error) { + if err := ValidatePasswordPolicy(password); err != nil { + return "", err + } + if cost < bcrypt.MinCost { + cost = bcrypt.DefaultCost + } + bytes, err := bcrypt.GenerateFromPassword([]byte(password), cost) + return string(bytes), err +} + +func CheckPasswordHash(password, hash string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +} + +func GetHashingCost(hashedPassword []byte) int { + cost, _ := bcrypt.Cost(hashedPassword) + return cost +} + +// GenerateTempPassword 產生符合政策的臨時密碼(16 字元)。 +func GenerateTempPassword() string { + const ( + lower = "abcdefghijkmnopqrstuvwxyz" + upper = "ABCDEFGHJKLMNPQRSTUVWXYZ" + digits = "23456789" + symbols = "!@#$%^&*+-=" + all = lower + upper + digits + symbols + ) + pick := func(set string) byte { + n, err := rand.Int(rand.Reader, big.NewInt(int64(len(set)))) + if err != nil { + return set[0] + } + return set[n.Int64()] + } + // 保證四類各至少 1 + out := []byte{ + pick(lower), pick(upper), pick(digits), pick(symbols), + } + // 16 ≥ PasswordMinLen(12) + for len(out) < 16 { + out = append(out, pick(all)) + } + // shuffle + for i := len(out) - 1; i > 0; i-- { + jBig, err := rand.Int(rand.Reader, big.NewInt(int64(i+1))) + j := 0 + if err == nil { + j = int(jBig.Int64()) + } + out[i], out[j] = out[j], out[i] + } + return string(out) +} diff --git a/apps/backend/internal/module/member/usecase/password_test.go b/apps/backend/internal/module/member/usecase/password_test.go new file mode 100644 index 0000000..97d80e0 --- /dev/null +++ b/apps/backend/internal/module/member/usecase/password_test.go @@ -0,0 +1,40 @@ +package usecase + +import ( + "testing" + + "apps/backend/internal/module/member/domain" +) + +func TestValidatePasswordPolicy(t *testing.T) { + cases := []struct { + pw string + want error + }{ + {"short", domain.ErrWeakPassword}, + {"alllowercase1!", domain.ErrWeakPassword}, // no upper + {"ALLUPPERCASE1!", domain.ErrWeakPassword}, // no lower + {"NoDigitsHere!!", domain.ErrWeakPassword}, // no digit + {"NoSymbolHere12", domain.ErrWeakPassword}, // no symbol + {"Admin123!@#x", nil}, // 12 ok + {"GoodPassw0rd!", nil}, + } + for _, c := range cases { + err := ValidatePasswordPolicy(c.pw) + if c.want == nil && err != nil { + t.Fatalf("%q: unexpected %v", c.pw, err) + } + if c.want != nil && err != c.want { + t.Fatalf("%q: got %v want %v", c.pw, err, c.want) + } + } +} + +func TestGenerateTempPasswordMeetsPolicy(t *testing.T) { + for i := 0; i < 20; i++ { + pw := GenerateTempPassword() + if err := ValidatePasswordPolicy(pw); err != nil { + t.Fatalf("temp %q: %v", pw, err) + } + } +} diff --git a/apps/backend/internal/module/notification/domain/brand.go b/apps/backend/internal/module/notification/domain/brand.go new file mode 100644 index 0000000..c7a29ad --- /dev/null +++ b/apps/backend/internal/module/notification/domain/brand.go @@ -0,0 +1,30 @@ +package domain + +// Brand is product identity for hermes email chrome (logo / name / link). +type Brand struct { + Name string // e.g. Harbor Desk + Link string // public site origin + LogoURL string // absolute URL to logo (email clients need absolute) + Copyright string +} + +// DefaultBrand when config incomplete. +func DefaultBrand(publicWebBase string) Brand { + base := stringsTrimRightSlash(publicWebBase) + if base == "" { + base = "http://127.0.0.1:5173" + } + return Brand{ + Name: "Harbor Desk", + Link: base, + LogoURL: base + "/brand-mark.jpg", + Copyright: "© Harbor Desk", + } +} + +func stringsTrimRightSlash(s string) string { + for len(s) > 0 && s[len(s)-1] == '/' { + s = s[:len(s)-1] + } + return s +} diff --git a/apps/backend/internal/module/notification/domain/hermes_template.go b/apps/backend/internal/module/notification/domain/hermes_template.go new file mode 100644 index 0000000..2155647 --- /dev/null +++ b/apps/backend/internal/module/notification/domain/hermes_template.go @@ -0,0 +1,137 @@ +package domain + +import ( + "fmt" + + "github.com/matcornic/hermes/v2" +) + +// BuildHermesHTML renders a hermes email skeleton (same stack as stand-alone notification). +func BuildHermesHTML(brand Brand, body hermes.Body) (string, error) { + if brand.Name == "" { + brand.Name = "Harbor Desk" + } + h := hermes.Hermes{ + Product: hermes.Product{ + Name: brand.Name, + Link: brand.Link, + Logo: brand.LogoURL, + Copyright: brand.Copyright, + }, + // Default theme is clean table layout suitable for most clients. + } + return h.GenerateHTML(hermes.Email{Body: body}) +} + +// GenerateTemplate builds subject + HTML for a kind/lang with placeholders still present +// ({{.Username}}, {{.VerifyCode}}, {{.ResetURL}} filled later by text/template). +func GenerateTemplate(kind TemplateKind, lang Language, brand Brand) (*EmailTemplate, error) { + lang = NormalizeLanguage(string(lang)) + switch kind { + case TemplateEmailVerify: + return generateEmailVerify(lang, brand) + case TemplatePasswordReset: + return generatePasswordReset(lang, brand) + default: + return nil, fmt.Errorf("unknown template kind: %s", kind) + } +} + +func generateEmailVerify(lang Language, brand Brand) (*EmailTemplate, error) { + var subject string + var intros, outros []string + var instr string + if lang == LangEnUS { + subject = "[Harbor Desk] Verify your email" + intros = []string{ + "You received this email because we need to verify your email address.", + } + instr = "Copy this verification code and enter it on the verify page (valid for 30 minutes):" + outros = []string{ + "If you did not create an account, you can ignore this email.", + "This is an automated message — please do not reply.", + } + } else { + subject = "【Harbor Desk】信箱驗證碼" + intros = []string{ + "您收到此信是因為需要驗證您的電子信箱。", + } + instr = "請複製下方驗證碼,於 30 分鐘內到驗證頁完成確認:" + outros = []string{ + "若您並未註冊帳號,可忽略本信。", + "此為系統自動發出,請勿直接回覆。", + } + } + html, err := BuildHermesHTML(brand, hermes.Body{ + Name: "{{.Username}}", + Intros: intros, + Actions: []hermes.Action{ + { + Instructions: instr, + InviteCode: "{{.VerifyCode}}", + }, + }, + Outros: outros, + Signature: " ", + }) + if err != nil { + return nil, err + } + return &EmailTemplate{Subject: subject, Body: html}, nil +} + +func generatePasswordReset(lang Language, brand Brand) (*EmailTemplate, error) { + var subject, instr, btnText string + var intros, outros []string + if lang == LangEnUS { + subject = "[Harbor Desk] Reset your password" + intros = []string{ + "You received this email because a password reset was requested for your account.", + } + instr = "Copy this code, or open the reset page with the button below (valid for 30 minutes):" + btnText = "Open reset page" + outros = []string{ + "If you did not request a password reset, no further action is required.", + "This is an automated message — please do not reply.", + } + } else { + subject = "【Harbor Desk】重設密碼" + intros = []string{ + "您收到此信是因為有人申請重設您的帳戶密碼。", + } + instr = "請複製下方驗證碼,或點擊按鈕開啟重設頁(30 分鐘內有效):" + btnText = "開啟重設密碼頁" + outros = []string{ + "若您並未申請重設密碼,可忽略本信,帳號不會有任何變更。", + "此為系統自動發出,請勿直接回覆。", + } + } + + actions := []hermes.Action{ + { + Instructions: instr, + InviteCode: "{{.VerifyCode}}", + }, + } + // Button only when ResetURL placeholder present after render — always include; + // empty href is avoided by filling ResetURL before send. + actions = append(actions, hermes.Action{ + Button: hermes.Button{ + Color: "#0f172a", + Text: btnText, + Link: "{{.ResetURL}}", + }, + }) + + html, err := BuildHermesHTML(brand, hermes.Body{ + Name: "{{.Username}}", + Intros: intros, + Actions: actions, + Outros: outros, + Signature: " ", + }) + if err != nil { + return nil, err + } + return &EmailTemplate{Subject: subject, Body: html}, nil +} diff --git a/apps/backend/internal/module/notification/domain/lang.go b/apps/backend/internal/module/notification/domain/lang.go new file mode 100644 index 0000000..65a2582 --- /dev/null +++ b/apps/backend/internal/module/notification/domain/lang.go @@ -0,0 +1,26 @@ +package domain + +import "strings" + +// NormalizeLanguage maps FE / Accept-Language / preferred_language → template Language. +// FE locales: zh-TW | en +func NormalizeLanguage(raw string) Language { + s := strings.ToLower(strings.TrimSpace(raw)) + if s == "" { + return LangZhTW + } + // take first tag only (e.g. "en-US,en;q=0.9") + if i := strings.IndexAny(s, ",;"); i >= 0 { + s = strings.TrimSpace(s[:i]) + } + s = strings.ReplaceAll(s, "_", "-") + switch { + case s == "en" || strings.HasPrefix(s, "en-"): + return LangEnUS + case s == "zh" || strings.HasPrefix(s, "zh-"): + // zh-TW / zh-Hant / zh-CN → 目前模板只有繁中,統一 zh-tw + return LangZhTW + default: + return LangZhTW + } +} diff --git a/apps/backend/internal/module/notification/domain/template.go b/apps/backend/internal/module/notification/domain/template.go new file mode 100644 index 0000000..704f876 --- /dev/null +++ b/apps/backend/internal/module/notification/domain/template.go @@ -0,0 +1,15 @@ +package domain + +// GetStaticTemplate is kept for compatibility; prefers hermes generators. +// brand may be zero — DefaultBrand used when Logo/Name empty. +func GetStaticTemplate(kind TemplateKind, lang Language) (*EmailTemplate, error) { + return GenerateTemplate(kind, lang, DefaultBrand("")) +} + +// GetTemplate with brand (logo / product name). +func GetTemplate(kind TemplateKind, lang Language, brand Brand) (*EmailTemplate, error) { + if brand.Name == "" && brand.LogoURL == "" { + brand = DefaultBrand(brand.Link) + } + return GenerateTemplate(kind, lang, brand) +} diff --git a/apps/backend/internal/module/notification/domain/types.go b/apps/backend/internal/module/notification/domain/types.go new file mode 100644 index 0000000..7980c0b --- /dev/null +++ b/apps/backend/internal/module/notification/domain/types.go @@ -0,0 +1,64 @@ +package domain + +import "context" + +// TemplateKind — Harbor Desk 驗證/重設等郵件種類。 +type TemplateKind string + +const ( + TemplateEmailVerify TemplateKind = "email_verify" + TemplatePasswordReset TemplateKind = "password_reset" +) + +// Language for templates. +type Language string + +const ( + LangZhTW Language = "zh-tw" + LangEnUS Language = "en-us" +) + +// EmailRequest is a ready-to-send message. +type EmailRequest struct { + RecipientEmail string + SenderEmail string + Subject string + HTMLBody string +} + +// VerifyTemplateVars for template placeholders. +type VerifyTemplateVars struct { + Username string + VerifyCode string + // ResetURL — absolute link to FE reset page (password reset only). + // e.g. https://app.example.com/reset-password?email=a%40b.com&token=123456 + ResetURL string +} + +// EmailTemplate is subject + HTML body (may still contain {{.Var}}). +type EmailTemplate struct { + Subject string + Body string +} + +// EmailDelivery sends one email. +type EmailDelivery interface { + SendEmail(ctx context.Context, req EmailRequest) error + // Name is for logs (smtp / log / ses). + Name() string +} + +// UseCase — templates + send. +type UseCase interface { + GetEmailTemplate(kind TemplateKind, lang Language) (*EmailTemplate, error) + RenderVerify(kind TemplateKind, lang Language, vars VerifyTemplateVars) (subject, html string, err error) + SendEmail(ctx context.Context, req EmailRequest) error + // SenderFrom returns configured From address. + SenderFrom() string + // ExposeCodeInAPI is true in local/dev so FE can show mock_code without a real inbox. + ExposeCodeInAPI() bool + // Enabled is true when a real transport is configured (not log-only). + Enabled() bool + // Brand used in hermes chrome (logo etc.). + Brand() Brand +} diff --git a/apps/backend/internal/module/notification/usecase/log_delivery.go b/apps/backend/internal/module/notification/usecase/log_delivery.go new file mode 100644 index 0000000..f08824d --- /dev/null +++ b/apps/backend/internal/module/notification/usecase/log_delivery.go @@ -0,0 +1,25 @@ +package usecase + +import ( + "context" + + "apps/backend/internal/module/notification/domain" + + "github.com/zeromicro/go-zero/core/logx" +) + +// LogDelivery — no SMTP; logs that a mail would be sent (local/dev). +type LogDelivery struct{} + +func NewLogDelivery() *LogDelivery { return &LogDelivery{} } + +func (d *LogDelivery) Name() string { return "log" } + +func (d *LogDelivery) SendEmail(ctx context.Context, req domain.EmailRequest) error { + // Do not log full body in production logs if code is inside; still useful for dev. + logx.WithContext(ctx).Infof( + "[mail:log] to=%s from=%s subject=%q body_len=%d (SMTP not configured — mail not actually sent)", + req.RecipientEmail, req.SenderEmail, req.Subject, len(req.HTMLBody), + ) + return nil +} diff --git a/apps/backend/internal/module/notification/usecase/service.go b/apps/backend/internal/module/notification/usecase/service.go new file mode 100644 index 0000000..1c5446f --- /dev/null +++ b/apps/backend/internal/module/notification/usecase/service.go @@ -0,0 +1,108 @@ +package usecase + +import ( + "bytes" + "context" + "fmt" + "html/template" + "strings" + + "apps/backend/internal/module/notification/domain" + + "github.com/zeromicro/go-zero/core/logx" +) + +// Config for notification service. +type Config struct { + Sender string + DevExposeCode bool + Brand domain.Brand + Providers []domain.EmailDelivery +} + +// Service implements domain.UseCase. +type Service struct { + cfg Config +} + +func NewService(cfg Config) *Service { + if cfg.Sender == "" { + cfg.Sender = "Harbor Desk " + } + if cfg.Brand.Name == "" { + cfg.Brand = domain.DefaultBrand(cfg.Brand.Link) + } + if len(cfg.Providers) == 0 { + cfg.Providers = []domain.EmailDelivery{NewLogDelivery()} + cfg.DevExposeCode = true + } + return &Service{cfg: cfg} +} + +func (s *Service) SenderFrom() string { return s.cfg.Sender } +func (s *Service) ExposeCodeInAPI() bool { return s.cfg.DevExposeCode } +func (s *Service) Brand() domain.Brand { return s.cfg.Brand } + +func (s *Service) Enabled() bool { + for _, p := range s.cfg.Providers { + if p.Name() != "log" { + return true + } + } + return false +} + +func (s *Service) GetEmailTemplate(kind domain.TemplateKind, lang domain.Language) (*domain.EmailTemplate, error) { + lang = domain.NormalizeLanguage(string(lang)) + return domain.GetTemplate(kind, lang, s.cfg.Brand) +} + +func (s *Service) RenderVerify(kind domain.TemplateKind, lang domain.Language, vars domain.VerifyTemplateVars) (string, string, error) { + lang = domain.NormalizeLanguage(string(lang)) + tpl, err := s.GetEmailTemplate(kind, lang) + if err != nil { + return "", "", err + } + if vars.Username == "" { + vars.Username = "there" + } + // hermes may leave empty ResetURL button — still OK if placeholder replaced + t, err := template.New(string(kind)).Parse(tpl.Body) + if err != nil { + return "", "", err + } + var buf bytes.Buffer + if err := t.Execute(&buf, vars); err != nil { + return "", "", err + } + return tpl.Subject, buf.String(), nil +} + +func (s *Service) SendEmail(ctx context.Context, req domain.EmailRequest) error { + if req.SenderEmail == "" { + req.SenderEmail = s.cfg.Sender + } + var last error + for _, p := range s.cfg.Providers { + if err := p.SendEmail(ctx, req); err != nil { + logx.WithContext(ctx).Errorf("notification send via %s failed: %v", p.Name(), err) + last = err + continue + } + logx.WithContext(ctx).Infof("notification sent via %s to %s subject=%q", p.Name(), maskEmail(req.RecipientEmail), req.Subject) + return nil + } + if last != nil { + return last + } + return fmt.Errorf("no email providers configured") +} + +func maskEmail(e string) string { + e = strings.TrimSpace(e) + at := strings.IndexByte(e, '@') + if at <= 1 { + return "***" + } + return e[:1] + "***" + e[at:] +} diff --git a/apps/backend/internal/module/notification/usecase/service_test.go b/apps/backend/internal/module/notification/usecase/service_test.go new file mode 100644 index 0000000..d677c3b --- /dev/null +++ b/apps/backend/internal/module/notification/usecase/service_test.go @@ -0,0 +1,90 @@ +package usecase + +import ( + "context" + "strings" + "testing" + + "apps/backend/internal/module/notification/domain" +) + +func TestRenderVerifyZhHermes(t *testing.T) { + s := NewService(Config{ + DevExposeCode: true, + Brand: domain.Brand{ + Name: "Harbor Desk", Link: "http://127.0.0.1:5173", + LogoURL: "http://127.0.0.1:5173/brand-mark.jpg", Copyright: "© Harbor Desk", + }, + }) + subj, html, err := s.RenderVerify(domain.TemplateEmailVerify, domain.LangZhTW, domain.VerifyTemplateVars{ + Username: "Daniel", VerifyCode: "123456", + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(subj, "Harbor") { + t.Fatalf("subject: %s", subj) + } + if !strings.Contains(html, "123456") || !strings.Contains(html, "Daniel") { + t.Fatalf("body missing code/name") + } + if !strings.Contains(html, "brand-mark.jpg") { + t.Fatalf("missing logo url") + } +} + +func TestRenderPasswordResetEnHasLinkAndLogo(t *testing.T) { + s := NewService(Config{ + Brand: domain.Brand{ + Name: "Harbor Desk", Link: "http://127.0.0.1:5173", + LogoURL: "http://127.0.0.1:5173/brand-mark.jpg", Copyright: "© Harbor Desk", + }, + }) + url := "http://127.0.0.1:5173/reset-password?email=a%40b.com&token=654321" + subj, html, err := s.RenderVerify(domain.TemplatePasswordReset, "en", domain.VerifyTemplateVars{ + Username: "Daniel", VerifyCode: "654321", ResetURL: url, + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(subj, "Reset") { + t.Fatalf("en subject: %s", subj) + } + if !strings.Contains(html, "654321") || !strings.Contains(html, "reset-password") { + t.Fatalf("reset mail missing code or link") + } + if !strings.Contains(html, "brand-mark.jpg") { + t.Fatalf("missing logo") + } + if !strings.Contains(html, "Open reset page") { + t.Fatalf("missing EN CTA") + } +} + +func TestNormalizeLanguage(t *testing.T) { + cases := map[string]domain.Language{ + "zh-TW": domain.LangZhTW, + "zh-tw": domain.LangZhTW, + "en": domain.LangEnUS, + "en-US": domain.LangEnUS, + "en-us,en;q=0.9": domain.LangEnUS, + "": domain.LangZhTW, + } + for in, want := range cases { + if got := domain.NormalizeLanguage(in); got != want { + t.Fatalf("%q → %q want %q", in, got, want) + } + } +} + +func TestLogDelivery(t *testing.T) { + s := NewService(Config{}) + err := s.SendEmail(context.Background(), domain.EmailRequest{ + RecipientEmail: "a@b.com", + Subject: "t", + HTMLBody: "

x

", + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/apps/backend/internal/module/notification/usecase/smtp_delivery.go b/apps/backend/internal/module/notification/usecase/smtp_delivery.go new file mode 100644 index 0000000..857cfdf --- /dev/null +++ b/apps/backend/internal/module/notification/usecase/smtp_delivery.go @@ -0,0 +1,175 @@ +package usecase + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "net/smtp" + "strings" + "time" + + "apps/backend/internal/module/notification/domain" +) + +// SMTPConfig for generic SMTP (Mailgun, SES SMTP, Mailpit, etc.). +// Mailpit local: Host=127.0.0.1 Port=1025 User/Password empty (or any if MP_SMTP_AUTH_ACCEPT_ANY). +// Mailgun: Host=smtp.mailgun.org Port=587 User=postmaster@domain Password=key. +type SMTPConfig struct { + Host string + Port int + User string + Password string +} + +// SMTPDelivery sends HTML mail via SMTP (optional AUTH). +type SMTPDelivery struct { + cfg SMTPConfig +} + +func NewSMTPDelivery(cfg SMTPConfig) *SMTPDelivery { + if cfg.Port <= 0 { + // Mailpit default is 1025; production SMTP usually 587 + cfg.Port = 587 + } + return &SMTPDelivery{cfg: cfg} +} + +func (d *SMTPDelivery) Name() string { return "smtp" } + +func (d *SMTPDelivery) SendEmail(ctx context.Context, req domain.EmailRequest) error { + if d.cfg.Host == "" { + return fmt.Errorf("smtp: empty host") + } + fromAddr := extractEmail(req.SenderEmail) + if fromAddr == "" { + return fmt.Errorf("smtp: invalid sender") + } + to := strings.TrimSpace(req.RecipientEmail) + if to == "" { + return fmt.Errorf("smtp: empty recipient") + } + + msg := buildMIME(req.SenderEmail, to, req.Subject, req.HTMLBody) + addr := fmt.Sprintf("%s:%d", d.cfg.Host, d.cfg.Port) + + // Prefer STARTTLS on 587; plain TLS on 465. + dialer := &net.Dialer{Timeout: 15 * time.Second} + conn, err := dialer.DialContext(ctx, "tcp", addr) + if err != nil { + return fmt.Errorf("smtp dial: %w", err) + } + + var client *smtp.Client + if d.cfg.Port == 465 { + tlsConn := tls.Client(conn, &tls.Config{ServerName: d.cfg.Host, MinVersion: tls.VersionTLS12}) + client, err = smtp.NewClient(tlsConn, d.cfg.Host) + if err != nil { + _ = conn.Close() + return fmt.Errorf("smtp tls client: %w", err) + } + } else { + client, err = smtp.NewClient(conn, d.cfg.Host) + if err != nil { + _ = conn.Close() + return fmt.Errorf("smtp client: %w", err) + } + if ok, _ := client.Extension("STARTTLS"); ok { + if err := client.StartTLS(&tls.Config{ServerName: d.cfg.Host, MinVersion: tls.VersionTLS12}); err != nil { + _ = client.Close() + return fmt.Errorf("smtp starttls: %w", err) + } + } + } + defer client.Close() + + if d.cfg.User != "" { + auth := smtp.PlainAuth("", d.cfg.User, d.cfg.Password, d.cfg.Host) + if err := client.Auth(auth); err != nil { + return fmt.Errorf("smtp auth: %w", err) + } + } + if err := client.Mail(fromAddr); err != nil { + return fmt.Errorf("smtp mail: %w", err) + } + if err := client.Rcpt(to); err != nil { + return fmt.Errorf("smtp rcpt: %w", err) + } + w, err := client.Data() + if err != nil { + return fmt.Errorf("smtp data: %w", err) + } + if _, err := w.Write(msg); err != nil { + _ = w.Close() + return fmt.Errorf("smtp write: %w", err) + } + if err := w.Close(); err != nil { + return fmt.Errorf("smtp close: %w", err) + } + return client.Quit() +} + +func extractEmail(from string) string { + from = strings.TrimSpace(from) + if i := strings.LastIndex(from, "<"); i >= 0 { + j := strings.LastIndex(from, ">") + if j > i { + return strings.TrimSpace(from[i+1 : j]) + } + } + return from +} + +func buildMIME(from, to, subject, html string) []byte { + var b strings.Builder + b.WriteString("From: " + from + "\r\n") + b.WriteString("To: " + to + "\r\n") + b.WriteString("Subject: " + encodeSubject(subject) + "\r\n") + b.WriteString("MIME-Version: 1.0\r\n") + b.WriteString("Content-Type: text/html; charset=UTF-8\r\n") + b.WriteString("\r\n") + b.WriteString(html) + return []byte(b.String()) +} + +// encodeSubject keeps ASCII subjects as-is; non-ASCII uses a simple UTF-8 Q-encoding header. +func encodeSubject(s string) string { + for _, r := range s { + if r > 127 { + // RFC 2047 + return "=?UTF-8?B?" + b64(s) + "?=" + } + } + return s +} + +func b64(s string) string { + const table = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" + src := []byte(s) + var out strings.Builder + for i := 0; i < len(src); i += 3 { + var n uint32 + remain := len(src) - i + switch { + case remain >= 3: + n = uint32(src[i])<<16 | uint32(src[i+1])<<8 | uint32(src[i+2]) + out.WriteByte(table[(n>>18)&63]) + out.WriteByte(table[(n>>12)&63]) + out.WriteByte(table[(n>>6)&63]) + out.WriteByte(table[n&63]) + case remain == 2: + n = uint32(src[i])<<16 | uint32(src[i+1])<<8 + out.WriteByte(table[(n>>18)&63]) + out.WriteByte(table[(n>>12)&63]) + out.WriteByte(table[(n>>6)&63]) + out.WriteByte('=') + case remain == 1: + n = uint32(src[i]) << 16 + out.WriteByte(table[(n>>18)&63]) + out.WriteByte(table[(n>>12)&63]) + out.WriteByte('=') + out.WriteByte('=') + } + } + return out.String() +} diff --git a/apps/backend/internal/module/token/domain/token.go b/apps/backend/internal/module/token/domain/token.go new file mode 100644 index 0000000..8d3a478 --- /dev/null +++ b/apps/backend/internal/module/token/domain/token.go @@ -0,0 +1,34 @@ +package domain + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +var ErrInvalidToken = errors.New("invalid token") + +// Claims is the JWT payload (simplified from stand-alone token claims; numeric uid). +type Claims struct { + UID int64 `json:"uid"` + Roles []string `json:"roles"` + JTI string `json:"jti,omitempty"` + Kind string `json:"kind"` // access | refresh + jwt.RegisteredClaims +} + +// TokenPair is returned to clients (access + refresh). +type TokenPair struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` +} + +// Issuer issues and validates JWT access/refresh pairs. +type Issuer interface { + Issue(uid int64, roles []string) (pair *TokenPair, refreshJTI string, refreshExp time.Time, err error) + ParseAccess(tokenStr string) (*Claims, error) + ParseRefresh(tokenStr string) (*Claims, error) +} diff --git a/apps/backend/internal/module/token/usecase/jwt.go b/apps/backend/internal/module/token/usecase/jwt.go new file mode 100644 index 0000000..9ee113f --- /dev/null +++ b/apps/backend/internal/module/token/usecase/jwt.go @@ -0,0 +1,107 @@ +package usecase + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "time" + + "apps/backend/internal/module/token/domain" + + "github.com/golang-jwt/jwt/v5" +) + +// JWTIssuer implements domain.Issuer (stand-alone token/usecase JWT spirit; HS256 dual secret). +type JWTIssuer struct { + AccessSecret []byte + RefreshSecret []byte + AccessTTL time.Duration + RefreshTTL time.Duration +} + +func NewJWTIssuer(accessSecret, refreshSecret string, accessExpireSec, refreshExpireSec int64) *JWTIssuer { + if accessExpireSec <= 0 { + accessExpireSec = 86400 + } + if refreshExpireSec <= 0 { + refreshExpireSec = 604800 + } + return &JWTIssuer{ + AccessSecret: []byte(accessSecret), + RefreshSecret: []byte(refreshSecret), + AccessTTL: time.Duration(accessExpireSec) * time.Second, + RefreshTTL: time.Duration(refreshExpireSec) * time.Second, + } +} + +func newJTI() string { + var b [16]byte + _, _ = rand.Read(b[:]) + return hex.EncodeToString(b[:]) +} + +func (i *JWTIssuer) Issue(uid int64, roles []string) (*domain.TokenPair, string, time.Time, error) { + now := time.Now() + accessExp := now.Add(i.AccessTTL) + refreshExp := now.Add(i.RefreshTTL) + refreshJTI := newJTI() + + access := jwt.NewWithClaims(jwt.SigningMethodHS256, domain.Claims{ + UID: uid, Roles: roles, Kind: "access", + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(accessExp), + IssuedAt: jwt.NewNumericDate(now), + Subject: fmt.Sprintf("%d", uid), + }, + }) + accessStr, err := access.SignedString(i.AccessSecret) + if err != nil { + return nil, "", time.Time{}, err + } + + refresh := jwt.NewWithClaims(jwt.SigningMethodHS256, domain.Claims{ + UID: uid, Roles: roles, JTI: refreshJTI, Kind: "refresh", + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(refreshExp), + IssuedAt: jwt.NewNumericDate(now), + Subject: fmt.Sprintf("%d", uid), + ID: refreshJTI, + }, + }) + refreshStr, err := refresh.SignedString(i.RefreshSecret) + if err != nil { + return nil, "", time.Time{}, err + } + + return &domain.TokenPair{ + AccessToken: accessStr, + RefreshToken: refreshStr, + TokenType: "Bearer", + ExpiresIn: int64(i.AccessTTL.Seconds()), + }, refreshJTI, refreshExp, nil +} + +func (i *JWTIssuer) ParseAccess(tokenStr string) (*domain.Claims, error) { + return i.parse(tokenStr, i.AccessSecret, "access") +} + +func (i *JWTIssuer) ParseRefresh(tokenStr string) (*domain.Claims, error) { + return i.parse(tokenStr, i.RefreshSecret, "refresh") +} + +func (i *JWTIssuer) parse(tokenStr string, secret []byte, kind string) (*domain.Claims, error) { + tok, err := jwt.ParseWithClaims(tokenStr, &domain.Claims{}, func(t *jwt.Token) (interface{}, error) { + if t.Method != jwt.SigningMethodHS256 { + return nil, fmt.Errorf("unexpected signing method") + } + return secret, nil + }) + if err != nil { + return nil, domain.ErrInvalidToken + } + claims, ok := tok.Claims.(*domain.Claims) + if !ok || !tok.Valid || claims.Kind != kind { + return nil, domain.ErrInvalidToken + } + return claims, nil +} diff --git a/apps/backend/internal/response/response.go b/apps/backend/internal/response/response.go new file mode 100644 index 0000000..fe4aaa2 --- /dev/null +++ b/apps/backend/internal/response/response.go @@ -0,0 +1,105 @@ +package response + +import ( + "context" + "errors" + "net/http" + + "apps/backend/internal/domain" + memberDomain "apps/backend/internal/module/member/domain" + tokenDomain "apps/backend/internal/module/token/domain" + + "github.com/zeromicro/go-zero/rest/httpx" +) + +// Envelope is the standard JSON body (AGENTS.md). +type Envelope struct { + Code int64 `json:"code"` + Message string `json:"message"` + Data interface{} `json:"data"` + Error interface{} `json:"error"` +} + +// Write is used by goctl-generated handlers. +func Write(ctx context.Context, w http.ResponseWriter, data interface{}, err error) { + if err != nil { + status, env := mapError(err) + httpx.WriteJsonCtx(ctx, w, status, env) + return + } + httpx.WriteJsonCtx(ctx, w, http.StatusOK, Envelope{ + Code: domain.SuccessCode, + Message: domain.SuccessMessage, + Data: data, + Error: nil, + }) +} + +func WrapRequestError(err error) error { + if err == nil { + return nil + } + return &bizError{http: http.StatusBadRequest, code: 400001, msg: err.Error()} +} + +type bizError struct { + http int + code int64 + msg string +} + +func (e *bizError) Error() string { return e.msg } + +func Biz(httpStatus int, code int64, msg string) error { + return &bizError{http: httpStatus, code: code, msg: msg} +} + +func mapError(err error) (int, Envelope) { + var be *bizError + if errors.As(err, &be) { + return be.http, Envelope{Code: be.code, Message: be.msg} + } + switch { + case errors.Is(err, memberDomain.ErrBadPassword): + return http.StatusUnauthorized, Envelope{Code: 401010, Message: "invalid email or password"} + case errors.Is(err, memberDomain.ErrSuspended): + return http.StatusForbidden, Envelope{Code: 403001, Message: "account suspended"} + case errors.Is(err, memberDomain.ErrEmailTaken): + return http.StatusConflict, Envelope{Code: 409001, Message: "email already registered"} + case errors.Is(err, memberDomain.ErrWeakPassword): + // 與 domain.PasswordPolicyEN、FE api.err.400003 / password.policy.hint 同一句 + return http.StatusBadRequest, Envelope{Code: 400003, Message: memberDomain.PasswordPolicyEN} + + case errors.Is(err, memberDomain.ErrInvalidCode): + return http.StatusBadRequest, Envelope{Code: 400004, Message: "invalid or expired code"} + case errors.Is(err, memberDomain.ErrEmailNotRegistered): + return http.StatusNotFound, Envelope{Code: 404002, Message: "email not registered"} + case errors.Is(err, memberDomain.ErrNotFound): + return http.StatusNotFound, Envelope{Code: 404001, Message: "not found"} + case errors.Is(err, memberDomain.ErrLastAdmin): + return http.StatusBadRequest, Envelope{Code: 400021, Message: err.Error()} + case errors.Is(err, memberDomain.ErrSelfSuspend): + return http.StatusBadRequest, Envelope{Code: 400020, Message: err.Error()} + case errors.Is(err, tokenDomain.ErrInvalidToken): + return http.StatusUnauthorized, Envelope{Code: 401002, Message: "invalid token"} + default: + return http.StatusInternalServerError, Envelope{Code: 500000, Message: err.Error()} + } +} + +func OK(w http.ResponseWriter, data interface{}) { + Write(context.Background(), w, data, nil) +} + +func Fail(w http.ResponseWriter, httpStatus int, code int64, message string, errDetail interface{}) { + if code == domain.SuccessCode { + code = 400000 + } + httpx.WriteJson(w, httpStatus, Envelope{Code: code, Message: message, Data: nil, Error: errDetail}) +} + +func NotImplemented(w http.ResponseWriter, feature string) { + Fail(w, http.StatusNotImplemented, 501000, "not implemented: "+feature, map[string]string{"feature": feature}) +} + +func IsSuccessCode(code int64) bool { return code == domain.SuccessCode } diff --git a/apps/backend/internal/response/response_test.go b/apps/backend/internal/response/response_test.go new file mode 100644 index 0000000..93197d5 --- /dev/null +++ b/apps/backend/internal/response/response_test.go @@ -0,0 +1,50 @@ +package response + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "apps/backend/internal/domain" + + "github.com/stretchr/testify/require" +) + +func TestOK_Envelope102000(t *testing.T) { + rr := httptest.NewRecorder() + Write(context.Background(), rr, map[string]string{"status": "ok"}, nil) + require.Equal(t, http.StatusOK, rr.Code) + var env Envelope + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &env)) + require.Equal(t, domain.SuccessCode, env.Code) + require.Equal(t, int64(102000), env.Code) + require.NotEmpty(t, env.Message) + require.Nil(t, env.Error) +} + +func TestFail_NotSuccessCode(t *testing.T) { + rr := httptest.NewRecorder() + Fail(rr, http.StatusBadRequest, 400001, "bad request", nil) + var env Envelope + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &env)) + require.NotEqual(t, domain.SuccessCode, env.Code) +} + +func TestNotImplemented_NotEmptySuccess(t *testing.T) { + rr := httptest.NewRecorder() + NotImplemented(rr, "auth.register") + require.Equal(t, http.StatusNotImplemented, rr.Code) + var env Envelope + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &env)) + require.False(t, IsSuccessCode(env.Code)) +} + +func TestWrite_Error(t *testing.T) { + rr := httptest.NewRecorder() + Write(context.Background(), rr, nil, Biz(http.StatusBadRequest, 400010, "x")) + var env Envelope + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &env)) + require.Equal(t, int64(400010), env.Code) +} diff --git a/apps/backend/internal/svc/service_context.go b/apps/backend/internal/svc/service_context.go new file mode 100644 index 0000000..b7a5f40 --- /dev/null +++ b/apps/backend/internal/svc/service_context.go @@ -0,0 +1,149 @@ +package svc + +import ( + "strings" + + "apps/backend/internal/config" + "apps/backend/internal/middleware" + fsDomain "apps/backend/internal/module/filestorage/domain" + "apps/backend/internal/module/filestorage/noop" + "apps/backend/internal/module/filestorage/s3store" + memberDomain "apps/backend/internal/module/member/domain" + memberRepo "apps/backend/internal/module/member/repository" + memberUC "apps/backend/internal/module/member/usecase" + notifDomain "apps/backend/internal/module/notification/domain" + notifUC "apps/backend/internal/module/notification/usecase" + tokenUC "apps/backend/internal/module/token/usecase" + + "github.com/zeromicro/go-zero/core/logx" + "github.com/zeromicro/go-zero/rest" +) + +// ServiceContext wires goctl middleware + modules. +// +// 持久化(關機不丟): +// - members / settings / refresh_tokens / auth_codes → Mongo +// Redis(可丟、可重建): +// - monc 實體快取 only(CacheRedis) +type ServiceContext struct { + Config config.Config + AuthJWT rest.Middleware + AdminAuth rest.Middleware + Members memberDomain.Repository + Auth *memberUC.AuthService + Token *tokenUC.JWTIssuer + Notification notifDomain.UseCase + Storage fsDomain.Storage +} + +func NewServiceContext(c config.Config) *ServiceContext { + if len(c.CacheRedis) == 0 { + logx.Must(errString("CacheRedis is required for monc entity cache")) + } + + repo := memberRepo.NewMoncStore(c.Mongo.URI, c.Mongo.Database, c.CacheRedis) + logx.Infof("mongo monc ready db=%s redis_cache=%s", c.Mongo.Database, c.CacheRedis[0].Host) + + issuer := tokenUC.NewJWTIssuer( + c.Auth.AccessSecret, + c.Auth.RefreshSecret, + c.Auth.AccessExpire, + c.Auth.RefreshExpire, + ) + cost := c.Bcrypt.Cost + if cost <= 0 { + cost = 10 + } + auth := memberUC.NewAccountService(repo, issuer, cost) + auth.GoogleClientID = c.OAuth.GoogleClientID + auth.LineClientID = c.OAuth.LineClientID + auth.LineClientSecret = c.OAuth.LineClientSecret + auth.LineRedirectURI = c.OAuth.LineRedirectURI + + notify := newNotification(c) + store := newStorage(c) + + return &ServiceContext{ + Config: c, + Members: repo, + Auth: auth, + Token: issuer, + Notification: notify, + Storage: store, + AuthJWT: middleware.NewAuthJWTMiddleware(issuer, repo).Handle, + AdminAuth: middleware.NewAdminAuthMiddleware().Handle, + } +} + +// newStorage 只接 MinIO/S3(S3 API);不落本機磁碟。 +func newStorage(c config.Config) fsDomain.Storage { + os := c.ObjectStorage + if strings.TrimSpace(os.Endpoint) == "" { + logx.Error("object storage: Endpoint empty — uploads disabled (MinIO/S3 required)") + return noop.New() + } + s, err := s3store.New(s3store.Config{ + Endpoint: os.Endpoint, + Region: os.Region, + Bucket: os.Bucket, + AccessKey: os.AccessKey, + SecretKey: os.SecretKey, + UsePathStyle: os.UsePathStyle, + PublicBaseURL: os.PublicBaseURL, + }) + if err != nil { + logx.Errorf("object storage MinIO/S3 init failed: %v — uploads disabled", err) + return noop.New() + } + logx.Infof("object storage: minio/s3 endpoint=%s bucket=%s", os.Endpoint, os.Bucket) + return s +} + +func newNotification(c config.Config) notifDomain.UseCase { + base := strings.TrimRight(c.PublicWebBase, "/") + if base == "" { + base = "http://127.0.0.1:5173" + } + brand := notifDomain.Brand{ + Name: c.Brand.Name, + Link: base, + LogoURL: c.Brand.LogoURL, + Copyright: c.Brand.Copyright, + } + if brand.Name == "" { + brand.Name = "Harbor Desk" + } + if brand.LogoURL == "" { + // apps/web/public/brand-mark.jpg — 郵件客戶端需絕對 URL + brand.LogoURL = base + "/brand-mark.jpg" + } + if brand.Copyright == "" { + brand.Copyright = "© Harbor Desk" + } + + cfg := notifUC.Config{ + Sender: c.Mail.Sender, + DevExposeCode: c.Mail.DevExposeCode, + Brand: brand, + } + if c.Mail.SMTP.Host != "" { + cfg.Providers = []notifDomain.EmailDelivery{ + notifUC.NewSMTPDelivery(notifUC.SMTPConfig{ + Host: c.Mail.SMTP.Host, + Port: c.Mail.SMTP.Port, + User: c.Mail.SMTP.User, + Password: c.Mail.SMTP.Password, + }), + } + logx.Infof("notification mail: smtp host=%s port=%d logo=%s expose_code=%v", + c.Mail.SMTP.Host, c.Mail.SMTP.Port, brand.LogoURL, c.Mail.DevExposeCode) + } else { + cfg.Providers = []notifDomain.EmailDelivery{notifUC.NewLogDelivery()} + logx.Info("notification mail: log-only (set Mail.SMTP.Host to send real email)") + } + return notifUC.NewService(cfg) +} + +type errString string + +func (e errString) Error() string { return string(e) } diff --git a/apps/backend/internal/testkit/testkit.go b/apps/backend/internal/testkit/testkit.go new file mode 100644 index 0000000..7169d5f --- /dev/null +++ b/apps/backend/internal/testkit/testkit.go @@ -0,0 +1,116 @@ +package testkit + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "apps/backend/internal/config" + "apps/backend/internal/domain" + "apps/backend/internal/handler" + "apps/backend/internal/response" + "apps/backend/internal/svc" + + "github.com/stretchr/testify/require" + "github.com/zeromicro/go-zero/core/stores/cache" + "github.com/zeromicro/go-zero/core/stores/redis" + "github.com/zeromicro/go-zero/rest" +) + +// FixedNow is a frozen clock for schedule tests (M4+). +var FixedNow = time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC) + +// TestConfig returns a minimal in-memory config for tests. +func TestConfig() config.Config { + var c config.Config + c.Name = "haixun-test" + c.Host = "127.0.0.1" + c.Port = 0 + c.Timeout = 3000 + c.Log.Mode = "console" + c.Log.Level = "error" + c.Mongo.URI = "mongodb://127.0.0.1:27017" + c.Mongo.Database = "haixun_test" + c.CacheRedis = cache.CacheConf{{ + RedisConf: redis.RedisConf{Host: "127.0.0.1:6379", Type: "node"}, + Weight: 100, + }} + c.Auth.AccessSecret = "test-access" + c.Auth.RefreshSecret = "test-refresh" + c.Auth.AccessExpire = 3600 + c.Auth.RefreshExpire = 86400 + c.Worker.ID = "test-worker" + c.Worker.PollIntervalMs = 100 + return c +} + +// StartGateway builds a rest.Server with registered routes (not listening). +// Callers use ServeHTTP via httptest for unit/integration without bind. +func StartGateway(t *testing.T) (*rest.Server, *svc.ServiceContext) { + t.Helper() + c := TestConfig() + // Avoid log spam / file mode issues in tests + server := rest.MustNewServer(c.RestConf) + ctx := svc.NewServiceContext(c) + handler.RegisterHandlers(server, ctx) + t.Cleanup(func() { server.Stop() }) + return server, ctx +} + +// AssertEnvelope102000 checks success envelope shape (A-07). +func AssertEnvelope102000(t *testing.T, body []byte) response.Envelope { + t.Helper() + var env response.Envelope + require.NoError(t, json.Unmarshal(body, &env)) + require.Equal(t, domain.SuccessCode, env.Code, "expected success code 102000") + require.NotEmpty(t, env.Message) + require.Nil(t, env.Error) + return env +} + +// AssertFailNotEmptySuccess ensures response is not 102000 with empty success (X-01). +func AssertFailNotEmptySuccess(t *testing.T, body []byte) response.Envelope { + t.Helper() + var env response.Envelope + require.NoError(t, json.Unmarshal(body, &env)) + require.NotEqual(t, domain.SuccessCode, env.Code, "must not be success empty") + require.NotEmpty(t, env.Message) + return env +} + +// GetJSON issues GET against the in-process server mux via handler registration. +// go-zero rest.Server does not expose ServeHTTP easily in all versions; we re-register +// on a plain mux for tests. +func NewTestMux(svcCtx *svc.ServiceContext) http.Handler { + mux := http.NewServeMux() + // Mirror routes.go paths for testkit convenience + h := handlerProbe{svc: svcCtx} + mux.HandleFunc("GET /api/v1/ping", h.ping) + mux.HandleFunc("GET /api/v1/health", h.ping) + mux.HandleFunc("GET /api/v1/_not_implemented_probe", h.notImpl) + return mux +} + +type handlerProbe struct { + svc *svc.ServiceContext +} + +func (h handlerProbe) ping(w http.ResponseWriter, r *http.Request) { + // Import cycle avoided: call response directly with same payload as ping logic + response.OK(w, map[string]string{"status": "ok", "service": "haixun-gateway"}) +} + +func (h handlerProbe) notImpl(w http.ResponseWriter, r *http.Request) { + response.NotImplemented(w, "probe") +} + +// DoGet helper +func DoGet(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, path, nil) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + return rr +} diff --git a/apps/backend/internal/testkit/testkit_test.go b/apps/backend/internal/testkit/testkit_test.go new file mode 100644 index 0000000..8a51011 --- /dev/null +++ b/apps/backend/internal/testkit/testkit_test.go @@ -0,0 +1,30 @@ +package testkit + +import ( + "net/http" + "testing" + + "apps/backend/internal/domain" + + "github.com/stretchr/testify/require" +) + +func TestTestkit_EnvelopeHelpers(t *testing.T) { + c := TestConfig() + require.Equal(t, "haixun-test", c.Name) + require.Equal(t, int64(102000), domain.SuccessCode) +} + +func TestTestkit_HealthEnvelope(t *testing.T) { + // no monc/redis required — envelope helpers only + mux := NewTestMux(nil) + + rr := DoGet(t, mux, "/api/v1/health") + require.Equal(t, http.StatusOK, rr.Code) + env := AssertEnvelope102000(t, rr.Body.Bytes()) + require.NotNil(t, env.Data) + + rr2 := DoGet(t, mux, "/api/v1/_not_implemented_probe") + require.Equal(t, http.StatusNotImplemented, rr2.Code) + AssertFailNotEmptySuccess(t, rr2.Body.Bytes()) +} diff --git a/apps/backend/internal/types/convert.go b/apps/backend/internal/types/convert.go new file mode 100644 index 0000000..fc969f0 --- /dev/null +++ b/apps/backend/internal/types/convert.go @@ -0,0 +1,126 @@ +package types + +import ( + "strings" + + memberDomain "apps/backend/internal/module/member/domain" +) + +// MemberFromModel maps domain member → goctl MemberPublic (no identities). +func MemberFromModel(m *memberDomain.Member) *MemberPublic { + return MemberFromModelWithIdentities(m, nil) +} + +// MemberFromModelWithIdentities includes bound login channels. +func MemberFromModelWithIdentities(m *memberDomain.Member, ids []*memberDomain.Identity) *MemberPublic { + if m == nil { + return nil + } + out := &MemberPublic{ + TenantId: m.TenantID, Uid: m.UID, Email: m.Email, Phone: m.Phone, + DisplayName: m.DisplayName, Nickname: m.Nickname, FullName: m.FullName, + Roles: m.Roles, Status: m.Status, EmailVerified: m.EmailVerified, + PhoneVerified: m.PhoneVerified, InviteCode: m.InviteCode, + Bio: m.Bio, Timezone: m.Timezone, AvatarUrl: m.AvatarURL, + NotifyEmail: m.NotifyEmail, GenderCode: int(m.GenderCode), Birthdate: m.Birthdate, + National: m.National, Address: m.Address, PostCode: m.PostCode, + PreferredLanguage: m.PreferredLanguage, Currency: m.Currency, + JoinedAt: m.CreatedAt, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt, + } + if m.EmailVerifiedAt != nil { + out.EmailVerifiedAt = *m.EmailVerifiedAt + } + if m.PhoneVerifiedAt != nil { + out.PhoneVerifiedAt = *m.PhoneVerifiedAt + } + if m.ParentUID != nil { + out.ParentUid = *m.ParentUID + } + if len(ids) > 0 { + out.Identities = make([]IdentityPublic, 0, len(ids)) + for _, id := range ids { + out.Identities = append(out.Identities, IdentityFromModel(id)) + } + } + return out +} + +func IdentityFromModel(id *memberDomain.Identity) IdentityPublic { + if id == nil { + return IdentityPublic{} + } + return IdentityPublic{ + LoginId: id.LoginID, Platform: id.Platform, + AccountType: id.AccountType, CreatedAt: id.CreatedAt, + } +} + +func TokenPairFromAuth(access, refresh, tokenType string, expiresIn int64) TokenPair { + return TokenPair{AccessToken: access, RefreshToken: refresh, TokenType: tokenType, ExpiresIn: expiresIn} +} + +// PatchFromAuthProfile builds domain patch from API request. +func PatchFromAuthProfile(req *AuthProfilePatchReq) *memberDomain.UpdateUserInfoPatch { + if req == nil { + return nil + } + p := &memberDomain.UpdateUserInfoPatch{} + if req.DisplayName != "" { + p.DisplayName = &req.DisplayName + } + if req.Nickname != "" { + p.Nickname = &req.Nickname + } + if req.FullName != "" { + p.FullName = &req.FullName + } + if req.Email != "" { + p.Email = &req.Email + } + if req.Phone != "" { + p.Phone = &req.Phone + } + if req.Bio != "" { + p.Bio = &req.Bio + } + if req.Timezone != "" { + p.Timezone = &req.Timezone + } + if req.NotifyEmail != nil { + p.NotifyEmail = req.NotifyEmail + } + // nil = omit(不改);非 nil 含 "" = 明確寫入/清除 + if req.AvatarUrl != nil { + u := strings.TrimSpace(*req.AvatarUrl) + p.AvatarURL = &u + } + if req.GenderCode != nil { + g := int8(*req.GenderCode) + p.GenderCode = &g + } + if req.Birthdate != nil { + p.Birthdate = req.Birthdate + } + if req.National != "" { + p.National = &req.National + } + if req.Address != "" { + p.Address = &req.Address + } + if req.PostCode != "" { + p.PostCode = &req.PostCode + } + if req.PreferredLanguage != "" { + p.PreferredLanguage = &req.PreferredLanguage + } + if req.Currency != "" { + p.Currency = &req.Currency + } + if req.CurrentPassword != "" { + p.CurrentPassword = &req.CurrentPassword + } + if req.NewPassword != "" { + p.NewPassword = &req.NewPassword + } + return p +} diff --git a/apps/backend/internal/types/convert_avatar_test.go b/apps/backend/internal/types/convert_avatar_test.go new file mode 100644 index 0000000..85bf866 --- /dev/null +++ b/apps/backend/internal/types/convert_avatar_test.go @@ -0,0 +1,20 @@ +package types + +import "testing" + +func TestPatchFromAuthProfileClearsAvatar(t *testing.T) { + empty := "" + url := "https://example.com/a.png" + p1 := PatchFromAuthProfile(&AuthProfilePatchReq{AvatarUrl: &empty}) + if p1.AvatarURL == nil || *p1.AvatarURL != "" { + t.Fatalf("empty should clear, got %#v", p1.AvatarURL) + } + p2 := PatchFromAuthProfile(&AuthProfilePatchReq{AvatarUrl: &url}) + if p2.AvatarURL == nil || *p2.AvatarURL != url { + t.Fatalf("set url, got %#v", p2.AvatarURL) + } + p3 := PatchFromAuthProfile(&AuthProfilePatchReq{DisplayName: "x"}) + if p3.AvatarURL != nil { + t.Fatalf("omit should leave nil, got %#v", p3.AvatarURL) + } +} diff --git a/apps/backend/internal/types/types.go b/apps/backend/internal/types/types.go new file mode 100644 index 0000000..81bc5e0 --- /dev/null +++ b/apps/backend/internal/types/types.go @@ -0,0 +1,309 @@ +// Code generated by goctl. DO NOT EDIT. +// goctl 1.7.6 + +package types + +type AdminCreateMemberData struct { + User MemberPublic `json:"user"` + TemporaryPassword string `json:"temporary_password"` +} + +type AdminCreateMemberReq struct { + DisplayName string `json:"display_name"` + Email string `json:"email"` + Phone string `json:"phone,optional"` + Password string `json:"password,optional"` + Roles []string `json:"roles,optional"` + EmailVerified bool `json:"email_verified,optional"` + Bio string `json:"bio,optional"` + Nickname string `json:"nickname,optional"` + FullName string `json:"full_name,optional"` +} + +type AdminEmailVerifiedReq struct { + Uid string `path:"uid"` + Verified bool `json:"verified"` +} + +type AdminListData struct { + List []MemberPublic `json:"list"` + Pagination Pagination `json:"pagination"` +} + +type AdminListMembersReq struct { + Page int `form:"page,optional"` + PageSize int `form:"pageSize,optional"` + Query string `form:"query,optional"` + Status string `form:"status,optional"` +} + +type AdminMemberIdentitiesData struct { + List []IdentityPublic `json:"list"` +} + +type AdminResetPasswordReq struct { + Uid string `path:"uid"` + NewPassword string `json:"new_password,optional"` + Password string `json:"password,optional"` +} + +type AdminRolesReq struct { + Uid string `path:"uid"` + Roles []string `json:"roles"` +} + +type AdminStatusReq struct { + Uid string `path:"uid"` + Status string `json:"status"` +} + +type AdminSuspendReq struct { + Uid string `path:"uid"` + Suspended bool `json:"suspended"` +} + +type AdminUidPath struct { + Uid string `path:"uid"` +} + +type AiSettingsData struct { + Provider string `json:"provider"` + Model string `json:"model"` + ResearchProvider string `json:"research_provider"` + ResearchModel string `json:"research_model"` + ApiKeyConfigured bool `json:"api_key_configured"` + ResearchApiKeyConfigured bool `json:"research_api_key_configured"` + PlatformKeyAvailable bool `json:"platform_key_available"` +} + +type AuthBindReq struct { + LoginId string `json:"login_id"` + Platform string `json:"platform"` + AccountType string `json:"account_type,optional"` + Password string `json:"password,optional"` +} + +type AuthIdentitiesData struct { + List []IdentityPublic `json:"list"` +} + +type AuthLoginReq struct { + Email string `json:"email,optional"` + LoginId string `json:"login_id,optional"` + Password string `json:"password"` + Platform string `json:"platform,optional"` // platform|google|line (default platform) +} + +type AuthLogoutAllData struct { + Ok bool `json:"ok"` +} + +type AuthLogoutReq struct { + RefreshToken string `json:"refresh_token,optional"` +} + +type AuthOAuthCallbackReq struct { + Provider string `path:"provider"` + Code string `json:"code,optional"` + IdToken string `json:"id_token,optional"` + AccessToken string `json:"access_token,optional"` + RedirectUri string `json:"redirect_uri,optional"` +} + +type AuthOAuthProviderPath struct { + Provider string `path:"provider"` +} + +type AuthOAuthStartData struct { + AuthorizeUrl string `json:"authorize_url"` + Provider string `json:"provider"` + State string `json:"state"` +} + +type AuthProfilePatchReq struct { + DisplayName string `json:"display_name,optional"` + Nickname string `json:"nickname,optional"` + FullName string `json:"full_name,optional"` + Email string `json:"email,optional"` + Phone string `json:"phone,optional"` + Bio string `json:"bio,optional"` + Timezone string `json:"timezone,optional"` + NotifyEmail *bool `json:"notify_email,optional"` + AvatarUrl *string `json:"avatar_url,optional"` + GenderCode *int `json:"gender_code,optional"` + Birthdate *int64 `json:"birthdate,optional"` + National string `json:"national,optional"` + Address string `json:"address,optional"` + PostCode string `json:"post_code,optional"` + PreferredLanguage string `json:"preferred_language,optional"` + Currency string `json:"currency,optional"` + CurrentPassword string `json:"current_password,optional"` + NewPassword string `json:"new_password,optional"` +} + +type AuthRefreshReq struct { + RefreshToken string `json:"refresh_token"` +} + +type AuthRegisterReq struct { + Email string `json:"email"` + Password string `json:"password"` + DisplayName string `json:"display_name,optional"` + Phone string `json:"phone,optional"` +} + +type AuthRequestResetData struct { + Ok bool `json:"ok"` + Message string `json:"message"` + MockCode string `json:"mock_code,optional"` + MockResetPath string `json:"mock_reset_path,optional"` +} + +type AuthRequestResetReq struct { + Email string `json:"email"` + Language string `json:"language,optional"` +} + +type AuthResetPasswordReq struct { + Email string `json:"email"` + Token string `json:"token,optional"` + Code string `json:"code,optional"` + NewPassword string `json:"new_password"` +} + +type AuthSendCodeData struct { + Ok bool `json:"ok"` + Message string `json:"message"` + MockCode string `json:"mock_code,optional"` +} + +type AuthSessionData struct { + Tokens TokenPair `json:"tokens"` + Member MemberPublic `json:"member"` +} + +type AuthTokensOnly struct { + Tokens TokenPair `json:"tokens"` +} + +type AuthUnbindReq struct { + LoginId string `json:"login_id"` + Platform string `json:"platform"` +} + +type AuthVerifyEmailReq struct { + Code string `json:"code"` +} + +type Empty struct { +} + +type HealthData struct { + Status string `json:"status"` + Service string `json:"service"` +} + +type IdentityPublic struct { + LoginId string `json:"login_id"` + Platform string `json:"platform"` + AccountType string `json:"account_type"` + CreatedAt int64 `json:"created_at,optional"` +} + +type ListModelsData struct { + List []string `json:"list"` + Provider string `json:"provider"` +} + +type ListModelsReq struct { + Provider string `form:"provider,optional"` +} + +type MediaUploadData struct { + Url string `json:"url"` + Key string `json:"key,optional"` +} + +type MediaUploadReq struct { + Content string `json:"content"` + Kind string `json:"kind,optional"` +} + +type MemberPublic struct { + TenantId string `json:"tenant_id"` + Uid int64 `json:"uid"` + Email string `json:"email"` + Phone string `json:"phone,optional"` + DisplayName string `json:"display_name"` + Nickname string `json:"nickname,optional"` + FullName string `json:"full_name,optional"` + Roles []string `json:"roles"` + Status string `json:"status"` + EmailVerified bool `json:"email_verified"` + EmailVerifiedAt int64 `json:"email_verified_at,optional"` + PhoneVerified bool `json:"phone_verified,optional"` + PhoneVerifiedAt int64 `json:"phone_verified_at,optional"` + InviteCode string `json:"invite_code,optional"` + ParentUid int64 `json:"parent_uid,optional"` + Bio string `json:"bio,optional"` + Timezone string `json:"timezone,optional"` + AvatarUrl string `json:"avatar_url,optional"` + NotifyEmail bool `json:"notify_email,optional"` + GenderCode int `json:"gender_code,optional"` + Birthdate int64 `json:"birthdate,optional"` + National string `json:"national,optional"` + Address string `json:"address,optional"` + PostCode string `json:"post_code,optional"` + PreferredLanguage string `json:"preferred_language,optional"` + Currency string `json:"currency,optional"` + Identities []IdentityPublic `json:"identities,optional"` + JoinedAt int64 `json:"joined_at,optional"` + CreatedAt int64 `json:"created_at,optional"` + UpdatedAt int64 `json:"updated_at,optional"` +} + +type OkData struct { + Ok bool `json:"ok"` + Message string `json:"message,optional"` +} + +type Pagination struct { + Page int `json:"page"` + PageSize int `json:"pageSize"` + Total int64 `json:"total"` + TotalPages int `json:"totalPages"` +} + +type PlacementSettingsData struct { + WebSearchProvider string `json:"web_search_provider"` + ExpandStrategy string `json:"expand_strategy"` + BraveApiKeyConfigured bool `json:"brave_api_key_configured"` + ExaApiKeyConfigured bool `json:"exa_api_key_configured"` + DevModeEnabled bool `json:"dev_mode_enabled"` + PlatformKeyAvailable bool `json:"platform_key_available"` +} + +type SaveAiReq struct { + Provider string `json:"provider,optional"` + Model string `json:"model,optional"` + ResearchProvider string `json:"research_provider,optional"` + ResearchModel string `json:"research_model,optional"` + ApiKey string `json:"api_key,optional"` + ResearchApiKey string `json:"research_api_key,optional"` + ApiKeyConfigured *bool `json:"api_key_configured,optional"` + ResearchApiKeyConfigured *bool `json:"research_api_key_configured,optional"` +} + +type SavePlacementReq struct { + ExpandStrategy string `json:"expand_strategy,optional"` + DevModeEnabled *bool `json:"dev_mode_enabled,optional"` + ExaApiKey string `json:"exa_api_key,optional"` + ExaApiKeyConfigured *bool `json:"exa_api_key_configured,optional"` +} + +type TokenPair struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` +} diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..6005dfb --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,15 @@ +# ── 前後端串接 ────────────────────────────────────────── +# 預設建議:不設 VITE_API_BASE,讓請求走 same-origin +# 瀏覽器 → Vite(/api) → proxy → gateway:8888 +# 遠端開 FE(threads-tool-dev.30cm.net)時必須這樣, +# 不要寫死 http://127.0.0.1:8888(那會打到使用者自己的電腦)。 +# +# 本機若要直連 gateway(跳過 proxy): +# VITE_API_BASE=http://127.0.0.1:8888 +# +# 走 proxy(推薦): +VITE_API_BASE= + +# 預設資料來源:mock | live(可被設定頁 / localStorage 覆寫) +# 會員/Admin/設定 live 時打 apps/backend;其餘業務域仍 mock 到 M2+ +VITE_DATA_SOURCE=live diff --git a/apps/web/public/downloads/haixun-threads-sync.zip b/apps/web/public/downloads/haixun-threads-sync.zip new file mode 100644 index 0000000..f7d9fbc Binary files /dev/null and b/apps/web/public/downloads/haixun-threads-sync.zip differ diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 0e02589..07dde1f 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,5 +1,6 @@ import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; import { AuthProvider } from "./auth/AuthContext"; +import { RequireAdmin } from "./auth/RequireAdmin"; import { RequireAuth } from "./auth/RequireAuth"; import { AppShell } from "./components/layout/AppShell"; import { DataProvider } from "./data/DataContext"; @@ -10,6 +11,7 @@ import { BrandsPage } from "./pages/BrandsPage"; import { CrewPage } from "./pages/CrewPage"; import { JobDetailPage } from "./pages/JobDetailPage"; import { InsightsPage } from "./pages/InsightsPage"; +import { InvitePage } from "./pages/InvitePage"; import { JobsPage } from "./pages/JobsPage"; import { ForgotPasswordPage } from "./pages/ForgotPasswordPage"; import { LoginPage } from "./pages/LoginPage"; @@ -59,7 +61,15 @@ export default function App() { } /> } /> } /> - } /> + } /> + + + + } + /> } /> } /> } /> diff --git a/apps/web/src/auth/RequireAdmin.tsx b/apps/web/src/auth/RequireAdmin.tsx new file mode 100644 index 0000000..12e5d75 --- /dev/null +++ b/apps/web/src/auth/RequireAdmin.tsx @@ -0,0 +1,29 @@ +import { Navigate } from "react-router-dom"; +import { useAuth } from "./AuthContext"; +import { memberRoleSummary } from "../lib/memberRole"; + +/** + * 管理員 permission:roles 含 admin。 + * 無權限一律導向今日,不渲染目標頁(避免 flash 出「需要管理員」空狀態)。 + */ +export function RequireAdmin({ children }: { children: React.ReactNode }) { + const { member, loading } = useAuth(); + + if (loading) { + return ( +
+

載入中…

+
+ ); + } + + if (!member) { + return ; + } + + if (!memberRoleSummary(member).isAdmin) { + return ; + } + + return children; +} diff --git a/apps/web/src/components/layout/AccountMenu.tsx b/apps/web/src/components/layout/AccountMenu.tsx index f991305..31ddd62 100644 --- a/apps/web/src/components/layout/AccountMenu.tsx +++ b/apps/web/src/components/layout/AccountMenu.tsx @@ -92,6 +92,14 @@ export function AccountMenu() { > {t("nav.profile")} + setOpen(false)} + > + {t("nav.invite")} + +
+ ); +} diff --git a/apps/web/src/components/ui/Input.tsx b/apps/web/src/components/ui/Input.tsx index 6b1852b..f7e6d44 100644 --- a/apps/web/src/components/ui/Input.tsx +++ b/apps/web/src/components/ui/Input.tsx @@ -3,15 +3,30 @@ import type { InputHTMLAttributes } from "react"; type Props = InputHTMLAttributes & { label?: string; hint?: string; + /** 欄位旁錯誤(比頁首 banner 更直覺) */ + error?: string; }; -export function Input({ label, hint, id, className = "", ...rest }: Props) { +export function Input({ label, hint, error, id, className = "", ...rest }: Props) { const inputId = id || rest.name; + const errId = error && inputId ? `${inputId}-error` : undefined; return ( -