3.30 以图搜图、私人图库内按颜色搜图

This commit is contained in:
zhangsan 2025-03-30 18:11:08 +08:00
parent 9651ef6790
commit 886d2a89d6
25 changed files with 1130 additions and 9 deletions

View File

@ -89,4 +89,8 @@ ALTER TABLE picture
ADD COLUMN space_id bigint null comment '空间 id为空表示公共空间';
-- 创建索引
CREATE INDEX idx_spaceId ON picture (space_id);
CREATE INDEX idx_spaceId ON picture (space_id);
-- 添加新列
ALTER TABLE picture
ADD COLUMN pic_color varchar(16) null comment '图片主色调';

View File

@ -0,0 +1,29 @@
package edu.whut.smilepicturebackend.api.imagesearch;
import edu.whut.smilepicturebackend.api.imagesearch.model.ImageSearchResult;
import edu.whut.smilepicturebackend.api.imagesearch.sub.GetImageFirstUrlApi;
import edu.whut.smilepicturebackend.api.imagesearch.sub.GetImageListApi;
import edu.whut.smilepicturebackend.api.imagesearch.sub.GetImagePageUrlApi;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
@Slf4j
public class ImageSearchApiFacade {
/**
* 搜索图片
* @param localImagePath
* @return
*/
public static List<ImageSearchResult> searchImage(String localImagePath) {
String imagePageUrl = GetImagePageUrlApi.getImagePageUrl(localImagePath);
String imageFirstUrl = GetImageFirstUrlApi.getImageFirstUrl(imagePageUrl);
List<ImageSearchResult> imageList = GetImageListApi.getImageList(imageFirstUrl);
return imageList;
}
public static void main(String[] args) {
List<ImageSearchResult> imageList = searchImage("https://www.codefather.cn/logo.png");
System.out.println("结果列表" + imageList);
}
}

View File

@ -0,0 +1,20 @@
package edu.whut.smilepicturebackend.api.imagesearch.model;
import lombok.Data;
/**
* 图片搜索结果
*/
@Data
public class ImageSearchResult {
/**
* 缩略图地址
*/
private String thumbUrl;
/**
* 来源地址
*/
private String fromUrl;
}

View File

@ -0,0 +1,64 @@
package edu.whut.smilepicturebackend.api.imagesearch.sub;
import edu.whut.smilepicturebackend.exception.BusinessException;
import edu.whut.smilepicturebackend.exception.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 获取图片列表接口的 ApiStep 2
*/
@Slf4j
public class GetImageFirstUrlApi {
/**
* 获取图片列表页面地址
*
* @param url
* @return
*/
public static String getImageFirstUrl(String url) {
try {
// 使用 Jsoup 获取 HTML 内容
Document document = Jsoup.connect(url)
.timeout(5000)
.get();
// 获取所有 <script> 标签
Elements scriptElements = document.getElementsByTag("script");
// 遍历找到包含 `firstUrl` 的脚本内容
for (Element script : scriptElements) {
String scriptContent = script.html();
if (scriptContent.contains("\"firstUrl\"")) {
// 正则表达式提取 firstUrl 的值
Pattern pattern = Pattern.compile("\"firstUrl\"\\s*:\\s*\"(.*?)\"");
Matcher matcher = pattern.matcher(scriptContent);
if (matcher.find()) {
String firstUrl = matcher.group(1);
// 处理转义字符
firstUrl = firstUrl.replace("\\/", "/");
return firstUrl;
}
}
}
throw new BusinessException(ErrorCode.OPERATION_ERROR, "未找到 url");
} catch (Exception e) {
log.error("搜索失败", e);
throw new BusinessException(ErrorCode.OPERATION_ERROR, "搜索失败");
}
}
public static void main(String[] args) {
// 请求目标 URL
String url = "https://graph.baidu.com/s?card_key=&entrance=GENERAL&extUiData[isLogoShow]=1&f=all&isLogoShow=1&session_id=11441865424208889950&sign=1265ce97cd54acd88139901750155197&tpl_from=pc";
String imageFirstUrl = getImageFirstUrl(url);
System.out.println("搜索成功,结果 URL" + imageFirstUrl);
}
}

View File

@ -0,0 +1,71 @@
package edu.whut.smilepicturebackend.api.imagesearch.sub;
import cn.hutool.http.HttpResponse;
import cn.hutool.http.HttpUtil;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import edu.whut.smilepicturebackend.api.imagesearch.model.ImageSearchResult;
import edu.whut.smilepicturebackend.exception.BusinessException;
import edu.whut.smilepicturebackend.exception.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import java.util.List;
/**
* 获取图片列表step 3
*/
@Slf4j
public class GetImageListApi {
/**
* 获取图片列表
*
* @param url
* @return
*/
public static List<ImageSearchResult> getImageList(String url) {
try {
// 发起GET请求
HttpResponse response = HttpUtil.createGet(url).execute();
// 获取响应内容
int statusCode = response.getStatus();
String body = response.body();
// 处理响应
if (statusCode == 200) {
// 解析 JSON 数据并处理
return processResponse(body);
} else {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "接口调用失败");
}
} catch (Exception e) {
log.error("获取图片列表失败", e);
throw new BusinessException(ErrorCode.OPERATION_ERROR, "获取图片列表失败");
}
}
/**
* 处理接口响应内容
*
* @param responseBody 接口返回的JSON字符串
*/
private static List<ImageSearchResult> processResponse(String responseBody) {
// 解析响应对象
JSONObject jsonObject = new JSONObject(responseBody);
if (!jsonObject.containsKey("data")) {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "未获取到图片列表");
}
JSONObject data = jsonObject.getJSONObject("data");
if (!data.containsKey("list")) {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "未获取到图片列表");
}
JSONArray list = data.getJSONArray("list");
return JSONUtil.toList(list, ImageSearchResult.class);
}
public static void main(String[] args) {
String url = "https://graph.baidu.com/ajax/pcsimi?carousel=503&entrance=GENERAL&extUiData%5BisLogoShow%5D=1&inspire=general_pc&limit=30&next=2&render_type=card&session_id=11441865424208889950&sign=1265ce97cd54acd88139901750155197&tk=9d296&tpl_from=pc";
List<ImageSearchResult> imageList = getImageList(url);
System.out.println("搜索成功" + imageList);
}
}

View File

@ -0,0 +1,87 @@
package edu.whut.smilepicturebackend.api.imagesearch.sub;
import cn.hutool.core.util.StrUtil;
import cn.hutool.core.util.URLUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.http.HttpStatus;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import edu.whut.smilepicturebackend.exception.BusinessException;
import edu.whut.smilepicturebackend.exception.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
/**
* 获取以图搜图页面地址step 1
*/
@Slf4j
public class GetImagePageUrlApi {
/**
* 获取以图搜图页面地址
*
* @param imageUrl
* @return
*/
/**
* 调用百度以图搜图上传接口返回结果页 URL
*/
public static String getImagePageUrl(String localImagePath) {
// 1 参数准备必须 urlencode
Map<String, Object> formData = new HashMap<>();
formData.put("image", new java.io.File(localImagePath)); // 关键
formData.put("tn", "pc");
formData.put("from", "pc");
formData.put("image_source", "PC_UPLOAD_URL");
String uploadUrl = "https://graph.baidu.com/upload?uptime=" + System.currentTimeMillis();
// Acs-Token 每次打开 graph.baidu.com 时都会刷新可抓包固定或动态获取
String acsToken = "jmM4zyI8OUixvSuWh0sCy4xWbsttVMZb9qcRTmn6SuNWg0vCO7N0s6Lffec+IY5yuqHujHmCctF9BVCGYGH0H5SH/H3VPFUl4O4CP1jp8GoAzuslb8kkQQ4a21Tebge8yhviopaiK66K6hNKGPlWt78xyyJxTteFdXYLvoO6raqhz2yNv50vk4/41peIwba4lc0hzoxdHxo3OBerHP2rfHwLWdpjcI9xeu2nJlGPgKB42rYYVW50+AJ3tQEBEROlg/UNLNxY+6200B/s6Ryz+n7xUptHFHi4d8Vp8q7mJ26yms+44i8tyiFluaZAr66/+wW/KMzOhqhXCNgckoGPX1SSYwueWZtllIchRdsvCZQ8tFJymKDjCf3yI/Lw1oig9OKZCAEtiLTeKE9/CY+Crp8DHa8Tpvlk2/i825E3LuTF8EQfzjcGpVnR00Lb4/8A";
try (HttpResponse resp = HttpRequest.post(uploadUrl)
.header("User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36")
.header("Referer", "https://graph.baidu.com/") // 403
.header("Acs-Token", acsToken) // 防风控
.form(formData)
.setFollowRedirects(true) // 跟随 302
.timeout(5000)
.execute()) {
if (resp.getStatus() != HttpStatus.HTTP_OK) {
log.error("[百度搜图] 上传失败HTTP={}", resp.getStatus());
throw new BusinessException(ErrorCode.OPERATION_ERROR, "搜图失败");
}
JSONObject json = JSONUtil.parseObj(resp.body()); // 兼容字符串/数字
if (json.getInt("status", -1) != 0) {
String msg = json.getStr("msg", "unknown");
log.error("[百度搜图] 接口返回错误:{}", msg);
throw new BusinessException(ErrorCode.OPERATION_ERROR, "搜图失败");
}
String rawUrl = json.getJSONObject("data").getStr("url");
String pageUrl = URLUtil.decode(rawUrl, StandardCharsets.UTF_8);
if (StrUtil.isBlank(pageUrl)) {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "未返回有效地址");
}
return pageUrl;
} catch (Exception e) {
log.error("[百度搜图] 上传过程异常", e);
throw new BusinessException(ErrorCode.OPERATION_ERROR, "搜图失败");
}
}
public static void main(String[] args) {
// 测试以图搜图功能
String imageUrl = "https://www.codefather.cn/logo.png";
String searchResultUrl = getImagePageUrl(imageUrl);
System.out.println("搜索成功,结果 URL" + searchResultUrl);
}
}

View File

@ -2,6 +2,8 @@ package edu.whut.smilepicturebackend.controller;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import edu.whut.smilepicturebackend.annotation.AuthCheck;
import edu.whut.smilepicturebackend.api.imagesearch.ImageSearchApiFacade;
import edu.whut.smilepicturebackend.api.imagesearch.model.ImageSearchResult;
import edu.whut.smilepicturebackend.common.BaseResponse;
import edu.whut.smilepicturebackend.common.DeleteRequest;
import edu.whut.smilepicturebackend.common.ResultUtils;
@ -27,6 +29,7 @@ import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
@ -44,7 +47,7 @@ public class PictureController {
/**
* 上传图片可重新上传前端选中图片就会调用该接口无需前端点'创建'按钮
*
*TODO:目前有个bug用户上传图片需要审核会跳转到一个空白的图片详情页
*/
@PostMapping("/upload")
// @AuthCheck(mustRole = UserConstant.ADMIN_ROLE)
@ -268,4 +271,27 @@ public class PictureController {
int uploadCount = pictureService.uploadPictureByBatch(pictureUploadByBatchRequest, loginUser);
return ResultUtils.success(uploadCount);
}
/**
* 以图搜图
*/
@PostMapping("/search/picture")
public BaseResponse<List<ImageSearchResult>> getSimilarPicture(@RequestBody SearchPictureByPictureRequest request) throws IOException {
ThrowUtils.throwIf(request == null, ErrorCode.NO_AUTH_ERROR);
List<ImageSearchResult> similarImage = pictureService.getSimilarPicture(request);
return ResultUtils.success(similarImage);
}
/**
* 按照颜色搜索
*/
@PostMapping("/search/color")
public BaseResponse<List<PictureVO>> searchPictureByColor(@RequestBody SearchPictureByColorRequest searchPictureByColorRequest, HttpServletRequest request) {
ThrowUtils.throwIf(searchPictureByColorRequest == null, ErrorCode.PARAMS_ERROR);
String picColor = searchPictureByColorRequest.getPicColor();
Long spaceId = searchPictureByColorRequest.getSpaceId();
User loginUser = userService.getLoginUser(request);
List<PictureVO> pictureVOList = pictureService.searchPictureByColor(spaceId, picColor, loginUser);
return ResultUtils.success(pictureVOList);
}
}

