Просмотр исходного кода

feat(asset): 后台素材库后端 — file-record 上传/检索/标签CRUD/统计

- StorageService 新增 storeFileRecord(MultipartFile, category):存储 + 写 file_record(SHA-256 去重),file_type 按 FileTypeResolver 推导(image/video/audio/document/other)
- JdCloudStorageService 存量写入回填 file_type;LocalDiskStorageService 本地模式实现 storeFileRecord
- 新增 FileRecordService 业务层(分页/标签/描述/统计),Controller 不直接触碰 Mapper
- FileRecordController 重写:/upload(微信图片内容安全校验 fail-open + 标签关联 + 去重命中补标签)、/list(tagIds 前置过滤保证 total)、/update、/delete、/tags、/tag/create|update|delete、/stats,统一 admin 角色守卫
- schema.sql file_type 注释口径统一;新增 docs/migrations/20261025_asset_library_file_type_backfill.sql(存量回填,严格按 content_type,不占迁移编号)
- API_REFERENCE.md §4.56 + PROJECT-OVERVIEW.md v2.22 同步文档
Sisyphus 2 недель назад
Родитель
Сommit
6bc7eafe7e

+ 191 - 99
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/FileRecordController.java

@@ -1,26 +1,42 @@
 package com.etotem.cfc.controller.admin;
 package com.etotem.cfc.controller.admin;
 
 
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
-import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.core.metadata.IPage;
-import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.FileRecord;
 import com.etotem.cfc.entity.FileRecord;
 import com.etotem.cfc.entity.FileTag;
 import com.etotem.cfc.entity.FileTag;
-import com.etotem.cfc.entity.FileTagRel;
-import com.etotem.cfc.mapper.FileRecordMapper;
-import com.etotem.cfc.mapper.FileTagMapper;
-import com.etotem.cfc.mapper.FileTagRelMapper;
+import com.etotem.cfc.service.FileRecordService;
 import com.etotem.cfc.service.StorageService;
 import com.etotem.cfc.service.StorageService;
+import com.etotem.cfc.service.api.WechatServiceInterface;
+import com.etotem.cfc.util.ParamUtils;
 import org.slf4j.Logger;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.slf4j.LoggerFactory;
-import org.springframework.web.bind.annotation.*;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
 
 
 import javax.annotation.Resource;
 import javax.annotation.Resource;
-import java.util.*;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
 import java.util.stream.Collectors;
 import java.util.stream.Collectors;
-import com.etotem.cfc.util.ParamUtils;
 
 
+/**
+ * 后台素材库:文件记录(上传/检索/维护)与标签字典管理。
+ *
+ * <p>接口约定:
+ * <ul>
+ *   <li>全部为 POST,admin 角色守卫(@RequestAttribute("role") == "admin")</li>
+ *   <li>统一返回 Result&lt;T&gt;(code/message/data)</li>
+ *   <li>/upload 为 multipart 上传,字段名 file;tagIds 支持数组或逗号分隔字符串</li>
+ * </ul>
+ */
 @RestController
 @RestController
 @RequestMapping("/api/admin/file-record")
 @RequestMapping("/api/admin/file-record")
 public class FileRecordController {
 public class FileRecordController {
@@ -28,16 +44,13 @@ public class FileRecordController {
     private static final Logger log = LoggerFactory.getLogger(FileRecordController.class);
     private static final Logger log = LoggerFactory.getLogger(FileRecordController.class);
 
 
     @Resource
     @Resource
-    private FileRecordMapper fileRecordMapper;
-
-    @Resource
-    private FileTagMapper fileTagMapper;
+    private FileRecordService fileRecordService;
 
 
     @Resource
     @Resource
-    private FileTagRelMapper fileTagRelMapper;
+    private StorageService storageService;
 
 
     @Resource
     @Resource
-    private StorageService storageService;
+    private WechatServiceInterface wechatService;
 
 
     @PostMapping("/list")
     @PostMapping("/list")
     public Result<Map<String, Object>> list(@RequestBody Map<String, Object> params,
     public Result<Map<String, Object>> list(@RequestBody Map<String, Object> params,
@@ -50,91 +63,86 @@ public class FileRecordController {
         String search = params.get("search") != null ? params.get("search").toString() : null;
         String search = params.get("search") != null ? params.get("search").toString() : null;
         String dateFrom = params.get("dateFrom") != null ? params.get("dateFrom").toString() : null;
         String dateFrom = params.get("dateFrom") != null ? params.get("dateFrom").toString() : null;
         String dateTo = params.get("dateTo") != null ? params.get("dateTo").toString() : null;
         String dateTo = params.get("dateTo") != null ? params.get("dateTo").toString() : null;
-        @SuppressWarnings("unchecked")
-        List<Integer> tagIds = params.get("tagIds") != null ? (List<Integer>) params.get("tagIds") : null;
-
-        LambdaQueryWrapper<FileRecord> wrapper = new LambdaQueryWrapper<>();
-        if (category != null && !category.isEmpty()) wrapper.eq(FileRecord::getCategory, category);
-        if (fileType != null && !fileType.isEmpty()) wrapper.eq(FileRecord::getFileType, fileType);
-        if (search != null && !search.isEmpty()) {
-            wrapper.and(w -> w.like(FileRecord::getOriginalFilename, search)
-                    .or().like(FileRecord::getDescription, search));
-        }
-        if (dateFrom != null && !dateFrom.isEmpty()) wrapper.ge(FileRecord::getCreatedAt, dateFrom + " 00:00:00");
-        if (dateTo != null && !dateTo.isEmpty()) wrapper.le(FileRecord::getCreatedAt, dateTo + " 23:59:59");
-        wrapper.orderByDesc(FileRecord::getCreatedAt);
-
-        IPage<FileRecord> page = fileRecordMapper.selectPage(new Page<>(pageNo, pageSize), wrapper);
-
-        List<Long> fileIds = page.getRecords().stream().map(FileRecord::getId).collect(Collectors.toList());
-        Map<Long, List<FileTag>> fileTagsMap = new HashMap<>();
-        if (!fileIds.isEmpty()) {
-            List<FileTagRel> rels = fileTagRelMapper.selectList(
-                    new LambdaQueryWrapper<FileTagRel>().in(FileTagRel::getFileId, fileIds));
-            if (!rels.isEmpty()) {
-                Set<Long> tagIdSet = rels.stream().map(FileTagRel::getTagId).collect(Collectors.toSet());
-                List<FileTag> tags = fileTagMapper.selectBatchIds(tagIdSet);
-                Map<Long, FileTag> tagMap = tags.stream().collect(Collectors.toMap(FileTag::getId, t -> t));
-                for (FileTagRel rel : rels) {
-                    fileTagsMap.computeIfAbsent(rel.getFileId(), k -> new ArrayList<>()).add(tagMap.get(rel.getTagId()));
-                }
-            }
-        }
+        List<Long> tagIds = parseTagIds(params.get("tagIds"));
 
 
-        List<FileRecord> filteredRecords = page.getRecords();
-        if (tagIds != null && !tagIds.isEmpty()) {
-            filteredRecords = filteredRecords.stream()
-                    .filter(r -> {
-                        List<FileTag> ft = fileTagsMap.get(r.getId());
-                        return ft != null && ft.stream().anyMatch(t -> tagIds.contains(t.getId().intValue()));
-                    }).collect(Collectors.toList());
-        }
+        IPage<FileRecord> page = fileRecordService.pageRecords(
+                pageNo, pageSize, category, fileType, search, dateFrom, dateTo, tagIds);
+
+        List<FileRecord> records = page.getRecords();
+        Map<Long, List<FileTag>> fileTagsMap = fileRecordService.tagsByFileIds(
+                records.stream().map(FileRecord::getId).collect(Collectors.toList()));
 
 
-        List<Map<String, Object>> records = new ArrayList<>();
-        for (FileRecord r : filteredRecords) {
-            Map<String, Object> item = new LinkedHashMap<>();
-            item.put("id", r.getId());
-            item.put("hash", r.getHash());
-            item.put("url", r.getUrl());
-            item.put("fileSize", r.getFileSize());
-            item.put("contentType", r.getContentType());
-            item.put("originalFilename", r.getOriginalFilename());
-            item.put("category", r.getCategory());
-            item.put("fileType", r.getFileType());
-            item.put("description", r.getDescription());
-            item.put("createdAt", r.getCreatedAt());
-            item.put("updatedAt", r.getUpdatedAt());
-            item.put("tags", fileTagsMap.getOrDefault(r.getId(), Collections.emptyList()));
-            records.add(item);
+        List<Map<String, Object>> items = new ArrayList<>();
+        for (FileRecord r : records) {
+            items.add(buildRecordMap(r, fileTagsMap.getOrDefault(r.getId(), java.util.Collections.emptyList())));
         }
         }
 
 
         Map<String, Object> result = new LinkedHashMap<>();
         Map<String, Object> result = new LinkedHashMap<>();
-        result.put("records", records);
+        result.put("records", items);
         result.put("total", page.getTotal());
         result.put("total", page.getTotal());
         result.put("page", pageNo);
         result.put("page", pageNo);
         result.put("pageSize", pageSize);
         result.put("pageSize", pageSize);
         return Result.success(result);
         return Result.success(result);
     }
     }
 
 
+    /**
+     * 统一上传入库:存储(含去重)→ 写入 file_record → 关联标签 → 返回记录。
+     *
+     * <p>重复 hash 命中时返回已存在的记录,并补打本次上传携带的标签。</p>
+     *
+     * @param file     multipart 文件字段(富文本编辑器/普通上传控件统一用 "file")
+     * @param category 素材分类,默认 general
+     * @param tagIds   标签ID列表,支持数组(tagIds=1&amp;tagIds=2)或逗号分隔(tagIds=1,2)
+     */
+    @PostMapping("/upload")
+    public Result<Map<String, Object>> upload(@RequestParam("file") MultipartFile file,
+                                              @RequestParam(value = "category", required = false) String category,
+                                              @RequestParam(value = "tagIds", required = false) List<String> tagIds,
+                                              @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) return Result.error("无权限");
+        if (file == null || file.isEmpty()) {
+            return Result.error("文件为空");
+        }
+        // 图片内容安全检测(与 AdminArticleController#uploadImage 保持一致;微信检测 fail-open)
+        if (file.getContentType() != null && file.getContentType().startsWith("image")) {
+            try {
+                byte[] imageBytes = file.getBytes();
+                if (!wechatService.checkImage(imageBytes)) {
+                    return Result.error("图片内容包含敏感信息,上传失败");
+                }
+            } catch (java.io.IOException e) {
+                return Result.error("图片读取失败: " + e.getMessage());
+            }
+        }
+        try {
+            FileRecord record = storageService.storeFileRecord(file, category);
+            List<Long> tagIdList = parseTagIds(tagIds);
+            fileRecordService.addTags(record.getId(), tagIdList);
+            Map<String, Object> map = buildRecordMap(record, fileRecordService.getTags(record.getId()));
+            log.info("素材库上传成功: id={}, category={}, fileType={}, url={}, tags={}",
+                    record.getId(), record.getCategory(), record.getFileType(), record.getUrl(), tagIdList);
+            return Result.success(map);
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        } catch (Exception e) {
+            log.error("素材库上传失败", e);
+            return Result.error("上传失败: " + e.getMessage());
+        }
+    }
+
     @PostMapping("/update")
     @PostMapping("/update")
     public Result<String> update(@RequestBody Map<String, Object> params,
     public Result<String> update(@RequestBody Map<String, Object> params,
                                  @RequestAttribute("role") String role) {
                                  @RequestAttribute("role") String role) {
         if (!"admin".equals(role)) return Result.error("无权限");
         if (!"admin".equals(role)) return Result.error("无权限");
         Long id = ParamUtils.getLong(params.get("id"));
         Long id = ParamUtils.getLong(params.get("id"));
-        FileRecord record = fileRecordMapper.selectById(id);
+        FileRecord record = fileRecordService.getById(id);
         if (record == null) return Result.error("文件不存在");
         if (record == null) return Result.error("文件不存在");
-        if (params.get("description") != null) record.setDescription(params.get("description").toString());
-        fileRecordMapper.updateById(record);
+        if (params.get("description") != null) {
+            fileRecordService.updateDescription(id, params.get("description").toString());
+        }
         if (params.get("tagIds") != null) {
         if (params.get("tagIds") != null) {
-            @SuppressWarnings("unchecked")
-            List<Integer> tagIds = (List<Integer>) params.get("tagIds");
-            fileTagRelMapper.delete(new LambdaQueryWrapper<FileTagRel>().eq(FileTagRel::getFileId, id));
-            for (Integer tagId : tagIds) {
-                FileTagRel rel = new FileTagRel();
-                rel.setFileId(id);
-                rel.setTagId(tagId.longValue());
-                fileTagRelMapper.insert(rel);
-            }
+            List<Long> tagIds = parseTagIds(params.get("tagIds"));
+            fileRecordService.replaceTags(id, tagIds);
         }
         }
         return Result.success("更新成功");
         return Result.success("更新成功");
     }
     }
@@ -144,34 +152,118 @@ public class FileRecordController {
                                      @RequestAttribute("role") String role) {
                                      @RequestAttribute("role") String role) {
         if (!"admin".equals(role)) return Result.error("无权限");
         if (!"admin".equals(role)) return Result.error("无权限");
         Long id = ParamUtils.getLong(params.get("id"));
         Long id = ParamUtils.getLong(params.get("id"));
-        FileRecord record = fileRecordMapper.selectById(id);
+        FileRecord record = fileRecordService.getById(id);
         if (record == null) return Result.error("文件不存在");
         if (record == null) return Result.error("文件不存在");
         try { storageService.deleteFile(record.getUrl()); }
         try { storageService.deleteFile(record.getUrl()); }
         catch (Exception e) { log.warn("OSS删除失败: {}", record.getUrl(), e); }
         catch (Exception e) { log.warn("OSS删除失败: {}", record.getUrl(), e); }
-        fileTagRelMapper.delete(new LambdaQueryWrapper<FileTagRel>().eq(FileTagRel::getFileId, id));
-        fileRecordMapper.deleteById(id);
+        fileRecordService.deleteRecord(id);
         return Result.success("删除成功");
         return Result.success("删除成功");
     }
     }
 
 
     @PostMapping("/tags")
     @PostMapping("/tags")
     public Result<List<FileTag>> getTags(@RequestAttribute("role") String role) {
     public Result<List<FileTag>> getTags(@RequestAttribute("role") String role) {
         if (!"admin".equals(role)) return Result.error("无权限");
         if (!"admin".equals(role)) return Result.error("无权限");
-        return Result.success(fileTagMapper.selectList(
-                new LambdaQueryWrapper<FileTag>().orderByAsc(FileTag::getSortOrder)));
+        return Result.success(fileRecordService.listTags());
+    }
+
+    @PostMapping("/tag/create")
+    public Result<FileTag> tagCreate(@RequestBody Map<String, Object> params,
+                                     @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) return Result.error("无权限");
+        try {
+            return Result.success(fileRecordService.createTag(
+                    str(params.get("name")), str(params.get("color")), integer(params.get("sortOrder"))));
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @PostMapping("/tag/update")
+    public Result<FileTag> tagUpdate(@RequestBody Map<String, Object> params,
+                                     @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) return Result.error("无权限");
+        try {
+            return Result.success(fileRecordService.updateTag(
+                    ParamUtils.getLong(params.get("id")),
+                    str(params.get("name")), str(params.get("color")), integer(params.get("sortOrder"))));
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        }
+    }
+
+    @PostMapping("/tag/delete")
+    public Result<String> tagDelete(@RequestBody Map<String, Object> params,
+                                    @RequestAttribute("role") String role) {
+        if (!"admin".equals(role)) return Result.error("无权限");
+        try {
+            fileRecordService.deleteTag(ParamUtils.getLong(params.get("id")));
+            return Result.success("删除成功");
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        }
     }
     }
 
 
     @PostMapping("/stats")
     @PostMapping("/stats")
     public Result<Map<String, Object>> stats(@RequestAttribute("role") String role) {
     public Result<Map<String, Object>> stats(@RequestAttribute("role") String role) {
         if (!"admin".equals(role)) return Result.error("无权限");
         if (!"admin".equals(role)) return Result.error("无权限");
-        Map<String, Object> stats = new LinkedHashMap<>();
-        stats.put("byCategory", fileRecordMapper.selectMaps(
-                new QueryWrapper<FileRecord>().select("category, COUNT(*) AS cnt").groupBy("category")));
-        stats.put("byFileType", fileRecordMapper.selectMaps(
-                new QueryWrapper<FileRecord>().select("file_type, COUNT(*) AS cnt").groupBy("file_type")));
-        stats.put("total", fileRecordMapper.selectCount(null));
-        List<Map<String, Object>> ts = fileRecordMapper.selectMaps(
-                new QueryWrapper<FileRecord>().select("SUM(file_size) AS total_bytes"));
-        stats.put("totalBytes", ts.get(0).get("total_bytes"));
-        return Result.success(stats);
+        return Result.success(fileRecordService.stats());
+    }
+
+    // ==================== 工具方法 ====================
+
+    /**
+     * 解析 tagIds 参数:支持 List(数组)、逗号分隔字符串,空/非法值忽略。
+     */
+    private List<Long> parseTagIds(Object raw) {
+        Set<Long> result = new LinkedHashSet<>();
+        if (raw == null) {
+            return new ArrayList<>();
+        }
+        if (raw instanceof List) {
+            for (Object o : (List<?>) raw) {
+                Long id = ParamUtils.getLong(o);
+                if (id != null) result.add(id);
+            }
+        } else {
+            String s = raw.toString();
+            for (String part : s.split(",")) {
+                Long id = ParamUtils.getLong(part);
+                if (id != null) result.add(id);
+            }
+        }
+        return new ArrayList<>(result);
+    }
+
+    /**
+     * 构建前端使用的记录 map(含 id/url/fileType/originalFilename/category/description/tags)。
+     */
+    private Map<String, Object> buildRecordMap(FileRecord r, List<FileTag> tags) {
+        Map<String, Object> item = new LinkedHashMap<>();
+        item.put("id", r.getId());
+        item.put("hash", r.getHash());
+        item.put("url", r.getUrl());
+        item.put("fileSize", r.getFileSize());
+        item.put("contentType", r.getContentType());
+        item.put("originalFilename", r.getOriginalFilename());
+        item.put("category", r.getCategory());
+        item.put("fileType", r.getFileType());
+        item.put("description", r.getDescription());
+        item.put("createdAt", r.getCreatedAt());
+        item.put("updatedAt", r.getUpdatedAt());
+        item.put("tags", tags == null ? new ArrayList<>() : tags);
+        return item;
+    }
+
+    private String str(Object o) {
+        return o != null ? o.toString() : null;
+    }
+
+    private Integer integer(Object o) {
+        if (o == null) return null;
+        try {
+            return Integer.parseInt(o.toString().trim());
+        } catch (NumberFormatException e) {
+            return null;
+        }
     }
     }
-}
+}

+ 311 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FileRecordService.java

@@ -0,0 +1,311 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.etotem.cfc.entity.FileRecord;
+import com.etotem.cfc.entity.FileTag;
+import com.etotem.cfc.entity.FileTagRel;
+import com.etotem.cfc.mapper.FileRecordMapper;
+import com.etotem.cfc.mapper.FileTagMapper;
+import com.etotem.cfc.mapper.FileTagRelMapper;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * 素材库(file_record / file_tag / file_tag_rel)业务服务。
+ *
+ * <p>后台素材库与文件管理页共用同一套数据:文件上传落库 → 标签关联 → 检索/维护。</p>
+ */
+@Service
+public class FileRecordService {
+
+    /** 标签默认颜色 */
+    private static final String DEFAULT_TAG_COLOR = "#409EFF";
+
+    @Resource
+    private FileRecordMapper fileRecordMapper;
+
+    @Resource
+    private FileTagMapper fileTagMapper;
+
+    @Resource
+    private FileTagRelMapper fileTagRelMapper;
+
+    // ==================== 文件记录 ====================
+
+    public FileRecord getById(Long id) {
+        if (id == null) {
+            return null;
+        }
+        return fileRecordMapper.selectById(id);
+    }
+
+    /**
+     * 分页查询文件记录。
+     *
+     * <p>tagIds 筛选在分页之前生效(命中任一标签即算命中),
+     * 避免旧实现「分页后过滤」导致总数与页内条数不一致。</p>
+     */
+    public IPage<FileRecord> pageRecords(int pageNo, int pageSize, String category, String fileType,
+                                         String search, String dateFrom, String dateTo, List<Long> tagIds) {
+        LambdaQueryWrapper<FileRecord> wrapper = new LambdaQueryWrapper<>();
+        if (category != null && !category.isEmpty()) {
+            wrapper.eq(FileRecord::getCategory, category);
+        }
+        if (fileType != null && !fileType.isEmpty()) {
+            wrapper.eq(FileRecord::getFileType, fileType);
+        }
+        if (search != null && !search.isEmpty()) {
+            wrapper.and(w -> w.like(FileRecord::getOriginalFilename, search)
+                    .or().like(FileRecord::getDescription, search));
+        }
+        if (dateFrom != null && !dateFrom.isEmpty()) {
+            wrapper.ge(FileRecord::getCreatedAt, dateFrom + " 00:00:00");
+        }
+        if (dateTo != null && !dateTo.isEmpty()) {
+            wrapper.le(FileRecord::getCreatedAt, dateTo + " 23:59:59");
+        }
+        if (tagIds != null && !tagIds.isEmpty()) {
+            List<Long> matched = fileTagRelMapper.selectList(
+                            new LambdaQueryWrapper<FileTagRel>().in(FileTagRel::getTagId, tagIds))
+                    .stream()
+                    .map(FileTagRel::getFileId)
+                    .distinct()
+                    .collect(Collectors.toList());
+            if (matched.isEmpty()) {
+                // 没有任何文件命中标签:用不可能的 id 兜底,避免 IN () 语法错误
+                wrapper.in(FileRecord::getId, Collections.singletonList(-1L));
+            } else {
+                wrapper.in(FileRecord::getId, matched);
+            }
+        }
+        wrapper.orderByDesc(FileRecord::getCreatedAt);
+        return fileRecordMapper.selectPage(new Page<>(pageNo, pageSize), wrapper);
+    }
+
+    /**
+     * 批量查询文件对应的标签,返回 fileId → tags 映射。
+     */
+    public Map<Long, List<FileTag>> tagsByFileIds(List<Long> fileIds) {
+        Map<Long, List<FileTag>> fileTagsMap = new HashMap<>();
+        if (fileIds == null || fileIds.isEmpty()) {
+            return fileTagsMap;
+        }
+        List<FileTagRel> rels = fileTagRelMapper.selectList(
+                new LambdaQueryWrapper<FileTagRel>().in(FileTagRel::getFileId, fileIds));
+        if (rels.isEmpty()) {
+            return fileTagsMap;
+        }
+        Set<Long> tagIdSet = rels.stream().map(FileTagRel::getTagId).collect(Collectors.toSet());
+        List<FileTag> tags = fileTagMapper.selectBatchIds(tagIdSet);
+        Map<Long, FileTag> tagMap = tags.stream().collect(Collectors.toMap(FileTag::getId, t -> t));
+        for (FileTagRel rel : rels) {
+            FileTag tag = tagMap.get(rel.getTagId());
+            if (tag != null) {
+                fileTagsMap.computeIfAbsent(rel.getFileId(), k -> new ArrayList<>()).add(tag);
+            }
+        }
+        return fileTagsMap;
+    }
+
+    /**
+     * 查询单个文件已关联的标签。
+     */
+    public List<FileTag> getTags(Long fileId) {
+        if (fileId == null) {
+            return Collections.emptyList();
+        }
+        List<FileTagRel> rels = fileTagRelMapper.selectList(
+                new LambdaQueryWrapper<FileTagRel>().eq(FileTagRel::getFileId, fileId));
+        if (rels.isEmpty()) {
+            return Collections.emptyList();
+        }
+        Set<Long> tagIds = rels.stream().map(FileTagRel::getTagId).collect(Collectors.toSet());
+        return fileTagMapper.selectBatchIds(tagIds);
+    }
+
+    public void updateDescription(Long id, String description) {
+        FileRecord record = getById(id);
+        if (record == null) {
+            return;
+        }
+        record.setDescription(description);
+        record.setUpdatedAt(new Date());
+        fileRecordMapper.updateById(record);
+    }
+
+    /**
+     * 覆盖式设置文件标签(先删后建)。
+     */
+    public void replaceTags(Long fileId, List<Long> tagIds) {
+        fileTagRelMapper.delete(new LambdaQueryWrapper<FileTagRel>().eq(FileTagRel::getFileId, fileId));
+        addTags(fileId, tagIds);
+    }
+
+    /**
+     * 追加式打标签(幂等:已存在的关联跳过,非法 tagId 忽略)。
+     */
+    public void addTags(Long fileId, List<Long> tagIds) {
+        if (fileId == null || tagIds == null || tagIds.isEmpty()) {
+            return;
+        }
+        Set<Long> validTagIds = existingTagIds(tagIds);
+        if (validTagIds.isEmpty()) {
+            return;
+        }
+        Set<Long> current = new LinkedHashSet<>();
+        List<FileTagRel> rels = fileTagRelMapper.selectList(
+                new LambdaQueryWrapper<FileTagRel>().eq(FileTagRel::getFileId, fileId));
+        for (FileTagRel rel : rels) {
+            current.add(rel.getTagId());
+        }
+        for (Long tagId : validTagIds) {
+            if (current.contains(tagId)) {
+                continue;
+            }
+            FileTagRel rel = new FileTagRel();
+            rel.setFileId(fileId);
+            rel.setTagId(tagId);
+            fileTagRelMapper.insert(rel);
+            current.add(tagId);
+        }
+    }
+
+    private Set<Long> existingTagIds(List<Long> tagIds) {
+        Set<Long> distinct = tagIds.stream().filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new));
+        if (distinct.isEmpty()) {
+            return Collections.emptySet();
+        }
+        return fileTagMapper.selectBatchIds(distinct).stream().map(FileTag::getId).collect(Collectors.toSet());
+    }
+
+    /**
+     * 删除文件记录及其标签关联(对象存储文件的删除由调用方负责)。
+     */
+    public void deleteRecord(Long id) {
+        fileTagRelMapper.delete(new LambdaQueryWrapper<FileTagRel>().eq(FileTagRel::getFileId, id));
+        fileRecordMapper.deleteById(id);
+    }
+
+    // ==================== 标签 ====================
+
+    public List<FileTag> listTags() {
+        return fileTagMapper.selectList(new LambdaQueryWrapper<FileTag>().orderByAsc(FileTag::getSortOrder));
+    }
+
+    /**
+     * 创建标签。名称为空或重名时抛 IllegalArgumentException。
+     */
+    public FileTag createTag(String name, String color, Integer sortOrder) {
+        String trimmed = name == null ? "" : name.trim();
+        if (trimmed.isEmpty()) {
+            throw new IllegalArgumentException("标签名称不能为空");
+        }
+        if (trimmed.length() > 50) {
+            throw new IllegalArgumentException("标签名称不能超过 50 个字符");
+        }
+        if (findTagByName(trimmed) != null) {
+            throw new IllegalArgumentException("标签名称已存在:" + trimmed);
+        }
+        FileTag tag = new FileTag();
+        tag.setName(trimmed);
+        tag.setColor(normalizeColor(color));
+        tag.setSortOrder(sortOrder == null ? 0 : sortOrder);
+        tag.setCreatedAt(new Date());
+        fileTagMapper.insert(tag);
+        return tag;
+    }
+
+    /**
+     * 更新标签。名称为空或与其它标签重名时抛 IllegalArgumentException。
+     */
+    public FileTag updateTag(Long id, String name, String color, Integer sortOrder) {
+        if (id == null) {
+            throw new IllegalArgumentException("缺少标签ID");
+        }
+        FileTag tag = fileTagMapper.selectById(id);
+        if (tag == null) {
+            throw new IllegalArgumentException("标签不存在");
+        }
+        if (name != null) {
+            String trimmed = name.trim();
+            if (trimmed.isEmpty()) {
+                throw new IllegalArgumentException("标签名称不能为空");
+            }
+            if (trimmed.length() > 50) {
+                throw new IllegalArgumentException("标签名称不能超过 50 个字符");
+            }
+            FileTag same = findTagByName(trimmed);
+            if (same != null && !same.getId().equals(id)) {
+                throw new IllegalArgumentException("标签名称已存在:" + trimmed);
+            }
+            tag.setName(trimmed);
+        }
+        if (color != null) {
+            tag.setColor(normalizeColor(color));
+        }
+        if (sortOrder != null) {
+            tag.setSortOrder(sortOrder);
+        }
+        fileTagMapper.updateById(tag);
+        return tag;
+    }
+
+    /**
+     * 删除标签(同时清理文件关联)。
+     */
+    public void deleteTag(Long id) {
+        if (id == null) {
+            throw new IllegalArgumentException("缺少标签ID");
+        }
+        FileTag tag = fileTagMapper.selectById(id);
+        if (tag == null) {
+            throw new IllegalArgumentException("标签不存在");
+        }
+        fileTagRelMapper.delete(new LambdaQueryWrapper<FileTagRel>().eq(FileTagRel::getTagId, id));
+        fileTagMapper.deleteById(id);
+    }
+
+    private FileTag findTagByName(String name) {
+        List<FileTag> tags = fileTagMapper.selectList(
+                new LambdaQueryWrapper<FileTag>().eq(FileTag::getName, name).last("LIMIT 1"));
+        return tags.isEmpty() ? null : tags.get(0);
+    }
+
+    private String normalizeColor(String color) {
+        if (color == null || color.trim().isEmpty()) {
+            return DEFAULT_TAG_COLOR;
+        }
+        String c = color.trim();
+        return c.startsWith("#") ? c : "#" + c;
+    }
+
+    // ==================== 统计 ====================
+
+    public Map<String, Object> stats() {
+        Map<String, Object> stats = new LinkedHashMap<>();
+        stats.put("byCategory", fileRecordMapper.selectMaps(
+                new QueryWrapper<FileRecord>().select("category, COUNT(*) AS cnt").groupBy("category")));
+        stats.put("byFileType", fileRecordMapper.selectMaps(
+                new QueryWrapper<FileRecord>().select("file_type, COUNT(*) AS cnt").groupBy("file_type")));
+        stats.put("total", fileRecordMapper.selectCount(null));
+        List<Map<String, Object>> ts = fileRecordMapper.selectMaps(
+                new QueryWrapper<FileRecord>().select("SUM(file_size) AS total_bytes"));
+        stats.put("totalBytes", ts.isEmpty() ? 0L : ts.get(0).get("total_bytes"));
+        return stats;
+    }
+}

+ 14 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/StorageService.java

@@ -1,5 +1,6 @@
 package com.etotem.cfc.service;
 package com.etotem.cfc.service;
 
 
+import com.etotem.cfc.entity.FileRecord;
 import com.etotem.cfc.service.api.StorageServiceInterface;
 import com.etotem.cfc.service.api.StorageServiceInterface;
 
 
 import org.springframework.web.multipart.MultipartFile;
 import org.springframework.web.multipart.MultipartFile;
@@ -27,6 +28,19 @@ public interface StorageService extends StorageServiceInterface {
      */
      */
     String storeBytes(byte[] data, String category, String filename, String contentType);
     String storeBytes(byte[] data, String category, String filename, String contentType);
 
 
+    /**
+     * 存储文件并返回持久化后的素材库记录(file_record)。
+     *
+     * <p>用于后台「素材库」统一入库:文件落盘/上云的同时写入 file_record,
+     * 并按 contentType 推导 file_type(image/video/audio/document/other)。
+     * 若文件 hash 已存在(去重命中),返回已存在的记录实体,不会重复落盘。</p>
+     *
+     * @param file     上传文件
+     * @param category 素材分类(articles / general / ...),为空时按 general 处理
+     * @return 已持久化的文件记录
+     */
+    FileRecord storeFileRecord(MultipartFile file, String category);
+
     /**
     /**
      * 删除文件
      * 删除文件
      */
      */

+ 35 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/impl/JdCloudStorageService.java

@@ -11,6 +11,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.etotem.cfc.entity.FileRecord;
 import com.etotem.cfc.entity.FileRecord;
 import com.etotem.cfc.mapper.FileRecordMapper;
 import com.etotem.cfc.mapper.FileRecordMapper;
 import com.etotem.cfc.service.StorageService;
 import com.etotem.cfc.service.StorageService;
+import com.etotem.cfc.util.FileTypeResolver;
 import org.slf4j.Logger;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.beans.factory.annotation.Value;
@@ -103,18 +104,44 @@ public class JdCloudStorageService implements StorageService {
 
 
     @Override
     @Override
     public String storeBytes(byte[] fileBytes, String category, String filename, String contentType) {
     public String storeBytes(byte[] fileBytes, String category, String filename, String contentType) {
+        return storeBytesAndRecord(fileBytes, category, filename, contentType).getUrl();
+    }
+
+    @Override
+    public FileRecord storeFileRecord(MultipartFile file, String category) {
+        try {
+            return storeBytesAndRecord(file.getBytes(), category,
+                    file.getOriginalFilename(), file.getContentType());
+        } catch (IOException e) {
+            throw new RuntimeException("京东云文件存储失败", e);
+        }
+    }
+
+    /**
+     * 存储字节数据并返回持久化后的素材库记录。
+     * 去重命中时返回已有记录(顺带补齐历史记录的 file_type),不重复上传。
+     */
+    private FileRecord storeBytesAndRecord(byte[] fileBytes, String category, String filename, String contentType) {
         try {
         try {
             String hash = sha256(fileBytes);
             String hash = sha256(fileBytes);
 
 
-            String dbCategory = category != null ? category : "general";
+            String dbCategory = isBlank(category) ? "general" : category.trim();
+            String fileType = FileTypeResolver.resolve(contentType, filename);
 
 
             // 查重:按 hash 查找已有记录
             // 查重:按 hash 查找已有记录
             FileRecord existing = fileRecordMapper.selectOne(
             FileRecord existing = fileRecordMapper.selectOne(
                     new LambdaQueryWrapper<FileRecord>().eq(FileRecord::getHash, hash));
                     new LambdaQueryWrapper<FileRecord>().eq(FileRecord::getHash, hash));
             if (existing != null) {
             if (existing != null) {
+                // 历史数据的 file_type 可能为 NULL,去重命中时顺带回填,保证素材库类型筛选可用
+                if (isBlank(existing.getFileType()) && !isBlank(fileType)) {
+                    existing.setFileType(fileType);
+                    existing.setUpdatedAt(new Date());
+                    fileRecordMapper.updateById(existing);
+                    log.info("去重命中并补齐 file_type: hash={}, fileType={}", hash, fileType);
+                }
                 log.info("文件已存在(去重命中): hash={}, url={}, category={}",
                 log.info("文件已存在(去重命中): hash={}, url={}, category={}",
                         hash, existing.getUrl(), existing.getCategory());
                         hash, existing.getUrl(), existing.getCategory());
-                return existing.getUrl();
+                return existing;
             }
             }
 
 
             // 生成 key 并上传
             // 生成 key 并上传
@@ -146,16 +173,21 @@ public class JdCloudStorageService implements StorageService {
             record.setContentType(contentType);
             record.setContentType(contentType);
             record.setOriginalFilename(filename);
             record.setOriginalFilename(filename);
             record.setCategory(dbCategory);
             record.setCategory(dbCategory);
+            record.setFileType(fileType);
             record.setCreatedAt(new Date());
             record.setCreatedAt(new Date());
             record.setUpdatedAt(new Date());
             record.setUpdatedAt(new Date());
             fileRecordMapper.insert(record);
             fileRecordMapper.insert(record);
 
 
-            return url;
+            return record;
         } catch (Exception e) {
         } catch (Exception e) {
             throw new RuntimeException("京东云文件存储失败", e);
             throw new RuntimeException("京东云文件存储失败", e);
         }
         }
     }
     }
 
 
+    private static boolean isBlank(String s) {
+        return s == null || s.trim().isEmpty();
+    }
+
     @Override
     @Override
     public void deleteFile(String fileUrl) {
     public void deleteFile(String fileUrl) {
         if (fileUrl == null || fileUrl.isEmpty()) {
         if (fileUrl == null || fileUrl.isEmpty()) {

+ 66 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/impl/LocalDiskStorageService.java

@@ -1,17 +1,25 @@
 package com.etotem.cfc.service.impl;
 package com.etotem.cfc.service.impl;
 
 
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.FileRecord;
+import com.etotem.cfc.mapper.FileRecordMapper;
 import com.etotem.cfc.service.StorageService;
 import com.etotem.cfc.service.StorageService;
+import com.etotem.cfc.util.FileTypeResolver;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
 import org.springframework.web.multipart.MultipartFile;
 import org.springframework.web.multipart.MultipartFile;
 
 
+import javax.annotation.Resource;
 import java.io.IOException;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.nio.file.Paths;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
 import java.time.LocalDate;
 import java.time.LocalDate;
 import java.time.format.DateTimeFormatter;
 import java.time.format.DateTimeFormatter;
+import java.util.Date;
 import java.util.UUID;
 import java.util.UUID;
 
 
 @Service
 @Service
@@ -21,6 +29,9 @@ public class LocalDiskStorageService implements StorageService {
     @Value("${upload.base-dir:/data/cfc-uploads}")
     @Value("${upload.base-dir:/data/cfc-uploads}")
     private String uploadDir;
     private String uploadDir;
 
 
+    @Resource
+    private FileRecordMapper fileRecordMapper;
+
     @Override
     @Override
     public String storeFile(MultipartFile file) {
     public String storeFile(MultipartFile file) {
         return storeFile(file, null);
         return storeFile(file, null);
@@ -89,6 +100,47 @@ public class LocalDiskStorageService implements StorageService {
         }
         }
     }
     }
 
 
+    @Override
+    public FileRecord storeFileRecord(MultipartFile file, String category) {
+        try {
+            byte[] data = file.getBytes();
+            String filename = file.getOriginalFilename();
+            String contentType = file.getContentType();
+            String hash = sha256(data);
+            String dbCategory = (category == null || category.trim().isEmpty()) ? "general" : category.trim();
+            String fileType = FileTypeResolver.resolve(contentType, filename);
+
+            // 查重:命中则直接返回已有记录(并补齐历史记录缺失的 file_type)
+            FileRecord existing = fileRecordMapper.selectOne(
+                    new LambdaQueryWrapper<FileRecord>().eq(FileRecord::getHash, hash));
+            if (existing != null) {
+                if ((existing.getFileType() == null || existing.getFileType().trim().isEmpty()) && fileType != null) {
+                    existing.setFileType(fileType);
+                    existing.setUpdatedAt(new Date());
+                    fileRecordMapper.updateById(existing);
+                }
+                return existing;
+            }
+
+            String url = storeBytes(data, dbCategory, filename, contentType);
+
+            FileRecord record = new FileRecord();
+            record.setHash(hash);
+            record.setUrl(url);
+            record.setFileSize((long) data.length);
+            record.setContentType(contentType);
+            record.setOriginalFilename(filename);
+            record.setCategory(dbCategory);
+            record.setFileType(fileType);
+            record.setCreatedAt(new Date());
+            record.setUpdatedAt(new Date());
+            fileRecordMapper.insert(record);
+            return record;
+        } catch (IOException e) {
+            throw new RuntimeException("文件存储失败", e);
+        }
+    }
+
     @Override
     @Override
     public void deleteFile(String fileUrl) {
     public void deleteFile(String fileUrl) {
         if (fileUrl == null || fileUrl.isEmpty()) {
         if (fileUrl == null || fileUrl.isEmpty()) {
@@ -112,4 +164,18 @@ public class LocalDiskStorageService implements StorageService {
         String relative = url.startsWith("/uploads/") ? url.substring("/uploads/".length()) : url;
         String relative = url.startsWith("/uploads/") ? url.substring("/uploads/".length()) : url;
         return Paths.get(uploadDir, relative).toAbsolutePath().normalize();
         return Paths.get(uploadDir, relative).toAbsolutePath().normalize();
     }
     }
+
+    private String sha256(byte[] data) {
+        try {
+            MessageDigest md = MessageDigest.getInstance("SHA-256");
+            byte[] digest = md.digest(data);
+            StringBuilder sb = new StringBuilder();
+            for (byte b : digest) {
+                sb.append(String.format("%02x", b));
+            }
+            return sb.toString();
+        } catch (NoSuchAlgorithmException e) {
+            throw new RuntimeException("SHA-256 不可用", e);
+        }
+    }
 }
 }

+ 129 - 0
cfc-backend/src/main/java/com/etotem/cfc/util/FileTypeResolver.java

@@ -0,0 +1,129 @@
+package com.etotem.cfc.util;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Set;
+
+/**
+ * 素材库文件类型推导工具。
+ *
+ * <p>file_record.file_type 取值:image / video / audio / document / other。
+ * 映射规则(与素材库设计规格、历史数据回填 SQL 保持一致):
+ * <ul>
+ *   <li>image/*              → image</li>
+ *   <li>video/*              → video</li>
+ *   <li>audio/*              → audio</li>
+ *   <li>application/pdf      → document</li>
+ *   <li>msword / openxml / excel 系列 → document</li>
+ *   <li>text/*               → document</li>
+ *   <li>其它                 → other</li>
+ * </ul>
+ */
+public final class FileTypeResolver {
+
+    public static final String IMAGE = "image";
+    public static final String VIDEO = "video";
+    public static final String AUDIO = "audio";
+    public static final String DOCUMENT = "document";
+    public static final String OTHER = "other";
+
+    /** 明确归类为文档的 MIME 白名单 */
+    private static final Set<String> DOCUMENT_MIME_TYPES = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
+            "application/pdf",
+            "application/msword",
+            "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+            "application/vnd.ms-excel",
+            "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+            "application/vnd.ms-powerpoint",
+            "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+            "application/rtf",
+            "application/vnd.oasis.opendocument.text",
+            "application/vnd.oasis.opendocument.spreadsheet",
+            "application/vnd.oasis.opendocument.presentation")));
+
+    /** contentType 缺失或为通用二进制流时,按扩展名兜底判断的扩展名映射 */
+    private static final Set<String> IMAGE_EXTENSIONS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
+            "jpg", "jpeg", "png", "gif", "webp", "bmp", "svg", "ico", "heic")));
+
+    private static final Set<String> VIDEO_EXTENSIONS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
+            "mp4", "webm", "ogg", "mov", "m4v", "avi", "mkv")));
+
+    private static final Set<String> AUDIO_EXTENSIONS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
+            "mp3", "wav", "m4a", "aac", "flac", "ogg", "amr")));
+
+    private static final Set<String> DOCUMENT_EXTENSIONS = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(
+            "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "md", "csv", "rtf", "odt", "ods")));
+
+    private FileTypeResolver() {
+    }
+
+    /**
+     * 仅按 MIME 类型推导文件类型。
+     */
+    public static String resolve(String contentType) {
+        return resolve(contentType, null);
+    }
+
+    /**
+     * 推导文件类型:优先按 MIME;MIME 缺失 / 通用二进制流(application/octet-stream)时按扩展名兜底。
+     */
+    public static String resolve(String contentType, String filename) {
+        if (contentType == null || contentType.trim().isEmpty()) {
+            return resolveByExtension(filename);
+        }
+        String ct = contentType.trim().toLowerCase(Locale.ROOT);
+        // 去掉可能带的参数,如 "text/plain; charset=utf-8"
+        int semi = ct.indexOf(';');
+        if (semi > 0) {
+            ct = ct.substring(0, semi).trim();
+        }
+        if (ct.startsWith("image/")) {
+            return IMAGE;
+        }
+        if (ct.startsWith("video/")) {
+            return VIDEO;
+        }
+        if (ct.startsWith("audio/")) {
+            return AUDIO;
+        }
+        if (ct.startsWith("text/")) {
+            return DOCUMENT;
+        }
+        if (DOCUMENT_MIME_TYPES.contains(ct)) {
+            return DOCUMENT;
+        }
+        if ("application/octet-stream".equals(ct)) {
+            return resolveByExtension(filename);
+        }
+        return OTHER;
+    }
+
+    /**
+     * 按文件扩展名推导文件类型(无法识别时返回 other)。
+     */
+    public static String resolveByExtension(String filename) {
+        if (filename == null) {
+            return OTHER;
+        }
+        int dot = filename.lastIndexOf('.');
+        if (dot < 0 || dot == filename.length() - 1) {
+            return OTHER;
+        }
+        String ext = filename.substring(dot + 1).toLowerCase(Locale.ROOT);
+        if (IMAGE_EXTENSIONS.contains(ext)) {
+            return IMAGE;
+        }
+        if (VIDEO_EXTENSIONS.contains(ext)) {
+            return VIDEO;
+        }
+        if (AUDIO_EXTENSIONS.contains(ext)) {
+            return AUDIO;
+        }
+        if (DOCUMENT_EXTENSIONS.contains(ext)) {
+            return DOCUMENT;
+        }
+        return OTHER;
+    }
+}

