Compare commits

...

24 Commits

Author SHA1 Message Date
3b11ddef6a feat(chat): 实现人脸智能聊天核心功能
All checks were successful
ZhenTu-BE/pipeline/head This commit looks good
- 新增小程序人脸聊天控制器 AppChatController,支持会话创建、消息收发、历史查询及会话关闭
- 集成智谱 GLM 模型客户端 GlmClient,支持流式文本生成与回调
- 新增聊天会话与消息实体类及 MyBatis 映射,实现数据持久化
- 提供 FaceChatService 接口及实现,封装聊天业务逻辑包括同步/流式消息发送
- 引入 zai-sdk 依赖以支持调用智谱 AI 大模型能力
- 支持基于人脸 ID 的唯一会话管理与用户权限校验
- 消息记录包含角色、内容、追踪 ID 及延迟信息,便于调试与分析
2025-12-11 17:45:49 +08:00
6e7b4729a8 feat(ai-cam): 新增使用人脸样本创建或获取Face记录功能
- 在AppAiCamController中新增/useSample/{faceSampleId}接口
- 实现通过人脸样本ID查找或创建Face记录的业务逻辑
- 自动关联AI相机照片到用户人脸记录
- 支持AI_CAM设备类型的二维码路径配置
- 完善人脸匹配及日志记录功能
- 添加相关实体类和工具类导入依赖
2025-12-09 16:20:50 +08:00
917cb37ccf feat(device): 添加获取设备基本信息的方法
- 新增 getDeviceBasic 方法直接返回 DeviceV2DTO 实例
- 添加设备基本信息查询的日志记录
- 实现通过设备ID获取设备详情的功能集成调用
2025-12-09 15:59:29 +08:00
7c0a3a63bb fix(order): 兼容旧逻辑并清理Redis缓存
- 在订单类型为3时设置Redis标识
- 删除冗余的Redis键值对清理操作
- 统一订单内容不可下载的处理逻辑
2025-12-08 13:51:28 +08:00
478467e124 fix(order): 更新商品类型13的名称显示
- 将"AI相机照片集"更正为"打卡点拍照"
- 同步更新OrderServiceImpl中的商品名称和订单类型
- 修改OrderMapper.xml中对应的商品名称映射逻辑
2025-12-08 13:43:11 +08:00
d5befd75e1 fix(pricing): 修复优惠券查询条件拼接问题
- 在每个查询条件后添加空格,避免SQL语法错误
- 确保动态SQL片段正确连接
- 优化时间范围查询条件的格式处理
2025-12-08 10:58:33 +08:00
b2c55c9feb refactor(printer): 优化照片处理管线与自动发券逻辑
- 调整自动发券判断条件,仅当存在type=3的source记录时触发
- 修改普通照片与拼图处理流程中的图像增强控制逻辑
- 移除冗余的图像缩放阶段,优化处理效率
- 增加processPhotoWithPipeline重载方法支持图像增强选项
- 重构水印配置方法,新增scale参数控制缩放比例
- 异步处理打印任务创建与推送,提升响应速度
- 复用processPhotoWithPipeline方法简化重打印处理逻辑
2025-12-07 21:42:48 +08:00
fef616c837 feat(image): 添加水印缩放功能支持
- 在 WatermarkConfig 中新增 scale 字段用于控制整体缩放倍数
- 在 WatermarkStage 中读取并传递 scale 参数到 WatermarkInfo
- 在 PrinterDefaultWatermarkOperator 中实现所有位置和尺寸的缩放逻辑
- 对偏移量、边距、字体大小、二维码尺寸等应用缩放因子
- 更新图像绘制相关参数计算方式以支持动态缩放
- 优化二维码圆形背景和头像绘制的缩放处理
- 确保缩放后的水印元素保持相对位置和视觉一致性
2025-12-07 21:42:11 +08:00
a5fe00052d feat(pricing): 支持发放多个首次打印优惠券
- 修改自动发券逻辑,支持发放多个符合条件的首次优惠券
- 更新查找优惠券方法,返回所有匹配的优惠券ID列表
- 添加发券过程中的异常处理,确保部分失败不影响其他券发放
- 记录详细的发券日志,包括成功、跳过和失败的数量
- 优化日志输出,提供更清晰的调试信息
2025-12-07 21:41:54 +08:00
349b702fc3 1 2025-12-06 22:42:05 +08:00
9f5a61247b feat(printer): 增加对source.type=3的特殊图片处理流程
- 新增ImageResizeStage、ImageSRStage和UpdateMemberPrintStage处理阶段
- 对type=3的图片增加超分辨率和图像增强处理
- 构建新的处理管线,包含下载、方向校正、超分、增强、上传等12个阶段
- 兼容旧版URL处理逻辑,针对type=3替换缩略图为原图URL
- 优化图片来源判断逻辑,增加source实体查询
- 完善处理日志记录和阶段状态控制
2025-12-06 22:42:05 +08:00
9321422e56 fix(mobile): 修复商品名称显示问题
- 修正商品类型为3时的名称显示逻辑
- 拍摄时间格式化后添加到商品名称中
- 优化商品名称前缀拼接逻辑
2025-12-06 22:42:05 +08:00
1834fe3ddd feat(order): 添加订单可下载状态查询接口
- 在AppOrderV2Controller中引入RedisTemplate依赖
- 新增/downloadable/{orderId} GET接口
- 通过检查Redis键值判断订单是否可下载
- 返回ApiResponse包装的布尔值表示下载状态
2025-12-06 21:23:58 +08:00
fa8f92d38b refactor(order): 调整图像处理逻辑与订单兼容性设置
- 将图像处理逻辑移至事务提交后执行
- 添加订单内容不可下载标识兼容旧逻辑
- 移除冗余的券服务注入依赖
- 清理订单相关缓存以确保数据一致性
2025-12-06 21:21:52 +08:00
df33e7929f feat(repository): 优化AI相机照片处理性能
- 引入CompletableFuture实现照片处理并发执行
- 创建专用线程池IMAGE_PROCESS_EXECUTOR管理异步任务
- 将原有串行处理逻辑改为并行处理
- 更新默认存储适配器从assets到assets-ext
2025-12-06 21:14:29 +08:00
554f55a7c1 feat(storage): 集成动态存储配置管理
- 引入ScenicConfigManager以支持景区级别的存储配置
- 添加StorageFactory和IStorageAdapter以实现灵活的存储适配
- 在图像处理流程中集成存储适配器的初始化逻辑
- 支持从配置中加载存储类型和相关参数
- 提供降级机制,默认使用assets存储适配器
- 增强SourceRepository对存储配置的依赖注入支持
2025-12-06 21:06:22 +08:00
f71149fd06 feat(order): 新增AI相机拍照套餐价格计算逻辑
- 在OrderBiz中增加对AI相机拍照套餐的价格计算处理
- 针对产品类型为AI_CAM_PHOTO_SET的场景实现价格查询逻辑
- 设置仅查询价格标志,避免实际使用优惠
- 补充价格对象的景区ID设置逻辑
2025-12-06 21:06:09 +08:00
e8eb8d816b refactor(repository): 暂时禁用图像超分辨率处理阶段
- 注释掉图像超分辨率处理阶段以优化处理流程
- 保留其他图像处理阶段(下载、增强、上传、清理)
- 为后续重新启用超分辨率功能预留接口配置
2025-12-06 20:00:40 +08:00
576d87d113 refactor(logging): 重构任务日志配置
- 将 DeviceVideoContinuityCheckTask 的专用日志 appender 重命名为通用 TASK_LOG
- 更新日志文件路径从 device_video_continuity_check_task.log 到 task.log
- 为多个任务类添加共享的日志记录器配置
- 包括 FaceCleaner、VideoPieceCleaner、DynamicTaskGenerator 和 DownloadNotificationTasker 的日志设置
2025-12-06 17:43:56 +08:00
a2378053a8 feat(printer): 打印订单成功后自动发放优惠券
- 在打印订单成功后调用自动发券服务
- 添加对自动发券异常的捕获与日志记录
- 确保发券失败不影响主业务流程
2025-12-06 17:32:21 +08:00
c92ea20575 feat(logging): 为设备视频连续性检查任务添加专用日志配置
- 新增 DeviceVideoContinuityCheckTask 专用日志文件
- 新增 FaceProcessingKafkaService 专用日志文件
- 新增 DeviceStorageOperator 专用日志文件
- 配置独立的日志滚动策略和文件命名规则
- 设置日志级别为 INFO 并禁用继承
- 限制最大历史文件数量为 30 天
- 设置单个日志文件最大大小为 10MB
- 总日志文件容量上限设置为 5GB
2025-12-06 16:11:31 +08:00
bb71cf9458 feat(image): 新增AI相机照片增强处理功能
- 在PipelineScene枚举中新增AI_CAM_ENHANCE场景
- 修改setUserIsBuyItem方法,增加对AI相机照片的图像处理逻辑
- 新增processAiCamImages方法,批量处理AI相机照片
- 新增processSingleAiCamImage方法,处理单张AI相机照片
- 新增buildEnhancerConfig方法,构建图像增强配置
- 实现图像处理管线:下载->超分->增强->上传->清理
- 添加处理结果URL回调更新机制
- 增加异常处理和日志记录,确保处理失败不影响主流程
2025-12-06 16:11:07 +08:00
7749faf807 feat(order): 添加AI相机照片集商品类型支持
- 在OrderServiceImpl中增加对商品类型13(AI相机照片集)的处理逻辑
- 新增listAiCamImageByFaceRelation方法用于查询AI相机图片数据
- 扩展订单详情展示逻辑,支持AI相机照片集的封面和拍摄时间显示
- 更新OrderMapper.xml,新增member_source_aicam_data查询片段
- 修改SQL映射,增加对goods_type=13情况的字段匹配规则
- 完善商品名称和订单类型的设置逻辑,区分AI相机照片集与其他类型
2025-12-06 14:42:16 +08:00
c42b055d5f feat(printer): 添加图片裁剪信息字段并实现裁剪功能
- 在 MemberPrintEntity 中新增 crop 字段用于存储裁剪信息
- 创建 Crop 类并添加 Lombok 注解以支持构造函数和 getter/setter
- 在 PrinterServiceImpl 中调用 smartCropAndFill 方法进行图片裁剪
- 设置默认旋转角度为 270 并将裁剪信息序列化后保存到数据库
- 更新 PrinterMapper.xml 配置文件以支持新字段的插入和查询
2025-12-06 14:41:06 +08:00
45 changed files with 1793 additions and 172 deletions

View File

@@ -273,6 +273,13 @@
<version>5.0.0</version>
</dependency>
<!-- 智谱AI SDK -->
<dependency>
<groupId>ai.z.openapi</groupId>
<artifactId>zai-sdk</artifactId>
<version>0.1.3</version>
</dependency>
<!-- Spring Kafka -->
<dependency>
<groupId>org.springframework.kafka</groupId>

View File

@@ -147,6 +147,21 @@ public class OrderBiz {
priceObj.setSlashPrice(priceCalculationResult.getOriginalAmount());
priceObj.setFaceId(goodsId);
break;
case 13:
PriceCalculationRequest aiCamCalculationRequest = new PriceCalculationRequest();
ProductItem aiCamProductItem = new ProductItem();
aiCamProductItem.setProductType(ProductType.AI_CAM_PHOTO_SET);
aiCamProductItem.setProductId(scenicId.toString());
aiCamProductItem.setPurchaseCount(1);
aiCamProductItem.setScenicId(scenicId.toString());
aiCamCalculationRequest.setProducts(Collections.singletonList(aiCamProductItem));
aiCamCalculationRequest.setPreviewOnly(true); // 仅查询价格,不实际使用优惠
PriceCalculationResult aiCamPriceCalculationResult = iPriceCalculationService.calculatePrice(aiCamCalculationRequest);
priceObj.setPrice(aiCamPriceCalculationResult.getFinalAmount());
priceObj.setSlashPrice(aiCamPriceCalculationResult.getOriginalAmount());
priceObj.setFaceId(goodsId);
priceObj.setScenicId(scenicId);
break;
}
return priceObj;
}

View File

@@ -1,8 +1,11 @@
package com.ycwl.basic.controller.mobile;
import com.ycwl.basic.model.jwt.JwtInfo;
import com.ycwl.basic.model.mobile.face.FaceRecognizeResp;
import com.ycwl.basic.model.mobile.goods.GoodsDetailVO;
import com.ycwl.basic.service.mobile.AppAiCamService;
import com.ycwl.basic.utils.ApiResponse;
import com.ycwl.basic.utils.JwtTokenUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
@@ -58,4 +61,21 @@ public class AppAiCamController {
return ApiResponse.fail("添加关联失败");
}
}
/**
* 使用人脸样本创建或获取Face记录
* @param faceSampleId 人脸样本ID
* @return 人脸识别响应
*/
@GetMapping("/useSample/{faceSampleId}")
public ApiResponse<FaceRecognizeResp> useSample(@PathVariable Long faceSampleId) {
try {
JwtInfo worker = JwtTokenUtil.getWorker();
FaceRecognizeResp resp = appAiCamService.useSample(worker.getUserId(), faceSampleId);
return ApiResponse.success(resp);
} catch (Exception e) {
log.error("使用人脸样本失败: faceSampleId={}", faceSampleId, e);
return ApiResponse.fail("使用人脸样本失败: " + e.getMessage());
}
}
}

