Persist live status and migrate legacy live data

- Add user_live_status to store hi score, hi combo count, and clear count per chart
- Expand user_live_goal to persist achieved live_goal_reward_id entries instead of only coarse goal types
- Update live reward handling to refresh live status and sync newly achieved live goals on every clear
- Return live status and achieved goal ids from stored user data in /api/live/status
- Aggregate profile liveCnt from stored user_live_status instead of hardcoded values
- Load complete-rank thresholds from live info for goal evaluation
- Migrate legacy user_live_record data into user_live_status and backfill achievable live goals on startup

Signed-off-by: Sean Du <do4suki@gmail.com>
This commit is contained in:
2026-06-06 16:25:06 +08:00
parent f8a0f2db13
commit f6466989c6
11 changed files with 731 additions and 86 deletions
+44 -27
View File
@@ -1,7 +1,6 @@
package session
import (
"honoka-chan/internal/constant"
usermodel "honoka-chan/internal/model/user"
liveschema "honoka-chan/internal/schema/live"
liverecordschema "honoka-chan/internal/schema/liverecord"
@@ -64,13 +63,34 @@ func (ss *Session) GetLiveInfo(LiveDifficultyID int) (bool, *liveschema.LiveInfo
BRankCombo int `xorm:"b_rank_combo"`
CRankCombo int `xorm:"c_rank_combo"`
SRankCombo int `xorm:"s_rank_combo"`
ARankComplete int `xorm:"a_rank_complete"`
BRankComplete int `xorm:"b_rank_complete"`
CRankComplete int `xorm:"c_rank_complete"`
SRankComplete int `xorm:"s_rank_complete"`
AcFlag int `xorm:"ac_flag"`
SwingFlag int `xorm:"swing_flag"`
}
has, err := ss.MainEng.Table("special_live_m").Alias("a").
Join("LEFT", "live_setting_m", "a.live_setting_id = live_setting_m.live_setting_id").
Where("live_difficulty_id = ?", LiveDifficultyID).
Cols("a.live_difficulty_id,live_setting_m.*").Get(&info)
Select(`
a.live_difficulty_id,
a.c_rank_complete,
a.b_rank_complete,
a.a_rank_complete,
a.s_rank_complete,
live_setting_m.notes_setting_asset,
live_setting_m.a_rank_score,
live_setting_m.b_rank_score,
live_setting_m.c_rank_score,
live_setting_m.s_rank_score,
live_setting_m.a_rank_combo,
live_setting_m.b_rank_combo,
live_setting_m.c_rank_combo,
live_setting_m.s_rank_combo,
live_setting_m.ac_flag,
live_setting_m.swing_flag
`).Get(&info)
if ss.CheckErr(err) {
return false, nil
}
@@ -79,7 +99,24 @@ func (ss *Session) GetLiveInfo(LiveDifficultyID int) (bool, *liveschema.LiveInfo
has, err = ss.MainEng.Table("normal_live_m").Alias("a").
Join("LEFT", "live_setting_m", "a.live_setting_id = live_setting_m.live_setting_id").
Where("live_difficulty_id = ?", LiveDifficultyID).
Cols("a.live_difficulty_id,live_setting_m.*").Get(&info)
Select(`
a.live_difficulty_id,
a.c_rank_complete,
a.b_rank_complete,
a.a_rank_complete,
a.s_rank_complete,
live_setting_m.notes_setting_asset,
live_setting_m.a_rank_score,
live_setting_m.b_rank_score,
live_setting_m.c_rank_score,
live_setting_m.s_rank_score,
live_setting_m.a_rank_combo,
live_setting_m.b_rank_combo,
live_setting_m.c_rank_combo,
live_setting_m.s_rank_combo,
live_setting_m.ac_flag,
live_setting_m.swing_flag
`).Get(&info)
if ss.CheckErr(err) {
return false, nil
}
@@ -105,6 +142,10 @@ func (ss *Session) GetLiveInfo(LiveDifficultyID int) (bool, *liveschema.LiveInfo
BRankCombo: info.BRankCombo,
CRankCombo: info.CRankCombo,
SRankCombo: info.SRankCombo,
ARankComplete: info.ARankComplete,
BRankComplete: info.BRankComplete,
CRankComplete: info.CRankComplete,
SRankComplete: info.SRankComplete,
AcFlag: info.AcFlag,
SwingFlag: info.SwingFlag,
NotesList: []liveschema.NotesList{},
@@ -188,27 +229,3 @@ func (ss *Session) GetDeckInfo(deckID int) (bool, *liverecordschema.DeckInfo) {
UnitList: unitData,
}
}
func (ss *Session) GetLiveGoalList(LiveDifficultyID int) []constant.LiveGoalType {
var goal []constant.LiveGoalType
err := ss.MainEng.Table("live_goal_reward_m").
Where("live_difficulty_id = ?", LiveDifficultyID).Cols("live_goal_type").Find(&goal)
if ss.CheckErr(err) {
return []constant.LiveGoalType{}
}
return goal
}
func (ss *Session) GetUserLiveGoalList(LiveDifficultyID int) []constant.LiveGoalType {
var goal []constant.LiveGoalType
var goalData []usermodel.UserLiveGoal
err := ss.UserEng.Table(new(usermodel.UserLiveGoal)).
Where("user_id = ? AND live_difficulty_id = ?", ss.UserID, LiveDifficultyID).Find(&goalData)
if ss.CheckErr(err) {
return []constant.LiveGoalType{}
}
for _, g := range goalData {
goal = append(goal, g.GoalType)
}
return goal
}
+281
View File
@@ -0,0 +1,281 @@
package session
import (
"honoka-chan/internal/constant"
usermodel "honoka-chan/internal/model/user"
"sort"
"time"
)
type liveGoalRewardRow struct {
LiveGoalRewardID int `xorm:"live_goal_reward_id"`
LiveDifficultyID int `xorm:"live_difficulty_id"`
LiveGoalType constant.LiveGoalType `xorm:"live_goal_type"`
Rank int `xorm:"rank"`
AddType int `xorm:"add_type"`
ItemID int `xorm:"item_id"`
ItemCategoryID int `xorm:"item_category_id"`
Amount int `xorm:"amount"`
}
type LiveStatusSnapshot struct {
Status int
HiScore int
HiComboCount int
ClearCnt int
AchievedGoalIDList []int
}
func liveRank(value int, cRank int, bRank int, aRank int, sRank int) int {
switch {
case value >= sRank:
return 1
case value >= aRank:
return 2
case value >= bRank:
return 3
case value >= cRank:
return 4
default:
return 5
}
}
func (ss *Session) GetUserLiveStatus(liveDifficultyID int) (bool, *usermodel.UserLiveStatus, error) {
status := usermodel.UserLiveStatus{}
has, err := ss.UserEng.Table(new(usermodel.UserLiveStatus)).
Where("user_id = ? AND live_difficulty_id = ?", ss.UserID, liveDifficultyID).
Get(&status)
if err != nil {
return false, nil, err
}
return has, &status, nil
}
func (ss *Session) GetUserLiveStatusMap(liveDifficultyIDs []int) (map[int]usermodel.UserLiveStatus, error) {
result := map[int]usermodel.UserLiveStatus{}
if len(liveDifficultyIDs) == 0 {
return result, nil
}
rows := []usermodel.UserLiveStatus{}
err := ss.UserEng.Table(new(usermodel.UserLiveStatus)).
In("live_difficulty_id", liveDifficultyIDs).
Where("user_id = ?", ss.UserID).
Find(&rows)
if err != nil {
return nil, err
}
for _, row := range rows {
result[row.LiveDifficultyID] = row
}
return result, nil
}
func (ss *Session) GetUserLiveGoalRewardIDMap(liveDifficultyIDs []int) (map[int][]int, error) {
result := map[int][]int{}
if len(liveDifficultyIDs) == 0 {
return result, nil
}
rows := []usermodel.UserLiveGoal{}
err := ss.UserEng.Table(new(usermodel.UserLiveGoal)).
In("live_difficulty_id", liveDifficultyIDs).
Where("user_id = ?", ss.UserID).
Where("live_goal_reward_id > 0").
OrderBy("live_goal_reward_id ASC").
Find(&rows)
if err != nil {
return nil, err
}
for _, row := range rows {
result[row.LiveDifficultyID] = append(result[row.LiveDifficultyID], row.LiveGoalRewardID)
}
return result, nil
}
func (ss *Session) BuildLiveStatusSnapshotMap(liveDifficultyIDs []int) (map[int]LiveStatusSnapshot, error) {
statusMap, err := ss.GetUserLiveStatusMap(liveDifficultyIDs)
if err != nil {
return nil, err
}
goalMap, err := ss.GetUserLiveGoalRewardIDMap(liveDifficultyIDs)
if err != nil {
return nil, err
}
result := make(map[int]LiveStatusSnapshot, len(liveDifficultyIDs))
for _, liveDifficultyID := range liveDifficultyIDs {
status := LiveStatusSnapshot{
Status: 1,
HiScore: 0,
HiComboCount: 0,
ClearCnt: 0,
AchievedGoalIDList: []int{},
}
if row, ok := statusMap[liveDifficultyID]; ok {
status.HiScore = row.HiScore
status.HiComboCount = row.HiComboCount
status.ClearCnt = row.ClearCnt
if row.ClearCnt > 0 {
status.Status = 2
}
}
if ids := goalMap[liveDifficultyID]; len(ids) > 0 {
status.AchievedGoalIDList = ids
}
result[liveDifficultyID] = status
}
return result, nil
}
func (ss *Session) UpdateUserLiveStatus(liveDifficultyID int, totalScore int, maxCombo int) (before usermodel.UserLiveStatus, after usermodel.UserLiveStatus, err error) {
now := time.Now().Unix()
has, current, err := ss.GetUserLiveStatus(liveDifficultyID)
if err != nil {
return before, after, err
}
if current == nil {
current = &usermodel.UserLiveStatus{}
}
before = *current
if !has {
after = usermodel.UserLiveStatus{
UserID: ss.UserID,
LiveDifficultyID: liveDifficultyID,
HiScore: totalScore,
HiComboCount: maxCombo,
ClearCnt: 1,
InsertDate: now,
UpdateDate: now,
}
_, err = ss.UserEng.Insert(&after)
return before, after, err
}
after = before
if totalScore > after.HiScore {
after.HiScore = totalScore
}
if maxCombo > after.HiComboCount {
after.HiComboCount = maxCombo
}
after.ClearCnt++
after.UpdateDate = now
_, err = ss.UserEng.Table(new(usermodel.UserLiveStatus)).
Where("user_id = ? AND live_difficulty_id = ?", ss.UserID, liveDifficultyID).
Cols("hi_score", "hi_combo_count", "clear_cnt", "update_date").
Update(&after)
return before, after, err
}
func (ss *Session) GetAchievedLiveGoalRewardRows(liveDifficultyID int, hiScore int, hiComboCount int, clearCnt int) ([]liveGoalRewardRow, error) {
has, liveInfo := ss.GetLiveInfo(liveDifficultyID)
if !has || liveInfo == nil {
return []liveGoalRewardRow{}, nil
}
rows := []liveGoalRewardRow{}
err := ss.MainEng.Table("live_goal_reward_m").
Where("live_difficulty_id = ?", liveDifficultyID).
OrderBy("live_goal_type ASC, rank ASC, live_goal_reward_id ASC").
Find(&rows)
if err != nil {
return nil, err
}
scoreRank := liveRank(hiScore, liveInfo.CRankScore, liveInfo.BRankScore, liveInfo.ARankScore, liveInfo.SRankScore)
comboRank := liveRank(hiComboCount, liveInfo.CRankCombo, liveInfo.BRankCombo, liveInfo.ARankCombo, liveInfo.SRankCombo)
clearRank := liveRank(clearCnt, liveInfo.CRankComplete, liveInfo.BRankComplete, liveInfo.ARankComplete, liveInfo.SRankComplete)
result := make([]liveGoalRewardRow, 0, len(rows))
for _, row := range rows {
switch row.LiveGoalType {
case constant.LiveGoalTypeScore:
if scoreRank <= row.Rank {
result = append(result, row)
}
case constant.LiveGoalTypeCombo:
if comboRank <= row.Rank {
result = append(result, row)
}
case constant.LiveGoalTypeClear:
if clearRank <= row.Rank {
result = append(result, row)
}
}
}
return result, nil
}
func (ss *Session) SyncUserLiveGoals(liveDifficultyID int, hiScore int, hiComboCount int, clearCnt int) ([]int, error) {
achievedRows, err := ss.GetAchievedLiveGoalRewardRows(liveDifficultyID, hiScore, hiComboCount, clearCnt)
if err != nil {
return nil, err
}
if len(achievedRows) == 0 {
return []int{}, nil
}
existingRows := []usermodel.UserLiveGoal{}
err = ss.UserEng.Table(new(usermodel.UserLiveGoal)).
Where("user_id = ? AND live_difficulty_id = ?", ss.UserID, liveDifficultyID).
Where("live_goal_reward_id > 0").
Find(&existingRows)
if err != nil {
return nil, err
}
existing := make(map[int]struct{}, len(existingRows))
for _, row := range existingRows {
existing[row.LiveGoalRewardID] = struct{}{}
}
now := time.Now().Unix()
newlyAchieved := make([]int, 0)
for _, row := range achievedRows {
if _, ok := existing[row.LiveGoalRewardID]; ok {
continue
}
_, err = ss.UserEng.Insert(&usermodel.UserLiveGoal{
UserID: ss.UserID,
LiveDifficultyID: liveDifficultyID,
LiveGoalRewardID: row.LiveGoalRewardID,
GoalType: row.LiveGoalType,
Rank: row.Rank,
CompletedAt: now,
})
if err != nil {
return nil, err
}
newlyAchieved = append(newlyAchieved, row.LiveGoalRewardID)
}
sort.Ints(newlyAchieved)
return newlyAchieved, nil
}
func (ss *Session) GetLiveGoalRewardRowsByIDs(goalRewardIDs []int) ([]liveGoalRewardRow, error) {
if len(goalRewardIDs) == 0 {
return []liveGoalRewardRow{}, nil
}
rows := []liveGoalRewardRow{}
err := ss.MainEng.Table("live_goal_reward_m").
In("live_goal_reward_id", goalRewardIDs).
OrderBy("live_goal_reward_id ASC").
Find(&rows)
if err != nil {
return nil, err
}
return rows, nil
}