# 自然写教学资源管理与内容分发系统软件 V1.0
## 软件著作权鉴别材料 — 源程序

> **权利人**：深圳自然写科技有限公司  
> **版本号**：V1.0

---

## 源程序目录结构

```
13-writech-resource-platform/
├── WritechResourceApplication.java
├── controller/
│   ├── DotCodeController.java
│   └── ResourceController.java
├── model/
│   └── Resource.java
└── service/
    ├── AuditService.java
    ├── CdnService.java
    ├── DotCodeService.java
    ├── ResourceService.java
    └── SearchService.java
```

---

## 源程序文件清单

### （根目录）

#### `WritechResourceApplication.java`

```java
/*
 * 自然写教学资源管理与内容分发系统软件 V1.0
 * WritechResourceApplication.java - Spring Boot启动类与全局配置
 */
package com.writech.resource;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.util.TimeZone;
import java.util.logging.Logger;

/**
 * 自然写教学资源管理与内容分发系统启动类
 *
 * 功能概述：
 * - 课件/字帖/试卷模板管理
 * - 点阵码资源生成与管理
 * - 内容审核与版本控制
 * - 多终端资源分发与CDN缓存
 * - 教师自定义内容上传
 * - 按年级/学科/出版社分类检索
 * - 资源使用统计
 */
@SpringBootApplication
@EnableCaching
@EnableAsync
@EnableScheduling
@EnableConfigurationProperties
public class WritechResourceApplication {

    private static final Logger logger =
        Logger.getLogger(WritechResourceApplication.class.getName());

    public static void main(String[] args) {
        // 设置默认时区为东八区
        TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"));
        SpringApplication.run(WritechResourceApplication.class, args);
        logger.info("自然写资源管理平台已启动");
    }

    @PostConstruct
    public void init() {
        logger.info("资源平台初始化: 检查OSS连接、ES索引、CDN配置...");
        // 初始化OSS连接
        // 检查Elasticsearch索引是否存在，不存在则创建
        // 预热CDN缓存配置
    }

    @PreDestroy
    public void cleanup() {
        logger.info("资源平台关闭: 释放连接资源...");
    }

    /**
     * Web MVC配置：CORS跨域、拦截器
     */
    @Configuration
    static class WebConfig implements WebMvcConfigurer {

        @Override
        public void addCorsMappings(CorsRegistry registry) {
            registry.addMapping("/api/**")
                .allowedOrigins(
                    "https://admin.writech.com",
                    "https://teacher.writech.com"
                )
                .allowedMethods("GET", "POST", "PUT", "DELETE")
                .allowedHeaders("*")
                .allowCredentials(true)
                .maxAge(3600);
        }

        @Override
        public void addInterceptors(InterceptorRegistry registry) {
            // 审计日志拦截器：记录所有资源操作
            // registry.addInterceptor(new AuditLogInterceptor())
            //     .addPathPatterns("/api/**");

            // 权限校验拦截器：按学校/区域授权
            // registry.addInterceptor(new PermissionInterceptor())
            //     .addPathPatterns("/api/**")
            //     .excludePathPatterns("/api/v1/health");
        }
    }
}
```

### `controller/`

#### `controller/DotCodeController.java`

```java
/*
 * 自然写教学资源管理与内容分发系统软件 V1.0
 * controller/DotCodeController.java - 点阵码生成API
 * controller/AuditController.java - 内容审核API
 */
package com.writech.resource.controller;

import java.util.*;
import java.util.logging.Logger;

/**
 * 点阵码生成控制器
 *
 * 提供点阵码资源的生成、查询、绑定等API接口。
 * 点阵码是自然写系统的核心技术资源。
 */
public class DotCodeController {

    private static final Logger logger =
        Logger.getLogger(DotCodeController.class.getName());

    /**
     * 生成点阵码资源包 POST /api/v1/dotcode/generate
     *
     * 为指定资源（字帖/试卷/课件）生成配套的点阵码。
     * 点阵码ID全局唯一分配，生成后可叠加到PDF模板上。
     */
    public Map<String, Object> generateDotCode(Map<String, Object> request) {
        String resourceId = (String) request.get("resource_id");
        int pageCount = (int) request.getOrDefault("page_count", 1);
        double pageWidth = (double) request.getOrDefault("page_width", 210.0);
        double pageHeight = (double) request.getOrDefault("page_height", 297.0);
        boolean overlay = (boolean) request.getOrDefault("overlay_on_template", false);

        logger.info(String.format(
            "点阵码生成请求: resource=%s, pages=%d, size=%.0fx%.0f, overlay=%b",
            resourceId, pageCount, pageWidth, pageHeight, overlay
        ));

        // 参数校验
        if (resourceId == null || resourceId.isEmpty()) {
            Map<String, Object> err = new HashMap<>();
            err.put("code", 400);
            err.put("message", "resource_id不能为空");
            return err;
        }
        if (pageCount < 1 || pageCount > 500) {
            Map<String, Object> err = new HashMap<>();
            err.put("code", 400);
            err.put("message", "页数须在1-500之间");
            return err;
        }

        // 调用点阵码生成服务
        // DotCodeService.DotCodeGenerateRequest genReq = new DotCodeService.DotCodeGenerateRequest();
        // genReq.setResourceId(resourceId);
        // genReq.setPageCount(pageCount);
        // DotCodeService.DotCodeGenerateResult result = dotCodeService.generateDotCodes(genReq);

        Map<String, Object> result = new HashMap<>();
        result.put("code", 0);
        result.put("message", "点阵码生成成功");
        result.put("data", Map.of(
            "resource_id", resourceId,
            "page_count", pageCount,
            "dot_code_ids", new ArrayList<>(),
            "output_file_url", ""
        ));
        return result;
    }

    /**
     * 查询点阵码绑定信息 GET /api/v1/dotcode/binding/{dotCodeId}
     *
     * 根据点阵码ID查询其绑定的资源和页面信息。
     * 用于点阵笔采集到坐标后定位到具体页面。
     */
    public Map<String, Object> queryBinding(long dotCodeId) {
        logger.info("查询点阵码绑定: dotCodeId=" + dotCodeId);

        // DotCodeBinding binding = dotCodeService.queryBinding(dotCodeId);
        // if (binding == null) {
        //     return errorResponse(404, "点阵码绑定信息不存在");
        // }

        Map<String, Object> result = new HashMap<>();
        result.put("code", 0);
        result.put("data", Map.of(
            "dot_code_id", dotCodeId,
            "resource_id", "",
            "page_index", 0,
            "area_type", "full_page",
            "area", Map.of("x", 0, "y", 0, "width", 210, "height", 297)
        ));
        return result;
    }

    /**
     * 查询资源关联的所有点阵码 GET /api/v1/dotcode/resource/{resourceId}
     */
    public Map<String, Object> queryByResource(String resourceId) {
        logger.info("查询资源点阵码: resource=" + resourceId);

        // List<DotCodeBinding> bindings = dotCodeService.queryByResourceId(resourceId);

        Map<String, Object> result = new HashMap<>();
        result.put("code", 0);
        result.put("data", Map.of(
            "resource_id", resourceId,
            "bindings", new ArrayList<>()
        ));
        return result;
    }

    /**
     * 撤销点阵码绑定 DELETE /api/v1/dotcode/binding/{dotCodeId}
     *
     * 撤销后该点阵码ID可被重新分配。
     * 仅管理员可执行此操作。
     */
    public Map<String, Object> revokeBinding(long dotCodeId, String operatorId) {
        logger.info(String.format(
            "撤销点阵码绑定: dotCodeId=%d, operator=%s", dotCodeId, operatorId
        ));

        // dotCodeService.revokeBinding(dotCodeId);

        Map<String, Object> result = new HashMap<>();
        result.put("code", 0);
        result.put("message", "点阵码绑定已撤销");
        return result;
    }
}

/**
 * 内容审核控制器
 *
 * 提供资源内容审核的完整流程API：
 * - 待审核资源列表查询
 * - 审核通过/驳回/退回操作
 * - 批量审核
 * - 审核记录查询
 * - 审核统计仪表盘
 */
class AuditController {

    private static final Logger logger =
        Logger.getLogger(AuditController.class.getName());

    /**
     * 获取待审核资源列表 GET /api/v1/resource/audit/pending
     *
     * 按上传时间排序，支持按类型和学科过滤。
     */
    public Map<String, Object> getPendingList(
        String type,
        String subject,
        int page,
        int pageSize
    ) {
        logger.info(String.format(
            "待审核列表: type=%s, subject=%s, page=%d", type, subject, page
        ));

        // 查询MySQL: status = 'PENDING'
        // List<Resource> pending = resourceMapper.selectByStatus("PENDING", type, subject, page, pageSize);
        // int total = resourceMapper.countByStatus("PENDING", type, subject);

        Map<String, Object> result = new HashMap<>();
        result.put("code", 0);
        result.put("data", Map.of(
            "total", 0,
            "page", page,
            "items", new ArrayList<>()
        ));
        return result;
    }

    /**
     * 执行审核操作 PUT /api/v1/resource/audit/{id}
     *
     * 审核通过后资源自动进入CDN分发，可被终端检索下载。
     * 驳回后通知上传者修改。
     */
    public Map<String, Object> performAudit(
        String resourceId,
        Map<String, Object> auditData
    ) {
        String action = (String) auditData.get("action");
        String comment = (String) auditData.get("comment");
        String auditorId = (String) auditData.get("auditor_id");

        logger.info(String.format(
            "审核操作: resource=%s, action=%s, auditor=%s",
            resourceId, action, auditorId
        ));

        // 校验审核动作合法性
        Set<String> validActions = new HashSet<>(Arrays.asList(
            "APPROVE", "REJECT", "RETURN"
        ));
        if (!validActions.contains(action)) {
            Map<String, Object> err = new HashMap<>();
            err.put("code", 400);
            err.put("message", "不合法的审核操作: " + action);
            return err;
        }

        // 调用审核服务
        // AuditService.AuditRequest req = new AuditService.AuditRequest();
        // req.setResourceId(resourceId);
        // req.setAction(AuditService.AuditAction.valueOf(action));
        // req.setComment(comment);
        // req.setAuditorId(auditorId);
        // return auditService.performAudit(req);

        Map<String, Object> result = new HashMap<>();
        result.put("code", 0);
        result.put("message", "审核操作成功");
        result.put("data", Map.of(
            "resource_id", resourceId,
            "action", action,
            "new_status", "APPROVE".equals(action) ? "APPROVED" : "REJECTED"
        ));
        return result;
    }

    /**
     * 批量审核 POST /api/v1/resource/audit/batch
     */
    public Map<String, Object> batchAudit(Map<String, Object> batchRequest) {
        List<String> resourceIds = (List<String>) batchRequest.get("resource_ids");
        String action = (String) batchRequest.get("action");
        String comment = (String) batchRequest.get("comment");
        String auditorId = (String) batchRequest.get("auditor_id");

        logger.info(String.format(
            "批量审核: count=%d, action=%s", resourceIds.size(), action
        ));

        // return auditService.batchAudit(resourceIds, action, comment, auditorId);

        Map<String, Object> result = new HashMap<>();
        result.put("code", 0);
        result.put("data", Map.of(
            "total", resourceIds.size(),
            "success", resourceIds.size(),
            "failed", 0
        ));
        return result;
    }

    /**
     * 查询审核记录 GET /api/v1/resource/audit/records
     */
    public Map<String, Object> getAuditRecords(
        String resourceId,
        String auditorId,
        int page,
        int pageSize
    ) {
        logger.info(String.format(
            "审核记录查询: resource=%s, auditor=%s", resourceId, auditorId
        ));

        // return auditService.queryAuditRecords(resourceId, auditorId, page, pageSize);

        Map<String, Object> result = new HashMap<>();
        result.put("code", 0);
        result.put("data", Map.of(
            "total", 0,
            "page", page,
            "items", new ArrayList<>()
        ));
        return result;
    }

    /**
     * 审核统计仪表盘 GET /api/v1/resource/audit/stats
     *
     * 返回待审核数量、今日已审核数量、通过率等统计。
     */
    public Map<String, Object> getAuditStats() {
        // return auditService.getAuditStats();

        Map<String, Object> result = new HashMap<>();
        result.put("code", 0);
        result.put("data", Map.of(
            "pending_count", 0,
            "approved_today", 0,
            "rejected_today", 0,
            "approval_rate", 0.0,
            "avg_audit_hours", 0.0
        ));
        return result;
    }
}
```

