diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 2def695..f5299d1 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -12,6 +12,7 @@ import ( "hfb_sys/backend/internal/config" "hfb_sys/backend/internal/database" + "hfb_sys/backend/internal/jobs/fileuploadcleanup" "hfb_sys/backend/internal/jobs/ordertimeout" "hfb_sys/backend/internal/jobs/refundretry" "hfb_sys/backend/internal/logging" @@ -91,6 +92,7 @@ func main() { defer stopJobs() if deps.DB != nil { ordertimeout.New(deps.DB, deps.Redis, logger).Start(jobCtx) + fileuploadcleanup.New(deps.DB, deps.Redis, logger).Start(jobCtx) if paymentConfigRepo := newPaymentConfigRepositoryForJobs(cfg, deps.DB, logger); paymentConfigRepo != nil { paymentRepo := payment.NewRepository(deps.DB, paymentConfigRepo, nil, payment.WithLogger(logger)) refundretry.New(deps.DB, deps.Redis, logger, paymentRepo).Start(jobCtx) diff --git a/backend/internal/jobs/fileuploadcleanup/job.go b/backend/internal/jobs/fileuploadcleanup/job.go new file mode 100644 index 0000000..08777df --- /dev/null +++ b/backend/internal/jobs/fileuploadcleanup/job.go @@ -0,0 +1,167 @@ +package fileuploadcleanup + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "net/url" + "time" + + "hfb_sys/backend/internal/model" + + "github.com/redis/go-redis/v9" + "go.uber.org/zap" + "gorm.io/gorm" +) + +const ( + cleanupLockKey = "hfb:job:file-upload-cleanup:lock" + cleanupInterval = 24 * time.Hour + cleanupRetention = 30 * 24 * time.Hour + cleanupBatchSize = 200 +) + +// Job 清理长期未关联业务记录的上传归属,避免临时草稿记录无限增长。 +type Job struct { + db *gorm.DB + redis *redis.Client + logger *zap.Logger + instanceID string +} + +func New(db *gorm.DB, redisClient *redis.Client, logger *zap.Logger) *Job { + if logger == nil { + logger = zap.NewNop() + } + return &Job{db: db, redis: redisClient, logger: logger, instanceID: newInstanceID()} +} + +func (j *Job) Start(ctx context.Context) { + if j == nil || j.db == nil { + return + } + go j.loop(ctx) +} + +func (j *Job) loop(ctx context.Context) { + j.run(ctx, time.Now()) + ticker := time.NewTicker(cleanupInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + j.logger.Debug("临时文件归属清理任务已停止") + return + case now := <-ticker.C: + j.run(ctx, now) + } + } +} + +func (j *Job) run(ctx context.Context, now time.Time) { + release, ok := j.acquireLock(ctx) + if !ok { + return + } + defer release() + deleted, err := j.cleanup(ctx, now) + if err != nil { + j.logger.Warn("临时文件归属清理失败", zap.Error(err)) + return + } + if deleted > 0 { + j.logger.Info("已清理未关联临时文件归属", zap.Int("count", deleted)) + } +} + +func (j *Job) acquireLock(ctx context.Context) (func(), bool) { + if j.redis == nil { + return func() {}, true + } + ok, err := j.redis.SetNX(ctx, cleanupLockKey, j.instanceID, 10*time.Minute).Result() + if err != nil { + j.logger.Warn("临时文件归属清理任务获取锁失败", zap.Error(err)) + return func() {}, true + } + if !ok { + return nil, false + } + return func() { + releaseCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + script := redis.NewScript(`if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`) + if err := script.Run(releaseCtx, j.redis, []string{cleanupLockKey}, j.instanceID).Err(); err != nil { + j.logger.Warn("临时文件归属清理任务释放锁失败", zap.Error(err)) + } + }, true +} + +func (j *Job) cleanup(ctx context.Context, now time.Time) (int, error) { + cutoff := now.Add(-cleanupRetention) + lastID := uint64(0) + deleted := 0 + for { + var records []model.FileUploadOwner + if err := j.db.WithContext(ctx). + Where("id > ? AND created_at < ?", lastID, cutoff). + Order("id ASC"). + Limit(cleanupBatchSize). + Find(&records).Error; err != nil { + return deleted, err + } + if len(records) == 0 { + return deleted, nil + } + for _, record := range records { + lastID = record.ID + referenced, err := j.isReferenced(ctx, record.ObjectKey) + if err != nil { + return deleted, err + } + if referenced { + continue + } + result := j.db.WithContext(ctx). + Where("id = ? AND created_at < ?", record.ID, cutoff). + Delete(&model.FileUploadOwner{}) + if result.Error != nil { + return deleted, result.Error + } + deleted += int(result.RowsAffected) + } + } +} + +func (j *Job) isReferenced(ctx context.Context, key string) (bool, error) { + encodedKey := url.QueryEscape(key) + queries := []struct { + sql string + args []any + }{ + {sql: "SELECT COUNT(1) FROM game_accounts WHERE INSTR(screenshot_urls, ?) > 0 OR INSTR(screenshot_urls, ?) > 0", args: []any{key, encodedKey}}, + {sql: "SELECT COUNT(1) FROM order_checkouts WHERE INSTR(evidence_urls, ?) > 0 OR INSTR(evidence_urls, ?) > 0", args: []any{key, encodedKey}}, + {sql: "SELECT COUNT(1) FROM disputes WHERE INSTR(evidence_urls, ?) > 0 OR INSTR(evidence_urls, ?) > 0", args: []any{key, encodedKey}}, + {sql: "SELECT COUNT(1) FROM handoff_records WHERE INSTR(attachment_urls, ?) > 0 OR INSTR(attachment_urls, ?) > 0", args: []any{key, encodedKey}}, + {sql: "SELECT COUNT(1) FROM chat_messages WHERE INSTR(attachment_urls, ?) > 0 OR INSTR(attachment_urls, ?) > 0", args: []any{key, encodedKey}}, + {sql: "SELECT COUNT(1) FROM user_payment_accounts WHERE INSTR(certificate_urls, ?) > 0 OR INSTR(certificate_urls, ?) > 0", args: []any{key, encodedKey}}, + } + for _, query := range queries { + var count int64 + if err := j.db.WithContext(ctx).Raw(query.sql, query.args...).Scan(&count).Error; err != nil { + return false, err + } + if count > 0 { + return true, nil + } + } + return false, nil +} + +func newInstanceID() string { + value := make([]byte, 8) + if _, err := rand.Read(value); err != nil { + return fmt.Sprintf("file-cleanup-%d", time.Now().UnixNano()) + } + return hex.EncodeToString(value) +} diff --git a/backend/internal/jobs/fileuploadcleanup/job_test.go b/backend/internal/jobs/fileuploadcleanup/job_test.go new file mode 100644 index 0000000..8f10a4f --- /dev/null +++ b/backend/internal/jobs/fileuploadcleanup/job_test.go @@ -0,0 +1,77 @@ +package fileuploadcleanup + +import ( + "context" + "testing" + "time" + + "hfb_sys/backend/internal/model" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func TestCleanupOnlyDeletesExpiredUnreferencedUploads(t *testing.T) { + db := openTestDB(t) + if err := db.AutoMigrate(&model.FileUploadOwner{}); err != nil { + t.Fatalf("AutoMigrate() error = %v", err) + } + createReferenceTables(t, db) + now := time.Date(2026, 8, 16, 0, 0, 0, 0, time.UTC) + old := now.Add(-31 * 24 * time.Hour) + recent := now.Add(-29 * 24 * time.Hour) + records := []model.FileUploadOwner{ + {UserID: 1, ObjectKey: "listing/orphan.jpg", CreatedAt: old}, + {UserID: 1, ObjectKey: "listing/referenced.jpg", CreatedAt: old}, + {UserID: 1, ObjectKey: "listing/recent.jpg", CreatedAt: recent}, + } + if err := db.Create(&records).Error; err != nil { + t.Fatalf("create upload owners error = %v", err) + } + if err := db.Exec("INSERT INTO game_accounts (id, screenshot_urls) VALUES (1, ?)", `["/api/files/object?key=listing%2Freferenced.jpg"]`).Error; err != nil { + t.Fatalf("create referenced account error = %v", err) + } + + job := New(db, nil, nil) + deleted, err := job.cleanup(context.Background(), now) + if err != nil { + t.Fatalf("cleanup() error = %v", err) + } + if deleted != 1 { + t.Fatalf("cleanup() deleted = %d, want 1", deleted) + } + var remaining []model.FileUploadOwner + if err := db.Order("id ASC").Find(&remaining).Error; err != nil { + t.Fatalf("load remaining uploads error = %v", err) + } + if len(remaining) != 2 || remaining[0].ObjectKey != "listing/referenced.jpg" || remaining[1].ObjectKey != "listing/recent.jpg" { + t.Fatalf("unexpected remaining uploads: %#v", remaining) + } +} + +func openTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatalf("open sqlite error = %v", err) + } + return db +} + +func createReferenceTables(t *testing.T, db *gorm.DB) { + t.Helper() + statements := []string{ + "CREATE TABLE game_accounts (id INTEGER PRIMARY KEY, screenshot_urls TEXT)", + "CREATE TABLE order_checkouts (id INTEGER PRIMARY KEY, evidence_urls TEXT)", + "CREATE TABLE disputes (id INTEGER PRIMARY KEY, evidence_urls TEXT)", + "CREATE TABLE handoff_records (id INTEGER PRIMARY KEY, attachment_urls TEXT)", + "CREATE TABLE chat_messages (id INTEGER PRIMARY KEY, attachment_urls TEXT)", + "CREATE TABLE user_payment_accounts (id INTEGER PRIMARY KEY, certificate_urls TEXT)", + } + for _, statement := range statements { + if err := db.Exec(statement).Error; err != nil { + t.Fatalf("create reference table error = %v", err) + } + } +} diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index 1ffa72f..0e08995 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -25,6 +25,7 @@ const ( ContextAuthCurrentVersion = "auth_current_token_version" ContextAuthFailureDetail = "auth_failure_detail" AdminAccessCookieName = "hfb_admin_access" + UserAccessCookieName = "hfb_user_access" ) type AdminTokenContext struct { @@ -43,16 +44,20 @@ func extractBearerToken(c *gin.Context) string { return "" } -func extractToken(c *gin.Context) string { - if tokenText := extractBearerToken(c); tokenText != "" { - return tokenText - } - return c.Query("token") -} +type UserTokenValidatorFunc func(ctx context.Context, userID uint64, tokenVersion int64) error -func Auth(jwtManager *auth.JWTManager) gin.HandlerFunc { +func Auth(jwtManager *auth.JWTManager, validators ...UserTokenValidatorFunc) gin.HandlerFunc { + var validate UserTokenValidatorFunc + if len(validators) > 0 { + validate = validators[0] + } return func(c *gin.Context) { - tokenText := extractToken(c) + tokenText := extractBearerToken(c) + if tokenText == "" { + if cookieToken, err := c.Cookie(UserAccessCookieName); err == nil { + tokenText = strings.TrimSpace(cookieToken) + } + } if tokenText == "" { response.Unauthorized(c, "缺少访问令牌") c.Abort() @@ -65,6 +70,13 @@ func Auth(jwtManager *auth.JWTManager) gin.HandlerFunc { c.Abort() return } + if validate != nil { + if err := validate(c.Request.Context(), claims.UserID, claims.TokenVersion); err != nil { + response.Unauthorized(c, "访问令牌无效或已过期") + c.Abort() + return + } + } c.Set(ContextUserID, claims.UserID) c.Set(ContextPhone, claims.Phone) diff --git a/backend/internal/middleware/auth_test.go b/backend/internal/middleware/auth_test.go new file mode 100644 index 0000000..3ab3caa --- /dev/null +++ b/backend/internal/middleware/auth_test.go @@ -0,0 +1,40 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "hfb_sys/backend/internal/modules/auth" + + "github.com/gin-gonic/gin" +) + +func TestUserAuthRejectsQueryToken(t *testing.T) { + gin.SetMode(gin.TestMode) + manager := auth.NewJWTManager("test-jwt-secret-for-query-token-rejection") + pair, err := manager.GenerateSubjectPairWithVersion(1, "13800000000", "user", 1) + if err != nil { + t.Fatalf("GenerateSubjectPairWithVersion() error = %v", err) + } + + engine := gin.New() + engine.GET("/protected", Auth(manager), func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) + + queryRequest := httptest.NewRequest(http.MethodGet, "/protected?token="+pair.AccessToken, nil) + queryResponse := httptest.NewRecorder() + engine.ServeHTTP(queryResponse, queryRequest) + if queryResponse.Code != http.StatusUnauthorized { + t.Fatalf("query token status = %d, want %d", queryResponse.Code, http.StatusUnauthorized) + } + + bearerRequest := httptest.NewRequest(http.MethodGet, "/protected", nil) + bearerRequest.Header.Set("Authorization", "Bearer "+pair.AccessToken) + bearerResponse := httptest.NewRecorder() + engine.ServeHTTP(bearerResponse, bearerRequest) + if bearerResponse.Code != http.StatusNoContent { + t.Fatalf("bearer token status = %d, want %d", bearerResponse.Code, http.StatusNoContent) + } +} diff --git a/backend/internal/model/file_upload.go b/backend/internal/model/file_upload.go new file mode 100644 index 0000000..caa5f5a --- /dev/null +++ b/backend/internal/model/file_upload.go @@ -0,0 +1,15 @@ +package model + +import "time" + +// FileUploadOwner 记录用户上传的私有文件归属,用于业务关联创建前的临时访问授权。 +type FileUploadOwner struct { + ID uint64 `gorm:"primaryKey"` + UserID uint64 `gorm:"not null;index"` + ObjectKey string `gorm:"size:512;not null;uniqueIndex"` + CreatedAt time.Time `json:"created_at"` +} + +func (FileUploadOwner) TableName() string { + return "file_upload_owners" +} diff --git a/backend/internal/model/user.go b/backend/internal/model/user.go index 96d9407..08e8e64 100644 --- a/backend/internal/model/user.go +++ b/backend/internal/model/user.go @@ -15,6 +15,7 @@ type User struct { RenterGrowthPoints int64 `gorm:"not null;default:0;index:idx_users_renter_growth_level,priority:2" json:"renter_growth_points"` RenterGrowthLevel string `gorm:"size:32;not null;default:'normal';index:idx_users_renter_growth_level,priority:1" json:"renter_growth_level"` Status string `gorm:"size:32;not null;default:'active'" json:"status"` + TokenVersion int64 `gorm:"not null;default:1" json:"-"` LastLoginAt *time.Time `json:"last_login_at"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/backend/internal/modules/adminuser/repository.go b/backend/internal/modules/adminuser/repository.go index 5ced310..2ea666d 100644 --- a/backend/internal/modules/adminuser/repository.go +++ b/backend/internal/modules/adminuser/repository.go @@ -216,6 +216,10 @@ func (r *Repository) updateStatus(ctx context.Context, adminID uint64, userID ui beforeRisk := user.RiskStatus user.Status = status user.RiskStatus = riskStatus + if user.TokenVersion < 1 { + user.TokenVersion = 1 + } + user.TokenVersion++ if err := tx.Save(&user).Error; err != nil { return err } diff --git a/backend/internal/modules/auth/handler.go b/backend/internal/modules/auth/handler.go index 2c2b908..93a1880 100644 --- a/backend/internal/modules/auth/handler.go +++ b/backend/internal/modules/auth/handler.go @@ -111,6 +111,7 @@ func (h *Handler) Login(c *gin.Context) { writeAuthError(c, err) return } + setAccessCookie(c, result.Tokens.AccessToken) response.OK(c, result) } @@ -131,11 +132,12 @@ func (h *Handler) Refresh(c *gin.Context) { response.BadRequest(c, "refresh_token 不能为空") return } - tokens, err := h.service.RefreshToken(req.RefreshToken) + tokens, err := h.service.RefreshToken(c.Request.Context(), req.RefreshToken) if err != nil { response.Unauthorized(c, "刷新令牌无效或已过期") return } + setAccessCookie(c, tokens.AccessToken) response.OK(c, tokens) } @@ -149,6 +151,16 @@ func (h *Handler) Refresh(c *gin.Context) { // @Security BearerAuth // @Router /auth/logout [post] func (h *Handler) Logout(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + if err := h.service.Logout(c.Request.Context(), userID); err != nil { + writeAuthError(c, err) + return + } + clearAccessCookie(c) response.OK(c, gin.H{"logged_out": true}) } @@ -163,6 +175,7 @@ func (h *Handler) PasswordLogin(c *gin.Context) { writeAuthError(c, err) return } + setAccessCookie(c, result.Tokens.AccessToken) response.OK(c, result) } @@ -177,6 +190,7 @@ func (h *Handler) Register(c *gin.Context) { writeAuthError(c, err) return } + setAccessCookie(c, result.Tokens.AccessToken) response.OK(c, result) } @@ -253,3 +267,17 @@ func currentUserID(c *gin.Context) (uint64, bool) { userID, ok := val.(uint64) return userID, ok } + +func setAccessCookie(c *gin.Context, token string) { + c.SetSameSite(http.SameSiteLaxMode) + c.SetCookie("hfb_user_access", token, 2*60*60, "/api", "", requestUsesHTTPS(c), true) +} + +func clearAccessCookie(c *gin.Context) { + c.SetSameSite(http.SameSiteLaxMode) + c.SetCookie("hfb_user_access", "", -1, "/api", "", requestUsesHTTPS(c), true) +} + +func requestUsesHTTPS(c *gin.Context) bool { + return c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") +} diff --git a/backend/internal/modules/auth/repository.go b/backend/internal/modules/auth/repository.go index f0d8721..33b3986 100644 --- a/backend/internal/modules/auth/repository.go +++ b/backend/internal/modules/auth/repository.go @@ -27,6 +27,21 @@ func (r *UserRepository) FindByID(ctx context.Context, id uint64) (*model.User, return &user, nil } +// FindActiveForToken 校验用户仍可用且令牌版本未被撤销。 +func (r *UserRepository) FindActiveForToken(ctx context.Context, id uint64, tokenVersion int64) (*model.User, error) { + if r == nil || r.db == nil { + return nil, ErrDependencyUnavailable + } + user, err := r.FindByID(ctx, id) + if err != nil { + return nil, err + } + if user.Status != "active" || user.TokenVersion <= 0 || user.TokenVersion != tokenVersion { + return nil, ErrUserDisabled + } + return user, nil +} + func (r *UserRepository) UpdateProfile(ctx context.Context, id uint64, nickname string, avatarURL string) (*model.User, error) { if err := r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Updates(map[string]any{ "nickname": nickname, @@ -46,6 +61,7 @@ func (r *UserRepository) FindOrCreateByPhone(ctx context.Context, phone string) RiskStatus: "normal", CreditScore: 100, Status: "active", + TokenVersion: 1, LastLoginAt: &now, } @@ -73,7 +89,16 @@ func (r *UserRepository) FindByPhone(ctx context.Context, phone string) (*model. } func (r *UserRepository) SetPassword(ctx context.Context, userID uint64, hash string) error { - return r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).Update("password_hash", hash).Error + return r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).Updates(map[string]any{ + "password_hash": hash, + "token_version": gorm.Expr("token_version + 1"), + }).Error +} + +// RevokeTokens 使当前用户的所有 access/refresh token 立即失效。 +func (r *UserRepository) RevokeTokens(ctx context.Context, userID uint64) error { + return r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID). + UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error } func (r *UserRepository) RegisterWithPassword(ctx context.Context, phone string, hash string) (*model.User, error) { @@ -86,6 +111,7 @@ func (r *UserRepository) RegisterWithPassword(ctx context.Context, phone string, RiskStatus: "normal", CreditScore: 100, Status: "active", + TokenVersion: 1, LastLoginAt: &now, } diff --git a/backend/internal/modules/auth/service.go b/backend/internal/modules/auth/service.go index 0d362c0..d0f250c 100644 --- a/backend/internal/modules/auth/service.go +++ b/backend/internal/modules/auth/service.go @@ -180,7 +180,7 @@ func (s *Service) LoginWithSMS(ctx context.Context, phone string, code string) ( return LoginResult{}, ErrUserDisabled } - tokens, err := s.jwt.GeneratePair(user.ID, user.Phone) + tokens, err := s.issueTokenPair(user) if err != nil { return LoginResult{}, err } @@ -189,12 +189,30 @@ func (s *Service) LoginWithSMS(ctx context.Context, phone string, code string) ( return LoginResult{User: user, Tokens: tokens}, nil } -func (s *Service) RefreshToken(refreshToken string) (TokenPair, error) { +func (s *Service) RefreshToken(ctx context.Context, refreshToken string) (TokenPair, error) { + if s.users == nil { + return TokenPair{}, ErrDependencyUnavailable + } claims, err := s.jwt.ParseSubject(refreshToken, tokenTypeRefresh, "user") if err != nil { return TokenPair{}, err } - return s.jwt.GeneratePair(claims.UserID, claims.Phone) + user, err := s.users.FindActiveForToken(ctx, claims.UserID, claims.TokenVersion) + if err != nil { + return TokenPair{}, err + } + return s.issueTokenPair(user) +} + +func (s *Service) issueTokenPair(user *model.User) (TokenPair, error) { + return s.jwt.GenerateSubjectPairWithVersion(user.ID, user.Phone, "user", user.TokenVersion) +} + +func (s *Service) Logout(ctx context.Context, userID uint64) error { + if s.users == nil { + return ErrDependencyUnavailable + } + return s.users.RevokeTokens(ctx, userID) } func codeKey(phone string) string { @@ -264,7 +282,7 @@ func (s *Service) LoginWithPassword(ctx context.Context, phone, password, client _ = clearLoginFailure(ctx, s.redis, phone, clientIP) - tokens, err := s.jwt.GeneratePair(user.ID, user.Phone) + tokens, err := s.issueTokenPair(user) if err != nil { return LoginResult{}, err } @@ -369,7 +387,7 @@ func (s *Service) RegisterWithPassword(ctx context.Context, phone, code, passwor return LoginResult{}, ErrUserDisabled } - tokens, err := s.jwt.GeneratePair(user.ID, user.Phone) + tokens, err := s.issueTokenPair(user) if err != nil { return LoginResult{}, err } diff --git a/backend/internal/modules/file/dto.go b/backend/internal/modules/file/dto.go index df0bf60..a7fb484 100644 --- a/backend/internal/modules/file/dto.go +++ b/backend/internal/modules/file/dto.go @@ -8,4 +8,13 @@ type UploadDTO struct { Filename string `json:"filename"` ContentType string `json:"content_type"` Size int64 `json:"size"` + objectKeys []string +} + +// ObjectKeys 返回上传产生的全部对象键,包含图片缩略图与中图变体。 +func (d *UploadDTO) ObjectKeys() []string { + if d == nil { + return nil + } + return append([]string(nil), d.objectKeys...) } diff --git a/backend/internal/modules/file/handler.go b/backend/internal/modules/file/handler.go index a5242f5..4a13220 100644 --- a/backend/internal/modules/file/handler.go +++ b/backend/internal/modules/file/handler.go @@ -1,22 +1,38 @@ package file import ( + "context" "errors" "net/http" "strings" + "hfb_sys/backend/internal/logging" "hfb_sys/backend/pkg/response" "github.com/gin-gonic/gin" + "go.uber.org/zap" ) type Handler struct { - service *Service - storage *Storage + service *Service + storage *Storage + objectAuthorizer ObjectAuthorizer + uploadOwnerRecorder UploadOwnerRecorder } -func NewHandler(service *Service, storage *Storage) *Handler { - return &Handler{service: service, storage: storage} +// ObjectAuthorizer 校验用户是否可读取指定私有对象。 +type ObjectAuthorizer func(ctx context.Context, userID uint64, key string) (bool, error) + +// UploadOwnerRecorder 保存用户上传私有文件的归属。 +type UploadOwnerRecorder func(ctx context.Context, userID uint64, objectKeys []string) error + +func NewHandler(service *Service, storage *Storage, objectAuthorizer ObjectAuthorizer, uploadOwnerRecorder UploadOwnerRecorder) *Handler { + return &Handler{ + service: service, + storage: storage, + objectAuthorizer: objectAuthorizer, + uploadOwnerRecorder: uploadOwnerRecorder, + } } func (h *Handler) Upload(c *gin.Context) { @@ -44,6 +60,19 @@ func (h *Handler) Upload(c *gin.Context) { writeFileError(c, err) return } + if userID, ok := c.Get("user_id"); ok && h.uploadOwnerRecorder != nil { + id, valid := userID.(uint64) + if !valid || h.uploadOwnerRecorder(c.Request.Context(), id, item.ObjectKeys()) != nil { + // 归属记录失败时补偿删除刚写入的对象,避免用户重试产生孤儿文件。 + if h.storage != nil { + if removeErr := h.storage.RemoveObjects(c.Request.Context(), item.ObjectKeys()); removeErr != nil { + logging.FromContext(c.Request.Context()).Warn("补偿删除上传对象失败", zap.Error(removeErr)) + } + } + response.ServiceUnavailable(c, "文件归属记录失败") + return + } + } response.Created(c, item) } @@ -68,7 +97,6 @@ func (h *Handler) writeObject(c *gin.Context, publicOnly bool) { if publicOnly && !strings.HasPrefix(key, "home-banner/") && !strings.HasPrefix(key, "avatar/") && - !strings.HasPrefix(key, "payment-cert/") && !strings.HasPrefix(key, "announcement/") && !strings.HasPrefix(key, "mohong/") && !strings.HasPrefix(key, "crash/") && @@ -77,6 +105,29 @@ func (h *Handler) writeObject(c *gin.Context, publicOnly bool) { response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问") return } + if !publicOnly { + if _, isAdmin := c.Get("admin_id"); !isAdmin { + userID, ok := c.Get("user_id") + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + id, ok := userID.(uint64) + if !ok || h.objectAuthorizer == nil { + response.Error(c, http.StatusForbidden, "forbidden", "无权访问该文件") + return + } + allowed, err := h.objectAuthorizer(c.Request.Context(), id, key) + if err != nil { + response.ServiceUnavailable(c, "文件权限校验失败") + return + } + if !allowed { + response.Error(c, http.StatusForbidden, "forbidden", "无权访问该文件") + return + } + } + } object, err := h.storage.Get(c.Request.Context(), key) if err != nil { response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问") diff --git a/backend/internal/modules/file/service.go b/backend/internal/modules/file/service.go index b0f58e6..181d84f 100644 --- a/backend/internal/modules/file/service.go +++ b/backend/internal/modules/file/service.go @@ -61,6 +61,7 @@ func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) { } var thumbnailURL string var mediumURL string + objectKeys := []string{key} for _, variant := range generateImageVariants(key, data, contentType) { err := s.storage.PutObject(req.Context, variant.Key, bytes.NewReader(variant.Content), int64(len(variant.Content)), variant.ContentType, map[string]string{ "source-object": key, @@ -68,6 +69,7 @@ func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) { if err != nil { continue } + objectKeys = append(objectKeys, variant.Key) if strings.Contains(variant.Key, "."+ImageVariantThumb+".") { thumbnailURL = fileURLForScene(scene, variant.Key) } @@ -84,6 +86,7 @@ func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) { Filename: req.Header.Filename, ContentType: contentType, Size: int64(len(data)), + objectKeys: objectKeys, }, nil } @@ -106,7 +109,7 @@ func normalizeContentType(contentType string, data []byte) string { func fileURLForScene(scene string, key string) string { fileURL := "/api/files/object?key=" + url.QueryEscape(key) - if scene == "home-banner" || scene == "avatar" || scene == "payment-cert" || scene == "announcement" || scene == "mohong" || scene == "crash" || scene == "aw-recycle" || scene == "cooperation-feedback" { + if scene == "home-banner" || scene == "avatar" || scene == "announcement" || scene == "mohong" || scene == "crash" || scene == "aw-recycle" || scene == "cooperation-feedback" { fileURL = "/api/public/files/object?key=" + url.QueryEscape(key) } return fileURL diff --git a/backend/internal/modules/file/storage.go b/backend/internal/modules/file/storage.go index 93d5fa1..322aeaa 100644 --- a/backend/internal/modules/file/storage.go +++ b/backend/internal/modules/file/storage.go @@ -157,6 +157,36 @@ func (s *Storage) Get(ctx context.Context, key string) (*Object, error) { }, nil } +// RemoveObjects 删除主存储与镜像中的对象,返回第一个错误;对象不存在视为成功。 +func (s *Storage) RemoveObjects(ctx context.Context, keys []string) error { + var firstErr error + for _, key := range keys { + if key == "" { + continue + } + if err := s.removeObject(ctx, key); err != nil && firstErr == nil { + firstErr = err + } + if s.mirror != nil { + if err := s.mirror.removeObject(ctx, key); err != nil && firstErr == nil { + firstErr = err + } + } + } + return firstErr +} + +func (s *Storage) removeObject(ctx context.Context, key string) error { + if err := s.ensureBucketReady(ctx); err != nil { + return err + } + err := s.client.RemoveObject(ctx, s.bucket, key, minio.RemoveObjectOptions{}) + if minio.ToErrorResponse(err).Code == "NoSuchKey" { + return nil + } + return err +} + func (s *Storage) ensureBucket(ctx context.Context) error { exists, err := s.client.BucketExists(ctx, s.bucket) if err != nil { diff --git a/backend/internal/modules/listing/dto.go b/backend/internal/modules/listing/dto.go index 6358e57..09c3cbb 100644 --- a/backend/internal/modules/listing/dto.go +++ b/backend/internal/modules/listing/dto.go @@ -43,6 +43,33 @@ type ListingDTO struct { UpdatedAt time.Time `json:"updated_at"` } +// PublicListingListItemDTO 是首页商品卡片的最小公开数据。 +// 号主身份、账号内部 ID、审核和结算字段仅限号主或后台接口返回。 +type PublicListingListItemDTO struct { + ID uint64 `json:"id"` + ListingNo string `json:"listing_no"` + Title string `json:"title"` + GameName string `json:"game_name"` + ServerRegion string `json:"server_region"` + LoginPlatform string `json:"login_platform"` + RankLevel string `json:"rank_level"` + HafCoinAmount int64 `json:"haf_coin_amount"` + AssetSummary map[string]any `json:"asset_summary,omitempty"` + CoverURL string `json:"cover_url"` + PriceCent int64 `json:"price_cent"` + DepositAmountCent int64 `json:"deposit_amount_cent"` + IsAccelerated bool `json:"is_accelerated_sale"` + PublishedAt *time.Time `json:"published_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// PublicListingDetailDTO 是公开商品详情数据,不包含号主身份或运营内部状态。 +type PublicListingDetailDTO struct { + PublicListingListItemDTO + Description string `json:"description"` + ScreenshotURLS []string `json:"screenshot_urls"` +} + type CreateRequest struct { Title string `json:"title" binding:"required"` Description string `json:"description"` @@ -159,11 +186,11 @@ type NumberRange struct { } type PublicListResult struct { - Items []ListingDTO `json:"items"` - Total int64 `json:"total"` - Page int `json:"page"` - PageSize int `json:"page_size"` - ZoneCounts map[string]int64 `json:"zone_counts"` + Items []PublicListingListItemDTO `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + ZoneCounts map[string]int64 `json:"zone_counts"` } type AdminActionRequest struct { diff --git a/backend/internal/modules/listing/presenter.go b/backend/internal/modules/listing/presenter.go index ba47c6e..10efd7a 100644 --- a/backend/internal/modules/listing/presenter.go +++ b/backend/internal/modules/listing/presenter.go @@ -71,9 +71,100 @@ func applyPublicListingURLs(item *ListingDTO) { if item.AssetSummary != nil { delete(item.AssetSummary, "import_meta") delete(item.AssetSummary, "price_breakdown") + // 截图分组可能保留原始对象 URL,公开详情统一使用受控图片接口。 + delete(item.AssetSummary, "screenshot_groups") } } +func publicListingListItems(items []ListingDTO) []PublicListingListItemDTO { + result := make([]PublicListingListItemDTO, 0, len(items)) + for _, item := range items { + result = append(result, item.toPublicListItem()) + } + return result +} + +func (item ListingDTO) toPublicListItem() PublicListingListItemDTO { + return PublicListingListItemDTO{ + ID: item.ID, + ListingNo: item.ListingNo, + Title: item.Title, + GameName: item.GameName, + ServerRegion: item.ServerRegion, + LoginPlatform: item.LoginPlatform, + RankLevel: item.RankLevel, + HafCoinAmount: item.HafCoinAmount, + AssetSummary: publicListAssetSummary(item.AssetSummary), + CoverURL: item.CoverURL, + PriceCent: item.PriceCent, + DepositAmountCent: item.DepositAmountCent, + IsAccelerated: item.IsAccelerated, + PublishedAt: item.PublishedAt, + CreatedAt: item.CreatedAt, + } +} + +func (item ListingDTO) toPublicDetail() PublicListingDetailDTO { + listItem := item.toPublicListItem() + listItem.AssetSummary = publicDetailAssetSummary(item.AssetSummary) + return PublicListingDetailDTO{ + PublicListingListItemDTO: listItem, + Description: item.Description, + ScreenshotURLS: item.ScreenshotURLS, + } +} + +// publicListAssetSummary 仅保留首页卡片和筛选展示所需资产字段。 +func publicListAssetSummary(summary map[string]any) map[string]any { + return selectPublicAssetSummary(summary, []string{ + "season_insurance", + "stamina_level", + "load_level", + "resources", + "skin_groups", + "total_asset_wan", + "online_time", + "can_change_game_name", + "all_hero", + "daily_loss_m", + "publish_ratio", + }) +} + +// publicDetailAssetSummary 仅保留公开详情明确展示的资产字段。 +func publicDetailAssetSummary(summary map[string]any) map[string]any { + return selectPublicAssetSummary(summary, []string{ + "season_insurance", + "stamina_level", + "load_level", + "resources", + "skin_groups", + "total_asset_wan", + "online_time", + "can_change_game_name", + "all_hero", + "daily_loss_m", + "publish_ratio", + "secret_kd", + "fire_level", + "common_regions", + "ban_record", + }) +} + +func selectPublicAssetSummary(summary map[string]any, keys []string) map[string]any { + if len(summary) == 0 { + return nil + } + result := make(map[string]any, len(keys)) + for _, key := range keys { + if value, ok := summary[key]; ok { + result[key] = value + } + } + return result +} + func applySellerListingPrice(item *ListingDTO) { if item == nil { return diff --git a/backend/internal/modules/listing/public_query.go b/backend/internal/modules/listing/public_query.go index 69501cf..3f21ced 100644 --- a/backend/internal/modules/listing/public_query.go +++ b/backend/internal/modules/listing/public_query.go @@ -48,7 +48,7 @@ func (r *Repository) ListPublic(ctx context.Context, query PublicListQuery) (*Pu items = items[start:end] } return &PublicListResult{ - Items: items, + Items: publicListingListItems(items), Total: total, Page: page, PageSize: pageSize, @@ -79,7 +79,7 @@ func (r *Repository) listPublicPage(ctx context.Context, query PublicListQuery, return nil, err } return &PublicListResult{ - Items: publicListings(rowsToDTO(rows)), + Items: publicListingListItems(publicListings(rowsToDTO(rows))), Total: total, Page: page, PageSize: pageSize, @@ -219,13 +219,14 @@ func copyPublicZoneCounts(counts map[string]int64) map[string]int64 { return copied } -func (r *Repository) FindPublic(ctx context.Context, id uint64) (*ListingDTO, error) { +func (r *Repository) FindPublic(ctx context.Context, id uint64) (*PublicListingDetailDTO, error) { dto, err := r.findDTO(ctx, "l.id = ? AND l.status = ? AND l.review_status = ? AND l.in_transaction = ?", id, "published", "approved", false) if err != nil { return nil, err } applyPublicListingURLs(dto) - return dto, nil + item := dto.toPublicDetail() + return &item, nil } func (r *Repository) FindPublicCoverKey(ctx context.Context, id uint64) (string, error) { diff --git a/backend/internal/modules/listing/service_query.go b/backend/internal/modules/listing/service_query.go index 92c7bed..3321caa 100644 --- a/backend/internal/modules/listing/service_query.go +++ b/backend/internal/modules/listing/service_query.go @@ -18,7 +18,7 @@ func (s *Service) ListMine(ctx context.Context, ownerID uint64) ([]ListingDTO, e return s.repo.ListMine(ctx, ownerID) } -func (s *Service) FindPublic(ctx context.Context, id uint64) (*ListingDTO, error) { +func (s *Service) FindPublic(ctx context.Context, id uint64) (*PublicListingDetailDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } diff --git a/backend/internal/modules/listing/service_test.go b/backend/internal/modules/listing/service_test.go index 19e8b01..d26e995 100644 --- a/backend/internal/modules/listing/service_test.go +++ b/backend/internal/modules/listing/service_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "strings" "testing" "time" @@ -329,8 +330,9 @@ func TestApplyPublicListingURLsHidesSourceChannel(t *testing.T) { "https://example.com/account.png", }, AssetSummary: map[string]any{ - "import_meta": map[string]any{"uploader_name": "客服1"}, - "price_breakdown": map[string]any{"buyer_total_price": 100}, + "import_meta": map[string]any{"uploader_name": "客服1"}, + "price_breakdown": map[string]any{"buyer_total_price": 100}, + "screenshot_groups": map[string]any{"coin": []string{"https://example.com/account.png"}}, }, } @@ -345,6 +347,78 @@ func TestApplyPublicListingURLsHidesSourceChannel(t *testing.T) { if _, ok := item.AssetSummary["price_breakdown"]; ok { t.Fatal("expected price_breakdown hidden from public listing") } + if _, ok := item.AssetSummary["screenshot_groups"]; ok { + t.Fatal("expected screenshot_groups hidden from public listing") + } +} + +func TestPublicListingDTOsDoNotSerializeSensitiveFields(t *testing.T) { + item := ListingDTO{ + ID: 1, + ListingNo: "SP000001", + AccountID: 2, + OwnerID: 3, + OwnerPhone: "13800001234", + OwnerNickname: "号主", + SourceChannel: "外部渠道", + IsExternalUpload: true, + Title: "测试账号", + Description: "公开描述", + ScreenshotURLS: []string{"/api/listings/1/screenshots/0"}, + Status: "published", + ReviewStatus: "approved", + HandoffMode: "platform", + SettlementMode: "platform_managed", + ManagedAdminID: uint64Pointer(4), + ReviewReason: "内部审核备注", + ListingGroupConversationID: 5, + AssetSummary: map[string]any{ + "season_insurance": "3*3", + "contact_phone": "13900005678", + "remark": "首页不应携带", + }, + } + + listRaw, err := json.Marshal(PublicListResult{Items: publicListingListItems([]ListingDTO{item})}) + if err != nil { + t.Fatalf("marshal public list error = %v", err) + } + detailRaw, err := json.Marshal(item.toPublicDetail()) + if err != nil { + t.Fatalf("marshal public detail error = %v", err) + } + for _, field := range []string{ + "account_id", + "owner_id", + "owner_phone", + "owner_nickname", + "source_channel", + "is_external_upload", + "status", + "review_status", + "handoff_mode", + "settlement_mode", + "managed_admin_id", + "review_reason", + "listing_group_conversation_id", + } { + if strings.Contains(string(listRaw), field) || strings.Contains(string(detailRaw), field) { + t.Fatalf("public response contains sensitive field %q", field) + } + } + if strings.Contains(string(listRaw), "description") || strings.Contains(string(listRaw), "screenshot_urls") { + t.Fatalf("public list contains detail-only fields: %s", listRaw) + } + if strings.Contains(string(listRaw), "contact_phone") || strings.Contains(string(listRaw), "首页不应携带") { + t.Fatalf("public list contains non-display asset fields: %s", listRaw) + } + if strings.Contains(string(detailRaw), "contact_phone") { + t.Fatalf("public detail contains non-public asset field: %s", detailRaw) + } +} + +func uint64Pointer(value uint64) *uint64 { + return &value } func TestParseExternalUploadItemsAcceptsSingleObject(t *testing.T) { diff --git a/backend/internal/modules/order/dto.go b/backend/internal/modules/order/dto.go index 067deff..7583b34 100644 --- a/backend/internal/modules/order/dto.go +++ b/backend/internal/modules/order/dto.go @@ -77,6 +77,46 @@ type OrderDTO struct { UpdatedAt time.Time `json:"updated_at"` } +// UserOrderListItemDTO 是“我的订单”列表的最小展示数据,详情数据仅由单订单接口返回。 +type UserOrderListItemDTO struct { + ID uint64 `json:"id"` + OrderNo string `json:"order_no"` + ListingID uint64 `json:"listing_id"` + ListingNo string `json:"listing_no"` + Role string `json:"role"` + Title string `json:"title"` + ServerRegion string `json:"server_region"` + LoginPlatform string `json:"login_platform"` + DisplayAmountCent int64 `json:"display_amount_cent"` + DepositAmountCent int64 `json:"deposit_amount_cent"` + DepositWaivedAmountCent int64 `json:"deposit_waived_amount_cent"` + Status string `json:"status"` + HandoffStatus string `json:"handoff_status"` + PaymentDeadlineAt *time.Time `json:"payment_deadline_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// AdminOrderListItemDTO 是后台订单表格的最小展示数据,敏感详情仅由详情接口按权限获取。 +type AdminOrderListItemDTO struct { + ID uint64 `json:"id"` + OrderNo string `json:"order_no"` + ListingID uint64 `json:"listing_id"` + ListingNo string `json:"listing_no"` + OwnerPhone string `json:"owner_phone,omitempty"` + RenterPhone string `json:"renter_phone,omitempty"` + Title string `json:"title"` + ServerRegion string `json:"server_region"` + LoginPlatform string `json:"login_platform"` + RentAmountCent int64 `json:"rent_amount_cent"` + DepositAmountCent int64 `json:"deposit_amount_cent"` + Status string `json:"status"` + HandoffStatus string `json:"handoff_status"` + HandoffMode string `json:"handoff_mode"` + SettlementMode string `json:"settlement_mode"` + SettlementStatus string `json:"settlement_status"` + CreatedAt time.Time `json:"created_at"` +} + type AdminActionsDTO struct { ResetHandoff *AdminActionDTO `json:"reset_handoff,omitempty"` PlatformHandoff *AdminActionDTO `json:"platform_handoff,omitempty"` @@ -169,6 +209,45 @@ type PaginatedResult struct { PageSize int `json:"page_size"` } +// UserPaginatedResult 为用户订单列表提供受限分页,避免一次导出全部历史订单。 +type UserPaginatedResult struct { + Items []UserOrderListItemDTO `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +// SellerHandoffQuery 是号主待办列表的受限查询条件。 +type SellerHandoffQuery struct { + Page int + PageSize int + Status string +} + +// SellerHandoffMetrics 为号主待办页提供跨分页的状态统计。 +type SellerHandoffMetrics struct { + PendingHandoff int64 `json:"pending_handoff"` + PendingCheckout int64 `json:"pending_checkout"` + Abnormal int64 `json:"abnormal"` +} + +// SellerHandoffPaginatedResult 只包含当前用户作为号主时需要处理的订单。 +type SellerHandoffPaginatedResult struct { + Items []UserOrderListItemDTO `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Metrics SellerHandoffMetrics `json:"metrics"` +} + +// AdminOrderListPaginatedResult 使用列表专用 DTO,避免后台首页携带订单敏感详情。 +type AdminOrderListPaginatedResult struct { + Items []AdminOrderListItemDTO `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + type RefundStatusDTO struct { OrderID uint64 `json:"order_id"` OrderNo string `json:"order_no"` diff --git a/backend/internal/modules/order/handler_error.go b/backend/internal/modules/order/handler_error.go index f8a8c02..d6cc0be 100644 --- a/backend/internal/modules/order/handler_error.go +++ b/backend/internal/modules/order/handler_error.go @@ -16,6 +16,8 @@ func writeOrderError(c *gin.Context, err error) { response.ServiceUnavailable(c, "数据库未连接") case errors.Is(err, ErrInvalidRentHours): response.BadRequest(c, "订单信息不符合规则") + case errors.Is(err, ErrInvalidSellerHandoffStatus): + response.BadRequest(c, "待办状态不正确") case errors.Is(err, ErrListingUnavailable): response.Error(c, http.StatusConflict, "listing_unavailable", "该账号暂不可租") case errors.Is(err, ErrCannotRentOwnListing): diff --git a/backend/internal/modules/order/handler_user.go b/backend/internal/modules/order/handler_user.go index 71d915b..747a718 100644 --- a/backend/internal/modules/order/handler_user.go +++ b/backend/internal/modules/order/handler_user.go @@ -31,12 +31,32 @@ func (h *Handler) List(c *gin.Context) { response.Unauthorized(c, "缺少用户上下文") return } - items, err := h.service.ListForUser(c.Request.Context(), userID) + page, pageSize := parsePagination(c) + items, err := h.service.ListForUser(c.Request.Context(), userID, page, pageSize) if err != nil { writeOrderError(c, err) return } - response.OK(c, gin.H{"items": items}) + response.OK(c, items) +} + +func (h *Handler) ListSellerHandoffs(c *gin.Context) { + userID, ok := currentUserID(c) + if !ok { + response.Unauthorized(c, "缺少用户上下文") + return + } + page, pageSize := parsePagination(c) + items, err := h.service.ListSellerHandoffs(c.Request.Context(), userID, SellerHandoffQuery{ + Page: page, + PageSize: pageSize, + Status: c.Query("status"), + }) + if err != nil { + writeOrderError(c, err) + return + } + response.OK(c, items) } func (h *Handler) Detail(c *gin.Context) { diff --git a/backend/internal/modules/order/presenter.go b/backend/internal/modules/order/presenter.go index 3c3108f..0cc00f3 100644 --- a/backend/internal/modules/order/presenter.go +++ b/backend/internal/modules/order/presenter.go @@ -2,6 +2,7 @@ package order import ( "encoding/json" + "time" "hfb_sys/backend/internal/model" @@ -150,6 +151,63 @@ func (row orderRow) toDTOForUser(userID uint64) OrderDTO { return dto } +func (row orderRow) toUserListItem(userID uint64, paymentDeadlineAt *time.Time) UserOrderListItemDTO { + role := "renter" + if userID == row.OwnerID { + role = "owner" + } + displayAmountCent := row.RentAmountCent + if role == "owner" && row.OwnerRentAmountCent > 0 { + displayAmountCent = row.OwnerRentAmountCent + } + return UserOrderListItemDTO{ + ID: row.ID, + OrderNo: row.OrderNo, + ListingID: row.ListingID, + ListingNo: row.ListingNo, + Role: role, + Title: row.Title, + ServerRegion: row.ServerRegion, + LoginPlatform: row.LoginPlatform, + DisplayAmountCent: displayAmountCent, + DepositAmountCent: row.DepositAmountCent, + DepositWaivedAmountCent: row.DepositWaivedAmountCent, + Status: row.Status, + HandoffStatus: row.HandoffStatus, + PaymentDeadlineAt: paymentDeadlineAt, + CreatedAt: row.CreatedAt, + } +} + +func (row orderRow) toAdminListItem() AdminOrderListItemDTO { + return AdminOrderListItemDTO{ + ID: row.ID, + OrderNo: row.OrderNo, + ListingID: row.ListingID, + ListingNo: row.ListingNo, + OwnerPhone: maskPhone(row.OwnerPhone), + RenterPhone: maskPhone(row.RenterPhone), + Title: row.Title, + ServerRegion: row.ServerRegion, + LoginPlatform: row.LoginPlatform, + RentAmountCent: row.RentAmountCent, + DepositAmountCent: row.DepositAmountCent, + Status: row.Status, + HandoffStatus: row.HandoffStatus, + HandoffMode: effectiveHandoffMode(row.RentalOrder), + SettlementMode: effectiveSettlementMode(row.RentalOrder), + SettlementStatus: row.SettlementStatus, + CreatedAt: row.CreatedAt, + } +} + +func maskPhone(phone string) string { + if len(phone) < 7 { + return "" + } + return phone[:3] + "****" + phone[len(phone)-4:] +} + func effectiveHandoffMode(order model.RentalOrder) string { if order.HandoffMode != "" { return order.HandoffMode diff --git a/backend/internal/modules/order/presenter_test.go b/backend/internal/modules/order/presenter_test.go index 7313d6a..4b4b558 100644 --- a/backend/internal/modules/order/presenter_test.go +++ b/backend/internal/modules/order/presenter_test.go @@ -1,6 +1,8 @@ package order import ( + "encoding/json" + "strings" "testing" "time" @@ -39,3 +41,52 @@ func TestOrderDTOForUserHidesDepositHoldFields(t *testing.T) { t.Fatalf("deposit hold released at = %v, want nil", dto.DepositHoldReleasedAt) } } + +func TestUserOrderListItemDoesNotSerializeDetailFields(t *testing.T) { + row := orderRow{ + RentalOrder: model.RentalOrder{ + ID: 1, + OrderNo: "ORD-001", + ListingID: 2, + OwnerID: 10, + RenterID: 20, + RentAmountCent: 1000, + DepositAmountCent: 2000, + DepositHoldReason: "风控复核", + DepositFreeManualQuotaCent: 5000, + Status: orderStatusPendingPayment, + }, + ListingNo: "SP000002", + Title: "测试账号", + } + + raw, err := json.Marshal(row.toUserListItem(20, nil)) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + text := string(raw) + for _, field := range []string{"account_snapshot", "deposit_hold_reason", "deposit_free_manual_quota_cent", "owner_id", "renter_id"} { + if strings.Contains(text, field) { + t.Fatalf("list response contains sensitive field %q: %s", field, text) + } + } +} + +func TestAdminOrderListItemMasksPhonesAndOmitsSnapshot(t *testing.T) { + row := orderRow{ + RentalOrder: model.RentalOrder{ID: 1, OrderNo: "ORD-001", Status: orderStatusPendingPayment}, + OwnerPhone: "13800001234", + RenterPhone: "13900005678", + } + raw, err := json.Marshal(row.toAdminListItem()) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + text := string(raw) + if strings.Contains(text, "13800001234") || strings.Contains(text, "13900005678") { + t.Fatalf("list response contains full phone number: %s", text) + } + if strings.Contains(text, "account_snapshot") { + t.Fatalf("list response contains account snapshot: %s", text) + } +} diff --git a/backend/internal/modules/order/queries.go b/backend/internal/modules/order/queries.go index 1679881..c42ecfb 100644 --- a/backend/internal/modules/order/queries.go +++ b/backend/internal/modules/order/queries.go @@ -11,32 +11,116 @@ import ( "gorm.io/gorm" ) -func (r *Repository) ListForUser(ctx context.Context, userID uint64) ([]OrderDTO, error) { +func (r *Repository) ListForUser(ctx context.Context, userID uint64, page, pageSize int) (*UserPaginatedResult, error) { + page, pageSize = normalizePagination(page, pageSize) + base := r.baseQuery(ctx).Where("o.renter_id = ? OR o.owner_id = ?", userID, userID) + var total int64 + if err := r.db.WithContext(ctx).Table("rental_orders AS o"). + Where("o.renter_id = ? OR o.owner_id = ?", userID, userID). + Count(&total).Error; err != nil { + return nil, err + } var rows []orderRow db := r.db.WithContext(ctx) - err := r.baseQuery(ctx). - Where("o.renter_id = ? OR o.owner_id = ?", userID, userID). + err := base. Order("o.id DESC"). + Offset((page - 1) * pageSize). + Limit(pageSize). Scan(&rows).Error if err != nil { return nil, err } - items := make([]OrderDTO, 0, len(rows)) + items := make([]UserOrderListItemDTO, 0, len(rows)) paymentTimeoutMinutes := pendingPaymentTimeoutMinutes(db) for _, row := range rows { - dto := row.toDTOForUser(userID) - applyPaymentDeadline(&dto, row.RentalOrder, paymentTimeoutMinutes) - if shouldAttachCheckout(row.Status) { - dto.Checkout = r.latestCheckoutDTOForUser(ctx, row.ID, userID, row.RentalOrder) + var deadline *time.Time + if row.Status == orderStatusPendingPayment && paymentTimeoutMinutes > 0 { + value := row.CreatedAt.Add(time.Duration(paymentTimeoutMinutes) * time.Minute) + deadline = &value } - items = append(items, dto) + items = append(items, row.toUserListItem(userID, deadline)) } - return items, nil + return &UserPaginatedResult{Items: items, Total: total, Page: page, PageSize: pageSize}, nil } -func (r *Repository) ListAdmin(ctx context.Context, query AdminOrderQuery) (*PaginatedResult, error) { +func (r *Repository) ListSellerHandoffs(ctx context.Context, userID uint64, query SellerHandoffQuery) (*SellerHandoffPaginatedResult, error) { + page, pageSize := normalizePagination(query.Page, query.PageSize) + base := r.baseQuery(ctx). + Where("o.owner_id = ?", userID). + Where("o.status IN ?", sellerHandoffStatuses()) + if query.Status != "" { + base = base.Where("o.status = ?", query.Status) + } + + var total int64 + if err := base.Session(&gorm.Session{}).Count(&total).Error; err != nil { + return nil, err + } + var rows []orderRow + if err := base.Order("o.id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&rows).Error; err != nil { + return nil, err + } + + items := make([]UserOrderListItemDTO, 0, len(rows)) + for _, row := range rows { + items = append(items, row.toUserListItem(userID, nil)) + } + metrics, err := r.sellerHandoffMetrics(ctx, userID) + if err != nil { + return nil, err + } + return &SellerHandoffPaginatedResult{ + Items: items, + Total: total, + Page: page, + PageSize: pageSize, + Metrics: metrics, + }, nil +} + +func (r *Repository) sellerHandoffMetrics(ctx context.Context, userID uint64) (SellerHandoffMetrics, error) { + base := r.db.WithContext(ctx).Table("rental_orders AS o").Where("o.owner_id = ?", userID) + var metrics SellerHandoffMetrics + if err := base.Where("o.status = ? AND o.handoff_status = ?", orderStatusPendingHandoff, handoffStatusPendingOwner).Count(&metrics.PendingHandoff).Error; err != nil { + return SellerHandoffMetrics{}, err + } + if err := r.db.WithContext(ctx).Table("rental_orders AS o").Where("o.owner_id = ? AND o.status = ?", userID, orderStatusPendingCheckoutConfirm).Count(&metrics.PendingCheckout).Error; err != nil { + return SellerHandoffMetrics{}, err + } + if err := r.db.WithContext(ctx).Table("rental_orders AS o").Where("o.owner_id = ? AND o.status IN ?", userID, sellerHandoffAbnormalStatuses()).Count(&metrics.Abnormal).Error; err != nil { + return SellerHandoffMetrics{}, err + } + return metrics, nil +} + +func sellerHandoffStatuses() []string { + return []string{ + orderStatusPendingHandoff, + orderStatusRenting, + orderStatusOverdue, + orderStatusPendingCheckoutConfirm, + orderStatusPendingCheckoutAccept, + orderStatusCheckoutDisputing, + "disputing", + orderStatusAbnormal, + } +} + +func sellerHandoffAbnormalStatuses() []string { + return []string{orderStatusOverdue, orderStatusCheckoutDisputing, "disputing", orderStatusAbnormal} +} + +func isSellerHandoffStatus(status string) bool { + for _, candidate := range sellerHandoffStatuses() { + if status == candidate { + return true + } + } + return false +} + +func (r *Repository) ListAdmin(ctx context.Context, query AdminOrderQuery) (*AdminOrderListPaginatedResult, error) { var total int64 - db := r.db.WithContext(ctx) countDB := applyAdminOrderFilters(r.adminOrderJoinQuery(ctx), query) if err := countDB.Count(&total).Error; err != nil { return nil, err @@ -61,15 +145,12 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminOrderQuery) (*Pag return nil, err } - items := make([]OrderDTO, 0, len(rows)) - paymentTimeoutMinutes := pendingPaymentTimeoutMinutes(db) + items := make([]AdminOrderListItemDTO, 0, len(rows)) for _, row := range rows { - dto := row.toAdminDTO() - applyPaymentDeadline(&dto, row.RentalOrder, paymentTimeoutMinutes) - items = append(items, dto) + items = append(items, row.toAdminListItem()) } - return &PaginatedResult{ + return &AdminOrderListPaginatedResult{ Items: items, Total: total, Page: page, @@ -77,6 +158,19 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminOrderQuery) (*Pag }, nil } +func normalizePagination(page, pageSize int) (int, int) { + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + if pageSize > 100 { + pageSize = 100 + } + return page, pageSize +} + func applyAdminOrderFilters(db *gorm.DB, query AdminOrderQuery) *gorm.DB { if query.Status != "" { db = db.Where("o.status = ?", query.Status) diff --git a/backend/internal/modules/order/service.go b/backend/internal/modules/order/service.go index 1f8643f..cd3ece0 100644 --- a/backend/internal/modules/order/service.go +++ b/backend/internal/modules/order/service.go @@ -32,6 +32,7 @@ var ( ErrDepositNotHeld = errors.New("deposit not held") ErrDepositHoldAmountEmpty = errors.New("deposit hold amount empty") ErrOfflineSettlementCannotMark = errors.New("offline settlement cannot mark") + ErrInvalidSellerHandoffStatus = errors.New("invalid seller handoff status") ) const internalOrderHours = 24 @@ -146,14 +147,24 @@ func (s *Service) AcceptCheckout(ctx context.Context, userID uint64, orderID uin return s.repo.AcceptCheckout(ctx, userID, orderID) } -func (s *Service) ListForUser(ctx context.Context, userID uint64) ([]OrderDTO, error) { +func (s *Service) ListForUser(ctx context.Context, userID uint64, page, pageSize int) (*UserPaginatedResult, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } - return s.repo.ListForUser(ctx, userID) + return s.repo.ListForUser(ctx, userID, page, pageSize) } -func (s *Service) ListAdmin(ctx context.Context, query AdminOrderQuery) (*PaginatedResult, error) { +func (s *Service) ListSellerHandoffs(ctx context.Context, userID uint64, query SellerHandoffQuery) (*SellerHandoffPaginatedResult, error) { + if s.repo == nil { + return nil, ErrDependencyUnavailable + } + if query.Status != "" && !isSellerHandoffStatus(query.Status) { + return nil, ErrInvalidSellerHandoffStatus + } + return s.repo.ListSellerHandoffs(ctx, userID, query) +} + +func (s *Service) ListAdmin(ctx context.Context, query AdminOrderQuery) (*AdminOrderListPaginatedResult, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } diff --git a/backend/internal/modules/paymentaccount/handler.go b/backend/internal/modules/paymentaccount/handler.go index 586e279..c2d0e13 100644 --- a/backend/internal/modules/paymentaccount/handler.go +++ b/backend/internal/modules/paymentaccount/handler.go @@ -177,6 +177,8 @@ func writeError(c *gin.Context, err error) { response.BadRequest(c, "收款账号数量已达上限(最多5个)") case ErrCannotDeleteDefault: response.BadRequest(c, "无法删除默认账号") + case ErrInvalidCertificateURL: + response.BadRequest(c, "收款凭证必须使用本人上传的图片") default: response.InternalServerError(c, "操作失败") } diff --git a/backend/internal/modules/paymentaccount/repository.go b/backend/internal/modules/paymentaccount/repository.go index 83f6ae6..8052329 100644 --- a/backend/internal/modules/paymentaccount/repository.go +++ b/backend/internal/modules/paymentaccount/repository.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "net/url" "strings" "hfb_sys/backend/internal/model" @@ -74,6 +75,9 @@ func (r *Repository) FindByID(ctx context.Context, userID, id uint64) (*PaymentA func (r *Repository) Create(ctx context.Context, userID uint64, req CreatePaymentAccountRequest) (*PaymentAccountDTO, error) { db := r.db.WithContext(ctx) + if err := r.validateCertificateURLs(ctx, userID, req.CertificateURLs, nil); err != nil { + return nil, err + } // 加密账号 encryptedNo, err := r.encryptor.Encrypt(req.AccountNo) if err != nil { @@ -131,6 +135,11 @@ func (r *Repository) Update(ctx context.Context, userID, id uint64, req UpdatePa } if len(req.CertificateURLs) > 0 { + var existingURLs []string + _ = json.Unmarshal(account.CertificateURLs, &existingURLs) + if err := r.validateCertificateURLs(ctx, userID, req.CertificateURLs, existingURLs); err != nil { + return nil, err + } certURLs, _ := json.Marshal(req.CertificateURLs) updates["certificate_urls"] = certURLs } @@ -152,6 +161,54 @@ func (r *Repository) Update(ctx context.Context, userID, id uint64, req UpdatePa return r.FindByID(ctx, userID, id) } +// validateCertificateURLs 确保新增收款凭证来自当前用户上传的 payment-cert 对象。 +// 已绑定在当前收款账号上的历史凭证允许保留,避免旧数据无法编辑。 +func (r *Repository) validateCertificateURLs(ctx context.Context, userID uint64, values []string, existing []string) error { + if len(values) == 0 { + return nil + } + existingKeys := make(map[string]struct{}, len(existing)) + for _, value := range existing { + if key, ok := paymentCertificateKey(value); ok { + existingKeys[key] = struct{}{} + } + } + for _, value := range values { + key, ok := paymentCertificateKey(value) + if !ok { + return ErrInvalidCertificateURL + } + if _, exists := existingKeys[key]; exists { + continue + } + var count int64 + if err := r.db.WithContext(ctx).Table("file_upload_owners"). + Where("user_id = ? AND object_key = ?", userID, key). + Count(&count).Error; err != nil { + return err + } + if count == 0 { + return ErrInvalidCertificateURL + } + } + return nil +} + +func paymentCertificateKey(value string) (string, bool) { + parsed, err := url.Parse(value) + if err != nil { + return "", false + } + if parsed.Path != "/api/files/object" && parsed.Path != "/api/public/files/object" { + return "", false + } + key := parsed.Query().Get("key") + if !strings.HasPrefix(key, "payment-cert/") { + return "", false + } + return key, true +} + func (r *Repository) Delete(ctx context.Context, userID, id uint64) error { db := r.db.WithContext(ctx) var account model.UserPaymentAccount @@ -269,6 +326,7 @@ func (r *Repository) toDTO(account model.UserPaymentAccount) (*PaymentAccountDTO if account.CertificateURLs != nil { json.Unmarshal(account.CertificateURLs, &certURLs) } + certURLs = privateCertificateURLs(certURLs) return &PaymentAccountDTO{ ID: account.ID, @@ -286,6 +344,22 @@ func (r *Repository) toDTO(account model.UserPaymentAccount) (*PaymentAccountDTO }, nil } +// privateCertificateURLs 兼容历史公开收款凭证 URL,统一经私有对象接口读取。 +func privateCertificateURLs(values []string) []string { + for index, value := range values { + parsed, err := url.Parse(value) + if err != nil || parsed.Path != "/api/public/files/object" { + continue + } + if !strings.HasPrefix(parsed.Query().Get("key"), "payment-cert/") { + continue + } + parsed.Path = "/api/files/object" + values[index] = parsed.String() + } + return values +} + // 账号脱敏 func maskAccountNo(accountNo, accountType string) string { length := len(accountNo) diff --git a/backend/internal/modules/paymentaccount/repository_test.go b/backend/internal/modules/paymentaccount/repository_test.go new file mode 100644 index 0000000..75b44cd --- /dev/null +++ b/backend/internal/modules/paymentaccount/repository_test.go @@ -0,0 +1,54 @@ +package paymentaccount + +import ( + "context" + "errors" + "testing" + + "hfb_sys/backend/internal/model" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func TestPrivateCertificateURLsConvertsLegacyPublicURL(t *testing.T) { + values := privateCertificateURLs([]string{ + "/api/public/files/object?key=payment-cert%2F2026%2Fqr.jpg", + "/api/public/files/object?key=avatar%2Fuser.jpg", + }) + if values[0] != "/api/files/object?key=payment-cert%2F2026%2Fqr.jpg" { + t.Fatalf("payment certificate URL = %q", values[0]) + } + if values[1] != "/api/public/files/object?key=avatar%2Fuser.jpg" { + t.Fatalf("non-certificate URL changed = %q", values[1]) + } +} + +func TestValidateCertificateURLsRequiresUploaderOwnership(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatalf("open sqlite error = %v", err) + } + if err := db.AutoMigrate(&model.FileUploadOwner{}); err != nil { + t.Fatalf("migrate upload owners error = %v", err) + } + if err := db.Create(&model.FileUploadOwner{UserID: 1, ObjectKey: "payment-cert/owned.jpg"}).Error; err != nil { + t.Fatalf("create upload owner error = %v", err) + } + repo := NewRepository(db, nil) + ownedURL := "/api/files/object?key=payment-cert%2Fowned.jpg" + if err := repo.validateCertificateURLs(context.Background(), 1, []string{ownedURL}, nil); err != nil { + t.Fatalf("owned certificate rejected: %v", err) + } + if err := repo.validateCertificateURLs(context.Background(), 2, []string{ownedURL}, nil); !errors.Is(err, ErrInvalidCertificateURL) { + t.Fatalf("other user's certificate validation error = %v", err) + } + if err := repo.validateCertificateURLs(context.Background(), 1, []string{"/api/files/object?key=listing%2Fowned.jpg"}, nil); !errors.Is(err, ErrInvalidCertificateURL) { + t.Fatalf("non-certificate file validation error = %v", err) + } + legacyURL := "/api/public/files/object?key=payment-cert%2Flegacy.jpg" + if err := repo.validateCertificateURLs(context.Background(), 1, []string{legacyURL}, []string{legacyURL}); err != nil { + t.Fatalf("existing legacy certificate rejected: %v", err) + } +} diff --git a/backend/internal/modules/paymentaccount/service.go b/backend/internal/modules/paymentaccount/service.go index 2dc2b0c..40fdec4 100644 --- a/backend/internal/modules/paymentaccount/service.go +++ b/backend/internal/modules/paymentaccount/service.go @@ -12,6 +12,7 @@ var ( ErrRealnameRequired = errors.New("realname verification required") ErrAccountLimit = errors.New("maximum payment accounts reached") ErrCannotDeleteDefault = errors.New("cannot delete default account") + ErrInvalidCertificateURL = errors.New("invalid certificate url") ) type Service struct { diff --git a/backend/internal/router/dependencies.go b/backend/internal/router/dependencies.go index c8c89fb..8e7d77e 100644 --- a/backend/internal/router/dependencies.go +++ b/backend/internal/router/dependencies.go @@ -1,11 +1,119 @@ package router import ( + "context" + "net/url" + "strings" + + "hfb_sys/backend/internal/model" + "github.com/redis/go-redis/v9" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type Dependencies struct { DB *gorm.DB Redis *redis.Client } + +// privateFileAuthorizer 仅允许对象所属用户、订单参与者或聊天成员读取私有文件。 +// 未能关联到业务记录的对象默认拒绝,避免随机 key 成为访问凭证。 +func privateFileAuthorizer(db *gorm.DB) func(context.Context, uint64, string) (bool, error) { + return func(ctx context.Context, userID uint64, key string) (bool, error) { + if db == nil || userID == 0 || key == "" { + return false, nil + } + encodedKey := url.QueryEscape(key) + var count int64 + queries := []struct { + sql string + args []any + }{ + { + sql: "SELECT COUNT(1) FROM file_upload_owners WHERE user_id = ? AND object_key = ?", + args: []any{userID, key}, + }, + { + sql: `SELECT COUNT(1) FROM game_accounts a +WHERE a.owner_id = ? + AND (INSTR(a.screenshot_urls, ?) > 0 OR INSTR(a.screenshot_urls, ?) > 0)`, + args: []any{userID, key, encodedKey}, + }, + { + sql: `SELECT COUNT(1) FROM game_accounts a +JOIN rental_orders o ON o.account_id = a.id +WHERE (o.owner_id = ? OR o.renter_id = ?) + AND (INSTR(a.screenshot_urls, ?) > 0 OR INSTR(a.screenshot_urls, ?) > 0)`, + args: []any{userID, userID, key, encodedKey}, + }, + { + sql: `SELECT COUNT(1) FROM order_checkouts c +JOIN rental_orders o ON o.id = c.order_id +WHERE (o.owner_id = ? OR o.renter_id = ?) + AND (INSTR(c.evidence_urls, ?) > 0 OR INSTR(c.evidence_urls, ?) > 0)`, + args: []any{userID, userID, key, encodedKey}, + }, + { + sql: `SELECT COUNT(1) FROM disputes d +JOIN rental_orders o ON o.id = d.order_id +WHERE (o.owner_id = ? OR o.renter_id = ?) + AND (INSTR(d.evidence_urls, ?) > 0 OR INSTR(d.evidence_urls, ?) > 0)`, + args: []any{userID, userID, key, encodedKey}, + }, + { + sql: `SELECT COUNT(1) FROM handoff_records h +JOIN rental_orders o ON o.id = h.order_id +WHERE (o.owner_id = ? OR o.renter_id = ?) + AND (INSTR(h.attachment_urls, ?) > 0 OR INSTR(h.attachment_urls, ?) > 0)`, + args: []any{userID, userID, key, encodedKey}, + }, + { + sql: `SELECT COUNT(1) FROM chat_messages m +JOIN chat_participants p ON p.conversation_id = m.conversation_id +WHERE p.participant_type = 'user' AND p.participant_id = ? + AND (INSTR(m.attachment_urls, ?) > 0 OR INSTR(m.attachment_urls, ?) > 0)`, + args: []any{userID, key, encodedKey}, + }, + { + sql: `SELECT COUNT(1) FROM user_payment_accounts p +WHERE p.user_id = ? + AND (INSTR(p.certificate_urls, ?) > 0 OR INSTR(p.certificate_urls, ?) > 0)`, + args: []any{userID, key, encodedKey}, + }, + } + for _, query := range queries { + if err := db.WithContext(ctx).Raw(query.sql, query.args...).Scan(&count).Error; err != nil { + // 旧库缺少新表时继续检查其他可用关联,其他数据库错误则安全拒绝。 + if strings.Contains(strings.ToLower(err.Error()), "no such table") || strings.Contains(strings.ToLower(err.Error()), "doesn't exist") { + continue + } + return false, err + } + if count > 0 { + return true, nil + } + } + return false, nil + } +} + +// recordPrivateFileUpload 记录用户上传的原图和变体,保证草稿状态下也仅上传者可预览。 +func recordPrivateFileUpload(db *gorm.DB) func(context.Context, uint64, []string) error { + return func(ctx context.Context, userID uint64, objectKeys []string) error { + if db == nil || userID == 0 || len(objectKeys) == 0 { + return nil + } + records := make([]model.FileUploadOwner, 0, len(objectKeys)) + for _, key := range objectKeys { + if key == "" { + continue + } + records = append(records, model.FileUploadOwner{UserID: userID, ObjectKey: key}) + } + if len(records) == 0 { + return nil + } + return db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&records).Error + } +} diff --git a/backend/internal/router/dependencies_test.go b/backend/internal/router/dependencies_test.go new file mode 100644 index 0000000..aa996aa --- /dev/null +++ b/backend/internal/router/dependencies_test.go @@ -0,0 +1,115 @@ +package router + +import ( + "context" + "testing" + + "hfb_sys/backend/internal/model" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func TestPrivateFileAuthorizer(t *testing.T) { + db := openAuthorizerTestDB(t) + if err := db.AutoMigrate(&model.FileUploadOwner{}); err != nil { + t.Fatalf("AutoMigrate() error = %v", err) + } + createAuthorizerBusinessTables(t, db) + + if err := db.Create(&model.FileUploadOwner{UserID: 11, ObjectKey: "listing/draft.jpg"}).Error; err != nil { + t.Fatalf("create upload owner error = %v", err) + } + if err := db.Exec("INSERT INTO game_accounts (id, owner_id, screenshot_urls) VALUES (1, 21, ?), (2, 23, ?)", `["/api/files/object?key=listing%2Faccount.jpg"]`, `["/api/files/object?key=listing%2Fno-order.jpg"]`).Error; err != nil { + t.Fatalf("create account error = %v", err) + } + if err := db.Exec("INSERT INTO rental_orders (id, account_id, owner_id, renter_id) VALUES (1, 1, 21, 22)").Error; err != nil { + t.Fatalf("create order error = %v", err) + } + if err := db.Exec("INSERT INTO chat_messages (id, conversation_id, attachment_urls) VALUES (1, 8, ?)", `["/api/files/object?key=chat%2Fmessage.jpg"]`).Error; err != nil { + t.Fatalf("create chat message error = %v", err) + } + if err := db.Exec("INSERT INTO chat_participants (id, conversation_id, participant_type, participant_id) VALUES (1, 8, 'user', 31)").Error; err != nil { + t.Fatalf("create chat participant error = %v", err) + } + if err := db.Exec("INSERT INTO user_payment_accounts (id, user_id, certificate_urls) VALUES (1, 41, ?)", `["/api/files/object?key=payment-cert%2Fqr.jpg"]`).Error; err != nil { + t.Fatalf("create payment account error = %v", err) + } + + authorize := privateFileAuthorizer(db) + cases := []struct { + name string + userID uint64 + key string + allowed bool + }{ + {name: "上传者可预览草稿", userID: 11, key: "listing/draft.jpg", allowed: true}, + {name: "账号所有者可读取未出租账号截图", userID: 23, key: "listing/no-order.jpg", allowed: true}, + {name: "订单参与人可读取截图", userID: 22, key: "listing/account.jpg", allowed: true}, + {name: "聊天成员可读取附件", userID: 31, key: "chat/message.jpg", allowed: true}, + {name: "收款账号所有者可读取凭证", userID: 41, key: "payment-cert/qr.jpg", allowed: true}, + {name: "无关联用户被拒绝", userID: 99, key: "listing/account.jpg", allowed: false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + allowed, err := authorize(context.Background(), tc.userID, tc.key) + if err != nil { + t.Fatalf("authorize() error = %v", err) + } + if allowed != tc.allowed { + t.Fatalf("authorize() = %v, want %v", allowed, tc.allowed) + } + }) + } +} + +func TestRecordPrivateFileUpload(t *testing.T) { + db := openAuthorizerTestDB(t) + if err := db.AutoMigrate(&model.FileUploadOwner{}); err != nil { + t.Fatalf("AutoMigrate() error = %v", err) + } + record := recordPrivateFileUpload(db) + keys := []string{"listing/original.jpg", "listing/original.thumb.jpg", "listing/original.medium.jpg"} + if err := record(context.Background(), 11, keys); err != nil { + t.Fatalf("record() error = %v", err) + } + if err := record(context.Background(), 11, keys); err != nil { + t.Fatalf("duplicate record() error = %v", err) + } + var count int64 + if err := db.Model(&model.FileUploadOwner{}).Where("user_id = ?", 11).Count(&count).Error; err != nil { + t.Fatalf("count records error = %v", err) + } + if count != int64(len(keys)) { + t.Fatalf("record count = %d, want %d", count, len(keys)) + } +} + +func openAuthorizerTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatalf("open sqlite error = %v", err) + } + return db +} + +func createAuthorizerBusinessTables(t *testing.T, db *gorm.DB) { + t.Helper() + statements := []string{ + "CREATE TABLE game_accounts (id INTEGER PRIMARY KEY, owner_id INTEGER NOT NULL, screenshot_urls TEXT)", + "CREATE TABLE rental_orders (id INTEGER PRIMARY KEY, account_id INTEGER NOT NULL, owner_id INTEGER NOT NULL, renter_id INTEGER NOT NULL)", + "CREATE TABLE order_checkouts (id INTEGER PRIMARY KEY, order_id INTEGER NOT NULL, evidence_urls TEXT)", + "CREATE TABLE disputes (id INTEGER PRIMARY KEY, order_id INTEGER NOT NULL, evidence_urls TEXT)", + "CREATE TABLE handoff_records (id INTEGER PRIMARY KEY, order_id INTEGER NOT NULL, attachment_urls TEXT)", + "CREATE TABLE chat_messages (id INTEGER PRIMARY KEY, conversation_id INTEGER NOT NULL, attachment_urls TEXT)", + "CREATE TABLE chat_participants (id INTEGER PRIMARY KEY, conversation_id INTEGER NOT NULL, participant_type TEXT NOT NULL, participant_id INTEGER NOT NULL)", + "CREATE TABLE user_payment_accounts (id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, certificate_urls TEXT)", + } + for _, statement := range statements { + if err := db.Exec(statement).Error; err != nil { + t.Fatalf("create test table error = %v", err) + } + } +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 4504235..615d855 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -355,7 +355,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { } } fileService := filemodule.NewService(fileStorage) - fileHandler := filemodule.NewHandler(fileService, fileStorage) + fileHandler := filemodule.NewHandler(fileService, fileStorage, privateFileAuthorizer(deps.DB), recordPrivateFileUpload(deps.DB)) listingService := listing.NewService(listingRepo, systemConfigRepo) listingHandler := listing.NewHandler(listingService, fileStorage, listing.HandlerOptions{ ExternalUploadSecret: cfg.ExternalUploadSecret, @@ -367,7 +367,14 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { } announcementService := announcement.NewService(announcementRepo) announcementHandler := announcement.NewHandler(announcementService) - requireAuth := middleware.Auth(jwtManager) + var validateUserToken middleware.UserTokenValidatorFunc + if userRepo != nil { + validateUserToken = func(ctx context.Context, userID uint64, tokenVersion int64) error { + _, err := userRepo.FindActiveForToken(ctx, userID, tokenVersion) + return err + } + } + requireAuth := middleware.Auth(jwtManager, validateUserToken) var validateAdminToken middleware.AdminTokenValidatorFunc if adminAuthRepo != nil { validateAdminToken = func(ctx context.Context, adminID uint64, tokenVersion int64) (middleware.AdminTokenContext, error) { @@ -416,7 +423,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { authRoutes.POST("/password/register", authHandler.Register) authRoutes.POST("/password/reset", authHandler.ResetPassword) authRoutes.POST("/refresh", authHandler.Refresh) - authRoutes.POST("/logout", authHandler.Logout) + authRoutes.POST("/logout", requireAuth, authHandler.Logout) } api.GET("/me", requireAuth, userHandler.Me) @@ -474,6 +481,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { { orderRoutes.POST("", requireRealname, orderHandler.Create) orderRoutes.GET("", orderHandler.List) + orderRoutes.GET("/handoffs", orderHandler.ListSellerHandoffs) orderRoutes.GET("/:id", orderHandler.Detail) orderRoutes.GET("/:id/chat", chatHandler.OrderConversation) orderRoutes.POST("/:id/pay", orderHandler.Pay) @@ -589,7 +597,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine { adminRoutes.POST("/users/:id/manual-realname", requirePerm("user:manual_realname"), adminUserHandler.ManualRealname) adminRoutes.POST("/users/:id/revoke-realname", requirePerm("user:revoke_realname"), adminUserHandler.RevokeRealname) adminRoutes.POST("/users/:id/wallet/adjust", requirePerm("user:wallet_adjust"), adminUserHandler.AdjustWallet) - adminRoutes.GET("/orders", requirePerm("order:view"), orderHandler.AdminList) + adminRoutes.GET("/orders", requirePerm("order:list"), orderHandler.AdminList) adminRoutes.GET("/listing-orders/:id/latest", requirePerm("order:view"), orderHandler.AdminLatestByListing) adminRoutes.GET("/orders/:id", requirePerm("order:view"), orderHandler.AdminDetail) adminRoutes.GET("/orders/:id/handoff-records", requirePerm("order:view"), orderHandler.AdminHandoffRecords) diff --git a/backend/migrations/000050_order_list_least_privilege.sql b/backend/migrations/000050_order_list_least_privilege.sql new file mode 100644 index 0000000..a341072 --- /dev/null +++ b/backend/migrations/000050_order_list_least_privilege.sql @@ -0,0 +1,24 @@ +-- +goose Up + +-- 订单列表使用独立权限和最小 DTO,避免查看列表即读取订单敏感详情。 +INSERT INTO permissions (code, name, resource, action) VALUES +('order:list', '查看订单列表', 'order', 'list') +ON DUPLICATE KEY UPDATE + name = VALUES(name), + resource = VALUES(resource), + action = VALUES(action); + +INSERT IGNORE INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.code = 'order:list' +WHERE r.code IN ('super_admin', 'cs', 'ops', 'finance'); + +-- +goose Down + +DELETE rp +FROM role_permissions rp +JOIN permissions p ON p.id = rp.permission_id +WHERE p.code = 'order:list'; + +DELETE FROM permissions WHERE code = 'order:list'; diff --git a/backend/migrations/000051_user_token_version.sql b/backend/migrations/000051_user_token_version.sql new file mode 100644 index 0000000..2febaf3 --- /dev/null +++ b/backend/migrations/000051_user_token_version.sql @@ -0,0 +1,10 @@ +-- +goose Up + +-- 用户会话版本:冻结、改密和退出时递增,立即撤销旧 access/refresh token。 +ALTER TABLE users + ADD COLUMN token_version BIGINT NOT NULL DEFAULT 1 COMMENT '用户令牌版本'; + +-- +goose Down + +ALTER TABLE users + DROP COLUMN token_version; diff --git a/backend/migrations/000052_file_upload_owners.sql b/backend/migrations/000052_file_upload_owners.sql new file mode 100644 index 0000000..51f5cfe --- /dev/null +++ b/backend/migrations/000052_file_upload_owners.sql @@ -0,0 +1,16 @@ +-- +goose Up + +-- 私有文件上传归属:在业务记录写入前,仅允许上传者预览自己的临时文件。 +CREATE TABLE file_upload_owners ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + object_key VARCHAR(512) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_file_upload_owners_object_key (object_key), + KEY idx_file_upload_owners_user_id (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='私有文件上传归属'; + +-- +goose Down + +DROP TABLE IF EXISTS file_upload_owners; diff --git a/backend/migrations/000053_file_upload_owners_cleanup_index.sql b/backend/migrations/000053_file_upload_owners_cleanup_index.sql new file mode 100644 index 0000000..9ac010a --- /dev/null +++ b/backend/migrations/000053_file_upload_owners_cleanup_index.sql @@ -0,0 +1,10 @@ +-- +goose Up + +-- 为长期未关联上传归属记录的定时清理提供索引。 +ALTER TABLE file_upload_owners + ADD KEY idx_file_upload_owners_created_at (created_at); + +-- +goose Down + +ALTER TABLE file_upload_owners + DROP INDEX idx_file_upload_owners_created_at; diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index dd0cada..9bc5cc0 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -6,6 +6,7 @@ # 防止客户端伪造 X-Forwarded-For 影响限流、审计和支付下单的 client_ip。 (backend_proxy) { header_up X-Real-IP {remote_host} + header_up X-Forwarded-Proto {scheme} } # 公开 API 反代片段:主站与选号网共用。 diff --git a/deploy/caddy/show.caddy.tmpl b/deploy/caddy/show.caddy.tmpl index b31401e..9782294 100644 --- a/deploy/caddy/show.caddy.tmpl +++ b/deploy/caddy/show.caddy.tmpl @@ -18,12 +18,14 @@ __CADDY_SHOW_DOMAIN__ { } reverse_proxy backend:8080 { header_up X-Real-IP {remote_host} + header_up X-Forwarded-Proto {scheme} } } handle /health { reverse_proxy backend:8080 { header_up X-Real-IP {remote_host} + header_up X-Forwarded-Proto {scheme} } } diff --git a/frontend/src/features/admin/views/AdminOrdersView.vue b/frontend/src/features/admin/views/AdminOrdersView.vue index 9fbac6b..33d2f65 100644 --- a/frontend/src/features/admin/views/AdminOrdersView.vue +++ b/frontend/src/features/admin/views/AdminOrdersView.vue @@ -3,7 +3,7 @@ import { ArrowLeft, Refresh, Search } from '@element-plus/icons-vue' import { computed, onMounted, reactive, ref } from 'vue' import { useRoute, useRouter } from 'vue-router' -import { fetchAdminOrders, type AdminOrderQuery, type Order } from '@/features/orders' +import { fetchAdminOrders, type AdminOrderListItem, type AdminOrderQuery } from '@/features/orders' import { orderHandoffStatusLabel, orderStatusLabel, @@ -24,7 +24,7 @@ const filters = reactive({ offline_settlement_status: '', }) const loading = ref(false) -const orders = ref([]) +const orders = ref([]) const total = ref(0) const currentPage = ref(1) const currentPageSize = ref(20) @@ -153,7 +153,7 @@ function userText(value: string | number | undefined) { return value || '-' } -function isPlatformManaged(row: Order) { +function isPlatformManaged(row: AdminOrderListItem) { return row.handoff_mode === 'platform' || row.settlement_mode === 'platform_managed' } @@ -298,9 +298,9 @@ function isPlatformManaged(row: Order) { diff --git a/frontend/src/features/auth/api/auth.ts b/frontend/src/features/auth/api/auth.ts index 0e88ea2..123803c 100644 --- a/frontend/src/features/auth/api/auth.ts +++ b/frontend/src/features/auth/api/auth.ts @@ -80,6 +80,14 @@ export async function resetPassword(phone: string, code: string, newPassword: st await apiClient.post('/auth/password/reset', { phone, code, new_password: newPassword }) } +export async function logoutUser() { + await apiClient.post('/auth/logout', undefined, { + silent: true, + skipErrorHandler: true, + skipAuthRefresh: true, + }) +} + export async function fetchMe() { const { data } = await apiClient.get>('/me') return data.data diff --git a/frontend/src/features/auth/views/MobileProfileView.vue b/frontend/src/features/auth/views/MobileProfileView.vue index bc178c0..39a37a1 100644 --- a/frontend/src/features/auth/views/MobileProfileView.vue +++ b/frontend/src/features/auth/views/MobileProfileView.vue @@ -267,8 +267,8 @@ function confirmLogout() { confirmButtonColor: '#ee0a24', cancelButtonText: '取消', }) - .then(() => { - session.logout() + .then(async () => { + await session.logout() router.replace('/m') }) .catch(() => {}) diff --git a/frontend/src/features/chats/composables/useChatSSE.ts b/frontend/src/features/chats/composables/useChatSSE.ts index c8922b0..d69c91a 100644 --- a/frontend/src/features/chats/composables/useChatSSE.ts +++ b/frontend/src/features/chats/composables/useChatSSE.ts @@ -1,6 +1,6 @@ import { onBeforeUnmount, ref, type Ref } from 'vue' import { refreshAccessToken } from '@/shared/api/client' -import { getAccessToken, type AuthScope } from '@/shared/utils/authStorage' +import type { AuthScope } from '@/shared/utils/authStorage' export interface SSEMessage { id: number @@ -42,13 +42,8 @@ export function useChatSSE(scope: AuthScope, endpoint: string) { function connect() { if (stopped || source) return - const token = getAccessToken(scope) - // 用户端通过 query token 鉴权;后台 token 存于 httpOnly cookie, - // localStorage 无 token,连接时依赖浏览器自动携带的 cookie,不能因空 token 提前返回。 - if (scope !== 'admin' && !token) return - - const url = scope === 'admin' ? endpoint : `${endpoint}?token=${encodeURIComponent(token)}` - source = new EventSource(url, { withCredentials: true }) + // SSE 与普通 API 一样使用同源 HttpOnly Cookie 鉴权,避免令牌进入 URL 和代理日志。 + source = new EventSource(endpoint, { withCredentials: true }) source.addEventListener('connected', () => { connected.value = true diff --git a/frontend/src/features/listings/api/listings.ts b/frontend/src/features/listings/api/listings.ts index 19ed28a..21ba78f 100644 --- a/frontend/src/features/listings/api/listings.ts +++ b/frontend/src/features/listings/api/listings.ts @@ -34,6 +34,32 @@ export interface Listing { updated_at: string } +// 公开商品卡片的最小字段,对应后端 PublicListingListItemDTO。 +export interface PublicListingItem { + id: number + listing_no: string + title: string + game_name: string + server_region: string + login_platform: string + rank_level: string + haf_coin_amount: number + asset_summary?: Record + screenshot_urls?: string[] + cover_url: string + price_cent: number + deposit_amount_cent: number + is_accelerated_sale?: boolean + published_at?: string + created_at: string +} + +// 公开商品详情,对应后端 PublicListingDetailDTO。 +export interface PublicListingDetail extends PublicListingItem { + description: string + screenshot_urls: string[] +} + export interface ListingPayload { title: string description: string @@ -85,7 +111,7 @@ export interface PublicListingQuery { } export interface PublicListingPage { - items: Listing[] + items: PublicListingItem[] total: number page: number page_size: number @@ -126,7 +152,7 @@ function normalizePublicListingPage( } export async function fetchListing(id: string | number) { - const { data } = await apiClient.get>(`/listings/${id}`) + const { data } = await apiClient.get>(`/listings/${id}`) return data.data } diff --git a/frontend/src/features/listings/components/ListingCard.vue b/frontend/src/features/listings/components/ListingCard.vue index 776ce19..1c43230 100644 --- a/frontend/src/features/listings/components/ListingCard.vue +++ b/frontend/src/features/listings/components/ListingCard.vue @@ -1,7 +1,7 @@ @@ -88,7 +80,13 @@ function sellerAmount(order: Order) {

处理待交接、租赁中、待结账确认和异常订单。

- + @@ -105,19 +103,19 @@ function sellerAmount(order: Order) {
待交接 - {{ pendingHandoffCount }} 单 + {{ metrics.pending_handoff }} 单
待结账 - {{ pendingCheckoutCount }} 单 + {{ metrics.pending_checkout }} 单
异常/争议 - {{ abnormalCount }} 单 + {{ metrics.abnormal }} 单
- + +
+ +
diff --git a/frontend/src/features/wallet/components/PaymentAccountDialog.vue b/frontend/src/features/wallet/components/PaymentAccountDialog.vue index c3179aa..3d996c8 100644 --- a/frontend/src/features/wallet/components/PaymentAccountDialog.vue +++ b/frontend/src/features/wallet/components/PaymentAccountDialog.vue @@ -158,9 +158,7 @@ async function handleUpload(event: Event) { if (!form.value.certificate_urls) { form.value.certificate_urls = [] } - // 使用公开访问的URL,不需要认证 - const publicUrl = uploaded.url.replace('/api/files/object', '/api/public/files/object') - form.value.certificate_urls.push(publicUrl) + form.value.certificate_urls.push(uploaded.url) ElMessage.success('上传成功') } catch (error: any) { ElMessage.error(error.response?.data?.message || '上传失败') diff --git a/frontend/src/features/wallet/views/MobileWithdrawalView.vue b/frontend/src/features/wallet/views/MobileWithdrawalView.vue index a419f23..fdcda56 100644 --- a/frontend/src/features/wallet/views/MobileWithdrawalView.vue +++ b/frontend/src/features/wallet/views/MobileWithdrawalView.vue @@ -323,8 +323,7 @@ async function handleCertificateUpload(event: Event) { uploadingCertificate.value = true try { const uploaded = await uploadFile(file, 'payment-cert') - const publicURL = uploaded.url.replace('/api/files/object', '/api/public/files/object') - accountForm.certificate_urls.push(publicURL) + accountForm.certificate_urls.push(uploaded.url) showToast({ message: '图片已上传', icon: 'passed' }) } catch (error) { showToast({ message: readError(error, '上传失败'), icon: 'cross' }) diff --git a/frontend/src/layouts/AppLayout.vue b/frontend/src/layouts/AppLayout.vue index 0f6a13f..3a7a60f 100644 --- a/frontend/src/layouts/AppLayout.vue +++ b/frontend/src/layouts/AppLayout.vue @@ -110,8 +110,8 @@ function handleTopSearchInput(event: Event) { }) } -function handleLogout() { - session.logout() +async function handleLogout() { + await session.logout() showUserDropdown.value = false router.replace('/') } diff --git a/frontend/src/shared/api/client.ts b/frontend/src/shared/api/client.ts index 1002ce2..f8dc867 100644 --- a/frontend/src/shared/api/client.ts +++ b/frontend/src/shared/api/client.ts @@ -17,6 +17,7 @@ declare module 'axios' { export interface AxiosRequestConfig { silent?: boolean skipErrorHandler?: boolean + skipAuthRefresh?: boolean showLoading?: boolean _startTime?: number } @@ -222,7 +223,12 @@ apiClient.interceptors.response.use( logError(error) const originalRequest = error.config as RetriableRequestConfig | undefined - if (!originalRequest || error?.response?.status !== 401 || originalRequest._retry) { + if ( + !originalRequest || + error?.response?.status !== 401 || + originalRequest._retry || + originalRequest.skipAuthRefresh + ) { // 统一错误处理,支持 skipErrorHandler 跳过 if (originalRequest && !originalRequest.silent && !originalRequest.skipErrorHandler) { const msg = error.response?.data?.message || error.message || '网络连接异常,请稍后重试' diff --git a/frontend/src/shared/utils/listingDisplay.ts b/frontend/src/shared/utils/listingDisplay.ts index 5eb9c93..a7fbdad 100644 --- a/frontend/src/shared/utils/listingDisplay.ts +++ b/frontend/src/shared/utils/listingDisplay.ts @@ -1,4 +1,4 @@ -import type { Listing } from '@/features/listings/api/listings' +import type { PublicListingItem } from '@/features/listings/api/listings' import { centToYuan } from '@/shared/utils/money' export interface ListingDisplayChip { @@ -21,15 +21,15 @@ export interface ListingDisplaySkinGroup { options: string[] } -export function getCoinWan(item: Listing) { +export function getCoinWan(item: PublicListingItem) { return Math.round(Number(item.haf_coin_amount || 0) / 10000) } -export function getCoinM(item: Listing) { +export function getCoinM(item: PublicListingItem) { return getCoinWan(item) / 100 } -export function formatListingCode(item: Listing) { +export function formatListingCode(item: PublicListingItem) { return formatListingNo(item.listing_no, item.id) } @@ -52,23 +52,23 @@ export function formatAssetNumber(value: number) { return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1) } -export function getListingDisplayPrice(item: Listing) { +export function getListingDisplayPrice(item: PublicListingItem) { return centToYuan(item.price_cent) } -export function getListingRentPrice(item: Listing) { +export function getListingRentPrice(item: PublicListingItem) { const buyerCoinBasePrice = readPriceBreakdownNumber(item, 'buyer_coin_base_price') if (buyerCoinBasePrice > 0) return roundMoney(buyerCoinBasePrice) return Math.max(0, roundMoney(getListingDisplayPrice(item) - getListingConsumablePrice(item))) } -export function getListingConsumablePrice(item: Listing) { +export function getListingConsumablePrice(item: PublicListingItem) { const consumablePrice = readPriceBreakdownNumber(item, 'consumable_price') if (consumablePrice > 0) return roundMoney(consumablePrice) return getListingResources(item).reduce((sum, resource) => sum + resource.amount, 0) } -export function getListingSellerPrice(item: Listing) { +export function getListingSellerPrice(item: PublicListingItem) { const priceBreakdown = item.asset_summary?.price_breakdown if (typeof priceBreakdown === 'object' && priceBreakdown !== null) { const price = readUnknownNumber((priceBreakdown as Record).seller_total_price) @@ -77,7 +77,7 @@ export function getListingSellerPrice(item: Listing) { return getListingDisplayPrice(item) } -export function getRatioValue(item: Listing) { +export function getRatioValue(item: PublicListingItem) { const ratio = readAssetNumber(item, 'publish_ratio') if (ratio > 0) return ratio const price = getListingDisplayPrice(item) @@ -85,20 +85,20 @@ export function getRatioValue(item: Listing) { return getCoinWan(item) / price } -export function formatRatio(item: Listing) { +export function formatRatio(item: PublicListingItem) { const ratio = getRatioValue(item) return ratio > 0 ? `1:${formatRatioNumber(ratio)}` : '--' } -export function getValuePerYuanText(item: Listing) { +export function getValuePerYuanText(item: PublicListingItem) { return formatRatio(item) } -export function getLoginMethod(item: Listing) { +export function getLoginMethod(item: PublicListingItem) { return item.login_platform.trim() } -export function getServerRegion(item: Listing) { +export function getServerRegion(item: PublicListingItem) { return item.server_region.trim() } @@ -113,7 +113,7 @@ export function formatGameName(value: unknown, fallback = '-') { return text } -export function getListingTitle(item: Listing) { +export function getListingTitle(item: PublicListingItem) { const parts = [ `纯币${formatHafCoinM(getCoinWan(item))}`, formatInsuranceSlotText(readAssetString(item, 'season_insurance')), @@ -126,11 +126,11 @@ export function getListingTitle(item: Listing) { return parts.join('/') } -export function getListingSubtitle(item: Listing) { +export function getListingSubtitle(item: PublicListingItem) { return getValuePerYuanText(item) } -export function getListingChips(item: Listing): ListingDisplayChip[] { +export function getListingChips(item: PublicListingItem): ListingDisplayChip[] { const totalAsset = readAssetNumber(item, 'total_asset_wan') const chips: ListingDisplayChip[] = [ { label: '哈夫币', value: formatHafCoinM(getCoinWan(item)) }, @@ -161,7 +161,7 @@ export function getListingChips(item: Listing): ListingDisplayChip[] { return chips.filter(chip => chip.value) } -export function getListingResources(item: Listing): ListingDisplayResource[] { +export function getListingResources(item: PublicListingItem): ListingDisplayResource[] { const resources = item.asset_summary?.resources if (!Array.isArray(resources)) return [] return resources @@ -188,15 +188,15 @@ export function getListingResources(item: Listing): ListingDisplayResource[] { }) } -export function getResourceQuantity(item: Listing, resourceKey: string) { +export function getResourceQuantity(item: PublicListingItem, resourceKey: string) { return getListingResources(item).find(resource => resource.key === resourceKey)?.quantity || 0 } -export function hasGiftResources(item: Listing) { +export function hasGiftResources(item: PublicListingItem) { return getListingResources(item).some(resource => resource.mode === '赠送') } -export function hasAcceleratedSaleRatio(item: Listing) { +export function hasAcceleratedSaleRatio(item: PublicListingItem) { if (item.is_accelerated_sale) return true const priceBreakdown = item.asset_summary?.price_breakdown @@ -211,7 +211,7 @@ export function hasAcceleratedSaleRatio(item: Listing) { return sellerRatio > referenceRatio || acceleratedRatio > referenceRatio } -export function getSkinGroup(item: Listing, groupKey: string) { +export function getSkinGroup(item: PublicListingItem, groupKey: string) { const skinGroups = item.asset_summary?.skin_groups if ( typeof skinGroups !== 'object' || @@ -225,7 +225,7 @@ export function getSkinGroup(item: Listing, groupKey: string) { ) } -export function getListingSkinGroups(item: Listing): ListingDisplaySkinGroup[] { +export function getListingSkinGroups(item: PublicListingItem): ListingDisplaySkinGroup[] { const skinGroups = item.asset_summary?.skin_groups if (typeof skinGroups !== 'object' || skinGroups === null) return [] const titles: Record = { @@ -248,11 +248,11 @@ export function getListingSkinGroups(item: Listing): ListingDisplaySkinGroup[] { .filter(group => group.options.length) } -export function getSkinNames(item: Listing) { +export function getSkinNames(item: PublicListingItem) { return getListingSkinGroups(item).flatMap(group => group.options) } -export function assetRegions(item: Listing) { +export function assetRegions(item: PublicListingItem) { const regions = item.asset_summary?.common_regions return Array.isArray(regions) ? regions.filter((region): region is string => typeof region === 'string') @@ -281,7 +281,7 @@ export function formatOnlineTimeRange(start: string, end: string) { return `${startLabel}-${endLabel}点` } -export function getOnlineTimeText(item: Listing) { +export function getOnlineTimeText(item: PublicListingItem) { const onlineTime = item.asset_summary?.online_time if (typeof onlineTime !== 'object' || onlineTime === null) return '' const start = (onlineTime as Record).start @@ -303,37 +303,37 @@ function formatClockLabel(value: string) { return value.endsWith(':00') ? value.slice(0, -3) : value } -export function getDailyLoss(item: Listing) { +export function getDailyLoss(item: PublicListingItem) { const dailyLossM = getDailyLossM(item) return dailyLossM > 0 ? `${formatCompactNumber(dailyLossM)}M` : '' } -export function getDailyLossM(item: Listing) { +export function getDailyLossM(item: PublicListingItem) { const configuredLoss = readAssetNumber(item, 'daily_loss_m') if (configuredLoss > 0) return configuredLoss return 0 } -export function getEstimatedRentalDays(item: Listing) { +export function getEstimatedRentalDays(item: PublicListingItem) { const coinM = getCoinM(item) const dailyLossM = getDailyLossM(item) if (coinM <= 0 || dailyLossM <= 0) return 0 return coinM / dailyLossM } -export function formatEstimatedRentalDuration(item: Listing) { +export function formatEstimatedRentalDuration(item: PublicListingItem) { const days = getEstimatedRentalDays(item) if (days <= 0) return '--' // 不足整天按整天向上取整,与后端 estimateOrderDurationHours 一致 return `${Math.max(1, Math.ceil(days))}天` } -export function readAssetString(item: Listing, key: string) { +export function readAssetString(item: PublicListingItem, key: string) { const value = item.asset_summary?.[key] return typeof value === 'string' ? value : '' } -export function readAssetNumber(item: Listing, key: string) { +export function readAssetNumber(item: PublicListingItem, key: string) { return readUnknownNumber(item.asset_summary?.[key]) } @@ -346,7 +346,7 @@ function readUnknownNumber(value: unknown) { return 0 } -function readPriceBreakdownNumber(item: Listing, key: string) { +function readPriceBreakdownNumber(item: PublicListingItem, key: string) { const priceBreakdown = item.asset_summary?.price_breakdown if (typeof priceBreakdown !== 'object' || priceBreakdown === null) return 0 return readUnknownNumber((priceBreakdown as Record)[key]) @@ -379,7 +379,7 @@ function formatLevelShort(value: string, suffix: string) { return level ? `${level}${suffix}` : value } -function formatResourceShort(item: Listing, key: string, label: string) { +function formatResourceShort(item: PublicListingItem, key: string, label: string) { const quantity = getResourceQuantity(item, key) return quantity > 0 ? `${quantity}${label}` : '' } diff --git a/frontend/src/stores/mobileHomeCache.ts b/frontend/src/stores/mobileHomeCache.ts index c66526d..e1e11d9 100644 --- a/frontend/src/stores/mobileHomeCache.ts +++ b/frontend/src/stores/mobileHomeCache.ts @@ -9,7 +9,7 @@ import { emptyListingPublishOptions, type ListingPublishOptions, } from '@/features/listings/api/listingOptions' -import type { Listing } from '@/features/listings/api/listings' +import type { PublicListingItem } from '@/features/listings/api/listings' export type MobileHomeRangeFilters = Record @@ -34,7 +34,7 @@ export const useMobileHomeCacheStore = defineStore('mobileHomeCache', { state: () => ({ /** 与当前 listings 数据对应的筛选签名;空字符串表示尚无可用快照 */ listSignature: '', - listings: [] as Listing[], + listings: [] as PublicListingItem[], totalListings: 0, zoneCounts: {} as Record, /** 下一页页码(与 MobileHomeView.currentPage 语义一致) */ @@ -58,7 +58,7 @@ export const useMobileHomeCacheStore = defineStore('mobileHomeCache', { }, saveList(payload: { signature: string - listings: Listing[] + listings: PublicListingItem[] totalListings: number zoneCounts: Record currentPage: number diff --git a/frontend/src/stores/session.ts b/frontend/src/stores/session.ts index 2609478..0ed0099 100644 --- a/frontend/src/stores/session.ts +++ b/frontend/src/stores/session.ts @@ -1,6 +1,12 @@ import { defineStore } from 'pinia' -import { fetchMe, loginWithSms, updateMe, type AuthUser } from '@/features/auth/api/auth' +import { + fetchMe, + loginWithSms, + logoutUser, + updateMe, + type AuthUser, +} from '@/features/auth/api/auth' import { clearAuthStorage, getAccessToken, @@ -83,7 +89,12 @@ export const useSessionStore = defineStore('session', { this.applyUser(user) return user }, - logout() { + async logout() { + try { + await logoutUser() + } catch { + // 本地退出不依赖网络成功,避免用户无法离开当前账号。 + } this.token = '' this.refreshToken = '' this.userId = 0