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..9ad2d9a 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,14 @@ func (a *Application) autoMigrate(db *gorm.DB) error { &apiEntities.ApiCall{}, &apiEntities.Report{}, + // 下属账号域 + &subordinateEntities.SubordinateInvitation{}, + &subordinateEntities.UserSubordinateLink{}, + &subordinateEntities.SubordinateWalletAllocation{}, + &subordinateEntities.SubordinateQuotaPurchase{}, + &subordinateEntities.UserProductQuotaAccount{}, + &subordinateEntities.UserProductQuotaLedger{}, + // 任务域 &taskEntities.AsyncTask{}, ) diff --git a/internal/application/api/api_application_service.go b/internal/application/api/api_application_service.go index 61e01a1..370bacc 100644 --- a/internal/application/api/api_application_service.go +++ b/internal/application/api/api_application_service.go @@ -20,6 +20,8 @@ import ( finance_services "tyapi-server/internal/domains/finance/services" product_entities "tyapi-server/internal/domains/product/entities" product_services "tyapi-server/internal/domains/product/services" + subordinate_entities "tyapi-server/internal/domains/subordinate/entities" + subordinate_repositories "tyapi-server/internal/domains/subordinate/repositories" user_repositories "tyapi-server/internal/domains/user/repositories" task_entities "tyapi-server/internal/infrastructure/task/entities" "tyapi-server/internal/infrastructure/task/interfaces" @@ -93,6 +95,7 @@ type ApiApplicationServiceImpl struct { walletService finance_services.WalletAggregateService subscriptionService *product_services.ProductSubscriptionService balanceAlertService finance_services.BalanceAlertService + subordinateRepo subordinate_repositories.SubordinateRepository } func NewApiApplicationService( @@ -112,6 +115,7 @@ func NewApiApplicationService( subscriptionService *product_services.ProductSubscriptionService, exportManager *export.ExportManager, balanceAlertService finance_services.BalanceAlertService, + subordinateRepo subordinate_repositories.SubordinateRepository, ) ApiApplicationService { service := &ApiApplicationServiceImpl{ apiCallService: apiCallService, @@ -130,6 +134,7 @@ func NewApiApplicationService( walletService: walletService, subscriptionService: subscriptionService, balanceAlertService: balanceAlertService, + subordinateRepo: subordinateRepo, } return service @@ -261,18 +266,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 { @@ -286,6 +291,44 @@ func (s *ApiApplicationServiceImpl) validateApiCall(ctx context.Context, cmd *co s.logger.Error("解析解密参数失败", zap.Error(err)) return nil, ErrDecryptFail } + + // 7.1 子账号主账号 AccessId 校验(仅在请求参数中携带时生效) + if parentAccessID, ok := extractParentAccessID(paramsMap); ok { + if s.subordinateRepo == nil { + s.logger.Error("子账号主账号AccessId校验失败:subordinateRepo未初始化") + return nil, ErrSystem + } + + link, err := s.subordinateRepo.FindLinkByChildUserID(ctx, apiUser.UserId) + if err != nil { + s.logger.Error("查询子账号主从关系失败", + zap.String("user_id", apiUser.UserId), + zap.Error(err)) + return nil, ErrSystem + } + if link == nil { + s.logger.Warn("子账号主账号AccessId校验失败:未找到主从关系", + zap.String("user_id", apiUser.UserId), + zap.String("parent_access_id", parentAccessID)) + return nil, ErrQueryFailed + } + + parentApiUser, err := s.apiUserService.LoadApiUserByUserId(ctx, link.ParentUserID) + if err != nil { + s.logger.Error("加载主账号API用户失败", + zap.String("child_user_id", apiUser.UserId), + zap.String("parent_user_id", link.ParentUserID), + zap.Error(err)) + return nil, ErrSystem + } + if parentApiUser == nil || parentApiUser.AccessId != parentAccessID { + s.logger.Warn("子账号主账号AccessId校验失败:主账号不匹配", + zap.String("child_user_id", apiUser.UserId), + zap.String("parent_user_id", link.ParentUserID), + zap.String("parent_access_id", parentAccessID)) + return nil, ErrQueryFailed + } + } result.SetRequestParams(paramsMap) // 8. 获取合同信息 @@ -302,6 +345,26 @@ func (s *ApiApplicationServiceImpl) validateApiCall(ctx context.Context, cmd *co return result, nil } +// extractParentAccessID 从解密参数中提取主账号 AccessId +// 仅支持键名:master_accessid +func extractParentAccessID(params map[string]interface{}) (string, bool) { + if len(params) == 0 { + return "", false + } + + value, ok := params["master_accessid"] + if !ok { + return "", false + } + if str, ok := value.(string); ok { + str = strings.TrimSpace(str) + if str != "" { + return str, true + } + } + return "", false +} + // callExternalApi 同步调用外部API func (s *ApiApplicationServiceImpl) callExternalApi(ctx context.Context, cmd *commands.ApiCallCommand, validation *dto.ApiCallValidationResult) (string, error) { // 创建CallContext @@ -1108,6 +1171,24 @@ func (s *ApiApplicationServiceImpl) ProcessDeduction(ctx context.Context, cmd *c return err } + // 优先扣减产品额度(若存在且可用),避免子账号有额度却因钱包余额不足失败 + deductedByQuota, err := s.tryDeductQuota(ctx, cmd.UserID, cmd.ProductID, cmd.ApiCallID, cmd.TransactionID) + if err != nil { + s.logger.Error("额度扣减失败", + zap.String("transaction_id", cmd.TransactionID), + zap.String("user_id", cmd.UserID), + zap.String("product_id", cmd.ProductID), + zap.Error(err)) + return err + } + if deductedByQuota { + s.logger.Info("额度扣减成功", + zap.String("transaction_id", cmd.TransactionID), + zap.String("user_id", cmd.UserID), + zap.String("product_id", cmd.ProductID)) + return nil + } + if err := s.walletService.Deduct(ctx, cmd.UserID, amount, cmd.ApiCallID, cmd.TransactionID, cmd.ProductID); err != nil { s.logger.Error("扣款处理失败", zap.String("transaction_id", cmd.TransactionID), @@ -1201,7 +1282,26 @@ 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 { + // 若用户在该产品有可用额度,则本次调用将走额度扣减,不再要求钱包余额预检通过 + if s.subordinateRepo != nil { + quotaAccount, err := s.subordinateRepo.FindQuotaAccount(ctx, userID, product.ID) + if err != nil { + s.logger.Error("查询额度账户失败", + zap.String("user_id", userID), + zap.String("product_id", product.ID), + zap.Error(err)) + return ErrSystem + } + if quotaAccount != nil && quotaAccount.AvailableQuota > 0 { + s.logger.Info("额度校验通过,跳过钱包余额预检", + zap.String("user_id", userID), + zap.String("product_id", product.ID), + zap.Int64("available_quota", quotaAccount.AvailableQuota)) + return nil + } + } + // 1. 获取用户钱包信息 wallet, err := s.walletService.LoadWalletByUserId(ctx, userID) if err != nil { @@ -1219,8 +1319,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), @@ -1246,6 +1351,56 @@ func (s *ApiApplicationServiceImpl) validateWalletStatus(ctx context.Context, us return nil } +// tryDeductQuota 尝试扣减产品额度;若不存在额度账户则返回 false,nil 以便回退钱包扣款 +func (s *ApiApplicationServiceImpl) tryDeductQuota(ctx context.Context, userID, productID, apiCallID, transactionID string) (bool, error) { + if s.subordinateRepo == nil || productID == "" { + return false, nil + } + + var deducted bool + err := s.txManager.ExecuteInTx(ctx, func(txCtx context.Context) error { + account, err := s.subordinateRepo.FindQuotaAccount(txCtx, userID, productID) + if err != nil { + return err + } + if account == nil { + return nil + } + if account.AvailableQuota <= 0 { + return ErrInsufficientBalance + } + + before := account.AvailableQuota + account.AvailableQuota -= 1 + account.UsedQuota += 1 + if err := s.subordinateRepo.UpdateQuotaAccount(txCtx, account); err != nil { + return err + } + + ledger := &subordinate_entities.UserProductQuotaLedger{ + UserID: userID, + ProductID: productID, + ChangeType: subordinate_entities.QuotaLedgerChangeTypeConsumeAPI, + DeltaQuota: -1, + BeforeQuota: before, + AfterQuota: account.AvailableQuota, + SourceID: apiCallID, + OperatorID: userID, + Remark: fmt.Sprintf("API调用扣减,transaction_id=%s", transactionID), + } + if err := s.subordinateRepo.CreateQuotaLedger(txCtx, ledger); err != nil { + return err + } + + deducted = true + return nil + }) + if err != nil { + return false, err + } + return deducted, nil +} + // validateSubscriptionStatus 验证订阅状态并返回订阅信息 func (s *ApiApplicationServiceImpl) validateSubscriptionStatus(ctx context.Context, userID string, product *product_entities.Product) (*product_entities.Subscription, error) { // 1. 检查用户是否已订阅该产品 diff --git a/internal/application/api/errors.go b/internal/application/api/errors.go index 629634e..3e16f96 100644 --- a/internal/application/api/errors.go +++ b/internal/application/api/errors.go @@ -5,6 +5,7 @@ import "errors" // API调用相关错误类型 var ( ErrQueryEmpty = errors.New("查询为空") + ErrQueryFailed = errors.New("查询失败") ErrSystem = errors.New("接口异常") ErrDecryptFail = errors.New("解密失败") ErrRequestParam = errors.New("请求参数结构不正确") @@ -27,6 +28,7 @@ var ( // 错误码映射 - 严格按照用户要求 var ErrorCodeMap = map[error]int{ ErrQueryEmpty: 1000, + ErrQueryFailed: 1000, ErrSystem: 1001, ErrDecryptFail: 1002, ErrRequestParam: 1003, 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..83888a0 --- /dev/null +++ b/internal/application/subordinate/dto/commands/subordinate_commands.go @@ -0,0 +1,78 @@ +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"` +} + +// PurchaseChildQuotaCommand 主账号为子账号购买调用额度 +type PurchaseChildQuotaCommand struct { + ParentUserID string + ChildUserID string `json:"child_user_id" binding:"required"` + ProductID string `json:"product_id" binding:"required"` + CallCount int64 `json:"call_count" binding:"required,min=1"` + VerifyCode string `json:"verify_code" binding:"required,len=6"` +} + +// ListChildQuotaPurchasesCommand 下属额度购买记录查询 +type ListChildQuotaPurchasesCommand 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"` +} + +// ListChildQuotaAccountsCommand 下属额度账户查询 +type ListChildQuotaAccountsCommand struct { + ParentUserID string + ChildUserID string `json:"child_user_id" form:"child_user_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..0aaefd7 --- /dev/null +++ b/internal/application/subordinate/dto/responses/subordinate_responses.go @@ -0,0 +1,82 @@ +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"` +} + +// ChildQuotaPurchaseItem 下属额度购买记录 +type ChildQuotaPurchaseItem struct { + ID string `json:"id"` + ProductID string `json:"product_id"` + CallCount int64 `json:"call_count"` + UnitPrice string `json:"unit_price"` + TotalAmount string `json:"total_amount"` + BusinessRef string `json:"business_ref"` + CreatedAt time.Time `json:"created_at"` +} + +// ChildQuotaPurchaseListResponse 下属额度购买记录列表 +type ChildQuotaPurchaseListResponse struct { + Total int64 `json:"total"` + Items []ChildQuotaPurchaseItem `json:"items"` +} + +// ChildQuotaAccountItem 下属产品额度账户 +type ChildQuotaAccountItem struct { + ProductID string `json:"product_id"` + TotalQuota int64 `json:"total_quota"` + UsedQuota int64 `json:"used_quota"` + AvailableQuota int64 `json:"available_quota"` +} 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..39adb5b --- /dev/null +++ b/internal/application/subordinate/subordinate_application_service.go @@ -0,0 +1,24 @@ +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 + PurchaseChildQuota(ctx context.Context, cmd *commands.PurchaseChildQuotaCommand) error + ListChildQuotaPurchases(ctx context.Context, cmd *commands.ListChildQuotaPurchasesCommand) (*responses.ChildQuotaPurchaseListResponse, error) + ListChildQuotaAccounts(ctx context.Context, cmd *commands.ListChildQuotaAccountsCommand) ([]responses.ChildQuotaAccountItem, error) + ListMyQuotaAccounts(ctx context.Context, userID string) ([]responses.ChildQuotaAccountItem, 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..dc5d07d --- /dev/null +++ b/internal/application/subordinate/subordinate_application_service_impl.go @@ -0,0 +1,608 @@ +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) +} + +// PurchaseChildQuota 主账号为子账号购买调用额度(按子账号订阅价结算) +func (s *SubordinateApplicationServiceImpl) PurchaseChildQuota(ctx context.Context, cmd *commands.PurchaseChildQuotaCommand) error { + if cmd.CallCount <= 0 { + 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("该用户不是您的有效下属") + } + + parentSub, err := s.productSub.GetUserSubscribedProduct(ctx, cmd.ParentUserID, cmd.ProductID) + if err != nil { + return err + } + if parentSub == nil { + return fmt.Errorf("主账号未订阅该产品,无法购买额度") + } + if !parentSub.Price.GreaterThan(decimal.Zero) { + return fmt.Errorf("主账号订阅价格异常,无法购买额度") + } + + callCountDec := decimal.NewFromInt(cmd.CallCount) + totalAmount := parentSub.Price.Mul(callCountDec) + if !totalAmount.GreaterThan(decimal.Zero) { + return fmt.Errorf("购买金额必须大于0") + } + + bizRef := uuid.New().String() + return s.txm.ExecuteInTx(ctx, func(txCtx context.Context) error { + // 购买额度前自动确保子账号存在该产品订阅,并统一为主账号订阅价 + childSub, err := s.productSub.GetUserSubscribedProduct(txCtx, cmd.ChildUserID, cmd.ProductID) + if err != nil { + return err + } + if childSub == nil { + newSub := &productentities.Subscription{ + UserID: cmd.ChildUserID, + ProductID: cmd.ProductID, + Price: parentSub.Price, + UIComponentPrice: parentSub.UIComponentPrice, + } + if err := s.productSub.SaveSubscription(txCtx, newSub); err != nil { + return fmt.Errorf("为下属创建订阅失败: %w", err) + } + } else { + childSub.Price = parentSub.Price + childSub.UIComponentPrice = parentSub.UIComponentPrice + if err := s.productSub.SaveSubscription(txCtx, childSub); err != nil { + return fmt.Errorf("更新下属订阅失败: %w", err) + } + } + + ok, err := s.walletRepo.UpdateBalanceByUserID(txCtx, cmd.ParentUserID, totalAmount, "subtract") + if err != nil { + return err + } + if !ok { + return fmt.Errorf("主账号扣款失败,请重试") + } + + account, err := s.subRepo.FindQuotaAccount(txCtx, cmd.ChildUserID, cmd.ProductID) + if err != nil { + return err + } + + var beforeAvailable int64 + if account == nil { + account = &subentities.UserProductQuotaAccount{ + UserID: cmd.ChildUserID, + ProductID: cmd.ProductID, + TotalQuota: cmd.CallCount, + UsedQuota: 0, + AvailableQuota: cmd.CallCount, + } + beforeAvailable = 0 + if err := s.subRepo.CreateQuotaAccount(txCtx, account); err != nil { + return err + } + } else { + beforeAvailable = account.AvailableQuota + account.TotalQuota += cmd.CallCount + account.AvailableQuota += cmd.CallCount + if err := s.subRepo.UpdateQuotaAccount(txCtx, account); err != nil { + return err + } + } + + purchase := &subentities.SubordinateQuotaPurchase{ + ParentUserID: cmd.ParentUserID, + ChildUserID: cmd.ChildUserID, + ProductID: cmd.ProductID, + CallCount: cmd.CallCount, + UnitPrice: parentSub.Price, + TotalAmount: totalAmount, + BusinessRef: bizRef, + OperatorUserID: cmd.ParentUserID, + } + if err := s.subRepo.CreateQuotaPurchase(txCtx, purchase); err != nil { + return err + } + + ledger := &subentities.UserProductQuotaLedger{ + UserID: cmd.ChildUserID, + ProductID: cmd.ProductID, + ChangeType: subentities.QuotaLedgerChangeTypePurchaseForSub, + DeltaQuota: cmd.CallCount, + BeforeQuota: beforeAvailable, + AfterQuota: beforeAvailable + cmd.CallCount, + SourceID: purchase.ID, + OperatorID: cmd.ParentUserID, + Remark: "主账号为子账号购买额度", + } + return s.subRepo.CreateQuotaLedger(txCtx, ledger) + }) +} + +// ListChildQuotaPurchases 下属额度购买记录 +func (s *SubordinateApplicationServiceImpl) ListChildQuotaPurchases(ctx context.Context, cmd *commands.ListChildQuotaPurchasesCommand) (*responses.ChildQuotaPurchaseListResponse, 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.ListQuotaPurchasesByParentAndChild(ctx, cmd.ParentUserID, cmd.ChildUserID, pageSize, offset) + if err != nil { + return nil, err + } + + items := make([]responses.ChildQuotaPurchaseItem, 0, len(rows)) + for _, row := range rows { + items = append(items, responses.ChildQuotaPurchaseItem{ + ID: row.ID, + ProductID: row.ProductID, + CallCount: row.CallCount, + UnitPrice: row.UnitPrice.StringFixed(2), + TotalAmount: row.TotalAmount.StringFixed(2), + BusinessRef: row.BusinessRef, + CreatedAt: row.CreatedAt, + }) + } + + return &responses.ChildQuotaPurchaseListResponse{ + Total: total, + Items: items, + }, nil +} + +// ListChildQuotaAccounts 下属额度账户 +func (s *SubordinateApplicationServiceImpl) ListChildQuotaAccounts(ctx context.Context, cmd *commands.ListChildQuotaAccountsCommand) ([]responses.ChildQuotaAccountItem, 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("该用户不是您的有效下属") + } + + accounts, err := s.subRepo.ListQuotaAccountsByUser(ctx, cmd.ChildUserID) + if err != nil { + return nil, err + } + items := make([]responses.ChildQuotaAccountItem, 0, len(accounts)) + for _, account := range accounts { + items = append(items, responses.ChildQuotaAccountItem{ + ProductID: account.ProductID, + TotalQuota: account.TotalQuota, + UsedQuota: account.UsedQuota, + AvailableQuota: account.AvailableQuota, + }) + } + return items, nil +} + +// ListMyQuotaAccounts 查询当前用户额度账户(通用能力,适配所有用户) +func (s *SubordinateApplicationServiceImpl) ListMyQuotaAccounts(ctx context.Context, userID string) ([]responses.ChildQuotaAccountItem, error) { + accounts, err := s.subRepo.ListQuotaAccountsByUser(ctx, userID) + if err != nil { + return nil, err + } + items := make([]responses.ChildQuotaAccountItem, 0, len(accounts)) + for _, account := range accounts { + items = append(items, responses.ChildQuotaAccountItem{ + ProductID: account.ProductID, + TotalQuota: account.TotalQuota, + UsedQuota: account.UsedQuota, + AvailableQuota: account.AvailableQuota, + }) + } + return items, nil +} 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..9a48ebd 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( @@ -799,6 +811,7 @@ func NewContainer() *Container { subscriptionService *product_services.ProductSubscriptionService, exportManager *export.ExportManager, balanceAlertService finance_services.BalanceAlertService, + subordinateRepo domain_subordinate_repo.SubordinateRepository, ) api_app.ApiApplicationService { return api_app.NewApiApplicationService( apiCallService, @@ -817,6 +830,7 @@ func NewContainer() *Container { subscriptionService, exportManager, balanceAlertService, + subordinateRepo, ) }, fx.As(new(api_app.ApiApplicationService)), @@ -889,6 +903,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 +934,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 +954,8 @@ func NewContainer() *Container { apiUserAggregateService, enterpriseInfoSubmitRecordService, ocrService, + subordinateRepo, + walletRepo, txManager, logger, cfg, @@ -1242,6 +1275,7 @@ func NewContainer() *Container { fx.Provide( // 用户HTTP处理器 handlers.NewUserHandler, + handlers.NewSubordinateHandler, // 认证HTTP处理器 handlers.NewCertificationHandler, // 财务HTTP处理器 @@ -1325,6 +1359,7 @@ func NewContainer() *Container { fx.Provide( // 用户路由 routes.NewUserRoutes, + routes.NewSubordinateRoutes, // 验证码路由 routes.NewCaptchaRoutes, // 认证路由 @@ -1457,6 +1492,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 +1520,7 @@ func RegisterRoutes( // 所有域名路由路由 userRoutes.Register(router) + subordinateRoutes.Register(router) captchaRoutes.Register(router) certificationRoutes.Register(router) financeRoutes.Register(router) diff --git a/internal/domains/api/services/processors/jrzq/jrzq0l85_processor.go b/internal/domains/api/services/processors/jrzq/jrzq0l85_processor.go index 29ae332..fb41183 100644 --- a/internal/domains/api/services/processors/jrzq/jrzq0l85_processor.go +++ b/internal/domains/api/services/processors/jrzq/jrzq0l85_processor.go @@ -23,29 +23,18 @@ func ProcessJRZQ0L85Request(ctx context.Context, params []byte, deps *processors return nil, errors.Join(processors.ErrInvalidParam, err) } - encryptedName, err := deps.ZhichaService.Encrypt(paramsDto.Name) - if err != nil { - return nil, errors.Join(processors.ErrSystem, err) - } - - encryptedIDCard, err := deps.ZhichaService.Encrypt(paramsDto.IDCard) - if err != nil { - return nil, errors.Join(processors.ErrSystem, err) - } - - encryptedMobileNo, err := deps.ZhichaService.Encrypt(paramsDto.MobileNo) - if err != nil { - return nil, errors.Join(processors.ErrSystem, err) - } + md5Name := deps.ZhichaService.MD5(paramsDto.Name) + md5IDCard := deps.ZhichaService.MD5(paramsDto.IDCard) + md5MobileNo := deps.ZhichaService.MD5(paramsDto.MobileNo) reqData := map[string]interface{}{ - "name": encryptedName, - "idCard": encryptedIDCard, - "phone": encryptedMobileNo, + "name": md5Name, + "idCard": md5IDCard, + "phone": md5MobileNo, "authorized": "1", } - respData, err := deps.ZhichaService.CallAPI(ctx, "ZCI021", reqData) + respData, err := deps.ZhichaService.CallAPI(ctx, "ZCI084", reqData) if err != nil { if errors.Is(err, zhicha.ErrDatasource) { return nil, errors.Join(processors.ErrDatasource, err) @@ -56,9 +45,9 @@ func ProcessJRZQ0L85Request(ctx context.Context, params []byte, deps *processors score := "-1" if m, ok := respData.(map[string]interface{}); ok { - if raw, exists := m["xyp_cpl0081"]; exists { - if v, ok := parseToFloat64(raw); ok { - score = mapXypToGeneralScore(v) + if rawScore, exists := m["scoreywbase"]; exists { + if v, ok := parseToFloat64(rawScore); ok { + score = mapScoreAfywBaseToGeneralScore(v) } } } @@ -99,17 +88,18 @@ func parseToFloat64(v interface{}) (float64, bool) { } } -func mapXypToGeneralScore(xyp float64) string { - // xyp_cpl0081: 0~1,值越大风险越高; - // score_120_General: 300~900,值越大信用越好。 - if xyp < 0 { - xyp = 0 +func mapScoreAfywBaseToGeneralScore(scoreAfywBase float64) string { + // scoreafywbase: 300~1000,分值越高违约概率越低。 + // score_120_General: 300~900,分值越高信用越好。 + if scoreAfywBase < 300 { + scoreAfywBase = 300 } - if xyp > 1 { - xyp = 1 + if scoreAfywBase > 1000 { + scoreAfywBase = 1000 } - score := 900 - xyp*600 + // 线性映射:300->300, 1000->900 + score := 300 + (scoreAfywBase-300)*600/700 scoreInt := int(math.Round(score)) if scoreInt < 300 { scoreInt = 300 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/quota.go b/internal/domains/subordinate/entities/quota.go new file mode 100644 index 0000000..2d713b7 --- /dev/null +++ b/internal/domains/subordinate/entities/quota.go @@ -0,0 +1,98 @@ +package entities + +import ( + "time" + + "github.com/google/uuid" + "github.com/shopspring/decimal" + "gorm.io/gorm" +) + +const ( + // QuotaLedgerChangeTypePurchaseForSub 主账号为子账号购买额度 + QuotaLedgerChangeTypePurchaseForSub = "purchase_for_sub" + // QuotaLedgerChangeTypeConsumeAPI 用户调用API消耗额度 + QuotaLedgerChangeTypeConsumeAPI = "api_consume" +) + +// SubordinateQuotaPurchase 主账号为子账号购买额度记录 +type SubordinateQuotaPurchase struct { + ID string `gorm:"primaryKey;type:varchar(36)" json:"id"` + ParentUserID string `gorm:"type:varchar(36);not null;index" json:"parent_user_id"` + ChildUserID string `gorm:"type:varchar(36);not null;index" json:"child_user_id"` + ProductID string `gorm:"type:varchar(36);not null;index" json:"product_id"` + CallCount int64 `gorm:"type:bigint;not null" json:"call_count"` + UnitPrice decimal.Decimal `gorm:"type:decimal(20,8);not null" json:"unit_price"` + TotalAmount decimal.Decimal `gorm:"type:decimal(20,8);not null" json:"total_amount"` + BusinessRef string `gorm:"type:varchar(64);not null;uniqueIndex" json:"business_ref"` + OperatorUserID string `gorm:"type:varchar(36);not null" json:"operator_user_id"` + + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +func (SubordinateQuotaPurchase) TableName() string { + return "subordinate_quota_purchases" +} + +func (q *SubordinateQuotaPurchase) BeforeCreate(tx *gorm.DB) error { + if q.ID == "" { + q.ID = uuid.New().String() + } + return nil +} + +// UserProductQuotaAccount 用户产品额度账户(通用模型,适配所有用户) +type UserProductQuotaAccount struct { + ID string `gorm:"primaryKey;type:varchar(36)" json:"id"` + UserID string `gorm:"type:varchar(36);not null;index:idx_user_product,unique" json:"user_id"` + ProductID string `gorm:"type:varchar(36);not null;index:idx_user_product,unique" json:"product_id"` + TotalQuota int64 `gorm:"type:bigint;not null;default:0" json:"total_quota"` + UsedQuota int64 `gorm:"type:bigint;not null;default:0" json:"used_quota"` + AvailableQuota int64 `gorm:"type:bigint;not null;default:0" json:"available_quota"` + + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +func (UserProductQuotaAccount) TableName() string { + return "user_product_quota_accounts" +} + +func (a *UserProductQuotaAccount) BeforeCreate(tx *gorm.DB) error { + if a.ID == "" { + a.ID = uuid.New().String() + } + return nil +} + +// UserProductQuotaLedger 用户产品额度流水(通用模型,适配所有用户) +type UserProductQuotaLedger struct { + ID string `gorm:"primaryKey;type:varchar(36)" json:"id"` + UserID string `gorm:"type:varchar(36);not null;index" json:"user_id"` + ProductID string `gorm:"type:varchar(36);not null;index" json:"product_id"` + ChangeType string `gorm:"type:varchar(50);not null;index" json:"change_type"` + DeltaQuota int64 `gorm:"type:bigint;not null" json:"delta_quota"` + BeforeQuota int64 `gorm:"type:bigint;not null" json:"before_quota"` + AfterQuota int64 `gorm:"type:bigint;not null" json:"after_quota"` + SourceID string `gorm:"type:varchar(36);index" json:"source_id"` + OperatorID string `gorm:"type:varchar(36);not null" json:"operator_id"` + Remark string `gorm:"type:varchar(255)" json:"remark"` + + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +func (UserProductQuotaLedger) TableName() string { + return "user_product_quota_ledgers" +} + +func (l *UserProductQuotaLedger) 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..8af8bfc --- /dev/null +++ b/internal/domains/subordinate/repositories/subordinate_repository_interface.go @@ -0,0 +1,42 @@ +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) + + // 额度购买 + CreateQuotaPurchase(ctx context.Context, p *entities.SubordinateQuotaPurchase) error + ListQuotaPurchasesByParentAndChild(ctx context.Context, parentUserID, childUserID string, limit, offset int) ([]*entities.SubordinateQuotaPurchase, int64, error) + + // 额度账户 + FindQuotaAccount(ctx context.Context, userID, productID string) (*entities.UserProductQuotaAccount, error) + CreateQuotaAccount(ctx context.Context, account *entities.UserProductQuotaAccount) error + UpdateQuotaAccount(ctx context.Context, account *entities.UserProductQuotaAccount) error + ListQuotaAccountsByUser(ctx context.Context, userID string) ([]*entities.UserProductQuotaAccount, error) + CreateQuotaLedger(ctx context.Context, ledger *entities.UserProductQuotaLedger) 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..07accf9 --- /dev/null +++ b/internal/infrastructure/database/repositories/subordinate/gorm_subordinate_repository.go @@ -0,0 +1,265 @@ +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 +} + +// CreateQuotaPurchase 创建额度购买记录 +func (r *GormSubordinateRepository) CreateQuotaPurchase(ctx context.Context, p *entities.SubordinateQuotaPurchase) error { + return r.withCtx(ctx).Create(p).Error +} + +// ListQuotaPurchasesByParentAndChild 查询主对子额度购买记录 +func (r *GormSubordinateRepository) ListQuotaPurchasesByParentAndChild(ctx context.Context, parentUserID, childUserID string, limit, offset int) ([]*entities.SubordinateQuotaPurchase, int64, error) { + var list []entities.SubordinateQuotaPurchase + var total int64 + q := r.withCtx(ctx).Model(&entities.SubordinateQuotaPurchase{}).Where("parent_user_id = ? AND child_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.SubordinateQuotaPurchase, len(list)) + for i := range list { + out[i] = &list[i] + } + return out, total, nil +} + +// FindQuotaAccount 查询用户产品额度账户 +func (r *GormSubordinateRepository) FindQuotaAccount(ctx context.Context, userID, productID string) (*entities.UserProductQuotaAccount, error) { + var account entities.UserProductQuotaAccount + err := r.withCtx(ctx).Where("user_id = ? AND product_id = ?", userID, productID).First(&account).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &account, nil +} + +// CreateQuotaAccount 创建额度账户 +func (r *GormSubordinateRepository) CreateQuotaAccount(ctx context.Context, account *entities.UserProductQuotaAccount) error { + return r.withCtx(ctx).Create(account).Error +} + +// UpdateQuotaAccount 更新额度账户 +func (r *GormSubordinateRepository) UpdateQuotaAccount(ctx context.Context, account *entities.UserProductQuotaAccount) error { + return r.withCtx(ctx).Save(account).Error +} + +// ListQuotaAccountsByUser 查询用户全部额度账户 +func (r *GormSubordinateRepository) ListQuotaAccountsByUser(ctx context.Context, userID string) ([]*entities.UserProductQuotaAccount, error) { + var list []entities.UserProductQuotaAccount + if err := r.withCtx(ctx).Where("user_id = ?", userID).Order("updated_at DESC").Find(&list).Error; err != nil { + return nil, err + } + out := make([]*entities.UserProductQuotaAccount, len(list)) + for i := range list { + out[i] = &list[i] + } + return out, nil +} + +// CreateQuotaLedger 创建额度流水 +func (r *GormSubordinateRepository) CreateQuotaLedger(ctx context.Context, ledger *entities.UserProductQuotaLedger) error { + return r.withCtx(ctx).Create(ledger).Error +} 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..db24da4 --- /dev/null +++ b/internal/infrastructure/http/handlers/subordinate_handler.go @@ -0,0 +1,299 @@ +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, "删除成功") +} + +// PurchaseQuota 为下属购买额度 +func (h *SubordinateHandler) PurchaseQuota(c *gin.Context) { + parentID := c.GetString("user_id") + if parentID == "" { + h.response.Unauthorized(c, "未登录") + return + } + var cmd commands.PurchaseChildQuotaCommand + if err := h.validator.BindAndValidate(c, &cmd); err != nil { + return + } + cmd.ParentUserID = parentID + if err := h.app.PurchaseChildQuota(c.Request.Context(), &cmd); err != nil { + h.logger.Error("为下属购买额度失败", zap.Error(err)) + h.response.BadRequest(c, err.Error()) + return + } + h.response.Success(c, nil, "购买额度成功") +} + +// ListQuotaPurchases 下属额度购买记录 +func (h *SubordinateHandler) ListQuotaPurchases(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.ListChildQuotaPurchasesCommand{ + 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.ListChildQuotaPurchases(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, "获取成功") +} + +// ListChildQuotaAccounts 下属额度账户 +func (h *SubordinateHandler) ListChildQuotaAccounts(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.ListChildQuotaAccounts(c.Request.Context(), &commands.ListChildQuotaAccountsCommand{ + 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, "获取成功") +} + +// ListMyQuotaAccounts 当前登录用户额度账户 +func (h *SubordinateHandler) ListMyQuotaAccounts(c *gin.Context) { + userID := c.GetString("user_id") + if userID == "" { + h.response.Unauthorized(c, "未登录") + return + } + res, err := h.app.ListMyQuotaAccounts(c.Request.Context(), userID) + if err != nil { + h.logger.Error("获取我的额度账户失败", zap.Error(err)) + h.response.BadRequest(c, err.Error()) + return + } + h.response.Success(c, res, "获取成功") +} 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..0bb7991 --- /dev/null +++ b/internal/infrastructure/http/routes/subordinate_routes.go @@ -0,0 +1,50 @@ +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) + sub.POST("/purchase-quota", r.handler.PurchaseQuota) + sub.GET("/quota-purchases", r.handler.ListQuotaPurchases) + sub.GET("/child-quotas", r.handler.ListChildQuotaAccounts) + sub.GET("/my-quotas", r.handler.ListMyQuotaAccounts) + } + + 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/infrastructure/task/handlers/api_task_handler.go b/internal/infrastructure/task/handlers/api_task_handler.go index ebb22c2..cfe9997 100644 --- a/internal/infrastructure/task/handlers/api_task_handler.go +++ b/internal/infrastructure/task/handlers/api_task_handler.go @@ -6,9 +6,9 @@ import ( "time" "github.com/hibiken/asynq" - "github.com/shopspring/decimal" "go.uber.org/zap" + api_commands "tyapi-server/internal/application/api/commands" "tyapi-server/internal/application/api" finance_services "tyapi-server/internal/domains/finance/services" product_services "tyapi-server/internal/domains/product/services" @@ -84,25 +84,23 @@ func (h *ApiTaskHandler) HandleDeduction(ctx context.Context, t *asynq.Task) err zap.String("amount", payload.Amount), zap.String("transaction_id", payload.TransactionID)) - // 调用钱包服务进行扣款 - if h.walletService != nil { - amount, err := decimal.NewFromString(payload.Amount) - if err != nil { - h.logger.Error("金额格式错误", zap.Error(err)) - h.updateTaskStatus(ctx, t, "failed", "金额格式错误") - return err - } - - if err := h.walletService.Deduct(ctx, payload.UserID, amount, payload.ApiCallID, payload.TransactionID, payload.ProductID); err != nil { - h.logger.Error("扣款处理失败", zap.Error(err)) - h.updateTaskStatus(ctx, t, "failed", "扣款处理失败: "+err.Error()) - return err - } - } else { - h.logger.Warn("钱包服务未初始化,跳过扣款", zap.String("user_id", payload.UserID)) - h.updateTaskStatus(ctx, t, "failed", "钱包服务未初始化") + // 统一走应用服务扣费链路(额度优先,钱包兜底) + if h.apiApplicationService == nil { + h.logger.Warn("API应用服务未初始化,无法处理扣款", zap.String("user_id", payload.UserID)) + h.updateTaskStatus(ctx, t, "failed", "API应用服务未初始化") return nil } + if err := h.apiApplicationService.ProcessDeduction(ctx, &api_commands.ProcessDeductionCommand{ + UserID: payload.UserID, + Amount: payload.Amount, + ApiCallID: payload.ApiCallID, + TransactionID: payload.TransactionID, + ProductID: payload.ProductID, + }); err != nil { + h.logger.Error("扣款处理失败", zap.Error(err)) + h.updateTaskStatus(ctx, t, "failed", "扣款处理失败: "+err.Error()) + return err + } // 更新任务状态为成功 h.updateTaskStatus(ctx, t, "completed", "") 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 }