- 做了什么: 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 生产构建通过。
178 lines
5.2 KiB
Go
178 lines
5.2 KiB
Go
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
|
||
}
|