feat: 接入旧站管理端并完整迁移后台能力

This commit is contained in:
yml
2026-11-12 22:03:01 +08:00
parent e5f923706b
commit a4441bba9a
509 changed files with 140714 additions and 1068 deletions
+1
View File
@@ -46,6 +46,7 @@ var (
ErrPickupShopNameExists = errors.New("pickup shop name already exists")
ErrPickupCancelled = errors.New("pickup is cancelled")
ErrInvalidThirdPartyOrderNo = errors.New("invalid third party order no")
ErrPickupExportTooLarge = errors.New("pickup export too large")
)
type PickupDTO struct {
+256
View File
@@ -0,0 +1,256 @@
package pickup
import (
"bytes"
"context"
"encoding/csv"
"fmt"
"strings"
"time"
"hfb_jysh/backend/internal/auditlog"
"hfb_jysh/backend/internal/timeutil"
"gorm.io/gorm"
)
const maxAdminPickupExportRows = 50000
var adminPickupExportHeader = []string{
"提号编号",
"账号标题",
"区服",
"登录平台",
"上架编号",
"卖家手机号",
"操作人",
"账号来源",
"来源渠道",
"交易渠道",
"第三方单号",
"店铺名称",
"提号备注",
"下单金额(元)",
"押金金额(元)",
"线下利润(元)",
"结算金额(元)",
"提号状态",
"结算状态",
"创建时间",
"完成时间",
"取消时间",
"完成备注",
}
// ListAdminForExport 按后台列表相同条件查询全部匹配提号,不返回账号快照。
func (r *Repository) ListAdminForExport(ctx context.Context, query AdminPickupQuery) ([]PickupDTO, error) {
if r == nil || r.db == nil {
return nil, ErrDependencyUnavailable
}
var rows []pickupRow
db := applyAdminPickupFilters(r.adminPickupExportQuery(ctx), query)
if err := db.Order("p.id DESC").Limit(maxAdminPickupExportRows + 1).Scan(&rows).Error; err != nil {
return nil, err
}
if len(rows) > maxAdminPickupExportRows {
return nil, ErrPickupExportTooLarge
}
items := make([]PickupDTO, 0, len(rows))
for _, row := range rows {
items = append(items, row.toDTO())
}
return items, nil
}
// RecordAdminPickupExport 记录敏感数据导出行为。
func (r *Repository) RecordAdminPickupExport(
ctx context.Context,
adminID uint64,
meta auditlog.Meta,
query AdminPickupQuery,
rowCount int,
) error {
if r == nil || r.db == nil {
return ErrDependencyUnavailable
}
return auditlog.Append(r.db.WithContext(ctx), auditlog.Entry{
ActorType: "admin",
ActorID: adminID,
Action: "pickup_export",
BizType: "admin_pickup",
Meta: meta,
Detail: map[string]any{
"row_count": rowCount,
"keyword": strings.TrimSpace(query.Keyword),
"status": strings.TrimSpace(query.Status),
"shop_name": strings.TrimSpace(query.ShopName),
"account_source": strings.TrimSpace(query.AccountSource),
"offline_settlement_status": strings.TrimSpace(query.OfflineSettlementStatus),
"operator": strings.TrimSpace(query.Operator),
"third_party_order_no": strings.TrimSpace(query.ThirdPartyOrderNo),
"start_time": formatExportTimePointer(query.StartTime),
"end_time": formatExportTimePointer(query.EndTime),
},
})
}
func (r *Repository) adminPickupExportQuery(ctx context.Context) *gorm.DB {
return r.db.WithContext(ctx).Table("admin_pickups AS p").
Select(`p.id, p.pickup_no, p.listing_id, p.account_id, p.owner_id, p.admin_id,
p.operator, p.platform, p.third_party_order_no, p.shop_name, p.raw_shop_name,
p.account_source, p.source_channel, p.settlement_mode,
p.order_amount_cent, p.deposit_amount_cent, p.profit_amount_cent, p.settle_amount_cent,
p.status, p.offline_settlement_status, p.remark, p.complete_remark,
p.created_at, p.completed_at, p.cancelled_at,
l.listing_no, a.title AS account_title, a.server_region, a.login_platform, owner.phone AS owner_phone,
COALESCE((SELECT SUM(fa.profit_delta_cent) FROM admin_pickup_financial_adjustments AS fa WHERE fa.pickup_id = p.id), 0) AS profit_adjustment_cent,
COALESCE((SELECT SUM(fa.settle_delta_cent) FROM admin_pickup_financial_adjustments AS fa WHERE fa.pickup_id = p.id), 0) AS settle_adjustment_cent`).
Joins("JOIN rental_listings AS l ON l.id = p.listing_id").
Joins("JOIN game_accounts AS a ON a.id = p.account_id").
Joins("JOIN users AS owner ON owner.id = p.owner_id")
}
func buildAdminPickupCSV(items []PickupDTO) ([]byte, error) {
var buf bytes.Buffer
buf.WriteString("\xEF\xBB\xBF")
writer := csv.NewWriter(&buf)
writer.UseCRLF = true
if err := writer.Write(adminPickupExportHeader); err != nil {
return nil, err
}
for _, item := range items {
settleAmount := ""
if item.Status == StatusCompleted {
settleAmount = formatExportAmount(item.EffectiveSettleAmountCent)
}
record := []string{
exportCSVText(item.PickupNo),
exportCSVText(item.AccountTitle),
exportCSVText(item.ServerRegion),
exportCSVText(item.LoginPlatform),
exportCSVText(item.ListingNo),
exportCSVText(item.OwnerPhone),
exportCSVText(item.Operator),
exportAccountSourceLabel(item.AccountSource),
exportCSVText(item.SourceChannel),
exportCSVText(item.Platform),
exportCSVText(item.ThirdPartyOrderNo),
exportCSVText(item.ShopName),
exportCSVText(item.Remark),
formatExportAmount(item.OrderAmountCent),
formatExportAmount(item.DepositAmountCent),
formatExportAmount(item.EffectiveProfitAmountCent),
settleAmount,
exportPickupStatusLabel(item.Status),
exportSettlementStatusLabel(item),
formatExportTime(item.CreatedAt),
formatExportTimePointer(item.CompletedAt),
formatExportTimePointer(item.CancelledAt),
exportCSVText(item.CompleteRemark),
}
if err := writer.Write(record); err != nil {
return nil, err
}
}
writer.Flush()
if err := writer.Error(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func exportCSVText(value string) string {
if value == "" {
return ""
}
trimmed := strings.TrimLeft(value, " \t\r")
if trimmed == "" {
return value
}
switch trimmed[0] {
case '=', '+', '-', '@', '\t', '\r':
return "'" + value
default:
return value
}
}
func exportPickupStatusLabel(status string) string {
switch status {
case StatusPickingUp:
return "提号中"
case StatusCompleted:
return "已完成"
case StatusCancelled:
return "已取消"
default:
return status
}
}
func exportAccountSourceLabel(source string) string {
switch source {
case AccountSourceExternalPlatformManaged:
return "外部上传 · 平台代管"
case AccountSourceInternal:
return "站内上传"
default:
return source
}
}
func exportSettlementStatusLabel(item PickupDTO) string {
if item.SettlementMode != SettlementModePlatformManaged {
return "钱包已结算"
}
switch item.OfflineSettlementStatus {
case OfflineSettlementStatusPending:
return "待线下结算"
case OfflineSettlementStatusSettled:
return "已线下结算"
default:
return "待完成提号"
}
}
func formatExportAmount(cent int64) string {
if cent < 0 {
return "-" + formatExportAmount(-cent)
}
return fmt.Sprintf("%d.%02d", cent/100, cent%100)
}
func formatExportTime(value time.Time) string {
if value.IsZero() {
return ""
}
return value.In(timeutil.ShanghaiLocation()).Format("2006-01-02 15:04:05")
}
func formatExportTimePointer(value *time.Time) string {
if value == nil {
return ""
}
return formatExportTime(*value)
}
func encodeExportFilename(filename string) string {
var builder strings.Builder
for _, value := range []byte(filename) {
if (value >= 'a' && value <= 'z') ||
(value >= 'A' && value <= 'Z') ||
(value >= '0' && value <= '9') ||
value == '-' || value == '_' || value == '.' || value == '~' {
builder.WriteByte(value)
continue
}
builder.WriteByte('%')
builder.WriteString(strings.ToUpper(fmt.Sprintf("%02X", value)))
}
return builder.String()
}
@@ -0,0 +1,196 @@
package pickup
import (
"bytes"
"encoding/csv"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"hfb_jysh/backend/internal/auditlog"
"hfb_jysh/backend/internal/middleware"
"hfb_jysh/backend/internal/model"
"github.com/gin-gonic/gin"
)
func TestBuildAdminPickupCSVUsesExcelFriendlyEncodingAndEscaping(t *testing.T) {
createdAt := time.Date(2026, 11, 12, 10, 20, 30, 0, time.FixedZone("CST", 8*60*60))
completedAt := createdAt.Add(2 * time.Hour)
items := []PickupDTO{
{
PickupNo: "PK-001",
AccountTitle: "账号,带逗号",
ServerRegion: "国服",
LoginPlatform: "QQ",
ListingNo: "202611120001",
OwnerPhone: "18800000000",
Operator: "运营小张",
AccountSource: AccountSourceExternalPlatformManaged,
SourceChannel: "闲鱼",
Platform: "拼多多",
ThirdPartyOrderNo: "=1+1",
ShopName: `大锤"专营店"`,
Remark: "第一行\n第二行",
OrderAmountCent: 29500,
DepositAmountCent: 35000,
EffectiveProfitAmountCent: 1234,
EffectiveSettleAmountCent: 20000,
SettlementMode: SettlementModePlatformManaged,
OfflineSettlementStatus: OfflineSettlementStatusPending,
Status: StatusCompleted,
CreatedAt: createdAt,
CompletedAt: &completedAt,
CompleteRemark: "已完成",
},
}
data, err := buildAdminPickupCSV(items)
if err != nil {
t.Fatalf("buildAdminPickupCSV() error = %v", err)
}
if !bytes.HasPrefix(data, []byte{0xEF, 0xBB, 0xBF}) {
t.Fatal("CSV 缺少 UTF-8 BOM")
}
records, err := csv.NewReader(bytes.NewReader(data[3:])).ReadAll()
if err != nil {
t.Fatalf("解析 CSV 失败: %v", err)
}
if len(records) != 2 {
t.Fatalf("CSV 行数 = %d, want 2", len(records))
}
if len(records[0]) != len(adminPickupExportHeader) {
t.Fatalf("表头列数 = %d, want %d", len(records[0]), len(adminPickupExportHeader))
}
row := records[1]
if row[1] != "账号,带逗号" {
t.Fatalf("账号标题 = %q", row[1])
}
if row[7] != "外部上传 · 平台代管" {
t.Fatalf("账号来源 = %q", row[7])
}
if row[10] != "'=1+1" {
t.Fatalf("第三方单号未转义公式注入: %q", row[10])
}
if row[11] != `大锤"专营店"` {
t.Fatalf("店铺名称 = %q", row[11])
}
if row[12] != "第一行\n第二行" {
t.Fatalf("提号备注 = %q", row[12])
}
if row[13] != "295.00" || row[14] != "350.00" || row[15] != "12.34" {
t.Fatalf("金额列 = %q/%q/%q", row[13], row[14], row[15])
}
if row[16] != "200.00" {
t.Fatalf("结算金额 = %q", row[16])
}
if row[17] != "已完成" || row[18] != "待线下结算" {
t.Fatalf("状态列 = %q/%q", row[17], row[18])
}
if row[19] != "2026-11-12 10:20:30" || row[20] != "2026-11-12 12:20:30" {
t.Fatalf("时间列 = %q/%q", row[19], row[20])
}
}
func TestServiceExportAdminReusesFiltersAndRecordsAudit(t *testing.T) {
repo, db := newPickupTestRepo(t)
first := seedPickup(t, db, StatusPickingUp)
if err := db.Model(&first).Updates(map[string]any{
"operator": "运营,小张",
"platform": "拼多多",
"third_party_order_no": "=1+1",
"order_amount_cent": 29500,
"deposit_amount_cent": 35000,
}).Error; err != nil {
t.Fatalf("更新第一条提号失败: %v", err)
}
second := first
second.ID = 0
second.PickupNo = "PK-EXPORT-002"
second.Status = StatusCompleted
second.Operator = "运营小李"
second.ThirdPartyOrderNo = "TB-002"
if err := db.Create(&second).Error; err != nil {
t.Fatalf("创建第二条提号失败: %v", err)
}
service := NewService(repo)
data, rowCount, err := service.ExportAdmin(
t.Context(),
AdminPickupQuery{Status: StatusPickingUp},
99,
auditlog.Meta{RequestID: "req-pickup-export"},
)
if err != nil {
t.Fatalf("ExportAdmin() error = %v", err)
}
if rowCount != 1 {
t.Fatalf("导出行数 = %d, want 1", rowCount)
}
records, err := csv.NewReader(bytes.NewReader(data[3:])).ReadAll()
if err != nil {
t.Fatalf("解析导出 CSV 失败: %v", err)
}
if len(records) != 2 {
t.Fatalf("筛选导出 CSV 行数 = %d, want 2", len(records))
}
if records[1][0] != first.PickupNo || records[1][10] != "'=1+1" {
t.Fatalf("筛选导出行 = %#v", records[1])
}
var audit model.AuditLog
if err := db.Where("action = ?", "pickup_export").First(&audit).Error; err != nil {
t.Fatalf("查询导出审计失败: %v", err)
}
var detail map[string]any
if err := json.Unmarshal(audit.Detail, &detail); err != nil {
t.Fatalf("解析导出审计失败: %v", err)
}
if detail["status"] != StatusPickingUp {
t.Fatalf("审计 status = %v, want %s", detail["status"], StatusPickingUp)
}
assertJSONNumber(t, "导出行数", detail["row_count"], 1)
if !strings.Contains(string(audit.Detail), "req-pickup-export") {
t.Fatalf("审计日志缺少 request_id: %s", audit.Detail)
}
}
func TestHandlerExportWritesCSVDownloadResponse(t *testing.T) {
gin.SetMode(gin.TestMode)
repo, db := newPickupTestRepo(t)
seedPickup(t, db, StatusPickingUp)
handler := NewHandler(NewService(repo))
router := gin.New()
router.GET("/export", func(c *gin.Context) {
c.Set(middleware.ContextAdminID, uint64(99))
handler.Export(c)
})
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/export", nil)
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("状态码 = %d, want %d, body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
}
if contentType := recorder.Header().Get("Content-Type"); !strings.HasPrefix(contentType, "text/csv") {
t.Fatalf("Content-Type = %q", contentType)
}
if disposition := recorder.Header().Get("Content-Disposition"); !strings.Contains(disposition, ".csv") {
t.Fatalf("Content-Disposition = %q", disposition)
}
if rowCount := recorder.Header().Get("X-Export-Row-Count"); rowCount != "1" {
t.Fatalf("X-Export-Row-Count = %q, want 1", rowCount)
}
if !bytes.HasPrefix(recorder.Body.Bytes(), []byte{0xEF, 0xBB, 0xBF}) {
t.Fatal("响应体缺少 UTF-8 BOM")
}
}
@@ -2,11 +2,13 @@ package pickup
import (
"errors"
"fmt"
"net/http"
"strings"
"hfb_jysh/backend/internal/auditlog"
"hfb_jysh/backend/internal/middleware"
"hfb_jysh/backend/internal/timeutil"
"hfb_jysh/backend/pkg/response"
"github.com/gin-gonic/gin"
@@ -244,6 +246,31 @@ func (h *Handler) List(c *gin.Context) {
response.OK(c, result)
}
// Export 导出线下提号 CSV(管理员)。
func (h *Handler) Export(c *gin.Context) {
adminID, ok := currentAdminID(c)
if !ok {
response.Unauthorized(c, "缺少管理员上下文")
return
}
query := parseAdminQuery(c)
data, rowCount, err := h.service.ExportAdmin(c.Request.Context(), query, adminID, auditMeta(c))
if err != nil {
writePickupError(c, err)
return
}
timestamp := timeutil.ShanghaiNow().Format("20060102-150405")
filename := fmt.Sprintf("线下提号_%s.csv", timestamp)
asciiFilename := fmt.Sprintf("pickup-export-%s.csv", timestamp)
c.Header(
"Content-Disposition",
fmt.Sprintf(`attachment; filename="%s"; filename*=UTF-8''%s`, asciiFilename, encodeExportFilename(filename)),
)
c.Header("X-Export-Row-Count", fmt.Sprintf("%d", rowCount))
c.Data(http.StatusOK, "text/csv; charset=utf-8", data)
}
// ShopOptions 返回后台提号已使用的店铺名称。
func (h *Handler) ShopOptions(c *gin.Context) {
items, err := h.service.ListShopNames(c.Request.Context(), c.Query("platform"))
@@ -333,6 +360,13 @@ func writePickupError(c *gin.Context, err error) {
response.BadRequest(c, "提号订单已取消,不能补录单号")
case errors.Is(err, ErrInvalidThirdPartyOrderNo):
response.BadRequest(c, "第三方单号不能为空且长度不能超过128位")
case errors.Is(err, ErrPickupExportTooLarge):
response.Error(
c,
http.StatusBadRequest,
"pickup_export_too_large",
fmt.Sprintf("导出数据超过 %d 条,请缩小筛选范围", maxAdminPickupExportRows),
)
default:
response.Error(c, http.StatusInternalServerError, "pickup_error", err.Error())
}
+21 -6
View File
@@ -16,6 +16,7 @@ import (
"hfb_jysh/backend/internal/model"
"hfb_jysh/backend/internal/modules/invitation"
"hfb_jysh/backend/internal/modules/notification"
"hfb_jysh/backend/internal/modules/rentergrowth"
"hfb_jysh/backend/internal/modules/wallet"
"hfb_jysh/backend/internal/timeutil"
"hfb_jysh/backend/pkg/money"
@@ -307,6 +308,17 @@ func (r *Repository) Complete(ctx context.Context, pickupID uint64, req Complete
}
}
if _, err := rentergrowth.AwardPickupOwnerCompleted(
tx,
pickup.ID,
pickup.PickupNo,
pickup.OwnerID,
pickup.SettleAmountCent,
rentergrowth.SourcePickupCompleted,
); err != nil {
return err
}
// 邀请返利:线下提号不走正常订单结账流程,必须在这里补发号主(卖家)侧返利,
// 否则号主邀请人永远拿不到提号产生的分成。
// 基准取「网站加价」与「线下利润」的较小值,避免按虚高的单一利润口径多算。
@@ -854,9 +866,9 @@ func applyAdminPickupFilters(db *gorm.DB, query AdminPickupQuery) *gorm.DB {
}
if kw := strings.TrimSpace(query.Keyword); kw != "" {
upper := strings.ToUpper(kw)
if len(kw) == 11 && kw[0] == '1' && isAllDigits(kw) {
// 11 位手机号:命中 users(phone) 唯一索引
db = db.Where("owner.phone = ?", kw)
if isPhoneKeyword(kw) {
// 先由 users(phone) 唯一索引解析用户,再让 admin_pickups(owner_id) 索引驱动。
db = db.Where("p.owner_id = (SELECT id FROM users WHERE phone = ?)", kw)
} else if strings.HasPrefix(upper, "PK") || strings.HasPrefix(upper, "P") {
// 提号编号:命中 admin_pickups(pickup_no) 唯一索引
db = db.Where("p.pickup_no = ?", kw)
@@ -894,9 +906,8 @@ func (r *Repository) adminPickupCountQuery(ctx context.Context, query AdminPicku
if id, err := strconv.ParseUint(kw, 10, 64); err == nil && id > 0 && kw[0] != '0' && id < 100000000 {
return r.db.WithContext(ctx).Table("admin_pickups AS p")
}
if len(kw) == 11 && kw[0] == '1' && isAllDigits(kw) {
return r.db.WithContext(ctx).Table("admin_pickups AS p").
Joins("JOIN users AS owner ON owner.id = p.owner_id")
if isPhoneKeyword(kw) {
return r.db.WithContext(ctx).Table("admin_pickups AS p")
}
if strings.HasPrefix(upper, "L") || (len(kw) >= 12 && isAllDigits(kw)) {
return r.db.WithContext(ctx).Table("admin_pickups AS p").
@@ -1974,3 +1985,7 @@ func isAllDigits(s string) bool {
}
return true
}
func isPhoneKeyword(s string) bool {
return len(s) == 11 && s[0] == '1' && isAllDigits(s)
}
@@ -3,6 +3,7 @@ package pickup
import (
"encoding/json"
"errors"
"strconv"
"strings"
"testing"
"time"
@@ -363,6 +364,20 @@ func TestRepositoryCompleteInternalPickupCreditsWallet(t *testing.T) {
t.Fatalf("站内提号应生成钱包流水: %v", err)
}
assertInt64(t, "站内提号钱包入账", ledger.AmountCent, 12000)
var owner model.User
if err := db.First(&owner, listing.OwnerID).Error; err != nil {
t.Fatalf("读取提号号主失败: %v", err)
}
assertInt64(t, "提号号主成长积分", owner.RenterGrowthPoints, 120)
var growthLedger model.RenterGrowthLedger
if err := db.Where("biz_key = ?", "pickup:"+strconv.FormatUint(pickup.ID, 10)+":owner:pickup_completed").
First(&growthLedger).Error; err != nil {
t.Fatalf("提号应生成号主积分流水: %v", err)
}
if growthLedger.Role != "owner" || growthLedger.Source != "pickup_completed" || growthLedger.Points != 120 {
t.Fatalf("提号积分流水 = %#v", growthLedger)
}
}
// 线下提号不走正常订单结账流程,完成时必须同步给号主邀请人发放返利,
@@ -736,6 +751,8 @@ func newPickupTestRepo(t *testing.T) (*Repository, *gorm.DB) {
&model.ProcessEvent{},
&model.Notification{},
&model.ListingStatusEvent{},
&model.SystemConfig{},
&model.RenterGrowthLedger{},
); err != nil {
t.Fatalf("迁移测试表失败: %v", err)
}
@@ -829,9 +846,9 @@ func TestApplyAdminPickupFiltersSmartRouting(t *testing.T) {
wantSQLFrag string
}{
{
name: "11位手机号路由至users(phone)",
name: "11位手机号先解析用户再走提号号主索引",
query: AdminPickupQuery{Keyword: "13800138000"},
wantSQLFrag: "owner.phone = ?",
wantSQLFrag: "p.owner_id = (SELECT id FROM users WHERE phone = ?)",
},
{
name: "PK前缀提号编号路由至admin_pickups(pickup_no)",
@@ -141,6 +141,29 @@ func (s *Service) ListAdmin(ctx context.Context, query AdminPickupQuery) (*Pagin
return s.repo.ListAdmin(ctx, query)
}
func (s *Service) ExportAdmin(
ctx context.Context,
query AdminPickupQuery,
adminID uint64,
meta auditlog.Meta,
) ([]byte, int, error) {
if s.repo == nil {
return nil, 0, ErrDependencyUnavailable
}
items, err := s.repo.ListAdminForExport(ctx, query)
if err != nil {
return nil, 0, err
}
data, err := buildAdminPickupCSV(items)
if err != nil {
return nil, 0, err
}
if err := s.repo.RecordAdminPickupExport(ctx, adminID, meta, query, len(items)); err != nil {
return nil, 0, err
}
return data, len(items), nil
}
func (s *Service) ListShopNames(ctx context.Context, platform string) ([]string, error) {
if s.repo == nil {
return nil, ErrDependencyUnavailable