feat(rock): 未扫码绑定的使用中链接可释放回库存
历史残留绑定不再占住提货链接,退款后可以把还没扫上的库存分给下一单。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -123,3 +123,17 @@ func (h *RockHandler) ResetToken(c *gin.Context) {
|
||||
}
|
||||
response.OK(c, gin.H{"message": "重置成功,Token 已重新释放至可用库存"})
|
||||
}
|
||||
|
||||
// ReleaseToken 把尚未扫码绑定的使用中链接释放回可用库存
|
||||
func (h *RockHandler) ReleaseToken(c *gin.Context) {
|
||||
idUint, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "无效的 Token ID")
|
||||
return
|
||||
}
|
||||
if err := h.rockSvc.ReleaseUnboundUsingToken(c.Request.Context(), uint(idUint)); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"message": "已释放回可用库存,该提货链接可以再次分配"})
|
||||
}
|
||||
|
||||
@@ -316,6 +316,7 @@ func Setup(h *Handlers) *gin.Engine {
|
||||
admin.POST("/platform/rock/purchase", h.Rock.PurchaseTokens)
|
||||
admin.GET("/platform/rock/tokens", h.Rock.ListTokens)
|
||||
admin.POST("/platform/rock/tokens/:id/reset", h.Rock.ResetToken)
|
||||
admin.POST("/platform/rock/tokens/:id/release", h.Rock.ReleaseToken)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ func (p *RockProvider) CheckBind(ctx context.Context, order *model.FulfillmentOr
|
||||
expectedUID = resultDataString(order.ResultData, "game_account")
|
||||
}
|
||||
|
||||
res, err := p.rockService.CheckBindingAndVerifyUID(ctx, uint(tokenIDUint), expectedUID)
|
||||
res, err := p.rockService.CheckBindingAndVerifyUID(ctx, uint(tokenIDUint), order.OrderNo, expectedUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -497,3 +497,179 @@ func TestRockService_StatsAndPurchase(t *testing.T) {
|
||||
t.Fatalf("期望按商户单号搜索出 1 条,实际为 total=%d len=%d", searchTotal, len(searchList))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseUnboundUsingToken(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
|
||||
var boundRoleID string
|
||||
var bindChangedAt string
|
||||
var deliveryTime string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if strings.HasSuffix(r.URL.Path, "/refresh") {
|
||||
var resp RockRefreshResponse
|
||||
if boundRoleID != "" {
|
||||
resp.Token.Status = "bound"
|
||||
resp.Token.BoundAccount = "已扫码角色"
|
||||
resp.Token.BoundAccountID = boundRoleID
|
||||
resp.Token.BindChangedAt = bindChangedAt
|
||||
}
|
||||
if deliveryTime != "" {
|
||||
resp.DeliveryList = []RockDeliveryItem{{
|
||||
Time: deliveryTime,
|
||||
Role: "iOS-微信-已扫码角色",
|
||||
Message: "成功发放",
|
||||
}}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewRockClient(server.URL)
|
||||
rockSvc := NewRockService(db, client)
|
||||
provider := NewRockProvider(rockSvc)
|
||||
bearer := "mock_bearer_token"
|
||||
if _, err := rockSvc.UpdateConfig(context.Background(), UpdateRockConfigReq{BearerToken: &bearer}); err != nil {
|
||||
t.Fatalf("update config: %v", err)
|
||||
}
|
||||
|
||||
snap := BindingSnapshot{
|
||||
Bound: true,
|
||||
RoleName: "历史残留",
|
||||
RoleID: "111",
|
||||
BindChangedAt: "2026-10-01 10:00:00",
|
||||
}
|
||||
snapBytes, _ := json.Marshal(snap)
|
||||
token := model.RockToken{
|
||||
UpstreamTokenID: "88001",
|
||||
Token: "jwt",
|
||||
RedeemCode: "redeem-unused",
|
||||
Color: model.RockColorBlack,
|
||||
Status: model.RockStatusUsing,
|
||||
AssignedOrderNo: "FO-RELEASE-1",
|
||||
ExpectedUID: "3688825401",
|
||||
InitialBoundSnapshot: string(snapBytes),
|
||||
}
|
||||
if err := db.Create(&token).Error; err != nil {
|
||||
t.Fatalf("create token: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.FulfillmentOrder{
|
||||
OrderNo: "FO-RELEASE-1",
|
||||
ProductSKU: model.RockSKUBlackTshirt,
|
||||
ProductName: "黑色ROCK长袖T恤",
|
||||
ResultData: fmt.Sprintf(`{"provider":"rock","rock_token_id":%d,"rock_redeem_code":"redeem-unused","expected_uid":"3688825401"}`, token.ID),
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create order: %v", err)
|
||||
}
|
||||
|
||||
// 历史残留与快照一致,视为买家还没扫,允许释放。
|
||||
boundRoleID = "111"
|
||||
bindChangedAt = "2026-10-01 10:00:00"
|
||||
if err := rockSvc.ReleaseUnboundUsingToken(context.Background(), token.ID); err != nil {
|
||||
t.Fatalf("release ghost snapshot: %v", err)
|
||||
}
|
||||
var released model.RockToken
|
||||
if err := db.First(&released, token.ID).Error; err != nil {
|
||||
t.Fatalf("reload token: %v", err)
|
||||
}
|
||||
if released.Status != model.RockStatusAvailable || released.AssignedOrderNo != "" || released.ExpectedUID != "" {
|
||||
t.Fatalf("释放后应回到可用库存,实际 status=%s order=%s uid=%s", released.Status, released.AssignedOrderNo, released.ExpectedUID)
|
||||
}
|
||||
var order model.FulfillmentOrder
|
||||
if err := db.Where("order_no = ?", "FO-RELEASE-1").First(&order).Error; err != nil {
|
||||
t.Fatalf("reload order: %v", err)
|
||||
}
|
||||
if resultDataNumber(order.ResultData, "rock_token_id") != 0 || !strings.Contains(order.ResultData, "rock_token_released") {
|
||||
t.Fatalf("原订单仍指向已释放链接: %s", order.ResultData)
|
||||
}
|
||||
|
||||
// 旧发货页继续轮询时,不能把已释放的链接再改成绑定。
|
||||
boundRoleID = "3688825401"
|
||||
bindChangedAt = "2026-10-05 21:00:00"
|
||||
check, err := provider.CheckBind(context.Background(), &order, fmt.Sprintf("%d", token.ID))
|
||||
if err != nil {
|
||||
t.Fatalf("check bind after release: %v", err)
|
||||
}
|
||||
if check.Bound {
|
||||
t.Fatalf("已释放链接不应再判定为绑定成功")
|
||||
}
|
||||
if err := db.First(&released, token.ID).Error; err != nil {
|
||||
t.Fatalf("reload after check: %v", err)
|
||||
}
|
||||
if released.Status != model.RockStatusAvailable {
|
||||
t.Fatalf("旧页面轮询后状态被改写为 %s", released.Status)
|
||||
}
|
||||
|
||||
// 上游已经出现新扫码时拒绝释放。
|
||||
using := model.RockToken{
|
||||
UpstreamTokenID: "88002",
|
||||
Token: "jwt2",
|
||||
RedeemCode: "redeem-bound",
|
||||
Color: model.RockColorBlack,
|
||||
Status: model.RockStatusUsing,
|
||||
AssignedOrderNo: "FO-RELEASE-2",
|
||||
ExpectedUID: "3688825401",
|
||||
}
|
||||
if err := db.Create(&using).Error; err != nil {
|
||||
t.Fatalf("create using token: %v", err)
|
||||
}
|
||||
boundRoleID = "3688825401"
|
||||
bindChangedAt = "2026-10-05 21:10:00"
|
||||
if err := rockSvc.ReleaseUnboundUsingToken(context.Background(), using.ID); err == nil || !strings.Contains(err.Error(), "已扫码绑定") {
|
||||
t.Fatalf("已绑定链接必须拒绝释放,实际: %v", err)
|
||||
}
|
||||
var stillUsing model.RockToken
|
||||
if err := db.First(&stillUsing, using.ID).Error; err != nil {
|
||||
t.Fatalf("reload using: %v", err)
|
||||
}
|
||||
if stillUsing.Status != model.RockStatusUsing || stillUsing.AssignedOrderNo != "FO-RELEASE-2" {
|
||||
t.Fatalf("拒绝释放后应保持使用中,实际 status=%s order=%s", stillUsing.Status, stillUsing.AssignedOrderNo)
|
||||
}
|
||||
|
||||
// 上游 status=bound,但发货记录早于本次领取、绑定时间仍为空:历史残留,可以再次使用。
|
||||
claimedAt := time.Date(2026, 10, 5, 20, 32, 17, 0, time.Local)
|
||||
stale := model.RockToken{
|
||||
UpstreamTokenID: "152415",
|
||||
Token: "jwt3",
|
||||
RedeemCode: "dbece05764c701a3be3bb5c689926d07",
|
||||
Color: model.RockColorBlack,
|
||||
Status: model.RockStatusUsing,
|
||||
AssignedOrderNo: "FO-RELEASE-3",
|
||||
ExpectedUID: "3364503067",
|
||||
ClaimedAt: &claimedAt,
|
||||
InitialBoundSnapshot: `{"bound":true,"role_name":"予心纯白","role_id":"1900505843","channel":"iOS-微信","bind_changed_at":""}`,
|
||||
}
|
||||
if err := db.Create(&stale).Error; err != nil {
|
||||
t.Fatalf("create stale token: %v", err)
|
||||
}
|
||||
boundRoleID = "1900505843"
|
||||
bindChangedAt = ""
|
||||
deliveryTime = "2026-09-27T14:38:51.23"
|
||||
if err := rockSvc.ReleaseUnboundUsingToken(context.Background(), stale.ID); err != nil {
|
||||
t.Fatalf("历史残留应允许释放: %v", err)
|
||||
}
|
||||
|
||||
// 领取之后出现新的发货记录,不能释放。
|
||||
fresh := model.RockToken{
|
||||
UpstreamTokenID: "152420",
|
||||
Token: "jwt4",
|
||||
RedeemCode: "redeem-fresh-delivery",
|
||||
Color: model.RockColorBlack,
|
||||
Status: model.RockStatusUsing,
|
||||
AssignedOrderNo: "FO-RELEASE-4",
|
||||
ExpectedUID: "4744920014",
|
||||
ClaimedAt: &claimedAt,
|
||||
}
|
||||
if err := db.Create(&fresh).Error; err != nil {
|
||||
t.Fatalf("create fresh token: %v", err)
|
||||
}
|
||||
boundRoleID = "4744920014"
|
||||
bindChangedAt = "2026-10-05 21:52:30"
|
||||
deliveryTime = "2026-10-05T21:52:40.61"
|
||||
if err := rockSvc.ReleaseUnboundUsingToken(context.Background(), fresh.ID); err == nil || !strings.Contains(err.Error(), "已扫码绑定") {
|
||||
t.Fatalf("领取后的新发货必须拒绝释放,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,7 +416,7 @@ type CheckBindingResult struct {
|
||||
}
|
||||
|
||||
// CheckBindingAndVerifyUID Anti-Ghost 基线比对与 UID 强一致门禁
|
||||
func (s *RockService) CheckBindingAndVerifyUID(ctx context.Context, tokenID uint, expectedUID string) (*CheckBindingResult, error) {
|
||||
func (s *RockService) CheckBindingAndVerifyUID(ctx context.Context, tokenID uint, orderNo, expectedUID string) (*CheckBindingResult, error) {
|
||||
cfg, err := s.GetConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -440,6 +440,20 @@ func (s *RockService) CheckBindingAndVerifyUID(ctx context.Context, tokenID uint
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 已释放回库存、或已改派给其他订单的链接,不能再被旧发货页轮询改写。
|
||||
if token.Status != model.RockStatusUsing && token.Status != model.RockStatusBound {
|
||||
return &CheckBindingResult{
|
||||
Bound: false,
|
||||
Message: "该提货链接已释放回库存,请重新获取二维码",
|
||||
}, nil
|
||||
}
|
||||
if strings.TrimSpace(orderNo) != "" && token.AssignedOrderNo != "" && token.AssignedOrderNo != strings.TrimSpace(orderNo) {
|
||||
return &CheckBindingResult{
|
||||
Bound: false,
|
||||
Message: "该提货链接已不属于当前订单",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 调上游 refresh 接口(带 800ms 瞬时防抖缓存,避免自动轮询与手动高频点击撞上上游 110001 频控)
|
||||
var ref *RockRefreshResponse
|
||||
cacheVal, hasCache := s.refreshCache.Load(token.UpstreamTokenID)
|
||||
@@ -496,18 +510,12 @@ func (s *RockService) CheckBindingAndVerifyUID(ctx context.Context, tokenID uint
|
||||
actualRoleID := ref.GetRoleID()
|
||||
actualChannel := ref.GetChannel()
|
||||
|
||||
// 2. Anti-Ghost 历史残留比对
|
||||
if token.InitialBoundSnapshot != "" {
|
||||
var snap BindingSnapshot
|
||||
if jsonErr := json.Unmarshal([]byte(token.InitialBoundSnapshot), &snap); jsonErr == nil {
|
||||
// 若当前绑定的角色、UID 与初始分配时完全一样且变更时间没有更新,说明买家还未扫码,仍是历史旧数据
|
||||
if snap.Bound && snap.RoleID == actualRoleID && snap.BindChangedAt == ref.Token.BindChangedAt {
|
||||
return &CheckBindingResult{
|
||||
Bound: false,
|
||||
Message: "等待买家扫码(检测到历史绑定残留,已自动忽略)",
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
// 2. 历史残留不算本次扫码:角色和绑定时间与分配快照一致,或发货记录都早于本次领取。
|
||||
if !rockBuyerNewlyBound(&token, ref) {
|
||||
return &CheckBindingResult{
|
||||
Bound: false,
|
||||
Message: "等待买家扫码(检测到历史绑定残留,已自动忽略)",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 3. 核心门禁:UID 强一致校验
|
||||
@@ -727,3 +735,187 @@ func (s *RockService) ResetMismatchToken(ctx context.Context, tokenID uint) erro
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
}
|
||||
|
||||
// rockBuyerNewlyBound 判断上游展示的绑定是不是本次领取之后新扫上的。
|
||||
// 和分配快照一致,或者绑定时间仍为空且发货记录都早于本次领取,都是历史残留,链接可以再次使用。
|
||||
// 没有快照、也没有更早的发货记录时,上游已有角色按新绑定处理,避免把刚扫上的号放回库存。
|
||||
func rockBuyerNewlyBound(token *model.RockToken, ref *RockRefreshResponse) bool {
|
||||
if ref == nil || !ref.IsBound() || strings.TrimSpace(ref.GetRoleID()) == "" {
|
||||
return false
|
||||
}
|
||||
snap, hasSnap := bindingSnapshot(token)
|
||||
if hasSnap && snap.Bound && snap.RoleID == ref.GetRoleID() && snap.BindChangedAt == strings.TrimSpace(ref.Token.BindChangedAt) {
|
||||
return false
|
||||
}
|
||||
if deliveryAfterClaim(token, ref) || bindChangedAfterClaim(token, ref.Token.BindChangedAt) {
|
||||
return true
|
||||
}
|
||||
if hasSnap && snap.RoleID != "" && snap.RoleID != ref.GetRoleID() {
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(ref.Token.BindChangedAt) == "" && historicalDeliveriesOnly(token, ref) {
|
||||
return false
|
||||
}
|
||||
if hasSnap {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func historicalDeliveriesOnly(token *model.RockToken, ref *RockRefreshResponse) bool {
|
||||
if ref == nil || len(ref.DeliveryList) == 0 {
|
||||
return false
|
||||
}
|
||||
return !deliveryAfterClaim(token, ref)
|
||||
}
|
||||
|
||||
func bindingSnapshot(token *model.RockToken) (BindingSnapshot, bool) {
|
||||
if token == nil || strings.TrimSpace(token.InitialBoundSnapshot) == "" {
|
||||
return BindingSnapshot{}, false
|
||||
}
|
||||
var snap BindingSnapshot
|
||||
if err := json.Unmarshal([]byte(token.InitialBoundSnapshot), &snap); err != nil {
|
||||
return BindingSnapshot{}, false
|
||||
}
|
||||
snap.RoleID = strings.TrimSpace(snap.RoleID)
|
||||
snap.BindChangedAt = strings.TrimSpace(snap.BindChangedAt)
|
||||
return snap, true
|
||||
}
|
||||
|
||||
func rockClaimBaseline(token *model.RockToken) time.Time {
|
||||
if token == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
if token.ClaimedAt != nil && !token.ClaimedAt.IsZero() {
|
||||
return token.ClaimedAt.UTC()
|
||||
}
|
||||
return token.CreatedAt.UTC()
|
||||
}
|
||||
|
||||
func parseRockTime(raw string) (time.Time, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
layouts := []string{
|
||||
time.RFC3339Nano,
|
||||
"2006-01-02T15:04:05.999999999",
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02 15:04:05",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if t, err := time.ParseInLocation(layout, raw, time.Local); err == nil {
|
||||
return t.UTC(), true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func bindChangedAfterClaim(token *model.RockToken, changedAt string) bool {
|
||||
changed, ok := parseRockTime(changedAt)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
baseline := rockClaimBaseline(token)
|
||||
if baseline.IsZero() {
|
||||
return true
|
||||
}
|
||||
return !changed.Before(baseline.Add(-time.Minute))
|
||||
}
|
||||
|
||||
func deliveryAfterClaim(token *model.RockToken, ref *RockRefreshResponse) bool {
|
||||
if ref == nil {
|
||||
return false
|
||||
}
|
||||
baseline := rockClaimBaseline(token)
|
||||
if baseline.IsZero() {
|
||||
return false
|
||||
}
|
||||
for _, item := range ref.DeliveryList {
|
||||
deliveredAt, ok := parseRockTime(item.Time)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !deliveredAt.Before(baseline.Add(-time.Minute)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ReleaseUnboundUsingToken 把尚未扫码绑定的使用中链接释放回可用库存。
|
||||
// 已绑定或上游已出现新扫码时拒绝释放,避免退款后的旧二维码被当成新库存发出去。
|
||||
func (s *RockService) ReleaseUnboundUsingToken(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.RockStatusUsing {
|
||||
return fmt.Errorf("仅扫码使用中且尚未绑定的链接可以释放回库存")
|
||||
}
|
||||
if token.BoundAt != nil || strings.TrimSpace(token.ActualRoleID) != "" {
|
||||
return fmt.Errorf("该链接已扫码绑定,不能释放回库存")
|
||||
}
|
||||
|
||||
cfg, err := s.GetConfig(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(cfg.BearerToken) == "" || strings.TrimSpace(token.UpstreamTokenID) == "" {
|
||||
return fmt.Errorf("缺少上游凭证,无法确认是否已扫码绑定")
|
||||
}
|
||||
|
||||
ref, err := s.client.RefreshToken(ctx, cfg.BearerToken, token.UpstreamTokenID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("确认上游绑定状态失败: %w", err)
|
||||
}
|
||||
if rockBuyerNewlyBound(&token, ref) {
|
||||
return fmt.Errorf("上游已扫码绑定角色 %s(%s),不能释放", ref.GetRoleName(), ref.GetRoleID())
|
||||
}
|
||||
|
||||
s.refreshCache.Delete(token.UpstreamTokenID)
|
||||
orderNo := token.AssignedOrderNo
|
||||
if err := s.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{
|
||||
"status": model.RockStatusAvailable,
|
||||
"assigned_order_no": "",
|
||||
"expected_uid": "",
|
||||
"actual_role_name": "",
|
||||
"actual_role_id": "",
|
||||
"actual_channel": "",
|
||||
"initial_bound_snapshot": "",
|
||||
"mismatch_at": nil,
|
||||
"mismatch_reason": "",
|
||||
"bound_at": nil,
|
||||
"updated_at": time.Now(),
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.detachReleasedRockToken(ctx, orderNo, token.ID)
|
||||
log.Printf("[RockService] 人工释放未绑定库存: token_id=%d order=%s", token.ID, orderNo)
|
||||
return nil
|
||||
}
|
||||
|
||||
// detachReleasedRockToken 清掉原订单上指向这条链接的记录,避免旧发货页继续占用它。
|
||||
func (s *RockService) detachReleasedRockToken(ctx context.Context, orderNo string, tokenID uint) {
|
||||
orderNo = strings.TrimSpace(orderNo)
|
||||
if orderNo == "" || tokenID == 0 {
|
||||
return
|
||||
}
|
||||
var order model.FulfillmentOrder
|
||||
if err := s.db.WithContext(ctx).Where("order_no = ?", orderNo).First(&order).Error; err != nil {
|
||||
return
|
||||
}
|
||||
if resultDataNumber(order.ResultData, "rock_token_id") != int64(tokenID) {
|
||||
return
|
||||
}
|
||||
raw := resultDataMap(order.ResultData)
|
||||
delete(raw, "rock_token_id")
|
||||
delete(raw, "rock_redeem_code")
|
||||
raw["rock_token_released"] = true
|
||||
encoded, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = s.db.WithContext(ctx).Model(&order).Update("result_data", string(encoded)).Error
|
||||
}
|
||||
|
||||
@@ -327,6 +327,7 @@ export const rockApi = {
|
||||
listTokens: (params?: { page?: number; page_size?: number; status?: string; search?: string }) =>
|
||||
request.get('/platform/rock/tokens', { params }).then((r) => r.data.data as { list: RockTokenItem[]; total: number; page: number; page_size: number }),
|
||||
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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -218,6 +218,18 @@ export default function PlatformRock() {
|
||||
}
|
||||
}
|
||||
|
||||
// 买家未扫码绑定时,把占用中的提货链接放回库存
|
||||
const handleReleaseToken = async (id: number) => {
|
||||
try {
|
||||
await rockApi.releaseToken(id)
|
||||
message.success('已释放回可用库存,该提货链接可以再次分配')
|
||||
loadData()
|
||||
loadTokens()
|
||||
} catch (e) {
|
||||
message.error('释放失败: ' + (e instanceof Error ? e.message : '未知错误'))
|
||||
}
|
||||
}
|
||||
|
||||
// 复制/生成发货凭证图片
|
||||
const handleCopyVoucher = async (record: RockTokenItem) => {
|
||||
setCopyingVoucherId(record.id)
|
||||
@@ -524,7 +536,7 @@ export default function PlatformRock() {
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 120,
|
||||
width: 140,
|
||||
fixed: 'right',
|
||||
render: (_, record) => {
|
||||
if (record.status === 'mismatch') {
|
||||
@@ -542,6 +554,21 @@ export default function PlatformRock() {
|
||||
</Popconfirm>
|
||||
)
|
||||
}
|
||||
if (record.status === 'using' && !record.bound_at && !record.actual_role_id) {
|
||||
return (
|
||||
<Popconfirm
|
||||
title="释放这条未绑定的提货链接?"
|
||||
description="仅在买家没有扫码绑定时可以释放。系统会先向上游确认;确认未绑定后,链接回到可用库存,可以分给下一单。请先确认该订单已退款。原发货页若仍打开,买家再次获取二维码会重新占用一条库存。"
|
||||
okText="确认释放"
|
||||
cancelText="取消"
|
||||
onConfirm={() => handleReleaseToken(record.id)}
|
||||
>
|
||||
<Button size="small" icon={<UndoOutlined />}>
|
||||
释放回库存
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)
|
||||
}
|
||||
return <Text type="secondary">-</Text>
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user