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 }