feat:未绑定token导入

This commit is contained in:
2026-10-31 14:03:04 +08:00
parent 8f30e6fe57
commit c50f471f64
8 changed files with 768 additions and 1 deletions
+17
View File
@@ -6,6 +6,7 @@ import (
"strconv"
"strings"
"affiliate_dash/internal/middleware"
"affiliate_dash/internal/pkg/response"
"affiliate_dash/internal/service"
@@ -165,6 +166,22 @@ func (h *RockHandler) GetPurchaseLog(c *gin.Context) {
response.OK(c, gin.H{"log": entry, "stages": stages})
}
type importAllPackagesReq struct {
MaxPackages int `json:"max_packages"`
}
// ImportAllUpstreamPackages 全量导入上游包:已导入的跳过,只导入未绑定的 Token
func (h *RockHandler) ImportAllUpstreamPackages(c *gin.Context) {
var req importAllPackagesReq
_ = c.ShouldBindJSON(&req)
result, err := h.rockSvc.ImportAllUpstreamPackages(c.Request.Context(), req.MaxPackages, middleware.GetUserID(c))
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, result)
}
// ListTokens 分页获取 Token 库存列表
func (h *RockHandler) ListTokens(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
+1
View File
@@ -291,6 +291,7 @@ func Setup(h *Handlers) *gin.Engine {
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.POST("/platform/rock/upstream-packages/import-all", h.Rock.ImportAllUpstreamPackages)
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)
+57
View File
@@ -371,6 +371,63 @@ func (r *RockPackageCreateResponse) ResolvePackage() (int64, string) {
return 0, ""
}
// RockPackageListResponse 是 GET /api/user/packages 的返回结构。
type RockPackageListResponse struct {
Packages []RockPackage `json:"packages"`
Pagination struct {
Page int `json:"page"`
PageSize int `json:"pageSize"`
Total int `json:"total"`
TotalPages int `json:"totalPages"`
Offset int `json:"offset"`
} `json:"pagination"`
Error string `json:"error,omitempty"`
}
// ListPackages 分页查询上游打包记录(需 Bearer Token)。
func (c *RockClient) ListPackages(ctx context.Context, bearerToken string, page, pageSize int) (*RockPackageListResponse, error) {
if page <= 0 {
page = 1
}
if pageSize <= 0 || pageSize > 100 {
pageSize = 20
}
reqURL := fmt.Sprintf("%s/api/user/packages?page=%d&pageSize=%d", c.BaseURL(), page, pageSize)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", strings.TrimSpace(bearerToken)))
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Origin", c.BaseURL())
req.Header.Set("Referer", c.BaseURL()+"/")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("请求 0666 packages 列表失败: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取 packages 列表响应失败: %w", err)
}
if resp.StatusCode == http.StatusUnauthorized {
return nil, fmt.Errorf("上游 Bearer Token 已失效或未授权 (401)")
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("上游 packages 列表接口返回错误码 %d: %s", resp.StatusCode, string(body))
}
var res RockPackageListResponse
if err := json.Unmarshal(body, &res); err != nil {
return nil, fmt.Errorf("解析 packages 列表响应失败: %w", err)
}
if res.Error != "" {
return nil, fmt.Errorf("上游 packages 列表业务错误: %s", res.Error)
}
return &res, nil
}
// CreatePackage 把一批已提取 Token 打包成公开分享包(需 Bearer Token)。
// 注意:上游未返回 share_code 时不会在这里报错,由调用方决定兜底策略。
func (c *RockClient) CreatePackage(ctx context.Context, bearerToken string, ids []string, note string) (*RockPackageCreateResponse, error) {
@@ -0,0 +1,221 @@
package service
import (
"context"
"errors"
"fmt"
"log"
"strconv"
"strings"
"affiliate_dash/internal/model"
)
// PackageImportResult 是单个包的导入结果。
type PackageImportResult struct {
PackageID int64 `json:"package_id"`
ShareCode string `json:"share_code"`
Inserted int `json:"inserted"`
SkippedBound int `json:"skipped_bound"`
Error string `json:"error,omitempty"`
}
// ImportAllResult 是全量导入的汇总结果。
type ImportAllResult struct {
Scanned int `json:"scanned"`
Skipped int `json:"skipped"` // 本地已有该包 Token,整包跳过
Empty int `json:"empty"` // 包内没有未绑定的 Token
Imported int `json:"imported"` // 成功导入了 Token 的包
Failed int `json:"failed"` // 拉详情或写库失败
TokensImported int `json:"tokens_imported"`
TokensSkippedBound int `json:"tokens_skipped_bound"`
Remaining int `json:"remaining"` // 超过单次上限未处理的包
Results []PackageImportResult `json:"results"`
}
// isUnboundRockToken 判断上游 Token 是否从未绑定过。
// 规则(与业务确认):bind_changed_at 与 bind_change_type 同时为空才算未绑定。
func isUnboundRockToken(token RockPackageToken) bool {
if strings.TrimSpace(token.BindChangedAt) != "" {
return false
}
if strings.TrimSpace(token.BindChangeType) != "" {
return false
}
return true
}
// filterUnboundTokens 拆出未绑定 Token,并返回被跳过的已绑定数量。
func filterUnboundTokens(tokens []RockPackageToken) ([]RockPackageToken, int) {
kept := make([]RockPackageToken, 0, len(tokens))
skipped := 0
for _, token := range tokens {
if isUnboundRockToken(token) {
kept = append(kept, token)
continue
}
skipped++
}
return kept, skipped
}
// ImportAllUpstreamPackages 全量扫描上游包:已导入的跳过,只导入包内未绑定的 Token,
// 且导入后统一标记为 available。
func (s *RockService) ImportAllUpstreamPackages(ctx context.Context, maxPackages int, actorUserID uint) (*ImportAllResult, error) {
if maxPackages <= 0 {
maxPackages = 50
}
if maxPackages > 200 {
maxPackages = 200
}
cfg, err := s.GetConfig(ctx)
if err != nil {
return nil, err
}
if strings.TrimSpace(cfg.BearerToken) == "" {
return nil, errors.New("请先配置 0666.pro Bearer Token")
}
// 1. 分页拉取全部上游包(最多 20 页,防止异常分页导致死循环)。
all := make([]RockPackage, 0, 64)
for page := 1; page <= 20; page++ {
resp, listErr := s.client.ListPackages(ctx, cfg.BearerToken, page, 100)
if listErr != nil {
return nil, listErr
}
all = append(all, resp.Packages...)
if resp.Pagination.TotalPages > 0 {
if page >= resp.Pagination.TotalPages {
break
}
} else if len(resp.Packages) == 0 {
break
}
if len(resp.Packages) == 0 {
break
}
}
result := &ImportAllResult{Scanned: len(all)}
if len(all) == 0 {
return result, nil
}
// 2. 查询本地已导入的 package_id 集合(以本地实际存在的 Token 为准)。
ids := make([]int64, 0, len(all))
for _, pkg := range all {
if pkg.ID > 0 {
ids = append(ids, pkg.ID)
}
}
importedIDs := make([]int64, 0, len(ids))
if len(ids) > 0 {
if err := s.db.WithContext(ctx).Model(&model.RockToken{}).
Where("package_id IN ?", ids).
Group("package_id").
Pluck("package_id", &importedIDs).Error; err != nil {
log.Printf("[RockService] 查询本地已导入包失败: %v", err)
}
}
importedSet := make(map[int64]struct{}, len(importedIDs))
for _, id := range importedIDs {
importedSet[id] = struct{}{}
}
// 3. 过滤出待处理的包,并按上限截断。
pending := make([]RockPackage, 0, len(all))
for _, pkg := range all {
if _, ok := importedSet[pkg.ID]; ok {
result.Skipped++
continue
}
pending = append(pending, pkg)
}
if len(pending) > maxPackages {
result.Remaining = len(pending) - maxPackages
pending = pending[:maxPackages]
}
// 4. 串行导入,单包失败不影响后续。
for _, pkg := range pending {
item := s.importAllOnePackage(ctx, pkg, actorUserID)
result.Results = append(result.Results, item)
switch {
case item.Error != "":
result.Failed++
case item.Inserted == 0:
result.Empty++
default:
result.Imported++
result.TokensImported += item.Inserted
}
result.TokensSkippedBound += item.SkippedBound
}
log.Printf("[RockService] 全量导入完成: %s", formatImportAllSummary(result))
return result, nil
}
func (s *RockService) importAllOnePackage(ctx context.Context, pkg RockPackage, actorUserID uint) PackageImportResult {
result := PackageImportResult{PackageID: pkg.ID, ShareCode: pkg.ShareCode}
detail, err := s.client.GetPackageDetail(ctx, pkg.ShareCode)
if err != nil {
result.Error = "拉取包详情失败: " + err.Error()
return result
}
if detail == nil || detail.Package.ID == 0 {
result.Error = "上游未返回 package.id"
return result
}
result.PackageID = detail.Package.ID
kept, skippedBound := filterUnboundTokens(detail.Tokens)
result.SkippedBound = skippedBound
if len(kept) == 0 {
return result
}
// 只把未绑定 Token 交给导入器,并强制写成 available。
filtered := *detail
filtered.Tokens = kept
forceIDs := make(map[string]struct{}, len(kept))
for _, token := range kept {
if token.ID > 0 {
forceIDs[strconv.FormatInt(token.ID, 10)] = struct{}{}
}
}
inserted, updated, skipped, err := s.importPackageDetail(ctx, &filtered, forceIDs)
if err != nil {
result.Error = "导入失败: " + err.Error()
return result
}
result.Inserted = inserted
if inserted > 0 {
actor := actorUserID
metadata := map[string]interface{}{
"package_id": detail.Package.ID,
"share_code": pkg.ShareCode,
"mode": "import_all",
"inserted": inserted,
"updated": updated,
"skipped": skipped,
"skipped_bound": skippedBound,
}
if auditErr := writeAudit(s.db.WithContext(ctx), nil, &actor, nil, "rock_package.import_all", "rock_package", pkg.ShareCode, metadata); auditErr != nil {
log.Printf("[RockService] 写入全量导入审计失败 share_code=%s: %v", pkg.ShareCode, auditErr)
}
}
return result
}
// formatImportAllSummary 生成便于日志排查的一行摘要。
func formatImportAllSummary(result *ImportAllResult) string {
if result == nil {
return ""
}
return fmt.Sprintf("扫描=%d 跳过=%d 空包=%d 导入=%d 失败=%d Token=%d 已绑定跳过=%d 剩余=%d",
result.Scanned, result.Skipped, result.Empty, result.Imported, result.Failed,
result.TokensImported, result.TokensSkippedBound, result.Remaining)
}
@@ -0,0 +1,249 @@
package service
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"affiliate_dash/internal/model"
)
func newImportAllMock(t *testing.T, packages []map[string]interface{}, details map[string]map[string]interface{}) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/api/user/packages":
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"packages": packages,
"pagination": map[string]interface{}{
"page": 1, "pageSize": 100, "total": len(packages), "totalPages": 1,
},
})
case strings.HasPrefix(r.URL.Path, "/api/package/"):
code := strings.TrimPrefix(r.URL.Path, "/api/package/")
detail, ok := details[code]
if !ok {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":"package not found"}`))
return
}
_ = json.NewEncoder(w).Encode(detail)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
}
func TestIsUnboundRockToken(t *testing.T) {
cases := []struct {
name string
token RockPackageToken
expect bool
}{
{"两个绑定字段都为空", RockPackageToken{BindChangedAt: "", BindChangeType: ""}, true},
{"有 bind_changed_at", RockPackageToken{BindChangedAt: "2026-10-30 16:02:19", BindChangeType: ""}, false},
{"bind 类型", RockPackageToken{BindChangedAt: "", BindChangeType: "bind"}, false},
{"rebind 类型", RockPackageToken{BindChangedAt: "", BindChangeType: "rebind"}, false},
{"两者都有", RockPackageToken{BindChangedAt: "2026-10-30 16:02:19", BindChangeType: "rebind"}, false},
}
for _, tc := range cases {
if got := isUnboundRockToken(tc.token); got != tc.expect {
t.Fatalf("%s: isUnboundRockToken=%v, want %v", tc.name, got, tc.expect)
}
}
}
func TestImportAllUpstreamPackages_SkipsImported(t *testing.T) {
db := newRockTestDB(t)
packages := []map[string]interface{}{
{"id": 100, "token_count": 1, "share_code": "share-100", "status": "active", "created_at": "2026-10-20 10:00:00", "note": "a"},
{"id": 101, "token_count": 1, "share_code": "share-101", "status": "active", "created_at": "2026-10-21 10:00:00", "note": "b"},
}
details := map[string]map[string]interface{}{
"share-101": {
"package": map[string]interface{}{"id": 101, "share_code": "share-101", "token_count": 1, "status": "active"},
"tokens": []map[string]interface{}{
{"id": 95001, "token": "tk-95001", "color": "black", "region": "Q/V可用", "status": "using", "redeem_code": "rc-95001"},
},
},
}
server := newImportAllMock(t, packages, details)
defer server.Close()
pid := int64(100)
if err := db.Create(&model.RockToken{
UpstreamTokenID: "90001",
Token: "tk-90001",
RedeemCode: "rc-90001",
Status: model.RockStatusAvailable,
PackageID: &pid,
}).Error; err != nil {
t.Fatalf("写入本地 Token 失败: %v", err)
}
svc := NewRockService(db, NewRockClient(server.URL))
setRockTestBearer(t, svc, "bearer-test")
result, err := svc.ImportAllUpstreamPackages(context.Background(), 50, 1)
if err != nil {
t.Fatalf("全量导入失败: %v", err)
}
if result.Scanned != 2 || result.Skipped != 1 || result.Imported != 1 {
t.Fatalf("汇总不符: %+v", result)
}
if result.TokensImported != 1 {
t.Fatalf("应导入 1 个 Token,实际 %d", result.TokensImported)
}
var token model.RockToken
if err := db.Where("upstream_token_id = ?", "95001").First(&token).Error; err != nil {
t.Fatalf("查询导入 Token 失败: %v", err)
}
if token.Status != model.RockStatusAvailable {
t.Fatalf("导入后应为 available,实际 %s", token.Status)
}
}
func TestImportAllUpstreamPackages_FiltersBoundTokens(t *testing.T) {
db := newRockTestDB(t)
packages := []map[string]interface{}{
{"id": 300, "token_count": 3, "share_code": "share-300", "status": "active", "created_at": "2026-10-22 10:00:00", "note": "c"},
}
details := map[string]map[string]interface{}{
"share-300": {
"package": map[string]interface{}{"id": 300, "share_code": "share-300", "token_count": 3, "status": "active"},
"tokens": []map[string]interface{}{
{"id": 96001, "token": "tk-96001", "color": "black", "region": "Q/V可用", "status": "using", "redeem_code": "rc-96001"},
{"id": 96002, "token": "tk-96002", "color": "black", "region": "Q/V可用", "status": "bound", "redeem_code": "rc-96002", "bind_changed_at": "2026-10-30 16:02:19", "bind_change_type": "bind", "bound_account": "角色A", "bound_account_id": "111"},
{"id": 96003, "token": "tk-96003", "color": "black", "region": "Q/V可用", "status": "redeemed", "redeem_code": "rc-96003", "bind_changed_at": "2026-10-31 12:45:06", "bind_change_type": "rebind", "bound_account": "角色B", "bound_account_id": "222"},
},
},
}
server := newImportAllMock(t, packages, details)
defer server.Close()
svc := NewRockService(db, NewRockClient(server.URL))
setRockTestBearer(t, svc, "bearer-test")
result, err := svc.ImportAllUpstreamPackages(context.Background(), 50, 1)
if err != nil {
t.Fatalf("全量导入失败: %v", err)
}
if result.Imported != 1 || result.TokensImported != 1 {
t.Fatalf("应只导入 1 个未绑定 Token,实际 %+v", result)
}
if result.TokensSkippedBound != 2 {
t.Fatalf("应跳过 2 个已绑定 Token,实际 %d", result.TokensSkippedBound)
}
var count int64
db.Model(&model.RockToken{}).Where("upstream_token_id IN ?", []string{"96002", "96003"}).Count(&count)
if count != 0 {
t.Fatalf("已绑定 Token 不应入库,实际 %d 条", count)
}
var token model.RockToken
if err := db.Where("upstream_token_id = ?", "96001").First(&token).Error; err != nil {
t.Fatalf("查询导入 Token 失败: %v", err)
}
if token.Status != model.RockStatusAvailable || token.UsingAt != nil || token.BoundAt != nil {
t.Fatalf("导入结果不符: status=%s usingAt=%v boundAt=%v", token.Status, token.UsingAt, token.BoundAt)
}
}
func TestImportAllUpstreamPackages_EmptyWhenAllBound(t *testing.T) {
db := newRockTestDB(t)
packages := []map[string]interface{}{
{"id": 310, "token_count": 1, "share_code": "share-310", "status": "active", "created_at": "2026-10-22 10:00:00", "note": "d"},
}
details := map[string]map[string]interface{}{
"share-310": {
"package": map[string]interface{}{"id": 310, "share_code": "share-310", "token_count": 1, "status": "active"},
"tokens": []map[string]interface{}{
{"id": 97001, "token": "tk-97001", "color": "black", "region": "Q/V可用", "status": "bound", "redeem_code": "rc-97001", "bind_changed_at": "2026-10-30 16:02:19", "bind_change_type": "bind"},
},
},
}
server := newImportAllMock(t, packages, details)
defer server.Close()
svc := NewRockService(db, NewRockClient(server.URL))
setRockTestBearer(t, svc, "bearer-test")
result, err := svc.ImportAllUpstreamPackages(context.Background(), 50, 1)
if err != nil {
t.Fatalf("全量导入失败: %v", err)
}
if result.Empty != 1 || result.Imported != 0 {
t.Fatalf("全部已绑定时应记为 empty,实际 %+v", result)
}
}
func TestImportAllUpstreamPackages_PartialFailure(t *testing.T) {
db := newRockTestDB(t)
packages := []map[string]interface{}{
{"id": 400, "token_count": 1, "share_code": "share-400", "status": "active", "created_at": "2026-10-23 10:00:00", "note": "e"},
{"id": 401, "token_count": 1, "share_code": "share-401", "status": "active", "created_at": "2026-10-24 10:00:00", "note": "f"},
}
details := map[string]map[string]interface{}{
"share-401": {
"package": map[string]interface{}{"id": 401, "share_code": "share-401", "token_count": 1, "status": "active"},
"tokens": []map[string]interface{}{
{"id": 98001, "token": "tk-98001", "color": "black", "region": "Q/V可用", "status": "using", "redeem_code": "rc-98001"},
},
},
}
server := newImportAllMock(t, packages, details)
defer server.Close()
svc := NewRockService(db, NewRockClient(server.URL))
setRockTestBearer(t, svc, "bearer-test")
result, err := svc.ImportAllUpstreamPackages(context.Background(), 50, 1)
if err != nil {
t.Fatalf("全量导入调用失败: %v", err)
}
if result.Failed != 1 || result.Imported != 1 {
t.Fatalf("应 1 失败 1 成功,实际 %+v", result)
}
if len(result.Results) != 2 || result.Results[0].Error == "" {
t.Fatalf("失败包应带错误信息: %+v", result.Results)
}
}
func TestImportAllUpstreamPackages_MaxPackages(t *testing.T) {
db := newRockTestDB(t)
packages := []map[string]interface{}{
{"id": 500, "token_count": 1, "share_code": "share-500", "status": "active", "created_at": "2026-10-25 10:00:00", "note": "g"},
{"id": 501, "token_count": 1, "share_code": "share-501", "status": "active", "created_at": "2026-10-26 10:00:00", "note": "h"},
{"id": 502, "token_count": 1, "share_code": "share-502", "status": "active", "created_at": "2026-10-27 10:00:00", "note": "i"},
}
details := map[string]map[string]interface{}{
"share-500": {
"package": map[string]interface{}{"id": 500, "share_code": "share-500", "token_count": 1, "status": "active"},
"tokens": []map[string]interface{}{
{"id": 99001, "token": "tk-99001", "color": "black", "region": "Q/V可用", "status": "using", "redeem_code": "rc-99001"},
},
},
}
server := newImportAllMock(t, packages, details)
defer server.Close()
svc := NewRockService(db, NewRockClient(server.URL))
setRockTestBearer(t, svc, "bearer-test")
result, err := svc.ImportAllUpstreamPackages(context.Background(), 1, 1)
if err != nil {
t.Fatalf("全量导入失败: %v", err)
}
if result.Remaining != 2 {
t.Fatalf("上限 1 时应剩余 2 个包,实际 %d", result.Remaining)
}
if len(result.Results) != 1 {
t.Fatalf("应只处理 1 个包,实际 %d", len(result.Results))
}
}
+26
View File
@@ -710,6 +710,26 @@ export interface RockPurchaseStage {
created_at: string;
}
export interface PackageImportItem {
package_id: number;
share_code: string;
inserted: number;
skipped_bound: number;
error?: string;
}
export interface ImportAllResult {
scanned: number;
skipped: number;
empty: number;
imported: number;
failed: number;
tokens_imported: number;
tokens_skipped_bound: number;
remaining: number;
results: PackageImportItem[];
}
export const rockApi = {
getConfig: () =>
request.get("/platform/rock/config").then((r) => r.data.data as RockConfig),
@@ -747,6 +767,12 @@ export const rockApi = {
(r) =>
r.data.data as { log: RockPurchaseLog; stages: RockPurchaseStage[] },
),
importAllUpstreamPackages: (maxPackages = 50) =>
request
.post("/platform/rock/upstream-packages/import-all", {
max_packages: maxPackages,
})
.then((r) => r.data.data as ImportAllResult),
listTokens: (params?: {
page?: number;
page_size?: number;
+15 -1
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
import { Alert, Button, Form, Modal, Space, message } from 'antd'
import { HistoryOutlined, ReloadOutlined, ShoppingCartOutlined } from '@ant-design/icons'
import { HistoryOutlined, ImportOutlined, 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'
@@ -9,6 +9,7 @@ import { RockTokenFilterBar } from './platformRock/RockTokenFilterBar'
import { RockTokenTable } from './platformRock/RockTokenTable'
import { PurchaseTokenModal } from './platformRock/PurchaseTokenModal'
import { PurchaseLogDrawer } from './platformRock/PurchaseLogDrawer'
import { ImportAllPackagesModal } from './platformRock/ImportAllPackagesModal'
import { UidEditModal } from './platformRock/UidEditModal'
import { VoucherPreviewModal } from './platformRock/VoucherPreviewModal'
import type { UidEditState } from './platformRock/types'
@@ -35,6 +36,7 @@ export default function PlatformRock() {
const [purchaseModalOpen, setPurchaseModalOpen] = useState(false)
const [purchaseCount, setPurchaseCount] = useState(50)
const [purchaseLogOpen, setPurchaseLogOpen] = useState(false)
const [importPackageOpen, setImportPackageOpen] = useState(false)
const [rawDataRecord, setRawDataRecord] = useState<RockTokenItem | null>(null)
// 凭证图片操作状态
@@ -302,6 +304,9 @@ export default function PlatformRock() {
<Button icon={<HistoryOutlined />} onClick={() => setPurchaseLogOpen(true)}>
采购日志
</Button>
<Button icon={<ImportOutlined />} onClick={() => setImportPackageOpen(true)}>
全量导入
</Button>
</Space>
}
/>
@@ -437,6 +442,15 @@ export default function PlatformRock() {
/>
<PurchaseLogDrawer open={purchaseLogOpen} onClose={() => setPurchaseLogOpen(false)} />
<ImportAllPackagesModal
open={importPackageOpen}
onClose={() => setImportPackageOpen(false)}
onImported={() => {
loadData()
loadTokens()
}}
/>
</div>
)
}
@@ -0,0 +1,182 @@
import { useState } from 'react'
import { Alert, Button, Descriptions, Modal, Space, Table, Tag, Typography, message } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { rockApi, type ImportAllResult, type PackageImportItem } from '../../api'
const { Text } = Typography
interface ImportAllPackagesModalProps {
open: boolean
onClose: () => void
onImported: () => void
}
const MAX_PACKAGES = 50
export function ImportAllPackagesModal({ open, onClose, onImported }: ImportAllPackagesModalProps) {
const [running, setRunning] = useState(false)
const [result, setResult] = useState<ImportAllResult | null>(null)
const handleClose = () => {
if (running) {
return
}
setResult(null)
onClose()
}
const handleStart = async () => {
setRunning(true)
setResult(null)
try {
const res = await rockApi.importAllUpstreamPackages(MAX_PACKAGES)
setResult(res)
if (res.imported > 0 || res.failed > 0) {
onImported()
}
} catch (e) {
message.error('全量导入失败: ' + (e instanceof Error ? e.message : '未知错误'))
} finally {
setRunning(false)
}
}
const columns: ColumnsType<PackageImportItem> = [
{ title: '包 ID', dataIndex: 'package_id', width: 90, align: 'right' },
{
title: 'share_code',
dataIndex: 'share_code',
width: 220,
render: (value: string) => (
<Text style={{ fontFamily: 'monospace', fontSize: 12 }}>{value}</Text>
),
},
{
title: '新增 Token',
dataIndex: 'inserted',
width: 100,
align: 'right',
render: (value: number) => (value > 0 ? <Tag color="success">{value}</Tag> : '-'),
},
{
title: '跳过已绑定',
dataIndex: 'skipped_bound',
width: 100,
align: 'right',
render: (value: number) => (value > 0 ? <Text type="secondary">{value}</Text> : '-'),
},
{
title: '结果',
dataIndex: 'error',
ellipsis: true,
render: (value?: string) =>
value ? (
<Text type="danger" style={{ fontSize: 12 }}>
{value}
</Text>
) : (
<Text type="success">成功</Text>
),
},
]
const detailRows = (result?.results || []).filter(
(item) => item.inserted > 0 || Boolean(item.error),
)
return (
<Modal
title="全量导入上游包"
width={900}
open={open}
onCancel={handleClose}
maskClosable={!running}
closable={!running}
footer={
result
? [
<Button key="close" onClick={handleClose}>
关闭
</Button>,
<Button key="again" type="primary" loading={running} onClick={handleStart}>
再次导入
</Button>,
]
: [
<Button key="cancel" onClick={handleClose} disabled={running}>
取消
</Button>,
<Button key="start" type="primary" loading={running} onClick={handleStart}>
开始导入
</Button>,
]
}
>
{!result ? (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message="导入规则"
description={
<div style={{ fontSize: 13, lineHeight: 1.8 }}>
· 扫描全部上游包,本地已存在该包 Token 的整包跳过
<br />· 只导入<b>未绑定</b>的 Token:<Text code>bind_changed_at</Text> 与{' '}
<Text code>bind_change_type</Text> 均为空
<br />· 导入后统一标记为<b>可用</b>
<br />· 单次最多处理 {MAX_PACKAGES} 个未导入包,剩余的下次再导入
</div>
}
/>
{running ? (
<Text type="secondary">
正在逐个拉取包详情并导入,可能需要 1 分钟,请勿关闭窗口…
</Text>
) : null}
</Space>
) : (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions bordered size="small" column={2}>
<Descriptions.Item label="扫描包数">{result.scanned}</Descriptions.Item>
<Descriptions.Item label="已导入跳过">{result.skipped}</Descriptions.Item>
<Descriptions.Item label="无可导入 Token">{result.empty}</Descriptions.Item>
<Descriptions.Item label="成功导入包数">{result.imported}</Descriptions.Item>
<Descriptions.Item label="新增 Token">{result.tokens_imported}</Descriptions.Item>
<Descriptions.Item label="跳过已绑定 Token">{result.tokens_skipped_bound}</Descriptions.Item>
<Descriptions.Item label="失败包数">{result.failed}</Descriptions.Item>
<Descriptions.Item label="超过上限未处理">{result.remaining}</Descriptions.Item>
</Descriptions>
{result.failed > 0 ? (
<Alert
type="warning"
showIcon
message={`有 ${result.failed} 个包导入失败,详情见下表`}
/>
) : (
<Alert
type="success"
showIcon
message={
result.tokens_imported > 0
? `本次新增 ${result.tokens_imported} 个可用 Token`
: '本次没有可导入的未绑定 Token'
}
/>
)}
{detailRows.length > 0 ? (
<Table
rowKey="package_id"
size="small"
columns={columns}
dataSource={detailRows}
pagination={false}
scroll={{ y: 260 }}
/>
) : null}
</Space>
)}
</Modal>
)
}