22 KiB
shortcuts/wiki/wiki.go 阅读笔记(面向 Go 小白)
第 1 行:package wiki
字面意思:声明这个文件属于 wiki 包
运行时作用:Go 语言规定每个文件必须属于一个包。包名决定了其他文件如何引用这里的函数/变量。
小白补充:
- 包就像"工具箱",
wiki包就是专门处理 Wiki 功能的工具箱 - 同一个包下的文件可以直接互相调用函数,不需要导入
- 包名一般和目录名一致(这里文件在
shortcuts/wiki/目录下,所以包名是wiki)
第 3-22 行:import 导入依赖
import (
"encoding/base64" // Base64 编解码
"encoding/json" // JSON 序列化/反序列化
"errors" // 错误处理
"fmt" // 格式化输出(类似 Python 的 print)
"net/http" // HTTP 客户端
"net/url" // URL 编码/解析
"os" // 操作系统交互(读文件等)
"regexp" // 正则表达式
"strconv" // 字符串转数字
"strings" // 字符串操作
"sync" // 并发同步(锁、线程安全)
"time" // 时间处理
"github.com/gitlink-org/gitlink-cli/internal/auth" // 认证模块
"github.com/gitlink-org/gitlink-cli/internal/client" // HTTP 客户端封装
clierrors "github.com/gitlink-org/gitlink-cli/internal/errors" // CLI 错误定义
"github.com/gitlink-org/gitlink-cli/internal/output" // 输出格式化
"github.com/gitlink-org/gitlink-cli/shortcuts/common" // 通用工具
)
字面意思:导入需要用到的外部库/包
运行时作用:告诉 Go 编译器,我需要使用这些包提供的功能。编译时会把这些包的代码链接进来。
小白补充:
| 包名 | 一句话解释 | 在本文件中的用途 |
|---|---|---|
encoding/base64 |
把文字转成 Base64 编码 | Wiki 内容需要用 Base64 编码后发送 |
encoding/json |
处理 JSON 数据 | 解析 API 返回的 JSON |
errors |
Go 标准错误处理工具 | 判断错误类型 |
fmt |
格式化打印 | 输出错误信息、拼接字符串 |
net/http |
HTTP 协议客户端 | 发送 HTTP 请求 |
net/url |
URL 处理 | 构建查询参数、URL 编码 |
os |
操作系统接口 | 读取本地文件内容 |
regexp |
正则表达式 | 匹配 Markdown 链接和图片 |
strconv |
字符串转换 | 把字符串转成数字 |
strings |
字符串操作 | 切割、查找、替换字符串 |
sync |
并发同步 | 提供线程安全的缓存(sync.Map) |
time |
时间处理 | 设置 HTTP 请求超时 |
internal/auth |
项目内部认证模块 | 获取带 Token 的 HTTP 客户端 |
internal/client |
项目内部客户端模块 | 封装 API 调用逻辑 |
internal/errors |
项目内部错误定义 | 自定义错误类型 |
internal/output |
项目内部输出模块 | 格式化输出结果(JSON/Table) |
shortcuts/common |
通用工具模块 | 提供 RuntimeContext 等基础结构 |
第 24 行:var projectIDCache sync.Map
字面意思:声明一个全局变量 projectIDCache,类型是 sync.Map
运行时作用:这是一个线程安全的缓存,用来存储 owner/repo -> projectID 的映射关系,避免重复调用 API 获取项目 ID。
小白补充:
var是 Go 声明变量的关键字sync.Map是 Go 标准库提供的并发安全的 map(普通 map 在多线程下读写会崩溃)projectIDCache是全局变量(在函数外面声明),整个包内都可以访问- 为什么需要缓存?因为每次操作 Wiki 都需要 projectID,但获取 projectID 需要调用一次 API,缓存可以节省网络请求
第 26-28 行:wikiPath 函数
func wikiPath(endpoint string) string {
return "/wiki/open/" + endpoint
}
字面意思:定义一个函数 wikiPath,接收一个字符串参数 endpoint,返回一个字符串Wiki 功能调用的是 Gateway API (网关 API),所有 Wiki 相关的接口都有一个固定的前缀 /wiki/open/
运行时作用:拼接 Wiki API 的路径前缀。比如传入 "wikiPages",返回 "/wiki/open/wikiPages"。
小白补充:
func是 Go 定义函数的关键字wikiPath(endpoint string):函数名是wikiPath,参数名是endpoint,参数类型是stringstring(返回类型):表示函数执行完返回一个字符串- 这是一个工具函数,用来避免重复写相同的路径前缀
第 30-49 行:getGatewayClient 函数
func getGatewayClient(ctx *common.RuntimeContext) *client.Client {
baseURL := ctx.GatewayBaseURL
if baseURL == "" {
baseURL = "https://gateway.gitlink.org.cn/api"
}
httpClient := ctx.GatewayHTTPClient
if httpClient == nil {
httpClient = auth.NewHTTPClient()
}
return &client.Client{
HTTP: httpClient,
BaseURL: baseURL,
SkipJSONSuffix: true,
Debug: ctx.Client.Debug,
}
}
字面意思:定义一个函数 getGatewayClient,接收 *common.RuntimeContext 类型的指针参数 ctx,返回 *client.Client 类型的指针
运行时作用:创建一个专门访问 Wiki Gateway API 的客户端实例。
小白补充:
① 为什么需要单独的 Gateway 客户端?
GitLink 的 Wiki API 和主 API 不在同一个域名:
- 主 API:
https://www.gitlink.org.cn/api(用于获取项目信息等) - Wiki Gateway API:
https://gateway.gitlink.org.cn/api(专门处理 Wiki 操作)
② 代码逐句解析:
baseURL := ctx.GatewayBaseURL // 从上下文获取 Gateway 地址
if baseURL == "" { // 如果没配置,用默认地址
baseURL = "https://gateway.gitlink.org.cn/api"
}
httpClient := ctx.GatewayHTTPClient // 获取自定义的 HTTP 客户端
if httpClient == nil { // 如果没有自定义的,创建一个带认证的默认客户端
httpClient = auth.NewHTTPClient()
}
return &client.Client{...} // 创建并返回 Client 结构体实例
③ 结构体初始化语法:
&client.Client{
HTTP: httpClient, // 使用上面创建的 HTTP 客户端
BaseURL: baseURL, // Gateway API 地址
SkipJSONSuffix: true, // 关键:Gateway API 不需要 .json 后缀
Debug: ctx.Client.Debug, // 继承调试模式
}
&符号表示取地址,返回指针(Go 中结构体传参常用指针,避免拷贝)client.Client是一个结构体类型,里面定义了客户端的各种配置
第 51-58 行:callWikiAPI 函数
func callWikiAPI(ctx *common.RuntimeContext, method, path string, body interface{}) (*output.Envelope, error) {
gc := getGatewayClient(ctx)
env, err := gc.Do(method, path, body, nil)
if err != nil {
return nil, err
}
return unwrapGatewayResponse(env)
}
字面意思:定义函数 callWikiAPI,接收上下文、HTTP 方法、路径、请求体,返回 (*output.Envelope, error)
运行时作用:封装对 Wiki Gateway API 的调用流程。
小白补充:
① 参数说明:
ctx *common.RuntimeContext:运行时上下文,包含认证信息、owner/repo 等method string:HTTP 方法(GET/POST/PUT/DELETE)path string:API 路径body interface{}:请求体(可以是任何类型,Go 中interface{}表示万能类型)
② 返回值说明:
(*output.Envelope, error):Go 可以返回多个值!第一个是 API 返回的包装数据,第二个是错误
③ 执行流程:
gc := getGatewayClient(ctx)→ 获取 Gateway 客户端gc.Do(...)→ 调用客户端的 Do 方法发送 HTTP 请求unwrapGatewayResponse(env)→ 解析并处理响应(后面会讲)
第 60-67 行:callWikiAPIWithQuery 函数
func callWikiAPIWithQuery(ctx *common.RuntimeContext, method, path string, query url.Values) (*output.Envelope, error) {
gc := getGatewayClient(ctx)
env, err := gc.Do(method, path, nil, query)
if err != nil {
return nil, err
}
return unwrapGatewayResponse(env)
}
字面意思:和 callWikiAPI 类似,但专门用于带查询参数的请求
运行时作用:当需要发送带 ?key=value 查询参数的 GET 请求时使用。
小白补充:
url.Values是 Go 标准库类型,本质是map[string][]string,用来存储 URL 查询参数- 比如
?owner=zzx&repo=test会被表示为{"owner": ["zzx"], "repo": ["test"]}
第 69-99 行:unwrapGatewayResponse 函数(核心!)
func unwrapGatewayResponse(env *output.Envelope) (*output.Envelope, error) {
// 1. 尝试把响应数据转成 map
resp, ok := env.Data.(map[string]interface{})
if !ok {
return env, nil // 不是 map 格式,直接返回
}
// 2. 检查响应中的 code 字段
if code, ok := resp["code"]; ok {
switch v := code.(type) {
case float64:
// HTTP 2xx 都算成功(包括 200/201/204 等)
if v < 200 || v >= 300 {
// 错误情况:提取错误信息
msg, _ := resp["msg"].(string)
kind := clierrors.KindServer
if int(v) == 404 {
kind = clierrors.KindNotFound
} else if int(v) == 401 || int(v) == 403 {
kind = clierrors.KindForbidden
}
// 返回自定义错误
return nil, clierrors.New(kind, msg,
"检查 owner/repo 是否正确,或确认仓库已在 GitLink 网页端开启 Wiki 功能")
}
}
}
// 3. 如果响应有 data 字段,提取出来作为新的响应数据
if innerData, ok := resp["data"]; ok {
return output.SuccessEnvelope(innerData, env.Meta), nil
}
return env, nil
}
字面意思:"拆开" Gateway API 的响应,提取真正的数据
运行时作用:处理 Gateway API 返回的特殊格式,统一成标准的 Envelope 结构。
小白补充:
① Gateway API 的响应格式:
Gateway API 返回的 JSON 格式是这样的:
{
"code": 200,
"msg": "success",
"data": { "真正的数据在这里" }
}
而我们需要的是直接拿到 data 里面的内容。
② 类型断言(Go 的特色语法):
resp, ok := env.Data.(map[string]interface{})
- 这是类型断言,把
env.Data(类型是interface{})转换成map[string]interface{} ok是一个布尔值,表示转换是否成功- 如果转换失败(比如
env.Data是个字符串而不是 map),ok就是false
③ switch type 语法:
switch v := code.(type) {
case float64:
// code 是浮点数类型时执行这里
}
- 这是 Go 的类型 switch,用来判断一个
interface{}变量的具体类型 - JSON 解析数字时,默认会转成
float64类型
④ 为什么要判断 2xx 状态码?
if v < 200 || v >= 300 {
// 错误处理
}
- HTTP 状态码中,200-299 表示成功
- 之前的代码只判断了 200/201,导致 DELETE 返回 204(No Content)时被误判为失败
- 现在扩展到所有 2xx 都算成功
第 101-135 行:resolveProjectID 函数(核心!)
func resolveProjectID(ctx *common.RuntimeContext) (string, error) {
// 1. 生成缓存 key
key := ctx.Owner + "/" + ctx.Repo
// 2. 先查缓存
if cached, ok := projectIDCache.Load(key); ok {
return cached.(string), nil // 缓存命中,直接返回
}
// 3. 缓存没命中,调用主 API 获取项目详情
path := fmt.Sprintf("/%s/%s/detail", ctx.Owner, ctx.Repo)
env, err := ctx.CallAPI("GET", path, nil)
if err != nil {
return "", fmt.Errorf("failed to fetch project details (needed for projectId): %w", err)
}
// 4. 从响应中提取 project_id
data, ok := env.Data.(map[string]interface{})
if !ok {
return "", fmt.Errorf("unexpected response from project detail API")
}
pid, ok := data["project_id"]
if !ok {
return "", fmt.Errorf("project_id not found in project detail response")
}
// 5. 处理 project_id 的不同类型(可能是 float64 或 int)
var pidStr string
switch v := pid.(type) {
case float64:
pidStr = fmt.Sprintf("%.0f", v)
case int:
pidStr = fmt.Sprintf("%d", v)
default:
pidStr = fmt.Sprintf("%v", v)
}
// 6. 存入缓存
projectIDCache.Store(key, pidStr)
return pidStr, nil
}
字面意思:根据 owner/repo 解析出项目的数字 ID
运行时作用:Wiki API 需要 projectId(数字),但用户只知道 owner/repo(字符串),这个函数就是做转换的。
小白补充:
① 为什么需要 projectID?
GitLink 的 Wiki Gateway API 设计要求传入数字形式的 projectId,而不是字符串形式的 owner/repo。所以必须先调用主 API 获取项目详情,从中提取 project_id。
② 缓存机制:
if cached, ok := projectIDCache.Load(key); ok {
return cached.(string), nil
}
projectIDCache.Load(key)从缓存中查找- 如果找到(
ok == true),直接返回缓存的值,不需要再调用 API - 这是性能优化,避免重复请求
③ fmt.Sprintf 的用法:
path := fmt.Sprintf("/%s/%s/detail", ctx.Owner, ctx.Repo)
- 类似 Python 的
"%s/%s/detail" % (owner, repo) %s是占位符,会被后面的参数替换
④ ctx.CallAPI 是什么?
env, err := ctx.CallAPI("GET", path, nil)
ctx是*common.RuntimeContext类型CallAPI是RuntimeContext结构体的方法(后面会详细讲)- 它内部调用
ctx.Client.Do()发送 HTTP 请求
第 137-140 行:parseProjectIDInt 函数
func parseProjectIDInt(pid string) int {
n, _ := strconv.Atoi(pid)
return n
}
字面意思:把字符串形式的 projectID 转成整数
运行时作用:Wiki API 的某些接口要求 projectId 是整数类型,所以需要转换。
小白补充:
strconv.Atoi是 string convert to int 的缩写_是 Go 语言的"忽略符",表示忽略返回的错误(这里假设 pid 一定是合法数字)
第 142-154 行:resolveUpdateContent 函数
func resolveUpdateContent(ctx *common.RuntimeContext, text, filePath string) (string, error) {
if text != "" {
return text, nil // 直接使用提供的文本
}
if filePath != "" {
data, err := os.ReadFile(filePath) // 从文件读取
if err != nil {
return "", fmt.Errorf("failed to read file %s: %w", filePath, err)
}
return string(data), nil
}
return "", fmt.Errorf("no content provided")
}
字面意思:解析更新 Wiki 时的内容来源
运行时作用:支持两种方式提供内容:直接文本(--cover)或文件路径(--file)。
第 156-179 行:fetchPageContent 函数(带自动重试)
func fetchPageContent(ctx *common.RuntimeContext, projectID, pageName string) (content string, actualPageName string, err error) {
// 第一次尝试
c, actual, err := fetchPageContentOnce(ctx, projectID, pageName)
if err == nil {
return c, actual, nil // 成功了,直接返回
}
// 第一次失败,且 pageName 不带 ".-" 后缀,自动重试
if !strings.HasSuffix(pageName, ".-") {
c2, actual2, err2 := fetchPageContentOnce(ctx, projectID, pageName+".-")
if err2 == nil {
return c2, actual2, nil // 重试成功
}
}
// 两次都失败
return "", "", fmt.Errorf("获取 Wiki 页面现有内容失败: %w", err)
}
字面意思:获取 Wiki 页面的明文内容,带自动重试机制
运行时作用:解决 GitLink 后端的一个命名问题。
小白补充:
① GitLink 后端的命名 bug:
GitLink 创建 Wiki 页面时,会自动给内部存储的 sub_url 追加 ".-" 后缀。但 wiki +list 返回的 title 不带后缀。
比如:
- 用户创建页面 "Home"
- 后端实际存储的 key 是 "Home.-"
- 但 list API 返回的 title 是 "Home"
所以用 "Home" 去查询会 404,必须用 "Home.-" 才能查到。
② 自动重试逻辑:
- 先用原始
pageName尝试查询 - 如果失败,且
pageName不带".-"后缀 - 自动用
pageName+".-"重试一次
第 181-205 行:fetchPageContentOnce 函数(单次查询)
func fetchPageContentOnce(ctx *common.RuntimeContext, projectID, pageName string) (string, string, error) {
// 构建查询参数
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", projectID)
q.Set("pageName", pageName)
// 调用 API
env, err := callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q)
if err != nil {
return "", "", err
}
// 解析响应
data, ok := env.Data.(map[string]interface{})
if !ok {
return "", "", fmt.Errorf("unexpected response from getWiki")
}
// 提取 base64 编码的内容
b64, _ := data["content_base64"].(string)
if b64 == "" {
return "", pageName, nil // 内容为空,返回空字符串
}
// Base64 解码
decoded, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return "", "", fmt.Errorf("failed to decode page content: %w", err)
}
return string(decoded), pageName, nil
}
字面意思:单次尝试获取 Wiki 页面内容
运行时作用:发送 GET 请求到 /wiki/open/getWiki,获取页面数据并解码。
小白补充:
① URL 查询参数构建:
q := url.Values{}
q.Set("owner", ctx.Owner)
url.Values是 map 类型,用来存储查询参数- 最终会变成
?owner=zzx&repo=test&projectId=12345&pageName=Home
② Base64 编解码:
b64, _ := data["content_base64"].(string) // 获取 base64 编码的内容
decoded, err := base64.StdEncoding.DecodeString(b64) // 解码
return string(decoded), pageName, nil // 转成字符串返回
- Wiki API 返回的内容是 Base64 编码的(可能是为了支持二进制文件)
- 需要解码才能得到人类可读的文本
第 207-216 行:fetchWikiPage 函数
func fetchWikiPage(ctx *common.RuntimeContext, projectID, pageName string) (*output.Envelope, error) {
q := url.Values{}
q.Set("owner", ctx.Owner)
q.Set("repo", ctx.Repo)
q.Set("projectId", projectID)
q.Set("pageName", pageName)
return callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q)
}
字面意思:获取 Wiki 页面的完整响应(不解码)
运行时作用:和 fetchPageContent 类似,但返回完整的 Envelope 而不是解码后的文本。
第 218-230 行:resolveContent 函数
func resolveContent(ctx *common.RuntimeContext) (string, error) {
if content := ctx.Arg("content"); content != "" {
return content, nil
}
if filePath := ctx.Arg("file"); filePath != "" {
data, err := os.ReadFile(filePath)
if err != nil {
return "", fmt.Errorf("failed to read file %s: %w", filePath, err)
}
return string(data), nil
}
return "", fmt.Errorf("--content or --file is required to provide wiki page content")
}
字面意思:解析创建 Wiki 时的内容来源
运行时作用:支持 --content 直接传内容,或 --file 从文件读取。
小白补充:
ctx.Arg("content")是从命令行参数中获取--content的值- 如果两个参数都没提供,返回错误
第 232-249 行:cleanWikiList 函数
func cleanWikiList(env *output.Envelope) {
// 把 data 转成 slice
items, ok := env.Data.([]interface{})
if !ok {
return
}
// 遍历每个 wiki 页面
for _, item := range items {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
// 删除不需要的字段
delete(m, "wiki_clone_link")
// URL 解码 sub_url
if raw, ok := m["sub_url"].(string); ok {
if decoded, err := url.QueryUnescape(raw); err == nil {
m["sub_url"] = decoded
}
}
}
}
字面意思:清理 Wiki 列表数据
运行时作用:对 wiki +list 返回的数据进行清洗,去掉无用字段,解码 URL。
第 251-299 行:outputWithDecodedContent 函数
func outputWithDecodedContent(ctx *common.RuntimeContext, env *output.Envelope) error {
data := env.Data
// 处理 JSON 字符串形式的 data
if raw, ok := data.(json.RawMessage); ok {
var m map[string]interface{}
if err := json.Unmarshal(raw, &m); err == nil {
data = m
env.Data = m
}
}
// 转成 map
m, ok := data.(map[string]interface{})
if !ok {
return ctx.Output(env)
}
// content_base64 → content(重命名并解码)
if b64, ok := m["content_base64"].(string); ok && b64 != "" {
if decoded, err := base64.StdEncoding.DecodeString(b64); err == nil {
m["content"] = string(decoded)
delete(m, "content_base64") // 删除原字段
}
}
// sidebar / footer 原地解码
for _, field := range []string{"sidebar", "footer"} {
if b64, ok := m[field].(string); ok && b64 != "" {
if decoded, err := base64.StdEncoding.DecodeString(b64); err == nil {
m[field] = string(decoded)
}
}
}
return ctx.Output(env)
}
字面意思:解码 Wiki 响应中的所有 Base64 字段,用明文替换
运行时作用:让返回的 Wiki 内容更易读,同时节省 token(Base64 编码会增加约 33% 的体积)。