diff --git a/internal/controller/energy_controller.go b/internal/controller/energy_controller.go index de7b10a..688b9ec 100644 --- a/internal/controller/energy_controller.go +++ b/internal/controller/energy_controller.go @@ -78,16 +78,19 @@ func (ec *EnergyController) GetEnergyInfo(c *gin.Context) { return } - energy, err := ec.energySvc.GetEnergyInfo(uid) + info, err := ec.energySvc.GetEnergyInfo(uid) if err != nil { common.Error(c, http.StatusInternalServerError, "获取域能信息失败") return } - energyDisplay := float64(energy) / 10 + energyDisplay := float64(info.Energy) / 10 common.Ok(c, gin.H{ - "energy": energy, - "energy_display": energyDisplay, + "energy": info.Energy, + "energy_display": energyDisplay, + "daily_energize_exp": info.DailyEnergizeExp, + "daily_exp_cap": info.DailyExpCap, + "daily_exp_cap_reached": info.DailyExpCapReached, }) } diff --git a/internal/controller/interfaces.go b/internal/controller/interfaces.go index e739cee..e117609 100644 --- a/internal/controller/interfaces.go +++ b/internal/controller/interfaces.go @@ -72,7 +72,7 @@ type levelUseCase interface { // energyUseCase EnergyController 对 EnergyService 的最小依赖(ISP:3 个方法) type energyUseCase interface { Energize(energizerID uint, postID uint, amount int) (int, error) - GetEnergyInfo(userID uint) (int, error) + GetEnergyInfo(userID uint) (*service.EnergyInfo, error) GetEnergyLogs(userID uint, days, page, pageSize int) ([]model.EnergyLog, int64, error) } diff --git a/internal/controller/settings_api_profile.go b/internal/controller/settings_api_profile.go index 9bd6af8..d1d4853 100644 --- a/internal/controller/settings_api_profile.go +++ b/internal/controller/settings_api_profile.go @@ -69,6 +69,29 @@ func (sc *SettingsController) UpdateProfile(c *gin.Context) { } // 管理员及以上直接更新 + // 先获取当前用户信息,判断是否改名 + currentUser, err := sc.authService.GetProfile(userID) + if err != nil { + common.Error(c, http.StatusInternalServerError, "操作失败") + return + } + + // 改名:首次免费,非首次扣 6 域能 + if req.Username != "" && req.Username != currentUser.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.authService.UpdateProfile(userID, req.Username, req.Bio); err != nil { handleSettingsError(c, err) return diff --git a/internal/repository/energy_repo.go b/internal/repository/energy_repo.go index 8f2b260..8720622 100644 --- a/internal/repository/energy_repo.go +++ b/internal/repository/energy_repo.go @@ -4,6 +4,7 @@ import ( "time" "metazone.cc/metalab/internal/model" + "metazone.cc/metalab/internal/service" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -19,6 +20,11 @@ func NewEnergyRepo(db *gorm.DB) *EnergyRepo { return &EnergyRepo{db: db} } +// WithTx 基于给定事务连接创建新的 EnergyRepo(确保事务内操作原子性) +func (r *EnergyRepo) WithTx(tx *gorm.DB) service.EnergyStore { + return &EnergyRepo{db: tx} +} + // FindUserByID 查找用户(获取能量余额等) func (r *EnergyRepo) FindUserByID(userID uint) (*model.User, error) { var user model.User diff --git a/internal/service/energy_service.go b/internal/service/energy_service.go index 17aae7e..768270b 100644 --- a/internal/service/energy_service.go +++ b/internal/service/energy_service.go @@ -17,17 +17,25 @@ type expAdder interface { // EnergyService 域能业务逻辑 type EnergyService struct { - db *gorm.DB - repo energyStore - expSvc expAdder + db *gorm.DB + repo EnergyStore + expSvc expAdder notifSvc energyNotifier } // NewEnergyService 构造函数 -func NewEnergyService(db *gorm.DB, repo energyStore, expSvc expAdder, notifSvc energyNotifier) *EnergyService { +func NewEnergyService(db *gorm.DB, repo EnergyStore, expSvc expAdder, notifSvc energyNotifier) *EnergyService { return &EnergyService{db: db, repo: repo, expSvc: expSvc, notifSvc: notifSvc} } +// EnergyInfo 域能信息(含每日赋能经验状态) +type EnergyInfo struct { + Energy int // 域能余额(乘10存储) + DailyEnergizeExp int // 当日已通过赋能获得的经验值 + DailyExpCap int // 每日赋能经验上限 + DailyExpCapReached bool // 是否已达上限 +} + // SetExpAdder 链式注入经验服务(用于赋能获得经验) func (s *EnergyService) SetExpAdder(svc expAdder) { s.expSvc = svc @@ -57,8 +65,10 @@ func (s *EnergyService) Energize(energizerID uint, postID uint, amount int) (int var expGained int err := s.db.Transaction(func(tx *gorm.DB) error { + txRepo := s.repo.WithTx(tx) + // 1. 检查帖子存在,获取作者 - post, err := s.repo.FindPostByID(postID) + post, err := txRepo.FindPostByID(postID) if err != nil { if err == gorm.ErrRecordNotFound { return common.ErrPostNotFound @@ -72,7 +82,7 @@ func (s *EnergyService) Energize(energizerID uint, postID uint, amount int) (int } // 3. 检查赋能者域能余额(owner 跳过) - energizer, err := s.repo.FindUserByID(energizerID) + energizer, err := txRepo.FindUserByID(energizerID) if err != nil { return fmt.Errorf("查询用户失败: %w", err) } @@ -81,7 +91,7 @@ func (s *EnergyService) Energize(energizerID uint, postID uint, amount int) (int } // 4. 检查单用户单文章赋能总量 ≤ MaxEnergizePerPost - total, err := s.repo.SumUserPostEnergy(energizerID, postID) + total, err := txRepo.SumUserPostEnergy(energizerID, postID) if err != nil { return fmt.Errorf("查询赋能记录失败: %w", err) } @@ -90,18 +100,18 @@ func (s *EnergyService) Energize(energizerID uint, postID uint, amount int) (int } // 5. 扣赋能者域能 - if err := s.repo.AddEnergy(energizerID, -amount); err != nil { + 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 := s.repo.AddEnergy(post.UserID, receivedAmount); err != nil { + if err := txRepo.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 { + if err := txRepo.IncrPostEnergy(postID, amount); err != nil { return fmt.Errorf("更新文章赋能计数失败: %w", err) } @@ -111,7 +121,7 @@ func (s *EnergyService) Energize(energizerID uint, postID uint, amount int) (int PostID: postID, Amount: amount, } - if err := s.repo.CreateEnergizeLog(eLog); err != nil { + if err := txRepo.CreateEnergizeLog(eLog); err != nil { return fmt.Errorf("创建赋能记录失败: %w", err) } @@ -127,7 +137,7 @@ func (s *EnergyService) Energize(energizerID uint, postID uint, amount int) (int RelatedID: postID, Description: fmt.Sprintf("给文章 %d 赋能 %.1f 域能", postID, amountDisplay), } - if err := s.repo.CreateEnergyLog(energizerLog); err != nil { + if err := txRepo.CreateEnergyLog(energizerLog); err != nil { return fmt.Errorf("创建赋能者流水失败: %w", err) } @@ -139,7 +149,7 @@ func (s *EnergyService) Energize(energizerID uint, postID uint, amount int) (int RelatedID: postID, Description: fmt.Sprintf("文章 %d 被赋能 +%.1f 域能", postID, receivedDisplay), } - if err := s.repo.CreateEnergyLog(energizedLog); err != nil { + if err := txRepo.CreateEnergyLog(energizedLog); err != nil { return fmt.Errorf("创建被赋能者流水失败: %w", err) } @@ -148,7 +158,7 @@ func (s *EnergyService) Energize(energizerID uint, postID uint, amount int) (int 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) + summary, err := txRepo.GetDailyEnergizeExp(energizerID, today) currentDaily := 0 if err == nil { currentDaily = summary.ExpEarned @@ -177,7 +187,7 @@ func (s *EnergyService) Energize(energizerID uint, postID uint, amount int) (int Date: today, ExpEarned: currentDaily + expGain, } - if err := s.repo.UpsertDailyExp(newSummary); err != nil { + if err := txRepo.UpsertDailyExp(newSummary); err != nil { return fmt.Errorf("更新每日经验汇总失败: %w", err) } } @@ -282,13 +292,29 @@ func (s *EnergyService) AdminAdjust(operatorUID uint, userIDs []uint, amount int return nil } -// GetEnergyInfo 获取用户域能余额 -func (s *EnergyService) GetEnergyInfo(userID uint) (int, error) { +// GetEnergyInfo 获取用户域能余额和每日赋能经验状态 +func (s *EnergyService) GetEnergyInfo(userID uint) (*EnergyInfo, error) { user, err := s.repo.FindUserByID(userID) if err != nil { - return 0, err + return nil, err } - return user.Energy, 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) + + dailyExp := 0 + summary, err := s.repo.GetDailyEnergizeExp(userID, today) + if err == nil { + dailyExp = summary.ExpEarned + } + + return &EnergyInfo{ + Energy: user.Energy, + DailyEnergizeExp: dailyExp, + DailyExpCap: DailyEnergizeExpCap, + DailyExpCapReached: dailyExp >= DailyEnergizeExpCap, + }, nil } // GetEnergyLogs 分页查询用户域能流水(近 N 天) diff --git a/internal/service/repository.go b/internal/service/repository.go index 9986524..fadf170 100644 --- a/internal/service/repository.go +++ b/internal/service/repository.go @@ -4,6 +4,8 @@ import ( "time" "metazone.cc/metalab/internal/model" + + "gorm.io/gorm" ) // userAuthStore AuthService 所需的最小仓储接口(ISP:7 个方法) @@ -87,8 +89,8 @@ type levelNotifier interface { Create(userID uint, notifyType, title, content string, relatedID *uint) error } -// energyStore EnergyService 所需的完整仓储接口 -type energyStore interface { +// EnergyStore EnergyService 所需的完整仓储接口 +type EnergyStore interface { FindUserByID(userID uint) (*model.User, error) FindPostByID(postID uint) (*model.Post, error) AddEnergy(userID uint, amount int) error @@ -102,8 +104,13 @@ type energyStore interface { 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) + // WithTx 基于给定事务连接创建新的 EnergyStore(确保事务内操作原子性) + WithTx(tx *gorm.DB) EnergyStore } +// energyStore 向后兼容别名 +type energyStore = EnergyStore + // energyNotifier EnergyService 所需的通知接口(ISP) type energyNotifier interface { Create(userID uint, notifyType, title, content string, relatedID *uint) error diff --git a/templates/MetaLab-2026/html/posts/show.html b/templates/MetaLab-2026/html/posts/show.html index cc64640..be2d850 100644 --- a/templates/MetaLab-2026/html/posts/show.html +++ b/templates/MetaLab-2026/html/posts/show.html @@ -43,6 +43,33 @@ {{end}} + + + {{end}} @@ -112,45 +139,83 @@ diff --git a/templates/MetaLab-2026/html/settings/index.html b/templates/MetaLab-2026/html/settings/index.html index 4be9ea1..fbab797 100644 --- a/templates/MetaLab-2026/html/settings/index.html +++ b/templates/MetaLab-2026/html/settings/index.html @@ -159,7 +159,7 @@

我的域能

-

域能流水和余额

+

域能余额和经验

@@ -167,6 +167,14 @@ 域能余额 加载中...
+
+ 经验 + 加载中... +
+
+ 等级 + 加载中... +
加载中...
@@ -899,6 +907,8 @@ // ===== 域能 Tab ===== (function () { var balanceEl = document.getElementById('energyBalance'); + var expEl = document.getElementById('energyExp'); + var levelEl = document.getElementById('energyLevel'); var logListEl = document.getElementById('energyLogList'); if (!balanceEl || !logListEl) return; @@ -914,6 +924,25 @@ }) .catch(function () { balanceEl.textContent = '获取失败'; }); + // 获取经验和等级 + if (expEl && levelEl) { + fetch('/api/level/info') + .then(function (r) { return r.json(); }) + .then(function (d) { + if (d.success && d.data) { + expEl.textContent = d.data.exp; + levelEl.textContent = 'Lv' + d.data.level; + } else { + expEl.textContent = '获取失败'; + levelEl.textContent = '获取失败'; + } + }) + .catch(function () { + expEl.textContent = '获取失败'; + levelEl.textContent = '获取失败'; + }); + } + // 获取域能流水 fetch('/api/energy/logs?days=7&page=1&page_size=50') .then(function (r) { return r.json(); }) diff --git a/templates/MetaLab-2026/html/studio/write.html b/templates/MetaLab-2026/html/studio/write.html index 2f33f19..4f61d1f 100644 --- a/templates/MetaLab-2026/html/studio/write.html +++ b/templates/MetaLab-2026/html/studio/write.html @@ -20,6 +20,16 @@
{{end}} + {{if and (not .Post) (lt .Exp 1)}} +
+
🔒
+
+ Lv1 解锁投稿 +

完成首次任务(设置用户名、上传头像)或参与社区互动以获取经验。

+
+
+ {{end}} +
{{if .Post}} @@ -71,6 +81,8 @@ {{if and .Post .Post.IsLocked}} 🔒 此文已锁定,无法修改 + {{else if and (not .Post) (lt .Exp 1)}} + 🔒 Lv1 解锁投稿 {{else}} {{end}} diff --git a/templates/MetaLab-2026/static/css/posts.css b/templates/MetaLab-2026/static/css/posts.css index 8b4988f..f02836d 100644 --- a/templates/MetaLab-2026/static/css/posts.css +++ b/templates/MetaLab-2026/static/css/posts.css @@ -603,3 +603,113 @@ border-radius: 12px; white-space: nowrap; } + +/* ========== 赋能弹窗 ========== */ +.energize-overlay { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; +} + +.energize-modal { + background: #fff; + border-radius: 12px; + width: 360px; + max-width: 92vw; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15); + overflow: hidden; +} + +.energize-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px 16px; +} + +.energize-modal-header h3 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: #111827; +} + +.energize-close { + background: none; + border: none; + font-size: 24px; + color: #9ca3af; + cursor: pointer; + padding: 0; + line-height: 1; +} + +.energize-close:hover { + color: #374151; +} + +.energize-modal-body { + padding: 0 24px 24px; +} + +.energize-hint { + background: #fff7ed; + border: 1px solid #fed7aa; + border-radius: 8px; + padding: 10px 14px; + font-size: 13px; + color: #c2410c; + margin-bottom: 16px; + line-height: 1.5; +} + +.energize-options { + display: flex; + gap: 12px; +} + +.energize-option-btn { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + padding: 16px 12px; + border: 2px solid #e5e7eb; + border-radius: 10px; + background: #fff; + cursor: pointer; + transition: border-color 0.2s, background 0.2s; +} + +.energize-option-btn:hover { + border-color: #6366f1; + background: #f5f3ff; +} + +.energize-option-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.energize-option-title { + font-size: 16px; + font-weight: 600; + color: #111827; + margin-bottom: 4px; +} + +.energize-option-cost { + font-size: 13px; + color: #6b7280; +} + +.energize-option-gain { + font-size: 12px; + color: #059669; + margin-top: 2px; + font-weight: 500; +}