问题:上游 0666.pro 兑换返回 HTTP 200 + status=pending(官方处理中), 旧逻辑仅凭「没有 error 字段」就判定成功并写 redeemed 终态,导致本地显示 已发货而上游仍是 bound,且 redeemed 为硬终态无法自愈。 后端: - RedeemSkin 改为三态判定(success / pending / failed),不再无 error 即成功 - 新增 redeeming「发货处理中」状态;pending 不写 redeemed_at/fulfill_id - 成功落库前二次 refresh 确认上游 redeemed 或 redeemed_at 才置终态 - 新增确认 worker:20s 轮询、30s→5m 指数退避、110001 全局冷却 60s、 15 分钟未确认仅告警不回退,确认后同步订单为 delivered 并触发回调 - pending 期间禁止重复兑换,订单保持 delivering,不下发发货成功回调 - 新增 rock_delivery_logs 表与记录器:入站参数、内部状态、上游请求/响应 原文全部留档,保留 180 天 - 分配/绑定校验/刷新/导入/删除/回收站/统计/客服查询全部适配 redeeming - 迁移 026:redeeming 跟踪字段、部分索引与报文日志表 前端: - Token 列表新增「发货处理中」筛选项、标签与超时提示 - 删除/回收站纳入 redeeming 危险状态 - 历史抽屉拆为「状态历史 / 发货报文」双页签,按 trace 展示报文详情 - 买家发货页在 pending 时提示官方处理中,无需重复提交 测试:新增 pending 不落终态、异常响应不误判、重复提交不重复兑换、 确认后同步订单、保持 pending 并退避 5 个用例;go build/vet/test 与 前端构建均通过。
236 lines
15 KiB
Go
236 lines
15 KiB
Go
package model
|
|
|
|
import (
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// Rock 相关常量
|
|
const (
|
|
// RockDefaultBaseURL 是 Rock 上游平台的默认地址,只作为「初始值 / 兜底值」。
|
|
// 运行期一律以 rock_configs.base_url 为准:启动时由
|
|
// RockService.ApplyConfigToClient 同步到 HTTP 客户端,否则后台改过的地址
|
|
// 会在进程重启后失效。
|
|
//
|
|
// 注意:迁移文件 012_rock_fulfillment.sql 中的默认值受 checksum 保护,
|
|
// 不能为了统一而修改它,否则已有部署会因 checksum mismatch 启动失败。
|
|
RockDefaultBaseURL = "https://0666.pro"
|
|
|
|
RockColorBlack = "black"
|
|
RockColorBlue = "blue"
|
|
|
|
RockStatusAvailable = "available" // 可用库存
|
|
RockStatusUsing = "using" // 已分配给订单,正在提货扫码
|
|
RockStatusBound = "bound" // 扫码绑定成功
|
|
RockStatusRedeeming = "redeeming" // 上游官方异步处理中,等待确认发货结果
|
|
RockStatusRedeemed = "redeemed" // 发货完成
|
|
RockStatusMismatch = "mismatch" // UID 校验不符阻断(绑错)
|
|
RockStatusAbnormal = "abnormal" // 异常/作废
|
|
|
|
RockSKUBlackTshirt = "rock_black_tshirt"
|
|
RockSKUBlueSportswear = "rock_blue_sportswear"
|
|
|
|
RockSkinBlackTshirt = "黑色ROCK长袖T恤"
|
|
RockSkinBlueSportswear = "蓝色运动服上衣"
|
|
)
|
|
|
|
// RockConfig 平台配置项
|
|
type RockConfig struct {
|
|
ID uint `gorm:"primaryKey;default:1" json:"id"`
|
|
AutoDeliveryEnabled bool `gorm:"not null;default:true" json:"auto_delivery_enabled"` // 全局自动发货总开关
|
|
BaseURL string `gorm:"size:255;not null;default:'https://0666.pro'" json:"base_url"` // 默认值见 RockDefaultBaseURL
|
|
BearerToken string `gorm:"type:text;not null;default:''" json:"bearer_token"`
|
|
LoginUsername string `gorm:"size:128;not null;default:''" json:"login_username"`
|
|
LoginPasswordCiphertext string `gorm:"type:text;not null;default:''" json:"-"`
|
|
LoginPasswordSet bool `gorm:"-" json:"login_password_set"`
|
|
LastTokenRefreshAt *time.Time `json:"last_token_refresh_at,omitempty"`
|
|
LastTokenError string `gorm:"type:text;not null;default:''" json:"last_token_error,omitempty"`
|
|
Username string `gorm:"size:128;not null;default:''" json:"username"`
|
|
Points float64 `gorm:"type:numeric(12,2);not null;default:0.00" json:"points"`
|
|
MinPointsWarning float64 `gorm:"type:numeric(12,2);not null;default:1050.00" json:"min_points_warning"`
|
|
BlackTokenCost float64 `gorm:"type:numeric(12,2);not null;default:20.00" json:"black_token_cost"`
|
|
TokenWarningStock int `gorm:"not null;default:10" json:"token_warning_stock"`
|
|
UsingWarnDays int `gorm:"not null;default:3" json:"using_warn_days"` // 使用中超过 N 天橙色预警
|
|
UsingDangerDays int `gorm:"not null;default:5" json:"using_danger_days"` // 使用中超过 N 天红色预警
|
|
BoundWarnDays int `gorm:"not null;default:2" json:"bound_warn_days"` // 已绑定超过 N 天预警
|
|
AutoRefreshMismatchEnabled bool `gorm:"not null;default:false" json:"auto_refresh_mismatch_enabled"` // 绑定错误自动刷新
|
|
AutoPurchaseEnabled bool `gorm:"not null;default:false" json:"auto_purchase_enabled"`
|
|
AutoPurchaseCount int `gorm:"not null;default:50" json:"auto_purchase_count"`
|
|
PurchaseCooldownMinutes int `gorm:"not null;default:10" json:"purchase_cooldown_minutes"`
|
|
LastPurchasedAt *time.Time `json:"last_purchased_at"`
|
|
LastAttemptAt *time.Time `json:"last_attempt_at,omitempty"` // 最近一次采购尝试(成功/失败)时间,用于失败冷却
|
|
LastAutoAttemptAt *time.Time `json:"last_auto_attempt_at,omitempty"` // 最近一次自动补货尝试时间,自动冷却只认这个
|
|
LastError string `gorm:"type:text;not null;default:''" json:"last_error"` // 最近一次补货/打包错误,成功后清空
|
|
LastErrorAt *time.Time `json:"last_error_at,omitempty"`
|
|
LastHeartbeatAt *time.Time `json:"last_heartbeat_at"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
func (RockConfig) TableName() string {
|
|
return "rock_configs"
|
|
}
|
|
|
|
// RockToken 通用 Token 本地库存池记录
|
|
type RockToken struct {
|
|
ID uint `gorm:"primaryKey" json:"id"`
|
|
UpstreamTokenID string `gorm:"size:64;not null;default:''" json:"upstream_token_id"`
|
|
Token string `gorm:"type:text;not null" json:"-"`
|
|
RedeemCode string `gorm:"size:64;not null;index" json:"redeem_code"`
|
|
Color string `gorm:"size:32;not null;default:'black';index" json:"color"`
|
|
Region string `gorm:"size:64;not null;default:'Q/V可用'" json:"region"`
|
|
Status string `gorm:"size:32;not null;default:'available';index" json:"status"`
|
|
PackageID *int64 `gorm:"index" json:"package_id,omitempty"`
|
|
PackageShareCode string `gorm:"size:64;not null;default:''" json:"package_share_code"`
|
|
AssignedOrderNo string `gorm:"size:128;index" json:"assigned_order_no"`
|
|
ClientOrderNo string `gorm:"-" json:"client_order_no,omitempty"` // 关联的创建订单商户单号
|
|
TokenCode string `gorm:"-" json:"token,omitempty"` // 提取码,仅管理端响应展示,不持久化
|
|
ExpectedUID string `gorm:"size:64" json:"expected_uid"`
|
|
ActualRoleName string `gorm:"size:128" json:"actual_role_name"`
|
|
ActualRoleID string `gorm:"size:64" json:"actual_role_id"`
|
|
ActualChannel string `gorm:"size:64" json:"actual_channel"`
|
|
InitialBoundSnapshot string `gorm:"type:text" json:"-"` // 分配时初始旧数据快照 JSON
|
|
UpstreamFulfillID int64 `json:"upstream_fulfill_id"`
|
|
TargetSkin string `gorm:"size:128" json:"target_skin"`
|
|
TargetCurrency string `gorm:"size:64;not null;default:'幸运币'" json:"target_currency"`
|
|
TargetCurrencyQuantity int `gorm:"not null;default:0" json:"target_currency_quantity"`
|
|
MismatchAt *time.Time `gorm:"index" json:"mismatch_at"`
|
|
MismatchReason string `gorm:"size:255" json:"mismatch_reason"`
|
|
Remark string `gorm:"type:text;not null;default:''" json:"remark"`
|
|
RawData string `gorm:"type:text;not null;default:''" json:"raw_data,omitempty"`
|
|
Source string `gorm:"size:32;not null;default:'purchase';index" json:"source"`
|
|
ClaimedAt *time.Time `json:"claimed_at"`
|
|
UsingAt *time.Time `gorm:"index" json:"using_at"` // 变为 using 的时间,超时预警起点
|
|
RestockedAt *time.Time `gorm:"index" json:"restocked_at,omitempty"` // 人工释放回库存的时间,分配时优先(FIFO)
|
|
BoundAt *time.Time `json:"bound_at"`
|
|
RedeemedAt *time.Time `json:"redeemed_at"`
|
|
|
|
// 异步兑换(上游 pending)跟踪字段。
|
|
RedeemRequestedAt *time.Time `gorm:"index" json:"redeem_requested_at,omitempty"` // 首次提交兑换的时间,用于 15 分钟超时告警
|
|
RedeemCheckedAt *time.Time `json:"redeem_checked_at,omitempty"` // 最近一次查询上游结果的时间
|
|
RedeemAttempts int `gorm:"not null;default:0" json:"redeem_attempts"`
|
|
RedeemNextRetryAt *time.Time `gorm:"index" json:"redeem_next_retry_at,omitempty"`
|
|
RedeemLastMessage string `gorm:"type:text;not null;default:''" json:"redeem_last_message,omitempty"`
|
|
RedeemLastError string `gorm:"type:text;not null;default:''" json:"redeem_last_error,omitempty"`
|
|
RedeemTimeoutWarnedAt *time.Time `json:"redeem_timeout_warned_at,omitempty"`
|
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
DeletedAtText string `gorm:"-" json:"deleted_at,omitempty"` // 回收站展示用,仅管理端响应返回
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
|
|
// 以下为列表派生字段,不落库:由后端用与筛选/统计完全相同的 now 计算。
|
|
BoundOverdue bool `gorm:"-" json:"bound_overdue"`
|
|
UsingOverdue bool `gorm:"-" json:"using_overdue"`
|
|
UsingDanger bool `gorm:"-" json:"using_danger"`
|
|
BoundDays float64 `gorm:"-" json:"bound_days"`
|
|
UsingDays float64 `gorm:"-" json:"using_days"`
|
|
}
|
|
|
|
func (RockToken) TableName() string {
|
|
return "rock_tokens"
|
|
}
|
|
|
|
// RockTokenStatusLog 记录 Token 的生命周期与状态流转,供状态历史时间线使用。
|
|
type RockTokenStatusLog struct {
|
|
ID uint `gorm:"primaryKey" json:"id"`
|
|
TokenID uint `gorm:"not null;index" json:"token_id"`
|
|
UpstreamTokenID string `gorm:"size:64;not null;default:''" json:"upstream_token_id"`
|
|
EventType string `gorm:"size:32;not null" json:"event_type"` // created / status_changed / deleted / restored
|
|
FromStatus string `gorm:"size:32;not null;default:''" json:"from_status"`
|
|
ToStatus string `gorm:"size:32;not null;default:''" json:"to_status"`
|
|
Source string `gorm:"size:32;not null;default:''" json:"source"`
|
|
Reason string `gorm:"size:255;not null;default:''" json:"reason"`
|
|
Forced bool `gorm:"not null;default:false" json:"forced"`
|
|
ActorUserID *uint `gorm:"index" json:"actor_user_id,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
func (RockTokenStatusLog) TableName() string {
|
|
return "rock_token_status_logs"
|
|
}
|
|
|
|
// RockDeliveryLog 记录一次发货链路的完整报文(本系统入站 / 上游请求 / 上游响应 / 内部状态)。
|
|
// 用于订单排障与对账;按 180 天保留,由后台任务定期清理。
|
|
type RockDeliveryLog struct {
|
|
ID uint `gorm:"primaryKey" json:"id"`
|
|
TraceID string `gorm:"size:64;not null;default:'';index:idx_rock_delivery_logs_trace" json:"trace_id"`
|
|
TokenID uint `gorm:"not null;default:0;index:idx_rock_delivery_logs_token" json:"token_id"`
|
|
UpstreamTokenID string `gorm:"size:64;not null;default:''" json:"upstream_token_id"`
|
|
OrderNo string `gorm:"size:128;not null;default:'';index:idx_rock_delivery_logs_order" json:"order_no"`
|
|
Step string `gorm:"size:40;not null;default:'';index" json:"step"`
|
|
Direction string `gorm:"size:16;not null;default:''" json:"direction"` // inbound / outbound / internal
|
|
Status string `gorm:"size:16;not null;default:'';index" json:"status"` // started / success / pending / failed / skipped
|
|
HTTPMethod string `gorm:"size:8;not null;default:''" json:"http_method"`
|
|
HTTPURL string `gorm:"type:text;not null;default:''" json:"http_url"`
|
|
HTTPStatus int `gorm:"not null;default:0" json:"http_status"`
|
|
RequestHeaders string `gorm:"type:text;not null;default:''" json:"request_headers"`
|
|
RequestBody string `gorm:"type:text;not null;default:''" json:"request_body"`
|
|
ResponseHeaders string `gorm:"type:text;not null;default:''" json:"response_headers"`
|
|
ResponseBody string `gorm:"type:text;not null;default:''" json:"response_body"`
|
|
DurationMs int `gorm:"not null;default:0" json:"duration_ms"`
|
|
ErrorMessage string `gorm:"type:text;not null;default:''" json:"error_message"`
|
|
ActorUserID *uint `gorm:"index" json:"actor_user_id,omitempty"`
|
|
ActorIP string `gorm:"size:64;not null;default:''" json:"actor_ip"`
|
|
UserAgent string `gorm:"size:512;not null;default:''" json:"user_agent"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
|
}
|
|
|
|
func (RockDeliveryLog) TableName() string {
|
|
return "rock_delivery_logs"
|
|
}
|
|
|
|
// RockPurchaseLog 是一次采购尝试的主记录。
|
|
// 每一次真正进入采购流程的尝试都会落一条,包含降级原因与各阶段汇总。
|
|
type RockPurchaseLog struct {
|
|
ID uint `gorm:"primaryKey" json:"id"`
|
|
TriggerType string `gorm:"size:16;not null;default:'auto';index:idx_rock_purchase_logs_trigger_status" json:"trigger_type"` // auto / manual
|
|
Status string `gorm:"size:16;not null;default:'running'" json:"status"` // running / success / partial / failed / skipped
|
|
RequestedCount int `gorm:"not null;default:0" json:"requested_count"`
|
|
PlannedCount int `gorm:"not null;default:0" json:"planned_count"`
|
|
ClaimedCount int `gorm:"not null;default:0" json:"claimed_count"`
|
|
ImportedCount int `gorm:"not null;default:0" json:"imported_count"`
|
|
UpstreamAvailable int `gorm:"not null;default:0" json:"upstream_available"`
|
|
PointsBefore float64 `gorm:"type:numeric(12,2);not null;default:0" json:"points_before"`
|
|
UnitCost float64 `gorm:"type:numeric(12,2);not null;default:0" json:"unit_cost"`
|
|
MaxByPoints int `gorm:"not null;default:0" json:"max_by_points"`
|
|
MaxByStock int `gorm:"not null;default:0" json:"max_by_stock"`
|
|
LimitReason string `gorm:"size:32;not null;default:''" json:"limit_reason"` // points / upstream_stock / points_and_stock
|
|
PointsSource string `gorm:"size:16;not null;default:''" json:"points_source"` // live / snapshot
|
|
Stage string `gorm:"size:32;not null;default:''" json:"stage"`
|
|
ErrorMessage string `gorm:"type:text;not null;default:''" json:"error_message"`
|
|
PackageID *int64 `json:"package_id,omitempty"`
|
|
ShareCode string `gorm:"size:64;not null;default:''" json:"share_code"`
|
|
DurationMs int `gorm:"not null;default:0" json:"duration_ms"`
|
|
StartedAt time.Time `json:"started_at"`
|
|
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
func (RockPurchaseLog) TableName() string {
|
|
return "rock_purchase_logs"
|
|
}
|
|
|
|
// RockPurchaseStage 记录一次采购中单个上游调用的原始请求与响应。
|
|
// ResponseBody 保存上游返回的原始 JSON 原文;超过 512KB 时截断并置 Truncated。
|
|
type RockPurchaseStage struct {
|
|
ID uint `gorm:"primaryKey" json:"id"`
|
|
LogID uint `gorm:"not null;index:idx_rock_purchase_stages_log_id" json:"log_id"`
|
|
Seq int `gorm:"not null;default:0" json:"seq"`
|
|
Stage string `gorm:"size:32;not null" json:"stage"`
|
|
Method string `gorm:"size:8;not null;default:''" json:"method"`
|
|
URL string `gorm:"type:text;not null;default:''" json:"url"`
|
|
RequestBody string `gorm:"type:text;not null;default:''" json:"request_body"`
|
|
HTTPStatus int `gorm:"not null;default:0" json:"http_status"`
|
|
ResponseBody string `gorm:"type:text;not null;default:''" json:"response_body"`
|
|
Truncated bool `gorm:"not null;default:false" json:"truncated"`
|
|
DurationMs int `gorm:"not null;default:0" json:"duration_ms"`
|
|
ErrorMessage string `gorm:"type:text;not null;default:''" json:"error_message"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
func (RockPurchaseStage) TableName() string {
|
|
return "rock_purchase_stages"
|
|
}
|