3 Commits

24 changed files with 1975 additions and 702 deletions

View File

@@ -1,366 +1,213 @@
# 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 |
| 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` 段。
---
## 9. config.json 最小示例
## 9. 最小 config.json
```json
{
@@ -389,41 +236,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,46 @@
# 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.3.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` |
## 文档
| 文档 | 说明 |
|------|------|
| [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,34 @@
# 版本管理说明
# 版本管理
## 版本号规则
遵循 [语义化版本](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.3.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.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

View File

@@ -20,6 +20,7 @@ type Config struct {
Logger *LoggerConfig `json:"logger"`
I18n *I18nConfig `json:"i18n"`
RateLimit *RateLimitConfig `json:"rateLimit"`
MCP *MCPConfig `json:"mcp"`
}
// I18nConfig 国际化配置
@@ -325,6 +326,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 +537,13 @@ func (c *Config) setDefaults() {
c.RateLimit.ByIP = true // 默认按IP限流
}
}
// MCP 默认值
if c.MCP != nil {
if c.MCP.CallTimeoutSeconds <= 0 {
c.MCP.CallTimeoutSeconds = 60
}
}
}
// GetDatabase 获取数据库配置
@@ -546,6 +598,11 @@ func (c *Config) GetI18n() *I18nConfig {
return c.I18n
}
// GetMCP 获取 MCP 配置
func (c *Config) GetMCP() *MCPConfig {
return c.MCP
}
// GetDatabaseDSN 获取数据库连接字符串
func (c *Config) GetDatabaseDSN() (string, error) {
if c.Database == nil {

View File

@@ -89,6 +89,30 @@
"period": 60,
"byIP": true,
"byUserID": false
},
"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

@@ -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

@@ -10,8 +10,10 @@ import (
"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 +40,8 @@ type Factory struct {
db *gorm.DB
redis *redis.Client
i18n *i18n.I18n
excel *excel.Excel
chain *middleware.Chain
mcp mcp.Manager
mu sync.Mutex
}
@@ -47,13 +49,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 +81,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 +111,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 +316,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 +333,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 +379,64 @@ 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()
}
// 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")
}
}

200
factory/lifecycle.go Normal file
View File

@@ -0,0 +1,200 @@
package factory
import (
"errors"
"fmt"
"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"
)
// 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()
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
}

76
factory/options.go Normal file
View File

@@ -0,0 +1,76 @@
package factory
import (
"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
}
}

12
go.mod
View File

@@ -1,12 +1,11 @@
module git.toowon.com/jimmy/go-common
go 1.24.0
toolchain go1.24.10
go 1.25.0
require (
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/redis/go-redis/v9 v9.17.1
github.com/xuri/excelize/v2 v2.10.0
golang.org/x/crypto v0.43.0
@@ -22,6 +21,7 @@ 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/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 +40,17 @@ 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
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
)

22
go.sum
View File

@@ -17,6 +17,12 @@ 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/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 +56,8 @@ 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/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 +73,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 +92,24 @@ 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=
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.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
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.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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
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.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
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
}