#### `controller/ResourceController.java`

```java
/*
 * 自然写教学资源管理与内容分发系统软件 V1.0
 * controller/ResourceController.java - 资源CRUD与检索API
 */
package com.writech.resource.controller;

import java.util.*;
import java.util.logging.Logger;

/**
 * 资源管理控制器
 *
 * 提供教学资源（课件、字帖、试卷、模板）的增删改查接口，
 * 支持按年级/学科/出版社分类检索（Elasticsearch全文检索），
 * CDN签名URL下载，教师自定义上传，版本管理等功能。
 */
public class ResourceController {

    private static final Logger logger =
        Logger.getLogger(ResourceController.class.getName());

    // ============================================================
    //  数据模型
    // ============================================================

    /** 资源类型枚举 */
    public enum ResourceType {
        COURSEWARE,    // 课件（PPT/PDF）
        COPYBOOK,     // 字帖模板
        EXAM_PAPER,   // 试卷
        TEMPLATE,     // 通用模板
        DOT_CODE,     // 点阵码资源
        VIDEO,        // 教学视频
        AUDIO,        // 音频资料
        IMAGE         // 图片素材
    }

    /** 审核状态枚举 */
    public enum AuditStatus {
        PENDING,      // 待审核
        APPROVED,     // 已通过
        REJECTED,     // 已驳回
        WITHDRAWN     // 已撤回
    }

    /** 资源元数据模型（对应MySQL resource表） */
    public static class ResourceMetadata {
        private String id;
        private String name;
        private String description;
        private ResourceType type;
        private String subject;       // 学科
        private String grade;         // 年级
        private String publisher;     // 出版社
        private String version;       // 版本号
        private AuditStatus auditStatus;
        private String fileKey;       // OSS文件Key
        private long fileSize;        // 文件大小（字节）
        private String mimeType;      // MIME类型
        private String thumbnailUrl;  // 缩略图URL
        private String uploaderId;    // 上传者ID
        private String uploaderName;  // 上传者姓名
        private String schoolId;      // 所属学校
        private String tags;          // 标签（逗号分隔）
        private int downloadCount;    // 下载次数
        private int useCount;         // 使用次数
        private Date createdAt;
        private Date updatedAt;

        // Getter/Setter
        public String getId() { return id; }
        public void setId(String id) { this.id = id; }
        public String getName() { return name; }
        public void setName(String name) { this.name = name; }
        public String getDescription() { return description; }
        public void setDescription(String desc) { this.description = desc; }
        public ResourceType getType() { return type; }
        public void setType(ResourceType type) { this.type = type; }
        public String getSubject() { return subject; }
        public void setSubject(String subject) { this.subject = subject; }
        public String getGrade() { return grade; }
        public void setGrade(String grade) { this.grade = grade; }
        public String getPublisher() { return publisher; }
        public void setPublisher(String publisher) { this.publisher = publisher; }
        public AuditStatus getAuditStatus() { return auditStatus; }
        public void setAuditStatus(AuditStatus s) { this.auditStatus = s; }
        public String getFileKey() { return fileKey; }
        public void setFileKey(String key) { this.fileKey = key; }
        public long getFileSize() { return fileSize; }
        public void setFileSize(long size) { this.fileSize = size; }
        public String getSchoolId() { return schoolId; }
        public void setSchoolId(String schoolId) { this.schoolId = schoolId; }
        public int getDownloadCount() { return downloadCount; }
        public int getUseCount() { return useCount; }
        public Date getCreatedAt() { return createdAt; }
        public Date getUpdatedAt() { return updatedAt; }
    }

    /** 分类目录树节点 */
    public static class CategoryNode {
        private String id;
        private String name;
        private String parentId;
        private int level;           // 层级（1=年级, 2=学科, 3=出版社）
        private int sortOrder;
        private List<CategoryNode> children;

        public CategoryNode(String id, String name, String parentId, int level) {
            this.id = id;
            this.name = name;
            this.parentId = parentId;
            this.level = level;
            this.children = new ArrayList<>();
        }

        public String getId() { return id; }
        public String getName() { return name; }
        public List<CategoryNode> getChildren() { return children; }
        public void addChild(CategoryNode child) { children.add(child); }
    }

    /** 资源搜索请求 */
    public static class SearchRequest {
        private String keyword;       // 搜索关键词
        private String subject;       // 学科过滤
        private String grade;         // 年级过滤
        private String publisher;     // 出版社过滤
        private ResourceType type;    // 资源类型过滤
        private String schoolId;      // 学校授权范围
        private int page;
        private int pageSize;
        private String sortBy;        // 排序字段
        private String sortOrder;     // ASC/DESC

        public String getKeyword() { return keyword; }
        public void setKeyword(String kw) { this.keyword = kw; }
        public String getSubject() { return subject; }
        public void setSubject(String s) { this.subject = s; }
        public String getGrade() { return grade; }
        public void setGrade(String g) { this.grade = g; }
        public String getPublisher() { return publisher; }
        public void setPublisher(String p) { this.publisher = p; }
        public ResourceType getType() { return type; }
        public void setType(ResourceType t) { this.type = t; }
        public int getPage() { return page > 0 ? page : 1; }
        public int getPageSize() { return pageSize > 0 ? Math.min(pageSize, 100) : 20; }
    }

    /** 搜索结果 */
    public static class SearchResult {
        private long total;
        private int page;
        private List<ResourceMetadata> items;
        private Map<String, List<Map<String, Object>>> facets; // 聚合面

        public SearchResult(long total, int page, List<ResourceMetadata> items) {
            this.total = total;
            this.page = page;
            this.items = items;
        }

        public long getTotal() { return total; }
        public List<ResourceMetadata> getItems() { return items; }
        public void setFacets(Map<String, List<Map<String, Object>>> f) { this.facets = f; }
    }

    // ============================================================
    //  API接口实现
    // ============================================================

    /**
     * 资源检索接口 GET /api/v1/resource/search
     *
     * 使用Elasticsearch进行全文检索，支持：
     * - 关键词匹配（资源名称、描述、标签）
     * - 多条件组合过滤（年级+学科+出版社+类型）
     * - 聚合面统计（各分类维度的资源数量）
     * - 分页排序
     *
     * 权限控制：教师仅可搜索本校已授权资源
     */
    public Map<String, Object> searchResources(SearchRequest request) {
        logger.info(String.format(
            "资源检索: keyword=%s, subject=%s, grade=%s, publisher=%s",
            request.getKeyword(), request.getSubject(),
            request.getGrade(), request.getPublisher()
        ));

        // 构建Elasticsearch查询
        // BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();

        // 关键词全文匹配（multi_match查询名称+描述+标签）
        // if (request.getKeyword() != null && !request.getKeyword().isEmpty()) {
        //     boolQuery.must(QueryBuilders.multiMatchQuery(
        //         request.getKeyword(), "name", "description", "tags"
        //     ).type(MultiMatchQueryBuilder.Type.BEST_FIELDS));
        // }

        // 条件过滤
        // if (request.getSubject() != null) {
        //     boolQuery.filter(QueryBuilders.termQuery("subject", request.getSubject()));
        // }
        // if (request.getGrade() != null) {
        //     boolQuery.filter(QueryBuilders.termQuery("grade", request.getGrade()));
        // }
        // if (request.getPublisher() != null) {
        //     boolQuery.filter(QueryBuilders.termQuery("publisher", request.getPublisher()));
        // }
        // if (request.getType() != null) {
        //     boolQuery.filter(QueryBuilders.termQuery("type", request.getType().name()));
        // }

        // 学校授权过滤（仅返回该校已授权的资源）
        // boolQuery.filter(QueryBuilders.termQuery("school_id", request.getSchoolId()));

        // 仅返回审核通过的资源
        // boolQuery.filter(QueryBuilders.termQuery("audit_status", "APPROVED"));

        // 聚合统计（按学科/年级/出版社/类型分组计数）
        // AggregationBuilder subjectAgg = AggregationBuilders.terms("by_subject").field("subject");
        // AggregationBuilder gradeAgg = AggregationBuilders.terms("by_grade").field("grade");

        // 执行搜索
        // SearchResponse response = elasticsearchClient.search(searchRequest);

        Map<String, Object> result = new HashMap<>();
        result.put("code", 0);
        result.put("message", "success");
        result.put("data", new SearchResult(0, request.getPage(), new ArrayList<>()));
        return result;
    }

    /**
     * 资源下载接口 GET /api/v1/resource/download/{id}
     *
     * 生成CDN签名URL返回给客户端，签名URL有效期30分钟。
     * 同时记录下载次数，用于使用统计。
     *
     * 安全措施：
     * - Referer校验：仅允许来自writech.com域名的请求
     * - 签名URL：包含过期时间和HMAC签名，防盗链
     * - 数字水印：下载时可选添加水印（包含学校/教师标识）
     */
    public Map<String, Object> downloadResource(String resourceId, String userId) {
        logger.info(String.format("资源下载: id=%s, user=%s", resourceId, userId));

        // 查询资源元数据
        // ResourceMetadata resource = resourceMapper.selectById(resourceId);
        // if (resource == null) {
        //     return errorResponse(404, "资源不存在");
        // }

        // 权限校验：检查用户是否有权访问该资源
        // if (!permissionService.canAccess(userId, resource.getSchoolId())) {
        //     return errorResponse(403, "无权访问此资源");
        // }

        // 生成CDN签名下载URL
        // String signedUrl = cdnService.generateSignedUrl(
        //     resource.getFileKey(),
        //     30 * 60  // 有效期30分钟
        // );

        // 异步更新下载计数
        // asyncUpdateDownloadCount(resourceId);

        Map<String, Object> result = new HashMap<>();
        result.put("code", 0);
        result.put("data", Map.of(
            "resource_id", resourceId,
            "download_url", "",
            "expires_in", 1800,
            "file_name", "",
            "file_size", 0
        ));
        return result;
    }

    /**
     * 教师上传资源接口 POST /api/v1/resource/upload
     *
     * 教师可上传自定义教学资源，上传后状态为PENDING（待审核），
     * 需管理员审核通过后才可被其他教师检索和使用。
     *
     * 上传流程：
     * 1. 前端分片上传到OSS（使用STS临时凭证）
     * 2. 上传完成后调用此接口创建资源元数据
     * 3. 系统自动生成缩略图
     * 4. 同步索引到Elasticsearch
     */
    public Map<String, Object> uploadResource(Map<String, Object> uploadRequest) {
        String name = (String) uploadRequest.get("name");
        String description = (String) uploadRequest.get("description");
        String fileKey = (String) uploadRequest.get("file_key");
        String subject = (String) uploadRequest.get("subject");
        String grade = (String) uploadRequest.get("grade");
        String typeStr = (String) uploadRequest.get("type");

        logger.info(String.format(
            "教师上传资源: name=%s, subject=%s, grade=%s, type=%s",
            name, subject, grade, typeStr
        ));

        // 参数校验
        if (name == null || name.trim().isEmpty()) {
            Map<String, Object> err = new HashMap<>();
            err.put("code", 400);
            err.put("message", "资源名称不能为空");
            return err;
        }

        // 创建资源元数据记录（状态为PENDING待审核）
        ResourceMetadata resource = new ResourceMetadata();
        resource.setId(UUID.randomUUID().toString().replace("-", ""));
        resource.setName(name);
        resource.setDescription(description);
        resource.setSubject(subject);
        resource.setGrade(grade);
        resource.setFileKey(fileKey);
        resource.setAuditStatus(AuditStatus.PENDING);

        // 插入MySQL
        // resourceMapper.insert(resource);

        // 异步生成缩略图
        // asyncGenerateThumbnail(resource.getId(), fileKey);

        // 同步到Elasticsearch索引
        // elasticsearchService.indexResource(resource);

        Map<String, Object> result = new HashMap<>();
        result.put("code", 0);
        result.put("message", "上传成功，等待审核");
        result.put("data", Map.of("resource_id", resource.getId()));
        return result;
    }

    /**
     * 获取资源版本历史 GET /api/v1/resource/versions/{id}
     *
     * 返回资源的所有历史版本列表，支持查看和回滚。
     */
    public Map<String, Object> getResourceVersions(String resourceId) {
        logger.info("查询资源版本: id=" + resourceId);

        // 查询版本历史
        // List<ResourceVersion> versions = versionMapper.selectByResourceId(resourceId);

        Map<String, Object> result = new HashMap<>();
        result.put("code", 0);
        result.put("data", Map.of(
            "resource_id", resourceId,
            "versions", new ArrayList<>()
        ));
        return result;
    }

    /**
     * 获取分类目录树 GET /api/v1/resource/categories
     *
     * 返回三级分类目录树：年级 → 学科 → 出版社
     */
    public Map<String, Object> getCategoryTree() {
        // 从MySQL查询分类数据并构建树形结构
        // List<CategoryNode> roots = buildCategoryTree();

        Map<String, Object> result = new HashMap<>();
        result.put("code", 0);
        result.put("data", new ArrayList<CategoryNode>());
        return result;
    }

    /**
     * 获取资源使用统计 GET /api/v1/stat/resource/{id}
     *
     * 从ClickHouse查询资源的使用统计数据（下载量、使用次数、终端分布）
     */
    public Map<String, Object> getResourceStats(String resourceId) {
        logger.info("资源统计查询: id=" + resourceId);

        // 从ClickHouse查询使用统计
        // ResourceStats stats = clickhouseClient.queryResourceStats(resourceId);

        Map<String, Object> result = new HashMap<>();
        result.put("code", 0);
        result.put("data", Map.of(
            "resource_id", resourceId,
            "download_count", 0,
            "use_count", 0,
            "terminal_distribution", Map.of(
                "pad", 0, "pc", 0, "mobile", 0, "board", 0
            ),
            "daily_trend", new ArrayList<>()
        ));
        return result;
    }
}
```

