refactor: 文件行数超标拆解(Service + Controller 9/16 完成)
- Service 层:energy_service → energize/operations/admin/query 四文件 - Service 层:post_service → helpers/audit + 核心 CRUD 保留 - Service 层:favorite_service → items + 核心文件夹操作保留 - Service 层:auth_service → password + 核心注册/登录保留 - Service 层:notification_service → notify + 核心列表/已读保留 - Service 层:audit_service → review + 核心列表/详情保留 - Controller 层:studio_controller → page/write/api/post_api/action 五文件 - Controller 层:post_controller → page/api/upload 三文件 - Controller 层:comment_controller → list/write/action/upload 四文件 - 清理未使用导入(notification_service.go 移除 fmt)
This commit is contained in:
104
internal/service/audit_review.go
Normal file
104
internal/service/audit_review.go
Normal file
@ -0,0 +1,104 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// review 通用审核操作
|
||||
func (s *AuditService) review(reviewerID uint, submissionID uint, approved bool, reason string) error {
|
||||
// 1. 查审核记录
|
||||
submission, err := s.auditRepo.FindByID(submissionID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return common.ErrAuditNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. 必须 pending
|
||||
if submission.Status != model.AuditStatusPending {
|
||||
return common.ErrAuditNotPending
|
||||
}
|
||||
|
||||
// 3. 查审核人信息
|
||||
reviewer, err := s.userRepo.FindByID(reviewerID)
|
||||
if err != nil {
|
||||
return common.ErrUserNotFound
|
||||
}
|
||||
|
||||
// 4. 标记审核结果
|
||||
submission.ReviewedBy = &reviewerID
|
||||
submission.ReviewerName = &reviewer.Username
|
||||
if approved {
|
||||
submission.Status = model.AuditStatusApproved
|
||||
// 更新 User 表对应字段
|
||||
if err := s.applyApproval(submission); err != nil {
|
||||
return err
|
||||
}
|
||||
// 通知用户审核通过
|
||||
if s.notifier != nil {
|
||||
s.notifier.NotifyAuditApproved(submission.UserID, submission.AuditType, submission.ID)
|
||||
}
|
||||
} else {
|
||||
submission.Status = model.AuditStatusRejected
|
||||
submission.RejectReason = &reason
|
||||
// 通知用户审核被拒(改名审核追加域能返还提示)
|
||||
if s.notifier != nil {
|
||||
reasonMsg := reason
|
||||
if submission.AuditType == model.AuditTypeUsername {
|
||||
reasonMsg += "。扣除的域能已被返还"
|
||||
}
|
||||
s.notifier.NotifyAuditRejected(submission.UserID, submission.AuditType, reasonMsg, submission.ID)
|
||||
}
|
||||
// 改名审核被拒 → 返还域能
|
||||
if submission.AuditType == model.AuditTypeUsername && s.energyRefundSvc != nil {
|
||||
_ = s.energyRefundSvc.RefundOnRenameReject(submission.UserID)
|
||||
}
|
||||
}
|
||||
return s.auditRepo.Update(submission)
|
||||
}
|
||||
|
||||
// applyApproval 将审核通过的内容写入 User 表
|
||||
// 用户名审批前再次检查冲突(防止提交到审批期间被其他用户抢占)
|
||||
func (s *AuditService) applyApproval(submission *model.AuditSubmission) error {
|
||||
user, err := s.userRepo.FindByID(submission.UserID)
|
||||
if err != nil {
|
||||
return common.ErrUserNotFound
|
||||
}
|
||||
|
||||
switch submission.AuditType {
|
||||
case model.AuditTypeUsername:
|
||||
exists, err := s.userRepo.ExistsByUsername(submission.NewValue)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return common.ErrUsernameTaken
|
||||
}
|
||||
user.Username = submission.NewValue
|
||||
case model.AuditTypeAvatar:
|
||||
user.Avatar = submission.NewValue
|
||||
case model.AuditTypeBio:
|
||||
user.Bio = submission.NewValue
|
||||
}
|
||||
if err := s.userRepo.Update(user); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 触发首次任务奖励(静默失败不影响主流程)
|
||||
if s.levelSvc != nil {
|
||||
switch submission.AuditType {
|
||||
case model.AuditTypeUsername:
|
||||
_, _, _ = s.levelSvc.CompleteTask(submission.UserID, "username")
|
||||
case model.AuditTypeAvatar:
|
||||
_, _, _ = s.levelSvc.CompleteTask(submission.UserID, "avatar")
|
||||
case model.AuditTypeBio:
|
||||
_, _, _ = s.levelSvc.CompleteTask(submission.UserID, "bio")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@ -4,8 +4,6 @@ import (
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/config"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// auditRepo AuditService 所需的最小仓储接口(ISP)
|
||||
@ -169,102 +167,6 @@ func (s *AuditService) Reject(reviewerID uint, submissionID uint, reason string)
|
||||
return s.review(reviewerID, submissionID, false, reason)
|
||||
}
|
||||
|
||||
// review 通用审核操作
|
||||
func (s *AuditService) review(reviewerID uint, submissionID uint, approved bool, reason string) error {
|
||||
// 1. 查审核记录
|
||||
submission, err := s.auditRepo.FindByID(submissionID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return common.ErrAuditNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. 必须 pending
|
||||
if submission.Status != model.AuditStatusPending {
|
||||
return common.ErrAuditNotPending
|
||||
}
|
||||
|
||||
// 3. 查审核人信息
|
||||
reviewer, err := s.userRepo.FindByID(reviewerID)
|
||||
if err != nil {
|
||||
return common.ErrUserNotFound
|
||||
}
|
||||
|
||||
// 4. 标记审核结果
|
||||
submission.ReviewedBy = &reviewerID
|
||||
submission.ReviewerName = &reviewer.Username
|
||||
if approved {
|
||||
submission.Status = model.AuditStatusApproved
|
||||
// 更新 User 表对应字段
|
||||
if err := s.applyApproval(submission); err != nil {
|
||||
return err
|
||||
}
|
||||
// 通知用户审核通过
|
||||
if s.notifier != nil {
|
||||
s.notifier.NotifyAuditApproved(submission.UserID, submission.AuditType, submission.ID)
|
||||
}
|
||||
} else {
|
||||
submission.Status = model.AuditStatusRejected
|
||||
submission.RejectReason = &reason
|
||||
// 通知用户审核被拒(改名审核追加域能返还提示)
|
||||
if s.notifier != nil {
|
||||
reasonMsg := reason
|
||||
if submission.AuditType == model.AuditTypeUsername {
|
||||
reasonMsg += "。扣除的域能已被返还"
|
||||
}
|
||||
s.notifier.NotifyAuditRejected(submission.UserID, submission.AuditType, reasonMsg, submission.ID)
|
||||
}
|
||||
// 改名审核被拒 → 返还域能
|
||||
if submission.AuditType == model.AuditTypeUsername && s.energyRefundSvc != nil {
|
||||
_ = s.energyRefundSvc.RefundOnRenameReject(submission.UserID)
|
||||
}
|
||||
}
|
||||
return s.auditRepo.Update(submission)
|
||||
}
|
||||
|
||||
// applyApproval 将审核通过的内容写入 User 表
|
||||
// 用户名审批前再次检查冲突(防止提交到审批期间被其他用户抢占)
|
||||
func (s *AuditService) applyApproval(submission *model.AuditSubmission) error {
|
||||
user, err := s.userRepo.FindByID(submission.UserID)
|
||||
if err != nil {
|
||||
return common.ErrUserNotFound
|
||||
}
|
||||
|
||||
switch submission.AuditType {
|
||||
case model.AuditTypeUsername:
|
||||
exists, err := s.userRepo.ExistsByUsername(submission.NewValue)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return common.ErrUsernameTaken
|
||||
}
|
||||
user.Username = submission.NewValue
|
||||
case model.AuditTypeAvatar:
|
||||
user.Avatar = submission.NewValue
|
||||
case model.AuditTypeBio:
|
||||
user.Bio = submission.NewValue
|
||||
}
|
||||
if err := s.userRepo.Update(user); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 触发首次任务奖励(静默失败不影响主流程)
|
||||
if s.levelSvc != nil {
|
||||
switch submission.AuditType {
|
||||
case model.AuditTypeUsername:
|
||||
_, _, _ = s.levelSvc.CompleteTask(submission.UserID, "username")
|
||||
case model.AuditTypeAvatar:
|
||||
_, _, _ = s.levelSvc.CompleteTask(submission.UserID, "avatar")
|
||||
case model.AuditTypeBio:
|
||||
_, _, _ = s.levelSvc.CompleteTask(submission.UserID, "bio")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// List 分页查询审核列表
|
||||
func (s *AuditService) List(auditType, status string, page, pageSize int) (*AuditListResult, error) {
|
||||
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||
|
||||
128
internal/service/auth_password.go
Normal file
128
internal/service/auth_password.go
Normal file
@ -0,0 +1,128 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var pwLetter = regexp.MustCompile(`[a-zA-Z]`)
|
||||
var pwDigit = regexp.MustCompile(`\d`)
|
||||
|
||||
// dummyHash 防时序攻击:当用户不存在时,仍对虚拟哈希执行完整的 bcrypt 比对
|
||||
var dummyHash []byte
|
||||
|
||||
func init() {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("metazone-dummy-hash-2026"), 12)
|
||||
if err != nil {
|
||||
panic("failed to generate bcrypt dummy hash: " + err.Error())
|
||||
}
|
||||
dummyHash = hash
|
||||
}
|
||||
|
||||
// validatePassword 密码强度:至少 8 位 + 包含字母 + 包含数字
|
||||
func validatePassword(pw string) error {
|
||||
if len(pw) < 8 || !pwLetter.MatchString(pw) || !pwDigit.MatchString(pw) {
|
||||
return common.ErrWeakPassword
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChangePassword 修改密码
|
||||
// 流程:验证当前密码 → 校验新密码强度 → 哈希 → 更新 → 删除所有 session(强制重新登录)
|
||||
func (s *AuthService) ChangePassword(userID uint, currentPassword, newPassword string) error {
|
||||
user, err := s.userRepo.FindByID(userID)
|
||||
if err != nil {
|
||||
return common.ErrUserNotFound
|
||||
}
|
||||
|
||||
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(currentPassword)) != nil {
|
||||
return common.ErrIncorrectPassword
|
||||
}
|
||||
|
||||
if err := validatePassword(newPassword); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hash, err := common.HashPassword(newPassword, s.cfg.Bcrypt.Cost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user.PasswordHash = hash
|
||||
if err := s.userRepo.Update(user); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 删除所有 session,强制所有设备重新登录
|
||||
return s.invalidateSessions(userID)
|
||||
}
|
||||
|
||||
// DeleteAccount 用户自主注销
|
||||
func (s *AuthService) DeleteAccount(userID uint, password, reason string) error {
|
||||
user, err := s.userRepo.FindByID(userID)
|
||||
if err != nil {
|
||||
return common.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.Role == model.RoleOwner {
|
||||
return common.ErrOwnerCannotDelete
|
||||
}
|
||||
|
||||
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil {
|
||||
return common.ErrIncorrectPassword
|
||||
}
|
||||
|
||||
user.Status = model.StatusDeleted
|
||||
user.DeleteReason = reason
|
||||
|
||||
if err := s.userRepo.Update(user); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.invalidateSessions(userID)
|
||||
}
|
||||
|
||||
// invalidateSessions 销毁某用户的所有 session(内部方法)
|
||||
func (s *AuthService) invalidateSessions(userID uint) error {
|
||||
return s.sessionManager.DestroyByUID(userID)
|
||||
}
|
||||
|
||||
// InvalidateSessions 公开方法:销毁用户所有 session(供 admin 等外部调用)
|
||||
func (s *AuthService) InvalidateSessions(userID uint) error {
|
||||
return s.sessionManager.DestroyByUID(userID)
|
||||
}
|
||||
|
||||
// ConfirmRestore 二次确认恢复已注销账号
|
||||
func (s *AuthService) ConfirmRestore(req model.LoginRequest, loginIP string) (*model.User, error) {
|
||||
user, err := s.userRepo.FindByEmail(req.Email)
|
||||
if err != nil {
|
||||
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
|
||||
return nil, common.ErrInvalidCred
|
||||
}
|
||||
|
||||
if user.Status != model.StatusDeleted {
|
||||
return nil, common.ErrUserNotFound
|
||||
}
|
||||
|
||||
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)) != nil {
|
||||
return nil, common.ErrInvalidCred
|
||||
}
|
||||
|
||||
// 恢复账号
|
||||
user.Status = model.StatusActive
|
||||
user.DeletedAt = gorm.DeletedAt{}
|
||||
now := time.Now()
|
||||
user.LastLoginIP = loginIP
|
||||
user.LastLoginAt = &now
|
||||
if err := s.userRepo.Update(user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
@ -12,7 +12,6 @@ import (
|
||||
"metazone.cc/metalab/internal/session"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AuthService 认证业务逻辑
|
||||
@ -28,31 +27,9 @@ func NewAuthService(userRepo userAuthStore, sm *session.Manager, cfg *config.Con
|
||||
return &AuthService{userRepo: userRepo, sessionManager: sm, cfg: cfg, siteSettings: siteSettings}
|
||||
}
|
||||
|
||||
var pwLetter = regexp.MustCompile(`[a-zA-Z]`)
|
||||
var pwDigit = regexp.MustCompile(`\d`)
|
||||
|
||||
// usernamePattern 用户名合法字符:中文、英文大小写、数字、下划线、连字符
|
||||
var usernamePattern = regexp.MustCompile(`^[\p{Han}a-zA-Z0-9_-]+$`)
|
||||
|
||||
// dummyHash 防时序攻击:当用户不存在时,仍对虚拟哈希执行完整的 bcrypt 比对
|
||||
var dummyHash []byte
|
||||
|
||||
func init() {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("metazone-dummy-hash-2026"), 12)
|
||||
if err != nil {
|
||||
panic("failed to generate bcrypt dummy hash: " + err.Error())
|
||||
}
|
||||
dummyHash = hash
|
||||
}
|
||||
|
||||
// validatePassword 密码强度:至少 8 位 + 包含字母 + 包含数字
|
||||
func validatePassword(pw string) error {
|
||||
if len(pw) < 8 || !pwLetter.MatchString(pw) || !pwDigit.MatchString(pw) {
|
||||
return common.ErrWeakPassword
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsRegistrationEnabled 检查是否允许新用户注册
|
||||
func (s *AuthService) IsRegistrationEnabled() bool {
|
||||
return s.siteSettings.IsRegistrationEnabled()
|
||||
@ -164,35 +141,6 @@ func (s *AuthService) Login(req model.LoginRequest, loginIP string) (*model.User
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// ConfirmRestore 二次确认恢复已注销账号
|
||||
func (s *AuthService) ConfirmRestore(req model.LoginRequest, loginIP string) (*model.User, error) {
|
||||
user, err := s.userRepo.FindByEmail(req.Email)
|
||||
if err != nil {
|
||||
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(req.Password))
|
||||
return nil, common.ErrInvalidCred
|
||||
}
|
||||
|
||||
if user.Status != model.StatusDeleted {
|
||||
return nil, common.ErrUserNotFound
|
||||
}
|
||||
|
||||
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)) != nil {
|
||||
return nil, common.ErrInvalidCred
|
||||
}
|
||||
|
||||
// 恢复账号
|
||||
user.Status = model.StatusActive
|
||||
user.DeletedAt = gorm.DeletedAt{}
|
||||
now := time.Now()
|
||||
user.LastLoginIP = loginIP
|
||||
user.LastLoginAt = &now
|
||||
if err := s.userRepo.Update(user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// CheckEmail 检查邮箱是否已被注册
|
||||
func (s *AuthService) CheckEmail(email string) (bool, error) {
|
||||
return s.userRepo.ExistsByEmail(email)
|
||||
@ -203,71 +151,6 @@ func (s *AuthService) GetProfile(userID uint) (*model.User, error) {
|
||||
return s.userRepo.FindByID(userID)
|
||||
}
|
||||
|
||||
// ChangePassword 修改密码
|
||||
// 流程:验证当前密码 → 校验新密码强度 → 哈希 → 更新 → 删除所有 session(强制重新登录)
|
||||
func (s *AuthService) ChangePassword(userID uint, currentPassword, newPassword string) error {
|
||||
user, err := s.userRepo.FindByID(userID)
|
||||
if err != nil {
|
||||
return common.ErrUserNotFound
|
||||
}
|
||||
|
||||
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(currentPassword)) != nil {
|
||||
return common.ErrIncorrectPassword
|
||||
}
|
||||
|
||||
if err := validatePassword(newPassword); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hash, err := common.HashPassword(newPassword, s.cfg.Bcrypt.Cost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user.PasswordHash = hash
|
||||
if err := s.userRepo.Update(user); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 删除所有 session,强制所有设备重新登录
|
||||
return s.invalidateSessions(userID)
|
||||
}
|
||||
|
||||
// DeleteAccount 用户自主注销
|
||||
func (s *AuthService) DeleteAccount(userID uint, password, reason string) error {
|
||||
user, err := s.userRepo.FindByID(userID)
|
||||
if err != nil {
|
||||
return common.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.Role == model.RoleOwner {
|
||||
return common.ErrOwnerCannotDelete
|
||||
}
|
||||
|
||||
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil {
|
||||
return common.ErrIncorrectPassword
|
||||
}
|
||||
|
||||
user.Status = model.StatusDeleted
|
||||
user.DeleteReason = reason
|
||||
|
||||
if err := s.userRepo.Update(user); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.invalidateSessions(userID)
|
||||
}
|
||||
|
||||
// invalidateSessions 销毁某用户的所有 session(内部方法)
|
||||
func (s *AuthService) invalidateSessions(userID uint) error {
|
||||
return s.sessionManager.DestroyByUID(userID)
|
||||
}
|
||||
|
||||
// InvalidateSessions 公开方法:销毁用户所有 session(供 admin 等外部调用)
|
||||
func (s *AuthService) InvalidateSessions(userID uint) error {
|
||||
return s.sessionManager.DestroyByUID(userID)
|
||||
}
|
||||
|
||||
// UpdateProfile 修改个人资料
|
||||
func (s *AuthService) UpdateProfile(userID uint, username, bio string) error {
|
||||
user, err := s.userRepo.FindByID(userID)
|
||||
|
||||
92
internal/service/energy_admin.go
Normal file
92
internal/service/energy_admin.go
Normal file
@ -0,0 +1,92 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AdminAdjust 后台调整用户域能
|
||||
// mode: "admin_transfer"(受公户余额约束)| "system_operation"(不受约束,不碰公户)
|
||||
func (s *EnergyService) AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string, mode string) error {
|
||||
if mode == "" {
|
||||
mode = model.FundTypeAdminTransfer
|
||||
}
|
||||
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
txRepo := s.repo.WithTx(tx)
|
||||
|
||||
// admin_transfer:公户出账需检查余额(行锁防 TOCTOU 竞态)
|
||||
isAdminTransfer := mode == model.FundTypeAdminTransfer
|
||||
if isAdminTransfer && amount > 0 && s.fundRepo != nil {
|
||||
txFundRepo := s.fundRepo.WithTx(tx)
|
||||
totalCost := amount * len(userIDs)
|
||||
balance, err := txFundRepo.LockAndGetBalance()
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询公户余额失败: %w", err)
|
||||
}
|
||||
if balance < totalCost {
|
||||
return common.ErrInsufficientFund
|
||||
}
|
||||
}
|
||||
|
||||
for _, uid := range userIDs {
|
||||
if err := txRepo.AddEnergy(uid, amount); err != nil {
|
||||
return fmt.Errorf("调整用户 %d 域能失败: %w", uid, err)
|
||||
}
|
||||
|
||||
opUID := operatorUID
|
||||
energyLog := &model.EnergyLog{
|
||||
UserID: uid,
|
||||
Amount: amount,
|
||||
Type: model.EnergyTypeAdminAdjust,
|
||||
Description: description,
|
||||
OperatorUID: &opUID,
|
||||
}
|
||||
if err := txRepo.CreateEnergyLog(energyLog); err != nil {
|
||||
return fmt.Errorf("创建调整流水失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// admin_transfer:公户扣款 + 公户流水(system_operation 不碰公户)
|
||||
if isAdminTransfer && s.fundRepo != nil {
|
||||
txFundRepo := s.fundRepo.WithTx(tx)
|
||||
totalCost := amount * len(userIDs)
|
||||
if err := txFundRepo.IncrBalance(-totalCost); err != nil {
|
||||
return fmt.Errorf("公户余额调整失败: %w", err)
|
||||
}
|
||||
|
||||
totalDisplay := float64(totalCost) / 10
|
||||
fundLog := &model.FundLog{
|
||||
Amount: -totalCost,
|
||||
Type: mode,
|
||||
OperatorUID: &operatorUID,
|
||||
Description: fmt.Sprintf("%s:调整 %d 名用户域能 %.1f,说明:%s", getFundModeName(mode), len(userIDs), totalDisplay, description),
|
||||
}
|
||||
if len(userIDs) > 0 {
|
||||
fundLog.RelatedType = "user"
|
||||
fundLog.RelatedID = userIDs[0]
|
||||
}
|
||||
if err := txFundRepo.CreateLog(fundLog); err != nil {
|
||||
return fmt.Errorf("创建公户流水失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// getFundModeName 返回操作模式中文名
|
||||
func getFundModeName(mode string) string {
|
||||
switch mode {
|
||||
case model.FundTypeAdminTransfer:
|
||||
return "管理转出"
|
||||
case model.FundTypeSystemOperation:
|
||||
return "系统操作"
|
||||
default:
|
||||
return "后台调整"
|
||||
}
|
||||
}
|
||||
190
internal/service/energy_energize.go
Normal file
190
internal/service/energy_energize.go
Normal file
@ -0,0 +1,190 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Energize 赋能文章
|
||||
// 返回:获得的经验值、错误
|
||||
func (s *EnergyService) Energize(energizerID uint, postID uint, amount int) (int, error) {
|
||||
// 验证赋能量
|
||||
if amount != EnergyEnergize1 && amount != EnergyEnergize2 {
|
||||
return 0, common.ErrInvalidEnergyAmount
|
||||
}
|
||||
|
||||
var expGained int
|
||||
var needExpUpdate bool
|
||||
var expToday time.Time
|
||||
var expTotal int
|
||||
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
txRepo := s.repo.WithTx(tx)
|
||||
var txFundRepo FundStore
|
||||
if s.fundRepo != nil {
|
||||
txFundRepo = s.fundRepo.WithTx(tx)
|
||||
}
|
||||
|
||||
// 1. 检查帖子存在,获取作者
|
||||
post, err := txRepo.FindPostByID(postID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return common.ErrPostNotFound
|
||||
}
|
||||
return fmt.Errorf("查询帖子失败: %w", err)
|
||||
}
|
||||
|
||||
// 2. 不能给自己赋能
|
||||
if post.UserID == energizerID {
|
||||
return common.ErrCannotEnergizeSelf
|
||||
}
|
||||
|
||||
// 3. 检查赋能者域能余额(owner 跳过)
|
||||
energizer, err := txRepo.FindUserByID(energizerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询用户失败: %w", err)
|
||||
}
|
||||
if energizer.Role != model.RoleOwner && energizer.Energy < amount {
|
||||
return common.ErrInsufficientEnergy
|
||||
}
|
||||
|
||||
// 4. 检查单用户单文章赋能总量 ≤ MaxEnergizePerPost
|
||||
total, err := txRepo.SumUserPostEnergy(energizerID, postID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询赋能记录失败: %w", err)
|
||||
}
|
||||
if total+amount > MaxEnergizePerPost {
|
||||
return common.ErrEnergizeLimitReached
|
||||
}
|
||||
|
||||
// 5. 扣赋能者域能
|
||||
if err := txRepo.AddEnergy(energizerID, -amount); err != nil {
|
||||
return fmt.Errorf("扣减域能失败: %w", err)
|
||||
}
|
||||
|
||||
// 6. 增加被赋能者(文章作者)域能(赋能量的十分之一)
|
||||
receivedAmount := amount / 10 // 1 域能→0.1,2 域能→0.2
|
||||
if err := txRepo.AddEnergy(post.UserID, receivedAmount); err != nil {
|
||||
return fmt.Errorf("增加被赋能者域能失败: %w", err)
|
||||
}
|
||||
|
||||
// 7. 更新 Post.total_energy_received
|
||||
if err := txRepo.IncrPostEnergy(postID, amount); err != nil {
|
||||
return fmt.Errorf("更新文章赋能计数失败: %w", err)
|
||||
}
|
||||
|
||||
// 8. 插入赋能记录
|
||||
eLog := &model.PostEnergizeLog{
|
||||
UserID: energizerID,
|
||||
PostID: postID,
|
||||
Amount: amount,
|
||||
}
|
||||
if err := txRepo.CreateEnergizeLog(eLog); err != nil {
|
||||
return fmt.Errorf("创建赋能记录失败: %w", err)
|
||||
}
|
||||
|
||||
// 9. 写入域能流水 ×2
|
||||
amountDisplay := float64(amount) / 10
|
||||
receivedDisplay := float64(receivedAmount) / 10
|
||||
|
||||
energizerLog := &model.EnergyLog{
|
||||
UserID: energizerID,
|
||||
Amount: -amount,
|
||||
Type: model.EnergyTypeEnergize,
|
||||
RelatedType: "post",
|
||||
RelatedID: postID,
|
||||
Description: fmt.Sprintf("给文章 %d 赋能 %.1f 域能", postID, amountDisplay),
|
||||
}
|
||||
if err := txRepo.CreateEnergyLog(energizerLog); err != nil {
|
||||
return fmt.Errorf("创建赋能者流水失败: %w", err)
|
||||
}
|
||||
|
||||
energizedLog := &model.EnergyLog{
|
||||
UserID: post.UserID,
|
||||
Amount: receivedAmount,
|
||||
Type: model.EnergyTypeEnergized,
|
||||
RelatedType: "post",
|
||||
RelatedID: postID,
|
||||
Description: fmt.Sprintf("文章 %d 被赋能 +%.1f 域能", postID, receivedDisplay),
|
||||
}
|
||||
if err := txRepo.CreateEnergyLog(energizedLog); err != nil {
|
||||
return fmt.Errorf("创建被赋能者流水失败: %w", err)
|
||||
}
|
||||
|
||||
// 10. 赋能税收入公户(差价 = amount - receivedAmount)
|
||||
if txFundRepo != nil {
|
||||
tax := amount - receivedAmount
|
||||
if tax > 0 {
|
||||
if err := txFundRepo.IncrBalance(tax); err != nil {
|
||||
return fmt.Errorf("公户入账失败: %w", err)
|
||||
}
|
||||
taxDisplay := float64(tax) / 10
|
||||
fundLog := &model.FundLog{
|
||||
Amount: tax,
|
||||
Type: model.FundTypeEnergizeTax,
|
||||
RelatedType: "post",
|
||||
RelatedID: postID,
|
||||
Description: fmt.Sprintf("赋能文章 #%d 税收 %.1f 域能,赋能者 uid=%d", postID, taxDisplay, energizerID),
|
||||
}
|
||||
if err := txFundRepo.CreateLog(fundLog); err != nil {
|
||||
return fmt.Errorf("创建公户流水失败: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 11. 计算经验值(溢出截断)—— 在事务内读取以保证一致性
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
now := time.Now().In(loc)
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
|
||||
|
||||
summary, err := txRepo.GetDailyEnergizeExp(energizerID, today)
|
||||
currentDaily := 0
|
||||
if err == nil && summary != nil {
|
||||
currentDaily = summary.ExpEarned
|
||||
}
|
||||
|
||||
expGain := amount // 1域能(DB10) → 10经验, 2域能(DB20) → 20经验
|
||||
expRoom := DailyEnergizeExpCap - currentDaily
|
||||
if expRoom < 0 {
|
||||
expRoom = 0
|
||||
}
|
||||
if expGain > expRoom {
|
||||
expGain = expRoom
|
||||
}
|
||||
|
||||
expGained = expGain
|
||||
if expGain > 0 {
|
||||
needExpUpdate = true
|
||||
expToday = today
|
||||
expTotal = currentDaily + expGain
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// 12. 事务提交后,在事务外增加经验 + 更新每日汇总
|
||||
if needExpUpdate {
|
||||
if s.expSvc != nil {
|
||||
if _, _, err := s.expSvc.AddExp(energizerID, expGained); err != nil {
|
||||
return expGained, fmt.Errorf("赋能成功,但增加经验失败: %w", err)
|
||||
}
|
||||
}
|
||||
newSummary := &model.DailyExpSummary{
|
||||
UserID: energizerID,
|
||||
Date: expToday,
|
||||
ExpEarned: expTotal,
|
||||
}
|
||||
if err := s.repo.UpsertDailyExp(newSummary); err != nil {
|
||||
return expGained, fmt.Errorf("赋能成功,但更新经验汇总失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return expGained, nil
|
||||
}
|
||||
149
internal/service/energy_operations.go
Normal file
149
internal/service/energy_operations.go
Normal file
@ -0,0 +1,149 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CheckInEnergy 签到获得域能(LV0 用户不调用此方法)
|
||||
func (s *EnergyService) CheckInEnergy(userID uint) error {
|
||||
if err := s.repo.AddEnergy(userID, EnergyCheckIn); err != nil {
|
||||
return fmt.Errorf("增加签到域能失败: %w", err)
|
||||
}
|
||||
|
||||
log := &model.EnergyLog{
|
||||
UserID: userID,
|
||||
Amount: EnergyCheckIn,
|
||||
Type: model.EnergyTypeSignIn,
|
||||
Description: "每日签到 +1.0 域能",
|
||||
}
|
||||
return s.repo.CreateEnergyLog(log)
|
||||
}
|
||||
|
||||
// DeductOnRename 改名扣域能(首次改名免费,不调用此方法)
|
||||
// 事务包裹:扣用户域能 + 入公户 + 写流水
|
||||
func (s *EnergyService) DeductOnRename(userID uint) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
txRepo := s.repo.WithTx(tx)
|
||||
|
||||
// 检查域能余额(非 owner 需 ≥ 0)
|
||||
user, err := txRepo.FindUserByID(userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询用户失败: %w", err)
|
||||
}
|
||||
if user.Role != model.RoleOwner && user.Energy+EnergyRename < 0 {
|
||||
return common.ErrInsufficientEnergy
|
||||
}
|
||||
|
||||
if err := txRepo.AddEnergy(userID, EnergyRename); err != nil {
|
||||
return fmt.Errorf("扣减改名域能失败: %w", err)
|
||||
}
|
||||
|
||||
// 公户入账
|
||||
if s.fundRepo != nil {
|
||||
txFundRepo := s.fundRepo.WithTx(tx)
|
||||
if err := txFundRepo.IncrBalance(-EnergyRename); err != nil { // EnergyRename = -60,入公户 +60
|
||||
return fmt.Errorf("公户入账失败: %w", err)
|
||||
}
|
||||
fundLog := &model.FundLog{
|
||||
Amount: -EnergyRename,
|
||||
Type: model.FundTypeRenameDeduction,
|
||||
RelatedType: "user",
|
||||
RelatedID: userID,
|
||||
Description: fmt.Sprintf("用户 uid=%d 改名消耗 6.0 域能", userID),
|
||||
}
|
||||
if err := txFundRepo.CreateLog(fundLog); err != nil {
|
||||
return fmt.Errorf("创建公户流水失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
log := &model.EnergyLog{
|
||||
UserID: userID,
|
||||
Amount: EnergyRename,
|
||||
Type: model.EnergyTypeRename,
|
||||
Description: "改名消耗 6.0 域能",
|
||||
}
|
||||
return txRepo.CreateEnergyLog(log)
|
||||
})
|
||||
}
|
||||
|
||||
// RefundOnRenameReject 改名审核被拒,从公户返还域能
|
||||
func (s *EnergyService) RefundOnRenameReject(userID uint) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
txRepo := s.repo.WithTx(tx)
|
||||
|
||||
// 从公户支出(允许透支)
|
||||
if s.fundRepo != nil {
|
||||
txFundRepo := s.fundRepo.WithTx(tx)
|
||||
if err := txFundRepo.IncrBalance(-EnergyRenameRefund); err != nil {
|
||||
return fmt.Errorf("公户出账失败: %w", err)
|
||||
}
|
||||
fundLog := &model.FundLog{
|
||||
Amount: -EnergyRenameRefund,
|
||||
Type: model.FundTypeRenameRefund,
|
||||
RelatedType: "user",
|
||||
RelatedID: userID,
|
||||
Description: fmt.Sprintf("用户 uid=%d 改名审核被拒,返还 6.0 域能", userID),
|
||||
}
|
||||
if err := txFundRepo.CreateLog(fundLog); err != nil {
|
||||
return fmt.Errorf("创建公户流水失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := txRepo.AddEnergy(userID, EnergyRenameRefund); err != nil {
|
||||
return fmt.Errorf("返还改名域能失败: %w", err)
|
||||
}
|
||||
|
||||
log := &model.EnergyLog{
|
||||
UserID: userID,
|
||||
Amount: EnergyRenameRefund,
|
||||
Type: model.EnergyTypeRenameRefund,
|
||||
Description: "改名审核被拒,返还 6.0 域能",
|
||||
}
|
||||
return txRepo.CreateEnergyLog(log)
|
||||
})
|
||||
}
|
||||
|
||||
// DeductOnDeletePost 删稿扣域能(扣文章作者的域能,无视负数)
|
||||
// 事务包裹:扣用户域能 + 入公户 + 写流水
|
||||
func (s *EnergyService) DeductOnDeletePost(authorUserID uint, postID uint) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
txRepo := s.repo.WithTx(tx)
|
||||
|
||||
if err := txRepo.AddEnergy(authorUserID, EnergyDeletePost); err != nil {
|
||||
return fmt.Errorf("扣减删稿域能失败: %w", err)
|
||||
}
|
||||
|
||||
// 公户入账
|
||||
if s.fundRepo != nil {
|
||||
txFundRepo := s.fundRepo.WithTx(tx)
|
||||
if err := txFundRepo.IncrBalance(-EnergyDeletePost); err != nil { // EnergyDeletePost = -20,入公户 +20
|
||||
return fmt.Errorf("公户入账失败: %w", err)
|
||||
}
|
||||
fundLog := &model.FundLog{
|
||||
Amount: -EnergyDeletePost,
|
||||
Type: model.FundTypeDeletePost,
|
||||
RelatedType: "post",
|
||||
RelatedID: postID,
|
||||
Description: fmt.Sprintf("用户 uid=%d 删除文章 #%d 消耗 2.0 域能", authorUserID, postID),
|
||||
}
|
||||
if err := txFundRepo.CreateLog(fundLog); err != nil {
|
||||
return fmt.Errorf("创建公户流水失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
log := &model.EnergyLog{
|
||||
UserID: authorUserID,
|
||||
Amount: EnergyDeletePost,
|
||||
Type: model.EnergyTypeDeletePost,
|
||||
RelatedType: "post",
|
||||
RelatedID: postID,
|
||||
Description: "删除文章消耗 2.0 域能",
|
||||
}
|
||||
return txRepo.CreateEnergyLog(log)
|
||||
})
|
||||
}
|
||||
85
internal/service/energy_query.go
Normal file
85
internal/service/energy_query.go
Normal file
@ -0,0 +1,85 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
)
|
||||
|
||||
// GetEnergyInfo 获取用户域能余额和每日赋能经验状态
|
||||
func (s *EnergyService) GetEnergyInfo(userID uint) (*EnergyInfo, error) {
|
||||
user, err := s.repo.FindUserByID(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
now := time.Now().In(loc)
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
|
||||
|
||||
dailyExp := 0
|
||||
summary, err := s.repo.GetDailyEnergizeExp(userID, today)
|
||||
if err == nil && summary != nil {
|
||||
dailyExp = summary.ExpEarned
|
||||
}
|
||||
|
||||
return &EnergyInfo{
|
||||
Energy: user.Energy,
|
||||
DailyEnergizeExp: dailyExp,
|
||||
DailyExpCap: DailyEnergizeExpCap,
|
||||
DailyExpCapReached: dailyExp >= DailyEnergizeExpCap,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetEnergyLogs 分页查询用户域能流水(近 N 天)
|
||||
func (s *EnergyService) GetEnergyLogs(userID uint, days, page, pageSize int) ([]model.EnergyLog, int64, error) {
|
||||
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||
p.DefaultPagination()
|
||||
|
||||
total, err := s.repo.CountEnergyLogsByUser(userID, days)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
logs, err := s.repo.ListEnergyLogsByUser(userID, days, p.Offset(), p.PageSize)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if logs == nil {
|
||||
logs = []model.EnergyLog{}
|
||||
}
|
||||
return logs, total, nil
|
||||
}
|
||||
|
||||
// GetAdminEnergyLogs 后台查询全部域能流水
|
||||
func (s *EnergyService) GetAdminEnergyLogs(energyType string, page, pageSize int) ([]model.EnergyLog, int64, error) {
|
||||
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||
p.DefaultPagination()
|
||||
return s.repo.ListAllEnergyLogs(energyType, p.Offset(), p.PageSize)
|
||||
}
|
||||
|
||||
// GetFundBalance 获取公户余额
|
||||
func (s *EnergyService) GetFundBalance() (int, error) {
|
||||
if s.fundRepo == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return s.fundRepo.GetBalance()
|
||||
}
|
||||
|
||||
// GetFundLogs 分页查询公户流水
|
||||
func (s *EnergyService) GetFundLogs(logType string, page, pageSize int) ([]model.FundLog, int64, error) {
|
||||
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||
p.DefaultPagination()
|
||||
if s.fundRepo == nil {
|
||||
return []model.FundLog{}, 0, nil
|
||||
}
|
||||
logs, total, err := s.fundRepo.ListLogs(logType, p.Offset(), p.PageSize)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if logs == nil {
|
||||
logs = []model.FundLog{}
|
||||
}
|
||||
return logs, total, nil
|
||||
}
|
||||
@ -1,12 +1,6 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@ -59,480 +53,3 @@ const (
|
||||
DailyEnergizeExpCap = 50 // 每日赋能经验上限
|
||||
MaxEnergizePerPost = 20 // 单用户单文章最多赋能 2 域能(×10)
|
||||
)
|
||||
|
||||
// Energize 赋能文章
|
||||
// 返回:获得的经验值、错误
|
||||
func (s *EnergyService) Energize(energizerID uint, postID uint, amount int) (int, error) {
|
||||
// 验证赋能量
|
||||
if amount != EnergyEnergize1 && amount != EnergyEnergize2 {
|
||||
return 0, common.ErrInvalidEnergyAmount
|
||||
}
|
||||
|
||||
var expGained int
|
||||
var needExpUpdate bool
|
||||
var expToday time.Time
|
||||
var expTotal int
|
||||
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
txRepo := s.repo.WithTx(tx)
|
||||
var txFundRepo FundStore
|
||||
if s.fundRepo != nil {
|
||||
txFundRepo = s.fundRepo.WithTx(tx)
|
||||
}
|
||||
|
||||
// 1. 检查帖子存在,获取作者
|
||||
post, err := txRepo.FindPostByID(postID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return common.ErrPostNotFound
|
||||
}
|
||||
return fmt.Errorf("查询帖子失败: %w", err)
|
||||
}
|
||||
|
||||
// 2. 不能给自己赋能
|
||||
if post.UserID == energizerID {
|
||||
return common.ErrCannotEnergizeSelf
|
||||
}
|
||||
|
||||
// 3. 检查赋能者域能余额(owner 跳过)
|
||||
energizer, err := txRepo.FindUserByID(energizerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询用户失败: %w", err)
|
||||
}
|
||||
if energizer.Role != model.RoleOwner && energizer.Energy < amount {
|
||||
return common.ErrInsufficientEnergy
|
||||
}
|
||||
|
||||
// 4. 检查单用户单文章赋能总量 ≤ MaxEnergizePerPost
|
||||
total, err := txRepo.SumUserPostEnergy(energizerID, postID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询赋能记录失败: %w", err)
|
||||
}
|
||||
if total+amount > MaxEnergizePerPost {
|
||||
return common.ErrEnergizeLimitReached
|
||||
}
|
||||
|
||||
// 5. 扣赋能者域能
|
||||
if err := txRepo.AddEnergy(energizerID, -amount); err != nil {
|
||||
return fmt.Errorf("扣减域能失败: %w", err)
|
||||
}
|
||||
|
||||
// 6. 增加被赋能者(文章作者)域能(赋能量的十分之一)
|
||||
receivedAmount := amount / 10 // 1 域能→0.1,2 域能→0.2
|
||||
if err := txRepo.AddEnergy(post.UserID, receivedAmount); err != nil {
|
||||
return fmt.Errorf("增加被赋能者域能失败: %w", err)
|
||||
}
|
||||
|
||||
// 7. 更新 Post.total_energy_received
|
||||
if err := txRepo.IncrPostEnergy(postID, amount); err != nil {
|
||||
return fmt.Errorf("更新文章赋能计数失败: %w", err)
|
||||
}
|
||||
|
||||
// 8. 插入赋能记录
|
||||
eLog := &model.PostEnergizeLog{
|
||||
UserID: energizerID,
|
||||
PostID: postID,
|
||||
Amount: amount,
|
||||
}
|
||||
if err := txRepo.CreateEnergizeLog(eLog); err != nil {
|
||||
return fmt.Errorf("创建赋能记录失败: %w", err)
|
||||
}
|
||||
|
||||
// 9. 写入域能流水 ×2
|
||||
amountDisplay := float64(amount) / 10
|
||||
receivedDisplay := float64(receivedAmount) / 10
|
||||
|
||||
energizerLog := &model.EnergyLog{
|
||||
UserID: energizerID,
|
||||
Amount: -amount,
|
||||
Type: model.EnergyTypeEnergize,
|
||||
RelatedType: "post",
|
||||
RelatedID: postID,
|
||||
Description: fmt.Sprintf("给文章 %d 赋能 %.1f 域能", postID, amountDisplay),
|
||||
}
|
||||
if err := txRepo.CreateEnergyLog(energizerLog); err != nil {
|
||||
return fmt.Errorf("创建赋能者流水失败: %w", err)
|
||||
}
|
||||
|
||||
energizedLog := &model.EnergyLog{
|
||||
UserID: post.UserID,
|
||||
Amount: receivedAmount,
|
||||
Type: model.EnergyTypeEnergized,
|
||||
RelatedType: "post",
|
||||
RelatedID: postID,
|
||||
Description: fmt.Sprintf("文章 %d 被赋能 +%.1f 域能", postID, receivedDisplay),
|
||||
}
|
||||
if err := txRepo.CreateEnergyLog(energizedLog); err != nil {
|
||||
return fmt.Errorf("创建被赋能者流水失败: %w", err)
|
||||
}
|
||||
|
||||
// 10. 赋能税收入公户(差价 = amount - receivedAmount)
|
||||
if txFundRepo != nil {
|
||||
tax := amount - receivedAmount
|
||||
if tax > 0 {
|
||||
if err := txFundRepo.IncrBalance(tax); err != nil {
|
||||
return fmt.Errorf("公户入账失败: %w", err)
|
||||
}
|
||||
taxDisplay := float64(tax) / 10
|
||||
fundLog := &model.FundLog{
|
||||
Amount: tax,
|
||||
Type: model.FundTypeEnergizeTax,
|
||||
RelatedType: "post",
|
||||
RelatedID: postID,
|
||||
Description: fmt.Sprintf("赋能文章 #%d 税收 %.1f 域能,赋能者 uid=%d", postID, taxDisplay, energizerID),
|
||||
}
|
||||
if err := txFundRepo.CreateLog(fundLog); err != nil {
|
||||
return fmt.Errorf("创建公户流水失败: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 11. 计算经验值(溢出截断)—— 在事务内读取以保证一致性
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
now := time.Now().In(loc)
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
|
||||
|
||||
summary, err := txRepo.GetDailyEnergizeExp(energizerID, today)
|
||||
currentDaily := 0
|
||||
if err == nil && summary != nil {
|
||||
currentDaily = summary.ExpEarned
|
||||
}
|
||||
|
||||
expGain := amount // 1域能(DB10) → 10经验, 2域能(DB20) → 20经验
|
||||
expRoom := DailyEnergizeExpCap - currentDaily
|
||||
if expRoom < 0 {
|
||||
expRoom = 0
|
||||
}
|
||||
if expGain > expRoom {
|
||||
expGain = expRoom
|
||||
}
|
||||
|
||||
expGained = expGain
|
||||
if expGain > 0 {
|
||||
needExpUpdate = true
|
||||
expToday = today
|
||||
expTotal = currentDaily + expGain
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// 12. 事务提交后,在事务外增加经验 + 更新每日汇总
|
||||
if needExpUpdate {
|
||||
if s.expSvc != nil {
|
||||
if _, _, err := s.expSvc.AddExp(energizerID, expGained); err != nil {
|
||||
return expGained, fmt.Errorf("赋能成功,但增加经验失败: %w", err)
|
||||
}
|
||||
}
|
||||
newSummary := &model.DailyExpSummary{
|
||||
UserID: energizerID,
|
||||
Date: expToday,
|
||||
ExpEarned: expTotal,
|
||||
}
|
||||
if err := s.repo.UpsertDailyExp(newSummary); err != nil {
|
||||
return expGained, fmt.Errorf("赋能成功,但更新经验汇总失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return expGained, nil
|
||||
}
|
||||
|
||||
// CheckInEnergy 签到获得域能(LV0 用户不调用此方法)
|
||||
func (s *EnergyService) CheckInEnergy(userID uint) error {
|
||||
if err := s.repo.AddEnergy(userID, EnergyCheckIn); err != nil {
|
||||
return fmt.Errorf("增加签到域能失败: %w", err)
|
||||
}
|
||||
|
||||
log := &model.EnergyLog{
|
||||
UserID: userID,
|
||||
Amount: EnergyCheckIn,
|
||||
Type: model.EnergyTypeSignIn,
|
||||
Description: "每日签到 +1.0 域能",
|
||||
}
|
||||
return s.repo.CreateEnergyLog(log)
|
||||
}
|
||||
|
||||
// DeductOnRename 改名扣域能(首次改名免费,不调用此方法)
|
||||
// 事务包裹:扣用户域能 + 入公户 + 写流水
|
||||
func (s *EnergyService) DeductOnRename(userID uint) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
txRepo := s.repo.WithTx(tx)
|
||||
|
||||
// 检查域能余额(非 owner 需 ≥ 0)
|
||||
user, err := txRepo.FindUserByID(userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询用户失败: %w", err)
|
||||
}
|
||||
if user.Role != model.RoleOwner && user.Energy+EnergyRename < 0 {
|
||||
return common.ErrInsufficientEnergy
|
||||
}
|
||||
|
||||
if err := txRepo.AddEnergy(userID, EnergyRename); err != nil {
|
||||
return fmt.Errorf("扣减改名域能失败: %w", err)
|
||||
}
|
||||
|
||||
// 公户入账
|
||||
if s.fundRepo != nil {
|
||||
txFundRepo := s.fundRepo.WithTx(tx)
|
||||
if err := txFundRepo.IncrBalance(-EnergyRename); err != nil { // EnergyRename = -60,入公户 +60
|
||||
return fmt.Errorf("公户入账失败: %w", err)
|
||||
}
|
||||
fundLog := &model.FundLog{
|
||||
Amount: -EnergyRename,
|
||||
Type: model.FundTypeRenameDeduction,
|
||||
RelatedType: "user",
|
||||
RelatedID: userID,
|
||||
Description: fmt.Sprintf("用户 uid=%d 改名消耗 6.0 域能", userID),
|
||||
}
|
||||
if err := txFundRepo.CreateLog(fundLog); err != nil {
|
||||
return fmt.Errorf("创建公户流水失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
log := &model.EnergyLog{
|
||||
UserID: userID,
|
||||
Amount: EnergyRename,
|
||||
Type: model.EnergyTypeRename,
|
||||
Description: "改名消耗 6.0 域能",
|
||||
}
|
||||
return txRepo.CreateEnergyLog(log)
|
||||
})
|
||||
}
|
||||
|
||||
// RefundOnRenameReject 改名审核被拒,从公户返还域能
|
||||
func (s *EnergyService) RefundOnRenameReject(userID uint) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
txRepo := s.repo.WithTx(tx)
|
||||
|
||||
// 从公户支出(允许透支)
|
||||
if s.fundRepo != nil {
|
||||
txFundRepo := s.fundRepo.WithTx(tx)
|
||||
if err := txFundRepo.IncrBalance(-EnergyRenameRefund); err != nil {
|
||||
return fmt.Errorf("公户出账失败: %w", err)
|
||||
}
|
||||
fundLog := &model.FundLog{
|
||||
Amount: -EnergyRenameRefund,
|
||||
Type: model.FundTypeRenameRefund,
|
||||
RelatedType: "user",
|
||||
RelatedID: userID,
|
||||
Description: fmt.Sprintf("用户 uid=%d 改名审核被拒,返还 6.0 域能", userID),
|
||||
}
|
||||
if err := txFundRepo.CreateLog(fundLog); err != nil {
|
||||
return fmt.Errorf("创建公户流水失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := txRepo.AddEnergy(userID, EnergyRenameRefund); err != nil {
|
||||
return fmt.Errorf("返还改名域能失败: %w", err)
|
||||
}
|
||||
|
||||
log := &model.EnergyLog{
|
||||
UserID: userID,
|
||||
Amount: EnergyRenameRefund,
|
||||
Type: model.EnergyTypeRenameRefund,
|
||||
Description: "改名审核被拒,返还 6.0 域能",
|
||||
}
|
||||
return txRepo.CreateEnergyLog(log)
|
||||
})
|
||||
}
|
||||
|
||||
// DeductOnDeletePost 删稿扣域能(扣文章作者的域能,无视负数)
|
||||
// 事务包裹:扣用户域能 + 入公户 + 写流水
|
||||
func (s *EnergyService) DeductOnDeletePost(authorUserID uint, postID uint) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
txRepo := s.repo.WithTx(tx)
|
||||
|
||||
if err := txRepo.AddEnergy(authorUserID, EnergyDeletePost); err != nil {
|
||||
return fmt.Errorf("扣减删稿域能失败: %w", err)
|
||||
}
|
||||
|
||||
// 公户入账
|
||||
if s.fundRepo != nil {
|
||||
txFundRepo := s.fundRepo.WithTx(tx)
|
||||
if err := txFundRepo.IncrBalance(-EnergyDeletePost); err != nil { // EnergyDeletePost = -20,入公户 +20
|
||||
return fmt.Errorf("公户入账失败: %w", err)
|
||||
}
|
||||
fundLog := &model.FundLog{
|
||||
Amount: -EnergyDeletePost,
|
||||
Type: model.FundTypeDeletePost,
|
||||
RelatedType: "post",
|
||||
RelatedID: postID,
|
||||
Description: fmt.Sprintf("用户 uid=%d 删除文章 #%d 消耗 2.0 域能", authorUserID, postID),
|
||||
}
|
||||
if err := txFundRepo.CreateLog(fundLog); err != nil {
|
||||
return fmt.Errorf("创建公户流水失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
log := &model.EnergyLog{
|
||||
UserID: authorUserID,
|
||||
Amount: EnergyDeletePost,
|
||||
Type: model.EnergyTypeDeletePost,
|
||||
RelatedType: "post",
|
||||
RelatedID: postID,
|
||||
Description: "删除文章消耗 2.0 域能",
|
||||
}
|
||||
return txRepo.CreateEnergyLog(log)
|
||||
})
|
||||
}
|
||||
|
||||
// AdminAdjust 后台调整用户域能
|
||||
// mode: "admin_transfer"(受公户余额约束)| "system_operation"(不受约束,不碰公户)
|
||||
func (s *EnergyService) AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string, mode string) error {
|
||||
if mode == "" {
|
||||
mode = model.FundTypeAdminTransfer
|
||||
}
|
||||
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
txRepo := s.repo.WithTx(tx)
|
||||
|
||||
// admin_transfer:公户出账需检查余额(行锁防 TOCTOU 竞态)
|
||||
isAdminTransfer := mode == model.FundTypeAdminTransfer
|
||||
if isAdminTransfer && amount > 0 && s.fundRepo != nil {
|
||||
txFundRepo := s.fundRepo.WithTx(tx)
|
||||
totalCost := amount * len(userIDs)
|
||||
balance, err := txFundRepo.LockAndGetBalance()
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询公户余额失败: %w", err)
|
||||
}
|
||||
if balance < totalCost {
|
||||
return common.ErrInsufficientFund
|
||||
}
|
||||
}
|
||||
|
||||
for _, uid := range userIDs {
|
||||
if err := txRepo.AddEnergy(uid, amount); err != nil {
|
||||
return fmt.Errorf("调整用户 %d 域能失败: %w", uid, err)
|
||||
}
|
||||
|
||||
opUID := operatorUID
|
||||
energyLog := &model.EnergyLog{
|
||||
UserID: uid,
|
||||
Amount: amount,
|
||||
Type: model.EnergyTypeAdminAdjust,
|
||||
Description: description,
|
||||
OperatorUID: &opUID,
|
||||
}
|
||||
if err := txRepo.CreateEnergyLog(energyLog); err != nil {
|
||||
return fmt.Errorf("创建调整流水失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// admin_transfer:公户扣款 + 公户流水(system_operation 不碰公户)
|
||||
if isAdminTransfer && s.fundRepo != nil {
|
||||
txFundRepo := s.fundRepo.WithTx(tx)
|
||||
totalCost := amount * len(userIDs)
|
||||
if err := txFundRepo.IncrBalance(-totalCost); err != nil {
|
||||
return fmt.Errorf("公户余额调整失败: %w", err)
|
||||
}
|
||||
|
||||
totalDisplay := float64(totalCost) / 10
|
||||
fundLog := &model.FundLog{
|
||||
Amount: -totalCost,
|
||||
Type: mode,
|
||||
OperatorUID: &operatorUID,
|
||||
Description: fmt.Sprintf("%s:调整 %d 名用户域能 %.1f,说明:%s", getFundModeName(mode), len(userIDs), totalDisplay, description),
|
||||
}
|
||||
if len(userIDs) > 0 {
|
||||
fundLog.RelatedType = "user"
|
||||
fundLog.RelatedID = userIDs[0]
|
||||
}
|
||||
if err := txFundRepo.CreateLog(fundLog); err != nil {
|
||||
return fmt.Errorf("创建公户流水失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// getFundModeName 返回操作模式中文名
|
||||
func getFundModeName(mode string) string {
|
||||
switch mode {
|
||||
case model.FundTypeAdminTransfer:
|
||||
return "管理转出"
|
||||
case model.FundTypeSystemOperation:
|
||||
return "系统操作"
|
||||
default:
|
||||
return "后台调整"
|
||||
}
|
||||
}
|
||||
|
||||
// GetEnergyInfo 获取用户域能余额和每日赋能经验状态
|
||||
func (s *EnergyService) GetEnergyInfo(userID uint) (*EnergyInfo, error) {
|
||||
user, err := s.repo.FindUserByID(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
now := time.Now().In(loc)
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
|
||||
|
||||
dailyExp := 0
|
||||
summary, err := s.repo.GetDailyEnergizeExp(userID, today)
|
||||
if err == nil && summary != nil {
|
||||
dailyExp = summary.ExpEarned
|
||||
}
|
||||
|
||||
return &EnergyInfo{
|
||||
Energy: user.Energy,
|
||||
DailyEnergizeExp: dailyExp,
|
||||
DailyExpCap: DailyEnergizeExpCap,
|
||||
DailyExpCapReached: dailyExp >= DailyEnergizeExpCap,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetEnergyLogs 分页查询用户域能流水(近 N 天)
|
||||
func (s *EnergyService) GetEnergyLogs(userID uint, days, page, pageSize int) ([]model.EnergyLog, int64, error) {
|
||||
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||
p.DefaultPagination()
|
||||
|
||||
total, err := s.repo.CountEnergyLogsByUser(userID, days)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
logs, err := s.repo.ListEnergyLogsByUser(userID, days, p.Offset(), p.PageSize)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if logs == nil {
|
||||
logs = []model.EnergyLog{}
|
||||
}
|
||||
return logs, total, nil
|
||||
}
|
||||
|
||||
// GetAdminEnergyLogs 后台查询全部域能流水
|
||||
func (s *EnergyService) GetAdminEnergyLogs(energyType string, page, pageSize int) ([]model.EnergyLog, int64, error) {
|
||||
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||
p.DefaultPagination()
|
||||
return s.repo.ListAllEnergyLogs(energyType, p.Offset(), p.PageSize)
|
||||
}
|
||||
|
||||
// GetFundBalance 获取公户余额
|
||||
func (s *EnergyService) GetFundBalance() (int, error) {
|
||||
if s.fundRepo == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return s.fundRepo.GetBalance()
|
||||
}
|
||||
|
||||
// GetFundLogs 分页查询公户流水
|
||||
func (s *EnergyService) GetFundLogs(logType string, page, pageSize int) ([]model.FundLog, int64, error) {
|
||||
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||
p.DefaultPagination()
|
||||
if s.fundRepo == nil {
|
||||
return []model.FundLog{}, 0, nil
|
||||
}
|
||||
logs, total, err := s.fundRepo.ListLogs(logType, p.Offset(), p.PageSize)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if logs == nil {
|
||||
logs = []model.FundLog{}
|
||||
}
|
||||
return logs, total, nil
|
||||
}
|
||||
|
||||
146
internal/service/favorite_items.go
Normal file
146
internal/service/favorite_items.go
Normal file
@ -0,0 +1,146 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AddToFolder 将文章加入收藏夹(会先移除该文章在其他收藏夹的收藏)
|
||||
func (s *FavoriteService) AddToFolder(userID, folderID, postID uint) error {
|
||||
// 检查收藏夹归属
|
||||
f, err := s.repo.FindFolderByID(folderID)
|
||||
if err != nil {
|
||||
return common.ErrPostNotFound
|
||||
}
|
||||
if f.UserID != userID {
|
||||
return common.ErrPermissionDenied
|
||||
}
|
||||
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
txRepo := s.repo.WithTx(tx)
|
||||
|
||||
// 如果该文章已在其他收藏夹中,先移除
|
||||
existing, err := txRepo.FindItemByUserAndPost(userID, postID)
|
||||
if err == nil && existing.FolderID != folderID {
|
||||
if err := txRepo.DeleteItem(existing.FolderID, postID); err != nil {
|
||||
return fmt.Errorf("移除旧收藏: %w", err)
|
||||
}
|
||||
// 旧收藏夹没有增加计数,所以不需要减
|
||||
}
|
||||
|
||||
// 检查是否已在目标收藏夹中
|
||||
if err == nil && existing.FolderID == folderID {
|
||||
return fmt.Errorf("文章已在此收藏夹中")
|
||||
}
|
||||
|
||||
item := &model.FolderItem{
|
||||
FolderID: folderID,
|
||||
PostID: postID,
|
||||
UserID: userID,
|
||||
}
|
||||
if err := txRepo.CreateItem(item); err != nil {
|
||||
return fmt.Errorf("添加收藏: %w", err)
|
||||
}
|
||||
|
||||
return txRepo.IncrPostFavoritesCount(postID, 1)
|
||||
})
|
||||
}
|
||||
|
||||
// RemoveFromFolder 从收藏夹移除文章
|
||||
func (s *FavoriteService) RemoveFromFolder(userID, folderID, postID uint) error {
|
||||
f, err := s.repo.FindFolderByID(folderID)
|
||||
if err != nil {
|
||||
return common.ErrPostNotFound
|
||||
}
|
||||
if f.UserID != userID {
|
||||
return common.ErrPermissionDenied
|
||||
}
|
||||
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
txRepo := s.repo.WithTx(tx)
|
||||
if err := txRepo.DeleteItem(folderID, postID); err != nil {
|
||||
return fmt.Errorf("移除收藏: %w", err)
|
||||
}
|
||||
return txRepo.IncrPostFavoritesCount(postID, -1)
|
||||
})
|
||||
}
|
||||
|
||||
// AddFavorite 收藏文章(自动选择或创建默认收藏夹)
|
||||
func (s *FavoriteService) AddFavorite(userID, postID uint, folderID *uint) error {
|
||||
var targetFolderID uint
|
||||
|
||||
if folderID != nil && *folderID > 0 {
|
||||
targetFolderID = *folderID
|
||||
} else {
|
||||
// 使用或创建默认收藏夹
|
||||
f, err := s.ensureDefaultFolder(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targetFolderID = f.ID
|
||||
}
|
||||
|
||||
return s.AddToFolder(userID, targetFolderID, postID)
|
||||
}
|
||||
|
||||
// RemoveFavorite 取消收藏文章
|
||||
func (s *FavoriteService) RemoveFavorite(userID, postID uint) error {
|
||||
existing, err := s.repo.FindItemByUserAndPost(userID, postID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("未收藏该文章")
|
||||
}
|
||||
return s.RemoveFromFolder(userID, existing.FolderID, postID)
|
||||
}
|
||||
|
||||
// ListFolderItems 列收藏夹内的文章
|
||||
func (s *FavoriteService) ListFolderItems(userID, folderID uint, page, pageSize int) (*FolderItemsResult, error) {
|
||||
f, err := s.repo.FindFolderByID(folderID)
|
||||
if err != nil {
|
||||
return nil, common.ErrPostNotFound
|
||||
}
|
||||
|
||||
// 权限检查:非本人 + 非公开 → 拒绝
|
||||
if f.UserID != userID && !f.IsPublic {
|
||||
return nil, common.ErrPermissionDenied
|
||||
}
|
||||
|
||||
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||
p.DefaultPagination()
|
||||
|
||||
items, total, err := s.repo.ListItemsByFolder(folderID, p.Offset(), p.PageSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询收藏内容: %w", err)
|
||||
}
|
||||
if items == nil {
|
||||
items = []model.FolderItem{}
|
||||
}
|
||||
|
||||
return &FolderItemsResult{
|
||||
Items: items,
|
||||
Total: total,
|
||||
Page: p.Page,
|
||||
TotalPages: common.PageCount(total, p.PageSize),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ToggleFavorite 切换收藏(有则取消,无则加入默认收藏夹)
|
||||
func (s *FavoriteService) ToggleFavorite(userID, postID uint) (bool, error) {
|
||||
existing, err := s.repo.FindItemByUserAndPost(userID, postID)
|
||||
if err == nil {
|
||||
// 已收藏,取消
|
||||
if err := s.RemoveFromFolder(userID, existing.FolderID, postID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// 未收藏,加入默认
|
||||
if err := s.AddFavorite(userID, postID, nil); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@ -206,124 +206,6 @@ func (s *FavoriteService) DeleteFolder(userID, folderID uint) error {
|
||||
})
|
||||
}
|
||||
|
||||
// AddToFolder 将文章加入收藏夹(会先移除该文章在其他收藏夹的收藏)
|
||||
func (s *FavoriteService) AddToFolder(userID, folderID, postID uint) error {
|
||||
// 检查收藏夹归属
|
||||
f, err := s.repo.FindFolderByID(folderID)
|
||||
if err != nil {
|
||||
return common.ErrPostNotFound
|
||||
}
|
||||
if f.UserID != userID {
|
||||
return common.ErrPermissionDenied
|
||||
}
|
||||
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
txRepo := s.repo.WithTx(tx)
|
||||
|
||||
// 如果该文章已在其他收藏夹中,先移除
|
||||
existing, err := txRepo.FindItemByUserAndPost(userID, postID)
|
||||
if err == nil && existing.FolderID != folderID {
|
||||
if err := txRepo.DeleteItem(existing.FolderID, postID); err != nil {
|
||||
return fmt.Errorf("移除旧收藏: %w", err)
|
||||
}
|
||||
// 旧收藏夹没有增加计数,所以不需要减
|
||||
}
|
||||
|
||||
// 检查是否已在目标收藏夹中
|
||||
if err == nil && existing.FolderID == folderID {
|
||||
return fmt.Errorf("文章已在此收藏夹中")
|
||||
}
|
||||
|
||||
item := &model.FolderItem{
|
||||
FolderID: folderID,
|
||||
PostID: postID,
|
||||
UserID: userID,
|
||||
}
|
||||
if err := txRepo.CreateItem(item); err != nil {
|
||||
return fmt.Errorf("添加收藏: %w", err)
|
||||
}
|
||||
|
||||
return txRepo.IncrPostFavoritesCount(postID, 1)
|
||||
})
|
||||
}
|
||||
|
||||
// RemoveFromFolder 从收藏夹移除文章
|
||||
func (s *FavoriteService) RemoveFromFolder(userID, folderID, postID uint) error {
|
||||
f, err := s.repo.FindFolderByID(folderID)
|
||||
if err != nil {
|
||||
return common.ErrPostNotFound
|
||||
}
|
||||
if f.UserID != userID {
|
||||
return common.ErrPermissionDenied
|
||||
}
|
||||
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
txRepo := s.repo.WithTx(tx)
|
||||
if err := txRepo.DeleteItem(folderID, postID); err != nil {
|
||||
return fmt.Errorf("移除收藏: %w", err)
|
||||
}
|
||||
return txRepo.IncrPostFavoritesCount(postID, -1)
|
||||
})
|
||||
}
|
||||
|
||||
// AddFavorite 收藏文章(自动选择或创建默认收藏夹)
|
||||
func (s *FavoriteService) AddFavorite(userID, postID uint, folderID *uint) error {
|
||||
var targetFolderID uint
|
||||
|
||||
if folderID != nil && *folderID > 0 {
|
||||
targetFolderID = *folderID
|
||||
} else {
|
||||
// 使用或创建默认收藏夹
|
||||
f, err := s.ensureDefaultFolder(userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targetFolderID = f.ID
|
||||
}
|
||||
|
||||
return s.AddToFolder(userID, targetFolderID, postID)
|
||||
}
|
||||
|
||||
// RemoveFavorite 取消收藏文章
|
||||
func (s *FavoriteService) RemoveFavorite(userID, postID uint) error {
|
||||
existing, err := s.repo.FindItemByUserAndPost(userID, postID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("未收藏该文章")
|
||||
}
|
||||
return s.RemoveFromFolder(userID, existing.FolderID, postID)
|
||||
}
|
||||
|
||||
// ListFolderItems 列收藏夹内的文章
|
||||
func (s *FavoriteService) ListFolderItems(userID, folderID uint, page, pageSize int) (*FolderItemsResult, error) {
|
||||
f, err := s.repo.FindFolderByID(folderID)
|
||||
if err != nil {
|
||||
return nil, common.ErrPostNotFound
|
||||
}
|
||||
|
||||
// 权限检查:非本人 + 非公开 → 拒绝
|
||||
if f.UserID != userID && !f.IsPublic {
|
||||
return nil, common.ErrPermissionDenied
|
||||
}
|
||||
|
||||
p := common.Pagination{Page: page, PageSize: pageSize}
|
||||
p.DefaultPagination()
|
||||
|
||||
items, total, err := s.repo.ListItemsByFolder(folderID, p.Offset(), p.PageSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询收藏内容: %w", err)
|
||||
}
|
||||
if items == nil {
|
||||
items = []model.FolderItem{}
|
||||
}
|
||||
|
||||
return &FolderItemsResult{
|
||||
Items: items,
|
||||
Total: total,
|
||||
Page: p.Page,
|
||||
TotalPages: common.PageCount(total, p.PageSize),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetPostFavoriteStatus 查询文章收藏状态
|
||||
func (s *FavoriteService) GetPostFavoriteStatus(userID, postID uint) (*FavoriteStatus, error) {
|
||||
item, err := s.repo.FindItemByUserAndPost(userID, postID)
|
||||
@ -343,21 +225,3 @@ func (s *FavoriteService) GetPostFavoriteStatus(userID, postID uint) (*FavoriteS
|
||||
FolderName: folderName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ToggleFavorite 切换收藏(有则取消,无则加入默认收藏夹)
|
||||
func (s *FavoriteService) ToggleFavorite(userID, postID uint) (bool, error) {
|
||||
existing, err := s.repo.FindItemByUserAndPost(userID, postID)
|
||||
if err == nil {
|
||||
// 已收藏,取消
|
||||
if err := s.RemoveFromFolder(userID, existing.FolderID, postID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// 未收藏,加入默认
|
||||
if err := s.AddFavorite(userID, postID, nil); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
125
internal/service/notification_notify.go
Normal file
125
internal/service/notification_notify.go
Normal file
@ -0,0 +1,125 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"metazone.cc/metalab/internal/model"
|
||||
)
|
||||
|
||||
// checkAndNotifyAggregation 检查并生成点赞聚合通知
|
||||
func (s *NotificationService) checkAndNotifyAggregation(userID uint) {
|
||||
summaries, err := s.repo.GetUnnotifiedDailyLikeSummaries(userID)
|
||||
if err != nil || len(summaries) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for _, sm := range summaries {
|
||||
count := countUIDs(sm.LikerUIDs)
|
||||
var content string
|
||||
if count <= 3 {
|
||||
content = fmt.Sprintf("你的文章被 %d 人点赞了", count)
|
||||
} else {
|
||||
content = fmt.Sprintf("你的文章被 %d 人点赞了", count)
|
||||
}
|
||||
_ = s.Create(userID, model.NotifyLikeAggregated,
|
||||
"点赞汇总通知",
|
||||
content,
|
||||
nil, nil)
|
||||
_ = s.repo.MarkDailyLikeSummaryNotified(sm.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func countUIDs(uids string) int {
|
||||
if uids == "" {
|
||||
return 0
|
||||
}
|
||||
count := 1
|
||||
for _, c := range uids {
|
||||
if c == ',' {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// categoryTypes 分类→通知类型列表
|
||||
func categoryTypes(category string) []string {
|
||||
switch category {
|
||||
case "system":
|
||||
return []string{model.NotifyAuditApproved, model.NotifyAuditRejected, model.NotifyPostApproved, model.NotifyPostRejected, model.NotifyLevelUp}
|
||||
case "mention":
|
||||
return []string{model.NotifyComment, model.NotifyCommentReply}
|
||||
case "like":
|
||||
return []string{model.NotifyLikeAggregated}
|
||||
case "follow":
|
||||
return []string{model.NotifyFollow}
|
||||
default:
|
||||
return []string{}
|
||||
}
|
||||
}
|
||||
|
||||
// sortNotifications 按 CreatedAt 降序排序
|
||||
func sortNotifications(items []model.Notification) {
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].CreatedAt.After(items[j].CreatedAt)
|
||||
})
|
||||
}
|
||||
|
||||
// NotifyAuditApproved 审核通过通知
|
||||
func (s *NotificationService) NotifyAuditApproved(userID uint, auditType string, relatedID uint) {
|
||||
typeName := model.AuditTypeNames[auditType]
|
||||
if typeName == "" {
|
||||
typeName = auditType
|
||||
}
|
||||
_ = s.Create(userID, model.NotifyAuditApproved,
|
||||
"审核已通过",
|
||||
"你的"+typeName+"修改已通过审核",
|
||||
&relatedID, nil)
|
||||
}
|
||||
|
||||
// NotifyAuditRejected 审核拒绝通知
|
||||
func (s *NotificationService) NotifyAuditRejected(userID uint, auditType, reason string, relatedID uint) {
|
||||
typeName := model.AuditTypeNames[auditType]
|
||||
if typeName == "" {
|
||||
typeName = auditType
|
||||
}
|
||||
content := "你的" + typeName + "修改被拒绝"
|
||||
if reason != "" {
|
||||
content += ",理由:" + reason
|
||||
}
|
||||
_ = s.Create(userID, model.NotifyAuditRejected,
|
||||
"审核未通过",
|
||||
content,
|
||||
&relatedID, nil)
|
||||
}
|
||||
|
||||
// NotifyPostApproved 稿件审核通过通知
|
||||
func (s *NotificationService) NotifyPostApproved(userID uint, postTitle string, postID uint) {
|
||||
title := "稿件审核通过"
|
||||
content := "你的稿件《" + postTitle + "》已通过审核,现已公开发布"
|
||||
_ = s.Create(userID, model.NotifyPostApproved, title, content, &postID, nil)
|
||||
}
|
||||
|
||||
// NotifyPostRejected 稿件审核拒绝通知
|
||||
func (s *NotificationService) NotifyPostRejected(userID uint, postTitle, reason string, postID uint) {
|
||||
title := "稿件审核未通过"
|
||||
content := "你的稿件《" + postTitle + "》未通过审核"
|
||||
if reason != "" {
|
||||
content += ",理由:" + reason
|
||||
}
|
||||
_ = s.Create(userID, model.NotifyPostRejected, title, content, &postID, nil)
|
||||
}
|
||||
|
||||
// NotifyFollow 关注通知(followerID 关注了 followeeID)
|
||||
func (s *NotificationService) NotifyFollow(followerID, followeeID uint) {
|
||||
// 获取关注者用户名
|
||||
followerName := s.getUsername(followerID)
|
||||
if followerName == "" {
|
||||
followerName = "一位用户"
|
||||
}
|
||||
title := "关注通知"
|
||||
content := followerName + " 关注了你"
|
||||
relID := followerID
|
||||
_ = s.Create(followeeID, model.NotifyFollow, title, content, &relID, nil)
|
||||
}
|
||||
@ -2,8 +2,6 @@ package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
@ -167,65 +165,6 @@ func (s *NotificationService) ListByCategory(userID uint, category string, page,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// checkAndNotifyAggregation 检查并生成点赞聚合通知
|
||||
func (s *NotificationService) checkAndNotifyAggregation(userID uint) {
|
||||
summaries, err := s.repo.GetUnnotifiedDailyLikeSummaries(userID)
|
||||
if err != nil || len(summaries) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for _, sm := range summaries {
|
||||
count := countUIDs(sm.LikerUIDs)
|
||||
var content string
|
||||
if count <= 3 {
|
||||
content = fmt.Sprintf("你的文章被 %d 人点赞了", count)
|
||||
} else {
|
||||
content = fmt.Sprintf("你的文章被 %d 人点赞了", count)
|
||||
}
|
||||
_ = s.Create(userID, model.NotifyLikeAggregated,
|
||||
"点赞汇总通知",
|
||||
content,
|
||||
nil, nil)
|
||||
_ = s.repo.MarkDailyLikeSummaryNotified(sm.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func countUIDs(uids string) int {
|
||||
if uids == "" {
|
||||
return 0
|
||||
}
|
||||
count := 1
|
||||
for _, c := range uids {
|
||||
if c == ',' {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// categoryTypes 分类→通知类型列表
|
||||
func categoryTypes(category string) []string {
|
||||
switch category {
|
||||
case "system":
|
||||
return []string{model.NotifyAuditApproved, model.NotifyAuditRejected, model.NotifyPostApproved, model.NotifyPostRejected, model.NotifyLevelUp}
|
||||
case "mention":
|
||||
return []string{model.NotifyComment, model.NotifyCommentReply}
|
||||
case "like":
|
||||
return []string{model.NotifyLikeAggregated}
|
||||
case "follow":
|
||||
return []string{model.NotifyFollow}
|
||||
default:
|
||||
return []string{}
|
||||
}
|
||||
}
|
||||
|
||||
// sortNotifications 按 CreatedAt 降序排序
|
||||
func sortNotifications(items []model.Notification) {
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].CreatedAt.After(items[j].CreatedAt)
|
||||
})
|
||||
}
|
||||
|
||||
// CountUnread 统计未读消息数(排除用户禁用的通知类型)
|
||||
func (s *NotificationService) CountUnread(userID uint) (int64, error) {
|
||||
excludeTypes := s.getDisabledTypes(userID)
|
||||
@ -263,64 +202,6 @@ func (s *NotificationService) MarkAllRead(userID uint) error {
|
||||
return s.repo.MarkAllRead(userID)
|
||||
}
|
||||
|
||||
// NotifyAuditApproved 审核通过通知
|
||||
func (s *NotificationService) NotifyAuditApproved(userID uint, auditType string, relatedID uint) {
|
||||
typeName := model.AuditTypeNames[auditType]
|
||||
if typeName == "" {
|
||||
typeName = auditType
|
||||
}
|
||||
_ = s.Create(userID, model.NotifyAuditApproved,
|
||||
"审核已通过",
|
||||
"你的"+typeName+"修改已通过审核",
|
||||
&relatedID, nil)
|
||||
}
|
||||
|
||||
// NotifyAuditRejected 审核拒绝通知
|
||||
func (s *NotificationService) NotifyAuditRejected(userID uint, auditType, reason string, relatedID uint) {
|
||||
typeName := model.AuditTypeNames[auditType]
|
||||
if typeName == "" {
|
||||
typeName = auditType
|
||||
}
|
||||
content := "你的" + typeName + "修改被拒绝"
|
||||
if reason != "" {
|
||||
content += ",理由:" + reason
|
||||
}
|
||||
_ = s.Create(userID, model.NotifyAuditRejected,
|
||||
"审核未通过",
|
||||
content,
|
||||
&relatedID, nil)
|
||||
}
|
||||
|
||||
// NotifyPostApproved 稿件审核通过通知
|
||||
func (s *NotificationService) NotifyPostApproved(userID uint, postTitle string, postID uint) {
|
||||
title := "稿件审核通过"
|
||||
content := "你的稿件《" + postTitle + "》已通过审核,现已公开发布"
|
||||
_ = s.Create(userID, model.NotifyPostApproved, title, content, &postID, nil)
|
||||
}
|
||||
|
||||
// NotifyPostRejected 稿件审核拒绝通知
|
||||
func (s *NotificationService) NotifyPostRejected(userID uint, postTitle, reason string, postID uint) {
|
||||
title := "稿件审核未通过"
|
||||
content := "你的稿件《" + postTitle + "》未通过审核"
|
||||
if reason != "" {
|
||||
content += ",理由:" + reason
|
||||
}
|
||||
_ = s.Create(userID, model.NotifyPostRejected, title, content, &postID, nil)
|
||||
}
|
||||
|
||||
// NotifyFollow 关注通知(followerID 关注了 followeeID)
|
||||
func (s *NotificationService) NotifyFollow(followerID, followeeID uint) {
|
||||
// 获取关注者用户名
|
||||
followerName := s.getUsername(followerID)
|
||||
if followerName == "" {
|
||||
followerName = "一位用户"
|
||||
}
|
||||
title := "关注通知"
|
||||
content := followerName + " 关注了你"
|
||||
relID := followerID
|
||||
_ = s.Create(followeeID, model.NotifyFollow, title, content, &relID, nil)
|
||||
}
|
||||
|
||||
// getUsername 获取用户名(辅助方法)
|
||||
func (s *NotificationService) getUsername(userID uint) string {
|
||||
name, err := s.repo.GetUsernameByID(userID)
|
||||
|
||||
186
internal/service/post_audit_service.go
Normal file
186
internal/service/post_audit_service.go
Normal file
@ -0,0 +1,186 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
)
|
||||
|
||||
// Update 编辑帖子(权限在 controller 层检查)
|
||||
func (s *PostService) Update(postID uint, title, body string) error {
|
||||
post, err := s.findPost(postID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if post.Status == model.PostStatusPending || post.IsLocked {
|
||||
return common.ErrPostCannotEdit
|
||||
}
|
||||
|
||||
// 已通过帖子:编辑内容保存为待审修订,不影响当前展示
|
||||
if post.Status == model.PostStatusApproved {
|
||||
post.PendingTitle = title
|
||||
post.PendingBody = body
|
||||
post.RejectReason = "" // 清空旧退回理由
|
||||
return s.repo.Update(post)
|
||||
}
|
||||
|
||||
// 草稿/已退回:直接修改原文(现有逻辑)
|
||||
post.Title = title
|
||||
post.Body = body
|
||||
post.Excerpt = generateExcerpt(body)
|
||||
|
||||
if post.Status == model.PostStatusRejected {
|
||||
post.Status = model.PostStatusDraft
|
||||
post.RejectReason = ""
|
||||
}
|
||||
|
||||
return s.repo.Update(post)
|
||||
}
|
||||
|
||||
// Delete 软删除(权限在 controller 层检查)
|
||||
func (s *PostService) Delete(postID uint) error {
|
||||
return s.repo.SoftDelete(postID)
|
||||
}
|
||||
|
||||
// SubmitForAudit 提交审核:draft → pending
|
||||
func (s *PostService) SubmitForAudit(postID uint) error {
|
||||
post, err := s.findPost(postID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if post.IsLocked {
|
||||
return common.ErrPostCannotEdit
|
||||
}
|
||||
if post.Status != model.PostStatusDraft && post.Status != model.PostStatusRejected {
|
||||
return common.ErrPostCannotSubmit
|
||||
}
|
||||
post.Status = model.PostStatusPending
|
||||
return s.repo.Update(post)
|
||||
}
|
||||
|
||||
// Approve 审核通过:pending → approved;或修订审核通过复制 Pending 到正式字段
|
||||
func (s *PostService) Approve(postID uint) error {
|
||||
post, err := s.findPost(postID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
wasRevision := post.PendingBody != ""
|
||||
|
||||
// 修订审核:将待审内容覆盖到正式字段
|
||||
if wasRevision {
|
||||
post.Title = post.PendingTitle
|
||||
post.Body = post.PendingBody
|
||||
post.Excerpt = generateExcerpt(post.PendingBody)
|
||||
post.PendingTitle = ""
|
||||
post.PendingBody = ""
|
||||
post.Status = model.PostStatusApproved
|
||||
post.RejectReason = ""
|
||||
if err := s.repo.Update(post); err != nil {
|
||||
return err
|
||||
}
|
||||
// 通知作者修订审核通过
|
||||
if s.notifier != nil {
|
||||
s.notifier.NotifyPostApproved(post.UserID, post.Title, post.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 首次审核通过
|
||||
if post.Status != model.PostStatusPending {
|
||||
return common.ErrPostCannotApprove
|
||||
}
|
||||
post.Status = model.PostStatusApproved
|
||||
post.RejectReason = ""
|
||||
if err := s.repo.Update(post); err != nil {
|
||||
return err
|
||||
}
|
||||
// 通知作者审核通过
|
||||
if s.notifier != nil {
|
||||
s.notifier.NotifyPostApproved(post.UserID, post.Title, post.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reject 退回:pending/approved → rejected;修订退回则清空 Pending 保持 approved
|
||||
func (s *PostService) Reject(postID uint, reason string) error {
|
||||
post, err := s.findPost(postID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if post.Status != model.PostStatusPending && post.Status != model.PostStatusApproved {
|
||||
return common.ErrPostCannotReject
|
||||
}
|
||||
|
||||
// 修订退回:清空待审修订,保持已发布内容不变
|
||||
if post.PendingBody != "" {
|
||||
post.PendingTitle = ""
|
||||
post.PendingBody = ""
|
||||
post.RejectReason = reason
|
||||
if err := s.repo.Update(post); err != nil {
|
||||
return err
|
||||
}
|
||||
// 通知作者修订被退回
|
||||
if s.notifier != nil {
|
||||
s.notifier.NotifyPostRejected(post.UserID, post.Title, reason, post.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 普通退回(首次审核)
|
||||
post.Status = model.PostStatusRejected
|
||||
post.RejectReason = reason
|
||||
if err := s.repo.Update(post); err != nil {
|
||||
return err
|
||||
}
|
||||
// 通知作者审核被退回
|
||||
if s.notifier != nil {
|
||||
s.notifier.NotifyPostRejected(post.UserID, post.Title, reason, post.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Lock 锁定:设置 IsLocked 标志,禁止编辑,不改变帖子发布状态
|
||||
func (s *PostService) Lock(postID uint, reason string) error {
|
||||
post, err := s.findPost(postID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 待审核帖子不允许锁定(审核流程中)
|
||||
if post.Status == model.PostStatusPending {
|
||||
return common.ErrPostCannotLock
|
||||
}
|
||||
post.IsLocked = true
|
||||
post.LockReason = reason
|
||||
return s.repo.Update(post)
|
||||
}
|
||||
|
||||
// Unlock 解锁:清除 IsLocked 标志,恢复可编辑
|
||||
func (s *PostService) Unlock(postID uint) error {
|
||||
post, err := s.findPost(postID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !post.IsLocked {
|
||||
return common.ErrPostCannotUnlock
|
||||
}
|
||||
post.IsLocked = false
|
||||
post.LockReason = ""
|
||||
return s.repo.Update(post)
|
||||
}
|
||||
|
||||
// Restore 恢复软删除
|
||||
func (s *PostService) Restore(postID uint) error {
|
||||
err := s.repo.Restore(postID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListPendingRevisions 查询有待审修订的帖子(approved 且 PendingBody 不为空)
|
||||
func (s *PostService) ListPendingRevisions(keyword string, page, pageSize int) ([]model.Post, int64, error) {
|
||||
return s.listPageable(page, pageSize, func(offset, limit int) ([]model.Post, int64, error) {
|
||||
return s.repo.FindPendingRevisions(keyword, offset, limit)
|
||||
})
|
||||
}
|
||||
66
internal/service/post_helpers.go
Normal file
66
internal/service/post_helpers.go
Normal file
@ -0,0 +1,66 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// mdPatterns Markdown 语法正则(用于生成纯文本摘要)
|
||||
var mdPatterns = []*regexp.Regexp{
|
||||
// 代码块 ```...```
|
||||
regexp.MustCompile("(?s)```[^`]*```"),
|
||||
// 行内代码 `...`
|
||||
regexp.MustCompile("`[^`]+`"),
|
||||
// 图片 
|
||||
regexp.MustCompile(`!\[.*?\]\(.*?\)`),
|
||||
// 链接 [text](url)
|
||||
regexp.MustCompile(`\[([^\]]*)\]\(.*?\)`),
|
||||
// 标题标记 #
|
||||
regexp.MustCompile(`^#{1,6}\s+`),
|
||||
// 粗体/斜体/删除线标记
|
||||
regexp.MustCompile(`\*{1,3}|_{1,3}|~~`),
|
||||
// 引用 >
|
||||
regexp.MustCompile(`^>\s?`),
|
||||
// 列表标记
|
||||
regexp.MustCompile(`^[\s]*[-*+]\s+`),
|
||||
regexp.MustCompile(`^[\s]*\d+\.\s+`),
|
||||
// 分割线
|
||||
regexp.MustCompile(`^[-*_]{3,}\s*$`),
|
||||
}
|
||||
|
||||
// generateExcerpt 从 Markdown 生成纯文本摘要(最多 300 字符)
|
||||
// MD 作为纯文本存储无 XSS 风险,前端由 Vditor 渲染
|
||||
func generateExcerpt(md string) string {
|
||||
text := md
|
||||
|
||||
// 按行处理,保留行结构
|
||||
lines := strings.Split(text, "\n")
|
||||
var cleaned []string
|
||||
for _, line := range lines {
|
||||
cleanedLine := line
|
||||
for _, re := range mdPatterns {
|
||||
if re == mdPatterns[2] {
|
||||
// 链接保留文字: [text](url) → text
|
||||
cleanedLine = re.ReplaceAllString(cleanedLine, "$1")
|
||||
} else {
|
||||
cleanedLine = re.ReplaceAllString(cleanedLine, "")
|
||||
}
|
||||
}
|
||||
cleanedLine = strings.TrimSpace(cleanedLine)
|
||||
if cleanedLine != "" {
|
||||
cleaned = append(cleaned, cleanedLine)
|
||||
}
|
||||
}
|
||||
|
||||
result := strings.Join(cleaned, " ")
|
||||
|
||||
// 按 rune 截断,不切中文字符
|
||||
const maxChars = 300
|
||||
if utf8.RuneCountInString(result) > maxChars {
|
||||
runes := []rune(result)
|
||||
result = string(runes[:maxChars]) + "..."
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@ -3,9 +3,6 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/config"
|
||||
@ -14,65 +11,6 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// mdPatterns Markdown 语法正则(用于生成纯文本摘要)
|
||||
var mdPatterns = []*regexp.Regexp{
|
||||
// 代码块 ```...```
|
||||
regexp.MustCompile("(?s)```[^`]*```"),
|
||||
// 行内代码 `...`
|
||||
regexp.MustCompile("`[^`]+`"),
|
||||
// 图片 
|
||||
regexp.MustCompile(`!\[.*?\]\(.*?\)`),
|
||||
// 链接 [text](url)
|
||||
regexp.MustCompile(`\[([^\]]*)\]\(.*?\)`),
|
||||
// 标题标记 #
|
||||
regexp.MustCompile(`^#{1,6}\s+`),
|
||||
// 粗体/斜体/删除线标记
|
||||
regexp.MustCompile(`\*{1,3}|_{1,3}|~~`),
|
||||
// 引用 >
|
||||
regexp.MustCompile(`^>\s?`),
|
||||
// 列表标记
|
||||
regexp.MustCompile(`^[\s]*[-*+]\s+`),
|
||||
regexp.MustCompile(`^[\s]*\d+\.\s+`),
|
||||
// 分割线
|
||||
regexp.MustCompile(`^[-*_]{3,}\s*$`),
|
||||
}
|
||||
|
||||
// generateExcerpt 从 Markdown 生成纯文本摘要(最多 300 字符)
|
||||
// MD 作为纯文本存储无 XSS 风险,前端由 Vditor 渲染
|
||||
func generateExcerpt(md string) string {
|
||||
text := md
|
||||
|
||||
// 按行处理,保留行结构
|
||||
lines := strings.Split(text, "\n")
|
||||
var cleaned []string
|
||||
for _, line := range lines {
|
||||
cleanedLine := line
|
||||
for _, re := range mdPatterns {
|
||||
if re == mdPatterns[2] {
|
||||
// 链接保留文字: [text](url) → text
|
||||
cleanedLine = re.ReplaceAllString(cleanedLine, "$1")
|
||||
} else {
|
||||
cleanedLine = re.ReplaceAllString(cleanedLine, "")
|
||||
}
|
||||
}
|
||||
cleanedLine = strings.TrimSpace(cleanedLine)
|
||||
if cleanedLine != "" {
|
||||
cleaned = append(cleaned, cleanedLine)
|
||||
}
|
||||
}
|
||||
|
||||
result := strings.Join(cleaned, " ")
|
||||
|
||||
// 按 rune 截断,不切中文字符
|
||||
const maxChars = 300
|
||||
if utf8.RuneCountInString(result) > maxChars {
|
||||
runes := []rune(result)
|
||||
result = string(runes[:maxChars]) + "..."
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// PostService 帖子业务逻辑
|
||||
type PostService struct {
|
||||
repo postStore
|
||||
@ -202,189 +140,6 @@ func (s *PostService) GetOverview(userID uint) (*StudioOverview, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Update 编辑帖子(权限在 controller 层检查)
|
||||
func (s *PostService) Update(postID uint, title, body string) error {
|
||||
post, err := s.findPost(postID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if post.Status == model.PostStatusPending || post.IsLocked {
|
||||
return common.ErrPostCannotEdit
|
||||
}
|
||||
|
||||
// 已通过帖子:编辑内容保存为待审修订,不影响当前展示
|
||||
if post.Status == model.PostStatusApproved {
|
||||
post.PendingTitle = title
|
||||
post.PendingBody = body
|
||||
post.RejectReason = "" // 清空旧退回理由
|
||||
return s.repo.Update(post)
|
||||
}
|
||||
|
||||
// 草稿/已退回:直接修改原文(现有逻辑)
|
||||
post.Title = title
|
||||
post.Body = body
|
||||
post.Excerpt = generateExcerpt(body)
|
||||
|
||||
if post.Status == model.PostStatusRejected {
|
||||
post.Status = model.PostStatusDraft
|
||||
post.RejectReason = ""
|
||||
}
|
||||
|
||||
return s.repo.Update(post)
|
||||
}
|
||||
|
||||
// Delete 软删除(权限在 controller 层检查)
|
||||
func (s *PostService) Delete(postID uint) error {
|
||||
return s.repo.SoftDelete(postID)
|
||||
}
|
||||
|
||||
// SubmitForAudit 提交审核:draft → pending
|
||||
func (s *PostService) SubmitForAudit(postID uint) error {
|
||||
post, err := s.findPost(postID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if post.IsLocked {
|
||||
return common.ErrPostCannotEdit
|
||||
}
|
||||
if post.Status != model.PostStatusDraft && post.Status != model.PostStatusRejected {
|
||||
return common.ErrPostCannotSubmit
|
||||
}
|
||||
post.Status = model.PostStatusPending
|
||||
return s.repo.Update(post)
|
||||
}
|
||||
|
||||
// Approve 审核通过:pending → approved;或修订审核通过复制 Pending 到正式字段
|
||||
func (s *PostService) Approve(postID uint) error {
|
||||
post, err := s.findPost(postID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
wasRevision := post.PendingBody != ""
|
||||
|
||||
// 修订审核:将待审内容覆盖到正式字段
|
||||
if wasRevision {
|
||||
post.Title = post.PendingTitle
|
||||
post.Body = post.PendingBody
|
||||
post.Excerpt = generateExcerpt(post.PendingBody)
|
||||
post.PendingTitle = ""
|
||||
post.PendingBody = ""
|
||||
post.Status = model.PostStatusApproved
|
||||
post.RejectReason = ""
|
||||
if err := s.repo.Update(post); err != nil {
|
||||
return err
|
||||
}
|
||||
// 通知作者修订审核通过
|
||||
if s.notifier != nil {
|
||||
s.notifier.NotifyPostApproved(post.UserID, post.Title, post.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 首次审核通过
|
||||
if post.Status != model.PostStatusPending {
|
||||
return common.ErrPostCannotApprove
|
||||
}
|
||||
post.Status = model.PostStatusApproved
|
||||
post.RejectReason = ""
|
||||
if err := s.repo.Update(post); err != nil {
|
||||
return err
|
||||
}
|
||||
// 通知作者审核通过
|
||||
if s.notifier != nil {
|
||||
s.notifier.NotifyPostApproved(post.UserID, post.Title, post.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reject 退回:pending/approved → rejected;修订退回则清空 Pending 保持 approved
|
||||
func (s *PostService) Reject(postID uint, reason string) error {
|
||||
post, err := s.findPost(postID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if post.Status != model.PostStatusPending && post.Status != model.PostStatusApproved {
|
||||
return common.ErrPostCannotReject
|
||||
}
|
||||
|
||||
// 修订退回:清空待审修订,保持已发布内容不变
|
||||
if post.PendingBody != "" {
|
||||
post.PendingTitle = ""
|
||||
post.PendingBody = ""
|
||||
post.RejectReason = reason
|
||||
if err := s.repo.Update(post); err != nil {
|
||||
return err
|
||||
}
|
||||
// 通知作者修订被退回
|
||||
if s.notifier != nil {
|
||||
s.notifier.NotifyPostRejected(post.UserID, post.Title, reason, post.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 普通退回(首次审核)
|
||||
post.Status = model.PostStatusRejected
|
||||
post.RejectReason = reason
|
||||
if err := s.repo.Update(post); err != nil {
|
||||
return err
|
||||
}
|
||||
// 通知作者审核被退回
|
||||
if s.notifier != nil {
|
||||
s.notifier.NotifyPostRejected(post.UserID, post.Title, reason, post.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Lock 锁定:设置 IsLocked 标志,禁止编辑,不改变帖子发布状态
|
||||
func (s *PostService) Lock(postID uint, reason string) error {
|
||||
post, err := s.findPost(postID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 待审核帖子不允许锁定(审核流程中)
|
||||
if post.Status == model.PostStatusPending {
|
||||
return common.ErrPostCannotLock
|
||||
}
|
||||
post.IsLocked = true
|
||||
post.LockReason = reason
|
||||
return s.repo.Update(post)
|
||||
}
|
||||
|
||||
// Unlock 解锁:清除 IsLocked 标志,恢复可编辑
|
||||
func (s *PostService) Unlock(postID uint) error {
|
||||
post, err := s.findPost(postID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !post.IsLocked {
|
||||
return common.ErrPostCannotUnlock
|
||||
}
|
||||
post.IsLocked = false
|
||||
post.LockReason = ""
|
||||
return s.repo.Update(post)
|
||||
}
|
||||
|
||||
// Restore 恢复软删除
|
||||
func (s *PostService) Restore(postID uint) error {
|
||||
err := s.repo.Restore(postID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return common.ErrPostNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListPendingRevisions 查询有待审修订的帖子(approved 且 PendingBody 不为空)
|
||||
func (s *PostService) ListPendingRevisions(keyword string, page, pageSize int) ([]model.Post, int64, error) {
|
||||
return s.listPageable(page, pageSize, func(offset, limit int) ([]model.Post, int64, error) {
|
||||
return s.repo.FindPendingRevisions(keyword, offset, limit)
|
||||
})
|
||||
}
|
||||
|
||||
// IsPostAccessible 检查用户是否有权限访问/操作帖子(作者或 moderator+)
|
||||
func (s *PostService) IsPostAccessible(userID uint, role string, postUserID uint) bool {
|
||||
return userID == postUserID || model.HasMinRole(role, model.RoleModerator)
|
||||
|
||||
Reference in New Issue
Block a user