feat: 实现平台抽成、提现审批、异步任务集成及安全审计功能
This commit is contained in:
14
TODO.md
Normal file
14
TODO.md
Normal file
@@ -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.
|
||||||
@@ -201,6 +201,11 @@ func (r *Routes) Register(router fiber.Router) {
|
|||||||
PathParam[string]("id"),
|
PathParam[string]("id"),
|
||||||
Body[dto.OrderPayForm]("form"),
|
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
|
// Register routes for controller: User
|
||||||
r.log.Debugf("Registering route: Delete /v1/me/favorites/:contentId -> user.RemoveFavorite")
|
r.log.Debugf("Registering route: Delete /v1/me/favorites/:contentId -> user.RemoveFavorite")
|
||||||
router.Delete("/v1/me/favorites/:contentId"[len(r.Path()):], Func1(
|
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) {
|
func (t *Transaction) Status(ctx fiber.Ctx, id string) (*dto.OrderStatusResponse, error) {
|
||||||
return services.Order.Status(ctx.Context(), id)
|
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 (
|
import (
|
||||||
"quyun/v2/app/http/v1/dto"
|
"quyun/v2/app/http/v1/dto"
|
||||||
auth_dto "quyun/v2/app/http/v1/dto"
|
auth_dto "quyun/v2/app/http/v1/dto"
|
||||||
|
"quyun/v2/app/requests"
|
||||||
"quyun/v2/app/services"
|
"quyun/v2/app/services"
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v3"
|
"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
|
package jobs
|
||||||
|
|
||||||
import (
|
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"
|
"go.ipao.vip/atom/opt"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Provide(opts ...opt.Option) error {
|
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
|
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
|
// Filters
|
||||||
q = q.Where(tbl.Status.Eq(consts.ContentStatusPublished))
|
q = q.Where(tbl.Status.Eq(consts.ContentStatusPublished))
|
||||||
if filter.Keyword != nil && *filter.Keyword != "" {
|
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 != "" {
|
if filter.Genre != nil && *filter.Genre != "" {
|
||||||
q = q.Where(tbl.Genre.Eq(*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("Author").
|
||||||
Preload("ContentAssets.Asset").
|
Preload("ContentAssets.Asset").
|
||||||
Find(&list).Error
|
Find(&list).Error
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errorx.ErrDatabaseError.WithCause(err)
|
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) {
|
func (s *content) Get(ctx context.Context, id string) (*content_dto.ContentDetail, error) {
|
||||||
cid := cast.ToInt64(id)
|
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)
|
_, q := models.ContentQuery.QueryContext(ctx)
|
||||||
|
|
||||||
var item models.Content
|
var item models.Content
|
||||||
@@ -94,7 +98,6 @@ func (s *content) Get(ctx context.Context, id string) (*content_dto.ContentDetai
|
|||||||
Preload("ContentAssets.Asset").
|
Preload("ContentAssets.Asset").
|
||||||
Where("id = ?", cid).
|
Where("id = ?", cid).
|
||||||
First(&item).Error
|
First(&item).Error
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, errorx.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").
|
Select("genre, count(*) as count").
|
||||||
Group("genre").
|
Group("genre").
|
||||||
Scan(&results).Error
|
Scan(&results).Error
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errorx.ErrDatabaseError.WithCause(err)
|
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 {
|
func (s *content) toContentItemDTO(item *models.Content) content_dto.ContentItem {
|
||||||
dto := content_dto.ContentItem{
|
dto := content_dto.ContentItem{
|
||||||
ID: cast.ToString(item.ID),
|
ID: cast.ToString(item.ID),
|
||||||
Title: item.Title,
|
Title: item.Title,
|
||||||
Genre: item.Genre,
|
Genre: item.Genre,
|
||||||
AuthorID: cast.ToString(item.UserID),
|
AuthorID: cast.ToString(item.UserID),
|
||||||
Views: int(item.Views),
|
Views: int(item.Views),
|
||||||
Likes: int(item.Likes),
|
Likes: int(item.Likes),
|
||||||
}
|
}
|
||||||
if item.Author != nil {
|
if item.Author != nil {
|
||||||
dto.AuthorName = item.Author.Nickname
|
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))
|
data = append(data, s.toContentItemDTO(item))
|
||||||
}
|
}
|
||||||
return data, nil
|
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"
|
"quyun/v2/app/errorx"
|
||||||
creator_dto "quyun/v2/app/http/v1/dto"
|
creator_dto "quyun/v2/app/http/v1/dto"
|
||||||
|
"quyun/v2/database/fields"
|
||||||
"quyun/v2/database/models"
|
"quyun/v2/database/models"
|
||||||
"quyun/v2/pkg/consts"
|
"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 {
|
func (s *creator) ProcessRefund(ctx context.Context, id string, form *creator_dto.RefundForm) error {
|
||||||
// Complex logic involving ledgers and order status update
|
tid, err := s.getTenantID(ctx)
|
||||||
return nil
|
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) {
|
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) {
|
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 {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *creator) RemovePayoutAccount(ctx context.Context, id string) error {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *creator) Withdraw(ctx context.Context, form *creator_dto.WithdrawForm) error {
|
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
|
// 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"
|
"quyun/v2/app/errorx"
|
||||||
user_dto "quyun/v2/app/http/v1/dto"
|
user_dto "quyun/v2/app/http/v1/dto"
|
||||||
|
"quyun/v2/app/jobs/args"
|
||||||
"quyun/v2/app/requests"
|
"quyun/v2/app/requests"
|
||||||
"quyun/v2/database/models"
|
"quyun/v2/database/models"
|
||||||
"quyun/v2/pkg/consts"
|
"quyun/v2/pkg/consts"
|
||||||
|
"quyun/v2/providers/job"
|
||||||
|
|
||||||
"github.com/spf13/cast"
|
"github.com/spf13/cast"
|
||||||
)
|
)
|
||||||
|
|
||||||
// @provider
|
// @provider
|
||||||
type notification struct{}
|
type notification struct {
|
||||||
|
job *job.Job
|
||||||
|
}
|
||||||
|
|
||||||
func (s *notification) List(ctx context.Context, page int, typeArg string) (*requests.Pager, error) {
|
func (s *notification) List(ctx context.Context, page int, typeArg string) (*requests.Pager, error) {
|
||||||
userID := ctx.Value(consts.CtxKeyUser)
|
userID := ctx.Value(consts.CtxKeyUser)
|
||||||
@@ -79,13 +83,12 @@ func (s *notification) MarkRead(ctx context.Context, id string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *notification) Send(ctx context.Context, userID int64, typ string, title, content string) error {
|
func (s *notification) Send(ctx context.Context, userID int64, typ, title, content string) error {
|
||||||
n := &models.Notification{
|
arg := args.NotificationArgs{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Type: typ,
|
Type: typ,
|
||||||
Title: title,
|
Title: title,
|
||||||
Content: content,
|
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")
|
err := Notification.Send(ctx, uID, "system", "Welcome", "Hello World")
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
|
|
||||||
list, err := Notification.List(ctx, 1)
|
list, err := Notification.List(ctx, 1, "")
|
||||||
So(err, ShouldBeNil)
|
So(err, ShouldBeNil)
|
||||||
So(list.Total, ShouldEqual, 1)
|
So(list.Total, ShouldEqual, 1)
|
||||||
|
|
||||||
items := list.Items.([]app_dto.Notification)
|
items := list.Items.([]app_dto.Notification)
|
||||||
So(len(items), ShouldEqual, 1)
|
So(len(items), ShouldEqual, 1)
|
||||||
So(items[0].Title, ShouldEqual, "Welcome")
|
So(items[0].Title, ShouldEqual, "Welcome")
|
||||||
|
|
||||||
// Mark Read
|
// Mark Read
|
||||||
// Need ID
|
// Need ID
|
||||||
n, _ := models.NotificationQuery.WithContext(ctx).Where(models.NotificationQuery.UserID.Eq(uID)).First()
|
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)
|
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)
|
return s.payWithBalance(ctx, o)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// External payment (mock) - normally returns URL/params
|
||||||
return &transaction_dto.OrderPayResponse{
|
return &transaction_dto.OrderPayResponse{
|
||||||
PayParams: "mock_pay_params",
|
PayParams: "mock_pay_params",
|
||||||
}, nil
|
}, 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) {
|
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
|
var tenantOwnerID int64
|
||||||
err := models.Q.Transaction(func(tx *models.Query) error {
|
err := models.Q.Transaction(func(tx *models.Query) error {
|
||||||
// 1. Deduct User Balance
|
// 1. Deduct User Balance (Only for balance method)
|
||||||
info, err := tx.User.WithContext(ctx).
|
if method == "balance" {
|
||||||
Where(tx.User.ID.Eq(o.UserID), tx.User.Balance.Gte(o.AmountPaid)).
|
info, err := tx.User.WithContext(ctx).
|
||||||
Update(tx.User.Balance, gorm.Expr("balance - ?", o.AmountPaid))
|
Where(tx.User.ID.Eq(o.UserID), tx.User.Balance.Gte(o.AmountPaid)).
|
||||||
if err != nil {
|
Update(tx.User.Balance, gorm.Expr("balance - ?", o.AmountPaid))
|
||||||
return err
|
if err != nil {
|
||||||
}
|
return err
|
||||||
if info.RowsAffected == 0 {
|
}
|
||||||
return errorx.ErrQuotaExceeded.WithMsg("余额不足")
|
if info.RowsAffected == 0 {
|
||||||
|
return errorx.ErrQuotaExceeded.WithMsg("余额不足")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Update Order Status
|
// 2. Update Order Status
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
_, err = tx.Order.WithContext(ctx).Where(tx.Order.ID.Eq(o.ID)).Updates(&models.Order{
|
// snapshot := o.Snapshot // Preserve existing snapshot or update it with external ID
|
||||||
Status: consts.OrderStatusPaid,
|
// TODO: Update snapshot with payment info
|
||||||
PaidAt: now,
|
_, 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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -210,18 +244,31 @@ func (s *order) payWithBalance(ctx context.Context, o *models.Order) (*transacti
|
|||||||
}
|
}
|
||||||
tenantOwnerID = t.UserID
|
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{
|
ledger := &models.TenantLedger{
|
||||||
TenantID: o.TenantID,
|
TenantID: o.TenantID,
|
||||||
UserID: t.UserID, // Owner
|
UserID: t.UserID, // Owner
|
||||||
OrderID: o.ID,
|
OrderID: o.ID,
|
||||||
Type: consts.TenantLedgerTypeDebitPurchase, // Income from purchase
|
Type: consts.TenantLedgerTypeDebitPurchase, // Income from purchase
|
||||||
Amount: o.AmountPaid,
|
Amount: creatorIncome,
|
||||||
BalanceBefore: 0, // TODO: Fetch previous balance if tracking tenant balance
|
BalanceBefore: 0, // TODO
|
||||||
BalanceAfter: 0, // TODO
|
BalanceAfter: 0, // TODO
|
||||||
FrozenBefore: 0,
|
FrozenBefore: 0,
|
||||||
FrozenAfter: 0,
|
FrozenAfter: 0,
|
||||||
IdempotencyKey: uuid.NewString(),
|
IdempotencyKey: uuid.NewString(),
|
||||||
Remark: "内容销售收入",
|
Remark: "内容销售收入 (扣除平台费)",
|
||||||
OperatorUserID: o.UserID,
|
OperatorUserID: o.UserID,
|
||||||
}
|
}
|
||||||
if err := tx.TenantLedger.WithContext(ctx).Create(ledger); err != nil {
|
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
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if _, ok := err.(*errorx.AppError); ok {
|
return err
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return nil, errorx.ErrDatabaseError.WithCause(err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if Notification != nil {
|
if Notification != nil {
|
||||||
@@ -243,10 +287,7 @@ func (s *order) payWithBalance(ctx context.Context, o *models.Order) (*transacti
|
|||||||
_ = Notification.Send(ctx, tenantOwnerID, "order", "新的订单", "您的店铺有新的订单,收入已入账。")
|
_ = Notification.Send(ctx, tenantOwnerID, "order", "新的订单", "您的店铺有新的订单,收入已入账。")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
return &transaction_dto.OrderPayResponse{
|
|
||||||
PayParams: "balance_paid",
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *order) Status(ctx context.Context, id string) (*transaction_dto.OrderStatusResponse, error) {
|
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()
|
l, _ := models.TenantLedgerQuery.WithContext(ctx).Where(models.TenantLedgerQuery.OrderID.Eq(o.ID)).First()
|
||||||
So(l, ShouldNotBeNil)
|
So(l, ShouldNotBeNil)
|
||||||
So(l.UserID, ShouldEqual, creator.ID)
|
So(l.UserID, ShouldEqual, creator.ID)
|
||||||
So(l.Amount, ShouldEqual, 1000)
|
So(l.Amount, ShouldEqual, 900)
|
||||||
So(l.Type, ShouldEqual, consts.TenantLedgerTypeDebitPurchase)
|
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
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"quyun/v2/providers/job"
|
||||||
"quyun/v2/providers/jwt"
|
"quyun/v2/providers/jwt"
|
||||||
|
|
||||||
"go.ipao.vip/atom"
|
"go.ipao.vip/atom"
|
||||||
@@ -11,6 +12,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func Provide(opts ...opt.Option) error {
|
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) {
|
if err := container.Container.Provide(func() (*common, error) {
|
||||||
obj := &common{}
|
obj := &common{}
|
||||||
|
|
||||||
@@ -32,8 +40,12 @@ func Provide(opts ...opt.Option) error {
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := container.Container.Provide(func() (*notification, error) {
|
if err := container.Container.Provide(func(
|
||||||
obj := ¬ification{}
|
job *job.Job,
|
||||||
|
) (*notification, error) {
|
||||||
|
obj := ¬ification{
|
||||||
|
job: job,
|
||||||
|
}
|
||||||
|
|
||||||
return obj, nil
|
return obj, nil
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -47,6 +59,7 @@ func Provide(opts ...opt.Option) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := container.Container.Provide(func(
|
if err := container.Container.Provide(func(
|
||||||
|
audit *audit,
|
||||||
common *common,
|
common *common,
|
||||||
content *content,
|
content *content,
|
||||||
creator *creator,
|
creator *creator,
|
||||||
@@ -59,6 +72,7 @@ func Provide(opts ...opt.Option) error {
|
|||||||
wallet *wallet,
|
wallet *wallet,
|
||||||
) (contracts.Initial, error) {
|
) (contracts.Initial, error) {
|
||||||
obj := &services{
|
obj := &services{
|
||||||
|
audit: audit,
|
||||||
common: common,
|
common: common,
|
||||||
content: content,
|
content: content,
|
||||||
creator: creator,
|
creator: creator,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ var _db *gorm.DB
|
|||||||
|
|
||||||
// exported CamelCase Services
|
// exported CamelCase Services
|
||||||
var (
|
var (
|
||||||
|
Audit *audit
|
||||||
Common *common
|
Common *common
|
||||||
Content *content
|
Content *content
|
||||||
Creator *creator
|
Creator *creator
|
||||||
@@ -23,6 +24,7 @@ var (
|
|||||||
type services struct {
|
type services struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
// define Services
|
// define Services
|
||||||
|
audit *audit
|
||||||
common *common
|
common *common
|
||||||
content *content
|
content *content
|
||||||
creator *creator
|
creator *creator
|
||||||
@@ -38,6 +40,7 @@ func (svc *services) Prepare() error {
|
|||||||
_db = svc.db
|
_db = svc.db
|
||||||
|
|
||||||
// set exported Services here
|
// set exported Services here
|
||||||
|
Audit = svc.audit
|
||||||
Common = svc.common
|
Common = svc.common
|
||||||
Content = svc.content
|
Content = svc.content
|
||||||
Creator = svc.creator
|
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) {
|
func (s *super) TenantStatuses(ctx context.Context) ([]requests.KV, error) {
|
||||||
return consts.TenantStatusItems(), nil
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -131,12 +132,37 @@ func (s *user) RealName(ctx context.Context, form *user_dto.RealNameForm) error
|
|||||||
}
|
}
|
||||||
uid := cast.ToInt64(userID)
|
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)
|
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,
|
IsRealNameVerified: true,
|
||||||
// RealName: form.Realname, // 需在 user 表添加字段? payout_accounts 有 realname
|
VerifiedAt: time.Now(),
|
||||||
|
Metas: types.JSON(b),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errorx.ErrDatabaseError.WithCause(err)
|
return errorx.ErrDatabaseError.WithCause(err)
|
||||||
|
|||||||
@@ -1688,6 +1688,8 @@ const (
|
|||||||
OrderTypeContentPurchase OrderType = "content_purchase"
|
OrderTypeContentPurchase OrderType = "content_purchase"
|
||||||
// OrderTypeRecharge is a OrderType of type recharge.
|
// OrderTypeRecharge is a OrderType of type recharge.
|
||||||
OrderTypeRecharge OrderType = "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, ", "))
|
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{
|
var _OrderTypeNames = []string{
|
||||||
string(OrderTypeContentPurchase),
|
string(OrderTypeContentPurchase),
|
||||||
string(OrderTypeRecharge),
|
string(OrderTypeRecharge),
|
||||||
|
string(OrderTypeWithdrawal),
|
||||||
}
|
}
|
||||||
|
|
||||||
// OrderTypeNames returns a list of possible string values of OrderType.
|
// OrderTypeNames returns a list of possible string values of OrderType.
|
||||||
@@ -1709,6 +1712,7 @@ func OrderTypeValues() []OrderType {
|
|||||||
return []OrderType{
|
return []OrderType{
|
||||||
OrderTypeContentPurchase,
|
OrderTypeContentPurchase,
|
||||||
OrderTypeRecharge,
|
OrderTypeRecharge,
|
||||||
|
OrderTypeWithdrawal,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1727,6 +1731,7 @@ func (x OrderType) IsValid() bool {
|
|||||||
var _OrderTypeValue = map[string]OrderType{
|
var _OrderTypeValue = map[string]OrderType{
|
||||||
"content_purchase": OrderTypeContentPurchase,
|
"content_purchase": OrderTypeContentPurchase,
|
||||||
"recharge": OrderTypeRecharge,
|
"recharge": OrderTypeRecharge,
|
||||||
|
"withdrawal": OrderTypeWithdrawal,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseOrderType attempts to convert a string to a OrderType.
|
// ParseOrderType attempts to convert a string to a OrderType.
|
||||||
@@ -2008,6 +2013,8 @@ const (
|
|||||||
TenantLedgerTypeDebitPurchase TenantLedgerType = "debit_purchase"
|
TenantLedgerTypeDebitPurchase TenantLedgerType = "debit_purchase"
|
||||||
// TenantLedgerTypeCreditRefund is a TenantLedgerType of type credit_refund.
|
// TenantLedgerTypeCreditRefund is a TenantLedgerType of type credit_refund.
|
||||||
TenantLedgerTypeCreditRefund TenantLedgerType = "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 is a TenantLedgerType of type freeze.
|
||||||
TenantLedgerTypeFreeze TenantLedgerType = "freeze"
|
TenantLedgerTypeFreeze TenantLedgerType = "freeze"
|
||||||
// TenantLedgerTypeUnfreeze is a TenantLedgerType of type unfreeze.
|
// TenantLedgerTypeUnfreeze is a TenantLedgerType of type unfreeze.
|
||||||
@@ -2021,6 +2028,7 @@ var ErrInvalidTenantLedgerType = fmt.Errorf("not a valid TenantLedgerType, try [
|
|||||||
var _TenantLedgerTypeNames = []string{
|
var _TenantLedgerTypeNames = []string{
|
||||||
string(TenantLedgerTypeDebitPurchase),
|
string(TenantLedgerTypeDebitPurchase),
|
||||||
string(TenantLedgerTypeCreditRefund),
|
string(TenantLedgerTypeCreditRefund),
|
||||||
|
string(TenantLedgerTypeCreditWithdrawal),
|
||||||
string(TenantLedgerTypeFreeze),
|
string(TenantLedgerTypeFreeze),
|
||||||
string(TenantLedgerTypeUnfreeze),
|
string(TenantLedgerTypeUnfreeze),
|
||||||
string(TenantLedgerTypeAdjustment),
|
string(TenantLedgerTypeAdjustment),
|
||||||
@@ -2038,6 +2046,7 @@ func TenantLedgerTypeValues() []TenantLedgerType {
|
|||||||
return []TenantLedgerType{
|
return []TenantLedgerType{
|
||||||
TenantLedgerTypeDebitPurchase,
|
TenantLedgerTypeDebitPurchase,
|
||||||
TenantLedgerTypeCreditRefund,
|
TenantLedgerTypeCreditRefund,
|
||||||
|
TenantLedgerTypeCreditWithdrawal,
|
||||||
TenantLedgerTypeFreeze,
|
TenantLedgerTypeFreeze,
|
||||||
TenantLedgerTypeUnfreeze,
|
TenantLedgerTypeUnfreeze,
|
||||||
TenantLedgerTypeAdjustment,
|
TenantLedgerTypeAdjustment,
|
||||||
@@ -2057,11 +2066,12 @@ func (x TenantLedgerType) IsValid() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var _TenantLedgerTypeValue = map[string]TenantLedgerType{
|
var _TenantLedgerTypeValue = map[string]TenantLedgerType{
|
||||||
"debit_purchase": TenantLedgerTypeDebitPurchase,
|
"debit_purchase": TenantLedgerTypeDebitPurchase,
|
||||||
"credit_refund": TenantLedgerTypeCreditRefund,
|
"credit_refund": TenantLedgerTypeCreditRefund,
|
||||||
"freeze": TenantLedgerTypeFreeze,
|
"credit_withdrawal": TenantLedgerTypeCreditWithdrawal,
|
||||||
"unfreeze": TenantLedgerTypeUnfreeze,
|
"freeze": TenantLedgerTypeFreeze,
|
||||||
"adjustment": TenantLedgerTypeAdjustment,
|
"unfreeze": TenantLedgerTypeUnfreeze,
|
||||||
|
"adjustment": TenantLedgerTypeAdjustment,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseTenantLedgerType attempts to convert a string to a TenantLedgerType.
|
// ParseTenantLedgerType attempts to convert a string to a TenantLedgerType.
|
||||||
|
|||||||
@@ -402,7 +402,7 @@ func ContentAccessStatusItems() []requests.KV {
|
|||||||
// orders
|
// orders
|
||||||
|
|
||||||
// swagger:enum OrderType
|
// swagger:enum OrderType
|
||||||
// ENUM( content_purchase, recharge )
|
// ENUM( content_purchase, recharge, withdrawal )
|
||||||
type OrderType string
|
type OrderType string
|
||||||
|
|
||||||
// Description returns the Chinese label for the specific enum value.
|
// Description returns the Chinese label for the specific enum value.
|
||||||
@@ -412,6 +412,8 @@ func (t OrderType) Description() string {
|
|||||||
return "购买内容"
|
return "购买内容"
|
||||||
case OrderTypeRecharge:
|
case OrderTypeRecharge:
|
||||||
return "充值"
|
return "充值"
|
||||||
|
case OrderTypeWithdrawal:
|
||||||
|
return "提现"
|
||||||
default:
|
default:
|
||||||
return "未知类型"
|
return "未知类型"
|
||||||
}
|
}
|
||||||
@@ -464,7 +466,7 @@ func OrderStatusItems() []requests.KV {
|
|||||||
// tenant_ledgers
|
// tenant_ledgers
|
||||||
|
|
||||||
// swagger:enum TenantLedgerType
|
// swagger:enum TenantLedgerType
|
||||||
// ENUM( debit_purchase, credit_refund, freeze, unfreeze, adjustment )
|
// ENUM( debit_purchase, credit_refund, credit_withdrawal, freeze, unfreeze, adjustment )
|
||||||
type TenantLedgerType string
|
type TenantLedgerType string
|
||||||
|
|
||||||
// Description returns the Chinese label for the specific enum value.
|
// Description returns the Chinese label for the specific enum value.
|
||||||
@@ -474,6 +476,8 @@ func (t TenantLedgerType) Description() string {
|
|||||||
return "购买扣款"
|
return "购买扣款"
|
||||||
case TenantLedgerTypeCreditRefund:
|
case TenantLedgerTypeCreditRefund:
|
||||||
return "退款回滚"
|
return "退款回滚"
|
||||||
|
case TenantLedgerTypeCreditWithdrawal:
|
||||||
|
return "提现扣款"
|
||||||
case TenantLedgerTypeFreeze:
|
case TenantLedgerTypeFreeze:
|
||||||
return "冻结"
|
return "冻结"
|
||||||
case TenantLedgerTypeUnfreeze:
|
case TenantLedgerTypeUnfreeze:
|
||||||
|
|||||||
Reference in New Issue
Block a user