feat: add medias

This commit is contained in:
Rogee
2024-12-02 17:48:46 +08:00
parent 2b4cfb1a1e
commit 9e7b35e3c9
17 changed files with 806 additions and 11 deletions

View File

@@ -32,6 +32,11 @@ lint:
proto: proto:
@buf generate @buf generate
.PHONY: fresh
fresh:
@go run . migrate down
@go run . migrate up
.PHONY: mup .PHONY: mup
mup: mup:
@go run . migrate up @go run . migrate up

View File

@@ -0,0 +1,19 @@
package errorx
import (
"fmt"
)
type Response struct {
Code int `json:"code"`
Message string `json:"message"`
}
func (r Response) Error() string {
return fmt.Sprintf("%d: %s", r.Code, r.Message)
}
var (
RequestParseError = Response{400, "请求解析错误"}
InternalError = Response{500, "内部错误"}
)

View File

@@ -2,3 +2,6 @@ ignores: [] # ignore tables
types: types:
users: # table name users: # table name
oauth: backend/pkg/pg.UserOAuth oauth: backend/pkg/pg.UserOAuth
media_resources: # table name
type: backend/pkg/pg.MediaType

View File

@@ -72,7 +72,7 @@ CREATE TABLE
title VARCHAR(198) NOT NULL, title VARCHAR(198) NOT NULL,
description VARCHAR(198) NOT NULL, description VARCHAR(198) NOT NULL,
price INT8 NOT NULL, price INT8 NOT NULL,
discount INT8 NOT NULL, discount INT8 NOT NULL default 100,
publish BOOL NOT NULL, publish BOOL NOT NULL,
created_at timestamp NOT NULL default now(), created_at timestamp NOT NULL default now(),
updated_at timestamp NOT NULL default now() updated_at timestamp NOT NULL default now()

View File

