feat: 实现平台抽成、提现审批、异步任务集成及安全审计功能

This commit is contained in:
2025-12-30 14:54:19 +08:00
parent 5e8dbec806
commit ee1acae3ed
25 changed files with 985 additions and 60 deletions

View File

@@ -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(

View File

@@ -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
}

View File

@@ -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"

View 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"
}

View 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"
}

View 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
}

View 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)
}

View File

@@ -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
}

View 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")
}

View File

@@ -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
}
}

View File

@@ -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)
})
})
}

View File

@@ -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

View File

@@ -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)
})
})
}

View File

@@ -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)
}

View File

@@ -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)
})
})
}
}

View File

@@ -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) {

View File

@@ -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)
})
})
}

View File

@@ -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 := &notification{}
if err := container.Container.Provide(func(
job *job.Job,
) (*notification, error) {
obj := &notification{
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,

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)
})
})
}

View File

@@ -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)