feat: 积分流水互转合并展示,并优化个人主页与经济看板
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user