新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
222 lines
6.8 KiB
Go
222 lines
6.8 KiB
Go
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
|
||
}
|