feat: Implement public access for tenant content

- Add TenantOptionalAuth middleware to allow access to public content without requiring authentication.
- Introduce ListPublicPublished and PublicDetail methods in the content service to retrieve publicly accessible content.
- Create tenant_public HTTP routes for listing and showing public content, including preview and main asset retrieval.
- Enhance content tests to cover scenarios for public content access and permissions.
- Update specifications to reflect the new public content access features and rules.
This commit is contained in:
2025-12-22 16:29:44 +08:00
parent 266de2f75e
commit 39454458f1
17 changed files with 1010 additions and 17 deletions

View File

@@ -2,11 +2,14 @@ package services
import (
"database/sql"
"errors"
"testing"
"time"
"quyun/v2/app/commands/testx"
"quyun/v2/app/errorx"
"quyun/v2/app/http/tenant/dto"
"quyun/v2/app/requests"
"quyun/v2/database"
"quyun/v2/database/models"
"quyun/v2/pkg/consts"
@@ -18,6 +21,7 @@ import (
"go.ipao.vip/atom/contracts"
"go.ipao.vip/gen/types"
"go.uber.org/dig"
"gorm.io/gorm"
)
type ContentTestSuiteInjectParams struct {
@@ -241,3 +245,247 @@ func (s *ContentTestSuite) Test_HasAccess() {
})
})
}
func (s *ContentTestSuite) Test_ListPublicPublished() {
Convey("Content.ListPublicPublished", s.T(), func() {
ctx := s.T().Context()
now := time.Now().UTC()
tenantID := int64(1)
ownerID := int64(2)
database.Truncate(ctx, s.DB,
models.TableNameContentAccess,
models.TableNameContentPrice,
models.TableNameContent,
)
publicPaid := &models.Content{
TenantID: tenantID,
UserID: ownerID,
Title: "public_paid",
Description: "d",
Status: consts.ContentStatusPublished,
Visibility: consts.ContentVisibilityPublic,
PreviewSeconds: consts.DefaultContentPreviewSeconds,
PreviewDownloadable: false,
PublishedAt: now,
CreatedAt: now,
UpdatedAt: now,
}
So(publicPaid.Create(ctx), ShouldBeNil)
So((&models.ContentPrice{
TenantID: tenantID,
UserID: ownerID,
ContentID: publicPaid.ID,
Currency: consts.CurrencyCNY,
PriceAmount: 100,
CreatedAt: now,
UpdatedAt: now,
}).Create(ctx), ShouldBeNil)
publicFree := &models.Content{
TenantID: tenantID,
UserID: ownerID,
Title: "public_free",
Description: "d",
Status: consts.ContentStatusPublished,
Visibility: consts.ContentVisibilityPublic,
PreviewSeconds: consts.DefaultContentPreviewSeconds,
PreviewDownloadable: false,
PublishedAt: now,
CreatedAt: now,
UpdatedAt: now,
}
So(publicFree.Create(ctx), ShouldBeNil)
tenantOnly := &models.Content{
TenantID: tenantID,
UserID: ownerID,
Title: "tenant_only",
Description: "d",
Status: consts.ContentStatusPublished,
Visibility: consts.ContentVisibilityTenantOnly,
PreviewSeconds: consts.DefaultContentPreviewSeconds,
PreviewDownloadable: false,
PublishedAt: now,
CreatedAt: now,
UpdatedAt: now,
}
So(tenantOnly.Create(ctx), ShouldBeNil)
privateContent := &models.Content{
TenantID: tenantID,
UserID: ownerID,
Title: "private",
Description: "d",
Status: consts.ContentStatusPublished,
Visibility: consts.ContentVisibilityPrivate,
PreviewSeconds: consts.DefaultContentPreviewSeconds,
PreviewDownloadable: false,
PublishedAt: now,
CreatedAt: now,
UpdatedAt: now,
}
So(privateContent.Create(ctx), ShouldBeNil)
draftPublic := &models.Content{
TenantID: tenantID,
UserID: ownerID,
Title: "draft_public",
Description: "d",
Status: consts.ContentStatusDraft,
Visibility: consts.ContentVisibilityPublic,
PreviewSeconds: consts.DefaultContentPreviewSeconds,
PreviewDownloadable: false,
CreatedAt: now,
UpdatedAt: now,
}
So(draftPublic.Create(ctx), ShouldBeNil)
deletedPublic := &models.Content{
TenantID: tenantID,
UserID: ownerID,
Title: "deleted_public",
Description: "d",
Status: consts.ContentStatusPublished,
Visibility: consts.ContentVisibilityPublic,
PreviewSeconds: consts.DefaultContentPreviewSeconds,
PreviewDownloadable: false,
PublishedAt: now,
DeletedAt: gorm.DeletedAt{Time: now, Valid: true},
CreatedAt: now,
UpdatedAt: now,
}
So(deletedPublic.Create(ctx), ShouldBeNil)
Convey("游客仅能看到 public+published且免费内容 has_access=true", func() {
pager, err := Content.ListPublicPublished(ctx, tenantID, 0, &dto.ContentListFilter{Pagination: requests.Pagination{Page: 1, Limit: 20}})
So(err, ShouldBeNil)
So(pager.Total, ShouldEqual, 2)
items := pager.Items.([]*dto.ContentItem)
So(len(items), ShouldEqual, 2)
got := map[int64]*dto.ContentItem{}
for _, it := range items {
got[it.Content.ID] = it
}
So(got[publicPaid.ID], ShouldNotBeNil)
So(got[publicFree.ID], ShouldNotBeNil)
So(got[publicPaid.ID].HasAccess, ShouldBeFalse)
So(got[publicFree.ID].HasAccess, ShouldBeTrue)
})
Convey("已登录用户若有权益则 has_access=true", func() {
viewerID := int64(99)
access := &models.ContentAccess{
TenantID: tenantID,
UserID: viewerID,
ContentID: publicPaid.ID,
OrderID: 123,
Status: consts.ContentAccessStatusActive,
CreatedAt: now,
UpdatedAt: now,
}
So(access.Create(ctx), ShouldBeNil)
pager, err := Content.ListPublicPublished(ctx, tenantID, viewerID, &dto.ContentListFilter{Pagination: requests.Pagination{Page: 1, Limit: 20}})
So(err, ShouldBeNil)
So(pager.Total, ShouldEqual, 2)
items := pager.Items.([]*dto.ContentItem)
got := map[int64]*dto.ContentItem{}
for _, it := range items {
got[it.Content.ID] = it
}
So(got[publicPaid.ID].HasAccess, ShouldBeTrue)
})
})
}
func (s *ContentTestSuite) Test_PublicDetail() {
Convey("Content.PublicDetail", s.T(), func() {
ctx := s.T().Context()
now := time.Now().UTC()
tenantID := int64(1)
ownerID := int64(2)
database.Truncate(ctx, s.DB,
models.TableNameContentAccess,
models.TableNameContentPrice,
models.TableNameContent,
)
publicPaid := &models.Content{
TenantID: tenantID,
UserID: ownerID,
Title: "public_paid",
Description: "d",
Status: consts.ContentStatusPublished,
Visibility: consts.ContentVisibilityPublic,
PreviewSeconds: consts.DefaultContentPreviewSeconds,
PreviewDownloadable: false,
PublishedAt: now,
CreatedAt: now,
UpdatedAt: now,
}
So(publicPaid.Create(ctx), ShouldBeNil)
So((&models.ContentPrice{
TenantID: tenantID,
UserID: ownerID,
ContentID: publicPaid.ID,
Currency: consts.CurrencyCNY,
PriceAmount: 100,
CreatedAt: now,
UpdatedAt: now,
}).Create(ctx), ShouldBeNil)
tenantOnly := &models.Content{
TenantID: tenantID,
UserID: ownerID,
Title: "tenant_only",
Description: "d",
Status: consts.ContentStatusPublished,
Visibility: consts.ContentVisibilityTenantOnly,
PreviewSeconds: consts.DefaultContentPreviewSeconds,
PreviewDownloadable: false,
PublishedAt: now,
CreatedAt: now,
UpdatedAt: now,
}
So(tenantOnly.Create(ctx), ShouldBeNil)
Convey("游客访问 public+paid可见但无正片权限", func() {
out, err := Content.PublicDetail(ctx, tenantID, 0, publicPaid.ID)
So(err, ShouldBeNil)
So(out, ShouldNotBeNil)
So(out.HasAccess, ShouldBeFalse)
})
Convey("tenant_only 在 public detail 下应表现为 not found", func() {
_, err := Content.PublicDetail(ctx, tenantID, 0, tenantOnly.ID)
So(err, ShouldNotBeNil)
var appErr *errorx.AppError
So(errors.As(err, &appErr), ShouldBeTrue)
So(appErr.Code, ShouldEqual, errorx.ErrRecordNotFound.Code)
})
Convey("有权益的已登录用户访问 public+paidhas_access=true", func() {
viewerID := int64(99)
access := &models.ContentAccess{
TenantID: tenantID,
UserID: viewerID,
ContentID: publicPaid.ID,
OrderID: 123,
Status: consts.ContentAccessStatusActive,
CreatedAt: now,
UpdatedAt: now,
}
So(access.Create(ctx), ShouldBeNil)
out, err := Content.PublicDetail(ctx, tenantID, viewerID, publicPaid.ID)
So(err, ShouldBeNil)
So(out.HasAccess, ShouldBeTrue)
})
})
}