From ee1acae3ed6166b88c4bbfa0ad14c542284dc45d Mon Sep 17 00:00:00 2001 From: Rogee Date: Tue, 30 Dec 2025 14:54:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E6=8A=BD=E6=88=90=E3=80=81=E6=8F=90=E7=8E=B0=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E3=80=81=E5=BC=82=E6=AD=A5=E4=BB=BB=E5=8A=A1=E9=9B=86=E6=88=90?= =?UTF-8?q?=E5=8F=8A=E5=AE=89=E5=85=A8=E5=AE=A1=E8=AE=A1=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO.md | 14 ++ backend/app/http/v1/routes.gen.go | 5 + backend/app/http/v1/transaction.go | 24 +++ backend/app/http/v1/user.go | 1 + backend/app/jobs/args/media.go | 19 ++ backend/app/jobs/args/notification.go | 22 +++ backend/app/jobs/media_process_job.go | 39 ++++ backend/app/jobs/notification_job.go | 27 +++ backend/app/jobs/provider.gen.go | 30 +++ backend/app/services/audit.go | 24 +++ backend/app/services/content.go | 24 +-- backend/app/services/content_test.go | 21 +++ backend/app/services/creator.go | 216 +++++++++++++++++++++- backend/app/services/creator_test.go | 145 +++++++++++++++ backend/app/services/notification.go | 13 +- backend/app/services/notification_test.go | 8 +- backend/app/services/order.go | 87 ++++++--- backend/app/services/order_test.go | 88 ++++++++- backend/app/services/provider.gen.go | 18 +- backend/app/services/services.gen.go | 3 + backend/app/services/super.go | 94 ++++++++++ backend/app/services/super_test.go | 63 +++++++ backend/app/services/user.go | 32 +++- backend/pkg/consts/consts.gen.go | 20 +- backend/pkg/consts/consts.go | 8 +- 25 files changed, 985 insertions(+), 60 deletions(-) create mode 100644 TODO.md create mode 100644 backend/app/jobs/args/media.go create mode 100644 backend/app/jobs/args/notification.go create mode 100644 backend/app/jobs/media_process_job.go create mode 100644 backend/app/jobs/notification_job.go create mode 100644 backend/app/services/audit.go diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..4a735d9 --- /dev/null +++ b/TODO.md @@ -0,0 +1,14 @@ +# Unfinished Tasks (Phase 4) + +## 1. Automation & Engineering (Postgres Queue) +- [x] **Async Jobs (River)**: Integrated `river` for job queue system. + - [x] Refactored Job Args and Workers to match River interface. + - [x] Configured service injection for River Client. + - [x] Migrated notification dispatch to use River. + +## 2. Media & Infra +- [ ] **Real Storage**: Implement S3/MinIO provider for presigned URLs. +- [ ] **Media Pipeline**: Implement FFmpeg integration (mock call in job). + +## 3. Growth +- [ ] **Coupons**: Implement coupon logic. diff --git a/backend/app/http/v1/routes.gen.go b/backend/app/http/v1/routes.gen.go index 402ac87..23209df 100644 --- a/backend/app/http/v1/routes.gen.go +++ b/backend/app/http/v1/routes.gen.go @@ -201,6 +201,11 @@ func (r *Routes) Register(router fiber.Router) { PathParam[string]("id"), Body[dto.OrderPayForm]("form"), )) + r.log.Debugf("Registering route: Post /v1/webhook/payment/notify -> transaction.Webhook") + router.Post("/v1/webhook/payment/notify"[len(r.Path()):], DataFunc1( + r.transaction.Webhook, + Body[WebhookForm]("form"), + )) // Register routes for controller: User r.log.Debugf("Registering route: Delete /v1/me/favorites/:contentId -> user.RemoveFavorite") router.Delete("/v1/me/favorites/:contentId"[len(r.Path()):], Func1( diff --git a/backend/app/http/v1/transaction.go b/backend/app/http/v1/transaction.go index 6fffd7f..cb71a93 100644 --- a/backend/app/http/v1/transaction.go +++ b/backend/app/http/v1/transaction.go @@ -56,3 +56,27 @@ func (t *Transaction) Pay(ctx fiber.Ctx, id string, form *dto.OrderPayForm) (*dt func (t *Transaction) Status(ctx fiber.Ctx, id string) (*dto.OrderStatusResponse, error) { return services.Order.Status(ctx.Context(), id) } + +type WebhookForm struct { + OrderID string `json:"order_id"` + ExternalID string `json:"external_id"` +} + +// Payment Webhook +// +// @Router /v1/webhook/payment/notify [post] +// @Summary Payment Webhook +// @Description Payment Webhook +// @Tags Transaction +// @Accept json +// @Produce json +// @Param form body WebhookForm true "Webhook Data" +// @Success 200 {string} string "success" +// @Bind form body +func (t *Transaction) Webhook(ctx fiber.Ctx, form *WebhookForm) (string, error) { + err := services.Order.ProcessExternalPayment(ctx.Context(), form.OrderID, form.ExternalID) + if err != nil { + return "fail", err + } + return "success", nil +} diff --git a/backend/app/http/v1/user.go b/backend/app/http/v1/user.go index a8cb100..b306723 100644 --- a/backend/app/http/v1/user.go +++ b/backend/app/http/v1/user.go @@ -3,6 +3,7 @@ package v1 import ( "quyun/v2/app/http/v1/dto" auth_dto "quyun/v2/app/http/v1/dto" + "quyun/v2/app/requests" "quyun/v2/app/services" "github.com/gofiber/fiber/v3" diff --git a/backend/app/jobs/args/media.go b/backend/app/jobs/args/media.go new file mode 100644 index 0000000..653a4fb --- /dev/null +++ b/backend/app/jobs/args/media.go @@ -0,0 +1,19 @@ +package args + +import "github.com/riverqueue/river" + +type MediaProcessArgs struct { + AssetID int64 `json:"asset_id"` +} + +func (MediaProcessArgs) Kind() string { + return "media_process" +} + +func (MediaProcessArgs) InsertOpts() river.InsertOpts { + return river.InsertOpts{} +} + +func (MediaProcessArgs) UniqueID() string { + return "media_process" +} diff --git a/backend/app/jobs/args/notification.go b/backend/app/jobs/args/notification.go new file mode 100644 index 0000000..efcc377 --- /dev/null +++ b/backend/app/jobs/args/notification.go @@ -0,0 +1,22 @@ +package args + +import "github.com/riverqueue/river" + +type NotificationArgs struct { + UserID int64 `json:"user_id"` + Type string `json:"type"` + Title string `json:"title"` + Content string `json:"content"` +} + +func (NotificationArgs) Kind() string { + return "notification" +} + +func (NotificationArgs) InsertOpts() river.InsertOpts { + return river.InsertOpts{} +} + +func (NotificationArgs) UniqueID() string { + return "notification" +} diff --git a/backend/app/jobs/media_process_job.go b/backend/app/jobs/media_process_job.go new file mode 100644 index 0000000..24751c6 --- /dev/null +++ b/backend/app/jobs/media_process_job.go @@ -0,0 +1,39 @@ +package jobs + +import ( + "context" + "time" + + "github.com/riverqueue/river" + "quyun/v2/app/jobs/args" + "quyun/v2/database/models" + "quyun/v2/pkg/consts" +) + +// @provider(job) +type MediaProcessWorker struct { + river.WorkerDefaults[args.MediaProcessArgs] +} + +func (j *MediaProcessWorker) Work(ctx context.Context, job *river.Job[args.MediaProcessArgs]) error { + arg := job.Args + // 1. Fetch Asset + asset, err := models.MediaAssetQuery.WithContext(ctx).Where(models.MediaAssetQuery.ID.Eq(arg.AssetID)).First() + if err != nil { + return err + } + + // 2. Mock Processing + // Update status to processing + _, err = models.MediaAssetQuery.WithContext(ctx).Where(models.MediaAssetQuery.ID.Eq(asset.ID)).UpdateSimple(models.MediaAssetQuery.Status.Value(consts.MediaAssetStatusProcessing)) + if err != nil { + return err + } + + // 3. Update status to ready + _, err = models.MediaAssetQuery.WithContext(ctx).Where(models.MediaAssetQuery.ID.Eq(asset.ID)).Updates(&models.MediaAsset{ + Status: consts.MediaAssetStatusReady, + UpdatedAt: time.Now(), + }) + return err +} diff --git a/backend/app/jobs/notification_job.go b/backend/app/jobs/notification_job.go new file mode 100644 index 0000000..2b20e0d --- /dev/null +++ b/backend/app/jobs/notification_job.go @@ -0,0 +1,27 @@ +package jobs + +import ( + "context" + + "quyun/v2/app/jobs/args" + "quyun/v2/database/models" + + "github.com/riverqueue/river" +) + +// @provider(job) +type NotificationWorker struct { + river.WorkerDefaults[args.NotificationArgs] +} + +func (j *NotificationWorker) Work(ctx context.Context, job *river.Job[args.NotificationArgs]) error { + arg := job.Args + n := &models.Notification{ + UserID: arg.UserID, + Type: arg.Type, + Title: arg.Title, + Content: arg.Content, + IsRead: false, + } + return models.NotificationQuery.WithContext(ctx).Create(n) +} diff --git a/backend/app/jobs/provider.gen.go b/backend/app/jobs/provider.gen.go index 81103cd..21aab21 100755 --- a/backend/app/jobs/provider.gen.go +++ b/backend/app/jobs/provider.gen.go @@ -1,9 +1,39 @@ package jobs import ( + "quyun/v2/providers/job" + + "github.com/riverqueue/river" + "go.ipao.vip/atom" + "go.ipao.vip/atom/container" + "go.ipao.vip/atom/contracts" "go.ipao.vip/atom/opt" ) func Provide(opts ...opt.Option) error { + if err := container.Container.Provide(func( + __job *job.Job, + ) (contracts.Initial, error) { + obj := &MediaProcessWorker{} + if err := river.AddWorkerSafely(__job.Workers, obj); err != nil { + return nil, err + } + + return obj, nil + }, atom.GroupInitial); err != nil { + return err + } + if err := container.Container.Provide(func( + __job *job.Job, + ) (contracts.Initial, error) { + obj := &NotificationWorker{} + if err := river.AddWorkerSafely(__job.Workers, obj); err != nil { + return nil, err + } + + return obj, nil + }, atom.GroupInitial); err != nil { + return err + } return nil } diff --git a/backend/app/services/audit.go b/backend/app/services/audit.go new file mode 100644 index 0000000..8d24c04 --- /dev/null +++ b/backend/app/services/audit.go @@ -0,0 +1,24 @@ +package services + +import ( + "context" + + "quyun/v2/pkg/consts" + + "github.com/sirupsen/logrus" + "github.com/spf13/cast" +) + +// @provider +type audit struct{} + +func (s *audit) Log(ctx context.Context, action, targetID, detail string) { + operatorID := cast.ToInt64(ctx.Value(consts.CtxKeyUser)) + logrus.WithFields(logrus.Fields{ + "audit": true, + "operator": operatorID, + "action": action, + "target": targetID, + "detail": detail, + }).Info("Audit Log") +} diff --git a/backend/app/services/content.go b/backend/app/services/content.go index 9d4909f..5bd5653 100644 --- a/backend/app/services/content.go +++ b/backend/app/services/content.go @@ -24,7 +24,8 @@ func (s *content) List(ctx context.Context, filter *content_dto.ContentListFilte // Filters q = q.Where(tbl.Status.Eq(consts.ContentStatusPublished)) if filter.Keyword != nil && *filter.Keyword != "" { - q = q.Where(tbl.Title.Like("%" + *filter.Keyword + "%")) + keyword := "%" + *filter.Keyword + "%" + q = q.Where(tbl.Title.Like(keyword)).Or(tbl.Description.Like(keyword)) } if filter.Genre != nil && *filter.Genre != "" { q = q.Where(tbl.Genre.Eq(*filter.Genre)) @@ -63,7 +64,6 @@ func (s *content) List(ctx context.Context, filter *content_dto.ContentListFilte Preload("Author"). Preload("ContentAssets.Asset"). Find(&list).Error - if err != nil { return nil, errorx.ErrDatabaseError.WithCause(err) } @@ -83,6 +83,10 @@ func (s *content) List(ctx context.Context, filter *content_dto.ContentListFilte func (s *content) Get(ctx context.Context, id string) (*content_dto.ContentDetail, error) { cid := cast.ToInt64(id) + + // Increment Views + _, _ = models.ContentQuery.WithContext(ctx).Where(models.ContentQuery.ID.Eq(cid)).UpdateSimple(models.ContentQuery.Views.Add(1)) + _, q := models.ContentQuery.QueryContext(ctx) var item models.Content @@ -94,7 +98,6 @@ func (s *content) Get(ctx context.Context, id string) (*content_dto.ContentDetai Preload("ContentAssets.Asset"). Where("id = ?", cid). First(&item).Error - if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errorx.ErrRecordNotFound @@ -366,7 +369,6 @@ func (s *content) ListTopics(ctx context.Context) ([]content_dto.Topic, error) { Select("genre, count(*) as count"). Group("genre"). Scan(&results).Error - if err != nil { return nil, errorx.ErrDatabaseError.WithCause(err) } @@ -412,12 +414,12 @@ func (s *content) ListTopics(ctx context.Context) ([]content_dto.Topic, error) { func (s *content) toContentItemDTO(item *models.Content) content_dto.ContentItem { dto := content_dto.ContentItem{ - ID: cast.ToString(item.ID), - Title: item.Title, - Genre: item.Genre, - AuthorID: cast.ToString(item.UserID), - Views: int(item.Views), - Likes: int(item.Likes), + ID: cast.ToString(item.ID), + Title: item.Title, + Genre: item.Genre, + AuthorID: cast.ToString(item.UserID), + Views: int(item.Views), + Likes: int(item.Likes), } if item.Author != nil { dto.AuthorName = item.Author.Nickname @@ -592,4 +594,4 @@ func (s *content) getInteractList(ctx context.Context, typ string) ([]user_dto.C data = append(data, s.toContentItemDTO(item)) } return data, nil -} \ No newline at end of file +} diff --git a/backend/app/services/content_test.go b/backend/app/services/content_test.go index e94814a..bbc316f 100644 --- a/backend/app/services/content_test.go +++ b/backend/app/services/content_test.go @@ -356,3 +356,24 @@ func (s *ContentTestSuite) Test_PreviewLogic() { }) }) } + +func (s *ContentTestSuite) Test_ViewCounting() { + Convey("ViewCounting", s.T(), func() { + ctx := s.T().Context() + database.Truncate(ctx, s.DB, models.TableNameContent, models.TableNameUser) + + author := &models.User{Username: "author_v", Phone: "13900000009"} + models.UserQuery.WithContext(ctx).Create(author) + + c := &models.Content{TenantID: 1, UserID: author.ID, Title: "View Me", Views: 0, Status: consts.ContentStatusPublished} + models.ContentQuery.WithContext(ctx).Create(c) + + Convey("should increment views", func() { + _, err := Content.Get(ctx, cast.ToString(c.ID)) + So(err, ShouldBeNil) + + cReload, _ := models.ContentQuery.WithContext(ctx).Where(models.ContentQuery.ID.Eq(c.ID)).First() + So(cReload.Views, ShouldEqual, 1) + }) + }) +} diff --git a/backend/app/services/creator.go b/backend/app/services/creator.go index 28ce3cc..d1b4b56 100644 --- a/backend/app/services/creator.go +++ b/backend/app/services/creator.go @@ -7,6 +7,7 @@ import ( "quyun/v2/app/errorx" creator_dto "quyun/v2/app/http/v1/dto" + "quyun/v2/database/fields" "quyun/v2/database/models" "quyun/v2/pkg/consts" @@ -302,8 +303,103 @@ func (s *creator) ListOrders(ctx context.Context, filter *creator_dto.CreatorOrd } func (s *creator) ProcessRefund(ctx context.Context, id string, form *creator_dto.RefundForm) error { - // Complex logic involving ledgers and order status update - return nil + tid, err := s.getTenantID(ctx) + if err != nil { + return err + } + oid := cast.ToInt64(id) + uid := cast.ToInt64(ctx.Value(consts.CtxKeyUser)) // Creator ID + + // Fetch Order + o, err := models.OrderQuery.WithContext(ctx).Where(models.OrderQuery.ID.Eq(oid), models.OrderQuery.TenantID.Eq(tid)).First() + if err != nil { + return errorx.ErrRecordNotFound + } + + // Validate Status + // Allow refunding 'refunding' orders. Or 'paid' if we treat this as "Initiate Refund". + // Given "Action" (accept/reject), assume 'refunding'. + if o.Status != consts.OrderStatusRefunding { + return errorx.ErrStatusConflict.WithMsg("订单状态不是退款中") + } + + if form.Action == "reject" { + _, err := models.OrderQuery.WithContext(ctx).Where(models.OrderQuery.ID.Eq(oid)).Updates(&models.Order{ + Status: consts.OrderStatusPaid, + RefundReason: form.Reason, // Store reject reason? Or clear it? + }) + return err + } + + if form.Action == "accept" { + return models.Q.Transaction(func(tx *models.Query) error { + // 1. Deduct Creator Balance + // We credited Creator User Balance in Order.Pay. Now deduct it. + info, err := tx.User.WithContext(ctx). + Where(tx.User.ID.Eq(uid), tx.User.Balance.Gte(o.AmountPaid)). + Update(tx.User.Balance, gorm.Expr("balance - ?", o.AmountPaid)) + if err != nil { + return err + } + if info.RowsAffected == 0 { + return errorx.ErrQuotaExceeded.WithMsg("余额不足,无法退款") + } + + // 2. Credit Buyer Balance + _, err = tx.User.WithContext(ctx). + Where(tx.User.ID.Eq(o.UserID)). + Update(tx.User.Balance, gorm.Expr("balance + ?", o.AmountPaid)) + if err != nil { + return err + } + + // 3. Update Order Status + _, err = tx.Order.WithContext(ctx).Where(tx.Order.ID.Eq(oid)).Updates(&models.Order{ + Status: consts.OrderStatusRefunded, + RefundedAt: time.Now(), + RefundOperatorUserID: uid, + RefundReason: form.Reason, + }) + if err != nil { + return err + } + + // 4. Revoke Content Access + // Fetch order items to get content IDs + items, _ := tx.OrderItem.WithContext(ctx).Where(tx.OrderItem.OrderID.Eq(oid)).Find() + contentIDs := make([]int64, len(items)) + for i, item := range items { + contentIDs[i] = item.ContentID + } + if len(contentIDs) > 0 { + _, err = tx.ContentAccess.WithContext(ctx). + Where(tx.ContentAccess.UserID.Eq(o.UserID), tx.ContentAccess.ContentID.In(contentIDs...)). + UpdateSimple(tx.ContentAccess.Status.Value(consts.ContentAccessStatusRevoked)) + if err != nil { + return err + } + } + + // 5. Create Tenant Ledger + ledger := &models.TenantLedger{ + TenantID: tid, + UserID: uid, + OrderID: oid, + Type: consts.TenantLedgerTypeCreditRefund, + Amount: o.AmountPaid, + Remark: "退款: " + form.Reason, + OperatorUserID: uid, + IdempotencyKey: uuid.NewString(), + } + if err := tx.TenantLedger.WithContext(ctx).Create(ledger); err != nil { + return err + } + + return nil + }) + } + + return errorx.ErrBadRequest.WithMsg("无效的操作") } func (s *creator) GetSettings(ctx context.Context) (*creator_dto.Settings, error) { @@ -327,19 +423,131 @@ func (s *creator) UpdateSettings(ctx context.Context, form *creator_dto.Settings } func (s *creator) ListPayoutAccounts(ctx context.Context) ([]creator_dto.PayoutAccount, error) { - return []creator_dto.PayoutAccount{}, nil + tid, err := s.getTenantID(ctx) + if err != nil { + return nil, err + } + + list, err := models.PayoutAccountQuery.WithContext(ctx).Where(models.PayoutAccountQuery.TenantID.Eq(tid)).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + var data []creator_dto.PayoutAccount + for _, v := range list { + data = append(data, creator_dto.PayoutAccount{ + ID: cast.ToString(v.ID), + Type: v.Type, + Name: v.Name, + Account: v.Account, + Realname: v.Realname, + }) + } + return data, nil } func (s *creator) AddPayoutAccount(ctx context.Context, form *creator_dto.PayoutAccount) error { + tid, err := s.getTenantID(ctx) + if err != nil { + return err + } + uid := cast.ToInt64(ctx.Value(consts.CtxKeyUser)) + + pa := &models.PayoutAccount{ + TenantID: tid, + UserID: uid, + Type: form.Type, + Name: form.Name, + Account: form.Account, + Realname: form.Realname, + } + if err := models.PayoutAccountQuery.WithContext(ctx).Create(pa); err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } return nil } func (s *creator) RemovePayoutAccount(ctx context.Context, id string) error { + tid, err := s.getTenantID(ctx) + if err != nil { + return err + } + pid := cast.ToInt64(id) + + _, err = models.PayoutAccountQuery.WithContext(ctx). + Where(models.PayoutAccountQuery.ID.Eq(pid), models.PayoutAccountQuery.TenantID.Eq(tid)). + Delete() + if err != nil { + return errorx.ErrDatabaseError.WithCause(err) + } return nil } func (s *creator) Withdraw(ctx context.Context, form *creator_dto.WithdrawForm) error { - return nil + tid, err := s.getTenantID(ctx) + if err != nil { + return err + } + uid := cast.ToInt64(ctx.Value(consts.CtxKeyUser)) + + amount := int64(form.Amount * 100) + if amount <= 0 { + return errorx.ErrBadRequest.WithMsg("金额无效") + } + + // Validate Payout Account + _, err = models.PayoutAccountQuery.WithContext(ctx). + Where(models.PayoutAccountQuery.ID.Eq(cast.ToInt64(form.AccountID)), models.PayoutAccountQuery.TenantID.Eq(tid)). + First() + if err != nil { + return errorx.ErrRecordNotFound.WithMsg("收款账户不存在") + } + + return models.Q.Transaction(func(tx *models.Query) error { + // 1. Deduct Balance + info, err := tx.User.WithContext(ctx). + Where(tx.User.ID.Eq(uid), tx.User.Balance.Gte(amount)). + Update(tx.User.Balance, gorm.Expr("balance - ?", amount)) + if err != nil { + return err + } + if info.RowsAffected == 0 { + return errorx.ErrQuotaExceeded.WithMsg("余额不足") + } + + // 2. Create Order (Withdrawal) + order := &models.Order{ + TenantID: tid, + UserID: uid, + Type: consts.OrderTypeWithdrawal, + Status: consts.OrderStatusCreated, // Created = Pending Processing + Currency: consts.CurrencyCNY, + AmountOriginal: amount, + AmountPaid: amount, // Actually Amount Withdrawn + IdempotencyKey: uuid.NewString(), + Snapshot: types.NewJSONType(fields.OrdersSnapshot{}), // Can store account details here + } + if err := tx.Order.WithContext(ctx).Create(order); err != nil { + return err + } + + // 3. Create Tenant Ledger + ledger := &models.TenantLedger{ + TenantID: tid, + UserID: uid, + OrderID: order.ID, + Type: consts.TenantLedgerTypeCreditWithdrawal, + Amount: amount, + Remark: "提现申请", + OperatorUserID: uid, + IdempotencyKey: uuid.NewString(), + } + if err := tx.TenantLedger.WithContext(ctx).Create(ledger); err != nil { + return err + } + + return nil + }) } // Helpers diff --git a/backend/app/services/creator_test.go b/backend/app/services/creator_test.go index 5c1005a..7efcd2f 100644 --- a/backend/app/services/creator_test.go +++ b/backend/app/services/creator_test.go @@ -177,3 +177,148 @@ func (s *CreatorTestSuite) Test_Dashboard() { }) }) } + +func (s *CreatorTestSuite) Test_PayoutAccount() { + Convey("PayoutAccount", s.T(), func() { + ctx := s.T().Context() + database.Truncate(ctx, s.DB, models.TableNameTenant, models.TableNamePayoutAccount, models.TableNameUser) + + u := &models.User{Username: "creator5", Phone: "13700000005"} + models.UserQuery.WithContext(ctx).Create(u) + ctx = context.WithValue(ctx, consts.CtxKeyUser, u.ID) + + t := &models.Tenant{UserID: u.ID, Name: "Channel 5", Code: "126", Status: consts.TenantStatusVerified} + models.TenantQuery.WithContext(ctx).Create(t) + + Convey("should CRUD payout account", func() { + // Add + form := &creator_dto.PayoutAccount{ + Type: string(consts.PayoutAccountTypeAlipay), + Name: "Alipay", + Account: "user@example.com", + Realname: "John Doe", + } + err := Creator.AddPayoutAccount(ctx, form) + So(err, ShouldBeNil) + + // List + list, err := Creator.ListPayoutAccounts(ctx) + So(err, ShouldBeNil) + So(len(list), ShouldEqual, 1) + So(list[0].Account, ShouldEqual, "user@example.com") + + // Remove + err = Creator.RemovePayoutAccount(ctx, list[0].ID) + So(err, ShouldBeNil) + + // Verify Empty + list, err = Creator.ListPayoutAccounts(ctx) + So(err, ShouldBeNil) + So(len(list), ShouldEqual, 0) + }) + }) +} + +func (s *CreatorTestSuite) Test_Withdraw() { + Convey("Withdraw", s.T(), func() { + ctx := s.T().Context() + database.Truncate(ctx, s.DB, models.TableNameTenant, models.TableNamePayoutAccount, models.TableNameUser, models.TableNameOrder, models.TableNameTenantLedger) + + u := &models.User{Username: "creator6", Phone: "13700000006", Balance: 5000} // 50.00 + models.UserQuery.WithContext(ctx).Create(u) + ctx = context.WithValue(ctx, consts.CtxKeyUser, u.ID) + + t := &models.Tenant{UserID: u.ID, Name: "Channel 6", Code: "127", Status: consts.TenantStatusVerified} + models.TenantQuery.WithContext(ctx).Create(t) + + pa := &models.PayoutAccount{TenantID: t.ID, UserID: u.ID, Type: "bank", Name: "Bank", Account: "123", Realname: "Creator"} + models.PayoutAccountQuery.WithContext(ctx).Create(pa) + + Convey("should withdraw successfully", func() { + form := &creator_dto.WithdrawForm{ + Amount: 20.00, + AccountID: cast.ToString(pa.ID), + } + err := Creator.Withdraw(ctx, form) + So(err, ShouldBeNil) + + // Verify Balance Deducted + uReload, _ := models.UserQuery.WithContext(ctx).Where(models.UserQuery.ID.Eq(u.ID)).First() + So(uReload.Balance, ShouldEqual, 3000) + + // Verify Order Created + o, _ := models.OrderQuery.WithContext(ctx).Where(models.OrderQuery.TenantID.Eq(t.ID), models.OrderQuery.Type.Eq(consts.OrderTypeWithdrawal)).First() + So(o, ShouldNotBeNil) + So(o.AmountPaid, ShouldEqual, 2000) + + // Verify Ledger + l, _ := models.TenantLedgerQuery.WithContext(ctx).Where(models.TenantLedgerQuery.OrderID.Eq(o.ID)).First() + So(l, ShouldNotBeNil) + So(l.Type, ShouldEqual, consts.TenantLedgerTypeCreditWithdrawal) + }) + + Convey("should fail if insufficient balance", func() { + form := &creator_dto.WithdrawForm{ + Amount: 100.00, + AccountID: cast.ToString(pa.ID), + } + err := Creator.Withdraw(ctx, form) + So(err, ShouldNotBeNil) + }) + }) +} + +func (s *CreatorTestSuite) Test_Refund() { + Convey("Refund", s.T(), func() { + ctx := s.T().Context() + database.Truncate(ctx, s.DB, + models.TableNameTenant, models.TableNameUser, models.TableNameOrder, + models.TableNameOrderItem, models.TableNameContentAccess, models.TableNameTenantLedger, + ) + + // Creator + creator := &models.User{Username: "creator7", Phone: "13700000007", Balance: 5000} // Has funds + models.UserQuery.WithContext(ctx).Create(creator) + creatorCtx := context.WithValue(ctx, consts.CtxKeyUser, creator.ID) + + // Tenant + t := &models.Tenant{UserID: creator.ID, Name: "Channel 7", Code: "128", Status: consts.TenantStatusVerified} + models.TenantQuery.WithContext(ctx).Create(t) + + // Buyer + buyer := &models.User{Username: "buyer7", Phone: "13900000007", Balance: 0} + models.UserQuery.WithContext(ctx).Create(buyer) + + // Order (Paid -> Refunding) + o := &models.Order{ + TenantID: t.ID, + UserID: buyer.ID, + AmountPaid: 1000, // 10.00 + Status: consts.OrderStatusRefunding, + } + models.OrderQuery.WithContext(ctx).Create(o) + models.OrderItemQuery.WithContext(ctx).Create(&models.OrderItem{OrderID: o.ID, ContentID: 100}) // Fake content + models.ContentAccessQuery.WithContext(ctx).Create(&models.ContentAccess{UserID: buyer.ID, ContentID: 100, Status: consts.ContentAccessStatusActive}) + + Convey("should accept refund", func() { + form := &creator_dto.RefundForm{Action: "accept", Reason: "Defective"} + err := Creator.ProcessRefund(creatorCtx, cast.ToString(o.ID), form) + So(err, ShouldBeNil) + + // Verify Order + oReload, _ := models.OrderQuery.WithContext(ctx).Where(models.OrderQuery.ID.Eq(o.ID)).First() + So(oReload.Status, ShouldEqual, consts.OrderStatusRefunded) + + // Verify Balances + cReload, _ := models.UserQuery.WithContext(ctx).Where(models.UserQuery.ID.Eq(creator.ID)).First() + So(cReload.Balance, ShouldEqual, 4000) // 5000 - 1000 + + bReload, _ := models.UserQuery.WithContext(ctx).Where(models.UserQuery.ID.Eq(buyer.ID)).First() + So(bReload.Balance, ShouldEqual, 1000) // 0 + 1000 + + // Verify Access + acc, _ := models.ContentAccessQuery.WithContext(ctx).Where(models.ContentAccessQuery.UserID.Eq(buyer.ID)).First() + So(acc.Status, ShouldEqual, consts.ContentAccessStatusRevoked) + }) + }) +} diff --git a/backend/app/services/notification.go b/backend/app/services/notification.go index 586b212..6ecb30c 100644 --- a/backend/app/services/notification.go +++ b/backend/app/services/notification.go @@ -6,15 +6,19 @@ import ( "quyun/v2/app/errorx" user_dto "quyun/v2/app/http/v1/dto" + "quyun/v2/app/jobs/args" "quyun/v2/app/requests" "quyun/v2/database/models" "quyun/v2/pkg/consts" + "quyun/v2/providers/job" "github.com/spf13/cast" ) // @provider -type notification struct{} +type notification struct { + job *job.Job +} func (s *notification) List(ctx context.Context, page int, typeArg string) (*requests.Pager, error) { userID := ctx.Value(consts.CtxKeyUser) @@ -79,13 +83,12 @@ func (s *notification) MarkRead(ctx context.Context, id string) error { return nil } -func (s *notification) Send(ctx context.Context, userID int64, typ string, title, content string) error { - n := &models.Notification{ +func (s *notification) Send(ctx context.Context, userID int64, typ, title, content string) error { + arg := args.NotificationArgs{ UserID: userID, Type: typ, Title: title, Content: content, - IsRead: false, } - return models.NotificationQuery.WithContext(ctx).Create(n) + return s.job.Add(arg) } diff --git a/backend/app/services/notification_test.go b/backend/app/services/notification_test.go index d2c83a7..7bc9e32 100644 --- a/backend/app/services/notification_test.go +++ b/backend/app/services/notification_test.go @@ -50,14 +50,14 @@ func (s *NotificationTestSuite) Test_CRUD() { err := Notification.Send(ctx, uID, "system", "Welcome", "Hello World") So(err, ShouldBeNil) - list, err := Notification.List(ctx, 1) + list, err := Notification.List(ctx, 1, "") So(err, ShouldBeNil) So(list.Total, ShouldEqual, 1) - + items := list.Items.([]app_dto.Notification) So(len(items), ShouldEqual, 1) So(items[0].Title, ShouldEqual, "Welcome") - + // Mark Read // Need ID n, _ := models.NotificationQuery.WithContext(ctx).Where(models.NotificationQuery.UserID.Eq(uID)).First() @@ -68,4 +68,4 @@ func (s *NotificationTestSuite) Test_CRUD() { So(nReload.IsRead, ShouldBeTrue) }) }) -} \ No newline at end of file +} diff --git a/backend/app/services/order.go b/backend/app/services/order.go index 8746677..5c5dadf 100644 --- a/backend/app/services/order.go +++ b/backend/app/services/order.go @@ -153,30 +153,64 @@ func (s *order) Pay(ctx context.Context, id string, form *transaction_dto.OrderP return s.payWithBalance(ctx, o) } + // External payment (mock) - normally returns URL/params return &transaction_dto.OrderPayResponse{ PayParams: "mock_pay_params", }, nil } +// ProcessExternalPayment handles callback from payment gateway +func (s *order) ProcessExternalPayment(ctx context.Context, orderID, externalID string) error { + oid := cast.ToInt64(orderID) + o, err := models.OrderQuery.WithContext(ctx).Where(models.OrderQuery.ID.Eq(oid)).First() + if err != nil { + return errorx.ErrRecordNotFound + } + if o.Status != consts.OrderStatusCreated { + return nil // Already processed idempotency + } + + return s.settleOrder(ctx, o, "external", externalID) +} + func (s *order) payWithBalance(ctx context.Context, o *models.Order) (*transaction_dto.OrderPayResponse, error) { + err := s.settleOrder(ctx, o, "balance", "") + if err != nil { + if _, ok := err.(*errorx.AppError); ok { + return nil, err + } + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + return &transaction_dto.OrderPayResponse{ + PayParams: "balance_paid", + }, nil +} + +func (s *order) settleOrder(ctx context.Context, o *models.Order, method, externalID string) error { var tenantOwnerID int64 err := models.Q.Transaction(func(tx *models.Query) error { - // 1. Deduct User Balance - info, err := tx.User.WithContext(ctx). - Where(tx.User.ID.Eq(o.UserID), tx.User.Balance.Gte(o.AmountPaid)). - Update(tx.User.Balance, gorm.Expr("balance - ?", o.AmountPaid)) - if err != nil { - return err - } - if info.RowsAffected == 0 { - return errorx.ErrQuotaExceeded.WithMsg("余额不足") + // 1. Deduct User Balance (Only for balance method) + if method == "balance" { + info, err := tx.User.WithContext(ctx). + Where(tx.User.ID.Eq(o.UserID), tx.User.Balance.Gte(o.AmountPaid)). + Update(tx.User.Balance, gorm.Expr("balance - ?", o.AmountPaid)) + if err != nil { + return err + } + if info.RowsAffected == 0 { + return errorx.ErrQuotaExceeded.WithMsg("余额不足") + } } // 2. Update Order Status now := time.Now() - _, err = tx.Order.WithContext(ctx).Where(tx.Order.ID.Eq(o.ID)).Updates(&models.Order{ - Status: consts.OrderStatusPaid, - PaidAt: now, + // snapshot := o.Snapshot // Preserve existing snapshot or update it with external ID + // TODO: Update snapshot with payment info + _, err := tx.Order.WithContext(ctx).Where(tx.Order.ID.Eq(o.ID)).Updates(&models.Order{ + Status: consts.OrderStatusPaid, + PaidAt: now, + UpdatedAt: now, }) if err != nil { return err @@ -210,18 +244,31 @@ func (s *order) payWithBalance(ctx context.Context, o *models.Order) (*transacti } tenantOwnerID = t.UserID + // Calculate Commission + amount := o.AmountPaid + fee := int64(float64(amount) * 0.10) + creatorIncome := amount - fee + + // Credit Tenant Owner Balance (Net Income) + _, err = tx.User.WithContext(ctx). + Where(tx.User.ID.Eq(tenantOwnerID)). + Update(tx.User.Balance, gorm.Expr("balance + ?", creatorIncome)) + if err != nil { + return err + } + ledger := &models.TenantLedger{ TenantID: o.TenantID, UserID: t.UserID, // Owner OrderID: o.ID, Type: consts.TenantLedgerTypeDebitPurchase, // Income from purchase - Amount: o.AmountPaid, - BalanceBefore: 0, // TODO: Fetch previous balance if tracking tenant balance + Amount: creatorIncome, + BalanceBefore: 0, // TODO BalanceAfter: 0, // TODO FrozenBefore: 0, FrozenAfter: 0, IdempotencyKey: uuid.NewString(), - Remark: "内容销售收入", + Remark: "内容销售收入 (扣除平台费)", OperatorUserID: o.UserID, } if err := tx.TenantLedger.WithContext(ctx).Create(ledger); err != nil { @@ -231,10 +278,7 @@ func (s *order) payWithBalance(ctx context.Context, o *models.Order) (*transacti return nil }) if err != nil { - if _, ok := err.(*errorx.AppError); ok { - return nil, err - } - return nil, errorx.ErrDatabaseError.WithCause(err) + return err } if Notification != nil { @@ -243,10 +287,7 @@ func (s *order) payWithBalance(ctx context.Context, o *models.Order) (*transacti _ = Notification.Send(ctx, tenantOwnerID, "order", "新的订单", "您的店铺有新的订单,收入已入账。") } } - - return &transaction_dto.OrderPayResponse{ - PayParams: "balance_paid", - }, nil + return nil } func (s *order) Status(ctx context.Context, id string) (*transaction_dto.OrderStatusResponse, error) { diff --git a/backend/app/services/order_test.go b/backend/app/services/order_test.go index 591c1af..d067b1e 100644 --- a/backend/app/services/order_test.go +++ b/backend/app/services/order_test.go @@ -104,7 +104,7 @@ func (s *OrderTestSuite) Test_PurchaseFlow() { l, _ := models.TenantLedgerQuery.WithContext(ctx).Where(models.TenantLedgerQuery.OrderID.Eq(o.ID)).First() So(l, ShouldNotBeNil) So(l.UserID, ShouldEqual, creator.ID) - So(l.Amount, ShouldEqual, 1000) + So(l.Amount, ShouldEqual, 900) So(l.Type, ShouldEqual, consts.TenantLedgerTypeDebitPurchase) }) @@ -169,3 +169,89 @@ func (s *OrderTestSuite) Test_OrderDetails() { }) }) } + +func (s *OrderTestSuite) Test_PlatformCommission() { + Convey("Platform Commission", s.T(), func() { + ctx := s.T().Context() + database.Truncate(ctx, s.DB, models.TableNameUser, models.TableNameOrder, models.TableNameOrderItem, models.TableNameTenant, models.TableNameTenantLedger, models.TableNameContentAccess) + + // Creator + creator := &models.User{Username: "creator_c", Balance: 0} + models.UserQuery.WithContext(ctx).Create(creator) + // Tenant + t := &models.Tenant{UserID: creator.ID, Name: "Shop C", Status: consts.TenantStatusVerified} + models.TenantQuery.WithContext(ctx).Create(t) + // Buyer + buyer := &models.User{Username: "buyer_c", Balance: 2000} + models.UserQuery.WithContext(ctx).Create(buyer) + buyerCtx := context.WithValue(ctx, consts.CtxKeyUser, buyer.ID) + + // Order (10.00 CNY = 1000) + o := &models.Order{ + TenantID: t.ID, + UserID: buyer.ID, + AmountPaid: 1000, + Status: consts.OrderStatusCreated, + } + models.OrderQuery.WithContext(ctx).Create(o) + models.OrderItemQuery.WithContext(ctx).Create(&models.OrderItem{OrderID: o.ID, ContentID: 999}) // Fake content + + Convey("should deduct 10% fee", func() { + payForm := &order_dto.OrderPayForm{Method: "balance"} + _, err := Order.Pay(buyerCtx, cast.ToString(o.ID), payForm) + So(err, ShouldBeNil) + + // Verify Creator Balance (1000 - 10% = 900) + cReload, _ := models.UserQuery.WithContext(ctx).Where(models.UserQuery.ID.Eq(creator.ID)).First() + So(cReload.Balance, ShouldEqual, 900) + + // Verify Ledger + l, _ := models.TenantLedgerQuery.WithContext(ctx).Where(models.TenantLedgerQuery.OrderID.Eq(o.ID)).First() + So(l.Amount, ShouldEqual, 900) + }) + }) +} + +func (s *OrderTestSuite) Test_ExternalPayment() { + Convey("External Payment", s.T(), func() { + ctx := s.T().Context() + database.Truncate(ctx, s.DB, models.TableNameUser, models.TableNameOrder, models.TableNameOrderItem, models.TableNameTenant, models.TableNameTenantLedger, models.TableNameContentAccess) + + // Creator + creator := &models.User{Username: "creator_ext", Balance: 0} + models.UserQuery.WithContext(ctx).Create(creator) + // Tenant + t := &models.Tenant{UserID: creator.ID, Name: "Shop Ext", Status: consts.TenantStatusVerified} + models.TenantQuery.WithContext(ctx).Create(t) + // Buyer (Balance 0) + buyer := &models.User{Username: "buyer_ext", Balance: 0} + models.UserQuery.WithContext(ctx).Create(buyer) + + // Order + o := &models.Order{ + TenantID: t.ID, + UserID: buyer.ID, + AmountPaid: 1000, + Status: consts.OrderStatusCreated, + } + models.OrderQuery.WithContext(ctx).Create(o) + models.OrderItemQuery.WithContext(ctx).Create(&models.OrderItem{OrderID: o.ID, ContentID: 999}) + + Convey("should process external payment callback", func() { + err := Order.ProcessExternalPayment(ctx, cast.ToString(o.ID), "ext_tx_id_123") + So(err, ShouldBeNil) + + // Verify Status + oReload, _ := models.OrderQuery.WithContext(ctx).Where(models.OrderQuery.ID.Eq(o.ID)).First() + So(oReload.Status, ShouldEqual, consts.OrderStatusPaid) + + // Verify Creator Credited + cReload, _ := models.UserQuery.WithContext(ctx).Where(models.UserQuery.ID.Eq(creator.ID)).First() + So(cReload.Balance, ShouldEqual, 900) // 1000 - 10% + + // Verify Buyer Balance (Should NOT be deducted) + bReload, _ := models.UserQuery.WithContext(ctx).Where(models.UserQuery.ID.Eq(buyer.ID)).First() + So(bReload.Balance, ShouldEqual, 0) + }) + }) +} diff --git a/backend/app/services/provider.gen.go b/backend/app/services/provider.gen.go index f43e5d8..8bd735d 100755 --- a/backend/app/services/provider.gen.go +++ b/backend/app/services/provider.gen.go @@ -1,6 +1,7 @@ package services import ( + "quyun/v2/providers/job" "quyun/v2/providers/jwt" "go.ipao.vip/atom" @@ -11,6 +12,13 @@ import ( ) func Provide(opts ...opt.Option) error { + if err := container.Container.Provide(func() (*audit, error) { + obj := &audit{} + + return obj, nil + }); err != nil { + return err + } if err := container.Container.Provide(func() (*common, error) { obj := &common{} @@ -32,8 +40,12 @@ func Provide(opts ...opt.Option) error { }); err != nil { return err } - if err := container.Container.Provide(func() (*notification, error) { - obj := ¬ification{} + if err := container.Container.Provide(func( + job *job.Job, + ) (*notification, error) { + obj := ¬ification{ + job: job, + } return obj, nil }); err != nil { @@ -47,6 +59,7 @@ func Provide(opts ...opt.Option) error { return err } if err := container.Container.Provide(func( + audit *audit, common *common, content *content, creator *creator, @@ -59,6 +72,7 @@ func Provide(opts ...opt.Option) error { wallet *wallet, ) (contracts.Initial, error) { obj := &services{ + audit: audit, common: common, content: content, creator: creator, diff --git a/backend/app/services/services.gen.go b/backend/app/services/services.gen.go index e780c7c..92533aa 100644 --- a/backend/app/services/services.gen.go +++ b/backend/app/services/services.gen.go @@ -8,6 +8,7 @@ var _db *gorm.DB // exported CamelCase Services var ( + Audit *audit Common *common Content *content Creator *creator @@ -23,6 +24,7 @@ var ( type services struct { db *gorm.DB // define Services + audit *audit common *common content *content creator *creator @@ -38,6 +40,7 @@ func (svc *services) Prepare() error { _db = svc.db // set exported Services here + Audit = svc.audit Common = svc.common Content = svc.content Creator = svc.creator diff --git a/backend/app/services/super.go b/backend/app/services/super.go index 0952b2c..97d590d 100644 --- a/backend/app/services/super.go +++ b/backend/app/services/super.go @@ -291,3 +291,97 @@ func (s *super) UserStatuses(ctx context.Context) ([]requests.KV, error) { func (s *super) TenantStatuses(ctx context.Context) ([]requests.KV, error) { return consts.TenantStatusItems(), nil } + +func (s *super) ListWithdrawals(ctx context.Context, filter *super_dto.SuperOrderListFilter) (*requests.Pager, error) { + tbl, q := models.OrderQuery.QueryContext(ctx) + q = q.Where(tbl.Type.Eq(consts.OrderTypeWithdrawal)) + + filter.Pagination.Format() + total, err := q.Count() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + list, err := q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)).Order(tbl.ID.Desc()).Find() + if err != nil { + return nil, errorx.ErrDatabaseError.WithCause(err) + } + + // TODO: Map to SuperOrderItem properly with Tenant/User lookup + return &requests.Pager{ + Pagination: filter.Pagination, + Total: total, + Items: list, + }, nil +} + +func (s *super) ApproveWithdrawal(ctx context.Context, id int64) error { + o, err := models.OrderQuery.WithContext(ctx).Where(models.OrderQuery.ID.Eq(id)).First() + if err != nil { + return errorx.ErrRecordNotFound + } + if o.Status != consts.OrderStatusCreated { + return errorx.ErrStatusConflict.WithMsg("订单状态不正确") + } + + // Mark as Paid (Assumes external transfer done) + _, err = models.OrderQuery.WithContext(ctx).Where(models.OrderQuery.ID.Eq(id)).Updates(&models.Order{ + Status: consts.OrderStatusPaid, + PaidAt: time.Now(), + UpdatedAt: time.Now(), + }) + if err == nil && Audit != nil { + Audit.Log(ctx, "approve_withdrawal", cast.ToString(id), "Approved withdrawal") + } + return err +} + +func (s *super) RejectWithdrawal(ctx context.Context, id int64, reason string) error { + err := models.Q.Transaction(func(tx *models.Query) error { + o, err := tx.Order.WithContext(ctx).Where(tx.Order.ID.Eq(id)).First() + if err != nil { + return errorx.ErrRecordNotFound + } + if o.Status != consts.OrderStatusCreated { + return errorx.ErrStatusConflict.WithMsg("订单状态不正确") + } + + // Refund User Balance + _, err = tx.User.WithContext(ctx).Where(tx.User.ID.Eq(o.UserID)).Update(tx.User.Balance, gorm.Expr("balance + ?", o.AmountPaid)) + if err != nil { + return err + } + + // Update Order + _, err = tx.Order.WithContext(ctx).Where(tx.Order.ID.Eq(id)).Updates(&models.Order{ + Status: consts.OrderStatusFailed, // or Canceled + RefundReason: reason, + UpdatedAt: time.Now(), + }) + if err != nil { + return err + } + + // Create Ledger (Adjustment/Unfreeze) + ledger := &models.TenantLedger{ + TenantID: o.TenantID, + UserID: o.UserID, + OrderID: o.ID, + Type: consts.TenantLedgerTypeAdjustment, + Amount: o.AmountPaid, + Remark: "提现拒绝返还: " + reason, + OperatorUserID: 0, // System/Admin + IdempotencyKey: uuid.NewString(), + } + if err := tx.TenantLedger.WithContext(ctx).Create(ledger); err != nil { + return err + } + + return nil + }) + + if err == nil && Audit != nil { + Audit.Log(ctx, "reject_withdrawal", cast.ToString(id), "Rejected: "+reason) + } + return err +} diff --git a/backend/app/services/super_test.go b/backend/app/services/super_test.go index bba20ab..dad2047 100644 --- a/backend/app/services/super_test.go +++ b/backend/app/services/super_test.go @@ -98,3 +98,66 @@ func (s *SuperTestSuite) Test_CreateTenant() { }) }) } + +func (s *SuperTestSuite) Test_WithdrawalApproval() { + Convey("Withdrawal Approval", s.T(), func() { + ctx := s.T().Context() + database.Truncate(ctx, s.DB, models.TableNameOrder, models.TableNameUser, models.TableNameTenantLedger) + + u := &models.User{Username: "user_w", Balance: 1000} // Initial 10.00 + models.UserQuery.WithContext(ctx).Create(u) + + // Create Withdrawal Order (Pending) + o1 := &models.Order{ + UserID: u.ID, + Type: consts.OrderTypeWithdrawal, + Status: consts.OrderStatusCreated, + AmountPaid: 500, + } + models.OrderQuery.WithContext(ctx).Create(o1) + + Convey("should list withdrawals", func() { + filter := &super_dto.SuperOrderListFilter{Pagination: requests.Pagination{Page: 1, Limit: 10}} + res, err := Super.ListWithdrawals(ctx, filter) + So(err, ShouldBeNil) + So(res.Total, ShouldEqual, 1) + }) + + Convey("should approve withdrawal", func() { + err := Super.ApproveWithdrawal(ctx, o1.ID) + So(err, ShouldBeNil) + + oReload, _ := models.OrderQuery.WithContext(ctx).Where(models.OrderQuery.ID.Eq(o1.ID)).First() + So(oReload.Status, ShouldEqual, consts.OrderStatusPaid) + }) + + Convey("should reject withdrawal and refund", func() { + // Another order + o2 := &models.Order{ + UserID: u.ID, + Type: consts.OrderTypeWithdrawal, + Status: consts.OrderStatusCreated, + AmountPaid: 200, + } + models.OrderQuery.WithContext(ctx).Create(o2) + + // Assuming user balance was deducted when o2 was created (logic in creator service) + // But here we set balance manually to 1000. Let's assume it was 1200 before. + // Current balance 1000. Refund 200 -> Expect 1200. + + err := Super.RejectWithdrawal(ctx, o2.ID, "Invalid account") + So(err, ShouldBeNil) + + oReload, _ := models.OrderQuery.WithContext(ctx).Where(models.OrderQuery.ID.Eq(o2.ID)).First() + So(oReload.Status, ShouldEqual, consts.OrderStatusFailed) + + uReload, _ := models.UserQuery.WithContext(ctx).Where(models.UserQuery.ID.Eq(u.ID)).First() + So(uReload.Balance, ShouldEqual, 1200) + + // Check Ledger + l, _ := models.TenantLedgerQuery.WithContext(ctx).Where(models.TenantLedgerQuery.OrderID.Eq(o2.ID)).First() + So(l, ShouldNotBeNil) + So(l.Type, ShouldEqual, consts.TenantLedgerTypeAdjustment) + }) + }) +} diff --git a/backend/app/services/user.go b/backend/app/services/user.go index c42098d..9e213e8 100644 --- a/backend/app/services/user.go +++ b/backend/app/services/user.go @@ -2,6 +2,7 @@ package services import ( "context" + "encoding/json" "errors" "time" @@ -131,12 +132,37 @@ func (s *user) RealName(ctx context.Context, form *user_dto.RealNameForm) error } uid := cast.ToInt64(userID) - // TODO: 调用实名认证接口校验 + // Mock Verification + if len(form.IDCard) != 18 { + return errorx.ErrBadRequest.WithMsg("身份证号格式错误") + } + if form.Realname == "" { + return errorx.ErrBadRequest.WithMsg("真实姓名不能为空") + } tbl, query := models.UserQuery.QueryContext(ctx) - _, err := query.Where(tbl.ID.Eq(uid)).Updates(&models.User{ + u, err := query.Where(tbl.ID.Eq(uid)).First() + if err != nil { + return errorx.ErrRecordNotFound + } + + var metaMap map[string]interface{} + if len(u.Metas) > 0 { + _ = json.Unmarshal(u.Metas, &metaMap) + } + if metaMap == nil { + metaMap = make(map[string]interface{}) + } + // Mock encryption + metaMap["real_name"] = form.Realname + metaMap["id_card"] = "ENC:" + form.IDCard + + b, _ := json.Marshal(metaMap) + + _, err = query.Where(tbl.ID.Eq(uid)).Updates(&models.User{ IsRealNameVerified: true, - // RealName: form.Realname, // 需在 user 表添加字段? payout_accounts 有 realname + VerifiedAt: time.Now(), + Metas: types.JSON(b), }) if err != nil { return errorx.ErrDatabaseError.WithCause(err) diff --git a/backend/pkg/consts/consts.gen.go b/backend/pkg/consts/consts.gen.go index 0559e39..2b7d3a8 100644 --- a/backend/pkg/consts/consts.gen.go +++ b/backend/pkg/consts/consts.gen.go @@ -1688,6 +1688,8 @@ const ( OrderTypeContentPurchase OrderType = "content_purchase" // OrderTypeRecharge is a OrderType of type recharge. OrderTypeRecharge OrderType = "recharge" + // OrderTypeWithdrawal is a OrderType of type withdrawal. + OrderTypeWithdrawal OrderType = "withdrawal" ) var ErrInvalidOrderType = fmt.Errorf("not a valid OrderType, try [%s]", strings.Join(_OrderTypeNames, ", ")) @@ -1695,6 +1697,7 @@ var ErrInvalidOrderType = fmt.Errorf("not a valid OrderType, try [%s]", strings. var _OrderTypeNames = []string{ string(OrderTypeContentPurchase), string(OrderTypeRecharge), + string(OrderTypeWithdrawal), } // OrderTypeNames returns a list of possible string values of OrderType. @@ -1709,6 +1712,7 @@ func OrderTypeValues() []OrderType { return []OrderType{ OrderTypeContentPurchase, OrderTypeRecharge, + OrderTypeWithdrawal, } } @@ -1727,6 +1731,7 @@ func (x OrderType) IsValid() bool { var _OrderTypeValue = map[string]OrderType{ "content_purchase": OrderTypeContentPurchase, "recharge": OrderTypeRecharge, + "withdrawal": OrderTypeWithdrawal, } // ParseOrderType attempts to convert a string to a OrderType. @@ -2008,6 +2013,8 @@ const ( TenantLedgerTypeDebitPurchase TenantLedgerType = "debit_purchase" // TenantLedgerTypeCreditRefund is a TenantLedgerType of type credit_refund. TenantLedgerTypeCreditRefund TenantLedgerType = "credit_refund" + // TenantLedgerTypeCreditWithdrawal is a TenantLedgerType of type credit_withdrawal. + TenantLedgerTypeCreditWithdrawal TenantLedgerType = "credit_withdrawal" // TenantLedgerTypeFreeze is a TenantLedgerType of type freeze. TenantLedgerTypeFreeze TenantLedgerType = "freeze" // TenantLedgerTypeUnfreeze is a TenantLedgerType of type unfreeze. @@ -2021,6 +2028,7 @@ var ErrInvalidTenantLedgerType = fmt.Errorf("not a valid TenantLedgerType, try [ var _TenantLedgerTypeNames = []string{ string(TenantLedgerTypeDebitPurchase), string(TenantLedgerTypeCreditRefund), + string(TenantLedgerTypeCreditWithdrawal), string(TenantLedgerTypeFreeze), string(TenantLedgerTypeUnfreeze), string(TenantLedgerTypeAdjustment), @@ -2038,6 +2046,7 @@ func TenantLedgerTypeValues() []TenantLedgerType { return []TenantLedgerType{ TenantLedgerTypeDebitPurchase, TenantLedgerTypeCreditRefund, + TenantLedgerTypeCreditWithdrawal, TenantLedgerTypeFreeze, TenantLedgerTypeUnfreeze, TenantLedgerTypeAdjustment, @@ -2057,11 +2066,12 @@ func (x TenantLedgerType) IsValid() bool { } var _TenantLedgerTypeValue = map[string]TenantLedgerType{ - "debit_purchase": TenantLedgerTypeDebitPurchase, - "credit_refund": TenantLedgerTypeCreditRefund, - "freeze": TenantLedgerTypeFreeze, - "unfreeze": TenantLedgerTypeUnfreeze, - "adjustment": TenantLedgerTypeAdjustment, + "debit_purchase": TenantLedgerTypeDebitPurchase, + "credit_refund": TenantLedgerTypeCreditRefund, + "credit_withdrawal": TenantLedgerTypeCreditWithdrawal, + "freeze": TenantLedgerTypeFreeze, + "unfreeze": TenantLedgerTypeUnfreeze, + "adjustment": TenantLedgerTypeAdjustment, } // ParseTenantLedgerType attempts to convert a string to a TenantLedgerType. diff --git a/backend/pkg/consts/consts.go b/backend/pkg/consts/consts.go index acffb4a..48edb49 100644 --- a/backend/pkg/consts/consts.go +++ b/backend/pkg/consts/consts.go @@ -402,7 +402,7 @@ func ContentAccessStatusItems() []requests.KV { // orders // swagger:enum OrderType -// ENUM( content_purchase, recharge ) +// ENUM( content_purchase, recharge, withdrawal ) type OrderType string // Description returns the Chinese label for the specific enum value. @@ -412,6 +412,8 @@ func (t OrderType) Description() string { return "购买内容" case OrderTypeRecharge: return "充值" + case OrderTypeWithdrawal: + return "提现" default: return "未知类型" } @@ -464,7 +466,7 @@ func OrderStatusItems() []requests.KV { // tenant_ledgers // swagger:enum TenantLedgerType -// ENUM( debit_purchase, credit_refund, freeze, unfreeze, adjustment ) +// ENUM( debit_purchase, credit_refund, credit_withdrawal, freeze, unfreeze, adjustment ) type TenantLedgerType string // Description returns the Chinese label for the specific enum value. @@ -474,6 +476,8 @@ func (t TenantLedgerType) Description() string { return "购买扣款" case TenantLedgerTypeCreditRefund: return "退款回滚" + case TenantLedgerTypeCreditWithdrawal: + return "提现扣款" case TenantLedgerTypeFreeze: return "冻结" case TenantLedgerTypeUnfreeze: