feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
@@ -10,21 +12,119 @@ import (
|
||||
// 板块管理员的板块范围由 Actor.BoardIDs 在业务层二次校验。
|
||||
const (
|
||||
PermUsers = "users" // 用户与权限管理(超管/站长)
|
||||
PermAnnouncements = "announcements" // 公告管理(管理员及以上)
|
||||
PermAnnouncements = "announcements" // 公告管理(管理员及以上;可账号级授予)
|
||||
PermPages = "pages" // 单页管理(管理员及以上;可账号级授予)
|
||||
PermLibrary = "library" // 书库管理(管理员及以上;可账号级授予)
|
||||
PermAds = "ads" // 广告与赞助管理(管理员及以上;可账号级授予)
|
||||
PermSettings = "settings" // 站点外观设置(超管/站长)
|
||||
PermModeration = "moderation" // 内容审核(任意管理角色,板块范围受限)
|
||||
PermMessages = "messages" // 后台消息管理(站长/超管/站点消息 flag;群管另有业务层放行)
|
||||
PermBoards = "boards" // 板块管理(仅站长)
|
||||
)
|
||||
|
||||
// GrantablePerms 可账号级授予的渠道权限码(授予者需持有 PermUsers)。
|
||||
// 管理员及以上角色对这些渠道为角色自带,账号级授予只对板块管理员/普通用户生效。
|
||||
var GrantablePerms = []string{PermAnnouncements, PermPages, PermLibrary, PermAds}
|
||||
|
||||
// ValidPermOverride 权限码是否可被账号级授予
|
||||
func ValidPermOverride(p string) bool {
|
||||
for _, g := range GrantablePerms {
|
||||
if p == g {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// NormalizePermOverrides 白名单过滤 + 去重 + 排序,得到可落库的权限码集
|
||||
func NormalizePermOverrides(perms []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(perms))
|
||||
for _, p := range perms {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" || seen[p] || !ValidPermOverride(p) {
|
||||
continue
|
||||
}
|
||||
seen[p] = true
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// ParsePermOverrides 解析 users.perm_overrides JSON 列(容错:非法内容视为空)
|
||||
func ParsePermOverrides(raw string) []string {
|
||||
if raw == "" {
|
||||
return []string{}
|
||||
}
|
||||
var arr []string
|
||||
if err := json.Unmarshal([]byte(raw), &arr); err != nil {
|
||||
return []string{}
|
||||
}
|
||||
return NormalizePermOverrides(arr)
|
||||
}
|
||||
|
||||
// SerializePermOverrides 序列化为落库 JSON(空集统一 "[]",避免 NULL/空串分歧)
|
||||
func SerializePermOverrides(perms []string) string {
|
||||
norm := NormalizePermOverrides(perms)
|
||||
b, err := json.Marshal(norm)
|
||||
if err != nil {
|
||||
return "[]"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// roleDefaultPerm 角色对功能点的固有权限(不含账号级授予)
|
||||
func roleDefaultPerm(role model.Role, p string) bool {
|
||||
switch p {
|
||||
case PermBoards:
|
||||
return role == model.RoleOwner
|
||||
case PermUsers, PermSettings:
|
||||
return role == model.RoleSuperAdmin || role == model.RoleOwner
|
||||
case PermAnnouncements, PermPages, PermLibrary, PermAds:
|
||||
return model.RoleLevel(role) >= model.RoleLevel(model.RoleAdmin)
|
||||
case PermModeration:
|
||||
return model.IsStaff(role)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// EffectivePerms 生效权限码列表(角色默认 ∪ 账号授予 + 消息管理),
|
||||
// 供 /me 下发,前端据此渲染后台入口/按钮;最终权限仍以 HasPerm 校验为准。
|
||||
func EffectivePerms(role model.Role, permOverridesRaw string, canManageMessages bool) []string {
|
||||
set := map[string]bool{}
|
||||
all := append([]string{
|
||||
PermUsers, PermAnnouncements, PermPages, PermLibrary, PermAds,
|
||||
PermSettings, PermModeration, PermBoards,
|
||||
}, GrantablePerms...)
|
||||
for _, p := range all {
|
||||
if roleDefaultPerm(role, p) {
|
||||
set[p] = true
|
||||
}
|
||||
}
|
||||
for _, p := range ParsePermOverrides(permOverridesRaw) {
|
||||
set[p] = true
|
||||
}
|
||||
if role == model.RoleOwner || role == model.RoleSuperAdmin || canManageMessages {
|
||||
set[PermMessages] = true
|
||||
}
|
||||
out := make([]string, 0, len(set))
|
||||
for p := range set {
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Actor 当前请求操作者的实时权限快照(每次后台请求从 DB 现取,
|
||||
// 不依赖 JWT 内的 role claim,角色/授权变更立即生效)
|
||||
type Actor struct {
|
||||
ID uint
|
||||
Username string
|
||||
Role model.Role
|
||||
BoardIDs []uint // 板块管理员被授权的板块;其他角色为空
|
||||
CanManageMessages bool // 站点级消息管理 flag(站长授予);站长/超管不必依赖此字段
|
||||
BoardIDs []uint // 板块管理员被授权的板块;其他角色为空
|
||||
CanManageMessages bool // 站点级消息管理 flag(站长授予);站长/超管不必依赖此字段
|
||||
ExtraPerms []string // 账号级授予的渠道权限(角色固有之外的增量)
|
||||
}
|
||||
|
||||
// IsStaff 是否管理团队成员
|
||||
@@ -32,23 +132,22 @@ func (a *Actor) IsStaff() bool {
|
||||
return a != nil && model.IsStaff(a.Role)
|
||||
}
|
||||
|
||||
// HasPerm 是否拥有某后台功能点
|
||||
// HasPerm 是否拥有某后台功能点:角色固有权限 ∪ 账号级授予
|
||||
func (a *Actor) HasPerm(p string) bool {
|
||||
if a == nil {
|
||||
return false
|
||||
}
|
||||
switch p {
|
||||
case PermBoards:
|
||||
return a.Role == model.RoleOwner
|
||||
case PermUsers, PermSettings:
|
||||
return a.Role == model.RoleSuperAdmin || a.Role == model.RoleOwner
|
||||
case PermAnnouncements:
|
||||
return model.RoleLevel(a.Role) >= model.RoleLevel(model.RoleAdmin)
|
||||
case PermModeration:
|
||||
return a.IsStaff()
|
||||
case PermMessages:
|
||||
if roleDefaultPerm(a.Role, p) {
|
||||
return true
|
||||
}
|
||||
if p == PermMessages {
|
||||
return a.HasSiteMessagePerm()
|
||||
}
|
||||
for _, e := range a.ExtraPerms {
|
||||
if e == p {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -113,15 +212,16 @@ func (a *Actor) CanAssignRole(target model.Role) bool {
|
||||
return a.HasPerm(PermUsers)
|
||||
}
|
||||
|
||||
// LoadActor 读取用户实时角色与板块授权
|
||||
// LoadActor 读取用户实时角色、板块授权与账号级渠道权限
|
||||
func (s *AuthService) LoadActor(id uint) (*Actor, error) {
|
||||
var u model.User
|
||||
if err := s.db.Select("id", "username", "role", "can_manage_messages").First(&u, id).Error; err != nil {
|
||||
if err := s.db.Select("id", "username", "role", "can_manage_messages", "perm_overrides").First(&u, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
actor := &Actor{
|
||||
ID: u.ID, Username: u.Username, Role: u.Role, BoardIDs: []uint{},
|
||||
CanManageMessages: u.CanManageMessages,
|
||||
ExtraPerms: ParsePermOverrides(u.PermOverrides),
|
||||
}
|
||||
if u.Role == model.RoleBoardAdmin {
|
||||
var ids []uint
|
||||
|
||||
@@ -17,25 +17,25 @@ import (
|
||||
const (
|
||||
SettingKeyAdsConfig = "ads_config"
|
||||
|
||||
AdMaxTitleRunes = 24
|
||||
AdMaxNoteRunes = 100
|
||||
AdMaxImageShow = 5
|
||||
AdMaxTextShow = 6
|
||||
AdMaxActiveShow = AdMaxImageShow + AdMaxTextShow
|
||||
AdDefaultTitle = "自助推广"
|
||||
AdMaxTitleRunes = 24
|
||||
AdMaxNoteRunes = 100
|
||||
AdMaxImageShow = 5
|
||||
AdMaxTextShow = 6
|
||||
AdMaxActiveShow = AdMaxImageShow + AdMaxTextShow
|
||||
AdDefaultTitle = "自助推广"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAdNotFound = errors.New("广告不存在")
|
||||
ErrAdForbidden = errors.New("无权操作")
|
||||
ErrAdInvalid = errors.New("广告参数无效")
|
||||
ErrAdCaptcha = errors.New("验证码错误或已过期")
|
||||
ErrAdDisabled = errors.New("自助推广暂未开放")
|
||||
ErrAdBadPayment = errors.New("请选择有效的支付方式")
|
||||
ErrAdBadDuration = errors.New("请选择有效的投放时长")
|
||||
hexColorRe = regexp.MustCompile(`^#([0-9a-fA-F]{6})$`)
|
||||
adImageHTTPSRe = regexp.MustCompile(`(?i)^https://[^\s\\]{1,500}$`)
|
||||
adImageUploadRe = regexp.MustCompile(`(?i)^/uploads/(ads|images|brand)/[0-9a-f]{32}\.(png|jpe?g|gif|webp)$`)
|
||||
ErrAdNotFound = errors.New("广告不存在")
|
||||
ErrAdForbidden = errors.New("无权操作")
|
||||
ErrAdInvalid = errors.New("广告参数无效")
|
||||
ErrAdCaptcha = errors.New("验证码错误或已过期")
|
||||
ErrAdDisabled = errors.New("自助推广暂未开放")
|
||||
ErrAdBadPayment = errors.New("请选择有效的支付方式")
|
||||
ErrAdBadDuration = errors.New("请选择有效的投放时长")
|
||||
hexColorRe = regexp.MustCompile(`^#([0-9a-fA-F]{6})$`)
|
||||
adImageHTTPSRe = regexp.MustCompile(`(?i)^https://[^\s\\]{1,500}$`)
|
||||
adImageUploadRe = regexp.MustCompile(`(?i)^/uploads/(ads|images|brand)/[0-9a-f]{32}\.(png|jpe?g|gif|webp)$`)
|
||||
)
|
||||
|
||||
// AdDurationOption 可购时长档位(图片 / 文字广告分别计价)
|
||||
|
||||
@@ -49,11 +49,11 @@ const (
|
||||
|
||||
// AdminContentCounts 当前操作者可见范围内的数量(用于页内 Tab)
|
||||
type AdminContentCounts struct {
|
||||
PostsLive int64 `json:"posts_live"`
|
||||
PostsPending int64 `json:"posts_pending"`
|
||||
PostsRejected int64 `json:"posts_rejected"`
|
||||
PostsDeleted int64 `json:"posts_deleted"`
|
||||
CommentsLive int64 `json:"comments_live"`
|
||||
PostsLive int64 `json:"posts_live"`
|
||||
PostsPending int64 `json:"posts_pending"`
|
||||
PostsRejected int64 `json:"posts_rejected"`
|
||||
PostsDeleted int64 `json:"posts_deleted"`
|
||||
CommentsLive int64 `json:"comments_live"`
|
||||
CommentsPending int64 `json:"comments_pending"`
|
||||
CommentsRejected int64 `json:"comments_rejected"`
|
||||
CommentsDeleted int64 `json:"comments_deleted"`
|
||||
@@ -61,32 +61,34 @@ type AdminContentCounts struct {
|
||||
|
||||
// AdminContentPost 后台帖子列表项
|
||||
type AdminContentPost struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
Deleted bool `json:"deleted"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CommentCount int `json:"comment_count"`
|
||||
BoardID uint `json:"board_id"`
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
Deleted bool `json:"deleted"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CommentCount int `json:"comment_count"`
|
||||
BoardID uint `json:"board_id"`
|
||||
Board model.Board `json:"board"`
|
||||
User model.User `json:"user"`
|
||||
User model.User `json:"user"`
|
||||
}
|
||||
|
||||
// AdminContentComment 后台评论列表项
|
||||
type AdminContentComment struct {
|
||||
ID uint `json:"id"`
|
||||
PostID uint `json:"post_id"`
|
||||
PostTitle string `json:"post_title"`
|
||||
BoardID uint `json:"board_id"`
|
||||
Content string `json:"content"`
|
||||
Status string `json:"status"`
|
||||
Edited bool `json:"edited"` // 相对创建已编辑(同评论侧规则),用于隐藏无修订的历史入口
|
||||
Deleted bool `json:"deleted"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID uint `json:"id"`
|
||||
PostID uint `json:"post_id"`
|
||||
PostTitle string `json:"post_title"`
|
||||
BoardID uint `json:"board_id"`
|
||||
Content string `json:"content"`
|
||||
Status string `json:"status"`
|
||||
Edited bool `json:"edited"` // 相对创建已编辑(同评论侧规则),用于隐藏无修订的历史入口
|
||||
Deleted bool `json:"deleted"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Floor uint `json:"floor"` // 所属楼层相对序号
|
||||
IsRoot bool `json:"is_root"` // 主楼/楼中楼,供前台拼 #comment-{floor}[-r{id}]
|
||||
Board model.Board `json:"board"`
|
||||
User model.User `json:"user"`
|
||||
User model.User `json:"user"`
|
||||
}
|
||||
|
||||
func normalizeAdminStatus(raw string) AdminContentStatus {
|
||||
@@ -270,6 +272,11 @@ func (s *ModerationService) ListAdminComments(actor *Actor, status, keyword stri
|
||||
}
|
||||
|
||||
items := make([]AdminContentComment, 0, len(rows))
|
||||
commentIDs := make([]uint, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
commentIDs = append(commentIDs, r.ID)
|
||||
}
|
||||
anchors := CommentAnchors(s.db, commentIDs)
|
||||
for _, r := range rows {
|
||||
it := AdminContentComment{
|
||||
ID: r.ID,
|
||||
@@ -283,6 +290,10 @@ func (s *ModerationService) ListAdminComments(actor *Actor, status, keyword stri
|
||||
Board: boards[r.BoardID],
|
||||
User: users[r.UserID],
|
||||
}
|
||||
if a, ok := anchors[r.ID]; ok {
|
||||
it.Floor = a.Floor
|
||||
it.IsRoot = a.IsRoot
|
||||
}
|
||||
if r.DeletedAt.Valid {
|
||||
it.Deleted = true
|
||||
t := r.DeletedAt.Time
|
||||
|
||||
@@ -54,6 +54,7 @@ type AdminUserItem struct {
|
||||
BoardIDs []uint `json:"board_ids"`
|
||||
Banned bool `json:"banned"`
|
||||
CanManageMessages bool `json:"can_manage_messages"`
|
||||
PermOverrides []string `json:"perm_overrides"` // 账号级渠道权限(角色固有之外的增量授予)
|
||||
PostCount int64 `json:"post_count"`
|
||||
CommentCount int64 `json:"comment_count"`
|
||||
Points int `json:"points"`
|
||||
@@ -71,7 +72,7 @@ type AdminUserItem struct {
|
||||
// - total / admins / banned:筛选 Tab 角标(不在顶部卡片展示,避免与仪表盘重复)
|
||||
type AdminUserSummary struct {
|
||||
Online int64 `json:"online"`
|
||||
InCooldown int64 `json:"in_cooldown"`
|
||||
InCooldown int64 `json:"in_cooldown"`
|
||||
Active7d int64 `json:"active_7d"`
|
||||
FailedLogins24h int64 `json:"failed_logins_24h"`
|
||||
Total int64 `json:"total"`
|
||||
@@ -237,6 +238,7 @@ func (s *AdminUserService) toItems(users []model.User) []AdminUserItem {
|
||||
BoardIDs: []uint{},
|
||||
Banned: u.Banned,
|
||||
CanManageMessages: u.CanManageMessages,
|
||||
PermOverrides: ParsePermOverrides(u.PermOverrides),
|
||||
Points: u.Points,
|
||||
TotalPoints: u.TotalPoints,
|
||||
Level: u.Level,
|
||||
@@ -407,6 +409,12 @@ func (s *AdminUserService) SetStaff(operator *Actor, targetID uint, role model.R
|
||||
return err
|
||||
}
|
||||
}
|
||||
// 升至管理员及以上后渠道权限变为角色自带,账号级授予清空以免误导
|
||||
if roleChanged && model.RoleLevel(role) >= model.RoleLevel(model.RoleAdmin) && u.PermOverrides != "" && u.PermOverrides != "[]" {
|
||||
if err := tx.Model(&u).Update("perm_overrides", "[]").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
@@ -415,6 +423,53 @@ func (s *AdminUserService) SetStaff(operator *Actor, targetID uint, role model.R
|
||||
return s.getItem(s.db, targetID)
|
||||
}
|
||||
|
||||
// GetPermOverrides 读取目标账号的渠道权限(PermUsers 持有者可读,供授权弹窗回显)
|
||||
func (s *AdminUserService) GetPermOverrides(operator *Actor, targetID uint) ([]string, error) {
|
||||
if operator == nil || !operator.HasPerm(PermUsers) {
|
||||
return nil, ErrCannotAssignRole
|
||||
}
|
||||
var u model.User
|
||||
if err := s.db.Select("id", "perm_overrides").First(&u, targetID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParsePermOverrides(u.PermOverrides), nil
|
||||
}
|
||||
|
||||
// SetPermOverrides 授予/撤销账号级渠道权限(公告/单页/书库/广告)。
|
||||
// 授予者需持有 PermUsers(超管/站长);目标为管理员及以上时渠道为角色自带,
|
||||
// 授权无意义,强制清空。Actor 每次请求现查 DB,变更即时生效,无需强制下线。
|
||||
func (s *AdminUserService) SetPermOverrides(operator *Actor, targetID uint, perms []string) (*AdminUserItem, error) {
|
||||
if operator == nil || !operator.HasPerm(PermUsers) {
|
||||
return nil, ErrCannotAssignRole
|
||||
}
|
||||
if operator.ID == targetID {
|
||||
return nil, ErrAdminSelfAction
|
||||
}
|
||||
norm := NormalizePermOverrides(perms)
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var u model.User
|
||||
if err := tx.First(&u, targetID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if u.Role == model.RoleOwner {
|
||||
return ErrProtectedOwner
|
||||
}
|
||||
// 管理员及以上角色自带全部渠道权限,账号级授予保持为空
|
||||
if model.RoleLevel(u.Role) >= model.RoleLevel(model.RoleAdmin) {
|
||||
norm = []string{}
|
||||
}
|
||||
raw := SerializePermOverrides(norm)
|
||||
if u.PermOverrides == raw {
|
||||
return nil
|
||||
}
|
||||
return tx.Model(&u).Update("perm_overrides", raw).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.getItem(s.db, targetID)
|
||||
}
|
||||
|
||||
// SetCanManageMessages 站长授予/撤销站点消息管理权限(不可操作站长账号与自己)
|
||||
func (s *AdminUserService) SetCanManageMessages(operator *Actor, targetID uint, enabled bool) (*AdminUserItem, error) {
|
||||
if operator == nil || operator.Role != model.RoleOwner {
|
||||
@@ -564,12 +619,12 @@ func normalizeAuditPage(page, size int) (int, int) {
|
||||
|
||||
// AdminUserAuditProfile 站长查看用户档案摘要
|
||||
type AdminUserAuditProfile struct {
|
||||
User AdminUserItem `json:"user"`
|
||||
PostsTotal int64 `json:"posts_total"` // 含各状态与软删
|
||||
CommentsTotal int64 `json:"comments_total"` // 含各状态与软删
|
||||
MessagesTotal int64 `json:"messages_total"` // 含撤回与软删
|
||||
PublishedPosts int64 `json:"published_posts"`
|
||||
PublishedComments int64 `json:"published_comments"`
|
||||
User AdminUserItem `json:"user"`
|
||||
PostsTotal int64 `json:"posts_total"` // 含各状态与软删
|
||||
CommentsTotal int64 `json:"comments_total"` // 含各状态与软删
|
||||
MessagesTotal int64 `json:"messages_total"` // 含撤回与软删
|
||||
PublishedPosts int64 `json:"published_posts"`
|
||||
PublishedComments int64 `json:"published_comments"`
|
||||
}
|
||||
|
||||
// AdminAuditPostItem 审计帖子行
|
||||
@@ -594,6 +649,8 @@ type AdminAuditCommentItem struct {
|
||||
Deleted bool `json:"deleted"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
Floor uint `json:"floor"`
|
||||
IsRoot bool `json:"is_root"`
|
||||
}
|
||||
|
||||
// AdminAuditMessageItem 审计聊天消息行
|
||||
@@ -686,6 +743,11 @@ func (s *AdminUserService) ListAuditComments(actor *Actor, userID uint, page, si
|
||||
Offset((page - 1) * size).Limit(size).Find(&comments).Error; err != nil {
|
||||
return nil, 0, page, err
|
||||
}
|
||||
commentIDs := make([]uint, 0, len(comments))
|
||||
for _, c := range comments {
|
||||
commentIDs = append(commentIDs, c.ID)
|
||||
}
|
||||
anchors := CommentAnchors(s.db, commentIDs)
|
||||
items := make([]AdminAuditCommentItem, 0, len(comments))
|
||||
for _, c := range comments {
|
||||
it := AdminAuditCommentItem{
|
||||
@@ -695,6 +757,10 @@ func (s *AdminUserService) ListAuditComments(actor *Actor, userID uint, page, si
|
||||
Status: c.Status,
|
||||
CreatedAt: c.CreatedAt,
|
||||
}
|
||||
if a, ok := anchors[c.ID]; ok {
|
||||
it.Floor = a.Floor
|
||||
it.IsRoot = a.IsRoot
|
||||
}
|
||||
if c.DeletedAt.Valid {
|
||||
it.Deleted = true
|
||||
t := c.DeletedAt.Time
|
||||
|
||||
@@ -75,8 +75,8 @@ type RoomView struct {
|
||||
Joined bool `json:"joined"`
|
||||
MyRole string `json:"my_role"`
|
||||
UnreadCount int64 `json:"unread_count"`
|
||||
Pinned bool `json:"pinned"` // 对当前用户是否置顶(含大厅强制)
|
||||
PinForced bool `json:"pin_forced"` // 大厅强制置顶,不可取消
|
||||
Pinned bool `json:"pinned"` // 对当前用户是否置顶(含大厅强制)
|
||||
PinForced bool `json:"pin_forced"` // 大厅强制置顶,不可取消
|
||||
Peer *model.User `json:"peer,omitempty"` // 私聊对方(仅 direct)
|
||||
}
|
||||
|
||||
@@ -114,6 +114,15 @@ func (s *ChatService) membership(tx *gorm.DB, roomID, userID uint) (*model.ChatR
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 过期禁言自动解除:惰性清理,不依赖定时任务
|
||||
if m.Muted && m.MutedUntil != nil && m.MutedUntil.Before(time.Now()) {
|
||||
m.Muted = false
|
||||
m.MutedUntil = nil
|
||||
_ = tx.Model(&model.ChatRoomMember{}).
|
||||
Where("room_id = ? AND user_id = ?", roomID, userID).
|
||||
Select("muted", "muted_until").
|
||||
Updates(map[string]interface{}{"muted": false, "muted_until": nil}).Error
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
@@ -758,44 +767,85 @@ func (s *ChatService) Kick(operatorID, roomID, targetID uint, oversee bool) erro
|
||||
return s.removeMember(roomID, targetID)
|
||||
}
|
||||
|
||||
// SetMemberMute 禁言/解禁:群主、群管、全站监管;不可禁言群主或站点站长账号
|
||||
func (s *ChatService) SetMemberMute(operatorID, roomID, targetID uint, muted, oversee bool) error {
|
||||
// SendSystemMessage 向房间落库一条系统提示消息(sender_id=0,is_system=true),
|
||||
// 并推进房间 last_message_id 与 updated_at。失败返回 nil + error。
|
||||
func (s *ChatService) SendSystemMessage(roomID uint, content string) (*model.ChatMessage, error) {
|
||||
msg := &model.ChatMessage{
|
||||
RoomID: roomID,
|
||||
SenderID: 0,
|
||||
Content: content,
|
||||
IsSystem: true,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(msg).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.ChatRoom{}).Where("id = ?", roomID).
|
||||
Updates(map[string]interface{}{
|
||||
"last_message_id": msg.ID,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// SetMemberMute 禁言/解禁:群主、群管、全站监管;不可禁言群主或站点站长账号。
|
||||
// duration 为禁言时长;0 表示永久禁言;muted=false 时忽略 duration 并清空 muted_until。
|
||||
// 返回目标用户昵称(供广播系统提示使用)。
|
||||
func (s *ChatService) SetMemberMute(operatorID, roomID, targetID uint, muted bool, duration time.Duration, oversee bool) (string, error) {
|
||||
room, err := s.getRoom(roomID)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
if room.RoomType == model.ChatRoomTypeDirect {
|
||||
return ErrChatOwnerOnly
|
||||
return "", ErrChatOwnerOnly
|
||||
}
|
||||
target, err := s.membership(s.db, roomID, targetID)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
if target.Role == model.ChatRoleOwner {
|
||||
return ErrChatCannotMuteOwner
|
||||
return "", ErrChatCannotMuteOwner
|
||||
}
|
||||
var targetUser model.User
|
||||
if err := s.db.Select("role").First(&targetUser, targetID).Error; err != nil {
|
||||
return ErrChatUserGone
|
||||
if err := s.db.Select("role", "nickname", "username").First(&targetUser, targetID).Error; err != nil {
|
||||
return "", ErrChatUserGone
|
||||
}
|
||||
if targetUser.Role == model.RoleOwner {
|
||||
return ErrChatCannotMuteOwner
|
||||
return "", ErrChatCannotMuteOwner
|
||||
}
|
||||
if !oversee {
|
||||
op, err := s.membership(s.db, roomID, operatorID)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
if op.Role != model.ChatRoleOwner && op.Role != model.ChatRoleAdmin {
|
||||
return ErrChatMuteDenied
|
||||
return "", ErrChatMuteDenied
|
||||
}
|
||||
if target.Role == model.ChatRoleAdmin && op.Role != model.ChatRoleOwner {
|
||||
return ErrChatMuteDenied
|
||||
return "", ErrChatMuteDenied
|
||||
}
|
||||
}
|
||||
return s.db.Model(&model.ChatRoomMember{}).
|
||||
var mutedUntil *time.Time
|
||||
if muted && duration > 0 {
|
||||
t := time.Now().Add(duration)
|
||||
mutedUntil = &t
|
||||
}
|
||||
if err := s.db.Model(&model.ChatRoomMember{}).
|
||||
Where("room_id = ? AND user_id = ?", roomID, targetID).
|
||||
Select("muted").Update("muted", muted).Error
|
||||
Select("muted", "muted_until").
|
||||
Updates(map[string]interface{}{"muted": muted, "muted_until": mutedUntil}).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
targetName := targetUser.Nickname
|
||||
if targetName == "" {
|
||||
targetName = targetUser.Username
|
||||
}
|
||||
return targetName, nil
|
||||
}
|
||||
|
||||
// SetMemberRole 仅站点站长可任命/撤销群管理员(admin <-> member);不可改群主
|
||||
|
||||
@@ -44,7 +44,7 @@ func IsNecroReply(lastReplyAt time.Time, afterHours int, now time.Time) bool {
|
||||
|
||||
// CommentService 评论服务
|
||||
type CommentService struct {
|
||||
db *gorm.DB
|
||||
db *gorm.DB
|
||||
setting *SettingService
|
||||
}
|
||||
|
||||
@@ -297,6 +297,80 @@ func (s *CommentService) FloorNumber(postID, commentID uint) int {
|
||||
return FloorNumber(s.db, postID, commentID)
|
||||
}
|
||||
|
||||
// CommentAnchor 评论在所属帖子内的相对锚点:
|
||||
// Floor=楼层号(含软删/待审占位,口径同 FloorNumber);IsRoot=true 为主楼,
|
||||
// false 为楼中楼(锚点需附带评论 ID 才能精确定位具体回复)。
|
||||
type CommentAnchor struct {
|
||||
Floor uint `json:"floor"`
|
||||
IsRoot bool `json:"is_root"`
|
||||
}
|
||||
|
||||
// CommentAnchors 批量查询评论锚点(两条 SQL,避免逐行 N+1);
|
||||
// 评论或其主楼已不存在(如整串被彻底删除)时不出现在结果中。
|
||||
func CommentAnchors(db *gorm.DB, ids []uint) map[uint]CommentAnchor {
|
||||
out := make(map[uint]CommentAnchor, len(ids))
|
||||
if db == nil || len(ids) == 0 {
|
||||
return out
|
||||
}
|
||||
// Unscoped:软删/待审评论仍占楼层,与 FloorNumber 同口径
|
||||
var targets []model.Comment
|
||||
if err := db.Unscoped().
|
||||
Select("id", "post_id", "parent_id", "root_id", "created_at").
|
||||
Where("id IN ?", ids).Find(&targets).Error; err != nil {
|
||||
return out
|
||||
}
|
||||
rootIDSet := make(map[uint]struct{}, len(targets))
|
||||
for _, c := range targets {
|
||||
rid := c.ID
|
||||
if c.ParentID != nil && c.RootID != nil {
|
||||
rid = *c.RootID
|
||||
}
|
||||
rootIDSet[rid] = struct{}{}
|
||||
}
|
||||
rootIDs := make([]uint, 0, len(rootIDSet))
|
||||
for rid := range rootIDSet {
|
||||
rootIDs = append(rootIDs, rid)
|
||||
}
|
||||
// 每个主楼之前(按 created_at/id 升序)的主楼数即楼层号 - 1
|
||||
type aheadRow struct {
|
||||
RootID uint
|
||||
Ahead int64
|
||||
}
|
||||
var aheadRows []aheadRow
|
||||
if err := db.Unscoped().Table("comments AS f").
|
||||
Select(`f.id AS root_id,
|
||||
(SELECT COUNT(*) FROM comments c
|
||||
WHERE c.post_id = f.post_id AND c.parent_id IS NULL
|
||||
AND (c.created_at < f.created_at
|
||||
OR (c.created_at = f.created_at AND c.id < f.id))) AS ahead`).
|
||||
Where("f.id IN ? AND f.parent_id IS NULL", rootIDs).
|
||||
Scan(&aheadRows).Error; err != nil {
|
||||
return out
|
||||
}
|
||||
floorByRoot := make(map[uint]uint, len(aheadRows))
|
||||
for _, r := range aheadRows {
|
||||
floorByRoot[r.RootID] = uint(r.Ahead) + 1
|
||||
}
|
||||
for _, c := range targets {
|
||||
isRoot := c.ParentID == nil
|
||||
rid := c.ID
|
||||
if !isRoot && c.RootID != nil {
|
||||
rid = *c.RootID
|
||||
}
|
||||
floor := floorByRoot[rid]
|
||||
if floor == 0 {
|
||||
continue
|
||||
}
|
||||
out[c.ID] = CommentAnchor{Floor: floor, IsRoot: isRoot}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// AnchorsByCommentIDs 见包级 CommentAnchors
|
||||
func (s *CommentService) AnchorsByCommentIDs(ids []uint) map[uint]CommentAnchor {
|
||||
return CommentAnchors(s.db, ids)
|
||||
}
|
||||
|
||||
// applyCommentListVisibility 评论流可见性:published,或 pending 且(作者 / 该板可审);
|
||||
// 软删行:已发布的对所有人占位;待审软删仅作者/可审者可见占位。
|
||||
func applyCommentListVisibility(db *gorm.DB, boardID, viewerID uint, actor *Actor) *gorm.DB {
|
||||
@@ -442,6 +516,8 @@ type UserCommentItem struct {
|
||||
PostID uint `json:"post_id"`
|
||||
PostTitle string `json:"post_title"`
|
||||
Content string `json:"content"`
|
||||
Floor uint `json:"floor"` // 所属楼层相对序号(主楼为自身楼层)
|
||||
IsRoot bool `json:"is_root"` // 主楼/楼中楼,供前端拼 #comment-{floor}[-r{id}]
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -470,6 +546,17 @@ func (s *CommentService) ListByUser(userID uint, page, size int) ([]UserCommentI
|
||||
if items == nil {
|
||||
items = []UserCommentItem{}
|
||||
}
|
||||
ids := make([]uint, 0, len(items))
|
||||
for _, it := range items {
|
||||
ids = append(ids, it.ID)
|
||||
}
|
||||
anchors := s.AnchorsByCommentIDs(ids)
|
||||
for i := range items {
|
||||
if a, ok := anchors[items[i].ID]; ok {
|
||||
items[i].Floor = a.Floor
|
||||
items[i].IsRoot = a.IsRoot
|
||||
}
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
// FollowService 关注服务(仅用户;关注时给对方发通知)
|
||||
type FollowService struct {
|
||||
db *gorm.DB
|
||||
db *gorm.DB
|
||||
notif *NotificationService
|
||||
}
|
||||
|
||||
|
||||
@@ -268,8 +268,8 @@ func (s *FriendLinkService) AdminSetStatus(id uint, status, rejectReason string)
|
||||
return err
|
||||
}
|
||||
updates := map[string]interface{}{
|
||||
"status": status,
|
||||
"updated_at": time.Now(),
|
||||
"status": status,
|
||||
"updated_at": time.Now(),
|
||||
"reject_reason": "",
|
||||
}
|
||||
switch status {
|
||||
|
||||
@@ -16,8 +16,8 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
hidePwdCookiePrefix = "j13_hp_"
|
||||
hidePwdCookieMaxAge = 30 * 24 * 3600 // 30 天
|
||||
hidePwdCookiePrefix = "j13_hp_"
|
||||
hidePwdCookieMaxAge = 30 * 24 * 3600 // 30 天
|
||||
hidePwdCookieVersion = "1"
|
||||
)
|
||||
|
||||
|
||||
276
backend/service/image_variants.go
Normal file
276
backend/service/image_variants.go
Normal file
@@ -0,0 +1,276 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
webpenc "github.com/gen2brain/webp"
|
||||
"golang.org/x/image/draw"
|
||||
xwebp "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
// 公开图片变体:GET /api/img?u=<源图URL>&w=<目标宽>
|
||||
// 按宽度白名单实时缩放为 WebP 并持久缓存到 .thumbs/v/,供全站 <img srcset> 消费。
|
||||
// 源内容不可变(上传文件名为随机哈希),变体 URL 可被浏览器/CDN 长期 immutable 缓存。
|
||||
// GIF/SVG/ICO 与「源宽 ≤ 请求宽」一律原样透传:保动效、保矢量、不放大。
|
||||
// 本地源按 mtime 失效;远程对象(/api/media/)ID 即内容,缓存永久有效。
|
||||
|
||||
// VariantWebPQuality 变体 WebP 有损质量(与 MediaThumb 一致)
|
||||
const VariantWebPQuality = 78
|
||||
|
||||
// variantWidths 宽度白名单(与前端 lib/responsiveImage.ts 同值,约 1.5x 步进)
|
||||
var variantWidths = []int{48, 96, 160, 256, 384, 512, 768, 1080, 1536, 1920}
|
||||
|
||||
// IsVariantWidth 是否白名单精确值(非法尺寸 400,防止被刷任意尺寸)
|
||||
func IsVariantWidth(w int) bool {
|
||||
for _, v := range variantWidths {
|
||||
if v == w {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// variantGenLocks 并发生成去重:key=sha1(url).w → 每变体一把锁
|
||||
var variantGenLocks sync.Map
|
||||
|
||||
// extMIME 扩展名 → MIME(含 SVG/ICO 透传类型)
|
||||
func extMIME(ext string) string {
|
||||
switch ext {
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".webp":
|
||||
return "image/webp"
|
||||
case ".gif":
|
||||
return "image/gif"
|
||||
case ".svg":
|
||||
return "image/svg+xml"
|
||||
case ".ico":
|
||||
return "image/x-icon"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// variantCacheFile 变体缓存路径:.thumbs/v/<sha1(url)>.w<w>.webp
|
||||
func (s *UploadService) variantCacheFile(url string, w int) string {
|
||||
sum := sha1.Sum([]byte(url))
|
||||
name := hex.EncodeToString(sum[:]) + ".w" + strconv.Itoa(w) + ".webp"
|
||||
return filepath.Join(s.dir, ".thumbs", "v", name)
|
||||
}
|
||||
|
||||
// variantCacheFresh 缓存是否新鲜:本地源按 mtime;远程对象(modTime 零值)只看存在
|
||||
func variantCacheFresh(cache string, modTime time.Time) bool {
|
||||
ti, err := os.Stat(cache)
|
||||
if err != nil || ti.IsDir() {
|
||||
return false
|
||||
}
|
||||
return modTime.IsZero() || !ti.ModTime().Before(modTime)
|
||||
}
|
||||
|
||||
// Variant 返回源图指定宽度的变体字节与 MIME。
|
||||
func (s *UploadService) Variant(rawURL string, width int) ([]byte, string, error) {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if len(rawURL) == 0 || len(rawURL) > 512 {
|
||||
return nil, "", errors.New("无效地址")
|
||||
}
|
||||
if !IsVariantWidth(width) {
|
||||
return nil, "", errors.New("不支持的尺寸")
|
||||
}
|
||||
|
||||
var (
|
||||
srcData []byte // 远程源预读字节(本地源在锁内读取)
|
||||
srcMIME string
|
||||
srcPath string // 本地源绝对路径
|
||||
modTime time.Time
|
||||
remote bool
|
||||
cache = s.variantCacheFile(rawURL, width)
|
||||
)
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(rawURL, "/uploads/"):
|
||||
rel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(rawURL, "/uploads/")))
|
||||
if filepath.IsAbs(rel) || rel == "." || strings.HasPrefix(rel, "..") {
|
||||
return nil, "", errors.New("无效地址")
|
||||
}
|
||||
dir := filepath.Dir(rel)
|
||||
catOK := false
|
||||
for _, cat := range mediaLibraryCategories {
|
||||
if cat.Dir == dir {
|
||||
catOK = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !catOK {
|
||||
return nil, "", errors.New("无效地址")
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(rel))
|
||||
mime := extMIME(ext)
|
||||
if mime == "" {
|
||||
return nil, "", errors.New("非图片")
|
||||
}
|
||||
srcPath = filepath.Join(s.dir, rel)
|
||||
info, err := os.Stat(srcPath)
|
||||
if err != nil || info.IsDir() {
|
||||
return nil, "", errors.New("文件不存在")
|
||||
}
|
||||
// 动图/矢量/图标:无法或无需光栅缩放,原样透传(不占变体缓存)
|
||||
if ext == ".gif" || ext == ".svg" || ext == ".ico" {
|
||||
data, err := os.ReadFile(srcPath)
|
||||
return data, mime, err
|
||||
}
|
||||
srcMIME = mime
|
||||
modTime = info.ModTime()
|
||||
|
||||
case strings.HasPrefix(rawURL, "/api/media/"):
|
||||
if s.ops == nil {
|
||||
return nil, "", errors.New("无效地址")
|
||||
}
|
||||
id := strings.TrimPrefix(rawURL, "/api/media/")
|
||||
if id == "" || strings.ContainsAny(id, "/?#") || len(id) > 128 {
|
||||
return nil, "", errors.New("无效地址")
|
||||
}
|
||||
remote = true
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
r, mime, e := s.ops.OpenObject(ctx, id, true)
|
||||
if e != nil {
|
||||
return nil, "", errors.New("文件暂不可用")
|
||||
}
|
||||
defer r.Close()
|
||||
switch mime {
|
||||
case "image/jpeg", "image/png", "image/webp":
|
||||
case "image/gif", "image/svg+xml", "image/x-icon", "image/vnd.microsoft.icon":
|
||||
data, err := io.ReadAll(r)
|
||||
return data, mime, err
|
||||
default:
|
||||
return nil, "", errors.New("非图片")
|
||||
}
|
||||
srcData, e = io.ReadAll(r)
|
||||
if e != nil {
|
||||
return nil, "", e
|
||||
}
|
||||
srcMIME = mime
|
||||
|
||||
default:
|
||||
return nil, "", errors.New("无效地址")
|
||||
}
|
||||
|
||||
// 快速路径:缓存已新鲜直接返回(无锁)
|
||||
if variantCacheFresh(cache, modTime) {
|
||||
if data, err := os.ReadFile(cache); err == nil {
|
||||
return data, "image/webp", nil
|
||||
}
|
||||
}
|
||||
|
||||
// 并发生成去重:同 url+w 只生成一次
|
||||
sum := sha1.Sum([]byte(rawURL))
|
||||
key := hex.EncodeToString(sum[:]) + ".w" + strconv.Itoa(width)
|
||||
mu, _ := variantGenLocks.LoadOrStore(key, &sync.Mutex{})
|
||||
lk := mu.(*sync.Mutex)
|
||||
lk.Lock()
|
||||
defer lk.Unlock()
|
||||
|
||||
// double-check:等锁期间可能已由他人生成
|
||||
if variantCacheFresh(cache, modTime) {
|
||||
if data, err := os.ReadFile(cache); err == nil {
|
||||
return data, "image/webp", nil
|
||||
}
|
||||
}
|
||||
|
||||
if !remote {
|
||||
data, err := os.ReadFile(srcPath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
srcData = data
|
||||
}
|
||||
|
||||
out, m, err := variantFromBytes(srcData, srcMIME, width)
|
||||
if err != nil {
|
||||
return nil, "", err // 如动图 WebP,调用方回退原图
|
||||
}
|
||||
// 仅 WebP 变体积缓存(小图透传不落盘);失败仅影响下次重复生成,不阻断响应
|
||||
if m == "image/webp" {
|
||||
_ = atomicWriteFile(cache, out)
|
||||
}
|
||||
return out, m, nil
|
||||
}
|
||||
|
||||
// variantFromBytes 源字节 → 目标变体:解码失败报错(调用方回退原图);
|
||||
// 源宽 ≤ 目标宽 → 原字节透传(不放大);否则按目标宽等比缩放为 WebP。
|
||||
func variantFromBytes(srcData []byte, srcMIME string, width int) ([]byte, string, error) {
|
||||
img, err := decodeImageBytes(srcData, srcMIME == "image/webp")
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
b := img.Bounds()
|
||||
if b.Dx() <= width {
|
||||
return srcData, srcMIME, nil
|
||||
}
|
||||
nh := max(1, b.Dy()*width/b.Dx())
|
||||
dst := scaleImage(img, width, nh)
|
||||
out, err := encodeLossyWebP(dst, VariantWebPQuality)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return out, "image/webp", nil
|
||||
}
|
||||
|
||||
// ---------- 与 MediaThumb 共用的图片处理核心 ----------
|
||||
|
||||
// decodeImageBytes 解码图片字节;WebP 走 x/image/webp 解码器
|
||||
func decodeImageBytes(data []byte, isWebP bool) (image.Image, error) {
|
||||
if isWebP {
|
||||
return xwebp.Decode(bytes.NewReader(data))
|
||||
}
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
return img, err
|
||||
}
|
||||
|
||||
// scaleImage CatmullRom 高质量缩放到指定宽高
|
||||
func scaleImage(img image.Image, nw, nh int) *image.RGBA {
|
||||
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
||||
draw.CatmullRom.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Src, nil)
|
||||
return dst
|
||||
}
|
||||
|
||||
// encodeLossyWebP 有损 WebP 编码
|
||||
func encodeLossyWebP(img image.Image, quality int) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := webpenc.Encode(&buf, img, webpenc.Options{Quality: quality}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// atomicWriteFile 临时文件 + rename 原子落盘
|
||||
func atomicWriteFile(path string, data []byte) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := path + ".partial"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
282
backend/service/image_variants_test.go
Normal file
282
backend/service/image_variants_test.go
Normal file
@@ -0,0 +1,282 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/gif"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/config"
|
||||
webpenc "github.com/gen2brain/webp"
|
||||
xwebp "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
// solidImg 生成渐变纯色测试图
|
||||
func solidImg(w, h int) *image.RGBA {
|
||||
src := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
src.Set(x, y, color.RGBA{uint8(x % 256), uint8(y % 256), 128, 255})
|
||||
}
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
// writeVariantSource 把按编码器生成的文件写到 uploads 目录
|
||||
func writeVariantSource(t *testing.T, dir, url string, encode func(io.Writer) error) {
|
||||
t.Helper()
|
||||
abs := filepath.Join(dir, filepath.FromSlash(strings.TrimPrefix(url, "/uploads/")))
|
||||
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
f, err := os.Create(abs)
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
if err := encode(f); err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func encodeJPEG(img image.Image) func(io.Writer) error {
|
||||
return func(w io.Writer) error {
|
||||
return jpeg.Encode(w, img, &jpeg.Options{Quality: 85})
|
||||
}
|
||||
}
|
||||
|
||||
func encodePNG(img image.Image) func(io.Writer) error {
|
||||
return func(w io.Writer) error {
|
||||
return png.Encode(w, img)
|
||||
}
|
||||
}
|
||||
|
||||
func encodeWebP(img image.Image) func(io.Writer) error {
|
||||
return func(w io.Writer) error {
|
||||
return webpenc.Encode(w, img, webpenc.Options{Quality: 85})
|
||||
}
|
||||
}
|
||||
|
||||
func encodeGIF(img image.Image) func(io.Writer) error {
|
||||
return func(w io.Writer) error {
|
||||
return gif.Encode(w, img, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// decodeVariant 解码返回的 WebP 变体
|
||||
func decodeVariant(t *testing.T, data []byte) image.Image {
|
||||
t.Helper()
|
||||
img, err := xwebp.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("decode variant webp: %v", err)
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func TestIsVariantWidth(t *testing.T) {
|
||||
if !IsVariantWidth(48) || !IsVariantWidth(1920) {
|
||||
t.Fatal("whitelist endpoints should be valid")
|
||||
}
|
||||
if IsVariantWidth(100) || IsVariantWidth(0) || IsVariantWidth(-48) {
|
||||
t.Fatal("non-whitelist width should be invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantRejectsInvalidInput(t *testing.T) {
|
||||
s, _ := newUploadTestService(t)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
w int
|
||||
}{
|
||||
{"非法尺寸", "/uploads/images/a.webp", 100},
|
||||
{"路径穿越", "/uploads/images/../../etc/passwd", 96},
|
||||
{"非白名单目录", "/uploads/secret/a.webp", 96},
|
||||
{"非图片扩展名", "/uploads/images/a.txt", 96},
|
||||
{"外部地址", "https://evil.com/a.webp", 96},
|
||||
{"空地址", "", 96},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, _, err := s.Variant(tc.url, tc.w); err == nil {
|
||||
t.Fatal("want error, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantMissingFile(t *testing.T) {
|
||||
s, _ := newUploadTestService(t)
|
||||
if _, _, err := s.Variant("/uploads/images/missing.webp", 96); err == nil {
|
||||
t.Fatal("missing source should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantPassthroughSmallImage(t *testing.T) {
|
||||
s, dir := newUploadTestService(t)
|
||||
url := "/uploads/images/small.webp"
|
||||
writeVariantSource(t, dir, url, encodeWebP(solidImg(48, 48)))
|
||||
|
||||
data, m, err := s.Variant(url, 96)
|
||||
if err != nil {
|
||||
t.Fatalf("variant: %v", err)
|
||||
}
|
||||
if m != "image/webp" {
|
||||
t.Fatalf("want webp mime, got %s", m)
|
||||
}
|
||||
src, _ := os.ReadFile(filepath.Join(dir, filepath.FromSlash("images/small.webp")))
|
||||
if !bytes.Equal(data, src) {
|
||||
t.Fatal("small image should be returned byte-for-byte")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantResizesByWidthLandscape(t *testing.T) {
|
||||
s, dir := newUploadTestService(t)
|
||||
url := "/uploads/images/wide.jpg"
|
||||
writeVariantSource(t, dir, url, encodeJPEG(solidImg(1000, 500)))
|
||||
|
||||
data, m, err := s.Variant(url, 384)
|
||||
if err != nil {
|
||||
t.Fatalf("variant: %v", err)
|
||||
}
|
||||
if m != "image/webp" {
|
||||
t.Fatalf("want webp, got %s", m)
|
||||
}
|
||||
b := decodeVariant(t, data).Bounds()
|
||||
if b.Dx() != 384 || b.Dy() != 192 {
|
||||
t.Fatalf("want 384x192, got %dx%d", b.Dx(), b.Dy())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantResizesByWidthPortrait(t *testing.T) {
|
||||
s, dir := newUploadTestService(t)
|
||||
url := "/uploads/images/tall.png"
|
||||
writeVariantSource(t, dir, url, encodePNG(solidImg(500, 1000)))
|
||||
|
||||
data, _, err := s.Variant(url, 256)
|
||||
if err != nil {
|
||||
t.Fatalf("variant: %v", err)
|
||||
}
|
||||
b := decodeVariant(t, data).Bounds()
|
||||
if b.Dx() != 256 || b.Dy() != 512 {
|
||||
t.Fatalf("want 256x512, got %dx%d", b.Dx(), b.Dy())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantPassthroughGIFSVGICO(t *testing.T) {
|
||||
s, dir := newUploadTestService(t)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
url string
|
||||
mime string
|
||||
body []byte
|
||||
}{
|
||||
{"gif", "/uploads/images/a.gif", "image/gif", nil},
|
||||
{"svg", "/uploads/images/a.svg", "image/svg+xml", []byte("<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>")},
|
||||
{"ico", "/uploads/images/a.ico", "image/x-icon", []byte("\x00\x00\x01\x00")},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.body != nil {
|
||||
writeVariantSource(t, dir, tc.url, func(w io.Writer) error {
|
||||
_, err := w.Write(tc.body)
|
||||
return err
|
||||
})
|
||||
} else {
|
||||
writeVariantSource(t, dir, tc.url, encodeGIF(solidImg(16, 16)))
|
||||
}
|
||||
data, m, err := s.Variant(tc.url, 96)
|
||||
if err != nil {
|
||||
t.Fatalf("variant: %v", err)
|
||||
}
|
||||
if m != tc.mime {
|
||||
t.Fatalf("want %s, got %s", tc.mime, m)
|
||||
}
|
||||
src, _ := os.ReadFile(filepath.Join(dir, filepath.FromSlash(strings.TrimPrefix(tc.url, "/uploads/"))))
|
||||
if !bytes.Equal(data, src) {
|
||||
t.Fatalf("%s should pass through unchanged", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantCachedAndReused(t *testing.T) {
|
||||
s, dir := newUploadTestService(t)
|
||||
url := "/uploads/images/cache.jpg"
|
||||
writeVariantSource(t, dir, url, encodeJPEG(solidImg(1000, 500)))
|
||||
|
||||
first, m, err := s.Variant(url, 384)
|
||||
if err != nil {
|
||||
t.Fatalf("first variant: %v", err)
|
||||
}
|
||||
if m != "image/webp" {
|
||||
t.Fatalf("want webp, got %s", m)
|
||||
}
|
||||
cache := s.variantCacheFile(url, 384)
|
||||
if info, err := os.Stat(cache); err != nil || info.IsDir() {
|
||||
t.Fatalf("cache file should exist: %v", err)
|
||||
}
|
||||
second, _, err := s.Variant(url, 384)
|
||||
if err != nil {
|
||||
t.Fatalf("second variant: %v", err)
|
||||
}
|
||||
if !bytes.Equal(first, second) {
|
||||
t.Fatal("cached variant should be byte-identical")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantCacheFreshness(t *testing.T) {
|
||||
_, dir := newUploadTestService(t)
|
||||
cache := filepath.Join(dir, ".thumbs", "v", "x.webp")
|
||||
if err := os.MkdirAll(filepath.Dir(cache), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(cache, []byte("data"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 远程对象:modTime 零值,存在即新鲜
|
||||
if !variantCacheFresh(cache, time.Time{}) {
|
||||
t.Fatal("remote cache should be fresh when file exists")
|
||||
}
|
||||
// 本地源:mtime 新于缓存 → 不新鲜
|
||||
future := time.Now().Add(1 * time.Hour)
|
||||
if variantCacheFresh(cache, future) {
|
||||
t.Fatal("cache should be stale vs newer source mtime")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantRemoteRejectsBadID(t *testing.T) {
|
||||
s, _ := newUploadTestService(t)
|
||||
s.ops = NewOperations(s.db, &config.Config{})
|
||||
|
||||
if _, _, err := s.Variant("/api/media/bad/id", 96); err == nil {
|
||||
t.Fatal("id with slash should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantRemoteUnavailable(t *testing.T) {
|
||||
s, _ := newUploadTestService(t)
|
||||
s.ops = NewOperations(s.db, &config.Config{})
|
||||
|
||||
// 表不存在/对象不存在:统一对外“暂不可用”,不泄漏细节
|
||||
if _, _, err := s.Variant("/api/media/deadbeef", 96); err == nil {
|
||||
t.Fatal("missing remote object should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVariantRemoteWithoutOps(t *testing.T) {
|
||||
s, _ := newUploadTestService(t)
|
||||
if _, _, err := s.Variant("/api/media/abc", 96); err == nil {
|
||||
t.Fatal("remote url without ops wired should error")
|
||||
}
|
||||
}
|
||||
@@ -38,9 +38,9 @@ func TestEarnedPointsWindowAndExclusions(t *testing.T) {
|
||||
{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: 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 {
|
||||
|
||||
@@ -102,7 +102,7 @@ type LegacyUserPreview struct {
|
||||
Nickname string `json:"nickname"`
|
||||
Posts int `json:"posts"`
|
||||
Comments int `json:"comments"`
|
||||
Exists bool `json:"exists"` // 本站已有同名账号(导入时自动跳过建号)
|
||||
Exists bool `json:"exists"` // 本站已有同名账号(导入时自动跳过建号)
|
||||
HasAvatar bool `json:"has_avatar"`
|
||||
}
|
||||
|
||||
@@ -142,8 +142,8 @@ type LegacyUserReport struct {
|
||||
|
||||
// LegacyBoardReport 板块导入明细
|
||||
type LegacyBoardReport struct {
|
||||
Created int `json:"created"` // 新建(预检时为「将新建」)
|
||||
Reused int `json:"reused"` // 复用同名已有板块
|
||||
Created int `json:"created"` // 新建(预检时为「将新建」)
|
||||
Reused int `json:"reused"` // 复用同名已有板块
|
||||
Names []string `json:"names,omitempty"` // 新建板块名
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ type LegacyBoardReport struct {
|
||||
type LegacyPostReport struct {
|
||||
Total int `json:"total"`
|
||||
Imported int `json:"imported"`
|
||||
Skipped int `json:"skipped"` // 已导入过(去重记录)
|
||||
Skipped int `json:"skipped"` // 已导入过(去重记录)
|
||||
Excluded int `json:"excluded"` // 手动排除
|
||||
ExcludedDetail []string `json:"excluded_detail,omitempty"`
|
||||
PollsSkipped int `json:"polls_skipped"`
|
||||
@@ -751,19 +751,19 @@ func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint
|
||||
continue
|
||||
}
|
||||
post := model.Post{
|
||||
BoardID: boardID,
|
||||
UserID: authorID,
|
||||
Title: truncateRunesN(title, 256),
|
||||
Content: content,
|
||||
Tags: p.Tags,
|
||||
PostType: model.NormalizePostType(p.PostType), // question→question,normal→discussion
|
||||
Pinned: p.Pinned,
|
||||
Recommended: p.Featured,
|
||||
Status: model.ContentStatusPublished,
|
||||
LikeCount: p.LikeCount,
|
||||
ViewCount: p.ViewCount,
|
||||
CreatedAt: p.CreatedAt,
|
||||
UpdatedAt: p.UpdatedAt,
|
||||
BoardID: boardID,
|
||||
UserID: authorID,
|
||||
Title: truncateRunesN(title, 256),
|
||||
Content: content,
|
||||
Tags: p.Tags,
|
||||
PostType: model.NormalizePostType(p.PostType), // question→question,normal→discussion
|
||||
Pinned: p.Pinned,
|
||||
Recommended: p.Featured,
|
||||
Status: model.ContentStatusPublished,
|
||||
LikeCount: p.LikeCount,
|
||||
ViewCount: p.ViewCount,
|
||||
CreatedAt: p.CreatedAt,
|
||||
UpdatedAt: p.UpdatedAt,
|
||||
}
|
||||
if err := s.db.Create(&post).Error; err != nil {
|
||||
out.Failed = append(out.Failed, fmt.Sprintf("%s (写入失败: %v)", title, err))
|
||||
|
||||
@@ -136,9 +136,9 @@ func TestLegacyImportSelectiveRun(t *testing.T) {
|
||||
svc, oldPath, avatarZip, imagesZip, adminID, hallMembers := newLegacyTestEnv(t)
|
||||
opts := LegacyImportOptions{
|
||||
WithContent: true,
|
||||
SkipUserIDs: map[uint]bool{2: true, 3: true}, // bob / carol 不建号
|
||||
UserTargetNames: map[uint]string{2: "admin"}, // bob 的内容归到已有账号 admin
|
||||
SkipCommentIDs: map[uint]bool{202: true}, // 排除 alice 在 #101 的评论
|
||||
SkipUserIDs: map[uint]bool{2: true, 3: true}, // bob / carol 不建号
|
||||
UserTargetNames: map[uint]string{2: "admin"}, // bob 的内容归到已有账号 admin
|
||||
SkipCommentIDs: map[uint]bool{202: true}, // 排除 alice 在 #101 的评论
|
||||
}
|
||||
rep, err := svc.ImportFromFiles(oldPath, avatarZip, imagesZip, opts, adminID)
|
||||
if err != nil {
|
||||
@@ -241,8 +241,8 @@ func TestLegacyImportSelectiveRun(t *testing.T) {
|
||||
func TestLegacyImportSkipPostCascades(t *testing.T) {
|
||||
svc, oldPath, avatarZip, imagesZip, adminID, _ := newLegacyTestEnv(t)
|
||||
opts := LegacyImportOptions{
|
||||
WithContent: true,
|
||||
SkipPostIDs: map[uint]bool{101: true}, // #101 排除 → 其评论 #202 自动跳过
|
||||
WithContent: true,
|
||||
SkipPostIDs: map[uint]bool{101: true}, // #101 排除 → 其评论 #202 自动跳过
|
||||
}
|
||||
rep, err := svc.ImportFromFiles(oldPath, avatarZip, imagesZip, opts, adminID)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -44,7 +46,16 @@ func LibraryExtAllowed(ext string) bool {
|
||||
|
||||
// 写入时显式列出字段(独立 Select 参数,同 sitePageWriteFields)
|
||||
var libraryDocWriteFields = []string{
|
||||
"Slug", "Title", "Description", "CoverURL", "Published", "SortOrder", "EntriesAuto",
|
||||
"Slug", "Title", "Description", "CoverURL", "CoverWidth", "CoverHeight",
|
||||
"Published", "SortOrder", "EntriesAuto", "Author", "CreatorID",
|
||||
}
|
||||
|
||||
// LibraryCreator 条目创建者摘要(仅列表/详情展示所需字段;用户已注销时为 nil)
|
||||
type LibraryCreator struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
}
|
||||
|
||||
// LibraryDocDetail 条目 + 文件列表 + 章节树(管理端与公开详情共用)
|
||||
@@ -53,27 +64,34 @@ type LibraryDocDetail struct {
|
||||
Deleted bool `json:"deleted"` // 软删标记(DeletedAt json:"-" 不出模型)
|
||||
Files []model.LibraryFile `json:"files"`
|
||||
Sections []model.LibrarySection `json:"sections"`
|
||||
Creator *LibraryCreator `json:"creator,omitempty"` // 创建者摘要(0/已注销=nil)
|
||||
}
|
||||
|
||||
// LibraryDocListItem 公开目录条目(不含 description 全文,含文件概要)
|
||||
// LibraryDocListItem 公开目录条目(description 纯文本截断,含文件概要)
|
||||
type LibraryDocListItem struct {
|
||||
ID uint `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
FileCount int `json:"file_count"`
|
||||
Files []model.LibraryFile `json:"files"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
FileCount int `json:"file_count"`
|
||||
Files []model.LibraryFile `json:"files"`
|
||||
Creator *LibraryCreator `json:"creator,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// LibraryInput 创建/更新书库条目入参
|
||||
type LibraryInput struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"` // 书籍作者(自由文本,可空)
|
||||
Description string `json:"description"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
CoverWidth int `json:"cover_width"` // 封面自然宽(0=未知)
|
||||
CoverHeight int `json:"cover_height"` // 封面自然高(0=未知)
|
||||
Published *bool `json:"published"`
|
||||
SortOrder *int `json:"sort_order"`
|
||||
EntriesAuto *bool `json:"entries_auto"` // 全章节条目卡模式(nil=不修改)
|
||||
@@ -99,6 +117,11 @@ func (in *LibraryInput) normalize() error {
|
||||
return errors.New("标题不能超过 200 字")
|
||||
}
|
||||
|
||||
in.Author = strings.TrimSpace(in.Author)
|
||||
if utf8.RuneCountInString(in.Author) > 100 {
|
||||
return errors.New("作者不能超过 100 字")
|
||||
}
|
||||
|
||||
in.Description = strings.TrimSpace(in.Description)
|
||||
if utf8.RuneCountInString(in.Description) > libraryDescriptionMax {
|
||||
return errors.New("介绍不能超过 20000 字")
|
||||
@@ -115,8 +138,17 @@ func (in *LibraryInput) normalize() error {
|
||||
func (in *LibraryInput) applyTo(d *model.LibraryDoc) {
|
||||
d.Slug = in.Slug
|
||||
d.Title = in.Title
|
||||
d.Author = in.Author
|
||||
d.Description = in.Description
|
||||
d.CoverURL = in.CoverURL
|
||||
// 封面清空时一并清除尺寸;有封面但未传尺寸时保留 0(前端回退运行时探测)
|
||||
if in.CoverURL == "" {
|
||||
d.CoverWidth = 0
|
||||
d.CoverHeight = 0
|
||||
} else {
|
||||
d.CoverWidth = in.CoverWidth
|
||||
d.CoverHeight = in.CoverHeight
|
||||
}
|
||||
if in.Published != nil {
|
||||
d.Published = *in.Published
|
||||
}
|
||||
@@ -130,9 +162,10 @@ func (in *LibraryInput) applyTo(d *model.LibraryDoc) {
|
||||
|
||||
// LibraryService 书库服务(条目 CRUD + 文件落盘/下载/预览)
|
||||
type LibraryService struct {
|
||||
db *gorm.DB
|
||||
dir string // data/library(绝不放 data/uploads:静态目录会按扩展名原样 serve html)
|
||||
setting *SettingService
|
||||
db *gorm.DB
|
||||
dir string // data/library(绝不放 data/uploads:静态目录会按扩展名原样 serve html)
|
||||
uploadsDir string // data/uploads:封面回填时解析本地封面图
|
||||
setting *SettingService
|
||||
}
|
||||
|
||||
func NewLibraryService(db *gorm.DB, dataDir string) *LibraryService {
|
||||
@@ -145,6 +178,12 @@ func (s *LibraryService) WithSetting(setting *SettingService) *LibraryService {
|
||||
return s
|
||||
}
|
||||
|
||||
// WithUploadsDir 注入上传根目录(封面回填用:把 /uploads/... 封面 URL 解析为本地文件)
|
||||
func (s *LibraryService) WithUploadsDir(uploadsDir string) *LibraryService {
|
||||
s.uploadsDir = uploadsDir
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *LibraryService) EnsureDir() error {
|
||||
return os.MkdirAll(s.dir, 0o755)
|
||||
}
|
||||
@@ -224,6 +263,7 @@ func (s *LibraryService) attachFiles(docs []model.LibraryDoc) []LibraryDocDetail
|
||||
for _, sec := range sections {
|
||||
secByDoc[sec.DocID] = append(secByDoc[sec.DocID], sec)
|
||||
}
|
||||
creatorByID := s.creatorMap(docs)
|
||||
for _, d := range docs {
|
||||
fs := byDoc[d.ID]
|
||||
if fs == nil {
|
||||
@@ -233,12 +273,40 @@ func (s *LibraryService) attachFiles(docs []model.LibraryDoc) []LibraryDocDetail
|
||||
if ss == nil {
|
||||
ss = []model.LibrarySection{}
|
||||
}
|
||||
out = append(out, LibraryDocDetail{LibraryDoc: d, Deleted: d.DeletedAt.Valid, Files: fs, Sections: ss})
|
||||
out = append(out, LibraryDocDetail{
|
||||
LibraryDoc: d, Deleted: d.DeletedAt.Valid, Files: fs, Sections: ss,
|
||||
Creator: creatorByID[d.CreatorID],
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *LibraryService) Create(in *LibraryInput) (*model.LibraryDoc, error) {
|
||||
// creatorMap 批量取条目创建者摘要(一条 SQL,避免列表 N+1;已注销用户查不到即缺省)
|
||||
func (s *LibraryService) creatorMap(docs []model.LibraryDoc) map[uint]*LibraryCreator {
|
||||
ids := make([]uint, 0, len(docs))
|
||||
seen := map[uint]bool{}
|
||||
for _, d := range docs {
|
||||
if d.CreatorID > 0 && !seen[d.CreatorID] {
|
||||
seen[d.CreatorID] = true
|
||||
ids = append(ids, d.CreatorID)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return map[uint]*LibraryCreator{}
|
||||
}
|
||||
var users []model.User
|
||||
if err := s.db.Select("id, username, nickname, avatar").Where("id IN ?", ids).Find(&users).Error; err != nil {
|
||||
return map[uint]*LibraryCreator{}
|
||||
}
|
||||
m := make(map[uint]*LibraryCreator, len(users))
|
||||
for i := range users {
|
||||
u := &users[i]
|
||||
m[u.ID] = &LibraryCreator{ID: u.ID, Username: u.Username, Nickname: u.Nickname, Avatar: u.Avatar}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (s *LibraryService) Create(in *LibraryInput, creatorID ...uint) (*model.LibraryDoc, error) {
|
||||
if err := in.normalize(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -253,6 +321,9 @@ func (s *LibraryService) Create(in *LibraryInput) (*model.LibraryDoc, error) {
|
||||
return nil, errors.New("该地址已被已删除条目占用,可在书库管理列表中彻底删除后重新使用")
|
||||
}
|
||||
d := &model.LibraryDoc{Published: false, SortOrder: 0}
|
||||
if len(creatorID) > 0 {
|
||||
d.CreatorID = creatorID[0]
|
||||
}
|
||||
in.applyTo(d)
|
||||
if err := s.db.Select(libraryDocWriteFields).Create(d).Error; err != nil {
|
||||
return nil, err
|
||||
@@ -279,12 +350,15 @@ func (s *LibraryService) Update(id uint, in *LibraryInput) (*model.LibraryDoc, e
|
||||
return nil, errors.New("该地址已被已删除条目占用,可在书库管理列表中彻底删除后重新使用")
|
||||
}
|
||||
in.applyTo(&d)
|
||||
// map 更新确保 false/空串写入(同 SitePage.Update)
|
||||
// map 更新确保 false/空串写入(同 SitePage.Update);creator_id 创建后不变,不在此列
|
||||
if err := s.db.Model(&model.LibraryDoc{}).Where("id = ?", d.ID).Updates(map[string]interface{}{
|
||||
"slug": d.Slug,
|
||||
"title": d.Title,
|
||||
"author": d.Author,
|
||||
"description": d.Description,
|
||||
"cover_url": d.CoverURL,
|
||||
"cover_width": d.CoverWidth,
|
||||
"cover_height": d.CoverHeight,
|
||||
"published": d.Published,
|
||||
"sort_order": d.SortOrder,
|
||||
"entries_auto": d.EntriesAuto,
|
||||
@@ -294,6 +368,77 @@ func (s *LibraryService) Update(id uint, in *LibraryInput) (*model.LibraryDoc, e
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// resolveCoverPath 把 /uploads/... 封面 URL 解析为本地绝对路径;外链返回空串
|
||||
func (s *LibraryService) resolveCoverPath(coverURL string) (string, bool) {
|
||||
rel := strings.TrimPrefix(strings.TrimSpace(coverURL), "/uploads/")
|
||||
if rel == "" || rel == coverURL || strings.Contains(rel, "..") {
|
||||
return "", false
|
||||
}
|
||||
abs := filepath.Join(s.uploadsDir, filepath.FromSlash(rel))
|
||||
root := filepath.Clean(s.uploadsDir)
|
||||
clean := filepath.Clean(abs)
|
||||
if clean != root && !strings.HasPrefix(clean, root+string(os.PathSeparator)) {
|
||||
return "", false
|
||||
}
|
||||
return clean, true
|
||||
}
|
||||
|
||||
// measureCoverReader 从图片流读取自然宽高(复用上传层的格式探测与尺寸解码)
|
||||
func measureCoverReader(r io.Reader) (w, h int, err error) {
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
format, err := detectImageFormat(data)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return decodeImageSizeReader(bytes.NewReader(data), format.mime)
|
||||
}
|
||||
|
||||
// ensureCoverDimensions 若条目有封面但未存自然宽高,则测量并写回库(幂等:已有尺寸直接跳过)。
|
||||
// 用于读路径懒回填,避免独立运维接口:首次访问后尺寸即落库,后续 SSR 首帧直接判定横竖版。
|
||||
func (s *LibraryService) ensureCoverDimensions(d *model.LibraryDoc) {
|
||||
if d.CoverURL == "" || (d.CoverWidth > 0 && d.CoverHeight > 0) || s.uploadsDir == "" {
|
||||
return
|
||||
}
|
||||
url := strings.TrimSpace(d.CoverURL)
|
||||
var reader io.ReadCloser
|
||||
switch {
|
||||
case strings.HasPrefix(url, "/uploads/"):
|
||||
abs, ok := s.resolveCoverPath(url)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
f, err := os.Open(abs)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
reader = f
|
||||
case strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://"):
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
reader = resp.Body
|
||||
default:
|
||||
return
|
||||
}
|
||||
w, h, err := measureCoverReader(reader)
|
||||
reader.Close()
|
||||
if err != nil || w <= 0 || h <= 0 {
|
||||
return
|
||||
}
|
||||
if err := s.db.Model(d).Updates(map[string]interface{}{
|
||||
"cover_width": w,
|
||||
"cover_height": h,
|
||||
}).Error; err != nil {
|
||||
return
|
||||
}
|
||||
d.CoverWidth, d.CoverHeight = w, h
|
||||
}
|
||||
|
||||
func (s *LibraryService) Delete(id uint) error {
|
||||
result := s.db.Delete(&model.LibraryDoc{}, id)
|
||||
if result.Error != nil {
|
||||
@@ -349,8 +494,9 @@ func (s *LibraryService) ListPublished() ([]LibraryDocListItem, error) {
|
||||
out := make([]LibraryDocListItem, 0, len(details))
|
||||
for _, d := range details {
|
||||
out = append(out, LibraryDocListItem{
|
||||
ID: d.ID, Slug: d.Slug, Title: d.Title, CoverURL: d.CoverURL,
|
||||
SortOrder: d.SortOrder, FileCount: len(d.Files), Files: d.Files,
|
||||
ID: d.ID, Slug: d.Slug, Title: d.Title, Author: d.Author, Description: d.Description,
|
||||
CoverURL: d.CoverURL, SortOrder: d.SortOrder, FileCount: len(d.Files), Files: d.Files,
|
||||
Creator: d.Creator,
|
||||
CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt,
|
||||
})
|
||||
}
|
||||
@@ -364,6 +510,8 @@ func (s *LibraryService) GetPublishedBySlug(slug string) (*LibraryDocDetail, err
|
||||
if err := s.db.Where("slug = ? AND published = ?", slug, true).First(&d).Error; err != nil {
|
||||
return nil, ErrLibraryNotFound
|
||||
}
|
||||
// 懒回填:有封面但未存尺寸时当场测量并写回(首次访问后即稳定,消除横竖版探测闪烁)
|
||||
s.ensureCoverDimensions(&d)
|
||||
out := s.attachFiles([]model.LibraryDoc{d})
|
||||
return &out[0], nil
|
||||
}
|
||||
|
||||
369
backend/service/library_export.go
Normal file
369
backend/service/library_export.go
Normal file
@@ -0,0 +1,369 @@
|
||||
package service
|
||||
|
||||
// 书库导出:单本 / 全部条目打包为 ZIP(JSON 清单 + 章节正文 + 附件原文件 + 本地封面),
|
||||
// 用于迁移与备份。清单自描述(format + format_version),为将来的导入功能预留。
|
||||
//
|
||||
// 单本 ZIP 布局:
|
||||
//
|
||||
// book.json 书籍清单(元信息 / 章节树 / 附件清单,路径均相对本目录)
|
||||
// cover.<ext> 本地封面(外链封面不内嵌,仅保留 cover_url)
|
||||
// files/<stored-name> 附件原文件
|
||||
//
|
||||
// 全库 ZIP 布局:
|
||||
//
|
||||
// library.json 索引(每本书的目录位置)
|
||||
// docs/<slug>/book.json 各书清单
|
||||
// docs/<slug>/cover.<ext>
|
||||
// docs/<slug>/files/<stored-name>
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
)
|
||||
|
||||
const (
|
||||
libraryExportFormat = "jiang13-library" // 全库包
|
||||
libraryExportBookFormat = "jiang13-library-book" // 单本书包
|
||||
libraryExportVersion = 1
|
||||
)
|
||||
|
||||
// 可内嵌进导出包的本地封面扩展名(与上传层支持的图片格式保持一致)
|
||||
var libraryExportCoverExts = map[string]struct{}{
|
||||
"jpg": {}, "jpeg": {}, "png": {}, "gif": {}, "webp": {},
|
||||
}
|
||||
|
||||
// libraryExportFile 附件清单条目
|
||||
type libraryExportFile struct {
|
||||
Name string `json:"name"` // 原始文件名
|
||||
Stored string `json:"stored,omitempty"` // ZIP 内相对路径(磁盘缺失时为空)
|
||||
Ext string `json:"ext"`
|
||||
MIME string `json:"mime"`
|
||||
Size int `json:"size"`
|
||||
DownloadCount int `json:"download_count"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
Missing bool `json:"missing,omitempty"` // 数据库有记录但磁盘文件已丢失
|
||||
}
|
||||
|
||||
// libraryExportSection 章节清单条目(两级树:ParentKey 指向章的 Key)
|
||||
type libraryExportSection struct {
|
||||
Key string `json:"key"`
|
||||
ParentKey string `json:"parent_key,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
// libraryExportBook 单本书的完整清单
|
||||
type libraryExportBook struct {
|
||||
ID uint `json:"id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
Description string `json:"description"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
CoverFile string `json:"cover_file,omitempty"` // 相对本书目录的内嵌封面路径
|
||||
CoverWidth int `json:"cover_width"`
|
||||
CoverHeight int `json:"cover_height"`
|
||||
Published bool `json:"published"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
EntriesAuto bool `json:"entries_auto"`
|
||||
|
||||
Files []libraryExportFile `json:"files"`
|
||||
Sections []libraryExportSection `json:"sections"`
|
||||
}
|
||||
|
||||
// libraryExportIndexEntry 全库索引中的单本概要
|
||||
type libraryExportIndexEntry struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Dir string `json:"dir"` // 相对 ZIP 根的目录
|
||||
FileCount int `json:"file_count"`
|
||||
SectionCount int `json:"section_count"`
|
||||
}
|
||||
|
||||
// libraryExportManifest ZIP 根清单:单本包用 Doc,全库包用 Docs 索引
|
||||
type libraryExportManifest struct {
|
||||
Format string `json:"format"`
|
||||
FormatVersion int `json:"format_version"`
|
||||
ExportedAt string `json:"exported_at"`
|
||||
Doc *libraryExportBook `json:"doc,omitempty"`
|
||||
Docs []libraryExportIndexEntry `json:"docs,omitempty"`
|
||||
}
|
||||
|
||||
// preparedFile 已打开、待流式拷入 ZIP 的附件
|
||||
type preparedFile struct {
|
||||
zipPath string
|
||||
reader *os.File
|
||||
}
|
||||
|
||||
// preparedBook 一本书的导出准备结果(清单 + 已打开的文件句柄)
|
||||
type preparedBook struct {
|
||||
meta libraryExportBook
|
||||
dir string // ZIP 内目录前缀(单本包为空串 = 根目录)
|
||||
coverPath string // ZIP 内封面路径(空 = 不内嵌)
|
||||
coverReader *os.File
|
||||
files []preparedFile // 仅磁盘存在的附件
|
||||
}
|
||||
|
||||
func (b *preparedBook) closeReaders() {
|
||||
if b.coverReader != nil {
|
||||
_ = b.coverReader.Close()
|
||||
}
|
||||
for i := range b.files {
|
||||
_ = b.files[i].reader.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// LibraryExport 已完成装载、可直接流式写出的导出包
|
||||
type LibraryExport struct {
|
||||
Filename string
|
||||
full bool
|
||||
exportedAt time.Time
|
||||
books []preparedBook
|
||||
}
|
||||
|
||||
// Close 释放导出过程中打开的全部文件句柄
|
||||
func (e *LibraryExport) Close() {
|
||||
for i := range e.books {
|
||||
e.books[i].closeReaders()
|
||||
}
|
||||
}
|
||||
|
||||
// BuildBookExport 装载单本书导出(不存在返回 ErrLibraryNotFound)。
|
||||
// 所有失败均发生在写出之前,handler 可安全返回 JSON 错误。
|
||||
func (s *LibraryService) BuildBookExport(id uint) (*LibraryExport, error) {
|
||||
d, err := s.Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := s.prepareBook(d, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := d.Slug
|
||||
if name == "" {
|
||||
name = "book"
|
||||
}
|
||||
return &LibraryExport{
|
||||
Filename: name + ".zip",
|
||||
full: false,
|
||||
exportedAt: time.Now(),
|
||||
books: []preparedBook{*b},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BuildAllExport 装载全库导出(仅在用条目,按 sort_order / id 排序)
|
||||
func (s *LibraryService) BuildAllExport() (*LibraryExport, error) {
|
||||
var docs []model.LibraryDoc
|
||||
if err := s.db.Order("sort_order ASC, id ASC").Find(&docs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
details := s.attachFiles(docs)
|
||||
exp := &LibraryExport{
|
||||
Filename: "jiang13-library-" + time.Now().Format("20060102-150405") + ".zip",
|
||||
full: true,
|
||||
exportedAt: time.Now(),
|
||||
books: make([]preparedBook, 0, len(details)),
|
||||
}
|
||||
for i := range details {
|
||||
// slug 受 [a-z0-9-] 约束,拼接 ZIP 路径无穿越风险;防御性兜底
|
||||
dir := "docs/" + details[i].Slug + "/"
|
||||
if strings.Contains(details[i].Slug, "..") || strings.ContainsAny(details[i].Slug, `/\`) {
|
||||
exp.Close()
|
||||
return nil, errors.New("条目 slug 含非法字符,无法导出")
|
||||
}
|
||||
b, err := s.prepareBook(&details[i], dir)
|
||||
if err != nil {
|
||||
exp.Close()
|
||||
return nil, err
|
||||
}
|
||||
exp.books = append(exp.books, *b)
|
||||
}
|
||||
return exp, nil
|
||||
}
|
||||
|
||||
// prepareBook 组装单本清单并打开全部待打包文件(dir 为 ZIP 内目录前缀)
|
||||
func (s *LibraryService) prepareBook(d *LibraryDocDetail, dir string) (*preparedBook, error) {
|
||||
b := &preparedBook{dir: dir}
|
||||
b.meta = libraryExportBook{
|
||||
ID: d.ID,
|
||||
CreatedAt: d.CreatedAt.UTC().Format(time.RFC3339),
|
||||
UpdatedAt: d.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
Slug: d.Slug,
|
||||
Title: d.Title,
|
||||
Author: d.Author,
|
||||
Description: d.Description,
|
||||
CoverURL: d.CoverURL,
|
||||
CoverWidth: d.CoverWidth,
|
||||
CoverHeight: d.CoverHeight,
|
||||
Published: d.Published,
|
||||
SortOrder: d.SortOrder,
|
||||
EntriesAuto: d.EntriesAuto,
|
||||
Files: make([]libraryExportFile, 0, len(d.Files)),
|
||||
Sections: make([]libraryExportSection, 0, len(d.Sections)),
|
||||
}
|
||||
|
||||
// 章节:按数组序号发 Key,ParentKey 引用父章 Key(attachFiles 已按 sort_order, id 排序)
|
||||
keyByID := make(map[uint]string, len(d.Sections))
|
||||
for i, sec := range d.Sections {
|
||||
keyByID[sec.ID] = "s" + strconv.Itoa(i)
|
||||
}
|
||||
for _, sec := range d.Sections {
|
||||
m := libraryExportSection{
|
||||
Key: keyByID[sec.ID],
|
||||
Title: sec.Title,
|
||||
Content: sec.Content,
|
||||
SortOrder: sec.SortOrder,
|
||||
}
|
||||
if sec.ParentID != nil {
|
||||
m.ParentKey = keyByID[*sec.ParentID]
|
||||
}
|
||||
b.meta.Sections = append(b.meta.Sections, m)
|
||||
}
|
||||
|
||||
// 附件:数据库清单始终保留;磁盘文件存在才打开并记录 ZIP 路径
|
||||
for _, f := range d.Files {
|
||||
m := libraryExportFile{
|
||||
Name: f.Name,
|
||||
Ext: f.Ext,
|
||||
MIME: f.MIME,
|
||||
Size: f.Size,
|
||||
DownloadCount: f.DownloadCount,
|
||||
SortOrder: f.SortOrder,
|
||||
}
|
||||
full := filepath.Join(s.dir, f.StoredName)
|
||||
if r, err := os.Open(full); err == nil {
|
||||
m.Stored = "files/" + f.StoredName
|
||||
b.files = append(b.files, preparedFile{zipPath: dir + m.Stored, reader: r})
|
||||
} else {
|
||||
m.Missing = true
|
||||
}
|
||||
b.meta.Files = append(b.meta.Files, m)
|
||||
}
|
||||
|
||||
// 本地封面(/uploads/...):能解析到磁盘文件则内嵌;外链或缺目录时仅保留 URL
|
||||
if rel, ok := s.resolveCoverPath(d.CoverURL); ok {
|
||||
if ext := coverExtOf(d.CoverURL); ext != "" {
|
||||
if r, err := os.Open(rel); err == nil {
|
||||
b.coverReader = r
|
||||
b.coverPath = dir + "cover." + ext
|
||||
b.meta.CoverFile = "cover." + ext
|
||||
}
|
||||
}
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// WriteZip 流式写出 ZIP。调用后文件句柄随之释放;中途的 IO 错误已无法改变 HTTP 状态。
|
||||
func (e *LibraryExport) WriteZip(w io.Writer) error {
|
||||
defer e.Close()
|
||||
zw := zip.NewWriter(w)
|
||||
|
||||
index := make([]libraryExportIndexEntry, 0, len(e.books))
|
||||
for i := range e.books {
|
||||
b := &e.books[i]
|
||||
// 全库包每本书在自己的目录内写 book.json;单本包的根清单即 book.json,在循环外写
|
||||
if e.full {
|
||||
if err := writeZipJSON(zw, b.dir+"book.json", b.meta); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if b.coverReader != nil {
|
||||
if err := writeZipFile(zw, b.coverPath, b.coverReader); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for j := range b.files {
|
||||
if err := writeZipFile(zw, b.files[j].zipPath, b.files[j].reader); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
index = append(index, libraryExportIndexEntry{
|
||||
Slug: b.meta.Slug,
|
||||
Title: b.meta.Title,
|
||||
Dir: b.dir,
|
||||
FileCount: len(b.meta.Files),
|
||||
SectionCount: len(b.meta.Sections),
|
||||
})
|
||||
}
|
||||
|
||||
// 单本包根清单为 book.json;全库包根清单为 library.json(各书清单在其目录内)
|
||||
if e.full {
|
||||
root := libraryExportManifest{
|
||||
Format: libraryExportFormat,
|
||||
FormatVersion: libraryExportVersion,
|
||||
ExportedAt: e.exportedAt.UTC().Format(time.RFC3339),
|
||||
Docs: index,
|
||||
}
|
||||
if err := writeZipJSON(zw, "library.json", root); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
root := libraryExportManifest{
|
||||
Format: libraryExportBookFormat,
|
||||
FormatVersion: libraryExportVersion,
|
||||
ExportedAt: e.exportedAt.UTC().Format(time.RFC3339),
|
||||
Doc: &e.books[0].meta,
|
||||
}
|
||||
if err := writeZipJSON(zw, "book.json", root); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return zw.Close()
|
||||
}
|
||||
|
||||
// coverExtOf 从 /uploads/xx.jpg 形式的封面 URL 提取受支持的图片扩展名
|
||||
func coverExtOf(coverURL string) string {
|
||||
u := strings.TrimSpace(coverURL)
|
||||
if i := strings.IndexByte(u, '?'); i >= 0 {
|
||||
u = u[:i]
|
||||
}
|
||||
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(u), "."))
|
||||
if _, ok := libraryExportCoverExts[ext]; !ok {
|
||||
return ""
|
||||
}
|
||||
return ext
|
||||
}
|
||||
|
||||
// writeZipJSON 写入一个 DEFLATE 压缩的 UTF-8 JSON 条目(关闭 HTML 转义、缩进可读)
|
||||
func writeZipJSON(zw *zip.Writer, name string, v any) error {
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(v); err != nil {
|
||||
return err
|
||||
}
|
||||
hdr := &zip.FileHeader{Name: name, Method: zip.Deflate}
|
||||
hdr.SetMode(0o644)
|
||||
f, err := zw.CreateHeader(hdr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = f.Write(buf.Bytes())
|
||||
return err
|
||||
}
|
||||
|
||||
// writeZipFile 以 STORE 方式原样写入二进制附件(多为已压缩格式,重复 deflate 仅耗 CPU)
|
||||
func writeZipFile(zw *zip.Writer, name string, r io.Reader) error {
|
||||
hdr := &zip.FileHeader{Name: name, Method: zip.Store}
|
||||
hdr.SetMode(0o644)
|
||||
f, err := zw.CreateHeader(hdr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(f, r)
|
||||
return err
|
||||
}
|
||||
199
backend/service/library_export_test.go
Normal file
199
backend/service/library_export_test.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// readZipEntry 读取 ZIP 内指定名称的文件内容(不存在返回 nil, false)
|
||||
func readZipEntry(t *testing.T, zr *zip.Reader, name string) ([]byte, bool) {
|
||||
t.Helper()
|
||||
for _, f := range zr.File {
|
||||
if f.Name == name {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatalf("open zip entry %s: %v", name, err)
|
||||
}
|
||||
defer rc.Close()
|
||||
data, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
t.Fatalf("read zip entry %s: %v", name, err)
|
||||
}
|
||||
return data, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func buildExportZip(t *testing.T, exp *LibraryExport) *zip.Reader {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if err := exp.WriteZip(&buf); err != nil {
|
||||
t.Fatalf("write zip: %v", err)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
|
||||
if err != nil {
|
||||
t.Fatalf("read exported zip: %v", err)
|
||||
}
|
||||
return zr
|
||||
}
|
||||
|
||||
// seedBookForExport 造一本含章节(章 + 小节)、附件与本地封面的书籍
|
||||
func seedBookForExport(t *testing.T, s *LibraryService, uploads string) (docID uint, storedName string) {
|
||||
t.Helper()
|
||||
pub := true
|
||||
doc, err := s.Create(&LibraryInput{Slug: "dao-jia", Title: "道家研究", Author: "老子", Description: "# 介绍"})
|
||||
if err != nil {
|
||||
t.Fatalf("create doc: %v", err)
|
||||
}
|
||||
|
||||
coverDir := filepath.Join(uploads, "images")
|
||||
if err := os.MkdirAll(coverDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir uploads: %v", err)
|
||||
}
|
||||
coverBytes := encodeJPEGBytes(t, 120, 160)
|
||||
if err := os.WriteFile(filepath.Join(coverDir, "cover.jpg"), coverBytes, 0o644); err != nil {
|
||||
t.Fatalf("write cover: %v", err)
|
||||
}
|
||||
if _, err := s.Update(doc.ID, &LibraryInput{
|
||||
Slug: "dao-jia", Title: "道家研究", Author: "老子", Description: "# 介绍",
|
||||
CoverURL: "/uploads/images/cover.jpg", CoverWidth: 120, CoverHeight: 160,
|
||||
Published: &pub,
|
||||
}); err != nil {
|
||||
t.Fatalf("update cover: %v", err)
|
||||
}
|
||||
|
||||
chapter, err := s.CreateSection(doc.ID, &SectionInput{Title: "第一章", Content: "章正文"})
|
||||
if err != nil {
|
||||
t.Fatalf("create chapter: %v", err)
|
||||
}
|
||||
if _, err := s.CreateSection(doc.ID, &SectionInput{
|
||||
ParentID: &chapter.ID, Title: "小节", Content: "小节正文",
|
||||
}); err != nil {
|
||||
t.Fatalf("create section: %v", err)
|
||||
}
|
||||
|
||||
f, err := s.AddFile(doc.ID, 1, "manual.epub", bytes.NewReader([]byte("EPUB-FILE-BYTES")))
|
||||
if err != nil {
|
||||
t.Fatalf("add file: %v", err)
|
||||
}
|
||||
return doc.ID, f.StoredName
|
||||
}
|
||||
|
||||
func TestLibraryExportBook(t *testing.T) {
|
||||
s, _ := newLibraryTestService(t)
|
||||
uploads := t.TempDir()
|
||||
s.WithUploadsDir(uploads)
|
||||
docID, storedName := seedBookForExport(t, s, uploads)
|
||||
|
||||
exp, err := s.BuildBookExport(docID)
|
||||
if err != nil {
|
||||
t.Fatalf("build export: %v", err)
|
||||
}
|
||||
defer exp.Close()
|
||||
if exp.Filename != "dao-jia.zip" {
|
||||
t.Fatalf("filename = %q, want dao-jia.zip", exp.Filename)
|
||||
}
|
||||
zr := buildExportZip(t, exp)
|
||||
|
||||
raw, ok := readZipEntry(t, zr, "book.json")
|
||||
if !ok {
|
||||
t.Fatal("缺少 book.json")
|
||||
}
|
||||
var manifest libraryExportManifest
|
||||
if err := json.Unmarshal(raw, &manifest); err != nil {
|
||||
t.Fatalf("parse book.json: %v", err)
|
||||
}
|
||||
if manifest.Format != libraryExportBookFormat || manifest.FormatVersion != libraryExportVersion {
|
||||
t.Fatalf("清单格式异常: %s v%d", manifest.Format, manifest.FormatVersion)
|
||||
}
|
||||
b := manifest.Doc
|
||||
if b == nil {
|
||||
t.Fatal("book.json 缺少 doc")
|
||||
}
|
||||
if b.Title != "道家研究" || b.Slug != "dao-jia" || b.Author != "老子" || !b.Published {
|
||||
t.Fatalf("元信息导出不正确: %+v", b)
|
||||
}
|
||||
if b.CoverFile != "cover.jpg" || b.CoverWidth != 120 || b.CoverHeight != 160 {
|
||||
t.Fatalf("封面信息导出不正确: %+v", b)
|
||||
}
|
||||
if len(b.Sections) != 2 || b.Sections[0].Key != "s0" || b.Sections[1].ParentKey != "s0" {
|
||||
t.Fatalf("章节树导出不正确: %+v", b.Sections)
|
||||
}
|
||||
if len(b.Files) != 1 || b.Files[0].Stored != "files/"+storedName || b.Files[0].Missing {
|
||||
t.Fatalf("附件清单导出不正确: %+v", b.Files)
|
||||
}
|
||||
|
||||
if data, ok := readZipEntry(t, zr, "files/"+storedName); !ok || string(data) != "EPUB-FILE-BYTES" {
|
||||
t.Fatalf("附件内容导出不正确(ok=%v)", ok)
|
||||
}
|
||||
if data, ok := readZipEntry(t, zr, "cover.jpg"); !ok || !bytes.HasPrefix(data, []byte{0xff, 0xd8, 0xff}) {
|
||||
t.Fatalf("封面内容导出不正确(ok=%v)", ok)
|
||||
}
|
||||
// 单本包不应出现全库索引
|
||||
if _, ok := readZipEntry(t, zr, "library.json"); ok {
|
||||
t.Fatal("单本包不应包含 library.json")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibraryExportAll(t *testing.T) {
|
||||
s, _ := newLibraryTestService(t)
|
||||
uploads := t.TempDir()
|
||||
s.WithUploadsDir(uploads)
|
||||
docID, storedName := seedBookForExport(t, s, uploads)
|
||||
_ = docID
|
||||
|
||||
exp, err := s.BuildAllExport()
|
||||
if err != nil {
|
||||
t.Fatalf("build all export: %v", err)
|
||||
}
|
||||
defer exp.Close()
|
||||
zr := buildExportZip(t, exp)
|
||||
|
||||
raw, ok := readZipEntry(t, zr, "library.json")
|
||||
if !ok {
|
||||
t.Fatal("缺少 library.json")
|
||||
}
|
||||
var manifest libraryExportManifest
|
||||
if err := json.Unmarshal(raw, &manifest); err != nil {
|
||||
t.Fatalf("parse library.json: %v", err)
|
||||
}
|
||||
if manifest.Format != libraryExportFormat || len(manifest.Docs) != 1 {
|
||||
t.Fatalf("全库索引异常: %+v", manifest)
|
||||
}
|
||||
idx := manifest.Docs[0]
|
||||
if idx.Dir != "docs/dao-jia/" || idx.FileCount != 1 || idx.SectionCount != 2 {
|
||||
t.Fatalf("索引条目异常: %+v", idx)
|
||||
}
|
||||
|
||||
bookRaw, ok := readZipEntry(t, zr, "docs/dao-jia/book.json")
|
||||
if !ok {
|
||||
t.Fatal("缺少 docs/dao-jia/book.json")
|
||||
}
|
||||
var book libraryExportBook
|
||||
if err := json.Unmarshal(bookRaw, &book); err != nil {
|
||||
t.Fatalf("parse nested book.json: %v", err)
|
||||
}
|
||||
if book.Slug != "dao-jia" || book.CoverFile != "cover.jpg" {
|
||||
t.Fatalf("嵌套书籍清单异常: %+v", book)
|
||||
}
|
||||
if _, ok := readZipEntry(t, zr, "docs/dao-jia/files/"+storedName); !ok {
|
||||
t.Fatal("缺少嵌套附件")
|
||||
}
|
||||
if _, ok := readZipEntry(t, zr, "docs/dao-jia/cover.jpg"); !ok {
|
||||
t.Fatal("缺少嵌套封面")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibraryExportMissingDoc(t *testing.T) {
|
||||
s, _ := newLibraryTestService(t)
|
||||
if _, err := s.BuildBookExport(999); !errors.Is(err, ErrLibraryNotFound) {
|
||||
t.Fatalf("不存在的条目应返回 ErrLibraryNotFound,got %v", err)
|
||||
}
|
||||
}
|
||||
631
backend/service/library_import.go
Normal file
631
backend/service/library_import.go
Normal file
@@ -0,0 +1,631 @@
|
||||
package service
|
||||
|
||||
// 书库导入恢复:解析导出 ZIP(见 library_export.go),在本站重建书籍。
|
||||
//
|
||||
// - 单本包(根 book.json,format=jiang13-library-book)与全库包(library.json 索引)
|
||||
// - mode=create(默认):同 slug 在用条目冲突时跳过该书;软删占用同样拒绝(释放后再来)
|
||||
// - mode=overwrite:覆盖同 slug 在用条目(章节/附件整体替换,元信息按包恢复)
|
||||
// - 附件复用 AddFile(扩展名白名单/大小/20 个上限/随机落盘),磁盘名重新生成但保留下载计数
|
||||
// - 内嵌封面按魔数校验后随机名落盘 uploads/images;外链封面仅保留 URL
|
||||
// - 逐本独立处理:单本书失败不影响包内其他书,结果以报告返回(包本身非法才整体报错)
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// LibraryImportMaxBytes 导入 ZIP 上传上限(超出由 handler 提前拦截)
|
||||
const LibraryImportMaxBytes = 512 << 20
|
||||
|
||||
const (
|
||||
LibraryImportModeCreate = "create" // 冲突跳过
|
||||
LibraryImportModeOverwrite = "overwrite" // 冲突覆盖
|
||||
)
|
||||
|
||||
// 失败原因(前端据此区分冲突,可提示覆盖重导)
|
||||
const (
|
||||
libraryImportReasonConflict = "conflict" // slug 被在用条目占用(create 模式)
|
||||
libraryImportReasonDeleted = "deleted" // slug 被已软删条目占用
|
||||
libraryImportReasonInvalid = "invalid" // 清单数据非法
|
||||
libraryImportReasonFailed = "failed" // 落盘/写库失败
|
||||
)
|
||||
|
||||
var (
|
||||
ErrLibraryImportBadZip = errors.New("无法读取导入文件,请上传书库导出的 ZIP 备份包")
|
||||
ErrLibraryImportFormat = errors.New("不是有效的书库导出包:缺少清单文件或清单已损坏")
|
||||
ErrLibraryImportVer = errors.New("导出版本不受支持")
|
||||
ErrLibraryImportEmpty = errors.New("备份包内没有可导入的书籍")
|
||||
ErrLibraryImportMode = errors.New("无效的导入模式")
|
||||
)
|
||||
|
||||
// LibraryImportItem 成功导入的单本结果
|
||||
type LibraryImportItem struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Action string `json:"action"` // created / overwritten
|
||||
Sections int `json:"sections"`
|
||||
Files int `json:"files"`
|
||||
}
|
||||
|
||||
// LibraryImportFailure 单本书失败明细
|
||||
type LibraryImportFailure struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Reason string `json:"reason"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// LibraryImportReport 导入报告
|
||||
type LibraryImportReport struct {
|
||||
Mode string `json:"mode"`
|
||||
Total int `json:"total"`
|
||||
Imported []LibraryImportItem `json:"imported"`
|
||||
Failed []LibraryImportFailure `json:"failed"`
|
||||
}
|
||||
|
||||
// bookImportPlan 单本书的导入计划(清单 + ZIP 内目录前缀)
|
||||
type bookImportPlan struct {
|
||||
book libraryExportBook
|
||||
dir string
|
||||
}
|
||||
|
||||
// plannedSection 重建后的章节(保留原始 key 用于两级映射;sort 为同层级序号)
|
||||
type plannedSection struct {
|
||||
key string
|
||||
parentKey string
|
||||
title string
|
||||
content string
|
||||
sort int
|
||||
}
|
||||
|
||||
// ImportLibraryZip 从导出 ZIP 恢复书籍。zipPath 为已落盘的临时文件路径。
|
||||
func (s *LibraryService) ImportLibraryZip(zipPath, mode string, userID uint) (*LibraryImportReport, error) {
|
||||
if mode != LibraryImportModeCreate && mode != LibraryImportModeOverwrite {
|
||||
return nil, ErrLibraryImportMode
|
||||
}
|
||||
zr, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return nil, ErrLibraryImportBadZip
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
entries := make(map[string]*zip.File, len(zr.File))
|
||||
for _, f := range zr.File {
|
||||
if f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
entries[strings.ReplaceAll(f.Name, "\\", "/")] = f // 正常包无重名
|
||||
}
|
||||
|
||||
plans, err := parseImportPlans(entries)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(plans) == 0 {
|
||||
return nil, ErrLibraryImportEmpty
|
||||
}
|
||||
|
||||
rep := &LibraryImportReport{
|
||||
Mode: mode,
|
||||
Total: len(plans),
|
||||
Imported: []LibraryImportItem{},
|
||||
Failed: []LibraryImportFailure{},
|
||||
}
|
||||
imp := &bookImporter{s: s, entries: entries, userID: userID}
|
||||
for _, p := range plans {
|
||||
item, fail := imp.run(p, mode)
|
||||
if fail != nil {
|
||||
rep.Failed = append(rep.Failed, *fail)
|
||||
} else {
|
||||
rep.Imported = append(rep.Imported, *item)
|
||||
}
|
||||
}
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// parseImportPlans 识别单本/全库包并解析书籍清单(不读正文/附件)
|
||||
func parseImportPlans(entries map[string]*zip.File) ([]bookImportPlan, error) {
|
||||
if rootRaw, ok := entries["library.json"]; ok {
|
||||
var root libraryExportManifest
|
||||
if err := readZipJSON(rootRaw, &root); err != nil {
|
||||
return nil, ErrLibraryImportFormat
|
||||
}
|
||||
if root.Format != libraryExportFormat || root.FormatVersion != libraryExportVersion {
|
||||
return nil, ErrLibraryImportVer
|
||||
}
|
||||
plans := make([]bookImportPlan, 0, len(root.Docs))
|
||||
for _, d := range root.Docs {
|
||||
dir := strings.ReplaceAll(d.Dir, "\\", "/")
|
||||
if !safeImportDir(dir) {
|
||||
return nil, ErrLibraryImportFormat
|
||||
}
|
||||
raw, ok := entries[path.Clean(dir)+"/book.json"]
|
||||
if !ok {
|
||||
return nil, ErrLibraryImportFormat
|
||||
}
|
||||
var b libraryExportBook
|
||||
if err := readZipJSON(raw, &b); err != nil {
|
||||
return nil, ErrLibraryImportFormat
|
||||
}
|
||||
plans = append(plans, bookImportPlan{book: b, dir: dir})
|
||||
}
|
||||
return plans, nil
|
||||
}
|
||||
if rootRaw, ok := entries["book.json"]; ok {
|
||||
var root libraryExportManifest
|
||||
if err := readZipJSON(rootRaw, &root); err != nil {
|
||||
return nil, ErrLibraryImportFormat
|
||||
}
|
||||
if root.Format != libraryExportBookFormat || root.FormatVersion != libraryExportVersion || root.Doc == nil {
|
||||
return nil, ErrLibraryImportVer
|
||||
}
|
||||
return []bookImportPlan{{book: *root.Doc, dir: ""}}, nil
|
||||
}
|
||||
return nil, ErrLibraryImportFormat
|
||||
}
|
||||
|
||||
// safeImportDir 全库包内目录必须是 docs/<slug>/ 形态(slug 与条目同规则)
|
||||
func safeImportDir(dir string) bool {
|
||||
if dir == "" || !strings.HasPrefix(dir, "docs/") || !strings.HasSuffix(dir, "/") {
|
||||
return false
|
||||
}
|
||||
slug := strings.TrimSuffix(strings.TrimPrefix(dir, "docs/"), "/")
|
||||
if strings.Contains(slug, "/") || strings.Contains(slug, "..") {
|
||||
return false
|
||||
}
|
||||
return sitePageSlugRe.MatchString(slug)
|
||||
}
|
||||
|
||||
// bookImporter 携带一次批量导入的共享上下文
|
||||
type bookImporter struct {
|
||||
s *LibraryService
|
||||
entries map[string]*zip.File
|
||||
userID uint
|
||||
}
|
||||
|
||||
func (imp *bookImporter) fail(p bookImportPlan, reason, msg string) *LibraryImportFailure {
|
||||
return &LibraryImportFailure{Slug: p.book.Slug, Title: p.book.Title, Reason: reason, Error: msg}
|
||||
}
|
||||
|
||||
// run 导入单本书;返回 item 或 failure(二者互斥)
|
||||
func (imp *bookImporter) run(p bookImportPlan, mode string) (*LibraryImportItem, *LibraryImportFailure) {
|
||||
b := p.book
|
||||
|
||||
in, err := buildImportInput(&b)
|
||||
if err != nil {
|
||||
return nil, imp.fail(p, libraryImportReasonInvalid, err.Error())
|
||||
}
|
||||
sections, err := buildImportSections(&b)
|
||||
if err != nil {
|
||||
return nil, imp.fail(p, libraryImportReasonInvalid, err.Error())
|
||||
}
|
||||
if err := imp.checkPlannedFiles(p); err != nil {
|
||||
return nil, imp.fail(p, libraryImportReasonInvalid, err.Error())
|
||||
}
|
||||
coverEntry, err := imp.planCover(p)
|
||||
if err != nil {
|
||||
return nil, imp.fail(p, libraryImportReasonInvalid, err.Error())
|
||||
}
|
||||
|
||||
active, deleted, err := imp.s.slugTaken(b.Slug, 0)
|
||||
if err != nil {
|
||||
return nil, imp.fail(p, libraryImportReasonFailed, "查询条目失败")
|
||||
}
|
||||
|
||||
var docID uint
|
||||
action := "created"
|
||||
var oldFiles []model.LibraryFile // overwrite 时待删磁盘附件
|
||||
var oldCoverDisk string // overwrite 时待删旧封面(仅本地 /uploads)
|
||||
backfillCreator := false // overwrite 且原条目无创建者时补记导入操作人
|
||||
switch {
|
||||
case active && mode != LibraryImportModeOverwrite:
|
||||
return nil, imp.fail(p, libraryImportReasonConflict, "slug 已被在用条目占用:"+b.Slug)
|
||||
case active:
|
||||
var existing model.LibraryDoc
|
||||
if err := imp.s.db.Where("slug = ?", b.Slug).First(&existing).Error; err != nil {
|
||||
return nil, imp.fail(p, libraryImportReasonFailed, "读取原条目失败")
|
||||
}
|
||||
docID = existing.ID
|
||||
// 跨站恢复不导出创建者:原条目无创建者时把本次导入操作人补为创建者
|
||||
backfillCreator = existing.CreatorID == 0
|
||||
_ = imp.s.db.Where("doc_id = ?", docID).Find(&oldFiles).Error
|
||||
if disk, ok := imp.s.resolveCoverPath(existing.CoverURL); ok {
|
||||
oldCoverDisk = disk
|
||||
}
|
||||
action = "overwritten"
|
||||
case deleted:
|
||||
return nil, imp.fail(p, libraryImportReasonDeleted,
|
||||
"该地址被已删除条目占用,请先在书库管理中彻底删除后再导入:"+b.Slug)
|
||||
}
|
||||
|
||||
// 内嵌封面先落盘(URL 在事务前确定;create 失败时随条目回滚删除)
|
||||
coverDisk := ""
|
||||
if coverEntry != nil {
|
||||
url, w, h, disk, ferr := imp.materializeCover(coverEntry)
|
||||
if ferr != nil {
|
||||
return nil, imp.fail(p, libraryImportReasonFailed, "封面恢复失败:"+ferr.Error())
|
||||
}
|
||||
in.CoverURL, in.CoverWidth, in.CoverHeight, coverDisk = url, w, h, disk
|
||||
}
|
||||
// 无内嵌封面时保留清单原值(外链或原站 /uploads URL)与尺寸
|
||||
|
||||
txErr := imp.s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if action == "created" {
|
||||
d := &model.LibraryDoc{Published: false, SortOrder: 0, CreatorID: imp.userID}
|
||||
in.applyTo(d)
|
||||
if err := tx.Select(libraryDocWriteFields).Create(d).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
docID = d.ID
|
||||
} else {
|
||||
var d model.LibraryDoc
|
||||
if err := tx.First(&d, docID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
in.applyTo(&d)
|
||||
updates := map[string]interface{}{
|
||||
"slug": d.Slug,
|
||||
"title": d.Title,
|
||||
"author": d.Author,
|
||||
"description": d.Description,
|
||||
"cover_url": d.CoverURL,
|
||||
"cover_width": d.CoverWidth,
|
||||
"cover_height": d.CoverHeight,
|
||||
"published": d.Published,
|
||||
"sort_order": d.SortOrder,
|
||||
"entries_auto": d.EntriesAuto,
|
||||
}
|
||||
if backfillCreator {
|
||||
updates["creator_id"] = imp.userID
|
||||
}
|
||||
if err := tx.Model(&model.LibraryDoc{}).Where("id = ?", d.ID).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Unscoped().Where("doc_id = ?", docID).Delete(&model.LibrarySection{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Unscoped().Where("doc_id = ?", docID).Delete(&model.LibraryFile{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return imp.createSections(tx, docID, sections)
|
||||
})
|
||||
if txErr != nil {
|
||||
if action == "created" && coverDisk != "" {
|
||||
_ = os.Remove(coverDisk)
|
||||
}
|
||||
return nil, imp.fail(p, libraryImportReasonFailed, "写入数据库失败:"+txErr.Error())
|
||||
}
|
||||
|
||||
// overwrite 事务成功后清理旧附件与旧封面磁盘文件(失败不影响结果)
|
||||
for i := range oldFiles {
|
||||
_ = os.Remove(imp.s.FilePath(&oldFiles[i]))
|
||||
}
|
||||
if oldCoverDisk != "" && oldCoverDisk != coverDisk {
|
||||
_ = os.Remove(oldCoverDisk)
|
||||
}
|
||||
|
||||
// 附件落盘。create 阶段失败需硬删新建条目以释放 slug;overwrite 仅报告失败
|
||||
added := 0
|
||||
var newFiles []model.LibraryFile
|
||||
for _, mf := range b.Files {
|
||||
if mf.Missing || mf.Stored == "" {
|
||||
continue // 导出时磁盘已丢失的附件:清单保留记录但无文件可恢复
|
||||
}
|
||||
f, fail := imp.addPlannedFile(p, docID, mf)
|
||||
if fail != nil {
|
||||
if action == "created" {
|
||||
imp.rollbackCreated(docID, newFiles, coverDisk)
|
||||
}
|
||||
return nil, fail
|
||||
}
|
||||
newFiles = append(newFiles, *f)
|
||||
added++
|
||||
}
|
||||
|
||||
return &LibraryImportItem{
|
||||
Slug: b.Slug,
|
||||
Title: b.Title,
|
||||
Action: action,
|
||||
Sections: len(sections),
|
||||
Files: added,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ---------- 清单解析与预检 ----------
|
||||
|
||||
func buildImportInput(b *libraryExportBook) (*LibraryInput, error) {
|
||||
pub, sortOrder, entriesAuto := b.Published, b.SortOrder, b.EntriesAuto
|
||||
in := &LibraryInput{
|
||||
Slug: b.Slug,
|
||||
Title: b.Title,
|
||||
Author: b.Author,
|
||||
Description: b.Description,
|
||||
CoverURL: b.CoverURL,
|
||||
CoverWidth: b.CoverWidth,
|
||||
CoverHeight: b.CoverHeight,
|
||||
Published: &pub,
|
||||
SortOrder: &sortOrder,
|
||||
EntriesAuto: &entriesAuto,
|
||||
}
|
||||
if err := in.normalize(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
|
||||
// buildImportSections 两轮解析:先章(编号)后节(按父分组编号),校验标题/正文/父引用
|
||||
func buildImportSections(b *libraryExportBook) ([]plannedSection, error) {
|
||||
if len(b.Sections) > MaxSectionsPerDoc {
|
||||
return nil, errors.New("章节数量超过上限(最多 200)")
|
||||
}
|
||||
keySeen := map[string]bool{}
|
||||
out := make([]plannedSection, 0, len(b.Sections))
|
||||
|
||||
chapterSort := map[string]int{}
|
||||
order := 0
|
||||
for _, sec := range b.Sections {
|
||||
if sec.ParentKey != "" {
|
||||
continue
|
||||
}
|
||||
title := strings.TrimSpace(sec.Title)
|
||||
if title == "" {
|
||||
return nil, errors.New("存在标题为空的章节")
|
||||
}
|
||||
if err := validateSectionText(title, sec.Content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if keySeen[sec.Key] {
|
||||
return nil, errors.New("章节标识重复:" + sec.Key)
|
||||
}
|
||||
keySeen[sec.Key] = true
|
||||
chapterSort[sec.Key] = order
|
||||
out = append(out, plannedSection{
|
||||
key: sec.Key, title: title, content: sec.Content, sort: order,
|
||||
})
|
||||
order++
|
||||
}
|
||||
|
||||
childCount := map[string]int{}
|
||||
for _, sec := range b.Sections {
|
||||
if sec.ParentKey == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := chapterSort[sec.ParentKey]; !ok {
|
||||
return nil, errors.New("小节「" + strings.TrimSpace(sec.Title) + "」找不到所属章节")
|
||||
}
|
||||
title := strings.TrimSpace(sec.Title)
|
||||
if title == "" {
|
||||
return nil, errors.New("存在标题为空的小节")
|
||||
}
|
||||
if err := validateSectionText(title, sec.Content); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, plannedSection{
|
||||
key: sec.Key,
|
||||
parentKey: sec.ParentKey,
|
||||
title: title,
|
||||
content: sec.Content,
|
||||
sort: childCount[sec.ParentKey],
|
||||
})
|
||||
childCount[sec.ParentKey]++
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validateSectionText(title, content string) error {
|
||||
if len([]rune(title)) > 200 {
|
||||
return errors.New("章节标题不能超过 200 字:" + title)
|
||||
}
|
||||
if len([]rune(content)) > MaxSectionContent {
|
||||
return errors.New("章节正文不能超过 100000 字:" + title)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkPlannedFiles 预检附件:数量、扩展名、ZIP 路径、声明大小、包内是否存在
|
||||
func (imp *bookImporter) checkPlannedFiles(p bookImportPlan) error {
|
||||
if len(p.book.Files) > MaxLibraryFilesPerDoc {
|
||||
return errors.New("附件数量超过上限(最多 20 个)")
|
||||
}
|
||||
maxB := imp.s.maxBytes()
|
||||
for _, f := range p.book.Files {
|
||||
if f.Missing || f.Stored == "" {
|
||||
continue
|
||||
}
|
||||
rel, ok := cleanZipRel(f.Stored)
|
||||
if !ok || !strings.HasPrefix(rel, "files/") {
|
||||
return errors.New("附件路径非法:" + f.Stored)
|
||||
}
|
||||
name := sanitizeFilename(f.Name)
|
||||
if name == "" || !LibraryExtAllowed(ExtOfFilename(name)) {
|
||||
return errors.New("附件格式不受支持:" + f.Name)
|
||||
}
|
||||
zf, ok := imp.entries[p.dir+rel]
|
||||
if !ok {
|
||||
return errors.New("备份包缺少附件文件:" + f.Name)
|
||||
}
|
||||
if zf.UncompressedSize64 > uint64(maxB) {
|
||||
return errors.New("附件超过大小上限:" + f.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// planCover 返回内嵌封面 ZIP 条目(无则 nil)
|
||||
func (imp *bookImporter) planCover(p bookImportPlan) (*zip.File, error) {
|
||||
cf := strings.TrimSpace(p.book.CoverFile)
|
||||
if cf == "" {
|
||||
return nil, nil
|
||||
}
|
||||
rel, ok := cleanZipRel(cf)
|
||||
if !ok || strings.Contains(rel, "/") {
|
||||
return nil, errors.New("封面路径非法:" + cf)
|
||||
}
|
||||
zf, ok := imp.entries[p.dir+rel]
|
||||
if !ok {
|
||||
return nil, errors.New("备份包缺少封面文件")
|
||||
}
|
||||
if zf.UncompressedSize64 > uint64(ImageMaxBytes) {
|
||||
return nil, errors.New("封面不能超过 5MB")
|
||||
}
|
||||
return zf, nil
|
||||
}
|
||||
|
||||
// ---------- 落盘 ----------
|
||||
|
||||
// createSections 两趟写入:先建章(key→新ID),再建节(父引用映射后的章 ID)
|
||||
func (imp *bookImporter) createSections(tx *gorm.DB, docID uint, sections []plannedSection) error {
|
||||
keyToID := make(map[string]uint, len(sections))
|
||||
for i := range sections {
|
||||
ps := §ions[i]
|
||||
if ps.parentKey != "" {
|
||||
continue
|
||||
}
|
||||
sec := &model.LibrarySection{DocID: docID, Title: ps.title, Content: ps.content, SortOrder: ps.sort}
|
||||
if err := tx.Create(sec).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
keyToID[ps.key] = sec.ID
|
||||
}
|
||||
for i := range sections {
|
||||
ps := §ions[i]
|
||||
if ps.parentKey == "" {
|
||||
continue
|
||||
}
|
||||
parentID, ok := keyToID[ps.parentKey]
|
||||
if !ok {
|
||||
return errors.New("小节找不到所属章节")
|
||||
}
|
||||
sec := &model.LibrarySection{
|
||||
DocID: docID, ParentID: &parentID,
|
||||
Title: ps.title, Content: ps.content, SortOrder: ps.sort,
|
||||
}
|
||||
if err := tx.Create(sec).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// addPlannedFile 从 ZIP 读取附件并复用 AddFile 落盘,随后恢复下载计数与排序
|
||||
func (imp *bookImporter) addPlannedFile(
|
||||
p bookImportPlan, docID uint, mf libraryExportFile,
|
||||
) (*model.LibraryFile, *LibraryImportFailure) {
|
||||
rel, _ := cleanZipRel(mf.Stored)
|
||||
zf, ok := imp.entries[p.dir+rel]
|
||||
if !ok {
|
||||
return nil, imp.fail(p, libraryImportReasonFailed, "备份包缺少附件文件:"+mf.Name)
|
||||
}
|
||||
rc, err := zf.Open()
|
||||
if err != nil {
|
||||
return nil, imp.fail(p, libraryImportReasonFailed, "读取附件失败:"+mf.Name)
|
||||
}
|
||||
defer rc.Close()
|
||||
f, err := imp.s.AddFile(docID, imp.userID, mf.Name, io.LimitReader(rc, imp.s.maxBytes()+1))
|
||||
if err != nil {
|
||||
return nil, imp.fail(p, libraryImportReasonFailed, "附件恢复失败「"+mf.Name+"」:"+err.Error())
|
||||
}
|
||||
if err := imp.s.db.Model(&model.LibraryFile{}).Where("id = ?", f.ID).
|
||||
UpdateColumns(map[string]interface{}{
|
||||
"download_count": mf.DownloadCount,
|
||||
"sort_order": mf.SortOrder,
|
||||
}).Error; err != nil {
|
||||
return nil, imp.fail(p, libraryImportReasonFailed, "附件信息写入失败:"+mf.Name)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// materializeCover 校验图片魔数后以随机名落盘 uploads/images,返回 URL/尺寸/磁盘路径
|
||||
func (imp *bookImporter) materializeCover(zf *zip.File) (string, int, int, string, error) {
|
||||
if imp.s.uploadsDir == "" {
|
||||
return "", 0, 0, "", errors.New("未配置上传目录")
|
||||
}
|
||||
rc, err := zf.Open()
|
||||
if err != nil {
|
||||
return "", 0, 0, "", err
|
||||
}
|
||||
defer rc.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(rc, ImageMaxBytes+1))
|
||||
if err != nil {
|
||||
return "", 0, 0, "", err
|
||||
}
|
||||
if int64(len(data)) > ImageMaxBytes {
|
||||
return "", 0, 0, "", errors.New("封面不能超过 5MB")
|
||||
}
|
||||
format, err := detectImageFormat(data)
|
||||
if err != nil {
|
||||
return "", 0, 0, "", err
|
||||
}
|
||||
width, height, err := decodeImageSizeReader(bytes.NewReader(data), format.mime)
|
||||
if err != nil || width < 1 || height < 1 {
|
||||
return "", 0, 0, "", errors.New("无法解析封面图片")
|
||||
}
|
||||
if width > ImageMaxDim || height > ImageMaxDim {
|
||||
return "", 0, 0, "", errors.New("封面边长不能超过 4096px")
|
||||
}
|
||||
nameBytes := make([]byte, 16)
|
||||
if _, err := rand.Read(nameBytes); err != nil {
|
||||
return "", 0, 0, "", err
|
||||
}
|
||||
filename := hex.EncodeToString(nameBytes) + format.ext
|
||||
imagesDir := filepath.Join(imp.s.uploadsDir, "images")
|
||||
if err := os.MkdirAll(imagesDir, 0o755); err != nil {
|
||||
return "", 0, 0, "", err
|
||||
}
|
||||
full := filepath.Join(imagesDir, filename)
|
||||
if err := os.WriteFile(full, data, 0o644); err != nil {
|
||||
return "", 0, 0, "", err
|
||||
}
|
||||
return "/uploads/images/" + filename, width, height, full, nil
|
||||
}
|
||||
|
||||
// rollbackCreated create 模式落盘阶段失败:硬删条目/章节/文件行与已落盘文件,释放 slug
|
||||
func (imp *bookImporter) rollbackCreated(docID uint, newFiles []model.LibraryFile, coverDisk string) {
|
||||
_ = imp.s.db.Unscoped().Where("doc_id = ?", docID).Delete(&model.LibrarySection{}).Error
|
||||
_ = imp.s.db.Unscoped().Where("doc_id = ?", docID).Delete(&model.LibraryFile{}).Error
|
||||
_ = imp.s.db.Unscoped().Delete(&model.LibraryDoc{}, docID).Error
|
||||
for i := range newFiles {
|
||||
_ = os.Remove(imp.s.FilePath(&newFiles[i]))
|
||||
}
|
||||
if coverDisk != "" {
|
||||
_ = os.Remove(coverDisk)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- ZIP 工具 ----------
|
||||
|
||||
// cleanZipRel 校验 ZIP 内相对路径:拒绝绝对路径与任何 ../ 穿越段
|
||||
func cleanZipRel(rel string) (string, bool) {
|
||||
p := path.Clean(strings.ReplaceAll(strings.TrimSpace(rel), "\\", "/"))
|
||||
if p == "." || p == "" || path.IsAbs(p) || p == ".." {
|
||||
return "", false
|
||||
}
|
||||
for _, seg := range strings.Split(p, "/") {
|
||||
if seg == ".." {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
return p, true
|
||||
}
|
||||
|
||||
func readZipJSON(zf *zip.File, v any) error {
|
||||
rc, err := zf.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rc.Close()
|
||||
return json.NewDecoder(rc).Decode(v)
|
||||
}
|
||||
354
backend/service/library_import_test.go
Normal file
354
backend/service/library_import_test.go
Normal file
@@ -0,0 +1,354 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"errors"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
)
|
||||
|
||||
func encodeJPEGBytes(t *testing.T, w, h int) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, image.NewRGBA(image.Rect(0, 0, w, h)), &jpeg.Options{Quality: 80}); err != nil {
|
||||
t.Fatalf("encode jpeg: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// writeExportZipFile 把内存导出包落盘为临时 zip(模拟 handler 上传后的临时文件)
|
||||
func writeExportZipFile(t *testing.T, exp *LibraryExport) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), "export.zip")
|
||||
f, err := os.Create(p)
|
||||
if err != nil {
|
||||
t.Fatalf("create temp zip: %v", err)
|
||||
}
|
||||
if err := exp.WriteZip(f); err != nil {
|
||||
t.Fatalf("write export zip: %v", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatalf("close temp zip: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// writeZipFileMap 手工构造 ZIP(用于非法包测试)
|
||||
func writeZipFileMap(t *testing.T, entries map[string]string) string {
|
||||
t.Helper()
|
||||
buf := new(bytes.Buffer)
|
||||
zw := zip.NewWriter(buf)
|
||||
for name, content := range entries {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("zip create %s: %v", name, err)
|
||||
}
|
||||
if _, err := io.WriteString(w, content); err != nil {
|
||||
t.Fatalf("zip write %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("zip close: %v", err)
|
||||
}
|
||||
p := filepath.Join(t.TempDir(), "in.zip")
|
||||
if err := os.WriteFile(p, buf.Bytes(), 0o644); err != nil {
|
||||
t.Fatalf("write temp zip: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func assertReportOK(t *testing.T, rep *LibraryImportReport, total int) {
|
||||
t.Helper()
|
||||
if rep == nil {
|
||||
t.Fatal("报告为空")
|
||||
}
|
||||
if rep.Total != total {
|
||||
t.Fatalf("total = %d, want %d", rep.Total, total)
|
||||
}
|
||||
if len(rep.Failed) != 0 {
|
||||
t.Fatalf("存在失败记录: %+v", rep.Failed)
|
||||
}
|
||||
if len(rep.Imported) != total {
|
||||
t.Fatalf("imported = %d, want %d", len(rep.Imported), total)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLibraryImportBookRoundTrip 单本包:导出 → 导入 → 冲突跳过 → 覆盖导入
|
||||
func TestLibraryImportBookRoundTrip(t *testing.T) {
|
||||
// ---- 源库 ----
|
||||
src, _ := newLibraryTestService(t)
|
||||
srcUploads := t.TempDir()
|
||||
src.WithUploadsDir(srcUploads)
|
||||
docID, storedName := seedBookForExport(t, src, srcUploads)
|
||||
|
||||
// 制造两次下载,验证计数随包保留
|
||||
var srcFile model.LibraryFile
|
||||
if err := src.db.Where("stored_name = ?", storedName).First(&srcFile).Error; err != nil {
|
||||
t.Fatalf("find source file: %v", err)
|
||||
}
|
||||
src.IncrDownload(srcFile.ID)
|
||||
src.IncrDownload(srcFile.ID)
|
||||
|
||||
exp, err := src.BuildBookExport(docID)
|
||||
if err != nil {
|
||||
t.Fatalf("build export: %v", err)
|
||||
}
|
||||
zipPath := writeExportZipFile(t, exp)
|
||||
exp.Close()
|
||||
|
||||
// ---- 目标库(空库)----
|
||||
dst, _ := newLibraryTestService(t)
|
||||
dstUploads := t.TempDir()
|
||||
dst.WithUploadsDir(dstUploads)
|
||||
|
||||
// 1) create 导入
|
||||
rep, err := dst.ImportLibraryZip(zipPath, LibraryImportModeCreate, 7)
|
||||
if err != nil {
|
||||
t.Fatalf("import: %v", err)
|
||||
}
|
||||
assertReportOK(t, rep, 1)
|
||||
item := rep.Imported[0]
|
||||
if item.Slug != "dao-jia" || item.Action != "created" || item.Sections != 2 || item.Files != 1 {
|
||||
t.Fatalf("导入条目异常: %+v", item)
|
||||
}
|
||||
|
||||
var doc model.LibraryDoc
|
||||
if err := dst.db.Where("slug = ?", "dao-jia").First(&doc).Error; err != nil {
|
||||
t.Fatalf("find imported doc: %v", err)
|
||||
}
|
||||
if doc.Title != "道家研究" || doc.Description != "# 介绍" || !doc.Published {
|
||||
t.Fatalf("元信息恢复不正确: %+v", doc)
|
||||
}
|
||||
if doc.Author != "老子" {
|
||||
t.Fatalf("作者恢复不正确: %q", doc.Author)
|
||||
}
|
||||
if doc.CreatorID != 7 {
|
||||
t.Fatalf("新建导入创建者应为导入操作人,got %d", doc.CreatorID)
|
||||
}
|
||||
if !strings.HasPrefix(doc.CoverURL, "/uploads/images/") || doc.CoverURL == "/uploads/images/cover.jpg" {
|
||||
t.Fatalf("封面 URL 应为重新落盘的随机名,got %q", doc.CoverURL)
|
||||
}
|
||||
if doc.CoverWidth != 120 || doc.CoverHeight != 160 {
|
||||
t.Fatalf("封面尺寸恢复不正确: %dx%d", doc.CoverWidth, doc.CoverHeight)
|
||||
}
|
||||
coverDisk, ok := dst.resolveCoverPath(doc.CoverURL)
|
||||
if !ok {
|
||||
t.Fatalf("封面路径无法解析: %s", doc.CoverURL)
|
||||
}
|
||||
coverData, err := os.ReadFile(coverDisk)
|
||||
if err != nil {
|
||||
t.Fatalf("封面未落盘: %v", err)
|
||||
}
|
||||
if !bytes.HasPrefix(coverData, []byte{0xff, 0xd8, 0xff}) {
|
||||
t.Fatal("封面内容不是 JPEG")
|
||||
}
|
||||
|
||||
// 章节树:章 + 小节父子关系
|
||||
secs, err := dst.ListSections(doc.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list sections: %v", err)
|
||||
}
|
||||
if len(secs) != 2 {
|
||||
t.Fatalf("章节数 = %d, want 2", len(secs))
|
||||
}
|
||||
var chapter, child *model.LibrarySection
|
||||
for i := range secs {
|
||||
if secs[i].ParentID == nil {
|
||||
chapter = &secs[i]
|
||||
} else {
|
||||
child = &secs[i]
|
||||
}
|
||||
}
|
||||
if chapter == nil || child == nil {
|
||||
t.Fatalf("章节层级不正确: %+v", secs)
|
||||
}
|
||||
if chapter.Title != "第一章" || chapter.SortOrder != 0 {
|
||||
t.Fatalf("章信息不正确: %+v", chapter)
|
||||
}
|
||||
if child.Title != "小节" || *child.ParentID != chapter.ID || child.SortOrder != 0 {
|
||||
t.Fatalf("小节信息不正确: %+v", child)
|
||||
}
|
||||
|
||||
// 附件:重新随机落盘、内容与下载计数保留
|
||||
var files []model.LibraryFile
|
||||
if err := dst.db.Where("doc_id = ?", doc.ID).Find(&files).Error; err != nil {
|
||||
t.Fatalf("list files: %v", err)
|
||||
}
|
||||
if len(files) != 1 {
|
||||
t.Fatalf("附件数 = %d, want 1", len(files))
|
||||
}
|
||||
nf := files[0]
|
||||
if nf.Name != "manual.epub" || nf.DownloadCount != 2 || nf.StoredName == srcFile.StoredName {
|
||||
t.Fatalf("附件恢复不正确: %+v", nf)
|
||||
}
|
||||
data, err := os.ReadFile(dst.FilePath(&nf))
|
||||
if err != nil {
|
||||
t.Fatalf("附件未落盘: %v", err)
|
||||
}
|
||||
if string(data) != "EPUB-FILE-BYTES" {
|
||||
t.Fatalf("附件内容不正确: %q", data)
|
||||
}
|
||||
firstCoverURL := doc.CoverURL
|
||||
|
||||
// 2) 再次 create:冲突跳过
|
||||
rep2, err := dst.ImportLibraryZip(zipPath, LibraryImportModeCreate, 7)
|
||||
if err != nil {
|
||||
t.Fatalf("re-import: %v", err)
|
||||
}
|
||||
if rep2.Total != 1 || len(rep2.Imported) != 0 || len(rep2.Failed) != 1 {
|
||||
t.Fatalf("冲突报告异常: %+v", rep2)
|
||||
}
|
||||
if rep2.Failed[0].Reason != libraryImportReasonConflict {
|
||||
t.Fatalf("失败原因应为 conflict,got %q", rep2.Failed[0].Reason)
|
||||
}
|
||||
var docCount int64
|
||||
dst.db.Model(&model.LibraryDoc{}).Count(&docCount)
|
||||
if docCount != 1 {
|
||||
t.Fatalf("冲突跳过后书库数量异常: %d", docCount)
|
||||
}
|
||||
|
||||
// 3) overwrite:先加一个计划外附件与旧封面,导入后应被整体替换
|
||||
extra, err := dst.AddFile(doc.ID, 7, "extra.txt", bytes.NewReader([]byte("EXTRA-BYTES")))
|
||||
if err != nil {
|
||||
t.Fatalf("add extra: %v", err)
|
||||
}
|
||||
extraPath := dst.FilePath(extra)
|
||||
if _, err := os.Stat(extraPath); err != nil {
|
||||
t.Fatalf("extra 未先落盘: %v", err)
|
||||
}
|
||||
|
||||
rep3, err := dst.ImportLibraryZip(zipPath, LibraryImportModeOverwrite, 7)
|
||||
if err != nil {
|
||||
t.Fatalf("overwrite import: %v", err)
|
||||
}
|
||||
assertReportOK(t, rep3, 1)
|
||||
if rep3.Imported[0].Action != "overwritten" {
|
||||
t.Fatalf("动作应为 overwritten,got %q", rep3.Imported[0].Action)
|
||||
}
|
||||
|
||||
dst.db.Model(&model.LibraryDoc{}).Count(&docCount)
|
||||
if docCount != 1 {
|
||||
t.Fatalf("覆盖后书库数量异常: %d", docCount)
|
||||
}
|
||||
var doc2 model.LibraryDoc
|
||||
if err := dst.db.Where("slug = ?", "dao-jia").First(&doc2).Error; err != nil {
|
||||
t.Fatalf("find overwritten doc: %v", err)
|
||||
}
|
||||
if secs2, err := dst.ListSections(doc2.ID); err != nil || len(secs2) != 2 {
|
||||
t.Fatalf("覆盖后章节异常: %d %v", len(secs2), err)
|
||||
}
|
||||
if doc2.Author != "老子" || doc2.CreatorID != 7 {
|
||||
t.Fatalf("覆盖后作者/创建者异常: author=%q creator=%d", doc2.Author, doc2.CreatorID)
|
||||
}
|
||||
var files2 []model.LibraryFile
|
||||
if err := dst.db.Where("doc_id = ?", doc2.ID).Find(&files2).Error; err != nil || len(files2) != 1 {
|
||||
t.Fatalf("覆盖后附件数量异常: %d %v", len(files2), err)
|
||||
}
|
||||
if files2[0].Name != "manual.epub" || files2[0].DownloadCount != 2 {
|
||||
t.Fatalf("覆盖后附件内容异常: %+v", files2[0])
|
||||
}
|
||||
if _, err := os.Stat(extraPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("旧附件磁盘文件应被删除,err=%v", err)
|
||||
}
|
||||
oldCoverDisk, _ := dst.resolveCoverPath(firstCoverURL)
|
||||
if _, err := os.Stat(oldCoverDisk); !os.IsNotExist(err) {
|
||||
t.Fatalf("旧封面磁盘文件应被删除,err=%v", err)
|
||||
}
|
||||
if doc2.CoverURL == firstCoverURL {
|
||||
t.Fatal("覆盖后封面应重新落盘为新文件")
|
||||
}
|
||||
newCoverDisk, _ := dst.resolveCoverPath(doc2.CoverURL)
|
||||
if _, err := os.ReadFile(newCoverDisk); err != nil {
|
||||
t.Fatalf("新封面未落盘: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLibraryImportAll 全库包往返
|
||||
func TestLibraryImportAll(t *testing.T) {
|
||||
src, _ := newLibraryTestService(t)
|
||||
srcUploads := t.TempDir()
|
||||
src.WithUploadsDir(srcUploads)
|
||||
if _, err := src.Create(&LibraryInput{Slug: "second-book", Title: "第二本"}); err != nil {
|
||||
t.Fatalf("create second doc: %v", err)
|
||||
}
|
||||
seedBookForExport(t, src, srcUploads)
|
||||
|
||||
exp, err := src.BuildAllExport()
|
||||
if err != nil {
|
||||
t.Fatalf("build all export: %v", err)
|
||||
}
|
||||
zipPath := writeExportZipFile(t, exp)
|
||||
exp.Close()
|
||||
|
||||
dst, _ := newLibraryTestService(t)
|
||||
dst.WithUploadsDir(t.TempDir())
|
||||
rep, err := dst.ImportLibraryZip(zipPath, LibraryImportModeCreate, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("import all: %v", err)
|
||||
}
|
||||
assertReportOK(t, rep, 2)
|
||||
slugs := map[string]bool{}
|
||||
for _, it := range rep.Imported {
|
||||
slugs[it.Slug] = true
|
||||
}
|
||||
if !slugs["dao-jia"] || !slugs["second-book"] {
|
||||
t.Fatalf("导入书集不正确: %+v", slugs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLibraryImportRejectBadPackage 非法包整体拒绝
|
||||
func TestLibraryImportRejectBadPackage(t *testing.T) {
|
||||
s, _ := newLibraryTestService(t)
|
||||
s.WithUploadsDir(t.TempDir())
|
||||
|
||||
badPath := filepath.Join(t.TempDir(), "bad.zip")
|
||||
if err := os.WriteFile(badPath, []byte("this is not a zip"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if _, err := s.ImportLibraryZip(badPath, LibraryImportModeCreate, 1); !errors.Is(err, ErrLibraryImportBadZip) {
|
||||
t.Fatalf("非 zip 应返回 ErrLibraryImportBadZip,got %v", err)
|
||||
}
|
||||
|
||||
noManifest := writeZipFileMap(t, map[string]string{"files/a.epub": "x"})
|
||||
if _, err := s.ImportLibraryZip(noManifest, LibraryImportModeCreate, 1); !errors.Is(err, ErrLibraryImportFormat) {
|
||||
t.Fatalf("缺清单应返回 ErrLibraryImportFormat,got %v", err)
|
||||
}
|
||||
|
||||
if _, err := s.ImportLibraryZip(badPath, "bogus", 1); !errors.Is(err, ErrLibraryImportMode) {
|
||||
t.Fatalf("非法模式应返回 ErrLibraryImportMode,got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanZipRel 路径穿越校验
|
||||
func TestCleanZipRel(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want bool
|
||||
}{
|
||||
{"", false},
|
||||
{".", false},
|
||||
{"..", false},
|
||||
{"../evil", false},
|
||||
{"a/../../b", false},
|
||||
{"/etc/passwd", false},
|
||||
{`docs\..\..\x`, false},
|
||||
{"files/a.epub", true},
|
||||
{"docs/x/book.json", true},
|
||||
{`docs\x/cover.jpg`, true},
|
||||
{"a//b", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
_, ok := cleanZipRel(tc.in)
|
||||
if ok != tc.want {
|
||||
t.Errorf("cleanZipRel(%q) = %v, want %v", tc.in, ok, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ func newLibraryTestService(t *testing.T) (*LibraryService, string) {
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.LibraryDoc{}, &model.LibraryFile{}, &model.LibrarySection{}); err != nil {
|
||||
if err := db.AutoMigrate(&model.User{}, &model.LibraryDoc{}, &model.LibraryFile{}, &model.LibrarySection{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
@@ -45,6 +45,7 @@ func TestLibraryNormalize(t *testing.T) {
|
||||
{"标题为空", func(i *LibraryInput) { i.Title = " " }, "标题不能为空"},
|
||||
{"标题超限", func(i *LibraryInput) { i.Title = strings.Repeat("书", 201) }, "标题不能超过"},
|
||||
{"介绍超限", func(i *LibraryInput) { i.Description = strings.Repeat("介", 20001) }, "介绍不能超过"},
|
||||
{"作者超限", func(i *LibraryInput) { i.Author = strings.Repeat("作", 101) }, "作者不能超过"},
|
||||
{"合法", func(i *LibraryInput) {}, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
@@ -63,6 +64,58 @@ func TestLibraryNormalize(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibraryAuthorAndCreator(t *testing.T) {
|
||||
s, _ := newLibraryTestService(t)
|
||||
user := &model.User{Username: "curator", Nickname: "馆长", Avatar: "/a.png"}
|
||||
if err := s.db.Create(user).Error; err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
pub := true
|
||||
doc, err := s.Create(&LibraryInput{
|
||||
Slug: "authored", Title: "署名本", Author: " 鲁迅 ", Published: &pub,
|
||||
}, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if doc.Author != "鲁迅" || doc.CreatorID != user.ID {
|
||||
t.Fatalf("作者应裁剪、创建者应落库,got %+v", doc)
|
||||
}
|
||||
|
||||
list, err := s.ListPublished()
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(list) != 1 || list[0].Author != "鲁迅" {
|
||||
t.Fatalf("列表作者异常: %+v", list)
|
||||
}
|
||||
if c := list[0].Creator; c == nil || c.ID != user.ID || c.Nickname != "馆长" {
|
||||
t.Fatalf("列表创建者摘要异常: %+v", c)
|
||||
}
|
||||
|
||||
detail, err := s.GetPublishedBySlug("authored")
|
||||
if err != nil {
|
||||
t.Fatalf("detail: %v", err)
|
||||
}
|
||||
if detail.Creator == nil || detail.Creator.Username != "curator" {
|
||||
t.Fatalf("详情创建者摘要异常: %+v", detail.Creator)
|
||||
}
|
||||
|
||||
// 作者可清空;更新不改变创建者归属
|
||||
if _, err := s.Update(doc.ID, &LibraryInput{
|
||||
Slug: "authored", Title: "署名本", Author: "", Published: &pub,
|
||||
}); err != nil {
|
||||
t.Fatalf("update: %v", err)
|
||||
}
|
||||
var again model.LibraryDoc
|
||||
if err := s.db.First(&again, doc.ID).Error; err != nil {
|
||||
t.Fatalf("reload: %v", err)
|
||||
}
|
||||
if again.Author != "" || again.CreatorID != user.ID {
|
||||
t.Fatalf("更新后作者/创建者异常: %+v", again)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibrarySlugUnique(t *testing.T) {
|
||||
s, _ := newLibraryTestService(t)
|
||||
if _, err := s.Create(&LibraryInput{Slug: "epub-latest", Title: "A"}); err != nil {
|
||||
|
||||
@@ -30,6 +30,27 @@ var mediaLibraryCategories = []struct {
|
||||
{"brand", "brand", "品牌资源"},
|
||||
}
|
||||
|
||||
// 衍生分类:不对应 uploads 子目录,按附件 source 归类
|
||||
const (
|
||||
mediaCategoryBook = "book"
|
||||
mediaCategoryBookName = "书籍"
|
||||
)
|
||||
|
||||
// mediaCategoryForAttachment 附件在媒体库中的展示分类:
|
||||
// 书库封面 / 正文插图(source=library_cover|library_content)统一归“书籍”,
|
||||
// 其余按 kind 对应磁盘目录分类。
|
||||
func mediaCategoryForAttachment(kind, source string) (key, name string) {
|
||||
if source == model.AttachmentSourceLibraryCover || source == model.AttachmentSourceLibraryContent {
|
||||
return mediaCategoryBook, mediaCategoryBookName
|
||||
}
|
||||
for _, cat := range mediaLibraryCategories {
|
||||
if cat.Key == kind {
|
||||
return cat.Key, cat.Name
|
||||
}
|
||||
}
|
||||
return kind, kind
|
||||
}
|
||||
|
||||
// MediaLibraryItem 媒体库条目
|
||||
type MediaLibraryItem struct {
|
||||
URL string `json:"url"` // 展示地址(有 WebP 时为 WebP)
|
||||
@@ -53,6 +74,7 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
|
||||
// 附件元数据索引:URL → 记录(联出上传者昵称)
|
||||
type attMeta struct {
|
||||
Kind string
|
||||
Source string
|
||||
URL string
|
||||
MIME string
|
||||
Size int
|
||||
@@ -64,7 +86,7 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
|
||||
}
|
||||
var rows []attMeta
|
||||
if err := s.db.Table("attachments").
|
||||
Select("attachments.kind, attachments.url, attachments.mime, attachments.size, attachments.width, attachments.height, attachments.created_at, users.nickname, users.username").
|
||||
Select("attachments.kind, attachments.source, attachments.url, attachments.mime, attachments.size, attachments.width, attachments.height, attachments.created_at, users.nickname, users.username").
|
||||
Joins("LEFT JOIN users ON users.id = attachments.user_id").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, nil, err
|
||||
@@ -72,30 +94,22 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
|
||||
metaByURL := make(map[string]attMeta, len(rows))
|
||||
// 远程存储对象(/api/media/)不在磁盘上,直接作为条目加入
|
||||
items := make([]MediaLibraryItem, 0, len(rows))
|
||||
categoryName := func(key string) string {
|
||||
for _, cat := range mediaLibraryCategories {
|
||||
if cat.Key == key {
|
||||
return cat.Name
|
||||
}
|
||||
}
|
||||
return key
|
||||
}
|
||||
for _, r := range rows {
|
||||
metaByURL[r.URL] = r // 本地文件在磁盘扫描时按 URL 合并元数据
|
||||
if !strings.HasPrefix(r.URL, "/api/media/") {
|
||||
continue
|
||||
}
|
||||
if r.Kind != model.AttachmentKindImage && r.Kind != model.AttachmentKindAvatar {
|
||||
continue // 未知 kind 不进媒体库
|
||||
}
|
||||
uploader := r.Nickname
|
||||
if uploader == "" {
|
||||
uploader = r.Username
|
||||
}
|
||||
cat := r.Kind
|
||||
if cat != model.AttachmentKindImage && cat != model.AttachmentKindAvatar {
|
||||
continue // 未知 kind 不进媒体库
|
||||
}
|
||||
catKey, catName := mediaCategoryForAttachment(r.Kind, r.Source)
|
||||
ca := r.CreatedAt
|
||||
items = append(items, MediaLibraryItem{
|
||||
URL: r.URL, Category: cat, CategoryName: categoryName(cat),
|
||||
URL: r.URL, Category: catKey, CategoryName: catName,
|
||||
Name: strings.TrimPrefix(r.URL, "/api/media/"), MIME: r.MIME,
|
||||
Size: int64(r.Size), Width: r.Width, Height: r.Height,
|
||||
Uploader: uploader, UploadedAt: &ca,
|
||||
@@ -153,6 +167,11 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
|
||||
if m.Width > 0 {
|
||||
item.Width, item.Height = m.Width, m.Height
|
||||
}
|
||||
// 书库封面 / 正文插图改归“书籍”分类
|
||||
if m.Source == model.AttachmentSourceLibraryCover || m.Source == model.AttachmentSourceLibraryContent {
|
||||
item.Category = mediaCategoryBook
|
||||
item.CategoryName = mediaCategoryBookName
|
||||
}
|
||||
}
|
||||
// 无尺寸记录时读图片头解析宽高(失败不阻断,保持 0)
|
||||
if item.Width == 0 && item.Size > 0 && item.Size <= 20<<20 {
|
||||
@@ -243,6 +262,8 @@ func (s *UploadService) attachMediaSources(items []MediaLibraryItem) {
|
||||
postByURL := map[string]postRef{}
|
||||
type commentRef struct {
|
||||
id, postID uint
|
||||
floor uint
|
||||
isRoot bool
|
||||
}
|
||||
commentByURL := map[string]commentRef{}
|
||||
|
||||
@@ -299,6 +320,19 @@ func (s *UploadService) attachMediaSources(items []MediaLibraryItem) {
|
||||
}
|
||||
}
|
||||
|
||||
// 评论锚点改用所属帖内相对楼层号(#comment-{floor},楼中楼追加 -r{id})
|
||||
commentIDs := make([]uint, 0, len(commentByURL))
|
||||
for _, cm := range commentByURL {
|
||||
commentIDs = append(commentIDs, cm.id)
|
||||
}
|
||||
commentAnchors := CommentAnchors(s.db, commentIDs)
|
||||
for u, cm := range commentByURL {
|
||||
if a, ok := commentAnchors[cm.id]; ok {
|
||||
cm.floor, cm.isRoot = a.Floor, a.IsRoot
|
||||
commentByURL[u] = cm
|
||||
}
|
||||
}
|
||||
|
||||
// 评论来源要带宿主帖子标题,一次性补齐
|
||||
postTitle := func(id uint) string {
|
||||
var p model.Post
|
||||
@@ -374,7 +408,15 @@ func (s *UploadService) attachMediaSources(items []MediaLibraryItem) {
|
||||
break
|
||||
}
|
||||
if cm, ok := commentByURL[u]; ok {
|
||||
it.SourceURL = fmt.Sprintf("/post/%d#comment-%d", cm.postID, cm.id)
|
||||
if cm.floor > 0 {
|
||||
if cm.isRoot {
|
||||
it.SourceURL = fmt.Sprintf("/post/%d#comment-%d", cm.postID, cm.floor)
|
||||
} else {
|
||||
it.SourceURL = fmt.Sprintf("/post/%d#comment-%d-r%d", cm.postID, cm.floor, cm.id)
|
||||
}
|
||||
} else {
|
||||
it.SourceURL = fmt.Sprintf("/post/%d", cm.postID)
|
||||
}
|
||||
title := commentPostTitle[cm.postID]
|
||||
if title != "" {
|
||||
it.SourceLabel = "帖子《" + truncate(title, 40) + "》下的评论"
|
||||
|
||||
@@ -250,8 +250,7 @@ func TestAdminMediaLibraryAttachSources(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("comment image missing")
|
||||
}
|
||||
if cimg.SourceURL != fmt.Sprintf("/post/%d#comment-", post.ID) &&
|
||||
!strings.HasPrefix(cimg.SourceURL, fmt.Sprintf("/post/%d#comment-", post.ID)) {
|
||||
if cimg.SourceURL != fmt.Sprintf("/post/%d#comment-1", post.ID) {
|
||||
t.Fatalf("comment source url wrong: %+v", cimg)
|
||||
}
|
||||
if !strings.Contains(cimg.SourceLabel, "下的评论") {
|
||||
@@ -259,6 +258,63 @@ func TestAdminMediaLibraryAttachSources(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminMediaLibraryBookCategory(t *testing.T) {
|
||||
s, dir := newMediaLibraryService(t)
|
||||
|
||||
// 书籍封面(本地磁盘)与正文插图(远程对象),另有一张普通帖子插图
|
||||
writeMediaFile(t, dir, "images/cover.webp", []byte("fake-webp-bytes"))
|
||||
|
||||
if err := s.db.Create(&model.User{Username: "alice", Nickname: "爱丽丝"}).Error; err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
var alice model.User
|
||||
if err := s.db.Where("username = ?", "alice").First(&alice).Error; err != nil {
|
||||
t.Fatalf("load user: %v", err)
|
||||
}
|
||||
created := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
|
||||
mkAtt := func(source, url string) {
|
||||
if err := s.db.Create(&model.Attachment{
|
||||
UserID: alice.ID, Kind: model.AttachmentKindImage, Source: source,
|
||||
URL: url, MIME: "image/webp", Size: 100, Width: 10, Height: 10,
|
||||
CreatedAt: created,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create attachment %s: %v", source, err)
|
||||
}
|
||||
}
|
||||
mkAtt(model.AttachmentSourceLibraryCover, "/uploads/images/cover.webp")
|
||||
mkAtt(model.AttachmentSourceLibraryContent, "/api/media/book-content")
|
||||
mkAtt(model.AttachmentSourcePost, "/api/media/post-image")
|
||||
|
||||
items, counts, err := s.AdminMediaLibrary()
|
||||
if err != nil {
|
||||
t.Fatalf("AdminMediaLibrary: %v", err)
|
||||
}
|
||||
byURL := map[string]MediaLibraryItem{}
|
||||
for _, it := range items {
|
||||
byURL[it.URL] = it
|
||||
}
|
||||
|
||||
// 封面(磁盘)与正文插图(远程)都归“书籍”
|
||||
for _, u := range []string{"/uploads/images/cover.webp", "/api/media/book-content"} {
|
||||
it, ok := byURL[u]
|
||||
if !ok {
|
||||
t.Fatalf("%s missing", u)
|
||||
}
|
||||
if it.Category != "book" || it.CategoryName != "书籍" {
|
||||
t.Fatalf("%s should be book category, got %q/%q", u, it.Category, it.CategoryName)
|
||||
}
|
||||
}
|
||||
|
||||
// 普通帖子插图仍归 image
|
||||
if post := byURL["/api/media/post-image"]; post.Category != "image" {
|
||||
t.Fatalf("post image should stay image category, got %q", post.Category)
|
||||
}
|
||||
|
||||
if counts["book"] != 2 || counts["image"] != 1 {
|
||||
t.Fatalf("counts wrong: %v", counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaThumb(t *testing.T) {
|
||||
s, dir := newMediaLibraryService(t)
|
||||
|
||||
|
||||
@@ -1,27 +1,19 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
webpenc "github.com/gen2brain/webp"
|
||||
"golang.org/x/image/draw"
|
||||
xwebp "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
// 媒体库缩略图:管理后台网格不再直连原图(全量图片一次加载网络压力大)。
|
||||
// 服务端按需生成最长边 480px 的 WebP 缩略图,落盘 .thumbs/(按 URL 哈希命名)缓存,
|
||||
// 源文件更新后(mtime 更新)自动重建。仅覆盖本地 /uploads/ 图片;
|
||||
// 远程存储对象与解码失败(如动图 WebP)由调用方回退原图。
|
||||
// 解码/缩放/编码/原子落盘共用 image_variants.go 的图片处理核心。
|
||||
|
||||
// MediaThumbMaxSide 缩略图最长边(网格单元 ~200px,2x DPR 足够)
|
||||
const MediaThumbMaxSide = 480
|
||||
@@ -70,12 +62,7 @@ func (s *UploadService) MediaThumb(url string) ([]byte, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var img image.Image
|
||||
if ext == ".webp" {
|
||||
img, err = xwebp.Decode(bytes.NewReader(data))
|
||||
} else {
|
||||
img, _, err = image.Decode(bytes.NewReader(data))
|
||||
}
|
||||
img, err := decodeImageBytes(data, ext == ".webp")
|
||||
if err != nil {
|
||||
return nil, err // 如动图 WebP,调用方回退原图
|
||||
}
|
||||
@@ -94,21 +81,13 @@ func (s *UploadService) MediaThumb(url string) ([]byte, error) {
|
||||
nh = MediaThumbMaxSide
|
||||
nw = max(1, w*MediaThumbMaxSide/h)
|
||||
}
|
||||
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
||||
draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Src, nil)
|
||||
dst := scaleImage(img, nw, nh)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := webpenc.Encode(&buf, dst, webpenc.Options{Quality: 78}); err != nil {
|
||||
out, err := encodeLossyWebP(dst, VariantWebPQuality)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 原子落缓存(失败仅影响下次重复生成,不阻断响应)
|
||||
if err := os.MkdirAll(filepath.Dir(thumbPath), 0o755); err == nil {
|
||||
tmp := thumbPath + ".partial"
|
||||
if err := os.WriteFile(tmp, buf.Bytes(), 0o644); err == nil {
|
||||
if err := os.Rename(tmp, thumbPath); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
}
|
||||
}
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
_ = atomicWriteFile(thumbPath, out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -402,6 +402,7 @@ func (o *Operations) SendCode(email, purpose, ip string) (int, error) {
|
||||
})
|
||||
return 0, e
|
||||
}
|
||||
|
||||
const (
|
||||
codeVerifyMaxAttempts = 5
|
||||
codeVerifyWindow = 15 * 60 // 秒,与验证码有效期一致
|
||||
|
||||
@@ -72,18 +72,18 @@ type NewUserItem struct {
|
||||
|
||||
// OverviewData 首页聚合数据
|
||||
type OverviewData struct {
|
||||
Stats OverviewStats `json:"stats"`
|
||||
Hot []PostListItem `json:"hot"`
|
||||
ActiveUsers []ActiveUser `json:"active_users"`
|
||||
Boards []BoardCount `json:"boards"`
|
||||
Announcements []AnnouncementItem `json:"announcements"`
|
||||
AnnouncementsTotal int64 `json:"announcements_total"`
|
||||
SidebarPages []SidebarPageItem `json:"sidebar_pages"`
|
||||
NewUsers []NewUserItem `json:"new_users"`
|
||||
Checkin *CheckinStatus `json:"checkin,omitempty"`
|
||||
Ads []PublicAdItem `json:"ads"`
|
||||
AdsPanelTitle string `json:"ads_panel_title"`
|
||||
AdsEnabled bool `json:"ads_enabled"`
|
||||
Stats OverviewStats `json:"stats"`
|
||||
Hot []PostListItem `json:"hot"`
|
||||
ActiveUsers []ActiveUser `json:"active_users"`
|
||||
Boards []BoardCount `json:"boards"`
|
||||
Announcements []AnnouncementItem `json:"announcements"`
|
||||
AnnouncementsTotal int64 `json:"announcements_total"`
|
||||
SidebarPages []SidebarPageItem `json:"sidebar_pages"`
|
||||
NewUsers []NewUserItem `json:"new_users"`
|
||||
Checkin *CheckinStatus `json:"checkin,omitempty"`
|
||||
Ads []PublicAdItem `json:"ads"`
|
||||
AdsPanelTitle string `json:"ads_panel_title"`
|
||||
AdsEnabled bool `json:"ads_enabled"`
|
||||
Sponsors []PublicSponsorItem `json:"sponsors"`
|
||||
SponsorsPanelTitle string `json:"sponsors_panel_title"`
|
||||
SponsorsEnabled bool `json:"sponsors_enabled"`
|
||||
|
||||
@@ -457,37 +457,37 @@ type PostAttachmentDTO struct {
|
||||
|
||||
// PostDetail 帖子详情(含可见性裁剪与附件)
|
||||
type PostDetail struct {
|
||||
ID uint `json:"id"`
|
||||
BoardID uint `json:"board_id"`
|
||||
UserID uint `json:"user_id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Tags string `json:"tags"`
|
||||
PostType string `json:"post_type"`
|
||||
ContentAccess string `json:"content_access"`
|
||||
AccessPoints int `json:"access_points"`
|
||||
TypeMeta string `json:"type_meta"`
|
||||
TypeStatus string `json:"type_status,omitempty"`
|
||||
Pinned int `json:"pinned"`
|
||||
Recommended bool `json:"recommended"`
|
||||
Locked bool `json:"locked"` // 管理员手动锁定:普通用户不可编辑/回复(staff 豁免)
|
||||
Status string `json:"status"`
|
||||
LikeCount int `json:"like_count"`
|
||||
ViewCount int `json:"view_count"`
|
||||
CommentCount int `json:"comment_count"`
|
||||
Liked bool `json:"liked"`
|
||||
Favorited bool `json:"favorited"` // 当前查看者是否已收藏(详情接口实时填充)
|
||||
Edited bool `json:"edited"` // 是否存在编辑历史快照(决定"更新于/编辑历史"入口展示)
|
||||
ID uint `json:"id"`
|
||||
BoardID uint `json:"board_id"`
|
||||
UserID uint `json:"user_id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Tags string `json:"tags"`
|
||||
PostType string `json:"post_type"`
|
||||
ContentAccess string `json:"content_access"`
|
||||
AccessPoints int `json:"access_points"`
|
||||
TypeMeta string `json:"type_meta"`
|
||||
TypeStatus string `json:"type_status,omitempty"`
|
||||
Pinned int `json:"pinned"`
|
||||
Recommended bool `json:"recommended"`
|
||||
Locked bool `json:"locked"` // 管理员手动锁定:普通用户不可编辑/回复(staff 豁免)
|
||||
Status string `json:"status"`
|
||||
LikeCount int `json:"like_count"`
|
||||
ViewCount int `json:"view_count"`
|
||||
CommentCount int `json:"comment_count"`
|
||||
Liked bool `json:"liked"`
|
||||
Favorited bool `json:"favorited"` // 当前查看者是否已收藏(详情接口实时填充)
|
||||
Edited bool `json:"edited"` // 是否存在编辑历史快照(决定"更新于/编辑历史"入口展示)
|
||||
// 最后一条已发布评论时间;null=无回复(旧帖判定回落 created_at)
|
||||
LastReplyAt *time.Time `json:"last_reply_at,omitempty"`
|
||||
// 旧帖回复确认提示:按查看者实时计算,命中才返回;前端存在即弹确认框
|
||||
NecroReply *NecroReplyHint `json:"necro_reply,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Board model.Board `json:"board"`
|
||||
User model.User `json:"user"`
|
||||
ContentLocked bool `json:"content_locked"`
|
||||
AccessHint string `json:"access_hint,omitempty"`
|
||||
NecroReply *NecroReplyHint `json:"necro_reply,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Board model.Board `json:"board"`
|
||||
User model.User `json:"user"`
|
||||
ContentLocked bool `json:"content_locked"`
|
||||
AccessHint string `json:"access_hint,omitempty"`
|
||||
Attachments []PostAttachmentDTO `json:"attachments"`
|
||||
Question *QuestionState `json:"question,omitempty"`
|
||||
Poll *PollState `json:"poll,omitempty"`
|
||||
@@ -620,7 +620,7 @@ func buildPostDetail(post *model.Post) *PostDetail {
|
||||
ContentAccess: model.NormalizeContentAccess(post.ContentAccess),
|
||||
AccessPoints: post.AccessPoints, TypeMeta: post.TypeMeta,
|
||||
TypeStatus: ComputeTypeStatus(post.PostType, post.TypeMeta),
|
||||
Pinned: post.Pinned, Recommended: post.Recommended, Locked: post.Locked, Status: post.Status,
|
||||
Pinned: post.Pinned, Recommended: post.Recommended, Locked: post.Locked, Status: post.Status,
|
||||
LikeCount: post.LikeCount, ViewCount: post.ViewCount, CommentCount: post.CommentCount,
|
||||
Liked: post.Liked, CreatedAt: post.CreatedAt, UpdatedAt: post.UpdatedAt,
|
||||
Board: post.Board, User: post.User,
|
||||
|
||||
@@ -472,6 +472,7 @@ func EnsureDeadlineOnPublish(typeMeta, postType string, publishedAt time.Time) (
|
||||
type AcceptedAnswer struct {
|
||||
ID uint `json:"id"`
|
||||
Floor int `json:"floor"`
|
||||
IsRoot bool `json:"is_root"` // false = 楼中楼回复,锚点需 #comment-{floor}-r{id}
|
||||
Content string `json:"content"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Deleted bool `json:"deleted,omitempty"`
|
||||
@@ -736,6 +737,7 @@ func (s *PostService) loadAcceptedAnswer(postID, commentID uint, floor int, view
|
||||
ans := &AcceptedAnswer{
|
||||
ID: c.ID,
|
||||
Floor: floor,
|
||||
IsRoot: c.ParentID == nil,
|
||||
CreatedAt: c.CreatedAt,
|
||||
}
|
||||
staff := c.DeletedAt.Valid && c.DeletedBy != 0 && c.DeletedBy != c.UserID
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
sitePageSlugRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||||
sitePageSlugRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||||
ErrSitePageNotDeleted = errors.New("仅已删除的单页可彻底删除")
|
||||
)
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ func (s *SettingService) ImportTimelineFromReleases(urls []string) (*TimelineGit
|
||||
client := &http.Client{
|
||||
Timeout: timelineGitHTTPTimeout,
|
||||
Transport: &http.Transport{
|
||||
DialContext: publicOnlyDial,
|
||||
DialContext: publicOnlyDial,
|
||||
TLSHandshakeTimeout: timelineGitHTTPTimeout,
|
||||
ForceAttemptHTTP2: true,
|
||||
},
|
||||
|
||||
@@ -238,7 +238,7 @@ func (s *UploadService) transcodeImageToWebP(tmp, name, ext string) (string, str
|
||||
}
|
||||
|
||||
// SaveImage 流式保存帖子插图:校验格式/大小/尺寸 → JPEG/PNG 转 WebP → 落盘 → 写 attachments(kind=image)
|
||||
func (s *UploadService) SaveImage(userID uint, src io.Reader) (*model.Attachment, error) {
|
||||
func (s *UploadService) SaveImage(userID uint, src io.Reader, source string) (*model.Attachment, error) {
|
||||
if src == nil {
|
||||
return nil, errors.New("文件为空")
|
||||
}
|
||||
@@ -354,6 +354,7 @@ func (s *UploadService) SaveImage(userID uint, src io.Reader) (*model.Attachment
|
||||
att := &model.Attachment{
|
||||
UserID: userID,
|
||||
Kind: model.AttachmentKindImage,
|
||||
Source: normalizeImageSource(source),
|
||||
URL: "/uploads/images/" + storeName,
|
||||
MIME: storeMime,
|
||||
Size: int(storeSize),
|
||||
@@ -585,9 +586,20 @@ func (s *UploadService) CopyBackgroundFromMedia(userID, attachmentID uint) (stri
|
||||
return s.SaveBackground(f)
|
||||
}
|
||||
|
||||
// normalizeImageSource 把外部传入的图片来源收敛到白名单,未知值回退为 post
|
||||
func normalizeImageSource(s string) string {
|
||||
switch s {
|
||||
case model.AttachmentSourceLibraryCover, model.AttachmentSourceLibraryContent:
|
||||
return s
|
||||
default:
|
||||
return model.AttachmentSourcePost
|
||||
}
|
||||
}
|
||||
|
||||
// CopyImageFromMedia 把当前用户媒体库里的一张图复制一份新插图(落 uploads/images 并记一条本人附件)
|
||||
// 用于书籍封面等长期引用场景:与原图解耦,原图删除后副本仍可用
|
||||
func (s *UploadService) CopyImageFromMedia(userID, attachmentID uint) (*model.Attachment, error) {
|
||||
// source 决定副本的来源标记(如 library_cover)
|
||||
func (s *UploadService) CopyImageFromMedia(userID, attachmentID uint, source string) (*model.Attachment, error) {
|
||||
var att model.Attachment
|
||||
if err := s.db.Where("id = ? AND user_id = ?", attachmentID, userID).First(&att).Error; err != nil {
|
||||
return nil, errors.New("图片不存在或不属于你")
|
||||
@@ -600,7 +612,7 @@ func (s *UploadService) CopyImageFromMedia(userID, attachmentID uint) (*model.At
|
||||
return nil, e
|
||||
}
|
||||
defer r.Close()
|
||||
return s.SaveImage(userID, r)
|
||||
return s.SaveImage(userID, r, source)
|
||||
}
|
||||
abs, ok := s.safeUploadPath(att.URL)
|
||||
if !ok {
|
||||
@@ -611,7 +623,7 @@ func (s *UploadService) CopyImageFromMedia(userID, attachmentID uint) (*model.At
|
||||
return nil, errors.New("读取图片失败")
|
||||
}
|
||||
defer f.Close()
|
||||
return s.SaveImage(userID, f)
|
||||
return s.SaveImage(userID, f, source)
|
||||
}
|
||||
|
||||
// BackgroundFileExists 确认 URL 对应文件在 backgrounds 目录内
|
||||
@@ -667,6 +679,15 @@ func (s *UploadService) ListMedia(userID uint) ([]model.Attachment, error) {
|
||||
return list, err
|
||||
}
|
||||
|
||||
// FindMediaBySource 按来源查本人附件(存在返回记录,不存在返回 error)
|
||||
func (s *UploadService) FindMediaBySource(userID, attachmentID uint, source string) (*model.Attachment, error) {
|
||||
var att model.Attachment
|
||||
if err := s.db.Where("id = ? AND user_id = ? AND source = ?", attachmentID, userID, source).First(&att).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &att, nil
|
||||
}
|
||||
|
||||
// ErrAttachmentInUse 图片正被帖子内容引用,不可物理删除
|
||||
var ErrAttachmentInUse = errors.New("该图片已被帖子使用,无法删除")
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ func TestSaveImageTranscodesJPEGToWebP(t *testing.T) {
|
||||
t.Fatalf("encode jpeg: %v", err)
|
||||
}
|
||||
|
||||
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()))
|
||||
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post")
|
||||
if err != nil {
|
||||
t.Fatalf("SaveImage: %v", err)
|
||||
}
|
||||
@@ -98,7 +98,7 @@ func TestSaveImageTranscodesPNGLosslessKeepsAlpha(t *testing.T) {
|
||||
t.Fatalf("encode png: %v", err)
|
||||
}
|
||||
|
||||
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()))
|
||||
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post")
|
||||
if err != nil {
|
||||
t.Fatalf("SaveImage: %v", err)
|
||||
}
|
||||
@@ -133,7 +133,7 @@ func TestSaveImageKeepsGIFAsIs(t *testing.T) {
|
||||
t.Fatalf("encode gif: %v", err)
|
||||
}
|
||||
|
||||
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()))
|
||||
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post")
|
||||
if err != nil {
|
||||
t.Fatalf("SaveImage: %v", err)
|
||||
}
|
||||
@@ -156,7 +156,7 @@ func TestSaveImageKeepsWebPAsIs(t *testing.T) {
|
||||
t.Fatalf("encode webp: %v", err)
|
||||
}
|
||||
|
||||
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()))
|
||||
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post")
|
||||
if err != nil {
|
||||
t.Fatalf("SaveImage: %v", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user