add minio

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

View File

@ -12,6 +12,9 @@ include $(ENV_FILE)
export HAIXUN_MONGO_URI HAIXUN_MONGO_DB HAIXUN_REDIS_ADDR HAIXUN_REDIS_PASSWORD
export HAIXUN_JWT_ACCESS_SECRET HAIXUN_JWT_REFRESH_SECRET HAIXUN_WORKER_SECRET
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 INIT_TENANT_ID INIT_ADMIN_EMAIL INIT_ADMIN_PASSWORD INIT_ADMIN_DISPLAY_NAME
endif

View File

@ -292,7 +292,7 @@ POST /api/v1/auth/refresh
POST /api/v1/auth/logout
```
`register` / `login` 回傳:
封測期間 `register` 需要 admin 會員 JWT公開登入頁不提供自行註冊之後開放註冊時再移除這層權限。`register` / `login` 回傳:
```json
{
@ -310,6 +310,18 @@ POST /api/v1/auth/logout
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 模擬登入:
```http

View File

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

View File

@ -23,6 +23,23 @@ Auth:
Secrets:
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:
Secret: ${HAIXUN_WORKER_SECRET}

View File

@ -26,6 +26,27 @@ Auth:
Secrets:
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:
Secret: ${HAIXUN_WORKER_SECRET}

View File

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

View File

@ -5,6 +5,7 @@ type (
TenantID string `json:"tenant_id"`
UID string `json:"uid"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
DisplayName string `json:"display_name,omitempty"`
Avatar string `json:"avatar,omitempty"`
Phone string `json:"phone,omitempty"`
@ -29,6 +30,49 @@ type (
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 {
WebSearchProvider string `json:"web_search_provider"` // brave | exa
BraveAPIKey string `json:"brave_api_key,omitempty"`
@ -42,6 +86,36 @@ type (
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 {
WebSearchProvider *string `json:"web_search_provider,optional"`
BraveAPIKey *string `json:"brave_api_key,optional"`
@ -72,18 +146,42 @@ type (
summary: "Current member profile endpoints. Requires Bearer JWT or dev headers."
)
service gateway {
@handler listMembers
get / (ListMembersReq) returns (ListMembersData)
@handler getMemberMe
get /me returns (MemberMeData)
@handler updateMemberMe
patch /me (UpdateMemberMeReq) returns (MemberMeData)
@handler uploadMemberAvatar
post /me/avatar returns (UploadAvatarData)
@handler getMemberPlacementSettings
get /me/placement-settings returns (MemberPlacementSettingsData)
@handler updateMemberPlacementSettings
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
get /me/capabilities returns (MemberCapabilitiesData)
@handler updateMemberRoles
patch /:uid/roles (UpdateMemberRolesReq) returns (MemberMeData)
@handler updateMemberPassword
patch /:uid/password (UpdateMemberPasswordReq) returns (MemberMeData)
@handler updateMemberProfile
patch /:uid/profile (UpdateMemberProfileReq) returns (MemberMeData)
}

View File

@ -1,8 +1,12 @@
module haixun-backend
go 1.23
go 1.24
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-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0
github.com/golang-jwt/jwt/v4 v4.5.2
@ -17,6 +21,20 @@ require (
require (
github.com/andybalholm/cascadia v1.3.3 // 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/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect

View File

@ -2,6 +2,42 @@ github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kk
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/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/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/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=

View File

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

View File

@ -35,6 +35,18 @@ func (m *memoryMemberRepo) Create(_ context.Context, member *entity.Member) (*en
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) {
for _, item := range m.byEmail {
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")
}
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) {
repo := &memoryMemberRepo{byEmail: map[string]*entity.Member{}}
member, created, err := EnsureAdminMember(context.Background(), repo, AdminOptions{

View File

@ -152,6 +152,11 @@ func Init(ctx context.Context, cfg config.Config, opts InitOptions) (*InitReport
if err := permissionUseCase.EnsureDefaultPermissions(ctx); err != nil {
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
if err := permissionUseCase.EnsureDefaultRolePermissions(ctx, opts.TenantID); err != nil {

View File

@ -42,12 +42,40 @@ type InternalWorkerConf struct {
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)
// used to encrypt sensitive data at rest (browser session, third-party API keys).
type SecretsConf struct {
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 {
APIKey string `json:",optional"`
}
@ -77,6 +105,9 @@ type Config struct {
Redis RedisConf `json:",optional"`
Auth AuthConf `json:",optional"`
Secrets SecretsConf `json:",optional"`
Storage StorageConf `json:",optional"`
SMTP SMTPConf `json:",optional"`
Permission PermissionConf `json:",optional"`
InternalWorker InternalWorkerConf `json:",optional"`
JobWorker JobWorkerConf `json:",optional"`
JobScheduler JobSchedulerConf `json:",optional"`

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -85,11 +85,6 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/refresh",
Handler: auth.RefreshHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/register",
Handler: auth.RegisterHandler(serverCtx),
},
},
rest.WithPrefix("/api/v1/auth"),
)
@ -103,6 +98,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/logout",
Handler: auth.LogoutHandler(serverCtx),
},
{
Method: http.MethodPost,
Path: "/register",
Handler: auth.RegisterHandler(serverCtx),
},
}...,
),
rest.WithPrefix("/api/v1/auth"),
@ -346,65 +346,6 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
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(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT},
@ -489,10 +430,89 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
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(
rest.WithMiddlewares(
[]rest.Middleware{serverCtx.AuthJWT},
[]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,
Path: "/me",
@ -503,6 +523,26 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
Path: "/me",
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,
Path: "/me/capabilities",

View File

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

View File

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

View File

@ -2,10 +2,18 @@ package auth
import (
"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"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
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) {
_, 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,
Email: req.Email,
Password: req.Password,
DisplayName: req.DisplayName,
Language: req.Language,
})
return toAuthTokenData(token), err
if err != nil {
return nil, err
}
verifyCode, err := newVerifyCode()
if err != nil {
return nil, err
}
expiresAt := clock.NowUnixNano() + int64(15*time.Minute)
if err := l.svcCtx.Member.SetEmailVerificationCode(l.ctx, member.TenantID, member.UID, verifyCode, expiresAt); err != nil {
return nil, err
}
if l.svcCtx.Mailer != nil {
body := fmt.Sprintf("你的巡樓 Console 驗證碼是:%s\n\n此驗證碼 15 分鐘內有效。", verifyCode)
if err := l.svcCtx.Mailer.Send(l.ctx, member.Email, "巡樓 Console 帳號驗證碼", body); err != nil {
logx.WithContext(l.ctx).Errorf("send verification email failed: uid=%s err=%v", member.UID, err)
}
}
return toAuthTokenData(token), nil
}
func newVerifyCode() (string, error) {
n, err := rand.Int(rand.Reader, big.NewInt(1000000))
if err != nil {
return "", err
}
return fmt.Sprintf("%06d", n.Int64()), nil
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,56 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package member
import (
"context"
"haixun-backend/internal/logic/authz"
memberusecase "haixun-backend/internal/model/member/domain/usecase"
"haixun-backend/internal/svc"
"haixun-backend/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type ListMembersLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewListMembersLogic(ctx context.Context, svcCtx *svc.ServiceContext) *ListMembersLogic {
return &ListMembersLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *ListMembersLogic) ListMembers(req *types.ListMembersReq) (resp *types.ListMembersData, err error) {
if err := authz.RequireAdmin(l.ctx, l.svcCtx); err != nil {
return nil, err
}
tenantID, _, err := actorFrom(l.ctx)
if err != nil {
return nil, err
}
result, err := l.svcCtx.Member.ListMembers(l.ctx, memberusecase.ListMembersRequest{
TenantID: tenantID,
Page: req.Page,
PageSize: req.PageSize,
})
if err != nil {
return nil, err
}
return &types.ListMembersData{
Pagination: types.PaginationData{
Total: result.Total,
Page: result.Page,
PageSize: result.PageSize,
TotalPages: result.TotalPages,
},
List: toMemberMeDataList(result.List),
}, nil
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -23,6 +23,9 @@ type Member struct {
TenantID string `bson:"tenant_id"`
UID string `bson:"uid"`
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"`
Avatar string `bson:"avatar,omitempty"`
Phone string `bson:"phone,omitempty"`

View File

@ -7,20 +7,28 @@ import (
)
type ProfileUpdate struct {
DisplayName *string
Avatar *string
Language *string
Currency *string
Phone *string
DisplayName *string
Avatar *string
Language *string
Currency *string
Phone *string
EmailVerified *bool
Status *entity.Status
BusinessEmail *string
BusinessEmailVerified *bool
BusinessPhone *string
BusinessPhoneVerified *bool
}
type Repository interface {
EnsureIndexes(ctx context.Context) 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)
FindByEmail(ctx context.Context, tenantID, email string) (*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
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
}

View File

@ -21,13 +21,33 @@ type LoginRequest struct {
}
type UpdateProfileRequest struct {
TenantID string
UID string
DisplayName *string
Avatar *string
Language *string
Currency *string
Phone *string
TenantID string
UID string
DisplayName *string
Avatar *string
Language *string
Currency *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 {
@ -41,7 +61,11 @@ type AuthToken struct {
type UseCase interface {
Register(ctx context.Context, req RegisterRequest) (*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)
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
}

View File

@ -65,6 +65,38 @@ func (r *mongoRepository) Create(ctx context.Context, member *entity.Member) (*e
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) {
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 {
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
err := r.collection.FindOneAndUpdate(
ctx,
@ -169,6 +219,27 @@ func (r *mongoRepository) UpdatePassword(ctx context.Context, tenantID, uid, pas
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) {
if r.collection == nil {
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 {
return strings.ToLower(strings.TrimSpace(email))
}
func normalizePagination(page, pageSize int64) (int64, int64) {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
return page, pageSize
}

View File

@ -75,6 +75,21 @@ func (u *memberUseCase) Login(ctx context.Context, req domusecase.LoginRequest)
return member, token, err
}
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) {
if tenantID == "" || uid == "" {
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)
}
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) {
if req.TenantID == "" || req.UID == "" {
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{
DisplayName: req.DisplayName,
Avatar: req.Avatar,
Language: req.Language,
Currency: req.Currency,
Phone: req.Phone,
DisplayName: req.DisplayName,
Avatar: req.Avatar,
Language: req.Language,
Currency: req.Currency,
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 {
return strings.ToLower(strings.TrimSpace(email))
}
func normalizeRoles(roles []string) []string {
seen := make(map[string]bool, len(roles))
out := make([]string, 0, len(roles))
for _, role := range roles {
role = strings.ToLower(strings.TrimSpace(role))
if role == "" || seen[role] {
continue
}
seen[role] = true
out = append(out, role)
}
return out
}

View File

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

View File

@ -29,6 +29,15 @@ func (u *permissionUseCase) EnsureDefaultPermissions(ctx context.Context) error
return nil
}
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 {
if tenantID == "" {
return nil
@ -196,6 +205,7 @@ func defaultUserPermissionNames() []string {
"auth.logout",
"member.me.read",
"member.me.update",
"member.avatar.update",
"member.capabilities.read",
"member.placement_settings.read",
"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.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.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.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.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},
}
}
func adminRegisterPermissions() []*entity.Permission {
return []*entity.Permission{
{Name: "auth.register", HTTPMethods: "POST", HTTPPath: "/api/v1/auth/register", Status: entity.StatusOpen, Type: entity.TypeBackendUser},
}
}

View File

@ -272,8 +272,11 @@ type UseCase interface {
UpdateConnection(ctx context.Context, req UpdateConnectionRequest) (*ConnectionData, error)
ImportBrowserSession(ctx context.Context, req ImportBrowserSessionRequest) (*ImportBrowserSessionResult, 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)
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)
ResolveWorkerAiCredential(ctx context.Context, tenantID, ownerUID, accountID string) (*WorkerAiCredential, error)
ResolveMemberAiCredential(ctx context.Context, tenantID, ownerUID string) (*WorkerAiCredential, error)

View File

@ -32,6 +32,17 @@ func (u *threadsAccountUseCase) GetAiSettings(ctx context.Context, tenantID, own
return toPublicAiSettings(account.ID, stored), nil
}
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(
ctx context.Context,
tenantID, ownerUID string,
@ -140,6 +151,28 @@ func (u *threadsAccountUseCase) ResolveAiProviderAPIKey(
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(
ctx context.Context,
tenantID, ownerUID, accountID string,
@ -160,6 +193,25 @@ func (u *threadsAccountUseCase) UpdateAiSettings(
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 {
Provider string
Model string

View File

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

View File

@ -1222,6 +1222,16 @@ type ListKnowledgeSourcesData struct {
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 {
List []MentionInboxItemData `json:"list"`
Pagination PaginationData `json:"pagination"`
@ -1364,6 +1374,28 @@ type MePermissionsQuery struct {
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 {
DiscoverReady bool `json:"discover_ready"`
AiReady bool `json:"ai_ready"`
@ -1378,6 +1410,7 @@ type MemberMeData struct {
TenantID string `json:"tenant_id"`
UID string `json:"uid"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
DisplayName string `json:"display_name,omitempty"`
Avatar string `json:"avatar,omitempty"`
Phone string `json:"phone,omitempty"`
@ -1394,6 +1427,10 @@ type MemberMeData struct {
UpdateAt int64 `json:"update_at"`
}
type MemberPath struct {
UID string `path:"uid" validate:"required"`
}
type MemberPlacementSettingsData struct {
WebSearchProvider string `json:"web_search_provider"` // brave | exa
BraveAPIKey string `json:"brave_api_key,omitempty"`
@ -2582,6 +2619,14 @@ type UpdateJobScheduleReq struct {
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 {
DisplayName string `json:"display_name,optional"`
Avatar string `json:"avatar,optional"`
@ -2590,6 +2635,11 @@ type UpdateMemberMeReq struct {
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 {
WebSearchProvider *string `json:"web_search_provider,optional"`
BraveAPIKey *string `json:"brave_api_key,optional"`
@ -2601,6 +2651,26 @@ type UpdateMemberPlacementSettingsReq struct {
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 {
BrandPath
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
}
type UploadAvatarData struct {
Avatar string `json:"avatar"`
}
type UpsertBrandScanScheduleHandlerReq struct {
BrandPath
UpsertBrandScanScheduleReq

View File

@ -12,12 +12,13 @@ import { MissionsPage } from "./pages/MissionsPage";
import { PatrolPage } from "./pages/PatrolPage";
import { PersonasPage } from "./pages/PersonasPage";
import { PublishPage } from "./pages/PublishPage";
import { RegisterMemberPage } from "./pages/RegisterMemberPage";
import { SettingsPage } from "./pages/SettingsPage";
import { BrandsPage } from "./pages/BrandsPage";
import { clearOAuthPending, parseOAuthReturn, stashOAuthReturnMessage, stripOAuthReturnFromUrl } from "./lib/threadsOAuth";
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"]);
export function App() {
@ -87,9 +88,11 @@ export function App() {
return <AuthPage />;
}
const effectivePage = page === "register" && !member.roles?.includes("admin") ? "dashboard" : page;
return (
<Layout active={page} onNavigate={navigate}>
<div key={`${page}-${accountVersion}`}>{renderPage(page, navigate, accountCount)}</div>
<Layout active={effectivePage} onNavigate={navigate}>
<div key={`${effectivePage}-${accountVersion}`}>{renderPage(effectivePage, navigate, accountCount)}</div>
</Layout>
);
}
@ -130,6 +133,8 @@ function renderPage(page: string, navigate: (key: string) => void, accountCount:
return <SettingsPage />;
case "gaps":
return <GapsPage />;
case "register":
return <RegisterMemberPage />;
default:
return <DashboardPage navigate={navigate} />;
}

View File

@ -139,7 +139,8 @@ function buildRequestInit(options: RequestOptions): RequestInit {
Accept: "application/json",
...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";
}
if (options.auth) {
@ -160,7 +161,7 @@ function buildRequestInit(options: RequestOptions): RequestInit {
return {
method: options.method || (options.body === undefined ? "GET" : "POST"),
headers,
body: options.body === undefined ? undefined : JSON.stringify(options.body)
body: options.body === undefined ? undefined : isFormData ? (options.body as BodyInit) : JSON.stringify(options.body)
};
}

View File

@ -17,6 +17,32 @@ export type CapabilityData = {
active_threads_account_id?: string;
};
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 = {
id: string;
display_name?: string;
@ -49,8 +75,8 @@ export type ThreadsConnection = {
prefs: Record<string, unknown>;
};
export type ThreadsAiSettings = {
account_id: string;
export type AiSettings = {
account_id?: string;
provider: string;
model: string;
research_provider?: string;
@ -812,10 +838,32 @@ export type SettingValue = {
export const api = {
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 }),
updatePlacementSettings: (body: Record<string, unknown>) =>
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 }),
createThreadsAccount: (body: { display_name?: string; activate?: boolean }) =>
@ -830,9 +878,9 @@ export const api = {
`/api/v1/threads-accounts/${id}/session/import`,
{ 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>) =>
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) =>
apiRequest<ThreadsAccountAiProviderModels>(`/api/v1/threads-accounts/${id}/ai-settings/providers/${provider}/models`, {
method: "POST",

View File

@ -14,7 +14,6 @@ type AuthContextValue = {
member: MemberMe | null;
loading: boolean;
login: (tenantId: string, email: string, password: string) => Promise<void>;
register: (tenantId: string, email: string, password: string, displayName?: string) => Promise<void>;
logout: () => Promise<void>;
reloadMember: () => Promise<void>;
};
@ -69,18 +68,6 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
[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 () => {
try {
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(
() => ({ member, loading, login, register, logout, reloadMember }),
[member, loading, login, register, logout, reloadMember]
() => ({ member, loading, login, logout, reloadMember }),
[member, loading, login, logout, reloadMember]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;

View File

@ -19,7 +19,9 @@ export function Layout({
children: React.ReactNode;
}) {
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 [activeAccountId, setActiveAccountId] = useState("");
const [switching, setSwitching] = useState(false);
@ -99,7 +101,7 @@ export function Layout({
<div className="ac-pocket-screen">
<div className="display-en muted ac-sidebar-pad">PATROL PAD</div>
<nav className="ac-pocket-scroll">
{navApps.map((app) => (
{visibleNavApps.map((app) => (
(() => {
const locked = accounts.length === 0 && requiresThreadsAccount(app.key);
return (

View File

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

View File

@ -1,5 +1,5 @@
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 { Badge, Button, Card, ErrorText, Field, Input, PageTitle, Select } from "../components/ui";
import { persistActiveThreadsAccountId } from "../lib/activeAccount";
@ -15,20 +15,12 @@ export function AccountsPage() {
const [activeId, setActiveId] = useState("");
const [caps, setCaps] = useState<CapabilityData>();
const [connection, setConnection] = useState<ThreadsConnection>();
const [aiSettings, setAiSettings] = useState<ThreadsAiSettings>();
const [diagnostics, setDiagnostics] = useState<ThreadsDiagnostics>();
const [guard, setGuard] = useState<PublishGuardPolicy>();
const [oauthEnabled, setOauthEnabled] = useState(false);
const [oauthRequiresHttps, setOauthRequiresHttps] = useState(false);
const [creating, setCreating] = useState(false);
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 [error, setError] = useState<unknown>();
const [message, setMessage] = useState("");
@ -58,18 +50,12 @@ export function AccountsPage() {
setActiveId(id);
persistActiveThreadsAccountId(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);
setAiSettings(ai);
setDiagnostics(diag);
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 {
setConnection(undefined);
setAiSettings(undefined);
setDiagnostics(undefined);
setGuard(undefined);
}
@ -136,7 +122,6 @@ export function AccountsPage() {
setMessage(result.message || `已刪除帳號:${label}`);
setActiveId(result.active_account_id || "");
setConnection(undefined);
setAiSettings(undefined);
setDiagnostics(undefined);
setGuard(undefined);
window.dispatchEvent(new CustomEvent("haixun:active-account-changed"));
@ -160,38 +145,6 @@ export function AccountsPage() {
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() {
if (!activeId) return;
await api.smokeTest(activeId);
@ -354,63 +307,6 @@ export function AccountsPage() {
<p className="muted"></p>
)}
</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>
);
}

View File

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

View File

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

View File

@ -1,7 +1,8 @@
import { useEffect, useState } from "react";
import { api } from "../api/haixun";
import { api, AiSettings, MemberData } from "../api/haixun";
import { useAuth } from "../auth/AuthContext";
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 = {
web_search_provider?: string;
@ -14,37 +15,32 @@ type PlacementSettings = {
};
function broadcastPlacementSettings(settings: PlacementSettings) {
window.dispatchEvent(
new CustomEvent("haixun:placement-settings-changed", {
detail: settings,
})
);
window.dispatchEvent(new CustomEvent("haixun:placement-settings-changed", { detail: settings }));
}
function buildPlacementPatch(placement: PlacementSettings): Record<string, unknown> {
const payload: Record<string, unknown> = {};
if (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.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;
}
if (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.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;
}
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 [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 [message, setMessage] = useState("");
const [savingDevMode, setSavingDevMode] = useState(false);
@ -52,22 +48,54 @@ export function SettingsPage() {
async function load() {
setError(undefined);
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);
setProfile({
display_name: memberData.display_name || "",
phone: memberData.phone || "",
language: memberData.language || "",
currency: memberData.currency || "",
avatar: memberData.avatar || ""
});
setPlacement((placementData || {}) as PlacementSettings);
setPermissions(permissionData);
applyAiSettings(aiData);
} catch (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(() => {
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() {
const updated = (await api.updatePlacementSettings(buildPlacementPatch(placement))) as PlacementSettings;
setPlacement((current) => ({ ...current, ...updated }));
setPlacement((current) => ({ ...current, ...updated, brave_api_key: "", exa_api_key: "" }));
setMessage("搜尋設定已儲存");
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 (
<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} />
{message ? <Card><p>{message}</p></Card> : null}
{message ? <Card><p style={{ margin: 0 }}>{message}</p></Card> : null}
<div className="grid-2">
<Card title="會員資料">
<pre style={{ whiteSpace: "pre-wrap" }}>{JSON.stringify(member, null, 2)}</pre>
<Card title="基本資訊">
<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 title="搜尋與延伸策略">
@ -117,20 +210,15 @@ export function SettingsPage() {
</Select>
</Field>
<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 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 label="開發模式(測試海巡)" hint="僅測試海巡走 Chrome 爬蟲;正式海巡、擴圖、發文與留言仍走 Threads API。開啟後才會顯示測試海巡按鈕與 Chrome Session 同步。">
<Field label="開發模式(測試海巡)" hint="僅測試海巡走 Chrome 爬蟲;正式海巡、擴圖、發文與留言仍走 Threads API。">
<label className="button-row" style={{ justifyContent: "space-between" }}>
<span>{savingDevMode ? "(儲存中…)" : ""}</span>
<input
type="checkbox"
checked={Boolean(placement.dev_mode_enabled)}
disabled={savingDevMode}
onChange={(e) => void toggleDevMode(e.target.checked)}
/>
<input type="checkbox" checked={Boolean(placement.dev_mode_enabled)} disabled={savingDevMode} onChange={(e) => void toggleDevMode(e.target.checked)} />
</label>
</Field>
<Button variant="primary" onClick={() => void savePlacement()}></Button>
@ -138,18 +226,72 @@ export function SettingsPage() {
</Card>
</div>
<Card title="Chrome 擴充套件">
<ExtensionInstallCard />
{!placement.dev_mode_enabled ? (
<p className="muted hx-text-sm" style={{ margin: "0.75rem 0 0" }}>
Threads Session使
</p>
) : null}
<Card title="AI 設定">
<div className="page-grid">
<Field label="套用範圍" hint="這裡設定的是你的整個大帳號。旗下所有 Threads 帳號、文案任務與研究任務共用這組 AI 設定。">
<Input value={member?.email || member?.uid || "我的大帳號"} readOnly />
</Field>
{aiSettings ? <p className="muted hx-text-sm" style={{ margin: 0 }}>{aiSettings.provider || "未設定"} / {aiSettings.model || "未設定模型"}</p> : 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 title="我的權限">
<pre style={{ whiteSpace: "pre-wrap" }}>{JSON.stringify(permissions, null, 2)}</pre>
<Card title="Chrome 擴充套件">
<ExtensionInstallCard />
{!placement.dev_mode_enabled ? <p className="muted hx-text-sm" style={{ margin: "0.75rem 0 0" }}> Session</p> : null}
</Card>
</div>
);
}
function InfoLine({ label, value }: { label: string; value?: string }) {
return (
<div className="card" style={{ padding: "0.85rem" }}>
<div className="subtle hx-text-sm">{label}</div>
<strong style={{ wordBreak: "break-all" }}>{value || "-"}</strong>
</div>
);
}

View File

@ -9,6 +9,18 @@ x-backend-env: &backend-env
HAIXUN_JWT_REFRESH_SECRET: ${HAIXUN_JWT_REFRESH_SECRET:?HAIXUN_JWT_REFRESH_SECRET is required}
HAIXUN_WORKER_SECRET: ${HAIXUN_WORKER_SECRET:?HAIXUN_WORKER_SECRET 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_SECRET: ${THREADS_APP_SECRET:-}
THREADS_REDIRECT_URI: ${THREADS_REDIRECT_URI:-}

View File

@ -1,4 +1,4 @@
# 巡樓資料服務Mongo + Redis
# 巡樓資料服務Mongo + Redis + MinIO + Mailpit
# 只綁 127.0.0.1,給同主機上以 systemd 跑的 Go gateway / worker 連線。
# 啟動devmake dev-infra # 讀 deploy/.env.dev
# 啟動prodmake prod-infra # 讀 deploy/.env.prod
@ -42,9 +42,40 @@ services:
networks:
- 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:
mongo_data:
redis_data:
minio_data:
networks:
haixun-infra: