Files
jiang13-bbs/backend/handler/admin_legacy_import.go
freefire 1d862a0dd3 feat: 站点媒体库与移动端底部导航,服务测试补强
- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面
- 移动端底部导航 MobileTabBar 替换 MobilePostBar
- 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强
- site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
2026-09-27 02:36:11 +08:00

222 lines
6.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
"github.com/gin-gonic/gin"
)
// ===== 数据导入(settingsAPI 组内,PermSettings 门控:仅超管/站长) =====
//
// POST /api/admin/import/:source multipart:
// - 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 等来源预留扩展。
// AdminImportLegacy 导入外部站点数据,返回分类统计报告。幂等可重复导入。
func (h *Handlers) AdminImportLegacy(c *gin.Context) {
source := c.Param("source")
if !service.LegacyImportSource(source) {
c.JSON(http.StatusBadRequest, gin.H{"error": "暂不支持该数据源(当前支持:jiang13)"})
return
}
const overhead = 64 << 10 // multipart 边界与表单开销
limit := int64(service.LegacyDBMaxBytes) + 2*int64(service.LegacyZipMaxBytes) + overhead
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
claims := middleware.CurrentUser(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
dbFH, err := c.FormFile("db_file")
if err != nil {
respondLegacyUploadError(c, err, "请上传旧站数据库文件(jiang13.db)")
return
}
if dbFH.Size > service.LegacyDBMaxBytes {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "数据库文件不能超过 64MB"})
return
}
zipPath, ok := optionalLegacyZip(c, "avatars_zip", "legacy-avatars-*.zip", "头像压缩包")
if !ok {
return
}
if zipPath != "" {
defer os.Remove(zipPath)
}
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")
if err != nil {
respondLegacyUploadError(c, err, "读取数据库文件失败")
return
}
defer os.Remove(dbPath)
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) || errors.Is(err, service.ErrLegacyBadTarget) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "导入失败: " + err.Error()})
return
}
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 == "" {
return def
}
b, err := strconv.ParseBool(v)
if err != nil {
return def
}
return b
}
func respondLegacyUploadError(c *gin.Context, err error, fallback string) {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "上传内容过大"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": fallback})
}
// saveMultipartToTemp 把上传文件落盘为临时文件(大文件不宜整读进内存,Docker 下 /tmp 可写)
func saveMultipartToTemp(fh *multipart.FileHeader, pattern string) (string, error) {
src, err := fh.Open()
if err != nil {
return "", err
}
defer src.Close()
tmp, err := os.CreateTemp("", pattern)
if err != nil {
return "", err
}
name := tmp.Name()
if _, err := io.Copy(tmp, src); err != nil {
tmp.Close()
os.Remove(name)
return "", err
}
if err := tmp.Close(); err != nil {
os.Remove(name)
return "", err
}
return name, nil
}