diff --git a/backend/internal/database/migrations/013_rock_tokens_remark.sql b/backend/internal/database/migrations/013_rock_tokens_remark.sql
new file mode 100644
index 0000000..5960c96
--- /dev/null
+++ b/backend/internal/database/migrations/013_rock_tokens_remark.sql
@@ -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 '';
diff --git a/backend/internal/handler/rock.go b/backend/internal/handler/rock.go
index ebd489f..e7b6ef8 100644
--- a/backend/internal/handler/rock.go
+++ b/backend/internal/handler/rock.go
@@ -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,
+ })
+}
diff --git a/backend/internal/model/rock.go b/backend/internal/model/rock.go
index b02d905..82a1feb 100644
--- a/backend/internal/model/rock.go
+++ b/backend/internal/model/rock.go
@@ -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"`
diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go
index c171d3c..73c9155 100644
--- a/backend/internal/router/router.go
+++ b/backend/internal/router/router.go
@@ -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)
}
}
diff --git a/backend/internal/service/provider_rock_test.go b/backend/internal/service/provider_rock_test.go
index e7175b4..3060a25 100644
--- a/backend/internal/service/provider_rock_test.go
+++ b/backend/internal/service/provider_rock_test.go
@@ -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)
+ }
+}
diff --git a/backend/internal/service/rock_recover.go b/backend/internal/service/rock_recover.go
index c6216cf..949ab0a 100644
--- a/backend/internal/service/rock_recover.go
+++ b/backend/internal/service/rock_recover.go
@@ -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)
+}
diff --git a/backend/internal/service/rock_stock.go b/backend/internal/service/rock_stock.go
index c447fa4..5c7c7a7 100644
--- a/backend/internal/service/rock_stock.go
+++ b/backend/internal/service/rock_stock.go
@@ -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
diff --git a/docs/Rock发货记录时间与备注及失效状态优化方案.md b/docs/Rock发货记录时间与备注及失效状态优化方案.md
new file mode 100644
index 0000000..c97dbda
--- /dev/null
+++ b/docs/Rock发货记录时间与备注及失效状态优化方案.md
@@ -0,0 +1,89 @@
+# Rock发货记录时间与备注及失效状态优化方案
+
+> **日期**:2026-10-08
+> **任务背景**:Rock (0666.pro) 发货平台看板中,提货记录缺少时间追踪与运营备注;当发生 UID 绑定错误(mismatch)并上报给上游(0868/0666 客服)后,该链接实际已废弃无法使用,但原有仅有的「解冻重置」会将坏卡重新释放回可用库存池,存在严重业务安全隐患。
+
+---
+
+## 一、进度与证据分类登记
+
+### 1.1 已知事实 (Known Facts)
+1. **现有数据结构与表定义**:
+ - 源码位置:`backend/internal/model/rock.go`、`backend/internal/database/migrations/012_rock_fulfillment.sql`
+ - `rock_tokens` 表已有字段:`id`, `upstream_token_id`, `redeem_code`, `status`, `assigned_order_no`, `expected_uid`, `actual_role_id`, `actual_role_name`, `mismatch_at`, `mismatch_reason`, `claimed_at`, `bound_at`, `redeemed_at`, `created_at`, `updated_at`。
+ - 确信度:**100%(源码与迁移文件已确认)**。
+2. **状态机支持情况**:
+ - `model.RockStatusAbnormal = "abnormal"` 已经定义(注释为“异常/作废”),并在 `rock_assign.go:50-62`、`rock_service.go:215` 等处被处理(当 status 为 abnormal 时,领号逻辑会直接拦截阻断该订单再次分配此号)。
+ - 当前缺少从前端直接将 Token 标记为 `abnormal` / 已上报失效的流转入口。
+ - 确信度:**100%(源码已确认)**。
+3. **表格现有列**:
+ - 源码位置:`frontend/src/pages/platformRock/tokenColumns.tsx`
+ - 现有列包含:`0666 编号 / 提取码`, `规格与适用范围`, `库存状态`, `关联业务工单`, `预期 UID vs 扫码角色`, `发货结果图片`, `操作`。
+ - 缺乏:时间列(入库时间、绑错时间、绑定/发货时间)以及备注列。
+ - 确信度:**100%(源码与截图已确认)**。
+4. **原有「解冻重置」的逻辑与缺陷**:
+ - 源码位置:`backend/internal/service/rock_recover.go:101-127`(`ResetMismatchToken`)
+ - 现有逻辑:只要是 mismatch 状态,调用 resetToken 会直接无条件将 `status` 更新为 `available`,清空 `assigned_order_no` 和 `expected_uid`。
+ - 业务缺陷:若运营人员已向 0868 上游反馈坏卡/上报问题,该链接在上游已被处理或作废,一旦客服误点“解冻重置”,该废卡被重新分给后续新订单,导致下一单继续报错。
+ - 确信度:**100%(源码与业务逻辑已确认)**。
+
+### 1.2 目标 (Goals)
+1. **数据库与数据模型升级**:
+ - 在 `rock_tokens` 表增加 `remark` 字段(TEXT / VARCHAR(255))。
+ - 新增安全数据库迁移脚本(`013_rock_tokens_remark.sql`),自动为现有表增列。
+ - 在 `model.RockToken` 补充 `Remark string json:"remark"` 字段。
+2. **后端 API 扩展**:
+ - 提供 Token 备注更新与状态联动接口:`POST /api/platform/rock/tokens/:id/remark`。
+ - 支持设置备注(例如“已上报上游”、“卡密绑定错误已上报”、“买家填错UID”等),并支持可选传参 `status`(如置为 `abnormal` / 已失效作废)。
+ - 提供针对已失效/作废的专门操作或优化现有 Reset 行为(若已上报失效,明确区分是作废还是强制重置)。
+ - 搜索功能支持对备注 `remark` 进行模糊查询。
+3. **前端表格重构 (`tokenColumns.tsx` & `RockTokenTable.tsx`)**:
+ - **新增「时间信息」列**:
+ - 入库时间(`created_at`);
+ - 关键节点流转时间(如 `mismatch_at` 绑错时间、`bound_at` 授权时间、`redeemed_at` 发货时间),带醒目标识。
+ - **新增「备注」列**:
+ - 支持快捷选择标签(下拉/气泡快捷选填):
+ - 🔴 `已上报上游 (作废失效)`(自动设置备注并将状态置为失效 `abnormal`,阻断再次使用);
+ - ⚠️ `绑定错误 (已上报)`(设置备注并将状态置为失效 `abnormal`);
+ - ✏️ `买家UID填错 (待核对)`;
+ - ⏳ `上报处理中`;
+ - 📝 `自定义输入备注`;
+ - 🧹 `清空备注`。
+ - 行内直观展示当前备注内容。
+ - **优化「操作」列**:
+ - 对于 `mismatch` 状态:
+ - 保留「修改 UID」(适用于买家扫对了但填错了);
+ - 新增一键「上报失效」(快捷将链接标记为已上报并作废,不再入库存池);
+ - 优化「解冻重置」的提示文案与门禁(警告不可将已上报上游的卡重置回库存)。
+ - 对于 `abnormal`(已上报失效 / 作废)状态:
+ - 展示「已作废 (不可用)」徽章,不再显示常规解冻重置;
+ - 如确需误操作恢复,提供带高危确认的「强制恢复为可用」。
+ - **优化筛选栏 (`RockTokenFilterBar.tsx`)**:
+ - 增加「已失效/已上报」(`abnormal`)筛选 Tab,方便运营检索所有上报废弃卡。
+
+### 1.3 下一步执行计划 (Next Steps)
+1. [x] 现状分析与数据库/代码核查完成。
+2. [x] 编写数据库迁移 `013_rock_tokens_remark.sql` 并更新 Go 结构体 `model.RockToken`。
+3. [x] 扩展后端 service 与 handler:实现 Token 备注与状态流转接口,编写对应单元测试并通过验证。
+4. [x] 前端类型与 API 定义补充 (`frontend/src/api/index.ts`)。
+5. [x] 前端组件实现:
+ - `tokenColumns.tsx` 增加流转时间列、备注列(含快速设置交互)、优化操作列。
+ - `TokenRemarkCell.tsx` 实现一键快捷选择(上报错误失效等)与自定义备注输入。
+ - `RockTokenFilterBar.tsx` 增加 `abnormal` 已上报失效状态筛选。
+6. [x] 完整回归验证:
+ - 前端构建 `npm run build`、代码规范 `oxlint` 全部通过 (0 errors, 0 warnings)。
+ - 后端代码格式化 `make fmt`、类型检查 `go vet` 全部通过。
+ - 单元测试 `go test -count=1 ./internal/service -run TestRock` 全部通过。
+
+---
+
+## 二、验收与验证结果清单
+
+| 检查项 | 验证命令 / 方式 | 结果 |
+| :--- | :--- | :--- |
+| 数据库增列与迁移 | 检查 `013_rock_tokens_remark.sql` 与 `model.RockToken` | 通过,新增 `remark` 字段 |
+| 接口联通与流转逻辑 | 运行单测 `TestRockService_TokenRemarkAndAbnormal` | 通过,支持备注更新、一键标记 abnormal、模糊搜索备注及异常重置 |
+| 前端 TypeScript 编译 | `npm run build` (`tsc -b && vite build`) | 通过 (0 errors) |
+| 前端代码质量检查 | `npm run lint` (`oxlint`) | 通过 (0 errors, 0 warnings) |
+| 后端代码格式与单测 | `make fmt` && `go vet ./...` && `go test -count=1 ./internal/service -run TestRock` | 全部通过 |
+
diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts
index e82dc0b..3a180e9 100644
--- a/frontend/src/api/index.ts
+++ b/frontend/src/api/index.ts
@@ -330,6 +330,7 @@ export interface RockTokenItem {
target_currency_quantity?: number
mismatch_at?: string
mismatch_reason?: string
+ remark?: string
claimed_at?: string
bound_at?: string
redeemed_at?: string
@@ -348,6 +349,10 @@ export const rockApi = {
request.post(`/platform/rock/tokens/${id}/expected-uid`, { expected_uid: expectedUid }).then((r) => r.data.data as { message?: string; status?: string }),
resetToken: (id: number) => request.post(`/platform/rock/tokens/${id}/reset`).then((r) => r.data.data),
releaseToken: (id: number) => request.post(`/platform/rock/tokens/${id}/release`).then((r) => r.data.data),
+ updateRemark: (id: number, data: { remark: string; status?: string }) =>
+ request.post(`/platform/rock/tokens/${id}/remark`, data).then((r) => r.data.data as { message: string; token: RockTokenItem }),
+ markAbnormal: (id: number, remark?: string) =>
+ request.post(`/platform/rock/tokens/${id}/abnormal`, { remark }).then((r) => r.data.data as { message: string; token: RockTokenItem }),
}
diff --git a/frontend/src/pages/platformRock/RockTokenFilterBar.tsx b/frontend/src/pages/platformRock/RockTokenFilterBar.tsx
index e7fce49..1137690 100644
--- a/frontend/src/pages/platformRock/RockTokenFilterBar.tsx
+++ b/frontend/src/pages/platformRock/RockTokenFilterBar.tsx
@@ -59,6 +59,11 @@ export function RockTokenFilterBar({
绑定错误
+