f
This commit is contained in:
42
config.yaml
42
config.yaml
@@ -5,6 +5,8 @@ app:
|
|||||||
name: "TYAPI Server"
|
name: "TYAPI Server"
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
env: "development"
|
env: "development"
|
||||||
|
# 子账号入口与主站可同域;邀请链接 {sub_portal_base_url}/sub/auth/register?invite=...
|
||||||
|
sub_portal_base_url: "http://localhost:5173/"
|
||||||
|
|
||||||
server:
|
server:
|
||||||
host: "0.0.0.0"
|
host: "0.0.0.0"
|
||||||
@@ -131,13 +133,13 @@ sms:
|
|||||||
expire_time: 5m
|
expire_time: 5m
|
||||||
mock_enabled: false
|
mock_enabled: false
|
||||||
# 签名验证配置(用于防止接口被刷)
|
# 签名验证配置(用于防止接口被刷)
|
||||||
signature_enabled: true # 是否启用签名验证
|
signature_enabled: true # 是否启用签名验证
|
||||||
signature_secret: "TyApi2024SMSSecretKey!@#$%^&*()_+QWERTYUIOP" # 签名密钥(请修改为复杂密钥)
|
signature_secret: "TyApi2024SMSSecretKey!@#$%^&*()_+QWERTYUIOP" # 签名密钥(请修改为复杂密钥)
|
||||||
# 滑块验证码配置
|
# 滑块验证码配置
|
||||||
captcha_enabled: true # 是否启用滑块验证码
|
captcha_enabled: true # 是否启用滑块验证码
|
||||||
captcha_secret: "" # 阿里云验证码密钥(加密模式时需要,可选)EKEY
|
captcha_secret: "" # 阿里云验证码密钥(加密模式时需要,可选)EKEY
|
||||||
captcha_endpoint: "captcha.cn-shanghai.aliyuncs.com" # 阿里云验证码服务Endpoint
|
captcha_endpoint: "captcha.cn-shanghai.aliyuncs.com" # 阿里云验证码服务Endpoint
|
||||||
scene_id: "wynt39to" # 阿里云验证码场景ID
|
scene_id: "wynt39to" # 阿里云验证码场景ID
|
||||||
rate_limit:
|
rate_limit:
|
||||||
daily_limit: 10
|
daily_limit: 10
|
||||||
hourly_limit: 5
|
hourly_limit: 5
|
||||||
@@ -206,7 +208,7 @@ daily_ratelimit:
|
|||||||
enable_referer: true # 是否检查Referer
|
enable_referer: true # 是否检查Referer
|
||||||
allowed_referers: # 允许的Referer
|
allowed_referers: # 允许的Referer
|
||||||
- "https://console.tianyuanapi.com" # 天元API控制台
|
- "https://console.tianyuanapi.com" # 天元API控制台
|
||||||
- "https://consoletest.tianyuanapi.com" # 天元API测试控制台
|
- "https://subsole.tianyuanapi.com" # 天元API子账号控制台
|
||||||
|
|
||||||
enable_proxy_check: false # 是否检查代理
|
enable_proxy_check: false # 是否检查代理
|
||||||
enable_geo_block: false # 是否启用地理位置阻止
|
enable_geo_block: false # 是否启用地理位置阻止
|
||||||
@@ -237,7 +239,7 @@ development:
|
|||||||
debug: true
|
debug: true
|
||||||
enable_profiler: true
|
enable_profiler: true
|
||||||
enable_cors: 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_methods: "GET,POST,PUT,PATCH,DELETE,OPTIONS"
|
||||||
cors_allowed_headers: "Origin,Content-Type,Accept,Authorization,X-Requested-With,Access-Id"
|
cors_allowed_headers: "Origin,Content-Type,Accept,Authorization,X-Requested-With,Access-Id"
|
||||||
|
|
||||||
@@ -549,20 +551,20 @@ jiguang:
|
|||||||
# ===========================================
|
# ===========================================
|
||||||
pdfgen:
|
pdfgen:
|
||||||
# 服务地址配置
|
# 服务地址配置
|
||||||
development_url: "http://pdfg.tianyuanapi.com" # 开发环境服务地址
|
development_url: "http://pdfg.tianyuanapi.com" # 开发环境服务地址
|
||||||
production_url: "http://1.117.67.95:15990" # 生产环境服务地址
|
production_url: "http://1.117.67.95:15990" # 生产环境服务地址
|
||||||
|
|
||||||
# API路径配置
|
# API路径配置
|
||||||
api_path: "/api/v1/generate/guangzhou" # PDF生成API路径
|
api_path: "/api/v1/generate/guangzhou" # PDF生成API路径
|
||||||
|
|
||||||
# 超时配置
|
# 超时配置
|
||||||
timeout: 120s # 请求超时时间(120秒)
|
timeout: 120s # 请求超时时间(120秒)
|
||||||
|
|
||||||
# 缓存配置
|
# 缓存配置
|
||||||
cache:
|
cache:
|
||||||
ttl: 24h # 缓存过期时间(24小时)
|
ttl: 24h # 缓存过期时间(24小时)
|
||||||
cache_dir: "" # 缓存目录(空则使用默认目录)
|
cache_dir: "" # 缓存目录(空则使用默认目录)
|
||||||
max_size: 0 # 最大缓存大小(0表示不限制,单位:字节)
|
max_size: 0 # 最大缓存大小(0表示不限制,单位:字节)
|
||||||
|
|
||||||
# ===========================================
|
# ===========================================
|
||||||
# ✨ 数脉配置走实时接口
|
# ✨ 数脉配置走实时接口
|
||||||
@@ -607,7 +609,6 @@ shumai:
|
|||||||
max_age: 30
|
max_age: 30
|
||||||
compress: true
|
compress: true
|
||||||
|
|
||||||
|
|
||||||
# ===========================================
|
# ===========================================
|
||||||
# ✨ 数据宝配置走实时接口
|
# ✨ 数据宝配置走实时接口
|
||||||
# ===========================================
|
# ===========================================
|
||||||
@@ -640,6 +641,3 @@ shujubao:
|
|||||||
max_backups: 5
|
max_backups: 5
|
||||||
max_age: 30
|
max_age: 30
|
||||||
compress: true
|
compress: true
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
# ===========================================
|
# ===========================================
|
||||||
app:
|
app:
|
||||||
env: development
|
env: development
|
||||||
|
# 子账号专属前端域名(用于邀请链接复制)
|
||||||
|
sub_portal_base_url: "http://localhost:5173"
|
||||||
|
|
||||||
# ===========================================
|
# ===========================================
|
||||||
# 🗄️ 数据库配置
|
# 🗄️ 数据库配置
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
# ===========================================
|
# ===========================================
|
||||||
app:
|
app:
|
||||||
env: production
|
env: production
|
||||||
|
# 子账号专属前端域名(用于邀请链接复制)
|
||||||
|
sub_portal_base_url: "https://subsole.tianyuanapi.com"
|
||||||
|
|
||||||
# ===========================================
|
# ===========================================
|
||||||
# 🌐 服务器配置
|
# 🌐 服务器配置
|
||||||
@@ -18,7 +20,7 @@ server:
|
|||||||
# ===========================================
|
# ===========================================
|
||||||
development:
|
development:
|
||||||
enable_cors: true
|
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_methods: "GET,POST,PUT,PATCH,DELETE,OPTIONS"
|
||||||
cors_allowed_headers: "Origin,Content-Type,Accept,Authorization,X-Requested-With,Access-Id"
|
cors_allowed_headers: "Origin,Content-Type,Accept,Authorization,X-Requested-With,Access-Id"
|
||||||
|
|
||||||
@@ -157,7 +159,7 @@ daily_ratelimit:
|
|||||||
enable_referer: true # 启用Referer检查
|
enable_referer: true # 启用Referer检查
|
||||||
allowed_referers: # 允许的Referer
|
allowed_referers: # 允许的Referer
|
||||||
- "https://console.tianyuanapi.com"
|
- "https://console.tianyuanapi.com"
|
||||||
- "https://consoletest.tianyuanapi.com"
|
- "https://subsole.tianyuanapi.com"
|
||||||
|
|
||||||
enable_geo_block: false # 生产环境暂时不启用地理位置阻止
|
enable_geo_block: false # 生产环境暂时不启用地理位置阻止
|
||||||
enable_proxy_check: true # 启用代理检查
|
enable_proxy_check: true # 启用代理检查
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import (
|
|||||||
statisticsEntities "tyapi-server/internal/domains/statistics/entities"
|
statisticsEntities "tyapi-server/internal/domains/statistics/entities"
|
||||||
|
|
||||||
apiEntities "tyapi-server/internal/domains/api/entities"
|
apiEntities "tyapi-server/internal/domains/api/entities"
|
||||||
|
subordinateEntities "tyapi-server/internal/domains/subordinate/entities"
|
||||||
"tyapi-server/internal/infrastructure/database"
|
"tyapi-server/internal/infrastructure/database"
|
||||||
taskEntities "tyapi-server/internal/infrastructure/task/entities"
|
taskEntities "tyapi-server/internal/infrastructure/task/entities"
|
||||||
)
|
)
|
||||||
@@ -264,6 +265,11 @@ func (a *Application) autoMigrate(db *gorm.DB) error {
|
|||||||
&apiEntities.ApiCall{},
|
&apiEntities.ApiCall{},
|
||||||
&apiEntities.Report{},
|
&apiEntities.Report{},
|
||||||
|
|
||||||
|
// 下属账号域
|
||||||
|
&subordinateEntities.SubordinateInvitation{},
|
||||||
|
&subordinateEntities.UserSubordinateLink{},
|
||||||
|
&subordinateEntities.SubordinateWalletAllocation{},
|
||||||
|
|
||||||
// 任务域
|
// 任务域
|
||||||
&taskEntities.AsyncTask{},
|
&taskEntities.AsyncTask{},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -261,18 +261,18 @@ func (s *ApiApplicationServiceImpl) validateApiCall(ctx context.Context, cmd *co
|
|||||||
zap.Strings("whiteListIPs", whiteListIPs))
|
zap.Strings("whiteListIPs", whiteListIPs))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 验证钱包状态
|
// 5. 先验证订阅(与扣费金额一致,便于余额预检使用订阅价)
|
||||||
if err := s.validateWalletStatus(ctx, apiUser.UserId, product); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. 验证订阅状态并获取订阅信息
|
|
||||||
subscription, err := s.validateSubscriptionStatus(ctx, apiUser.UserId, product)
|
subscription, err := s.validateSubscriptionStatus(ctx, apiUser.UserId, product)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
result.SetSubscription(subscription)
|
result.SetSubscription(subscription)
|
||||||
|
|
||||||
|
// 6. 验证钱包状态(有订阅时按订阅价与目录价取较大者预检,避免代配价高于目录价时误判余额不足)
|
||||||
|
if err := s.validateWalletStatus(ctx, apiUser.UserId, product, subscription); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
// 7. 解密参数
|
// 7. 解密参数
|
||||||
requestParams, err := crypto.AesDecrypt(cmd.Data, apiUser.SecretKey)
|
requestParams, err := crypto.AesDecrypt(cmd.Data, apiUser.SecretKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1201,7 +1201,7 @@ func (s *ApiApplicationServiceImpl) ProcessCompensation(ctx context.Context, cmd
|
|||||||
}
|
}
|
||||||
|
|
||||||
// validateWalletStatus 验证钱包状态
|
// validateWalletStatus 验证钱包状态
|
||||||
func (s *ApiApplicationServiceImpl) validateWalletStatus(ctx context.Context, userID string, product *product_entities.Product) error {
|
func (s *ApiApplicationServiceImpl) validateWalletStatus(ctx context.Context, userID string, product *product_entities.Product, subscription *product_entities.Subscription) error {
|
||||||
// 1. 获取用户钱包信息
|
// 1. 获取用户钱包信息
|
||||||
wallet, err := s.walletService.LoadWalletByUserId(ctx, userID)
|
wallet, err := s.walletService.LoadWalletByUserId(ctx, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1219,8 +1219,13 @@ func (s *ApiApplicationServiceImpl) validateWalletStatus(ctx context.Context, us
|
|||||||
return ErrFrozenAccount
|
return ErrFrozenAccount
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 检查钱包余额是否充足
|
// 3. 检查钱包余额是否充足(有订阅时与扣费金额对齐:取目录价与订阅价较大者)
|
||||||
requiredAmount := product.Price
|
requiredAmount := product.Price
|
||||||
|
if subscription != nil {
|
||||||
|
if subscription.Price.GreaterThan(requiredAmount) {
|
||||||
|
requiredAmount = subscription.Price
|
||||||
|
}
|
||||||
|
}
|
||||||
if wallet.Balance.LessThan(requiredAmount) {
|
if wallet.Balance.LessThan(requiredAmount) {
|
||||||
s.logger.Error("钱包余额不足",
|
s.logger.Error("钱包余额不足",
|
||||||
zap.String("user_id", userID),
|
zap.String("user_id", userID),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
"tyapi-server/internal/application/certification/dto/commands"
|
"tyapi-server/internal/application/certification/dto/commands"
|
||||||
"tyapi-server/internal/application/certification/dto/queries"
|
"tyapi-server/internal/application/certification/dto/queries"
|
||||||
"tyapi-server/internal/application/certification/dto/responses"
|
"tyapi-server/internal/application/certification/dto/responses"
|
||||||
@@ -18,7 +19,10 @@ import (
|
|||||||
certification_value_objects "tyapi-server/internal/domains/certification/entities/value_objects"
|
certification_value_objects "tyapi-server/internal/domains/certification/entities/value_objects"
|
||||||
"tyapi-server/internal/domains/certification/enums"
|
"tyapi-server/internal/domains/certification/enums"
|
||||||
"tyapi-server/internal/domains/certification/repositories"
|
"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"
|
"tyapi-server/internal/domains/certification/services"
|
||||||
|
subordinate_repositories "tyapi-server/internal/domains/subordinate/repositories"
|
||||||
finance_service "tyapi-server/internal/domains/finance/services"
|
finance_service "tyapi-server/internal/domains/finance/services"
|
||||||
user_entities "tyapi-server/internal/domains/user/entities"
|
user_entities "tyapi-server/internal/domains/user/entities"
|
||||||
user_service "tyapi-server/internal/domains/user/services"
|
user_service "tyapi-server/internal/domains/user/services"
|
||||||
@@ -49,6 +53,8 @@ type CertificationApplicationServiceImpl struct {
|
|||||||
// 仓储依赖
|
// 仓储依赖
|
||||||
queryRepository repositories.CertificationQueryRepository
|
queryRepository repositories.CertificationQueryRepository
|
||||||
enterpriseInfoSubmitRecordRepo repositories.EnterpriseInfoSubmitRecordRepository
|
enterpriseInfoSubmitRecordRepo repositories.EnterpriseInfoSubmitRecordRepository
|
||||||
|
subordinateRepo subordinate_repositories.SubordinateRepository
|
||||||
|
walletRepo finance_repositories.WalletRepository
|
||||||
txManager *database.TransactionManager
|
txManager *database.TransactionManager
|
||||||
|
|
||||||
wechatWorkService *notification.WeChatWorkService
|
wechatWorkService *notification.WeChatWorkService
|
||||||
@@ -71,6 +77,8 @@ func NewCertificationApplicationService(
|
|||||||
apiUserAggregateService api_service.ApiUserAggregateService,
|
apiUserAggregateService api_service.ApiUserAggregateService,
|
||||||
enterpriseInfoSubmitRecordService *services.EnterpriseInfoSubmitRecordService,
|
enterpriseInfoSubmitRecordService *services.EnterpriseInfoSubmitRecordService,
|
||||||
ocrService sharedOCR.OCRService,
|
ocrService sharedOCR.OCRService,
|
||||||
|
subordinateRepo subordinate_repositories.SubordinateRepository,
|
||||||
|
walletRepo finance_repositories.WalletRepository,
|
||||||
txManager *database.TransactionManager,
|
txManager *database.TransactionManager,
|
||||||
logger *zap.Logger,
|
logger *zap.Logger,
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
@@ -93,6 +101,8 @@ func NewCertificationApplicationService(
|
|||||||
apiUserAggregateService: apiUserAggregateService,
|
apiUserAggregateService: apiUserAggregateService,
|
||||||
enterpriseInfoSubmitRecordService: enterpriseInfoSubmitRecordService,
|
enterpriseInfoSubmitRecordService: enterpriseInfoSubmitRecordService,
|
||||||
ocrService: ocrService,
|
ocrService: ocrService,
|
||||||
|
subordinateRepo: subordinateRepo,
|
||||||
|
walletRepo: walletRepo,
|
||||||
txManager: txManager,
|
txManager: txManager,
|
||||||
wechatWorkService: wechatSvc,
|
wechatWorkService: wechatSvc,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
@@ -1632,8 +1642,24 @@ func (s *CertificationApplicationServiceImpl) AddStatusMetadata(ctx context.Cont
|
|||||||
|
|
||||||
// completeUserActivationWithoutContract 创建钱包、API用户并在用户域标记完成认证(不依赖合同信息)
|
// completeUserActivationWithoutContract 创建钱包、API用户并在用户域标记完成认证(不依赖合同信息)
|
||||||
func (s *CertificationApplicationServiceImpl) completeUserActivationWithoutContract(ctx context.Context, cert *entities.Certification) error {
|
func (s *CertificationApplicationServiceImpl) completeUserActivationWithoutContract(ctx context.Context, cert *entities.Certification) error {
|
||||||
// 创建钱包
|
// 创建钱包:子账号认证通过后不赠送初始余额(初始额度为0)
|
||||||
if _, err := s.walletAggregateService.CreateWallet(ctx, cert.UserID); err != nil {
|
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))
|
s.logger.Error("创建钱包失败", zap.String("user_id", cert.UserID), zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
16
internal/application/product/self_subscribe_policy.go
Normal file
16
internal/application/product/self_subscribe_policy.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ type SubscriptionApplicationServiceImpl struct {
|
|||||||
productSubscriptionService *product_service.ProductSubscriptionService
|
productSubscriptionService *product_service.ProductSubscriptionService
|
||||||
userRepo user_repositories.UserRepository
|
userRepo user_repositories.UserRepository
|
||||||
apiCallRepository domain_api_repo.ApiCallRepository
|
apiCallRepository domain_api_repo.ApiCallRepository
|
||||||
|
selfSubscribePolicy SelfSubscribePolicy
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,12 +32,17 @@ func NewSubscriptionApplicationService(
|
|||||||
productSubscriptionService *product_service.ProductSubscriptionService,
|
productSubscriptionService *product_service.ProductSubscriptionService,
|
||||||
userRepo user_repositories.UserRepository,
|
userRepo user_repositories.UserRepository,
|
||||||
apiCallRepository domain_api_repo.ApiCallRepository,
|
apiCallRepository domain_api_repo.ApiCallRepository,
|
||||||
|
selfSubscribePolicy SelfSubscribePolicy,
|
||||||
logger *zap.Logger,
|
logger *zap.Logger,
|
||||||
) SubscriptionApplicationService {
|
) SubscriptionApplicationService {
|
||||||
|
if selfSubscribePolicy == nil {
|
||||||
|
selfSubscribePolicy = DefaultAllowSelfSubscribe{}
|
||||||
|
}
|
||||||
return &SubscriptionApplicationServiceImpl{
|
return &SubscriptionApplicationServiceImpl{
|
||||||
productSubscriptionService: productSubscriptionService,
|
productSubscriptionService: productSubscriptionService,
|
||||||
userRepo: userRepo,
|
userRepo: userRepo,
|
||||||
apiCallRepository: apiCallRepository,
|
apiCallRepository: apiCallRepository,
|
||||||
|
selfSubscribePolicy: selfSubscribePolicy,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,7 +163,17 @@ func (s *SubscriptionApplicationServiceImpl) BatchUpdateSubscriptionPrices(ctx c
|
|||||||
// CreateSubscription 创建订阅
|
// CreateSubscription 创建订阅
|
||||||
// 业务流程:1. 创建订阅
|
// 业务流程:1. 创建订阅
|
||||||
func (s *SubscriptionApplicationServiceImpl) CreateSubscription(ctx context.Context, cmd *commands.CreateSubscriptionCommand) error {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package commands
|
||||||
|
|
||||||
|
// SubPortalRegisterCommand 子站注册(邀请码必填)
|
||||||
|
type SubPortalRegisterCommand struct {
|
||||||
|
Phone string `json:"phone" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
ConfirmPassword string `json:"confirm_password" binding:"required"`
|
||||||
|
Code string `json:"code" binding:"required"`
|
||||||
|
InviteToken string `json:"invite_token" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateInvitationCommand 主账号创建邀请
|
||||||
|
type CreateInvitationCommand struct {
|
||||||
|
ParentUserID string
|
||||||
|
// ExpiresInHours 可选,0 用默认 168 小时
|
||||||
|
ExpiresInHours int `json:"expires_in_hours"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllocateToChildCommand 主账号向下属划余额
|
||||||
|
type AllocateToChildCommand struct {
|
||||||
|
ParentUserID string
|
||||||
|
ChildUserID string `json:"child_user_id" binding:"required"`
|
||||||
|
Amount string `json:"amount" binding:"required"`
|
||||||
|
VerifyCode string `json:"verify_code" binding:"required,len=6"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssignChildSubscriptionCommand 为下属代配订阅
|
||||||
|
type AssignChildSubscriptionCommand struct {
|
||||||
|
ParentUserID string
|
||||||
|
ChildUserID string `json:"child_user_id" binding:"required"`
|
||||||
|
ProductID string `json:"product_id" binding:"required"`
|
||||||
|
Price string `json:"price" binding:"required"`
|
||||||
|
UIComponentPrice string `json:"ui_component_price"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListChildAllocationsCommand 下属划拨记录查询
|
||||||
|
type ListChildAllocationsCommand struct {
|
||||||
|
ParentUserID string
|
||||||
|
ChildUserID string `json:"child_user_id" form:"child_user_id" binding:"required"`
|
||||||
|
Page int `json:"page" form:"page"`
|
||||||
|
PageSize int `json:"page_size" form:"page_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListChildSubscriptionsCommand 下属订阅列表查询
|
||||||
|
type ListChildSubscriptionsCommand struct {
|
||||||
|
ParentUserID string
|
||||||
|
ChildUserID string `json:"child_user_id" form:"child_user_id" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveChildSubscriptionCommand 删除下属订阅
|
||||||
|
type RemoveChildSubscriptionCommand struct {
|
||||||
|
ParentUserID string
|
||||||
|
ChildUserID string `json:"child_user_id" binding:"required"`
|
||||||
|
SubscriptionID string `json:"subscription_id" binding:"required"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package responses
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// CreateInvitationResponse 创建邀请
|
||||||
|
type CreateInvitationResponse struct {
|
||||||
|
InviteToken string `json:"invite_token" description:"仅返回一次,请转达被邀请人"`
|
||||||
|
InviteURL string `json:"invite_url" description:"子站注册完整链接"`
|
||||||
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
InvitationID string `json:"invitation_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubordinateListItem 下属一条
|
||||||
|
type SubordinateListItem struct {
|
||||||
|
ChildUserID string `json:"child_user_id"`
|
||||||
|
Phone string `json:"phone,omitempty"`
|
||||||
|
LinkID string `json:"link_id"`
|
||||||
|
RegisteredAt time.Time `json:"registered_at"`
|
||||||
|
CompanyName string `json:"company_name"`
|
||||||
|
IsCertified bool `json:"is_certified"`
|
||||||
|
Balance string `json:"balance"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubordinateListResponse 列表
|
||||||
|
type SubordinateListResponse struct {
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Items []SubordinateListItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubPortalRegisterResponse 子站注册
|
||||||
|
type SubPortalRegisterResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChildAllocationItem 下属划拨记录
|
||||||
|
type ChildAllocationItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Amount string `json:"amount"`
|
||||||
|
BusinessRef string `json:"business_ref"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChildAllocationListResponse 下属划拨记录列表
|
||||||
|
type ChildAllocationListResponse struct {
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Items []ChildAllocationItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChildSubscriptionItem 下属订阅项
|
||||||
|
type ChildSubscriptionItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ProductID string `json:"product_id"`
|
||||||
|
Price string `json:"price"`
|
||||||
|
UIComponentPrice string `json:"ui_component_price"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
35
internal/application/subordinate/invite_token.go
Normal file
35
internal/application/subordinate/invite_token.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
26
internal/application/subordinate/invite_token_test.go
Normal file
26
internal/application/subordinate/invite_token_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package subordinate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"tyapi-server/internal/application/subordinate/dto/commands"
|
||||||
|
"tyapi-server/internal/application/subordinate/dto/responses"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SubordinateApplicationService 下属账号:邀请/注册/划款/代配
|
||||||
|
type SubordinateApplicationService interface {
|
||||||
|
RegisterSubPortal(ctx context.Context, cmd *commands.SubPortalRegisterCommand) (*responses.SubPortalRegisterResponse, error)
|
||||||
|
CreateInvitation(ctx context.Context, cmd *commands.CreateInvitationCommand) (*responses.CreateInvitationResponse, error)
|
||||||
|
ListMySubordinates(ctx context.Context, parentUserID string, page, pageSize int) (*responses.SubordinateListResponse, error)
|
||||||
|
AllocateToChild(ctx context.Context, cmd *commands.AllocateToChildCommand) error
|
||||||
|
ListChildAllocations(ctx context.Context, cmd *commands.ListChildAllocationsCommand) (*responses.ChildAllocationListResponse, error)
|
||||||
|
AssignChildSubscription(ctx context.Context, cmd *commands.AssignChildSubscriptionCommand) error
|
||||||
|
ListChildSubscriptions(ctx context.Context, cmd *commands.ListChildSubscriptionsCommand) ([]responses.ChildSubscriptionItem, error)
|
||||||
|
RemoveChildSubscription(ctx context.Context, cmd *commands.RemoveChildSubscriptionCommand) error
|
||||||
|
}
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
package subordinate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"tyapi-server/internal/application/subordinate/dto/commands"
|
||||||
|
"tyapi-server/internal/application/subordinate/dto/responses"
|
||||||
|
"tyapi-server/internal/config"
|
||||||
|
"tyapi-server/internal/domains/finance/repositories"
|
||||||
|
productentities "tyapi-server/internal/domains/product/entities"
|
||||||
|
product_service "tyapi-server/internal/domains/product/services"
|
||||||
|
subentities "tyapi-server/internal/domains/subordinate/entities"
|
||||||
|
subrepositories "tyapi-server/internal/domains/subordinate/repositories"
|
||||||
|
user_entities "tyapi-server/internal/domains/user/entities"
|
||||||
|
user_repositories "tyapi-server/internal/domains/user/repositories"
|
||||||
|
domain_user_services "tyapi-server/internal/domains/user/services"
|
||||||
|
"tyapi-server/internal/shared/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SubordinateApplicationServiceImpl 实现
|
||||||
|
type SubordinateApplicationServiceImpl struct {
|
||||||
|
subRepo subrepositories.SubordinateRepository
|
||||||
|
userAgg domain_user_services.UserAggregateService
|
||||||
|
smsService *domain_user_services.SMSCodeService
|
||||||
|
productSub *product_service.ProductSubscriptionService
|
||||||
|
cfg *config.Config
|
||||||
|
txm *database.TransactionManager
|
||||||
|
walletRepo repositories.WalletRepository
|
||||||
|
userRepo user_repositories.UserRepository
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSubordinateApplicationService 构造
|
||||||
|
func NewSubordinateApplicationService(
|
||||||
|
subRepo subrepositories.SubordinateRepository,
|
||||||
|
userAgg domain_user_services.UserAggregateService,
|
||||||
|
smsService *domain_user_services.SMSCodeService,
|
||||||
|
productSub *product_service.ProductSubscriptionService,
|
||||||
|
cfg *config.Config,
|
||||||
|
txm *database.TransactionManager,
|
||||||
|
walletRepo repositories.WalletRepository,
|
||||||
|
userRepo user_repositories.UserRepository,
|
||||||
|
logger *zap.Logger,
|
||||||
|
) SubordinateApplicationService {
|
||||||
|
return &SubordinateApplicationServiceImpl{
|
||||||
|
subRepo: subRepo,
|
||||||
|
userAgg: userAgg,
|
||||||
|
smsService: smsService,
|
||||||
|
productSub: productSub,
|
||||||
|
cfg: cfg,
|
||||||
|
txm: txm,
|
||||||
|
walletRepo: walletRepo,
|
||||||
|
userRepo: userRepo,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterSubPortal 子站注册
|
||||||
|
func (s *SubordinateApplicationServiceImpl) RegisterSubPortal(ctx context.Context, cmd *commands.SubPortalRegisterCommand) (*responses.SubPortalRegisterResponse, error) {
|
||||||
|
if cmd.Password != cmd.ConfirmPassword {
|
||||||
|
return nil, fmt.Errorf("两次输入的密码不一致")
|
||||||
|
}
|
||||||
|
if err := s.smsService.VerifyCode(ctx, cmd.Phone, cmd.Code, user_entities.SMSSceneRegister); err != nil {
|
||||||
|
return nil, fmt.Errorf("验证码错误或已过期")
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp *responses.SubPortalRegisterResponse
|
||||||
|
err := s.txm.ExecuteInTx(ctx, func(txCtx context.Context) error {
|
||||||
|
inv, err := s.subRepo.FindInvitationByTokenHash(txCtx, HashInviteToken(strings.TrimSpace(cmd.InviteToken)))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if inv == nil {
|
||||||
|
return fmt.Errorf("邀请码无效")
|
||||||
|
}
|
||||||
|
if inv.Status != subentities.InvitationStatusPending {
|
||||||
|
return fmt.Errorf("邀请码已使用或已失效")
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if now.After(inv.ExpiresAt) {
|
||||||
|
return fmt.Errorf("邀请码已过期")
|
||||||
|
}
|
||||||
|
|
||||||
|
u, createErr := s.userAgg.CreateUser(txCtx, cmd.Phone, cmd.Password)
|
||||||
|
if createErr != nil {
|
||||||
|
return createErr
|
||||||
|
}
|
||||||
|
|
||||||
|
link := &subentities.UserSubordinateLink{
|
||||||
|
ParentUserID: inv.ParentUserID,
|
||||||
|
ChildUserID: u.ID,
|
||||||
|
InvitationID: &inv.ID,
|
||||||
|
Status: subentities.LinkStatusActive,
|
||||||
|
}
|
||||||
|
if linkErr := s.subRepo.CreateLink(txCtx, link); linkErr != nil {
|
||||||
|
s.logger.Error("创建主从关系失败", zap.Error(linkErr), zap.String("user_id", u.ID))
|
||||||
|
return fmt.Errorf("注册失败,请重试或联系主账号")
|
||||||
|
}
|
||||||
|
|
||||||
|
consumed, consumeErr := s.subRepo.ConsumeInvitation(txCtx, inv.ID, u.ID, now)
|
||||||
|
if consumeErr != nil {
|
||||||
|
s.logger.Error("核销邀请失败", zap.Error(consumeErr), zap.String("user_id", u.ID))
|
||||||
|
return fmt.Errorf("注册失败,请重试或联系主账号")
|
||||||
|
}
|
||||||
|
if !consumed {
|
||||||
|
return fmt.Errorf("邀请码已使用或已失效")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = &responses.SubPortalRegisterResponse{ID: u.ID, Phone: u.Phone}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateInvitation 主账号发邀请
|
||||||
|
func (s *SubordinateApplicationServiceImpl) CreateInvitation(ctx context.Context, cmd *commands.CreateInvitationCommand) (*responses.CreateInvitationResponse, error) {
|
||||||
|
hours := cmd.ExpiresInHours
|
||||||
|
if hours <= 0 {
|
||||||
|
hours = 24 * 7
|
||||||
|
}
|
||||||
|
raw, hash, err := GenerateInviteToken()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("生成邀请失败")
|
||||||
|
}
|
||||||
|
inv := &subentities.SubordinateInvitation{
|
||||||
|
ParentUserID: cmd.ParentUserID,
|
||||||
|
TokenHash: hash,
|
||||||
|
ExpiresAt: time.Now().Add(time.Duration(hours) * time.Hour),
|
||||||
|
Status: subentities.InvitationStatusPending,
|
||||||
|
}
|
||||||
|
if err := s.subRepo.CreateInvitation(ctx, inv); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
base := strings.TrimSpace(os.Getenv("SUB_PORTAL_BASE_URL"))
|
||||||
|
if base == "" {
|
||||||
|
base = s.cfg.App.SubPortalBaseURL
|
||||||
|
}
|
||||||
|
base = strings.TrimRight(base, "/")
|
||||||
|
if base == "" {
|
||||||
|
return nil, fmt.Errorf("子账号域名未配置,请设置 app.sub_portal_base_url 或环境变量 SUB_PORTAL_BASE_URL")
|
||||||
|
}
|
||||||
|
// 与前端同仓路由一致:/sub/auth/register 为子账号专用注册页
|
||||||
|
inviteURL := base + "/sub/auth/register?invite=" + raw
|
||||||
|
return &responses.CreateInvitationResponse{
|
||||||
|
InviteToken: raw,
|
||||||
|
InviteURL: inviteURL,
|
||||||
|
ExpiresAt: inv.ExpiresAt,
|
||||||
|
InvitationID: inv.ID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMySubordinates 主账号的下属
|
||||||
|
func (s *SubordinateApplicationServiceImpl) ListMySubordinates(ctx context.Context, parentUserID string, page, pageSize int) (*responses.SubordinateListResponse, error) {
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 || pageSize > 100 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
links, total, err := s.subRepo.ListChildrenByParent(ctx, parentUserID, pageSize, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := make([]responses.SubordinateListItem, 0, len(links))
|
||||||
|
for _, ln := range links {
|
||||||
|
phone := ""
|
||||||
|
companyName := "未认证"
|
||||||
|
isCertified := false
|
||||||
|
registeredAt := ln.CreatedAt
|
||||||
|
balance := "0.00"
|
||||||
|
|
||||||
|
if u, e := s.userRepo.GetByIDWithEnterpriseInfo(ctx, ln.ChildUserID); e == nil {
|
||||||
|
phone = u.Phone
|
||||||
|
isCertified = u.IsCertified
|
||||||
|
registeredAt = u.CreatedAt
|
||||||
|
if u.EnterpriseInfo != nil && strings.TrimSpace(u.EnterpriseInfo.CompanyName) != "" {
|
||||||
|
companyName = strings.TrimSpace(u.EnterpriseInfo.CompanyName)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
s.logger.Warn("获取下属用户失败", zap.String("child_id", ln.ChildUserID), zap.Error(e))
|
||||||
|
}
|
||||||
|
if w, e := s.walletRepo.GetByUserID(ctx, ln.ChildUserID); e == nil && w != nil {
|
||||||
|
balance = w.Balance.StringFixed(2)
|
||||||
|
}
|
||||||
|
items = append(items, responses.SubordinateListItem{
|
||||||
|
ChildUserID: ln.ChildUserID,
|
||||||
|
Phone: phone,
|
||||||
|
LinkID: ln.ID,
|
||||||
|
RegisteredAt: registeredAt,
|
||||||
|
CompanyName: companyName,
|
||||||
|
IsCertified: isCertified,
|
||||||
|
Balance: balance,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return &responses.SubordinateListResponse{Total: total, Items: items}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllocateToChild 划款
|
||||||
|
func (s *SubordinateApplicationServiceImpl) AllocateToChild(ctx context.Context, cmd *commands.AllocateToChildCommand) error {
|
||||||
|
amount, err := decimal.NewFromString(strings.TrimSpace(cmd.Amount))
|
||||||
|
if err != nil || !amount.GreaterThan(decimal.Zero) {
|
||||||
|
return fmt.Errorf("金额必须大于0")
|
||||||
|
}
|
||||||
|
parentUser, err := s.userRepo.GetByID(ctx, cmd.ParentUserID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("主账号信息获取失败")
|
||||||
|
}
|
||||||
|
if err := s.smsService.VerifyCode(ctx, parentUser.Phone, strings.TrimSpace(cmd.VerifyCode), user_entities.SMSSceneLogin); err != nil {
|
||||||
|
return fmt.Errorf("验证码错误或已过期")
|
||||||
|
}
|
||||||
|
lnk, err := s.subRepo.FindLinkByParentAndChild(ctx, cmd.ParentUserID, cmd.ChildUserID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if lnk == nil || lnk.Status != subentities.LinkStatusActive {
|
||||||
|
return fmt.Errorf("该用户不是您的有效下属")
|
||||||
|
}
|
||||||
|
bizRef := uuid.New().String()
|
||||||
|
return s.txm.ExecuteInTx(ctx, func(txCtx context.Context) error {
|
||||||
|
ok, err := s.walletRepo.UpdateBalanceByUserID(txCtx, cmd.ParentUserID, amount, "subtract")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("主账号扣款失败,请重试")
|
||||||
|
}
|
||||||
|
ok2, err := s.walletRepo.UpdateBalanceByUserID(txCtx, cmd.ChildUserID, amount, "add")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok2 {
|
||||||
|
return fmt.Errorf("向下属入账失败,请重试")
|
||||||
|
}
|
||||||
|
alloc := &subentities.SubordinateWalletAllocation{
|
||||||
|
FromUserID: cmd.ParentUserID,
|
||||||
|
ToUserID: cmd.ChildUserID,
|
||||||
|
Amount: amount,
|
||||||
|
BusinessRef: bizRef,
|
||||||
|
OperatorUserID: cmd.ParentUserID,
|
||||||
|
}
|
||||||
|
return s.subRepo.CreateWalletAllocation(txCtx, alloc)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListChildAllocations 下属划拨记录
|
||||||
|
func (s *SubordinateApplicationServiceImpl) ListChildAllocations(ctx context.Context, cmd *commands.ListChildAllocationsCommand) (*responses.ChildAllocationListResponse, error) {
|
||||||
|
lnk, err := s.subRepo.FindLinkByParentAndChild(ctx, cmd.ParentUserID, cmd.ChildUserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if lnk == nil || lnk.Status != subentities.LinkStatusActive {
|
||||||
|
return nil, fmt.Errorf("该用户不是您的有效下属")
|
||||||
|
}
|
||||||
|
page := cmd.Page
|
||||||
|
pageSize := cmd.PageSize
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 || pageSize > 100 {
|
||||||
|
pageSize = 20
|
||||||
|
}
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
rows, total, err := s.subRepo.ListWalletAllocationsByParentAndChild(ctx, cmd.ParentUserID, cmd.ChildUserID, pageSize, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := make([]responses.ChildAllocationItem, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
items = append(items, responses.ChildAllocationItem{
|
||||||
|
ID: row.ID,
|
||||||
|
Amount: row.Amount.StringFixed(2),
|
||||||
|
BusinessRef: row.BusinessRef,
|
||||||
|
CreatedAt: row.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return &responses.ChildAllocationListResponse{
|
||||||
|
Total: total,
|
||||||
|
Items: items,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssignChildSubscription 代配订阅
|
||||||
|
func (s *SubordinateApplicationServiceImpl) AssignChildSubscription(ctx context.Context, cmd *commands.AssignChildSubscriptionCommand) error {
|
||||||
|
lnk, err := s.subRepo.FindLinkByParentAndChild(ctx, cmd.ParentUserID, cmd.ChildUserID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if lnk == nil || lnk.Status != subentities.LinkStatusActive {
|
||||||
|
return fmt.Errorf("该用户不是您的有效下属")
|
||||||
|
}
|
||||||
|
|
||||||
|
price, err := decimal.NewFromString(strings.TrimSpace(cmd.Price))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("价格格式无效")
|
||||||
|
}
|
||||||
|
|
||||||
|
parentSub, err := s.productSub.GetUserSubscribedProduct(ctx, cmd.ParentUserID, cmd.ProductID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if parentSub == nil {
|
||||||
|
return fmt.Errorf("主账号未订阅该产品,无法为下属代配")
|
||||||
|
}
|
||||||
|
if price.LessThan(parentSub.Price) {
|
||||||
|
return fmt.Errorf("下属订阅价不能低于主账号对该产品的订阅价")
|
||||||
|
}
|
||||||
|
|
||||||
|
uip := parentSub.UIComponentPrice
|
||||||
|
if strings.TrimSpace(cmd.UIComponentPrice) != "" {
|
||||||
|
p, err2 := decimal.NewFromString(strings.TrimSpace(cmd.UIComponentPrice))
|
||||||
|
if err2 != nil {
|
||||||
|
return fmt.Errorf("UI组件价格格式无效")
|
||||||
|
}
|
||||||
|
if p.LessThan(parentSub.UIComponentPrice) {
|
||||||
|
return fmt.Errorf("下属 UI 组合价不能低于主账号的 UI 组合价")
|
||||||
|
}
|
||||||
|
uip = p
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := s.productSub.GetUserSubscribedProduct(ctx, cmd.ChildUserID, cmd.ProductID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if existing == nil {
|
||||||
|
newSub := &productentities.Subscription{
|
||||||
|
UserID: cmd.ChildUserID,
|
||||||
|
ProductID: cmd.ProductID,
|
||||||
|
Price: price,
|
||||||
|
UIComponentPrice: uip,
|
||||||
|
}
|
||||||
|
return s.productSub.SaveSubscription(ctx, newSub)
|
||||||
|
}
|
||||||
|
existing.Price = price
|
||||||
|
existing.UIComponentPrice = uip
|
||||||
|
return s.productSub.SaveSubscription(ctx, existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListChildSubscriptions 下属订阅列表
|
||||||
|
func (s *SubordinateApplicationServiceImpl) ListChildSubscriptions(ctx context.Context, cmd *commands.ListChildSubscriptionsCommand) ([]responses.ChildSubscriptionItem, error) {
|
||||||
|
lnk, err := s.subRepo.FindLinkByParentAndChild(ctx, cmd.ParentUserID, cmd.ChildUserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if lnk == nil || lnk.Status != subentities.LinkStatusActive {
|
||||||
|
return nil, fmt.Errorf("该用户不是您的有效下属")
|
||||||
|
}
|
||||||
|
subs, err := s.productSub.GetUserSubscriptions(ctx, cmd.ChildUserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := make([]responses.ChildSubscriptionItem, 0, len(subs))
|
||||||
|
for _, sub := range subs {
|
||||||
|
items = append(items, responses.ChildSubscriptionItem{
|
||||||
|
ID: sub.ID,
|
||||||
|
ProductID: sub.ProductID,
|
||||||
|
Price: sub.Price.StringFixed(2),
|
||||||
|
UIComponentPrice: sub.UIComponentPrice.StringFixed(2),
|
||||||
|
CreatedAt: sub.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveChildSubscription 删除下属订阅
|
||||||
|
func (s *SubordinateApplicationServiceImpl) RemoveChildSubscription(ctx context.Context, cmd *commands.RemoveChildSubscriptionCommand) error {
|
||||||
|
lnk, err := s.subRepo.FindLinkByParentAndChild(ctx, cmd.ParentUserID, cmd.ChildUserID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if lnk == nil || lnk.Status != subentities.LinkStatusActive {
|
||||||
|
return fmt.Errorf("该用户不是您的有效下属")
|
||||||
|
}
|
||||||
|
sub, err := s.productSub.GetSubscriptionByID(ctx, cmd.SubscriptionID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("订阅不存在")
|
||||||
|
}
|
||||||
|
if sub.UserID != cmd.ChildUserID {
|
||||||
|
return fmt.Errorf("订阅不属于该下属")
|
||||||
|
}
|
||||||
|
return s.productSub.CancelSubscription(ctx, cmd.SubscriptionID)
|
||||||
|
}
|
||||||
@@ -51,6 +51,8 @@ type UserProfileResponse struct {
|
|||||||
IsCertified bool `json:"is_certified" example:"false"`
|
IsCertified bool `json:"is_certified" example:"false"`
|
||||||
CreatedAt time.Time `json:"created_at" example:"2024-01-01T00:00:00Z"`
|
CreatedAt time.Time `json:"created_at" example:"2024-01-01T00:00:00Z"`
|
||||||
UpdatedAt time.Time `json:"updated_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 发送验证码响应
|
// SendCodeResponse 发送验证码响应
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"tyapi-server/internal/domains/user/entities"
|
"tyapi-server/internal/domains/user/entities"
|
||||||
"tyapi-server/internal/domains/user/events"
|
"tyapi-server/internal/domains/user/events"
|
||||||
user_service "tyapi-server/internal/domains/user/services"
|
user_service "tyapi-server/internal/domains/user/services"
|
||||||
|
"tyapi-server/internal/shared/auth"
|
||||||
"tyapi-server/internal/shared/interfaces"
|
"tyapi-server/internal/shared/interfaces"
|
||||||
"tyapi-server/internal/shared/middleware"
|
"tyapi-server/internal/shared/middleware"
|
||||||
)
|
)
|
||||||
@@ -27,6 +28,7 @@ type UserApplicationServiceImpl struct {
|
|||||||
contractService user_service.ContractAggregateService
|
contractService user_service.ContractAggregateService
|
||||||
eventBus interfaces.EventBus
|
eventBus interfaces.EventBus
|
||||||
jwtAuth *middleware.JWTAuthMiddleware
|
jwtAuth *middleware.JWTAuthMiddleware
|
||||||
|
accountKindProvider interfaces.AccountKindProvider
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +41,7 @@ func NewUserApplicationService(
|
|||||||
contractService user_service.ContractAggregateService,
|
contractService user_service.ContractAggregateService,
|
||||||
eventBus interfaces.EventBus,
|
eventBus interfaces.EventBus,
|
||||||
jwtAuth *middleware.JWTAuthMiddleware,
|
jwtAuth *middleware.JWTAuthMiddleware,
|
||||||
|
accountKindProvider interfaces.AccountKindProvider,
|
||||||
logger *zap.Logger,
|
logger *zap.Logger,
|
||||||
) UserApplicationService {
|
) UserApplicationService {
|
||||||
return &UserApplicationServiceImpl{
|
return &UserApplicationServiceImpl{
|
||||||
@@ -49,6 +52,7 @@ func NewUserApplicationService(
|
|||||||
contractService: contractService,
|
contractService: contractService,
|
||||||
eventBus: eventBus,
|
eventBus: eventBus,
|
||||||
jwtAuth: jwtAuth,
|
jwtAuth: jwtAuth,
|
||||||
|
accountKindProvider: accountKindProvider,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,76 +94,16 @@ func (s *UserApplicationServiceImpl) LoginWithPassword(ctx context.Context, cmd
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 生成包含用户类型的token
|
// 2. 账号类型(下属/普通)
|
||||||
accessToken, err := s.jwtAuth.GenerateToken(user.ID, user.Phone, user.Phone, user.UserType)
|
accountKind := auth.AccountKindStandalone
|
||||||
if err != nil {
|
if s.accountKindProvider != nil {
|
||||||
s.logger.Error("生成令牌失败", zap.Error(err))
|
if k, err := s.accountKindProvider.AccountKind(ctx, user.ID); err == nil && k != "" {
|
||||||
return nil, fmt.Errorf("生成访问令牌失败")
|
accountKind = k
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 获取用户权限(仅管理员)
|
// 3. 生成包含用户类型的 token
|
||||||
var permissions []string
|
accessToken, err := s.jwtAuth.GenerateToken(user.ID, user.Phone, user.Phone, user.UserType, accountKind)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("生成令牌失败", zap.Error(err))
|
s.logger.Error("生成令牌失败", zap.Error(err))
|
||||||
return nil, fmt.Errorf("生成访问令牌失败")
|
return nil, fmt.Errorf("生成访问令牌失败")
|
||||||
@@ -201,6 +145,83 @@ func (s *UserApplicationServiceImpl) LoginWithSMS(ctx context.Context, cmd *comm
|
|||||||
Permissions: permissions,
|
Permissions: permissions,
|
||||||
CreatedAt: user.CreatedAt,
|
CreatedAt: user.CreatedAt,
|
||||||
UpdatedAt: user.UpdatedAt,
|
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{
|
return &responses.LoginUserResponse{
|
||||||
@@ -262,6 +283,12 @@ func (s *UserApplicationServiceImpl) GetUserProfile(ctx context.Context, userID
|
|||||||
Permissions: permissions,
|
Permissions: permissions,
|
||||||
CreatedAt: user.CreatedAt,
|
CreatedAt: user.CreatedAt,
|
||||||
UpdatedAt: user.UpdatedAt,
|
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. 添加企业信息
|
// 4. 添加企业信息
|
||||||
|
|||||||
@@ -197,6 +197,8 @@ type AppConfig struct {
|
|||||||
Name string `mapstructure:"name"`
|
Name string `mapstructure:"name"`
|
||||||
Version string `mapstructure:"version"`
|
Version string `mapstructure:"version"`
|
||||||
Env string `mapstructure:"env"`
|
Env string `mapstructure:"env"`
|
||||||
|
// SubPortalBaseURL 子账号使用的前端基址(可与主站同域),用于邀请链接,无尾斜杠
|
||||||
|
SubPortalBaseURL string `mapstructure:"sub_portal_base_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// APIConfig API配置
|
// APIConfig API配置
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"tyapi-server/internal/application/certification"
|
"tyapi-server/internal/application/certification"
|
||||||
"tyapi-server/internal/application/finance"
|
"tyapi-server/internal/application/finance"
|
||||||
"tyapi-server/internal/application/product"
|
"tyapi-server/internal/application/product"
|
||||||
|
subordinate_app "tyapi-server/internal/application/subordinate"
|
||||||
"tyapi-server/internal/application/statistics"
|
"tyapi-server/internal/application/statistics"
|
||||||
"tyapi-server/internal/application/user"
|
"tyapi-server/internal/application/user"
|
||||||
"tyapi-server/internal/config"
|
"tyapi-server/internal/config"
|
||||||
@@ -27,6 +28,7 @@ import (
|
|||||||
finance_service "tyapi-server/internal/domains/finance/services"
|
finance_service "tyapi-server/internal/domains/finance/services"
|
||||||
domain_product_repo "tyapi-server/internal/domains/product/repositories"
|
domain_product_repo "tyapi-server/internal/domains/product/repositories"
|
||||||
product_service "tyapi-server/internal/domains/product/services"
|
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"
|
statistics_service "tyapi-server/internal/domains/statistics/services"
|
||||||
user_service "tyapi-server/internal/domains/user/services"
|
user_service "tyapi-server/internal/domains/user/services"
|
||||||
"tyapi-server/internal/infrastructure/cache"
|
"tyapi-server/internal/infrastructure/cache"
|
||||||
@@ -35,7 +37,9 @@ import (
|
|||||||
certification_repo "tyapi-server/internal/infrastructure/database/repositories/certification"
|
certification_repo "tyapi-server/internal/infrastructure/database/repositories/certification"
|
||||||
finance_repo "tyapi-server/internal/infrastructure/database/repositories/finance"
|
finance_repo "tyapi-server/internal/infrastructure/database/repositories/finance"
|
||||||
product_repo "tyapi-server/internal/infrastructure/database/repositories/product"
|
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"
|
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/alicloud"
|
||||||
"tyapi-server/internal/infrastructure/external/captcha"
|
"tyapi-server/internal/infrastructure/external/captcha"
|
||||||
"tyapi-server/internal/infrastructure/external/email"
|
"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.Provide(
|
||||||
fx.Annotate(
|
fx.Annotate(
|
||||||
@@ -889,6 +901,21 @@ func NewContainer() *Container {
|
|||||||
user.NewUserApplicationService,
|
user.NewUserApplicationService,
|
||||||
fx.As(new(user.UserApplicationService)),
|
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(
|
fx.Annotate(
|
||||||
func(
|
func(
|
||||||
@@ -905,6 +932,8 @@ func NewContainer() *Container {
|
|||||||
apiUserAggregateService api_services.ApiUserAggregateService,
|
apiUserAggregateService api_services.ApiUserAggregateService,
|
||||||
enterpriseInfoSubmitRecordService *certification_service.EnterpriseInfoSubmitRecordService,
|
enterpriseInfoSubmitRecordService *certification_service.EnterpriseInfoSubmitRecordService,
|
||||||
ocrService sharedOCR.OCRService,
|
ocrService sharedOCR.OCRService,
|
||||||
|
subordinateRepo domain_subordinate_repo.SubordinateRepository,
|
||||||
|
walletRepo domain_finance_repo.WalletRepository,
|
||||||
txManager *shared_database.TransactionManager,
|
txManager *shared_database.TransactionManager,
|
||||||
logger *zap.Logger,
|
logger *zap.Logger,
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
@@ -923,6 +952,8 @@ func NewContainer() *Container {
|
|||||||
apiUserAggregateService,
|
apiUserAggregateService,
|
||||||
enterpriseInfoSubmitRecordService,
|
enterpriseInfoSubmitRecordService,
|
||||||
ocrService,
|
ocrService,
|
||||||
|
subordinateRepo,
|
||||||
|
walletRepo,
|
||||||
txManager,
|
txManager,
|
||||||
logger,
|
logger,
|
||||||
cfg,
|
cfg,
|
||||||
@@ -1242,6 +1273,7 @@ func NewContainer() *Container {
|
|||||||
fx.Provide(
|
fx.Provide(
|
||||||
// 用户HTTP处理器
|
// 用户HTTP处理器
|
||||||
handlers.NewUserHandler,
|
handlers.NewUserHandler,
|
||||||
|
handlers.NewSubordinateHandler,
|
||||||
// 认证HTTP处理器
|
// 认证HTTP处理器
|
||||||
handlers.NewCertificationHandler,
|
handlers.NewCertificationHandler,
|
||||||
// 财务HTTP处理器
|
// 财务HTTP处理器
|
||||||
@@ -1325,6 +1357,7 @@ func NewContainer() *Container {
|
|||||||
fx.Provide(
|
fx.Provide(
|
||||||
// 用户路由
|
// 用户路由
|
||||||
routes.NewUserRoutes,
|
routes.NewUserRoutes,
|
||||||
|
routes.NewSubordinateRoutes,
|
||||||
// 验证码路由
|
// 验证码路由
|
||||||
routes.NewCaptchaRoutes,
|
routes.NewCaptchaRoutes,
|
||||||
// 认证路由
|
// 认证路由
|
||||||
@@ -1457,6 +1490,7 @@ func RegisterMiddlewares(
|
|||||||
func RegisterRoutes(
|
func RegisterRoutes(
|
||||||
router *sharedhttp.GinRouter,
|
router *sharedhttp.GinRouter,
|
||||||
userRoutes *routes.UserRoutes,
|
userRoutes *routes.UserRoutes,
|
||||||
|
subordinateRoutes *routes.SubordinateRoutes,
|
||||||
captchaRoutes *routes.CaptchaRoutes,
|
captchaRoutes *routes.CaptchaRoutes,
|
||||||
certificationRoutes *routes.CertificationRoutes,
|
certificationRoutes *routes.CertificationRoutes,
|
||||||
financeRoutes *routes.FinanceRoutes,
|
financeRoutes *routes.FinanceRoutes,
|
||||||
@@ -1484,6 +1518,7 @@ func RegisterRoutes(
|
|||||||
|
|
||||||
// 所有域名路由路由
|
// 所有域名路由路由
|
||||||
userRoutes.Register(router)
|
userRoutes.Register(router)
|
||||||
|
subordinateRoutes.Register(router)
|
||||||
captchaRoutes.Register(router)
|
captchaRoutes.Register(router)
|
||||||
certificationRoutes.Register(router)
|
certificationRoutes.Register(router)
|
||||||
financeRoutes.Register(router)
|
financeRoutes.Register(router)
|
||||||
|
|||||||
46
internal/domains/subordinate/entities/invitation.go
Normal file
46
internal/domains/subordinate/entities/invitation.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
42
internal/domains/subordinate/entities/link.go
Normal file
42
internal/domains/subordinate/entities/link.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
36
internal/domains/subordinate/entities/wallet_allocation.go
Normal file
36
internal/domains/subordinate/entities/wallet_allocation.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
"tyapi-server/internal/domains/subordinate/entities"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SubordinateRepository 下属模块仓储
|
||||||
|
type SubordinateRepository interface {
|
||||||
|
// 邀请
|
||||||
|
CreateInvitation(ctx context.Context, inv *entities.SubordinateInvitation) error
|
||||||
|
FindInvitationByTokenHash(ctx context.Context, tokenHash string) (*entities.SubordinateInvitation, error)
|
||||||
|
FindInvitationByID(ctx context.Context, id string) (*entities.SubordinateInvitation, error)
|
||||||
|
UpdateInvitation(ctx context.Context, inv *entities.SubordinateInvitation) error
|
||||||
|
ConsumeInvitation(ctx context.Context, invitationID, childUserID string, consumedAt time.Time) (bool, error)
|
||||||
|
ListInvitationsByParent(ctx context.Context, parentUserID string, limit, offset int) ([]*entities.SubordinateInvitation, int64, error)
|
||||||
|
|
||||||
|
// 主从
|
||||||
|
CreateLink(ctx context.Context, link *entities.UserSubordinateLink) error
|
||||||
|
FindLinkByChildUserID(ctx context.Context, childUserID string) (*entities.UserSubordinateLink, error)
|
||||||
|
FindLinkByParentAndChild(ctx context.Context, parentUserID, childUserID string) (*entities.UserSubordinateLink, error)
|
||||||
|
ListChildrenByParent(ctx context.Context, parentUserID string, limit, offset int) ([]*entities.UserSubordinateLink, int64, error)
|
||||||
|
UpdateLink(ctx context.Context, link *entities.UserSubordinateLink) error
|
||||||
|
// 是否存在子账号关系(任意子账号)
|
||||||
|
IsUserSubordinate(ctx context.Context, userID string) (bool, error)
|
||||||
|
|
||||||
|
// 划拨
|
||||||
|
CreateWalletAllocation(ctx context.Context, a *entities.SubordinateWalletAllocation) error
|
||||||
|
ListWalletAllocationsByParentAndChild(ctx context.Context, parentUserID, childUserID string, limit, offset int) ([]*entities.SubordinateWalletAllocation, int64, error)
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
package subordinate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"tyapi-server/internal/domains/subordinate/entities"
|
||||||
|
"tyapi-server/internal/domains/subordinate/repositories"
|
||||||
|
shared_database "tyapi-server/internal/shared/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GormSubordinateRepository 下属模块 GORM 实现
|
||||||
|
type GormSubordinateRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ repositories.SubordinateRepository = (*GormSubordinateRepository)(nil)
|
||||||
|
|
||||||
|
// NewGormSubordinateRepository 构造
|
||||||
|
func NewGormSubordinateRepository(db *gorm.DB, logger *zap.Logger) *GormSubordinateRepository {
|
||||||
|
return &GormSubordinateRepository{db: db, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *GormSubordinateRepository) withCtx(ctx context.Context) *gorm.DB {
|
||||||
|
if tx, ok := shared_database.GetTx(ctx); ok {
|
||||||
|
return tx.WithContext(ctx)
|
||||||
|
}
|
||||||
|
return r.db.WithContext(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateInvitation 创建邀请
|
||||||
|
func (r *GormSubordinateRepository) CreateInvitation(ctx context.Context, inv *entities.SubordinateInvitation) error {
|
||||||
|
return r.withCtx(ctx).Create(inv).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindInvitationByTokenHash 按 token 哈希查询
|
||||||
|
func (r *GormSubordinateRepository) FindInvitationByTokenHash(ctx context.Context, tokenHash string) (*entities.SubordinateInvitation, error) {
|
||||||
|
var inv entities.SubordinateInvitation
|
||||||
|
err := r.withCtx(ctx).Where("token_hash = ?", tokenHash).First(&inv).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &inv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindInvitationByID 按ID
|
||||||
|
func (r *GormSubordinateRepository) FindInvitationByID(ctx context.Context, id string) (*entities.SubordinateInvitation, error) {
|
||||||
|
var inv entities.SubordinateInvitation
|
||||||
|
err := r.withCtx(ctx).Where("id = ?", id).First(&inv).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &inv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateInvitation 更新
|
||||||
|
func (r *GormSubordinateRepository) UpdateInvitation(ctx context.Context, inv *entities.SubordinateInvitation) error {
|
||||||
|
return r.withCtx(ctx).Save(inv).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConsumeInvitation 原子核销邀请(仅 pending 可核销)
|
||||||
|
func (r *GormSubordinateRepository) ConsumeInvitation(ctx context.Context, invitationID, childUserID string, consumedAt time.Time) (bool, error) {
|
||||||
|
uid := childUserID
|
||||||
|
res := r.withCtx(ctx).
|
||||||
|
Model(&entities.SubordinateInvitation{}).
|
||||||
|
Where("id = ? AND status = ?", invitationID, entities.InvitationStatusPending).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"status": entities.InvitationStatusConsumed,
|
||||||
|
"consumed_by_user_id": &uid,
|
||||||
|
"consumed_at": &consumedAt,
|
||||||
|
})
|
||||||
|
if res.Error != nil {
|
||||||
|
return false, res.Error
|
||||||
|
}
|
||||||
|
return res.RowsAffected > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListInvitationsByParent 主账号邀请列表
|
||||||
|
func (r *GormSubordinateRepository) ListInvitationsByParent(ctx context.Context, parentUserID string, limit, offset int) ([]*entities.SubordinateInvitation, int64, error) {
|
||||||
|
var list []entities.SubordinateInvitation
|
||||||
|
var total int64
|
||||||
|
q := r.withCtx(ctx).Model(&entities.SubordinateInvitation{}).Where("parent_user_id = ?", parentUserID)
|
||||||
|
if err := q.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if err := q.Order("created_at DESC").Limit(limit).Offset(offset).Find(&list).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
out := make([]*entities.SubordinateInvitation, len(list))
|
||||||
|
for i := range list {
|
||||||
|
out[i] = &list[i]
|
||||||
|
}
|
||||||
|
return out, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateLink 创建主从
|
||||||
|
func (r *GormSubordinateRepository) CreateLink(ctx context.Context, link *entities.UserSubordinateLink) error {
|
||||||
|
return r.withCtx(ctx).Create(link).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindLinkByChildUserID 按子查
|
||||||
|
func (r *GormSubordinateRepository) FindLinkByChildUserID(ctx context.Context, childUserID string) (*entities.UserSubordinateLink, error) {
|
||||||
|
var l entities.UserSubordinateLink
|
||||||
|
err := r.withCtx(ctx).Where("child_user_id = ? AND status = ?", childUserID, entities.LinkStatusActive).First(&l).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &l, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindLinkByParentAndChild 精确查
|
||||||
|
func (r *GormSubordinateRepository) FindLinkByParentAndChild(ctx context.Context, parentUserID, childUserID string) (*entities.UserSubordinateLink, error) {
|
||||||
|
var l entities.UserSubordinateLink
|
||||||
|
err := r.withCtx(ctx).Where("parent_user_id = ? AND child_user_id = ?", parentUserID, childUserID).First(&l).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &l, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListChildrenByParent 列出下属
|
||||||
|
func (r *GormSubordinateRepository) ListChildrenByParent(ctx context.Context, parentUserID string, limit, offset int) ([]*entities.UserSubordinateLink, int64, error) {
|
||||||
|
var list []entities.UserSubordinateLink
|
||||||
|
var total int64
|
||||||
|
q := r.withCtx(ctx).Model(&entities.UserSubordinateLink{}).Where("parent_user_id = ? AND status = ?", parentUserID, entities.LinkStatusActive)
|
||||||
|
if err := q.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if err := q.Order("created_at DESC").Limit(limit).Offset(offset).Find(&list).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
out := make([]*entities.UserSubordinateLink, len(list))
|
||||||
|
for i := range list {
|
||||||
|
out[i] = &list[i]
|
||||||
|
}
|
||||||
|
return out, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateLink 更新
|
||||||
|
func (r *GormSubordinateRepository) UpdateLink(ctx context.Context, link *entities.UserSubordinateLink) error {
|
||||||
|
return r.withCtx(ctx).Save(link).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsUserSubordinate 是否为主账号的下属(存在 active 的 child 记录)
|
||||||
|
func (r *GormSubordinateRepository) IsUserSubordinate(ctx context.Context, userID string) (bool, error) {
|
||||||
|
var n int64
|
||||||
|
err := r.withCtx(ctx).Model(&entities.UserSubordinateLink{}).Where("child_user_id = ? AND status = ?", userID, entities.LinkStatusActive).Count(&n).Error
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return n > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateWalletAllocation 记划拨
|
||||||
|
func (r *GormSubordinateRepository) CreateWalletAllocation(ctx context.Context, a *entities.SubordinateWalletAllocation) error {
|
||||||
|
// 幂等:同 business_ref 不重复
|
||||||
|
var cnt int64
|
||||||
|
if err := r.withCtx(ctx).Model(&entities.SubordinateWalletAllocation{}).Where("business_ref = ?", a.BusinessRef).Count(&cnt).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if cnt > 0 {
|
||||||
|
return fmt.Errorf("划拨记录已存在")
|
||||||
|
}
|
||||||
|
return r.withCtx(ctx).Create(a).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListWalletAllocationsByParentAndChild 查询主对子划拨记录
|
||||||
|
func (r *GormSubordinateRepository) ListWalletAllocationsByParentAndChild(ctx context.Context, parentUserID, childUserID string, limit, offset int) ([]*entities.SubordinateWalletAllocation, int64, error) {
|
||||||
|
var list []entities.SubordinateWalletAllocation
|
||||||
|
var total int64
|
||||||
|
q := r.withCtx(ctx).Model(&entities.SubordinateWalletAllocation{}).Where("from_user_id = ? AND to_user_id = ?", parentUserID, childUserID)
|
||||||
|
if err := q.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if err := q.Order("created_at DESC").Limit(limit).Offset(offset).Find(&list).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
out := make([]*entities.SubordinateWalletAllocation, len(list))
|
||||||
|
for i := range list {
|
||||||
|
out[i] = &list[i]
|
||||||
|
}
|
||||||
|
return out, total, nil
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
|
||||||
"tyapi-server/internal/domains/user/entities"
|
"tyapi-server/internal/domains/user/entities"
|
||||||
"tyapi-server/internal/domains/user/repositories"
|
"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 {
|
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 {
|
func (r *GormUserRepository) CreateBatch(ctx context.Context, users []entities.User) error {
|
||||||
|
|||||||
211
internal/infrastructure/http/handlers/subordinate_handler.go
Normal file
211
internal/infrastructure/http/handlers/subordinate_handler.go
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
subordinate_app "tyapi-server/internal/application/subordinate"
|
||||||
|
"tyapi-server/internal/application/subordinate/dto/commands"
|
||||||
|
"tyapi-server/internal/shared/interfaces"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SubordinateHandler 下属账号
|
||||||
|
type SubordinateHandler struct {
|
||||||
|
app subordinate_app.SubordinateApplicationService
|
||||||
|
response interfaces.ResponseBuilder
|
||||||
|
validator interfaces.RequestValidator
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSubordinateHandler 构造
|
||||||
|
func NewSubordinateHandler(
|
||||||
|
app subordinate_app.SubordinateApplicationService,
|
||||||
|
response interfaces.ResponseBuilder,
|
||||||
|
validator interfaces.RequestValidator,
|
||||||
|
logger *zap.Logger,
|
||||||
|
) *SubordinateHandler {
|
||||||
|
return &SubordinateHandler{app: app, response: response, validator: validator, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubPortalRegister 子站注册
|
||||||
|
func (h *SubordinateHandler) SubPortalRegister(c *gin.Context) {
|
||||||
|
var cmd commands.SubPortalRegisterCommand
|
||||||
|
if err := h.validator.BindAndValidate(c, &cmd); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := h.app.RegisterSubPortal(c.Request.Context(), &cmd)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("子站注册失败", zap.Error(err))
|
||||||
|
h.response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.response.Created(c, res, "注册成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateInvitation 主账号创建邀请
|
||||||
|
func (h *SubordinateHandler) CreateInvitation(c *gin.Context) {
|
||||||
|
parentID := c.GetString("user_id")
|
||||||
|
if parentID == "" {
|
||||||
|
h.response.Unauthorized(c, "未登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
ExpiresInHours int `json:"expires_in_hours"`
|
||||||
|
}
|
||||||
|
_ = c.ShouldBindJSON(&body)
|
||||||
|
res, err := h.app.CreateInvitation(c.Request.Context(), &commands.CreateInvitationCommand{
|
||||||
|
ParentUserID: parentID,
|
||||||
|
ExpiresInHours: body.ExpiresInHours,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("创建邀请失败", zap.Error(err))
|
||||||
|
h.response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.response.Success(c, res, "邀请已创建")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSubordinates 下属列表
|
||||||
|
func (h *SubordinateHandler) ListSubordinates(c *gin.Context) {
|
||||||
|
parentID := c.GetString("user_id")
|
||||||
|
if parentID == "" {
|
||||||
|
h.response.Unauthorized(c, "未登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
res, err := h.app.ListMySubordinates(c.Request.Context(), parentID, page, size)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("获取下属列表失败", zap.Error(err))
|
||||||
|
h.response.InternalError(c, "获取下属列表失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.response.Success(c, res, "获取成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocate 划款
|
||||||
|
func (h *SubordinateHandler) Allocate(c *gin.Context) {
|
||||||
|
parentID := c.GetString("user_id")
|
||||||
|
if parentID == "" {
|
||||||
|
h.response.Unauthorized(c, "未登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var cmd commands.AllocateToChildCommand
|
||||||
|
if err := h.validator.BindAndValidate(c, &cmd); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cmd.ParentUserID = parentID
|
||||||
|
if err := h.app.AllocateToChild(c.Request.Context(), &cmd); err != nil {
|
||||||
|
h.logger.Error("划拨失败", zap.Error(err))
|
||||||
|
h.response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.response.Success(c, nil, "划拨成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAllocations 下属划拨记录
|
||||||
|
func (h *SubordinateHandler) ListAllocations(c *gin.Context) {
|
||||||
|
parentID := c.GetString("user_id")
|
||||||
|
if parentID == "" {
|
||||||
|
h.response.Unauthorized(c, "未登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||||
|
cmd := &commands.ListChildAllocationsCommand{
|
||||||
|
ParentUserID: parentID,
|
||||||
|
ChildUserID: c.Query("child_user_id"),
|
||||||
|
Page: page,
|
||||||
|
PageSize: size,
|
||||||
|
}
|
||||||
|
if cmd.ChildUserID == "" {
|
||||||
|
h.response.BadRequest(c, "child_user_id 不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := h.app.ListChildAllocations(c.Request.Context(), cmd)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("获取划拨记录失败", zap.Error(err))
|
||||||
|
h.response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.response.Success(c, res, "获取成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssignSubscription 代配订阅
|
||||||
|
func (h *SubordinateHandler) AssignSubscription(c *gin.Context) {
|
||||||
|
parentID := c.GetString("user_id")
|
||||||
|
if parentID == "" {
|
||||||
|
h.response.Unauthorized(c, "未登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var cmd commands.AssignChildSubscriptionCommand
|
||||||
|
if err := h.validator.BindAndValidate(c, &cmd); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cmd.ParentUserID = parentID
|
||||||
|
if err := h.app.AssignChildSubscription(c.Request.Context(), &cmd); err != nil {
|
||||||
|
h.logger.Error("代配订阅失败", zap.Error(err))
|
||||||
|
h.response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.response.Success(c, nil, "已保存下属订阅")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListChildSubscriptions 下属订阅列表
|
||||||
|
func (h *SubordinateHandler) ListChildSubscriptions(c *gin.Context) {
|
||||||
|
parentID := c.GetString("user_id")
|
||||||
|
if parentID == "" {
|
||||||
|
h.response.Unauthorized(c, "未登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
childID := c.Query("child_user_id")
|
||||||
|
if childID == "" {
|
||||||
|
h.response.BadRequest(c, "child_user_id 不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res, err := h.app.ListChildSubscriptions(c.Request.Context(), &commands.ListChildSubscriptionsCommand{
|
||||||
|
ParentUserID: parentID,
|
||||||
|
ChildUserID: childID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("获取下属订阅失败", zap.Error(err))
|
||||||
|
h.response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.response.Success(c, res, "获取成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveChildSubscription 删除下属订阅
|
||||||
|
func (h *SubordinateHandler) RemoveChildSubscription(c *gin.Context) {
|
||||||
|
parentID := c.GetString("user_id")
|
||||||
|
if parentID == "" {
|
||||||
|
h.response.Unauthorized(c, "未登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
ChildUserID string `json:"child_user_id"`
|
||||||
|
}
|
||||||
|
_ = c.ShouldBindJSON(&body)
|
||||||
|
if body.ChildUserID == "" {
|
||||||
|
h.response.BadRequest(c, "child_user_id 不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
subID := c.Param("subscription_id")
|
||||||
|
if subID == "" {
|
||||||
|
h.response.BadRequest(c, "subscription_id 不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := h.app.RemoveChildSubscription(c.Request.Context(), &commands.RemoveChildSubscriptionCommand{
|
||||||
|
ParentUserID: parentID,
|
||||||
|
ChildUserID: body.ChildUserID,
|
||||||
|
SubscriptionID: subID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("删除下属订阅失败", zap.Error(err))
|
||||||
|
h.response.BadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.response.Success(c, nil, "删除成功")
|
||||||
|
}
|
||||||
@@ -49,8 +49,6 @@ func (r *CertificationRoutes) Register(router *http.GinRouter) {
|
|||||||
authGroup := certificationGroup.Group("")
|
authGroup := certificationGroup.Group("")
|
||||||
authGroup.Use(r.auth.Handle())
|
authGroup.Use(r.auth.Handle())
|
||||||
{
|
{
|
||||||
authGroup.GET("", r.handler.ListCertifications) // 查询认证列表(管理员)
|
|
||||||
|
|
||||||
// 1. 获取认证详情
|
// 1. 获取认证详情
|
||||||
authGroup.GET("/details", r.handler.GetCertification)
|
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("/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.auth.Handle())
|
||||||
adminGroup.Use(r.admin.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)
|
adminGroup.POST("/transition-status", r.handler.AdminTransitionCertificationStatus)
|
||||||
}
|
}
|
||||||
adminCertGroup := adminGroup.Group("/submit-records")
|
adminCertGroup := adminGroup.Group("/submit-records")
|
||||||
|
|||||||
46
internal/infrastructure/http/routes/subordinate_routes.go
Normal file
46
internal/infrastructure/http/routes/subordinate_routes.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"tyapi-server/internal/infrastructure/http/handlers"
|
||||||
|
sharedhttp "tyapi-server/internal/shared/http"
|
||||||
|
"tyapi-server/internal/shared/middleware"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SubordinateRoutes 下属与邀请路由
|
||||||
|
type SubordinateRoutes struct {
|
||||||
|
handler *handlers.SubordinateHandler
|
||||||
|
auth *middleware.JWTAuthMiddleware
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSubordinateRoutes 构造
|
||||||
|
func NewSubordinateRoutes(
|
||||||
|
handler *handlers.SubordinateHandler,
|
||||||
|
auth *middleware.JWTAuthMiddleware,
|
||||||
|
logger *zap.Logger,
|
||||||
|
) *SubordinateRoutes {
|
||||||
|
return &SubordinateRoutes{handler: handler, auth: auth, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register 注册
|
||||||
|
func (r *SubordinateRoutes) Register(router *sharedhttp.GinRouter) {
|
||||||
|
g := router.GetEngine()
|
||||||
|
|
||||||
|
g.POST("/api/v1/sub-portal/register", r.handler.SubPortalRegister)
|
||||||
|
|
||||||
|
sub := g.Group("/api/v1/subordinate")
|
||||||
|
sub.Use(r.auth.Handle())
|
||||||
|
{
|
||||||
|
sub.POST("/invitations", r.handler.CreateInvitation)
|
||||||
|
sub.GET("/subordinates", r.handler.ListSubordinates)
|
||||||
|
sub.POST("/allocate", r.handler.Allocate)
|
||||||
|
sub.GET("/allocations", r.handler.ListAllocations)
|
||||||
|
sub.POST("/assign-subscription", r.handler.AssignSubscription)
|
||||||
|
sub.GET("/child-subscriptions", r.handler.ListChildSubscriptions)
|
||||||
|
sub.DELETE("/child-subscriptions/:subscription_id", r.handler.RemoveChildSubscription)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.logger.Info("下属账号路由注册完成")
|
||||||
|
}
|
||||||
31
internal/infrastructure/subordinate/account_kind_provider.go
Normal file
31
internal/infrastructure/subordinate/account_kind_provider.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
7
internal/shared/auth/account_kind.go
Normal file
7
internal/shared/auth/account_kind.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
// 账号在控制台维度的「壳」类型(与 user_type 管理员/普通 正交)
|
||||||
|
const (
|
||||||
|
AccountKindStandalone = "standalone"
|
||||||
|
AccountKindSubordinate = "subordinate"
|
||||||
|
)
|
||||||
9
internal/shared/interfaces/account_kind_provider.go
Normal file
9
internal/shared/interfaces/account_kind_provider.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -81,6 +81,11 @@ func (m *JWTAuthMiddleware) Handle() gin.HandlerFunc {
|
|||||||
c.Set("email", claims.Email)
|
c.Set("email", claims.Email)
|
||||||
c.Set("phone", claims.Phone)
|
c.Set("phone", claims.Phone)
|
||||||
c.Set("user_type", claims.UserType)
|
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.Set("token_claims", claims)
|
||||||
|
|
||||||
c.Next()
|
c.Next()
|
||||||
@@ -99,6 +104,8 @@ type JWTClaims struct {
|
|||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
UserType string `json:"user_type"` // 新增:用户类型
|
UserType string `json:"user_type"` // 新增:用户类型
|
||||||
|
// AccountKind 控制台壳类型:standalone / subordinate(与主从关系表一致时下属为 subordinate)
|
||||||
|
AccountKind string `json:"account_kind"`
|
||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,15 +144,19 @@ func (m *JWTAuthMiddleware) respondUnauthorized(c *gin.Context, message string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GenerateToken 生成JWT token
|
// 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()
|
now := time.Now()
|
||||||
|
if accountKind == "" {
|
||||||
|
accountKind = "standalone"
|
||||||
|
}
|
||||||
|
|
||||||
claims := &JWTClaims{
|
claims := &JWTClaims{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Username: phone, // 普通用户用手机号,管理员用用户名
|
Username: phone, // 普通用户用手机号,管理员用用户名
|
||||||
Email: email,
|
Email: email,
|
||||||
Phone: phone,
|
Phone: phone,
|
||||||
UserType: userType, // 新增:用户类型
|
UserType: userType, // 新增:用户类型
|
||||||
|
AccountKind: accountKind, // 下属 / 普通
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
Issuer: "tyapi-server",
|
Issuer: "tyapi-server",
|
||||||
Subject: userID,
|
Subject: userID,
|
||||||
@@ -262,6 +273,11 @@ func (m *OptionalAuthMiddleware) Handle() gin.HandlerFunc {
|
|||||||
c.Set("email", claims.Email)
|
c.Set("email", claims.Email)
|
||||||
c.Set("phone", claims.Phone)
|
c.Set("phone", claims.Phone)
|
||||||
c.Set("user_type", claims.UserType)
|
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.Set("token_claims", claims)
|
||||||
|
|
||||||
c.Next()
|
c.Next()
|
||||||
@@ -343,6 +359,11 @@ func (m *AdminAuthMiddleware) Handle() gin.HandlerFunc {
|
|||||||
c.Set("email", claims.Email)
|
c.Set("email", claims.Email)
|
||||||
c.Set("phone", claims.Phone)
|
c.Set("phone", claims.Phone)
|
||||||
c.Set("user_type", claims.UserType)
|
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.Set("token_claims", claims)
|
||||||
|
|
||||||
c.Next()
|
c.Next()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -403,9 +404,24 @@ func (m *DailyRateLimitMiddleware) checkReferer(c *gin.Context) error {
|
|||||||
|
|
||||||
// 检查允许的Referer
|
// 检查允许的Referer
|
||||||
if len(m.limitConfig.AllowedReferers) > 0 {
|
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
|
allowed := false
|
||||||
for _, allowedRef := range m.limitConfig.AllowedReferers {
|
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
|
allowed = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user