摘要:在 Spring Boot + Vue 3 二手/电商项目中接入阿里云百炼视觉大模型,实现"上传图片 → AI 提取检索词 → 业务库模糊匹配"的完整链路。本文将带你从环境搭建、API 申请、后端开发、前端开发到测试调优,一步步落地 AI 识图搜索功能。含完整前后端代码与配置说明。

关键词:AI 识图、Spring Boot、Vue 3、百炼、多模态、二手交易、视觉大模型、语义搜索


目录


一、引言:为什么我们需要 AI 识图搜索

1.1 一个真实的场景

想象这样一个场景:你走在街上,看到有人背着一款你特别喜欢的背包,但你不知道品牌、不知道型号,甚至连它属于什么品类都说不清楚——"就是那个米色的、方形的、有个小logo的包"。你打开某电商 App,在搜索框里敲下这串描述,结果返回一堆不相关的东西。你尝试拍照,但 App 只有「扫码购」功能,拍背包根本没用。

这就是传统搜索的盲区:用户手里有图,脑子有印象,却无法用文字准确表达搜索意图

1.2 传统搜索的三大痛点

在电商、内容平台、企业资产库、仓储盘点、社交市集等场景中,传统关键词搜索存在以下结构性缺陷:

痛点 具体表现 业务影响
认知门槛高 用户需知道品牌、型号、品类名称才能发起搜索 流失"只会看图、不会描述"的用户
表述偏差大 用户描述与商品标题/标签表述不一致时,返回零结果或大量噪声 搜索命中率低,用户满意度下降
分类依赖强 用户需手动点选品类、筛选条件,操作路径长 移动端体验差,跳出率高

1.3 AI 识图搜索能带来什么

AI 识图搜索将交互方式从「输入关键词」升级为「上传图片」,由视觉大模型理解画面内容,再自动映射到业务系统的分类体系与检索条件,在真实业务数据库中返回可点击、可交易、可管理的结果列表。

一句话总结:把「搜像素的困难」转交给 AI,把「搜业务的准确性」交给检索引擎与数据层。

1.4 适用场景

本方案适用于但不限于以下场景:

  • 二手 / 闲置交易平台:用户拍旧物照片,AI 识别后在平台内匹配相似商品

  • 综合电商与垂直类目商城:降低搜索门槛,提升长尾商品曝光率

  • 企业内部物资、样品、备件查询:仓库管理员拍照即可查库存

  • 图库、素材站、版权内容检索:设计师上传参考图,匹配素材库

  • 本地生活、同城便民信息匹配:拍照找附近的餐厅、商铺、服务

核心命题不是「聊天识图」,而是 「看图 → AI 大模型理解 → 可检索 → 可落地」 的完整链路。


二、行业现状与技术选型

2.1 从关键词搜索到多模态搜索的演进

搜索引擎的发展大致经历了三个阶段:

阶段 代表技术 交互方式 局限
1.0 关键词匹配 SQL LIKE、Elasticsearch BM25 输入文字关键词 依赖用户准确描述
2.0 语义搜索 BERT、Sentence Embedding 输入自然语言句子 仍局限于文本模态
2.5 以图搜图 CNN 特征向量 + 向量相似度 上传图片 只能找"视觉相似"的图片,缺乏语义理解
3.0 多模态语义搜索 视觉大模型(VLM)+ 结构化检索 上传图片 → 语义理解 → 结构化搜索 本篇重点

2.2 为什么不直接用以图搜图?

以图搜图(如 ResNet + Faiss)做的是像素级别的相似度匹配:你上传一张红色运动鞋的照片,它返回一堆红色的鞋子图片。但它不知道你上传的是一双"Nike Air Max 90 红白配色 42码 九成新"——它只看像素,不做语义理解。

而视觉大模型(VLM)能做到:

  • 识别物品品类(运动鞋 → 鞋类)

  • 提取属性信息(品牌、颜色、成色、尺码)

  • 生成结构化检索词("Nike", "Air Max 90", "红色", "42码")

  • 与业务分类体系对齐(映射到平台类目 ID)

2.3 为什么选择阿里云百炼?

在多模态大模型选型时,我们对比了以下方案:

方案 优势 劣势 结论
阿里云百炼(Qwen-VL) 国内合规、中文理解强、API 稳定、按量付费 需阿里云账号 ✅ 首选
OpenAI GPT-4V 视觉理解能力顶尖 网络限制、中文场景不如百炼、数据出境风险 ❌ 国内生产环境不推荐
本地部署(LLaVA 等) 数据不出域、零 API 成本 GPU 资源消耗大、部署运维复杂、效果弱于云端 ❌ 中小企业不划算
百度文心 / 讯飞星火 国内合规 多模态能力弱于百炼、API 生态不如阿里云 ⚠️ 备选

最终选择阿里云百炼的理由:

  1. Qwen-VL 系列(如 qwen-vl-max、qwen3.6-flash)在中文场景的视觉理解评测中名列前茅

  2. DashScope API 提供统一的调用入口,SDK 成熟,社区活跃

  3. 按量付费模式灵活,适合从 MVP 到规模化全阶段

  4. 数据安全:国内部署,符合等保和数据合规要求

2.4 为什么选 Spring Boot + Vue 3 架构?

考量维度 Spring Boot + Vue 3 其他方案 优势
开发效率 MyBatis-Plus 代码生成器 + Element Plus UI 组件库 Django + React / Node.js + Next.js 国内最主流,生态最完善
学习成本 Java + Vue 是国内高校/培训机构主力技术栈 Go / Python / Node 团队招聘和交接成本低
部署运维 JAR 包一键部署,Nginx 反向代理 Docker 化部署 传统运维人员友好
社区生态 组件库(Element Plus)、工具链(MyBatis-Plus)成熟 各有优劣 问题排查资源丰富

三、核心架构设计

3.1 整体技术链路

本方案采用 "视觉理解"与"库存检索"分层架构,严格分离 AI 理解与业务检索:

客户端(Web / App / 小程序)
  选择图片 → 预览 → 提交 multipart
        ↓
API 网关 / 业务服务
  校验 → Base64/DataURL → 调用多模态 API
        ↓
视觉大模型(阿里云百炼 Qwen-VL)
  返回 JSON:名称、类目、关键词、摘要
        ↓
检索层(SQL LIKE / Elasticsearch / 向量库)
  映射类目 ID + 文本条件 + 业务状态过滤
        ↓
统一分页结构 → 卡片列表 → 详情 / 下单 / 工单

3.2 架构分层详解

职责 关键技术 产出
客户端层 图片上传、预览、结果展示 Vue 3 + Element Plus + Axios 用户交互界面
API 网关层 请求校验、文件接收、Base64 编码 Spring Boot + MultipartFile 标准化请求数据
AI 理解层 图像 → 文本的语义转换 阿里云百炼 DashScope API 结构化 JSON(itemName, categoryName, keywords, summary)
检索层 JSON → SQL 查询条件 MyBatis-Plus LambdaQueryWrapper 数据库查询结果
数据层 存储商品/物料结构化数据 MySQL / PostgreSQL 业务数据

3.3 为什么不做"端到端黑盒"?

业界常见误区是把大模型当成"直接生成商品列表"的黑盒——给一张图,让模型直接返回"这里有这些商品、价格分别是多少"。这会导致严重问题:

  • 幻觉问题:模型可能编造不存在的商品、价格、库存

  • 不可审计:结果无法追溯到数据库中的真实记录

  • 无法交易:用户看到的"价格"是模型生成的,与实际订单系统脱节

本方案中,AI 只负责"看懂图"并输出结构化检索条件,数据库才是结果的唯一来源。每条返回结果都对应库表中的一条真实记录,可下单、可追溯、可统计。

3.4 数据流详解

以用户上传一张"机械键盘照片"为例,完整数据流如下:

1. 用户上传机械键盘照片
   → POST /api/ai-image/search (multipart/form-data, field: "file")
​
2. 后端接收 MultipartFile
   → 校验文件类型(image/jpeg, image/png 等)
   → 校验文件大小(建议 ≤ 10MB)
   → 将文件转为 Base64 字符串
​
3. 构建大模型请求
   → System Prompt: "你是一个商品识别助手..."
   → User Message: [text: "识别图中商品"] + [image: base64...]
   → POST https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions
