diff --git a/backend/app/http/posts.go b/backend/app/http/posts.go index 0b0b871..2e20ca6 100644 --- a/backend/app/http/posts.go +++ b/backend/app/http/posts.go @@ -5,12 +5,14 @@ import ( "strconv" "time" + "quyun/app/jobs" "quyun/app/models" "quyun/app/requests" "quyun/database/conds" "quyun/database/fields" "quyun/database/schemas/public/model" "quyun/providers/ali" + "quyun/providers/job" "quyun/providers/wepay" "github.com/go-pay/gopay/wechat/v3" @@ -28,6 +30,7 @@ type ListQuery struct { type posts struct { wepay *wepay.Client oss *ali.OSSClient + job *job.Job } // List posts @@ -276,6 +279,22 @@ func (ctl *posts) Buy(ctx fiber.Ctx, id int64, user *model.Users) (*wechat.JSAPI } payPrice := post.Price * int64(post.Discount) / 100 + if user.Balance >= payPrice { + err = models.Users.SetBalance(ctx.Context(), user.ID, user.Balance-payPrice) + if err != nil { + return nil, errors.Wrap(err, "余额支付失败") + } + + if err := ctl.job.Add(&jobs.BalancePayNotify{OrderNo: order.OrderNo}); err != nil { + log.Errorf("add job error:%v", err) + return nil, errors.Wrap(err, "Failed to add job") + } + + return &wechat.JSAPIPayParams{ + AppId: "balance", + }, nil + } + prePayResp, err := ctl.wepay.V3TransactionJsapi(ctx.Context(), func(bm *wepay.BodyMap) { bm. Expire(30 * time.Minute). diff --git a/backend/app/jobs/balance_pay_notify.go b/backend/app/jobs/balance_pay_notify.go new file mode 100644 index 0000000..23cdc7a --- /dev/null +++ b/backend/app/jobs/balance_pay_notify.go @@ -0,0 +1,88 @@ +package jobs + +import ( + "context" + "fmt" + "time" + + "quyun/app/models" + "quyun/database/fields" + + "github.com/pkg/errors" + . "github.com/riverqueue/river" + log "github.com/sirupsen/logrus" + _ "go.ipao.vip/atom" + "go.ipao.vip/atom/contracts" + _ "go.ipao.vip/atom/contracts" +) + +var _ contracts.JobArgs = (*BalancePayNotify)(nil) + +type BalancePayNotify struct { + OrderNo string `json:"order_no"` +} + +func (s BalancePayNotify) InsertOpts() InsertOpts { + return InsertOpts{ + Queue: QueueDefault, + Priority: PriorityDefault, + } +} + +func (BalancePayNotify) Kind() string { return "balance_pay_notify" } +func (a BalancePayNotify) UniqueID() string { return a.Kind() } + +var _ Worker[BalancePayNotify] = (*BalancePayNotifyWorker)(nil) + +// @provider(job) +type BalancePayNotifyWorker struct { + WorkerDefaults[BalancePayNotify] +} + +func (w *BalancePayNotifyWorker) Work(ctx context.Context, job *Job[BalancePayNotify]) error { + log := log.WithField("job", job.Args.Kind()) + + log.Infof("[Start] Working on job with strings: %+v", job.Args) + defer log.Infof("[End] Finished %s", job.Args.Kind()) + + order, err := models.Orders.GetByOrderNo(context.Background(), job.Args.OrderNo) + if err != nil { + log.Errorf("GetByOrderNo error:%v", err) + return err + } + + if order.Status != fields.OrderStatusPending { + log.Infof("Order %s is paid, processing...", job.Args.OrderNo) + return JobCancel(fmt.Errorf("Order already paid, currently status: %d", order.Status)) + } + + order.PaymentMethod = "balance" + order.Status = fields.OrderStatusCompleted + log.Infof("Updated order details: %+v", order) + tx, err := models.Transaction(ctx) + if err != nil { + return errors.Wrap(err, "Transaction error") + } + defer tx.Rollback() + + if err := models.Users.BuyPosts(context.Background(), order.UserID, order.PostID, order.Price); err != nil { + log.Errorf("BuyPosts error:%v", err) + return errors.Wrap(err, "BuyPosts error") + } + + if err := models.Orders.Update(context.Background(), order); err != nil { + log.Errorf("Update order error:%v", err) + return errors.Wrap(err, "Update order error") + } + if err := tx.Commit(); err != nil { + log.Errorf("Commit error:%v", err) + return errors.Wrap(err, "Commit error") + } + + log.Infof("Successfully processed order %s", order.OrderNo) + return nil +} + +func (w *BalancePayNotifyWorker) NextRetry(job *Job[BalancePayNotify]) time.Time { + return time.Now().Add(30 * time.Second) +} diff --git a/backend/app/models/users.go b/backend/app/models/users.go index 5ec42f8..2252d6d 100644 --- a/backend/app/models/users.go +++ b/backend/app/models/users.go @@ -474,3 +474,21 @@ func (m *usersModel) RevokePosts(ctx context.Context, userID, postID int64) erro } return nil } + +// SetBalance +func (m *usersModel) SetBalance(ctx context.Context, id int64, balance int64) error { + tbl := table.Users + stmt := tbl. + UPDATE(tbl.Balance). + SET(Int64(balance)). + WHERE( + tbl.ID.EQ(Int64(id)), + ) + m.log.Infof("sql: %s", stmt.DebugSql()) + + if _, err := stmt.ExecContext(ctx, db); err != nil { + m.log.Errorf("error updating user balance: %v", err) + return err + } + return nil +} diff --git a/backend/database/migrations/20250512113213_alter_user.sql b/backend/database/migrations/20250512113213_alter_user.sql new file mode 100644 index 0000000..b635d31 --- /dev/null +++ b/backend/database/migrations/20250512113213_alter_user.sql @@ -0,0 +1,11 @@ +-- +goose Up +-- +goose StatementBegin +ALTER TABLE public.users + ADD balance int8 DEFAULT 0 NOT NULL; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE public.users + DROP COLUMN balance; +-- +goose StatementEnd diff --git a/backend/database/schemas/public/model/medias.go b/backend/database/schemas/public/model/medias.go index ed24315..b6b1c15 100644 --- a/backend/database/schemas/public/model/medias.go +++ b/backend/database/schemas/public/model/medias.go @@ -19,6 +19,6 @@ type Medias struct { MimeType string `json:"mime_type"` Size int64 `json:"size"` Path string `json:"path"` - Hash string `json:"hash"` Metas fields.Json[fields.MediaMetas] `json:"metas"` + Hash string `json:"hash"` } diff --git a/backend/database/schemas/public/model/posts.go b/backend/database/schemas/public/model/posts.go index e344d69..ccf2b8c 100644 --- a/backend/database/schemas/public/model/posts.go +++ b/backend/database/schemas/public/model/posts.go @@ -19,6 +19,7 @@ type Posts struct { DeletedAt *time.Time `json:"deleted_at"` Status fields.PostStatus `json:"status"` Title string `json:"title"` + HeadImages fields.Json[[]int64] `json:"head_images"` Description string `json:"description"` Content string `json:"content"` Price int64 `json:"price"` @@ -27,5 +28,4 @@ type Posts struct { Likes int64 `json:"likes"` Tags fields.Json[[]string] `json:"tags"` Assets fields.Json[[]fields.MediaAsset] `json:"assets"` - HeadImages fields.Json[[]int64] `json:"head_images"` } diff --git a/backend/database/schemas/public/model/users.go b/backend/database/schemas/public/model/users.go index dacce39..9070887 100644 --- a/backend/database/schemas/public/model/users.go +++ b/backend/database/schemas/public/model/users.go @@ -23,4 +23,5 @@ type Users struct { Avatar *string `json:"avatar"` Metas fields.Json[fields.UserMetas] `json:"metas"` AuthToken fields.Json[fields.UserAuthToken] `json:"auth_token"` + Balance int64 `json:"balance"` } diff --git a/backend/database/schemas/public/table/medias.go b/backend/database/schemas/public/table/medias.go index a517519..02c207c 100644 --- a/backend/database/schemas/public/table/medias.go +++ b/backend/database/schemas/public/table/medias.go @@ -23,8 +23,8 @@ type mediasTable struct { MimeType postgres.ColumnString Size postgres.ColumnInteger Path postgres.ColumnString - Hash postgres.ColumnString Metas postgres.ColumnString + Hash postgres.ColumnString AllColumns postgres.ColumnList MutableColumns postgres.ColumnList @@ -71,10 +71,10 @@ func newMediasTableImpl(schemaName, tableName, alias string) mediasTable { MimeTypeColumn = postgres.StringColumn("mime_type") SizeColumn = postgres.IntegerColumn("size") PathColumn = postgres.StringColumn("path") - HashColumn = postgres.StringColumn("hash") MetasColumn = postgres.StringColumn("metas") - allColumns = postgres.ColumnList{IDColumn, CreatedAtColumn, NameColumn, MimeTypeColumn, SizeColumn, PathColumn, HashColumn, MetasColumn} - mutableColumns = postgres.ColumnList{CreatedAtColumn, NameColumn, MimeTypeColumn, SizeColumn, PathColumn, HashColumn, MetasColumn} + HashColumn = postgres.StringColumn("hash") + allColumns = postgres.ColumnList{IDColumn, CreatedAtColumn, NameColumn, MimeTypeColumn, SizeColumn, PathColumn, MetasColumn, HashColumn} + mutableColumns = postgres.ColumnList{CreatedAtColumn, NameColumn, MimeTypeColumn, SizeColumn, PathColumn, MetasColumn, HashColumn} ) return mediasTable{ @@ -87,8 +87,8 @@ func newMediasTableImpl(schemaName, tableName, alias string) mediasTable { MimeType: MimeTypeColumn, Size: SizeColumn, Path: PathColumn, - Hash: HashColumn, Metas: MetasColumn, + Hash: HashColumn, AllColumns: allColumns, MutableColumns: mutableColumns, diff --git a/backend/database/schemas/public/table/posts.go b/backend/database/schemas/public/table/posts.go index c1afaed..7a18cff 100644 --- a/backend/database/schemas/public/table/posts.go +++ b/backend/database/schemas/public/table/posts.go @@ -23,6 +23,7 @@ type postsTable struct { DeletedAt postgres.ColumnTimestamp Status postgres.ColumnInteger Title postgres.ColumnString + HeadImages postgres.ColumnString Description postgres.ColumnString Content postgres.ColumnString Price postgres.ColumnInteger @@ -31,7 +32,6 @@ type postsTable struct { Likes postgres.ColumnInteger Tags postgres.ColumnString Assets postgres.ColumnString - HeadImages postgres.ColumnString AllColumns postgres.ColumnList MutableColumns postgres.ColumnList @@ -78,6 +78,7 @@ func newPostsTableImpl(schemaName, tableName, alias string) postsTable { DeletedAtColumn = postgres.TimestampColumn("deleted_at") StatusColumn = postgres.IntegerColumn("status") TitleColumn = postgres.StringColumn("title") + HeadImagesColumn = postgres.StringColumn("head_images") DescriptionColumn = postgres.StringColumn("description") ContentColumn = postgres.StringColumn("content") PriceColumn = postgres.IntegerColumn("price") @@ -86,9 +87,8 @@ func newPostsTableImpl(schemaName, tableName, alias string) postsTable { LikesColumn = postgres.IntegerColumn("likes") TagsColumn = postgres.StringColumn("tags") AssetsColumn = postgres.StringColumn("assets") - HeadImagesColumn = postgres.StringColumn("head_images") - allColumns = postgres.ColumnList{IDColumn, CreatedAtColumn, UpdatedAtColumn, DeletedAtColumn, StatusColumn, TitleColumn, DescriptionColumn, ContentColumn, PriceColumn, DiscountColumn, ViewsColumn, LikesColumn, TagsColumn, AssetsColumn, HeadImagesColumn} - mutableColumns = postgres.ColumnList{CreatedAtColumn, UpdatedAtColumn, DeletedAtColumn, StatusColumn, TitleColumn, DescriptionColumn, ContentColumn, PriceColumn, DiscountColumn, ViewsColumn, LikesColumn, TagsColumn, AssetsColumn, HeadImagesColumn} + allColumns = postgres.ColumnList{IDColumn, CreatedAtColumn, UpdatedAtColumn, DeletedAtColumn, StatusColumn, TitleColumn, HeadImagesColumn, DescriptionColumn, ContentColumn, PriceColumn, DiscountColumn, ViewsColumn, LikesColumn, TagsColumn, AssetsColumn} + mutableColumns = postgres.ColumnList{CreatedAtColumn, UpdatedAtColumn, DeletedAtColumn, StatusColumn, TitleColumn, HeadImagesColumn, DescriptionColumn, ContentColumn, PriceColumn, DiscountColumn, ViewsColumn, LikesColumn, TagsColumn, AssetsColumn} ) return postsTable{ @@ -101,6 +101,7 @@ func newPostsTableImpl(schemaName, tableName, alias string) postsTable { DeletedAt: DeletedAtColumn, Status: StatusColumn, Title: TitleColumn, + HeadImages: HeadImagesColumn, Description: DescriptionColumn, Content: ContentColumn, Price: PriceColumn, @@ -109,7 +110,6 @@ func newPostsTableImpl(schemaName, tableName, alias string) postsTable { Likes: LikesColumn, Tags: TagsColumn, Assets: AssetsColumn, - HeadImages: HeadImagesColumn, AllColumns: allColumns, MutableColumns: mutableColumns, diff --git a/backend/database/schemas/public/table/users.go b/backend/database/schemas/public/table/users.go index bdfb361..920f352 100644 --- a/backend/database/schemas/public/table/users.go +++ b/backend/database/schemas/public/table/users.go @@ -27,6 +27,7 @@ type usersTable struct { Avatar postgres.ColumnString Metas postgres.ColumnString AuthToken postgres.ColumnString + Balance postgres.ColumnInteger AllColumns postgres.ColumnList MutableColumns postgres.ColumnList @@ -77,8 +78,9 @@ func newUsersTableImpl(schemaName, tableName, alias string) usersTable { AvatarColumn = postgres.StringColumn("avatar") MetasColumn = postgres.StringColumn("metas") AuthTokenColumn = postgres.StringColumn("auth_token") - allColumns = postgres.ColumnList{IDColumn, CreatedAtColumn, UpdatedAtColumn, DeletedAtColumn, StatusColumn, OpenIDColumn, UsernameColumn, AvatarColumn, MetasColumn, AuthTokenColumn} - mutableColumns = postgres.ColumnList{CreatedAtColumn, UpdatedAtColumn, DeletedAtColumn, StatusColumn, OpenIDColumn, UsernameColumn, AvatarColumn, MetasColumn, AuthTokenColumn} + BalanceColumn = postgres.IntegerColumn("balance") + allColumns = postgres.ColumnList{IDColumn, CreatedAtColumn, UpdatedAtColumn, DeletedAtColumn, StatusColumn, OpenIDColumn, UsernameColumn, AvatarColumn, MetasColumn, AuthTokenColumn, BalanceColumn} + mutableColumns = postgres.ColumnList{CreatedAtColumn, UpdatedAtColumn, DeletedAtColumn, StatusColumn, OpenIDColumn, UsernameColumn, AvatarColumn, MetasColumn, AuthTokenColumn, BalanceColumn} ) return usersTable{ @@ -95,6 +97,7 @@ func newUsersTableImpl(schemaName, tableName, alias string) usersTable { Avatar: AvatarColumn, Metas: MetasColumn, AuthToken: AuthTokenColumn, + Balance: BalanceColumn, AllColumns: allColumns, MutableColumns: mutableColumns, diff --git a/frontend/wechat/src/views/ArticleDetail.vue b/frontend/wechat/src/views/ArticleDetail.vue index 6ed4b7a..1a4a81b 100644 --- a/frontend/wechat/src/views/ArticleDetail.vue +++ b/frontend/wechat/src/views/ArticleDetail.vue @@ -92,35 +92,40 @@ const handleBuy = async () => { buying.value = true; try { const response = await postApi.buy(article.value.id); + const payData = response.data; - // 调用微信支付 - window.WeixinJSBridge.invoke( - "getBrandWCPayRequest", - { - ...payData, - }, - async function (res) { - if (res.err_msg === "get_brand_wcpay_request:ok") { - // 支付成功,刷新文章数据 - fetchArticle(); - await updateMediaSource(); - } else if (res.err_msg === "get_brand_wcpay_request:cancel") { - // 用户取消支付 - console.log("Payment cancelled"); - alert("支付已取消"); - } else { - // 支付失败或取消 - console.error("Payment failed:", res.err_msg); - alert( - "支付失败:" + - (res.err_msg === "get_brand_wcpay_request:cancel" - ? "支付已取消" - : "支付异常") - ); + if (payData.AppID != "balance") { + // 调用微信支付 + window.WeixinJSBridge.invoke( + "getBrandWCPayRequest", + { + ...payData, + }, + async function (res) { + if (res.err_msg === "get_brand_wcpay_request:ok") { + // 支付成功,刷新文章数据 + fetchArticle(); + await updateMediaSource(); + } else if (res.err_msg === "get_brand_wcpay_request:cancel") { + // 用户取消支付 + console.log("Payment cancelled"); + alert("支付已取消"); + } else { + // 支付失败或取消 + console.error("Payment failed:", res.err_msg); + alert( + "支付失败:" + + (res.err_msg === "get_brand_wcpay_request:cancel" + ? "支付已取消" + : "支付异常") + ); + } } - } - ); + ); + } else { + alert("余额支付成功"); + } } catch (error) { console.error("Failed to initiate payment:", error); alert("发起支付失败,请稍后重试"); @@ -225,7 +230,7 @@ onUnmounted(() => {