+ 1 - 1
cfc-backend/src/main/resources/schema.sql

@@ -5237,7 +5237,7 @@ CREATE TABLE IF NOT EXISTS file_record (
     content_type VARCHAR(100) DEFAULT NULL COMMENT 'MIME类型',
     content_type VARCHAR(100) DEFAULT NULL COMMENT 'MIME类型',
     original_filename VARCHAR(255) DEFAULT NULL COMMENT '原始文件名',
     original_filename VARCHAR(255) DEFAULT NULL COMMENT '原始文件名',
     category VARCHAR(50) DEFAULT 'general' COMMENT '分类: articles/dan-reports/assessment-reports/health-reports/general',
     category VARCHAR(50) DEFAULT 'general' COMMENT '分类: articles/dan-reports/assessment-reports/health-reports/general',
-    file_type VARCHAR(50) DEFAULT NULL COMMENT '文件类型: image/pdf/video/audio/other',
+    file_type VARCHAR(50) DEFAULT NULL COMMENT '文件类型: image/video/audio/document/other',
     description VARCHAR(500) DEFAULT NULL COMMENT '描述',
     description VARCHAR(500) DEFAULT NULL COMMENT '描述',
     created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
     created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
     updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

+ 116 - 0
docs/migrations/20261025_asset_library_file_type_backfill.sql

@@ -0,0 +1,116 @@
+-- ============================================================================
+-- 迁移: file_record.file_type 历史数据回填
+-- 日期: 2026-10-25
+-- 功能: 后台素材库(/api/admin/file-record/*)上线前的存量数据初始化
+--
+-- 背景:
+--   file_record.file_type 列早已存在(原注释取值 image/pdf/video/audio/other),
+--   但历史写入路径(JdCloudStorageService / LocalDiskStorageService)未回填该列,
+--   导致大量存量行为 NULL,或沿用旧口径的 'pdf' 字面值。
+--   本次素材库功能把口径统一为 image / video / audio / document / other,
+--   并在写入侧(StorageService.storeFileRecord + FileTypeResolver)自动推导。
+--   本脚本负责把存量数据补齐,与写入侧口径保持一致。
+--
+-- 推导规则(与 FileTypeResolver.resolve 的 MIME 分支严格一致,不做扩展名兜底):
+--   content_type LIKE 'image/%'                        → 'image'
+--   content_type LIKE 'video/%'                        → 'video'
+--   content_type LIKE 'audio/%'                        → 'audio'
+--   content_type LIKE 'text/%'                         → 'document'
+--   content_type IN (application/pdf, application/msword,
+--                    application/vnd.openxmlformats-officedocument.wordprocessingml.document,
+--                    application/vnd.ms-excel,
+--                    application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,
+--                    application/vnd.ms-powerpoint,
+--                    application/vnd.openxmlformats-officedocument.presentationml.presentation,
+--                    application/rtf,
+--                    application/vnd.oasis.opendocument.text,
+--                    application/vnd.oasis.opendocument.spreadsheet,
+--                    application/vnd.oasis.opendocument.presentation) → 'document'
+--   其它(含 content_type 为空、application/octet-stream)      → 'other'
+--
+-- 为什么不用扩展名兜底:
+--   历史数据的 content_type 是上传时浏览器/服务端上报的,可信度高;
+--   而 original_filename 存在被截断/无扩展名/扩展名与实际不符的情况,
+--   按扩展名兜底会引入误判。因此回填与 Java 侧的「MIME 分支」对齐,
+--   扩展名兜底只用于写入侧(新数据 content_type 缺失时的兜底)。
+--
+-- 幂等性:
+--   纯 UPDATE,按 content_type 分类覆盖 file_type,重复执行结果不变。
+--   WHERE 条件中显式排除与目标值相同的行,避免无意义的行更新与 updated_at 抖动。
+--
+-- 回滚:
+--   UPDATE file_record SET file_type = NULL;
+-- ============================================================================
+
+-- ----------------------------------------------------------------------------
+-- 1. 备份(部署前必做)
+-- ----------------------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS file_record_bak_20261025 LIKE file_record;
+INSERT IGNORE INTO file_record_bak_20261025 SELECT * FROM file_record;
+
+-- ----------------------------------------------------------------------------
+-- 2. 回填 file_type(按 content_type 严格分类)
+-- ----------------------------------------------------------------------------
+
+UPDATE file_record
+SET file_type = 'image'
+WHERE (file_type IS NULL OR file_type = 'pdf' OR file_type <> 'image')
+  AND content_type LIKE 'image/%';
+
+UPDATE file_record
+SET file_type = 'video'
+WHERE (file_type IS NULL OR file_type = 'pdf' OR file_type <> 'video')
+  AND content_type LIKE 'video/%';
+
+UPDATE file_record
+SET file_type = 'audio'
+WHERE (file_type IS NULL OR file_type = 'pdf' OR file_type <> 'audio')
+  AND content_type LIKE 'audio/%';
+
+UPDATE file_record
+SET file_type = 'document'
+WHERE (file_type IS NULL OR file_type = 'pdf' OR file_type <> 'document')
+  AND (content_type LIKE 'text/%'
+       OR content_type IN (
+            'application/pdf',
+            'application/msword',
+            'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+            'application/vnd.ms-excel',
+            'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+            'application/vnd.ms-powerpoint',
+            'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+            'application/rtf',
+            'application/vnd.oasis.opendocument.text',
+            'application/vnd.oasis.opendocument.spreadsheet',
+            'application/vnd.oasis.opendocument.presentation'));
+
+-- 剩余未命中的行统一归入 other(含 content_type 为空的情况)
+UPDATE file_record
+SET file_type = 'other'
+WHERE file_type IS NULL OR file_type = 'pdf';
+
+-- ----------------------------------------------------------------------------
+-- 3. 同步修正列注释(口径统一为 document,不再使用 pdf 字面值)
+-- ----------------------------------------------------------------------------
+ALTER TABLE file_record
+    MODIFY COLUMN file_type VARCHAR(50) DEFAULT NULL
+    COMMENT '文件类型: image/video/audio/document/other';
+
+-- ----------------------------------------------------------------------------
+-- 4. 校验(部署后执行,人工核对)
+-- ----------------------------------------------------------------------------
+SELECT file_type, COUNT(*) AS cnt
+FROM file_record
+GROUP BY file_type
+ORDER BY cnt DESC;
+
+-- 期望结果: 无 NULL、无 'pdf' 行
+SELECT COUNT(*) AS remaining_bad
+FROM file_record
+WHERE file_type IS NULL OR file_type = 'pdf';
+
+-- ----------------------------------------------------------------------------
+-- 5. 清理备份(确认无误后执行)
+-- ----------------------------------------------------------------------------
+-- DROP TABLE IF EXISTS file_record_bak_20261025;
+-- ============================================================================

Разница между файлами не показана из-за своего большого размера
+ 3 - 2
docs/superpowers/PROJECT-OVERVIEW.md


+ 61 - 0
docs/superpowers/api/API_REFERENCE.md

@@ -767,6 +767,7 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 | `/api/admin/energy/*` | 能量配置 |
 | `/api/admin/energy/*` | 能量配置 |
 | `/api/admin/energy-config/*` | 五行能量来源/每日限制配置 |
 | `/api/admin/energy-config/*` | 五行能量来源/每日限制配置 |
 | `/api/admin/ecom-supplier/*` | 电商供应商管理 |
 | `/api/admin/ecom-supplier/*` | 电商供应商管理 |
+| `/api/admin/file-record/*` | 后台素材库(上传入库/检索/标签字典/统计,见 §4.56) |
 | `/api/admin/guide/*` | 成长规划师管理(申请/套餐) |
 | `/api/admin/guide/*` | 成长规划师管理(申请/套餐) |
 | `/api/admin/growth-archive-product/*` | 成长档案关联商品 |
 | `/api/admin/growth-archive-product/*` | 成长档案关联商品 |
 | `/api/admin/health-plan/*` | 健康方案管理(跨家庭列表/详情) |
 | `/api/admin/health-plan/*` | 健康方案管理(跨家庭列表/详情) |
@@ -2171,3 +2172,63 @@ W0.2 双配置中心合并:`product_config` 已并入 `service_config`(唯
 
 
 - 实现走统一账本 `CfBalanceService` / `CouponService`,本控制器不直接写库、不提供任何写接口。
 - 实现走统一账本 `CfBalanceService` / `CouponService`,本控制器不直接写库、不提供任何写接口。
 - 流水/余额响应结构单测:`cfc-backend/src/test/java/com/etotem/cfc/unit/CfBalanceControllerTest.java`(memberId 解析命中/未命中 + balance/logs 字段映射)。
 - 流水/余额响应结构单测:`cfc-backend/src/test/java/com/etotem/cfc/unit/CfBalanceControllerTest.java`(memberId 解析命中/未命中 + balance/logs 字段映射)。
+
+### 4.56 管理端-素材库(`/api/admin/file-record/*`)
+
+> 控制器:`FileRecordController`(`controller/admin/FileRecordController.java`),前缀 `/api/admin/file-record`。
+> 服务层:`FileRecordService`;文件存储复用 `StorageService.storeFileRecord(MultipartFile, category)`
+> (`JdCloudStorageService` / `LocalDiskStorageService` 实现),统一写入 `file_record` 并推导 `file_type`。
+> 权限:全部 `@PostMapping`,控制器内 `@RequestAttribute("role") == "admin"` 守卫(无 token 时为 `anonymous`,直接拒绝)。
+> 数据表:`file_record`(唯一键 `uk_hash` 去重)、`file_tag`(`uk_name`)、`file_tag_rel`(`uk_file_tag`),均为既有表,无新增迁移。
+
+**`file_type` 口径**(与 `util/FileTypeResolver.java` 一致):
+
+| file_type | 判定 |
+|-----------|------|
+| `image` | `content_type LIKE 'image/%'` |
+| `video` | `content_type LIKE 'video/%'` |
+| `audio` | `content_type LIKE 'audio/%'` |
+| `document` | `content_type LIKE 'text/%'` 或 PDF/Office/RTF/ODF 白名单 |
+| `other` | 其它(含 `application/octet-stream`、content_type 缺失) |
+
+> 写入侧(新数据):content_type 缺失或为 `application/octet-stream` 时按扩展名兜底;历史回填见 `docs/migrations/20261025_asset_library_file_type_backfill.sql`(严格按 content_type 分类,不用扩展名兜底)。
+
+**接口清单:**
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/admin/file-record/list` | 分页检索。Body:`{ page, pageSize, category, fileType, search, dateFrom, dateTo, tagIds }`;`tagIds` 为数组或逗号分隔字符串,**前置过滤**(先按标签 JOIN 过滤再分页,保证 total 正确)。返回 `{ records, total, page, pageSize }`,records 元素含 `id/hash/url/fileSize/contentType/originalFilename/category/fileType/description/createdAt/updatedAt/tags` |
+| `POST /api/admin/file-record/upload` | 统一上传入库。multipart 字段:`file`(必填)、`category`(选填,默认 `general`)、`tagIds`(选填,数组或逗号分隔)。存储 → 写 `file_record`(SHA-256 去重,命中返回既有记录并补打本次标签)→ 关联标签。图片(contentType 以 image 开头)先过微信内容安全检测(`WechatServiceInterface.checkImage`,fail-open,与旧 `AdminArticleController#uploadImage` 保持一致)。返回素材记录 map |
+| `POST /api/admin/file-record/update` | 更新描述/标签。Body:`{ id, description?, tagIds? }`;`tagIds` 传空数组表示清空标签,不传表示不改动 |
+| `POST /api/admin/file-record/delete` | 删除素材。Body:`{ id }`;先删 OSS 文件(失败仅告警)再删记录及其标签关联 |
+| `POST /api/admin/file-record/tags` | 标签字典列表(按 sortOrder 排序) |
+| `POST /api/admin/file-record/tag/create` | 新建标签。Body:`{ name, color?, sortOrder? }`,name 唯一 |
+| `POST /api/admin/file-record/tag/update` | 更新标签。Body:`{ id, name?, color?, sortOrder? }` |
+| `POST /api/admin/file-record/tag/delete` | 删除标签。Body:`{ id }`(级联清理 `file_tag_rel`) |
+| `POST /api/admin/file-record/stats` | 统计。返回 `{ total, totalBytes, byFileType: [{file_type, cnt}], byCategory: [{category, cnt}] }` |
+
+**上传响应示例(`data` 为素材记录):**
+
+```json
+{
+  "id": 1024,
+  "hash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
+  "url": "https://bucket.endpoint/uploads/general/xxx.png",
+  "fileSize": 102400,
+  "contentType": "image/png",
+  "originalFilename": "cover.png",
+  "category": "articles",
+  "fileType": "image",
+  "description": null,
+  "createdAt": "2026-10-25T12:00:00",
+  "updatedAt": "2026-10-25T12:00:00",
+  "tags": []
+}
+```
+
+**前端配套:** `cfc-web/src/utils/asset.js`(API 封装 + `resolveAssetUrl`)、`cfc-web/src/components/AssetPicker.vue`(素材选择弹窗,image/video/document 三类)、`cfc-web/src/views/admin/AssetLibrary.vue`(素材库管理页,路由 `/admin/asset-library`)、`cfc-web/src/utils/editorAssetUpload.js`(wangEditor 上传配置公共封装,端点统一指向本组 upload)。
+
+**存量回填:** `docs/migrations/20261025_asset_library_file_type_backfill.sql`(备份 → 按 content_type 回填 file_type → 修正列注释 → 校验),不占用 DatabaseInitializer 迁移编号(407-421 为 CF 体系保留)。
+
+*文档更新:2026-10-25(后台素材库:/api/admin/file-record/* 上传入库 + 标签字典 + 统计;file_type 口径统一为 image/video/audio/document/other)*
+

Некоторые файлы не были показаны из-за большого количества измененных файлов