# 自然写教学资源管理与内容分发系统软件 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 generateDotCode(Map 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 err = new HashMap<>(); err.put("code", 400); err.put("message", "resource_id不能为空"); return err; } if (pageCount < 1 || pageCount > 500) { Map 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 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 queryBinding(long dotCodeId) { logger.info("查询点阵码绑定: dotCodeId=" + dotCodeId); // DotCodeBinding binding = dotCodeService.queryBinding(dotCodeId); // if (binding == null) { // return errorResponse(404, "点阵码绑定信息不存在"); // } Map 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 queryByResource(String resourceId) { logger.info("查询资源点阵码: resource=" + resourceId); // List bindings = dotCodeService.queryByResourceId(resourceId); Map 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 revokeBinding(long dotCodeId, String operatorId) { logger.info(String.format( "撤销点阵码绑定: dotCodeId=%d, operator=%s", dotCodeId, operatorId )); // dotCodeService.revokeBinding(dotCodeId); Map 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 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 pending = resourceMapper.selectByStatus("PENDING", type, subject, page, pageSize); // int total = resourceMapper.countByStatus("PENDING", type, subject); Map 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 performAudit( String resourceId, Map 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 validActions = new HashSet<>(Arrays.asList( "APPROVE", "REJECT", "RETURN" )); if (!validActions.contains(action)) { Map 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 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 batchAudit(Map batchRequest) { List resourceIds = (List) 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 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 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 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 getAuditStats() { // return auditService.getAuditStats(); Map 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 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 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 items; private Map>> facets; // 聚合面 public SearchResult(long total, int page, List items) { this.total = total; this.page = page; this.items = items; } public long getTotal() { return total; } public List getItems() { return items; } public void setFacets(Map>> f) { this.facets = f; } } // ============================================================ // API接口实现 // ============================================================ /** * 资源检索接口 GET /api/v1/resource/search * * 使用Elasticsearch进行全文检索,支持: * - 关键词匹配(资源名称、描述、标签) * - 多条件组合过滤(年级+学科+出版社+类型) * - 聚合面统计(各分类维度的资源数量) * - 分页排序 * * 权限控制:教师仅可搜索本校已授权资源 */ public Map 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 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 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 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 uploadResource(Map 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 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 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 getResourceVersions(String resourceId) { logger.info("查询资源版本: id=" + resourceId); // 查询版本历史 // List versions = versionMapper.selectByResourceId(resourceId); Map 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 getCategoryTree() { // 从MySQL查询分类数据并构建树形结构 // List roots = buildCategoryTree(); Map result = new HashMap<>(); result.put("code", 0); result.put("data", new ArrayList()); return result; } /** * 获取资源使用统计 GET /api/v1/stat/resource/{id} * * 从ClickHouse查询资源的使用统计数据(下载量、使用次数、终端分布) */ public Map getResourceStats(String resourceId) { logger.info("资源统计查询: id=" + resourceId); // 从ClickHouse查询使用统计 // ResourceStats stats = clickhouseClient.queryResourceStats(resourceId); Map 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 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 getNodes() { return nodes; } public void setNodes(List 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 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 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 getWarnings() { return warnings; } public void addWarning(String w) { this.warnings.add(w); } } // ============================================================ // 审核业务方法 // ============================================================ /** * 执行审核操作 PUT /api/v1/resource/audit/{id} * * @param request 审核请求 * @return 审核结果 */ public Map 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 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 batchAudit( List 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 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 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 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 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 queryAuditRecords( String resourceId, String auditorId, int page, int pageSize ) { logger.info(String.format( "查询审核记录: resource=%s, auditor=%s, page=%d", resourceId, auditorId, page )); // List records = auditRecordMapper.selectByCondition( // resourceId, auditorId, page, pageSize // ); // int total = auditRecordMapper.countByCondition(resourceId, auditorId); Map result = new HashMap<>(); result.put("code", 0); result.put("data", Map.of( "total", 0, "page", page, "items", new ArrayList<>() )); return result; } /** * 获取待审核资源数量(仪表盘统计用) */ public Map getAuditStats() { // int pendingCount = resourceMapper.countByStatus("PENDING"); // int approvedToday = auditRecordMapper.countTodayByAction("APPROVE"); // int rejectedToday = auditRecordMapper.countTodayByAction("REJECT"); Map 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 REFERER_WHITELIST = new HashSet<>(Arrays.asList( "*.writech.com", "localhost", "127.0.0.1" )); /** CDN缓存策略(按资源类型配置TTL) */ private static final Map 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 objectKeys) { logger.info(String.format("CDN缓存预热: %d个资源", objectKeys.size())); List 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 objectKeys) { logger.info(String.format("CDN缓存刷新: %d个资源", objectKeys.size())); List 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 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 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 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 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 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 getDotCodeIds() { return dotCodeIds; } public void setDotCodeIds(List 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 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 allocateDotCodeIds(int count) { List 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 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 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 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 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 metadata) { logger.info("索引资源到ES: id=" + resourceId); // Map 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 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 = ""; private static final String HIGHLIGHT_POST_TAG = ""; /** * 创建资源索引(系统初始化时调用) * * 索引映射字段: * - 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 settings = new HashMap<>(); settings.put("number_of_shards", NUMBER_OF_SHARDS); settings.put("number_of_replicas", NUMBER_OF_REPLICAS); // IK分词器配置 Map analysis = new HashMap<>(); Map 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 properties = new LinkedHashMap<>(); // 名称字段:主搜索字段 Map 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 search( String keyword, Map 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 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 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> 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 doc) { logger.info("索引资源: id=" + resourceId); } /** 更新索引文档(部分更新) */ public void updateDocument(String resourceId, Map 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 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; } } ```