8.3 修复日期转换、long精度bug、公共空间图片删除bug,增加爬图随机性,批量上传图片时设默认标签

This commit is contained in:
zhangsan 2025-08-03 15:04:04 +08:00
parent 32191d2e7e
commit 1a02275023
17 changed files with 333 additions and 246 deletions

View File

@ -30,7 +30,7 @@ http {
proxy_pass http://picture_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
client_max_body_size 10M; # 允许上传 10MB
client_max_body_size 10M; # 允许上传 100MB
# 添加代理超时设置(单位:秒)
proxy_connect_timeout 600s;
proxy_send_timeout 600s;

View File

@ -1 +1 @@
import{_ as o,c as s,a as t,o as a}from"./index-CzJ9mX-e.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};
import{_ as o,c as s,a as t,o as a}from"./index-DXhv3sfF.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

View File

@ -6,8 +6,8 @@
<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-CzJ9mX-e.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-URLOKQOW.css">
<script type="module" crossorigin src="/assets/index-DXhv3sfF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DVYdPOkg.css">
</head>
<body>
<div id="app"></div>

View File

@ -1,6 +1,7 @@
package edu.whut.smilepicturebackend.config;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
@ -25,45 +26,30 @@ public class JacksonConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() {
return builder -> {
// 1) 忽略未知属性
builder.featuresToDisable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
// 2) 全局日期格式对于 Date 类型
builder.simpleDateFormat(DATETIME_FORMAT);
builder.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
// 3) Java 8 Time 类型的序列化/反序列化
JavaTimeModule javaTime = new JavaTimeModule();
javaTime.addSerializer(
LocalDateTime.class,
new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DATETIME_FORMAT))
);
javaTime.addSerializer(
LocalDate.class,
new LocalDateSerializer(DateTimeFormatter.ofPattern(DATE_FORMAT))
);
javaTime.addSerializer(
LocalTime.class,
new LocalTimeSerializer(DateTimeFormatter.ofPattern(TIME_FORMAT))
);
javaTime.addDeserializer(
LocalDateTime.class,
new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DATETIME_FORMAT))
);
javaTime.addDeserializer(
LocalDate.class,
new LocalDateDeserializer(DateTimeFormatter.ofPattern(DATE_FORMAT))
);
javaTime.addDeserializer(
LocalTime.class,
new LocalTimeDeserializer(DateTimeFormatter.ofPattern(TIME_FORMAT))
);
builder.modules(javaTime);
javaTime.addSerializer(LocalDateTime.class,
new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DATETIME_FORMAT)));
javaTime.addSerializer(LocalDate.class,
new LocalDateSerializer(DateTimeFormatter.ofPattern(DATE_FORMAT)));
javaTime.addSerializer(LocalTime.class,
new LocalTimeSerializer(DateTimeFormatter.ofPattern(TIME_FORMAT)));
javaTime.addDeserializer(LocalDateTime.class,
new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DATETIME_FORMAT)));
javaTime.addDeserializer(LocalDate.class,
new LocalDateDeserializer(DateTimeFormatter.ofPattern(DATE_FORMAT)));
javaTime.addDeserializer(LocalTime.class,
new LocalTimeDeserializer(DateTimeFormatter.ofPattern(TIME_FORMAT)));
// 4) 将所有 long / Long 类型序列化成 String
SimpleModule longToString = new SimpleModule();
longToString.addSerializer(Long.class, ToStringSerializer.instance);
longToString.addSerializer(Long.TYPE, ToStringSerializer.instance);
builder.modules(longToString);
longToString.addSerializer(Long.class, ToStringSerializer.instance);
longToString.addSerializer(Long.TYPE, ToStringSerializer.instance);
builder.modules(javaTime, longToString);
};
}
}

View File

@ -18,9 +18,6 @@ import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import java.util.List;
/**
* @author 程序员鱼皮 <a href="https://www.codefather.cn">编程导航原创项目</a>
*/
@Slf4j
@RestController
@RequiredArgsConstructor

View File

@ -29,9 +29,6 @@ import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;
/**
* @author 程序员鱼皮 <a href="https://www.codefather.cn">编程导航原创项目</a>
*/
@Slf4j
@RestController
@RequiredArgsConstructor

View File

@ -128,6 +128,7 @@ public abstract class PictureUploadTemplate {
// 设置压缩后的原图地址
uploadPictureResult.setUrl(cosClientConfig.getHost() + "/" + compressedCiObject.getKey());
uploadPictureResult.setName(FileUtil.mainName(originalFilename));
// 压缩后的原图大小
uploadPictureResult.setPicSize(compressedCiObject.getSize().longValue());
uploadPictureResult.setPicWidth(picWidth);
uploadPictureResult.setPicHeight(picHeight);

View File

@ -6,8 +6,6 @@ import java.io.Serializable;
/**
* 图片上传请求
*
* @author 程序员鱼皮 <a href="https://www.codefather.cn">编程导航原创项目</a>
*/
@Data
public class PictureUploadRequest implements Serializable {
@ -32,5 +30,10 @@ public class PictureUploadRequest implements Serializable {
*/
private Long spaceId;
/**
* 标签JSON 数组
*/
private String tags;
private static final long serialVersionUID = 1L;
}

View File

@ -1,8 +1,10 @@
package edu.whut.smilepicturebackend.model.dto.user;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDate;
import java.util.Date;
/**
@ -14,7 +16,7 @@ public class UserEditRequest implements Serializable {
/**
* 用户ID
*/
private Long Id;
private Long id;
/**
* 账号
@ -44,7 +46,7 @@ public class UserEditRequest implements Serializable {
/**
* 出生日期
*/
private Date birthday;
private LocalDate birthday;
/**
* 分享码

View File

@ -28,7 +28,7 @@ public class UploadPictureResult {
private String name;
/**
* 文件体积
* 文件体积单位字节bytes
*/
private Long picSize;

View File

@ -3,6 +3,7 @@ package edu.whut.smilepicturebackend.model.vo;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDate;
import java.util.Date;
/**
@ -41,6 +42,11 @@ public class UserVO implements Serializable {
*/
private String userAvatar;
/**
* 生日
*/
private LocalDate birthday;
/**
* 用户简介
*/

View File

@ -58,8 +58,11 @@ import javax.servlet.http.HttpServletRequest;
import java.awt.*;
import java.io.File;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
import java.util.stream.Collectors;
/**
@ -187,6 +190,10 @@ public class PictureServiceImpl extends ServiceImpl<PictureMapper, Picture>
uploadPictureResult.getName()
)
);
if (pictureUploadRequest.getTags() != null) {
String rawTags = pictureUploadRequest.getTags();
picture.setTags(normalizeTags(rawTags));
}
picture.setUserId(loginUser.getId());
picture.setSpaceId(spaceId);
// 转换为标准颜色
@ -304,26 +311,33 @@ public class PictureServiceImpl extends ServiceImpl<PictureMapper, Picture>
&& !userService.isAdmin(loginUser)) {
throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
}
// 校验权限已改为注解鉴权
// checkPictureAuth(loginUser, oldPicture);
// 开启事务
transactionTemplate.execute(status -> {
// 操作数据库
boolean result = this.removeById(pictureId);
ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR);
// 更新空间的使用额度释放额度
boolean update = spaceService.lambdaUpdate()
.eq(Space::getId, oldPicture.getSpaceId())
.setSql("total_size = total_size - " + oldPicture.getPicSize())
.setSql("total_count = total_count - 1")
.update();
ThrowUtils.throwIf(!update, ErrorCode.OPERATION_ERROR, "额度更新失败");
// 事务删除记录 + 私有空间额度更新
// 只有属于私有空间spaceId != null才更新额度
boolean txSuccess = Boolean.TRUE.equals(transactionTemplate.execute(status -> {
boolean removed = this.removeById(pictureId);
ThrowUtils.throwIf(!removed, ErrorCode.OPERATION_ERROR);
// 只有属于私有空间spaceId != null才更新额度
Long spaceId = oldPicture.getSpaceId();
if (spaceId != null) {
boolean update = spaceService.lambdaUpdate()
.eq(Space::getId, spaceId)
.setSql("total_size = total_size - " + oldPicture.getPicSize())
.setSql("total_count = total_count - 1")
.update();
ThrowUtils.throwIf(!update, ErrorCode.OPERATION_ERROR, "额度更新失败");
}
return true;
});
//清理图片资源
this.clearPictureFile(oldPicture);
}));
// 事务成功后再清理物理文件
if (Boolean.TRUE.equals(txSuccess)) {
this.clearPictureFile(oldPicture);
}
}
@Override
public void editPicture(PictureEditRequest pictureEditRequest, User loginUser) {
// 在此处将实体类和 DTO 进行转换
@ -448,69 +462,125 @@ public class PictureServiceImpl extends ServiceImpl<PictureMapper, Picture>
//爬取网落图片可以用ai分析标签
@Override
public Integer uploadPictureByBatch(PictureUploadByBatchRequest pictureUploadByBatchRequest, User loginUser) {
// 校验参数
// 1) 校验参数
String searchText = pictureUploadByBatchRequest.getSearchText();
Integer count = pictureUploadByBatchRequest.getCount();
ThrowUtils.throwIf(count > 30, ErrorCode.PARAMS_ERROR, "最多 30 条");
ThrowUtils.throwIf(StrUtil.isBlank(searchText), ErrorCode.PARAMS_ERROR, "关键词不能为空");
Integer count = ObjUtil.defaultIfNull(pictureUploadByBatchRequest.getCount(), 10);
ThrowUtils.throwIf(count <= 0 || count > 30, ErrorCode.PARAMS_ERROR, "数量范围应为 1 ~ 30");
// 名称前缀默认等于搜索关键词
String namePrefix = pictureUploadByBatchRequest.getNamePrefix();
if (StrUtil.isBlank(namePrefix)) {
namePrefix = searchText;
}
// 抓取内容
String fetchUrl = String.format("https://cn.bing.com/images/async?q=%s&mmasync=1", searchText);
//最全的html文档
// 2) 构造抓取 URL加入随机偏移 first扩大抓取条数以便去重 N 2 最多 50
int first = ThreadLocalRandom.current().nextInt(0, 201); // 0~200 的随机起始
int fetchSize = Math.min(Math.max(count * 2, count), 50);
String fetchUrl = String.format(
"https://cn.bing.com/images/async?q=%s&mmasync=1&first=%d&count=%d&setlang=zh-cn&mkt=zh-CN",
URLEncoder.encode(searchText, StandardCharsets.UTF_8), first, fetchSize
);
// 3) 抓取页面
Document document;
try {
document = Jsoup.connect(fetchUrl).get();
document = Jsoup.connect(fetchUrl)
.userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36") // 随机 UA 提升多样性
.header("Cache-Control", "no-cache")
.timeout(10_000)
.get();
} catch (IOException e) {
log.error("获取页面失败", e);
throw new BusinessException(ErrorCode.OPERATION_ERROR, "获取页面失败");
}
// 解析内容
Element div = document.getElementsByClass("dgControl").first();
if (ObjUtil.isEmpty(div)) {
// 4) 解析内容
Element container = document.selectFirst(".dgControl");
if (container == null) {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "获取元素失败");
}
Elements imgElementList = div.select("img.mimg");
// 遍历元素依次处理上传图片
Elements imgElements = container.select("img.mimg");
// 打乱顺序减少总是取到相同前几张
List<Element> candidates = new ArrayList<>(imgElements);
Collections.shuffle(candidates);
// 5) 本次任务内去重 URL
Set<String> seen = new HashSet<>();
// 6) 遍历并上传
int uploadCount = 0;
for (Element imgElement : imgElementList) {
// 找到最近的 <a class="iusc">,它的 m 属性里有完整的 JSON
Element a = imgElement.closest("a.iusc");
if (a == null || a.attr("m").isEmpty()) {
log.info("没找到 m 属性,跳过该图片");
for (Element img : candidates) {
// 优先从最近的 <a class="iusc"> 的 m 属性中取原图 murl
String fileUrl = null;
Element a = img.closest("a.iusc");
if (a != null && StrUtil.isNotBlank(a.attr("m"))) {
try {
JSONObject mObj = JSONUtil.parseObj(a.attr("m"));
fileUrl = mObj.getStr("murl"); // 原图地址
} catch (Exception ignore) {
// 解析失败走后备
}
}
// 后备 img 上取 src / data-src / data-src-hq
if (StrUtil.isBlank(fileUrl)) {
fileUrl = StrUtil.firstNonNull(
img.attr("src"),
img.attr("data-src"),
img.attr("data-src-hq"),
img.attr("data-url")
);
}
if (StrUtil.isBlank(fileUrl)) {
log.info("未获取到图片链接,跳过");
continue;
}
// Hutool 解析 JSON取出 murl
JSONObject mObj = JSONUtil.parseObj(a.attr("m"));
String fileUrl = mObj.getStr("murl"); // murl是带 .jpg/.png 的原图 , src是缩略图
// 可选去掉 URL 后面的 ? 及参数
int qm = fileUrl.indexOf("?");
if (qm > 0) {
fileUrl = fileUrl.substring(0, qm);
// 规范化 URL去掉 ? 之后的参数去空白
fileUrl = normalizeUrl(fileUrl);
// 本次任务内去重
if (!seen.add(fileUrl)) {
log.info("重复链接(本次任务),已跳过:{}", fileUrl);
continue;
}
// 上传图片
PictureUploadRequest pictureUploadRequest = new PictureUploadRequest();
pictureUploadRequest.setFileUrl(fileUrl);
pictureUploadRequest.setPicName(namePrefix + (uploadCount + 1));
// 7) 上传
PictureUploadRequest req = new PictureUploadRequest();
req.setFileUrl(fileUrl);
req.setPicName(namePrefix + (uploadCount + 1));
req.setTags(namePrefix);
try {
log.info("爬取图片url"+fileUrl);
PictureVO pictureVO = this.uploadPicture(fileUrl, pictureUploadRequest, loginUser);
log.info("爬取图片 url{}", fileUrl);
PictureVO pictureVO = this.uploadPicture(fileUrl, req, loginUser);
log.info("图片上传成功id = {}", pictureVO.getId());
uploadCount++;
} catch (Exception e) {
log.error("图片上传失败", e);
continue;
// 不中断继续下一张
}
if (uploadCount >= count) {
break;
}
}
return uploadCount;
}
/** 规范化图片 URL去掉查询参数trim 空白 */
private String normalizeUrl(String url) {
if (StrUtil.isBlank(url)) return url;
int i = url.indexOf('?');
if (i > -1) {
url = url.substring(0, i);
}
return url.trim();
}
/**
* 查询图片带缓存
* @param queryRequest
@ -749,6 +819,35 @@ public class PictureServiceImpl extends ServiceImpl<PictureMapper, Picture>
throw new BusinessException(ErrorCode.OPERATION_ERROR, "名称解析错误");
}
}
// 工具方法把任意传入的 tags 字符串规范为 JSON 数组字符串
private static String normalizeTags(String raw) {
if (cn.hutool.core.util.StrUtil.isBlank(raw)) {
return "[]";
}
String t = raw.trim();
try {
// 已经是 JSON 数组
if (t.startsWith("[")) {
// 校验一下能不能 parse
cn.hutool.json.JSONUtil.parseArray(t);
return t;
}
// 逗号/顿号/空白分隔中英文逗号都支持
String[] parts = t.split("[,,、\\s]+");
java.util.List<String> list = java.util.Arrays.stream(parts)
.map(String::trim)
.filter(cn.hutool.core.util.StrUtil::isNotBlank)
.distinct()
.collect(java.util.stream.Collectors.toList());
return cn.hutool.json.JSONUtil.toJsonStr(list);
} catch (Exception e) {
// 兜底当成一个元素
return cn.hutool.json.JSONUtil.toJsonStr(
java.util.Collections.singletonList(t));
}
}
}

View File

@ -222,6 +222,9 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User>
if (userObj == null) {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "未登录");
}
// 先从 session 拿出 userId 清掉 Sa-Token SPACE 会话
User user = (User) userObj;
StpKit.SPACE.logout(user.getId()); // 退出 Sa-Token SPACE 登录态
// 移除登录态
request.getSession().removeAttribute(UserConstant.USER_LOGIN_STATE);
return true;
@ -294,8 +297,6 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User>
user.setUserPassword(getEncryptPassword(userEditPasswordRequest.getNewPassword()));
boolean result = this.updateById(user);
if (result) {
// 移除登录态
request.getSession().removeAttribute(UserConstant.USER_LOGIN_STATE);
return;
}
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "密码修改失败!");
@ -304,9 +305,7 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User>
@Override
public void editUser(UserEditRequest userEditRequest,HttpServletRequest request) {
boolean existed= this.getBaseMapper()
.exists(new QueryWrapper<User>()
.eq("id", userEditRequest.getId())
);
.exists(new QueryWrapper<User>().eq("id", userEditRequest.getId()));
if (!existed) {
throw new BusinessException(ErrorCode.NOT_FOUND_ERROR, "用户不存在!");
}
@ -315,8 +314,6 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User>
boolean result = this.updateById(user);
if (result) {
// 移除登录态
request.getSession().removeAttribute(UserConstant.USER_LOGIN_STATE);
return;
}
throw new BusinessException(ErrorCode.SYSTEM_ERROR, "编辑失败!");
@ -339,8 +336,6 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User>
if (!result) {
throw new BusinessException(ErrorCode.OPERATION_ERROR, "头像更新失败");
}
// 移除登录态
// request.getSession().removeAttribute(UserConstant.USER_LOGIN_STATE);
return avatarUrl;
}
@ -359,7 +354,8 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User>
String random = RandomUtil.randomString(6);
user.setUserAccount("user_" + random);
user.setUserName("用户_" + random);
user.setUserRole(UserRoleEnum.USER.getText());
user.setUserRole(UserRoleEnum.USER.getValue());
user.setUserProfile("这个人很懒,什么也没留下!");
user.setUserPassword(getEncryptPassword("12345678"));
}

View File

@ -13,14 +13,14 @@ spring:
# 数据库配置
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:13308/smile-picture
url: jdbc:mysql://localhost:3306/smile-picture
username: root
password: 123456
# Redis 配置
redis:
database: 1
host: localhost
port: 36380
port: 6379
password: 123456
timeout: 5000
# Session 配置
@ -39,7 +39,7 @@ spring:
smile-picture:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:13308/smile-picture
url: jdbc:mysql://localhost:3306/smile-picture
username: root
password: 123456
rules: