feat: 新增腾讯云COS存储支持功能
此提交实现了完整的腾讯云COS存储集成方案: 1. 新增COS配置管理页面与后端接口,支持配置的增改查、连接测试和历史文件批量迁移 2. 新增COS配置实体、Mapper和VO类,完善数据库表结构初始化脚本 3. 改造媒体下载接口,优先返回COS重定向链接,失败则回退本地读取 4. 新增自动上传逻辑,新产生的媒体文件会自动同步到COS 5. 新增未上传COS的历史媒体文件查询接口,支持批量迁移本地存量文件 6. 引入腾讯云COS SDK依赖,添加侧边栏COS配置菜单入口
This commit is contained in:
parent
51310683c7
commit
32d9cbc09c
@ -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>
|
||||
)
|
||||
}
|
||||
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