2960 lines
103 KiB
Markdown
2960 lines
103 KiB
Markdown
# 自然写教学资源管理与内容分发系统软件 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;
|
||
}
|
||
}
|
||
```
|
||
|