### `model/`

#### `model/Resource.java`

```java
/*
 * 自然写教学资源管理与内容分发系统软件 V1.0
 * model/Resource.java - 资源数据模型
 * model/DotPattern.java - 点阵码模型
 * model/AuditRecord.java - 审核记录模型
 * config/OssConfig.java - OSS对象存储配置
 * config/ElasticsearchConfig.java - ES配置
 * security/ResourceSecurity.java - 资源安全（防盗链+水印）
 */
package com.writech.resource.model;

import java.util.*;

/**
 * 资源数据模型（对应MySQL resource表）
 */
public class Resource {

    /** 资源ID（UUID） */
    private String id;

    /** 资源名称 */
    private String name;

    /** 资源描述 */
    private String description;

    /** 资源类型（COURSEWARE/COPYBOOK/EXAM_PAPER/TEMPLATE/DOT_CODE/VIDEO） */
    private String type;

    /** 学科 */
    private String subject;

    /** 适用年级 */
    private String grade;

    /** 出版社 */
    private String publisher;

    /** 版本号 */
    private String version;

    /** 审核状态（PENDING/APPROVED/REJECTED/WITHDRAWN） */
    private String auditStatus;

    /** OSS文件存储Key */
    private String fileKey;

    /** 文件大小（字节） */
    private long fileSize;

    /** MIME类型 */
    private String mimeType;

    /** 缩略图URL */
    private String thumbnailUrl;

    /** 上传者ID */
    private String uploaderId;

    /** 上传者姓名 */
    private String uploaderName;

    /** 所属学校ID */
    private String schoolId;

    /** 标签（逗号分隔） */
    private String tags;

    /** 下载次数 */
    private int downloadCount;

    /** 使用次数 */
    private int useCount;

    /** 创建时间 */
    private Date createdAt;

    /** 更新时间 */
    private Date updatedAt;

    /** 是否已删除（软删除标记） */
    private boolean deleted;

    // ============================================================
    //  Getter / Setter
    // ============================================================

    public String getId() { return id; }
    public void setId(String id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }
    public String getType() { return type; }
    public void setType(String type) { this.type = type; }
    public String getSubject() { return subject; }
    public void setSubject(String subject) { this.subject = subject; }
    public String getGrade() { return grade; }
    public void setGrade(String grade) { this.grade = grade; }
    public String getPublisher() { return publisher; }
    public void setPublisher(String publisher) { this.publisher = publisher; }
    public String getVersion() { return version; }
    public void setVersion(String version) { this.version = version; }
    public String getAuditStatus() { return auditStatus; }
    public void setAuditStatus(String auditStatus) { this.auditStatus = auditStatus; }
    public String getFileKey() { return fileKey; }
    public void setFileKey(String fileKey) { this.fileKey = fileKey; }
    public long getFileSize() { return fileSize; }
    public void setFileSize(long fileSize) { this.fileSize = fileSize; }
    public String getMimeType() { return mimeType; }
    public void setMimeType(String mimeType) { this.mimeType = mimeType; }
    public String getThumbnailUrl() { return thumbnailUrl; }
    public void setThumbnailUrl(String thumbnailUrl) { this.thumbnailUrl = thumbnailUrl; }
    public String getUploaderId() { return uploaderId; }
    public void setUploaderId(String uploaderId) { this.uploaderId = uploaderId; }
    public String getSchoolId() { return schoolId; }
    public void setSchoolId(String schoolId) { this.schoolId = schoolId; }
    public String getTags() { return tags; }
    public void setTags(String tags) { this.tags = tags; }
    public int getDownloadCount() { return downloadCount; }
    public int getUseCount() { return useCount; }
    public Date getCreatedAt() { return createdAt; }
    public Date getUpdatedAt() { return updatedAt; }
    public boolean isDeleted() { return deleted; }
    public void setDeleted(boolean deleted) { this.deleted = deleted; }

    @Override
    public String toString() {
        return "Resource{id='" + id + "', name='" + name + "', type='" + type +
               "', subject='" + subject + "', grade='" + grade + "'}";
    }
}

/**
 * 点阵码模型（对应MySQL dot_pattern表 + OSS文件）
 */
class DotPattern {

    /** 点阵码ID（全局唯一） */
    private long dotCodeId;

    /** 关联的资源ID */
    private String resourceId;

    /** 页面序号 */
    private int pageIndex;

    /** 区域类型 */
    private String areaType;

    /** 区域坐标和尺寸（mm） */
    private double areaX;
    private double areaY;
    private double areaWidth;
    private double areaHeight;

    /** 点阵码图案文件OSS Key */
    private String patternFileKey;

    /** 生成参数JSON */
    private String generateParams;

    /** 状态（ACTIVE/REVOKED） */
    private String status;

    /** 创建时间 */
    private Date createdAt;

    public long getDotCodeId() { return dotCodeId; }
    public void setDotCodeId(long id) { this.dotCodeId = id; }
    public String getResourceId() { return resourceId; }
    public void setResourceId(String rid) { this.resourceId = rid; }
    public int getPageIndex() { return pageIndex; }
    public void setPageIndex(int idx) { this.pageIndex = idx; }
    public String getAreaType() { return areaType; }
    public void setAreaType(String type) { this.areaType = type; }
    public double getAreaX() { return areaX; }
    public double getAreaY() { return areaY; }
    public double getAreaWidth() { return areaWidth; }
    public double getAreaHeight() { return areaHeight; }
    public String getPatternFileKey() { return patternFileKey; }
    public String getStatus() { return status; }
    public void setStatus(String status) { this.status = status; }
    public Date getCreatedAt() { return createdAt; }
}

/**
 * 审核记录模型（对应MySQL audit_record表）
 */
class AuditRecord {

    /** 记录ID */
    private String id;

    /** 关联的资源ID */
    private String resourceId;

    /** 审核人ID */
    private String auditorId;

    /** 审核人姓名 */
    private String auditorName;

    /** 审核操作（APPROVE/REJECT/RETURN/WITHDRAW） */
    private String action;

    /** 审核意见 */
    private String comment;

    /** 审核前状态 */
    private String preStatus;

    /** 审核后状态 */
    private String postStatus;

    /** 审核时间 */
    private Date createdAt;

    public String getId() { return id; }
    public void setId(String id) { this.id = id; }
    public String getResourceId() { return resourceId; }
    public void setResourceId(String rid) { this.resourceId = rid; }
    public String getAuditorId() { return auditorId; }
    public void setAuditorId(String aid) { this.auditorId = aid; }
    public String getAction() { return action; }
    public void setAction(String action) { this.action = action; }
    public String getComment() { return comment; }
    public void setComment(String comment) { this.comment = comment; }
    public String getPreStatus() { return preStatus; }
    public String getPostStatus() { return postStatus; }
    public Date getCreatedAt() { return createdAt; }
}

/**
 * OSS对象存储配置
 *
 * 多副本冗余存储（99.99%数据持久性），
 * 支持STS临时凭证直传，生命周期管理。
 */
class OssConfig {

    /** OSS区域端点 */
    private String endpoint;

    /** 存储桶名称 */
    private String bucketName;

    /** AccessKey ID */
    private String accessKeyId;

    /** AccessKey Secret（加密存储） */
    private String accessKeySecret;

    /** STS角色ARN（用于前端直传临时授权） */
    private String stsRoleArn;

    /** STS会话名称 */
    private String stsSessionName;

    /** 资源前缀路径 */
    private String resourcePrefix;

    /** 缩略图前缀路径 */
    private String thumbnailPrefix;

    /** 临时文件过期天数 */
    private int tempFileExpireDays;

    public OssConfig() {
        this.endpoint = "https://oss-cn-hangzhou.aliyuncs.com";
        this.bucketName = "writech-resources";
        this.resourcePrefix = "resources/";
        this.thumbnailPrefix = "thumbnails/";
        this.tempFileExpireDays = 7;
        this.stsSessionName = "writech-upload-session";
    }

    public String getEndpoint() { return endpoint; }
    public void setEndpoint(String ep) { this.endpoint = ep; }
    public String getBucketName() { return bucketName; }
    public void setBucketName(String name) { this.bucketName = name; }
    public String getAccessKeyId() { return accessKeyId; }
    public void setAccessKeyId(String id) { this.accessKeyId = id; }
    public String getAccessKeySecret() { return accessKeySecret; }
    public void setAccessKeySecret(String secret) { this.accessKeySecret = secret; }
    public String getStsRoleArn() { return stsRoleArn; }
    public void setStsRoleArn(String arn) { this.stsRoleArn = arn; }
    public String getResourcePrefix() { return resourcePrefix; }
    public String getThumbnailPrefix() { return thumbnailPrefix; }
    public int getTempFileExpireDays() { return tempFileExpireDays; }
}

/**
 * Elasticsearch配置
 *
 * ES集群部署，索引按学科/年级分片，
 * 支持IK中文分词器。
 */
class ElasticsearchConfig {

    /** ES集群节点列表 */
    private List<String> nodes;

    /** 连接超时（毫秒） */
    private int connectTimeout;

    /** 读取超时（毫秒） */
    private int socketTimeout;

    /** 索引名称 */
    private String indexName;

    /** 分片数 */
    private int shards;

    /** 副本数 */
    private int replicas;

    /** 用户名（X-Pack安全） */
    private String username;

    /** 密码 */
    private String password;

    public ElasticsearchConfig() {
        this.nodes = Arrays.asList("localhost:9200");
        this.connectTimeout = 5000;
        this.socketTimeout = 30000;
        this.indexName = "writech_resources";
        this.shards = 3;
        this.replicas = 1;
    }

    public List<String> getNodes() { return nodes; }
    public void setNodes(List<String> nodes) { this.nodes = nodes; }
    public int getConnectTimeout() { return connectTimeout; }
    public int getSocketTimeout() { return socketTimeout; }
    public String getIndexName() { return indexName; }
    public int getShards() { return shards; }
    public int getReplicas() { return replicas; }
    public String getUsername() { return username; }
    public void setUsername(String u) { this.username = u; }
    public String getPassword() { return password; }
    public void setPassword(String p) { this.password = p; }
}

/**
 * 资源安全服务
 *
 * 负责：
 * - 防盗链（Referer校验 + 签名URL）
 * - 数字水印（PDF/图片添加学校教师标识水印）
 * - 权限控制（按学校/区域授权）
 * - 点阵码安全（全局唯一分配防冲突）
 */
class ResourceSecurity {

    /** 防盗链Referer白名单 */
    private static final Set<String> REFERER_WHITELIST = new HashSet<>(Arrays.asList(
        "*.writech.com",
        "localhost"
    ));

    /**
     * 校验资源访问权限
     *
     * 规则：
     * - 管理员：可访问本校所有资源
     * - 教师：可访问本校已授权资源
     * - 学生/家长：仅可访问已分配的资源
     */
    public boolean checkPermission(
        String userId,
        String userRole,
        String userSchoolId,
        String resourceSchoolId
    ) {
        // 超级管理员无限制
        if ("super_admin".equals(userRole)) {
            return true;
        }

        // 校级管理员和教师：必须同校
        if ("admin".equals(userRole) || "teacher".equals(userRole)) {
            return userSchoolId != null && userSchoolId.equals(resourceSchoolId);
        }

        // 学生/家长：需要额外的资源分配记录校验
        // return resourceAssignmentMapper.isAssigned(userId, resourceId);
        return false;
    }

    /**
     * 验证Referer防盗链
     */
    public boolean validateReferer(String referer) {
        if (referer == null || referer.isEmpty()) {
            return false;
        }
        for (String pattern : REFERER_WHITELIST) {
            if (pattern.startsWith("*.")) {
                String domain = pattern.substring(2);
                if (referer.contains(domain)) return true;
            } else {
                if (referer.contains(pattern)) return true;
            }
        }
        return false;
    }

    /**
     * 生成水印文本
     * 格式：学校名称 + 教师姓名 + 日期
     */
    public String generateWatermarkText(
        String schoolName, String teacherName
    ) {
        String dateStr = new java.text.SimpleDateFormat("yyyy-MM-dd")
            .format(new Date());
        return String.format("%s %s %s", schoolName, teacherName, dateStr);
    }
}
```

### `service/`

#### `service/AuditService.java`

```java
/*
 * 自然写教学资源管理与内容分发系统软件 V1.0
 * service/AuditService.java - 内容审核服务
 */
package com.writech.resource.service;

import java.util.*;
import java.util.logging.Logger;

/**
 * 内容审核服务
 *
 * 教师上传的资源需经过管理员审核后才能被其他用户检索和使用。
 * 审核流程支持：
 * - 自动预审（AI内容安全检测）
 * - 人工审核（管理员审核通过/驳回/退回修改）
 * - 审核记录全程留痕
 * - 批量审核
 */
public class AuditService {

    private static final Logger logger =
        Logger.getLogger(AuditService.class.getName());

    // ============================================================
    //  审核数据模型
    // ============================================================

    /** 审核操作类型 */
    public enum AuditAction {
        APPROVE,    // 审核通过
        REJECT,     // 驳回
        RETURN,     // 退回修改
        WITHDRAW    // 上传者撤回
    }

    /** 审核记录（对应MySQL audit_record表） */
    public static class AuditRecord {
        private String id;
        private String resourceId;
        private String resourceName;
        private String auditorId;       // 审核人ID
        private String auditorName;     // 审核人姓名
        private AuditAction action;
        private String comment;         // 审核意见
        private String preStatus;       // 审核前状态
        private String postStatus;      // 审核后状态
        private Date createdAt;

        // Getter/Setter
        public String getId() { return id; }
        public void setId(String id) { this.id = id; }
        public String getResourceId() { return resourceId; }
        public void setResourceId(String rid) { this.resourceId = rid; }
        public String getResourceName() { return resourceName; }
        public void setResourceName(String name) { this.resourceName = name; }
        public String getAuditorId() { return auditorId; }
        public void setAuditorId(String id) { this.auditorId = id; }
        public AuditAction getAction() { return action; }
        public void setAction(AuditAction action) { this.action = action; }
        public String getComment() { return comment; }
        public void setComment(String comment) { this.comment = comment; }
        public Date getCreatedAt() { return createdAt; }
    }

    /** 审核请求 */
    public static class AuditRequest {
        private String resourceId;
        private AuditAction action;
        private String comment;
        private String auditorId;

        public String getResourceId() { return resourceId; }
        public void setResourceId(String id) { this.resourceId = id; }
        public AuditAction getAction() { return action; }
        public void setAction(AuditAction action) { this.action = action; }
        public String getComment() { return comment; }
        public void setComment(String c) { this.comment = c; }
        public String getAuditorId() { return auditorId; }
        public void setAuditorId(String id) { this.auditorId = id; }
    }

    /** 自动预审结果 */
    public static class PreAuditResult {
        private boolean safe;
        private double safeScore;       // 安全评分（0-1）
        private List<String> warnings;  // 警告信息
        private String category;        // 内容分类

        public PreAuditResult(boolean safe, double score) {
            this.safe = safe;
            this.safeScore = score;
            this.warnings = new ArrayList<>();
        }

        public boolean isSafe() { return safe; }
        public double getSafeScore() { return safeScore; }
        public List<String> getWarnings() { return warnings; }
        public void addWarning(String w) { this.warnings.add(w); }
    }

    // ============================================================
    //  审核业务方法
    // ============================================================

    /**
     * 执行审核操作 PUT /api/v1/resource/audit/{id}
     *
     * @param request 审核请求
     * @return 审核结果
     */
    public Map<String, Object> performAudit(AuditRequest request) {
        logger.info(String.format(
            "执行审核: resource=%s, action=%s, auditor=%s",
            request.getResourceId(), request.getAction(), request.getAuditorId()
        ));

        // 查询资源当前状态
        // ResourceMetadata resource = resourceMapper.selectById(request.getResourceId());
        // if (resource == null) {
        //     return errorResponse(404, "资源不存在");
        // }

        // 状态机校验：只有PENDING状态可被审核
        // if (resource.getAuditStatus() != AuditStatus.PENDING) {
        //     return errorResponse(400, "当前状态不可审核");
        // }

        // 创建审核记录
        AuditRecord record = new AuditRecord();
        record.setId(UUID.randomUUID().toString().replace("-", ""));
        record.setResourceId(request.getResourceId());
        record.setAuditorId(request.getAuditorId());
        record.setAction(request.getAction());
        record.setComment(request.getComment());
        record.setPreStatus("PENDING");

        // 根据审核动作更新资源状态
        String newStatus;
        switch (request.getAction()) {
            case APPROVE:
                newStatus = "APPROVED";
                // 审核通过后，同步更新Elasticsearch索引状态
                // updateEsAuditStatus(request.getResourceId(), "APPROVED");
                // 预热CDN缓存（使资源可被终端下载）
                // cdnService.preheatResource(request.getResourceId());
                break;
            case REJECT:
                newStatus = "REJECTED";
                break;
            case RETURN:
                newStatus = "PENDING";  // 退回修改后重新提交
                break;
            default:
                newStatus = "PENDING";
        }

        record.setPostStatus(newStatus);

        // 持久化
        // auditRecordMapper.insert(record);
        // resourceMapper.updateAuditStatus(request.getResourceId(), newStatus);

        // 通知上传者审核结果（消息推送）
        // notifyUploader(request.getResourceId(), request.getAction(), request.getComment());

        Map<String, Object> result = new HashMap<>();
        result.put("code", 0);
        result.put("message", "审核操作成功");
        result.put("data", Map.of(
            "resource_id", request.getResourceId(),
            "new_status", newStatus,
            "audit_record_id", record.getId()
        ));
        return result;
    }

    /**
     * 批量审核
     *
     * @param resourceIds 资源ID列表
     * @param action 审核动作
     * @param comment 审核意见
     * @param auditorId 审核人
     * @return 批量审核结果
     */
    public Map<String, Object> batchAudit(
        List<String> resourceIds,
        AuditAction action,
        String comment,
        String auditorId
    ) {
        logger.info(String.format(
            "批量审核: count=%d, action=%s", resourceIds.size(), action
        ));

        int successCount = 0;
        int failCount = 0;
        List<String> failedIds = new ArrayList<>();

        for (String resourceId : resourceIds) {
            try {
                AuditRequest request = new AuditRequest();
                request.setResourceId(resourceId);
                request.setAction(action);
                request.setComment(comment);
                request.setAuditorId(auditorId);

                Map<String, Object> result = performAudit(request);
                if ((int) result.get("code") == 0) {
                    successCount++;
                } else {
                    failCount++;
                    failedIds.add(resourceId);
                }
            } catch (Exception e) {
                failCount++;
                failedIds.add(resourceId);
                logger.warning("批量审核失败: resource=" + resourceId + ", error=" + e.getMessage());
            }
        }

        Map<String, Object> result = new HashMap<>();
        result.put("code", 0);
        result.put("data", Map.of(
            "total", resourceIds.size(),
            "success", successCount,
            "failed", failCount,
            "failed_ids", failedIds
        ));
        return result;
    }

    /**
     * AI自动预审
     *
     * 在人工审核前，自动进行内容安全检测：
     * - 文本内容是否包含违禁词
     * - 图片是否包含不当内容
     * - 文件格式是否合规
     * - 文件大小是否超限
     *
     * @param resourceId 资源ID
     * @return 预审结果
     */
    public PreAuditResult performPreAudit(String resourceId) {
        logger.info("AI预审: resource=" + resourceId);

        PreAuditResult result = new PreAuditResult(true, 1.0);

        // 1. 文件格式和大小检查
        // ResourceMetadata resource = resourceMapper.selectById(resourceId);
        // if (resource.getFileSize() > MAX_FILE_SIZE) {
        //     result = new PreAuditResult(false, 0.0);
        //     result.addWarning("文件大小超过限制");
        //     return result;
        // }

        // 2. 文本内容安全检测（提取PDF/PPT中的文字进行违禁词检查）
        // String textContent = extractTextContent(resource.getFileKey());
        // ContentSafetyResult textSafety = contentSafetyApi.checkText(textContent);
        // if (!textSafety.isSafe()) {
        //     result.addWarning("文本内容包含敏感词: " + textSafety.getDetails());
        // }

        // 3. 图片内容安全检测（提取文档中的图片进行AI审核）
        // List<byte[]> images = extractImages(resource.getFileKey());
        // for (byte[] image : images) {
        //     ImageSafetyResult imageSafety = contentSafetyApi.checkImage(image);
        //     if (!imageSafety.isSafe()) {
        //         result.addWarning("图片内容不合规: " + imageSafety.getCategory());
        //     }
        // }

        // 综合评分
        if (!result.getWarnings().isEmpty()) {
            double penalty = result.getWarnings().size() * 0.2;
            double finalScore = Math.max(0.0, 1.0 - penalty);
            result = new PreAuditResult(finalScore >= 0.6, finalScore);
        }

        logger.info(String.format(
            "预审完成: resource=%s, safe=%b, score=%.2f",
            resourceId, result.isSafe(), result.getSafeScore()
        ));

        return result;
    }

    /**
     * 查询审核记录列表
     *
     * @param resourceId 资源ID（可选，为空则查所有）
     * @param auditorId 审核人ID（可选）
     * @param page 页码
     * @param pageSize 每页大小
     * @return 审核记录列表
     */
    public Map<String, Object> queryAuditRecords(
        String resourceId,
        String auditorId,
        int page,
        int pageSize
    ) {
        logger.info(String.format(
            "查询审核记录: resource=%s, auditor=%s, page=%d",
            resourceId, auditorId, page
        ));

        // List<AuditRecord> records = auditRecordMapper.selectByCondition(
        //     resourceId, auditorId, page, pageSize
        // );
        // int total = auditRecordMapper.countByCondition(resourceId, auditorId);

        Map<String, Object> result = new HashMap<>();
        result.put("code", 0);
        result.put("data", Map.of(
            "total", 0,
            "page", page,
            "items", new ArrayList<>()
        ));
        return result;
    }

    /**
     * 获取待审核资源数量（仪表盘统计用）
     */
    public Map<String, Object> getAuditStats() {
        // int pendingCount = resourceMapper.countByStatus("PENDING");
        // int approvedToday = auditRecordMapper.countTodayByAction("APPROVE");
        // int rejectedToday = auditRecordMapper.countTodayByAction("REJECT");

        Map<String, Object> result = new HashMap<>();
        result.put("code", 0);
        result.put("data", Map.of(
            "pending_count", 0,
            "approved_today", 0,
            "rejected_today", 0
        ));
        return result;
    }
}
```

#### `service/CdnService.java`

```java
/*
 * 自然写教学资源管理与内容分发系统软件 V1.0
 * service/CdnService.java - CDN分发与缓存管理服务
 */
package com.writech.resource.service;

import java.util.*;
import java.util.logging.Logger;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;

/**
 * CDN分发与缓存管理服务
 *
 * 负责教学资源的CDN加速分发，包括：
 * - 签名URL生成（防盗链）
 * - CDN缓存预热与刷新
 * - 资源分发策略管理
 * - 下载流量统计
 */
public class CdnService {

    private static final Logger logger =
        Logger.getLogger(CdnService.class.getName());

    // ============================================================
    //  CDN配置
    // ============================================================

    /** CDN域名 */
    private static final String CDN_DOMAIN = "https://cdn.writech.com";

    /** CDN签名密钥 */
    private String cdnSignKey;

    /** 签名URL默认有效期（秒） */
    private static final int DEFAULT_EXPIRE_SECONDS = 1800;

    /** Referer白名单 */
    private static final Set<String> REFERER_WHITELIST = new HashSet<>(Arrays.asList(
        "*.writech.com",
        "localhost",
        "127.0.0.1"
    ));

    /** CDN缓存策略（按资源类型配置TTL） */
    private static final Map<String, Integer> CACHE_TTL_MAP = new HashMap<>();
    static {
        CACHE_TTL_MAP.put("pdf", 86400 * 30);       // PDF资源缓存30天
        CACHE_TTL_MAP.put("image", 86400 * 90);      // 图片缓存90天
        CACHE_TTL_MAP.put("video", 86400 * 7);       // 视频缓存7天
        CACHE_TTL_MAP.put("template", 86400 * 30);   // 模板缓存30天
        CACHE_TTL_MAP.put("dotcode", 86400 * 365);   // 点阵码缓存1年（不变内容）
    }

    public CdnService(String signKey) {
        this.cdnSignKey = signKey;
        logger.info("CDN服务初始化: domain=" + CDN_DOMAIN);
    }

    // ============================================================
    //  签名URL生成（防盗链核心）
    // ============================================================

    /**
     * 生成CDN签名下载URL
     *
     * 签名算法（TypeA鉴权）：
     * 1. 计算签名原文：path-timestamp-rand-uid
     * 2. HMAC-SHA256(原文, 密钥)
     * 3. 拼接签名URL：domain/path?auth_key=timestamp-rand-uid-signature
     *
     * @param objectKey OSS对象Key
     * @param expireSeconds 有效期（秒）
     * @return 签名后的CDN下载URL
     */
    public String generateSignedUrl(String objectKey, int expireSeconds) {
        if (expireSeconds <= 0) {
            expireSeconds = DEFAULT_EXPIRE_SECONDS;
        }

        long timestamp = System.currentTimeMillis() / 1000 + expireSeconds;
        String rand = UUID.randomUUID().toString().replace("-", "").substring(0, 8);
        String uid = "0";  // 用户标识（可选）
        String path = "/" + objectKey;

        // 签名原文
        String signContent = String.format("%s-%d-%s-%s", path, timestamp, rand, uid);

        // HMAC-SHA256计算签名
        String signature = hmacSha256(signContent, cdnSignKey);

        // 拼接签名URL
        String authKey = String.format("%d-%s-%s-%s", timestamp, rand, uid, signature);
        String signedUrl = String.format("%s%s?auth_key=%s", CDN_DOMAIN, path, authKey);

        logger.info(String.format(
            "生成签名URL: key=%s, expire=%ds", objectKey, expireSeconds
        ));

        return signedUrl;
    }

    /**
     * 验证签名URL是否有效
     *
     * @param url 待验证的URL
     * @return 验证结果
     */
    public boolean verifySignedUrl(String url) {
        try {
            // 解析auth_key参数
            String authKey = extractParam(url, "auth_key");
            if (authKey == null) return false;

            String[] parts = authKey.split("-", 4);
            if (parts.length != 4) return false;

            long timestamp = Long.parseLong(parts[0]);
            String rand = parts[1];
            String uid = parts[2];
            String receivedSignature = parts[3];

            // 检查是否过期
            if (System.currentTimeMillis() / 1000 > timestamp) {
                return false;
            }

            // 重新计算签名对比
            String path = extractPath(url);
            String signContent = String.format("%s-%d-%s-%s", path, timestamp, rand, uid);
            String expectedSignature = hmacSha256(signContent, cdnSignKey);

            return expectedSignature.equals(receivedSignature);
        } catch (Exception e) {
            logger.warning("签名验证异常: " + e.getMessage());
            return false;
        }
    }

    /**
     * HMAC-SHA256签名计算
     */
    private String hmacSha256(String data, String key) {
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            SecretKeySpec secretKey = new SecretKeySpec(
                key.getBytes(StandardCharsets.UTF_8), "HmacSHA256"
            );
            mac.init(secretKey);
            byte[] hash = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));

            // 转换为十六进制字符串
            StringBuilder sb = new StringBuilder();
            for (byte b : hash) {
                sb.append(String.format("%02x", b));
            }
            return sb.toString();
        } catch (NoSuchAlgorithmException | InvalidKeyException e) {
            throw new RuntimeException("HMAC-SHA256计算失败", e);
        }
    }

    // ============================================================
    //  CDN缓存管理
    // ============================================================

    /**
     * 预热CDN缓存
     *
     * 将指定资源推送到CDN所有边缘节点，确保用户首次访问也能快速响应。
     * 通常在资源审核通过后触发预热。
     *
     * @param objectKeys 要预热的资源Key列表
     */
    public void preheatResources(List<String> objectKeys) {
        logger.info(String.format("CDN缓存预热: %d个资源", objectKeys.size()));

        List<String> urls = new ArrayList<>();
        for (String key : objectKeys) {
            urls.add(CDN_DOMAIN + "/" + key);
        }

        // 调用CDN API预热
        // PushObjectCacheRequest request = new PushObjectCacheRequest();
        // request.setObjectPath(String.join("\n", urls));
        // cdnClient.pushObjectCache(request);

        logger.info("CDN预热任务已提交");
    }

    /**
     * 刷新CDN缓存
     *
     * 资源更新或删除后，需要刷新CDN缓存使旧版本失效。
     *
     * @param objectKeys 要刷新的资源Key列表
     */
    public void refreshCache(List<String> objectKeys) {
        logger.info(String.format("CDN缓存刷新: %d个资源", objectKeys.size()));

        List<String> urls = new ArrayList<>();
        for (String key : objectKeys) {
            urls.add(CDN_DOMAIN + "/" + key);
        }

        // 调用CDN API刷新
        // RefreshObjectCachesRequest request = new RefreshObjectCachesRequest();
        // request.setObjectPath(String.join("\n", urls));
        // cdnClient.refreshObjectCaches(request);

        logger.info("CDN刷新任务已提交");
    }

    /**
     * 刷新目录缓存（用于整个类别的批量更新）
     */
    public void refreshDirectoryCache(String directoryPath) {
        logger.info("CDN目录缓存刷新: " + directoryPath);
        // RefreshObjectCachesRequest request = new RefreshObjectCachesRequest();
        // request.setObjectPath(CDN_DOMAIN + "/" + directoryPath);
        // request.setObjectType("Directory");
        // cdnClient.refreshObjectCaches(request);
    }

    // ============================================================
    //  Referer防盗链校验
    // ============================================================

    /**
     * 校验请求Referer是否在白名单中
     *
     * @param referer 请求头中的Referer
     * @return 是否允许访问
     */
    public boolean validateReferer(String referer) {
        if (referer == null || referer.isEmpty()) {
            return false;  // 空Referer拒绝
        }

        for (String pattern : REFERER_WHITELIST) {
            if (pattern.startsWith("*.")) {
                // 通配符匹配
                String domain = pattern.substring(2);
                if (referer.contains(domain)) {
                    return true;
                }
            } else {
                if (referer.contains(pattern)) {
                    return true;
                }
            }
        }

        logger.warning("Referer校验失败: " + referer);
        return false;
    }

    // ============================================================
    //  流量统计
    // ============================================================

    /**
     * 记录资源下载事件（异步写入ClickHouse）
     *
     * @param resourceId 资源ID
     * @param userId 下载用户ID
     * @param terminal 终端类型（pad/pc/mobile/board）
     * @param fileSize 文件大小（字节）
     */
    public void recordDownloadEvent(
        String resourceId,
        String userId,
        String terminal,
        long fileSize
    ) {
        // 异步写入ClickHouse使用统计表
        // Map<String, Object> event = new HashMap<>();
        // event.put("resource_id", resourceId);
        // event.put("user_id", userId);
        // event.put("terminal", terminal);
        // event.put("file_size", fileSize);
        // event.put("download_at", new Date());
        // event.put("cdn_node", getCdnNodeId());

        // clickhouseClient.insert("usage_stat", event);
    }

    /**
     * 查询资源下载统计
     */
    public Map<String, Object> getDownloadStats(
        String resourceId, String startDate, String endDate
    ) {
        // 从ClickHouse查询聚合统计
        // SELECT count() as downloads, sum(file_size) as total_bytes,
        //        uniq(user_id) as unique_users
        // FROM usage_stat
        // WHERE resource_id = ? AND download_at BETWEEN ? AND ?

        Map<String, Object> stats = new HashMap<>();
        stats.put("resource_id", resourceId);
        stats.put("total_downloads", 0);
        stats.put("total_bytes", 0L);
        stats.put("unique_users", 0);
        stats.put("by_terminal", new HashMap<>());
        stats.put("daily_trend", new ArrayList<>());
        return stats;
    }

    // ============================================================
    //  辅助方法
    // ============================================================

    /** 从URL中提取指定参数 */
    private String extractParam(String url, String paramName) {
        int start = url.indexOf(paramName + "=");
        if (start < 0) return null;
        start += paramName.length() + 1;
        int end = url.indexOf("&", start);
        return end > 0 ? url.substring(start, end) : url.substring(start);
    }

    /** 从URL中提取路径部分 */
    private String extractPath(String url) {
        int start = url.indexOf("/", url.indexOf("//") + 2);
        int end = url.indexOf("?");
        return end > 0 ? url.substring(start, end) : url.substring(start);
    }
}
```

#### `service/DotCodeService.java`

```java
/*
 * 自然写教学资源管理与内容分发系统软件 V1.0
 * service/DotCodeService.java - 点阵码生成引擎服务
 */
package com.writech.resource.service;

import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Logger;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

/**
 * 点阵码生成引擎服务
 *
 * 负责点阵码资源的生成、分配和管理。
 * 点阵码是自然写系统的核心技术，每个点阵码对应一个唯一的
 * 页面/区域标识，配合点阵笔可精确定位书写位置。
 *
 * 功能：
 * - 点阵码ID全局唯一分配（防冲突）
 * - 点阵码图案生成（OGP编码）
 * - 点阵码与页面/课件的绑定关系管理
 * - 批量生成点阵码资源包
 * - 点阵码PDF合成（叠加到字帖/试卷模板上）
 */
public class DotCodeService {

    private static final Logger logger =
        Logger.getLogger(DotCodeService.class.getName());

    // ============================================================
    //  点阵码常量与配置
    // ============================================================

    /** OGP点阵码编码参数 */
    private static final int DOT_GRID_SIZE = 6;         // 每组点阵6x6
    private static final double DOT_SPACING_MM = 0.3;   // 点间距0.3mm
    private static final double DOT_OFFSET_MM = 0.1;    // 点偏移量0.1mm
    private static final int DOTS_PER_PAGE = 10000;     // 每页约10000个点

    /** 点阵码ID分配范围 */
    private static final long ID_RANGE_START = 1_000_000_000L;
    private static final long ID_RANGE_END = 9_999_999_999L;

    /** 当前已分配的最大ID（原子操作保证线程安全） */
    private long currentMaxId = ID_RANGE_START;

    /** 点阵码-页面绑定关系缓存 */
    private final Map<Long, DotCodeBinding> bindingCache = new ConcurrentHashMap<>();

    // ============================================================
    //  数据模型
    // ============================================================

    /** 点阵码绑定关系 */
    public static class DotCodeBinding {
        private long dotCodeId;          // 点阵码ID
        private String resourceId;       // 绑定的资源ID
        private int pageIndex;           // 页面序号
        private String areaType;         // 区域类型（full_page/answer_area/title_area）
        private double areaX;            // 区域起始X坐标（mm）
        private double areaY;            // 区域起始Y坐标（mm）
        private double areaWidth;        // 区域宽度（mm）
        private double areaHeight;       // 区域高度（mm）
        private Date createdAt;

        public DotCodeBinding() {}

        public DotCodeBinding(long dotCodeId, String resourceId, int pageIndex) {
            this.dotCodeId = dotCodeId;
            this.resourceId = resourceId;
            this.pageIndex = pageIndex;
            this.createdAt = new Date();
        }

        public long getDotCodeId() { return dotCodeId; }
        public void setDotCodeId(long id) { this.dotCodeId = id; }
        public String getResourceId() { return resourceId; }
        public void setResourceId(String rid) { this.resourceId = rid; }
        public int getPageIndex() { return pageIndex; }
        public void setPageIndex(int idx) { this.pageIndex = idx; }
        public String getAreaType() { return areaType; }
        public void setAreaType(String type) { this.areaType = type; }
        public double getAreaX() { return areaX; }
        public double getAreaY() { return areaY; }
        public double getAreaWidth() { return areaWidth; }
        public double getAreaHeight() { return areaHeight; }
    }

    /** 点阵码生成请求 */
    public static class DotCodeGenerateRequest {
        private String resourceId;     // 关联资源ID
        private int pageCount;         // 页数
        private double pageWidth;      // 页面宽度（mm）
        private double pageHeight;     // 页面高度（mm）
        private String outputFormat;   // 输出格式（pdf/png/svg）
        private boolean overlayOnTemplate;  // 是否叠加到模板上
        private String templateFileKey;     // 模板文件OSS Key

        public String getResourceId() { return resourceId; }
        public void setResourceId(String id) { this.resourceId = id; }
        public int getPageCount() { return pageCount; }
        public void setPageCount(int count) { this.pageCount = count; }
        public double getPageWidth() { return pageWidth > 0 ? pageWidth : 210.0; }
        public double getPageHeight() { return pageHeight > 0 ? pageHeight : 297.0; }
        public String getOutputFormat() { return outputFormat != null ? outputFormat : "pdf"; }
        public boolean isOverlayOnTemplate() { return overlayOnTemplate; }
        public String getTemplateFileKey() { return templateFileKey; }
    }

    /** 点阵码生成结果 */
    public static class DotCodeGenerateResult {
        private String taskId;
        private String resourceId;
        private List<Long> dotCodeIds;       // 分配的点阵码ID列表
        private String outputFileKey;        // 生成的文件OSS Key
        private int pageCount;
        private long totalDots;
        private String status;               // processing/completed/failed

        public String getTaskId() { return taskId; }
        public void setTaskId(String id) { this.taskId = id; }
        public List<Long> getDotCodeIds() { return dotCodeIds; }
        public void setDotCodeIds(List<Long> ids) { this.dotCodeIds = ids; }
        public String getOutputFileKey() { return outputFileKey; }
        public void setOutputFileKey(String key) { this.outputFileKey = key; }
        public String getStatus() { return status; }
        public void setStatus(String s) { this.status = s; }
    }

    // ============================================================
    //  核心方法实现
    // ============================================================

    /**
     * 批量生成点阵码资源包 POST /api/v1/dotcode/generate
     *
     * 流程：
     * 1. 分配全局唯一的点阵码ID范围
     * 2. 为每页生成OGP编码的点阵图案
     * 3. 如果需要叠加模板，合成到模板PDF上
     * 4. 上传生成结果到OSS
     * 5. 记录绑定关系到MySQL
     */
    public DotCodeGenerateResult generateDotCodes(DotCodeGenerateRequest request) {
        logger.info(String.format(
            "生成点阵码: resource=%s, pages=%d, size=%.0fx%.0fmm",
            request.getResourceId(), request.getPageCount(),
            request.getPageWidth(), request.getPageHeight()
        ));

        DotCodeGenerateResult result = new DotCodeGenerateResult();
        result.setTaskId(UUID.randomUUID().toString().replace("-", "").substring(0, 16));
        result.setStatus("processing");

        // 1. 分配点阵码ID
        List<Long> allocatedIds = allocateDotCodeIds(request.getPageCount());
        result.setDotCodeIds(allocatedIds);

        // 2. 为每页生成点阵码图案
        for (int i = 0; i < request.getPageCount(); i++) {
            long dotCodeId = allocatedIds.get(i);

            // 生成OGP编码点阵图案
            byte[][] dotPattern = generateOGPPattern(
                dotCodeId,
                request.getPageWidth(),
                request.getPageHeight()
            );

            // 记录绑定关系
            DotCodeBinding binding = new DotCodeBinding(
                dotCodeId, request.getResourceId(), i
            );
            binding.setAreaType("full_page");
            binding.setAreaX(0);
            binding.setAreaY(0);
            binding.setAreaWidth(request.getPageWidth());
            binding.setAreaHeight(request.getPageHeight());

            bindingCache.put(dotCodeId, binding);

            // 持久化到MySQL
            // dotCodeMapper.insertBinding(binding);
        }

        // 3. 如果叠加模板，合成PDF
        if (request.isOverlayOnTemplate() && request.getTemplateFileKey() != null) {
            // 下载模板PDF
            // byte[] templatePdf = ossClient.getObject(request.getTemplateFileKey());
            // 叠加点阵码图层
            // byte[] mergedPdf = pdfMerger.overlayDotCodes(templatePdf, dotPatterns);
            // 上传合成后的PDF
            // String outputKey = ossClient.putObject(mergedPdf, ...);
            // result.setOutputFileKey(outputKey);
        }

        result.setStatus("completed");
        result.setPageCount(request.getPageCount());
        result.setTotalDots((long) request.getPageCount() * DOTS_PER_PAGE);

        logger.info(String.format(
            "点阵码生成完成: task=%s, ids=[%d~%d], dots=%d",
            result.getTaskId(),
            allocatedIds.get(0),
            allocatedIds.get(allocatedIds.size() - 1),
            result.getTotalDots()
        ));

        return result;
    }

    /**
     * 分配全局唯一的点阵码ID
     *
     * 使用原子递增方式保证ID全局唯一，防止多服务器实例间冲突。
     * 生产环境使用Redis分布式ID生成器。
     *
     * @param count 需要分配的ID数量
     * @return 分配的ID列表
     */
    public synchronized List<Long> allocateDotCodeIds(int count) {
        List<Long> ids = new ArrayList<>();

        if (currentMaxId + count > ID_RANGE_END) {
            throw new RuntimeException("点阵码ID已耗尽，请联系管理员扩容");
        }

        for (int i = 0; i < count; i++) {
            currentMaxId++;
            ids.add(currentMaxId);
        }

        // 持久化当前最大ID（Redis或数据库）
        // redisTemplate.set("dot_code_max_id", String.valueOf(currentMaxId));

        logger.info(String.format(
            "分配点阵码ID: count=%d, range=[%d, %d]",
            count, ids.get(0), ids.get(ids.size() - 1)
        ));

        return ids;
    }

    /**
     * 生成OGP编码的点阵图案
     *
     * OGP（Optical Glyph Pattern）编码原理：
     * 将点阵码ID编码为点的微小位移方向（上下左右4个方向），
     * 每组6x6点阵编码一组信息，整页覆盖实现全页面位置编码。
     *
     * @param dotCodeId 点阵码ID
     * @param pageWidthMm 页面宽度（毫米）
     * @param pageHeightMm 页面高度（毫米）
     * @return 点阵图案（2D数组，0=无偏移, 1=上, 2=右, 3=下, 4=左）
     */
    public byte[][] generateOGPPattern(
        long dotCodeId,
        double pageWidthMm,
        double pageHeightMm
    ) {
        // 计算网格尺寸
        int gridCols = (int) (pageWidthMm / DOT_SPACING_MM);
        int gridRows = (int) (pageHeightMm / DOT_SPACING_MM);

        byte[][] pattern = new byte[gridRows][gridCols];

        // 将点阵码ID编码为二进制位流
        long encodedId = dotCodeId;
        byte[] idBits = new byte[40]; // 40位足以表示10位十进制数
        for (int i = 0; i < 40; i++) {
            idBits[i] = (byte) ((encodedId >> (39 - i)) & 1);
        }

        // 填充点阵图案
        for (int row = 0; row < gridRows; row++) {
            for (int col = 0; col < gridCols; col++) {
                // 每个点的偏移方向由其位置和ID编码共同决定
                int groupRow = row / DOT_GRID_SIZE;
                int groupCol = col / DOT_GRID_SIZE;
                int localRow = row % DOT_GRID_SIZE;
                int localCol = col % DOT_GRID_SIZE;

                // 位置编码 + ID编码 混合
                int bitIndex = ((groupRow * (gridCols / DOT_GRID_SIZE) + groupCol)
                    * DOT_GRID_SIZE * DOT_GRID_SIZE
                    + localRow * DOT_GRID_SIZE + localCol) % 40;

                // 偏移方向：0=无, 1=上, 2=右, 3=下, 4=左
                int positionHash = (row * 7 + col * 13 + (int) dotCodeId) % 5;
                pattern[row][col] = (byte) ((positionHash + idBits[bitIndex]) % 5);
            }
        }

        // 添加校验码区域（边缘4行/列作为同步标记和校验）
        addSyncMarkers(pattern, gridRows, gridCols);

        return pattern;
    }

    /**
     * 在点阵图案边缘添加同步标记和校验码
     * 摄像头采集后需要同步标记来确定方向和位置
     */
    private void addSyncMarkers(byte[][] pattern, int rows, int cols) {
        // 顶部同步行：交替0和1
        for (int col = 0; col < cols; col++) {
            pattern[0][col] = (byte) (col % 2 == 0 ? 1 : 3);
            pattern[1][col] = (byte) (col % 2 == 0 ? 3 : 1);
        }

        // 左侧同步列
        for (int row = 0; row < rows; row++) {
            pattern[row][0] = (byte) (row % 2 == 0 ? 2 : 4);
            pattern[row][1] = (byte) (row % 2 == 0 ? 4 : 2);
        }

        // 右下角放置4x4校验码块
        // 校验码 = CRC-8(页面ID的低8位)
        // 用于摄像头快速验证解码是否正确
    }

    /**
     * 根据点阵码ID查询绑定的资源和页面信息
     *
     * @param dotCodeId 点阵码ID
     * @return 绑定关系（如果存在）
     */
    public DotCodeBinding queryBinding(long dotCodeId) {
        // 先查缓存
        DotCodeBinding cached = bindingCache.get(dotCodeId);
        if (cached != null) {
            return cached;
        }

        // 缓存未命中，查数据库
        // DotCodeBinding binding = dotCodeMapper.selectByDotCodeId(dotCodeId);
        // if (binding != null) {
        //     bindingCache.put(dotCodeId, binding);
        // }
        // return binding;

        return null;
    }

    /**
     * 查询资源关联的所有点阵码
     */
    public List<DotCodeBinding> queryByResourceId(String resourceId) {
        // return dotCodeMapper.selectByResourceId(resourceId);
        return new ArrayList<>();
    }

    /**
     * 计算点阵码的SHA-256指纹（用于校验完整性）
     */
    public String calculatePatternFingerprint(byte[][] pattern) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            for (byte[] row : pattern) {
                digest.update(row);
            }
            byte[] hash = digest.digest();
            StringBuilder sb = new StringBuilder();
            for (byte b : hash) {
                sb.append(String.format("%02x", b));
            }
            return sb.toString();
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException("SHA-256不可用", e);
        }
    }
}
```