View File

@@ -0,0 +1,98 @@
package com.ycwl.basic.controller.mobile;
import com.ycwl.basic.model.jwt.JwtInfo;
import com.ycwl.basic.model.mobile.chat.*;
import com.ycwl.basic.service.mobile.FaceChatService;
import com.ycwl.basic.utils.ApiResponse;
import com.ycwl.basic.utils.JwtTokenUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter;
import java.util.concurrent.CompletableFuture;
/**
* 小程序人脸智能聊天接口。
*/
@Slf4j
@RestController
@RequestMapping("/api/mobile/chat/v1")
@RequiredArgsConstructor
public class AppChatController {
private final FaceChatService faceChatService;
/**
* 获取或创建会话(同一人脸只保留一条)。
*/
@PostMapping("/faces/{faceId}/conversation")
public ApiResponse<ChatConversationVO> createConversation(@PathVariable Long faceId) {
JwtInfo worker = JwtTokenUtil.getWorker();
ChatConversationVO vo = faceChatService.getOrCreateConversation(faceId, worker.getUserId());
return ApiResponse.success(vo);
}
/**
* 同步发送消息,适用于短回复或前端自行轮询。
*/
@PostMapping("/conversations/{conversationId}/messages")
public ApiResponse<ChatSendMessageResp> sendMessage(@PathVariable Long conversationId,
@RequestBody ChatSendMessageReq req) {
JwtInfo worker = JwtTokenUtil.getWorker();
ChatSendMessageResp resp = faceChatService.sendMessage(conversationId, worker.getUserId(),
req.getContent(), req.getTraceId());
return ApiResponse.success(resp);
}
/**
* 流式返回,使用 HTTP chunked。小程序侧用 wx.request 的 onChunkReceived 消费。
*/
@PostMapping(value = "/conversations/{conversationId}/messages/stream", produces = "text/plain;charset=UTF-8")
public ResponseBodyEmitter streamMessage(@PathVariable Long conversationId,
@RequestBody ChatSendMessageReq req) {
JwtInfo worker = JwtTokenUtil.getWorker();
ResponseBodyEmitter emitter = new ResponseBodyEmitter(30_000L);
CompletableFuture.runAsync(() -> {
try {
faceChatService.sendMessageStream(
conversationId,
worker.getUserId(),
req.getContent(),
req.getTraceId(),
chunk -> {
try {
emitter.send(chunk, new MediaType("text", "plain", java.nio.charset.StandardCharsets.UTF_8));
} catch (Exception e) {
emitter.completeWithError(e);
}
});
emitter.complete();
} catch (Exception e) {
log.error("streamMessage error", e);
emitter.completeWithError(e);
}
});
return emitter;
}
/**
* 查询历史消息,cursor 为最后一条 seq,limit 为条数。
*/
@GetMapping("/conversations/{conversationId}/messages")
public ApiResponse<ChatMessagePageResp> listMessages(@PathVariable Long conversationId,
@RequestParam(value = "cursor", required = false) Integer cursor,
@RequestParam(value = "limit", required = false) Integer limit) {
JwtInfo worker = JwtTokenUtil.getWorker();
ChatMessagePageResp resp = faceChatService.listMessages(conversationId, cursor, limit, worker.getUserId());
return ApiResponse.success(resp);
}
@PostMapping("/conversations/{conversationId}/close")
public ApiResponse<String> closeConversation(@PathVariable Long conversationId) {
JwtInfo worker = JwtTokenUtil.getWorker();
faceChatService.closeConversation(conversationId, worker.getUserId());
return ApiResponse.success("OK");
}
}

View File

@@ -29,6 +29,7 @@ import com.ycwl.basic.order.dto.PaymentParamsResponse;
import com.ycwl.basic.order.dto.PaymentCallbackResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.*;
import jakarta.servlet.http.HttpServletRequest;
@@ -55,6 +56,7 @@ public class AppOrderV2Controller {
private final VideoTaskRepository videoTaskRepository;
private final TemplateRepository templateRepository;
private final VideoRepository videoRepository;
private final RedisTemplate<String, Object> redisTemplate;
/**
* 移动端价格计算
@@ -349,4 +351,9 @@ public class AppOrderV2Controller {
return "FAIL";
}
}
@GetMapping("/downloadable/{orderId}")
public ApiResponse<Boolean> getDownloadableOrder(@PathVariable("orderId") Long orderId) {
return ApiResponse.success(!redisTemplate.hasKey("order_content_not_downloadable_" + orderId));
}
}

View File

@@ -4,6 +4,7 @@ package com.ycwl.basic.controller.printer;
import cn.hutool.core.date.DateUtil;
import com.ycwl.basic.facebody.adapter.IFaceBodyAdapter;
import com.ycwl.basic.mapper.FaceMapper;
import com.ycwl.basic.model.pc.device.entity.DeviceEntity;
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity;
import com.ycwl.basic.model.task.resp.SearchFaceRespVo;
@@ -99,14 +100,20 @@ public class PrinterTvController {
@GetMapping("/{sampleId}/qrcode")
public void getQrcode(@PathVariable("sampleId") Long sampleId, HttpServletResponse response) throws Exception {
File qrcode = new File("qrcode_"+sampleId+".jpg");
try {
FaceSampleEntity faceSample = faceRepository.getFaceSample(sampleId);
if (faceSample == null) {
response.setStatus(404);
return;
}
String targetPath = "pages/printer/from_sample";
DeviceV2DTO device = deviceRepository.getDeviceBasic(faceSample.getDeviceId());
if (device.getType().equals("AI_CAM")) {
// AI_CAM,需要修改path
targetPath = "pages/ai-cam/from_sample";
}
try {
MpConfigEntity scenicMpConfig = scenicRepository.getScenicMpConfig(faceSample.getScenicId());
WxMpUtil.generateUnlimitedWXAQRCode(scenicMpConfig.getAppId(), scenicMpConfig.getAppSecret(), "pages/printer/from_sample", sampleId.toString(), qrcode);
WxMpUtil.generateUnlimitedWXAQRCode(scenicMpConfig.getAppId(), scenicMpConfig.getAppSecret(), targetPath, sampleId.toString(), qrcode);
// 设置响应头
response.setContentType("image/jpeg");

View File

@@ -22,7 +22,13 @@ public enum PipelineScene {
* 源图片超分辨率增强场景
* IPC设备拍摄的源图片进行质量提升
*/
SOURCE_PHOTO_SUPER_RESOLUTION("source_photo_sr", "源图片超分辨率增强");
SOURCE_PHOTO_SUPER_RESOLUTION("source_photo_sr", "源图片超分辨率增强"),
/**
* AI相机照片增强场景
* AI相机拍摄的照片进行超分辨率和质量增强
*/
AI_CAM_ENHANCE("ai_cam_enhance", "AI相机照片增强");
private final String code;
private final String description;

View File

@@ -0,0 +1,126 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
import lombok.extern.slf4j.Slf4j;
import javax.imageio.ImageIO;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.File;
/**
* 图像缩放Stage
* 支持按比例放大或缩小图片
*/
@Slf4j
@StageConfig(
stageId = "image_resize",
optionalMode = StageOptionalMode.SUPPORT,
description = "图像缩放处理",
defaultEnabled = true
)
public class ImageResizeStage extends AbstractPipelineStage<PhotoProcessContext> {
private final double scaleFactor;
/**
* 构造函数
* @param scaleFactor 缩放比例(例如: 1.5表示放大1.5倍, 0.333表示缩小到1/3)
*/
public ImageResizeStage(double scaleFactor) {
if (scaleFactor <= 0) {
throw new IllegalArgumentException("scaleFactor must be positive");
}
this.scaleFactor = scaleFactor;
}
@Override
public String getName() {
return "ImageResizeStage";
}
@Override
protected StageResult<PhotoProcessContext> doExecute(PhotoProcessContext context) {
File currentFile = context.getCurrentFile();
if (currentFile == null || !currentFile.exists()) {
return StageResult.skipped("当前文件不存在");
}
BufferedImage originalImage = null;
BufferedImage resizedImage = null;
try {
log.debug("开始图像缩放处理: file={}, scaleFactor={}", currentFile.getName(), scaleFactor);
// 读取原图
originalImage = ImageIO.read(currentFile);
if (originalImage == null) {
return StageResult.failed("无法读取图片文件");
}
int originalWidth = originalImage.getWidth();
int originalHeight = originalImage.getHeight();
// 计算新尺寸
int newWidth = (int) Math.round(originalWidth * scaleFactor);
int newHeight = (int) Math.round(originalHeight * scaleFactor);
// 检查尺寸是否合理
if (newWidth <= 0 || newHeight <= 0) {
return StageResult.failed("缩放后尺寸无效: " + newWidth + "x" + newHeight);
}
// 创建缩放后的图像
resizedImage = new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = resizedImage.createGraphics();
try {
// 设置高质量渲染选项
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
// 执行缩放
g2d.drawImage(originalImage, 0, 0, newWidth, newHeight, null);
} finally {
g2d.dispose();
}
// 保存缩放后的图片
File resizedFile = context.getTempFileManager().createTempFile("resized", ".jpg");
ImageIO.write(resizedImage, "jpg", resizedFile);
if (!resizedFile.exists() || resizedFile.length() == 0) {
return StageResult.failed("缩放后图片保存失败");
}
// 更新处理后的文件
context.updateProcessedFile(resizedFile);
log.info("图像缩放完成: {}x{} -> {}x{} (比例: {})",
originalWidth, originalHeight,
newWidth, newHeight,
scaleFactor);
return StageResult.success(String.format("缩放完成 (%dx%d -> %dx%d)",
originalWidth, originalHeight, newWidth, newHeight));
} catch (Exception e) {
log.error("图像缩放失败: {}", e.getMessage(), e);
return StageResult.failed("缩放失败: " + e.getMessage(), e);
} finally {
// 释放图像资源
if (originalImage != null) {
originalImage.flush();
}
if (resizedImage != null) {
resizedImage.flush();
}
}
}
}

View File

@@ -0,0 +1,82 @@
package com.ycwl.basic.image.pipeline.stages;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.mapper.PrinterMapper;
import com.ycwl.basic.pipeline.annotation.StageConfig;
import com.ycwl.basic.pipeline.core.AbstractPipelineStage;
import com.ycwl.basic.pipeline.core.StageResult;
import com.ycwl.basic.pipeline.enums.StageOptionalMode;
import lombok.extern.slf4j.Slf4j;
/**
* 更新MemberPrint记录Stage
* 用于更新member_print表中的cropUrl字段
*/
@Slf4j
@StageConfig(
stageId = "update_member_print",
optionalMode = StageOptionalMode.UNSUPPORT,
description = "更新MemberPrint记录",
defaultEnabled = true
)
public class UpdateMemberPrintStage extends AbstractPipelineStage<PhotoProcessContext> {
private final PrinterMapper printerMapper;
private final Integer memberPrintId;
private final Long memberId;
private final Long scenicId;
/**
* 构造函数
* @param printerMapper PrinterMapper实例
* @param memberPrintId MemberPrint记录ID
* @param memberId 用户ID
* @param scenicId 景区ID
*/
public UpdateMemberPrintStage(PrinterMapper printerMapper, Integer memberPrintId, Long memberId, Long scenicId) {
this.printerMapper = printerMapper;
this.memberPrintId = memberPrintId;
this.memberId = memberId;
this.scenicId = scenicId;
}
@Override
public String getName() {
return "UpdateMemberPrintStage";
}
@Override
protected StageResult<PhotoProcessContext> doExecute(PhotoProcessContext context) {
String resultUrl = context.getResultUrl();
if (resultUrl == null || resultUrl.trim().isEmpty()) {
return StageResult.skipped("结果URL为空,跳过更新");
}
if (memberPrintId == null || memberId == null || scenicId == null) {
log.warn("MemberPrint更新参数不完整: memberPrintId={}, memberId={}, scenicId={}",
memberPrintId, memberId, scenicId);
return StageResult.skipped("更新参数不完整");
}
try {
log.debug("开始更新MemberPrint记录: id={}, newCropUrl={}", memberPrintId, resultUrl);
// 更新cropUrl字段
int rows = printerMapper.setPhotoCropped(memberId, scenicId, memberPrintId.longValue(), resultUrl, null);
if (rows > 0) {
log.info("MemberPrint记录更新成功: id={}, cropUrl已更新", memberPrintId);
return StageResult.success("更新成功");
} else {
log.warn("MemberPrint记录更新失败: 可能记录不存在, id={}", memberPrintId);
return StageResult.degraded("更新失败,记录可能不存在");
}
} catch (Exception e) {
log.error("更新MemberPrint记录异常: id={}", memberPrintId, e);
// 更新失败不影响整个流程,使用降级状态
return StageResult.degraded("更新异常: " + e.getMessage());
}
}
}

View File

@@ -34,4 +34,11 @@ public class WatermarkConfig {
* 二维码文件
*/
private final File qrcodeFile;
/**
* 缩放倍数,用于将所有定位和大小乘以该倍数
* 默认值为 1.0(不缩放)
*/
@Builder.Default
private final Double scale = 1.0;
}

View File

@@ -170,6 +170,12 @@ public class WatermarkStage extends AbstractPipelineStage<PhotoProcessContext> {
info.setQrcodeFile(qrcodeFile);
}
// 从 config 读取缩放倍数
Double scale = config.getScale();
if (scale != null) {
info.setScale(scale);
}
// 根据旋转状态自己处理 offsetLeft
if (context.isRotationApplied()) {
if (context.getImageRotation() == 90) {

View File

@@ -33,6 +33,13 @@ public class WatermarkInfo {
private Integer offsetLeft;
private Integer offsetRight;
/**
* 缩放倍数,用于将所有定位和大小乘以该倍数
* 例如: scale=2.0 表示所有尺寸和位置都放大2倍
* null 表示使用默认值1.0(不缩放)
*/
private Double scale;
public String getDatetimeLine() {
if (datetimeLine == null) {
datetimeLine = DateUtil.format(datetime, dtFormat);

View File

@@ -64,11 +64,14 @@ public class PrinterDefaultWatermarkOperator implements IOperator {
@Override
public File process(WatermarkInfo info) throws ImageWatermarkException {
// 获取四边偏移值,优先使用传入的值,否则使用默认值
int offsetTop = info.getOffsetTop() != null ? info.getOffsetTop() : DEFAULT_OFFSET_TOP;
int offsetBottom = info.getOffsetBottom() != null ? info.getOffsetBottom() : DEFAULT_OFFSET_BOTTOM;
int offsetLeft = info.getOffsetLeft() != null ? info.getOffsetLeft() : DEFAULT_OFFSET_LEFT;
int offsetRight = info.getOffsetRight() != null ? info.getOffsetRight() : DEFAULT_OFFSET_RIGHT;
// 获取缩放倍数,默认为1.0(不缩放)
double scale = info.getScale() != null ? info.getScale() : 1.0;
// 获取四边偏移值,优先使用传入的值,否则使用默认值,并应用缩放
int offsetTop = (int) ((info.getOffsetTop() != null ? info.getOffsetTop() : DEFAULT_OFFSET_TOP) * scale);
int offsetBottom = (int) ((info.getOffsetBottom() != null ? info.getOffsetBottom() : DEFAULT_OFFSET_BOTTOM) * scale);
int offsetLeft = (int) ((info.getOffsetLeft() != null ? info.getOffsetLeft() : DEFAULT_OFFSET_LEFT) * scale);
int offsetRight = (int) ((info.getOffsetRight() != null ? info.getOffsetRight() : DEFAULT_OFFSET_RIGHT) * scale);
BufferedImage baseImage;
BufferedImage qrcodeImage;
@@ -86,17 +89,26 @@ public class PrinterDefaultWatermarkOperator implements IOperator {
} catch (IOException e) {
throw new ImageWatermarkException("图片打开失败");
}
// 应用缩放到所有常量
int scaledExtraBorder = (int) (EXTRA_BORDER_PX * scale);
int scaledOffsetY = (int) (OFFSET_Y * scale);
int scaledQrcodeSize = (int) (QRCODE_SIZE * scale);
int scaledQrcodeOffsetY = (int) (QRCODE_OFFSET_Y * scale);
int scaledScenicFontSize = (int) (SCENIC_FONT_SIZE * scale);
int scaledDatetimeFontSize = (int) (DATETIME_FONT_SIZE * scale);
// 新图像画布
BufferedImage newImage = new BufferedImage(baseImage.getWidth() + 2 * EXTRA_BORDER_PX, baseImage.getHeight() + 2 * EXTRA_BORDER_PX, BufferedImage.TYPE_INT_RGB);
BufferedImage newImage = new BufferedImage(baseImage.getWidth() + 2 * scaledExtraBorder, baseImage.getHeight() + 2 * scaledExtraBorder, BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = newImage.createGraphics();
g2d.setColor(BG_COLOR);
g2d.fillRect(0, 0, newImage.getWidth(), newImage.getHeight());
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2d.drawImage(baseImage, EXTRA_BORDER_PX, EXTRA_BORDER_PX, null);
int newQrcodeHeight = QRCODE_SIZE;
g2d.drawImage(baseImage, scaledExtraBorder, scaledExtraBorder, null);
int newQrcodeHeight = scaledQrcodeSize;
int newQrcodeWidth = (int) (newQrcodeHeight * 1.0 / qrcodeImage.getHeight() * qrcodeImage.getWidth());
Font scenicFont = new Font(defaultFontName, Font.BOLD, SCENIC_FONT_SIZE);
Font datetimeFont = new Font(defaultFontName, Font.BOLD, DATETIME_FONT_SIZE);
Font scenicFont = new Font(defaultFontName, Font.BOLD, scaledScenicFontSize);
Font datetimeFont = new Font(defaultFontName, Font.BOLD, scaledDatetimeFontSize);
FontMetrics scenicFontMetrics = g2d.getFontMetrics(scenicFont);
FontMetrics datetimeFontMetrics = g2d.getFontMetrics(datetimeFont);
int scenicLineHeight = scenicFontMetrics.getHeight();
@@ -106,13 +118,14 @@ public class PrinterDefaultWatermarkOperator implements IOperator {
// 二维码放置在左下角,距离左边缘图片宽度的5%,再加上左侧偏移
int qrcodeOffsetX = (int) (newImage.getWidth() * QRCODE_LEFT_MARGIN_RATIO) + offsetLeft;
int qrcodeOffsetY = EXTRA_BORDER_PX + baseImage.getHeight() - OFFSET_Y - newQrcodeHeight - offsetBottom;
int qrcodeOffsetY = scaledExtraBorder + baseImage.getHeight() - scaledOffsetY - newQrcodeHeight - offsetBottom;
Shape originalClip = g2d.getClip();
// 创建比二维码大10像素的白色圆形背景
int whiteCircleSize = Math.max(newQrcodeWidth, newQrcodeHeight) + 10;
// 创建比二维码大10像素的白色圆形背景(10像素也要缩放)
int whiteCirclePadding = (int) (10 * scale);
int whiteCircleSize = Math.max(newQrcodeWidth, newQrcodeHeight) + whiteCirclePadding;
int whiteCircleX = qrcodeOffsetX - (whiteCircleSize - newQrcodeWidth) / 2;
int whiteCircleY = qrcodeOffsetY + QRCODE_OFFSET_Y - (whiteCircleSize - newQrcodeHeight) / 2;
int whiteCircleY = qrcodeOffsetY + scaledQrcodeOffsetY - (whiteCircleSize - newQrcodeHeight) / 2;
// 绘制白色圆形背景
g2d.setColor(Color.WHITE);
@@ -122,7 +135,7 @@ public class PrinterDefaultWatermarkOperator implements IOperator {
// 用白色圆形尺寸裁切二维码(保持二维码原始尺寸,但用大圆裁切)
Ellipse2D qrcodeCircle = new Ellipse2D.Double(whiteCircleX, whiteCircleY, whiteCircleSize, whiteCircleSize);
g2d.setClip(qrcodeCircle);
g2d.drawImage(qrcodeImage, qrcodeOffsetX, qrcodeOffsetY + QRCODE_OFFSET_Y, newQrcodeWidth, newQrcodeHeight, null);
g2d.drawImage(qrcodeImage, qrcodeOffsetX, qrcodeOffsetY + scaledQrcodeOffsetY, newQrcodeWidth, newQrcodeHeight, null);
g2d.setClip(originalClip);
// 在圆形二维码中央绘制圆形头像
@@ -130,7 +143,7 @@ public class PrinterDefaultWatermarkOperator implements IOperator {
// 计算圆形头像的尺寸和位置
int avatarDiameter = (int) (newQrcodeHeight * 0.45);
int avatarX = qrcodeOffsetX + (newQrcodeWidth - avatarDiameter) / 2;
int avatarY = qrcodeOffsetY + QRCODE_OFFSET_Y + (newQrcodeHeight - avatarDiameter) / 2;
int avatarY = qrcodeOffsetY + scaledQrcodeOffsetY + (newQrcodeHeight - avatarDiameter) / 2;
// 保存当前的渲染设置
RenderingHints originalHints = g2d.getRenderingHints();
@@ -149,10 +162,10 @@ public class PrinterDefaultWatermarkOperator implements IOperator {
double faceHeight = faceImage.getHeight();
double scaleX = avatarDiameter / faceWidth;
double scaleY = avatarDiameter / faceHeight;
double scale = Math.max(scaleX, scaleY); // 使用较大的缩放比例以填满圆形
double faceScale = Math.max(scaleX, scaleY); // 使用较大的缩放比例以填满圆形
int scaledWidth = (int) (faceWidth * scale);
int scaledHeight = (int) (faceHeight * scale);
int scaledWidth = (int) (faceWidth * faceScale);
int scaledHeight = (int) (faceHeight * faceScale);
// 计算居中位置
int faceDrawX = avatarX + (avatarDiameter - scaledWidth) / 2;
@@ -167,7 +180,7 @@ public class PrinterDefaultWatermarkOperator implements IOperator {
}
// 计算文字与二维码垂直居中对齐的Y坐标
int qrcodeTop = qrcodeOffsetY + QRCODE_OFFSET_Y;
int qrcodeTop = qrcodeOffsetY + scaledQrcodeOffsetY;
int qrcodeBottom = qrcodeTop + newQrcodeHeight;
int qrcodeCenter = (qrcodeTop + qrcodeBottom) / 2;

View File

@@ -0,0 +1,17 @@
package com.ycwl.basic.integration.glm;
import java.util.List;
/**
* 智谱 GLM 模型调用抽象。
*/
public interface GlmClient {
/**
* 流式回复,实时回调分片,同时返回完整文本。
*/
String streamReply(Long faceId,
Long memberId,
String traceId,
List<ai.z.openapi.service.model.ChatMessage> messages,
java.util.function.Consumer<String> chunkConsumer);
}

View File

@@ -0,0 +1,118 @@
package com.ycwl.basic.integration.glm;
import ai.z.openapi.ZhipuAiClient;
import ai.z.openapi.service.model.ChatCompletionCreateParams;
import ai.z.openapi.service.model.ChatCompletionResponse;
import ai.z.openapi.service.model.ChatMessage;
import ai.z.openapi.service.model.ChatMessageRole;
import ai.z.openapi.service.model.ChatThinking;
import ai.z.openapi.service.model.Delta;
import ai.z.openapi.service.model.ModelData;
import io.reactivex.rxjava3.core.Flowable;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
/**
* 智谱 GLM 官方 SDK 调用实现,流式拆分文本。
*/
@Slf4j
@Component
public class GlmClientImpl implements GlmClient {
private static final String DEFAULT_MODEL = "glm-4.5-airx";
private final ZhipuAiClient client;
public GlmClientImpl(@Value("${zhipu.api-key:}") String apiKeyFromCfg) {
String apiKey = StringUtils.isNotBlank(apiKeyFromCfg) ? apiKeyFromCfg : System.getenv("ZHIPU_API_KEY");
if (StringUtils.isBlank(apiKey)) {
log.warn("未配置 ZHIPU_API_KEY,GLM 调用将返回占位结果");
client = null;
} else {
client = ZhipuAiClient.builder().apiKey(apiKey).build();
}
}
@Override
public String streamReply(Long faceId,
Long memberId,
String traceId,
List<ChatMessage> messages,
Consumer<String> chunkConsumer) {
if (client == null) {
String fallback = "GLM 未配置 API KEY,无法调用。";
if (chunkConsumer != null) {
chunkConsumer.accept(fallback);
}
return fallback;
}
List<ChatMessage> finalMessages = messages == null ? new ArrayList<>() : new ArrayList<>(messages);
if (finalMessages.isEmpty()) {
finalMessages.add(ChatMessage.builder()
.role(ChatMessageRole.USER.value())
.content("你好")
.build());
}
finalMessages.addFirst(ChatMessage.builder()
.role(ChatMessageRole.SYSTEM.value())
.content("你是一个智能销售,然后用户是实际想购买的人。你需要尽可能推广景区生成的Vlog、视频、照片等内容给用户,鼓励购买。注意,返回内容需要为普通文本格式,禁止出现Markdown标记。")
.build());
finalMessages.addFirst(ChatMessage.builder()
.role(ChatMessageRole.SYSTEM.value())
.content("注意,返回内容需要为普通文本格式,禁止使用Markdown格式进行返回。")
.build());
ChatCompletionCreateParams request = ChatCompletionCreateParams.builder()
.model(DEFAULT_MODEL)
.messages(finalMessages)
.thinking(ChatThinking.builder().type("enabled").build())
.stream(true)
.maxTokens(4096)
.temperature(0.8f)
.build();
ChatCompletionResponse response = client.chat().createChatCompletion(request);
if (!response.isSuccess()) {
String msg = "GLM 调用失败: " + response.getMsg();
log.warn(msg);
if (chunkConsumer != null) {
chunkConsumer.accept(msg);
}
return msg;
}
StringBuilder sb = new StringBuilder();
Flowable<ModelData> flowable = response.getFlowable();
flowable.blockingSubscribe(
data -> {
if (data.getChoices() == null || data.getChoices().isEmpty()) {
return;
}
Delta delta = data.getChoices().getFirst().getDelta();
if (delta == null) {
return;
}
String piece = delta.getContent();
if (StringUtils.isNotBlank(piece)) {
sb.append(piece);
if (chunkConsumer != null) {
chunkConsumer.accept(piece);
}
}
},
error -> {
log.error("GLM 流式调用异常", error);
String err = "GLM 调用异常:" + error.getMessage();
sb.append(err);
if (chunkConsumer != null) {
chunkConsumer.accept(err);
}
}
);
return sb.toString();
}
}

View File

@@ -0,0 +1,16 @@
package com.ycwl.basic.mapper;
import com.ycwl.basic.model.mobile.chat.entity.FaceChatConversationEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface FaceChatConversationMapper {
FaceChatConversationEntity findByFaceId(@Param("faceId") Long faceId);
FaceChatConversationEntity getById(@Param("id") Long id);
int insert(FaceChatConversationEntity entity);
int updateStatus(@Param("id") Long id, @Param("status") String status);
}

View File

@@ -0,0 +1,24 @@
package com.ycwl.basic.mapper;
import com.ycwl.basic.model.mobile.chat.entity.FaceChatMessageEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface FaceChatMessageMapper {
Integer maxSeqForUpdate(@Param("conversationId") Long conversationId);
int insert(FaceChatMessageEntity entity);
List<FaceChatMessageEntity> listByConversation(@Param("conversationId") Long conversationId,
@Param("cursor") Integer cursor,
@Param("limit") Integer limit);
/**
* 按 seq 倒序获取最近若干条消息,用于拼接上下文。
*/
List<FaceChatMessageEntity> listRecentByConversation(@Param("conversationId") Long conversationId,
@Param("limit") Integer limit);
}

View File

@@ -82,6 +82,7 @@ public interface SourceMapper {
List<SourceEntity> listVideoByFaceRelation(Long faceId);
List<SourceEntity> listImageByFaceRelation(Long faceId);
List<SourceEntity> listAiCamImageByFaceRelation(Long faceId);
List<MemberSourceEntity> listByFaceRelation(Long faceId, Integer type);
SourceEntity getEntity(Long id);

View File

@@ -1,11 +1,15 @@
package com.ycwl.basic.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 裁剪信息
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Crop {
private Integer rotation;
}

View File

@@ -0,0 +1,14 @@
package com.ycwl.basic.model.mobile.chat;
import lombok.Data;
/**
* 会话信息返回对象。
*/
@Data
public class ChatConversationVO {
private Long conversationId;
private Long faceId;
private String status;
private String model;
}

View File

@@ -0,0 +1,17 @@
package com.ycwl.basic.model.mobile.chat;
import lombok.Data;
import java.util.List;
/**
* 消息列表响应。
*/
@Data
public class ChatMessagePageResp {
private List<ChatMessageVO> messages;
/**
* 下一条游标(返回最后一条 seq)。
*/
private Integer nextCursor;
}

View File

@@ -0,0 +1,18 @@
package com.ycwl.basic.model.mobile.chat;
import lombok.Data;
import java.util.Date;
/**
* 聊天消息视图对象。
*/
@Data
public class ChatMessageVO {
private Long id;
private Integer seq;
private String role;
private String content;
private String traceId;
private Date createdAt;
}

View File

@@ -0,0 +1,22 @@
package com.ycwl.basic.model.mobile.chat;
import lombok.Data;
/**
* 发送消息请求体。
*/
@Data
public class ChatSendMessageReq {
/**
* 用户输入的文本内容。
*/
private String content;
/**
* 链路追踪ID,前端可透传,没有则服务端生成。
*/
private String traceId;
/**
* 是否期望流式返回。
*/
private Boolean stream;
}

View File

@@ -0,0 +1,13 @@
package com.ycwl.basic.model.mobile.chat;
import lombok.Data;
/**
* 发送消息同步响应。
*/
@Data
public class ChatSendMessageResp {
private ChatMessageVO userMessage;
private ChatMessageVO assistantMessage;
private String traceId;
}

View File

@@ -0,0 +1,16 @@
package com.ycwl.basic.model.mobile.chat;
import lombok.Data;
import java.util.List;
/**
* 流式发送消息的服务结果。
*/
@Data
public class ChatSendMessageStreamResp {
private ChatMessageVO userMessage;
private ChatMessageVO assistantMessage;
private String traceId;
private List<String> chunks;
}

View File

@@ -0,0 +1,35 @@
package com.ycwl.basic.model.mobile.chat.entity;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.util.Date;
/**
* 小程序人脸聊天会话,一脸一会话。
*/
@Data
@TableName("face_chat_conversation")
public class FaceChatConversationEntity {
@TableId
private Long id;
/**
* 对应的人脸ID。
*/
private Long faceId;
/**
* 归属用户ID,冗余校验越权。
*/
private Long memberId;
/**
* 会话状态 active/closed。
*/
private String status;
/**
* 使用的模型名称,例如 glm-v。
*/
private String model;
private Date createdAt;
private Date updatedAt;
}

View File

@@ -0,0 +1,28 @@
package com.ycwl.basic.model.mobile.chat.entity;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.util.Date;
/**
* 小程序人脸聊天消息,只保存文本。
*/
@Data
@TableName("face_chat_message")
public class FaceChatMessageEntity {
@TableId
private Long id;
private Long conversationId;
private Long faceId;
private Integer seq;
/**
* user / assistant / system。
*/
private String role;
private String content;
private String traceId;
private Integer latencyMs;
private Date createdAt;
}

View File

@@ -24,4 +24,5 @@ public class MemberPrintEntity {
private Integer status;
private Date createTime;
private Date updateTime;
private String crop;
}

View File

@@ -36,26 +36,37 @@ public class AutoCouponServiceImpl implements IAutoCouponService {
return false;
}
// 2. 查找该景区、该商品类型的首次打印优惠券配置
Long couponId = findFirstCouponId(scenicId, productType);
if (couponId == null) {
// 2. 查找该景区、该商品类型的所有首次打印优惠券配置
List<Long> couponIds = findFirstCouponIds(scenicId, productType);
if (couponIds == null || couponIds.isEmpty()) {
log.debug("景区未配置首次打印优惠券: scenicId={}, productType={}", scenicId, productType);
return false;
}
// 3. 检查用户是否已领取过该券(领券即消耗首次资格)
log.info("找到{}张首次优惠券待发放: scenicId={}, productType={}, couponIds={}",
couponIds.size(), scenicId, productType, couponIds);
// 3. 遍历所有优惠券,逐一检查并发放
int successCount = 0;
int skipCount = 0;
int failCount = 0;
for (Long couponId : couponIds) {
try {
// 检查用户是否已领取过该券(领券即消耗首次资格)
PriceCouponClaimRecord existingRecord = couponClaimRecordMapper.selectUserCouponRecord(
memberId,
couponId
);
if (existingRecord != null) {
log.debug("用户已领取过首次优惠券,不重复发券: memberId={}, couponId={}, claimTime={}",
log.debug("用户已领取过优惠券,跳过: memberId={}, couponId={}, claimTime={}",
memberId, couponId, existingRecord.getClaimTime());
return false;
skipCount++;
continue;
}
// 4. 自动发券
// 自动发券
CouponClaimRequest request = new CouponClaimRequest(
memberId,
couponId,
@@ -64,11 +75,23 @@ public class AutoCouponServiceImpl implements IAutoCouponService {
);
couponService.claimCoupon(request);
successCount++;
log.info("成功自动发放首次打印优惠券: memberId={}, faceId={}, scenicId={}, productType={}, couponId={}",
log.info("成功自动发放首次优惠券: memberId={}, faceId={}, scenicId={}, productType={}, couponId={}",
memberId, faceId, scenicId, productType, couponId);
return true;
} catch (Exception e) {
failCount++;
log.error("单张优惠券发放失败,继续处理其他券: memberId={}, couponId={}, error={}",
memberId, couponId, e.getMessage());
}
}
log.info("自动发券完成: memberId={}, 成功{}张, 跳过{}张, 失败{}张",
memberId, successCount, skipCount, failCount);
// 只要有一张成功就返回true
return successCount > 0;
} catch (Exception e) {
log.error("自动发券失败: memberId={}, faceId={}, scenicId={}, productType={}",
@@ -78,14 +101,15 @@ public class AutoCouponServiceImpl implements IAutoCouponService {
}
/**
* 查找指定景区、指定商品类型的首次打印优惠券ID
* 查找指定景区、指定商品类型的所有首次打印优惠券ID
* 规则:优惠券名称包含 "首次" 且 适用商品类型包含目标类型
*
* @param scenicId 景区ID
* @param productType 商品类型
* @return 优惠券ID,未找到返回null
* @return 优惠券ID列表,未找到返回空列表
*/
private Long findFirstCouponId(Long scenicId, ProductType productType) {
private List<Long> findFirstCouponIds(Long scenicId, ProductType productType) {
List<Long> couponIds = new java.util.ArrayList<>();
try {
// 查询该景区的有效优惠券
List<PriceCouponConfig> coupons = couponConfigMapper.selectValidCouponsByScenicId(
@@ -100,17 +124,22 @@ public class AutoCouponServiceImpl implements IAutoCouponService {
String applicableProducts = coupon.getApplicableProducts();
if (applicableProducts != null &&
applicableProducts.contains(productType.getCode())) {
return coupon.getId();
couponIds.add(coupon.getId());
log.debug("找到匹配的首次优惠券: couponId={}, couponName={}, scenicId={}, productType={}",
coupon.getId(), coupon.getCouponName(), scenicId, productType);
}
}
}
if (couponIds.isEmpty()) {
log.debug("未找到匹配的首次打印优惠券: scenicId={}, productType={}", scenicId, productType);
return null;
}
return couponIds;
} catch (Exception e) {
log.error("查找首次打印优惠券失败: scenicId={}, productType={}", scenicId, productType, e);
return null;
return couponIds;
}
}
}

View File

@@ -97,6 +97,17 @@ public class DeviceRepository {
return device;
}
/**
* 获取设备基本信息(直接返回DeviceV2DTO)
*
* @param deviceId 设备ID
* @return DeviceV2DTO实例
*/
public DeviceV2DTO getDeviceBasic(Long deviceId) {
log.debug("获取设备基本信息, deviceId: {}", deviceId);
return deviceIntegrationService.getDevice(deviceId);
}
public DeviceEntity getDeviceByDeviceNo(String deviceNo) {
log.debug("根据设备编号获取设备信息, deviceNo: {}", deviceNo);
DeviceV2DTO deviceDto = deviceIntegrationService.getDeviceByNo(deviceNo);

View File

@@ -1,13 +1,25 @@
package com.ycwl.basic.repository;
import com.ycwl.basic.image.enhancer.entity.BceEnhancerConfig;
import com.ycwl.basic.image.pipeline.core.PhotoProcessContext;
import com.ycwl.basic.image.pipeline.enums.ImageSource;
import com.ycwl.basic.image.pipeline.enums.ImageType;
import com.ycwl.basic.image.pipeline.enums.PipelineScene;
import com.ycwl.basic.image.pipeline.stages.*;
import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
import com.ycwl.basic.mapper.SourceMapper;
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity;
import com.ycwl.basic.model.pc.source.entity.SourceEntity;
import com.ycwl.basic.model.pc.device.entity.DeviceEntity;
import com.ycwl.basic.pipeline.core.Pipeline;
import com.ycwl.basic.pipeline.core.PipelineBuilder;
import com.ycwl.basic.pricing.dto.VoucherInfo;
import com.ycwl.basic.pricing.enums.VoucherDiscountType;
import com.ycwl.basic.pricing.service.IVoucherService;
import com.ycwl.basic.storage.StorageFactory;
import com.ycwl.basic.storage.adapters.IStorageAdapter;
import com.ycwl.basic.storage.exceptions.StorageUnsupportedException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
@@ -16,18 +28,29 @@ import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.Collectors;
@Slf4j
@Component
public class SourceRepository {
private static final ExecutorService IMAGE_PROCESS_EXECUTOR = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors(),
runnable -> {
Thread thread = new Thread(runnable);
thread.setName("ai-cam-image-processor-" + thread.getId());
thread.setDaemon(true);
return thread;
}
);
@Autowired
private SourceMapper sourceMapper;
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private IVoucherService iVoucherService;
@Autowired
private FaceRepository faceRepository;
@Autowired
private TemplateRepository templateRepository;
@@ -35,15 +58,21 @@ public class SourceRepository {
private DeviceRepository deviceRepository;
@Autowired
private MemberRelationRepository memberRelationRepository;
@Autowired
private ScenicRepository scenicRepository;
public void addSource(SourceEntity source) {
sourceMapper.add(source);
}
public void setUserIsBuyItem(Long memberId, int type, Long faceId, Long orderId) {
// 如果是AI相机照片类型(type=13),需要进行图像超分和增强处理
boolean needsImageProcessing = (type == 13 || type == 3);
if (type == 13) {
type = 3; // compact
}
MemberSourceEntity memberSource = new MemberSourceEntity();
memberSource.setMemberId(memberId);
memberSource.setFaceId(faceId);
@@ -52,6 +81,143 @@ public class SourceRepository {
memberSource.setIsBuy(1);
sourceMapper.updateRelation(memberSource);
memberRelationRepository.clearSCacheByFace(faceId);
// 如果需要图像处理,对该faceId下的所有type=3的照片进行处理
if (needsImageProcessing) {
processAiCamImages(faceId);
}
redisTemplate.delete("order_content_not_downloadable_" + orderId);
}
/**
* 处理AI相机照片 - 对照片进行超分辨率和增强处理
*
* @param faceId 人脸ID
*/
private void processAiCamImages(Long faceId) {
try {
// 1. 获取该faceId下所有type=3的照片
List<SourceEntity> aiCamImages = sourceMapper.listAiCamImageByFaceRelation(faceId);
if (aiCamImages == null || aiCamImages.isEmpty()) {
log.info("没有找到需要处理的AI相机照片, faceId: {}", faceId);
return;
}
log.info("开始处理AI相机照片, faceId: {}, 照片数量: {}", faceId, aiCamImages.size());
// 2. 构建图像处理配置
BceEnhancerConfig config = buildEnhancerConfig();
// 3. 并发处理所有照片
List<CompletableFuture<Void>> futures = aiCamImages.stream()
.map(source -> CompletableFuture.runAsync(() -> {
try {
processSingleAiCamImage(source, config);
} catch (Exception e) {
log.error("处理AI相机照片失败, sourceId: {}, error: {}", source.getId(), e.getMessage(), e);
// 继续处理下一张照片,不中断整个流程
}
}, IMAGE_PROCESS_EXECUTOR))
.collect(Collectors.toList());
// 4. 等待所有任务完成
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
log.info("AI相机照片处理完成, faceId: {}", faceId);
} catch (Exception e) {
log.error("处理AI相机照片整体流程失败, faceId: {}, error: {}", faceId, e.getMessage(), e);
// 即使处理失败也不抛出异常,不影响订单购买流程
}
}
/**
* 处理单张AI相机照片
*
* @param source 原始照片
* @param config 增强配置
*/
private void processSingleAiCamImage(SourceEntity source, BceEnhancerConfig config) {
// 1. 创建处理上下文
PhotoProcessContext context = PhotoProcessContext.builder()
.processId("aicam-" + source.getId())
.originalUrl(source.getUrl())
.scenicId(source.getScenicId())
.imageType(ImageType.NORMAL_PHOTO)
.source(ImageSource.IPC)
.scene(PipelineScene.AI_CAM_ENHANCE)
.build();
context.enableStage("image_sr");
context.enableStage("image_enhance");
ScenicConfigManager configManager = scenicRepository.getScenicConfigManager(source.getScenicId());
IStorageAdapter adapter;
try {
adapter = StorageFactory.get(configManager.getString("store_type"));
adapter.loadConfig(configManager.getObject("store_config_json", Map.class));
} catch (StorageUnsupportedException ignored) {
adapter = StorageFactory.use("assets-ext");
}
context.setStorageAdapter(adapter);
// 2. 设置结果URL回调 - 更新source记录
context.setResultUrlCallback(newUrl -> {
SourceEntity updateEntity = new SourceEntity();
updateEntity.setId(source.getId());
updateEntity.setUrl(newUrl);
sourceMapper.update(updateEntity);
log.info("已更新AI相机照片URL, sourceId: {}, oldUrl: {}, newUrl: {}",
source.getId(), source.getUrl(), newUrl);
});
// 3. 构建处理管线: 下载 -> 超分 -> 增强 -> 上传 -> 清理
Pipeline<PhotoProcessContext> pipeline = new PipelineBuilder<PhotoProcessContext>("AiCamEnhancePipeline")
.addStage(new DownloadStage()) // 下载原图
// .addStage(new ImageSRStage(config)) // 图像超分辨率
.addStage(new ImageEnhanceStage(config)) // 图像增强
.addStage(new UploadStage()) // 上传处理后的图片
.addStage(new CleanupStage()) // 清理临时文件
.build();
// 4. 执行管线
boolean success = pipeline.execute(context);
if (!success) {
log.warn("AI相机照片处理管线执行失败, sourceId: {}", source.getId());
}
}
/**
* 构建图像增强配置
*
* @return 增强配置
*/
private BceEnhancerConfig buildEnhancerConfig() {
BceEnhancerConfig config = new BceEnhancerConfig();
// 尝试从环境变量读取
String appId = System.getenv("BCE_IMAGE_APP_ID");
String apiKey = System.getenv("BCE_IMAGE_API_KEY");
String secretKey = System.getenv("BCE_IMAGE_SECRET_KEY");
// 如果环境变量没有配置,使用默认值(与PrinterServiceImpl保持一致)
if (appId == null || appId.isBlank()) {
appId = "119554288";
}
if (apiKey == null || apiKey.isBlank()) {
apiKey = "OX6QoijgKio3eVtA0PiUVf7f";
}
if (secretKey == null || secretKey.isBlank()) {
secretKey = "dYatXReVriPeiktTjUblhfubpcmYfuMk";
}
config.setAppId(appId);
config.setApiKey(apiKey);
config.setSecretKey(secretKey);
config.setQps(1.0f);
return config;
}
public void setUserNotBuyItem(Long memberId, int type, Long faceId) {

View File

@@ -1,5 +1,6 @@
package com.ycwl.basic.service.mobile;
import com.ycwl.basic.model.mobile.face.FaceRecognizeResp;
import com.ycwl.basic.model.mobile.goods.GoodsDetailVO;
import java.util.List;
@@ -23,4 +24,12 @@ public interface AppAiCamService {
* @return 添加成功的数量
*/
int addMemberSourceRelations(Long faceId, List<Long> sourceIds);
/**
* 使用人脸样本创建或获取Face记录
* @param userId 用户ID
* @param faceSampleId 人脸样本ID
* @return 人脸识别响应
*/
FaceRecognizeResp useSample(Long userId, Long faceSampleId);
}

View File

@@ -0,0 +1,36 @@
package com.ycwl.basic.service.mobile;
import com.ycwl.basic.model.mobile.chat.*;
import java.util.List;
public interface FaceChatService {
/**
* 获取或创建人脸会话,一脸一会话。
*/
ChatConversationVO getOrCreateConversation(Long faceId, Long memberId);
/**
* 同步发送消息并保存助手回复。
*/
ChatSendMessageResp sendMessage(Long conversationId, Long memberId, String content, String traceId);
/**
* 流式发送消息,支持实时分片回调,仍返回完整结果。
*/
ChatSendMessageStreamResp sendMessageStream(Long conversationId,
Long memberId,
String content,
String traceId,
java.util.function.Consumer<String> chunkConsumer);
/**
* 拉取历史消息,cursor 为最后一条 seq,limit 为条数。
*/
ChatMessagePageResp listMessages(Long conversationId, Integer cursor, Integer limit, Long memberId);
/**
* 关闭会话。
*/
void closeConversation(Long conversationId, Long memberId);
}

View File

@@ -1,21 +1,36 @@
package com.ycwl.basic.service.mobile.impl;
import com.ycwl.basic.exception.BaseException;
import com.ycwl.basic.facebody.FaceBodyFactory;
import com.ycwl.basic.facebody.adapter.IFaceBodyAdapter;
import com.ycwl.basic.facebody.entity.SearchFaceResp;
import com.ycwl.basic.facebody.entity.SearchFaceResultItem;
import com.ycwl.basic.integration.common.manager.DeviceConfigManager;
import com.ycwl.basic.integration.common.manager.ScenicConfigManager;
import com.ycwl.basic.mapper.FaceDetectLogAiCamMapper;
import com.ycwl.basic.mapper.FaceMapper;
import com.ycwl.basic.mapper.FaceSampleMapper;
import com.ycwl.basic.mapper.SourceMapper;
import com.ycwl.basic.model.mobile.face.FaceRecognizeResp;
import com.ycwl.basic.model.mobile.goods.GoodsDetailVO;
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
import com.ycwl.basic.model.pc.face.resp.FaceRespVO;
import com.ycwl.basic.model.pc.faceDetectLog.entity.FaceDetectLogAiCamEntity;
import com.ycwl.basic.model.pc.faceSample.entity.FaceSampleEntity;
import com.ycwl.basic.model.pc.scenic.entity.ScenicConfigEntity;
import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity;
import com.ycwl.basic.model.pc.source.entity.SourceEntity;
import com.ycwl.basic.repository.DeviceRepository;
import com.ycwl.basic.repository.ScenicRepository;
import com.ycwl.basic.service.mobile.AppAiCamService;
import com.ycwl.basic.service.pc.FaceDetectLogAiCamService;
import com.ycwl.basic.service.pc.FaceService;
import com.ycwl.basic.service.pc.ScenicService;
import com.ycwl.basic.utils.JacksonUtil;
import com.ycwl.basic.utils.SnowFlakeUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import java.util.*;
@@ -33,10 +48,14 @@ public class AppAiCamServiceImpl implements AppAiCamService {
private final SourceMapper sourceMapper;
private final FaceMapper faceMapper;
private final DeviceRepository deviceRepository;
private final FaceSampleMapper faceSampleMapper;
private static final float DEFAULT_SCORE_THRESHOLD = 0.8f;
private static final int DEFAULT_PHOTO_LIMIT = 10;
private static final int AI_CAM_SOURCE_TYPE = 3;
private final FaceService faceService;
private final FaceDetectLogAiCamService faceDetectLogAiCamService;
private final ScenicService scenicService;
@Override
public List<GoodsDetailVO> getAiCamGoodsByFaceId(Long faceId) {
@@ -272,4 +291,93 @@ public class AppAiCamServiceImpl implements AppAiCamService {
return inserted;
}
@Override
public FaceRecognizeResp useSample(Long userId, Long faceSampleId) {
// 1. 查询 faceSample 获取其 URL
FaceSampleEntity faceSample = faceSampleMapper.getEntity(faceSampleId);
if (faceSample == null) {
throw new BaseException("人脸样本不存在");
}
String faceUrl = faceSample.getFaceUrl();
if (StringUtils.isBlank(faceUrl)) {
throw new BaseException("人脸样本URL为空");
}
Long scenicId = faceSample.getScenicId();
// 2. 检查face数据库中有没有同用户、同URL的face记录
FaceEntity existingFace = null;
Long faceId = null;
// 查询该用户在该景区的所有人脸记录
List<FaceRespVO> userFaces = faceMapper.listByScenicAndUserId(scenicId, userId);
// 查找是否存在相同URL的记录
for (FaceRespVO faceResp : userFaces) {
if (faceUrl.equals(faceResp.getFaceUrl())) {
existingFace = faceMapper.get(faceResp.getId());
faceId = existingFace.getId();
break;
}
}
// 3. 如果不存在,则新建一个face记录
if (existingFace == null) {
faceId = SnowFlakeUtil.getLongId();
FaceEntity newFace = new FaceEntity();
newFace.setId(faceId);
newFace.setCreateAt(new Date());
newFace.setScenicId(scenicId);
newFace.setMemberId(userId);
newFace.setFaceUrl(faceUrl);
faceMapper.add(newFace);
log.info("创建新的face记录, userId: {}, faceSampleId: {}, faceId: {}, faceUrl: {}",
userId, faceSampleId, faceId, faceUrl);
} else {
log.info("使用已存在的face记录, userId: {}, faceSampleId: {}, faceId: {}, faceUrl: {}",
userId, faceSampleId, faceId, faceUrl);
}
// 4. 查询对应的 type=3 的 source 记录并自动添加关联
SourceEntity sourceEntity = sourceMapper.getBySampleIdAndType(faceSampleId, AI_CAM_SOURCE_TYPE);
if (sourceEntity != null && existingFace == null) {
// 检查是否已存在该source的关联
List<GoodsDetailVO> existingGoods = getAiCamGoodsByFaceId(faceId);
boolean alreadyExists = existingGoods.stream()
.anyMatch(item -> Objects.equals(item.getGoodsId(), sourceEntity.getId()));
if (!alreadyExists) {
// 添加关联
MemberSourceEntity relation = new MemberSourceEntity();
relation.setMemberId(userId);
relation.setScenicId(scenicId);
relation.setFaceId(faceId);
relation.setSourceId(sourceEntity.getId());
relation.setType(AI_CAM_SOURCE_TYPE);
relation.setIsBuy(0);
relation.setIsFree(0);
sourceMapper.addRelations(Collections.singletonList(relation));
log.info("自动添加AI相机照片关联: userId={}, faceId={}, sourceId={}",
userId, faceId, sourceEntity.getId());
}
}
// 5. 返回结果
FaceRecognizeResp resp = new FaceRecognizeResp();
resp.setUrl(faceUrl);
resp.setFaceId(faceId);
resp.setScenicId(scenicId);
IFaceBodyAdapter faceBodyAdapter = scenicService.getScenicFaceBodyAdapter(scenicId);
try {
faceService.matchFaceId(faceId);
faceDetectLogAiCamService.searchAndLog(scenicId, faceId, faceUrl, faceBodyAdapter);
} catch (Exception e) {
// 人脸匹配失败不可以阻止正常流程
log.error("人脸匹配失败", e);
}
return resp;
}
}

View File

@@ -0,0 +1,234 @@
package com.ycwl.basic.service.mobile.impl;
import com.ycwl.basic.exception.BaseException;
import com.ycwl.basic.integration.glm.GlmClient;
import com.ycwl.basic.mapper.FaceChatConversationMapper;
import com.ycwl.basic.mapper.FaceChatMessageMapper;
import com.ycwl.basic.model.mobile.chat.*;
import com.ycwl.basic.model.mobile.chat.entity.FaceChatConversationEntity;
import com.ycwl.basic.model.mobile.chat.entity.FaceChatMessageEntity;
import com.ycwl.basic.model.pc.face.entity.FaceEntity;
import com.ycwl.basic.repository.FaceRepository;
import com.ycwl.basic.service.mobile.FaceChatService;
import com.ycwl.basic.utils.SnowFlakeUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import ai.z.openapi.service.model.ChatMessage;
import ai.z.openapi.service.model.ChatMessageRole;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class FaceChatServiceImpl implements FaceChatService {
private static final String STATUS_ACTIVE = "active";
private static final String STATUS_CLOSED = "closed";
private static final String ROLE_USER = "user";
private static final String ROLE_ASSISTANT = "assistant";
private static final String DEFAULT_MODEL = "glm-4.5-airx";
private static final int HISTORY_LIMIT = 50;
private final FaceChatConversationMapper conversationMapper;
private final FaceChatMessageMapper messageMapper;
private final FaceRepository faceRepository;
private final GlmClient glmClient;
@Override
public ChatConversationVO getOrCreateConversation(Long faceId, Long memberId) {
FaceChatConversationEntity exist = conversationMapper.findByFaceId(faceId);
if (exist != null) {
assertOwner(exist, memberId);
return toConversationVO(exist);
}
// DEBUG阶段,暂时不检查
// FaceEntity face = faceRepository.getFace(faceId);
// if (face == null) {
// throw new BaseException("人脸不存在");
// }
// if (!Objects.equals(face.getMemberId(), memberId)) {
// throw new BaseException("无权访问该人脸");
// }
FaceChatConversationEntity entity = new FaceChatConversationEntity();
entity.setId(SnowFlakeUtil.getLongId());
entity.setFaceId(faceId);
entity.setMemberId(memberId);
entity.setStatus(STATUS_ACTIVE);
entity.setModel(DEFAULT_MODEL);
conversationMapper.insert(entity);
return toConversationVO(entity);
}
@Override
@Transactional(rollbackFor = Exception.class)
public ChatSendMessageResp sendMessage(Long conversationId, Long memberId, String content, String traceId) {
ChatSendMessageStreamResp result = doSend(conversationId, memberId, content, traceId, null);
ChatSendMessageResp resp = new ChatSendMessageResp();
resp.setUserMessage(result.getUserMessage());
resp.setAssistantMessage(result.getAssistantMessage());
resp.setTraceId(result.getTraceId());
return resp;
}
@Override
@Transactional(rollbackFor = Exception.class)
public ChatSendMessageStreamResp sendMessageStream(Long conversationId, Long memberId, String content, String traceId,
java.util.function.Consumer<String> chunkConsumer) {
return doSend(conversationId, memberId, content, traceId, chunkConsumer);
}
@Override
public ChatMessagePageResp listMessages(Long conversationId, Integer cursor, Integer limit, Long memberId) {
FaceChatConversationEntity conv = conversationMapper.getById(conversationId);
if (conv == null) {
throw new BaseException("会话不存在");
}
assertOwner(conv, memberId);
int pageSize = limit == null ? 50 : Math.max(1, Math.min(limit, 100));
List<FaceChatMessageEntity> list = messageMapper.listByConversation(conversationId, cursor, pageSize);
List<ChatMessageVO> vos = list.stream().map(this::toMessageVO).collect(Collectors.toList());
ChatMessagePageResp resp = new ChatMessagePageResp();
resp.setMessages(vos);
if (!list.isEmpty()) {
resp.setNextCursor(list.getLast().getSeq());
} else {
resp.setNextCursor(cursor == null ? 0 : cursor);
}
return resp;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void closeConversation(Long conversationId, Long memberId) {
FaceChatConversationEntity conv = conversationMapper.getById(conversationId);
if (conv == null) {
throw new BaseException("会话不存在");
}
assertOwner(conv, memberId);
if (STATUS_CLOSED.equals(conv.getStatus())) {
return;
}
conversationMapper.updateStatus(conversationId, STATUS_CLOSED);
}
private ChatSendMessageStreamResp doSend(Long conversationId, Long memberId, String content, String traceId,
java.util.function.Consumer<String> liveConsumer) {
if (StringUtils.isBlank(content)) {
throw new BaseException("消息内容不能为空");
}
FaceChatConversationEntity conv = conversationMapper.getById(conversationId);
if (conv == null) {
throw new BaseException("会话不存在");
}
assertOwner(conv, memberId);
if (STATUS_CLOSED.equals(conv.getStatus())) {
throw new BaseException("会话已关闭,请重新创建");
}
String resolvedTraceId = StringUtils.isBlank(traceId) ? UUID.randomUUID().toString() : traceId;
Integer maxSeq = messageMapper.maxSeqForUpdate(conversationId);
int baseSeq = maxSeq == null ? 0 : maxSeq;
int userSeq = baseSeq + 1;
FaceChatMessageEntity userMsg = buildMessage(conv, userSeq, ROLE_USER, content, resolvedTraceId, null);
messageMapper.insert(userMsg);
long start = System.currentTimeMillis();
List<FaceChatMessageEntity> recentDesc = messageMapper.listRecentByConversation(conversationId, HISTORY_LIMIT);
Collections.reverse(recentDesc); // 按时间升序
List<ChatMessage> chatMessages = recentDesc.stream()
.map(this::toChatMessage)
.collect(Collectors.toList());
CopyOnWriteArrayList<String> chunks = new CopyOnWriteArrayList<>();
java.util.function.Consumer<String> chunkConsumer = piece -> {
if (StringUtils.isNotBlank(piece)) {
chunks.add(piece);
if (liveConsumer != null) {
liveConsumer.accept(piece);
}
}
};
String assistantText = glmClient.streamReply(conv.getFaceId(), memberId, resolvedTraceId, chatMessages, chunkConsumer);
if (StringUtils.isBlank(assistantText)) {
assistantText = "GLM 暂未接入,稍后再试。";
chunkConsumer.accept(assistantText);
}
int latency = (int) (System.currentTimeMillis() - start);
FaceChatMessageEntity assistantMsg = buildMessage(conv, userSeq + 1, ROLE_ASSISTANT, assistantText, resolvedTraceId, latency);
messageMapper.insert(assistantMsg);
ChatSendMessageStreamResp resp = new ChatSendMessageStreamResp();
resp.setUserMessage(toMessageVO(userMsg));
resp.setAssistantMessage(toMessageVO(assistantMsg));
resp.setTraceId(resolvedTraceId);
resp.setChunks(chunks);
return resp;
}
private FaceChatMessageEntity buildMessage(FaceChatConversationEntity conv, int seq, String role, String content, String traceId, Integer latencyMs) {
FaceChatMessageEntity msg = new FaceChatMessageEntity();
msg.setId(SnowFlakeUtil.getLongId());
msg.setConversationId(conv.getId());
msg.setFaceId(conv.getFaceId());
msg.setSeq(seq);
msg.setRole(role);
msg.setContent(content);
msg.setTraceId(traceId);
msg.setLatencyMs(latencyMs);
msg.setCreatedAt(new Date());
return msg;
}
private void assertOwner(FaceChatConversationEntity conv, Long memberId) {
if (!Objects.equals(conv.getMemberId(), memberId)) {
throw new BaseException("无权访问该会话");
}
}
private ChatConversationVO toConversationVO(FaceChatConversationEntity entity) {
ChatConversationVO vo = new ChatConversationVO();
vo.setConversationId(entity.getId());
vo.setFaceId(entity.getFaceId());
vo.setStatus(entity.getStatus());
vo.setModel(entity.getModel());
return vo;
}
private ChatMessageVO toMessageVO(FaceChatMessageEntity entity) {
ChatMessageVO vo = new ChatMessageVO();
vo.setId(entity.getId());
vo.setSeq(entity.getSeq());
vo.setRole(entity.getRole());
vo.setContent(entity.getContent());
vo.setTraceId(entity.getTraceId());
vo.setCreatedAt(entity.getCreatedAt());
return vo;
}
private ChatMessage toChatMessage(FaceChatMessageEntity entity) {
String role = entity.getRole();
String mappedRole = ChatMessageRole.USER.value();
if (ROLE_ASSISTANT.equalsIgnoreCase(role)) {
mappedRole = ChatMessageRole.ASSISTANT.value();
} else if ("system".equalsIgnoreCase(role)) {
mappedRole = ChatMessageRole.SYSTEM.value();
}
return ChatMessage.builder()
.role(mappedRole)
.content(entity.getContent())
.build();
}
}

View File

@@ -127,8 +127,6 @@ public class GoodsServiceImpl implements GoodsService {
goodsNamePrefix = "录像";
} else if (sourceType == 2) {
goodsNamePrefix = "图片";
} else if (sourceType == 3) {
goodsNamePrefix = "AI微单";
} else {
goodsNamePrefix = "其他类型";
}
@@ -139,11 +137,15 @@ public class GoodsServiceImpl implements GoodsService {
goodsDetailVO.setFaceId(sourceRespVO.getFaceId());
goodsDetailVO.setGoodsId(sourceRespVO.getId());
String shootingTime = DateUtil.format(sourceRespVO.getCreateTime(), "yyyy.MM.dd HH:mm:ss");
if (Integer.valueOf(3).equals(sourceType)) {
goodsDetailVO.setGoodsName("拍摄时间:" + shootingTime);
} else {
if (i < 10) {
goodsDetailVO.setGoodsName(goodsNamePrefix + "0" + i + " " + shootingTime);
} else {
goodsDetailVO.setGoodsName(goodsNamePrefix + i + " " + shootingTime);
}
}
goodsDetailVO.setScenicId(sourceRespVO.getScenicId());
try {
ScenicV2DTO scenic = scenicRepository.getScenicBasic(sourceRespVO.getScenicId());

View File

@@ -177,7 +177,7 @@ public class OrderServiceImpl implements OrderService {
} else if (Integer.valueOf(2).equals(orderItemList.getFirst().getGoodsType())) {
item.setGoodsName("照片集");
item.setOrderType("照片集");
} else if (Integer.valueOf(3).equals(orderItemList.getFirst().getGoodsType())) {
} else if (Integer.valueOf(13).equals(orderItemList.getFirst().getGoodsType())) {
item.setGoodsName("AI微单");
item.setOrderType("AI微单");
} else if (Integer.valueOf(0).equals(orderItemList.getFirst().getGoodsType())) {
@@ -240,7 +240,7 @@ public class OrderServiceImpl implements OrderService {
} else if (Integer.valueOf(2).equals(orderItemList.getFirst().getGoodsType())) {
item.setGoodsName("照片集");
item.setOrderType("照片集");
} else if (Integer.valueOf(3).equals(orderItemList.getFirst().getGoodsType())) {
} else if (Integer.valueOf(13).equals(orderItemList.getFirst().getGoodsType())) {
item.setGoodsName("AI微单");
item.setOrderType("AI微单");
} else if (Integer.valueOf(0).equals(orderItemList.getFirst().getGoodsType())) {
@@ -350,6 +350,26 @@ public class OrderServiceImpl implements OrderService {
item.setShootingTime(memberVideoEntityList.getFirst().getCreateTime());
}
}
} else if (Integer.valueOf(13).equals(item.getGoodsType())) { // AI相机照片 goodsId就是人脸ID
List<SourceEntity> aiCamImageList = sourceMapper.listAiCamImageByFaceRelation(item.getGoodsId());
item.setCoverList(aiCamImageList.stream().map(SourceEntity::getUrl).collect(Collectors.toList()));
if (!_f.contains(13)) {
_f.add(13);
if (!aiCamImageList.isEmpty()) {
for (SourceEntity sourceEntity : aiCamImageList) {
GoodsDetailVO goods = new GoodsDetailVO();
goods.setGoodsId(sourceEntity.getId());
goods.setGoodsName("AI相机照片");
goods.setUrl(sourceEntity.getUrl());
goods.setGoodsType(sourceEntity.getType());
goods.setScenicId(sourceEntity.getScenicId());
goods.setTemplateCoverUrl(sourceEntity.getUrl());
goods.setCreateTime(sourceEntity.getCreateTime());
goodsList.add(goods);
}
item.setShootingTime(aiCamImageList.getFirst().getCreateTime());
}
}
} else if (Integer.valueOf(3).equals(item.getGoodsType())) { // 打印照片 goodsId就是memberPrintId
List<MemberPrintResp> list = printerMapper.getUserPhotoByIds(orderItemList.stream().map(OrderItemVO::getGoodsId).collect(Collectors.toList()));
item.setCoverList(orderItemList.stream().map(OrderItemVO::getCoverUrl).collect(Collectors.toList()));
@@ -552,6 +572,13 @@ public class OrderServiceImpl implements OrderService {
item.setShootingTime(memberVideoEntityList.getFirst().getCreateTime());
item.setCount(1);
}
} else if (Integer.valueOf(13).equals(item.getGoodsType())) {
List<SourceEntity> aiCamImageList = sourceMapper.listAiCamImageByFaceRelation(item.getFaceId());
item.setCoverList(aiCamImageList.stream().map(SourceEntity::getUrl).collect(Collectors.toList()));
if (!aiCamImageList.isEmpty()) {
item.setShootingTime(aiCamImageList.getFirst().getCreateTime());
item.setCount(1);
}
} else if (Integer.valueOf(0).equals(item.getGoodsType())) {
item.setCoverList(Collections.singletonList(item.getCoverUrl()));
VideoEntity video = videoRepository.getVideo(item.getGoodsId());
@@ -688,6 +715,9 @@ public class OrderServiceImpl implements OrderService {
} else if (Integer.valueOf(2).equals(orderItemList.getFirst().getGoodsType())) {
item.setGoodsName("照片集");
item.setOrderType("照片集");
} else if (Integer.valueOf(13).equals(orderItemList.getFirst().getGoodsType())) {
item.setGoodsName("打卡点拍照");
item.setOrderType("打卡点拍照");
} else if (Integer.valueOf(0).equals(orderItemList.getFirst().getGoodsType())) {
item.setOrderType("旅行Vlog");
item.setGoodsName(orderItemList.getFirst().getGoodsName());
@@ -1025,6 +1055,13 @@ public class OrderServiceImpl implements OrderService {
orderItem.setGoodsType(type);
orderItem.setOrderId(order.getId());
orderItems.add(orderItem);
// ======== 兼容旧逻辑 ==========
if (order.getType() == 3) {
redisTemplate.opsForValue().set("order_content_not_downloadable_" + order.getId(), "1");
}
if (type == 13) {
redisTemplate.opsForValue().set("order_content_not_downloadable_" + order.getId(), "1");
}
// 在事务中保存订单数据
try {
self.saveOrderInTransaction(order, orderItems, haveOldOrder);

View File

@@ -14,8 +14,11 @@ import com.ycwl.basic.image.pipeline.stages.ConditionalRotateStage;
import com.ycwl.basic.image.pipeline.stages.DownloadStage;
import com.ycwl.basic.image.pipeline.stages.ImageEnhanceStage;
import com.ycwl.basic.image.pipeline.stages.ImageOrientationStage;
import com.ycwl.basic.image.pipeline.stages.ImageResizeStage;
import com.ycwl.basic.image.pipeline.stages.ImageSRStage;
import com.ycwl.basic.image.pipeline.stages.PuzzleBorderStage;
import com.ycwl.basic.image.pipeline.stages.RestoreOrientationStage;
import com.ycwl.basic.image.pipeline.stages.UpdateMemberPrintStage;
import com.ycwl.basic.image.pipeline.stages.UploadStage;
import com.ycwl.basic.image.pipeline.stages.WatermarkConfig;
import com.ycwl.basic.image.pipeline.stages.WatermarkStage;
@@ -29,6 +32,7 @@ import com.ycwl.basic.mapper.OrderMapper;
import com.ycwl.basic.mapper.PrintTaskMapper;
import com.ycwl.basic.mapper.PrinterMapper;
import com.ycwl.basic.mapper.SourceMapper;
import com.ycwl.basic.model.Crop;
import com.ycwl.basic.model.PrinterOrderItem;
import com.ycwl.basic.model.mobile.face.FaceRecognizeResp;
import com.ycwl.basic.model.mobile.order.PriceObj;
@@ -347,6 +351,8 @@ public class PrinterServiceImpl implements PrinterService {
log.info("照片裁剪成功: memberId={}, scenicId={}, 原图={}, 裁剪后={}, 尺寸={}x{}",
memberId, scenicId, url, cropUrl, printWidth, printHeight);
String crop = JacksonUtil.toJSONString(new Crop(270));
entity.setCrop(crop);
} finally {
// 清理临时文件
if (croppedFile != null && croppedFile.exists()) {
@@ -467,6 +473,34 @@ public class PrinterServiceImpl implements PrinterService {
request.setProducts(productItems);
// 检查是否存在type=3的source记录,存在才自动发券
boolean hasType3Source = userPhotoList.stream()
.filter(item -> item.getSourceId() != null && item.getSourceId() > 0)
.anyMatch(item -> {
try {
SourceEntity source = sourceMapper.getEntity(item.getSourceId());
return source != null && Integer.valueOf(3).equals(source.getType());
} catch (Exception e) {
log.warn("查询source失败: sourceId={}, error={}", item.getSourceId(), e.getMessage());
return false;
}
});
if (hasType3Source) {
if (normalCount > 0) {
try {
autoCouponService.autoGrantCoupon(
memberId,
faceId,
scenicId,
ProductType.PHOTO_PRINT
);
} catch (Exception e) {
log.warn("自动发券失败,不影响下单流程: memberId={}, faceId={}, scenicId={}, error={}",
memberId, faceId, scenicId, e.getMessage());
}
}
}
if (mobileCount > 0) {
try {
autoCouponService.autoGrantCoupon(
@@ -505,6 +539,10 @@ public class PrinterServiceImpl implements PrinterService {
}
String url = byId.getUrl();
// 特殊兼容处理
if (Integer.valueOf(3).equals(byId.getType())) {
url = byId.getThumbUrl().replace("_t.", "_o.");
}
MemberPrintEntity entity = new MemberPrintEntity();
entity.setMemberId(memberId);
entity.setScenicId(scenicId);
@@ -540,6 +578,7 @@ public class PrinterServiceImpl implements PrinterService {
// 使用smartCropAndFill裁剪图片
File croppedFile = ImageUtils.smartCropAndFill(url, printWidth, printHeight);
entity.setCrop(JacksonUtil.toJSONString(new Crop(270)));
try {
// 上传裁剪后的图片
@@ -800,6 +839,18 @@ public class PrinterServiceImpl implements PrinterService {
* @return 处理后的URL,失败返回原URL
*/
private String processPhotoWithPipeline(MemberPrintResp item, Long scenicId, File qrCodeFile) {
return processPhotoWithPipeline(item, scenicId, qrCodeFile, null);
}
/**
* 使用管线处理照片(支持增强选项)
* @param item 打印项
* @param scenicId 景区ID
* @param qrCodeFile 二维码文件
* @param needEnhance 是否需要图像增强(null 或 false 表示不增强)
* @return 处理后的URL,失败返回原URL
*/
private String processPhotoWithPipeline(MemberPrintResp item, Long scenicId, File qrCodeFile, Boolean needEnhance) {
PrinterOrderItem orderItem = PrinterOrderItem.fromMemberPrintResp(item);
PhotoProcessContext context = PhotoProcessContext.fromPrinterOrderItem(orderItem, scenicId);
@@ -812,11 +863,15 @@ public class PrinterServiceImpl implements PrinterService {
// 设置管线场景为图片打印
context.setScene(PipelineScene.IMAGE_PRINT);
// 根据sourceId判断图片来源
// sourceId > 0: IPC设备拍摄
// sourceId == null: 手机上传
// sourceId == 0: 拼图(暂定为UNKNOWN)
// 处理图像增强选项
if (needEnhance != null && needEnhance) {
context.setStageState("image_enhance", true);
}
// 根据sourceId判断图片来源和source类型
SourceEntity source = null;
if (item.getSourceId() != null && item.getSourceId() > 0) {
source = sourceMapper.getEntity(item.getSourceId());
context.setSource(ImageSource.IPC);
} else if (item.getSourceId() == null) {
context.setSource(ImageSource.PHONE);
@@ -825,15 +880,49 @@ public class PrinterServiceImpl implements PrinterService {
}
Pipeline<PhotoProcessContext> pipeline;
if (context.getImageType() == ImageType.NORMAL_PHOTO) {
// 特殊处理: sourceId > 0 且 source.type == 3
if (source != null && source.getType() != null && source.getType() == 3) {
// Type=3的特殊处理流程
log.info("检测到source.type=3, 使用特殊处理管线: sourceId={}", item.getSourceId());
// 准备百度云配置
BceEnhancerConfig bceConfig = new BceEnhancerConfig();
bceConfig.setQps(1);
bceConfig.setAppId("119554288");
bceConfig.setApiKey("OX6QoijgKio3eVtA0PiUVf7f");
bceConfig.setSecretKey("dYatXReVriPeiktTjUblhfubpcmYfuMk");
// 准备水印配置
WatermarkConfig watermarkConfig = prepareWatermarkConfig(context, qrCodeFile);
WatermarkConfig watermarkConfig = prepareWatermarkConfig(context, qrCodeFile, 3.0);
// 准备存储适配器
prepareStorageAdapter(context);
// 创建普通照片管线
context.enableStage("image_sr");
context.enableStage("image_enhance");
// 构建特殊管线: 超分(2倍) -> 增强 -> 更新MemberPrint -> 缩小2倍 -> 水印 -> 上传
pipeline = new PipelineBuilder<PhotoProcessContext>("Type3Pipeline")
.addStage(new DownloadStage()) // 1. 下载图片
.addStage(new ImageOrientationStage()) // 2. 检测方向
.addStage(new ConditionalRotateStage()) // 3. 条件性旋转
.addStage(new ImageSRStage(bceConfig)) // 4. 超分辨率(2倍放大)
.addStage(new ImageEnhanceStage(bceConfig)) // 5. 图像增强
.addStage(new UploadStage()) // 6. 上传(用于更新MemberPrint)
.addStage(new UpdateMemberPrintStage(printerMapper, // 7. 更新MemberPrint的cropUrl
item.getId(), item.getMemberId(), scenicId))
.addStage(new WatermarkStage(watermarkConfig)) // 9. 添加水印
.addStage(new RestoreOrientationStage()) // 10. 恢复方向
.addStage(new UploadStage()) // 11. 最终上传
.addStage(new CleanupStage()) // 12. 清理
.build();
} else if (context.getImageType() == ImageType.NORMAL_PHOTO) {
// 普通照片处理流程
WatermarkConfig watermarkConfig = prepareWatermarkConfig(context, qrCodeFile, 1.5);
prepareStorageAdapter(context);
pipeline = createNormalPhotoPipeline(watermarkConfig);
} else {
// 拼图
// 拼图处理流程
prepareStorageAdapter(context);
pipeline = createPuzzlePipeline();
}
@@ -867,7 +956,7 @@ public class PrinterServiceImpl implements PrinterService {
* @param qrCodeFile 二维码文件
* @return WatermarkConfig
*/
private WatermarkConfig prepareWatermarkConfig(PhotoProcessContext context, File qrCodeFile) {
private WatermarkConfig prepareWatermarkConfig(PhotoProcessContext context, File qrCodeFile, Double scale) {
ScenicConfigManager scenicConfig = context.getScenicConfigManager();
if (scenicConfig == null) {
log.warn("scenicConfigManager未设置,返回空水印配置");
@@ -888,6 +977,7 @@ public class PrinterServiceImpl implements PrinterService {
.scenicText(scenicText)
.dateFormat(dateFormat)
.qrcodeFile(qrCodeFile)
.scale(scale)
.build();
}
@@ -937,6 +1027,7 @@ public class PrinterServiceImpl implements PrinterService {
} catch (Exception e) {
throw new RuntimeException(e);
}
Thread.ofVirtual().start(() -> {
userPhotoListByOrderId.forEach(item -> {
PrinterEntity printer = printerMapper.getById(item.getPrinterId());
@@ -985,6 +1076,8 @@ public class PrinterServiceImpl implements PrinterService {
}
}
});
redisTemplate.delete("order_content_not_downloadable_" + orderId);
});
}
/**
@@ -1418,58 +1511,15 @@ public class PrinterServiceImpl implements PrinterService {
needEnhance = false; // 默认不增强
}
// 3.1 创建图片处理上下文
PrinterOrderItem orderItem = PrinterOrderItem.fromMemberPrintResp(memberPrint);
PhotoProcessContext context = PhotoProcessContext.fromPrinterOrderItem(orderItem, memberPrint.getScenicId());
context.setStageState("image_enhance", needEnhance); // 通过setStageState方法设置是否启用
// 3.2 设置景区配置和场景
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(memberPrint.getScenicId());
context.setScenicConfigManager(scenicConfig);
context.setScene(PipelineScene.IMAGE_PRINT);
// 3.3 判断图片来源
if (memberPrint.getSourceId() != null && memberPrint.getSourceId() > 0) {
context.setSource(ImageSource.IPC);
} else if (memberPrint.getSourceId() == null) {
context.setSource(ImageSource.PHONE);
} else {
context.setSource(ImageSource.UNKNOWN);
}
// 3.4 构建管线(关键:条件性添加 ImageEnhanceStage)
Pipeline<PhotoProcessContext> pipeline;
String newPrintUrl = null;
// 3.1 使用管线处理照片(复用 processPhotoWithPipeline)
String newPrintUrl;
try {
if (context.getImageType() == ImageType.NORMAL_PHOTO) {
// 准备水印配置(重打印需要二维码)
WatermarkConfig watermarkConfig = prepareWatermarkConfig(context, qrCodeFile);
prepareStorageAdapter(context);
// 创建管线,条件性添加增强 Stage
pipeline = createNormalPhotoPipeline(watermarkConfig);
} else {
// 拼图
prepareStorageAdapter(context);
pipeline = createPuzzlePipeline();
}
// 3.5 执行管线
boolean success = pipeline.execute(context);
if (success && context.getResultUrl() != null) {
newPrintUrl = context.getResultUrl();
newPrintUrl = processPhotoWithPipeline(memberPrint, memberPrint.getScenicId(), qrCodeFile, needEnhance);
log.info("handleReprint: 照片重新处理成功, taskId={}, mpId={}, enhance={}, newUrl={}",
id, mpId, needEnhance, newPrintUrl);
} else {
log.warn("handleReprint: 照片重新处理失败, taskId={}, 使用原图", id);
newPrintUrl = memberPrint.getCropUrl(); // 使用原裁剪图
}
} catch (Exception e) {
log.error("handleReprint: 照片重新处理异常, taskId={}, 使用原图", id, e);
newPrintUrl = memberPrint.getCropUrl();
} finally {
context.cleanup();
}
// 4. 更新打印任务

View File

@@ -46,6 +46,82 @@
</encoder>
</appender>
<!-- Task specific log -->
<appender name="TASK_LOG" class="ch.qos.logback.core.rolling.RollingFileAppender">
<File>logs/task.log</File>
<append>true</append>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>logs/task.%d.%i.log</fileNamePattern>
<maxHistory>30</maxHistory>
<totalSizeCap>5GB</totalSizeCap>
<maxFileSize>10MB</maxFileSize>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level --- [%15.15(%thread)] %-40.40(%logger{40}) : %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- FaceProcessingKafkaService specific log -->
<appender name="FACE_PROCESSING_KAFKA_SERVICE_LOG" class="ch.qos.logback.core.rolling.RollingFileAppender">
<File>logs/face_processing_kafka_service.log</File>
<append>true</append>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>logs/face_processing_kafka_service.%d.%i.log</fileNamePattern>
<maxHistory>30</maxHistory>
<totalSizeCap>5GB</totalSizeCap>
<maxFileSize>10MB</maxFileSize>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level --- [%15.15(%thread)] %-40.40(%logger{40}) : %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- DeviceStorageOperator specific log -->
<appender name="DEVICE_STORAGE_OPERATOR_LOG" class="ch.qos.logback.core.rolling.RollingFileAppender">
<File>logs/device_storage_operator.log</File>
<append>true</append>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>logs/device_storage_operator.%d.%i.log</fileNamePattern>
<maxHistory>30</maxHistory>
<totalSizeCap>5GB</totalSizeCap>
<maxFileSize>10MB</maxFileSize>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level --- [%15.15(%thread)] %-40.40(%logger{40}) : %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<logger name="com.ycwl.basic.device.operator" level="INFO" additivity="false">
<appender-ref ref="DEVICE_STORAGE_OPERATOR_LOG"/>
</logger>
<logger name="com.ycwl.basic.task.DeviceVideoContinuityCheckTask" level="INFO" additivity="false">
<appender-ref ref="TASK_LOG"/>
</logger>
<logger name="com.ycwl.basic.task.FaceCleaner" level="INFO" additivity="false">
<appender-ref ref="TASK_LOG"/>
</logger>
<logger name="com.ycwl.basic.task.VideoPieceCleaner" level="INFO" additivity="false">
<appender-ref ref="TASK_LOG"/>
</logger>
<logger name="com.ycwl.basic.task.DynamicTaskGenerator" level="INFO" additivity="false">
<appender-ref ref="TASK_LOG"/>
</logger>
<logger name="com.ycwl.basic.task.DownloadNotificationTasker" level="INFO" additivity="false">
<appender-ref ref="TASK_LOG"/>
</logger>
<logger name="com.ycwl.basic.integration.kafka.service.FaceProcessingKafkaService" level="INFO" additivity="false">
<appender-ref ref="FACE_PROCESSING_KAFKA_SERVICE_LOG"/>
</logger>
<root level="ERROR">
<appender-ref ref="error_log" />
<appender-ref ref="STDOUT" />

View File

@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ycwl.basic.mapper.FaceChatConversationMapper">
<resultMap id="BaseResultMap" type="com.ycwl.basic.model.mobile.chat.entity.FaceChatConversationEntity">
<id column="id" property="id"/>
<result column="face_id" property="faceId"/>
<result column="member_id" property="memberId"/>
<result column="status" property="status"/>
<result column="model" property="model"/>
<result column="created_at" property="createdAt"/>
<result column="updated_at" property="updatedAt"/>
</resultMap>
<select id="findByFaceId" resultMap="BaseResultMap">
select id, face_id, member_id, status, model, created_at, updated_at
from face_chat_conversation
where face_id = #{faceId}
limit 1
</select>
<select id="getById" resultMap="BaseResultMap">
select id, face_id, member_id, status, model, created_at, updated_at
from face_chat_conversation
where id = #{id}
limit 1
</select>
<insert id="insert">
insert into face_chat_conversation
(id, face_id, member_id, status, model, created_at, updated_at)
values
(#{id}, #{faceId}, #{memberId}, #{status}, #{model}, now(), now())
</insert>
<update id="updateStatus">
update face_chat_conversation
set status = #{status}, updated_at = now()
where id = #{id}
</update>
</mapper>

View File

@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ycwl.basic.mapper.FaceChatMessageMapper">
<resultMap id="BaseResultMap" type="com.ycwl.basic.model.mobile.chat.entity.FaceChatMessageEntity">
<id column="id" property="id"/>
<result column="conversation_id" property="conversationId"/>
<result column="face_id" property="faceId"/>
<result column="seq" property="seq"/>
<result column="role" property="role"/>
<result column="content" property="content"/>
<result column="trace_id" property="traceId"/>
<result column="latency_ms" property="latencyMs"/>
<result column="created_at" property="createdAt"/>
</resultMap>
<select id="maxSeqForUpdate" resultType="java.lang.Integer">
select ifnull(max(seq), 0)
from face_chat_message
where conversation_id = #{conversationId}
for update
</select>
<insert id="insert">
insert into face_chat_message
(id, conversation_id, face_id, seq, role, content, trace_id, latency_ms, created_at)
values
(#{id}, #{conversationId}, #{faceId}, #{seq}, #{role}, #{content}, #{traceId}, #{latencyMs}, now())
</insert>
<select id="listByConversation" resultMap="BaseResultMap">
select id, conversation_id, face_id, seq, role, content, trace_id, latency_ms, created_at
from face_chat_message
where conversation_id = #{conversationId}
<if test="cursor != null">
and seq &gt; #{cursor}
</if>
order by seq asc
<if test="limit != null">
limit #{limit}
</if>
</select>
<select id="listRecentByConversation" resultMap="BaseResultMap">
select id, conversation_id, face_id, seq, role, content, trace_id, latency_ms, created_at
from face_chat_message
where conversation_id = #{conversationId}
order by seq desc
<if test="limit != null">
limit #{limit}
</if>
</select>
</mapper>

View File

@@ -95,6 +95,13 @@
LEFT JOIN face f ON ms.face_id = f.id
LEFT JOIN source s ON ms.source_id = s.id
WHERE s.id IS NOT NULL
),
member_source_aicam_data AS (
SELECT ms.member_id, ms.source_id, ms.face_id, f.face_url, s.url
FROM member_source ms
LEFT JOIN face f ON ms.face_id = f.id
LEFT JOIN source s ON ms.source_id = s.id
WHERE s.id IS NOT NULL AND ms.type = 3
),
member_photo_data AS (
SELECT mp.member_id, 3 as type, mp.id, mp.crop_url as url, mp.quantity, mp.status, mp.create_time
@@ -127,17 +134,20 @@
WHEN '3' THEN '照片打印'
WHEN '4' THEN '一体机照片打印'
WHEN '5' THEN 'pLog'
WHEN '13' THEN '打卡点拍照'
ELSE '其他'
END AS goods_name,
CASE oi.goods_type
WHEN '0' THEN mvd.face_id
WHEN '1' THEN oi.goods_id
WHEN '2' THEN oi.goods_id
WHEN '13' THEN oi.goods_id
END AS face_id,
CASE oi.goods_type
WHEN '0' THEN mvd.face_url
WHEN '1' THEN msd.face_url
WHEN '2' THEN msd.face_url
WHEN '13' THEN msac.face_url
END AS face_url,
CASE oi.goods_type
WHEN '0' THEN mvd.video_url
@@ -149,11 +159,13 @@
WHEN '3' THEN mpd.url
WHEN '4' THEN mpa.url
WHEN '5' THEN mpl.url
WHEN '13' THEN msac.url
END AS imgUrl
FROM order_item oi
LEFT JOIN `order` o ON oi.order_id = o.id
LEFT JOIN member_video_data mvd ON o.face_id = mvd.face_id AND oi.goods_id = mvd.video_id
LEFT JOIN member_source_data msd ON o.face_id = msd.face_id AND oi.goods_id = msd.face_id AND msd.type = oi.goods_type
LEFT JOIN member_source_aicam_data msac ON o.face_id = msac.face_id AND oi.goods_id = msac.face_id AND oi.goods_type = 13
LEFT JOIN member_photo_data mpd ON oi.goods_id = mpd.id AND mpd.type = oi.goods_type
LEFT JOIN member_aio_photo_data mpa ON oi.goods_id = mpa.id AND mpa.type = oi.goods_type
LEFT JOIN member_plog_data mpl ON oi.goods_id = mpl.id AND mpl.type = oi.goods_type AND o.face_id = mpl.face_id

View File

@@ -116,6 +116,7 @@
source_id,
orig_url,
crop_url,
crop,
quantity,
status,
create_time,
@@ -127,6 +128,7 @@
#{sourceId},
#{origUrl},
#{cropUrl},
#{crop},
1,
0,
NOW(),

View File

@@ -198,7 +198,7 @@
where so.id = #{id} and ms.member_id = #{userId} and so.id is not null
</select>
<select id="getById" resultType="com.ycwl.basic.model.pc.source.resp.SourceRespVO">
select so.id, scenic_id, device_id, thumb_url, url, video_url, so.create_time, so.update_time
select so.id, scenic_id, device_id, thumb_url, type, url, video_url, so.create_time, so.update_time
from source so
where so.id = #{id}
@@ -348,6 +348,12 @@
left join source s on ms.source_id = s.id
where ms.face_id = #{faceId} and ms.type = 2
</select>
<select id="listAiCamImageByFaceRelation" resultType="com.ycwl.basic.model.pc.source.entity.SourceEntity">
select s.*, ms.is_buy
from member_source ms
left join source s on ms.source_id = s.id
where ms.face_id = #{faceId} and ms.type = 3
</select>
<select id="getEntity" resultType="com.ycwl.basic.model.pc.source.entity.SourceEntity">
select *
from source