Git连接以及提交

要将本地代码连接到远程仓库(例如 https://github.com/TECNB/StarGazerBackend.git),你可以按照以下步骤在终端中操作:

  1. 初始化本地仓库(如果还没有):
    如果你的代码还没有在一个 Git 仓库中,你需要先在代码的根目录中初始化一个 Git 仓库。

    1
    git init
  2. 添加远程仓库
    使用以下命令将远程仓库链接到本地仓库:

    1
    git remote add origin https://github.com/TECNB/StarGazerBackend.git
  3. 检查远程仓库是否添加成功
    你可以使用以下命令查看当前添加的远程仓库列表,以确保添加正确:

    1
    git remote -v
  4. 添加文件到暂存区
    使用以下命令将所有更改添加到暂存区:

    1
    git add .
  5. 提交更改
    使用以下命令提交更改到本地仓库:

    1
    git commit -m "chore(setup): 初始化项目"
  6. 推送到远程仓库
    使用以下命令将本地仓库的代码推送到远程仓库的 main 分支(如果是 master 分支,则将 main 改为 master):

    1
    git push -u origin main

如果没有设置默认分支为 mainmaster,请先确认远程仓库的默认分支名称,然后替换命令中的分支名。

完成这些步骤后,你的本地代码就会推送到 https://github.com/TECNB/StarGazerBackend.git 仓库中了。

1
chore(setup): 初始化项目

JAVA项目初始化

pom.xml

关键是不同版本之间的兼容问题,一般还是MP的问题,改依赖后,再启动,看是否报错就可以判断依赖是否正确

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.2</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.tec</groupId>
<artifactId>StarGazerBackend</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>StarGazerBackend</name>
<description>StarGazerBackend</description>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>3.0.3</version>
</dependency>

<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-boot-starter</artifactId>
<version>3.0.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter-test</artifactId>
<version>3.0.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.5</version>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>

</project>

application.yml

1
2
3
4
5
6
7
8
9
10
11
server:
port: 8383
spring:
application:
name: stargazerbackend

datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/stargazer?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true
username: root
password: xxxxxxx

MP的代码生成器使用方法

利用插件MyBatisPlus(二次元头像的那个)

之后会在顶部栏出现Other这个选项,在子菜单Config Database中配置好数据库连接信息

最后在Code Generator中选择具体表以及对应的代码配置,这里踩过一个坑module处不用填留空,否则代码

成的位置不对

通用响应类 R

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package com.tec.stargazerbackend.common;

import lombok.Data;

@Data
public class R<T> {
private int code; // 状态码
private String message; // 消息
private T data; // 返回数据

public R() {}

public R(int code, String message, T data) {
this.code = code;
this.message = message;
this.data = data;
}

public static <T> R<T> ok(T data) {
return new R<>(200, "Success", data);
}

public static <T> R<T> error(int code, String message) {
return new R<>(code, message, null);
}

public static <T> R<T> error(String message) {
return new R<>(500, message, null);
}
}

接口测试

1
2
3
4
5
6
7
8
9
10
11
12
13
@RestController
@RequestMapping("/users")
public class UsersController {
@Resource
private IUsersService usersService;

// 实现获取users表中所有数据的接口
@GetMapping("/all")
public R<List<Users>> getAll() {
List<Users> usersList = usersService.list();
return R.ok(usersList);
}
}

参数校验(⚠️)

参考链接:【禁止血压飙升】如何拥有一个优雅的 controller见过几千行代码的 controller吗?我见过。 见过全是 t - 掘金 (juejin.cn)

外键设置

外键主要是为了能够在数据库端就进行同步的更新以及删除,同时进行数据正确性的判断

选择外键约束类型

主要取决于你的业务需求和数据完整性要求。以下是对常见约束类型的建议使用场景:

  1. CASCADE

    • 适用场景: 当删除或更新父表中的记录时,确实希望子表中的相关记录也自动删除或更新时使用。例如,删除用户时自动删除该用户的所有订单记录。
    • 使用建议:
      • 如果你的数据是高度关联的,并且在删除或更新父表记录时,不希望遗留任何相关的子表记录,可以使用 CASCADE
      • 需要注意的是,使用 CASCADE 可能会导致意外删除大量数据,因此需要谨慎。
  2. SET NULL

    • 适用场景: 当删除或更新父表中的记录时,子表中的相关记录应保留,但将外键值设为 NULL。适用于可选关联的场景。
    • 使用建议:
      • 当子表记录应该继续存在,但与父表的关联可以解除时使用 SET NULL。例如,一个产品可能会被分类,但当分类被删除时,产品仍然存在,只是没有分类。
      • 确保外键列允许 NULL 值。
  3. RESTRICTNO ACTION

    • 适用场景: 当删除或更新父表中的记录时,强制确保没有相关子表记录,避免孤立数据。
    • 使用建议:
      • 当你希望严格控制数据删除或更新行为,确保不会因为删除或更新父表记录而破坏数据的完整性时使用 RESTRICTNO ACTION
      • 适用于数据之间的关系非常重要,并且不能轻易删除或更新父表记录的场景,比如订单和客户关系。
  4. 不使用外键约束

    • 适用场景: 某些场景下,可能选择不使用外键约束,依靠应用程序逻辑来保证数据完整性。
    • 使用建议:
      • 在高并发系统中,为了提高性能,有时会放弃外键约束,改为通过应用逻辑手动管理数据的完整性。
      • 不建议在大多数情况下完全依赖应用逻辑,除非你非常清楚可能的风险和后果。

总结建议

  • 重要且需要自动清理关联数据:使用 CASCADE
  • 需要保留子表记录但解除关联:使用 SET NULL
  • 严格限制删除和更新行为:使用 RESTRICTNO ACTION
  • 高性能或特殊场景:谨慎地选择不使用外键约束。

在大多数典型场景中,RESTRICT 是一个安全的默认选择,因为它强制了数据的完整性,防止意外的数据删除或更新。

外键设置位置:

参考链接:外键列到底要建在哪里?_外键应该添加在哪个表上?-CSDN博客

(1)1:1的关系。我们知道1对1的关系一般都有一个主、一个附,一般我们把外键创建在附表上。(比如订单和订单详情,订单表是主表、订单详情表是附表,外键建在订单详情表中。)

(2)1:n的关系。把外键建立在n的那张表上。如果将外键建在1的上面会造成数据冗余。不明白的小伙伴打开你的Excel画个图就明白了。

(3)n:m的情况,需要建立一个关系表(中间表),两个原表和其关系分别是1:n,1:m。用第三张表去维护原来两张表之间的关系。

多对一外键查询

下面的 Service 层和 mapper 层的两种方式对比确实是正确的,但是实际操作下来,发现很多情况都是 Service 层已经能够直接通过简洁的方式去避免sql,并且性能是一样的,

主要在很复杂的sql查询中,才会出现性能的大差别,同时在相当大一部分场景里是局限性很高的,比如说LEFT JOIN,能够把两个表,连起来去进行查询,减少了时间,但是返回的数据也会随着多表的查询去进行重复,再去处理数据很麻烦,

所以后续还是尽量少去mapper层去动sql,一来麻烦局限大,二来可以在 Service 层用各种技巧去优化性能,比如减少返回数量,进行分页等等

在 Service 层通过 MyBatis-Plus 提供的方法进行两次查询,然后手动组装数据的方式,与在 DiaryMapper 中直接操作相比,各有优缺点。以下是两者的对比:

Service 层手动组装的优缺点

优点:

  1. 清晰的分层设计

    • 业务逻辑清晰分层,Mapper 层专注于数据访问,而业务逻辑放在 Service 层。这样可以保持 Mapper 层的简洁和可维护性。
  2. 灵活性更高

    • Service 层手动组装数据,意味着你可以在业务逻辑中灵活处理数据和关系,而不需要将复杂的逻辑写在 Mapper 层。可以方便地进行一些业务规则的应用。
  3. 降低复杂性

    • 复杂的 SQL 查询和数据组装逻辑被分解成更小的单元处理,可以更容易理解和调试。

缺点:

  1. 效率较低

    • 需要进行两次数据库查询(一次查询 Diary,一次查询 DiaryImages),而在 Mapper 中通过 JOIN 查询可以一次性获取所有相关数据,这在数据量大时会影响性能。
  2. 多次查询可能导致一致性问题

    • 如果在两次查询之间有并发数据修改,可能导致数据不一致。尽管这个问题不常见,但在高并发环境下可能需要注意。
  3. 增加网络通信成本

    • 如果数据库和应用服务器之间的网络通信成本较高,两次查询会增加通信的开销。

DiaryMapper 中直接操作的优缺点

优点:

  1. 性能较高

    • 可以通过 JOIN 查询一次性获取 DiaryDiaryImages 的数据,减少数据库查询次数,提升性能。
  2. 数据一致性更好

    • 通过单一查询获取数据,可以避免多次查询带来的数据不一致问题,特别是在高并发环境下。
  3. 减少网络通信成本

    • 一次查询获取全部数据,减少了数据库和应用服务器之间的通信次数。

缺点:

  1. 增加了 Mapper 层的复杂性

    • 如果在 Mapper 层编写复杂的 SQL 查询和结果映射逻辑,可能会使 Mapper 层的代码变得难以维护。
  2. 降低代码可读性

    • 复杂的查询和结果映射逻辑可能会导致代码难以理解,特别是当查询逻辑涉及多个关联表时。
  3. 减少了灵活性

    • 将复杂逻辑放在 Mapper 层,可能会降低业务逻辑处理的灵活性。在需要修改业务逻辑时,可能需要调整 Mapper 层代码。

总结

选择哪种方法取决于你的应用场景:

  • 如果性能是关键:在 DiaryMapper 中编写 JOIN 查询可能更好。
  • 如果你更关注代码的可维护性和清晰的分层设计:在 Service 层手动组装数据可能更适合。

对于大多数情况,在 Service 层手动组装数据的方式更符合现代应用的分层设计原则,但在性能要求非常高或数据一致性至关重要的场景下,直接在 Mapper 中操作可能是更好的选择。

为了具体比较在 Service 层进行多次查询与在 Mapper 中进行联合查询的性能差异,我们可以通过一个假设的场景来进行比较。以下是一些示例数字和假设:

假设场景

  • 日记总数:100
  • 每篇日记的图片总数:最多 10 张
  • 单次查询响应时间:10 毫秒(对于数据库简单查询)
  • 复杂查询响应时间:50 毫秒(对于联合查询)
  1. 在 Service 层进行多次查询

步骤

  1. 查询所有日记
  2. 对于每篇日记,再查询其关联的图片

计算

  • 查询所有日记的响应时间:1 次查询 × 10 毫秒 = 10 毫秒
  • 对于每篇日记查询其图片:100 次查询 × 10 毫秒 = 1000 毫秒

总时间

  • 10 毫秒(获取日记) + 1000 毫秒(获取图片) = 1010 毫秒
  1. Mapper 中进行联合查询

步骤

  1. 使用 JOIN 查询一次性获取所有日记和其关联的图片

计算

  • 联合查询响应时间:1 次查询 × 50 毫秒 = 50 毫秒

性能比较

Service 层多次查询

  • 总时间 = 1010 毫秒

Mapper 层联合查询

  • 总时间 = 50 毫秒

结论

从以上示例中可以看出,在这种假设情况下,Service 层进行多次查询的总响应时间是 1010 毫秒,而在 Mapper 中进行联合查询的总响应时间是 50 毫秒。这表明,联合查询可以显著提高性能,尤其是在数据量较大时。

影响因素

  1. 数据量:数据量越大,多次查询的性能差距越明显。
  2. 数据库性能:数据库的处理能力和优化会影响查询响应时间。
  3. 网络延迟:如果数据库和应用服务器之间的网络延迟较高,多次查询的开销会更大。

实际测试

为了获得准确的性能数据,建议在你的具体环境中进行实际的性能测试。你可以使用数据库的性能分析工具来测量查询时间,并根据实际数据量和查询复杂性来评估性能差异。

用户功能

密码加盐

首先是为什么要密码加盐,是为了防止数据库泄露或者前端返回属性被劫持,下面的1、2、3种方式都可以直接或者间接被反向计算出来(例如彩虹表),而第4种方式,也就是目前采取的较为安全的方式,将密码与随机salt相加后,又进行多次hash计算,即使获取到salt以及password,反向计算也十分困难

用户密码加密方式

用户密码保存到数据库时,常见的加密方式有哪些?以下几种方式是常见的密码保存方式:

1. 明文保存

比如用户设置的密码是“123456”,直接将“123456”保存在数据库中,这种是最简单的保存方式,也是最不安全的方式。但实际上不少互联网公司,都可能采取的是这种方式。

2. 对称加密算法来保存

比如3DES、AES等算法,使用这种方式加密是可以通过解密来还原出原始密码的,当然前提条件是需要获取到密钥。不过既然大量的用户信息已经泄露了,密钥很可能也会泄露,当然可以将一般数据和密钥分开存储、分开管理,但要完全保护好密钥也是一件非常复杂的事情,所以这种方式并不是很好的方式。

3. MD5、SHA1等单向HASH算法

使用这些算法后,无法通过计算还原出原始密码,而且实现比较简单,因此很多互联网公司都采用这种方式保存用户密码,曾经这种方式也是比较安全的方式,但随着彩虹表技术的兴起,可以建立彩虹表进行查表破解,目前这种方式已经很不安全了。

其实之前公司也是采用的这种MD5加密方式。

4. PBKDF2算法

该算法原理大致相当于在HASH算法基础上增加随机盐,并进行多次HASH运算,随机盐使得彩虹表的建表难度大幅增加,而多次HASH也使得建表和破解的难度都大幅增加。

在使用PBKDF2算法时,HASH一般会选用sha1或者sha256,随机盐的长度一般不能少于8字节,HASH次数至少也要1000次,这样安全性才足够高。一次密码验证过程进行1000次HASH运算,对服务器来说可能只需要1ms,但对于破解者来说计算成本增加了1000倍,而至少8字节随机盐,更是把建表难度提升了N个数量级,使得大批量的破解密码几乎不可行,该算法也是美国国家标准与技术研究院推荐使用的算法。

Utils:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class Utils {

// 使用SHA-512算法加密密码
public static String encryptHv(String password, String salt) throws NoSuchAlgorithmException {
MessageDigest hash = MessageDigest.getInstance("SHA-512");
hash.update(salt.getBytes());
hash.update(password.getBytes());
byte[] value = hash.digest();

// 重复512次,使散列更安全
for (int i = 0; i < 512; i++) {
MessageDigest hashInner = MessageDigest.getInstance("SHA-512");
hashInner.update(value);
value = hashInner.digest();
}

return Base64.getEncoder().encodeToString(value);
}

// 生成盐
public static String generateSalt() {
String characters = "abcdefghijklmnopqrstuvwxyz0123456789";
StringBuilder objectId = new StringBuilder();
SecureRandom random = new SecureRandom();

for (int i = 0; i < 48; i++) {
int randomIndex = random.nextInt(characters.length());
objectId.append(characters.charAt(randomIndex));
}

return objectId.toString();
}
}

UsersController:

  1. 完成注册功能的实现
    1. 获取到前端的password以及name
    2. 生成盐算法实现
    3. 加密密码算法实现,接受参数包括密码以及盐
    4. 将获取到的password以及salt,通过加密密码算法后的值,存入数据库的password中
  2. 完成登陆功能的实现
    1. 获取token
    2. 通过加密密码算法获取到password,然后与数据库中的进行比对,存在则createLoginSession更新token,以及lastLogin
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
@RestController
@RequestMapping("/users")
public class UsersController {
@Resource
private IUsersService usersService;

// 注册功能
@PostMapping("/register")
public R<Users> register(@RequestParam String username, @RequestParam String password) throws NoSuchAlgorithmException {
// 如果存在该用户,返回错误信息
if (usersService.getByUsername(username) != null) {
throw new CustomException(USER_ALREADY_EXISTS);
// return R.error("用户已存在");
}
Users user = new Users();
// 生成盐
String salt = generateSalt();

// 生成哈希值
String hv = encryptHv(password, salt);

user.setUsername(username);
user.setPasswordHash(hv);
user.setSalt(salt);
usersService.save(user);
// 返回完整用户信息
return R.ok(user);
}

// 登录功能
@PostMapping("/login")
public R<Users> login(String username, String password) throws NoSuchAlgorithmException{
Users user = usersService.getByUsername(username);
if (user == null) {
return R.error("用户不存在");
}
String salt = user.getSalt();
String hv = user.getPasswordHash();
if (hv.equals(encryptHv(password, salt))) {
user.setToken(StpUtil.createLoginSession(user.getUserId()));
user.setLastLogin(getCurrentTime());
usersService.updateById(user);
return R.ok(user);
} else {
// 通过CustomException丢出报错
throw new CustomException(PASSWORD_ERROR);
// return R.error(202,"密码错误");
}
}
}

密码轮换

从超的项目中学到的,通过KeyRotation记录是否需要换密钥以及去先进行旧密钥解密的尝试,就直接无痛密码轮换了

问题是需要靠用户操作的时候去轮换,要是长时间没有登录的用户,密钥又泄了,那就会导致这一部分的用户的密码被反向推理出来

下面为kotlin实现的方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// Get user by session token
suspend fun getUserBySessionToken(sessionToken: String): User = withContext(Dispatchers.IO) {
var keyRotation = false

// Try KEY_A and KEY_B to decrypt session token
val decryptData: String = try {
decrypt(sessionToken, string2Key(KEY_A))
} catch (e: BadPaddingException) {
try {
val result = decrypt(sessionToken, string2Key(KEY_B))
keyRotation = true
result
} catch (e: BadPaddingException) {
throw UserNotFoundException()
}
}

// Get user from decrypted token
val id = decryptData.split(",")[0]
val doc = collection.find(Filters.eq("_id", ObjectId(id))).first()
?: throw UserNotFoundException()

val user = doc.let(User::fromDocument)

// Check session token is available
if (user.sessionToken != sessionToken) {
throw UserNotFoundException()
}

// Check if key rotation is needed
if (keyRotation) {
val encryptData = "${id},${System.currentTimeMillis()}"
val newSessionToken = encrypt(encryptData, string2Key(KEY_A))

collection.updateOne(
Filters.eq("_id", ObjectId(id)),
Updates.set("sessionToken", newSessionToken)
)

user.sessionToken = newSessionToken
}
user
}

匹配功能

feat(MatchesController):完成正常匹配的逻辑

  • 先是根据token获取到当前user的信息,主要是城市以及岁数
  • 根据城市以及岁数相差不大作为依据查询出潜在匹配用户列表
  • 最后根据兴趣爱好标签一致进行匹配过滤,输出最终匹配列表

MatchesServiceImpl:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
@Service
public class MatchesServiceImpl extends ServiceImpl<MatchesMapper, Matches> implements IMatchesService {

@Autowired
private UsersMapper usersMapper;

@Autowired
private MatchesMapper matchesMapper;

@Autowired
private UserInterestsMapper userInterestsMapper;

@Override
/**
* 根据用户的兴趣爱好标签、地理位置和年龄进行匹配
* @param userId 当前用户ID
* @return 匹配到的用户列表
*/
public List<Users> matchUsers(String userId) {
// 获取当前用户的兴趣标签
List<String> userTags = getUserTags(userId);
System.out.println("userTags: " + userTags);

// 获取当前用户的基本信息
Users currentUser = usersMapper.selectById(userId);
System.out.println("currentUser: " + currentUser);
String currentUserLocation = currentUser.getCity();
System.out.println("currentUserLocation: " + currentUserLocation);
Integer currentUserAge = currentUser.getAge();
System.out.println("currentUserAge: " + currentUserAge);

// 查询潜在匹配用户
List<Users> potentialMatches = usersMapper.selectList(new QueryWrapper<Users>()
.ne("user_id", userId) // 排除当前用户
.eq("city", currentUserLocation) // 相同城市
.between("age", currentUserAge - 3, currentUserAge + 3)); // 年龄范围内
System.out.println("potentialMatches: " + potentialMatches);

// 根据兴趣爱好标签进行匹配过滤
return potentialMatches.stream()
.filter(user -> hasCommonTags(userTags, getUserTags(user.getUserId())))
.limit(2) // 限制同时匹配的用户数量
.collect(Collectors.toList());
}

/**
* 获取用户的兴趣标签
* @param userId 用户ID
* @return 兴趣标签列表
*/
private List<String> getUserTags(String userId) {
return userInterestsMapper.selectList(new QueryWrapper<UserInterests>()
.eq("user_id", userId))
.stream()
.map(UserInterests::getTag)
.collect(Collectors.toList());
}

/**
* 检查两个用户是否有共同的兴趣标签
* @param tags1 第一个用户的标签列表
* @param tags2 第二个用户的标签列表
* @return 是否有共同标签
*/
private boolean hasCommonTags(List<String> tags1, List<String> tags2) {
for (String tag : tags1) {
if (tags2.contains(tag)) {
return true;
}
}
return false;
}

}

聊天室功能

Netty 使用

MinIO 作为OSS

RocketMQ 作为数据缓冲

创建时间以及更新时间的问题

直接在实体类中把创建时间以及更新时间赋值为getCurrentTime即可

因为只有在第一次注册的时候会创建全新的user类,其他情况都是从数据库中获取的,需要更新更新时间时再进行getCurrentTime即可

这样的解决方案要简单高效,之前用的是MP的全局属性注入,不仅麻烦而且会出现无法属性赋值的相关问题

User:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("users")
@ApiModel(value="Users对象", description="")
public class Users implements Serializable {

private static final long serialVersionUID = 1L;

@TableId(value = "user_id", type = IdType.ASSIGN_UUID)
private String userId;

private String createdAt = getCurrentTime();

private String updatedAt = getCurrentTime();
}

服务器git clone速度太慢

方法一:

使用本地电脑去克隆好后再传到服务器上面去,缺点为遇到大量小文件可能会比较慢

方法二:

参考文章:git clone加速(实测推荐)_git clone 加速-CSDN博客

在git仓库前添加gitclone.com的前缀,比如下面的命令

缺点为我还没试过这个方法行不行😗

1
2
git clone https://github.com/Elegycloud/clash-for-linux-backup.git
git clone https://gitclone.com/github.com/Elegycloud/clash-for-linux-backup.git

Linux服务器系统选择

以下是按不同Linux服务器系统版本分类的介绍:

  1. Ubuntu Server

Ubuntu Server 是一个流行且易于使用的Linux服务器操作系统,适合多种场景,包括Web服务器、开发环境和云计算。它有两个主要版本:

  • LTS (Long Term Support) 版本: 每两年发布一次,提供5年的长期支持,适合需要长期稳定的生产环境。
  • 非LTS版本: 每六个月发布一次,适合开发和测试环境,能够快速获取新功能。

特点:

  • 丰富的社区支持和文档。
  • 使用 APT 作为包管理器,易于软件更新和维护。
  • 广泛的云平台支持,包括AWS、Azure和Google Cloud。
  1. CentOS

CentOSRed Hat Enterprise Linux (RHEL) 的免费社区版,广泛用于企业级环境。CentOS 强调稳定性和兼容性,特别适合企业的生产服务器。

版本:

  • CentOS 7: 长期支持版本,稳定性强,支持到2024年。
  • CentOS 8: 引入了 DNF 作为新包管理器,但已经被 CentOS Stream 替代。

特点:

  • 企业级稳定性,适合长时间运行的生产环境。
  • 社区提供了许多与 RHEL 兼容的资源和文档。
  • 采用 YUMDNF 进行软件包管理。
  1. Red Hat Enterprise Linux (RHEL)

RHEL 是由Red Hat公司提供的企业级Linux发行版,特别适合需要高可靠性和技术支持的大型企业。RHEL 提供付费订阅,涵盖了技术支持和安全更新。

版本:

  • RHEL 7: 稳定的企业级版本,提供长期支持,适合稳定的生产环境。
  • RHEL 8: 更现代化的企业操作系统,支持云计算和容器化。

特点:

  • 企业级支持和安全性,包含专业的技术支持服务。
  • 强大的生态系统和硬件兼容性。
  • 使用 YUMDNF 管理软件包。
  1. Debian

Debian 是一个高度稳定且轻量的Linux发行版,适合需要可靠性的服务器和开发环境。Debian 以其稳定性和灵活性而著称,常用于基础设施和Web服务器。

版本:

  • Stable: 这是Debian的主要版本,专注于稳定性,适合生产环境。
  • Testing: 包含即将在下一版本发布的软件包,适合需要较新软件但仍需要稳定性的用户。
  • Unstable (Sid): 包含最新的软件包和功能,适合开发和测试环境。

特点:

  • 高度稳定和安全,适合长时间运行的服务。
  • 丰富的软件包和社区支持。
  • 使用 APT 进行包管理,软件更新简单。
  1. Arch Linux

Arch Linux 是一个滚动发行的Linux发行版,特别适合需要最新软件包和高度定制的用户。尽管它主要被用于个人桌面,但也有一些高级用户在服务器上使用。

特点:

  • 滚动更新机制,始终保持最新的软件和功能。
  • 高度可定制,适合有经验的用户。
  • 轻量级,没有多余的预装软件。

通过上述分类和介绍,您可以根据需求选择最适合您的Linux服务器系统版本。

Linux服务器利用Clash访问外网

源码:Elegycloud/clash-for-linux-backup: 基于Clash Core 制作的Clash For Linux备份仓库 A Clash For Linux Backup Warehouse Based on Clash Core (github.com)

下载项目

下载项目

1
git clone https://github.com/Elegycloud/clash-for-linux-backup.git

进入到项目目录,编辑.env文件,修改变量CLASH_URL的值。

1
2
3
4
cd clash-for-linux-backup
vim .env
https://9oxxk.no-mad-world.club/link/8xxxxx?clash=3&extend=1
https://dkcru.no-mad-world.club/link/8xxxxx?clash=3&extend=1

注意: .env 文件中的变量 CLASH_SECRET 为自定义 Clash Secret,值为空时,脚本将自动生成随机字符串,可以自己输入对应Secret,避免每次重新生成。

启动程序

直接运行脚本文件start.sh

  • 进入项目目录
1
2
cd clash-for-linux-backup
chmod 777 start.sh
  • 运行启动脚本

注意export https_proxy=http://127.0.0.1:7890 http_proxy=http://127.0.0.1:7890 all_proxy=socks5://127.0.0.1:7890就相当于这个proxy_on,不过根据超来说,最好不要开这个全局的代理,对应的需要外网的软件,就进行对应的配置,比如docker就需要自己进行配置,具体会在下面的笔记中提到

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
sudo ./start.sh

正在检测订阅地址...
Clash订阅地址可访问! [ OK ]

正在下载Clash配置文件...
配置文件config.yaml下载成功! [ OK ]

正在启动Clash服务...
服务启动成功! [ OK ]

Clash Dashboard 访问地址:http://<ip>:9090/ui
Secret:xxxxxxxxxxxxx

请执行以下命令加载环境变量: source /etc/profile.d/clash.sh

请执行以下命令开启系统代理: proxy_on

若要临时关闭系统代理,请执行: proxy_off
  • 验证是否外网代理是否成功

注意下面的命令中必须加上-x http://127.0.0.1:7890,否则正常来说curl包括ping都是不走系统代理的,下面的命令输完后,就可以去UI面板看看,流量是否走代理了

1
curl -x http://127.0.0.1:7890 https://www.google.com.hk

UI面板

步骤

地址:101.43.00.000:9090 - yacd

API Base URL填入http://101.43.00.000:9090,密码为启动时控制台打印的Secret,注意这里的Secret需要填入.env文件中的CLASH_SECRET,这样就不用每次启动都重新更换密码了

BUG

输入API Base URL时,出现报错Unauthorized

参考链接:Unauthorized for login · Issue #46 · Elegycloud/clash-for-linux-backup (github.com)

重新启动就行,把之前的记录和端口占用都先kill掉

1
./restart.sh

再次启动订阅事报错Failed connect to 127.0.0.1:7890

参考链接:curl: (7) Failed connect to 127.0.0.1:7890; Connection refused_curl: (7) failed connect to 127.0.0.1:7890; connec-CSDN博客

完整报错

1
curl: (7) Failed connect to 127.0.0.1:7890; Connection refused

原因

端口7890就是clash搞的鬼,因为代理了全局的网络,clash再去通过网络拉取配置文件的时候,走了代理,导致无法连接下载配置文件

解决方法

执行下面的命令

1
export -p

最下面有一行(如果不是这样,另外自己找办法,不要乱试!不要乱试!不要乱试!)

declare -x all_proxy="socks5://127.0.0.1:7890

执行命令

1
unset all_proxy

再执行第一条命令查看 那行代理就没了

一打开clash,就连接服务器卡顿

原因是被盗刷流量了

存在脚本一直在腾讯云的网段范围里面,去暴力尝试3306端口的连接,我因为之前对于腾讯云面板的防火墙了解不清楚,以为是正对内网的防火墙,如果不开的话,内部就无法连接3306端口

实际上腾讯云面板的防火墙是针对外网对于服务器的访问的,内部则其实是通过iptables或者firewalld来进行访问的静止,实际上会先经过腾讯云面板的网关防火墙,然后再进入内部的防火墙

Linux服务器下载Docker以及Docker-Compose

Docker下载

镜像站:docker-ce | 镜像站使用帮助 | 清华大学开源软件镜像站 | Tsinghua Open Source Mirror

通过上面的镜像站找到对应的版本,然后复制命令,进行Docker CE(Community Edition) Docker 社区版的下载

Debian/Ubuntu/Raspbian 用户

如果你过去安装过 docker,先删掉:

1
for pkg in docker.io docker-doc docker-compose podman-docker containerd runc; do apt-get remove $pkg; done

首先安装依赖:

1
2
apt-get update
apt-get install ca-certificates curl gnupg

信任 Docker 的 GPG 公钥并添加仓库:

发行版Debian

1
2
3
4
5
6
7
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://mirrors.tuna.tsinghua.edu.cn/docker-ce/linux/debian \
"$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" | \
tee /etc/apt/sources.list.d/docker.list > /dev/null

最后安装

1
2
apt-get update
apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Docker-Compose下载

在安装完 Docker CE 后,你可以通过 apt 包管理器安装 docker-compose。以下是步骤:

  1. 更新APT包索引

    1
    sudo apt update
  2. **安装docker-compose**:
    你可以直接通过 apt 安装 docker-compose

    1
    sudo apt install docker-compose
  3. 验证安装
    安装完成后,你可以通过以下命令验证是否安装成功:

    1
    docker-compose --version

    如果显示版本信息,则表示安装成功。

配置代理

参考链接:如何优雅的给 Docker 配置网络代理 - CharyGao - 博客园 (cnblogs.com)

总的来说就是去docker的配置的文件中去加入配置代理的代码,比较麻烦的在于每个系统,它代理文件的位置啊,名字啊都有些许不同,需要自己找到常用的docker文件位置,然后自己排查,找到自己这个系统的docker配置文件位于哪里

下面以Debian系统为例,演示配置代理过程

找到/etc/systemd/system/docker.service.d/proxy.conf文件,如果没有的话运行下面的命令进行创建

1
2
sudo mkdir -p /etc/systemd/system/docker.service.d
sudo touch /etc/systemd/system/docker.service.d/proxy.conf
1
2
3
4
[Service]
Environment="HTTP_PROXY=http://127.0.0.1:7890/"
Environment="HTTPS_PROXY=http://127.0.0.1:7890/"
Environment="NO_PROXY=localhost,127.0.0.1"

BUG

下载docke时报错Problem: cannot install the best candidate for the job

原因

由于服务器使用的是腾讯云自家的系统,所以存在版本不适配的问题,下载工具yum找不到对应的

解决方法:

目前我的解决方法是直接换系统

docker拉镜像的时候没有走代理

docker需要单独配置代理,单单使用export https_proxy=http://127.0.0.1:7890 http_proxy=http://127.0.0.1:7890 all_proxy=socks5://127.0.0.1:7890是无法指定docker走代理的,具体配置代理流程见上面的笔记

宝塔下载问题

不要通过宝塔面板去下载docker,因为宝塔下载的docker,最后是不知道配置文件在什么位置的

JAVA的Docker部署方法(⚠️)

stargazer- docker-compose-app.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
version: '3.8'

services:
stargazer-backend:
image: tecnb/stargazer-backend
container_name: stargazer-backend
ports:
- "8080:8080"
depends_on:
- db
networks:
- app-network

networks:
app-network:
external: true

stargazer-docker-compose-env.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
version: '3.8'

services:
db:
image: mysql:8.1
container_name: mysql-db
ports:
- "3306:3306"
environment:
MYSQL_ROOT_PASSWORD: your_password
MYSQL_DATABASE: your_db_name
volumes:
- mysql_data:/var/lib/mysql
networks:
- app-network

networks:
app-network:
driver: bridge

经过超的开导,决定还是使用单个compose文件的方式并且规定命名为docker-compose.yml,因为只有固定的几个名字,比如docker-compose.yml, docker-compose.yaml, compose.yml, compose.yaml,可以直接docker compose up,而不用通过-f加上对应的配置的文件名,这样更方便快速

docker-compose.yml

之前还有一个BUG就是,只有项目内使用外网公开的路径jdbc:mysql://101.43.00.000:9090才可以访问到数据库,而使用本地的jdbc:mysql://localhost:3306却访问不到,

后面使用healthcheck+depends_on内加上condition后才可以解决这个问题,现在觉得的可能是mysql没有完全加载好,就去访问mysql所以报错,

但是这样的话为什么外网又能访问到呢,所以现在我怀疑实际上是第一次进行加载的时候用的jdbc:mysql://localhost:3306,这个时候sql还在初始化,所以无论是用外网还是localhost这个时候都是访问不到的,

那么当第一次初始化完成后,再去启动就都可以进行访问了

不过上面的逻辑是有问题的,因为超去启动的时候,启动多次容器还是localhost不行,而外网是可以的

感觉也没有完全弄懂这个问题,后面再去想想看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
version: '3.8'

services:
mysql:
image: mysql:8
container_name: stargazer-mysql
ports:
- "3306:3306"
environment:
MYSQL_ROOT_PASSWORD: xxxxxx
MYSQL_DATABASE: stargazer
volumes:
- ./data/mysql:/var/lib/mysql # 本地数据持久化目录
- ./sql:/docker-entrypoint-initdb.d # SQL 文件目录
networks:
- app-network
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-pxxxxx"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s

stargazer-backend:
image: tecnb/stargazer-backend:remote
container_name: stargazer-backend
ports:
- "8080:8080"
depends_on:
mysql:
condition: service_healthy
networks:
- app-network


networks:
app-network:
driver: bridge

服务器防火墙的使用

有挺多种方式的,这里重点介绍两种,firewalld以及iptables

firewalld 是基于 iptables 的抽象层:当你使用 firewalld 时,它实际上在后台生成并管理 iptables 规则。firewalld 提供了一个更高级的接口来管理这些规则,旨在简化防火墙配置。

firewalld使用

(1)查看防火墙状态

1
sudo firewall-cmd --state 

如果返回的是 “not running”,那么需要先开启防火墙;

1
sudo systemctl start firewalld.service

(2)开启指定端口

1
sudo firewall-cmd --zone=public --add-port=9000/tcp --permanent

显示 success 表示成功

参数解释:

–zone=public 表示作用域为公共的

–add-port=443/tcp 添加 tcp 协议的端口端口号为 443

–permanent 永久生效,如果没有此参数,则只能维持当前 服 务生命周期内,重新启动后失效;

(3)重启防火墙

1
sudo systemctl restart firewalld.service

系统没有任何提示表示成功!

(4)重新加载防火墙

1
sudo firewall-cmd --reload

显示 success 表示成功

这时候就可以访问到服务器的9000端口了

iptables使用

使用 iptables 增加规则可以通过命令行进行,通常会涉及到指定链(如 INPUTOUTPUTFORWARD),以及你希望应用的规则(如端口、协议、源地址、目标地址等)。下面是一些常见的 iptables 规则添加示例:

  1. 添加允许特定端口的入站规则

假设你想允许 TCP 端口 80(HTTP)的入站流量:

1
sudo iptables -A INPUT -p tcp --dport 80 -j ACCEPT
  1. 添加拒绝特定IP地址的入站流量

假设你想拒绝来自特定IP地址 192.168.1.100 的所有入站流量:

1
sudo iptables -A INPUT -s 192.168.1.100 -j DROP
  1. 添加允许特定端口的出站规则

假设你想允许 TCP 端口 443(HTTPS)的出站流量:

1
sudo iptables -A OUTPUT -p tcp --dport 443 -j ACCEPT
  1. 添加特定接口的规则

假设你想允许通过 eth0 接口的所有 SSH(端口22)流量:

1
sudo iptables -A INPUT -i eth0 -p tcp --dport 22 -j ACCEPT
  1. 保存规则

添加规则后,为了确保在系统重启后规则仍然有效,你需要保存这些规则。保存方法根据不同的Linux发行版有所不同:

  • 在 Debian/Ubuntu 上:

    1
    2
    sudo apt-get install iptables-persistent
    sudo netfilter-persistent save
  • 在 CentOS/RHEL 上:

    1
    sudo service iptables save
  1. 查看规则

添加完规则后,可以通过以下命令查看现有的规则:

1
sudo iptables -L -v -n
  1. 删除规则

如果你需要删除一条规则,首先需要找到规则的编号,然后删除它:

  • 查看规则编号:

    1
    sudo iptables -L INPUT --line-numbers
  • 删除规则:

    假设你想删除规则编号为 3 的规则:

    1
    sudo iptables -D INPUT 3

通过这些命令,你可以有效地管理 iptables 防火墙的规则,增加、删除或查看现有的规则配置。

MP 和 JPA 的选择

JPA提供的API足够多,在简单的CURD上确实是要方便点,

毕竟直接就提供了根据对应的属性去查找数据的API,而且还有@ManytoOne对外键支持更好,但是在动态SQL中的支持并没有MP好,

而且后续的优化肯定是直接SQL,没有抽象层的性能更好,最后国内MP用得更多些,不过简单的项目还是JPA方便的多

MP开启SQL日志打印

1
2
3
4
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl #开启sql日志
map-underscore-to-camel-case: true #开启自动驼峰命名规则(camel case)映射

ApiPost 中 form-data 下传递数组参数

可以直接在参数名中使用一个diaryImages,参数值用,隔开就行:1,2,3

参数名中使用多个diaryImages,参数值正常写就行:123

@ModelAttribute与@RequestBody

参考链接:【笔记】SpringMVC中@ModelAttribute与@RequestBody的区别-CSDN博客

@ModelAttribute注解的实体类接收前端发来的数据格式需要为”x-www-form-urlencoded”(formdata也能正确接收),@RequestBody注解的实体类接收前端的数据格式为JSON(application/json)格式

restAPI的最佳实践

之前本来一直用的rawjson,因为之前跟着教程是这样的,但是后面竞赛的时候超说formdata格式更好,于是这次开写的时候就一直是用的这种格式,实际上rawjson更加的标准,也更灵活自由,formdata 遇到数组对象时是无法传递的

下面的内容总结为:

  1. DELETE使用**@PathVariable**
  2. GET 使用 @RequestParam @PathVariable
    1. @RequestParam 处理查询参数
    2. 使用 @PathVariable 处理路径参数
  3. POSTPUT使用@RequestBody,用于让JSON作为统一的传递格式

RESTful API 设计中的最佳实践与常见问题总结

1. GET 请求中的参数传递:

​ • 推荐使用 @RequestParam @PathVariable

​ • 在 GET 请求中,参数通常通过 URL 传递,使用 @RequestParam 处理查询参数,使用 @PathVariable 处理路径参数。

​ • 例如:GET /users/{userId} 使用 @PathVariable 获取 userId,GET /search?name=John&age=30 使用 @RequestParam 处理查询参数。

​ • 不推荐使用 @RequestBody

​ • GET 请求的设计目的在于检索资源,并且是幂等的(多次请求结果应相同)。使用请求体传递数据不符合 RESTful 原则,也可能导致缓存、调试困难等问题。

​ • 查询参数和路径参数在 URL 中是可见的,支持缓存和书签功能。

​ • 复杂查询场景

​ • 对于复杂查询,考虑使用 POST 请求并通过 @RequestBody 传递 JSON 数据,这样可以在请求体中包含复杂的查询条件。

​ • 尽管这种做法在严格的 RESTful 语义下不完全合适,但在实际应用中是可以接受的。

2. DELETE 请求中的参数传递:

​ • 推荐使用 @PathVariable

​ • DELETE 请求通常用于删除特定资源,使用 @PathVariable 通过路径参数指定要删除的资源。

​ • 例如:DELETE /users/{userId} 使用路径参数删除特定用户。

​ • 特殊场景下使用 @RequestBody

​ • 在批量删除或删除需要复杂条件时,可以通过 @RequestBody 传递 JSON 数据。例如,传递一组 ID 来执行批量删除操作。

​ • 这种情况下,可以通过 POST 或者其他方式来设计 API,避免在 DELETE 中使用请求体。

3. RESTful API 设计的整体原则:

​ • 一致性

​ • 设计 API 时,尽量保持参数传递方式的一致性。例如,若要求所有数据传输使用 JSON 格式,那么应在所有请求中保持一致,避免混用 @RequestParam 和 @RequestBody。

​ • 在 POST 请求中,推荐使用 @RequestBody 传递 JSON 数据,在 GET 请求中使用查询参数或路径参数。

​ • 语义清晰

​ • 路径参数通常用于标识资源和表示资源层次关系,查询参数用于过滤、排序、分页等操作。路径参数适合标识唯一资源,而查询参数适合可选条件。

​ • 易于使用

​ • API 设计应尽量简洁直观,参数传递方式应符合使用习惯。例如,使用 GET /users/{userId} 获取用户信息,而不是通过查询参数或请求体。

​ • 可维护性

​ • 通过清晰的路径设计和一致的数据传递方式,提升 API 的可读性和维护性,减少客户端和服务端的潜在错误和混淆。

BUG

Springboot3整合MP报错

参考文章:Springboot3整合myBatisplus报错:Bean named ‘ddlApplicationRunner‘ is expected to be of type ‘org.sprin_bean named ‘ddlapplicationrunner’ is expected to b-CSDN博客

初始化后直接点击启动时报错

1
Bean named 'ddlApplicationRunner' is expected to be of type 'org.springframework.boot.Runner' but was actually of type 'org.springframework.beans.factory.support.NullBean'

无法访问端口的问题(腾讯云放行端口与防火墙放行端口的关系)

腾讯云面板的防火墙是针对外网对于服务器的访问的,内部则其实是通过iptables或者firewalld来进行访问的静止,实际上会先经过腾讯云面板的网关防火墙,然后再进入内部的防火墙

所以即使内部的防火墙全是关着的,网关的腾讯云端口不放行就访问不了

遇到非阿里家或者腾讯家的自搭类服务器就可以直接通过防火墙放行端口的方式进行访问

@TableId(value = “user_id”, type = IdType.AUTO)无效的问题

参考链接:自定义ID生成器 | MyBatis-Plus (baomidou.com)

type = IdType.AUTO只有当数据库设置为自增后才能有效

当需要为String类型自增时需要改为:type = IdType.ASSIGN_UUID

不过说来也是蛮离谱的明明都AUTO了,为什么不能自动切换为ASSIGN_UUID

下面为自定义的方法,如果对生成的ID有要求可以用这个:

这里了解的教训还是从官网学习怎么写,不要单独看gpt的,gpt出来的问题还是挺大的,官网的才是最好的学习方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package com.tec.stargazerbackend.utils;

import com.baomidou.mybatisplus.core.incrementer.IdentifierGenerator;
import org.springframework.stereotype.Component;

import java.util.Random;

@Component
public class CustomIdGenerator implements IdentifierGenerator {

@Override
public Long nextId(Object entity) {
// 不使用,返回 null
return null;
}

@Override
public String nextUUID(Object entity) {
String characters = "abcdefghijklmnopqrstuvwxyz0123456789";
StringBuilder objectId = new StringBuilder();
Random random = new Random();

for (int i = 0; i < 24; i++) {
int randomIndex = random.nextInt(characters.length());
objectId.append(characters.charAt(randomIndex));
}

return objectId.toString();
}
}

docker中修改了MYSQL_ROOT_PASSWORD后重新重启没有生效

因为在 volumes 中挂载了本地数据目录 (./data/mysql:/var/lib/mysql)。这意味着即使你删除并重新创建容器,MySQL 仍然会使用持久化的数据,不会重新初始化密码。

docker-compose up没有重新拉取镜像

在使用 docker-compose up 时,如果 Docker Compose 发现镜像已经存在,它不会自动重新拉取镜像。这是因为 docker-compose up 默认只会启动已经存在的镜像,而不是检查镜像是否有更新版本。如果需要确保 Docker Compose 每次都拉取最新版本的镜像,可以使用以下方法:

使用 docker-compose pull

在执行 docker-compose up 之前,先手动拉取最新的镜像:

1
2
docker-compose pull
docker-compose up

Gradle: Downloading gradle-8.5-bin.zip 速度过慢

参考链接:国内下载gradle慢,下载gradle超时问题解决【笔记】_gradle下载超时-CSDN博客

在 IDEA 中下载 Gradle 分发包时,如果速度较慢,可以尝试以下方法来加快下载速度:

使用国内镜像
你可以将 Gradle 的下载源切换为国内镜像,以提高下载速度。具体操作如下:

  • 打开 IDEA 项目下的 gradle/wrapper/gradle-wrapper.properties 文件。
  • 修改 distributionUrl 为国内镜像。例如:
    1
    distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
    可以修改为腾讯云的镜像源:
    1
    distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.5-bin.zip

错误3780:引用列和外键约束中引用的列不兼容

参考链接:mysql - 错误 3780:引用列和外键约束中引用的列不兼容 - 堆栈溢出 (stackoverflow.com)

完整报错:

1
3780 - Referencing column 'diary_id' and referenced column 'diary_id' in foreign key constraint 'diary_images_ibfk_1' are incompatible.

原因:

两个属性之间肯定存在一处不一样,可能是数据类型不匹配字符集或排序规则不匹配长度不一致

解决过程:

去 Navicat Premium Lite 中的选项修改了默认排序规则,以及检查了两个属性是否还有不同,发现确实没有了,但是还是报错

解决方法:

通过下面的命令去查看默认排序规则,发现实际上改了 Navicat Premium Lite 中的并没有用,实际上属性的默认排序规则还是没有被改,于是接着采用下面的命令去修改

1
2
3
SHOW FULL COLUMNS FROM diary_images;
ALTER TABLE diary_images MODIFY diary_id VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;
ALTER TABLE diary_images MODIFY image_url VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;

外键设置的排查问题(‼️)

好好排查,明天早上所有的时间都可以给到这块,问题实在太大了,这么小的一个错误绕了老大一圈,才找到,花了将近4个小时,好好整理下思路,看看自己在排查bug这块到底是哪里这么欠缺

问题原因:

@Many所使用的方法selectDiaryImagesByDiaryId中的参数diaryId,应该为String而不是Long

解决过程:

  1. 一开始设置外键时,本来准备用@ManytoOne,结果发现这个其实是JPA的注解,后面查找发现MP本身并不支持直接创建好外键,于是一开始的方案是准备转JPA了,后面去搜索了下,结论见 MP 和 JPA 的选择笔记,最后还是接着走MP

  2. 那么走MP之后,面临两个设置外键多表查询的方式:1、在 Service 层通过 MyBatis-Plus 提供的方法进行两次查询,然后手动组装数据的方式,2、在 DiaryMapper 中直接操作,最后的选择见多对一外键查询笔记

  3. 决定使用在 DiaryMapper 中直接操作,首先是用gpt给我先简单生成了一份,代码如下

    1
    2
    3
    4
    5
    6
    7
    8
    @Select("SELECT * FROM diaries d LEFT JOIN diary_images i ON d.diary_id = i.diary_id WHERE d.user_id = #{userId}")
    @Results({
    @Result(column = "diary_id", property = "diaryId"),
    @Result(column = "content", property = "content"),
    @Result(column = "diary_id", property = "images",
    many = @Many(select = "selectDiaryImagesByDiaryId"))
    })
    List<Diaries> selectDiariesWithImagesByUserId(String userId);

    运行后出现报错

    1
    2
    3
    4
    {
    "status": 999,
    "message": "Error attempting to get column 'diary_id' from result set. Cause: java.sql.SQLDataException: Cannot determine value type from string 'h5i6j7k8l9m0n1o2pdas'\n; Cannot determine value type from string 'h5i6j7k8l9m0n1o2pdas'"
    }

    还是直接丢gpt,然后由于没有给全代码,所以给我的答案也只有数据类型不匹配的问题,但是实体类和数据库数据类型的是完全没有问题的,这里我直接宕机了,开始疯狂纠结这个问题,后面又去询问,又推测是返回值和@Result没有对齐的问题,然后又直接拿gpt的回答代码,例如,下面的代码,导致又出现Unknown column 'd.typeTag' in 'field list'\等低级的报错浪费时间去看

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    @Select("SELECT d.diary_id as d_diary_id, d.user_id, d.content, d.visibility, d.typeTag, d.createdAt, i.image_url " +
    "FROM diaries d LEFT JOIN diary_images i ON d.diary_id = i.diary_id " +
    "WHERE d.user_id = #{userId}")
    @Results({
    @Result(column = "d_diary_id", property = "diaryId"),
    @Result(column = "user_id", property = "userId"),
    @Result(column = "content", property = "content"),
    @Result(column = "visibility", property = "visibility"),
    @Result(column = "typeTag", property = "typeTag"),
    @Result(column = "createdAt", property = "createdAt"),
    @Result(column = "d_diary_id", property = "diaryImages",
    many = @Many(select = "selectDiaryImagesByDiaryId"))
    })
    List<Diaries> selectDiariesWithImagesByUserId(String userId);

    后面去重新看了下SQL语句,发现原本的没什么问题,于是回到了原来的SELECT *

  4. 后面针对这个报错第三点的报错,先怀疑是不是UUID的格式问题,不过觉得格式是varchar,有没有指定什么UUID格式,怎么可能,根据gpt的引导又怀疑是重复的diary_id的问题,于是改为d.diary_id,这个时候不再报之前的错误,并且有数据返回了,只不过是diaryImages内为null,于是觉得进度是往前推进了的,之前的错误解决了,殊不知伏笔已经埋下

  5. 于是又开始思考@Result(column = "d_diary_id", property = "diaryImages",many = @Many(select = "selectDiaryImagesByDiaryId"))语句的问题,毕竟只有这个语句和diaryImages相关,先是怀疑selectDiaryImagesByDiaryId是不是需要写全路径,后面补全后,还是一样的为null,不过觉得这一步还是有必要的于是保留下来了,但是因为没有直接跳转,所以还是不确定是否进入到这个方法中,决定用断点调试的方法,结果发现确实没有进入到方法(不过后面发现就算正确了,好像断点就是不会进入到这里,这点还是不知道为什么),于是又开始纠结,后面通过打开SQL语句打印输出,确定绝对是没有进行这个方法,后面带着SQL日志找gpt又问,回答是可能d_diary_id值不正确导致无法进入这个方法

  6. 接着就是最精彩的,疯狂换d_diary_id了,先是换名称为d.diary_id,后面又换成i_diary_id,疯狂纠结,后面机缘巧合,把位置对换了,使用@Result(column = "d_diary_id", property = "diaryId"),发现本来有的diaryId变为null了,又对换试了好几次,终于给我看出来了,d_diary_id就是不存在的,只有diary_id是有值的,但是这时候实际上知道了,还是卡住了,因为我现在知道之前的是重复的diary_id的猜测是错误的,但是这不是又回到起点了吗

  7. 这次盯着之前的报错,终于啊终于发觉了,diary_id不只是在实体类中用到,selectDiaryImagesByDiaryId中也是用到了的,把diaryIdLong改为String,终于解决了,啊啊啊啊啊啊啊啊啊啊啊啊啊,现在再回来思考,为什么把diary_id改为d_diary_id后就不报错了,因为d_diary_id为null啊,所以就不存在无法正确转化类型的问题

    1
    2
    3
    4
    public interface DiaryImagesMapper extends BaseMapper<DiaryImages> {
    @Select("SELECT * FROM diary_images WHERE diary_id = #{diaryId}")
    List<DiaryImages> selectDiaryImagesByDiaryId(String diaryId);
    }
  8. 还有高手!!!,BUG奇妙冒险还没结束,最后发现上面全是白写,为什么呢,因为之前选择mapper去写这个逻辑的原因是因为考虑到LEFT JOIN,可以减少查询次数,结果发现在一对多的连表中,LEFT JOIN会将 diaries 表和 diary_images 表的数据进行联接。LEFT JOIN 会扫描两个表的数据,并将结果集返回给应用层,可能会返回比单纯查询 diaries 表更多的数据行,在一对多的场景下,会返回大量重复的数据,虽然减少了 MP 需要发出的额外查询次数,但却可能导致 MP 处理更大的结果集

  9. 最后把LEFT JOIN给删除了,然后一看代码,woc这不就是直接在Service 层手动组装吗,之前用的老办法就是这个,这下终于老实了🥲

断点不停的问题

断点不是哪都能停点的,断点一般用于调试Main Thread里的代码,如果是线程里或者是反射的代码断点就不合适了

1
2
3
4
5
6
7
8
9
@Select("SELECT * FROM diaries d LEFT JOIN diary_images i ON d.diary_id = i.diary_id WHERE d.user_id = #{userId}")
@Results({
@Result(column = "diary_id", property = "diaryId", javaType = String.class),
@Result(column = "content", property = "content"),
@Result(column = "diary_id", property = "diaryImages",
javaType = List.class,
many = @Many(select = "com.tec.stargazerbackend.mapper.DiaryImagesMapper.selectDiaryImagesByDiaryId"))
})
List<Diaries> selectDiariesWithImagesByUserId(String userId);
1
2
3
4
public interface DiaryImagesMapper extends BaseMapper<DiaryImages> {
@Select("SELECT * FROM diary_images WHERE diary_id = #{diaryId}")
List<DiaryImages> selectDiaryImagesByDiaryId(String diaryId);
}

报错:Content-Type ‘multipart/form-data;boundary=————————–843173282939659591303139;charset=UTF-8’ is not supported

使用form-data去传递了原本需要使用rawjson格式的参数