View File

@ -4,13 +4,17 @@ import cn.hutool.core.util.StrUtil;
import com.qcloud.cos.COSClient;
import com.qcloud.cos.model.*;
import com.qcloud.cos.model.ciModel.persistence.PicOperations;
import com.qcloud.cos.utils.IOUtils;
import edu.whut.smilepicturebackend.config.CosClientConfig;
import edu.whut.smilepicturebackend.exception.BusinessException;
import edu.whut.smilepicturebackend.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
@ -189,7 +193,7 @@ public class CosManager {
* @param originalUrl 形如 https://bucket.cos.xxx/my/prefix/file.png
* @return my/prefix/file.png
*/
public static String extractUploadPath(String originalUrl) {
public String extractUploadPath(String originalUrl) {
try {
// 1. 直接拿 URI path 部分例如 "//smile-picture/public/.../xxx.png"
String path = new URI(originalUrl).getPath();
@ -201,4 +205,29 @@ public class CosManager {
}
}
/**
* 将COS中的文件下载到本地
* @param filepath 文件路径如folder/picture.jpg
* @param localPath 本地存储路径
*/
public void downloadPicture(String filepath, String localPath) throws IOException {
File file = new File(localPath);
COSObjectInputStream cosObjectInput = null;
try {
COSObject cosObject = this.getObject(filepath);
cosObjectInput = cosObject.getObjectContent();
// 将输入流转为字节数组
byte[] data = IOUtils.toByteArray(cosObjectInput);
// 将字节数组写入本地文件
FileUtil.writeBytes(data, file);
} catch (Exception e) {
log.error("file download error, filepath = {}", filepath, e);
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "下载失败");
} finally {
if (cosObjectInput != null) {
cosObjectInput.close();
}
}
}
}

View File

@ -71,7 +71,7 @@ public abstract class PictureUploadTemplate {
List<CIObject> objectList = processResults.getObjectList();
if (CollUtil.isNotEmpty(objectList)) {
// 获取压缩之后得到的文件信息
CIObject compressedCiObject = objectList.get(0); //第一个是压缩后的
CIObject compressedCiObject = objectList.get(0); //第一个是压缩后的,webp格式
// 缩略图默认等于压缩图,压缩图是必有的
CIObject thumbnailCiObject = compressedCiObject;
// 有生成缩略图才获取缩略图

View File

@ -0,0 +1,24 @@
package edu.whut.smilepicturebackend.model.dto.picture;
import lombok.Data;
import java.io.Serializable;
/**
* 按照颜色搜索图片请求
*/
@Data
public class SearchPictureByColorRequest implements Serializable {
/**
* 图片主色调
*/
private String picColor;
/**
* 空间 id
*/
private Long spaceId;
private static final long serialVersionUID = 1L;
}

View File

@ -0,0 +1,19 @@
package edu.whut.smilepicturebackend.model.dto.picture;
import lombok.Data;
import java.io.Serializable;
/**
* 以图搜图请求
*/
@Data
public class SearchPictureByPictureRequest implements Serializable {
/**
* 图片 id
*/
private Long pictureId;
private static final long serialVersionUID = 1L;
}

View File

@ -5,6 +5,7 @@ import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.util.Date;
/**
* 查询空间请求
@ -38,5 +39,15 @@ public class SpaceQueryRequest extends PageRequest implements Serializable {
*/
private Integer spaceType;
/*
* 开始编辑时间
*/
private Date startEditTime;
/*
* 结束编辑时间
*/
private Date endEditTime;
private static final long serialVersionUID = 1L;
}

View File

@ -79,6 +79,11 @@ public class Picture implements Serializable {
*/
private String picFormat;
/**
* 图片主色调
*/
private String picColor;
/**
* 创建用户 id
*/

View File

@ -78,6 +78,11 @@ public class PictureVO implements Serializable {
*/
private String picFormat;
/**
* 图片主色调
*/
private String picColor;
/**
* 用户 id

View File

@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import edu.whut.smilepicturebackend.api.imagesearch.model.ImageSearchResult;
import edu.whut.smilepicturebackend.model.dto.picture.*;
import edu.whut.smilepicturebackend.model.entity.Picture;
import edu.whut.smilepicturebackend.model.entity.User;
@ -11,6 +12,8 @@ import edu.whut.smilepicturebackend.model.vo.PictureVO;
import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.List;
/**
* @author 张三
@ -120,4 +123,16 @@ public interface PictureService extends IService<Picture> {
void clearPictureFile(Picture oldPicture);
void checkPictureAuth(User loginUser, Picture picture);
List<ImageSearchResult> getSimilarPicture(SearchPictureByPictureRequest request) throws IOException;
/**
* 根据颜色搜索图片
*
* @param spaceId
* @param picColor
* @param loginUser
* @return
*/
List<PictureVO> searchPictureByColor(Long spaceId, String picColor, User loginUser);
}

View File

@ -2,6 +2,7 @@ package edu.whut.smilepicturebackend.service.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.core.util.RandomUtil;
import cn.hutool.core.util.StrUtil;
@ -11,6 +12,8 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import edu.whut.smilepicturebackend.api.imagesearch.ImageSearchApiFacade;
import edu.whut.smilepicturebackend.api.imagesearch.model.ImageSearchResult;
import edu.whut.smilepicturebackend.exception.BusinessException;
import edu.whut.smilepicturebackend.exception.ErrorCode;
import edu.whut.smilepicturebackend.exception.ThrowUtils;
@ -32,6 +35,8 @@ import edu.whut.smilepicturebackend.model.vo.UserVO;
import edu.whut.smilepicturebackend.service.PictureService;
import edu.whut.smilepicturebackend.service.SpaceService;
import edu.whut.smilepicturebackend.service.UserService;
import edu.whut.smilepicturebackend.utils.ColorSimilarUtils;
import edu.whut.smilepicturebackend.utils.ColorTransformUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jsoup.Jsoup;
@ -46,11 +51,11 @@ import org.springframework.util.DigestUtils;
import javax.servlet.http.HttpServletRequest;
import java.awt.*;
import java.io.File;
import java.io.IOException;
import java.util.Date;
import java.util.*;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
@ -155,8 +160,10 @@ public class PictureServiceImpl extends ServiceImpl<PictureMapper, Picture>
UploadPictureResult uploadPictureResult = pictureUploadTemplate.uploadPicture(inputSource, uploadPathPrefix);
// 构造要入库的图片信息将图片信息存入数据库中
Picture picture = new Picture();
// 复制同名属性urlnamepicSizepicWidthpicHeightpicScalepicFormat
BeanUtils.copyProperties(uploadPictureResult, picture);
// 支持外层pictureUploadRequest传递图片名称
picture.setName(
StrUtil.blankToDefault(
@ -166,6 +173,10 @@ public class PictureServiceImpl extends ServiceImpl<PictureMapper, Picture>
);
picture.setUserId(loginUser.getId());
picture.setSpaceId(spaceId);
// 转换为标准颜色
//TODO:不知道为什么没有正确设置到数据库
log.info("颜色"+uploadPictureResult.getPicColor());
picture.setPicColor(ColorTransformUtils.getStandardColor(uploadPictureResult.getPicColor()));
// 补充审核参数
this.fillReviewParams(picture, loginUser);
// 操作数据库
@ -227,8 +238,8 @@ public class PictureServiceImpl extends ServiceImpl<PictureMapper, Picture>
.eq(ObjUtil.isNotEmpty(req.getPicScale()), Picture::getPicScale, req.getPicScale())
.eq(ObjUtil.isNotEmpty(req.getReviewStatus()), Picture::getReviewStatus, req.getReviewStatus())
.eq(ObjUtil.isNotEmpty(req.getReviewerId()), Picture::getReviewerId, req.getReviewerId())
.ge(ObjUtil.isNotEmpty(req.getStartEditTime()), Picture::getEditTime, req.getStartEditTime())
.lt(ObjUtil.isNotEmpty(req.getEndEditTime()), Picture::getEditTime, req.getEndEditTime());
.ge(ObjUtil.isNotEmpty(req.getStartEditTime()), Picture::getEditTime, req.getStartEditTime()) // >=
.lt(ObjUtil.isNotEmpty(req.getEndEditTime()), Picture::getEditTime, req.getEndEditTime()); // <=
// 全字段模糊搜索
if (StrUtil.isNotBlank(req.getSearchText())) {
@ -547,6 +558,85 @@ public class PictureServiceImpl extends ServiceImpl<PictureMapper, Picture>
}
}
}
@Override
public List<ImageSearchResult> getSimilarPicture(SearchPictureByPictureRequest request) throws IOException {
// 1.校验参数
Long pictureId = request.getPictureId();
ThrowUtils.throwIf(pictureId == null || pictureId <= 0 ,ErrorCode.NOT_FOUND_ERROR);
// 2.查询数据库
Picture picture = this.getById(pictureId);
ThrowUtils.throwIf(picture == null, ErrorCode.NOT_FOUND_ERROR);
// 3.提取图片在COS中的key
String pictureKey = cosManager.extractUploadPath(picture.getUrl());
// 4.下载文件 文件地址 = 当前项目路径 + 图片key图片key带时间戳本来是唯一
String suffix = pictureKey.replace('/', '\\');
String localPath = System.getProperty("user.dir") + "\\images\\" + suffix;
cosManager.downloadPicture(pictureKey, localPath);
// 5.返回结果
try {
return ImageSearchApiFacade.searchImage(localPath);
} finally {
// 删除本地文件及文件夹
File dir = new File(System.getProperty("user.dir") + "\\images\\");
if (dir.isDirectory()) {
// 清空目录
FileUtil.clean(dir);
log.info("Directory cleaned successfully.");
} else {
log.info("The provided path is not a directory.");
}
}
}
@Override
public List<PictureVO> searchPictureByColor(Long spaceId, String picColor, User loginUser) {
// 1. 校验参数
ThrowUtils.throwIf(spaceId == null || StrUtil.isBlank(picColor), ErrorCode.PARAMS_ERROR);
ThrowUtils.throwIf(loginUser == null, ErrorCode.NO_AUTH_ERROR);
// 2. 校验空间权限
Space space = spaceService.getById(spaceId);
ThrowUtils.throwIf(space == null, ErrorCode.NOT_FOUND_ERROR, "空间不存在");
if (!space.getUserId().equals(loginUser.getId())) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "没有空间访问权限");
}
// 3. 查询该空间下的所有图片必须要有主色调
List<Picture> pictureList = this.lambdaQuery()
.eq(Picture::getSpaceId, spaceId)
.isNotNull(Picture::getPicColor)
.list();
// 如果没有图片直接返回空列表
if (CollUtil.isEmpty(pictureList)) {
return new ArrayList<>();
}
// 将颜色字符串转换为主色调
Color targetColor = Color.decode(picColor);
// 4. 计算相似度并排序
List<Picture> sortedPictureList = pictureList.stream()
.sorted(Comparator.comparingDouble(picture -> {
String hexColor = picture.getPicColor();
// 没有主色调的图片会默认排序到最后
if (StrUtil.isBlank(hexColor)) {
return Double.MAX_VALUE;
}
Color pictureColor = Color.decode(hexColor);
// 计算相似度
// 越大越相似
return -ColorSimilarUtils.calculateSimilarity(targetColor, pictureColor);
}))
.limit(12) // 取前 12
.collect(Collectors.toList());
// 5. 返回结果
return sortedPictureList.stream()
.map(PictureVO::objToVo)
.collect(Collectors.toList());
}
}

