diff --git a/backend/internal/modules/listing/mutation.go b/backend/internal/modules/listing/mutation.go
index 5df0ac6..d3b01eb 100644
--- a/backend/internal/modules/listing/mutation.go
+++ b/backend/internal/modules/listing/mutation.go
@@ -208,7 +208,7 @@ func (r *Repository) Update(ctx context.Context, ownerID uint64, listingID uint6
if err != nil {
return err
}
- if listing.Status == "rented" || listing.InTransaction {
+ if listingLockedForOwnerMutation(listing) {
return ErrListingLocked
}
@@ -256,7 +256,7 @@ func (r *Repository) SubmitReview(ctx context.Context, ownerID uint64, listingID
if err != nil {
return err
}
- if listing.Status == "rented" || listing.InTransaction {
+ if listingLockedForOwnerMutation(listing) {
return ErrListingLocked
}
listingStatus, reviewStatus, publishedAt := initialPublishState(reviewRequired)
@@ -346,7 +346,7 @@ func (r *Repository) Offline(ctx context.Context, ownerID uint64, listingID uint
if err != nil {
return err
}
- if listing.Status == "rented" || listing.InTransaction {
+ if listingLockedForOwnerMutation(listing) {
return ErrListingLocked
}
listing.Status = "offline"
@@ -365,3 +365,10 @@ func (r *Repository) Offline(ctx context.Context, ownerID uint64, listingID uint
})
return dto, err
}
+
+func listingLockedForOwnerMutation(listing *model.RentalListing) bool {
+ if listing == nil {
+ return true
+ }
+ return listing.Status == "rented" || listing.Status == "completed" || listing.InTransaction
+}
diff --git a/backend/internal/modules/listing/service_test.go b/backend/internal/modules/listing/service_test.go
index 0160371..3593c49 100644
--- a/backend/internal/modules/listing/service_test.go
+++ b/backend/internal/modules/listing/service_test.go
@@ -213,6 +213,47 @@ func TestRepositoryUpdateAllowsOfflineListingResubmit(t *testing.T) {
}
}
+func TestRepositoryRejectsCompletedListingOwnerMutations(t *testing.T) {
+ db := database.NewTestDB()
+ if err := db.AutoMigrate(&model.GameAccount{}, &model.RentalListing{}); err != nil {
+ t.Fatalf("failed to migrate test db: %v", err)
+ }
+ repo := NewRepository(db, nil)
+ account := model.GameAccount{
+ OwnerID: 1,
+ GameName: "delta_force",
+ ServerRegion: "QQ",
+ LoginPlatform: "QQ账号密码",
+ Title: "已完成账号",
+ Status: "offline",
+ }
+ if err := db.Create(&account).Error; err != nil {
+ t.Fatalf("failed to create account: %v", err)
+ }
+ listing := model.RentalListing{
+ ListingNo: "202606270099",
+ AccountID: account.ID,
+ OwnerID: 1,
+ Status: "completed",
+ ReviewStatus: "approved",
+ PriceCent: 10000,
+ DepositAmountCent: 50000,
+ }
+ if err := db.Create(&listing).Error; err != nil {
+ t.Fatalf("failed to create listing: %v", err)
+ }
+
+ if _, err := repo.Update(t.Context(), 1, listing.ID, validCreateRequest(), false); err != ErrListingLocked {
+ t.Fatalf("Update expected ErrListingLocked, got %v", err)
+ }
+ if _, err := repo.SubmitReview(t.Context(), 1, listing.ID, false); err != ErrListingLocked {
+ t.Fatalf("SubmitReview expected ErrListingLocked, got %v", err)
+ }
+ if _, err := repo.Offline(t.Context(), 1, listing.ID); err != ErrListingLocked {
+ t.Fatalf("Offline expected ErrListingLocked, got %v", err)
+ }
+}
+
func validCreateRequest() CreateRequest {
return CreateRequest{
Title: "测试账号",
diff --git a/backend/internal/modules/order/assets.go b/backend/internal/modules/order/assets.go
index 5768df0..bb2dd05 100644
--- a/backend/internal/modules/order/assets.go
+++ b/backend/internal/modules/order/assets.go
@@ -63,6 +63,13 @@ func archiveAssets(listing *model.RentalListing, account *model.GameAccount) {
account.Status = accountStatusOffline
}
+func completeAssets(listing *model.RentalListing, account *model.GameAccount) {
+ listing.Status = listingStatusCompleted
+ listing.InTransaction = false
+ listing.PublishedAt = nil
+ account.Status = accountStatusOffline
+}
+
func markAssetsAbnormal(listing *model.RentalListing, account *model.GameAccount) {
listing.Status = listingStatusAbnormal
listing.InTransaction = false
diff --git a/backend/internal/modules/order/checkout_finalize.go b/backend/internal/modules/order/checkout_finalize.go
index fc5f707..c665714 100644
--- a/backend/internal/modules/order/checkout_finalize.go
+++ b/backend/internal/modules/order/checkout_finalize.go
@@ -21,7 +21,7 @@ func (r *Repository) finalizeCheckout(tx *gorm.DB, order *model.RentalOrder, che
order.SettlementStatus = settlementStatusSettled
order.SettledAt = &now
order.OwnerSettledAt = &now
- archiveAssets(listing, account)
+ completeAssets(listing, account)
settlement := buildCheckoutSettlement(*order, checkout)
if err := appendCheckoutOwnerIncome(tx, order, settlement); err != nil {
diff --git a/backend/internal/modules/order/constants.go b/backend/internal/modules/order/constants.go
index a6bb424..1c2f94a 100644
--- a/backend/internal/modules/order/constants.go
+++ b/backend/internal/modules/order/constants.go
@@ -47,6 +47,7 @@ const (
listingStatusPublished = "published"
listingStatusRented = "rented"
listingStatusOffline = "offline"
+ listingStatusCompleted = "completed"
listingStatusAbnormal = "abnormal"
accountStatusPublished = "published"
diff --git a/backend/internal/modules/order/repository_test.go b/backend/internal/modules/order/repository_test.go
index 53ebca4..c7e4322 100644
--- a/backend/internal/modules/order/repository_test.go
+++ b/backend/internal/modules/order/repository_test.go
@@ -165,6 +165,31 @@ func TestArchiveAssetsMovesListingOffline(t *testing.T) {
}
}
+func TestCompleteAssetsMovesListingCompleted(t *testing.T) {
+ now := time.Now()
+ listing := model.RentalListing{
+ Status: listingStatusRented,
+ InTransaction: true,
+ PublishedAt: &now,
+ }
+ account := model.GameAccount{Status: accountStatusRented}
+
+ completeAssets(&listing, &account)
+
+ if listing.Status != listingStatusCompleted {
+ t.Fatalf("listing.Status = %q, want %q", listing.Status, listingStatusCompleted)
+ }
+ if listing.InTransaction {
+ t.Fatal("listing.InTransaction = true, want false")
+ }
+ if listing.PublishedAt != nil {
+ t.Fatal("listing.PublishedAt is not nil")
+ }
+ if account.Status != accountStatusOffline {
+ t.Fatalf("account.Status = %q, want %q", account.Status, accountStatusOffline)
+ }
+}
+
func TestNewOrderNoUsesShanghaiTimeWhenLocalIsUTC(t *testing.T) {
oldLocal := time.Local
time.Local = time.UTC
diff --git a/backend/migrations/000018_completed_order_listing_status.sql b/backend/migrations/000018_completed_order_listing_status.sql
new file mode 100644
index 0000000..d1ffb2d
--- /dev/null
+++ b/backend/migrations/000018_completed_order_listing_status.sql
@@ -0,0 +1,25 @@
+-- +goose Up
+-- +goose StatementBegin
+
+UPDATE rental_listings l
+JOIN rental_orders o ON o.listing_id = l.id
+SET l.status = 'completed',
+ l.in_transaction = 0,
+ l.published_at = NULL
+WHERE o.status = 'completed'
+ AND l.status = 'offline';
+
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+
+UPDATE rental_listings l
+JOIN rental_orders o ON o.listing_id = l.id
+SET l.status = 'offline',
+ l.in_transaction = 0,
+ l.published_at = NULL
+WHERE o.status = 'completed'
+ AND l.status = 'completed';
+
+-- +goose StatementEnd
diff --git a/frontend/src/features/admin/views/AdminListingsView.vue b/frontend/src/features/admin/views/AdminListingsView.vue
index 789a614..6f7dea2 100644
--- a/frontend/src/features/admin/views/AdminListingsView.vue
+++ b/frontend/src/features/admin/views/AdminListingsView.vue
@@ -473,6 +473,7 @@ function formatQuantity(value: number) {
+
diff --git a/frontend/src/features/seller/composables/usePublishForm.ts b/frontend/src/features/seller/composables/usePublishForm.ts
index 5feb365..0ddd56d 100644
--- a/frontend/src/features/seller/composables/usePublishForm.ts
+++ b/frontend/src/features/seller/composables/usePublishForm.ts
@@ -190,6 +190,11 @@ export function usePublishForm(options: UsePublishFormOptions) {
loading.value = true
try {
const listing = await fetchSellerListing(editListingID.value)
+ if (listing.status === 'completed') {
+ options.notifyError('该商品订单已完成,不能再次编辑')
+ await router.push(options.submitSuccessPath)
+ return
+ }
applyListingToForm(listing)
} catch (error) {
options.notifyError(readError(error, '商品信息加载失败'))
diff --git a/frontend/src/features/seller/views/SellerListingsView.vue b/frontend/src/features/seller/views/SellerListingsView.vue
index dc67155..ab91219 100644
--- a/frontend/src/features/seller/views/SellerListingsView.vue
+++ b/frontend/src/features/seller/views/SellerListingsView.vue
@@ -44,6 +44,11 @@ const stats = computed(() => [
label: '已下架',
value: listings.value.filter(item => item.status === 'offline').length,
},
+ {
+ key: 'completed',
+ label: '已完成',
+ value: listings.value.filter(item => item.status === 'completed').length,
+ },
])
onMounted(loadListings)
@@ -124,15 +129,15 @@ function coinText(row: Listing) {
}
function canSubmit(row: Listing) {
- return !isPendingReview(row) && row.status !== 'rented' && row.status !== 'published'
+ return !isTerminalListing(row) && !isPendingReview(row) && row.status !== 'published'
}
function canEdit(row: Listing) {
- return !isPendingReview(row) && row.status !== 'rented' && row.status !== 'published'
+ return !isTerminalListing(row) && !isPendingReview(row) && row.status !== 'published'
}
function canOffline(row: Listing) {
- return row.status !== 'rented' && row.status !== 'offline'
+ return !isTerminalListing(row) && row.status !== 'offline'
}
function editPath(row: Listing) {
@@ -146,6 +151,7 @@ function statusTone(status: string) {
published: 'success',
draft: 'info',
offline: 'muted',
+ completed: 'success',
rented: 'warning',
abnormal: 'danger',
}
@@ -166,8 +172,16 @@ function effectiveReviewStatus(row: Listing) {
return row.status === 'offline' ? 'none' : row.review_status
}
+function showReviewStatus(row: Listing) {
+ return row.status !== 'completed'
+}
+
+function isTerminalListing(row: Listing) {
+ return row.status === 'rented' || row.status === 'completed'
+}
+
function isPendingReview(row: Listing) {
- return row.status !== 'offline' && row.review_status === 'pending'
+ return !isTerminalListing(row) && row.status !== 'offline' && row.review_status === 'pending'
}
@@ -218,7 +232,11 @@ function isPendingReview(row: Listing) {
{{
listingStatusLabel(item.status)
}}
-
+
{{ listingReviewStatusLabel(effectiveReviewStatus(item)) }}
diff --git a/frontend/src/shared/types/status.ts b/frontend/src/shared/types/status.ts
index 37f2519..38fb7c7 100644
--- a/frontend/src/shared/types/status.ts
+++ b/frontend/src/shared/types/status.ts
@@ -1,4 +1,11 @@
-export const listingStatuses = ['draft', 'published', 'rented', 'offline', 'abnormal'] as const
+export const listingStatuses = [
+ 'draft',
+ 'published',
+ 'rented',
+ 'offline',
+ 'completed',
+ 'abnormal',
+] as const
export type ListingStatus = (typeof listingStatuses)[number]
export const listingReviewStatuses = ['none', 'pending', 'approved', 'rejected'] as const
diff --git a/frontend/src/shared/utils/statusLabels.ts b/frontend/src/shared/utils/statusLabels.ts
index 06c3a42..23f8474 100644
--- a/frontend/src/shared/utils/statusLabels.ts
+++ b/frontend/src/shared/utils/statusLabels.ts
@@ -18,6 +18,7 @@ const listingStatusMap: Record = {
published: '已上架',
rented: '租用中',
offline: '已下架',
+ completed: '已完成',
abnormal: '异常',
}