add minio

This commit is contained in:
王性驊 2026-07-07 14:02:41 +00:00
parent 5acd1b6567
commit 7ed9f50a2c
58 changed files with 2291 additions and 305 deletions

View File

@ -12,6 +12,9 @@ include $(ENV_FILE)
export HAIXUN_MONGO_URI HAIXUN_MONGO_DB HAIXUN_REDIS_ADDR HAIXUN_REDIS_PASSWORD export HAIXUN_MONGO_URI HAIXUN_MONGO_DB HAIXUN_REDIS_ADDR HAIXUN_REDIS_PASSWORD
export HAIXUN_JWT_ACCESS_SECRET HAIXUN_JWT_REFRESH_SECRET HAIXUN_WORKER_SECRET export HAIXUN_JWT_ACCESS_SECRET HAIXUN_JWT_REFRESH_SECRET HAIXUN_WORKER_SECRET
export HAIXUN_SECRETS_KEY HAIXUN_BACKEND_URL export HAIXUN_SECRETS_KEY HAIXUN_BACKEND_URL
export HAIXUN_STORAGE_S3_ENDPOINT HAIXUN_STORAGE_S3_PUBLIC_BASE_URL HAIXUN_STORAGE_S3_REGION
export HAIXUN_STORAGE_S3_BUCKET HAIXUN_STORAGE_S3_ACCESS_KEY HAIXUN_STORAGE_S3_SECRET_KEY HAIXUN_STORAGE_S3_USE_PATH_STYLE
export HAIXUN_SMTP_HOST HAIXUN_SMTP_PORT HAIXUN_SMTP_USERNAME HAIXUN_SMTP_PASSWORD HAIXUN_SMTP_FROM
export HAIXUN_WORKER_POLL_MS HAIXUN_8D_TARGET_SAMPLES PLAYWRIGHT_HEADLESS export HAIXUN_WORKER_POLL_MS HAIXUN_8D_TARGET_SAMPLES PLAYWRIGHT_HEADLESS
export INIT_TENANT_ID INIT_ADMIN_EMAIL INIT_ADMIN_PASSWORD INIT_ADMIN_DISPLAY_NAME export INIT_TENANT_ID INIT_ADMIN_EMAIL INIT_ADMIN_PASSWORD INIT_ADMIN_DISPLAY_NAME
endif endif

View File

@ -292,7 +292,7 @@ POST /api/v1/auth/refresh
POST /api/v1/auth/logout POST /api/v1/auth/logout
``` ```
`register` / `login` 回傳: 封測期間 `register` 需要 admin 會員 JWT公開登入頁不提供自行註冊之後開放註冊時再移除這層權限。`register` / `login` 回傳:
```json ```json
{ {
@ -310,6 +310,18 @@ POST /api/v1/auth/logout
Authorization: Bearer <access_token> Authorization: Bearer <access_token>
``` ```
Admin 島民管理 API封測用皆需 admin JWT
```text
GET /api/v1/members?page=1&pageSize=20
PATCH /api/v1/members/:uid/profile
PATCH /api/v1/members/:uid/roles
PATCH /api/v1/members/:uid/password
```
角色第一版只支援 `admin` / `user`admin 不能調整自己的角色避免把自己降權後鎖住管理入口。Admin 可協助更新島民基本資訊、狀態與商務 email/phone 驗證狀態。
封測建立帳號權限由 `Permission.SeedAdminRegister` 控制是否 seed 進 Mongo之後要開放公開註冊時先關 config再清掉 DB 裡的 `auth.register` permission 即可。`member.manage` 留在一般權限目錄,但 user 預設不會拿到,仍只有 admin 可管理島民。
本機開發可以開啟 `Auth.DevHeaderFallback`,用 header 模擬登入: 本機開發可以開啟 `Auth.DevHeaderFallback`,用 header 模擬登入:
```http ```http

View File

@ -24,7 +24,7 @@ npm run dev
## 使用順序 ## 使用順序
1. 註冊或登入tenant 預設 `default` 1. 使用已建立帳號登入tenant 預設 `default`;封測期間不開放公開註冊
2. **AI 設定**:選研究 provider → 貼 API key → **讀取模型列表** → 選 model → **儲存** 2. **AI 設定**:選研究 provider → 貼 API key → **讀取模型列表** → 選 model → **儲存**
3. 建立人設 → 點選列表中的一筆 3. 建立人設 → 點選列表中的一筆
4. 填對標 Threads 帳號 → **開始 8D** 4. 填對標 Threads 帳號 → **開始 8D**

View File

@ -23,6 +23,23 @@ Auth:
Secrets: Secrets:
EncryptionKey: ${HAIXUN_SECRETS_KEY} EncryptionKey: ${HAIXUN_SECRETS_KEY}
Storage:
S3:
Endpoint: ${HAIXUN_STORAGE_S3_ENDPOINT}
PublicBaseURL: ${HAIXUN_STORAGE_S3_PUBLIC_BASE_URL}
Region: ${HAIXUN_STORAGE_S3_REGION}
Bucket: ${HAIXUN_STORAGE_S3_BUCKET}
AccessKey: ${HAIXUN_STORAGE_S3_ACCESS_KEY}
SecretKey: ${HAIXUN_STORAGE_S3_SECRET_KEY}
UsePathStyle: ${HAIXUN_STORAGE_S3_USE_PATH_STYLE}
SMTP:
Host: ${HAIXUN_SMTP_HOST}
Port: ${HAIXUN_SMTP_PORT}
Username: ${HAIXUN_SMTP_USERNAME}
Password: ${HAIXUN_SMTP_PASSWORD}
From: ${HAIXUN_SMTP_FROM}
InternalWorker: InternalWorker:
Secret: ${HAIXUN_WORKER_SECRET} Secret: ${HAIXUN_WORKER_SECRET}

View File

@ -26,6 +26,27 @@ Auth:
Secrets: Secrets:
EncryptionKey: ${HAIXUN_SECRETS_KEY} EncryptionKey: ${HAIXUN_SECRETS_KEY}
Storage:
S3:
Endpoint: ${HAIXUN_STORAGE_S3_ENDPOINT}
PublicBaseURL: ${HAIXUN_STORAGE_S3_PUBLIC_BASE_URL}
Region: ${HAIXUN_STORAGE_S3_REGION}
Bucket: ${HAIXUN_STORAGE_S3_BUCKET}
AccessKey: ${HAIXUN_STORAGE_S3_ACCESS_KEY}
SecretKey: ${HAIXUN_STORAGE_S3_SECRET_KEY}
UsePathStyle: ${HAIXUN_STORAGE_S3_USE_PATH_STYLE}
SMTP:
Host: ${HAIXUN_SMTP_HOST}
Port: ${HAIXUN_SMTP_PORT}
Username: ${HAIXUN_SMTP_USERNAME}
Password: ${HAIXUN_SMTP_PASSWORD}
From: ${HAIXUN_SMTP_FROM}
# 封測 admin 建立帳號權限 seed。之後開放公開註冊時可關閉。
Permission:
SeedAdminRegister: true
InternalWorker: InternalWorker:
Secret: ${HAIXUN_WORKER_SECRET} Secret: ${HAIXUN_WORKER_SECRET}

View File

@ -39,9 +39,6 @@ type (
summary: "Native member auth and JWT token endpoints" summary: "Native member auth and JWT token endpoints"
) )
service gateway { service gateway {
@handler register
post /register (AuthRegisterReq) returns (AuthTokenData)
@handler login @handler login
post /login (AuthLoginReq) returns (AuthTokenData) post /login (AuthLoginReq) returns (AuthTokenData)
@ -54,9 +51,12 @@ service gateway {
prefix: /api/v1/auth prefix: /api/v1/auth
middleware: AuthJWT middleware: AuthJWT
tags: "Auth" tags: "Auth"
summary: "Logout requires member Bearer JWT" summary: "Authenticated auth management endpoints"
) )
service gateway { service gateway {
@handler register
post /register (AuthRegisterReq) returns (AuthTokenData)
@handler logout @handler logout
post /logout returns (LogoutData) post /logout returns (LogoutData)
} }

View File

@ -5,6 +5,7 @@ type (
TenantID string `json:"tenant_id"` TenantID string `json:"tenant_id"`
UID string `json:"uid"` UID string `json:"uid"`
Email string `json:"email"` Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
DisplayName string `json:"display_name,omitempty"` DisplayName string `json:"display_name,omitempty"`
Avatar string `json:"avatar,omitempty"` Avatar string `json:"avatar,omitempty"`
Phone string `json:"phone,omitempty"` Phone string `json:"phone,omitempty"`
@ -29,6 +30,49 @@ type (
Phone string `json:"phone,optional"` Phone string `json:"phone,optional"`
} }
ListMembersReq {
Page int64 `form:"page,optional"`
PageSize int64 `form:"pageSize,optional"`
}
ListMembersData {
Pagination PaginationData `json:"pagination"`
List []MemberMeData `json:"list"`
}
MemberPath {
UID string `path:"uid" validate:"required"`
}
UpdateMemberRolesReq {
UID string `path:"uid" validate:"required"`
Roles []string `json:"roles" validate:"required"`
}
UpdateMemberPasswordReq {
UID string `path:"uid" validate:"required"`
Password string `json:"password" validate:"required,min=8,max=128"`
}
UpdateMemberProfileReq {
UID string `path:"uid" validate:"required"`
DisplayName *string `json:"display_name,optional"`
Avatar *string `json:"avatar,optional"`
Language *string `json:"language,optional"`
Currency *string `json:"currency,optional"`
Phone *string `json:"phone,optional"`
Status *string `json:"status,optional"`
BusinessEmail *string `json:"business_email,optional"`
BusinessEmailVerified *bool `json:"business_email_verified,optional"`
BusinessPhone *string `json:"business_phone,optional"`
BusinessPhoneVerified *bool `json:"business_phone_verified,optional"`
EmailVerified *bool `json:"email_verified,optional"`
}
UploadAvatarData {
Avatar string `json:"avatar"`
}
MemberPlacementSettingsData { MemberPlacementSettingsData {
WebSearchProvider string `json:"web_search_provider"` // brave | exa WebSearchProvider string `json:"web_search_provider"` // brave | exa
BraveAPIKey string `json:"brave_api_key,omitempty"` BraveAPIKey string `json:"brave_api_key,omitempty"`
@ -42,6 +86,36 @@ type (
DevModeEnabled bool `json:"dev_mode_enabled"` DevModeEnabled bool `json:"dev_mode_enabled"`
} }
MemberAiSettingsData {
Provider string `json:"provider"`
Model string `json:"model"`
ResearchProvider string `json:"research_provider,omitempty"`
ResearchModel string `json:"research_model,omitempty"`
ApiKeys map[string]string `json:"api_keys,omitempty"`
ApiKeysConfigured map[string]interface{} `json:"api_keys_configured"`
}
UpdateMemberAiSettingsReq {
Provider *string `json:"provider,optional"`
Model *string `json:"model,optional"`
ResearchProvider *string `json:"research_provider,optional"`
ResearchModel *string `json:"research_model,optional"`
ApiKeys map[string]string `json:"api_keys,optional"`
}
MemberAiProviderModelsReq {
Provider string `path:"provider" validate:"required,oneof=opencode-go xai"`
ApiKey string `json:"api_key,optional"`
}
MemberAiProviderModelsData {
ID string `json:"id"`
Label string `json:"label"`
Models []string `json:"models"`
Streams bool `json:"streams"`
Error string `json:"error,omitempty"`
}
UpdateMemberPlacementSettingsReq { UpdateMemberPlacementSettingsReq {
WebSearchProvider *string `json:"web_search_provider,optional"` WebSearchProvider *string `json:"web_search_provider,optional"`
BraveAPIKey *string `json:"brave_api_key,optional"` BraveAPIKey *string `json:"brave_api_key,optional"`
@ -72,18 +146,42 @@ type (
summary: "Current member profile endpoints. Requires Bearer JWT or dev headers." summary: "Current member profile endpoints. Requires Bearer JWT or dev headers."
) )
service gateway { service gateway {
@handler listMembers
get / (ListMembersReq) returns (ListMembersData)
@handler getMemberMe @handler getMemberMe
get /me returns (MemberMeData) get /me returns (MemberMeData)
@handler updateMemberMe @handler updateMemberMe
patch /me (UpdateMemberMeReq) returns (MemberMeData) patch /me (UpdateMemberMeReq) returns (MemberMeData)
@handler uploadMemberAvatar
post /me/avatar returns (UploadAvatarData)
@handler getMemberPlacementSettings @handler getMemberPlacementSettings
get /me/placement-settings returns (MemberPlacementSettingsData) get /me/placement-settings returns (MemberPlacementSettingsData)
@handler updateMemberPlacementSettings @handler updateMemberPlacementSettings
patch /me/placement-settings (UpdateMemberPlacementSettingsReq) returns (MemberPlacementSettingsData) patch /me/placement-settings (UpdateMemberPlacementSettingsReq) returns (MemberPlacementSettingsData)
@handler getMemberAiSettings
get /me/ai-settings returns (MemberAiSettingsData)
@handler updateMemberAiSettings
put /me/ai-settings (UpdateMemberAiSettingsReq) returns (MemberAiSettingsData)
@handler listMemberAiProviderModels
post /me/ai-settings/providers/:provider/models (MemberAiProviderModelsReq) returns (MemberAiProviderModelsData)
@handler getMemberCapabilities @handler getMemberCapabilities
get /me/capabilities returns (MemberCapabilitiesData) get /me/capabilities returns (MemberCapabilitiesData)
@handler updateMemberRoles
patch /:uid/roles (UpdateMemberRolesReq) returns (MemberMeData)
@handler updateMemberPassword
patch /:uid/password (UpdateMemberPasswordReq) returns (MemberMeData)
@handler updateMemberProfile
patch /:uid/profile (UpdateMemberProfileReq) returns (MemberMeData)
} }

View File

@ -1,8 +1,12 @@
module haixun-backend module haixun-backend
go 1.23 go 1.24
require ( require (
github.com/aws/aws-sdk-go-v2 v1.42.1
github.com/aws/aws-sdk-go-v2/config v1.32.28
github.com/aws/aws-sdk-go-v2/credentials v1.19.27
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0
github.com/go-playground/validator/v10 v10.27.0 github.com/go-playground/validator/v10 v10.27.0
github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0 github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0
github.com/golang-jwt/jwt/v4 v4.5.2 github.com/golang-jwt/jwt/v4 v4.5.2
@ -17,6 +21,20 @@ require (
require ( require (
github.com/andybalholm/cascadia v1.3.3 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de // indirect github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // 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/signin v1.3.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.32.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.44.0 // indirect
github.com/aws/smithy-go v1.27.3 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect

View File

@ -2,6 +2,42 @@ github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kk
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA= github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw= github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
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/config v1.32.28 h1:qY6afygxK5c2PPU3Sz8W6yB5W44RF1vnmPdBwViDN+Y=
github.com/aws/aws-sdk-go-v2/config v1.32.28/go.mod h1:WeS/wN1IDs8YC+BxTrFz9ZyJ1rufRBQfirOcDusEpmQ=
github.com/aws/aws-sdk-go-v2/credentials v1.19.27 h1:cFksKkdaBGGmpe6XJpvrxFNWkbXY5/gwFqZNB2O9WCM=
github.com/aws/aws-sdk-go-v2/credentials v1.19.27/go.mod h1:20CoObBgNhFfl8/ggDQu2IZmItxDhkLcWSy4C3alDPI=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 h1:/hi1JADLEW9YYryEz1w4GQu0EtP23pP553Cf9KgsDV4=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30/go.mod h1:/3AOgy4K17Dm4ucMZVC/MJkzy5kmfKUcINRHZyo0koQ=
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/aws-sdk-go-v2/service/signin v1.3.0 h1:i0+tbB9QBnzL5NrF2WR/zk8q2s+1N+RaDYr2627E8UI=
github.com/aws/aws-sdk-go-v2/service/signin v1.3.0/go.mod h1:mxC0nT/C8wMMS97DemZPzvUZxvIt+2Iq+eS3JdFZGgg=
github.com/aws/aws-sdk-go-v2/service/sso v1.32.0 h1:qjMmry/cBDee1E/2gyvel0uRYCi3mwRZ2hf6N+GAodo=
github.com/aws/aws-sdk-go-v2/service/sso v1.32.0/go.mod h1:u8af9Nqkmqnr96f7v9nHqzZT9XBwbXEkTiqT4ROuJSE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.0 h1:fpOlDPI55HdszaxapEGk6HsGosOUaM2YPWJpjMgp8UI=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.0/go.mod h1:DMPWJBjYs6+3+f/qhBFEFPPlQ6NlhWjai3dJNvipJ84=
github.com/aws/aws-sdk-go-v2/service/sts v1.44.0 h1:bLZ0PolJ8J+HkJHztcXORUpHXBye2U8298lCEMi6ZCU=
github.com/aws/aws-sdk-go-v2/service/sts v1.44.0/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q=
github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY=
github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=

View File

@ -63,15 +63,16 @@ func EnsureAdminMember(ctx context.Context, repo domrepo.Repository, opts AdminO
displayName = "Admin" displayName = "Admin"
} }
member, err := repo.Create(ctx, &entity.Member{ member, err := repo.Create(ctx, &entity.Member{
TenantID: tenantID, TenantID: tenantID,
UID: uuid.NewString(), UID: uuid.NewString(),
Email: email, Email: email,
DisplayName: displayName, EmailVerified: true,
Language: "zh-TW", DisplayName: displayName,
Status: entity.StatusOpen, Language: "zh-TW",
Origin: entity.OriginNative, Status: entity.StatusOpen,
PasswordHash: string(hash), Origin: entity.OriginNative,
Roles: []string{"admin"}, PasswordHash: string(hash),
Roles: []string{"admin"},
}) })
if err != nil { if err != nil {
return nil, false, err return nil, false, err

View File

@ -35,6 +35,18 @@ func (m *memoryMemberRepo) Create(_ context.Context, member *entity.Member) (*en
return &cp, nil return &cp, nil
} }
func (m *memoryMemberRepo) List(_ context.Context, tenantID string, page, pageSize int64) ([]*entity.Member, int64, int64, int64, error) {
items := make([]*entity.Member, 0)
for _, item := range m.byEmail {
if item.TenantID != tenantID {
continue
}
cp := *item
items = append(items, &cp)
}
return items, int64(len(items)), page, pageSize, nil
}
func (m *memoryMemberRepo) FindByUID(_ context.Context, tenantID, uid string) (*entity.Member, error) { func (m *memoryMemberRepo) FindByUID(_ context.Context, tenantID, uid string) (*entity.Member, error) {
for _, item := range m.byEmail { for _, item := range m.byEmail {
if item.TenantID == tenantID && item.UID == uid { if item.TenantID == tenantID && item.UID == uid {
@ -88,6 +100,18 @@ func (m *memoryMemberRepo) SetRoles(_ context.Context, tenantID, uid string, rol
return app.For(code.Member).ResNotFound("member not found") return app.For(code.Member).ResNotFound("member not found")
} }
func (m *memoryMemberRepo) SetEmailVerificationCode(_ context.Context, tenantID, uid, verifyCode string, expiresAt int64) error {
for _, item := range m.byEmail {
if item.TenantID == tenantID && item.UID == uid {
item.EmailVerifyCode = verifyCode
item.EmailVerifyExpiresAt = expiresAt
item.EmailVerified = false
return nil
}
}
return app.For(code.Member).ResNotFound("member not found")
}
func TestEnsureAdminMemberCreatesAdmin(t *testing.T) { func TestEnsureAdminMemberCreatesAdmin(t *testing.T) {
repo := &memoryMemberRepo{byEmail: map[string]*entity.Member{}} repo := &memoryMemberRepo{byEmail: map[string]*entity.Member{}}
member, created, err := EnsureAdminMember(context.Background(), repo, AdminOptions{ member, created, err := EnsureAdminMember(context.Background(), repo, AdminOptions{

View File

@ -152,6 +152,11 @@ func Init(ctx context.Context, cfg config.Config, opts InitOptions) (*InitReport
if err := permissionUseCase.EnsureDefaultPermissions(ctx); err != nil { if err := permissionUseCase.EnsureDefaultPermissions(ctx); err != nil {
return nil, fmt.Errorf("seed permissions catalog: %w", err) return nil, fmt.Errorf("seed permissions catalog: %w", err)
} }
if cfg.Permission.SeedAdminRegister {
if err := permissionUseCase.EnsureAdminRegisterPermission(ctx); err != nil {
return nil, fmt.Errorf("seed admin register permission: %w", err)
}
}
report.PermissionsSeeded = true report.PermissionsSeeded = true
if err := permissionUseCase.EnsureDefaultRolePermissions(ctx, opts.TenantID); err != nil { if err := permissionUseCase.EnsureDefaultRolePermissions(ctx, opts.TenantID); err != nil {

View File

@ -42,12 +42,40 @@ type InternalWorkerConf struct {
Secret string `json:",optional"` Secret string `json:",optional"`
} }
type PermissionConf struct {
// SeedAdminRegister controls the closed-beta admin-only register permission.
// Turn this off when public registration policy is redesigned.
SeedAdminRegister bool `json:",default=true"`
}
// SecretsConf holds the application-layer encryption key (base64 of 32 bytes) // SecretsConf holds the application-layer encryption key (base64 of 32 bytes)
// used to encrypt sensitive data at rest (browser session, third-party API keys). // used to encrypt sensitive data at rest (browser session, third-party API keys).
type SecretsConf struct { type SecretsConf struct {
EncryptionKey string `json:",optional"` EncryptionKey string `json:",optional"`
} }
type S3Conf struct {
Endpoint string `json:",optional"`
PublicBaseURL string `json:",optional"`
Region string `json:",default=us-east-1"`
Bucket string `json:",optional"`
AccessKey string `json:",optional"`
SecretKey string `json:",optional"`
UsePathStyle bool `json:",default=true"`
}
type StorageConf struct {
S3 S3Conf `json:",optional"`
}
type SMTPConf struct {
Host string `json:",optional"`
Port int `json:",default=25"`
Username string `json:",optional"`
Password string `json:",optional"`
From string `json:",optional"`
}
type BraveConf struct { type BraveConf struct {
APIKey string `json:",optional"` APIKey string `json:",optional"`
} }
@ -77,6 +105,9 @@ type Config struct {
Redis RedisConf `json:",optional"` Redis RedisConf `json:",optional"`
Auth AuthConf `json:",optional"` Auth AuthConf `json:",optional"`
Secrets SecretsConf `json:",optional"` Secrets SecretsConf `json:",optional"`
Storage StorageConf `json:",optional"`
SMTP SMTPConf `json:",optional"`
Permission PermissionConf `json:",optional"`
InternalWorker InternalWorkerConf `json:",optional"` InternalWorker InternalWorkerConf `json:",optional"`
JobWorker JobWorkerConf `json:",optional"` JobWorker JobWorkerConf `json:",optional"`
JobScheduler JobSchedulerConf `json:",optional"` JobScheduler JobSchedulerConf `json:",optional"`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,37 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package member
import (
"errors"
"net/http"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
"haixun-backend/internal/logic/member"
"haixun-backend/internal/response"
"haixun-backend/internal/svc"
)
const maxAvatarUploadBytes = 5 << 20
func UploadMemberAvatarHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxAvatarUploadBytes)
file, header, err := r.FormFile("avatar")
if err != nil {
if errors.As(err, new(*http.MaxBytesError)) {
response.Write(r.Context(), w, nil, app.For(code.Member).InputInvalidFormat("avatar file must be smaller than 5MB"))
return
}
response.Write(r.Context(), w, nil, app.For(code.Member).InputMissingRequired("avatar file is required"))
return
}
defer file.Close()
l := member.NewUploadMemberAvatarLogic(r.Context(), svcCtx)
data, err := l.UploadMemberAvatar(header.Filename, header.Header.Get("Content-Type"), file)
response.Write(r.Context(), w, data, err)
}
}

View File

@ -85,11 +85,6 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/refresh", Path: "/refresh",
Handler: auth.RefreshHandler(serverCtx), Handler: auth.RefreshHandler(serverCtx),
}, },
{
Method: http.MethodPost,
Path: "/register",
Handler: auth.RegisterHandler(serverCtx),
},
}, },
rest.WithPrefix("/api/v1/auth"), rest.WithPrefix("/api/v1/auth"),
) )
@ -103,6 +98,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/logout", Path: "/logout",
Handler: auth.LogoutHandler(serverCtx), Handler: auth.LogoutHandler(serverCtx),
}, },
{
Method: http.MethodPost,
Path: "/register",
Handler: auth.RegisterHandler(serverCtx),
},
}..., }...,
), ),
rest.WithPrefix("/api/v1/auth"), rest.WithPrefix("/api/v1/auth"),
@ -346,65 +346,6 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithPrefix("/api/v1/personas"), rest.WithPrefix("/api/v1/personas"),
) )
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.WorkerSecret},
[]rest.Route{
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/analyze-style8d",
Handler: job.AnalyzeStyle8DFromWorkerHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/cancel-ack",
Handler: job.AckWorkerJobCancelHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/cancel-check",
Handler: job.CheckWorkerJobCancelHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/complete",
Handler: job.CompleteWorkerJobHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/fail",
Handler: job.FailWorkerJobHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/heartbeat",
Handler: job.RefreshWorkerJobLockHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/progress",
Handler: job.UpdateWorkerJobProgressHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/claim",
Handler: job.ClaimWorkerJobHandler(serverCtx),
},
{
Method: http.MethodPatch,
Path: "/workers/personas/:id/style-profile",
Handler: job.StorePersonaStyleProfileFromWorkerHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/threads-accounts/:id/session",
Handler: job.GetWorkerThreadsAccountSessionHandler(serverCtx),
},
}...,
),
rest.WithPrefix("/api/v1/internal"),
)
server.AddRoutes( server.AddRoutes(
rest.WithMiddlewares( rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT}, []rest.Middleware{serverCtx.AuthJWT},
@ -489,10 +430,89 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
rest.WithPrefix("/api/v1"), rest.WithPrefix("/api/v1"),
) )
server.AddRoutes(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.WorkerSecret},
[]rest.Route{
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/analyze-style8d",
Handler: job.AnalyzeStyle8DFromWorkerHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/cancel-ack",
Handler: job.AckWorkerJobCancelHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/cancel-check",
Handler: job.CheckWorkerJobCancelHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/complete",
Handler: job.CompleteWorkerJobHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/fail",
Handler: job.FailWorkerJobHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/heartbeat",
Handler: job.RefreshWorkerJobLockHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/:id/progress",
Handler: job.UpdateWorkerJobProgressHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/jobs/claim",
Handler: job.ClaimWorkerJobHandler(serverCtx),
},
{
Method: http.MethodPatch,
Path: "/workers/personas/:id/style-profile",
Handler: job.StorePersonaStyleProfileFromWorkerHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/workers/threads-accounts/:id/session",
Handler: job.GetWorkerThreadsAccountSessionHandler(serverCtx),
},
}...,
),
rest.WithPrefix("/api/v1/internal"),
)
server.AddRoutes( server.AddRoutes(
rest.WithMiddlewares( rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT}, []rest.Middleware{serverCtx.AuthJWT},
[]rest.Route{ []rest.Route{
{
Method: http.MethodGet,
Path: "/",
Handler: member.ListMembersHandler(serverCtx),
},
{
Method: http.MethodPatch,
Path: "/:uid/password",
Handler: member.UpdateMemberPasswordHandler(serverCtx),
},
{
Method: http.MethodPatch,
Path: "/:uid/profile",
Handler: member.UpdateMemberProfileHandler(serverCtx),
},
{
Method: http.MethodPatch,
Path: "/:uid/roles",
Handler: member.UpdateMemberRolesHandler(serverCtx),
},
{ {
Method: http.MethodGet, Method: http.MethodGet,
Path: "/me", Path: "/me",
@ -503,6 +523,26 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/me", Path: "/me",
Handler: member.UpdateMemberMeHandler(serverCtx), Handler: member.UpdateMemberMeHandler(serverCtx),
}, },
{
Method: http.MethodGet,
Path: "/me/ai-settings",
Handler: member.GetMemberAiSettingsHandler(serverCtx),
},
{
Method: http.MethodPut,
Path: "/me/ai-settings",
Handler: member.UpdateMemberAiSettingsHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/me/ai-settings/providers/:provider/models",
Handler: member.ListMemberAiProviderModelsHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/me/avatar",
Handler: member.UploadMemberAvatarHandler(serverCtx),
},
{ {
Method: http.MethodGet, Method: http.MethodGet,
Path: "/me/capabilities", Path: "/me/capabilities",

View File

@ -0,0 +1,54 @@
package mailer
import (
"context"
"fmt"
"net/smtp"
"strings"
"haixun-backend/internal/config"
)
type Mailer interface {
Send(ctx context.Context, to, subject, text string) error
}
type SMTPMailer struct {
host string
port int
username string
password string
from string
}
func NewSMTPMailer(cfg config.SMTPConf) *SMTPMailer {
if strings.TrimSpace(cfg.Host) == "" || strings.TrimSpace(cfg.From) == "" {
return nil
}
return &SMTPMailer{host: cfg.Host, port: cfg.Port, username: cfg.Username, password: cfg.Password, from: cfg.From}
}
func (m *SMTPMailer) Send(ctx context.Context, to, subject, text string) error {
if m == nil {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
addr := fmt.Sprintf("%s:%d", m.host, m.port)
headers := []string{
"From: " + m.from,
"To: " + to,
"Subject: " + subject,
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=UTF-8",
}
msg := strings.Join(headers, "\r\n") + "\r\n\r\n" + text
var auth smtp.Auth
if m.username != "" || m.password != "" {
auth = smtp.PlainAuth("", m.username, m.password, m.host)
}
return smtp.SendMail(addr, auth, m.from, []string{to}, []byte(msg))
}

View File

@ -0,0 +1,84 @@
package storage
import (
"context"
"fmt"
"io"
"mime"
"path"
"strings"
"haixun-backend/internal/config"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
type Uploader interface {
Upload(ctx context.Context, key, contentType string, body io.Reader) (string, error)
}
type S3Uploader struct {
client *s3.Client
bucket string
publicBaseURL string
bucketReady bool
}
func NewS3Uploader(ctx context.Context, cfg config.S3Conf) (*S3Uploader, error) {
if strings.TrimSpace(cfg.Bucket) == "" {
return nil, nil
}
awsCfg, err := awsconfig.LoadDefaultConfig(ctx,
awsconfig.WithRegion(cfg.Region),
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.AccessKey, cfg.SecretKey, "")),
)
if err != nil {
return nil, err
}
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
if cfg.Endpoint != "" {
o.BaseEndpoint = aws.String(cfg.Endpoint)
}
o.UsePathStyle = cfg.UsePathStyle
})
return &S3Uploader{client: client, bucket: cfg.Bucket, publicBaseURL: strings.TrimRight(cfg.PublicBaseURL, "/")}, nil
}
func (u *S3Uploader) Upload(ctx context.Context, key, contentType string, body io.Reader) (string, error) {
if u == nil || u.client == nil {
return "", fmt.Errorf("s3 uploader is not configured")
}
if !u.bucketReady {
if _, err := u.client.HeadBucket(ctx, &s3.HeadBucketInput{Bucket: aws.String(u.bucket)}); err != nil {
if _, createErr := u.client.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(u.bucket)}); createErr != nil {
return "", createErr
}
}
policy := fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":["*"]},"Action":["s3:GetObject"],"Resource":["arn:aws:s3:::%s/*"]}]}`, u.bucket)
_, _ = u.client.PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{Bucket: aws.String(u.bucket), Policy: aws.String(policy)})
u.bucketReady = true
}
key = strings.TrimLeft(path.Clean("/"+key), "/")
if contentType == "" {
contentType = mime.TypeByExtension(path.Ext(key))
}
if contentType == "" {
contentType = "application/octet-stream"
}
_, err := u.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(u.bucket),
Key: aws.String(key),
Body: body,
ContentType: aws.String(contentType),
})
if err != nil {
return "", err
}
if u.publicBaseURL != "" {
return u.publicBaseURL + "/" + u.bucket + "/" + key, nil
}
return key, nil
}

View File

@ -2,10 +2,18 @@ package auth
import ( import (
"context" "context"
"crypto/rand"
"fmt"
"math/big"
"time"
"haixun-backend/internal/library/clock"
"haixun-backend/internal/logic/authz"
memberusecase "haixun-backend/internal/model/member/domain/usecase" memberusecase "haixun-backend/internal/model/member/domain/usecase"
"haixun-backend/internal/svc" "haixun-backend/internal/svc"
"haixun-backend/internal/types" "haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
) )
type RegisterLogic struct { type RegisterLogic struct {
@ -18,12 +26,40 @@ func NewRegisterLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Register
} }
func (l *RegisterLogic) Register(req *types.AuthRegisterReq) (*types.AuthTokenData, error) { func (l *RegisterLogic) Register(req *types.AuthRegisterReq) (*types.AuthTokenData, error) {
_, token, err := l.svcCtx.Member.Register(l.ctx, memberusecase.RegisterRequest{ if err := authz.RequireAdmin(l.ctx, l.svcCtx); err != nil {
return nil, err
}
member, token, err := l.svcCtx.Member.Register(l.ctx, memberusecase.RegisterRequest{
TenantID: req.TenantID, TenantID: req.TenantID,
Email: req.Email, Email: req.Email,
Password: req.Password, Password: req.Password,
DisplayName: req.DisplayName, DisplayName: req.DisplayName,
Language: req.Language, Language: req.Language,
}) })
return toAuthTokenData(token), err if err != nil {
return nil, err
}
verifyCode, err := newVerifyCode()
if err != nil {
return nil, err
}
expiresAt := clock.NowUnixNano() + int64(15*time.Minute)
if err := l.svcCtx.Member.SetEmailVerificationCode(l.ctx, member.TenantID, member.UID, verifyCode, expiresAt); err != nil {
return nil, err
}
if l.svcCtx.Mailer != nil {
body := fmt.Sprintf("你的巡樓 Console 驗證碼是:%s\n\n此驗證碼 15 分鐘內有效。", verifyCode)
if err := l.svcCtx.Mailer.Send(l.ctx, member.Email, "巡樓 Console 帳號驗證碼", body); err != nil {
logx.WithContext(l.ctx).Errorf("send verification email failed: uid=%s err=%v", member.UID, err)
}
}
return toAuthTokenData(token), nil
}
func newVerifyCode() (string, error) {
n, err := rand.Int(rand.Reader, big.NewInt(1000000))
if err != nil {
return "", err
}
return fmt.Sprintf("%06d", n.Int64()), nil
} }

View File

@ -0,0 +1,37 @@
package member
import (
domusecase "haixun-backend/internal/model/threads_account/domain/usecase"
"haixun-backend/internal/types"
)
func toMemberAiSettingsData(data *domusecase.AiSettings) *types.MemberAiSettingsData {
if data == nil {
return nil
}
configured := map[string]interface{}{}
for provider, ok := range data.ApiKeysConfigured {
configured[provider] = ok
}
return &types.MemberAiSettingsData{
Provider: data.Provider,
Model: data.Model,
ResearchProvider: data.ResearchProvider,
ResearchModel: data.ResearchModel,
ApiKeys: data.ApiKeys,
ApiKeysConfigured: configured,
}
}
func toMemberAiSettingsPatch(req *types.UpdateMemberAiSettingsReq) domusecase.AiSettingsPatch {
if req == nil {
return domusecase.AiSettingsPatch{}
}
return domusecase.AiSettingsPatch{
Provider: req.Provider,
Model: req.Model,
ResearchProvider: req.ResearchProvider,
ResearchModel: req.ResearchModel,
ApiKeys: req.ApiKeys,
}
}

View File

@ -0,0 +1,39 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package member
import (
"context"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type GetMemberAiSettingsLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGetMemberAiSettingsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetMemberAiSettingsLogic {
return &GetMemberAiSettingsLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetMemberAiSettingsLogic) GetMemberAiSettings() (resp *types.MemberAiSettingsData, err error) {
tenantID, uid, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
data, err := l.svcCtx.ThreadsAccount.GetMemberAiSettings(l.ctx, tenantID, uid)
if err != nil {
return nil, err
}
return toMemberAiSettingsData(data), nil
}

View File

@ -0,0 +1,48 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package member
import (
"context"
"haixun-backend/internal/model/ai/domain/enum"
aiuc "haixun-backend/internal/model/ai/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type ListMemberAiProviderModelsLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewListMemberAiProviderModelsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListMemberAiProviderModelsLogic {
return &ListMemberAiProviderModelsLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *ListMemberAiProviderModelsLogic) ListMemberAiProviderModels(req *types.MemberAiProviderModelsReq) (resp *types.MemberAiProviderModelsData, err error) {
tenantID, uid, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
apiKey, err := l.svcCtx.ThreadsAccount.ResolveMemberAiProviderAPIKey(l.ctx, tenantID, uid, req.Provider, req.ApiKey)
if err != nil {
return nil, err
}
result := l.svcCtx.AI.ListProviderModels(l.ctx, enum.ProviderID(req.Provider), aiuc.Credential{APIKey: apiKey})
return &types.MemberAiProviderModelsData{
ID: result.ID,
Label: result.Label,
Models: result.Models,
Streams: result.Streams,
Error: result.Error,
}, nil
}

View File

@ -0,0 +1,56 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package member
import (
"context"
"haixun-backend/internal/logic/authz"
memberusecase "haixun-backend/internal/model/member/domain/usecase"
"haixun-backend/internal/svc"
"haixun-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.ListMembersReq) (resp *types.ListMembersData, err error) {
if err := authz.RequireAdmin(l.ctx, l.svcCtx); err != nil {
return nil, err
}
tenantID, _, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
result, err := l.svcCtx.Member.ListMembers(l.ctx, memberusecase.ListMembersRequest{
TenantID: tenantID,
Page: req.Page,
PageSize: req.PageSize,
})
if err != nil {
return nil, err
}
return &types.ListMembersData{
Pagination: types.PaginationData{
Total: result.Total,
Page: result.Page,
PageSize: result.PageSize,
TotalPages: result.TotalPages,
},
List: toMemberMeDataList(result.List),
}, nil
}

View File

@ -45,6 +45,7 @@ func toMemberMeData(member *entity.Member) *types.MemberMeData {
TenantID: member.TenantID, TenantID: member.TenantID,
UID: member.UID, UID: member.UID,
Email: member.Email, Email: member.Email,
EmailVerified: member.EmailVerified,
DisplayName: member.DisplayName, DisplayName: member.DisplayName,
Avatar: member.Avatar, Avatar: member.Avatar,
Phone: member.Phone, Phone: member.Phone,
@ -61,3 +62,15 @@ func toMemberMeData(member *entity.Member) *types.MemberMeData {
UpdateAt: member.UpdateAt, UpdateAt: member.UpdateAt,
} }
} }
func toMemberMeDataList(members []*entity.Member) []types.MemberMeData {
list := make([]types.MemberMeData, 0, len(members))
for _, item := range members {
data := toMemberMeData(item)
if data == nil {
continue
}
list = append(list, *data)
}
return list
}

View File

@ -0,0 +1,39 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package member
import (
"context"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type UpdateMemberAiSettingsLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewUpdateMemberAiSettingsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateMemberAiSettingsLogic {
return &UpdateMemberAiSettingsLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *UpdateMemberAiSettingsLogic) UpdateMemberAiSettings(req *types.UpdateMemberAiSettingsReq) (resp *types.MemberAiSettingsData, err error) {
tenantID, uid, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
data, err := l.svcCtx.ThreadsAccount.UpdateMemberAiSettings(l.ctx, tenantID, uid, toMemberAiSettingsPatch(req))
if err != nil {
return nil, err
}
return toMemberAiSettingsData(data), nil
}

View File

@ -0,0 +1,46 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package member
import (
"context"
"haixun-backend/internal/logic/authz"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type UpdateMemberPasswordLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewUpdateMemberPasswordLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateMemberPasswordLogic {
return &UpdateMemberPasswordLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *UpdateMemberPasswordLogic) UpdateMemberPassword(req *types.UpdateMemberPasswordReq) (resp *types.MemberMeData, err error) {
if err := authz.RequireAdmin(l.ctx, l.svcCtx); err != nil {
return nil, err
}
tenantID, _, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
if err := l.svcCtx.Member.UpdatePassword(l.ctx, tenantID, req.UID, req.Password); err != nil {
return nil, err
}
member, err := l.svcCtx.Member.GetByUID(l.ctx, tenantID, req.UID)
if err != nil {
return nil, err
}
return toMemberMeData(member), nil
}

View File

@ -0,0 +1,58 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package member
import (
"context"
"haixun-backend/internal/logic/authz"
memberusecase "haixun-backend/internal/model/member/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type UpdateMemberProfileLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewUpdateMemberProfileLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateMemberProfileLogic {
return &UpdateMemberProfileLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *UpdateMemberProfileLogic) UpdateMemberProfile(req *types.UpdateMemberProfileReq) (resp *types.MemberMeData, err error) {
if err := authz.RequireAdmin(l.ctx, l.svcCtx); err != nil {
return nil, err
}
tenantID, _, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
member, err := l.svcCtx.Member.UpdateProfile(l.ctx, memberusecase.UpdateProfileRequest{
TenantID: tenantID,
UID: req.UID,
DisplayName: req.DisplayName,
Avatar: req.Avatar,
Language: req.Language,
Currency: req.Currency,
Phone: req.Phone,
EmailVerified: req.EmailVerified,
Status: req.Status,
BusinessEmail: req.BusinessEmail,
BusinessEmailVerified: req.BusinessEmailVerified,
BusinessPhone: req.BusinessPhone,
BusinessPhoneVerified: req.BusinessPhoneVerified,
})
if err != nil {
return nil, err
}
return toMemberMeData(member), nil
}

View File

@ -0,0 +1,51 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package member
import (
"context"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
"haixun-backend/internal/logic/authz"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type UpdateMemberRolesLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewUpdateMemberRolesLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateMemberRolesLogic {
return &UpdateMemberRolesLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *UpdateMemberRolesLogic) UpdateMemberRoles(req *types.UpdateMemberRolesReq) (resp *types.MemberMeData, err error) {
if err := authz.RequireAdmin(l.ctx, l.svcCtx); err != nil {
return nil, err
}
tenantID, actorUID, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
if req.UID == actorUID {
return nil, app.For(code.Auth).AuthForbidden("admin cannot change own roles")
}
if err := l.svcCtx.Member.SetRoles(l.ctx, tenantID, req.UID, req.Roles); err != nil {
return nil, err
}
member, err := l.svcCtx.Member.GetByUID(l.ctx, tenantID, req.UID)
if err != nil {
return nil, err
}
return toMemberMeData(member), nil
}

View File

@ -0,0 +1,68 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package member
import (
"context"
"io"
"path/filepath"
"strings"
app "haixun-backend/internal/library/errors"
"haixun-backend/internal/library/errors/code"
memberusecase "haixun-backend/internal/model/member/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type UploadMemberAvatarLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewUploadMemberAvatarLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UploadMemberAvatarLogic {
return &UploadMemberAvatarLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *UploadMemberAvatarLogic) UploadMemberAvatar(filename, contentType string, body io.Reader) (resp *types.UploadAvatarData, err error) {
if l.svcCtx.AvatarStorage == nil {
return nil, app.For(code.Member).SysNotImplemented("avatar storage is not configured")
}
tenantID, uid, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
ext := strings.ToLower(filepath.Ext(filename))
if !allowedAvatarExt(ext) {
return nil, app.For(code.Member).InputInvalidFormat("avatar file must be jpg, png, webp, or gif")
}
if contentType != "" && !strings.HasPrefix(strings.ToLower(contentType), "image/") {
return nil, app.For(code.Member).InputInvalidFormat("avatar file must be an image")
}
key := "avatars/" + tenantID + "/" + uid + ext
url, err := l.svcCtx.AvatarStorage.Upload(l.ctx, key, contentType, body)
if err != nil {
return nil, app.For(code.Member).SysInternal("upload avatar failed").WithCause(err)
}
if _, err := l.svcCtx.Member.UpdateProfile(l.ctx, memberusecase.UpdateProfileRequest{TenantID: tenantID, UID: uid, Avatar: &url}); err != nil {
return nil, err
}
return &types.UploadAvatarData{Avatar: url}, nil
}
func allowedAvatarExt(ext string) bool {
switch ext {
case ".jpg", ".jpeg", ".png", ".webp", ".gif":
return true
default:
return false
}
}

View File

@ -23,6 +23,9 @@ type Member struct {
TenantID string `bson:"tenant_id"` TenantID string `bson:"tenant_id"`
UID string `bson:"uid"` UID string `bson:"uid"`
Email string `bson:"email"` Email string `bson:"email"`
EmailVerified bool `bson:"email_verified"`
EmailVerifyCode string `bson:"email_verify_code,omitempty"`
EmailVerifyExpiresAt int64 `bson:"email_verify_expires_at,omitempty"`
DisplayName string `bson:"display_name,omitempty"` DisplayName string `bson:"display_name,omitempty"`
Avatar string `bson:"avatar,omitempty"` Avatar string `bson:"avatar,omitempty"`
Phone string `bson:"phone,omitempty"` Phone string `bson:"phone,omitempty"`

View File

@ -7,20 +7,28 @@ import (
) )
type ProfileUpdate struct { type ProfileUpdate struct {
DisplayName *string DisplayName *string
Avatar *string Avatar *string
Language *string Language *string
Currency *string Currency *string
Phone *string Phone *string
EmailVerified *bool
Status *entity.Status
BusinessEmail *string
BusinessEmailVerified *bool
BusinessPhone *string
BusinessPhoneVerified *bool
} }
type Repository interface { type Repository interface {
EnsureIndexes(ctx context.Context) error EnsureIndexes(ctx context.Context) error
Create(ctx context.Context, member *entity.Member) (*entity.Member, error) Create(ctx context.Context, member *entity.Member) (*entity.Member, error)
List(ctx context.Context, tenantID string, page, pageSize int64) ([]*entity.Member, int64, int64, int64, error)
FindByUID(ctx context.Context, tenantID, uid string) (*entity.Member, error) FindByUID(ctx context.Context, tenantID, uid string) (*entity.Member, error)
FindByEmail(ctx context.Context, tenantID, email string) (*entity.Member, error) FindByEmail(ctx context.Context, tenantID, email string) (*entity.Member, error)
UpdateProfile(ctx context.Context, tenantID, uid string, update ProfileUpdate) (*entity.Member, error) UpdateProfile(ctx context.Context, tenantID, uid string, update ProfileUpdate) (*entity.Member, error)
SetRoles(ctx context.Context, tenantID, uid string, roles []string) error SetRoles(ctx context.Context, tenantID, uid string, roles []string) error
SetActiveThreadsAccountID(ctx context.Context, tenantID, uid, accountID string) error SetActiveThreadsAccountID(ctx context.Context, tenantID, uid, accountID string) error
SetEmailVerificationCode(ctx context.Context, tenantID, uid, code string, expiresAt int64) error
UpdatePassword(ctx context.Context, tenantID, uid, passwordHash string) error UpdatePassword(ctx context.Context, tenantID, uid, passwordHash string) error
} }

View File

@ -21,13 +21,33 @@ type LoginRequest struct {
} }
type UpdateProfileRequest struct { type UpdateProfileRequest struct {
TenantID string TenantID string
UID string UID string
DisplayName *string DisplayName *string
Avatar *string Avatar *string
Language *string Language *string
Currency *string Currency *string
Phone *string Phone *string
EmailVerified *bool
Status *string
BusinessEmail *string
BusinessEmailVerified *bool
BusinessPhone *string
BusinessPhoneVerified *bool
}
type ListMembersRequest struct {
TenantID string
Page int64
PageSize int64
}
type ListMembersResult struct {
List []*entity.Member
Total int64
Page int64
PageSize int64
TotalPages int64
} }
type AuthToken struct { type AuthToken struct {
@ -41,7 +61,11 @@ type AuthToken struct {
type UseCase interface { type UseCase interface {
Register(ctx context.Context, req RegisterRequest) (*entity.Member, *AuthToken, error) Register(ctx context.Context, req RegisterRequest) (*entity.Member, *AuthToken, error)
Login(ctx context.Context, req LoginRequest) (*entity.Member, *AuthToken, error) Login(ctx context.Context, req LoginRequest) (*entity.Member, *AuthToken, error)
ListMembers(ctx context.Context, req ListMembersRequest) (*ListMembersResult, error)
GetByUID(ctx context.Context, tenantID, uid string) (*entity.Member, error) GetByUID(ctx context.Context, tenantID, uid string) (*entity.Member, error)
UpdateProfile(ctx context.Context, req UpdateProfileRequest) (*entity.Member, error) UpdateProfile(ctx context.Context, req UpdateProfileRequest) (*entity.Member, error)
UpdatePassword(ctx context.Context, tenantID, uid, password string) error
SetRoles(ctx context.Context, tenantID, uid string, roles []string) error
SetEmailVerificationCode(ctx context.Context, tenantID, uid, code string, expiresAt int64) error
SetActiveThreadsAccountID(ctx context.Context, tenantID, uid, accountID string) error SetActiveThreadsAccountID(ctx context.Context, tenantID, uid, accountID string) error
} }

View File

@ -65,6 +65,38 @@ func (r *mongoRepository) Create(ctx context.Context, member *entity.Member) (*e
return member, nil return member, nil
} }
func (r *mongoRepository) List(ctx context.Context, tenantID string, page, pageSize int64) ([]*entity.Member, int64, int64, int64, error) {
if r.collection == nil {
return nil, 0, page, pageSize, app.For(code.Member).DBUnavailable("Mongo is not configured")
}
if tenantID == "" {
return nil, 0, page, pageSize, app.For(code.Member).InputMissingRequired("tenant_id is required")
}
page, pageSize = normalizePagination(page, pageSize)
filter := bson.M{"tenant_id": tenantID}
total, err := r.collection.CountDocuments(ctx, filter)
if err != nil {
return nil, 0, page, pageSize, err
}
cursor, err := r.collection.Find(ctx, filter, options.Find().SetSort(bson.D{{Key: "create_at", Value: -1}}).SetSkip((page-1)*pageSize).SetLimit(pageSize))
if err != nil {
return nil, 0, page, pageSize, err
}
defer cursor.Close(ctx)
items := make([]*entity.Member, 0)
for cursor.Next(ctx) {
var item entity.Member
if err := cursor.Decode(&item); err != nil {
return nil, 0, page, pageSize, err
}
items = append(items, &item)
}
if err := cursor.Err(); err != nil {
return nil, 0, page, pageSize, err
}
return items, total, page, pageSize, nil
}
func (r *mongoRepository) FindByUID(ctx context.Context, tenantID, uid string) (*entity.Member, error) { func (r *mongoRepository) FindByUID(ctx context.Context, tenantID, uid string) (*entity.Member, error) {
return r.findOne(ctx, bson.M{"tenant_id": tenantID, "uid": uid}) return r.findOne(ctx, bson.M{"tenant_id": tenantID, "uid": uid})
} }
@ -135,6 +167,24 @@ func (r *mongoRepository) UpdateProfile(ctx context.Context, tenantID, uid strin
if update.Phone != nil { if update.Phone != nil {
set["phone"] = *update.Phone set["phone"] = *update.Phone
} }
if update.EmailVerified != nil {
set["email_verified"] = *update.EmailVerified
}
if update.Status != nil {
set["status"] = *update.Status
}
if update.BusinessEmail != nil {
set["business_email"] = *update.BusinessEmail
}
if update.BusinessEmailVerified != nil {
set["business_email_verified"] = *update.BusinessEmailVerified
}
if update.BusinessPhone != nil {
set["business_phone"] = *update.BusinessPhone
}
if update.BusinessPhoneVerified != nil {
set["business_phone_verified"] = *update.BusinessPhoneVerified
}
var out entity.Member var out entity.Member
err := r.collection.FindOneAndUpdate( err := r.collection.FindOneAndUpdate(
ctx, ctx,
@ -169,6 +219,27 @@ func (r *mongoRepository) UpdatePassword(ctx context.Context, tenantID, uid, pas
return nil return nil
} }
func (r *mongoRepository) SetEmailVerificationCode(ctx context.Context, tenantID, uid, verifyCode string, expiresAt int64) error {
if r.collection == nil {
return app.For(code.Member).DBUnavailable("Mongo is not configured")
}
if tenantID == "" || uid == "" {
return app.For(code.Member).InputMissingRequired("tenant_id and uid are required")
}
res, err := r.collection.UpdateOne(
ctx,
bson.M{"tenant_id": tenantID, "uid": uid},
bson.M{"$set": bson.M{"email_verify_code": verifyCode, "email_verify_expires_at": expiresAt, "email_verified": false, "update_at": clock.NowUnixNano()}},
)
if err != nil {
return err
}
if res.MatchedCount == 0 {
return app.For(code.Member).ResNotFound("member not found")
}
return nil
}
func (r *mongoRepository) findOne(ctx context.Context, filter bson.M) (*entity.Member, error) { func (r *mongoRepository) findOne(ctx context.Context, filter bson.M) (*entity.Member, error) {
if r.collection == nil { if r.collection == nil {
return nil, app.For(code.Member).DBUnavailable("Mongo is not configured") return nil, app.For(code.Member).DBUnavailable("Mongo is not configured")
@ -187,3 +258,16 @@ func (r *mongoRepository) findOne(ctx context.Context, filter bson.M) (*entity.M
func normalizeEmail(email string) string { func normalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email)) return strings.ToLower(strings.TrimSpace(email))
} }
func normalizePagination(page, pageSize int64) (int64, int64) {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
return page, pageSize
}

View File

@ -75,6 +75,21 @@ func (u *memberUseCase) Login(ctx context.Context, req domusecase.LoginRequest)
return member, token, err return member, token, err
} }
func (u *memberUseCase) ListMembers(ctx context.Context, req domusecase.ListMembersRequest) (*domusecase.ListMembersResult, error) {
if strings.TrimSpace(req.TenantID) == "" {
return nil, app.For(code.Member).InputMissingRequired("tenant_id is required")
}
items, total, page, pageSize, err := u.repo.List(ctx, strings.TrimSpace(req.TenantID), req.Page, req.PageSize)
if err != nil {
return nil, err
}
totalPages := int64(0)
if pageSize > 0 {
totalPages = (total + pageSize - 1) / pageSize
}
return &domusecase.ListMembersResult{List: items, Total: total, Page: page, PageSize: pageSize, TotalPages: totalPages}, nil
}
func (u *memberUseCase) GetByUID(ctx context.Context, tenantID, uid string) (*entity.Member, error) { func (u *memberUseCase) GetByUID(ctx context.Context, tenantID, uid string) (*entity.Member, error) {
if tenantID == "" || uid == "" { if tenantID == "" || uid == "" {
return nil, app.For(code.Member).InputMissingRequired("tenant_id and uid are required") return nil, app.For(code.Member).InputMissingRequired("tenant_id and uid are required")
@ -89,16 +104,67 @@ func (u *memberUseCase) SetActiveThreadsAccountID(ctx context.Context, tenantID,
return u.repo.SetActiveThreadsAccountID(ctx, tenantID, uid, accountID) return u.repo.SetActiveThreadsAccountID(ctx, tenantID, uid, accountID)
} }
func (u *memberUseCase) SetRoles(ctx context.Context, tenantID, uid string, roles []string) error {
if tenantID == "" || uid == "" {
return app.For(code.Member).InputMissingRequired("tenant_id and uid are required")
}
normalized := normalizeRoles(roles)
if len(normalized) == 0 {
return app.For(code.Member).InputInvalidFormat("roles must include user or admin")
}
for _, role := range normalized {
if role != "user" && role != "admin" {
return app.For(code.Member).InputInvalidFormat("roles only support user or admin")
}
}
return u.repo.SetRoles(ctx, tenantID, uid, normalized)
}
func (u *memberUseCase) SetEmailVerificationCode(ctx context.Context, tenantID, uid, verifyCode string, expiresAt int64) error {
if tenantID == "" || uid == "" || strings.TrimSpace(verifyCode) == "" || expiresAt <= 0 {
return app.For(code.Member).InputMissingRequired("tenant_id, uid, code, and expires_at are required")
}
return u.repo.SetEmailVerificationCode(ctx, tenantID, uid, strings.TrimSpace(verifyCode), expiresAt)
}
func (u *memberUseCase) UpdatePassword(ctx context.Context, tenantID, uid, password string) error {
if tenantID == "" || uid == "" || password == "" {
return app.For(code.Member).InputMissingRequired("tenant_id, uid, and password are required")
}
if len(password) < 8 {
return app.For(code.Member).InputInvalidFormat("password must be at least 8 characters")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return app.For(code.Member).SysInternal("hash password failed").WithCause(err)
}
return u.repo.UpdatePassword(ctx, tenantID, uid, string(hash))
}
func (u *memberUseCase) UpdateProfile(ctx context.Context, req domusecase.UpdateProfileRequest) (*entity.Member, error) { func (u *memberUseCase) UpdateProfile(ctx context.Context, req domusecase.UpdateProfileRequest) (*entity.Member, error) {
if req.TenantID == "" || req.UID == "" { if req.TenantID == "" || req.UID == "" {
return nil, app.For(code.Member).InputMissingRequired("tenant_id and uid are required") return nil, app.For(code.Member).InputMissingRequired("tenant_id and uid are required")
} }
var status *entity.Status
if req.Status != nil {
next := entity.Status(strings.TrimSpace(*req.Status))
if next != entity.StatusOpen && next != entity.StatusSuspended && next != entity.StatusDeleted {
return nil, app.For(code.Member).InputInvalidFormat("status only supports open, suspended, or deleted")
}
status = &next
}
return u.repo.UpdateProfile(ctx, req.TenantID, req.UID, domrepo.ProfileUpdate{ return u.repo.UpdateProfile(ctx, req.TenantID, req.UID, domrepo.ProfileUpdate{
DisplayName: req.DisplayName, DisplayName: req.DisplayName,
Avatar: req.Avatar, Avatar: req.Avatar,
Language: req.Language, Language: req.Language,
Currency: req.Currency, Currency: req.Currency,
Phone: req.Phone, Phone: req.Phone,
EmailVerified: req.EmailVerified,
Status: status,
BusinessEmail: req.BusinessEmail,
BusinessEmailVerified: req.BusinessEmailVerified,
BusinessPhone: req.BusinessPhone,
BusinessPhoneVerified: req.BusinessPhoneVerified,
}) })
} }
@ -125,3 +191,17 @@ func (u *memberUseCase) issue(ctx context.Context, member *entity.Member) (*domu
func normalizeEmail(email string) string { func normalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email)) return strings.ToLower(strings.TrimSpace(email))
} }
func normalizeRoles(roles []string) []string {
seen := make(map[string]bool, len(roles))
out := make([]string, 0, len(roles))
for _, role := range roles {
role = strings.ToLower(strings.TrimSpace(role))
if role == "" || seen[role] {
continue
}
seen[role] = true
out = append(out, role)
}
return out
}

View File

@ -34,6 +34,7 @@ type MePermissions struct {
type UseCase interface { type UseCase interface {
EnsureDefaultPermissions(ctx context.Context) error EnsureDefaultPermissions(ctx context.Context) error
EnsureAdminRegisterPermission(ctx context.Context) error
EnsureDefaultRolePermissions(ctx context.Context, tenantID string) error EnsureDefaultRolePermissions(ctx context.Context, tenantID string) error
Catalog(ctx context.Context, req CatalogRequest) ([]PermissionNode, []PermissionNode, error) Catalog(ctx context.Context, req CatalogRequest) ([]PermissionNode, []PermissionNode, error)
Me(ctx context.Context, member *entity.Member, includeTree bool) (*MePermissions, error) Me(ctx context.Context, member *entity.Member, includeTree bool) (*MePermissions, error)

View File

@ -29,6 +29,15 @@ func (u *permissionUseCase) EnsureDefaultPermissions(ctx context.Context) error
return nil return nil
} }
func (u *permissionUseCase) EnsureAdminRegisterPermission(ctx context.Context) error {
for _, permission := range adminRegisterPermissions() {
if err := u.permissions.UpsertByName(ctx, permission); err != nil {
return err
}
}
return nil
}
func (u *permissionUseCase) EnsureDefaultRolePermissions(ctx context.Context, tenantID string) error { func (u *permissionUseCase) EnsureDefaultRolePermissions(ctx context.Context, tenantID string) error {
if tenantID == "" { if tenantID == "" {
return nil return nil
@ -196,6 +205,7 @@ func defaultUserPermissionNames() []string {
"auth.logout", "auth.logout",
"member.me.read", "member.me.read",
"member.me.update", "member.me.update",
"member.avatar.update",
"member.capabilities.read", "member.capabilities.read",
"member.placement_settings.read", "member.placement_settings.read",
"member.placement_settings.update", "member.placement_settings.update",
@ -260,9 +270,11 @@ func defaultPermissions() []*entity.Permission {
{Name: "member.me.read", HTTPMethods: "GET", HTTPPath: "/api/v1/members/me", Status: entity.StatusOpen, Type: entity.TypeFrontendUser}, {Name: "member.me.read", HTTPMethods: "GET", HTTPPath: "/api/v1/members/me", Status: entity.StatusOpen, Type: entity.TypeFrontendUser},
{Name: "member.me.update", HTTPMethods: "PATCH", HTTPPath: "/api/v1/members/me", Status: entity.StatusOpen, Type: entity.TypeFrontendUser}, {Name: "member.me.update", HTTPMethods: "PATCH", HTTPPath: "/api/v1/members/me", Status: entity.StatusOpen, Type: entity.TypeFrontendUser},
{Name: "member.avatar.update", HTTPMethods: "POST", HTTPPath: "/api/v1/members/me/avatar", Status: entity.StatusOpen, Type: entity.TypeFrontendUser},
{Name: "member.capabilities.read", HTTPMethods: "GET", HTTPPath: "/api/v1/members/me/capabilities", Status: entity.StatusOpen, Type: entity.TypeFrontendUser}, {Name: "member.capabilities.read", HTTPMethods: "GET", HTTPPath: "/api/v1/members/me/capabilities", Status: entity.StatusOpen, Type: entity.TypeFrontendUser},
{Name: "member.placement_settings.read", HTTPMethods: "GET", HTTPPath: "/api/v1/members/me/placement-settings", Status: entity.StatusOpen, Type: entity.TypeFrontendUser}, {Name: "member.placement_settings.read", HTTPMethods: "GET", HTTPPath: "/api/v1/members/me/placement-settings", Status: entity.StatusOpen, Type: entity.TypeFrontendUser},
{Name: "member.placement_settings.update", HTTPMethods: "PATCH", HTTPPath: "/api/v1/members/me/placement-settings", Status: entity.StatusOpen, Type: entity.TypeFrontendUser}, {Name: "member.placement_settings.update", HTTPMethods: "PATCH", HTTPPath: "/api/v1/members/me/placement-settings", Status: entity.StatusOpen, Type: entity.TypeFrontendUser},
{Name: "member.manage", HTTPMethods: "GET|PATCH", HTTPPath: "/api/v1/members/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser},
{Name: "permission.catalog.read", HTTPMethods: "GET", HTTPPath: "/api/v1/permissions/catalog", Status: entity.StatusOpen, Type: entity.TypeBackendUser}, {Name: "permission.catalog.read", HTTPMethods: "GET", HTTPPath: "/api/v1/permissions/catalog", Status: entity.StatusOpen, Type: entity.TypeBackendUser},
{Name: "permission.me.read", HTTPMethods: "GET", HTTPPath: "/api/v1/permissions/me", Status: entity.StatusOpen, Type: entity.TypeFrontendUser}, {Name: "permission.me.read", HTTPMethods: "GET", HTTPPath: "/api/v1/permissions/me", Status: entity.StatusOpen, Type: entity.TypeFrontendUser},
@ -280,3 +292,9 @@ func defaultPermissions() []*entity.Permission {
{Name: "job.template.manage", HTTPMethods: "GET|PUT", HTTPPath: "/api/v1/job/templates/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser}, {Name: "job.template.manage", HTTPMethods: "GET|PUT", HTTPPath: "/api/v1/job/templates/*", Status: entity.StatusOpen, Type: entity.TypeBackendUser},
} }
} }
func adminRegisterPermissions() []*entity.Permission {
return []*entity.Permission{
{Name: "auth.register", HTTPMethods: "POST", HTTPPath: "/api/v1/auth/register", Status: entity.StatusOpen, Type: entity.TypeBackendUser},
}
}

View File

@ -272,8 +272,11 @@ type UseCase interface {
UpdateConnection(ctx context.Context, req UpdateConnectionRequest) (*ConnectionData, error) UpdateConnection(ctx context.Context, req UpdateConnectionRequest) (*ConnectionData, error)
ImportBrowserSession(ctx context.Context, req ImportBrowserSessionRequest) (*ImportBrowserSessionResult, error) ImportBrowserSession(ctx context.Context, req ImportBrowserSessionRequest) (*ImportBrowserSessionResult, error)
GetBrowserSession(ctx context.Context, tenantID, ownerUID, accountID string) (*BrowserSessionData, error) GetBrowserSession(ctx context.Context, tenantID, ownerUID, accountID string) (*BrowserSessionData, error)
GetMemberAiSettings(ctx context.Context, tenantID, ownerUID string) (*AiSettings, error)
UpdateMemberAiSettings(ctx context.Context, tenantID, ownerUID string, patch AiSettingsPatch) (*AiSettings, error)
GetAiSettings(ctx context.Context, tenantID, ownerUID, accountID string) (*AiSettings, error) GetAiSettings(ctx context.Context, tenantID, ownerUID, accountID string) (*AiSettings, error)
UpdateAiSettings(ctx context.Context, tenantID, ownerUID, accountID string, patch AiSettingsPatch) (*AiSettings, error) UpdateAiSettings(ctx context.Context, tenantID, ownerUID, accountID string, patch AiSettingsPatch) (*AiSettings, error)
ResolveMemberAiProviderAPIKey(ctx context.Context, tenantID, ownerUID, provider, overrideKey string) (string, error)
ResolveAiProviderAPIKey(ctx context.Context, tenantID, ownerUID, accountID, provider, overrideKey string) (string, error) ResolveAiProviderAPIKey(ctx context.Context, tenantID, ownerUID, accountID, provider, overrideKey string) (string, error)
ResolveWorkerAiCredential(ctx context.Context, tenantID, ownerUID, accountID string) (*WorkerAiCredential, error) ResolveWorkerAiCredential(ctx context.Context, tenantID, ownerUID, accountID string) (*WorkerAiCredential, error)
ResolveMemberAiCredential(ctx context.Context, tenantID, ownerUID string) (*WorkerAiCredential, error) ResolveMemberAiCredential(ctx context.Context, tenantID, ownerUID string) (*WorkerAiCredential, error)

View File

@ -32,6 +32,17 @@ func (u *threadsAccountUseCase) GetAiSettings(ctx context.Context, tenantID, own
return toPublicAiSettings(account.ID, stored), nil return toPublicAiSettings(account.ID, stored), nil
} }
func (u *threadsAccountUseCase) GetMemberAiSettings(ctx context.Context, tenantID, ownerUID string) (*domusecase.AiSettings, error) {
if err := requireActor(tenantID, ownerUID); err != nil {
return nil, err
}
stored, err := u.loadAiCredentials(ctx, ownerUID, "")
if err != nil {
return nil, err
}
return toPublicAiSettings("", stored), nil
}
func (u *threadsAccountUseCase) ResolveMemberAiCredential( func (u *threadsAccountUseCase) ResolveMemberAiCredential(
ctx context.Context, ctx context.Context,
tenantID, ownerUID string, tenantID, ownerUID string,
@ -140,6 +151,28 @@ func (u *threadsAccountUseCase) ResolveAiProviderAPIKey(
return apiKey, nil return apiKey, nil
} }
func (u *threadsAccountUseCase) ResolveMemberAiProviderAPIKey(
ctx context.Context,
tenantID, ownerUID, provider, overrideKey string,
) (string, error) {
if err := requireActor(tenantID, ownerUID); err != nil {
return "", err
}
trimmedOverride := strings.TrimSpace(overrideKey)
if trimmedOverride != "" && !isMaskedAPIKey(trimmedOverride) {
return trimmedOverride, nil
}
stored, err := u.loadAiCredentials(ctx, ownerUID, "")
if err != nil {
return "", err
}
apiKey := strings.TrimSpace(stored.ApiKeys[provider])
if apiKey == "" {
return "", app.For(code.ThreadsAccount).InputMissingRequired("請先在設定頁設定 " + provider + " API key")
}
return apiKey, nil
}
func (u *threadsAccountUseCase) UpdateAiSettings( func (u *threadsAccountUseCase) UpdateAiSettings(
ctx context.Context, ctx context.Context,
tenantID, ownerUID, accountID string, tenantID, ownerUID, accountID string,
@ -160,6 +193,25 @@ func (u *threadsAccountUseCase) UpdateAiSettings(
return toPublicAiSettings(account.ID, next), nil return toPublicAiSettings(account.ID, next), nil
} }
func (u *threadsAccountUseCase) UpdateMemberAiSettings(
ctx context.Context,
tenantID, ownerUID string,
patch domusecase.AiSettingsPatch,
) (*domusecase.AiSettings, error) {
if err := requireActor(tenantID, ownerUID); err != nil {
return nil, err
}
current, err := u.loadAiCredentials(ctx, ownerUID, "")
if err != nil {
return nil, err
}
next := applyAiSettingsPatch(current, patch)
if err := u.saveAiCredentials(ctx, ownerUID, next); err != nil {
return nil, err
}
return toPublicAiSettings("", next), nil
}
type aiCredentials struct { type aiCredentials struct {
Provider string Provider string
Model string Model string

View File

@ -9,8 +9,10 @@ import (
"haixun-backend/internal/config" "haixun-backend/internal/config"
libcrypto "haixun-backend/internal/library/crypto" libcrypto "haixun-backend/internal/library/crypto"
"haixun-backend/internal/library/mailer"
libmongo "haixun-backend/internal/library/mongo" libmongo "haixun-backend/internal/library/mongo"
libredis "haixun-backend/internal/library/redis" libredis "haixun-backend/internal/library/redis"
"haixun-backend/internal/library/storage"
libthreads "haixun-backend/internal/library/threadsapi" libthreads "haixun-backend/internal/library/threadsapi"
"haixun-backend/internal/library/validate" "haixun-backend/internal/library/validate"
"haixun-backend/internal/middleware" "haixun-backend/internal/middleware"
@ -133,6 +135,8 @@ type ServiceContext struct {
OwnPostFormula ownpostformuladomain.UseCase OwnPostFormula ownpostformuladomain.UseCase
ContentFormula contentformuladomain.UseCase ContentFormula contentformuladomain.UseCase
MentionInbox mentioninboxdomain.UseCase MentionInbox mentioninboxdomain.UseCase
AvatarStorage storage.Uploader
Mailer mailer.Mailer
// Middlewares mounted per route group via generate/api `middleware:` directive. // Middlewares mounted per route group via generate/api `middleware:` directive.
AuthJWT rest.Middleware AuthJWT rest.Middleware
@ -170,6 +174,11 @@ func NewServiceContext(c config.Config) *ServiceContext {
tokenRevokeStore = authrepo.NewRedisTokenRevokeStore(redisClient) tokenRevokeStore = authrepo.NewRedisTokenRevokeStore(redisClient)
} }
authTokenUseCase := authuc.NewTokenUseCase(c.Auth, tokenRevokeStore) authTokenUseCase := authuc.NewTokenUseCase(c.Auth, tokenRevokeStore)
avatarStorage, err := storage.NewS3Uploader(ctx, c.Storage.S3)
if err != nil {
panic(err)
}
smtpMailer := mailer.NewSMTPMailer(c.SMTP)
memberRepository := memberrepo.NewMongoRepository(mongoClient.Database()) memberRepository := memberrepo.NewMongoRepository(mongoClient.Database())
if err := memberRepository.EnsureIndexes(ctx); err != nil { if err := memberRepository.EnsureIndexes(ctx); err != nil {
@ -189,6 +198,11 @@ func NewServiceContext(c config.Config) *ServiceContext {
if err := permissionUseCase.EnsureDefaultPermissions(ctx); err != nil { if err := permissionUseCase.EnsureDefaultPermissions(ctx); err != nil {
panic(err) panic(err)
} }
if c.Permission.SeedAdminRegister {
if err := permissionUseCase.EnsureAdminRegisterPermission(ctx); err != nil {
panic(err)
}
}
if err := permissionUseCase.EnsureDefaultRolePermissions(ctx, "default"); err != nil { if err := permissionUseCase.EnsureDefaultRolePermissions(ctx, "default"); err != nil {
panic(err) panic(err)
} }
@ -436,6 +450,8 @@ func NewServiceContext(c config.Config) *ServiceContext {
OwnPostFormula: ownPostFormulaUseCase, OwnPostFormula: ownPostFormulaUseCase,
ContentFormula: contentFormulaUseCase, ContentFormula: contentFormulaUseCase,
MentionInbox: mentionInboxUseCase, MentionInbox: mentionInboxUseCase,
AvatarStorage: avatarStorage,
Mailer: smtpMailer,
} }
hostname, _ := os.Hostname() hostname, _ := os.Hostname()

View File

@ -1222,6 +1222,16 @@ type ListKnowledgeSourcesData struct {
List []KnowledgeSourceData `json:"list"` List []KnowledgeSourceData `json:"list"`
} }
type ListMembersData struct {
Pagination PaginationData `json:"pagination"`
List []MemberMeData `json:"list"`
}
type ListMembersReq struct {
Page int64 `form:"page,optional"`
PageSize int64 `form:"pageSize,optional"`
}
type ListMentionInboxData struct { type ListMentionInboxData struct {
List []MentionInboxItemData `json:"list"` List []MentionInboxItemData `json:"list"`
Pagination PaginationData `json:"pagination"` Pagination PaginationData `json:"pagination"`
@ -1364,6 +1374,28 @@ type MePermissionsQuery struct {
IncludeTree bool `form:"include_tree,optional"` IncludeTree bool `form:"include_tree,optional"`
} }
type MemberAiProviderModelsData struct {
ID string `json:"id"`
Label string `json:"label"`
Models []string `json:"models"`
Streams bool `json:"streams"`
Error string `json:"error,omitempty"`
}
type MemberAiProviderModelsReq struct {
Provider string `path:"provider" validate:"required,oneof=opencode-go xai"`
ApiKey string `json:"api_key,optional"`
}
type MemberAiSettingsData struct {
Provider string `json:"provider"`
Model string `json:"model"`
ResearchProvider string `json:"research_provider,omitempty"`
ResearchModel string `json:"research_model,omitempty"`
ApiKeys map[string]string `json:"api_keys,omitempty"`
ApiKeysConfigured map[string]interface{} `json:"api_keys_configured"`
}
type MemberCapabilitiesData struct { type MemberCapabilitiesData struct {
DiscoverReady bool `json:"discover_ready"` DiscoverReady bool `json:"discover_ready"`
AiReady bool `json:"ai_ready"` AiReady bool `json:"ai_ready"`
@ -1378,6 +1410,7 @@ type MemberMeData struct {
TenantID string `json:"tenant_id"` TenantID string `json:"tenant_id"`
UID string `json:"uid"` UID string `json:"uid"`
Email string `json:"email"` Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
DisplayName string `json:"display_name,omitempty"` DisplayName string `json:"display_name,omitempty"`
Avatar string `json:"avatar,omitempty"` Avatar string `json:"avatar,omitempty"`
Phone string `json:"phone,omitempty"` Phone string `json:"phone,omitempty"`
@ -1394,6 +1427,10 @@ type MemberMeData struct {
UpdateAt int64 `json:"update_at"` UpdateAt int64 `json:"update_at"`
} }
type MemberPath struct {
UID string `path:"uid" validate:"required"`
}
type MemberPlacementSettingsData struct { type MemberPlacementSettingsData struct {
WebSearchProvider string `json:"web_search_provider"` // brave | exa WebSearchProvider string `json:"web_search_provider"` // brave | exa
BraveAPIKey string `json:"brave_api_key,omitempty"` BraveAPIKey string `json:"brave_api_key,omitempty"`
@ -2582,6 +2619,14 @@ type UpdateJobScheduleReq struct {
Enabled *bool `json:"enabled,optional"` // enabled flag Enabled *bool `json:"enabled,optional"` // enabled flag
} }
type UpdateMemberAiSettingsReq struct {
Provider *string `json:"provider,optional"`
Model *string `json:"model,optional"`
ResearchProvider *string `json:"research_provider,optional"`
ResearchModel *string `json:"research_model,optional"`
ApiKeys map[string]string `json:"api_keys,optional"`
}
type UpdateMemberMeReq struct { type UpdateMemberMeReq struct {
DisplayName string `json:"display_name,optional"` DisplayName string `json:"display_name,optional"`
Avatar string `json:"avatar,optional"` Avatar string `json:"avatar,optional"`
@ -2590,6 +2635,11 @@ type UpdateMemberMeReq struct {
Phone string `json:"phone,optional"` Phone string `json:"phone,optional"`
} }
type UpdateMemberPasswordReq struct {
UID string `path:"uid" validate:"required"`
Password string `json:"password" validate:"required,min=8,max=128"`
}
type UpdateMemberPlacementSettingsReq struct { type UpdateMemberPlacementSettingsReq struct {
WebSearchProvider *string `json:"web_search_provider,optional"` WebSearchProvider *string `json:"web_search_provider,optional"`
BraveAPIKey *string `json:"brave_api_key,optional"` BraveAPIKey *string `json:"brave_api_key,optional"`
@ -2601,6 +2651,26 @@ type UpdateMemberPlacementSettingsReq struct {
DevModeEnabled *bool `json:"dev_mode_enabled,optional"` DevModeEnabled *bool `json:"dev_mode_enabled,optional"`
} }
type UpdateMemberProfileReq struct {
UID string `path:"uid" validate:"required"`
DisplayName *string `json:"display_name,optional"`
Avatar *string `json:"avatar,optional"`
Language *string `json:"language,optional"`
Currency *string `json:"currency,optional"`
Phone *string `json:"phone,optional"`
Status *string `json:"status,optional"`
BusinessEmail *string `json:"business_email,optional"`
BusinessEmailVerified *bool `json:"business_email_verified,optional"`
BusinessPhone *string `json:"business_phone,optional"`
BusinessPhoneVerified *bool `json:"business_phone_verified,optional"`
EmailVerified *bool `json:"email_verified,optional"`
}
type UpdateMemberRolesReq struct {
UID string `path:"uid" validate:"required"`
Roles []string `json:"roles" validate:"required"`
}
type UpdateOutreachDraftHandlerReq struct { type UpdateOutreachDraftHandlerReq struct {
BrandPath BrandPath
DraftID string `path:"draftId" validate:"required"` DraftID string `path:"draftId" validate:"required"`
@ -2689,6 +2759,10 @@ type UpdateThreadsAccountReq struct {
PersonaID *string `json:"persona_id,optional"` // deprecated: use persona_id in publish payload instead PersonaID *string `json:"persona_id,optional"` // deprecated: use persona_id in publish payload instead
} }
type UploadAvatarData struct {
Avatar string `json:"avatar"`
}
type UpsertBrandScanScheduleHandlerReq struct { type UpsertBrandScanScheduleHandlerReq struct {
BrandPath BrandPath
UpsertBrandScanScheduleReq UpsertBrandScanScheduleReq

View File

@ -12,12 +12,13 @@ import { MissionsPage } from "./pages/MissionsPage";
import { PatrolPage } from "./pages/PatrolPage"; import { PatrolPage } from "./pages/PatrolPage";
import { PersonasPage } from "./pages/PersonasPage"; import { PersonasPage } from "./pages/PersonasPage";
import { PublishPage } from "./pages/PublishPage"; import { PublishPage } from "./pages/PublishPage";
import { RegisterMemberPage } from "./pages/RegisterMemberPage";
import { SettingsPage } from "./pages/SettingsPage"; import { SettingsPage } from "./pages/SettingsPage";
import { BrandsPage } from "./pages/BrandsPage"; import { BrandsPage } from "./pages/BrandsPage";
import { clearOAuthPending, parseOAuthReturn, stashOAuthReturnMessage, stripOAuthReturnFromUrl } from "./lib/threadsOAuth"; import { clearOAuthPending, parseOAuthReturn, stashOAuthReturnMessage, stripOAuthReturnFromUrl } from "./lib/threadsOAuth";
import { persistActiveThreadsAccountId } from "./lib/activeAccount"; import { persistActiveThreadsAccountId } from "./lib/activeAccount";
const validPages = new Set(["dashboard", "accounts", "brands", "publish", "personas", "missions", "patrol", "jobs", "settings", "gaps"]); const validPages = new Set(["dashboard", "accounts", "brands", "publish", "personas", "missions", "patrol", "jobs", "settings", "gaps", "register"]);
const accountRequiredPages = new Set(["publish", "personas", "missions", "patrol"]); const accountRequiredPages = new Set(["publish", "personas", "missions", "patrol"]);
export function App() { export function App() {
@ -87,9 +88,11 @@ export function App() {
return <AuthPage />; return <AuthPage />;
} }
const effectivePage = page === "register" && !member.roles?.includes("admin") ? "dashboard" : page;
return ( return (
<Layout active={page} onNavigate={navigate}> <Layout active={effectivePage} onNavigate={navigate}>
<div key={`${page}-${accountVersion}`}>{renderPage(page, navigate, accountCount)}</div> <div key={`${effectivePage}-${accountVersion}`}>{renderPage(effectivePage, navigate, accountCount)}</div>
</Layout> </Layout>
); );
} }
@ -130,6 +133,8 @@ function renderPage(page: string, navigate: (key: string) => void, accountCount:
return <SettingsPage />; return <SettingsPage />;
case "gaps": case "gaps":
return <GapsPage />; return <GapsPage />;
case "register":
return <RegisterMemberPage />;
default: default:
return <DashboardPage navigate={navigate} />; return <DashboardPage navigate={navigate} />;
} }

View File

@ -139,7 +139,8 @@ function buildRequestInit(options: RequestOptions): RequestInit {
Accept: "application/json", Accept: "application/json",
...options.headers ...options.headers
}; };
if (options.body !== undefined) { const isFormData = typeof FormData !== "undefined" && options.body instanceof FormData;
if (options.body !== undefined && !isFormData) {
headers["Content-Type"] = "application/json"; headers["Content-Type"] = "application/json";
} }
if (options.auth) { if (options.auth) {
@ -160,7 +161,7 @@ function buildRequestInit(options: RequestOptions): RequestInit {
return { return {
method: options.method || (options.body === undefined ? "GET" : "POST"), method: options.method || (options.body === undefined ? "GET" : "POST"),
headers, headers,
body: options.body === undefined ? undefined : JSON.stringify(options.body) body: options.body === undefined ? undefined : isFormData ? (options.body as BodyInit) : JSON.stringify(options.body)
}; };
} }

View File

@ -17,6 +17,32 @@ export type CapabilityData = {
active_threads_account_id?: string; active_threads_account_id?: string;
}; };
export type MemberData = {
tenant_id: string;
uid: string;
email: string;
email_verified: boolean;
display_name?: string;
avatar?: string;
phone?: string;
language?: string;
currency?: string;
status: string;
origin: string;
roles?: string[];
business_email?: string;
business_email_verified: boolean;
business_phone?: string;
business_phone_verified: boolean;
create_at: number;
update_at: number;
};
export type MemberListData = {
pagination: Pagination;
list: MemberData[];
};
export type ThreadsAccount = { export type ThreadsAccount = {
id: string; id: string;
display_name?: string; display_name?: string;
@ -49,8 +75,8 @@ export type ThreadsConnection = {
prefs: Record<string, unknown>; prefs: Record<string, unknown>;
}; };
export type ThreadsAiSettings = { export type AiSettings = {
account_id: string; account_id?: string;
provider: string; provider: string;
model: string; model: string;
research_provider?: string; research_provider?: string;
@ -812,10 +838,32 @@ export type SettingValue = {
export const api = { export const api = {
capabilities: () => apiRequest<CapabilityData>("/api/v1/members/me/capabilities", { auth: true }), capabilities: () => apiRequest<CapabilityData>("/api/v1/members/me/capabilities", { auth: true }),
memberMe: () => apiRequest<unknown>("/api/v1/members/me", { auth: true }), memberMe: () => apiRequest<MemberData>("/api/v1/members/me", { auth: true }),
updateMemberMe: (body: Record<string, unknown>) => apiRequest<MemberData>("/api/v1/members/me", { method: "PATCH", body, auth: true }),
uploadMemberAvatar: (file: File) => {
const form = new FormData();
form.append("avatar", file);
return apiRequest<{ avatar: string }>("/api/v1/members/me/avatar", { method: "POST", body: form, auth: true });
},
members: (page = 1, pageSize = 100) => apiRequest<MemberListData>(`/api/v1/members?page=${page}&pageSize=${pageSize}`, { auth: true }),
updateMemberProfile: (uid: string, body: Record<string, unknown>) =>
apiRequest<MemberData>(`/api/v1/members/${encodeURIComponent(uid)}/profile`, { method: "PATCH", body, auth: true }),
updateMemberRoles: (uid: string, roles: string[]) =>
apiRequest<MemberData>(`/api/v1/members/${encodeURIComponent(uid)}/roles`, { method: "PATCH", body: { roles }, auth: true }),
updateMemberPassword: (uid: string, password: string) =>
apiRequest<MemberData>(`/api/v1/members/${encodeURIComponent(uid)}/password`, { method: "PATCH", body: { password }, auth: true }),
placementSettings: () => apiRequest<unknown>("/api/v1/members/me/placement-settings", { auth: true }), placementSettings: () => apiRequest<unknown>("/api/v1/members/me/placement-settings", { auth: true }),
updatePlacementSettings: (body: Record<string, unknown>) => updatePlacementSettings: (body: Record<string, unknown>) =>
apiRequest<unknown>("/api/v1/members/me/placement-settings", { method: "PATCH", body, auth: true }), apiRequest<unknown>("/api/v1/members/me/placement-settings", { method: "PATCH", body, auth: true }),
memberAiSettings: () => apiRequest<AiSettings>("/api/v1/members/me/ai-settings", { auth: true }),
updateMemberAiSettings: (body: Record<string, unknown>) =>
apiRequest<AiSettings>("/api/v1/members/me/ai-settings", { method: "PUT", body, auth: true }),
memberAiProviderModels: (provider: string, apiKey?: string) =>
apiRequest<ThreadsAccountAiProviderModels>(`/api/v1/members/me/ai-settings/providers/${provider}/models`, {
method: "POST",
body: apiKey ? { api_key: apiKey } : {},
auth: true
}),
threadsAccounts: () => apiRequest<ThreadsAccountList>("/api/v1/threads-accounts/", { auth: true }), threadsAccounts: () => apiRequest<ThreadsAccountList>("/api/v1/threads-accounts/", { auth: true }),
createThreadsAccount: (body: { display_name?: string; activate?: boolean }) => createThreadsAccount: (body: { display_name?: string; activate?: boolean }) =>
@ -830,9 +878,9 @@ export const api = {
`/api/v1/threads-accounts/${id}/session/import`, `/api/v1/threads-accounts/${id}/session/import`,
{ method: "POST", body: { storageState }, auth: true } { method: "POST", body: { storageState }, auth: true }
), ),
threadsAiSettings: (id: string) => apiRequest<ThreadsAiSettings>(`/api/v1/threads-accounts/${id}/ai-settings`, { auth: true }), threadsAiSettings: (id: string) => apiRequest<AiSettings>(`/api/v1/threads-accounts/${id}/ai-settings`, { auth: true }),
updateThreadsAiSettings: (id: string, body: Record<string, unknown>) => updateThreadsAiSettings: (id: string, body: Record<string, unknown>) =>
apiRequest<ThreadsAiSettings>(`/api/v1/threads-accounts/${id}/ai-settings`, { method: "PUT", body, auth: true }), apiRequest<AiSettings>(`/api/v1/threads-accounts/${id}/ai-settings`, { method: "PUT", body, auth: true }),
threadsAccountProviderModels: (id: string, provider: string, apiKey?: string) => threadsAccountProviderModels: (id: string, provider: string, apiKey?: string) =>
apiRequest<ThreadsAccountAiProviderModels>(`/api/v1/threads-accounts/${id}/ai-settings/providers/${provider}/models`, { apiRequest<ThreadsAccountAiProviderModels>(`/api/v1/threads-accounts/${id}/ai-settings/providers/${provider}/models`, {
method: "POST", method: "POST",

View File

@ -14,7 +14,6 @@ type AuthContextValue = {
member: MemberMe | null; member: MemberMe | null;
loading: boolean; loading: boolean;
login: (tenantId: string, email: string, password: string) => Promise<void>; login: (tenantId: string, email: string, password: string) => Promise<void>;
register: (tenantId: string, email: string, password: string, displayName?: string) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
reloadMember: () => Promise<void>; reloadMember: () => Promise<void>;
}; };
@ -69,18 +68,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
[reloadMember] [reloadMember]
); );
const register = useCallback(
async (tenantId: string, email: string, password: string, displayName?: string) => {
const data = await apiRequest<TokenPair>("/api/v1/auth/register", {
method: "POST",
body: { tenant_id: tenantId, email, password, display_name: displayName }
});
setTokens(data.access_token, data.refresh_token);
await reloadMember();
},
[reloadMember]
);
const logout = useCallback(async () => { const logout = useCallback(async () => {
try { try {
await apiRequest<null>("/api/v1/auth/logout", { method: "POST", auth: true }); await apiRequest<null>("/api/v1/auth/logout", { method: "POST", auth: true });
@ -92,8 +79,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}, []); }, []);
const value = useMemo( const value = useMemo(
() => ({ member, loading, login, register, logout, reloadMember }), () => ({ member, loading, login, logout, reloadMember }),
[member, loading, login, register, logout, reloadMember] [member, loading, login, logout, reloadMember]
); );
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;

View File

@ -19,7 +19,9 @@ export function Layout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const { member, logout } = useAuth(); const { member, logout } = useAuth();
const activeApp = navApps.find((app) => app.key === active) || navApps[0]; const isAdmin = member?.roles?.includes("admin") || false;
const visibleNavApps = navApps.filter((app) => app.key !== "register" || isAdmin);
const activeApp = visibleNavApps.find((app) => app.key === active) || visibleNavApps[0];
const [accounts, setAccounts] = useState<ThreadsAccount[]>([]); const [accounts, setAccounts] = useState<ThreadsAccount[]>([]);
const [activeAccountId, setActiveAccountId] = useState(""); const [activeAccountId, setActiveAccountId] = useState("");
const [switching, setSwitching] = useState(false); const [switching, setSwitching] = useState(false);
@ -99,7 +101,7 @@ export function Layout({
<div className="ac-pocket-screen"> <div className="ac-pocket-screen">
<div className="display-en muted ac-sidebar-pad">PATROL PAD</div> <div className="display-en muted ac-sidebar-pad">PATROL PAD</div>
<nav className="ac-pocket-scroll"> <nav className="ac-pocket-scroll">
{navApps.map((app) => ( {visibleNavApps.map((app) => (
(() => { (() => {
const locked = accounts.length === 0 && requiresThreadsAccount(app.key); const locked = accounts.length === 0 && requiresThreadsAccount(app.key);
return ( return (

View File

@ -17,6 +17,7 @@ export const navApps: NavApp[] = [
{ key: "personas", label: "人設", sublabel: "VOICE", icon: "persona" }, { key: "personas", label: "人設", sublabel: "VOICE", icon: "persona" },
{ key: "brands", label: "品牌", sublabel: "PROFILE", icon: "brand" }, { key: "brands", label: "品牌", sublabel: "PROFILE", icon: "brand" },
{ key: "accounts", label: "帳號", sublabel: "THREADS OPS", icon: "account" }, { key: "accounts", label: "帳號", sublabel: "THREADS OPS", icon: "account" },
{ key: "register", label: "島民管理", sublabel: "ADMIN", icon: "account" },
{ key: "settings", label: "設定", sublabel: "CONFIG", icon: "settings" } { key: "settings", label: "設定", sublabel: "CONFIG", icon: "settings" }
]; ];

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api, CapabilityData, PublishGuardPolicy, ThreadsAccount, ThreadsAiSettings, ThreadsConnection, ThreadsDiagnostics } from "../api/haixun"; import { api, CapabilityData, PublishGuardPolicy, ThreadsAccount, ThreadsConnection, ThreadsDiagnostics } from "../api/haixun";
import { DevToolsPanel } from "../components/DevToolsPanel"; import { DevToolsPanel } from "../components/DevToolsPanel";
import { Badge, Button, Card, ErrorText, Field, Input, PageTitle, Select } from "../components/ui"; import { Badge, Button, Card, ErrorText, Field, Input, PageTitle, Select } from "../components/ui";
import { persistActiveThreadsAccountId } from "../lib/activeAccount"; import { persistActiveThreadsAccountId } from "../lib/activeAccount";
@ -15,20 +15,12 @@ export function AccountsPage() {
const [activeId, setActiveId] = useState(""); const [activeId, setActiveId] = useState("");
const [caps, setCaps] = useState<CapabilityData>(); const [caps, setCaps] = useState<CapabilityData>();
const [connection, setConnection] = useState<ThreadsConnection>(); const [connection, setConnection] = useState<ThreadsConnection>();
const [aiSettings, setAiSettings] = useState<ThreadsAiSettings>();
const [diagnostics, setDiagnostics] = useState<ThreadsDiagnostics>(); const [diagnostics, setDiagnostics] = useState<ThreadsDiagnostics>();
const [guard, setGuard] = useState<PublishGuardPolicy>(); const [guard, setGuard] = useState<PublishGuardPolicy>();
const [oauthEnabled, setOauthEnabled] = useState(false); const [oauthEnabled, setOauthEnabled] = useState(false);
const [oauthRequiresHttps, setOauthRequiresHttps] = useState(false); const [oauthRequiresHttps, setOauthRequiresHttps] = useState(false);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
const [apiKey, setApiKey] = useState("");
const [provider, setProvider] = useState("opencode-go");
const [model, setModel] = useState("");
const [researchProvider, setResearchProvider] = useState("opencode-go");
const [researchModel, setResearchModel] = useState("");
const [modelsByProvider, setModelsByProvider] = useState<Record<string, string[]>>({});
const [loadingModels, setLoadingModels] = useState("");
const [devModeEnabled, setDevModeEnabled] = useState(false); const [devModeEnabled, setDevModeEnabled] = useState(false);
const [error, setError] = useState<unknown>(); const [error, setError] = useState<unknown>();
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
@ -58,18 +50,12 @@ export function AccountsPage() {
setActiveId(id); setActiveId(id);
persistActiveThreadsAccountId(id); persistActiveThreadsAccountId(id);
if (id) { if (id) {
const [conn, ai, diag, guardData] = await Promise.all([api.getThreadsConnection(id), api.threadsAiSettings(id), api.threadsDiagnostics(id), api.publishGuardPolicy(id)]); const [conn, diag, guardData] = await Promise.all([api.getThreadsConnection(id), api.threadsDiagnostics(id), api.publishGuardPolicy(id)]);
setConnection(conn); setConnection(conn);
setAiSettings(ai);
setDiagnostics(diag); setDiagnostics(diag);
setGuard(guardData); setGuard(guardData);
setProvider(ai.provider || "opencode-go");
setModel(ai.model || "");
setResearchProvider(ai.research_provider || ai.provider || "opencode-go");
setResearchModel(ai.research_model || ai.model || "");
} else { } else {
setConnection(undefined); setConnection(undefined);
setAiSettings(undefined);
setDiagnostics(undefined); setDiagnostics(undefined);
setGuard(undefined); setGuard(undefined);
} }
@ -136,7 +122,6 @@ export function AccountsPage() {
setMessage(result.message || `已刪除帳號:${label}`); setMessage(result.message || `已刪除帳號:${label}`);
setActiveId(result.active_account_id || ""); setActiveId(result.active_account_id || "");
setConnection(undefined); setConnection(undefined);
setAiSettings(undefined);
setDiagnostics(undefined); setDiagnostics(undefined);
setGuard(undefined); setGuard(undefined);
window.dispatchEvent(new CustomEvent("haixun:active-account-changed")); window.dispatchEvent(new CustomEvent("haixun:active-account-changed"));
@ -160,38 +145,6 @@ export function AccountsPage() {
await load(); await load();
} }
async function saveAi() {
if (!activeId) return;
const api_keys = apiKey ? { [provider]: apiKey } : undefined;
await api.updateThreadsAiSettings(activeId, {
provider,
model,
research_provider: researchProvider,
research_model: researchModel,
api_keys
});
setApiKey("");
setMessage("AI 設定已儲存");
await load();
}
async function fetchModels(target: "copy" | "research") {
if (!activeId) return;
const targetProvider = target === "copy" ? provider : researchProvider;
setLoadingModels(target);
try {
const data = await api.threadsAccountProviderModels(activeId, targetProvider, apiKey);
setModelsByProvider((current) => ({ ...current, [targetProvider]: data.models || [] }));
if (data.models?.length) {
if (target === "copy" && !model) setModel(data.models[0]);
if (target === "research" && !researchModel) setResearchModel(data.models[0]);
}
setMessage(data.error ? `模型清單取得完成,但 provider 回報:${data.error}` : `已取得 ${data.label} 模型清單`);
} finally {
setLoadingModels("");
}
}
async function smokeTest() { async function smokeTest() {
if (!activeId) return; if (!activeId) return;
await api.smokeTest(activeId); await api.smokeTest(activeId);
@ -354,63 +307,6 @@ export function AccountsPage() {
<p className="muted"></p> <p className="muted"></p>
)} )}
</Card> </Card>
<Card title="AI 設定">
{aiSettings || activeId ? (
<div className="grid-2">
<Field label="文案 provider">
<Select value={provider} onChange={(e) => setProvider(e.target.value)}>
<option value="opencode-go">OpenCode Go</option>
<option value="xai">xAI</option>
</Select>
</Field>
<Field label="文案 model">
<div className="button-row">
{modelsByProvider[provider]?.length ? (
<Select value={model} onChange={(e) => setModel(e.target.value)}>
<option value=""></option>
{modelsByProvider[provider].map((item) => <option key={item} value={item}>{item}</option>)}
</Select>
) : (
<Input value={model} onChange={(e) => setModel(e.target.value)} placeholder="例如 deepseek-v4-pro" />
)}
<Button disabled={!activeId || loadingModels === "copy"} onClick={() => void fetchModels("copy")}>
{loadingModels === "copy" ? "讀取中" : "取得模型"}
</Button>
</div>
</Field>
<Field label="研究 provider">
<Select value={researchProvider} onChange={(e) => setResearchProvider(e.target.value)}>
<option value="opencode-go">OpenCode Go</option>
<option value="xai">xAI</option>
</Select>
</Field>
<Field label="研究 model">
<div className="button-row">
{modelsByProvider[researchProvider]?.length ? (
<Select value={researchModel} onChange={(e) => setResearchModel(e.target.value)}>
<option value=""></option>
{modelsByProvider[researchProvider].map((item) => <option key={item} value={item}>{item}</option>)}
</Select>
) : (
<Input value={researchModel} onChange={(e) => setResearchModel(e.target.value)} />
)}
<Button disabled={!activeId || loadingModels === "research"} onClick={() => void fetchModels("research")}>
{loadingModels === "research" ? "讀取中" : "取得模型"}
</Button>
</div>
</Field>
<Field label="API key">
<Input type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="只在儲存時送出,不顯示既有 key" />
</Field>
<div className="button-row" style={{ alignSelf: "end" }}>
<Button variant="primary" onClick={() => void saveAi()}> AI </Button>
</div>
</div>
) : (
<p className="muted"></p>
)}
</Card>
</div> </div>
); );
} }

View File

@ -6,12 +6,10 @@ import { Button, Field, Input } from "../components/ui";
import { safeGetItem, safeSetItem } from "../lib/safeStorage"; import { safeGetItem, safeSetItem } from "../lib/safeStorage";
export function AuthPage() { export function AuthPage() {
const { login, register } = useAuth(); const { login } = useAuth();
const [mode, setMode] = useState<"login" | "register">("login");
const [tenantId, setTenantId] = useState(() => safeGetItem("haixun.tenant_id") || "default"); const [tenantId, setTenantId] = useState(() => safeGetItem("haixun.tenant_id") || "default");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [displayName, setDisplayName] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
@ -22,13 +20,9 @@ export function AuthPage() {
try { try {
const normalizedTenantId = tenantId.trim() || "default"; const normalizedTenantId = tenantId.trim() || "default";
safeSetItem("haixun.tenant_id", normalizedTenantId); safeSetItem("haixun.tenant_id", normalizedTenantId);
if (mode === "login") { await login(normalizedTenantId, email, password);
await login(normalizedTenantId, email, password);
} else {
await register(normalizedTenantId, email, password, displayName);
}
} catch (err) { } catch (err) {
setError(toAuthErrorMessage(err, mode)); setError(toAuthErrorMessage(err));
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@ -38,13 +32,8 @@ export function AuthPage() {
<AuthShell> <AuthShell>
<form className="page-grid" onSubmit={submit}> <form className="page-grid" onSubmit={submit}>
<p className="muted" style={{ margin: 0 }}> <p className="muted" style={{ margin: 0 }}>
{mode === "login" ? "登入後就能管理 Threads 帳號、發文庫存與找 TA 任務。" : "建立島民帳號,開始設定你的巡樓工作台。"} Threads TA
</p> </p>
{mode === "register" ? (
<Field label="顯示名稱">
<Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="例如Daniel" />
</Field>
) : null}
<Field label="Email"> <Field label="Email">
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required placeholder="you@example.com" /> <Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required placeholder="you@example.com" />
</Field> </Field>
@ -54,10 +43,7 @@ export function AuthPage() {
{error ? <p className="error-text">{error}</p> : null} {error ? <p className="error-text">{error}</p> : null}
<div className="button-row"> <div className="button-row">
<Button type="submit" variant="primary" disabled={submitting}> <Button type="submit" variant="primary" disabled={submitting}>
{submitting ? "處理中" : mode === "login" ? "登入" : "註冊"} {submitting ? "處理中" : "登入"}
</Button>
<Button type="button" variant="ghost" onClick={() => setMode(mode === "login" ? "register" : "login")}>
{mode === "login" ? "我要註冊" : "已有帳號,去登入"}
</Button> </Button>
</div> </div>
</form> </form>
@ -65,7 +51,7 @@ export function AuthPage() {
); );
} }
function toAuthErrorMessage(error: unknown, mode: "login" | "register") { function toAuthErrorMessage(error: unknown) {
if (error instanceof TypeError) { if (error instanceof TypeError) {
return "連不上伺服器,請確認後端服務是否已啟動。"; return "連不上伺服器,請確認後端服務是否已啟動。";
} }
@ -93,5 +79,5 @@ function toAuthErrorMessage(error: unknown, mode: "login" | "register") {
return "伺服器暫時無法處理,請稍後再試。"; return "伺服器暫時無法處理,請稍後再試。";
} }
} }
return mode === "login" ? "登入失敗,請檢查資料後再試一次。" : "註冊失敗,請檢查資料後再試一次。"; return "登入失敗,請檢查資料後再試一次。";
} }

View File

@ -0,0 +1,328 @@
import { FormEvent, useEffect, useState } from "react";
import { api, MemberData } from "../api/haixun";
import { ApiError, apiRequest } from "../api/client";
import { useAuth } from "../auth/AuthContext";
import { Badge, Button, Card, CopyableId, Field, Input, PageTitle, Select } from "../components/ui";
type AuthTokenData = {
uid: string;
};
type MemberProfileDraft = {
display_name: string;
phone: string;
language: string;
currency: string;
status: string;
email_verified: boolean;
business_email: string;
business_email_verified: boolean;
business_phone: string;
business_phone_verified: boolean;
};
export function RegisterMemberPage() {
const { member } = useAuth();
const [tenantId, setTenantId] = useState(member?.tenant_id || "default");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [displayName, setDisplayName] = useState("");
const [createdUID, setCreatedUID] = useState("");
const [items, setItems] = useState<MemberData[]>([]);
const [loadingList, setLoadingList] = useState(false);
const [listError, setListError] = useState("");
const [passwordDrafts, setPasswordDrafts] = useState<Record<string, string>>({});
const [editingUID, setEditingUID] = useState("");
const [profileDraft, setProfileDraft] = useState<MemberProfileDraft | null>(null);
const [savingUID, setSavingUID] = useState("");
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
const isAdmin = member?.roles?.includes("admin") || false;
useEffect(() => {
if (!isAdmin) return;
void loadMembers();
}, [isAdmin]);
useEffect(() => {
if (member?.tenant_id) setTenantId(member.tenant_id);
}, [member?.tenant_id]);
async function loadMembers() {
setLoadingList(true);
setListError("");
try {
const data = await api.members(1, 100);
setItems(data.list || []);
} catch (err) {
setListError(toAdminActionErrorMessage(err));
} finally {
setLoadingList(false);
}
}
async function submit(event: FormEvent) {
event.preventDefault();
if (!isAdmin) return;
setError("");
setCreatedUID("");
setSubmitting(true);
try {
const data = await apiRequest<AuthTokenData>("/api/v1/auth/register", {
method: "POST",
auth: true,
body: {
tenant_id: tenantId.trim() || member?.tenant_id || "default",
email,
password,
display_name: displayName
}
});
setCreatedUID(data.uid);
setEmail("");
setPassword("");
setDisplayName("");
await loadMembers();
} catch (err) {
setError(toRegisterErrorMessage(err));
} finally {
setSubmitting(false);
}
}
async function updateRole(item: MemberData, role: "admin" | "user") {
setSavingUID(item.uid);
setListError("");
try {
const updated = await api.updateMemberRoles(item.uid, [role]);
setItems((current) => current.map((entry) => (entry.uid === item.uid ? updated : entry)));
} catch (err) {
setListError(toAdminActionErrorMessage(err));
} finally {
setSavingUID("");
}
}
async function updatePassword(item: MemberData) {
const nextPassword = passwordDrafts[item.uid] || "";
if (nextPassword.length < 8) {
setListError("新密碼至少需要 8 個字元。");
return;
}
setSavingUID(item.uid);
setListError("");
try {
const updated = await api.updateMemberPassword(item.uid, nextPassword);
setItems((current) => current.map((entry) => (entry.uid === item.uid ? updated : entry)));
setPasswordDrafts((current) => ({ ...current, [item.uid]: "" }));
} catch (err) {
setListError(toAdminActionErrorMessage(err));
} finally {
setSavingUID("");
}
}
function editMember(item: MemberData) {
setEditingUID(item.uid);
setProfileDraft({
display_name: item.display_name || "",
phone: item.phone || "",
language: item.language || "",
currency: item.currency || "",
status: item.status || "open",
email_verified: Boolean(item.email_verified),
business_email: item.business_email || "",
business_email_verified: Boolean(item.business_email_verified),
business_phone: item.business_phone || "",
business_phone_verified: Boolean(item.business_phone_verified)
});
}
async function updateProfile() {
if (!editingUID || !profileDraft) return;
setSavingUID(editingUID);
setListError("");
try {
const updated = await api.updateMemberProfile(editingUID, profileDraft);
setItems((current) => current.map((entry) => (entry.uid === editingUID ? updated : entry)));
editMember(updated);
} catch (err) {
setListError(toAdminActionErrorMessage(err));
} finally {
setSavingUID("");
}
}
if (!isAdmin) {
return (
<div className="page-grid">
<PageTitle title="島民管理" subtitle="封測階段只開放 admin 管理帳號。" />
<Card title="權限不足">
<p className="muted"> admin</p>
</Card>
</div>
);
}
return (
<div className="page-grid">
<PageTitle title="島民管理" subtitle="封測階段不開放公開註冊,由 admin 建立帳號、重設密碼與調整角色。" />
<Card title="新增登入帳號">
<form className="page-grid" onSubmit={submit}>
<Field label="Tenant ID">
<Input value={tenantId} onChange={(e) => setTenantId(e.target.value)} required />
</Field>
<Field label="顯示名稱">
<Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="例如Daniel" />
</Field>
<Field label="Email">
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required placeholder="you@example.com" />
</Field>
<Field label="密碼" hint="至少 8 個字元。">
<Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={8} />
</Field>
{error ? <p className="error-text">{error}</p> : null}
{createdUID ? <p className="badge badge-success">{createdUID}</p> : null}
<div className="button-row">
<Button type="submit" variant="primary" disabled={submitting}>{submitting ? "建立中" : "建立島民帳號"}</Button>
</div>
</form>
</Card>
<Card title="所有島民帳號" action={<Button variant="secondary" onClick={() => void loadMembers()} disabled={loadingList}>{loadingList ? "載入中" : "重新載入"}</Button>}>
{listError ? <p className="error-text">{listError}</p> : null}
<div className="table-wrap">
<table>
<thead>
<tr>
<th></th>
<th>UID</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{items.map((item) => {
const role = item.roles?.includes("admin") ? "admin" : "user";
const isSelf = item.uid === member?.uid;
return (
<tr key={item.uid}>
<td>
<strong>{item.display_name || item.email}</strong>
<div className="subtle hx-text-sm">{item.email}</div>
</td>
<td><CopyableId value={item.uid} /></td>
<td>
<Select value={role} disabled={savingUID === item.uid || isSelf} onChange={(event) => void updateRole(item, event.target.value as "admin" | "user")}>
<option value="user">user</option>
<option value="admin">admin</option>
</Select>
{isSelf ? <div className="subtle hx-text-sm"></div> : null}
</td>
<td><Badge variant={item.status === "open" ? "success" : "warning"}>{item.status}</Badge></td>
<td className="subtle hx-text-sm">{formatUnixNano(item.create_at)}</td>
<td><Button variant="soft" onClick={() => editMember(item)}></Button></td>
<td>
<div className="button-row" style={{ gap: "0.5rem" }}>
<Input
type="password"
value={passwordDrafts[item.uid] || ""}
minLength={8}
placeholder="新密碼"
onChange={(event) => setPasswordDrafts((current) => ({ ...current, [item.uid]: event.target.value }))}
style={{ width: "9rem" }}
/>
<Button variant="soft" disabled={savingUID === item.uid} onClick={() => void updatePassword(item)}></Button>
</div>
</td>
</tr>
);
})}
{!loadingList && items.length === 0 ? (
<tr><td colSpan={7} className="muted"></td></tr>
) : null}
</tbody>
</table>
</div>
</Card>
{profileDraft ? (
<Card title="編輯島民資訊">
<div className="page-grid">
<div className="button-row">
<CopyableId value={editingUID} />
<Button variant="ghost" onClick={() => { setEditingUID(""); setProfileDraft(null); }}></Button>
</div>
<div className="grid-2">
<Field label="顯示名稱">
<Input value={profileDraft.display_name} onChange={(e) => setProfileDraft({ ...profileDraft, display_name: e.target.value })} />
</Field>
<Field label="狀態">
<Select value={profileDraft.status} onChange={(e) => setProfileDraft({ ...profileDraft, status: e.target.value })}>
<option value="open">open</option>
<option value="suspended">suspended</option>
<option value="deleted">deleted</option>
</Select>
</Field>
<Field label="電話">
<Input value={profileDraft.phone} onChange={(e) => setProfileDraft({ ...profileDraft, phone: e.target.value })} />
</Field>
<Field label="語言">
<Input value={profileDraft.language} onChange={(e) => setProfileDraft({ ...profileDraft, language: e.target.value })} placeholder="zh-TW" />
</Field>
<Field label="幣別">
<Input value={profileDraft.currency} onChange={(e) => setProfileDraft({ ...profileDraft, currency: e.target.value })} placeholder="TWD" />
</Field>
<Field label="商務 Email">
<Input type="email" value={profileDraft.business_email} onChange={(e) => setProfileDraft({ ...profileDraft, business_email: e.target.value })} />
</Field>
<Field label="商務電話">
<Input value={profileDraft.business_phone} onChange={(e) => setProfileDraft({ ...profileDraft, business_phone: e.target.value })} />
</Field>
<Field label="驗證狀態">
<div className="page-grid">
<label className="button-row"><input type="checkbox" checked={profileDraft.business_email_verified} onChange={(e) => setProfileDraft({ ...profileDraft, business_email_verified: e.target.checked })} /> Email </label>
<label className="button-row"><input type="checkbox" checked={profileDraft.business_phone_verified} onChange={(e) => setProfileDraft({ ...profileDraft, business_phone_verified: e.target.checked })} /> </label>
<label className="button-row"><input type="checkbox" checked={profileDraft.email_verified} onChange={(e) => setProfileDraft({ ...profileDraft, email_verified: e.target.checked })} /> Email </label>
</div>
</Field>
</div>
<Button variant="primary" disabled={savingUID === editingUID} onClick={() => void updateProfile()}></Button>
</div>
</Card>
) : null}
</div>
);
}
function toRegisterErrorMessage(error: unknown) {
if (error instanceof TypeError) return "連不上伺服器,請確認後端服務是否已啟動。";
if (error instanceof ApiError) {
const message = error.message.toLowerCase();
if (error.status === 403 || message.includes("admin role required")) return "只有 admin 可以建立帳號。";
if (message.includes("already") || message.includes("duplicate")) return "這個 Email 已經註冊過。";
if (message.includes("password")) return "密碼至少需要 8 個字元。";
if (message.includes("email")) return "請輸入有效的 Email。";
}
return "建立帳號失敗,請檢查資料後再試一次。";
}
function toAdminActionErrorMessage(error: unknown) {
if (error instanceof TypeError) return "連不上伺服器,請確認後端服務是否已啟動。";
if (error instanceof ApiError) {
const message = error.message.toLowerCase();
if (message.includes("permission denied")) return "你的帳號是 admin但後端權限目錄尚未同步請重啟後端讓權限 catalog 補齊。";
if (error.status === 403 || message.includes("admin")) return "只有 admin 可以管理島民帳號。";
if (message.includes("password")) return "密碼至少需要 8 個字元。";
if (message.includes("roles")) return "角色只支援 admin 或 user。";
if (message.includes("status")) return "狀態只支援 open、suspended 或 deleted。";
}
return "操作失敗,請稍後再試。";
}
function formatUnixNano(value: number) {
if (!value) return "-";
return new Date(Math.floor(value / 1_000_000)).toLocaleString();
}

View File

@ -1,7 +1,8 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api } from "../api/haixun"; import { api, AiSettings, MemberData } from "../api/haixun";
import { useAuth } from "../auth/AuthContext";
import { ExtensionInstallCard } from "../components/ExtensionInstallCard"; import { ExtensionInstallCard } from "../components/ExtensionInstallCard";
import { Button, Card, ErrorText, Field, Input, PageTitle, Select } from "../components/ui"; import { Badge, Button, Card, ErrorText, Field, Input, PageTitle, Select } from "../components/ui";
type PlacementSettings = { type PlacementSettings = {
web_search_provider?: string; web_search_provider?: string;
@ -14,37 +15,32 @@ type PlacementSettings = {
}; };
function broadcastPlacementSettings(settings: PlacementSettings) { function broadcastPlacementSettings(settings: PlacementSettings) {
window.dispatchEvent( window.dispatchEvent(new CustomEvent("haixun:placement-settings-changed", { detail: settings }));
new CustomEvent("haixun:placement-settings-changed", {
detail: settings,
})
);
} }
function buildPlacementPatch(placement: PlacementSettings): Record<string, unknown> { function buildPlacementPatch(placement: PlacementSettings): Record<string, unknown> {
const payload: Record<string, unknown> = {}; const payload: Record<string, unknown> = {};
if (placement.web_search_provider) { if (placement.web_search_provider) payload.web_search_provider = placement.web_search_provider;
payload.web_search_provider = placement.web_search_provider; if (placement.expand_strategy) payload.expand_strategy = placement.expand_strategy;
} if (placement.brave_api_key) payload.brave_api_key = placement.brave_api_key;
if (placement.expand_strategy) { if (placement.exa_api_key) payload.exa_api_key = placement.exa_api_key;
payload.expand_strategy = placement.expand_strategy; if (placement.dev_mode_enabled !== undefined) payload.dev_mode_enabled = placement.dev_mode_enabled;
}
if (placement.brave_api_key) {
payload.brave_api_key = placement.brave_api_key;
}
if (placement.exa_api_key) {
payload.exa_api_key = placement.exa_api_key;
}
if (placement.dev_mode_enabled !== undefined) {
payload.dev_mode_enabled = placement.dev_mode_enabled;
}
return payload; return payload;
} }
export function SettingsPage() { export function SettingsPage() {
const [member, setMember] = useState<unknown>(); const { reloadMember } = useAuth();
const [member, setMember] = useState<MemberData>();
const [profile, setProfile] = useState({ display_name: "", phone: "", language: "", currency: "", avatar: "" });
const [placement, setPlacement] = useState<PlacementSettings>({}); const [placement, setPlacement] = useState<PlacementSettings>({});
const [permissions, setPermissions] = useState<unknown>(); const [aiSettings, setAiSettings] = useState<AiSettings>();
const [apiKey, setApiKey] = useState("");
const [provider, setProvider] = useState("opencode-go");
const [model, setModel] = useState("");
const [researchProvider, setResearchProvider] = useState("opencode-go");
const [researchModel, setResearchModel] = useState("");
const [modelsByProvider, setModelsByProvider] = useState<Record<string, string[]>>({});
const [loadingModels, setLoadingModels] = useState("");
const [error, setError] = useState<unknown>(); const [error, setError] = useState<unknown>();
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [savingDevMode, setSavingDevMode] = useState(false); const [savingDevMode, setSavingDevMode] = useState(false);
@ -52,22 +48,54 @@ export function SettingsPage() {
async function load() { async function load() {
setError(undefined); setError(undefined);
try { try {
const [memberData, placementData, permissionData] = await Promise.all([api.memberMe(), api.placementSettings(), api.permissions()]); const [memberData, placementData, aiData] = await Promise.all([api.memberMe(), api.placementSettings(), api.memberAiSettings()]);
setMember(memberData); setMember(memberData);
setProfile({
display_name: memberData.display_name || "",
phone: memberData.phone || "",
language: memberData.language || "",
currency: memberData.currency || "",
avatar: memberData.avatar || ""
});
setPlacement((placementData || {}) as PlacementSettings); setPlacement((placementData || {}) as PlacementSettings);
setPermissions(permissionData); applyAiSettings(aiData);
} catch (err) { } catch (err) {
setError(err); setError(err);
} }
} }
function applyAiSettings(data: AiSettings) {
setAiSettings(data);
setProvider(data.provider || "opencode-go");
setModel(data.model || "");
setResearchProvider(data.research_provider || data.provider || "opencode-go");
setResearchModel(data.research_model || data.model || "");
}
useEffect(() => { useEffect(() => {
void load(); void load();
}, []); }, []);
async function saveProfile() {
const updated = await api.updateMemberMe(profile);
setMember(updated);
setMessage("基本資訊已儲存");
await reloadMember();
}
async function uploadAvatar(file?: File) {
if (!file) return;
setError(undefined);
const result = await api.uploadMemberAvatar(file);
setProfile((current) => ({ ...current, avatar: result.avatar }));
setMember((current) => current ? { ...current, avatar: result.avatar } : current);
setMessage("頭像已上傳");
await reloadMember();
}
async function savePlacement() { async function savePlacement() {
const updated = (await api.updatePlacementSettings(buildPlacementPatch(placement))) as PlacementSettings; const updated = (await api.updatePlacementSettings(buildPlacementPatch(placement))) as PlacementSettings;
setPlacement((current) => ({ ...current, ...updated })); setPlacement((current) => ({ ...current, ...updated, brave_api_key: "", exa_api_key: "" }));
setMessage("搜尋設定已儲存"); setMessage("搜尋設定已儲存");
broadcastPlacementSettings(updated); broadcastPlacementSettings(updated);
} }
@ -90,15 +118,80 @@ export function SettingsPage() {
} }
} }
async function saveAi() {
const api_keys = apiKey ? { [provider]: apiKey } : undefined;
const updated = await api.updateMemberAiSettings({
provider,
model,
research_provider: researchProvider,
research_model: researchModel,
api_keys
});
setApiKey("");
setAiSettings(updated);
setMessage("AI 設定已儲存");
}
async function fetchModels(target: "copy" | "research") {
const targetProvider = target === "copy" ? provider : researchProvider;
setLoadingModels(target);
try {
const data = await api.memberAiProviderModels(targetProvider, apiKey);
setModelsByProvider((current) => ({ ...current, [targetProvider]: data.models || [] }));
if (data.models?.length) {
if (target === "copy" && !model) setModel(data.models[0]);
if (target === "research" && !researchModel) setResearchModel(data.models[0]);
}
setMessage(data.error ? `模型清單取得完成,但 provider 回報:${data.error}` : `已取得 ${data.label} 模型清單`);
} finally {
setLoadingModels("");
}
}
return ( return (
<div className="page-grid"> <div className="page-grid">
<PageTitle title="設定" subtitle="管理會員資訊、搜尋供應商、API key readiness 與權限檢視。" action={<Button onClick={() => void load()}></Button>} /> <PageTitle title="設定" subtitle="管理自己的基本資訊、AI 模型、搜尋策略與測試工具。" action={<Button onClick={() => void load()}></Button>} />
<ErrorText error={error} /> <ErrorText error={error} />
{message ? <Card><p>{message}</p></Card> : null} {message ? <Card><p style={{ margin: 0 }}>{message}</p></Card> : null}
<div className="grid-2"> <div className="grid-2">
<Card title="會員資料"> <Card title="基本資訊">
<pre style={{ whiteSpace: "pre-wrap" }}>{JSON.stringify(member, null, 2)}</pre> <div className="page-grid">
<div className="button-row">
<Badge variant={member?.status === "open" ? "success" : "warning"}>{member?.status || "unknown"}</Badge>
<Badge variant="neutral">{member?.email}</Badge>
<Badge variant={member?.email_verified ? "success" : "warning"}>{member?.email_verified ? "Email 已驗證" : "Email 未驗證"}</Badge>
<Badge variant="brand">{member?.roles?.join("、") || "user"}</Badge>
</div>
{profile.avatar ? (
<img src={profile.avatar} alt="目前頭像" style={{ width: "5rem", height: "5rem", borderRadius: "9999px", objectFit: "cover", border: "1px solid var(--hx-line)" }} />
) : null}
<Field label="顯示名稱">
<Input value={profile.display_name} onChange={(e) => setProfile({ ...profile, display_name: e.target.value })} />
</Field>
<Field label="電話">
<Input value={profile.phone} onChange={(e) => setProfile({ ...profile, phone: e.target.value })} />
</Field>
<div className="grid-2">
<Field label="語言">
<Input value={profile.language} onChange={(e) => setProfile({ ...profile, language: e.target.value })} placeholder="zh-TW" />
</Field>
<Field label="幣別">
<Input value={profile.currency} onChange={(e) => setProfile({ ...profile, currency: e.target.value })} placeholder="TWD" />
</Field>
</div>
<Field label="頭像 URL">
<Input value={profile.avatar} onChange={(e) => setProfile({ ...profile, avatar: e.target.value })} />
</Field>
<Field label="上傳頭像" hint="支援圖片檔,上傳後會寫入 MinIO/S3 並更新頭像 URL。">
<Input type="file" accept="image/*" onChange={(e) => void uploadAvatar(e.target.files?.[0])} />
</Field>
<div className="grid-2">
<InfoLine label="Tenant" value={member?.tenant_id} />
<InfoLine label="UID" value={member?.uid} />
</div>
<Button variant="primary" onClick={() => void saveProfile()}></Button>
</div>
</Card> </Card>
<Card title="搜尋與延伸策略"> <Card title="搜尋與延伸策略">
@ -117,20 +210,15 @@ export function SettingsPage() {
</Select> </Select>
</Field> </Field>
<Field label="Brave API Key"> <Field label="Brave API Key">
<Input type="password" placeholder={placement.brave_api_key_configured ? "已設定,輸入新值才會覆蓋" : "尚未設定"} onChange={(e) => setPlacement({ ...placement, brave_api_key: e.target.value })} /> <Input type="password" value={placement.brave_api_key || ""} placeholder={placement.brave_api_key_configured ? "已設定,輸入新值才會覆蓋" : "尚未設定"} onChange={(e) => setPlacement({ ...placement, brave_api_key: e.target.value })} />
</Field> </Field>
<Field label="Exa API Key"> <Field label="Exa API Key">
<Input type="password" placeholder={placement.exa_api_key_configured ? "已設定,輸入新值才會覆蓋" : "尚未設定"} onChange={(e) => setPlacement({ ...placement, exa_api_key: e.target.value })} /> <Input type="password" value={placement.exa_api_key || ""} placeholder={placement.exa_api_key_configured ? "已設定,輸入新值才會覆蓋" : "尚未設定"} onChange={(e) => setPlacement({ ...placement, exa_api_key: e.target.value })} />
</Field> </Field>
<Field label="開發模式(測試海巡)" hint="僅測試海巡走 Chrome 爬蟲;正式海巡、擴圖、發文與留言仍走 Threads API。開啟後才會顯示測試海巡按鈕與 Chrome Session 同步。"> <Field label="開發模式(測試海巡)" hint="僅測試海巡走 Chrome 爬蟲;正式海巡、擴圖、發文與留言仍走 Threads API。">
<label className="button-row" style={{ justifyContent: "space-between" }}> <label className="button-row" style={{ justifyContent: "space-between" }}>
<span>{savingDevMode ? "(儲存中…)" : ""}</span> <span>{savingDevMode ? "(儲存中…)" : ""}</span>
<input <input type="checkbox" checked={Boolean(placement.dev_mode_enabled)} disabled={savingDevMode} onChange={(e) => void toggleDevMode(e.target.checked)} />
type="checkbox"
checked={Boolean(placement.dev_mode_enabled)}
disabled={savingDevMode}
onChange={(e) => void toggleDevMode(e.target.checked)}
/>
</label> </label>
</Field> </Field>
<Button variant="primary" onClick={() => void savePlacement()}></Button> <Button variant="primary" onClick={() => void savePlacement()}></Button>
@ -138,18 +226,72 @@ export function SettingsPage() {
</Card> </Card>
</div> </div>
<Card title="Chrome 擴充套件"> <Card title="AI 設定">
<ExtensionInstallCard /> <div className="page-grid">
{!placement.dev_mode_enabled ? ( <Field label="套用範圍" hint="這裡設定的是你的整個大帳號。旗下所有 Threads 帳號、文案任務與研究任務共用這組 AI 設定。">
<p className="muted hx-text-sm" style={{ margin: "0.75rem 0 0" }}> <Input value={member?.email || member?.uid || "我的大帳號"} readOnly />
Threads Session使 </Field>
</p> {aiSettings ? <p className="muted hx-text-sm" style={{ margin: 0 }}>{aiSettings.provider || "未設定"} / {aiSettings.model || "未設定模型"}</p> : null}
) : null} <div className="grid-2">
<Field label="文案 provider">
<Select value={provider} onChange={(e) => setProvider(e.target.value)}>
<option value="opencode-go">OpenCode Go</option>
<option value="xai">xAI</option>
</Select>
</Field>
<Field label="文案 model">
<div className="button-row">
{modelsByProvider[provider]?.length ? (
<Select value={model} onChange={(e) => setModel(e.target.value)}>
<option value=""></option>
{modelsByProvider[provider].map((item) => <option key={item} value={item}>{item}</option>)}
</Select>
) : (
<Input value={model} onChange={(e) => setModel(e.target.value)} placeholder="例如 deepseek-v4-pro" />
)}
<Button disabled={loadingModels === "copy"} onClick={() => void fetchModels("copy")}>{loadingModels === "copy" ? "讀取中" : "取得模型"}</Button>
</div>
</Field>
<Field label="研究 provider">
<Select value={researchProvider} onChange={(e) => setResearchProvider(e.target.value)}>
<option value="opencode-go">OpenCode Go</option>
<option value="xai">xAI</option>
</Select>
</Field>
<Field label="研究 model">
<div className="button-row">
{modelsByProvider[researchProvider]?.length ? (
<Select value={researchModel} onChange={(e) => setResearchModel(e.target.value)}>
<option value=""></option>
{modelsByProvider[researchProvider].map((item) => <option key={item} value={item}>{item}</option>)}
</Select>
) : (
<Input value={researchModel} onChange={(e) => setResearchModel(e.target.value)} />
)}
<Button disabled={loadingModels === "research"} onClick={() => void fetchModels("research")}>{loadingModels === "research" ? "讀取中" : "取得模型"}</Button>
</div>
</Field>
</div>
<Field label="API key" hint="只在儲存或取得模型時送出,不會顯示既有 key。">
<Input type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="輸入新 key 才會覆蓋" />
</Field>
<Button variant="primary" onClick={() => void saveAi()}> AI </Button>
</div>
</Card> </Card>
<Card title="我的權限"> <Card title="Chrome 擴充套件">
<pre style={{ whiteSpace: "pre-wrap" }}>{JSON.stringify(permissions, null, 2)}</pre> <ExtensionInstallCard />
{!placement.dev_mode_enabled ? <p className="muted hx-text-sm" style={{ margin: "0.75rem 0 0" }}> Session</p> : null}
</Card> </Card>
</div> </div>
); );
} }
function InfoLine({ label, value }: { label: string; value?: string }) {
return (
<div className="card" style={{ padding: "0.85rem" }}>
<div className="subtle hx-text-sm">{label}</div>
<strong style={{ wordBreak: "break-all" }}>{value || "-"}</strong>
</div>
);
}

View File

@ -9,6 +9,18 @@ x-backend-env: &backend-env
HAIXUN_JWT_REFRESH_SECRET: ${HAIXUN_JWT_REFRESH_SECRET:?HAIXUN_JWT_REFRESH_SECRET is required} HAIXUN_JWT_REFRESH_SECRET: ${HAIXUN_JWT_REFRESH_SECRET:?HAIXUN_JWT_REFRESH_SECRET is required}
HAIXUN_WORKER_SECRET: ${HAIXUN_WORKER_SECRET:?HAIXUN_WORKER_SECRET is required} HAIXUN_WORKER_SECRET: ${HAIXUN_WORKER_SECRET:?HAIXUN_WORKER_SECRET is required}
HAIXUN_SECRETS_KEY: ${HAIXUN_SECRETS_KEY:?HAIXUN_SECRETS_KEY is required} HAIXUN_SECRETS_KEY: ${HAIXUN_SECRETS_KEY:?HAIXUN_SECRETS_KEY is required}
HAIXUN_STORAGE_S3_ENDPOINT: ${HAIXUN_STORAGE_S3_ENDPOINT:-}
HAIXUN_STORAGE_S3_PUBLIC_BASE_URL: ${HAIXUN_STORAGE_S3_PUBLIC_BASE_URL:-}
HAIXUN_STORAGE_S3_REGION: ${HAIXUN_STORAGE_S3_REGION:-us-east-1}
HAIXUN_STORAGE_S3_BUCKET: ${HAIXUN_STORAGE_S3_BUCKET:-}
HAIXUN_STORAGE_S3_ACCESS_KEY: ${HAIXUN_STORAGE_S3_ACCESS_KEY:-}
HAIXUN_STORAGE_S3_SECRET_KEY: ${HAIXUN_STORAGE_S3_SECRET_KEY:-}
HAIXUN_STORAGE_S3_USE_PATH_STYLE: ${HAIXUN_STORAGE_S3_USE_PATH_STYLE:-true}
HAIXUN_SMTP_HOST: ${HAIXUN_SMTP_HOST:-}
HAIXUN_SMTP_PORT: ${HAIXUN_SMTP_PORT:-25}
HAIXUN_SMTP_USERNAME: ${HAIXUN_SMTP_USERNAME:-}
HAIXUN_SMTP_PASSWORD: ${HAIXUN_SMTP_PASSWORD:-}
HAIXUN_SMTP_FROM: ${HAIXUN_SMTP_FROM:-}
THREADS_APP_ID: ${THREADS_APP_ID:-} THREADS_APP_ID: ${THREADS_APP_ID:-}
THREADS_APP_SECRET: ${THREADS_APP_SECRET:-} THREADS_APP_SECRET: ${THREADS_APP_SECRET:-}
THREADS_REDIRECT_URI: ${THREADS_REDIRECT_URI:-} THREADS_REDIRECT_URI: ${THREADS_REDIRECT_URI:-}

View File

@ -1,4 +1,4 @@
# 巡樓資料服務Mongo + Redis # 巡樓資料服務Mongo + Redis + MinIO + Mailpit
# 只綁 127.0.0.1,給同主機上以 systemd 跑的 Go gateway / worker 連線。 # 只綁 127.0.0.1,給同主機上以 systemd 跑的 Go gateway / worker 連線。
# 啟動devmake dev-infra # 讀 deploy/.env.dev # 啟動devmake dev-infra # 讀 deploy/.env.dev
# 啟動prodmake prod-infra # 讀 deploy/.env.prod # 啟動prodmake prod-infra # 讀 deploy/.env.prod
@ -42,9 +42,40 @@ services:
networks: networks:
- haixun-infra - haixun-infra
minio:
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
restart: unless-stopped
command: ["server", "/data", "--console-address", ":9001"]
ports:
- "127.0.0.1:${MINIO_API_PORT:-9000}:9000"
- "127.0.0.1:${MINIO_CONSOLE_PORT:-9001}:9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-haixun}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?MINIO_ROOT_PASSWORD is required}
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
networks:
- haixun-infra
mailpit:
image: axllent/mailpit:v1.27
restart: unless-stopped
ports:
- "127.0.0.1:${MAILPIT_SMTP_PORT:-1025}:1025"
- "127.0.0.1:${MAILPIT_UI_PORT:-8025}:8025"
networks:
- haixun-infra
volumes: volumes:
mongo_data: mongo_data:
redis_data: redis_data:
minio_data:
networks: networks:
haixun-infra: haixun-infra: