This commit is contained in:
2025-12-18 09:54:29 +08:00
parent 1eef314e98
commit 650ada9cc6
25 changed files with 3929 additions and 43 deletions

View File

@@ -0,0 +1,157 @@
package tenant
import (
"quyun/v2/app/errorx"
"quyun/v2/app/http/tenant/dto"
"quyun/v2/app/requests"
"quyun/v2/app/services"
"quyun/v2/database/models"
"quyun/v2/pkg/consts"
"github.com/gofiber/fiber/v3"
log "github.com/sirupsen/logrus"
)
// content provides tenant-side read-only content endpoints.
//
// @provider
type content struct{}
// list
//
// @Summary 内容列表(已发布)
// @Tags Tenant
// @Accept json
// @Produce json
// @Param tenant_code path string true "Tenant Code"
// @Param filter query dto.ContentListFilter true "Filter"
// @Success 200 {object} requests.Pager{items=dto.ContentItem}
//
// @Router /t/:tenant_code/v1/contents [get]
// @Bind tenant local key(tenant)
// @Bind user local key(user)
// @Bind filter query
func (*content) list(ctx fiber.Ctx, tenant *models.Tenant, user *models.User, filter *dto.ContentListFilter) (*requests.Pager, error) {
log.WithFields(log.Fields{
"tenant_id": tenant.ID,
"user_id": user.ID,
}).Info("tenant.contents.list")
filter.Pagination.Format()
return services.Content.ListPublished(ctx, tenant.ID, user.ID, filter)
}
// show
//
// @Summary 内容详情(可见性+权益校验)
// @Tags Tenant
// @Accept json
// @Produce json
// @Param tenant_code path string true "Tenant Code"
// @Param contentID path int64 true "ContentID"
// @Success 200 {object} dto.ContentDetail
//
// @Router /t/:tenant_code/v1/contents/:contentID [get]
// @Bind tenant local key(tenant)
// @Bind user local key(user)
// @Bind contentID path
func (*content) show(ctx fiber.Ctx, tenant *models.Tenant, user *models.User, contentID int64) (*dto.ContentDetail, error) {
log.WithFields(log.Fields{
"tenant_id": tenant.ID,
"user_id": user.ID,
"content_id": contentID,
}).Info("tenant.contents.show")
item, err := services.Content.Detail(ctx, tenant.ID, user.ID, contentID)
if err != nil {
return nil, err
}
return &dto.ContentDetail{
Content: item.Content,
Price: item.Price,
HasAccess: item.HasAccess,
}, nil
}
// previewAssets
//
// @Summary 获取试看资源preview role
// @Tags Tenant
// @Accept json
// @Produce json
// @Param tenant_code path string true "Tenant Code"
// @Param contentID path int64 true "ContentID"
// @Success 200 {object} dto.ContentAssetsResponse
//
// @Router /t/:tenant_code/v1/contents/:contentID/preview [get]
// @Bind tenant local key(tenant)
// @Bind user local key(user)
// @Bind contentID path
func (*content) previewAssets(ctx fiber.Ctx, tenant *models.Tenant, user *models.User, contentID int64) (*dto.ContentAssetsResponse, error) {
log.WithFields(log.Fields{
"tenant_id": tenant.ID,
"user_id": user.ID,
"content_id": contentID,
}).Info("tenant.contents.preview_assets")
detail, err := services.Content.Detail(ctx, tenant.ID, user.ID, contentID)
if err != nil {
return nil, err
}
assets, err := services.Content.AssetsByRole(ctx, tenant.ID, contentID, consts.ContentAssetRolePreview)
if err != nil {
return nil, err
}
previewSeconds := int32(detail.Content.PreviewSeconds)
if previewSeconds <= 0 {
previewSeconds = 60
}
return &dto.ContentAssetsResponse{
Content: detail.Content,
Assets: assets,
PreviewSeconds: previewSeconds,
}, nil
}
// mainAssets
//
// @Summary 获取正片资源main role需要已购或免费
// @Tags Tenant
// @Accept json
// @Produce json
// @Param tenant_code path string true "Tenant Code"
// @Param contentID path int64 true "ContentID"
// @Success 200 {object} dto.ContentAssetsResponse
//
// @Router /t/:tenant_code/v1/contents/:contentID/assets [get]
// @Bind tenant local key(tenant)
// @Bind user local key(user)
// @Bind contentID path
func (*content) mainAssets(ctx fiber.Ctx, tenant *models.Tenant, user *models.User, contentID int64) (*dto.ContentAssetsResponse, error) {
log.WithFields(log.Fields{
"tenant_id": tenant.ID,
"user_id": user.ID,
"content_id": contentID,
}).Info("tenant.contents.main_assets")
detail, err := services.Content.Detail(ctx, tenant.ID, user.ID, contentID)
if err != nil {
return nil, err
}
if !detail.HasAccess {
return nil, errorx.ErrPermissionDenied.WithMsg("未购买或无权限访问")
}
assets, err := services.Content.AssetsByRole(ctx, tenant.ID, contentID, consts.ContentAssetRoleMain)
if err != nil {
return nil, err
}
return &dto.ContentAssetsResponse{
Content: detail.Content,
Assets: assets,
}, nil
}

View File

@@ -0,0 +1,158 @@
package tenant
import (
"time"
"quyun/v2/app/errorx"
"quyun/v2/app/http/tenant/dto"
"quyun/v2/app/services"
"quyun/v2/database/models"
"quyun/v2/pkg/consts"
"github.com/gofiber/fiber/v3"
log "github.com/sirupsen/logrus"
)
// contentAdmin provides tenant-admin content management endpoints.
//
// @provider
type contentAdmin struct{}
func requireTenantAdmin(tenantUser *models.TenantUser) error {
if tenantUser == nil {
return errorx.ErrPermissionDenied
}
if !tenantUser.Role.Contains(consts.TenantUserRoleTenantAdmin) {
return errorx.ErrPermissionDenied
}
return nil
}
// create
//
// @Summary 创建内容(草稿)
// @Tags Tenant
// @Accept json
// @Produce json
// @Param tenant_code path string true "Tenant Code"
// @Param form body dto.ContentCreateForm true "Form"
// @Success 200 {object} models.Content
//
// @Router /t/:tenant_code/v1/admin/contents [post]
// @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user)
// @Bind form body
func (*contentAdmin) create(ctx fiber.Ctx, tenant *models.Tenant, tenantUser *models.TenantUser, form *dto.ContentCreateForm) (*models.Content, error) {
if err := requireTenantAdmin(tenantUser); err != nil {
return nil, err
}
log.WithFields(log.Fields{
"tenant_id": tenant.ID,
"user_id": tenantUser.UserID,
}).Info("tenant.admin.contents.create")
return services.Content.Create(ctx, tenant.ID, tenantUser.UserID, form)
}
// update
//
// @Summary 更新内容(标题/描述/状态等)
// @Tags Tenant
// @Accept json
// @Produce json
// @Param tenant_code path string true "Tenant Code"
// @Param contentID path int64 true "ContentID"
// @Param form body dto.ContentUpdateForm true "Form"
// @Success 200 {object} models.Content
//
// @Router /t/:tenant_code/v1/admin/contents/:contentID [patch]
// @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user)
// @Bind contentID path
// @Bind form body
func (*contentAdmin) update(ctx fiber.Ctx, tenant *models.Tenant, tenantUser *models.TenantUser, contentID int64, form *dto.ContentUpdateForm) (*models.Content, error) {
if err := requireTenantAdmin(tenantUser); err != nil {
return nil, err
}
log.WithFields(log.Fields{
"tenant_id": tenant.ID,
"user_id": tenantUser.UserID,
"content_id": contentID,
}).Info("tenant.admin.contents.update")
return services.Content.Update(ctx, tenant.ID, tenantUser.UserID, contentID, form)
}
// upsertPrice
//
// @Summary 设置内容价格与折扣
// @Tags Tenant
// @Accept json
// @Produce json
// @Param tenant_code path string true "Tenant Code"
// @Param contentID path int64 true "ContentID"
// @Param form body dto.ContentPriceUpsertForm true "Form"
// @Success 200 {object} models.ContentPrice
//
// @Router /t/:tenant_code/v1/admin/contents/:contentID/price [put]
// @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user)
// @Bind contentID path
// @Bind form body
func (*contentAdmin) upsertPrice(ctx fiber.Ctx, tenant *models.Tenant, tenantUser *models.TenantUser, contentID int64, form *dto.ContentPriceUpsertForm) (*models.ContentPrice, error) {
if err := requireTenantAdmin(tenantUser); err != nil {
return nil, err
}
log.WithFields(log.Fields{
"tenant_id": tenant.ID,
"user_id": tenantUser.UserID,
"content_id": contentID,
}).Info("tenant.admin.contents.upsert_price")
return services.Content.UpsertPrice(ctx, tenant.ID, tenantUser.UserID, contentID, form)
}
// attachAsset
//
// @Summary 绑定媒体资源到内容main/cover/preview
// @Tags Tenant
// @Accept json
// @Produce json
// @Param tenant_code path string true "Tenant Code"
// @Param contentID path int64 true "ContentID"
// @Param form body dto.ContentAssetAttachForm true "Form"
// @Success 200 {object} models.ContentAsset
//
// @Router /t/:tenant_code/v1/admin/contents/:contentID/assets [post]
// @Bind tenant local key(tenant)
// @Bind tenantUser local key(tenant_user)
// @Bind contentID path
// @Bind form body
func (*contentAdmin) attachAsset(ctx fiber.Ctx, tenant *models.Tenant, tenantUser *models.TenantUser, contentID int64, form *dto.ContentAssetAttachForm) (*models.ContentAsset, error) {
if err := requireTenantAdmin(tenantUser); err != nil {
return nil, err
}
log.WithFields(log.Fields{
"tenant_id": tenant.ID,
"user_id": tenantUser.UserID,
"content_id": contentID,
"asset_id": form.AssetID,
"role": form.Role,
}).Info("tenant.admin.contents.attach_asset")
role := form.Role
if role == "" {
role = consts.ContentAssetRoleMain
}
sort := int32(0)
if form.Sort > 0 {
sort = form.Sort
}
return services.Content.AttachAsset(ctx, tenant.ID, tenantUser.UserID, contentID, form.AssetID, role, sort, time.Now())
}

View File

@@ -0,0 +1,43 @@
package dto
import (
"quyun/v2/app/requests"
"quyun/v2/database/models"
)
type ContentListFilter struct {
// Pagination controls paging parameters (page/limit).
requests.Pagination `json:",inline" query:",inline"`
// Keyword filters by title keyword (LIKE).
Keyword *string `json:"keyword,omitempty" query:"keyword"`
}
// ContentItem is a list item with price snapshot and access indicator for current user.
type ContentItem struct {
// Content is the content entity.
Content *models.Content `json:"content,omitempty"`
// Price is the current price settings for the content (may be nil if not set).
Price *models.ContentPrice `json:"price,omitempty"`
// HasAccess indicates whether current user can access main assets (free/owner/purchased).
HasAccess bool `json:"has_access"`
}
// ContentDetail is the detail payload with price snapshot and access indicator for current user.
type ContentDetail struct {
// Content is the content entity.
Content *models.Content `json:"content,omitempty"`
// Price is the current price settings for the content (may be nil if not set).
Price *models.ContentPrice `json:"price,omitempty"`
// HasAccess indicates whether current user can access main assets (free/owner/purchased).
HasAccess bool `json:"has_access"`
}
// ContentAssetsResponse returns assets for either preview or main role.
type ContentAssetsResponse struct {
// Content is the content entity.
Content *models.Content `json:"content,omitempty"`
// Assets is the list of media assets for the requested role (preview/main).
Assets []*models.MediaAsset `json:"assets,omitempty"`
// PreviewSeconds indicates the max preview duration (only meaningful for preview response).
PreviewSeconds int32 `json:"preview_seconds,omitempty"`
}

View File

@@ -0,0 +1,58 @@
package dto
import (
"time"
"quyun/v2/pkg/consts"
)
type ContentCreateForm struct {
// Title is the content title.
Title string `json:"title,omitempty"`
// Description is the content description.
Description string `json:"description,omitempty"`
// Visibility controls who can view the content detail (main assets still require free/purchase).
Visibility consts.ContentVisibility `json:"visibility,omitempty"`
// PreviewSeconds controls preview duration (defaults to 60 when omitted).
PreviewSeconds *int32 `json:"preview_seconds,omitempty"`
}
// ContentUpdateForm updates mutable fields of a content.
type ContentUpdateForm struct {
// Title updates the title when provided.
Title *string `json:"title,omitempty"`
// Description updates the description when provided.
Description *string `json:"description,omitempty"`
// Visibility updates the visibility when provided.
Visibility *consts.ContentVisibility `json:"visibility,omitempty"`
// Status updates the content status when provided (e.g. publish/unpublish).
Status *consts.ContentStatus `json:"status,omitempty"`
// PreviewSeconds updates preview duration when provided (must be > 0).
PreviewSeconds *int32 `json:"preview_seconds,omitempty"`
}
// ContentPriceUpsertForm upserts pricing and discount settings for a content.
type ContentPriceUpsertForm struct {
// PriceAmount is the base price in cents (CNY 分).
PriceAmount int64 `json:"price_amount,omitempty"`
// Currency is fixed to CNY for now.
Currency consts.Currency `json:"currency,omitempty"`
// DiscountType defines the discount algorithm (none/percent/amount).
DiscountType consts.DiscountType `json:"discount_type,omitempty"`
// DiscountValue is interpreted based on DiscountType.
DiscountValue int64 `json:"discount_value,omitempty"`
// DiscountStartAt enables discount from this time (optional).
DiscountStartAt *time.Time `json:"discount_start_at,omitempty"`
// DiscountEndAt disables discount after this time (optional).
DiscountEndAt *time.Time `json:"discount_end_at,omitempty"`
}
// ContentAssetAttachForm attaches a media asset to a content with a role and sort order.
type ContentAssetAttachForm struct {
// AssetID is the media asset id to attach.
AssetID int64 `json:"asset_id,omitempty"`
// Role indicates how this asset is used (main/cover/preview).
Role consts.ContentAssetRole `json:"role,omitempty"`
// Sort controls ordering under the same role.
Sort int32 `json:"sort,omitempty"`
}

View File

@@ -2,8 +2,12 @@ package dto
import "quyun/v2/database/models"
// MeResponse returns the resolved tenant context for the current request.
type MeResponse struct {
Tenant *models.Tenant `json:"tenant,omitempty"`
User *models.User `json:"user,omitempty"`
// Tenant is the resolved tenant by `tenant_code`.
Tenant *models.Tenant `json:"tenant,omitempty"`
// User is the authenticated user derived from JWT `user_id`.
User *models.User `json:"user,omitempty"`
// TenantUser is the membership record of the authenticated user within the tenant.
TenantUser *models.TenantUser `json:"tenant_user,omitempty"`
}

View File

@@ -7,6 +7,8 @@ import (
"github.com/gofiber/fiber/v3"
)
// me provides tenant context introspection endpoints (current tenant/user/tenant_user).
//
// @provider
type me struct{}

View File

@@ -10,6 +10,20 @@ import (
)
func Provide(opts ...opt.Option) error {
if err := container.Container.Provide(func() (*content, error) {
obj := &content{}
return obj, nil
}); err != nil {
return err
}
if err := container.Container.Provide(func() (*contentAdmin, error) {
obj := &contentAdmin{}
return obj, nil
}); err != nil {
return err
}
if err := container.Container.Provide(func() (*me, error) {
obj := &me{}
@@ -18,12 +32,16 @@ func Provide(opts ...opt.Option) error {
return err
}
if err := container.Container.Provide(func(
content *content,
contentAdmin *contentAdmin,
me *me,
middlewares *middlewares.Middlewares,
) (contracts.HttpRoute, error) {
obj := &Routes{
me: me,
middlewares: middlewares,
content: content,
contentAdmin: contentAdmin,
me: me,
middlewares: middlewares,
}
if err := obj.Prepare(); err != nil {
return nil, err

View File

@@ -5,6 +5,7 @@
package tenant
import (
"quyun/v2/app/http/tenant/dto"
"quyun/v2/app/middlewares"
"quyun/v2/database/models"
@@ -23,7 +24,9 @@ type Routes struct {
log *log.Entry `inject:"false"`
middlewares *middlewares.Middlewares
// Controller instances
me *me
content *content
contentAdmin *contentAdmin
me *me
}
// Prepare initializes the routes provider with logging configuration.
@@ -41,6 +44,67 @@ func (r *Routes) Name() string {
// Register registers all HTTP routes with the provided fiber router.
// Each route is registered with its corresponding controller action and parameter bindings.
func (r *Routes) Register(router fiber.Router) {
// Register routes for controller: content
r.log.Debugf("Registering route: Get /t/:tenant_code/v1/contents -> content.list")
router.Get("/t/:tenant_code/v1/contents"[len(r.Path()):], DataFunc3(
r.content.list,
Local[*models.Tenant]("tenant"),
Local[*models.User]("user"),
Query[dto.ContentListFilter]("filter"),
))
r.log.Debugf("Registering route: Get /t/:tenant_code/v1/contents/:contentID -> content.show")
router.Get("/t/:tenant_code/v1/contents/:contentID"[len(r.Path()):], DataFunc3(
r.content.show,
Local[*models.Tenant]("tenant"),
Local[*models.User]("user"),
PathParam[int64]("contentID"),
))
r.log.Debugf("Registering route: Get /t/:tenant_code/v1/contents/:contentID/assets -> content.mainAssets")
router.Get("/t/:tenant_code/v1/contents/:contentID/assets"[len(r.Path()):], DataFunc3(
r.content.mainAssets,
Local[*models.Tenant]("tenant"),
Local[*models.User]("user"),
PathParam[int64]("contentID"),
))
r.log.Debugf("Registering route: Get /t/:tenant_code/v1/contents/:contentID/preview -> content.previewAssets")
router.Get("/t/:tenant_code/v1/contents/:contentID/preview"[len(r.Path()):], DataFunc3(
r.content.previewAssets,
Local[*models.Tenant]("tenant"),
Local[*models.User]("user"),
PathParam[int64]("contentID"),
))
// Register routes for controller: contentAdmin
r.log.Debugf("Registering route: Patch /t/:tenant_code/v1/admin/contents/:contentID -> contentAdmin.update")
router.Patch("/t/:tenant_code/v1/admin/contents/:contentID"[len(r.Path()):], DataFunc4(
r.contentAdmin.update,
Local[*models.Tenant]("tenant"),
Local[*models.TenantUser]("tenant_user"),
PathParam[int64]("contentID"),
Body[dto.ContentUpdateForm]("form"),
))
r.log.Debugf("Registering route: Post /t/:tenant_code/v1/admin/contents -> contentAdmin.create")
router.Post("/t/:tenant_code/v1/admin/contents"[len(r.Path()):], DataFunc3(
r.contentAdmin.create,
Local[*models.Tenant]("tenant"),
Local[*models.TenantUser]("tenant_user"),
Body[dto.ContentCreateForm]("form"),
))
r.log.Debugf("Registering route: Post /t/:tenant_code/v1/admin/contents/:contentID/assets -> contentAdmin.attachAsset")
router.Post("/t/:tenant_code/v1/admin/contents/:contentID/assets"[len(r.Path()):], DataFunc4(
r.contentAdmin.attachAsset,
Local[*models.Tenant]("tenant"),
Local[*models.TenantUser]("tenant_user"),
PathParam[int64]("contentID"),
Body[dto.ContentAssetAttachForm]("form"),
))
r.log.Debugf("Registering route: Put /t/:tenant_code/v1/admin/contents/:contentID/price -> contentAdmin.upsertPrice")
router.Put("/t/:tenant_code/v1/admin/contents/:contentID/price"[len(r.Path()):], DataFunc4(
r.contentAdmin.upsertPrice,
Local[*models.Tenant]("tenant"),
Local[*models.TenantUser]("tenant_user"),
PathParam[int64]("contentID"),
Body[dto.ContentPriceUpsertForm]("form"),
))
// Register routes for controller: me
r.log.Debugf("Registering route: Get /t/:tenant_code/v1/me -> me.get")
router.Get("/t/:tenant_code/v1/me"[len(r.Path()):], DataFunc3(

View File

@@ -5,9 +5,13 @@ import (
"quyun/v2/providers/jwt"
)
// Middlewares provides reusable Fiber middlewares shared across modules.
//
// @provider
type Middlewares struct {
// log is the module logger injected by the framework.
log *log.Entry `inject:"false"`
// jwt is the JWT provider used by auth-related middlewares.
jwt *jwt.JWT
}

View File

@@ -1,6 +1,7 @@
package middlewares
import (
"go.ipao.vip/atom/container"
"quyun/v2/app/errorx"
"quyun/v2/app/services"
"quyun/v2/database/models"
@@ -8,7 +9,6 @@ import (
"quyun/v2/providers/jwt"
"github.com/gofiber/fiber/v3"
"github.com/sirupsen/logrus"
)
func (f *Middlewares) TenantResolve(c fiber.Ctx) error {
@@ -19,9 +19,15 @@ func (f *Middlewares) TenantResolve(c fiber.Ctx) error {
tenantModel, err := services.Tenant.FindByCode(c, tenantCode)
if err != nil {
f.log.WithField("tenant_code", tenantCode).WithError(err).Warn("middlewares.tenant.resolve.failed")
return err
}
f.log.WithFields(map[string]any{
"tenant_id": tenantModel.ID,
"tenant_code": tenantCode,
}).Info("middlewares.tenant.resolve.ok")
c.Locals(consts.CtxKeyTenant, tenantModel)
return c.Next()
}
@@ -29,12 +35,24 @@ func (f *Middlewares) TenantResolve(c fiber.Ctx) error {
func (f *Middlewares) TenantAuth(c fiber.Ctx) error {
authHeader := c.Get(jwt.HttpHeader)
if authHeader == "" {
f.log.Info("middlewares.tenant.auth.missing_token")
return errorx.ErrTokenMissing
}
logrus.Infof("Token: %s", authHeader)
claims, err := f.jwt.Parse(authHeader)
jwtProvider := f.jwt
if jwtProvider == nil {
if err := container.Container.Invoke(func(j *jwt.JWT) {
jwtProvider = j
f.jwt = j
}); err != nil {
f.log.WithError(err).Error("middlewares.tenant.auth.jwt_provider_missing")
return errorx.ErrInternalError.WithMsg("jwt provider missing")
}
}
claims, err := jwtProvider.Parse(authHeader)
if err != nil {
f.log.WithError(err).Warn("middlewares.tenant.auth.invalid_token")
switch err {
case jwt.TokenExpired:
return errorx.ErrTokenExpired
@@ -45,9 +63,14 @@ func (f *Middlewares) TenantAuth(c fiber.Ctx) error {
}
}
if claims.UserID == 0 {
f.log.Warn("middlewares.tenant.auth.missing_user_id")
return errorx.ErrTokenInvalid
}
f.log.WithFields(map[string]any{
"user_id": claims.UserID,
}).Info("middlewares.tenant.auth.ok")
c.Locals(consts.CtxKeyClaims, claims)
return c.Next()
}
@@ -55,24 +78,36 @@ func (f *Middlewares) TenantAuth(c fiber.Ctx) error {
func (f *Middlewares) TenantRequireMember(c fiber.Ctx) error {
tenantModel, ok := c.Locals(consts.CtxKeyTenant).(*models.Tenant)
if !ok || tenantModel == nil {
f.log.Error("middlewares.tenant.require_member.missing_tenant_context")
return errorx.ErrInternalError.WithMsg("tenant context missing")
}
claims, ok := c.Locals(consts.CtxKeyClaims).(*jwt.Claims)
if !ok || claims == nil {
f.log.Error("middlewares.tenant.require_member.missing_claims_context")
return errorx.ErrInternalError.WithMsg("claims context missing")
}
tenantUser, err := services.Tenant.FindTenantUser(c, tenantModel.ID, claims.UserID)
if err != nil {
f.log.WithFields(map[string]any{
"tenant_id": tenantModel.ID,
"user_id": claims.UserID,
}).WithError(err).Warn("middlewares.tenant.require_member.denied")
return errorx.ErrPermissionDenied.WithMsg("不属于该租户")
}
userModel, err := services.User.FindByID(c, claims.UserID)
if err != nil {
f.log.WithField("user_id", claims.UserID).WithError(err).Warn("middlewares.tenant.require_member.load_user_failed")
return err
}
f.log.WithFields(map[string]any{
"tenant_id": tenantModel.ID,
"user_id": claims.UserID,
}).Info("middlewares.tenant.require_member.ok")
c.Locals(consts.CtxKeyTenantUser, tenantUser)
c.Locals(consts.CtxKeyUser, userModel)
return c.Next()

View File

@@ -0,0 +1,416 @@
package services
import (
"context"
"errors"
"time"
"quyun/v2/app/http/tenant/dto"
"quyun/v2/app/requests"
"quyun/v2/database"
"quyun/v2/database/models"
"quyun/v2/pkg/consts"
pkgerrors "github.com/pkg/errors"
"github.com/samber/lo"
log "github.com/sirupsen/logrus"
"go.ipao.vip/gen"
"gorm.io/gorm"
)
// content implements content-related domain operations.
//
// @provider
type content struct{}
// ContentDetailResult is the internal detail result used by controllers.
type ContentDetailResult struct {
// Content is the content entity.
Content *models.Content
// Price is the price settings (may be nil).
Price *models.ContentPrice
// HasAccess indicates whether the user can access main assets.
HasAccess bool
}
func (s *content) Create(ctx context.Context, tenantID, userID int64, form *dto.ContentCreateForm) (*models.Content, error) {
log.WithFields(log.Fields{
"tenant_id": tenantID,
"user_id": userID,
}).Info("services.content.create")
visibility := form.Visibility
if visibility == "" {
visibility = consts.ContentVisibilityTenantOnly
}
previewSeconds := int32(60)
if form.PreviewSeconds != nil && *form.PreviewSeconds > 0 {
previewSeconds = *form.PreviewSeconds
}
m := &models.Content{
TenantID: tenantID,
UserID: userID,
Title: form.Title,
Description: form.Description,
Status: consts.ContentStatusDraft,
Visibility: visibility,
PreviewSeconds: previewSeconds,
PreviewDownloadable: false,
}
if err := m.Create(ctx); err != nil {
return nil, pkgerrors.Wrap(err, "create content failed")
}
return m, nil
}
func (s *content) Update(ctx context.Context, tenantID, userID, contentID int64, form *dto.ContentUpdateForm) (*models.Content, error) {
log.WithFields(log.Fields{
"tenant_id": tenantID,
"user_id": userID,
"content_id": contentID,
}).Info("services.content.update")
tbl, query := models.ContentQuery.QueryContext(ctx)
m, err := query.Where(
tbl.TenantID.Eq(tenantID),
tbl.ID.Eq(contentID),
).First()
if err != nil {
return nil, pkgerrors.Wrap(err, "content not found")
}
if form.Title != nil {
m.Title = *form.Title
}
if form.Description != nil {
m.Description = *form.Description
}
if form.Visibility != nil {
m.Visibility = *form.Visibility
}
if form.PreviewSeconds != nil && *form.PreviewSeconds > 0 {
m.PreviewSeconds = *form.PreviewSeconds
m.PreviewDownloadable = false
}
if form.Status != nil {
m.Status = *form.Status
if m.Status == consts.ContentStatusPublished && m.PublishedAt.IsZero() {
m.PublishedAt = time.Now()
}
}
if _, err := m.Update(ctx); err != nil {
return nil, pkgerrors.Wrap(err, "update content failed")
}
return m, nil
}
func (s *content) UpsertPrice(ctx context.Context, tenantID, userID, contentID int64, form *dto.ContentPriceUpsertForm) (*models.ContentPrice, error) {
log.WithFields(log.Fields{
"tenant_id": tenantID,
"user_id": userID,
"content_id": contentID,
"amount": form.PriceAmount,
}).Info("services.content.upsert_price")
currency := form.Currency
if currency == "" {
currency = consts.CurrencyCNY
}
discountType := form.DiscountType
if discountType == "" {
discountType = consts.DiscountTypeNone
}
startAt := time.Time{}
if form.DiscountStartAt != nil {
startAt = *form.DiscountStartAt
}
endAt := time.Time{}
if form.DiscountEndAt != nil {
endAt = *form.DiscountEndAt
}
tbl, query := models.ContentPriceQuery.QueryContext(ctx)
m, err := query.Where(
tbl.TenantID.Eq(tenantID),
tbl.ContentID.Eq(contentID),
).First()
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, pkgerrors.Wrap(err, "find content price failed")
}
if errors.Is(err, gorm.ErrRecordNotFound) {
m = &models.ContentPrice{
TenantID: tenantID,
UserID: userID,
ContentID: contentID,
Currency: currency,
PriceAmount: form.PriceAmount,
DiscountType: discountType,
DiscountValue: form.DiscountValue,
DiscountStartAt: startAt,
DiscountEndAt: endAt,
}
if err := m.Create(ctx); err != nil {
return nil, pkgerrors.Wrap(err, "create content price failed")
}
return m, nil
}
m.UserID = userID
m.Currency = currency
m.PriceAmount = form.PriceAmount
m.DiscountType = discountType
m.DiscountValue = form.DiscountValue
m.DiscountStartAt = startAt
m.DiscountEndAt = endAt
if _, err := m.Update(ctx); err != nil {
return nil, pkgerrors.Wrap(err, "update content price failed")
}
return m, nil
}
func (s *content) AttachAsset(ctx context.Context, tenantID, userID, contentID, assetID int64, role consts.ContentAssetRole, sort int32, now time.Time) (*models.ContentAsset, error) {
log.WithFields(log.Fields{
"tenant_id": tenantID,
"user_id": userID,
"content_id": contentID,
"asset_id": assetID,
"role": role,
"sort": sort,
}).Info("services.content.attach_asset")
m := &models.ContentAsset{
TenantID: tenantID,
UserID: userID,
ContentID: contentID,
AssetID: assetID,
Role: role,
Sort: sort,
CreatedAt: now,
UpdatedAt: now,
}
if err := m.Create(ctx); err != nil {
return nil, pkgerrors.Wrap(err, "attach content asset failed")
}
return m, nil
}
func (s *content) ListPublished(ctx context.Context, tenantID, userID int64, filter *dto.ContentListFilter) (*requests.Pager, error) {
log.WithFields(log.Fields{
"tenant_id": tenantID,
"user_id": userID,
"page": filter.Page,
"limit": filter.Limit,
}).Info("services.content.list_published")
tbl, query := models.ContentQuery.QueryContext(ctx)
conds := []gen.Condition{
tbl.TenantID.Eq(tenantID),
tbl.Status.Eq(consts.ContentStatusPublished),
tbl.Visibility.In(consts.ContentVisibilityPublic, consts.ContentVisibilityTenantOnly),
}
if filter.Keyword != nil && *filter.Keyword != "" {
conds = append(conds, tbl.Title.Like(database.WrapLike(*filter.Keyword)))
}
filter.Pagination.Format()
items, total, err := query.Where(conds...).Order(tbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit))
if err != nil {
return nil, err
}
contentIDs := lo.Map(items, func(item *models.Content, _ int) int64 { return item.ID })
priceByContent, err := s.contentPriceMapping(ctx, tenantID, contentIDs)
if err != nil {
return nil, err
}
accessSet, err := s.accessSet(ctx, tenantID, userID, contentIDs)
if err != nil {
return nil, err
}
respItems := lo.Map(items, func(model *models.Content, _ int) *dto.ContentItem {
price := priceByContent[model.ID]
free := price == nil || price.PriceAmount == 0
has := free || accessSet[model.ID] || model.UserID == userID
return &dto.ContentItem{
Content: model,
Price: price,
HasAccess: has,
}
})
return &requests.Pager{
Pagination: filter.Pagination,
Total: total,
Items: respItems,
}, nil
}
func (s *content) Detail(ctx context.Context, tenantID, userID, contentID int64) (*ContentDetailResult, error) {
log.WithFields(log.Fields{
"tenant_id": tenantID,
"user_id": userID,
"content_id": contentID,
}).Info("services.content.detail")
tbl, query := models.ContentQuery.QueryContext(ctx)
model, err := query.Where(
tbl.TenantID.Eq(tenantID),
tbl.ID.Eq(contentID),
).First()
if err != nil {
return nil, pkgerrors.Wrapf(err, "content not found, tenantID=%d, contentID=%d", tenantID, contentID)
}
if model.Status != consts.ContentStatusPublished && model.UserID != userID {
return nil, errors.New("content is not published")
}
price, err := s.contentPrice(ctx, tenantID, contentID)
if err != nil {
return nil, err
}
free := price == nil || price.PriceAmount == 0
canView := false
switch model.Visibility {
case consts.ContentVisibilityPublic, consts.ContentVisibilityTenantOnly:
canView = true
case consts.ContentVisibilityPrivate:
canView = model.UserID == userID
default:
canView = false
}
hasAccess := model.UserID == userID || free
if !hasAccess {
ok, err := s.HasAccess(ctx, tenantID, userID, contentID)
if err != nil {
return nil, err
}
hasAccess = ok
canView = canView || ok
}
if !canView {
return nil, errors.New("content is private")
}
return &ContentDetailResult{
Content: model,
Price: price,
HasAccess: hasAccess,
}, nil
}
func (s *content) HasAccess(ctx context.Context, tenantID, userID, contentID int64) (bool, error) {
log.WithFields(log.Fields{
"tenant_id": tenantID,
"user_id": userID,
"content_id": contentID,
}).Info("services.content.has_access")
tbl, query := models.ContentAccessQuery.QueryContext(ctx)
_, err := query.Where(
tbl.TenantID.Eq(tenantID),
tbl.UserID.Eq(userID),
tbl.ContentID.Eq(contentID),
tbl.Status.Eq(consts.ContentAccessStatusActive),
).First()
if err != nil {
return false, nil
}
return true, nil
}
func (s *content) AssetsByRole(ctx context.Context, tenantID, contentID int64, role consts.ContentAssetRole) ([]*models.MediaAsset, error) {
log.WithFields(log.Fields{
"tenant_id": tenantID,
"content_id": contentID,
"role": role,
}).Info("services.content.assets_by_role")
maTbl, maQuery := models.MediaAssetQuery.QueryContext(ctx)
caTbl, _ := models.ContentAssetQuery.QueryContext(ctx)
assets, err := maQuery.
LeftJoin(caTbl, caTbl.AssetID.EqCol(maTbl.ID)).
Select(maTbl.ALL).
Where(
maTbl.TenantID.Eq(tenantID),
caTbl.TenantID.Eq(tenantID),
caTbl.ContentID.Eq(contentID),
caTbl.Role.Eq(role),
).
Order(caTbl.Sort.Asc()).
Find()
if err != nil {
return nil, err
}
return assets, nil
}
func (s *content) contentPrice(ctx context.Context, tenantID, contentID int64) (*models.ContentPrice, error) {
tbl, query := models.ContentPriceQuery.QueryContext(ctx)
m, err := query.Where(
tbl.TenantID.Eq(tenantID),
tbl.ContentID.Eq(contentID),
).First()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return m, nil
}
func (s *content) contentPriceMapping(ctx context.Context, tenantID int64, contentIDs []int64) (map[int64]*models.ContentPrice, error) {
if len(contentIDs) == 0 {
return map[int64]*models.ContentPrice{}, nil
}
tbl, query := models.ContentPriceQuery.QueryContext(ctx)
items, err := query.Where(
tbl.TenantID.Eq(tenantID),
tbl.ContentID.In(contentIDs...),
).Find()
if err != nil {
return nil, err
}
return lo.SliceToMap(items, func(item *models.ContentPrice) (int64, *models.ContentPrice) {
return item.ContentID, item
}), nil
}
func (s *content) accessSet(ctx context.Context, tenantID, userID int64, contentIDs []int64) (map[int64]bool, error) {
if len(contentIDs) == 0 {
return map[int64]bool{}, nil
}
tbl, query := models.ContentAccessQuery.QueryContext(ctx)
items, err := query.Where(
tbl.TenantID.Eq(tenantID),
tbl.UserID.Eq(userID),
tbl.ContentID.In(contentIDs...),
tbl.Status.Eq(consts.ContentAccessStatusActive),
).Find()
if err != nil {
return nil, err
}
out := make(map[int64]bool, len(items))
for _, item := range items {
out[item.ContentID] = true
}
return out, nil
}

View File

@@ -9,17 +9,26 @@ import (
)
func Provide(opts ...opt.Option) error {
if err := container.Container.Provide(func() (*content, error) {
obj := &content{}
return obj, nil
}); err != nil {
return err
}
if err := container.Container.Provide(func(
content *content,
db *gorm.DB,
tenant *tenant,
test *test,
user *user,
) (contracts.Initial, error) {
obj := &services{
db: db,
tenant: tenant,
test: test,
user: user,
content: content,
db: db,
tenant: tenant,
test: test,
user: user,
}
if err := obj.Prepare(); err != nil {
return nil, err

View File

@@ -8,24 +8,27 @@ var _db *gorm.DB
// exported CamelCase Services
var (
Tenant *tenant
Test *test
User *user
Content *content
Tenant *tenant
Test *test
User *user
)
// @provider(model)
type services struct {
db *gorm.DB
// define Services
tenant *tenant
test *test
user *user
content *content
tenant *tenant
test *test
user *user
}
func (svc *services) Prepare() error {
_db = svc.db
// set exported Services here
Content = svc.content
Tenant = svc.tenant
Test = svc.test
User = svc.user

View File

@@ -17,6 +17,8 @@ import (
"go.ipao.vip/gen"
)
// tenant implements tenant-related domain operations.
//
// @provider
type tenant struct{}