diff --git a/backend/internal/modules/pickup/dto.go b/backend/internal/modules/pickup/dto.go index 087b596..34fc97b 100644 --- a/backend/internal/modules/pickup/dto.go +++ b/backend/internal/modules/pickup/dto.go @@ -66,6 +66,11 @@ type CompleteRequest struct { CompleteRemark string `json:"complete_remark"` } +type UpdateProfitRequest struct { + ProfitAmountCent int64 `json:"profit_amount_cent" binding:"min=0"` + Reason string `json:"reason"` +} + type CancelRequest struct { Reason string `json:"reason" binding:"required"` } diff --git a/backend/internal/modules/pickup/handler.go b/backend/internal/modules/pickup/handler.go index 72be943..d3892fe 100644 --- a/backend/internal/modules/pickup/handler.go +++ b/backend/internal/modules/pickup/handler.go @@ -64,6 +64,30 @@ func (h *Handler) Complete(c *gin.Context) { response.OK(c, item) } +// UpdateProfit 修改提号中的线下利润(管理员) +func (h *Handler) UpdateProfit(c *gin.Context) { + adminID, ok := currentAdminID(c) + if !ok { + response.Unauthorized(c, "缺少管理员上下文") + return + } + id, ok := parseID(c) + if !ok { + return + } + var req UpdateProfitRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "利润金额不正确") + return + } + item, err := h.service.UpdateProfit(c.Request.Context(), id, req, adminID, auditMeta(c)) + if err != nil { + writePickupError(c, err) + return + } + response.OK(c, item) +} + // Cancel 取消提号(管理员) func (h *Handler) Cancel(c *gin.Context) { adminID, ok := currentAdminID(c) diff --git a/backend/internal/modules/pickup/repository.go b/backend/internal/modules/pickup/repository.go index a43def0..9a5ddd9 100644 --- a/backend/internal/modules/pickup/repository.go +++ b/backend/internal/modules/pickup/repository.go @@ -270,6 +270,51 @@ func (r *Repository) Complete(ctx context.Context, pickupID uint64, req Complete return r.FindByID(ctx, pickupID) } +// UpdateProfit 修改提号中的线下利润,已完成和已取消的提号不可修改。 +func (r *Repository) UpdateProfit(ctx context.Context, pickupID uint64, req UpdateProfitRequest, adminID uint64, meta auditlog.Meta) (*PickupDTO, error) { + if r == nil || r.db == nil { + return nil, ErrDependencyUnavailable + } + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var pickup model.AdminPickup + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&pickup, pickupID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrPickupNotFound + } + return err + } + if pickup.Status != StatusPickingUp { + return ErrPickupNotPickingUp + } + + oldProfitAmountCent := pickup.ProfitAmountCent + pickup.ProfitAmountCent = req.ProfitAmountCent + if err := tx.Model(&pickup).Update("profit_amount_cent", pickup.ProfitAmountCent).Error; err != nil { + return err + } + + bid := pickup.ID + return auditlog.Append(tx, auditlog.Entry{ + ActorType: "admin", + ActorID: adminID, + Action: "pickup_profit_update", + BizType: "admin_pickup", + BizID: &bid, + Meta: meta, + Detail: map[string]any{ + "pickup_no": pickup.PickupNo, + "old_profit_amount_cent": oldProfitAmountCent, + "new_profit_amount_cent": pickup.ProfitAmountCent, + "reason": strings.TrimSpace(req.Reason), + }, + }) + }) + if err != nil { + return nil, err + } + return r.FindByID(ctx, pickupID) +} + // Cancel 取消提号:恢复 listing 为 published + 解锁 in_transaction,账号恢复可租。 func (r *Repository) Cancel(ctx context.Context, pickupID uint64, reason string, adminID uint64, meta auditlog.Meta) error { if r == nil || r.db == nil { diff --git a/backend/internal/modules/pickup/repository_test.go b/backend/internal/modules/pickup/repository_test.go index cca4038..ba1255b 100644 --- a/backend/internal/modules/pickup/repository_test.go +++ b/backend/internal/modules/pickup/repository_test.go @@ -2,11 +2,16 @@ package pickup import ( "encoding/json" + "errors" "testing" + "hfb_sys/backend/internal/auditlog" "hfb_sys/backend/internal/model" "gorm.io/datatypes" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" ) func TestBuildPickupPriceSnapshotSplitsOwnerPrice(t *testing.T) { @@ -62,9 +67,137 @@ func TestBuildPickupPriceSnapshotFallsBackToSplitSum(t *testing.T) { assertInt64(t, "网站加价兜底", snapshot.WebsiteProfitCent, 5000) } +func TestRepositoryUpdateProfitAllowsPickingUp(t *testing.T) { + repo, db := newPickupTestRepo(t) + pickup := seedPickup(t, db, StatusPickingUp) + + item, err := repo.UpdateProfit(t.Context(), pickup.ID, UpdateProfitRequest{ + ProfitAmountCent: 2500, + Reason: "线下扣点调整", + }, 99, auditlog.Meta{RequestID: "req-profit"}) + if err != nil { + t.Fatalf("UpdateProfit() error = %v", err) + } + assertInt64(t, "修改后利润", item.ProfitAmountCent, 2500) + + var stored model.AdminPickup + if err := db.First(&stored, pickup.ID).Error; err != nil { + t.Fatalf("查询提号失败: %v", err) + } + assertInt64(t, "数据库利润", stored.ProfitAmountCent, 2500) + + var audit model.AuditLog + if err := db.Where("action = ?", "pickup_profit_update").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) + } + assertJSONNumber(t, "旧利润", detail["old_profit_amount_cent"], 1000) + assertJSONNumber(t, "新利润", detail["new_profit_amount_cent"], 2500) + if detail["reason"] != "线下扣点调整" { + t.Fatalf("reason = %v, want 线下扣点调整", detail["reason"]) + } +} + +func TestRepositoryUpdateProfitRejectsCompleted(t *testing.T) { + repo, db := newPickupTestRepo(t) + pickup := seedPickup(t, db, StatusCompleted) + + _, err := repo.UpdateProfit(t.Context(), pickup.ID, UpdateProfitRequest{ProfitAmountCent: 2500}, 99, auditlog.Meta{}) + if !errors.Is(err, ErrPickupNotPickingUp) { + t.Fatalf("UpdateProfit() error = %v, want ErrPickupNotPickingUp", err) + } + + var stored model.AdminPickup + if err := db.First(&stored, pickup.ID).Error; err != nil { + t.Fatalf("查询提号失败: %v", err) + } + assertInt64(t, "已完成提号利润", stored.ProfitAmountCent, 1000) + + var auditCount int64 + if err := db.Model(&model.AuditLog{}).Where("action = ?", "pickup_profit_update").Count(&auditCount).Error; err != nil { + t.Fatalf("统计审计日志失败: %v", err) + } + if auditCount != 0 { + t.Fatalf("审计日志数量 = %d, want 0", auditCount) + } +} + func assertInt64(t *testing.T, name string, got, want int64) { t.Helper() if got != want { t.Fatalf("%s = %d, want %d", name, got, want) } } + +func assertJSONNumber(t *testing.T, name string, got any, want float64) { + t.Helper() + value, ok := got.(float64) + if !ok || value != want { + t.Fatalf("%s = %v, want %.0f", name, got, want) + } +} + +func newPickupTestRepo(t *testing.T) (*Repository, *gorm.DB) { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("打开测试数据库失败: %v", err) + } + if err := db.AutoMigrate( + &model.User{}, + &model.GameAccount{}, + &model.RentalListing{}, + &model.AdminPickup{}, + &model.AuditLog{}, + ); err != nil { + t.Fatalf("迁移测试表失败: %v", err) + } + return NewRepository(db), db +} + +func seedPickup(t *testing.T, db *gorm.DB, status string) model.AdminPickup { + t.Helper() + owner := model.User{Phone: "18800000000"} + if err := db.Create(&owner).Error; err != nil { + t.Fatalf("创建号主失败: %v", err) + } + account := model.GameAccount{ + OwnerID: owner.ID, + ServerRegion: "测试区", + LoginPlatform: "微信", + Title: "测试账号", + Status: "rented", + } + if err := db.Create(&account).Error; err != nil { + t.Fatalf("创建账号失败: %v", err) + } + listing := model.RentalListing{ + ListingNo: "LST-PROFIT-001", + AccountID: account.ID, + OwnerID: owner.ID, + PriceCent: 10000, + Status: "rented", + ReviewStatus: "approved", + } + if err := db.Create(&listing).Error; err != nil { + t.Fatalf("创建上架记录失败: %v", err) + } + pickup := model.AdminPickup{ + PickupNo: "PK-PROFIT-001", + ListingID: listing.ID, + AccountID: account.ID, + OwnerID: owner.ID, + AdminID: 1, + ProfitAmountCent: 1000, + Status: status, + } + if err := db.Create(&pickup).Error; err != nil { + t.Fatalf("创建提号记录失败: %v", err) + } + return pickup +} diff --git a/backend/internal/modules/pickup/service.go b/backend/internal/modules/pickup/service.go index b122c8b..5aad68e 100644 --- a/backend/internal/modules/pickup/service.go +++ b/backend/internal/modules/pickup/service.go @@ -40,6 +40,19 @@ func (s *Service) Complete(ctx context.Context, pickupID uint64, req CompleteReq return s.repo.Complete(ctx, pickupID, req, adminID, meta) } +func (s *Service) UpdateProfit(ctx context.Context, pickupID uint64, req UpdateProfitRequest, adminID uint64, meta auditlog.Meta) (*PickupDTO, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if pickupID == 0 { + return nil, ErrPickupNotFound + } + if req.ProfitAmountCent < 0 { + return nil, ErrInvalidProfit + } + return s.repo.UpdateProfit(ctx, pickupID, req, adminID, meta) +} + func (s *Service) Cancel(ctx context.Context, pickupID uint64, reason string, adminID uint64, meta auditlog.Meta) error { if s.repo == nil { return ErrDependencyUnavailable diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 92dd97a..a26f8ea 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -590,6 +590,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.GET("/pickups/available-listings", requirePerm("order:pickup"), pickupHandler.AvailableListings) adminRoutes.GET("/pickups/:id", requirePerm("order:pickup"), pickupHandler.Detail) adminRoutes.POST("/pickups/:id/complete", requirePerm("order:pickup"), pickupHandler.Complete) + adminRoutes.PUT("/pickups/:id/profit", requirePerm("order:pickup"), pickupHandler.UpdateProfit) adminRoutes.POST("/pickups/:id/cancel", requirePerm("order:pickup"), pickupHandler.Cancel) adminRoutes.GET("/listings", requirePerm("listing:view"), listingHandler.ListAdmin) diff --git a/frontend/src/features/admin/api/adminPickup.ts b/frontend/src/features/admin/api/adminPickup.ts index 8d25025..feb6e5c 100644 --- a/frontend/src/features/admin/api/adminPickup.ts +++ b/frontend/src/features/admin/api/adminPickup.ts @@ -62,6 +62,11 @@ export interface AdminPickupCompleteRequest { complete_remark?: string } +export interface AdminPickupProfitUpdateRequest { + profit_amount_cent: number + reason?: string +} + export interface AdminPickupListQuery { page?: number page_size?: number @@ -119,6 +124,11 @@ export async function completeAdminPickup(id: number, req: AdminPickupCompleteRe return data.data } +export async function updateAdminPickupProfit(id: number, req: AdminPickupProfitUpdateRequest) { + const { data } = await apiClient.put>(`/admin/pickups/${id}/profit`, req) + return data.data +} + export async function cancelAdminPickup(id: number, reason: string) { const { data } = await apiClient.post>( `/admin/pickups/${id}/cancel`, diff --git a/frontend/src/features/admin/views/AdminPickupDetailView.vue b/frontend/src/features/admin/views/AdminPickupDetailView.vue index 42bdd54..8061157 100644 --- a/frontend/src/features/admin/views/AdminPickupDetailView.vue +++ b/frontend/src/features/admin/views/AdminPickupDetailView.vue @@ -1,12 +1,23 @@