Goweb入门

学习

goweb 入门

工具

nc 命令

nc 用法

调试热加载

热加载

fresh go get -v -u github.com/pilu/fresh go install

air go get -u github.com/cosmtrek/air

处理请求

curl 使用说明

powershell 中需要使用 curl.exe

RESTful API

通过调用"net/http"和"github.com/gin-gonic/gin"

定义 album 类,实例化

用 router:=gin.Default(),去调用 get/post 和定义的其他方法

通讯问题

  1. 字符显示问题

chcp 65001

打开 cmd 运行可解决部分错误

  1. 输入秒发送

逻辑

  • server 监听端口
  • go 监听消息,将信息发给 usermap
  • 接受 socket,处理信息
  1. 新版客户端 没有存在字符问题 但 rename 和 who 有问题 存疑 client 中的模式处理 user 中的 who 处理

  2. 44 集踢出问题 在 handle 函数中时间超过后打印被踢信息,并且关闭资源连接,返回 start 函数 但是在 start 函数中一直在 for 循环监听 listener,因此会占用资源 通过排错,发现 server 问题

打印错误

1
2
3
4
5
_, err := this.conn.Write([]byte(msg + "\n"))
if err != nil {
fmt.Println(err)
return
}

panic 用法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func main() {
	test()
}

func test() {
	defer func() {
		if err := recover(); err != nil {
			fmt.Printf("recover:%v\n", err)
		}
	}()
	panic("an error occurred")
}

for-range channel 用法 当 channel 关闭时,for 循环会自动退出,无需主动检测 channel 是否关闭,防止读取已关闭的 channel,造成读取到数据为通道所存储的数据类型的零值

基础

数据模型

User,session,thread,post 和数据库交互,处理器返回数据给模板引擎,再传给模板

通过 URL 进行接受请求

多路复用器 mux := http.NewServeMux() net/http,将收到的请求重定向到处理器

将发送至根 URL 的请求重定向到处理器 mux.HandleFunc("/", index) 所有处理器都接受一个 ResponseWriter 和一个指向 Request 结构的指针作为参数,并且所有请求参数通过访问 Requset 结构得到,所以程序不需要向处理器显式地传入任何请求参数

服务静态文件 file := http.FileServer(http.Dir("/public")) mux.Handle("/static/", http.StripPrefix("/static/", files)) 删除/static/字符串在 public 目录里查找被请求的文件

处理器函数,接受 ResponseWriter 和 Request 指针作为参数的函数

cookie 服务器在响应首部写入 cookie,客户端接受 cookie 后把它存储到浏览器里,route_auth.go 的 authenticate 处理器函数

session 记录各项信息存储到数据库,决定访问 public 或 private 页面 utility 包 判断 cookie 存在,data.Session 的 check 方法访问数据库校对唯一 ID 存在,index 函数获取 err 变量判断用户是否已经登录

使用模板生成 HTML 响应 用{{define “”}}标识动作 ParseFiles 函数对模板进行语法分析并创建出相应模板,Must 函数包围 ParseFiles 函数,返回错误报告

(.)代表了传递给被引用模板的数据 {{.Topic}}访问的是 Thread 结构的 Topic 字段,在访问字段时必须在字段名的前面加上点号,并且字段名的首字母必须大写

安装 PostgreSQL

数据库创建

查看数据库 \l

表格创建

pq 包虽然用_忽略,但实际上连接数据库还是会调用

psql -f setup.sql -d chitchat 无法生效 手动创建数据库

在 chitchat 目录下运行 go build,报 data 包错误,直接将 data 丢入 src 路径,go mod tidy 解决

添加项目

SSL

处理器与处理器函数

任何实现了 ServeHTTP 的类型都可以作为 HTTP 请求的处理器

处理器拥有 ServeHTTP 方法接口,ServeHTTP 需要接受两个参数,ResponseWriter 和 Request 指针

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}
// 自定义类型实现 Handler 接口
type MyHandler struct{}

func (h *MyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("Hello from Handler!"))
}

// 注册处理器
http.Handle("/path", &MyHandler{})

允许普通函数直接作为处理器使用,无需定义新类型。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
type HandlerFunc func(ResponseWriter, *Request)

// 实现 ServeHTTP 方法(将函数转为 Handler)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
    f(w, r) // 直接调用自身
}
// 普通函数
func helloHandler(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("Hello from HandlerFunc!"))
}

// 将函数转换为 HandlerFunc 类型,再注册为处理器
http.Handle("/hello", http.HandlerFunc(helloHandler))

// 更简洁的写法(直接使用 HandleFunc)
http.HandleFunc("/hello", helloHandler)

处理器函数与处理器有相同行为,与 ServeHTTP 方法拥有相同签名 func hello(w http.ResponseWriter, r *http.Requset) http.HandleFunc("/hello", hello)

HandleFunc 可以把一个带有正确签名的函数 f 转换成一个带有方法 f 的 Handler,并与 DefaultServeMux 进行绑定

处理器函数不能替代处理器,代码包含了某个接口或者某种类型,需要为它们添加 ServeHTTP 方法转变为处理器

串联处理器和处理器函数

ServeMux 请求多路复用器,DefaultServeMux 是 ServeMux 一个实例,用户没有指定处理器则会使用

HTTP2 http2.ConfigureServer(&server, &http2.Server{}) curl -I --http2 --insecure https://localhost:8080/

实际应用在中间件中 通过 HandlerFunc 链式调用实现中间件

1
2
3
4
5
6
7
8
9
func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        log.Println("Request received:", r.URL.Path)
        next.ServeHTTP(w, r) // 调用下一个处理器
    })
}

// 使用中间件包装处理器
http.Handle("/secure", loggingMiddleware(&AuthHandler{}))

在 Gin 中,处理器函数通常是 gin.HandlerFunc 类型,与标准库思想类似

1
2
3
4
5
6
func ginHandler(c *gin.Context) {
    c.String(200, "Hello Gin!")
}

router := gin.Default()
router.GET("/gin", ginHandler) // 直接使用函数

表单 enctype 属性

简单使用 application/x-www-form-urlencoded 不同得键值对将使用&符号分隔,键和值用=分隔 first_name=sau%20sheong&last_name=chang

上传文件使用 mutipart/form-data 表单中得键值对带有各自的内容类型和配置 ----WebKit.... Content-Disposition:form-data;name="firstname" sau sheong

分析字段

以下三者围绕着返回值和表单属性进行区分 requset 将 url/主体等提取到 form/postform/multipartform 字段中,调用 parseform 方法或者 parsemultipartform 方法进行解析

Form 字段 r.ParseForm() fmt.Fprintln(w, r.Form) 将获得一个切片,包含键的表单值或者 URL 值 map[thread:[123] hello:[sau sheong world] post:[456]]

PostForm 字段 r.PostForm() 将获得一个键的表单值不包含 URL 值 只支持 urlencoded 编码

application/x-www-form-urlencoded 下 PostForm,获得表单值 map[post:[456] hello:[sau sheong]]

修改成 multipart/form-data 下使用回 r.Form,获得 url 值 map[hello:[world] thread:[123]]

MutipartForm 字段 只包含表单不包含 URL 键值对,包含两个映射,一个为字符串组成切片,一个为空,为上传文件

r.ParseMultipartForm(1024) fmt.Fprintln(w, r.MultipartForm)

&{map[hello:[sau sheong] post:[456]] map[]}

FormValue 字段 允许直接访问与给定键相关联的值 FormValue 自动调用 ParseForm 或 ParseMultipartForm 方法,但指挥从 Form 结构取出给定键的第一个值,要获取全部需要直接访问 Form 结构

PostFormValue 字段 PostFormValue 同理,只会返回表单不会返回 url,两者都只支持 application/x-www-form-urlencoded,在 multipart/form-data 中不会得到任何结果 multipart/form-data 时,数据被存储到 MultipartForm 字段中而不是 Form 字段和 PostForm 字段

上传文件 前端 enctype="multipart/form-data" <input type="file" name="uploaded">

后端

