From 039f9bc4b8a4508a4950a14c58858bede0f0afbe Mon Sep 17 00:00:00 2001 From: yml Date: Fri, 13 Nov 2026 13:49:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E4=BC=98=E5=8C=96=E5=95=86?= =?UTF-8?q?=E5=93=81=E4=B8=8E=E6=94=AF=E4=BB=98=E9=85=8D=E7=BD=AE=E5=88=97?= =?UTF-8?q?=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/modules/paymentconfig/dto.go | 5 +- .../modules/paymentconfig/mutation.go | 12 +- .../modules/paymentconfig/presenter.go | 45 +++--- .../internal/modules/paymentconfig/query.go | 129 +++--------------- .../modules/paymentconfig/repository_test.go | 49 +++++-- .../src/features/admin/api/paymentConfig.ts | 5 +- .../admin/views/AdminListingsView.vue | 52 ++++--- .../admin/views/AdminPaymentConfigsView.vue | 53 +------ frontend/src/styles/admin.css | 3 + 9 files changed, 125 insertions(+), 228 deletions(-) diff --git a/backend/internal/modules/paymentconfig/dto.go b/backend/internal/modules/paymentconfig/dto.go index ea86d723..d53f49e9 100644 --- a/backend/internal/modules/paymentconfig/dto.go +++ b/backend/internal/modules/paymentconfig/dto.go @@ -46,10 +46,7 @@ type ConfigDTO struct { Status string `json:"status"` Environment string `json:"environment"` BusinessTags []string `json:"business_tags"` - ReferenceCount int64 `json:"reference_count"` - TotalTransactions int64 `json:"total_transactions"` - TotalAmountCent int64 `json:"total_amount_cent"` - LastUsedAt *string `json:"last_used_at"` + HasPaymentRecords bool `json:"has_payment_records"` CreatedBy *uint64 `json:"created_by"` UpdatedBy *uint64 `json:"updated_by"` CreatedAt string `json:"created_at"` diff --git a/backend/internal/modules/paymentconfig/mutation.go b/backend/internal/modules/paymentconfig/mutation.go index 464fcecf..3b1138f5 100644 --- a/backend/internal/modules/paymentconfig/mutation.go +++ b/backend/internal/modules/paymentconfig/mutation.go @@ -295,17 +295,17 @@ func (r *Repository) Delete(ctx context.Context, id uint64, actorID uint64, meta return err } - var usedCount int64 usedDB := tx.Model(&model.PaymentOrder{}). Where("payment_config_id = ?", item.ID). Or("(payment_config_id = ? OR payment_config_id IS NULL) AND provider = ? AND merchant_id = ? AND pay_way = ?", 0, item.Provider, item.MerchantID, item.PayWay) - if err := usedDB. - Count(&usedCount).Error; err != nil { - return err - } - if usedCount > 0 { + var usedPayment model.PaymentOrder + err := usedDB.Select("id").Limit(1).Take(&usedPayment).Error + if err == nil { return ErrCannotDeleteInUse } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } if err := tx.Delete(&item).Error; err != nil { return err diff --git a/backend/internal/modules/paymentconfig/presenter.go b/backend/internal/modules/paymentconfig/presenter.go index 386f07e1..5716a852 100644 --- a/backend/internal/modules/paymentconfig/presenter.go +++ b/backend/internal/modules/paymentconfig/presenter.go @@ -9,32 +9,25 @@ import ( // toDTO 转换为 DTO func (r *Repository) toDTO(item model.PaymentMerchantConfig, includeSecret bool) (ConfigDTO, error) { dto := ConfigDTO{ - ID: item.ID, - Name: item.Name, - Provider: item.Provider, - MerchantID: item.MerchantID, - GatewayURL: item.GatewayURL, - NotifyURL: item.NotifyURL, - JumpURL: item.JumpURL, - PayWay: item.PayWay, - JSPayFlag: item.JSPayFlag, - SignType: item.SignType, - ExtraConfig: item.ExtraConfig, - IsDefault: item.IsDefault, - Status: item.Status, - Environment: item.Environment, - BusinessTags: item.BusinessTags, - TotalTransactions: item.TotalTransactions, - TotalAmountCent: item.TotalAmountCent, - CreatedBy: item.CreatedBy, - UpdatedBy: item.UpdatedBy, - CreatedAt: item.CreatedAt.Format(time.RFC3339), - UpdatedAt: item.UpdatedAt.Format(time.RFC3339), - } - - if item.LastUsedAt != nil { - s := item.LastUsedAt.Format(time.RFC3339) - dto.LastUsedAt = &s + ID: item.ID, + Name: item.Name, + Provider: item.Provider, + MerchantID: item.MerchantID, + GatewayURL: item.GatewayURL, + NotifyURL: item.NotifyURL, + JumpURL: item.JumpURL, + PayWay: item.PayWay, + JSPayFlag: item.JSPayFlag, + SignType: item.SignType, + ExtraConfig: item.ExtraConfig, + IsDefault: item.IsDefault, + Status: item.Status, + Environment: item.Environment, + BusinessTags: item.BusinessTags, + CreatedBy: item.CreatedBy, + UpdatedBy: item.UpdatedBy, + CreatedAt: item.CreatedAt.Format(time.RFC3339), + UpdatedAt: item.UpdatedAt.Format(time.RFC3339), } // 只有明确要求时才解密并返回密钥 diff --git a/backend/internal/modules/paymentconfig/query.go b/backend/internal/modules/paymentconfig/query.go index a42a334e..ff683d7c 100644 --- a/backend/internal/modules/paymentconfig/query.go +++ b/backend/internal/modules/paymentconfig/query.go @@ -2,9 +2,7 @@ package paymentconfig import ( "context" - "database/sql/driver" "errors" - "time" "hfb_sys/backend/internal/model" @@ -63,10 +61,8 @@ func (r *Repository) List(ctx context.Context, query ListQuery) ([]ConfigDTO, in } dtos = append(dtos, dto) } - if err := r.applySuccessfulPaymentStats(ctx, dtos); err != nil { - return nil, 0, err - } - if err := r.applyReferenceCounts(ctx, dtos); err != nil { + + if err := r.applyPaymentReferenceFlags(ctx, dtos); err != nil { return nil, 0, err } @@ -86,14 +82,12 @@ func (r *Repository) FindByID(ctx context.Context, id uint64, includeSecret bool if err != nil { return nil, err } - statDTOs := []ConfigDTO{dto} - if err := r.applySuccessfulPaymentStats(ctx, statDTOs); err != nil { + + dtos := []ConfigDTO{dto} + if err := r.applyPaymentReferenceFlags(ctx, dtos); err != nil { return nil, err } - if err := r.applyReferenceCounts(ctx, statDTOs); err != nil { - return nil, err - } - dto = statDTOs[0] + dto = dtos[0] if includeSecret { if err := appendAuditLog(r.db.WithContext(ctx), actorID, "payment_config.view_secret", item.ID, meta, map[string]any{ "name": item.Name, @@ -106,15 +100,9 @@ func (r *Repository) FindByID(ctx context.Context, id uint64, includeSecret bool return &dto, nil } -type paymentConfigSuccessStat struct { - ConfigID uint64 - TotalTransactions int64 - TotalAmountCent int64 - LastPaidAt nullableTime -} - -// applySuccessfulPaymentStats 使用真实成功支付流水覆盖配置统计,避免仅打开二维码也被算作成交。 -func (r *Repository) applySuccessfulPaymentStats(ctx context.Context, dtos []ConfigDTO) error { +// applyPaymentReferenceFlags 批量标记配置是否已有支付单,用于前端提前禁用删除。 +// 真正的删除保护仍由 Delete 按包含历史数据的口径校验。 +func (r *Repository) applyPaymentReferenceFlags(ctx context.Context, dtos []ConfigDTO) error { if len(dtos) == 0 { return nil } @@ -123,109 +111,26 @@ func (r *Repository) applySuccessfulPaymentStats(ctx context.Context, dtos []Con for idx, dto := range dtos { ids = append(ids, dto.ID) indexByID[dto.ID] = idx - dtos[idx].TotalTransactions = 0 - dtos[idx].TotalAmountCent = 0 - dtos[idx].LastUsedAt = nil + dtos[idx].HasPaymentRecords = false } - var rows []paymentConfigSuccessStat + var referencedIDs []uint64 if err := r.db.WithContext(ctx).Model(&model.PaymentOrder{}). - Select("payment_config_id AS config_id, COUNT(*) AS total_transactions, COALESCE(SUM(amount_cent), 0) AS total_amount_cent, MAX(paid_at) AS last_paid_at"). - Where("payment_config_id IN ? AND biz_type = ? AND status = ?", ids, "order_pay", "paid"). - Group("payment_config_id"). - Scan(&rows).Error; err != nil { + Where("payment_config_id IN ?", ids). + Distinct(). + Pluck("payment_config_id", &referencedIDs).Error; err != nil { return err } - for _, row := range rows { - idx, ok := indexByID[row.ConfigID] + for _, id := range referencedIDs { + idx, ok := indexByID[id] if !ok { continue } - dtos[idx].TotalTransactions = row.TotalTransactions - dtos[idx].TotalAmountCent = row.TotalAmountCent - if row.LastPaidAt.Time != nil { - value := row.LastPaidAt.Time.Format(time.RFC3339) - dtos[idx].LastUsedAt = &value - } + dtos[idx].HasPaymentRecords = true } return nil } -// applyReferenceCounts 使用删除保护的同一口径统计配置被支付记录引用的次数。 -func (r *Repository) applyReferenceCounts(ctx context.Context, dtos []ConfigDTO) error { - for idx := range dtos { - count, err := r.referenceCount(ctx, dtos[idx]) - if err != nil { - return err - } - dtos[idx].ReferenceCount = count - } - return nil -} - -func (r *Repository) referenceCount(ctx context.Context, dto ConfigDTO) (int64, error) { - var count int64 - db := r.db.WithContext(ctx).Model(&model.PaymentOrder{}). - Where("payment_config_id = ?", dto.ID). - Or("(payment_config_id = ? OR payment_config_id IS NULL) AND provider = ? AND merchant_id = ? AND pay_way = ?", 0, dto.Provider, dto.MerchantID, dto.PayWay) - if err := db.Count(&count).Error; err != nil { - return 0, err - } - return count, nil -} - -type nullableTime struct { - Time *time.Time -} - -// Value 实现 driver.Valuer,让 GORM 能把 nullableTime 当作普通扫描字段处理。 -func (nt nullableTime) Value() (driver.Value, error) { - if nt.Time == nil { - return nil, nil - } - return *nt.Time, nil -} - -// Scan 兼容 MySQL 聚合函数可能返回的 time.Time 与字符串时间。 -func (nt *nullableTime) Scan(value any) error { - switch v := value.(type) { - case nil: - nt.Time = nil - case time.Time: - nt.Time = &v - case []byte: - return nt.scanString(string(v)) - case string: - return nt.scanString(v) - default: - nt.Time = nil - } - return nil -} - -func (nt *nullableTime) scanString(value string) error { - if value == "" { - nt.Time = nil - return nil - } - for _, layout := range []string{ - time.RFC3339Nano, - time.RFC3339, - "2006-01-02 15:04:05.999999999-07:00", - "2006-01-02 15:04:05-07:00", - "2006-01-02 15:04:05.999999999", - "2006-01-02 15:04:05", - } { - parsed, err := time.Parse(layout, value) - if err == nil { - nt.Time = &parsed - return nil - } - } - nt.Time = nil - return nil -} - // FindRuntimeByID 根据配置 ID 查询运行时配置,不写审计日志,供支付单回溯原配置使用。 func (r *Repository) FindRuntimeByID(ctx context.Context, id uint64, includeSecret bool) (*ConfigDTO, error) { var item model.PaymentMerchantConfig diff --git a/backend/internal/modules/paymentconfig/repository_test.go b/backend/internal/modules/paymentconfig/repository_test.go index 3127f2ab..af25715c 100644 --- a/backend/internal/modules/paymentconfig/repository_test.go +++ b/backend/internal/modules/paymentconfig/repository_test.go @@ -67,8 +67,8 @@ func TestValidateCreateRequestRequiresShunchengSecretID(t *testing.T) { } } -// TestListUsesSuccessfulPaymentStats 验证配置页只统计成功支付,不把打开二维码或取消订单算作成交。 -func TestListUsesSuccessfulPaymentStats(t *testing.T) { +// TestListFlagsPaymentReferences 验证列表只批量标记是否存在支付引用,不再聚合所有支付记录。 +func TestListFlagsPaymentReferences(t *testing.T) { db := database.NewTestDB() if err := database.AutoMigrateIfMissing(db, &model.PaymentMerchantConfig{}, &model.PaymentOrder{}); err != nil { t.Fatalf("migrate failed: %v", err) @@ -142,13 +142,46 @@ func TestListUsesSuccessfulPaymentStats(t *testing.T) { if total != 1 || len(items) != 1 { t.Fatalf("items/total = %d/%d, want 1/1", len(items), total) } - if items[0].TotalTransactions != 1 || items[0].TotalAmountCent != 10130 { - t.Fatalf("success stats = %d/%d, want 1/10130", items[0].TotalTransactions, items[0].TotalAmountCent) + if !items[0].HasPaymentRecords { + t.Fatal("has_payment_records should be true") } - if items[0].ReferenceCount != 3 { - t.Fatalf("reference_count = %d, want 3", items[0].ReferenceCount) +} + +func TestDeleteRejectsReferencedConfig(t *testing.T) { + db := database.NewTestDB() + if err := database.AutoMigrateIfMissing(db, &model.PaymentMerchantConfig{}, &model.PaymentOrder{}); err != nil { + t.Fatalf("migrate failed: %v", err) } - if items[0].LastUsedAt == nil { - t.Fatal("last success pay time should not be nil") + config := model.PaymentMerchantConfig{ + Name: "已有交易配置", + Provider: "lakala", + MerchantID: "M1", + PayWay: "WXZF", + Status: "active", + Environment: "production", + } + if err := db.Create(&config).Error; err != nil { + t.Fatalf("create config failed: %v", err) + } + payment := model.PaymentOrder{ + PaymentNo: "PAY-DELETE-GUARD", + OrderID: 1, + OrderNo: "ORD-DELETE-GUARD", + UserID: 2, + PaymentConfigID: config.ID, + Provider: "lakala", + MerchantID: "M1", + ThirdOrderID: "PAY-DELETE-GUARD", + PayWay: "WXZF", + BizType: "order_pay", + Status: "paid", + } + if err := db.Create(&payment).Error; err != nil { + t.Fatalf("create payment failed: %v", err) + } + + repo := NewRepository(db, nil) + if err := repo.Delete(t.Context(), config.ID, 0, AuditMeta{}); err != ErrCannotDeleteInUse { + t.Fatalf("Delete() error = %v, want ErrCannotDeleteInUse", err) } } diff --git a/frontend/src/features/admin/api/paymentConfig.ts b/frontend/src/features/admin/api/paymentConfig.ts index 17a88ff3..431efaff 100644 --- a/frontend/src/features/admin/api/paymentConfig.ts +++ b/frontend/src/features/admin/api/paymentConfig.ts @@ -19,10 +19,7 @@ export interface PaymentConfig { status: string environment: string business_tags: string[] | null - reference_count: number - total_transactions: number - total_amount_cent: number - last_used_at: string | null + has_payment_records: boolean created_by: number | null updated_by: number | null created_at: string diff --git a/frontend/src/features/admin/views/AdminListingsView.vue b/frontend/src/features/admin/views/AdminListingsView.vue index e2311c1d..ccfbc6d1 100644 --- a/frontend/src/features/admin/views/AdminListingsView.vue +++ b/frontend/src/features/admin/views/AdminListingsView.vue @@ -62,7 +62,8 @@ const publishOptions = ref(emptyListingPublishOptions) const listingColumnUnits = { code: 68, zone: 70, - region: 100, + region: 62, + login: 38, core: 80, level: 58, equipment: 78, @@ -161,10 +162,11 @@ const screenshotColumns = [ read: (row: Listing) => zoneCompensationText(row), }, { - label: '地区/上号/纯币', - width: 140, - read: (row: Listing) => regionLoginCoinText(row), + label: '地区/纯币', + width: 105, + read: (row: Listing) => regionCoinText(row), }, + { label: '上号方式', width: 70, read: (row: Listing) => loginMethodText(row) }, { label: '保险/体力/负重', width: 112, @@ -179,8 +181,8 @@ const screenshotColumns = [ { label: '皮肤资产', width: 189, read: (row: Listing) => skinAssetText(row) }, { label: '人脸/全英雄', width: 81, read: (row: Listing) => faceHeroText(row) }, { label: '号主备注', width: 112, read: (row: Listing) => ownerRemarkText(row) }, - { label: '租客纯币价格', width: 101, read: (row: Listing) => renterPureCoinPriceText(row) }, { label: '租期', width: 70, read: (row: Listing) => rentalPeriodText(row) }, + { label: '租客纯币价格', width: 101, read: (row: Listing) => renterPureCoinPriceText(row) }, { label: '租金/押金', width: 106, read: (row: Listing) => rentAndDepositText(row) }, { label: '比例', width: 42, read: (row: Listing) => formatRatio(row) }, { label: '详情', width: 64, read: () => '详情' }, @@ -517,7 +519,7 @@ function renderTableScreenshotCanvas(rows: Listing[]) { drawCellText( ctx, headerLines[columnIndex] ?? [column.label], - x + cellPaddingX, + x + column.width / 2, y + cellPaddingY + 12, lineHeight ) @@ -538,7 +540,7 @@ function renderTableScreenshotCanvas(rows: Listing[]) { columnIndex === 0 ? '700 13px Arial, "Microsoft YaHei", sans-serif' : '13px Arial, "Microsoft YaHei", sans-serif' - drawCellText(ctx, lines, x + cellPaddingX, y + cellPaddingY + 14, lineHeight) + drawCellText(ctx, lines, x + column.width / 2, y + cellPaddingY + 14, lineHeight) x += column.width } drawLine(ctx, paddingX, y + rowHeight, paddingX + tableWidth, y + rowHeight) @@ -569,12 +571,13 @@ function wrapCanvasText(ctx: CanvasRenderingContext2D, text: string, maxWidth: n function drawCellText( ctx: CanvasRenderingContext2D, lines: string[], - x: number, + centerX: number, y: number, lineHeight: number ) { + ctx.textAlign = 'center' lines.forEach((line, index) => { - ctx.fillText(line, x, y + index * lineHeight) + ctx.fillText(line, centerX, y + index * lineHeight) }) } @@ -648,8 +651,12 @@ function zoneCompensationText(row: Listing) { return `${zone}\n${compensation}` } -function regionLoginCoinText(row: Listing) { - return [regionText(row), row.login_platform || '-', listingCoinM(row)].join('\n') +function regionCoinText(row: Listing) { + return [regionText(row), listingCoinM(row)].join('\n') +} + +function loginMethodText(row: Listing) { + return row.login_platform?.trim() || '-' } function insuranceSlotText(row: Listing) { @@ -1070,15 +1077,22 @@ function formatQuantity(value: number) { + + + + + + - - - diff --git a/frontend/src/features/admin/views/AdminPaymentConfigsView.vue b/frontend/src/features/admin/views/AdminPaymentConfigsView.vue index cf2832bb..086a5f35 100644 --- a/frontend/src/features/admin/views/AdminPaymentConfigsView.vue +++ b/frontend/src/features/admin/views/AdminPaymentConfigsView.vue @@ -1,6 +1,6 @@ @@ -413,7 +403,7 @@ function deleteDisabledReason(row: PaymentConfig) {
备用 - - - - - -