feat: 集成 KieAI 服务,移除 models-integration 子项目
- 添加 Gemini 2.5 Flash 对话接口(流式+非流式) - 添加 NanoBanana 图像生成/编辑接口 - 添加 Sora2 视频生成接口(文生视频、图生视频、去水印) - 移除 models-integration 子项目(功能已迁移至主后端) - 新增测试文档和 Playwright E2E 配置 - 更新前端页面和 API 接口 - 更新后端配置和日志处理
This commit is contained in:
@@ -6,6 +6,7 @@ import com.anji.captcha.service.CaptchaService;
|
||||
import com.anji.captcha.util.StringUtils;
|
||||
import com.zbkj.service.service.SafetyService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
@@ -20,6 +21,9 @@ import javax.servlet.http.HttpServletRequest;
|
||||
@Service
|
||||
public class SafetyServiceImpl implements SafetyService {
|
||||
|
||||
@Value("${captcha.enabled:true}")
|
||||
private boolean captchaEnabled;
|
||||
|
||||
@Autowired
|
||||
private CaptchaService captchaService;
|
||||
|
||||
@@ -28,6 +32,9 @@ public class SafetyServiceImpl implements SafetyService {
|
||||
*/
|
||||
@Override
|
||||
public ResponseModel getSafetyCode(CaptchaVO data, HttpServletRequest request) {
|
||||
if (!captchaEnabled) {
|
||||
return ResponseModel.successMsg("captcha disabled");
|
||||
}
|
||||
assert request.getRemoteHost() != null;
|
||||
data.setBrowserInfo(getRemoteId(request));
|
||||
return captchaService.get(data);
|
||||
@@ -38,6 +45,9 @@ public class SafetyServiceImpl implements SafetyService {
|
||||
*/
|
||||
@Override
|
||||
public ResponseModel checkSafetyCode(CaptchaVO data, HttpServletRequest request) {
|
||||
if (!captchaEnabled) {
|
||||
return ResponseModel.successMsg("captcha disabled");
|
||||
}
|
||||
data.setBrowserInfo(getRemoteId(request));
|
||||
return captchaService.check(data);
|
||||
}
|
||||
@@ -47,6 +57,9 @@ public class SafetyServiceImpl implements SafetyService {
|
||||
*/
|
||||
@Override
|
||||
public ResponseModel verifySafetyCode(CaptchaVO data) {
|
||||
if (!captchaEnabled) {
|
||||
return ResponseModel.successMsg("captcha disabled");
|
||||
}
|
||||
return captchaService.verification(data);
|
||||
}
|
||||
|
||||
|
||||
@@ -204,15 +204,10 @@ public class UserSignServiceImpl extends ServiceImpl<UserSignDao, UserSign> impl
|
||||
@Override
|
||||
public HashMap<String, Object> get() {
|
||||
HashMap<String, Object> map = new HashMap<>();
|
||||
//当前积分
|
||||
User info = userService.getInfo();
|
||||
User info = userService.getInfoException();
|
||||
map.put("integral", info.getIntegral());
|
||||
//总计签到天数
|
||||
map.put("count", signCount(info.getUid()));
|
||||
//连续签到数据
|
||||
|
||||
//今日是否已经签到
|
||||
map.put("today", false);
|
||||
map.put("today", checkDaySign(info.getUid()));
|
||||
return map;
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ public class DishImageServiceImpl implements DishImageService {
|
||||
|
||||
/** 默认占位图 URL */
|
||||
private static final String DEFAULT_PLACEHOLDER_URL =
|
||||
"https://uthink2025.oss-cn-shanghai.aliyuncs.com/recipes/default-food.png";
|
||||
"https://uthink2026.oss-cn-shanghai.aliyuncs.com/recipes/default-food.png";
|
||||
|
||||
/** OSS 上传路径前缀(菜品) */
|
||||
private static final String OSS_RECIPES_PATH = "recipes/";
|
||||
@@ -61,6 +61,9 @@ public class DishImageServiceImpl implements DishImageService {
|
||||
/** OSS 上传路径前缀(食物百科) */
|
||||
private static final String OSS_FOODS_PATH = "foods/";
|
||||
|
||||
/** OSS 上传路径前缀(知识封面) */
|
||||
private static final String OSS_KNOWLEDGE_PATH = "knowledge/";
|
||||
|
||||
/** 上传前图片最大体积(字节),压缩到此值以内 */
|
||||
private static final long MAX_IMAGE_BYTES = 100 * 1024L;
|
||||
|
||||
@@ -178,43 +181,10 @@ public class DishImageServiceImpl implements DishImageService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用流程:根据 name 与 prompt 调用 KieAI 文生图 → 下载 → 压缩至 100KB → 上传 OSS,返回 OSS 完整 URL
|
||||
*
|
||||
* @param name 名称(用于生成文件名)
|
||||
* @param prompt 文生图 prompt
|
||||
* @param pathPrefix OSS 路径前缀,如 "recipes/" 或 "foods/"
|
||||
* @param filePrefix 文件名前缀,如 "dish-" 或 "food-"
|
||||
* @return OSS 完整 URL,失败返回 null
|
||||
* 通用流程:根据 name 与 prompt 调用 KieAI 文生图 → 下载 → 压缩至 100KB → 上传 OSS,返回 OSS 完整 URL(使用默认比例)
|
||||
*/
|
||||
private String generateImageAndUploadToOss(String name, String prompt, String pathPrefix, String filePrefix) {
|
||||
if (kieAIConfig.getApiToken() == null || kieAIConfig.getApiToken().trim().isEmpty()) {
|
||||
logger.warn("KieAI API Token 未配置,跳过AI图片生成");
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
KieAINanoBananaRequest request = buildKieAIRequest(prompt);
|
||||
KieAICreateTaskResponse createResp = kieAIService.createTextToImageTask(request);
|
||||
String taskId = createResp.getTaskId();
|
||||
logger.info("KieAI文生图任务已创建, name: {}, taskId: {}", name, taskId);
|
||||
|
||||
KieAIQueryTaskResponse result = kieAIService.waitForTaskCompletion(taskId, KIEAI_WAIT_TIMEOUT);
|
||||
if (!"success".equalsIgnoreCase(result.getState()) || result.getResultJson() == null || result.getResultJson().isEmpty()) {
|
||||
logger.warn("KieAI文生图未成功, name: {}, state: {}", name, result != null ? result.getState() : "null");
|
||||
return null;
|
||||
}
|
||||
String generatedImageUrl = extractImageUrlFromResultJson(result.getResultJson());
|
||||
if (generatedImageUrl == null) {
|
||||
return null;
|
||||
}
|
||||
byte[] imageBytes = downloadImage(generatedImageUrl);
|
||||
if (imageBytes == null || imageBytes.length == 0) {
|
||||
return null;
|
||||
}
|
||||
return uploadToOss(imageBytes, name, pathPrefix, filePrefix);
|
||||
} catch (Exception e) {
|
||||
logger.error("generateImageAndUploadToOss 失败: name={}", name, e);
|
||||
return null;
|
||||
}
|
||||
return generateImageAndUploadToOss(name, prompt, pathPrefix, filePrefix, kieAIConfig.getDefaultImageSize());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -291,15 +261,64 @@ public class DishImageServiceImpl implements DishImageService {
|
||||
* 构建 KieAI 请求
|
||||
*/
|
||||
private KieAINanoBananaRequest buildKieAIRequest(String prompt) {
|
||||
return buildKieAIRequest(prompt, kieAIConfig.getDefaultImageSize());
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 KieAI 请求,可指定图片比例(如 1:1)
|
||||
*/
|
||||
private KieAINanoBananaRequest buildKieAIRequest(String prompt, String imageSize) {
|
||||
KieAINanoBananaRequest request = new KieAINanoBananaRequest();
|
||||
KieAINanoBananaRequest.Input input = new KieAINanoBananaRequest.Input();
|
||||
input.setPrompt(prompt);
|
||||
input.setOutput_format(kieAIConfig.getDefaultOutputFormat());
|
||||
input.setImage_size(kieAIConfig.getDefaultImageSize());
|
||||
input.setImage_size(imageSize != null ? imageSize : kieAIConfig.getDefaultImageSize());
|
||||
request.setInput(input);
|
||||
return request;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String generateImageAndUploadToOssForKnowledge(String name, String prompt) {
|
||||
if (StrUtil.isBlank(prompt)) {
|
||||
prompt = "健康营养主题的配图,简洁现代风格";
|
||||
}
|
||||
return generateImageAndUploadToOss(name, prompt, OSS_KNOWLEDGE_PATH, "knowledge-", "1:1");
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用流程:根据 name 与 prompt 调用 KieAI 文生图 → 下载 → 压缩至 100KB → 上传 OSS(可指定比例)
|
||||
*/
|
||||
private String generateImageAndUploadToOss(String name, String prompt, String pathPrefix, String filePrefix, String imageSize) {
|
||||
if (kieAIConfig.getApiToken() == null || kieAIConfig.getApiToken().trim().isEmpty()) {
|
||||
logger.warn("KieAI API Token 未配置,跳过AI图片生成");
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
KieAINanoBananaRequest request = buildKieAIRequest(prompt, imageSize);
|
||||
KieAICreateTaskResponse createResp = kieAIService.createTextToImageTask(request);
|
||||
String taskId = createResp.getTaskId();
|
||||
logger.info("KieAI文生图任务已创建, name: {}, taskId: {}", name, taskId);
|
||||
|
||||
KieAIQueryTaskResponse result = kieAIService.waitForTaskCompletion(taskId, KIEAI_WAIT_TIMEOUT);
|
||||
if (!"success".equalsIgnoreCase(result.getState()) || result.getResultJson() == null || result.getResultJson().isEmpty()) {
|
||||
logger.warn("KieAI文生图未成功, name: {}, state: {}", name, result != null ? result.getState() : "null");
|
||||
return null;
|
||||
}
|
||||
String generatedImageUrl = extractImageUrlFromResultJson(result.getResultJson());
|
||||
if (generatedImageUrl == null) {
|
||||
return null;
|
||||
}
|
||||
byte[] imageBytes = downloadImage(generatedImageUrl);
|
||||
if (imageBytes == null || imageBytes.length == 0) {
|
||||
return null;
|
||||
}
|
||||
return uploadToOss(imageBytes, name, pathPrefix, filePrefix);
|
||||
} catch (Exception e) {
|
||||
logger.error("generateImageAndUploadToOss 失败: name={}", name, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载图片(带浏览器 User-Agent 绕过 Cloudflare WAF)
|
||||
*/
|
||||
|
||||
@@ -116,6 +116,10 @@ public class ToolCozeServiceImpl implements ToolCozeService {
|
||||
try {
|
||||
CozeAPI client = getClient();
|
||||
List<Message> messages = buildMessages(request);
|
||||
if (messages == null || messages.isEmpty()) {
|
||||
logger.warn("Coze chat: no user message in request (additionalMessages/chatHistory empty or without content)");
|
||||
return CozeBaseResponse.error("请提供对话内容");
|
||||
}
|
||||
|
||||
CreateChatReq.CreateChatReqBuilder builder = CreateChatReq.builder()
|
||||
.botID(request.getBotId())
|
||||
@@ -142,6 +146,11 @@ public class ToolCozeServiceImpl implements ToolCozeService {
|
||||
try {
|
||||
CozeAPI client = getClient();
|
||||
List<Message> messages = buildMessages(request);
|
||||
if (messages == null || messages.isEmpty()) {
|
||||
logger.warn("Coze chat stream: no user message in request");
|
||||
emitter.completeWithError(new RuntimeException("请提供对话内容"));
|
||||
return emitter;
|
||||
}
|
||||
|
||||
CreateChatReq.CreateChatReqBuilder builder = CreateChatReq.builder()
|
||||
.botID(request.getBotId())
|
||||
|
||||
@@ -64,8 +64,11 @@ public class ToolFoodServiceImpl implements ToolFoodService {
|
||||
map.put("id", food.getFoodId());
|
||||
map.put("name", food.getName());
|
||||
map.put("image", food.getImage());
|
||||
map.put("category", food.getCategory());
|
||||
map.put("energy", food.getEnergy());
|
||||
map.put("protein", food.getProtein());
|
||||
map.put("potassium", food.getPotassium());
|
||||
map.put("phosphorus", food.getPhosphorus());
|
||||
map.put("suitabilityLevel", food.getSuitabilityLevel());
|
||||
result.add(map);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.zbkj.common.utils.RedisUtil;
|
||||
import com.zbkj.service.dao.tool.V2KnowledgeDao;
|
||||
import com.zbkj.service.dao.tool.V2RecipeDao;
|
||||
import com.zbkj.service.dao.tool.V2UserPointsDao;
|
||||
import com.zbkj.service.service.SystemConfigService;
|
||||
import com.zbkj.service.service.tool.ToolHomeService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@@ -44,6 +45,9 @@ public class ToolHomeServiceImpl implements ToolHomeService {
|
||||
private static final long CACHE_SECONDS_RECOMMENDED_LIST = 600L;
|
||||
private static final long CACHE_SECONDS_HEALTH_STATUS = 300L;
|
||||
|
||||
/** 系统配置 key:首页是否显示四大功能入口,value=1 时显示 */
|
||||
private static final String CONFIG_KEY_FIELD01 = "field101";
|
||||
|
||||
@Resource
|
||||
private V2RecipeDao v2RecipeDao;
|
||||
|
||||
@@ -59,6 +63,9 @@ public class ToolHomeServiceImpl implements ToolHomeService {
|
||||
@Autowired
|
||||
private RedisUtil redisUtil;
|
||||
|
||||
@Autowired
|
||||
private SystemConfigService systemConfigService;
|
||||
|
||||
/**
|
||||
* 获取首页数据
|
||||
* @return 首页数据
|
||||
@@ -258,4 +265,17 @@ public class ToolHomeServiceImpl implements ToolHomeService {
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> getDisplayConfig() {
|
||||
Map<String, Object> config = new HashMap<>();
|
||||
try {
|
||||
String field01 = systemConfigService.getValueByKey(CONFIG_KEY_FIELD01);
|
||||
config.put("showFunctionEntries", "1".equals(field01));
|
||||
} catch (Exception e) {
|
||||
log.warn("getDisplayConfig field01 not set, default showFunctionEntries=false", e);
|
||||
config.put("showFunctionEntries", false);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.zbkj.common.exception.CrmebException;
|
||||
import com.zbkj.common.model.tool.V2Knowledge;
|
||||
import com.zbkj.common.request.PageParamRequest;
|
||||
import com.zbkj.service.dao.tool.V2KnowledgeDao;
|
||||
import com.zbkj.service.service.tool.DishImageService;
|
||||
import com.zbkj.service.service.tool.ToolKnowledgeService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -31,6 +32,9 @@ public class ToolKnowledgeServiceImpl implements ToolKnowledgeService {
|
||||
@Resource
|
||||
private V2KnowledgeDao v2KnowledgeDao;
|
||||
|
||||
@Resource
|
||||
private DishImageService dishImageService;
|
||||
|
||||
/**
|
||||
* 获取营养知识列表
|
||||
* @param pageParamRequest 分页参数
|
||||
@@ -103,4 +107,46 @@ public class ToolKnowledgeServiceImpl implements ToolKnowledgeService {
|
||||
|
||||
return BeanUtil.beanToMap(knowledge);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int fillMissingCoverImages(int limit) {
|
||||
if (limit <= 0) {
|
||||
limit = 10;
|
||||
}
|
||||
limit = Math.min(limit, 20);
|
||||
LambdaQueryWrapper<V2Knowledge> lqw = new LambdaQueryWrapper<>();
|
||||
lqw.in(V2Knowledge::getType, "guide", "article");
|
||||
lqw.eq(V2Knowledge::getStatus, "published");
|
||||
lqw.and(w -> w.isNull(V2Knowledge::getCoverImage).or().eq(V2Knowledge::getCoverImage, ""));
|
||||
lqw.orderByAsc(V2Knowledge::getKnowledgeId);
|
||||
lqw.last("LIMIT " + limit);
|
||||
List<V2Knowledge> list = v2KnowledgeDao.selectList(lqw);
|
||||
int updated = 0;
|
||||
for (V2Knowledge item : list) {
|
||||
String name = item.getTitle() != null ? item.getTitle() : "knowledge-" + item.getKnowledgeId();
|
||||
String prompt = buildKnowledgeCoverPrompt(item);
|
||||
String ossUrl = dishImageService.generateImageAndUploadToOssForKnowledge(name, prompt);
|
||||
if (StrUtil.isNotBlank(ossUrl)) {
|
||||
item.setCoverImage(ossUrl);
|
||||
v2KnowledgeDao.updateById(item);
|
||||
updated++;
|
||||
log.info("知识封面已更新: knowledgeId={}, title={}, ossUrl={}", item.getKnowledgeId(), item.getTitle(), ossUrl);
|
||||
} else {
|
||||
log.warn("知识封面生成失败: knowledgeId={}, title={}", item.getKnowledgeId(), item.getTitle());
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
private String buildKnowledgeCoverPrompt(V2Knowledge item) {
|
||||
String title = StrUtil.isNotBlank(item.getTitle()) ? item.getTitle() : "营养知识";
|
||||
String summary = StrUtil.isNotBlank(item.getSummary()) ? item.getSummary() : "";
|
||||
if (StrUtil.isNotBlank(summary)) {
|
||||
if (summary.length() > 80) {
|
||||
summary = summary.substring(0, 80) + "…";
|
||||
}
|
||||
return "健康营养主题配图,标题:" + title + "。摘要:" + summary + "。简洁现代风格,1:1 方图。";
|
||||
}
|
||||
return "健康营养主题配图,标题:" + title + "。简洁现代风格,1:1 方图。";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,4 +37,13 @@ public interface DishImageService {
|
||||
* @return 更新后的图片 URL(若已是 OSS 或更新成功返回 URL,失败返回原 image 或 null)
|
||||
*/
|
||||
String ensureFoodImageAndUpdateDb(Long foodId);
|
||||
|
||||
/**
|
||||
* 为知识封面生成 1:1 图片并压缩到 100KB 以内上传到 OSS
|
||||
*
|
||||
* @param name 名称(用于生成文件名)
|
||||
* @param prompt 文生图描述
|
||||
* @return OSS 完整 URL,失败返回 null
|
||||
*/
|
||||
String generateImageAndUploadToOssForKnowledge(String name, String prompt);
|
||||
}
|
||||
|
||||
@@ -35,5 +35,11 @@ public interface ToolHomeService {
|
||||
* @return 健康档案状态
|
||||
*/
|
||||
Map<String, Object> getHealthStatus();
|
||||
|
||||
/**
|
||||
* 获取首页展示配置(如是否显示四大功能入口,依赖 eb_system_config 中 field01:1=显示)
|
||||
* @return 含 showFunctionEntries 等展示开关
|
||||
*/
|
||||
Map<String, Object> getDisplayConfig();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package com.zbkj.service.service.tool;
|
||||
|
||||
import com.zbkj.common.request.kieai.KieAIGeminiChatRequest;
|
||||
import com.zbkj.common.request.kieai.KieAINanoBananaRequest;
|
||||
import com.zbkj.common.response.kieai.KieAICreateTaskResponse;
|
||||
import com.zbkj.common.response.kieai.KieAIQueryTaskResponse;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* KieAI 服务接口
|
||||
@@ -53,4 +57,20 @@ public interface ToolKieAIService {
|
||||
* @param response 回调数据
|
||||
*/
|
||||
void handleTaskCallback(String taskId, KieAIQueryTaskResponse response);
|
||||
|
||||
/**
|
||||
* Gemini 2.5 Flash 非流式对话
|
||||
*
|
||||
* @param request 对话请求
|
||||
* @return 完整 JSON 响应(id, choices, usage 等)
|
||||
*/
|
||||
Map<String, Object> geminiChat(KieAIGeminiChatRequest request);
|
||||
|
||||
/**
|
||||
* Gemini 2.5 Flash 流式对话(SSE)
|
||||
*
|
||||
* @param request 对话请求
|
||||
* @return SseEmitter,向前端推送 data 事件
|
||||
*/
|
||||
SseEmitter geminiChatStream(KieAIGeminiChatRequest request);
|
||||
}
|
||||
|
||||
@@ -35,5 +35,13 @@ public interface ToolKnowledgeService {
|
||||
* @return 营养素详情
|
||||
*/
|
||||
Map<String, Object> getNutrientDetail(String name);
|
||||
|
||||
/**
|
||||
* 为 cover_image 为空的饮食指南/科普文章记录生成封面图(KieAI 1:1,压缩至 100KB,上传 OSS 并写回 v2_knowledge)
|
||||
*
|
||||
* @param limit 本次最多处理条数
|
||||
* @return 成功更新条数
|
||||
*/
|
||||
int fillMissingCoverImages(int limit);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user