From ba463ae38d9c43df8b929bc3be66ed2e1ab86412 Mon Sep 17 00:00:00 2001 From: liangzai <2440983361@qq.com> Date: Sat, 25 Apr 2026 11:59:10 +0800 Subject: [PATCH] f --- config.yaml | 42 +- configs/env.development.yaml | 2 + configs/env.production.yaml | 6 +- internal/app/app.go | 6 + .../api/api_application_service.go | 21 +- .../certification_application_service_impl.go | 30 +- .../product/self_subscribe_policy.go | 16 + .../subscription_application_service_impl.go | 18 +- .../dto/commands/subordinate_commands.go | 55 +++ .../dto/responses/subordinate_responses.go | 57 +++ .../application/subordinate/invite_token.go | 35 ++ .../subordinate/invite_token_test.go | 26 ++ .../self_subscribe_policy_subordinate.go | 30 ++ .../subordinate_application_service.go | 20 + .../subordinate_application_service_impl.go | 393 ++++++++++++++++++ .../user/dto/responses/user_responses.go | 2 + .../user/user_application_service_impl.go | 161 ++++--- internal/config/config.go | 2 + internal/container/container.go | 35 ++ .../subordinate/entities/invitation.go | 46 ++ internal/domains/subordinate/entities/link.go | 42 ++ .../subordinate/entities/wallet_allocation.go | 36 ++ .../subordinate_repository_interface.go | 31 ++ .../gorm_subordinate_repository.go | 201 +++++++++ .../repositories/user/gorm_user_repository.go | 44 +- .../http/handlers/subordinate_handler.go | 211 ++++++++++ .../http/routes/certification_routes.go | 8 +- .../http/routes/subordinate_routes.go | 46 ++ .../subordinate/account_kind_provider.go | 31 ++ internal/shared/auth/account_kind.go | 7 + .../interfaces/account_kind_provider.go | 9 + internal/shared/middleware/auth.go | 25 +- .../shared/middleware/daily_rate_limit.go | 18 +- 33 files changed, 1600 insertions(+), 112 deletions(-) create mode 100644 internal/application/product/self_subscribe_policy.go create mode 100644 internal/application/subordinate/dto/commands/subordinate_commands.go create mode 100644 internal/application/subordinate/dto/responses/subordinate_responses.go create mode 100644 internal/application/subordinate/invite_token.go create mode 100644 internal/application/subordinate/invite_token_test.go create mode 100644 internal/application/subordinate/self_subscribe_policy_subordinate.go create mode 100644 internal/application/subordinate/subordinate_application_service.go create mode 100644 internal/application/subordinate/subordinate_application_service_impl.go create mode 100644 internal/domains/subordinate/entities/invitation.go create mode 100644 internal/domains/subordinate/entities/link.go create mode 100644 internal/domains/subordinate/entities/wallet_allocation.go create mode 100644 internal/domains/subordinate/repositories/subordinate_repository_interface.go create mode 100644 internal/infrastructure/database/repositories/subordinate/gorm_subordinate_repository.go create mode 100644 internal/infrastructure/http/handlers/subordinate_handler.go create mode 100644 internal/infrastructure/http/routes/subordinate_routes.go create mode 100644 internal/infrastructure/subordinate/account_kind_provider.go create mode 100644 internal/shared/auth/account_kind.go create mode 100644 internal/shared/interfaces/account_kind_provider.go diff --git a/config.yaml b/config.yaml index 5bbc9ba..675860a 100644 --- a/config.yaml +++ b/config.yaml @@ -5,6 +5,8 @@ app: name: "TYAPI Server" version: "1.0.0" env: "development" + # 子账号入口与主站可同域;邀请链接 {sub_portal_base_url}/sub/auth/register?invite=... + sub_portal_base_url: "http://localhost:5173/" server: host: "0.0.0.0" @@ -131,13 +133,13 @@ sms: expire_time: 5m mock_enabled: false # 签名验证配置(用于防止接口被刷) - signature_enabled: true # 是否启用签名验证 - signature_secret: "TyApi2024SMSSecretKey!@#$%^&*()_+QWERTYUIOP" # 签名密钥(请修改为复杂密钥) + signature_enabled: true # 是否启用签名验证 + signature_secret: "TyApi2024SMSSecretKey!@#$%^&*()_+QWERTYUIOP" # 签名密钥(请修改为复杂密钥) # 滑块验证码配置 - captcha_enabled: true # 是否启用滑块验证码 - captcha_secret: "" # 阿里云验证码密钥(加密模式时需要,可选)EKEY - captcha_endpoint: "captcha.cn-shanghai.aliyuncs.com" # 阿里云验证码服务Endpoint - scene_id: "wynt39to" # 阿里云验证码场景ID + captcha_enabled: true # 是否启用滑块验证码 + captcha_secret: "" # 阿里云验证码密钥(加密模式时需要,可选)EKEY + captcha_endpoint: "captcha.cn-shanghai.aliyuncs.com" # 阿里云验证码服务Endpoint + scene_id: "wynt39to" # 阿里云验证码场景ID rate_limit: daily_limit: 10 hourly_limit: 5 @@ -206,7 +208,7 @@ daily_ratelimit: enable_referer: true # 是否检查Referer allowed_referers: # 允许的Referer - "https://console.tianyuanapi.com" # 天元API控制台 - - "https://consoletest.tianyuanapi.com" # 天元API测试控制台 + - "https://subsole.tianyuanapi.com" # 天元API子账号控制台 enable_proxy_check: false # 是否检查代理 enable_geo_block: false # 是否启用地理位置阻止 @@ -237,7 +239,7 @@ development: debug: true enable_profiler: true enable_cors: true - cors_allowed_origins: "http://localhost:5173,https://consoletest.tianyuanapi.com,https://console.tianyuanapi.com" + cors_allowed_origins: "http://localhost:5173,https://console.tianyuanapi.com,https://subsole.tianyuanapi.com" cors_allowed_methods: "GET,POST,PUT,PATCH,DELETE,OPTIONS" cors_allowed_headers: "Origin,Content-Type,Accept,Authorization,X-Requested-With,Access-Id" @@ -549,20 +551,20 @@ jiguang: # =========================================== pdfgen: # 服务地址配置 - development_url: "http://pdfg.tianyuanapi.com" # 开发环境服务地址 - production_url: "http://1.117.67.95:15990" # 生产环境服务地址 - + development_url: "http://pdfg.tianyuanapi.com" # 开发环境服务地址 + production_url: "http://1.117.67.95:15990" # 生产环境服务地址 + # API路径配置 - api_path: "/api/v1/generate/guangzhou" # PDF生成API路径 - + api_path: "/api/v1/generate/guangzhou" # PDF生成API路径 + # 超时配置 - timeout: 120s # 请求超时时间(120秒) - + timeout: 120s # 请求超时时间(120秒) + # 缓存配置 cache: - ttl: 24h # 缓存过期时间(24小时) - cache_dir: "" # 缓存目录(空则使用默认目录) - max_size: 0 # 最大缓存大小(0表示不限制,单位:字节) + ttl: 24h # 缓存过期时间(24小时) + cache_dir: "" # 缓存目录(空则使用默认目录) + max_size: 0 # 最大缓存大小(0表示不限制,单位:字节) # =========================================== # ✨ 数脉配置走实时接口 @@ -607,7 +609,6 @@ shumai: max_age: 30 compress: true - # =========================================== # ✨ 数据宝配置走实时接口 # =========================================== @@ -640,6 +641,3 @@ shujubao: max_backups: 5 max_age: 30 compress: true - - - diff --git a/configs/env.development.yaml b/configs/env.development.yaml index c4d8960..14aa98e 100644 --- a/configs/env.development.yaml +++ b/configs/env.development.yaml @@ -6,6 +6,8 @@ # =========================================== app: env: development + # 子账号专属前端域名(用于邀请链接复制) + sub_portal_base_url: "http://localhost:5173" # =========================================== # 🗄️ 数据库配置 diff --git a/configs/env.production.yaml b/configs/env.production.yaml index 92145c0..7d337ca 100644 --- a/configs/env.production.yaml +++ b/configs/env.production.yaml @@ -6,6 +6,8 @@ # =========================================== app: env: production + # 子账号专属前端域名(用于邀请链接复制) + sub_portal_base_url: "https://subsole.tianyuanapi.com" # =========================================== # 🌐 服务器配置 @@ -18,7 +20,7 @@ server: # =========================================== development: enable_cors: true - cors_allowed_origins: "http://localhost:5173,https://consoletest.tianyuanapi.com,https://console.tianyuanapi.com" + cors_allowed_origins: "https://console.tianyuanapi.com,https://subsole.tianyuanapi.com" cors_allowed_methods: "GET,POST,PUT,PATCH,DELETE,OPTIONS" cors_allowed_headers: "Origin,Content-Type,Accept,Authorization,X-Requested-With,Access-Id" @@ -157,7 +159,7 @@ daily_ratelimit: enable_referer: true # 启用Referer检查 allowed_referers: # 允许的Referer - "https://console.tianyuanapi.com" - - "https://consoletest.tianyuanapi.com" + - "https://subsole.tianyuanapi.com" enable_geo_block: false # 生产环境暂时不启用地理位置阻止 enable_proxy_check: true # 启用代理检查 diff --git a/internal/app/app.go b/internal/app/app.go index 65acf9b..2bf963d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -30,6 +30,7 @@ import ( statisticsEntities "tyapi-server/internal/domains/statistics/entities" apiEntities "tyapi-server/internal/domains/api/entities" + subordinateEntities "tyapi-server/internal/domains/subordinate/entities" "tyapi-server/internal/infrastructure/database" taskEntities "tyapi-server/internal/infrastructure/task/entities" ) @@ -264,6 +265,11 @@ func (a *Application) autoMigrate(db *gorm.DB) error { &apiEntities.ApiCall{}, &apiEntities.Report{}, + // 下属账号域 + &subordinateEntities.SubordinateInvitation{}, + &subordinateEntities.UserSubordinateLink{}, + &subordinateEntities.SubordinateWalletAllocation{}, + // 任务域 &taskEntities.AsyncTask{}, ) diff --git a/internal/application/api/api_application_service.go b/internal/application/api/api_application_service.go index 61e01a1..04ad165 100644 --- a/internal/application/api/api_application_service.go +++ b/internal/application/api/api_application_service.go @@ -261,18 +261,18 @@ func (s *ApiApplicationServiceImpl) validateApiCall(ctx context.Context, cmd *co zap.Strings("whiteListIPs", whiteListIPs)) } - // 5. 验证钱包状态 - if err := s.validateWalletStatus(ctx, apiUser.UserId, product); err != nil { - return nil, err - } - - // 6. 验证订阅状态并获取订阅信息 + // 5. 先验证订阅(与扣费金额一致,便于余额预检使用订阅价) subscription, err := s.validateSubscriptionStatus(ctx, apiUser.UserId, product) if err != nil { return nil, err } result.SetSubscription(subscription) + // 6. 验证钱包状态(有订阅时按订阅价与目录价取较大者预检,避免代配价高于目录价时误判余额不足) + if err := s.validateWalletStatus(ctx, apiUser.UserId, product, subscription); err != nil { + return nil, err + } + // 7. 解密参数 requestParams, err := crypto.AesDecrypt(cmd.Data, apiUser.SecretKey) if err != nil { @@ -1201,7 +1201,7 @@ func (s *ApiApplicationServiceImpl) ProcessCompensation(ctx context.Context, cmd } // validateWalletStatus 验证钱包状态 -func (s *ApiApplicationServiceImpl) validateWalletStatus(ctx context.Context, userID string, product *product_entities.Product) error { +func (s *ApiApplicationServiceImpl) validateWalletStatus(ctx context.Context, userID string, product *product_entities.Product, subscription *product_entities.Subscription) error { // 1. 获取用户钱包信息 wallet, err := s.walletService.LoadWalletByUserId(ctx, userID) if err != nil { @@ -1219,8 +1219,13 @@ func (s *ApiApplicationServiceImpl) validateWalletStatus(ctx context.Context, us return ErrFrozenAccount } - // 3. 检查钱包余额是否充足 + // 3. 检查钱包余额是否充足(有订阅时与扣费金额对齐:取目录价与订阅价较大者) requiredAmount := product.Price + if subscription != nil { + if subscription.Price.GreaterThan(requiredAmount) { + requiredAmount = subscription.Price + } + } if wallet.Balance.LessThan(requiredAmount) { s.logger.Error("钱包余额不足", zap.String("user_id", userID), diff --git a/internal/application/certification/certification_application_service_impl.go b/internal/application/certification/certification_application_service_impl.go index eae946a..8278ae8 100644 --- a/internal/application/certification/certification_application_service_impl.go +++ b/internal/application/certification/certification_application_service_impl.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/shopspring/decimal" "tyapi-server/internal/application/certification/dto/commands" "tyapi-server/internal/application/certification/dto/queries" "tyapi-server/internal/application/certification/dto/responses" @@ -18,7 +19,10 @@ import ( certification_value_objects "tyapi-server/internal/domains/certification/entities/value_objects" "tyapi-server/internal/domains/certification/enums" "tyapi-server/internal/domains/certification/repositories" + finance_entities "tyapi-server/internal/domains/finance/entities" + finance_repositories "tyapi-server/internal/domains/finance/repositories" "tyapi-server/internal/domains/certification/services" + subordinate_repositories "tyapi-server/internal/domains/subordinate/repositories" finance_service "tyapi-server/internal/domains/finance/services" user_entities "tyapi-server/internal/domains/user/entities" user_service "tyapi-server/internal/domains/user/services" @@ -49,6 +53,8 @@ type CertificationApplicationServiceImpl struct { // 仓储依赖 queryRepository repositories.CertificationQueryRepository enterpriseInfoSubmitRecordRepo repositories.EnterpriseInfoSubmitRecordRepository + subordinateRepo subordinate_repositories.SubordinateRepository + walletRepo finance_repositories.WalletRepository txManager *database.TransactionManager wechatWorkService *notification.WeChatWorkService @@ -71,6 +77,8 @@ func NewCertificationApplicationService( apiUserAggregateService api_service.ApiUserAggregateService, enterpriseInfoSubmitRecordService *services.EnterpriseInfoSubmitRecordService, ocrService sharedOCR.OCRService, + subordinateRepo subordinate_repositories.SubordinateRepository, + walletRepo finance_repositories.WalletRepository, txManager *database.TransactionManager, logger *zap.Logger, cfg *config.Config, @@ -93,6 +101,8 @@ func NewCertificationApplicationService( apiUserAggregateService: apiUserAggregateService, enterpriseInfoSubmitRecordService: enterpriseInfoSubmitRecordService, ocrService: ocrService, + subordinateRepo: subordinateRepo, + walletRepo: walletRepo, txManager: txManager, wechatWorkService: wechatSvc, logger: logger, @@ -1632,8 +1642,24 @@ func (s *CertificationApplicationServiceImpl) AddStatusMetadata(ctx context.Cont // completeUserActivationWithoutContract 创建钱包、API用户并在用户域标记完成认证(不依赖合同信息) func (s *CertificationApplicationServiceImpl) completeUserActivationWithoutContract(ctx context.Context, cert *entities.Certification) error { - // 创建钱包 - if _, err := s.walletAggregateService.CreateWallet(ctx, cert.UserID); err != nil { + // 创建钱包:子账号认证通过后不赠送初始余额(初始额度为0) + isSubordinate := false + if s.subordinateRepo != nil { + if ok, err := s.subordinateRepo.IsUserSubordinate(ctx, cert.UserID); err != nil { + s.logger.Warn("检查子账号关系失败,按普通账号处理", zap.String("user_id", cert.UserID), zap.Error(err)) + } else { + isSubordinate = ok + } + } + + if isSubordinate { + if _, err := s.walletRepo.GetByUserID(ctx, cert.UserID); err != nil { + zeroWallet := finance_entities.NewWallet(cert.UserID, decimal.Zero) + if _, createErr := s.walletRepo.Create(ctx, *zeroWallet); createErr != nil { + s.logger.Error("创建子账号钱包失败", zap.String("user_id", cert.UserID), zap.Error(createErr)) + } + } + } else if _, err := s.walletAggregateService.CreateWallet(ctx, cert.UserID); err != nil { s.logger.Error("创建钱包失败", zap.String("user_id", cert.UserID), zap.Error(err)) } diff --git a/internal/application/product/self_subscribe_policy.go b/internal/application/product/self_subscribe_policy.go new file mode 100644 index 0000000..a6b10f6 --- /dev/null +++ b/internal/application/product/self_subscribe_policy.go @@ -0,0 +1,16 @@ +package product + +import "context" + +// SelfSubscribePolicy 是否允许用户在控制台自助发起「订阅产品」 +type SelfSubscribePolicy interface { + Allow(ctx context.Context, userID string) (allowed bool, message string, err error) +} + +// DefaultAllowSelfSubscribe 未装配下属模块时:恒允许 +type DefaultAllowSelfSubscribe struct{} + +// Allow 恒允许 +func (DefaultAllowSelfSubscribe) Allow(_ context.Context, _ string) (bool, string, error) { + return true, "", nil +} diff --git a/internal/application/product/subscription_application_service_impl.go b/internal/application/product/subscription_application_service_impl.go index bd59af3..c7701f6 100644 --- a/internal/application/product/subscription_application_service_impl.go +++ b/internal/application/product/subscription_application_service_impl.go @@ -23,6 +23,7 @@ type SubscriptionApplicationServiceImpl struct { productSubscriptionService *product_service.ProductSubscriptionService userRepo user_repositories.UserRepository apiCallRepository domain_api_repo.ApiCallRepository + selfSubscribePolicy SelfSubscribePolicy logger *zap.Logger } @@ -31,12 +32,17 @@ func NewSubscriptionApplicationService( productSubscriptionService *product_service.ProductSubscriptionService, userRepo user_repositories.UserRepository, apiCallRepository domain_api_repo.ApiCallRepository, + selfSubscribePolicy SelfSubscribePolicy, logger *zap.Logger, ) SubscriptionApplicationService { + if selfSubscribePolicy == nil { + selfSubscribePolicy = DefaultAllowSelfSubscribe{} + } return &SubscriptionApplicationServiceImpl{ productSubscriptionService: productSubscriptionService, userRepo: userRepo, apiCallRepository: apiCallRepository, + selfSubscribePolicy: selfSubscribePolicy, logger: logger, } } @@ -157,7 +163,17 @@ func (s *SubscriptionApplicationServiceImpl) BatchUpdateSubscriptionPrices(ctx c // CreateSubscription 创建订阅 // 业务流程:1. 创建订阅 func (s *SubscriptionApplicationServiceImpl) CreateSubscription(ctx context.Context, cmd *commands.CreateSubscriptionCommand) error { - _, err := s.productSubscriptionService.CreateSubscription(ctx, cmd.UserID, cmd.ProductID) + allow, msg, err := s.selfSubscribePolicy.Allow(ctx, cmd.UserID) + if err != nil { + return err + } + if !allow { + if msg == "" { + msg = "当前账号不允许自助订阅" + } + return fmt.Errorf("%s", msg) + } + _, err = s.productSubscriptionService.CreateSubscription(ctx, cmd.UserID, cmd.ProductID) return err } diff --git a/internal/application/subordinate/dto/commands/subordinate_commands.go b/internal/application/subordinate/dto/commands/subordinate_commands.go new file mode 100644 index 0000000..6e4bdf8 --- /dev/null +++ b/internal/application/subordinate/dto/commands/subordinate_commands.go @@ -0,0 +1,55 @@ +package commands + +// SubPortalRegisterCommand 子站注册(邀请码必填) +type SubPortalRegisterCommand struct { + Phone string `json:"phone" binding:"required"` + Password string `json:"password" binding:"required"` + ConfirmPassword string `json:"confirm_password" binding:"required"` + Code string `json:"code" binding:"required"` + InviteToken string `json:"invite_token" binding:"required"` +} + +// CreateInvitationCommand 主账号创建邀请 +type CreateInvitationCommand struct { + ParentUserID string + // ExpiresInHours 可选,0 用默认 168 小时 + ExpiresInHours int `json:"expires_in_hours"` +} + +// AllocateToChildCommand 主账号向下属划余额 +type AllocateToChildCommand struct { + ParentUserID string + ChildUserID string `json:"child_user_id" binding:"required"` + Amount string `json:"amount" binding:"required"` + VerifyCode string `json:"verify_code" binding:"required,len=6"` +} + +// AssignChildSubscriptionCommand 为下属代配订阅 +type AssignChildSubscriptionCommand struct { + ParentUserID string + ChildUserID string `json:"child_user_id" binding:"required"` + ProductID string `json:"product_id" binding:"required"` + Price string `json:"price" binding:"required"` + UIComponentPrice string `json:"ui_component_price"` +} + +// ListChildAllocationsCommand 下属划拨记录查询 +type ListChildAllocationsCommand struct { + ParentUserID string + ChildUserID string `json:"child_user_id" form:"child_user_id" binding:"required"` + Page int `json:"page" form:"page"` + PageSize int `json:"page_size" form:"page_size"` +} + +// ListChildSubscriptionsCommand 下属订阅列表查询 +type ListChildSubscriptionsCommand struct { + ParentUserID string + ChildUserID string `json:"child_user_id" form:"child_user_id" binding:"required"` +} + +// RemoveChildSubscriptionCommand 删除下属订阅 +type RemoveChildSubscriptionCommand struct { + ParentUserID string + ChildUserID string `json:"child_user_id" binding:"required"` + SubscriptionID string `json:"subscription_id" binding:"required"` +} diff --git a/internal/application/subordinate/dto/responses/subordinate_responses.go b/internal/application/subordinate/dto/responses/subordinate_responses.go new file mode 100644 index 0000000..1e15694 --- /dev/null +++ b/internal/application/subordinate/dto/responses/subordinate_responses.go @@ -0,0 +1,57 @@ +package responses + +import "time" + +// CreateInvitationResponse 创建邀请 +type CreateInvitationResponse struct { + InviteToken string `json:"invite_token" description:"仅返回一次,请转达被邀请人"` + InviteURL string `json:"invite_url" description:"子站注册完整链接"` + ExpiresAt time.Time `json:"expires_at"` + InvitationID string `json:"invitation_id"` +} + +// SubordinateListItem 下属一条 +type SubordinateListItem struct { + ChildUserID string `json:"child_user_id"` + Phone string `json:"phone,omitempty"` + LinkID string `json:"link_id"` + RegisteredAt time.Time `json:"registered_at"` + CompanyName string `json:"company_name"` + IsCertified bool `json:"is_certified"` + Balance string `json:"balance"` +} + +// SubordinateListResponse 列表 +type SubordinateListResponse struct { + Total int64 `json:"total"` + Items []SubordinateListItem `json:"items"` +} + +// SubPortalRegisterResponse 子站注册 +type SubPortalRegisterResponse struct { + ID string `json:"id"` + Phone string `json:"phone"` +} + +// ChildAllocationItem 下属划拨记录 +type ChildAllocationItem struct { + ID string `json:"id"` + Amount string `json:"amount"` + BusinessRef string `json:"business_ref"` + CreatedAt time.Time `json:"created_at"` +} + +// ChildAllocationListResponse 下属划拨记录列表 +type ChildAllocationListResponse struct { + Total int64 `json:"total"` + Items []ChildAllocationItem `json:"items"` +} + +// ChildSubscriptionItem 下属订阅项 +type ChildSubscriptionItem struct { + ID string `json:"id"` + ProductID string `json:"product_id"` + Price string `json:"price"` + UIComponentPrice string `json:"ui_component_price"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/internal/application/subordinate/invite_token.go b/internal/application/subordinate/invite_token.go new file mode 100644 index 0000000..b97edfb --- /dev/null +++ b/internal/application/subordinate/invite_token.go @@ -0,0 +1,35 @@ +package subordinate + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "math/big" +) + +const ( + // 邀请码固定 6 位,字符集为大写字母+数字 + inviteTokenLength = 6 + inviteTokenCharset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" +) + +// HashInviteToken 邀请码 SHA256 十六进制 +func HashInviteToken(raw string) string { + sum := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(sum[:]) +} + +// GenerateInviteToken 生成随机邀请明文与存储用哈希 +func GenerateInviteToken() (raw string, hash string, err error) { + token := make([]byte, inviteTokenLength) + charsetSize := big.NewInt(int64(len(inviteTokenCharset))) + for i := range token { + n, e := rand.Int(rand.Reader, charsetSize) + if e != nil { + return "", "", e + } + token[i] = inviteTokenCharset[n.Int64()] + } + raw = string(token) + return raw, HashInviteToken(raw), nil +} diff --git a/internal/application/subordinate/invite_token_test.go b/internal/application/subordinate/invite_token_test.go new file mode 100644 index 0000000..a3ed581 --- /dev/null +++ b/internal/application/subordinate/invite_token_test.go @@ -0,0 +1,26 @@ +package subordinate + +import "testing" + +func TestGenerateInviteTokenFormat(t *testing.T) { + raw, hash, err := GenerateInviteToken() + if err != nil { + t.Fatalf("GenerateInviteToken error: %v", err) + } + + if len(raw) != inviteTokenLength { + t.Fatalf("unexpected token length: got %d, want %d", len(raw), inviteTokenLength) + } + + for _, ch := range raw { + isUpper := ch >= 'A' && ch <= 'Z' + isDigit := ch >= '0' && ch <= '9' + if !isUpper && !isDigit { + t.Fatalf("token contains invalid char: %q", ch) + } + } + + if hash != HashInviteToken(raw) { + t.Fatalf("hash mismatch for token") + } +} diff --git a/internal/application/subordinate/self_subscribe_policy_subordinate.go b/internal/application/subordinate/self_subscribe_policy_subordinate.go new file mode 100644 index 0000000..aee323e --- /dev/null +++ b/internal/application/subordinate/self_subscribe_policy_subordinate.go @@ -0,0 +1,30 @@ +package subordinate + +import ( + "context" + + "tyapi-server/internal/application/product" + "tyapi-server/internal/domains/subordinate/repositories" +) + +// BlockSelfSubscribeForSubordinate 子账号禁止自助订 +type BlockSelfSubscribeForSubordinate struct { + repo repositories.SubordinateRepository +} + +// NewBlockSelfSubscribeForSubordinate 构造 +func NewBlockSelfSubscribeForSubordinate(repo repositories.SubordinateRepository) product.SelfSubscribePolicy { + return &BlockSelfSubscribeForSubordinate{repo: repo} +} + +// Allow 若为主账号的下属则拒绝 +func (p *BlockSelfSubscribeForSubordinate) Allow(ctx context.Context, userID string) (bool, string, error) { + ok, err := p.repo.IsUserSubordinate(ctx, userID) + if err != nil { + return false, "", err + } + if ok { + return false, "子账号需由主账号配置订阅", nil + } + return true, "", nil +} diff --git a/internal/application/subordinate/subordinate_application_service.go b/internal/application/subordinate/subordinate_application_service.go new file mode 100644 index 0000000..822bc34 --- /dev/null +++ b/internal/application/subordinate/subordinate_application_service.go @@ -0,0 +1,20 @@ +package subordinate + +import ( + "context" + + "tyapi-server/internal/application/subordinate/dto/commands" + "tyapi-server/internal/application/subordinate/dto/responses" +) + +// SubordinateApplicationService 下属账号:邀请/注册/划款/代配 +type SubordinateApplicationService interface { + RegisterSubPortal(ctx context.Context, cmd *commands.SubPortalRegisterCommand) (*responses.SubPortalRegisterResponse, error) + CreateInvitation(ctx context.Context, cmd *commands.CreateInvitationCommand) (*responses.CreateInvitationResponse, error) + ListMySubordinates(ctx context.Context, parentUserID string, page, pageSize int) (*responses.SubordinateListResponse, error) + AllocateToChild(ctx context.Context, cmd *commands.AllocateToChildCommand) error + ListChildAllocations(ctx context.Context, cmd *commands.ListChildAllocationsCommand) (*responses.ChildAllocationListResponse, error) + AssignChildSubscription(ctx context.Context, cmd *commands.AssignChildSubscriptionCommand) error + ListChildSubscriptions(ctx context.Context, cmd *commands.ListChildSubscriptionsCommand) ([]responses.ChildSubscriptionItem, error) + RemoveChildSubscription(ctx context.Context, cmd *commands.RemoveChildSubscriptionCommand) error +} diff --git a/internal/application/subordinate/subordinate_application_service_impl.go b/internal/application/subordinate/subordinate_application_service_impl.go new file mode 100644 index 0000000..7685c52 --- /dev/null +++ b/internal/application/subordinate/subordinate_application_service_impl.go @@ -0,0 +1,393 @@ +package subordinate + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/google/uuid" + "github.com/shopspring/decimal" + "go.uber.org/zap" + + "tyapi-server/internal/application/subordinate/dto/commands" + "tyapi-server/internal/application/subordinate/dto/responses" + "tyapi-server/internal/config" + "tyapi-server/internal/domains/finance/repositories" + productentities "tyapi-server/internal/domains/product/entities" + product_service "tyapi-server/internal/domains/product/services" + subentities "tyapi-server/internal/domains/subordinate/entities" + subrepositories "tyapi-server/internal/domains/subordinate/repositories" + user_entities "tyapi-server/internal/domains/user/entities" + user_repositories "tyapi-server/internal/domains/user/repositories" + domain_user_services "tyapi-server/internal/domains/user/services" + "tyapi-server/internal/shared/database" +) + +// SubordinateApplicationServiceImpl 实现 +type SubordinateApplicationServiceImpl struct { + subRepo subrepositories.SubordinateRepository + userAgg domain_user_services.UserAggregateService + smsService *domain_user_services.SMSCodeService + productSub *product_service.ProductSubscriptionService + cfg *config.Config + txm *database.TransactionManager + walletRepo repositories.WalletRepository + userRepo user_repositories.UserRepository + logger *zap.Logger +} + +// NewSubordinateApplicationService 构造 +func NewSubordinateApplicationService( + subRepo subrepositories.SubordinateRepository, + userAgg domain_user_services.UserAggregateService, + smsService *domain_user_services.SMSCodeService, + productSub *product_service.ProductSubscriptionService, + cfg *config.Config, + txm *database.TransactionManager, + walletRepo repositories.WalletRepository, + userRepo user_repositories.UserRepository, + logger *zap.Logger, +) SubordinateApplicationService { + return &SubordinateApplicationServiceImpl{ + subRepo: subRepo, + userAgg: userAgg, + smsService: smsService, + productSub: productSub, + cfg: cfg, + txm: txm, + walletRepo: walletRepo, + userRepo: userRepo, + logger: logger, + } +} + +// RegisterSubPortal 子站注册 +func (s *SubordinateApplicationServiceImpl) RegisterSubPortal(ctx context.Context, cmd *commands.SubPortalRegisterCommand) (*responses.SubPortalRegisterResponse, error) { + if cmd.Password != cmd.ConfirmPassword { + return nil, fmt.Errorf("两次输入的密码不一致") + } + if err := s.smsService.VerifyCode(ctx, cmd.Phone, cmd.Code, user_entities.SMSSceneRegister); err != nil { + return nil, fmt.Errorf("验证码错误或已过期") + } + + var resp *responses.SubPortalRegisterResponse + err := s.txm.ExecuteInTx(ctx, func(txCtx context.Context) error { + inv, err := s.subRepo.FindInvitationByTokenHash(txCtx, HashInviteToken(strings.TrimSpace(cmd.InviteToken))) + if err != nil { + return err + } + if inv == nil { + return fmt.Errorf("邀请码无效") + } + if inv.Status != subentities.InvitationStatusPending { + return fmt.Errorf("邀请码已使用或已失效") + } + now := time.Now() + if now.After(inv.ExpiresAt) { + return fmt.Errorf("邀请码已过期") + } + + u, createErr := s.userAgg.CreateUser(txCtx, cmd.Phone, cmd.Password) + if createErr != nil { + return createErr + } + + link := &subentities.UserSubordinateLink{ + ParentUserID: inv.ParentUserID, + ChildUserID: u.ID, + InvitationID: &inv.ID, + Status: subentities.LinkStatusActive, + } + if linkErr := s.subRepo.CreateLink(txCtx, link); linkErr != nil { + s.logger.Error("创建主从关系失败", zap.Error(linkErr), zap.String("user_id", u.ID)) + return fmt.Errorf("注册失败,请重试或联系主账号") + } + + consumed, consumeErr := s.subRepo.ConsumeInvitation(txCtx, inv.ID, u.ID, now) + if consumeErr != nil { + s.logger.Error("核销邀请失败", zap.Error(consumeErr), zap.String("user_id", u.ID)) + return fmt.Errorf("注册失败,请重试或联系主账号") + } + if !consumed { + return fmt.Errorf("邀请码已使用或已失效") + } + + resp = &responses.SubPortalRegisterResponse{ID: u.ID, Phone: u.Phone} + return nil + }) + if err != nil { + return nil, err + } + return resp, nil +} + +// CreateInvitation 主账号发邀请 +func (s *SubordinateApplicationServiceImpl) CreateInvitation(ctx context.Context, cmd *commands.CreateInvitationCommand) (*responses.CreateInvitationResponse, error) { + hours := cmd.ExpiresInHours + if hours <= 0 { + hours = 24 * 7 + } + raw, hash, err := GenerateInviteToken() + if err != nil { + return nil, fmt.Errorf("生成邀请失败") + } + inv := &subentities.SubordinateInvitation{ + ParentUserID: cmd.ParentUserID, + TokenHash: hash, + ExpiresAt: time.Now().Add(time.Duration(hours) * time.Hour), + Status: subentities.InvitationStatusPending, + } + if err := s.subRepo.CreateInvitation(ctx, inv); err != nil { + return nil, err + } + base := strings.TrimSpace(os.Getenv("SUB_PORTAL_BASE_URL")) + if base == "" { + base = s.cfg.App.SubPortalBaseURL + } + base = strings.TrimRight(base, "/") + if base == "" { + return nil, fmt.Errorf("子账号域名未配置,请设置 app.sub_portal_base_url 或环境变量 SUB_PORTAL_BASE_URL") + } + // 与前端同仓路由一致:/sub/auth/register 为子账号专用注册页 + inviteURL := base + "/sub/auth/register?invite=" + raw + return &responses.CreateInvitationResponse{ + InviteToken: raw, + InviteURL: inviteURL, + ExpiresAt: inv.ExpiresAt, + InvitationID: inv.ID, + }, nil +} + +// ListMySubordinates 主账号的下属 +func (s *SubordinateApplicationServiceImpl) ListMySubordinates(ctx context.Context, parentUserID string, page, pageSize int) (*responses.SubordinateListResponse, error) { + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + offset := (page - 1) * pageSize + links, total, err := s.subRepo.ListChildrenByParent(ctx, parentUserID, pageSize, offset) + if err != nil { + return nil, err + } + items := make([]responses.SubordinateListItem, 0, len(links)) + for _, ln := range links { + phone := "" + companyName := "未认证" + isCertified := false + registeredAt := ln.CreatedAt + balance := "0.00" + + if u, e := s.userRepo.GetByIDWithEnterpriseInfo(ctx, ln.ChildUserID); e == nil { + phone = u.Phone + isCertified = u.IsCertified + registeredAt = u.CreatedAt + if u.EnterpriseInfo != nil && strings.TrimSpace(u.EnterpriseInfo.CompanyName) != "" { + companyName = strings.TrimSpace(u.EnterpriseInfo.CompanyName) + } + } else { + s.logger.Warn("获取下属用户失败", zap.String("child_id", ln.ChildUserID), zap.Error(e)) + } + if w, e := s.walletRepo.GetByUserID(ctx, ln.ChildUserID); e == nil && w != nil { + balance = w.Balance.StringFixed(2) + } + items = append(items, responses.SubordinateListItem{ + ChildUserID: ln.ChildUserID, + Phone: phone, + LinkID: ln.ID, + RegisteredAt: registeredAt, + CompanyName: companyName, + IsCertified: isCertified, + Balance: balance, + }) + } + return &responses.SubordinateListResponse{Total: total, Items: items}, nil +} + +// AllocateToChild 划款 +func (s *SubordinateApplicationServiceImpl) AllocateToChild(ctx context.Context, cmd *commands.AllocateToChildCommand) error { + amount, err := decimal.NewFromString(strings.TrimSpace(cmd.Amount)) + if err != nil || !amount.GreaterThan(decimal.Zero) { + return fmt.Errorf("金额必须大于0") + } + parentUser, err := s.userRepo.GetByID(ctx, cmd.ParentUserID) + if err != nil { + return fmt.Errorf("主账号信息获取失败") + } + if err := s.smsService.VerifyCode(ctx, parentUser.Phone, strings.TrimSpace(cmd.VerifyCode), user_entities.SMSSceneLogin); err != nil { + return fmt.Errorf("验证码错误或已过期") + } + lnk, err := s.subRepo.FindLinkByParentAndChild(ctx, cmd.ParentUserID, cmd.ChildUserID) + if err != nil { + return err + } + if lnk == nil || lnk.Status != subentities.LinkStatusActive { + return fmt.Errorf("该用户不是您的有效下属") + } + bizRef := uuid.New().String() + return s.txm.ExecuteInTx(ctx, func(txCtx context.Context) error { + ok, err := s.walletRepo.UpdateBalanceByUserID(txCtx, cmd.ParentUserID, amount, "subtract") + if err != nil { + return err + } + if !ok { + return fmt.Errorf("主账号扣款失败,请重试") + } + ok2, err := s.walletRepo.UpdateBalanceByUserID(txCtx, cmd.ChildUserID, amount, "add") + if err != nil { + return err + } + if !ok2 { + return fmt.Errorf("向下属入账失败,请重试") + } + alloc := &subentities.SubordinateWalletAllocation{ + FromUserID: cmd.ParentUserID, + ToUserID: cmd.ChildUserID, + Amount: amount, + BusinessRef: bizRef, + OperatorUserID: cmd.ParentUserID, + } + return s.subRepo.CreateWalletAllocation(txCtx, alloc) + }) +} + +// ListChildAllocations 下属划拨记录 +func (s *SubordinateApplicationServiceImpl) ListChildAllocations(ctx context.Context, cmd *commands.ListChildAllocationsCommand) (*responses.ChildAllocationListResponse, error) { + lnk, err := s.subRepo.FindLinkByParentAndChild(ctx, cmd.ParentUserID, cmd.ChildUserID) + if err != nil { + return nil, err + } + if lnk == nil || lnk.Status != subentities.LinkStatusActive { + return nil, fmt.Errorf("该用户不是您的有效下属") + } + page := cmd.Page + pageSize := cmd.PageSize + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + offset := (page - 1) * pageSize + rows, total, err := s.subRepo.ListWalletAllocationsByParentAndChild(ctx, cmd.ParentUserID, cmd.ChildUserID, pageSize, offset) + if err != nil { + return nil, err + } + items := make([]responses.ChildAllocationItem, 0, len(rows)) + for _, row := range rows { + items = append(items, responses.ChildAllocationItem{ + ID: row.ID, + Amount: row.Amount.StringFixed(2), + BusinessRef: row.BusinessRef, + CreatedAt: row.CreatedAt, + }) + } + return &responses.ChildAllocationListResponse{ + Total: total, + Items: items, + }, nil +} + +// AssignChildSubscription 代配订阅 +func (s *SubordinateApplicationServiceImpl) AssignChildSubscription(ctx context.Context, cmd *commands.AssignChildSubscriptionCommand) error { + lnk, err := s.subRepo.FindLinkByParentAndChild(ctx, cmd.ParentUserID, cmd.ChildUserID) + if err != nil { + return err + } + if lnk == nil || lnk.Status != subentities.LinkStatusActive { + return fmt.Errorf("该用户不是您的有效下属") + } + + price, err := decimal.NewFromString(strings.TrimSpace(cmd.Price)) + if err != nil { + return fmt.Errorf("价格格式无效") + } + + parentSub, err := s.productSub.GetUserSubscribedProduct(ctx, cmd.ParentUserID, cmd.ProductID) + if err != nil { + return err + } + if parentSub == nil { + return fmt.Errorf("主账号未订阅该产品,无法为下属代配") + } + if price.LessThan(parentSub.Price) { + return fmt.Errorf("下属订阅价不能低于主账号对该产品的订阅价") + } + + uip := parentSub.UIComponentPrice + if strings.TrimSpace(cmd.UIComponentPrice) != "" { + p, err2 := decimal.NewFromString(strings.TrimSpace(cmd.UIComponentPrice)) + if err2 != nil { + return fmt.Errorf("UI组件价格格式无效") + } + if p.LessThan(parentSub.UIComponentPrice) { + return fmt.Errorf("下属 UI 组合价不能低于主账号的 UI 组合价") + } + uip = p + } + + existing, err := s.productSub.GetUserSubscribedProduct(ctx, cmd.ChildUserID, cmd.ProductID) + if err != nil { + return err + } + if existing == nil { + newSub := &productentities.Subscription{ + UserID: cmd.ChildUserID, + ProductID: cmd.ProductID, + Price: price, + UIComponentPrice: uip, + } + return s.productSub.SaveSubscription(ctx, newSub) + } + existing.Price = price + existing.UIComponentPrice = uip + return s.productSub.SaveSubscription(ctx, existing) +} + +// ListChildSubscriptions 下属订阅列表 +func (s *SubordinateApplicationServiceImpl) ListChildSubscriptions(ctx context.Context, cmd *commands.ListChildSubscriptionsCommand) ([]responses.ChildSubscriptionItem, error) { + lnk, err := s.subRepo.FindLinkByParentAndChild(ctx, cmd.ParentUserID, cmd.ChildUserID) + if err != nil { + return nil, err + } + if lnk == nil || lnk.Status != subentities.LinkStatusActive { + return nil, fmt.Errorf("该用户不是您的有效下属") + } + subs, err := s.productSub.GetUserSubscriptions(ctx, cmd.ChildUserID) + if err != nil { + return nil, err + } + items := make([]responses.ChildSubscriptionItem, 0, len(subs)) + for _, sub := range subs { + items = append(items, responses.ChildSubscriptionItem{ + ID: sub.ID, + ProductID: sub.ProductID, + Price: sub.Price.StringFixed(2), + UIComponentPrice: sub.UIComponentPrice.StringFixed(2), + CreatedAt: sub.CreatedAt, + }) + } + return items, nil +} + +// RemoveChildSubscription 删除下属订阅 +func (s *SubordinateApplicationServiceImpl) RemoveChildSubscription(ctx context.Context, cmd *commands.RemoveChildSubscriptionCommand) error { + lnk, err := s.subRepo.FindLinkByParentAndChild(ctx, cmd.ParentUserID, cmd.ChildUserID) + if err != nil { + return err + } + if lnk == nil || lnk.Status != subentities.LinkStatusActive { + return fmt.Errorf("该用户不是您的有效下属") + } + sub, err := s.productSub.GetSubscriptionByID(ctx, cmd.SubscriptionID) + if err != nil { + return fmt.Errorf("订阅不存在") + } + if sub.UserID != cmd.ChildUserID { + return fmt.Errorf("订阅不属于该下属") + } + return s.productSub.CancelSubscription(ctx, cmd.SubscriptionID) +} diff --git a/internal/application/user/dto/responses/user_responses.go b/internal/application/user/dto/responses/user_responses.go index e6f62ee..9977904 100644 --- a/internal/application/user/dto/responses/user_responses.go +++ b/internal/application/user/dto/responses/user_responses.go @@ -51,6 +51,8 @@ type UserProfileResponse struct { IsCertified bool `json:"is_certified" example:"false"` CreatedAt time.Time `json:"created_at" example:"2024-01-01T00:00:00Z"` UpdatedAt time.Time `json:"updated_at" example:"2024-01-01T00:00:00Z"` + // AccountKind standalone=普通/主站用户 subordinate=主账号邀请的下属 + AccountKind string `json:"account_kind" example:"standalone"` } // SendCodeResponse 发送验证码响应 diff --git a/internal/application/user/user_application_service_impl.go b/internal/application/user/user_application_service_impl.go index f1df09f..53938cd 100644 --- a/internal/application/user/user_application_service_impl.go +++ b/internal/application/user/user_application_service_impl.go @@ -13,6 +13,7 @@ import ( "tyapi-server/internal/domains/user/entities" "tyapi-server/internal/domains/user/events" user_service "tyapi-server/internal/domains/user/services" + "tyapi-server/internal/shared/auth" "tyapi-server/internal/shared/interfaces" "tyapi-server/internal/shared/middleware" ) @@ -27,6 +28,7 @@ type UserApplicationServiceImpl struct { contractService user_service.ContractAggregateService eventBus interfaces.EventBus jwtAuth *middleware.JWTAuthMiddleware + accountKindProvider interfaces.AccountKindProvider logger *zap.Logger } @@ -39,6 +41,7 @@ func NewUserApplicationService( contractService user_service.ContractAggregateService, eventBus interfaces.EventBus, jwtAuth *middleware.JWTAuthMiddleware, + accountKindProvider interfaces.AccountKindProvider, logger *zap.Logger, ) UserApplicationService { return &UserApplicationServiceImpl{ @@ -49,6 +52,7 @@ func NewUserApplicationService( contractService: contractService, eventBus: eventBus, jwtAuth: jwtAuth, + accountKindProvider: accountKindProvider, logger: logger, } } @@ -90,76 +94,16 @@ func (s *UserApplicationServiceImpl) LoginWithPassword(ctx context.Context, cmd return nil, err } - // 2. 生成包含用户类型的token - accessToken, err := s.jwtAuth.GenerateToken(user.ID, user.Phone, user.Phone, user.UserType) - if err != nil { - s.logger.Error("生成令牌失败", zap.Error(err)) - return nil, fmt.Errorf("生成访问令牌失败") - } - - // 3. 如果是管理员,更新登录统计 - if user.IsAdmin() { - if err := s.userAggregateService.UpdateLoginStats(ctx, user.ID); err != nil { - s.logger.Error("更新登录统计失败", zap.Error(err)) - } - // 重新获取用户信息以获取最新的登录统计 - updatedUser, err := s.userAggregateService.GetUserByID(ctx, user.ID) - if err != nil { - s.logger.Error("重新获取用户信息失败", zap.Error(err)) - } else { - user = updatedUser + // 2. 账号类型(下属/普通) + accountKind := auth.AccountKindStandalone + if s.accountKindProvider != nil { + if k, err := s.accountKindProvider.AccountKind(ctx, user.ID); err == nil && k != "" { + accountKind = k } } - // 4. 获取用户权限(仅管理员) - var permissions []string - if user.IsAdmin() { - permissions, err = s.userAuthService.GetUserPermissions(ctx, user) - if err != nil { - s.logger.Error("获取用户权限失败", zap.Error(err)) - permissions = []string{} - } - } - - // 5. 构建用户信息 - userProfile := &responses.UserProfileResponse{ - ID: user.ID, - Phone: user.Phone, - Username: user.Username, - UserType: user.UserType, - IsActive: user.Active, - LastLoginAt: user.LastLoginAt, - LoginCount: user.LoginCount, - Permissions: permissions, - CreatedAt: user.CreatedAt, - UpdatedAt: user.UpdatedAt, - } - - return &responses.LoginUserResponse{ - User: userProfile, - AccessToken: accessToken, - TokenType: "Bearer", - ExpiresIn: 86400, // 24h - LoginMethod: "password", - }, nil -} - -// LoginWithSMS 短信验证码登录 -// 业务流程:1. 验证短信验证码 2. 验证用户登录状态 3. 生成访问令牌 4. 更新登录统计 5. 获取用户权限 -func (s *UserApplicationServiceImpl) LoginWithSMS(ctx context.Context, cmd *commands.LoginWithSMSCommand) (*responses.LoginUserResponse, error) { - // 1. 验证短信验证码 - if err := s.smsCodeService.VerifyCode(ctx, cmd.Phone, cmd.Code, entities.SMSSceneLogin); err != nil { - return nil, fmt.Errorf("验证码错误或已过期") - } - - // 2. 验证用户登录状态 - user, err := s.userAuthService.ValidateUserLogin(ctx, cmd.Phone) - if err != nil { - return nil, err - } - - // 3. 生成包含用户类型的token - accessToken, err := s.jwtAuth.GenerateToken(user.ID, user.Phone, user.Phone, user.UserType) + // 3. 生成包含用户类型的 token + accessToken, err := s.jwtAuth.GenerateToken(user.ID, user.Phone, user.Phone, user.UserType, accountKind) if err != nil { s.logger.Error("生成令牌失败", zap.Error(err)) return nil, fmt.Errorf("生成访问令牌失败") @@ -201,6 +145,83 @@ func (s *UserApplicationServiceImpl) LoginWithSMS(ctx context.Context, cmd *comm Permissions: permissions, CreatedAt: user.CreatedAt, UpdatedAt: user.UpdatedAt, + AccountKind: accountKind, + } + + return &responses.LoginUserResponse{ + User: userProfile, + AccessToken: accessToken, + TokenType: "Bearer", + ExpiresIn: 86400, // 24h + LoginMethod: "password", + }, nil +} + +// LoginWithSMS 短信验证码登录 +// 业务流程:1. 验证短信验证码 2. 验证用户登录状态 3. 生成访问令牌 4. 更新登录统计 5. 获取用户权限 +func (s *UserApplicationServiceImpl) LoginWithSMS(ctx context.Context, cmd *commands.LoginWithSMSCommand) (*responses.LoginUserResponse, error) { + // 1. 验证短信验证码 + if err := s.smsCodeService.VerifyCode(ctx, cmd.Phone, cmd.Code, entities.SMSSceneLogin); err != nil { + return nil, fmt.Errorf("验证码错误或已过期") + } + + // 2. 验证用户登录状态 + user, err := s.userAuthService.ValidateUserLogin(ctx, cmd.Phone) + if err != nil { + return nil, err + } + + accountKind := auth.AccountKindStandalone + if s.accountKindProvider != nil { + if k, err := s.accountKindProvider.AccountKind(ctx, user.ID); err == nil && k != "" { + accountKind = k + } + } + + // 3. 生成包含用户类型的 token + accessToken, err := s.jwtAuth.GenerateToken(user.ID, user.Phone, user.Phone, user.UserType, accountKind) + if err != nil { + s.logger.Error("生成令牌失败", zap.Error(err)) + return nil, fmt.Errorf("生成访问令牌失败") + } + + // 4. 如果是管理员,更新登录统计 + if user.IsAdmin() { + if err := s.userAggregateService.UpdateLoginStats(ctx, user.ID); err != nil { + s.logger.Error("更新登录统计失败", zap.Error(err)) + } + // 重新获取用户信息以获取最新的登录统计 + updatedUser, err := s.userAggregateService.GetUserByID(ctx, user.ID) + if err != nil { + s.logger.Error("重新获取用户信息失败", zap.Error(err)) + } else { + user = updatedUser + } + } + + // 5. 获取用户权限(仅管理员) + var permissions []string + if user.IsAdmin() { + permissions, err = s.userAuthService.GetUserPermissions(ctx, user) + if err != nil { + s.logger.Error("获取用户权限失败", zap.Error(err)) + permissions = []string{} + } + } + + // 6. 构建用户信息 + userProfile := &responses.UserProfileResponse{ + ID: user.ID, + Phone: user.Phone, + Username: user.Username, + UserType: user.UserType, + IsActive: user.Active, + LastLoginAt: user.LastLoginAt, + LoginCount: user.LoginCount, + Permissions: permissions, + CreatedAt: user.CreatedAt, + UpdatedAt: user.UpdatedAt, + AccountKind: accountKind, } return &responses.LoginUserResponse{ @@ -262,6 +283,12 @@ func (s *UserApplicationServiceImpl) GetUserProfile(ctx context.Context, userID Permissions: permissions, CreatedAt: user.CreatedAt, UpdatedAt: user.UpdatedAt, + AccountKind: auth.AccountKindStandalone, + } + if s.accountKindProvider != nil { + if k, err := s.accountKindProvider.AccountKind(ctx, userID); err == nil && k != "" { + userProfile.AccountKind = k + } } // 4. 添加企业信息 diff --git a/internal/config/config.go b/internal/config/config.go index b1a6061..3b8629c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -197,6 +197,8 @@ type AppConfig struct { Name string `mapstructure:"name"` Version string `mapstructure:"version"` Env string `mapstructure:"env"` + // SubPortalBaseURL 子账号使用的前端基址(可与主站同域),用于邀请链接,无尾斜杠 + SubPortalBaseURL string `mapstructure:"sub_portal_base_url"` } // APIConfig API配置 diff --git a/internal/container/container.go b/internal/container/container.go index c39ab9c..8a2372a 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -15,6 +15,7 @@ import ( "tyapi-server/internal/application/certification" "tyapi-server/internal/application/finance" "tyapi-server/internal/application/product" + subordinate_app "tyapi-server/internal/application/subordinate" "tyapi-server/internal/application/statistics" "tyapi-server/internal/application/user" "tyapi-server/internal/config" @@ -27,6 +28,7 @@ import ( finance_service "tyapi-server/internal/domains/finance/services" domain_product_repo "tyapi-server/internal/domains/product/repositories" product_service "tyapi-server/internal/domains/product/services" + domain_subordinate_repo "tyapi-server/internal/domains/subordinate/repositories" statistics_service "tyapi-server/internal/domains/statistics/services" user_service "tyapi-server/internal/domains/user/services" "tyapi-server/internal/infrastructure/cache" @@ -35,7 +37,9 @@ import ( certification_repo "tyapi-server/internal/infrastructure/database/repositories/certification" finance_repo "tyapi-server/internal/infrastructure/database/repositories/finance" product_repo "tyapi-server/internal/infrastructure/database/repositories/product" + subordinate_db "tyapi-server/internal/infrastructure/database/repositories/subordinate" infra_events "tyapi-server/internal/infrastructure/events" + subordinate_infra "tyapi-server/internal/infrastructure/subordinate" "tyapi-server/internal/infrastructure/external/alicloud" "tyapi-server/internal/infrastructure/external/captcha" "tyapi-server/internal/infrastructure/external/email" @@ -666,6 +670,14 @@ func NewContainer() *Container { ), ), + // 下属账号仓储 + fx.Provide( + fx.Annotate( + subordinate_db.NewGormSubordinateRepository, + fx.As(new(domain_subordinate_repo.SubordinateRepository)), + ), + ), + // 统计域仓储层 fx.Provide( fx.Annotate( @@ -889,6 +901,21 @@ func NewContainer() *Container { user.NewUserApplicationService, fx.As(new(user.UserApplicationService)), ), + // 下属:账号类型供 JWT / 资料 + fx.Annotate( + subordinate_infra.NewAccountKindProviderImpl, + fx.As(new(interfaces.AccountKindProvider)), + ), + // 下属:禁止子账号自助订 + fx.Annotate( + subordinate_app.NewBlockSelfSubscribeForSubordinate, + fx.As(new(product.SelfSubscribePolicy)), + ), + // 下属:邀请/划款/代配 + fx.Annotate( + subordinate_app.NewSubordinateApplicationService, + fx.As(new(subordinate_app.SubordinateApplicationService)), + ), // 认证应用服务 - 绑定到接口 fx.Annotate( func( @@ -905,6 +932,8 @@ func NewContainer() *Container { apiUserAggregateService api_services.ApiUserAggregateService, enterpriseInfoSubmitRecordService *certification_service.EnterpriseInfoSubmitRecordService, ocrService sharedOCR.OCRService, + subordinateRepo domain_subordinate_repo.SubordinateRepository, + walletRepo domain_finance_repo.WalletRepository, txManager *shared_database.TransactionManager, logger *zap.Logger, cfg *config.Config, @@ -923,6 +952,8 @@ func NewContainer() *Container { apiUserAggregateService, enterpriseInfoSubmitRecordService, ocrService, + subordinateRepo, + walletRepo, txManager, logger, cfg, @@ -1242,6 +1273,7 @@ func NewContainer() *Container { fx.Provide( // 用户HTTP处理器 handlers.NewUserHandler, + handlers.NewSubordinateHandler, // 认证HTTP处理器 handlers.NewCertificationHandler, // 财务HTTP处理器 @@ -1325,6 +1357,7 @@ func NewContainer() *Container { fx.Provide( // 用户路由 routes.NewUserRoutes, + routes.NewSubordinateRoutes, // 验证码路由 routes.NewCaptchaRoutes, // 认证路由 @@ -1457,6 +1490,7 @@ func RegisterMiddlewares( func RegisterRoutes( router *sharedhttp.GinRouter, userRoutes *routes.UserRoutes, + subordinateRoutes *routes.SubordinateRoutes, captchaRoutes *routes.CaptchaRoutes, certificationRoutes *routes.CertificationRoutes, financeRoutes *routes.FinanceRoutes, @@ -1484,6 +1518,7 @@ func RegisterRoutes( // 所有域名路由路由 userRoutes.Register(router) + subordinateRoutes.Register(router) captchaRoutes.Register(router) certificationRoutes.Register(router) financeRoutes.Register(router) diff --git a/internal/domains/subordinate/entities/invitation.go b/internal/domains/subordinate/entities/invitation.go new file mode 100644 index 0000000..cb4d2fe --- /dev/null +++ b/internal/domains/subordinate/entities/invitation.go @@ -0,0 +1,46 @@ +package entities + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// InvitationStatus 邀请状态 +type InvitationStatus string + +const ( + InvitationStatusPending InvitationStatus = "pending" + InvitationStatusConsumed InvitationStatus = "consumed" + InvitationStatusRevoked InvitationStatus = "revoked" +) + +// SubordinateInvitation 主账号邀请记录(存 token 哈希) +type SubordinateInvitation struct { + ID string `gorm:"primaryKey;type:varchar(36)" json:"id" comment:"唯一标识"` + ParentUserID string `gorm:"type:varchar(36);not null;index" json:"parent_user_id" comment:"主账号用户ID"` + TokenHash string `gorm:"type:varchar(64);not null;uniqueIndex" json:"-" comment:"邀请码的SHA256(十六进制)"` + ExpiresAt time.Time `gorm:"not null;index" json:"expires_at" comment:"过期时间"` + Status InvitationStatus `gorm:"type:varchar(20);not null;default:pending" json:"status" comment:"状态"` + + ConsumedByUserID *string `gorm:"type:varchar(36);index" json:"consumed_by_user_id,omitempty" comment:"核销后的子账号用户ID"` + ConsumedAt *time.Time `json:"consumed_at,omitempty" comment:"核销时间"` + + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// TableName 表名 +func (SubordinateInvitation) TableName() string { + return "subordinate_invitations" +} + +// BeforeCreate 生成ID +func (i *SubordinateInvitation) BeforeCreate(tx *gorm.DB) error { + if i.ID == "" { + i.ID = uuid.New().String() + } + return nil +} diff --git a/internal/domains/subordinate/entities/link.go b/internal/domains/subordinate/entities/link.go new file mode 100644 index 0000000..9b77540 --- /dev/null +++ b/internal/domains/subordinate/entities/link.go @@ -0,0 +1,42 @@ +package entities + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// LinkStatus 主从关系状态 +type LinkStatus string + +const ( + LinkStatusActive LinkStatus = "active" + LinkStatusRevoked LinkStatus = "revoked" +) + +// UserSubordinateLink 主账号与下属关系 +type UserSubordinateLink struct { + ID string `gorm:"primaryKey;type:varchar(36)" json:"id" comment:"唯一标识"` + ParentUserID string `gorm:"type:varchar(36);not null;index:idx_parent,priority:1" json:"parent_user_id" comment:"主账号用户ID"` + ChildUserID string `gorm:"type:varchar(36);not null;uniqueIndex" json:"child_user_id" comment:"子账号用户ID(唯一)"` + InvitationID *string `gorm:"type:varchar(36);index" json:"invitation_id,omitempty" comment:"关联的邀请ID"` + Status LinkStatus `gorm:"type:varchar(20);not null;default:active" json:"status" comment:"状态"` + + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// TableName 表名 +func (UserSubordinateLink) TableName() string { + return "user_subordinate_links" +} + +// BeforeCreate 生成ID +func (l *UserSubordinateLink) BeforeCreate(tx *gorm.DB) error { + if l.ID == "" { + l.ID = uuid.New().String() + } + return nil +} diff --git a/internal/domains/subordinate/entities/wallet_allocation.go b/internal/domains/subordinate/entities/wallet_allocation.go new file mode 100644 index 0000000..eeedfdc --- /dev/null +++ b/internal/domains/subordinate/entities/wallet_allocation.go @@ -0,0 +1,36 @@ +package entities + +import ( + "time" + + "github.com/google/uuid" + "github.com/shopspring/decimal" + "gorm.io/gorm" +) + +// SubordinateWalletAllocation 主账号向下属余额划拨记录 +type SubordinateWalletAllocation struct { + ID string `gorm:"primaryKey;type:varchar(36)" json:"id" comment:"唯一标识"` + FromUserID string `gorm:"type:varchar(36);not null;index" json:"from_user_id" comment:"主账号用户ID"` + ToUserID string `gorm:"type:varchar(36);not null;index" json:"to_user_id" comment:"子账号用户ID"` + Amount decimal.Decimal `gorm:"type:decimal(20,8);not null" json:"amount" comment:"金额"` + BusinessRef string `gorm:"type:varchar(64);not null;index" json:"business_ref" comment:"业务单号(幂等/对账)"` + OperatorUserID string `gorm:"type:varchar(36);not null" json:"operator_user_id" comment:"操作者(一般同主账号)"` + + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +// TableName 表名 +func (SubordinateWalletAllocation) TableName() string { + return "subordinate_wallet_allocations" +} + +// BeforeCreate 生成ID +func (a *SubordinateWalletAllocation) BeforeCreate(tx *gorm.DB) error { + if a.ID == "" { + a.ID = uuid.New().String() + } + return nil +} diff --git a/internal/domains/subordinate/repositories/subordinate_repository_interface.go b/internal/domains/subordinate/repositories/subordinate_repository_interface.go new file mode 100644 index 0000000..be0ca3d --- /dev/null +++ b/internal/domains/subordinate/repositories/subordinate_repository_interface.go @@ -0,0 +1,31 @@ +package repositories + +import ( + "context" + "time" + "tyapi-server/internal/domains/subordinate/entities" +) + +// SubordinateRepository 下属模块仓储 +type SubordinateRepository interface { + // 邀请 + CreateInvitation(ctx context.Context, inv *entities.SubordinateInvitation) error + FindInvitationByTokenHash(ctx context.Context, tokenHash string) (*entities.SubordinateInvitation, error) + FindInvitationByID(ctx context.Context, id string) (*entities.SubordinateInvitation, error) + UpdateInvitation(ctx context.Context, inv *entities.SubordinateInvitation) error + ConsumeInvitation(ctx context.Context, invitationID, childUserID string, consumedAt time.Time) (bool, error) + ListInvitationsByParent(ctx context.Context, parentUserID string, limit, offset int) ([]*entities.SubordinateInvitation, int64, error) + + // 主从 + CreateLink(ctx context.Context, link *entities.UserSubordinateLink) error + FindLinkByChildUserID(ctx context.Context, childUserID string) (*entities.UserSubordinateLink, error) + FindLinkByParentAndChild(ctx context.Context, parentUserID, childUserID string) (*entities.UserSubordinateLink, error) + ListChildrenByParent(ctx context.Context, parentUserID string, limit, offset int) ([]*entities.UserSubordinateLink, int64, error) + UpdateLink(ctx context.Context, link *entities.UserSubordinateLink) error + // 是否存在子账号关系(任意子账号) + IsUserSubordinate(ctx context.Context, userID string) (bool, error) + + // 划拨 + CreateWalletAllocation(ctx context.Context, a *entities.SubordinateWalletAllocation) error + ListWalletAllocationsByParentAndChild(ctx context.Context, parentUserID, childUserID string, limit, offset int) ([]*entities.SubordinateWalletAllocation, int64, error) +} diff --git a/internal/infrastructure/database/repositories/subordinate/gorm_subordinate_repository.go b/internal/infrastructure/database/repositories/subordinate/gorm_subordinate_repository.go new file mode 100644 index 0000000..e64d35c --- /dev/null +++ b/internal/infrastructure/database/repositories/subordinate/gorm_subordinate_repository.go @@ -0,0 +1,201 @@ +package subordinate + +import ( + "context" + "errors" + "fmt" + "time" + + "go.uber.org/zap" + "gorm.io/gorm" + + "tyapi-server/internal/domains/subordinate/entities" + "tyapi-server/internal/domains/subordinate/repositories" + shared_database "tyapi-server/internal/shared/database" +) + +// GormSubordinateRepository 下属模块 GORM 实现 +type GormSubordinateRepository struct { + db *gorm.DB + logger *zap.Logger +} + +var _ repositories.SubordinateRepository = (*GormSubordinateRepository)(nil) + +// NewGormSubordinateRepository 构造 +func NewGormSubordinateRepository(db *gorm.DB, logger *zap.Logger) *GormSubordinateRepository { + return &GormSubordinateRepository{db: db, logger: logger} +} + +func (r *GormSubordinateRepository) withCtx(ctx context.Context) *gorm.DB { + if tx, ok := shared_database.GetTx(ctx); ok { + return tx.WithContext(ctx) + } + return r.db.WithContext(ctx) +} + +// CreateInvitation 创建邀请 +func (r *GormSubordinateRepository) CreateInvitation(ctx context.Context, inv *entities.SubordinateInvitation) error { + return r.withCtx(ctx).Create(inv).Error +} + +// FindInvitationByTokenHash 按 token 哈希查询 +func (r *GormSubordinateRepository) FindInvitationByTokenHash(ctx context.Context, tokenHash string) (*entities.SubordinateInvitation, error) { + var inv entities.SubordinateInvitation + err := r.withCtx(ctx).Where("token_hash = ?", tokenHash).First(&inv).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &inv, nil +} + +// FindInvitationByID 按ID +func (r *GormSubordinateRepository) FindInvitationByID(ctx context.Context, id string) (*entities.SubordinateInvitation, error) { + var inv entities.SubordinateInvitation + err := r.withCtx(ctx).Where("id = ?", id).First(&inv).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &inv, nil +} + +// UpdateInvitation 更新 +func (r *GormSubordinateRepository) UpdateInvitation(ctx context.Context, inv *entities.SubordinateInvitation) error { + return r.withCtx(ctx).Save(inv).Error +} + +// ConsumeInvitation 原子核销邀请(仅 pending 可核销) +func (r *GormSubordinateRepository) ConsumeInvitation(ctx context.Context, invitationID, childUserID string, consumedAt time.Time) (bool, error) { + uid := childUserID + res := r.withCtx(ctx). + Model(&entities.SubordinateInvitation{}). + Where("id = ? AND status = ?", invitationID, entities.InvitationStatusPending). + Updates(map[string]interface{}{ + "status": entities.InvitationStatusConsumed, + "consumed_by_user_id": &uid, + "consumed_at": &consumedAt, + }) + if res.Error != nil { + return false, res.Error + } + return res.RowsAffected > 0, nil +} + +// ListInvitationsByParent 主账号邀请列表 +func (r *GormSubordinateRepository) ListInvitationsByParent(ctx context.Context, parentUserID string, limit, offset int) ([]*entities.SubordinateInvitation, int64, error) { + var list []entities.SubordinateInvitation + var total int64 + q := r.withCtx(ctx).Model(&entities.SubordinateInvitation{}).Where("parent_user_id = ?", parentUserID) + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + if err := q.Order("created_at DESC").Limit(limit).Offset(offset).Find(&list).Error; err != nil { + return nil, 0, err + } + out := make([]*entities.SubordinateInvitation, len(list)) + for i := range list { + out[i] = &list[i] + } + return out, total, nil +} + +// CreateLink 创建主从 +func (r *GormSubordinateRepository) CreateLink(ctx context.Context, link *entities.UserSubordinateLink) error { + return r.withCtx(ctx).Create(link).Error +} + +// FindLinkByChildUserID 按子查 +func (r *GormSubordinateRepository) FindLinkByChildUserID(ctx context.Context, childUserID string) (*entities.UserSubordinateLink, error) { + var l entities.UserSubordinateLink + err := r.withCtx(ctx).Where("child_user_id = ? AND status = ?", childUserID, entities.LinkStatusActive).First(&l).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &l, nil +} + +// FindLinkByParentAndChild 精确查 +func (r *GormSubordinateRepository) FindLinkByParentAndChild(ctx context.Context, parentUserID, childUserID string) (*entities.UserSubordinateLink, error) { + var l entities.UserSubordinateLink + err := r.withCtx(ctx).Where("parent_user_id = ? AND child_user_id = ?", parentUserID, childUserID).First(&l).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &l, nil +} + +// ListChildrenByParent 列出下属 +func (r *GormSubordinateRepository) ListChildrenByParent(ctx context.Context, parentUserID string, limit, offset int) ([]*entities.UserSubordinateLink, int64, error) { + var list []entities.UserSubordinateLink + var total int64 + q := r.withCtx(ctx).Model(&entities.UserSubordinateLink{}).Where("parent_user_id = ? AND status = ?", parentUserID, entities.LinkStatusActive) + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + if err := q.Order("created_at DESC").Limit(limit).Offset(offset).Find(&list).Error; err != nil { + return nil, 0, err + } + out := make([]*entities.UserSubordinateLink, len(list)) + for i := range list { + out[i] = &list[i] + } + return out, total, nil +} + +// UpdateLink 更新 +func (r *GormSubordinateRepository) UpdateLink(ctx context.Context, link *entities.UserSubordinateLink) error { + return r.withCtx(ctx).Save(link).Error +} + +// IsUserSubordinate 是否为主账号的下属(存在 active 的 child 记录) +func (r *GormSubordinateRepository) IsUserSubordinate(ctx context.Context, userID string) (bool, error) { + var n int64 + err := r.withCtx(ctx).Model(&entities.UserSubordinateLink{}).Where("child_user_id = ? AND status = ?", userID, entities.LinkStatusActive).Count(&n).Error + if err != nil { + return false, err + } + return n > 0, nil +} + +// CreateWalletAllocation 记划拨 +func (r *GormSubordinateRepository) CreateWalletAllocation(ctx context.Context, a *entities.SubordinateWalletAllocation) error { + // 幂等:同 business_ref 不重复 + var cnt int64 + if err := r.withCtx(ctx).Model(&entities.SubordinateWalletAllocation{}).Where("business_ref = ?", a.BusinessRef).Count(&cnt).Error; err != nil { + return err + } + if cnt > 0 { + return fmt.Errorf("划拨记录已存在") + } + return r.withCtx(ctx).Create(a).Error +} + +// ListWalletAllocationsByParentAndChild 查询主对子划拨记录 +func (r *GormSubordinateRepository) ListWalletAllocationsByParentAndChild(ctx context.Context, parentUserID, childUserID string, limit, offset int) ([]*entities.SubordinateWalletAllocation, int64, error) { + var list []entities.SubordinateWalletAllocation + var total int64 + q := r.withCtx(ctx).Model(&entities.SubordinateWalletAllocation{}).Where("from_user_id = ? AND to_user_id = ?", parentUserID, childUserID) + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + if err := q.Order("created_at DESC").Limit(limit).Offset(offset).Find(&list).Error; err != nil { + return nil, 0, err + } + out := make([]*entities.SubordinateWalletAllocation, len(list)) + for i := range list { + out[i] = &list[i] + } + return out, total, nil +} diff --git a/internal/infrastructure/database/repositories/user/gorm_user_repository.go b/internal/infrastructure/database/repositories/user/gorm_user_repository.go index 7f98e85..68abc37 100644 --- a/internal/infrastructure/database/repositories/user/gorm_user_repository.go +++ b/internal/infrastructure/database/repositories/user/gorm_user_repository.go @@ -11,6 +11,7 @@ import ( "go.uber.org/zap" "gorm.io/gorm" + "gorm.io/gorm/clause" "tyapi-server/internal/domains/user/entities" "tyapi-server/internal/domains/user/repositories" @@ -107,7 +108,48 @@ func (r *GormUserRepository) ExistsByUnifiedSocialCode(ctx context.Context, unif } func (r *GormUserRepository) Update(ctx context.Context, user entities.User) error { - return r.UpdateEntity(ctx, &user) + db := r.GetDB(ctx) + + return db.Transaction(func(tx *gorm.DB) error { + // 避免 GORM 自动保存关联触发 ON CONFLICT(受历史库索引差异影响) + if err := tx.WithContext(ctx).Omit(clause.Associations).Save(&user).Error; err != nil { + return err + } + + // 企业信息单独按 user_id 做更新或创建,避免关联 upsert 依赖冲突约束 + if user.EnterpriseInfo == nil { + return nil + } + + enterpriseInfo := *user.EnterpriseInfo + enterpriseInfo.UserID = user.ID + enterpriseInfo.User = nil + + var count int64 + if err := tx.WithContext(ctx). + Model(&entities.EnterpriseInfo{}). + Where("user_id = ?", user.ID). + Count(&count).Error; err != nil { + return err + } + + if count > 0 { + updates := map[string]interface{}{ + "company_name": enterpriseInfo.CompanyName, + "unified_social_code": enterpriseInfo.UnifiedSocialCode, + "legal_person_name": enterpriseInfo.LegalPersonName, + "legal_person_id": enterpriseInfo.LegalPersonID, + "legal_person_phone": enterpriseInfo.LegalPersonPhone, + "enterprise_address": enterpriseInfo.EnterpriseAddress, + } + return tx.WithContext(ctx). + Model(&entities.EnterpriseInfo{}). + Where("user_id = ?", user.ID). + Updates(updates).Error + } + + return tx.WithContext(ctx).Create(&enterpriseInfo).Error + }) } func (r *GormUserRepository) CreateBatch(ctx context.Context, users []entities.User) error { diff --git a/internal/infrastructure/http/handlers/subordinate_handler.go b/internal/infrastructure/http/handlers/subordinate_handler.go new file mode 100644 index 0000000..ae5bc12 --- /dev/null +++ b/internal/infrastructure/http/handlers/subordinate_handler.go @@ -0,0 +1,211 @@ +package handlers + +import ( + "strconv" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + subordinate_app "tyapi-server/internal/application/subordinate" + "tyapi-server/internal/application/subordinate/dto/commands" + "tyapi-server/internal/shared/interfaces" +) + +// SubordinateHandler 下属账号 +type SubordinateHandler struct { + app subordinate_app.SubordinateApplicationService + response interfaces.ResponseBuilder + validator interfaces.RequestValidator + logger *zap.Logger +} + +// NewSubordinateHandler 构造 +func NewSubordinateHandler( + app subordinate_app.SubordinateApplicationService, + response interfaces.ResponseBuilder, + validator interfaces.RequestValidator, + logger *zap.Logger, +) *SubordinateHandler { + return &SubordinateHandler{app: app, response: response, validator: validator, logger: logger} +} + +// SubPortalRegister 子站注册 +func (h *SubordinateHandler) SubPortalRegister(c *gin.Context) { + var cmd commands.SubPortalRegisterCommand + if err := h.validator.BindAndValidate(c, &cmd); err != nil { + return + } + res, err := h.app.RegisterSubPortal(c.Request.Context(), &cmd) + if err != nil { + h.logger.Error("子站注册失败", zap.Error(err)) + h.response.BadRequest(c, err.Error()) + return + } + h.response.Created(c, res, "注册成功") +} + +// CreateInvitation 主账号创建邀请 +func (h *SubordinateHandler) CreateInvitation(c *gin.Context) { + parentID := c.GetString("user_id") + if parentID == "" { + h.response.Unauthorized(c, "未登录") + return + } + var body struct { + ExpiresInHours int `json:"expires_in_hours"` + } + _ = c.ShouldBindJSON(&body) + res, err := h.app.CreateInvitation(c.Request.Context(), &commands.CreateInvitationCommand{ + ParentUserID: parentID, + ExpiresInHours: body.ExpiresInHours, + }) + if err != nil { + h.logger.Error("创建邀请失败", zap.Error(err)) + h.response.BadRequest(c, err.Error()) + return + } + h.response.Success(c, res, "邀请已创建") +} + +// ListSubordinates 下属列表 +func (h *SubordinateHandler) ListSubordinates(c *gin.Context) { + parentID := c.GetString("user_id") + if parentID == "" { + h.response.Unauthorized(c, "未登录") + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + res, err := h.app.ListMySubordinates(c.Request.Context(), parentID, page, size) + if err != nil { + h.logger.Error("获取下属列表失败", zap.Error(err)) + h.response.InternalError(c, "获取下属列表失败") + return + } + h.response.Success(c, res, "获取成功") +} + +// Allocate 划款 +func (h *SubordinateHandler) Allocate(c *gin.Context) { + parentID := c.GetString("user_id") + if parentID == "" { + h.response.Unauthorized(c, "未登录") + return + } + var cmd commands.AllocateToChildCommand + if err := h.validator.BindAndValidate(c, &cmd); err != nil { + return + } + cmd.ParentUserID = parentID + if err := h.app.AllocateToChild(c.Request.Context(), &cmd); err != nil { + h.logger.Error("划拨失败", zap.Error(err)) + h.response.BadRequest(c, err.Error()) + return + } + h.response.Success(c, nil, "划拨成功") +} + +// ListAllocations 下属划拨记录 +func (h *SubordinateHandler) ListAllocations(c *gin.Context) { + parentID := c.GetString("user_id") + if parentID == "" { + h.response.Unauthorized(c, "未登录") + return + } + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + cmd := &commands.ListChildAllocationsCommand{ + ParentUserID: parentID, + ChildUserID: c.Query("child_user_id"), + Page: page, + PageSize: size, + } + if cmd.ChildUserID == "" { + h.response.BadRequest(c, "child_user_id 不能为空") + return + } + res, err := h.app.ListChildAllocations(c.Request.Context(), cmd) + if err != nil { + h.logger.Error("获取划拨记录失败", zap.Error(err)) + h.response.BadRequest(c, err.Error()) + return + } + h.response.Success(c, res, "获取成功") +} + +// AssignSubscription 代配订阅 +func (h *SubordinateHandler) AssignSubscription(c *gin.Context) { + parentID := c.GetString("user_id") + if parentID == "" { + h.response.Unauthorized(c, "未登录") + return + } + var cmd commands.AssignChildSubscriptionCommand + if err := h.validator.BindAndValidate(c, &cmd); err != nil { + return + } + cmd.ParentUserID = parentID + if err := h.app.AssignChildSubscription(c.Request.Context(), &cmd); err != nil { + h.logger.Error("代配订阅失败", zap.Error(err)) + h.response.BadRequest(c, err.Error()) + return + } + h.response.Success(c, nil, "已保存下属订阅") +} + +// ListChildSubscriptions 下属订阅列表 +func (h *SubordinateHandler) ListChildSubscriptions(c *gin.Context) { + parentID := c.GetString("user_id") + if parentID == "" { + h.response.Unauthorized(c, "未登录") + return + } + childID := c.Query("child_user_id") + if childID == "" { + h.response.BadRequest(c, "child_user_id 不能为空") + return + } + res, err := h.app.ListChildSubscriptions(c.Request.Context(), &commands.ListChildSubscriptionsCommand{ + ParentUserID: parentID, + ChildUserID: childID, + }) + if err != nil { + h.logger.Error("获取下属订阅失败", zap.Error(err)) + h.response.BadRequest(c, err.Error()) + return + } + h.response.Success(c, res, "获取成功") +} + +// RemoveChildSubscription 删除下属订阅 +func (h *SubordinateHandler) RemoveChildSubscription(c *gin.Context) { + parentID := c.GetString("user_id") + if parentID == "" { + h.response.Unauthorized(c, "未登录") + return + } + var body struct { + ChildUserID string `json:"child_user_id"` + } + _ = c.ShouldBindJSON(&body) + if body.ChildUserID == "" { + h.response.BadRequest(c, "child_user_id 不能为空") + return + } + subID := c.Param("subscription_id") + if subID == "" { + h.response.BadRequest(c, "subscription_id 不能为空") + return + } + err := h.app.RemoveChildSubscription(c.Request.Context(), &commands.RemoveChildSubscriptionCommand{ + ParentUserID: parentID, + ChildUserID: body.ChildUserID, + SubscriptionID: subID, + }) + if err != nil { + h.logger.Error("删除下属订阅失败", zap.Error(err)) + h.response.BadRequest(c, err.Error()) + return + } + h.response.Success(c, nil, "删除成功") +} diff --git a/internal/infrastructure/http/routes/certification_routes.go b/internal/infrastructure/http/routes/certification_routes.go index 338ff6d..262644d 100644 --- a/internal/infrastructure/http/routes/certification_routes.go +++ b/internal/infrastructure/http/routes/certification_routes.go @@ -49,8 +49,6 @@ func (r *CertificationRoutes) Register(router *http.GinRouter) { authGroup := certificationGroup.Group("") authGroup.Use(r.auth.Handle()) { - authGroup.GET("", r.handler.ListCertifications) // 查询认证列表(管理员) - // 1. 获取认证详情 authGroup.GET("/details", r.handler.GetCertification) @@ -71,10 +69,6 @@ func (r *CertificationRoutes) Register(router *http.GinRouter) { // 前端确认是否完成签署 authGroup.POST("/confirm-sign", r.handler.ConfirmSign) - - // 管理员代用户完成认证(暂不关联合同) - authGroup.POST("/admin/complete-without-contract", r.handler.AdminCompleteCertificationWithoutContract) - } // 管理端企业审核(需管理员权限,以状态机状态为准) @@ -82,6 +76,8 @@ func (r *CertificationRoutes) Register(router *http.GinRouter) { adminGroup.Use(r.auth.Handle()) adminGroup.Use(r.admin.Handle()) { + adminGroup.GET("", r.handler.ListCertifications) // 查询认证列表(管理员) + adminGroup.POST("/complete-without-contract", r.handler.AdminCompleteCertificationWithoutContract) adminGroup.POST("/transition-status", r.handler.AdminTransitionCertificationStatus) } adminCertGroup := adminGroup.Group("/submit-records") diff --git a/internal/infrastructure/http/routes/subordinate_routes.go b/internal/infrastructure/http/routes/subordinate_routes.go new file mode 100644 index 0000000..f3a21c7 --- /dev/null +++ b/internal/infrastructure/http/routes/subordinate_routes.go @@ -0,0 +1,46 @@ +package routes + +import ( + "tyapi-server/internal/infrastructure/http/handlers" + sharedhttp "tyapi-server/internal/shared/http" + "tyapi-server/internal/shared/middleware" + + "go.uber.org/zap" +) + +// SubordinateRoutes 下属与邀请路由 +type SubordinateRoutes struct { + handler *handlers.SubordinateHandler + auth *middleware.JWTAuthMiddleware + logger *zap.Logger +} + +// NewSubordinateRoutes 构造 +func NewSubordinateRoutes( + handler *handlers.SubordinateHandler, + auth *middleware.JWTAuthMiddleware, + logger *zap.Logger, +) *SubordinateRoutes { + return &SubordinateRoutes{handler: handler, auth: auth, logger: logger} +} + +// Register 注册 +func (r *SubordinateRoutes) Register(router *sharedhttp.GinRouter) { + g := router.GetEngine() + + g.POST("/api/v1/sub-portal/register", r.handler.SubPortalRegister) + + sub := g.Group("/api/v1/subordinate") + sub.Use(r.auth.Handle()) + { + sub.POST("/invitations", r.handler.CreateInvitation) + sub.GET("/subordinates", r.handler.ListSubordinates) + sub.POST("/allocate", r.handler.Allocate) + sub.GET("/allocations", r.handler.ListAllocations) + sub.POST("/assign-subscription", r.handler.AssignSubscription) + sub.GET("/child-subscriptions", r.handler.ListChildSubscriptions) + sub.DELETE("/child-subscriptions/:subscription_id", r.handler.RemoveChildSubscription) + } + + r.logger.Info("下属账号路由注册完成") +} diff --git a/internal/infrastructure/subordinate/account_kind_provider.go b/internal/infrastructure/subordinate/account_kind_provider.go new file mode 100644 index 0000000..7953217 --- /dev/null +++ b/internal/infrastructure/subordinate/account_kind_provider.go @@ -0,0 +1,31 @@ +package subordinate + +import ( + "context" + + "tyapi-server/internal/domains/subordinate/repositories" + "tyapi-server/internal/shared/auth" + "tyapi-server/internal/shared/interfaces" +) + +// AccountKindProviderImpl 从主从表判断 account_kind +type AccountKindProviderImpl struct { + repo repositories.SubordinateRepository +} + +// NewAccountKindProviderImpl 构造 +func NewAccountKindProviderImpl(repo repositories.SubordinateRepository) interfaces.AccountKindProvider { + return &AccountKindProviderImpl{repo: repo} +} + +// AccountKind 返回 standalone 或 subordinate +func (p *AccountKindProviderImpl) AccountKind(ctx context.Context, userID string) (string, error) { + ok, err := p.repo.IsUserSubordinate(ctx, userID) + if err != nil { + return "", err + } + if ok { + return auth.AccountKindSubordinate, nil + } + return auth.AccountKindStandalone, nil +} diff --git a/internal/shared/auth/account_kind.go b/internal/shared/auth/account_kind.go new file mode 100644 index 0000000..c05cd67 --- /dev/null +++ b/internal/shared/auth/account_kind.go @@ -0,0 +1,7 @@ +package auth + +// 账号在控制台维度的「壳」类型(与 user_type 管理员/普通 正交) +const ( + AccountKindStandalone = "standalone" + AccountKindSubordinate = "subordinate" +) diff --git a/internal/shared/interfaces/account_kind_provider.go b/internal/shared/interfaces/account_kind_provider.go new file mode 100644 index 0000000..ef310ec --- /dev/null +++ b/internal/shared/interfaces/account_kind_provider.go @@ -0,0 +1,9 @@ +package interfaces + +import "context" + +// AccountKindProvider 为 JWT / 用户资料 提供 account_kind +type AccountKindProvider interface { + // AccountKind 返回 shared/auth 中的 AccountKind* 常量 + AccountKind(ctx context.Context, userID string) (string, error) +} diff --git a/internal/shared/middleware/auth.go b/internal/shared/middleware/auth.go index 05c9473..2a24d97 100644 --- a/internal/shared/middleware/auth.go +++ b/internal/shared/middleware/auth.go @@ -81,6 +81,11 @@ func (m *JWTAuthMiddleware) Handle() gin.HandlerFunc { c.Set("email", claims.Email) c.Set("phone", claims.Phone) c.Set("user_type", claims.UserType) + if claims.AccountKind != "" { + c.Set("account_kind", claims.AccountKind) + } else { + c.Set("account_kind", "standalone") + } c.Set("token_claims", claims) c.Next() @@ -99,6 +104,8 @@ type JWTClaims struct { Email string `json:"email"` Phone string `json:"phone"` UserType string `json:"user_type"` // 新增:用户类型 + // AccountKind 控制台壳类型:standalone / subordinate(与主从关系表一致时下属为 subordinate) + AccountKind string `json:"account_kind"` jwt.RegisteredClaims } @@ -137,15 +144,19 @@ func (m *JWTAuthMiddleware) respondUnauthorized(c *gin.Context, message string) } // GenerateToken 生成JWT token -func (m *JWTAuthMiddleware) GenerateToken(userID, phone, email, userType string) (string, error) { +func (m *JWTAuthMiddleware) GenerateToken(userID, phone, email, userType, accountKind string) (string, error) { now := time.Now() + if accountKind == "" { + accountKind = "standalone" + } claims := &JWTClaims{ UserID: userID, Username: phone, // 普通用户用手机号,管理员用用户名 Email: email, Phone: phone, - UserType: userType, // 新增:用户类型 + UserType: userType, // 新增:用户类型 + AccountKind: accountKind, // 下属 / 普通 RegisteredClaims: jwt.RegisteredClaims{ Issuer: "tyapi-server", Subject: userID, @@ -262,6 +273,11 @@ func (m *OptionalAuthMiddleware) Handle() gin.HandlerFunc { c.Set("email", claims.Email) c.Set("phone", claims.Phone) c.Set("user_type", claims.UserType) + if claims.AccountKind != "" { + c.Set("account_kind", claims.AccountKind) + } else { + c.Set("account_kind", "standalone") + } c.Set("token_claims", claims) c.Next() @@ -343,6 +359,11 @@ func (m *AdminAuthMiddleware) Handle() gin.HandlerFunc { c.Set("email", claims.Email) c.Set("phone", claims.Phone) c.Set("user_type", claims.UserType) + if claims.AccountKind != "" { + c.Set("account_kind", claims.AccountKind) + } else { + c.Set("account_kind", "standalone") + } c.Set("token_claims", claims) c.Next() diff --git a/internal/shared/middleware/daily_rate_limit.go b/internal/shared/middleware/daily_rate_limit.go index 64fc403..a486518 100644 --- a/internal/shared/middleware/daily_rate_limit.go +++ b/internal/shared/middleware/daily_rate_limit.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "math" + "net/url" "strconv" "strings" "time" @@ -403,9 +404,24 @@ func (m *DailyRateLimitMiddleware) checkReferer(c *gin.Context) error { // 检查允许的Referer if len(m.limitConfig.AllowedReferers) > 0 { + parsedReferer, err := url.Parse(referer) + if err != nil || parsedReferer.Scheme == "" || parsedReferer.Host == "" { + return fmt.Errorf("Referer格式无效") + } + refererOrigin := parsedReferer.Scheme + "://" + parsedReferer.Host + allowed := false for _, allowedRef := range m.limitConfig.AllowedReferers { - if strings.Contains(referer, allowedRef) { + allowedRef = strings.TrimSpace(allowedRef) + if allowedRef == "" { + continue + } + parsedAllowed, parseErr := url.Parse(allowedRef) + if parseErr != nil || parsedAllowed.Scheme == "" || parsedAllowed.Host == "" { + continue + } + allowedOrigin := parsedAllowed.Scheme + "://" + parsedAllowed.Host + if refererOrigin == allowedOrigin { allowed = true break }