Merge branch 'master' of https://git-a.9artedu.com/jqb/agent_9art
This commit is contained in:
commit
1730d7c746
@ -19,6 +19,11 @@
|
||||
<dependency><groupId>org.bouncycastle</groupId><artifactId>bcpkix-jdk18on</artifactId><version>1.78</version></dependency>
|
||||
<dependency><groupId>com.alibaba</groupId><artifactId>easyexcel</artifactId><version>3.3.4</version></dependency>
|
||||
<dependency><groupId>io.milvus</groupId><artifactId>milvus-sdk-java</artifactId><version>2.4.10</version></dependency>
|
||||
<dependency>
|
||||
<groupId>com.qcloud</groupId>
|
||||
<artifactId>cos_api</artifactId>
|
||||
<version>5.6.227</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
<build>
|
||||
<plugins>
|
||||
|
||||
@ -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<byte[]> 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 {
|
||||
|
||||
@ -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<CosConfigVO> getConfig() {
|
||||
List<CosConfig> list = cosConfigMapper.selectList(new LambdaQueryWrapper<CosConfig>().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<CosConfigVO> saveConfig(@RequestBody CosConfig config) {
|
||||
if (config == null) {
|
||||
return Result.fail("配置不能为空");
|
||||
}
|
||||
// 查询是否已有配置
|
||||
CosConfig existing = null;
|
||||
List<CosConfig> list = cosConfigMapper.selectList(new LambdaQueryWrapper<CosConfig>().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<Boolean> 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<CosConfig> savedList = cosConfigMapper.selectList(new LambdaQueryWrapper<CosConfig>().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<Integer> 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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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<ArchiveMediaFile> {
|
||||
|
||||
/**
|
||||
* 根据消息 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);
|
||||
}
|
||||
@ -105,4 +105,14 @@ public interface ArchiveMessageMapper extends BaseMapper<ArchiveMessage> {
|
||||
|
||||
@Select("SELECT DISTINCT from_user FROM archive_messages WHERE corp_id = #{corpId} AND from_role = 'INTERNAL'")
|
||||
List<String> 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<ArchiveMessage> selectMediaMessagesWithoutCos(@Param("offset") int offset, @Param("limit") int limit);
|
||||
}
|
||||
|
||||
@ -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<CosConfig> {
|
||||
|
||||
/**
|
||||
* 查询当前启用的配置
|
||||
*/
|
||||
@Select("SELECT * FROM cos_config WHERE enabled = 1 LIMIT 1")
|
||||
CosConfig selectActive();
|
||||
}
|
||||
@ -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<String, Object> 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<ArchiveMessage> 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";
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
29
deploy_cos_tables.sql
Normal file
29
deploy_cos_tables.sql
Normal file
@ -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 映射表';
|
||||
@ -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() {
|
||||
<Route path="risk/message-search" element={<MessageSearch />} />
|
||||
<Route path="llm-models" element={<LlmModelList />} />
|
||||
<Route path="chat-mining" element={<ChatMining />} />
|
||||
<Route path="cos-config" element={<CosConfig />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@ -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: <TeamOutlined />, label: '管理员账号' },
|
||||
{ key: '/llm-models', icon: <RobotOutlined />, label: '大模型管理' },
|
||||
{ key: '/cos-config', icon: <CloudUploadOutlined />, label: 'COS配置' },
|
||||
{ key: '/chat-mining', icon: <GoldOutlined />, label: '会话挖掘' },
|
||||
]
|
||||
|
||||
|
||||
224
frontend/admin/src/pages/CosConfig.tsx
Normal file
224
frontend/admin/src/pages/CosConfig.tsx
Normal file
@ -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<number | null>(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 (
|
||||
<div>
|
||||
<Card title="腾讯云 COS 配置" loading={loading}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSave}
|
||||
initialValues={{ pathPrefix: 'archive/media/', enabled: false }}
|
||||
>
|
||||
<Form.Item
|
||||
label="SecretId"
|
||||
name="secretId"
|
||||
rules={[{ required: true, message: '请输入 SecretId' }]}
|
||||
>
|
||||
<Input placeholder="腾讯云 SecretId" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="SecretKey"
|
||||
name="secretKey"
|
||||
rules={[{ required: !hasSecretKey, message: '请输入 SecretKey' }]}
|
||||
extra={hasSecretKey ? '已保存 SecretKey,如需修改请重新填写' : undefined}
|
||||
>
|
||||
<Input.Password placeholder="腾讯云 SecretKey" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Bucket"
|
||||
name="bucket"
|
||||
rules={[{ required: true, message: '请输入 Bucket 名称' }]}
|
||||
>
|
||||
<Input placeholder="存储桶名称,如 my-bucket-1250000000" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Region"
|
||||
name="region"
|
||||
rules={[{ required: true, message: '请输入 Region' }]}
|
||||
>
|
||||
<Input placeholder="地域,如 ap-shanghai" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="路径前缀"
|
||||
name="pathPrefix"
|
||||
>
|
||||
<Input placeholder="COS 上的路径前缀,如 archive/media/" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="自定义域名(可选)"
|
||||
name="domain"
|
||||
>
|
||||
<Input placeholder="CDN 或自定义域名,为空则使用默认 COS 域名" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="启用 COS"
|
||||
name="enabled"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch checkedChildren="启用" unCheckedChildren="停用" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" icon={<SaveOutlined />}>
|
||||
保存配置
|
||||
</Button>
|
||||
<Button icon={<ApiOutlined />} onClick={handleTest} loading={testLoading}>
|
||||
测试连接
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card style={{ marginTop: 16 }} title="历史媒体文件迁移">
|
||||
<p style={{ color: '#666', marginBottom: 16 }}>
|
||||
将已下载到本地的会话存档媒体文件批量上传到腾讯云 COS。上传完成后,后续访问将直接走 COS 链接。
|
||||
</p>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CloudUploadOutlined />}
|
||||
loading={migrateLoading}
|
||||
onClick={handleMigrate}
|
||||
>
|
||||
开始批量迁移
|
||||
</Button>
|
||||
{migratedCount !== null && (
|
||||
<Tag color="green" style={{ marginLeft: 12 }}>
|
||||
本次迁移 {migratedCount} 个文件
|
||||
</Tag>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card style={{ marginTop: 16 }} title="说明">
|
||||
<ul style={{ color: '#666', paddingLeft: 20 }}>
|
||||
<li>启用 COS 后,新产生的媒体文件会在下载完成后自动上传到 COS。</li>
|
||||
<li>媒体访问接口 /api/v1/archive/media/{id} 会优先返回 COS URL(302 重定向)。</li>
|
||||
<li>如果 COS 未启用或上传失败,会回退到本地文件读取。</li>
|
||||
<li>自定义域名需以 http:// 或 https:// 开头,保存时会自动处理。</li>
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -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<SummaryItem[]>([])
|
||||
@ -281,11 +292,11 @@ function SummaryApp() {
|
||||
<div style={{
|
||||
background: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: '12px 16px',
|
||||
marginBottom: 12,
|
||||
padding: '14px 16px',
|
||||
marginBottom: 14,
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.06)'
|
||||
}}>
|
||||
<div style={{ fontSize: 16, fontWeight: 500, color: '#333' }}>
|
||||
<div style={{ fontSize: 17, fontWeight: 600, color: '#333' }}>
|
||||
📋 每日总结
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
|
||||
@ -294,30 +305,352 @@ function SummaryApp() {
|
||||
</div>
|
||||
|
||||
{list.map((item, index) => (
|
||||
<div key={item.id || index} style={{
|
||||
background: '#fff',
|
||||
borderRadius: 12,
|
||||
padding: '14px 16px',
|
||||
marginBottom: 12,
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.06)'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
|
||||
<div style={{ fontSize: 13, color: '#1677ff', fontWeight: 500 }}>
|
||||
{item.msgTime || formatDate(item.createdAt)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#999' }}>
|
||||
{item.staffName || item.staffId || '未知老师'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 14, lineHeight: 1.7, color: '#333', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||
{item.summary || item.messageContext || '无内容'}
|
||||
</div>
|
||||
</div>
|
||||
<SummaryCard key={item.id || index} item={item} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SummaryCard({ item }: { item: SummaryItem }) {
|
||||
const parsed = parseSummary(item.summary)
|
||||
const hasStructure = parsed.sections.length > 0 || parsed.tags.length > 0
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: '#fff',
|
||||
borderRadius: 14,
|
||||
padding: '18px 16px',
|
||||
marginBottom: 14,
|
||||
boxShadow: '0 2px 10px rgba(0,0,0,0.05)'
|
||||
}}>
|
||||
{/* 头部 */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingBottom: 14,
|
||||
marginBottom: 16,
|
||||
borderBottom: '1px solid #f0f0f0'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<CalendarIcon />
|
||||
<span style={{ fontSize: 15, fontWeight: 600, color: '#1677ff' }}>
|
||||
{item.msgTime || formatDate(item.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<UserIcon />
|
||||
<span style={{ fontSize: 14, color: '#666' }}>
|
||||
{item.staffName || item.staffId || '未知老师'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasStructure ? (
|
||||
<>
|
||||
{parsed.sections.map((section, idx) => (
|
||||
<SectionBlock key={idx} section={section} />
|
||||
))}
|
||||
{parsed.tags.length > 0 && <TagList tags={parsed.tags} />}
|
||||
</>
|
||||
) : (
|
||||
<div style={{ fontSize: 14, lineHeight: 1.8, color: '#333', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||
{item.summary || item.messageContext || '无内容'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionBlock({ section }: { section: SummarySection }) {
|
||||
const config = getSectionConfig(section.title)
|
||||
const showTitle = displaySectionTitle(section.title)
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 18 }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
marginBottom: 12,
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
color: config.titleColor
|
||||
}}>
|
||||
<span style={{ fontSize: 16 }}>{config.icon}</span>
|
||||
{showTitle}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{section.items.map((text, idx) => (
|
||||
<div key={idx} style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
|
||||
<div style={{ flexShrink: 0, marginTop: 2 }}>
|
||||
{section.title.includes('核心诉求') ? (
|
||||
<NumberBadge n={idx + 1} />
|
||||
) : section.title.includes('处理结果') ? (
|
||||
<GreenCheck />
|
||||
) : section.title.includes('待办') ? (
|
||||
<OrangeClock />
|
||||
) : (
|
||||
<Dot color={config.titleColor} />
|
||||
)}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 14,
|
||||
lineHeight: 1.7,
|
||||
color: '#333',
|
||||
wordBreak: 'break-all',
|
||||
flex: 1
|
||||
}}>
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TagList({ tags }: { tags: string[] }) {
|
||||
return (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
marginBottom: 10,
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
color: '#1677ff'
|
||||
}}>
|
||||
<span style={{ fontSize: 16 }}>🏷️</span>
|
||||
关键标签
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{tags.map((tag, idx) => (
|
||||
<span key={idx} style={{
|
||||
display: 'inline-block',
|
||||
padding: '5px 12px',
|
||||
background: '#e6f4ff',
|
||||
color: '#1677ff',
|
||||
borderRadius: 16,
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
border: '1px solid #bae0ff'
|
||||
}}>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: '50%',
|
||||
background: '#1677ff',
|
||||
color: '#fff',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
{n}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GreenCheck() {
|
||||
return (
|
||||
<div style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: '50%',
|
||||
background: '#f6ffed',
|
||||
border: '1px solid #b7eb8f',
|
||||
color: '#52c41a',
|
||||
fontSize: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
✓
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OrangeClock() {
|
||||
return (
|
||||
<div style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: '50%',
|
||||
background: '#fff7e6',
|
||||
border: '1px solid #ffd591',
|
||||
color: '#fa8c16',
|
||||
fontSize: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}>
|
||||
🕐
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Dot({ color }: { color: string }) {
|
||||
return (
|
||||
<div style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: color,
|
||||
marginTop: 6
|
||||
}} />
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#1677ff" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function UserIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#999" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
|
||||
<circle cx="12" cy="7" r="4"></circle>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
36
init.sql
36
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)
|
||||
-- ============================================================
|
||||
|
||||
Loading…
Reference in New Issue
Block a user