feat: 积分流水互转合并展示,并优化个人主页与经济看板

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-18 02:37:12 +08:00
parent b3a4b8bb03
commit fa713bfc3f
9 changed files with 1098 additions and 230 deletions

View File

@@ -125,6 +125,170 @@ type LedgerItem struct {
CreatedAt time.Time `json:"created_at"`
}
// LedgerParty 管理端流水相关用户摘要
type LedgerParty struct {
ID uint `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
}
// AdminLedgerItem 管理端最近流水:单边入账带 user;用户互转合并为 from → to
type AdminLedgerItem struct {
ID uint `json:"id"`
Delta int `json:"delta"`
Balance int `json:"balance"`
Reason string `json:"reason"`
RefType string `json:"ref_type"`
RefID uint `json:"ref_id"`
Note string `json:"note"`
CreatedAt time.Time `json:"created_at"`
User *LedgerParty `json:"user,omitempty"`
From *LedgerParty `json:"from,omitempty"`
To *LedgerParty `json:"to,omitempty"`
}
const (
adminRecentLedgerLimit = 15
adminRecentLedgerFetch = 32
ledgerPairMaxDist = 8
)
func absInt(n int) int {
if n < 0 {
return -n
}
return n
}
func ledgerPartyOf(u model.User) LedgerParty {
return LedgerParty{ID: u.ID, Username: u.Username, Nickname: u.Nickname, Avatar: u.Avatar}
}
func copyLedgerParty(p LedgerParty) *LedgerParty {
if p.ID == 0 {
return nil
}
cp := p
return &cp
}
// complementaryLedgerRef 用户互转对向 ref_type;非互转返回空
func complementaryLedgerRef(reason, refType string) string {
switch reason {
case model.PointReasonUnlockPost:
if refType == "post" {
return "post_earn"
}
if refType == "post_earn" {
return "post"
}
case model.PointReasonDownloadFile:
if refType == "attachment" {
return "attachment_earn"
}
if refType == "attachment_earn" {
return "attachment"
}
}
return ""
}
func ledgersComplement(a, b model.PointLedger) bool {
comp := complementaryLedgerRef(a.Reason, a.RefType)
if comp == "" || b.Reason != a.Reason || b.RefID != a.RefID || b.RefType != comp {
return false
}
if absInt(a.Delta) != absInt(b.Delta) || absInt(a.Delta) == 0 {
return false
}
return (a.Delta > 0) != (b.Delta > 0)
}
func idDist(a, b uint) int {
if a > b {
return int(a - b)
}
return int(b - a)
}
func findComplementIndex(rows []model.PointLedger, i int, used []bool) int {
r := rows[i]
if complementaryLedgerRef(r.Reason, r.RefType) == "" {
return -1
}
best := -1
bestDist := ledgerPairMaxDist + 1
for j := range rows {
if j == i || used[j] || !ledgersComplement(r, rows[j]) {
continue
}
d := idDist(r.ID, rows[j].ID)
if d == 0 || d > ledgerPairMaxDist || d >= bestDist {
continue
}
bestDist = d
best = j
}
return best
}
// pairLedgerIndices 将用户互转的支出/收益配成一对;返回 [本条, 对向](无对向则为 -1)
func pairLedgerIndices(rows []model.PointLedger) [][2]int {
used := make([]bool, len(rows))
out := make([][2]int, 0, len(rows))
for i := range rows {
if used[i] {
continue
}
j := findComplementIndex(rows, i, used)
if j >= 0 {
used[i] = true
used[j] = true
out = append(out, [2]int{i, j})
continue
}
used[i] = true
out = append(out, [2]int{i, -1})
}
return out
}
func mergePairedLedger(a, b model.PointLedger) model.PointLedger {
newer, older := a, b
if b.ID > a.ID {
newer, older = b, a
}
out := newer
out.Delta = absInt(newer.Delta)
if newer.Delta < 0 {
out.Delta = absInt(older.Delta)
}
debit, credit := a, b
if a.Delta > 0 {
debit, credit = b, a
}
if debit.Note != "" {
out.Note = debit.Note
} else {
out.Note = credit.Note
}
return out
}
func transferEnds(a, b model.PointLedger) (fromID, toID uint) {
if a.Delta < 0 {
return a.UserID, b.UserID
}
if b.Delta < 0 {
return b.UserID, a.UserID
}
if a.Delta > 0 {
return b.UserID, a.UserID
}
return a.UserID, b.UserID
}
// Ledger 分页查询本人积分流水
func (s *PointsService) Ledger(userID uint, page, size int) ([]LedgerItem, int64, error) {
if page < 1 {
@@ -161,17 +325,17 @@ type DeltaDay struct {
// PointsEconomyStats 全站积分与类型帖概览(管理端)
type PointsEconomyStats struct {
TotalBalance int64 `json:"total_balance"`
LedgerCount int64 `json:"ledger_count"`
CheckinToday int64 `json:"checkin_today"`
OpenBounties int64 `json:"open_bounties"`
EscrowedPoints int64 `json:"escrowed_points"`
PostsByType map[string]int64 `json:"posts_by_type"`
RecentLedger []LedgerItem `json:"recent_ledger"`
CheckinSeries []DayCount `json:"checkin_series"`
LedgerByReason []NamedCount `json:"ledger_by_reason"`
DeltaSeries []DeltaDay `json:"delta_series"`
NetDelta7d int64 `json:"net_delta_7d"`
TotalBalance int64 `json:"total_balance"`
LedgerCount int64 `json:"ledger_count"`
CheckinToday int64 `json:"checkin_today"`
OpenBounties int64 `json:"open_bounties"`
EscrowedPoints int64 `json:"escrowed_points"`
PostsByType map[string]int64 `json:"posts_by_type"`
RecentLedger []AdminLedgerItem `json:"recent_ledger"`
CheckinSeries []DayCount `json:"checkin_series"`
LedgerByReason []NamedCount `json:"ledger_by_reason"`
DeltaSeries []DeltaDay `json:"delta_series"`
NetDelta7d int64 `json:"net_delta_7d"`
}
// AdminEconomyStats 管理端经济看板
@@ -181,6 +345,7 @@ func (s *PointsService) AdminEconomyStats() (*PointsEconomyStats, error) {
CheckinSeries: make([]DayCount, 0),
LedgerByReason: make([]NamedCount, 0),
DeltaSeries: make([]DeltaDay, 0),
RecentLedger: make([]AdminLedgerItem, 0),
}
if err := s.db.Model(&model.User{}).Where("deleted_at IS NULL").
Select("COALESCE(SUM(points),0)").Scan(&st.TotalBalance).Error; err != nil {
@@ -228,17 +393,11 @@ func (s *PointsService) AdminEconomyStats() (*PointsEconomyStats, error) {
}
}
var recent []model.PointLedger
if err := s.db.Order("id DESC").Limit(15).Find(&recent).Error; err != nil {
recent, err := s.buildAdminRecentLedger()
if err != nil {
return nil, err
}
st.RecentLedger = make([]LedgerItem, 0, len(recent))
for _, r := range recent {
st.RecentLedger = append(st.RecentLedger, LedgerItem{
ID: r.ID, Delta: r.Delta, Balance: r.Balance, Reason: r.Reason,
RefType: r.RefType, RefID: r.RefID, Note: r.Note, CreatedAt: r.CreatedAt,
})
}
st.RecentLedger = recent
now := time.Now()
day0 := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
@@ -313,3 +472,206 @@ func (s *PointsService) AdminEconomyStats() (*PointsEconomyStats, error) {
return st, nil
}
func (s *PointsService) lookupLedgerMate(r model.PointLedger) (*model.PointLedger, error) {
comp := complementaryLedgerRef(r.Reason, r.RefType)
if comp == "" || r.RefID == 0 {
return nil, nil
}
amt := absInt(r.Delta)
if amt == 0 {
return nil, nil
}
lo, hi := uint(1), r.ID+uint(ledgerPairMaxDist)
if r.ID > uint(ledgerPairMaxDist) {
lo = r.ID - uint(ledgerPairMaxDist)
}
var mates []model.PointLedger
q := s.db.Where("reason = ? AND ref_id = ? AND ref_type = ? AND id BETWEEN ? AND ? AND id <> ?",
r.Reason, r.RefID, comp, lo, hi, r.ID)
if r.Delta > 0 {
q = q.Where("delta = ?", -amt)
} else {
q = q.Where("delta = ?", amt)
}
if err := q.Find(&mates).Error; err != nil {
return nil, err
}
best := -1
bestDist := ledgerPairMaxDist + 1
for i := range mates {
d := idDist(r.ID, mates[i].ID)
if d == 0 || d > ledgerPairMaxDist || d >= bestDist {
continue
}
bestDist = d
best = i
}
if best < 0 {
return nil, nil
}
return &mates[best], nil
}
func (s *PointsService) loadLedgerParties(ids []uint) (map[uint]LedgerParty, error) {
out := map[uint]LedgerParty{}
if len(ids) == 0 {
return out, nil
}
seen := map[uint]struct{}{}
uniq := make([]uint, 0, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
uniq = append(uniq, id)
}
if len(uniq) == 0 {
return out, nil
}
var users []model.User
if err := s.db.Unscoped().Select("id", "username", "nickname", "avatar").
Where("id IN ?", uniq).Find(&users).Error; err != nil {
return nil, err
}
for _, u := range users {
out[u.ID] = ledgerPartyOf(u)
}
return out, nil
}
func (s *PointsService) bountyPayers(rows []model.PointLedger) (map[uint]uint, error) {
ids := make([]uint, 0)
seen := map[uint]struct{}{}
for _, r := range rows {
if r.Reason != model.PointReasonBountyAward || r.RefType != "post" || r.RefID == 0 {
continue
}
if _, ok := seen[r.RefID]; ok {
continue
}
seen[r.RefID] = struct{}{}
ids = append(ids, r.RefID)
}
out := map[uint]uint{}
if len(ids) == 0 {
return out, nil
}
var posts []model.Post
if err := s.db.Unscoped().Select("id", "user_id").Where("id IN ?", ids).Find(&posts).Error; err != nil {
return nil, err
}
for _, p := range posts {
out[p.ID] = p.UserID
}
return out, nil
}
func (s *PointsService) buildAdminRecentLedger() ([]AdminLedgerItem, error) {
out := make([]AdminLedgerItem, 0, adminRecentLedgerLimit)
var recent []model.PointLedger
if err := s.db.Order("id DESC").Limit(adminRecentLedgerFetch).Find(&recent).Error; err != nil {
return nil, err
}
if len(recent) == 0 {
return out, nil
}
pairs := pairLedgerIndices(recent)
outside := map[int]model.PointLedger{}
for _, p := range pairs {
if p[1] >= 0 {
continue
}
r := recent[p[0]]
if complementaryLedgerRef(r.Reason, r.RefType) == "" {
continue
}
mate, err := s.lookupLedgerMate(r)
if err != nil {
return nil, err
}
if mate != nil {
outside[p[0]] = *mate
}
}
payers, err := s.bountyPayers(recent)
if err != nil {
return nil, err
}
userIDs := make([]uint, 0, len(recent)+len(outside)+len(payers))
for _, r := range recent {
userIDs = append(userIDs, r.UserID)
}
for _, m := range outside {
userIDs = append(userIDs, m.UserID)
}
for _, uid := range payers {
userIDs = append(userIDs, uid)
}
parties, err := s.loadLedgerParties(userIDs)
if err != nil {
return nil, err
}
party := func(id uint) *LedgerParty {
if id == 0 {
return nil
}
if p, ok := parties[id]; ok {
return copyLedgerParty(p)
}
return &LedgerParty{ID: id, Nickname: "已注销"}
}
itemFrom := func(r model.PointLedger) AdminLedgerItem {
return AdminLedgerItem{
ID: r.ID, Delta: r.Delta, Balance: r.Balance, Reason: r.Reason,
RefType: r.RefType, RefID: r.RefID, Note: r.Note, CreatedAt: r.CreatedAt,
User: party(r.UserID),
}
}
for _, p := range pairs {
if len(out) >= adminRecentLedgerLimit {
break
}
a := recent[p[0]]
var mate *model.PointLedger
if p[1] >= 0 {
m := recent[p[1]]
mate = &m
} else if m, ok := outside[p[0]]; ok {
mate = &m
}
if mate != nil {
merged := mergePairedLedger(a, *mate)
fromID, toID := transferEnds(a, *mate)
item := itemFrom(merged)
item.Delta = absInt(merged.Delta)
item.User = nil
item.From = party(fromID)
item.To = party(toID)
out = append(out, item)
continue
}
item := itemFrom(a)
if a.Reason == model.PointReasonBountyAward && a.Delta > 0 {
if payerID := payers[a.RefID]; payerID > 0 && payerID != a.UserID {
item.From = party(payerID)
item.To = party(a.UserID)
item.User = nil
item.Delta = a.Delta
}
}
out = append(out, item)
}
return out, nil
}