feat: 站点媒体库与移动端底部导航,服务测试补强
- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面 - 移动端底部导航 MobileTabBar 替换 MobilePostBar - 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强 - site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
This commit is contained in:
@@ -2,11 +2,13 @@ package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/middleware"
|
||||
"github.com/freefire/jiang13-bbs/service"
|
||||
@@ -16,10 +18,16 @@ import (
|
||||
// ===== 数据导入(settingsAPI 组内,PermSettings 门控:仅超管/站长) =====
|
||||
//
|
||||
// POST /api/admin/import/:source multipart:
|
||||
// - db_file 旧站 SQLite 库(必填)
|
||||
// - avatars_zip 头像压缩包(可选)
|
||||
// - with_content "true"/"false",是否同时导入板块/帖子/评论(默认 true)
|
||||
// - dry_run "true"/"false",预检模式不写库(默认 false)
|
||||
// - db_file 旧站 SQLite 库(必填)
|
||||
// - avatars_zip 头像压缩包(可选)
|
||||
// - images_zip 帖子图片压缩包(可选,旧站 data/uploads/posts 打包)
|
||||
// - with_content "true"/"false",是否同时导入板块/帖子/评论(默认 true)
|
||||
// - dry_run "true"/"false",预检模式不写库并返回勾选清单(默认 false)
|
||||
// - skip_users 逗号分隔旧用户ID,不创建账号(内容仍按归属规则落位)
|
||||
// - operator_users 逗号分隔旧用户ID,内容归到站长
|
||||
// - user_map "旧用户ID:本站用户名" 逗号分隔,内容归到指定已有账号
|
||||
// - skip_posts 逗号分隔旧帖ID,排除(其评论自动跳过)
|
||||
// - skip_comments 逗号分隔旧评论ID,排除
|
||||
//
|
||||
// source 走白名单(当前仅 jiang13),为 WordPress / Typecho 等来源预留扩展。
|
||||
|
||||
@@ -32,7 +40,7 @@ func (h *Handlers) AdminImportLegacy(c *gin.Context) {
|
||||
}
|
||||
|
||||
const overhead = 64 << 10 // multipart 边界与表单开销
|
||||
limit := int64(service.LegacyDBMaxBytes) + int64(service.LegacyZipMaxBytes) + overhead
|
||||
limit := int64(service.LegacyDBMaxBytes) + 2*int64(service.LegacyZipMaxBytes) + overhead
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
|
||||
|
||||
claims := middleware.CurrentUser(c)
|
||||
@@ -50,25 +58,25 @@ func (h *Handlers) AdminImportLegacy(c *gin.Context) {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "数据库文件不能超过 64MB"})
|
||||
return
|
||||
}
|
||||
zipPath := ""
|
||||
if zipFH, ferr := c.FormFile("avatars_zip"); ferr == nil && zipFH != nil && zipFH.Size > 0 {
|
||||
if zipFH.Size > service.LegacyZipMaxBytes {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "头像压缩包不能超过 128MB"})
|
||||
return
|
||||
}
|
||||
zipPath, err = saveMultipartToTemp(zipFH, "legacy-avatars-*.zip")
|
||||
if err != nil {
|
||||
respondLegacyUploadError(c, err, "读取头像压缩包失败")
|
||||
return
|
||||
}
|
||||
zipPath, ok := optionalLegacyZip(c, "avatars_zip", "legacy-avatars-*.zip", "头像压缩包")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if zipPath != "" {
|
||||
defer os.Remove(zipPath)
|
||||
} else if ferr != nil {
|
||||
// 表单整体解析失败(超限等);「字段不存在」直接忽略
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(ferr, &maxErr) {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "上传内容过大"})
|
||||
return
|
||||
}
|
||||
}
|
||||
imagesZipPath, ok := optionalLegacyZip(c, "images_zip", "legacy-images-*.zip", "帖子图片压缩包")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if imagesZipPath != "" {
|
||||
defer os.Remove(imagesZipPath)
|
||||
}
|
||||
|
||||
userMap, err := parseUserTargetMap(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
dbPath, err := saveMultipartToTemp(dbFH, "legacy-*.db")
|
||||
@@ -78,12 +86,17 @@ func (h *Handlers) AdminImportLegacy(c *gin.Context) {
|
||||
}
|
||||
defer os.Remove(dbPath)
|
||||
|
||||
rep, err := h.LegacyImport.ImportFromFiles(dbPath, zipPath, service.LegacyImportOptions{
|
||||
WithContent: formBool(c, "with_content", true),
|
||||
DryRun: formBool(c, "dry_run", false),
|
||||
rep, err := h.LegacyImport.ImportFromFiles(dbPath, zipPath, imagesZipPath, service.LegacyImportOptions{
|
||||
WithContent: formBool(c, "with_content", true),
|
||||
DryRun: formBool(c, "dry_run", false),
|
||||
SkipUserIDs: parseIDSet(c, "skip_users"),
|
||||
OperatorUsers: parseIDSet(c, "operator_users"),
|
||||
UserTargetNames: userMap,
|
||||
SkipPostIDs: parseIDSet(c, "skip_posts"),
|
||||
SkipCommentIDs: parseIDSet(c, "skip_comments"),
|
||||
}, claims.ID)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrLegacyInvalidSQLite) || errors.Is(err, service.ErrLegacyBadZip) {
|
||||
if errors.Is(err, service.ErrLegacyInvalidSQLite) || errors.Is(err, service.ErrLegacyBadZip) || errors.Is(err, service.ErrLegacyBadTarget) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -93,6 +106,75 @@ func (h *Handlers) AdminImportLegacy(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"report": rep})
|
||||
}
|
||||
|
||||
// optionalLegacyZip 读取可选 zip 表单项并落临时文件;字段不存在返回空串。
|
||||
// 出错时已写响应,返回 false 中止请求。
|
||||
func optionalLegacyZip(c *gin.Context, field, pattern, label string) (string, bool) {
|
||||
fh, err := c.FormFile(field)
|
||||
if err != nil {
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "上传内容过大"})
|
||||
return "", false
|
||||
}
|
||||
return "", true // 字段不存在,视为未上传
|
||||
}
|
||||
if fh.Size > service.LegacyZipMaxBytes {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": label+"不能超过 128MB"})
|
||||
return "", false
|
||||
}
|
||||
path, err := saveMultipartToTemp(fh, pattern)
|
||||
if err != nil {
|
||||
respondLegacyUploadError(c, err, "读取"+label+"失败")
|
||||
return "", false
|
||||
}
|
||||
return path, true
|
||||
}
|
||||
|
||||
// parseIDSet 解析逗号分隔的旧对象 ID 列表(如 "3,7,12")
|
||||
func parseIDSet(c *gin.Context, key string) map[uint]bool {
|
||||
v := strings.TrimSpace(c.PostForm(key))
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
out := map[uint]bool{}
|
||||
for _, part := range strings.Split(v, ",") {
|
||||
id, err := strconv.ParseUint(strings.TrimSpace(part), 10, 64)
|
||||
if err == nil && id > 0 {
|
||||
out[uint(id)] = true
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseUserTargetMap 解析 "旧用户ID:本站用户名" 列表(如 "5:bob,7:alice");
|
||||
// 目标账号由 service 在导入时校验并解析为账号 ID。
|
||||
func parseUserTargetMap(c *gin.Context) (map[uint]string, error) {
|
||||
v := strings.TrimSpace(c.PostForm("user_map"))
|
||||
if v == "" {
|
||||
return nil, nil
|
||||
}
|
||||
out := map[uint]string{}
|
||||
for _, pair := range strings.Split(v, ",") {
|
||||
kv := strings.SplitN(strings.TrimSpace(pair), ":", 2)
|
||||
if len(kv) != 2 {
|
||||
return nil, fmt.Errorf("user_map 格式错误: %s", pair)
|
||||
}
|
||||
oldID, err := strconv.ParseUint(strings.TrimSpace(kv[0]), 10, 64)
|
||||
name := strings.TrimSpace(kv[1])
|
||||
if err != nil || oldID == 0 || name == "" {
|
||||
return nil, fmt.Errorf("user_map 格式错误: %s", pair)
|
||||
}
|
||||
out[uint(oldID)] = name
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func formBool(c *gin.Context, key string, def bool) bool {
|
||||
v := c.PostForm(key)
|
||||
if v == "" {
|
||||
|
||||
@@ -213,8 +213,9 @@ func (h *Handlers) Login(c *gin.Context) {
|
||||
// dev 模式不设 Secure,生产环境需 HTTPS
|
||||
h.Ops.ClearFailure(req.Username)
|
||||
setAuthCookies(c, accessToken, refreshToken, !h.Cfg.DevMode)
|
||||
// 真·单会话:本次登录顶掉了所有旧会话,即时广播告知被踢设备(旧浏览器 WS 在线则
|
||||
// 当场弹"登录已失效",无需等 access token 过期或硬刷新才发现)
|
||||
// 多会话并存:新登录不踢旧设备;仅当本次登录超出会话上限、淘汰了最旧
|
||||
// 会话时广播告知(被淘汰设备 WS 在线则当场弹"登录已失效",无需等
|
||||
// access token 过期或硬刷新才发现)
|
||||
if kicked > 0 {
|
||||
h.Hub.BroadcastUser(user.ID, realtime.Envelope{
|
||||
Type: realtime.EventSessionReplaced,
|
||||
@@ -309,7 +310,7 @@ func (h *Handlers) MyLoginDevices(c *gin.Context) {
|
||||
}
|
||||
familyID := claims.FamilyID
|
||||
if refreshToken, err := c.Cookie(service.RefreshCookieName); err == nil {
|
||||
if fid := h.Auth.TouchDeviceFromRefresh(refreshToken, ip, ua, claims.ID); fid != 0 {
|
||||
if fid := h.Auth.TouchDeviceFromRefresh(refreshToken, claims.ID); fid != 0 {
|
||||
familyID = fid
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ func (h *Handlers) UploadAvatar(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"url": att.URL, "attachment": att})
|
||||
}
|
||||
|
||||
// UploadImage 上传帖子插图(multipart 字段 file:JPEG / PNG / WebP)
|
||||
// UploadImage 上传帖子插图(multipart 字段 file:JPEG / PNG / WebP / GIF;除 GIF 外自动转存 WebP)
|
||||
func (h *Handlers) UploadImage(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
|
||||
@@ -311,6 +311,27 @@ func (h *Handlers) UploadBrandFromMedia(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"url": url})
|
||||
}
|
||||
|
||||
// AdminMediaLibrary 管理后台媒体库:全站图片盘点(磁盘五类目录 + 附件元数据)
|
||||
func (h *Handlers) AdminMediaLibrary(c *gin.Context) {
|
||||
items, counts, err := h.Upload.AdminMediaLibrary()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取媒体库失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": nonNilSlice(items), "counts": counts, "total": len(items)})
|
||||
}
|
||||
|
||||
// MediaLibraryThumb 媒体库网格缩略图(?u=/uploads/images/xx.webp),失败回退由前端处理
|
||||
func (h *Handlers) MediaLibraryThumb(c *gin.Context) {
|
||||
data, err := h.Upload.MediaThumb(c.Query("u"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "缩略图不可用"})
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "private, max-age=86400")
|
||||
c.Data(http.StatusOK, "image/webp", data)
|
||||
}
|
||||
|
||||
func brandTooLarge(slot string) string {
|
||||
if slot == service.BrandSlotFavicon {
|
||||
return "Favicon 不能超过 512KB"
|
||||
|
||||
Reference in New Issue
Block a user