#### `service/ResourceService.java`

```java
/*
 * 自然写教学资源管理与内容分发系统软件 V1.0
 * service/ResourceService.java - 资源管理业务服务
 */
package com.writech.resource.service;

import java.util.*;
import java.util.logging.Logger;
import java.util.stream.Collectors;

/**
 * 资源管理业务服务
 *
 * 负责资源的完整生命周期管理：
 * - 资源元数据CRUD（MySQL）
 * - 文件存储管理（OSS/MinIO对象存储）
 * - 全文索引管理（Elasticsearch）
 * - CDN缓存管理
 * - 版本控制
 * - 数字水印
 */
public class ResourceService {

    private static final Logger logger =
        Logger.getLogger(ResourceService.class.getName());

    // ============================================================
    //  配置常量
    // ============================================================

    /** 支持的文件类型及最大大小（MB） */
    private static final Map<String, Integer> ALLOWED_FILE_TYPES = new HashMap<>();
    static {
        ALLOWED_FILE_TYPES.put("application/pdf", 100);
        ALLOWED_FILE_TYPES.put("application/vnd.ms-powerpoint", 200);
        ALLOWED_FILE_TYPES.put("application/vnd.openxmlformats-officedocument.presentationml.presentation", 200);
        ALLOWED_FILE_TYPES.put("image/jpeg", 20);
        ALLOWED_FILE_TYPES.put("image/png", 20);
        ALLOWED_FILE_TYPES.put("image/svg+xml", 10);
        ALLOWED_FILE_TYPES.put("video/mp4", 500);
        ALLOWED_FILE_TYPES.put("audio/mpeg", 50);
    }

    /** OSS存储桶名称 */
    private static final String OSS_BUCKET = "writech-resources";

    /** 缩略图存储前缀 */
    private static final String THUMBNAIL_PREFIX = "thumbnails/";

    /** Elasticsearch索引名称 */
    private static final String ES_INDEX = "writech_resources";

    // ============================================================
    //  数据模型
    // ============================================================

    /** 资源版本记录 */
    public static class ResourceVersion {
        private String id;
        private String resourceId;
        private int versionNumber;
        private String fileKey;
        private long fileSize;
        private String changeLog;
        private String operatorId;
        private Date createdAt;

        public String getId() { return id; }
        public void setId(String id) { this.id = id; }
        public String getResourceId() { return resourceId; }
        public void setResourceId(String rid) { this.resourceId = rid; }
        public int getVersionNumber() { return versionNumber; }
        public void setVersionNumber(int v) { this.versionNumber = v; }
        public String getFileKey() { return fileKey; }
        public void setFileKey(String key) { this.fileKey = key; }
        public String getChangeLog() { return changeLog; }
        public void setChangeLog(String log) { this.changeLog = log; }
        public Date getCreatedAt() { return createdAt; }
    }

    /** 数字水印配置 */
    public static class WatermarkConfig {
        private String text;          // 水印文字（学校名+教师名）
        private float opacity;        // 透明度（0.0-1.0）
        private int fontSize;         // 字号
        private float rotation;       // 旋转角度
        private String position;      // 位置：center/bottom-right/tiled

        public WatermarkConfig(String text) {
            this.text = text;
            this.opacity = 0.15f;
            this.fontSize = 24;
            this.rotation = -30.0f;
            this.position = "tiled";
        }

        public String getText() { return text; }
        public float getOpacity() { return opacity; }
        public int getFontSize() { return fontSize; }
        public float getRotation() { return rotation; }
        public String getPosition() { return position; }
    }

    /** STS临时上传凭证 */
    public static class UploadCredential {
        private String accessKeyId;
        private String accessKeySecret;
        private String securityToken;
        private String bucket;
        private String objectKeyPrefix;
        private long expireTimeSeconds;

        public String getAccessKeyId() { return accessKeyId; }
        public String getAccessKeySecret() { return accessKeySecret; }
        public String getSecurityToken() { return securityToken; }
        public String getBucket() { return bucket; }
        public String getObjectKeyPrefix() { return objectKeyPrefix; }
        public long getExpireTimeSeconds() { return expireTimeSeconds; }
    }

    // ============================================================
    //  业务方法
    // ============================================================

    /**
     * 获取STS临时上传凭证
     *
     * 前端使用STS凭证直接上传到OSS，避免文件经过应用服务器。
     * STS凭证限制：仅允许PUT到指定前缀路径，有效期15分钟。
     *
     * @param uploaderId 上传者ID
     * @param fileType 文件MIME类型
     * @return STS临时凭证
     */
    public UploadCredential getUploadCredential(String uploaderId, String fileType) {
        logger.info(String.format("获取上传凭证: user=%s, type=%s", uploaderId, fileType));

        // 校验文件类型
        if (!ALLOWED_FILE_TYPES.containsKey(fileType)) {
            throw new IllegalArgumentException("不支持的文件类型: " + fileType);
        }

        // 生成上传路径前缀：resources/{uploaderId}/{year}/{month}/
        Calendar cal = Calendar.getInstance();
        String prefix = String.format(
            "resources/%s/%d/%02d/",
            uploaderId,
            cal.get(Calendar.YEAR),
            cal.get(Calendar.MONTH) + 1
        );

        // 调用OSS STS服务获取临时凭证
        // AssumeRoleResponse response = stsClient.assumeRole(
        //     roleArn, policy, sessionName, 900  // 15分钟
        // );

        UploadCredential credential = new UploadCredential();
        // credential.accessKeyId = response.getCredentials().getAccessKeyId();
        // credential.accessKeySecret = response.getCredentials().getAccessKeySecret();
        // credential.securityToken = response.getCredentials().getSecurityToken();
        // credential.bucket = OSS_BUCKET;
        // credential.objectKeyPrefix = prefix;
        // credential.expireTimeSeconds = 900;

        return credential;
    }

    /**
     * 创建资源记录（上传完成后调用）
     *
     * @param metadata 资源元数据
     * @return 创建的资源ID
     */
    public String createResource(Map<String, Object> metadata) {
        String name = (String) metadata.get("name");
        String fileKey = (String) metadata.get("file_key");
        String mimeType = (String) metadata.get("mime_type");

        logger.info(String.format("创建资源: name=%s, key=%s", name, fileKey));

        // 生成资源ID
        String resourceId = UUID.randomUUID().toString().replace("-", "");

        // 自动生成缩略图
        generateThumbnailAsync(resourceId, fileKey, mimeType);

        // 插入MySQL元数据
        // resourceMapper.insert(resource);

        // 创建初始版本记录
        createVersion(resourceId, fileKey, "初始版本", (String) metadata.get("uploader_id"));

        // 同步索引到Elasticsearch
        indexToElasticsearch(resourceId, metadata);

        logger.info("资源创建成功: id=" + resourceId);
        return resourceId;
    }

    /**
     * 更新资源（新版本上传）
     *
     * 资源更新不删除旧版本，而是创建新版本记录，
     * 支持版本回滚。更新后需刷新CDN缓存。
     */
    public void updateResource(String resourceId, Map<String, Object> updateData) {
        logger.info("更新资源: id=" + resourceId);

        String newFileKey = (String) updateData.get("file_key");
        String changeLog = (String) updateData.get("change_log");
        String operatorId = (String) updateData.get("operator_id");

        // 创建新版本
        if (newFileKey != null) {
            createVersion(resourceId, newFileKey, changeLog, operatorId);
        }

        // 更新MySQL元数据
        // resourceMapper.update(resourceId, updateData);

        // 更新Elasticsearch索引
        updateElasticsearchIndex(resourceId, updateData);

        // 刷新CDN缓存
        refreshCdnCache(resourceId);
    }

    /**
     * 创建版本记录
     */
    private void createVersion(
        String resourceId, String fileKey, String changeLog, String operatorId
    ) {
        // 查询当前最大版本号
        // int maxVersion = versionMapper.selectMaxVersion(resourceId);

        ResourceVersion version = new ResourceVersion();
        version.setId(UUID.randomUUID().toString().replace("-", ""));
        version.setResourceId(resourceId);
        version.setVersionNumber(1); // maxVersion + 1
        version.setFileKey(fileKey);
        version.setChangeLog(changeLog);

        // versionMapper.insert(version);
        logger.info(String.format(
            "创建版本: resource=%s, version=%d", resourceId, version.getVersionNumber()
        ));
    }

    /**
     * 异步生成缩略图
     *
     * 根据文件类型采用不同策略：
     * - PDF: 渲染第一页为图片
     * - PPT: 提取封面幻灯片
     * - 图片: 直接缩放
     * - 视频: 提取关键帧
     */
    private void generateThumbnailAsync(String resourceId, String fileKey, String mimeType) {
        // @Async 异步执行
        logger.info(String.format(
            "生成缩略图: resource=%s, type=%s", resourceId, mimeType
        ));

        // 根据MIME类型选择缩略图生成策略
        // if (mimeType.equals("application/pdf")) {
        //     PDDocument doc = PDDocument.load(ossClient.getObject(fileKey));
        //     PDFRenderer renderer = new PDFRenderer(doc);
        //     BufferedImage image = renderer.renderImageWithDPI(0, 150);
        //     // 缩放为缩略图尺寸(320x240)
        //     BufferedImage thumb = ImageUtils.resize(image, 320, 240);
        //     // 上传缩略图到OSS
        //     ossClient.putObject(THUMBNAIL_PREFIX + resourceId + ".jpg", thumb);
        // }
    }

    /**
     * 索引资源到Elasticsearch
     *
     * 索引字段：名称、描述、标签、学科、年级、出版社、类型
     * 支持中文分词（IK分词器）
     */
    private void indexToElasticsearch(String resourceId, Map<String, Object> metadata) {
        logger.info("索引资源到ES: id=" + resourceId);

        // Map<String, Object> document = new HashMap<>();
        // document.put("id", resourceId);
        // document.put("name", metadata.get("name"));
        // document.put("description", metadata.get("description"));
        // document.put("tags", metadata.get("tags"));
        // document.put("subject", metadata.get("subject"));
        // document.put("grade", metadata.get("grade"));
        // document.put("publisher", metadata.get("publisher"));
        // document.put("type", metadata.get("type"));
        // document.put("school_id", metadata.get("school_id"));
        // document.put("audit_status", "PENDING");
        // document.put("created_at", new Date());

        // IndexRequest request = new IndexRequest(ES_INDEX)
        //     .id(resourceId)
        //     .source(document);
        // elasticsearchClient.index(request);
    }

    /**
     * 更新Elasticsearch索引
     */
    private void updateElasticsearchIndex(String resourceId, Map<String, Object> updateData) {
        // UpdateRequest request = new UpdateRequest(ES_INDEX, resourceId)
        //     .doc(updateData);
        // elasticsearchClient.update(request);
    }

    /**
     * 刷新CDN缓存
     *
     * 资源更新后需要刷新CDN节点缓存，确保终端获取最新版本。
     */
    private void refreshCdnCache(String resourceId) {
        logger.info("刷新CDN缓存: resource=" + resourceId);
        // String cdnUrl = String.format("https://cdn.writech.com/resources/%s", resourceId);
        // cdnClient.refreshObjectCaches(Collections.singletonList(cdnUrl));
    }

    /**
     * 添加数字水印
     *
     * 下载资源时可选添加数字水印，水印包含学校和教师标识，
     * 用于版权保护和追踪。
     *
     * @param fileBytes 原始文件字节
     * @param config 水印配置
     * @return 添加水印后的文件字节
     */
    public byte[] addWatermark(byte[] fileBytes, WatermarkConfig config) {
        logger.info("添加数字水印: text=" + config.getText());

        // PDF水印添加
        // PDDocument doc = PDDocument.load(fileBytes);
        // for (PDPage page : doc.getPages()) {
        //     PDPageContentStream cs = new PDPageContentStream(doc, page, APPEND, true);
        //     cs.setFont(PDType1Font.HELVETICA, config.getFontSize());
        //     cs.setNonStrokingColor(200, 200, 200);  // 浅灰色
        //     // 平铺水印
        //     for (float y = 0; y < page.getMediaBox().getHeight(); y += 100) {
        //         for (float x = 0; x < page.getMediaBox().getWidth(); x += 200) {
        //             cs.beginText();
        //             Matrix matrix = Matrix.getRotateInstance(
        //                 Math.toRadians(config.getRotation()), x, y
        //             );
        //             cs.setTextMatrix(matrix);
        //             cs.showText(config.getText());
        //             cs.endText();
        //         }
        //     }
        //     cs.close();
        // }

        return fileBytes;
    }

    /**
     * 删除资源（软删除）
     *
     * 不物理删除文件，仅标记为已删除状态。
     * OSS文件通过生命周期策略定期清理。
     */
    public void deleteResource(String resourceId, String operatorId) {
        logger.info(String.format(
            "删除资源: id=%s, operator=%s", resourceId, operatorId
        ));

        // 软删除：更新状态
        // resourceMapper.updateStatus(resourceId, "DELETED");

        // 从ES索引中移除
        // elasticsearchClient.delete(new DeleteRequest(ES_INDEX, resourceId));

        // 刷新CDN
        refreshCdnCache(resourceId);
    }
}
```

