feat: 实现域能系统
- 新增域能完整分层:EnergyLog/PostEnergizeLog/DailyExpSummary 模型、EnergyRepo、EnergyService、EnergyController/AdminEnergyController - 支持签到获取域能(+1)、帖子赋能(-1/-2)/被赋能(+1/+2)、改名扣能(-6)/退款(+6)、删帖扣能(-2) - 赋能单人单帖上限20域能,每日赋能获得经验上限50 - 管理员支持批量调整域能(owner)和查询域能日志(admin) - LV0用户(exp<1)禁止签到和发帖,余额为负可签到/被赋能/删帖但不能赋能/改名 - 首次改名免费(通过HasCompletedTask判重),改名扣能在提交审核时执行 - 移除导航栏手动签到按钮,签到融入域能系统(自动签到) - 新增个人中心域能TAB展示余额和流水日志(近7天) - 新增帖子详情页赋能按钮(轻赋/重赋) - 管理后台新增域能管理和域能日志页面
This commit is contained in:
@ -45,4 +45,11 @@ var (
|
||||
ErrPostCannotReject = errors.New("仅待审核和已发布帖子可退回")
|
||||
ErrPostCannotLock = errors.New("待审核帖子不允许锁定")
|
||||
ErrPostCannotUnlock = errors.New("仅锁定状态可解锁")
|
||||
|
||||
// 域能相关
|
||||
ErrInvalidEnergyAmount = errors.New("无效的赋能数量")
|
||||
ErrCannotEnergizeSelf = errors.New("不能给自己的文章赋能")
|
||||
ErrInsufficientEnergy = errors.New("域能不足,可通过每日签到或创作被赋能获取")
|
||||
ErrEnergizeLimitReached = errors.New("对该文章赋能已达上限")
|
||||
ErrCreatePostNoExp = errors.New("经验不足,Lv1 解锁投稿")
|
||||
)
|
||||
|
||||
103
internal/controller/admin/admin_energy_controller.go
Normal file
103
internal/controller/admin/admin_energy_controller.go
Normal file
@ -0,0 +1,103 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// adminEnergyUseCase AdminEnergyController 对 EnergyService 的最小依赖(ISP:2 个方法)
|
||||
type adminEnergyUseCase interface {
|
||||
AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string) error
|
||||
GetAdminEnergyLogs(energyType string, page, pageSize int) ([]model.EnergyLog, int64, error)
|
||||
}
|
||||
|
||||
// AdminEnergyController 后台域能管理控制器
|
||||
type AdminEnergyController struct {
|
||||
energySvc adminEnergyUseCase
|
||||
}
|
||||
|
||||
// NewAdminEnergyController 构造函数
|
||||
func NewAdminEnergyController(energySvc adminEnergyUseCase) *AdminEnergyController {
|
||||
return &AdminEnergyController{energySvc: energySvc}
|
||||
}
|
||||
|
||||
// EnergyPage 域能管理页面
|
||||
func (ac *AdminEnergyController) EnergyPage(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "admin/energy/index.html", common.BuildAdminPageData(c, gin.H{
|
||||
"Title": "域能管理",
|
||||
}))
|
||||
}
|
||||
|
||||
// EnergyLogPage 域能日志页面
|
||||
func (ac *AdminEnergyController) EnergyLogPage(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "admin/energy/logs.html", common.BuildAdminPageData(c, gin.H{
|
||||
"Title": "域能日志",
|
||||
}))
|
||||
}
|
||||
|
||||
// AdjustEnergy 调整用户域能
|
||||
func (ac *AdminEnergyController) AdjustEnergy(c *gin.Context) {
|
||||
operatorUID, ok := c.Get("uid")
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
UserIDs []uint `json:"user_ids" binding:"required,min=1"`
|
||||
Amount int `json:"amount" binding:"required"`
|
||||
Description string `json:"description" binding:"required,min=1,max=500"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "参数错误:需要 user_ids、amount 和 description")
|
||||
return
|
||||
}
|
||||
|
||||
if err := ac.energySvc.AdminAdjust(operatorUID.(uint), req.UserIDs, req.Amount, req.Description); err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "调整失败")
|
||||
return
|
||||
}
|
||||
|
||||
common.OkMessage(c, "域能调整成功")
|
||||
}
|
||||
|
||||
// ListEnergyLogs 查询域能日志
|
||||
func (ac *AdminEnergyController) ListEnergyLogs(c *gin.Context) {
|
||||
energyType := c.Query("type")
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
logs, total, err := ac.energySvc.GetAdminEnergyLogs(energyType, page, pageSize)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "查询失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为展示友好的视图
|
||||
type logView struct {
|
||||
model.EnergyLog
|
||||
EnergyDisplay float64 `json:"energy_display"`
|
||||
TypeName string `json:"type_name"`
|
||||
}
|
||||
views := make([]logView, len(logs))
|
||||
for i, l := range logs {
|
||||
views[i] = logView{
|
||||
EnergyLog: l,
|
||||
EnergyDisplay: float64(l.Amount) / 10,
|
||||
TypeName: model.EnergyLogDisplayNames[l.Type],
|
||||
}
|
||||
}
|
||||
|
||||
common.Ok(c, gin.H{
|
||||
"items": views,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total_pages": common.PageCount(total, pageSize),
|
||||
})
|
||||
}
|
||||
146
internal/controller/energy_controller.go
Normal file
146
internal/controller/energy_controller.go
Normal file
@ -0,0 +1,146 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// EnergyController 域能 API 控制器
|
||||
type EnergyController struct {
|
||||
energySvc energyUseCase
|
||||
}
|
||||
|
||||
// NewEnergyController 构造函数
|
||||
func NewEnergyController(energySvc energyUseCase) *EnergyController {
|
||||
return &EnergyController{energySvc: energySvc}
|
||||
}
|
||||
|
||||
// Energize 赋能文章
|
||||
func (ec *EnergyController) Energize(c *gin.Context) {
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
PostID uint `json:"post_id" binding:"required"`
|
||||
Amount int `json:"amount" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
expGained, err := ec.energySvc.Energize(uid, req.PostID, req.Amount)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case common.ErrPostNotFound:
|
||||
common.Error(c, http.StatusNotFound, "文章不存在")
|
||||
case common.ErrCannotEnergizeSelf:
|
||||
common.Error(c, http.StatusBadRequest, "不能给自己的文章赋能")
|
||||
case common.ErrInsufficientEnergy:
|
||||
common.Error(c, http.StatusBadRequest, "域能不足,可通过每日签到或创作被赋能获取")
|
||||
case common.ErrEnergizeLimitReached:
|
||||
common.Error(c, http.StatusBadRequest, "对该文章赋能已达上限")
|
||||
case common.ErrInvalidEnergyAmount:
|
||||
common.Error(c, http.StatusBadRequest, "无效的赋能数量")
|
||||
default:
|
||||
common.Error(c, http.StatusInternalServerError, "赋能失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
common.Ok(c, gin.H{
|
||||
"exp_gained": expGained,
|
||||
"message": buildEnergizeMessage(expGained),
|
||||
})
|
||||
}
|
||||
|
||||
// buildEnergizeMessage 根据获得的经验生成提示消息
|
||||
func buildEnergizeMessage(expGained int) string {
|
||||
if expGained > 0 {
|
||||
return "赋能成功!" + strconv.Itoa(expGained) + "经验已加入你的旅程。"
|
||||
}
|
||||
return "赋能成功!今日赋能经验奖励已达上限,对方仍会获得激励。"
|
||||
}
|
||||
|
||||
// GetEnergyInfo 获取域能余额和经验上限状态
|
||||
func (ec *EnergyController) GetEnergyInfo(c *gin.Context) {
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
energy, err := ec.energySvc.GetEnergyInfo(uid)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "获取域能信息失败")
|
||||
return
|
||||
}
|
||||
|
||||
energyDisplay := float64(energy) / 10
|
||||
common.Ok(c, gin.H{
|
||||
"energy": energy,
|
||||
"energy_display": energyDisplay,
|
||||
})
|
||||
}
|
||||
|
||||
// GetEnergyLogs 获取我的域能流水
|
||||
func (ec *EnergyController) GetEnergyLogs(c *gin.Context) {
|
||||
uid, _, ok := common.GetGinUser(c)
|
||||
if !ok {
|
||||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||||
return
|
||||
}
|
||||
|
||||
days, _ := strconv.Atoi(c.DefaultQuery("days", "7"))
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
|
||||
logs, total, err := ec.energySvc.GetEnergyLogs(uid, days, page, pageSize)
|
||||
if err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "获取流水失败")
|
||||
return
|
||||
}
|
||||
|
||||
common.Ok(c, gin.H{
|
||||
"items": toEnergyLogViews(logs),
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total_pages": common.PageCount(total, pageSize),
|
||||
})
|
||||
}
|
||||
|
||||
// EnergyLogView 前端展示用的域能流水视图
|
||||
type EnergyLogView struct {
|
||||
ID uint `json:"id"`
|
||||
Amount int `json:"amount"`
|
||||
AmountDisplay float64 `json:"amount_display"`
|
||||
Type string `json:"type"`
|
||||
TypeName string `json:"type_name"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func toEnergyLogViews(logs []model.EnergyLog) []EnergyLogView {
|
||||
views := make([]EnergyLogView, len(logs))
|
||||
for i, l := range logs {
|
||||
views[i] = EnergyLogView{
|
||||
ID: l.ID,
|
||||
Amount: l.Amount,
|
||||
AmountDisplay: float64(l.Amount) / 10,
|
||||
Type: l.Type,
|
||||
TypeName: model.EnergyLogDisplayNames[l.Type],
|
||||
Description: l.Description,
|
||||
CreatedAt: l.CreatedAt.Format(common.TimeFormat),
|
||||
}
|
||||
}
|
||||
return views
|
||||
}
|
||||
@ -68,3 +68,26 @@ type levelUseCase interface {
|
||||
GetLevel(userID uint) (level int, exp int, checkedIn bool, err error)
|
||||
CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error)
|
||||
}
|
||||
|
||||
// energyUseCase EnergyController 对 EnergyService 的最小依赖(ISP:3 个方法)
|
||||
type energyUseCase interface {
|
||||
Energize(energizerID uint, postID uint, amount int) (int, error)
|
||||
GetEnergyInfo(userID uint) (int, error)
|
||||
GetEnergyLogs(userID uint, days, page, pageSize int) ([]model.EnergyLog, int64, error)
|
||||
}
|
||||
|
||||
// energyDeducter 删稿/改名时域能扣减接口(ISP)
|
||||
type energyDeducter interface {
|
||||
DeductOnDeletePost(authorUserID uint, postID uint) error
|
||||
}
|
||||
|
||||
// energyRenameHandler 改名域能操作接口(ISP)
|
||||
type energyRenameHandler interface {
|
||||
DeductOnRename(userID uint) error
|
||||
}
|
||||
|
||||
// energyAdminUseCase AdminEnergyController 对 EnergyService 的依赖(ISP:2 个方法)
|
||||
type energyAdminUseCase interface {
|
||||
AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string) error
|
||||
GetAdminEnergyLogs(energyType string, page, pageSize int) ([]model.EnergyLog, int64, error)
|
||||
}
|
||||
|
||||
@ -18,7 +18,7 @@ func NewLevelController(levelSvc levelUseCase) *LevelController {
|
||||
return &LevelController{levelSvc: levelSvc}
|
||||
}
|
||||
|
||||
// CheckIn 手动签到 API
|
||||
// CheckIn 手动签到 API(已废弃前端手动按钮,保留后端接口)
|
||||
func (lc *LevelController) CheckIn(c *gin.Context) {
|
||||
uidVal, exists := c.Get("uid")
|
||||
if !exists {
|
||||
|
||||
@ -43,6 +43,23 @@ func (sc *SettingsController) UpdateProfile(c *gin.Context) {
|
||||
common.Error(c, http.StatusInternalServerError, "操作失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 改名:首次免费,非首次扣 6 域能
|
||||
if req.Username != "" && req.Username != user.Username && sc.energySvc != nil {
|
||||
hasCompleted, _ := sc.levelSvc.HasCompletedTask(userID, "username")
|
||||
if hasCompleted {
|
||||
if err := sc.energySvc.DeductOnRename(userID); err != nil {
|
||||
switch err {
|
||||
case common.ErrInsufficientEnergy:
|
||||
common.Error(c, http.StatusBadRequest, "域能不足,无法改名。可通过每日签到或创作被赋能获取")
|
||||
default:
|
||||
common.Error(c, http.StatusInternalServerError, "操作失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := sc.auditService.SubmitProfileChanges(userID, user, req.Username, req.Bio); err != nil {
|
||||
handleAuditSubmitError(c, err)
|
||||
return
|
||||
|
||||
@ -40,9 +40,10 @@ type sessionConfig interface {
|
||||
GetRememberTimeout() int // 分钟
|
||||
}
|
||||
|
||||
// taskCompleter 设置页对等级服务的依赖(用于首次任务奖励)
|
||||
// taskCompleter 设置页对等级服务的依赖(用于首次任务奖励 + 检查任务)
|
||||
type taskCompleter interface {
|
||||
CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error)
|
||||
HasCompletedTask(userID uint, taskType string) (bool, error)
|
||||
}
|
||||
|
||||
// SettingsController 个人设置控制器
|
||||
@ -53,6 +54,7 @@ type SettingsController struct {
|
||||
sessionMgr sessionManager
|
||||
sessionCfg sessionConfig
|
||||
levelSvc taskCompleter
|
||||
energySvc energyRenameHandler
|
||||
}
|
||||
|
||||
// NewSettingsController 构造函数
|
||||
@ -60,6 +62,12 @@ func NewSettingsController(authService profileProvider, avatarService avatarProv
|
||||
return &SettingsController{authService: authService, avatarService: avatarService, auditService: auditService, sessionMgr: sessionMgr, sessionCfg: sessionCfg, levelSvc: levelSvc}
|
||||
}
|
||||
|
||||
// WithEnergyService 链式注入域能服务
|
||||
func (sc *SettingsController) WithEnergyService(svc energyRenameHandler) *SettingsController {
|
||||
sc.energySvc = svc
|
||||
return sc
|
||||
}
|
||||
|
||||
// SettingsPage 个人设置页面(需登录)
|
||||
func (sc *SettingsController) SettingsPage(c *gin.Context) {
|
||||
uidVal, exists := c.Get("uid")
|
||||
@ -70,7 +78,7 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) {
|
||||
uid := uidVal.(uint)
|
||||
|
||||
tab := c.Param("tab")
|
||||
if tab != "profile" && tab != "account" && tab != "sessions" {
|
||||
if tab != "profile" && tab != "account" && tab != "sessions" && tab != "energy" {
|
||||
c.String(http.StatusNotFound, "页面不存在")
|
||||
return
|
||||
}
|
||||
|
||||
@ -12,7 +12,8 @@ import (
|
||||
|
||||
// StudioController 创作中心控制器(SSR 页面 + API)
|
||||
type StudioController struct {
|
||||
postService studioUseCase
|
||||
postService studioUseCase
|
||||
energySvc energyDeducter
|
||||
}
|
||||
|
||||
// NewStudioController 构造函数
|
||||
@ -20,6 +21,12 @@ func NewStudioController(ps studioUseCase) *StudioController {
|
||||
return &StudioController{postService: ps}
|
||||
}
|
||||
|
||||
// WithEnergyService 链式注入域能服务
|
||||
func (ctrl *StudioController) WithEnergyService(svc energyDeducter) *StudioController {
|
||||
ctrl.energySvc = svc
|
||||
return ctrl
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// SSR 页面方法
|
||||
// ==============================
|
||||
@ -250,6 +257,14 @@ func (ctrl *StudioController) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// LV0 用户不允许投稿
|
||||
if expVal, exists := c.Get("exp"); exists {
|
||||
if exp, ok := expVal.(int); ok && exp < 1 {
|
||||
common.Error(c, http.StatusForbidden, "经验不足,Lv1 解锁投稿")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var req model.PostCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.Error(c, http.StatusBadRequest, "标题和正文不能为空")
|
||||
@ -327,11 +342,18 @@ func (ctrl *StudioController) Delete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
authorID := post.UserID
|
||||
|
||||
if err := ctrl.postService.Delete(uint(id)); err != nil {
|
||||
common.Error(c, http.StatusInternalServerError, "删除失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 删稿扣作者域能(无视负数,静默失败)
|
||||
if ctrl.energySvc != nil {
|
||||
_ = ctrl.energySvc.DeductOnDeletePost(authorID, uint(id))
|
||||
}
|
||||
|
||||
common.OkMessage(c, "删除成功")
|
||||
}
|
||||
|
||||
|
||||
13
internal/model/daily_exp_summary.go
Normal file
13
internal/model/daily_exp_summary.go
Normal file
@ -0,0 +1,13 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// DailyExpSummary 每日通过赋能获得的经验汇总(判断是否达 50 上限)
|
||||
type DailyExpSummary struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
UserID uint `gorm:"uniqueIndex:idx_user_date;not null" json:"user_id"`
|
||||
Date time.Time `gorm:"type:date;uniqueIndex:idx_user_date;not null" json:"date"` // Asia/Shanghai 自然日
|
||||
ExpEarned int `gorm:"default:0" json:"exp_earned"` // 当日通过赋能获得的经验值
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
38
internal/model/energy_log.go
Normal file
38
internal/model/energy_log.go
Normal file
@ -0,0 +1,38 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// 域能流水类型常量
|
||||
const (
|
||||
EnergyTypeSignIn = "sign_in"
|
||||
EnergyTypeRename = "rename"
|
||||
EnergyTypeRenameRefund = "rename_refund"
|
||||
EnergyTypeEnergize = "energize"
|
||||
EnergyTypeEnergized = "energized"
|
||||
EnergyTypeDeletePost = "delete_post"
|
||||
EnergyTypeAdminAdjust = "admin_adjust"
|
||||
)
|
||||
|
||||
// EnergyLog 域能流水记录
|
||||
type EnergyLog struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||
Amount int `gorm:"not null" json:"amount"` // 变化量(乘10,正为增加,负为扣减)
|
||||
Type string `gorm:"type:varchar(30);index;not null" json:"type"` // sign_in/rename/rename_refund/energize/energized/delete_post/admin_adjust
|
||||
RelatedType string `gorm:"type:varchar(30);default:''" json:"related_type,omitempty"`
|
||||
RelatedID uint `gorm:"default:0" json:"related_id,omitempty"`
|
||||
Description string `gorm:"type:varchar(500);default:''" json:"description,omitempty"`
|
||||
OperatorUID *uint `gorm:"default:null" json:"operator_uid,omitempty"` // 操作者UID(后台操作时必填)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// EnergyLogDisplayNames 流水类型 → 中文名
|
||||
var EnergyLogDisplayNames = map[string]string{
|
||||
EnergyTypeSignIn: "每日签到",
|
||||
EnergyTypeRename: "改名消耗",
|
||||
EnergyTypeRenameRefund: "改名退款",
|
||||
EnergyTypeEnergize: "赋能消耗",
|
||||
EnergyTypeEnergized: "被赋能",
|
||||
EnergyTypeDeletePost: "删稿消耗",
|
||||
EnergyTypeAdminAdjust: "后台调整",
|
||||
}
|
||||
@ -20,9 +20,10 @@ type Post struct {
|
||||
PendingBody string `gorm:"type:text" json:"pending_body,omitempty"`
|
||||
RejectReason string `gorm:"type:varchar(500);default:''" json:"reject_reason,omitempty"`
|
||||
AllowComment bool `gorm:"default:true" json:"allow_comment"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
TotalEnergyReceived int `gorm:"default:0" json:"total_energy_received"` // 文章累计被赋能域能(乘10,冗余计数)
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// 非数据库字段(联表查询填充)
|
||||
// gorm:"-:migration" 指定不创建/迁移该列,"<-:false" 禁止写入,"column:author_name" 允许
|
||||
|
||||
12
internal/model/post_energize_log.go
Normal file
12
internal/model/post_energize_log.go
Normal file
@ -0,0 +1,12 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// PostEnergizeLog 赋能操作记录(校验单用户单文章赋能总量 ≤ 2 域能)
|
||||
type PostEnergizeLog struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
UserID uint `gorm:"index;not null" json:"user_id"` // 赋能者
|
||||
PostID uint `gorm:"index;not null" json:"post_id"`
|
||||
Amount int `gorm:"not null" json:"amount"` // 赋能域能量(乘10,10或20)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@ -15,8 +15,9 @@ type User struct {
|
||||
Role string `gorm:"type:varchar(20);default:user;index;not null" json:"role"`
|
||||
Status string `gorm:"type:varchar(20);default:active;index;not null" json:"status"`
|
||||
|
||||
// 积分与等级
|
||||
Exp int `gorm:"default:0" json:"exp"` // 经验值(只增不减)
|
||||
// 经验值与域能
|
||||
Exp int `gorm:"default:0" json:"exp"` // 经验值(只增不减)
|
||||
Energy int `gorm:"default:0" json:"energy"` // 域能余额(可为负数,乘10存储)
|
||||
|
||||
// 安全与审计
|
||||
RegIP string `gorm:"type:varchar(45);default:''" json:"-"` // 注册 IP
|
||||
|
||||
138
internal/repository/energy_repo.go
Normal file
138
internal/repository/energy_repo.go
Normal file
@ -0,0 +1,138 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"metazone.cc/metalab/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// EnergyRepo 域能数据访问
|
||||
type EnergyRepo struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewEnergyRepo 构造函数
|
||||
func NewEnergyRepo(db *gorm.DB) *EnergyRepo {
|
||||
return &EnergyRepo{db: db}
|
||||
}
|
||||
|
||||
// FindUserByID 查找用户(获取能量余额等)
|
||||
func (r *EnergyRepo) FindUserByID(userID uint) (*model.User, error) {
|
||||
var user model.User
|
||||
err := r.db.First(&user, userID).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// FindPostByID 查找帖子
|
||||
func (r *EnergyRepo) FindPostByID(postID uint) (*model.Post, error) {
|
||||
var post model.Post
|
||||
err := r.db.First(&post, postID).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &post, nil
|
||||
}
|
||||
|
||||
// AddEnergy 原子增减用户域能余额(amount 可为正负)
|
||||
func (r *EnergyRepo) AddEnergy(userID uint, amount int) error {
|
||||
return r.db.Model(&model.User{}).
|
||||
Where("uid = ?", userID).
|
||||
UpdateColumn("energy", gorm.Expr("energy + ?", amount)).Error
|
||||
}
|
||||
|
||||
// IncrPostEnergy 原子增减文章累计被赋能域能
|
||||
func (r *EnergyRepo) IncrPostEnergy(postID uint, amount int) error {
|
||||
return r.db.Model(&model.Post{}).
|
||||
Where("id = ?", postID).
|
||||
UpdateColumn("total_energy_received", gorm.Expr("total_energy_received + ?", amount)).Error
|
||||
}
|
||||
|
||||
// CreateEnergyLog 创建域能流水
|
||||
func (r *EnergyRepo) CreateEnergyLog(log *model.EnergyLog) error {
|
||||
return r.db.Create(log).Error
|
||||
}
|
||||
|
||||
// SumUserPostEnergy 统计某用户对某文章累计赋能总量(用于 ≤ 20 检查)
|
||||
func (r *EnergyRepo) SumUserPostEnergy(userID, postID uint) (int, error) {
|
||||
var total int
|
||||
err := r.db.Model(&model.PostEnergizeLog{}).
|
||||
Where("user_id = ? AND post_id = ?", userID, postID).
|
||||
Select("COALESCE(SUM(amount), 0)").
|
||||
Scan(&total).Error
|
||||
return total, err
|
||||
}
|
||||
|
||||
// CreateEnergizeLog 创建赋能记录
|
||||
func (r *EnergyRepo) CreateEnergizeLog(log *model.PostEnergizeLog) error {
|
||||
return r.db.Create(log).Error
|
||||
}
|
||||
|
||||
// GetDailyEnergizeExp 获取用户当日通过赋能获得的经验值
|
||||
func (r *EnergyRepo) GetDailyEnergizeExp(userID uint, date time.Time) (*model.DailyExpSummary, error) {
|
||||
var s model.DailyExpSummary
|
||||
err := r.db.Where("user_id = ? AND date = ?", userID, date).First(&s).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
// UpsertDailyExp 创建或更新每日经验汇总
|
||||
func (r *EnergyRepo) UpsertDailyExp(summary *model.DailyExpSummary) error {
|
||||
return r.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "user_id"}, {Name: "date"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"exp_earned", "updated_at"}),
|
||||
}).Create(summary).Error
|
||||
}
|
||||
|
||||
// AddDailyExp 原子增加每日赋能经验值
|
||||
func (r *EnergyRepo) AddDailyExp(userID uint, date time.Time, delta int) error {
|
||||
return r.db.Model(&model.DailyExpSummary{}).
|
||||
Where("user_id = ? AND date = ?", userID, date).
|
||||
UpdateColumn("exp_earned", gorm.Expr("exp_earned + ?", delta)).Error
|
||||
}
|
||||
|
||||
// ListEnergyLogsByUser 分页查询用户域能流水(近 N 天)
|
||||
func (r *EnergyRepo) ListEnergyLogsByUser(userID uint, days int, offset, limit int) ([]model.EnergyLog, error) {
|
||||
var logs []model.EnergyLog
|
||||
query := r.db.Where("user_id = ?", userID).Order("created_at DESC")
|
||||
if days > 0 {
|
||||
since := time.Now().AddDate(0, 0, -days)
|
||||
query = query.Where("created_at >= ?", since)
|
||||
}
|
||||
err := query.Offset(offset).Limit(limit).Find(&logs).Error
|
||||
return logs, err
|
||||
}
|
||||
|
||||
// CountEnergyLogsByUser 统计用户域能流水总数
|
||||
func (r *EnergyRepo) CountEnergyLogsByUser(userID uint, days int) (int64, error) {
|
||||
var count int64
|
||||
query := r.db.Model(&model.EnergyLog{}).Where("user_id = ?", userID)
|
||||
if days > 0 {
|
||||
since := time.Now().AddDate(0, 0, -days)
|
||||
query = query.Where("created_at >= ?", since)
|
||||
}
|
||||
err := query.Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
// ListAllEnergyLogs 查询全部域能流水(后台,支持筛选)
|
||||
func (r *EnergyRepo) ListAllEnergyLogs(energyType string, offset, limit int) ([]model.EnergyLog, int64, error) {
|
||||
query := r.db.Model(&model.EnergyLog{})
|
||||
if energyType != "" {
|
||||
query = query.Where("type = ?", energyType)
|
||||
}
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var logs []model.EnergyLog
|
||||
err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&logs).Error
|
||||
return logs, total, err
|
||||
}
|
||||
@ -64,3 +64,12 @@ func (r *LevelRepo) FindTask(userID uint, taskType string) (*model.UserTask, err
|
||||
func (r *LevelRepo) CreateTask(task *model.UserTask) error {
|
||||
return r.db.Create(task).Error
|
||||
}
|
||||
|
||||
// HasTask 检查用户是否已完成过指定任务(无副作用查询)
|
||||
func (r *LevelRepo) HasTask(userID uint, taskType string) (bool, error) {
|
||||
var count int64
|
||||
err := r.db.Model(&model.UserTask{}).
|
||||
Where("user_id = ? AND task_type = ?", userID, taskType).
|
||||
Count(&count).Error
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
@ -28,6 +28,8 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
||||
adminPages.GET("/posts", d.adminPostCtrl.PostsPage)
|
||||
adminPages.GET("/site-settings", d.siteSettingCtrl.SiteSettingsPage,
|
||||
middleware.RequirePageRole(model.RoleOwner))
|
||||
adminPages.GET("/energy", d.adminEnergyCtrl.EnergyPage)
|
||||
adminPages.GET("/energy/logs", d.adminEnergyCtrl.EnergyLogPage)
|
||||
}
|
||||
|
||||
// --- 管理后台 API ---
|
||||
@ -71,4 +73,16 @@ func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
||||
adminPostAPI.POST("/:id/unlock", d.adminPostCtrl.Unlock)
|
||||
adminPostAPI.POST("/:id/restore", d.adminPostCtrl.Restore)
|
||||
}
|
||||
|
||||
// --- 域能管理 API(admin+,调整需要 owner) ---
|
||||
adminEnergyAPI := r.Group("/api/admin/energy")
|
||||
adminEnergyAPI.Use(d.authMdw.Required())
|
||||
adminEnergyAPI.Use(middleware.RequireMinRole(model.RoleAdmin))
|
||||
adminEnergyAPI.Use(d.authMdw.BannedWriteGuard())
|
||||
adminEnergyAPI.Use(middleware.CSRF(cfg))
|
||||
{
|
||||
adminEnergyAPI.POST("/adjust", d.adminEnergyCtrl.AdjustEnergy,
|
||||
middleware.RequireMinRole(model.RoleOwner))
|
||||
adminEnergyAPI.GET("/logs", d.adminEnergyCtrl.ListEnergyLogs)
|
||||
}
|
||||
}
|
||||
|
||||
@ -87,4 +87,15 @@ func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) {
|
||||
studioAPI.DELETE("/posts/:id", d.studioCtrl.Delete)
|
||||
studioAPI.POST("/posts/:id/submit", d.studioCtrl.Submit)
|
||||
}
|
||||
|
||||
// --- 域能 API(需登录) ---
|
||||
energyAPI := r.Group("/api/energy")
|
||||
energyAPI.Use(d.authMdw.Required())
|
||||
energyAPI.Use(d.authMdw.BannedWriteGuard())
|
||||
energyAPI.Use(middleware.CSRF(cfg))
|
||||
{
|
||||
energyAPI.POST("/energize", d.energyCtrl.Energize)
|
||||
energyAPI.GET("/info", d.energyCtrl.GetEnergyInfo)
|
||||
energyAPI.GET("/logs", d.energyCtrl.GetEnergyLogs)
|
||||
}
|
||||
}
|
||||
|
||||
@ -31,6 +31,9 @@ type dependencies struct {
|
||||
auditCtrl *adminCtrl.AuditController
|
||||
siteSettingCtrl *adminCtrl.SiteSettingController
|
||||
levelCtrl *controller.LevelController
|
||||
energyCtrl *controller.EnergyController
|
||||
adminEnergyCtrl *adminCtrl.AdminEnergyController
|
||||
energyService *service.EnergyService
|
||||
}
|
||||
|
||||
func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) (
|
||||
|
||||
@ -19,15 +19,25 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
|
||||
notificationRepo := repository.NewNotificationRepo(db)
|
||||
notificationService := service.NewNotificationService(notificationRepo)
|
||||
|
||||
// 等级/签到/任务系统
|
||||
// 域能系统
|
||||
energyRepo := repository.NewEnergyRepo(db)
|
||||
energyService := service.NewEnergyService(db, energyRepo, nil, notificationService)
|
||||
|
||||
// 等级/签到/任务系统(链式注入域能服务)
|
||||
_, levelSvc, levelCtrl := buildDepsLevel(db, notificationService, authMdw)
|
||||
levelSvc.WithEnergyService(energyService)
|
||||
|
||||
// EnergyService 也需要 LevelService(用于赋能获得经验),通过 setter 注入
|
||||
energyService.SetExpAdder(levelSvc)
|
||||
|
||||
auditService := service.NewAuditService(auditRepo, userRepo, siteSettings, cfg.Audit, notificationService, levelSvc)
|
||||
auditService.WithEnergyRefundService(energyService)
|
||||
auditController := adminCtrl.NewAuditController(auditService, userRepo)
|
||||
|
||||
messageController := controller.NewMessageController(notificationService)
|
||||
|
||||
settingsCtrl := controller.NewSettingsController(authService, avatarSvc, auditService, sessionMgr, cfg, levelSvc)
|
||||
settingsCtrl.WithEnergyService(energyService)
|
||||
siteSettingController := adminCtrl.NewSiteSettingController(
|
||||
service.NewSiteSettingService(siteSettings),
|
||||
&service.AuditDefaults{
|
||||
@ -43,6 +53,11 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
|
||||
adminPostCtrl := adminCtrl.NewAdminPostController(postService)
|
||||
adminController := adminCtrl.NewAdminController(service.NewAdminService(userRepo, postRepo, sessionMgr))
|
||||
studioCtrl := controller.NewStudioController(postService)
|
||||
studioCtrl.WithEnergyService(energyService)
|
||||
|
||||
// 域能控制器
|
||||
energyCtrl := controller.NewEnergyController(energyService)
|
||||
adminEnergyCtrl := adminCtrl.NewAdminEnergyController(energyService)
|
||||
|
||||
// 用户空间
|
||||
spaceService := service.NewSpaceService(userRepo, postRepo)
|
||||
@ -59,5 +74,8 @@ func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSetting
|
||||
adminCtrl: adminController, auditCtrl: auditController,
|
||||
siteSettingCtrl: siteSettingController,
|
||||
levelCtrl: levelCtrl,
|
||||
energyCtrl: energyCtrl,
|
||||
adminEnergyCtrl: adminEnergyCtrl,
|
||||
energyService: energyService,
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,14 +44,20 @@ type auditTaskCompleter interface {
|
||||
CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error)
|
||||
}
|
||||
|
||||
// energyRenameRefunder 改名审核被拒时返还域能的接口(ISP)
|
||||
type energyRenameRefunder interface {
|
||||
RefundOnRenameReject(userID uint) error
|
||||
}
|
||||
|
||||
// AuditService 审核业务逻辑
|
||||
type AuditService struct {
|
||||
auditRepo auditRepo
|
||||
userRepo userProfileStore
|
||||
settings siteSettingsChecker
|
||||
defaultCfg config.AuditConfig
|
||||
notifier auditNotifier
|
||||
levelSvc auditTaskCompleter
|
||||
auditRepo auditRepo
|
||||
userRepo userProfileStore
|
||||
settings siteSettingsChecker
|
||||
defaultCfg config.AuditConfig
|
||||
notifier auditNotifier
|
||||
levelSvc auditTaskCompleter
|
||||
energyRefundSvc energyRenameRefunder
|
||||
}
|
||||
|
||||
// NewAuditService 构造函数
|
||||
@ -66,6 +72,12 @@ func NewAuditService(auditRepo auditRepo, userRepo userProfileStore, settings si
|
||||
}
|
||||
}
|
||||
|
||||
// WithEnergyRefundService 链式注入域能退款服务
|
||||
func (s *AuditService) WithEnergyRefundService(svc energyRenameRefunder) *AuditService {
|
||||
s.energyRefundSvc = svc
|
||||
return s
|
||||
}
|
||||
|
||||
// ShouldAudit 判断指定用户是否需要走审核流程
|
||||
// 规则:admin 及以上角色免审
|
||||
func (s *AuditService) ShouldAudit(userID uint) (bool, error) {
|
||||
@ -195,11 +207,18 @@ func (s *AuditService) review(reviewerID uint, submissionID uint, approved bool,
|
||||
} 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)
|
||||
}
|
||||
|
||||
319
internal/service/energy_service.go
Normal file
319
internal/service/energy_service.go
Normal file
@ -0,0 +1,319 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"metazone.cc/metalab/internal/common"
|
||||
"metazone.cc/metalab/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// expAdder EnergyService 对经验增加的最小依赖(ISP)
|
||||
type expAdder interface {
|
||||
AddExp(userID uint, delta int) (newExp int, levelUp bool, err error)
|
||||
}
|
||||
|
||||
// EnergyService 域能业务逻辑
|
||||
type EnergyService struct {
|
||||
db *gorm.DB
|
||||
repo energyStore
|
||||
expSvc expAdder
|
||||
notifSvc energyNotifier
|
||||
}
|
||||
|
||||
// NewEnergyService 构造函数
|
||||
func NewEnergyService(db *gorm.DB, repo energyStore, expSvc expAdder, notifSvc energyNotifier) *EnergyService {
|
||||
return &EnergyService{db: db, repo: repo, expSvc: expSvc, notifSvc: notifSvc}
|
||||
}
|
||||
|
||||
// SetExpAdder 链式注入经验服务(用于赋能获得经验)
|
||||
func (s *EnergyService) SetExpAdder(svc expAdder) {
|
||||
s.expSvc = svc
|
||||
}
|
||||
|
||||
// 域能相关常量(乘10存储,DB值)
|
||||
const (
|
||||
EnergyCheckIn = 10 // +1.0 域能
|
||||
EnergyRename = -60 // -6.0 域能
|
||||
EnergyRenameRefund = 60 // +6.0 域能
|
||||
EnergyDeletePost = -20 // -2.0 域能
|
||||
EnergyEnergize1 = 10 // 轻赋 1 域能
|
||||
EnergyEnergize2 = 20 // 重赋 2 域能
|
||||
EnergizeExpPerEnergy = 10 // 每 1 域能 = 10 经验
|
||||
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
|
||||
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 1. 检查帖子存在,获取作者
|
||||
post, err := s.repo.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 := s.repo.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 := s.repo.SumUserPostEnergy(energizerID, postID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询赋能记录失败: %w", err)
|
||||
}
|
||||
if total+amount > MaxEnergizePerPost {
|
||||
return common.ErrEnergizeLimitReached
|
||||
}
|
||||
|
||||
// 5. 扣赋能者域能
|
||||
if err := s.repo.AddEnergy(energizerID, -amount); err != nil {
|
||||
return fmt.Errorf("扣减域能失败: %w", err)
|
||||
}
|
||||
|
||||
// 6. 增加被赋能者(文章作者)域能(赋能量的十分之一)
|
||||
receivedAmount := amount / 10 // 1 域能→0.1,2 域能→0.2
|
||||
if err := s.repo.AddEnergy(post.UserID, receivedAmount); err != nil {
|
||||
return fmt.Errorf("增加被赋能者域能失败: %w", err)
|
||||
}
|
||||
|
||||
// 7. 更新 Post.total_energy_received
|
||||
if err := s.repo.IncrPostEnergy(postID, amount); err != nil {
|
||||
return fmt.Errorf("更新文章赋能计数失败: %w", err)
|
||||
}
|
||||
|
||||
// 8. 插入赋能记录
|
||||
eLog := &model.PostEnergizeLog{
|
||||
UserID: energizerID,
|
||||
PostID: postID,
|
||||
Amount: amount,
|
||||
}
|
||||
if err := s.repo.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 := s.repo.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 := s.repo.CreateEnergyLog(energizedLog); err != nil {
|
||||
return fmt.Errorf("创建被赋能者流水失败: %w", err)
|
||||
}
|
||||
|
||||
// 10. 计算经验值(溢出截断)
|
||||
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 := s.repo.GetDailyEnergizeExp(energizerID, today)
|
||||
currentDaily := 0
|
||||
if err == 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
|
||||
}
|
||||
|
||||
// 11. 增加经验 + 更新每日经验汇总
|
||||
if expGain > 0 {
|
||||
if s.expSvc != nil {
|
||||
_, _, err = s.expSvc.AddExp(energizerID, expGain)
|
||||
if err != nil {
|
||||
return fmt.Errorf("增加经验失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
newSummary := &model.DailyExpSummary{
|
||||
UserID: energizerID,
|
||||
Date: today,
|
||||
ExpEarned: currentDaily + expGain,
|
||||
}
|
||||
if err := s.repo.UpsertDailyExp(newSummary); err != nil {
|
||||
return fmt.Errorf("更新每日经验汇总失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
expGained = expGain
|
||||
return nil
|
||||
})
|
||||
|
||||
return expGained, err
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 检查域能余额(非 owner 需 ≥ 0)
|
||||
user, err := s.repo.FindUserByID(userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("查询用户失败: %w", err)
|
||||
}
|
||||
if user.Role != model.RoleOwner && user.Energy+EnergyRename < 0 {
|
||||
return common.ErrInsufficientEnergy
|
||||
}
|
||||
|
||||
if err := s.repo.AddEnergy(userID, EnergyRename); err != nil {
|
||||
return fmt.Errorf("扣减改名域能失败: %w", err)
|
||||
}
|
||||
|
||||
log := &model.EnergyLog{
|
||||
UserID: userID,
|
||||
Amount: EnergyRename,
|
||||
Type: model.EnergyTypeRename,
|
||||
Description: "改名消耗 6.0 域能",
|
||||
}
|
||||
return s.repo.CreateEnergyLog(log)
|
||||
}
|
||||
|
||||
// RefundOnRenameReject 改名审核被拒,返还域能
|
||||
func (s *EnergyService) RefundOnRenameReject(userID uint) error {
|
||||
if err := s.repo.AddEnergy(userID, EnergyRenameRefund); err != nil {
|
||||
return fmt.Errorf("返还改名域能失败: %w", err)
|
||||
}
|
||||
|
||||
log := &model.EnergyLog{
|
||||
UserID: userID,
|
||||
Amount: EnergyRenameRefund,
|
||||
Type: model.EnergyTypeRenameRefund,
|
||||
Description: "改名审核被拒,返还 6.0 域能",
|
||||
}
|
||||
return s.repo.CreateEnergyLog(log)
|
||||
}
|
||||
|
||||
// DeductOnDeletePost 删稿扣域能(扣文章作者的域能,无视负数)
|
||||
func (s *EnergyService) DeductOnDeletePost(authorUserID uint, postID uint) error {
|
||||
if err := s.repo.AddEnergy(authorUserID, EnergyDeletePost); 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 s.repo.CreateEnergyLog(log)
|
||||
}
|
||||
|
||||
// AdminAdjust 后台调整用户域能
|
||||
func (s *EnergyService) AdminAdjust(operatorUID uint, userIDs []uint, amount int, description string) error {
|
||||
for _, uid := range userIDs {
|
||||
if err := s.repo.AddEnergy(uid, amount); err != nil {
|
||||
return fmt.Errorf("调整用户 %d 域能失败: %w", uid, err)
|
||||
}
|
||||
|
||||
opUID := operatorUID
|
||||
log := &model.EnergyLog{
|
||||
UserID: uid,
|
||||
Amount: amount,
|
||||
Type: model.EnergyTypeAdminAdjust,
|
||||
Description: description,
|
||||
OperatorUID: &opUID,
|
||||
}
|
||||
if err := s.repo.CreateEnergyLog(log); err != nil {
|
||||
return fmt.Errorf("创建调整流水失败: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetEnergyInfo 获取用户域能余额
|
||||
func (s *EnergyService) GetEnergyInfo(userID uint) (int, error) {
|
||||
user, err := s.repo.FindUserByID(userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return user.Energy, 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)
|
||||
}
|
||||
@ -9,10 +9,16 @@ import (
|
||||
|
||||
// LevelService 积分与等级业务逻辑
|
||||
type LevelService struct {
|
||||
userRepo userLevelStore
|
||||
checkRepo checkInStore
|
||||
taskRepo taskStore
|
||||
notifSvc levelNotifier
|
||||
userRepo userLevelStore
|
||||
checkRepo checkInStore
|
||||
taskRepo taskStore
|
||||
notifSvc levelNotifier
|
||||
energyCheckInSvc energyCheckIner
|
||||
}
|
||||
|
||||
// energyCheckIner LevelService 对域能签到的最小依赖(ISP)
|
||||
type energyCheckIner interface {
|
||||
CheckInEnergy(userID uint) error
|
||||
}
|
||||
|
||||
// NewLevelService 构造函数
|
||||
@ -20,6 +26,12 @@ func NewLevelService(userRepo userLevelStore, checkRepo checkInStore, taskRepo t
|
||||
return &LevelService{userRepo: userRepo, checkRepo: checkRepo, taskRepo: taskRepo, notifSvc: notifSvc}
|
||||
}
|
||||
|
||||
// WithEnergyService 链式注入域能服务(用于签到域能奖励)
|
||||
func (s *LevelService) WithEnergyService(svc energyCheckIner) *LevelService {
|
||||
s.energyCheckInSvc = svc
|
||||
return s
|
||||
}
|
||||
|
||||
// GetLevel 获取用户当前等级、经验值和今日签到状态
|
||||
func (s *LevelService) GetLevel(userID uint) (level int, exp int, checkedIn bool, err error) {
|
||||
user, err := s.userRepo.FindByID(userID)
|
||||
@ -81,7 +93,17 @@ func (s *LevelService) AddExp(userID uint, delta int) (newExp int, levelUp bool,
|
||||
|
||||
// CheckIn 执行签到(基于 Asia/Shanghai 自然日严格防重)
|
||||
// 返回:是否成功签到、新经验值、是否升级、错误
|
||||
// LV0 用户不触发签到
|
||||
func (s *LevelService) CheckIn(userID uint) (checkedIn bool, newExp int, levelUp bool, err error) {
|
||||
// LV0 用户不触发签到
|
||||
user, err := s.userRepo.FindByID(userID)
|
||||
if err != nil {
|
||||
return false, 0, false, err
|
||||
}
|
||||
if user.Exp < 1 {
|
||||
return false, user.Exp, false, nil
|
||||
}
|
||||
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
now := time.Now().In(loc)
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
|
||||
@ -90,11 +112,7 @@ func (s *LevelService) CheckIn(userID uint) (checkedIn bool, newExp int, levelUp
|
||||
_, err = s.checkRepo.FindCheckIn(userID, today)
|
||||
if err == nil {
|
||||
// 已签到
|
||||
user, _ := s.userRepo.FindByID(userID)
|
||||
if user != nil {
|
||||
return false, user.Exp, false, nil
|
||||
}
|
||||
return false, 0, false, nil
|
||||
return false, user.Exp, false, nil
|
||||
}
|
||||
|
||||
// 创建签到记录
|
||||
@ -104,11 +122,12 @@ func (s *LevelService) CheckIn(userID uint) (checkedIn bool, newExp int, levelUp
|
||||
}
|
||||
if err := s.checkRepo.CreateCheckIn(ci); err != nil {
|
||||
// 唯一索引冲突 → 已签到
|
||||
user, _ := s.userRepo.FindByID(userID)
|
||||
if user != nil {
|
||||
return false, user.Exp, false, nil
|
||||
}
|
||||
return false, 0, false, nil
|
||||
return false, user.Exp, false, nil
|
||||
}
|
||||
|
||||
// 增加域能(静默失败不影响签到经验)
|
||||
if s.energyCheckInSvc != nil {
|
||||
_ = s.energyCheckInSvc.CheckInEnergy(userID)
|
||||
}
|
||||
|
||||
// 增加经验值
|
||||
@ -116,6 +135,11 @@ func (s *LevelService) CheckIn(userID uint) (checkedIn bool, newExp int, levelUp
|
||||
return true, newExp, levelUp, err
|
||||
}
|
||||
|
||||
// HasCompletedTask 检查用户是否已完成过指定任务(无副作用查询)
|
||||
func (s *LevelService) HasCompletedTask(userID uint, taskType string) (bool, error) {
|
||||
return s.taskRepo.HasTask(userID, taskType)
|
||||
}
|
||||
|
||||
// CompleteTask 完成首次任务(严格防重)
|
||||
// 返回:新经验值、是否升级、错误
|
||||
func (s *LevelService) CompleteTask(userID uint, taskType string) (newExp int, levelUp bool, err error) {
|
||||
|
||||
@ -79,9 +79,32 @@ type checkInStore interface {
|
||||
type taskStore interface {
|
||||
FindTask(userID uint, taskType string) (*model.UserTask, error)
|
||||
CreateTask(task *model.UserTask) error
|
||||
HasTask(userID uint, taskType string) (bool, error)
|
||||
}
|
||||
|
||||
// levelNotifier 等级升级通知接口
|
||||
type levelNotifier interface {
|
||||
Create(userID uint, notifyType, title, content string, relatedID *uint) error
|
||||
}
|
||||
|
||||
// energyStore EnergyService 所需的完整仓储接口
|
||||
type energyStore interface {
|
||||
FindUserByID(userID uint) (*model.User, error)
|
||||
FindPostByID(postID uint) (*model.Post, error)
|
||||
AddEnergy(userID uint, amount int) error
|
||||
IncrPostEnergy(postID uint, amount int) error
|
||||
CreateEnergyLog(log *model.EnergyLog) error
|
||||
SumUserPostEnergy(userID, postID uint) (int, error)
|
||||
CreateEnergizeLog(log *model.PostEnergizeLog) error
|
||||
GetDailyEnergizeExp(userID uint, date time.Time) (*model.DailyExpSummary, error)
|
||||
UpsertDailyExp(summary *model.DailyExpSummary) error
|
||||
AddDailyExp(userID uint, date time.Time, delta int) error
|
||||
ListEnergyLogsByUser(userID uint, days int, offset, limit int) ([]model.EnergyLog, error)
|
||||
CountEnergyLogsByUser(userID uint, days int) (int64, error)
|
||||
ListAllEnergyLogs(energyType string, offset, limit int) ([]model.EnergyLog, int64, error)
|
||||
}
|
||||
|
||||
// energyNotifier EnergyService 所需的通知接口(ISP)
|
||||
type energyNotifier interface {
|
||||
Create(userID uint, notifyType, title, content string, relatedID *uint) error
|
||||
}
|
||||
|
||||
@ -43,10 +43,6 @@
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<a href="javascript:void(0)" class="nav-dropdown-item" id="checkInBtn">
|
||||
<svg class="nav-dropdown-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
<span>签到</span>
|
||||
</a>
|
||||
<a href="/settings/profile" class="nav-dropdown-item">
|
||||
<svg class="nav-dropdown-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
<span>个人中心</span>
|
||||
|
||||
@ -39,6 +39,9 @@
|
||||
{{if and $.Post (eq $.Post.UserID $.UID) (or (eq $.Post.Status "draft") (eq $.Post.Status "rejected")) (not $.Post.IsLocked)}}
|
||||
<button class="btn btn-primary" id="submitAuditBtn" data-id="{{$.Post.ID}}">提交审核</button>
|
||||
{{end}}
|
||||
{{if and $.Post (ne $.Post.UserID $.UID)}}
|
||||
<button class="btn btn-primary" id="energizeBtn" data-id="{{$.Post.ID}}">赋能</button>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
@ -107,5 +110,49 @@
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// 赋能按钮
|
||||
(function() {
|
||||
var btn = document.getElementById('energizeBtn');
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', function() {
|
||||
var postId = btn.dataset.id;
|
||||
var choice = confirm('为这篇文章注入能量\n\n选择「确定」轻赋 1 域能,选择「取消」则打开重赋选项');
|
||||
var amount = 20; // 重赋 2 域能
|
||||
if (choice) {
|
||||
amount = 10; // 轻赋 1 域能
|
||||
}
|
||||
|
||||
var csrf = document.querySelector('meta[name="csrf-token"]');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '赋能中...';
|
||||
|
||||
fetch('/api/energy/energize', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrf ? csrf.getAttribute('content') : ''
|
||||
},
|
||||
body: JSON.stringify({ post_id: parseInt(postId), amount: amount })
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '赋能';
|
||||
if (d.success) {
|
||||
alert(d.data.message || '赋能成功!');
|
||||
} else {
|
||||
alert(d.message || '赋能失败');
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '赋能';
|
||||
alert('请求失败');
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -33,6 +33,13 @@
|
||||
</svg>
|
||||
登录管理
|
||||
</a>
|
||||
<a href="/settings/energy"
|
||||
class="settings-nav-item {{if eq .ActiveTab "energy"}}active{{end}}">
|
||||
<svg class="settings-nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
|
||||
</svg>
|
||||
我的域能
|
||||
</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
@ -147,6 +154,25 @@
|
||||
<span class="form-hint">将强制退出除当前设备外的所有登录会话</span>
|
||||
</div>
|
||||
</div>
|
||||
{{else if eq .ActiveTab "energy"}}
|
||||
<!-- 我的域能 -->
|
||||
<div class="settings-card">
|
||||
<div class="settings-card-header">
|
||||
<h2>我的域能</h2>
|
||||
<p class="settings-card-desc">域能流水和余额</p>
|
||||
</div>
|
||||
<div class="settings-card-body">
|
||||
<div class="energy-summary">
|
||||
<div class="energy-stat">
|
||||
<span class="energy-label">域能余额</span>
|
||||
<span class="energy-value" id="energyBalance">加载中...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="energy-log-list" id="energyLogList">
|
||||
<div class="energy-log-empty">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<!-- 账号信息 -->
|
||||
<div class="settings-card">
|
||||
@ -869,6 +895,61 @@
|
||||
}, 500);
|
||||
}
|
||||
})();
|
||||
|
||||
// ===== 域能 Tab =====
|
||||
(function () {
|
||||
var balanceEl = document.getElementById('energyBalance');
|
||||
var logListEl = document.getElementById('energyLogList');
|
||||
if (!balanceEl || !logListEl) return;
|
||||
|
||||
// 获取域能信息
|
||||
fetch('/api/energy/info')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
if (d.success && d.data) {
|
||||
balanceEl.textContent = d.data.energy_display + ' 域能';
|
||||
} else {
|
||||
balanceEl.textContent = '获取失败';
|
||||
}
|
||||
})
|
||||
.catch(function () { balanceEl.textContent = '获取失败'; });
|
||||
|
||||
// 获取域能流水
|
||||
fetch('/api/energy/logs?days=7&page=1&page_size=50')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (d) {
|
||||
if (!d.success || !d.data || !d.data.items || d.data.items.length === 0) {
|
||||
logListEl.innerHTML = '<div class="energy-log-empty">近7天无变更记录</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '';
|
||||
d.data.items.forEach(function (item) {
|
||||
var cls = item.amount > 0 ? 'energy-log-plus' : 'energy-log-minus';
|
||||
var sign = item.amount > 0 ? '+' : '';
|
||||
html += '<div class="energy-log-item ' + cls + '">'
|
||||
+ '<div class="energy-log-meta">'
|
||||
+ '<span class="energy-log-type">' + escapeHTML(item.type_name) + '</span>'
|
||||
+ '<span class="energy-log-time">' + escapeHTML(item.created_at) + '</span>'
|
||||
+ '</div>'
|
||||
+ '<div class="energy-log-info">'
|
||||
+ '<span class="energy-log-desc">' + escapeHTML(item.description) + '</span>'
|
||||
+ '<span class="energy-log-amount">' + sign + item.amount_display + ' 域能</span>'
|
||||
+ '</div>'
|
||||
+ '</div>';
|
||||
});
|
||||
logListEl.innerHTML = html;
|
||||
})
|
||||
.catch(function () {
|
||||
logListEl.innerHTML = '<div class="energy-log-empty">加载失败</div>';
|
||||
});
|
||||
|
||||
function escapeHTML(str) {
|
||||
var div = document.createElement('div');
|
||||
div.appendChild(document.createTextNode(str));
|
||||
return div.innerHTML;
|
||||
}
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@ -783,3 +783,97 @@ body {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Energy Tab ---------- */
|
||||
.energy-summary {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.energy-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .25rem;
|
||||
}
|
||||
|
||||
.energy-label {
|
||||
font-size: .82rem;
|
||||
color: var(--color-text-light);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.energy-value {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.energy-log-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.energy-log-empty {
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
color: #bbb;
|
||||
font-size: .9rem;
|
||||
}
|
||||
|
||||
.energy-log-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .3rem;
|
||||
padding: .85rem 0;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
}
|
||||
|
||||
.energy-log-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.energy-log-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.energy-log-type {
|
||||
font-size: .85rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.energy-log-time {
|
||||
font-size: .78rem;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.energy-log-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.energy-log-desc {
|
||||
font-size: .82rem;
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
|
||||
.energy-log-amount {
|
||||
font-size: .88rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.energy-log-plus .energy-log-amount {
|
||||
color: #2ecc71;
|
||||
}
|
||||
|
||||
.energy-log-minus .energy-log-amount {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
@ -283,59 +283,6 @@
|
||||
setInterval(pollUnread, POLL_INTERVAL);
|
||||
}
|
||||
|
||||
// ---- Level / Check-in ----
|
||||
var checkInBtn = document.getElementById('checkInBtn');
|
||||
if (checkInBtn) {
|
||||
// 页面加载时查询等级和签到状态
|
||||
fetch('/api/level/info')
|
||||
.then(function (res) {
|
||||
if (!res.ok) return;
|
||||
return res.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
if (!data || !data.success) return;
|
||||
var d = data.data || {};
|
||||
if (d.checked_in) {
|
||||
setCheckInDone();
|
||||
}
|
||||
})
|
||||
.catch(function () {});
|
||||
|
||||
function setCheckInDone() {
|
||||
checkInBtn.classList.add('disabled');
|
||||
var span = checkInBtn.querySelector('span');
|
||||
if (span) span.textContent = '今日已签到';
|
||||
}
|
||||
|
||||
checkInBtn.addEventListener('click', function () {
|
||||
if (checkInBtn.classList.contains('disabled')) return;
|
||||
|
||||
fetch('/api/level/checkin', { method: 'POST' })
|
||||
.then(function (res) {
|
||||
if (!res.ok) return;
|
||||
return res.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
if (!data || !data.success) return;
|
||||
var d = data.data || {};
|
||||
if (!d.checked_in) {
|
||||
setCheckInDone();
|
||||
return;
|
||||
}
|
||||
setCheckInDone();
|
||||
if (d.level_up) {
|
||||
window.MetaLab.toast(document.querySelector('.toast'), '恭喜您!您的等级已提升至 Lv' + d.level, false);
|
||||
}
|
||||
// 刷新等级展示
|
||||
var levelEl = document.querySelector('.nav-dropdown-level');
|
||||
if (levelEl) {
|
||||
levelEl.textContent = 'Lv' + d.level;
|
||||
}
|
||||
})
|
||||
.catch(function () {});
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Auto-refresh token (silent) ----
|
||||
// 场景 A:页面开着时,每 10 分钟静默续期(保持 access token 不过期)
|
||||
// 场景 B:关浏览器 15+ min 后回来,access token 过期但 refresh 仍有效
|
||||
|
||||
2
templates/admin/html/energy/index.html
Normal file
2
templates/admin/html/energy/index.html
Normal file
@ -0,0 +1,2 @@
|
||||
{{template "admin/layout/base.html" .}}
|
||||
{{template "admin/layout/footer.html" .}}
|
||||
2
templates/admin/html/energy/logs.html
Normal file
2
templates/admin/html/energy/logs.html
Normal file
@ -0,0 +1,2 @@
|
||||
{{template "admin/layout/base.html" .}}
|
||||
{{template "admin/layout/footer.html" .}}
|
||||
@ -43,6 +43,14 @@
|
||||
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg></span>
|
||||
内容管理
|
||||
</a>
|
||||
<a href="/admin/energy" class="sidebar-item {{if eq .CurrentPath "/admin/energy"}}active{{end}}">
|
||||
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg></span>
|
||||
域能管理
|
||||
</a>
|
||||
<a href="/admin/energy/logs" class="sidebar-item {{if eq .CurrentPath "/admin/energy/logs"}}active{{end}}">
|
||||
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></span>
|
||||
域能日志
|
||||
</a>
|
||||
{{if .CanManageSettings}}
|
||||
<a href="/admin/site-settings" class="sidebar-item {{if eq .CurrentPath "/admin/site-settings"}}active{{end}}">
|
||||
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></span>
|
||||
|
||||
Reference in New Issue
Block a user