7.31 线上部署 frp-本地docker

This commit is contained in:
zhangsan 2025-07-31 17:02:17 +08:00
parent 528422fed6
commit b9501daf0a
23 changed files with 1070 additions and 630 deletions

1
.gitignore vendored
View File

@ -74,3 +74,4 @@ application-local.*
# 如果你用到 IDEA 自带的 File-Based Storage8+版本默认),可以添加:
# .idea/.name
# .idea/gradle.xml
/src/main/java/picturesearch/

14
.mvn/settings.xml Normal file
View File

@ -0,0 +1,14 @@
<settings xmlns="http://maven.apache.org/SETTINGS/1.1.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.1.0
https://maven.apache.org/xsd/settings-1.1.0.xsd">
<mirrors>
<mirror>
<id>aliyun</id>
<name>aliyun maven</name>
<url>https://maven.aliyun.com/repository/public</url>
<mirrorOf>central,apache.snapshots</mirrorOf>
</mirror>
</mirrors>
</settings>

26
Dockerfile Normal file
View File

@ -0,0 +1,26 @@
# —— builder 阶段 ——
FROM maven:3.8.7-eclipse-temurin-17-alpine AS builder
WORKDIR /workspace
# ① 如没有私服,可删掉这行;否则保留
COPY .mvn/settings.xml /root/.m2/settings.xml
# ② 先拷 POM 并预拉依赖,提升缓存命中率
COPY pom.xml .
RUN mvn -B dependency:go-offline
# ③ 再拷源码并真正打包,显式执行 spring-boot:repackage
COPY src src
RUN mvn -B clean package spring-boot:repackage -DskipTests
# —— runtime 阶段 ——
FROM openjdk:17-jdk-slim
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
# 指向你在 pom.xml 里定义的 finalName
ARG JAR_FILE=smile-picture-backend.jar
COPY --from=builder /workspace/target/${JAR_FILE} /app.jar
EXPOSE 8096
ENTRYPOINT ["java", "-jar", "/app.jar"]

View File

@ -0,0 +1,80 @@
version: "3.8"
services:
### 1. 前端 -------------------------------------------------
smile-picture-front:
image: nginx:alpine
container_name: smile-picture-front
restart: unless-stopped
ports:
- "18096:80"
volumes:
- ./nginx/html:/usr/share/nginx/html
- ./nginx/conf/nginx.conf:/etc/nginx/nginx.conf:ro
networks: [smile-picture-network]
### 2. MySQL ------------------------------------------------
mysql:
image: mysql:8.0
container_name: picture-mysql
hostname: mysql
restart: unless-stopped
command: --default-authentication-plugin=mysql_native_password
environment:
TZ: Asia/Shanghai
MYSQL_ROOT_PASSWORD: 123456
ports:
- "13307:3306"
volumes:
# 配置文件只读挂载 ✅
- ./mysql/my.cnf:/etc/mysql/conf.d/mysql.cnf:ro
# 初始化脚本只读也没问题 ✅
- ./mysql/sql:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 5s
timeout: 10s
retries: 10
start_period: 15s
networks: [smile-picture-network]
### 3. Redis -----------------------------------------------
redis:
image: redis:6.2
container_name: picture-redis
hostname: redis
restart: unless-stopped
ports:
- "36379:6379"
volumes:
- ./redis/redis.conf:/usr/local/etc/redis/redis.conf:ro
command: redis-server /usr/local/etc/redis/redis.conf
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 3
networks: [smile-picture-network]
### 4. Java 后端 -------------------------------------------
smile-picture-backend:
build:
context: ../../.. # 建议把 compose 放到项目根;这里就用 `.`
dockerfile: Dockerfile
image: smile/smile-picture-backend:latest
container_name: smile-picture-backend
restart: unless-stopped
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
environment: # ← 推荐通过环境变量注入容器名而非 localhost
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/smile-picture?useSSL=false&serverTimezone=Asia/Shanghai
ports:
- "8096:8096"
networks: [smile-picture-network]
networks:
smile-picture-network:
driver: bridge

View File

@ -0,0 +1,24 @@
[client]
port = 3306
default-character-set = utf8mb4
[mysqld]
user = mysql
port = 3306
sql_mode = NO_ENGINE_SUBSTITUTION,STRICT_TRANS_TABLES
default-storage-engine = InnoDB
default-authentication-plugin = mysql_native_password
character-set-server = utf8mb4
collation-server = utf8mb4_unicode_ci
init_connect = 'SET NAMES utf8mb4'
slow_query_log
#long_query_time = 3
slow-query-log-file = /var/log/mysql/mysql.slow.log
log-error = /var/log/mysql/mysql.error.log
default-time-zone = '+8:00'
[mysql]
default-character-set = utf8mb4

View File

@ -0,0 +1,59 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
# 上游后端定义
upstream picture_backend {
server smile-picture-backend:8096;
}
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# ---------- REST API 代理 ----------
location /api/ {
proxy_pass http://picture_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
client_max_body_size 10M; # 允许上传 10MB
# 添加代理超时设置(单位:秒)
proxy_connect_timeout 600s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
}
# ---------- WebSocket 代理 ----------
location /api/ws/ {
proxy_pass http://picture_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_buffering off;
proxy_read_timeout 86400s;
}
# ---------- 前端路由兼容 ----------
# HTML5 history-mode找不到文件时回退到 index.html 交给前端渲染
location / {
try_files $uri $uri/ /index.html;
}
}
}

View File

@ -0,0 +1 @@
@media (min-width: 1024px){.about{min-height:100vh;display:flex;align-items:center}}

View File

@ -0,0 +1 @@
import{_ as o,c as s,a as t,o as a}from"./index-CrUyCRGJ.js";const n={},c={class:"about"};function r(_,e){return a(),s("div",c,e[0]||(e[0]=[t("h1",null,"This is an about page",-1)]))}const l=o(n,[["render",r]]);export{l as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Smile云图库</title>
<meta name="description" content="Smile云图库海量图片素材免费获取">
<script type="module" crossorigin src="/assets/index-CrUyCRGJ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cidcg2WD.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

41
pom.xml
View File

@ -153,7 +153,31 @@
</dependencyManagement>
<build>
<!-- ① 指定最终打包出来的名称(不带后缀 .jar -->
<finalName>smile-picture-backend</finalName>
<!-- ② 资源过滤(如果你有占位符要替换才需要,否则可以删掉这一块) -->
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>**/**</include>
</includes>
</resource>
</resources>
<testResources>
<testResource>
<directory>src/test/resources</directory>
<filtering>true</filtering>
<includes>
<include>**/**</include>
</includes>
</testResource>
</testResources>
<plugins>
<!-- ③ 保留你的编译配置 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
@ -164,13 +188,27 @@
<encoding>UTF-8</encoding>
</configuration>
</plugin>
<!-- ④ 跳过单元测试(可选) -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.22.2</version>
<configuration>
<skipTests>true</skipTests>
</configuration>
</plugin>
<!-- ⑤ Spring Boot 打包:去掉 skip显式 repackage保证 Manifest 写入 Main-Class -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
<configuration>
<!-- 你的主类全限定名 -->
<mainClass>edu.whut.smilepicturebackend.SmilePictureBackendApplication</mainClass>
<skip>true</skip>
<!-- 打成可执行 JAR -->
<layout>JAR</layout>
</configuration>
<executions>
<execution>
@ -184,4 +222,5 @@
</plugins>
</build>
</project>

View File

@ -1,63 +0,0 @@
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

@ -1,27 +0,0 @@
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

@ -1,69 +0,0 @@
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

@ -1,201 +0,0 @@
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

@ -1,137 +0,0 @@
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

@ -1,33 +0,0 @@
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

@ -0,0 +1,101 @@
server:
port: 8123
servlet:
context-path: /api
# cookie 30 天过期
session:
cookie:
max-age: 2592000
spring:
profiles:
active: local
application:
name: smile-picture-backend
# 数据库配置
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/smile-picture
username: root
password: 123456
# Redis 配置
redis:
database: 1
host: localhost
port: 6379
password: 123456
timeout: 5000
# Session 配置
session:
store-type: redis
# session 30 天后过期,单位是秒
timeout: 2592000
servlet:
multipart:
max-file-size: 10MB
max-request-size: 10MB
# 空间图片分表
shardingsphere:
datasource:
names: smile-picture
smile-picture:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/smile-picture
username: root
password: 123456
rules:
sharding:
tables:
picture:
actual-data-nodes: smile-picture.picture # 动态分表
table-strategy:
standard:
sharding-column: space_id
sharding-algorithm-name: picture_sharding_algorithm # 使用自定义分片算法
sharding-algorithms:
picture_sharding_algorithm:
type: CLASS_BASED
props:
strategy: standard
algorithmClassName: edu.whut.smilepicturebackend.manager.sharding.PictureShardingAlgorithm
props:
sql-show: true #打印实际执行的sql
mybatis-plus:
type-aliases-package: edu.whut.smilepicturebackend.model.entity
configuration:
# MyBatis 配置
map-underscore-to-camel-case: true #是否开启驼峰转换
# 仅在开发环境打印日志
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
enable-sql-runner: true
db-config:
logic-delete-field: isDelete # 全局逻辑删除的实体字段名 删除操作时表中数据不会被物理删除只会设置isDelete为1
logic-delete-value: 1 # 逻辑已删除值(默认为 1
logic-not-delete-value: 0 # 逻辑未删除值(默认为 0
# 接口文档配置
knife4j:
enable: true
openapi:
title: 接口文档
version: 1.0
group:
default:
api-rule: package
api-rule-resources:
- edu.whut.smilepicturebackend.controller #接口地址
# 对象存储配置(需要从腾讯云获取)
cos:
client:
host: ${smile-picture.cos.client.host}
secretId: ${smile-picture.cos.client.secretId}
secretKey: ${smile-picture.cos.client.secretKey}
region: ${smile-picture.cos.client.region}
bucket: ${smile-picture.cos.client.bucket}
smile-picture:
aliyun:
apiKey: ${smile-picture.aliyun.apiKey}

View File

@ -0,0 +1,100 @@
server:
port: 8096
servlet:
context-path: /api
# cookie 30 天过期
session:
cookie:
max-age: 2592000
spring:
application:
name: smile-picture-backend
# 数据库配置
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://mysql:3306/smile-picture
username: root
password: 123456
# Redis 配置
redis:
database: 1
host: redis
port: 6379
password: 123456
timeout: 5000
# Session 配置
session:
store-type: redis
# session 30 天后过期,单位是秒
timeout: 2592000
servlet:
multipart:
max-file-size: 10MB
max-request-size: 10MB
# 空间图片分表
shardingsphere:
datasource:
names: smile-picture
smile-picture:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://mysql:3306/smile-picture
username: root
password: 123456
rules:
sharding:
tables:
picture:
actual-data-nodes: smile-picture.picture # 动态分表
table-strategy:
standard:
sharding-column: space_id
sharding-algorithm-name: picture_sharding_algorithm # 使用自定义分片算法
sharding-algorithms:
picture_sharding_algorithm:
type: CLASS_BASED
props:
strategy: standard
algorithmClassName: edu.whut.smilepicturebackend.manager.sharding.PictureShardingAlgorithm
props:
sql-show: false # 生产建议关闭;需要排查时可临时改 true
mybatis-plus:
type-aliases-package: edu.whut.smilepicturebackend.model.entity
configuration:
# MyBatis 配置
map-underscore-to-camel-case: true #是否开启驼峰转换
# 仅在开发环境打印日志
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
enable-sql-runner: true
db-config:
logic-delete-field: isDelete # 全局逻辑删除的实体字段名 删除操作时表中数据不会被物理删除只会设置isDelete为1
logic-delete-value: 1 # 逻辑已删除值(默认为 1
logic-not-delete-value: 0 # 逻辑未删除值(默认为 0
# 接口文档配置
knife4j:
enable: true
openapi:
title: 接口文档
version: 1.0
group:
default:
api-rule: package
api-rule-resources:
- edu.whut.smilepicturebackend.controller #接口地址
# 对象存储配置(需要从腾讯云获取)
cos:
client:
host: ${smile-picture.cos.client.host}
secretId: ${smile-picture.cos.client.secretId}
secretKey: ${smile-picture.cos.client.secretKey}
region: ${smile-picture.cos.client.region}
bucket: ${smile-picture.cos.client.bucket}
smile-picture:
aliyun:
apiKey: ${smile-picture.aliyun.apiKey}

View File

@ -1,101 +1,3 @@
server:
port: 8123
servlet:
context-path: /api
# cookie 30 天过期
session:
cookie:
max-age: 2592000
spring:
profiles:
active: local
application:
name: smile-picture-backend
# 数据库配置
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/smile-picture
username: root
password: 123456
# Redis 配置
redis:
database: 1
host: 127.0.0.1
port: 6379
password: 123456
timeout: 5000
# Session 配置
session:
store-type: redis
# session 30 天后过期,单位是秒
timeout: 2592000
servlet:
multipart:
max-file-size: 10MB
max-request-size: 10MB
# 空间图片分表
shardingsphere:
datasource:
names: smile-picture
smile-picture:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/smile-picture
username: root
password: 123456
rules:
sharding:
tables:
picture:
actual-data-nodes: smile-picture.picture # 动态分表
table-strategy:
standard:
sharding-column: space_id
sharding-algorithm-name: picture_sharding_algorithm # 使用自定义分片算法
sharding-algorithms:
picture_sharding_algorithm:
type: CLASS_BASED
props:
strategy: standard
algorithmClassName: edu.whut.smilepicturebackend.manager.sharding.PictureShardingAlgorithm
props:
sql-show: true #打印实际执行的sql
mybatis-plus:
type-aliases-package: edu.whut.smilepicturebackend.model.entity
configuration:
# MyBatis 配置
map-underscore-to-camel-case: true #是否开启驼峰转换
# 仅在开发环境打印日志
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
global-config:
enable-sql-runner: true
db-config:
logic-delete-field: isDelete # 全局逻辑删除的实体字段名 删除操作时表中数据不会被物理删除只会设置isDelete为1
logic-delete-value: 1 # 逻辑已删除值(默认为 1
logic-not-delete-value: 0 # 逻辑未删除值(默认为 0
# 接口文档配置
knife4j:
enable: true
openapi:
title: 接口文档
version: 1.0
group:
default:
api-rule: package
api-rule-resources:
- edu.whut.smilepicturebackend.controller #接口地址
# 对象存储配置(需要从腾讯云获取)
cos:
client:
host: ${smile-picture.cos.client.host}
secretId: ${smile-picture.cos.client.secretId}
secretKey: ${smile-picture.cos.client.secretKey}
region: ${smile-picture.cos.client.region}
bucket: ${smile-picture.cos.client.bucket}
smile-picture:
aliyun:
apiKey: ${smile-picture.aliyun.apiKey}
active: prod,local