4 Commits

29 changed files with 2811 additions and 702 deletions

View File

@@ -1,366 +1,249 @@
# GoCommon 业务项目对接操作手册
本文档供**引用 GoCommon 的业务项目**使用。按本手册对接即可,无需重复沟通基础设施实现方式。
# GoCommon 业务项目对接手册
模块路径:`git.toowon.com/jimmy/go-common`
> 本文档只描述**目标架构**,重构时一步到位,**不提供过渡期用法,不考虑向后兼容**
> 目标架构一步到位,不考虑向后兼容。
---
## 1. 对接原则
## 1. 原则
| 原则 | 说明 |
|------|------|
| Factory 只是入口 | 启动初始化一次,通过 getter 获取各模块**对象** |
| 能力在模块自身 | 使用 `log.Info()``db.Find()``store.Upload()`,不在 Factory 上堆透传方法 |
| 按需引用 | 用什么模块取什么对象;无状态工具直接 `import tools` |
| 不重写基础设施 | 数据库连接、Redis 客户端、统一 HTTP 出参、中间件链由 GoCommon 提供 |
| 业务只管业务 | Service 返回数据;Handler 交给 `http.Handler` 出参Migration 只写 SQL 文件 |
| Factory 只是入口 | 启动初始化一次getter 取模块对象 |
| 能力在模块自身 | `log.Info()``db.Find()``store.Upload()`,不在 Factory 透传 |
| 按需配置 | 只用到的模块写进 `config.json` |
| 不重写基础设施 | 连接池、HTTP 出参、中间件链由本库提供 |
| 业务只管业务 | Service 返回数据;出参用 `http.Handler`;迁移只写 SQL |
登录鉴权、参数校验、出站 HTTP Client 等由**业务项目自行实现**,不在本库范围。
---
## 2. 环境准备
### 2.1 配置私有模块
## 2. 安装
```bash
go env -w GOPRIVATE=git.toowon.com
go env -w GONOPROXY=git.toowon.com
go env -w GONOSUMDB=git.toowon.com
```
### 2.2 Git 认证SSH 推荐)
```bash
git config --global url."git@git.toowon.com:".insteadOf "https://git.toowon.com/"
go get git.toowon.com/jimmy/go-common@v1.2.0
```
### 2.3 安装依赖
```bash
go get git.toowon.com/jimmy/go-common@v2.0.0
```
`go.mod` 中:
```go
require git.toowon.com/jimmy/go-common v2.0.0
```
配置示例见 [`config/example.json`](./config/example.json)。
配置示例:[`config/example.json`](./config/example.json)。
---
## 3. 推荐项目结构
## 3. 推荐结构
```text
your-project/
├── config.json
├── cmd/
│ ├── server/main.go
│ └── migrate/main.go # 从 templates/migrate/main.go 复制
├── cmd/server/main.go # 复制 templates/server/main.go
├── cmd/migrate/main.go # 复制 templates/migrate/main.go
├── migrations/
│ └── 20240101000001_create_users.sql
├── locales/ # i18n可选
│ ├── zh-CN.json
│ └── en-US.json
├── internal/
│ ├── handler/
│ └── service/
├── locales/ # 可选
├── internal/handler/
├── internal/service/
└── go.mod
```
---
## 4. 启动初始化(只做一次)
## 4. 启动(只做一次)
```go
package main
import (
"log"
"net/http"
"git.toowon.com/jimmy/go-common/factory"
)
func main() {
if err := factory.Init("config.json"); err != nil {
log.Fatal(err)
}
app := factory.Default()
chain := app.MiddlewareChain()
chain.Append(yourAuthMiddleware)
userSvc, err := NewUserService(app)
if err != nil {
log.Fatal(err)
}
http.Handle("/api/users", chain.ThenFunc(func(w http.ResponseWriter, r *http.Request) {
listUsers(w, r, userSvc, app)
}))
log.Fatal(http.ListenAndServe(":8080", nil))
}
```bash
cp templates/server/main.go cmd/server/main.go
```
**禁止**在每个 Handler 里 `NewFromFile` / `Init`。全局只初始化一次,通过 `factory.Default()` 或注入 `*factory.Factory`
模板默认已包含:`MustInit` + `buildOptions`Option 挂载点)+ `MustLogger` + `Warmup` + `MiddlewareChain` + `NewHandler` + `Close`
复制后主要改三处:
| 函数 | 用途 |
|------|------|
| `buildOptions()` | `WithLogger` / `WithStorage` 等自定义注入 |
| `setupMiddleware()` | `chain.Append(业务鉴权…)` |
| `registerRoutes()` | 注册业务路由 |
```bash
go run ./cmd/server -config config.json -addr :8080
```
---
## 5. 获取模块对象Factory getter
## 5. 获取模块对象
连接与客户端由 Factory **lazy 初始化并缓存**。业务侧**不要**自行 `gorm.Open` / `redis.NewClient`
连接由 Factory lazy 缓存。业务侧不要自行 `gorm.Open` / `redis.NewClient`
| 模块 | API | 用法 |
| 模块 | API | 说明 |
|------|-----|------|
| 配置 | `app.Config()` | 原始配置 |
| 数据库 | `app.Database()` | `db.Find(&users)``db.Transaction(...)` |
| Redis | `app.Redis()` | `rds.Set(ctx, key, val, ttl)` |
| 日志 | `app.Logger()` | `log.Info(...)`退出 `log.Close()` |
| 存储 | `app.Storage()` | `store.Upload(ctx, key, reader)` |
| 邮件 | `app.Email()` | `mail.SendEmail(...)` |
| 短信 | `app.SMS()` | `sms.SendSMS(...)` |
| Excel | `app.Excel()` | `ex.ExportToFile(...)` |
| 国际化 | `app.I18n()` | `http.Handler` 注入;或 `i18n.GetMessage(...)` |
| 中间件链 | `app.MiddlewareChain()` | `chain.Append(...).ThenFunc(...)` |
| 迁移 | `app.Migrator("migrations")` | `m.Up()` / `m.Status()` / `m.Down()` |
| 配置 | `Config()` | 原始配置 |
| 数据库 | `Database()` / `MustDatabase()` | `*gorm.DB` |
| Redis | `Redis()` / `MustRedis()` | `*redis.Client` |
| 日志 | `Logger()` / `MustLogger()` | 退出 `Close()` |
| 存储 | `Storage()` / `MustStorage()` | Upload / GetURL |
| 邮件 | `Email()` / `MustEmail()` | SendEmail / SendEmailAsync |
| 短信 | `SMS()` / `MustSMS()` | SendSMS / SendSMSAsync |
| Excel | `Excel()` | 每次新建导出器 |
| 国际化 | `I18n()` / `MustI18n()` | 一般由 `NewHandler` 注入 |
| MCP 工具 | `MCP()` / `MustMCP()` | `ListTools` / `CallTool`(见 §8 |
| 图形验证码 | `Captcha()` / `MustCaptcha()` | `Generate` / `Verify` / 登录防暴力(见 §8 |
| HTTP 出参 | `NewHandler(w, r)` | `Success` / `Error` / `ErrorData` |
| 中间件 | `MiddlewareChain()` | `Append` / `ThenFunc` |
| 迁移 | `Migrator(dir)` | Up / Down / Status |
| 生命周期 | `Close()` / `Warmup(...)` | 收口 / 启动预热 |
### 注入 Service 示例
`MustXxx` 仅用于 **main / 启动注入**(失败 panic
```go
type UserService struct {
db *gorm.DB
rds *redis.Client
}
func NewUserService(app *factory.Factory) (*UserService, error) {
db, err := app.Database()
if err != nil {
return nil, err
}
rds, err := app.Redis()
if err != nil {
return nil, err
}
return &UserService{db: db, rds: rds}, nil
}
func (s *UserService) List(page, size int) (users []User, total int64, err error) {
s.db.Model(&User{}).Count(&total)
err = s.db.Offset((page-1)*size).Limit(size).Find(&users).Error
return
func NewUserService(app *factory.Factory) *UserService {
return &UserService{db: app.MustDatabase(), rds: app.MustRedis()}
}
```
`config.json` 未配置的模块,调用 getter 会返回错误——**只配置使用的模块即可**。
---
## 6. HTTP 统一出参
### 6.1 职责划分
| 层级 | 职责 |
|------|------|
| Service | 返回数据或 error |
| Handler | 解析、调 Service、`h.Success` / `h.Error` |
| `http.Handler` | 统一 JSON 信封、i18n、timestamp |
| 层级 | 做什么 |
|------|--------|
| Service | 返回 `[]User``total`、业务 error |
| Handler | 解析请求、调 Service、通过 `http.Handler` 出参 |
| `http.Handler` | PageData → Response → JSON结构 / 编码 / 语种 / 时间统一) |
**禁止**在 Service 拼 JSON**禁止**在业务项目自定义 `Response` 结构,**禁止**经 Factory 出参(无 `app.Success` / `app.Error`)。
### 6.2 标准响应格式
响应格式HTTP 恒 200
```json
{
"code": 0,
"message": "success",
"timestamp": 1704067200,
"data": {}
}
{ "code": 0, "message": "success", "timestamp": 1704067200, "data": {} }
```
分页 `data`
```json
{
"list": [],
"total": 100,
"page": 1,
"pageSize": 20
}
```
类型定义在 `http` 包:`http.Response``http.PageData`
### 6.3 Handler 用法(唯一方式)
中间件链须包含 `Language``Timezone``MiddlewareChain()` 已默认组装)。
分页 `data``{ "list", "total", "page", "pageSize" }`
```go
import commonhttp "git.toowon.com/jimmy/go-common/http"
h := app.NewHandler(w, r) // 自动注入已配置的 i18n
func listUsers(w http.ResponseWriter, r *http.Request, svc *UserService, app *factory.Factory) {
i18n, _ := app.I18n()
h := commonhttp.NewHandler(w, r, commonhttp.WithI18n(i18n))
var req ListUserRequest
if err := h.ParseJSON(&req); err != nil {
var req ListUserRequest
if err := h.ParseJSON(&req); err != nil {
h.Error("common.invalid_request")
return
}
p := h.Pagination()
users, total, err := svc.List(p.GetPage(), p.GetPageSize())
if err != nil {
}
users, total, err := svc.List(h.Pagination().GetPage(), h.Pagination().GetPageSize())
if err != nil {
h.Error("user.list_failed")
return
}
h.SuccessPage(users, total)
}
h.SuccessPage(users, total)
```
`http.Handler` 统一负责:
- `Content-Type: application/json; charset=utf-8`
- 从 context 读取语种、时区
- 消息码(如 `user.not_found`)经 i18n 转为文案与业务 code
- `timestamp` 按统一时区策略写入
### 6.4 请求头约定
| Header | 用途 |
|--------|------|
| `Accept-Language` | 响应消息语种 |
| `X-Timezone` | 时区(默认 `Asia/Shanghai` |
请求头:`Accept-Language`(语种)、`X-Timezone`(默认 `Asia/Shanghai`)。
---
## 7. 数据库迁移
执行框架已封装,业务**只写 SQL 文件**。
### 7.1 推荐:独立 migrate 命令(与 Web 解耦)
```bash
cp templates/migrate/main.go cmd/migrate/main.go
go build -o bin/migrate cmd/migrate/main.go
./bin/migrate up
./bin/migrate status
./bin/migrate down
./bin/migrate up -config config.json -dir migrations
./bin/migrate up|status|down
```
模板默认走 Factory`MustInit` + 日志 + `Warmup(Database)` + `Migrator` + `Close`;同样可用 `buildOptions()` 注入自定义 DB/Logger。
```sql
-- migrations/20240101000001_create_users.sql
CREATE TABLE users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(255) NOT NULL
);
```
CREATE TABLE users (id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(255) NOT NULL);
```sql
-- migrations/20240101000001_create_users.down.sql
DROP TABLE IF EXISTS users;
```
独立 CLI 内部调用 `migration.RunMigrationsFromConfigWithCommand`,无需业务实现连接逻辑。
### 7.2 可选:经 Factory开发 / 小项目)
```go
m, err := app.Migrator("migrations")
if err != nil {
log.Fatal(err)
}
if err := m.Up(); err != nil {
log.Fatal(err)
}
```
### 7.3 Docker
```yaml
command: sh -c "./bin/migrate up && ./bin/server"
volumes:
- ./config.json:/app/config.json:ro
- ./migrations:/app/migrations:ro
```
---
## 8. 模块按需对接
### 8.1 日志
## 8. 模块速查
```go
log, _ := app.Logger()
defer log.Close()
log.Info("服务启动")
```
// 日志
app.MustLogger().Info("服务启动", nil)
### 8.2 存储
```go
// 存储
store, _ := app.Storage()
store.Upload(ctx, "images/a.jpg", fileReader, "image/jpeg")
url, _ := store.GetURL("images/a.jpg", 3600)
```
_ = store.Upload(ctx, "images/a.jpg", reader, "image/jpeg")
不经 Factory 时:`storage.NewStorage(storage.StorageTypeLocal, cfg)`
// 邮件 / 短信HTTP 通知用 Async验证码用同步
app.MustEmail().SendEmailAsync(ctx, []string{"a@b.com"}, "主题", "正文")
app.MustSMS().SendSMSAsync(ctx, []string{"13800138000"}, map[string]string{"code": "123456"})
### 8.3 邮件 / 短信
// Excel
app.Excel().ExportToFile("users.xlsx", "用户列表", columns, users)
```go
mail, _ := app.Email()
defer mail.Close()
// HTTP 通知类:异步,不阻塞请求
mail.SendEmailAsync(r.Context(), []string{"a@b.com"}, "主题", "正文")
// 验证码等需等待结果:同步
mail.SendEmail([]string{"a@b.com"}, "主题", "正文")
sms, _ := app.SMS()
defer sms.Close()
sms.SendSMSAsync(r.Context(), []string{"13800138000"}, map[string]string{"code": "123456"})
sms.SendSMS([]string{"13800138000"}, map[string]string{"code": "123456"})
```
### 8.4 Excel
```go
ex := app.Excel()
ex.ExportToFile("users.xlsx", "用户列表", columns, users)
```
### 8.5 国际化
```go
i18n, _ := app.I18n()
i18n.LoadFromDir("locales")
msg := i18n.GetMessage("zh-CN", "user.not_found")
```
HTTP 出参的 i18n 通过 `NewHandler(..., WithI18n(i18n))` 注入Handler 内用消息码调用 `h.Error("user.not_found")`
### 8.6 无状态工具(不经 Factory
```go
import "git.toowon.com/jimmy/go-common/tools"
// i18nlocales 目录;出参经 NewHandler 自动用)
app.MustI18n().GetMessage("zh-CN", "user.not_found")
// tools不经 Factory
tools.Now()
tools.MD5("text")
tools.YuanToCents(100.5)
```
### MCP 工具Model Context ProtocolClient
本库只做 **MCP Client**:按配置连接外部 MCP server容器/服务),发现工具、调用工具;
**不含路由/抽参/编排逻辑**——用哪个工具、怎么填参数、怎么组合答复,属于业务侧的
Prompt/Agent 编排,需要业务项目自行实现(可参考已有 RAG 项目里的 ToolOrchestrator 写法)。
```go
m := app.MustMCP() // config.mcp 未配置或 enabled=false 时 m.Enabled() 为 false
tools, _ := m.ListTools(ctx) // 聚合全部已启用 server 的工具FQName = "{toolNamePrefix}__{原始工具名}"
res, _ := m.CallTool(ctx, "word__create_document", map[string]any{"title": "周报"})
// 可选:把 HTTP 层的 request id 传入,调用日志会附带该字段(不传不影响功能)
ctx = mcp.WithRequestID(ctx, requestID)
```
`config.json``mcp.servers[]` 一条 = 一个独立 MCP server自带多个工具新增能力 =
加一条配置,不改代码;`allowedTools` 留空表示开放该 server 全部工具。完整字段见
[`config/example.json`](./config/example.json) 的 `mcp` 段。
### 图形验证码Captcha
可选模块:`config.captcha.enabled=false`(或未配置)时 `Captcha().Enabled()` 为 false**不影响 middleware**;业务 handler 按需调用即可。
```go
cap := app.MustCaptcha() // enabled=true 时需配置 redis
// 1. 生成(注册/改手机号等发短信前)
result, _ := cap.Generate(ctx, captcha.SceneRegister)
// 返回 result.ID + result.ImageBase64 给前端展示
// 2. 校验(一次性消费)
if err := cap.Verify(ctx, captcha.VerifyRequest{
Scene: captcha.SceneRegister, ID: captchaID, Answer: userInput,
}); err != nil {
h.Error("captcha.invalid")
return
}
// 验证通过后再 app.MustSMS().SendSMS(...)
// 3. 登录防暴力(失败 N 次后要求验证码M 次后锁定)
key := username // 或 username+IP由业务决定
if need, _ := cap.LoginNeedCaptcha(ctx, key); need {
// 要求前端传 captchaId + captchaCode并 Verify
}
status, err := cap.RecordLoginFailure(ctx, key)
if status.Locked { h.Error("captcha.login_locked"); return }
// 登录成功后
_ = cap.ClearLoginFailures(ctx, key)
```
场景常量:`SceneRegister` / `SceneChangePhone` / `SceneLogin` / `SceneSendSMS`
`scenes.register.mode=always` 表示该场景始终要求图形验证码;登录场景由 `login.showAfterFailures` 控制「失败后才要求」。
完整配置见 [`config/example.json`](./config/example.json) 的 `captcha` 段。
---
## 9. config.json 最小示例
## 9. 最小 config.json
```json
{
@@ -389,41 +272,24 @@ tools.YuanToCents(100.5)
---
## 10. 反模式(禁止)
## 10. 反模式
- 每个 Handler 内 `factory.Init` / `NewFromFile`
- 业务 Service 内写 HTTP JSON 响应
- Handler 内重复 `Init`
- Service HTTP JSON / 自定义 Response
- 自行 `gorm.Open` / `redis.NewClient`
- 使用 Factory 透传`LogInfo``RedisSet``Success``Error``Now``MD5`
- 直接调用 `http.Success(w, ...)` 包级函数(应使用 `http.Handler`
- 在业务项目复制 GoCommon 的 Response / 中间件实现
- Factory 透传`LogInfo``Success``Now`
- 请求路径滥用 `MustXxx`
- 把 MCP 工具路由/抽参/Prompt 编排逻辑塞进本库(应在业务侧基于 `mcp.Manager` 自行实现
---
## 11. 故障排除
**无法下载模块:**
```bash
go env -w GOPRIVATE=git.toowon.com
git config --global url."git@git.toowon.com:".insteadOf "https://git.toowon.com/"
go get git.toowon.com/jimmy/go-common@latest
```
**依赖错误:** `go clean -modcache && go mod tidy`
**getter 报 config is nil** 补充 `config.json` 对应段,或不调用该 getter
**迁移找不到文件:** 检查 `-dir` 与 SQL 文件命名
---
## 12. 文档与版本
| 文档 | 用途 |
| 问题 | 处理 |
|------|------|
| 本文档 | 业务项目对接 |
| [README.md](./README.md) | 库概述 |
| [VERSION.md](./VERSION.md) | 版本发布 |
| 无法下载模块 | 检查 `GOPRIVATE` 与 SSH insteadOf |
| 依赖异常 | `go clean -modcache && go mod tidy` |
| getter 报 config is nil | 补配置段,或不调用该模块 |
| 迁移找不到文件 | 检查 `-dir` 与 SQL 命名 |
版本升级见 [VERSION.md](./VERSION.md)。
版本发布见 [VERSION.md](./VERSION.md)。

View File

@@ -1,56 +1,47 @@
# GoCommon - Go 通用工具类库
# GoCommon
供其他 Go 项目引用的通用工具集合。业务项目对接请直接阅读:
**[业务项目对接操作手册INTEGRATION.md](./INTEGRATION.md)**
## 模块路径
供其他 Go 项目引用的通用工具库。对接请看:**[INTEGRATION.md](./INTEGRATION.md)**
```
git.toowon.com/jimmy/go-common
```
## 安装
```bash
go env -w GOPRIVATE=git.toowon.com
go get git.toowon.com/jimmy/go-common@v2.0.0
go get git.toowon.com/jimmy/go-common@v1.4.0
```
## 设计概要
## 设计
- **Factory**入口,启动初始化一次,按需 getter 取模块对象DB、Redis、Logger 等)
- **模块包**能力在对象方法上(`log.Info()``store.Upload()`
- **http**:统一 HTTP 出参Response / PageData / Handler
- **migration**:独立 CLI Factory 执行 SQL 迁移
- **tools**:无状态工具函数,直接 import
- **Factory**启动初始化一次getter 取模块对象
- **模块包**`object.Method()`(如 `log.Info``store.Upload`
- **http**:统一出参(`Handler`
- **tools / migration**无状态或独立 CLI,不经 Factory 透传
## 功能模块
## 模块
| 模块 | 包路径 | 说明 |
|------|--------|------|
| 配置 | `config` | JSON 配置加载 |
| 工厂 | `factory` | 统一入口与 lazy getter |
| HTTP | `http` | 请求解析、统一响应 |
| 中间件 | `middleware` | CORS、日志、Recovery、限流、语种、时区 |
| 工具 | `tools` | 时间、加密、金额、类型转换 |
| 日志 | `logger` | 异步日志 |
| 存储 | `storage` | Local / OSS / MinIO |
| 邮件 / 短信 | `email` / `sms` | SMTP、阿里云短信 |
| Excel | `excel` | 数据导出 |
| 国际化 | `i18n` | 多语言消息 |
| 迁移 | `migration` | SQL 版本管理 |
| 包 | 说明 |
|----|------|
| `config` | JSON 配置 |
| `factory` | 入口与 lazy getter |
| `http` | 请求解析、统一响应 |
| `middleware` | CORS、日志、Recovery、限流、语种、时区 |
| `logger` | 异步日志 |
| `storage` | Local / OSS / MinIO |
| `email` / `sms` | SMTP、阿里云短信 |
| `excel` | 导出 |
| `i18n` | 多语言消息 |
| `migration` | SQL 迁移 |
| `tools` | 时间、加密、金额等 |
| `mcp` | MCP Client多 server、`ListTools`/`CallTool` |
| `captcha` | 图形验证码、登录防暴力(可选,`enabled` 开关) |
## 文档
| 文档 | 说明 |
|------|------|
| [INTEGRATION.md](./INTEGRATION.md) | 业务项目对接操作手册 |
| [VERSION.md](./VERSION.md) | 版本管理与发布 |
| [templates/](./templates/) | migrate 等脚手架模板 |
| [config/example.json](./config/example.json) | 配置文件示例 |
| [examples/](./examples/) | 代码示例 |
## 许可证
MIT License
| [INTEGRATION.md](./INTEGRATION.md) | 对接手册 |
| [VERSION.md](./VERSION.md) | 版本与发布 |
| [templates/](./templates/) | server / migrate 等脚手架 |
| [config/example.json](./config/example.json) | 配置示例 |
| [examples/](./examples/) | 示例代码 |

View File

@@ -1,143 +1,35 @@
# 版本管理说明
# 版本管理
## 版本号规则
遵循 [语义化版本](https://semver.org/lang/zh-CN/)`v主.次.修订`
本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/) 规范:
- **主版本号MAJOR**:当你做了不兼容的 API 修改
- **次版本号MINOR**:当你做了向下兼容的功能性新增
- **修订号PATCH**:当你做了向下兼容的问题修正
版本格式:`v主版本号.次版本号.修订号`,例如:`v1.0.0``v1.1.0``v2.0.0`
## 发布新版本
### 方式1使用发布脚本推荐
## 发布
```bash
# 使用发布脚本(会自动验证版本格式、检查未提交更改等)
./scripts/release.sh v1.0.0 "Release version 1.0.0"
# 推荐:脚本校验并打标签
./scripts/release.sh v1.2.0 "Release version 1.2.0"
# 或手动
git tag -a v1.2.0 -m "Release version 1.2.0"
git push origin v1.2.0
```
### 方式2手动创建标签
发布前:代码已提交;同步更新本文档与 `README.md` 中的版本号。
## 消费方引用
```bash
# 1. 确保所有更改已提交
git add .
git commit -m "Prepare for release v1.0.0"
# 2. 创建版本标签
git tag -a v1.0.0 -m "Release version 1.0.0"
# 3. 推送标签到远程仓库
git push origin v1.0.0
# 或者一次性推送所有标签
git push origin --tags
go get git.toowon.com/jimmy/go-common@v1.3.0 # 生产固定版本
go get git.toowon.com/jimmy/go-common@latest # 开发
```
### 验证标签
```bash
# 查看所有标签
git tag -l
# 查看标签详情
git show v1.0.0
# 查看标签列表(带注释)
git tag -l -n
```
## 调用方如何使用版本
### 方式1使用最新版本推荐用于开发
```bash
go get git.toowon.com/jimmy/go-common@latest
```
### 方式2使用特定版本推荐用于生产
```bash
# 使用具体版本号
go get git.toowon.com/jimmy/go-common@v1.0.0
# 使用版本范围(自动选择最新版本)
go get git.toowon.com/jimmy/go-common@v1.0
```
### 方式3在 go.mod 中指定版本
```go
module your-project
require (
git.toowon.com/jimmy/go-common v1.0.0
)
```
然后运行:
```bash
go mod tidy
```
### 方式4更新到最新版本
```bash
# 更新到最新版本
go get -u git.toowon.com/jimmy/go-common@latest
# 更新到最新补丁版本(如从 v1.0.0 更新到 v1.0.1
go get -u=patch git.toowon.com/jimmy/go-common
# 更新到最新次版本(如从 v1.0.0 更新到 v1.1.0
go get -u=minor git.toowon.com/jimmy/go-common
```
## 版本发布流程
1. **开发完成**:确保所有功能已实现并通过测试
2. **更新版本号**:在 `README.md``VERSION.md` 中更新版本号
3. **提交代码**:提交所有更改到 Git
```bash
git add .
git commit -m "Prepare for release v1.0.0"
```
4. **创建并推送标签**
```bash
# 使用脚本(推荐)
./scripts/release.sh v1.0.0 "Release version 1.0.0"
# 或手动创建
git tag -a v1.0.0 -m "Release version 1.0.0"
git push origin v1.0.0
```
5. **验证**:在其他项目中测试是否能正确获取该版本
```bash
# 在新项目中测试
go get git.toowon.com/jimmy/go-common@v1.0.0
```
## 当前版本
当前版本:**v2.0.0**
**v1.4.0**
## 版本历史
- **v2.0.0** (当前版本Breaking)
- Factory 精简为 `Init` / `Default()` + lazy getter删除全部透传方法
- HTTP 出参统一由 `http.Handler` 负责,删除包级 `Success` / `SystemError`
- Logger API 精简为 `Debug/Info/Error(msg, fields)`,新增 Request ID + `FromContext`
- email / sms 新增异步队列 + `Close()`
- 中间件链默认顺序Recovery → RequestID → Logging → …
- **v1.0.0**
- 初始版本
- 包含所有基础工具类migration、datetime、http、middleware、config、storage、email、sms、factory、logger
- **v1.1.0** (未发布)
- storage新增本地文件夹存储LocalStorage
- config新增 `localStorage` 配置段
- factory支持 Local/MinIO/OSS 自动选择
- **v1.4.0** — 新增 `captcha` 模块:图形验证码(`Generate`/`Verify`、Redis 存储、场景配置)与登录防暴力(失败计数、触发验证码、临时锁定);`config.CaptchaConfig``factory.Captcha()`/`MustCaptcha()`/`WithCaptcha`/`ModuleCaptcha`**不进默认 middleware**,第三方按需 `enabled` 开关
- **v1.3.0** — 新增 `mcp` 模块MCPModel Context ProtocolClient`mcp.Manager`:多 server、命名空间前缀、白名单、`ListTools`/`CallTool``config.MCPConfig``factory.MCP()`/`MustMCP()`/`WithMCP`;不含路由/抽参/编排(业务侧自行实现)
- **v1.2.0** — DX`MustInit` / `Warmup` / `MustXxx` / `Close` / `NewHandler`Option 注入Excel 每次新建;`templates/server``ErrorData`;文档精简
- **v1.1.0** — 同上 DX 能力首次合入(见上)
- **v1.0.0** — 初始Factory lazy getter、http.Handler 统一出参、logger / email / sms / storage / middleware / migration / tools / i18n / excel

307
captcha/captcha.go Normal file
View File

@@ -0,0 +1,307 @@
package captcha
import (
"context"
"fmt"
"strings"
"time"
"git.toowon.com/jimmy/go-common/config"
"github.com/mojocn/base64Captcha"
"github.com/redis/go-redis/v9"
)
// Scene 验证码业务场景。
type Scene string
const (
SceneRegister Scene = "register"
SceneChangePhone Scene = "change_phone"
SceneLogin Scene = "login"
SceneSendSMS Scene = "send_sms"
)
// GenerateResult 生成图形验证码的返回。
type GenerateResult struct {
ID string `json:"id"`
ImageBase64 string `json:"imageBase64"`
}
// VerifyRequest 校验图形验证码的请求。
type VerifyRequest struct {
Scene Scene
ID string
Answer string
}
// LoginGuardStatus 登录防暴力状态。
type LoginGuardStatus struct {
FailCount int `json:"failCount"`
NeedCaptcha bool `json:"needCaptcha"`
Locked bool `json:"locked"`
RemainingSec int `json:"remainingSec"`
}
// Manager 图形验证码子系统(`app.Captcha()` 取得)。
//
// 职责:
// - 生成/校验图形验证码Redis 存储,一次性消费);
// - 登录失败计数、触发验证码、临时锁定;
// - Enabled() 反映 config.captcha.enabled 且 Redis 可用。
//
// 不含路由与业务编排:消费方在 handler 中按需调用 Generate / Verify / RecordLoginFailure。
type Manager interface {
Enabled() bool
Generate(ctx context.Context, scene Scene) (*GenerateResult, error)
Verify(ctx context.Context, req VerifyRequest) error
SceneRequiresCaptcha(scene Scene) bool
LoginNeedCaptcha(ctx context.Context, key string) (bool, error)
IsLoginLocked(ctx context.Context, key string) (bool, int, error)
RecordLoginFailure(ctx context.Context, key string) (LoginGuardStatus, error)
ClearLoginFailures(ctx context.Context, key string) error
}
type manager struct {
enabled bool
cfg *config.CaptchaConfig
rdb *redis.Client
captcha *base64Captcha.Captcha
}
// NewManager 根据配置与 Redis 构建图形验证码子系统。
// cfg 为 nil 或 enabled=false 时返回 Enabled()=false 的安全实例,其余方法返回 ErrDisabled。
// enabled=true 但 rdb=nil 时同样视为不可用Enabled()=false
func NewManager(rdb *redis.Client, cfg *config.CaptchaConfig) Manager {
m := &manager{cfg: cfg}
if cfg == nil || !cfg.Enabled || rdb == nil {
return m
}
normalizeCaptchaConfig(cfg)
ttl := time.Duration(cfg.TTLSec) * time.Second
store := newRedisStore(rdb, ttl)
driver := base64Captcha.NewDriverDigit(cfg.Height, cfg.Width, cfg.Length, 0.7, 80)
m.enabled = true
m.rdb = rdb
m.captcha = base64Captcha.NewCaptcha(driver, store)
return m
}
func normalizeCaptchaConfig(cfg *config.CaptchaConfig) {
if cfg.Length == 0 {
cfg.Length = 4
}
if cfg.Width == 0 {
cfg.Width = 120
}
if cfg.Height == 0 {
cfg.Height = 40
}
if cfg.TTLSec == 0 {
cfg.TTLSec = 300
}
if cfg.Login == nil {
cfg.Login = &config.LoginCaptchaConfig{}
}
if cfg.Login.ShowAfterFailures == 0 {
cfg.Login.ShowAfterFailures = 3
}
if cfg.Login.LockAfterFailures == 0 {
cfg.Login.LockAfterFailures = 10
}
if cfg.Login.LockDurationMin == 0 {
cfg.Login.LockDurationMin = 30
}
}
func (m *manager) Enabled() bool {
return m != nil && m.enabled
}
func (m *manager) Generate(ctx context.Context, scene Scene) (*GenerateResult, error) {
if err := m.requireEnabled(); err != nil {
return nil, err
}
_ = ctx
id, b64s, _, err := m.captcha.Generate()
if err != nil {
return nil, fmt.Errorf("captcha generate: %w", err)
}
return &GenerateResult{
ID: id,
ImageBase64: b64s,
}, nil
}
func (m *manager) Verify(ctx context.Context, req VerifyRequest) error {
if err := m.requireEnabled(); err != nil {
return err
}
_ = ctx
id := strings.TrimSpace(req.ID)
answer := strings.TrimSpace(req.Answer)
if id == "" || answer == "" {
return ErrInvalid
}
if !m.captcha.Verify(id, answer, true) {
return ErrInvalid
}
return nil
}
func (m *manager) SceneRequiresCaptcha(scene Scene) bool {
if !m.Enabled() {
return false
}
if scene == SceneLogin {
return false
}
mode := m.sceneMode(scene)
return mode == "always"
}
func (m *manager) LoginNeedCaptcha(ctx context.Context, key string) (bool, error) {
if !m.Enabled() {
return false, nil
}
loginCfg := m.loginConfig()
if loginCfg.ShowAfterFailures <= 0 {
return false, nil
}
locked, _, err := m.IsLoginLocked(ctx, key)
if err != nil {
return false, err
}
if locked {
return true, nil
}
count, err := m.getLoginFailCount(ctx, key)
if err != nil {
return false, err
}
return count >= loginCfg.ShowAfterFailures, nil
}
func (m *manager) IsLoginLocked(ctx context.Context, key string) (bool, int, error) {
if !m.Enabled() {
return false, 0, nil
}
ttl, err := m.rdb.TTL(ctx, loginLockKey(key)).Result()
if err != nil {
return false, 0, err
}
if ttl <= 0 {
return false, 0, nil
}
return true, int(ttl.Seconds()), nil
}
func (m *manager) RecordLoginFailure(ctx context.Context, key string) (LoginGuardStatus, error) {
status := LoginGuardStatus{}
if !m.Enabled() {
return status, nil
}
loginCfg := m.loginConfig()
lockKey := loginLockKey(key)
failKey := loginFailKey(key)
if ttl, err := m.rdb.TTL(ctx, lockKey).Result(); err != nil {
return status, err
} else if ttl > 0 {
status.Locked = true
status.NeedCaptcha = true
status.RemainingSec = int(ttl.Seconds())
return status, ErrLoginLocked
}
count, err := m.rdb.Incr(ctx, failKey).Result()
if err != nil {
return status, err
}
if count == 1 {
_ = m.rdb.Expire(ctx, failKey, time.Duration(loginCfg.LockDurationMin)*time.Minute).Err()
}
status.FailCount = int(count)
status.NeedCaptcha = loginCfg.ShowAfterFailures > 0 && int(count) >= loginCfg.ShowAfterFailures
if loginCfg.LockAfterFailures > 0 && int(count) >= loginCfg.LockAfterFailures {
lockTTL := time.Duration(loginCfg.LockDurationMin) * time.Minute
if err := m.rdb.Set(ctx, lockKey, "1", lockTTL).Err(); err != nil {
return status, err
}
_ = m.rdb.Del(ctx, failKey).Err()
status.Locked = true
status.NeedCaptcha = true
status.RemainingSec = int(lockTTL.Seconds())
return status, ErrLoginLocked
}
return status, nil
}
func (m *manager) ClearLoginFailures(ctx context.Context, key string) error {
if !m.Enabled() {
return nil
}
return m.rdb.Del(ctx, loginFailKey(key), loginLockKey(key)).Err()
}
func (m *manager) requireEnabled() error {
if !m.Enabled() {
return ErrDisabled
}
return nil
}
func (m *manager) sceneMode(scene Scene) string {
if m.cfg == nil || m.cfg.Scenes == nil {
return defaultSceneMode(scene)
}
if sc, ok := m.cfg.Scenes[string(scene)]; ok && sc.Mode != "" {
return sc.Mode
}
return defaultSceneMode(scene)
}
func defaultSceneMode(scene Scene) string {
switch scene {
case SceneRegister, SceneChangePhone, SceneSendSMS:
return "always"
default:
return "never"
}
}
func (m *manager) loginConfig() config.LoginCaptchaConfig {
if m.cfg == nil || m.cfg.Login == nil {
return config.LoginCaptchaConfig{
ShowAfterFailures: 3,
LockAfterFailures: 10,
LockDurationMin: 30,
}
}
return *m.cfg.Login
}
func (m *manager) getLoginFailCount(ctx context.Context, key string) (int, error) {
n, err := m.rdb.Get(ctx, loginFailKey(key)).Int()
if err == redis.Nil {
return 0, nil
}
return n, err
}
func loginFailKey(key string) string {
return "captcha:login:fail:" + key
}
func loginLockKey(key string) string {
return "captcha:login:lock:" + key
}

174
captcha/captcha_test.go Normal file
View File

@@ -0,0 +1,174 @@
package captcha_test
import (
"context"
"testing"
"time"
"git.toowon.com/jimmy/go-common/captcha"
"git.toowon.com/jimmy/go-common/config"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
)
func TestDisabledManager(t *testing.T) {
m := captcha.NewManager(nil, nil)
if m.Enabled() {
t.Fatal("expected disabled")
}
_, err := m.Generate(context.Background(), captcha.SceneRegister)
if err != captcha.ErrDisabled {
t.Fatalf("Generate err = %v, want ErrDisabled", err)
}
}
func TestGenerateAndVerify(t *testing.T) {
s, err := miniredis.Run()
if err != nil {
t.Fatal(err)
}
defer s.Close()
rdb := redis.NewClient(&redis.Options{Addr: s.Addr()})
cfg := &config.CaptchaConfig{
Enabled: true,
Length: 4,
Width: 120,
Height: 40,
TTLSec: 300,
Login: &config.LoginCaptchaConfig{},
}
m := captcha.NewManager(rdb, cfg)
if !m.Enabled() {
t.Fatal("expected enabled")
}
ctx := context.Background()
result, err := m.Generate(ctx, captcha.SceneRegister)
if err != nil {
t.Fatal(err)
}
if result.ID == "" || result.ImageBase64 == "" {
t.Fatal("expected id and image")
}
// 从 redis 读取答案用于测试
answer, err := rdb.Get(ctx, "captcha:code:"+result.ID).Result()
if err != nil {
t.Fatal(err)
}
if err := m.Verify(ctx, captcha.VerifyRequest{
Scene: captcha.SceneRegister,
ID: result.ID,
Answer: answer,
}); err != nil {
t.Fatalf("Verify: %v", err)
}
if err := m.Verify(ctx, captcha.VerifyRequest{
Scene: captcha.SceneRegister,
ID: result.ID,
Answer: answer,
}); err != captcha.ErrInvalid {
t.Fatalf("second Verify err = %v, want ErrInvalid", err)
}
}
func TestLoginGuard(t *testing.T) {
s, err := miniredis.Run()
if err != nil {
t.Fatal(err)
}
defer s.Close()
rdb := redis.NewClient(&redis.Options{Addr: s.Addr()})
cfg := &config.CaptchaConfig{
Enabled: true,
Length: 4,
Width: 120,
Height: 40,
TTLSec: 300,
Login: &config.LoginCaptchaConfig{
ShowAfterFailures: 2,
LockAfterFailures: 4,
LockDurationMin: 1,
},
}
m := captcha.NewManager(rdb, cfg)
ctx := context.Background()
key := "alice"
status, err := m.RecordLoginFailure(ctx, key)
if err != nil || status.FailCount != 1 || status.NeedCaptcha {
t.Fatalf("first failure: status=%+v err=%v", status, err)
}
status, err = m.RecordLoginFailure(ctx, key)
if err != nil || status.FailCount != 2 || !status.NeedCaptcha {
t.Fatalf("second failure: status=%+v err=%v", status, err)
}
need, err := m.LoginNeedCaptcha(ctx, key)
if err != nil || !need {
t.Fatalf("LoginNeedCaptcha = %v, err = %v", need, err)
}
for i := 0; i < 2; i++ {
_, _ = m.RecordLoginFailure(ctx, key)
}
status, err = m.RecordLoginFailure(ctx, key)
if err != captcha.ErrLoginLocked || !status.Locked {
t.Fatalf("lock: status=%+v err=%v", status, err)
}
locked, remaining, err := m.IsLoginLocked(ctx, key)
if err != nil || !locked || remaining <= 0 {
t.Fatalf("IsLoginLocked = %v, %d, err = %v", locked, remaining, err)
}
if err := m.ClearLoginFailures(ctx, key); err != nil {
t.Fatal(err)
}
locked, _, _ = m.IsLoginLocked(ctx, key)
if locked {
t.Fatal("expected unlocked after clear")
}
}
func TestSceneRequiresCaptcha(t *testing.T) {
cfg := &config.CaptchaConfig{
Enabled: true,
Scenes: map[string]config.CaptchaSceneConfig{
"register": {Mode: "always"},
"login": {Mode: "never"},
},
}
m := captcha.NewManager(redis.NewClient(&redis.Options{Addr: "127.0.0.1:1"}), cfg)
if !m.SceneRequiresCaptcha(captcha.SceneRegister) {
t.Fatal("register should require captcha")
}
if m.SceneRequiresCaptcha(captcha.SceneLogin) {
t.Fatal("login should not always require captcha")
}
}
func TestRedisStoreTTL(t *testing.T) {
s, err := miniredis.Run()
if err != nil {
t.Fatal(err)
}
defer s.Close()
rdb := redis.NewClient(&redis.Options{Addr: s.Addr()})
cfg := &config.CaptchaConfig{Enabled: true, TTLSec: 1, Login: &config.LoginCaptchaConfig{}}
m := captcha.NewManager(rdb, cfg)
ctx := context.Background()
result, err := m.Generate(ctx, captcha.SceneRegister)
if err != nil {
t.Fatal(err)
}
s.FastForward(2 * time.Second)
if err := m.Verify(ctx, captcha.VerifyRequest{ID: result.ID, Answer: "x"}); err != captcha.ErrInvalid {
t.Fatalf("expired verify err = %v", err)
}
}

17
captcha/errors.go Normal file
View File

@@ -0,0 +1,17 @@
package captcha
import "errors"
var (
// ErrDisabled 图形验证码子系统未启用。
ErrDisabled = errors.New("captcha disabled")
// ErrInvalid 验证码错误或已失效。
ErrInvalid = errors.New("captcha invalid")
// ErrRequired 当前场景要求验证码但未提供或校验失败。
ErrRequired = errors.New("captcha required")
// ErrLoginLocked 登录失败次数过多,账号或 IP 已被临时锁定。
ErrLoginLocked = errors.New("login locked")
)

42
captcha/redis_store.go Normal file
View File

@@ -0,0 +1,42 @@
package captcha
import (
"context"
"time"
"github.com/redis/go-redis/v9"
)
const codeKeyPrefix = "captcha:code:"
// redisStore 实现 base64Captcha.Store将验证码答案存入 Redis。
type redisStore struct {
client *redis.Client
ttl time.Duration
}
func newRedisStore(client *redis.Client, ttl time.Duration) *redisStore {
return &redisStore{client: client, ttl: ttl}
}
func (s *redisStore) Set(id string, value string) error {
return s.client.Set(context.Background(), codeKeyPrefix+id, value, s.ttl).Err()
}
func (s *redisStore) Get(id string, clear bool) string {
ctx := context.Background()
key := codeKeyPrefix + id
val, err := s.client.Get(ctx, key).Result()
if err != nil {
return ""
}
if clear {
_ = s.client.Del(ctx, key).Err()
}
return val
}
func (s *redisStore) Verify(id, answer string, clear bool) bool {
val := s.Get(id, clear)
return val != "" && val == answer
}

View File

@@ -20,6 +20,8 @@ type Config struct {
Logger *LoggerConfig `json:"logger"`
I18n *I18nConfig `json:"i18n"`
RateLimit *RateLimitConfig `json:"rateLimit"`
MCP *MCPConfig `json:"mcp"`
Captcha *CaptchaConfig `json:"captcha"`
}
// I18nConfig 国际化配置
@@ -307,6 +309,48 @@ func (c *LoggerConfig) IsAsync() bool {
return *c.Async
}
// CaptchaConfig 图形验证码配置可选模块enabled=false 或未配置时不启用)
type CaptchaConfig struct {
// Enabled 总开关
Enabled bool `json:"enabled"`
// Length 验证码字符数,默认 4
Length int `json:"length"`
// Width 图片宽度(像素),默认 120
Width int `json:"width"`
// Height 图片高度(像素),默认 40
Height int `json:"height"`
// TTLSec 验证码有效期(秒),默认 300
TTLSec int `json:"ttlSec"`
// Login 登录防暴力配置(失败 N 次后要求验证码M 次后锁定)
Login *LoginCaptchaConfig `json:"login"`
// Scenes 各场景是否强制图形验证码mode: always | neverlogin 场景由 Login 配置控制)
Scenes map[string]CaptchaSceneConfig `json:"scenes"`
}
// LoginCaptchaConfig 登录防暴力配置
type LoginCaptchaConfig struct {
// ShowAfterFailures 失败多少次后开始要求图形验证码,默认 3
ShowAfterFailures int `json:"showAfterFailures"`
// LockAfterFailures 失败多少次后临时锁定,默认 10
LockAfterFailures int `json:"lockAfterFailures"`
// LockDurationMin 锁定时长(分钟),默认 30
LockDurationMin int `json:"lockDurationMin"`
}
// CaptchaSceneConfig 单场景验证码策略
type CaptchaSceneConfig struct {
// Mode always=该场景始终要求图形验证码never=不要求login 场景忽略此项)
Mode string `json:"mode"`
}
// RateLimitConfig 限流配置
type RateLimitConfig struct {
// Enable 是否启用限流
@@ -325,6 +369,50 @@ type RateLimitConfig struct {
ByUserID bool `json:"byUserID"`
}
// MCPConfig MCPModel Context Protocol工具子系统配置。
// 本库只做 MCP **Client**:连接外部 MCP server容器/服务),发现并调用工具;
// 不包含任何路由/抽参/编排逻辑(那属于业务侧的 Prompt/Agent 编排,由消费方自行实现)。
type MCPConfig struct {
// Enabled 总开关false 或 Servers 为空时 mcp.Manager.Enabled() 返回 false
Enabled bool `json:"enabled"`
// CallTimeoutSeconds 单次连接/列工具/调用工具的超时(秒),默认 60
CallTimeoutSeconds int `json:"callTimeoutSeconds"`
// Servers MCP 服务列表;一条 = 一个独立 MCP server自带多个工具
Servers []MCPServerConfig `json:"servers"`
}
// MCPServerConfig 单个 MCP server容器/服务)的连接配置。
type MCPServerConfig struct {
// Name 逻辑名(用于日志与路由),必填
Name string `json:"name"`
// Transport 传输类型streamable-http默认| sse
Transport string `json:"transport"`
// URL server 的 MCP 端点地址
URL string `json:"url"`
// Enabled 是否启用该 servernil 默认 true
Enabled *bool `json:"enabled"`
// AuthToken 可选Bearer/Basic 凭证;仅 token 时自动补 "Bearer "
AuthToken string `json:"authToken"`
// ToolNamePrefix 工具命名空间前缀,防多 server 工具重名;留空则用 Name
// 工具全名 = "{ToolNamePrefix}__{原始工具名}"
ToolNamePrefix string `json:"toolNamePrefix"`
// AllowedTools 工具白名单(原始工具名,不含前缀);留空表示开放该 server 全部工具
AllowedTools []string `json:"allowedTools"`
}
// IsEnabled 该 server 是否启用nil 默认 true
func (s *MCPServerConfig) IsEnabled() bool {
return s == nil || s.Enabled == nil || *s.Enabled
}
// LoadFromFile 从文件加载配置
// filePath: 配置文件路径(支持绝对路径和相对路径)
func LoadFromFile(filePath string) (*Config, error) {
@@ -492,6 +580,41 @@ func (c *Config) setDefaults() {
c.RateLimit.ByIP = true // 默认按IP限流
}
}
// MCP 默认值
if c.MCP != nil {
if c.MCP.CallTimeoutSeconds <= 0 {
c.MCP.CallTimeoutSeconds = 60
}
}
// Captcha 默认值
if c.Captcha != nil {
if c.Captcha.Length == 0 {
c.Captcha.Length = 4
}
if c.Captcha.Width == 0 {
c.Captcha.Width = 120
}
if c.Captcha.Height == 0 {
c.Captcha.Height = 40
}
if c.Captcha.TTLSec == 0 {
c.Captcha.TTLSec = 300
}
if c.Captcha.Login == nil {
c.Captcha.Login = &LoginCaptchaConfig{}
}
if c.Captcha.Login.ShowAfterFailures == 0 {
c.Captcha.Login.ShowAfterFailures = 3
}
if c.Captcha.Login.LockAfterFailures == 0 {
c.Captcha.Login.LockAfterFailures = 10
}
if c.Captcha.Login.LockDurationMin == 0 {
c.Captcha.Login.LockDurationMin = 30
}
}
}
// GetDatabase 获取数据库配置
@@ -546,6 +669,16 @@ func (c *Config) GetI18n() *I18nConfig {
return c.I18n
}
// GetMCP 获取 MCP 配置
func (c *Config) GetMCP() *MCPConfig {
return c.MCP
}
// GetCaptcha 获取图形验证码配置
func (c *Config) GetCaptcha() *CaptchaConfig {
return c.Captcha
}
// GetDatabaseDSN 获取数据库连接字符串
func (c *Config) GetDatabaseDSN() (string, error) {
if c.Database == nil {

View File

@@ -89,6 +89,47 @@
"period": 60,
"byIP": true,
"byUserID": false
},
"captcha": {
"enabled": false,
"length": 4,
"width": 120,
"height": 40,
"ttlSec": 300,
"login": {
"showAfterFailures": 3,
"lockAfterFailures": 10,
"lockDurationMin": 30
},
"scenes": {
"register": { "mode": "always" },
"change_phone": { "mode": "always" },
"send_sms": { "mode": "always" }
}
},
"mcp": {
"enabled": true,
"callTimeoutSeconds": 60,
"servers": [
{
"name": "fetch-web",
"transport": "streamable-http",
"url": "http://mcp-gateway:1111/mcp/fetch-web",
"enabled": true,
"authToken": "dev-local-token",
"toolNamePrefix": "fetch-web",
"allowedTools": ["fetch_web"]
},
{
"name": "word",
"transport": "sse",
"url": "http://mcp-gateway:1111/mcp/word",
"enabled": false,
"authToken": "dev-local-token",
"toolNamePrefix": "word",
"allowedTools": ["create_document", "add_heading", "add_paragraph"]
}
]
}
}

View File

@@ -0,0 +1,62 @@
//go:build example
// +build example
package main
import (
"context"
"fmt"
"log"
"git.toowon.com/jimmy/go-common/captcha"
"git.toowon.com/jimmy/go-common/config"
"git.toowon.com/jimmy/go-common/factory"
)
func main() {
cfg, err := config.LoadFromFile("./config/example.json")
if err != nil {
log.Fatal(err)
}
app := factory.New(cfg)
cap, err := app.Captcha()
if err != nil {
log.Fatal(err)
}
if !cap.Enabled() {
fmt.Println("captcha disabled — set captcha.enabled=true and configure redis to enable")
return
}
ctx := context.Background()
// 1. 注册场景:生成图形验证码
result, err := cap.Generate(ctx, captcha.SceneRegister)
if err != nil {
log.Fatal(err)
}
fmt.Printf("captcha id: %s\n", result.ID)
fmt.Printf("captcha image length: %d (base64)\n", len(result.ImageBase64))
// 2. 发短信前校验(业务侧在 handler 中调用;此处演示 API
// 实际使用时 answer 来自用户输入
_ = cap.Verify(ctx, captcha.VerifyRequest{
Scene: captcha.SceneRegister,
ID: result.ID,
Answer: "user-input",
})
// 3. 登录防暴力
key := "user@example.com"
status, _ := cap.RecordLoginFailure(ctx, key)
fmt.Printf("login failures: %d, needCaptcha: %v, locked: %v\n",
status.FailCount, status.NeedCaptcha, status.Locked)
need, _ := cap.LoginNeedCaptcha(ctx, key)
fmt.Printf("login need captcha: %v\n", need)
// 登录成功后清除
_ = cap.ClearLoginFailures(ctx, key)
}

View File

@@ -30,8 +30,7 @@ func main() {
func listUsers(w http.ResponseWriter, r *http.Request) {
app := factory.Default()
i18n, _ := app.I18n()
h := commonhttp.NewHandler(w, r, commonhttp.WithI18n(i18n))
h := app.NewHandler(w, r)
var req struct {
Keyword string `json:"keyword"`
@@ -42,11 +41,9 @@ func listUsers(w http.ResponseWriter, r *http.Request) {
return
}
p := h.Pagination()
users := []User{
{ID: 1, Name: "User1", Email: "user1@example.com"},
{ID: 2, Name: "User2", Email: "user2@example.com"},
}
h.SuccessPage(users, 100)
_ = p
}

View File

@@ -1,38 +1,18 @@
{
"common.success": {
"code": 0,
"message": "success"
},
"common.invalid_request": {
"code": 100001,
"message": "Invalid request"
},
"user.not_found": {
"code": 100002,
"message": "User not found"
},
"user.login_success": {
"code": 0,
"message": "Login successful"
},
"user.welcome": {
"code": 0,
"message": "Welcome, %s"
},
"user.logout": {
"code": 0,
"message": "Logout"
},
"error.invalid_params": {
"code": 100001,
"message": "Invalid parameters"
},
"error.server_error": {
"system.internal_error": {
"code": 100003,
"message": "Server error"
},
"order.created": {
"code": 0,
"message": "Order created successfully"
},
"order.paid": {
"code": 0,
"message": "Order paid successfully"
},
"message.count": {
"code": 0,
"message": "You have %d new messages"
"message": "Internal server error"
}
}

View File

@@ -1,38 +1,18 @@
{
"common.success": {
"code": 0,
"message": "成功"
},
"common.invalid_request": {
"code": 100001,
"message": "请求无效"
},
"user.not_found": {
"code": 100002,
"message": "用户不存在"
},
"user.login_success": {
"code": 0,
"message": "登录成功"
},
"user.welcome": {
"code": 0,
"message": "欢迎,%s"
},
"user.logout": {
"code": 0,
"message": "退出登录"
},
"error.invalid_params": {
"code": 100001,
"message": "参数无效"
},
"error.server_error": {
"system.internal_error": {
"code": 100003,
"message": "服务器错误"
},
"order.created": {
"code": 0,
"message": "订单创建成功"
},
"order.paid": {
"code": 0,
"message": "订单支付成功"
},
"message.count": {
"code": 0,
"message": "您有 %d 条新消息"
}
}

61
examples/mcp_example.go Normal file
View File

@@ -0,0 +1,61 @@
//go:build example
// +build example
package main
import (
"context"
"fmt"
"log"
"time"
"git.toowon.com/jimmy/go-common/config"
"git.toowon.com/jimmy/go-common/factory"
)
// 本示例演示 MCPModel Context ProtocolClient 的用法:
// - Factory 只负责按配置装配 mcp.Manager多 server + 命名空间 + 白名单);
// - 业务侧拿到 Manager 后自行 ListTools / CallTool
// - 路由到哪个工具、怎么抽参、怎么组合答复,属于业务编排,不在本库范围
// (可参考各业务项目里的 ToolOrchestrator/PromptOrchestrator 写法)。
func main() {
cfg, err := config.LoadFromFile("./config/example.json")
if err != nil {
log.Fatal(err)
}
app := factory.New(cfg)
defer app.Close()
m, err := app.MCP()
if err != nil {
log.Fatal("mcp not configured:", err)
}
if !m.Enabled() {
log.Fatal("mcp subsystem disabled (check config.mcp.enabled / servers)")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// ListTools 聚合全部已启用 server 的工具,工具名带命名空间前缀(防止多 server 重名)。
tools, err := m.ListTools(ctx)
if err != nil {
log.Fatal(err)
}
for _, t := range tools {
fmt.Printf("tool: %s (server=%s) - %s\n", t.FQName, t.ServerName, t.Description)
}
if len(tools) == 0 {
return
}
// CallTool 按「命名空间全名」路由到对应 server
// 可选:用 mcp.WithRequestID 把 HTTP 层的 request id 传下去,方便日志串联。
res, err := m.CallTool(ctx, tools[0].FQName, map[string]any{})
if err != nil {
log.Fatal(err)
}
fmt.Printf("call result: is_error=%v text=%s\n", res.IsError, res.Text)
}

View File

@@ -87,9 +87,12 @@ type ExportData interface {
//
// excel.ExportToWriter(w, "用户列表", columns, users)
func (e *Excel) ExportToWriter(w io.Writer, sheetName string, columns []ExportColumn, data interface{}) error {
if e.file == nil {
// 每次导出使用新文件,避免同实例多次/并发导出互相污染
e.file = excelize.NewFile()
}
defer func() {
_ = e.file.Close()
e.file = nil
}()
// 设置工作表名称
if sheetName == "" {

35
excel/excel_test.go Normal file
View File

@@ -0,0 +1,35 @@
package excel
import (
"bytes"
"testing"
)
type row struct {
ID int
Name string
}
func TestExportToWriterResetsFile(t *testing.T) {
ex := NewExcel()
cols := []ExportColumn{
{Header: "ID", Field: "ID"},
{Header: "Name", Field: "Name"},
}
data := []row{{ID: 1, Name: "a"}, {ID: 2, Name: "b"}}
var buf1, buf2 bytes.Buffer
if err := ex.ExportToWriter(&buf1, "S1", cols, data); err != nil {
t.Fatal(err)
}
if err := ex.ExportToWriter(&buf2, "S1", cols, data); err != nil {
t.Fatal(err)
}
if buf1.Len() == 0 || buf2.Len() == 0 {
t.Fatal("expected non-empty xlsx output")
}
// 导出后不应残留内部文件句柄
if ex.file != nil {
t.Fatal("internal file should be cleared after ExportToWriter")
}
}

View File

@@ -7,11 +7,14 @@ import (
"sync"
"time"
"git.toowon.com/jimmy/go-common/captcha"
"git.toowon.com/jimmy/go-common/config"
"git.toowon.com/jimmy/go-common/email"
"git.toowon.com/jimmy/go-common/excel"
commonhttp "git.toowon.com/jimmy/go-common/http"
"git.toowon.com/jimmy/go-common/i18n"
"git.toowon.com/jimmy/go-common/logger"
"git.toowon.com/jimmy/go-common/mcp"
"git.toowon.com/jimmy/go-common/middleware"
"git.toowon.com/jimmy/go-common/migration"
"git.toowon.com/jimmy/go-common/sms"
@@ -38,8 +41,9 @@ type Factory struct {
db *gorm.DB
redis *redis.Client
i18n *i18n.I18n
excel *excel.Excel
chain *middleware.Chain
mcp mcp.Manager
captcha captcha.Manager
mu sync.Mutex
}
@@ -47,13 +51,6 @@ type Factory struct {
// Option Factory 可选项(支持重载模块实现)
type Option func(*Factory)
// WithStorage 注入自定义存储实现
func WithStorage(s storage.Storage) Option {
return func(f *Factory) {
f.storage = s
}
}
// Init 从配置文件初始化全局 Factory启动时调用一次
func Init(filePath string, opts ...Option) error {
cfg, err := config.LoadFromFile(filePath)
@@ -86,6 +83,15 @@ func (f *Factory) Config() *config.Config {
return f.cfg
}
// NewHandler 创建 HTTP 出参处理器,并自动注入已配置的 i18n若可用
// 能力仍在 *http.Handler 上Success / ErrorFactory 不透传出参方法。
func (f *Factory) NewHandler(w http.ResponseWriter, r *http.Request, opts ...commonhttp.HandlerOption) *commonhttp.Handler {
if i18nInst, err := f.getI18n(); err == nil && i18nInst != nil {
opts = append([]commonhttp.HandlerOption{commonhttp.WithI18n(i18nInst)}, opts...)
}
return commonhttp.NewHandler(w, r, opts...)
}
func (f *Factory) getLogger() (*logger.Logger, error) {
if f.logger != nil {
return f.logger, nil
@@ -107,7 +113,7 @@ func (f *Factory) getLogger() (*logger.Logger, error) {
return l, nil
}
// Logger 获取日志对象
// Logger 获取日志对象(无 logger 配置时使用默认配置)
func (f *Factory) Logger() (*logger.Logger, error) {
return f.getLogger()
}
@@ -312,21 +318,9 @@ func (f *Factory) I18n() (*i18n.I18n, error) {
return f.getI18n()
}
func (f *Factory) getExcel() *excel.Excel {
if f.excel != nil {
return f.excel
}
f.mu.Lock()
defer f.mu.Unlock()
if f.excel == nil {
f.excel = excel.NewExcel()
}
return f.excel
}
// Excel 获取 Excel 导出器
// Excel 创建 Excel 导出器(每次新建,避免共享可变文件状态)
func (f *Factory) Excel() *excel.Excel {
return f.getExcel()
return excel.NewExcel()
}
// MiddlewareChain 获取默认中间件链
@@ -341,8 +335,11 @@ func (f *Factory) MiddlewareChain() *middleware.Chain {
}
var mws []func(http.Handler) http.Handler
l, _ := f.getLogger()
i18nInst, _ := f.getI18n()
// logger 无配置时有默认实现;失败则降级为 nilRecovery/Logging 可处理)
l, _ := f.getLoggerUnlocked()
// i18n 为可选:未配置时 Recovery 使用默认文案
i18nInst, _ := f.getI18nUnlocked()
mws = append(mws, middleware.Recovery(&middleware.RecoveryConfig{
Logger: l,
@@ -384,6 +381,99 @@ func (f *Factory) MiddlewareChain() *middleware.Chain {
return f.chain
}
// getLoggerUnlocked 在已持有 f.mu 时初始化 logger供 MiddlewareChain 使用)
func (f *Factory) getLoggerUnlocked() (*logger.Logger, error) {
if f.logger != nil {
return f.logger, nil
}
var cfg *config.LoggerConfig
if f.cfg != nil {
cfg = f.cfg.Logger
}
l, err := logger.NewLogger(cfg)
if err != nil {
return nil, err
}
f.logger = l
return l, nil
}
// getI18nUnlocked 在已持有 f.mu 时初始化 i18n供 MiddlewareChain 使用)
func (f *Factory) getI18nUnlocked() (*i18n.I18n, error) {
if f.i18n != nil {
return f.i18n, nil
}
if f.cfg == nil || f.cfg.I18n == nil {
return nil, fmt.Errorf("i18n config is nil")
}
i := i18n.NewI18n(f.cfg.I18n.DefaultLang)
if f.cfg.I18n.LocalesDir != "" {
if err := i.LoadFromDir(f.cfg.I18n.LocalesDir); err != nil {
return nil, err
}
}
f.i18n = i
return i, nil
}
func (f *Factory) getMCP() (mcp.Manager, error) {
if f.mcp != nil {
return f.mcp, nil
}
f.mu.Lock()
defer f.mu.Unlock()
if f.mcp != nil {
return f.mcp, nil
}
if f.cfg == nil || f.cfg.MCP == nil {
return nil, fmt.Errorf("mcp config is nil")
}
l, _ := f.getLoggerUnlocked()
f.mcp = mcp.NewManager(l, f.cfg.MCP)
return f.mcp, nil
}
// MCP 获取 MCP 工具子系统对象ListTools / CallTool
// 本库只做 MCP Client发现+调用工具);路由/抽参/编排逻辑由业务侧基于此对象实现。
func (f *Factory) MCP() (mcp.Manager, error) {
return f.getMCP()
}
func (f *Factory) getCaptcha() (captcha.Manager, error) {
if f.captcha != nil {
return f.captcha, nil
}
var cfg *config.CaptchaConfig
if f.cfg != nil {
cfg = f.cfg.Captcha
}
var rdb *redis.Client
if cfg != nil && cfg.Enabled {
var err error
rdb, err = f.getRedis()
if err != nil {
return nil, fmt.Errorf("captcha enabled but redis unavailable: %w", err)
}
}
f.mu.Lock()
defer f.mu.Unlock()
if f.captcha != nil {
return f.captcha, nil
}
f.captcha = captcha.NewManager(rdb, cfg)
return f.captcha, nil
}
// Captcha 获取图形验证码子系统Generate / Verify / 登录防暴力)。
// config.captcha 未配置或 enabled=false 时返回 Enabled()=false 的安全实例。
// enabled=true 时需配置 redis否则返回 error。
func (f *Factory) Captcha() (captcha.Manager, error) {
return f.getCaptcha()
}
// Migrator 创建迁移器并加载指定目录下的 SQL 文件
func (f *Factory) Migrator(migrationsDir string) (*migration.Migrator, error) {
db, err := f.getDatabase()

121
factory/factory_test.go Normal file
View File

@@ -0,0 +1,121 @@
package factory
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.toowon.com/jimmy/go-common/config"
"git.toowon.com/jimmy/go-common/i18n"
"git.toowon.com/jimmy/go-common/logger"
)
func TestExcelReturnsFreshInstance(t *testing.T) {
app := New(nil)
a := app.Excel()
b := app.Excel()
if a == nil || b == nil {
t.Fatal("Excel() returned nil")
}
if a == b {
t.Fatal("Excel() should return a new instance each call")
}
}
func TestWithLoggerOption(t *testing.T) {
custom, err := logger.NewLogger(&config.LoggerConfig{
Level: "error",
Output: "stdout",
Async: config.BoolPtr(false),
})
if err != nil {
t.Fatal(err)
}
app := New(nil, WithLogger(custom))
got, err := app.Logger()
if err != nil {
t.Fatal(err)
}
if got != custom {
t.Fatal("WithLogger should inject the provided logger")
}
}
func TestWithI18nAndNewHandler(t *testing.T) {
i := i18n.NewI18n("zh-CN")
i.LoadFromMap("zh-CN", map[string]i18n.MessageInfo{
"common.success": {Code: 0, Message: "成功"},
})
app := New(nil, WithI18n(i))
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/ping", nil)
h := app.NewHandler(rec, req)
h.Success(map[string]string{"ok": "1"})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
body := rec.Body.String()
if body == "" || !strings.Contains(body, "成功") {
t.Fatalf("response body missing i18n message: %s", body)
}
}
func TestNewHandlerWithoutI18n(t *testing.T) {
app := New(nil)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
h := app.NewHandler(rec, req)
h.Success(nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
}
func TestWarmupLogger(t *testing.T) {
app := New(nil)
if err := app.Warmup(ModuleLogger); err != nil {
t.Fatal(err)
}
}
func TestWarmupMissingDatabase(t *testing.T) {
app := New(nil)
err := app.Warmup(ModuleDatabase)
if err == nil {
t.Fatal("expected database warmup to fail")
}
}
func TestWarmupUnknownModule(t *testing.T) {
app := New(nil)
err := app.Warmup(Module("unknown"))
if err == nil {
t.Fatal("expected unknown module error")
}
}
func TestCloseLogger(t *testing.T) {
app := New(nil)
if _, err := app.Logger(); err != nil {
t.Fatal(err)
}
if err := app.Close(); err != nil {
t.Fatal(err)
}
// Close 后再取 logger 应可重新创建
if _, err := app.Logger(); err != nil {
t.Fatal(err)
}
_ = app.Close()
}
func TestMustLogger(t *testing.T) {
app := New(nil)
l := app.MustLogger()
if l == nil {
t.Fatal("MustLogger returned nil")
}
}

213
factory/lifecycle.go Normal file
View File

@@ -0,0 +1,213 @@
package factory
import (
"errors"
"fmt"
"git.toowon.com/jimmy/go-common/captcha"
"git.toowon.com/jimmy/go-common/email"
"git.toowon.com/jimmy/go-common/i18n"
"git.toowon.com/jimmy/go-common/logger"
"git.toowon.com/jimmy/go-common/mcp"
"git.toowon.com/jimmy/go-common/sms"
"git.toowon.com/jimmy/go-common/storage"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
// Module 可预热的模块标识(用于 Warmup
type Module string
const (
ModuleLogger Module = "logger"
ModuleDatabase Module = "database"
ModuleRedis Module = "redis"
ModuleStorage Module = "storage"
ModuleEmail Module = "email"
ModuleSMS Module = "sms"
ModuleI18n Module = "i18n"
ModuleMCP Module = "mcp"
ModuleCaptcha Module = "captcha"
)
// MustInit 从配置文件初始化全局 Factory失败则 panic适合 main 启动)
func MustInit(filePath string, opts ...Option) *Factory {
if err := Init(filePath, opts...); err != nil {
panic(err)
}
return Default()
}
// Warmup 按需预热模块:配置缺失或连接失败时立即返回错误(适合启动期 fail-fast
func (f *Factory) Warmup(modules ...Module) error {
if f == nil {
return fmt.Errorf("factory is nil")
}
for _, m := range modules {
var err error
switch m {
case ModuleLogger:
_, err = f.Logger()
case ModuleDatabase:
_, err = f.Database()
case ModuleRedis:
_, err = f.Redis()
case ModuleStorage:
_, err = f.Storage()
case ModuleEmail:
_, err = f.Email()
case ModuleSMS:
_, err = f.SMS()
case ModuleI18n:
_, err = f.I18n()
case ModuleMCP:
_, err = f.MCP()
case ModuleCaptcha:
_, err = f.Captcha()
default:
return fmt.Errorf("unknown module: %s", m)
}
if err != nil {
return fmt.Errorf("warmup %s: %w", m, err)
}
}
return nil
}
// Close 关闭已创建的有状态模块email / sms / redis / database / logger
// 可重复调用;未初始化的模块会被跳过。
func (f *Factory) Close() error {
if f == nil {
return nil
}
f.mu.Lock()
defer f.mu.Unlock()
var errs []error
if f.email != nil {
if err := f.email.Close(); err != nil {
errs = append(errs, fmt.Errorf("email: %w", err))
}
f.email = nil
}
if f.sms != nil {
if err := f.sms.Close(); err != nil {
errs = append(errs, fmt.Errorf("sms: %w", err))
}
f.sms = nil
}
if f.redis != nil {
if err := f.redis.Close(); err != nil {
errs = append(errs, fmt.Errorf("redis: %w", err))
}
f.redis = nil
}
if f.db != nil {
if sqlDB, err := f.db.DB(); err != nil {
errs = append(errs, fmt.Errorf("database: %w", err))
} else if err := sqlDB.Close(); err != nil {
errs = append(errs, fmt.Errorf("database: %w", err))
}
f.db = nil
}
if f.mcp != nil {
if err := f.mcp.Close(); err != nil {
errs = append(errs, fmt.Errorf("mcp: %w", err))
}
f.mcp = nil
}
if f.logger != nil {
if err := f.logger.Close(); err != nil {
errs = append(errs, fmt.Errorf("logger: %w", err))
}
f.logger = nil
}
// 中间件链依赖 logger/i18n关闭后允许下次重新组装
f.chain = nil
return errors.Join(errs...)
}
// MustLogger 获取日志对象;失败则 panic适合启动期注入
func (f *Factory) MustLogger() *logger.Logger {
l, err := f.Logger()
if err != nil {
panic(err)
}
return l
}
// MustDatabase 获取数据库连接;失败则 panic适合启动期注入
func (f *Factory) MustDatabase() *gorm.DB {
db, err := f.Database()
if err != nil {
panic(err)
}
return db
}
// MustRedis 获取 Redis 客户端;失败则 panic适合启动期注入
func (f *Factory) MustRedis() *redis.Client {
c, err := f.Redis()
if err != nil {
panic(err)
}
return c
}
// MustStorage 获取存储对象;失败则 panic适合启动期注入
func (f *Factory) MustStorage() storage.Storage {
s, err := f.Storage()
if err != nil {
panic(err)
}
return s
}
// MustEmail 获取邮件客户端;失败则 panic适合启动期注入
func (f *Factory) MustEmail() *email.Email {
e, err := f.Email()
if err != nil {
panic(err)
}
return e
}
// MustSMS 获取短信客户端;失败则 panic适合启动期注入
func (f *Factory) MustSMS() *sms.SMS {
s, err := f.SMS()
if err != nil {
panic(err)
}
return s
}
// MustI18n 获取国际化对象;失败则 panic适合启动期注入
func (f *Factory) MustI18n() *i18n.I18n {
i, err := f.I18n()
if err != nil {
panic(err)
}
return i
}
// MustMCP 获取 MCP 工具子系统对象;失败则 panic适合启动期注入
func (f *Factory) MustMCP() mcp.Manager {
m, err := f.MCP()
if err != nil {
panic(err)
}
return m
}
// MustCaptcha 获取图形验证码子系统对象;失败则 panic适合启动期注入
func (f *Factory) MustCaptcha() captcha.Manager {
c, err := f.Captcha()
if err != nil {
panic(err)
}
return c
}

84
factory/options.go Normal file
View File

@@ -0,0 +1,84 @@
package factory
import (
"git.toowon.com/jimmy/go-common/captcha"
"git.toowon.com/jimmy/go-common/email"
"git.toowon.com/jimmy/go-common/i18n"
"git.toowon.com/jimmy/go-common/logger"
"git.toowon.com/jimmy/go-common/mcp"
"git.toowon.com/jimmy/go-common/middleware"
"git.toowon.com/jimmy/go-common/sms"
"git.toowon.com/jimmy/go-common/storage"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
// WithStorage 注入自定义存储实现
func WithStorage(s storage.Storage) Option {
return func(f *Factory) {
f.storage = s
}
}
// WithLogger 注入自定义日志对象
func WithLogger(l *logger.Logger) Option {
return func(f *Factory) {
f.logger = l
}
}
// WithDatabase 注入自定义数据库连接
func WithDatabase(db *gorm.DB) Option {
return func(f *Factory) {
f.db = db
}
}
// WithRedis 注入自定义 Redis 客户端
func WithRedis(c *redis.Client) Option {
return func(f *Factory) {
f.redis = c
}
}
// WithEmail 注入自定义邮件客户端
func WithEmail(e *email.Email) Option {
return func(f *Factory) {
f.email = e
}
}
// WithSMS 注入自定义短信客户端
func WithSMS(s *sms.SMS) Option {
return func(f *Factory) {
f.sms = s
}
}
// WithI18n 注入自定义国际化对象
func WithI18n(i *i18n.I18n) Option {
return func(f *Factory) {
f.i18n = i
}
}
// WithMiddlewareChain 注入自定义中间件链
func WithMiddlewareChain(c *middleware.Chain) Option {
return func(f *Factory) {
f.chain = c
}
}
// WithMCP 注入自定义 MCP 管理器(如测试用的 fake 实现)
func WithMCP(m mcp.Manager) Option {
return func(f *Factory) {
f.mcp = m
}
}
// WithCaptcha 注入自定义图形验证码实现(如测试用的 fake 实现)
func WithCaptcha(c captcha.Manager) Option {
return func(f *Factory) {
f.captcha = c
}
}

17
go.mod
View File

@@ -1,12 +1,13 @@
module git.toowon.com/jimmy/go-common
go 1.24.0
toolchain go1.24.10
go 1.25.0
require (
github.com/alicebob/miniredis/v2 v2.38.0
github.com/google/uuid v1.6.0
github.com/minio/minio-go/v7 v7.0.97
github.com/modelcontextprotocol/go-sdk v1.6.1
github.com/mojocn/base64Captcha v1.3.6
github.com/redis/go-redis/v9 v9.17.1
github.com/xuri/excelize/v2 v2.10.0
golang.org/x/crypto v0.43.0
@@ -22,6 +23,8 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-sql-driver/mysql v1.7.1 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/google/jsonschema-go v0.4.3 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
@@ -40,13 +43,19 @@ require (
github.com/richardlehane/msoleps v1.0.4 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/tiendc/go-deepcopy v1.7.1 // indirect
github.com/tinylib/msgp v1.3.0 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
golang.org/x/image v0.25.0 // indirect
golang.org/x/net v0.46.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

62
go.sum
View File

@@ -1,3 +1,5 @@
github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw=
github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -17,6 +19,14 @@ github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3I
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -50,6 +60,10 @@ github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.97 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5occ=
github.com/minio/minio-go/v7 v7.0.97/go.mod h1:re5VXuo0pwEtoNLsNuSr0RrLfT/MBtohwdaSmPPSRSk=
github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
github.com/mojocn/base64Captcha v1.3.6 h1:gZEKu1nsKpttuIAQgWHO+4Mhhls8cAKyiV2Ew03H+Tw=
github.com/mojocn/base64Captcha v1.3.6/go.mod h1:i5CtHvm+oMbj1UzEPXaA8IH/xHFZ3DGY3Wh3dBpZ28E=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -65,6 +79,10 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -80,18 +98,58 @@ github.com/xuri/excelize/v2 v2.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstf
github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/image v0.13.0/go.mod h1:6mmbMOeV28HuMTgA6OSRkdXKYw/t5W9Uwn2Yv1r3Yxk=
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -82,8 +82,13 @@ func (h *Handler) SuccessPage(list interface{}, total int64) {
h.Success(pageData)
}
// Error 失败响应messageCode 为 i18n 消息码)
// Error 失败响应messageCode 为 i18n 消息码data 为 null
func (h *Handler) Error(messageCode string, args ...interface{}) {
h.ErrorData(messageCode, nil, args...)
}
// ErrorData 失败响应并附带 data
func (h *Handler) ErrorData(messageCode string, data interface{}, args ...interface{}) {
code := 0
message := messageCode
if h.i18n != nil {
@@ -91,5 +96,5 @@ func (h *Handler) Error(messageCode string, args ...interface{}) {
code = info.Code
message = info.Message
}
writeResponse(h.w, code, message, nil)
writeResponse(h.w, code, message, data)
}

299
mcp/client.go Normal file
View File

@@ -0,0 +1,299 @@
// Package mcp 提供 MCPModel Context Protocol**Client** 能力:连接外部 MCP
// server容器/服务),发现工具、调用工具。
//
// 定位(见 .cursor/skills/go-common/SKILL.md本包只做「连接 + 调用」基础设施,
// 不包含任何路由 / 参数抽取 / 编排逻辑 —— 那属于业务侧的 Prompt/Agent 编排,
// 由消费方基于 Manager 暴露的 ListTools/CallTool 自行实现(可参考各业务项目里的
// ToolOrchestrator 写法)。
//
// 传输基于官方 SDKgithub.com/modelcontextprotocol/go-sdk支持
// streamable-http / sse绝不手写 JSON-RPC。
package mcp
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"git.toowon.com/jimmy/go-common/config"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
)
const (
// clientName / clientVersion 在 initialize 握手时上报给 server便于对端日志识别调用方。
clientName = "go-common-mcp-client"
clientVersion = "1.0.0"
)
// ToolDescriptor 从 MCP server 拉取的工具元信息。
type ToolDescriptor struct {
Name string // 原始工具名(不含命名空间前缀)
Description string // 工具说明
InputSchema json.RawMessage // JSON Schema 原文,供上层抽参/校验
}
// ToolCallResult 单次工具调用结果。
type ToolCallResult struct {
Text string // 合并后的文本内容MCP text 内容块拼接)
IsError bool // 工具自身报告的错误(非协议层错误)
Raw json.RawMessage // 原始 CallToolResult JSON含结构化内容/文件信息等)
}
// Client 单个 MCP server 连接的最小接口;可替换实现(参考 storage.Storage 的做法),
// 便于测试时注入 fake 实现。
type Client interface {
// Name 返回 server 逻辑名。
Name() string
// ListTools 列出该 server 暴露的全部工具(带 inputSchema
ListTools(ctx context.Context) ([]ToolDescriptor, error)
// CallTool 调用指定工具name 为原始工具名,不含命名空间前缀)。
CallTool(ctx context.Context, name string, args map[string]any) (ToolCallResult, error)
// Close 关闭会话并释放资源。
Close() error
}
// sdkClient 是 Client 的默认实现:封装与单个 MCP server 的会话。
// 惰性连接(首次调用才连)+ 失败后自动重连;调用超时不影响整个会话生命周期。
type sdkClient struct {
cfg config.MCPServerConfig
callTimeout time.Duration
sdk *mcpsdk.Client
mu sync.Mutex
session *mcpsdk.ClientSession
cancel context.CancelFunc
}
// NewClient 创建单个 MCP server 客户端(此时不建立连接,首次调用时惰性连接)。
func NewClient(cfg config.MCPServerConfig, callTimeout time.Duration) Client {
if callTimeout <= 0 {
callTimeout = 60 * time.Second
}
return &sdkClient{
cfg: cfg,
callTimeout: callTimeout,
sdk: mcpsdk.NewClient(&mcpsdk.Implementation{Name: clientName, Version: clientVersion}, nil),
}
}
// Name 返回 server 逻辑名。
func (c *sdkClient) Name() string { return c.cfg.Name }
// ListTools 列出 server 暴露的全部工具(带 inputSchema。失败时重置会话以便下次重连。
func (c *sdkClient) ListTools(ctx context.Context) ([]ToolDescriptor, error) {
session, err := c.ensureSession(ctx)
if err != nil {
return nil, err
}
opCtx, cancel := context.WithTimeout(ctx, c.callTimeout)
defer cancel()
res, err := session.ListTools(opCtx, nil)
if err != nil {
c.reset()
return nil, fmt.Errorf("list tools from mcp server %q: %w", c.cfg.Name, err)
}
out := make([]ToolDescriptor, 0, len(res.Tools))
for _, t := range res.Tools {
if t == nil {
continue
}
schema, mErr := json.Marshal(t.InputSchema)
if mErr != nil {
schema = nil
}
out = append(out, ToolDescriptor{
Name: t.Name,
Description: t.Description,
InputSchema: schema,
})
}
return out, nil
}
// CallTool 调用指定工具name 为原始工具名,不含命名空间前缀)。
func (c *sdkClient) CallTool(ctx context.Context, name string, args map[string]any) (ToolCallResult, error) {
session, err := c.ensureSession(ctx)
if err != nil {
return ToolCallResult{}, err
}
opCtx, cancel := context.WithTimeout(ctx, c.callTimeout)
defer cancel()
res, err := session.CallTool(opCtx, &mcpsdk.CallToolParams{Name: name, Arguments: args})
if err != nil {
c.reset()
return ToolCallResult{}, fmt.Errorf("call tool %q on mcp server %q: %w", name, c.cfg.Name, err)
}
raw, _ := json.Marshal(res)
return ToolCallResult{
Text: joinTextContent(res.Content),
IsError: res.IsError,
Raw: raw,
}, nil
}
// Close 关闭会话并释放资源。
func (c *sdkClient) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
return c.closeLocked()
}
// ensureSession 确保会话已连接SDK 在 Connect 内自动完成 initialize 握手),返回可用会话。
//
// 注意Connect 传入的 context 会被用于会话的整个生命周期(含后台监听),因此必须使用
// 后台 context 而非调用超时 context否则调用结束取消 context 会断开会话。这里通过
// goroutine + 定时器为「连接握手」单独施加超时,超时则取消后台 context 放弃本次连接。
func (c *sdkClient) ensureSession(ctx context.Context) (*mcpsdk.ClientSession, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.session != nil {
return c.session, nil
}
transport, err := c.newTransport()
if err != nil {
return nil, err
}
baseCtx, cancel := context.WithCancel(context.Background())
type result struct {
session *mcpsdk.ClientSession
err error
}
ch := make(chan result, 1)
go func() {
s, e := c.sdk.Connect(baseCtx, transport, nil)
ch <- result{session: s, err: e}
}()
select {
case <-ctx.Done():
cancel()
return nil, fmt.Errorf("connect mcp server %q canceled: %w", c.cfg.Name, ctx.Err())
case <-time.After(c.callTimeout):
cancel()
return nil, fmt.Errorf("connect mcp server %q timeout after %s", c.cfg.Name, c.callTimeout)
case r := <-ch:
if r.err != nil {
cancel()
return nil, fmt.Errorf("connect mcp server %q: %w", c.cfg.Name, r.err)
}
c.session = r.session
c.cancel = cancel
return r.session, nil
}
}
// newTransport 按传输类型构造官方 SDK 传输;带鉴权时注入自定义 HTTP 客户端添加 Authorization 头。
func (c *sdkClient) newTransport() (mcpsdk.Transport, error) {
httpClient := c.authHTTPClient()
switch normalizeTransport(c.cfg.Transport) {
case "sse":
return &mcpsdk.SSEClientTransport{Endpoint: c.cfg.URL, HTTPClient: httpClient}, nil
case "streamable-http":
// DisableStandaloneSSE仅做请求-响应式调用,不维持服务端主动推送的持久 SSE 流,
// 兼容性更好、超时语义更清晰。
return &mcpsdk.StreamableClientTransport{
Endpoint: c.cfg.URL,
HTTPClient: httpClient,
DisableStandaloneSSE: true,
}, nil
default:
return nil, fmt.Errorf("unsupported mcp transport %q for server %q", c.cfg.Transport, c.cfg.Name)
}
}
// authHTTPClient 在配置了凭证时返回注入 Authorization 头的 HTTP 客户端;否则返回 nilSDK 用默认客户端)。
func (c *sdkClient) authHTTPClient() *http.Client {
value := authHeaderValue(c.cfg.AuthToken)
if value == "" {
return nil
}
return &http.Client{Transport: &authRoundTripper{base: http.DefaultTransport, value: value}}
}
// reset 重置会话,使下次调用重新连接(处理连接中断/对端重启)。
func (c *sdkClient) reset() {
c.mu.Lock()
defer c.mu.Unlock()
_ = c.closeLocked()
}
// closeLocked 在持有 mu 的前提下关闭会话。
func (c *sdkClient) closeLocked() error {
var err error
if c.session != nil {
err = c.session.Close()
c.session = nil
}
if c.cancel != nil {
c.cancel()
c.cancel = nil
}
return err
}
// authRoundTripper 为每个出站请求注入固定的 Authorization 头。
type authRoundTripper struct {
base http.RoundTripper
value string
}
func (a *authRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
r := req.Clone(req.Context())
r.Header.Set("Authorization", a.value)
base := a.base
if base == nil {
base = http.DefaultTransport
}
return base.RoundTrip(r)
}
// joinTextContent 拼接 MCP 返回内容中的所有 text 块(忽略图片/音频等其他类型)。
func joinTextContent(contents []mcpsdk.Content) string {
var b strings.Builder
for _, ct := range contents {
if tc, ok := ct.(*mcpsdk.TextContent); ok {
if b.Len() > 0 {
b.WriteString("\n")
}
b.WriteString(tc.Text)
}
}
return b.String()
}
// normalizeTransport 归一化传输类型,空值/http 视为 streamable-http。
func normalizeTransport(t string) string {
switch strings.TrimSpace(strings.ToLower(t)) {
case "", "http", "streamable-http", "streamablehttp":
return "streamable-http"
case "sse":
return "sse"
default:
return strings.TrimSpace(strings.ToLower(t))
}
}
// authHeaderValue 归一化鉴权头:已带 Bearer/Basic 前缀则原样使用,否则补 "Bearer "。
func authHeaderValue(token string) string {
token = strings.TrimSpace(token)
if token == "" {
return ""
}
lower := strings.ToLower(token)
if strings.HasPrefix(lower, "bearer ") || strings.HasPrefix(lower, "basic ") {
return token
}
return "Bearer " + token
}

364
mcp/manager.go Normal file
View File

@@ -0,0 +1,364 @@
package mcp
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"git.toowon.com/jimmy/go-common/config"
"git.toowon.com/jimmy/go-common/logger"
"github.com/google/uuid"
)
// toolNameSeparator 命名空间分隔符FQName = <prefix>__<rawName>,防多 server 工具重名。
const toolNameSeparator = "__"
// toolCallLogMaxRunes 工具返回写入日志时的最大字符数,避免大返回值把日志灌爆。
const toolCallLogMaxRunes = 2048
// Tool 暴露给上层(业务编排/接口)的工具元信息,带命名空间全名与所属 server。
type Tool struct {
FQName string `json:"fq_name"` // 命名空间全名,如 word__create_document
Name string `json:"name"` // 原始工具名(不含前缀)
ServerName string `json:"server_name"` // 所属 MCP server 逻辑名
Description string `json:"description,omitempty"` // 工具说明
InputSchema json.RawMessage `json:"input_schema,omitempty"` // JSON Schema 原文,供上层抽参/校验
}
// ToolResult 工具调用结果。
type ToolResult struct {
Text string `json:"text"` // 合并后的文本内容
IsError bool `json:"is_error"` // 工具自身报告的错误
Raw json.RawMessage `json:"raw,omitempty"` // 原始结果 JSON含文件名/路径等结构化信息)
}
// Manager 是 MCP 工具子系统的标准模块对象(`app.MCP()` 取得)。
//
// 职责:
// - 按配置管理多个 MCP server 客户端;
// - 工具加命名空间前缀防冲突;
// - CallTool 路由到对应 server
// - allowedTools 白名单收敛;
// - 对外暴露工具的 InputSchema供业务侧抽参
//
// 本接口是「基础底座」:仅负责工具的发现与调用,不包含路由/抽参/编排逻辑
// (那属于业务侧的 Prompt/Agent 编排,由消费方基于本接口自行实现)。
type Manager interface {
// Enabled 子系统是否启用(关闭或无可用 server 时为 false
Enabled() bool
// ListTools 聚合所有 server 的可用工具(已按白名单过滤、带命名空间前缀)。
ListTools(ctx context.Context) ([]Tool, error)
// CallTool 按命名空间全名路由并调用工具。
CallTool(ctx context.Context, fqName string, args map[string]any) (ToolResult, error)
// Close 关闭全部底层 server 连接。
Close() error
}
// serverEntry 单个 server 的运行态:底层客户端 + 命名空间前缀 + 白名单。
type serverEntry struct {
name string
prefix string
allowed map[string]bool // 空表示放开全部工具
client Client
}
// isAllowed 判断原始工具名是否在白名单内(白名单为空=全部放开)。
func (e *serverEntry) isAllowed(rawName string) bool {
if len(e.allowed) == 0 {
return true
}
return e.allowed[rawName]
}
// route 命名空间全名到「server + 原始工具名」的路由。
type route struct {
server *serverEntry
rawName string
}
// manager 是 Manager 的默认实现。
type manager struct {
log *logger.Logger
enabled bool
servers []*serverEntry // 稳定顺序,便于路由前缀匹配
mu sync.RWMutex
routes map[string]route // FQName -> 路由缓存ListTools 时刷新CallTool 时按需补全)
}
// NewManager 根据配置构建 MCP 工具子系统。
// - loggo-common 日志对象(可为 nil仅跳过调用日志
// - cfgnil 或 Enabled=false 时返回的 Manager.Enabled() 恒为 false其余方法安全返回空结果。
//
// 此处仅创建客户端对象(惰性连接),不会在启动时连接外部 MCP server避免外部服务未就绪
// 导致本服务启动失败。
func NewManager(log *logger.Logger, cfg *config.MCPConfig) Manager {
m := &manager{log: log, routes: make(map[string]route)}
if cfg == nil {
return m
}
m.enabled = cfg.Enabled
timeout := time.Duration(cfg.CallTimeoutSeconds) * time.Second
for _, sc := range cfg.Servers {
if !sc.IsEnabled() {
continue
}
m.servers = append(m.servers, newServerEntry(sc, NewClient(sc, timeout)))
}
return m
}
// newServerEntry 组装 server 运行态(前缀归一化 + 白名单集合化)。
func newServerEntry(sc config.MCPServerConfig, client Client) *serverEntry {
prefix := strings.TrimSpace(sc.ToolNamePrefix)
if prefix == "" {
prefix = strings.TrimSpace(sc.Name)
}
allowed := make(map[string]bool, len(sc.AllowedTools))
for _, t := range sc.AllowedTools {
if t = strings.TrimSpace(t); t != "" {
allowed[t] = true
}
}
return &serverEntry{name: sc.Name, prefix: prefix, allowed: allowed, client: client}
}
// Enabled 子系统启用且至少配置了一个 server 时才视为可用。
func (m *manager) Enabled() bool {
return m != nil && m.enabled && len(m.servers) > 0
}
// ListTools 聚合全部 server 工具。单个 server 失败不影响其余 server仅记录告警保证整体健壮性。
func (m *manager) ListTools(ctx context.Context) ([]Tool, error) {
if !m.Enabled() {
return nil, nil
}
m.mu.Lock()
defer m.mu.Unlock()
// 重建路由缓存,保证与最新工具列表一致。
m.routes = make(map[string]route)
var out []Tool
for _, srv := range m.servers {
tools, err := srv.client.ListTools(ctx)
if err != nil {
m.logError("mcp list tools from server failed", err, map[string]any{"server": srv.name})
continue
}
for _, t := range tools {
if !srv.isAllowed(t.Name) {
continue
}
fq := srv.prefix + toolNameSeparator + t.Name
m.routes[fq] = route{server: srv, rawName: t.Name}
out = append(out, Tool{
FQName: fq,
Name: t.Name,
ServerName: srv.name,
Description: t.Description,
InputSchema: t.InputSchema,
})
}
}
return out, nil
}
// CallTool 按命名空间全名路由到对应 server 并调用。
func (m *manager) CallTool(ctx context.Context, fqName string, args map[string]any) (ToolResult, error) {
if !m.Enabled() {
return ToolResult{}, fmt.Errorf("mcp subsystem disabled")
}
r, ok := m.resolve(fqName)
if !ok {
return ToolResult{}, fmt.Errorf("unknown mcp tool %q", fqName)
}
// 白名单二次校验,防止绕过 ListTools 直接调用未开放工具。
if !r.server.isAllowed(r.rawName) {
return ToolResult{}, fmt.Errorf("mcp tool %q is not allowed", fqName)
}
callID := uuid.NewString()
requestID := RequestIDFromContext(ctx)
m.logToolCallAsync(toolCallLogSnapshot{
phase: "request", requestID: requestID, callID: callID,
fqName: fqName, server: r.server.name, rawTool: r.rawName,
args: cloneAnyMap(args),
})
res, err := r.server.client.CallTool(ctx, r.rawName, args)
if err != nil {
m.logToolCallAsync(toolCallLogSnapshot{
phase: "error", requestID: requestID, callID: callID,
fqName: fqName, server: r.server.name, rawTool: r.rawName,
args: cloneAnyMap(args), errMsg: err.Error(),
})
return ToolResult{}, err
}
out := ToolResult{Text: res.Text, IsError: res.IsError, Raw: res.Raw}
m.logToolCallAsync(toolCallLogSnapshot{
phase: "response", requestID: requestID, callID: callID,
fqName: fqName, server: r.server.name, rawTool: r.rawName,
args: cloneAnyMap(args), hasResult: true, result: out,
})
return out, nil
}
// resolve 解析命名空间全名到路由:优先读缓存,未命中则按 server 前缀推导(无需先 ListTools
func (m *manager) resolve(fqName string) (route, bool) {
m.mu.RLock()
r, ok := m.routes[fqName]
m.mu.RUnlock()
if ok {
return r, true
}
for _, srv := range m.servers {
pfx := srv.prefix + toolNameSeparator
if strings.HasPrefix(fqName, pfx) {
raw := strings.TrimPrefix(fqName, pfx)
if raw == "" {
continue
}
r := route{server: srv, rawName: raw}
m.mu.Lock()
m.routes[fqName] = r
m.mu.Unlock()
return r, true
}
}
return route{}, false
}
// Close 关闭全部底层 server 连接。
func (m *manager) Close() error {
var firstErr error
for _, srv := range m.servers {
if err := srv.client.Close(); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
// --- 可选的请求追踪 ID不强依赖任何业务 requestctx 包) ---
type requestIDKey struct{}
// WithRequestID 可选:将请求追踪 ID 写入 contextCallTool 的调用日志会附带该字段。
// 典型用法HTTP 层已经有 middleware.RequestID 生成的 ID 时,
// `ctx = mcp.WithRequestID(ctx, middleware.GetRequestID(r.Context()))`。
// 不调用时日志里的 request_id 为空,不影响功能。
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey{}, id)
}
// RequestIDFromContext 读取通过 WithRequestID 写入的请求追踪 ID未设置时返回空字符串。
func RequestIDFromContext(ctx context.Context) string {
if v, ok := ctx.Value(requestIDKey{}).(string); ok {
return v
}
return ""
}
// --- 异步调用日志 ---
// toolCallLogSnapshot 异步日志快照;主流程只组装结构体,重活(截断/序列化/写日志)在 goroutine 内完成。
type toolCallLogSnapshot struct {
phase string
requestID string
callID string
fqName string
server string
rawTool string
args map[string]any
hasResult bool
result ToolResult
errMsg string
}
func (m *manager) logToolCallAsync(snap toolCallLogSnapshot) {
if m.log == nil {
return
}
go writeToolCallLog(m.log, snap)
}
func (m *manager) logError(message string, err error, fields map[string]any) {
if m.log == nil {
return
}
if fields == nil {
fields = make(map[string]any)
}
fields["error"] = err.Error()
m.log.Error(message, fields)
}
func writeToolCallLog(l *logger.Logger, snap toolCallLogSnapshot) {
payload := map[string]any{
"type": "mcp_tool_call",
"phase": snap.phase,
"request_id": snap.requestID,
"call_id": snap.callID,
"tool": snap.fqName,
"server": snap.server,
"raw_tool": snap.rawTool,
}
if len(snap.args) > 0 {
payload["arguments"] = snap.args
}
if snap.hasResult {
text := snap.result.Text
totalRunes := len([]rune(text))
truncated := totalRunes > toolCallLogMaxRunes
if truncated {
text = truncateRunes(text, toolCallLogMaxRunes)
}
resp := map[string]any{
"is_error": snap.result.IsError,
"text": text,
"text_total_runes": totalRunes,
"text_truncated": truncated,
}
if len(snap.result.Raw) > 0 {
rawStr := string(snap.result.Raw)
rawRunes := len([]rune(rawStr))
if rawRunes > toolCallLogMaxRunes {
resp["raw"] = truncateRunes(rawStr, toolCallLogMaxRunes)
resp["raw_total_runes"] = rawRunes
resp["raw_truncated"] = true
} else {
resp["raw"] = json.RawMessage(snap.result.Raw)
}
}
payload["response"] = resp
}
if snap.errMsg != "" {
payload["error"] = snap.errMsg
}
l.Info("mcp tool call", payload)
}
func cloneAnyMap(m map[string]any) map[string]any {
if len(m) == 0 {
return nil
}
out := make(map[string]any, len(m))
for k, v := range m {
out[k] = v
}
return out
}
func truncateRunes(s string, max int) string {
r := []rune(s)
if len(r) <= max {
return s
}
return string(r[:max]) + "…"
}

200
mcp/manager_test.go Normal file
View File

@@ -0,0 +1,200 @@
package mcp
import (
"context"
"errors"
"testing"
"git.toowon.com/jimmy/go-common/config"
)
// fakeClient 是测试用的 Client 假实现,不依赖真实网络连接。
type fakeClient struct {
name string
tools []ToolDescriptor
listErr error
callResult ToolCallResult
callErr error
closed bool
lastCalledTool string
lastCalledArgs map[string]any
}
func (f *fakeClient) Name() string { return f.name }
func (f *fakeClient) ListTools(ctx context.Context) ([]ToolDescriptor, error) {
if f.listErr != nil {
return nil, f.listErr
}
return f.tools, nil
}
func (f *fakeClient) CallTool(ctx context.Context, name string, args map[string]any) (ToolCallResult, error) {
f.lastCalledTool = name
f.lastCalledArgs = args
if f.callErr != nil {
return ToolCallResult{}, f.callErr
}
return f.callResult, nil
}
func (f *fakeClient) Close() error {
f.closed = true
return nil
}
func newTestManager(entries ...*serverEntry) *manager {
return &manager{enabled: true, servers: entries, routes: make(map[string]route)}
}
func TestManagerDisabledWithoutConfig(t *testing.T) {
m := NewManager(nil, nil)
if m.Enabled() {
t.Fatal("Manager should be disabled when cfg is nil")
}
if _, err := m.CallTool(context.Background(), "x__y", nil); err == nil {
t.Fatal("CallTool should error when disabled")
}
}
func TestManagerDisabledWhenNoServers(t *testing.T) {
m := NewManager(nil, &config.MCPConfig{Enabled: true})
if m.Enabled() {
t.Fatal("Manager should be disabled when no servers configured")
}
}
func TestManagerListToolsNamespacesAndFiltersAllowlist(t *testing.T) {
fc := &fakeClient{name: "word", tools: []ToolDescriptor{
{Name: "create_document", Description: "create a doc"},
{Name: "delete_document", Description: "delete a doc"},
}}
entry := newServerEntry(config.MCPServerConfig{
Name: "word",
ToolNamePrefix: "word",
AllowedTools: []string{"create_document"},
}, fc)
m := newTestManager(entry)
tools, err := m.ListTools(context.Background())
if err != nil {
t.Fatal(err)
}
if len(tools) != 1 {
t.Fatalf("expected 1 tool after allowlist filter, got %d", len(tools))
}
if tools[0].FQName != "word__create_document" {
t.Fatalf("unexpected FQName: %s", tools[0].FQName)
}
if tools[0].ServerName != "word" {
t.Fatalf("unexpected ServerName: %s", tools[0].ServerName)
}
}
func TestManagerListToolsSkipsFailingServer(t *testing.T) {
ok := &fakeClient{name: "ok-server", tools: []ToolDescriptor{{Name: "t1"}}}
bad := &fakeClient{name: "bad-server", listErr: errors.New("boom")}
m := newTestManager(
newServerEntry(config.MCPServerConfig{Name: "ok-server"}, ok),
newServerEntry(config.MCPServerConfig{Name: "bad-server"}, bad),
)
tools, err := m.ListTools(context.Background())
if err != nil {
t.Fatal(err)
}
if len(tools) != 1 {
t.Fatalf("expected 1 tool from healthy server, got %d", len(tools))
}
}
func TestManagerCallToolRoutesByFQNameWithoutPriorListTools(t *testing.T) {
fc := &fakeClient{name: "fetch-web", callResult: ToolCallResult{Text: "hello"}}
entry := newServerEntry(config.MCPServerConfig{Name: "fetch-web", ToolNamePrefix: "fetch-web"}, fc)
m := newTestManager(entry)
res, err := m.CallTool(context.Background(), "fetch-web__fetch_url", map[string]any{"url": "https://example.com"})
if err != nil {
t.Fatal(err)
}
if res.Text != "hello" {
t.Fatalf("unexpected result text: %s", res.Text)
}
if fc.lastCalledTool != "fetch_url" {
t.Fatalf("expected raw tool name %q, got %q", "fetch_url", fc.lastCalledTool)
}
}
func TestManagerCallToolRejectsDisallowedTool(t *testing.T) {
entry := newServerEntry(config.MCPServerConfig{
Name: "word",
ToolNamePrefix: "word",
AllowedTools: []string{"create_document"},
}, &fakeClient{name: "word"})
m := newTestManager(entry)
if _, err := m.CallTool(context.Background(), "word__delete_document", nil); err == nil {
t.Fatal("expected error for tool not in allowlist")
}
}
func TestManagerCallToolUnknownServerPrefix(t *testing.T) {
entry := newServerEntry(config.MCPServerConfig{Name: "word", ToolNamePrefix: "word"}, &fakeClient{name: "word"})
m := newTestManager(entry)
if _, err := m.CallTool(context.Background(), "unknown__tool", nil); err == nil {
t.Fatal("expected error for unresolvable fq name")
}
}
func TestManagerCloseClosesAllClients(t *testing.T) {
fc1 := &fakeClient{name: "a"}
fc2 := &fakeClient{name: "b"}
m := newTestManager(
newServerEntry(config.MCPServerConfig{Name: "a"}, fc1),
newServerEntry(config.MCPServerConfig{Name: "b"}, fc2),
)
if err := m.Close(); err != nil {
t.Fatal(err)
}
if !fc1.closed || !fc2.closed {
t.Fatal("Close should close all underlying clients")
}
}
func TestRequestIDContext(t *testing.T) {
ctx := context.Background()
if got := RequestIDFromContext(ctx); got != "" {
t.Fatalf("expected empty request id, got %q", got)
}
ctx = WithRequestID(ctx, "req-123")
if got := RequestIDFromContext(ctx); got != "req-123" {
t.Fatalf("expected req-123, got %q", got)
}
}
func TestNormalizeTransportAndAuthHeader(t *testing.T) {
cases := map[string]string{
"": "streamable-http",
"http": "streamable-http",
"streamable-http": "streamable-http",
"SSE": "sse",
"weird": "weird",
}
for in, want := range cases {
if got := normalizeTransport(in); got != want {
t.Errorf("normalizeTransport(%q) = %q, want %q", in, got, want)
}
}
if got := authHeaderValue(""); got != "" {
t.Fatalf("expected empty auth header, got %q", got)
}
if got := authHeaderValue("abc"); got != "Bearer abc" {
t.Fatalf("expected Bearer prefix, got %q", got)
}
if got := authHeaderValue("Bearer abc"); got != "Bearer abc" {
t.Fatalf("expected unchanged, got %q", got)
}
}

View File

@@ -1,40 +1,17 @@
# 模板文件
# 模板
这个目录包含了可以直接复制到你项目中使用的模板文件
复制到业务项目后,按注释中的挂载点扩展即可
## 包含的模板
- `migrate/main.go` - 数据库迁移工具模板 ⭐
- `Dockerfile.example` - Docker 构建示例
- `docker-compose.example.yml` - Docker Compose 示例
- `Makefile.example` - Makefile 常用命令示例
## 快速使用
### 迁移工具模板
| 文件 | 默认已接好 | 主要改哪里 |
|------|------------|------------|
| `server/main.go` | MustInit、日志、Warmup、默认中间件链、NewHandler、Close`-config`/`-addr` | `buildOptions` / `setupMiddleware` / `registerRoutes` |
| `migrate/main.go` | MustInit、日志、Database Warmup、Migrator、Close`-config`/`-dir` | `buildOptions`SQL 放 `migrations/` |
| `Dockerfile.example` 等 | 镜像 / Compose / Makefile 示例 | 按部署环境改 |
```bash
# 1. 复制到你的项目
mkdir -p cmd/migrate
cp templates/migrate/main.go cmd/migrate/
# 2. 编译
go build -o bin/migrate cmd/migrate/main.go
# 3. 使用
./bin/migrate up
./bin/migrate -help
mkdir -p cmd/server cmd/migrate
cp templates/server/main.go cmd/server/main.go
cp templates/migrate/main.go cmd/migrate/main.go
```
### Docker 模板
```bash
# 复制到你的项目根目录
cp templates/Dockerfile.example Dockerfile
cp templates/docker-compose.example.yml docker-compose.yml
cp templates/Makefile.example Makefile
```
## 完整文档
详细使用说明请查看:[INTEGRATION.md](../INTEGRATION.md) 第 7 节「数据库迁移」
对接说明:[INTEGRATION.md](../INTEGRATION.md)。

View File

@@ -5,145 +5,153 @@ import (
"fmt"
"os"
"git.toowon.com/jimmy/go-common/factory"
"git.toowon.com/jimmy/go-common/migration"
)
// 数据库迁移工具(黑盒模式)
// 数据库迁移 CLI 脚手架。
//
// 工作原理:
// 此工具调用 migration.RunMigrationsFromConfigWithCommand() 方法,
// 内部自动处理配置加载、数据库连接、迁移执行等所有细节。
// 你只需要提供配置文件和SQL迁移文件即可。
// mkdir -p cmd/migrate
// cp templates/migrate/main.go cmd/migrate/main.go
// go build -o bin/migrate cmd/migrate/main.go
// ./bin/migrate up|status|down
//
// 使用方式:
// 基本用法:
// ./migrate up # 使用默认配置
// ./migrate up -config /path/to/config.json # 指定配置文件
// ./migrate up -config config.json -dir db/migrations # 指定配置和迁移目录
// ./migrate status # 查看迁移状态
// ./migrate down # 回滚最后一个迁移
//
// Docker 中使用:
// # 方式1挂载配置文件推荐
// docker run -v /host/config.json:/app/config.json myapp ./migrate up
//
// # 方式2使用环境变量指定配置文件路径
// docker run -e CONFIG_FILE=/etc/app/config.json myapp ./migrate up
//
// # 方式3指定容器内的配置文件路径
// docker run myapp ./migrate up -config /etc/app/config.json
//
// 支持的命令:
// up - 执行所有待执行的迁移
// down - 回滚最后一个迁移
// status - 查看迁移状态
//
// 配置优先级(从高到低):
// 1. 命令行参数 -config 和 -dir
// 2. 环境变量 CONFIG_FILE 和 MIGRATIONS_DIR
// 3. 默认值config.json 和 migrations
// 默认已接好Factory 初始化、日志、Database Warmup、Migrator、Close 收口。
// 配置优先级:-config/-dir > 环境变量 CONFIG_FILE/MIGRATIONS_DIR > 默认值。
var (
func main() {
var (
configFile string
migrationsDir string
showHelp bool
)
)
func init() {
flag.StringVar(&configFile, "config", "", "配置文件路径默认config.json 或环境变量 CONFIG_FILE")
flag.StringVar(&configFile, "config", "", "配置文件路径(默认 config.json")
flag.StringVar(&configFile, "c", "", "配置文件路径(简写)")
flag.StringVar(&migrationsDir, "dir", "", "迁移文件目录(默认migrations 或环境变量 MIGRATIONS_DIR")
flag.StringVar(&migrationsDir, "d", "", "迁移文件目录(简写)")
flag.BoolVar(&showHelp, "help", false, "显示帮助信息")
flag.BoolVar(&showHelp, "h", false, "显示帮助信息(简写)")
}
func main() {
flag.StringVar(&migrationsDir, "dir", "", "迁移目录(默认 migrations")
flag.StringVar(&migrationsDir, "d", "", "迁移目录(简写)")
flag.BoolVar(&showHelp, "help", false, "显示帮助")
flag.BoolVar(&showHelp, "h", false, "显示帮助(简写)")
flag.Parse()
// 显示帮助
if showHelp {
printHelp()
os.Exit(0)
return
}
// 获取命令默认up
// 支持两种方式:
// 1. 位置参数:./migrate up
// 2. 标志参数:./migrate -cmd=up向后兼容
command := "up"
args := flag.Args()
if len(args) > 0 {
if args := flag.Args(); len(args) > 0 {
command = args[0]
}
// 验证命令
if command != "up" && command != "down" && command != "status" {
fmt.Fprintf(os.Stderr, "错误:未知命令 '%s'\n\n", command)
fmt.Fprintf(os.Stderr, "未知命令: %s\n\n", command)
printHelp()
os.Exit(1)
}
// 获取配置文件路径(优先级:命令行 > 环境变量 > 默认值)
// 如果未指定RunMigrationsFromConfigWithCommand 会自动查找
if configFile == "" {
configFile = getEnv("CONFIG_FILE", "")
configFile = envOr("CONFIG_FILE", "config.json")
}
// 获取迁移目录(优先级:命令行 > 环境变量 > 默认值)
// 如果未指定RunMigrationsFromConfigWithCommand 会使用默认值 "migrations"
if migrationsDir == "" {
migrationsDir = getEnv("MIGRATIONS_DIR", "")
migrationsDir = envOr("MIGRATIONS_DIR", "migrations")
}
// 执行迁移(黑盒模式:内部自动处理所有细节)
if err := migration.RunMigrationsFromConfigWithCommand(configFile, migrationsDir, command); err != nil {
fmt.Fprintf(os.Stderr, "错误: %v\n", err)
app := factory.MustInit(configFile, buildOptions()...)
defer app.Close()
log := app.MustLogger()
if err := app.Warmup(factory.ModuleLogger, factory.ModuleDatabase); err != nil {
log.Error("warmup failed", map[string]any{"error": err.Error()})
os.Exit(1)
}
m, err := app.Migrator(migrationsDir)
if err != nil {
log.Error("create migrator failed", map[string]any{"error": err.Error()})
os.Exit(1)
}
switch command {
case "up":
if err := m.Up(); err != nil {
log.Error("migrate up failed", map[string]any{"error": err.Error()})
os.Exit(1)
}
log.Info("migrate up ok", map[string]any{"dir": migrationsDir})
fmt.Println("✓ 迁移执行成功")
case "down":
if err := m.Down(); err != nil {
log.Error("migrate down failed", map[string]any{"error": err.Error()})
os.Exit(1)
}
log.Info("migrate down ok", map[string]any{"dir": migrationsDir})
fmt.Println("✓ 迁移回滚成功")
case "status":
status, err := m.Status()
if err != nil {
log.Error("migrate status failed", map[string]any{"error": err.Error()})
os.Exit(1)
}
printStatus(status)
}
}
func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
// buildOptions 组装 Factory Option自定义 DB / Logger 等在此注入)
func buildOptions() []factory.Option {
// 默认走 config.json。需要替换时例如
//
// return []factory.Option{
// factory.WithLogger(customLogger),
// factory.WithDatabase(db),
// }
return nil
}
func printStatus(status []migration.MigrationStatus) {
if len(status) == 0 {
fmt.Println("没有找到迁移")
return
}
return defaultValue
fmt.Println("\n迁移状态:")
fmt.Printf("%-20s %-40s %-10s\n", "版本", "描述", "状态")
for _, s := range status {
st := "待执行"
if s.Applied {
st = "已应用"
}
fmt.Printf("%-20s %-40s %-10s\n", s.Version, s.Description, st)
}
fmt.Println()
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func printHelp() {
fmt.Println("数据库迁移工具")
fmt.Println()
fmt.Println("用法:")
fmt.Println(" migrate [命令] [选项]")
fmt.Println()
fmt.Println("命令:")
fmt.Println(" up 执行所有待执行迁移(默认)")
fmt.Println(" down 回滚最后一个迁移")
fmt.Println(" status 查看迁移状态")
fmt.Println()
fmt.Println("选项:")
fmt.Println(" -config, -c 配置文件路径(默认: config.json")
fmt.Println(" -dir, -d 迁移文件目录(默认: migrations")
fmt.Println(" -help, -h 显示帮助信息")
fmt.Println()
fmt.Println("示例:")
fmt.Println(" # 使用默认配置")
fmt.Println(" migrate up")
fmt.Println()
fmt.Println(" # 指定配置文件")
fmt.Println(" migrate up -config /etc/app/config.json")
fmt.Println()
fmt.Println(" # 指定配置和迁移目录")
fmt.Println(" migrate up -c config.json -d db/migrations")
fmt.Println()
fmt.Println(" # 使用环境变量指定配置文件路径")
fmt.Println(" CONFIG_FILE=/etc/app/config.json migrate up")
fmt.Println()
fmt.Println(" # Docker 中使用(挂载配置文件)")
fmt.Println(" docker run -v /host/config.json:/app/config.json myapp migrate up")
fmt.Println()
fmt.Println("配置优先级(从高到低):")
fmt.Println(" 1. 命令行参数 -config 和 -dir")
fmt.Println(" 2. 环境变量 CONFIG_FILE 和 MIGRATIONS_DIR")
fmt.Println(" 3. 默认值config.json 和 migrations")
fmt.Println(`数据库迁移工具
用法:
migrate [命令] [选项]
命令:
up 执行待执行迁移(默认)
down 回滚最后一个迁移
status 查看状态
选项:
-config, -c 配置文件(默认: config.json,可用 CONFIG_FILE
-dir, -d 迁移目录(默认: migrations,可用 MIGRATIONS_DIR
-help, -h 帮助
示例:
migrate up
migrate up -config /etc/app/config.json -dir db/migrations
CONFIG_FILE=config.json migrate status`)
}

100
templates/server/main.go Normal file
View File

@@ -0,0 +1,100 @@
package main
import (
"flag"
"net/http"
"os"
"git.toowon.com/jimmy/go-common/factory"
"git.toowon.com/jimmy/go-common/middleware"
)
// 业务 HTTP 服务脚手架。
//
// mkdir -p cmd/server
// cp templates/server/main.go cmd/server/main.go
//
// 默认已接好Factory 初始化、日志、默认中间件链、NewHandler 出参、Close 收口。
// 按需改buildOptions / setupMiddleware / registerRoutes / Warmup 模块列表。
func main() {
configPath := flag.String("config", envOr("CONFIG_FILE", "config.json"), "配置文件路径")
addr := flag.String("addr", envOr("HTTP_ADDR", ":8080"), "监听地址")
flag.Parse()
app := factory.MustInit(*configPath, buildOptions()...)
defer app.Close()
log := app.MustLogger()
// 启动期预热:按业务实际使用的模块增删(未配置的模块 Warmup 会失败)
if err := app.Warmup(
factory.ModuleLogger,
// factory.ModuleI18n,
// factory.ModuleDatabase,
// factory.ModuleRedis,
); err != nil {
log.Error("warmup failed", map[string]any{"error": err.Error()})
os.Exit(1)
}
chain := setupMiddleware(app)
mux := http.NewServeMux()
registerRoutes(mux, chain, app)
log.Info("server starting", map[string]any{"addr": *addr, "config": *configPath})
if err := http.ListenAndServe(*addr, mux); err != nil {
log.Error("server stopped", map[string]any{"error": err.Error()})
os.Exit(1)
}
}
// buildOptions 组装 Factory Option自定义实现 / 测试替身在此注入)
func buildOptions() []factory.Option {
// 默认不注入,全部走 config.json lazy 初始化。
// 需要替换实现时取消注释,例如:
//
// return []factory.Option{
// factory.WithLogger(customLogger),
// factory.WithStorage(customStorage),
// factory.WithDatabase(db),
// factory.WithRedis(rds),
// factory.WithI18n(i18nInst),
// factory.WithMiddlewareChain(customChain),
// }
return nil
}
// setupMiddleware 基于 Factory 默认链追加业务中间件
// 默认链Recovery → RequestID → Logging → RateLimit? → CORS? → Language → Timezone
func setupMiddleware(app *factory.Factory) *middleware.Chain {
chain := app.MiddlewareChain()
// 业务鉴权等自行挂载(本库不提供登录体系)
// chain.Append(authMiddleware)
// chain.Append(permissionMiddleware)
return chain
}
// registerRoutes 注册路由;出参统一用 app.NewHandler
func registerRoutes(mux *http.ServeMux, chain *middleware.Chain, app *factory.Factory) {
mux.Handle("/api/ping", chain.ThenFunc(func(w http.ResponseWriter, r *http.Request) {
h := app.NewHandler(w, r)
h.Success(map[string]string{"status": "ok"})
}))
// 示例:业务路由
// mux.Handle("/api/users", chain.ThenFunc(func(w http.ResponseWriter, r *http.Request) {
// h := app.NewHandler(w, r)
// // ...
// h.Success(nil)
// }))
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}