feat(rock): 发货记录表格新增时间与备注列并支持上报失效联动

- 做了什么:
  1. 数据库新增 013_rock_tokens_remark.sql 迁移,rock_tokens 表补充 remark 字段并在 ListTokens 中支持备注模糊检索;
  2. 后端扩展 UpdateTokenRemark、MarkTokenAbnormal 接口,同时优化 ResetMismatchToken 支持 mismatch 与 abnormal 状态的解冻重置;
  3. 前端 RockTokenTable 新增「流转时间」列(入库/绑错/发货/绑定/提货)与「备注」列(TokenRemarkCell 浮窗快捷预设选择与自定义输入);
  4. 快捷选择「已上报」或点击「上报失效」自动将链接标记为 abnormal 作废,从可用库存池中剔除,防止被新订单领走;
  5. 优化操作列与顶部筛选栏:mismatch 增加「上报失效」与警示重置;abnormal 状态明确显示已失效并提供强制恢复;筛选栏新增「已上报失效」Tab。

- 为什么:
  解决绑定错误上报 0868 上游后无法记录备注、且原「解冻重置」容易误将已作废卡密放回可用库存池导致后续订单报错的问题。

- 验证方式:
  1. 后端单元测试 TestRockService_TokenRemarkAndAbnormal 与 TestRock 测试集通过;
  2. make fmt、go vet 通过;
  3. 前端 oxlint 检查通过 (0 errors, 0 warnings),npm run build 生产构建通过。
