feat: 接入旧站管理端并完整迁移后台能力
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user