From e3ef31037cd6e952dfc196a1737851725ba93512 Mon Sep 17 00:00:00 2001 From: Rogee Date: Tue, 10 Dec 2024 11:21:55 +0800 Subject: [PATCH] feat: user buy media --- .../migrations/20241128075611_init.sql | 11 +-- .../public/model/tenant_user_balances.go | 15 --- .../qvyun/public/model/users_tenants.go | 1 + .../qvyun/public/table/table_use_schema.go | 1 - .../public/table/tenant_user_balances.go | 84 ----------------- .../qvyun/public/table/users_tenants.go | 7 +- backend/modules/medias/controller.go | 43 ++++++--- backend/modules/medias/router.go | 1 + backend/modules/medias/service.go | 92 +++++++++++++++++++ backend/modules/medias/service_test.go | 17 ++++ backend/modules/middlewares/mid_response.go | 30 ++++++ backend/pkg/dao.go | 2 +- backend/pkg/data_structures.go | 2 +- backend/pkg/errorx/error.go | 23 ++++- backend/pkg/service/http/http.go | 1 + backend/pkg/service/tasks/tasks.go | 2 + backend/pkg/session.go | 1 - 17 files changed, 201 insertions(+), 132 deletions(-) delete mode 100644 backend/database/models/qvyun/public/model/tenant_user_balances.go delete mode 100644 backend/database/models/qvyun/public/table/tenant_user_balances.go create mode 100644 backend/modules/middlewares/mid_response.go delete mode 100644 backend/pkg/session.go diff --git a/backend/database/migrations/20241128075611_init.sql b/backend/database/migrations/20241128075611_init.sql index baa7d6f..f26884a 100644 --- a/backend/database/migrations/20241128075611_init.sql +++ b/backend/database/migrations/20241128075611_init.sql @@ -33,6 +33,7 @@ CREATE TABLE id SERIAL8 PRIMARY KEY, user_id INT8 NOT NULL, tenant_id INT8 NOT NULL, + balance INT8 NOT NULL default 0, created_at timestamp NOT NULL default now() ); @@ -41,16 +42,6 @@ CREATE INDEX idx_users_tenants_tenant_id ON users_tenants (tenant_id); -- uniq user_id, tenant_id CREATE UNIQUE INDEX idx_users_tenants_user_id_tenant_id ON users_tenants (user_id, tenant_id); -CREATE TABLE tenant_user_balances ( - id SERIAL8 PRIMARY KEY, - user_id INT8 NOT NULL, - tenant_id INT8 NOT NULL, - balance INT8 NOT NULL -); - -CREATE INDEX idx_tenant_user_balance_user_id ON tenant_user_balances (user_id); -CREATE INDEX idx_tenant_user_balance_tenant_id ON tenant_user_balances (tenant_id); - -- table user_balance_history CREATE TABLE user_balance_histories ( id SERIAL8 PRIMARY KEY, diff --git a/backend/database/models/qvyun/public/model/tenant_user_balances.go b/backend/database/models/qvyun/public/model/tenant_user_balances.go deleted file mode 100644 index c2d5f2d..0000000 --- a/backend/database/models/qvyun/public/model/tenant_user_balances.go +++ /dev/null @@ -1,15 +0,0 @@ -// -// Code generated by go-jet DO NOT EDIT. -// -// WARNING: Changes to this file may cause incorrect behavior -// and will be lost if the code is regenerated -// - -package model - -type TenantUserBalances struct { - ID int64 `sql:"primary_key" json:"id"` - UserID int64 `json:"user_id"` - TenantID int64 `json:"tenant_id"` - Balance int64 `json:"balance"` -} diff --git a/backend/database/models/qvyun/public/model/users_tenants.go b/backend/database/models/qvyun/public/model/users_tenants.go index 4825e7b..1c15e25 100644 --- a/backend/database/models/qvyun/public/model/users_tenants.go +++ b/backend/database/models/qvyun/public/model/users_tenants.go @@ -15,5 +15,6 @@ type UsersTenants struct { ID int64 `sql:"primary_key" json:"id"` UserID int64 `json:"user_id"` TenantID int64 `json:"tenant_id"` + Balance int64 `json:"balance"` CreatedAt time.Time `json:"created_at"` } diff --git a/backend/database/models/qvyun/public/table/table_use_schema.go b/backend/database/models/qvyun/public/table/table_use_schema.go index 1115a31..7b3d859 100644 --- a/backend/database/models/qvyun/public/table/table_use_schema.go +++ b/backend/database/models/qvyun/public/table/table_use_schema.go @@ -12,7 +12,6 @@ package table func UseSchema(schema string) { Medias = Medias.FromSchema(schema) Migrations = Migrations.FromSchema(schema) - TenantUserBalances = TenantUserBalances.FromSchema(schema) Tenants = Tenants.FromSchema(schema) UserBalanceHistories = UserBalanceHistories.FromSchema(schema) UserMedias = UserMedias.FromSchema(schema) diff --git a/backend/database/models/qvyun/public/table/tenant_user_balances.go b/backend/database/models/qvyun/public/table/tenant_user_balances.go deleted file mode 100644 index ec30cf1..0000000 --- a/backend/database/models/qvyun/public/table/tenant_user_balances.go +++ /dev/null @@ -1,84 +0,0 @@ -// -// Code generated by go-jet DO NOT EDIT. -// -// WARNING: Changes to this file may cause incorrect behavior -// and will be lost if the code is regenerated -// - -package table - -import ( - "github.com/go-jet/jet/v2/postgres" -) - -var TenantUserBalances = newTenantUserBalancesTable("public", "tenant_user_balances", "") - -type tenantUserBalancesTable struct { - postgres.Table - - // Columns - ID postgres.ColumnInteger - UserID postgres.ColumnInteger - TenantID postgres.ColumnInteger - Balance postgres.ColumnInteger - - AllColumns postgres.ColumnList - MutableColumns postgres.ColumnList -} - -type TenantUserBalancesTable struct { - tenantUserBalancesTable - - EXCLUDED tenantUserBalancesTable -} - -// AS creates new TenantUserBalancesTable with assigned alias -func (a TenantUserBalancesTable) AS(alias string) *TenantUserBalancesTable { - return newTenantUserBalancesTable(a.SchemaName(), a.TableName(), alias) -} - -// Schema creates new TenantUserBalancesTable with assigned schema name -func (a TenantUserBalancesTable) FromSchema(schemaName string) *TenantUserBalancesTable { - return newTenantUserBalancesTable(schemaName, a.TableName(), a.Alias()) -} - -// WithPrefix creates new TenantUserBalancesTable with assigned table prefix -func (a TenantUserBalancesTable) WithPrefix(prefix string) *TenantUserBalancesTable { - return newTenantUserBalancesTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) -} - -// WithSuffix creates new TenantUserBalancesTable with assigned table suffix -func (a TenantUserBalancesTable) WithSuffix(suffix string) *TenantUserBalancesTable { - return newTenantUserBalancesTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) -} - -func newTenantUserBalancesTable(schemaName, tableName, alias string) *TenantUserBalancesTable { - return &TenantUserBalancesTable{ - tenantUserBalancesTable: newTenantUserBalancesTableImpl(schemaName, tableName, alias), - EXCLUDED: newTenantUserBalancesTableImpl("", "excluded", ""), - } -} - -func newTenantUserBalancesTableImpl(schemaName, tableName, alias string) tenantUserBalancesTable { - var ( - IDColumn = postgres.IntegerColumn("id") - UserIDColumn = postgres.IntegerColumn("user_id") - TenantIDColumn = postgres.IntegerColumn("tenant_id") - BalanceColumn = postgres.IntegerColumn("balance") - allColumns = postgres.ColumnList{IDColumn, UserIDColumn, TenantIDColumn, BalanceColumn} - mutableColumns = postgres.ColumnList{UserIDColumn, TenantIDColumn, BalanceColumn} - ) - - return tenantUserBalancesTable{ - Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), - - //Columns - ID: IDColumn, - UserID: UserIDColumn, - TenantID: TenantIDColumn, - Balance: BalanceColumn, - - AllColumns: allColumns, - MutableColumns: mutableColumns, - } -} diff --git a/backend/database/models/qvyun/public/table/users_tenants.go b/backend/database/models/qvyun/public/table/users_tenants.go index c204664..0425b1b 100644 --- a/backend/database/models/qvyun/public/table/users_tenants.go +++ b/backend/database/models/qvyun/public/table/users_tenants.go @@ -20,6 +20,7 @@ type usersTenantsTable struct { ID postgres.ColumnInteger UserID postgres.ColumnInteger TenantID postgres.ColumnInteger + Balance postgres.ColumnInteger CreatedAt postgres.ColumnTimestamp AllColumns postgres.ColumnList @@ -64,9 +65,10 @@ func newUsersTenantsTableImpl(schemaName, tableName, alias string) usersTenantsT IDColumn = postgres.IntegerColumn("id") UserIDColumn = postgres.IntegerColumn("user_id") TenantIDColumn = postgres.IntegerColumn("tenant_id") + BalanceColumn = postgres.IntegerColumn("balance") CreatedAtColumn = postgres.TimestampColumn("created_at") - allColumns = postgres.ColumnList{IDColumn, UserIDColumn, TenantIDColumn, CreatedAtColumn} - mutableColumns = postgres.ColumnList{UserIDColumn, TenantIDColumn, CreatedAtColumn} + allColumns = postgres.ColumnList{IDColumn, UserIDColumn, TenantIDColumn, BalanceColumn, CreatedAtColumn} + mutableColumns = postgres.ColumnList{UserIDColumn, TenantIDColumn, BalanceColumn, CreatedAtColumn} ) return usersTenantsTable{ @@ -76,6 +78,7 @@ func newUsersTenantsTableImpl(schemaName, tableName, alias string) usersTenantsT ID: IDColumn, UserID: UserIDColumn, TenantID: TenantIDColumn, + Balance: BalanceColumn, CreatedAt: CreatedAtColumn, AllColumns: allColumns, diff --git a/backend/modules/medias/controller.go b/backend/modules/medias/controller.go index b2f31f4..96849fa 100644 --- a/backend/modules/medias/controller.go +++ b/backend/modules/medias/controller.go @@ -2,7 +2,6 @@ package medias import ( "backend/pkg/consts" - "backend/pkg/errorx" "backend/pkg/pg" "backend/providers/jwt" @@ -22,13 +21,13 @@ func (c *Controller) List(ctx fiber.Ctx) error { filter := ListFilter{} if err := ctx.Bind().Body(&filter); err != nil { log.WithError(err).Error("parse body failed") - return ctx.Status(fiber.StatusBadRequest).JSON(errorx.RequestParseError) + return err } claim := ctx.Locals(consts.CtxKeyClaim).(*jwt.Claims) items, err := c.svc.List(ctx.Context(), claim.TenantID, claim.UserID, &filter) if err != nil { - return ctx.Status(fiber.StatusInternalServerError).JSON(errorx.InternalError) + return err } return ctx.JSON(items) @@ -43,14 +42,14 @@ func (c *Controller) Show(ctx fiber.Ctx) error { model, err := c.svc.GetMediaByHash(ctx.Context(), claim.TenantID, hash) if err != nil { log.WithField("action", "medias.Show").WithError(err).Error("GetMediaByHash") - return ctx.Status(fiber.StatusInternalServerError).JSON(errorx.InternalError) + return err } resource := c.svc.ModelToListItem(ctx.Context(), model) resource.Bought, err = c.svc.HasUserBought(ctx.Context(), claim.TenantID, claim.UserID, model.ID) if err != nil { log.WithField("action", "medias.Show").WithError(err).Error("HasUserBought") - return ctx.Status(fiber.StatusInternalServerError).JSON(errorx.InternalError) + return err } return ctx.JSON(resource) @@ -60,7 +59,7 @@ func (c *Controller) Show(ctx fiber.Ctx) error { func (c *Controller) MediaIndex(ctx fiber.Ctx) error { mediaType, err := pg.ParseMediaType(ctx.Params("type")) if err != nil { - return ctx.Status(fiber.StatusInternalServerError).JSON(errorx.RequestParseError) + return err } hash := ctx.Params("hash") @@ -70,19 +69,19 @@ func (c *Controller) MediaIndex(ctx fiber.Ctx) error { model, err := c.svc.GetMediaByHash(ctx.Context(), claim.TenantID, hash) if err != nil { log.WithField("action", "medias.MediaIndex").WithError(err).Error("GetMediaByHash") - return ctx.Status(fiber.StatusInternalServerError).JSON(errorx.InternalError) + return err } bought, err := c.svc.HasUserBought(ctx.Context(), claim.TenantID, claim.UserID, model.ID) if err != nil { log.WithField("action", "medias.MediaIndex").WithError(err).Error("HasUserBought") - return ctx.Status(fiber.StatusInternalServerError).JSON(errorx.InternalError) + return err } playlist, err := c.svc.GetM3U8(ctx.Context(), claim.TenantID, mediaType, model.Hash, bought) if err != nil { log.WithField("action", "medias.MediaIndex").WithError(err).Error("GetMediaPlaylist") - return ctx.Status(fiber.StatusInternalServerError).JSON(errorx.InternalError) + return err } return ctx.SendString(playlist.String()) @@ -91,14 +90,14 @@ func (c *Controller) MediaIndex(ctx fiber.Ctx) error { func (c *Controller) MediaSegment(ctx fiber.Ctx) error { mediaType, err := pg.ParseMediaType(ctx.Params("type")) if err != nil { - return ctx.Status(fiber.StatusInternalServerError).JSON(errorx.RequestParseError) + return err } segment := ctx.Params("segment") segments, err := c.hashIds.DecodeInt64WithError(segment) if err != nil { log.WithField("action", "medias.MediaSegment").WithError(err).Error("DecodeInt64WithError") - return ctx.Status(fiber.StatusInternalServerError).JSON(errorx.RequestParseError) + return err } hash := ctx.Params("hash") @@ -108,9 +107,29 @@ func (c *Controller) MediaSegment(ctx fiber.Ctx) error { model, err := c.svc.GetMediaByHash(ctx.Context(), claim.TenantID, hash) if err != nil { log.WithField("action", "medias.MediaSegment").WithError(err).Error("GetMediaByHash") - return ctx.Status(fiber.StatusInternalServerError).JSON(errorx.InternalError) + return err } filepath := c.svc.GetSegmentPath(ctx.Context(), mediaType, model.Hash, segments[0]) return ctx.SendFile(filepath) } + +// Checkout +func (c *Controller) Checkout(ctx fiber.Ctx) error { + hash := ctx.Params("hash") + claim := fiber.Locals[*jwt.Claims](ctx, consts.CtxKeyClaim) + log.Debug(claim) + + model, err := c.svc.GetMediaByHash(ctx.Context(), claim.TenantID, hash) + if err != nil { + log.WithField("action", "medias.MediaSegment").WithError(err).Error("GetMediaByHash") + return err + } + + if err := c.svc.Checkout(ctx.Context(), claim.TenantID, claim.UserID, model.ID); err != nil { + log.WithField("action", "medias.MediaSegment").WithError(err).Error("Checkout") + return err + } + + return ctx.JSON(nil) +} diff --git a/backend/modules/medias/router.go b/backend/modules/medias/router.go index d8f1ad4..30d22fa 100755 --- a/backend/modules/medias/router.go +++ b/backend/modules/medias/router.go @@ -29,4 +29,5 @@ func (r *Router) Register(router fiber.Router) { group.Get(":hash", r.controller.Show) group.Get(":hash/:type", r.controller.MediaIndex) group.Get(":hash/:type/:segment.ts", r.controller.MediaSegment) + group.Get(":hash/checkout", r.controller.Checkout) } diff --git a/backend/modules/medias/service.go b/backend/modules/medias/service.go index 1c3ae4e..e56f598 100644 --- a/backend/modules/medias/service.go +++ b/backend/modules/medias/service.go @@ -11,6 +11,7 @@ import ( "backend/database/models/qvyun/public/model" "backend/database/models/qvyun/public/table" + "backend/pkg/errorx" "backend/pkg/media_store" "backend/pkg/path" "backend/pkg/pg" @@ -314,3 +315,94 @@ func (svc *Service) GetM3U8(ctx context.Context, tenantId int64, types pg.MediaT func (svc *Service) GetSegmentPath(ctx context.Context, t pg.MediaType, hash string, segment int64) string { return filepath.Join(svc.storageConfig.Path, hash, t.String(), fmt.Sprintf("%d.ts", segment)) } + +func (svc *Service) Checkout(ctx context.Context, tenantId, userId, mediaId int64) error { + log := svc.log.WithField("method", "Checkout") + + bought, err := svc.HasUserBought(ctx, tenantId, userId, mediaId) + if err != nil { + return errors.Wrap(err, "check user bought") + } + + if bought { + return nil + } + + media, err := svc.GetMediaByID(ctx, tenantId, userId, mediaId) + if err != nil { + return errors.Wrap(err, "get media") + } + + userBalance, err := svc.GetUserBalance(ctx, tenantId, userId) + if err != nil { + return errors.Wrap(err, "get user balance") + } + + if userBalance < media.Price { + return errorx.UserBalanceNotEnough + } + + tx, err := svc.db.Begin() + if err != nil { + return errors.Wrap(err, "begin transaction") + } + defer tx.Rollback() + + tbl := table.UserMedias + stmt := tbl. + INSERT(tbl.TenantID, tbl.UserID, tbl.MediaID, tbl.Price). + VALUES(Int(tenantId), Int(userId), Int(mediaId), Int(media.Price)) + log.Debug(stmt.DebugSql()) + + if _, err := stmt.ExecContext(ctx, tx); err != nil { + return errors.Wrap(err, "insert user media") + } + + // update user balance + tblUserTenants := table.UsersTenants + stmtUserTenants := tblUserTenants. + UPDATE(). + SET( + tblUserTenants.Balance.SET( + tblUserTenants.Balance.SUB(Int(media.Price)), + ), + ). + WHERE( + tblUserTenants.TenantID.EQ(Int(tenantId)).AND( + tblUserTenants.UserID.EQ(Int(userId)), + ), + ) + log.Debug(stmtUserTenants.DebugSql()) + + if _, err := stmtUserTenants.ExecContext(ctx, tx); err != nil { + return errors.Wrap(err, "update user balance") + } + + if err := tx.Commit(); err != nil { + return errors.Wrap(err, "commit transaction") + } + + return nil +} + +// GetUserBalance +func (svc *Service) GetUserBalance(ctx context.Context, tenantId, userId int64) (int64, error) { + log := svc.log.WithField("method", "GetUserBalance") + + tbl := table.UsersTenants + stmt := tbl.SELECT(tbl.Balance.AS("balance")).WHERE( + tbl.TenantID.EQ(Int(tenantId)).AND( + tbl.UserID.EQ(Int(userId)), + ), + ) + log.Debug(stmt.DebugSql()) + + var result struct { + Balance int64 + } + if err := stmt.QueryContext(ctx, svc.db, &result); err != nil { + return 0, errors.Wrap(err, "query user balance") + } + + return result.Balance, nil +} diff --git a/backend/modules/medias/service_test.go b/backend/modules/medias/service_test.go index b876317..10697d4 100644 --- a/backend/modules/medias/service_test.go +++ b/backend/modules/medias/service_test.go @@ -11,6 +11,7 @@ import ( "backend/providers/postgres" "backend/providers/storage" + log "github.com/sirupsen/logrus" . "github.com/smartystreets/goconvey/convey" "github.com/stretchr/testify/suite" "go.uber.org/dig" @@ -27,6 +28,7 @@ type ServiceTestSuite struct { } func Test_DiscoverMedias(t *testing.T) { + log.SetLevel(log.DebugLevel) providers := testx.Default( postgres.DefaultProvider(), storage.DefaultProvider(), @@ -66,3 +68,18 @@ func (t *ServiceTestSuite) Test_getMediaByHash() { So(ext, ShouldEqual, "ts") }) } + +func (t *ServiceTestSuite) Test_GetUserBalance() { + Convey("Test_GetUserBalance", t.T(), func() { + balance, err := t.Svc.GetUserBalance(context.Background(), 1, 1) + So(err, ShouldBeNil) + t.T().Logf("balance: %+v", balance) + }) +} + +func (t *ServiceTestSuite) Test_Checkout() { + Convey("Test_Checkout", t.T(), func() { + err := t.Svc.Checkout(context.TODO(), 1, 1, 1) + So(err, ShouldBeNil) + }) +} diff --git a/backend/modules/middlewares/mid_response.go b/backend/modules/middlewares/mid_response.go new file mode 100644 index 0000000..27fd1e9 --- /dev/null +++ b/backend/modules/middlewares/mid_response.go @@ -0,0 +1,30 @@ +package middlewares + +import ( + "backend/pkg/errorx" + + "github.com/gofiber/fiber/v3" + log "github.com/sirupsen/logrus" +) + +func (f *Middlewares) ProcessResponse(c fiber.Ctx) error { + if err := c.Next(); err != nil { + log.WithError(err).Error("process response error") + + if e, ok := err.(errorx.Response); ok { + return e.Response(c) + } + + if e, ok := err.(*fiber.Error); ok { + return errorx.Response{ + StatusCode: e.Code, + Code: e.Code, + Message: e.Message, + }.Response(c) + } + + return errorx.Wrap(err).Response(c) + + } + return c.Next() +} diff --git a/backend/pkg/dao.go b/backend/pkg/dao.go index a2d881d..df67d26 100755 --- a/backend/pkg/dao.go +++ b/backend/pkg/dao.go @@ -1,4 +1,4 @@ -package common +package pkg func WrapLike(v string) string { return "%" + v + "%" diff --git a/backend/pkg/data_structures.go b/backend/pkg/data_structures.go index 9b6aa0d..36623c5 100755 --- a/backend/pkg/data_structures.go +++ b/backend/pkg/data_structures.go @@ -1,4 +1,4 @@ -package common +package pkg import ( "strings" diff --git a/backend/pkg/errorx/error.go b/backend/pkg/errorx/error.go index f318711..0dc9ecf 100644 --- a/backend/pkg/errorx/error.go +++ b/backend/pkg/errorx/error.go @@ -2,18 +2,31 @@ package errorx import ( "fmt" + "net/http" + + "github.com/gofiber/fiber/v3" ) type Response struct { - Code int `json:"code"` - Message string `json:"message"` + StatusCode int `json:"-"` + Code int `json:"code"` + Message string `json:"message"` +} + +func Wrap(err error) Response { + return Response{http.StatusInternalServerError, http.StatusInternalServerError, err.Error()} } func (r Response) Error() string { - return fmt.Sprintf("%d: %s", r.Code, r.Message) + return fmt.Sprintf("[%d] %s", r.Code, r.Message) +} + +func (r Response) Response(ctx fiber.Ctx) error { + return ctx.Status(r.Code).JSON(r) } var ( - RequestParseError = Response{400, "请求解析错误"} - InternalError = Response{500, "内部错误"} + RequestParseError = Response{http.StatusBadRequest, http.StatusBadRequest, "请求解析错误"} + InternalError = Response{http.StatusInternalServerError, http.StatusInternalServerError, "内部错误"} + UserBalanceNotEnough = Response{http.StatusPaymentRequired, 1001, "余额不足,请充值"} ) diff --git a/backend/pkg/service/http/http.go b/backend/pkg/service/http/http.go index a9dca25..3b23a12 100644 --- a/backend/pkg/service/http/http.go +++ b/backend/pkg/service/http/http.go @@ -64,6 +64,7 @@ func Serve(cmd *cobra.Command, args []string) error { mid := http.Middlewares http.Service.Engine.Use(mid.DebugMode) + http.Service.Engine.Use(mid.ProcessResponse) http.Service.Engine.Use(mid.WeChatVerify) http.Service.Engine.Use(mid.WeChatAuthUserInfo) http.Service.Engine.Use(mid.WeChatSilentAuth) diff --git a/backend/pkg/service/tasks/tasks.go b/backend/pkg/service/tasks/tasks.go index cbd2ba2..61b1c3e 100644 --- a/backend/pkg/service/tasks/tasks.go +++ b/backend/pkg/service/tasks/tasks.go @@ -5,6 +5,7 @@ import ( "backend/modules/commands/store" "backend/modules/medias" "backend/providers/app" + "backend/providers/hashids" "backend/providers/postgres" "backend/providers/storage" @@ -18,6 +19,7 @@ func defaultProviders(providers ...container.ProviderContainer) container.Provid app.DefaultProvider(), storage.DefaultProvider(), postgres.DefaultProvider(), + hashids.DefaultProvider(), }, providers...) } diff --git a/backend/pkg/session.go b/backend/pkg/session.go deleted file mode 100644 index 805d0c7..0000000 --- a/backend/pkg/session.go +++ /dev/null @@ -1 +0,0 @@ -package common