feat:采购日志
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
# 迁移文件统一按 CRLF 检出:schema_migrations.checksum 按字节计算,
|
||||
# 行尾变化会被判定为「已执行版本被修改」并阻止服务启动。
|
||||
# 历史原因(001–019 全部按 CRLF 记录校验值),这里固定为 CRLF。
|
||||
backend/internal/database/migrations/*.sql text eol=crlf
|
||||
@@ -10,6 +10,7 @@ backend/.env
|
||||
frontend/.env
|
||||
!.env.example
|
||||
!frontend/.env.example
|
||||
docs/.obsidian
|
||||
|
||||
# backend
|
||||
backend/bin/
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
-- 019_rock_purchase_logs.sql
|
||||
-- 采购流水:每一次进入采购流程的尝试(含降级、跳过、失败)都留一条主记录,
|
||||
-- 每个上游接口调用在阶段表里保留原始请求/响应,便于对账与排障。
|
||||
-- 注意:只记录请求体,不记录任何请求头,避免 Bearer Token 落库。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rock_purchase_logs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
trigger_type VARCHAR(16) NOT NULL DEFAULT 'auto', -- auto / manual
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'running',-- running / success / partial / failed / skipped
|
||||
requested_count INTEGER NOT NULL DEFAULT 0, -- 原计划(批次值或手动输入)
|
||||
planned_count INTEGER NOT NULL DEFAULT 0, -- 降级后的实际计划数
|
||||
claimed_count INTEGER NOT NULL DEFAULT 0, -- 上游实际返回数
|
||||
imported_count INTEGER NOT NULL DEFAULT 0, -- 本地实际入库数
|
||||
upstream_available INTEGER NOT NULL DEFAULT 0, -- 采购时上游可用库存
|
||||
points_before NUMERIC(12,2) NOT NULL DEFAULT 0.00, -- 采购前积分
|
||||
unit_cost NUMERIC(12,2) NOT NULL DEFAULT 0.00, -- 采购单价
|
||||
max_by_points INTEGER NOT NULL DEFAULT 0, -- 积分可买上限
|
||||
max_by_stock INTEGER NOT NULL DEFAULT 0, -- 上游库存可买上限
|
||||
limit_reason VARCHAR(32) NOT NULL DEFAULT '', -- points / upstream_stock / points_and_stock
|
||||
points_source VARCHAR(16) NOT NULL DEFAULT '', -- live / snapshot
|
||||
stage VARCHAR(32) NOT NULL DEFAULT '', -- me / dashboard / claim / package / detail / import
|
||||
error_message TEXT NOT NULL DEFAULT '',
|
||||
package_id BIGINT,
|
||||
share_code VARCHAR(64) NOT NULL DEFAULT '',
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
finished_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rock_purchase_logs_created_at
|
||||
ON rock_purchase_logs (created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_rock_purchase_logs_trigger_status
|
||||
ON rock_purchase_logs (trigger_type, status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rock_purchase_stages (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
log_id BIGINT NOT NULL REFERENCES rock_purchase_logs(id) ON DELETE CASCADE,
|
||||
seq INTEGER NOT NULL DEFAULT 0,
|
||||
stage VARCHAR(32) NOT NULL,
|
||||
method VARCHAR(8) NOT NULL DEFAULT '',
|
||||
url TEXT NOT NULL DEFAULT '',
|
||||
request_body TEXT NOT NULL DEFAULT '',
|
||||
http_status INTEGER NOT NULL DEFAULT 0,
|
||||
response_body TEXT NOT NULL DEFAULT '',
|
||||
truncated BOOLEAN NOT NULL DEFAULT false,
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
error_message TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rock_purchase_stages_log_id
|
||||
ON rock_purchase_stages (log_id, seq);
|
||||
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -100,12 +101,70 @@ func (h *RockHandler) PurchaseTokens(c *gin.Context) {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
summary := h.rockSvc.LastPurchaseSummary()
|
||||
message := "采购成功,已批量入库"
|
||||
planned := len(tokens)
|
||||
limitReason := ""
|
||||
if summary != nil {
|
||||
planned = summary.PlannedCount
|
||||
limitReason = summary.LimitReason
|
||||
switch summary.LimitReason {
|
||||
case "upstream_stock":
|
||||
message = fmt.Sprintf("上游货源不足,已按 %d 个采购", summary.PlannedCount)
|
||||
case "points":
|
||||
message = fmt.Sprintf("积分不足,已按 %d 个采购", summary.PlannedCount)
|
||||
case "points_and_stock":
|
||||
message = fmt.Sprintf("积分与上游库存不足,已按 %d 个采购", summary.PlannedCount)
|
||||
case "points_snapshot":
|
||||
message = fmt.Sprintf("积分不足(按快照估算),已按 %d 个采购", summary.PlannedCount)
|
||||
}
|
||||
}
|
||||
response.OK(c, gin.H{
|
||||
"purchased_count": len(tokens),
|
||||
"message": "采购成功,已批量入库",
|
||||
"requested_count": req.Count,
|
||||
"planned_count": planned,
|
||||
"limit_reason": limitReason,
|
||||
"log_id": summaryLogID(summary),
|
||||
"message": message,
|
||||
})
|
||||
}
|
||||
|
||||
func summaryLogID(summary *service.PurchaseSummary) uint {
|
||||
if summary == nil {
|
||||
return 0
|
||||
}
|
||||
return summary.LogID
|
||||
}
|
||||
|
||||
// ListPurchaseLogs 分页查询采购流水
|
||||
func (h *RockHandler) ListPurchaseLogs(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
triggerType := strings.TrimSpace(c.Query("trigger_type"))
|
||||
status := strings.TrimSpace(c.Query("status"))
|
||||
list, total, err := h.rockSvc.ListPurchaseLogs(c.Request.Context(), page, pageSize, triggerType, status)
|
||||
if err != nil {
|
||||
response.ServerError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"list": list, "total": total})
|
||||
}
|
||||
|
||||
// GetPurchaseLog 查询单次采购详情(含各阶段原始报文)
|
||||
func (h *RockHandler) GetPurchaseLog(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
response.BadRequest(c, "无效的日志 ID")
|
||||
return
|
||||
}
|
||||
entry, stages, err := h.rockSvc.GetPurchaseLog(c.Request.Context(), uint(id))
|
||||
if err != nil {
|
||||
response.NotFound(c, "采购日志不存在")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"log": entry, "stages": stages})
|
||||
}
|
||||
|
||||
// ListTokens 分页获取 Token 库存列表
|
||||
func (h *RockHandler) ListTokens(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
|
||||
@@ -100,3 +100,56 @@ type RockToken struct {
|
||||
func (RockToken) TableName() string {
|
||||
return "rock_tokens"
|
||||
}
|
||||
|
||||
// RockPurchaseLog 是一次采购尝试的主记录。
|
||||
// 每一次真正进入采购流程的尝试都会落一条,包含降级原因与各阶段汇总。
|
||||
type RockPurchaseLog struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
TriggerType string `gorm:"size:16;not null;default:'auto';index:idx_rock_purchase_logs_trigger_status" json:"trigger_type"` // auto / manual
|
||||
Status string `gorm:"size:16;not null;default:'running'" json:"status"` // running / success / partial / failed / skipped
|
||||
RequestedCount int `gorm:"not null;default:0" json:"requested_count"`
|
||||
PlannedCount int `gorm:"not null;default:0" json:"planned_count"`
|
||||
ClaimedCount int `gorm:"not null;default:0" json:"claimed_count"`
|
||||
ImportedCount int `gorm:"not null;default:0" json:"imported_count"`
|
||||
UpstreamAvailable int `gorm:"not null;default:0" json:"upstream_available"`
|
||||
PointsBefore float64 `gorm:"type:numeric(12,2);not null;default:0" json:"points_before"`
|
||||
UnitCost float64 `gorm:"type:numeric(12,2);not null;default:0" json:"unit_cost"`
|
||||
MaxByPoints int `gorm:"not null;default:0" json:"max_by_points"`
|
||||
MaxByStock int `gorm:"not null;default:0" json:"max_by_stock"`
|
||||
LimitReason string `gorm:"size:32;not null;default:''" json:"limit_reason"` // points / upstream_stock / points_and_stock
|
||||
PointsSource string `gorm:"size:16;not null;default:''" json:"points_source"` // live / snapshot
|
||||
Stage string `gorm:"size:32;not null;default:''" json:"stage"`
|
||||
ErrorMessage string `gorm:"type:text;not null;default:''" json:"error_message"`
|
||||
PackageID *int64 `json:"package_id,omitempty"`
|
||||
ShareCode string `gorm:"size:64;not null;default:''" json:"share_code"`
|
||||
DurationMs int `gorm:"not null;default:0" json:"duration_ms"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (RockPurchaseLog) TableName() string {
|
||||
return "rock_purchase_logs"
|
||||
}
|
||||
|
||||
// RockPurchaseStage 记录一次采购中单个上游调用的原始请求与响应。
|
||||
// ResponseBody 保存上游返回的原始 JSON 原文;超过 512KB 时截断并置 Truncated。
|
||||
type RockPurchaseStage struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
LogID uint `gorm:"not null;index:idx_rock_purchase_stages_log_id" json:"log_id"`
|
||||
Seq int `gorm:"not null;default:0" json:"seq"`
|
||||
Stage string `gorm:"size:32;not null" json:"stage"`
|
||||
Method string `gorm:"size:8;not null;default:''" json:"method"`
|
||||
URL string `gorm:"type:text;not null;default:''" json:"url"`
|
||||
RequestBody string `gorm:"type:text;not null;default:''" json:"request_body"`
|
||||
HTTPStatus int `gorm:"not null;default:0" json:"http_status"`
|
||||
ResponseBody string `gorm:"type:text;not null;default:''" json:"response_body"`
|
||||
Truncated bool `gorm:"not null;default:false" json:"truncated"`
|
||||
DurationMs int `gorm:"not null;default:0" json:"duration_ms"`
|
||||
ErrorMessage string `gorm:"type:text;not null;default:''" json:"error_message"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (RockPurchaseStage) TableName() string {
|
||||
return "rock_purchase_stages"
|
||||
}
|
||||
|
||||
@@ -289,6 +289,8 @@ func Setup(h *Handlers) *gin.Engine {
|
||||
platform.GET("/platform/rock/stats", h.Rock.GetStats)
|
||||
platform.POST("/platform/rock/dashboard/refresh", h.Rock.RefreshDashboard)
|
||||
platform.POST("/platform/rock/purchase", h.Rock.PurchaseTokens)
|
||||
platform.GET("/platform/rock/purchase-logs", h.Rock.ListPurchaseLogs)
|
||||
platform.GET("/platform/rock/purchase-logs/:id", h.Rock.GetPurchaseLog)
|
||||
platform.GET("/platform/rock/tokens", h.Rock.ListTokens)
|
||||
platform.POST("/platform/rock/tokens/:id/expected-uid", h.Rock.CorrectTokenUID)
|
||||
platform.POST("/platform/rock/tokens/:id/reset", h.Rock.ResetToken)
|
||||
|
||||
@@ -374,6 +374,7 @@ func (r *RockPackageCreateResponse) ResolvePackage() (int64, string) {
|
||||
// CreatePackage 把一批已提取 Token 打包成公开分享包(需 Bearer Token)。
|
||||
// 注意:上游未返回 share_code 时不会在这里报错,由调用方决定兜底策略。
|
||||
func (c *RockClient) CreatePackage(ctx context.Context, bearerToken string, ids []string, note string) (*RockPackageCreateResponse, error) {
|
||||
startedAt := time.Now()
|
||||
if len(ids) == 0 {
|
||||
return nil, fmt.Errorf("打包 ids 不能为空")
|
||||
}
|
||||
@@ -404,6 +405,7 @@ func (c *RockClient) CreatePackage(ctx context.Context, bearerToken string, ids
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 packages 创建响应失败: %w", err)
|
||||
}
|
||||
traceRockRequest(ctx, "package", http.MethodPost, reqURL, jsonBytes, resp.StatusCode, body, time.Since(startedAt), err)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("上游 packages 创建接口返回错误码 %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
@@ -420,6 +422,7 @@ func (c *RockClient) CreatePackage(ctx context.Context, bearerToken string, ids
|
||||
|
||||
// GetPackageDetail 按 share_code 查询打包详情(公开接口,无需 Bearer)。
|
||||
func (c *RockClient) GetPackageDetail(ctx context.Context, shareCode string) (*RockPackageDetailResponse, error) {
|
||||
startedAt := time.Now()
|
||||
shareCode = strings.TrimSpace(shareCode)
|
||||
if shareCode == "" {
|
||||
return nil, fmt.Errorf("share_code 不能为空")
|
||||
@@ -441,6 +444,7 @@ func (c *RockClient) GetPackageDetail(ctx context.Context, shareCode string) (*R
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 package 详情响应失败: %w", err)
|
||||
}
|
||||
traceRockRequest(ctx, "detail", http.MethodGet, reqURL, nil, resp.StatusCode, body, time.Since(startedAt), err)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("上游 package 详情接口返回错误码 %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
@@ -456,6 +460,7 @@ func (c *RockClient) GetPackageDetail(ctx context.Context, shareCode string) (*R
|
||||
|
||||
// GetMe 获取商户个人信息及积分
|
||||
func (c *RockClient) GetMe(ctx context.Context, bearerToken string) (*RockMeResponse, error) {
|
||||
startedAt := time.Now()
|
||||
reqURL := fmt.Sprintf("%s/api/me", c.BaseURL())
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
@@ -474,6 +479,7 @@ func (c *RockClient) GetMe(ctx context.Context, bearerToken string) (*RockMeResp
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取响应失败: %w", err)
|
||||
}
|
||||
traceRockRequest(ctx, "me", http.MethodGet, reqURL, nil, resp.StatusCode, body, time.Since(startedAt), err)
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return nil, fmt.Errorf("上游 Bearer Token 已失效或未授权 (401)")
|
||||
@@ -532,6 +538,7 @@ func (r *RockDashboardResponse) ResolveBlueTokenCost() float64 {
|
||||
|
||||
// GetDashboard 获取上游可用库存与实时采购单价(需 Bearer Token)。
|
||||
func (c *RockClient) GetDashboard(ctx context.Context, bearerToken string) (*RockDashboardResponse, error) {
|
||||
startedAt := time.Now()
|
||||
reqURL := fmt.Sprintf("%s/api/user/dashboard", c.BaseURL())
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
@@ -552,6 +559,7 @@ func (c *RockClient) GetDashboard(ctx context.Context, bearerToken string) (*Roc
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 dashboard 响应失败: %w", err)
|
||||
}
|
||||
traceRockRequest(ctx, "dashboard", http.MethodGet, reqURL, nil, resp.StatusCode, body, time.Since(startedAt), err)
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return nil, fmt.Errorf("上游 Bearer Token 已失效或未授权 (401)")
|
||||
}
|
||||
@@ -570,6 +578,7 @@ func (c *RockClient) GetDashboard(ctx context.Context, bearerToken string) (*Roc
|
||||
|
||||
// ClaimTokens 批量提取采购 Token
|
||||
func (c *RockClient) ClaimTokens(ctx context.Context, bearerToken string, color string, count int) ([]RockClaimedToken, error) {
|
||||
startedAt := time.Now()
|
||||
if count <= 0 {
|
||||
return nil, fmt.Errorf("提取数量必须大于 0")
|
||||
}
|
||||
@@ -600,6 +609,7 @@ func (c *RockClient) ClaimTokens(ctx context.Context, bearerToken string, color
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 claim 响应失败: %w", err)
|
||||
}
|
||||
traceRockRequest(ctx, "claim", http.MethodPost, reqURL, jsonBytes, resp.StatusCode, body, time.Since(startedAt), err)
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("上游 claim 接口返回错误码 %d: %s", resp.StatusCode, string(body))
|
||||
|
||||
@@ -101,7 +101,7 @@ func TestRefreshDashboard_UpdatesChangedCost(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurchaseTokens_BlocksWhenUpstreamStockInsufficient(t *testing.T) {
|
||||
func TestPurchaseTokens_DowngradesWhenUpstreamStockInsufficient(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
var claimCalls int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -124,23 +124,30 @@ func TestPurchaseTokens_BlocksWhenUpstreamStockInsufficient(t *testing.T) {
|
||||
svc := NewRockService(db, NewRockClient(server.URL))
|
||||
setRockTestBearer(t, svc, "bearer-test")
|
||||
|
||||
_, err := svc.PurchaseTokens(context.Background(), 2, false)
|
||||
if err == nil || !strings.Contains(err.Error(), "上游可用库存不足") {
|
||||
t.Fatalf("库存不足时应阻止采购,实际 err=%v", err)
|
||||
tokens, err := svc.PurchaseTokens(context.Background(), 2, false)
|
||||
if err != nil {
|
||||
t.Fatalf("上游库存不足时应降级采购而非失败,实际 err=%v", err)
|
||||
}
|
||||
if calls := atomic.LoadInt32(&claimCalls); calls != 0 {
|
||||
t.Fatalf("库存不足时不应调用上游 claim,实际调用 %d 次", calls)
|
||||
if calls := atomic.LoadInt32(&claimCalls); calls != 1 {
|
||||
t.Fatalf("降级后应调用上游 claim 1 次,实际 %d 次", calls)
|
||||
}
|
||||
if len(tokens) != 1 {
|
||||
t.Fatalf("期望按库存上限入库 1 个 Token,实际 %d 个", len(tokens))
|
||||
}
|
||||
summary := svc.LastPurchaseSummary()
|
||||
if summary == nil {
|
||||
t.Fatal("缺少采购摘要")
|
||||
}
|
||||
if summary.PlannedCount != 1 || summary.LimitReason != "upstream_stock" {
|
||||
t.Fatalf("降级信息不符: planned=%d reason=%s", summary.PlannedCount, summary.LimitReason)
|
||||
}
|
||||
|
||||
var count int64
|
||||
db.Model(&model.RockToken{}).Count(&count)
|
||||
if count != 0 {
|
||||
t.Fatalf("库存不足时不应入库任何 Token,实际 %d 条", count)
|
||||
var entry model.RockPurchaseLog
|
||||
if err := db.Order("id DESC").First(&entry).Error; err != nil {
|
||||
t.Fatalf("查询采购日志失败: %v", err)
|
||||
}
|
||||
|
||||
cfg, _ := svc.GetConfig(context.Background())
|
||||
if !strings.Contains(cfg.LastError, "上游可用库存不足") {
|
||||
t.Fatalf("last_error 应记录库存不足,实际 %q", cfg.LastError)
|
||||
if entry.Status != "partial" || entry.MaxByStock != 1 {
|
||||
t.Fatalf("采购日志不符: status=%s max_by_stock=%d", entry.Status, entry.MaxByStock)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// rockPurchaseStageMaxBytes 单个阶段原始响应/请求的最大保存体积。
|
||||
// 超过后截断并标记 truncated,避免异常大响应撑爆数据库。
|
||||
const rockPurchaseStageMaxBytes = 512 * 1024
|
||||
|
||||
// PurchaseSummary 是最近一次采购的可展示摘要,供手动采购接口回显降级结果。
|
||||
type PurchaseSummary struct {
|
||||
LogID uint `json:"log_id"`
|
||||
TriggerType string `json:"trigger_type"`
|
||||
Status string `json:"status"`
|
||||
RequestedCount int `json:"requested_count"`
|
||||
PlannedCount int `json:"planned_count"`
|
||||
ClaimedCount int `json:"claimed_count"`
|
||||
ImportedCount int `json:"imported_count"`
|
||||
LimitReason string `json:"limit_reason"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
}
|
||||
|
||||
// purchaseRecorder 负责一次采购的日志生命周期:
|
||||
// 开始时插入主记录(running),每个上游调用写一条阶段记录,结束时更新主记录。
|
||||
// 所有写入失败都只记日志,绝不阻断采购主流程。
|
||||
type purchaseRecorder struct {
|
||||
db *gorm.DB
|
||||
logID uint
|
||||
seq int
|
||||
requested int
|
||||
planned int
|
||||
claimed int
|
||||
imported int
|
||||
limitReason string
|
||||
pointsSource string
|
||||
stage string
|
||||
errMessage string
|
||||
degraded bool
|
||||
|
||||
upstreamAvailable int
|
||||
pointsBefore float64
|
||||
unitCost float64
|
||||
maxByPoints int
|
||||
maxByStock int
|
||||
packageID *int64
|
||||
shareCode string
|
||||
}
|
||||
|
||||
func newPurchaseRecorder(db *gorm.DB, triggerType string, requested int, startedAt time.Time) *purchaseRecorder {
|
||||
rec := &purchaseRecorder{db: db, requested: requested}
|
||||
entry := model.RockPurchaseLog{
|
||||
TriggerType: triggerType,
|
||||
Status: "running",
|
||||
RequestedCount: requested,
|
||||
StartedAt: startedAt,
|
||||
CreatedAt: startedAt,
|
||||
}
|
||||
if err := db.WithContext(context.Background()).Create(&entry).Error; err != nil {
|
||||
log.Printf("[RockService] 创建采购日志失败,本次采购不记录流水: %v", err)
|
||||
return rec
|
||||
}
|
||||
rec.logID = entry.ID
|
||||
return rec
|
||||
}
|
||||
|
||||
// addStage 记录一次上游调用的原始请求与响应。
|
||||
func (r *purchaseRecorder) addStage(stage, method, url string, reqBody []byte, status int, respBody []byte, dur time.Duration, callErr error) {
|
||||
if r == nil || r.logID == 0 {
|
||||
return
|
||||
}
|
||||
r.seq++
|
||||
responseText, truncated := truncateStageBody(respBody)
|
||||
requestText, _ := truncateStageBody(reqBody)
|
||||
|
||||
errText := ""
|
||||
if callErr != nil {
|
||||
errText = callErr.Error()
|
||||
}
|
||||
if errText == "" && status >= 400 {
|
||||
errText = fmt.Sprintf("上游返回 HTTP %d", status)
|
||||
}
|
||||
if errText != "" && r.stage == "" {
|
||||
r.stage = stage
|
||||
r.errMessage = truncatePurchaseError(errText)
|
||||
}
|
||||
|
||||
entry := model.RockPurchaseStage{
|
||||
LogID: r.logID,
|
||||
Seq: r.seq,
|
||||
Stage: stage,
|
||||
Method: method,
|
||||
URL: url,
|
||||
RequestBody: requestText,
|
||||
HTTPStatus: status,
|
||||
ResponseBody: responseText,
|
||||
Truncated: truncated,
|
||||
DurationMs: int(dur.Milliseconds()),
|
||||
ErrorMessage: truncatePurchaseError(errText),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := r.db.WithContext(context.Background()).Create(&entry).Error; err != nil {
|
||||
log.Printf("[RockService] 写入采购阶段日志失败 stage=%s: %v", stage, err)
|
||||
}
|
||||
}
|
||||
|
||||
// markDegraded 标记本次采购虽有产出但存在降级/兜底行为,最终状态记为 partial。
|
||||
func (r *purchaseRecorder) markDegraded(reason string) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.degraded = true
|
||||
if r.errMessage == "" {
|
||||
r.errMessage = truncatePurchaseError(reason)
|
||||
}
|
||||
}
|
||||
|
||||
// finish 更新主记录并返回摘要。
|
||||
func (r *purchaseRecorder) finish(tokens []model.RockToken, runErr error, duration time.Duration) *PurchaseSummary {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
if r.imported == 0 && len(tokens) > 0 {
|
||||
r.imported = len(tokens)
|
||||
}
|
||||
status := "success"
|
||||
switch {
|
||||
case r.planned <= 0:
|
||||
status = "skipped"
|
||||
case runErr != nil:
|
||||
status = "failed"
|
||||
case r.degraded || r.planned < r.requested || (r.claimed > 0 && r.imported < r.claimed):
|
||||
status = "partial"
|
||||
}
|
||||
if runErr != nil && r.errMessage == "" {
|
||||
r.errMessage = truncatePurchaseError(runErr.Error())
|
||||
}
|
||||
|
||||
finished := time.Now()
|
||||
summary := &PurchaseSummary{
|
||||
LogID: r.logID,
|
||||
Status: status,
|
||||
RequestedCount: r.requested,
|
||||
PlannedCount: r.planned,
|
||||
ClaimedCount: r.claimed,
|
||||
ImportedCount: r.imported,
|
||||
LimitReason: r.limitReason,
|
||||
ErrorMessage: r.errMessage,
|
||||
}
|
||||
if r.logID == 0 {
|
||||
return summary
|
||||
}
|
||||
updates := map[string]interface{}{
|
||||
"status": status,
|
||||
"planned_count": r.planned,
|
||||
"claimed_count": r.claimed,
|
||||
"imported_count": r.imported,
|
||||
"upstream_available": r.upstreamAvailable,
|
||||
"points_before": r.pointsBefore,
|
||||
"unit_cost": r.unitCost,
|
||||
"max_by_points": r.maxByPoints,
|
||||
"max_by_stock": r.maxByStock,
|
||||
"limit_reason": r.limitReason,
|
||||
"points_source": r.pointsSource,
|
||||
"stage": r.stage,
|
||||
"error_message": r.errMessage,
|
||||
"package_id": r.packageID,
|
||||
"share_code": r.shareCode,
|
||||
"duration_ms": int(duration.Milliseconds()),
|
||||
"finished_at": finished,
|
||||
}
|
||||
if err := r.db.WithContext(context.Background()).Model(&model.RockPurchaseLog{}).
|
||||
Where("id = ?", r.logID).Updates(updates).Error; err != nil {
|
||||
log.Printf("[RockService] 更新采购日志失败 id=%d: %v", r.logID, err)
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
// truncateStageBody 把原始请求/响应限制在 512KB 内,保证截断后仍是合法 UTF-8。
|
||||
func truncateStageBody(raw []byte) (string, bool) {
|
||||
if len(raw) == 0 {
|
||||
return "", false
|
||||
}
|
||||
if len(raw) <= rockPurchaseStageMaxBytes {
|
||||
return string(raw), false
|
||||
}
|
||||
cut := raw[:rockPurchaseStageMaxBytes]
|
||||
return strings.ToValidUTF8(string(cut), "?"), true
|
||||
}
|
||||
|
||||
// LastPurchaseSummary 返回最近一次采购的摘要(包含降级原因),供手动采购接口回显。
|
||||
func (s *RockService) LastPurchaseSummary() *PurchaseSummary {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.lastSummary
|
||||
}
|
||||
|
||||
// ListPurchaseLogs 分页查询采购流水(不返回阶段大字段)。
|
||||
func (s *RockService) ListPurchaseLogs(ctx context.Context, page, pageSize int, triggerType, status string) ([]model.RockPurchaseLog, int64, error) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 || pageSize > 200 {
|
||||
pageSize = 20
|
||||
}
|
||||
db := s.db.WithContext(ctx).Model(&model.RockPurchaseLog{})
|
||||
if t := strings.TrimSpace(triggerType); t != "" {
|
||||
db = db.Where("trigger_type = ?", t)
|
||||
}
|
||||
if st := strings.TrimSpace(status); st != "" {
|
||||
db = db.Where("status = ?", st)
|
||||
}
|
||||
var total int64
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var list []model.RockPurchaseLog
|
||||
if err := db.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&list).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return list, total, nil
|
||||
}
|
||||
|
||||
// GetPurchaseLog 查询单次采购详情,包含全部阶段原始报文。
|
||||
func (s *RockService) GetPurchaseLog(ctx context.Context, id uint) (*model.RockPurchaseLog, []model.RockPurchaseStage, error) {
|
||||
var entry model.RockPurchaseLog
|
||||
if err := s.db.WithContext(ctx).First(&entry, id).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var stages []model.RockPurchaseStage
|
||||
if err := s.db.WithContext(ctx).Where("log_id = ?", id).Order("seq ASC, id ASC").Find(&stages).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &entry, stages, nil
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"affiliate_dash/internal/model"
|
||||
)
|
||||
|
||||
// newPurchaseMockServer 构造覆盖采购全链路的上游 mock,返回服务与 claim 调用计数。
|
||||
func newPurchaseMockServer(t *testing.T, points string, stock int, cost string, claimStatus int) (*httptest.Server, *int32) {
|
||||
t.Helper()
|
||||
var claimCalls int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/api/me":
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"user": map[string]interface{}{"username": "tester", "points": points},
|
||||
})
|
||||
case "/api/user/dashboard":
|
||||
writeRockDashboardJSON(w, stock, cost)
|
||||
case "/api/user/tokens/claim":
|
||||
atomic.AddInt32(&claimCalls, 1)
|
||||
if claimStatus != http.StatusOK {
|
||||
w.WriteHeader(claimStatus)
|
||||
_, _ = w.Write([]byte(`{"error":"claim down"}`))
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"tokens": []map[string]interface{}{
|
||||
{"id": 60001, "token": "tk-1", "color": "black", "region": "Q/V可用", "status": "using", "redeem_code": "rc-1"},
|
||||
},
|
||||
})
|
||||
case "/api/user/packages":
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"package": map[string]interface{}{"id": 8001, "share_code": "share-log", "token_count": 1},
|
||||
})
|
||||
case "/api/package/share-log":
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"package": map[string]interface{}{"id": 8001, "share_code": "share-log", "token_count": 1},
|
||||
"tokens": []map[string]interface{}{
|
||||
{"id": 60001, "token": "tk-1", "color": "black", "region": "Q/V可用", "status": "using", "redeem_code": "rc-1"},
|
||||
},
|
||||
})
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
return server, &claimCalls
|
||||
}
|
||||
|
||||
func TestPurchaseTokens_DowngradesByPoints(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
server, claimCalls := newPurchaseMockServer(t, "30.0", 100, "20.0", http.StatusOK)
|
||||
defer server.Close()
|
||||
|
||||
svc := NewRockService(db, NewRockClient(server.URL))
|
||||
setRockTestBearer(t, svc, "bearer-test")
|
||||
|
||||
tokens, err := svc.PurchaseTokens(context.Background(), 2, false)
|
||||
if err != nil {
|
||||
t.Fatalf("积分不足时应降级采购而非失败,实际 err=%v", err)
|
||||
}
|
||||
if len(tokens) != 1 {
|
||||
t.Fatalf("30 点 / 20 点单价期望采购 1 个,实际 %d 个", len(tokens))
|
||||
}
|
||||
if calls := atomic.LoadInt32(claimCalls); calls != 1 {
|
||||
t.Fatalf("应调用上游 claim 1 次,实际 %d 次", calls)
|
||||
}
|
||||
|
||||
summary := svc.LastPurchaseSummary()
|
||||
if summary == nil || summary.PlannedCount != 1 || summary.LimitReason != "points" {
|
||||
t.Fatalf("降级信息不符: %+v", summary)
|
||||
}
|
||||
|
||||
var entry model.RockPurchaseLog
|
||||
if err := db.Order("id DESC").First(&entry).Error; err != nil {
|
||||
t.Fatalf("查询采购日志失败: %v", err)
|
||||
}
|
||||
if entry.Status != "partial" {
|
||||
t.Fatalf("降级成交应记为 partial,实际 %s", entry.Status)
|
||||
}
|
||||
if entry.MaxByPoints != 1 || entry.MaxByStock != 100 {
|
||||
t.Fatalf("上限快照不符: points=%d stock=%d", entry.MaxByPoints, entry.MaxByStock)
|
||||
}
|
||||
if math.Abs(entry.PointsBefore-30) > 1e-9 || math.Abs(entry.UnitCost-20) > 1e-9 {
|
||||
t.Fatalf("积分/单价快照不符: points=%v cost=%v", entry.PointsBefore, entry.UnitCost)
|
||||
}
|
||||
if entry.PointsSource != "live" {
|
||||
t.Fatalf("应使用实时积分,实际来源 %s", entry.PointsSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurchaseTokens_SkipsWhenBudgetZero(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
server, claimCalls := newPurchaseMockServer(t, "10.0", 100, "20.0", http.StatusOK)
|
||||
defer server.Close()
|
||||
|
||||
svc := NewRockService(db, NewRockClient(server.URL))
|
||||
setRockTestBearer(t, svc, "bearer-test")
|
||||
|
||||
_, err := svc.PurchaseTokens(context.Background(), 2, false)
|
||||
if err == nil || !strings.Contains(err.Error(), "本次未采购") {
|
||||
t.Fatalf("积分不足一个时不应采购,实际 err=%v", err)
|
||||
}
|
||||
if calls := atomic.LoadInt32(claimCalls); calls != 0 {
|
||||
t.Fatalf("未采购时不应调用 claim,实际 %d 次", calls)
|
||||
}
|
||||
|
||||
var entry model.RockPurchaseLog
|
||||
if err := db.Order("id DESC").First(&entry).Error; err != nil {
|
||||
t.Fatalf("查询采购日志失败: %v", err)
|
||||
}
|
||||
if entry.Status != "skipped" || entry.PlannedCount != 0 || entry.LimitReason != "points" {
|
||||
t.Fatalf("跳过记录不符: status=%s planned=%d reason=%s", entry.Status, entry.PlannedCount, entry.LimitReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurchaseTokens_DowngradesByPointsAndStock(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
server, _ := newPurchaseMockServer(t, "30.0", 1, "20.0", http.StatusOK)
|
||||
defer server.Close()
|
||||
|
||||
svc := NewRockService(db, NewRockClient(server.URL))
|
||||
setRockTestBearer(t, svc, "bearer-test")
|
||||
|
||||
tokens, err := svc.PurchaseTokens(context.Background(), 5, false)
|
||||
if err != nil {
|
||||
t.Fatalf("双重限制时应降级采购,实际 err=%v", err)
|
||||
}
|
||||
if len(tokens) != 1 {
|
||||
t.Fatalf("积分与库存都只够 1 个,实际入库 %d 个", len(tokens))
|
||||
}
|
||||
summary := svc.LastPurchaseSummary()
|
||||
if summary == nil || summary.LimitReason != "points_and_stock" || summary.PlannedCount != 1 {
|
||||
t.Fatalf("双重限制信息不符: %+v", summary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurchaseTokens_PersistsRawResponse(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
server, _ := newPurchaseMockServer(t, "1000.0", 100, "20.0", http.StatusOK)
|
||||
defer server.Close()
|
||||
|
||||
svc := NewRockService(db, NewRockClient(server.URL))
|
||||
setRockTestBearer(t, svc, "bearer-test")
|
||||
|
||||
tokens, err := svc.PurchaseTokens(context.Background(), 1, false)
|
||||
if err != nil {
|
||||
t.Fatalf("采购失败: %v", err)
|
||||
}
|
||||
if len(tokens) != 1 {
|
||||
t.Fatalf("期望入库 1 个,实际 %d", len(tokens))
|
||||
}
|
||||
summary := svc.LastPurchaseSummary()
|
||||
if summary == nil || summary.LogID == 0 {
|
||||
t.Fatal("缺少采购日志 ID")
|
||||
}
|
||||
|
||||
_, stages, err := svc.GetPurchaseLog(context.Background(), summary.LogID)
|
||||
if err != nil {
|
||||
t.Fatalf("查询采购阶段失败: %v", err)
|
||||
}
|
||||
if len(stages) < 5 {
|
||||
t.Fatalf("应记录 me/dashboard/claim/package/detail 至少 5 个阶段,实际 %d 个", len(stages))
|
||||
}
|
||||
var claimStage *model.RockPurchaseStage
|
||||
for i := range stages {
|
||||
if stages[i].Stage == "claim" {
|
||||
claimStage = &stages[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if claimStage == nil {
|
||||
t.Fatal("缺少 claim 阶段记录")
|
||||
}
|
||||
if claimStage.HTTPStatus != http.StatusOK {
|
||||
t.Fatalf("claim 阶段状态码应为 200,实际 %d", claimStage.HTTPStatus)
|
||||
}
|
||||
if !strings.Contains(claimStage.ResponseBody, "60001") {
|
||||
t.Fatalf("原始响应未保留,实际 %q", claimStage.ResponseBody)
|
||||
}
|
||||
if !strings.Contains(claimStage.RequestBody, "\"count\"") {
|
||||
t.Fatalf("请求体未保留,实际 %q", claimStage.RequestBody)
|
||||
}
|
||||
if strings.Contains(claimStage.RequestBody, "bearer-test") {
|
||||
t.Fatal("请求体不应包含 Bearer Token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurchaseTokens_RecordsFailedStage(t *testing.T) {
|
||||
db := newRockTestDB(t)
|
||||
server, _ := newPurchaseMockServer(t, "1000.0", 100, "20.0", http.StatusInternalServerError)
|
||||
defer server.Close()
|
||||
|
||||
svc := NewRockService(db, NewRockClient(server.URL))
|
||||
setRockTestBearer(t, svc, "bearer-test")
|
||||
|
||||
_, err := svc.PurchaseTokens(context.Background(), 1, false)
|
||||
if err == nil {
|
||||
t.Fatal("上游 claim 返回 500 时应当失败")
|
||||
}
|
||||
|
||||
var entry model.RockPurchaseLog
|
||||
if err := db.Order("id DESC").First(&entry).Error; err != nil {
|
||||
t.Fatalf("查询采购日志失败: %v", err)
|
||||
}
|
||||
if entry.Status != "failed" || entry.Stage != "claim" {
|
||||
t.Fatalf("失败记录不符: status=%s stage=%s", entry.Status, entry.Stage)
|
||||
}
|
||||
|
||||
_, stages, err := svc.GetPurchaseLog(context.Background(), entry.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("查询阶段失败: %v", err)
|
||||
}
|
||||
var failedStage *model.RockPurchaseStage
|
||||
for i := range stages {
|
||||
if stages[i].Stage == "claim" {
|
||||
failedStage = &stages[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if failedStage == nil {
|
||||
t.Fatal("缺少 claim 阶段记录")
|
||||
}
|
||||
if !strings.Contains(failedStage.ResponseBody, "claim down") {
|
||||
t.Fatalf("失败响应原文未保留,实际 %q", failedStage.ResponseBody)
|
||||
}
|
||||
if failedStage.ErrorMessage == "" {
|
||||
t.Fatal("失败阶段应记录错误信息")
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,10 @@ func setRockTestBearer(t *testing.T, svc *RockService, token string) {
|
||||
if _, err := svc.UpdateConfig(context.Background(), UpdateRockConfigReq{BearerToken: &bearer}); err != nil {
|
||||
t.Fatalf("设置 Bearer Token 失败: %v", err)
|
||||
}
|
||||
// 测试默认给足积分,避免降级逻辑把采购量算成 0;积分限制由专门用例覆盖。
|
||||
if err := svc.db.Model(&model.RockConfig{}).Where("id = 1").Update("points", 100000).Error; err != nil {
|
||||
t.Fatalf("设置测试积分失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurchaseTokens_CreatesPackageAndImportsAvailable(t *testing.T) {
|
||||
|
||||
@@ -20,6 +20,7 @@ type RockService struct {
|
||||
mu sync.Mutex
|
||||
refreshCache sync.Map // key: upstreamTokenID string, value: rockRefreshCacheItem
|
||||
bindLinkCache sync.Map // key: token ID uint, value: rockBindLinkCacheItem
|
||||
lastSummary *PurchaseSummary
|
||||
}
|
||||
|
||||
func NewRockService(db *gorm.DB, client *RockClient) *RockService {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -19,13 +20,26 @@ const (
|
||||
rockPurchaseNoteManualPrefix = "手动采购"
|
||||
)
|
||||
|
||||
// PurchaseTokens 执行采购并入库。
|
||||
// 流程:claim 批量提取 → 创建上游打包记录 → 拉取包详情 → 幂等导入本地(新记录 available)。
|
||||
// PurchaseTokens 执行采购并入库,同时记录完整采购流水。
|
||||
// 流程:积分/库存预检 → 降级计算 → claim 批量提取 → 创建上游打包记录 → 拉取包详情 → 幂等导入本地。
|
||||
// 后续任一步失败都会用 claim 数据兜底入库,保证已扣积分的 Token 不会丢失。
|
||||
func (s *RockService) PurchaseTokens(ctx context.Context, count int, isAuto bool) ([]model.RockToken, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
startedAt := time.Now()
|
||||
trigger := "manual"
|
||||
if isAuto {
|
||||
trigger = "auto"
|
||||
}
|
||||
rec := newPurchaseRecorder(s.db, trigger, count, startedAt)
|
||||
tokens, err := s.purchaseTokensWithRecorder(ctx, count, isAuto, rec)
|
||||
s.lastSummary = rec.finish(tokens, err, time.Since(startedAt))
|
||||
return tokens, err
|
||||
}
|
||||
|
||||
// purchaseTokensWithRecorder 是采购主体,rec 负责记录各阶段的原始请求与响应。
|
||||
func (s *RockService) purchaseTokensWithRecorder(ctx context.Context, count int, isAuto bool, rec *purchaseRecorder) ([]model.RockToken, error) {
|
||||
if count <= 0 {
|
||||
return nil, fmt.Errorf("采购数量必须大于 0")
|
||||
}
|
||||
@@ -54,25 +68,80 @@ func (s *RockService) PurchaseTokens(ctx context.Context, count int, isAuto bool
|
||||
}
|
||||
s.updatePurchaseAttempt(ctx, cfg.ID, now)
|
||||
|
||||
// 采购前先刷新上游 dashboard:校验可用库存,同时同步最新采购单价。
|
||||
// 把采购追踪器挂到 context,客户端会把每个上游调用的原始报文交给 rec。
|
||||
ctx = WithRockTrace(ctx, &RockTrace{OnStage: rec.addStage})
|
||||
|
||||
// 采购前先取实时积分:积分决定这轮最多能买多少个。
|
||||
points := cfg.Points
|
||||
rec.pointsSource = "snapshot"
|
||||
if me, meErr := s.client.GetMe(ctx, cfg.BearerToken); meErr == nil && me != nil {
|
||||
points = me.User.Points.Float64()
|
||||
rec.pointsSource = "live"
|
||||
} else if meErr != nil {
|
||||
log.Printf("[RockService] 获取实时积分失败,回退快照 %.2f: %v", points, meErr)
|
||||
}
|
||||
rec.pointsBefore = points
|
||||
|
||||
// 再刷新 dashboard:拿到上游可用库存与最新单价(同时同步单价到配置)。
|
||||
dashboard, dashErr := s.refreshDashboardWithConfig(ctx, cfg)
|
||||
if dashErr != nil {
|
||||
wrapped := fmt.Errorf("获取上游库存失败,已取消本次采购: %w", dashErr)
|
||||
s.recordPurchaseFailure(ctx, cfg.ID, wrapped.Error())
|
||||
return nil, wrapped
|
||||
}
|
||||
if dashboard.AvailableBlack < count {
|
||||
err := fmt.Errorf("上游可用库存不足:当前 %d 个,本次需要 %d 个", dashboard.AvailableBlack, count)
|
||||
rec.upstreamAvailable = dashboard.AvailableBlack
|
||||
|
||||
unitCost := dashboard.BlackTokenCost
|
||||
if unitCost <= 0 {
|
||||
unitCost = cfg.BlackTokenCost
|
||||
}
|
||||
rec.unitCost = unitCost
|
||||
if unitCost <= 0 {
|
||||
err := errors.New("采购单价无效,无法计算可采购数量")
|
||||
s.recordPurchaseFailure(ctx, cfg.ID, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
claimed, err := s.client.ClaimTokens(ctx, cfg.BearerToken, model.RockColorBlack, count)
|
||||
maxByPoints := int(math.Floor(points / unitCost))
|
||||
if maxByPoints < 0 {
|
||||
maxByPoints = 0
|
||||
}
|
||||
maxByStock := dashboard.AvailableBlack
|
||||
if maxByStock < 0 {
|
||||
maxByStock = 0
|
||||
}
|
||||
rec.maxByPoints = maxByPoints
|
||||
rec.maxByStock = maxByStock
|
||||
|
||||
planned := count
|
||||
if maxByPoints < planned {
|
||||
planned = maxByPoints
|
||||
}
|
||||
if maxByStock < planned {
|
||||
planned = maxByStock
|
||||
}
|
||||
if planned < 0 {
|
||||
planned = 0
|
||||
}
|
||||
rec.planned = planned
|
||||
rec.limitReason = purchaseLimitReason(count, planned, maxByPoints, maxByStock)
|
||||
|
||||
if planned <= 0 {
|
||||
err := fmt.Errorf("本次未采购:积分 %.2f 点最多可买 %d 个,上游可用库存 %d 个", points, maxByPoints, maxByStock)
|
||||
s.recordPurchaseFailure(ctx, cfg.ID, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
if planned < count {
|
||||
log.Printf("[RockService] 采购数量降级:计划 %d 个,实际 %d 个(原因: %s)", count, planned, rec.limitReason)
|
||||
}
|
||||
|
||||
claimed, err := s.client.ClaimTokens(ctx, cfg.BearerToken, model.RockColorBlack, planned)
|
||||
if err != nil {
|
||||
wrapped := fmt.Errorf("调用上游采购接口失败: %w", err)
|
||||
s.recordPurchaseFailure(ctx, cfg.ID, wrapped.Error())
|
||||
return nil, wrapped
|
||||
}
|
||||
rec.claimed = len(claimed)
|
||||
if len(claimed) == 0 {
|
||||
err := errors.New("上游未返回任何有效 Token")
|
||||
s.recordPurchaseFailure(ctx, cfg.ID, err.Error())
|
||||
@@ -113,31 +182,39 @@ func (s *RockService) PurchaseTokens(ctx context.Context, count int, isAuto bool
|
||||
pkgErr = errors.New("上游打包接口未返回内容")
|
||||
}
|
||||
if pkgErr != nil {
|
||||
return s.fallbackClaimedTokens(ctx, cfg.ID, claimed, 0, "", "purchase", now,
|
||||
"创建上游打包记录失败: "+pkgErr.Error())
|
||||
reason := "创建上游打包记录失败: " + pkgErr.Error()
|
||||
rec.markDegraded(reason)
|
||||
return s.fallbackClaimedTokens(ctx, cfg.ID, claimed, 0, "", "purchase", now, reason)
|
||||
}
|
||||
|
||||
// 2. 拉取包详情(带重试)
|
||||
detail, detailErr := s.getPackageDetailWithRetry(ctx, shareCode)
|
||||
pid := packageID
|
||||
rec.packageID = &pid
|
||||
rec.shareCode = shareCode
|
||||
if detailErr != nil {
|
||||
return s.fallbackClaimedTokens(ctx, cfg.ID, claimed, packageID, shareCode, "package", now,
|
||||
fmt.Sprintf("包 %s 详情拉取失败: %v", shareCode, detailErr))
|
||||
reason := fmt.Sprintf("包 %s 详情拉取失败: %v", shareCode, detailErr)
|
||||
rec.markDegraded(reason)
|
||||
return s.fallbackClaimedTokens(ctx, cfg.ID, claimed, packageID, shareCode, "package", now, reason)
|
||||
}
|
||||
|
||||
// 3. 导入包内 Token:本批刚 claim 的 Token 强制 available,其余沿用上游状态
|
||||
inserted, updated, skipped, importErr := s.importPackageDetail(ctx, detail, claimedSet)
|
||||
if importErr != nil {
|
||||
return s.fallbackClaimedTokens(ctx, cfg.ID, claimed, packageID, shareCode, "package", now,
|
||||
fmt.Sprintf("包 %s 导入失败: %v", shareCode, importErr))
|
||||
reason := fmt.Sprintf("包 %s 导入失败: %v", shareCode, importErr)
|
||||
rec.markDegraded(reason)
|
||||
return s.fallbackClaimedTokens(ctx, cfg.ID, claimed, packageID, shareCode, "package", now, reason)
|
||||
}
|
||||
if inserted+updated == 0 {
|
||||
return s.fallbackClaimedTokens(ctx, cfg.ID, claimed, packageID, shareCode, "package", now,
|
||||
fmt.Sprintf("包 %s 未返回有效 Token", shareCode))
|
||||
reason := fmt.Sprintf("包 %s 未返回有效 Token", shareCode)
|
||||
rec.markDegraded(reason)
|
||||
return s.fallbackClaimedTokens(ctx, cfg.ID, claimed, packageID, shareCode, "package", now, reason)
|
||||
}
|
||||
|
||||
// 4. 极端情况下详情缺 Token:用 claim 数据补齐,保证每个已扣积分的 Token 都在本地
|
||||
ensureErr := s.ensureClaimedTokens(ctx, claimed, packageID, shareCode, "package", now)
|
||||
if ensureErr != nil {
|
||||
rec.markDegraded("补录缺失 Token 失败: " + ensureErr.Error())
|
||||
s.recordPurchaseFailure(ctx, cfg.ID, "补录缺失 Token 失败: "+ensureErr.Error())
|
||||
}
|
||||
|
||||
@@ -148,6 +225,7 @@ func (s *RockService) PurchaseTokens(ctx context.Context, count int, isAuto bool
|
||||
}
|
||||
dbTokens = s.buildClaimedTokens(claimed, packageID, shareCode, "package", now)
|
||||
}
|
||||
rec.imported = len(dbTokens)
|
||||
|
||||
if ensureErr == nil {
|
||||
s.recordPurchaseSuccess(ctx, cfg.ID)
|
||||
@@ -351,6 +429,23 @@ func truncatePurchaseError(msg string) string {
|
||||
return string(runes[:500]) + "..."
|
||||
}
|
||||
|
||||
// purchaseLimitReason 判断本次采购被哪个上限截断,用于流水与页面提示。
|
||||
func purchaseLimitReason(requested, planned, maxByPoints, maxByStock int) string {
|
||||
if planned >= requested {
|
||||
return ""
|
||||
}
|
||||
switch {
|
||||
case maxByPoints == planned && maxByStock == planned:
|
||||
return "points_and_stock"
|
||||
case maxByPoints == planned:
|
||||
return "points"
|
||||
case maxByStock == planned:
|
||||
return "upstream_stock"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// CheckStockAndAutoPurchase 水位检查与自动采购触发
|
||||
func (s *RockService) CheckStockAndAutoPurchase(ctx context.Context) {
|
||||
cfg, err := s.GetConfig(ctx)
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// rockTraceKey 用于把采购追踪器挂到 context 上。
|
||||
// 采用 context 传递而不是客户端全局字段,避免并发采购时互相串数据。
|
||||
type rockTraceKey struct{}
|
||||
|
||||
// RockTrace 收集一次采购流程中所有上游 HTTP 调用的原始请求与响应。
|
||||
// OnStage 在客户端每次请求结束后同步调用,由采购日志记录器落库。
|
||||
type RockTrace struct {
|
||||
mu sync.Mutex
|
||||
OnStage func(stage, method, url string, reqBody []byte, status int, respBody []byte, dur time.Duration, err error)
|
||||
}
|
||||
|
||||
// WithRockTrace 返回带追踪器的 context;trace 为 nil 时原样返回。
|
||||
func WithRockTrace(ctx context.Context, trace *RockTrace) context.Context {
|
||||
if trace == nil {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, rockTraceKey{}, trace)
|
||||
}
|
||||
|
||||
func traceFromContext(ctx context.Context) *RockTrace {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
trace, _ := ctx.Value(rockTraceKey{}).(*RockTrace)
|
||||
return trace
|
||||
}
|
||||
|
||||
// traceRockRequest 记录一次上游调用;没有追踪器时是空操作。
|
||||
func traceRockRequest(ctx context.Context, stage, method, url string, reqBody []byte, status int, respBody []byte, dur time.Duration, err error) {
|
||||
trace := traceFromContext(ctx)
|
||||
if trace == nil || trace.OnStage == nil {
|
||||
return
|
||||
}
|
||||
trace.mu.Lock()
|
||||
handler := trace.OnStage
|
||||
trace.mu.Unlock()
|
||||
handler(stage, method, url, reqBody, status, respBody, dur, err)
|
||||
}
|
||||
@@ -660,6 +660,56 @@ export interface RockTokenItem {
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface RockPurchaseResult {
|
||||
purchased_count: number;
|
||||
requested_count: number;
|
||||
planned_count: number;
|
||||
limit_reason: string;
|
||||
log_id: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface RockPurchaseLog {
|
||||
id: number;
|
||||
trigger_type: "auto" | "manual";
|
||||
status: "running" | "success" | "partial" | "failed" | "skipped";
|
||||
requested_count: number;
|
||||
planned_count: number;
|
||||
claimed_count: number;
|
||||
imported_count: number;
|
||||
upstream_available: number;
|
||||
points_before: number;
|
||||
unit_cost: number;
|
||||
max_by_points: number;
|
||||
max_by_stock: number;
|
||||
limit_reason: string;
|
||||
points_source: string;
|
||||
stage: string;
|
||||
error_message: string;
|
||||
package_id?: number;
|
||||
share_code: string;
|
||||
duration_ms: number;
|
||||
started_at: string;
|
||||
finished_at?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface RockPurchaseStage {
|
||||
id: number;
|
||||
log_id: number;
|
||||
seq: number;
|
||||
stage: string;
|
||||
method: string;
|
||||
url: string;
|
||||
request_body: string;
|
||||
http_status: number;
|
||||
response_body: string;
|
||||
truncated: boolean;
|
||||
duration_ms: number;
|
||||
error_message: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const rockApi = {
|
||||
getConfig: () =>
|
||||
request.get("/platform/rock/config").then((r) => r.data.data as RockConfig),
|
||||
@@ -678,7 +728,25 @@ export const rockApi = {
|
||||
.post("/platform/rock/dashboard/refresh")
|
||||
.then((r) => r.data.data as RockDashboard),
|
||||
purchaseTokens: (count = 50) =>
|
||||
request.post("/platform/rock/purchase", { count }).then((r) => r.data.data),
|
||||
request
|
||||
.post("/platform/rock/purchase", { count })
|
||||
.then((r) => r.data.data as RockPurchaseResult),
|
||||
listPurchaseLogs: (params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
trigger_type?: string;
|
||||
status?: string;
|
||||
}) =>
|
||||
request
|
||||
.get("/platform/rock/purchase-logs", { params })
|
||||
.then((r) => r.data.data as { list: RockPurchaseLog[]; total: number }),
|
||||
getPurchaseLog: (id: number) =>
|
||||
request
|
||||
.get(`/platform/rock/purchase-logs/${id}`)
|
||||
.then(
|
||||
(r) =>
|
||||
r.data.data as { log: RockPurchaseLog; stages: RockPurchaseStage[] },
|
||||
),
|
||||
listTokens: (params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Alert, Button, Form, Modal, Space, message } from 'antd'
|
||||
import { ReloadOutlined, ShoppingCartOutlined } from '@ant-design/icons'
|
||||
import { HistoryOutlined, ReloadOutlined, ShoppingCartOutlined } from '@ant-design/icons'
|
||||
import { rockApi, type RockConfig, type RockStats, type RockTokenItem } from '../api'
|
||||
import { PageHeader } from '../components/PageHeader'
|
||||
import { RockMetricCards } from './platformRock/RockMetricCards'
|
||||
@@ -8,6 +8,7 @@ import { RockStrategyCard } from './platformRock/RockStrategyCard'
|
||||
import { RockTokenFilterBar } from './platformRock/RockTokenFilterBar'
|
||||
import { RockTokenTable } from './platformRock/RockTokenTable'
|
||||
import { PurchaseTokenModal } from './platformRock/PurchaseTokenModal'
|
||||
import { PurchaseLogDrawer } from './platformRock/PurchaseLogDrawer'
|
||||
import { UidEditModal } from './platformRock/UidEditModal'
|
||||
import { VoucherPreviewModal } from './platformRock/VoucherPreviewModal'
|
||||
import type { UidEditState } from './platformRock/types'
|
||||
@@ -33,6 +34,7 @@ export default function PlatformRock() {
|
||||
const [purchasing, setPurchasing] = useState(false)
|
||||
const [purchaseModalOpen, setPurchaseModalOpen] = useState(false)
|
||||
const [purchaseCount, setPurchaseCount] = useState(50)
|
||||
const [purchaseLogOpen, setPurchaseLogOpen] = useState(false)
|
||||
const [rawDataRecord, setRawDataRecord] = useState<RockTokenItem | null>(null)
|
||||
|
||||
// 凭证图片操作状态
|
||||
@@ -209,7 +211,11 @@ export default function PlatformRock() {
|
||||
setPurchasing(true)
|
||||
try {
|
||||
const res = await rockApi.purchaseTokens(count)
|
||||
message.success(res.message || `成功采购 ${count} 个通用黑色 Token!`)
|
||||
if (res.limit_reason) {
|
||||
message.warning(res.message || `上游货源不足,已按 ${res.planned_count} 个采购`)
|
||||
} else {
|
||||
message.success(res.message || `成功采购 ${count} 个通用黑色 Token!`)
|
||||
}
|
||||
setPurchaseModalOpen(false)
|
||||
loadData()
|
||||
loadTokens()
|
||||
@@ -293,6 +299,9 @@ export default function PlatformRock() {
|
||||
<Button icon={<ReloadOutlined />} onClick={loadData} loading={loadingStats}>
|
||||
刷新状态
|
||||
</Button>
|
||||
<Button icon={<HistoryOutlined />} onClick={() => setPurchaseLogOpen(true)}>
|
||||
采购日志
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
@@ -426,6 +435,8 @@ export default function PlatformRock() {
|
||||
previewVoucherUrl={previewVoucherUrl}
|
||||
onClose={() => setPreviewVoucherOpen(false)}
|
||||
/>
|
||||
|
||||
<PurchaseLogDrawer open={purchaseLogOpen} onClose={() => setPurchaseLogOpen(false)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { CSSProperties } from 'react'
|
||||
import { Collapse, Drawer, Select, Space, Table, Tag, Typography, message } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { rockApi, type RockPurchaseLog, type RockPurchaseStage } from '../../api'
|
||||
import { formatDateTime } from '../../utils/time'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
interface PurchaseLogDrawerProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const statusMeta: Record<string, { color: string; text: string }> = {
|
||||
running: { color: 'processing', text: '进行中' },
|
||||
success: { color: 'success', text: '成功' },
|
||||
partial: { color: 'warning', text: '部分成交' },
|
||||
failed: { color: 'error', text: '失败' },
|
||||
skipped: { color: 'default', text: '未采购' },
|
||||
}
|
||||
|
||||
const limitText: Record<string, string> = {
|
||||
points: '积分不足',
|
||||
upstream_stock: '上游货源不足',
|
||||
points_and_stock: '积分与货源不足',
|
||||
}
|
||||
|
||||
const stageText: Record<string, string> = {
|
||||
me: '查询积分',
|
||||
dashboard: '查询库存/单价',
|
||||
claim: '提取 Token',
|
||||
package: '创建打包',
|
||||
detail: '拉取包详情',
|
||||
import: '导入本地',
|
||||
}
|
||||
|
||||
function prettyJSON(raw: string): string {
|
||||
if (!raw) return ''
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(raw), null, 2)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
export function PurchaseLogDrawer({ open, onClose }: PurchaseLogDrawerProps) {
|
||||
const [logs, setLogs] = useState<RockPurchaseLog[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [triggerType, setTriggerType] = useState('')
|
||||
const [status, setStatus] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [stageMap, setStageMap] = useState<Record<number, RockPurchaseStage[]>>({})
|
||||
const [stageLoading, setStageLoading] = useState<Record<number, boolean>>({})
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await rockApi.listPurchaseLogs({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
trigger_type: triggerType || undefined,
|
||||
status: status || undefined,
|
||||
})
|
||||
setLogs(res.list || [])
|
||||
setTotal(res.total || 0)
|
||||
} catch (e) {
|
||||
message.error('加载采购日志失败: ' + (e instanceof Error ? e.message : '未知错误'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, pageSize, triggerType, status])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
load()
|
||||
}
|
||||
}, [open, load])
|
||||
|
||||
const loadStages = async (id: number) => {
|
||||
if (stageMap[id] || stageLoading[id]) return
|
||||
setStageLoading((prev) => ({ ...prev, [id]: true }))
|
||||
try {
|
||||
const res = await rockApi.getPurchaseLog(id)
|
||||
setStageMap((prev) => ({ ...prev, [id]: res.stages || [] }))
|
||||
} catch (e) {
|
||||
message.error('加载阶段详情失败: ' + (e instanceof Error ? e.message : '未知错误'))
|
||||
} finally {
|
||||
setStageLoading((prev) => ({ ...prev, [id]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<RockPurchaseLog> = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 70, align: 'right' },
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'started_at',
|
||||
width: 175,
|
||||
render: (value?: string) => (value ? formatDateTime(value) : '-'),
|
||||
},
|
||||
{
|
||||
title: '触发方式',
|
||||
dataIndex: 'trigger_type',
|
||||
width: 90,
|
||||
render: (value: string) =>
|
||||
value === 'auto' ? <Tag color="blue">自动补货</Tag> : <Tag>手动采购</Tag>,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (value: string) => {
|
||||
const meta = statusMeta[value] || { color: 'default', text: value }
|
||||
return <Tag color={meta.color}>{meta.text}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '计划 → 实际',
|
||||
width: 110,
|
||||
render: (_, record) => (
|
||||
<Text style={{ fontFamily: 'monospace' }}>
|
||||
{record.requested_count} → {record.planned_count}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '入库 / 提取',
|
||||
width: 105,
|
||||
render: (_, record) => (
|
||||
<Text style={{ fontFamily: 'monospace' }}>
|
||||
{record.imported_count} / {record.claimed_count}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '积分 / 单价',
|
||||
width: 120,
|
||||
render: (_, record) => (
|
||||
<Text style={{ fontFamily: 'monospace', fontSize: 12 }}>
|
||||
{record.points_before.toFixed(2)} / {record.unit_cost.toFixed(2)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '上游库存',
|
||||
dataIndex: 'upstream_available',
|
||||
width: 85,
|
||||
align: 'right',
|
||||
},
|
||||
{
|
||||
title: '限制原因',
|
||||
dataIndex: 'limit_reason',
|
||||
width: 120,
|
||||
render: (value: string) =>
|
||||
value ? <Tag color="orange">{limitText[value] || value}</Tag> : <Text type="secondary">-</Text>,
|
||||
},
|
||||
{
|
||||
title: '耗时',
|
||||
dataIndex: 'duration_ms',
|
||||
width: 80,
|
||||
align: 'right',
|
||||
render: (value: number) => `${(value / 1000).toFixed(1)}s`,
|
||||
},
|
||||
{
|
||||
title: '错误',
|
||||
dataIndex: 'error_message',
|
||||
ellipsis: true,
|
||||
render: (value: string) =>
|
||||
value ? (
|
||||
<Text type="danger" style={{ fontSize: 12 }}>
|
||||
{value}
|
||||
</Text>
|
||||
) : (
|
||||
<Text type="secondary">-</Text>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title="采购日志"
|
||||
width={1180}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Select
|
||||
value={triggerType}
|
||||
style={{ width: 140 }}
|
||||
onChange={(value) => {
|
||||
setTriggerType(value)
|
||||
setPage(1)
|
||||
}}
|
||||
options={[
|
||||
{ value: '', label: '全部触发方式' },
|
||||
{ value: 'auto', label: '自动补货' },
|
||||
{ value: 'manual', label: '手动采购' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={status}
|
||||
style={{ width: 140 }}
|
||||
onChange={(value) => {
|
||||
setStatus(value)
|
||||
setPage(1)
|
||||
}}
|
||||
options={[
|
||||
{ value: '', label: '全部状态' },
|
||||
{ value: 'success', label: '成功' },
|
||||
{ value: 'partial', label: '部分成交' },
|
||||
{ value: 'failed', label: '失败' },
|
||||
{ value: 'skipped', label: '未采购' },
|
||||
]}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
共 {total} 条记录,点击行可展开原始响应
|
||||
</Text>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
columns={columns}
|
||||
dataSource={logs}
|
||||
loading={loading}
|
||||
scroll={{ x: 1320 }}
|
||||
expandable={{
|
||||
expandedRowRender: (record) => {
|
||||
if (stageLoading[record.id]) {
|
||||
return <Text type="secondary">阶段详情加载中…</Text>
|
||||
}
|
||||
const stages = stageMap[record.id]
|
||||
if (!stages || stages.length === 0) {
|
||||
return <Text type="secondary">暂无阶段数据</Text>
|
||||
}
|
||||
return (
|
||||
<Collapse
|
||||
size="small"
|
||||
items={stages.map((stage) => ({
|
||||
key: String(stage.id),
|
||||
label: (
|
||||
<Space size={8} wrap>
|
||||
<Tag color="geekblue">{stageText[stage.stage] || stage.stage}</Tag>
|
||||
<Text style={{ fontFamily: 'monospace', fontSize: 12 }}>
|
||||
{stage.method} {stage.http_status || '-'}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{stage.duration_ms}ms
|
||||
</Text>
|
||||
{stage.truncated ? <Tag color="orange">已截断</Tag> : null}
|
||||
{stage.error_message ? (
|
||||
<Text type="danger" style={{ fontSize: 12 }}>
|
||||
{stage.error_message}
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
children: (
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12, wordBreak: 'break-all' }}>
|
||||
{stage.url}
|
||||
</Text>
|
||||
{stage.request_body ? (
|
||||
<>
|
||||
<div style={{ marginTop: 8, fontWeight: 600, fontSize: 12 }}>请求体</div>
|
||||
<pre style={preStyle}>{prettyJSON(stage.request_body)}</pre>
|
||||
</>
|
||||
) : null}
|
||||
<div style={{ marginTop: 8, fontWeight: 600, fontSize: 12 }}>原始响应</div>
|
||||
<pre style={preStyle}>{prettyJSON(stage.response_body) || '(空)'}</pre>
|
||||
</div>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
)
|
||||
},
|
||||
onExpand: (expanded, record) => {
|
||||
if (expanded) {
|
||||
loadStages(record.id)
|
||||
}
|
||||
},
|
||||
}}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['10', '20', '50', '100'],
|
||||
showTotal: (count) => `共 ${count} 条采购记录`,
|
||||
onChange: (nextPage, nextSize) => {
|
||||
setPage(nextPage)
|
||||
setPageSize(nextSize)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
const preStyle: CSSProperties = {
|
||||
margin: '6px 0 0',
|
||||
padding: 10,
|
||||
background: '#0f172a',
|
||||
color: '#e2e8f0',
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
maxHeight: 320,
|
||||
overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
}
|
||||
Reference in New Issue
Block a user