订单接口最小化与私有文件访问加固
- 订单列表使用独立最小 DTO 并分页,号主待办提供独立接口与统计 - 用户 token 增加版本控制,冻结/改密/退出即时撤销会话 - 移除 URL token 传参,SSE 与接口统一使用 HttpOnly Cookie - 私有文件按上传归属与业务关联授权,收款凭证转私有访问并校验归属 - 公开商品接口返回最小字段,隐藏号主身份与内部状态 - 每日清理超过 30 天未关联业务的上传归属,上传归属失败时补偿删除对象
This commit is contained in:
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"hfb_sys/backend/internal/config"
|
"hfb_sys/backend/internal/config"
|
||||||
"hfb_sys/backend/internal/database"
|
"hfb_sys/backend/internal/database"
|
||||||
|
"hfb_sys/backend/internal/jobs/fileuploadcleanup"
|
||||||
"hfb_sys/backend/internal/jobs/ordertimeout"
|
"hfb_sys/backend/internal/jobs/ordertimeout"
|
||||||
"hfb_sys/backend/internal/jobs/refundretry"
|
"hfb_sys/backend/internal/jobs/refundretry"
|
||||||
"hfb_sys/backend/internal/logging"
|
"hfb_sys/backend/internal/logging"
|
||||||
@@ -91,6 +92,7 @@ func main() {
|
|||||||
defer stopJobs()
|
defer stopJobs()
|
||||||
if deps.DB != nil {
|
if deps.DB != nil {
|
||||||
ordertimeout.New(deps.DB, deps.Redis, logger).Start(jobCtx)
|
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 {
|
if paymentConfigRepo := newPaymentConfigRepositoryForJobs(cfg, deps.DB, logger); paymentConfigRepo != nil {
|
||||||
paymentRepo := payment.NewRepository(deps.DB, paymentConfigRepo, nil, payment.WithLogger(logger))
|
paymentRepo := payment.NewRepository(deps.DB, paymentConfigRepo, nil, payment.WithLogger(logger))
|
||||||
refundretry.New(deps.DB, deps.Redis, logger, paymentRepo).Start(jobCtx)
|
refundretry.New(deps.DB, deps.Redis, logger, paymentRepo).Start(jobCtx)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ const (
|
|||||||
ContextAuthCurrentVersion = "auth_current_token_version"
|
ContextAuthCurrentVersion = "auth_current_token_version"
|
||||||
ContextAuthFailureDetail = "auth_failure_detail"
|
ContextAuthFailureDetail = "auth_failure_detail"
|
||||||
AdminAccessCookieName = "hfb_admin_access"
|
AdminAccessCookieName = "hfb_admin_access"
|
||||||
|
UserAccessCookieName = "hfb_user_access"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AdminTokenContext struct {
|
type AdminTokenContext struct {
|
||||||
@@ -43,16 +44,20 @@ func extractBearerToken(c *gin.Context) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractToken(c *gin.Context) string {
|
type UserTokenValidatorFunc func(ctx context.Context, userID uint64, tokenVersion int64) error
|
||||||
if tokenText := extractBearerToken(c); tokenText != "" {
|
|
||||||
return tokenText
|
|
||||||
}
|
|
||||||
return c.Query("token")
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
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 == "" {
|
if tokenText == "" {
|
||||||
response.Unauthorized(c, "缺少访问令牌")
|
response.Unauthorized(c, "缺少访问令牌")
|
||||||
c.Abort()
|
c.Abort()
|
||||||
@@ -65,6 +70,13 @@ func Auth(jwtManager *auth.JWTManager) gin.HandlerFunc {
|
|||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
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(ContextUserID, claims.UserID)
|
||||||
c.Set(ContextPhone, claims.Phone)
|
c.Set(ContextPhone, claims.Phone)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"`
|
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"`
|
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"`
|
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"`
|
LastLoginAt *time.Time `json:"last_login_at"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
|||||||
@@ -216,6 +216,10 @@ func (r *Repository) updateStatus(ctx context.Context, adminID uint64, userID ui
|
|||||||
beforeRisk := user.RiskStatus
|
beforeRisk := user.RiskStatus
|
||||||
user.Status = status
|
user.Status = status
|
||||||
user.RiskStatus = riskStatus
|
user.RiskStatus = riskStatus
|
||||||
|
if user.TokenVersion < 1 {
|
||||||
|
user.TokenVersion = 1
|
||||||
|
}
|
||||||
|
user.TokenVersion++
|
||||||
if err := tx.Save(&user).Error; err != nil {
|
if err := tx.Save(&user).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ func (h *Handler) Login(c *gin.Context) {
|
|||||||
writeAuthError(c, err)
|
writeAuthError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
setAccessCookie(c, result.Tokens.AccessToken)
|
||||||
response.OK(c, result)
|
response.OK(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,11 +132,12 @@ func (h *Handler) Refresh(c *gin.Context) {
|
|||||||
response.BadRequest(c, "refresh_token 不能为空")
|
response.BadRequest(c, "refresh_token 不能为空")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tokens, err := h.service.RefreshToken(req.RefreshToken)
|
tokens, err := h.service.RefreshToken(c.Request.Context(), req.RefreshToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Unauthorized(c, "刷新令牌无效或已过期")
|
response.Unauthorized(c, "刷新令牌无效或已过期")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
setAccessCookie(c, tokens.AccessToken)
|
||||||
response.OK(c, tokens)
|
response.OK(c, tokens)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +151,16 @@ func (h *Handler) Refresh(c *gin.Context) {
|
|||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Router /auth/logout [post]
|
// @Router /auth/logout [post]
|
||||||
func (h *Handler) Logout(c *gin.Context) {
|
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})
|
response.OK(c, gin.H{"logged_out": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +175,7 @@ func (h *Handler) PasswordLogin(c *gin.Context) {
|
|||||||
writeAuthError(c, err)
|
writeAuthError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
setAccessCookie(c, result.Tokens.AccessToken)
|
||||||
response.OK(c, result)
|
response.OK(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +190,7 @@ func (h *Handler) Register(c *gin.Context) {
|
|||||||
writeAuthError(c, err)
|
writeAuthError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
setAccessCookie(c, result.Tokens.AccessToken)
|
||||||
response.OK(c, result)
|
response.OK(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,3 +267,17 @@ func currentUserID(c *gin.Context) (uint64, bool) {
|
|||||||
userID, ok := val.(uint64)
|
userID, ok := val.(uint64)
|
||||||
return userID, ok
|
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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,6 +27,21 @@ func (r *UserRepository) FindByID(ctx context.Context, id uint64) (*model.User,
|
|||||||
return &user, nil
|
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) {
|
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{
|
if err := r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Updates(map[string]any{
|
||||||
"nickname": nickname,
|
"nickname": nickname,
|
||||||
@@ -46,6 +61,7 @@ func (r *UserRepository) FindOrCreateByPhone(ctx context.Context, phone string)
|
|||||||
RiskStatus: "normal",
|
RiskStatus: "normal",
|
||||||
CreditScore: 100,
|
CreditScore: 100,
|
||||||
Status: "active",
|
Status: "active",
|
||||||
|
TokenVersion: 1,
|
||||||
LastLoginAt: &now,
|
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 {
|
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) {
|
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",
|
RiskStatus: "normal",
|
||||||
CreditScore: 100,
|
CreditScore: 100,
|
||||||
Status: "active",
|
Status: "active",
|
||||||
|
TokenVersion: 1,
|
||||||
LastLoginAt: &now,
|
LastLoginAt: &now,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ func (s *Service) LoginWithSMS(ctx context.Context, phone string, code string) (
|
|||||||
return LoginResult{}, ErrUserDisabled
|
return LoginResult{}, ErrUserDisabled
|
||||||
}
|
}
|
||||||
|
|
||||||
tokens, err := s.jwt.GeneratePair(user.ID, user.Phone)
|
tokens, err := s.issueTokenPair(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return LoginResult{}, err
|
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
|
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")
|
claims, err := s.jwt.ParseSubject(refreshToken, tokenTypeRefresh, "user")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return TokenPair{}, err
|
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 {
|
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)
|
_ = clearLoginFailure(ctx, s.redis, phone, clientIP)
|
||||||
|
|
||||||
tokens, err := s.jwt.GeneratePair(user.ID, user.Phone)
|
tokens, err := s.issueTokenPair(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return LoginResult{}, err
|
return LoginResult{}, err
|
||||||
}
|
}
|
||||||
@@ -369,7 +387,7 @@ func (s *Service) RegisterWithPassword(ctx context.Context, phone, code, passwor
|
|||||||
return LoginResult{}, ErrUserDisabled
|
return LoginResult{}, ErrUserDisabled
|
||||||
}
|
}
|
||||||
|
|
||||||
tokens, err := s.jwt.GeneratePair(user.ID, user.Phone)
|
tokens, err := s.issueTokenPair(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return LoginResult{}, err
|
return LoginResult{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,13 @@ type UploadDTO struct {
|
|||||||
Filename string `json:"filename"`
|
Filename string `json:"filename"`
|
||||||
ContentType string `json:"content_type"`
|
ContentType string `json:"content_type"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
|
objectKeys []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ObjectKeys 返回上传产生的全部对象键,包含图片缩略图与中图变体。
|
||||||
|
func (d *UploadDTO) ObjectKeys() []string {
|
||||||
|
if d == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return append([]string(nil), d.objectKeys...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,38 @@
|
|||||||
package file
|
package file
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/logging"
|
||||||
"hfb_sys/backend/pkg/response"
|
"hfb_sys/backend/pkg/response"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
service *Service
|
service *Service
|
||||||
storage *Storage
|
storage *Storage
|
||||||
|
objectAuthorizer ObjectAuthorizer
|
||||||
|
uploadOwnerRecorder UploadOwnerRecorder
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHandler(service *Service, storage *Storage) *Handler {
|
// ObjectAuthorizer 校验用户是否可读取指定私有对象。
|
||||||
return &Handler{service: service, storage: storage}
|
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) {
|
func (h *Handler) Upload(c *gin.Context) {
|
||||||
@@ -44,6 +60,19 @@ func (h *Handler) Upload(c *gin.Context) {
|
|||||||
writeFileError(c, err)
|
writeFileError(c, err)
|
||||||
return
|
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)
|
response.Created(c, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +97,6 @@ func (h *Handler) writeObject(c *gin.Context, publicOnly bool) {
|
|||||||
if publicOnly &&
|
if publicOnly &&
|
||||||
!strings.HasPrefix(key, "home-banner/") &&
|
!strings.HasPrefix(key, "home-banner/") &&
|
||||||
!strings.HasPrefix(key, "avatar/") &&
|
!strings.HasPrefix(key, "avatar/") &&
|
||||||
!strings.HasPrefix(key, "payment-cert/") &&
|
|
||||||
!strings.HasPrefix(key, "announcement/") &&
|
!strings.HasPrefix(key, "announcement/") &&
|
||||||
!strings.HasPrefix(key, "mohong/") &&
|
!strings.HasPrefix(key, "mohong/") &&
|
||||||
!strings.HasPrefix(key, "crash/") &&
|
!strings.HasPrefix(key, "crash/") &&
|
||||||
@@ -77,6 +105,29 @@ func (h *Handler) writeObject(c *gin.Context, publicOnly bool) {
|
|||||||
response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问")
|
response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问")
|
||||||
return
|
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)
|
object, err := h.storage.Get(c.Request.Context(), key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问")
|
response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问")
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) {
|
|||||||
}
|
}
|
||||||
var thumbnailURL string
|
var thumbnailURL string
|
||||||
var mediumURL string
|
var mediumURL string
|
||||||
|
objectKeys := []string{key}
|
||||||
for _, variant := range generateImageVariants(key, data, contentType) {
|
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{
|
err := s.storage.PutObject(req.Context, variant.Key, bytes.NewReader(variant.Content), int64(len(variant.Content)), variant.ContentType, map[string]string{
|
||||||
"source-object": key,
|
"source-object": key,
|
||||||
@@ -68,6 +69,7 @@ func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
objectKeys = append(objectKeys, variant.Key)
|
||||||
if strings.Contains(variant.Key, "."+ImageVariantThumb+".") {
|
if strings.Contains(variant.Key, "."+ImageVariantThumb+".") {
|
||||||
thumbnailURL = fileURLForScene(scene, variant.Key)
|
thumbnailURL = fileURLForScene(scene, variant.Key)
|
||||||
}
|
}
|
||||||
@@ -84,6 +86,7 @@ func (s *Service) Upload(req uploadRequest) (*UploadDTO, error) {
|
|||||||
Filename: req.Header.Filename,
|
Filename: req.Header.Filename,
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
Size: int64(len(data)),
|
Size: int64(len(data)),
|
||||||
|
objectKeys: objectKeys,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +109,7 @@ func normalizeContentType(contentType string, data []byte) string {
|
|||||||
|
|
||||||
func fileURLForScene(scene string, key string) string {
|
func fileURLForScene(scene string, key string) string {
|
||||||
fileURL := "/api/files/object?key=" + url.QueryEscape(key)
|
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)
|
fileURL = "/api/public/files/object?key=" + url.QueryEscape(key)
|
||||||
}
|
}
|
||||||
return fileURL
|
return fileURL
|
||||||
|
|||||||
@@ -157,6 +157,36 @@ func (s *Storage) Get(ctx context.Context, key string) (*Object, error) {
|
|||||||
}, nil
|
}, 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 {
|
func (s *Storage) ensureBucket(ctx context.Context) error {
|
||||||
exists, err := s.client.BucketExists(ctx, s.bucket)
|
exists, err := s.client.BucketExists(ctx, s.bucket)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -43,6 +43,33 @@ type ListingDTO struct {
|
|||||||
UpdatedAt time.Time `json:"updated_at"`
|
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 {
|
type CreateRequest struct {
|
||||||
Title string `json:"title" binding:"required"`
|
Title string `json:"title" binding:"required"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
@@ -159,7 +186,7 @@ type NumberRange struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PublicListResult struct {
|
type PublicListResult struct {
|
||||||
Items []ListingDTO `json:"items"`
|
Items []PublicListingListItemDTO `json:"items"`
|
||||||
Total int64 `json:"total"`
|
Total int64 `json:"total"`
|
||||||
Page int `json:"page"`
|
Page int `json:"page"`
|
||||||
PageSize int `json:"page_size"`
|
PageSize int `json:"page_size"`
|
||||||
|
|||||||
@@ -71,9 +71,100 @@ func applyPublicListingURLs(item *ListingDTO) {
|
|||||||
if item.AssetSummary != nil {
|
if item.AssetSummary != nil {
|
||||||
delete(item.AssetSummary, "import_meta")
|
delete(item.AssetSummary, "import_meta")
|
||||||
delete(item.AssetSummary, "price_breakdown")
|
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) {
|
func applySellerListingPrice(item *ListingDTO) {
|
||||||
if item == nil {
|
if item == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ func (r *Repository) ListPublic(ctx context.Context, query PublicListQuery) (*Pu
|
|||||||
items = items[start:end]
|
items = items[start:end]
|
||||||
}
|
}
|
||||||
return &PublicListResult{
|
return &PublicListResult{
|
||||||
Items: items,
|
Items: publicListingListItems(items),
|
||||||
Total: total,
|
Total: total,
|
||||||
Page: page,
|
Page: page,
|
||||||
PageSize: pageSize,
|
PageSize: pageSize,
|
||||||
@@ -79,7 +79,7 @@ func (r *Repository) listPublicPage(ctx context.Context, query PublicListQuery,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &PublicListResult{
|
return &PublicListResult{
|
||||||
Items: publicListings(rowsToDTO(rows)),
|
Items: publicListingListItems(publicListings(rowsToDTO(rows))),
|
||||||
Total: total,
|
Total: total,
|
||||||
Page: page,
|
Page: page,
|
||||||
PageSize: pageSize,
|
PageSize: pageSize,
|
||||||
@@ -219,13 +219,14 @@ func copyPublicZoneCounts(counts map[string]int64) map[string]int64 {
|
|||||||
return copied
|
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)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
applyPublicListingURLs(dto)
|
applyPublicListingURLs(dto)
|
||||||
return dto, nil
|
item := dto.toPublicDetail()
|
||||||
|
return &item, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) FindPublicCoverKey(ctx context.Context, id uint64) (string, error) {
|
func (r *Repository) FindPublicCoverKey(ctx context.Context, id uint64) (string, error) {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ func (s *Service) ListMine(ctx context.Context, ownerID uint64) ([]ListingDTO, e
|
|||||||
return s.repo.ListMine(ctx, ownerID)
|
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 {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -331,6 +332,7 @@ func TestApplyPublicListingURLsHidesSourceChannel(t *testing.T) {
|
|||||||
AssetSummary: map[string]any{
|
AssetSummary: map[string]any{
|
||||||
"import_meta": map[string]any{"uploader_name": "客服1"},
|
"import_meta": map[string]any{"uploader_name": "客服1"},
|
||||||
"price_breakdown": map[string]any{"buyer_total_price": 100},
|
"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 {
|
if _, ok := item.AssetSummary["price_breakdown"]; ok {
|
||||||
t.Fatal("expected price_breakdown hidden from public listing")
|
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) {
|
func TestParseExternalUploadItemsAcceptsSingleObject(t *testing.T) {
|
||||||
|
|||||||
@@ -77,6 +77,46 @@ type OrderDTO struct {
|
|||||||
UpdatedAt time.Time `json:"updated_at"`
|
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 {
|
type AdminActionsDTO struct {
|
||||||
ResetHandoff *AdminActionDTO `json:"reset_handoff,omitempty"`
|
ResetHandoff *AdminActionDTO `json:"reset_handoff,omitempty"`
|
||||||
PlatformHandoff *AdminActionDTO `json:"platform_handoff,omitempty"`
|
PlatformHandoff *AdminActionDTO `json:"platform_handoff,omitempty"`
|
||||||
@@ -169,6 +209,45 @@ type PaginatedResult struct {
|
|||||||
PageSize int `json:"page_size"`
|
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 {
|
type RefundStatusDTO struct {
|
||||||
OrderID uint64 `json:"order_id"`
|
OrderID uint64 `json:"order_id"`
|
||||||
OrderNo string `json:"order_no"`
|
OrderNo string `json:"order_no"`
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ func writeOrderError(c *gin.Context, err error) {
|
|||||||
response.ServiceUnavailable(c, "数据库未连接")
|
response.ServiceUnavailable(c, "数据库未连接")
|
||||||
case errors.Is(err, ErrInvalidRentHours):
|
case errors.Is(err, ErrInvalidRentHours):
|
||||||
response.BadRequest(c, "订单信息不符合规则")
|
response.BadRequest(c, "订单信息不符合规则")
|
||||||
|
case errors.Is(err, ErrInvalidSellerHandoffStatus):
|
||||||
|
response.BadRequest(c, "待办状态不正确")
|
||||||
case errors.Is(err, ErrListingUnavailable):
|
case errors.Is(err, ErrListingUnavailable):
|
||||||
response.Error(c, http.StatusConflict, "listing_unavailable", "该账号暂不可租")
|
response.Error(c, http.StatusConflict, "listing_unavailable", "该账号暂不可租")
|
||||||
case errors.Is(err, ErrCannotRentOwnListing):
|
case errors.Is(err, ErrCannotRentOwnListing):
|
||||||
|
|||||||
@@ -31,12 +31,32 @@ func (h *Handler) List(c *gin.Context) {
|
|||||||
response.Unauthorized(c, "缺少用户上下文")
|
response.Unauthorized(c, "缺少用户上下文")
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
writeOrderError(c, err)
|
writeOrderError(c, err)
|
||||||
return
|
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) {
|
func (h *Handler) Detail(c *gin.Context) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package order
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/model"
|
"hfb_sys/backend/internal/model"
|
||||||
|
|
||||||
@@ -150,6 +151,63 @@ func (row orderRow) toDTOForUser(userID uint64) OrderDTO {
|
|||||||
return dto
|
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 {
|
func effectiveHandoffMode(order model.RentalOrder) string {
|
||||||
if order.HandoffMode != "" {
|
if order.HandoffMode != "" {
|
||||||
return order.HandoffMode
|
return order.HandoffMode
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package order
|
package order
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -39,3 +41,52 @@ func TestOrderDTOForUserHidesDepositHoldFields(t *testing.T) {
|
|||||||
t.Fatalf("deposit hold released at = %v, want nil", dto.DepositHoldReleasedAt)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,32 +11,116 @@ import (
|
|||||||
"gorm.io/gorm"
|
"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
|
var rows []orderRow
|
||||||
db := r.db.WithContext(ctx)
|
db := r.db.WithContext(ctx)
|
||||||
err := r.baseQuery(ctx).
|
err := base.
|
||||||
Where("o.renter_id = ? OR o.owner_id = ?", userID, userID).
|
|
||||||
Order("o.id DESC").
|
Order("o.id DESC").
|
||||||
|
Offset((page - 1) * pageSize).
|
||||||
|
Limit(pageSize).
|
||||||
Scan(&rows).Error
|
Scan(&rows).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items := make([]OrderDTO, 0, len(rows))
|
items := make([]UserOrderListItemDTO, 0, len(rows))
|
||||||
paymentTimeoutMinutes := pendingPaymentTimeoutMinutes(db)
|
paymentTimeoutMinutes := pendingPaymentTimeoutMinutes(db)
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
dto := row.toDTOForUser(userID)
|
var deadline *time.Time
|
||||||
applyPaymentDeadline(&dto, row.RentalOrder, paymentTimeoutMinutes)
|
if row.Status == orderStatusPendingPayment && paymentTimeoutMinutes > 0 {
|
||||||
if shouldAttachCheckout(row.Status) {
|
value := row.CreatedAt.Add(time.Duration(paymentTimeoutMinutes) * time.Minute)
|
||||||
dto.Checkout = r.latestCheckoutDTOForUser(ctx, row.ID, userID, row.RentalOrder)
|
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
|
var total int64
|
||||||
db := r.db.WithContext(ctx)
|
|
||||||
countDB := applyAdminOrderFilters(r.adminOrderJoinQuery(ctx), query)
|
countDB := applyAdminOrderFilters(r.adminOrderJoinQuery(ctx), query)
|
||||||
if err := countDB.Count(&total).Error; err != nil {
|
if err := countDB.Count(&total).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -61,15 +145,12 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminOrderQuery) (*Pag
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
items := make([]OrderDTO, 0, len(rows))
|
items := make([]AdminOrderListItemDTO, 0, len(rows))
|
||||||
paymentTimeoutMinutes := pendingPaymentTimeoutMinutes(db)
|
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
dto := row.toAdminDTO()
|
items = append(items, row.toAdminListItem())
|
||||||
applyPaymentDeadline(&dto, row.RentalOrder, paymentTimeoutMinutes)
|
|
||||||
items = append(items, dto)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &PaginatedResult{
|
return &AdminOrderListPaginatedResult{
|
||||||
Items: items,
|
Items: items,
|
||||||
Total: total,
|
Total: total,
|
||||||
Page: page,
|
Page: page,
|
||||||
@@ -77,6 +158,19 @@ func (r *Repository) ListAdmin(ctx context.Context, query AdminOrderQuery) (*Pag
|
|||||||
}, nil
|
}, 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 {
|
func applyAdminOrderFilters(db *gorm.DB, query AdminOrderQuery) *gorm.DB {
|
||||||
if query.Status != "" {
|
if query.Status != "" {
|
||||||
db = db.Where("o.status = ?", query.Status)
|
db = db.Where("o.status = ?", query.Status)
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ var (
|
|||||||
ErrDepositNotHeld = errors.New("deposit not held")
|
ErrDepositNotHeld = errors.New("deposit not held")
|
||||||
ErrDepositHoldAmountEmpty = errors.New("deposit hold amount empty")
|
ErrDepositHoldAmountEmpty = errors.New("deposit hold amount empty")
|
||||||
ErrOfflineSettlementCannotMark = errors.New("offline settlement cannot mark")
|
ErrOfflineSettlementCannotMark = errors.New("offline settlement cannot mark")
|
||||||
|
ErrInvalidSellerHandoffStatus = errors.New("invalid seller handoff status")
|
||||||
)
|
)
|
||||||
|
|
||||||
const internalOrderHours = 24
|
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)
|
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 {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
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 {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,6 +177,8 @@ func writeError(c *gin.Context, err error) {
|
|||||||
response.BadRequest(c, "收款账号数量已达上限(最多5个)")
|
response.BadRequest(c, "收款账号数量已达上限(最多5个)")
|
||||||
case ErrCannotDeleteDefault:
|
case ErrCannotDeleteDefault:
|
||||||
response.BadRequest(c, "无法删除默认账号")
|
response.BadRequest(c, "无法删除默认账号")
|
||||||
|
case ErrInvalidCertificateURL:
|
||||||
|
response.BadRequest(c, "收款凭证必须使用本人上传的图片")
|
||||||
default:
|
default:
|
||||||
response.InternalServerError(c, "操作失败")
|
response.InternalServerError(c, "操作失败")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"hfb_sys/backend/internal/model"
|
"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) {
|
func (r *Repository) Create(ctx context.Context, userID uint64, req CreatePaymentAccountRequest) (*PaymentAccountDTO, error) {
|
||||||
db := r.db.WithContext(ctx)
|
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)
|
encryptedNo, err := r.encryptor.Encrypt(req.AccountNo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -131,6 +135,11 @@ func (r *Repository) Update(ctx context.Context, userID, id uint64, req UpdatePa
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(req.CertificateURLs) > 0 {
|
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)
|
certURLs, _ := json.Marshal(req.CertificateURLs)
|
||||||
updates["certificate_urls"] = certURLs
|
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)
|
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 {
|
func (r *Repository) Delete(ctx context.Context, userID, id uint64) error {
|
||||||
db := r.db.WithContext(ctx)
|
db := r.db.WithContext(ctx)
|
||||||
var account model.UserPaymentAccount
|
var account model.UserPaymentAccount
|
||||||
@@ -269,6 +326,7 @@ func (r *Repository) toDTO(account model.UserPaymentAccount) (*PaymentAccountDTO
|
|||||||
if account.CertificateURLs != nil {
|
if account.CertificateURLs != nil {
|
||||||
json.Unmarshal(account.CertificateURLs, &certURLs)
|
json.Unmarshal(account.CertificateURLs, &certURLs)
|
||||||
}
|
}
|
||||||
|
certURLs = privateCertificateURLs(certURLs)
|
||||||
|
|
||||||
return &PaymentAccountDTO{
|
return &PaymentAccountDTO{
|
||||||
ID: account.ID,
|
ID: account.ID,
|
||||||
@@ -286,6 +344,22 @@ func (r *Repository) toDTO(account model.UserPaymentAccount) (*PaymentAccountDTO
|
|||||||
}, nil
|
}, 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 {
|
func maskAccountNo(accountNo, accountType string) string {
|
||||||
length := len(accountNo)
|
length := len(accountNo)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ var (
|
|||||||
ErrRealnameRequired = errors.New("realname verification required")
|
ErrRealnameRequired = errors.New("realname verification required")
|
||||||
ErrAccountLimit = errors.New("maximum payment accounts reached")
|
ErrAccountLimit = errors.New("maximum payment accounts reached")
|
||||||
ErrCannotDeleteDefault = errors.New("cannot delete default account")
|
ErrCannotDeleteDefault = errors.New("cannot delete default account")
|
||||||
|
ErrInvalidCertificateURL = errors.New("invalid certificate url")
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
|
|||||||
@@ -1,11 +1,119 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hfb_sys/backend/internal/model"
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Dependencies struct {
|
type Dependencies struct {
|
||||||
DB *gorm.DB
|
DB *gorm.DB
|
||||||
Redis *redis.Client
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -355,7 +355,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fileService := filemodule.NewService(fileStorage)
|
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)
|
listingService := listing.NewService(listingRepo, systemConfigRepo)
|
||||||
listingHandler := listing.NewHandler(listingService, fileStorage, listing.HandlerOptions{
|
listingHandler := listing.NewHandler(listingService, fileStorage, listing.HandlerOptions{
|
||||||
ExternalUploadSecret: cfg.ExternalUploadSecret,
|
ExternalUploadSecret: cfg.ExternalUploadSecret,
|
||||||
@@ -367,7 +367,14 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
}
|
}
|
||||||
announcementService := announcement.NewService(announcementRepo)
|
announcementService := announcement.NewService(announcementRepo)
|
||||||
announcementHandler := announcement.NewHandler(announcementService)
|
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
|
var validateAdminToken middleware.AdminTokenValidatorFunc
|
||||||
if adminAuthRepo != nil {
|
if adminAuthRepo != nil {
|
||||||
validateAdminToken = func(ctx context.Context, adminID uint64, tokenVersion int64) (middleware.AdminTokenContext, error) {
|
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/register", authHandler.Register)
|
||||||
authRoutes.POST("/password/reset", authHandler.ResetPassword)
|
authRoutes.POST("/password/reset", authHandler.ResetPassword)
|
||||||
authRoutes.POST("/refresh", authHandler.Refresh)
|
authRoutes.POST("/refresh", authHandler.Refresh)
|
||||||
authRoutes.POST("/logout", authHandler.Logout)
|
authRoutes.POST("/logout", requireAuth, authHandler.Logout)
|
||||||
}
|
}
|
||||||
|
|
||||||
api.GET("/me", requireAuth, userHandler.Me)
|
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.POST("", requireRealname, orderHandler.Create)
|
||||||
orderRoutes.GET("", orderHandler.List)
|
orderRoutes.GET("", orderHandler.List)
|
||||||
|
orderRoutes.GET("/handoffs", orderHandler.ListSellerHandoffs)
|
||||||
orderRoutes.GET("/:id", orderHandler.Detail)
|
orderRoutes.GET("/:id", orderHandler.Detail)
|
||||||
orderRoutes.GET("/:id/chat", chatHandler.OrderConversation)
|
orderRoutes.GET("/:id/chat", chatHandler.OrderConversation)
|
||||||
orderRoutes.POST("/:id/pay", orderHandler.Pay)
|
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/manual-realname", requirePerm("user:manual_realname"), adminUserHandler.ManualRealname)
|
||||||
adminRoutes.POST("/users/:id/revoke-realname", requirePerm("user:revoke_realname"), adminUserHandler.RevokeRealname)
|
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.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("/listing-orders/:id/latest", requirePerm("order:view"), orderHandler.AdminLatestByListing)
|
||||||
adminRoutes.GET("/orders/:id", requirePerm("order:view"), orderHandler.AdminDetail)
|
adminRoutes.GET("/orders/:id", requirePerm("order:view"), orderHandler.AdminDetail)
|
||||||
adminRoutes.GET("/orders/:id/handoff-records", requirePerm("order:view"), orderHandler.AdminHandoffRecords)
|
adminRoutes.GET("/orders/:id/handoff-records", requirePerm("order:view"), orderHandler.AdminHandoffRecords)
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
# 防止客户端伪造 X-Forwarded-For 影响限流、审计和支付下单的 client_ip。
|
# 防止客户端伪造 X-Forwarded-For 影响限流、审计和支付下单的 client_ip。
|
||||||
(backend_proxy) {
|
(backend_proxy) {
|
||||||
header_up X-Real-IP {remote_host}
|
header_up X-Real-IP {remote_host}
|
||||||
|
header_up X-Forwarded-Proto {scheme}
|
||||||
}
|
}
|
||||||
|
|
||||||
# 公开 API 反代片段:主站与选号网共用。
|
# 公开 API 反代片段:主站与选号网共用。
|
||||||
|
|||||||
@@ -18,12 +18,14 @@ __CADDY_SHOW_DOMAIN__ {
|
|||||||
}
|
}
|
||||||
reverse_proxy backend:8080 {
|
reverse_proxy backend:8080 {
|
||||||
header_up X-Real-IP {remote_host}
|
header_up X-Real-IP {remote_host}
|
||||||
|
header_up X-Forwarded-Proto {scheme}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handle /health {
|
handle /health {
|
||||||
reverse_proxy backend:8080 {
|
reverse_proxy backend:8080 {
|
||||||
header_up X-Real-IP {remote_host}
|
header_up X-Real-IP {remote_host}
|
||||||
|
header_up X-Forwarded-Proto {scheme}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ArrowLeft, Refresh, Search } from '@element-plus/icons-vue'
|
|||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
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 {
|
import {
|
||||||
orderHandoffStatusLabel,
|
orderHandoffStatusLabel,
|
||||||
orderStatusLabel,
|
orderStatusLabel,
|
||||||
@@ -24,7 +24,7 @@ const filters = reactive<AdminOrderQuery>({
|
|||||||
offline_settlement_status: '',
|
offline_settlement_status: '',
|
||||||
})
|
})
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const orders = ref<Order[]>([])
|
const orders = ref<AdminOrderListItem[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const currentPageSize = ref(20)
|
const currentPageSize = ref(20)
|
||||||
@@ -153,7 +153,7 @@ function userText(value: string | number | undefined) {
|
|||||||
return value || '-'
|
return value || '-'
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPlatformManaged(row: Order) {
|
function isPlatformManaged(row: AdminOrderListItem) {
|
||||||
return row.handoff_mode === 'platform' || row.settlement_mode === 'platform_managed'
|
return row.handoff_mode === 'platform' || row.settlement_mode === 'platform_managed'
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -298,9 +298,9 @@ function isPlatformManaged(row: Order) {
|
|||||||
<el-table-column label="用户" min-width="170">
|
<el-table-column label="用户" min-width="170">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="stacked-cell compact">
|
<div class="stacked-cell compact">
|
||||||
<span>租客 {{ userText(row.renter_phone || row.renter_id) }}</span>
|
<span>租客 {{ userText(row.renter_phone) }}</span>
|
||||||
<span v-if="isPlatformManaged(row)" class="platform-managed-owner">号主 平台代管</span>
|
<span v-if="isPlatformManaged(row)" class="platform-managed-owner">号主 平台代管</span>
|
||||||
<span v-else>号主 {{ userText(row.owner_phone || row.owner_id) }}</span>
|
<span v-else>号主 {{ userText(row.owner_phone) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|||||||
@@ -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 })
|
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() {
|
export async function fetchMe() {
|
||||||
const { data } = await apiClient.get<ApiResponse<AuthUser>>('/me')
|
const { data } = await apiClient.get<ApiResponse<AuthUser>>('/me')
|
||||||
return data.data
|
return data.data
|
||||||
|
|||||||
@@ -267,8 +267,8 @@ function confirmLogout() {
|
|||||||
confirmButtonColor: '#ee0a24',
|
confirmButtonColor: '#ee0a24',
|
||||||
cancelButtonText: '取消',
|
cancelButtonText: '取消',
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(async () => {
|
||||||
session.logout()
|
await session.logout()
|
||||||
router.replace('/m')
|
router.replace('/m')
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { onBeforeUnmount, ref, type Ref } from 'vue'
|
import { onBeforeUnmount, ref, type Ref } from 'vue'
|
||||||
import { refreshAccessToken } from '@/shared/api/client'
|
import { refreshAccessToken } from '@/shared/api/client'
|
||||||
import { getAccessToken, type AuthScope } from '@/shared/utils/authStorage'
|
import type { AuthScope } from '@/shared/utils/authStorage'
|
||||||
|
|
||||||
export interface SSEMessage {
|
export interface SSEMessage {
|
||||||
id: number
|
id: number
|
||||||
@@ -42,13 +42,8 @@ export function useChatSSE(scope: AuthScope, endpoint: string) {
|
|||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
if (stopped || source) return
|
if (stopped || source) return
|
||||||
const token = getAccessToken(scope)
|
// SSE 与普通 API 一样使用同源 HttpOnly Cookie 鉴权,避免令牌进入 URL 和代理日志。
|
||||||
// 用户端通过 query token 鉴权;后台 token 存于 httpOnly cookie,
|
source = new EventSource(endpoint, { withCredentials: true })
|
||||||
// localStorage 无 token,连接时依赖浏览器自动携带的 cookie,不能因空 token 提前返回。
|
|
||||||
if (scope !== 'admin' && !token) return
|
|
||||||
|
|
||||||
const url = scope === 'admin' ? endpoint : `${endpoint}?token=${encodeURIComponent(token)}`
|
|
||||||
source = new EventSource(url, { withCredentials: true })
|
|
||||||
|
|
||||||
source.addEventListener('connected', () => {
|
source.addEventListener('connected', () => {
|
||||||
connected.value = true
|
connected.value = true
|
||||||
|
|||||||
@@ -34,6 +34,32 @@ export interface Listing {
|
|||||||
updated_at: string
|
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<string, unknown>
|
||||||
|
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 {
|
export interface ListingPayload {
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
@@ -85,7 +111,7 @@ export interface PublicListingQuery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PublicListingPage {
|
export interface PublicListingPage {
|
||||||
items: Listing[]
|
items: PublicListingItem[]
|
||||||
total: number
|
total: number
|
||||||
page: number
|
page: number
|
||||||
page_size: number
|
page_size: number
|
||||||
@@ -126,7 +152,7 @@ function normalizePublicListingPage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchListing(id: string | number) {
|
export async function fetchListing(id: string | number) {
|
||||||
const { data } = await apiClient.get<ApiResponse<Listing>>(`/listings/${id}`)
|
const { data } = await apiClient.get<ApiResponse<PublicListingDetail>>(`/listings/${id}`)
|
||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
import type { Listing } from '@/features/listings'
|
import type { PublicListingItem } from '@/features/listings'
|
||||||
import { formatCent, formatMoney } from '@/shared/utils/money'
|
import { formatCent, formatMoney } from '@/shared/utils/money'
|
||||||
import {
|
import {
|
||||||
formatEstimatedRentalDuration,
|
formatEstimatedRentalDuration,
|
||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
} from '@/shared/utils/listingDisplay'
|
} from '@/shared/utils/listingDisplay'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
listing: Listing
|
listing: PublicListingItem
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { reactive, ref, computed, type Ref } from 'vue'
|
import { reactive, ref, computed, type Ref } from 'vue'
|
||||||
import type { ListingPublishOptions } from '../api/listingOptions'
|
import type { ListingPublishOptions } from '../api/listingOptions'
|
||||||
import type { Listing } from '../api/listings'
|
import type { PublicListingItem } from '../api/listings'
|
||||||
import { assetRegions } from '@/shared/utils/listingDisplay'
|
import { assetRegions } from '@/shared/utils/listingDisplay'
|
||||||
|
|
||||||
/** PC 默认区优先展示的皮肤组:刀皮 / 红皮 */
|
/** PC 默认区优先展示的皮肤组:刀皮 / 红皮 */
|
||||||
@@ -68,7 +68,7 @@ export interface SkinFilterGroup {
|
|||||||
|
|
||||||
export function useHomeFilters(
|
export function useHomeFilters(
|
||||||
publishOptions: Ref<ListingPublishOptions>,
|
publishOptions: Ref<ListingPublishOptions>,
|
||||||
listings: Ref<Listing[]>
|
listings: Ref<PublicListingItem[]>
|
||||||
) {
|
) {
|
||||||
const filters = reactive<HomeFilters>({
|
const filters = reactive<HomeFilters>({
|
||||||
keyword: '',
|
keyword: '',
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
import type { Listing } from '@/features/listings/api/listings'
|
import type { PublicListingItem } from '@/features/listings/api/listings'
|
||||||
|
|
||||||
const historyKey = 'hfb.listing.view.history'
|
const historyKey = 'hfb.listing.view.history'
|
||||||
const favoriteKey = 'hfb.listing.favorites'
|
const favoriteKey = 'hfb.listing.favorites'
|
||||||
const maxHistoryItems = 80
|
const maxHistoryItems = 80
|
||||||
|
|
||||||
export interface ListingCollectionItem {
|
export interface ListingCollectionItem {
|
||||||
listing: Listing
|
listing: PublicListingItem
|
||||||
viewed_at?: string
|
viewed_at?: string
|
||||||
favorited_at?: string
|
favorited_at?: string
|
||||||
}
|
}
|
||||||
@@ -20,7 +20,7 @@ export function useListingCollections() {
|
|||||||
const favorites = computed(() => favoriteItems.value)
|
const favorites = computed(() => favoriteItems.value)
|
||||||
const favoriteIDs = computed(() => new Set(favoriteItems.value.map(item => item.listing.id)))
|
const favoriteIDs = computed(() => new Set(favoriteItems.value.map(item => item.listing.id)))
|
||||||
|
|
||||||
function recordListingView(listing: Listing) {
|
function recordListingView(listing: PublicListingItem) {
|
||||||
const nextItem: ListingCollectionItem = {
|
const nextItem: ListingCollectionItem = {
|
||||||
listing: snapshotListing(listing),
|
listing: snapshotListing(listing),
|
||||||
viewed_at: new Date().toISOString(),
|
viewed_at: new Date().toISOString(),
|
||||||
@@ -36,7 +36,7 @@ export function useListingCollections() {
|
|||||||
return favoriteIDs.value.has(listingID)
|
return favoriteIDs.value.has(listingID)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleFavorite(listing: Listing) {
|
function toggleFavorite(listing: PublicListingItem) {
|
||||||
if (isFavorite(listing.id)) {
|
if (isFavorite(listing.id)) {
|
||||||
favoriteItems.value = favoriteItems.value.filter(item => item.listing.id !== listing.id)
|
favoriteItems.value = favoriteItems.value.filter(item => item.listing.id !== listing.id)
|
||||||
writeItems(favoriteKey, favoriteItems.value)
|
writeItems(favoriteKey, favoriteItems.value)
|
||||||
@@ -100,7 +100,7 @@ function normalizeItem(value: unknown): ListingCollectionItem | null {
|
|||||||
const id = Number(value.listing.id)
|
const id = Number(value.listing.id)
|
||||||
if (!Number.isFinite(id) || id <= 0) return null
|
if (!Number.isFinite(id) || id <= 0) return null
|
||||||
return {
|
return {
|
||||||
listing: value.listing as unknown as Listing,
|
listing: value.listing as unknown as PublicListingItem,
|
||||||
viewed_at: typeof value.viewed_at === 'string' ? value.viewed_at : undefined,
|
viewed_at: typeof value.viewed_at === 'string' ? value.viewed_at : undefined,
|
||||||
favorited_at: typeof value.favorited_at === 'string' ? value.favorited_at : undefined,
|
favorited_at: typeof value.favorited_at === 'string' ? value.favorited_at : undefined,
|
||||||
}
|
}
|
||||||
@@ -111,8 +111,8 @@ function writeItems(key: string, items: ListingCollectionItem[]) {
|
|||||||
localStorage.setItem(key, JSON.stringify(items))
|
localStorage.setItem(key, JSON.stringify(items))
|
||||||
}
|
}
|
||||||
|
|
||||||
function snapshotListing(listing: Listing): Listing {
|
function snapshotListing(listing: PublicListingItem): PublicListingItem {
|
||||||
return JSON.parse(JSON.stringify(listing)) as Listing
|
return JSON.parse(JSON.stringify(listing)) as PublicListingItem
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ref, onMounted, onBeforeUnmount, watch, type Ref } from 'vue'
|
import { ref, onMounted, onBeforeUnmount, watch, type Ref } from 'vue'
|
||||||
import { fetchListingsPage, type Listing, type PublicListingQuery } from '../api/listings'
|
import { fetchListingsPage, type PublicListingItem, type PublicListingQuery } from '../api/listings'
|
||||||
import type { HomeFilters } from './useHomeFilters'
|
import type { HomeFilters } from './useHomeFilters'
|
||||||
|
|
||||||
const homePageSize = 12
|
const homePageSize = 12
|
||||||
@@ -11,7 +11,7 @@ export function useListingQuery(
|
|||||||
) {
|
) {
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const loadingMore = ref(false)
|
const loadingMore = ref(false)
|
||||||
const listings = ref<Listing[]>([])
|
const listings = ref<PublicListingItem[]>([])
|
||||||
const totalListings = ref(0)
|
const totalListings = ref(0)
|
||||||
const zoneCounts = ref<Record<string, number>>({})
|
const zoneCounts = ref<Record<string, number>>({})
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import { ShoppingCart, Star, StarFilled, ArrowLeft } from '@element-plus/icons-vue'
|
import { ShoppingCart, Star, StarFilled, ArrowLeft } from '@element-plus/icons-vue'
|
||||||
import { goBackOrHome } from '@/shared/utils/routerBack'
|
import { goBackOrHome } from '@/shared/utils/routerBack'
|
||||||
|
|
||||||
import { fetchListing, type Listing } from '@/features/listings/api/listings'
|
import { fetchListing, type PublicListingDetail } from '@/features/listings/api/listings'
|
||||||
import { useListingCollections } from '@/features/listings/composables/useListingCollections'
|
import { useListingCollections } from '@/features/listings/composables/useListingCollections'
|
||||||
import {
|
import {
|
||||||
createOrder,
|
createOrder,
|
||||||
@@ -63,7 +63,7 @@ const canCreateOrderAfterAgreement = computed(
|
|||||||
virtualAgreementChecked.value &&
|
virtualAgreementChecked.value &&
|
||||||
renterAgreementChecked.value
|
renterAgreementChecked.value
|
||||||
)
|
)
|
||||||
const listing = ref<Listing | null>(null)
|
const listing = ref<PublicListingDetail | null>(null)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -135,7 +135,7 @@ const detailScreenshots = computed(() => {
|
|||||||
})
|
})
|
||||||
const detailScreenshotUrls = computed(() => detailScreenshots.value.map(shot => shot.url))
|
const detailScreenshotUrls = computed(() => detailScreenshots.value.map(shot => shot.url))
|
||||||
|
|
||||||
function readGroupedScreenshots(item: Listing) {
|
function readGroupedScreenshots(item: PublicListingDetail) {
|
||||||
const groups = item.asset_summary?.screenshot_groups
|
const groups = item.asset_summary?.screenshot_groups
|
||||||
if (typeof groups !== 'object' || groups === null) return []
|
if (typeof groups !== 'object' || groups === null) return []
|
||||||
const slots = [
|
const slots = [
|
||||||
@@ -337,7 +337,7 @@ watch(agreementVisible, visible => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function listingPrice(item: Listing) {
|
function listingPrice(item: PublicListingDetail) {
|
||||||
return formatMoney(getListingDisplayPrice(item))
|
return formatMoney(getListingDisplayPrice(item))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,9 +505,7 @@ function goBack() {
|
|||||||
<span class="order-eyebrow">平台担保</span>
|
<span class="order-eyebrow">平台担保</span>
|
||||||
<h2>立即下单</h2>
|
<h2>立即下单</h2>
|
||||||
</div>
|
</div>
|
||||||
<span class="order-state" :class="{ 'is-busy': listing.in_transaction }">
|
<span class="order-state">可租</span>
|
||||||
{{ listing.in_transaction ? '交易中' : '可租' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="order-safe-text">平台托管订单与押金,按平台交接流程完成账号使用。</p>
|
<p class="order-safe-text">平台托管订单与押金,按平台交接流程完成账号使用。</p>
|
||||||
|
|
||||||
@@ -553,12 +551,11 @@ function goBack() {
|
|||||||
type="warning"
|
type="warning"
|
||||||
size="large"
|
size="large"
|
||||||
:loading="ordering || agreementsLoading"
|
:loading="ordering || agreementsLoading"
|
||||||
:disabled="listing.in_transaction"
|
|
||||||
class="full-control order-primary-btn"
|
class="full-control order-primary-btn"
|
||||||
@click="handleCreateOrder"
|
@click="handleCreateOrder"
|
||||||
>
|
>
|
||||||
<el-icon><ShoppingCart /></el-icon>
|
<el-icon><ShoppingCart /></el-icon>
|
||||||
{{ listing.in_transaction ? '交易中' : '立即下单' }}
|
立即下单
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
class="full-control favorite-order-btn"
|
class="full-control favorite-order-btn"
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from '@/features/listings/api/listingOptions'
|
} from '@/features/listings/api/listingOptions'
|
||||||
import {
|
import {
|
||||||
fetchListingsPage,
|
fetchListingsPage,
|
||||||
type Listing,
|
type PublicListingItem,
|
||||||
type PublicListingQuery,
|
type PublicListingQuery,
|
||||||
} from '@/features/listings/api/listings'
|
} from '@/features/listings/api/listings'
|
||||||
import {
|
import {
|
||||||
@@ -65,7 +65,7 @@ const scrollEl = ref<HTMLElement | null>(null)
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const loadingMore = ref(false)
|
const loadingMore = ref(false)
|
||||||
const loadFailed = ref(false)
|
const loadFailed = ref(false)
|
||||||
const listings = ref<Listing[]>([])
|
const listings = ref<PublicListingItem[]>([])
|
||||||
const totalListings = ref(0)
|
const totalListings = ref(0)
|
||||||
const zoneCounts = ref<Record<string, number>>({})
|
const zoneCounts = ref<Record<string, number>>({})
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
@@ -772,7 +772,7 @@ function getAccessBadgeMeta(value: string) {
|
|||||||
return { icon: 'bookmark-o', tone: 'default' }
|
return { icon: 'bookmark-o', tone: 'default' }
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMobileRatioText(item: Listing) {
|
function getMobileRatioText(item: PublicListingItem) {
|
||||||
const ratio = getRatioValue(item)
|
const ratio = getRatioValue(item)
|
||||||
if (ratio <= 0) return ''
|
if (ratio <= 0) return ''
|
||||||
const formatted = Number.isInteger(ratio) ? String(ratio) : ratio.toFixed(1)
|
const formatted = Number.isInteger(ratio) ? String(ratio) : ratio.toFixed(1)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { computed, nextTick, onMounted, ref } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { showToast, showDialog } from 'vant'
|
import { showToast, showDialog } from 'vant'
|
||||||
|
|
||||||
import { fetchListing, type Listing } from '@/features/listings/api/listings'
|
import { fetchListing, type PublicListingDetail } from '@/features/listings/api/listings'
|
||||||
import { useListingCollections } from '@/features/listings/composables/useListingCollections'
|
import { useListingCollections } from '@/features/listings/composables/useListingCollections'
|
||||||
import {
|
import {
|
||||||
createOrder,
|
createOrder,
|
||||||
@@ -43,7 +43,7 @@ const loading = ref(false)
|
|||||||
const ordering = ref(false)
|
const ordering = ref(false)
|
||||||
const agreementsLoading = ref(false)
|
const agreementsLoading = ref(false)
|
||||||
const agreementVisible = ref(false)
|
const agreementVisible = ref(false)
|
||||||
const listing = ref<Listing | null>(null)
|
const listing = ref<PublicListingDetail | null>(null)
|
||||||
const agreements = ref<OrderAgreements | null>(null)
|
const agreements = ref<OrderAgreements | null>(null)
|
||||||
const virtualAgreementRead = ref(false)
|
const virtualAgreementRead = ref(false)
|
||||||
const renterAgreementRead = ref(false)
|
const renterAgreementRead = ref(false)
|
||||||
@@ -135,7 +135,7 @@ const detailScreenshots = computed(() => {
|
|||||||
})
|
})
|
||||||
const detailScreenshotUrls = computed(() => detailScreenshots.value.map(shot => shot.url))
|
const detailScreenshotUrls = computed(() => detailScreenshots.value.map(shot => shot.url))
|
||||||
|
|
||||||
function readGroupedScreenshots(item: Listing) {
|
function readGroupedScreenshots(item: PublicListingDetail) {
|
||||||
const groups = item.asset_summary?.screenshot_groups
|
const groups = item.asset_summary?.screenshot_groups
|
||||||
if (typeof groups !== 'object' || groups === null) return []
|
if (typeof groups !== 'object' || groups === null) return []
|
||||||
const slots = [
|
const slots = [
|
||||||
@@ -493,11 +493,10 @@ function handleToggleFavorite() {
|
|||||||
round
|
round
|
||||||
class="order-btn"
|
class="order-btn"
|
||||||
:loading="ordering || agreementsLoading"
|
:loading="ordering || agreementsLoading"
|
||||||
:disabled="listing.in_transaction"
|
|
||||||
loading-text="下单中..."
|
loading-text="下单中..."
|
||||||
@click="handleCreateOrder"
|
@click="handleCreateOrder"
|
||||||
>
|
>
|
||||||
{{ listing.in_transaction ? '交易中' : '立即下单' }}
|
立即下单
|
||||||
</van-button>
|
</van-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,56 @@ export interface Order {
|
|||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 订单列表只承载卡片和表格所需字段;完整订单信息由详情接口返回。
|
||||||
|
export interface UserOrderListItem {
|
||||||
|
id: number
|
||||||
|
order_no: string
|
||||||
|
listing_id: number
|
||||||
|
listing_no: string
|
||||||
|
role: 'owner' | 'renter'
|
||||||
|
title: string
|
||||||
|
server_region: string
|
||||||
|
login_platform: string
|
||||||
|
display_amount_cent: number
|
||||||
|
deposit_amount_cent: number
|
||||||
|
deposit_waived_amount_cent: number
|
||||||
|
status: OrderStatus
|
||||||
|
handoff_status: HandoffStatus
|
||||||
|
payment_deadline_at?: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 后台订单列表使用脱敏后的表格数据,敏感字段仅在详情接口中按权限返回。
|
||||||
|
export interface AdminOrderListItem {
|
||||||
|
id: number
|
||||||
|
order_no: string
|
||||||
|
listing_id: number
|
||||||
|
listing_no: string
|
||||||
|
owner_phone?: string
|
||||||
|
renter_phone?: string
|
||||||
|
title: string
|
||||||
|
server_region: string
|
||||||
|
login_platform: string
|
||||||
|
rent_amount_cent: number
|
||||||
|
deposit_amount_cent: number
|
||||||
|
status: OrderStatus
|
||||||
|
handoff_status: HandoffStatus
|
||||||
|
handoff_mode: string
|
||||||
|
settlement_mode: string
|
||||||
|
settlement_status: SettlementStatus
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SellerHandoffMetrics {
|
||||||
|
pending_handoff: number
|
||||||
|
pending_checkout: number
|
||||||
|
abnormal: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SellerHandoffResult extends PaginatedResult<UserOrderListItem> {
|
||||||
|
metrics: SellerHandoffMetrics
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminActions {
|
export interface AdminActions {
|
||||||
reset_handoff?: AdminAction
|
reset_handoff?: AdminAction
|
||||||
platform_handoff?: AdminAction
|
platform_handoff?: AdminAction
|
||||||
@@ -246,9 +296,28 @@ export async function fetchPostRentalNotice() {
|
|||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchOrders() {
|
export async function fetchOrders(page = 1, pageSize = 20) {
|
||||||
const { data } = await apiClient.get<ApiResponse<{ items: Order[] }>>('/orders')
|
const { data } = await apiClient.get<ApiResponse<PaginatedResult<UserOrderListItem>>>('/orders', {
|
||||||
return Array.isArray(data.data?.items) ? data.data.items : []
|
params: { page, page_size: pageSize },
|
||||||
|
})
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSellerHandoffs(
|
||||||
|
params: {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
status?: string
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
const { data } = await apiClient.get<ApiResponse<SellerHandoffResult>>('/orders/handoffs', {
|
||||||
|
params: {
|
||||||
|
page: params.page || 1,
|
||||||
|
page_size: params.pageSize || 20,
|
||||||
|
status: params.status || undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchOrder(id: string | number) {
|
export async function fetchOrder(id: string | number) {
|
||||||
@@ -363,9 +432,12 @@ export async function counterCheckout(id: number, payload: SubmitCheckoutPayload
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAdminOrders(page = 1, pageSize = 20, query: AdminOrderQuery = {}) {
|
export async function fetchAdminOrders(page = 1, pageSize = 20, query: AdminOrderQuery = {}) {
|
||||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<Order>>>('/admin/orders', {
|
const { data } = await apiClient.get<ApiResponse<PaginatedResult<AdminOrderListItem>>>(
|
||||||
|
'/admin/orders',
|
||||||
|
{
|
||||||
params: { page, page_size: pageSize, ...query },
|
params: { page, page_size: pageSize, ...query },
|
||||||
})
|
}
|
||||||
|
)
|
||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import { useRouter, useRoute } from 'vue-router'
|
|||||||
import { showToast } from 'vant'
|
import { showToast } from 'vant'
|
||||||
import MobileBottomNav from '@/components/MobileBottomNav.vue'
|
import MobileBottomNav from '@/components/MobileBottomNav.vue'
|
||||||
|
|
||||||
import { fetchOrders, startOrderPayment, type Order } from '@/features/orders/api/orders'
|
import {
|
||||||
|
fetchOrders,
|
||||||
|
startOrderPayment,
|
||||||
|
type UserOrderListItem,
|
||||||
|
} from '@/features/orders/api/orders'
|
||||||
import MobilePaymentCashierPopup from '@/features/orders/components/MobilePaymentCashierPopup.vue'
|
import MobilePaymentCashierPopup from '@/features/orders/components/MobilePaymentCashierPopup.vue'
|
||||||
import MobilePayWaySelectPopup from '@/features/orders/components/MobilePayWaySelectPopup.vue'
|
import MobilePayWaySelectPopup from '@/features/orders/components/MobilePayWaySelectPopup.vue'
|
||||||
import { useMobilePaymentCashier } from '@/features/orders/composables/useMobilePaymentCashier'
|
import { useMobilePaymentCashier } from '@/features/orders/composables/useMobilePaymentCashier'
|
||||||
@@ -17,7 +21,6 @@ import {
|
|||||||
type MohongOrder,
|
type MohongOrder,
|
||||||
} from '@/features/mohong/api/mohong'
|
} from '@/features/mohong/api/mohong'
|
||||||
import { centToYuan, formatCent, formatMoney } from '@/shared/utils/money'
|
import { centToYuan, formatCent, formatMoney } from '@/shared/utils/money'
|
||||||
import { useSessionStore } from '@/stores/session'
|
|
||||||
import { formatDateMinute } from '@/shared/utils/time'
|
import { formatDateMinute } from '@/shared/utils/time'
|
||||||
import { formatListingNo } from '@/shared/utils/listingDisplay'
|
import { formatListingNo } from '@/shared/utils/listingDisplay'
|
||||||
|
|
||||||
@@ -35,18 +38,20 @@ interface UnifiedOrderItem {
|
|||||||
quantity?: number
|
quantity?: number
|
||||||
unit?: string
|
unit?: string
|
||||||
amountText: string
|
amountText: string
|
||||||
rental?: Order
|
rental?: UserOrderListItem
|
||||||
crash?: MohongOrder
|
crash?: MohongOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const session = useSessionStore()
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const rentalOrders = ref<Order[]>([])
|
const rentalOrders = ref<UserOrderListItem[]>([])
|
||||||
const crashOrders = ref<MohongOrder[]>([])
|
const crashOrders = ref<MohongOrder[]>([])
|
||||||
const activeTab = ref('all')
|
const activeTab = ref('all')
|
||||||
const payingOrderId = ref<number | null>(null)
|
const payingOrderId = ref<number | null>(null)
|
||||||
|
const rentalTotal = ref(0)
|
||||||
|
const rentalPage = ref(1)
|
||||||
|
const rentalPageSize = 20
|
||||||
const {
|
const {
|
||||||
paymentPopupVisible,
|
paymentPopupVisible,
|
||||||
activePayment,
|
activePayment,
|
||||||
@@ -139,14 +144,16 @@ async function loadOrders() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const [rentalResult, crashResult] = await Promise.allSettled([
|
const [rentalResult, crashResult] = await Promise.allSettled([
|
||||||
fetchOrders(),
|
fetchOrders(rentalPage.value, rentalPageSize),
|
||||||
fetchMyMohongOrders({ page: 1, page_size: 50 }),
|
fetchMyMohongOrders({ page: 1, page_size: 50 }),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (rentalResult.status === 'fulfilled') {
|
if (rentalResult.status === 'fulfilled') {
|
||||||
rentalOrders.value = Array.isArray(rentalResult.value) ? rentalResult.value : []
|
rentalOrders.value = rentalResult.value?.items || []
|
||||||
|
rentalTotal.value = rentalResult.value?.total || 0
|
||||||
} else {
|
} else {
|
||||||
rentalOrders.value = []
|
rentalOrders.value = []
|
||||||
|
rentalTotal.value = 0
|
||||||
showToast({ message: '租赁订单加载失败', icon: 'warning-o' })
|
showToast({ message: '租赁订单加载失败', icon: 'warning-o' })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,6 +171,11 @@ async function loadOrders() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleRentalPageChange(page: number) {
|
||||||
|
rentalPage.value = page
|
||||||
|
void loadOrders()
|
||||||
|
}
|
||||||
|
|
||||||
function statusLabel(item: UnifiedOrderItem) {
|
function statusLabel(item: UnifiedOrderItem) {
|
||||||
if (item.bizType === 'crash') return mohongOrderStatusLabel(item.status)
|
if (item.bizType === 'crash') return mohongOrderStatusLabel(item.status)
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
@@ -196,7 +208,7 @@ function goDetail(item: UnifiedOrderItem) {
|
|||||||
router.push(`/m/orders/${item.id}`)
|
router.push(`/m/orders/${item.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePay(order: Order) {
|
async function handlePay(order: UserOrderListItem) {
|
||||||
const payWay = await selectPayWay()
|
const payWay = await selectPayWay()
|
||||||
if (!payWay) return
|
if (!payWay) return
|
||||||
payingOrderId.value = order.id
|
payingOrderId.value = order.id
|
||||||
@@ -215,7 +227,7 @@ async function handlePay(order: Order) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openOrderChatAfterPayment(order: Order) {
|
async function openOrderChatAfterPayment(order: UserOrderListItem) {
|
||||||
try {
|
try {
|
||||||
const chat = await fetchOrderChat(order.id)
|
const chat = await fetchOrderChat(order.id)
|
||||||
await router.push(`/m/chats/${chat.id}`)
|
await router.push(`/m/chats/${chat.id}`)
|
||||||
@@ -229,11 +241,11 @@ function money(value: unknown) {
|
|||||||
return formatMoney(Number(value || 0))
|
return formatMoney(Number(value || 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
function isOwner(order: Order) {
|
function isOwner(order: UserOrderListItem) {
|
||||||
return order.owner_id === session.userId
|
return order.role === 'owner'
|
||||||
}
|
}
|
||||||
|
|
||||||
function amountLabel(order: Order) {
|
function amountLabel(order: UserOrderListItem) {
|
||||||
return isOwner(order) ? '预计租金' : '支付租金'
|
return isOwner(order) ? '预计租金' : '支付租金'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,23 +254,15 @@ function amountYuan(cent: unknown) {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function orderRentAmount(order: Order) {
|
function orderRentAmount(order: UserOrderListItem) {
|
||||||
if (isOwner(order)) return amountYuan(order.owner_rent_amount_cent)
|
return amountYuan(order.display_amount_cent)
|
||||||
return amountYuan(order.rent_amount_cent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ownerActualIncome(order: Order) {
|
function formatListingCode(order: UserOrderListItem) {
|
||||||
if (!isOwner(order)) return null
|
|
||||||
const value = order.checkout?.owner_income_amount_cent
|
|
||||||
if (typeof value === 'number') return centToYuan(value)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatListingCode(order: Order) {
|
|
||||||
return formatListingNo(order.listing_no, order.listing_id)
|
return formatListingNo(order.listing_no, order.listing_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyListingCode(order: Order) {
|
async function copyListingCode(order: UserOrderListItem) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(formatListingCode(order))
|
await navigator.clipboard.writeText(formatListingCode(order))
|
||||||
showToast({ message: '商品编号已复制', icon: 'passed' })
|
showToast({ message: '商品编号已复制', icon: 'passed' })
|
||||||
@@ -340,9 +344,6 @@ async function copyListingCode(order: Order) {
|
|||||||
<div class="price-item">
|
<div class="price-item">
|
||||||
<span class="price-label">{{ amountLabel(item.rental) }}</span>
|
<span class="price-label">{{ amountLabel(item.rental) }}</span>
|
||||||
<span class="price-val">¥{{ money(orderRentAmount(item.rental)) }}</span>
|
<span class="price-val">¥{{ money(orderRentAmount(item.rental)) }}</span>
|
||||||
<span v-if="ownerActualIncome(item.rental) !== null" class="price-sub"
|
|
||||||
>实际到手 ¥{{ money(ownerActualIncome(item.rental)) }}</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="price-item">
|
<div class="price-item">
|
||||||
<span class="price-label">押金金额</span>
|
<span class="price-label">押金金额</span>
|
||||||
@@ -363,10 +364,7 @@ async function copyListingCode(order: Order) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="footer-action">
|
<div class="footer-action">
|
||||||
<van-button
|
<van-button
|
||||||
v-if="
|
v-if="item.rental.status === 'pending_payment' && item.rental.role === 'renter'"
|
||||||
item.rental.status === 'pending_payment' &&
|
|
||||||
item.rental.renter_id === session.userId
|
|
||||||
"
|
|
||||||
size="small"
|
size="small"
|
||||||
type="warning"
|
type="warning"
|
||||||
round
|
round
|
||||||
@@ -421,6 +419,15 @@ async function copyListingCode(order: Order) {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
<van-pagination
|
||||||
|
v-if="rentalTotal > rentalPageSize"
|
||||||
|
v-model="rentalPage"
|
||||||
|
class="orders-pagination"
|
||||||
|
mode="simple"
|
||||||
|
:total-items="rentalTotal"
|
||||||
|
:items-per-page="rentalPageSize"
|
||||||
|
@change="handleRentalPageChange"
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<MobilePayWaySelectPopup
|
<MobilePayWaySelectPopup
|
||||||
|
|||||||
@@ -5,14 +5,13 @@ import { ElMessage } from 'element-plus'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { CopyDocument, Search } from '@element-plus/icons-vue'
|
import { CopyDocument, Search } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
import { fetchOrders, type Order } from '@/features/orders'
|
import { fetchOrders, type UserOrderListItem } from '@/features/orders'
|
||||||
import {
|
import {
|
||||||
fetchMyMohongOrders,
|
fetchMyMohongOrders,
|
||||||
mohongOrderStatusLabel,
|
mohongOrderStatusLabel,
|
||||||
type MohongOrder,
|
type MohongOrder,
|
||||||
} from '@/features/mohong/api/mohong'
|
} from '@/features/mohong/api/mohong'
|
||||||
import { centToYuan, formatCent, formatMoney } from '@/shared/utils/money'
|
import { centToYuan, formatCent, formatMoney } from '@/shared/utils/money'
|
||||||
import { useSessionStore } from '@/stores/session'
|
|
||||||
import { orderStatusLabel } from '@/shared/utils/statusLabels'
|
import { orderStatusLabel } from '@/shared/utils/statusLabels'
|
||||||
import { formatDateTime } from '@/shared/utils/time'
|
import { formatDateTime } from '@/shared/utils/time'
|
||||||
import { formatListingNo } from '@/shared/utils/listingDisplay'
|
import { formatListingNo } from '@/shared/utils/listingDisplay'
|
||||||
@@ -31,19 +30,21 @@ interface UnifiedOrderItem {
|
|||||||
coverUrl?: string
|
coverUrl?: string
|
||||||
quantity?: number
|
quantity?: number
|
||||||
unit?: string
|
unit?: string
|
||||||
rental?: Order
|
rental?: UserOrderListItem
|
||||||
crash?: MohongOrder
|
crash?: MohongOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const rentalOrders = ref<Order[]>([])
|
const rentalOrders = ref<UserOrderListItem[]>([])
|
||||||
const crashOrders = ref<MohongOrder[]>([])
|
const crashOrders = ref<MohongOrder[]>([])
|
||||||
const payingOrderId = ref<number | null>(null)
|
const payingOrderId = ref<number | null>(null)
|
||||||
const session = useSessionStore()
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchKeyword = ref('')
|
const searchKeyword = ref('')
|
||||||
const fallbackPendingPaymentMinutes = 15
|
const fallbackPendingPaymentMinutes = 15
|
||||||
|
const rentalTotal = ref(0)
|
||||||
|
const rentalPage = ref(1)
|
||||||
|
const rentalPageSize = 20
|
||||||
|
|
||||||
const statusTabs = [
|
const statusTabs = [
|
||||||
{ key: 'all', label: '全部' },
|
{ key: 'all', label: '全部' },
|
||||||
@@ -107,8 +108,7 @@ const displayOrders = computed(() => {
|
|||||||
if (order.bizType === 'rental' && order.rental) {
|
if (order.bizType === 'rental' && order.rental) {
|
||||||
return (
|
return (
|
||||||
formatListingCode(order.rental).toLowerCase().includes(keyword) ||
|
formatListingCode(order.rental).toLowerCase().includes(keyword) ||
|
||||||
String(order.rental.listing_id).includes(keyword) ||
|
String(order.rental.listing_id).includes(keyword)
|
||||||
String(order.rental.account_id).includes(keyword)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -152,14 +152,16 @@ async function loadOrders() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const [rentalResult, crashResult] = await Promise.allSettled([
|
const [rentalResult, crashResult] = await Promise.allSettled([
|
||||||
fetchOrders(),
|
fetchOrders(rentalPage.value, rentalPageSize),
|
||||||
fetchMyMohongOrders({ page: 1, page_size: 50 }),
|
fetchMyMohongOrders({ page: 1, page_size: 50 }),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (rentalResult.status === 'fulfilled') {
|
if (rentalResult.status === 'fulfilled') {
|
||||||
rentalOrders.value = Array.isArray(rentalResult.value) ? rentalResult.value : []
|
rentalOrders.value = rentalResult.value?.items || []
|
||||||
|
rentalTotal.value = rentalResult.value?.total || 0
|
||||||
} else {
|
} else {
|
||||||
rentalOrders.value = []
|
rentalOrders.value = []
|
||||||
|
rentalTotal.value = 0
|
||||||
ElMessage.error('租赁订单加载失败')
|
ElMessage.error('租赁订单加载失败')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,6 +176,11 @@ async function loadOrders() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleRentalPageChange(page: number) {
|
||||||
|
rentalPage.value = page
|
||||||
|
void loadOrders()
|
||||||
|
}
|
||||||
|
|
||||||
function goDetail(item: UnifiedOrderItem) {
|
function goDetail(item: UnifiedOrderItem) {
|
||||||
if (item.bizType === 'crash') {
|
if (item.bizType === 'crash') {
|
||||||
router.push(`/crash/orders/${item.id}`)
|
router.push(`/crash/orders/${item.id}`)
|
||||||
@@ -182,7 +189,7 @@ function goDetail(item: UnifiedOrderItem) {
|
|||||||
router.push(`/orders/${item.id}`)
|
router.push(`/orders/${item.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePay(order: Order) {
|
async function handlePay(order: UserOrderListItem) {
|
||||||
payingOrderId.value = order.id
|
payingOrderId.value = order.id
|
||||||
try {
|
try {
|
||||||
await router.push(`/orders/${order.id}?pay=1`)
|
await router.push(`/orders/${order.id}?pay=1`)
|
||||||
@@ -193,21 +200,15 @@ async function handlePay(order: Order) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function orderRole(order: Order) {
|
function orderRole(order: UserOrderListItem) {
|
||||||
if (order.renter_id === session.userId) return '租客'
|
return order.role === 'owner' ? '号主' : '租客'
|
||||||
if (order.owner_id === session.userId) return '号主'
|
|
||||||
return '-'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRenter(order: Order) {
|
function isRenter(order: UserOrderListItem) {
|
||||||
return order.renter_id === session.userId
|
return order.role === 'renter'
|
||||||
}
|
}
|
||||||
|
|
||||||
function isOwner(order: Order) {
|
function amountLabel(order: UserOrderListItem) {
|
||||||
return order.owner_id === session.userId
|
|
||||||
}
|
|
||||||
|
|
||||||
function amountLabel(order: Order) {
|
|
||||||
return isRenter(order) ? '支付租金' : '预计租金'
|
return isRenter(order) ? '支付租金' : '预计租金'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,19 +217,10 @@ function amountYuan(cent: unknown) {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function orderRentAmount(order: Order) {
|
function orderRentAmount(order: UserOrderListItem) {
|
||||||
if (isOwner(order)) return amountYuan(order.owner_rent_amount_cent)
|
|
||||||
if (isRenter(order)) return amountYuan(order.rent_amount_cent)
|
|
||||||
return amountYuan(order.display_amount_cent)
|
return amountYuan(order.display_amount_cent)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ownerActualIncome(order: Order) {
|
|
||||||
if (!isOwner(order)) return null
|
|
||||||
const value = order.checkout?.owner_income_amount_cent
|
|
||||||
if (typeof value === 'number') return centToYuan(value)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function money(value: unknown) {
|
function money(value: unknown) {
|
||||||
return formatMoney(Number(value || 0))
|
return formatMoney(Number(value || 0))
|
||||||
}
|
}
|
||||||
@@ -262,11 +254,11 @@ async function copyOrderNo(orderNo: unknown) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatListingCode(order: Order) {
|
function formatListingCode(order: UserOrderListItem) {
|
||||||
return formatListingNo(order.listing_no, order.listing_id)
|
return formatListingNo(order.listing_no, order.listing_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyListingCode(order: Order) {
|
async function copyListingCode(order: UserOrderListItem) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(formatListingCode(order))
|
await navigator.clipboard.writeText(formatListingCode(order))
|
||||||
ElMessage.success('商品编号已复制')
|
ElMessage.success('商品编号已复制')
|
||||||
@@ -275,7 +267,7 @@ async function copyListingCode(order: Order) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPaymentDeadline(order: Order) {
|
function getPaymentDeadline(order: UserOrderListItem) {
|
||||||
if (order.status !== 'pending_payment' || !order.created_at) return null
|
if (order.status !== 'pending_payment' || !order.created_at) return null
|
||||||
if (order.payment_deadline_at) {
|
if (order.payment_deadline_at) {
|
||||||
const serverDeadline = new Date(order.payment_deadline_at)
|
const serverDeadline = new Date(order.payment_deadline_at)
|
||||||
@@ -285,7 +277,7 @@ function getPaymentDeadline(order: Order) {
|
|||||||
return new Date(created.getTime() + fallbackPendingPaymentMinutes * 60 * 1000)
|
return new Date(created.getTime() + fallbackPendingPaymentMinutes * 60 * 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCountdownMinutes(order: Order) {
|
function getCountdownMinutes(order: UserOrderListItem) {
|
||||||
const deadline = getPaymentDeadline(order)
|
const deadline = getPaymentDeadline(order)
|
||||||
if (!deadline) return 0
|
if (!deadline) return 0
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
@@ -366,9 +358,7 @@ function bizTypeLabel(bizType: BizType) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="crash-product-info">
|
<div class="crash-product-info">
|
||||||
<strong>{{ row.title }}</strong>
|
<strong>{{ row.title }}</strong>
|
||||||
<span
|
<span>x{{ row.quantity || 1 }}{{ row.unit ? ` · ${row.unit}` : '' }}</span>
|
||||||
>x{{ row.quantity || 1 }}{{ row.unit ? ` · ${row.unit}` : '' }}</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="rental-product-cell">
|
<div v-else class="rental-product-cell">
|
||||||
@@ -390,9 +380,6 @@ function bizTypeLabel(bizType: BizType) {
|
|||||||
<div v-if="row.bizType === 'rental' && row.rental" class="amount-cell">
|
<div v-if="row.bizType === 'rental' && row.rental" class="amount-cell">
|
||||||
<span class="amount-value">¥{{ money(orderRentAmount(row.rental)) }}</span>
|
<span class="amount-value">¥{{ money(orderRentAmount(row.rental)) }}</span>
|
||||||
<span class="amount-label">{{ amountLabel(row.rental) }}</span>
|
<span class="amount-label">{{ amountLabel(row.rental) }}</span>
|
||||||
<span v-if="ownerActualIncome(row.rental) !== null" class="amount-sub"
|
|
||||||
>实际到手 ¥{{ money(ownerActualIncome(row.rental)) }}</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="amount-cell">
|
<div v-else class="amount-cell">
|
||||||
<span class="amount-value">¥{{ row.amountText }}</span>
|
<span class="amount-value">¥{{ row.amountText }}</span>
|
||||||
@@ -403,8 +390,12 @@ function bizTypeLabel(bizType: BizType) {
|
|||||||
<el-table-column label="押金 / 数量" width="110">
|
<el-table-column label="押金 / 数量" width="110">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div v-if="row.bizType === 'rental' && row.rental" class="amount-cell">
|
<div v-if="row.bizType === 'rental' && row.rental" class="amount-cell">
|
||||||
<span class="amount-value">¥{{ money(amountYuan(row.rental.deposit_amount_cent)) }}</span>
|
<span class="amount-value"
|
||||||
<span v-if="amountYuan(row.rental.deposit_waived_amount_cent) > 0" class="amount-label"
|
>¥{{ money(amountYuan(row.rental.deposit_amount_cent)) }}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="amountYuan(row.rental.deposit_waived_amount_cent) > 0"
|
||||||
|
class="amount-label"
|
||||||
>免 ¥{{ money(amountYuan(row.rental.deposit_waived_amount_cent)) }}</span
|
>免 ¥{{ money(amountYuan(row.rental.deposit_waived_amount_cent)) }}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -451,13 +442,11 @@ function bizTypeLabel(bizType: BizType) {
|
|||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<el-button
|
<el-button
|
||||||
v-if="
|
v-if="
|
||||||
row.bizType === 'rental' &&
|
row.bizType === 'rental' && row.rental && row.rental.status === 'pending_payment'
|
||||||
row.rental &&
|
|
||||||
row.rental.status === 'pending_payment'
|
|
||||||
"
|
"
|
||||||
size="small"
|
size="small"
|
||||||
type="primary"
|
type="primary"
|
||||||
:disabled="row.rental.renter_id !== session.userId"
|
:disabled="!isRenter(row.rental)"
|
||||||
:loading="payingOrderId === row.rental.id"
|
:loading="payingOrderId === row.rental.id"
|
||||||
@click="handlePay(row.rental)"
|
@click="handlePay(row.rental)"
|
||||||
>
|
>
|
||||||
@@ -511,10 +500,6 @@ function bizTypeLabel(bizType: BizType) {
|
|||||||
<span class="meta-label">{{ amountLabel(item.rental) }}</span>
|
<span class="meta-label">{{ amountLabel(item.rental) }}</span>
|
||||||
<span class="meta-value amount">¥{{ money(orderRentAmount(item.rental)) }}</span>
|
<span class="meta-value amount">¥{{ money(orderRentAmount(item.rental)) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="ownerActualIncome(item.rental) !== null" class="meta-row">
|
|
||||||
<span class="meta-label">实际到手</span>
|
|
||||||
<span class="meta-value amount">¥{{ money(ownerActualIncome(item.rental)) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="meta-row">
|
<div class="meta-row">
|
||||||
<span class="meta-label">押金</span>
|
<span class="meta-label">押金</span>
|
||||||
<span class="meta-value">
|
<span class="meta-value">
|
||||||
@@ -582,6 +567,15 @@ function bizTypeLabel(bizType: BizType) {
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<el-pagination
|
||||||
|
v-if="rentalTotal > rentalPageSize"
|
||||||
|
v-model:current-page="rentalPage"
|
||||||
|
class="orders-pagination"
|
||||||
|
layout="prev, pager, next"
|
||||||
|
:page-size="rentalPageSize"
|
||||||
|
:total="rentalTotal"
|
||||||
|
@current-change="handleRentalPageChange"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,66 +1,58 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
import { fetchOrders, type Order } from '@/features/orders'
|
import {
|
||||||
import { useSessionStore } from '@/stores/session'
|
fetchSellerHandoffs,
|
||||||
|
type SellerHandoffMetrics,
|
||||||
|
type UserOrderListItem,
|
||||||
|
} from '@/features/orders'
|
||||||
import { handoffStatusLabel, orderStatusLabel } from '@/shared/utils/statusLabels'
|
import { handoffStatusLabel, orderStatusLabel } from '@/shared/utils/statusLabels'
|
||||||
import { formatDateTime } from '@/shared/utils/time'
|
import { formatDateTime } from '@/shared/utils/time'
|
||||||
import { centToYuan, formatMoney } from '@/shared/utils/money'
|
import { centToYuan, formatMoney } from '@/shared/utils/money'
|
||||||
import { formatListingNo } from '@/shared/utils/listingDisplay'
|
import { formatListingNo } from '@/shared/utils/listingDisplay'
|
||||||
|
|
||||||
const session = useSessionStore()
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const orders = ref<Order[]>([])
|
const orders = ref<UserOrderListItem[]>([])
|
||||||
const status = ref('')
|
const status = ref('')
|
||||||
|
const total = ref(0)
|
||||||
const sellerOrders = computed(() => orders.value.filter(order => order.owner_id === session.userId))
|
const page = ref(1)
|
||||||
const todoOrders = computed(() =>
|
const pageSize = 20
|
||||||
sellerOrders.value.filter(order =>
|
const metrics = ref<SellerHandoffMetrics>({
|
||||||
[
|
pending_handoff: 0,
|
||||||
'pending_handoff',
|
pending_checkout: 0,
|
||||||
'renting',
|
abnormal: 0,
|
||||||
'overdue',
|
|
||||||
'pending_checkout_confirm',
|
|
||||||
'pending_checkout_accept',
|
|
||||||
'checkout_disputing',
|
|
||||||
'disputing',
|
|
||||||
'abnormal',
|
|
||||||
].includes(order.status)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
const displayOrders = computed(() => {
|
|
||||||
const source = todoOrders.value
|
|
||||||
if (!status.value) return source
|
|
||||||
return source.filter(order => order.status === status.value)
|
|
||||||
})
|
})
|
||||||
const pendingHandoffCount = computed(
|
|
||||||
() =>
|
|
||||||
sellerOrders.value.filter(
|
|
||||||
order => order.status === 'pending_handoff' && order.handoff_status === 'pending_owner'
|
|
||||||
).length
|
|
||||||
)
|
|
||||||
const pendingCheckoutCount = computed(
|
|
||||||
() => sellerOrders.value.filter(order => order.status === 'pending_checkout_confirm').length
|
|
||||||
)
|
|
||||||
const abnormalCount = computed(
|
|
||||||
() =>
|
|
||||||
sellerOrders.value.filter(order =>
|
|
||||||
['overdue', 'checkout_disputing', 'disputing', 'abnormal'].includes(order.status)
|
|
||||||
).length
|
|
||||||
)
|
|
||||||
|
|
||||||
onMounted(loadOrders)
|
onMounted(loadOrders)
|
||||||
|
|
||||||
async function loadOrders() {
|
async function loadOrders() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
orders.value = await fetchOrders()
|
const result = await fetchSellerHandoffs({ page: page.value, pageSize, status: status.value })
|
||||||
|
orders.value = result.items
|
||||||
|
total.value = result.total
|
||||||
|
metrics.value = result.metrics
|
||||||
|
} catch {
|
||||||
|
orders.value = []
|
||||||
|
total.value = 0
|
||||||
|
ElMessage.error('交接待办加载失败')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function actionText(order: Order) {
|
function handleStatusChange() {
|
||||||
|
page.value = 1
|
||||||
|
void loadOrders()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePageChange(nextPage: number) {
|
||||||
|
page.value = nextPage
|
||||||
|
void loadOrders()
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionText(order: UserOrderListItem) {
|
||||||
if (order.status === 'pending_handoff' && order.handoff_status === 'pending_owner')
|
if (order.status === 'pending_handoff' && order.handoff_status === 'pending_owner')
|
||||||
return '提交交接'
|
return '提交交接'
|
||||||
if (order.status === 'pending_checkout_confirm') return '处理结账'
|
if (order.status === 'pending_checkout_confirm') return '处理结账'
|
||||||
@@ -74,8 +66,8 @@ function money(value: unknown) {
|
|||||||
return formatMoney(Number(value || 0))
|
return formatMoney(Number(value || 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
function sellerAmount(order: Order) {
|
function sellerAmount(order: UserOrderListItem) {
|
||||||
return centToYuan(order.owner_rent_amount_cent ?? order.display_amount_cent)
|
return centToYuan(order.display_amount_cent)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -88,7 +80,13 @@ function sellerAmount(order: Order) {
|
|||||||
<p>处理待交接、租赁中、待结账确认和异常订单。</p>
|
<p>处理待交接、租赁中、待结账确认和异常订单。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar-actions">
|
<div class="toolbar-actions">
|
||||||
<el-select v-model="status" clearable placeholder="待办状态" style="width: 190px">
|
<el-select
|
||||||
|
v-model="status"
|
||||||
|
clearable
|
||||||
|
placeholder="待办状态"
|
||||||
|
style="width: 190px"
|
||||||
|
@change="handleStatusChange"
|
||||||
|
>
|
||||||
<el-option label="待交接" value="pending_handoff" />
|
<el-option label="待交接" value="pending_handoff" />
|
||||||
<el-option label="使用中" value="renting" />
|
<el-option label="使用中" value="renting" />
|
||||||
<el-option label="逾期中" value="overdue" />
|
<el-option label="逾期中" value="overdue" />
|
||||||
@@ -105,19 +103,19 @@ function sellerAmount(order: Order) {
|
|||||||
<div class="metric-grid">
|
<div class="metric-grid">
|
||||||
<div class="metric-card">
|
<div class="metric-card">
|
||||||
<span>待交接</span>
|
<span>待交接</span>
|
||||||
<strong>{{ pendingHandoffCount }} 单</strong>
|
<strong>{{ metrics.pending_handoff }} 单</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-card">
|
<div class="metric-card">
|
||||||
<span>待结账</span>
|
<span>待结账</span>
|
||||||
<strong>{{ pendingCheckoutCount }} 单</strong>
|
<strong>{{ metrics.pending_checkout }} 单</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-card">
|
<div class="metric-card">
|
||||||
<span>异常/争议</span>
|
<span>异常/争议</span>
|
||||||
<strong>{{ abnormalCount }} 单</strong>
|
<strong>{{ metrics.abnormal }} 单</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-table v-loading="loading" class="table-panel" :data="displayOrders">
|
<el-table v-loading="loading" class="table-panel" :data="orders">
|
||||||
<el-table-column label="商品编号" width="140">
|
<el-table-column label="商品编号" width="140">
|
||||||
<template #default="{ row }">{{
|
<template #default="{ row }">{{
|
||||||
formatListingNo(row.listing_no, row.listing_id)
|
formatListingNo(row.listing_no, row.listing_id)
|
||||||
@@ -145,5 +143,16 @@ function sellerAmount(order: Order) {
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
<div class="pagination-wrapper">
|
||||||
|
<el-pagination
|
||||||
|
v-if="total > pageSize"
|
||||||
|
v-model:current-page="page"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
background
|
||||||
|
layout="prev, pager, next"
|
||||||
|
@current-change="handlePageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -158,9 +158,7 @@ async function handleUpload(event: Event) {
|
|||||||
if (!form.value.certificate_urls) {
|
if (!form.value.certificate_urls) {
|
||||||
form.value.certificate_urls = []
|
form.value.certificate_urls = []
|
||||||
}
|
}
|
||||||
// 使用公开访问的URL,不需要认证
|
form.value.certificate_urls.push(uploaded.url)
|
||||||
const publicUrl = uploaded.url.replace('/api/files/object', '/api/public/files/object')
|
|
||||||
form.value.certificate_urls.push(publicUrl)
|
|
||||||
ElMessage.success('上传成功')
|
ElMessage.success('上传成功')
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
ElMessage.error(error.response?.data?.message || '上传失败')
|
ElMessage.error(error.response?.data?.message || '上传失败')
|
||||||
|
|||||||
@@ -323,8 +323,7 @@ async function handleCertificateUpload(event: Event) {
|
|||||||
uploadingCertificate.value = true
|
uploadingCertificate.value = true
|
||||||
try {
|
try {
|
||||||
const uploaded = await uploadFile(file, 'payment-cert')
|
const uploaded = await uploadFile(file, 'payment-cert')
|
||||||
const publicURL = uploaded.url.replace('/api/files/object', '/api/public/files/object')
|
accountForm.certificate_urls.push(uploaded.url)
|
||||||
accountForm.certificate_urls.push(publicURL)
|
|
||||||
showToast({ message: '图片已上传', icon: 'passed' })
|
showToast({ message: '图片已上传', icon: 'passed' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast({ message: readError(error, '上传失败'), icon: 'cross' })
|
showToast({ message: readError(error, '上传失败'), icon: 'cross' })
|
||||||
|
|||||||
@@ -110,8 +110,8 @@ function handleTopSearchInput(event: Event) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLogout() {
|
async function handleLogout() {
|
||||||
session.logout()
|
await session.logout()
|
||||||
showUserDropdown.value = false
|
showUserDropdown.value = false
|
||||||
router.replace('/')
|
router.replace('/')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ declare module 'axios' {
|
|||||||
export interface AxiosRequestConfig {
|
export interface AxiosRequestConfig {
|
||||||
silent?: boolean
|
silent?: boolean
|
||||||
skipErrorHandler?: boolean
|
skipErrorHandler?: boolean
|
||||||
|
skipAuthRefresh?: boolean
|
||||||
showLoading?: boolean
|
showLoading?: boolean
|
||||||
_startTime?: number
|
_startTime?: number
|
||||||
}
|
}
|
||||||
@@ -222,7 +223,12 @@ apiClient.interceptors.response.use(
|
|||||||
logError(error)
|
logError(error)
|
||||||
|
|
||||||
const originalRequest = error.config as RetriableRequestConfig | undefined
|
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 跳过
|
// 统一错误处理,支持 skipErrorHandler 跳过
|
||||||
if (originalRequest && !originalRequest.silent && !originalRequest.skipErrorHandler) {
|
if (originalRequest && !originalRequest.silent && !originalRequest.skipErrorHandler) {
|
||||||
const msg = error.response?.data?.message || error.message || '网络连接异常,请稍后重试'
|
const msg = error.response?.data?.message || error.message || '网络连接异常,请稍后重试'
|
||||||
|
|||||||
@@ -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'
|
import { centToYuan } from '@/shared/utils/money'
|
||||||
|
|
||||||
export interface ListingDisplayChip {
|
export interface ListingDisplayChip {
|
||||||
@@ -21,15 +21,15 @@ export interface ListingDisplaySkinGroup {
|
|||||||
options: string[]
|
options: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCoinWan(item: Listing) {
|
export function getCoinWan(item: PublicListingItem) {
|
||||||
return Math.round(Number(item.haf_coin_amount || 0) / 10000)
|
return Math.round(Number(item.haf_coin_amount || 0) / 10000)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCoinM(item: Listing) {
|
export function getCoinM(item: PublicListingItem) {
|
||||||
return getCoinWan(item) / 100
|
return getCoinWan(item) / 100
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatListingCode(item: Listing) {
|
export function formatListingCode(item: PublicListingItem) {
|
||||||
return formatListingNo(item.listing_no, item.id)
|
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)
|
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getListingDisplayPrice(item: Listing) {
|
export function getListingDisplayPrice(item: PublicListingItem) {
|
||||||
return centToYuan(item.price_cent)
|
return centToYuan(item.price_cent)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getListingRentPrice(item: Listing) {
|
export function getListingRentPrice(item: PublicListingItem) {
|
||||||
const buyerCoinBasePrice = readPriceBreakdownNumber(item, 'buyer_coin_base_price')
|
const buyerCoinBasePrice = readPriceBreakdownNumber(item, 'buyer_coin_base_price')
|
||||||
if (buyerCoinBasePrice > 0) return roundMoney(buyerCoinBasePrice)
|
if (buyerCoinBasePrice > 0) return roundMoney(buyerCoinBasePrice)
|
||||||
return Math.max(0, roundMoney(getListingDisplayPrice(item) - getListingConsumablePrice(item)))
|
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')
|
const consumablePrice = readPriceBreakdownNumber(item, 'consumable_price')
|
||||||
if (consumablePrice > 0) return roundMoney(consumablePrice)
|
if (consumablePrice > 0) return roundMoney(consumablePrice)
|
||||||
return getListingResources(item).reduce((sum, resource) => sum + resource.amount, 0)
|
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
|
const priceBreakdown = item.asset_summary?.price_breakdown
|
||||||
if (typeof priceBreakdown === 'object' && priceBreakdown !== null) {
|
if (typeof priceBreakdown === 'object' && priceBreakdown !== null) {
|
||||||
const price = readUnknownNumber((priceBreakdown as Record<string, unknown>).seller_total_price)
|
const price = readUnknownNumber((priceBreakdown as Record<string, unknown>).seller_total_price)
|
||||||
@@ -77,7 +77,7 @@ export function getListingSellerPrice(item: Listing) {
|
|||||||
return getListingDisplayPrice(item)
|
return getListingDisplayPrice(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRatioValue(item: Listing) {
|
export function getRatioValue(item: PublicListingItem) {
|
||||||
const ratio = readAssetNumber(item, 'publish_ratio')
|
const ratio = readAssetNumber(item, 'publish_ratio')
|
||||||
if (ratio > 0) return ratio
|
if (ratio > 0) return ratio
|
||||||
const price = getListingDisplayPrice(item)
|
const price = getListingDisplayPrice(item)
|
||||||
@@ -85,20 +85,20 @@ export function getRatioValue(item: Listing) {
|
|||||||
return getCoinWan(item) / price
|
return getCoinWan(item) / price
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatRatio(item: Listing) {
|
export function formatRatio(item: PublicListingItem) {
|
||||||
const ratio = getRatioValue(item)
|
const ratio = getRatioValue(item)
|
||||||
return ratio > 0 ? `1:${formatRatioNumber(ratio)}` : '--'
|
return ratio > 0 ? `1:${formatRatioNumber(ratio)}` : '--'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getValuePerYuanText(item: Listing) {
|
export function getValuePerYuanText(item: PublicListingItem) {
|
||||||
return formatRatio(item)
|
return formatRatio(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLoginMethod(item: Listing) {
|
export function getLoginMethod(item: PublicListingItem) {
|
||||||
return item.login_platform.trim()
|
return item.login_platform.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getServerRegion(item: Listing) {
|
export function getServerRegion(item: PublicListingItem) {
|
||||||
return item.server_region.trim()
|
return item.server_region.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ export function formatGameName(value: unknown, fallback = '-') {
|
|||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getListingTitle(item: Listing) {
|
export function getListingTitle(item: PublicListingItem) {
|
||||||
const parts = [
|
const parts = [
|
||||||
`纯币${formatHafCoinM(getCoinWan(item))}`,
|
`纯币${formatHafCoinM(getCoinWan(item))}`,
|
||||||
formatInsuranceSlotText(readAssetString(item, 'season_insurance')),
|
formatInsuranceSlotText(readAssetString(item, 'season_insurance')),
|
||||||
@@ -126,11 +126,11 @@ export function getListingTitle(item: Listing) {
|
|||||||
return parts.join('/')
|
return parts.join('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getListingSubtitle(item: Listing) {
|
export function getListingSubtitle(item: PublicListingItem) {
|
||||||
return getValuePerYuanText(item)
|
return getValuePerYuanText(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getListingChips(item: Listing): ListingDisplayChip[] {
|
export function getListingChips(item: PublicListingItem): ListingDisplayChip[] {
|
||||||
const totalAsset = readAssetNumber(item, 'total_asset_wan')
|
const totalAsset = readAssetNumber(item, 'total_asset_wan')
|
||||||
const chips: ListingDisplayChip[] = [
|
const chips: ListingDisplayChip[] = [
|
||||||
{ label: '哈夫币', value: formatHafCoinM(getCoinWan(item)) },
|
{ label: '哈夫币', value: formatHafCoinM(getCoinWan(item)) },
|
||||||
@@ -161,7 +161,7 @@ export function getListingChips(item: Listing): ListingDisplayChip[] {
|
|||||||
return chips.filter(chip => chip.value)
|
return chips.filter(chip => chip.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getListingResources(item: Listing): ListingDisplayResource[] {
|
export function getListingResources(item: PublicListingItem): ListingDisplayResource[] {
|
||||||
const resources = item.asset_summary?.resources
|
const resources = item.asset_summary?.resources
|
||||||
if (!Array.isArray(resources)) return []
|
if (!Array.isArray(resources)) return []
|
||||||
return resources
|
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
|
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 === '赠送')
|
return getListingResources(item).some(resource => resource.mode === '赠送')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasAcceleratedSaleRatio(item: Listing) {
|
export function hasAcceleratedSaleRatio(item: PublicListingItem) {
|
||||||
if (item.is_accelerated_sale) return true
|
if (item.is_accelerated_sale) return true
|
||||||
|
|
||||||
const priceBreakdown = item.asset_summary?.price_breakdown
|
const priceBreakdown = item.asset_summary?.price_breakdown
|
||||||
@@ -211,7 +211,7 @@ export function hasAcceleratedSaleRatio(item: Listing) {
|
|||||||
return sellerRatio > referenceRatio || acceleratedRatio > referenceRatio
|
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
|
const skinGroups = item.asset_summary?.skin_groups
|
||||||
if (
|
if (
|
||||||
typeof skinGroups !== 'object' ||
|
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
|
const skinGroups = item.asset_summary?.skin_groups
|
||||||
if (typeof skinGroups !== 'object' || skinGroups === null) return []
|
if (typeof skinGroups !== 'object' || skinGroups === null) return []
|
||||||
const titles: Record<string, string> = {
|
const titles: Record<string, string> = {
|
||||||
@@ -248,11 +248,11 @@ export function getListingSkinGroups(item: Listing): ListingDisplaySkinGroup[] {
|
|||||||
.filter(group => group.options.length)
|
.filter(group => group.options.length)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSkinNames(item: Listing) {
|
export function getSkinNames(item: PublicListingItem) {
|
||||||
return getListingSkinGroups(item).flatMap(group => group.options)
|
return getListingSkinGroups(item).flatMap(group => group.options)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assetRegions(item: Listing) {
|
export function assetRegions(item: PublicListingItem) {
|
||||||
const regions = item.asset_summary?.common_regions
|
const regions = item.asset_summary?.common_regions
|
||||||
return Array.isArray(regions)
|
return Array.isArray(regions)
|
||||||
? regions.filter((region): region is string => typeof region === 'string')
|
? regions.filter((region): region is string => typeof region === 'string')
|
||||||
@@ -281,7 +281,7 @@ export function formatOnlineTimeRange(start: string, end: string) {
|
|||||||
return `${startLabel}-${endLabel}点`
|
return `${startLabel}-${endLabel}点`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getOnlineTimeText(item: Listing) {
|
export function getOnlineTimeText(item: PublicListingItem) {
|
||||||
const onlineTime = item.asset_summary?.online_time
|
const onlineTime = item.asset_summary?.online_time
|
||||||
if (typeof onlineTime !== 'object' || onlineTime === null) return ''
|
if (typeof onlineTime !== 'object' || onlineTime === null) return ''
|
||||||
const start = (onlineTime as Record<string, unknown>).start
|
const start = (onlineTime as Record<string, unknown>).start
|
||||||
@@ -303,37 +303,37 @@ function formatClockLabel(value: string) {
|
|||||||
return value.endsWith(':00') ? value.slice(0, -3) : value
|
return value.endsWith(':00') ? value.slice(0, -3) : value
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDailyLoss(item: Listing) {
|
export function getDailyLoss(item: PublicListingItem) {
|
||||||
const dailyLossM = getDailyLossM(item)
|
const dailyLossM = getDailyLossM(item)
|
||||||
return dailyLossM > 0 ? `${formatCompactNumber(dailyLossM)}M` : ''
|
return dailyLossM > 0 ? `${formatCompactNumber(dailyLossM)}M` : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDailyLossM(item: Listing) {
|
export function getDailyLossM(item: PublicListingItem) {
|
||||||
const configuredLoss = readAssetNumber(item, 'daily_loss_m')
|
const configuredLoss = readAssetNumber(item, 'daily_loss_m')
|
||||||
if (configuredLoss > 0) return configuredLoss
|
if (configuredLoss > 0) return configuredLoss
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getEstimatedRentalDays(item: Listing) {
|
export function getEstimatedRentalDays(item: PublicListingItem) {
|
||||||
const coinM = getCoinM(item)
|
const coinM = getCoinM(item)
|
||||||
const dailyLossM = getDailyLossM(item)
|
const dailyLossM = getDailyLossM(item)
|
||||||
if (coinM <= 0 || dailyLossM <= 0) return 0
|
if (coinM <= 0 || dailyLossM <= 0) return 0
|
||||||
return coinM / dailyLossM
|
return coinM / dailyLossM
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatEstimatedRentalDuration(item: Listing) {
|
export function formatEstimatedRentalDuration(item: PublicListingItem) {
|
||||||
const days = getEstimatedRentalDays(item)
|
const days = getEstimatedRentalDays(item)
|
||||||
if (days <= 0) return '--'
|
if (days <= 0) return '--'
|
||||||
// 不足整天按整天向上取整,与后端 estimateOrderDurationHours 一致
|
// 不足整天按整天向上取整,与后端 estimateOrderDurationHours 一致
|
||||||
return `${Math.max(1, Math.ceil(days))}天`
|
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]
|
const value = item.asset_summary?.[key]
|
||||||
return typeof value === 'string' ? value : ''
|
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])
|
return readUnknownNumber(item.asset_summary?.[key])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,7 +346,7 @@ function readUnknownNumber(value: unknown) {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function readPriceBreakdownNumber(item: Listing, key: string) {
|
function readPriceBreakdownNumber(item: PublicListingItem, key: string) {
|
||||||
const priceBreakdown = item.asset_summary?.price_breakdown
|
const priceBreakdown = item.asset_summary?.price_breakdown
|
||||||
if (typeof priceBreakdown !== 'object' || priceBreakdown === null) return 0
|
if (typeof priceBreakdown !== 'object' || priceBreakdown === null) return 0
|
||||||
return readUnknownNumber((priceBreakdown as Record<string, unknown>)[key])
|
return readUnknownNumber((priceBreakdown as Record<string, unknown>)[key])
|
||||||
@@ -379,7 +379,7 @@ function formatLevelShort(value: string, suffix: string) {
|
|||||||
return level ? `${level}${suffix}` : value
|
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)
|
const quantity = getResourceQuantity(item, key)
|
||||||
return quantity > 0 ? `${quantity}${label}` : ''
|
return quantity > 0 ? `${quantity}${label}` : ''
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
emptyListingPublishOptions,
|
emptyListingPublishOptions,
|
||||||
type ListingPublishOptions,
|
type ListingPublishOptions,
|
||||||
} from '@/features/listings/api/listingOptions'
|
} 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<string, { min: string; max: string }>
|
export type MobileHomeRangeFilters = Record<string, { min: string; max: string }>
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ export const useMobileHomeCacheStore = defineStore('mobileHomeCache', {
|
|||||||
state: () => ({
|
state: () => ({
|
||||||
/** 与当前 listings 数据对应的筛选签名;空字符串表示尚无可用快照 */
|
/** 与当前 listings 数据对应的筛选签名;空字符串表示尚无可用快照 */
|
||||||
listSignature: '',
|
listSignature: '',
|
||||||
listings: [] as Listing[],
|
listings: [] as PublicListingItem[],
|
||||||
totalListings: 0,
|
totalListings: 0,
|
||||||
zoneCounts: {} as Record<string, number>,
|
zoneCounts: {} as Record<string, number>,
|
||||||
/** 下一页页码(与 MobileHomeView.currentPage 语义一致) */
|
/** 下一页页码(与 MobileHomeView.currentPage 语义一致) */
|
||||||
@@ -58,7 +58,7 @@ export const useMobileHomeCacheStore = defineStore('mobileHomeCache', {
|
|||||||
},
|
},
|
||||||
saveList(payload: {
|
saveList(payload: {
|
||||||
signature: string
|
signature: string
|
||||||
listings: Listing[]
|
listings: PublicListingItem[]
|
||||||
totalListings: number
|
totalListings: number
|
||||||
zoneCounts: Record<string, number>
|
zoneCounts: Record<string, number>
|
||||||
currentPage: number
|
currentPage: number
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { defineStore } from 'pinia'
|
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 {
|
import {
|
||||||
clearAuthStorage,
|
clearAuthStorage,
|
||||||
getAccessToken,
|
getAccessToken,
|
||||||
@@ -83,7 +89,12 @@ export const useSessionStore = defineStore('session', {
|
|||||||
this.applyUser(user)
|
this.applyUser(user)
|
||||||
return user
|
return user
|
||||||
},
|
},
|
||||||
logout() {
|
async logout() {
|
||||||
|
try {
|
||||||
|
await logoutUser()
|
||||||
|
} catch {
|
||||||
|
// 本地退出不依赖网络成功,避免用户无法离开当前账号。
|
||||||
|
}
|
||||||
this.token = ''
|
this.token = ''
|
||||||
this.refreshToken = ''
|
this.refreshToken = ''
|
||||||
this.userId = 0
|
this.userId = 0
|
||||||
|
|||||||
Reference in New Issue
Block a user