Files
quyun-v2/backend/app/services/tenant_join.go
Rogee 39454458f1 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.
2025-12-22 16:29:44 +08:00

512 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package services
import (
"context"
"crypto/rand"
"encoding/base32"
"strings"
"time"
"quyun/v2/app/errorx"
"quyun/v2/app/http/tenant/dto"
tenant_join_dto "quyun/v2/app/http/tenant_join/dto"
"quyun/v2/app/requests"
"quyun/v2/database/models"
"quyun/v2/pkg/consts"
"github.com/jackc/pgx/v5/pgconn"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"go.ipao.vip/gen"
"go.ipao.vip/gen/types"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
return pgErr.Code == "23505"
}
return errors.Is(err, gorm.ErrDuplicatedKey)
}
func newInviteCode() (string, error) {
// 邀请码为安全敏感值:使用强随机数,避免可预测性导致被撞库加入租户。
buf := make([]byte, 10) // 80-bit
if _, err := rand.Read(buf); err != nil {
return "", err
}
// base32去掉 padding便于输入统一转小写存储与比较。
return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf)), nil
}
// AdminCreateInvite 租户管理员创建邀请(用于用户通过邀请码加入租户)。
func (t *tenant) AdminCreateInvite(ctx context.Context, tenantID, operatorUserID int64, form *dto.AdminTenantInviteCreateForm) (*models.TenantInvite, error) {
if tenantID <= 0 {
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id must be > 0")
}
if operatorUserID <= 0 {
return nil, errorx.ErrInvalidParameter.WithMsg("operator_user_id must be > 0")
}
if form == nil {
return nil, errorx.ErrInvalidParameter.WithMsg("form is nil")
}
now := time.Now().UTC()
code := strings.ToLower(strings.TrimSpace(form.Code))
if code == "" {
var err error
code, err = newInviteCode()
if err != nil {
return nil, err
}
}
if form.ExpiresAt != nil && !form.ExpiresAt.IsZero() && form.ExpiresAt.Before(now) {
return nil, errorx.ErrInvalidParameter.WithMsg("expires_at must be in future")
}
if form.MaxUses != nil && *form.MaxUses < 0 {
return nil, errorx.ErrInvalidParameter.WithMsg("max_uses must be >= 0")
}
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"operator_user_id": operatorUserID,
"code": code,
"max_uses": form.MaxUses,
"expires_at_present": form.ExpiresAt != nil,
}).Info("services.tenant.admin.create_invite")
invite := &models.TenantInvite{
TenantID: tenantID,
UserID: operatorUserID,
Code: code,
Status: consts.TenantInviteStatusActive,
MaxUses: 0,
UsedCount: 0,
Remark: strings.TrimSpace(form.Remark),
CreatedAt: now,
UpdatedAt: now,
}
if form.MaxUses != nil {
invite.MaxUses = int32(*form.MaxUses)
}
if form.ExpiresAt != nil && !form.ExpiresAt.IsZero() {
invite.ExpiresAt = form.ExpiresAt.UTC()
}
// 关键点expires_at/disabled_at 允许为空,避免写入 0001-01-01 造成误判。
db := models.Q.TenantInvite.WithContext(ctx).UnderlyingDB().Omit("disabled_at", "disabled_operator_user_id")
if invite.ExpiresAt.IsZero() {
db = db.Omit("expires_at")
}
if err := db.Create(invite).Error; err != nil {
if isUniqueViolation(err) {
return nil, errorx.ErrRecordDuplicated.WithMsg("邀请码已存在,请重试")
}
return nil, err
}
return invite, nil
}
// AdminDisableInvite 租户管理员禁用邀请(幂等)。
func (t *tenant) AdminDisableInvite(ctx context.Context, tenantID, operatorUserID, inviteID int64, reason string) (*models.TenantInvite, error) {
if tenantID <= 0 || operatorUserID <= 0 || inviteID <= 0 {
return nil, errorx.ErrInvalidParameter.WithMsg("invalid tenant_id/operator_user_id/invite_id")
}
now := time.Now().UTC()
reason = strings.TrimSpace(reason)
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"operator_user_id": operatorUserID,
"invite_id": inviteID,
}).Info("services.tenant.admin.disable_invite")
var out models.TenantInvite
err := _db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var inv models.TenantInvite
if err := tx.
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND tenant_id = ?", inviteID, tenantID).
First(&inv).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errorx.ErrRecordNotFound.WithMsg("邀请码不存在")
}
return err
}
// 幂等:重复禁用直接返回当前状态。
if inv.Status == consts.TenantInviteStatusDisabled || inv.Status == consts.TenantInviteStatusExpired {
out = inv
return nil
}
inv.Status = consts.TenantInviteStatusDisabled
inv.DisabledOperatorUserID = operatorUserID
inv.DisabledAt = now
if reason != "" {
inv.Remark = reason
}
inv.UpdatedAt = now
// 关键点disabled_at/disabled_operator_user_id 允许为空,但禁用时必须落审计信息。
if err := tx.Save(&inv).Error; err != nil {
return err
}
out = inv
return nil
})
if err != nil {
return nil, err
}
return &out, nil
}
// AdminInvitePage 租户管理员分页查询邀请列表。
func (t *tenant) AdminInvitePage(ctx context.Context, tenantID int64, filter *dto.AdminTenantInviteListFilter) (*requests.Pager, error) {
if tenantID <= 0 {
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id must be > 0")
}
if filter == nil {
filter = &dto.AdminTenantInviteListFilter{}
}
filter.Pagination.Format()
tbl, query := models.TenantInviteQuery.QueryContext(ctx)
conds := []gen.Condition{tbl.TenantID.Eq(tenantID)}
if filter.Status != nil && *filter.Status != "" {
conds = append(conds, tbl.Status.Eq(*filter.Status))
}
if code := filter.CodeTrimmed(); code != "" {
conds = append(conds, tbl.Code.Like("%"+strings.ToLower(code)+"%"))
}
items, total, err := query.Where(conds...).Order(tbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit))
if err != nil {
return nil, err
}
return &requests.Pager{
Pagination: filter.Pagination,
Total: total,
Items: items,
}, nil
}
// JoinByInvite 用户通过邀请码加入租户(无须已是租户成员)。
func (t *tenant) JoinByInvite(ctx context.Context, tenantID, userID int64, inviteCode string) (*models.TenantUser, error) {
if tenantID <= 0 || userID <= 0 {
return nil, errorx.ErrInvalidParameter.WithMsg("invalid tenant_id/user_id")
}
inviteCode = strings.ToLower(strings.TrimSpace(inviteCode))
if inviteCode == "" {
return nil, errorx.ErrInvalidParameter.WithMsg("invite_code is empty")
}
now := time.Now().UTC()
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"user_id": userID,
"invite_code": inviteCode,
"invite_token": "[masked]",
}).Info("services.tenant.join_by_invite")
var out models.TenantUser
err := _db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 关键前置条件:已经是成员时直接成功返回,不消耗邀请码使用次数。
var existingTU models.TenantUser
if err := tx.Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(&existingTU).Error; err == nil {
out = existingTU
return nil
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
// 邀请校验必须加行锁,避免并发超发 used_count。
var inv models.TenantInvite
if err := tx.
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("tenant_id = ? AND code = ?", tenantID, inviteCode).
First(&inv).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errorx.ErrRecordNotFound.WithMsg("邀请码不存在")
}
return err
}
// 关键规则:禁用/过期的邀请码不可使用。
if inv.Status != consts.TenantInviteStatusActive {
return errorx.ErrPreconditionFailed.WithMsg("邀请码不可用")
}
if !inv.ExpiresAt.IsZero() && inv.ExpiresAt.Before(now) {
// 业务侧保持状态一致:过期时顺手标记 expired避免后续误用。
_ = tx.Model(&inv).Updates(map[string]any{
"status": consts.TenantInviteStatusExpired,
"updated_at": now,
}).Error
return errorx.ErrPreconditionFailed.WithMsg("邀请码已过期")
}
if inv.MaxUses > 0 && inv.UsedCount >= inv.MaxUses {
_ = tx.Model(&inv).Updates(map[string]any{
"status": consts.TenantInviteStatusExpired,
"updated_at": now,
}).Error
return errorx.ErrPreconditionFailed.WithMsg("邀请码已用尽")
}
// 加入租户:默认 member + verified与 tenant.AddUser 保持一致。
tu := &models.TenantUser{
TenantID: tenantID,
UserID: userID,
Role: types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleMember}),
Status: consts.UserStatusVerified,
Balance: 0,
BalanceFrozen: 0,
CreatedAt: now,
UpdatedAt: now,
}
if err := tx.Create(tu).Error; err != nil {
if isUniqueViolation(err) {
// 并发幂等:重复插入按已加入处理,不消耗邀请码次数。
if err := tx.Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(&out).Error; err != nil {
return err
}
return nil
}
return err
}
out = *tu
// 只有在“新加入”成功时才消耗邀请码次数。
updates := map[string]any{
"used_count": inv.UsedCount + 1,
"updated_at": now,
}
if inv.MaxUses > 0 && inv.UsedCount+1 >= inv.MaxUses {
updates["status"] = consts.TenantInviteStatusExpired
}
return tx.Model(&inv).Updates(updates).Error
})
if err != nil {
return nil, err
}
return &out, nil
}
// CreateJoinRequest 用户提交加入租户申请(无邀请码场景)。
func (t *tenant) CreateJoinRequest(ctx context.Context, tenantID, userID int64, form *tenant_join_dto.JoinRequestCreateForm) (*models.TenantJoinRequest, error) {
if tenantID <= 0 || userID <= 0 {
return nil, errorx.ErrInvalidParameter.WithMsg("invalid tenant_id/user_id")
}
if form == nil {
return nil, errorx.ErrInvalidParameter.WithMsg("form is nil")
}
now := time.Now().UTC()
reason := strings.TrimSpace(form.Reason)
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"user_id": userID,
}).Info("services.tenant.create_join_request")
// 关键前置条件:已是成员则不允许重复申请。
var existingTU models.TenantUser
if err := _db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", tenantID, userID).First(&existingTU).Error; err == nil {
return nil, errorx.ErrPreconditionFailed.WithMsg("已是该租户成员")
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
req := &models.TenantJoinRequest{
TenantID: tenantID,
UserID: userID,
Status: consts.TenantJoinRequestStatusPending,
Reason: reason,
CreatedAt: now,
UpdatedAt: now,
DecidedAt: time.Time{},
DecidedReason: "",
}
// 关键点decided_at/decided_operator_user_id 允许为空,避免写入 0001-01-01 造成误判。
db := models.Q.TenantJoinRequest.WithContext(ctx).UnderlyingDB().Omit("decided_at", "decided_operator_user_id")
if err := db.Create(req).Error; err != nil {
if isUniqueViolation(err) {
// 幂等:重复提交时返回现有 pending 申请。
tbl, query := models.TenantJoinRequestQuery.QueryContext(ctx)
existing, qErr := query.Where(
tbl.TenantID.Eq(tenantID),
tbl.UserID.Eq(userID),
tbl.Status.Eq(consts.TenantJoinRequestStatusPending),
).First()
if qErr == nil {
return existing, nil
}
return nil, err
}
return nil, err
}
return req, nil
}
// AdminJoinRequestPage 租户管理员分页查询加入申请列表。
func (t *tenant) AdminJoinRequestPage(ctx context.Context, tenantID int64, filter *dto.AdminTenantJoinRequestListFilter) (*requests.Pager, error) {
if tenantID <= 0 {
return nil, errorx.ErrInvalidParameter.WithMsg("tenant_id must be > 0")
}
if filter == nil {
filter = &dto.AdminTenantJoinRequestListFilter{}
}
filter.Pagination.Format()
tbl, query := models.TenantJoinRequestQuery.QueryContext(ctx)
conds := []gen.Condition{tbl.TenantID.Eq(tenantID)}
if filter.UserID != nil && *filter.UserID > 0 {
conds = append(conds, tbl.UserID.Eq(*filter.UserID))
}
if filter.Status != nil && *filter.Status != "" {
conds = append(conds, tbl.Status.Eq(*filter.Status))
}
items, total, err := query.Where(conds...).Order(tbl.ID.Desc()).FindByPage(int(filter.Offset()), int(filter.Limit))
if err != nil {
return nil, err
}
return &requests.Pager{
Pagination: filter.Pagination,
Total: total,
Items: items,
}, nil
}
// AdminApproveJoinRequest 租户管理员通过加入申请(幂等)。
func (t *tenant) AdminApproveJoinRequest(ctx context.Context, tenantID, operatorUserID, requestID int64, reason string) (*models.TenantJoinRequest, error) {
if tenantID <= 0 || operatorUserID <= 0 || requestID <= 0 {
return nil, errorx.ErrInvalidParameter.WithMsg("invalid tenant_id/operator_user_id/request_id")
}
now := time.Now().UTC()
reason = strings.TrimSpace(reason)
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"operator_user_id": operatorUserID,
"request_id": requestID,
}).Info("services.tenant.admin.approve_join_request")
var out models.TenantJoinRequest
err := _db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var req models.TenantJoinRequest
if err := tx.
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND tenant_id = ?", requestID, tenantID).
First(&req).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errorx.ErrRecordNotFound.WithMsg("申请不存在")
}
return err
}
// 幂等:已通过则直接返回。
if req.Status == consts.TenantJoinRequestStatusApproved {
out = req
return nil
}
if req.Status != consts.TenantJoinRequestStatusPending {
return errorx.ErrPreconditionFailed.WithMsg("申请状态不可通过")
}
// 先落成员关系,再更新申请状态,保证“通过后一定能成为成员”(至少幂等)。
tu := &models.TenantUser{
TenantID: tenantID,
UserID: req.UserID,
Role: types.NewArray([]consts.TenantUserRole{consts.TenantUserRoleMember}),
Status: consts.UserStatusVerified,
Balance: 0,
BalanceFrozen: 0,
CreatedAt: now,
UpdatedAt: now,
}
if err := tx.Create(tu).Error; err != nil && !isUniqueViolation(err) {
return err
}
req.Status = consts.TenantJoinRequestStatusApproved
req.DecidedAt = now
req.DecidedOperatorUserID = operatorUserID
req.DecidedReason = reason
req.UpdatedAt = now
if err := tx.Save(&req).Error; err != nil {
return err
}
out = req
return nil
})
if err != nil {
return nil, err
}
return &out, nil
}
// AdminRejectJoinRequest 租户管理员拒绝加入申请(幂等)。
func (t *tenant) AdminRejectJoinRequest(ctx context.Context, tenantID, operatorUserID, requestID int64, reason string) (*models.TenantJoinRequest, error) {
if tenantID <= 0 || operatorUserID <= 0 || requestID <= 0 {
return nil, errorx.ErrInvalidParameter.WithMsg("invalid tenant_id/operator_user_id/request_id")
}
now := time.Now().UTC()
reason = strings.TrimSpace(reason)
logrus.WithFields(logrus.Fields{
"tenant_id": tenantID,
"operator_user_id": operatorUserID,
"request_id": requestID,
}).Info("services.tenant.admin.reject_join_request")
var out models.TenantJoinRequest
err := _db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var req models.TenantJoinRequest
if err := tx.
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ? AND tenant_id = ?", requestID, tenantID).
First(&req).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errorx.ErrRecordNotFound.WithMsg("申请不存在")
}
return err
}
// 幂等:已拒绝则直接返回。
if req.Status == consts.TenantJoinRequestStatusRejected {
out = req
return nil
}
if req.Status != consts.TenantJoinRequestStatusPending {
return errorx.ErrPreconditionFailed.WithMsg("申请状态不可拒绝")
}
req.Status = consts.TenantJoinRequestStatusRejected
req.DecidedAt = now
req.DecidedOperatorUserID = operatorUserID
req.DecidedReason = reason
req.UpdatedAt = now
if err := tx.Save(&req).Error; err != nil {
return err
}
out = req
return nil
})
if err != nil {
return nil, err
}
return &out, nil
}