Files
affiliate_dash/backend/internal/service/rock_stock.go
T
yml2213 e293cebaf5 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 生产构建通过。
2026-10-08 20:13:25 +08:00

178 lines
5.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"fmt"
"log"
"strings"
"time"
"affiliate_dash/internal/model"
"gorm.io/gorm"
)
// PurchaseTokens 执行采购并批量入库(通用黑色 Token)
func (s *RockService) PurchaseTokens(ctx context.Context, count int, isAuto bool) ([]model.RockToken, error) {
s.mu.Lock()
defer s.mu.Unlock()
cfg, err := s.GetConfig(ctx)
if err != nil {
return nil, err
}
if cfg.BearerToken == "" {
return nil, fmt.Errorf("尚未配置上游 Bearer Token,无法执行采购")
}
// 自动补货防抖冷却检查
now := time.Now()
if isAuto && cfg.LastPurchasedAt != nil {
cooldown := time.Duration(cfg.PurchaseCooldownMinutes) * time.Minute
if now.Sub(*cfg.LastPurchasedAt) < cooldown {
return nil, fmt.Errorf("自动采购处于冷却中(冷却时间 %d 分钟)", cfg.PurchaseCooldownMinutes)
}
}
// 调上游 Claim 接口(统一下单黑色通用 Token)
tokens, err := s.client.ClaimTokens(ctx, cfg.BearerToken, model.RockColorBlack, count)
if err != nil {
return nil, fmt.Errorf("调用上游采购接口失败: %w", err)
}
if len(tokens) == 0 {
return nil, fmt.Errorf("上游未返回任何有效 Token")
}
var dbTokens []model.RockToken
for _, t := range tokens {
dbTokens = append(dbTokens, model.RockToken{
UpstreamTokenID: fmt.Sprintf("%d", t.ID),
Token: t.Token,
RedeemCode: t.RedeemCode,
Color: model.RockColorBlack,
Region: t.Region,
Status: model.RockStatusAvailable,
ClaimedAt: &now,
})
}
// 事务写入库存表
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&dbTokens).Error; err != nil {
return err
}
// 更新最后采购时间
return tx.Model(&model.RockConfig{}).Where("id = ?", cfg.ID).Update("last_purchased_at", now).Error
})
if err != nil {
return nil, fmt.Errorf("入库采购 Token 失败: %w", err)
}
// 异步更新上游剩余积分快照
go func() {
bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
me, meErr := s.client.GetMe(bgCtx, cfg.BearerToken)
if meErr == nil && me != nil {
_ = s.db.Model(&model.RockConfig{}).Where("id = 1").Update("points", me.User.Points.Float64()).Error
}
}()
return dbTokens, nil
}
// CheckStockAndAutoPurchase 水位检查与自动采购触发
func (s *RockService) CheckStockAndAutoPurchase(ctx context.Context) {
cfg, err := s.GetConfig(ctx)
if err != nil {
return
}
var count int64
s.db.WithContext(ctx).Model(&model.RockToken{}).
Where("status = ? AND color = ?", model.RockStatusAvailable, model.RockColorBlack).
Count(&count)
if count <= int64(cfg.TokenWarningStock) {
log.Printf("[RockService] 可用库存预警: 当前仅存 %d 个 (预警阈值 %d)", count, cfg.TokenWarningStock)
if cfg.AutoPurchaseEnabled {
buyCount := cfg.AutoPurchaseCount
if buyCount <= 0 {
buyCount = 50
}
log.Printf("[RockService] 触发自动补货,采购数量: %d", buyCount)
_, err := s.PurchaseTokens(ctx, buyCount, true)
if err != nil {
log.Printf("[RockService] 自动补货失败: %v", err)
} else {
log.Printf("[RockService] 自动补货成功,新增 %d 个 Token", buyCount)
}
}
}
}
// ListTokens 分页查询 Token 列表
func (s *RockService) ListTokens(ctx context.Context, page, pageSize int, status, search string) ([]model.RockToken, int64, error) {
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 20
}
db := s.db.WithContext(ctx).Model(&model.RockToken{})
if strings.TrimSpace(status) != "" {
db = db.Where("status = ?", strings.TrimSpace(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 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
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
var tokens []model.RockToken
err := db.Order("id DESC").
Offset((page - 1) * pageSize).
Limit(pageSize).
Find(&tokens).Error
if err != nil {
return nil, 0, err
}
// 批量反查关联订单的商户单号(创建订单时的 ClientOrderNo)
orderNos := make([]string, 0, len(tokens))
for _, t := range tokens {
if t.AssignedOrderNo != "" {
orderNos = append(orderNos, t.AssignedOrderNo)
}
}
if len(orderNos) > 0 {
var orders []struct {
OrderNo string `gorm:"column:order_no"`
ClientOrderNo string `gorm:"column:client_order_no"`
}
if scanErr := s.db.WithContext(ctx).Table("fulfillment_orders").
Select("order_no, client_order_no").
Where("order_no IN ?", orderNos).
Scan(&orders).Error; scanErr == nil {
clientMap := make(map[string]string, len(orders))
for _, o := range orders {
clientMap[o.OrderNo] = o.ClientOrderNo
}
for i := range tokens {
if cNo, ok := clientMap[tokens[i].AssignedOrderNo]; ok && cNo != "" {
tokens[i].ClientOrderNo = cNo
}
}
}
}
return tokens, total, nil
}