This commit is contained in:
qinyong@9artedu.com 2026-06-25 09:52:28 +08:00
commit 1730d7c746
16 changed files with 1326 additions and 24 deletions

View File

@ -19,6 +19,11 @@
<dependency><groupId>org.bouncycastle</groupId><artifactId>bcpkix-jdk18on</artifactId><version>1.78</version></dependency> <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>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>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> </dependencies>
<build> <build>
<plugins> <plugins>

View File

@ -8,6 +8,7 @@ import com.artedu.archive.mapper.CustomerMapper;
import com.artedu.archive.mapper.StaffMapper; import com.artedu.archive.mapper.StaffMapper;
import com.artedu.archive.client.WeComApiClient; import com.artedu.archive.client.WeComApiClient;
import com.artedu.archive.service.ArchivePullService; import com.artedu.archive.service.ArchivePullService;
import com.artedu.archive.service.CosStorageService;
import com.artedu.archive.vo.ArchiveMessageVO; import com.artedu.archive.vo.ArchiveMessageVO;
import com.artedu.common.result.PageResult; import com.artedu.common.result.PageResult;
import com.artedu.common.result.Result; import com.artedu.common.result.Result;
@ -60,6 +61,9 @@ public class ArchiveMessageController {
@Autowired @Autowired
private ArchivePullService archivePullService; private ArchivePullService archivePullService;
@Autowired
private CosStorageService cosStorageService;
@Autowired @Autowired
private WeComApiClient weComApiClient; private WeComApiClient weComApiClient;
@ -70,9 +74,10 @@ public class ArchiveMessageController {
/** /**
* 下载媒体文件(图片/语音/视频等) * 下载媒体文件(图片/语音/视频等)
* 如果已配置并启用 COS,优先返回 COS URL 302 重定向
*/ */
@GetMapping("/media/{id}") @GetMapping("/media/{id}")
public ResponseEntity<byte[]> downloadMedia(@PathVariable Long id) { public ResponseEntity<?> downloadMedia(@PathVariable Long id) {
ArchiveMessage message = archiveMessageMapper.selectById(id); ArchiveMessage message = archiveMessageMapper.selectById(id);
if (message == null) { if (message == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null);
@ -84,6 +89,21 @@ public class ArchiveMessageController {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null); 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 // 从 mediaData 解析 sdkfileid
String sdkfileid = null; String sdkfileid = null;
try { try {

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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'") @Select("SELECT DISTINCT from_user FROM archive_messages WHERE corp_id = #{corpId} AND from_role = 'INTERNAL'")
List<String> selectStaffIdsWithArchive(@Param("corpId") String corpId); 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);
} }

View File

@ -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();
}

View File

@ -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";
}
}

View File

@ -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
View 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 映射表';

View File

@ -14,6 +14,7 @@ import RiskEventList from './pages/RiskEventList'
import MessageSearch from './pages/MessageSearch' import MessageSearch from './pages/MessageSearch'
import LlmModelList from './pages/LlmModelList' import LlmModelList from './pages/LlmModelList'
import ChatMining from './pages/ChatMining' import ChatMining from './pages/ChatMining'
import CosConfig from './pages/CosConfig'
function App() { function App() {
return ( return (
@ -35,6 +36,7 @@ function App() {
<Route path="risk/message-search" element={<MessageSearch />} /> <Route path="risk/message-search" element={<MessageSearch />} />
<Route path="llm-models" element={<LlmModelList />} /> <Route path="llm-models" element={<LlmModelList />} />
<Route path="chat-mining" element={<ChatMining />} /> <Route path="chat-mining" element={<ChatMining />} />
<Route path="cos-config" element={<CosConfig />} />
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Outlet, useNavigate, useLocation } from 'react-router-dom' import { Outlet, useNavigate, useLocation } from 'react-router-dom'
import { Layout as AntLayout, Menu, Button, message, Badge } from 'antd' 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' import { connectRiskEventStream, disconnectRiskEventStream, requestNotificationPermission, showDesktopNotification } from '../utils/notification'
const { Header, Sider, Content } = AntLayout const { Header, Sider, Content } = AntLayout
@ -77,6 +77,7 @@ export default function Layout() {
}, },
{ key: '/admins', icon: <TeamOutlined />, label: '管理员账号' }, { key: '/admins', icon: <TeamOutlined />, label: '管理员账号' },
{ key: '/llm-models', icon: <RobotOutlined />, label: '大模型管理' }, { key: '/llm-models', icon: <RobotOutlined />, label: '大模型管理' },
{ key: '/cos-config', icon: <CloudUploadOutlined />, label: 'COS配置' },
{ key: '/chat-mining', icon: <GoldOutlined />, label: '会话挖掘' }, { key: '/chat-mining', icon: <GoldOutlined />, label: '会话挖掘' },
] ]

View 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/&#123;id&#125; 会优先返回 COS URL(302 重定向)。</li>
<li>如果 COS 未启用或上传失败,会回退到本地文件读取。</li>
<li>自定义域名需以 http:// 或 https:// 开头,保存时会自动处理。</li>
</ul>
</Card>
</div>
)
}

View File

@ -19,6 +19,17 @@ interface SummaryItem {
staffName?: string staffName?: string
} }
interface SummarySection {
title: string
items: string[]
}
interface ParsedSummary {
sections: SummarySection[]
tags: string[]
raw: string
}
function SummaryApp() { function SummaryApp() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [list, setList] = useState<SummaryItem[]>([]) const [list, setList] = useState<SummaryItem[]>([])
@ -281,11 +292,11 @@ function SummaryApp() {
<div style={{ <div style={{
background: '#fff', background: '#fff',
borderRadius: 12, borderRadius: 12,
padding: '12px 16px', padding: '14px 16px',
marginBottom: 12, marginBottom: 14,
boxShadow: '0 1px 4px rgba(0,0,0,0.06)' 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>
<div style={{ fontSize: 12, color: '#999', marginTop: 4 }}> <div style={{ fontSize: 12, color: '#999', marginTop: 4 }}>
@ -294,30 +305,352 @@ function SummaryApp() {
</div> </div>
{list.map((item, index) => ( {list.map((item, index) => (
<div key={item.id || index} style={{ <SummaryCard key={item.id || index} item={item} />
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>
))} ))}
</div> </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) { function formatDate(timestamp?: number) {
if (!timestamp) return '-' if (!timestamp) return '-'
const date = new Date(timestamp) const date = new Date(timestamp)

View File

@ -42,6 +42,42 @@ CREATE TABLE IF NOT EXISTS `archive_messages` (
INDEX `idx_corp_msgtype` (`corp_id`, `msgtype`, `msgtime`) COMMENT '企业+消息类型+时间' INDEX `idx_corp_msgtype` (`corp_id`, `msgtype`, `msgtime`) COMMENT '企业+消息类型+时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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) -- 会话表 (conversations)
-- ============================================================ -- ============================================================