From 62ec94adecddeaf1bed21662ebecf0c8da9ba28d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E8=BF=9B?= Date: Tue, 27 Oct 2026 16:47:02 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=87=BA=E5=BA=93=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E6=9B=B4=E5=90=8D=E4=B8=BA=E9=A6=96=E6=AC=A1=E8=B4=AD?= =?UTF-8?q?=E4=B9=B0=E5=A5=97=E9=A4=90=E6=97=B6=E9=97=B4=EF=BC=8C=E6=8D=A2?= =?UTF-8?q?=E8=B4=A7=E6=96=B0=E8=B5=84=E4=BA=A7=E4=B8=8D=E5=86=8D=E6=8C=89?= =?UTF-8?q?=E6=8D=A2=E8=B4=A7=E5=AE=8C=E6=88=90=E6=97=B6=E9=97=B4=E8=AE=B0?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 字段 outbound_at → first_purchase_at,记录表 tb_asset_outbound_record → tb_asset_first_purchase_record,接口 /outbound-records → /first-purchase-records,筛选参数与导出列同步更名 - 换货后的新资产不因换货写入,在当前世代真正支付套餐订单时才记录;记录来源只保留 order / backfill,去掉换货单字段 - 迁移 000266 重命名列、表、索引与约束;同步 Spec、设计、任务与前端接口变更说明 Co-Authored-By: Claude Opus 5.5 --- cmd/silence-backfill/main.go | 18 +- ...7-实名即生效套餐沉默期-前端接口变更说明.md | 13 +- .../application/assetautorenewal/service.go | 4 +- .../application/packagesilence/backfill.go | 44 ++-- .../application/packagesilence/exchange.go | 32 +-- .../packagesilence/extension_execute.go | 2 +- .../packagesilence/extension_service.go | 2 +- .../packagesilence/first_purchase.go | 202 ++++++++++++++++ .../packagesilence/online_order.go | 2 +- .../application/packagesilence/outbound.go | 223 ------------------ internal/bootstrap/package_silence.go | 20 +- internal/bootstrap/services.go | 4 +- internal/bootstrap/worker_services.go | 6 +- internal/exporter/device_scene.go | 42 ++-- internal/exporter/iot_card_scene.go | 28 +-- internal/handler/admin/package_silence.go | 8 +- .../infrastructure/audit/package_silence.go | 8 +- .../audit/package_silence_registry.go | 12 +- internal/model/device.go | 2 +- internal/model/dto/asset_dto.go | 30 +-- internal/model/dto/device_dto.go | 6 +- internal/model/dto/iot_card_dto.go | 6 +- internal/model/dto/package_silence_dto.go | 13 +- internal/model/iot_card.go | 2 +- internal/model/package_silence.go | 12 +- internal/query/packagesilence/query.go | 30 ++- internal/routes/package_silence.go | 6 +- internal/service/asset/service.go | 4 +- internal/service/device/service.go | 16 +- internal/service/exchange/service.go | 10 +- internal/service/iot_card/service.go | 16 +- internal/service/order/service.go | 14 +- internal/store/postgres/device_store.go | 8 +- internal/store/postgres/iot_card_store.go | 8 +- .../store/postgres/package_silence_store.go | 26 +- internal/task/auto_purchase.go | 14 +- ...rename_outbound_to_first_purchase.down.sql | 20 ++ ...6_rename_outbound_to_first_purchase.up.sql | 27 +++ .../add-package-silence-period/design.md | 14 +- .../add-package-silence-period/proposal.md | 12 +- .../specs/asset-first-purchase-time/spec.md | 49 ++++ .../specs/asset-outbound-time/spec.md | 49 ---- .../add-package-silence-period/tasks.md | 21 +- pkg/constants/package_silence.go | 33 ++- pkg/queue/handler.go | 2 +- pkg/queue/types.go | 10 +- 46 files changed, 570 insertions(+), 560 deletions(-) create mode 100644 internal/application/packagesilence/first_purchase.go delete mode 100644 internal/application/packagesilence/outbound.go create mode 100644 migrations/000266_rename_outbound_to_first_purchase.down.sql create mode 100644 migrations/000266_rename_outbound_to_first_purchase.up.sql create mode 100644 openspec/changes/add-package-silence-period/specs/asset-first-purchase-time/spec.md delete mode 100644 openspec/changes/add-package-silence-period/specs/asset-outbound-time/spec.md diff --git a/cmd/silence-backfill/main.go b/cmd/silence-backfill/main.go index 33e2f65a..8087c793 100644 --- a/cmd/silence-backfill/main.go +++ b/cmd/silence-backfill/main.go @@ -1,12 +1,12 @@ -// silence-backfill 是沉默期与出库时间历史回填工具(add-package-silence-period,技术方案 §6、§17.4)。 +// silence-backfill 是沉默期与首次购买套餐时间历史回填工具(add-package-silence-period,技术方案 §6、§17.4)。 // // 用法(数据库连接只读取显式传入的 DB_* 环境变量): // // DB_HOST=... DB_PORT=... DB_USER=... DB_PASSWORD=... DB_NAME=... DB_SSLMODE=disable \ // go run ./cmd/silence-backfill -target silence # 只输出影响清单(默认 dry-run) // go run ./cmd/silence-backfill -target silence -execute # 业务确认清单后执行 -// go run ./cmd/silence-backfill -target outbound # 出库时间:只输出可回填数与无订单数 -// go run ./cmd/silence-backfill -target outbound -execute +// go run ./cmd/silence-backfill -target first_purchase # 首次购买套餐时间:只输出可回填数与无订单数 +// go run ./cmd/silence-backfill -target first_purchase -execute // // 两种回填都只处理为空的记录、分批执行、可重复执行;可在沉默期总开关关闭时运行。 package main @@ -31,11 +31,11 @@ import ( ) func main() { - target := flag.String("target", "", "回填对象:silence(套餐沉默期)/ outbound(资产出库时间)") + target := flag.String("target", "", "回填对象:silence(套餐沉默期)/ first_purchase(资产首次购买套餐时间)") execute := flag.Bool("execute", false, "执行写入;不传时只输出影响清单") flag.Parse() - if *target != "silence" && *target != "outbound" { - fail("必须指定 -target silence 或 -target outbound") + if *target != "silence" && *target != "first_purchase" { + fail("必须指定 -target silence 或 -target first_purchase") } db, err := openDB() if err != nil { @@ -61,12 +61,12 @@ func main() { } printSilence(silence) report = silence - case "outbound": - outbound, err := backfill.OutboundBackfill(ctx, *execute) + case "first_purchase": + firstPurchase, err := backfill.FirstPurchaseBackfill(ctx, *execute) if err != nil { fail(err.Error()) } - report = outbound + report = firstPurchase } encoded, _ := json.MarshalIndent(report, "", " ") fmt.Println(string(encoded)) diff --git a/docs/2026-10-27-实名即生效套餐沉默期-前端接口变更说明.md b/docs/2026-10-27-实名即生效套餐沉默期-前端接口变更说明.md index baa6685f..ed985f84 100644 --- a/docs/2026-10-27-实名即生效套餐沉默期-前端接口变更说明.md +++ b/docs/2026-10-27-实名即生效套餐沉默期-前端接口变更说明.md @@ -13,7 +13,7 @@ | 延期资格、试算、下单、线下登记、特殊延期 | 返回错误码 **1274** | 可用 | | 套餐默认沉默期、延期政策、企微审批场景配置 | 可用 | 可用 | | 购买时调整沉默期 | 可用 | 可用 | -| 卡 / 设备出库时间 | 不受开关影响 | 不受开关影响 | +| 卡 / 设备首次购买套餐时间 | 不受开关影响 | 不受开关影响 | 后台"系统配置"通用接口不能修改总开关(只读),请用专用接口: @@ -83,12 +83,13 @@ 沉默期已过期的套餐申请退款返回 **1268**(总开关打开时)。 -## 8. 卡 / 设备出库时间 +## 8. 卡 / 设备首次购买套餐时间 -- 卡列表、设备列表返回 `outbound_at`,支持 `outbound_start`、`outbound_end`(RFC3339)筛选;卡详情、设备详情、资产解析返回 `outbound_at`。 -- 卡、设备导出末尾新增"出库时间"列。 -- 出库记录:`GET /api/admin/assets/:asset_type/:id/outbound-records`(asset_type 为 card / device),按世代倒序,含来源(首次购买套餐 / 换货新资产 / 历史回填)、订单号或换货单号、结束时间与原因。 -- 绑定在设备上的卡不记录出库时间;只读,不提供修改。 +- 卡列表、设备列表返回 `first_purchase_at`,支持 `first_purchase_start`、`first_purchase_end`(RFC3339)筛选;卡详情、设备详情、资产解析返回 `first_purchase_at`。 +- 卡、设备导出末尾新增"首次购买套餐时间"列。 +- 首次购买套餐记录:`GET /api/admin/assets/:asset_type/:id/first-purchase-records`(asset_type 为 card / device),按世代倒序,含来源(首次购买套餐 / 历史回填)、订单号、结束时间与原因。 +- 换货后的新资产不会因换货得到首次购买套餐时间,要等它在当前世代真正支付套餐订单;旧资产换货回库后该时间清空,旧世代记录带结束时间与原因"换货回库"。 +- 绑定在设备上的卡不记录首次购买套餐时间;只读,不提供修改。 ## 9. 错误码 diff --git a/internal/application/assetautorenewal/service.go b/internal/application/assetautorenewal/service.go index 61ddeef9..49ba299f 100644 --- a/internal/application/assetautorenewal/service.go +++ b/internal/application/assetautorenewal/service.go @@ -95,13 +95,13 @@ type Service struct { silence SilenceRecorder } -// SilenceRecorder 在续购创建套餐记录的同一事务内设置沉默期与出库时间(无人参与路径只用默认值)。 +// SilenceRecorder 在续购创建套餐记录的同一事务内设置沉默期与首次购买套餐时间(无人参与路径只用默认值)。 type SilenceRecorder interface { ApplyOnUsageCreatedInTx(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, pkg *model.Package, paidAt time.Time, trigger string) error RecordForOrderInTx(ctx context.Context, tx *gorm.DB, order *model.Order) error } -// SetSilenceRecorder 注入沉默期与出库时间写入接缝;未注入时不记录。 +// SetSilenceRecorder 注入沉默期与首次购买套餐时间写入接缝;未注入时不记录。 func (s *Service) SetSilenceRecorder(recorder SilenceRecorder) { s.silence = recorder } diff --git a/internal/application/packagesilence/backfill.go b/internal/application/packagesilence/backfill.go index 86b36a9e..6c2b294a 100644 --- a/internal/application/packagesilence/backfill.go +++ b/internal/application/packagesilence/backfill.go @@ -14,18 +14,18 @@ import ( "github.com/break/junhong_cmp_fiber/pkg/errors" ) -// Backfill 是历史数据回填用例(技术方案 §6、§17.4):沉默期回填与出库时间回填,均先出清单、只处理空值、可重复执行。 +// Backfill 是历史数据回填用例(技术方案 §6、§17.4):沉默期回填与首次购买套餐时间回填,均先出清单、只处理空值、可重复执行。 // 可在总开关关闭时运行;不给上线宽限,截止早于上线日且期内未实名的,打开总开关后过期。 type Backfill struct { - db *gorm.DB - audit *audit.Writer - outbound *OutboundRecorder - now func() time.Time + db *gorm.DB + audit *audit.Writer + firstPurchase *FirstPurchaseRecorder + now func() time.Time } // NewBackfill 创建回填用例。 func NewBackfill(db *gorm.DB, writer *audit.Writer) *Backfill { - return &Backfill{db: db, audit: writer, outbound: NewOutboundRecorder(writer), now: time.Now} + return &Backfill{db: db, audit: writer, firstPurchase: NewFirstPurchaseRecorder(writer), now: time.Now} } // SilenceBackfillReport 是沉默期回填影响清单。 @@ -173,8 +173,8 @@ func (b *Backfill) backfillOne(ctx context.Context, row *silenceCandidate, now t }) } -// OutboundBackfillReport 是出库时间回填清单。 -type OutboundBackfillReport struct { +// FirstPurchaseBackfillReport 是首次购买套餐时间回填清单。 +type FirstPurchaseBackfillReport struct { CardBackfillable int `json:"card_backfillable"` // 可回填的卡(当前世代有已支付或已退款的单卡订单,未绑定设备) CardWithoutOrder int `json:"card_without_order"` // 无订单、保持为空的卡 CardBoundToDevice int `json:"card_bound_to_device"` // 绑定在设备上、不记录的卡 @@ -192,14 +192,14 @@ type firstOrderRow struct { OrderNo string `gorm:"column:order_no"` } -// OutboundBackfill 按资产当前世代最早一笔已支付或已退款的套餐订单回填出库时间(排除绑定在设备上的卡), -// 插入 backfill 出库记录;只更新为空的,可重复执行。 -func (b *Backfill) OutboundBackfill(ctx context.Context, execute bool) (*OutboundBackfillReport, error) { - report := &OutboundBackfillReport{} +// FirstPurchaseBackfill 按资产当前世代最早一笔已支付或已退款的套餐订单回填首次购买套餐时间(排除绑定在设备上的卡), +// 插入 backfill 首次购买套餐记录;只更新为空的,可重复执行。 +func (b *Backfill) FirstPurchaseBackfill(ctx context.Context, execute bool) (*FirstPurchaseBackfillReport, error) { + report := &FirstPurchaseBackfillReport{} db := b.db.WithContext(ctx) countWithout := func(table, orderType, column string, extra string) (int64, error) { var count int64 - err := db.Table(table+" AS a").Where("a.deleted_at IS NULL AND a.outbound_at IS NULL "+extra). + err := db.Table(table+" AS a").Where("a.deleted_at IS NULL AND a.first_purchase_at IS NULL "+extra). Where("NOT EXISTS (SELECT 1 FROM tb_order o WHERE o.order_type = ? AND o."+column+" = a.id AND o.generation = a.generation AND o.payment_status IN ? AND o.paid_at IS NOT NULL AND o.deleted_at IS NULL)", orderType, []int{model.PaymentStatusPaid, model.PaymentStatusRefunded}).Count(&count).Error return count, err @@ -214,20 +214,20 @@ func (b *Backfill) OutboundBackfill(ctx context.Context, execute bool) (*Outboun return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计无订单的设备失败") } var bound int64 - if err := db.Table("tb_iot_card AS a").Where("a.deleted_at IS NULL AND a.outbound_at IS NULL AND EXISTS (SELECT 1 FROM tb_device_sim_binding bd WHERE bd.iot_card_id = a.id AND bd.bind_status = 1 AND bd.deleted_at IS NULL)").Count(&bound).Error; err != nil { + if err := db.Table("tb_iot_card AS a").Where("a.deleted_at IS NULL AND a.first_purchase_at IS NULL AND EXISTS (SELECT 1 FROM tb_device_sim_binding bd WHERE bd.iot_card_id = a.id AND bd.bind_status = 1 AND bd.deleted_at IS NULL)").Count(&bound).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计绑定在设备上的卡失败") } report.CardWithoutOrder, report.DeviceWithoutOrder, report.CardBoundToDevice = int(cardWithout), int(deviceWithout), int(bound) - if err := b.outboundBatches(ctx, "tb_iot_card", model.OrderTypeSingleCard, "iot_card_id", notBound, constants.OutboundAssetTypeIotCard, execute, &report.CardBackfillable, &report.CardWritten); err != nil { + if err := b.firstPurchaseBatches(ctx, "tb_iot_card", model.OrderTypeSingleCard, "iot_card_id", notBound, constants.FirstPurchaseAssetTypeIotCard, execute, &report.CardBackfillable, &report.CardWritten); err != nil { return nil, err } - if err := b.outboundBatches(ctx, "tb_device", model.OrderTypeDevice, "device_id", "", constants.OutboundAssetTypeDevice, execute, &report.DeviceBackfillable, &report.DeviceWritten); err != nil { + if err := b.firstPurchaseBatches(ctx, "tb_device", model.OrderTypeDevice, "device_id", "", constants.FirstPurchaseAssetTypeDevice, execute, &report.DeviceBackfillable, &report.DeviceWritten); err != nil { return nil, err } return report, nil } -func (b *Backfill) outboundBatches(ctx context.Context, table, orderType, column, extra, assetType string, execute bool, backfillable, written *int) error { +func (b *Backfill) firstPurchaseBatches(ctx context.Context, table, orderType, column, extra, assetType string, execute bool, backfillable, written *int) error { lastID := uint(0) for { var rows []firstOrderRow @@ -239,10 +239,10 @@ func (b *Backfill) outboundBatches(ctx context.Context, table, orderType, column ORDER BY o.paid_at ASC, o.id ASC LIMIT 1 ) AS f ON TRUE`, orderType, []int{model.PaymentStatusPaid, model.PaymentStatusRefunded}). Select("a.id AS asset_id, a.generation, f.paid_at, f.order_id, f.order_no"). - Where("a.deleted_at IS NULL AND a.outbound_at IS NULL AND a.id > ? "+extra, lastID). - Order("a.id ASC").Limit(constants.SilenceOutboundBackfillBatchSize).Scan(&rows).Error + Where("a.deleted_at IS NULL AND a.first_purchase_at IS NULL AND a.id > ? "+extra, lastID). + Order("a.id ASC").Limit(constants.SilenceFirstPurchaseBackfillBatchSize).Scan(&rows).Error if err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "查询待回填出库时间的资产失败") + return errors.Wrap(errors.CodeDatabaseError, err, "查询待回填首次购买套餐时间的资产失败") } if len(rows) == 0 { return nil @@ -254,7 +254,7 @@ func (b *Backfill) outboundBatches(ctx context.Context, table, orderType, column continue } err := b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - inserted, err := b.outbound.RecordBackfillInTx(ctx, tx, assetType, row.AssetID, row.Generation, row.PaidAt, row.OrderID, row.OrderNo) + inserted, err := b.firstPurchase.RecordBackfillInTx(ctx, tx, assetType, row.AssetID, row.Generation, row.PaidAt, row.OrderID, row.OrderNo) if inserted { *written++ } @@ -264,7 +264,7 @@ func (b *Backfill) outboundBatches(ctx context.Context, table, orderType, column return err } } - if len(rows) < constants.SilenceOutboundBackfillBatchSize { + if len(rows) < constants.SilenceFirstPurchaseBackfillBatchSize { return nil } } diff --git a/internal/application/packagesilence/exchange.go b/internal/application/packagesilence/exchange.go index 0c16f43b..a58806a9 100644 --- a/internal/application/packagesilence/exchange.go +++ b/internal/application/packagesilence/exchange.go @@ -11,15 +11,15 @@ import ( "github.com/break/junhong_cmp_fiber/pkg/errors" ) -// ExchangeHook 是换货流程中的沉默期与出库时间接缝(技术方案 §9、§17.3.2)。 +// ExchangeHook 是换货流程中的沉默期与首次购买套餐时间接缝(技术方案 §9、§17.3.2)。 type ExchangeHook struct { - setter *Setter - outbound *OutboundRecorder + setter *Setter + firstPurchase *FirstPurchaseRecorder } // NewExchangeHook 创建换货接缝。 -func NewExchangeHook(setter *Setter, outbound *OutboundRecorder) *ExchangeHook { - return &ExchangeHook{setter: setter, outbound: outbound} +func NewExchangeHook(setter *Setter, firstPurchase *FirstPurchaseRecorder) *ExchangeHook { + return &ExchangeHook{setter: setter, firstPurchase: firstPurchase} } // OnUsagesMigratedInTx 换货迁移套餐记录后写沉默期流水(exchange):沉默期字段随记录迁移,不重新起算,截止时间不变; @@ -52,27 +52,19 @@ func (h *ExchangeHook) OnUsagesMigratedInTx(ctx context.Context, tx *gorm.DB, or return nil } -// OnNewAssetInTx 换货完成时为新资产写出库时间(换货完成时间)与 exchange 出库记录;已有出库时间的不覆盖。 -func (h *ExchangeHook) OnNewAssetInTx(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, order *model.ExchangeOrder, completedAt time.Time) error { - if h == nil || h.outbound == nil { - return nil - } - return h.outbound.RecordForExchangeInTx(ctx, tx, normalizeOutboundAssetType(assetType), assetID, order.ID, order.ExchangeNo, completedAt) -} - -// OnOldAssetRenewedInTx 旧资产换货回库、世代 +1 的同一事务内清空出库时间并结束旧世代出库记录。 +// OnOldAssetRenewedInTx 旧资产换货回库、世代 +1 的同一事务内清空首次购买套餐时间并结束旧世代首次购买套餐记录。 func (h *ExchangeHook) OnOldAssetRenewedInTx(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, oldGeneration int, at time.Time) error { - if h == nil || h.outbound == nil { + if h == nil || h.firstPurchase == nil { return nil } - return h.outbound.EndGenerationInTx(ctx, tx, normalizeOutboundAssetType(assetType), assetID, oldGeneration, at) + return h.firstPurchase.EndGenerationInTx(ctx, tx, normalizeFirstPurchaseAssetType(assetType), assetID, oldGeneration, at) } -func normalizeOutboundAssetType(assetType string) string { +func normalizeFirstPurchaseAssetType(assetType string) string { switch assetType { - case "card", constants.OutboundAssetTypeIotCard: - return constants.OutboundAssetTypeIotCard + case "card", constants.FirstPurchaseAssetTypeIotCard: + return constants.FirstPurchaseAssetTypeIotCard default: - return constants.OutboundAssetTypeDevice + return constants.FirstPurchaseAssetTypeDevice } } diff --git a/internal/application/packagesilence/extension_execute.go b/internal/application/packagesilence/extension_execute.go index ffdbbde0..4d010a0f 100644 --- a/internal/application/packagesilence/extension_execute.go +++ b/internal/application/packagesilence/extension_execute.go @@ -182,7 +182,7 @@ func lockCarrier(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage) er return nil } var id uint - if err := tx.WithContext(ctx).Table(outboundTable(assetType)).Clauses(clause.Locking{Strength: "UPDATE"}). + if err := tx.WithContext(ctx).Table(firstPurchaseTable(assetType)).Clauses(clause.Locking{Strength: "UPDATE"}). Select("id").Where("id = ?", assetID).Scan(&id).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "锁定套餐载体失败") } diff --git a/internal/application/packagesilence/extension_service.go b/internal/application/packagesilence/extension_service.go index 803a63c2..f3fabb23 100644 --- a/internal/application/packagesilence/extension_service.go +++ b/internal/application/packagesilence/extension_service.go @@ -132,7 +132,7 @@ func (s *ExtensionService) loadUsage(ctx context.Context, db *gorm.DB, usageID u return &usage, nil } var count int64 - query := db.WithContext(ctx).Table(outboundTable(assetType)).Where("id = ? AND deleted_at IS NULL", assetID) + query := db.WithContext(ctx).Table(firstPurchaseTable(assetType)).Where("id = ? AND deleted_at IS NULL", assetID) if err := middleware.ApplyStrictShopFilter(ctx, query).Count(&count).Error; err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "校验资产数据范围失败") } diff --git a/internal/application/packagesilence/first_purchase.go b/internal/application/packagesilence/first_purchase.go new file mode 100644 index 00000000..8b7f797c --- /dev/null +++ b/internal/application/packagesilence/first_purchase.go @@ -0,0 +1,202 @@ +package packagesilence + +import ( + "context" + "time" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" + "github.com/break/junhong_cmp_fiber/internal/model" + "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" +) + +// FirstPurchaseRecorder 写资产首次购买套餐时间与首次购买套餐记录(技术方案 §17)。 +// 首次购买套餐时间 = 资产当前世代第 1 次购买套餐的支付时间;只做记录展示,不受沉默期总开关影响。 +type FirstPurchaseRecorder struct { + audit *audit.Writer +} + +// NewFirstPurchaseRecorder 创建首次购买套餐时间写入用例。 +func NewFirstPurchaseRecorder(writer *audit.Writer) *FirstPurchaseRecorder { + return &FirstPurchaseRecorder{audit: writer} +} + +// RecordForOrderInTx 在订单发放套餐的事务内写首次购买套餐时间:只在为空且世代与订单一致时写入, +// 影响行数为 1 时插入首次购买套餐记录;当前绑定在设备上的卡不记录(套餐在设备上)。 +func (r *FirstPurchaseRecorder) RecordForOrderInTx(ctx context.Context, tx *gorm.DB, order *model.Order) error { + if r == nil || order == nil { + return nil + } + paidAt := time.Now() + if order.PaidAt != nil { + paidAt = *order.PaidAt + } + generation := order.Generation + if generation <= 0 { + generation = 1 + } + orderID := order.ID + switch { + case order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil: + bound, err := cardBoundToDevice(ctx, tx, *order.IotCardID) + if err != nil || bound { + return err + } + return r.write(ctx, tx, constants.FirstPurchaseAssetTypeIotCard, *order.IotCardID, generation, paidAt, &model.AssetFirstPurchaseRecord{ + Source: constants.FirstPurchaseSourceOrder, OrderID: &orderID, OrderNo: order.OrderNo, + }) + case order.OrderType == model.OrderTypeDevice && order.DeviceID != nil: + return r.write(ctx, tx, constants.FirstPurchaseAssetTypeDevice, *order.DeviceID, generation, paidAt, &model.AssetFirstPurchaseRecord{ + Source: constants.FirstPurchaseSourceOrder, OrderID: &orderID, OrderNo: order.OrderNo, + }) + default: + return nil + } +} + +// EndGenerationInTx 在换货旧资产世代 +1 的同一事务内清空首次购买套餐时间并结束旧世代记录。 +// oldGeneration 为换货前的世代。 +func (r *FirstPurchaseRecorder) EndGenerationInTx(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, oldGeneration int, endedAt time.Time) error { + if r == nil { + return nil + } + table := firstPurchaseTable(assetType) + if table == "" { + return nil + } + if err := tx.WithContext(ctx).Table(table).Where("id = ?", assetID).Update("first_purchase_at", nil).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "清空旧资产首次购买套餐时间失败") + } + affected, err := postgres.NewAssetFirstPurchaseRecordStore(tx).EndGeneration(ctx, assetType, assetID, oldGeneration, endedAt, constants.FirstPurchaseEndReasonExchange) + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "结束旧世代首次购买套餐记录失败") + } + if affected > 0 && r.audit != nil { + record := &model.AssetFirstPurchaseRecord{AssetType: assetType, AssetID: assetID, Generation: oldGeneration} + return r.audit.AppendSilenceResource(ctx, tx, constants.AuditActionAssetFirstPurchaseEnded, "换货回库结束首次购买套餐记录", + audit.AssetFirstPurchaseResource(record, map[string]any{"ended_at": endedAt, "end_reason": constants.FirstPurchaseEndReasonExchange}), nil) + } + return nil +} + +// RecordBackfillInTx 历史回填:只更新为空的首次购买套餐时间并插入 backfill 记录,返回是否写入。 +func (r *FirstPurchaseRecorder) RecordBackfillInTx(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, generation int, firstPurchaseAt time.Time, orderID uint, orderNo string) (bool, error) { + id := orderID + before, err := r.countRecords(ctx, tx, assetType, assetID, generation) + if err != nil { + return false, err + } + if err := r.write(ctx, tx, assetType, assetID, generation, firstPurchaseAt, &model.AssetFirstPurchaseRecord{ + Source: constants.FirstPurchaseSourceBackfill, OrderID: &id, OrderNo: orderNo, + }); err != nil { + return false, err + } + after, err := r.countRecords(ctx, tx, assetType, assetID, generation) + return after > before, err +} + +func (r *FirstPurchaseRecorder) countRecords(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, generation int) (int64, error) { + var count int64 + err := tx.WithContext(ctx).Model(&model.AssetFirstPurchaseRecord{}). + Where("asset_type = ? AND asset_id = ? AND generation = ?", assetType, assetID, generation).Count(&count).Error + return count, err +} + +func (r *FirstPurchaseRecorder) write(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, generation int, firstPurchaseAt time.Time, record *model.AssetFirstPurchaseRecord) error { + table := firstPurchaseTable(assetType) + result := tx.WithContext(ctx).Table(table). + Where("id = ? AND generation = ? AND first_purchase_at IS NULL AND deleted_at IS NULL", assetID, generation). + Update("first_purchase_at", firstPurchaseAt) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "写入资产首次购买套餐时间失败") + } + if result.RowsAffected != 1 { + return nil + } + identifier, shopID, err := firstPurchaseAssetSnapshot(ctx, tx, assetType, assetID) + if err != nil { + return err + } + record.AssetType = assetType + record.AssetID = assetID + record.AssetIdentifierSnapshot = identifier + record.Generation = generation + record.FirstPurchaseAt = firstPurchaseAt + record.ShopID = shopID + record.CreatedAt = time.Now() + inserted, err := postgres.NewAssetFirstPurchaseRecordStore(tx).CreateIfAbsent(ctx, record) + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "写入资产首次购买套餐记录失败") + } + if inserted && r.audit != nil { + return r.audit.AppendSilenceResource(ctx, tx, constants.AuditActionAssetFirstPurchaseRecorded, "记录资产首次购买套餐时间", + audit.AssetFirstPurchaseResource(record, map[string]any{"first_purchase_at": firstPurchaseAt, "source": record.Source, "order_no": record.OrderNo}), nil) + } + return nil +} + +func firstPurchaseTable(assetType string) string { + switch assetType { + case constants.FirstPurchaseAssetTypeIotCard: + return "tb_iot_card" + case constants.FirstPurchaseAssetTypeDevice: + return "tb_device" + default: + return "" + } +} + +func cardBoundToDevice(ctx context.Context, tx *gorm.DB, cardID uint) (bool, error) { + var count int64 + if err := tx.WithContext(ctx).Model(&model.DeviceSimBinding{}). + Where("iot_card_id = ? AND bind_status = ?", cardID, constants.BindStatusBound).Count(&count).Error; err != nil { + return false, errors.Wrap(errors.CodeDatabaseError, err, "查询卡绑定设备失败") + } + return count > 0, nil +} + +func currentGeneration(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) (int, error) { + var generation int + if err := tx.WithContext(ctx).Table(firstPurchaseTable(assetType)).Select("generation").Where("id = ?", assetID).Scan(&generation).Error; err != nil { + return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询资产世代失败") + } + return generation, nil +} + +func firstPurchaseAssetSnapshot(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) (string, *uint, error) { + if assetType == constants.FirstPurchaseAssetTypeDevice { + var device model.Device + if err := tx.WithContext(ctx).Select("id", "virtual_no", "imei", "shop_id").Where("id = ?", assetID).First(&device).Error; err != nil { + return "", nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备快照失败") + } + identifier := device.VirtualNo + if identifier == "" { + identifier = device.IMEI + } + return identifier, device.ShopID, nil + } + var card model.IotCard + if err := tx.WithContext(ctx).Select("id", "iccid", "shop_id").Where("id = ?", assetID).First(&card).Error; err != nil { + return "", nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡快照失败") + } + return card.ICCID, card.ShopID, nil +} + +// PurchaseRecorder 组合沉默期设置与首次购买套餐时间写入,供续费等其他发放套餐路径注入。 +type PurchaseRecorder struct { + Setter *Setter + FirstPurchase *FirstPurchaseRecorder +} + +// ApplyOnUsageCreatedInTx 设置沉默期。 +func (r PurchaseRecorder) ApplyOnUsageCreatedInTx(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, pkg *model.Package, paidAt time.Time, trigger string) error { + return r.Setter.ApplyOnUsageCreatedInTx(ctx, tx, usage, pkg, paidAt, trigger) +} + +// RecordForOrderInTx 写首次购买套餐时间。 +func (r PurchaseRecorder) RecordForOrderInTx(ctx context.Context, tx *gorm.DB, order *model.Order) error { + return r.FirstPurchase.RecordForOrderInTx(ctx, tx, order) +} diff --git a/internal/application/packagesilence/online_order.go b/internal/application/packagesilence/online_order.go index 12850309..05d5e441 100644 --- a/internal/application/packagesilence/online_order.go +++ b/internal/application/packagesilence/online_order.go @@ -671,7 +671,7 @@ func isUniqueViolation(err error) bool { func assetShopID(ctx context.Context, db *gorm.DB, usage *model.PackageUsage) uint { assetType, assetID := usageAsset(usage) var shopID *uint - _ = db.WithContext(ctx).Table(outboundTable(assetType)).Select("shop_id").Where("id = ?", assetID).Scan(&shopID).Error + _ = db.WithContext(ctx).Table(firstPurchaseTable(assetType)).Select("shop_id").Where("id = ?", assetID).Scan(&shopID).Error if shopID == nil { return 0 } diff --git a/internal/application/packagesilence/outbound.go b/internal/application/packagesilence/outbound.go deleted file mode 100644 index 8c624b3b..00000000 --- a/internal/application/packagesilence/outbound.go +++ /dev/null @@ -1,223 +0,0 @@ -package packagesilence - -import ( - "context" - "time" - - "gorm.io/gorm" - - "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" - "github.com/break/junhong_cmp_fiber/internal/model" - "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" -) - -// OutboundRecorder 写资产出库时间与出库记录(技术方案 §17)。 -// 出库时间 = 资产当前世代第 1 次购买套餐的支付时间;只做记录展示,不受沉默期总开关影响。 -type OutboundRecorder struct { - audit *audit.Writer -} - -// NewOutboundRecorder 创建出库时间写入用例。 -func NewOutboundRecorder(writer *audit.Writer) *OutboundRecorder { - return &OutboundRecorder{audit: writer} -} - -// RecordForOrderInTx 在订单发放套餐的事务内写出库时间:只在为空且世代与订单一致时写入, -// 影响行数为 1 时插入出库记录;当前绑定在设备上的卡不记录(套餐在设备上)。 -func (r *OutboundRecorder) RecordForOrderInTx(ctx context.Context, tx *gorm.DB, order *model.Order) error { - if r == nil || order == nil { - return nil - } - paidAt := time.Now() - if order.PaidAt != nil { - paidAt = *order.PaidAt - } - generation := order.Generation - if generation <= 0 { - generation = 1 - } - orderID := order.ID - switch { - case order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil: - bound, err := cardBoundToDevice(ctx, tx, *order.IotCardID) - if err != nil || bound { - return err - } - return r.write(ctx, tx, constants.OutboundAssetTypeIotCard, *order.IotCardID, generation, paidAt, &model.AssetOutboundRecord{ - Source: constants.OutboundSourceOrder, OrderID: &orderID, OrderNo: order.OrderNo, - }) - case order.OrderType == model.OrderTypeDevice && order.DeviceID != nil: - return r.write(ctx, tx, constants.OutboundAssetTypeDevice, *order.DeviceID, generation, paidAt, &model.AssetOutboundRecord{ - Source: constants.OutboundSourceOrder, OrderID: &orderID, OrderNo: order.OrderNo, - }) - default: - return nil - } -} - -// EndGenerationInTx 在换货旧资产世代 +1 的同一事务内清空出库时间并结束旧世代记录。 -// oldGeneration 为换货前的世代。 -func (r *OutboundRecorder) EndGenerationInTx(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, oldGeneration int, endedAt time.Time) error { - if r == nil { - return nil - } - table := outboundTable(assetType) - if table == "" { - return nil - } - if err := tx.WithContext(ctx).Table(table).Where("id = ?", assetID).Update("outbound_at", nil).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "清空旧资产出库时间失败") - } - affected, err := postgres.NewAssetOutboundRecordStore(tx).EndGeneration(ctx, assetType, assetID, oldGeneration, endedAt, constants.OutboundEndReasonExchange) - if err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "结束旧世代出库记录失败") - } - if affected > 0 && r.audit != nil { - record := &model.AssetOutboundRecord{AssetType: assetType, AssetID: assetID, Generation: oldGeneration, Source: constants.OutboundSourceExchange} - return r.audit.AppendSilenceResource(ctx, tx, constants.AuditActionAssetOutboundEnded, "换货回库结束出库记录", - audit.AssetOutboundResource(record, map[string]any{"ended_at": endedAt, "end_reason": constants.OutboundEndReasonExchange}), nil) - } - return nil -} - -// RecordForExchangeInTx 为换货后的新资产写出库时间(换货完成时间)并插入 exchange 记录;已有出库时间的不覆盖。 -func (r *OutboundRecorder) RecordForExchangeInTx(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, exchangeOrderID uint, exchangeNo string, completedAt time.Time) error { - if r == nil { - return nil - } - if assetType == constants.OutboundAssetTypeIotCard { - bound, err := cardBoundToDevice(ctx, tx, assetID) - if err != nil || bound { - return err - } - } - generation, err := currentGeneration(ctx, tx, assetType, assetID) - if err != nil { - return err - } - id := exchangeOrderID - return r.write(ctx, tx, assetType, assetID, generation, completedAt, &model.AssetOutboundRecord{ - Source: constants.OutboundSourceExchange, ExchangeOrderID: &id, ExchangeNo: exchangeNo, - }) -} - -// RecordBackfillInTx 历史回填:只更新为空的出库时间并插入 backfill 记录,返回是否写入。 -func (r *OutboundRecorder) RecordBackfillInTx(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, generation int, outboundAt time.Time, orderID uint, orderNo string) (bool, error) { - id := orderID - before, err := r.countRecords(ctx, tx, assetType, assetID, generation) - if err != nil { - return false, err - } - if err := r.write(ctx, tx, assetType, assetID, generation, outboundAt, &model.AssetOutboundRecord{ - Source: constants.OutboundSourceBackfill, OrderID: &id, OrderNo: orderNo, - }); err != nil { - return false, err - } - after, err := r.countRecords(ctx, tx, assetType, assetID, generation) - return after > before, err -} - -func (r *OutboundRecorder) countRecords(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, generation int) (int64, error) { - var count int64 - err := tx.WithContext(ctx).Model(&model.AssetOutboundRecord{}). - Where("asset_type = ? AND asset_id = ? AND generation = ?", assetType, assetID, generation).Count(&count).Error - return count, err -} - -func (r *OutboundRecorder) write(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, generation int, outboundAt time.Time, record *model.AssetOutboundRecord) error { - table := outboundTable(assetType) - result := tx.WithContext(ctx).Table(table). - Where("id = ? AND generation = ? AND outbound_at IS NULL AND deleted_at IS NULL", assetID, generation). - Update("outbound_at", outboundAt) - if result.Error != nil { - return errors.Wrap(errors.CodeDatabaseError, result.Error, "写入资产出库时间失败") - } - if result.RowsAffected != 1 { - return nil - } - identifier, shopID, err := outboundAssetSnapshot(ctx, tx, assetType, assetID) - if err != nil { - return err - } - record.AssetType = assetType - record.AssetID = assetID - record.AssetIdentifierSnapshot = identifier - record.Generation = generation - record.OutboundAt = outboundAt - record.ShopID = shopID - record.CreatedAt = time.Now() - inserted, err := postgres.NewAssetOutboundRecordStore(tx).CreateIfAbsent(ctx, record) - if err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "写入资产出库记录失败") - } - if inserted && r.audit != nil { - return r.audit.AppendSilenceResource(ctx, tx, constants.AuditActionAssetOutboundRecorded, "记录资产出库时间", - audit.AssetOutboundResource(record, map[string]any{"outbound_at": outboundAt, "source": record.Source, "order_no": record.OrderNo, "exchange_no": record.ExchangeNo}), nil) - } - return nil -} - -func outboundTable(assetType string) string { - switch assetType { - case constants.OutboundAssetTypeIotCard: - return "tb_iot_card" - case constants.OutboundAssetTypeDevice: - return "tb_device" - default: - return "" - } -} - -func cardBoundToDevice(ctx context.Context, tx *gorm.DB, cardID uint) (bool, error) { - var count int64 - if err := tx.WithContext(ctx).Model(&model.DeviceSimBinding{}). - Where("iot_card_id = ? AND bind_status = ?", cardID, constants.BindStatusBound).Count(&count).Error; err != nil { - return false, errors.Wrap(errors.CodeDatabaseError, err, "查询卡绑定设备失败") - } - return count > 0, nil -} - -func currentGeneration(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) (int, error) { - var generation int - if err := tx.WithContext(ctx).Table(outboundTable(assetType)).Select("generation").Where("id = ?", assetID).Scan(&generation).Error; err != nil { - return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询资产世代失败") - } - return generation, nil -} - -func outboundAssetSnapshot(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) (string, *uint, error) { - if assetType == constants.OutboundAssetTypeDevice { - var device model.Device - if err := tx.WithContext(ctx).Select("id", "virtual_no", "imei", "shop_id").Where("id = ?", assetID).First(&device).Error; err != nil { - return "", nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备快照失败") - } - identifier := device.VirtualNo - if identifier == "" { - identifier = device.IMEI - } - return identifier, device.ShopID, nil - } - var card model.IotCard - if err := tx.WithContext(ctx).Select("id", "iccid", "shop_id").Where("id = ?", assetID).First(&card).Error; err != nil { - return "", nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡快照失败") - } - return card.ICCID, card.ShopID, nil -} - -// PurchaseRecorder 组合沉默期设置与出库时间写入,供续费等其他发放套餐路径注入。 -type PurchaseRecorder struct { - Setter *Setter - Outbound *OutboundRecorder -} - -// ApplyOnUsageCreatedInTx 设置沉默期。 -func (r PurchaseRecorder) ApplyOnUsageCreatedInTx(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, pkg *model.Package, paidAt time.Time, trigger string) error { - return r.Setter.ApplyOnUsageCreatedInTx(ctx, tx, usage, pkg, paidAt, trigger) -} - -// RecordForOrderInTx 写出库时间。 -func (r PurchaseRecorder) RecordForOrderInTx(ctx context.Context, tx *gorm.DB, order *model.Order) error { - return r.Outbound.RecordForOrderInTx(ctx, tx, order) -} diff --git a/internal/bootstrap/package_silence.go b/internal/bootstrap/package_silence.go index c189aa6c..189fe610 100644 --- a/internal/bootstrap/package_silence.go +++ b/internal/bootstrap/package_silence.go @@ -15,17 +15,17 @@ import ( // packageSilenceBundle 是沉默期相关用例的装配结果(API 与 Worker 共用)。 type packageSilenceBundle struct { - Switch *packagesilenceApp.Switch - Resolver *packagesilenceApp.Resolver - Setter *packagesilenceApp.Setter - Outbound *packagesilenceApp.OutboundRecorder + Switch *packagesilenceApp.Switch + Resolver *packagesilenceApp.Resolver + Setter *packagesilenceApp.Setter + FirstPurchase *packagesilenceApp.FirstPurchaseRecorder // 以下仅 API 与 Worker 中延期相关入口装配 Extensions *packagesilenceApp.ExtensionService Policies *packagesilenceApp.PolicyService Switches *packagesilenceApp.SwitchService } -// newPackageSilenceBundle 装配沉默期总开关、判定、设置与出库时间用例,并把激活闸门注入激活服务。 +// newPackageSilenceBundle 装配沉默期总开关、判定、设置与首次购买套餐时间用例,并把激活闸门注入激活服务。 func newPackageSilenceBundle(db *gorm.DB, redisClient *redis.Client, logger *zap.Logger, auditWriter *auditInfra.Writer, activation *packageSvc.ActivationService) *packageSilenceBundle { sw := packagesilenceApp.NewDefaultSwitch(db, redisClient, logger) resolver := packagesilenceApp.NewResolver(db, sw, auditWriter, logger) @@ -35,11 +35,11 @@ func newPackageSilenceBundle(db *gorm.DB, redisClient *redis.Client, logger *zap } return &packageSilenceBundle{ Switch: sw, Resolver: resolver, - Setter: packagesilenceApp.NewSetter(db, auditWriter), - Outbound: packagesilenceApp.NewOutboundRecorder(auditWriter), - Extensions: packagesilenceApp.NewExtensionService(db, resolver, auditWriter, logger), - Policies: packagesilenceApp.NewPolicyService(db, auditWriter), - Switches: packagesilenceApp.NewSwitchService(db, redisClient, sw, auditWriter), + Setter: packagesilenceApp.NewSetter(db, auditWriter), + FirstPurchase: packagesilenceApp.NewFirstPurchaseRecorder(auditWriter), + Extensions: packagesilenceApp.NewExtensionService(db, resolver, auditWriter, logger), + Policies: packagesilenceApp.NewPolicyService(db, auditWriter), + Switches: packagesilenceApp.NewSwitchService(db, redisClient, sw, auditWriter), } } diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index 195d0c4a..bef782f1 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -342,7 +342,7 @@ func initServices(s *stores, deps *Dependencies) *services { orderService.SetObservationSeriesEventWriter(observationSeriesEvents) orderService.SetPriorityEventWriter(priorityEvents) orderService.SetSilenceSetter(packageSilence.Setter) - orderService.SetOutboundRecorder(packageSilence.Outbound) + orderService.SetFirstPurchaseRecorder(packageSilence.FirstPurchase) walletOutbox := outbox.NewRepository() walletDebitEvents := walletinfra.NewDebitEventWriter(walletOutbox, auditWriter) orderService.SetAgentWalletDebitService(walletapp.NewDebitService(walletDebitEvents, nil)) @@ -411,7 +411,7 @@ func initServices(s *stores, deps *Dependencies) *services { exchangeService := exchangeSvc.New(deps.DB, s.ExchangeOrder, s.IotCard, s.Device, s.AssetWallet, s.AssetWalletTransaction, s.PackageUsage, s.PackageUsageDailyRecord, s.ResourceTag, customerBinding, deps.Logger) exchangeService.SetShippingCreatedNotifier(exchangeApp.NewShippingCreatedNotifier(exchangeInfra.NewShippingNotificationWriter(outbox.NewRepository()))) exchangeService.SetAccessAudit(auditWriter) - exchangeService.SetSilenceHook(packagesilenceApp.NewExchangeHook(packageSilence.Setter, packageSilence.Outbound)) + exchangeService.SetSilenceHook(packagesilenceApp.NewExchangeHook(packageSilence.Setter, packageSilence.FirstPurchase)) exchangeService.SetPhoneAssetAssociationStore(s.PhoneAssetAssociation) assetService := assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard, deps.GatewayClient, s.AssetIdentifier, s.Order, s.OrderItem, s.ExchangeOrder) assetService.SetPhoneAssetAssociationStore(s.PhoneAssetAssociation) diff --git a/internal/bootstrap/worker_services.go b/internal/bootstrap/worker_services.go index 8cb677ea..aac5fea9 100644 --- a/internal/bootstrap/worker_services.go +++ b/internal/bootstrap/worker_services.go @@ -199,7 +199,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q orderService.SetObservationSeriesEventWriter(observationSeriesEvents) orderService.SetPriorityEventWriter(priorityEvents) orderService.SetSilenceSetter(packageSilence.Setter) - orderService.SetOutboundRecorder(packageSilence.Outbound) + orderService.SetFirstPurchaseRecorder(packageSilence.FirstPurchase) // 创建停复机服务并注入回调:流量耗尽自动停机、套餐激活/重置/支付后自动复机 stopResumeService := iotCardSvc.NewStopResumeService( @@ -232,7 +232,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q ObservationEvents: observationSeriesEvents, PriorityEvents: priorityEvents, }) - assetAutoRenewalService.SetSilenceRecorder(packagesilenceApp.PurchaseRecorder{Setter: packageSilence.Setter, Outbound: packageSilence.Outbound}) + assetAutoRenewalService.SetSilenceRecorder(packagesilenceApp.PurchaseRecorder{Setter: packageSilence.Setter, FirstPurchase: packageSilence.FirstPurchase}) activationService.SetObservationSeriesEventWriter(observationSeriesEvents) activationService.SetPriorityEventWriter(priorityEvents) usageService.SetStopResumeCallback(stopResumeService) @@ -271,7 +271,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q SilenceSwitch: packageSilence.Switch, SilenceResolver: packageSilence.Resolver, SilenceSetter: packageSilence.Setter, - SilenceOutbound: packageSilence.Outbound, + SilenceFirstPurchase: packageSilence.FirstPurchase, SilenceExtensions: packageSilence.Extensions, } } diff --git a/internal/exporter/device_scene.go b/internal/exporter/device_scene.go index 8afc9c68..6efcebbf 100644 --- a/internal/exporter/device_scene.go +++ b/internal/exporter/device_scene.go @@ -16,7 +16,7 @@ const ( deviceExportCardGroupSize = 5 // deviceExportTailHeaderCount 是当前尾部固定列数(含「关联手机号」「设备合计(GB)」列)。 deviceExportTailHeaderCount = 9 - // deviceExportPollingTailHeaderCount 是追加「出库时间」前(以「是否轮询」结尾)的尾部列数 + // deviceExportPollingTailHeaderCount 是追加「首次购买套餐时间」前(以「是否轮询」结尾)的尾部列数 deviceExportPollingTailHeaderCount = 8 // deviceExportGBTailHeaderCount 是新增「是否轮询」之前、带「设备合计(GB)」的尾部列数,供历史任务表头反解。 deviceExportGBTailHeaderCount = 7 @@ -31,8 +31,8 @@ const ( deviceExportTotalGBHeader = "设备合计(GB)" // deviceExportPollingHeader 是「是否轮询」列的表头,位于导出最后一列。 deviceExportPollingHeader = "是否轮询" - // deviceExportOutboundHeader 出库时间(当前世代首次购买套餐的支付时间) - deviceExportOutboundHeader = "出库时间" + // deviceExportFirstPurchaseHeader 首次购买套餐时间(当前世代第一笔套餐订单的支付时间) + deviceExportFirstPurchaseHeader = "首次购买套餐时间" // deviceExportMBPerGB MB 换算 GB 的进制。 deviceExportMBPerGB = 1024 ) @@ -131,11 +131,11 @@ func (s *DeviceDataSource) baseQuery(ctx context.Context) *gorm.DB { // 导出任务运行在 Worker 中,权限只能使用任务创建时保存的店铺范围快照。 func (s *DeviceDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB { query = applyExportShopScope(query, params, "d.shop_id") - if start, ok := filterTime(params.Filters, "outbound_start"); ok { - query = query.Where("d.outbound_at >= ?", start) + if start, ok := filterTime(params.Filters, "first_purchase_start"); ok { + query = query.Where("d.first_purchase_at >= ?", start) } - if end, ok := filterTime(params.Filters, "outbound_end"); ok { - query = query.Where("d.outbound_at <= ?", end) + if end, ok := filterTime(params.Filters, "first_purchase_end"); ok { + query = query.Where("d.first_purchase_at <= ?", end) } if virtualNo, ok := filterString(params.Filters, "virtual_no"); ok { @@ -284,7 +284,7 @@ func (s *DeviceDataSource) fetchDevices(ctx context.Context, params ExportParams COALESCE(ps.series_name, '') AS series_name, COALESCE(aw.balance, 0) AS wallet_balance, d.enable_polling, - d.outbound_at + d.first_purchase_at `). Order("d.id ASC"). Limit(limit). @@ -363,16 +363,16 @@ func (s *DeviceDataSource) fetchActivePackages(ctx context.Context, deviceIDs [] } type deviceExportRow struct { - ID uint `gorm:"column:id"` - VirtualNo string `gorm:"column:virtual_no"` - IMEI string `gorm:"column:imei"` - DeviceName string `gorm:"column:device_name"` - DeviceModel string `gorm:"column:device_model"` - ShopName string `gorm:"column:shop_name"` - SeriesName string `gorm:"column:series_name"` - WalletBalance int64 `gorm:"column:wallet_balance"` - EnablePolling bool `gorm:"column:enable_polling"` - OutboundAt *time.Time `gorm:"column:outbound_at"` + ID uint `gorm:"column:id"` + VirtualNo string `gorm:"column:virtual_no"` + IMEI string `gorm:"column:imei"` + DeviceName string `gorm:"column:device_name"` + DeviceModel string `gorm:"column:device_model"` + ShopName string `gorm:"column:shop_name"` + SeriesName string `gorm:"column:series_name"` + WalletBalance int64 `gorm:"column:wallet_balance"` + EnablePolling bool `gorm:"column:enable_polling"` + FirstPurchaseAt *time.Time `gorm:"column:first_purchase_at"` } type deviceExportCardRow struct { @@ -416,7 +416,7 @@ func buildDeviceExportHeaders(cardGroups int) []string { deviceExportAssociatedPhoneHeader, deviceExportTotalGBHeader, deviceExportPollingHeader, - deviceExportOutboundHeader, + deviceExportFirstPurchaseHeader, ) return headers } @@ -474,7 +474,7 @@ func buildDeviceExportRow(item deviceExportRow, cards []deviceExportCardRow, pkg row = append(row, yesNo(item.EnablePolling)) } if tailCount >= deviceExportTailHeaderCount { - row = append(row, formatOptionalTime(item.OutboundAt)) + row = append(row, formatOptionalTime(item.FirstPurchaseAt)) } return row } @@ -491,7 +491,7 @@ func deviceExportTailCount(headers []string) int { return deviceExportTailHeaderCount } switch headers[len(headers)-1] { - case deviceExportOutboundHeader: + case deviceExportFirstPurchaseHeader: return deviceExportTailHeaderCount case deviceExportPollingHeader: return deviceExportPollingTailHeaderCount diff --git a/internal/exporter/iot_card_scene.go b/internal/exporter/iot_card_scene.go index 27dbc766..4e500387 100644 --- a/internal/exporter/iot_card_scene.go +++ b/internal/exporter/iot_card_scene.go @@ -40,7 +40,7 @@ func (s *IotCardDataSource) Count(ctx context.Context, params ExportParams) (int // Headers 返回 IoT 卡导出表头。 func (s *IotCardDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) { // 「关联手机号」「套餐到期时间」「使用流量(GB)」「剩余流量(GB)」依次固定追加在尾部:导出表头在 dispatch 阶段落库,历史任务重导出沿用同一列序。 - return []string{"ICCID", "MSISDN", "绑定设备虚拟号", "运营商", "店铺名称", "绑定设备名称", "是否实名", "实名时间", "网络状态", "套餐名称", "使用流量(MB)", "剩余流量(MB)", iotCardExportAssociatedPhoneHeader, iotCardExportFinalExpiryHeader, iotCardExportUsageGBHeader, iotCardExportRemainingGBHeader, iotCardExportPollingHeader, iotCardExportOutboundHeader}, nil + return []string{"ICCID", "MSISDN", "绑定设备虚拟号", "运营商", "店铺名称", "绑定设备名称", "是否实名", "实名时间", "网络状态", "套餐名称", "使用流量(MB)", "剩余流量(MB)", iotCardExportAssociatedPhoneHeader, iotCardExportFinalExpiryHeader, iotCardExportUsageGBHeader, iotCardExportRemainingGBHeader, iotCardExportPollingHeader, iotCardExportFirstPurchaseHeader}, nil } // Fetch 按 offset/limit 查询 IoT 卡导出数据。 @@ -62,7 +62,7 @@ func (s *IotCardDataSource) Fetch(ctx context.Context, params ExportParams, offs c.real_name_status, c.enable_polling, c.first_realname_at, - c.outbound_at, + c.first_purchase_at, c.network_status, COALESCE(pkg.package_name, '') AS package_name, COALESCE(pkg.data_usage_mb, 0) AS data_usage_mb, @@ -83,7 +83,7 @@ func (s *IotCardDataSource) Fetch(ctx context.Context, params ExportParams, offs if err != nil { return nil, err } - includeExpiry, includeGB, includePolling, includeOutbound := iotCardExportTailColumns(params.ResolvedHeaders) + includeExpiry, includeGB, includePolling, includeFirstPurchase := iotCardExportTailColumns(params.ResolvedHeaders) var finalExpiries map[uint]string if includeExpiry { finalExpiries, err = s.loadFinalExpiries(ctx, cardIDs) @@ -119,8 +119,8 @@ func (s *IotCardDataSource) Fetch(ctx context.Context, params ExportParams, offs if includePolling { row = append(row, yesNo(item.EnablePolling)) } - if includeOutbound { - row = append(row, formatOptionalTime(item.OutboundAt)) + if includeFirstPurchase { + row = append(row, formatOptionalTime(item.FirstPurchaseAt)) } rows = append(rows, row) } @@ -145,12 +145,12 @@ func (s *IotCardDataSource) loadFinalExpiries(ctx context.Context, cardIDs []uin // iotCardExportTailColumns 按冻结表头末列识别尾部版本:末列「是否轮询」输出到期、GB 与是否轮询列, // 末列「剩余流量(GB)」输出到期列与 GB 列,末列「套餐到期时间」只输出到期列,更早的表头都不输出;无冻结表头按当前版本输出。 -func iotCardExportTailColumns(headers []string) (includeExpiry, includeGB, includePolling, includeOutbound bool) { +func iotCardExportTailColumns(headers []string) (includeExpiry, includeGB, includePolling, includeFirstPurchase bool) { if len(headers) == 0 { return true, true, true, true } switch headers[len(headers)-1] { - case iotCardExportOutboundHeader: + case iotCardExportFirstPurchaseHeader: return true, true, true, true case iotCardExportPollingHeader: return true, true, true, false @@ -204,11 +204,11 @@ func (s *IotCardDataSource) baseQuery(ctx context.Context) *gorm.DB { // 导出任务运行在 Worker 中,权限只能使用任务创建时保存的店铺范围快照。 func (s *IotCardDataSource) applyFilters(ctx context.Context, query *gorm.DB, params ExportParams) *gorm.DB { query = applyExportShopScope(query, params, "c.shop_id") - if start, ok := filterTime(params.Filters, "outbound_start"); ok { - query = query.Where("c.outbound_at >= ?", start) + if start, ok := filterTime(params.Filters, "first_purchase_start"); ok { + query = query.Where("c.first_purchase_at >= ?", start) } - if end, ok := filterTime(params.Filters, "outbound_end"); ok { - query = query.Where("c.outbound_at <= ?", end) + if end, ok := filterTime(params.Filters, "first_purchase_end"); ok { + query = query.Where("c.first_purchase_at <= ?", end) } if isStandalone, ok := filterOptionalBool(params.Filters, "is_standalone"); ok { @@ -314,8 +314,8 @@ const ( iotCardExportRemainingGBHeader = "剩余流量(GB)" // iotCardExportPollingHeader 是「是否轮询」列的表头,位于 GB 流量列之后、导出最后一列。 iotCardExportPollingHeader = "是否轮询" - // iotCardExportOutboundHeader 出库时间(当前世代首次购买套餐的支付时间) - iotCardExportOutboundHeader = "出库时间" + // iotCardExportFirstPurchaseHeader 首次购买套餐时间(当前世代第一笔套餐订单的支付时间) + iotCardExportFirstPurchaseHeader = "首次购买套餐时间" ) type iotCardExportRow struct { @@ -328,7 +328,7 @@ type iotCardExportRow struct { DeviceName string `gorm:"column:device_name"` RealNameStatus int `gorm:"column:real_name_status"` FirstRealnameAt *time.Time `gorm:"column:first_realname_at"` - OutboundAt *time.Time `gorm:"column:outbound_at"` + FirstPurchaseAt *time.Time `gorm:"column:first_purchase_at"` NetworkStatus int `gorm:"column:network_status"` PackageName string `gorm:"column:package_name"` DataUsageMB int64 `gorm:"column:data_usage_mb"` diff --git a/internal/handler/admin/package_silence.go b/internal/handler/admin/package_silence.go index 3249280d..ab3fbda4 100644 --- a/internal/handler/admin/package_silence.go +++ b/internal/handler/admin/package_silence.go @@ -11,7 +11,7 @@ import ( "github.com/break/junhong_cmp_fiber/pkg/response" ) -// PackageSilenceHandler 后台沉默期:总开关、延期政策、延期资格、延期订单、线下登记、特殊延期、批次、异常、流水与出库记录。 +// PackageSilenceHandler 后台沉默期:总开关、延期政策、延期资格、延期订单、线下登记、特殊延期、批次、异常、流水与首次购买套餐记录。 // Handler 只做绑定与参数校验,权限点、数据范围、金额与状态由用例判断。 type PackageSilenceHandler struct { switches *packagesilenceapp.SwitchService @@ -341,8 +341,8 @@ func (h *PackageSilenceHandler) ListUsages(c *fiber.Ctx) error { return response.Success(c, result) } -// ListOutboundRecords 资产各世代出库记录。GET /api/admin/assets/:asset_type/:id/outbound-records -func (h *PackageSilenceHandler) ListOutboundRecords(c *fiber.Ctx) error { +// ListFirstPurchaseRecords 资产各世代首次购买套餐记录。GET /api/admin/assets/:asset_type/:id/first-purchase-records +func (h *PackageSilenceHandler) ListFirstPurchaseRecords(c *fiber.Ctx) error { id, err := silencePathID(c) if err != nil { return err @@ -351,7 +351,7 @@ func (h *PackageSilenceHandler) ListOutboundRecords(c *fiber.Ctx) error { if assetType != "card" && assetType != "iot_card" && assetType != "device" { return errors.New(errors.CodeInvalidParam, "资产类型仅支持 card 或 device") } - result, err := h.query.ListOutboundRecords(c.UserContext(), assetType, id) + result, err := h.query.ListFirstPurchaseRecords(c.UserContext(), assetType, id) if err != nil { return err } diff --git a/internal/infrastructure/audit/package_silence.go b/internal/infrastructure/audit/package_silence.go index 5792192d..e4c220c2 100644 --- a/internal/infrastructure/audit/package_silence.go +++ b/internal/infrastructure/audit/package_silence.go @@ -32,7 +32,7 @@ func (w *Writer) AppendSilenceUsage(ctx context.Context, tx *gorm.DB, input Sile return w.Append(ctx, tx, silenceAppendInput(ctx, input.ActionCode, input.Summary, input.Metadata, []ResourceInput{resource})) } -// AppendSilenceResource 追加一条主资源为非套餐记录(套餐、政策、配置、出库记录)的沉默期审计。 +// AppendSilenceResource 追加一条主资源为非套餐记录(套餐、政策、配置、首次购买套餐记录)的沉默期审计。 func (w *Writer) AppendSilenceResource(ctx context.Context, tx *gorm.DB, actionCode, summary string, resource ResourceInput, metadata map[string]any) error { if w == nil { return nil @@ -54,11 +54,11 @@ func SilencePolicyResource(policy *model.PackageSilenceExtensionPolicy, beforeDa } } -// AssetOutboundResource 构造出库记录审计资源。 -func AssetOutboundResource(record *model.AssetOutboundRecord, afterData map[string]any) ResourceInput { +// AssetFirstPurchaseResource 构造首次购买套餐记录审计资源。 +func AssetFirstPurchaseResource(record *model.AssetFirstPurchaseRecord, afterData map[string]any) ResourceInput { id := strconv.FormatUint(uint64(record.ID), 10) return ResourceInput{ - Type: AuditResourceAssetOutboundRecord, ID: &id, Key: id, DisplayName: record.AssetIdentifierSnapshot, + Type: AuditResourceAssetFirstPurchaseRecord, ID: &id, Key: id, DisplayName: record.AssetIdentifierSnapshot, Role: constants.AuditResourceRoleConfig, IdentitySnapshot: map[string]any{ "id": record.ID, "asset_type": record.AssetType, "asset_id": record.AssetID, diff --git a/internal/infrastructure/audit/package_silence_registry.go b/internal/infrastructure/audit/package_silence_registry.go index 9d8adbb8..55f09a27 100644 --- a/internal/infrastructure/audit/package_silence_registry.go +++ b/internal/infrastructure/audit/package_silence_registry.go @@ -10,8 +10,8 @@ const ( AuditResourcePackageSilencePolicy = "package_silence_policy" // AuditResourcePackageSilenceBatch 沉默期申请批次资源(审批关联业务)。 AuditResourcePackageSilenceBatch = "package_silence_batch" - // AuditResourceAssetOutboundRecord 资产出库记录资源。 - AuditResourceAssetOutboundRecord = "asset_outbound_record" + // AuditResourceAssetFirstPurchaseRecord 资产首次购买套餐记录资源。 + AuditResourceAssetFirstPurchaseRecord = "asset_first_purchase_record" ) func silenceAuditOrigins() []ActionOrigin { @@ -61,8 +61,8 @@ func registerPackageSilenceAudit(registry *Registry) { silenceAction(constants.AuditActionSilencePolicyCreated, "新增沉默期延期政策", AuditResourcePackageSilencePolicy, config, normal), silenceAction(constants.AuditActionSilencePolicyUpdated, "修改沉默期延期政策", AuditResourcePackageSilencePolicy, config, normal), silenceAction(constants.AuditActionSilencePolicyStatusChanged, "启停沉默期延期政策", AuditResourcePackageSilencePolicy, config, normal), - silenceAction(constants.AuditActionAssetOutboundRecorded, "记录资产出库时间", AuditResourceAssetOutboundRecord, business, normal), - silenceAction(constants.AuditActionAssetOutboundEnded, "结束资产出库记录", AuditResourceAssetOutboundRecord, business, normal), + silenceAction(constants.AuditActionAssetFirstPurchaseRecorded, "记录资产首次购买套餐时间", AuditResourceAssetFirstPurchaseRecord, business, normal), + silenceAction(constants.AuditActionAssetFirstPurchaseEnded, "结束资产首次购买套餐记录", AuditResourceAssetFirstPurchaseRecord, business, normal), } for _, action := range actions { registry.actionsByCode[action.Code] = action @@ -75,8 +75,8 @@ func registerPackageSilenceAudit(registry *Registry) { Type: AuditResourcePackageSilenceBatch, Name: "沉默期申请批次", IdentityFields: []string{"id", "batch_no", "path", "extension_type", "status", "approval_instance_id"}, } - registry.resources[AuditResourceAssetOutboundRecord] = ResourceDefinition{ - Type: AuditResourceAssetOutboundRecord, Name: "资产出库记录", + registry.resources[AuditResourceAssetFirstPurchaseRecord] = ResourceDefinition{ + Type: AuditResourceAssetFirstPurchaseRecord, Name: "资产首次购买套餐记录", IdentityFields: []string{"id", "asset_type", "asset_id", "generation", "source"}, } } diff --git a/internal/model/device.go b/internal/model/device.go index 019bbc0f..0ac7d2ac 100644 --- a/internal/model/device.go +++ b/internal/model/device.go @@ -35,7 +35,7 @@ type Device struct { FirstRechargeTriggeredBySeriesJSON string `gorm:"column:first_recharge_triggered_by_series;type:jsonb;default:'{}';comment:按套餐系列追踪的首充触发状态" json:"-"` AssetStatus int `gorm:"column:asset_status;type:int;not null;default:1;comment:业务状态 1-在库 2-已销售 3-已换货 4-已停用" json:"asset_status"` Generation int `gorm:"column:generation;type:int;not null;default:1;comment:资产世代编号" json:"generation"` - OutboundAt *time.Time `gorm:"column:outbound_at;comment:出库时间(当前世代首次购买套餐的支付时间)" json:"outbound_at,omitempty"` + FirstPurchaseAt *time.Time `gorm:"column:first_purchase_at;comment:首次购买套餐时间(当前世代第一笔套餐订单的支付时间)" json:"first_purchase_at,omitempty"` // EnablePolling 是否参与轮询,false 时设备下所有卡不加入轮询队列 EnablePolling bool `gorm:"column:enable_polling;not null;default:true" json:"enable_polling"` // 以下字段由 Gateway sync-info 接口同步,有离线存储意义 diff --git a/internal/model/dto/asset_dto.go b/internal/model/dto/asset_dto.go index 7323dcda..9d7cc7c5 100644 --- a/internal/model/dto/asset_dto.go +++ b/internal/model/dto/asset_dto.go @@ -5,21 +5,21 @@ import "time" // AssetResolveResponse 统一资产解析响应 type AssetResolveResponse struct { PackageExpiryEstimate - AssetType string `json:"asset_type" description:"资产类型:card 或 device"` - AssetID uint `json:"asset_id" description:"资产数据库ID"` - Identifier string `json:"identifier,omitempty" description:"解析时使用的标识符"` - VirtualNo string `json:"virtual_no" description:"虚拟号"` - Status int `json:"status" description:"资产归属状态 (1:在库, 2:已分销;卡仍沿用原卡状态枚举)"` - StatusName string `json:"status_name" description:"资产状态名称(中文)"` - BatchNo string `json:"batch_no" description:"批次号"` - OutboundAt *time.Time `json:"outbound_at,omitempty" description:"出库时间(当前世代首次购买套餐的支付时间)"` - ShopID *uint `json:"shop_id,omitempty" description:"所属店铺ID"` - ShopName string `json:"shop_name,omitempty" description:"所属店铺名称"` - SeriesID *uint `json:"series_id,omitempty" description:"套餐系列ID"` - SeriesName string `json:"series_name,omitempty" description:"套餐系列名称"` - ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间(未来准备移除,请勿依赖)"` - CreatedAt time.Time `json:"created_at" description:"创建时间"` - UpdatedAt time.Time `json:"updated_at" description:"更新时间"` + AssetType string `json:"asset_type" description:"资产类型:card 或 device"` + AssetID uint `json:"asset_id" description:"资产数据库ID"` + Identifier string `json:"identifier,omitempty" description:"解析时使用的标识符"` + VirtualNo string `json:"virtual_no" description:"虚拟号"` + Status int `json:"status" description:"资产归属状态 (1:在库, 2:已分销;卡仍沿用原卡状态枚举)"` + StatusName string `json:"status_name" description:"资产状态名称(中文)"` + BatchNo string `json:"batch_no" description:"批次号"` + FirstPurchaseAt *time.Time `json:"first_purchase_at,omitempty" description:"首次购买套餐时间(当前世代第一笔套餐订单的支付时间)"` + ShopID *uint `json:"shop_id,omitempty" description:"所属店铺ID"` + ShopName string `json:"shop_name,omitempty" description:"所属店铺名称"` + SeriesID *uint `json:"series_id,omitempty" description:"套餐系列ID"` + SeriesName string `json:"series_name,omitempty" description:"套餐系列名称"` + ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间(未来准备移除,请勿依赖)"` + CreatedAt time.Time `json:"created_at" description:"创建时间"` + UpdatedAt time.Time `json:"updated_at" description:"更新时间"` // 状态聚合字段 ActivationStatus int `json:"activation_status" description:"激活状态:0未激活 1已激活;设备需有生效中主套餐且任意绑定卡已实名"` ActivationStatusName string `json:"activation_status_name" description:"激活状态名称(中文)"` diff --git a/internal/model/dto/device_dto.go b/internal/model/dto/device_dto.go index afdaf937..4bd3da57 100644 --- a/internal/model/dto/device_dto.go +++ b/internal/model/dto/device_dto.go @@ -24,8 +24,8 @@ type ListDeviceRequest struct { Keyword string `json:"keyword" query:"keyword" validate:"omitempty,max=100" maxLength:"100" description:"关键字搜索,匹配虚拟号或IMEI(模糊查询,与virtual_no/imei独立)"` AuthorizedEnterpriseID *uint `json:"authorized_enterprise_id" query:"authorized_enterprise_id" description:"按有效授权企业ID过滤(只返回当前授权给该企业的设备)"` IsAuthorizedToEnterprise *bool `json:"is_authorized_to_enterprise" query:"is_authorized_to_enterprise" description:"企业授权状态过滤 (true:已授权给某企业, false:未授权任何企业)"` - OutboundStart string `json:"outbound_start" query:"outbound_start" description:"出库时间起(RFC3339,含)"` - OutboundEnd string `json:"outbound_end" query:"outbound_end" description:"出库时间止(RFC3339,含)"` + FirstPurchaseStart string `json:"first_purchase_start" query:"first_purchase_start" description:"首次购买套餐时间起(RFC3339,含)"` + FirstPurchaseEnd string `json:"first_purchase_end" query:"first_purchase_end" description:"首次购买套餐时间止(RFC3339,含)"` } type DeviceResponse struct { @@ -68,7 +68,7 @@ type DeviceResponse struct { AuthorizedEnterpriseID *uint `json:"authorized_enterprise_id" description:"当前有效授权的企业ID,未授权时为 null"` AuthorizedEnterpriseName string `json:"authorized_enterprise_name" description:"当前有效授权的企业名称,未授权时为空字符串"` AssociatedPhones []string `json:"associated_phones" description:"当前关联手机号列表(按资产数据范围返回完整手机号,无关联时为空数组)"` - OutboundAt *time.Time `json:"outbound_at,omitempty" description:"出库时间(当前世代首次购买套餐的支付时间)"` + FirstPurchaseAt *time.Time `json:"first_purchase_at,omitempty" description:"首次购买套餐时间(当前世代第一笔套餐订单的支付时间)"` } type ListDeviceResponse struct { diff --git a/internal/model/dto/iot_card_dto.go b/internal/model/dto/iot_card_dto.go index 82f9997d..ff8ee3b4 100644 --- a/internal/model/dto/iot_card_dto.go +++ b/internal/model/dto/iot_card_dto.go @@ -28,8 +28,8 @@ type ListStandaloneIotCardRequest struct { RealNameStatus *int `json:"real_name_status" query:"real_name_status" validate:"omitempty,min=0,max=1" minimum:"0" maximum:"1" description:"实名状态 (0:未实名, 1:已实名)"` AuthorizedEnterpriseID *uint `json:"authorized_enterprise_id" query:"authorized_enterprise_id" description:"按有效授权企业ID过滤(只返回当前授权给该企业的卡)"` IsAuthorizedToEnterprise *bool `json:"is_authorized_to_enterprise" query:"is_authorized_to_enterprise" description:"企业授权状态过滤 (true:已授权给某企业, false:未授权任何企业)"` - OutboundStart string `json:"outbound_start" query:"outbound_start" description:"出库时间起(RFC3339,含)"` - OutboundEnd string `json:"outbound_end" query:"outbound_end" description:"出库时间止(RFC3339,含)"` + FirstPurchaseStart string `json:"first_purchase_start" query:"first_purchase_start" description:"首次购买套餐时间起(RFC3339,含)"` + FirstPurchaseEnd string `json:"first_purchase_end" query:"first_purchase_end" description:"首次购买套餐时间止(RFC3339,含)"` } type StandaloneIotCardResponse struct { @@ -60,7 +60,7 @@ type StandaloneIotCardResponse struct { GatewayCardIMEI string `json:"gateway_card_imei" description:"插拔卡业务 IMEI(由 Gateway 卡状态接口同步,非设备自身 IMEI,无业务含义,仅供查看)"` DataUsageMB int64 `json:"data_usage_mb" description:"累计流量使用(MB)"` CurrentMonthUsageMB float64 `json:"current_month_usage_mb" description:"系统自然月累计流量(MB,不等于运营商周期口径)"` - OutboundAt *time.Time `json:"outbound_at,omitempty" description:"出库时间(当前世代首次购买套餐的支付时间;绑定在设备上的卡不记录)"` + FirstPurchaseAt *time.Time `json:"first_purchase_at,omitempty" description:"首次购买套餐时间(当前世代第一笔套餐订单的支付时间;绑定在设备上的卡不记录)"` LastGatewayReadingMB float64 `json:"last_gateway_reading_mb" description:"运营商当前周期累计流量读数(MB,展示运营商口径时使用)"` CurrentMonthStartDate *time.Time `json:"current_month_start_date" description:"本月开始日期"` LastMonthTotalMB float64 `json:"last_month_total_mb" description:"上月流量总量(MB)"` diff --git a/internal/model/dto/package_silence_dto.go b/internal/model/dto/package_silence_dto.go index d2285d4c..e4a009c1 100644 --- a/internal/model/dto/package_silence_dto.go +++ b/internal/model/dto/package_silence_dto.go @@ -420,24 +420,23 @@ type SilencePolicyMatchRequest struct { PackageID uint `json:"package_id" query:"package_id" validate:"required,min=1" required:"true" description:"套餐 ID"` } -// AssetOutboundRecordRequest 出库记录查询。 -type AssetOutboundRecordRequest struct { +// AssetFirstPurchaseRecordRequest 首次购买套餐记录查询。 +type AssetFirstPurchaseRecordRequest struct { AssetType string `json:"asset_type" params:"asset_type" path:"asset_type" validate:"required,oneof=card device iot_card" required:"true" description:"资产类型 card/iot_card/device"` ID uint `json:"id" params:"id" path:"id" validate:"required,min=1" required:"true" description:"资产 ID"` } -// AssetOutboundRecordResponse 出库记录。 -type AssetOutboundRecordResponse struct { +// AssetFirstPurchaseRecordResponse 首次购买套餐记录。 +type AssetFirstPurchaseRecordResponse struct { ID uint `json:"id" description:"记录 ID"` AssetType string `json:"asset_type" description:"资产类型"` AssetID uint `json:"asset_id" description:"资产 ID"` AssetIdentifier string `json:"asset_identifier" description:"资产标识快照"` Generation int `json:"generation" description:"世代"` - OutboundAt time.Time `json:"outbound_at" description:"出库时间"` - Source string `json:"source" description:"来源:order / exchange / backfill"` + FirstPurchaseAt time.Time `json:"first_purchase_at" description:"首次购买套餐时间"` + Source string `json:"source" description:"来源:order 首次购买套餐 / backfill 历史回填"` SourceName string `json:"source_name" description:"来源名称"` OrderNo string `json:"order_no,omitempty" description:"订单号"` - ExchangeNo string `json:"exchange_no,omitempty" description:"换货单号"` EndedAt *time.Time `json:"ended_at,omitempty" description:"该世代结束时间"` EndReason string `json:"end_reason,omitempty" description:"结束原因:exchange_return 换货回库"` CreatedAt time.Time `json:"created_at" description:"记录时间"` diff --git a/internal/model/iot_card.go b/internal/model/iot_card.go index 494c2bf5..e7026e5b 100644 --- a/internal/model/iot_card.go +++ b/internal/model/iot_card.go @@ -51,7 +51,7 @@ type IotCard struct { StopReason string `gorm:"column:stop_reason;type:varchar(50);comment:停机原因(traffic_exhausted=流量耗尽,manual=手动停机,arrears=欠费)" json:"stop_reason,omitempty"` AssetStatus int `gorm:"column:asset_status;type:int;not null;default:1;comment:业务状态 1-在库 2-已销售 3-已换货 4-已停用" json:"asset_status"` Generation int `gorm:"column:generation;type:int;not null;default:1;comment:资产世代编号" json:"generation"` - OutboundAt *time.Time `gorm:"column:outbound_at;comment:出库时间(当前世代首次购买套餐的支付时间)" json:"outbound_at,omitempty"` + FirstPurchaseAt *time.Time `gorm:"column:first_purchase_at;comment:首次购买套餐时间(当前世代第一笔套餐订单的支付时间)" json:"first_purchase_at,omitempty"` IsStandalone bool `gorm:"column:is_standalone;type:boolean;default:true;not null;comment:是否为独立卡(未绑定设备) 由触发器自动维护" json:"is_standalone"` VirtualNo string `gorm:"column:virtual_no;type:varchar(50);uniqueIndex:idx_iot_card_virtual_no,where:deleted_at IS NULL AND virtual_no IS NOT NULL AND virtual_no <> '';comment:虚拟号(可选,非空时全局唯一)" json:"virtual_no,omitempty"` DeviceVirtualNo string `gorm:"column:device_virtual_no;type:varchar(100);not null;default:'';comment:绑定设备的虚拟号快照(未绑定时为空字符串)" json:"device_virtual_no"` diff --git a/internal/model/package_silence.go b/internal/model/package_silence.go index e2e21059..a74c7f41 100644 --- a/internal/model/package_silence.go +++ b/internal/model/package_silence.go @@ -218,19 +218,17 @@ func (PackageSilenceExtensionOrder) TableName() string { return "tb_package_silence_extension_order" } -// AssetOutboundRecord 资产出库记录:每个世代一条,只新增与填写结束信息。 -type AssetOutboundRecord struct { +// AssetFirstPurchaseRecord 资产首次购买套餐记录:每个世代一条,只新增与填写结束信息。 +type AssetFirstPurchaseRecord struct { ID uint `gorm:"column:id;primaryKey" json:"id"` AssetType string `gorm:"column:asset_type;type:varchar(20);not null" json:"asset_type"` AssetID uint `gorm:"column:asset_id;not null" json:"asset_id"` AssetIdentifierSnapshot string `gorm:"column:asset_identifier_snapshot;type:varchar(100);not null;default:''" json:"asset_identifier_snapshot"` Generation int `gorm:"column:generation;not null" json:"generation"` - OutboundAt time.Time `gorm:"column:outbound_at;not null" json:"outbound_at"` + FirstPurchaseAt time.Time `gorm:"column:first_purchase_at;not null" json:"first_purchase_at"` Source string `gorm:"column:source;type:varchar(20);not null" json:"source"` OrderID *uint `gorm:"column:order_id" json:"order_id,omitempty"` OrderNo string `gorm:"column:order_no;type:varchar(40);not null;default:''" json:"order_no"` - ExchangeOrderID *uint `gorm:"column:exchange_order_id" json:"exchange_order_id,omitempty"` - ExchangeNo string `gorm:"column:exchange_no;type:varchar(64);not null;default:''" json:"exchange_no"` ShopID *uint `gorm:"column:shop_id" json:"shop_id,omitempty"` EndedAt *time.Time `gorm:"column:ended_at" json:"ended_at,omitempty"` EndReason string `gorm:"column:end_reason;type:varchar(40);not null;default:''" json:"end_reason"` @@ -238,6 +236,6 @@ type AssetOutboundRecord struct { } // TableName 指定表名 -func (AssetOutboundRecord) TableName() string { - return "tb_asset_outbound_record" +func (AssetFirstPurchaseRecord) TableName() string { + return "tb_asset_first_purchase_record" } diff --git a/internal/query/packagesilence/query.go b/internal/query/packagesilence/query.go index e60ec2a3..ebce3fe1 100644 --- a/internal/query/packagesilence/query.go +++ b/internal/query/packagesilence/query.go @@ -1,4 +1,4 @@ -// Package packagesilence 提供沉默期延期订单、批次、即将过期 / 已过期套餐与出库记录的只读查询。 +// Package packagesilence 提供沉默期延期订单、批次、即将过期 / 已过期套餐与首次购买套餐记录的只读查询。 package packagesilence import ( @@ -308,13 +308,13 @@ func (q *Query) FetchUsages(ctx context.Context, query *gorm.DB, offset, limit i return items, nil } -// ListOutboundRecords 按资产查询各世代出库记录(叠加资产数据范围)。 -func (q *Query) ListOutboundRecords(ctx context.Context, assetType string, assetID uint) ([]dto.AssetOutboundRecordResponse, error) { +// ListFirstPurchaseRecords 按资产查询各世代首次购买套餐记录(叠加资产数据范围)。 +func (q *Query) ListFirstPurchaseRecords(ctx context.Context, assetType string, assetID uint) ([]dto.AssetFirstPurchaseRecordResponse, error) { if assetType == "card" { - assetType = constants.OutboundAssetTypeIotCard + assetType = constants.FirstPurchaseAssetTypeIotCard } table := "tb_iot_card" - if assetType == constants.OutboundAssetTypeDevice { + if assetType == constants.FirstPurchaseAssetTypeDevice { table = "tb_device" } var count int64 @@ -324,28 +324,26 @@ func (q *Query) ListOutboundRecords(ctx context.Context, assetType string, asset if count == 0 { return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") } - var records []model.AssetOutboundRecord + var records []model.AssetFirstPurchaseRecord if err := q.db.WithContext(ctx).Where("asset_type = ? AND asset_id = ?", assetType, assetID).Order("generation DESC").Find(&records).Error; err != nil { - return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询出库记录失败") + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询首次购买套餐记录失败") } - items := make([]dto.AssetOutboundRecordResponse, 0, len(records)) + items := make([]dto.AssetFirstPurchaseRecordResponse, 0, len(records)) for _, record := range records { - items = append(items, dto.AssetOutboundRecordResponse{ + items = append(items, dto.AssetFirstPurchaseRecordResponse{ ID: record.ID, AssetType: record.AssetType, AssetID: record.AssetID, AssetIdentifier: record.AssetIdentifierSnapshot, - Generation: record.Generation, OutboundAt: record.OutboundAt, Source: record.Source, SourceName: outboundSourceName(record.Source), - OrderNo: record.OrderNo, ExchangeNo: record.ExchangeNo, EndedAt: record.EndedAt, EndReason: record.EndReason, CreatedAt: record.CreatedAt, + Generation: record.Generation, FirstPurchaseAt: record.FirstPurchaseAt, Source: record.Source, SourceName: firstPurchaseSourceName(record.Source), + OrderNo: record.OrderNo, EndedAt: record.EndedAt, EndReason: record.EndReason, CreatedAt: record.CreatedAt, }) } return items, nil } -func outboundSourceName(source string) string { +func firstPurchaseSourceName(source string) string { switch source { - case constants.OutboundSourceOrder: + case constants.FirstPurchaseSourceOrder: return "首次购买套餐" - case constants.OutboundSourceExchange: - return "换货新资产" - case constants.OutboundSourceBackfill: + case constants.FirstPurchaseSourceBackfill: return "历史回填" default: return source diff --git a/internal/routes/package_silence.go b/internal/routes/package_silence.go index 4c6ec6cb..6e373aa3 100644 --- a/internal/routes/package_silence.go +++ b/internal/routes/package_silence.go @@ -103,9 +103,9 @@ func registerPackageSilenceRoutes(router fiber.Router, handler *admin.PackageSil Summary: "即将过期 / 已过期套餐", Description: "view=expiring(默认 7 天内到期)/ expired / all;导出使用统一导出任务,场景 package_silence_usage,筛选参数同本接口。", Tags: tags, Input: new(dto.SilenceUsageListRequest), Output: new(dto.SilenceUsagePageResult), Auth: true, }) - Register(router, doc, basePath, "GET", "/assets/:asset_type/:id/outbound-records", handler.ListOutboundRecords, RouteSpec{ - Summary: "资产出库记录", Description: "按资产查看各世代出库记录(出库时间、来源、订单号或换货单号、结束时间与原因);只读。", - Tags: []string{"资产 - 出库时间"}, Input: new(dto.AssetOutboundRecordRequest), Output: new([]dto.AssetOutboundRecordResponse), Auth: true, + Register(router, doc, basePath, "GET", "/assets/:asset_type/:id/first-purchase-records", handler.ListFirstPurchaseRecords, RouteSpec{ + Summary: "资产首次购买套餐记录", Description: "按资产查看各世代首次购买套餐记录(首次购买套餐时间、来源(首次购买套餐 / 历史回填)、订单号、结束时间与原因);换货后的新资产在当前世代真正购买套餐后才有记录;只读。", + Tags: []string{"资产 - 首次购买套餐时间"}, Input: new(dto.AssetFirstPurchaseRecordRequest), Output: new([]dto.AssetFirstPurchaseRecordResponse), Auth: true, }) } diff --git a/internal/service/asset/service.go b/internal/service/asset/service.go index 9fac3b79..c2ec508c 100644 --- a/internal/service/asset/service.go +++ b/internal/service/asset/service.go @@ -201,7 +201,7 @@ func (s *Service) buildDeviceResolveResponse(ctx context.Context, device *model. AssetType: "device", AssetID: device.ID, VirtualNo: device.VirtualNo, - OutboundAt: device.OutboundAt, + FirstPurchaseAt: device.FirstPurchaseAt, Status: device.Status, StatusName: constants.GetDeviceStatusName(device.Status), BatchNo: device.BatchNo, @@ -346,7 +346,7 @@ func (s *Service) buildCardResolveResponse(ctx context.Context, card *model.IotC AssetType: "card", AssetID: card.ID, VirtualNo: card.VirtualNo, - OutboundAt: card.OutboundAt, + FirstPurchaseAt: card.FirstPurchaseAt, Status: card.Status, StatusName: constants.GetIotCardStatusName(card.Status), BatchNo: card.BatchNo, diff --git a/internal/service/device/service.go b/internal/service/device/service.go index cddaa7fe..ceacc283 100644 --- a/internal/service/device/service.go +++ b/internal/service/device/service.go @@ -262,7 +262,7 @@ func (s *Service) List(ctx context.Context, req *dto.ListDeviceRequest) (*dto.Li if req.IsAuthorizedToEnterprise != nil { filters["is_authorized_to_enterprise"] = *req.IsAuthorizedToEnterprise } - if err := applyOutboundFilters(filters, req.OutboundStart, req.OutboundEnd); err != nil { + if err := applyFirstPurchaseFilters(filters, req.FirstPurchaseStart, req.FirstPurchaseEnd); err != nil { return nil, err } @@ -895,7 +895,7 @@ func (s *Service) toDeviceResponse(device *model.Device, shopMap map[uint]string activationStatus := activationStatuses[device.ID] resp := &dto.DeviceResponse{ EnablePolling: device.EnablePolling, - OutboundAt: device.OutboundAt, + FirstPurchaseAt: device.FirstPurchaseAt, ID: device.ID, VirtualNo: device.VirtualNo, IMEI: device.IMEI, @@ -1774,21 +1774,21 @@ func (s *Service) recordCardCommand(ctx context.Context, card *model.IotCard, ac }) } -// applyOutboundFilters 解析出库时间范围筛选(RFC3339)。 -func applyOutboundFilters(filters map[string]any, start, end string) error { +// applyFirstPurchaseFilters 解析首次购买套餐时间范围筛选(RFC3339)。 +func applyFirstPurchaseFilters(filters map[string]any, start, end string) error { if start != "" { value, err := time.Parse(time.RFC3339, start) if err != nil { - return errors.New(errors.CodeInvalidParam, "出库时间起格式应为 RFC3339") + return errors.New(errors.CodeInvalidParam, "首次购买套餐时间起格式应为 RFC3339") } - filters["outbound_start"] = value + filters["first_purchase_start"] = value } if end != "" { value, err := time.Parse(time.RFC3339, end) if err != nil { - return errors.New(errors.CodeInvalidParam, "出库时间止格式应为 RFC3339") + return errors.New(errors.CodeInvalidParam, "首次购买套餐时间止格式应为 RFC3339") } - filters["outbound_end"] = value + filters["first_purchase_end"] = value } return nil } diff --git a/internal/service/exchange/service.go b/internal/service/exchange/service.go index 080968ee..d87867b9 100644 --- a/internal/service/exchange/service.go +++ b/internal/service/exchange/service.go @@ -39,7 +39,7 @@ type Service struct { silenceHook *packagesilenceapp.ExchangeHook } -// SetSilenceHook 注入沉默期与出库时间换货接缝。 +// SetSilenceHook 注入沉默期与首次购买套餐时间换货接缝。 func (s *Service) SetSilenceHook(hook *packagesilenceapp.ExchangeHook) { s.silenceHook = hook } @@ -402,7 +402,7 @@ func (s *Service) Renew(ctx context.Context, id uint) error { }).Error; updateErr != nil { return errors.Wrap(errors.CodeDatabaseError, updateErr, "重置旧卡转新状态失败") } - // 出库时间:旧卡回库、世代 +1,清空出库时间并结束旧世代出库记录 + // 首次购买套餐时间:旧卡回库、世代 +1,清空首次购买套餐时间并结束旧世代首次购买套餐记录 if hookErr := s.silenceHook.OnOldAssetRenewedInTx(ctx, tx, constants.ExchangeAssetTypeIotCard, card.ID, card.Generation, time.Now()); hookErr != nil { return hookErr } @@ -479,7 +479,7 @@ func (s *Service) Renew(ctx context.Context, id uint) error { }).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "重置旧设备转新状态失败") } - // 出库时间:旧设备回库、世代 +1,清空出库时间并结束旧世代出库记录 + // 首次购买套餐时间:旧设备回库、世代 +1,清空首次购买套餐时间并结束旧世代首次购买套餐记录 if err = s.silenceHook.OnOldAssetRenewedInTx(ctx, tx, constants.ExchangeAssetTypeDevice, device.ID, device.Generation, time.Now()); err != nil { return err } @@ -897,10 +897,6 @@ func (s *Service) completeExchangeWithTx(ctx context.Context, tx *gorm.DB, order if result.RowsAffected == 0 { return errors.New(errors.CodeExchangeStatusInvalid) } - // 出库时间:换货后的新资产取换货完成时间(当前世代已有出库时间的不覆盖) - if err = s.silenceHook.OnNewAssetInTx(ctx, tx, order.NewAssetType, *order.NewAssetID, order, now); err != nil { - return err - } orderBefore := map[string]any{"status": fromStatus, "migration_completed": order.MigrationCompleted, "migration_balance": order.MigrationBalance, "migration_status": beforeMigrationStatus} order.Status = constants.ExchangeStatusCompleted order.CompletedAt = &now diff --git a/internal/service/iot_card/service.go b/internal/service/iot_card/service.go index 092dad22..66b2fe6f 100644 --- a/internal/service/iot_card/service.go +++ b/internal/service/iot_card/service.go @@ -275,7 +275,7 @@ func (s *Service) ListStandalone(ctx context.Context, req *dto.ListStandaloneIot if req.IsAuthorizedToEnterprise != nil { filters["is_authorized_to_enterprise"] = *req.IsAuthorizedToEnterprise } - if err := applyOutboundFilters(filters, req.OutboundStart, req.OutboundEnd); err != nil { + if err := applyFirstPurchaseFilters(filters, req.FirstPurchaseStart, req.FirstPurchaseEnd); err != nil { return nil, err } @@ -484,7 +484,7 @@ func (s *Service) toStandaloneResponse(card *model.IotCard, shopMap map[uint]str ID: card.ID, ICCID: card.ICCID, VirtualNo: card.VirtualNo, - OutboundAt: card.OutboundAt, + FirstPurchaseAt: card.FirstPurchaseAt, CardCategory: card.CardCategory, CarrierID: card.CarrierID, CarrierType: card.CarrierType, @@ -2078,21 +2078,21 @@ func (s *Service) clearRealnameReversalCounter(ctx context.Context, cardID uint) } } -// applyOutboundFilters 解析出库时间范围筛选(RFC3339)。 -func applyOutboundFilters(filters map[string]any, start, end string) error { +// applyFirstPurchaseFilters 解析首次购买套餐时间范围筛选(RFC3339)。 +func applyFirstPurchaseFilters(filters map[string]any, start, end string) error { if start != "" { value, err := time.Parse(time.RFC3339, start) if err != nil { - return errors.New(errors.CodeInvalidParam, "出库时间起格式应为 RFC3339") + return errors.New(errors.CodeInvalidParam, "首次购买套餐时间起格式应为 RFC3339") } - filters["outbound_start"] = value + filters["first_purchase_start"] = value } if end != "" { value, err := time.Parse(time.RFC3339, end) if err != nil { - return errors.New(errors.CodeInvalidParam, "出库时间止格式应为 RFC3339") + return errors.New(errors.CodeInvalidParam, "首次购买套餐时间止格式应为 RFC3339") } - filters["outbound_end"] = value + filters["first_purchase_end"] = value } return nil } diff --git a/internal/service/order/service.go b/internal/service/order/service.go index 7ee839ee..4e827e51 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -87,7 +87,7 @@ type Service struct { paymentIntegration *integrationlog.Repository billCreation *employeecollectionapp.BillCreationService silenceSetter *packagesilenceapp.Setter - outboundRecorder *packagesilenceapp.OutboundRecorder + firstPurchaseRecorder *packagesilenceapp.FirstPurchaseRecorder } // SetSilenceSetter 注入沉默期设置用例(支付成功创建套餐记录时设置沉默期)。 @@ -95,9 +95,9 @@ func (s *Service) SetSilenceSetter(setter *packagesilenceapp.Setter) { s.silenceSetter = setter } -// SetOutboundRecorder 注入资产出库时间写入用例。 -func (s *Service) SetOutboundRecorder(recorder *packagesilenceapp.OutboundRecorder) { - s.outboundRecorder = recorder +// SetFirstPurchaseRecorder 注入资产首次购买套餐时间写入用例。 +func (s *Service) SetFirstPurchaseRecorder(recorder *packagesilenceapp.FirstPurchaseRecorder) { + s.firstPurchaseRecorder = recorder } // SetEmployeeCollectionBillCreation 注入员工代收款账单建账用例。 @@ -2359,9 +2359,9 @@ func (s *Service) activatePackage(ctx context.Context, tx *gorm.DB, order *model createdUsage = true } } - if createdUsage && s.outboundRecorder != nil { - // 出库时间:资产当前世代第 1 次购买套餐的支付时间(只记录展示,不受沉默期总开关影响) - if err := s.outboundRecorder.RecordForOrderInTx(ctx, tx, order); err != nil { + if createdUsage && s.firstPurchaseRecorder != nil { + // 首次购买套餐时间:资产当前世代第 1 次购买套餐的支付时间(只记录展示,不受沉默期总开关影响) + if err := s.firstPurchaseRecorder.RecordForOrderInTx(ctx, tx, order); err != nil { return err } } diff --git a/internal/store/postgres/device_store.go b/internal/store/postgres/device_store.go index d27ce289..5380a55a 100644 --- a/internal/store/postgres/device_store.go +++ b/internal/store/postgres/device_store.go @@ -231,11 +231,11 @@ func (s *DeviceStore) applyDeviceFilters(ctx context.Context, query *gorm.DB, fi Select("device_id"). Where("enterprise_id = ? AND revoked_at IS NULL AND deleted_at IS NULL", enterpriseID)) } - if start, ok := filters["outbound_start"].(time.Time); ok { - query = query.Where("outbound_at >= ?", start) + if start, ok := filters["first_purchase_start"].(time.Time); ok { + query = query.Where("first_purchase_at >= ?", start) } - if end, ok := filters["outbound_end"].(time.Time); ok { - query = query.Where("outbound_at <= ?", end) + if end, ok := filters["first_purchase_end"].(time.Time); ok { + query = query.Where("first_purchase_at <= ?", end) } if isAuthorized, ok := filters["is_authorized_to_enterprise"].(bool); ok { subQ := s.db.WithContext(ctx).Table("tb_enterprise_device_authorization"). diff --git a/internal/store/postgres/iot_card_store.go b/internal/store/postgres/iot_card_store.go index 52f7312f..09a92fb8 100644 --- a/internal/store/postgres/iot_card_store.go +++ b/internal/store/postgres/iot_card_store.go @@ -915,11 +915,11 @@ func (s *IotCardStore) applyStandaloneFilters(ctx context.Context, query *gorm.D Select("card_id"). Where("enterprise_id = ? AND revoked_at IS NULL AND deleted_at IS NULL", enterpriseID)) } - if start, ok := filters["outbound_start"].(time.Time); ok { - query = query.Where("outbound_at >= ?", start) + if start, ok := filters["first_purchase_start"].(time.Time); ok { + query = query.Where("first_purchase_at >= ?", start) } - if end, ok := filters["outbound_end"].(time.Time); ok { - query = query.Where("outbound_at <= ?", end) + if end, ok := filters["first_purchase_end"].(time.Time); ok { + query = query.Where("first_purchase_at <= ?", end) } if isAuthorized, ok := filters["is_authorized_to_enterprise"].(bool); ok { subQ := s.db.WithContext(ctx).Table("tb_enterprise_card_authorization"). diff --git a/internal/store/postgres/package_silence_store.go b/internal/store/postgres/package_silence_store.go index ecb8a7d4..281706ce 100644 --- a/internal/store/postgres/package_silence_store.go +++ b/internal/store/postgres/package_silence_store.go @@ -278,16 +278,16 @@ func (s *PackageSilenceOrderStore) TransitionStatus(ctx context.Context, id uint return result.RowsAffected, result.Error } -// AssetOutboundRecordStore 资产出库记录 Store(只新增与填写结束信息)。 -type AssetOutboundRecordStore struct{ db *gorm.DB } +// AssetFirstPurchaseRecordStore 资产首次购买套餐记录 Store(只新增与填写结束信息)。 +type AssetFirstPurchaseRecordStore struct{ db *gorm.DB } -// NewAssetOutboundRecordStore 创建出库记录 Store。 -func NewAssetOutboundRecordStore(db *gorm.DB) *AssetOutboundRecordStore { - return &AssetOutboundRecordStore{db: db} +// NewAssetFirstPurchaseRecordStore 创建首次购买套餐记录 Store。 +func NewAssetFirstPurchaseRecordStore(db *gorm.DB) *AssetFirstPurchaseRecordStore { + return &AssetFirstPurchaseRecordStore{db: db} } -// CreateIfAbsent 插入一条出库记录;同一资产同一世代已存在时不插入,返回是否插入。 -func (s *AssetOutboundRecordStore) CreateIfAbsent(ctx context.Context, record *model.AssetOutboundRecord) (bool, error) { +// CreateIfAbsent 插入一条首次购买套餐记录;同一资产同一世代已存在时不插入,返回是否插入。 +func (s *AssetFirstPurchaseRecordStore) CreateIfAbsent(ctx context.Context, record *model.AssetFirstPurchaseRecord) (bool, error) { result := s.db.WithContext(ctx).Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "asset_type"}, {Name: "asset_id"}, {Name: "generation"}}, DoNothing: true, @@ -295,17 +295,17 @@ func (s *AssetOutboundRecordStore) CreateIfAbsent(ctx context.Context, record *m return result.RowsAffected == 1, result.Error } -// EndGeneration 为某资产某世代的出库记录填写结束时间与原因,返回影响行数。 -func (s *AssetOutboundRecordStore) EndGeneration(ctx context.Context, assetType string, assetID uint, generation int, endedAt time.Time, reason string) (int64, error) { - result := s.db.WithContext(ctx).Model(&model.AssetOutboundRecord{}). +// EndGeneration 为某资产某世代的首次购买套餐记录填写结束时间与原因,返回影响行数。 +func (s *AssetFirstPurchaseRecordStore) EndGeneration(ctx context.Context, assetType string, assetID uint, generation int, endedAt time.Time, reason string) (int64, error) { + result := s.db.WithContext(ctx).Model(&model.AssetFirstPurchaseRecord{}). Where("asset_type = ? AND asset_id = ? AND generation = ? AND ended_at IS NULL", assetType, assetID, generation). Updates(map[string]any{"ended_at": endedAt, "end_reason": reason}) return result.RowsAffected, result.Error } -// ListByAsset 按资产查询各世代出库记录(世代倒序)。 -func (s *AssetOutboundRecordStore) ListByAsset(ctx context.Context, assetType string, assetID uint) ([]model.AssetOutboundRecord, error) { - var records []model.AssetOutboundRecord +// ListByAsset 按资产查询各世代首次购买套餐记录(世代倒序)。 +func (s *AssetFirstPurchaseRecordStore) ListByAsset(ctx context.Context, assetType string, assetID uint) ([]model.AssetFirstPurchaseRecord, error) { + var records []model.AssetFirstPurchaseRecord err := s.db.WithContext(ctx).Where("asset_type = ? AND asset_id = ?", assetType, assetID). Order("generation DESC").Find(&records).Error return records, err diff --git a/internal/task/auto_purchase.go b/internal/task/auto_purchase.go index 8a2123a2..c0f7e0ae 100644 --- a/internal/task/auto_purchase.go +++ b/internal/task/auto_purchase.go @@ -56,7 +56,7 @@ type AutoPurchaseHandler struct { priorityEvents priorityapp.PriorityEventWriter auditWriter *audit.Writer silenceSetter *packagesilenceapp.Setter - outboundRecorder *packagesilenceapp.OutboundRecorder + firstPurchaseRecorder *packagesilenceapp.FirstPurchaseRecorder } // SetSilenceSetter 注入沉默期设置用例;自动购包是无人参与路径,只用套餐默认沉默期。 @@ -64,9 +64,9 @@ func (h *AutoPurchaseHandler) SetSilenceSetter(setter *packagesilenceapp.Setter) h.silenceSetter = setter } -// SetOutboundRecorder 注入资产出库时间写入用例。 -func (h *AutoPurchaseHandler) SetOutboundRecorder(recorder *packagesilenceapp.OutboundRecorder) { - h.outboundRecorder = recorder +// SetFirstPurchaseRecorder 注入资产首次购买套餐时间写入用例。 +func (h *AutoPurchaseHandler) SetFirstPurchaseRecorder(recorder *packagesilenceapp.FirstPurchaseRecorder) { + h.firstPurchaseRecorder = recorder } // NewAutoPurchaseHandler 创建充值后自动购包处理器 @@ -626,9 +626,9 @@ func (h *AutoPurchaseHandler) activatePackages( } } } - if createdUsage && h.outboundRecorder != nil { - // 出库时间:资产当前世代第 1 次购买套餐的支付时间 - return h.outboundRecorder.RecordForOrderInTx(ctx, tx, order) + if createdUsage && h.firstPurchaseRecorder != nil { + // 首次购买套餐时间:资产当前世代第 1 次购买套餐的支付时间 + return h.firstPurchaseRecorder.RecordForOrderInTx(ctx, tx, order) } return nil } diff --git a/migrations/000266_rename_outbound_to_first_purchase.down.sql b/migrations/000266_rename_outbound_to_first_purchase.down.sql new file mode 100644 index 00000000..53e23d02 --- /dev/null +++ b/migrations/000266_rename_outbound_to_first_purchase.down.sql @@ -0,0 +1,20 @@ +-- 回滚首次购买套餐时间更名,恢复 000265 的出库时间结构(换货来源的记录不恢复)。 +ALTER INDEX IF EXISTS idx_asset_first_purchase_created RENAME TO idx_asset_outbound_created; +ALTER INDEX IF EXISTS uq_asset_first_purchase_generation RENAME TO uq_asset_outbound_generation; +ALTER TABLE tb_asset_first_purchase_record DROP CONSTRAINT IF EXISTS ck_asset_first_purchase_source; +ALTER TABLE tb_asset_first_purchase_record ADD CONSTRAINT ck_asset_outbound_source CHECK (source IN ('order', 'exchange', 'backfill')); +ALTER TABLE tb_asset_first_purchase_record RENAME CONSTRAINT ck_asset_first_purchase_asset_type TO ck_asset_outbound_asset_type; +ALTER SEQUENCE IF EXISTS tb_asset_first_purchase_record_id_seq RENAME TO tb_asset_outbound_record_id_seq; +ALTER TABLE tb_asset_first_purchase_record RENAME CONSTRAINT tb_asset_first_purchase_record_pkey TO tb_asset_outbound_record_pkey; +ALTER TABLE tb_asset_first_purchase_record ADD COLUMN IF NOT EXISTS exchange_order_id BIGINT; +ALTER TABLE tb_asset_first_purchase_record ADD COLUMN IF NOT EXISTS exchange_no VARCHAR(64) NOT NULL DEFAULT ''; +ALTER TABLE tb_asset_first_purchase_record RENAME COLUMN first_purchase_at TO outbound_at; +ALTER TABLE tb_asset_first_purchase_record RENAME TO tb_asset_outbound_record; +COMMENT ON TABLE tb_asset_outbound_record IS '资产出库记录:每个世代一条,只新增与填写结束信息,不修改出库时间、不删除'; + +ALTER INDEX IF EXISTS idx_device_first_purchase_at RENAME TO idx_device_outbound_at; +ALTER INDEX IF EXISTS idx_iot_card_first_purchase_at RENAME TO idx_iot_card_outbound_at; +ALTER TABLE tb_device RENAME COLUMN first_purchase_at TO outbound_at; +ALTER TABLE tb_iot_card RENAME COLUMN first_purchase_at TO outbound_at; +COMMENT ON COLUMN tb_iot_card.outbound_at IS '出库时间(当前世代首次购买套餐的支付时间)'; +COMMENT ON COLUMN tb_device.outbound_at IS '出库时间(当前世代首次购买套餐的支付时间)'; diff --git a/migrations/000266_rename_outbound_to_first_purchase.up.sql b/migrations/000266_rename_outbound_to_first_purchase.up.sql new file mode 100644 index 00000000..974006aa --- /dev/null +++ b/migrations/000266_rename_outbound_to_first_purchase.up.sql @@ -0,0 +1,27 @@ +-- 出库时间更名为"首次购买套餐时间",并取消换货新资产按换货完成时间记录(add-package-silence-period)。 +-- 口径:资产当前世代第一笔套餐订单(主套餐或加油包)的支付时间;换货后的新资产在当前世代真正购买套餐后才记录。 +-- 设备上绑定的卡不记录;只做记录展示,不参与业务判断,不受沉默期总开关影响。 +ALTER TABLE tb_iot_card RENAME COLUMN outbound_at TO first_purchase_at; +ALTER TABLE tb_device RENAME COLUMN outbound_at TO first_purchase_at; +ALTER INDEX IF EXISTS idx_iot_card_outbound_at RENAME TO idx_iot_card_first_purchase_at; +ALTER INDEX IF EXISTS idx_device_outbound_at RENAME TO idx_device_first_purchase_at; +COMMENT ON COLUMN tb_iot_card.first_purchase_at IS '首次购买套餐时间(当前世代第一笔套餐订单的支付时间;绑定在设备上的卡不记录)'; +COMMENT ON COLUMN tb_device.first_purchase_at IS '首次购买套餐时间(当前世代第一笔套餐订单的支付时间)'; + +DELETE FROM tb_asset_outbound_record WHERE source = 'exchange'; +ALTER TABLE tb_asset_outbound_record RENAME TO tb_asset_first_purchase_record; +ALTER TABLE tb_asset_first_purchase_record RENAME COLUMN outbound_at TO first_purchase_at; +ALTER TABLE tb_asset_first_purchase_record DROP COLUMN IF EXISTS exchange_order_id; +ALTER TABLE tb_asset_first_purchase_record DROP COLUMN IF EXISTS exchange_no; +ALTER TABLE tb_asset_first_purchase_record RENAME CONSTRAINT tb_asset_outbound_record_pkey TO tb_asset_first_purchase_record_pkey; +ALTER SEQUENCE IF EXISTS tb_asset_outbound_record_id_seq RENAME TO tb_asset_first_purchase_record_id_seq; +ALTER TABLE tb_asset_first_purchase_record RENAME CONSTRAINT ck_asset_outbound_asset_type TO ck_asset_first_purchase_asset_type; +ALTER TABLE tb_asset_first_purchase_record DROP CONSTRAINT IF EXISTS ck_asset_outbound_source; +ALTER TABLE tb_asset_first_purchase_record ADD CONSTRAINT ck_asset_first_purchase_source CHECK (source IN ('order', 'backfill')); +ALTER INDEX IF EXISTS uq_asset_outbound_generation RENAME TO uq_asset_first_purchase_generation; +ALTER INDEX IF EXISTS idx_asset_outbound_created RENAME TO idx_asset_first_purchase_created; + +COMMENT ON TABLE tb_asset_first_purchase_record IS '资产首次购买套餐记录:每个世代一条,只新增与填写结束信息,不修改时间、不删除'; +COMMENT ON COLUMN tb_asset_first_purchase_record.first_purchase_at IS '首次购买套餐时间(该世代第一笔套餐订单的支付时间)'; +COMMENT ON COLUMN tb_asset_first_purchase_record.source IS '来源:order-首次购买套餐 backfill-历史回填'; +COMMENT ON COLUMN tb_asset_first_purchase_record.end_reason IS '结束原因:exchange_return-换货回库(世代 +1)'; diff --git a/openspec/changes/add-package-silence-period/design.md b/openspec/changes/add-package-silence-period/design.md index fe6d4b98..39108477 100644 --- a/openspec/changes/add-package-silence-period/design.md +++ b/openspec/changes/add-package-silence-period/design.md @@ -18,14 +18,14 @@ - 每笔实名即生效套餐有沉默期;过期后不能激活、不能退款;可按四种方式延期。 - 总开关默认关闭:上线、配置、回填期间不改变任何现有行为;打开前自动检查。 - 延期订单与线上订单同一套支付(商户池、回调、查单、原路退款),交易可追溯。 -- 卡、设备出库时间与出库记录。 +- 卡、设备首次购买套餐时间与首次购买套餐记录。 **Non-Goals:** - 通知提醒(短信、公众号、弹窗)。 - 延期费分佣、延期费计入代理业绩。 - 运营直接修改截止时间的任何接口。 -- 老系统无订单资产的出库时间补数(后续另行回填)。 +- 老系统无订单资产的首次购买套餐时间补数(后续另行回填)。 ## Decisions @@ -74,13 +74,13 @@ 截止 = 当日 23:59:59(`Asia/Shanghai`);延期按输入天数 `ceil(days / 30)` 计费,延长天数 = 计费天数;新截止从原截止日加。最少月数 `floor(overdue/30) + 1` 保证新截止晚于今天。特殊延期只受最少月数约束。 -### D9 出库时间写在发放套餐的两个入口 +### D9 首次购买套餐时间写在发放套餐的入口 -在 `activatePackage` 与 `activatePackages` 的事务里写,条件 `outbound_at IS NULL AND generation = 订单世代`,影响行数为 1 时插入出库记录;当前绑定在设备上的卡跳过。换货在世代 +1 的同一事务里清空并结束旧记录,新资产写换货完成时间。 +在 `activatePackage` 与 `activatePackages` 的事务里写,条件 `first_purchase_at IS NULL AND generation = 订单世代`,影响行数为 1 时插入首次购买套餐记录;当前绑定在设备上的卡跳过。换货在世代 +1 的同一事务里清空并结束旧记录;换货后的新资产不因换货写入(套餐记录迁移不算购买),在当前世代真正支付套餐订单时才按首次购买写入(10-27 与业务确认,替代技术方案 D28)。 ### D10 历史回填是独立工具,先出清单 -回填(沉默期、出库时间)放在 `cmd/` 下的一次性工具,支持"只出清单"与"执行"两种模式,分批、只处理空值、可重复执行。生产执行前 MUST 由业务确认清单。生产只读拉数遵守项目约束(dbhub 只读)。 +回填(沉默期、首次购买套餐时间)放在 `cmd/` 下的一次性工具,支持"只出清单"与"执行"两种模式,分批、只处理空值、可重复执行。生产执行前 MUST 由业务确认清单。生产只读拉数遵守项目约束(dbhub 只读)。 ## Risks / Trade-offs @@ -95,9 +95,9 @@ ## Migration Plan -迁移①~⑨见技术方案 §13.1:`tb_package_usage` 加 10 个字段与部分索引;`tb_package` 加 2 个字段;新建 5 张表;默认政策;系统配置(总开关默认关闭);扩展 `chk_wecom_approval_scene_business` 加入 `package_silence_approval`;`tb_iot_card`、`tb_device` 加 `outbound_at` 与索引;新建 `tb_asset_outbound_record`。加列使用常量默认值(PostgreSQL 11+ 不重写表),建索引放低峰。 +迁移①~⑨见技术方案 §13.1:`tb_package_usage` 加 10 个字段与部分索引;`tb_package` 加 2 个字段;新建 5 张表;默认政策;系统配置(总开关默认关闭);扩展 `chk_wecom_approval_scene_business` 加入 `package_silence_approval`;`tb_iot_card`、`tb_device` 加 `first_purchase_at` 与索引;新建 `tb_asset_first_purchase_record`。加列使用常量默认值(PostgreSQL 11+ 不重写表),建索引放低峰。 -上线顺序(技术方案 §13.3):测试环境 `up → down → up` 与全量场景验证 → 生产迁移 → 部署(总开关关闭)→ 配置企微模板与权限 → 业务补齐默认沉默期 → 核查新订单字段 → 回填(沉默期、出库时间)→ 超级管理员打开总开关并观察。 +上线顺序(技术方案 §13.3):测试环境 `up → down → up` 与全量场景验证 → 生产迁移 → 部署(总开关关闭)→ 配置企微模板与权限 → 业务补齐默认沉默期 → 核查新订单字段 → 回填(沉默期、首次购买套餐时间)→ 超级管理员打开总开关并观察。 回滚:业务层面用总开关关闭;代码回滚前先关闭总开关;生产不执行 down。 diff --git a/openspec/changes/add-package-silence-period/proposal.md b/openspec/changes/add-package-silence-period/proposal.md index 8e6ea107..cc7b83b8 100644 --- a/openspec/changes/add-package-silence-period/proposal.md +++ b/openspec/changes/add-package-silence-period/proposal.md @@ -1,6 +1,6 @@ ## Why -实名即生效套餐在资产实名前一直处于待生效,现在没有任何期限:客户买了套餐长期不实名,套餐永远挂着,既不能失效也不能收取延期费用。业务已确认《实名即生效套餐沉默期规则需求确认稿_10.16》及 10-27 的补充决定,需要为这类套餐加上沉默期:期限内实名正常生效,过期失效,失效后可付费或经审批延期。同时卡与设备需要记录出库时间(首次购买套餐时间)用于展示。技术方案见 `docs/沉默期技术方案/实名即生效套餐沉默期技术方案_v3.md`(以下简称"技术方案"),规则以技术方案为准。 +实名即生效套餐在资产实名前一直处于待生效,现在没有任何期限:客户买了套餐长期不实名,套餐永远挂着,既不能失效也不能收取延期费用。业务已确认《实名即生效套餐沉默期规则需求确认稿_10.16》及 10-27 的补充决定,需要为这类套餐加上沉默期:期限内实名正常生效,过期失效,失效后可付费或经审批延期。同时卡与设备需要记录首次购买套餐时间(首次购买套餐时间)用于展示。技术方案见 `docs/沉默期技术方案/实名即生效套餐沉默期技术方案_v3.md`(以下简称"技术方案"),规则以技术方案为准。 ## What Changes @@ -15,7 +15,7 @@ - **企微审批**:新增一个审批场景,用"申请类型"区分购买时调整、特殊延期、线下付费延期。 - **换货**:沉默期字段随套餐迁移,不重新起算。 - **历史回填**:按购买时间起算,不给上线宽限;截止前已实名的记为已结束;先出影响清单,业务确认后执行。 -- **出库时间**:卡、设备新增出库时间(当前世代首次购买套餐的支付时间)与出库记录表;设备上绑定的卡不记;换货新资产取换货完成时间;只做记录展示,不受总开关控制。 +- **首次购买套餐时间**:卡、设备新增首次购买套餐时间(当前世代第一笔套餐订单的支付时间,原技术方案称"出库时间")与首次购买套餐记录表;设备上绑定的卡不记;换货后的新资产在当前世代真正购买套餐后才记录;只做记录展示,不受总开关控制。 - 不做通知提醒(确认稿第七节不实现)。 ## Capabilities @@ -24,7 +24,7 @@ - `package-silence-period`:实名即生效套餐沉默期的设置、购买时调整、实名与过期判定、激活拦截、扫描、总开关与历史回填。 - `package-silence-extension`:延期政策、延期资格与计费、四种延期办理、延期订单与支付、企微审批与免审批、延期执行与异常处理。 -- `asset-outbound-time`:卡与设备的出库时间与出库记录。 +- `asset-first-purchase-time`:卡与设备的首次购买套餐时间与首次购买套餐记录。 ### Modified Capabilities @@ -33,8 +33,8 @@ ## Impact -- 数据库:迁移 9 项(技术方案 §13.1)——`tb_package_usage` 新增 10 个沉默期字段与扫描索引;`tb_package` 新增 2 个字段;新建延期政策、沉默期流水、延期订单、延期批次、延期明细 5 张表;默认政策与系统配置(含总开关,默认关闭);扩展企微审批场景业务类型白名单;`tb_iot_card`、`tb_device` 新增出库时间与出库记录表。总开关关闭前不改变任何现有行为。 +- 数据库:迁移 9 项(技术方案 §13.1)——`tb_package_usage` 新增 10 个沉默期字段与扫描索引;`tb_package` 新增 2 个字段;新建延期政策、沉默期流水、延期订单、延期批次、延期明细 5 张表;默认政策与系统配置(含总开关,默认关闭);扩展企微审批场景业务类型白名单;`tb_iot_card`、`tb_device` 新增首次购买套餐时间与首次购买套餐记录表。总开关关闭前不改变任何现有行为。 - 代码:套餐服务与分配、订单支付后发放套餐(`internal/service/order/service.go`、`internal/task/auto_purchase.go`)、激活服务(`internal/service/package/activation_service.go`)、排队与孤儿扫描(`internal/polling/package_activation_handler.go`)、退款、换货、支付回调(`internal/handler/callback/payment.go`)、商户池与支付渠道适配、企微审批(`internal/application/approval/`)、新应用层 `internal/application/packagesilence/`、领域规则 `internal/domain/package/`、定时任务、后台与 H5 Handler、导出、财务流水。 -- 接口:新增延期政策、延期资格、延期下单、线下登记、特殊延期、延期订单查询、总开关、出库记录等接口;套餐、购买、资产套餐、卡与设备列表和详情增加字段。H5 新增延期入口。 -- 数据:上线后先补齐套餐默认沉默期,再执行历史回填与出库时间回填;打开总开关后,截止已过且期内未实名的历史套餐会失效(不给宽限)。 +- 接口:新增延期政策、延期资格、延期下单、线下登记、特殊延期、延期订单查询、总开关、首次购买套餐记录等接口;套餐、购买、资产套餐、卡与设备列表和详情增加字段。H5 新增延期入口。 +- 数据:上线后先补齐套餐默认沉默期,再执行历史回填与首次购买套餐时间回填;打开总开关后,截止已过且期内未实名的历史套餐会失效(不给宽限)。 - 运营配置:延期政策、企微审批模板、权限分配、商户池与线下收款方式检查(技术方案 §18.3)。 diff --git a/openspec/changes/add-package-silence-period/specs/asset-first-purchase-time/spec.md b/openspec/changes/add-package-silence-period/specs/asset-first-purchase-time/spec.md new file mode 100644 index 00000000..1b4d6cb3 --- /dev/null +++ b/openspec/changes/add-package-silence-period/specs/asset-first-purchase-time/spec.md @@ -0,0 +1,49 @@ +## ADDED Requirements + +### Requirement: 卡与设备首次购买套餐时间 + +系统 SHALL 为 IoT 卡与设备记录首次购买套餐时间 `first_purchase_at` = 资产当前世代第一笔套餐订单(主套餐或加油包)的支付成功时间。卡取单卡订单,设备取设备订单;**当前绑定在设备上的卡不记录**(套餐在设备上)。写入后 MUST NOT 被后续订单覆盖,首笔订单退款也不清空。该时间只用于展示、筛选与导出,MUST NOT 参与业务判断,不受沉默期总开关影响。 + +写入发生在订单支付后发放套餐的入口(订单 `activatePackage`、自动购包 `activatePackages`、钱包自动续费)的同一事务内:`UPDATE ... SET first_purchase_at = :paid_at WHERE id = :asset_id AND generation = :order_generation AND first_purchase_at IS NULL`,影响行数为 1 时插入首次购买套餐记录。 + +#### Scenario: 首次购买写入 + +- **WHEN** 新卡当前世代第一次支付套餐订单 +- **THEN** 卡 `first_purchase_at` 为该订单支付时间,并新增一条首次购买套餐记录(来源 `order`,带订单号) + +#### Scenario: 后续订单不覆盖 + +- **WHEN** 同一世代再次购买套餐,或首笔订单被退款 +- **THEN** `first_purchase_at` 不变 + +#### Scenario: 设备上绑定的卡 + +- **WHEN** 设备订单支付成功,设备上绑定了两张卡 +- **THEN** 只写设备的首次购买套餐时间,两张卡不写 + +### Requirement: 首次购买套餐记录 + +系统 SHALL 用 `tb_asset_first_purchase_record` 保存每个世代的首次购买套餐记录:资产类型与 ID、资产标识快照、世代、首次购买套餐时间、来源(`order` / `backfill`)、订单号、店铺、结束时间与原因。同一资产同一世代只有一条(唯一约束);记录只新增与填写结束信息,不修改时间、不删除。系统 SHALL 提供按资产查询各世代记录的接口。 + +#### Scenario: 换货回库后历史可查 + +- **WHEN** 资产换货回库后再次销售并购买套餐 +- **THEN** 能查到两条记录:旧世代带结束时间与原因 `exchange_return`,新世代为新的首次购买套餐时间 + +### Requirement: 换货时的首次购买套餐时间 + +换货时旧资产世代 +1、回到在库的同一事务内,系统 MUST 清空旧资产 `first_purchase_at` 并为旧世代记录填写结束时间与原因。换货后的新资产 MUST NOT 因换货完成而写入首次购买套餐时间;套餐记录随换货迁移不算购买,新资产在当前世代真正支付套餐订单时才按首次购买写入。 + +#### Scenario: 换货新资产未购买套餐 + +- **WHEN** 卡 A 换成卡 B 完成,套餐记录迁移到卡 B,卡 B 当前世代没有支付过套餐订单 +- **THEN** 卡 A 首次购买套餐时间清空;卡 B 首次购买套餐时间为空,直到卡 B 当前世代第一笔套餐订单支付成功 + +### Requirement: 首次购买套餐时间历史回填与展示 + +系统 SHALL 提供首次购买套餐时间回填工具:按资产当前世代最早一笔已支付或已退款的套餐订单回填(排除当前绑定在设备上的卡),分批执行、只更新为空的、可重复执行,并插入来源 `backfill` 的记录;回填前输出可回填数与无订单数。老系统迁移、新系统无订单的资产保持为空,后续另行回填。卡列表、设备列表返回首次购买套餐时间并支持按时间范围筛选;卡详情、设备详情与资产解析返回该时间;卡、设备导出增加"首次购买套餐时间"列。字段与记录只读。 + +#### Scenario: 无订单资产 + +- **WHEN** 老系统迁移的卡在新系统没有任何套餐订单 +- **THEN** 回填后首次购买套餐时间仍为空 diff --git a/openspec/changes/add-package-silence-period/specs/asset-outbound-time/spec.md b/openspec/changes/add-package-silence-period/specs/asset-outbound-time/spec.md deleted file mode 100644 index 8ed350bd..00000000 --- a/openspec/changes/add-package-silence-period/specs/asset-outbound-time/spec.md +++ /dev/null @@ -1,49 +0,0 @@ -## ADDED Requirements - -### Requirement: 卡与设备出库时间 - -系统 SHALL 为 IoT 卡与设备记录出库时间 `outbound_at` = 资产当前世代第 1 次购买套餐(主套餐或加油包)的订单支付成功时间。卡取单卡订单,设备取设备订单;**当前绑定在设备上的卡不记录出库时间**(套餐在设备上)。出库时间写入后 MUST NOT 被后续订单覆盖,首笔订单退款也不清空。出库时间只用于展示、筛选与导出,MUST NOT 参与业务判断,不受沉默期总开关影响。 - -写入发生在订单支付后发放套餐的两个入口(订单 `activatePackage`、自动购包 `activatePackages`)的同一事务内:`UPDATE ... SET outbound_at = :paid_at WHERE id = :asset_id AND generation = :order_generation AND outbound_at IS NULL`,影响行数为 1 时插入出库记录。 - -#### Scenario: 首次购买写入 - -- **WHEN** 新卡当前世代第一次支付套餐订单 -- **THEN** 卡 `outbound_at` 为该订单支付时间,并新增一条出库记录(来源 `order`,带订单号) - -#### Scenario: 后续订单不覆盖 - -- **WHEN** 同一世代再次购买套餐,或首笔订单被退款 -- **THEN** `outbound_at` 不变 - -#### Scenario: 设备上绑定的卡 - -- **WHEN** 设备订单支付成功,设备上绑定了两张卡 -- **THEN** 只写设备的出库时间,两张卡不写 - -### Requirement: 出库记录 - -系统 SHALL 用 `tb_asset_outbound_record` 保存每个世代的出库记录:资产类型与 ID、资产标识快照、世代、出库时间、来源(`order` / `exchange` / `backfill`)、订单号或换货单号、店铺、结束时间与原因。同一资产同一世代只有一条(唯一约束);记录只新增与填写结束信息,不修改出库时间、不删除。系统 SHALL 提供按资产查询各世代出库记录的接口。 - -#### Scenario: 换货后历史可查 - -- **WHEN** 资产换货回库后再次销售并购买套餐 -- **THEN** 能查到两条记录:旧世代带结束时间与原因 `exchange_return`,新世代为新的出库时间 - -### Requirement: 换货时的出库时间 - -换货时旧资产世代 +1、回到在库的同一事务内,系统 MUST 清空旧资产 `outbound_at` 并为旧世代记录填写结束时间与原因;换货后的新资产若当前世代出库时间为空,MUST 写入换货完成时间并插入出库记录(来源 `exchange`,带换货单号)。 - -#### Scenario: 换货新资产 - -- **WHEN** 卡 A 换成卡 B 完成 -- **THEN** 卡 A 出库时间清空,卡 B 出库时间为换货完成时间 - -### Requirement: 出库时间历史回填与展示 - -系统 SHALL 提供出库时间回填工具:按资产当前世代最早一笔已支付或已退款的套餐订单回填(排除当前绑定在设备上的卡),分批执行、只更新为空的、可重复执行,并插入来源 `backfill` 的出库记录;回填前输出可回填数与无订单数。老系统迁移、新系统无订单的资产保持为空,后续另行回填。卡列表、设备列表返回出库时间并支持按时间范围筛选;卡详情、设备详情与资产解析返回出库时间;卡、设备导出增加"出库时间"列。字段与记录只读。 - -#### Scenario: 无订单资产 - -- **WHEN** 老系统迁移的卡在新系统没有任何套餐订单 -- **THEN** 回填后出库时间仍为空 diff --git a/openspec/changes/add-package-silence-period/tasks.md b/openspec/changes/add-package-silence-period/tasks.md index d4f1c2fe..56c3b109 100644 --- a/openspec/changes/add-package-silence-period/tasks.md +++ b/openspec/changes/add-package-silence-period/tasks.md @@ -8,10 +8,10 @@ - [x] 1.4 迁移④:新建 `tb_package_silence_extension_policy`、`tb_package_silence_record`、`tb_package_silence_extension_order`、`tb_package_silence_extension_batch`、`tb_package_silence_extension`,含 §7.1 / §7.2 的唯一约束与部分唯一索引(同一套餐最多一笔未结束订单、线下收款方式 + 交易单号唯一、`UNIQUE(batch_id, package_usage_id)`、同一套餐最多一条未结束明细) - [x] 1.5 迁移⑤⑥:插入默认延期政策(全部套餐、长期、500 分/月、免费 1 次、累计 6 个月、单次不限、无免审批场景);插入系统配置 `package.silence.enabled`(false)、`package.silence.default_days`(30)、`package.silence.extension_unit_price`(500) - [x] 1.6 迁移⑦:扩展 `tb_wecom_approval_scene.chk_wecom_approval_scene_business`,在 `000216` 现有白名单基础上加入 `package_silence_approval`;down 恢复原白名单 -- [x] 1.7 迁移⑧⑨:`tb_iot_card`、`tb_device` 新增 `outbound_at timestamptz NULL` 与索引;新建 `tb_asset_outbound_record`(`UNIQUE(asset_type, asset_id, generation)`) +- [x] 1.7 迁移⑧⑨:`tb_iot_card`、`tb_device` 新增 `first_purchase_at timestamptz NULL` 与索引;新建 `tb_asset_first_purchase_record`(`UNIQUE(asset_type, asset_id, generation)`) - [x] 1.8 测试库执行 `up → down → up`,确认约束、索引、默认数据正确 - [x] 1.9 模型:`PackageUsage`、`Package`、`IotCard`、`Device` 增加字段;新增 6 个表的模型 -- [x] 1.10 Store:延期政策、沉默期流水、延期订单、批次、明细、出库记录的 Store(条件更新方法返回影响行数) +- [x] 1.10 Store:延期政策、沉默期流水、延期订单、批次、明细、首次购买套餐记录的 Store(条件更新方法返回影响行数) - [x] 1.11 常量与错误码:`silence_status` 取值、延期路径与类型、免审批场景枚举、`order_type = silence_extension`、审批业务类型 `package_silence_approval`、审计动作(§11.2)、权限点(§11.1)、错误码(§12.5) ## 2. 领域规则(§4.2、§5.2、§8.2、§3.6) @@ -79,12 +79,13 @@ - [x] 8.2 回调分发:按审批实例关联的调整申请 / 延期批次 / 延期订单处理通过、驳回、撤回;重复事件只处理一次 - [x] 8.3 调整审批通过后重算截止日并调用 `ResolveUsageSilence`;驳回保持默认值并立即判定 -## 9. 换货与出库时间(§9、§17) +## 9. 换货与首次购买套餐时间(§9、§17) - [x] 9.1 换货迁移写沉默期流水(换货单号、前后资产、截止时间);确认 `expired` 记录随迁移且不重算 -- [x] 9.2 出库时间写入函数:订单 `activatePackage` 与自动购包入口调用;条件 `outbound_at IS NULL AND generation = 订单世代`;绑定在设备上的卡跳过;影响行数为 1 时插入出库记录 -- [x] 9.3 换货:旧资产世代 +1 的同一事务清空 `outbound_at` 并结束旧记录;新资产写换货完成时间与 `exchange` 记录 -- [x] 9.4 卡 / 设备列表返回并可按出库时间筛选;详情与资产解析返回;导出增加"出库时间"列;出库记录查询接口 +- [x] 9.2 首次购买套餐时间写入函数:订单 `activatePackage` 与自动购包入口调用;条件 `first_purchase_at IS NULL AND generation = 订单世代`;绑定在设备上的卡跳过;影响行数为 1 时插入首次购买套餐记录 +- [x] 9.3 换货:旧资产世代 +1 的同一事务清空 `first_purchase_at` 并结束旧记录;换货后的新资产不因换货写入,当前世代真正购买套餐时才记录 + - 备注(2026-10-27):按业务决定取消"新资产写换货完成时间与 `exchange` 记录",并把"出库时间"更名为"首次购买套餐时间"(字段 `first_purchase_at`、表 `tb_asset_first_purchase_record`、接口 `/first-purchase-records`),迁移 000266 在测试库已 up → down → up。 +- [x] 9.4 卡 / 设备列表返回并可按首次购买套餐时间筛选;详情与资产解析返回;导出增加"首次购买套餐时间"列;首次购买套餐记录查询接口 ## 10. 展示、导出、文档与回填工具(§12、§6、§17.4) @@ -92,7 +93,7 @@ - [x] 10.2 财务交易流水(`internal/query/audit/finance.go`)纳入延期订单支付类型 - [x] 10.3 新增 Handler 同步路由、`cmd/api/docs.go`、`cmd/gendocs/main.go`,重新生成 OpenAPI 文档 - [x] 10.4 沉默期回填工具(`cmd/`):`--dry-run` 输出影响清单(§6 第 7 条各项);执行模式分批、只处理空值、可重复执行、按实名时间判定 `ended`、写流水 -- [x] 10.5 出库时间回填工具:按当前世代最早已支付 / 已退款套餐订单回填,排除绑定在设备上的卡,插入 `backfill` 记录,输出可回填数与无订单数 +- [x] 10.5 首次购买套餐时间回填工具:按当前世代最早已支付 / 已退款套餐订单回填,排除绑定在设备上的卡,插入 `backfill` 记录,输出可回填数与无订单数 - [x] 10.6 前端接口变更说明(新增接口、字段、错误码、总开关对页面的影响) - [x] 10.7 `gofmt`、`go build ./...`、`go vet`、`openspec validate add-package-silence-period` @@ -105,17 +106,17 @@ - [ ] 11.5 四种延期办理(§14 第 16~21 条):H5 微信或支付宝真实小额支付、后台二维码扫码、线下登记审批、特殊与免费延期、批量部分失败、跨天支付异常退款 - [ ] 11.6 延期政策与免审批开关(§14 第 22~23 条) - [ ] 11.7 换货与历史回填(§14 第 24~25 条):回填 dry-run 清单与执行、重复执行结果不变 -- [ ] 11.8 出库时间(§17.6 全部场景) +- [ ] 11.8 首次购买套餐时间(§17.6 全部场景) - [ ] 11.9 应急关闭:打开后关闭,确认规则暂停、数据保留、进行中支付回调照常完成 - [ ] 11.10 清理本变更创建的测试数据,记录验证结果到本文件 ## 实施备注(2026-10-27) -- 迁移编号 000259~000265(测试库 `junhong_cmp_test` 已执行 up → down 7 → up,默认政策、三项系统配置、扫描索引、部分唯一索引、审批白名单核对正确)。 +- 迁移编号 000259~000266(000266 为首次购买套餐时间更名;测试库 `junhong_cmp_test` 已执行 up → down 7 → up,默认政策、三项系统配置、扫描索引、部分唯一索引、审批白名单核对正确)。 - 审批关联:三种申请共用 `package_silence_approval`,审批实例 `business_id` 统一为批次 ID;购买时调整也建批次(`path = adjust`),线下付费的批次与订单一一对应。 - H5 自助与后台二维码都复用代理在线充值的渠道适配器 `CreatePaymentURL`,返回 `qr_content`(支付链接 / 二维码内容),未新增 JSAPI 支付方式。 - 在线订单执行异常(跨天、金额不一致、订单关闭后到账等)自动按冻结商户原路退款,恢复任务 `package:silence:order_recover` 每 5 分钟重试;线下与特殊延期的异常只能人工重试或关闭。 - 支付截止时间不跨天(不晚于当天 23:59:59),降低跨天支付进入异常的概率。 - 总开关在通用系统配置接口中只读,只能通过 `PUT /api/admin/package-silence/switch` 切换;进程内缓存 5 秒。 -- 回填工具:`cmd/silence-backfill`(`-target silence|outbound`,默认只出清单,`-execute` 执行);2026-10-27 在测试库 dry-run:沉默期候选 1 条(套餐未配置默认沉默期、不回填),出库时间可回填卡 9 / 设备 6、无订单卡 7、绑定在设备上的卡 15。 +- 回填工具:`cmd/silence-backfill`(`-target silence|first_purchase`,默认只出清单,`-execute` 执行);2026-10-27 在测试库 dry-run:沉默期候选 1 条(套餐未配置默认沉默期、不回填),首次购买套餐时间可回填卡 9 / 设备 6、无订单卡 7、绑定在设备上的卡 15。 - 前端说明:`docs/2026-10-27-实名即生效套餐沉默期-前端接口变更说明.md`。 diff --git a/pkg/constants/package_silence.go b/pkg/constants/package_silence.go index 69936469..ccdd7477 100644 --- a/pkg/constants/package_silence.go +++ b/pkg/constants/package_silence.go @@ -67,7 +67,7 @@ const ( SilencePurchaseAdjustDaysMax = 3650 SilenceExtensionOptionsMax = 24 SilenceBackfillBatchSize = 500 - SilenceOutboundBackfillBatchSize = 5000 + SilenceFirstPurchaseBackfillBatchSize = 5000 SilenceSwitchPrecheckUnconfiguredPackages = "unconfigured_packages" SilenceSwitchPrecheckUnbackfilledUsages = "unbackfilled_usages" SilenceSwitchPrecheckDefaultPolicyMissing = "default_policy_missing" @@ -264,25 +264,24 @@ const ( AuditActionSilencePolicyCreated = "package_silence_policy.created" AuditActionSilencePolicyUpdated = "package_silence_policy.updated" AuditActionSilencePolicyStatusChanged = "package_silence_policy.status_changed" - AuditActionAssetOutboundRecorded = "asset.outbound_recorded" - AuditActionAssetOutboundEnded = "asset.outbound_ended" + AuditActionAssetFirstPurchaseRecorded = "asset.first_purchase_recorded" + AuditActionAssetFirstPurchaseEnded = "asset.first_purchase_ended" ) -// 资产出库记录。 +// 资产首次购买套餐记录。 const ( - OutboundSourceOrder = "order" - OutboundSourceExchange = "exchange" - OutboundSourceBackfill = "backfill" - OutboundEndReasonExchange = "exchange_return" - OutboundAssetTypeIotCard = "iot_card" - OutboundAssetTypeDevice = "device" - SilenceAssetTypeIotCard = "iot_card" - SilenceAssetTypeDevice = "device" - SilenceAuditResourceUsage = "package_usage" - SilenceAuditResourcePackage = "package" - SilenceAuditResourcePolicy = "package_silence_policy" - SilenceAuditResourceConfig = "system_config" - SilenceAuditResourceAsset = "asset" + FirstPurchaseSourceOrder = "order" + FirstPurchaseSourceBackfill = "backfill" + FirstPurchaseEndReasonExchange = "exchange_return" + FirstPurchaseAssetTypeIotCard = "iot_card" + FirstPurchaseAssetTypeDevice = "device" + SilenceAssetTypeIotCard = "iot_card" + SilenceAssetTypeDevice = "device" + SilenceAuditResourceUsage = "package_usage" + SilenceAuditResourcePackage = "package" + SilenceAuditResourcePolicy = "package_silence_policy" + SilenceAuditResourceConfig = "system_config" + SilenceAuditResourceAsset = "asset" ) // 沉默期审批业务字段(企微场景配置把模板控件映射到这些字段)。 diff --git a/pkg/queue/handler.go b/pkg/queue/handler.go index 5f235220..9712f9a5 100644 --- a/pkg/queue/handler.go +++ b/pkg/queue/handler.go @@ -405,7 +405,7 @@ func (h *Handler) registerAutoPurchaseHandler() { ) if services := h.workerResult.Services; services != nil { autoPurchaseHandler.SetSilenceSetter(services.SilenceSetter) - autoPurchaseHandler.SetOutboundRecorder(services.SilenceOutbound) + autoPurchaseHandler.SetFirstPurchaseRecorder(services.SilenceFirstPurchase) } h.mux.HandleFunc(constants.TaskTypeAutoPurchaseAfterRecharge, autoPurchaseHandler.ProcessTask) h.logger.Info("注册自动购包任务处理器", zap.String("task_type", constants.TaskTypeAutoPurchaseAfterRecharge)) diff --git a/pkg/queue/types.go b/pkg/queue/types.go index 211aed54..7873cffa 100644 --- a/pkg/queue/types.go +++ b/pkg/queue/types.go @@ -92,11 +92,11 @@ type WorkerServices struct { AssetPolling *pollingSvc.AssetPollingService // IotCardPolling 是 AssetPolling 使用的卡服务,Worker 启动时注入轮询生命周期回调。 IotCardPolling *iotCardSvc.Service - // 沉默期:总开关、统一判定、设置与出库时间用例(扫描、自动购包、延期执行共用)。 - SilenceSwitch *packagesilenceApp.Switch - SilenceResolver *packagesilenceApp.Resolver - SilenceSetter *packagesilenceApp.Setter - SilenceOutbound *packagesilenceApp.OutboundRecorder + // 沉默期:总开关、统一判定、设置与首次购买套餐时间用例(扫描、自动购包、延期执行共用)。 + SilenceSwitch *packagesilenceApp.Switch + SilenceResolver *packagesilenceApp.Resolver + SilenceSetter *packagesilenceApp.Setter + SilenceFirstPurchase *packagesilenceApp.FirstPurchaseRecorder // SilenceExtensions 执行沉默期审批通过后的延期、延期订单查单恢复与异常原路退款。 SilenceExtensions *packagesilenceApp.ExtensionService }