​
4. 大模型返回 JSON
   {
     "itemName": "机械键盘",
     "categoryName": "电脑外设",
     "keywords": ["机械键盘", "RGB背光", "键帽", "Cherry轴"],
     "summary": "识别到一款带有RGB背光的机械键盘,疑似Cherry轴体..."
   }
​
5. 后端解析 JSON
   → 提取 keywords 数组
   → 查询 category 表,将 "电脑外设" 映射为 category_id = 5
   → 构建 SQL: SELECT * FROM goods WHERE category_id = 5 AND (title LIKE '%机械键盘%' OR description LIKE '%RGB%' OR ...)
​
6. 返回分页结果
   → 封装为 Result<Page<Goods>> 返回前端
   → 前端渲染商品卡片列表

四、环境准备与依赖安装

4.1 基础环境要求

软件 版本要求 用途 下载地址
JDK 17+ (推荐 21 LTS) Spring Boot 3.x 运行环境 Home | Adoptium
Maven 3.8+ 项目构建与依赖管理 Welcome to Apache Maven – Maven
Node.js 18+ (推荐 20 LTS) Vue 3 前端开发环境 Node.js — Run JavaScript Everywhere
MySQL 8.0+ 业务数据库 https://dev.mysql.com/downloads/
IDEA / VS Code 最新版 IDE 开发工具 JetBrains / Microsoft

4.2 后端依赖(pom.xml 关键依赖)

以下是本项目涉及的核心 Maven 依赖及说明:

<!-- Spring Boot 父工程(3.x 版本) -->
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.2.0</version>
</parent>
​
<dependencies>
    <!-- Spring Boot Web Starter:提供 REST API 能力 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
​
    <!-- MyBatis-Plus:增强版 ORM,提供 LambdaQueryWrapper 等便捷 API -->
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <version>3.5.5</version>
    </dependency>
​
    <!-- MySQL 驱动 -->
    <dependency>
        <groupId>com.mysql</groupId>
        <artifactId>mysql-connector-j</artifactId>
        <scope>runtime</scope>
    </dependency>
​
    <!-- Jackson:JSON 解析,Spring Boot 自带,无需额外引入 -->
    <!-- RestTemplate:HTTP 客户端,Spring Boot Web Starter 自带 -->
​
    <!-- Lombok:简化 Getter/Setter/日志代码(可选) -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

4.3 前端依赖(package.json 关键依赖)

{
  "dependencies": {
    "vue": "^3.4.0",
    "vue-router": "^4.2.0",
    "element-plus": "^2.4.0",
    "@element-plus/icons-vue": "^2.3.0",
    "axios": "^1.6.0",
    "pinia": "^2.1.0"
  }
}
依赖 版本 用途
vue 3.4+ 前端框架,Composition API
vue-router 4.2+ 前端路由管理
element-plus 2.4+ UI 组件库,提供 el-button、el-alert 等
@element-plus/icons-vue 2.3+ Element Plus 图标库
axios 1.6+ HTTP 请求库,用于前后端通信
pinia 2.1+ Vue 3 官方状态管理(可选,简单页面可不引入)

4.4 项目初始化步骤

后端初始化

# 方式一:使用 Spring Initializr 创建项目
# 访问 https://start.spring.io/
# 选择:Maven、Java 21、Spring Boot 3.2.0
# 添加依赖:Spring Web、MySQL Driver、MyBatis-Plus
​
# 方式二:直接克隆配套项目
git clone <项目仓库地址>
cd secondhand-mall-base/springboot
mvn clean install -DskipTests

前端初始化

# 进入前端目录
cd secondhand-mall-base/vue
​
# 安装依赖
npm install
​
# 启动开发服务器(默认 http://localhost:5173)
npm run dev

五、阿里云百炼 API 申请与配置

5.1 开通百炼服务

步骤一:登录阿里云控制台

访问 阿里云百炼控制台,使用阿里云账号登录。如果没有账号,需要先注册并完成实名认证。

步骤二:开通 DashScope 服务

进入百炼控制台后,在左侧导航栏找到「模型广场」,搜索 "Qwen-VL" 或 "通义千问视觉"。点击进入模型详情页,按提示开通服务。

注意:百炼采用按量付费模式,开通后会获得一定的免费额度(通常为 100 万 Token),足够开发测试使用。正式上线前需要充值或绑定企业账户。

步骤三:创建 API Key

在控制台右上角点击头像 → API-KEY 管理,进入 API Key 管理页面。

  1. 点击「创建 API Key」

  2. 填写 Key 名称(如 secondhand-mall-ai-search

  3. 系统生成一串类似 sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 的密钥

  4. 立即复制保存!关闭弹窗后将无法再次查看完整 Key

⚠️ 安全提示:API Key 等同于你的账户操作权限,切勿上传到 GitHub 或公开分享。建议使用环境变量或配置中心管理。

5.2 模型选型建议

百炼提供了多个 Qwen-VL 模型版本,按需选择:

模型 特点 建议场景 单价(参考)
qwen-vl-max 最强视觉理解能力,支持高分辨率图片 复杂场景、精细化识别 较高
qwen-vl-plus 均衡性价比,日常识别场景足够 生产环境推荐 中等
qwen3.6-flash 轻量模型,响应速度快 开发测试、低延迟要求 较低

本项目示例使用 qwen3.6-flash,兼顾速度与效果。如果对识别精度有更高要求,可直接替换为 qwen-vl-plusqwen-vl-max,代码无需改动。

5.3 测试 API 连通性

在正式开始编码之前,可以先通过 curl 命令测试 API 是否可用:

curl -X POST https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions \
  -H "Authorization: Bearer sk-你的API-KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "qwen3.6-flash",
    "messages": [
      {
        "role": "user",
        "content": [
          {"type": "text", "text": "这张图片里有什么?"},
          {"type": "image_url", "image_url": {"url": "https://example.com/test.jpg"}}
        ]
      }
    ]
  }'

如果返回正常的 JSON 响应(包含 choices[0].message.content),说明 API Key 和网络均正常。


六、后端开发详解

6.1 项目结构

src/main/java/com/base/
├── controller/
│   └── AIImageController.java      # AI 识图搜索控制器
├── entity/
│   ├── Goods.java                   # 商品实体
│   └── GoodsExt.java                # 商品扩展实体
├── mapper/
│   └── GoodsMapper.java             # 商品 Mapper
├── common/
│   └── Result.java                  # 统一返回结果封装
└── exception/
    └── BusinessException.java       # 业务异常类

6.2 商品实体类(Goods.java)

package com.base.entity;

import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;

/**
 * 商品实体类 —— AI 识图搜索的主要检索对象
 * 对应数据库 goods 表
 */
@Data
@TableName("goods")
public class Goods {
    /** 商品主键 ID */
    @TableId
    private Long id;

    /** 商品标题 —— AI 识图搜索的主要匹配字段之一 */
    private String title;

    /** 商品描述/详情 —— 辅助匹配字段 */
    private String description;

    /** 所属分类 ID —— 与 category 表关联,用于「分类+关键词」联合过滤 */
    private Integer categoryId;

    /** 商品价格 */
    private BigDecimal price;

    /** 商品主图 URL */
    private String imageUrl;

    /** 商品状态:1-在售 2-已售 3-下架 —— 检索时过滤非在售商品 */
    private Integer status;

    /** 卖家 ID */
    private Long sellerId;

    /** 创建时间 */
    private LocalDateTime createTime;

    /** 更新时间 */
    private LocalDateTime updateTime;
}

6.3 AI 识图控制器(AIImageController.java)—— 完整代码逐段解析

package com.base.controller;

// ======================== 1. 导入依赖 ========================
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.base.common.Result;
import com.base.entity.Goods;
import com.base.entity.GoodsExt;
import com.base.exception.BusinessException;
import com.base.mapper.GoodsMapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.multipart.MultipartFile;

import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

/**
 * AI 识图搜索控制器
 * 接口:POST /api/ai-image/search
 * 请求方式:multipart/form-data,字段名 "file"
 *
 * 核心流程:
 *   1. 接收前端上传的图片文件
 *   2. 将图片转为 Base64 编码
 *   3. 调用阿里云百炼视觉大模型(Qwen-VL)进行图像理解
 *   4. 解析大模型返回的 JSON,提取商品名称、类目、关键词
 *   5. 将关键词转为数据库查询条件(多关键词 OR 匹配 + 类目过滤)
 *   6. 分页返回命中的商品列表
 */
@RestController
@RequestMapping("/api/ai-image")
public class AIImageController {

    // ======================== 2. 成员变量 ========================

    private static final Logger log = LoggerFactory.getLogger(AIImageController.class);

    /** 阿里云 DashScope API 地址 */
    @Value("${dashscope.base-url}")
    private String dashscopeBaseUrl;

    /** 阿里云百炼 API Key —— 敏感信息,仅在服务端配置 */
    @Value("${dashscope.api-key}")
    private String apiKey;

    /** 视觉模型名称,可通过配置文件切换 */
    @Value("${dashscope.vision-model}")
    private String visionModel;

    private final GoodsMapper goodsMapper;
    private final RestTemplate restTemplate;
    private final ObjectMapper objectMapper;

    /**
     * 构造函数注入 —— Spring 推荐的依赖注入方式
     */
    public AIImageController(GoodsMapper goodsMapper,
                             RestTemplate restTemplate,
                             ObjectMapper objectMapper) {
        this.goodsMapper = goodsMapper;
        this.restTemplate = restTemplate;
        this.objectMapper = objectMapper;
    }

    // ======================== 3. 核心接口 ========================

    /**
     * AI 识图搜索入口
     *
     * @param file  前端上传的图片文件(multipart/form-data, field="file")
     * @param page  当前页码,默认 1
     * @param size  每页条数,默认 10
     * @return Result 统一返回格式,data 中包含分页结果
     */
    @PostMapping("/search")
    public Result<?> aiImageSearch(
            @RequestParam("file") MultipartFile file,
            @RequestParam(defaultValue = "1") Integer page,
            @RequestParam(defaultValue = "10") Integer size) {

        // ---- 3.1 入参校验 ----
        if (file.isEmpty()) {
            return Result.error("请选择一张图片");
        }

        // 校验文件类型:仅允许常见图片格式
        String contentType = file.getContentType();
        if (contentType == null || !contentType.startsWith("image/")) {
            return Result.error("仅支持上传图片文件(jpg、png、webp 等)");
        }

        // 校验文件大小:建议不超过 10MB(百炼 API 对图片 Base64 长度有上限)
        if (file.getSize() > 10 * 1024 * 1024) {
            return Result.error("图片文件大小不能超过 10MB");
        }

        try {
            // ---- 3.2 图片转 Base64 ----
            // 大模型 API 需要 Base64 或 URL 格式的图片
            byte[] imageBytes = file.getBytes();
            String base64Image = Base64.getEncoder().encodeToString(imageBytes);
            String dataUrl = "data:" + contentType + ";base64," + base64Image;

            // ---- 3.3 调用视觉大模型 ----
            String aiResponse = callVisionModel(dataUrl);

            // ---- 3.4 解析大模型返回 JSON ----
            JsonNode root = objectMapper.readTree(aiResponse);
            String itemName = extractField(root, "itemName");
            String categoryName = extractField(root, "categoryName");
            List<String> keywords = extractKeywordsArray(root);
            String summary = extractField(root, "summary");

            log.info("AI 识图结果 —— 商品: {}, 类目: {}, 关键词: {}", itemName, categoryName, keywords);

            // ---- 3.5 构建数据库查询条件 ----
            LambdaQueryWrapper<Goods> wrapper = new LambdaQueryWrapper<>();

            // 类目过滤:将 categoryName 映射为 categoryId
            // 实际项目中应查询 category 表做映射,这里简化处理
            // 如果类目名为空或无法映射,则跳过类目条件
            if (StringUtils.hasText(categoryName)) {
                Integer categoryId = mapCategoryNameToId(categoryName);
                if (categoryId != null) {
                    wrapper.eq(Goods::getCategoryId, categoryId);
                }
            }

            // 多关键词 OR 模糊匹配
            // 策略:任一个关键词命中 title 或 description 即纳入候选
            if (keywords != null && !keywords.isEmpty()) {
                wrapper.and(w -> {
                    for (String keyword : keywords) {
                        // 忽略过短的关键词(单字符易产生大量噪声)
                        if (keyword.length() >= 2) {
                            w.or().like(Goods::getTitle, keyword)
                             .or().like(Goods::getDescription, keyword);
                        }
                    }
                });
            }

            // 仅查询在售商品(业务状态过滤)
            wrapper.eq(Goods::getStatus, 1);

            // 按更新时间倒序排列
            wrapper.orderByDesc(Goods::getUpdateTime);

            // ---- 3.6 分页查询 ----
            Page<Goods> resultPage = goodsMapper.selectPage(new Page<>(page, size), wrapper);

            // ---- 3.7 构建响应 ----
            // 附加 AI 识别信息,供前端展示
            Map<String, Object> data = new LinkedHashMap<>();
            data.put("records", resultPage.getRecords());
            data.put("total", resultPage.getTotal());
            data.put("current", resultPage.getCurrent());
            data.put("size", resultPage.getSize());
            data.put("aiInfo", Map.of(
                "itemName", itemName != null ? itemName : "",
                "categoryName", categoryName != null ? categoryName : "",
                "keywords", keywords != null ? keywords : List.of(),
                "summary", summary != null ? summary : ""
            ));

            return Result.success(data);

        } catch (BusinessException e) {
            // 业务异常直接抛出
            return Result.error(e.getMessage());
        } catch (Exception e) {
            log.error("AI 识图搜索异常", e);
            return Result.error("识图搜索服务异常,请稍后重试");
        }
    }

    // ======================== 4. 辅助方法 ========================

    /**
     * 调用阿里云百炼视觉大模型
     *
     * 使用 OpenAI 兼容接口格式调用 DashScope API
     * 参考文档:https://help.aliyun.com/zh/model-studio/
     *
     * @param dataUrl 图片的 Data URL(data:image/jpeg;base64,...)
     * @return 大模型返回的 JSON 文本
     */
    private String callVisionModel(String dataUrl) {
        // 4.1 构建 System Prompt —— 关键!约束模型输出格式
        String systemPrompt = buildSystemPrompt();

        // 4.2 构建请求体(OpenAI 兼容格式)
        Map<String, Object> requestBody = new LinkedHashMap<>();
        requestBody.put("model", visionModel);

        // messages 数组
        List<Map<String, Object>> messages = new ArrayList<>();

        // System Message:定义模型角色和输出格式
        Map<String, Object> systemMsg = new LinkedHashMap<>();
        systemMsg.put("role", "system");
        systemMsg.put("content", systemPrompt);
        messages.add(systemMsg);

        // User Message:包含文本指令和图片
        Map<String, Object> userMsg = new LinkedHashMap<>();
        userMsg.put("role", "user");

        List<Map<String, Object>> userContent = new ArrayList<>();
        // 文本部分
        Map<String, Object> textPart = new LinkedHashMap<>();
        textPart.put("type", "text");
        textPart.put("text", "请识别这张图片中的商品,按照系统提示的 JSON 格式输出结果。");
        userContent.add(textPart);

        // 图片部分
        Map<String, Object> imagePart = new LinkedHashMap<>();
        imagePart.put("type", "image_url");
        imagePart.put("image_url", Map.of("url", dataUrl));
        userContent.add(imagePart);

        userMsg.put("content", userContent);
        messages.add(userMsg);

        requestBody.put("messages", messages);
        requestBody.put("temperature", 0.1);  // 低温度,提高输出稳定性

        // 4.3 构建 HTTP Headers
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        headers.set("Authorization", "Bearer " + apiKey);

        HttpEntity<Map<String, Object>> entity = new HttpEntity<>(requestBody, headers);

        // 4.4 发送请求
        String url = dashscopeBaseUrl + "/compatible-mode/v1/chat/completions";
        ResponseEntity<String> response = restTemplate.exchange(
                url, HttpMethod.POST, entity, String.class);

        if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
            throw new BusinessException("调用视觉大模型失败,状态码: " + response.getStatusCode());
        }

        // 4.5 解析响应,提取 content
        try {
            JsonNode responseJson = objectMapper.readTree(response.getBody());
            JsonNode choices = responseJson.get("choices");
            if (choices == null || !choices.isArray() || choices.size() == 0) {
                throw new BusinessException("大模型返回结果为空");
            }
            String content = choices.get(0).get("message").get("content").asText();
            // 提取 JSON 块(可能包含在 ```json ... ``` 中)
            return extractJsonBlock(content);
        } catch (Exception e) {
            log.error("解析大模型响应失败: {}", response.getBody(), e);
            throw new BusinessException("解析AI识别结果失败");
        }
    }

    /**
     * 构建 System Prompt
     *
     * 核心策略:通过 Prompt 工程约束模型的输出格式和内容边界,
     * 确保输出可直接被程序解析并使用。
     */
    private String buildSystemPrompt() {
        return """
            你是一个商品识别助手。用户会上传商品图片,请识别图片中的商品。

            **必须严格按照以下 JSON 格式输出(不要输出其他内容):**
            {
              "itemName": "商品主体名称",
              "categoryName": "所属分类(如:手机数码、电脑办公、服饰鞋包、家居生活、图书教材、运动户外、美妆个护、食品饮料、母婴用品、其他)",
              "keywords": ["关键词1", "关键词2", "关键词3"],
              "summary": "一段简短的识别描述(50字以内)"
            }

            **重要约束:**
            1. categoryName 必须从上面列举的分类中选择,不要自创分类
            2. keywords 提取 3-5 个最关键的检索词(品牌、型号、材质、颜色等)
            3. summary 用通俗的中文描述,让普通用户能看懂
            4. 只输出 JSON,不要有任何额外的解释文字
            """;
    }

    /**
     * 从模型返回的文本中提取 JSON 块
     * 处理可能包裹在 ```json ... ``` 中的情况
     */
    private String extractJsonBlock(String content) {
        // 尝试匹配 ```json ... ``` 或 ``` ... ```
        Pattern pattern = Pattern.compile("```(?:json)?\\s*\\n?([\\s\\S]*?)\\n?```");
        Matcher matcher = pattern.matcher(content);
        if (matcher.find()) {
            return matcher.group(1).trim();
        }
        // 如果没有代码块标记,直接返回原始内容
        return content.trim();
    }

    /**
     * 从 JSON 节点中安全提取字符串字段
     */
    private String extractField(JsonNode root, String fieldName) {
        JsonNode node = root.get(fieldName);
        return (node != null && !node.isNull()) ? node.asText() : null;
    }

    /**
     * 从 JSON 节点中提取 keywords 数组
     */
    private List<String> extractKeywordsArray(JsonNode root) {
        JsonNode keywordsNode = root.get("keywords");
        if (keywordsNode == null || !keywordsNode.isArray()) {
            return new ArrayList<>();
        }
        List<String> keywords = new ArrayList<>();
        for (JsonNode node : keywordsNode) {
            String kw = node.asText().trim();
            if (!kw.isEmpty()) {
                keywords.add(kw);
            }
        }
        return keywords;
    }

    /**
     * 将类目名称映射为类目 ID
     *
     * 实际项目中建议从 category 表查询,这里做简化映射
     * 也可以使用 Map 常量或枚举来实现
     */
    private Integer mapCategoryNameToId(String categoryName) {
        Map<String, Integer> categoryMap = Map.ofEntries(
            Map.entry("手机数码", 1),
            Map.entry("电脑办公", 2),
            Map.entry("服饰鞋包", 3),
            Map.entry("家居生活", 4),
            Map.entry("图书教材", 5),
            Map.entry("运动户外", 6),
            Map.entry("美妆个护", 7),
            Map.entry("食品饮料", 8),
            Map.entry("母婴用品", 9),
            Map.entry("其他", 10)
        );
        return categoryMap.getOrDefault(categoryName, null);
    }

    // ======================== 5. 配置 RestTemplate Bean ========================
    // 在 @Configuration 类中添加以下 Bean(也可以放在主启动类中)

    /**
     * 在项目的配置类(如 WebConfig.java)中注册 RestTemplate Bean:
     *
     * @Bean
     * public RestTemplate restTemplate() {
     *     return new RestTemplate();
     * }
     */
}

6.4 统一返回结果封装(Result.java)

package com.base.common;

import lombok.Data;

/**
 * 统一响应体
 * 所有 API 接口统一使用此类封装返回结果
 */
@Data
public class Result<T> {
    /** 响应码:200-成功,其他-失败 */
    private int code;

    /** 响应消息 */
    private String message;

    /** 响应数据 */
    private T data;

    /** 成功返回(带数据) */
    public static <T> Result<T> success(T data) {
        Result<T> result = new Result<>();
        result.setCode(200);
        result.setMessage("success");
        result.setData(data);
        return result;
    }

    /** 成功返回(无数据) */
    public static <T> Result<T> success() {
        return success(null);
    }

    /** 失败返回 */
    public static <T> Result<T> error(String message) {
        Result<T> result = new Result<>();
        result.setCode(500);
        result.setMessage(message);
        return result;
    }

    /** 自定义状态码返回 */
    public static <T> Result<T> of(int code, String message, T data) {
        Result<T> result = new Result<>();
        result.setCode(code);
        result.setMessage(message);
        result.setData(data);
        return result;
    }
}

七、前端开发详解

7.1 项目结构

src/
├── views/
│   └── user/
│       └── AiImageSearch.vue       # AI 识图搜索页面(单文件组件)
├── router/
│   └── index.js                    # 路由配置
├── api/
│   └── aiImage.js                  # AI 识图 API 封装(可选)
└── components/
    └── layout/
        └── FrontNav.vue            # 前端导航组件(含菜单入口)

7.2 AI 识图搜索页面(AiImageSearch.vue)—— 完整代码逐段解析

<!--
  AI 识图搜索页(单文件版)
  路由:/user/ai-search
  接口:POST /api/ai-image/search,字段 file

  核心功能:
  1. 用户点击「选择图片」按钮,从本地选择图片
  2. 选中后显示本地预览
  3. 点击「开始识图」,将图片上传到后端
  4. 后端调用大模型识别 → 数据库检索 → 返回结果
  5. 前端展示识别摘要 + 搜索结果商品列表
-->
<template>
  <div class="ai-page">
    <!-- ============ 7.2.1 页头标题区 ============ -->
    <div class="ai-page__header">
      <h2 class="ai-page__title">AI 识图搜索</h2>
      <p class="ai-page__desc">
        上传一张商品图片,AI 将自动识别商品信息并在商品库中为您匹配相似商品
      </p>
    </div>

    <!-- ============ 7.2.2 上传操作区 ============ -->
    <div class="ai-upload-bar">
      <!--
        隐藏的原生文件选择框
        设置 accept="image/*" 限制只能选择图片文件
        通过 ref 获取 DOM 引用,在 pickImage 方法中触发点击
      -->
      <input
        ref="fileInputRef"
        type="file"
        accept="image/*"
        class="ai-upload-bar__input"
        @change="onFileChange"
      />

      <!-- 选择图片按钮:点击后间接触发隐藏的 input -->
      <el-button type="primary" :loading="loading" @click="pickImage">
        <el-icon><Picture /></el-icon>
        {{ previewUrl ? '重新选择' : '选择图片' }}
      </el-button>

      <!--
        开始识图按钮:
        - 有图才能点击(:disabled="!selectedFile")
        - loading 时显示加载动画
        - 点击后调用 doSearch 方法
      -->
      <el-button
        type="success"
        :loading="loading"
        :disabled="!selectedFile"
        @click="doSearch"
      >
        <el-icon><Search /></el-icon>
        开始识图
      </el-button>

      <!-- 清除选择按钮:重置当前选中的图片 -->
      <el-button
        v-if="selectedFile"
        :disabled="loading"
        @click="clearSelection"
      >
        清除
      </el-button>
    </div>

    <!-- ============ 7.2.3 本地预览区 ============ -->
    <!--
      选中图片后,在本地生成 Blob URL 进行预览
      此时图片尚未上传到服务器
    -->
    <div v-if="previewUrl" class="ai-preview-wrapper">
      <img :src="previewUrl" class="ai-preview" alt="预览图" />
    </div>

    <!-- ============ 7.2.4 AI 识别摘要区 ============ -->
    <!--
      大模型识别完成后,展示摘要信息:
      - AI 认为这是什么商品
      - 提取了哪些关键词
      - 匹配到哪个类目
    -->
    <div v-if="aiInfo" class="ai-summary">
      <el-alert
        title="AI 识别结果"
        type="info"
        show-icon
        :closable="false"
      >
        <template #default>
          <div class="ai-summary__content">
            <p><strong>识别商品:</strong>{{ aiInfo.itemName }}</p>
            <p><strong>匹配类目:</strong>{{ aiInfo.categoryName }}</p>
            <p><strong>检索关键词:</strong>
              <el-tag
                v-for="kw in aiInfo.keywords"
                :key="kw"
                size="small"
                class="ai-summary__tag"
              >
                {{ kw }}
              </el-tag>
            </p>
            <p><strong>识别摘要:</strong>{{ aiInfo.summary }}</p>
          </div>
        </template>
      </el-alert>
    </div>

    <!-- ============ 7.2.5 搜索结果统计 ============ -->
    <div v-if="total > 0" class="ai-result-meta">
      共找到 <strong>{{ total }}</strong> 件相关商品
    </div>

    <!-- 无结果提示 -->
    <el-empty
      v-if="searched && total === 0"
      description="未找到匹配的商品,请尝试更换图片或使用关键词搜索"
    />

    <!-- ============ 7.2.6 搜索结果列表 ============ -->
    <div v-if="records.length > 0" class="ai-result-grid">
      <div
        v-for="item in records"
        :key="item.id"
        class="ai-result-card"
        @click="goDetail(item.id)"
      >
        <!-- 商品图片 -->
        <div class="ai-result-card__image">
          <img :src="item.imageUrl" :alt="item.title" />
        </div>
        <!-- 商品信息 -->
        <div class="ai-result-card__info">
          <h4 class="ai-result-card__title">{{ item.title }}</h4>
          <p class="ai-result-card__price">
            <span class="price-symbol">¥</span>
            {{ item.price }}
          </p>
          <p class="ai-result-card__desc">{{ truncateText(item.description, 60) }}</p>
        </div>
      </div>
    </div>

    <!-- ============ 7.2.7 分页组件 ============ -->
    <div v-if="total > size" class="ai-page__pagination">
      <el-pagination
        v-model:current-page="currentPage"
        :page-size="size"
        :total="total"
        layout="prev, pager, next"
        @current-change="onPageChange"
      />
    </div>
  </div>
</template>

<script setup>
/**
 * AI 识图搜索页面 —— 逻辑部分
 *
 * 使用 Vue 3 Composition API(<script setup> 语法糖)
 * 相较于 Options API,代码更简洁,逻辑更清晰
 */
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Picture, Search } from '@element-plus/icons-vue'
import axios from 'axios'

// ==================== 1. 响应式状态 ====================

const router = useRouter()

/** 是否正在加载(上传 + AI 识别 + 搜索) */
const loading = ref(false)

/** 是否已经执行过搜索(用于控制空状态显示) */
const searched = ref(false)

/** 隐藏的 file input DOM 引用 */
const fileInputRef = ref(null)

/** 用户选择的文件对象 */
const selectedFile = ref(null)

/** 本地预览的 Blob URL */
const previewUrl = ref('')

/** AI 识别信息(后端返回的 aiInfo 字段) */
const aiInfo = ref(null)

/** 搜索结果商品列表 */
const records = ref([])

/** 搜索结果总数 */
const total = ref(0)

/** 当前页码 */
const currentPage = ref(1)

/** 每页条数 */
const size = ref(10)

// ==================== 2. 方法 ====================

/**
 * 触发文件选择对话框
 * 通过 ref 拿到隐藏的 input 元素,调用其 click() 方法
 */
function pickImage() {
  fileInputRef.value?.click()
}

/**
 * 文件选择变化回调
 * 当用户在文件选择框中选中(或重新选中)图片时触发
 *
 * @param {Event} event - input change 事件
 */
function onFileChange(event) {
  const file = event.target.files?.[0]
  if (!file) return

  // 前端二次校验:仅允许图片类型
  if (!file.type.startsWith('image/')) {
    ElMessage.warning('请选择图片文件(jpg、png、webp 等)')
    return
  }

  // 前端二次校验:大小不超过 10MB
  if (file.size > 10 * 1024 * 1024) {
    ElMessage.warning('图片文件不能超过 10MB')
    return
  }

  // 保存文件对象
  selectedFile.value = file

  // 生成本地预览 URL
  // 使用 URL.createObjectURL 创建 Blob URL,注意在组件卸载时释放
  if (previewUrl.value) {
    URL.revokeObjectURL(previewUrl.value)
  }
  previewUrl.value = URL.createObjectURL(file)

  // 清除之前的搜索结果和 AI 信息
  aiInfo.value = null
  records.value = []
  total.value = 0
  searched.value = false
}

/**
 * 发起识图搜索
 * 核心流程:构建 FormData → 发送 POST 请求 → 处理响应
 */
async function doSearch() {
  if (!selectedFile.value) {
    ElMessage.warning('请先选择一张图片')
    return
  }

  loading.value = true
  searched.value = true

  try {
    // 构建 multipart/form-data 请求体
    const formData = new FormData()
    formData.append('file', selectedFile.value)
    formData.append('page', currentPage.value.toString())
    formData.append('size', size.value.toString())

    // 发送 POST 请求
    // 注意:axios 会自动识别 FormData 并设置 Content-Type 为 multipart/form-data
    const response = await axios.post('/api/ai-image/search', formData)

    if (response.data.code === 200) {
      const data = response.data.data

      // 更新 AI 识别信息
      aiInfo.value = data.aiInfo || null

      // 更新搜索结果
      records.value = data.records || []
      total.value = data.total || 0
      currentPage.value = data.current || 1

      if (total.value > 0) {
        ElMessage.success(`AI 识图完成,找到 ${total.value} 件相关商品`)
      } else {
        ElMessage.info('AI 已识别图片内容,但未在商品库中找到匹配结果')
      }
    } else {
      ElMessage.error(response.data.message || '识图搜索失败')
    }
  } catch (error) {
    console.error('AI 识图搜索请求异常:', error)
    if (error.response) {
      // 服务器返回了错误响应
      ElMessage.error(error.response.data?.message || '服务器异常,请稍后重试')
    } else if (error.request) {
      // 请求已发出但未收到响应(网络问题)
      ElMessage.error('网络连接异常,请检查网络后重试')
    } else {
      ElMessage.error('请求发送失败,请稍后重试')
    }
  } finally {
    loading.value = false
  }
}

/**
 * 清除当前选择
 */
function clearSelection() {
  selectedFile.value = null
  if (previewUrl.value) {
    URL.revokeObjectURL(previewUrl.value)
  }
  previewUrl.value = ''
  aiInfo.value = null
  records.value = []
  total.value = 0
  searched.value = false
  // 重置 file input,以便再次选择同一文件时能触发 change 事件
  if (fileInputRef.value) {
    fileInputRef.value.value = ''
  }
}

/**
 * 翻页回调
 */
function onPageChange(page) {
  currentPage.value = page
  // 翻页时重新发起搜索(携带新的页码参数)
  doSearch()
}

/**
 * 跳转到商品详情页
 * @param {number} id - 商品 ID
 */
function goDetail(id) {
  router.push(`/goods/detail/${id}`)
}

/**
 * 截断文本(用于商品描述预览)
 * @param {string} text - 原始文本
 * @param {number} maxLen - 最大长度
 * @returns {string} 截断后的文本
 */
function truncateText(text, maxLen) {
  if (!text) return ''
  return text.length > maxLen ? text.substring(0, maxLen) + '...' : text
}
</script>

<style scoped>
/* ==================== 样式 ==================== */

.ai-page {
  max-width: 1200px;
  margin: 0 auto;
  padding: 24px;
}

.ai-page__header {
  text-align: center;
  margin-bottom: 32px;
}

.ai-page__title {
  font-size: 28px;
  font-weight: 700;
  color: #303133;
  margin-bottom: 12px;
}

.ai-page__desc {
  font-size: 14px;
  color: #909399;
}

/* 上传操作区 */
.ai-upload-bar {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-bottom: 20px;
  flex-wrap: wrap;
}

.ai-upload-bar__input {
  display: none;  /* 隐藏原生文件选择框 */
}

/* 图片预览 */
.ai-preview-wrapper {
  text-align: center;
  margin-bottom: 24px;
}

.ai-preview {
  max-width: 400px;
  max-height: 400px;
  border-radius: 8px;
  border: 1px solid #e4e7ed;
  object-fit: contain;
}

/* AI 识别摘要 */
.ai-summary {
  margin-bottom: 24px;
}

.ai-summary__content p {
  margin: 8px 0;
  font-size: 14px;
  color: #606266;
}

.ai-summary__tag {
  margin-right: 6px;
  margin-bottom: 4px;
}

/* 搜索结果统计 */
.ai-result-meta {
  font-size: 14px;
  color: #606266;
  margin-bottom: 16px;
}

.ai-result-meta strong {
  color: #409EFF;
  font-size: 16px;
}

/* 搜索结果网格 */
.ai-result-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
  gap: 16px;
  margin-bottom: 24px;
}

.ai-result-card {
  border: 1px solid #ebeef5;
  border-radius: 8px;
  overflow: hidden;
  cursor: pointer;
  transition: box-shadow 0.3s, transform 0.3s;
  background: #fff;
}

.ai-result-card:hover {
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
  transform: translateY(-2px);
}

.ai-result-card__image {
  width: 100%;
  height: 180px;
  overflow: hidden;
  background: #f5f7fa;
}

.ai-result-card__image img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.ai-result-card__info {
  padding: 12px;
}

.ai-result-card__title {
  font-size: 14px;
  font-weight: 600;
  color: #303133;
  margin-bottom: 8px;
  /* 最多显示两行 */
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.ai-result-card__price {
  font-size: 18px;
  font-weight: 700;
  color: #f56c6c;
  margin-bottom: 6px;
}

.price-symbol {
  font-size: 14px;
}

.ai-result-card__desc {
  font-size: 12px;
  color: #909399;
  line-height: 1.4;
}

/* 分页 */
.ai-page__pagination {
  display: flex;
  justify-content: center;
  padding: 20px 0;
}
</style>

7.3 路由配置(router/index.js)

// 在路由配置文件中添加 AI 识图搜索路由
{
  path: 'ai-search',
  name: 'AiImageSearch',
  // 使用路由懒加载,减小首屏加载体积
  component: () => import('@/views/user/AiImageSearch.vue'),
  meta: {
    front: true,        // 标记为前端页面(需要用户登录)
    title: 'AI 识图'    // 页面标题
  }
}

路由注册要点

  • 懒加载:使用 () => import(...) 语法,Vite 会将此页面打包为独立的 chunk,用户访问时才加载,减小首屏体积

  • meta.front:标记为需要登录的前端页面,在全局路由守卫中判断

  • meta.title:设置页面标题,可在路由守卫中通过 document.title 动态设置

7.4 前端导航菜单入口

<!-- 在导航组件中添加 AI 识图入口 -->
<router-link
  to="/user/ai-search"
  class="front-nav__item"
  :class="{ 'is-active': activeMenu === '/user/ai-search' }"
>
  <el-icon><Camera /></el-icon>
  AI识图
</router-link>

导航入口设计要点

  • 使用 <router-link> 而非 <a> 标签,保持 SPA 导航体验(不刷新页面)

  • 通过 :class="{ 'is-active': ... }" 动态绑定高亮样式

  • 使用 Element Plus 的 <Camera /> 图标,直观表达"拍照/识图"功能

  • 放在用户导航栏而非管理后台,因为这是面向终端用户的功能

7.5 AI 识图 API 封装(可选,api/aiImage.js)

/**
 * AI 识图搜索 API 封装
 * 将接口调用逻辑从组件中抽离,便于维护和复用
 */
import axios from 'axios'

/**
 * AI 识图搜索
 * @param {File} file - 用户选择的图片文件
 * @param {number} page - 页码(默认 1)
 * @param {number} size - 每页条数(默认 10)
 * @returns {Promise} 搜索结果
 */
export function searchByImage(file, page = 1, size = 10) {
  const formData = new FormData()
  formData.append('file', file)
  formData.append('page', page.toString())
  formData.append('size', size.toString())

  return axios.post('/api/ai-image/search', formData)
}

八、配置文件详解

8.1 后端配置文件(application.yml)

# ==================== 服务器配置 ====================
server:
  port: 8080                      # 服务端口

# ==================== Spring 配置 ====================
spring:
  application:
    name: secondhand-mall         # 应用名称

  # 数据源配置
  datasource:
    url: jdbc:mysql://localhost:3306/secondhand_mall?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
    username: root
    password: your_password
    driver-class-name: com.mysql.cj.jdbc.Driver

  # 文件上传限制
  servlet:
    multipart:
      max-file-size: 10MB         # 单个文件最大 10MB
      max-request-size: 20MB      # 单次请求最大 20MB

# ==================== MyBatis-Plus 配置 ====================
mybatis-plus:
  configuration:
    map-underscore-to-camel-case: true   # 下划线转驼峰命名
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl  # 开发环境打印 SQL
  global-config:
    db-config:
      id-type: auto               # 主键自增

# ==================== 阿里云百炼 DashScope 配置 ====================
# 申请 API Key: https://bailian.console.aliyun.com/cn-beijing?tab=model#/api-key
dashscope:
  api-key: 'sk-your-api-key-here'       # ⚠️ 生产环境建议使用环境变量或配置中心
  base-url: https://dashscope.aliyuncs.com
  vision-model: qwen3.6-flash           # 可选: qwen-vl-plus, qwen-vl-max

# ==================== 日志配置 ====================
logging:
  level:
    com.base: DEBUG               # 项目包日志级别
    org.springframework.web: INFO # Spring Web 日志级别

8.2 配置项详解

配置项 说明 注意事项
server.port 服务端口 确保端口未被占用
spring.datasource.url 数据库连接 serverTimezone=Asia/Shanghai 解决时区问题
spring.servlet.multipart.max-file-size 单文件大小限制 需与前端校验保持一致
dashscope.api-key 百炼 API Key 严禁提交到公开仓库,建议用 ${DASHSCOPE_API_KEY} 环境变量
dashscope.vision-model 视觉模型名称 qwen3.6-flash 适合开发测试,生产建议 qwen-vl-plus
mybatis-plus.configuration.log-impl SQL 日志 生产环境务必关闭,避免日志膨胀

8.3 环境变量最佳实践

开发环境:在 application-dev.yml 中配置测试 API Key。

生产环境:通过环境变量注入敏感信息:

# Linux / macOS
export DASHSCOPE_API_KEY="sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

# Windows PowerShell
$env:DASHSCOPE_API_KEY="sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

# 启动命令
java -jar app.jar --dashscope.api-key=$env:DASHSCOPE_API_KEY

对应修改 application.yml

dashscope:
  api-key: ${DASHSCOPE_API_KEY:}  # 从环境变量读取,未设置时为空(识图功能降级)
  base-url: https://dashscope.aliyuncs.com
  vision-model: qwen3.6-flash

九、核心亮点深度解析

亮点 1:多模态理解 + 业务检索闭环

传统的关键词搜索只能理解文本,而本方案接入支持 图像 + 文本 输入的视觉大模型(阿里云百炼 Qwen-VL 系列),对上传图片进行物体识别、场景理解与属性抽取。

输出不是自然语言闲聊,而是结构化 JSON

{
  "itemName": "机械键盘",
  "categoryName": "电脑办公",
  "keywords": ["机械键盘", "RGB背光", "Cherry轴", "键帽"],
  "summary": "识别到一款带有RGB背光的机械键盘,疑似搭载Cherry轴体,键帽为PBT材质"
}

随后由服务端将上述字段转为数据库查询条件,返回业务库中的真实记录。AI 负责「看懂图」,系统负责「找对货」——职责清晰,便于扩展与运维。

亮点 2:「视觉理解」与「库存检索」分层架构

业界常见误区是把大模型当成"直接生成商品列表"的黑盒。这种做法在实际生产中存在严重问题:

问题 说明
幻觉 模型可能编造不存在的商品、错误的价格、虚构的卖家
不可溯源 结果没有对应的数据库主键,无法点击下单
不可控 模型输出不稳定,同一张图两次可能返回不同结果

本方案采用语义检索架构:

  • 结果可审计:每条记录对应库表主键,可追溯

  • 流程可解释:用户可见「AI 识别了什么、检索了哪些关键词」

  • 架构可替换:模型可换(百炼 → GPT-4V),检索规则可换,数据层不变

亮点 3:多关键词 OR + 分级兜底,提升命中率

视觉模型给出的描述往往比卖家标题更"学术"或更"细碎"。例如:

来源 表述
AI 识别 "机械键盘、键帽、RGB 背光、樱桃轴"
卖家标题 "Keychron K2 茶轴 九成新 包邮"

如果拼成整句做模糊匹配,大概率零结果。本方案的优化策略包括:

  1. 关键词拆分 OR:任一词命中标题或描述即纳入候选

  2. 分类 + 关键词联合过滤:先收窄类目,再文本匹配,大幅减少噪声

  3. 分级放宽策略:精准匹配 → 单词匹配 → 同类目浏览 → 全库关键词

  4. 业务状态过滤:仅展示「在售 / 已上架 / 审核通过」等有效状态

召回率相关性之间取得平衡,避免"认对了却搜不到"。

亮点 4:领域约束 Prompt,抑制分类幻觉

通过 Prompt 工程将模型输出约束在平台已有类目枚举内:

**categoryName 必须从下面列举的分类中选择:**
手机数码、电脑办公、服饰鞋包、家居生活、图书教材、
运动户外、美妆个护、食品饮料、母婴用品、其他

避免模型自创"智能家居""数码配件"等库中不存在的分类,保证后续 SQL 查询可执行。这是 RAG 之前的第一道闸门:用规则 + 结构化输出,把开放域视觉理解收束到封闭业务词典

亮点 5:安全部署与模块解耦

  • API Key 仅部署在服务端:前端只传图片,不暴露密钥,符合密钥管理规范

  • 独立识图 APIPOST /api/ai-image/search 与商品、订单、用户等模块解耦

  • 友好降级:未配置 API Key 时,识图接口明确报错,不影响其他业务模块

适合 SaaS 多租户、私有化交付、教学实训等多种部署形态。

亮点 6:轻量模块,易集成、易二次开发

本方案采用单页前端 + 单控制器后端的交付形态:

  • 前端:上传、预览、结果网格、识别摘要——一个 .vue 文件搞定

  • 后端:调用大模型、解析 JSON、查库、分页返回——一个 Controller 类搞定

接入方只需已有商品(或物料)表、类目表、登录与列表 UI,即可快速集成。后续可平滑演进为:向量 embedding 真·以图搜图、搜索日志分析、点击率排序、A/B 测试等,无需推翻初版架构。


十、测试与调优经验

10.1 本地测试流程

步骤一:启动后端服务

cd springboot
mvn spring-boot:run
# 或直接运行主启动类

确认服务启动成功后,访问 http://localhost:8080 验证。

步骤二:启动前端服务

cd vue
npm install
npm run dev

默认在 http://localhost:5173 启动 Vite 开发服务器。

步骤三:准备测试图片

建议准备以下类型的测试图片,覆盖不同识别难度:

图片类型 示例 测试重点
清晰单品图 白色背景的商品正面照 验证基础识别能力
复杂场景图 桌面上堆放的物品 验证主体抽取能力
多物品图 多件不同商品合影 验证是否能识别主要物品
低分辨率图 压缩过的模糊图片 验证鲁棒性
非标准角度 侧面、斜角拍摄 验证泛化能力

步骤四:使用 Postman 测试后端接口

POST http://localhost:8080/api/ai-image/search
Content-Type: multipart/form-data

file: (选择测试图片)
page: 1
size: 10

10.2 调优经验

Prompt 调优

最初的 Prompt 没有约束类目枚举,模型有时会输出"智能家居"等库中不存在的分类,导致类目过滤失效。通过在 System Prompt 中显式列举可用类目并强调"必须从中选择",彻底解决了这个问题。

关键词拆分策略

初期将所有关键词拼成一条 LIKE 语句(如 '%机械键盘 RGB 背光%'),导致命中率极低。改为每个关键词独立 OR 匹配后,召回率提升了 3 倍以上。

温度参数设置

temperature 设置为 0.1(接近 0),确保模型输出稳定、可解析。实际测试中,同一张图在 temperature=0.1 时输出基本一致,而在 temperature=0.8 时关键词差异较大。

10.3 效果评估指标

指标 说明 参考值
识别准确率 AI 识别的商品名称与实际商品一致的比例 > 85%
召回率 库中实际存在但被搜到的比例 > 70%(含分级兜底)
精确率 搜索结果中真正相关的比例 > 60%(允许模糊匹配)
平均响应时间 从上传图片到返回结果的总耗时 < 5 秒(含大模型调用)

十一、常见问题与排错指南

Q1:调用百炼 API 返回 401 错误

错误信息401 UnauthorizedInvalid API Key

原因

  • API Key 填写错误或已过期

  • Authorization Header 格式不正确(应为 Bearer sk-xxx

解决方案

  1. 检查 application.ymldashscope.api-key 是否正确

  2. 前往 百炼 API Key 管理页 确认 Key 状态

  3. 确认 API Key 前缀为 sk-,不要漏掉

Q2:大模型返回的结果不是标准 JSON

错误信息解析AI识别结果失败

原因

  • System Prompt 约束不够强,模型输出了额外的解释文字

  • temperature 参数过高,输出不稳定

解决方案

  1. 确认 System Prompt 中包含"只输出 JSON,不要有任何额外的解释文字"

  2. temperature 设置为 0.1 或更低

  3. 在后端添加 extractJsonBlock() 方法(已在上文提供),自动去除 json 包裹

Q3:搜索结果为空,但 AI 识别看起来是正确的

原因

  • 数据库中可能确实没有对应商品

  • 关键词拆分不够细

  • 类目映射错误

解决方案

  1. 检查数据库 goods 表是否有对应类目的商品

  2. 确认 mapCategoryNameToId() 方法的映射关系是否与数据库一致

  3. 建议从数据库 category 表动态查询映射,而非硬编码

  4. 开启分级兜底:类目不匹配时,降级为仅按关键词搜索

Q4:图片上传失败或超时

错误信息413 Payload Too Large 或请求超时

原因

  • 图片文件过大,超过 Spring Boot 默认上传限制(1MB)

  • 大模型处理大图片耗时过长

解决方案

  1. application.yml 中配置 spring.servlet.multipart.max-file-size: 10MB

  2. 前端在上传前对图片进行压缩(可使用 Canvas API)

  3. 建议前端限制图片大小不超过 10MB,并在选择文件时即时校验

Q5:前端跨域问题(CORS)

错误信息Access to XMLHttpRequest has been blocked by CORS policy

原因:前端开发服务器(localhost:5173)与后端(localhost:8080)端口不同

解决方案:在后端配置 CORS:

@Configuration
public class CorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOrigins("http://localhost:5173")
                .allowedMethods("GET", "POST", "PUT", "DELETE")
                .allowCredentials(true);
    }
}

或通过 Vite 开发服务器配置代理(推荐):

// vite.config.js
export default {
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true
      }
    }
  }
}

Q6:大模型调用耗时过长

原因:百炼 API 是远程调用,网络延迟 + 模型推理时间

解决方案

  1. 设置合理超时:RestTemplate 默认无超时,需显式设置:

@Bean
public RestTemplate restTemplate() {
    SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
    factory.setConnectTimeout(5000);   // 连接超时 5 秒
    factory.setReadTimeout(30000);     // 读取超时 30 秒
    return new RestTemplate(factory);
}
  1. 前端增加加载动画:让用户感知到正在进行中

  2. 异步处理:对于耗时较长的图片,可考虑异步 + 轮询模式

  3. 图片预处理:上传前在前端压缩图片至合理分辨率(1024px 以内)


十二、性能优化建议

12.1 图片压缩与预处理

大模型 API 的图片 Base64 编码后体积直接影响请求耗时和费用。建议在前端上传前进行压缩:

/**
 * 图片压缩函数(使用 Canvas API)
 * @param {File} file - 原始图片文件
 * @param {number} maxWidth - 最大宽度,默认 1024
 * @param {number} quality - 压缩质量 0-1,默认 0.8
 * @returns {Promise<File>} 压缩后的文件
 */
async function compressImage(file, maxWidth = 1024, quality = 0.8) {
  return new Promise((resolve, reject) => {
    const reader = new FileReader()
    reader.onload = (e) => {
      const img = new Image()
      img.onload = () => {
        const canvas = document.createElement('canvas')
        let width = img.width
        let height = img.height

        if (width > maxWidth) {
          height = (maxWidth / width) * height
          width = maxWidth
        }

        canvas.width = width
        canvas.height = height
        const ctx = canvas.getContext('2d')
        ctx.drawImage(img, 0, 0, width, height)

        canvas.toBlob(
          (blob) => resolve(new File([blob], file.name, { type: 'image/jpeg' })),
          'image/jpeg',
          quality
        )
      }
      img.onerror = reject
      img.src = e.target.result
    }
    reader.onerror = reject
    reader.readAsDataURL(file)
  })
}

12.2 数据库查询优化

为检索字段建立索引

-- 商品标题支持模糊搜索的前缀索引
ALTER TABLE goods ADD INDEX idx_title (title(100));

-- 商品描述全文索引(可选,数据库需要支持)
ALTER TABLE goods ADD FULLTEXT INDEX ft_description (description);

-- 类目查询索引
ALTER TABLE goods ADD INDEX idx_category_status (category_id, status);

避免全表扫描

  • 始终携带 category_id 过滤条件(如果有的话)

  • 限制单次查询返回数量(分页)

  • 避免 LIKE '%xxx%' 的前缀通配(可用全文索引代替)

12.3 大模型调用优化

优化手段 说明 效果
图片预处理 前端压缩至 1024px 以内 Base64 体积减少 60%+
temperature 降低 设为 0.1 减少重复请求概率
选择 Flash 模型 qwen3.6-flash 比 max 快 3-5 倍 响应时间大幅缩短
结果缓存 对相同图片的识别结果做缓存(MD5 去重) 大幅减少 API 调用
超时控制 RestTemplate 设置合理的连接和读取超时 避免请求阻塞

12.4 结果缓存方案

对相同图片的 AI 识别结果做缓存可以显著降低 API 调用成本:

/**
 * 简单的内存缓存策略(可替换为 Redis)
 * key: 图片 MD5
 * value: AI 识别 JSON
 */
@Component
public class AiImageCache {

    // 生产环境建议替换为 Redis Cache
    private final Map<String, CacheEntry> cache = new LinkedHashMap<>() {
        @Override
        protected boolean removeEldestEntry(Map.Entry eldest) {
            return size() > 100;  // 最多缓存 100 条
        }
    };

    /**
     * 获取缓存
     * @param imageMd5 图片的 MD5 值
     * @return 缓存的识别结果,不存在返回 null
     */
    public String get(String imageMd5) {
        CacheEntry entry = cache.get(imageMd5);
        if (entry != null && !entry.isExpired()) {
            return entry.getContent();
        }
        return null;
    }

    /**
     * 存入缓存(TTL 1 小时)
     */
    public void put(String imageMd5, String aiResponse) {
        cache.put(imageMd5, new CacheEntry(aiResponse, 3600_000L));
    }

    private static class CacheEntry {
        private final String content;
        private final long expireAt;

        CacheEntry(String content, long ttlMs) {
            this.content = content;
            this.expireAt = System.currentTimeMillis() + ttlMs;
        }

        boolean isExpired() {
            return System.currentTimeMillis() > expireAt;
        }

        String getContent() {
            return content;
        }
    }
}

十三、扩展方向与未来演进

13.1 真·以图搜图:向量 Embedding 方案

当前方案是"图片 → 文字 → 数据库检索",本质上是图文跨模态检索。更进一步可以做以图搜图:将图片和商品图片都转为向量,在向量空间中进行相似度搜索。

技术方案:

商品图片 → CLIP / 通义万相 Embedding → 向量存入 Milvus / Faiss
用户上传图片 → 同样 Embedding → 向量相似度搜索 → 返回 Top-K

13.2 接入 Elasticsearch 全文检索

当商品数量达到十万级以上时,MySQL LIKE 模糊搜索的性能会急剧下降。此时应迁移到 Elasticsearch:

// ES 查询示例:多关键词 OR 匹配
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
for (String keyword : keywords) {
    boolQuery.should(QueryBuilders.matchQuery("title", keyword));
    boolQuery.should(QueryBuilders.matchQuery("description", keyword));
}
boolQuery.filter(QueryBuilders.termQuery("categoryId", categoryId));
boolQuery.filter(QueryBuilders.termQuery("status", 1));

Elasticsearch 的优势:

  • 全文搜索引擎,模糊匹配毫秒级响应

  • 支持同义词、拼音搜索

  • 可结合向量插件(如 elastiknn)实现混合搜索

13.3 多模态 RAG 增强

将识图搜索与 RAG(Retrieval-Augmented Generation)结合:

用户上传图片 → VLM 理解 → 关键词检索 → 召回候选商品
       ↓
  LLM 汇总候选结果,生成自然语言推荐
  "根据您上传的机械键盘照片,我们为您找到 3 款相似商品:..."

这样用户不仅能搜到商品,还能得到 AI 的智能推荐解读。

13.4 搜索日志与效果反馈闭环

在生产环境中,应记录每次识图搜索的日志:

日志字段 说明
image_md5 图片唯一标识(用于去重和缓存)
ai_item_name AI 识别商品名
ai_keywords AI 提取关键词
result_count 搜索结果数量
user_click_id 用户点击的商品 ID(如有)
search_time_ms 搜索耗时

通过分析这些日志,可以:

  • 优化 Prompt(哪些识别经常出错)

  • 调整关键词权重(哪些词匹配度高)

  • A/B 测试不同模型版本

  • 构建点击率排序模型

13.5 小程序与移动端适配

当前方案基于 Web 端,但核心逻辑可以无缝迁移到微信小程序、UniApp 等移动端:

// 小程序端调用 wx.chooseMedia 而非 input[type=file]
wx.chooseMedia({
  count: 1,
  mediaType: ['image'],
  success(res) {
    const tempFilePath = res.tempFiles[0].tempFilePath
    // 转为 Base64 或直接上传
    wx.getFileSystemManager().readFile({
      filePath: tempFilePath,
      encoding: 'base64',
      success: (data) => {
        // 调用后端 API
      }
    })
  }
})

十四、总结与展望

14.1 核心收获

本文从零到一搭建了一套完整的 AI 识图搜索系统,核心要点回顾:

  1. 架构分层:视觉理解(大模型)与业务检索(数据库)严格分离,各司其职

  2. Prompt 工程:通过精心设计的 System Prompt,约束模型输出结构化 JSON

  3. 多关键词 OR:将 AI 提取的多个关键词独立匹配,大幅提升命中率

  4. 分级兜底:类目过滤 → 关键词匹配 → 放宽条件,在召回率和精确率之间平衡

  5. 安全合规:API Key 仅存服务端,前端只传图片;独立接口,模块解耦

  6. 轻量易集成:单页前端 + 单控制器后端,快速接入任何有商品表的项目

14.2 适用场景总结

AI 识图搜索的价值,在于把多模态能力嵌入既有业务数据流,而非替代数据库:

  • 降低搜索门槛——用图片作为统一入口,用户不再需要精确描述

  • 提高可解释性——识别结果与检索条件对用户可见,过程透明

  • 保证结果真实性——列表来自业务库,可溯源、可交易、可统计

  • 工程可落地——密钥安全、模块解耦、分级兜底、类目对齐

适用于任何「用户手里是图,系统里是结构化商品(或物料)数据」的检索增强场景。

14.3 未来展望

随着多模态大模型能力的持续进步(更大分辨率、更强细节理解、更低延迟),AI 识图搜索将成为电商、资产管理、本地生活等领域的标配能力。从"以文搜文"到"以图搜文"再到"以图搜图",搜索范式的演进才刚刚开始。

技术标签:Multimodal LLM、Structured Output、Semantic Search、Fallback Retrieval、BFF API、前后端分离、Spring Boot、Vue 3、阿里云百炼。


更多推荐