feat: 卡流量读数先过采集队列再入账,扣减按采集时刻确定套餐
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m27s

- 每卡 Redis 采集队列(默认 N=3、跨天清空、回退容差 1 MB),攒满且不下降才写最后一个点
- 测试环境全量切换日快照计量,下线灰度名单、切换水位与卡表基线扣费;全部卡置待建起点
- 读数区分没给值与 0;有套餐的卡读数 0 也写快照与日用量 0 行;重置日比较基准为 0 照常防旧值
- 新增采集记录(按月分区保留 90 天)、人工冲正流水与后台接口、采集告警
- 扣减按采集时刻有效套餐查找,只对生效中套餐做状态迁移,查不到套餐按成功跳过
- 换货新卡(新设备下的卡)打待建起点;采集间隔上限只校验启用中的轮询配置

Change: add-card-traffic-queue-and-observed-deduction

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
邹进
2026-10-29 12:18:47 +09:00
parent c33cc6f6c3
commit 9c3c258f16
55 changed files with 3142 additions and 881 deletions

View File

@@ -0,0 +1,54 @@
package model
import (
"time"
"gorm.io/datatypes"
)
// CardTrafficCollection 卡流量采集记录:采集时刻有套餐的卡每次采集一条,按月分区保留 90 天。
type CardTrafficCollection struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
IotCardID uint `gorm:"column:iot_card_id;not null" json:"iot_card_id"`
ObservedAt time.Time `gorm:"column:observed_at;primaryKey;not null" json:"observed_at"`
ObservationID string `gorm:"column:observation_id;type:varchar(128);not null;default:''" json:"observation_id"`
Source string `gorm:"column:source;type:varchar(32);not null;default:''" json:"source"`
Scene string `gorm:"column:scene;type:varchar(64);not null;default:''" json:"scene"`
Result string `gorm:"column:result;type:varchar(32);not null" json:"result"`
VendorUsedMB *float64 `gorm:"column:vendor_used_mb;type:numeric(14,3)" json:"vendor_used_mb"`
QueuePoints datatypes.JSON `gorm:"column:queue_points;type:jsonb" json:"queue_points"`
Decision string `gorm:"column:decision;type:varchar(32);not null;default:''" json:"decision"`
MonthUsedMB *float64 `gorm:"column:month_used_mb;type:numeric(14,3)" json:"month_used_mb"`
StaleGuarded *bool `gorm:"column:stale_guarded" json:"stale_guarded"`
DeltaMB *float64 `gorm:"column:delta_mb;type:numeric(14,3)" json:"delta_mb"`
EventID string `gorm:"column:event_id;type:varchar(128);not null;default:''" json:"event_id"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:now()" json:"created_at"`
}
// TableName 指定表名。
func (CardTrafficCollection) TableName() string {
return "tb_card_traffic_collection"
}
// PackageUsageAdjustment 套餐流量人工冲正流水:只新增,不修改、不删除。
type PackageUsageAdjustment struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
PackageUsageID uint `gorm:"column:package_usage_id;not null" json:"package_usage_id"`
IotCardID uint `gorm:"column:iot_card_id;not null;default:0" json:"iot_card_id"`
DeviceID uint `gorm:"column:device_id;not null;default:0" json:"device_id"`
DeltaMB int64 `gorm:"column:delta_mb;not null" json:"delta_mb"`
BeforeUsageMB int64 `gorm:"column:before_usage_mb;not null" json:"before_usage_mb"`
AfterUsageMB int64 `gorm:"column:after_usage_mb;not null" json:"after_usage_mb"`
BeforeStatus int `gorm:"column:before_status;not null" json:"before_status"`
AfterStatus int `gorm:"column:after_status;not null" json:"after_status"`
Reason string `gorm:"column:reason;type:varchar(500);not null" json:"reason"`
RefObservationID string `gorm:"column:ref_observation_id;type:varchar(128);not null;default:''" json:"ref_observation_id"`
RefEventID string `gorm:"column:ref_event_id;type:varchar(128);not null;default:''" json:"ref_event_id"`
OperatorID uint `gorm:"column:operator_id;not null" json:"operator_id"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:now()" json:"created_at"`
}
// TableName 指定表名。
func (PackageUsageAdjustment) TableName() string {
return "tb_package_usage_adjustment"
}

View File

