software copyright
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
/*
|
||||
* 自然写教学资源管理与内容分发系统软件 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
/*
|
||||
* 自然写教学资源管理与内容分发系统软件 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
/*
|
||||
* 自然写教学资源管理与内容分发系统软件 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
/*
|
||||
* 自然写教学资源管理与内容分发系统软件 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/*
|
||||
* 自然写教学资源管理与内容分发系统软件 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user