All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 5m45s
主要变更: 1. OpenAPI 文档契约对齐 - 统一错误响应字段名为 msg(非 message) - 规范 envelope 响应结构(code, msg, data, timestamp) - 个人客户路由纳入文档体系(使用 Register 机制) - 新增 BuildDocHandlers() 统一管理 handler 构造 - 确保文档生成的幂等性 2. Service 层错误处理统一 - 全面替换 fmt.Errorf 为 errors.New/Wrap - 统一错误码使用规范 - Handler 层参数校验不泄露底层细节 - 新增错误码验证集成测试 3. 代码质量提升 - 删除未使用的 Task handler 和路由 - 新增代码规范检查脚本(check-service-errors.sh) - 新增注释路径一致性检查(check-comment-paths.sh) - 更新 API 文档生成指南 4. OpenSpec 归档 - 归档 openapi-contract-alignment 变更(63 tasks) - 归档 service-error-unify-core 变更 - 归档 service-error-unify-support 变更 - 归档 code-cleanup-docs-update 变更 - 归档 handler-validation-security 变更 - 同步 delta specs 到主规范文件 影响范围: - pkg/openapi: 新增 handlers.go,优化 generator.go - internal/service/*: 48 个 service 文件错误处理统一 - internal/handler/admin: 优化参数校验错误提示 - internal/routes: 个人客户路由改造,删除 task 路由 - scripts: 新增 3 个代码检查脚本 - docs: 更新 OpenAPI 文档(15750+ 行) - openspec/specs: 同步 3 个主规范文件 破坏性变更:无 向后兼容:是
424 lines
12 KiB
Go
424 lines
12 KiB
Go
package packagepkg
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"time"
|
||
|
||
"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/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"
|
||
)
|
||
|
||
type Service struct {
|
||
packageStore *postgres.PackageStore
|
||
packageSeriesStore *postgres.PackageSeriesStore
|
||
packageAllocationStore *postgres.ShopPackageAllocationStore
|
||
seriesAllocationStore *postgres.ShopSeriesAllocationStore
|
||
commissionTierStore *postgres.ShopSeriesCommissionTierStore
|
||
}
|
||
|
||
func New(
|
||
packageStore *postgres.PackageStore,
|
||
packageSeriesStore *postgres.PackageSeriesStore,
|
||
packageAllocationStore *postgres.ShopPackageAllocationStore,
|
||
seriesAllocationStore *postgres.ShopSeriesAllocationStore,
|
||
commissionTierStore *postgres.ShopSeriesCommissionTierStore,
|
||
) *Service {
|
||
return &Service{
|
||
packageStore: packageStore,
|
||
packageSeriesStore: packageSeriesStore,
|
||
packageAllocationStore: packageAllocationStore,
|
||
seriesAllocationStore: seriesAllocationStore,
|
||
commissionTierStore: commissionTierStore,
|
||
}
|
||
}
|
||
|
||
func (s *Service) Create(ctx context.Context, req *dto.CreatePackageRequest) (*dto.PackageResponse, error) {
|
||
currentUserID := middleware.GetUserIDFromContext(ctx)
|
||
if currentUserID == 0 {
|
||
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||
}
|
||
|
||
existing, _ := s.packageStore.GetByCode(ctx, req.PackageCode)
|
||
if existing != nil {
|
||
return nil, errors.New(errors.CodeConflict, "套餐编码已存在")
|
||
}
|
||
|
||
var seriesName *string
|
||
if req.SeriesID != nil && *req.SeriesID > 0 {
|
||
series, err := s.packageSeriesStore.GetByID(ctx, *req.SeriesID)
|
||
if err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return nil, errors.New(errors.CodeNotFound, "套餐系列不存在")
|
||
}
|
||
return nil, errors.Wrap(errors.CodeInternalError, err, "获取套餐系列失败")
|
||
}
|
||
seriesName = &series.SeriesName
|
||
}
|
||
|
||
pkg := &model.Package{
|
||
PackageCode: req.PackageCode,
|
||
PackageName: req.PackageName,
|
||
PackageType: req.PackageType,
|
||
DurationMonths: req.DurationMonths,
|
||
Price: req.Price,
|
||
Status: constants.StatusEnabled,
|
||
ShelfStatus: 2,
|
||
}
|
||
if req.SeriesID != nil {
|
||
pkg.SeriesID = *req.SeriesID
|
||
}
|
||
if req.DataType != nil {
|
||
pkg.DataType = *req.DataType
|
||
}
|
||
if req.RealDataMB != nil {
|
||
pkg.RealDataMB = *req.RealDataMB
|
||
}
|
||
if req.VirtualDataMB != nil {
|
||
pkg.VirtualDataMB = *req.VirtualDataMB
|
||
}
|
||
if req.DataAmountMB != nil {
|
||
pkg.DataAmountMB = *req.DataAmountMB
|
||
}
|
||
if req.SuggestedCostPrice != nil {
|
||
pkg.SuggestedCostPrice = *req.SuggestedCostPrice
|
||
}
|
||
if req.SuggestedRetailPrice != nil {
|
||
pkg.SuggestedRetailPrice = *req.SuggestedRetailPrice
|
||
}
|
||
pkg.Creator = currentUserID
|
||
|
||
if err := s.packageStore.Create(ctx, pkg); err != nil {
|
||
return nil, errors.Wrap(errors.CodeInternalError, err, "创建套餐失败")
|
||
}
|
||
|
||
resp := s.toResponse(ctx, pkg)
|
||
resp.SeriesName = seriesName
|
||
return resp, nil
|
||
}
|
||
|
||
func (s *Service) Get(ctx context.Context, id uint) (*dto.PackageResponse, error) {
|
||
pkg, err := s.packageStore.GetByID(ctx, id)
|
||
if err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return nil, errors.New(errors.CodeNotFound, "套餐不存在")
|
||
}
|
||
return nil, errors.Wrap(errors.CodeInternalError, err, "获取套餐失败")
|
||
}
|
||
|
||
resp := s.toResponse(ctx, pkg)
|
||
// 查询系列名称
|
||
if pkg.SeriesID > 0 {
|
||
series, err := s.packageSeriesStore.GetByID(ctx, pkg.SeriesID)
|
||
if err == nil {
|
||
resp.SeriesName = &series.SeriesName
|
||
}
|
||
}
|
||
return resp, nil
|
||
}
|
||
|
||
func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageRequest) (*dto.PackageResponse, error) {
|
||
currentUserID := middleware.GetUserIDFromContext(ctx)
|
||
if currentUserID == 0 {
|
||
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||
}
|
||
|
||
pkg, err := s.packageStore.GetByID(ctx, id)
|
||
if err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return nil, errors.New(errors.CodeNotFound, "套餐不存在")
|
||
}
|
||
return nil, errors.Wrap(errors.CodeInternalError, err, "获取套餐失败")
|
||
}
|
||
|
||
var seriesName *string
|
||
if req.SeriesID != nil && *req.SeriesID > 0 {
|
||
series, err := s.packageSeriesStore.GetByID(ctx, *req.SeriesID)
|
||
if err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return nil, errors.New(errors.CodeNotFound, "套餐系列不存在")
|
||
}
|
||
return nil, errors.Wrap(errors.CodeInternalError, err, "获取套餐系列失败")
|
||
}
|
||
pkg.SeriesID = *req.SeriesID
|
||
seriesName = &series.SeriesName
|
||
} else if pkg.SeriesID > 0 {
|
||
// 如果没有更新 SeriesID,但现有套餐有 SeriesID,则查询当前的系列名称
|
||
series, err := s.packageSeriesStore.GetByID(ctx, pkg.SeriesID)
|
||
if err == nil {
|
||
seriesName = &series.SeriesName
|
||
}
|
||
}
|
||
|
||
if req.PackageName != nil {
|
||
pkg.PackageName = *req.PackageName
|
||
}
|
||
if req.PackageType != nil {
|
||
pkg.PackageType = *req.PackageType
|
||
}
|
||
if req.DurationMonths != nil {
|
||
pkg.DurationMonths = *req.DurationMonths
|
||
}
|
||
if req.DataType != nil {
|
||
pkg.DataType = *req.DataType
|
||
}
|
||
if req.RealDataMB != nil {
|
||
pkg.RealDataMB = *req.RealDataMB
|
||
}
|
||
if req.VirtualDataMB != nil {
|
||
pkg.VirtualDataMB = *req.VirtualDataMB
|
||
}
|
||
if req.DataAmountMB != nil {
|
||
pkg.DataAmountMB = *req.DataAmountMB
|
||
}
|
||
if req.Price != nil {
|
||
pkg.Price = *req.Price
|
||
}
|
||
if req.SuggestedCostPrice != nil {
|
||
pkg.SuggestedCostPrice = *req.SuggestedCostPrice
|
||
}
|
||
if req.SuggestedRetailPrice != nil {
|
||
pkg.SuggestedRetailPrice = *req.SuggestedRetailPrice
|
||
}
|
||
pkg.Updater = currentUserID
|
||
|
||
if err := s.packageStore.Update(ctx, pkg); err != nil {
|
||
return nil, errors.Wrap(errors.CodeInternalError, err, "更新套餐失败")
|
||
}
|
||
|
||
resp := s.toResponse(ctx, pkg)
|
||
resp.SeriesName = seriesName
|
||
return resp, nil
|
||
}
|
||
|
||
func (s *Service) Delete(ctx context.Context, id uint) error {
|
||
_, err := s.packageStore.GetByID(ctx, id)
|
||
if err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return errors.New(errors.CodeNotFound, "套餐不存在")
|
||
}
|
||
return errors.Wrap(errors.CodeInternalError, err, "获取套餐失败")
|
||
}
|
||
|
||
if err := s.packageStore.Delete(ctx, id); err != nil {
|
||
return errors.Wrap(errors.CodeInternalError, err, "删除套餐失败")
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) List(ctx context.Context, req *dto.PackageListRequest) ([]*dto.PackageResponse, int64, error) {
|
||
opts := &store.QueryOptions{
|
||
Page: req.Page,
|
||
PageSize: req.PageSize,
|
||
OrderBy: "id DESC",
|
||
}
|
||
if opts.Page == 0 {
|
||
opts.Page = 1
|
||
}
|
||
if opts.PageSize == 0 {
|
||
opts.PageSize = constants.DefaultPageSize
|
||
}
|
||
|
||
filters := make(map[string]interface{})
|
||
if req.PackageName != nil {
|
||
filters["package_name"] = *req.PackageName
|
||
}
|
||
if req.SeriesID != nil {
|
||
filters["series_id"] = *req.SeriesID
|
||
}
|
||
if req.Status != nil {
|
||
filters["status"] = *req.Status
|
||
}
|
||
if req.ShelfStatus != nil {
|
||
filters["shelf_status"] = *req.ShelfStatus
|
||
}
|
||
if req.PackageType != nil {
|
||
filters["package_type"] = *req.PackageType
|
||
}
|
||
|
||
packages, total, err := s.packageStore.List(ctx, opts, filters)
|
||
if err != nil {
|
||
return nil, 0, errors.Wrap(errors.CodeInternalError, err, "查询套餐列表失败")
|
||
}
|
||
|
||
// 收集所有唯一的 series_id
|
||
seriesIDMap := make(map[uint]bool)
|
||
for _, pkg := range packages {
|
||
if pkg.SeriesID > 0 {
|
||
seriesIDMap[pkg.SeriesID] = true
|
||
}
|
||
}
|
||
|
||
// 批量查询套餐系列
|
||
seriesMap := make(map[uint]string)
|
||
if len(seriesIDMap) > 0 {
|
||
seriesIDs := make([]uint, 0, len(seriesIDMap))
|
||
for id := range seriesIDMap {
|
||
seriesIDs = append(seriesIDs, id)
|
||
}
|
||
seriesList, err := s.packageSeriesStore.GetByIDs(ctx, seriesIDs)
|
||
if err != nil {
|
||
return nil, 0, errors.Wrap(errors.CodeInternalError, err, "批量查询套餐系列失败")
|
||
}
|
||
for _, series := range seriesList {
|
||
seriesMap[series.ID] = series.SeriesName
|
||
}
|
||
}
|
||
|
||
// 构建响应,填充系列名称
|
||
responses := make([]*dto.PackageResponse, len(packages))
|
||
for i, pkg := range packages {
|
||
resp := s.toResponse(ctx, pkg)
|
||
if pkg.SeriesID > 0 {
|
||
if seriesName, ok := seriesMap[pkg.SeriesID]; ok {
|
||
resp.SeriesName = &seriesName
|
||
}
|
||
}
|
||
responses[i] = resp
|
||
}
|
||
|
||
return responses, total, nil
|
||
}
|
||
|
||
func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error {
|
||
currentUserID := middleware.GetUserIDFromContext(ctx)
|
||
if currentUserID == 0 {
|
||
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
||
}
|
||
|
||
pkg, err := s.packageStore.GetByID(ctx, id)
|
||
if err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return errors.New(errors.CodeNotFound, "套餐不存在")
|
||
}
|
||
return errors.Wrap(errors.CodeInternalError, err, "获取套餐失败")
|
||
}
|
||
|
||
pkg.Status = status
|
||
pkg.Updater = currentUserID
|
||
|
||
if status == constants.StatusDisabled {
|
||
pkg.ShelfStatus = 2
|
||
}
|
||
|
||
if err := s.packageStore.Update(ctx, pkg); err != nil {
|
||
return errors.Wrap(errors.CodeInternalError, err, "更新套餐状态失败")
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) UpdateShelfStatus(ctx context.Context, id uint, shelfStatus int) error {
|
||
currentUserID := middleware.GetUserIDFromContext(ctx)
|
||
if currentUserID == 0 {
|
||
return errors.New(errors.CodeUnauthorized, "未授权访问")
|
||
}
|
||
|
||
pkg, err := s.packageStore.GetByID(ctx, id)
|
||
if err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return errors.New(errors.CodeNotFound, "套餐不存在")
|
||
}
|
||
return errors.Wrap(errors.CodeInternalError, err, "获取套餐失败")
|
||
}
|
||
|
||
if shelfStatus == 1 && pkg.Status == constants.StatusDisabled {
|
||
return errors.New(errors.CodeInvalidStatus, "禁用的套餐不能上架,请先启用")
|
||
}
|
||
|
||
pkg.ShelfStatus = shelfStatus
|
||
pkg.Updater = currentUserID
|
||
|
||
if err := s.packageStore.Update(ctx, pkg); err != nil {
|
||
return errors.Wrap(errors.CodeInternalError, err, "更新套餐上架状态失败")
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) toResponse(ctx context.Context, pkg *model.Package) *dto.PackageResponse {
|
||
var seriesID *uint
|
||
if pkg.SeriesID > 0 {
|
||
seriesID = &pkg.SeriesID
|
||
}
|
||
|
||
resp := &dto.PackageResponse{
|
||
ID: pkg.ID,
|
||
PackageCode: pkg.PackageCode,
|
||
PackageName: pkg.PackageName,
|
||
SeriesID: seriesID,
|
||
PackageType: pkg.PackageType,
|
||
DurationMonths: pkg.DurationMonths,
|
||
DataType: pkg.DataType,
|
||
RealDataMB: pkg.RealDataMB,
|
||
VirtualDataMB: pkg.VirtualDataMB,
|
||
DataAmountMB: pkg.DataAmountMB,
|
||
Price: pkg.Price,
|
||
SuggestedCostPrice: pkg.SuggestedCostPrice,
|
||
SuggestedRetailPrice: pkg.SuggestedRetailPrice,
|
||
Status: pkg.Status,
|
||
ShelfStatus: pkg.ShelfStatus,
|
||
CreatedAt: pkg.CreatedAt.Format(time.RFC3339),
|
||
UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339),
|
||
}
|
||
|
||
userType := middleware.GetUserTypeFromContext(ctx)
|
||
shopID := middleware.GetShopIDFromContext(ctx)
|
||
if userType == constants.UserTypeAgent && shopID > 0 {
|
||
allocation, err := s.packageAllocationStore.GetByShopAndPackage(ctx, shopID, pkg.ID)
|
||
if err == nil && allocation != nil {
|
||
resp.CostPrice = &allocation.CostPrice
|
||
profitMargin := pkg.SuggestedRetailPrice - allocation.CostPrice
|
||
resp.ProfitMargin = &profitMargin
|
||
|
||
commissionInfo := s.getCommissionInfo(ctx, allocation.AllocationID)
|
||
if commissionInfo != nil {
|
||
resp.CurrentCommissionRate = commissionInfo.CurrentRate
|
||
resp.TierInfo = commissionInfo
|
||
}
|
||
}
|
||
}
|
||
|
||
return resp
|
||
}
|
||
|
||
func (s *Service) getCommissionInfo(ctx context.Context, allocationID uint) *dto.CommissionTierInfo {
|
||
seriesAllocation, err := s.seriesAllocationStore.GetByID(ctx, allocationID)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
|
||
info := &dto.CommissionTierInfo{}
|
||
|
||
if seriesAllocation.BaseCommissionMode == constants.CommissionModeFixed {
|
||
info.CurrentRate = fmt.Sprintf("%.2f元/单", float64(seriesAllocation.BaseCommissionValue)/100)
|
||
} else {
|
||
info.CurrentRate = fmt.Sprintf("%.1f%%", float64(seriesAllocation.BaseCommissionValue)/10)
|
||
}
|
||
|
||
if seriesAllocation.EnableTierCommission {
|
||
tiers, err := s.commissionTierStore.ListByAllocationID(ctx, allocationID)
|
||
if err == nil && len(tiers) > 0 {
|
||
tier := tiers[0]
|
||
info.NextThreshold = &tier.ThresholdValue
|
||
if tier.CommissionMode == constants.CommissionModeFixed {
|
||
nextRate := fmt.Sprintf("%.2f元/单", float64(tier.CommissionValue)/100)
|
||
info.NextRate = nextRate
|
||
} else {
|
||
nextRate := fmt.Sprintf("%.1f%%", float64(tier.CommissionValue)/10)
|
||
info.NextRate = nextRate
|
||
}
|
||
}
|
||
}
|
||
|
||
return info
|
||
}
|