@@ -1,6 +1,9 @@
package dto
import "time"
import (
"encoding/json"
"time"
)
// AssetResolveResponse 统一资产解析响应
type AssetResolveResponse struct {
@@ -129,22 +132,32 @@ type BoundCardInfo struct {
// AssetRealtimeStatusResponse 资产实时状态(只读DB/Redis)
type AssetRealtimeStatusResponse struct {
AssetType string `json:"asset_type" description:"资产类型:card 或 device"`
AssetID uint `json:"asset_id" description:"资产ID"`
NetworkStatus int `json:"network_status" description:"网络状态(asset_type=card时有效):0停机 1开机"`
GatewayExtend string `json:"gateway_extend" description:"Gateway 卡状态扩展字段,原样返回上游 extend,用于展示运营商侧实际停机原因"`
GatewayCardIMEI string `json:"gateway_card_imei" description:"插拔卡业务 IMEI(由 Gateway 卡状态接口同步,非设备自身 IMEI,无业务含义,仅供查看)"`
RealNameStatus int `json:"real_name_status" description:"实名状态(asset_type=card时有效):0未实名 1已实名"`
RealnamePolicy string `json:"realname_policy,omitempty" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"`
CurrentMonthUsageMB float64 `json:"current_month_usage_mb,omitempty" description:"系统自然月累计流量(MB,asset_type=card时有效,不等于运营商周期口径)"`
LastGatewayReadingMB float64 `json:"last_gateway_reading_mb,omitempty" description:"运营商当前周期累计流量读数(MB,asset_type=card时有效,运营商口径展示建议读取该字段)"`
LastSyncTime *time.Time `json:"last_sync_time" description:"最后同步时间(asset_type=card时有效)"`
LastDataCheckAt *time.Time `json:"last_data_check_at" description:"最后一次流量检查时间(asset_type=card时有效)"`
LastRealNameCheckAt *time.Time `json:"last_real_name_check_at" description:"最后一次实名检查时间(asset_type=card时有效)"`
LastCardStatusCheckAt *time.Time `json:"last_card_status_check_at" description:"最后一次卡状态检查时间(asset_type=card时有效)"`
DeviceProtectStatus string `json:"device_protect_status,omitempty" deprecated:"true" description:"已废弃:设备保护期已下线,固定返回 none,仅为兼容保留(asset_type=device时有效)"`
Cards []BoundCardInfo `json:"cards,omitempty" description:"绑定卡状态列表(asset_type=device时有效)"`
DeviceRealtime *DeviceGatewayInfo `json:"device_realtime" description:"设备实时状态(来自 Gateway sync-info,始终返回,失败时仅含 gateway_msg)"`
AssetType string `json:"asset_type" description:"资产类型:card 或 device"`
AssetID uint `json:"asset_id" description:"资产ID"`
NetworkStatus int `json:"network_status" description:"网络状态(asset_type=card时有效):0停机 1开机"`
GatewayExtend string `json:"gateway_extend" description:"Gateway 卡状态扩展字段,原样返回上游 extend,用于展示运营商侧实际停机原因"`
GatewayCardIMEI string `json:"gateway_card_imei" description:"插拔卡业务 IMEI(由 Gateway 卡状态接口同步,非设备自身 IMEI,无业务含义,仅供查看)"`
RealNameStatus int `json:"real_name_status" description:"实名状态(asset_type=card时有效):0未实名 1已实名"`
RealnamePolicy string `json:"realname_policy,omitempty" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"`
CurrentMonthUsageMB float64 `json:"current_month_usage_mb,omitempty" description:"系统自然月累计流量(MB,asset_type=card时有效,不等于运营商周期口径)"`
LastGatewayReadingMB float64 `json:"last_gateway_reading_mb,omitempty" description:"运营商当前周期累计流量读数(MB,asset_type=card时有效,运营商口径展示建议读取该字段)"`
LastSyncTime *time.Time `json:"last_sync_time" description:"最后同步时间(asset_type=card时有效)"`
LastDataCheckAt *time.Time `json:"last_data_check_at" description:"最后一次流量检查时间(asset_type=card时有效)"`
LastRealNameCheckAt *time.Time `json:"last_real_name_check_at" description:"最后一次实名检查时间(asset_type=card时有效)"`
LastCardStatusCheckAt *time.Time `json:"last_card_status_check_at" description:"最后一次卡状态检查时间(asset_type=card时有效)"`
DeviceProtectStatus string `json:"device_protect_status,omitempty" deprecated:"true" description:"已废弃:设备保护期已下线,固定返回 none,仅为兼容保留(asset_type=device时有效)"`
Cards []BoundCardInfo `json:"cards,omitempty" description:"绑定卡状态列表(asset_type=device时有效)"`
DeviceRealtime *DeviceGatewayInfo `json:"device_realtime" description:"设备实时状态(来自 Gateway sync-info,始终返回,失败时仅含 gateway_msg)"`
TrafficRefresh *CardTrafficRefreshResult `json:"traffic_refresh,omitempty" description:"本次刷新的流量写入结果(仅刷新单卡接口返回)"`
}
// CardTrafficRefreshResult 人工刷新卡流量的写入结果。
type CardTrafficRefreshResult struct {
ReadingMB *float64 `json:"reading_mb" description:"本次运营商读数(MB),没有给值时为空"`
Result string `json:"result" description:"读数结果:ok/failed/rate_limited/invalid_payload/no_reading/invalid_reading"`
Written bool `json:"written" description:"本次读数是否写入日快照"`
Decision string `json:"decision" description:"写入判断:written/baseline/queue_not_full/slope_negative/out_of_order,读数无效时为空"`
DeltaMB float64 `json:"delta_mb" description:"本次写入流量事件的增量(MB)"`
}
// AssetPackageResponse 资产套餐信息
@@ -252,6 +265,78 @@ type UpdateAssetPackageExpiresAtRequest struct {
}
// UpdateAssetPackageUsageRequest 修改资产套餐已用量请求
// AdjustAssetPackageTrafficRequest 人工冲正套餐流量请求。
type AdjustAssetPackageTrafficRequest struct {
Identifier string `path:"identifier" description:"资产标识符(虚拟号/ICCID/IMEI/SN/MSISDN)" required:"true"`
PackageUsageID uint `path:"package_usage_id" description:"套餐使用记录ID" required:"true"`
DeltaMB int64 `json:"delta_mb" validate:"required,ne=0" required:"true" description:"调整量(MB):正数补扣,负数退回,不能为 0"`
Reason string `json:"reason" validate:"required,max=500" required:"true" maxLength:"500" description:"冲正原因"`
RefObservationID string `json:"ref_observation_id" validate:"omitempty,max=128" maxLength:"128" description:"关联的采集 ID(可选)"`
RefEventID string `json:"ref_event_id" validate:"omitempty,max=128" maxLength:"128" description:"关联的流量事件 ID(可选)"`
}
// PackageTrafficAdjustmentListRequest 冲正流水查询请求。
type PackageTrafficAdjustmentListRequest struct {
Identifier string `path:"identifier" description:"资产标识符(虚拟号/ICCID/IMEI/SN/MSISDN)" required:"true"`
PackageUsageID uint `path:"package_usage_id" description:"套餐使用记录ID" required:"true"`
Page int `query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码,默认 1"`
PageSize int `query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页条数,默认 20"`
}
// PackageTrafficAdjustmentItem 冲正流水。
type PackageTrafficAdjustmentItem struct {
ID uint `json:"id" description:"流水ID"`
PackageUsageID uint `json:"package_usage_id" description:"套餐使用记录ID"`
DeltaMB int64 `json:"delta_mb" description:"调整量(MB):正数补扣,负数退回"`
BeforeUsageMB int64 `json:"before_usage_mb" description:"调整前已用量(MB)"`
AfterUsageMB int64 `json:"after_usage_mb" description:"调整后已用量(MB)"`
BeforeStatus int `json:"before_status" description:"调整前套餐状态"`
AfterStatus int `json:"after_status" description:"调整后套餐状态"`
Reason string `json:"reason" description:"冲正原因"`
RefObservationID string `json:"ref_observation_id" description:"关联的采集 ID"`
RefEventID string `json:"ref_event_id" description:"关联的流量事件 ID"`
OperatorID uint `json:"operator_id" description:"操作人账号ID"`
CreatedAt time.Time `json:"created_at" description:"冲正时间"`
}
// PackageTrafficAdjustmentListResponse 冲正流水分页结果。
type PackageTrafficAdjustmentListResponse struct {
Items []PackageTrafficAdjustmentItem `json:"items" description:"冲正流水"`
Total int64 `json:"total" description:"总数"`
}
// CardTrafficCollectionListRequest 卡流量采集记录查询请求。
type CardTrafficCollectionListRequest struct {
Identifier string `path:"identifier" description:"卡标识符(虚拟号/ICCID/MSISDN)" required:"true"`
StartTime string `query:"start_time" validate:"required" required:"true" description:"开始时间(RFC3339 或 2006-01-02 15:04:05,上海时间)"`
EndTime string `query:"end_time" validate:"required" required:"true" description:"结束时间(不含),最长跨度 31 天"`
Page int `query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码,默认 1"`
PageSize int `query:"page_size" validate:"omitempty,min=1,max=200" minimum:"1" maximum:"200" description:"每页条数,默认 50"`
}
// CardTrafficCollectionItem 卡流量采集记录。
type CardTrafficCollectionItem struct {
ID uint `json:"id" description:"记录ID"`
ObservedAt time.Time `json:"observed_at" description:"采集时间"`
ObservationID string `json:"observation_id" description:"采集 ID"`
Source string `json:"source" description:"来源:polling/manual_sync/business_event"`
Scene string `json:"scene" description:"场景"`
Result string `json:"result" description:"读数结果:ok/failed/rate_limited/invalid_payload/no_reading/invalid_reading"`
VendorUsedMB *float64 `json:"vendor_used_mb" description:"运营商原始读数(MB)"`
QueuePoints json.RawMessage `json:"queue_points" description:"入队后的采集队列"`
Decision string `json:"decision" description:"写入判断:written/baseline/queue_not_full/slope_negative/out_of_order/settled_existing"`
MonthUsedMB *float64 `json:"month_used_mb" description:"写入时换算后的本月累计(MB)"`
StaleGuarded *bool `json:"stale_guarded" description:"写入时是否触发重置日防旧值"`
DeltaMB *float64 `json:"delta_mb" description:"写入时本次增量(MB)"`
EventID string `json:"event_id" description:"流量事件 ID"`
}
// CardTrafficCollectionListResponse 卡流量采集记录分页结果。
type CardTrafficCollectionListResponse struct {
Items []CardTrafficCollectionItem `json:"items" description:"采集记录"`
Total int64 `json:"total" description:"总数"`
}
type UpdateAssetPackageUsageRequest struct {
Identifier string `path:"identifier" description:"资产标识符(虚拟号/ICCID/IMEI/SN/MSISDN)" required:"true"`
PackageUsageID uint `path:"package_usage_id" description:"套餐使用记录ID" required:"true"`

View File

@@ -45,18 +45,19 @@ type IotCard struct {
FirstRechargeTriggeredBySeriesJSON string `gorm:"column:first_recharge_triggered_by_series;type:jsonb;default:'{}';comment:按套餐系列追踪的首充触发状态" json:"-"`
// 任务 24.1: 停复机相关字段
FirstRealnameAt *time.Time `gorm:"column:first_realname_at;comment:首次实名时间(用于触发首次实名激活)" json:"first_realname_at,omitempty"`
StoppedAt *time.Time `gorm:"column:stopped_at;comment:停机时间" json:"stopped_at,omitempty"`
ResumedAt *time.Time `gorm:"column:resumed_at;comment:最近复机时间" json:"resumed_at,omitempty"`
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"`
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"`
LastGatewayReadingMB float64 `gorm:"column:last_gateway_reading_mb;type:float;not null;default:0;comment:运营商当前周期累计流量读数(MB,来自网关)" json:"last_gateway_reading_mb"`
GatewayExtend string `gorm:"column:gateway_extend;type:text;not null;default:'';comment:Gateway 卡状态扩展字段,原样保存上游 extend,用于展示运营商侧实际停机原因" json:"gateway_extend"`
FirstRealnameAt *time.Time `gorm:"column:first_realname_at;comment:首次实名时间(用于触发首次实名激活)" json:"first_realname_at,omitempty"`
StoppedAt *time.Time `gorm:"column:stopped_at;comment:停机时间" json:"stopped_at,omitempty"`
ResumedAt *time.Time `gorm:"column:resumed_at;comment:最近复机时间" json:"resumed_at,omitempty"`
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"`
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"`
LastGatewayReadingMB float64 `gorm:"column:last_gateway_reading_mb;type:float;not null;default:0;comment:运营商当前周期累计流量读数(MB,来自网关)" json:"last_gateway_reading_mb"`
TrafficBaselinePending bool `gorm:"column:traffic_baseline_pending;type:boolean;not null;default:false;comment:卡流量待建起点:第一次通过采集队列写入时只建起点不扣费" json:"traffic_baseline_pending"`
GatewayExtend string `gorm:"column:gateway_extend;type:text;not null;default:'';comment:Gateway 卡状态扩展字段,原样保存上游 extend,用于展示运营商侧实际停机原因" json:"gateway_extend"`
// GatewayCardIMEI 为插拔卡业务中网关上报的设备 IMEI,非设备自身 IMEI,仅用于数据同步落库
GatewayCardIMEI string `gorm:"column:gateway_card_imei;type:varchar(50);not null;default:'';comment:插拔卡业务 IMEI,网关卡状态接口同步,非设备 IMEI" json:"gateway_card_imei"`
RealnamePolicy string `gorm:"column:realname_policy;type:varchar(20);default:'after_order';not null;comment:实名认证策略(none=无需实名,before_order=先实名后充值/购买,after_order=先充值/购买后实名)" json:"realname_policy"`