@@ -8,16 +8,17 @@
package model package model
import ( import (
"backend/pkg/pg"
"time" "time"
) )
type MediaResources struct { type MediaResources struct {
ID int64 `sql:"primary_key" json:"id"` ID int64 `sql:"primary_key" json:"id"`
MediaID int64 `json:"media_id"` MediaID int64 `json:"media_id"`
Type string `json:"type"` Type pg.MediaType `json:"type"`
Source *string `json:"source"` Source *string `json:"source"`
Size int64 `json:"size"` Size int64 `json:"size"`
Publish bool `json:"publish"` Publish bool `json:"publish"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }

View File

@@ -18,6 +18,7 @@ require (
github.com/sirupsen/logrus v1.9.3 github.com/sirupsen/logrus v1.9.3
github.com/smartystreets/goconvey v1.6.4 github.com/smartystreets/goconvey v1.6.4
github.com/speps/go-hashids/v2 v2.0.1 github.com/speps/go-hashids/v2 v2.0.1
github.com/spf13/cast v1.5.1
github.com/spf13/cobra v1.8.1 github.com/spf13/cobra v1.8.1
github.com/spf13/viper v1.17.0 github.com/spf13/viper v1.17.0
go.uber.org/dig v1.18.0 go.uber.org/dig v1.18.0
@@ -68,7 +69,6 @@ require (
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d // indirect github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d // indirect
github.com/sourcegraph/conc v0.3.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.10.0 // indirect github.com/spf13/afero v1.10.0 // indirect
github.com/spf13/cast v1.5.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/testify v1.9.0 // indirect github.com/stretchr/testify v1.9.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect

Binary file not shown.

View File

@@ -0,0 +1,30 @@
package medias
import (
"backend/common/errorx"
"github.com/gofiber/fiber/v3"
. "github.com/spf13/cast"
)
// @provider
type Controller struct {
svc *Service
}
// List
func (c *Controller) List(ctx fiber.Ctx) error {
filter := ListFilter{}
if err := ctx.Bind().Body(&filter); err != nil {
return ctx.Status(fiber.StatusBadRequest).JSON(errorx.RequestParseError)
}
tenantId, userId := ToInt64(ctx.Locals("tenantId")), ToInt64(ctx.Locals("userId"))
items, err := c.svc.List(ctx.Context(), tenantId, userId, &filter)
if err != nil {
return ctx.Status(fiber.StatusInternalServerError).JSON(errorx.InternalError)
}
return ctx.JSON(items)
}

View File

@@ -0,0 +1,18 @@
package medias
import (
"backend/database/models/qvyun/public/model"
"backend/pkg/db"
)
type ListFilter struct {
db.Pagination
Title *string `json:"title"`
Bought *bool `json:"bought"`
}
type ListItem struct {
model.Medias
Bought bool `json:"bought"`
MediaResources []model.MediaResources `json:"media_resources"`
}

View File

@@ -0,0 +1,54 @@
package medias
import (
"database/sql"
"backend/providers/http"
"git.ipao.vip/rogeecn/atom"
"git.ipao.vip/rogeecn/atom/container"
"git.ipao.vip/rogeecn/atom/contracts"
"git.ipao.vip/rogeecn/atom/utils/opt"
)
func Provide(opts ...opt.Option) error {
if err := container.Container.Provide(func(
svc *Service,
) (*Controller, error) {
obj := &Controller{
svc: svc,
}
return obj, nil
}); err != nil {
return err
}
if err := container.Container.Provide(func(
controller *Controller,
http *http.Service,
) (contracts.HttpRoute, error) {
obj := &Router{
controller: controller,
http: http,
}
return obj, nil
}, atom.GroupRoutes); err != nil {
return err
}
if err := container.Container.Provide(func(
db *sql.DB,
) (*Service, error) {
obj := &Service{
db: db,
}
if err := obj.Prepare(); err != nil {
return nil, err
}
return obj, nil
}); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,26 @@
package medias
import (
"backend/providers/http"
_ "git.ipao.vip/rogeecn/atom"
_ "git.ipao.vip/rogeecn/atom/contracts"
"github.com/gofiber/fiber/v3"
log "github.com/sirupsen/logrus"
)
// @provider:except contracts.HttpRoute atom.GroupRoutes
type Router struct {
http *http.Service
controller *Controller
}
func (r *Router) Register() error {
group := r.http.Engine.Group("medias")
log.Infof("register route group: %s", group.(*fiber.Group).Prefix)
group.Get("", r.controller.List)
group.Get("{id}", r.controller.List)
return nil
}

View File

@@ -0,0 +1,164 @@
package medias
import (
"context"
"database/sql"
"backend/database/models/qvyun/public/model"
"backend/database/models/qvyun/public/table"
. "github.com/go-jet/jet/v2/postgres"
"github.com/pkg/errors"
"github.com/samber/lo"
"github.com/sirupsen/logrus"
)
// @provider:except
type Service struct {
db *sql.DB
log *logrus.Entry `inject:"false"`
}
func (svc *Service) Prepare() error {
svc.log = logrus.WithField("module", "medias.service")
return nil
}
// GetByID
func (svc *Service) GetByID(ctx context.Context, id int64) (*ListItem, error) {
log := svc.log.WithField("method", "GetByID")
tbl := table.Medias
stmt := tbl.SELECT(tbl.AllColumns).WHERE(tbl.ID.EQ(Int(id)))
log.Debug(stmt.Sql())
var media ListItem
if err := stmt.QueryContext(ctx, svc.db, &media); err != nil {
return nil, errors.Wrap(err, "query media by id")
}
return &media, nil
}
// Decorate List Resources
func (svc *Service) DecorateListResources(ctx context.Context, tenantId, userId int64, items []ListItem) ([]ListItem, error) {
log := svc.log.WithField("method", "DecorateListResources")
mediaIDs := make([]int64, len(items))
for i, item := range items {
mediaIDs[i] = item.ID
}
tbl := table.MediaResources
stmt := tbl.
SELECT(
tbl.MediaID,
tbl.Type,
tbl.Size,
).
WHERE(tbl.MediaID.IN(lo.Map(mediaIDs, func(item int64, _ int) Expression {
return Int(item)
})...))
log.Debug(stmt.DebugSql())
var resources []model.MediaResources
if err := stmt.QueryContext(ctx, svc.db, &resources); err != nil {
return nil, errors.Wrap(err, "query media resources")
}
if len(resources) == 0 {
return nil, nil
}
// group resources by media id
resourcesMap := make(map[int64][]model.MediaResources)
for _, resource := range resources {
if _, ok := resourcesMap[resource.MediaID]; !ok {
resourcesMap[resource.MediaID] = make([]model.MediaResources, 0)
}
resourcesMap[resource.MediaID] = append(resourcesMap[resource.MediaID], resource)
}
// set resources to items
for i, item := range items {
if resources, ok := resourcesMap[item.ID]; ok {
items[i].MediaResources = resources
}
}
return items, nil
}
// List
func (svc *Service) List(ctx context.Context, tenantId, userId int64, filter *ListFilter) ([]ListItem, error) {
log := svc.log.WithField("method", "List")
tbl := table.Medias
stmt := tbl.
SELECT(tbl.AllColumns).
WHERE(tbl.TenantID.EQ(Int(tenantId))).
ORDER_BY(tbl.ID.DESC())
if filter.Title != nil && *filter.Title != "" {
stmt = stmt.WHERE(tbl.Title.LIKE(String("%" + *filter.Title + "%")))
}
if filter.Bought != nil && *filter.Bought {
boughtIDs, err := svc.GetUserBoughtMedias(ctx, tenantId, userId)
if err != nil {
return nil, errors.Wrap(err, "get user bought medias")
}
if len(boughtIDs) > 0 {
stmt = stmt.
WHERE(tbl.ID.IN(lo.Map(boughtIDs, func(item int64, _ int) Expression {
return Int(item)
})...))
}
} else {
stmt = stmt.WHERE(tbl.Publish.EQ(Bool(true)))
}
if filter.OffsetID > 0 {
if filter.Action == 0 {
stmt = stmt.WHERE(tbl.ID.GT(Int(filter.OffsetID)))
}
if filter.Action == 1 {
stmt = stmt.WHERE(tbl.ID.LT(Int(filter.OffsetID)))
stmt = stmt.LIMIT(10)
}
} else {
stmt = stmt.LIMIT(10)
}
log.Debug(stmt.DebugSql())
var dest []ListItem
if err := stmt.QueryContext(ctx, svc.db, &dest); err != nil {
return nil, errors.Wrap(err, "query medias")
}
return dest, nil
}
// GetUserBoughtMedias
func (svc *Service) GetUserBoughtMedias(ctx context.Context, tenant int64, userID int64) ([]int64, error) {
log := svc.log.WithField("method", "GetUserBoughtMedias")
tbl := table.UserMedias
stmt := tbl.
SELECT(tbl.MediaID).
WHERE(
tbl.TenantID.EQ(Int(tenant)).AND(
tbl.UserID.EQ(Int(userID)),
),
)
log.Debug(stmt.Sql())
var mediaIDs []int64
if err := stmt.QueryContext(ctx, svc.db, &mediaIDs); err != nil {
return nil, errors.Wrap(err, "query user bought medias")
}
return mediaIDs, nil
}

View File

@@ -0,0 +1,280 @@
package medias
import (
"context"
"testing"
"backend/database/models/qvyun/public/model"
"backend/database/models/qvyun/public/table"
"backend/fixtures"
dbUtil "backend/pkg/db"
"backend/pkg/pg"
. "github.com/go-jet/jet/v2/postgres"
"github.com/samber/lo"
. "github.com/smartystreets/goconvey/convey"
)
func TestService_GetUserBoughtMedias(t *testing.T) {
Convey("TestService_GetUserBoughtMedias", t, func() {
db, err := fixtures.GetDB()
So(err, ShouldBeNil)
defer db.Close()
So(dbUtil.TruncateAllTables(context.TODO(), db, "user_medias"), ShouldBeNil)
Convey("insert some data", func() {
items := []model.UserMedias{
{UserID: 1, TenantID: 1, MediaID: 1, Price: 10},
{UserID: 1, TenantID: 1, MediaID: 2, Price: 10},
{UserID: 1, TenantID: 1, MediaID: 3, Price: 10},
}
tbl := table.UserMedias
stmt := tbl.INSERT(tbl.UserID, tbl.TenantID, tbl.MediaID, tbl.Price).MODELS(items)
t.Log(stmt.DebugSql())
_, err := stmt.Exec(db)
So(err, ShouldBeNil)
Convey("get user bought medias", func() {
svc := &Service{db: db}
So(svc.Prepare(), ShouldBeNil)
ids, err := svc.GetUserBoughtMedias(context.TODO(), 1, 1)
So(err, ShouldBeNil)
for _, id := range ids {
So(lo.Contains([]int64{1, 2, 3}, id), ShouldBeTrue)
}
})
})
})
}
func TestService_List(t *testing.T) {
Convey("TestService_list", t, func() {
db, err := fixtures.GetDB()
So(err, ShouldBeNil)
defer db.Close()
Convey("truncate all tables", func() {
So(dbUtil.TruncateAllTables(context.TODO(), db, "medias", "media_resources", "user_medias"), ShouldBeNil)
})
Convey("insert user_medias data", func() {
items := []model.UserMedias{
{UserID: 1, TenantID: 1, MediaID: 1, Price: 10},
}
tbl := table.UserMedias
stmt := tbl.INSERT(tbl.UserID, tbl.TenantID, tbl.MediaID, tbl.Price).MODELS(items)
_, err := stmt.Exec(db)
So(err, ShouldBeNil)
})
Convey("insert medias data", func() {
items := []model.Medias{
{
TenantID: 1,
Title: "title1",
Description: "hello",
Price: 100,
Publish: true,
},
{
TenantID: 1,
Title: "title2",
Description: "hello",
Price: 100,
Publish: true,
},
{
TenantID: 1,
Title: "title3",
Description: "hello",
Price: 100,
Publish: false,
},
}
tbl := table.Medias
stmt := tbl.INSERT(
tbl.TenantID,
tbl.Title,
tbl.Description,
tbl.Price,
tbl.Publish,
).MODELS(items)
t.Log(stmt.DebugSql())
_, err := stmt.Exec(db)
So(err, ShouldBeNil)
})
Convey("create media's resources", func() {
items := []model.MediaResources{
{
MediaID: 1,
Type: pg.MediaTypeVideo,
Source: lo.ToPtr("http://www.baidu.com"),
Size: 100,
Publish: true,
},
{
MediaID: 1,
Type: pg.MediaTypeAudio,
Source: lo.ToPtr("http://www.baidu.com"),
Size: 100,
Publish: true,
},
{
MediaID: 2,
Type: pg.MediaTypeVideo,
Source: lo.ToPtr("http://www.baidu.com"),
Size: 100,
Publish: true,
},
{
MediaID: 2,
Type: pg.MediaTypeAudio,
Source: lo.ToPtr("http://www.baidu.com"),
Size: 100,
Publish: true,
},
{
MediaID: 3,
Type: pg.MediaTypeVideo,
Source: lo.ToPtr("http://www.baidu.com"),
Size: 100,
Publish: true,
},
{
MediaID: 3,
Type: pg.MediaTypeAudio,
Source: lo.ToPtr("http://www.baidu.com"),
Size: 100,
Publish: true,
},
}
tbl := table.MediaResources
stmt := tbl.INSERT(
tbl.MediaID,
tbl.Type,
tbl.Size,
tbl.Publish,
).MODELS(items)
t.Log(stmt.DebugSql())
_, err := stmt.Exec(db)
So(err, ShouldBeNil)
})
Convey("get list", func() {
svc := &Service{db: db}
So(svc.Prepare(), ShouldBeNil)
items, err := svc.List(context.TODO(), 1, 1, &ListFilter{
Bought: lo.ToPtr(true),
})
So(err, ShouldBeNil)
t.Logf("items: %+v", items)
So(items, ShouldHaveLength, 1)
})
})
}
func TestService_List1(t *testing.T) {
Convey("TestService_list", t, func() {
db, err := fixtures.GetDB()
So(err, ShouldBeNil)
defer db.Close()
tbl := table.Medias
stmt := tbl.
SELECT(tbl.AllColumns).
WHERE(
tbl.Publish.EQ(Bool(true)).AND(
tbl.TenantID.EQ(Int(1)),
),
).
WHERE(tbl.ID.EQ(Int(1))).
ORDER_BY(tbl.ID.DESC())
t.Log(stmt.DebugSql())
type list struct {
Media model.Medias `alias:"ListItem.*"`
}
var dest []ListItem
err = stmt.QueryContext(context.TODO(), db, &dest)
So(err, ShouldBeNil)
t.Logf("dest: %+v", dest)
})
}
func TestService_DecorateListResources(t *testing.T) {
Convey("TestService_DecorateListResources", t, func() {
db, err := fixtures.GetDB()
So(err, ShouldBeNil)
defer db.Close()
Convey("truncate all tables", func() {
So(dbUtil.TruncateAllTables(context.TODO(), db, "medias", "media_resources"), ShouldBeNil)
})
Convey("create media's resources", func() {
items := []model.MediaResources{
{
MediaID: 1,
Type: pg.MediaTypeVideo,
Source: lo.ToPtr("http://www.baidu.com"),
Size: 100,
Publish: true,
},
{
MediaID: 2,
Type: pg.MediaTypeAudio,
Source: lo.ToPtr("http://www.baidu.com"),
Size: 100,
Publish: true,
},
}
tbl := table.MediaResources
stmt := tbl.INSERT(
tbl.MediaID,
tbl.Type,
tbl.Size,
tbl.Publish,
).MODELS(items)
t.Log(stmt.DebugSql())
_, err := stmt.Exec(db)
So(err, ShouldBeNil)
})
Convey("decorate list resources", func() {
items := []ListItem{
{Medias: model.Medias{ID: 1}},
{Medias: model.Medias{ID: 2}},
}
svc := &Service{db: db}
So(svc.Prepare(), ShouldBeNil)
items, err := svc.DecorateListResources(context.TODO(), 1, 1, items)
So(err, ShouldBeNil)
for _, item := range items {
So(item.MediaResources, ShouldHaveLength, 1)
}
})
})
}

View File

@@ -19,7 +19,7 @@ func FromContext(ctx context.Context, db *sql.DB) qrm.DB {
func TruncateAllTables(ctx context.Context, db *sql.DB, tableName ...string) error { func TruncateAllTables(ctx context.Context, db *sql.DB, tableName ...string) error {
for _, name := range tableName { for _, name := range tableName {
sql := fmt.Sprintf("TRUNCATE TABLE %s CASCADE", name) sql := fmt.Sprintf("TRUNCATE TABLE %s RESTART IDENTITY", name)
if _, err := db.ExecContext(ctx, sql); err != nil { if _, err := db.ExecContext(ctx, sql); err != nil {
return err return err
} }

View File

@@ -0,0 +1,7 @@
package db
type Pagination struct {
Offset string `json:"offset"`
OffsetID int64 `json:"-"`
Action int `json:"action"` // action: 0 :加载更多 1:刷新
}

179
backend/pkg/pg/media.gen.go Normal file
View File

@@ -0,0 +1,179 @@
// Code generated by go-enum DO NOT EDIT.
// Version: -
// Revision: -
// Build Date: -
// Built By: -
package pg
import (
"database/sql/driver"
"errors"
"fmt"
"strings"
)
const (
// MediaTypeVideo is a MediaType of type Video.
MediaTypeVideo MediaType = "video"
// MediaTypeAudio is a MediaType of type Audio.
MediaTypeAudio MediaType = "audio"
// MediaTypePdf is a MediaType of type Pdf.
MediaTypePdf MediaType = "pdf"
)
var ErrInvalidMediaType = fmt.Errorf("not a valid MediaType, try [%s]", strings.Join(_MediaTypeNames, ", "))
var _MediaTypeNames = []string{
string(MediaTypeVideo),
string(MediaTypeAudio),
string(MediaTypePdf),
}
// MediaTypeNames returns a list of possible string values of MediaType.
func MediaTypeNames() []string {
tmp := make([]string, len(_MediaTypeNames))
copy(tmp, _MediaTypeNames)
return tmp
}
// MediaTypeValues returns a list of the values for MediaType
func MediaTypeValues() []MediaType {
return []MediaType{
MediaTypeVideo,
MediaTypeAudio,
MediaTypePdf,
}
}
// String implements the Stringer interface.
func (x MediaType) String() string {
return string(x)
}
// IsValid provides a quick way to determine if the typed value is
// part of the allowed enumerated values
func (x MediaType) IsValid() bool {
_, err := ParseMediaType(string(x))
return err == nil
}
var _MediaTypeValue = map[string]MediaType{
"video": MediaTypeVideo,
"audio": MediaTypeAudio,
"pdf": MediaTypePdf,
}
// ParseMediaType attempts to convert a string to a MediaType.
func ParseMediaType(name string) (MediaType, error) {
if x, ok := _MediaTypeValue[name]; ok {
return x, nil
}
return MediaType(""), fmt.Errorf("%s is %w", name, ErrInvalidMediaType)
}
var errMediaTypeNilPtr = errors.New("value pointer is nil") // one per type for package clashes
// Scan implements the Scanner interface.
func (x *MediaType) Scan(value interface{}) (err error) {
if value == nil {
*x = MediaType("")
return
}
// A wider range of scannable types.
// driver.Value values at the top of the list for expediency
switch v := value.(type) {
case string:
*x, err = ParseMediaType(v)
case []byte:
*x, err = ParseMediaType(string(v))
case MediaType:
*x = v
case *MediaType:
if v == nil {
return errMediaTypeNilPtr
}
*x = *v
case *string:
if v == nil {
return errMediaTypeNilPtr
}
*x, err = ParseMediaType(*v)
default:
return errors.New("invalid type for MediaType")
}
return
}
// Value implements the driver Valuer interface.
func (x MediaType) Value() (driver.Value, error) {
return x.String(), nil
}
// Set implements the Golang flag.Value interface func.
func (x *MediaType) Set(val string) error {
v, err := ParseMediaType(val)
*x = v
return err
}
// Get implements the Golang flag.Getter interface func.
func (x *MediaType) Get() interface{} {
return *x
}
// Type implements the github.com/spf13/pFlag Value interface.
func (x *MediaType) Type() string {
return "MediaType"
}
type NullMediaType struct {
MediaType MediaType
Valid bool
}
func NewNullMediaType(val interface{}) (x NullMediaType) {
err := x.Scan(val) // yes, we ignore this error, it will just be an invalid value.
_ = err // make any errcheck linters happy
return
}
// Scan implements the Scanner interface.
func (x *NullMediaType) Scan(value interface{}) (err error) {
if value == nil {
x.MediaType, x.Valid = MediaType(""), false
return
}
err = x.MediaType.Scan(value)
x.Valid = (err == nil)
return
}
// Value implements the driver Valuer interface.
func (x NullMediaType) Value() (driver.Value, error) {
if !x.Valid {
return nil, nil
}
// driver.Value accepts int64 for int values.
return string(x.MediaType), nil
}
type NullMediaTypeStr struct {
NullMediaType
}
func NewNullMediaTypeStr(val interface{}) (x NullMediaTypeStr) {
x.Scan(val) // yes, we ignore this error, it will just be an invalid value.
return
}
// Value implements the driver Valuer interface.
func (x NullMediaTypeStr) Value() (driver.Value, error) {
if !x.Valid {
return nil, nil
}
return x.MediaType.String(), nil
}

9
backend/pkg/pg/media.go Normal file
View File

@@ -0,0 +1,9 @@
package pg
// swagger:enum MediaType
// ENUM(
// Video = "video",
// Audio = "audio",
// Pdf = "pdf",
// )
type MediaType string