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> <version>5.0.0</version>
</dependency> </dependency>
<!-- 智谱AI SDK -->
<dependency>
<groupId>ai.z.openapi</groupId>
<artifactId>zai-sdk</artifactId>
<version>0.1.3</version>
</dependency>
<!-- Spring Kafka --> <!-- Spring Kafka -->
<dependency> <dependency>
<groupId>org.springframework.kafka</groupId> <groupId>org.springframework.kafka</groupId>

View File

@@ -147,6 +147,21 @@ public class OrderBiz {
priceObj.setSlashPrice(priceCalculationResult.getOriginalAmount()); priceObj.setSlashPrice(priceCalculationResult.getOriginalAmount());
priceObj.setFaceId(goodsId); priceObj.setFaceId(goodsId);
break; 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; return priceObj;
} }
@@ -220,7 +235,7 @@ public class OrderBiz {
break; break;
case 1: // 视频原素材 case 1: // 视频原素材
case 2: // 照片原素材 case 2: // 照片原素材
case 13: // AI微单 case 13: // AI微单
sourceRepository.setUserIsBuyItem(order.getMemberId(), item.getGoodsType(), item.getGoodsId(), order.getId()); sourceRepository.setUserIsBuyItem(order.getMemberId(), item.getGoodsType(), item.getGoodsId(), order.getId());
break; break;
case 3: case 3:

View File

@@ -1,8 +1,11 @@
package com.ycwl.basic.controller.mobile; 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.model.mobile.goods.GoodsDetailVO;
import com.ycwl.basic.service.mobile.AppAiCamService; import com.ycwl.basic.service.mobile.AppAiCamService;
import com.ycwl.basic.utils.ApiResponse; import com.ycwl.basic.utils.ApiResponse;
import com.ycwl.basic.utils.JwtTokenUtil;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -58,4 +61,21 @@ public class AppAiCamController {
return ApiResponse.fail("添加关联失败"); 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 com.ycwl.basic.order.dto.PaymentCallbackResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
@@ -55,6 +56,7 @@ public class AppOrderV2Controller {
private final VideoTaskRepository videoTaskRepository; private final VideoTaskRepository videoTaskRepository;
private final TemplateRepository templateRepository; private final TemplateRepository templateRepository;
private final VideoRepository videoRepository; private final VideoRepository videoRepository;
private final RedisTemplate<String, Object> redisTemplate;
/** /**
* 移动端价格计算 * 移动端价格计算
@@ -349,4 +351,9 @@ public class AppOrderV2Controller {
return "FAIL"; 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 cn.hutool.core.date.DateUtil;
import com.ycwl.basic.facebody.adapter.IFaceBodyAdapter; import com.ycwl.basic.facebody.adapter.IFaceBodyAdapter;
import com.ycwl.basic.mapper.FaceMapper; 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.face.entity.FaceEntity;
import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity; import com.ycwl.basic.model.pc.source.entity.MemberSourceEntity;
import com.ycwl.basic.model.task.resp.SearchFaceRespVo; import com.ycwl.basic.model.task.resp.SearchFaceRespVo;
@@ -99,14 +100,20 @@ public class PrinterTvController {
@GetMapping("/{sampleId}/qrcode") @GetMapping("/{sampleId}/qrcode")
public void getQrcode(@PathVariable("sampleId") Long sampleId, HttpServletResponse response) throws Exception { public void getQrcode(@PathVariable("sampleId") Long sampleId, HttpServletResponse response) throws Exception {
File qrcode = new File("qrcode_"+sampleId+".jpg"); File qrcode = new File("qrcode_"+sampleId+".jpg");
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 { try {
FaceSampleEntity faceSample = faceRepository.getFaceSample(sampleId);
if (faceSample == null) {
response.setStatus(404);
return;
}
MpConfigEntity scenicMpConfig = scenicRepository.getScenicMpConfig(faceSample.getScenicId()); 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"); response.setContentType("image/jpeg");

View File

@@ -22,7 +22,13 @@ public enum PipelineScene {
* 源图片超分辨率增强场景 * 源图片超分辨率增强场景
* IPC设备拍摄的源图片进行质量提升 * 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 code;
private final String description; 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; 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); info.setQrcodeFile(qrcodeFile);
} }
// 从 config 读取缩放倍数
Double scale = config.getScale();
if (scale != null) {
info.setScale(scale);
}
// 根据旋转状态自己处理 offsetLeft // 根据旋转状态自己处理 offsetLeft
if (context.isRotationApplied()) { if (context.isRotationApplied()) {
if (context.getImageRotation() == 90) { if (context.getImageRotation() == 90) {

View File

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

View File

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

View File

@@ -1,11 +1,15 @@
package com.ycwl.basic.model; package com.ycwl.basic.model;
import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor;
/** /**
* 裁剪信息 * 裁剪信息
*/ */
@Data @Data
@AllArgsConstructor
@NoArgsConstructor
public class Crop { public class Crop {
private Integer rotation; 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 Integer status;
private Date createTime; private Date createTime;
private Date updateTime; private Date updateTime;
private String crop;
} }

View File

@@ -89,22 +89,22 @@ public interface PriceCouponClaimRecordMapper extends BaseMapper<PriceCouponClai
"LEFT JOIN price_coupon_config c ON r.coupon_id = c.id " + "LEFT JOIN price_coupon_config c ON r.coupon_id = c.id " +
"<where>" + "<where>" +
"<if test='userId != null'>" + "<if test='userId != null'>" +
"AND r.user_id = #{userId}" + "AND r.user_id = #{userId} " +
"</if>" + "</if>" +
"<if test='couponId != null'>" + "<if test='couponId != null'>" +
"AND r.coupon_id = #{couponId}" + "AND r.coupon_id = #{couponId} " +
"</if>" + "</if>" +
"<if test='status != null'>" + "<if test='status != null'>" +
"AND r.status = #{status}" + "AND r.status = #{status} " +
"</if>" + "</if>" +
"<if test='startTime != null and startTime != \"\"'>" + "<if test='startTime != null and startTime != \"\"'>" +
"AND r.claim_time >= #{startTime}" + "AND r.claim_time >= #{startTime} " +
"</if>" + "</if>" +
"<if test='endTime != null and endTime != \"\"'>" + "<if test='endTime != null and endTime != \"\"'>" +
"AND r.claim_time &lt;= #{endTime}" + "AND r.claim_time &lt;= #{endTime} " +
"</if>" + "</if>" +
"<if test='scenicId != null and scenicId != \"\"'>" + "<if test='scenicId != null and scenicId != \"\"'>" +
"AND r.scenic_id = #{scenicId}" + "AND r.scenic_id = #{scenicId} " +
"</if>" + "</if>" +
"</where>" + "</where>" +
"ORDER BY r.create_time DESC" + "ORDER BY r.create_time DESC" +

View File

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

View File

@@ -97,6 +97,17 @@ public class DeviceRepository {
return device; 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) { public DeviceEntity getDeviceByDeviceNo(String deviceNo) {
log.debug("根据设备编号获取设备信息, deviceNo: {}", deviceNo); log.debug("根据设备编号获取设备信息, deviceNo: {}", deviceNo);
DeviceV2DTO deviceDto = deviceIntegrationService.getDeviceByNo(deviceNo); DeviceV2DTO deviceDto = deviceIntegrationService.getDeviceByNo(deviceNo);

View File

@@ -1,13 +1,25 @@
package com.ycwl.basic.repository; 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.mapper.SourceMapper;
import com.ycwl.basic.model.pc.face.entity.FaceEntity; 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.MemberSourceEntity;
import com.ycwl.basic.model.pc.source.entity.SourceEntity; import com.ycwl.basic.model.pc.source.entity.SourceEntity;
import com.ycwl.basic.model.pc.device.entity.DeviceEntity; 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.dto.VoucherInfo;
import com.ycwl.basic.pricing.enums.VoucherDiscountType; import com.ycwl.basic.pricing.enums.VoucherDiscountType;
import com.ycwl.basic.pricing.service.IVoucherService; 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 lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.RedisTemplate;
@@ -16,18 +28,29 @@ import org.springframework.stereotype.Component;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Slf4j @Slf4j
@Component @Component
public class SourceRepository { 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 @Autowired
private SourceMapper sourceMapper; private SourceMapper sourceMapper;
@Autowired @Autowired
private RedisTemplate<String, String> redisTemplate; private RedisTemplate<String, String> redisTemplate;
@Autowired @Autowired
private IVoucherService iVoucherService;
@Autowired
private FaceRepository faceRepository; private FaceRepository faceRepository;
@Autowired @Autowired
private TemplateRepository templateRepository; private TemplateRepository templateRepository;
@@ -35,15 +58,21 @@ public class SourceRepository {
private DeviceRepository deviceRepository; private DeviceRepository deviceRepository;
@Autowired @Autowired
private MemberRelationRepository memberRelationRepository; private MemberRelationRepository memberRelationRepository;
@Autowired
private ScenicRepository scenicRepository;
public void addSource(SourceEntity source) { public void addSource(SourceEntity source) {
sourceMapper.add(source); sourceMapper.add(source);
} }
public void setUserIsBuyItem(Long memberId, int type, Long faceId, Long orderId) { public void setUserIsBuyItem(Long memberId, int type, Long faceId, Long orderId) {
// 如果是AI相机照片类型(type=13),需要进行图像超分和增强处理
boolean needsImageProcessing = (type == 13 || type == 3);
if (type == 13) { if (type == 13) {
type = 3; // compact type = 3; // compact
} }
MemberSourceEntity memberSource = new MemberSourceEntity(); MemberSourceEntity memberSource = new MemberSourceEntity();
memberSource.setMemberId(memberId); memberSource.setMemberId(memberId);
memberSource.setFaceId(faceId); memberSource.setFaceId(faceId);
@@ -52,6 +81,143 @@ public class SourceRepository {
memberSource.setIsBuy(1); memberSource.setIsBuy(1);
sourceMapper.updateRelation(memberSource); sourceMapper.updateRelation(memberSource);
memberRelationRepository.clearSCacheByFace(faceId); 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) { public void setUserNotBuyItem(Long memberId, int type, Long faceId) {

View File

@@ -1,5 +1,6 @@
package com.ycwl.basic.service.mobile; package com.ycwl.basic.service.mobile;
import com.ycwl.basic.model.mobile.face.FaceRecognizeResp;
import com.ycwl.basic.model.mobile.goods.GoodsDetailVO; import com.ycwl.basic.model.mobile.goods.GoodsDetailVO;
import java.util.List; import java.util.List;
@@ -23,4 +24,12 @@ public interface AppAiCamService {
* @return 添加成功的数量 * @return 添加成功的数量
*/ */
int addMemberSourceRelations(Long faceId, List<Long> sourceIds); 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; 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.SearchFaceResp;
import com.ycwl.basic.facebody.entity.SearchFaceResultItem; import com.ycwl.basic.facebody.entity.SearchFaceResultItem;
import com.ycwl.basic.integration.common.manager.DeviceConfigManager; 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.FaceDetectLogAiCamMapper;
import com.ycwl.basic.mapper.FaceMapper; import com.ycwl.basic.mapper.FaceMapper;
import com.ycwl.basic.mapper.FaceSampleMapper;
import com.ycwl.basic.mapper.SourceMapper; 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.mobile.goods.GoodsDetailVO;
import com.ycwl.basic.model.pc.face.entity.FaceEntity; 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.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.MemberSourceEntity;
import com.ycwl.basic.model.pc.source.entity.SourceEntity; import com.ycwl.basic.model.pc.source.entity.SourceEntity;
import com.ycwl.basic.repository.DeviceRepository; import com.ycwl.basic.repository.DeviceRepository;
import com.ycwl.basic.repository.ScenicRepository;
import com.ycwl.basic.service.mobile.AppAiCamService; 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.JacksonUtil;
import com.ycwl.basic.utils.SnowFlakeUtil;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.*; import java.util.*;
@@ -33,10 +48,14 @@ public class AppAiCamServiceImpl implements AppAiCamService {
private final SourceMapper sourceMapper; private final SourceMapper sourceMapper;
private final FaceMapper faceMapper; private final FaceMapper faceMapper;
private final DeviceRepository deviceRepository; private final DeviceRepository deviceRepository;
private final FaceSampleMapper faceSampleMapper;
private static final float DEFAULT_SCORE_THRESHOLD = 0.8f; private static final float DEFAULT_SCORE_THRESHOLD = 0.8f;
private static final int DEFAULT_PHOTO_LIMIT = 10; private static final int DEFAULT_PHOTO_LIMIT = 10;
private static final int AI_CAM_SOURCE_TYPE = 3; private static final int AI_CAM_SOURCE_TYPE = 3;
private final FaceService faceService;
private final FaceDetectLogAiCamService faceDetectLogAiCamService;
private final ScenicService scenicService;
@Override @Override
public List<GoodsDetailVO> getAiCamGoodsByFaceId(Long faceId) { public List<GoodsDetailVO> getAiCamGoodsByFaceId(Long faceId) {
@@ -272,4 +291,93 @@ public class AppAiCamServiceImpl implements AppAiCamService {
return inserted; 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 = "录像"; goodsNamePrefix = "录像";
} else if (sourceType == 2) { } else if (sourceType == 2) {
goodsNamePrefix = "图片"; goodsNamePrefix = "图片";
} else if (sourceType == 3) {
goodsNamePrefix = "AI微单";
} else { } else {
goodsNamePrefix = "其他类型"; goodsNamePrefix = "其他类型";
} }
@@ -139,10 +137,14 @@ public class GoodsServiceImpl implements GoodsService {
goodsDetailVO.setFaceId(sourceRespVO.getFaceId()); goodsDetailVO.setFaceId(sourceRespVO.getFaceId());
goodsDetailVO.setGoodsId(sourceRespVO.getId()); goodsDetailVO.setGoodsId(sourceRespVO.getId());
String shootingTime = DateUtil.format(sourceRespVO.getCreateTime(), "yyyy.MM.dd HH:mm:ss"); String shootingTime = DateUtil.format(sourceRespVO.getCreateTime(), "yyyy.MM.dd HH:mm:ss");
if (i < 10) { if (Integer.valueOf(3).equals(sourceType)) {
goodsDetailVO.setGoodsName(goodsNamePrefix + "0" + i + " " + shootingTime); goodsDetailVO.setGoodsName("拍摄时间:" + shootingTime);
} else { } else {
goodsDetailVO.setGoodsName(goodsNamePrefix + i + " " + shootingTime); if (i < 10) {
goodsDetailVO.setGoodsName(goodsNamePrefix + "0" + i + " " + shootingTime);
} else {
goodsDetailVO.setGoodsName(goodsNamePrefix + i + " " + shootingTime);
}
} }
goodsDetailVO.setScenicId(sourceRespVO.getScenicId()); goodsDetailVO.setScenicId(sourceRespVO.getScenicId());
try { try {

View File

@@ -177,7 +177,7 @@ public class OrderServiceImpl implements OrderService {
} else if (Integer.valueOf(2).equals(orderItemList.getFirst().getGoodsType())) { } else if (Integer.valueOf(2).equals(orderItemList.getFirst().getGoodsType())) {
item.setGoodsName("照片集"); item.setGoodsName("照片集");
item.setOrderType("照片集"); item.setOrderType("照片集");
} else if (Integer.valueOf(3).equals(orderItemList.getFirst().getGoodsType())) { } else if (Integer.valueOf(13).equals(orderItemList.getFirst().getGoodsType())) {
item.setGoodsName("AI微单"); item.setGoodsName("AI微单");
item.setOrderType("AI微单"); item.setOrderType("AI微单");
} else if (Integer.valueOf(0).equals(orderItemList.getFirst().getGoodsType())) { } 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())) { } else if (Integer.valueOf(2).equals(orderItemList.getFirst().getGoodsType())) {
item.setGoodsName("照片集"); item.setGoodsName("照片集");
item.setOrderType("照片集"); item.setOrderType("照片集");
} else if (Integer.valueOf(3).equals(orderItemList.getFirst().getGoodsType())) { } else if (Integer.valueOf(13).equals(orderItemList.getFirst().getGoodsType())) {
item.setGoodsName("AI微单"); item.setGoodsName("AI微单");
item.setOrderType("AI微单"); item.setOrderType("AI微单");
} else if (Integer.valueOf(0).equals(orderItemList.getFirst().getGoodsType())) { } else if (Integer.valueOf(0).equals(orderItemList.getFirst().getGoodsType())) {
@@ -350,6 +350,26 @@ public class OrderServiceImpl implements OrderService {
item.setShootingTime(memberVideoEntityList.getFirst().getCreateTime()); 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 } else if (Integer.valueOf(3).equals(item.getGoodsType())) { // 打印照片 goodsId就是memberPrintId
List<MemberPrintResp> list = printerMapper.getUserPhotoByIds(orderItemList.stream().map(OrderItemVO::getGoodsId).collect(Collectors.toList())); List<MemberPrintResp> list = printerMapper.getUserPhotoByIds(orderItemList.stream().map(OrderItemVO::getGoodsId).collect(Collectors.toList()));
item.setCoverList(orderItemList.stream().map(OrderItemVO::getCoverUrl).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.setShootingTime(memberVideoEntityList.getFirst().getCreateTime());
item.setCount(1); 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())) { } else if (Integer.valueOf(0).equals(item.getGoodsType())) {
item.setCoverList(Collections.singletonList(item.getCoverUrl())); item.setCoverList(Collections.singletonList(item.getCoverUrl()));
VideoEntity video = videoRepository.getVideo(item.getGoodsId()); VideoEntity video = videoRepository.getVideo(item.getGoodsId());
@@ -688,6 +715,9 @@ public class OrderServiceImpl implements OrderService {
} else if (Integer.valueOf(2).equals(orderItemList.getFirst().getGoodsType())) { } else if (Integer.valueOf(2).equals(orderItemList.getFirst().getGoodsType())) {
item.setGoodsName("照片集"); item.setGoodsName("照片集");
item.setOrderType("照片集"); item.setOrderType("照片集");
} else if (Integer.valueOf(13).equals(orderItemList.getFirst().getGoodsType())) {
item.setGoodsName("打卡点拍照");
item.setOrderType("打卡点拍照");
} else if (Integer.valueOf(0).equals(orderItemList.getFirst().getGoodsType())) { } else if (Integer.valueOf(0).equals(orderItemList.getFirst().getGoodsType())) {
item.setOrderType("旅行Vlog"); item.setOrderType("旅行Vlog");
item.setGoodsName(orderItemList.getFirst().getGoodsName()); item.setGoodsName(orderItemList.getFirst().getGoodsName());
@@ -1025,6 +1055,13 @@ public class OrderServiceImpl implements OrderService {
orderItem.setGoodsType(type); orderItem.setGoodsType(type);
orderItem.setOrderId(order.getId()); orderItem.setOrderId(order.getId());
orderItems.add(orderItem); 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 { try {
self.saveOrderInTransaction(order, orderItems, haveOldOrder); 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.DownloadStage;
import com.ycwl.basic.image.pipeline.stages.ImageEnhanceStage; import com.ycwl.basic.image.pipeline.stages.ImageEnhanceStage;
import com.ycwl.basic.image.pipeline.stages.ImageOrientationStage; 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.PuzzleBorderStage;
import com.ycwl.basic.image.pipeline.stages.RestoreOrientationStage; 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.UploadStage;
import com.ycwl.basic.image.pipeline.stages.WatermarkConfig; import com.ycwl.basic.image.pipeline.stages.WatermarkConfig;
import com.ycwl.basic.image.pipeline.stages.WatermarkStage; 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.PrintTaskMapper;
import com.ycwl.basic.mapper.PrinterMapper; import com.ycwl.basic.mapper.PrinterMapper;
import com.ycwl.basic.mapper.SourceMapper; import com.ycwl.basic.mapper.SourceMapper;
import com.ycwl.basic.model.Crop;
import com.ycwl.basic.model.PrinterOrderItem; import com.ycwl.basic.model.PrinterOrderItem;
import com.ycwl.basic.model.mobile.face.FaceRecognizeResp; import com.ycwl.basic.model.mobile.face.FaceRecognizeResp;
import com.ycwl.basic.model.mobile.order.PriceObj; import com.ycwl.basic.model.mobile.order.PriceObj;
@@ -347,6 +351,8 @@ public class PrinterServiceImpl implements PrinterService {
log.info("照片裁剪成功: memberId={}, scenicId={}, 原图={}, 裁剪后={}, 尺寸={}x{}", log.info("照片裁剪成功: memberId={}, scenicId={}, 原图={}, 裁剪后={}, 尺寸={}x{}",
memberId, scenicId, url, cropUrl, printWidth, printHeight); memberId, scenicId, url, cropUrl, printWidth, printHeight);
String crop = JacksonUtil.toJSONString(new Crop(270));
entity.setCrop(crop);
} finally { } finally {
// 清理临时文件 // 清理临时文件
if (croppedFile != null && croppedFile.exists()) { if (croppedFile != null && croppedFile.exists()) {
@@ -467,6 +473,34 @@ public class PrinterServiceImpl implements PrinterService {
request.setProducts(productItems); 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) { if (mobileCount > 0) {
try { try {
autoCouponService.autoGrantCoupon( autoCouponService.autoGrantCoupon(
@@ -505,6 +539,10 @@ public class PrinterServiceImpl implements PrinterService {
} }
String url = byId.getUrl(); String url = byId.getUrl();
// 特殊兼容处理
if (Integer.valueOf(3).equals(byId.getType())) {
url = byId.getThumbUrl().replace("_t.", "_o.");
}
MemberPrintEntity entity = new MemberPrintEntity(); MemberPrintEntity entity = new MemberPrintEntity();
entity.setMemberId(memberId); entity.setMemberId(memberId);
entity.setScenicId(scenicId); entity.setScenicId(scenicId);
@@ -540,6 +578,7 @@ public class PrinterServiceImpl implements PrinterService {
// 使用smartCropAndFill裁剪图片 // 使用smartCropAndFill裁剪图片
File croppedFile = ImageUtils.smartCropAndFill(url, printWidth, printHeight); File croppedFile = ImageUtils.smartCropAndFill(url, printWidth, printHeight);
entity.setCrop(JacksonUtil.toJSONString(new Crop(270)));
try { try {
// 上传裁剪后的图片 // 上传裁剪后的图片
@@ -800,6 +839,18 @@ public class PrinterServiceImpl implements PrinterService {
* @return 处理后的URL,失败返回原URL * @return 处理后的URL,失败返回原URL
*/ */
private String processPhotoWithPipeline(MemberPrintResp item, Long scenicId, File qrCodeFile) { 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); PrinterOrderItem orderItem = PrinterOrderItem.fromMemberPrintResp(item);
PhotoProcessContext context = PhotoProcessContext.fromPrinterOrderItem(orderItem, scenicId); PhotoProcessContext context = PhotoProcessContext.fromPrinterOrderItem(orderItem, scenicId);
@@ -812,11 +863,15 @@ public class PrinterServiceImpl implements PrinterService {
// 设置管线场景为图片打印 // 设置管线场景为图片打印
context.setScene(PipelineScene.IMAGE_PRINT); context.setScene(PipelineScene.IMAGE_PRINT);
// 根据sourceId判断图片来源 // 处理图像增强选项
// sourceId > 0: IPC设备拍摄 if (needEnhance != null && needEnhance) {
// sourceId == null: 手机上传 context.setStageState("image_enhance", true);
// sourceId == 0: 拼图(暂定为UNKNOWN) }
// 根据sourceId判断图片来源和source类型
SourceEntity source = null;
if (item.getSourceId() != null && item.getSourceId() > 0) { if (item.getSourceId() != null && item.getSourceId() > 0) {
source = sourceMapper.getEntity(item.getSourceId());
context.setSource(ImageSource.IPC); context.setSource(ImageSource.IPC);
} else if (item.getSourceId() == null) { } else if (item.getSourceId() == null) {
context.setSource(ImageSource.PHONE); context.setSource(ImageSource.PHONE);
@@ -825,15 +880,49 @@ public class PrinterServiceImpl implements PrinterService {
} }
Pipeline<PhotoProcessContext> pipeline; 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); 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); pipeline = createNormalPhotoPipeline(watermarkConfig);
} else { } else {
// 拼图 // 拼图处理流程
prepareStorageAdapter(context); prepareStorageAdapter(context);
pipeline = createPuzzlePipeline(); pipeline = createPuzzlePipeline();
} }
@@ -867,7 +956,7 @@ public class PrinterServiceImpl implements PrinterService {
* @param qrCodeFile 二维码文件 * @param qrCodeFile 二维码文件
* @return WatermarkConfig * @return WatermarkConfig
*/ */
private WatermarkConfig prepareWatermarkConfig(PhotoProcessContext context, File qrCodeFile) { private WatermarkConfig prepareWatermarkConfig(PhotoProcessContext context, File qrCodeFile, Double scale) {
ScenicConfigManager scenicConfig = context.getScenicConfigManager(); ScenicConfigManager scenicConfig = context.getScenicConfigManager();
if (scenicConfig == null) { if (scenicConfig == null) {
log.warn("scenicConfigManager未设置,返回空水印配置"); log.warn("scenicConfigManager未设置,返回空水印配置");
@@ -888,6 +977,7 @@ public class PrinterServiceImpl implements PrinterService {
.scenicText(scenicText) .scenicText(scenicText)
.dateFormat(dateFormat) .dateFormat(dateFormat)
.qrcodeFile(qrCodeFile) .qrcodeFile(qrCodeFile)
.scale(scale)
.build(); .build();
} }
@@ -937,53 +1027,56 @@ public class PrinterServiceImpl implements PrinterService {
} catch (Exception e) { } catch (Exception e) {
throw new RuntimeException(e); throw new RuntimeException(e);
} }
userPhotoListByOrderId.forEach(item -> { Thread.ofVirtual().start(() -> {
PrinterEntity printer = printerMapper.getById(item.getPrinterId()); userPhotoListByOrderId.forEach(item -> {
PrinterEntity printer = printerMapper.getById(item.getPrinterId());
// 使用管线处理照片 // 使用管线处理照片
String printUrl = processPhotoWithPipeline(item, item.getScenicId(), qrCodeFile); String printUrl = processPhotoWithPipeline(item, item.getScenicId(), qrCodeFile);
// 根据数量创建多个打印任务 // 根据数量创建多个打印任务
Integer quantity = item.getQuantity(); Integer quantity = item.getQuantity();
if (quantity == null || quantity <= 0) { if (quantity == null || quantity <= 0) {
quantity = 1; // 默认至少打印1张 quantity = 1; // 默认至少打印1张
} }
for (int i = 0; i < quantity; i++) { for (int i = 0; i < quantity; i++) {
// 获取打印机名称(支持轮询) // 获取打印机名称(支持轮询)
String selectedPrinter = getNextPrinter(printer); String selectedPrinter = getNextPrinter(printer);
// 根据景区配置决定任务初始状态 // 根据景区配置决定任务初始状态
ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(order.getScenicId()); ScenicConfigManager scenicConfig = scenicRepository.getScenicConfigManager(order.getScenicId());
Boolean purchaseNeedReview = scenicConfig.getBoolean("printer_manual_approve"); Boolean purchaseNeedReview = scenicConfig.getBoolean("printer_manual_approve");
int initialStatus = (purchaseNeedReview != null && purchaseNeedReview) int initialStatus = (purchaseNeedReview != null && purchaseNeedReview)
? TASK_STATUS_PENDING_REVIEW ? TASK_STATUS_PENDING_REVIEW
: TASK_STATUS_PENDING; : TASK_STATUS_PENDING;
PrintTaskEntity task = new PrintTaskEntity(); PrintTaskEntity task = new PrintTaskEntity();
task.setPrinterId(printer.getId()); task.setPrinterId(printer.getId());
task.setPrinterName(selectedPrinter); task.setPrinterName(selectedPrinter);
task.setMpId(item.getId()); task.setMpId(item.getId());
task.setPaper(printer.getPreferPaper()); task.setPaper(printer.getPreferPaper());
task.setStatus(initialStatus); task.setStatus(initialStatus);
task.setUrl(printUrl); task.setUrl(printUrl);
task.setHeight(printer.getPreferH()); task.setHeight(printer.getPreferH());
task.setWidth(printer.getPreferW()); task.setWidth(printer.getPreferW());
task.setCreateTime(new Date()); task.setCreateTime(new Date());
task.setUpdateTime(new Date()); task.setUpdateTime(new Date());
printTaskMapper.insertTask(task); printTaskMapper.insertTask(task);
// ========== WebSocket 推送任务 ========== // ========== WebSocket 推送任务 ==========
// 只推送立即可处理的任务(status=0),待审核任务(status=4)等审核通过后再推送 // 只推送立即可处理的任务(status=0),待审核任务(status=4)等审核通过后再推送
if (initialStatus == TASK_STATUS_PENDING) { if (initialStatus == TASK_STATUS_PENDING) {
try { try {
taskPushService.pushTaskToPrinter(printer.getId(), task.getId()); taskPushService.pushTaskToPrinter(printer.getId(), task.getId());
} catch (Exception e) { } catch (Exception e) {
log.error("推送任务失败: printerId={}, taskId={}", printer.getId(), task.getId(), e); log.error("推送任务失败: printerId={}, taskId={}", printer.getId(), task.getId(), e);
// 推送失败不影响任务创建,任务会通过 HTTP 轮询获取 // 推送失败不影响任务创建,任务会通过 HTTP 轮询获取
}
} }
} }
} });
redisTemplate.delete("order_content_not_downloadable_" + orderId);
}); });
} }
@@ -1418,58 +1511,15 @@ public class PrinterServiceImpl implements PrinterService {
needEnhance = false; // 默认不增强 needEnhance = false; // 默认不增强
} }
// 3.1 创建图片处理上下文 // 3.1 使用管线处理照片(复用 processPhotoWithPipeline)
PrinterOrderItem orderItem = PrinterOrderItem.fromMemberPrintResp(memberPrint); String newPrintUrl;
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;
try { try {
if (context.getImageType() == ImageType.NORMAL_PHOTO) { newPrintUrl = processPhotoWithPipeline(memberPrint, memberPrint.getScenicId(), qrCodeFile, needEnhance);
// 准备水印配置(重打印需要二维码) log.info("handleReprint: 照片重新处理成功, taskId={}, mpId={}, enhance={}, newUrl={}",
WatermarkConfig watermarkConfig = prepareWatermarkConfig(context, qrCodeFile); id, mpId, needEnhance, newPrintUrl);
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();
log.info("handleReprint: 照片重新处理成功, taskId={}, mpId={}, enhance={}, newUrl={}",
id, mpId, needEnhance, newPrintUrl);
} else {
log.warn("handleReprint: 照片重新处理失败, taskId={}, 使用原图", id);
newPrintUrl = memberPrint.getCropUrl(); // 使用原裁剪图
}
} catch (Exception e) { } catch (Exception e) {
log.error("handleReprint: 照片重新处理异常, taskId={}, 使用原图", id, e); log.error("handleReprint: 照片重新处理异常, taskId={}, 使用原图", id, e);
newPrintUrl = memberPrint.getCropUrl(); newPrintUrl = memberPrint.getCropUrl();
} finally {
context.cleanup();
} }
// 4. 更新打印任务 // 4. 更新打印任务

View File

@@ -46,6 +46,82 @@
</encoder> </encoder>
</appender> </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"> <root level="ERROR">
<appender-ref ref="error_log" /> <appender-ref ref="error_log" />
<appender-ref ref="STDOUT" /> <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 face f ON ms.face_id = f.id
LEFT JOIN source s ON ms.source_id = s.id LEFT JOIN source s ON ms.source_id = s.id
WHERE s.id IS NOT NULL 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 ( 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 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 '3' THEN '照片打印'
WHEN '4' THEN '一体机照片打印' WHEN '4' THEN '一体机照片打印'
WHEN '5' THEN 'pLog' WHEN '5' THEN 'pLog'
WHEN '13' THEN '打卡点拍照'
ELSE '其他' ELSE '其他'
END AS goods_name, END AS goods_name,
CASE oi.goods_type CASE oi.goods_type
WHEN '0' THEN mvd.face_id WHEN '0' THEN mvd.face_id
WHEN '1' THEN oi.goods_id WHEN '1' THEN oi.goods_id
WHEN '2' THEN oi.goods_id WHEN '2' THEN oi.goods_id
WHEN '13' THEN oi.goods_id
END AS face_id, END AS face_id,
CASE oi.goods_type CASE oi.goods_type
WHEN '0' THEN mvd.face_url WHEN '0' THEN mvd.face_url
WHEN '1' THEN msd.face_url WHEN '1' THEN msd.face_url
WHEN '2' THEN msd.face_url WHEN '2' THEN msd.face_url
WHEN '13' THEN msac.face_url
END AS face_url, END AS face_url,
CASE oi.goods_type CASE oi.goods_type
WHEN '0' THEN mvd.video_url WHEN '0' THEN mvd.video_url
@@ -149,11 +159,13 @@
WHEN '3' THEN mpd.url WHEN '3' THEN mpd.url
WHEN '4' THEN mpa.url WHEN '4' THEN mpa.url
WHEN '5' THEN mpl.url WHEN '5' THEN mpl.url
WHEN '13' THEN msac.url
END AS imgUrl END AS imgUrl
FROM order_item oi FROM order_item oi
LEFT JOIN `order` o ON oi.order_id = o.id 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_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_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_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_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 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, source_id,
orig_url, orig_url,
crop_url, crop_url,
crop,
quantity, quantity,
status, status,
create_time, create_time,
@@ -127,6 +128,7 @@
#{sourceId}, #{sourceId},
#{origUrl}, #{origUrl},
#{cropUrl}, #{cropUrl},
#{crop},
1, 1,
0, 0,
NOW(), NOW(),

View File

@@ -198,7 +198,7 @@
where so.id = #{id} and ms.member_id = #{userId} and so.id is not null where so.id = #{id} and ms.member_id = #{userId} and so.id is not null
</select> </select>
<select id="getById" resultType="com.ycwl.basic.model.pc.source.resp.SourceRespVO"> <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 from source so
where so.id = #{id} where so.id = #{id}
@@ -348,6 +348,12 @@
left join source s on ms.source_id = s.id left join source s on ms.source_id = s.id
where ms.face_id = #{faceId} and ms.type = 2 where ms.face_id = #{faceId} and ms.type = 2
</select> </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 id="getEntity" resultType="com.ycwl.basic.model.pc.source.entity.SourceEntity">
select * select *
from source from source