Constraint: 切换 main 前必须保存当前七月分支全部项目进展,套餐生效提案仅属于 Iteration/7-11。 Rejected: 将七月套餐修复直接移植到 main | 两个分支的可靠投递架构不同。 Confidence: medium Scope-risk: broad Directive: 不得将本提交整体 cherry-pick 到 main;main 套餐热修必须基于其纯 Asynq 代码独立实施。 Tested: git diff --check;openspec validate fix-package-activation-starvation --strict。 Not-tested: 按用户要求未运行自动化测试;go build ./... 因当前审计改造中的 Enterprise 模型字面量和 role.recordFailure 参数类型错误未通过。
394 lines
12 KiB
Go
394 lines
12 KiB
Go
package shop
|
|
|
|
import (
|
|
"context"
|
|
|
|
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
|
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
|
"github.com/break/junhong_cmp_fiber/internal/store"
|
|
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
|
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
|
"github.com/redis/go-redis/v9"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
type Service struct {
|
|
db *gorm.DB
|
|
redisClient *redis.Client
|
|
accessAudit accessauditapp.Writer
|
|
shopStore *postgres.ShopStore
|
|
accountStore *postgres.AccountStore
|
|
shopRoleStore *postgres.ShopRoleStore
|
|
roleStore *postgres.RoleStore
|
|
}
|
|
|
|
// SetAccessAudit 注入店铺角色授权的事务、缓存和统一审计边界。
|
|
func (s *Service) SetAccessAudit(db *gorm.DB, redisClient *redis.Client, writer accessauditapp.Writer) {
|
|
s.db = db
|
|
s.redisClient = redisClient
|
|
s.accessAudit = writer
|
|
}
|
|
|
|
func New(
|
|
shopStore *postgres.ShopStore,
|
|
accountStore *postgres.AccountStore,
|
|
shopRoleStore *postgres.ShopRoleStore,
|
|
roleStore *postgres.RoleStore,
|
|
) *Service {
|
|
return &Service{
|
|
shopStore: shopStore,
|
|
accountStore: accountStore,
|
|
shopRoleStore: shopRoleStore,
|
|
roleStore: roleStore,
|
|
}
|
|
}
|
|
|
|
func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateShopRequest) (*dto.ShopResponse, error) {
|
|
currentUserID := middleware.GetUserIDFromContext(ctx)
|
|
if currentUserID == 0 {
|
|
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
|
}
|
|
|
|
shop, err := s.shopStore.GetByID(ctx, id)
|
|
if err != nil {
|
|
return nil, errors.New(errors.CodeShopNotFound, "店铺不存在")
|
|
}
|
|
|
|
shop.ShopName = req.ShopName
|
|
shop.ContactName = req.ContactName
|
|
shop.ContactPhone = req.ContactPhone
|
|
shop.Province = req.Province
|
|
shop.City = req.City
|
|
shop.District = req.District
|
|
shop.Address = req.Address
|
|
shop.Status = req.Status
|
|
shop.Updater = currentUserID
|
|
|
|
if err := s.shopStore.Update(ctx, shop); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
parentShopName := ""
|
|
if shop.ParentID != nil {
|
|
parentShop, err := s.shopStore.GetByID(ctx, *shop.ParentID)
|
|
if err == nil {
|
|
parentShopName = parentShop.ShopName
|
|
}
|
|
}
|
|
|
|
return &dto.ShopResponse{
|
|
ID: shop.ID,
|
|
ShopName: shop.ShopName,
|
|
ShopCode: shop.ShopCode,
|
|
ParentID: shop.ParentID,
|
|
ParentShopName: parentShopName,
|
|
Level: shop.Level,
|
|
ContactName: shop.ContactName,
|
|
ContactPhone: shop.ContactPhone,
|
|
Province: shop.Province,
|
|
City: shop.City,
|
|
District: shop.District,
|
|
Address: shop.Address,
|
|
Status: shop.Status,
|
|
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"),
|
|
}, nil
|
|
}
|
|
|
|
// Disable 禁用店铺
|
|
func (s *Service) Disable(ctx context.Context, id uint) error {
|
|
// 获取当前用户 ID
|
|
currentUserID := middleware.GetUserIDFromContext(ctx)
|
|
if currentUserID == 0 {
|
|
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
|
}
|
|
|
|
// 查询店铺
|
|
shop, err := s.shopStore.GetByID(ctx, id)
|
|
if err != nil {
|
|
return errors.New(errors.CodeShopNotFound, "店铺不存在")
|
|
}
|
|
|
|
// 更新状态
|
|
shop.Status = constants.StatusDisabled
|
|
shop.Updater = currentUserID
|
|
|
|
return s.shopStore.Update(ctx, shop)
|
|
}
|
|
|
|
// Enable 启用店铺
|
|
func (s *Service) Enable(ctx context.Context, id uint) error {
|
|
// 获取当前用户 ID
|
|
currentUserID := middleware.GetUserIDFromContext(ctx)
|
|
if currentUserID == 0 {
|
|
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
|
}
|
|
|
|
// 查询店铺
|
|
shop, err := s.shopStore.GetByID(ctx, id)
|
|
if err != nil {
|
|
return errors.New(errors.CodeShopNotFound, "店铺不存在")
|
|
}
|
|
|
|
// 更新状态
|
|
shop.Status = constants.StatusEnabled
|
|
shop.Updater = currentUserID
|
|
|
|
return s.shopStore.Update(ctx, shop)
|
|
}
|
|
|
|
// GetByID 获取店铺详情
|
|
func (s *Service) GetByID(ctx context.Context, id uint) (*model.Shop, error) {
|
|
shop, err := s.shopStore.GetByID(ctx, id)
|
|
if err != nil {
|
|
return nil, errors.New(errors.CodeShopNotFound, "店铺不存在")
|
|
}
|
|
return shop, nil
|
|
}
|
|
|
|
func (s *Service) ListShopResponses(ctx context.Context, req *dto.ShopListRequest) ([]*dto.ShopResponse, int64, error) {
|
|
opts := &store.QueryOptions{
|
|
Page: req.Page,
|
|
PageSize: req.PageSize,
|
|
OrderBy: "created_at DESC",
|
|
}
|
|
if opts.Page == 0 {
|
|
opts.Page = 1
|
|
}
|
|
if opts.PageSize == 0 {
|
|
opts.PageSize = constants.DefaultPageSize
|
|
}
|
|
|
|
filters := make(map[string]interface{})
|
|
if req.ShopName != "" {
|
|
filters["shop_name"] = req.ShopName
|
|
}
|
|
if req.ShopCode != "" {
|
|
filters["shop_code"] = req.ShopCode
|
|
}
|
|
if req.ContactPhone != "" {
|
|
filters["contact_phone"] = req.ContactPhone
|
|
}
|
|
if req.ParentID != nil {
|
|
filters["parent_id"] = *req.ParentID
|
|
}
|
|
if req.Level != nil {
|
|
filters["level"] = *req.Level
|
|
}
|
|
if req.Status != nil {
|
|
filters["status"] = *req.Status
|
|
}
|
|
|
|
shops, total, err := s.shopStore.List(ctx, opts, filters)
|
|
if err != nil {
|
|
return nil, 0, errors.Wrap(errors.CodeInternalError, err, "查询店铺列表失败")
|
|
}
|
|
|
|
parentShopNameMap, err := s.buildParentShopNameMap(ctx, shops)
|
|
if err != nil {
|
|
return nil, 0, errors.Wrap(errors.CodeInternalError, err, "查询上级店铺名称失败")
|
|
}
|
|
|
|
responses := make([]*dto.ShopResponse, 0, len(shops))
|
|
for _, shop := range shops {
|
|
parentShopName := ""
|
|
if shop.ParentID != nil {
|
|
parentShopName = parentShopNameMap[*shop.ParentID]
|
|
}
|
|
|
|
responses = append(responses, &dto.ShopResponse{
|
|
ID: shop.ID,
|
|
ShopName: shop.ShopName,
|
|
ShopCode: shop.ShopCode,
|
|
ParentID: shop.ParentID,
|
|
ParentShopName: parentShopName,
|
|
Level: shop.Level,
|
|
ContactName: shop.ContactName,
|
|
ContactPhone: shop.ContactPhone,
|
|
Province: shop.Province,
|
|
City: shop.City,
|
|
District: shop.District,
|
|
Address: shop.Address,
|
|
Status: shop.Status,
|
|
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"),
|
|
})
|
|
}
|
|
|
|
return responses, total, nil
|
|
}
|
|
|
|
// buildParentShopNameMap 批量查询店铺上级名称,避免列表接口出现 N+1 查询。
|
|
func (s *Service) buildParentShopNameMap(ctx context.Context, shops []*model.Shop) (map[uint]string, error) {
|
|
parentIDs := make([]uint, 0, len(shops))
|
|
parentIDSet := make(map[uint]struct{}, len(shops))
|
|
for _, shop := range shops {
|
|
if shop.ParentID == nil {
|
|
continue
|
|
}
|
|
if _, exists := parentIDSet[*shop.ParentID]; exists {
|
|
continue
|
|
}
|
|
parentIDSet[*shop.ParentID] = struct{}{}
|
|
parentIDs = append(parentIDs, *shop.ParentID)
|
|
}
|
|
|
|
if len(parentIDs) == 0 {
|
|
return map[uint]string{}, nil
|
|
}
|
|
|
|
parentShops, err := s.shopStore.GetByIDs(ctx, parentIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
parentShopNameMap := make(map[uint]string, len(parentShops))
|
|
for _, parentShop := range parentShops {
|
|
parentShopNameMap[parentShop.ID] = parentShop.ShopName
|
|
}
|
|
return parentShopNameMap, nil
|
|
}
|
|
|
|
func (s *Service) List(ctx context.Context, opts *store.QueryOptions, filters map[string]interface{}) ([]*model.Shop, int64, error) {
|
|
return s.shopStore.List(ctx, opts, filters)
|
|
}
|
|
|
|
// ListCascade 联级查询店铺,用于新增店铺或其他场景选择上级店铺
|
|
func (s *Service) ListCascade(ctx context.Context, req *dto.ShopCascadeRequest) ([]*dto.ShopCascadeItem, error) {
|
|
shops, err := s.shopStore.ListForCascade(ctx, req.ShopName, req.ParentID, req.ExcludeSelf)
|
|
if err != nil {
|
|
return nil, errors.Wrap(errors.CodeInternalError, err, "查询店铺失败")
|
|
}
|
|
|
|
if len(shops) == 0 {
|
|
return []*dto.ShopCascadeItem{}, nil
|
|
}
|
|
|
|
ids := make([]uint, 0, len(shops))
|
|
for _, shop := range shops {
|
|
ids = append(ids, shop.ID)
|
|
}
|
|
|
|
childParentIDs, err := s.shopStore.GetParentIDsWithChildren(ctx, ids)
|
|
if err != nil {
|
|
return nil, errors.Wrap(errors.CodeInternalError, err, "查询下级店铺失败")
|
|
}
|
|
|
|
hasChildrenSet := make(map[uint]bool, len(childParentIDs))
|
|
for _, pid := range childParentIDs {
|
|
hasChildrenSet[pid] = true
|
|
}
|
|
|
|
result := make([]*dto.ShopCascadeItem, 0, len(shops))
|
|
for _, shop := range shops {
|
|
result = append(result, &dto.ShopCascadeItem{
|
|
ID: shop.ID,
|
|
ShopName: shop.ShopName,
|
|
HasChildren: hasChildrenSet[shop.ID],
|
|
})
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Service) Delete(ctx context.Context, id uint) error {
|
|
currentUserID := middleware.GetUserIDFromContext(ctx)
|
|
if currentUserID == 0 {
|
|
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
|
}
|
|
|
|
if s.db == nil || s.accessAudit == nil {
|
|
return errors.New(errors.CodeInvalidStatus, "店铺删除审计接缝未配置")
|
|
}
|
|
var shop *model.Shop
|
|
var parent *model.Shop
|
|
var accounts []*model.Account
|
|
var accountIDs []uint
|
|
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
var locked model.Shop
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&locked, id).Error; err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return errors.New(errors.CodeShopNotFound, "店铺不存在")
|
|
}
|
|
return errors.Wrap(errors.CodeDatabaseError, err, "获取店铺失败")
|
|
}
|
|
shop = &locked
|
|
parent = loadDeletedShopParent(tx, locked.ParentID)
|
|
if err := tx.Where("shop_id = ?", locked.ID).Find(&accounts).Error; err != nil {
|
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询店铺账号失败")
|
|
}
|
|
accountChanges := make([]accessauditapp.AccountChange, 0, len(accounts))
|
|
accountIDs = make([]uint, 0, len(accounts))
|
|
for _, account := range accounts {
|
|
accountIDs = append(accountIDs, account.ID)
|
|
accountChanges = append(accountChanges, accessauditapp.AccountChange{
|
|
Account: account, BeforeData: map[string]any{"status": account.Status}, AfterData: map[string]any{"status": constants.StatusDisabled},
|
|
})
|
|
}
|
|
if len(accountIDs) > 0 {
|
|
if err := postgres.NewAccountStore(tx, nil).BulkUpdateStatus(ctx, accountIDs, constants.StatusDisabled, currentUserID); err != nil {
|
|
return errors.Wrap(errors.CodeDatabaseError, err, "禁用店铺账号失败")
|
|
}
|
|
}
|
|
if err := tx.Delete(&model.Shop{}, id).Error; err != nil {
|
|
return errors.Wrap(errors.CodeDatabaseError, err, "删除店铺失败")
|
|
}
|
|
if err := s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
|
|
ActionCode: constants.AuditActionShopDeleted, Summary: "删除店铺", OperatorID: currentUserID,
|
|
Shop: shop, ParentShop: parent, Accounts: accountChanges,
|
|
BeforeData: map[string]any{"deleted": false}, AfterData: map[string]any{"deleted": true},
|
|
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "店铺已删除",
|
|
}); err != nil {
|
|
return errors.Wrap(errors.CodeInternalError, err, "写入店铺删除审计失败")
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
if shop != nil {
|
|
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, accessauditapp.ChangeAudit{
|
|
ActionCode: constants.AuditActionShopDeleted, Summary: "删除店铺失败", Result: shopRoleFailureResult(err),
|
|
OperatorID: currentUserID, Shop: shop, ParentShop: parent, SubjectVisibility: constants.AuditSubjectInternalOnly,
|
|
}, err)
|
|
}
|
|
return err
|
|
}
|
|
s.clearDeletedShopCaches(ctx, shop.ID, shop.ParentID, accountIDs)
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) clearDeletedShopCaches(ctx context.Context, shopID uint, parentID *uint, accountIDs []uint) {
|
|
if s.redisClient == nil {
|
|
return
|
|
}
|
|
keys := []string{constants.RedisShopSubordinatesKey(shopID)}
|
|
if parentID != nil {
|
|
keys = append(keys, constants.RedisShopSubordinatesKey(*parentID))
|
|
}
|
|
for _, accountID := range accountIDs {
|
|
keys = append(keys, constants.RedisUserPermissionsKey(accountID))
|
|
}
|
|
_ = s.redisClient.Del(ctx, keys...).Err()
|
|
}
|
|
|
|
func loadDeletedShopParent(tx *gorm.DB, parentID *uint) *model.Shop {
|
|
if parentID == nil {
|
|
return nil
|
|
}
|
|
var parent model.Shop
|
|
if err := tx.Unscoped().First(&parent, *parentID).Error; err != nil {
|
|
return nil
|
|
}
|
|
return &parent
|
|
}
|
|
|
|
// GetSubordinateShopIDs 获取下级店铺 ID 列表(包含自己)
|
|
func (s *Service) GetSubordinateShopIDs(ctx context.Context, shopID uint) ([]uint, error) {
|
|
return s.shopStore.GetSubordinateShopIDs(ctx, shopID)
|
|
}
|