feat: 集成 KieAI 服务,移除 models-integration 子项目

- 添加 Gemini 2.5 Flash 对话接口(流式+非流式)
- 添加 NanoBanana 图像生成/编辑接口
- 添加 Sora2 视频生成接口(文生视频、图生视频、去水印)
- 移除 models-integration 子项目(功能已迁移至主后端)
- 新增测试文档和 Playwright E2E 配置
- 更新前端页面和 API 接口
- 更新后端配置和日志处理
This commit is contained in:
2026-03-03 15:33:50 +08:00
parent 1ddb051977
commit 4be53dcd1b
586 changed files with 21142 additions and 25130 deletions

View File

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

View File

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

View File

@@ -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
*/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,5 +35,11 @@ public interface ToolHomeService {
* @return 健康档案状态
*/
Map<String, Object> getHealthStatus();
/**
* 获取首页展示配置(如是否显示四大功能入口,依赖 eb_system_config 中 field011=显示)
* @return 含 showFunctionEntries 等展示开关
*/
Map<String, Object> getDisplayConfig();
}

View File

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

View File

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