feat: 友链/书库/移动端导航/排行榜等多模块功能

新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
This commit is contained in:
2026-09-30 16:30:07 +08:00
parent e487bc2e92
commit 4f3f3a265c
105 changed files with 12371 additions and 852 deletions

View File

@@ -0,0 +1,67 @@
package service
import (
"testing"
"time"
"github.com/freefire/jiang13-bbs/model"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
// 贡献值口径:earnedPoints 只累计窗口内正入账积分(排除 bounty_refund),
// 支出、悬赏退回、封禁用户、窗口外入账均不计
func TestEarnedPointsWindowAndExclusions(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.User{}, &model.PointLedger{}); err != nil {
t.Fatalf("migrate: %v", err)
}
now := time.Now()
weekAgo := now.AddDate(0, 0, -7)
users := []*model.User{
{Username: "earner", Password: "x"},
{Username: "refund_only", Password: "x"},
{Username: "banned", Password: "x", Banned: true},
{Username: "old_earner", Password: "x"},
}
for _, u := range users {
if err := db.Create(u).Error; err != nil {
t.Fatalf("seed user %s: %v", u.Username, err)
}
}
a, d := users[0].ID, users[3].ID
ledgers := []model.PointLedger{
{UserID: a, Delta: 5, Reason: model.PointReasonCheckin, Balance: 5, CreatedAt: now},
{UserID: a, Delta: 3, Reason: model.PointReasonReplyReward, Balance: 8, CreatedAt: now},
{UserID: a, Delta: 2, Reason: model.PointReasonStreakBonus, Balance: 10, CreatedAt: now.Add(-time.Hour)},
{UserID: a, Delta: -3, Reason: model.PointReasonUnlockPost, Balance: 7, CreatedAt: now}, // 支出不计
{UserID: users[1].ID, Delta: 8, Reason: model.PointReasonBountyRefund, Balance: 8, CreatedAt: now}, // 退回不计
{UserID: users[2].ID, Delta: 5, Reason: model.PointReasonCheckin, Balance: 5, CreatedAt: now}, // 封禁不计
{UserID: d, Delta: 5, Reason: model.PointReasonCheckin, Balance: 5, CreatedAt: weekAgo.Add(-time.Hour)}, // 窗口外
}
if err := db.Create(&ledgers).Error; err != nil {
t.Fatalf("seed ledgers: %v", err)
}
s := NewLeaderboardService(db)
from := weekAgo
pts, err := s.earnedPoints(&from, nil)
if err != nil {
t.Fatalf("earnedPoints: %v", err)
}
if len(pts) != 1 || pts[a] != 10 {
t.Fatalf("周窗内应仅 earner 计 10 分,got %v", pts)
}
all, err := s.earnedPoints(nil, nil)
if err != nil {
t.Fatalf("earnedPoints(all): %v", err)
}
if len(all) != 2 || all[a] != 10 || all[d] != 5 {
t.Fatalf("全窗口:earner=10、old_earner=5,退回/封禁不计,got %v", all)
}
}