From 683965ae391d3641253908f83ba948f171103b13 Mon Sep 17 00:00:00 2001 From: Rogee Date: Mon, 22 Dec 2025 21:11:33 +0800 Subject: [PATCH] feat: Refactor order snapshot handling and introduce structured snapshot types - Added new structured snapshot types for orders and order items to improve data integrity and clarity. - Updated the Order and OrderItem models to use the new JSONType for snapshots. - Refactored tests to accommodate the new snapshot structure, ensuring compatibility with legacy data. - Enhanced the OrdersSnapshot struct to support multiple snapshot types and maintain backward compatibility. - Introduced new fields for order items and orders to capture detailed snapshot information for auditing and historical display. --- .gitignore | 1 + backend/app/services/order.go | 103 +++-------------- backend/app/services/order_test.go | 124 ++++++++++++--------- backend/database/.transform.yaml | 4 + backend/database/fields/order_items.go | 15 +++ backend/database/fields/orders.go | 98 ++++++++++++++++ backend/database/models/order_items.gen.go | 26 +++-- backend/database/models/orders.gen.go | 39 +++---- 8 files changed, 239 insertions(+), 171 deletions(-) create mode 100644 backend/database/fields/order_items.go create mode 100644 backend/database/fields/orders.go diff --git a/.gitignore b/.gitignore index 3dd2275..2fb8239 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ build/* .vscode .idea tmp/ +.cache/ .gocache/ .gotmp/ docker-compose.yml diff --git a/backend/app/services/order.go b/backend/app/services/order.go index bfb8e02..7f213b5 100644 --- a/backend/app/services/order.go +++ b/backend/app/services/order.go @@ -14,6 +14,7 @@ import ( "quyun/v2/app/http/tenant/dto" "quyun/v2/app/requests" "quyun/v2/database" + "quyun/v2/database/fields" "quyun/v2/database/models" "quyun/v2/pkg/consts" @@ -28,6 +29,17 @@ import ( "go.ipao.vip/gen/types" ) +func newOrderSnapshot(kind consts.OrderType, payload any) types.JSONType[fields.OrdersSnapshot] { + b, err := json.Marshal(payload) + if err != nil || len(b) == 0 { + b = []byte("{}") + } + return types.NewJSONType(fields.OrdersSnapshot{ + Kind: string(kind), + Data: b, + }) +} + // AdminOrderExportCSV 租户管理员导出订单列表(CSV 文本)。 func (s *order) AdminOrderExportCSV(ctx context.Context, tenantID int64, filter *dto.AdminOrderListFilter) (*dto.AdminOrderExportResponse, error) { if tenantID <= 0 { @@ -305,76 +317,6 @@ func (s *order) AdminBatchTopupUsers( return out, nil } -// PurchaseOrderSnapshot 为“内容购买订单”的下单快照(用于历史展示与争议审计)。 -type PurchaseOrderSnapshot struct { - // ContentID 内容ID。 - ContentID int64 `json:"content_id"` - // ContentTitle 内容标题(下单时快照,避免事后改名影响历史订单展示)。 - ContentTitle string `json:"content_title"` - // ContentUserID 内容作者用户ID(用于审计与后续分成扩展)。 - ContentUserID int64 `json:"content_user_id"` - // ContentVisibility 下单时的可见性快照。 - ContentVisibility consts.ContentVisibility `json:"content_visibility"` - // PreviewSeconds 下单时的试看秒数快照。 - PreviewSeconds int32 `json:"preview_seconds"` - // PreviewDownloadable 下单时的试看是否可下载快照(当前固定为 false)。 - PreviewDownloadable bool `json:"preview_downloadable"` - // Currency 币种:当前固定 CNY(金额单位为分)。 - Currency consts.Currency `json:"currency"` - // PriceAmount 基础价格(分)。 - PriceAmount int64 `json:"price_amount"` - // DiscountType 折扣类型(none/percent/amount)。 - DiscountType consts.DiscountType `json:"discount_type"` - // DiscountValue 折扣值(percent=0-100;amount=分)。 - DiscountValue int64 `json:"discount_value"` - // DiscountStartAt 折扣开始时间(可选)。 - DiscountStartAt *time.Time `json:"discount_start_at,omitempty"` - // DiscountEndAt 折扣结束时间(可选)。 - DiscountEndAt *time.Time `json:"discount_end_at,omitempty"` - // AmountOriginal 原价金额(分)。 - AmountOriginal int64 `json:"amount_original"` - // AmountDiscount 优惠金额(分)。 - AmountDiscount int64 `json:"amount_discount"` - // AmountPaid 实付金额(分)。 - AmountPaid int64 `json:"amount_paid"` - // PurchaseAt 下单时间(逻辑时间)。 - PurchaseAt time.Time `json:"purchase_at"` - // PurchaseIdempotency 幂等键(可选)。 - PurchaseIdempotency string `json:"purchase_idempotency_key,omitempty"` - // PurchasePricingNotes 价格计算补充说明(可选,便于排查争议)。 - PurchasePricingNotes string `json:"purchase_pricing_notes,omitempty"` -} - -// OrderItemSnapshot 为“订单明细”的内容快照。 -type OrderItemSnapshot struct { - // ContentID 内容ID。 - ContentID int64 `json:"content_id"` - // ContentTitle 内容标题快照。 - ContentTitle string `json:"content_title"` - // ContentUserID 内容作者用户ID。 - ContentUserID int64 `json:"content_user_id"` - // AmountPaid 该行实付金额(分)。 - AmountPaid int64 `json:"amount_paid"` -} - -// TopupOrderSnapshot 为“后台充值订单”的快照(用于审计与追责)。 -type TopupOrderSnapshot struct { - // OperatorUserID 充值操作人用户ID(租户管理员)。 - OperatorUserID int64 `json:"operator_user_id"` - // TargetUserID 充值目标用户ID(租户成员)。 - TargetUserID int64 `json:"target_user_id"` - // Amount 充值金额(分)。 - Amount int64 `json:"amount"` - // Currency 币种:当前固定 CNY(金额单位为分)。 - Currency consts.Currency `json:"currency"` - // Reason 充值原因(可选,强烈建议填写用于审计)。 - Reason string `json:"reason,omitempty"` - // IdempotencyKey 幂等键(可选)。 - IdempotencyKey string `json:"idempotency_key,omitempty"` - // TopupAt 充值时间(逻辑时间)。 - TopupAt time.Time `json:"topup_at"` -} - // PurchaseContentParams 定义“租户内使用余额购买内容”的入参。 type PurchaseContentParams struct { // TenantID 租户 ID(多租户隔离范围)。 @@ -409,17 +351,6 @@ type order struct { ledger *ledger } -func marshalSnapshot(v any) types.JSON { - b, err := json.Marshal(v) - if err != nil { - return types.JSON([]byte("{}")) - } - if len(b) == 0 { - return types.JSON([]byte("{}")) - } - return types.JSON(b) -} - // AdminTopupUser 租户管理员给租户成员充值(增加该租户下的可用余额)。 func (s *order) AdminTopupUser( ctx context.Context, @@ -475,7 +406,7 @@ func (s *order) AdminTopupUser( } // 先落订单(paid),再写入账本(credit_topup),确保“订单可追溯 + 账本可对账”。 - snapshot := marshalSnapshot(&TopupOrderSnapshot{ + snapshot := newOrderSnapshot(consts.OrderTypeTopup, &fields.OrdersTopupSnapshot{ OperatorUserID: operatorUserID, TargetUserID: targetUserID, Amount: amount, @@ -1061,7 +992,7 @@ func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentPara discountEndAt = &t } - purchaseSnapshot := marshalSnapshot(&PurchaseOrderSnapshot{ + purchaseSnapshot := newOrderSnapshot(consts.OrderTypeContentPurchase, &fields.OrdersContentPurchaseSnapshot{ ContentID: content.ID, ContentTitle: content.Title, ContentUserID: content.UserID, @@ -1080,7 +1011,7 @@ func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentPara PurchaseAt: now, PurchaseIdempotency: params.IdempotencyKey, }) - itemSnapshot := marshalSnapshot(&OrderItemSnapshot{ + itemSnapshot := types.NewJSONType(fields.OrderItemsSnapshot{ ContentID: content.ID, ContentTitle: content.Title, ContentUserID: content.UserID, @@ -1303,7 +1234,7 @@ func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentPara t := price.DiscountEndAt discountEndAt = &t } - purchaseSnapshot := marshalSnapshot(&PurchaseOrderSnapshot{ + purchaseSnapshot := newOrderSnapshot(consts.OrderTypeContentPurchase, &fields.OrdersContentPurchaseSnapshot{ ContentID: content.ID, ContentTitle: content.Title, ContentUserID: content.UserID, @@ -1321,7 +1252,7 @@ func (s *order) PurchaseContent(ctx context.Context, params *PurchaseContentPara AmountPaid: amountPaid, PurchaseAt: now, }) - itemSnapshot := marshalSnapshot(&OrderItemSnapshot{ + itemSnapshot := types.NewJSONType(fields.OrderItemsSnapshot{ ContentID: content.ID, ContentTitle: content.Title, ContentUserID: content.UserID, diff --git a/backend/app/services/order_test.go b/backend/app/services/order_test.go index b848065..e8bf0bc 100644 --- a/backend/app/services/order_test.go +++ b/backend/app/services/order_test.go @@ -14,6 +14,7 @@ import ( "quyun/v2/app/http/tenant/dto" "quyun/v2/app/requests" "quyun/v2/database" + "quyun/v2/database/fields" "quyun/v2/database/models" "quyun/v2/pkg/consts" @@ -27,6 +28,13 @@ import ( "go.uber.org/dig" ) +func newLegacyOrderSnapshot() types.JSONType[fields.OrdersSnapshot] { + return types.NewJSONType(fields.OrdersSnapshot{ + Kind: "legacy", + Data: json.RawMessage([]byte("{}")), + }) +} + type OrderTestSuiteInjectParams struct { dig.In @@ -144,11 +152,14 @@ func (s *OrderTestSuite) Test_AdminTopupUser() { So(orderModel.Status, ShouldEqual, consts.OrderStatusPaid) So(orderModel.AmountPaid, ShouldEqual, 300) - var snap map[string]any - So(json.Unmarshal([]byte(orderModel.Snapshot), &snap), ShouldBeNil) - So(snap["operator_user_id"], ShouldEqual, float64(operatorUserID)) - So(snap["target_user_id"], ShouldEqual, float64(targetUserID)) - So(snap["amount"], ShouldEqual, float64(300)) + snap := orderModel.Snapshot.Data() + So(snap.Kind, ShouldEqual, string(consts.OrderTypeTopup)) + + var snapData fields.OrdersTopupSnapshot + So(json.Unmarshal(snap.Data, &snapData), ShouldBeNil) + So(snapData.OperatorUserID, ShouldEqual, operatorUserID) + So(snapData.TargetUserID, ShouldEqual, targetUserID) + So(snapData.Amount, ShouldEqual, int64(300)) var tu models.TenantUser So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, targetUserID).First(&tu).Error, ShouldBeNil) @@ -211,7 +222,7 @@ func (s *OrderTestSuite) Test_MyOrderPage() { Status: consts.OrderStatusPaid, Currency: consts.CurrencyCNY, AmountPaid: 100, - Snapshot: types.JSON([]byte("{}")), + Snapshot: newLegacyOrderSnapshot(), PaidAt: now, CreatedAt: now, UpdatedAt: now, @@ -224,7 +235,7 @@ func (s *OrderTestSuite) Test_MyOrderPage() { ContentID: 111, ContentUserID: 1, AmountPaid: 100, - Snapshot: types.JSON([]byte("{}")), + Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}), CreatedAt: now, UpdatedAt: now, }).Create(ctx), ShouldBeNil) @@ -236,7 +247,7 @@ func (s *OrderTestSuite) Test_MyOrderPage() { Status: consts.OrderStatusPaid, Currency: consts.CurrencyCNY, AmountPaid: 200, - Snapshot: types.JSON([]byte("{}")), + Snapshot: newLegacyOrderSnapshot(), PaidAt: now.Add(time.Minute), CreatedAt: now.Add(time.Minute), UpdatedAt: now.Add(time.Minute), @@ -249,7 +260,7 @@ func (s *OrderTestSuite) Test_MyOrderPage() { ContentID: 222, ContentUserID: 1, AmountPaid: 200, - Snapshot: types.JSON([]byte("{}")), + Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}), CreatedAt: now.Add(time.Minute), UpdatedAt: now.Add(time.Minute), }).Create(ctx), ShouldBeNil) @@ -310,7 +321,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() { Status: consts.OrderStatusPaid, Currency: consts.CurrencyCNY, AmountPaid: 100, - Snapshot: types.JSON([]byte("{}")), + Snapshot: newLegacyOrderSnapshot(), PaidAt: now.Add(-time.Hour), CreatedAt: now.Add(-time.Hour), UpdatedAt: now.Add(-time.Hour), @@ -323,7 +334,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() { ContentID: 333, ContentUserID: 1, AmountPaid: 100, - Snapshot: types.JSON([]byte("{}")), + Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}), CreatedAt: now.Add(-time.Hour), UpdatedAt: now.Add(-time.Hour), }).Create(ctx), ShouldBeNil) @@ -335,7 +346,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() { Status: consts.OrderStatusPaid, Currency: consts.CurrencyCNY, AmountPaid: 200, - Snapshot: types.JSON([]byte("{}")), + Snapshot: newLegacyOrderSnapshot(), PaidAt: now, CreatedAt: now, UpdatedAt: now, @@ -348,7 +359,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() { ContentID: 444, ContentUserID: 1, AmountPaid: 200, - Snapshot: types.JSON([]byte("{}")), + Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}), CreatedAt: now, UpdatedAt: now, }).Create(ctx), ShouldBeNil) @@ -373,7 +384,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() { Status: consts.OrderStatusPaid, Currency: consts.CurrencyCNY, AmountPaid: 100, - Snapshot: types.JSON([]byte("{}")), + Snapshot: newLegacyOrderSnapshot(), PaidAt: now, CreatedAt: now, UpdatedAt: now, @@ -386,7 +397,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() { ContentID: 555, ContentUserID: 1, AmountPaid: 100, - Snapshot: types.JSON([]byte("{}")), + Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}), CreatedAt: now, UpdatedAt: now, }).Create(ctx), ShouldBeNil) @@ -429,7 +440,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() { Status: consts.OrderStatusPaid, Currency: consts.CurrencyCNY, AmountPaid: 100, - Snapshot: types.JSON([]byte("{}")), + Snapshot: newLegacyOrderSnapshot(), PaidAt: now, CreatedAt: now, UpdatedAt: now, @@ -442,7 +453,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() { Status: consts.OrderStatusPaid, Currency: consts.CurrencyCNY, AmountPaid: 100, - Snapshot: types.JSON([]byte("{}")), + Snapshot: newLegacyOrderSnapshot(), PaidAt: now, CreatedAt: now, UpdatedAt: now, @@ -499,7 +510,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() { Status: consts.OrderStatusPaid, Currency: consts.CurrencyCNY, AmountPaid: 100, - Snapshot: types.JSON([]byte("{}")), + Snapshot: newLegacyOrderSnapshot(), PaidAt: now, CreatedAt: now, UpdatedAt: now, @@ -512,7 +523,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() { ContentID: c1.ID, ContentUserID: 1, AmountPaid: 100, - Snapshot: types.JSON([]byte("{}")), + Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}), CreatedAt: now, UpdatedAt: now, }).Create(ctx), ShouldBeNil) @@ -524,7 +535,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() { Status: consts.OrderStatusPaid, Currency: consts.CurrencyCNY, AmountPaid: 100, - Snapshot: types.JSON([]byte("{}")), + Snapshot: newLegacyOrderSnapshot(), PaidAt: now, CreatedAt: now, UpdatedAt: now, @@ -537,7 +548,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() { ContentID: c2.ID, ContentUserID: 1, AmountPaid: 100, - Snapshot: types.JSON([]byte("{}")), + Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}), CreatedAt: now, UpdatedAt: now, }).Create(ctx), ShouldBeNil) @@ -560,7 +571,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() { Status: consts.OrderStatusPaid, Currency: consts.CurrencyCNY, AmountPaid: 100, - Snapshot: types.JSON([]byte("{}")), + Snapshot: newLegacyOrderSnapshot(), PaidAt: now, CreatedAt: now.Add(-time.Hour), UpdatedAt: now.Add(-time.Hour), @@ -574,7 +585,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() { Status: consts.OrderStatusPaid, Currency: consts.CurrencyCNY, AmountPaid: 200, - Snapshot: types.JSON([]byte("{}")), + Snapshot: newLegacyOrderSnapshot(), PaidAt: now, CreatedAt: now, UpdatedAt: now, @@ -601,7 +612,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() { Status: consts.OrderStatusPaid, Currency: consts.CurrencyCNY, AmountPaid: 500, - Snapshot: types.JSON([]byte("{}")), + Snapshot: newLegacyOrderSnapshot(), PaidAt: now, CreatedAt: now.Add(-time.Hour), UpdatedAt: now.Add(-time.Hour), @@ -615,7 +626,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() { Status: consts.OrderStatusPaid, Currency: consts.CurrencyCNY, AmountPaid: 100, - Snapshot: types.JSON([]byte("{}")), + Snapshot: newLegacyOrderSnapshot(), PaidAt: now, CreatedAt: now, UpdatedAt: now, @@ -653,7 +664,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() { Status: consts.OrderStatusPaid, Currency: consts.CurrencyCNY, AmountPaid: 100, - Snapshot: types.JSON([]byte("{}")), + Snapshot: newLegacyOrderSnapshot(), PaidAt: now, CreatedAt: now, UpdatedAt: now, @@ -667,7 +678,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() { Status: consts.OrderStatusPaid, Currency: consts.CurrencyCNY, AmountPaid: 200, - Snapshot: types.JSON([]byte("{}")), + Snapshot: newLegacyOrderSnapshot(), PaidAt: now, CreatedAt: now, UpdatedAt: now, @@ -697,7 +708,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() { Status: consts.OrderStatusPaid, Currency: consts.CurrencyCNY, AmountPaid: 500, - Snapshot: types.JSON([]byte("{}")), + Snapshot: newLegacyOrderSnapshot(), PaidAt: now, CreatedAt: now, UpdatedAt: now, @@ -710,7 +721,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() { ContentID: contentID, ContentUserID: 1, AmountPaid: 500, - Snapshot: types.JSON([]byte("{}")), + Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}), CreatedAt: now, UpdatedAt: now, }).Create(ctx), ShouldBeNil) @@ -723,7 +734,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() { Status: consts.OrderStatusPaid, Currency: consts.CurrencyCNY, AmountPaid: 50, - Snapshot: types.JSON([]byte("{}")), + Snapshot: newLegacyOrderSnapshot(), PaidAt: now, CreatedAt: now, UpdatedAt: now, @@ -736,7 +747,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() { ContentID: contentID, ContentUserID: 1, AmountPaid: 50, - Snapshot: types.JSON([]byte("{}")), + Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}), CreatedAt: now, UpdatedAt: now, }).Create(ctx), ShouldBeNil) @@ -749,7 +760,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() { Status: consts.OrderStatusCreated, Currency: consts.CurrencyCNY, AmountPaid: 500, - Snapshot: types.JSON([]byte("{}")), + Snapshot: newLegacyOrderSnapshot(), PaidAt: now, CreatedAt: now, UpdatedAt: now, @@ -762,7 +773,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() { ContentID: contentID, ContentUserID: 1, AmountPaid: 500, - Snapshot: types.JSON([]byte("{}")), + Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}), CreatedAt: now, UpdatedAt: now, }).Create(ctx), ShouldBeNil) @@ -775,7 +786,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() { Status: consts.OrderStatusPaid, Currency: consts.CurrencyCNY, AmountPaid: 500, - Snapshot: types.JSON([]byte("{}")), + Snapshot: newLegacyOrderSnapshot(), PaidAt: now, CreatedAt: now, UpdatedAt: now, @@ -788,7 +799,7 @@ func (s *OrderTestSuite) Test_AdminOrderPage() { ContentID: contentID, ContentUserID: 1, AmountPaid: 500, - Snapshot: types.JSON([]byte("{}")), + Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}), CreatedAt: now, UpdatedAt: now, }).Create(ctx), ShouldBeNil) @@ -859,7 +870,7 @@ func (s *OrderTestSuite) Test_AdminOrderExportCSV() { Status: consts.OrderStatusPaid, Currency: consts.CurrencyCNY, AmountPaid: 123, - Snapshot: types.JSON([]byte("{}")), + Snapshot: newLegacyOrderSnapshot(), PaidAt: now, CreatedAt: now, UpdatedAt: now, @@ -1028,7 +1039,7 @@ func (s *OrderTestSuite) Test_AdminRefundOrder() { AmountOriginal: 100, AmountDiscount: 0, AmountPaid: 100, - Snapshot: types.JSON([]byte("{}")), + Snapshot: newLegacyOrderSnapshot(), PaidAt: now, CreatedAt: now, UpdatedAt: now, @@ -1055,7 +1066,7 @@ func (s *OrderTestSuite) Test_AdminRefundOrder() { AmountOriginal: 100, AmountDiscount: 0, AmountPaid: 100, - Snapshot: types.JSON([]byte("{}")), + Snapshot: newLegacyOrderSnapshot(), PaidAt: now.Add(-consts.DefaultOrderRefundWindow).Add(-time.Second), CreatedAt: now, UpdatedAt: now, @@ -1079,7 +1090,7 @@ func (s *OrderTestSuite) Test_AdminRefundOrder() { AmountOriginal: 300, AmountDiscount: 0, AmountPaid: 300, - Snapshot: types.JSON([]byte("{}")), + Snapshot: newLegacyOrderSnapshot(), PaidAt: now, CreatedAt: now, UpdatedAt: now, @@ -1093,7 +1104,7 @@ func (s *OrderTestSuite) Test_AdminRefundOrder() { ContentID: contentID, ContentUserID: 999, AmountPaid: 300, - Snapshot: types.JSON([]byte("{}")), + Snapshot: types.NewJSONType(fields.OrderItemsSnapshot{}), CreatedAt: now, UpdatedAt: now, } @@ -1217,11 +1228,14 @@ func (s *OrderTestSuite) Test_PurchaseContent() { So(res1.Access, ShouldNotBeNil) So(res1.Access.Status, ShouldEqual, consts.ContentAccessStatusActive) - var snap map[string]any - So(json.Unmarshal([]byte(res1.Order.Snapshot), &snap), ShouldBeNil) - So(snap["content_id"], ShouldEqual, float64(content.ID)) - So(snap["content_title"], ShouldEqual, content.Title) - So(snap["amount_paid"], ShouldEqual, float64(0)) + snap := res1.Order.Snapshot.Data() + So(snap.Kind, ShouldEqual, string(consts.OrderTypeContentPurchase)) + + var snapData fields.OrdersContentPurchaseSnapshot + So(json.Unmarshal(snap.Data, &snapData), ShouldBeNil) + So(snapData.ContentID, ShouldEqual, content.ID) + So(snapData.ContentTitle, ShouldEqual, content.Title) + So(snapData.AmountPaid, ShouldEqual, int64(0)) res2, err := Order.PurchaseContent(ctx, &PurchaseContentParams{ TenantID: tenantID, @@ -1264,16 +1278,18 @@ func (s *OrderTestSuite) Test_PurchaseContent() { So(res1.Access, ShouldNotBeNil) So(res1.Access.Status, ShouldEqual, consts.ContentAccessStatusActive) - var snap map[string]any - So(json.Unmarshal([]byte(res1.Order.Snapshot), &snap), ShouldBeNil) - So(snap["content_id"], ShouldEqual, float64(content.ID)) - So(snap["amount_paid"], ShouldEqual, float64(300)) - So(snap["amount_original"], ShouldEqual, float64(300)) + snap := res1.Order.Snapshot.Data() + So(snap.Kind, ShouldEqual, string(consts.OrderTypeContentPurchase)) - var itemSnap map[string]any - So(json.Unmarshal([]byte(res1.OrderItem.Snapshot), &itemSnap), ShouldBeNil) - So(itemSnap["content_id"], ShouldEqual, float64(content.ID)) - So(itemSnap["amount_paid"], ShouldEqual, float64(300)) + var snapData fields.OrdersContentPurchaseSnapshot + So(json.Unmarshal(snap.Data, &snapData), ShouldBeNil) + So(snapData.ContentID, ShouldEqual, content.ID) + So(snapData.AmountPaid, ShouldEqual, int64(300)) + So(snapData.AmountOriginal, ShouldEqual, int64(300)) + + itemSnap := res1.OrderItem.Snapshot.Data() + So(itemSnap.ContentID, ShouldEqual, content.ID) + So(itemSnap.AmountPaid, ShouldEqual, int64(300)) var tu models.TenantUser So(_db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, buyerUserID).First(&tu).Error, ShouldBeNil) diff --git a/backend/database/.transform.yaml b/backend/database/.transform.yaml index 5c1db56..f29e059 100644 --- a/backend/database/.transform.yaml +++ b/backend/database/.transform.yaml @@ -9,6 +9,7 @@ ignores: imports: - go.ipao.vip/gen - quyun/v2/pkg/consts + - quyun/v2/database/fields field_type: users: roles: types.Array[consts.Role] @@ -37,6 +38,9 @@ field_type: type: consts.OrderType status: consts.OrderStatus currency: consts.Currency + snapshot: types.JSONType[fields.OrdersSnapshot] + order_items: + snapshot: types.JSONType[fields.OrderItemsSnapshot] tenant_ledgers: type: consts.TenantLedgerType tenant_invites: diff --git a/backend/database/fields/order_items.go b/backend/database/fields/order_items.go new file mode 100644 index 0000000..8fb8b17 --- /dev/null +++ b/backend/database/fields/order_items.go @@ -0,0 +1,15 @@ +package fields + +// OrderItemsSnapshot 定义 order_items.snapshot 的固定结构。 +// +// 该快照用于“历史展示与审计”:即使内容标题/作者等信息后续变更,也不影响已下单记录的展示一致性。 +type OrderItemsSnapshot struct { + // ContentID 内容 ID。 + ContentID int64 `json:"content_id"` + // ContentTitle 内容标题快照。 + ContentTitle string `json:"content_title"` + // ContentUserID 内容作者用户 ID。 + ContentUserID int64 `json:"content_user_id"` + // AmountPaid 该行实付金额(分)。 + AmountPaid int64 `json:"amount_paid"` +} diff --git a/backend/database/fields/orders.go b/backend/database/fields/orders.go new file mode 100644 index 0000000..0aa6ebe --- /dev/null +++ b/backend/database/fields/orders.go @@ -0,0 +1,98 @@ +package fields + +import ( + "encoding/json" + "time" + + "quyun/v2/pkg/consts" +) + +// OrdersSnapshot 是 orders.snapshot 的统一包裹结构(判别联合)。 +// +// 设计目标: +// - 同一字段支持多种快照结构(按 kind 区分)。 +// - 查询/展示时可以先看 kind,再按需解析 data。 +// - 兼容历史数据:如果旧数据没有 kind/data,则按 legacy 处理(data = 原始 JSON)。 +type OrdersSnapshot struct { + // Kind 快照类型:建议与订单类型对齐(例如 content_purchase / topup)。 + Kind string `json:"kind"` + // Data 具体快照数据(按 Kind 对应不同结构)。 + Data json.RawMessage `json:"data"` +} + +type ordersSnapshotWire struct { + Kind string `json:"kind"` + Data json.RawMessage `json:"data"` +} + +func (s *OrdersSnapshot) UnmarshalJSON(b []byte) error { + var w ordersSnapshotWire + if err := json.Unmarshal(b, &w); err == nil && (w.Kind != "" || w.Data != nil) { + s.Kind = w.Kind + s.Data = w.Data + return nil + } + + // 兼容旧结构:旧 snapshot 通常是一个扁平对象(没有 kind/data)。 + s.Kind = "legacy" + s.Data = append(s.Data[:0], b...) + return nil +} + +// OrdersContentPurchaseSnapshot 为“内容购买订单”的下单快照(用于历史展示与争议审计)。 +type OrdersContentPurchaseSnapshot struct { + // ContentID 内容ID。 + ContentID int64 `json:"content_id"` + // ContentTitle 内容标题(下单时快照,避免事后改名影响历史订单展示)。 + ContentTitle string `json:"content_title"` + // ContentUserID 内容作者用户ID(用于审计与后续分成扩展)。 + ContentUserID int64 `json:"content_user_id"` + // ContentVisibility 下单时的可见性快照。 + ContentVisibility consts.ContentVisibility `json:"content_visibility"` + // PreviewSeconds 下单时的试看秒数快照。 + PreviewSeconds int32 `json:"preview_seconds"` + // PreviewDownloadable 下单时的试看是否可下载快照(当前固定为 false)。 + PreviewDownloadable bool `json:"preview_downloadable"` + // Currency 币种:当前固定 CNY(金额单位为分)。 + Currency consts.Currency `json:"currency"` + // PriceAmount 基础价格(分)。 + PriceAmount int64 `json:"price_amount"` + // DiscountType 折扣类型(none/percent/amount)。 + DiscountType consts.DiscountType `json:"discount_type"` + // DiscountValue 折扣值(percent=0-100;amount=分)。 + DiscountValue int64 `json:"discount_value"` + // DiscountStartAt 折扣开始时间(可选)。 + DiscountStartAt *time.Time `json:"discount_start_at,omitempty"` + // DiscountEndAt 折扣结束时间(可选)。 + DiscountEndAt *time.Time `json:"discount_end_at,omitempty"` + // AmountOriginal 原价金额(分)。 + AmountOriginal int64 `json:"amount_original"` + // AmountDiscount 优惠金额(分)。 + AmountDiscount int64 `json:"amount_discount"` + // AmountPaid 实付金额(分)。 + AmountPaid int64 `json:"amount_paid"` + // PurchaseAt 下单时间(逻辑时间)。 + PurchaseAt time.Time `json:"purchase_at"` + // PurchaseIdempotency 幂等键(可选)。 + PurchaseIdempotency string `json:"purchase_idempotency_key,omitempty"` + // PurchasePricingNotes 价格计算补充说明(可选,便于排查争议)。 + PurchasePricingNotes string `json:"purchase_pricing_notes,omitempty"` +} + +// OrdersTopupSnapshot 为“后台充值订单”的快照(用于审计与追责)。 +type OrdersTopupSnapshot struct { + // OperatorUserID 充值操作人用户ID(租户管理员)。 + OperatorUserID int64 `json:"operator_user_id"` + // TargetUserID 充值目标用户ID(租户成员)。 + TargetUserID int64 `json:"target_user_id"` + // Amount 充值金额(分)。 + Amount int64 `json:"amount"` + // Currency 币种:当前固定 CNY(金额单位为分)。 + Currency consts.Currency `json:"currency"` + // Reason 充值原因(可选,强烈建议填写用于审计)。 + Reason string `json:"reason,omitempty"` + // IdempotencyKey 幂等键(可选)。 + IdempotencyKey string `json:"idempotency_key,omitempty"` + // TopupAt 充值时间(逻辑时间)。 + TopupAt time.Time `json:"topup_at"` +} diff --git a/backend/database/models/order_items.gen.go b/backend/database/models/order_items.gen.go index 4ac2774..43930e2 100644 --- a/backend/database/models/order_items.gen.go +++ b/backend/database/models/order_items.gen.go @@ -8,6 +8,8 @@ import ( "context" "time" + "quyun/v2/database/fields" + "go.ipao.vip/gen" "go.ipao.vip/gen/types" ) @@ -16,18 +18,18 @@ const TableNameOrderItem = "order_items" // OrderItem mapped from table type OrderItem struct { - ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true;comment:主键ID:自增" json:"id"` // 主键ID:自增 - TenantID int64 `gorm:"column:tenant_id;type:bigint;not null;comment:租户ID:多租户隔离关键字段;必须与 orders.tenant_id 一致" json:"tenant_id"` // 租户ID:多租户隔离关键字段;必须与 orders.tenant_id 一致 - UserID int64 `gorm:"column:user_id;type:bigint;not null;comment:用户ID:下单用户(buyer);冗余字段用于查询加速与审计" json:"user_id"` // 用户ID:下单用户(buyer);冗余字段用于查询加速与审计 - OrderID int64 `gorm:"column:order_id;type:bigint;not null;comment:订单ID:关联 orders.id;用于聚合订单明细" json:"order_id"` // 订单ID:关联 orders.id;用于聚合订单明细 - ContentID int64 `gorm:"column:content_id;type:bigint;not null;comment:内容ID:关联 contents.id;用于生成/撤销 content_access" json:"content_id"` // 内容ID:关联 contents.id;用于生成/撤销 content_access - ContentUserID int64 `gorm:"column:content_user_id;type:bigint;not null;comment:内容作者用户ID:用于后续分成/对账扩展;当前可为 0 或写入内容创建者" json:"content_user_id"` // 内容作者用户ID:用于后续分成/对账扩展;当前可为 0 或写入内容创建者 - AmountPaid int64 `gorm:"column:amount_paid;type:bigint;not null;comment:该行实付金额:分;通常等于订单 amount_paid(单内容场景)" json:"amount_paid"` // 该行实付金额:分;通常等于订单 amount_paid(单内容场景) - Snapshot types.JSON `gorm:"column:snapshot;type:jsonb;not null;default:{};comment:内容快照:JSON;建议包含 title/price/discount 等,用于历史展示与审计" json:"snapshot"` // 内容快照:JSON;建议包含 title/price/discount 等,用于历史展示与审计 - CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now();comment:创建时间:默认 now()" json:"created_at"` // 创建时间:默认 now() - UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now();comment:更新时间:默认 now()" json:"updated_at"` // 更新时间:默认 now() - Order *Order `gorm:"foreignKey:OrderID;references:ID" json:"order,omitempty"` - Content *Content `gorm:"foreignKey:ContentID;references:ID" json:"content,omitempty"` + ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true;comment:主键ID:自增" json:"id"` // 主键ID:自增 + TenantID int64 `gorm:"column:tenant_id;type:bigint;not null;comment:租户ID:多租户隔离关键字段;必须与 orders.tenant_id 一致" json:"tenant_id"` // 租户ID:多租户隔离关键字段;必须与 orders.tenant_id 一致 + UserID int64 `gorm:"column:user_id;type:bigint;not null;comment:用户ID:下单用户(buyer);冗余字段用于查询加速与审计" json:"user_id"` // 用户ID:下单用户(buyer);冗余字段用于查询加速与审计 + OrderID int64 `gorm:"column:order_id;type:bigint;not null;comment:订单ID:关联 orders.id;用于聚合订单明细" json:"order_id"` // 订单ID:关联 orders.id;用于聚合订单明细 + ContentID int64 `gorm:"column:content_id;type:bigint;not null;comment:内容ID:关联 contents.id;用于生成/撤销 content_access" json:"content_id"` // 内容ID:关联 contents.id;用于生成/撤销 content_access + ContentUserID int64 `gorm:"column:content_user_id;type:bigint;not null;comment:内容作者用户ID:用于后续分成/对账扩展;当前可为 0 或写入内容创建者" json:"content_user_id"` // 内容作者用户ID:用于后续分成/对账扩展;当前可为 0 或写入内容创建者 + AmountPaid int64 `gorm:"column:amount_paid;type:bigint;not null;comment:该行实付金额:分;通常等于订单 amount_paid(单内容场景)" json:"amount_paid"` // 该行实付金额:分;通常等于订单 amount_paid(单内容场景) + Snapshot types.JSONType[fields.OrderItemsSnapshot] `gorm:"column:snapshot;type:jsonb;not null;default:{};comment:内容快照:JSON;建议包含 title/price/discount 等,用于历史展示与审计" json:"snapshot"` // 内容快照:JSON;建议包含 title/price/discount 等,用于历史展示与审计 + CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now();comment:创建时间:默认 now()" json:"created_at"` // 创建时间:默认 now() + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now();comment:更新时间:默认 now()" json:"updated_at"` // 更新时间:默认 now() + Order *Order `gorm:"foreignKey:OrderID;references:ID" json:"order,omitempty"` + Content *Content `gorm:"foreignKey:ContentID;references:ID" json:"content,omitempty"` } // Quick operations without importing query package diff --git a/backend/database/models/orders.gen.go b/backend/database/models/orders.gen.go index 0426559..235ff29 100644 --- a/backend/database/models/orders.gen.go +++ b/backend/database/models/orders.gen.go @@ -8,6 +8,7 @@ import ( "context" "time" + "quyun/v2/database/fields" "quyun/v2/pkg/consts" "go.ipao.vip/gen" @@ -18,25 +19,25 @@ const TableNameOrder = "orders" // Order mapped from table type Order struct { - ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true;comment:主键ID:自增;用于关联订单明细、账本流水、权益等" json:"id"` // 主键ID:自增;用于关联订单明细、账本流水、权益等 - TenantID int64 `gorm:"column:tenant_id;type:bigint;not null;comment:租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id" json:"tenant_id"` // 租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id - UserID int64 `gorm:"column:user_id;type:bigint;not null;comment:用户ID:下单用户(buyer);余额扣款与权益归属以该 user_id 为准" json:"user_id"` // 用户ID:下单用户(buyer);余额扣款与权益归属以该 user_id 为准 - Type consts.OrderType `gorm:"column:type;type:character varying(32);not null;default:content_purchase;comment:订单类型:content_purchase(购买内容)/topup(充值)等;当前默认 content_purchase" json:"type"` // 订单类型:content_purchase(购买内容)/topup(充值)等;当前默认 content_purchase - Status consts.OrderStatus `gorm:"column:status;type:character varying(32);not null;default:created;comment:订单状态:created/paid/refunding/refunded/canceled/failed;状态变更需与账本/权益保持一致" json:"status"` // 订单状态:created/paid/refunding/refunded/canceled/failed;状态变更需与账本/权益保持一致 - Currency consts.Currency `gorm:"column:currency;type:character varying(16);not null;default:CNY;comment:币种:当前固定 CNY;金额单位为分" json:"currency"` // 币种:当前固定 CNY;金额单位为分 - AmountOriginal int64 `gorm:"column:amount_original;type:bigint;not null;comment:原价金额:分;未折扣前金额(用于展示与对账)" json:"amount_original"` // 原价金额:分;未折扣前金额(用于展示与对账) - AmountDiscount int64 `gorm:"column:amount_discount;type:bigint;not null;comment:优惠金额:分;amount_paid = amount_original - amount_discount(下单时快照)" json:"amount_discount"` // 优惠金额:分;amount_paid = amount_original - amount_discount(下单时快照) - AmountPaid int64 `gorm:"column:amount_paid;type:bigint;not null;comment:实付金额:分;从租户内余额扣款的金额(下单时快照)" json:"amount_paid"` // 实付金额:分;从租户内余额扣款的金额(下单时快照) - Snapshot types.JSON `gorm:"column:snapshot;type:jsonb;not null;default:{};comment:订单快照:JSON;建议包含 content 标题/定价/折扣、请求来源等,避免改价影响历史展示" json:"snapshot"` // 订单快照:JSON;建议包含 content 标题/定价/折扣、请求来源等,避免改价影响历史展示 - IdempotencyKey string `gorm:"column:idempotency_key;type:character varying(128);not null;comment:幂等键:同一租户同一用户同一业务请求可用;用于防重复下单/重复扣款(建议由客户端生成)" json:"idempotency_key"` // 幂等键:同一租户同一用户同一业务请求可用;用于防重复下单/重复扣款(建议由客户端生成) - PaidAt time.Time `gorm:"column:paid_at;type:timestamp with time zone;comment:支付/扣款完成时间:余额支付在 debit_purchase 成功后写入" json:"paid_at"` // 支付/扣款完成时间:余额支付在 debit_purchase 成功后写入 - RefundedAt time.Time `gorm:"column:refunded_at;type:timestamp with time zone;comment:退款完成时间:退款落账成功后写入" json:"refunded_at"` // 退款完成时间:退款落账成功后写入 - RefundForced bool `gorm:"column:refund_forced;type:boolean;not null;comment:是否强制退款:true 表示租户管理侧绕过时间窗执行退款(需审计)" json:"refund_forced"` // 是否强制退款:true 表示租户管理侧绕过时间窗执行退款(需审计) - RefundOperatorUserID int64 `gorm:"column:refund_operator_user_id;type:bigint;comment:退款操作人用户ID:租户管理员/系统;用于审计与追责" json:"refund_operator_user_id"` // 退款操作人用户ID:租户管理员/系统;用于审计与追责 - RefundReason string `gorm:"column:refund_reason;type:character varying(255);not null;comment:退款原因:后台/用户发起退款的原因说明;用于审计" json:"refund_reason"` // 退款原因:后台/用户发起退款的原因说明;用于审计 - CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now();comment:创建时间:默认 now();用于审计与排序" json:"created_at"` // 创建时间:默认 now();用于审计与排序 - UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now();comment:更新时间:默认 now();状态变更/退款写入时更新" json:"updated_at"` // 更新时间:默认 now();状态变更/退款写入时更新 - Items []*OrderItem `gorm:"foreignKey:OrderID;references:ID" json:"items,omitempty"` + ID int64 `gorm:"column:id;type:bigint;primaryKey;autoIncrement:true;comment:主键ID:自增;用于关联订单明细、账本流水、权益等" json:"id"` // 主键ID:自增;用于关联订单明细、账本流水、权益等 + TenantID int64 `gorm:"column:tenant_id;type:bigint;not null;comment:租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id" json:"tenant_id"` // 租户ID:多租户隔离关键字段;所有查询/写入必须限定 tenant_id + UserID int64 `gorm:"column:user_id;type:bigint;not null;comment:用户ID:下单用户(buyer);余额扣款与权益归属以该 user_id 为准" json:"user_id"` // 用户ID:下单用户(buyer);余额扣款与权益归属以该 user_id 为准 + Type consts.OrderType `gorm:"column:type;type:character varying(32);not null;default:content_purchase;comment:订单类型:content_purchase(购买内容)/topup(充值)等;当前默认 content_purchase" json:"type"` // 订单类型:content_purchase(购买内容)/topup(充值)等;当前默认 content_purchase + Status consts.OrderStatus `gorm:"column:status;type:character varying(32);not null;default:created;comment:订单状态:created/paid/refunding/refunded/canceled/failed;状态变更需与账本/权益保持一致" json:"status"` // 订单状态:created/paid/refunding/refunded/canceled/failed;状态变更需与账本/权益保持一致 + Currency consts.Currency `gorm:"column:currency;type:character varying(16);not null;default:CNY;comment:币种:当前固定 CNY;金额单位为分" json:"currency"` // 币种:当前固定 CNY;金额单位为分 + AmountOriginal int64 `gorm:"column:amount_original;type:bigint;not null;comment:原价金额:分;未折扣前金额(用于展示与对账)" json:"amount_original"` // 原价金额:分;未折扣前金额(用于展示与对账) + AmountDiscount int64 `gorm:"column:amount_discount;type:bigint;not null;comment:优惠金额:分;amount_paid = amount_original - amount_discount(下单时快照)" json:"amount_discount"` // 优惠金额:分;amount_paid = amount_original - amount_discount(下单时快照) + AmountPaid int64 `gorm:"column:amount_paid;type:bigint;not null;comment:实付金额:分;从租户内余额扣款的金额(下单时快照)" json:"amount_paid"` // 实付金额:分;从租户内余额扣款的金额(下单时快照) + Snapshot types.JSONType[fields.OrdersSnapshot] `gorm:"column:snapshot;type:jsonb;not null;default:{};comment:订单快照:JSON;建议包含 content 标题/定价/折扣、请求来源等,避免改价影响历史展示" json:"snapshot"` // 订单快照:JSON;建议包含 content 标题/定价/折扣、请求来源等,避免改价影响历史展示 + IdempotencyKey string `gorm:"column:idempotency_key;type:character varying(128);not null;comment:幂等键:同一租户同一用户同一业务请求可用;用于防重复下单/重复扣款(建议由客户端生成)" json:"idempotency_key"` // 幂等键:同一租户同一用户同一业务请求可用;用于防重复下单/重复扣款(建议由客户端生成) + PaidAt time.Time `gorm:"column:paid_at;type:timestamp with time zone;comment:支付/扣款完成时间:余额支付在 debit_purchase 成功后写入" json:"paid_at"` // 支付/扣款完成时间:余额支付在 debit_purchase 成功后写入 + RefundedAt time.Time `gorm:"column:refunded_at;type:timestamp with time zone;comment:退款完成时间:退款落账成功后写入" json:"refunded_at"` // 退款完成时间:退款落账成功后写入 + RefundForced bool `gorm:"column:refund_forced;type:boolean;not null;comment:是否强制退款:true 表示租户管理侧绕过时间窗执行退款(需审计)" json:"refund_forced"` // 是否强制退款:true 表示租户管理侧绕过时间窗执行退款(需审计) + RefundOperatorUserID int64 `gorm:"column:refund_operator_user_id;type:bigint;comment:退款操作人用户ID:租户管理员/系统;用于审计与追责" json:"refund_operator_user_id"` // 退款操作人用户ID:租户管理员/系统;用于审计与追责 + RefundReason string `gorm:"column:refund_reason;type:character varying(255);not null;comment:退款原因:后台/用户发起退款的原因说明;用于审计" json:"refund_reason"` // 退款原因:后台/用户发起退款的原因说明;用于审计 + CreatedAt time.Time `gorm:"column:created_at;type:timestamp with time zone;not null;default:now();comment:创建时间:默认 now();用于审计与排序" json:"created_at"` // 创建时间:默认 now();用于审计与排序 + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp with time zone;not null;default:now();comment:更新时间:默认 now();状态变更/退款写入时更新" json:"updated_at"` // 更新时间:默认 now();状态变更/退款写入时更新 + Items []*OrderItem `gorm:"foreignKey:OrderID;references:ID" json:"items,omitempty"` } // Quick operations without importing query package