This commit is contained in:
yml2213
2026-10-08 20:13:25 +08:00
parent fcbd7715c7
commit e293cebaf5
13 changed files with 656 additions and 15 deletions
@@ -0,0 +1,5 @@
-- ============================================================================
-- 013_rock_tokens_remark.sql: 为 rock_tokens 表新增 remark 备注字段
-- ============================================================================
ALTER TABLE rock_tokens ADD COLUMN IF NOT EXISTS remark TEXT NOT NULL DEFAULT '';
+52
View File
@@ -169,3 +169,55 @@ func (h *RockHandler) ReleaseToken(c *gin.Context) {
}
response.OK(c, gin.H{"message": "已释放回可用库存,该提货链接可以再次分配"})
}
type updateRemarkReq struct {
Remark string `json:"remark"`
Status string `json:"status"` // 可选:比如 abnormal
}
// UpdateRemark 更新 Token 备注并可联动更新状态
func (h *RockHandler) UpdateRemark(c *gin.Context) {
idUint, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "无效的 Token ID")
return
}
var req updateRemarkReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数解析失败")
return
}
token, err := h.rockSvc.UpdateTokenRemark(c.Request.Context(), uint(idUint), req.Remark, req.Status)
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, gin.H{
"message": "备注更新成功",
"token": token,
})
}
type markAbnormalReq struct {
Remark string `json:"remark"`
}
// MarkAbnormal 快捷标记为上报失效(作废,不可再用)
func (h *RockHandler) MarkAbnormal(c *gin.Context) {
idUint, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "无效的 Token ID")
return
}
var req markAbnormalReq
_ = c.ShouldBindJSON(&req)
token, err := h.rockSvc.MarkTokenAbnormal(c.Request.Context(), uint(idUint), req.Remark)
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, gin.H{
"message": "已标记为上报失效,该提货链接已作废",
"token": token,
})
}
+1
View File
@@ -77,6 +77,7 @@ type RockToken struct {
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"`
ClaimedAt *time.Time `json:"claimed_at"`
BoundAt *time.Time `json:"bound_at"`
RedeemedAt *time.Time `json:"redeemed_at"`
+2
View File
@@ -279,6 +279,8 @@ func Setup(h *Handlers) *gin.Engine {
platform.POST("/platform/rock/tokens/:id/expected-uid", h.Rock.CorrectTokenUID)
platform.POST("/platform/rock/tokens/:id/reset", h.Rock.ResetToken)
platform.POST("/platform/rock/tokens/:id/release", h.Rock.ReleaseToken)
platform.POST("/platform/rock/tokens/:id/remark", h.Rock.UpdateRemark)
platform.POST("/platform/rock/tokens/:id/abnormal", h.Rock.MarkAbnormal)
}
}
@@ -996,3 +996,71 @@ func TestRockProvider_CheckBindRefreshesSameTokenLink(t *testing.T) {
t.Fatalf("其他订单不能拿到这个提货号的二维码: %s", checkOther.BindURL)
}
}
func TestRockService_TokenRemarkAndAbnormal(t *testing.T) {
db := testdb.New(t)
client := NewRockClient("https://0666.pro")
rockSvc := NewRockService(db, client)
now := time.Now()
token := model.RockToken{
UpstreamTokenID: "TEST_REMARK_1",
Token: "jwt-remark-1",
RedeemCode: "CODE_REMARK_1",
Status: model.RockStatusMismatch,
AssignedOrderNo: "ORDER_REMARK_1",
ExpectedUID: "123456",
ActualRoleID: "654321",
ActualRoleName: "路人甲",
MismatchAt: &now,
MismatchReason: "预期UID不符",
}
if err := db.Create(&token).Error; err != nil {
t.Fatalf("create test token failed: %v", err)
}
// 1. 测试单纯更新备注
updated, err := rockSvc.UpdateTokenRemark(context.Background(), token.ID, "买家联系客服核对中", "")
if err != nil {
t.Fatalf("update remark failed: %v", err)
}
if updated.Remark != "买家联系客服核对中" || updated.Status != model.RockStatusMismatch {
t.Fatalf("更新备注不符: remark=%s status=%s", updated.Remark, updated.Status)
}
// 2. 测试快捷标记为已上报失效 (MarkTokenAbnormal)
abnormalToken, err := rockSvc.MarkTokenAbnormal(context.Background(), token.ID, "已上报0868客服 (卡密绑定错误废弃)")
if err != nil {
t.Fatalf("mark abnormal failed: %v", err)
}
if abnormalToken.Status != model.RockStatusAbnormal {
t.Fatalf("标记异常后状态应为 abnormal,实际: %s", abnormalToken.Status)
}
if abnormalToken.Remark != "已上报0868客服 (卡密绑定错误废弃)" {
t.Fatalf("标记异常后备注不符: %s", abnormalToken.Remark)
}
// 3. 搜索备注测试
list, total, err := rockSvc.ListTokens(context.Background(), 1, 10, "", "卡密绑定错误废弃")
if err != nil {
t.Fatalf("list by remark search error: %v", err)
}
if total != 1 || len(list) != 1 || list[0].ID != token.ID {
t.Fatalf("通过备注搜索未检索到目标 token, total=%d", total)
}
// 4. 从 abnormal 状态解冻重置回 available
if err := rockSvc.ResetMismatchToken(context.Background(), token.ID); err != nil {
t.Fatalf("reset abnormal token error: %v", err)
}
var resetToken model.RockToken
if err := db.First(&resetToken, token.ID).Error; err != nil {
t.Fatalf("find reset token error: %v", err)
}
if resetToken.Status != model.RockStatusAvailable {
t.Fatalf("重置后应为 available,实际: %s", resetToken.Status)
}
if resetToken.AssignedOrderNo != "" || resetToken.ExpectedUID != "" {
t.Fatalf("重置后应清空订单关联, assigned_order_no=%s", resetToken.AssignedOrderNo)
}
}
+52 -4
View File
@@ -98,20 +98,20 @@ func isDigits(value string) bool {
return true
}
// ResetMismatchToken 客服人工重置解冻绑定错误的 Token
// ResetMismatchToken 客服人工重置解冻绑定错误或作废的 Token
func (s *RockService) ResetMismatchToken(ctx context.Context, tokenID uint) error {
var token model.RockToken
if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil {
return err
}
if token.Status != model.RockStatusMismatch {
return fmt.Errorf("仅 mismatch 状态的 Token 允许人工解冻重置")
if token.Status != model.RockStatusMismatch && token.Status != model.RockStatusAbnormal {
return fmt.Errorf("仅 mismatch 或 abnormal 状态的 Token 允许人工解冻重置")
}
s.refreshCache.Delete(token.UpstreamTokenID)
// 重置回 available,清空订单关联
// 重置回 available,清空订单关联与异常标记
return s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{
"status": model.RockStatusAvailable,
"assigned_order_no": "",
@@ -125,3 +125,51 @@ func (s *RockService) ResetMismatchToken(ctx context.Context, tokenID uint) erro
"updated_at": time.Now(),
}).Error
}
// UpdateTokenRemark 更新 Token 备注,并支持联动流转状态(例如上报后置为 abnormal 失效)
func (s *RockService) UpdateTokenRemark(ctx context.Context, tokenID uint, remark string, nextStatus string) (*model.RockToken, error) {
var token model.RockToken
if err := s.db.WithContext(ctx).First(&token, tokenID).Error; err != nil {
return nil, err
}
updates := map[string]interface{}{
"remark": strings.TrimSpace(remark),
"updated_at": time.Now(),
}
trimmedStatus := strings.TrimSpace(nextStatus)
if trimmedStatus != "" {
switch trimmedStatus {
case model.RockStatusAbnormal, model.RockStatusAvailable, model.RockStatusMismatch:
updates["status"] = trimmedStatus
token.Status = trimmedStatus
if trimmedStatus == model.RockStatusAbnormal {
s.refreshCache.Delete(token.UpstreamTokenID)
}
default:
return nil, fmt.Errorf("不支持的目标状态: %s", trimmedStatus)
}
}
if err := s.db.WithContext(ctx).Model(&token).Updates(updates).Error; err != nil {
return nil, err
}
token.Remark = updates["remark"].(string)
_ = writeAudit(s.db.WithContext(ctx), nil, nil, nil, "rock_token.update_remark", "rock_token", fmt.Sprintf("%d", token.ID), map[string]interface{}{
"remark": token.Remark,
"status": token.Status,
})
return &token, nil
}
// MarkTokenAbnormal 快捷标记为上报失效(算作废,不可再用)
func (s *RockService) MarkTokenAbnormal(ctx context.Context, tokenID uint, reason string) (*model.RockToken, error) {
remark := strings.TrimSpace(reason)
if remark == "" {
remark = "已上报上游 (失效作废)"
}
return s.UpdateTokenRemark(ctx, tokenID, remark, model.RockStatusAbnormal)
}
+2 -2
View File
@@ -127,8 +127,8 @@ func (s *RockService) ListTokens(ctx context.Context, page, pageSize int, status
}
if strings.TrimSpace(search) != "" {
term := "%" + strings.TrimSpace(search) + "%"
db = db.Where("CAST(id AS TEXT) = ? OR upstream_token_id = ? OR upstream_token_id LIKE ? OR assigned_order_no LIKE ? OR expected_uid LIKE ? OR actual_role_name LIKE ? OR actual_role_id LIKE ? OR redeem_code LIKE ? OR assigned_order_no IN (SELECT order_no FROM fulfillment_orders WHERE client_order_no LIKE ?)",
strings.TrimSpace(search), strings.TrimSpace(search), term, term, term, term, term, term, term)
db = db.Where("CAST(id AS TEXT) = ? OR upstream_token_id = ? OR upstream_token_id LIKE ? OR assigned_order_no LIKE ? OR expected_uid LIKE ? OR actual_role_name LIKE ? OR actual_role_id LIKE ? OR redeem_code LIKE ? OR remark LIKE ? OR assigned_order_no IN (SELECT order_no FROM fulfillment_orders WHERE client_order_no LIKE ?)",
strings.TrimSpace(search), strings.TrimSpace(search), term, term, term, term, term, term, term, term)
}
var total int64