This commit is contained in:
2026-04-25 11:59:10 +08:00
parent e246271a24
commit ba463ae38d
33 changed files with 1600 additions and 112 deletions

View File

@@ -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"`
}

View File

@@ -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"`
}

View 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
}

View 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")
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}