diff --git a/README.md b/README.md index 858768a..14a5dc9 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,7 @@ default: - **批量购买套餐脚本**:支持从单列 CSV 读取 ICCID/虚拟号,逐资产调用后台订单接口购买统一套餐,提供预演、重复拦截和逐条结果落盘能力。详见 [使用说明](scripts/batch_package_purchase/README.md) 与 [功能总结](docs/批量购买套餐脚本/功能总结.md) - **轮询系统**:IoT 卡实名状态、流量使用、套餐余额的定时轮询检查;支持配置化轮询策略、动态并发控制、告警系统、数据清理和手动触发功能;详见 [轮询系统文档](docs/polling-system/README.md) - **套餐系统升级**:完整的套餐生命周期管理,支持主套餐排队激活、加油包绑定主套餐、囤货待实名激活、流量按优先级扣减、自然月/按天有效期计算、日/月/年流量重置、客户端流量查询和套餐流量详单;详见 [套餐系统升级文档](docs/package-system-upgrade/) +- **套餐生效条件覆盖与购买快照**:支持代理分配覆盖套餐生效条件,并将生效条件、周期类型和购买时长固化到套餐使用记录;激活与排队接续只消费购买快照。详见 [功能总结](docs/ur55-package-expiry-base/功能总结.md) - **套餐价格回退与平台赠送策略**:新增价格配置状态、普通套餐成本价回退、赠送套餐独立语义、平台后台赠送订单发放和历史 0 价复核清单;详见 [功能总结](docs/package-price-fallback-and-platform-gift-policy/功能总结.md) 与 [最终验收清单](docs/package-price-fallback-and-platform-gift-policy/最终验收清单.md) - **分佣验证指引**:对代理分佣的冻结、解冻、提现校验流程进行了结构化说明与流程图,详见 [分佣逻辑正确与否验证](docs/优化说明/分佣逻辑正确与否验证.md) - **对象存储**:S3 兼容的对象存储服务集成(联通云 OSS),支持预签名 URL 上传、文件下载、临时文件处理;用于 ICCID 批量导入、数据导出等场景;详见 [使用指南](docs/object-storage/使用指南.md) 和 [前端接入指南](docs/object-storage/前端接入指南.md) diff --git a/docs/admin-openapi.yaml b/docs/admin-openapi.yaml index 53b29bd..5ff362b 100644 --- a/docs/admin-openapi.yaml +++ b/docs/admin-openapi.yaml @@ -2011,6 +2011,10 @@ components: type: object DtoBatchAllocatePackagesRequest: properties: + expiry_base_override: + description: 分配生效条件覆盖 (null:跟随套餐默认值, from_activation:实名激活时生效, from_purchase:购买即生效) + nullable: true + type: string one_time_commission_amount: description: 该代理能拿到的一次性佣金(分) minimum: 0 @@ -2030,6 +2034,24 @@ components: - shop_id - series_id type: object + DtoBatchAllocatePackagesResponse: + properties: + allocated_count: + description: 成功分配数量 + type: integer + allocations: + description: 本次新建套餐分配及其生效条件 + items: + $ref: '#/components/schemas/DtoShopPackageAllocationTermsResponse' + nullable: true + type: array + skipped_count: + description: 跳过数量(已存在) + type: integer + total_packages: + description: 总套餐数 + type: integer + type: object DtoBatchPricingSkipped: properties: allocation_id: @@ -3845,6 +3867,10 @@ components: description: 是否启用代理强充 nullable: true type: boolean + expiry_base_override: + description: 分配生效条件覆盖 (null:跟随套餐默认值, from_activation:实名激活时生效, from_purchase:购买即生效) + nullable: true + type: string force_recharge_amount: description: 代理强充金额(分) nullable: true @@ -6085,6 +6111,10 @@ components: type: object DtoManageGrantPackagesParams: properties: + expiry_base_override: + description: 新增分配的生效条件覆盖 (null:跟随套餐默认值, from_activation:实名激活时生效, from_purchase:购买即生效) + nullable: true + type: string packages: description: 套餐操作列表 items: @@ -6605,6 +6635,12 @@ components: data_reset_cycle: description: 流量重置周期 (daily:每日, monthly:每月, yearly:每年, none:不重置) type: string + default_expiry_base: + description: 套餐默认生效条件 (from_activation:实名激活时生效, from_purchase:购买即生效) + type: string + default_expiry_base_name: + description: 套餐默认生效条件名称(中文) + type: string duration_days: description: 套餐天数(calendar_type=by_day时有值) nullable: true @@ -6612,6 +6648,12 @@ components: duration_months: description: 套餐时长(月数) type: integer + effective_expiry_base: + description: 最终生效条件 (from_activation:实名激活时生效, from_purchase:购买即生效) + type: string + effective_expiry_base_name: + description: 最终生效条件名称(中文) + type: string effective_retail_price: description: 生效零售价(分,仅销售链路展示使用) nullable: true @@ -6622,6 +6664,13 @@ components: expiry_base: description: 到期时间基准 (from_activation:实名激活时起算, from_purchase:购买时起算) type: string + expiry_base_override: + description: 分配生效条件覆盖,null 表示跟随套餐默认值 + nullable: true + type: string + expiry_base_override_name: + description: 分配生效条件覆盖名称(中文) + type: string id: description: 套餐ID minimum: 0 @@ -7991,6 +8040,32 @@ components: description: 总记录数 type: integer type: object + DtoShopPackageAllocationTermsResponse: + properties: + default_expiry_base: + description: 套餐默认生效条件 (from_activation:实名激活时生效, from_purchase:购买即生效) + type: string + default_expiry_base_name: + description: 套餐默认生效条件名称(中文) + type: string + effective_expiry_base: + description: 最终生效条件 (from_activation:实名激活时生效, from_purchase:购买即生效) + type: string + effective_expiry_base_name: + description: 最终生效条件名称(中文) + type: string + expiry_base_override: + description: 分配生效条件覆盖,null 表示跟随套餐默认值 + nullable: true + type: string + expiry_base_override_name: + description: 分配生效条件覆盖名称(中文) + type: string + id: + description: 套餐分配ID + minimum: 0 + type: integer + type: object DtoShopPageResult: properties: items: @@ -8153,6 +8228,25 @@ components: cost_price: description: 成本价(分) type: integer + default_expiry_base: + description: 套餐默认生效条件 (from_activation:实名激活时生效, from_purchase:购买即生效) + type: string + default_expiry_base_name: + description: 套餐默认生效条件名称(中文) + type: string + effective_expiry_base: + description: 最终生效条件 (from_activation:实名激活时生效, from_purchase:购买即生效) + type: string + effective_expiry_base_name: + description: 最终生效条件名称(中文) + type: string + expiry_base_override: + description: 分配生效条件覆盖,null 表示跟随套餐默认值 + nullable: true + type: string + expiry_base_override_name: + description: 分配生效条件覆盖名称(中文) + type: string package_code: description: 套餐编码 type: string @@ -8610,6 +8704,13 @@ components: nullable: true type: string type: object + DtoUpdateAllocationExpiryBaseParams: + properties: + expiry_base_override: + description: 分配生效条件覆盖 (null:跟随套餐默认值, from_activation:实名激活时生效, from_purchase:购买即生效) + nullable: true + type: string + type: object DtoUpdateAssetPackageExpiresAtRequest: properties: expires_at: @@ -22325,6 +22426,78 @@ paths: summary: 移除权限 tags: - 角色 + /api/admin/shop-package-allocations/{id}/expiry-base: + patch: + parameters: + - description: ID + in: path + name: id + required: true + schema: + description: ID + minimum: 0 + type: integer + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoUpdateAllocationExpiryBaseParams' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoShopPackageAllocationTermsResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 修改套餐分配生效条件覆盖 + tags: + - 批量套餐分配 /api/admin/shop-package-batch-allocations: post: requestBody: @@ -22333,6 +22506,32 @@ paths: schema: $ref: '#/components/schemas/DtoBatchAllocatePackagesRequest' responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoBatchAllocatePackagesResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 "400": content: application/json: diff --git a/docs/ur55-package-expiry-base/功能总结.md b/docs/ur55-package-expiry-base/功能总结.md new file mode 100644 index 0000000..e88f89c --- /dev/null +++ b/docs/ur55-package-expiry-base/功能总结.md @@ -0,0 +1,24 @@ +# UR#55 套餐分配生效条件覆盖与购买快照 + +## 实现范围 + +- 系列首次授权、系列追加授权和批量套餐分配必须显式提交 `expiry_base_override`,支持 `null`、`from_purchase`、`from_activation`。 +- 套餐分配响应返回套餐默认值、分配覆盖值和最终有效值及其中文名称。 +- 新增单条分配覆盖修改接口:`PATCH /api/admin/shop-package-allocations/{id}/expiry-base`,显式 `null` 恢复跟随默认,重复提交保持幂等。 +- 同步购买和自动购包创建主套餐或加油包时,在同一事务写入生效条件、周期类型、月数和天数快照。 +- 实名激活、指定激活、排队接续和退款后的下一套餐接续优先读取购买快照;仅四字段全部为空或零的历史记录允许告警回退套餐当前配置,部分缺失或非法快照直接拒绝。 + +## 数据库变更 + +- `tb_shop_package_allocation.expiry_base_override`:可空字符串,`NULL` 表示跟随套餐默认值。 +- `tb_package_usage`:新增 `expiry_base_snapshot`、`calendar_type_snapshot`、`duration_months_snapshot`、`duration_days_snapshot`。 +- 历史套餐使用记录不回填,保留空值和零值作为历史未快照标记。 + +## 发布与回滚 + +发布时先部署兼容读取快照和历史回退的应用,再执行 162、163 迁移,并在同一维护窗口切换所有写入路径。回滚迁移只删除本需求新增字段和约束,不修改套餐使用状态或历史业务数据。 + +## 验证 + +- 领域测试覆盖覆盖值解析、自然月/按天快照、快照优先、历史回退和异常快照拒绝。 +- 已执行全仓 `go test ./...`、OpenAPI 文档生成以及迁移向上、单步向下、重新向上验证。 diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index 0c00075..e29a72e 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -14,11 +14,11 @@ import ( authSvc "github.com/break/junhong_cmp_fiber/internal/service/auth" carrierSvc "github.com/break/junhong_cmp_fiber/internal/service/carrier" clientAuthSvc "github.com/break/junhong_cmp_fiber/internal/service/client_auth" - customerBindingSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_binding" commissionCalculationSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_calculation" commissionStatsSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_stats" commissionWithdrawalSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal" commissionWithdrawalSettingSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal_setting" + customerBindingSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_binding" "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/payment" @@ -265,7 +265,7 @@ func initServices(s *stores, deps *Dependencies) *services { Package: packageService, PackageDailyRecord: packageSvc.NewDailyRecordService(deps.DB, deps.Redis, s.PackageUsageDailyRecord, deps.Logger), PackageCustomerView: packageSvc.NewCustomerViewService(deps.DB, deps.Redis, s.PackageUsage, deps.Logger), - ShopPackageBatchAllocation: shopPackageBatchAllocationSvc.New(deps.DB, s.Package, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.Shop), + ShopPackageBatchAllocation: shopPackageBatchAllocationSvc.New(deps.DB, s.Package, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.Shop, accountAudit), ShopPackageBatchPricing: shopPackageBatchPricingSvc.New(deps.DB, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop), ShopSeriesGrant: shopSeriesGrantSvc.New(deps.DB, s.ShopSeriesAllocation, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, s.Package, s.PackageSeries, deps.Logger), CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats), diff --git a/internal/domain/package/terms.go b/internal/domain/package/terms.go new file mode 100644 index 0000000..6aa11c2 --- /dev/null +++ b/internal/domain/package/terms.go @@ -0,0 +1,65 @@ +// Package package 提供套餐生命周期领域规则。 +package packagedomain + +import ( + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// TermsSnapshot 套餐购买时不可变计时条款。 +type TermsSnapshot struct { + ExpiryBase string + CalendarType string + DurationMonths int + DurationDays int +} + +// ResolveTermsSnapshot 解析并校验套餐购买时计时条款。 +func ResolveTermsSnapshot(pkg *model.Package, allocation *model.ShopPackageAllocation) (TermsSnapshot, error) { + if pkg == nil { + return TermsSnapshot{}, errors.New(errors.CodeInvalidParam, "套餐计时条款缺失") + } + expiryBase := pkg.ExpiryBase + if allocation != nil && allocation.ExpiryBaseOverride != nil { + expiryBase = *allocation.ExpiryBaseOverride + } + if expiryBase != constants.PackageExpiryBaseFromActivation && expiryBase != constants.PackageExpiryBaseFromPurchase { + return TermsSnapshot{}, errors.New(errors.CodeInvalidParam, "套餐生效条件无效") + } + if pkg.CalendarType == constants.PackageCalendarTypeNaturalMonth { + if pkg.DurationMonths <= 0 { + return TermsSnapshot{}, errors.New(errors.CodeInvalidParam, "套餐自然月时长无效") + } + return TermsSnapshot{ExpiryBase: expiryBase, CalendarType: pkg.CalendarType, DurationMonths: pkg.DurationMonths}, nil + } + if pkg.CalendarType == constants.PackageCalendarTypeByDay && pkg.DurationDays > 0 { + return TermsSnapshot{ExpiryBase: expiryBase, CalendarType: pkg.CalendarType, DurationDays: pkg.DurationDays}, nil + } + return TermsSnapshot{}, errors.New(errors.CodeInvalidParam, "套餐按天时长无效") +} + +// Apply 将计时条款写入套餐使用记录。 +func (s TermsSnapshot) Apply(usage *model.PackageUsage) { + usage.ExpiryBaseSnapshot = s.ExpiryBase + usage.CalendarTypeSnapshot = s.CalendarType + usage.DurationMonthsSnapshot = s.DurationMonths + usage.DurationDaysSnapshot = s.DurationDays +} + +// IsValid 判断快照是否完整有效。 +func (s TermsSnapshot) IsValid() bool { + if s.ExpiryBase != constants.PackageExpiryBaseFromActivation && s.ExpiryBase != constants.PackageExpiryBaseFromPurchase { + return false + } + return (s.CalendarType == constants.PackageCalendarTypeNaturalMonth && s.DurationMonths > 0) || + (s.CalendarType == constants.PackageCalendarTypeByDay && s.DurationDays > 0) +} + +// TermsSnapshotFromUsage 从使用记录读取计时条款。 +func TermsSnapshotFromUsage(usage *model.PackageUsage) TermsSnapshot { + return TermsSnapshot{ + ExpiryBase: usage.ExpiryBaseSnapshot, CalendarType: usage.CalendarTypeSnapshot, + DurationMonths: usage.DurationMonthsSnapshot, DurationDays: usage.DurationDaysSnapshot, + } +} diff --git a/internal/domain/package/terms_test.go b/internal/domain/package/terms_test.go new file mode 100644 index 0000000..07b1606 --- /dev/null +++ b/internal/domain/package/terms_test.go @@ -0,0 +1,35 @@ +package packagedomain + +import ( + "testing" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// TestResolveTermsSnapshot 验证默认、覆盖及两种周期规则。 +func TestResolveTermsSnapshot(t *testing.T) { + override := constants.PackageExpiryBaseFromPurchase + tests := []struct { + name string + pkg model.Package + allocation *model.ShopPackageAllocation + wantErr bool + }{ + {name: "自然月默认", pkg: model.Package{ExpiryBase: constants.PackageExpiryBaseFromActivation, CalendarType: constants.PackageCalendarTypeNaturalMonth, DurationMonths: 12}}, + {name: "按天覆盖", pkg: model.Package{ExpiryBase: constants.PackageExpiryBaseFromActivation, CalendarType: constants.PackageCalendarTypeByDay, DurationDays: 30}, allocation: &model.ShopPackageAllocation{ExpiryBaseOverride: &override}}, + {name: "非法生效条件", pkg: model.Package{ExpiryBase: "invalid", CalendarType: constants.PackageCalendarTypeByDay, DurationDays: 30}, wantErr: true}, + {name: "非法按天时长", pkg: model.Package{ExpiryBase: constants.PackageExpiryBaseFromPurchase, CalendarType: constants.PackageCalendarTypeByDay}, wantErr: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := ResolveTermsSnapshot(&test.pkg, test.allocation) + if (err != nil) != test.wantErr { + t.Fatalf("错误状态不符合预期:%v", err) + } + if !test.wantErr && !got.IsValid() { + t.Fatalf("快照应有效:%+v", got) + } + }) + } +} diff --git a/internal/handler/admin/shop_package_batch_allocation.go b/internal/handler/admin/shop_package_batch_allocation.go index e3d8459..524a416 100644 --- a/internal/handler/admin/shop_package_batch_allocation.go +++ b/internal/handler/admin/shop_package_batch_allocation.go @@ -1,6 +1,9 @@ package admin import ( + "strconv" + + "github.com/bytedance/sonic" "github.com/gofiber/fiber/v2" "github.com/break/junhong_cmp_fiber/internal/model/dto" @@ -23,10 +26,40 @@ func (h *ShopPackageBatchAllocationHandler) BatchAllocate(c *fiber.Ctx) error { if err := c.BodyParser(&req); err != nil { return errors.New(errors.CodeInvalidParam, "请求参数解析失败") } + req.ExpiryBaseOverrideSet = hasJSONField(c.Body(), "expiry_base_override") - if err := h.service.BatchAllocate(c.UserContext(), &req); err != nil { + result, err := h.service.BatchAllocate(c.UserContext(), &req) + if err != nil { return err } - return response.Success(c, nil) + return response.Success(c, result) +} + +// UpdateExpiryBase 修改套餐分配生效条件覆盖。 +// PATCH /api/admin/shop-package-allocations/:id/expiry-base +func (h *ShopPackageBatchAllocationHandler) UpdateExpiryBase(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil { + return errors.New(errors.CodeInvalidParam) + } + var req dto.UpdateAllocationExpiryBaseRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + req.ExpiryBaseOverrideSet = hasJSONField(c.Body(), "expiry_base_override") + result, err := h.service.UpdateExpiryBase(c.UserContext(), uint(id), &req) + if err != nil { + return err + } + return response.Success(c, result) +} + +func hasJSONField(body []byte, field string) bool { + var object map[string]any + if err := sonic.Unmarshal(body, &object); err != nil { + return false + } + _, ok := object[field] + return ok } diff --git a/internal/handler/admin/shop_series_grant.go b/internal/handler/admin/shop_series_grant.go index ff23c9c..f6c15e2 100644 --- a/internal/handler/admin/shop_series_grant.go +++ b/internal/handler/admin/shop_series_grant.go @@ -28,6 +28,7 @@ func (h *ShopSeriesGrantHandler) Create(c *fiber.Ctx) error { if err := c.BodyParser(&req); err != nil { return errors.New(errors.CodeInvalidParam, "请求参数解析失败") } + req.ExpiryBaseOverrideSet = hasJSONField(c.Body(), "expiry_base_override") result, err := h.service.Create(c.UserContext(), &req) if err != nil { @@ -83,7 +84,6 @@ func (h *ShopSeriesGrantHandler) Update(c *fiber.Ctx) error { if err := c.BodyParser(&req); err != nil { return errors.New(errors.CodeInvalidParam, "请求参数解析失败") } - result, err := h.service.Update(c.UserContext(), uint(id), &req) if err != nil { return err @@ -105,6 +105,7 @@ func (h *ShopSeriesGrantHandler) ManagePackages(c *fiber.Ctx) error { if err := c.BodyParser(&req); err != nil { return errors.New(errors.CodeInvalidParam, "请求参数解析失败") } + req.ExpiryBaseOverrideSet = hasJSONField(c.Body(), "expiry_base_override") result, err := h.service.ManagePackages(c.UserContext(), uint(id), &req) if err != nil { diff --git a/internal/model/dto/package_dto.go b/internal/model/dto/package_dto.go index f869492..38ca494 100644 --- a/internal/model/dto/package_dto.go +++ b/internal/model/dto/package_dto.go @@ -106,6 +106,12 @@ type PackageResponse struct { DurationDays *int `json:"duration_days,omitempty" description:"套餐天数(calendar_type=by_day时有值)"` DataResetCycle string `json:"data_reset_cycle" description:"流量重置周期 (daily:每日, monthly:每月, yearly:每年, none:不重置)"` ExpiryBase string `json:"expiry_base" description:"到期时间基准 (from_activation:实名激活时起算, from_purchase:购买时起算)"` + DefaultExpiryBase string `json:"default_expiry_base" description:"套餐默认生效条件 (from_activation:实名激活时生效, from_purchase:购买即生效)"` + DefaultExpiryBaseName string `json:"default_expiry_base_name" description:"套餐默认生效条件名称(中文)"` + ExpiryBaseOverride *string `json:"expiry_base_override" description:"分配生效条件覆盖,null 表示跟随套餐默认值"` + ExpiryBaseOverrideName string `json:"expiry_base_override_name" description:"分配生效条件覆盖名称(中文)"` + EffectiveExpiryBase string `json:"effective_expiry_base" description:"最终生效条件 (from_activation:实名激活时生效, from_purchase:购买即生效)"` + EffectiveExpiryBaseName string `json:"effective_expiry_base_name" description:"最终生效条件名称(中文)"` } // UpdatePackageParams 更新套餐聚合参数 diff --git a/internal/model/dto/shop_package_batch_allocation_dto.go b/internal/model/dto/shop_package_batch_allocation_dto.go index a4724b2..3064354 100644 --- a/internal/model/dto/shop_package_batch_allocation_dto.go +++ b/internal/model/dto/shop_package_batch_allocation_dto.go @@ -12,12 +12,37 @@ type BatchAllocatePackagesRequest struct { SeriesID uint `json:"series_id" validate:"required" required:"true" description:"套餐系列ID"` PriceAdjustment *PriceAdjustment `json:"price_adjustment" validate:"omitempty" description:"可选加价配置"` OneTimeCommissionAmount *int64 `json:"one_time_commission_amount" validate:"omitempty,min=0" minimum:"0" description:"该代理能拿到的一次性佣金(分)"` + ExpiryBaseOverride *string `json:"expiry_base_override" nullable:"true" description:"分配生效条件覆盖 (null:跟随套餐默认值, from_activation:实名激活时生效, from_purchase:购买即生效)"` + ExpiryBaseOverrideSet bool `json:"-" description:"是否显式提交分配生效条件覆盖(内部解析字段)"` } // BatchAllocatePackagesResponse 批量分配套餐响应 type BatchAllocatePackagesResponse struct { - TotalPackages int `json:"total_packages" description:"总套餐数"` - AllocatedCount int `json:"allocated_count" description:"成功分配数量"` - SkippedCount int `json:"skipped_count" description:"跳过数量(已存在)"` - PackageIDs []uint `json:"package_ids" description:"分配的套餐ID列表"` + TotalPackages int `json:"total_packages" description:"总套餐数"` + AllocatedCount int `json:"allocated_count" description:"成功分配数量"` + SkippedCount int `json:"skipped_count" description:"跳过数量(已存在)"` + Allocations []ShopPackageAllocationTermsResponse `json:"allocations" description:"本次新建套餐分配及其生效条件"` +} + +// UpdateAllocationExpiryBaseRequest 修改套餐分配生效条件覆盖请求。 +type UpdateAllocationExpiryBaseRequest struct { + ExpiryBaseOverride *string `json:"expiry_base_override" nullable:"true" description:"分配生效条件覆盖 (null:跟随套餐默认值, from_activation:实名激活时生效, from_purchase:购买即生效)"` + ExpiryBaseOverrideSet bool `json:"-" description:"是否显式提交分配生效条件覆盖(内部解析字段)"` +} + +// ShopPackageAllocationTermsResponse 套餐分配计时条款响应。 +type ShopPackageAllocationTermsResponse struct { + ID uint `json:"id" description:"套餐分配ID"` + DefaultExpiryBase string `json:"default_expiry_base" description:"套餐默认生效条件 (from_activation:实名激活时生效, from_purchase:购买即生效)"` + DefaultExpiryBaseName string `json:"default_expiry_base_name" description:"套餐默认生效条件名称(中文)"` + ExpiryBaseOverride *string `json:"expiry_base_override" description:"分配生效条件覆盖,null 表示跟随套餐默认值"` + ExpiryBaseOverrideName string `json:"expiry_base_override_name" description:"分配生效条件覆盖名称(中文)"` + EffectiveExpiryBase string `json:"effective_expiry_base" description:"最终生效条件 (from_activation:实名激活时生效, from_purchase:购买即生效)"` + EffectiveExpiryBaseName string `json:"effective_expiry_base_name" description:"最终生效条件名称(中文)"` +} + +// UpdateAllocationExpiryBaseParams 修改套餐分配生效条件覆盖聚合参数。 +type UpdateAllocationExpiryBaseParams struct { + IDReq + UpdateAllocationExpiryBaseRequest } diff --git a/internal/model/dto/shop_series_grant_dto.go b/internal/model/dto/shop_series_grant_dto.go index 431e519..30d9acf 100644 --- a/internal/model/dto/shop_series_grant_dto.go +++ b/internal/model/dto/shop_series_grant_dto.go @@ -9,12 +9,18 @@ type GrantPackageItem struct { // ShopSeriesGrantPackageItem 授权套餐详情(响应中的套餐信息) type ShopSeriesGrantPackageItem struct { - PackageID uint `json:"package_id" description:"套餐ID"` - PackageName string `json:"package_name" description:"套餐名称"` - PackageCode string `json:"package_code" description:"套餐编码"` - CostPrice int64 `json:"cost_price" description:"成本价(分)"` - ShelfStatus int `json:"shelf_status" description:"上架状态 1-上架 2-下架"` - Status int `json:"status" description:"分配状态 0=禁用 1=启用"` + PackageID uint `json:"package_id" description:"套餐ID"` + PackageName string `json:"package_name" description:"套餐名称"` + PackageCode string `json:"package_code" description:"套餐编码"` + CostPrice int64 `json:"cost_price" description:"成本价(分)"` + ShelfStatus int `json:"shelf_status" description:"上架状态 1-上架 2-下架"` + Status int `json:"status" description:"分配状态 0=禁用 1=启用"` + DefaultExpiryBase string `json:"default_expiry_base" description:"套餐默认生效条件 (from_activation:实名激活时生效, from_purchase:购买即生效)"` + DefaultExpiryBaseName string `json:"default_expiry_base_name" description:"套餐默认生效条件名称(中文)"` + ExpiryBaseOverride *string `json:"expiry_base_override" description:"分配生效条件覆盖,null 表示跟随套餐默认值"` + ExpiryBaseOverrideName string `json:"expiry_base_override_name" description:"分配生效条件覆盖名称(中文)"` + EffectiveExpiryBase string `json:"effective_expiry_base" description:"最终生效条件 (from_activation:实名激活时生效, from_purchase:购买即生效)"` + EffectiveExpiryBaseName string `json:"effective_expiry_base_name" description:"最终生效条件名称(中文)"` } // GrantCommissionTierItem 梯度佣金档位(operator/dimension/stat_scope 仅出现在响应中,来自 PackageSeries 全局配置) @@ -57,6 +63,8 @@ type CreateShopSeriesGrantRequest struct { EnableForceRecharge *bool `json:"enable_force_recharge,omitempty" description:"是否启用代理强充"` ForceRechargeAmount *int64 `json:"force_recharge_amount,omitempty" description:"代理强充金额(分)"` Packages []GrantPackageItem `json:"packages,omitempty" description:"初始授权套餐列表"` + ExpiryBaseOverride *string `json:"expiry_base_override" nullable:"true" description:"分配生效条件覆盖 (null:跟随套餐默认值, from_activation:实名激活时生效, from_purchase:购买即生效)"` + ExpiryBaseOverrideSet bool `json:"-" description:"是否显式提交分配生效条件覆盖(内部解析字段)"` } // UpdateShopSeriesGrantRequest 更新系列授权请求 @@ -69,7 +77,9 @@ type UpdateShopSeriesGrantRequest struct { // ManageGrantPackagesRequest 管理授权套餐请求 type ManageGrantPackagesRequest struct { - Packages []GrantPackageItem `json:"packages" validate:"required,min=1" description:"套餐操作列表"` + Packages []GrantPackageItem `json:"packages" validate:"required,min=1" description:"套餐操作列表"` + ExpiryBaseOverride *string `json:"expiry_base_override" nullable:"true" description:"新增分配的生效条件覆盖 (null:跟随套餐默认值, from_activation:实名激活时生效, from_purchase:购买即生效)"` + ExpiryBaseOverrideSet bool `json:"-" description:"是否显式提交分配生效条件覆盖(内部解析字段)"` } // ShopSeriesGrantListRequest 系列授权列表查询请求 diff --git a/internal/model/package.go b/internal/model/package.go index 8c68655..000c3e4 100644 --- a/internal/model/package.go +++ b/internal/model/package.go @@ -75,6 +75,10 @@ type PackageUsage struct { VirtualTotalMBSnapshot int64 `gorm:"column:virtual_total_mb_snapshot;type:bigint;default:0;comment:虚总量快照(MB,业务停机阈值)" json:"virtual_total_mb_snapshot"` DisplayGainRatioSnapshot float64 `gorm:"column:display_gain_ratio_snapshot;type:decimal(18,6);default:1.0;comment:展示倍率快照(real_total_mb/virtual_total_mb)" json:"display_gain_ratio_snapshot"` EnableVirtualDataSnapshot bool `gorm:"column:enable_virtual_data_snapshot;type:boolean;default:false;comment:是否启用虚流量快照" json:"enable_virtual_data_snapshot"` + ExpiryBaseSnapshot string `gorm:"column:expiry_base_snapshot;type:varchar(30);not null;default:'';comment:购买时生效条件快照" json:"expiry_base_snapshot"` + CalendarTypeSnapshot string `gorm:"column:calendar_type_snapshot;type:varchar(20);not null;default:'';comment:购买时周期类型快照" json:"calendar_type_snapshot"` + DurationMonthsSnapshot int `gorm:"column:duration_months_snapshot;type:int;not null;default:0;comment:购买时月数快照" json:"duration_months_snapshot"` + DurationDaysSnapshot int `gorm:"column:duration_days_snapshot;type:int;not null;default:0;comment:购买时天数快照" json:"duration_days_snapshot"` ActivatedAt *time.Time `gorm:"column:activated_at;comment:套餐生效时间" json:"activated_at"` ExpiresAt *time.Time `gorm:"column:expires_at;comment:套餐过期时间" json:"expires_at"` Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 0-待生效 1-生效中 2-已用完 3-已过期 4-已失效" json:"status"` diff --git a/internal/model/shop_package_allocation.go b/internal/model/shop_package_allocation.go index 81b0b60..8d8ed1d 100644 --- a/internal/model/shop_package_allocation.go +++ b/internal/model/shop_package_allocation.go @@ -4,18 +4,21 @@ import ( "gorm.io/gorm" ) +// ShopPackageAllocation 店铺套餐分配模型 +// 记录套餐授权、价格及购买生效条件覆盖配置。 type ShopPackageAllocation struct { gorm.Model - BaseModel `gorm:"embedded"` - ShopID uint `gorm:"column:shop_id;index;not null;comment:被分配的店铺ID" json:"shop_id"` - PackageID uint `gorm:"column:package_id;index;not null;comment:套餐ID" json:"package_id"` - AllocatorShopID uint `gorm:"column:allocator_shop_id;index;not null;default:0;comment:分配者店铺ID,0表示平台分配" json:"allocator_shop_id"` - CostPrice int64 `gorm:"column:cost_price;type:bigint;not null;comment:该代理的成本价(分)" json:"cost_price"` - SeriesAllocationID *uint `gorm:"column:series_allocation_id;index;comment:关联的系列分配ID" json:"series_allocation_id"` - Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 0=禁用 1=启用" json:"status"` - ShelfStatus int `gorm:"column:shelf_status;type:int;default:1;not null;comment:上架状态 1-上架 2-下架" json:"shelf_status"` - RetailPrice int64 `gorm:"column:retail_price;type:bigint;not null;default:0;comment:代理面向终端客户的零售价(分)" json:"retail_price"` - RetailPriceConfigStatus int `gorm:"column:retail_price_config_status;type:int;default:0;not null;comment:零售价配置状态 0-未配置 1-赠送0价 2-已配置非0" json:"retail_price_config_status"` + BaseModel `gorm:"embedded"` + ShopID uint `gorm:"column:shop_id;index;not null;comment:被分配的店铺ID" json:"shop_id"` + PackageID uint `gorm:"column:package_id;index;not null;comment:套餐ID" json:"package_id"` + AllocatorShopID uint `gorm:"column:allocator_shop_id;index;not null;default:0;comment:分配者店铺ID,0表示平台分配" json:"allocator_shop_id"` + CostPrice int64 `gorm:"column:cost_price;type:bigint;not null;comment:该代理的成本价(分)" json:"cost_price"` + SeriesAllocationID *uint `gorm:"column:series_allocation_id;index;comment:关联的系列分配ID" json:"series_allocation_id"` + Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 0=禁用 1=启用" json:"status"` + ShelfStatus int `gorm:"column:shelf_status;type:int;default:1;not null;comment:上架状态 1-上架 2-下架" json:"shelf_status"` + RetailPrice int64 `gorm:"column:retail_price;type:bigint;not null;default:0;comment:代理面向终端客户的零售价(分)" json:"retail_price"` + RetailPriceConfigStatus int `gorm:"column:retail_price_config_status;type:int;default:0;not null;comment:零售价配置状态 0-未配置 1-赠送0价 2-已配置非0" json:"retail_price_config_status"` + ExpiryBaseOverride *string `gorm:"column:expiry_base_override;type:varchar(30);comment:到期时间基准覆盖,NULL表示跟随套餐默认值" json:"expiry_base_override"` } // TableName 指定表名 diff --git a/internal/routes/shop_package_batch_allocation.go b/internal/routes/shop_package_batch_allocation.go index 9492e42..4b2373f 100644 --- a/internal/routes/shop_package_batch_allocation.go +++ b/internal/routes/shop_package_batch_allocation.go @@ -16,7 +16,13 @@ func registerShopPackageBatchAllocationRoutes(router fiber.Router, handler *admi Summary: "批量分配套餐", Tags: []string{"批量套餐分配"}, Input: new(dto.BatchAllocatePackagesRequest), - Output: nil, + Output: new(dto.BatchAllocatePackagesResponse), Auth: true, }) + + Register(router, doc, basePath, "PATCH", "/shop-package-allocations/:id/expiry-base", handler.UpdateExpiryBase, RouteSpec{ + Summary: "修改套餐分配生效条件覆盖", + Tags: []string{"批量套餐分配"}, Input: new(dto.UpdateAllocationExpiryBaseParams), + Output: new(dto.ShopPackageAllocationTermsResponse), Auth: true, + }) } diff --git a/internal/service/order/service.go b/internal/service/order/service.go index 55ceee4..1f9c4a0 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -8,6 +8,7 @@ import ( "strings" "time" + packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package" @@ -2222,6 +2223,11 @@ func (s *Service) markOrderCreated(ctx context.Context, idempotencyKey string, o // activateMainPackage 任务 8.2-8.4: 主套餐激活逻辑 func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *model.Order, pkg *model.Package, carrierType string, carrierID uint, now time.Time) error { + terms, err := s.resolvePackageTerms(ctx, pkg, order.SellerShopID) + if err != nil { + s.logger.Error("生成套餐计时快照失败", zap.Uint("package_id", pkg.ID), zap.Error(err)) + return err + } if err := s.lockPackageCarrier(ctx, tx, carrierType, carrierID); err != nil { return err } @@ -2252,19 +2258,17 @@ func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *m priority = 1 activatedAt = now // 使用工具函数计算过期时间 - expiresAt = packagepkg.CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays) + expiresAt = packagepkg.CalculateExpiryTime(terms.CalendarType, activatedAt, terms.DurationMonths, terms.DurationDays) // 计算下次重置时间(基于套餐周期类型) - nextResetAt = packagepkg.CalculateNextResetTime(pkg.DataResetCycle, pkg.CalendarType, now, activatedAt) + nextResetAt = packagepkg.CalculateNextResetTime(pkg.DataResetCycle, terms.CalendarType, now, activatedAt) // REALNAME-02: 后台囤货场景三维决策(卡类型 + 购买路径 + expiry_base + 实名状态) // 查询卡类型(行业卡永远直接激活,不等实名) // 同时查询载体当前实名状态:已实名则跳过 pending,直接激活 - var cardCategory string var currentlyRealnamed bool if carrierType == "iot_card" { var card model.IotCard - if err := tx.Select("card_category", "real_name_status").First(&card, carrierID).Error; err == nil { - cardCategory = card.CardCategory + if err := tx.Select("real_name_status").First(&card, carrierID).Error; err == nil { currentlyRealnamed = card.RealNameStatus == constants.RealNameStatusVerified } // err != nil 时 currentlyRealnamed 保守默认 false,不阻断购买流程 @@ -2285,9 +2289,9 @@ func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *m // 判断是否 C 端购买(C 端购买前已做实名前置检查,直接激活) isCEndPurchase := order.BuyerType == model.BuyerTypePersonal - if cardCategory != "industry" && !isCEndPurchase { + if !isCEndPurchase { // 后台囤货路径:按 expiry_base 决定是否等实名 - if pkg.ExpiryBase != "from_purchase" { + if terms.ExpiryBase != constants.PackageExpiryBaseFromPurchase { if currentlyRealnamed { s.logger.Info("购买时载体已实名,直接激活套餐", zap.String("carrier_type", carrierType), @@ -2303,7 +2307,7 @@ func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *m } // from_purchase:立即激活,status 已为 Active,保持不变 } - // 行业卡或 C 端购买:直接激活,status 已为 Active,保持不变 + // C 端购买已完成实名前置校验,直接激活。 } // 创建套餐使用记录 @@ -2332,6 +2336,7 @@ func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *m PackagePriceConfigStatus: pkg.PriceConfigStatus, PackageIsGift: pkg.IsGift, } + terms.Apply(usage) if carrierType == "iot_card" { usage.IotCardID = carrierID @@ -2362,6 +2367,11 @@ func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *m // activateAddonPackage 任务 8.5-8.7: 加油包激活逻辑 func (s *Service) activateAddonPackage(ctx context.Context, tx *gorm.DB, order *model.Order, pkg *model.Package, carrierType string, carrierID uint, now time.Time) error { + terms, err := s.resolvePackageTerms(ctx, pkg, order.SellerShopID) + if err != nil { + s.logger.Error("生成加油包计时快照失败", zap.Uint("package_id", pkg.ID), zap.Error(err)) + return err + } // 任务 8.5-8.6: 检查是否有可挂靠主套餐(待生效、未过期的生效中或已用完) mainPackage, err := packagepkg.FindAttachableMainPackageForAddon(tx, carrierType, carrierID, now) if err == gorm.ErrRecordNotFound { @@ -2421,6 +2431,7 @@ func (s *Service) activateAddonPackage(ctx context.Context, tx *gorm.DB, order * PackagePriceConfigStatus: pkg.PriceConfigStatus, PackageIsGift: pkg.IsGift, } + terms.Apply(usage) if carrierType == "iot_card" { usage.IotCardID = carrierID @@ -2436,6 +2447,20 @@ func (s *Service) activateAddonPackage(ctx context.Context, tx *gorm.DB, order * return nil } +func (s *Service) resolvePackageTerms(ctx context.Context, pkg *model.Package, sellerShopID *uint) (packagedomain.TermsSnapshot, error) { + var allocation *model.ShopPackageAllocation + if sellerShopID != nil && *sellerShopID > 0 { + found, err := s.shopPackageAllocationStore.GetByShopAndPackageForSystem(ctx, *sellerShopID, pkg.ID) + if err != nil && err != gorm.ErrRecordNotFound { + return packagedomain.TermsSnapshot{}, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐分配失败") + } + if err == nil { + allocation = found + } + } + return packagedomain.ResolveTermsSnapshot(pkg, allocation) +} + func (s *Service) enqueueCommissionCalculation(ctx context.Context, orderID uint) { if s.queueClient == nil { s.logger.Warn("队列客户端未初始化,跳过佣金计算任务入队", zap.Uint("order_id", orderID)) diff --git a/internal/service/package/activation_service.go b/internal/service/package/activation_service.go index e6a1a90..dcbfa17 100644 --- a/internal/service/package/activation_service.go +++ b/internal/service/package/activation_service.go @@ -113,18 +113,22 @@ func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType } } - // REALNAME-04: 按 ExpiryBase 选择激活计时基准 + terms, termsErr := ResolveUsageTerms(usage, &pkg, s.logger) + if termsErr != nil { + return termsErr + } + // REALNAME-04: 按购买快照选择激活计时基准 // from_purchase:购买时起算,用 PackageUsage 创建时间;否则用实名触发当前时刻 var activatedAt time.Time - if pkg.ExpiryBase == "from_purchase" { + if terms.ExpiryBase == constants.PackageExpiryBaseFromPurchase { activatedAt = usage.CreatedAt } else { activatedAt = now } - expiresAt := CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays) + expiresAt := CalculateExpiryTime(terms.CalendarType, activatedAt, terms.DurationMonths, terms.DurationDays) // 计算下次重置时间 - nextResetAt := CalculateNextResetTime(pkg.DataResetCycle, pkg.CalendarType, now, activatedAt) + nextResetAt := CalculateNextResetTime(pkg.DataResetCycle, terms.CalendarType, now, activatedAt) // 更新套餐使用记录 updates := map[string]interface{}{ @@ -552,20 +556,24 @@ func (s *ActivationService) isCarrierRealnamed(ctx context.Context, tx *gorm.DB, } func (s *ActivationService) activatePendingUsage(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, pkg *model.Package, carrierType string, carrierID uint, now time.Time, logMessage string) error { + terms, err := ResolveUsageTerms(usage, pkg, s.logger) + if err != nil { + return err + } // ExpiryBase=from_purchase 只用于"等待实名激活"场景(REALNAME-04):套餐已购买但资产未实名, // 计时基准按购买时间算,实名只是解锁使用权。此函数同时被"前一个主套餐到期后排队顺延"场景复用, // 这种情况下 usage.PendingRealnameActivation 为 false,不应该套用购买时间,否则排队等待的天数会 // 从到期时间里被扣掉。只有当这条记录确实是因为等实名才被搁置时,才按 ExpiryBase 选基准。 var activatedAt time.Time - if usage.PendingRealnameActivation && pkg.ExpiryBase == "from_purchase" { + if usage.PendingRealnameActivation && terms.ExpiryBase == constants.PackageExpiryBaseFromPurchase { activatedAt = usage.CreatedAt } else { activatedAt = now } - expiresAt := CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays) + expiresAt := CalculateExpiryTime(terms.CalendarType, activatedAt, terms.DurationMonths, terms.DurationDays) // 计算下次重置时间 - nextResetAt := CalculateNextResetTime(pkg.DataResetCycle, pkg.CalendarType, now, activatedAt) + nextResetAt := CalculateNextResetTime(pkg.DataResetCycle, terms.CalendarType, now, activatedAt) // 更新套餐使用记录 updates := map[string]interface{}{ diff --git a/internal/service/package/service.go b/internal/service/package/service.go index 92d8154..998a7cf 100644 --- a/internal/service/package/service.go +++ b/internal/service/package/service.go @@ -101,18 +101,18 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePackageRequest) (*d } pkg := &model.Package{ - PackageCode: req.PackageCode, - PackageName: req.PackageName, - PackageType: req.PackageType, - IsGift: req.IsGift, - DurationMonths: req.DurationMonths, - CostPrice: req.CostPrice, - PriceConfigStatus: priceConfigStatus, + PackageCode: req.PackageCode, + PackageName: req.PackageName, + PackageType: req.PackageType, + IsGift: req.IsGift, + DurationMonths: req.DurationMonths, + CostPrice: req.CostPrice, + PriceConfigStatus: priceConfigStatus, SuggestedRetailPrice: storedRetailPrice, - EnableVirtualData: req.EnableVirtualData, - CalendarType: calendarType, - Status: constants.StatusEnabled, - ShelfStatus: 2, + EnableVirtualData: req.EnableVirtualData, + CalendarType: calendarType, + Status: constants.StatusEnabled, + ShelfStatus: 2, } if req.SeriesID != nil { pkg.SeriesID = *req.SeriesID @@ -607,29 +607,33 @@ func (s *Service) toResponse(ctx context.Context, pkg *model.Package) *dto.Packa } resp := &dto.PackageResponse{ - ID: pkg.ID, - PackageCode: pkg.PackageCode, - PackageName: pkg.PackageName, - SeriesID: seriesID, - PackageType: pkg.PackageType, - IsGift: pkg.IsGift, - DurationMonths: pkg.DurationMonths, - RealDataMB: pkg.RealDataMB, - VirtualDataMB: pkg.VirtualDataMB, - EnableVirtualData: pkg.EnableVirtualData, - VirtualRatio: calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB), - CostPrice: pkg.CostPrice, - SuggestedRetailPrice: packageprice.PackageRawSuggestedRetailPrice(pkg), - PriceConfigStatus: pkg.PriceConfigStatus, - PriceConfigStatusName: packagePriceConfigStatusName(pkg.PriceConfigStatus), - CalendarType: pkg.CalendarType, - DurationDays: durationDays, - DataResetCycle: pkg.DataResetCycle, - ExpiryBase: pkg.ExpiryBase, - Status: pkg.Status, - ShelfStatus: pkg.ShelfStatus, - CreatedAt: pkg.CreatedAt.Format(time.RFC3339), - UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339), + ID: pkg.ID, + PackageCode: pkg.PackageCode, + PackageName: pkg.PackageName, + SeriesID: seriesID, + PackageType: pkg.PackageType, + IsGift: pkg.IsGift, + DurationMonths: pkg.DurationMonths, + RealDataMB: pkg.RealDataMB, + VirtualDataMB: pkg.VirtualDataMB, + EnableVirtualData: pkg.EnableVirtualData, + VirtualRatio: calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB), + CostPrice: pkg.CostPrice, + SuggestedRetailPrice: packageprice.PackageRawSuggestedRetailPrice(pkg), + PriceConfigStatus: pkg.PriceConfigStatus, + PriceConfigStatusName: packagePriceConfigStatusName(pkg.PriceConfigStatus), + CalendarType: pkg.CalendarType, + DurationDays: durationDays, + DataResetCycle: pkg.DataResetCycle, + ExpiryBase: pkg.ExpiryBase, + DefaultExpiryBase: pkg.ExpiryBase, + DefaultExpiryBaseName: ExpiryBaseName(pkg.ExpiryBase), + EffectiveExpiryBase: pkg.ExpiryBase, + EffectiveExpiryBaseName: ExpiryBaseName(pkg.ExpiryBase), + Status: pkg.Status, + ShelfStatus: pkg.ShelfStatus, + CreatedAt: pkg.CreatedAt.Format(time.RFC3339), + UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339), } userType := middleware.GetUserTypeFromContext(ctx) @@ -646,6 +650,7 @@ func (s *Service) toResponse(ctx context.Context, pkg *model.Package) *dto.Packa profitMargin := effectiveRetailPrice - allocation.CostPrice resp.ProfitMargin = &profitMargin resp.ShelfStatus = allocation.ShelfStatus + applyAllocationExpiryBase(resp, pkg, allocation) } } else { effectiveRetailPrice := packageprice.PackageEffectiveRetailPrice(pkg) @@ -684,29 +689,33 @@ func (s *Service) toResponseWithAllocation(_ context.Context, pkg *model.Package } resp := &dto.PackageResponse{ - ID: pkg.ID, - PackageCode: pkg.PackageCode, - PackageName: pkg.PackageName, - SeriesID: seriesID, - PackageType: pkg.PackageType, - IsGift: pkg.IsGift, - DurationMonths: pkg.DurationMonths, - RealDataMB: pkg.RealDataMB, - VirtualDataMB: pkg.VirtualDataMB, - EnableVirtualData: pkg.EnableVirtualData, - VirtualRatio: calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB), - CostPrice: pkg.CostPrice, - SuggestedRetailPrice: packageprice.PackageRawSuggestedRetailPrice(pkg), - PriceConfigStatus: pkg.PriceConfigStatus, - PriceConfigStatusName: packagePriceConfigStatusName(pkg.PriceConfigStatus), - CalendarType: pkg.CalendarType, - DurationDays: durationDays, - DataResetCycle: pkg.DataResetCycle, - ExpiryBase: pkg.ExpiryBase, - Status: pkg.Status, - ShelfStatus: pkg.ShelfStatus, - CreatedAt: pkg.CreatedAt.Format(time.RFC3339), - UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339), + ID: pkg.ID, + PackageCode: pkg.PackageCode, + PackageName: pkg.PackageName, + SeriesID: seriesID, + PackageType: pkg.PackageType, + IsGift: pkg.IsGift, + DurationMonths: pkg.DurationMonths, + RealDataMB: pkg.RealDataMB, + VirtualDataMB: pkg.VirtualDataMB, + EnableVirtualData: pkg.EnableVirtualData, + VirtualRatio: calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB), + CostPrice: pkg.CostPrice, + SuggestedRetailPrice: packageprice.PackageRawSuggestedRetailPrice(pkg), + PriceConfigStatus: pkg.PriceConfigStatus, + PriceConfigStatusName: packagePriceConfigStatusName(pkg.PriceConfigStatus), + CalendarType: pkg.CalendarType, + DurationDays: durationDays, + DataResetCycle: pkg.DataResetCycle, + ExpiryBase: pkg.ExpiryBase, + DefaultExpiryBase: pkg.ExpiryBase, + DefaultExpiryBaseName: ExpiryBaseName(pkg.ExpiryBase), + EffectiveExpiryBase: pkg.ExpiryBase, + EffectiveExpiryBaseName: ExpiryBaseName(pkg.ExpiryBase), + Status: pkg.Status, + ShelfStatus: pkg.ShelfStatus, + CreatedAt: pkg.CreatedAt.Format(time.RFC3339), + UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339), } if allocationMap != nil { @@ -719,6 +728,7 @@ func (s *Service) toResponseWithAllocation(_ context.Context, pkg *model.Package resp.EffectiveRetailPrice = &effectiveRetailPrice profitMargin := effectiveRetailPrice - allocation.CostPrice resp.ProfitMargin = &profitMargin + applyAllocationExpiryBase(resp, pkg, allocation) resp.ShelfStatus = allocation.ShelfStatus } } else { @@ -734,6 +744,13 @@ func (s *Service) toResponseWithAllocation(_ context.Context, pkg *model.Package return resp } +func applyAllocationExpiryBase(resp *dto.PackageResponse, pkg *model.Package, allocation *model.ShopPackageAllocation) { + resp.ExpiryBaseOverride = allocation.ExpiryBaseOverride + resp.ExpiryBaseOverrideName = ExpiryBaseOverrideName(allocation.ExpiryBaseOverride) + resp.EffectiveExpiryBase = EffectiveExpiryBase(pkg, allocation) + resp.EffectiveExpiryBaseName = ExpiryBaseName(resp.EffectiveExpiryBase) +} + // fillCommissionInfo 填充返佣信息到响应中 func (s *Service) fillCommissionInfo(resp *dto.PackageResponse, seriesID uint, seriesAllocationMap map[uint]*model.ShopSeriesAllocation, seriesConfigMap map[uint]*model.OneTimeCommissionConfig) { seriesAllocation, hasAllocation := seriesAllocationMap[seriesID] diff --git a/internal/service/package/terms.go b/internal/service/package/terms.go new file mode 100644 index 0000000..e8c8a7d --- /dev/null +++ b/internal/service/package/terms.go @@ -0,0 +1,49 @@ +package packagepkg + +import ( + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// ValidateExpiryBaseOverride 校验显式提交的分配生效条件覆盖。 +func ValidateExpiryBaseOverride(value *string, submitted bool) (*string, error) { + if !submitted { + return nil, errors.New(errors.CodeInvalidParam) + } + if value == nil { + return nil, nil + } + if *value != constants.PackageExpiryBaseFromActivation && *value != constants.PackageExpiryBaseFromPurchase { + return nil, errors.New(errors.CodeInvalidParam) + } + return value, nil +} + +// EffectiveExpiryBase 返回套餐分配最终采用的生效条件。 +func EffectiveExpiryBase(pkg *model.Package, allocation *model.ShopPackageAllocation) string { + if allocation != nil && allocation.ExpiryBaseOverride != nil { + return *allocation.ExpiryBaseOverride + } + return pkg.ExpiryBase +} + +// ExpiryBaseName 返回生效条件中文名称。 +func ExpiryBaseName(value string) string { + switch value { + case constants.PackageExpiryBaseFromActivation: + return "实名激活时生效" + case constants.PackageExpiryBaseFromPurchase: + return "购买即生效" + default: + return "未知" + } +} + +// ExpiryBaseOverrideName 返回覆盖值中文名称,空值表示跟随套餐默认。 +func ExpiryBaseOverrideName(value *string) string { + if value == nil { + return "跟随套餐默认" + } + return ExpiryBaseName(*value) +} diff --git a/internal/service/package/terms_test.go b/internal/service/package/terms_test.go new file mode 100644 index 0000000..4440f5d --- /dev/null +++ b/internal/service/package/terms_test.go @@ -0,0 +1,57 @@ +package packagepkg + +import ( + "testing" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// TestValidateExpiryBaseOverride 验证覆盖字段必须显式提交且仅接受既定枚举。 +func TestValidateExpiryBaseOverride(t *testing.T) { + fromPurchase := constants.PackageExpiryBaseFromPurchase + invalid := "from_realname" + tests := []struct { + name string + value *string + submitted bool + wantValue *string + wantErr bool + }{ + {name: "字段缺失", wantErr: true}, + {name: "跟随默认", submitted: true}, + {name: "购买即生效", value: &fromPurchase, submitted: true, wantValue: &fromPurchase}, + {name: "非法枚举", value: &invalid, submitted: true, wantErr: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := ValidateExpiryBaseOverride(test.value, test.submitted) + if (err != nil) != test.wantErr { + t.Fatalf("错误状态不符合预期:%v", err) + } + if test.wantValue != nil && (got == nil || *got != *test.wantValue) { + t.Fatalf("覆盖值不符合预期:%v", got) + } + if test.wantValue == nil && !test.wantErr && got != nil { + t.Fatalf("期望跟随默认,实际为:%v", *got) + } + }) + } +} + +// TestEffectiveExpiryBase 验证覆盖优先于套餐默认值。 +func TestEffectiveExpiryBase(t *testing.T) { + pkg := &model.Package{ExpiryBase: constants.PackageExpiryBaseFromActivation} + if got := EffectiveExpiryBase(pkg, nil); got != constants.PackageExpiryBaseFromActivation { + t.Fatalf("无覆盖时应使用套餐默认值,实际为 %s", got) + } + override := constants.PackageExpiryBaseFromPurchase + allocation := &model.ShopPackageAllocation{ExpiryBaseOverride: &override} + if got := EffectiveExpiryBase(pkg, allocation); got != constants.PackageExpiryBaseFromPurchase { + t.Fatalf("有覆盖时应使用覆盖值,实际为 %s", got) + } + pkg.ExpiryBase = constants.PackageExpiryBaseFromPurchase + if got := EffectiveExpiryBase(pkg, allocation); got != constants.PackageExpiryBaseFromPurchase { + t.Fatalf("套餐默认值变化不应改变覆盖结果,实际为 %s", got) + } +} diff --git a/internal/service/package/usage_terms.go b/internal/service/package/usage_terms.go new file mode 100644 index 0000000..2657591 --- /dev/null +++ b/internal/service/package/usage_terms.go @@ -0,0 +1,47 @@ +package packagepkg + +import ( + "sync/atomic" + + packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "go.uber.org/zap" +) + +var historicalTermsFallbackCount atomic.Uint64 + +// ResolveUsageTerms 优先读取使用记录快照,仅对完整空快照的历史记录显式回退。 +func ResolveUsageTerms(usage *model.PackageUsage, pkg *model.Package, logger *zap.Logger) (packagedomain.TermsSnapshot, error) { + terms := packagedomain.TermsSnapshotFromUsage(usage) + if terms.IsValid() { + return terms, nil + } + if !isEmptyHistoricalTerms(usage) { + if logger != nil { + logger.Error("套餐使用记录计时快照异常", zap.Uint("package_usage_id", usage.ID), zap.Uint("package_id", usage.PackageID)) + } + return packagedomain.TermsSnapshot{}, errors.New(errors.CodeInternalError, "套餐使用记录计时快照异常") + } + fallback, err := packagedomain.ResolveTermsSnapshot(pkg, nil) + if err != nil { + return packagedomain.TermsSnapshot{}, err + } + historicalTermsFallbackCount.Add(1) + if logger != nil { + logger.Warn("历史套餐使用记录缺少计时快照,回退套餐当前配置", + zap.Uint("package_usage_id", usage.ID), zap.Uint("package_id", usage.PackageID), + zap.Uint64("historical_terms_fallback_count", historicalTermsFallbackCount.Load())) + } + return fallback, nil +} + +// HistoricalTermsFallbackCount 返回历史计时条款回退累计次数。 +func HistoricalTermsFallbackCount() uint64 { + return historicalTermsFallbackCount.Load() +} + +func isEmptyHistoricalTerms(usage *model.PackageUsage) bool { + return usage.ExpiryBaseSnapshot == "" && usage.CalendarTypeSnapshot == "" && + usage.DurationMonthsSnapshot == 0 && usage.DurationDaysSnapshot == 0 +} diff --git a/internal/service/package/usage_terms_test.go b/internal/service/package/usage_terms_test.go new file mode 100644 index 0000000..1ca0312 --- /dev/null +++ b/internal/service/package/usage_terms_test.go @@ -0,0 +1,59 @@ +package packagepkg + +import ( + "testing" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "go.uber.org/zap" +) + +// TestResolveUsageTerms 验证快照优先、历史完整空值回退和部分缺失拒绝。 +func TestResolveUsageTerms(t *testing.T) { + pkg := &model.Package{ + ExpiryBase: constants.PackageExpiryBaseFromActivation, + CalendarType: constants.PackageCalendarTypeByDay, DurationDays: 30, + } + usage := &model.PackageUsage{ + ExpiryBaseSnapshot: constants.PackageExpiryBaseFromPurchase, + CalendarTypeSnapshot: constants.PackageCalendarTypeNaturalMonth, + DurationMonthsSnapshot: 12, + } + terms, err := ResolveUsageTerms(usage, pkg, zap.NewNop()) + if err != nil || terms.ExpiryBase != constants.PackageExpiryBaseFromPurchase || terms.DurationMonths != 12 { + t.Fatalf("应优先读取不可变快照:%+v, %v", terms, err) + } + + before := HistoricalTermsFallbackCount() + historical := &model.PackageUsage{Model: usage.Model} + terms, err = ResolveUsageTerms(historical, pkg, zap.NewNop()) + if err != nil || terms.DurationDays != 30 || HistoricalTermsFallbackCount() != before+1 { + t.Fatalf("历史空快照应可观测回退:%+v, %v", terms, err) + } + + broken := &model.PackageUsage{ExpiryBaseSnapshot: constants.PackageExpiryBaseFromPurchase} + if _, err = ResolveUsageTerms(broken, pkg, zap.NewNop()); err == nil { + t.Fatal("部分缺失快照必须拒绝") + } +} + +// TestResolveUsageTermsKeepsPurchasedTerms 验证购买后修改套餐配置不改变已有使用记录语义。 +func TestResolveUsageTermsKeepsPurchasedTerms(t *testing.T) { + usage := &model.PackageUsage{ + ExpiryBaseSnapshot: constants.PackageExpiryBaseFromPurchase, + CalendarTypeSnapshot: constants.PackageCalendarTypeByDay, + DurationDaysSnapshot: 90, + } + pkg := &model.Package{ + ExpiryBase: constants.PackageExpiryBaseFromActivation, + CalendarType: constants.PackageCalendarTypeNaturalMonth, + DurationMonths: 1, + } + terms, err := ResolveUsageTerms(usage, pkg, zap.NewNop()) + if err != nil { + t.Fatalf("读取购买快照失败:%v", err) + } + if terms.ExpiryBase != constants.PackageExpiryBaseFromPurchase || terms.CalendarType != constants.PackageCalendarTypeByDay || terms.DurationDays != 90 { + t.Fatalf("套餐当前配置不应覆盖购买快照:%+v", terms) + } +} diff --git a/internal/service/shop_package_batch_allocation/service.go b/internal/service/shop_package_batch_allocation/service.go index 1d3eff6..b0359b1 100644 --- a/internal/service/shop_package_batch_allocation/service.go +++ b/internal/service/shop_package_batch_allocation/service.go @@ -2,9 +2,11 @@ package shop_package_batch_allocation import ( "context" + "fmt" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" + packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package" "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" @@ -18,6 +20,12 @@ type Service struct { packageAllocationStore *postgres.ShopPackageAllocationStore seriesAllocationStore *postgres.ShopSeriesAllocationStore shopStore *postgres.ShopStore + auditService AuditService +} + +// AuditService 敏感配置变更审计能力。 +type AuditService interface { + LogOperation(ctx context.Context, log *model.AccountOperationLog) } func New( @@ -26,6 +34,7 @@ func New( packageAllocationStore *postgres.ShopPackageAllocationStore, seriesAllocationStore *postgres.ShopSeriesAllocationStore, shopStore *postgres.ShopStore, + auditService AuditService, ) *Service { return &Service{ db: db, @@ -33,61 +42,123 @@ func New( packageAllocationStore: packageAllocationStore, seriesAllocationStore: seriesAllocationStore, shopStore: shopStore, + auditService: auditService, } } -func (s *Service) BatchAllocate(ctx context.Context, req *dto.BatchAllocatePackagesRequest) error { +// UpdateExpiryBase 修改单条套餐分配的生效条件覆盖。 +func (s *Service) UpdateExpiryBase(ctx context.Context, id uint, req *dto.UpdateAllocationExpiryBaseRequest) (*dto.ShopPackageAllocationTermsResponse, error) { + override, err := packagepkg.ValidateExpiryBaseOverride(req.ExpiryBaseOverride, req.ExpiryBaseOverrideSet) + if err != nil { + return nil, err + } + allocation, err := s.packageAllocationStore.GetByID(ctx, id) + if err != nil { + return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") + } + pkg, err := s.packageStore.GetByID(ctx, allocation.PackageID) + if err != nil { + return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") + } + before := allocation.ExpiryBaseOverride + if !sameNullableString(before, override) { + allocation.ExpiryBaseOverride = override + allocation.Updater = middleware.GetUserIDFromContext(ctx) + if err := s.packageAllocationStore.Update(ctx, allocation); err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新套餐分配生效条件失败") + } + s.logExpiryBaseAudit(ctx, allocation, before, override) + } + return buildTermsResponse(pkg, allocation), nil +} + +func buildTermsResponse(pkg *model.Package, allocation *model.ShopPackageAllocation) *dto.ShopPackageAllocationTermsResponse { + effective := packagepkg.EffectiveExpiryBase(pkg, allocation) + return &dto.ShopPackageAllocationTermsResponse{ + ID: allocation.ID, DefaultExpiryBase: pkg.ExpiryBase, + DefaultExpiryBaseName: packagepkg.ExpiryBaseName(pkg.ExpiryBase), + ExpiryBaseOverride: allocation.ExpiryBaseOverride, + ExpiryBaseOverrideName: packagepkg.ExpiryBaseOverrideName(allocation.ExpiryBaseOverride), + EffectiveExpiryBase: effective, EffectiveExpiryBaseName: packagepkg.ExpiryBaseName(effective), + } +} + +func sameNullableString(left, right *string) bool { + return left == nil && right == nil || left != nil && right != nil && *left == *right +} + +func (s *Service) logExpiryBaseAudit(ctx context.Context, allocation *model.ShopPackageAllocation, before, after *string) { + if s.auditService == nil { + return + } + s.auditService.LogOperation(ctx, &model.AccountOperationLog{ + OperatorID: middleware.GetUserIDFromContext(ctx), OperatorType: middleware.GetUserTypeFromContext(ctx), + OperatorName: middleware.GetUsernameFromContext(ctx), OperationType: "update_package_expiry_base", + OperationDesc: fmt.Sprintf("修改套餐分配生效条件覆盖: %d", allocation.ID), + BeforeData: model.JSONB{"allocation_id": allocation.ID, "expiry_base_override": before}, + AfterData: model.JSONB{"allocation_id": allocation.ID, "expiry_base_override": after}, + RequestID: middleware.GetRequestIDFromContext(ctx), IPAddress: middleware.GetIPFromContext(ctx), UserAgent: middleware.GetUserAgentFromContext(ctx), + }) +} + +func (s *Service) BatchAllocate(ctx context.Context, req *dto.BatchAllocatePackagesRequest) (*dto.BatchAllocatePackagesResponse, error) { + expiryBaseOverride, err := packagepkg.ValidateExpiryBaseOverride(req.ExpiryBaseOverride, req.ExpiryBaseOverrideSet) + if err != nil { + return nil, err + } currentUserID := middleware.GetUserIDFromContext(ctx) if currentUserID == 0 { - return errors.New(errors.CodeUnauthorized, "未授权访问") + return nil, errors.New(errors.CodeUnauthorized, "未授权访问") } userType := middleware.GetUserTypeFromContext(ctx) allocatorShopID := middleware.GetShopIDFromContext(ctx) if userType == constants.UserTypeAgent && allocatorShopID == 0 { - return errors.New(errors.CodeUnauthorized, "当前用户不属于任何店铺") + return nil, errors.New(errors.CodeUnauthorized, "当前用户不属于任何店铺") } targetShop, err := s.shopStore.GetByID(ctx, req.ShopID) if err != nil { if err == gorm.ErrRecordNotFound { - return errors.New(errors.CodeNotFound, "目标店铺不存在") + return nil, errors.New(errors.CodeNotFound, "目标店铺不存在") } - return errors.Wrap(errors.CodeInternalError, err, "获取目标店铺失败") + return nil, errors.Wrap(errors.CodeInternalError, err, "获取目标店铺失败") } if userType == constants.UserTypeAgent { if targetShop.ParentID == nil || *targetShop.ParentID != allocatorShopID { - return errors.New(errors.CodeForbidden, "只能分配给直属下级店铺") + return nil, errors.New(errors.CodeForbidden, "只能分配给直属下级店铺") } } packages, err := s.getEnabledPackagesBySeries(ctx, req.SeriesID) if err != nil { - return err + return nil, err } if len(packages) == 0 { - return errors.New(errors.CodeInvalidParam, "该系列下没有启用的套餐") + return nil, errors.New(errors.CodeInvalidParam, "该系列下没有启用的套餐") } // 检查目标店铺是否有该系列的分配 seriesAllocation, err := s.seriesAllocationStore.GetByShopAndSeries(ctx, req.ShopID, req.SeriesID) if err != nil { if err == gorm.ErrRecordNotFound { - return errors.New(errors.CodeInvalidParam, "目标店铺没有该系列的分配权限") + return nil, errors.New(errors.CodeInvalidParam, "目标店铺没有该系列的分配权限") } - return errors.Wrap(errors.CodeInternalError, err, "查询系列分配失败") + return nil, errors.Wrap(errors.CodeInternalError, err, "查询系列分配失败") } - return s.db.Transaction(func(tx *gorm.DB) error { + result := &dto.BatchAllocatePackagesResponse{TotalPackages: len(packages), Allocations: []dto.ShopPackageAllocationTermsResponse{}} + err = s.db.Transaction(func(tx *gorm.DB) error { txPkgAllocStore := postgres.NewShopPackageAllocationStore(tx) for _, pkg := range packages { // 已存在该套餐的分配记录则跳过,避免唯一约束冲突 _, existErr := txPkgAllocStore.GetByShopAndPackageForSystem(ctx, req.ShopID, pkg.ID) if existErr == nil { + result.SkippedCount++ continue } @@ -97,23 +168,30 @@ func (s *Service) BatchAllocate(ctx context.Context, req *dto.BatchAllocatePacka } allocation := &model.ShopPackageAllocation{ - BaseModel: model.BaseModel{Creator: currentUserID, Updater: currentUserID}, - ShopID: req.ShopID, - PackageID: pkg.ID, - AllocatorShopID: allocatorShopID, - CostPrice: costPrice, - RetailPrice: pkg.SuggestedRetailPrice, + BaseModel: model.BaseModel{Creator: currentUserID, Updater: currentUserID}, + ShopID: req.ShopID, + PackageID: pkg.ID, + AllocatorShopID: allocatorShopID, + CostPrice: costPrice, + RetailPrice: pkg.SuggestedRetailPrice, RetailPriceConfigStatus: pkg.PriceConfigStatus, - SeriesAllocationID: &seriesAllocation.ID, - Status: constants.StatusEnabled, + SeriesAllocationID: &seriesAllocation.ID, + ExpiryBaseOverride: expiryBaseOverride, + Status: constants.StatusEnabled, } if err := tx.Create(allocation).Error; err != nil { return errors.Wrap(errors.CodeInternalError, err, "创建套餐分配失败") } + result.Allocations = append(result.Allocations, *buildTermsResponse(pkg, allocation)) } return nil }) + if err != nil { + return nil, err + } + result.AllocatedCount = len(result.Allocations) + return result, nil } func (s *Service) getEnabledPackagesBySeries(ctx context.Context, seriesID uint) ([]*model.Package, error) { diff --git a/internal/service/shop_series_grant/service.go b/internal/service/shop_series_grant/service.go index 29fdbe4..5fc5452 100644 --- a/internal/service/shop_series_grant/service.go +++ b/internal/service/shop_series_grant/service.go @@ -8,6 +8,7 @@ import ( "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" + packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package" "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" @@ -185,12 +186,18 @@ func (s *Service) buildGrantResponse(ctx context.Context, allocation *model.Shop continue } packages = append(packages, dto.ShopSeriesGrantPackageItem{ - PackageID: pa.PackageID, - PackageName: pkg.PackageName, - PackageCode: pkg.PackageCode, - CostPrice: pa.CostPrice, - ShelfStatus: pa.ShelfStatus, - Status: pa.Status, + PackageID: pa.PackageID, + PackageName: pkg.PackageName, + PackageCode: pkg.PackageCode, + CostPrice: pa.CostPrice, + ShelfStatus: pa.ShelfStatus, + Status: pa.Status, + DefaultExpiryBase: pkg.ExpiryBase, + DefaultExpiryBaseName: packagepkg.ExpiryBaseName(pkg.ExpiryBase), + ExpiryBaseOverride: pa.ExpiryBaseOverride, + ExpiryBaseOverrideName: packagepkg.ExpiryBaseOverrideName(pa.ExpiryBaseOverride), + EffectiveExpiryBase: packagepkg.EffectiveExpiryBase(pkg, pa), + EffectiveExpiryBaseName: packagepkg.ExpiryBaseName(packagepkg.EffectiveExpiryBase(pkg, pa)), }) } resp.Packages = packages @@ -201,6 +208,10 @@ func (s *Service) buildGrantResponse(ctx context.Context, allocation *model.Shop // Create 创建系列授权 // POST /api/admin/shop-series-grants func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequest) (*dto.ShopSeriesGrantResponse, error) { + expiryBaseOverride, err := packagepkg.ValidateExpiryBaseOverride(req.ExpiryBaseOverride, req.ExpiryBaseOverrideSet) + if err != nil { + return nil, err + } operatorID := middleware.GetUserIDFromContext(ctx) operatorShopID := middleware.GetShopIDFromContext(ctx) operatorType := middleware.GetUserTypeFromContext(ctx) @@ -339,15 +350,16 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ } } pkgAlloc := &model.ShopPackageAllocation{ - ShopID: req.ShopID, - PackageID: item.PackageID, - AllocatorShopID: allocatorShopID, - CostPrice: item.CostPrice, - RetailPrice: pkg.SuggestedRetailPrice, + ShopID: req.ShopID, + PackageID: item.PackageID, + AllocatorShopID: allocatorShopID, + CostPrice: item.CostPrice, + RetailPrice: pkg.SuggestedRetailPrice, RetailPriceConfigStatus: pkg.PriceConfigStatus, - SeriesAllocationID: &allocation.ID, - Status: constants.StatusEnabled, - ShelfStatus: constants.ShelfStatusOn, + SeriesAllocationID: &allocation.ID, + ExpiryBaseOverride: expiryBaseOverride, + Status: constants.StatusEnabled, + ShelfStatus: constants.ShelfStatusOn, } pkgAlloc.Creator = operatorID pkgAlloc.Updater = operatorID @@ -600,6 +612,10 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateShopSeries // ManagePackages 管理授权套餐(新增/更新/删除) // PUT /api/admin/shop-series-grants/:id/packages func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGrantPackagesRequest) (*dto.ShopSeriesGrantResponse, error) { + expiryBaseOverride, err := packagepkg.ValidateExpiryBaseOverride(req.ExpiryBaseOverride, req.ExpiryBaseOverrideSet) + if err != nil { + return nil, err + } operatorID := middleware.GetUserIDFromContext(ctx) operatorShopID := middleware.GetShopIDFromContext(ctx) @@ -677,15 +693,16 @@ func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGr } } pkgAlloc := &model.ShopPackageAllocation{ - ShopID: allocation.ShopID, - PackageID: item.PackageID, - AllocatorShopID: allocation.AllocatorShopID, - CostPrice: item.CostPrice, - RetailPrice: pkg.SuggestedRetailPrice, + ShopID: allocation.ShopID, + PackageID: item.PackageID, + AllocatorShopID: allocation.AllocatorShopID, + CostPrice: item.CostPrice, + RetailPrice: pkg.SuggestedRetailPrice, RetailPriceConfigStatus: pkg.PriceConfigStatus, - SeriesAllocationID: &allocation.ID, - Status: constants.StatusEnabled, - ShelfStatus: constants.ShelfStatusOn, + SeriesAllocationID: &allocation.ID, + ExpiryBaseOverride: expiryBaseOverride, + Status: constants.StatusEnabled, + ShelfStatus: constants.ShelfStatusOn, } pkgAlloc.Creator = operatorID pkgAlloc.Updater = operatorID diff --git a/internal/store/postgres/package_terms_snapshot_integration_test.go b/internal/store/postgres/package_terms_snapshot_integration_test.go new file mode 100644 index 0000000..53fe0ae --- /dev/null +++ b/internal/store/postgres/package_terms_snapshot_integration_test.go @@ -0,0 +1,54 @@ +package postgres + +import ( + "testing" + "time" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/testutil" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// TestPackageUsageRejectsNewEmptyTermsSnapshot 验证迁移约束拒绝上线后新增空计时快照。 +func TestPackageUsageRejectsNewEmptyTermsSnapshot(t *testing.T) { + tx := testutil.NewPostgresTransaction(t) + usage := newTermsSnapshotTestUsage() + if err := tx.Create(usage).Error; err == nil { + t.Fatal("新增套餐使用记录缺少计时快照时必须被数据库拒绝") + } +} + +// TestPackageUsageAcceptsCompleteTermsSnapshot 验证完整计时快照可与使用记录一次性落库。 +func TestPackageUsageAcceptsCompleteTermsSnapshot(t *testing.T) { + tx := testutil.NewPostgresTransaction(t) + usage := newTermsSnapshotTestUsage() + usage.ExpiryBaseSnapshot = constants.PackageExpiryBaseFromPurchase + usage.CalendarTypeSnapshot = constants.PackageCalendarTypeByDay + usage.DurationDaysSnapshot = 30 + if err := tx.Create(usage).Error; err != nil { + t.Fatalf("完整计时快照应可落库:%v", err) + } +} + +// TestHistoricalPackageUsageWithoutTermsSnapshotCanUpdateStatus 验证历史空快照可继续状态流转。 +func TestHistoricalPackageUsageWithoutTermsSnapshotCanUpdateStatus(t *testing.T) { + tx := testutil.NewPostgresTransaction(t) + var usage model.PackageUsage + if err := tx.Where("expiry_base_snapshot = '' AND calendar_type_snapshot = '' AND duration_months_snapshot = 0 AND duration_days_snapshot = 0"). + First(&usage).Error; err != nil { + t.Skip("当前开发库没有历史空快照记录") + } + if err := tx.Model(&usage).Update("status", usage.Status).Error; err != nil { + t.Fatalf("历史空快照记录应可继续更新状态:%v", err) + } +} + +func newTermsSnapshotTestUsage() *model.PackageUsage { + unique := uint(time.Now().UnixNano() & 0x7fffffff) + return &model.PackageUsage{ + OrderID: unique, OrderNo: "UR55-SNAPSHOT-TEST", PackageID: unique, + UsageType: constants.AssetWalletResourceTypeIotCard, IotCardID: unique, + DataLimitMB: 1, Status: constants.PackageUsageStatusPending, + Priority: 1, PackageName: "UR55测试套餐", Generation: 1, + } +} diff --git a/internal/task/auto_purchase.go b/internal/task/auto_purchase.go index 171a28b..95e15e8 100644 --- a/internal/task/auto_purchase.go +++ b/internal/task/auto_purchase.go @@ -12,11 +12,13 @@ import ( "gorm.io/gorm" "gorm.io/gorm/clause" + packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package" "github.com/break/junhong_cmp_fiber/internal/model" packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package" "github.com/break/junhong_cmp_fiber/internal/service/packageprice" "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/constants" + pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors" ) // AutoPurchasePayload 充值后自动购包任务载荷 @@ -496,7 +498,11 @@ func (h *AutoPurchaseHandler) activateMainPackage( carrierID uint, now time.Time, ) error { - _ = ctx + terms, err := h.resolvePackageTerms(ctx, pkg, order.SellerShopID) + if err != nil { + h.logger.Error("自动购包生成套餐计时快照失败", zap.Uint("package_id", pkg.ID), zap.Error(err)) + return err + } if err := h.lockPackageCarrier(ctx, tx, carrierType, carrierID); err != nil { return err } @@ -512,6 +518,13 @@ func (h *AutoPurchaseHandler) activateMainPackage( var expiresAt time.Time var nextResetAt *time.Time var pendingRealnameActivation bool + if terms.ExpiryBase == constants.PackageExpiryBaseFromActivation { + realnamed, realnameErr := h.isCarrierRealnamed(ctx, tx, carrierType, carrierID) + if realnameErr != nil { + return realnameErr + } + pendingRealnameActivation = !realnamed + } if hasCurrentMain { status = constants.PackageUsageStatusPending @@ -522,16 +535,17 @@ func (h *AutoPurchaseHandler) activateMainPackage( Scan(&maxPriority) priority = maxPriority + 1 } else { - status = constants.PackageUsageStatusActive priority = 1 - activatedAt = now - expiresAt = packagepkg.CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays) - nextResetAt = packagepkg.CalculateNextResetTime(pkg.DataResetCycle, pkg.CalendarType, now, activatedAt) + if pendingRealnameActivation { + status = constants.PackageUsageStatusPending + } else { + status = constants.PackageUsageStatusActive + activatedAt = now + expiresAt = packagepkg.CalculateExpiryTime(terms.CalendarType, activatedAt, terms.DurationMonths, terms.DurationDays) + nextResetAt = packagepkg.CalculateNextResetTime(pkg.DataResetCycle, terms.CalendarType, now, activatedAt) + } } - // REALNAME-02: 自动购包属于 C 端充值触发,不需要等实名(C 端已做前置实名检查) - // ExpiryBase 仅影响后台囤货路径,此处无需判断 - virtualTotalMBSnapshot, displayGainRatioSnapshot, enableVirtualDataSnapshot := model.BuildPackageUsageSnapshotValues(pkg) retailAmount := order.TotalAmount usage := &model.PackageUsage{ @@ -558,6 +572,7 @@ func (h *AutoPurchaseHandler) activateMainPackage( PackagePriceConfigStatus: pkg.PriceConfigStatus, PackageIsGift: pkg.IsGift, } + terms.Apply(usage) if carrierType == constants.AssetWalletResourceTypeIotCard { usage.IotCardID = carrierID @@ -581,6 +596,30 @@ func (h *AutoPurchaseHandler) activateMainPackage( }).Error } +// isCarrierRealnamed 查询自动购包载体是否已满足实名激活条件。 +func (h *AutoPurchaseHandler) isCarrierRealnamed(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (bool, error) { + switch carrierType { + case constants.AssetWalletResourceTypeIotCard, "card": + var card model.IotCard + if err := tx.WithContext(ctx).Select("real_name_status").First(&card, carrierID).Error; err != nil { + return false, err + } + return card.RealNameStatus == constants.RealNameStatusVerified, nil + case constants.AssetWalletResourceTypeDevice: + var count int64 + subQuery := tx.WithContext(ctx).Model(&model.DeviceSimBinding{}). + Select("iot_card_id").Where("device_id = ? AND bind_status = ?", carrierID, constants.BindStatusBound) + if err := tx.WithContext(ctx).Model(&model.IotCard{}). + Where("id IN (?) AND real_name_status = ?", subQuery, constants.RealNameStatusVerified). + Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil + default: + return false, pkgerrors.New(pkgerrors.CodeInvalidParam, "无效的套餐载体类型") + } +} + func (h *AutoPurchaseHandler) lockPackageCarrier(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) error { switch carrierType { case constants.AssetWalletResourceTypeIotCard, "card": @@ -603,7 +642,11 @@ func (h *AutoPurchaseHandler) activateAddonPackage( carrierID uint, now time.Time, ) error { - _ = ctx + terms, err := h.resolvePackageTerms(ctx, pkg, order.SellerShopID) + if err != nil { + h.logger.Error("自动购包生成加油包计时快照失败", zap.Uint("package_id", pkg.ID), zap.Error(err)) + return err + } mainPackage, err := packagepkg.FindAttachableMainPackageForAddon(tx, carrierType, carrierID, now) if err == gorm.ErrRecordNotFound { return errors.New("必须有主套餐才能购买加油包") @@ -649,6 +692,7 @@ func (h *AutoPurchaseHandler) activateAddonPackage( PackagePriceConfigStatus: pkg.PriceConfigStatus, PackageIsGift: pkg.IsGift, } + terms.Apply(usage) if carrierType == constants.AssetWalletResourceTypeIotCard { usage.IotCardID = carrierID @@ -659,6 +703,20 @@ func (h *AutoPurchaseHandler) activateAddonPackage( return tx.Create(usage).Error } +func (h *AutoPurchaseHandler) resolvePackageTerms(ctx context.Context, pkg *model.Package, sellerShopID *uint) (packagedomain.TermsSnapshot, error) { + var allocation *model.ShopPackageAllocation + if sellerShopID != nil && *sellerShopID > 0 { + found, err := h.shopPackageAllocationStore.GetByShopAndPackageForSystem(ctx, *sellerShopID, pkg.ID) + if err != nil && err != gorm.ErrRecordNotFound { + return packagedomain.TermsSnapshot{}, err + } + if err == nil { + allocation = found + } + } + return packagedomain.ResolveTermsSnapshot(pkg, allocation) +} + func parseLinkedPackageIDs(raw []byte) ([]uint, error) { var packageIDs []uint if len(raw) == 0 { diff --git a/migrations/000162_add_expiry_base_override_to_shop_package_allocation.down.sql b/migrations/000162_add_expiry_base_override_to_shop_package_allocation.down.sql new file mode 100644 index 0000000..10952c4 --- /dev/null +++ b/migrations/000162_add_expiry_base_override_to_shop_package_allocation.down.sql @@ -0,0 +1,6 @@ +-- 回滚套餐分配生效条件覆盖字段及其枚举约束。 +ALTER TABLE tb_shop_package_allocation + DROP CONSTRAINT IF EXISTS chk_shop_package_allocation_expiry_base_override; + +ALTER TABLE tb_shop_package_allocation + DROP COLUMN IF EXISTS expiry_base_override; diff --git a/migrations/000162_add_expiry_base_override_to_shop_package_allocation.up.sql b/migrations/000162_add_expiry_base_override_to_shop_package_allocation.up.sql new file mode 100644 index 0000000..8fd7e32 --- /dev/null +++ b/migrations/000162_add_expiry_base_override_to_shop_package_allocation.up.sql @@ -0,0 +1,9 @@ +-- 为套餐分配增加可选生效条件覆盖,NULL 表示跟随套餐默认值。 +ALTER TABLE tb_shop_package_allocation + ADD COLUMN expiry_base_override VARCHAR(30); + +COMMENT ON COLUMN tb_shop_package_allocation.expiry_base_override IS '到期时间基准覆盖,NULL表示跟随套餐默认值'; + +ALTER TABLE tb_shop_package_allocation + ADD CONSTRAINT chk_shop_package_allocation_expiry_base_override + CHECK (expiry_base_override IS NULL OR expiry_base_override IN ('from_purchase', 'from_activation')); diff --git a/migrations/000163_add_package_usage_terms_snapshots.down.sql b/migrations/000163_add_package_usage_terms_snapshots.down.sql new file mode 100644 index 0000000..045e6ed --- /dev/null +++ b/migrations/000163_add_package_usage_terms_snapshots.down.sql @@ -0,0 +1,9 @@ +-- 回滚套餐使用记录的购买计时条款快照字段。 +DROP TRIGGER IF EXISTS trg_validate_package_usage_terms_snapshot ON tb_package_usage; +DROP FUNCTION IF EXISTS validate_package_usage_terms_snapshot(); + +ALTER TABLE tb_package_usage + DROP COLUMN IF EXISTS duration_days_snapshot, + DROP COLUMN IF EXISTS duration_months_snapshot, + DROP COLUMN IF EXISTS calendar_type_snapshot, + DROP COLUMN IF EXISTS expiry_base_snapshot; diff --git a/migrations/000163_add_package_usage_terms_snapshots.up.sql b/migrations/000163_add_package_usage_terms_snapshots.up.sql new file mode 100644 index 0000000..68e2d8b --- /dev/null +++ b/migrations/000163_add_package_usage_terms_snapshots.up.sql @@ -0,0 +1,32 @@ +-- 为套餐使用记录增加购买时不可变计时条款快照;空值和零值保留为历史未快照标记。 +ALTER TABLE tb_package_usage + ADD COLUMN expiry_base_snapshot VARCHAR(30) NOT NULL DEFAULT '', + ADD COLUMN calendar_type_snapshot VARCHAR(20) NOT NULL DEFAULT '', + ADD COLUMN duration_months_snapshot INTEGER NOT NULL DEFAULT 0, + ADD COLUMN duration_days_snapshot INTEGER NOT NULL DEFAULT 0; + +COMMENT ON COLUMN tb_package_usage.expiry_base_snapshot IS '购买时生效条件快照,空字符串表示历史未快照'; +COMMENT ON COLUMN tb_package_usage.calendar_type_snapshot IS '购买时周期类型快照,空字符串表示历史未快照'; +COMMENT ON COLUMN tb_package_usage.duration_months_snapshot IS '购买时月数快照,0表示未使用或历史未快照'; +COMMENT ON COLUMN tb_package_usage.duration_days_snapshot IS '购买时天数快照,0表示未使用或历史未快照'; + +-- 触发器只校验新记录及快照字段变更,历史空快照仍可更新状态等非快照字段。 +CREATE OR REPLACE FUNCTION validate_package_usage_terms_snapshot() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.expiry_base_snapshot NOT IN ('from_purchase', 'from_activation') + OR NOT ( + (NEW.calendar_type_snapshot = 'natural_month' AND NEW.duration_months_snapshot > 0 AND NEW.duration_days_snapshot = 0) + OR (NEW.calendar_type_snapshot = 'by_day' AND NEW.duration_days_snapshot > 0 AND NEW.duration_months_snapshot = 0) + ) THEN + RAISE EXCEPTION '套餐使用记录计时快照不完整或无效'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_validate_package_usage_terms_snapshot +BEFORE INSERT OR UPDATE OF expiry_base_snapshot, calendar_type_snapshot, duration_months_snapshot, duration_days_snapshot +ON tb_package_usage +FOR EACH ROW +EXECUTE FUNCTION validate_package_usage_terms_snapshot(); diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 3caa2a6..160aa89 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -143,6 +143,12 @@ const ( PackageCalendarTypeByDay = "by_day" // 按天周期 ) +// 套餐生效条件常量 +const ( + PackageExpiryBaseFromActivation = "from_activation" // 实名激活时生效 + PackageExpiryBaseFromPurchase = "from_purchase" // 购买即生效 +) + // 套餐流量重置周期常量 const ( PackageDataResetDaily = "daily" // 每日重置