View File

@ -0,0 +1,63 @@
package edu.whut.smilepicturebackend.utils;
import java.awt.*;
/**
* 工具类计算颜色相似度
*/
public class ColorSimilarUtils {
private ColorSimilarUtils() {
// 工具类不需要实例化
}
/**
* 计算两个颜色的相似度
*
* @param color1 第一个颜色
* @param color2 第二个颜色
* @return 相似度0到1之间1为完全相同
*/
public static double calculateSimilarity(Color color1, Color color2) {
int r1 = color1.getRed();
int g1 = color1.getGreen();
int b1 = color1.getBlue();
int r2 = color2.getRed();
int g2 = color2.getGreen();
int b2 = color2.getBlue();
// 计算欧氏距离
double distance = Math.sqrt(Math.pow(r1 - r2, 2) + Math.pow(g1 - g2, 2) + Math.pow(b1 - b2, 2));
// 计算相似度
return 1 - distance / Math.sqrt(3 * Math.pow(255, 2));
}
/**
* 根据十六进制颜色代码计算相似度
*
* @param hexColor1 第一个颜色的十六进制代码 0xFF0000
* @param hexColor2 第二个颜色的十六进制代码 0xFE0101
* @return 相似度0到1之间1为完全相同
*/
public static double calculateSimilarity(String hexColor1, String hexColor2) {
Color color1 = Color.decode(hexColor1);
Color color2 = Color.decode(hexColor2);
return calculateSimilarity(color1, color2);
}
// 示例代码
public static void main(String[] args) {
// 测试颜色
Color color1 = Color.decode("0xFF0000");
Color color2 = Color.decode("0xFE0101");
double similarity = calculateSimilarity(color1, color2);
System.out.println("颜色相似度为:" + similarity);
// 测试十六进制方法
double hexSimilarity = calculateSimilarity("0xFF0000", "0xFE0101");
System.out.println("十六进制颜色相似度为:" + hexSimilarity);
}
}

