feat:未绑定token导入
This commit is contained in:
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user