1
2
3
r.ParseMultipartForm(1024)
fileHeader := r.MultipartForm.File["uploaded][0]
file, err := fileHeader.Open()

执行 ParseMultipartForm 方法,从 MultipartForm 的 File 取出 fileHeader,然后通过调用文件头的 Open 方法打开文件,服务器会将文件的内容读取到一个字节数组中,并将这个字节数组的内容打印出来,纯文本文件会把这个文件内容打印在浏览器上 FormFile 可以返回给定键的第一个值,返回文件和文件头

ParseForm 方法无法从 Angular 客户端发送 POST 请求中获取 JSON 数据,使用的是 application/json,ParseForm 方法只对表单数据进行分析

ResponseWriter 实际上 ResponseWriter 就是 response 这个非导出结构的接口,传递的也是指向 response 结构的指针,包含 Write/WriteHeader/Header 三个方法 Write 可以将 HTML 字符串写入 HTTP 响应主题中 WriteHeader 接受一个代表 HTTP 响应状态码的整数作为参数,并将这个整数用作 HTTP 响应的返回状态码,用户可以继续对 ResponseWriter 进行写入但是不能对响应的首部做任何写入 Header 取得一个由首部组成的映射,修改映射可以修改首部,修改后的首部将被包含在 HTTP 响应里面,并随着响应一同发送至客户端

重定向方法 w.Header().Set("Location", "http://www.baidu.com") w.WriteHeader(302) 给响应首部添加一个 Location,并设置为重定向目的地,WriteHeader 执行后不再允许写入

Cookie 除了 Expires 字段,还有 MaxAge 字段

设置 cookie

1
2
3
w.Header().Set("Set-Cookie", c1.String())
w.Header().Add("Set-Cookie", c2.String())
http.SetCook(w, &c1) //传递指向Cookie结构的指针

cookie 实现闪现消息 c := http.Cookie{ Name: “flash”, Value:base64.URLEncoding.EncodeToString(msg) }

rc := http.Cookie{ Name: “flash” MaxAge: -1 Expires: time.Unix(1, 0) } val, _ := base64.URLEncoding.DecodeString(c.Value)

通过完全移除 cookie,程序对旧 cookie 解码并返回

5. 模板引擎

无逻辑模板引擎,将模板中指定的占位符替换成相应的动态数据,完全分离程序的表现和逻辑,计算交给处理器完成

嵌入逻辑的模板引擎,将编程语言嵌入模板并在模板引擎渲染模板时,由代码进行相应的字符串替换

模板中默认动作使用{{}}包围,而点(.)是一个动作,模板引擎执行模板时,使用一个值去替换动作本身 步骤: (1) 对文本格式模板源进行语法分析,创建一个经过语法分析的模板结构,模板源既可以是一个字符串,也可以是模板文件中包含的内容; (2) 执行经过语法分析的模板,将 ResponseWriter 和模板所需的动态数据传递给模板引擎,被调用的模板引擎会把经过语法分析的模板和传入的数据结合起来,生成出最终的 HTML,并将这些 HTML 传递给 ResponseWriter

t, _ := template.ParseFiles("tmpl.html") t.Execute(w, "Hello World!") 等同于 t := template.New("tmpl.html") t, _ := t.ParseFiles("tmpl.html')

ParseFiles 可以接受多个文件名作为参数,变成集合,但只返回第一个文件的已分析模板 ParseGlob 会对匹配给定模式的所有文件进行语法分析 t, _ := template.ParseGlob("*.html")

处理分析模板时出现的错误 t := template.Must(template.ParseFiles("tmpl.html")) Must 函数可以包裹起一个函数,返回一个指向模板的指针和一个错误,如果不是 nil 则产生 panic panic 会终止正常流程,如果 panic 是内部函数产生那会返回给调用者,一直向调用栈的上方传递,直到 main 函数

如果想执行别的模板需要 t.ExecuteTemplate(w, "t2.html", "Hello World")

动作 条件动作/迭代动作/设置动作/包含动作

t.Execute(w, )负责传入

1
2
3
{{if .}} action
{{else}} action
{{end}}
1
2
3
4
{{range .}}
<li>{{.}}</li>
{{else}} action
{{end}}
1
2
3
4
5
{{with arg}}
到end之间.会变成arg的内容
{{else}} action
如果arg为空,则会替换成else
{{end}}

包含另一个模板 {{template “name”}}

ParseFiles 第一个参数有特殊作用,传递给第二个模板需要用{{template “t2.html” .}},可以把 t1.html 的{{.}}传递给 t2.html

变量$variable := value 管道可以传递给下一个参数

自定义模板函数 (1)创建一个名为 FuncMap 映射并将映射的键设置为函数名字,而映射的值则设置为实际定义函数 (2)将 FuncMap 与模板进行绑定 用户常常需要将时间对象或日期转换为 ISO8601 格式的时间字符串或者日期字符串

定义函数返回 t.Format(layout),处理器中创建一个变量名为 funcMap,使用结构将名字 fdate 映射至 formatDate 函数,template.New 函数创建一个名为 tmpl.html 的模板,funcMap 传递给 template.New 返回被创建模板进行绑定,对 tmpl.html 进行语法分析,将 ResponseWriter 以及当前时间传递给模板

funcMap := template.FuncMap{"fdate": formatDate} t := template.New("tmpl.html").Funcs(funcMap) t, _ = t.ParseFiles("tmpl.html") t.Execute(w, time.Now())

上下文感知,对 HTML/JS 等进行转义 w.Header().Set("X-XSS-Protection", "0")

介于标签之间的内容就是 layout 模板 {{define “layout”}} {{end}}

t.ExecuteTemplate(w, "layout", "")

块动作定义默认模板,可以将 content 放置到 layout 中 {{block arg}} {{end}}

构建运行

1.将文件夹复制到"C:\Program Files\Go\src"中 2.管理员权限开 cmd 运行 go install 目录名 3.可以在 bin 目录里找到 exe,运行

常见问题: 端口被占用无法运行 netstat -ano | findstr 查看端口 taskkill /PID pid /f 关闭端口

curl -i 返回请求头 -d 提交 Post

存储数据

gob 一种能存储在文件里面的二进制格式,可以快速高效地将内存中的数据序列化到一个或多个文件里面

io/ioutill 库 用 WriteFile 和 ReadFile 对文件进行写入读取

1
2
ioutill.WriteFile("data1", data, 0644)
read1, _ := ioutil.ReadFile("data1")

写入程序会将文件的名字/数据以及权限传入 读取将文件名做参,返回一个由字节组成的切片

os 库 通过 File 结构对文件进行写入读取

1
2
3
4
5
6
7
file1, _ := os.Create("data2")
defer file1.Close()
bytes, _ := file1.Write(data)
file2, _ := os.Open("data2")
defer file2.Close()
read2 := make([]byte, len(data))
bytes, _ = file2.Read(read2)

Create 出 file 文件后进行写入,Open 文件后进行 read

CSV 处理

创建写入器,把数据创建为一个由字符串组成的切片,Flush 方法保证缓冲区数据写入

1
2
3
writer := csv.NewWriter(csvFile)
...
writer.Flush()

打开 csv 文件,将 FieldsPerRecord 字段设为-1,即使读取时缺少字段也不会中断,为正数时,字段数量少于值会报错,为 0 时,读取第一条记录的字段数量未作值,在使用 readall 一次性读取所有记录

1
2
3
4
5
6
7
file, err := os.Open("csv")
...
defer file.Close()
reader := csv.NewReader(file)
reader.FiledsPerRecord = -1
record, err := reader.ReadAll()
...

gob 包

通过二进制读取存储数据 存储数据

1
2
3
4
5
func store(data interface{}, filename string){
    buffer := new(bytes.Buffer)
    encoder := gob.NewEncoder(buffer)
    ...
}

载入数据

1
2
3
4
5
6
func load(data interface{}, filename string){
    raw, err := ioutil.ReadFile(filename)
    buffer := bytes.NewBuffer(raw)
    dec := dec.Decode(data)
    ...
}

Postgres CRUD

导入驱动,调用库 _ "github.com/lib/pq" "database/sql" 隐形调用 sql.Register(“postgres”, &drv{})

创建用户,-P 密码,-d 权限 createuser -P -d gwp

创建数据库 createdb gwp

创建表格 psql -U gwp -f setup.sql -d gwp

连接数据库 Db, err = sql.Open(exe, user dbname password sslmode)

如果密码不对,则会产生权限问题,无法对数据库进行操作 defer Db.Close()放在 main 函数中否则占用资源 惰性连接

详细说明 有了 sql.DB 实例之后就可以开始执行查询语句了。

Go 将数据库操作分为两类:Query 与 Exec。两者的区别在于前者会返回结果,而后者不会。

  • Query 表示查询,它会从数据库获取查询结果(一系列行,可能为空)。
  • Exec 表示执行语句,它不会返回行。

此外还有两种常见的数据库操作模式:

  • QueryRow 表示只返回一行的查询,作为 Query 的一个常见特例。
  • Prepare 表示准备一个需要多次使用的语句,供后续执行用

Scan 方法把行中的值复制到程序为其提供的参数里面,一般与 QueryRow 搭配使用

查询数据,传入 limit 限制行数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
func Posts(limit int) (posts []Post, err error) {
	rows, err := Db.Query("select id, content, author from posts limit $1", limit)
	if err != nil {
		return
	}
	for rows.Next() {
		post := Post{}
		err = rows.Scan(&post.Id, &post.Content, &post.Author)
		if err != nil {
			return
		}
		posts = append(posts, post)
	}
	rows.Close()
	return
}

单一查询

1
2
3
4
5
func GetPost(id int) (post Post, err error) {
	post = Post{}
	err = Db.QueryRow("select id, content, author from posts where id = $1", id).Scan(&post.Id, &post.Content, &post.Author)
	return
}

增加

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func (post *Post) Create() (err error) {
	statement := "insert into posts (content, author) values ($1, $2) returning id"
	stmt, err := Db.Prepare(statement)
	if err != nil {
		return
	}
	defer stmt.Close()
	err = stmt.QueryRow(post.Content, post.Author).Scan(&post.Id)
	return
}

删除

1
2
3
4
func (post *Post) Delete() (err error) {
	_, err = Db.Exec("delete from posts where id = $1", post.Id)
	return
}

改变

1
2
3
4
func (post *Post) Update() (err error) {
	_, err = Db.Exec("update posts set content = $2, author = $3 where id = $1", post.Id, post.Content, post.Author)
	return
}

尝试将其他搜索条件传入,返回 id 值

7.go web

SOAP 响应报文由 WSDL 生成的 SOAP 服务器负责,每次修改服务器,即使是修改返回值类型客户端也需要重新生成

REST 对象模型表示事物,函数称为方法,以资源的形式把模型暴露出来,人们通过少数几个称为动词的动作来操纵资源 HTTP 实现 REST 服务时,URL 用于表示资源,HTTP 方法则操纵资源动词

PUT 再使用时需要知道哪项资源会被替换,POST 则只会创建出一项新资源以及一个新 URL PUT 是幂等的,无论调用多少次服务器的状态都不会改变,PUT 会重复修改一项资源

REST 对资源执行动作方法 把过程具体化,把动作转为名词然后用作资源 把动作用作资源的属性 优点是可以添加额外属性,再用 PATCH 对资源进行部分更新

XML

创建用于存储 XML 数据的结构 使用 unmarshal 将 xml 数据解封

在结构中使用`作为标签,名字必须以大写英文字母开头,创建一个与 XML 元素标签同名的字段存储,可以将元素"</name/>“属性存到字段,也可以使用 a>b>c 形式

解码 处理体积小的 XML 文件,unmarshal

1
2
3
4
5
6
7
	xmlFile, err := os.Open("post.xml")
...
	defer xmlFile.Close()
	xmlData, err := ioutil.ReadAll(xmlFile)
...
	var post Post
	xml.Unmarshal(xmlData, &post)

以流的方式传输 XML 文件以及体积较大的文件 decoder

1
2
3
4
5
6
	xmlFile, err := os.Create("post.xml")
...
	encoder := xml.NewEncoder(xmlFile)
	encoder.Indent("", "\t")
	err = encoder.Encode(&post)
...

创建 XML marshal

1
2
output, err := xml.Marshal(&post) //无格式
output, err := xml.MarshalIndent(&post, "", "\t\t") //有格式

添加 XML 声明 err = ioutil.WriteFile("post.xml", []byte(xml.Header + string(output)), 0644)

encode

1
2
3
4
5
	xmlFile, err := os.Create("post.xml")
...
	encoder := xml.NewEncoder(xmlFile)
	encoder.Indent("", "\t")
...

JSON

创建存储结构-把 json 数据解封到结构 创建存储结构-创建用于解码的解码器-遍历整个 JSON 文件并将数据解码至结构

创建存储结构并填充-把结构封装为 JSON 数据 创建存储结构并填充-创建出用于存储 JSON 数据的 JSON 文件-创建用于编码 JSON 数据的编码器-通过编码器把结构编码至 JSON 文件

解码 marshal

1
2
3
4
5
6
7
	jsonFile, err := os.Open("post.json")
...
	defer jsonFile.Close()
	jsonData, err := ioutil.ReadAll(jsonFile)
...
	var post Post
	json.Unmarshal(jsonData, &post)

encode

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
	jsonFile, err := os.Open("post.json")
...
	defer jsonFile.Close()

	decoder := json.NewDecoder(jsonFile)
	for {
		var post Post
		err := decoder.Decode(&post)
...
	}

创建 json marshal

1
2
3
4
	output, err := json.MarshalIndent(&post, "", "\t\t")
...
	err = ioutil.WriteFile("post.json", output, 0644)
...

encode

1
2
3
4
5
6
	jsonFile, err := os.Create("post.json")
...
	jsonWriter := io.Writer(jsonFile)
	encoder := json.NewEncoder(jsonWriter)
	err = encoder.Encode(&post)
...

并发与并行

并发,多个任务在同一时间段内启动互动,任务通过通信分享数据并协调执行时间,共享一个资源

并行,把大任务分割成小任务,需要独立资源,不会重叠处理

GOMAXPROCS 让并行可以同时运行多个任务

打印数字和英文

创建 go routine 程序 在 TestGoPrint1 中,如果规定 CPU 最大数量不为 1,每次打印的结果不相同,可以通过定义数量来固定结果 go test -run x -bench . -cpu 1 go test -v 需要通过延迟来显示结果 time.Sleep(1 * time.Millisecond)

增加 CPU 的数量并不一定能带来性能提升

等待执行完毕

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func (wg *sync.WaitGroup){
...
wg.Done()
}
fuc main(){
	var wg sync.WaitGroup
	wg.Add(2)
	go printNumbers2(&wg)
	go printLetters2(&wg)
	wg.Wait()
}

如果没有对计数器进行减数操作会引发 panic

通道

只能执行发送操作的字符串管道 ch := make(chan <- string)

只能执行接收操作的字符串管道 ch := make(<-chan string)

阻塞主程序,直到 w <- true 被触发 <-w1 main 函数尝试移除 w1 的值,但没有包含任何值因此阻塞

无缓冲通道会轮番打印,但由于 print 抢先执行,会出现先取后入的情况

有缓冲通道会一直运行直到通道满

select 语句允许从多个通道选择一个来操作,但不加上 default 会出现死锁 当 select 没有发现可用通道,会执行 default,如果不加延时,只会看见默认分支输出,通道 a 和 b 还没来得及接受值,select 就跳过执行

close 关闭通道并不是必须的,用于通知接收者该通道不会在收到任何值

程序从通道取值是多值方式,值和通道的状态 case value, ok1 = <-a

马赛克算法

  1. 键:图片的文件名,值:图片平均颜色, 通过计算图片每个像素红/绿/蓝 3 种颜色的总和,并将它们除以像素总数量,得到一个三元组,三元组计算图片的平均颜色
  2. 根据瓷砖图片大小切割目标图片
  3. 对目标图片的子图片计算位于左上方的第一个像素定义为平均颜色
  4. 根据子图片平均颜色,在瓷砖图片找出一张最为接近的然后替代。程序需要将子图片平均颜色与瓷砖图片平均颜色转化为三维空间的一个点并计算欧几里得距离
  5. 选中瓷砖图片后从数据库移除
最后更新于 05月19日 12点44分, 2026年