View File

@ -0,0 +1,28 @@
package edu.whut.smilepicturebackend.utils;
/**
* 颜色转换工具类
*/
public class ColorTransformUtils {
private ColorTransformUtils() {
// 工具类不需要实例化
}
/**
* 获取标准颜色将数据万象的 5 位色值转为 6
*
* @param color
* @return
*/
public static String getStandardColor(String color) {
// 每一种 rgb 色值都有可能只有一个 0要转换为 00)
// 如果是六位不用转换如果是五位要给第三位后面加个 0
// 示例
// 0x080e0 => 0x0800e
if (color.length() == 7) {
color = color.substring(0, 4) + "0" + color.substring(4, 7);
}
return color;
}
}

View File

@ -0,0 +1,63 @@
package picturesearch;
import edu.whut.smilepicturebackend.exception.BusinessException;
import edu.whut.smilepicturebackend.exception.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import picturesearch.enums.SearchSourceEnum;
import picturesearch.model.SearchPictureResult;
import java.util.List;
/**
* 以图搜图
*
* @author Silas Yan 2025-03-23:09:50
*/
@Slf4j
public abstract class AbstractSearchPicture {
/**
* 执行搜索
*
* @param searchSource 搜索源
* @param sourcePicture 源图片
* @param randomSeed 随机种子
* @param searchCount 搜索数量
* @return 搜索结果
*/
public final List<SearchPictureResult> execute(String searchSource, String sourcePicture, Integer randomSeed, Integer searchCount) {
log.info("开始搜索图片,搜索源:{},源图片:{},随机种子:{}", searchSource, sourcePicture, randomSeed);
// 校验
SearchSourceEnum searchSourceEnum = SearchSourceEnum.getEnumByKey(searchSource);
if (searchSourceEnum == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "不支持的搜索源");
}
// 执行搜索
String requestUrl = this.executeSearch(searchSourceEnum, sourcePicture);
List<SearchPictureResult> pictureResultList = this.sendRequestGetResponse(requestUrl, randomSeed, searchCount);
// 如果当前结果大于 searchCount 就截取
if (pictureResultList.size() > searchCount) {
pictureResultList = pictureResultList.subList(0, searchCount);
}
log.info("搜索图片结束,返回结果数量:{}", pictureResultList.size());
return pictureResultList;
}
/**
* 根据原图片获取搜索图片的列表地址
*
* @param searchSourceEnum 搜索源枚举
* @param sourcePicture 源图片
* @return 搜索图片的列表地址
*/
protected abstract String executeSearch(SearchSourceEnum searchSourceEnum, String sourcePicture);
/**
* 发送请求获取响应
*
* @param requestUrl 请求地址
* @param randomSeed 随机种子
* @param searchCount 搜索数量
* @return 响应结果
*/
protected abstract List<SearchPictureResult> sendRequestGetResponse(String requestUrl, Integer randomSeed, Integer searchCount);
}

View File

@ -0,0 +1,27 @@
package picturesearch;
import cn.hutool.json.JSONUtil;
import picturesearch.impl.SoSearchPicture;
import picturesearch.model.SearchPictureResult;
import java.util.List;
/**
* 以图搜图测试
*/
public class PictureSearchTest {
public static void main(String[] args) {
// 360以图搜图
// String imageUrl1 = "https://baolong-picture-1259638363.cos.ap-shanghai.myqcloud.com//public/10000000/2025-02-15_lzn23PuxZqt8CPB1.";
String imageUrl1 = "https://fshare.bitday.top/api/public/dl/BL9SNN2V/store/820b2a3c-fa59-472e-b3ee-572c63c2ae91.png";
AbstractSearchPicture soSearchPicture = new SoSearchPicture();
List<SearchPictureResult> soResultList = soSearchPicture.execute("SO", imageUrl1, 1, 21);
System.out.println("结果列表: " + JSONUtil.parse(soResultList));
// // 百度以图搜图
// String imageUrl2 = "https://www.codefather.cn/logo.png";
// AbstractSearchPicture baiduSearchPicture = new BaiduSearchPicture();
// List<SearchPictureResult> baiduResultList = baiduSearchPicture.execute("BAIDU", imageUrl2, 1, 31);
// System.out.println("结果列表" + JSONUtil.parse(baiduResultList));
}
}

View File

@ -0,0 +1,69 @@
package picturesearch.enums;
import cn.hutool.core.util.ArrayUtil;
import cn.hutool.core.util.ObjUtil;
import lombok.Getter;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
/**
* 搜索来源枚举
*/
@Getter
public enum SearchSourceEnum {
SO("SO", "360", "https://st.so.com/r?src=st&srcsp=home&img_url=%s&submittype=imgurl"),
BAIDU("BAIDU", "百度", "https://graph.baidu.com/upload?uptime=%s");
private final String key;
private final String label;
private final String url;
SearchSourceEnum(String key, String label, String url) {
this.key = key;
this.label = label;
this.url = url;
}
/**
* 根据 KEY 获取枚举
*
* @param key 状态键值
* @return 枚举对象未找到时返回 null
*/
public static SearchSourceEnum of(String key) {
if (ObjUtil.isEmpty(key)) return null;
return ArrayUtil.firstMatch(e -> e.getKey().equals(key), values());
}
/**
* 根据 KEY 获取枚举
*
* @param key KEY
* @return 枚举
*/
public static SearchSourceEnum getEnumByKey(String key) {
if (ObjUtil.isEmpty(key)) {
return null;
}
for (SearchSourceEnum anEnum : SearchSourceEnum.values()) {
if (anEnum.key.equals(key)) {
return anEnum;
}
}
return null;
}
/**
* 获取所有有效的 KEY 列表
*
* @return 有效 KEY 集合不可变列表
*/
public static List<String> keys() {
return Arrays.stream(values())
.map(SearchSourceEnum::getKey)
.collect(Collectors.toList());
}
}

View File

@ -0,0 +1,201 @@
package picturesearch.impl;
import cn.hutool.core.util.RandomUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.core.util.URLUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.http.HttpStatus;
import cn.hutool.http.HttpUtil;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import edu.whut.smilepicturebackend.exception.BusinessException;
import edu.whut.smilepicturebackend.exception.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;
import org.springframework.stereotype.Component;
import picturesearch.AbstractSearchPicture;
import picturesearch.enums.SearchSourceEnum;
import picturesearch.model.SearchPictureResult;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 百度以图搜图实现
* <p>
* 说明: 百度的以图搜图默认返回 30
*
* @author Silas Yan 2025-03-23:11:09
*/
@Slf4j
@Component
public class BaiduSearchPicture extends AbstractSearchPicture {
/**
* 根据原图片获取搜索图片的列表地址
*
* @param searchSourceEnum 搜索源枚举
* @param sourcePicture 源图片
* @return 搜索图片的列表地址
*/
@Override
protected String executeSearch(SearchSourceEnum searchSourceEnum, String sourcePicture) {
String searchUrl = String.format(searchSourceEnum.getUrl(), System.currentTimeMillis());
log.info("[百度搜图]搜图地址:{}", searchUrl);
try {
String pageUrl = getPageUrl(searchUrl, sourcePicture);
return getListUrl(pageUrl);
} catch (Exception e) {
log.error("[百度搜图]搜图失败", e);
throw new BusinessException(ErrorCode.OPERATION_ERROR, "搜图失败");
}
}
/**
* 发送请求获取响应
*
* @param requestUrl 请求地址
* @param randomSeed 随机种子
* @param searchCount 搜索数量
* @return 响应结果
*/
@Override
protected List<SearchPictureResult> sendRequestGetResponse(String requestUrl, Integer randomSeed, Integer searchCount) {
log.info("[百度搜图]搜图地址:{}, 随机种子: {}, 搜索数量: {}", requestUrl, randomSeed, searchCount);
if (searchCount == null) searchCount = 30;
List<SearchPictureResult> resultList = new ArrayList<>();
int currentWhileNum = 0;
int targetWhileNum = searchCount / 30 + 1;
while (currentWhileNum < targetWhileNum && resultList.size() < searchCount) {
if (randomSeed == null) randomSeed = RandomUtil.randomInt(1, 20);
log.info("[百度搜图]当前随机种子: {}, 当前结果数量: {}", randomSeed, resultList.size());
String URL = requestUrl + "&page=" + randomSeed;
try (HttpResponse response = HttpUtil.createGet(URL).execute()) {
// 判断响应状态
if (HttpStatus.HTTP_OK != response.getStatus()) {
log.error("[百度搜图]搜图失败,响应状态码:{}", response.getStatus());
throw new BusinessException(ErrorCode.OPERATION_ERROR, "搜图失败");
}
// 解析响应, 处理响应结果
JSONObject body = JSONUtil.parseObj(response.body());
if (!body.containsKey("data")) {
log.error("[百度搜图]搜图失败,未获取到图片数据");
throw new BusinessException(ErrorCode.OPERATION_ERROR, "未获取到图片列表");
}
JSONObject data = body.getJSONObject("data");
if (!data.containsKey("list")) {
log.error("[百度搜图]搜图失败,未获取到图片数据");
throw new BusinessException(ErrorCode.OPERATION_ERROR, "未获取到图片列表");
}
JSONArray baiduResult = data.getJSONArray("list");
for (Object o : baiduResult) {
JSONObject so = (JSONObject) o;
SearchPictureResult pictureResult = new SearchPictureResult();
pictureResult.setImageUrl(so.getStr("thumbUrl"));
pictureResult.setImageKey(so.getStr("contsign"));
resultList.add(pictureResult);
}
currentWhileNum++;
} catch (Exception e) {
log.error("[百度搜图]搜图失败", e);
throw new BusinessException(ErrorCode.OPERATION_ERROR, "搜图失败");
} finally {
randomSeed++;
}
}
log.info("[百度搜图]最终结果数量: {}", resultList.size());
return resultList;
}
/**
* 获取图片页面地址
*
* @param searchUrl 搜索地址
* @param sourcePicture 源图片
* @return 图片页面地址
*/
public static String getPageUrl(String searchUrl, String sourcePicture) {
Map<String, Object> formData = new HashMap<>();
formData.put("image", sourcePicture);
formData.put("tn", "pc");
formData.put("from", "pc");
formData.put("image_source", "PC_UPLOAD_URL");
String acsToken = "jmM4zyI8OUixvSuWh0sCy4xWbsttVMZb9qcRTmn6SuNWg0vCO7N0s6Lffec+IY5yuqHujHmCctF9BVCGYGH0H5SH/H3VPFUl4O4CP1jp8GoAzuslb8kkQQ4a21Tebge8yhviopaiK66K6hNKGPlWt78xyyJxTteFdXYLvoO6raqhz2yNv50vk4/41peIwba4lc0hzoxdHxo3OBerHP2rfHwLWdpjcI9xeu2nJlGPgKB42rYYVW50+AJ3tQEBEROlg/UNLNxY+6200B/s6Ryz+n7xUptHFHi4d8Vp8q7mJ26yms+44i8tyiFluaZAr66/+wW/KMzOhqhXCNgckoGPX1SSYwueWZtllIchRdsvCZQ8tFJymKDjCf3yI/Lw1oig9OKZCAEtiLTeKE9/CY+Crp8DHa8Tpvlk2/i825E3LuTF8EQfzjcGpVnR00Lb4/8A";
try (HttpResponse response = HttpRequest.post(searchUrl).form(formData)
.header("Acs-Token", acsToken).timeout(5000).execute()) {
// 判断响应状态
if (HttpStatus.HTTP_OK != response.getStatus()) {
log.error("[百度搜图]搜图失败,响应状态码:{}", response.getStatus());
throw new BusinessException(ErrorCode.OPERATION_ERROR, "搜图失败");
}
// 解析响应
JSONObject body = JSONUtil.parseObj(response.body());
if (!body.getInt("status").equals(0)) {
log.error("[百度搜图]搜图失败,响应内容为空");
throw new BusinessException(ErrorCode.OPERATION_ERROR, "搜图失败");
}
JSONObject data = JSONUtil.parseObj(body.getStr("data"));
String rawUrl = data.getStr("url");
if (StrUtil.isEmpty(rawUrl)) {
log.error("[百度搜图]搜图失败,地址为空");
throw new BusinessException(ErrorCode.OPERATION_ERROR, "搜图失败");
}
String decodeUrl = URLUtil.decode(rawUrl, StandardCharsets.UTF_8);
if (StrUtil.isEmpty(decodeUrl)) {
log.error("[百度搜图]搜图失败,未获取到图片页面地址");
throw new BusinessException(ErrorCode.OPERATION_ERROR, "搜图失败");
}
return decodeUrl;
} catch (Exception e) {
log.error("[百度搜图]搜图失败", e);
throw new BusinessException(ErrorCode.OPERATION_ERROR, "搜索失败");
}
}
/**
* 获取图片列表地址
*
* @param resultUrl 结果页面地址
* @return 图片列表地址
*/
private static String getListUrl(String resultUrl) {
try {
// 使用 Jsoup 获取 HTML 内容
Document document = Jsoup.connect(resultUrl).timeout(5000).get();
// 获取所有 <script> 标签
Elements scriptElements = document.getElementsByTag("script");
// 遍历找到包含 `firstUrl` 的脚本内容
String firstUrl = null;
for (Element script : scriptElements) {
String scriptContent = script.html();
if (scriptContent.contains("\"firstUrl\"")) {
// 正则表达式提取 firstUrl 的值
Pattern pattern = Pattern.compile("\"firstUrl\"\\s*:\\s*\"(.*?)\"");
Matcher matcher = pattern.matcher(scriptContent);
if (matcher.find()) {
// 处理转义字符
firstUrl = matcher.group(1).replace("\\/", "/");
}
}
}
if (StrUtil.isEmpty(firstUrl)) {
log.error("[百度搜图]搜图失败,未找到图片元素");
throw new BusinessException(ErrorCode.OPERATION_ERROR, "搜图失败");
}
return firstUrl;
} catch (Exception e) {
log.error("[百度搜图]搜图失败", e);
throw new BusinessException(ErrorCode.OPERATION_ERROR, "搜图失败");
}
}
}

View File

@ -0,0 +1,137 @@
package picturesearch.impl;
import cn.hutool.core.util.RandomUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.http.HttpStatus;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import edu.whut.smilepicturebackend.exception.BusinessException;
import edu.whut.smilepicturebackend.exception.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.springframework.stereotype.Component;
import picturesearch.AbstractSearchPicture;
import picturesearch.enums.SearchSourceEnum;
import picturesearch.model.SearchPictureResult;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 360以图搜图实现
* <p>
* 说明: 360的以图搜图默认返回 20
*
* @author Silas Yan 2025-03-23:10:05
*/
@Slf4j
@Component
public class SoSearchPicture extends AbstractSearchPicture {
/**
* 根据原图片获取搜索图片的列表地址
*
* @param searchSourceEnum 搜索源枚举
* @param sourcePicture 源图片
* @return 搜索图片的列表地址
*/
@Override
protected String executeSearch(SearchSourceEnum searchSourceEnum, String sourcePicture) {
String searchUrl = String.format(searchSourceEnum.getUrl(), sourcePicture);
log.info("[360搜图]搜图地址:{}", searchUrl);
try {
Document document = Jsoup.connect(searchUrl).timeout(5000).get();
System.out.println(document);
Element element = document.selectFirst(".img_img");
if (element == null) {
log.error("[360搜图]搜图失败,未找到图片元素");
throw new BusinessException(ErrorCode.OPERATION_ERROR, "搜图失败,未找到图片");
}
String imagesUrl = "";
// 获取当前元素的属性
String style = element.attr("style");
if (style.contains("background-image:url(")) {
// 提取URL部分
int start = style.indexOf("url(") + 4; // "Url("之后开始
int end = style.indexOf(")", start); // 找到右括号的位置
if (start > 4 && end > start) {
imagesUrl = style.substring(start, end);
}
}
if (StrUtil.isEmpty(imagesUrl)) {
log.error("[360搜图]搜图失败,未找到图片地址");
throw new BusinessException(ErrorCode.OPERATION_ERROR, "搜图失败,未找到图片");
}
return imagesUrl;
} catch (Exception e) {
log.error("[360搜图]搜图失败", e);
throw new BusinessException(ErrorCode.OPERATION_ERROR, "搜图失败");
}
}
/**
* 发送请求获取响应
*
* @param requestUrl 请求地址
* @param randomSeed 随机种子
* @param searchCount 搜索数量
* @return 响应结果
*/
@Override
protected List<SearchPictureResult> sendRequestGetResponse(String requestUrl, Integer randomSeed, Integer searchCount) {
log.info("[360搜图]搜图地址:{}, 随机种子: {}, 搜索数量: {}", requestUrl, randomSeed, searchCount);
if (searchCount == null) searchCount = 20;
List<SearchPictureResult> resultList = new ArrayList<>();
int currentWhileNum = 0;
int targetWhileNum = searchCount / 20 + 1;
while (currentWhileNum < targetWhileNum && resultList.size() < searchCount) {
if (randomSeed == null) randomSeed = RandomUtil.randomInt(1, 20);
log.info("[360搜图]当前随机种子: {}, 当前结果数量: {}", randomSeed, resultList.size());
String URL = "https://st.so.com/stu?a=mrecomm&start=" + randomSeed;
Map<String, Object> formData = new HashMap<>();
formData.put("img_url", requestUrl);
try (HttpResponse response = HttpRequest.post(URL).form(formData).timeout(5000).execute()) {
// 判断响应状态
if (HttpStatus.HTTP_OK != response.getStatus()) {
log.error("[360搜图]搜图失败,响应状态码:{}", response.getStatus());
throw new BusinessException(ErrorCode.OPERATION_ERROR, "搜图失败");
}
// 解析响应, 处理响应结果
JSONObject body = JSONUtil.parseObj(response.body());
if (!Integer.valueOf(0).equals(body.getInt("errno"))) {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "搜图失败");
}
JSONObject data = body.getJSONObject("data");
JSONArray soResult = data.getJSONArray("result");
for (Object o : soResult) {
JSONObject so = (JSONObject) o;
SearchPictureResult pictureResult = new SearchPictureResult();
String prefix;
if (StrUtil.isNotBlank(so.getStr("https"))) {
prefix = "https://" + so.getStr("https") + "/";
} else {
prefix = "http://" + so.getStr("http") + "/";
}
pictureResult.setImageUrl(prefix + so.getStr("imgkey"));
pictureResult.setImageName(so.getStr("title"));
pictureResult.setImageKey(so.getStr("imgkey"));
resultList.add(pictureResult);
}
currentWhileNum++;
} catch (Exception e) {
log.error("[360搜图]搜图失败", e);
throw new BusinessException(ErrorCode.OPERATION_ERROR, "搜图失败");
} finally {
randomSeed++;
}
}
log.info("[360搜图]最终结果数量: {}", resultList.size());
return resultList;
}
}

View File

@ -0,0 +1,33 @@
package picturesearch.model;
import lombok.Data;
import lombok.experimental.Accessors;
import java.io.Serializable;
/**
* 以图搜图结果
*
* @author Silas Yan 2025-03-23:09:40
*/
@Data
@Accessors(chain = true)
public class SearchPictureResult implements Serializable {
/**
* 图片地址
*/
private String imageUrl;
/**
* 图片名称
*/
private String imageName;
/**
* 图片 KEY
*/
private String imageKey;
private static final long serialVersionUID = 1L;
}

View File

@ -18,6 +18,7 @@
<result property="picHeight" column="pic_height" jdbcType="INTEGER"/>
<result property="picScale" column="pic_scale" jdbcType="DOUBLE"/>
<result property="picFormat" column="pic_format" jdbcType="VARCHAR"/>
<result property="picColor" column="pic_color" jdbcType="VARCHAR"/>
<result property="userId" column="user_id" jdbcType="BIGINT"/>
<result property="spaceId" column="space_id" jdbcType="BIGINT"/>
<result property="createTime" column="create_time" jdbcType="TIMESTAMP"/>