diff --git a/backend/archive-service/pom.xml b/backend/archive-service/pom.xml index f21d90b..dd38df3 100644 --- a/backend/archive-service/pom.xml +++ b/backend/archive-service/pom.xml @@ -19,6 +19,11 @@ org.bouncycastlebcpkix-jdk18on1.78 com.alibabaeasyexcel3.3.4 io.milvusmilvus-sdk-java2.4.10 + + com.qcloud + cos_api + 5.6.227 + diff --git a/backend/archive-service/src/main/java/com/artedu/archive/controller/ArchiveMessageController.java b/backend/archive-service/src/main/java/com/artedu/archive/controller/ArchiveMessageController.java index fc0e27b..da4cab3 100644 --- a/backend/archive-service/src/main/java/com/artedu/archive/controller/ArchiveMessageController.java +++ b/backend/archive-service/src/main/java/com/artedu/archive/controller/ArchiveMessageController.java @@ -8,6 +8,7 @@ import com.artedu.archive.mapper.CustomerMapper; import com.artedu.archive.mapper.StaffMapper; import com.artedu.archive.client.WeComApiClient; import com.artedu.archive.service.ArchivePullService; +import com.artedu.archive.service.CosStorageService; import com.artedu.archive.vo.ArchiveMessageVO; import com.artedu.common.result.PageResult; import com.artedu.common.result.Result; @@ -60,6 +61,9 @@ public class ArchiveMessageController { @Autowired private ArchivePullService archivePullService; + @Autowired + private CosStorageService cosStorageService; + @Autowired private WeComApiClient weComApiClient; @@ -70,9 +74,10 @@ public class ArchiveMessageController { /** * 下载媒体文件(图片/语音/视频等) + * 如果已配置并启用 COS,优先返回 COS URL 302 重定向 */ @GetMapping("/media/{id}") - public ResponseEntity downloadMedia(@PathVariable Long id) { + public ResponseEntity downloadMedia(@PathVariable Long id) { ArchiveMessage message = archiveMessageMapper.selectById(id); if (message == null) { return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null); @@ -84,6 +89,21 @@ public class ArchiveMessageController { return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null); } + // 优先尝试 COS + try { + String cosUrl = cosStorageService.getCosUrl(id); + if (cosUrl == null || cosUrl.isEmpty()) { + cosUrl = cosStorageService.ensureUploaded(message); + } + if (cosUrl != null && !cosUrl.isEmpty()) { + HttpHeaders headers = new HttpHeaders(); + headers.add("Location", cosUrl); + return new ResponseEntity<>(headers, HttpStatus.FOUND); + } + } catch (Exception e) { + log.warn("COS 媒体处理失败,回退到本地: id={}", id, e); + } + // 从 mediaData 解析 sdkfileid String sdkfileid = null; try { diff --git a/backend/archive-service/src/main/java/com/artedu/archive/controller/CosConfigController.java b/backend/archive-service/src/main/java/com/artedu/archive/controller/CosConfigController.java new file mode 100644 index 0000000..3e03c27 --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/controller/CosConfigController.java @@ -0,0 +1,162 @@ +package com.artedu.archive.controller; + +import com.artedu.archive.entity.CosConfig; +import com.artedu.archive.mapper.CosConfigMapper; +import com.artedu.archive.service.CosStorageService; +import com.artedu.archive.vo.CosConfigVO; +import com.artedu.common.result.Result; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 腾讯云 COS 配置管理接口 + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/archive/cos-config") +public class CosConfigController { + + @Autowired + private CosConfigMapper cosConfigMapper; + + @Autowired + private CosStorageService cosStorageService; + + /** + * 获取当前 COS 配置 + */ + @GetMapping + public Result getConfig() { + List list = cosConfigMapper.selectList(new LambdaQueryWrapper().orderByDesc(CosConfig::getId)); + if (list == null || list.isEmpty()) { + return Result.success(null); + } + CosConfig config = list.get(0); + return Result.success(toVO(config, true)); + } + + /** + * 保存或更新 COS 配置(全局仅保留一条有效配置) + */ + @PostMapping + public Result saveConfig(@RequestBody CosConfig config) { + if (config == null) { + return Result.fail("配置不能为空"); + } + // 查询是否已有配置 + CosConfig existing = null; + List list = cosConfigMapper.selectList(new LambdaQueryWrapper().orderByDesc(CosConfig::getId)); + if (list != null && !list.isEmpty()) { + existing = list.get(0); + } + + // 兼容前端把脱敏后的 SecretKey 原样传回的情况:包含 * 则视为未传 + if (config.getSecretKey() != null && config.getSecretKey().contains("*")) { + config.setSecretKey(null); + } + + if (config.getSecretId() == null || config.getSecretId().isEmpty() + || config.getBucket() == null || config.getBucket().isEmpty() + || config.getRegion() == null || config.getRegion().isEmpty()) { + return Result.fail("SecretId、Bucket、Region 不能为空"); + } + // SecretKey 为空时必须有已保存的配置 + if ((config.getSecretKey() == null || config.getSecretKey().isEmpty()) && existing == null) { + return Result.fail("首次保存时 SecretKey 不能为空"); + } + + // 路径前缀默认值 + if (config.getPathPrefix() == null || config.getPathPrefix().isEmpty()) { + config.setPathPrefix("archive/media/"); + } + + if (existing != null) { + // 如果前端没传 SecretKey,保留原有 SecretKey + if (config.getSecretKey() == null || config.getSecretKey().isEmpty()) { + config.setSecretKey(existing.getSecretKey()); + } + config.setId(existing.getId()); + config.setCreatedAt(existing.getCreatedAt()); + config.setUpdatedAt(LocalDateTime.now()); + cosConfigMapper.updateById(config); + } else { + config.setCreatedAt(LocalDateTime.now()); + config.setUpdatedAt(LocalDateTime.now()); + cosConfigMapper.insert(config); + } + + return Result.success(toVO(config, true)); + } + + /** + * 测试 COS 连接 + */ + @PostMapping("/test") + public Result testConnection(@RequestBody CosConfig config) { + if (config == null) { + return Result.fail("配置不能为空"); + } + // 如果前端没传完整凭据,尝试用已保存配置补齐(SecretKey 等) + if ((config.getSecretKey() == null || config.getSecretKey().isEmpty()) + || (config.getSecretId() == null || config.getSecretId().isEmpty())) { + CosConfig saved = null; + List savedList = cosConfigMapper.selectList(new LambdaQueryWrapper().orderByDesc(CosConfig::getId)); + if (savedList != null && !savedList.isEmpty()) { + saved = savedList.get(0); + } + if (saved == null) { + return Result.fail("配置信息不完整"); + } + if (config.getSecretId() == null || config.getSecretId().isEmpty()) { + config.setSecretId(saved.getSecretId()); + } + if (config.getSecretKey() == null || config.getSecretKey().isEmpty()) { + config.setSecretKey(saved.getSecretKey()); + } + if (config.getBucket() == null || config.getBucket().isEmpty()) { + config.setBucket(saved.getBucket()); + } + if (config.getRegion() == null || config.getRegion().isEmpty()) { + config.setRegion(saved.getRegion()); + } + } + if (config.getSecretId() == null || config.getSecretKey() == null + || config.getBucket() == null || config.getRegion() == null) { + return Result.fail("配置信息不完整"); + } + boolean ok = cosStorageService.testConnection(config); + return Result.success(ok); + } + + /** + * 批量迁移历史媒体文件到 COS + */ + @PostMapping("/migrate") + public Result migrate(@RequestParam(defaultValue = "50") Integer batchSize) { + if (batchSize == null || batchSize <= 0 || batchSize > 200) { + batchSize = 50; + } + int count = cosStorageService.migrateLocalFiles(batchSize); + return Result.success(count); + } + + private CosConfigVO toVO(CosConfig config, boolean maskSecretKey) { + CosConfigVO vo = new CosConfigVO(); + BeanUtils.copyProperties(config, vo); + if (maskSecretKey && vo.getSecretKey() != null && !vo.getSecretKey().isEmpty()) { + String key = vo.getSecretKey(); + if (key.length() > 8) { + vo.setSecretKey(key.substring(0, 4) + "****" + key.substring(key.length() - 4)); + } else { + vo.setSecretKey("****"); + } + } + return vo; + } +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/entity/ArchiveMediaFile.java b/backend/archive-service/src/main/java/com/artedu/archive/entity/ArchiveMediaFile.java new file mode 100644 index 0000000..42bd623 --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/entity/ArchiveMediaFile.java @@ -0,0 +1,48 @@ +package com.artedu.archive.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 会话存档媒体文件 COS 映射实体 + * 对应 archive_media_files 表 + */ +@Data +@TableName("archive_media_files") +public class ArchiveMediaFile { + + @TableId(type = IdType.AUTO) + private Long id; + + @TableField("msg_id") + private String msgId; + + @TableField("archive_message_id") + private Long archiveMessageId; + + @TableField("sdkfileid") + private String sdkfileid; + + @TableField("cos_url") + private String cosUrl; + + @TableField("file_size") + private Long fileSize; + + @TableField("file_type") + private String fileType; + + @TableField("status") + private Integer status; + + @TableField("created_at") + private LocalDateTime createdAt; + + @TableField("updated_at") + private LocalDateTime updatedAt; +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/entity/CosConfig.java b/backend/archive-service/src/main/java/com/artedu/archive/entity/CosConfig.java new file mode 100644 index 0000000..22dfbe0 --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/entity/CosConfig.java @@ -0,0 +1,48 @@ +package com.artedu.archive.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 腾讯云 COS 配置实体 + * 对应 cos_config 表 + */ +@Data +@TableName("cos_config") +public class CosConfig { + + @TableId(type = IdType.AUTO) + private Long id; + + @TableField("secret_id") + private String secretId; + + @TableField("secret_key") + private String secretKey; + + @TableField("bucket") + private String bucket; + + @TableField("region") + private String region; + + @TableField("path_prefix") + private String pathPrefix; + + @TableField("domain") + private String domain; + + @TableField("enabled") + private Integer enabled; + + @TableField("created_at") + private LocalDateTime createdAt; + + @TableField("updated_at") + private LocalDateTime updatedAt; +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/mapper/ArchiveMediaFileMapper.java b/backend/archive-service/src/main/java/com/artedu/archive/mapper/ArchiveMediaFileMapper.java new file mode 100644 index 0000000..3df0eb1 --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/mapper/ArchiveMediaFileMapper.java @@ -0,0 +1,26 @@ +package com.artedu.archive.mapper; + +import com.artedu.archive.entity.ArchiveMediaFile; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +/** + * 会话存档媒体文件映射 Mapper + */ +@Mapper +public interface ArchiveMediaFileMapper extends BaseMapper { + + /** + * 根据消息 ID 查询 + */ + @Select("SELECT * FROM archive_media_files WHERE archive_message_id = #{archiveMessageId} AND status = 1 LIMIT 1") + ArchiveMediaFile selectByArchiveMessageId(@Param("archiveMessageId") Long archiveMessageId); + + /** + * 根据企微消息 msgid 查询 + */ + @Select("SELECT * FROM archive_media_files WHERE msg_id = #{msgId} AND status = 1 LIMIT 1") + ArchiveMediaFile selectByMsgId(@Param("msgId") String msgId); +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/mapper/ArchiveMessageMapper.java b/backend/archive-service/src/main/java/com/artedu/archive/mapper/ArchiveMessageMapper.java index 57939d6..e174a30 100644 --- a/backend/archive-service/src/main/java/com/artedu/archive/mapper/ArchiveMessageMapper.java +++ b/backend/archive-service/src/main/java/com/artedu/archive/mapper/ArchiveMessageMapper.java @@ -105,4 +105,14 @@ public interface ArchiveMessageMapper extends BaseMapper { @Select("SELECT DISTINCT from_user FROM archive_messages WHERE corp_id = #{corpId} AND from_role = 'INTERNAL'") List selectStaffIdsWithArchive(@Param("corpId") String corpId); + + /** + * 查询尚未上传到 COS 的媒体消息 + */ + @Select("SELECT m.* FROM archive_messages m " + + "LEFT JOIN archive_media_files f ON m.id = f.archive_message_id AND f.status = 1 " + + "WHERE m.media_data IS NOT NULL AND m.media_data != '' " + + "AND f.id IS NULL " + + "ORDER BY m.id LIMIT #{offset}, #{limit}") + List selectMediaMessagesWithoutCos(@Param("offset") int offset, @Param("limit") int limit); } diff --git a/backend/archive-service/src/main/java/com/artedu/archive/mapper/CosConfigMapper.java b/backend/archive-service/src/main/java/com/artedu/archive/mapper/CosConfigMapper.java new file mode 100644 index 0000000..de3ceb2 --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/mapper/CosConfigMapper.java @@ -0,0 +1,19 @@ +package com.artedu.archive.mapper; + +import com.artedu.archive.entity.CosConfig; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; + +/** + * COS 配置 Mapper + */ +@Mapper +public interface CosConfigMapper extends BaseMapper { + + /** + * 查询当前启用的配置 + */ + @Select("SELECT * FROM cos_config WHERE enabled = 1 LIMIT 1") + CosConfig selectActive(); +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/service/CosStorageService.java b/backend/archive-service/src/main/java/com/artedu/archive/service/CosStorageService.java new file mode 100644 index 0000000..1e1c2df --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/service/CosStorageService.java @@ -0,0 +1,307 @@ +package com.artedu.archive.service; + +import com.artedu.archive.entity.ArchiveMediaFile; +import com.artedu.archive.entity.ArchiveMessage; +import com.artedu.archive.entity.CosConfig; +import com.artedu.archive.mapper.ArchiveMediaFileMapper; +import com.artedu.archive.mapper.ArchiveMessageMapper; +import com.artedu.archive.mapper.CosConfigMapper; +import com.artedu.common.util.JsonUtils; +import com.qcloud.cos.COSClient; +import com.qcloud.cos.ClientConfig; +import com.qcloud.cos.auth.BasicCOSCredentials; +import com.qcloud.cos.auth.COSCredentials; +import com.qcloud.cos.exception.CosClientException; +import com.qcloud.cos.exception.CosServiceException; +import com.qcloud.cos.model.HeadBucketRequest; +import com.qcloud.cos.model.ObjectMetadata; +import com.qcloud.cos.model.PutObjectRequest; +import com.qcloud.cos.model.PutObjectResult; +import com.qcloud.cos.region.Region; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.nio.file.Files; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 腾讯云 COS 存储服务 + * 负责媒体文件上传到 COS 及 URL 管理 + */ +@Slf4j +@Service +public class CosStorageService { + + @Autowired + private CosConfigMapper cosConfigMapper; + + @Autowired + private ArchiveMediaFileMapper archiveMediaFileMapper; + + @Autowired + private ArchiveMessageMapper archiveMessageMapper; + + @Autowired + private ArchivePullService archivePullService; + + private static final String MEDIA_CACHE_DIR = "/app/media"; + + /** + * 获取当前启用的 COS 配置 + */ + public CosConfig getActiveConfig() { + try { + return cosConfigMapper.selectActive(); + } catch (Exception e) { + log.error("查询 COS 配置失败", e); + return null; + } + } + + /** + * 根据配置创建 COS 客户端 + */ + public COSClient createCosClient(CosConfig config) { + COSCredentials cred = new BasicCOSCredentials(config.getSecretId(), config.getSecretKey()); + ClientConfig clientConfig = new ClientConfig(new Region(config.getRegion())); + return new COSClient(cred, clientConfig); + } + + /** + * 测试 COS 连接 + */ + public boolean testConnection(CosConfig config) { + COSClient cosClient = null; + try { + cosClient = createCosClient(config); + // 简单调用 head bucket 验证权限 + HeadBucketRequest request = new HeadBucketRequest(config.getBucket()); + cosClient.headBucket(request); + return true; + } catch (CosServiceException e) { + log.error("COS 连接测试失败(服务异常): errCode={}, statusCode={}", e.getErrorCode(), e.getStatusCode(), e); + return false; + } catch (CosClientException e) { + log.error("COS 连接测试失败(客户端异常): {}", e.getMessage(), e); + return false; + } finally { + if (cosClient != null) { + cosClient.shutdown(); + } + } + } + + /** + * 生成 COS 对象 key + */ + public String buildCosKey(CosConfig config, ArchiveMessage message, String ext) { + String prefix = config.getPathPrefix(); + if (prefix == null || prefix.isEmpty()) { + prefix = "archive/media/"; + } + if (!prefix.endsWith("/")) { + prefix = prefix + "/"; + } + String fileName = message.getMsgid().replaceAll("[^a-zA-Z0-9_-]", "_") + ext; + return prefix + fileName; + } + + /** + * 上传文件到 COS + */ + public String uploadToCos(CosConfig config, File file, String cosKey, String contentType) { + COSClient cosClient = createCosClient(config); + try { + ObjectMetadata metadata = new ObjectMetadata(); + if (contentType != null && !contentType.isEmpty()) { + metadata.setContentType(contentType); + } + metadata.setContentLength(file.length()); + PutObjectRequest putObjectRequest = new PutObjectRequest(config.getBucket(), cosKey, file); + putObjectRequest.setMetadata(metadata); + PutObjectResult result = cosClient.putObject(putObjectRequest); + log.info("上传文件到 COS 成功: bucket={}, key={}, etag={}", config.getBucket(), cosKey, result.getETag()); + return buildAccessUrl(config, cosKey); + } finally { + cosClient.shutdown(); + } + } + + /** + * 上传字节数组到 COS + */ + public String uploadToCos(CosConfig config, byte[] data, String cosKey, String contentType) { + COSClient cosClient = createCosClient(config); + try { + ObjectMetadata metadata = new ObjectMetadata(); + if (contentType != null && !contentType.isEmpty()) { + metadata.setContentType(contentType); + } + metadata.setContentLength(data.length); + PutObjectRequest putObjectRequest = new PutObjectRequest( + config.getBucket(), cosKey, new ByteArrayInputStream(data), metadata); + PutObjectResult result = cosClient.putObject(putObjectRequest); + log.info("上传字节到 COS 成功: bucket={}, key={}, etag={}", config.getBucket(), cosKey, result.getETag()); + return buildAccessUrl(config, cosKey); + } finally { + cosClient.shutdown(); + } + } + + /** + * 构建访问 URL + */ + public String buildAccessUrl(CosConfig config, String cosKey) { + String domain = config.getDomain(); + if (domain != null && !domain.isEmpty()) { + if (!domain.startsWith("http://") && !domain.startsWith("https://")) { + domain = "https://" + domain; + } + if (domain.endsWith("/")) { + domain = domain.substring(0, domain.length() - 1); + } + return domain + "/" + cosKey; + } + // 默认使用腾讯云 COS 域名 + return "https://" + config.getBucket() + ".cos." + config.getRegion() + ".myqcloud.com/" + cosKey; + } + + /** + * 根据消息 ID 查询 COS URL + */ + public String getCosUrl(Long archiveMessageId) { + ArchiveMediaFile mediaFile = archiveMediaFileMapper.selectByArchiveMessageId(archiveMessageId); + if (mediaFile != null && mediaFile.getStatus() != null && mediaFile.getStatus() == 1) { + return mediaFile.getCosUrl(); + } + return null; + } + + /** + * 确保媒体文件已上传到 COS,返回 COS URL + */ + public String ensureUploaded(ArchiveMessage message) { + CosConfig config = getActiveConfig(); + if (config == null || config.getEnabled() == null || config.getEnabled() != 1) { + return null; + } + + // 已存在则直接返回 + String existingUrl = getCosUrl(message.getId()); + if (existingUrl != null && !existingUrl.isEmpty()) { + return existingUrl; + } + + String mediaData = message.getMediaData(); + if (mediaData == null || mediaData.isEmpty()) { + return null; + } + + String sdkfileid = null; + try { + Map mediaMap = JsonUtils.fromJsonMap(mediaData); + if (mediaMap != null) { + sdkfileid = (String) mediaMap.get("sdkfileid"); + } + } catch (Exception e) { + log.error("解析 mediaData 失败: id={}, mediaData={}", message.getId(), mediaData); + return null; + } + if (sdkfileid == null || sdkfileid.isEmpty()) { + return null; + } + + // 下载到本地缓存 + String ext = getMediaExtension(message.getMsgtype()); + String contentType = getMediaContentType(message.getMsgtype()); + File cacheDir = new File(MEDIA_CACHE_DIR); + if (!cacheDir.exists()) { + cacheDir.mkdirs(); + } + String cacheFileName = message.getMsgid().replaceAll("[^a-zA-Z0-9_-]", "_") + ext; + File cacheFile = new File(cacheDir, cacheFileName); + + if (!cacheFile.exists()) { + boolean success = archivePullService.downloadMedia(sdkfileid, cacheFile.getAbsolutePath()); + if (!success) { + log.error("下载媒体文件失败: id={}, sdkfileid={}", message.getId(), sdkfileid); + return null; + } + } + + // 上传到 COS + String cosKey = buildCosKey(config, message, ext); + String cosUrl = uploadToCos(config, cacheFile, cosKey, contentType); + + // 保存映射关系 + ArchiveMediaFile mediaFile = new ArchiveMediaFile(); + mediaFile.setMsgId(message.getMsgid()); + mediaFile.setArchiveMessageId(message.getId()); + mediaFile.setSdkfileid(sdkfileid); + mediaFile.setCosUrl(cosUrl); + mediaFile.setFileSize(cacheFile.length()); + mediaFile.setFileType(message.getMsgtype()); + mediaFile.setStatus(1); + archiveMediaFileMapper.insert(mediaFile); + + return cosUrl; + } + + /** + * 批量迁移本地历史媒体文件到 COS + */ + public int migrateLocalFiles(int batchSize) { + CosConfig config = getActiveConfig(); + if (config == null || config.getEnabled() == null || config.getEnabled() != 1) { + log.warn("COS 未启用,无法迁移"); + return 0; + } + + int count = 0; + int offset = 0; + while (true) { + List messages = archiveMessageMapper.selectMediaMessagesWithoutCos(offset, batchSize); + if (messages == null || messages.isEmpty()) { + break; + } + for (ArchiveMessage message : messages) { + try { + String url = ensureUploaded(message); + if (url != null && !url.isEmpty()) { + count++; + } + } catch (Exception e) { + log.error("迁移媒体文件失败: id={}", message.getId(), e); + } + } + offset += batchSize; + // 简单限流,避免触发接口频率限制 + try { + Thread.sleep(50); + } catch (InterruptedException ignored) { + } + } + log.info("批量迁移完成,共迁移 {} 个文件", count); + return count; + } + + private String getMediaExtension(String msgtype) { + if ("image".equals(msgtype)) return ".jpg"; + if ("voice".equals(msgtype)) return ".amr"; + if ("video".equals(msgtype)) return ".mp4"; + return ""; + } + + private String getMediaContentType(String msgtype) { + if ("image".equals(msgtype)) return "image/jpeg"; + if ("voice".equals(msgtype)) return "audio/amr"; + if ("video".equals(msgtype)) return "video/mp4"; + return "application/octet-stream"; + } +} diff --git a/backend/archive-service/src/main/java/com/artedu/archive/vo/CosConfigVO.java b/backend/archive-service/src/main/java/com/artedu/archive/vo/CosConfigVO.java new file mode 100644 index 0000000..3116bb5 --- /dev/null +++ b/backend/archive-service/src/main/java/com/artedu/archive/vo/CosConfigVO.java @@ -0,0 +1,32 @@ +package com.artedu.archive.vo; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * COS 配置 VO(返回给前端,SecretKey 脱敏) + */ +@Data +public class CosConfigVO { + + private Long id; + + private String secretId; + + private String secretKey; + + private String bucket; + + private String region; + + private String pathPrefix; + + private String domain; + + private Integer enabled; + + private LocalDateTime createdAt; + + private LocalDateTime updatedAt; +} diff --git a/deploy_cos_tables.sql b/deploy_cos_tables.sql new file mode 100644 index 0000000..83760a7 --- /dev/null +++ b/deploy_cos_tables.sql @@ -0,0 +1,29 @@ +CREATE TABLE IF NOT EXISTS `cos_config` ( + `id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键', + `secret_id` VARCHAR(255) NOT NULL COMMENT '腾讯云 SecretId', + `secret_key` VARCHAR(255) NOT NULL COMMENT '腾讯云 SecretKey', + `bucket` VARCHAR(255) NOT NULL COMMENT '存储桶名称', + `region` VARCHAR(64) NOT NULL COMMENT '地域,如 ap-shanghai', + `path_prefix` VARCHAR(255) DEFAULT 'archive/media/' COMMENT 'COS 路径前缀', + `domain` VARCHAR(255) DEFAULT NULL COMMENT '自定义访问域名(可选)', + `enabled` TINYINT(1) DEFAULT 0 COMMENT '是否启用:0-停用 1-启用', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + INDEX `idx_enabled` (`enabled`) COMMENT '启用状态索引' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='腾讯云 COS 配置表'; + +CREATE TABLE IF NOT EXISTS `archive_media_files` ( + `id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键', + `msg_id` VARCHAR(255) NOT NULL COMMENT '企微消息 msgid', + `archive_message_id` BIGINT(20) NOT NULL COMMENT 'archive_messages.id', + `sdkfileid` VARCHAR(512) DEFAULT NULL COMMENT '企微媒体文件 sdkfileid', + `cos_url` VARCHAR(1024) NOT NULL COMMENT 'COS 文件访问 URL', + `file_size` BIGINT(20) DEFAULT 0 COMMENT '文件大小(字节)', + `file_type` VARCHAR(32) DEFAULT NULL COMMENT '文件类型:image/voice/video/file', + `status` TINYINT(1) DEFAULT 1 COMMENT '状态:1-有效 0-无效', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY `uk_archive_msg_id` (`archive_message_id`) COMMENT '消息ID唯一', + UNIQUE KEY `uk_msg_id` (`msg_id`) COMMENT '企微消息ID唯一', + INDEX `idx_status` (`status`) COMMENT '状态索引' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会话存档媒体文件 COS 映射表'; diff --git a/frontend/admin/src/App.tsx b/frontend/admin/src/App.tsx index 80c6f0f..ddb7e6b 100644 --- a/frontend/admin/src/App.tsx +++ b/frontend/admin/src/App.tsx @@ -14,6 +14,7 @@ import RiskEventList from './pages/RiskEventList' import MessageSearch from './pages/MessageSearch' import LlmModelList from './pages/LlmModelList' import ChatMining from './pages/ChatMining' +import CosConfig from './pages/CosConfig' function App() { return ( @@ -35,6 +36,7 @@ function App() { } /> } /> } /> + } /> diff --git a/frontend/admin/src/components/Layout.tsx b/frontend/admin/src/components/Layout.tsx index e375f51..1fb60a5 100644 --- a/frontend/admin/src/components/Layout.tsx +++ b/frontend/admin/src/components/Layout.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' import { Outlet, useNavigate, useLocation } from 'react-router-dom' import { Layout as AntLayout, Menu, Button, message, Badge } from 'antd' -import { DashboardOutlined, CommentOutlined, BookOutlined, TeamOutlined, UserOutlined, SafetyCertificateOutlined, RobotOutlined, GoldOutlined } from '@ant-design/icons' +import { DashboardOutlined, CommentOutlined, BookOutlined, TeamOutlined, UserOutlined, SafetyCertificateOutlined, RobotOutlined, GoldOutlined, CloudUploadOutlined } from '@ant-design/icons' import { connectRiskEventStream, disconnectRiskEventStream, requestNotificationPermission, showDesktopNotification } from '../utils/notification' const { Header, Sider, Content } = AntLayout @@ -77,6 +77,7 @@ export default function Layout() { }, { key: '/admins', icon: , label: '管理员账号' }, { key: '/llm-models', icon: , label: '大模型管理' }, + { key: '/cos-config', icon: , label: 'COS配置' }, { key: '/chat-mining', icon: , label: '会话挖掘' }, ] diff --git a/frontend/admin/src/pages/CosConfig.tsx b/frontend/admin/src/pages/CosConfig.tsx new file mode 100644 index 0000000..9967496 --- /dev/null +++ b/frontend/admin/src/pages/CosConfig.tsx @@ -0,0 +1,224 @@ +import { useState, useEffect } from 'react' +import { Card, Form, Input, Switch, Button, message, Space, Tag } from 'antd' +import { CloudUploadOutlined, SaveOutlined, ApiOutlined } from '@ant-design/icons' +import request from '../utils/request' + +interface CosConfigForm { + id?: number + secretId: string + secretKey: string + bucket: string + region: string + pathPrefix: string + domain: string + enabled: boolean +} + +export default function CosConfig() { + const [form] = Form.useForm() + const [loading, setLoading] = useState(false) + const [testLoading, setTestLoading] = useState(false) + const [migrateLoading, setMigrateLoading] = useState(false) + const [migratedCount, setMigratedCount] = useState(null) + const [hasSecretKey, setHasSecretKey] = useState(false) + + const loadConfig = async () => { + setLoading(true) + try { + const res: any = await request.get('/v1/archive/cos-config') + const data = res.data + if (data) { + // 后端返回的 secretKey 是脱敏的,不能作为表单默认值,否则保存时会把掩码存回去 + const { secretKey, ...rest } = data + form.setFieldsValue({ + ...rest, + enabled: data.enabled === 1, + }) + // 如果返回的 SecretKey 是脱敏的(包含*),说明后端有值 + setHasSecretKey(secretKey && secretKey.includes('*')) + } else { + form.setFieldsValue({ + pathPrefix: 'archive/media/', + enabled: false, + }) + } + } finally { + setLoading(false) + } + } + + useEffect(() => { + loadConfig() + }, []) + + const handleSave = async (values: CosConfigForm) => { + const payload = { + ...values, + enabled: values.enabled ? 1 : 0, + } + // 如果 SecretKey 为空且已有配置,则保留原值由后端处理 + if (!payload.secretKey && hasSecretKey) { + payload.secretKey = undefined as any + } + + try { + await request.post('/v1/archive/cos-config', payload) + message.success('保存成功') + loadConfig() + } catch (e: any) { + message.error(e.response?.data?.message || '保存失败') + } + } + + const handleTest = async () => { + const values = form.getFieldsValue() + if (!values.secretId || !values.bucket || !values.region) { + message.warning('请填写 SecretId、Bucket、Region') + return + } + // 如果 SecretKey 为空且已有原值,提示用户输入 SecretKey 后再测试 + if (!values.secretKey && hasSecretKey) { + message.warning('已保存 SecretKey,但测试连接需要重新输入 SecretKey') + return + } + setTestLoading(true) + try { + const res: any = await request.post('/v1/archive/cos-config/test', { + secretId: values.secretId, + secretKey: values.secretKey, + bucket: values.bucket, + region: values.region, + }) + if (res.data === true) { + message.success('COS 连接测试成功') + } else { + message.error('COS 连接测试失败,请检查配置') + } + } catch (e: any) { + message.error(e.response?.data?.message || '测试失败') + } finally { + setTestLoading(false) + } + } + + const handleMigrate = async () => { + setMigrateLoading(true) + setMigratedCount(null) + try { + const res: any = await request.post('/v1/archive/cos-config/migrate?batchSize=50') + setMigratedCount(res.data || 0) + message.success(`批量迁移完成,共迁移 ${res.data || 0} 个文件`) + } catch (e: any) { + message.error(e.response?.data?.message || '迁移失败') + } finally { + setMigrateLoading(false) + } + } + + return ( +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +

+ 将已下载到本地的会话存档媒体文件批量上传到腾讯云 COS。上传完成后,后续访问将直接走 COS 链接。 +

+ + {migratedCount !== null && ( + + 本次迁移 {migratedCount} 个文件 + + )} +
+ + +
    +
  • 启用 COS 后,新产生的媒体文件会在下载完成后自动上传到 COS。
  • +
  • 媒体访问接口 /api/v1/archive/media/{id} 会优先返回 COS URL(302 重定向)。
  • +
  • 如果 COS 未启用或上传失败,会回退到本地文件读取。
  • +
  • 自定义域名需以 http:// 或 https:// 开头,保存时会自动处理。
  • +
+
+
+ ) +} diff --git a/frontend/sidebar/src/SummaryApp.tsx b/frontend/sidebar/src/SummaryApp.tsx index e3edf9a..32ef826 100644 --- a/frontend/sidebar/src/SummaryApp.tsx +++ b/frontend/sidebar/src/SummaryApp.tsx @@ -19,6 +19,17 @@ interface SummaryItem { staffName?: string } +interface SummarySection { + title: string + items: string[] +} + +interface ParsedSummary { + sections: SummarySection[] + tags: string[] + raw: string +} + function SummaryApp() { const [loading, setLoading] = useState(true) const [list, setList] = useState([]) @@ -281,11 +292,11 @@ function SummaryApp() {
-
+
📋 每日总结
@@ -294,30 +305,352 @@ function SummaryApp() {
{list.map((item, index) => ( -
-
-
- {item.msgTime || formatDate(item.createdAt)} -
-
- {item.staffName || item.staffId || '未知老师'} -
-
-
- {item.summary || item.messageContext || '无内容'} -
-
+ ))}
) } +function SummaryCard({ item }: { item: SummaryItem }) { + const parsed = parseSummary(item.summary) + const hasStructure = parsed.sections.length > 0 || parsed.tags.length > 0 + + return ( +
+ {/* 头部 */} +
+
+ + + {item.msgTime || formatDate(item.createdAt)} + +
+
+ + + {item.staffName || item.staffId || '未知老师'} + +
+
+ + {hasStructure ? ( + <> + {parsed.sections.map((section, idx) => ( + + ))} + {parsed.tags.length > 0 && } + + ) : ( +
+ {item.summary || item.messageContext || '无内容'} +
+ )} +
+ ) +} + +function SectionBlock({ section }: { section: SummarySection }) { + const config = getSectionConfig(section.title) + const showTitle = displaySectionTitle(section.title) + + return ( +
+
+ {config.icon} + {showTitle} +
+
+ {section.items.map((text, idx) => ( +
+
+ {section.title.includes('核心诉求') ? ( + + ) : section.title.includes('处理结果') ? ( + + ) : section.title.includes('待办') ? ( + + ) : ( + + )} +
+
+ {text} +
+
+ ))} +
+
+ ) +} + +function TagList({ tags }: { tags: string[] }) { + return ( +
+
+ 🏷️ + 关键标签 +
+
+ {tags.map((tag, idx) => ( + + #{tag} + + ))} +
+
+ ) +} + +function getSectionConfig(title: string) { + if (title.includes('核心诉求')) { + return { icon: '📝', titleColor: '#1677ff' } + } + if (title.includes('处理结果')) { + return { icon: '✅', titleColor: '#52c41a' } + } + if (title.includes('待办') || title.includes('后续')) { + return { icon: '⏰', titleColor: '#fa8c16' } + } + return { icon: '•', titleColor: '#666' } +} + +function displaySectionTitle(title: string) { + if (title.includes('核心诉求')) return '学员咨询' + if (title.includes('处理结果')) return '老师解答' + if (title.includes('待办')) return '跟进建议' + return title +} + +function NumberBadge({ n }: { n: number }) { + return ( +
+ {n} +
+ ) +} + +function GreenCheck() { + return ( +
+ ✓ +
+ ) +} + +function OrangeClock() { + return ( +
+ 🕐 +
+ ) +} + +function Dot({ color }: { color: string }) { + return ( +
+ ) +} + +function CalendarIcon() { + return ( + + + + + + + ) +} + +function UserIcon() { + return ( + + + + + ) +} + +function parseSummary(text?: string): ParsedSummary { + if (!text) return { sections: [], tags: [], raw: '' } + + const result: ParsedSummary = { sections: [], tags: [], raw: text } + let content = text + + // 提取关键标签:支持 "**关键标签**:#a#b" 或 "关键标签:#a#b" 或单独行 + const tagPatterns = [ + /(?:\*\*)?关键标签(?:\*\*)?[::]\s*(.+?)(?=\n\s*\*\*|\n\s*$|$)/s, + /(?:\*\*)?标签(?:\*\*)?[::]\s*(.+?)(?=\n\s*\*\*|\n\s*$|$)/s + ] + for (const pattern of tagPatterns) { + const tagMatch = content.match(pattern) + if (tagMatch) { + result.tags = parseTags(tagMatch[1]) + content = content.replace(tagMatch[0], '') + break + } + } + + // 按 **标题** 分隔区块,标题可能有空格 + const sectionRegex = /\*\*\s*(核心诉求|处理结果|待办事项|待处理|后续跟进|关键标签)\s*\*\*/g + const parts: { title: string; text: string }[] = [] + let lastIndex = 0 + let lastTitle = '' + let match + + while ((match = sectionRegex.exec(content)) !== null) { + if (lastTitle) { + parts.push({ title: lastTitle, text: content.slice(lastIndex, match.index) }) + } + lastTitle = match[1] + lastIndex = match.index + match[0].length + } + if (lastTitle) { + parts.push({ title: lastTitle, text: content.slice(lastIndex) }) + } + + for (const part of parts) { + if (part.title === '关键标签') { + const tags = parseTags(part.text) + if (tags.length) result.tags = tags + continue + } + const items = parseItems(part.text) + if (items.length > 0) { + result.sections.push({ title: part.title, items }) + } + } + + // 没有任何结构化区块时,尝试整段按行解析列表 + if (result.sections.length === 0 && result.tags.length === 0) { + const items = parseItems(content) + if (items.length > 1) { + result.sections.push({ title: '总结', items }) + } + } + + return result +} + +function parseItems(text: string): string[] { + const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 0) + const items: string[] = [] + + for (const line of lines) { + // 匹配 1. / 1. / - / * / • 开头的列表项 + const match = line.match(/^(?:\d+[..]\s*|[\-*•]\s+)(.+)$/) + if (match) { + items.push(cleanText(match[1])) + } else if (items.length > 0 && !line.match(/^\*\*/)) { + // 续行(非标题) + items[items.length - 1] += ' ' + cleanText(line) + } + } + + // 如果整段没有列表项,把段落本身作为一条内容(兼容纯文本区块) + if (items.length === 0 && text.trim().length > 0) { + items.push(cleanText(text.trim())) + } + + return items +} + +function parseTags(text: string): string[] { + const s = text.trim() + if (!s) return [] + // 支持 #标签1#标签2 或 标签1#标签2 或 #标签1 #标签2 + return s.split(/[#\s]+/) + .map(t => t.trim()) + .filter(t => t.length > 0 && t !== '、' && t !== ',') +} + +function cleanText(s: string): string { + return s.replace(/\*\*/g, '').trim() +} + function formatDate(timestamp?: number) { if (!timestamp) return '-' const date = new Date(timestamp) diff --git a/init.sql b/init.sql index d75ef3f..1a75398 100644 --- a/init.sql +++ b/init.sql @@ -42,6 +42,42 @@ CREATE TABLE IF NOT EXISTS `archive_messages` ( INDEX `idx_corp_msgtype` (`corp_id`, `msgtype`, `msgtime`) COMMENT '企业+消息类型+时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企微会话存档消息表'; +-- ============================================================ +-- 腾讯云 COS 配置表 (cos_config) +-- ============================================================ +CREATE TABLE IF NOT EXISTS `cos_config` ( + `id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键', + `secret_id` VARCHAR(255) NOT NULL COMMENT '腾讯云 SecretId', + `secret_key` VARCHAR(255) NOT NULL COMMENT '腾讯云 SecretKey', + `bucket` VARCHAR(255) NOT NULL COMMENT '存储桶名称', + `region` VARCHAR(64) NOT NULL COMMENT '地域,如 ap-shanghai', + `path_prefix` VARCHAR(255) DEFAULT 'archive/media/' COMMENT 'COS 路径前缀', + `domain` VARCHAR(255) DEFAULT NULL COMMENT '自定义访问域名(可选)', + `enabled` TINYINT(1) DEFAULT 0 COMMENT '是否启用:0-停用 1-启用', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + INDEX `idx_enabled` (`enabled`) COMMENT '启用状态索引' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='腾讯云 COS 配置表'; + +-- ============================================================ +-- 会话存档媒体文件 COS 映射表 (archive_media_files) +-- ============================================================ +CREATE TABLE IF NOT EXISTS `archive_media_files` ( + `id` BIGINT(20) PRIMARY KEY AUTO_INCREMENT COMMENT '自增主键', + `msg_id` VARCHAR(255) NOT NULL COMMENT '企微消息 msgid', + `archive_message_id` BIGINT(20) NOT NULL COMMENT 'archive_messages.id', + `sdkfileid` VARCHAR(512) DEFAULT NULL COMMENT '企微媒体文件 sdkfileid', + `cos_url` VARCHAR(1024) NOT NULL COMMENT 'COS 文件访问 URL', + `file_size` BIGINT(20) DEFAULT 0 COMMENT '文件大小(字节)', + `file_type` VARCHAR(32) DEFAULT NULL COMMENT '文件类型:image/voice/video/file', + `status` TINYINT(1) DEFAULT 1 COMMENT '状态:1-有效 0-无效', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY `uk_archive_msg_id` (`archive_message_id`) COMMENT '消息ID唯一', + UNIQUE KEY `uk_msg_id` (`msg_id`) COMMENT '企微消息ID唯一', + INDEX `idx_status` (`status`) COMMENT '状态索引' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会话存档媒体文件 COS 映射表'; + -- ============================================================ -- 会话表 (conversations) -- ============================================================