diff --git a/backend/Makefile b/backend/Makefile index d9ca395..3b86a13 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -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 diff --git a/backend/README.md b/backend/README.md index 96879cf..5e70226 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 ``` +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 diff --git a/backend/dev-console/README.md b/backend/dev-console/README.md index 3ff7126..cc17e90 100644 --- a/backend/dev-console/README.md +++ b/backend/dev-console/README.md @@ -24,7 +24,7 @@ npm run dev ## 使用順序 -1. 註冊或登入(tenant 預設 `default`) +1. 使用已建立帳號登入(tenant 預設 `default`;封測期間不開放公開註冊) 2. **AI 設定**:選研究 provider → 貼 API key → **讀取模型列表** → 選 model → **儲存** 3. 建立人設 → 點選列表中的一筆 4. 填對標 Threads 帳號 → **開始 8D** @@ -34,4 +34,4 @@ npm run dev - 8D 分析使用**研究用** provider / model + API key(與 Chrome session 無關) - 若尚無經營帳號,Dev Console 會自動建立「Dev Console」帳號(AI key 實際存會員 scope) -- 僅供本機開發,不是產品 UI \ No newline at end of file +- 僅供本機開發,不是產品 UI diff --git a/backend/etc/gateway.prod.yaml b/backend/etc/gateway.prod.yaml index 580f5aa..37cb84d 100644 --- a/backend/etc/gateway.prod.yaml +++ b/backend/etc/gateway.prod.yaml @@ -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} diff --git a/backend/etc/gateway.yaml b/backend/etc/gateway.yaml index d9858a3..1bd4f60 100644 --- a/backend/etc/gateway.yaml +++ b/backend/etc/gateway.yaml @@ -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} diff --git a/backend/generate/api/auth.api b/backend/generate/api/auth.api index b2556cd..21c928a 100644 --- a/backend/generate/api/auth.api +++ b/backend/generate/api/auth.api @@ -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) } diff --git a/backend/generate/api/member.api b/backend/generate/api/member.api index 4df8067..6deb184 100644 --- a/backend/generate/api/member.api +++ b/backend/generate/api/member.api @@ -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) } diff --git a/backend/go.mod b/backend/go.mod index 4bae144..b5f7b7f 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 diff --git a/backend/go.sum b/backend/go.sum index 6b31483..a4e1d71 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/bootstrap/admin.go b/backend/internal/bootstrap/admin.go index 2ab36d3..42e3a9e 100644 --- a/backend/internal/bootstrap/admin.go +++ b/backend/internal/bootstrap/admin.go @@ -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 diff --git a/backend/internal/bootstrap/admin_test.go b/backend/internal/bootstrap/admin_test.go index 06b0f20..64da60e 100644 --- a/backend/internal/bootstrap/admin_test.go +++ b/backend/internal/bootstrap/admin_test.go @@ -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{ diff --git a/backend/internal/bootstrap/init.go b/backend/internal/bootstrap/init.go index baf77c2..e9c9a8e 100644 --- a/backend/internal/bootstrap/init.go +++ b/backend/internal/bootstrap/init.go @@ -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 { diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 868cb19..5b02fd0 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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"` diff --git a/backend/internal/handler/member/get_member_ai_settings_handler.go b/backend/internal/handler/member/get_member_ai_settings_handler.go new file mode 100644 index 0000000..da32387 --- /dev/null +++ b/backend/internal/handler/member/get_member_ai_settings_handler.go @@ -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) + } +} diff --git a/backend/internal/handler/member/list_member_ai_provider_models_handler.go b/backend/internal/handler/member/list_member_ai_provider_models_handler.go new file mode 100644 index 0000000..769cc2e --- /dev/null +++ b/backend/internal/handler/member/list_member_ai_provider_models_handler.go @@ -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) + } +} diff --git a/backend/internal/handler/member/list_members_handler.go b/backend/internal/handler/member/list_members_handler.go new file mode 100644 index 0000000..7bb0006 --- /dev/null +++ b/backend/internal/handler/member/list_members_handler.go @@ -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) + } +} diff --git a/backend/internal/handler/member/update_member_ai_settings_handler.go b/backend/internal/handler/member/update_member_ai_settings_handler.go new file mode 100644 index 0000000..8989654 --- /dev/null +++ b/backend/internal/handler/member/update_member_ai_settings_handler.go @@ -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) + } +} diff --git a/backend/internal/handler/member/update_member_password_handler.go b/backend/internal/handler/member/update_member_password_handler.go new file mode 100644 index 0000000..098a335 --- /dev/null +++ b/backend/internal/handler/member/update_member_password_handler.go @@ -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) + } +} diff --git a/backend/internal/handler/member/update_member_profile_handler.go b/backend/internal/handler/member/update_member_profile_handler.go new file mode 100644 index 0000000..ccb5651 --- /dev/null +++ b/backend/internal/handler/member/update_member_profile_handler.go @@ -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) + } +} diff --git a/backend/internal/handler/member/update_member_roles_handler.go b/backend/internal/handler/member/update_member_roles_handler.go new file mode 100644 index 0000000..6847a8b --- /dev/null +++ b/backend/internal/handler/member/update_member_roles_handler.go @@ -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) + } +} diff --git a/backend/internal/handler/member/upload_member_avatar_handler.go b/backend/internal/handler/member/upload_member_avatar_handler.go new file mode 100644 index 0000000..a540e0b --- /dev/null +++ b/backend/internal/handler/member/upload_member_avatar_handler.go @@ -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) + } +} diff --git a/backend/internal/handler/routes.go b/backend/internal/handler/routes.go index efaebe0..1734322 100644 --- a/backend/internal/handler/routes.go +++ b/backend/internal/handler/routes.go @@ -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", diff --git a/backend/internal/library/mailer/smtp.go b/backend/internal/library/mailer/smtp.go new file mode 100644 index 0000000..d86cb40 --- /dev/null +++ b/backend/internal/library/mailer/smtp.go @@ -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)) +} diff --git a/backend/internal/library/storage/s3.go b/backend/internal/library/storage/s3.go new file mode 100644 index 0000000..68274fe --- /dev/null +++ b/backend/internal/library/storage/s3.go @@ -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 +} diff --git a/backend/internal/logic/auth/register_logic.go b/backend/internal/logic/auth/register_logic.go index 9ee49cf..8859384 100644 --- a/backend/internal/logic/auth/register_logic.go +++ b/backend/internal/logic/auth/register_logic.go @@ -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 } diff --git a/backend/internal/logic/member/ai_settings_mapper.go b/backend/internal/logic/member/ai_settings_mapper.go new file mode 100644 index 0000000..a999a99 --- /dev/null +++ b/backend/internal/logic/member/ai_settings_mapper.go @@ -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, + } +} diff --git a/backend/internal/logic/member/get_member_ai_settings_logic.go b/backend/internal/logic/member/get_member_ai_settings_logic.go new file mode 100644 index 0000000..afc0777 --- /dev/null +++ b/backend/internal/logic/member/get_member_ai_settings_logic.go @@ -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 +} diff --git a/backend/internal/logic/member/list_member_ai_provider_models_logic.go b/backend/internal/logic/member/list_member_ai_provider_models_logic.go new file mode 100644 index 0000000..2a78bc4 --- /dev/null +++ b/backend/internal/logic/member/list_member_ai_provider_models_logic.go @@ -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 +} diff --git a/backend/internal/logic/member/list_members_logic.go b/backend/internal/logic/member/list_members_logic.go new file mode 100644 index 0000000..10044b9 --- /dev/null +++ b/backend/internal/logic/member/list_members_logic.go @@ -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 +} diff --git a/backend/internal/logic/member/mapper.go b/backend/internal/logic/member/mapper.go index 44edd5e..88c78b7 100644 --- a/backend/internal/logic/member/mapper.go +++ b/backend/internal/logic/member/mapper.go @@ -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 +} diff --git a/backend/internal/logic/member/update_member_ai_settings_logic.go b/backend/internal/logic/member/update_member_ai_settings_logic.go new file mode 100644 index 0000000..37f106d --- /dev/null +++ b/backend/internal/logic/member/update_member_ai_settings_logic.go @@ -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 +} diff --git a/backend/internal/logic/member/update_member_password_logic.go b/backend/internal/logic/member/update_member_password_logic.go new file mode 100644 index 0000000..45bbab8 --- /dev/null +++ b/backend/internal/logic/member/update_member_password_logic.go @@ -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 +} diff --git a/backend/internal/logic/member/update_member_profile_logic.go b/backend/internal/logic/member/update_member_profile_logic.go new file mode 100644 index 0000000..5651579 --- /dev/null +++ b/backend/internal/logic/member/update_member_profile_logic.go @@ -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 +} diff --git a/backend/internal/logic/member/update_member_roles_logic.go b/backend/internal/logic/member/update_member_roles_logic.go new file mode 100644 index 0000000..ccb9f9d --- /dev/null +++ b/backend/internal/logic/member/update_member_roles_logic.go @@ -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 +} diff --git a/backend/internal/logic/member/upload_member_avatar_logic.go b/backend/internal/logic/member/upload_member_avatar_logic.go new file mode 100644 index 0000000..fb661f8 --- /dev/null +++ b/backend/internal/logic/member/upload_member_avatar_logic.go @@ -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 + } +} diff --git a/backend/internal/model/member/domain/entity/member.go b/backend/internal/model/member/domain/entity/member.go index cbb8e56..fd3e13e 100644 --- a/backend/internal/model/member/domain/entity/member.go +++ b/backend/internal/model/member/domain/entity/member.go @@ -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"` diff --git a/backend/internal/model/member/domain/repository/member.go b/backend/internal/model/member/domain/repository/member.go index d01a182..3d13ee2 100644 --- a/backend/internal/model/member/domain/repository/member.go +++ b/backend/internal/model/member/domain/repository/member.go @@ -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 } diff --git a/backend/internal/model/member/domain/usecase/member.go b/backend/internal/model/member/domain/usecase/member.go index 284c839..00940b0 100644 --- a/backend/internal/model/member/domain/usecase/member.go +++ b/backend/internal/model/member/domain/usecase/member.go @@ -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 } diff --git a/backend/internal/model/member/repository/mongo.go b/backend/internal/model/member/repository/mongo.go index 162ddd2..e4ace3f 100644 --- a/backend/internal/model/member/repository/mongo.go +++ b/backend/internal/model/member/repository/mongo.go @@ -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 +} diff --git a/backend/internal/model/member/usecase/usecase.go b/backend/internal/model/member/usecase/usecase.go index 7881650..86273ce 100644 --- a/backend/internal/model/member/usecase/usecase.go +++ b/backend/internal/model/member/usecase/usecase.go @@ -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 +} diff --git a/backend/internal/model/permission/domain/usecase/permission.go b/backend/internal/model/permission/domain/usecase/permission.go index 60b2f33..141dc7e 100644 --- a/backend/internal/model/permission/domain/usecase/permission.go +++ b/backend/internal/model/permission/domain/usecase/permission.go @@ -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) diff --git a/backend/internal/model/permission/usecase/usecase.go b/backend/internal/model/permission/usecase/usecase.go index ad73322..99decdd 100644 --- a/backend/internal/model/permission/usecase/usecase.go +++ b/backend/internal/model/permission/usecase/usecase.go @@ -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}, + } +} diff --git a/backend/internal/model/threads_account/domain/usecase/usecase.go b/backend/internal/model/threads_account/domain/usecase/usecase.go index 7d41efd..b5d814f 100644 --- a/backend/internal/model/threads_account/domain/usecase/usecase.go +++ b/backend/internal/model/threads_account/domain/usecase/usecase.go @@ -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) diff --git a/backend/internal/model/threads_account/usecase/ai_credentials.go b/backend/internal/model/threads_account/usecase/ai_credentials.go index b146c91..df047f8 100644 --- a/backend/internal/model/threads_account/usecase/ai_credentials.go +++ b/backend/internal/model/threads_account/usecase/ai_credentials.go @@ -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 diff --git a/backend/internal/svc/service_context.go b/backend/internal/svc/service_context.go index a1596be..c9234f2 100644 --- a/backend/internal/svc/service_context.go +++ b/backend/internal/svc/service_context.go @@ -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() diff --git a/backend/internal/types/types.go b/backend/internal/types/types.go index 4efaa08..2234964 100644 --- a/backend/internal/types/types.go +++ b/backend/internal/types/types.go @@ -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 diff --git a/backend/web/src/App.tsx b/backend/web/src/App.tsx index f47d478..740e807 100644 --- a/backend/web/src/App.tsx +++ b/backend/web/src/App.tsx @@ -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 ; } + const effectivePage = page === "register" && !member.roles?.includes("admin") ? "dashboard" : page; + return ( - -
{renderPage(page, navigate, accountCount)}
+ +
{renderPage(effectivePage, navigate, accountCount)}
); } @@ -130,6 +133,8 @@ function renderPage(page: string, navigate: (key: string) => void, accountCount: return ; case "gaps": return ; + case "register": + return ; default: return ; } diff --git a/backend/web/src/api/client.ts b/backend/web/src/api/client.ts index e11570d..0a8fad1 100644 --- a/backend/web/src/api/client.ts +++ b/backend/web/src/api/client.ts @@ -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) }; } diff --git a/backend/web/src/api/haixun.ts b/backend/web/src/api/haixun.ts index 59c0cae..ec580d2 100644 --- a/backend/web/src/api/haixun.ts +++ b/backend/web/src/api/haixun.ts @@ -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; }; -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("/api/v1/members/me/capabilities", { auth: true }), - memberMe: () => apiRequest("/api/v1/members/me", { auth: true }), + memberMe: () => apiRequest("/api/v1/members/me", { auth: true }), + updateMemberMe: (body: Record) => apiRequest("/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(`/api/v1/members?page=${page}&pageSize=${pageSize}`, { auth: true }), + updateMemberProfile: (uid: string, body: Record) => + apiRequest(`/api/v1/members/${encodeURIComponent(uid)}/profile`, { method: "PATCH", body, auth: true }), + updateMemberRoles: (uid: string, roles: string[]) => + apiRequest(`/api/v1/members/${encodeURIComponent(uid)}/roles`, { method: "PATCH", body: { roles }, auth: true }), + updateMemberPassword: (uid: string, password: string) => + apiRequest(`/api/v1/members/${encodeURIComponent(uid)}/password`, { method: "PATCH", body: { password }, auth: true }), placementSettings: () => apiRequest("/api/v1/members/me/placement-settings", { auth: true }), updatePlacementSettings: (body: Record) => apiRequest("/api/v1/members/me/placement-settings", { method: "PATCH", body, auth: true }), + memberAiSettings: () => apiRequest("/api/v1/members/me/ai-settings", { auth: true }), + updateMemberAiSettings: (body: Record) => + apiRequest("/api/v1/members/me/ai-settings", { method: "PUT", body, auth: true }), + memberAiProviderModels: (provider: string, apiKey?: string) => + apiRequest(`/api/v1/members/me/ai-settings/providers/${provider}/models`, { + method: "POST", + body: apiKey ? { api_key: apiKey } : {}, + auth: true + }), threadsAccounts: () => apiRequest("/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(`/api/v1/threads-accounts/${id}/ai-settings`, { auth: true }), + threadsAiSettings: (id: string) => apiRequest(`/api/v1/threads-accounts/${id}/ai-settings`, { auth: true }), updateThreadsAiSettings: (id: string, body: Record) => - apiRequest(`/api/v1/threads-accounts/${id}/ai-settings`, { method: "PUT", body, auth: true }), + apiRequest(`/api/v1/threads-accounts/${id}/ai-settings`, { method: "PUT", body, auth: true }), threadsAccountProviderModels: (id: string, provider: string, apiKey?: string) => apiRequest(`/api/v1/threads-accounts/${id}/ai-settings/providers/${provider}/models`, { method: "POST", diff --git a/backend/web/src/auth/AuthContext.tsx b/backend/web/src/auth/AuthContext.tsx index b755f5e..fcc8c34 100644 --- a/backend/web/src/auth/AuthContext.tsx +++ b/backend/web/src/auth/AuthContext.tsx @@ -14,7 +14,6 @@ type AuthContextValue = { member: MemberMe | null; loading: boolean; login: (tenantId: string, email: string, password: string) => Promise; - register: (tenantId: string, email: string, password: string, displayName?: string) => Promise; logout: () => Promise; reloadMember: () => Promise; }; @@ -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("/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("/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 {children}; diff --git a/backend/web/src/components/Layout.tsx b/backend/web/src/components/Layout.tsx index 4b86742..91d55a6 100644 --- a/backend/web/src/components/Layout.tsx +++ b/backend/web/src/components/Layout.tsx @@ -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([]); const [activeAccountId, setActiveAccountId] = useState(""); const [switching, setSwitching] = useState(false); @@ -99,7 +101,7 @@ export function Layout({
PATROL PAD
); } diff --git a/backend/web/src/pages/AuthPage.tsx b/backend/web/src/pages/AuthPage.tsx index db8d856..63d5222 100644 --- a/backend/web/src/pages/AuthPage.tsx +++ b/backend/web/src/pages/AuthPage.tsx @@ -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() {

- {mode === "login" ? "登入後就能管理 Threads 帳號、發文庫存與找 TA 任務。" : "建立島民帳號,開始設定你的巡樓工作台。"} + 登入後就能管理 Threads 帳號、發文庫存與找 TA 任務。封測階段暫不開放自行註冊。

- {mode === "register" ? ( - - setDisplayName(e.target.value)} placeholder="例如:Daniel" /> - - ) : null} setEmail(e.target.value)} required placeholder="you@example.com" /> @@ -54,10 +43,7 @@ export function AuthPage() { {error ?

{error}

: null}
-
@@ -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 "登入失敗,請檢查資料後再試一次。"; } diff --git a/backend/web/src/pages/RegisterMemberPage.tsx b/backend/web/src/pages/RegisterMemberPage.tsx new file mode 100644 index 0000000..dfe7a6d --- /dev/null +++ b/backend/web/src/pages/RegisterMemberPage.tsx @@ -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([]); + const [loadingList, setLoadingList] = useState(false); + const [listError, setListError] = useState(""); + const [passwordDrafts, setPasswordDrafts] = useState>({}); + const [editingUID, setEditingUID] = useState(""); + const [profileDraft, setProfileDraft] = useState(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("/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 ( +
+ + +

你目前不是 admin,無法管理島民帳號。

+
+
+ ); + } + + return ( +
+ + +
+ + setTenantId(e.target.value)} required /> + + + setDisplayName(e.target.value)} placeholder="例如:Daniel" /> + + + setEmail(e.target.value)} required placeholder="you@example.com" /> + + + setPassword(e.target.value)} required minLength={8} /> + + {error ?

{error}

: null} + {createdUID ?

已建立帳號:{createdUID}

: null} +
+ +
+
+
+ void loadMembers()} disabled={loadingList}>{loadingList ? "載入中" : "重新載入"}}> + {listError ?

{listError}

: null} +
+ + + + + + + + + + + + + + {items.map((item) => { + const role = item.roles?.includes("admin") ? "admin" : "user"; + const isSelf = item.uid === member?.uid; + return ( + + + + + + + + + + ); + })} + {!loadingList && items.length === 0 ? ( + + ) : null} + +
島民UID角色狀態建立時間資訊重設密碼
+ {item.display_name || item.email} +
{item.email}
+
+ + {isSelf ?
不能改自己的角色
: null} +
{item.status}{formatUnixNano(item.create_at)} +
+ setPasswordDrafts((current) => ({ ...current, [item.uid]: event.target.value }))} + style={{ width: "9rem" }} + /> + +
+
尚未找到島民帳號。
+
+
+ {profileDraft ? ( + +
+
+ + +
+
+ + setProfileDraft({ ...profileDraft, display_name: e.target.value })} /> + + + + + + setProfileDraft({ ...profileDraft, phone: e.target.value })} /> + + + setProfileDraft({ ...profileDraft, language: e.target.value })} placeholder="zh-TW" /> + + + setProfileDraft({ ...profileDraft, currency: e.target.value })} placeholder="TWD" /> + + + setProfileDraft({ ...profileDraft, business_email: e.target.value })} /> + + + setProfileDraft({ ...profileDraft, business_phone: e.target.value })} /> + + +
+ + + +
+
+
+ +
+
+ ) : null} +
+ ); +} + +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(); +} diff --git a/backend/web/src/pages/SettingsPage.tsx b/backend/web/src/pages/SettingsPage.tsx index 7e37ee2..bbb0002 100644 --- a/backend/web/src/pages/SettingsPage.tsx +++ b/backend/web/src/pages/SettingsPage.tsx @@ -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 { const payload: Record = {}; - 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(); + const { reloadMember } = useAuth(); + const [member, setMember] = useState(); + const [profile, setProfile] = useState({ display_name: "", phone: "", language: "", currency: "", avatar: "" }); const [placement, setPlacement] = useState({}); - const [permissions, setPermissions] = useState(); + const [aiSettings, setAiSettings] = 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>({}); + const [loadingModels, setLoadingModels] = useState(""); const [error, setError] = useState(); 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 (
- void load()}>重新載入} /> + void load()}>重新載入} /> - {message ?

{message}

: null} + {message ?

{message}

: null}
- -
{JSON.stringify(member, null, 2)}
+ +
+
+ {member?.status || "unknown"} + {member?.email} + {member?.email_verified ? "Email 已驗證" : "Email 未驗證"} + {member?.roles?.join("、") || "user"} +
+ {profile.avatar ? ( + 目前頭像 + ) : null} + + setProfile({ ...profile, display_name: e.target.value })} /> + + + setProfile({ ...profile, phone: e.target.value })} /> + +
+ + setProfile({ ...profile, language: e.target.value })} placeholder="zh-TW" /> + + + setProfile({ ...profile, currency: e.target.value })} placeholder="TWD" /> + +
+ + setProfile({ ...profile, avatar: e.target.value })} /> + + + void uploadAvatar(e.target.files?.[0])} /> + +
+ + +
+ +
@@ -117,20 +210,15 @@ export function SettingsPage() { - setPlacement({ ...placement, brave_api_key: e.target.value })} /> + setPlacement({ ...placement, brave_api_key: e.target.value })} /> - setPlacement({ ...placement, exa_api_key: e.target.value })} /> + setPlacement({ ...placement, exa_api_key: e.target.value })} /> - + @@ -138,18 +226,72 @@ export function SettingsPage() {
- - - {!placement.dev_mode_enabled ? ( -

- 請先開啟上方「啟用爬蟲測試海巡」,安裝擴充後才能在 Threads 帳號頁同步 Session、在海巡頁使用測試海巡。 -

- ) : null} + +
+ + + + {aiSettings ?

目前設定:{aiSettings.provider || "未設定"} / {aiSettings.model || "未設定模型"}

: null} +
+ + + + +
+ {modelsByProvider[provider]?.length ? ( + + ) : ( + setModel(e.target.value)} placeholder="例如 deepseek-v4-pro" /> + )} + +
+
+ + + + +
+ {modelsByProvider[researchProvider]?.length ? ( + + ) : ( + setResearchModel(e.target.value)} /> + )} + +
+
+
+ + setApiKey(e.target.value)} placeholder="輸入新 key 才會覆蓋" /> + + +
- -
{JSON.stringify(permissions, null, 2)}
+ + + {!placement.dev_mode_enabled ?

請先開啟上方「啟用爬蟲測試海巡」,安裝擴充後才能同步 Session。

: null}
); -} \ No newline at end of file +} + +function InfoLine({ label, value }: { label: string; value?: string }) { + return ( +
+
{label}
+ {value || "-"} +
+ ); +} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 365bd05..f3d9f09 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -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:-} diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index ece083e..9386c8b 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -1,4 +1,4 @@ -# 巡樓資料服務:Mongo + Redis +# 巡樓資料服務:Mongo + Redis + MinIO + Mailpit # 只綁 127.0.0.1,給同主機上以 systemd 跑的 Go gateway / worker 連線。 # 啟動(dev):make dev-infra # 讀 deploy/.env.dev # 啟動(prod):make 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: