feat(distribution): 分销站点增加用户数量统计与用户信息展示模块

- 数据模型与迁移: 新增 000082 迁移在 users 表追加 distribution_site_id 并建索引,用户首次在分销站点注册/短信登录时自动绑定站点归属
- 分销管理接口: 新增 GET /admin/distribution/users 接口,聚合本站注册用户与下单客户,返回完整11位手机号、累计消费金额、订单数及来源标签
- 统计与大盘扩展: GET /admin/distribution/sites/:id/analytics 补全今日注册、累计用户、成交客户指标,主站分销商列表追加用户体量展示
- 分销后台前端: 新增「客户与用户」页面 (Users.tsx) 支持一键复制手机号与订单穿透,大盘 (Dashboard.tsx) 增加用户规模核心卡片
- 自动化测试与门禁: 新增 TestListDistributionUsersAndAnalytics 单元测试,通过全量 Go 后端集成测试及前后端构建检查
This commit is contained in:
yml2213
2026-10-10 14:58:33 +08:00
parent 6d34f86765
commit 4ea08e355b
17 changed files with 956 additions and 60 deletions
+6 -4
View File
@@ -148,13 +148,14 @@ func (h *Handler) Login(c *gin.Context) {
response.BadRequest(c, "手机号和验证码不能为空")
return
}
result, err := h.service.LoginWithSMS(c.Request.Context(), strings.TrimSpace(req.Phone), strings.TrimSpace(req.Code), strings.TrimSpace(req.TrackCode))
siteCode := sitecode.FromRequest(c)
result, err := h.service.LoginWithSMS(c.Request.Context(), strings.TrimSpace(req.Phone), strings.TrimSpace(req.Code), strings.TrimSpace(req.TrackCode), siteCode)
if err != nil {
writeAuthError(c, err)
return
}
if h.invitationBinder != nil && strings.TrimSpace(req.InviteCode) != "" {
_ = h.invitationBinder.BindInvitation(c.Request.Context(), result.User.ID, strings.TrimSpace(req.InviteCode), 0, sitecode.FromRequest(c))
_ = h.invitationBinder.BindInvitation(c.Request.Context(), result.User.ID, strings.TrimSpace(req.InviteCode), 0, siteCode)
}
setAccessCookie(c, result.Tokens.AccessToken)
response.OK(c, result)
@@ -234,13 +235,14 @@ func (h *Handler) Register(c *gin.Context) {
response.BadRequest(c, "手机号、验证码和密码不能为空,密码长度 8-20 位")
return
}
result, err := h.service.RegisterWithPassword(c.Request.Context(), strings.TrimSpace(req.Phone), strings.TrimSpace(req.Code), req.Password, strings.TrimSpace(req.TrackCode))
siteCode := sitecode.FromRequest(c)
result, err := h.service.RegisterWithPassword(c.Request.Context(), strings.TrimSpace(req.Phone), strings.TrimSpace(req.Code), req.Password, strings.TrimSpace(req.TrackCode), siteCode)
if err != nil {
writeAuthError(c, err)
return
}
if h.invitationBinder != nil && strings.TrimSpace(req.InviteCode) != "" {
_ = h.invitationBinder.BindInvitation(c.Request.Context(), result.User.ID, strings.TrimSpace(req.InviteCode), 0, sitecode.FromRequest(c))
_ = h.invitationBinder.BindInvitation(c.Request.Context(), result.User.ID, strings.TrimSpace(req.InviteCode), 0, siteCode)
}
setAccessCookie(c, result.Tokens.AccessToken)
response.OK(c, result)
+41 -25
View File
@@ -69,21 +69,35 @@ func (r *UserRepository) UpdateProfile(ctx context.Context, id uint64, nickname
return r.FindByID(ctx, id)
}
func (r *UserRepository) FindOrCreateByPhone(ctx context.Context, phone string, trackCode ...string) (*model.User, error) {
func (r *UserRepository) resolveSiteID(ctx context.Context, siteCode string) uint64 {
siteCode = strings.TrimSpace(siteCode)
if siteCode == "" {
return 0
}
var ds model.DistributionSite
if err := r.db.WithContext(ctx).Where("site_code = ? AND status = 'active'", siteCode).Select("id").First(&ds).Error; err == nil {
return ds.ID
}
return 0
}
func (r *UserRepository) FindOrCreateByPhone(ctx context.Context, phone string, trackCode string, siteCode ...string) (*model.User, error) {
now := time.Now()
var code string
if len(trackCode) > 0 {
code = sanitizeTrackCode(trackCode[0])
code := sanitizeTrackCode(trackCode)
var siteID uint64
if len(siteCode) > 0 {
siteID = r.resolveSiteID(ctx, siteCode[0])
}
user := model.User{
Phone: phone,
Nickname: "用户" + phone[len(phone)-4:],
RealnameStatus: "unverified",
RiskStatus: "normal",
CreditScore: 100,
Status: "active",
RegisterTrackCode: code,
LastLoginAt: &now,
Phone: phone,
Nickname: "用户" + phone[len(phone)-4:],
RealnameStatus: "unverified",
RiskStatus: "normal",
CreditScore: 100,
Status: "active",
RegisterTrackCode: code,
DistributionSiteID: siteID,
LastLoginAt: &now,
}
err := r.db.WithContext(ctx).Clauses(clause.OnConflict{
@@ -122,22 +136,24 @@ func (r *UserRepository) RevokeTokens(ctx context.Context, userID uint64) error
UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error
}
func (r *UserRepository) RegisterWithPassword(ctx context.Context, phone string, hash string, trackCode ...string) (*model.User, error) {
func (r *UserRepository) RegisterWithPassword(ctx context.Context, phone string, hash string, trackCode string, siteCode ...string) (*model.User, error) {
now := time.Now()
var code string
if len(trackCode) > 0 {
code = sanitizeTrackCode(trackCode[0])
code := sanitizeTrackCode(trackCode)
var siteID uint64
if len(siteCode) > 0 {
siteID = r.resolveSiteID(ctx, siteCode[0])
}
user := model.User{
Phone: phone,
PasswordHash: hash,
Nickname: "用户" + phone[len(phone)-4:],
RealnameStatus: "unverified",
RiskStatus: "normal",
CreditScore: 100,
Status: "active",
RegisterTrackCode: code,
LastLoginAt: &now,
Phone: phone,
PasswordHash: hash,
Nickname: "用户" + phone[len(phone)-4:],
RealnameStatus: "unverified",
RiskStatus: "normal",
CreditScore: 100,
Status: "active",
RegisterTrackCode: code,
DistributionSiteID: siteID,
LastLoginAt: &now,
}
err := r.db.WithContext(ctx).Create(&user).Error
+4 -4
View File
@@ -193,7 +193,7 @@ func (s *Service) SendSMSCode(ctx context.Context, phone string, captchaID strin
return nil
}
func (s *Service) LoginWithSMS(ctx context.Context, phone string, code string, trackCode ...string) (LoginResult, error) {
func (s *Service) LoginWithSMS(ctx context.Context, phone string, code string, trackCode string, siteCode ...string) (LoginResult, error) {
if s.redis == nil || s.users == nil {
return LoginResult{}, ErrDependencyUnavailable
}
@@ -209,7 +209,7 @@ func (s *Service) LoginWithSMS(ctx context.Context, phone string, code string, t
return LoginResult{}, err
}
user, err := s.users.FindOrCreateByPhone(ctx, phone, trackCode...)
user, err := s.users.FindOrCreateByPhone(ctx, phone, trackCode, siteCode...)
if err != nil {
return LoginResult{}, err
}
@@ -385,7 +385,7 @@ func (s *Service) ResetPassword(ctx context.Context, phone, code, newPassword st
return nil
}
func (s *Service) RegisterWithPassword(ctx context.Context, phone, code, password string, trackCode ...string) (LoginResult, error) {
func (s *Service) RegisterWithPassword(ctx context.Context, phone, code, password string, trackCode string, siteCode ...string) (LoginResult, error) {
if s.redis == nil || s.users == nil {
return LoginResult{}, ErrDependencyUnavailable
}
@@ -418,7 +418,7 @@ func (s *Service) RegisterWithPassword(ctx context.Context, phone, code, passwor
return LoginResult{}, err
}
user, err := s.users.RegisterWithPassword(ctx, phone, string(hash), trackCode...)
user, err := s.users.RegisterWithPassword(ctx, phone, string(hash), trackCode, siteCode...)
if err != nil {
// 并发场景下仍可能因唯一索引冲突,统一按"已注册"处理
return LoginResult{}, ErrUserAlreadyExists