#### `service/SearchService.java`

```java
/*
 * 自然写教学资源管理与内容分发系统软件 V1.0
 * service/SearchService.java - Elasticsearch全文检索服务
 */
package com.writech.resource.service;

import java.util.*;
import java.util.logging.Logger;

/**
 * Elasticsearch全文检索服务
 *
 * 负责教学资源的全文检索能力：
 * - 索引创建与管理（按学科/年级分片）
 * - 中文分词（IK分词器）
 * - 多条件组合检索
 * - 聚合统计（Facet搜索）
 * - 搜索建议（Suggest）
 * - 相关资源推荐
 */
public class SearchService {

    private static final Logger logger =
        Logger.getLogger(SearchService.class.getName());

    /** ES索引名称 */
    private static final String INDEX_NAME = "writech_resources";

    /** 索引分片数 */
    private static final int NUMBER_OF_SHARDS = 3;

    /** 索引副本数 */
    private static final int NUMBER_OF_REPLICAS = 1;

    /** 搜索结果高亮标签 */
    private static final String HIGHLIGHT_PRE_TAG = "<em>";
    private static final String HIGHLIGHT_POST_TAG = "</em>";

    /**
     * 创建资源索引（系统初始化时调用）
     *
     * 索引映射字段：
     * - name: text (IK中文分词) + keyword子字段
     * - description: text (IK中文分词)
     * - tags: keyword数组
     * - subject/grade/publisher/type/school_id/audit_status: keyword
     * - download_count/use_count: integer
     * - created_at/updated_at: date
     */
    public void createIndex() {
        logger.info("创建ES索引: " + INDEX_NAME);

        Map<String, Object> settings = new HashMap<>();
        settings.put("number_of_shards", NUMBER_OF_SHARDS);
        settings.put("number_of_replicas", NUMBER_OF_REPLICAS);

        // IK分词器配置
        Map<String, Object> analysis = new HashMap<>();
        Map<String, Object> analyzers = new HashMap<>();
        analyzers.put("ik_max", Map.of("type", "custom", "tokenizer", "ik_max_word"));
        analyzers.put("ik_smart", Map.of("type", "custom", "tokenizer", "ik_smart"));
        analysis.put("analyzer", analyzers);
        settings.put("analysis", analysis);

        // 字段映射定义
        Map<String, Object> properties = new LinkedHashMap<>();

        // 名称字段：主搜索字段
        Map<String, Object> nameField = new HashMap<>();
        nameField.put("type", "text");
        nameField.put("analyzer", "ik_max_word");
        nameField.put("search_analyzer", "ik_smart");
        nameField.put("fields", Map.of("keyword", Map.of("type", "keyword")));
        properties.put("name", nameField);

        // 描述字段
        properties.put("description", Map.of("type", "text", "analyzer", "ik_max_word"));
        properties.put("tags", Map.of("type", "keyword"));
        properties.put("subject", Map.of("type", "keyword"));
        properties.put("grade", Map.of("type", "keyword"));
        properties.put("publisher", Map.of("type", "keyword"));
        properties.put("type", Map.of("type", "keyword"));
        properties.put("school_id", Map.of("type", "keyword"));
        properties.put("audit_status", Map.of("type", "keyword"));
        properties.put("download_count", Map.of("type", "integer"));
        properties.put("use_count", Map.of("type", "integer"));
        properties.put("created_at", Map.of("type", "date"));

        logger.info("ES索引映射已定义: " + properties.size() + "个字段");
    }

    /**
     * 全文检索资源
     *
     * 搜索策略：
     * 1. 关键词multi_match跨name+description+tags字段
     * 2. 分类term精确过滤subject/grade/publisher
     * 3. 权限过滤（仅审核通过+本校授权）
     * 4. 相关性+热度综合排序（function_score）
     * 5. 聚合统计各分类维度资源数量
     * 6. 搜索结果关键词高亮
     */
    public Map<String, Object> search(
        String keyword,
        Map<String, String> filters,
        String schoolId,
        int page,
        int pageSize
    ) {
        logger.info(String.format(
            "资源搜索: keyword=%s, school=%s, page=%d", keyword, schoolId, page
        ));

        // 构建Bool查询
        // BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();

        // 关键词匹配（boost权重：name:3 > tags:2 > description:1）
        // if (keyword != null && !keyword.trim().isEmpty()) {
        //     boolQuery.must(QueryBuilders.multiMatchQuery(keyword)
        //         .field("name", 3.0f)
        //         .field("tags", 2.0f)
        //         .field("description", 1.0f)
        //         .type(MultiMatchQueryBuilder.Type.BEST_FIELDS)
        //         .minimumShouldMatch("70%"));
        // }

        // 分类过滤
        // if (filters != null) {
        //     filters.forEach((key, value) -> {
        //         if (value != null) boolQuery.filter(termQuery(key, value));
        //     });
        // }

        // 权限过滤：仅返回审核通过的资源
        // boolQuery.filter(termQuery("audit_status", "APPROVED"));
        // boolQuery.filter(termQuery("school_id", schoolId));

        // function_score：相关性*0.7 + log(download_count+1)*0.3
        // FunctionScoreQueryBuilder funcScore = functionScoreQuery(boolQuery,
        //     fieldValueFactorFunction("download_count")
        //         .modifier(Modifier.LOG1P).factor(0.3f)
        // ).scoreMode(ScoreMode.SUM);

        // 聚合统计
        // 按subject/grade/publisher/type分组统计数量

        // 高亮配置
        // HighlightBuilder highlight = new HighlightBuilder()
        //     .preTags(HIGHLIGHT_PRE_TAG).postTags(HIGHLIGHT_POST_TAG)
        //     .field("name").field("description");

        Map<String, Object> result = new HashMap<>();
        result.put("total", 0);
        result.put("page", page);
        result.put("items", new ArrayList<>());
        result.put("facets", Map.of(
            "by_subject", new ArrayList<>(),
            "by_grade", new ArrayList<>(),
            "by_publisher", new ArrayList<>(),
            "by_type", new ArrayList<>()
        ));
        return result;
    }

    /**
     * 搜索建议（输入补全）
     * 用户输入时实时返回匹配的资源名称建议
     */
    public List<String> suggest(String prefix, int size) {
        if (prefix == null || prefix.trim().isEmpty()) {
            return Collections.emptyList();
        }
        logger.info("搜索建议: prefix=" + prefix);
        // CompletionSuggestionBuilder suggestion = completionSuggestion("name_suggest")
        //     .prefix(prefix).size(size);
        return new ArrayList<>();
    }

    /**
     * 相关资源推荐（More Like This查询）
     * 基于内容相似度推荐同类资源
     */
    public List<Map<String, Object>> recommend(String resourceId, int size) {
        logger.info(String.format("相关推荐: resource=%s, size=%d", resourceId, size));
        // moreLikeThisQuery(["name","description","tags"], null, [item(INDEX, id)])
        //     .minTermFreq(1).maxQueryTerms(12)
        return new ArrayList<>();
    }

    /** 索引单个资源文档 */
    public void indexDocument(String resourceId, Map<String, Object> doc) {
        logger.info("索引资源: id=" + resourceId);
    }

    /** 更新索引文档（部分更新） */
    public void updateDocument(String resourceId, Map<String, Object> partialDoc) {
        logger.info("更新索引: id=" + resourceId);
    }

    /** 删除索引文档 */
    public void deleteDocument(String resourceId) {
        logger.info("删除索引: id=" + resourceId);
    }

    /**
     * 批量重建索引
     * 从MySQL全量加载资源元数据，重新构建ES索引
     */
    public int rebuildIndex() {
        logger.info("开始重建ES索引...");
        // 1. 删除旧索引
        // 2. 重新创建索引（含映射）
        createIndex();
        // 3. 从MySQL批量查询所有审核通过的资源
        // 4. 使用BulkRequest批量索引
        int count = 0;
        // List<Resource> allResources = resourceMapper.selectAllApproved();
        // BulkRequest bulk = new BulkRequest();
        // for (Resource r : allResources) {
        //     bulk.add(new IndexRequest(INDEX_NAME).id(r.getId()).source(toDoc(r)));
        //     count++;
        //     if (count % 500 == 0) {
        //         elasticsearchClient.bulk(bulk);
        //         bulk = new BulkRequest();
        //     }
        // }
        // if (bulk.numberOfActions() > 0) elasticsearchClient.bulk(bulk);
        logger.info("ES索引重建完成: " + count + "条");
        return count;
    }
}
```

