diff --git a/backend/internal/model/listing.go b/backend/internal/model/listing.go index 40cd26c..632ec92 100644 --- a/backend/internal/model/listing.go +++ b/backend/internal/model/listing.go @@ -30,6 +30,7 @@ func (GameAccount) TableName() string { type RentalListing struct { ID uint64 `gorm:"primaryKey" json:"id"` + ListingNo string `gorm:"column:listing_no;size:20;not null;uniqueIndex" json:"listing_no"` AccountID uint64 `gorm:"not null;index" json:"account_id"` OwnerID uint64 `gorm:"not null;index" json:"owner_id"` Price float64 `gorm:"type:decimal(12,2);not null;default:0" json:"price"` diff --git a/backend/internal/modules/listing/dto.go b/backend/internal/modules/listing/dto.go index 1e4d62a..d7a254c 100644 --- a/backend/internal/modules/listing/dto.go +++ b/backend/internal/modules/listing/dto.go @@ -10,6 +10,7 @@ import ( type ListingDTO struct { ID uint64 `json:"id"` + ListingNo string `json:"listing_no"` AccountID uint64 `json:"account_id"` OwnerID uint64 `json:"owner_id"` OwnerPhone string `json:"owner_phone,omitempty"` @@ -187,6 +188,7 @@ type ExternalUploadMeta struct { type ExternalUploadResult struct { Index int `json:"index"` ListingID uint64 `json:"listing_id,omitempty"` + ListingNo string `json:"listing_no,omitempty"` AccountID uint64 `json:"account_id,omitempty"` Status string `json:"status,omitempty"` ReviewStatus string `json:"review_status,omitempty"` @@ -195,6 +197,7 @@ type ExternalUploadResult struct { type ExternalUploadResponse struct { ListingID uint64 `json:"listing_id,omitempty"` + ListingNo string `json:"listing_no,omitempty"` AccountID uint64 `json:"account_id,omitempty"` Status string `json:"status,omitempty"` ReviewStatus string `json:"review_status,omitempty"` diff --git a/backend/internal/modules/listing/repository.go b/backend/internal/modules/listing/repository.go index d726d25..1f0201d 100644 --- a/backend/internal/modules/listing/repository.go +++ b/backend/internal/modules/listing/repository.go @@ -50,6 +50,10 @@ func initialPublishState(reviewRequired bool) (string, string, *time.Time) { func (r *Repository) Create(ownerID uint64, req CreateRequest, reviewRequired bool) (*ListingDTO, error) { var dto *ListingDTO err := r.db.Transaction(func(tx *gorm.DB) error { + listingNo, err := r.nextListingNo(tx, time.Now()) + if err != nil { + return err + } screenshots, err := marshalScreenshots(req.ScreenshotURLS) if err != nil { return err @@ -78,6 +82,7 @@ func (r *Repository) Create(ownerID uint64, req CreateRequest, reviewRequired bo price := normalizedListingPrice(req) depositAmount := roundMoney(req.DepositAmount) listing := model.RentalListing{ + ListingNo: listingNo, AccountID: account.ID, OwnerID: ownerID, Price: price, @@ -106,6 +111,10 @@ type externalUploadCreate struct { func (r *Repository) CreateFromExternalUpload(upload externalUploadCreate, req CreateRequest) (*ListingDTO, error) { var dto *ListingDTO err := r.db.Transaction(func(tx *gorm.DB) error { + listingNo, err := r.nextListingNo(tx, time.Now()) + if err != nil { + return err + } admin, err := r.findActiveUploadAdmin(tx, upload.UploaderName) if err != nil { return err @@ -139,6 +148,7 @@ func (r *Repository) CreateFromExternalUpload(upload externalUploadCreate, req C return err } listing := model.RentalListing{ + ListingNo: listingNo, AccountID: account.ID, OwnerID: owner.ID, Price: normalizedListingPrice(req), @@ -1009,6 +1019,9 @@ func publicZoneCounts(items []ListingDTO) map[string]int64 { func publicSearchText(item ListingDTO) string { parts := []string{ + item.ListingNo, + strconv.FormatUint(item.ID, 10), + strconv.FormatUint(item.AccountID, 10), item.Title, item.Description, item.RankLevel, @@ -1303,6 +1316,39 @@ func (r *Repository) findForReviewUpdate(tx *gorm.DB, listingID uint64) (*model. return &listing, &account, nil } +func (r *Repository) nextListingNo(tx *gorm.DB, now time.Time) (string, error) { + bizDate := now.Format("20060102") + if tx.Dialector.Name() == "mysql" { + if err := tx.Exec(` + INSERT INTO listing_no_sequences (biz_date, next_seq) + VALUES (?, LAST_INSERT_ID(1)) + ON DUPLICATE KEY UPDATE next_seq = LAST_INSERT_ID(next_seq + 1) + `, bizDate).Error; err != nil { + return "", err + } + var seq int + if err := tx.Raw("SELECT LAST_INSERT_ID()").Scan(&seq).Error; err != nil { + return "", err + } + return fmt.Sprintf("%s%04d", bizDate, seq), nil + } + + var maxNo string + if err := tx.Table("rental_listings"). + Select("COALESCE(MAX(listing_no), '')"). + Where("listing_no LIKE ?", bizDate+"%"). + Scan(&maxNo).Error; err != nil { + return "", err + } + seq := 1 + if len(maxNo) > len(bizDate) { + if parsed, err := strconv.Atoi(maxNo[len(bizDate):]); err == nil { + seq = parsed + 1 + } + } + return fmt.Sprintf("%s%04d", bizDate, seq), nil +} + type listingRow struct { model.RentalListing Title string @@ -1394,6 +1440,7 @@ func (row listingRow) toDTO() ListingDTO { reviewStatus, reviewReason := normalizedReviewState(row.Status, row.ReviewStatus, row.ReviewReason) return ListingDTO{ ID: row.ID, + ListingNo: row.ListingNo, AccountID: row.AccountID, OwnerID: row.OwnerID, OwnerPhone: row.OwnerPhone, @@ -1427,6 +1474,7 @@ func toDTO(account model.GameAccount, listing model.RentalListing) *ListingDTO { reviewStatus, reviewReason := normalizedReviewState(listing.Status, listing.ReviewStatus, listing.ReviewReason) return &ListingDTO{ ID: listing.ID, + ListingNo: listing.ListingNo, AccountID: account.ID, OwnerID: listing.OwnerID, Title: account.Title, diff --git a/backend/internal/modules/listing/service.go b/backend/internal/modules/listing/service.go index dc2f639..79813e9 100644 --- a/backend/internal/modules/listing/service.go +++ b/backend/internal/modules/listing/service.go @@ -150,6 +150,7 @@ func (s *Service) ImportExternalUpload(req ExternalUploadRequest, meta ExternalU result := ExternalUploadResult{ Index: index, ListingID: dto.ID, + ListingNo: dto.ListingNo, AccountID: dto.AccountID, Status: dto.Status, ReviewStatus: dto.ReviewStatus, @@ -157,6 +158,7 @@ func (s *Service) ImportExternalUpload(req ExternalUploadRequest, meta ExternalU results = append(results, result) if len(items) == 1 { resp.ListingID = dto.ID + resp.ListingNo = dto.ListingNo resp.AccountID = dto.AccountID resp.Status = dto.Status resp.ReviewStatus = dto.ReviewStatus diff --git a/backend/migrations/000002_add_listing_no.sql b/backend/migrations/000002_add_listing_no.sql new file mode 100644 index 0000000..cc982a9 --- /dev/null +++ b/backend/migrations/000002_add_listing_no.sql @@ -0,0 +1,33 @@ +-- 商品编号:用于前台展示、搜索和客服定位 +ALTER TABLE rental_listings + ADD COLUMN listing_no VARCHAR(20) NULL COMMENT '商品编号,格式yyyyMMddNNNN' AFTER id; + +UPDATE rental_listings AS l +JOIN ( + SELECT + id, + CONCAT( + DATE_FORMAT(created_at, '%Y%m%d'), + LPAD(ROW_NUMBER() OVER (PARTITION BY DATE(created_at) ORDER BY id), 4, '0') + ) AS generated_listing_no + FROM rental_listings +) AS seq ON seq.id = l.id +SET l.listing_no = seq.generated_listing_no +WHERE l.listing_no IS NULL OR l.listing_no = ''; + +ALTER TABLE rental_listings + MODIFY COLUMN listing_no VARCHAR(20) NOT NULL COMMENT '商品编号,格式yyyyMMddNNNN', + ADD UNIQUE KEY uk_rental_listings_listing_no (listing_no); + +CREATE TABLE IF NOT EXISTS listing_no_sequences ( + biz_date CHAR(8) PRIMARY KEY COMMENT '业务日期yyyyMMdd', + next_seq INT NOT NULL DEFAULT 0 COMMENT '当日已分配最大序号', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='商品编号日序列表'; + +INSERT INTO listing_no_sequences (biz_date, next_seq) +SELECT DATE_FORMAT(created_at, '%Y%m%d') AS biz_date, COUNT(*) AS next_seq +FROM rental_listings +GROUP BY DATE_FORMAT(created_at, '%Y%m%d') +ON DUPLICATE KEY UPDATE next_seq = GREATEST(next_seq, VALUES(next_seq)); diff --git a/frontend/src/features/listings/api/listings.ts b/frontend/src/features/listings/api/listings.ts index 8d5758b..75f4474 100644 --- a/frontend/src/features/listings/api/listings.ts +++ b/frontend/src/features/listings/api/listings.ts @@ -4,6 +4,7 @@ import type { ListingReviewStatus, ListingStatus } from '@/shared/types/status' export interface Listing { id: number + listing_no: string account_id: number owner_id: number owner_phone?: string diff --git a/frontend/src/features/listings/components/ListingCard.vue b/frontend/src/features/listings/components/ListingCard.vue index a189ca9..3e3bd55 100644 --- a/frontend/src/features/listings/components/ListingCard.vue +++ b/frontend/src/features/listings/components/ListingCard.vue @@ -7,6 +7,7 @@ import { getCoinWan, getDailyLoss, getListingDisplayPrice, + formatListingCode, getListingResources, getListingTitle, getLoginMethod, @@ -100,7 +101,10 @@ function formatStatNumber(value: number) {

{{ getListingTitle(listing) }}

- 日耗 {{ dailyLoss }} +
+ 编号 {{ formatListingCode(listing) }} + 日耗 {{ dailyLoss }} +
@@ -252,6 +256,23 @@ function formatStatNumber(value: number) { white-space: nowrap; } +.card-title-meta { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 8px; +} + +.listing-no { + padding: 4px 10px; + border: 1px solid #dbeafe; + border-radius: 6px; + background: #eff6ff; + font-size: 12px; + font-weight: 800; + color: #2563eb; +} + .daily-loss { flex: 0 0 auto; padding: 4px 10px; diff --git a/frontend/src/features/listings/views/HomeView.vue b/frontend/src/features/listings/views/HomeView.vue index 63586f9..52bd506 100644 --- a/frontend/src/features/listings/views/HomeView.vue +++ b/frontend/src/features/listings/views/HomeView.vue @@ -1,5 +1,6 @@