feat: 实现平台抽成、提现审批、异步任务集成及安全审计功能
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
19
backend/app/jobs/args/media.go
Normal file
19
backend/app/jobs/args/media.go
Normal file
@@ -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"
|
||||
}
|
||||
22
backend/app/jobs/args/notification.go
Normal file
22
backend/app/jobs/args/notification.go
Normal file
@@ -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"
|
||||
}
|
||||
39
backend/app/jobs/media_process_job.go
Normal file
39
backend/app/jobs/media_process_job.go
Normal file
@@ -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
|
||||
}
|
||||
27
backend/app/jobs/notification_job.go
Normal file
27
backend/app/jobs/notification_job.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
24
backend/app/services/audit.go
Normal file
24
backend/app/services/audit.go
Normal file
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user