Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
AUG26-008。
- 迁移 000214–000217:tb_shop 全局唯一且不可修改的随机分销码(含存量回填)、
tb_agent_distribution_registration 待审批注册记录、tb_withdrawal_qualification 资料版本、
tb_commission_withdrawal_request_attempt 审批尝试记录,以及提现申请的 latest_*/异常标记列;
不修改既有迁移,down 在存在本 Change 业务事实或新类型场景行时拒绝破坏性回滚。
- 公开接口 POST /api/c/v1/agent-distribution-registrations:无认证,复用既有短信验证码校验、
消费与限流;无效分销码、停用上级、验证码无效或已消费统一返回「分销码不可用」且不落库,
审批通过前不创建店铺、账号或钱包。
- 审批通过才在同一事务内建启用店铺、代理主账号、钱包、上级层级与业务员快照,驳回不建实体,
重复回调不重复建实体,提交后清理上级下级缓存。
- 提现资料资格按不可变版本保存,替换合同或法人身份证即新增版本并同事务失效旧有效版本;
超管作废原因必填;代理停用与店铺删除联动失效。
- 提现每次提交或重提新增不可变审批尝试记录并冻结金额;企业微信通过仅一次从冻结扣减、
保持状态 2 并写 paid_at(不使用状态 4),驳回/cancelled/deleted 仅一次释放,
通过后撤销不回滚、不重新冻结、只写正交异常标记;加锁顺序统一为申请→尝试→钱包。
- 本地人工终审对已关联审批实例的申请返回状态冲突,approval_instance_id 为空的存量申请保持既有行为,
不新增任何配置开关。
- 补齐审批业务类型注册点全集:业务类型与场景字段常量、场景 DTO 两处枚举与中文描述、
场景字段白名单/合法类型/中文名、数据库 CHECK、Worker 决策消费者与装配、审批审计资源映射,
以及三个新审计资源与 13 个审计动作;失败/拒绝审计改为必达。
- 新增后台路由与 OpenAPI:资格提交/查询/作废、提现申请/重提/详情、店铺详情返回只读分销码。
- 归档本 Change:主 Spec 新增 agent-distribution-withdrawal 能力(5 个 Requirement)。
验证(junhong_cmp_test + Redis DB 6,显式 DB_*,未重置整库):
- 迁移 up → version 217 且 dirty=false → down 3 → up 回 217,fixture 复核残留为 0。
- 受控状态机脚手架 227 项通过 / 0 项失败,覆盖 18 组场景(幂等与乱序回调、资金冻结/释放/重提、
退款回扣 × 在途提现并发、负向场景拒绝审计与 14 个动作码审计真实落库)。
- gofmt 空、go build/go vet 通过、gendocs 与工作区逐字节一致、context-health 通过、
openspec validate --strict 通过、doctor healthy;自动化测试按项目决策为 N/A。
运行期前置(未完成,非代码交付物):由超管经 PUT /api/admin/wecom/scenes/{business_type} 为
agent_distribution_approval、withdrawal_qualification_approval、commission_withdrawal_approval
配置启用场景与模板控件映射;未配置时相应提交失败关闭。
222 lines
7.9 KiB
Go
222 lines
7.9 KiB
Go
// Package shop 提供店铺业务员归属与资金概况读取投影。
|
|
package shop
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
|
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
|
)
|
|
|
|
// BusinessOwnerQuery 提供店铺业务员归属读取能力。
|
|
type BusinessOwnerQuery struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
// NewBusinessOwnerQuery 创建店铺业务员归属 Query。
|
|
func NewBusinessOwnerQuery(db *gorm.DB) *BusinessOwnerQuery {
|
|
return &BusinessOwnerQuery{db: db}
|
|
}
|
|
|
|
// List 查询调用者数据范围内的店铺,并批量投影上级和业务员摘要。
|
|
func (q *BusinessOwnerQuery) List(ctx context.Context, request dto.ShopListRequest) ([]*dto.ShopResponse, int64, error) {
|
|
base := middleware.ApplyShopIDFilter(ctx, q.db.WithContext(ctx).Model(&model.Shop{}))
|
|
base = applyShopFilters(base, request)
|
|
var total int64
|
|
if err := base.Count(&total).Error; err != nil {
|
|
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺总数失败")
|
|
}
|
|
var shops []*model.Shop
|
|
offset := (request.Page - 1) * request.PageSize
|
|
if err := base.Order("created_at DESC, id DESC").Offset(offset).Limit(request.PageSize).Find(&shops).Error; err != nil {
|
|
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺列表失败")
|
|
}
|
|
responses, err := q.project(ctx, shops)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return responses, total, nil
|
|
}
|
|
|
|
// Detail 查询调用者数据范围内的一家店铺详情。
|
|
func (q *BusinessOwnerQuery) Detail(ctx context.Context, shopID uint) (*dto.ShopResponse, error) {
|
|
if shopID == 0 {
|
|
return nil, errors.New(errors.CodeInvalidParam)
|
|
}
|
|
var shop model.Shop
|
|
db := middleware.ApplyShopIDFilter(ctx, q.db.WithContext(ctx).Model(&model.Shop{}))
|
|
if err := db.Where("id = ?", shopID).First(&shop).Error; err != nil {
|
|
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
|
}
|
|
responses, err := q.project(ctx, []*model.Shop{&shop})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return responses[0], nil
|
|
}
|
|
|
|
// Candidates 查询当前可人工绑定的平台业务员最小投影。
|
|
func (q *BusinessOwnerQuery) Candidates(ctx context.Context, request dto.ShopBusinessOwnerCandidateRequest) ([]dto.ShopBusinessOwnerCandidate, int64, int, int, error) {
|
|
userType := middleware.GetUserTypeFromContext(ctx)
|
|
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
|
return nil, 0, 0, 0, errors.New(errors.CodeForbidden, "无权限查询业务员候选")
|
|
}
|
|
page, pageSize := request.Page, request.PageSize
|
|
if page == 0 {
|
|
page = constants.DefaultPage
|
|
}
|
|
if pageSize == 0 {
|
|
pageSize = constants.DefaultPageSize
|
|
}
|
|
base := q.db.WithContext(ctx).Model(&model.Account{}).
|
|
Where("user_type = ? AND status = ?", constants.UserTypePlatform, constants.StatusEnabled)
|
|
if request.Keyword != "" {
|
|
keyword := "%" + request.Keyword + "%"
|
|
base = base.Where("username ILIKE ? OR phone ILIKE ?", keyword, keyword)
|
|
}
|
|
var total int64
|
|
if err := base.Count(&total).Error; err != nil {
|
|
return nil, 0, 0, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询业务员候选总数失败")
|
|
}
|
|
var accounts []model.Account
|
|
if err := base.Order("id ASC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&accounts).Error; err != nil {
|
|
return nil, 0, 0, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询业务员候选失败")
|
|
}
|
|
items := make([]dto.ShopBusinessOwnerCandidate, 0, len(accounts))
|
|
for _, account := range accounts {
|
|
items = append(items, dto.ShopBusinessOwnerCandidate{
|
|
ID: account.ID, Username: account.Username, PhoneSummary: maskPhone(account.Phone),
|
|
})
|
|
}
|
|
return items, total, page, pageSize, nil
|
|
}
|
|
|
|
func applyShopFilters(db *gorm.DB, request dto.ShopListRequest) *gorm.DB {
|
|
if request.ShopName != "" {
|
|
db = db.Where("shop_name LIKE ?", "%"+request.ShopName+"%")
|
|
}
|
|
if request.ShopCode != "" {
|
|
db = db.Where("shop_code = ?", request.ShopCode)
|
|
}
|
|
if request.ContactPhone != "" {
|
|
db = db.Where("contact_phone = ?", request.ContactPhone)
|
|
}
|
|
if request.BusinessOwnerAccountID != nil {
|
|
db = db.Where("business_owner_account_id = ?", *request.BusinessOwnerAccountID)
|
|
}
|
|
if request.ParentID != nil {
|
|
db = db.Where("parent_id = ?", *request.ParentID)
|
|
}
|
|
if request.Level != nil {
|
|
db = db.Where("level = ?", *request.Level)
|
|
}
|
|
if request.Status != nil {
|
|
db = db.Where("status = ?", *request.Status)
|
|
}
|
|
return db
|
|
}
|
|
|
|
func (q *BusinessOwnerQuery) project(ctx context.Context, shops []*model.Shop) ([]*dto.ShopResponse, error) {
|
|
parentIDs, ownerIDs := collectProjectionIDs(shops)
|
|
parentNames, err := q.loadParentNames(ctx, parentIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
owners, err := q.loadBusinessOwners(ctx, ownerIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
responses := make([]*dto.ShopResponse, 0, len(shops))
|
|
for _, shop := range shops {
|
|
response := &dto.ShopResponse{
|
|
ID: shop.ID, ShopName: shop.ShopName, ShopCode: shop.ShopCode,
|
|
DistributionCode: shop.DistributionCode, ParentID: shop.ParentID,
|
|
BusinessOwnerAccountID: shop.BusinessOwnerAccountID, Level: shop.Level,
|
|
ContactName: shop.ContactName, ContactPhone: shop.ContactPhone, Province: shop.Province,
|
|
City: shop.City, District: shop.District, Address: shop.Address, Status: shop.Status,
|
|
ClientLoginDisabled: shop.ClientLoginDisabled,
|
|
StatusName: constants.GetStatusName(shop.Status), CreatedAt: shop.CreatedAt.Format("2006-01-02 15:04:05"),
|
|
UpdatedAt: shop.UpdatedAt.Format("2006-01-02 15:04:05"),
|
|
}
|
|
if shop.ParentID != nil {
|
|
response.ParentShopName = parentNames[*shop.ParentID]
|
|
}
|
|
if shop.BusinessOwnerAccountID != nil {
|
|
if owner, exists := owners[*shop.BusinessOwnerAccountID]; exists {
|
|
response.BusinessOwnerUsername = owner.Username
|
|
response.BusinessOwnerPhoneSummary = maskPhone(owner.Phone)
|
|
response.BusinessOwnerAvailable = owner.UserType == constants.UserTypePlatform && owner.Status == constants.StatusEnabled && !owner.DeletedAt.Valid
|
|
}
|
|
}
|
|
responses = append(responses, response)
|
|
}
|
|
return responses, nil
|
|
}
|
|
|
|
func collectProjectionIDs(shops []*model.Shop) ([]uint, []uint) {
|
|
parents := make(map[uint]struct{})
|
|
owners := make(map[uint]struct{})
|
|
for _, shop := range shops {
|
|
if shop.ParentID != nil {
|
|
parents[*shop.ParentID] = struct{}{}
|
|
}
|
|
if shop.BusinessOwnerAccountID != nil {
|
|
owners[*shop.BusinessOwnerAccountID] = struct{}{}
|
|
}
|
|
}
|
|
return mapKeys(parents), mapKeys(owners)
|
|
}
|
|
|
|
func (q *BusinessOwnerQuery) loadParentNames(ctx context.Context, ids []uint) (map[uint]string, error) {
|
|
result := make(map[uint]string, len(ids))
|
|
if len(ids) == 0 {
|
|
return result, nil
|
|
}
|
|
var shops []model.Shop
|
|
db := middleware.ApplyShopIDFilter(ctx, q.db.WithContext(ctx).Model(&model.Shop{}))
|
|
if err := db.Select("id", "shop_name").Where("id IN ?", ids).Find(&shops).Error; err != nil {
|
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询上级店铺摘要失败")
|
|
}
|
|
for _, shop := range shops {
|
|
result[shop.ID] = shop.ShopName
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (q *BusinessOwnerQuery) loadBusinessOwners(ctx context.Context, ids []uint) (map[uint]model.Account, error) {
|
|
result := make(map[uint]model.Account, len(ids))
|
|
if len(ids) == 0 {
|
|
return result, nil
|
|
}
|
|
var accounts []model.Account
|
|
if err := q.db.WithContext(ctx).Unscoped().Where("id IN ?", ids).Find(&accounts).Error; err != nil {
|
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询业务员摘要失败")
|
|
}
|
|
for _, account := range accounts {
|
|
result[account.ID] = account
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func mapKeys(values map[uint]struct{}) []uint {
|
|
keys := make([]uint, 0, len(values))
|
|
for key := range values {
|
|
keys = append(keys, key)
|
|
}
|
|
return keys
|
|
}
|
|
|
|
func maskPhone(phone string) string {
|
|
phone = strings.TrimSpace(phone)
|
|
if len(phone) < 7 {
|
|
return ""
|
|
}
|
|
return phone[:3] + "****" + phone[len(phone)-4:]
|
|
}
|