From bab522bcb31ebdc4ff4f4f72abec62ebff6763be Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 21 Oct 2026 22:37:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E6=8E=A5=E5=85=A5=20DCAuth=20v2?= =?UTF-8?q?=20=E7=99=BB=E5=BD=95=E5=B9=B6=E5=AE=8C=E5=96=84=E6=88=90?= =?UTF-8?q?=E5=91=98=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 登录链路改为用户名 + 密码 + 短信验证码 + DCAuth v2 设备签名 - 迁移 014:新增 login_sms_codes / user_devices / device_challenges 与 users.phone - 新增 /api/dcauth/authorize 与 challenge 状态轮询;旧密码登录仅放行未绑定手机号的账号 - 成员管理支持手机号绑定、设备重置、账号改名、昵称与重置密码 - 移除最后一个商户关系时软删除账号,释放用户名、手机号并撤销设备绑定 --- .env.example | 12 + backend/cmd/server/main.go | 15 +- backend/internal/config/config.go | 18 + .../migrations/014_login_sms_device_auth.sql | 98 ++ backend/internal/handler/auth.go | 75 +- backend/internal/handler/dcauth.go | 68 + backend/internal/handler/dcauth_test.go | 50 + backend/internal/handler/merchant_member.go | 49 +- backend/internal/handler/user.go | 31 + backend/internal/model/login_auth.go | 76 + backend/internal/model/models.go | 11 + backend/internal/router/router.go | 17 +- backend/internal/service/auth.go | 121 +- backend/internal/service/login_auth_test.go | 442 +++++ .../internal/service/login_dcauth_service.go | 507 ++++++ backend/internal/service/login_sms_service.go | 349 ++++ backend/internal/service/merchant_member.go | 138 +- .../service/merchant_member_security_test.go | 353 ++++ backend/internal/service/merchant_test.go | 30 +- backend/internal/service/user.go | 177 +- docker-compose.yml | 8 + frontend/src/api/index.ts | 758 ++++++--- frontend/src/pages/Login.tsx | 223 ++- frontend/src/pages/MerchantCenter.tsx | 1336 ++++++++++----- frontend/src/pages/PlatformUsers.tsx | 144 +- frontend/src/pages/merchantCenterTabs.tsx | 1508 ++++++++++++----- frontend/src/store/auth.tsx | 13 +- frontend/src/store/authContext.ts | 3 +- frontend/src/types/index.ts | 30 + 29 files changed, 5490 insertions(+), 1170 deletions(-) create mode 100644 backend/internal/database/migrations/014_login_sms_device_auth.sql create mode 100644 backend/internal/handler/dcauth.go create mode 100644 backend/internal/handler/dcauth_test.go create mode 100644 backend/internal/model/login_auth.go create mode 100644 backend/internal/service/login_auth_test.go create mode 100644 backend/internal/service/login_dcauth_service.go create mode 100644 backend/internal/service/login_sms_service.go create mode 100644 backend/internal/service/merchant_member_security_test.go diff --git a/.env.example b/.env.example index d0462e8..9d83a10 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,18 @@ DELIVERY_LINK_TTL_MINUTES=0 FULFILLMENT_PROCESSING_TIMEOUT_MINUTES=30 FULFILLMENT_TIMEOUT_SCAN_INTERVAL_SECONDS=60 +# 登录短信渠道(用户名 + 密码 + 短信验证码 + DCAuth v2 设备签名) +# mock: 只把验证码写入后端日志,不做每日限额,适合本地开发 +# aliyun: 通过 Dysmsapi RPC 签名调用真实短信 +WORKER_SMS_PROVIDER=mock +WORKER_SMS_ACCESS_KEY_ID= +WORKER_SMS_ACCESS_KEY_SECRET= +WORKER_SMS_SIGN_NAME= +WORKER_SMS_TEMPLATE_CODE= +WORKER_SMS_CODE_TTL_SECONDS=300 +WORKER_SMS_COOLDOWN_SECONDS=60 +WORKER_SMS_DAILY_LIMIT=10 + # 乐玩开放平台配置 LEWAN_BASE_URL=https://shop.cloudtentacles.com LEWAN_APPLICATION_ID= diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 0003858..259ac85 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -79,7 +79,18 @@ func main() { log.Fatalf("setup secret codec: %v", err) } tenantSvc := service.NewTenantService(db) - authSvc := service.NewAuthService(db, jm, tenantSvc) + smsSvc := service.NewLoginSMSService(db, service.SMSConfig{ + Provider: cfg.SMSProvider, + AccessKeyID: cfg.SMSAccessKeyID, + AccessKeySecret: cfg.SMSAccessKeySecret, + SignName: cfg.SMSSignName, + TemplateCode: cfg.SMSTemplateCode, + CodeTTLSeconds: cfg.SMSCodeTTLSeconds, + CooldownSeconds: cfg.SMSCooldownSeconds, + DailyLimit: cfg.SMSDailyLimit, + }) + deviceSvc := service.NewDCAuthService(db) + authSvc := service.NewAuthService(db, jm, tenantSvc, service.WithLoginSecurity(smsSvc, deviceSvc)) userSvc := service.NewUserService(db, tenantSvc) callbackSvc := service.NewCallbackService(db, codec, service.WithCallbackConfig(service.CallbackConfig{ MaxAttempts: cfg.CallbackMaxAttempts, @@ -127,6 +138,7 @@ func main() { h := &router.Handlers{ Auth: handler.NewAuthHandler(authSvc), + DCAuth: handler.NewDCAuthHandler(deviceSvc), Dashboard: handler.NewDashboardHandler(fulfillmentSvc), Delivery: handler.NewDeliveryHandler(deliverySvc), User: handler.NewUserHandler(userSvc), @@ -176,6 +188,7 @@ func main() { if cfg.LogFile != "" { applog.Infof("日志文件: %s (同时输出控制台)", cfg.LogFile) } + applog.Infof("登录短信渠道: %s", cfg.SMSProvider) if err := r.Run(addr); err != nil { log.Fatalf("server: %v", err) } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index d6e8f02..509294d 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -57,6 +57,16 @@ type Config struct { // 生产前后端同域(Caddy 统一入口)时无需配置;仅在前后端分离部署时设置。 CORSAllowedOrigins []string + // 登录短信渠道(WORKER_SMS_*)。provider=mock 时只写日志,不做每日限额。 + SMSProvider string + SMSAccessKeyID string + SMSAccessKeySecret string + SMSSignName string + SMSTemplateCode string + SMSCodeTTLSeconds int + SMSCooldownSeconds int + SMSDailyLimit int + // 乐玩开放平台配置 LewanBaseURL string LewanApplicationID string @@ -96,6 +106,14 @@ func Load() *Config { CallbackRetryScheduleSeconds: getEnvIntList("CALLBACK_RETRY_SCHEDULE", nil), UploadDir: getEnv("UPLOAD_DIR", "data/uploads"), CORSAllowedOrigins: getEnvList("CORS_ALLOWED_ORIGINS", nil), + SMSProvider: getEnv("WORKER_SMS_PROVIDER", "mock"), + SMSAccessKeyID: getEnv("WORKER_SMS_ACCESS_KEY_ID", ""), + SMSAccessKeySecret: getEnv("WORKER_SMS_ACCESS_KEY_SECRET", ""), + SMSSignName: getEnv("WORKER_SMS_SIGN_NAME", ""), + SMSTemplateCode: getEnv("WORKER_SMS_TEMPLATE_CODE", ""), + SMSCodeTTLSeconds: getEnvInt("WORKER_SMS_CODE_TTL_SECONDS", 300), + SMSCooldownSeconds: getEnvInt("WORKER_SMS_COOLDOWN_SECONDS", 60), + SMSDailyLimit: getEnvInt("WORKER_SMS_DAILY_LIMIT", 10), LewanBaseURL: getEnv("LEWAN_BASE_URL", "https://shop.cloudtentacles.com"), LewanApplicationID: getEnv("LEWAN_APPLICATION_ID", ""), LewanAccessKey: getEnv("LEWAN_ACCESS_KEY", ""), diff --git a/backend/internal/database/migrations/014_login_sms_device_auth.sql b/backend/internal/database/migrations/014_login_sms_device_auth.sql new file mode 100644 index 0000000..14c07b2 --- /dev/null +++ b/backend/internal/database/migrations/014_login_sms_device_auth.sql @@ -0,0 +1,98 @@ +-- ============================================================================ +-- 014_login_sms_device_auth.sql +-- 用户名 + 密码 + 短信验证码 + DCAuth v2 设备签名登录。 +-- 未绑定手机号的历史账号仍可使用原密码登录。 +-- ============================================================================ + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS phone VARCHAR(32) NOT NULL DEFAULT ''; +ALTER TABLE users + ADD COLUMN IF NOT EXISTS phone_bound_at TIMESTAMPTZ; +ALTER TABLE users + ADD COLUMN IF NOT EXISTS phone_bound_by BIGINT; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_users_phone_bound_by' + ) THEN + ALTER TABLE users + ADD CONSTRAINT fk_users_phone_bound_by + FOREIGN KEY (phone_bound_by) REFERENCES users(id) ON DELETE SET NULL; + END IF; +END $$; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_phone + ON users(phone) + WHERE phone <> ''; + +CREATE TABLE IF NOT EXISTS login_sms_codes ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + phone VARCHAR(32) NOT NULL, + purpose VARCHAR(32) NOT NULL DEFAULT 'login', + code_hash VARCHAR(64) NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ, + failed_attempt_count INTEGER NOT NULL DEFAULT 0, + request_ip VARCHAR(64) NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_login_sms_codes_user_created + ON login_sms_codes(user_id, purpose, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_login_sms_codes_phone_created + ON login_sms_codes(phone, created_at DESC); + +CREATE TABLE IF NOT EXISTS user_devices ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + device_id VARCHAR(128) NOT NULL, + public_key VARCHAR(128) NOT NULL, + client_version VARCHAR(64) NOT NULL DEFAULT '', + status VARCHAR(32) NOT NULL DEFAULT 'active', + bound_at TIMESTAMPTZ NOT NULL, + last_used_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + revoked_by BIGINT REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_user_devices_active_user + ON user_devices(user_id) + WHERE status = 'active'; +CREATE INDEX IF NOT EXISTS idx_user_devices_device_id + ON user_devices(device_id); + +CREATE TABLE IF NOT EXISTS device_challenges ( + id BIGSERIAL PRIMARY KEY, + challenge_id VARCHAR(128) NOT NULL UNIQUE, + app VARCHAR(64) NOT NULL, + purpose VARCHAR(32) NOT NULL DEFAULT 'login', + challenge VARCHAR(512) NOT NULL, + poll_token_hash VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'pending', + user_id BIGINT REFERENCES users(id) ON DELETE SET NULL, + device_id VARCHAR(128) NOT NULL DEFAULT '', + public_key VARCHAR(128) NOT NULL DEFAULT '', + client_version VARCHAR(64) NOT NULL DEFAULT '', + device_ticket_hash VARCHAR(64) NOT NULL DEFAULT '', + expires_at TIMESTAMPTZ NOT NULL, + authorized_at TIMESTAMPTZ, + ticket_issued_at TIMESTAMPTZ, + consumed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_device_challenges_expires + ON device_challenges(expires_at); +CREATE INDEX IF NOT EXISTS idx_device_challenges_user + ON device_challenges(user_id, created_at DESC); + +COMMENT ON COLUMN users.phone IS '账号绑定手机号,用于登录短信验证码'; +COMMENT ON COLUMN users.phone_bound_by IS '执行手机号绑定的管理员用户 ID'; +COMMENT ON TABLE login_sms_codes IS '登录短信验证码(哈希存储、一次性消费)'; +COMMENT ON TABLE user_devices IS '账号绑定的 DCAuth 设备(公钥指纹)'; +COMMENT ON TABLE device_challenges IS 'DCAuth v2 设备认证 challenge 与一次性 ticket'; diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index 613386d..b1b5b55 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -29,12 +29,85 @@ func (h *AuthHandler) Login(c *gin.Context) { } result, err := h.svc.Login(req.Username, req.Password) if err != nil { - response.BadRequest(c, err.Error()) + writeServiceError(c, err, "用户名或密码错误") return } response.OK(c, result) } +type sendSMSCodeReq struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +func (h *AuthHandler) SendLoginSMSCode(c *gin.Context) { + var req sendSMSCodeReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "请输入用户名和密码") + return + } + result, err := h.svc.SendLoginSMSCode(req.Username, req.Password, c.ClientIP()) + if err != nil { + writeServiceError(c, err, "验证码发送失败") + return + } + response.OK(c, result) +} + +type prepareLoginReq struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` + SMSCode string `json:"smsCode" binding:"required"` + APIBase string `json:"apiBase"` +} + +func (h *AuthHandler) PrepareLogin(c *gin.Context) { + var req prepareLoginReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "请填写用户名、密码和短信验证码") + return + } + apiBase := req.APIBase + if apiBase == "" { + apiBase = c.GetHeader("Origin") + } + prepared, err := h.svc.PrepareLogin(req.Username, req.Password, req.SMSCode, apiBase) + if err != nil { + writeServiceError(c, err, "安全登录准备失败") + return + } + response.OK(c, prepared) +} + +type completeLoginReq struct { + ChallengeID string `json:"challengeId" binding:"required"` + PollToken string `json:"pollToken" binding:"required"` + DeviceTicket string `json:"deviceTicket" binding:"required"` +} + +func (h *AuthHandler) CompleteLogin(c *gin.Context) { + var req completeLoginReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "缺少设备认证票据") + return + } + result, err := h.svc.CompleteLogin(req.ChallengeID, req.PollToken, req.DeviceTicket) + if err != nil { + writeServiceError(c, err, "安全登录失败") + return + } + response.OK(c, result) +} + +// writeServiceError 保留 AuthError 的 HTTP 状态码;其他错误统一 400。 +func writeServiceError(c *gin.Context, err error, fallback string) { + if authErr, ok := service.AsAuthError(err); ok { + response.Fail(c, authErr.Status, authErr.Status, authErr.Message) + return + } + response.BadRequest(c, err.Error()) +} + func (h *AuthHandler) Profile(c *gin.Context) { user, err := h.svc.GetProfile(middleware.GetUserID(c)) if err != nil { diff --git a/backend/internal/handler/dcauth.go b/backend/internal/handler/dcauth.go new file mode 100644 index 0000000..3f19773 --- /dev/null +++ b/backend/internal/handler/dcauth.go @@ -0,0 +1,68 @@ +package handler + +import ( + "net/http" + + "affiliate_dash/internal/pkg/response" + "affiliate_dash/internal/service" + + "github.com/gin-gonic/gin" +) + +// DCAuthHandler 暴露 DCAuth 2.0.0 协议端点。 +type DCAuthHandler struct { + svc *service.DCAuthService +} + +func NewDCAuthHandler(svc *service.DCAuthService) *DCAuthHandler { + return &DCAuthHandler{svc: svc} +} + +// Authorize 接收 DCAuth 客户端签名并推进 challenge 到 authorized。 +func (h *DCAuthHandler) Authorize(c *gin.Context) { + if h == nil || h.svc == nil { + writeDCAuthError(c, newDCAuthUnavailable()) + return + } + var req service.DCAuthorizeRequest + if err := c.ShouldBindJSON(&req); err != nil { + writeDCAuthError(c, &service.AuthError{Status: http.StatusBadRequest, Code: "payload_invalid", Message: "设备认证参数无效"}) + return + } + if err := h.svc.Authorize(req); err != nil { + writeDCAuthError(c, err) + return + } + response.OK(c, gin.H{"status": "authorized"}) +} + +// Status 轮询 challenge 状态;authorized 时签发一次性 deviceTicket。 +func (h *DCAuthHandler) Status(c *gin.Context) { + if h == nil || h.svc == nil { + writeDCAuthError(c, newDCAuthUnavailable()) + return + } + pollToken := c.GetHeader("X-Poll-Token") + if pollToken == "" { + pollToken = c.Query("pollToken") + } + status, err := h.svc.ChallengeStatus(c.Param("challengeId"), pollToken) + if err != nil { + writeDCAuthError(c, err) + return + } + response.OK(c, status) +} + +func newDCAuthUnavailable() *service.AuthError { + return &service.AuthError{Status: http.StatusServiceUnavailable, Code: "device_auth_unavailable", Message: "设备认证服务未配置"} +} + +// writeDCAuthError 使用协议约定的字符串 error code,便于 DCAuth 客户端识别。 +func writeDCAuthError(c *gin.Context, err error) { + if authErr, ok := service.AsAuthError(err); ok { + c.JSON(authErr.Status, gin.H{"code": authErr.Code, "message": authErr.Message}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"code": "internal_error", "message": "设备认证服务异常"}) +} diff --git a/backend/internal/handler/dcauth_test.go b/backend/internal/handler/dcauth_test.go new file mode 100644 index 0000000..ea6fd9a --- /dev/null +++ b/backend/internal/handler/dcauth_test.go @@ -0,0 +1,50 @@ +package handler + +import ( + "encoding/json" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "affiliate_dash/internal/service" + + "github.com/gin-gonic/gin" +) + +func TestDCAuthAuthorizeOldClientVersionReturns426(t *testing.T) { + gin.SetMode(gin.TestMode) + h := NewDCAuthHandler(service.NewDCAuthService(nil)) + r := gin.New() + r.POST("/api/dcauth/authorize", h.Authorize) + + body := `{ + "app":"affiliate_dash", + "challengeId":"challenge-id", + "challenge":"challenge-value", + "deviceId":"D1-ABCDEFGH", + "publicKey":"YWJj", + "signature":"ZGVm", + "timestamp":` + strconv.FormatInt(time.Now().Unix(), 10) + `, + "clientVersion":"1.9.9" + }` + req := httptest.NewRequest("POST", "/api/dcauth/authorize", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != 426 { + t.Fatalf("expected 426, got %d: %s", w.Code, w.Body.String()) + } + var payload struct { + Code string `json:"code"` + Message string `json:"message"` + } + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode body: %v", err) + } + if payload.Code != "client_outdated" { + t.Fatalf("expected client_outdated, got %q", payload.Code) + } +} diff --git a/backend/internal/handler/merchant_member.go b/backend/internal/handler/merchant_member.go index a47963a..0837245 100644 --- a/backend/internal/handler/merchant_member.go +++ b/backend/internal/handler/merchant_member.go @@ -50,9 +50,12 @@ func (h *MerchantHandler) AddCurrentMerchantMember(c *gin.Context) { } type updateMemberReq struct { - Role string `json:"role" binding:"required"` - Status int `json:"status"` - IsDefault bool `json:"is_default"` + Username string `json:"username"` + Password string `json:"password"` + Nickname *string `json:"nickname"` + Role string `json:"role" binding:"required"` + Status int `json:"status"` + IsDefault bool `json:"is_default"` } func (h *MerchantHandler) UpdateCurrentMerchantMember(c *gin.Context) { @@ -63,6 +66,7 @@ func (h *MerchantHandler) UpdateCurrentMerchantMember(c *gin.Context) { return } member, err := h.merchantSvc.UpdateMember(middleware.GetMerchantID(c), uint(memberID), service.UpdateMemberInput{ + Username: req.Username, Password: req.Password, Nickname: req.Nickname, Role: req.Role, Status: req.Status, IsDefault: req.IsDefault, }, middleware.GetUserID(c)) if err != nil { @@ -132,3 +136,42 @@ func (h *MerchantHandler) UpdateRole(c *gin.Context) { } response.OK(c, role) } + +type bindMemberPhoneReq struct { + Phone string `json:"phone" binding:"required"` +} + +// BindCurrentMerchantMemberPhone 商户管理员为当前商户成员绑定/修改登录手机号。 +func (h *MerchantHandler) BindCurrentMerchantMemberPhone(c *gin.Context) { + memberID, _ := strconv.ParseUint(c.Param("id"), 10, 64) + var req bindMemberPhoneReq + if memberID == 0 || c.ShouldBindJSON(&req) != nil { + response.BadRequest(c, "请输入手机号") + return + } + user, err := h.merchantSvc.SetMemberPhone( + middleware.GetMerchantID(c), uint(memberID), req.Phone, middleware.GetUserID(c), + ) + if err != nil { + writeServiceError(c, err, "手机号绑定失败") + return + } + response.OK(c, user) +} + +// ResetCurrentMerchantMemberDevice 重置当前商户成员绑定的 DCAuth 设备。 +func (h *MerchantHandler) ResetCurrentMerchantMemberDevice(c *gin.Context) { + memberID, _ := strconv.ParseUint(c.Param("id"), 10, 64) + if memberID == 0 { + response.BadRequest(c, "参数错误") + return + } + reset, err := h.merchantSvc.ResetMemberDevice( + middleware.GetMerchantID(c), uint(memberID), middleware.GetUserID(c), + ) + if err != nil { + writeServiceError(c, err, "设备重置失败") + return + } + response.OK(c, gin.H{"reset": reset}) +} diff --git a/backend/internal/handler/user.go b/backend/internal/handler/user.go index 091ccef..203b733 100644 --- a/backend/internal/handler/user.go +++ b/backend/internal/handler/user.go @@ -104,3 +104,34 @@ func (h *UserHandler) DeleteMerchantAccount(c *gin.Context) { } response.OK(c, nil) } + +type bindUserPhoneReq struct { + Phone string `json:"phone" binding:"required"` +} + +// BindPlatformUserPhone 管理员为用户绑定/修改登录手机号。 +func (h *UserHandler) BindPlatformUserPhone(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + var req bindUserPhoneReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "请输入手机号") + return + } + user, err := h.svc.BindUserPhone(uint(id), req.Phone, middleware.GetUserID(c)) + if err != nil { + writeServiceError(c, err, "手机号绑定失败") + return + } + response.OK(c, user) +} + +// ResetPlatformUserDevice 管理员重置用户绑定的 DCAuth 设备。 +func (h *UserHandler) ResetPlatformUserDevice(c *gin.Context) { + id, _ := strconv.ParseUint(c.Param("id"), 10, 64) + reset, err := h.svc.ResetUserDevice(uint(id), middleware.GetUserID(c)) + if err != nil { + writeServiceError(c, err, "设备重置失败") + return + } + response.OK(c, gin.H{"reset": reset}) +} diff --git a/backend/internal/model/login_auth.go b/backend/internal/model/login_auth.go new file mode 100644 index 0000000..168c7bf --- /dev/null +++ b/backend/internal/model/login_auth.go @@ -0,0 +1,76 @@ +package model + +import "time" + +const ( + LoginSMSPurposeLogin = "login" + + DeviceChallengeStatusPending = "pending" + DeviceChallengeStatusAuthorized = "authorized" + DeviceChallengeStatusTicketIssued = "ticket_issued" + DeviceChallengeStatusConsumed = "consumed" + DeviceChallengeStatusExpired = "expired" + DeviceChallengeStatusRejected = "rejected" + + UserDeviceStatusActive = "active" + UserDeviceStatusRevoked = "revoked" +) + +// LoginSMSCode 登录短信验证码。验证码只保存 sha256 哈希,且一次性消费。 +type LoginSMSCode struct { + ID uint `gorm:"primarykey" json:"id"` + UserID uint `gorm:"not null;index" json:"user_id"` + Phone string `gorm:"size:32;not null" json:"phone"` + Purpose string `gorm:"size:32;not null;default:login" json:"purpose"` + CodeHash string `gorm:"size:64;not null" json:"-"` + ExpiresAt time.Time `gorm:"not null" json:"expires_at"` + ConsumedAt *time.Time `json:"consumed_at,omitempty"` + FailedAttemptCount int `gorm:"not null;default:0" json:"failed_attempt_count"` + RequestIP string `gorm:"size:64;not null;default:''" json:"request_ip"` + CreatedAt time.Time `json:"created_at"` +} + +func (LoginSMSCode) TableName() string { return "login_sms_codes" } + +// UserDevice DCAuth 设备绑定。每个用户同时只能有一台 active 设备。 +type UserDevice struct { + ID uint `gorm:"primarykey" json:"id"` + UserID uint `gorm:"not null;index" json:"user_id"` + DeviceID string `gorm:"size:128;not null;index" json:"device_id"` + PublicKey string `gorm:"size:128;not null" json:"public_key"` + ClientVersion string `gorm:"size:64;not null;default:''" json:"client_version"` + Status string `gorm:"size:32;not null;default:active" json:"status"` + BoundAt time.Time `gorm:"not null" json:"bound_at"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + RevokedAt *time.Time `json:"revoked_at,omitempty"` + RevokedBy *uint `json:"revoked_by,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (UserDevice) TableName() string { return "user_devices" } + +// DeviceChallenge DCAuth 登录 challenge 与一次性 ticket 的状态机。 +// pending -> authorized -> ticket_issued -> consumed +type DeviceChallenge struct { + ID uint `gorm:"primarykey" json:"id"` + ChallengeID string `gorm:"size:128;not null;uniqueIndex" json:"challenge_id"` + App string `gorm:"size:64;not null" json:"app"` + Purpose string `gorm:"size:32;not null;default:login" json:"purpose"` + Challenge string `gorm:"size:512;not null" json:"challenge"` + PollTokenHash string `gorm:"size:64;not null" json:"-"` + Status string `gorm:"size:32;not null;default:pending" json:"status"` + UserID *uint `json:"user_id,omitempty"` + DeviceID string `gorm:"size:128;not null;default:''" json:"device_id"` + PublicKey string `gorm:"size:128;not null;default:''" json:"public_key"` + ClientVersion string `gorm:"size:64;not null;default:''" json:"client_version"` + DeviceTicketHash string `gorm:"size:64;not null;default:''" json:"-"` + ExpiresAt time.Time `gorm:"not null" json:"expires_at"` + AuthorizedAt *time.Time `json:"authorized_at,omitempty"` + TicketIssuedAt *time.Time `json:"ticket_issued_at,omitempty"` + ConsumedAt *time.Time `json:"consumed_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (DeviceChallenge) TableName() string { return "device_challenges" } diff --git a/backend/internal/model/models.go b/backend/internal/model/models.go index c862170..7e3d40f 100644 --- a/backend/internal/model/models.go +++ b/backend/internal/model/models.go @@ -26,4 +26,15 @@ type User struct { Nickname string `gorm:"size:64" json:"nickname"` Role string `gorm:"size:32;not null;default:merchant" json:"role"` Status int `gorm:"default:1" json:"status"` // 1启用 0禁用 + + // 登录安全:绑定手机号后强制走「密码 + 短信验证码 + DCAuth 设备签名」。 + Phone string `gorm:"size:32;not null;default:''" json:"phone"` + PhoneBoundAt *time.Time `json:"phone_bound_at,omitempty"` + PhoneBoundBy *uint `json:"phone_bound_by,omitempty"` + + // 以下字段只用于管理端列表展示,不落库到 users 表。 + PhoneMasked string `gorm:"-" json:"phone_masked,omitempty"` + DeviceID string `gorm:"-" json:"device_id,omitempty"` + DeviceIDMasked string `gorm:"-" json:"device_id_masked,omitempty"` + DeviceBound bool `gorm:"-" json:"device_bound"` } diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 73c9155..59609f3 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -20,6 +20,7 @@ import ( type Handlers struct { Auth *handler.AuthHandler + DCAuth *handler.DCAuthHandler Dashboard *handler.DashboardHandler Delivery *handler.DeliveryHandler User *handler.UserHandler @@ -153,7 +154,7 @@ func Setup(h *Handlers) *gin.Engine { r.Use(cors.New(cors.Config{ AllowOrigins: corsOrigins(h), AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, - AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-Merchant-ID", "X-App-Key", "X-Api-Key", "X-Timestamp", "X-Nonce", "X-Sign", "X-Request-ID"}, + AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-Merchant-ID", "X-App-Key", "X-Api-Key", "X-Timestamp", "X-Nonce", "X-Sign", "X-Request-ID", "X-Poll-Token"}, ExposeHeaders: []string{"Content-Length", "X-Request-ID"}, AllowCredentials: true, })) @@ -170,6 +171,15 @@ func Setup(h *Handlers) *gin.Engine { api := r.Group("/api") { api.POST("/auth/login", h.Auth.Login) + api.POST("/auth/login/sms-code", h.Auth.SendLoginSMSCode) + api.POST("/auth/login/prepare", h.Auth.PrepareLogin) + api.POST("/auth/login/complete", h.Auth.CompleteLogin) + + // DCAuth 2.0.0 客户端协议端点(客户端硬编码 /api/dcauth/authorize)。 + if h.DCAuth != nil { + api.POST("/dcauth/authorize", h.DCAuth.Authorize) + api.GET("/dcauth/challenge/:challengeId/status", h.DCAuth.Status) + } // 乐玩开放平台 Webhook 回调 if h.LewanWebhook != nil { @@ -235,6 +245,9 @@ func Setup(h *Handlers) *gin.Engine { platform.POST("/platform/admins", h.User.CreatePlatformAdmin) platform.PATCH("/platform/admins/:id", h.User.UpdatePlatformAdmin) platform.DELETE("/platform/admins/:id", h.User.DeletePlatformAdmin) + // 登录安全:管理端绑定手机号与重置 DCAuth 设备。 + platform.POST("/admin/users/:id/phone", h.User.BindPlatformUserPhone) + platform.POST("/admin/users/:id/device/reset", h.User.ResetPlatformUserDevice) platform.GET("/platform/merchants", h.Merchant.ListPlatformMerchants) // 不分页的精简商户列表,供筛选下拉使用(分页接口的 size 上限是 100) platform.GET("/platform/merchant-options", h.Merchant.ListMerchantOptions) @@ -330,6 +343,8 @@ func Setup(h *Handlers) *gin.Engine { merchant.POST("/members", middleware.RequireMerchantPermissions(h.Tenant, model.PermissionMembersManage), h.Merchant.AddCurrentMerchantMember) merchant.PATCH("/members/:id", middleware.RequireMerchantPermissions(h.Tenant, model.PermissionMembersManage), h.Merchant.UpdateCurrentMerchantMember) merchant.DELETE("/members/:id", middleware.RequireMerchantPermissions(h.Tenant, model.PermissionMembersManage), h.Merchant.RemoveCurrentMerchantMember) + merchant.POST("/members/:id/phone", middleware.RequireMerchantPermissions(h.Tenant, model.PermissionMembersManage), h.Merchant.BindCurrentMerchantMemberPhone) + merchant.POST("/members/:id/device/reset", middleware.RequireMerchantPermissions(h.Tenant, model.PermissionMembersManage), h.Merchant.ResetCurrentMerchantMemberDevice) merchant.GET("/roles", middleware.RequireMerchantPermissions(h.Tenant, model.PermissionMembersManage), h.Merchant.ListRoles) merchant.POST("/roles", middleware.RequireMerchantPermissions(h.Tenant, model.PermissionMembersManage), h.Merchant.CreateRole) merchant.PATCH("/roles/:id", middleware.RequireMerchantPermissions(h.Tenant, model.PermissionMembersManage), h.Merchant.UpdateRole) diff --git a/backend/internal/service/auth.go b/backend/internal/service/auth.go index 113fc4f..1a0bc1a 100644 --- a/backend/internal/service/auth.go +++ b/backend/internal/service/auth.go @@ -15,10 +15,28 @@ type AuthService struct { db *gorm.DB jwt *jwt.Manager tenant *TenantService + sms *LoginSMSService + device *DCAuthService } -func NewAuthService(db *gorm.DB, jm *jwt.Manager, tenant *TenantService) *AuthService { - return &AuthService{db: db, jwt: jm, tenant: tenant} +type AuthServiceOption func(*AuthService) + +// WithLoginSecurity 注入短信与设备认证依赖;未注入时仅保留旧密码登录。 +func WithLoginSecurity(sms *LoginSMSService, device *DCAuthService) AuthServiceOption { + return func(s *AuthService) { + s.sms = sms + s.device = device + } +} + +func NewAuthService(db *gorm.DB, jm *jwt.Manager, tenant *TenantService, opts ...AuthServiceOption) *AuthService { + svc := &AuthService{db: db, jwt: jm, tenant: tenant} + for _, opt := range opts { + if opt != nil { + opt(svc) + } + } + return svc } type LoginResult struct { @@ -34,24 +52,115 @@ type UpdateCurrentAccountInput struct { } func (s *AuthService) Login(username, password string) (*LoginResult, error) { + user, err := s.authenticatePassword(username, password) + if err != nil { + return nil, err + } + // 已绑定手机号的账号必须走安全登录,避免短信 + 设备绑定被旧入口绕过。 + if strings.TrimSpace(user.Phone) != "" { + return nil, newAuthError(403, "device_auth_required", "该账号已启用安全登录,请使用账号密码 + 短信验证码 + 本机验证") + } + return s.issueLogin(user) +} + +// authenticatePassword 统一处理账号状态与 bcrypt 校验。 +func (s *AuthService) authenticatePassword(username, password string) (*model.User, error) { + username = strings.TrimSpace(username) + if username == "" || password == "" { + return nil, newAuthError(400, "credentials_required", "请输入用户名和密码") + } var user model.User if err := s.db.Where("username = ?", username).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, errors.New("用户名或密码错误") + return nil, newAuthError(401, "login_failed", "用户名或密码错误") } return nil, err } if user.Status != 1 { - return nil, errors.New("账号已禁用") + return nil, newAuthError(403, "account_disabled", "账号已禁用") } if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { - return nil, errors.New("用户名或密码错误") + return nil, newAuthError(401, "login_failed", "用户名或密码错误") } + return &user, nil +} + +func (s *AuthService) issueLogin(user *model.User) (*LoginResult, error) { token, err := s.jwt.Generate(user.ID, user.Username, user.Role) if err != nil { return nil, err } - return &LoginResult{Token: token, User: &user}, nil + return &LoginResult{Token: token, User: user}, nil +} + +// SendLoginSMSCode 校验密码后向账号绑定手机号发送验证码。 +func (s *AuthService) SendLoginSMSCode(username, password, requestIP string) (*SendLoginCodeResult, error) { + if s.sms == nil { + return nil, newAuthError(503, "sms_unavailable", "短信服务未配置") + } + user, err := s.authenticatePassword(username, password) + if err != nil { + return nil, err + } + if strings.TrimSpace(user.Phone) == "" { + return nil, newAuthError(409, "phone_unbound", "该账号尚未绑定手机号,请联系管理员") + } + return s.sms.SendLoginCode(user, requestIP) +} + +// PrepareLogin 校验密码与短信验证码后创建 DCAuth challenge。 +func (s *AuthService) PrepareLogin(username, password, smsCode, apiBase string) (*PreparedDeviceLogin, error) { + if s.sms == nil || s.device == nil { + return nil, newAuthError(503, "device_auth_unavailable", "安全登录服务未配置") + } + user, err := s.authenticatePassword(username, password) + if err != nil { + return nil, err + } + if strings.TrimSpace(user.Phone) == "" { + return nil, newAuthError(409, "phone_unbound", "该账号尚未绑定手机号,请联系管理员") + } + if err := s.sms.VerifyLoginCode(user.ID, smsCode); err != nil { + return nil, err + } + prepared, err := s.device.CreateChallenge(user.ID, apiBase) + if err != nil { + return nil, err + } + prepared.PhoneMasked = MaskPhone(user.Phone) + return prepared, nil +} + +// CompleteLogin 消费一次性 ticket、校验设备绑定并复用现有 JWT 签发会话。 +func (s *AuthService) CompleteLogin(challengeID, pollToken, deviceTicket string) (*LoginResult, error) { + if s.device == nil { + return nil, newAuthError(503, "device_auth_unavailable", "安全登录服务未配置") + } + challenge, err := s.device.ConsumeTicket(challengeID, pollToken, deviceTicket) + if err != nil { + return nil, err + } + if challenge.App != DCAuthApp || challenge.Purpose != DCAuthPurposeLogin || + challenge.DeviceID == "" || challenge.PublicKey == "" { + return nil, newAuthError(401, "ticket_invalid", "设备认证票据无效或已使用,请重新登录") + } + if challenge.UserID == nil { + return nil, newAuthError(401, "auth_user_invalid", "登录账号已不可用,请重新登录") + } + var user model.User + if err := s.db.First(&user, *challenge.UserID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, newAuthError(401, "auth_user_invalid", "登录账号已不可用,请重新登录") + } + return nil, err + } + if user.Status != 1 { + return nil, newAuthError(401, "auth_user_invalid", "登录账号已不可用,请重新登录") + } + if err := s.device.EnsureDeviceBinding(user.ID, challenge); err != nil { + return nil, err + } + return s.issueLogin(&user) } func (s *AuthService) GetProfile(userID uint) (*model.User, error) { diff --git a/backend/internal/service/login_auth_test.go b/backend/internal/service/login_auth_test.go new file mode 100644 index 0000000..ded5672 --- /dev/null +++ b/backend/internal/service/login_auth_test.go @@ -0,0 +1,442 @@ +package service + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "os" + "strings" + "testing" + "time" + + "affiliate_dash/internal/model" + "affiliate_dash/internal/pkg/jwt" + "affiliate_dash/internal/testdb" + + "golang.org/x/crypto/bcrypt" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func TestBuildDCAuthCanonicalExact(t *testing.T) { + got := BuildDCAuthCanonical( + "affiliate_dash", + "challenge-id-1", + "challenge-value", + "D1-ABCDEFGH", + 1700000000, + ) + want := "dcauth.v2\naffiliate_dash\nchallenge-id-1\nchallenge-value\nD1-ABCDEFGH\n1700000000" + if got != want { + t.Fatalf("canonical mismatch:\nwant %q\n got %q", want, got) + } +} + +func TestIsSupportedDCAuthClientVersion(t *testing.T) { + cases := []struct { + version string + want bool + }{ + {"2.0.0", true}, + {"2", true}, + {"10.1.2", true}, + {"1.9.9", false}, + {"0.1.0", false}, + {"v2.0.0", false}, + {"", false}, + } + for _, tc := range cases { + if got := IsSupportedDCAuthClientVersion(tc.version); got != tc.want { + t.Errorf("IsSupportedDCAuthClientVersion(%q) = %v, want %v", tc.version, got, tc.want) + } + } +} + +func TestNormalizeAPIBase(t *testing.T) { + cases := []struct { + name string + input string + want string + wantErr bool + }{ + {name: "http origin", input: "http://localhost:15173", want: "http://localhost:15173"}, + {name: "https with trailing slash", input: "https://admin.example.com/", want: "https://admin.example.com"}, + {name: "case normalization", input: "HTTPS://Admin.Example.com", want: "https://admin.example.com"}, + {name: "path rejected", input: "https://admin.example.com/api", wantErr: true}, + {name: "query rejected", input: "https://admin.example.com?x=1", wantErr: true}, + {name: "scheme rejected", input: "ftp://admin.example.com", wantErr: true}, + {name: "empty rejected", input: " ", wantErr: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := NormalizeAPIBase(tc.input) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got %q", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Fatalf("got %q, want %q", got, tc.want) + } + }) + } +} + +func TestDCAuthChallengeSingleConsumption(t *testing.T) { + db := loginAuthTestDB(t) + user := model.User{ + Username: "dcauth-" + strings.ReplaceAll(time.Now().Format("150405.000000"), ".", ""), + PasswordHash: "unused", + Role: model.RoleAdmin, + Status: 1, + } + if err := db.Create(&user).Error; err != nil { + t.Fatalf("create user: %v", err) + } + + svc := NewDCAuthService(db) + prepared, err := svc.CreateChallenge(user.ID, "http://localhost:15173") + if err != nil { + t.Fatalf("create challenge: %v", err) + } + + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + deviceID := "D1-ABCDEFGH" + timestamp := time.Now().Unix() + canonical := BuildDCAuthCanonical(DCAuthApp, prepared.ChallengeID, prepared.Challenge, deviceID, timestamp) + signature := ed25519.Sign(privateKey, []byte(canonical)) + + if err := svc.Authorize(DCAuthorizeRequest{ + App: DCAuthApp, + ChallengeID: prepared.ChallengeID, + Challenge: prepared.Challenge, + DeviceID: deviceID, + PublicKey: base64.RawURLEncoding.EncodeToString(publicKey), + Signature: base64.RawURLEncoding.EncodeToString(signature), + Timestamp: timestamp, + ClientVersion: "2.0.0", + }); err != nil { + t.Fatalf("authorize: %v", err) + } + + status, err := svc.ChallengeStatus(prepared.ChallengeID, prepared.PollToken) + if err != nil { + t.Fatalf("challenge status: %v", err) + } + if status.Status != model.DeviceChallengeStatusAuthorized || status.DeviceTicket == "" { + t.Fatalf("expected authorized ticket, got %+v", status) + } + + consumed, err := svc.ConsumeTicket(prepared.ChallengeID, prepared.PollToken, status.DeviceTicket) + if err != nil { + t.Fatalf("consume ticket: %v", err) + } + if consumed.Status != model.DeviceChallengeStatusConsumed || consumed.DeviceID != deviceID { + t.Fatalf("unexpected consumed challenge: %+v", consumed) + } + + if _, err := svc.ConsumeTicket(prepared.ChallengeID, prepared.PollToken, status.DeviceTicket); err == nil { + t.Fatal("second consumption must fail") + } else if authErr, ok := AsAuthError(err); !ok || authErr.Status != 401 { + t.Fatalf("expected 401 on second consumption, got %v", err) + } +} + +func TestDCAuthBindingMismatchRejected(t *testing.T) { + db := loginAuthTestDB(t) + user := model.User{ + Username: "bind-" + strings.ReplaceAll(time.Now().Format("150405.000000"), ".", ""), + PasswordHash: "unused", + Role: model.RoleAdmin, + Status: 1, + } + if err := db.Create(&user).Error; err != nil { + t.Fatalf("create user: %v", err) + } + boundKey, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate bound key: %v", err) + } + otherKey, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate other key: %v", err) + } + now := time.Now() + if err := db.Create(&model.UserDevice{ + UserID: user.ID, + DeviceID: "D1-BOUNDDEV", + PublicKey: base64.RawURLEncoding.EncodeToString(boundKey), + ClientVersion: "2.0.0", + Status: model.UserDeviceStatusActive, + BoundAt: now, + CreatedAt: now, + UpdatedAt: now, + }).Error; err != nil { + t.Fatalf("create bound device: %v", err) + } + + svc := NewDCAuthService(db) + err = svc.EnsureDeviceBinding(user.ID, &model.DeviceChallenge{ + DeviceID: "D1-OTHERDEV", + PublicKey: base64.RawURLEncoding.EncodeToString(otherKey), + ClientVersion: "2.0.0", + }) + if err == nil { + t.Fatal("device mismatch must be rejected") + } + authErr, ok := AsAuthError(err) + if !ok || authErr.Status != 401 { + t.Fatalf("expected 401 AuthError, got %v", err) + } + if !strings.Contains(authErr.Message, "当前设备与账号绑定设备不一致") { + t.Fatalf("unexpected message: %s", authErr.Message) + } + + // 同一设备与公钥应通过。 + if err := svc.EnsureDeviceBinding(user.ID, &model.DeviceChallenge{ + DeviceID: "D1-BOUNDDEV", + PublicKey: base64.RawURLEncoding.EncodeToString(boundKey), + ClientVersion: "2.0.0", + }); err != nil { + t.Fatalf("matching device should pass: %v", err) + } +} + +func TestSecureLoginPrepareCompleteReusesJWT(t *testing.T) { + db := loginAuthTestDB(t) + password := "pass123456" + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost) + if err != nil { + t.Fatalf("hash password: %v", err) + } + user := model.User{ + Username: "secure-" + strings.ReplaceAll(time.Now().Format("150405.000000"), ".", ""), + PasswordHash: string(hash), + Phone: "13900139000", + Role: model.RoleAdmin, + Status: 1, + } + if err := db.Create(&user).Error; err != nil { + t.Fatalf("create user: %v", err) + } + now := time.Now() + if err := db.Create(&model.LoginSMSCode{ + UserID: user.ID, + Phone: user.Phone, + Purpose: model.LoginSMSPurposeLogin, + CodeHash: HashSMSCode("123456"), + ExpiresAt: now.Add(5 * time.Minute), + CreatedAt: now, + }).Error; err != nil { + t.Fatalf("create sms code: %v", err) + } + + jm := jwt.NewManager("test-jwt-secret") + smsSvc := NewLoginSMSService(db, SMSConfig{Provider: "mock"}) + deviceSvc := NewDCAuthService(db) + authSvc := NewAuthService(db, jm, nil, WithLoginSecurity(smsSvc, deviceSvc)) + + prepared, err := authSvc.PrepareLogin(user.Username, password, "123456", "http://localhost:15173") + if err != nil { + t.Fatalf("prepare login: %v", err) + } + if prepared.App != DCAuthApp || prepared.PhoneMasked != "139****9000" { + t.Fatalf("unexpected prepared result: %+v", prepared) + } + + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + deviceID := "D1-SECUREDEV" + timestamp := time.Now().Unix() + canonical := BuildDCAuthCanonical(DCAuthApp, prepared.ChallengeID, prepared.Challenge, deviceID, timestamp) + if err := deviceSvc.Authorize(DCAuthorizeRequest{ + App: DCAuthApp, + ChallengeID: prepared.ChallengeID, + Challenge: prepared.Challenge, + DeviceID: deviceID, + PublicKey: base64.RawURLEncoding.EncodeToString(publicKey), + Signature: base64.RawURLEncoding.EncodeToString(ed25519.Sign(privateKey, []byte(canonical))), + Timestamp: timestamp, + ClientVersion: "2.0.0", + }); err != nil { + t.Fatalf("authorize: %v", err) + } + status, err := deviceSvc.ChallengeStatus(prepared.ChallengeID, prepared.PollToken) + if err != nil || status.DeviceTicket == "" { + t.Fatalf("challenge status: %+v %v", status, err) + } + + result, err := authSvc.CompleteLogin(prepared.ChallengeID, prepared.PollToken, status.DeviceTicket) + if err != nil { + t.Fatalf("complete login: %v", err) + } + if result.Token == "" || result.User == nil || result.User.ID != user.ID { + t.Fatalf("unexpected login result: %+v", result) + } + claims, err := jm.Parse(result.Token) + if err != nil || claims.UserID != user.ID { + t.Fatalf("issued JWT should be reusable: %+v %v", claims, err) + } + + var device model.UserDevice + if err := db.Where("user_id = ? AND status = ?", user.ID, model.UserDeviceStatusActive).First(&device).Error; err != nil { + t.Fatalf("device should be auto-bound: %v", err) + } + if device.DeviceID != deviceID { + t.Fatalf("unexpected bound device: %+v", device) + } + + if _, err := authSvc.CompleteLogin(prepared.ChallengeID, prepared.PollToken, status.DeviceTicket); err == nil { + t.Fatal("ticket must be single-use") + } + if _, err := authSvc.Login(user.Username, password); err == nil { + t.Fatal("password-only login must be rejected once phone is bound") + } else if authErr, ok := AsAuthError(err); !ok || authErr.Status != 403 { + t.Fatalf("expected 403 for bound account legacy login, got %v", err) + } +} + +func TestBindUserPhoneAndResetDevice(t *testing.T) { + db := loginAuthTestDB(t) + first := model.User{Username: "bindphone-a-" + strings.ReplaceAll(time.Now().Format("150405.000000"), ".", ""), PasswordHash: "x", Role: model.RoleAdmin, Status: 1} + second := model.User{Username: "bindphone-b-" + strings.ReplaceAll(time.Now().Format("150405.000000"), ".", ""), PasswordHash: "x", Role: model.RoleAdmin, Status: 1} + if err := db.Create(&first).Error; err != nil { + t.Fatalf("create first user: %v", err) + } + if err := db.Create(&second).Error; err != nil { + t.Fatalf("create second user: %v", err) + } + actor := first.ID + svc := NewUserService(db, nil) + updated, err := svc.BindUserPhone(first.ID, "13700137000", actor) + if err != nil { + t.Fatalf("bind phone: %v", err) + } + if updated.Phone != "13700137000" || updated.PhoneBoundBy == nil || *updated.PhoneBoundBy != actor { + t.Fatalf("unexpected bound user: %+v", updated) + } + if _, err := svc.BindUserPhone(second.ID, "13700137000", actor); err == nil { + t.Fatal("duplicate phone must be rejected") + } else if authErr, ok := AsAuthError(err); !ok || authErr.Status != 409 { + t.Fatalf("expected 409 duplicate phone, got %v", err) + } + + now := time.Now() + device := model.UserDevice{ + UserID: first.ID, + DeviceID: "D1-RESETDEV", + PublicKey: "test-public-key", + ClientVersion: "2.0.0", + Status: model.UserDeviceStatusActive, + BoundAt: now, + CreatedAt: now, + UpdatedAt: now, + } + if err := db.Create(&device).Error; err != nil { + t.Fatalf("create device: %v", err) + } + reset, err := svc.ResetUserDevice(first.ID, actor) + if err != nil || !reset { + t.Fatalf("reset device: reset=%v err=%v", reset, err) + } + var stored model.UserDevice + if err := db.First(&stored, device.ID).Error; err != nil { + t.Fatalf("reload device: %v", err) + } + if stored.Status != model.UserDeviceStatusRevoked || stored.RevokedAt == nil { + t.Fatalf("device should be revoked: %+v", stored) + } + if reset, err := svc.ResetUserDevice(first.ID, actor); err != nil || reset { + t.Fatalf("second reset should be a no-op: reset=%v err=%v", reset, err) + } +} + +func TestLoginSMSCodeLocksAfterFiveFailures(t *testing.T) { + db := loginAuthTestDB(t) + user := model.User{ + Username: "sms-" + strings.ReplaceAll(time.Now().Format("150405.000000"), ".", ""), + PasswordHash: "unused", + Phone: "13800138000", + Role: model.RoleAdmin, + Status: 1, + } + if err := db.Create(&user).Error; err != nil { + t.Fatalf("create user: %v", err) + } + now := time.Now() + row := model.LoginSMSCode{ + UserID: user.ID, + Phone: user.Phone, + Purpose: model.LoginSMSPurposeLogin, + CodeHash: HashSMSCode("123456"), + ExpiresAt: now.Add(5 * time.Minute), + CreatedAt: now, + } + if err := db.Create(&row).Error; err != nil { + t.Fatalf("create sms code: %v", err) + } + + svc := NewLoginSMSService(db, SMSConfig{}) + for attempt := 1; attempt <= 4; attempt++ { + err := svc.VerifyLoginCode(user.ID, "000000") + authErr, ok := AsAuthError(err) + if !ok || authErr.Code != "sms_code_invalid" { + t.Fatalf("attempt %d: expected sms_code_invalid, got %v", attempt, err) + } + } + err := svc.VerifyLoginCode(user.ID, "000000") + authErr, ok := AsAuthError(err) + if !ok || authErr.Code != "sms_code_locked" { + t.Fatalf("fifth attempt should lock the code, got %v", err) + } + + var stored model.LoginSMSCode + if err := db.First(&stored, row.ID).Error; err != nil { + t.Fatalf("reload sms code: %v", err) + } + if stored.FailedAttemptCount != 5 || stored.ConsumedAt == nil { + t.Fatalf("expected locked row with 5 failures, got %+v", stored) + } + if err := svc.VerifyLoginCode(user.ID, "123456"); err == nil { + t.Fatal("correct code must not work after lock") + } +} + +// loginAuthTestDB 在 PostgreSQL 可用时创建隔离 schema;不可用时跳过集成断言。 +func loginAuthTestDB(t *testing.T) *gorm.DB { + t.Helper() + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + dsn = os.Getenv("DATABASE_URL") + } + if dsn == "" { + dsn = "postgres://affiliate:affiliate_dev_password@127.0.0.1:15432/affiliate_dash?sslmode=disable" + } + probe, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Skipf("跳过数据库集成测试:PostgreSQL 不可用: %v", err) + } + sqlDB, err := probe.DB() + if err != nil { + t.Skipf("跳过数据库集成测试:获取连接失败: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := sqlDB.PingContext(ctx); err != nil { + _ = sqlDB.Close() + t.Skipf("跳过数据库集成测试:PostgreSQL ping 失败: %v", err) + } + _ = sqlDB.Close() + return testdb.New(t) +} diff --git a/backend/internal/service/login_dcauth_service.go b/backend/internal/service/login_dcauth_service.go new file mode 100644 index 0000000..2566a71 --- /dev/null +++ b/backend/internal/service/login_dcauth_service.go @@ -0,0 +1,507 @@ +package service + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "errors" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "affiliate_dash/internal/model" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +const ( + DCAuthApp = "affiliate_dash" + DCAuthPurposeLogin = "login" + DCAuthChallengeTTL = 120 * time.Second + DCAuthMaxClockSkew = 300 * time.Second + DCAuthRateLimitPerMin = 30 + dcAuthDeviceIDPattern = `^D1-[A-Z0-9-]{8,64}$` + dcAuthBase64URLPattern = `^[A-Za-z0-9_-]+$` +) + +var ( + deviceIDRegexp = regexp.MustCompile(dcAuthDeviceIDPattern) + base64URLRegexp = regexp.MustCompile(dcAuthBase64URLPattern) +) + +// AuthError 携带 HTTP 状态码与稳定的错误码,供 handler 转成协议响应。 +type AuthError struct { + Status int + Code string + Message string +} + +func (e *AuthError) Error() string { return e.Message } + +func newAuthError(status int, code, message string) *AuthError { + return &AuthError{Status: status, Code: code, Message: message} +} + +// AsAuthError 把业务错误转换为 AuthError。 +func AsAuthError(err error) (*AuthError, bool) { + var target *AuthError + if errors.As(err, &target) { + return target, true + } + return nil, false +} + +// DCAuthorizeRequest 是 DCAuth 2.0.0 客户端提交的签名请求。 +type DCAuthorizeRequest struct { + App string `json:"app"` + ChallengeID string `json:"challengeId"` + Challenge string `json:"challenge"` + DeviceID string `json:"deviceId"` + PublicKey string `json:"publicKey"` + Signature string `json:"signature"` + Timestamp int64 `json:"timestamp"` + ClientVersion string `json:"clientVersion"` +} + +// PreparedDeviceLogin 是 prepare 阶段返回给前端的 challenge 信息。 +type PreparedDeviceLogin struct { + ChallengeID string `json:"challengeId"` + Challenge string `json:"challenge"` + ExpiresAt time.Time `json:"expiresAt"` + PollToken string `json:"pollToken"` + LaunchURL string `json:"launchUrl"` + PhoneMasked string `json:"phoneMasked"` + App string `json:"app"` +} + +// DeviceChallengeStatus 是轮询接口的响应。 +type DeviceChallengeStatus struct { + Status string `json:"status"` + DeviceTicket string `json:"deviceTicket,omitempty"` + DeviceID string `json:"deviceId,omitempty"` + Message string `json:"message,omitempty"` +} + +type challengeRateLimiter struct { + mu sync.Mutex + buckets map[string]*challengeRateBucket +} + +type challengeRateBucket struct { + windowStart time.Time + count int +} + +func newChallengeRateLimiter() *challengeRateLimiter { + return &challengeRateLimiter{buckets: make(map[string]*challengeRateBucket)} +} + +func (l *challengeRateLimiter) allow(key string, now time.Time) bool { + if l == nil || key == "" { + return true + } + l.mu.Lock() + defer l.mu.Unlock() + if len(l.buckets) > 10000 { + for k, bucket := range l.buckets { + if now.Sub(bucket.windowStart) >= time.Minute { + delete(l.buckets, k) + } + } + } + bucket, ok := l.buckets[key] + if !ok || now.Sub(bucket.windowStart) >= time.Minute { + l.buckets[key] = &challengeRateBucket{windowStart: now, count: 1} + return true + } + if bucket.count >= DCAuthRateLimitPerMin { + return false + } + bucket.count++ + return true +} + +// DCAuthService 负责 challenge 状态机、Ed25519 验签与设备绑定。 +type DCAuthService struct { + db *gorm.DB + limiter *challengeRateLimiter + now func() time.Time +} + +func NewDCAuthService(db *gorm.DB) *DCAuthService { + return &DCAuthService{ + db: db, + limiter: newChallengeRateLimiter(), + now: time.Now, + } +} + +// BuildDCAuthCanonical 生成签名原文(必须与客户端逐字节一致)。 +func BuildDCAuthCanonical(app, challengeID, challenge, deviceID string, timestamp int64) string { + return strings.Join([]string{ + "dcauth.v2", + app, + challengeID, + challenge, + deviceID, + strconv.FormatInt(timestamp, 10), + }, "\n") +} + +// IsSupportedDCAuthClientVersion 判断客户端主版本是否 >= 2。 +func IsSupportedDCAuthClientVersion(clientVersion string) bool { + major := strings.Split(strings.TrimSpace(clientVersion), ".")[0] + value, err := strconv.Atoi(major) + return err == nil && value >= 2 +} + +// NormalizeAPIBase 只接受 scheme://host 形式,防止 launchUrl 被注入路径或查询串。 +func NormalizeAPIBase(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", newAuthError(http.StatusBadRequest, "api_base_invalid", "无法确定 API 地址") + } + parsed, err := url.Parse(raw) + if err != nil || parsed.Host == "" { + return "", newAuthError(http.StatusBadRequest, "api_base_invalid", "API 地址无效") + } + scheme := strings.ToLower(parsed.Scheme) + if scheme != "http" && scheme != "https" { + return "", newAuthError(http.StatusBadRequest, "api_base_invalid", "API 地址必须使用 http 或 https") + } + if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return "", newAuthError(http.StatusBadRequest, "api_base_invalid", "API 地址不允许携带用户信息、查询串或片段") + } + if path := strings.TrimRight(parsed.EscapedPath(), "/"); path != "" { + return "", newAuthError(http.StatusBadRequest, "api_base_invalid", "API 地址必须是不带路径的 origin") + } + return scheme + "://" + strings.ToLower(parsed.Host), nil +} + +// CreateChallenge 创建一次登录 challenge,并返回前端直接唤起 DCAuth 的 launchUrl。 +func (s *DCAuthService) CreateChallenge(userID uint, apiBase string) (*PreparedDeviceLogin, error) { + if s.db == nil { + return nil, newAuthError(http.StatusServiceUnavailable, "device_auth_unavailable", "设备认证服务未配置") + } + origin, err := NormalizeAPIBase(apiBase) + if err != nil { + return nil, err + } + challengeID := uuid.NewString() + challenge, err := randomURLToken(32) + if err != nil { + return nil, err + } + pollToken, err := randomURLToken(32) + if err != nil { + return nil, err + } + now := s.now() + expiresAt := now.Add(DCAuthChallengeTTL) + user := userID + row := &model.DeviceChallenge{ + ChallengeID: challengeID, + App: DCAuthApp, + Purpose: DCAuthPurposeLogin, + Challenge: challenge, + PollTokenHash: hashToken(pollToken), + Status: model.DeviceChallengeStatusPending, + UserID: &user, + ExpiresAt: expiresAt, + } + if err := s.db.Create(row).Error; err != nil { + return nil, newAuthError(http.StatusInternalServerError, "challenge_create_failed", "创建设备认证会话失败,请重试") + } + return &PreparedDeviceLogin{ + ChallengeID: challengeID, + Challenge: challenge, + ExpiresAt: expiresAt, + PollToken: pollToken, + LaunchURL: buildDCAuthLaunchURL(origin, challengeID, challenge), + App: DCAuthApp, + }, nil +} + +// Authorize 校验客户端签名并原子推进 challenge 到 authorized。 +func (s *DCAuthService) Authorize(in DCAuthorizeRequest) error { + app := strings.TrimSpace(in.App) + challengeID := strings.TrimSpace(in.ChallengeID) + challengeValue := strings.TrimSpace(in.Challenge) + deviceID := strings.ToUpper(strings.TrimSpace(in.DeviceID)) + publicKey := strings.TrimSpace(in.PublicKey) + signature := strings.TrimSpace(in.Signature) + clientVersion := strings.TrimSpace(in.ClientVersion) + now := s.now() + + if app != DCAuthApp { + return newAuthError(http.StatusBadRequest, "app_mismatch", "设备认证应用不匹配") + } + if challengeID == "" || challengeValue == "" || deviceID == "" || publicKey == "" || signature == "" { + return newAuthError(http.StatusBadRequest, "payload_invalid", "设备认证参数不完整") + } + if !s.limiter.allow(challengeID, now) { + return newAuthError(http.StatusTooManyRequests, "rate_limited", "设备认证请求过于频繁,请稍后再试") + } + if !IsSupportedDCAuthClientVersion(clientVersion) { + return newAuthError(http.StatusUpgradeRequired, "client_outdated", "DCAuth 客户端版本过低,请升级到 v2.0.0 或更高版本") + } + if !deviceIDRegexp.MatchString(deviceID) { + return newAuthError(http.StatusBadRequest, "device_id_invalid", "设备码格式不正确") + } + if in.Timestamp == 0 || absDuration(now.Sub(time.Unix(in.Timestamp, 0))) > DCAuthMaxClockSkew { + return newAuthError(http.StatusBadRequest, "clock_invalid", "设备时间与服务器偏差过大,请校准系统时间") + } + if s.db == nil { + return newAuthError(http.StatusServiceUnavailable, "device_auth_unavailable", "设备认证服务未配置") + } + + var challenge model.DeviceChallenge + if err := s.db.Where("challenge_id = ?", challengeID).First(&challenge).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return newAuthError(http.StatusNotFound, "challenge_not_found", "设备认证会话不存在") + } + return err + } + if challenge.App != DCAuthApp || challenge.Purpose != DCAuthPurposeLogin { + return newAuthError(http.StatusNotFound, "challenge_not_found", "设备认证会话不存在") + } + if subtle.ConstantTimeCompare([]byte(challenge.Challenge), []byte(challengeValue)) != 1 { + return newAuthError(http.StatusBadRequest, "challenge_mismatch", "设备认证 challenge 不匹配") + } + if challenge.Status != model.DeviceChallengeStatusPending || !challenge.ExpiresAt.After(now) { + return newAuthError(http.StatusConflict, "challenge_expired", "设备认证会话已失效,请重新登录") + } + + publicKeyBytes, err := decodeBase64URL(publicKey) + if err != nil { + return newAuthError(http.StatusBadRequest, "signature_invalid", "设备签名编码无效") + } + signatureBytes, err := decodeBase64URL(signature) + if err != nil { + return newAuthError(http.StatusBadRequest, "signature_invalid", "设备签名编码无效") + } + if len(publicKeyBytes) != ed25519.PublicKeySize || len(signatureBytes) != ed25519.SignatureSize { + return newAuthError(http.StatusBadRequest, "signature_invalid", "设备公钥或签名格式不正确") + } + + canonical := BuildDCAuthCanonical(app, challengeID, challengeValue, deviceID, in.Timestamp) + if !ed25519.Verify(ed25519.PublicKey(publicKeyBytes), []byte(canonical), signatureBytes) { + return newAuthError(http.StatusUnauthorized, "signature_invalid", "设备签名校验失败") + } + + result := s.db.Exec(` + UPDATE device_challenges + SET status = ?, device_id = ?, public_key = ?, client_version = ?, + authorized_at = ?, updated_at = ? + WHERE challenge_id = ? AND app = ? AND purpose = ? + AND status = ? AND expires_at > ?`, + model.DeviceChallengeStatusAuthorized, deviceID, publicKey, clientVersion, + now, now, + challengeID, DCAuthApp, DCAuthPurposeLogin, + model.DeviceChallengeStatusPending, now, + ) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return newAuthError(http.StatusConflict, "challenge_used", "设备认证会话已被使用,请重新登录") + } + return nil +} + +// ChallengeStatus 校验轮询密钥;authorized 状态下原子签发一次性 ticket。 +func (s *DCAuthService) ChallengeStatus(challengeIDValue, pollTokenValue string) (*DeviceChallengeStatus, error) { + challengeID := strings.TrimSpace(challengeIDValue) + pollToken := strings.TrimSpace(pollTokenValue) + if challengeID == "" || pollToken == "" { + return nil, newAuthError(http.StatusBadRequest, "poll_invalid", "缺少设备认证参数") + } + if s.db == nil { + return nil, newAuthError(http.StatusServiceUnavailable, "device_auth_unavailable", "设备认证服务未配置") + } + + var challenge model.DeviceChallenge + if err := s.db.Where("challenge_id = ?", challengeID).First(&challenge).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return &DeviceChallengeStatus{Status: model.DeviceChallengeStatusRejected, Message: "设备认证会话不存在"}, nil + } + return nil, err + } + expectedPollHash := hashToken(pollToken) + if subtle.ConstantTimeCompare([]byte(challenge.PollTokenHash), []byte(expectedPollHash)) != 1 { + return nil, newAuthError(http.StatusForbidden, "poll_forbidden", "设备认证轮询密钥无效") + } + + now := s.now() + if !challenge.ExpiresAt.After(now) && challenge.Status != model.DeviceChallengeStatusConsumed { + return &DeviceChallengeStatus{Status: model.DeviceChallengeStatusExpired, Message: "设备认证会话已过期"}, nil + } + if challenge.Status == model.DeviceChallengeStatusAuthorized { + ticket, err := randomURLToken(32) + if err != nil { + return nil, err + } + result := s.db.Exec(` + UPDATE device_challenges + SET status = ?, device_ticket_hash = ?, ticket_issued_at = ?, updated_at = ? + WHERE challenge_id = ? AND status = ? AND expires_at > ?`, + model.DeviceChallengeStatusTicketIssued, hashToken(ticket), now, now, + challengeID, model.DeviceChallengeStatusAuthorized, now, + ) + if result.Error != nil { + return nil, result.Error + } + if result.RowsAffected == 1 { + return &DeviceChallengeStatus{ + Status: model.DeviceChallengeStatusAuthorized, + DeviceTicket: ticket, + DeviceID: challenge.DeviceID, + }, nil + } + var latest model.DeviceChallenge + if err := s.db.Where("challenge_id = ?", challengeID).First(&latest).Error; err == nil { + return &DeviceChallengeStatus{Status: latest.Status, DeviceID: latest.DeviceID}, nil + } + return &DeviceChallengeStatus{Status: model.DeviceChallengeStatusPending}, nil + } + return &DeviceChallengeStatus{Status: challenge.Status, DeviceID: challenge.DeviceID}, nil +} + +// ConsumeTicket 原子消费 ticket:ticket_issued -> consumed。 +func (s *DCAuthService) ConsumeTicket(challengeID, pollToken, deviceTicket string) (*model.DeviceChallenge, error) { + challengeID = strings.TrimSpace(challengeID) + pollToken = strings.TrimSpace(pollToken) + deviceTicket = strings.TrimSpace(deviceTicket) + if challengeID == "" || pollToken == "" || deviceTicket == "" { + return nil, newAuthError(http.StatusBadRequest, "ticket_required", "缺少设备认证票据") + } + if s.db == nil { + return nil, newAuthError(http.StatusServiceUnavailable, "device_auth_unavailable", "设备认证服务未配置") + } + now := s.now() + var challenge model.DeviceChallenge + result := s.db.Raw(` + UPDATE device_challenges + SET status = ?, consumed_at = ?, updated_at = ? + WHERE challenge_id = ? AND poll_token_hash = ? AND device_ticket_hash = ? + AND status = ? AND expires_at > ? + RETURNING *`, + model.DeviceChallengeStatusConsumed, now, now, + challengeID, hashToken(pollToken), hashToken(deviceTicket), + model.DeviceChallengeStatusTicketIssued, now, + ).Scan(&challenge) + if result.Error != nil { + return nil, result.Error + } + if challenge.ID == 0 { + return nil, newAuthError(http.StatusUnauthorized, "ticket_invalid", "设备认证票据无效或已使用,请重新登录") + } + return &challenge, nil +} + +// EnsureDeviceBinding 校验 active 设备绑定;首次登录时自动绑定当前设备。 +func (s *DCAuthService) EnsureDeviceBinding(userID uint, challenge *model.DeviceChallenge) error { + if s.db == nil { + return newAuthError(http.StatusServiceUnavailable, "device_auth_unavailable", "设备认证服务未配置") + } + if challenge == nil { + return newAuthError(http.StatusUnauthorized, "device_binding_invalid", "设备认证信息无效,请重新登录") + } + now := s.now() + current, err := s.activeDevice(userID) + if err != nil { + return err + } + if current != nil { + return s.verifyAndTouchDevice(current, challenge, now) + } + + result := s.db.Exec(` + INSERT INTO user_devices ( + user_id, device_id, public_key, client_version, status, + bound_at, last_used_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (user_id) WHERE status = 'active' DO NOTHING`, + userID, challenge.DeviceID, challenge.PublicKey, challenge.ClientVersion, + model.UserDeviceStatusActive, now, now, now, now, + ) + if result.Error != nil { + return result.Error + } + bound, err := s.activeDevice(userID) + if err != nil { + return err + } + if bound == nil { + return newAuthError(http.StatusInternalServerError, "device_bind_failed", "绑定本机设备失败,请重试") + } + return s.verifyAndTouchDevice(bound, challenge, now) +} + +func (s *DCAuthService) verifyAndTouchDevice(device *model.UserDevice, challenge *model.DeviceChallenge, now time.Time) error { + if subtle.ConstantTimeCompare([]byte(device.DeviceID), []byte(challenge.DeviceID)) != 1 || + subtle.ConstantTimeCompare([]byte(device.PublicKey), []byte(challenge.PublicKey)) != 1 { + return newAuthError(http.StatusUnauthorized, "device_mismatch", "当前设备与账号绑定设备不一致,请联系管理员重置设备") + } + return s.db.Exec(` + UPDATE user_devices SET last_used_at = ?, updated_at = ? + WHERE id = ? AND status = ?`, + now, now, device.ID, model.UserDeviceStatusActive).Error +} + +func (s *DCAuthService) activeDevice(userID uint) (*model.UserDevice, error) { + var device model.UserDevice + err := s.db.Where("user_id = ? AND status = ?", userID, model.UserDeviceStatusActive). + Order("id DESC").First(&device).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + return &device, nil +} + +func buildDCAuthLaunchURL(apiBase, challengeID, challenge string) string { + query := url.Values{} + query.Set("app", DCAuthApp) + query.Set("challenge_id", challengeID) + query.Set("challenge", challenge) + query.Set("api", apiBase) + return "dcauth://authorize?" + query.Encode() +} + +func decodeBase64URL(value string) ([]byte, error) { + if !base64URLRegexp.MatchString(value) { + return nil, errors.New("invalid base64url") + } + return base64.RawURLEncoding.Strict().DecodeString(value) +} + +func randomURLToken(size int) (string, error) { + raw := make([]byte, size) + if _, err := rand.Read(raw); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(raw), nil +} + +func hashToken(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} + +func absDuration(value time.Duration) time.Duration { + if value < 0 { + return -value + } + return value +} diff --git a/backend/internal/service/login_sms_service.go b/backend/internal/service/login_sms_service.go new file mode 100644 index 0000000..298bd31 --- /dev/null +++ b/backend/internal/service/login_sms_service.go @@ -0,0 +1,349 @@ +package service + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha1" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "sort" + "strings" + "time" + + "affiliate_dash/internal/model" + "affiliate_dash/internal/pkg/applog" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +const ( + smsPurposeLogin = model.LoginSMSPurposeLogin + smsMaxVerifyAttempts = 5 + smsDefaultTTLSeconds = 300 + smsDefaultCooldown = 60 + smsDefaultDailyLimit = 10 + aliyunDysmsEndpoint = "https://dysmsapi.aliyuncs.com/" + aliyunDysmsAPIVersion = "2017-05-25" +) + +var phoneRegexp = regexp.MustCompile(`^1[3-9]\d{9}$`) + +// SMSConfig 对应 WORKER_SMS_* 环境变量。 +type SMSConfig struct { + Provider string + AccessKeyID string + AccessKeySecret string + SignName string + TemplateCode string + CodeTTLSeconds int + CooldownSeconds int + DailyLimit int +} + +// SendLoginCodeResult 是发送验证码接口的返回。 +type SendLoginCodeResult struct { + Sent bool `json:"sent"` + PhoneMasked string `json:"phoneMasked"` + ExpiresInSeconds int `json:"expiresInSeconds"` +} + +// LoginSMSService 负责短信验证码的签发、校验与渠道调用。 +type LoginSMSService struct { + db *gorm.DB + cfg SMSConfig + now func() time.Time +} + +func NewLoginSMSService(db *gorm.DB, cfg SMSConfig) *LoginSMSService { + if cfg.CodeTTLSeconds < 60 { + cfg.CodeTTLSeconds = smsDefaultTTLSeconds + } + if cfg.CooldownSeconds < 10 { + cfg.CooldownSeconds = smsDefaultCooldown + } + if cfg.DailyLimit < 1 { + cfg.DailyLimit = smsDefaultDailyLimit + } + return &LoginSMSService{db: db, cfg: cfg, now: time.Now} +} + +func (s *LoginSMSService) effectiveProvider() string { + provider := strings.ToLower(strings.TrimSpace(s.cfg.Provider)) + if provider == "" { + provider = "mock" + } + if provider != "aliyun" { + return "mock" + } + if strings.TrimSpace(s.cfg.AccessKeyID) == "" || + strings.TrimSpace(s.cfg.AccessKeySecret) == "" || + strings.TrimSpace(s.cfg.SignName) == "" || + strings.TrimSpace(s.cfg.TemplateCode) == "" { + applog.Warnf("[SMS] 阿里云短信配置不完整,已降级为 Mock 模式") + return "mock" + } + return "aliyun" +} + +// SendLoginCode 生成 6 位验证码并发送到用户已绑定手机号。 +func (s *LoginSMSService) SendLoginCode(user *model.User, requestIP string) (*SendLoginCodeResult, error) { + if s.db == nil { + return nil, newAuthError(http.StatusServiceUnavailable, "sms_unavailable", "短信服务未配置") + } + if user == nil { + return nil, newAuthError(http.StatusUnauthorized, "login_failed", "用户名或密码错误") + } + phone, err := NormalizePhone(user.Phone) + if err != nil { + return nil, err + } + now := s.now() + cooldown := time.Duration(s.cfg.CooldownSeconds) * time.Second + + var latest model.LoginSMSCode + if err := s.db.Where("user_id = ? AND purpose = ?", user.ID, smsPurposeLogin). + Order("id DESC").First(&latest).Error; err == nil { + if latest.ConsumedAt == nil && now.Sub(latest.CreatedAt) < cooldown { + return nil, newAuthError(http.StatusTooManyRequests, "sms_cooldown", "验证码发送过于频繁,请稍后再试") + } + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + + provider := s.effectiveProvider() + if provider != "mock" { + start, end := chinaDayRange(now) + var count int64 + if err := s.db.Model(&model.LoginSMSCode{}). + Where("user_id = ? AND purpose = ? AND created_at >= ? AND created_at < ?", user.ID, smsPurposeLogin, start, end). + Count(&count).Error; err != nil { + return nil, err + } + if count >= int64(s.cfg.DailyLimit) { + return nil, newAuthError(http.StatusTooManyRequests, "sms_daily_limit", "今日验证码发送次数已达上限,请明天再试") + } + } + + code, err := generateSMSCode() + if err != nil { + return nil, err + } + if provider == "aliyun" { + if err := s.sendViaAliyun(phone, code); err != nil { + return nil, err + } + } else { + applog.Warnf("[SMS/MOCK] 【开发模式】登录验证码:%s -> %s(生产环境请配置 WORKER_SMS_PROVIDER=aliyun)", phone, code) + } + + ttl := time.Duration(s.cfg.CodeTTLSeconds) * time.Second + row := &model.LoginSMSCode{ + UserID: user.ID, + Phone: phone, + Purpose: smsPurposeLogin, + CodeHash: HashSMSCode(code), + ExpiresAt: now.Add(ttl), + RequestIP: truncateString(strings.TrimSpace(requestIP), 64), + CreatedAt: now, + } + if err := s.db.Create(row).Error; err != nil { + return nil, err + } + return &SendLoginCodeResult{ + Sent: true, + PhoneMasked: MaskPhone(phone), + ExpiresInSeconds: int(ttl.Seconds()), + }, nil +} + +// VerifyLoginCode 校验并一次性消费验证码;同一验证码错 5 次即作废。 +func (s *LoginSMSService) VerifyLoginCode(userID uint, codeValue string) error { + if s.db == nil { + return newAuthError(http.StatusServiceUnavailable, "sms_unavailable", "短信服务未配置") + } + code := strings.TrimSpace(codeValue) + if code == "" { + return newAuthError(http.StatusBadRequest, "sms_code_required", "请输入短信验证码") + } + var latest model.LoginSMSCode + if err := s.db.Where("user_id = ? AND purpose = ?", userID, smsPurposeLogin). + Order("id DESC").First(&latest).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return newAuthError(http.StatusBadRequest, "sms_code_invalid", "短信验证码错误或已过期") + } + return err + } + + now := s.now() + valid := latest.ConsumedAt == nil && + latest.ExpiresAt.After(now) && + subtle.ConstantTimeCompare([]byte(latest.CodeHash), []byte(HashSMSCode(code))) == 1 + if !valid { + if latest.ConsumedAt == nil { + var row struct { + FailedAttemptCount int + ConsumedAt *time.Time + } + result := s.db.Raw(` + UPDATE login_sms_codes + SET failed_attempt_count = failed_attempt_count + 1, + consumed_at = CASE WHEN failed_attempt_count + 1 >= ? THEN ? ELSE consumed_at END + WHERE id = ? AND consumed_at IS NULL + RETURNING failed_attempt_count, consumed_at`, + smsMaxVerifyAttempts, now, latest.ID, + ).Scan(&row) + if result.Error != nil { + return result.Error + } + if row.FailedAttemptCount >= smsMaxVerifyAttempts || row.ConsumedAt != nil { + return newAuthError(http.StatusBadRequest, "sms_code_locked", "验证码错误次数过多,请重新获取验证码") + } + } + return newAuthError(http.StatusBadRequest, "sms_code_invalid", "短信验证码错误或已过期") + } + + result := s.db.Model(&model.LoginSMSCode{}). + Where("id = ? AND consumed_at IS NULL", latest.ID). + Update("consumed_at", now) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return newAuthError(http.StatusBadRequest, "sms_code_invalid", "验证码已失效,请重新获取") + } + return nil +} + +// NormalizePhone 校验中国大陆手机号。 +func NormalizePhone(value string) (string, error) { + phone := strings.TrimSpace(value) + if phone == "" { + return "", newAuthError(http.StatusConflict, "phone_unbound", "该账号尚未绑定手机号,请联系管理员") + } + if !phoneRegexp.MatchString(phone) { + return "", newAuthError(http.StatusBadRequest, "phone_invalid", "手机号格式不正确") + } + return phone, nil +} + +// MaskPhone 返回 138****8000 形式的脱敏号码。 +func MaskPhone(value string) string { + phone := strings.TrimSpace(value) + if !phoneRegexp.MatchString(phone) { + return phone + } + return phone[:3] + "****" + phone[len(phone)-4:] +} + +// HashSMSCode 计算短信验证码摘要(只存哈希)。 +func HashSMSCode(code string) string { + sum := sha256.Sum256([]byte(code)) + return hex.EncodeToString(sum[:]) +} + +func generateSMSCode() (string, error) { + raw := make([]byte, 6) + if _, err := rand.Read(raw); err != nil { + return "", err + } + var builder strings.Builder + builder.Grow(6) + for _, value := range raw { + builder.WriteByte(byte('0' + value%10)) + } + return builder.String(), nil +} + +func chinaDayRange(now time.Time) (time.Time, time.Time) { + china := time.FixedZone("CST", 8*60*60) + local := now.In(china) + start := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, china) + return start, start.Add(24 * time.Hour) +} + +func truncateString(value string, max int) string { + if len(value) <= max { + return value + } + return value[:max] +} + +// sendViaAliyun 使用阿里云 Dysmsapi RPC 签名(HMAC-SHA1)直接发起 HTTP 调用。 +func (s *LoginSMSService) sendViaAliyun(phone, code string) error { + params := map[string]string{ + "AccessKeyId": strings.TrimSpace(s.cfg.AccessKeyID), + "Action": "SendSms", + "Format": "JSON", + "PhoneNumbers": phone, + "RegionId": "cn-hangzhou", + "SignName": strings.TrimSpace(s.cfg.SignName), + "SignatureMethod": "HMAC-SHA1", + "SignatureNonce": uuid.NewString(), + "SignatureVersion": "1.0", + "TemplateCode": strings.TrimSpace(s.cfg.TemplateCode), + "TemplateParam": fmt.Sprintf(`{"code":%q}`, code), + "Timestamp": s.now().UTC().Format("2006-01-02T15:04:05Z"), + "Version": aliyunDysmsAPIVersion, + } + canonical := canonicalAliyunQuery(params) + stringToSign := "GET&%2F&" + aliyunPercentEncode(canonical) + mac := hmac.New(sha1.New, []byte(strings.TrimSpace(s.cfg.AccessKeySecret)+"&")) + _, _ = mac.Write([]byte(stringToSign)) + signature := base64.StdEncoding.EncodeToString(mac.Sum(nil)) + endpoint := aliyunDysmsEndpoint + "?" + canonical + "&Signature=" + aliyunPercentEncode(signature) + + client := &http.Client{Timeout: 10 * time.Second} + response, err := client.Get(endpoint) + if err != nil { + return newAuthError(http.StatusBadGateway, "sms_send_failed", "短信发送失败:"+err.Error()) + } + defer response.Body.Close() + body, _ := io.ReadAll(io.LimitReader(response.Body, 64*1024)) + var payload struct { + Code string `json:"Code"` + Message string `json:"Message"` + } + _ = json.Unmarshal(body, &payload) + if response.StatusCode < 200 || response.StatusCode >= 300 || payload.Code != "OK" { + message := strings.TrimSpace(payload.Message) + if message == "" { + message = strings.TrimSpace(string(body)) + } + if message == "" { + message = "未知错误" + } + return newAuthError(http.StatusBadGateway, "sms_send_failed", "短信发送失败:"+message) + } + return nil +} + +func canonicalAliyunQuery(params map[string]string) string { + keys := make([]string, 0, len(params)) + for key := range params { + keys = append(keys, key) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, key := range keys { + parts = append(parts, aliyunPercentEncode(key)+"="+aliyunPercentEncode(params[key])) + } + return strings.Join(parts, "&") +} + +func aliyunPercentEncode(value string) string { + encoded := url.QueryEscape(value) + encoded = strings.ReplaceAll(encoded, "+", "%20") + encoded = strings.ReplaceAll(encoded, "*", "%2A") + encoded = strings.ReplaceAll(encoded, "%7E", "~") + return encoded +} diff --git a/backend/internal/service/merchant_member.go b/backend/internal/service/merchant_member.go index ac2eed0..3b4afa3 100644 --- a/backend/internal/service/merchant_member.go +++ b/backend/internal/service/merchant_member.go @@ -24,6 +24,9 @@ type AddMemberInput struct { } type UpdateMemberInput struct { + Username string + Password string + Nickname *string Role string Status int IsDefault bool @@ -227,8 +230,19 @@ func (s *MerchantService) AddMember(merchantID uint, in AddMemberInput, actorUse func (s *MerchantService) ListMembers(merchantID uint) ([]model.MerchantMember, error) { var members []model.MerchantMember - err := s.db.Preload("User").Where("merchant_id = ?", merchantID).Order("id ASC").Find(&members).Error - return members, err + if err := s.db.Preload("User").Where("merchant_id = ?", merchantID).Order("id ASC").Find(&members).Error; err != nil { + return nil, err + } + // 成员列表和平台用户列表共用一套安全字段(手机号掩码 / 已绑定设备), + // 否则「员工管理-成员」看不到最新的绑定状态。 + users := make([]*model.User, 0, len(members)) + for index := range members { + if members[index].User != nil { + users = append(users, members[index].User) + } + } + NewUserService(s.db, s.tenant).enrichLoginSecurity(users) + return members, nil } // UpdateMember updates a member's relationship with one merchant without changing the login account. @@ -257,11 +271,68 @@ func (s *MerchantService) UpdateMember(merchantID, memberID uint, in UpdateMembe if _, err := (&TenantService{db: tx}).MerchantRolePermissions(merchantID, in.Role); err != nil { return err } + username := strings.TrimSpace(in.Username) + if username != "" { + if len(username) < 3 { + return errors.New("员工用户名至少 3 位") + } + var user model.User + if err := tx.First(&user, member.UserID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return errors.New("成员账号不存在") + } + return err + } + if user.Username != username { + var count int64 + if err := tx.Model(&model.User{}). + Where("username = ? AND id <> ?", username, user.ID). + Count(&count).Error; err != nil { + return err + } + if count > 0 { + return errors.New("员工用户名已存在") + } + if err := tx.Model(&user).Update("username", username).Error; err != nil { + return errors.New("员工用户名已存在") + } + } + } + password := strings.TrimSpace(in.Password) + if password != "" { + if len(password) < 6 { + return errors.New("员工密码至少 6 位") + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + if err := tx.Model(&model.User{}). + Where("id = ?", member.UserID). + Update("password_hash", string(hash)).Error; err != nil { + return err + } + } + if in.Nickname != nil { + nickname := strings.TrimSpace(*in.Nickname) + if err := tx.Model(&model.User{}). + Where("id = ?", member.UserID). + Update("nickname", nickname).Error; err != nil { + return err + } + } updates := map[string]interface{}{"role": in.Role, "status": in.Status, "is_default": in.IsDefault} if err := tx.Model(&member).Updates(updates).Error; err != nil { return err } - return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.member.update", "merchant_member", fmt.Sprint(memberID), map[string]string{"role": in.Role, "status": fmt.Sprint(in.Status)}) + metadata := map[string]string{"role": in.Role, "status": fmt.Sprint(in.Status), "username": username} + if password != "" { + metadata["password_reset"] = "true" + } + if in.Nickname != nil { + metadata["nickname"] = strings.TrimSpace(*in.Nickname) + } + return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.member.update", "merchant_member", fmt.Sprint(memberID), metadata) }) if err != nil { return nil, err @@ -291,6 +362,23 @@ func (s *MerchantService) RemoveMember(merchantID, memberID, actorUserID uint) e if err := tx.Delete(&member).Error; err != nil { return err } + // 该账号不再属于任何商户时,软删除账号以释放用户名: + // users.username 的唯一索引只约束未删除账号,保留账号会让这个名字永远建不回来。 + var remaining int64 + if err := tx.Model(&model.MerchantMember{}).Where("user_id = ?", member.UserID).Count(&remaining).Error; err != nil { + return err + } + if remaining == 0 { + var user model.User + if err := tx.First(&user, member.UserID).Error; err == nil && user.Role == model.RoleMerchant { + if err := releaseUserIdentity(tx, user.ID); err != nil { + return err + } + if err := tx.Delete(&user).Error; err != nil { + return err + } + } + } return writeAudit(tx, &merchantID, &actorUserID, nil, "merchant.member.remove", "merchant_member", fmt.Sprint(memberID), nil) }) } @@ -445,3 +533,47 @@ func (s *MerchantService) UpdateMerchantOwnerAccount(merchantID uint, in UpdateM } return &out, nil } + +// findMember 读取当前商户下的成员关系,避免跨商户操作他人账号。 +func (s *MerchantService) findMember(merchantID, memberID uint) (*model.MerchantMember, error) { + var member model.MerchantMember + if err := s.db.Where("id = ? AND merchant_id = ?", memberID, merchantID).First(&member).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("成员不存在") + } + return nil, err + } + return &member, nil +} + +// SetMemberPhone 为当前商户的成员绑定/修改登录手机号。 +// 负责人账号只能由本人或平台管理员维护,避免被其他成员改绑手机号后接管账号。 +func (s *MerchantService) SetMemberPhone(merchantID, memberID uint, phoneValue string, actorUserID uint) (*model.User, error) { + member, err := s.findMember(merchantID, memberID) + if err != nil { + return nil, err + } + if member.Role == model.MemberRoleOwner && member.UserID != actorUserID { + return nil, errors.New("负责人账号的手机号请由平台管理员维护") + } + if strings.TrimSpace(phoneValue) == "" { + return nil, newAuthError(400, "phone_required", "请输入手机号") + } + phone, err := NormalizePhone(phoneValue) + if err != nil { + return nil, err + } + return NewUserService(s.db, s.tenant).BindUserPhone(member.UserID, phone, actorUserID) +} + +// ResetMemberDevice 重置当前商户成员绑定的 DCAuth 设备。 +func (s *MerchantService) ResetMemberDevice(merchantID, memberID uint, actorUserID uint) (bool, error) { + member, err := s.findMember(merchantID, memberID) + if err != nil { + return false, err + } + if member.Role == model.MemberRoleOwner && member.UserID != actorUserID { + return false, errors.New("负责人账号的设备绑定请由平台管理员维护") + } + return NewUserService(s.db, s.tenant).ResetUserDevice(member.UserID, actorUserID) +} diff --git a/backend/internal/service/merchant_member_security_test.go b/backend/internal/service/merchant_member_security_test.go new file mode 100644 index 0000000..cb0c23f --- /dev/null +++ b/backend/internal/service/merchant_member_security_test.go @@ -0,0 +1,353 @@ +package service + +import ( + "testing" + "time" + + "affiliate_dash/internal/model" + + "golang.org/x/crypto/bcrypt" + + "gorm.io/gorm" +) + +func TestSetMemberPhoneIsMerchantScopedAndProtectsOwner(t *testing.T) { + db := newServiceTestDB(t) + svc := NewMerchantService(db, nil, NewTenantService(db)) + + merchantA, ownerAUserID := seedMerchantWithOwner(t, db, "scope-a", "scope-owner-a", 1) + merchantB, _ := seedMerchantWithOwner(t, db, "scope-b", "scope-owner-b", 1) + + staff := model.User{Username: "scope-staff", PasswordHash: "h", Role: model.RoleMerchant, Status: 1} + if err := db.Create(&staff).Error; err != nil { + t.Fatalf("create staff: %v", err) + } + staffMember := model.MerchantMember{MerchantID: merchantA, UserID: staff.ID, Role: model.MemberRoleOperator, Status: 1} + if err := db.Create(&staffMember).Error; err != nil { + t.Fatalf("create staff member: %v", err) + } + + // 本商户内可以正常绑定。 + if _, err := svc.SetMemberPhone(merchantA, staffMember.ID, "13800001111", ownerAUserID); err != nil { + t.Fatalf("bind phone in own merchant: %v", err) + } + var loaded model.User + if err := db.First(&loaded, staff.ID).Error; err != nil { + t.Fatalf("reload staff: %v", err) + } + if loaded.Phone != "13800001111" { + t.Fatalf("phone not persisted: %q", loaded.Phone) + } + + // 跨商户不能操作别人的成员。 + if _, err := svc.SetMemberPhone(merchantB, staffMember.ID, "13800002222", ownerAUserID); err == nil { + t.Fatal("binding a member through another merchant must be rejected") + } + + // 负责人账号不能被普通成员改绑手机号。 + var ownerMember model.MerchantMember + if err := db.Where("merchant_id = ? AND user_id = ?", merchantA, ownerAUserID).First(&ownerMember).Error; err != nil { + t.Fatalf("load owner member: %v", err) + } + if _, err := svc.SetMemberPhone(merchantA, ownerMember.ID, "13800003333", staff.ID); err == nil { + t.Fatal("a staff member must not rebind the owner's phone") + } + + // 成员列表要带上手机号掩码。 + members, err := svc.ListMembers(merchantA) + if err != nil { + t.Fatalf("list members: %v", err) + } + found := false + for _, member := range members { + if member.UserID != staff.ID { + continue + } + found = true + if member.User == nil || member.User.PhoneMasked != "138****1111" { + t.Fatalf("phone mask missing in member list: %+v", member.User) + } + } + if !found { + t.Fatal("staff member missing from list") + } +} + +func TestResetMemberDeviceRevokesActiveDevice(t *testing.T) { + db := newServiceTestDB(t) + svc := NewMerchantService(db, nil, NewTenantService(db)) + + merchantID, ownerUserID := seedMerchantWithOwner(t, db, "device-scope", "device-owner", 1) + staff := model.User{Username: "device-staff", PasswordHash: "h", Role: model.RoleMerchant, Status: 1} + if err := db.Create(&staff).Error; err != nil { + t.Fatalf("create staff: %v", err) + } + member := model.MerchantMember{MerchantID: merchantID, UserID: staff.ID, Role: model.MemberRoleOperator, Status: 1} + if err := db.Create(&member).Error; err != nil { + t.Fatalf("create member: %v", err) + } + + now := time.Now() + device := model.UserDevice{ + UserID: staff.ID, + DeviceID: "D1-AAAA-BBBB-CCCC-DDDD", + PublicKey: "pk", + ClientVersion: "2.0.0", + Status: model.UserDeviceStatusActive, + BoundAt: now, + CreatedAt: now, + UpdatedAt: now, + } + if err := db.Create(&device).Error; err != nil { + t.Fatalf("create device: %v", err) + } + + // 其他商户不能重置。 + otherMerchantID, _ := seedMerchantWithOwner(t, db, "device-other", "device-other-owner", 1) + if _, err := svc.ResetMemberDevice(otherMerchantID, member.ID, ownerUserID); err == nil { + t.Fatal("resetting a member through another merchant must be rejected") + } + + reset, err := svc.ResetMemberDevice(merchantID, member.ID, ownerUserID) + if err != nil { + t.Fatalf("reset device: %v", err) + } + if !reset { + t.Fatal("expected an active device to be revoked") + } + var reloaded model.UserDevice + if err := db.First(&reloaded, device.ID).Error; err != nil { + t.Fatalf("reload device: %v", err) + } + if reloaded.Status != model.UserDeviceStatusRevoked { + t.Fatalf("device status = %q, want revoked", reloaded.Status) + } + if reloaded.RevokedBy == nil || *reloaded.RevokedBy != ownerUserID { + t.Fatalf("revoked_by = %v, want %d", reloaded.RevokedBy, ownerUserID) + } +} + +func TestSetMemberPhoneRejectsInvalidPhone(t *testing.T) { + db := newServiceTestDB(t) + svc := NewMerchantService(db, nil, NewTenantService(db)) + merchantID, ownerUserID := seedMerchantWithOwner(t, db, "phone-check", "phone-check-owner", 1) + + staff := model.User{Username: "phone-check-staff", PasswordHash: "h", Role: model.RoleMerchant, Status: 1} + if err := db.Create(&staff).Error; err != nil { + t.Fatalf("create staff: %v", err) + } + member := model.MerchantMember{MerchantID: merchantID, UserID: staff.ID, Role: model.MemberRoleOperator, Status: 1} + if err := db.Create(&member).Error; err != nil { + t.Fatalf("create member: %v", err) + } + + if _, err := svc.SetMemberPhone(merchantID, member.ID, "12345", ownerUserID); err == nil { + t.Fatal("invalid phone must be rejected") + } + if _, err := svc.SetMemberPhone(merchantID, member.ID, " ", ownerUserID); err == nil { + t.Fatal("empty phone must be rejected") + } +} + +func seedOperatorRole(t *testing.T, db *gorm.DB, merchantID uint) { + t.Helper() + if err := db.Create(&model.MerchantRole{ + MerchantID: merchantID, Code: model.MemberRoleOperator, Name: "运营", + Permissions: model.PermissionOrdersManage, Status: 1, + }).Error; err != nil { + t.Fatalf("create operator role: %v", err) + } +} + +func TestUpdateMemberCanRenameAccountAndReleaseOldName(t *testing.T) { + db := newServiceTestDB(t) + svc := NewMerchantService(db, nil, NewTenantService(db)) + merchantID, ownerUserID := seedMerchantWithOwner(t, db, "rename-merchant", "rename-owner", 1) + seedOperatorRole(t, db, merchantID) + + staff := model.User{Username: "old-staff", PasswordHash: "h", Role: model.RoleMerchant, Status: 1} + if err := db.Create(&staff).Error; err != nil { + t.Fatalf("create staff: %v", err) + } + member := model.MerchantMember{MerchantID: merchantID, UserID: staff.ID, Role: model.MemberRoleOperator, Status: 1} + if err := db.Create(&member).Error; err != nil { + t.Fatalf("create member: %v", err) + } + + updated, err := svc.UpdateMember(merchantID, member.ID, UpdateMemberInput{ + Username: "new-staff", Role: model.MemberRoleOperator, Status: 1, + }, ownerUserID) + if err != nil { + t.Fatalf("rename member: %v", err) + } + if updated.User == nil || updated.User.Username != "new-staff" { + t.Fatalf("unexpected member after rename: %+v", updated.User) + } + + // 改名后旧用户名应立即释放。 + created, err := svc.AddMember(merchantID, AddMemberInput{ + Username: "old-staff", Password: "secret1", Role: model.MemberRoleOperator, + }, ownerUserID) + if err != nil { + t.Fatalf("old username should be reusable: %v", err) + } + if created.User == nil || created.User.Username != "old-staff" { + t.Fatalf("unexpected recreated member: %+v", created.User) + } +} + +func TestRemoveMemberReleasesUsernamePhoneAndDevice(t *testing.T) { + db := newServiceTestDB(t) + svc := NewMerchantService(db, nil, NewTenantService(db)) + merchantID, ownerUserID := seedMerchantWithOwner(t, db, "release-merchant", "release-owner", 1) + seedOperatorRole(t, db, merchantID) + + staff := model.User{Username: "release-staff", PasswordHash: "h", Role: model.RoleMerchant, Status: 1, Phone: "13800009999"} + if err := db.Create(&staff).Error; err != nil { + t.Fatalf("create staff: %v", err) + } + member := model.MerchantMember{MerchantID: merchantID, UserID: staff.ID, Role: model.MemberRoleOperator, Status: 1} + if err := db.Create(&member).Error; err != nil { + t.Fatalf("create member: %v", err) + } + now := time.Now() + device := model.UserDevice{ + UserID: staff.ID, DeviceID: "D1-RELE-ASE0-0000-0001", PublicKey: "pk", + ClientVersion: "2.0.0", Status: model.UserDeviceStatusActive, + BoundAt: now, CreatedAt: now, UpdatedAt: now, + } + if err := db.Create(&device).Error; err != nil { + t.Fatalf("create device: %v", err) + } + + if err := svc.RemoveMember(merchantID, member.ID, ownerUserID); err != nil { + t.Fatalf("remove member: %v", err) + } + + var oldUser model.User + if err := db.Unscoped().First(&oldUser, staff.ID).Error; err != nil { + t.Fatalf("reload removed user: %v", err) + } + if !oldUser.DeletedAt.Valid { + t.Fatal("account without memberships should be soft-deleted") + } + if oldUser.Phone != "" { + t.Fatalf("phone should be released, got %q", oldUser.Phone) + } + + created, err := svc.AddMember(merchantID, AddMemberInput{ + Username: "release-staff", Password: "secret1", Role: model.MemberRoleOperator, + }, ownerUserID) + if err != nil { + t.Fatalf("username should be reusable after removal: %v", err) + } + if _, err := svc.SetMemberPhone(merchantID, created.ID, "13800009999", ownerUserID); err != nil { + t.Fatalf("phone should be reusable after removal: %v", err) + } + + var reloaded model.UserDevice + if err := db.First(&reloaded, device.ID).Error; err != nil { + t.Fatalf("reload device: %v", err) + } + if reloaded.Status != model.UserDeviceStatusRevoked { + t.Fatalf("device should be revoked on removal, got %q", reloaded.Status) + } +} + +func TestUpdateMemberCanResetPassword(t *testing.T) { + db := newServiceTestDB(t) + svc := NewMerchantService(db, nil, NewTenantService(db)) + merchantID, ownerUserID := seedMerchantWithOwner(t, db, "password-merchant", "password-owner", 1) + seedOperatorRole(t, db, merchantID) + + oldHash, err := bcrypt.GenerateFromPassword([]byte("oldpass123"), bcrypt.DefaultCost) + if err != nil { + t.Fatalf("hash old password: %v", err) + } + staff := model.User{Username: "password-staff", PasswordHash: string(oldHash), Role: model.RoleMerchant, Status: 1} + if err := db.Create(&staff).Error; err != nil { + t.Fatalf("create staff: %v", err) + } + member := model.MerchantMember{MerchantID: merchantID, UserID: staff.ID, Role: model.MemberRoleOperator, Status: 1} + if err := db.Create(&member).Error; err != nil { + t.Fatalf("create member: %v", err) + } + + if _, err := svc.UpdateMember(merchantID, member.ID, UpdateMemberInput{ + Username: "password-staff", Password: "newpass123", Role: model.MemberRoleOperator, Status: 1, + }, ownerUserID); err != nil { + t.Fatalf("reset password: %v", err) + } + var user model.User + if err := db.First(&user, staff.ID).Error; err != nil { + t.Fatalf("reload staff: %v", err) + } + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte("newpass123")); err != nil { + t.Fatalf("new password should work: %v", err) + } + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte("oldpass123")); err == nil { + t.Fatal("old password should no longer work") + } + + // 留空表示不修改。 + before := user.PasswordHash + if _, err := svc.UpdateMember(merchantID, member.ID, UpdateMemberInput{ + Username: "password-staff", Role: model.MemberRoleOperator, Status: 1, + }, ownerUserID); err != nil { + t.Fatalf("update without password: %v", err) + } + if err := db.First(&user, staff.ID).Error; err != nil { + t.Fatalf("reload staff: %v", err) + } + if user.PasswordHash != before { + t.Fatal("empty password must keep the current hash") + } + + // 过短密码拒绝。 + if _, err := svc.UpdateMember(merchantID, member.ID, UpdateMemberInput{ + Username: "password-staff", Password: "123", Role: model.MemberRoleOperator, Status: 1, + }, ownerUserID); err == nil { + t.Fatal("short password must be rejected") + } +} + +func TestUpdateMemberCanChangeNickname(t *testing.T) { + db := newServiceTestDB(t) + svc := NewMerchantService(db, nil, NewTenantService(db)) + merchantID, ownerUserID := seedMerchantWithOwner(t, db, "nickname-merchant", "nickname-owner", 1) + seedOperatorRole(t, db, merchantID) + + staff := model.User{Username: "nickname-staff", PasswordHash: "h", Nickname: "旧昵称", Role: model.RoleMerchant, Status: 1} + if err := db.Create(&staff).Error; err != nil { + t.Fatalf("create staff: %v", err) + } + member := model.MerchantMember{MerchantID: merchantID, UserID: staff.ID, Role: model.MemberRoleOperator, Status: 1} + if err := db.Create(&member).Error; err != nil { + t.Fatalf("create member: %v", err) + } + + nickname := " 新昵称 " + updated, err := svc.UpdateMember(merchantID, member.ID, UpdateMemberInput{ + Username: "nickname-staff", Nickname: &nickname, Role: model.MemberRoleOperator, Status: 1, + }, ownerUserID) + if err != nil { + t.Fatalf("change nickname: %v", err) + } + if updated.User == nil || updated.User.Nickname != "新昵称" { + t.Fatalf("unexpected nickname after update: %+v", updated.User) + } + + // 不传昵称时保持原值。 + if _, err := svc.UpdateMember(merchantID, member.ID, UpdateMemberInput{ + Username: "nickname-staff", Role: model.MemberRoleOperator, Status: 1, + }, ownerUserID); err != nil { + t.Fatalf("update without nickname: %v", err) + } + var user model.User + if err := db.First(&user, staff.ID).Error; err != nil { + t.Fatalf("reload staff: %v", err) + } + if user.Nickname != "新昵称" { + t.Fatalf("nickname should be kept, got %q", user.Nickname) + } +} diff --git a/backend/internal/service/merchant_test.go b/backend/internal/service/merchant_test.go index 5819bf0..0fba691 100644 --- a/backend/internal/service/merchant_test.go +++ b/backend/internal/service/merchant_test.go @@ -267,7 +267,7 @@ func TestDefaultMerchantRolesCanBeEdited(t *testing.T) { } } -func TestMemberCanBeUpdatedOrRemovedWithoutDeletingAccount(t *testing.T) { +func TestMemberCanBeUpdatedOrRemovedWithAccountRetired(t *testing.T) { db := newServiceTestDB(t) merchant := model.Merchant{Code: "member-crud", Name: "成员管理商户", Status: model.MerchantStatusActive} actor := model.User{Username: "member-crud-owner", PasswordHash: "hash", Role: model.RoleMerchant, Status: 1} @@ -303,8 +303,11 @@ func TestMemberCanBeUpdatedOrRemovedWithoutDeletingAccount(t *testing.T) { t.Fatalf("remove member: %v", err) } var user model.User - if err := db.First(&user, employee.ID).Error; err != nil { - t.Fatalf("employee account should remain: %v", err) + if err := db.Unscoped().First(&user, employee.ID).Error; err != nil { + t.Fatalf("employee account row should be retained for audit: %v", err) + } + if !user.DeletedAt.Valid { + t.Fatal("employee account should be soft-deleted after the last membership is removed") } var count int64 if err := db.Model(&model.MerchantMember{}).Where("id = ?", member.ID).Count(&count).Error; err != nil || count != 0 { @@ -532,9 +535,9 @@ func TestUsernameCanBeReusedAfterMerchantAccountSoftDelete(t *testing.T) { } } -// TestRemoveMemberDoesNotReleaseUsername 固化行为:商户侧"移除成员"只删除成员关系, -// 登录账号(含用户名)保留,同名用户名仍被占用,无法新建。 -func TestRemoveMemberDoesNotReleaseUsername(t *testing.T) { +// TestRemoveMemberReleasesUsername 固化新行为:移除最后一个商户关系后账号软删除, +// 用户名立即释放,可以用同名账号重新建员工。 +func TestRemoveMemberReleasesUsername(t *testing.T) { db := newServiceTestDB(t) merchant := model.Merchant{Code: "remove-member-keep", Name: "移除成员保留账号", Status: model.MerchantStatusActive} actor := model.User{Username: "remove-member-actor", PasswordHash: "hash", Role: model.RoleMerchant, Status: 1} @@ -560,13 +563,16 @@ func TestRemoveMemberDoesNotReleaseUsername(t *testing.T) { t.Fatalf("remove member: %v", err) } - // 登录账号仍在(未被删除),用户名继续占用。 - var kept model.User - if err := db.First(&kept, staff.ID).Error; err != nil { - t.Fatalf("login account should be retained after member removal: %v", err) + // 账号已软删除(保留行供审计),用户名立即释放。 + var released model.User + if err := db.Unscoped().First(&released, staff.ID).Error; err != nil { + t.Fatalf("account row should be retained for audit: %v", err) + } + if !released.DeletedAt.Valid { + t.Fatal("account should be soft-deleted after member removal") } userSvc := NewUserService(db, nil) - if _, err := userSvc.Create("kf-2", "password123", "新员工", model.RoleMerchant, 0); err == nil { - t.Fatalf("username should still be occupied after member removal") + if _, err := userSvc.Create("kf-2", "password123", "新员工", model.RoleMerchant, 0); err != nil { + t.Fatalf("username should be reusable after member removal: %v", err) } } diff --git a/backend/internal/service/user.go b/backend/internal/service/user.go index bc7cb23..b6f2838 100644 --- a/backend/internal/service/user.go +++ b/backend/internal/service/user.go @@ -2,6 +2,9 @@ package service import ( "errors" + "fmt" + "strings" + "time" "affiliate_dash/internal/model" @@ -142,7 +145,16 @@ type UpdatePlatformAdminInput struct { } func (s *UserService) ListPlatformAdmins(page, size int) ([]model.User, int64, error) { - return s.List(UserListQuery{Page: page, Size: size, Role: model.RoleAdmin}) + list, total, err := s.List(UserListQuery{Page: page, Size: size, Role: model.RoleAdmin}) + if err != nil { + return nil, 0, err + } + pointers := make([]*model.User, 0, len(list)) + for i := range list { + pointers = append(pointers, &list[i]) + } + s.enrichLoginSecurity(pointers) + return list, total, nil } // ListMerchantAccountGroups returns merchant employee accounts grouped by @@ -191,6 +203,13 @@ func (s *UserService) ListMerchantAccountGroups(page, size int) ([]MerchantMembe } group.Members = append(group.Members, member) } + pointers := make([]*model.User, 0, len(members)) + for i := range members { + if members[i].User != nil { + pointers = append(pointers, members[i].User) + } + } + s.enrichLoginSecurity(pointers) return groups, total, nil } @@ -317,6 +336,22 @@ func (s *UserService) DeletePlatformAdmin(id, actorUserID uint) error { }) } +// releaseUserIdentity 在账号软删除前释放手机号与设备绑定。 +// users.phone 的唯一索引不区分软删除,不清理会让该手机号永远无法再绑定。 +func releaseUserIdentity(tx *gorm.DB, userID uint) error { + now := time.Now() + if err := tx.Model(&model.UserDevice{}). + Where("user_id = ? AND status = ?", userID, model.UserDeviceStatusActive). + Updates(map[string]interface{}{ + "status": model.UserDeviceStatusRevoked, + "revoked_at": now, + "updated_at": now, + }).Error; err != nil { + return err + } + return tx.Model(&model.User{}).Where("id = ?", userID).Update("phone", "").Error +} + // DeleteMerchantAccount permanently removes a merchant login account and all // of its merchant memberships. Platform administrators must be managed from // the dedicated platform administrator view instead. @@ -341,6 +376,146 @@ func (s *UserService) DeleteMerchantAccount(id, actorUserID uint) error { if err := tx.Where("user_id = ?", user.ID).Delete(&model.MerchantMember{}).Error; err != nil { return err } + if err := releaseUserIdentity(tx, user.ID); err != nil { + return err + } return tx.Delete(&user).Error }) } + +// BindUserPhone 由管理员为账号绑定/修改登录手机号(手机号全平台唯一)。 +func (s *UserService) BindUserPhone(id uint, phoneValue string, actorUserID uint) (*model.User, error) { + if id == 0 { + return nil, newAuthError(404, "user_not_found", "用户不存在") + } + phone, err := NormalizePhone(phoneValue) + if err != nil { + return nil, err + } + updated := &model.User{} + err = s.db.Transaction(func(tx *gorm.DB) error { + var user model.User + if err := tx.First(&user, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return newAuthError(404, "user_not_found", "用户不存在") + } + return err + } + if user.Phone == phone { + updated = &user + return nil + } + var count int64 + if err := tx.Model(&model.User{}). + Where("phone = ? AND id <> ?", phone, user.ID). + Count(&count).Error; err != nil { + return err + } + if count > 0 { + return newAuthError(409, "phone_exists", "该手机号已绑定其他账号") + } + now := time.Now() + if err := tx.Model(&user).Updates(map[string]interface{}{ + "phone": phone, + "phone_bound_at": now, + "phone_bound_by": actorUserID, + }).Error; err != nil { + return err + } + if err := writeAudit(tx, nil, &actorUserID, nil, "user.phone.bind", "user", fmt.Sprint(user.ID), map[string]interface{}{ + "phone": MaskPhone(phone), + }); err != nil { + return err + } + return tx.First(updated, user.ID).Error + }) + if err != nil { + return nil, err + } + s.enrichLoginSecurity([]*model.User{updated}) + return updated, nil +} + +// ResetUserDevice 由管理员撤销账号当前 active 的 DCAuth 设备绑定。 +func (s *UserService) ResetUserDevice(id uint, actorUserID uint) (bool, error) { + if id == 0 { + return false, newAuthError(404, "user_not_found", "用户不存在") + } + var reset bool + err := s.db.Transaction(func(tx *gorm.DB) error { + var user model.User + if err := tx.First(&user, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return newAuthError(404, "user_not_found", "用户不存在") + } + return err + } + now := time.Now() + result := tx.Model(&model.UserDevice{}). + Where("user_id = ? AND status = ?", user.ID, model.UserDeviceStatusActive). + Updates(map[string]interface{}{ + "status": model.UserDeviceStatusRevoked, + "revoked_at": now, + "revoked_by": actorUserID, + "updated_at": now, + }) + if result.Error != nil { + return result.Error + } + reset = result.RowsAffected > 0 + return writeAudit(tx, nil, &actorUserID, nil, "user.device.reset", "user", fmt.Sprint(user.ID), map[string]interface{}{ + "revoked_devices": result.RowsAffected, + }) + }) + if err != nil { + return false, err + } + return reset, nil +} + +// enrichLoginSecurity 批量补充手机号掩码与 active 设备信息,避免列表 N+1 查询。 +func (s *UserService) enrichLoginSecurity(users []*model.User) { + if len(users) == 0 { + return + } + ids := make([]uint, 0, len(users)) + for _, user := range users { + if user == nil { + continue + } + user.PhoneMasked = MaskPhone(user.Phone) + ids = append(ids, user.ID) + } + if len(ids) == 0 { + return + } + var devices []model.UserDevice + if err := s.db.Where("user_id IN ? AND status = ?", ids, model.UserDeviceStatusActive).Find(&devices).Error; err != nil { + return + } + byUser := make(map[uint]model.UserDevice, len(devices)) + for _, device := range devices { + byUser[device.UserID] = device + } + for _, user := range users { + if user == nil { + continue + } + device, ok := byUser[user.ID] + if !ok { + continue + } + user.DeviceBound = true + user.DeviceID = device.DeviceID + user.DeviceIDMasked = MaskDeviceID(device.DeviceID) + } +} + +// MaskDeviceID 返回 D1-ABC-****-1234 形式的设备码。 +func MaskDeviceID(value string) string { + deviceID := strings.ToUpper(strings.TrimSpace(value)) + if len(deviceID) <= 8 { + return deviceID + } + return deviceID[:6] + "-****-" + deviceID[len(deviceID)-4:] +} diff --git a/docker-compose.yml b/docker-compose.yml index 463ef1a..68293af 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -60,6 +60,14 @@ services: - CALLBACK_PUSH_TIMEOUT_SECONDS=${CALLBACK_PUSH_TIMEOUT_SECONDS:-15} - CALLBACK_RETRY_SCHEDULE=${CALLBACK_RETRY_SCHEDULE:-} - DATA_ENCRYPTION_KEY=${DATA_ENCRYPTION_KEY:-} + - WORKER_SMS_PROVIDER=${WORKER_SMS_PROVIDER:-mock} + - WORKER_SMS_ACCESS_KEY_ID=${WORKER_SMS_ACCESS_KEY_ID:-} + - WORKER_SMS_ACCESS_KEY_SECRET=${WORKER_SMS_ACCESS_KEY_SECRET:-} + - WORKER_SMS_SIGN_NAME=${WORKER_SMS_SIGN_NAME:-} + - WORKER_SMS_TEMPLATE_CODE=${WORKER_SMS_TEMPLATE_CODE:-} + - WORKER_SMS_CODE_TTL_SECONDS=${WORKER_SMS_CODE_TTL_SECONDS:-300} + - WORKER_SMS_COOLDOWN_SECONDS=${WORKER_SMS_COOLDOWN_SECONDS:-60} + - WORKER_SMS_DAILY_LIMIT=${WORKER_SMS_DAILY_LIMIT:-10} - LEWAN_BASE_URL=${LEWAN_BASE_URL:-https://shop.cloudtentacles.com} - LEWAN_APPLICATION_ID=${LEWAN_APPLICATION_ID:-} - LEWAN_ACCESS_KEY=${LEWAN_ACCESS_KEY:-} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 3a180e9..22793fb 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -1,4 +1,4 @@ -import request from './request' +import request from "./request"; import type { DashboardStats, AlertChannelInput, @@ -17,11 +17,11 @@ import type { LoginResult, LowBalanceAlertConfig, Merchant, - MerchantMember, - MerchantMemberGroup, - MerchantOption, - MerchantOwnerAccount, - MerchantRole, + MerchantMember, + MerchantMemberGroup, + MerchantOption, + MerchantOwnerAccount, + MerchantRole, MerchantProduct, PageResult, ProductCatalogItem, @@ -32,328 +32,644 @@ import type { LewanProductListResult, UpdateProductRoutingInput, SyncLewanProductInput, -} from '../types' + SendLoginSMSCodeResult, + PreparedDeviceLogin, + DCAuthChallengeStatus, +} from "../types"; export const authApi = { login: async (username: string, password: string) => { - const res = await request.post('/auth/login', { username, password }) - return res.data.data as LoginResult + const res = await request.post("/auth/login", { username, password }); + return res.data.data as LoginResult; + }, + sendLoginSmsCode: async (username: string, password: string) => { + const res = await request.post("/auth/login/sms-code", { + username, + password, + }); + return res.data.data as SendLoginSMSCodeResult; + }, + prepareLogin: async (data: { + username: string; + password: string; + smsCode: string; + apiBase?: string; + }) => { + const res = await request.post("/auth/login/prepare", data); + return res.data.data as PreparedDeviceLogin; + }, + completeLogin: async (data: { + challengeId: string; + pollToken: string; + deviceTicket: string; + }) => { + const res = await request.post("/auth/login/complete", data); + return res.data.data as LoginResult; + }, + deviceChallengeStatus: async (challengeId: string, pollToken: string) => { + const res = await request.get( + `/dcauth/challenge/${encodeURIComponent(challengeId)}/status`, + { + params: { pollToken }, + }, + ); + return res.data.data as DCAuthChallengeStatus; }, profile: async () => { - const res = await request.get('/auth/profile') - return res.data.data as User + const res = await request.get("/auth/profile"); + return res.data.data as User; }, changePassword: (data: { current_password: string; new_password: string }) => - request.put('/auth/password', data).then((r) => r.data.data), - updateCurrentAccount: (data: { current_password: string; username: string; nickname?: string; new_password?: string }) => - request.put('/auth/account', data).then((r) => r.data.data as User), -} + request.put("/auth/password", data).then((r) => r.data.data), + updateCurrentAccount: (data: { + current_password: string; + username: string; + nickname?: string; + new_password?: string; + }) => request.put("/auth/account", data).then((r) => r.data.data as User), +}; export const dashboardApi = { stats: () => - request.get('/dashboard').then((r) => r.data.data as DashboardStats), -} + request.get("/dashboard").then((r) => r.data.data as DashboardStats), +}; export const userApi = { list: (params?: Record) => - request.get('/platform/admins', { params }).then((r) => r.data.data as PageResult), + request + .get("/platform/admins", { params }) + .then((r) => r.data.data as PageResult), create: (data: { username: string; password: string; nickname?: string }) => - request.post('/platform/admins', data).then((r) => r.data.data as User), - update: (id: number, data: { username: string; nickname?: string; password?: string; status: number }) => - request.patch(`/platform/admins/${id}`, data).then((r) => r.data.data as User), + request.post("/platform/admins", data).then((r) => r.data.data as User), + update: ( + id: number, + data: { + username: string; + nickname?: string; + password?: string; + status: number; + }, + ) => + request + .patch(`/platform/admins/${id}`, data) + .then((r) => r.data.data as User), remove: (id: number) => request.delete(`/platform/admins/${id}`).then((r) => r.data.data), -} + bindPhone: (id: number, phone: string) => + request + .post(`/admin/users/${id}/phone`, { phone }) + .then((r) => r.data.data as User), + resetDevice: (id: number) => + request + .post(`/admin/users/${id}/device/reset`) + .then((r) => r.data.data as { reset: boolean }), +}; export const merchantApi = { current: () => - request.get('/merchant').then((r) => r.data.data as { merchant: Merchant; role: string; permissions: string[] }), + request.get("/merchant").then( + (r) => + r.data.data as { + merchant: Merchant; + role: string; + permissions: string[]; + }, + ), products: (params?: Record) => - request.get('/merchant/products', { params }).then((r) => r.data.data as PageResult), + request + .get("/merchant/products", { params }) + .then((r) => r.data.data as PageResult), updateProduct: ( id: number, data: { - display_name?: string - price_amount?: number - cost_amount?: number - stock?: number - status?: 'active' | 'inactive' - } + display_name?: string; + price_amount?: number; + cost_amount?: number; + stock?: number; + status?: "active" | "inactive"; + }, ) => request.patch(`/merchant/products/${id}`, data).then((r) => r.data.data), - updateProductStatus: (id: number, status: MerchantProduct['status']) => - request.patch(`/merchant/products/${id}/status`, { status }).then((r) => r.data.data), + updateProductStatus: (id: number, status: MerchantProduct["status"]) => + request + .patch(`/merchant/products/${id}/status`, { status }) + .then((r) => r.data.data), deleteProduct: (id: number) => request.delete(`/merchant/products/${id}`).then((r) => r.data.data), orders: (params?: Record) => - request.get('/merchant/orders', { params }).then((r) => r.data.data as PageResult), + request + .get("/merchant/orders", { params }) + .then((r) => r.data.data as PageResult), manualOrderProducts: () => - request.get('/merchant/orders/products').then((r) => r.data.data as MerchantProduct[]), + request + .get("/merchant/orders/products") + .then((r) => r.data.data as MerchantProduct[]), createTestOrder: (data: { - sku: string - buyer_reference?: string - note?: string - order_status?: 'paid' | 'ship_failed' | 'cancelled' - }) => request.post('/merchant/orders/test', data).then((r) => r.data.data as CreateTestOrderResult), + sku: string; + buyer_reference?: string; + note?: string; + order_status?: "paid" | "ship_failed" | "cancelled"; + }) => + request + .post("/merchant/orders/test", data) + .then((r) => r.data.data as CreateTestOrderResult), createManualOrder: (data: { - client_order_no: string - sku: string - quantity: number - buyer_reference?: string - game_account?: string - note?: string - }) => request.post('/merchant/orders/manual', data).then((r) => r.data.data as CreateManualOrderResult), + client_order_no: string; + sku: string; + quantity: number; + buyer_reference?: string; + game_account?: string; + note?: string; + }) => + request + .post("/merchant/orders/manual", data) + .then((r) => r.data.data as CreateManualOrderResult), cancelOrder: (orderNo: string, reason: string) => - request.post(`/merchant/orders/${encodeURIComponent(orderNo)}/cancel`, { reason }).then((r) => r.data.data as { order: FulfillmentOrder; refund_amount: number }), + request + .post(`/merchant/orders/${encodeURIComponent(orderNo)}/cancel`, { + reason, + }) + .then( + (r) => + r.data.data as { order: FulfillmentOrder; refund_amount: number }, + ), getDeliveryLink: (orderNo: string) => - request.get(`/merchant/orders/${encodeURIComponent(orderNo)}/delivery-link`).then((r) => r.data.data as DeliveryLinkResult), + request + .get(`/merchant/orders/${encodeURIComponent(orderNo)}/delivery-link`) + .then((r) => r.data.data as DeliveryLinkResult), revokeDeliveryLink: (orderNo: string) => - request.post(`/merchant/orders/${encodeURIComponent(orderNo)}/delivery-link/revoke`).then((r) => r.data.data), + request + .post( + `/merchant/orders/${encodeURIComponent(orderNo)}/delivery-link/revoke`, + ) + .then((r) => r.data.data), restoreDeliveryLink: (orderNo: string) => - request.post(`/merchant/orders/${encodeURIComponent(orderNo)}/delivery-link/restore`).then((r) => r.data.data as DeliveryLinkResult), + request + .post( + `/merchant/orders/${encodeURIComponent(orderNo)}/delivery-link/restore`, + ) + .then((r) => r.data.data as DeliveryLinkResult), wallet: () => - request.get('/merchant/wallet').then((r) => r.data.data as WalletAccount), + request.get("/merchant/wallet").then((r) => r.data.data as WalletAccount), ledger: (params?: Record) => - request.get('/merchant/wallet/ledger', { params }).then((r) => r.data.data as PageResult), + request + .get("/merchant/wallet/ledger", { params }) + .then((r) => r.data.data as PageResult), apiClients: () => - request.get('/merchant/api-clients').then((r) => r.data.data as ApiClient[]), + request + .get("/merchant/api-clients") + .then((r) => r.data.data as ApiClient[]), createApiClient: (data: { - name: string - scopes: string - signature_version?: string - expires_at?: string - }) => request.post('/merchant/api-clients', data).then((r) => r.data.data as ApiCredential), - updateApiClientStatus: (id: number, status: ApiClient['status']) => - request.patch(`/merchant/api-clients/${id}/status`, { status }).then((r) => r.data.data), + name: string; + scopes: string; + signature_version?: string; + expires_at?: string; + }) => + request + .post("/merchant/api-clients", data) + .then((r) => r.data.data as ApiCredential), + updateApiClientStatus: (id: number, status: ApiClient["status"]) => + request + .patch(`/merchant/api-clients/${id}/status`, { status }) + .then((r) => r.data.data), deleteApiClient: (id: number) => request.delete(`/merchant/api-clients/${id}`).then((r) => r.data.data), callbacks: () => - request.get('/merchant/callbacks').then((r) => r.data.data as CallbackSubscription | null), - saveCallback: (data: { url: string; events: string; status: CallbackSubscription['status']; rotate_secret?: boolean }) => - request.post('/merchant/callbacks', data).then((r) => r.data.data as CallbackCredential), + request + .get("/merchant/callbacks") + .then((r) => r.data.data as CallbackSubscription | null), + saveCallback: (data: { + url: string; + events: string; + status: CallbackSubscription["status"]; + rotate_secret?: boolean; + }) => + request + .post("/merchant/callbacks", data) + .then((r) => r.data.data as CallbackCredential), members: () => - request.get('/merchant/members').then((r) => r.data.data as MerchantMember[]), - addMember: (data: { user_id?: number; username?: string; password?: string; nickname?: string; role: string; is_default?: boolean }) => - request.post('/merchant/members', data).then((r) => r.data.data as MerchantMember), - updateMember: (id: number, data: { role: string; status: number; is_default: boolean }) => - request.patch(`/merchant/members/${id}`, data).then((r) => r.data.data as MerchantMember), + request + .get("/merchant/members") + .then((r) => r.data.data as MerchantMember[]), + addMember: (data: { + user_id?: number; + username?: string; + password?: string; + nickname?: string; + role: string; + is_default?: boolean; + }) => + request + .post("/merchant/members", data) + .then((r) => r.data.data as MerchantMember), + updateMember: ( + id: number, + data: { + username?: string; + password?: string; + nickname?: string; + role: string; + status: number; + is_default: boolean; + }, + ) => + request + .patch(`/merchant/members/${id}`, data) + .then((r) => r.data.data as MerchantMember), removeMember: (id: number) => request.delete(`/merchant/members/${id}`).then((r) => r.data.data), - roles: () => request.get('/merchant/roles').then((r) => r.data.data as MerchantRole[]), + bindMemberPhone: (id: number, phone: string) => + request + .post(`/merchant/members/${id}/phone`, { phone }) + .then((r) => r.data.data as User), + resetMemberDevice: (id: number) => + request + .post(`/merchant/members/${id}/device/reset`) + .then((r) => r.data.data as { reset: boolean }), + roles: () => + request.get("/merchant/roles").then((r) => r.data.data as MerchantRole[]), createRole: (data: { code: string; name: string; permissions: string[] }) => - request.post('/merchant/roles', data).then((r) => r.data.data as MerchantRole), + request + .post("/merchant/roles", data) + .then((r) => r.data.data as MerchantRole), updateRole: (id: number, data: { name: string; permissions: string[] }) => - request.patch(`/merchant/roles/${id}`, data).then((r) => r.data.data as MerchantRole), + request + .patch(`/merchant/roles/${id}`, data) + .then((r) => r.data.data as MerchantRole), rechargeApplications: (params?: Record) => - request.get('/merchant/recharge/applications', { params }).then((r) => r.data.data as PageResult), - createRechargeApplication: (data: { amount_cny: number; vouchers: string[]; note?: string }) => + request + .get("/merchant/recharge/applications", { params }) + .then((r) => r.data.data as PageResult), + createRechargeApplication: (data: { + amount_cny: number; + vouchers: string[]; + note?: string; + }) => // amount_cny 单位:分(1元 = 100分),积分按 1元 = 100积分 折算 - request.post('/merchant/recharge/applications', data).then((r) => r.data.data as RechargeApplication), + request + .post("/merchant/recharge/applications", data) + .then((r) => r.data.data as RechargeApplication), alertConfig: () => - request.get('/merchant/recharge/alert-config').then((r) => r.data.data as LowBalanceAlertConfig), - saveAlertConfig: (data: { enabled: boolean; threshold_points: number; notify_interval_minutes: number; max_notifications: number }) => - request.put('/merchant/recharge/alert-config', data).then((r) => r.data.data as LowBalanceAlertConfig), + request + .get("/merchant/recharge/alert-config") + .then((r) => r.data.data as LowBalanceAlertConfig), + saveAlertConfig: (data: { + enabled: boolean; + threshold_points: number; + notify_interval_minutes: number; + max_notifications: number; + }) => + request + .put("/merchant/recharge/alert-config", data) + .then((r) => r.data.data as LowBalanceAlertConfig), alertChannels: () => - request.get('/merchant/alert-channels').then((r) => r.data.data as AlertNotifyChannel[]), + request + .get("/merchant/alert-channels") + .then((r) => r.data.data as AlertNotifyChannel[]), createAlertChannel: (data: AlertChannelInput) => - request.post('/merchant/alert-channels', data).then((r) => r.data.data as AlertNotifyChannel), + request + .post("/merchant/alert-channels", data) + .then((r) => r.data.data as AlertNotifyChannel), updateAlertChannel: (id: number, data: AlertChannelInput) => - request.put(`/merchant/alert-channels/${id}`, data).then((r) => r.data.data as AlertNotifyChannel), + request + .put(`/merchant/alert-channels/${id}`, data) + .then((r) => r.data.data as AlertNotifyChannel), deleteAlertChannel: (id: number) => request.delete(`/merchant/alert-channels/${id}`).then((r) => r.data.data), testAlertChannel: (id: number) => - request.post(`/merchant/alert-channels/${id}/test`).then((r) => r.data.data), -} + request + .post(`/merchant/alert-channels/${id}/test`) + .then((r) => r.data.data), +}; export const uploadApi = { file: (file: File) => { - const form = new FormData() - form.append('file', file) - return request.post('/upload', form, { - headers: { 'Content-Type': 'multipart/form-data' }, - timeout: 60000, - }).then((r) => r.data.data as { url: string }) + const form = new FormData(); + form.append("file", file); + return request + .post("/upload", form, { + headers: { "Content-Type": "multipart/form-data" }, + timeout: 60000, + }) + .then((r) => r.data.data as { url: string }); }, -} +}; export const deliveryApi = { getOrder: (orderNo: string, auth?: { exp?: string; sign?: string }) => - request.get(`/delivery/v1/orders/${encodeURIComponent(orderNo)}`, { params: auth }).then((r) => r.data.data as DeliveryOrderInfo), - bind: (orderNo: string, gameAccount: string, auth?: { exp?: string; sign?: string }) => - request.post( - `/delivery/v1/orders/${encodeURIComponent(orderNo)}/bind`, - { - game_account: gameAccount, - }, - { params: auth }, - ).then((r) => r.data.data as DeliveryBindResult), - getBindResult: (orderNo: string, bindUUID: string, auth?: { exp?: string; sign?: string }) => - request.get(`/delivery/v1/orders/${encodeURIComponent(orderNo)}/bind-result`, { - params: { ...auth, bind_uuid: bindUUID }, - }).then((r) => r.data.data as DeliveryBindResult), - submit: (orderNo: string, gameAccount: string, bindUUID: string, auth?: { exp?: string; sign?: string }) => - request.post( - `/delivery/v1/orders/${encodeURIComponent(orderNo)}/submit`, - { - game_account: gameAccount, - bind_uuid: bindUUID, - }, - { params: auth }, - ).then((r) => r.data.data as DeliverySubmitResult), -} + request + .get(`/delivery/v1/orders/${encodeURIComponent(orderNo)}`, { + params: auth, + }) + .then((r) => r.data.data as DeliveryOrderInfo), + bind: ( + orderNo: string, + gameAccount: string, + auth?: { exp?: string; sign?: string }, + ) => + request + .post( + `/delivery/v1/orders/${encodeURIComponent(orderNo)}/bind`, + { + game_account: gameAccount, + }, + { params: auth }, + ) + .then((r) => r.data.data as DeliveryBindResult), + getBindResult: ( + orderNo: string, + bindUUID: string, + auth?: { exp?: string; sign?: string }, + ) => + request + .get(`/delivery/v1/orders/${encodeURIComponent(orderNo)}/bind-result`, { + params: { ...auth, bind_uuid: bindUUID }, + }) + .then((r) => r.data.data as DeliveryBindResult), + submit: ( + orderNo: string, + gameAccount: string, + bindUUID: string, + auth?: { exp?: string; sign?: string }, + ) => + request + .post( + `/delivery/v1/orders/${encodeURIComponent(orderNo)}/submit`, + { + game_account: gameAccount, + bind_uuid: bindUUID, + }, + { params: auth }, + ) + .then((r) => r.data.data as DeliverySubmitResult), +}; export const platformApi = { merchantAccounts: (params?: Record) => - request.get('/platform/merchant-accounts', { params }).then((r) => r.data.data as PageResult), + request + .get("/platform/merchant-accounts", { params }) + .then((r) => r.data.data as PageResult), deleteMerchantAccount: (userId: number) => - request.delete(`/platform/merchant-accounts/${userId}`).then((r) => r.data.data), + request + .delete(`/platform/merchant-accounts/${userId}`) + .then((r) => r.data.data), merchants: (params?: Record) => - request.get('/platform/merchants', { params }).then((r) => r.data.data as PageResult), + request + .get("/platform/merchants", { params }) + .then((r) => r.data.data as PageResult), // 不分页的精简商户列表,供筛选下拉使用(/platform/merchants 的 size 上限是 100) merchantOptions: () => - request.get('/platform/merchant-options').then((r) => r.data.data as MerchantOption[]), + request + .get("/platform/merchant-options") + .then((r) => r.data.data as MerchantOption[]), // 平台侧跨商户订单视图:不传 merchant_id 即全部商户 orders: (params?: Record) => - request.get('/platform/orders', { params }).then((r) => r.data.data as PageResult), + request + .get("/platform/orders", { params }) + .then((r) => r.data.data as PageResult), createMerchant: (data: { - code: string - name: string - contact_name?: string - contact_info?: string - owner_user_id?: number - owner_username?: string - owner_password?: string - owner_nickname?: string - features?: string - fee_type?: 'rate' | 'fixed' - fee_rate_bp?: number - fee_fixed_amount?: number - }) => request.post('/platform/merchants', data).then((r) => r.data.data as Merchant), + code: string; + name: string; + contact_name?: string; + contact_info?: string; + owner_user_id?: number; + owner_username?: string; + owner_password?: string; + owner_nickname?: string; + features?: string; + fee_type?: "rate" | "fixed"; + fee_rate_bp?: number; + fee_fixed_amount?: number; + }) => + request + .post("/platform/merchants", data) + .then((r) => r.data.data as Merchant), updateMerchant: (id: number, data: Partial) => request.patch(`/platform/merchants/${id}`, data).then((r) => r.data.data), members: (merchantId: number) => - request.get(`/platform/merchants/${merchantId}/members`).then((r) => r.data.data as MerchantMember[]), + request + .get(`/platform/merchants/${merchantId}/members`) + .then((r) => r.data.data as MerchantMember[]), addMember: ( merchantId: number, - data: { user_id?: number; username?: string; password?: string; nickname?: string; role: string; is_default?: boolean }, - ) => request.post(`/platform/merchants/${merchantId}/members`, data).then((r) => r.data.data as MerchantMember), + data: { + user_id?: number; + username?: string; + password?: string; + nickname?: string; + role: string; + is_default?: boolean; + }, + ) => + request + .post(`/platform/merchants/${merchantId}/members`, data) + .then((r) => r.data.data as MerchantMember), updateMember: ( merchantId: number, memberId: number, data: { role: string; status: number; is_default: boolean }, - ) => request.patch(`/platform/merchants/${merchantId}/members/${memberId}`, data).then((r) => r.data.data as MerchantMember), + ) => + request + .patch(`/platform/merchants/${merchantId}/members/${memberId}`, data) + .then((r) => r.data.data as MerchantMember), removeMember: (merchantId: number, memberId: number) => - request.delete(`/platform/merchants/${merchantId}/members/${memberId}`).then((r) => r.data.data), + request + .delete(`/platform/merchants/${merchantId}/members/${memberId}`) + .then((r) => r.data.data), roles: (merchantId: number) => - request.get(`/platform/merchants/${merchantId}/roles`).then((r) => r.data.data as MerchantRole[]), + request + .get(`/platform/merchants/${merchantId}/roles`) + .then((r) => r.data.data as MerchantRole[]), productCatalog: () => - request.get('/platform/product-catalog').then((r) => r.data.data as ProductCatalogItem[]), + request + .get("/platform/product-catalog") + .then((r) => r.data.data as ProductCatalogItem[]), merchantProducts: (merchantId: number) => - request.get(`/platform/merchants/${merchantId}/products`).then((r) => r.data.data as ProductCatalogItem[]), - updateMerchantProduct: (merchantId: number, productId: number, data: Partial) => - request.patch(`/platform/merchants/${merchantId}/products/${productId}`, data).then((r) => r.data.data), + request + .get(`/platform/merchants/${merchantId}/products`) + .then((r) => r.data.data as ProductCatalogItem[]), + updateMerchantProduct: ( + merchantId: number, + productId: number, + data: Partial, + ) => + request + .patch(`/platform/merchants/${merchantId}/products/${productId}`, data) + .then((r) => r.data.data), assignProducts: (merchantId: number, catalogIds: number[]) => - request.post(`/platform/merchants/${merchantId}/products/assign`, { catalog_ids: catalogIds }).then((r) => r.data.data as { assigned: number }), + request + .post(`/platform/merchants/${merchantId}/products/assign`, { + catalog_ids: catalogIds, + }) + .then((r) => r.data.data as { assigned: number }), wallet: (merchantId: number) => - request.get(`/platform/merchants/${merchantId}/wallet`).then((r) => r.data.data as WalletAccount), + request + .get(`/platform/merchants/${merchantId}/wallet`) + .then((r) => r.data.data as WalletAccount), merchantAccount: (merchantId: number) => - request.get(`/platform/merchants/${merchantId}/account`).then((r) => r.data.data as MerchantOwnerAccount[]), - updateMerchantAccount: (merchantId: number, data: { user_id: number; username: string; nickname?: string; password?: string; status?: number }) => - request.patch(`/platform/merchants/${merchantId}/account`, data).then((r) => r.data.data as MerchantOwnerAccount), + request + .get(`/platform/merchants/${merchantId}/account`) + .then((r) => r.data.data as MerchantOwnerAccount[]), + updateMerchantAccount: ( + merchantId: number, + data: { + user_id: number; + username: string; + nickname?: string; + password?: string; + status?: number; + }, + ) => + request + .patch(`/platform/merchants/${merchantId}/account`, data) + .then((r) => r.data.data as MerchantOwnerAccount), walletLedger: (merchantId: number, params?: Record) => - request.get(`/platform/merchants/${merchantId}/wallet/ledger`, { params }).then((r) => r.data.data as PageResult), - adjustWallet: (merchantId: number, data: { amount: number; idempotency_key: string; note?: string }) => - request.post(`/platform/merchants/${merchantId}/wallet/adjust`, data).then((r) => r.data.data as WalletAccount), + request + .get(`/platform/merchants/${merchantId}/wallet/ledger`, { params }) + .then((r) => r.data.data as PageResult), + adjustWallet: ( + merchantId: number, + data: { amount: number; idempotency_key: string; note?: string }, + ) => + request + .post(`/platform/merchants/${merchantId}/wallet/adjust`, data) + .then((r) => r.data.data as WalletAccount), rechargeApplications: (params?: Record) => - request.get('/platform/recharge/applications', { params }).then((r) => r.data.data as PageResult), - reviewRechargeApplication: (id: number, data: { approved: boolean; review_note?: string }) => - request.post(`/platform/recharge/applications/${id}/review`, data).then((r) => r.data.data as RechargeApplication), -} + request + .get("/platform/recharge/applications", { params }) + .then((r) => r.data.data as PageResult), + reviewRechargeApplication: ( + id: number, + data: { approved: boolean; review_note?: string }, + ) => + request + .post(`/platform/recharge/applications/${id}/review`, data) + .then((r) => r.data.data as RechargeApplication), +}; export const lewanApi = { listProducts: (params?: { page?: number; size?: number; keyword?: string }) => - request.get('/platform/lewan/products', { params }).then((r) => r.data.data as LewanProductListResult), + request + .get("/platform/lewan/products", { params }) + .then((r) => r.data.data as LewanProductListResult), syncCatalog: (data: SyncLewanProductInput) => - request.post('/platform/lewan/sync-catalog', data).then((r) => r.data.data as ProductCatalogItem), + request + .post("/platform/lewan/sync-catalog", data) + .then((r) => r.data.data as ProductCatalogItem), batchSyncCatalog: (products: SyncLewanProductInput[]) => - request.post('/platform/lewan/batch-sync-catalog', { products }).then((r) => r.data.data as { synced_count: number }), + request + .post("/platform/lewan/batch-sync-catalog", { products }) + .then((r) => r.data.data as { synced_count: number }), updateRouting: (id: number, data: UpdateProductRoutingInput) => - request.put(`/platform/products/${id}/routing`, data).then((r) => r.data.data as { updated: boolean }), -} + request + .put(`/platform/products/${id}/routing`, data) + .then((r) => r.data.data as { updated: boolean }), +}; export interface RockConfig { - id: number - auto_delivery_enabled: boolean - base_url: string - bearer_token: string - username: string - points: number - min_points_warning: number - token_warning_stock: number - auto_purchase_enabled: boolean - auto_purchase_count: number - purchase_cooldown_minutes: number - last_purchased_at?: string - last_heartbeat_at?: string + id: number; + auto_delivery_enabled: boolean; + base_url: string; + bearer_token: string; + username: string; + points: number; + min_points_warning: number; + token_warning_stock: number; + auto_purchase_enabled: boolean; + auto_purchase_count: number; + purchase_cooldown_minutes: number; + last_purchased_at?: string; + last_heartbeat_at?: string; } export interface RockStats { - available_stock: number - using_stock: number - bound_stock: number - redeemed_stock: number - mismatch_stock: number - abnormal_stock: number - upstream_points: number - auto_delivery_enabled: boolean - token_warning_stock: number - auto_purchase_enabled: boolean - username: string - last_heartbeat_at?: string + available_stock: number; + using_stock: number; + bound_stock: number; + redeemed_stock: number; + mismatch_stock: number; + abnormal_stock: number; + upstream_points: number; + auto_delivery_enabled: boolean; + token_warning_stock: number; + auto_purchase_enabled: boolean; + username: string; + last_heartbeat_at?: string; } export interface RockTokenItem { - id: number - upstream_token_id: string - redeem_code: string - color: string - region: string - status: string - assigned_order_no?: string - client_order_no?: string - expected_uid?: string - actual_role_name?: string - actual_role_id?: string - actual_channel?: string - upstream_fulfill_id?: number - target_skin?: string - target_currency?: string - target_currency_quantity?: number - mismatch_at?: string - mismatch_reason?: string - remark?: string - claimed_at?: string - bound_at?: string - redeemed_at?: string - created_at: string + id: number; + upstream_token_id: string; + redeem_code: string; + color: string; + region: string; + status: string; + assigned_order_no?: string; + client_order_no?: string; + expected_uid?: string; + actual_role_name?: string; + actual_role_id?: string; + actual_channel?: string; + upstream_fulfill_id?: number; + target_skin?: string; + target_currency?: string; + target_currency_quantity?: number; + mismatch_at?: string; + mismatch_reason?: string; + remark?: string; + claimed_at?: string; + bound_at?: string; + redeemed_at?: string; + created_at: string; } export const rockApi = { - getConfig: () => request.get('/platform/rock/config').then((r) => r.data.data as RockConfig), - updateConfig: (data: Partial) => request.put('/platform/rock/config', data).then((r) => r.data.data as RockConfig), - testConnection: (bearer_token?: string) => request.post('/platform/rock/test-conn', { bearer_token }).then((r) => r.data.data), - getStats: () => request.get('/platform/rock/stats').then((r) => r.data.data as RockStats), - purchaseTokens: (count = 50) => request.post('/platform/rock/purchase', { count }).then((r) => r.data.data), - listTokens: (params?: { page?: number; page_size?: number; status?: string; search?: string }) => - request.get('/platform/rock/tokens', { params }).then((r) => r.data.data as { list: RockTokenItem[]; total: number; page: number; page_size: number }), + getConfig: () => + request.get("/platform/rock/config").then((r) => r.data.data as RockConfig), + updateConfig: (data: Partial) => + request + .put("/platform/rock/config", data) + .then((r) => r.data.data as RockConfig), + testConnection: (bearer_token?: string) => + request + .post("/platform/rock/test-conn", { bearer_token }) + .then((r) => r.data.data), + getStats: () => + request.get("/platform/rock/stats").then((r) => r.data.data as RockStats), + purchaseTokens: (count = 50) => + request.post("/platform/rock/purchase", { count }).then((r) => r.data.data), + listTokens: (params?: { + page?: number; + page_size?: number; + status?: string; + search?: string; + }) => + request.get("/platform/rock/tokens", { params }).then( + (r) => + r.data.data as { + list: RockTokenItem[]; + total: number; + page: number; + page_size: number; + }, + ), correctExpectedUid: (id: number, expectedUid: string) => - request.post(`/platform/rock/tokens/${id}/expected-uid`, { expected_uid: expectedUid }).then((r) => r.data.data as { message?: string; status?: string }), - resetToken: (id: number) => request.post(`/platform/rock/tokens/${id}/reset`).then((r) => r.data.data), - releaseToken: (id: number) => request.post(`/platform/rock/tokens/${id}/release`).then((r) => r.data.data), + request + .post(`/platform/rock/tokens/${id}/expected-uid`, { + expected_uid: expectedUid, + }) + .then((r) => r.data.data as { message?: string; status?: string }), + resetToken: (id: number) => + request.post(`/platform/rock/tokens/${id}/reset`).then((r) => r.data.data), + releaseToken: (id: number) => + request + .post(`/platform/rock/tokens/${id}/release`) + .then((r) => r.data.data), updateRemark: (id: number, data: { remark: string; status?: string }) => - request.post(`/platform/rock/tokens/${id}/remark`, data).then((r) => r.data.data as { message: string; token: RockTokenItem }), + request + .post(`/platform/rock/tokens/${id}/remark`, data) + .then((r) => r.data.data as { message: string; token: RockTokenItem }), markAbnormal: (id: number, remark?: string) => - request.post(`/platform/rock/tokens/${id}/abnormal`, { remark }).then((r) => r.data.data as { message: string; token: RockTokenItem }), -} - - - + request + .post(`/platform/rock/tokens/${id}/abnormal`, { remark }) + .then((r) => r.data.data as { message: string; token: RockTokenItem }), +}; diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index da2815e..0e9a607 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,24 +1,124 @@ -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' -import { Form, Input, Button, Typography, message, Space } from 'antd' -import { UserOutlined, LockOutlined, RightOutlined } from '@ant-design/icons' +import { Alert, Button, Divider, Form, Input, Space, Typography, message } from 'antd' +import { LockOutlined, RightOutlined, SafetyCertificateOutlined, UserOutlined } from '@ant-design/icons' +import { authApi } from '../api' +import type { PreparedDeviceLogin } from '../types' import { useAuth } from '../store/authContext' -export default function Login() { - const { login } = useAuth() - const navigate = useNavigate() - const [loading, setLoading] = useState(false) - const [form] = Form.useForm() +type LoginForm = { + username: string + password: string + smsCode: string +} - const onFinish = async (values: { username: string; password: string }) => { +export default function Login() { + const { login, applyLoginResult } = useAuth() + const navigate = useNavigate() + const [form] = Form.useForm() + const [loading, setLoading] = useState(false) + const [sending, setSending] = useState(false) + const [countdown, setCountdown] = useState(0) + const [waiting, setWaiting] = useState(false) + const [phoneMasked, setPhoneMasked] = useState('') + const timerRef = useRef(null) + const submittingRef = useRef(false) + + useEffect(() => { + return () => { + if (timerRef.current) window.clearInterval(timerRef.current) + } + }, []) + + const startCountdown = (seconds: number) => { + setCountdown(seconds) + if (timerRef.current) window.clearInterval(timerRef.current) + timerRef.current = window.setInterval(() => { + setCountdown((current) => { + if (current <= 1) { + if (timerRef.current) window.clearInterval(timerRef.current) + return 0 + } + return current - 1 + }) + }, 1000) + } + + const sendCode = async () => { + try { + const values = await form.validateFields(['username', 'password']) + setSending(true) + const result = await authApi.sendLoginSmsCode(values.username.trim(), values.password) + setPhoneMasked(result.phoneMasked) + startCountdown(60) + message.success(`验证码已发送至 ${result.phoneMasked}`) + } catch (error) { + const text = error instanceof Error ? error.message : '验证码发送失败' + if (text.includes('未绑定手机号')) { + message.warning(`${text},可使用下方「旧账号仅密码登录」`) + } else { + message.error(text) + } + } finally { + setSending(false) + } + } + + const onFinish = async (values: LoginForm) => { + if (submittingRef.current) return + submittingRef.current = true + setLoading(true) + setWaiting(false) + try { + const prepared = await authApi.prepareLogin({ + username: values.username.trim(), + password: values.password, + smsCode: values.smsCode.trim(), + apiBase: window.location.origin, + }) + setPhoneMasked(prepared.phoneMasked) + setWaiting(true) + message.info('已发起本机验证,请在弹出的 DCAuth 窗口中确认') + launchProtocol(prepared.launchUrl) + + const result = await waitForDevice(prepared) + applyLoginResult(result) + message.success('登录成功,欢迎回来!') + navigate('/', { replace: true }) + } catch (error) { + const text = error instanceof Error ? error.message : '登录失败,请检查账号密码' + if (text.includes('未绑定手机号')) { + message.warning(`${text},可使用下方「旧账号仅密码登录」`) + } else if (text.includes('绑定设备不一致')) { + message.error('当前设备与账号绑定设备不一致,请联系管理员重置设备') + } else { + message.error(text) + } + } finally { + submittingRef.current = false + setLoading(false) + setWaiting(false) + } + } + + const submitLegacyLogin = async () => { + if (submittingRef.current) return + let values: LoginForm + try { + values = await form.validateFields(['username', 'password']) + } catch { + return + } + submittingRef.current = true setLoading(true) try { - await login(values.username, values.password) + await login(values.username.trim(), values.password) message.success('登录成功,欢迎回来!') - navigate('/') - } catch (e) { - message.error(e instanceof Error ? e.message : '登录失败,请检查账号密码') + navigate('/', { replace: true }) + } catch (error) { + message.error(error instanceof Error ? error.message : '账号密码登录失败') } finally { + submittingRef.current = false setLoading(false) } } @@ -33,16 +133,20 @@ export default function Login() { Skin Hub 游戏皮肤分销平台 - 基于 Go + React 的高并发皮肤分发管理系统 + 账号密码 + 短信验证码 + DCAuth 本机设备签名 -
+ {waiting ? ( + + ) : null} + + } - placeholder="用户名 / 手机号" + placeholder="用户名(支持中文 / 英文)" size="large" + autoComplete="username" style={{ borderRadius: 8 }} /> @@ -59,16 +164,45 @@ export default function Login() { } placeholder="密码" size="large" + autoComplete="current-password" style={{ borderRadius: 8 }} /> + + } + placeholder={`6 位短信验证码${phoneMasked ? `(${phoneMasked})` : ''}`} + size="large" + maxLength={6} + autoComplete="one-time-code" + style={{ borderRadius: 8 }} + addonAfter={ + + } + /> + + + + 未绑定手机号的历史账号 + + + - 请使用平台管理员或商户负责人分配的账号登录 + 已绑定手机号的账号必须通过短信验证码与 DCAuth 本机签名登录 ) } + +async function waitForDevice(prepared: PreparedDeviceLogin) { + for (let attempt = 0; attempt < 120; attempt += 1) { + await sleep(1000) + const status = await authApi.deviceChallengeStatus(prepared.challengeId, prepared.pollToken) + if (status.status === 'authorized' && status.deviceTicket) { + return authApi.completeLogin({ + challengeId: prepared.challengeId, + pollToken: prepared.pollToken, + deviceTicket: status.deviceTicket, + }) + } + if (status.status === 'expired' || status.status === 'rejected') { + throw new Error(status.message || '本机验证已过期,请重新登录') + } + if (attempt === 4) { + message.info('如果浏览器没有弹出 DCAuth,请先安装并运行一次 DCAuth 客户端') + } + } + throw new Error('等待本机验证超时,请确认 DCAuth 已安装并允许浏览器唤起') +} + +function launchProtocol(url: string) { + const anchor = document.createElement('a') + anchor.href = url + anchor.style.display = 'none' + document.body.appendChild(anchor) + anchor.click() + window.setTimeout(() => anchor.remove(), 1000) +} + +function sleep(ms: number) { + return new Promise((resolve) => window.setTimeout(resolve, ms)) +} diff --git a/frontend/src/pages/MerchantCenter.tsx b/frontend/src/pages/MerchantCenter.tsx index 0b4b04e..45c28ad 100644 --- a/frontend/src/pages/MerchantCenter.tsx +++ b/frontend/src/pages/MerchantCenter.tsx @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' -import { useLocation, useNavigate } from 'react-router-dom' +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; import { Button, Form, @@ -11,11 +11,11 @@ import { Tabs, Typography, message, -} from 'antd' -import { ReloadOutlined } from '@ant-design/icons' -import { useAuth } from '../store/authContext' -import { merchantApi } from '../api' -import { PageHeader } from '../components/PageHeader' +} from "antd"; +import { ReloadOutlined } from "@ant-design/icons"; +import { useAuth } from "../store/authContext"; +import { merchantApi } from "../api"; +import { PageHeader } from "../components/PageHeader"; import type { ApiClient, ApiCredential, @@ -29,7 +29,7 @@ import type { PageResult, WalletAccount, WalletLedgerEntry, -} from '../types' +} from "../types"; import { apiClientMax, defaultCallbackFormValues, @@ -41,8 +41,11 @@ import { roleText, scopeOptions, tabFromSearch, -} from './merchantCenterUtils' -import type { MerchantCenterProps, MerchantCenterTab } from './merchantCenterUtils' +} from "./merchantCenterUtils"; +import type { + MerchantCenterProps, + MerchantCenterTab, +} from "./merchantCenterUtils"; import { ApiKeysTab, CallbacksTab, @@ -51,307 +54,444 @@ import { ProductsTab, SecretBlock, WalletTab, -} from './merchantCenterTabs' +} from "./merchantCenterTabs"; -export default function MerchantCenter({ fixedTab, title = '商户中心' }: MerchantCenterProps = {}) { - const { isAdmin, user } = useAuth() - const location = useLocation() - const navigate = useNavigate() - const routeTab = useMemo(() => fixedTab ?? tabFromSearch(location.search), [fixedTab, location.search]) - const [merchant, setMerchant] = useState(null) - const [merchantRole, setMerchantRole] = useState() - const [merchantPermissions, setMerchantPermissions] = useState([]) - const [activeTab, setActiveTab] = useState(routeTab ?? 'products') - const [products, setProducts] = useState>({ list: [], total: 0, page: 1, size: 10 }) - const [orders, setOrders] = useState>({ list: [], total: 0, page: 1, size: 10 }) - const [orderSource, setOrderSource] = useState<'api' | 'manual'>('api') - const [orderFilters, setOrderFilters] = useState<{ keyword: string; order_status: string }>({ keyword: '', order_status: '' }) - const [productFilters, setProductFilters] = useState<{ keyword: string; status: string }>({ - keyword: '', - status: '', - }) - const [ledger, setLedger] = useState>({ list: [], total: 0, page: 1, size: 10 }) - const [wallet, setWallet] = useState(null) - const [apiClients, setApiClients] = useState([]) - const [callback, setCallback] = useState(null) - const [members, setMembers] = useState([]) - const [roles, setRoles] = useState([]) - const [loading, setLoading] = useState(false) - const [apiClientOpen, setApiClientOpen] = useState(false) - const [apiCredential, setApiCredential] = useState(null) - const [callbackCredential, setCallbackCredential] = useState(null) - const [memberOpen, setMemberOpen] = useState(false) - const [editingMember, setEditingMember] = useState(null) - const [roleOpen, setRoleOpen] = useState(false) - const [editingRole, setEditingRole] = useState(null) - const [manualOrderOpen, setManualOrderOpen] = useState(false) - const [manualOrderSubmitting, setManualOrderSubmitting] = useState(false) - const [manualOrderProducts, setManualOrderProducts] = useState([]) - const [manualOrderProductsLoading, setManualOrderProductsLoading] = useState(false) - const [walletFilterForm] = Form.useForm() - const [apiClientForm] = Form.useForm() - const [callbackForm] = Form.useForm() - const [memberForm] = Form.useForm() - const [memberEditForm] = Form.useForm() - const [roleForm] = Form.useForm() - const [manualOrderForm] = Form.useForm() +export default function MerchantCenter({ + fixedTab, + title = "商户中心", +}: MerchantCenterProps = {}) { + const { isAdmin, user } = useAuth(); + const location = useLocation(); + const navigate = useNavigate(); + const routeTab = useMemo( + () => fixedTab ?? tabFromSearch(location.search), + [fixedTab, location.search], + ); + const [merchant, setMerchant] = useState(null); + const [merchantRole, setMerchantRole] = useState(); + const [merchantPermissions, setMerchantPermissions] = useState([]); + const [activeTab, setActiveTab] = useState( + routeTab ?? "products", + ); + const [products, setProducts] = useState>({ + list: [], + total: 0, + page: 1, + size: 10, + }); + const [orders, setOrders] = useState>({ + list: [], + total: 0, + page: 1, + size: 10, + }); + const [orderSource, setOrderSource] = useState<"api" | "manual">("api"); + const [orderFilters, setOrderFilters] = useState<{ + keyword: string; + order_status: string; + }>({ keyword: "", order_status: "" }); + const [productFilters, setProductFilters] = useState<{ + keyword: string; + status: string; + }>({ + keyword: "", + status: "", + }); + const [ledger, setLedger] = useState>({ + list: [], + total: 0, + page: 1, + size: 10, + }); + const [wallet, setWallet] = useState(null); + const [apiClients, setApiClients] = useState([]); + const [callback, setCallback] = useState(null); + const [members, setMembers] = useState([]); + const [roles, setRoles] = useState([]); + const [loading, setLoading] = useState(false); + const [apiClientOpen, setApiClientOpen] = useState(false); + const [apiCredential, setApiCredential] = useState( + null, + ); + const [callbackCredential, setCallbackCredential] = + useState(null); + const [memberOpen, setMemberOpen] = useState(false); + const [editingMember, setEditingMember] = useState( + null, + ); + const [roleOpen, setRoleOpen] = useState(false); + const [editingRole, setEditingRole] = useState(null); + const [manualOrderOpen, setManualOrderOpen] = useState(false); + const [manualOrderSubmitting, setManualOrderSubmitting] = useState(false); + const [manualOrderProducts, setManualOrderProducts] = useState< + MerchantProduct[] + >([]); + const [manualOrderProductsLoading, setManualOrderProductsLoading] = + useState(false); + const [walletFilterForm] = Form.useForm(); + const [apiClientForm] = Form.useForm(); + const [callbackForm] = Form.useForm(); + const [memberForm] = Form.useForm(); + const [memberEditForm] = Form.useForm(); + const [roleForm] = Form.useForm(); + const [manualOrderForm] = Form.useForm(); - const hasPermission = useCallback((permission: string) => merchantPermissions.includes(permission), [merchantPermissions]) - const canManage = hasPermission('products:manage') - const canOrderManage = hasPermission('orders:manage') - const canAPIManage = hasPermission('api:manage') - const canCallbacksManage = hasPermission('callbacks:manage') - const canFinance = hasPermission('wallet:ledger') - const enabledFeatures = useMemo(() => new Set(featuresToList(merchant?.features)), [merchant?.features]) - const hasFeature = useCallback((feature: string) => !merchant || enabledFeatures.has(feature), [enabledFeatures, merchant]) - const manualOrderProductOptions = useMemo(() => manualOrderProducts.map((item) => ({ - value: item.sku, - label: productOptionLabel(item), - })), [manualOrderProducts]) + const hasPermission = useCallback( + (permission: string) => merchantPermissions.includes(permission), + [merchantPermissions], + ); + const canManage = hasPermission("products:manage"); + const canOrderManage = hasPermission("orders:manage"); + const canAPIManage = hasPermission("api:manage"); + const canCallbacksManage = hasPermission("callbacks:manage"); + const canFinance = hasPermission("wallet:ledger"); + const enabledFeatures = useMemo( + () => new Set(featuresToList(merchant?.features)), + [merchant?.features], + ); + const hasFeature = useCallback( + (feature: string) => !merchant || enabledFeatures.has(feature), + [enabledFeatures, merchant], + ); + const manualOrderProductOptions = useMemo( + () => + manualOrderProducts.map((item) => ({ + value: item.sku, + label: productOptionLabel(item), + })), + [manualOrderProducts], + ); const copyDeliveryLink = useCallback((orderNo: string) => { - merchantApi.getDeliveryLink(orderNo) - .then((data) => navigator.clipboard.writeText(data.delivery_url).then(() => message.success('发货链接已复制'))) - .catch((e) => message.error(e instanceof Error ? e.message : '复制失败')) - }, []) + merchantApi + .getDeliveryLink(orderNo) + .then((data) => + navigator.clipboard + .writeText(data.delivery_url) + .then(() => message.success("发货链接已复制")), + ) + .catch((e) => message.error(e instanceof Error ? e.message : "复制失败")); + }, []); const openDeliveryLink = useCallback((orderNo: string) => { - merchantApi.getDeliveryLink(orderNo) - .then((data) => window.open(data.delivery_url, '_blank', 'noopener,noreferrer')) - .catch((e) => message.error(e instanceof Error ? e.message : '打开失败')) - }, []) + merchantApi + .getDeliveryLink(orderNo) + .then((data) => + window.open(data.delivery_url, "_blank", "noopener,noreferrer"), + ) + .catch((e) => message.error(e instanceof Error ? e.message : "打开失败")); + }, []); - const patchDeliveryLinkState = useCallback((orderNo: string, patch: Partial) => { - setOrders((prev) => ({ - ...prev, - list: prev.list.map((item) => item.order_no === orderNo ? { ...item, ...patch } : item), - })) - }, []) + const patchDeliveryLinkState = useCallback( + (orderNo: string, patch: Partial) => { + setOrders((prev) => ({ + ...prev, + list: prev.list.map((item) => + item.order_no === orderNo ? { ...item, ...patch } : item, + ), + })); + }, + [], + ); - const revokeDeliveryLink = useCallback((orderNo: string) => { - merchantApi.revokeDeliveryLink(orderNo) - .then(() => { - const revokedAt = new Date().toISOString() - message.success('发货链接已作废') - patchDeliveryLinkState(orderNo, { delivery_link_revoked_at: revokedAt }) - }) - .catch((e) => message.error(e instanceof Error ? e.message : '链接作废失败')) - }, [patchDeliveryLinkState]) - - const restoreDeliveryLink = useCallback((orderNo: string) => { - merchantApi.restoreDeliveryLink(orderNo) - .then((data) => { - message.success('发货链接已恢复') - patchDeliveryLinkState(orderNo, { - delivery_link_expires_at: data.expires_at, - delivery_link_revoked_at: null, + const revokeDeliveryLink = useCallback( + (orderNo: string) => { + merchantApi + .revokeDeliveryLink(orderNo) + .then(() => { + const revokedAt = new Date().toISOString(); + message.success("发货链接已作废"); + patchDeliveryLinkState(orderNo, { + delivery_link_revoked_at: revokedAt, + }); }) - }) - .catch((e) => message.error(e instanceof Error ? e.message : '恢复失败')) - }, [patchDeliveryLinkState]) + .catch((e) => + message.error(e instanceof Error ? e.message : "链接作废失败"), + ); + }, + [patchDeliveryLinkState], + ); + + const restoreDeliveryLink = useCallback( + (orderNo: string) => { + merchantApi + .restoreDeliveryLink(orderNo) + .then((data) => { + message.success("发货链接已恢复"); + patchDeliveryLinkState(orderNo, { + delivery_link_expires_at: data.expires_at, + delivery_link_revoked_at: null, + }); + }) + .catch((e) => + message.error(e instanceof Error ? e.message : "恢复失败"), + ); + }, + [patchDeliveryLinkState], + ); const loadCurrent = useCallback(async () => { - const data = await merchantApi.current() - setMerchant(data.merchant) - setMerchantRole(data.role) - const nextPermissions = [...new Set(data.permissions || [])].sort() - setMerchantPermissions((current) => ( - current.length === nextPermissions.length && current.every((value, index) => value === nextPermissions[index]) + const data = await merchantApi.current(); + setMerchant(data.merchant); + setMerchantRole(data.role); + const nextPermissions = [...new Set(data.permissions || [])].sort(); + setMerchantPermissions((current) => + current.length === nextPermissions.length && + current.every((value, index) => value === nextPermissions[index]) ? current - : nextPermissions - )) - return data - }, []) + : nextPermissions, + ); + return data; + }, []); - const loadProducts = useCallback(async ( - page = products.page, - size = products.size, - filters = productFilters, - ) => { - const data = await merchantApi.products({ - page, - size, - keyword: filters.keyword || undefined, - status: filters.status || undefined, - }) - setProducts(data) - }, [productFilters, products.page, products.size]) + const loadProducts = useCallback( + async ( + page = products.page, + size = products.size, + filters = productFilters, + ) => { + const data = await merchantApi.products({ + page, + size, + keyword: filters.keyword || undefined, + status: filters.status || undefined, + }); + setProducts(data); + }, + [productFilters, products.page, products.size], + ); - const loadOrders = useCallback(async ( - page = orders.page, - size = orders.size, - source = orderSource, - filters = orderFilters, - ) => { - const data = await merchantApi.orders({ page, size, order_source: source, ...filters }) - setOrders(data) - }, [orderFilters, orderSource, orders.page, orders.size]) + const loadOrders = useCallback( + async ( + page = orders.page, + size = orders.size, + source = orderSource, + filters = orderFilters, + ) => { + const data = await merchantApi.orders({ + page, + size, + order_source: source, + ...filters, + }); + setOrders(data); + }, + [orderFilters, orderSource, orders.page, orders.size], + ); - const loadWallet = useCallback(async ( - page = ledger.page, - size = ledger.size, - permissions = merchantPermissions, - filters?: { reference_no?: string; type?: string }, - ) => { - const walletData = await merchantApi.wallet() - setWallet(walletData) - if (permissions.includes('wallet:ledger')) { - const ledgerData = await merchantApi.ledger({ page, size, ...filters }) - setLedger(ledgerData) - } else { - setLedger({ list: [], total: 0, page, size }) - } - }, [ledger.page, ledger.size, merchantPermissions]) + const loadWallet = useCallback( + async ( + page = ledger.page, + size = ledger.size, + permissions = merchantPermissions, + filters?: { reference_no?: string; type?: string }, + ) => { + const walletData = await merchantApi.wallet(); + setWallet(walletData); + if (permissions.includes("wallet:ledger")) { + const ledgerData = await merchantApi.ledger({ page, size, ...filters }); + setLedger(ledgerData); + } else { + setLedger({ list: [], total: 0, page, size }); + } + }, + [ledger.page, ledger.size, merchantPermissions], + ); const loadAPIClients = useCallback(async () => { - const clientData = await merchantApi.apiClients() - setApiClients(clientData || []) - }, []) + const clientData = await merchantApi.apiClients(); + setApiClients(clientData || []); + }, []); - const loadCallbacks = useCallback(async (permissions = merchantPermissions) => { - if (!permissions.includes('callbacks:manage')) { - setCallback(null) - callbackForm.resetFields() - return - } - const callbackData = await merchantApi.callbacks() - setCallback(callbackData || null) - callbackForm.setFieldsValue(callbackData ? { - url: callbackData.url, - events: eventsToValue(callbackData.events), - status: callbackData.status, - } : defaultCallbackFormValues()) - }, [callbackForm, merchantPermissions]) + const loadCallbacks = useCallback( + async (permissions = merchantPermissions) => { + if (!permissions.includes("callbacks:manage")) { + setCallback(null); + callbackForm.resetFields(); + return; + } + const callbackData = await merchantApi.callbacks(); + setCallback(callbackData || null); + callbackForm.setFieldsValue( + callbackData + ? { + url: callbackData.url, + events: eventsToValue(callbackData.events), + status: callbackData.status, + } + : defaultCallbackFormValues(), + ); + }, + [callbackForm, merchantPermissions], + ); - const loadMembers = useCallback(async (permissions = merchantPermissions) => { - if (!permissions.includes('members:manage')) { - setMembers([]) - setRoles([]) - return - } - const [memberData, roleData] = await Promise.all([merchantApi.members(), merchantApi.roles()]) - setMembers(memberData || []) - setRoles(roleData || []) - }, [merchantPermissions]) + const loadMembers = useCallback( + async (permissions = merchantPermissions) => { + if (!permissions.includes("members:manage")) { + setMembers([]); + setRoles([]); + return; + } + const [memberData, roleData] = await Promise.all([ + merchantApi.members(), + merchantApi.roles(), + ]); + setMembers(memberData || []); + setRoles(roleData || []); + }, + [merchantPermissions], + ); - const loadActiveTab = useCallback(async (permissions = merchantPermissions, tab = activeTab) => { - switch (tab) { - case 'products': - await loadProducts() - return - case 'orders': - await loadOrders() - return - case 'wallet': - await loadWallet(undefined, undefined, permissions) - return - case 'api': - await loadAPIClients() - return - case 'callbacks': - await loadCallbacks(permissions) - return - case 'members': - await loadMembers(permissions) - return - } - }, [activeTab, loadAPIClients, loadCallbacks, loadMembers, loadOrders, loadProducts, loadWallet, merchantPermissions]) + const loadActiveTab = useCallback( + async (permissions = merchantPermissions, tab = activeTab) => { + switch (tab) { + case "products": + await loadProducts(); + return; + case "orders": + await loadOrders(); + return; + case "wallet": + await loadWallet(undefined, undefined, permissions); + return; + case "api": + await loadAPIClients(); + return; + case "callbacks": + await loadCallbacks(permissions); + return; + case "members": + await loadMembers(permissions); + return; + } + }, + [ + activeTab, + loadAPIClients, + loadCallbacks, + loadMembers, + loadOrders, + loadProducts, + loadWallet, + merchantPermissions, + ], + ); const loadAll = useCallback(async () => { - setLoading(true) + setLoading(true); try { - const current = await loadCurrent() - const nextTab = fixedTab ?? resolveEnabledTab(routeTab ?? activeTab, current.merchant.features) + const current = await loadCurrent(); + const nextTab = + fixedTab ?? + resolveEnabledTab(routeTab ?? activeTab, current.merchant.features); if (!fixedTab && nextTab !== activeTab) { - setActiveTab(nextTab) + setActiveTab(nextTab); } - await loadActiveTab(current.permissions || [], nextTab) + await loadActiveTab(current.permissions || [], nextTab); } catch (e) { - message.error(e instanceof Error ? e.message : '加载失败') + message.error(e instanceof Error ? e.message : "加载失败"); } finally { - setLoading(false) + setLoading(false); } - }, [activeTab, fixedTab, loadActiveTab, loadCurrent, routeTab]) + }, [activeTab, fixedTab, loadActiveTab, loadCurrent, routeTab]); useEffect(() => { - loadAll() - }, [loadAll]) + loadAll(); + }, [loadAll]); useEffect(() => { if (routeTab && activeTab !== routeTab) { - setActiveTab(routeTab) + setActiveTab(routeTab); } - }, [activeTab, routeTab]) + }, [activeTab, routeTab]); const openProductToggle = (record: MerchantProduct) => { - merchantApi.updateProductStatus(record.id, record.status === 'active' ? 'inactive' : 'active') + merchantApi + .updateProductStatus( + record.id, + record.status === "active" ? "inactive" : "active", + ) .then(() => { - message.success(record.status === 'active' ? '商品已下架,商户无法再下单' : '商品已上架') - loadProducts() + message.success( + record.status === "active" + ? "商品已下架,商户无法再下单" + : "商品已上架", + ); + loadProducts(); }) - .catch((e) => message.error(e instanceof Error ? e.message : '操作失败')) - } + .catch((e) => message.error(e instanceof Error ? e.message : "操作失败")); + }; const deleteProduct = (record: MerchantProduct) => { - merchantApi.deleteProduct(record.id) + merchantApi + .deleteProduct(record.id) .then(() => { - message.success('商品已删除') - loadProducts() + message.success("商品已删除"); + loadProducts(); }) - .catch((e) => message.error(e instanceof Error ? e.message : '删除失败')) - } + .catch((e) => message.error(e instanceof Error ? e.message : "删除失败")); + }; - const changeProductFilters = (filters: { keyword: string; status: string }) => { - setProductFilters(filters) - loadProducts(1, products.size, filters).catch((e) => message.error(e instanceof Error ? e.message : '商品加载失败')) - } + const changeProductFilters = (filters: { + keyword: string; + status: string; + }) => { + setProductFilters(filters); + loadProducts(1, products.size, filters).catch((e) => + message.error(e instanceof Error ? e.message : "商品加载失败"), + ); + }; const handleWalletFilter = async () => { - const values = await walletFilterForm.validateFields() + const values = await walletFilterForm.validateFields(); loadWallet(1, ledger.size, merchantPermissions, { reference_no: values.reference_no || undefined, type: values.type || undefined, - }) - } + }); + }; const handleWalletFilterReset = () => { - walletFilterForm.resetFields() - loadWallet(1, ledger.size, merchantPermissions, {}) - } + walletFilterForm.resetFields(); + loadWallet(1, ledger.size, merchantPermissions, {}); + }; - const handleLoadLedger = useCallback((page: number, size: number) => { - const values = walletFilterForm.getFieldsValue() - loadWallet(page, size, merchantPermissions, { - reference_no: values.reference_no || undefined, - type: values.type || undefined, - }) - }, [loadWallet, merchantPermissions, walletFilterForm]) + const handleLoadLedger = useCallback( + (page: number, size: number) => { + const values = walletFilterForm.getFieldsValue(); + loadWallet(page, size, merchantPermissions, { + reference_no: values.reference_no || undefined, + type: values.type || undefined, + }); + }, + [loadWallet, merchantPermissions, walletFilterForm], + ); const openManualOrderCreate = () => { - manualOrderForm.resetFields() + manualOrderForm.resetFields(); manualOrderForm.setFieldsValue({ quantity: 1, - }) - setManualOrderOpen(true) - setManualOrderProductsLoading(true) - merchantApi.manualOrderProducts() + }); + setManualOrderOpen(true); + setManualOrderProductsLoading(true); + merchantApi + .manualOrderProducts() .then((data) => { - setManualOrderProducts(data || []) + setManualOrderProducts(data || []); if (data.length > 0) { - manualOrderForm.setFieldsValue({ sku: data[0].sku }) + manualOrderForm.setFieldsValue({ sku: data[0].sku }); } else { - message.warning('没有可用商品') + message.warning("没有可用商品"); } }) .catch((e) => { - message.error(e instanceof Error ? e.message : '商品加载失败') + message.error(e instanceof Error ? e.message : "商品加载失败"); }) - .finally(() => setManualOrderProductsLoading(false)) - } + .finally(() => setManualOrderProductsLoading(false)); + }; const submitManualOrder = async () => { - const values = await manualOrderForm.validateFields() - setManualOrderSubmitting(true) + const values = await manualOrderForm.validateFields(); + setManualOrderSubmitting(true); try { const result = await merchantApi.createManualOrder({ client_order_no: values.client_order_no, @@ -360,122 +500,141 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer buyer_reference: values.buyer_reference, game_account: values.game_account, note: values.note, - }) - setManualOrderOpen(false) - setOrderSource('manual') - message.success(result.idempotent ? '已找到已有手动订单,未重复扣款' : '手动订单已创建,余额与库存已扣减') - await loadOrders(1, orders.size, 'manual') + }); + setManualOrderOpen(false); + setOrderSource("manual"); + message.success( + result.idempotent + ? "已找到已有手动订单,未重复扣款" + : "手动订单已创建,余额与库存已扣减", + ); + await loadOrders(1, orders.size, "manual"); } catch (e) { - message.error(e instanceof Error ? e.message : '创建失败') + message.error(e instanceof Error ? e.message : "创建失败"); } finally { - setManualOrderSubmitting(false) + setManualOrderSubmitting(false); } - } + }; - const changeOrderSource = (source: 'api' | 'manual') => { - setOrderSource(source) - loadOrders(1, orders.size, source, orderFilters).catch((e) => message.error(e instanceof Error ? e.message : '订单加载失败')) - } + const changeOrderSource = (source: "api" | "manual") => { + setOrderSource(source); + loadOrders(1, orders.size, source, orderFilters).catch((e) => + message.error(e instanceof Error ? e.message : "订单加载失败"), + ); + }; - const changeOrderFilters = (filters: { keyword: string; order_status: string }) => { - setOrderFilters(filters) - loadOrders(1, orders.size, orderSource, filters).catch((e) => message.error(e instanceof Error ? e.message : '订单加载失败')) - } + const changeOrderFilters = (filters: { + keyword: string; + order_status: string; + }) => { + setOrderFilters(filters); + loadOrders(1, orders.size, orderSource, filters).catch((e) => + message.error(e instanceof Error ? e.message : "订单加载失败"), + ); + }; - const cancelOrder = useCallback(async (orderNo: string, reason: string) => { - const result = await merchantApi.cancelOrder(orderNo, reason) - message.success(`订单已取消,已退还 ${Number(result.refund_amount || 0)} 积分`) - await loadOrders() - }, [loadOrders]) + const cancelOrder = useCallback( + async (orderNo: string, reason: string) => { + const result = await merchantApi.cancelOrder(orderNo, reason); + message.success( + `订单已取消,已退还 ${Number(result.refund_amount || 0)} 积分`, + ); + await loadOrders(); + }, + [loadOrders], + ); const submitAPIClient = async () => { - const values = await apiClientForm.validateFields() + const values = await apiClientForm.validateFields(); try { const credential = await merchantApi.createApiClient({ name: values.name, - scopes: (values.scopes || []).join(','), + scopes: (values.scopes || []).join(","), signature_version: values.signature_version, expires_at: values.expires_at, - }) - setApiCredential(credential) - setApiClientOpen(false) - message.success('API 密钥已创建') - loadAPIClients() + }); + setApiCredential(credential); + setApiClientOpen(false); + message.success("API 密钥已创建"); + loadAPIClients(); } catch (e) { - message.error(e instanceof Error ? e.message : '创建失败') + message.error(e instanceof Error ? e.message : "创建失败"); } - } + }; const submitCallback = async () => { - const values = await callbackForm.validateFields() + const values = await callbackForm.validateFields(); try { const credential = await merchantApi.saveCallback({ url: values.url, - events: (values.events || []).join(','), + events: (values.events || []).join(","), status: values.status, - }) - setCallback(credential.subscription) + }); + setCallback(credential.subscription); if (credential.secret) { - setCallbackCredential(credential) + setCallbackCredential(credential); } - message.success('回调配置已保存') + message.success("回调配置已保存"); } catch (e) { - message.error(e instanceof Error ? e.message : '保存失败') + message.error(e instanceof Error ? e.message : "保存失败"); } - } + }; const rotateCallbackSecret = async () => { - const values = await callbackForm.validateFields() + const values = await callbackForm.validateFields(); try { const credential = await merchantApi.saveCallback({ url: values.url, - events: (values.events || []).join(','), + events: (values.events || []).join(","), status: values.status, rotate_secret: true, - }) - setCallback(credential.subscription) + }); + setCallback(credential.subscription); if (credential.secret) { - setCallbackCredential(credential) + setCallbackCredential(credential); } - message.success('回调密钥已重置,旧密钥立即失效') + message.success("回调密钥已重置,旧密钥立即失效"); } catch (e) { - message.error(e instanceof Error ? e.message : '重置失败') + message.error(e instanceof Error ? e.message : "重置失败"); } - } + }; const submitMember = async () => { - const values = await memberForm.validateFields() + const values = await memberForm.validateFields(); try { await merchantApi.addMember({ username: values.username, password: values.password, nickname: values.nickname, role: values.role, - }) - setMemberOpen(false) - message.success('成员已添加') - loadMembers() + }); + setMemberOpen(false); + message.success("成员已添加"); + loadMembers(); } catch (e) { - message.error(e instanceof Error ? e.message : '添加失败') + message.error(e instanceof Error ? e.message : "添加失败"); } - } + }; const submitMemberEdit = async () => { - if (!editingMember) return + if (!editingMember) return; try { - const values = await memberEditForm.validateFields() + const values = await memberEditForm.validateFields(); await merchantApi.updateMember(editingMember.id, { + username: (values.username || "").trim(), + password: (values.new_password || "").trim(), + nickname: (values.nickname || "").trim(), role: values.role, status: values.status, is_default: values.is_default || false, - }) - setEditingMember(null) - message.success('成员已更新') - loadMembers() + }); + setEditingMember(null); + message.success("成员已更新"); + loadMembers(); } catch (e) { - message.error(e instanceof Error ? e.message : '更新失败') + message.error(e instanceof Error ? e.message : "更新失败"); } - } + }; const toggleMemberStatus = async (member: MerchantMember) => { try { @@ -483,166 +642,260 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer role: member.role, status: member.status === 1 ? 0 : 1, is_default: member.is_default, - }) - message.success(member.status === 1 ? '员工已停用' : '员工已启用') - loadMembers() + }); + message.success(member.status === 1 ? "员工已停用" : "员工已启用"); + loadMembers(); } catch (e) { - message.error(e instanceof Error ? e.message : '操作失败') + message.error(e instanceof Error ? e.message : "操作失败"); } - } + }; const removeMember = async (member: MerchantMember) => { try { - await merchantApi.removeMember(member.id) - message.success('员工已从当前商户移除') - loadMembers() + await merchantApi.removeMember(member.id); + message.success("员工已从当前商户移除"); + loadMembers(); } catch (e) { - message.error(e instanceof Error ? e.message : '移除失败') + message.error(e instanceof Error ? e.message : "移除失败"); } - } + }; const submitRole = async () => { - const values = await roleForm.validateFields() + const values = await roleForm.validateFields(); try { if (editingRole) { - await merchantApi.updateRole(editingRole.id, { name: values.name, permissions: values.permissions || [] }) + await merchantApi.updateRole(editingRole.id, { + name: values.name, + permissions: values.permissions || [], + }); } else { - await merchantApi.createRole({ ...values, permissions: values.permissions || [] }) + await merchantApi.createRole({ + ...values, + permissions: values.permissions || [], + }); } - setRoleOpen(false) - setEditingRole(null) - message.success(editingRole ? '角色已更新' : '角色已创建') - loadMembers() + setRoleOpen(false); + setEditingRole(null); + message.success(editingRole ? "角色已更新" : "角色已创建"); + loadMembers(); } catch (e) { - message.error(e instanceof Error ? e.message : '创建失败') + message.error(e instanceof Error ? e.message : "创建失败"); } - } + }; const toggleAPIClient = async (record: ApiClient) => { try { - await merchantApi.updateApiClientStatus(record.id, record.status === 'active' ? 'disabled' : 'active') - message.success('状态已更新') - loadAPIClients() + await merchantApi.updateApiClientStatus( + record.id, + record.status === "active" ? "disabled" : "active", + ); + message.success("状态已更新"); + loadAPIClients(); } catch (e) { - message.error(e instanceof Error ? e.message : '更新失败') + message.error(e instanceof Error ? e.message : "更新失败"); } - } + }; const deleteAPIClient = async (record: ApiClient) => { try { - await merchantApi.deleteApiClient(record.id) - message.success('密钥已删除') - loadAPIClients() + await merchantApi.deleteApiClient(record.id); + message.success("密钥已删除"); + loadAPIClients(); } catch (e) { - message.error(e instanceof Error ? e.message : '删除失败') + message.error(e instanceof Error ? e.message : "删除失败"); } - } + }; const updateProduct = async ( id: number, data: { - display_name?: string - price_amount?: number - cost_amount?: number - stock?: number - status?: 'active' | 'inactive' - } + display_name?: string; + price_amount?: number; + cost_amount?: number; + stock?: number; + status?: "active" | "inactive"; + }, ) => { try { - await merchantApi.updateProduct(id, data) - message.success('商品信息更新成功') - loadProducts(products.page, products.size) + await merchantApi.updateProduct(id, data); + message.success("商品信息更新成功"); + loadProducts(products.page, products.size); } catch (e) { - message.error(e instanceof Error ? e.message : '更新商品失败') - throw e + message.error(e instanceof Error ? e.message : "更新商品失败"); + throw e; } - } + }; const tabItems = [ - { key: 'products', label: '商品列表', disabled: !hasFeature('products'), children: ( - - ) }, - { key: 'orders', label: '发货订单', disabled: !hasFeature('orders'), children: ( - - ) }, - { key: 'wallet', label: '钱包', disabled: !hasFeature('wallet'), children: ( - - ) }, - { key: 'api', label: 'API 密钥', disabled: !hasFeature('api'), children: ( - setApiClientOpen(true)} onNavigateDocs={() => navigate('/open-api')} onNavigateDebug={() => navigate('/api-debug')} onToggleClient={toggleAPIClient} onDeleteClient={deleteAPIClient} /> - ) }, - { key: 'callbacks', label: '回调', disabled: !hasFeature('callbacks'), children: ( - - ) }, - { key: 'members', label: '成员', children: ( - setMemberOpen(true)} - onOpenCreateRole={() => { - setEditingRole(null) - setRoleOpen(true) - }} - onEditRole={(role) => { - setEditingRole(role) - roleForm.setFieldsValue({ - name: role.name, - code: role.code, - permissions: role.permissions.split(',').filter(Boolean), - }) - setRoleOpen(true) - }} - onEditMember={(member) => { - setEditingMember(member) - memberEditForm.setFieldsValue({ role: member.role, status: member.status, is_default: member.is_default }) - }} - onToggleMemberStatus={toggleMemberStatus} - onRemoveMember={removeMember} - currentUserID={user?.id} - /> - ) }, - ] - const fixedTabContent = tabItems.find((item) => item.key === fixedTab)?.children + { + key: "products", + label: "商品列表", + disabled: !hasFeature("products"), + children: ( + + ), + }, + { + key: "orders", + label: "发货订单", + disabled: !hasFeature("orders"), + children: ( + + ), + }, + { + key: "wallet", + label: "钱包", + disabled: !hasFeature("wallet"), + children: ( + + ), + }, + { + key: "api", + label: "API 密钥", + disabled: !hasFeature("api"), + children: ( + setApiClientOpen(true)} + onNavigateDocs={() => navigate("/open-api")} + onNavigateDebug={() => navigate("/api-debug")} + onToggleClient={toggleAPIClient} + onDeleteClient={deleteAPIClient} + /> + ), + }, + { + key: "callbacks", + label: "回调", + disabled: !hasFeature("callbacks"), + children: ( + + ), + }, + { + key: "members", + label: "成员", + children: ( + setMemberOpen(true)} + onOpenCreateRole={() => { + setEditingRole(null); + setRoleOpen(true); + }} + onEditRole={(role) => { + setEditingRole(role); + roleForm.setFieldsValue({ + name: role.name, + code: role.code, + permissions: role.permissions.split(",").filter(Boolean), + }); + setRoleOpen(true); + }} + onEditMember={(member) => { + setEditingMember(member); + memberEditForm.setFieldsValue({ + username: member.user?.username || "", + nickname: member.user?.nickname || "", + new_password: "", + role: member.role, + status: member.status, + is_default: member.is_default, + }); + }} + onToggleMemberStatus={toggleMemberStatus} + onRemoveMember={removeMember} + onReload={() => void loadMembers()} + currentUserID={user?.id} + /> + ), + }, + ]; + const fixedTabContent = tabItems.find( + (item) => item.key === fixedTab, + )?.children; const parentCategoryMap: Record = { - products: '商品管理', - orders: '订单管理', - wallet: '积分管理', - api: '开放 API', - callbacks: '开放 API', - members: '员工管理', - } + products: "商品管理", + orders: "订单管理", + wallet: "积分管理", + api: "开放 API", + callbacks: "开放 API", + members: "员工管理", + }; - const currentTabKey = fixedTab ?? activeTab - const parentGroup = parentCategoryMap[currentTabKey] + const currentTabKey = fixedTab ?? activeTab; + const parentGroup = parentCategoryMap[currentTabKey]; const computedBreadcrumbs = useMemo(() => { - const pageTitle = title === '商品' ? '商品列表' : title + const pageTitle = title === "商品" ? "商品列表" : title; if (parentGroup) { - return [{ title: parentGroup }, { title: pageTitle }] + return [{ title: parentGroup }, { title: pageTitle }]; } - return [{ title: pageTitle }] - }, [parentGroup, title]) + return [{ title: pageTitle }]; + }, [parentGroup, title]); return (
} loading={loading} onClick={loadAll}> @@ -651,7 +904,9 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer } /> - {fixedTab ? fixedTabContent : ( + {fixedTab ? ( + fixedTabContent + ) : ( setActiveTab(key as MerchantCenterTab)} @@ -659,12 +914,35 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer /> )} - setManualOrderOpen(false)} destroyOnClose width={560}> -
- + setManualOrderOpen(false)} + destroyOnClose + width={560} + > + + - + - + @@ -687,74 +977,214 @@ export default function MerchantCenter({ fixedTab, title = '商户中心' }: Mer - setApiClientOpen(false)} destroyOnClose> + setApiClientOpen(false)} + destroyOnClose + >
- + + - + - ({ + value: item.code, + label: item.name, + }))} + />
- setEditingMember(null)} destroyOnClose> + setEditingMember(null)} + destroyOnClose + >
- + + + + + + + + + - ({ + value: item.code, + label: item.name, + }))} + /> - - +
- { setRoleOpen(false); setEditingRole(null) }} destroyOnClose> + { + setRoleOpen(false); + setEditingRole(null); + }} + destroyOnClose + >
- - {!editingRole && } - + + {!editingRole && ( + + + + )} + + + + + + +
+
+ + {appKey && (
App Key - {appKey} + + {appKey} +
)}
Secret - {secret} + + {secret} +
-
- ) + ); } export function ProductsTab({ @@ -71,175 +117,227 @@ export function ProductsTab({ onLoadProducts, onUpdateProduct, }: { - loading: boolean - products: PageResult - canManage: boolean - canEditPricing?: boolean - filters: { keyword: string; status: string } - onToggleProduct: (record: MerchantProduct) => void - onDeleteProduct: (record: MerchantProduct) => void - onFilterChange: (filters: { keyword: string; status: string }) => void - onLoadProducts: (page?: number, size?: number) => void + loading: boolean; + products: PageResult; + canManage: boolean; + canEditPricing?: boolean; + filters: { keyword: string; status: string }; + onToggleProduct: (record: MerchantProduct) => void; + onDeleteProduct: (record: MerchantProduct) => void; + onFilterChange: (filters: { keyword: string; status: string }) => void; + onLoadProducts: (page?: number, size?: number) => void; onUpdateProduct?: ( id: number, data: { - display_name?: string - price_amount?: number - cost_amount?: number - stock?: number - status?: 'active' | 'inactive' - } - ) => Promise + display_name?: string; + price_amount?: number; + cost_amount?: number; + stock?: number; + status?: "active" | "inactive"; + }, + ) => Promise; }) { - const [keyword, setKeyword] = useState(filters.keyword) - const [editModalOpen, setEditModalOpen] = useState(false) - const [editingProduct, setEditingProduct] = useState(null) - const [editForm] = Form.useForm() - const [editSubmitting, setEditSubmitting] = useState(false) - const [stockMode, setStockMode] = useState<'unlimited' | 'custom'>('unlimited') + const [keyword, setKeyword] = useState(filters.keyword); + const [editModalOpen, setEditModalOpen] = useState(false); + const [editingProduct, setEditingProduct] = useState( + null, + ); + const [editForm] = Form.useForm(); + const [editSubmitting, setEditSubmitting] = useState(false); + const [stockMode, setStockMode] = useState<"unlimited" | "custom">( + "unlimited", + ); useEffect(() => { - setKeyword(filters.keyword) - }, [filters.keyword]) + setKeyword(filters.keyword); + }, [filters.keyword]); - const submitKeyword = () => onFilterChange({ ...filters, keyword: keyword.trim() }) + const submitKeyword = () => + onFilterChange({ ...filters, keyword: keyword.trim() }); const openEditModal = (record: MerchantProduct) => { - setEditingProduct(record) - const isUnlimited = record.stock < 0 - setStockMode(isUnlimited ? 'unlimited' : 'custom') + setEditingProduct(record); + const isUnlimited = record.stock < 0; + setStockMode(isUnlimited ? "unlimited" : "custom"); editForm.setFieldsValue({ - display_name: record.display_name || record.product?.name || '', + display_name: record.display_name || record.product?.name || "", price_amount: record.price_amount, cost_amount: record.cost_amount, - stock_mode: isUnlimited ? 'unlimited' : 'custom', + stock_mode: isUnlimited ? "unlimited" : "custom", stock_count: isUnlimited ? 100 : record.stock, status: record.status, - }) - setEditModalOpen(true) - } + }); + setEditModalOpen(true); + }; const handleEditSubmit = async () => { - if (!editingProduct || !onUpdateProduct) return - const values = await editForm.validateFields() - setEditSubmitting(true) + if (!editingProduct || !onUpdateProduct) return; + const values = await editForm.validateFields(); + setEditSubmitting(true); try { - const stock = values.stock_mode === 'unlimited' ? -1 : Number(values.stock_count ?? 0) + const stock = + values.stock_mode === "unlimited" + ? -1 + : Number(values.stock_count ?? 0); await onUpdateProduct(editingProduct.id, { display_name: values.display_name?.trim(), price_amount: Number(values.price_amount), cost_amount: Number(values.cost_amount), stock, status: values.status, - }) - setEditModalOpen(false) + }); + setEditModalOpen(false); } finally { - setEditSubmitting(false) + setEditSubmitting(false); } - } + }; const productColumns: ColumnsType = [ { - title: '商户 SKU', - dataIndex: 'sku', + title: "商户 SKU", + dataIndex: "sku", width: 180, ellipsis: true, - render: (v) => {v}, + render: (v) => ( + + {v} + + ), }, { - title: '商品名称', - dataIndex: 'display_name', + title: "商品名称", + dataIndex: "display_name", width: 220, ellipsis: true, render: (_, r) => (
-
{r.display_name || r.product?.name || '-'}
+
+ {r.display_name || r.product?.name || "-"} +
{r.product?.category && ( - {r.product.category} + + {r.product.category} + )}
), }, { - title: '目录编码', - dataIndex: ['product', 'code'], + title: "目录编码", + dataIndex: ["product", "code"], width: 150, ellipsis: true, - render: (_, r) => r.product?.code ? {r.product.code} : '-', + render: (_, r) => + r.product?.code ? ( + {r.product.code} + ) : ( + "-" + ), }, { - title: '对外售价', - dataIndex: 'price_amount', + title: "对外售价", + dataIndex: "price_amount", width: 120, - render: (v) => {money(v)}, + render: (v) => ( + {money(v)} + ), }, { - title: '成本价', - dataIndex: 'cost_amount', + title: "成本价", + dataIndex: "cost_amount", width: 120, - render: (v) => {money(v)}, + render: (v) => {money(v)}, }, { - title: '当前库存', - dataIndex: 'stock', + title: "当前库存", + dataIndex: "stock", width: 110, - render: (v) => (v < 0 ? 无限库存 : v === 0 ? 已缺货 : `${v} 件`), + render: (v) => + v < 0 ? ( + 无限库存 + ) : v === 0 ? ( + 已缺货 + ) : ( + `${v} 件` + ), }, { - title: '状态', - dataIndex: 'status', + title: "状态", + dataIndex: "status", width: 100, render: productStatusTag, }, { - title: '操作', - key: 'action', + title: "操作", + key: "action", width: 160, - fixed: 'right', - render: (_, record) => canManage ? ( - - {canEditPricing && ( - - )} - onToggleProduct(record)} - > - - - {record.status === 'inactive' && ( + fixed: "right", + render: (_, record) => + canManage ? ( + + {canEditPricing && ( + + )} onDeleteProduct(record)} + title={ + record.status === "active" ? "下架该商品?" : "上架该商品?" + } + description={ + record.status === "active" + ? "下架后商户将无法再创建该商品订单" + : "上架后商户可正常下单" + } + okText="确认" + onConfirm={() => onToggleProduct(record)} > - - )} - - ) : '-', + {record.status === "inactive" && ( + onDeleteProduct(record)} + > + + + )} + + ) : ( + "-" + ), }, - ] + ]; return ( - - + +
- 商品上架与售卖管理 + + 商品上架与售卖管理 + 可编辑商品名称、对外积分售价、成本价及库存模式;已下架商品可删除。 @@ -260,15 +358,19 @@ export function ProductsTab({ value={filters.status || undefined} style={{ width: 120 }} options={[ - { value: 'active', label: '已上架' }, - { value: 'inactive', label: '已下架' }, + { value: "active", label: "已上架" }, + { value: "inactive", label: "已下架" }, ]} - onChange={(status) => onFilterChange({ ...filters, status: status || '' })} + onChange={(status) => + onFilterChange({ ...filters, status: status || "" }) + } /> - @@ -283,7 +385,7 @@ export function ProductsTab({ {/* 编辑商品售价与库存 Modal */} setEditModalOpen(false)} @@ -293,12 +395,14 @@ export function ProductsTab({ >
- {editingProduct?.sku} + + {editingProduct?.sku} + @@ -307,13 +411,13 @@ export function ProductsTab({ @@ -322,31 +426,31 @@ export function ProductsTab({ - + setStockMode(e.target.value)}> 无限库存 (推荐) 指定有限库存 - {stockMode === 'custom' && ( + {stockMode === "custom" && ( @@ -362,7 +466,7 @@ export function ProductsTab({
- ) + ); } export function OrdersTab({ @@ -381,117 +485,181 @@ export function OrdersTab({ onRestoreLink, onCancelOrder, }: { - loading: boolean - orders: PageResult - orderSource: 'api' | 'manual' - filters: { keyword: string; order_status: string } - canManage: boolean - onCreateManualOrder: () => void - onOrderSourceChange: (source: 'api' | 'manual') => void - onFilterChange: (filters: { keyword: string; order_status: string }) => void - onLoadOrders: (page?: number, size?: number) => void - onCopyLink: (orderNo: string) => void - onOpenLink: (orderNo: string) => void - onRevokeLink: (orderNo: string) => void - onRestoreLink: (orderNo: string) => void - onCancelOrder: (orderNo: string, reason: string) => Promise | void + loading: boolean; + orders: PageResult; + orderSource: "api" | "manual"; + filters: { keyword: string; order_status: string }; + canManage: boolean; + onCreateManualOrder: () => void; + onOrderSourceChange: (source: "api" | "manual") => void; + onFilterChange: (filters: { keyword: string; order_status: string }) => void; + onLoadOrders: (page?: number, size?: number) => void; + onCopyLink: (orderNo: string) => void; + onOpenLink: (orderNo: string) => void; + onRevokeLink: (orderNo: string) => void; + onRestoreLink: (orderNo: string) => void; + onCancelOrder: (orderNo: string, reason: string) => Promise | void; }) { - const [keyword, setKeyword] = useState(filters.keyword) - const [cancelling, setCancelling] = useState(null) - const [cancelReason, setCancelReason] = useState('') - const [cancelSubmitting, setCancelSubmitting] = useState(false) + const [keyword, setKeyword] = useState(filters.keyword); + const [cancelling, setCancelling] = useState(null); + const [cancelReason, setCancelReason] = useState(""); + const [cancelSubmitting, setCancelSubmitting] = useState(false); useEffect(() => { - setKeyword(filters.keyword) - }, [filters.keyword]) + setKeyword(filters.keyword); + }, [filters.keyword]); - const submitKeyword = () => onFilterChange({ ...filters, keyword: keyword.trim() }) + const submitKeyword = () => + onFilterChange({ ...filters, keyword: keyword.trim() }); const submitCancel = async () => { - if (!cancelling) return + if (!cancelling) return; if (!cancelReason.trim()) { - message.warning('请填写取消原因') - return + message.warning("请填写取消原因"); + return; } - setCancelSubmitting(true) + setCancelSubmitting(true); try { - await onCancelOrder(cancelling.order_no, cancelReason.trim()) - setCancelling(null) - setCancelReason('') + await onCancelOrder(cancelling.order_no, cancelReason.trim()); + setCancelling(null); + setCancelReason(""); } finally { - setCancelSubmitting(false) + setCancelSubmitting(false); } - } + }; const orderColumns: ColumnsType = [ { - title: '平台订单号', - dataIndex: 'order_no', + title: "平台订单号", + dataIndex: "order_no", width: 190, ellipsis: true, - render: (v) => {v}, + render: (v) => ( + + {v} + + ), }, - { title: '商户单号', dataIndex: 'client_order_no', width: 140, ellipsis: true }, { - title: 'SKU', - dataIndex: 'product_sku', + title: "商户单号", + dataIndex: "client_order_no", + width: 140, + ellipsis: true, + }, + { + title: "SKU", + dataIndex: "product_sku", width: 130, ellipsis: true, - render: (v) => {v}, + render: (v) => ( + + {v} + + ), }, - { title: '商品', dataIndex: 'product_name', width: 220, ellipsis: true }, - { title: '基础金额', dataIndex: 'base_amount', width: 88, render: money }, - { title: '手续费', dataIndex: 'service_fee_amount', width: 82, render: money }, - { title: '扣款合计', dataIndex: 'amount', width: 88, render: money }, - { title: '状态', dataIndex: 'order_status', width: 92, render: orderStatusTag }, + { title: "商品", dataIndex: "product_name", width: 220, ellipsis: true }, + { title: "基础金额", dataIndex: "base_amount", width: 88, render: money }, { - title: '链接有效期', - dataIndex: 'delivery_link_expires_at', + title: "手续费", + dataIndex: "service_fee_amount", + width: 82, + render: money, + }, + { title: "扣款合计", dataIndex: "amount", width: 88, render: money }, + { + title: "状态", + dataIndex: "order_status", + width: 92, + render: orderStatusTag, + }, + { + title: "链接有效期", + dataIndex: "delivery_link_expires_at", width: 140, render: (_, record) => { - if (record.delivery_link_revoked_at) return 已作废 - if (!record.delivery_link_expires_at) return 永久有效 - return formatDateTime(record.delivery_link_expires_at) + if (record.delivery_link_revoked_at) + return 已作废; + if (!record.delivery_link_expires_at) + return 永久有效; + return formatDateTime(record.delivery_link_expires_at); }, }, - { title: '时间', dataIndex: 'created_at', width: 150, render: formatDateTime }, { - title: '发货链接', - key: 'delivery_link', + title: "时间", + dataIndex: "created_at", + width: 150, + render: formatDateTime, + }, + { + title: "发货链接", + key: "delivery_link", width: 160, - fixed: 'right', + fixed: "right", render: (_, record) => { // 非可发货状态不再展示链接作废等操作;已交付订单保留复制/打开,便于查看兑换结果。 - if (record.order_status === 'cancelled') { - return 订单已取消 + if (record.order_status === "cancelled") { + return 订单已取消; } - if (record.order_status === 'delivering') { - return 发货中 + if (record.order_status === "delivering") { + return 发货中; } - if (record.order_status === 'delivered') { + if (record.order_status === "delivered") { if (record.delivery_link_revoked_at) { - return 已作废 + return 已作废; } // expires_at 为空表示永久有效,不视为过期 - const expired = !!record.delivery_link_expires_at - && new Date(record.delivery_link_expires_at).getTime() < Date.now() + const expired = + !!record.delivery_link_expires_at && + new Date(record.delivery_link_expires_at).getTime() < Date.now(); if (expired) { - return 已过期 + return 已过期; } return ( - - + + - ) + ); } if (record.delivery_link_revoked_at) { - return + return ( + + ); } return ( - - + + - ) + ); } - return '-' + return "-"; }, }, - ] + ]; return ( - - + + onOrderSourceChange(key as 'api' | 'manual')} - items={[{ key: 'api', label: 'API 订单' }, { key: 'manual', label: '手动订单' }]} + onChange={(key) => onOrderSourceChange(key as "api" | "manual")} + items={[ + { key: "api", label: "API 订单" }, + { key: "manual", label: "手动订单" }, + ]} style={{ minWidth: 220 }} /> {canManage && ( - )} - + onFilterChange({ ...filters, keyword: '' })} - style={{ width: 300, maxWidth: '100%' }} + onClear={() => onFilterChange({ ...filters, keyword: "" })} + style={{ width: 300, maxWidth: "100%" }} /> {orderStatusOptions.map((option) => ( - onToggleMemberStatus(member)}> - - - onRemoveMember(member)} okButtonProps={{ danger: true }}> - - - + const phone = member.user?.phone_masked || ""; + return ( + + {phone ? ( + {phone} + ) : ( + 未绑定 + )} + {canEditSecurity(member) && ( + + )} + + ); }, }, - ] + { + title: "登录设备", + width: 210, + render: (_, member) => { + const bound = Boolean(member.user?.device_bound); + return ( + + {bound ? ( + {member.user?.device_id_masked || "已绑定"} + ) : ( + 未绑定 + )} + {bound && canEditSecurity(member) && ( + resetDevice(member)} + > + + + )} + + ); + }, + }, + { + title: "角色", + dataIndex: "role", + width: 140, + render: (value) => ( + {roleNames.get(value) || roleText(value)} + ), + }, + { + title: "默认", + dataIndex: "is_default", + width: 80, + render: (v) => (v ? 默认 : "-"), + }, + { + title: "状态", + dataIndex: "status", + width: 90, + render: (v) => + v === 1 ? 启用 : 禁用, + }, + { + title: "操作", + width: 210, + render: (_, member) => { + const protectedMember = + member.role === "owner" || member.user_id === currentUserID; + if (!canManage) return "-"; + if (protectedMember) + return 受保护; + const action = member.status === 1 ? "停用" : "启用"; + return ( + + + onToggleMemberStatus(member)} + > + + + onRemoveMember(member)} + okButtonProps={{ danger: true }} + > + + + + ); + }, + }, + ]; return ( - - - 成员仅属于当前商户;角色和权限也在当前商户内独立配置。 - {canManage && - - - } + + + + 成员仅属于当前商户;角色和权限也在当前商户内独立配置。 + + {canManage && ( + + + + + )} - +
role.id || role.code} @@ -1089,29 +1663,105 @@ export function MembersTab({ pagination={false} dataSource={roles} columns={[ - { title: '角色', dataIndex: 'name', width: 160, render: (name, role) => {name}{role.code === 'owner' && 固定} }, - { title: '权限', dataIndex: 'permissions', render: (permissions) => permissions ? permissions.split(',').map((item: string) => {rolePermissionText(item)}) : '-' }, - { title: '操作', width: 90, render: (_, role) => role.code === 'owner' ? '-' : }, + { + title: "角色", + dataIndex: "name", + width: 160, + render: (name, role) => ( + + + {name} + + {role.code === "owner" && ( + 固定 + )} + + ), + }, + { + title: "权限", + dataIndex: "permissions", + render: (permissions) => + permissions + ? permissions + .split(",") + .map((item: string) => ( + {rolePermissionText(item)} + )) + : "-", + }, + { + title: "操作", + width: 90, + render: (_, role) => + role.code === "owner" ? ( + "-" + ) : ( + + ), + }, ]} /> + + setPhoneTarget(null)} + onOk={() => void submitPhone()} + confirmLoading={phoneSaving} + okText="保存" + cancelText="取消" + > + + 绑定后该成员登录时需要短信验证码,并强制校验本机 DCAuth 设备。 + + setPhoneValue(event.target.value)} + onPressEnter={() => void submitPhone()} + placeholder="请输入 11 位手机号" + maxLength={11} + inputMode="numeric" + prefix={} + /> + - ) + ); } function moneyWithSign(value?: number | null) { - const amount = Number(value || 0) + const amount = Number(value || 0); if (amount > 0) { - return +{money(amount)} + return ( + + +{money(amount)} + + ); } if (amount < 0) { - return {money(amount)} + return ( + {money(amount)} + ); } - return {money(amount)} + return ( + {money(amount)} + ); } function productStatusTag(value: string) { - return value === 'active' ? ( + return value === "active" ? ( 已上架 @@ -1121,11 +1771,11 @@ function productStatusTag(value: string) { 已下架 - ) + ); } function activeStatusTag(value: string) { - return value === 'active' ? ( + return value === "active" ? ( 已启用 @@ -1135,46 +1785,48 @@ function activeStatusTag(value: string) { 已禁用 - ) + ); } function orderStatusTag(value: string) { - const item = orderStatusMap[value] || { color: 'default', text: value } + const item = orderStatusMap[value] || { color: "default", text: value }; const tagClassMap: Record = { - paid: 'status-tag--blue', - delivering: 'status-tag--cyan', - delivered: 'status-tag--green', - ship_failed: 'status-tag--red', - cancelled: 'status-tag--gray', - } - const tagClass = tagClassMap[value] || 'status-tag--gray' + paid: "status-tag--blue", + delivering: "status-tag--cyan", + delivered: "status-tag--green", + ship_failed: "status-tag--red", + cancelled: "status-tag--gray", + }; + const tagClass = tagClassMap[value] || "status-tag--gray"; return ( {item.text} - ) + ); } function ledgerTypeTag(value: string) { const map: Record = { - credit: { color: 'green', text: '入账' }, - debit: { color: 'red', text: '扣款' }, - refund: { color: 'purple', text: '退款' }, - adjust: { color: 'blue', text: '调整' }, - } - const item = map[value] || { color: 'default', text: value } - return {item.text} + credit: { color: "green", text: "入账" }, + debit: { color: "red", text: "扣款" }, + refund: { color: "purple", text: "退款" }, + adjust: { color: "blue", text: "调整" }, + }; + const item = map[value] || { color: "default", text: value }; + return {item.text}; } function scopesTag(value: string) { - const scopes = (value || '').split(',').filter(Boolean) + const scopes = (value || "").split(",").filter(Boolean); if (scopes.length === 0) { - return '-' + return "-"; } return ( - {scopes.map((s) => {scopeLabel(s)})} + {scopes.map((s) => ( + {scopeLabel(s)} + ))} - ) + ); } diff --git a/frontend/src/store/auth.tsx b/frontend/src/store/auth.tsx index 025c604..3b8c416 100644 --- a/frontend/src/store/auth.tsx +++ b/frontend/src/store/auth.tsx @@ -5,7 +5,7 @@ import { useState, type ReactNode, } from 'react' -import type { Merchant, User } from '../types' +import type { LoginResult, Merchant, User } from '../types' import { authApi, merchantApi } from '../api' import { AuthContext } from './authContext' @@ -58,8 +58,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { void refreshMerchantContext() }, [refreshMerchantContext]) - const login = useCallback(async (username: string, password: string) => { - const result = await authApi.login(username, password) + const applyLoginResult = useCallback((result: LoginResult) => { localStorage.removeItem('merchant_id') localStorage.setItem('token', result.token) localStorage.setItem('user', JSON.stringify(result.user)) @@ -68,6 +67,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { setMerchantLoading(true) }, []) + const login = useCallback(async (username: string, password: string) => { + const result = await authApi.login(username, password) + applyLoginResult(result) + }, [applyLoginResult]) + const logout = useCallback(() => { localStorage.removeItem('token') localStorage.removeItem('user') @@ -91,6 +95,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { token, user, login, + applyLoginResult, logout, refreshProfile, merchant, @@ -100,7 +105,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { refreshMerchantContext, isAdmin: user?.role === 'admin', }), - [token, user, login, logout, refreshProfile, merchant, merchantRole, merchantPermissions, merchantLoading, refreshMerchantContext], + [token, user, login, applyLoginResult, logout, refreshProfile, merchant, merchantRole, merchantPermissions, merchantLoading, refreshMerchantContext], ) return {children} diff --git a/frontend/src/store/authContext.ts b/frontend/src/store/authContext.ts index 216f197..a6c3bbc 100644 --- a/frontend/src/store/authContext.ts +++ b/frontend/src/store/authContext.ts @@ -1,10 +1,11 @@ import { createContext, useContext } from 'react' -import type { Merchant, User } from '../types' +import type { LoginResult, Merchant, User } from '../types' export interface AuthState { token: string | null user: User | null login: (username: string, password: string) => Promise + applyLoginResult: (result: LoginResult) => void logout: () => void refreshProfile: () => Promise merchant: Merchant | null diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 715d59f..835b5d1 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -5,6 +5,13 @@ export interface User { role: 'admin' | 'merchant' status: number created_at: string + phone?: string + phone_masked?: string + phone_bound_at?: string | null + phone_bound_by?: number | null + device_id?: string + device_id_masked?: string + device_bound?: boolean } export interface Merchant { @@ -320,6 +327,29 @@ export interface LoginResult { user: User } +export interface SendLoginSMSCodeResult { + sent: boolean + phoneMasked: string + expiresInSeconds: number +} + +export interface PreparedDeviceLogin { + challengeId: string + challenge: string + expiresAt: string + pollToken: string + launchUrl: string + phoneMasked: string + app: string +} + +export interface DCAuthChallengeStatus { + status: 'pending' | 'authorized' | 'ticket_issued' | 'consumed' | 'expired' | 'rejected' | string + deviceTicket?: string + deviceId?: string + message?: string +} + export interface RechargeApplication { id: number application_no: string