add minio
This commit is contained in:
parent
5acd1b6567
commit
7ed9f50a2c
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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**
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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=
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ func EnsureAdminMember(ctx context.Context, repo domrepo.Repository, opts AdminO
|
||||||
TenantID: tenantID,
|
TenantID: tenantID,
|
||||||
UID: uuid.NewString(),
|
UID: uuid.NewString(),
|
||||||
Email: email,
|
Email: email,
|
||||||
|
EmailVerified: true,
|
||||||
DisplayName: displayName,
|
DisplayName: displayName,
|
||||||
Language: "zh-TW",
|
Language: "zh-TW",
|
||||||
Status: entity.StatusOpen,
|
Status: entity.StatusOpen,
|
||||||
|
|
|
||||||
|
|
@ -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{
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"`
|
||||||
|
|
|
||||||
|
|
@ -12,15 +12,23 @@ type ProfileUpdate struct {
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,26 @@ type UpdateProfileRequest struct {
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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" }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 "登入失敗,請檢查資料後再試一次。";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:-}
|
||||||
|
|
|
||||||
|
|
@ -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 連線。
|
||||||
# 啟動(dev):make dev-infra # 讀 deploy/.env.dev
|
# 啟動(dev):make dev-infra # 讀 deploy/.env.dev
|
||||||
# 啟動(prod):make prod-infra # 讀 deploy/.env.prod
|
# 啟動(prod):make 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:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue