go_project_IM

Last updated on 4 months ago

仓库

觉得写的好就给个star吧
https://github.com/taosu0216/go_stu/tree/main/IM_project

IM即时消息系统

照着马士兵的做的
https://www.bilibili.com/video/BV1rK4y1w7JB/?p=5&spm_id_from=pageDriver&vd_source=593e95872463bd08c88726bf6aade29c
记一下中间的笔记

Day1

下载gorm
go get gorm.io/gorm 前面下错成 jinzhu/gorm 了,但是说那个用的少
测试单独新建test目录
gorm文档https://gorm.io/zh_CN/docs/index.html
msb的官方代码库https://git.mashibing.com/msb_47094/GinChat
go官方文档(用来搜第三方包)https://pkg.go.dev/

gorm官方文档的入门例子

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
package main

import (
"gorm.io/gorm"
"gorm.io/driver/sqlite"
)

type Product struct {
gorm.Model
Code string
Price uint
}

func main() {
db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
if err != nil {
panic("failed to connect database")
}

// 迁移 schema
db.AutoMigrate(&Product{})

// Create
db.Create(&Product{Code: "D42", Price: 100})

// Read
var product Product
db.First(&product, 1) // 根据整型主键查找
db.First(&product, "code = ?", "D42") // 查找 code 字段值为 D42 的记录

// Update - 将 product 的 price 更新为 200
db.Model(&product).Update("Price", 200)
// Update - 更新多个字段
db.Model(&product).Updates(Product{Price: 200, Code: "F42"}) // 仅更新非零值字段
db.Model(&product).Updates(map[string]interface{}{"Price": 200, "Code": "F42"})

// Delete - 删除 product
db.Delete(&product, 1)
}

视频里把sqlite改成了mysql,用phpstudy应该也可以,但为了统一还是搞一下mysql吧
https://dev.mysql.com/downloads/installer
选择内存大的那个下载,然后一路下一步就行(甲骨云真他妈麻烦)

  • Server only

  • execute

  • 同意

  • Use Legacy Authentication Method (Retain MySOL 5.x Compatibility(第二个)

  • 一路next

验证是否安装成功
C:\Program Files\MySQL\MySQL Server 8.0\bin
打开cmd(powershell不行!!!),运行mysql -h localhost -u root -p ,然后输入密码,显示mysql>即安装成功
也可以打开MySQL 8.0 Command Line Client快捷进入

才发现原来博客的笔记一整个完整的数据库操作都没有,麻了

1
2
3
4
5
6
7
8
9
10
11
12
//创立新的数据库,打分号!!!
create database test_2023_10_12;
//选择数据库
use test_2023_10_12;
//建立表
CREATE TABLE test (
time float NOT NULL
)ENGINE=InnoDB DEFAULT CHARSET=utf8;
//删除表
drop table test;
//显示已有的表,打分号!!!
show tables;

剩下的明天再搞

Day2

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
//查看一个表的结构
desc test(table name);
//查看一个表的数据
select * from test;
//重来一遍
create database IM;
use IM;
create table test(
id float
);
//添加字段
//是alter!!!
alter table test add email int;
alter table test add username char(255);
alter table test add passwd char(255);
//修改字段数据类型
alter table test modify email char(255);
//向字段添加数据
insert into test (username,passwd,email,id) values ('taosu','123456','[email protected]',1);
//多创建几个用户用于测试
insert into test (username,passwd,email,id) values ('yblue','qwerty','[email protected]',2);
insert into test (username,passwd,email,id) values ('qlu_coder','my_password','[email protected]',3);

/*
mysql> select * from test;
+----+------------------+-----------+-------------+
| id | email | username | passwd |
+----+------------------+-----------+-------------+
| 1 | [email protected] | taosu | 123456 |
| 2 | [email protected] | yblue | qwerty |
| 3 | [email protected] | qlu_coder | my_password |
+----+------------------+-----------+-------------+
3 rows in set (0.00 sec)
*/

暂时跟mysql告一段落了应该,感觉还是直接用phpstudy就行了,没必要安mysql

testGorm.go的操作

1
2
3
4
//第一行修改成这样,将sqlite改为mysql
//"用户名:密码@tcp(地址:端口)/数据库名"
//其他连接选项:charset=utf8 表示使用UTF-8字符集,parseTime=True 表示GORM将尝试解析时间字段,loc=Local 表示使用本地时区。
db, err := gorm.Open(mysql.Open("root:root@tcp(127.0.0.1:3306)/IM?charset=utf8&parseTime=True&loc=Local"), &gorm.Config{})

完整的

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
package main

import (
"IM_project/models"

"gorm.io/driver/mysql"
"gorm.io/gorm"
)

type Product struct {
gorm.Model
Code string
Price uint
}

func main() {
//"用户名:密码@tcp(地址:端口)/数据库名"
db, err := gorm.Open(mysql.Open("root:root@tcp(127.0.0.1:3306)/IM?charset=utf8&parseTime=True&loc=Local"), &gorm.Config{})
if err != nil {
panic("failed to connect database")
}

// 迁移 schema
db.AutoMigrate(&models.UserBasic{})

// Create
//这里连接IM数据库后,会自动使用结构体名作为表名(按理说是全变小写+复数,但是我看的没加复数,只变成小写了)
user:=&models.UserBasic{}
user.Name="taosu"
db.Create(user)

// Read
fmt.Println("db.First(user, 1) : ",db.First(user, 1))

// Update - 将 user 的 password 更新为 1234
db.Model(user).Update("PassWord", "1234")
}

/*
打印结果
db.First(user, 1) : &{0xc0000ec510 <nil> 1 0xc000068000 0}
*/

迁移

在数据库领域,”迁移”(Migration)是指通过代码和脚本来管理数据库模式(结构)的变化。迁移通常用于以下情况:

  • 创建新表格:当你需要引入新数据表时,迁移会创建该表格的结构。
  • 修改表格结构:如果你需要添加、删除、修改或重命名表格的列,迁移会记录这些更改,并在数据库中应用它们。
  • 填充默认数据:有时需要在表格中填充默认数据,迁移可以包含填充数据的脚本。
  • 删除表格:如果某个表格不再需要,迁移可以包含删除该表格的操作。
  • 数据迁移:当你需要将数据从一个表格或数据库迁移到另一个表格或数据库时,迁移也是有用的。

目录创建

提前创建用于存放各种东西的目录

  • common(感觉应该是public)
  • config 配置
  • router 路由
  • service 服务
  • sql

router创建app.go,将原来的UserBasic.go改名成user_basic.go(这里不加_会报错,很怪)

逻辑

main函数

1
2
3
4
5
6
7
8
9
10
package main

import (
"IM_project/router"
)

func main() {
r := router.Router()
r.Run("127.0.0.1:8099")
}

在main函数中调用router函数,返回值r是 *gin.Engine,然后r运行在本地的8099端口

IM_project/router中的router函数(app.go)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package router

import (
"IM_project/service"

"github.com/gin-gonic/gin"
)

func Router() *gin.Engine {
//创建基本路由
r := gin.Default()
//处理方式放在service中处理,这里不是不是service.GetIndex()
r.GET("/index", service.GetIndex)
return r
}

首先使用gin框架创建基本路由,这里的r也是 *gin.Engine类型,然后在有用户使用get请求并且请求路径为uri/index时,调用service包的GetIndex函数,注意这里是调用service.GetIndex的函数,而不是直接使用,是告诉程序有请求时,将请求发送到service.GetIndex函数,所以这里只写函数名,而不是service.GetIndex()

报错修复

在下一个视频中,吧LoginTime,HeartbeatTime和LoginOutTime的类型从uint64修改成了time.Time,但是运行时会报错

1
2
3
4
5
6
7
8
9
10
11
PS IM_project> go run "IM_project\test\testGorm.go"

2023/10/13 13:58:18 IM_project/test/testGorm.go:31 Error 1292 (22007): Incorrect datetime value: '0000-00-00' for column 'login_time' at row 1
[17.203ms] [rows:0] INSERT INTO `user_basic` (`created_at`,`updated_at`,`deleted_at`,`name`,`pass_word`,`identity`,`phone`,`email`,`client_ip`,`client_port`,`login_time`,`heartbeat_time`,`login_out_time`,`is_logout`,`device_info`) VALUES ('2023-10-13 13:58:18.207','2023-10-13 13:58:18.207',NULL,'taosu','','','','','','','0000-00-00 00:00:00','0000-00-00 00:00:00','0000-00-00 00:00:00',false,'')

2023/10/13 13:58:18 IM_project/test/testGorm.go:34 record not found
[1.232ms] [rows:0] SELECT * FROM `user_basic` WHERE `user_basic`.`id` = 1 AND `user_basic`.`deleted_at` IS NULL ORDER BY `user_basic`.`id` LIMIT 1
db.First(user, 1) : &{0xc00012e480 record not found 0 0xc00028d880 0}

2023/10/13 13:58:18 IM_project/test/testGorm.go:37 WHERE conditions required
[0.475ms] [rows:0] UPDATE `user_basic` SET `pass_word`='1234',`updated_at`='2023-10-13 13:58:18.227' WHERE `user_basic`.`deleted_at` IS NULL

这里很迷,明明grom.Model里的两个时间都是time.Time类型的,但是能用,我这里把自定义的改成time.Time类型就不可以了

问了问gpt,原因是mysql库现在是严格模式,对数据的格式很严,然后time.Time的零值好像是格式有点问题,是存不进去的,然后可以修改成兼容模式
打开mysql终端

1
2
3
4
//查看当前模式
SELECT @@sql_mode;
//修改成兼容模式
SET GLOBAL sql_mode = 'NO_ENGINE_SUBSTITUTION';

不报错了

第二天有报错了,这玩意好像每重启电脑就得设置一遍

继续

创建utils目录,建立system_init.go,在config目录下创建app.yml

“utils” 是一个缩写,通常用于描述一组实用工具或函数,这些工具和函数可以帮助程序员更容易地完成一些常见的任务,例如处理日期和时间、处理字符串、读写文件、执行数学运算等。这些工具旨在节省开发时间和减少代码的冗余,因此开发人员可以更轻松地构建应用程序。所以,当你在代码中看到 “utils”,它通常指的是一组实用的程序代码,用于处理各种常见编程任务。

Day3

viper

下载
go get github.com/spf13/viper
导入需要

1
import "github.com/spf13/viper"
  • 设置默认值

  • 从JSON、TOML、YAML、HCL、envfile和Java properties格式的配置文件读取配置信息

  • 实时监控和重新读取配置文件(可选)

  • 从环境变量中读取

  • 从远程配置系统(etcd或Consul)读取并监控配置变化

  • 从命令行参数读取配置

  • 从buffer读取配置

  • 显式配置值

Viper能够为你执行下列操作:

  • 查找、加载和反序列化JSONTOMLYAMLHCLINIenvfileJava properties格式的配置文件。
  • 提供一种机制为你的不同配置选项设置默认值。
  • 提供一种机制来通过命令行参数覆盖指定选项的值。
  • 提供别名系统,以便在不破坏现有代码的情况下轻松重命名参数。
  • 当用户提供了与默认值相同的命令行或配置文件时,可以很容易地分辨出它们之间的区别。

Viper会按照下面的优先级。每个项目的优先级都高于它下面的项目:

  • 显示调用Set设置值
  • 命令行参数(flag)
  • 环境变量
  • 配置文件
  • key/value存储
  • 默认值

!!! 说人话就是帮忙管理yml,json等这种配置文件的工具 !!!

现在有种感觉就是写之前那个test,就是为了把请求的过程一点点分成很多份,然后分给不同的函数,比如本来test的请求就是直接连接数据库,然后这里是把数据库的信息存放在yml文件中,然后通过viper管理并获取,然后拼接到初始化mysql连接的函数中,然后需要时再调用初始化函数,可能是为了安全和方便修改?

InitMySQL调整

本来是直接mysql.dns获取,改成分批获取配置,增加可读性,更好维护

app.yml

1
2
3
4
5
6
7
8
9
10
11
mysql:
dns: root:root@tcp(127.0.0.1:3306)/IM?charset=utf8mb4&parseTime=True&loc=Local
username: root
passwd: root
host: 127.0.0.1
port: 3306
db: IM
options:
charset: utf8mb4
parseTime: true
loc: Local

InitMySQL

1
2
3
4
5
6
7
8
9
10
11
12
13
func InitMySQL() {
username := viper.GetString("mysql.username")
passwd := viper.GetString("mysql.passwd")
host := viper.GetString("mysql.host")
port := viper.GetString("mysql.port")
db := viper.GetString("mysql.db")
options := viper.Sub("mysql.options")
charset := options.GetString("charset")
parseTime := options.GetBool("parseTime")
loc := options.GetString("loc")
databaseURL := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=%s&parseTime=%t&loc=%s", username, passwd, host, port, db, charset, parseTime, loc)
DB, _ = gorm.Open(mysql.Open(databaseURL), &gorm.Config{})
}

小问题

username := viper.GetString(“mysql.username”) 这里是直接获取mysql.username,万一后面不止获取app.yml,又获取了app2.yml,并且app2.yml也有mysql.username这个配置项,这个语句会获取哪个信息

可能是把所有配置都放在app.yml文件里了所以没有这个问题?

swag

1
2
3
4
5
go install github.com/swaggo/swag/cmd/swag@latest
//废了一个多小时,勉强找到了算是解决办法的办法,傻逼东西,纯nt
//根本原因就是GOBIN跟GOPATH不一样,东西下载在GOPATH的bin目录下,但是调用的时候是用GOBIN路径,但是网上说的设置GOBIN路径的没有一个是能用的,最后把GOPATH的exe复制到GOBIN里,直接完事
//最后找着了,是在用户变量里新建GOBIN,配置路径
//go env GOBIN出了一次配好的路径,再来一次就没了

Day4


昨晚装了一晚上没弄好,回宿舍又找了n久,说可能得调GOROOT的路径,然后今天来了一试,GOPATH它又出来了,难道重启电脑真是解决问题的最好答案吗

swag

1
2
3
4
5
6
7
8
//今天又是跟swagger不死不休的一天
//总结,浪费了快9个,得到了还是得用goland不能用vsc的道理
//像群友说的一样拥抱goland好了

//具体操作没啥好说的了
swag init
swag init -g /service/index.html
go run main.go

配下goland

Day5

现在看看swag的功能确实挺方便的,但不知道为什么vsc里打开的终端就是不能用

捣鼓了昨天一天,对这玩意大概了解了.
就是一个类似apifox自动化测试的东西,在后端写出了业务逻辑需要测试,要么就手动curl发请求,要么就是apifox写好请求发过去,然后这个swag是在程序界面,用特定的方式(就是// @)写好注释,在init之后,就会在url/swagger/index.html生成对应的请求方法,就是比较方便(好像还有自动写文档的功能?没用到还)

1
2
3
4
5
6
7
8
9
10
go install github.com/swaggo/swag/cmd/swag@latest
//然后使用时会自动导包
//app.go
//前面的是别名
"IM_project/docs"
"IM_project/service"

"github.com/gin-gonic/gin"
swaggerfiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"

剩下的现用现查吧,主要东西都在注释里了

new

增删改查(CRUD)正式收工
Create
Read
Update
Delete

对U的信息校验

比如说在手机号那里输字母,进行这种校验

1
go get github.com/asaskevich/govalidator

新加修改手机号和邮箱

防止已注册的手机号/名字/邮箱重复注册

魔改了不少,终于有点自己的想法了

Day6

加盐加密

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
// 小写
func Md5Encode(data string) string {
h := md5.New()
h.Write([]byte(data))
tempStr := h.Sum(nil)
return hex.EncodeToString(tempStr)
}

// 大写
func MD5Encode(data string) string {
return strings.ToUpper(Md5Encode(data))
}

// 加密
func MakePassword(plainpwd, salt string) string {
return Md5Encode(plainpwd + salt)
}

// 解密
func ValidPassword(plainpwd, salt string, passwd string) bool {
return Md5Encode(plainpwd+salt) == passwd
}

/*
整体逻辑
1.传入用户输入的密码和随机生成的盐,并拼接字符串
2.将拼接生成的新字符串传入md5加密函数
3.对拼接的字符串,对其进行 MD5 加密,然后返回结果的十六进制表示。
4.将加密后的密码和盐传入数据库
*/

在密码学和计算机安全领域,散列(Hash)是一种将任意长度的数据映射为固定长度数据的过程。它的核心目标是将输入数据(称为消息)通过一种数学算法,转换为固定长度的字符串,通常是一串数字和字母组成的十六进制值。这个输出字符串通常称为“散列值”或“摘要”。

散列函数有以下特点:

  • 固定输出长度:无论输入数据的大小,散列函数都生成相同长度的输出,这个输出的长度是固定的。
  • 雪崩效应:即使输入数据的微小变化,散列值应该有显著的差异,这称为雪崩效应。这是散列函数的一个关键特性,因为它保证了数据的微小改变会导致完全不同的散列值。
  • 不可逆性:理论上,从散列值不能还原出原始输入数据。这是散列函数的一个重要特性,通常用于存储密码的散列值,因为不应该直接从密码的散列值反向计算出密码本身。
  • 快速计算:散列函数需要在合理的时间内计算出结果,以便广泛的应用。

散列函数在信息安全中有多种应用,包括密码存储、数据完整性验证、数字签名、数字证书等领域。常见的散列算法包括 MD5、SHA-1、SHA-256 等,其中 SHA 系列较为安全,因此在许多安全应用中被广泛使用。但请注意,随着计算能力的增强,一些散列算法可能不再足够安全,需要采用更强大的算法。

用户登录

post请求,更安全点

修改用户信息

盐重新生成并修改数据库中的盐
加了id校验,视频里面是没有这个的(也可能在后面我还没看到)
但感觉好像没必要,因为真要修改信息的话,肯定是先确定账密(也可能再调用一遍login?),然后修改,不会让用户输入id进行判断是否存在,这个功能后面应该是得删掉的

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
// UpdateUser
// @Summary 修改用户信息
// @Tags 用户模块
// @Param id formData string false "id"
// @Param name formData string false "用户名"
// @Param password formData string false "密码"
// @Param email formData string false "邮箱"
// @Param phone formData string false "手机号"
// @Success 200 {string} json{"code","message"}
// @Router /user/updateUser [post]
func UpdateUser(c *gin.Context) {
//根据用户id修改信息,但是id不存在时未进行校验
user := models.UserBasic{}
id, err := strconv.Atoi(c.PostForm("id"))
if err != nil {
log.Fatalln(err)
}
user.ID = uint(id)
is_Exit, _ := models.FindUserById(user.ID)
if !is_Exit {
c.JSON(400, gin.H{
"message": "用户不存在!",
})
return
}
user.Name = c.PostForm("name")
passwd := c.PostForm("password")
salt := fmt.Sprintf("%06d", rand.Int31())
user.PassWord = utils.MakePassword(passwd, salt)
user.Phone = c.PostForm("phone")
user.Email = c.PostForm("email")
user.Salt = salt
//对用户输入信息进行校验
//调用govalidator包的ValidateStruct方法对传入的结构体进行内容校验,校验方法就是在定义结构体时`valid:""`里写的内容
_, err = govalidator.ValidateStruct(user)
if err != nil {
fmt.Println(err)
c.JSON(400, gin.H{
"message": "修改用户信息格式错误!",
})
return
} else {
err = models.UpdateUser(user)
if err != nil {
log.Fatalln(err)
}
fmt.Println("update :", user)
c.JSON(200, gin.H{
"message": "修改用户信息成功",
})
}
}


//finduserbyid
unc FindUserById(id uint) (bool, UserBasic) {
user := UserBasic{}
utils.DB.Where("id = ?", id).First(&user)
if user.ID != 0 {
return true, user
}
return false, user
}

新学的东西

思路

对请求分批处理,分成很多小份,交给不同的程序处理

函数

gin相关

1
2
3
4
//返回值是*gin.Engine
r:=gin.Default()
//类似http.HandleFunc("/", indexHandler),用当请求index时,调用service.GetIndex函数处理
r.Get("index",service.GetIndex)

viper相关

1
2
3
4
5
6
7
8
9
10
11
//设置获取文件的路径,即在./config目录下查找对应文件
viper.AddConfigPath("config")
//设置文件名字,即在./config目录下查找名为"app"的文件,因为能获取各种配置文件,所以不用写后缀名/扩展名
viper.SetConfigName("app")
//检测是否能正常读取配置文件
err := viper.ReadInConfig()
//在读取到app.yml文件后,获取mysql.username这个键对应的值(),并强制转换成string类型变量
username := viper.GetString("mysql.username")
//获取配置中的子配置项
options := viper.Sub("mysql.options")
charset := options.GetString("charset")

gorm相关

1
2
3
4
5
6
7
8
//gorm这里是数据库类型(MySQL、PostgreSQL、SQLite等)
//opts是一个可选参数列表,用于传递额外的选项,以配置数据库连接。这些选项是 gorm.Option 类型的可变参数。你可以使用这些选项来配置数据库的行为,例如启用日志记录、设置连接池大小等,这里是&gorm.Config{},这里没再填写具体参数,填写的话可以对数据库连接进行更精细的操作,比如打开日志,但是为什么传递指针还不是很明白,这里是空的,所以直接不写这个也可以。
mysql.Open("root:root@tcp(127.0.0.1:3306)/IM)
func gorm.Open(dialector gorm.Dialector, opts ...gorm.Option) (db *gorm.DB, err error)

//db:这是一个 *gorm.DB 类型的指针,表示成功打开的数据库连接。*gorm.DB 是 GORM 库的主要接口,用于执行数据库操作。
//err:这是一个 error 类型的返回值,用于表示在打开数据库连接时是否发生了错误。如果打开成功,err 为 nil;如果发生错误,将包含错误信息。
db, err := gorm.Open(mysql.Open("root:root@tcp(127.0.0.1:3306)/IM?charset=utf8mb4&parseTime=True&loc=Local"), &gorm.Config{})

不知道放在哪的

1
2
3
4
5
6

Q: 那用户能在页面看到message" 字段的值 "welecome!!",是通过c.JSON()还是gin.H{},就是是哪个函数把message" 字段的值 "welecome!!"打印在页面上传给用户的

A: 在用户的浏览器页面上看到 "message" 字段的值 "welecome!!" 实际上是通过 c.JSON() 这个函数实现的。
c.JSON(200, ...) 函数用于生成 JSON 格式的响应,其中的参数是 HTTP 状态码和包含要返回的 JSON 数据的 Go 数据结构。在这里,gin.H{ "message": "welecome!!" } 构建了 JSON 数据,而 c.JSON(200, ...) 把这个 JSON 数据包装成 HTTP 响应并发送给客户端。
这意味着用户在浏览器上看到的 "message" 字段的值 "welecome!!" 是由 c.JSON(200, ...) 函数生成的响应传输到客户端的结果。所以,c.JSON() 函数实际上是将 JSON 数据发送给用户的一部分,而 gin.H{} 只是用于构建 JSON 数据的辅助工具。

数据结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//1.
gin.Default()返回值
*gin.Engine

//2.
gorm.Model
type Model struct {
ID uint `gorm:"primarykey"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt DeletedAt `gorm:"index"`
}

//3.
*gorm.DB

//4.
*gin.Context
//r.GET("/user/getUserList", service.GetUserList)
//当有请求进行访问/user/getUserList时, service.GetUserList()会接收到一个
//c *gin.Context类型的变量,这里的c是一个上下文对象,通过这个上下文对象,可以访问请求的参数、请求头、请求体,以及设置响应等操作。

go_project_IM
https://blog.yblue.top/2023/10/12/go-project-IM/
Posted on
October 12, 2023
Licensed under