20 Commits

Author SHA1 Message Date
4f00b83e86 增加图形验证码的功能 2026-08-01 00:05:04 +08:00
97450f0739 go-common中增加mcp抽离成通用方法(编排由业务方自行处理) 2026-07-25 11:27:34 +08:00
121928733b 调整项目架构,让框架更优化 2026-07-20 21:39:01 +08:00
987b16fd41 调整版本统一为1.0.0 2026-06-25 00:41:24 +08:00
6072ec57e8 重构项目的实现,优化使用方法与使用逻辑 2026-06-25 00:03:59 +08:00
a6e8101e09 添加本地上传的功能 2026-01-30 21:40:21 +08:00
38ebe73e45 调整Excel导出时使用的方法 2025-12-28 12:33:06 +08:00
e3d9bbbcc5 调整Excel导出时使用的方法 2025-12-28 12:15:37 +08:00
47cbdbb2de 增加导出数据到Excel的方法 2025-12-28 11:53:24 +08:00
f8f4df4073 增加多语言支持 2025-12-07 10:32:36 +08:00
684923f9cb 调整request的方法,增加类型转换的方法 2025-12-07 09:31:59 +08:00
545c6ef6a4 增加getuint64/32的方法 2025-12-07 01:09:52 +08:00
5cfa0d7ce5 PageRequest增加了keyword 2025-12-07 01:03:25 +08:00
f62e8ecebf 增加time的函数,crypto的函数 2025-12-07 00:51:02 +08:00
339920a940 调整项目结构,factory只负责暴露方法,不实现业务细节 2025-12-07 00:04:01 +08:00
b66f345281 修复迁移时,数据库未指定的情况下数据库脚本混乱的问题 2025-12-06 22:03:57 +08:00
6547e7bca8 调整迁移的逻辑 2025-12-06 21:38:53 +08:00
6146178111 调整工厂模式的方法 2025-12-05 00:07:15 +08:00
0650feb0d2 调整工具类的方法,优化方法调用及增加迁移工具及其用法 2025-12-04 22:30:48 +08:00
de8fc13f18 日志方法增加异步与同步的方法 2025-11-30 21:01:47 +08:00
91 changed files with 7524 additions and 7533 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
.cursor .cursor
.DS_Store

295
INTEGRATION.md Normal file
View File

@@ -0,0 +1,295 @@
# GoCommon 业务项目对接手册
模块路径:`git.toowon.com/jimmy/go-common`
> 目标架构一步到位,不考虑向后兼容。
---
## 1. 原则
| 原则 | 说明 |
|------|------|
| Factory 只是入口 | 启动初始化一次getter 取模块对象 |
| 能力在模块自身 | `log.Info()``db.Find()``store.Upload()`,不在 Factory 透传 |
| 按需配置 | 只用到的模块写进 `config.json` |
| 不重写基础设施 | 连接池、HTTP 出参、中间件链由本库提供 |
| 业务只管业务 | Service 返回数据;出参用 `http.Handler`;迁移只写 SQL |
登录鉴权、参数校验、出站 HTTP Client 等由**业务项目自行实现**,不在本库范围。
---
## 2. 安装
```bash
go env -w GOPRIVATE=git.toowon.com
go env -w GONOPROXY=git.toowon.com
go env -w GONOSUMDB=git.toowon.com
git config --global url."git@git.toowon.com:".insteadOf "https://git.toowon.com/"
go get git.toowon.com/jimmy/go-common@v1.2.0
```
配置示例:[`config/example.json`](./config/example.json)。
---
## 3. 推荐结构
```text
your-project/
├── config.json
├── cmd/server/main.go # 复制 templates/server/main.go
├── cmd/migrate/main.go # 复制 templates/migrate/main.go
├── migrations/
├── locales/ # 可选
├── internal/handler/
├── internal/service/
└── go.mod
```
---
## 4. 启动(只做一次)
```bash
cp templates/server/main.go cmd/server/main.go
```
模板默认已包含:`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 lazy 缓存。业务侧不要自行 `gorm.Open` / `redis.NewClient`
| 模块 | API | 说明 |
|------|-----|------|
| 配置 | `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(...)` | 收口 / 启动预热 |
`MustXxx` 仅用于 **main / 启动注入**(失败 panic
```go
func NewUserService(app *factory.Factory) *UserService {
return &UserService{db: app.MustDatabase(), rds: app.MustRedis()}
}
```
---
## 6. HTTP 统一出参
| 层级 | 职责 |
|------|------|
| Service | 返回数据或 error |
| Handler | 解析、调 Service、`h.Success` / `h.Error` |
| `http.Handler` | 统一 JSON 信封、i18n、timestamp |
响应格式HTTP 恒 200
```json
{ "code": 0, "message": "success", "timestamp": 1704067200, "data": {} }
```
分页 `data``{ "list", "total", "page", "pageSize" }`
```go
h := app.NewHandler(w, r) // 自动注入已配置的 i18n
var req ListUserRequest
if err := h.ParseJSON(&req); err != nil {
h.Error("common.invalid_request")
return
}
users, total, err := svc.List(h.Pagination().GetPage(), h.Pagination().GetPageSize())
if err != nil {
h.Error("user.list_failed")
return
}
h.SuccessPage(users, total)
```
请求头:`Accept-Language`(语种)、`X-Timezone`(默认 `Asia/Shanghai`)。
---
## 7. 数据库迁移
```bash
cp templates/migrate/main.go cmd/migrate/main.go
go build -o bin/migrate cmd/migrate/main.go
./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);
-- migrations/20240101000001_create_users.down.sql
DROP TABLE IF EXISTS users;
```
---
## 8. 模块速查
```go
// 日志
app.MustLogger().Info("服务启动", nil)
// 存储
store, _ := app.Storage()
_ = store.Upload(ctx, "images/a.jpg", reader, "image/jpeg")
// 邮件 / 短信HTTP 通知用 Async验证码用同步
app.MustEmail().SendEmailAsync(ctx, []string{"a@b.com"}, "主题", "正文")
app.MustSMS().SendSMSAsync(ctx, []string{"13800138000"}, map[string]string{"code": "123456"})
// Excel
app.Excel().ExportToFile("users.xlsx", "用户列表", columns, users)
// i18nlocales 目录;出参经 NewHandler 自动用)
app.MustI18n().GetMessage("zh-CN", "user.not_found")
// tools不经 Factory
tools.Now()
tools.MD5("text")
```
### 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
```json
{
"database": {
"type": "mysql",
"host": "localhost",
"port": 3306,
"user": "root",
"password": "password",
"database": "mydb"
},
"logger": {
"level": "info",
"output": "both",
"filePath": "./logs/app.log",
"async": true
},
"i18n": {
"defaultLang": "zh-CN",
"localesDir": "locales"
}
}
```
完整字段见 [`config/example.json`](./config/example.json)。
---
## 10. 反模式
- Handler 内重复 `Init`
- Service 拼 HTTP JSON / 自定义 Response
- 自行 `gorm.Open` / `redis.NewClient`
- Factory 透传(`LogInfo``Success``Now` 等)
- 请求路径滥用 `MustXxx`
- 把 MCP 工具路由/抽参/Prompt 编排逻辑塞进本库(应在业务侧基于 `mcp.Manager` 自行实现)
---
## 11. 故障排除
| 问题 | 处理 |
|------|------|
| 无法下载模块 | 检查 `GOPRIVATE` 与 SSH insteadOf |
| 依赖异常 | `go clean -modcache && go mod tidy` |
| getter 报 config is nil | 补配置段,或不调用该模块 |
| 迁移找不到文件 | 检查 `-dir` 与 SQL 命名 |
版本发布见 [VERSION.md](./VERSION.md)。

301
README.md
View File

@@ -1,278 +1,47 @@
# GoCommon - Go通用工具类库 # GoCommon
这是一个Go语言开发的通用工具类库其他Go项目提供常用的工具方法集合。 其他 Go 项目引用的通用工具库。对接请看:**[INTEGRATION.md](./INTEGRATION.md)**
## 功能模块 ```
git.toowon.com/jimmy/go-common
### 1. 数据库迁移工具 (migration) ```
提供数据库迁移功能支持MySQL、PostgreSQL、SQLite等数据库。
### 2. 日期转换工具 (datetime)
提供日期时间转换功能,支持时区设定和多种格式转换。
### 3. HTTP Restful工具 (http)
提供HTTP请求/响应处理工具包含标准化的响应结构、分页支持和HTTP状态码与业务状态码的分离。
### 4. 中间件工具 (middleware)
提供常用的HTTP中间件包括CORS处理和时区管理。
### 5. 配置工具 (config)
提供从外部文件加载配置的功能支持数据库、OSS、Redis、CORS、MinIO等配置。
### 6. 存储工具 (storage)
提供文件上传和查看功能支持OSS和MinIO两种存储方式并提供HTTP处理器。
### 7. 邮件工具 (email)
提供SMTP邮件发送功能支持纯文本和HTML邮件使用Go标准库实现。
### 8. 短信工具 (sms)
提供阿里云短信发送功能支持模板短信和批量发送使用Go标准库实现。
### 9. 工厂工具 (factory)
提供从配置文件直接创建已初始化客户端对象的功能包括数据库、Redis、邮件、短信、日志等避免调用方重复实现创建逻辑。
### 10. 日志工具 (logger)
提供统一的日志记录功能支持多种日志级别和输出方式使用Go标准库实现。
## 安装
### 1. 配置私有仓库(重要)
由于本项目使用私有 Git 仓库,需要先配置 `GOPRIVATE` 环境变量:
```bash ```bash
# 使用 go env 命令配置(推荐,永久生效)
go env -w GOPRIVATE=git.toowon.com go env -w GOPRIVATE=git.toowon.com
go get git.toowon.com/jimmy/go-common@v1.4.0
# 验证配置
go env GOPRIVATE
``` ```
**详细配置说明请参考 [SETUP.md](./SETUP.md)** ## 设计
**遇到问题?请查看 [故障排除指南](./TROUBLESHOOTING.md)** - **Factory**启动初始化一次getter 取模块对象
- **模块包**`object.Method()`(如 `log.Info``store.Upload`
- **http**:统一出参(`Handler`
- **tools / migration**:无状态或独立 CLI不经 Factory 透传
### 2. 安装模块 ## 模块
```bash | 包 | 说明 |
# 安装最新版本(推荐用于开发) |----|------|
go get git.toowon.com/jimmy/go-common@latest | `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` 开关) |
# 安装特定版本(推荐用于生产) ## 文档
go get git.toowon.com/jimmy/go-common@v1.0.0
```
**版本管理说明请参考 [VERSION.md](./VERSION.md)**
## 使用示例
详细的使用说明请参考各模块的文档:
- [数据库迁移工具文档](./docs/migration.md)
- [日期转换工具文档](./docs/datetime.md)
- [HTTP Restful工具文档](./docs/http.md)
- [中间件工具文档](./docs/middleware.md)
- [配置工具文档](./docs/config.md)
- [存储工具文档](./docs/storage.md)
- [邮件工具文档](./docs/email.md)
- [短信工具文档](./docs/sms.md)
- [工厂工具文档](./docs/factory.md)
- [日志工具文档](./docs/logger.md)
### 快速示例
#### 数据库迁移
```go
import "git.toowon.com/jimmy/go-common/migration"
migrator := migration.NewMigrator(db)
migrator.AddMigration(migration.Migration{
Version: "20240101000001",
Description: "create_users_table",
Up: func(db *gorm.DB) error {
return db.Exec("CREATE TABLE users ...").Error
},
})
migrator.Up()
```
#### 日期转换
```go
import "git.toowon.com/jimmy/go-common/datetime"
datetime.SetDefaultTimeZone(datetime.AsiaShanghai)
now := datetime.Now()
str := datetime.FormatDateTime(now)
```
#### HTTP响应Handler黑盒模式
```go
import (
"net/http"
commonhttp "git.toowon.com/jimmy/go-common/http"
)
// 使用Handler黑盒模式
func GetUser(h *commonhttp.Handler) {
id := h.GetQueryInt64("id", 0) // 无需传递r
h.Success(data) // 无需传递w
}
http.HandleFunc("/user", commonhttp.HandleFunc(GetUser))
```
#### 中间件
```go
import (
"git.toowon.com/jimmy/go-common/middleware"
commonhttp "git.toowon.com/jimmy/go-common/http"
)
// CORS + 时区中间件
chain := middleware.NewChain(
middleware.CORS(),
middleware.Timezone,
)
handler := chain.ThenFunc(yourHandler)
// 在Handler中获取时区
func handler(h *commonhttp.Handler) {
timezone := h.GetTimezone()
}
```
#### 配置管理
```go
import "git.toowon.com/jimmy/go-common/config"
// 从文件加载配置
cfg, err := config.LoadFromFile("./config.json")
// 获取各种配置
dsn, _ := cfg.GetDatabaseDSN()
redisAddr := cfg.GetRedisAddr()
corsConfig := cfg.GetCORS()
```
#### 文件上传和查看(推荐使用工厂黑盒模式)
```go
import (
"context"
"git.toowon.com/jimmy/go-common/factory"
)
fac, _ := factory.NewFactoryFromFile("./config.json")
ctx := context.Background()
// 黑盒模式推荐自动选择OSS或MinIO
file, _ := os.Open("test.jpg")
url, _ := fac.UploadFile(ctx, "images/test.jpg", file, "image/jpeg")
// 获取文件URL
url, _ := fac.GetFileURL("images/test.jpg", 0) // 永久有效
url, _ := fac.GetFileURL("images/test.jpg", 3600) // 1小时后过期
// 或使用存储处理器需要HTTP处理器时
storage, _ := storage.NewStorage(storage.StorageTypeOSS, cfg)
uploadHandler := storage.NewUploadHandler(...)
```
#### 邮件发送(推荐使用工厂黑盒模式)
```go
import "git.toowon.com/jimmy/go-common/factory"
fac, _ := factory.NewFactoryFromFile("./config.json")
// 黑盒模式(推荐)
fac.SendEmail([]string{"user@example.com"}, "主题", "正文")
fac.SendEmail([]string{"user@example.com"}, "主题", "纯文本", "<h1>HTML内容</h1>")
// 或获取客户端对象(需要高级功能时)
emailClient, _ := fac.GetEmailClient()
emailClient.SendSimple(...)
```
#### 短信发送(推荐使用工厂黑盒模式)
```go
import "git.toowon.com/jimmy/go-common/factory"
fac, _ := factory.NewFactoryFromFile("./config.json")
// 黑盒模式(推荐)
fac.SendSMS([]string{"13800138000"}, map[string]string{"code": "123456"})
// 或获取客户端对象(需要高级功能时)
smsClient, _ := fac.GetSMSClient()
smsClient.SendSimple(...)
```
#### 使用工厂(黑盒模式,推荐)
```go
import (
"context"
"git.toowon.com/jimmy/go-common/factory"
)
// 从配置文件创建工厂(最推荐)
fac, _ := factory.NewFactoryFromFile("./config.json")
ctx := context.Background()
// 日志(黑盒模式,直接调用)
fac.LogInfo("用户登录成功")
fac.LogError("登录失败: %v", err)
// 邮件发送(黑盒模式,直接调用)
fac.SendEmail([]string{"user@example.com"}, "验证码", "您的验证码是123456")
// 短信发送(黑盒模式,直接调用)
fac.SendSMS([]string{"13800138000"}, map[string]string{"code": "123456"})
// 文件上传黑盒模式自动选择OSS或MinIO
file, _ := os.Open("test.jpg")
url, _ := fac.UploadFile(ctx, "images/test.jpg", file, "image/jpeg")
// 获取文件URL
url, _ := fac.GetFileURL("images/test.jpg", 0) // 永久有效
url, _ := fac.GetFileURL("images/test.jpg", 3600) // 1小时后过期
// Redis操作黑盒模式直接调用
fac.RedisSet(ctx, "key", "value", time.Hour)
value, _ := fac.RedisGet(ctx, "key")
fac.RedisDelete(ctx, "key")
// 数据库(黑盒模式,获取已初始化对象)
db, _ := fac.GetDatabase()
db.Find(&users)
// Redis客户端黑盒模式获取已初始化对象
redisClient, _ := fac.GetRedisClient()
redisClient.HGet(ctx, "key", "field").Result()
```
更多示例请查看 [examples](./examples/) 目录。
## 版本管理
当前版本:**v1.0.0**
### 如何指定版本
`go.mod` 文件中指定版本:
```go
require (
git.toowon.com/jimmy/go-common v1.0.0
)
```
或者使用命令行:
```bash
# 使用最新版本
go get git.toowon.com/jimmy/go-common@latest
# 使用特定版本
go get git.toowon.com/jimmy/go-common@v1.0.0
```
**详细版本管理说明请参考 [VERSION.md](./VERSION.md)**
| 文档 | 说明 |
|------|------|
| [INTEGRATION.md](./INTEGRATION.md) | 对接手册 |
| [VERSION.md](./VERSION.md) | 版本与发布 |
| [templates/](./templates/) | server / migrate 等脚手架 |
| [config/example.json](./config/example.json) | 配置示例 |
| [examples/](./examples/) | 示例代码 |

174
SETUP.md
View File

@@ -1,174 +0,0 @@
# 项目配置说明
## GOPRIVATE 环境变量配置
由于本项目使用私有 Git 仓库 (`git.toowon.com`),需要配置 `GOPRIVATE` 环境变量,让 Go 工具链知道这些模块是私有的,不要通过公共代理下载。
### 配置方法
#### 方法一:临时配置(当前终端会话有效)
```bash
# macOS/Linux
export GOPRIVATE=git.toowon.com
# Windows (PowerShell)
$env:GOPRIVATE="git.toowon.com"
# Windows (CMD)
set GOPRIVATE=git.toowon.com
```
#### 方法二:永久配置(推荐)
**macOS/Linux:**
1. 编辑 shell 配置文件(根据你使用的 shell 选择):
```bash
# 如果是 zshmacOS 默认)
nano ~/.zshrc
# 如果是 bash
nano ~/.bashrc
# 或
nano ~/.bash_profile
```
2. 添加以下内容:
```bash
export GOPRIVATE=git.toowon.com
```
3. 保存文件并重新加载配置:
```bash
# zsh
source ~/.zshrc
# bash
source ~/.bashrc
```
**Windows:**
1. 打开"系统属性" -> "高级" -> "环境变量"
2. 在"用户变量"或"系统变量"中点击"新建"
3. 变量名:`GOPRIVATE`
4. 变量值:`git.toowon.com`
5. 点击"确定"保存
#### 方法三:使用 go env 命令推荐Go 1.13+
```bash
# 设置 GOPRIVATE
go env -w GOPRIVATE=git.toowon.com
# 查看当前配置
go env GOPRIVATE
# 如果需要设置多个私有仓库,用逗号分隔
go env -w GOPRIVATE=git.toowon.com,github.com/your-org
```
### 验证配置
```bash
# 查看 GOPRIVATE 配置
go env GOPRIVATE
# 应该输出: git.toowon.com
```
### 其他相关环境变量(可选)
如果需要更细粒度的控制,还可以配置:
```bash
# GOPRIVATE: 私有模块,不通过代理下载,不校验 checksum
go env -w GOPRIVATE=git.toowon.com
# GONOPROXY: 不通过代理下载的模块(默认与 GOPRIVATE 相同)
go env -w GONOPROXY=git.toowon.com
# GONOSUMDB: 不校验 checksum 的模块(默认与 GOPRIVATE 相同)
go env -w GONOSUMDB=git.toowon.com
```
### Git 认证配置
由于是私有仓库,还需要配置 Git 认证:
#### 方法一:使用 SSH推荐
1. 确保已配置 SSH 密钥
2. 使用 SSH URL
```bash
git config --global url."git@git.toowon.com:".insteadOf "https://git.toowon.com/"
```
#### 方法二:使用 HTTPS + 个人访问令牌
1. 在 Git 服务器上生成个人访问令牌
2. 配置 Git 凭据:
```bash
git config --global credential.helper store
```
3. 首次访问时会提示输入用户名和令牌
#### 方法三:在 URL 中包含凭据(不推荐,安全性较低)
```bash
# 在 go.mod 中使用(不推荐)
# 或者通过 .netrc 文件配置
```
### 常见问题
#### 问题1go get 失败,提示找不到模块
**解决方案:**
1. 确认 GOPRIVATE 已正确配置
2. 确认 Git 认证已配置
3. 尝试手动克隆仓库验证:
```bash
git clone git@git.toowon.com:jimmy/go-common.git
```
#### 问题2go mod download 失败
**解决方案:**
```bash
# 清除模块缓存
go clean -modcache
# 重新下载
go mod download
```
#### 问题3IDE 无法识别模块
**解决方案:**
1. 重启 IDE
2. 在 IDE 中执行:`go mod tidy`
3. 确认 IDE 的 Go 环境变量配置正确
### 完整配置示例
```bash
# 1. 配置 GOPRIVATE
go env -w GOPRIVATE=git.toowon.com
# 2. 配置 Git SSH如果使用 SSH
git config --global url."git@git.toowon.com:".insteadOf "https://git.toowon.com/"
# 3. 验证配置
go env | grep GOPRIVATE
# 4. 测试模块下载
go get git.toowon.com/jimmy/go-common@latest
```
### 参考文档
- [Go Modules 官方文档](https://go.dev/ref/mod)
- [Go 私有模块配置](https://go.dev/ref/mod#private-modules)

View File

@@ -1,208 +0,0 @@
# 故障排除指南
## 常见问题
### 1. 伪版本错误 (Pseudo-version error)
**错误信息:**
```
go: github.com/pmezard/go-difflib@v1.1.1-0.20181226105442-5d4384ee4fb2: invalid pseudo-version: preceding tag (v1.1.0) not found
```
**原因:**
- Go 模块缓存损坏
- 依赖版本冲突
- 间接依赖使用了无效的伪版本
**解决方案:**
#### 方案1清理模块缓存推荐
```bash
# 清理 Go 模块缓存
go clean -modcache
# 重新下载依赖
go mod download
# 整理依赖
go mod tidy
```
#### 方案2在调用方项目中解决
如果是在调用方项目中遇到此问题:
```bash
# 进入调用方项目目录
cd /path/to/your/project
# 清理模块缓存
go clean -modcache
# 删除 go.sum 文件(可选,会自动重新生成)
rm go.sum
# 重新获取依赖
go get git.toowon.com/jimmy/go-common@v0.0.1
# 整理依赖
go mod tidy
```
#### 方案3使用代理如果网络问题
```bash
# 设置 Go 代理(国内用户推荐)
go env -w GOPROXY=https://goproxy.cn,direct
# 或者使用官方代理
go env -w GOPROXY=https://proxy.golang.org,direct
```
#### 方案4强制更新依赖
```bash
# 强制更新所有依赖
go get -u ./...
# 或者更新特定依赖
go get -u git.toowon.com/jimmy/go-common@latest
```
### 2. 私有仓库访问问题
**错误信息:**
```
go: git.toowon.com/jimmy/go-common@v0.0.1: unrecognized import path
```
**解决方案:**
```bash
# 配置 GOPRIVATE
go env -w GOPRIVATE=git.toowon.com
# 配置 Git 认证(如果需要)
git config --global url."git@git.toowon.com:".insteadOf "https://git.toowon.com/"
```
详细说明请参考 [SETUP.md](./SETUP.md)
### 3. 版本标签不存在
**错误信息:**
```
go: git.toowon.com/jimmy/go-common@v0.0.1: invalid version: unknown revision
```
**解决方案:**
1. 确认版本标签已创建并推送:
```bash
# 在库项目中查看标签
git tag -l
# 如果标签不存在,创建并推送
git tag -a v0.0.1 -m "Release v0.0.1"
git push origin v0.0.1
```
2. 在调用方项目中清理缓存后重试:
```bash
go clean -modcache
go get git.toowon.com/jimmy/go-common@v0.0.1
```
### 4. 依赖版本冲突
**错误信息:**
```
go: conflicting versions for module
```
**解决方案:**
```bash
# 查看依赖树
go mod graph | grep conflicting-module
# 更新冲突的依赖
go get -u conflicting-module@latest
# 整理依赖
go mod tidy
```
### 5. 网络连接问题
**错误信息:**
```
dial tcp: lookup proxy.golang.org: no such host
```
**解决方案:**
```bash
# 使用国内代理
go env -w GOPROXY=https://goproxy.cn,direct
# 或者使用七牛云代理
go env -w GOPROXY=https://goproxy.io,direct
# 禁用代理(直接访问)
go env -w GOPROXY=direct
```
## 通用排查步骤
如果遇到其他问题,按以下步骤排查:
1. **清理缓存**
```bash
go clean -modcache
```
2. **验证模块**
```bash
go mod verify
```
3. **整理依赖**
```bash
go mod tidy
```
4. **查看依赖图**
```bash
go mod graph
```
5. **查看模块信息**
```bash
go list -m all
```
6. **检查 Go 环境**
```bash
go env
```
## 获取帮助
如果以上方法都无法解决问题,请:
1. 检查 Go 版本(建议使用 Go 1.21 或更高版本)
```bash
go version
```
2. 查看详细的错误信息
```bash
go get -v git.toowon.com/jimmy/go-common@v0.0.1
```
3. 检查项目仓库是否有对应的版本标签
4. 联系项目维护者

View File

@@ -1,131 +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 ```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 ```bash
# 1. 确保所有更改已提交 go get git.toowon.com/jimmy/go-common@v1.3.0 # 生产固定版本
git add . go get git.toowon.com/jimmy/go-common@latest # 开发
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
``` ```
### 验证标签
```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
```
## 当前版本 ## 当前版本
当前版本:**v1.0.0** **v1.4.0**
## 版本历史 ## 版本历史
- **v1.0.0** (当前版本) - **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`;不含路由/抽参/编排(业务侧自行实现)
- 包含所有基础工具类migration、datetime、http、middleware、config、storage、email、sms、factory、logger - **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

@@ -5,8 +5,6 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"git.toowon.com/jimmy/go-common/middleware"
) )
// Config 应用配置 // Config 应用配置
@@ -16,9 +14,35 @@ type Config struct {
Redis *RedisConfig `json:"redis"` Redis *RedisConfig `json:"redis"`
CORS *CORSConfig `json:"cors"` CORS *CORSConfig `json:"cors"`
MinIO *MinIOConfig `json:"minio"` MinIO *MinIOConfig `json:"minio"`
Local *LocalStorageConfig `json:"localStorage"`
Email *EmailConfig `json:"email"` Email *EmailConfig `json:"email"`
SMS *SMSConfig `json:"sms"` SMS *SMSConfig `json:"sms"`
Logger *LoggerConfig `json:"logger"` Logger *LoggerConfig `json:"logger"`
I18n *I18nConfig `json:"i18n"`
RateLimit *RateLimitConfig `json:"rateLimit"`
MCP *MCPConfig `json:"mcp"`
Captcha *CaptchaConfig `json:"captcha"`
}
// I18nConfig 国际化配置
type I18nConfig struct {
DefaultLang string `json:"defaultLang"`
LocalesDir string `json:"localesDir"`
}
// LocalStorageConfig 本地存储配置
// 用于将文件保存到本地文件夹(适合开发环境、单机部署等场景)
type LocalStorageConfig struct {
// BaseDir 本地文件保存根目录(必填)
// 示例: "./uploads" 或 "/var/app/uploads"
BaseDir string `json:"baseDir"`
// PublicURL 对外访问URL可选
// 1) 若包含 "{objectKey}" 占位符,则会替换为 url.QueryEscape(objectKey)
// 示例: "http://localhost:8080/file?key={objectKey}" (配合 ProxyHandler 使用)
// 2) 若不包含占位符则作为URL前缀自动拼接 objectKey
// 示例: "http://localhost:8080/static/" => "http://localhost:8080/static/<objectKey>"
PublicURL string `json:"publicURL"`
} }
// DatabaseConfig 数据库配置 // DatabaseConfig 数据库配置
@@ -193,6 +217,23 @@ type EmailConfig struct {
// Timeout 连接超时时间(秒) // Timeout 连接超时时间(秒)
Timeout int `json:"timeout"` Timeout int `json:"timeout"`
// Async 是否异步发送(默认 true省略时启用
Async *bool `json:"async"`
// Workers 异步 worker 数量(默认 2
Workers int `json:"workers"`
// QueueSize 异步队列大小(默认 1000
QueueSize int `json:"queueSize"`
}
// IsAsync 是否启用异步(默认 true
func (c *EmailConfig) IsAsync() bool {
if c == nil || c.Async == nil {
return true
}
return *c.Async
} }
// SMSConfig 短信配置(阿里云短信) // SMSConfig 短信配置(阿里云短信)
@@ -217,11 +258,28 @@ type SMSConfig struct {
// Timeout 请求超时时间(秒) // Timeout 请求超时时间(秒)
Timeout int `json:"timeout"` Timeout int `json:"timeout"`
// Async 是否异步发送(默认 true省略时启用
Async *bool `json:"async"`
// Workers 异步 worker 数量(默认 2
Workers int `json:"workers"`
// QueueSize 异步队列大小(默认 1000
QueueSize int `json:"queueSize"`
}
// IsAsync 是否启用异步(默认 true
func (c *SMSConfig) IsAsync() bool {
if c == nil || c.Async == nil {
return true
}
return *c.Async
} }
// LoggerConfig 日志配置 // LoggerConfig 日志配置
type LoggerConfig struct { type LoggerConfig struct {
// Level 日志级别: debug, info, warn, error // Level 日志级别: debug, info, error
Level string `json:"level"` Level string `json:"level"`
// Output 输出方式: stdout, stderr, file, both // Output 输出方式: stdout, stderr, file, both
@@ -235,6 +293,124 @@ type LoggerConfig struct {
// DisableTimestamp 禁用时间戳 // DisableTimestamp 禁用时间戳
DisableTimestamp bool `json:"disableTimestamp"` DisableTimestamp bool `json:"disableTimestamp"`
// Async 是否使用异步模式(默认 true省略时启用
Async *bool `json:"async"`
// BufferSize 异步模式下的缓冲区大小默认1000
BufferSize int `json:"bufferSize"`
}
// IsAsync 是否启用异步(默认 true
func (c *LoggerConfig) IsAsync() bool {
if c == nil || c.Async == nil {
return true
}
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 是否启用限流
Enable bool `json:"enable"`
// Rate 每个时间窗口允许的请求数量
Rate int `json:"rate"`
// Period 时间窗口(秒)
Period int `json:"period"`
// ByIP 按IP限流
ByIP bool `json:"byIP"`
// ByUserID 按用户ID限流从X-User-ID header获取
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 从文件加载配置 // LoadFromFile 从文件加载配置
@@ -342,13 +518,19 @@ func (c *Config) setDefaults() {
// 邮件默认值 // 邮件默认值
if c.Email != nil { if c.Email != nil {
if c.Email.Port == 0 { if c.Email.Port == 0 {
c.Email.Port = 587 // 默认使用587端口TLS c.Email.Port = 587
} }
if c.Email.From == "" { if c.Email.From == "" {
c.Email.From = c.Email.Username c.Email.From = c.Email.Username
} }
if c.Email.Timeout == 0 { if c.Email.Timeout == 0 {
c.Email.Timeout = 30 c.Email.Timeout = 5
}
if c.Email.Workers == 0 {
c.Email.Workers = 2
}
if c.Email.QueueSize == 0 {
c.Email.QueueSize = 1000
} }
} }
@@ -358,7 +540,13 @@ func (c *Config) setDefaults() {
c.SMS.Region = "cn-hangzhou" c.SMS.Region = "cn-hangzhou"
} }
if c.SMS.Timeout == 0 { if c.SMS.Timeout == 0 {
c.SMS.Timeout = 10 c.SMS.Timeout = 5
}
if c.SMS.Workers == 0 {
c.SMS.Workers = 2
}
if c.SMS.QueueSize == 0 {
c.SMS.QueueSize = 1000
} }
} }
@@ -370,6 +558,62 @@ func (c *Config) setDefaults() {
if c.Logger.Output == "" { if c.Logger.Output == "" {
c.Logger.Output = "stdout" c.Logger.Output = "stdout"
} }
if c.Logger.BufferSize == 0 {
c.Logger.BufferSize = 1000
}
}
// i18n 默认值
if c.I18n != nil && c.I18n.DefaultLang == "" {
c.I18n.DefaultLang = "zh-CN"
}
// 限流默认值
if c.RateLimit != nil {
if c.RateLimit.Rate == 0 {
c.RateLimit.Rate = 100 // 默认每个窗口100个请求
}
if c.RateLimit.Period == 0 {
c.RateLimit.Period = 60 // 默认时间窗口60秒
}
if !c.RateLimit.ByIP && !c.RateLimit.ByUserID {
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
}
} }
} }
@@ -388,20 +632,11 @@ func (c *Config) GetRedis() *RedisConfig {
return c.Redis return c.Redis
} }
// GetCORS 获取CORS配置并转换为middleware.CORSConfig // GetCORS 获取CORS配置
func (c *Config) GetCORS() *middleware.CORSConfig { // 返回的是 config.CORSConfig需要转换为 middleware.CORSConfig
if c.CORS == nil { // 可以使用 middleware.CORSFromConfig() 函数
return middleware.DefaultCORSConfig() func (c *Config) GetCORS() *CORSConfig {
} return c.CORS
return &middleware.CORSConfig{
AllowedOrigins: c.CORS.AllowedOrigins,
AllowedMethods: c.CORS.AllowedMethods,
AllowedHeaders: c.CORS.AllowedHeaders,
ExposedHeaders: c.CORS.ExposedHeaders,
AllowCredentials: c.CORS.AllowCredentials,
MaxAge: c.CORS.MaxAge,
}
} }
// GetMinIO 获取MinIO配置 // GetMinIO 获取MinIO配置
@@ -409,6 +644,11 @@ func (c *Config) GetMinIO() *MinIOConfig {
return c.MinIO return c.MinIO
} }
// GetLocalStorage 获取本地存储配置
func (c *Config) GetLocalStorage() *LocalStorageConfig {
return c.Local
}
// GetEmail 获取邮件配置 // GetEmail 获取邮件配置
func (c *Config) GetEmail() *EmailConfig { func (c *Config) GetEmail() *EmailConfig {
return c.Email return c.Email
@@ -424,6 +664,21 @@ func (c *Config) GetLogger() *LoggerConfig {
return c.Logger return c.Logger
} }
// GetI18n 获取国际化配置
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 获取数据库连接字符串 // GetDatabaseDSN 获取数据库连接字符串
func (c *Config) GetDatabaseDSN() (string, error) { func (c *Config) GetDatabaseDSN() (string, error) {
if c.Database == nil { if c.Database == nil {
@@ -498,3 +753,8 @@ func (c *Config) GetRedisAddr() string {
// 构建地址 // 构建地址
return fmt.Sprintf("%s:%d", c.Redis.Host, c.Redis.Port) return fmt.Sprintf("%s:%d", c.Redis.Host, c.Redis.Port)
} }
// BoolPtr 返回 bool 指针(用于配置默认值)
func BoolPtr(v bool) *bool {
return &v
}

View File

@@ -50,6 +50,10 @@
"region": "us-east-1", "region": "us-east-1",
"domain": "http://localhost:9000" "domain": "http://localhost:9000"
}, },
"localStorage": {
"baseDir": "./uploads",
"publicURL": "http://localhost:8080/file?key={objectKey}"
},
"email": { "email": {
"host": "smtp.example.com", "host": "smtp.example.com",
"port": 587, "port": 587,
@@ -75,7 +79,57 @@
"output": "stdout", "output": "stdout",
"filePath": "", "filePath": "",
"prefix": "app", "prefix": "app",
"disableTimestamp": false "disableTimestamp": false,
"async": false,
"bufferSize": 1000
},
"rateLimit": {
"enable": true,
"rate": 100,
"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

@@ -1,112 +0,0 @@
# GoCommon 工具类库文档
## 目录
- [数据库迁移工具](./migration.md) - 数据库版本管理和迁移
- [日期转换工具](./datetime.md) - 日期时间处理和时区转换
- [HTTP Restful工具](./http.md) - HTTP请求响应处理和分页
- [中间件工具](./middleware.md) - CORS和时区处理中间件
- [配置工具](./config.md) - 外部配置文件加载和管理
- [存储工具](./storage.md) - 文件上传和查看OSS、MinIO
- [邮件工具](./email.md) - SMTP邮件发送
- [短信工具](./sms.md) - 阿里云短信发送
- [工厂工具](./factory.md) - 从配置直接创建已初始化客户端对象
- [日志工具](./logger.md) - 统一的日志记录功能
## 快速开始
### 安装
```bash
go get git.toowon.com/jimmy/go-common
```
### 使用示例
#### 数据库迁移
```go
import "git.toowon.com/jimmy/go-common/migration"
migrator := migration.NewMigrator(db)
migrator.AddMigration(migration.Migration{
Version: "20240101000001",
Description: "create_users_table",
Up: func(db *gorm.DB) error {
return db.Exec("CREATE TABLE users ...").Error
},
})
migrator.Up()
```
#### 日期转换
```go
import "git.toowon.com/jimmy/go-common/datetime"
datetime.SetDefaultTimeZone(datetime.AsiaShanghai)
now := datetime.Now()
str := datetime.FormatDateTime(now)
```
#### HTTP响应Handler黑盒模式
```go
import (
"net/http"
commonhttp "git.toowon.com/jimmy/go-common/http"
)
func GetUser(h *commonhttp.Handler) {
id := h.GetQueryInt64("id", 0)
h.Success(data)
}
http.HandleFunc("/user", commonhttp.HandleFunc(GetUser))
```
#### 中间件
```go
import (
"net/http"
"git.toowon.com/jimmy/go-common/middleware"
commonhttp "git.toowon.com/jimmy/go-common/http"
)
// CORS + 时区中间件
chain := middleware.NewChain(
middleware.CORS(),
middleware.Timezone,
)
handler := chain.ThenFunc(func(w http.ResponseWriter, r *http.Request) {
h := commonhttp.NewHandler(w, r)
// 在Handler中获取时区
timezone := h.GetTimezone()
h.Success(data)
})
```
#### 配置管理
```go
import "git.toowon.com/jimmy/go-common/config"
// 从文件加载配置
cfg, err := config.LoadFromFile("./config.json")
// 获取各种配置
dsn, _ := cfg.GetDatabaseDSN()
redisAddr := cfg.GetRedisAddr()
corsConfig := cfg.GetCORS()
```
## 版本
v1.0.0
## 许可证
MIT

View File

@@ -1,516 +0,0 @@
# 配置工具文档
## 概述
配置工具提供了从外部文件加载和管理应用配置的功能支持数据库、OSS、Redis、CORS、MinIO、邮件、短信等常用服务的配置。
## 功能特性
- 支持从外部JSON文件加载配置
- 支持数据库配置MySQL、PostgreSQL、SQLite
- 支持OSS对象存储配置阿里云、腾讯云、AWS、七牛云等
- 支持Redis配置
- 支持CORS配置与middleware包集成
- 支持MinIO配置
- 支持邮件配置SMTP
- 支持短信配置(阿里云短信)
- 自动设置默认值
- 自动生成数据库连接字符串DSN
- 自动生成Redis地址
## 配置文件格式
配置文件采用JSON格式支持以下配置项
```json
{
"database": {
"type": "mysql",
"host": "localhost",
"port": 3306,
"user": "root",
"password": "password",
"database": "testdb",
"charset": "utf8mb4",
"maxOpenConns": 100,
"maxIdleConns": 10,
"connMaxLifetime": 3600
},
"oss": {
"provider": "aliyun",
"endpoint": "oss-cn-hangzhou.aliyuncs.com",
"accessKeyId": "your-access-key-id",
"accessKeySecret": "your-access-key-secret",
"bucket": "your-bucket-name",
"region": "cn-hangzhou",
"useSSL": true,
"domain": "https://cdn.example.com"
},
"redis": {
"host": "localhost",
"port": 6379,
"password": "",
"database": 0,
"maxRetries": 3,
"poolSize": 10,
"minIdleConns": 5,
"dialTimeout": 5,
"readTimeout": 3,
"writeTimeout": 3
},
"cors": {
"allowedOrigins": ["*"],
"allowedMethods": ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
"allowedHeaders": ["Content-Type", "Authorization", "X-Requested-With", "X-Timezone"],
"exposedHeaders": [],
"allowCredentials": false,
"maxAge": 86400
},
"minio": {
"endpoint": "localhost:9000",
"accessKeyId": "minioadmin",
"secretAccessKey": "minioadmin",
"useSSL": false,
"bucket": "test-bucket",
"region": "us-east-1",
"domain": "http://localhost:9000"
},
"email": {
"host": "smtp.example.com",
"port": 587,
"username": "your-email@example.com",
"password": "your-email-password",
"from": "your-email@example.com",
"fromName": "Your App Name",
"useTLS": true,
"useSSL": false,
"timeout": 30
},
"sms": {
"accessKeyId": "your-aliyun-access-key-id",
"accessKeySecret": "your-aliyun-access-key-secret",
"region": "cn-hangzhou",
"signName": "Your Sign Name",
"templateCode": "SMS_123456789",
"endpoint": "",
"timeout": 10
}
}
```
## 使用方法
### 1. 加载配置文件
```go
import "git.toowon.com/jimmy/go-common/config"
// 从文件加载配置(支持绝对路径和相对路径)
config, err := config.LoadFromFile("/path/to/config.json")
if err != nil {
log.Fatal(err)
}
// 或者从字节数组加载
data := []byte(`{"database": {...}}`)
config, err := config.LoadFromBytes(data)
```
### 2. 获取数据库配置
```go
// 获取数据库配置对象
dbConfig := config.GetDatabase()
if dbConfig != nil {
fmt.Printf("Database: %s@%s:%d/%s\n",
dbConfig.User, dbConfig.Host, dbConfig.Port, dbConfig.Database)
}
// 获取数据库连接字符串DSN
dsn, err := config.GetDatabaseDSN()
if err != nil {
log.Fatal(err)
}
// MySQL: "root:password@tcp(localhost:3306)/testdb?charset=utf8mb4&parseTime=True&loc=UTC"
// PostgreSQL: "host=localhost port=5432 user=root password=password dbname=testdb timezone=UTC sslmode=disable"
// 注意数据库时间统一使用UTC时间
```
### 3. 获取OSS配置
```go
ossConfig := config.GetOSS()
if ossConfig != nil {
fmt.Printf("OSS Provider: %s\n", ossConfig.Provider)
fmt.Printf("Endpoint: %s\n", ossConfig.Endpoint)
fmt.Printf("Bucket: %s\n", ossConfig.Bucket)
}
```
### 4. 获取Redis配置
```go
redisConfig := config.GetRedis()
if redisConfig != nil {
fmt.Printf("Redis: %s:%d\n", redisConfig.Host, redisConfig.Port)
}
// 获取Redis地址格式: host:port
addr := config.GetRedisAddr()
// 输出: "localhost:6379"
```
### 5. 获取CORS配置
```go
// 获取CORS配置返回middleware.CORSConfig类型可直接用于中间件
corsConfig := config.GetCORS()
// 使用CORS中间件
import "git.toowon.com/jimmy/go-common/middleware"
chain := middleware.NewChain(
middleware.CORS(corsConfig),
)
```
### 6. 获取MinIO配置
```go
minioConfig := config.GetMinIO()
if minioConfig != nil {
fmt.Printf("MinIO Endpoint: %s\n", minioConfig.Endpoint)
fmt.Printf("Bucket: %s\n", minioConfig.Bucket)
}
```
## 配置项说明
### DatabaseConfig 数据库配置
| 字段 | 类型 | 说明 | 默认值 |
|------|------|------|--------|
| Type | string | 数据库类型: mysql, postgres, sqlite | - |
| Host | string | 数据库主机 | - |
| Port | int | 数据库端口 | - |
| User | string | 数据库用户名 | - |
| Password | string | 数据库密码 | - |
| Database | string | 数据库名称 | - |
| Charset | string | 字符集MySQL使用 | utf8mb4 |
| MaxOpenConns | int | 最大打开连接数 | 100 |
| MaxIdleConns | int | 最大空闲连接数 | 10 |
| ConnMaxLifetime | int | 连接最大生存时间(秒) | 3600 |
| DSN | string | 数据库连接字符串(如果设置,优先使用) | - |
### OSSConfig OSS配置
| 字段 | 类型 | 说明 |
|------|------|------|
| Provider | string | 提供商: aliyun, tencent, aws, qiniu |
| Endpoint | string | 端点地址 |
| AccessKeyID | string | 访问密钥ID |
| AccessKeySecret | string | 访问密钥 |
| Bucket | string | 存储桶名称 |
| Region | string | 区域 |
| UseSSL | bool | 是否使用SSL |
| Domain | string | 自定义域名CDN域名 |
### RedisConfig Redis配置
| 字段 | 类型 | 说明 | 默认值 |
|------|------|------|--------|
| Host | string | Redis主机 | - |
| Port | int | Redis端口 | 6379 |
| Password | string | Redis密码 | - |
| Database | int | Redis数据库编号 | 0 |
| MaxRetries | int | 最大重试次数 | 3 |
| PoolSize | int | 连接池大小 | 10 |
| MinIdleConns | int | 最小空闲连接数 | 5 |
| DialTimeout | int | 连接超时时间(秒) | 5 |
| ReadTimeout | int | 读取超时时间(秒) | 3 |
| WriteTimeout | int | 写入超时时间(秒) | 3 |
| Addr | string | Redis地址如果设置优先使用 | - |
### CORSConfig CORS配置
| 字段 | 类型 | 说明 | 默认值 |
|------|------|------|--------|
| AllowedOrigins | []string | 允许的源 | ["*"] |
| AllowedMethods | []string | 允许的HTTP方法 | ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"] |
| AllowedHeaders | []string | 允许的请求头 | ["Content-Type", "Authorization", "X-Requested-With", "X-Timezone"] |
| ExposedHeaders | []string | 暴露给客户端的响应头 | [] |
| AllowCredentials | bool | 是否允许发送凭证 | false |
| MaxAge | int | 预检请求的缓存时间(秒) | 86400 |
### MinIOConfig MinIO配置
| 字段 | 类型 | 说明 |
|------|------|------|
| Endpoint | string | MinIO端点地址 |
| AccessKeyID | string | 访问密钥ID |
| SecretAccessKey | string | 密钥 |
| UseSSL | bool | 是否使用SSL |
| Bucket | string | 存储桶名称 |
| Region | string | 区域 |
| Domain | string | 自定义域名 |
### EmailConfig 邮件配置
| 字段 | 类型 | 说明 | 默认值 |
|------|------|------|--------|
| Host | string | SMTP服务器地址 | - |
| Port | int | SMTP服务器端口 | 587 |
| Username | string | 发件人邮箱 | - |
| Password | string | 邮箱密码或授权码 | - |
| From | string | 发件人邮箱地址如果为空使用Username | Username |
| FromName | string | 发件人名称 | - |
| UseTLS | bool | 是否使用TLS | false |
| UseSSL | bool | 是否使用SSL | false |
| Timeout | int | 连接超时时间(秒) | 30 |
### SMSConfig 短信配置(阿里云短信)
| 字段 | 类型 | 说明 | 默认值 |
|------|------|------|--------|
| AccessKeyID | string | 阿里云AccessKey ID | - |
| AccessKeySecret | string | 阿里云AccessKey Secret | - |
| Region | string | 区域cn-hangzhou | cn-hangzhou |
| SignName | string | 短信签名 | - |
| TemplateCode | string | 短信模板代码 | - |
| Endpoint | string | 服务端点(可选,默认使用区域端点) | - |
| Timeout | int | 请求超时时间(秒) | 10 |
### LoggerConfig 日志配置
| 字段 | 类型 | 说明 | 默认值 |
|------|------|------|--------|
| Level | string | 日志级别: debug, info, warn, error | info |
| Output | string | 输出方式: stdout, stderr, file, both | stdout |
| FilePath | string | 日志文件路径当output为file或both时必需 | - |
| Prefix | string | 日志前缀 | - |
| DisableTimestamp | bool | 禁用时间戳 | false |
## 完整示例
### 示例1加载配置并使用
```go
package main
import (
"log"
"git.toowon.com/jimmy/go-common/config"
"git.toowon.com/jimmy/go-common/middleware"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
func main() {
// 加载配置
cfg, err := config.LoadFromFile("./config.json")
if err != nil {
log.Fatal(err)
}
// 使用数据库配置
dsn, err := cfg.GetDatabaseDSN()
if err != nil {
log.Fatal(err)
}
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
log.Fatal(err)
}
// 使用Redis配置
redisAddr := cfg.GetRedisAddr()
fmt.Printf("Redis Address: %s\n", redisAddr)
// 使用CORS配置
corsConfig := cfg.GetCORS()
chain := middleware.NewChain(
middleware.CORS(corsConfig),
)
// 使用OSS配置
ossConfig := cfg.GetOSS()
if ossConfig != nil {
fmt.Printf("OSS Provider: %s\n", ossConfig.Provider)
}
// 使用MinIO配置
minioConfig := cfg.GetMinIO()
if minioConfig != nil {
fmt.Printf("MinIO Endpoint: %s\n", minioConfig.Endpoint)
}
}
```
### 示例2部分配置
配置文件可以只包含需要的配置项:
```json
{
"database": {
"type": "mysql",
"host": "localhost",
"port": 3306,
"user": "root",
"password": "password",
"database": "testdb"
},
"redis": {
"host": "localhost",
"port": 6379
}
}
```
未配置的部分会返回nil需要在使用前检查
```go
cfg, _ := config.LoadFromFile("./config.json")
dbConfig := cfg.GetDatabase()
if dbConfig != nil {
// 使用数据库配置
}
ossConfig := cfg.GetOSS()
if ossConfig == nil {
// OSS未配置
}
```
### 示例3使用DSN字段
如果配置文件中直接提供了DSN会优先使用
```json
{
"database": {
"type": "mysql",
"dsn": "root:password@tcp(localhost:3306)/testdb?charset=utf8mb4&parseTime=True"
}
}
```
```go
dsn, err := cfg.GetDatabaseDSN()
// 直接返回配置中的DSN不会重新构建
```
## API 参考
### LoadFromFile(filePath string) (*Config, error)
从文件加载配置。
**参数:**
- `filePath`: 配置文件路径(支持绝对路径和相对路径)
**返回:** 配置对象和错误信息
### LoadFromBytes(data []byte) (*Config, error)
从字节数组加载配置。
**参数:**
- `data`: JSON格式的配置数据
**返回:** 配置对象和错误信息
### (c *Config) GetDatabase() *DatabaseConfig
获取数据库配置。
**返回:** 数据库配置对象可能为nil
### (c *Config) GetDatabaseDSN() (string, error)
获取数据库连接字符串。
**返回:** DSN字符串和错误信息
### (c *Config) GetOSS() *OSSConfig
获取OSS配置。
**返回:** OSS配置对象可能为nil
### (c *Config) GetRedis() *RedisConfig
获取Redis配置。
**返回:** Redis配置对象可能为nil
### (c *Config) GetRedisAddr() string
获取Redis地址格式: host:port
**返回:** Redis地址字符串
### (c *Config) GetCORS() *middleware.CORSConfig
获取CORS配置并转换为middleware.CORSConfig类型。
**返回:** CORS配置对象如果配置为nil返回默认配置
### (c *Config) GetMinIO() *MinIOConfig
获取MinIO配置。
**返回:** MinIO配置对象可能为nil
### (c *Config) GetEmail() *EmailConfig
获取邮件配置。
**返回:** 邮件配置对象可能为nil
### (c *Config) GetSMS() *SMSConfig
获取短信配置。
**返回:** 短信配置对象可能为nil
### (c *Config) GetLogger() *LoggerConfig
获取日志配置。
**返回:** 日志配置对象可能为nil
## 注意事项
1. **配置文件路径**
- 支持绝对路径和相对路径
- 相对路径基于当前工作目录
2. **默认值**
- 配置加载时会自动设置默认值
- 如果配置项为nil对应的Get方法会返回nil
3. **DSN优先级**
- 如果配置中设置了DSN字段会优先使用
- 否则会根据配置项自动构建DSN
4. **配置验证**
- 当前版本不进行配置验证,请确保配置正确
- 建议在生产环境中添加配置验证逻辑
5. **安全性**
- 配置文件可能包含敏感信息(密码、密钥等)
- 建议将配置文件放在安全的位置,不要提交到版本控制系统
- 可以使用环境变量或配置管理服务
6. **数据库时区**
- 数据库时间统一使用UTC时间存储
- DSN中会自动设置UTC时区MySQL: loc=UTC, PostgreSQL: timezone=UTC
- 时区转换应在应用层处理使用datetime工具包进行时区转换
## 配置文件示例
完整配置文件示例请参考 `config/example.json`

View File

@@ -1,461 +0,0 @@
# 日期转换工具文档
## 概述
日期转换工具提供了丰富的日期时间处理功能,支持时区设定、格式转换、时间计算等常用操作。
## 功能特性
- 支持时区设定和转换
- 支持多种时间格式的解析和格式化
- 提供常用时间格式常量
- 支持Unix时间戳转换
- 提供时间计算功能(添加天数、月数、年数等)
- 提供时间范围获取功能(开始/结束时间)
- 支持将任意时区时间转换为UTC时间用于数据库存储
## 使用方法
### 1. 设置默认时区
```go
import "git.toowon.com/jimmy/go-common/datetime"
// 设置默认时区为上海时区
err := datetime.SetDefaultTimeZone(datetime.AsiaShanghai)
if err != nil {
log.Fatal(err)
}
```
### 2. 获取当前时间
```go
// 使用默认时区
now := datetime.Now()
// 使用指定时区
now := datetime.Now(datetime.AsiaShanghai)
now := datetime.Now("America/New_York")
```
### 3. 解析时间字符串
```go
// 使用默认时区解析
t, err := datetime.Parse("2006-01-02 15:04:05", "2024-01-01 12:00:00")
if err != nil {
log.Fatal(err)
}
// 使用指定时区解析
t, err := datetime.Parse("2006-01-02 15:04:05", "2024-01-01 12:00:00", datetime.AsiaShanghai)
// 使用常用格式解析
t, err := datetime.ParseDateTime("2024-01-01 12:00:00")
t, err := datetime.ParseDate("2024-01-01")
```
### 4. 格式化时间
```go
t := time.Now()
// 使用默认时区格式化
str := datetime.Format(t, "2006-01-02 15:04:05")
// 使用指定时区格式化
str := datetime.Format(t, "2006-01-02 15:04:05", datetime.AsiaShanghai)
// 使用常用格式
str := datetime.FormatDateTime(t) // "2006-01-02 15:04:05"
str := datetime.FormatDate(t) // "2006-01-02"
str := datetime.FormatTime(t) // "15:04:05"
```
### 5. 时区转换
```go
t := time.Now()
// 转换到指定时区
t2, err := datetime.ToTimezone(t, datetime.AsiaShanghai)
if err != nil {
log.Fatal(err)
}
```
### 6. Unix时间戳转换
```go
t := time.Now()
// 转换为Unix时间戳
unix := datetime.ToUnix(t)
// 从Unix时间戳创建时间
t2 := datetime.FromUnix(unix)
// 转换为Unix毫秒时间戳
unixMilli := datetime.ToUnixMilli(t)
// 从Unix毫秒时间戳创建时间
t3 := datetime.FromUnixMilli(unixMilli)
```
### 7. 时间计算
```go
t := time.Now()
// 添加天数
t1 := datetime.AddDays(t, 7)
// 添加月数
t2 := datetime.AddMonths(t, 1)
// 添加年数
t3 := datetime.AddYears(t, 1)
```
### 8. 时间范围获取
```go
t := time.Now()
// 获取一天的开始时间00:00:00
start := datetime.StartOfDay(t)
// 获取一天的结束时间23:59:59.999999999
end := datetime.EndOfDay(t)
// 获取月份的开始时间
monthStart := datetime.StartOfMonth(t)
// 获取月份的结束时间
monthEnd := datetime.EndOfMonth(t)
// 获取年份的开始时间
yearStart := datetime.StartOfYear(t)
// 获取年份的结束时间
yearEnd := datetime.EndOfYear(t)
```
### 9. 时间差计算
```go
t1 := time.Now()
t2 := time.Now().Add(24 * time.Hour)
// 计算天数差
days := datetime.DiffDays(t1, t2)
// 计算小时差
hours := datetime.DiffHours(t1, t2)
// 计算分钟差
minutes := datetime.DiffMinutes(t1, t2)
// 计算秒数差
seconds := datetime.DiffSeconds(t1, t2)
```
### 10. 转换为UTC时间用于数据库存储
```go
// 将任意时区的时间转换为UTC
t := time.Now() // 当前时区的时间
utcTime := datetime.ToUTC(t)
// 从指定时区转换为UTC
t, _ := datetime.ParseDateTime("2024-01-01 12:00:00", datetime.AsiaShanghai)
utcTime, err := datetime.ToUTCFromTimezone(t, datetime.AsiaShanghai)
// 解析时间字符串并直接转换为UTC
utcTime, err := datetime.ParseDateTimeToUTC("2024-01-01 12:00:00", datetime.AsiaShanghai)
// 解析日期并转换为UTC当天的00:00:00 UTC
utcTime, err := datetime.ParseDateToUTC("2024-01-01", datetime.AsiaShanghai)
```
## API 参考
### 时区常量
```go
const (
UTC = "UTC"
AsiaShanghai = "Asia/Shanghai"
AmericaNewYork = "America/New_York"
EuropeLondon = "Europe/London"
AsiaTokyo = "Asia/Tokyo"
)
```
### 常用时间格式
```go
CommonLayouts.DateTime = "2006-01-02 15:04"
CommonLayouts.DateTimeSec = "2006-01-02 15:04:05"
CommonLayouts.Date = "2006-01-02"
CommonLayouts.Time = "15:04"
CommonLayouts.TimeSec = "15:04:05"
CommonLayouts.ISO8601 = "2006-01-02T15:04:05Z07:00"
CommonLayouts.RFC3339 = time.RFC3339
CommonLayouts.RFC3339Nano = time.RFC3339Nano
```
### 主要函数
#### SetDefaultTimeZone(timezone string) error
设置默认时区。
**参数:**
- `timezone`: 时区字符串,如 "Asia/Shanghai"
**返回:** 错误信息
#### Now(timezone ...string) time.Time
获取当前时间。
**参数:**
- `timezone`: 可选,时区字符串,不指定则使用默认时区
**返回:** 时间对象
#### Parse(layout, value string, timezone ...string) (time.Time, error)
解析时间字符串。
**参数:**
- `layout`: 时间格式,如 "2006-01-02 15:04:05"
- `value`: 时间字符串
- `timezone`: 可选,时区字符串
**返回:** 时间对象和错误信息
#### Format(t time.Time, layout string, timezone ...string) string
格式化时间。
**参数:**
- `t`: 时间对象
- `layout`: 时间格式
- `timezone`: 可选,时区字符串
**返回:** 格式化后的时间字符串
#### ToTimezone(t time.Time, timezone string) (time.Time, error)
转换时区。
**参数:**
- `t`: 时间对象
- `timezone`: 目标时区
**返回:** 转换后的时间对象和错误信息
#### ToUnix(t time.Time) int64
转换为Unix时间戳
#### FromUnix(sec int64, timezone ...string) time.Time
从Unix时间戳创建时间。
#### ToUnixMilli(t time.Time) int64
转换为Unix毫秒时间戳。
#### FromUnixMilli(msec int64, timezone ...string) time.Time
从Unix毫秒时间戳创建时间。
#### AddDays(t time.Time, days int) time.Time
添加天数。
#### AddMonths(t time.Time, months int) time.Time
添加月数。
#### AddYears(t time.Time, years int) time.Time
添加年数。
#### StartOfDay(t time.Time, timezone ...string) time.Time
获取一天的开始时间。
#### EndOfDay(t time.Time, timezone ...string) time.Time
获取一天的结束时间。
#### StartOfMonth(t time.Time, timezone ...string) time.Time
获取月份的开始时间。
#### EndOfMonth(t time.Time, timezone ...string) time.Time
获取月份的结束时间。
#### StartOfYear(t time.Time, timezone ...string) time.Time
获取年份的开始时间。
#### EndOfYear(t time.Time, timezone ...string) time.Time
获取年份的结束时间。
#### DiffDays(t1, t2 time.Time) int
计算两个时间之间的天数差。
#### DiffHours(t1, t2 time.Time) int64
计算两个时间之间的小时差。
#### DiffMinutes(t1, t2 time.Time) int64
计算两个时间之间的分钟差。
#### DiffSeconds(t1, t2 time.Time) int64
计算两个时间之间的秒数差。
### UTC转换函数
#### ToUTC(t time.Time) time.Time
将时间转换为UTC时间。
**参数:**
- `t`: 时间对象(可以是任意时区)
**返回:** UTC时间
#### ToUTCFromTimezone(t time.Time, timezone string) (time.Time, error)
从指定时区转换为UTC时间。
**参数:**
- `t`: 时间对象(会被视为指定时区的时间)
- `timezone`: 源时区
**返回:** UTC时间和错误信息
#### ParseToUTC(layout, value string, timezone ...string) (time.Time, error)
解析时间字符串并转换为UTC时间。
**参数:**
- `layout`: 时间格式,如 "2006-01-02 15:04:05"
- `value`: 时间字符串
- `timezone`: 源时区,如果为空则使用默认时区
**返回:** UTC时间和错误信息
#### ParseDateTimeToUTC(value string, timezone ...string) (time.Time, error)
解析日期时间字符串并转换为UTC时间便捷方法
**参数:**
- `value`: 时间字符串(格式: 2006-01-02 15:04:05
- `timezone`: 源时区,如果为空则使用默认时区
**返回:** UTC时间和错误信息
#### ParseDateToUTC(value string, timezone ...string) (time.Time, error)
解析日期字符串并转换为UTC时间便捷方法
**参数:**
- `value`: 日期字符串(格式: 2006-01-02
- `timezone`: 源时区,如果为空则使用默认时区
**返回:** UTC时间当天的00:00:00 UTC时间和错误信息
## 注意事项
1. **时区字符串**必须符合IANA时区数据库格式
2. **默认时区**默认时区为UTC建议在应用启动时设置合适的默认时区
3. **时间格式**时间格式字符串必须使用Go的特定时间2006-01-02 15:04:05
4. **时间范围函数**所有时间范围函数StartOfDay、EndOfDay等都会考虑时区
5. **数据库存储**
- 数据库时间统一使用UTC时间存储
- 使用`ToUTC``ParseToUTC`等方法将时间转换为UTC后存储到数据库
- 从数据库读取UTC时间后使用`ToTimezone`转换为用户时区显示
## 完整示例
### 示例1基本使用
```go
package main
import (
"fmt"
"log"
"time"
"git.toowon.com/jimmy/go-common/datetime"
)
func main() {
// 设置默认时区
datetime.SetDefaultTimeZone(datetime.AsiaShanghai)
// 获取当前时间
now := datetime.Now()
fmt.Printf("Current time: %s\n", datetime.FormatDateTime(now))
// 时区转换
t, _ := datetime.ParseDateTime("2024-01-01 12:00:00")
t2, _ := datetime.ToTimezone(t, datetime.AmericaNewYork)
fmt.Printf("Time in New York: %s\n", datetime.FormatDateTime(t2))
}
```
### 示例2UTC转换数据库存储场景
```go
package main
import (
"fmt"
"log"
"git.toowon.com/jimmy/go-common/datetime"
)
func main() {
// 从请求中获取时间(假设是上海时区)
requestTimeStr := "2024-01-01 12:00:00"
requestTimezone := datetime.AsiaShanghai
// 转换为UTC时间用于数据库存储
dbTime, err := datetime.ParseDateTimeToUTC(requestTimeStr, requestTimezone)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Request time (Shanghai): %s\n", requestTimeStr)
fmt.Printf("Database time (UTC): %s\n", datetime.FormatDateTime(dbTime, datetime.UTC))
// 从数据库读取UTC时间转换为用户时区显示
userTimezone := datetime.AsiaShanghai
displayTime, err := datetime.ToTimezone(dbTime, userTimezone)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Display time (Shanghai): %s\n", datetime.FormatDateTime(displayTime, userTimezone))
}
```
完整示例请参考:
- `examples/datetime_example.go` - 基本使用示例
- `examples/datetime_utc_example.go` - UTC转换示例

View File

@@ -1,332 +0,0 @@
# 邮件工具文档
## 概述
邮件工具提供了SMTP邮件发送功能使用Go标准库实现无需第三方依赖。
## 功能特性
- 支持SMTP邮件发送
- 支持TLS/SSL加密
- 支持发送原始邮件内容(完全由外部控制)
- 支持便捷方法发送简单邮件
- 使用配置工具统一管理配置
## 使用方法
### 1. 创建邮件发送器
```go
import (
"git.toowon.com/jimmy/go-common/config"
"git.toowon.com/jimmy/go-common/email"
)
// 从配置加载
cfg, err := config.LoadFromFile("./config.json")
if err != nil {
log.Fatal(err)
}
emailConfig := cfg.GetEmail()
if emailConfig == nil {
log.Fatal("email config is nil")
}
// 创建邮件发送器
mailer, err := email.NewEmail(emailConfig)
if err != nil {
log.Fatal(err)
}
```
### 2. 发送原始邮件内容(推荐,最灵活)
```go
// 外部构建完整的邮件内容MIME格式
emailBody := []byte(`From: sender@example.com
To: recipient@example.com
Subject: 邮件主题
Content-Type: text/html; charset=UTF-8
<html>
<body>
<h1>邮件内容</h1>
<p>这是由外部构建的完整邮件内容</p>
</body>
</html>
`)
// 发送邮件工具只负责SMTP发送不构建内容
err := mailer.SendRaw(
[]string{"recipient@example.com"}, // 收件人列表
emailBody, // 完整的邮件内容
)
if err != nil {
log.Fatal(err)
}
```
### 3. 发送简单邮件(便捷方法)
```go
// 发送纯文本邮件(内部会构建邮件内容)
err := mailer.SendSimple(
[]string{"recipient@example.com"},
"邮件主题",
"邮件正文内容",
)
if err != nil {
log.Fatal(err)
}
```
### 4. 发送HTML邮件便捷方法
```go
// 发送HTML邮件内部会构建邮件内容
htmlBody := `
<html>
<body>
<h1>欢迎</h1>
<p>这是一封HTML邮件</p>
</body>
</html>
`
err := mailer.SendHTML(
[]string{"recipient@example.com"},
"邮件主题",
htmlBody,
)
if err != nil {
log.Fatal(err)
}
```
### 5. 使用Message结构发送便捷方法
```go
import "git.toowon.com/jimmy/go-common/email"
msg := &email.Message{
To: []string{"to@example.com"},
Cc: []string{"cc@example.com"},
Bcc: []string{"bcc@example.com"},
Subject: "邮件主题",
Body: "纯文本正文",
HTMLBody: "<html><body><h1>HTML正文</h1></body></html>",
}
err := mailer.Send(msg)
if err != nil {
log.Fatal(err)
}
```
## API 参考
### NewEmail(cfg *config.EmailConfig) (*Email, error)
创建邮件发送器。
**参数:**
- `cfg`: 邮件配置对象
**返回:** 邮件发送器实例和错误信息
### (e *Email) SendRaw(recipients []string, body []byte) error
发送原始邮件内容(推荐使用,最灵活)。
**参数:**
- `recipients`: 收件人列表To、Cc、Bcc的合并列表
- `body`: 完整的邮件内容MIME格式由外部构建
**返回:** 错误信息
**说明:** 此方法允许外部完全控制邮件内容工具只负责SMTP发送。
### (e *Email) Send(msg *Message) error
发送邮件使用Message结构内部会构建邮件内容
**参数:**
- `msg`: 邮件消息对象
**返回:** 错误信息
**说明:** 如果需要完全控制邮件内容请使用SendRaw方法。
### (e *Email) SendSimple(to []string, subject, body string) error
发送简单邮件(便捷方法)。
**参数:**
- `to`: 收件人列表
- `subject`: 主题
- `body`: 正文
### (e *Email) SendHTML(to []string, subject, htmlBody string) error
发送HTML邮件便捷方法
**参数:**
- `to`: 收件人列表
- `subject`: 主题
- `htmlBody`: HTML正文
### Message 结构体
```go
type Message struct {
To []string // 收件人列表
Cc []string // 抄送列表(可选)
Bcc []string // 密送列表(可选)
Subject string // 主题
Body string // 正文(纯文本)
HTMLBody string // HTML正文可选
Attachments []Attachment // 附件列表(可选)
}
```
### Attachment 结构体
```go
type Attachment struct {
Filename string // 文件名
Content []byte // 文件内容
ContentType string // 文件类型
}
```
## 配置说明
邮件配置通过 `config.EmailConfig` 提供:
| 字段 | 类型 | 说明 | 默认值 |
|------|------|------|--------|
| Host | string | SMTP服务器地址 | - |
| Port | int | SMTP服务器端口 | 587 |
| Username | string | 发件人邮箱 | - |
| Password | string | 邮箱密码或授权码 | - |
| From | string | 发件人邮箱地址 | Username |
| FromName | string | 发件人名称 | - |
| UseTLS | bool | 是否使用TLS | false |
| UseSSL | bool | 是否使用SSL | false |
| Timeout | int | 连接超时时间(秒) | 30 |
## 常见SMTP服务器配置
### Gmail
```json
{
"host": "smtp.gmail.com",
"port": 587,
"useTLS": true,
"useSSL": false
}
```
### QQ邮箱
```json
{
"host": "smtp.qq.com",
"port": 587,
"useTLS": true,
"useSSL": false
}
```
### 163邮箱
```json
{
"host": "smtp.163.com",
"port": 25,
"useTLS": false,
"useSSL": false
}
```
### 企业邮箱SSL
```json
{
"host": "smtp.exmail.qq.com",
"port": 465,
"useTLS": false,
"useSSL": true
}
```
## 注意事项
1. **推荐使用SendRaw方法**
- `SendRaw`方法允许外部完全控制邮件内容
- 可以构建任意格式的MIME邮件包括复杂附件、多部分内容等
- 工具只负责SMTP发送不构建内容
2. **邮件内容构建**
- 使用`SendRaw`需要外部构建完整的MIME格式邮件内容
- 可以参考RFC 5322标准构建邮件内容
- 便捷方法(`Send``SendSimple``SendHTML`)内部会构建简单格式的邮件内容
3. **密码/授权码**
- 很多邮箱服务商需要使用授权码而不是登录密码
- Gmail、QQ邮箱等需要开启SMTP服务并获取授权码
4. **端口选择**
- 587端口通常使用TLSSTARTTLS
- 465端口通常使用SSL
- 25端口通常不使用加密不推荐
5. **TLS vs SSL**
- UseTLS=true使用STARTTLS推荐端口587
- UseSSL=true使用SSL端口465
6. **错误处理**
- 所有操作都应该进行错误处理
- 建议记录详细的错误日志
## 完整示例
```go
package main
import (
"log"
"git.toowon.com/jimmy/go-common/config"
"git.toowon.com/jimmy/go-common/email"
)
func main() {
// 加载配置
cfg, err := config.LoadFromFile("./config.json")
if err != nil {
log.Fatal(err)
}
// 创建邮件发送器
mailer, err := email.NewEmail(cfg.GetEmail())
if err != nil {
log.Fatal(err)
}
// 发送邮件
err = mailer.SendSimple(
[]string{"recipient@example.com"},
"测试邮件",
"这是一封测试邮件",
)
if err != nil {
log.Fatal(err)
}
log.Println("邮件发送成功")
}
```
## 示例
完整示例请参考 `examples/email_example.go`

View File

@@ -1,481 +0,0 @@
# 工厂工具文档
## 概述
工厂工具提供了从配置直接创建已初始化客户端对象的功能,并提供了黑盒模式的便捷方法,让调用方无需关心底层实现细节,大大降低业务复杂度。
## 功能特性
- **黑盒模式**:提供直接调用的方法,无需获取客户端对象
- **延迟初始化**:所有客户端在首次使用时才创建
- **自动选择**存储类型OSS/MinIO根据配置自动选择
- **统一接口**:所有操作通过工厂方法调用
- **向后兼容**:保留 `GetXXX()` 方法,需要时可获取对象
## 使用方法
### 1. 创建工厂(推荐)
```go
import "git.toowon.com/jimmy/go-common/factory"
// 方式1直接从配置文件创建最推荐
fac, err := factory.NewFactoryFromFile("./config.json")
if err != nil {
log.Fatal(err)
}
// 方式2从配置对象创建
cfg, _ := config.LoadFromFile("./config.json")
fac := factory.NewFactory(cfg)
```
### 2. 日志记录(黑盒模式,推荐)
```go
// 简单日志
fac.LogDebug("调试信息: %s", "test")
fac.LogInfo("用户登录成功")
fac.LogWarn("警告信息")
fac.LogError("错误信息: %v", err)
// 带字段的日志
fac.LogInfof(map[string]interface{}{
"user_id": 123,
"ip": "192.168.1.1",
}, "用户登录成功")
fac.LogErrorf(map[string]interface{}{
"error_code": 1001,
}, "登录失败: %v", err)
```
### 3. 邮件发送(黑盒模式,推荐)
```go
// 简单邮件
err := fac.SendEmail(
[]string{"user@example.com"},
"验证码",
"您的验证码是123456",
)
// HTML邮件
err := fac.SendEmail(
[]string{"user@example.com"},
"验证码",
"纯文本内容",
"<h1>HTML内容</h1>",
)
```
### 4. 短信发送(黑盒模式,推荐)
```go
// 使用配置中的模板代码
resp, err := fac.SendSMS(
[]string{"13800138000"},
map[string]string{"code": "123456"},
)
// 指定模板代码
resp, err := fac.SendSMS(
[]string{"13800138000"},
map[string]string{"code": "123456"},
"SMS_123456789", // 模板代码
)
```
### 5. 文件上传和查看(黑盒模式,推荐)
```go
import (
"context"
"os"
)
ctx := context.Background()
// 上传文件自动选择OSS或MinIO
file, _ := os.Open("test.jpg")
defer file.Close()
url, err := fac.UploadFile(ctx, "images/test.jpg", file, "image/jpeg")
if err != nil {
log.Fatal(err)
}
fmt.Println("文件URL:", url)
// 获取文件URL永久有效
url, _ := fac.GetFileURL("images/test.jpg", 0)
// 获取临时访问URL1小时后过期
url, _ := fac.GetFileURL("images/test.jpg", 3600)
```
### 6. Redis操作黑盒模式推荐
```go
import "context"
ctx := context.Background()
// 设置值(不过期)
err := fac.RedisSet(ctx, "user:123", "value")
// 设置值(带过期时间)
err := fac.RedisSet(ctx, "user:123", "value", time.Hour)
// 获取值
value, err := fac.RedisGet(ctx, "user:123")
// 删除键
err := fac.RedisDelete(ctx, "user:123", "user:456")
// 检查键是否存在
exists, err := fac.RedisExists(ctx, "user:123")
```
### 7. 数据库操作(黑盒模式)
```go
// 获取数据库对象(已初始化,黑盒模式)
db, err := fac.GetDatabase()
if err != nil {
log.Fatal(err)
}
// 直接使用GORM无需自己实现创建逻辑
var users []User
db.Find(&users)
db.Create(&user)
```
### 8. Redis操作获取客户端对象
```go
import (
"context"
"github.com/redis/go-redis/v9"
)
ctx := context.Background()
// 获取Redis客户端对象已初始化黑盒模式
redisClient, err := fac.GetRedisClient()
if err != nil {
log.Fatal(err)
}
// 直接使用Redis客户端无需自己实现创建逻辑
val, err := redisClient.Get(ctx, "key").Result()
if err != nil && err != redis.Nil {
log.Printf("Redis error: %v", err)
} else if err == redis.Nil {
fmt.Println("Key not found")
} else {
fmt.Printf("Value: %s\n", val)
}
// 使用高级功能如Hash操作
redisClient.HSet(ctx, "user:123", "name", "John")
name, _ := redisClient.HGet(ctx, "user:123", "name").Result()
```
## 完整示例
```go
package main
import (
"context"
"log"
"os"
"time"
"git.toowon.com/jimmy/go-common/factory"
)
func main() {
// 创建工厂
fac, err := factory.NewFactoryFromFile("./config.json")
if err != nil {
log.Fatal(err)
}
ctx := context.Background()
// 日志记录(黑盒模式)
fac.LogInfo("应用启动")
fac.LogInfof(map[string]interface{}{
"version": "1.0.0",
}, "应用启动成功")
// 邮件发送(黑盒模式)
err = fac.SendEmail(
[]string{"user@example.com"},
"欢迎",
"欢迎使用我们的服务",
)
if err != nil {
fac.LogError("发送邮件失败: %v", err)
}
// 短信发送(黑盒模式)
resp, err := fac.SendSMS(
[]string{"13800138000"},
map[string]string{"code": "123456"},
)
if err != nil {
fac.LogError("发送短信失败: %v", err)
} else {
fac.LogInfo("短信发送成功: %s", resp.RequestID)
}
// 文件上传黑盒模式自动选择OSS或MinIO
file, _ := os.Open("test.jpg")
defer file.Close()
url, err := fac.UploadFile(ctx, "images/test.jpg", file, "image/jpeg")
if err != nil {
fac.LogError("上传文件失败: %v", err)
} else {
fac.LogInfo("文件上传成功: %s", url)
}
// Redis操作黑盒模式
err = fac.RedisSet(ctx, "user:123", "value", time.Hour)
if err != nil {
fac.LogError("Redis设置失败: %v", err)
}
value, err := fac.RedisGet(ctx, "user:123")
if err != nil {
fac.LogError("Redis获取失败: %v", err)
} else {
fac.LogInfo("Redis值: %s", value)
}
// 数据库操作
db, err := fac.GetDatabase()
if err != nil {
fac.LogError("数据库连接失败: %v", err)
} else {
var count int64
db.Table("users").Count(&count)
fac.LogInfo("用户数量: %d", count)
}
}
```
## API 参考
### 工厂创建
#### NewFactory(cfg *config.Config) *Factory
创建工厂实例。
**参数:**
- `cfg`: 配置对象
**返回:** 工厂实例
#### NewFactoryFromFile(filePath string) (*Factory, error)
从配置文件直接创建工厂实例(便捷方法)。
**参数:**
- `filePath`: 配置文件路径
**返回:** 工厂实例和错误信息
**说明:** 这是推荐的使用方式,一步完成配置加载和工厂创建。
### 日志方法(黑盒模式)
#### LogDebug(message string, args ...interface{})
记录调试日志。
#### LogDebugf(fields map[string]interface{}, message string, args ...interface{})
记录调试日志(带字段)。
#### LogInfo(message string, args ...interface{})
记录信息日志。
#### LogInfof(fields map[string]interface{}, message string, args ...interface{})
记录信息日志(带字段)。
#### LogWarn(message string, args ...interface{})
记录警告日志。
#### LogWarnf(fields map[string]interface{}, message string, args ...interface{})
记录警告日志(带字段)。
#### LogError(message string, args ...interface{})
记录错误日志。
#### LogErrorf(fields map[string]interface{}, message string, args ...interface{})
记录错误日志(带字段)。
### 邮件方法(黑盒模式)
#### SendEmail(to []string, subject, body string, htmlBody ...string) error
发送邮件。
**参数:**
- `to`: 收件人列表
- `subject`: 邮件主题
- `body`: 邮件正文(纯文本)
- `htmlBody`: HTML正文可选如果设置了会优先使用
### 短信方法(黑盒模式)
#### SendSMS(phoneNumbers []string, templateParam interface{}, templateCode ...string) (*sms.SendResponse, error)
发送短信。
**参数:**
- `phoneNumbers`: 手机号列表
- `templateParam`: 模板参数map或JSON字符串
- `templateCode`: 模板代码(可选,如果为空使用配置中的模板代码)
### 存储方法(黑盒模式)
#### UploadFile(ctx context.Context, objectKey string, reader io.Reader, contentType ...string) (string, error)
上传文件。
**参数:**
- `ctx`: 上下文
- `objectKey`: 对象键(文件路径)
- `reader`: 文件内容
- `contentType`: 文件类型(可选)
**返回:** 文件访问URL和错误信息
**说明:** 自动根据配置选择OSS或MinIO优先级MinIO > OSS
#### GetFileURL(objectKey string, expires int64) (string, error)
获取文件访问URL。
**参数:**
- `objectKey`: 对象键
- `expires`: 过期时间0表示永久有效
**返回:** 文件访问URL和错误信息
### Redis方法黑盒模式
#### RedisGet(ctx context.Context, key string) (string, error)
获取Redis值。
**参数:**
- `ctx`: 上下文
- `key`: Redis键
**返回:** 值和错误信息key不存在时返回空字符串
#### RedisSet(ctx context.Context, key string, value interface{}, expiration ...time.Duration) error
设置Redis值。
**参数:**
- `ctx`: 上下文
- `key`: Redis键
- `value`: Redis值
- `expiration`: 过期时间可选0表示不过期
#### RedisDelete(ctx context.Context, keys ...string) error
删除Redis键。
**参数:**
- `ctx`: 上下文
- `keys`: Redis键列表
#### RedisExists(ctx context.Context, key string) (bool, error)
检查Redis键是否存在。
**参数:**
- `ctx`: 上下文
- `key`: Redis键
**返回:** 是否存在和错误信息
### 数据库方法
#### GetDatabase() (*gorm.DB, error)
获取数据库连接对象(已初始化)。
**返回:** 已初始化的GORM数据库对象和错误信息
**说明:**
- 支持MySQL、PostgreSQL、SQLite
- 自动配置连接池参数
- 数据库时间统一使用UTC时区
- 延迟初始化,首次调用时创建连接
- 黑盒模式只需传递config对象无需自己实现创建逻辑
### Redis方法
#### GetRedisClient() (*redis.Client, error)
获取Redis客户端对象已初始化
**返回:** 已初始化的Redis客户端对象和错误信息
**说明:**
- 自动处理所有配置检查和连接测试
- 自动设置默认值(连接池大小、超时时间等)
- 连接失败时会自动关闭客户端并返回错误
- 返回的客户端已通过Ping测试可直接使用
- 黑盒模式只需传递config对象无需自己实现创建逻辑
- 推荐使用 `RedisGet``RedisSet``RedisDelete` 等方法直接操作Redis
- 如果需要使用Redis的高级功能如Hash、List、Set等可以使用此方法获取客户端对象
### 配置方法
#### GetConfig() *config.Config
获取配置对象。
**返回:** 配置对象
## 设计优势
### 优势总结
1. **降低复杂度**:调用方无需关心客户端对象的创建和管理
2. **延迟初始化**:所有客户端在首次使用时才创建,提高性能
3. **自动选择**:存储类型根据配置自动选择,无需手动指定
4. **统一接口**:所有操作通过工厂方法调用,接口统一
5. **容错处理**:日志初始化失败时自动回退到标准输出
6. **代码简洁**:只提供黑盒模式方法,保持代码简洁清晰
## 注意事项
1. **配置检查**工厂方法会自动检查配置是否存在如果配置为nil会返回错误
2. **错误处理**:所有方法都可能返回错误,需要正确处理
3. **延迟初始化**:所有客户端在首次使用时才创建,首次调用可能稍慢
4. **存储选择**存储类型根据配置自动选择优先级MinIO > OSS
5. **数据库对象**数据库保持返回GORM对象因为GORM已经提供了很好的抽象
6. **黑盒模式**:所有功能都通过工厂方法直接调用,无需获取底层客户端对象
## 示例
完整示例请参考 `examples/factory_example.go`

View File

@@ -1,584 +0,0 @@
# HTTP Restful工具文档
## 概述
HTTP Restful工具提供了标准化的HTTP请求和响应处理功能采用Handler黑盒模式封装了`ResponseWriter``Request`提供简洁的API无需每次都传递这两个参数。
## 功能特性
- **黑盒模式**:封装`ResponseWriter``Request`提供简洁的API
- **标准化的响应结构**`{code, message, timestamp, data}`
- **分离HTTP状态码和业务状态码**
- **支持分页响应**
- **提供便捷的请求参数解析方法**
- **支持JSON请求体解析**
## 响应结构
### 标准响应结构
```json
{
"code": 0,
"message": "success",
"timestamp": 1704067200,
"data": {}
}
```
### 分页响应结构
```json
{
"code": 0,
"message": "success",
"timestamp": 1704067200,
"data": {
"list": [],
"total": 100,
"page": 1,
"pageSize": 10
}
}
```
## 使用方法
### 1. 创建Handler
```go
import (
"net/http"
commonhttp "git.toowon.com/jimmy/go-common/http"
)
// 方式1使用HandleFunc包装器推荐最简洁
func GetUser(h *commonhttp.Handler) {
id := h.GetQueryInt64("id", 0)
h.Success(data)
}
http.HandleFunc("/user", commonhttp.HandleFunc(GetUser))
// 方式2手动创建Handler需要更多控制时
http.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) {
h := commonhttp.NewHandler(w, r)
GetUser(h)
})
```
### 2. 成功响应
```go
func handler(h *commonhttp.Handler) {
// 简单成功响应data为nil
h.Success(nil)
// 带数据的成功响应
data := map[string]interface{}{
"id": 1,
"name": "test",
}
h.Success(data)
// 带消息的成功响应
h.SuccessWithMessage("操作成功", data)
}
```
### 3. 错误响应
```go
func handler(h *commonhttp.Handler) {
// 业务错误HTTP 200业务code非0
h.Error(1001, "用户不存在")
// 系统错误HTTP 500
h.SystemError("服务器内部错误")
// 其他HTTP错误状态码使用WriteJSON直接指定
// 请求错误HTTP 400
h.WriteJSON(http.StatusBadRequest, 400, "请求参数错误", nil)
// 未授权HTTP 401
h.WriteJSON(http.StatusUnauthorized, 401, "未登录", nil)
// 禁止访问HTTP 403
h.WriteJSON(http.StatusForbidden, 403, "无权限访问", nil)
// 未找到HTTP 404
h.WriteJSON(http.StatusNotFound, 404, "资源不存在", nil)
}
```
### 4. 分页响应
```go
func handler(h *commonhttp.Handler) {
// 获取分页参数
pagination := h.ParsePaginationRequest()
page := pagination.GetPage()
pageSize := pagination.GetSize()
// 查询数据(示例)
list, total := getDataList(page, pageSize)
// 返回分页响应(使用默认消息)
h.SuccessPage(list, total, page, pageSize)
// 返回分页响应(自定义消息)
h.SuccessPage(list, total, page, pageSize, "查询成功")
}
```
### 5. 解析请求
#### 解析JSON请求体
```go
func handler(h *commonhttp.Handler) {
type CreateUserRequest struct {
Name string `json:"name"`
Email string `json:"email"`
}
var req CreateUserRequest
if err := h.ParseJSON(&req); err != nil {
h.WriteJSON(http.StatusBadRequest, 400, "请求参数解析失败", nil)
return
}
// 使用req...
}
```
#### 获取查询参数
```go
func handler(h *commonhttp.Handler) {
// 获取字符串参数
name := h.GetQuery("name", "")
email := h.GetQuery("email", "default@example.com")
// 获取整数参数
id := h.GetQueryInt("id", 0)
age := h.GetQueryInt("age", 18)
// 获取int64参数
userId := h.GetQueryInt64("userId", 0)
// 获取布尔参数
isActive := h.GetQueryBool("isActive", false)
// 获取浮点数参数
price := h.GetQueryFloat64("price", 0.0)
}
```
#### 获取表单参数
```go
func handler(h *commonhttp.Handler) {
// 获取表单字符串
name := h.GetFormValue("name", "")
// 获取表单整数
age := h.GetFormInt("age", 0)
// 获取表单int64
userId := h.GetFormInt64("userId", 0)
// 获取表单布尔值
isActive := h.GetFormBool("isActive", false)
}
```
#### 获取请求头
```go
func handler(h *commonhttp.Handler) {
token := h.GetHeader("Authorization", "")
contentType := h.GetHeader("Content-Type", "application/json")
}
```
#### 获取分页参数
**方式1使用 PaginationRequest 结构(推荐)**
```go
func handler(h *commonhttp.Handler) {
// 定义请求结构(包含分页字段)
type ListUserRequest struct {
Keyword string `json:"keyword"`
commonhttp.PaginationRequest // 嵌入分页请求结构
}
// 从JSON请求体解析分页字段会自动解析
var req ListUserRequest
if err := h.ParseJSON(&req); err != nil {
h.WriteJSON(http.StatusBadRequest, 400, "请求参数解析失败", nil)
return
}
// 使用分页方法
page := req.GetPage() // 获取页码默认1
size := req.GetSize() // 获取每页数量默认20最大100优先使用page_size
offset := req.GetOffset() // 计算偏移量
}
// 或者从查询参数/form解析分页
func handler(h *commonhttp.Handler) {
pagination := h.ParsePaginationRequest()
page := pagination.GetPage()
size := pagination.GetSize()
offset := pagination.GetOffset()
}
```
#### 获取时区
```go
func handler(h *commonhttp.Handler) {
// 从请求的context中获取时区
// 如果使用了middleware.Timezone中间件可以从context中获取时区信息
// 如果未设置,返回默认时区 AsiaShanghai
timezone := h.GetTimezone()
}
```
### 6. 访问原始对象
如果需要访问原始的`ResponseWriter``Request`
```go
func handler(h *commonhttp.Handler) {
// 获取原始ResponseWriter
w := h.ResponseWriter()
// 获取原始Request
r := h.Request()
// 获取Context
ctx := h.Context()
}
```
## 完整示例
```go
package main
import (
"log"
"net/http"
commonhttp "git.toowon.com/jimmy/go-common/http"
)
// 用户结构
type User struct {
ID int64 `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
// 用户列表接口
func GetUserList(h *commonhttp.Handler) {
// 获取分页参数
pagination := h.ParsePaginationRequest()
page := pagination.GetPage()
pageSize := pagination.GetSize()
// 获取查询参数
keyword := h.GetQuery("keyword", "")
// 查询数据
users, total := queryUsers(keyword, page, pageSize)
// 返回分页响应
h.SuccessPage(users, total, page, pageSize)
}
// 创建用户接口
func CreateUser(h *commonhttp.Handler) {
// 解析请求体
var req struct {
Name string `json:"name"`
Email string `json:"email"`
}
if err := h.ParseJSON(&req); err != nil {
h.WriteJSON(http.StatusBadRequest, 400, "请求参数解析失败", nil)
return
}
// 参数验证
if req.Name == "" {
h.Error(1001, "用户名不能为空")
return
}
// 创建用户
user, err := createUser(req.Name, req.Email)
if err != nil {
h.SystemError("创建用户失败")
return
}
// 返回成功响应
h.SuccessWithMessage("创建成功", user)
}
// 获取用户详情接口
func GetUser(h *commonhttp.Handler) {
// 获取查询参数
id := h.GetQueryInt64("id", 0)
if id == 0 {
h.WriteJSON(http.StatusBadRequest, 400, "用户ID不能为空", nil)
return
}
// 查询用户
user, err := getUserByID(id)
if err != nil {
h.SystemError("查询用户失败")
return
}
if user == nil {
h.Error(1002, "用户不存在")
return
}
h.Success(user)
}
func main() {
// 使用HandleFunc包装器推荐
http.HandleFunc("/users", commonhttp.HandleFunc(func(h *commonhttp.Handler) {
switch h.Request().Method {
case http.MethodGet:
GetUserList(h)
case http.MethodPost:
CreateUser(h)
default:
h.WriteJSON(http.StatusMethodNotAllowed, 405, "方法不支持", nil)
}
}))
http.HandleFunc("/user", commonhttp.HandleFunc(GetUser))
log.Println("Server started on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
```
## API 参考
### Handler结构
Handler封装了`ResponseWriter``Request`提供更简洁的API。
```go
type Handler struct {
w http.ResponseWriter
r *http.Request
}
```
### 创建Handler
#### NewHandler(w http.ResponseWriter, r *http.Request) *Handler
创建Handler实例。
#### HandleFunc(fn func(*Handler)) http.HandlerFunc
将Handler函数转换为标准的http.HandlerFunc便捷包装器
**示例:**
```go
http.HandleFunc("/users", commonhttp.HandleFunc(func(h *commonhttp.Handler) {
h.Success(data)
}))
```
### Handler响应方法
#### (h *Handler) Success(data interface{})
成功响应HTTP 200业务code 0。
#### (h *Handler) SuccessWithMessage(message string, data interface{})
带消息的成功响应。
#### (h *Handler) Error(code int, message string)
业务错误响应HTTP 200业务code非0。
#### (h *Handler) SystemError(message string)
系统错误响应HTTP 500业务code 500。
#### (h *Handler) WriteJSON(httpCode, code int, message string, data interface{})
写入JSON响应自定义
**参数:**
- `httpCode`: HTTP状态码
- `code`: 业务状态码
- `message`: 响应消息
- `data`: 响应数据
#### (h *Handler) SuccessPage(list interface{}, total int64, page, pageSize int, message ...string)
分页成功响应。
**参数:**
- `list`: 数据列表
- `total`: 总记录数
- `page`: 当前页码
- `pageSize`: 每页大小
- `message`: 响应消息(可选,如果为空则使用默认消息 "success"
### Handler请求解析方法
#### (h *Handler) ParseJSON(v interface{}) error
解析JSON请求体。
#### (h *Handler) GetQuery(key, defaultValue string) string
获取查询参数(字符串)。
#### (h *Handler) GetQueryInt(key string, defaultValue int) int
获取查询参数(整数)。
#### (h *Handler) GetQueryInt64(key string, defaultValue int64) int64
获取查询参数int64
#### (h *Handler) GetQueryBool(key string, defaultValue bool) bool
获取查询参数(布尔值)。
#### (h *Handler) GetQueryFloat64(key string, defaultValue float64) float64
获取查询参数(浮点数)。
#### (h *Handler) GetFormValue(key, defaultValue string) string
获取表单值(字符串)。
#### (h *Handler) GetFormInt(key string, defaultValue int) int
获取表单值(整数)。
#### (h *Handler) GetFormInt64(key string, defaultValue int64) int64
获取表单值int64
#### (h *Handler) GetFormBool(key string, defaultValue bool) bool
获取表单值(布尔值)。
#### (h *Handler) GetHeader(key, defaultValue string) string
获取请求头。
#### (h *Handler) ParsePaginationRequest() *PaginationRequest
从请求中解析分页参数。
**说明:**
- 支持从查询参数和form表单中解析
- 优先级:查询参数 > form表单
- 如果请求体是JSON格式且包含分页字段建议先使用`ParseJSON`解析完整请求体到包含`PaginationRequest`的结构体中
#### (h *Handler) GetTimezone() string
从请求的context中获取时区。
**说明:**
- 如果使用了middleware.Timezone中间件可以从context中获取时区信息
- 如果未设置,返回默认时区 AsiaShanghai
### Handler访问原始对象
#### (h *Handler) ResponseWriter() http.ResponseWriter
获取原始的ResponseWriter需要时使用
#### (h *Handler) Request() *http.Request
获取原始的Request需要时使用
#### (h *Handler) Context() context.Context
获取请求的Context。
### 分页请求结构
#### PaginationRequest
分页请求结构支持从JSON和form中解析分页参数。
**字段:**
- `Page`: 页码默认1
- `Size`: 每页数量(兼容旧版本)
- `PageSize`: 每页数量推荐使用优先于Size
**方法:**
- `GetPage() int`: 获取页码如果未设置则返回默认值1
- `GetSize() int`: 获取每页数量优先使用PageSize如果未设置则使用Size默认20最大100
- `GetOffset() int`: 计算数据库查询的偏移量
#### ParsePaginationRequest(r *http.Request) *PaginationRequest
从请求中解析分页参数内部函数Handler内部使用
## 状态码说明
### HTTP状态码
- `200`: 正常响应(包括业务错误)
- `400`: 请求参数错误
- `401`: 未授权
- `403`: 禁止访问
- `404`: 资源不存在
- `500`: 系统内部错误
### 业务状态码
- `0`: 成功
- `非0`: 业务错误(具体错误码由业务定义)
## 注意事项
1. **HTTP状态码与业务状态码分离**
- 业务错误如用户不存在、参数验证失败等返回HTTP 200业务code非0
- 只有系统异常如数据库连接失败、程序panic等才返回HTTP 500
2. **分页参数限制**
- page最小值为1
- pageSize最小值为1最大值为100
3. **响应格式统一**
- 所有响应都遵循标准结构
- timestamp为Unix时间戳
4. **错误处理**
- 使用`Error`方法返回业务错误HTTP 200业务code非0
- 使用`SystemError`返回系统错误HTTP 500
- 其他HTTP错误状态码400, 401, 403, 404等使用`WriteJSON`方法直接指定
5. **黑盒模式**
- 所有功能都通过Handler对象调用无需传递`w``r`参数
- 代码更简洁,减少调用方工作量
## 示例
完整示例请参考 `examples/http_handler_example.go`

View File

@@ -1,266 +0,0 @@
# 日志工具文档
## 概述
日志工具提供了统一的日志记录功能使用Go标准库实现无需第三方依赖。
## 功能特性
- 支持多种日志级别debug, info, warn, error
- 支持多种输出方式stdout, stderr, file, both
- 支持日志文件自动创建
- 支持日志前缀
- 支持禁用时间戳
- 支持带字段的日志记录
- 使用配置工具统一管理配置
## 使用方法
### 1. 从配置创建日志记录器(推荐)
```go
import (
"git.toowon.com/jimmy/go-common/config"
"git.toowon.com/jimmy/go-common/factory"
)
// 加载配置
cfg, err := config.LoadFromFile("./config.json")
if err != nil {
log.Fatal(err)
}
// 使用工厂创建日志记录器(已初始化,可直接使用)
fac := factory.NewFactory(cfg)
logger, err := fac.GetLogger()
if err != nil {
log.Fatal(err)
}
// 直接使用
logger.Info("Application started")
logger.Error("Failed to connect: %v", err)
```
### 2. 直接创建日志记录器
```go
import (
"git.toowon.com/jimmy/go-common/config"
"git.toowon.com/jimmy/go-common/logger"
)
// 从配置获取日志配置
cfg, _ := config.LoadFromFile("./config.json")
loggerConfig := cfg.GetLogger()
// 创建日志记录器
logger, err := logger.NewLogger(loggerConfig)
if err != nil {
log.Fatal(err)
}
// 使用默认配置如果loggerConfig为nil
logger, err := logger.NewLogger(nil)
```
### 3. 基本日志记录
```go
// 记录不同级别的日志
logger.Debug("Debug message: %s", "debug info")
logger.Info("Info message: %s", "info")
logger.Warn("Warning message: %s", "warning")
logger.Error("Error message: %s", "error")
// 致命错误(会退出程序)
logger.Fatal("Fatal error: %s", "fatal")
// 恐慌错误会触发panic
logger.Panic("Panic error: %s", "panic")
```
### 4. 带字段的日志记录
```go
// 记录带字段的日志
fields := map[string]interface{}{
"user_id": 123,
"action": "login",
}
logger.Infof(fields, "User logged in")
logger.Errorf(fields, "Failed to process request")
```
## API 参考
### NewLogger(cfg *config.LoggerConfig) (*Logger, error)
创建日志记录器。
**参数:**
- `cfg`: 日志配置对象如果为nil使用默认配置
**返回:** 日志记录器实例和错误信息
### (l *Logger) Debug(format string, v ...interface{})
记录调试日志。
### (l *Logger) Info(format string, v ...interface{})
记录信息日志。
### (l *Logger) Warn(format string, v ...interface{})
记录警告日志。
### (l *Logger) Error(format string, v ...interface{})
记录错误日志。
### (l *Logger) Fatal(format string, v ...interface{})
记录致命错误日志并退出程序。
### (l *Logger) Panic(format string, v ...interface{})
记录恐慌日志并触发panic。
### (l *Logger) Debugf(fields map[string]interface{}, format string, v ...interface{})
记录调试日志(带字段)。
### (l *Logger) Infof(fields map[string]interface{}, format string, v ...interface{})
记录信息日志(带字段)。
### (l *Logger) Warnf(fields map[string]interface{}, format string, v ...interface{})
记录警告日志(带字段)。
### (l *Logger) Errorf(fields map[string]interface{}, format string, v ...interface{})
记录错误日志(带字段)。
## 配置说明
日志配置通过 `config.LoggerConfig` 提供:
| 字段 | 类型 | 说明 | 默认值 |
|------|------|------|--------|
| Level | string | 日志级别: debug, info, warn, error | info |
| Output | string | 输出方式: stdout, stderr, file, both | stdout |
| FilePath | string | 日志文件路径当output为file或both时必需 | - |
| Prefix | string | 日志前缀 | - |
| DisableTimestamp | bool | 禁用时间戳 | false |
## 配置示例
### 输出到标准输出
```json
{
"logger": {
"level": "info",
"output": "stdout",
"prefix": "app"
}
}
```
### 输出到文件
```json
{
"logger": {
"level": "debug",
"output": "file",
"filePath": "./logs/app.log",
"prefix": "app"
}
}
```
### 同时输出到标准输出和文件
```json
{
"logger": {
"level": "info",
"output": "both",
"filePath": "./logs/app.log",
"prefix": "app",
"disableTimestamp": false
}
}
```
## 日志级别说明
- **debug**: 调试信息,最详细的日志级别
- **info**: 一般信息,正常的程序运行信息
- **warn**: 警告信息,可能的问题但不影响程序运行
- **error**: 错误信息,程序运行中的错误
## 注意事项
1. **文件路径**
- 当output为`file``both`时,必须提供`filePath`
- 日志文件目录会自动创建(如果不存在)
2. **日志级别**
- 设置为`debug`时,会记录所有级别的日志
- 设置为`info`会记录info、warn、error级别的日志
- 设置为`warn`只记录warn和error级别的日志
- 设置为`error`只记录error级别的日志
3. **文件权限**
- 日志文件创建时使用0666权限
- 目录创建时使用0755权限
4. **性能考虑**
- 使用标准库log包性能较好
- 文件输出使用追加模式,不会覆盖已有日志
## 完整示例
```go
package main
import (
"git.toowon.com/jimmy/go-common/config"
"git.toowon.com/jimmy/go-common/factory"
)
func main() {
// 加载配置
cfg, err := config.LoadFromFile("./config.json")
if err != nil {
log.Fatal(err)
}
// 使用工厂创建日志记录器
fac := factory.NewFactory(cfg)
logger, err := fac.GetLogger()
if err != nil {
log.Fatal(err)
}
// 使用日志记录器
logger.Info("Application started")
// 记录带字段的日志
logger.Infof(map[string]interface{}{
"user_id": 123,
"action": "login",
}, "User logged in successfully")
logger.Error("An error occurred: %v", err)
}
```
## 示例
完整示例请参考 `examples/logger_example.go`

View File

@@ -1,446 +0,0 @@
# 中间件工具文档
## 概述
中间件工具提供了常用的HTTP中间件功能包括CORS处理和时区管理。
## 功能特性
- **CORS中间件**:支持跨域资源共享配置
- **时区中间件**:从请求头读取时区信息,支持默认时区设置
- **中间件链**:提供便捷的中间件链式调用
## CORS中间件
### 功能说明
CORS中间件用于处理跨域资源共享支持
- 配置允许的源(支持通配符)
- 配置允许的HTTP方法
- 配置允许的请求头
- 配置暴露的响应头
- 支持凭证传递
- 预检请求缓存时间设置
### 使用方法
#### 基本使用(默认配置)
```go
import (
"net/http"
"git.toowon.com/jimmy/go-common/middleware"
)
func main() {
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 处理请求
})
// 使用默认CORS配置
corsHandler := middleware.CORS()(handler)
http.Handle("/api", corsHandler)
http.ListenAndServe(":8080", nil)
}
```
#### 自定义配置
```go
import (
"net/http"
"git.toowon.com/jimmy/go-common/middleware"
)
func main() {
// 自定义CORS配置
corsConfig := &middleware.CORSConfig{
AllowedOrigins: []string{
"https://example.com",
"https://app.example.com",
"*.example.com", // 支持通配符
},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{
"Content-Type",
"Authorization",
"X-Requested-With",
"X-Timezone",
},
ExposedHeaders: []string{"X-Total-Count"},
AllowCredentials: true,
MaxAge: 3600, // 1小时
}
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 处理请求
})
corsHandler := middleware.CORS(corsConfig)(handler)
http.Handle("/api", corsHandler)
http.ListenAndServe(":8080", nil)
}
```
#### 允许所有源(开发环境)
```go
corsConfig := &middleware.CORSConfig{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"*"},
}
corsHandler := middleware.CORS(corsConfig)(handler)
```
### CORSConfig 配置说明
| 字段 | 类型 | 说明 | 默认值 |
|------|------|------|--------|
| AllowedOrigins | []string | 允许的源,支持 "*" 和 "*.example.com" | ["*"] |
| AllowedMethods | []string | 允许的HTTP方法 | ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"] |
| AllowedHeaders | []string | 允许的请求头 | ["Content-Type", "Authorization", "X-Requested-With", "X-Timezone"] |
| ExposedHeaders | []string | 暴露给客户端的响应头 | [] |
| AllowCredentials | bool | 是否允许发送凭证 | false |
| MaxAge | int | 预检请求缓存时间(秒) | 86400 |
### 注意事项
1. 如果 `AllowCredentials``true``AllowedOrigins` 不能使用 "*",必须指定具体的源
2. 通配符支持:`"*.example.com"` 会匹配 `"https://app.example.com"` 等子域名
3. 预检请求OPTIONS会自动处理无需在业务代码中处理
## 时区中间件
### 功能说明
时区中间件用于从请求头读取时区信息并存储到context中方便后续使用。
- 从请求头 `X-Timezone` 读取时区
- 如果未传递时区信息,使用默认时区 `AsiaShanghai`
- 时区信息存储到context中可通过Handler的`GetTimezone()`方法获取
- 自动验证时区有效性,无效时区会回退到默认时区
### 使用方法
#### 基本使用(默认时区 AsiaShanghai
```go
import (
"net/http"
"git.toowon.com/jimmy/go-common/middleware"
commonhttp "git.toowon.com/jimmy/go-common/http"
"git.toowon.com/jimmy/go-common/datetime"
)
func handler(w http.ResponseWriter, r *http.Request) {
h := commonhttp.NewHandler(w, r)
// 从Handler获取时区
timezone := h.GetTimezone()
// 使用时区
now := datetime.Now(timezone)
datetime.FormatDateTime(now, timezone)
h.Success(map[string]interface{}{
"timezone": timezone,
"time": datetime.FormatDateTime(now),
})
}
func main() {
handler := middleware.Timezone(http.HandlerFunc(handler))
http.Handle("/api", handler)
http.ListenAndServe(":8080", nil)
}
```
#### 自定义默认时区
```go
import (
"net/http"
"git.toowon.com/jimmy/go-common/middleware"
"git.toowon.com/jimmy/go-common/datetime"
)
func handler(w http.ResponseWriter, r *http.Request) {
// 处理请求
}
func main() {
// 使用自定义默认时区
handler := middleware.TimezoneWithDefault(datetime.UTC)(http.HandlerFunc(handler))
http.Handle("/api", handler)
http.ListenAndServe(":8080", nil)
}
```
#### 在业务代码中使用时区
```go
import (
"net/http"
commonhttp "git.toowon.com/jimmy/go-common/http"
"git.toowon.com/jimmy/go-common/datetime"
)
func GetUserList(w http.ResponseWriter, r *http.Request) {
h := commonhttp.NewHandler(w, r)
// 从Handler获取时区
timezone := h.GetTimezone()
// 使用时区进行时间处理
now := datetime.Now(timezone)
// 查询数据时使用时区
startTime := datetime.StartOfDay(now, timezone)
endTime := datetime.EndOfDay(now, timezone)
// 返回数据
h.Success(map[string]interface{}{
"timezone": timezone,
"startTime": datetime.FormatDateTime(startTime),
"endTime": datetime.FormatDateTime(endTime),
})
}
```
### 请求头格式
客户端需要在请求头中传递时区信息:
```
X-Timezone: Asia/Shanghai
```
支持的时区格式IANA时区数据库
- `Asia/Shanghai`
- `America/New_York`
- `Europe/London`
- `UTC`
- 等等
### 注意事项
1. 如果请求头中未传递 `X-Timezone`,默认使用 `AsiaShanghai`
2. 如果传递的时区无效,会自动回退到默认时区
3. 时区信息存储在context中可以在整个请求生命周期中使用
4. 建议在CORS配置中包含 `X-Timezone` 请求头
## 中间件链
### 功能说明
中间件链提供便捷的中间件组合方式,支持链式调用。
### 使用方法
```go
import (
"net/http"
"git.toowon.com/jimmy/go-common/middleware"
)
func handler(w http.ResponseWriter, r *http.Request) {
// 处理请求
}
func main() {
// 创建中间件链
chain := middleware.NewChain(
middleware.CORS(),
middleware.Timezone,
)
// 应用到处理器
handler := chain.ThenFunc(handler)
http.Handle("/api", handler)
http.ListenAndServe(":8080", nil)
}
```
#### 链式追加中间件
```go
chain := middleware.NewChain(middleware.CORS())
chain.Append(middleware.Timezone)
handler := chain.ThenFunc(handler)
```
## 完整示例
### 示例1CORS + 时区中间件
```go
package main
import (
"log"
"net/http"
"git.toowon.com/jimmy/go-common/middleware"
commonhttp "git.toowon.com/jimmy/go-common/http"
"git.toowon.com/jimmy/go-common/datetime"
)
func apiHandler(w http.ResponseWriter, r *http.Request) {
h := commonhttp.NewHandler(w, r)
// 从Handler获取时区
timezone := h.GetTimezone()
now := datetime.Now(timezone)
h.Success(map[string]interface{}{
"message": "Hello",
"timezone": timezone,
"time": datetime.FormatDateTime(now),
})
}
func main() {
// 配置CORS
corsConfig := &middleware.CORSConfig{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "OPTIONS"},
AllowedHeaders: []string{"Content-Type", "Authorization", "X-Timezone"},
}
// 创建中间件链
chain := middleware.NewChain(
middleware.CORS(corsConfig),
middleware.Timezone,
)
// 应用中间件
handler := chain.ThenFunc(apiHandler)
http.Handle("/api", handler)
log.Println("Server started on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
```
### 示例2与路由框架集成
```go
package main
import (
"net/http"
"git.toowon.com/jimmy/go-common/middleware"
commonhttp "git.toowon.com/jimmy/go-common/http"
)
func main() {
mux := http.NewServeMux()
// 创建中间件链
chain := middleware.NewChain(
middleware.CORS(),
middleware.Timezone,
)
// 应用中间件到所有路由
mux.Handle("/api/users", chain.ThenFunc(getUsers))
mux.Handle("/api/posts", chain.ThenFunc(getPosts))
http.ListenAndServe(":8080", mux)
}
func getUsers(w http.ResponseWriter, r *http.Request) {
h := commonhttp.NewHandler(w, r)
timezone := h.GetTimezone()
// 处理逻辑
h.Success(nil)
}
func getPosts(w http.ResponseWriter, r *http.Request) {
h := commonhttp.NewHandler(w, r)
timezone := h.GetTimezone()
// 处理逻辑
h.Success(nil)
}
```
## API 参考
### CORS中间件
#### CORS(config ...*CORSConfig) func(http.Handler) http.Handler
创建CORS中间件。
**参数:**
- `config`: 可选的CORS配置不指定则使用默认配置
**返回:** 中间件函数
#### DefaultCORSConfig() *CORSConfig
返回默认的CORS配置。
### 时区中间件
#### Timezone(next http.Handler) http.Handler
时区处理中间件(默认时区为 AsiaShanghai
#### TimezoneWithDefault(defaultTimezone string) func(http.Handler) http.Handler
时区处理中间件(可自定义默认时区)。
**参数:**
- `defaultTimezone`: 默认时区字符串
**返回:** 中间件函数
#### GetTimezoneFromContext(ctx context.Context) string
从context中获取时区。
### 中间件链
#### NewChain(middlewares ...func(http.Handler) http.Handler) *Chain
创建新的中间件链。
#### (c *Chain) Then(handler http.Handler) http.Handler
将中间件链应用到处理器。
#### (c *Chain) ThenFunc(handler http.HandlerFunc) http.Handler
将中间件链应用到处理器函数。
#### (c *Chain) Append(middlewares ...func(http.Handler) http.Handler) *Chain
追加中间件到链中。
## 注意事项
1. **CORS配置**
- 生产环境建议明确指定允许的源,避免使用 "*"
- 如果使用凭证cookies必须明确指定源不能使用 "*"
2. **时区处理**
- 时区信息存储在context中确保中间件在处理器之前执行
- 时区验证失败时会自动回退到默认时区,不会返回错误
3. **中间件顺序**
- CORS中间件应该放在最外层以便处理预检请求
- 时区中间件可以放在CORS之后
4. **性能考虑**
- CORS预检请求会被缓存减少重复请求
- 时区验证只在请求头存在时进行,性能影响很小

View File

@@ -1,324 +0,0 @@
# 数据库迁移工具文档
## 概述
数据库迁移工具提供了数据库版本管理和迁移功能支持MySQL、PostgreSQL、SQLite等数据库。使用GORM作为数据库操作库可以方便地进行数据库结构的版本控制。
## 功能特性
- 支持迁移版本管理
- 支持迁移和回滚操作
- 支持从文件系统加载迁移文件
- 支持迁移状态查询
- 自动创建迁移记录表
- 事务支持,确保迁移的原子性
## 使用方法
### 1. 创建迁移器
```go
import (
"gorm.io/driver/mysql"
"gorm.io/gorm"
"git.toowon.com/jimmy/go-common/migration"
)
// 初始化数据库连接
dsn := "user:password@tcp(localhost:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local"
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
// 创建迁移器
migrator := migration.NewMigrator(db)
// 或者指定自定义的迁移记录表名
migrator := migration.NewMigrator(db, "my_migrations")
```
### 2. 添加迁移
#### 方式一:代码方式添加迁移
```go
migrator.AddMigration(migration.Migration{
Version: "20240101000001",
Description: "create_users_table",
Up: func(db *gorm.DB) error {
return db.Exec(`
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`).Error
},
Down: func(db *gorm.DB) error {
return db.Exec("DROP TABLE IF EXISTS users").Error
},
})
```
#### 方式二:批量添加迁移
```go
migrations := []migration.Migration{
{
Version: "20240101000001",
Description: "create_users_table",
Up: func(db *gorm.DB) error {
return db.Exec("CREATE TABLE users ...").Error
},
Down: func(db *gorm.DB) error {
return db.Exec("DROP TABLE users").Error
},
},
{
Version: "20240101000002",
Description: "add_index_to_users",
Up: func(db *gorm.DB) error {
return db.Exec("CREATE INDEX idx_email ON users(email)").Error
},
Down: func(db *gorm.DB) error {
return db.Exec("DROP INDEX idx_email ON users").Error
},
},
}
migrator.AddMigrations(migrations...)
```
#### 方式三:从文件加载迁移
```go
// 文件命名格式: {version}_{description}.sql 或 {version}_{description}.up.sql
// 例如: 20240101000001_create_users_table.up.sql
// 对应的回滚文件: 20240101000001_create_users_table.down.sql
migrations, err := migration.LoadMigrationsFromFiles("./migrations", "*.up.sql")
if err != nil {
log.Fatal(err)
}
migrator.AddMigrations(migrations...)
```
### 3. 执行迁移
```go
// 执行所有未应用的迁移
err := migrator.Up()
if err != nil {
log.Fatal(err)
}
```
### 4. 回滚迁移
```go
// 回滚最后一个迁移
err := migrator.Down()
if err != nil {
log.Fatal(err)
}
```
### 5. 查看迁移状态
```go
status, err := migrator.Status()
if err != nil {
log.Fatal(err)
}
for _, s := range status {
fmt.Printf("Version: %s, Description: %s, Applied: %v\n",
s.Version, s.Description, s.Applied)
}
```
### 6. 重置迁移
#### 方式一:仅清空迁移记录(不回滚数据库变更)
```go
// 直接调用(需要传入确认标志)
err := migrator.Reset(true)
if err != nil {
log.Fatal(err)
}
// 交互式确认(推荐,会提示警告信息)
err := migrator.ResetWithConfirm()
if err != nil {
log.Fatal(err)
}
```
#### 方式二:回滚所有迁移并清空记录
```go
// 直接调用(需要传入确认标志)
err := migrator.ResetAll(true)
if err != nil {
log.Fatal(err)
}
// 交互式确认(推荐,会提示警告信息)
err := migrator.ResetAllWithConfirm()
if err != nil {
log.Fatal(err)
}
```
**注意:**
- `Reset()` 仅清空迁移记录表,不会回滚已执行的数据库变更
- `ResetAll()` 会回滚所有已应用的迁移执行Down函数然后清空记录
- 交互式方法会显示详细的警告信息,需要输入确认文本才能执行
### 7. 生成迁移版本号
```go
// 生成基于时间戳的版本号
version := migration.GenerateVersion()
// 输出: 1704067200 (Unix时间戳)
```
## API 参考
### Migration 结构体
```go
type Migration struct {
Version string // 迁移版本号(必须唯一)
Description string // 迁移描述
Up func(*gorm.DB) error // 升级函数
Down func(*gorm.DB) error // 回滚函数(可选)
}
```
### Migrator 方法
#### NewMigrator(db *gorm.DB, tableName ...string) *Migrator
创建新的迁移器。
**参数:**
- `db`: GORM数据库连接
- `tableName`: 可选,迁移记录表名,默认为 "schema_migrations"
**返回:** 迁移器实例
#### AddMigration(migration Migration)
添加单个迁移。
#### AddMigrations(migrations ...Migration)
批量添加迁移。
#### Up() error
执行所有未应用的迁移。按版本号升序执行。
**返回:** 错误信息
#### Down() error
回滚最后一个已应用的迁移。
**返回:** 错误信息
#### Status() ([]MigrationStatus, error)
查看所有迁移的状态。
**返回:** 迁移状态列表和错误信息
#### Reset(confirm bool) error
重置所有迁移记录(仅清空记录表,不回滚数据库变更)。
**参数:**
- `confirm`: 确认标志必须为true才能执行
**返回:** 错误信息
**注意:** 此操作只清空迁移记录不会回滚已执行的迁移操作。如果需要回滚迁移请先使用Down方法逐个回滚。
#### ResetWithConfirm() error
交互式重置所有迁移记录(带确认提示)。
**返回:** 错误信息
**说明:** 会显示警告信息,需要输入"RESET"(全大写)才能执行。
#### ResetAll(confirm bool) error
重置所有迁移并回滚所有已应用的迁移。
**参数:**
- `confirm`: 确认标志必须为true才能执行
**返回:** 错误信息
**注意:** 此操作会回滚所有已应用的迁移执行Down函数然后清空迁移记录。操作不可逆请谨慎使用。
#### ResetAllWithConfirm() error
交互式重置所有迁移并回滚(带确认提示)。
**返回:** 错误信息
**说明:** 会显示警告信息,需要输入"RESET ALL"(全大写)才能执行。
### MigrationStatus 结构体
```go
type MigrationStatus struct {
Version string // 版本号
Description string // 描述
Applied bool // 是否已应用
}
```
### 辅助函数
#### LoadMigrationsFromFiles(dir string, pattern string) ([]Migration, error)
从文件系统加载迁移文件。
**参数:**
- `dir`: 迁移文件目录
- `pattern`: 文件匹配模式,如 "*.up.sql"
**返回:** 迁移列表和错误信息
**文件命名格式:** `{version}_{description}.up.sql`
**示例:**
- `20240101000001_create_users_table.up.sql` - 升级文件
- `20240101000001_create_users_table.down.sql` - 回滚文件(可选)
#### GenerateVersion() string
生成基于时间戳的迁移版本号。
**返回:** Unix时间戳字符串
## 注意事项
1. **迁移版本号**:必须唯一,建议使用时间戳格式
2. **事务支持**:迁移操作在事务中执行,失败会自动回滚
3. **自动创建表**:迁移记录表会自动创建,无需手动创建
4. **回滚文件**如果迁移文件没有对应的down文件回滚操作会失败
5. **执行顺序**:迁移按版本号升序执行,确保顺序正确
6. **重置操作**
- `Reset()` 只清空迁移记录,不会回滚数据库变更
- `ResetAll()` 会回滚所有迁移,操作不可逆
- 建议使用交互式方法(`ResetWithConfirm``ResetAllWithConfirm`)以确保安全
- 在生产环境使用重置功能前,请确保已备份数据库
## 示例
完整示例请参考 `examples/migration_example.go`

View File

@@ -1,370 +0,0 @@
# 短信工具文档
## 概述
短信工具提供了阿里云短信发送功能使用Go标准库实现无需第三方依赖。
## 功能特性
- 支持阿里云短信服务
- 支持发送原始请求(完全由外部控制请求参数)
- 支持模板短信发送
- 支持批量发送
- 自动签名计算
- 使用配置工具统一管理配置
## 使用方法
### 1. 创建短信发送器
```go
import (
"git.toowon.com/jimmy/go-common/config"
"git.toowon.com/jimmy/go-common/sms"
)
// 从配置加载
cfg, err := config.LoadFromFile("./config.json")
if err != nil {
log.Fatal(err)
}
smsConfig := cfg.GetSMS()
if smsConfig == nil {
log.Fatal("SMS config is nil")
}
// 创建短信发送器
smsClient, err := sms.NewSMS(smsConfig)
if err != nil {
log.Fatal(err)
}
```
### 2. 发送原始请求(推荐,最灵活)
```go
// 外部构建完整的请求参数
params := map[string]string{
"PhoneNumbers": "13800138000,13900139000",
"SignName": "我的签名",
"TemplateCode": "SMS_123456789",
"TemplateParam": `{"code":"123456","expire":"5"}`,
}
// 发送短信(工具只负责添加系统参数、计算签名并发送)
resp, err := smsClient.SendRaw(params)
if err != nil {
log.Fatal(err)
}
fmt.Printf("发送成功RequestID: %s\n", resp.RequestID)
```
### 3. 发送简单短信(便捷方法)
```go
// 使用配置中的模板代码发送短信
templateParam := map[string]string{
"code": "123456",
}
resp, err := smsClient.SendSimple(
[]string{"13800138000"},
templateParam,
)
if err != nil {
log.Fatal(err)
}
fmt.Printf("发送成功RequestID: %s\n", resp.RequestID)
```
### 4. 使用指定模板发送短信(便捷方法)
```go
// 使用指定的模板代码发送短信
templateParam := map[string]string{
"code": "123456",
"expire": "5",
}
resp, err := smsClient.SendWithTemplate(
[]string{"13800138000"},
"SMS_123456789", // 模板代码
templateParam,
)
if err != nil {
log.Fatal(err)
}
```
### 5. 使用SendRequest结构发送便捷方法
```go
import "git.toowon.com/jimmy/go-common/sms"
req := &sms.SendRequest{
PhoneNumbers: []string{"13800138000", "13900139000"},
TemplateCode: "SMS_123456789",
TemplateParam: map[string]string{
"code": "123456",
},
SignName: "我的签名", // 可选,如果为空使用配置中的签名
}
resp, err := smsClient.Send(req)
if err != nil {
log.Fatal(err)
}
```
### 6. 使用JSON字符串作为模板参数
```go
// TemplateParam可以是JSON字符串
req := &sms.SendRequest{
PhoneNumbers: []string{"13800138000"},
TemplateCode: "SMS_123456789",
TemplateParam: `{"code":"123456","expire":"5"}`, // 直接使用JSON字符串
}
resp, err := smsClient.Send(req)
```
## API 参考
### NewSMS(cfg *config.SMSConfig) (*SMS, error)
创建短信发送器。
**参数:**
- `cfg`: 短信配置对象
**返回:** 短信发送器实例和错误信息
### (s *SMS) SendRaw(params map[string]string) (*SendResponse, error)
发送原始请求(推荐使用,最灵活)。
**参数:**
- `params`: 请求参数map工具只负责添加必要的系统参数如签名、时间戳等并发送
**返回:** 发送响应和错误信息
**说明:** 此方法允许外部完全控制请求参数,工具只负责添加系统参数、计算签名并发送。
### (s *SMS) Send(req *SendRequest) (*SendResponse, error)
发送短信使用SendRequest结构
**参数:**
- `req`: 发送请求对象
**返回:** 发送响应和错误信息
**说明:** 如果需要完全控制请求参数请使用SendRaw方法。
### (s *SMS) SendSimple(phoneNumbers []string, templateParam map[string]string) (*SendResponse, error)
发送简单短信(便捷方法,使用配置中的模板代码)。
**参数:**
- `phoneNumbers`: 手机号列表
- `templateParam`: 模板参数
### (s *SMS) SendWithTemplate(phoneNumbers []string, templateCode string, templateParam map[string]string) (*SendResponse, error)
使用指定模板发送短信(便捷方法)。
**参数:**
- `phoneNumbers`: 手机号列表
- `templateCode`: 模板代码
- `templateParam`: 模板参数
### SendRequest 结构体
```go
type SendRequest struct {
PhoneNumbers []string // 手机号列表
TemplateCode string // 模板代码(可选,如果为空使用配置中的)
TemplateParam interface{} // 模板参数可以是map[string]string或JSON字符串
SignName string // 签名(可选,如果为空使用配置中的)
}
```
### SendResponse 结构体
```go
type SendResponse struct {
RequestID string // 请求ID
Code string // 响应码OK表示成功
Message string // 响应消息
BizID string // 业务ID
}
```
## 配置说明
短信配置通过 `config.SMSConfig` 提供:
| 字段 | 类型 | 说明 | 默认值 |
|------|------|------|--------|
| AccessKeyID | string | 阿里云AccessKey ID | - |
| AccessKeySecret | string | 阿里云AccessKey Secret | - |
| Region | string | 区域cn-hangzhou | cn-hangzhou |
| SignName | string | 短信签名 | - |
| TemplateCode | string | 短信模板代码 | - |
| Endpoint | string | 服务端点(可选) | - |
| Timeout | int | 请求超时时间(秒) | 10 |
## 阿里云短信配置步骤
1. **开通阿里云短信服务**
- 登录阿里云控制台
- 开通短信服务
2. **创建AccessKey**
- 在AccessKey管理页面创建AccessKey
- 保存AccessKey ID和Secret
3. **申请短信签名**
- 在短信服务控制台申请签名
- 等待审核通过
4. **创建短信模板**
- 在短信服务控制台创建模板
- 模板格式示例:`您的验证码是${code},有效期${expire}分钟`
- 等待审核通过获取模板代码SMS_123456789
5. **配置参数**
```json
{
"sms": {
"accessKeyId": "your-access-key-id",
"accessKeySecret": "your-access-key-secret",
"region": "cn-hangzhou",
"signName": "您的签名",
"templateCode": "SMS_123456789"
}
}
```
## 模板参数示例
### 验证码模板
模板内容:`您的验证码是${code},有效期${expire}分钟`
```go
templateParam := map[string]string{
"code": "123456",
"expire": "5",
}
```
### 通知模板
模板内容:`您的订单${orderNo}已发货,物流单号:${trackingNo}`
```go
templateParam := map[string]string{
"orderNo": "ORD123456",
"trackingNo": "SF1234567890",
}
```
## 响应码说明
| Code | 说明 |
|------|------|
| OK | 发送成功 |
| InvalidSignName | 签名不存在 |
| InvalidTemplateCode | 模板不存在 |
| InvalidPhoneNumbers | 手机号格式错误 |
| Throttling | 请求被限流 |
| 其他 | 参考阿里云短信服务错误码文档 |
## 注意事项
1. **推荐使用SendRaw方法**
- `SendRaw`方法允许外部完全控制请求参数
- 可以发送任意阿里云短信API支持的请求
- 工具只负责添加系统参数、计算签名并发送
2. **模板参数格式**
- `TemplateParam`可以是`map[string]string`或JSON字符串
- 如果使用JSON字符串必须是有效的JSON格式
- 模板参数必须与模板中定义的变量匹配
3. **AccessKey安全**
- AccessKey具有账户权限请妥善保管
- 建议使用子账户AccessKey并限制权限
4. **签名和模板**
- 签名和模板需要先申请并审核通过
- 模板参数必须与模板中定义的变量匹配
5. **手机号格式**
- 支持国内手机号11位数字
- 支持国际手机号(需要加国家代码)
6. **发送频率**
- 注意阿里云的发送频率限制
- 建议实现发送频率控制
7. **错误处理**
- 所有操作都应该进行错误处理
- 建议记录详细的错误日志
- 注意区分业务错误和系统错误
8. **批量发送**
- 支持一次发送给多个手机号
- 注意批量发送的数量限制
## 完整示例
```go
package main
import (
"fmt"
"log"
"git.toowon.com/jimmy/go-common/config"
"git.toowon.com/jimmy/go-common/sms"
)
func main() {
// 加载配置
cfg, err := config.LoadFromFile("./config.json")
if err != nil {
log.Fatal(err)
}
// 创建短信发送器
smsClient, err := sms.NewSMS(cfg.GetSMS())
if err != nil {
log.Fatal(err)
}
// 发送验证码短信
templateParam := map[string]string{
"code": "123456",
"expire": "5",
}
resp, err := smsClient.SendSimple(
[]string{"13800138000"},
templateParam,
)
if err != nil {
log.Fatal(err)
}
fmt.Printf("发送成功RequestID: %s\n", resp.RequestID)
}
```
## 示例
完整示例请参考 `examples/sms_example.go`

View File

@@ -1,496 +0,0 @@
# 存储工具文档
## 概述
存储工具提供了文件上传和查看功能支持OSS和MinIO两种存储方式并提供HTTP处理器用于文件上传和代理查看。
## 功能特性
- 支持OSS对象存储阿里云、腾讯云、AWS、七牛云等
- 支持MinIO对象存储
- 提供统一的存储接口
- 支持文件上传HTTP处理器
- 支持文件代理查看HTTP处理器
- 支持文件大小和扩展名限制
- 自动生成唯一文件名
- 支持自定义对象键前缀
## 使用方法
### 1. 创建存储实例
```go
import (
"git.toowon.com/jimmy/go-common/config"
"git.toowon.com/jimmy/go-common/storage"
)
// 加载配置
cfg, err := config.LoadFromFile("./config.json")
if err != nil {
log.Fatal(err)
}
// 创建OSS存储实例
ossStorage, err := storage.NewStorage(storage.StorageTypeOSS, cfg)
if err != nil {
log.Fatal(err)
}
// 创建MinIO存储实例
minioStorage, err := storage.NewStorage(storage.StorageTypeMinIO, cfg)
if err != nil {
log.Fatal(err)
}
```
### 2. 上传文件
```go
import (
"context"
"os"
"git.toowon.com/jimmy/go-common/storage"
)
// 打开文件
file, err := os.Open("test.jpg")
if err != nil {
log.Fatal(err)
}
defer file.Close()
// 上传文件
ctx := context.Background()
objectKey := "images/test.jpg"
err = ossStorage.Upload(ctx, objectKey, file, "image/jpeg")
if err != nil {
log.Fatal(err)
}
// 获取文件URL
url, err := ossStorage.GetURL(objectKey, 0)
if err != nil {
log.Fatal(err)
}
fmt.Printf("File URL: %s\n", url)
```
### 3. 使用HTTP处理器上传文件
```go
import (
"net/http"
"git.toowon.com/jimmy/go-common/storage"
)
// 创建上传处理器
uploadHandler := storage.NewUploadHandler(storage.UploadHandlerConfig{
Storage: ossStorage,
MaxFileSize: 10 * 1024 * 1024, // 10MB
AllowedExts: []string{".jpg", ".jpeg", ".png", ".gif"},
ObjectPrefix: "images/",
})
// 注册路由
http.Handle("/upload", uploadHandler)
http.ListenAndServe(":8080", nil)
```
**上传请求示例:**
```bash
curl -X POST http://localhost:8080/upload \
-F "file=@test.jpg" \
-F "prefix=images/"
```
**响应示例:**
```json
{
"code": 0,
"message": "Upload successful",
"timestamp": 1704067200,
"data": {
"objectKey": "images/test_1704067200000000000.jpg",
"url": "https://bucket.oss-cn-hangzhou.aliyuncs.com/images/test_1704067200000000000.jpg",
"size": 102400,
"contentType": "image/jpeg",
"uploadTime": "2024-01-01T12:00:00Z"
}
}
```
### 4. 使用HTTP处理器查看文件
```go
import (
"net/http"
"git.toowon.com/jimmy/go-common/storage"
)
// 创建代理查看处理器
proxyHandler := storage.NewProxyHandler(ossStorage)
// 注册路由
http.Handle("/file", proxyHandler)
http.ListenAndServe(":8080", nil)
```
**查看请求示例:**
```
GET /file?key=images/test.jpg
```
### 5. 生成对象键
```go
import "git.toowon.com/jimmy/go-common/storage"
// 生成简单对象键
objectKey := storage.GenerateObjectKey("images/", "test.jpg")
// 输出: "images/test.jpg"
// 生成带日期的对象键
objectKey := storage.GenerateObjectKeyWithDate("images", "test.jpg")
// 输出: "images/2024/01/01/test.jpg"
```
### 6. 删除文件
```go
ctx := context.Background()
err := ossStorage.Delete(ctx, "images/test.jpg")
if err != nil {
log.Fatal(err)
}
```
### 7. 检查文件是否存在
```go
ctx := context.Background()
exists, err := ossStorage.Exists(ctx, "images/test.jpg")
if err != nil {
log.Fatal(err)
}
if exists {
fmt.Println("File exists")
}
```
## API 参考
### Storage 接口
```go
type Storage interface {
// Upload 上传文件
Upload(ctx context.Context, objectKey string, reader io.Reader, contentType ...string) error
// GetURL 获取文件访问URL
GetURL(objectKey string, expires int64) (string, error)
// Delete 删除文件
Delete(ctx context.Context, objectKey string) error
// Exists 检查文件是否存在
Exists(ctx context.Context, objectKey string) (bool, error)
// GetObject 获取文件内容
GetObject(ctx context.Context, objectKey string) (io.ReadCloser, error)
}
```
### NewStorage(storageType StorageType, cfg *config.Config) (Storage, error)
创建存储实例。
**参数:**
- `storageType`: 存储类型(`storage.StorageTypeOSS``storage.StorageTypeMinIO`
- `cfg`: 配置对象
**返回:** 存储实例和错误信息
### UploadHandler
文件上传HTTP处理器。
#### NewUploadHandler(cfg UploadHandlerConfig) *UploadHandler
创建上传处理器。
**配置参数:**
- `Storage`: 存储实例
- `MaxFileSize`: 最大文件大小字节0表示不限制
- `AllowedExts`: 允许的文件扩展名,空表示不限制
- `ObjectPrefix`: 对象键前缀
#### 请求格式
- **方法**: POST
- **表单字段**:
- `file`: 文件(必需)
- `prefix`: 对象键前缀(可选,会覆盖配置中的前缀)
#### 响应格式
```json
{
"code": 0,
"message": "Upload successful",
"timestamp": 1704067200,
"data": {
"objectKey": "images/test.jpg",
"url": "https://...",
"size": 102400,
"contentType": "image/jpeg",
"uploadTime": "2024-01-01T12:00:00Z"
}
}
```
### ProxyHandler
文件代理查看HTTP处理器。
#### NewProxyHandler(storage Storage) *ProxyHandler
创建代理查看处理器。
#### 请求格式
- **方法**: GET
- **URL参数**:
- `key`: 对象键(必需)
#### 响应
直接返回文件内容设置适当的Content-Type。
### 辅助函数
#### GenerateObjectKey(prefix, filename string) string
生成对象键。
#### GenerateObjectKeyWithDate(prefix, filename string) string
生成带日期的对象键(格式: prefix/YYYY/MM/DD/filename
## 完整示例
### 示例1文件上传和查看
```go
package main
import (
"log"
"net/http"
"git.toowon.com/jimmy/go-common/config"
"git.toowon.com/jimmy/go-common/middleware"
"git.toowon.com/jimmy/go-common/storage"
)
func main() {
// 加载配置
cfg, err := config.LoadFromFile("./config.json")
if err != nil {
log.Fatal(err)
}
// 创建存储实例使用OSS
ossStorage, err := storage.NewStorage(storage.StorageTypeOSS, cfg)
if err != nil {
log.Fatal(err)
}
// 创建上传处理器
uploadHandler := storage.NewUploadHandler(storage.UploadHandlerConfig{
Storage: ossStorage,
MaxFileSize: 10 * 1024 * 1024, // 10MB
AllowedExts: []string{".jpg", ".jpeg", ".png", ".gif", ".pdf"},
ObjectPrefix: "uploads/",
})
// 创建代理查看处理器
proxyHandler := storage.NewProxyHandler(ossStorage)
// 创建中间件链
chain := middleware.NewChain(
middleware.CORS(cfg.GetCORS()),
middleware.Timezone,
)
// 注册路由
mux := http.NewServeMux()
mux.Handle("/upload", chain.Then(uploadHandler))
mux.Handle("/file", chain.Then(proxyHandler))
log.Println("Server started on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
```
### 示例2直接使用存储接口
```go
package main
import (
"context"
"fmt"
"log"
"os"
"git.toowon.com/jimmy/go-common/config"
"git.toowon.com/jimmy/go-common/storage"
)
func main() {
// 加载配置
cfg, err := config.LoadFromFile("./config.json")
if err != nil {
log.Fatal(err)
}
// 创建存储实例
s, err := storage.NewStorage(storage.StorageTypeMinIO, cfg)
if err != nil {
log.Fatal(err)
}
// 打开文件
file, err := os.Open("test.jpg")
if err != nil {
log.Fatal(err)
}
defer file.Close()
// 生成对象键
objectKey := storage.GenerateObjectKeyWithDate("images", "test.jpg")
// 上传文件
ctx := context.Background()
err = s.Upload(ctx, objectKey, file, "image/jpeg")
if err != nil {
log.Fatal(err)
}
// 获取文件URL
url, err := s.GetURL(objectKey, 0)
if err != nil {
log.Fatal(err)
}
fmt.Printf("File uploaded: %s\n", url)
}
```
## 注意事项
1. **OSS和MinIO SDK实现**
- 当前实现提供了接口和框架但具体的OSS和MinIO SDK集成需要根据实际使用的SDK实现
- 需要在`oss.go``minio.go`中实现具体的SDK调用
2. **文件大小限制**
- 建议设置合理的文件大小限制
- 大文件上传可能需要分片上传
3. **文件扩展名验证**
- 建议限制允许的文件类型,防止上传恶意文件
- 仅验证扩展名不够安全,建议结合文件内容验证
4. **安全性**
- 上传接口应该添加身份验证
- 代理查看接口可以添加访问控制
5. **性能优化**
- 对于大文件,考虑使用分片上传
- 代理查看可以添加缓存机制
6. **错误处理**
- 所有操作都应该进行错误处理
- 建议记录详细的错误日志
## 实现OSS和MinIO SDK集成
由于不同的OSS提供商和MinIO有不同的SDK当前实现提供了框架需要根据实际情况集成
### OSS SDK集成示例阿里云OSS
```go
import (
"github.com/aliyun/aliyun-oss-go-sdk/oss"
)
func NewOSSStorage(cfg *config.OSSConfig) (*OSSStorage, error) {
client, err := oss.New(cfg.Endpoint, cfg.AccessKeyID, cfg.AccessKeySecret)
if err != nil {
return nil, err
}
storage := &OSSStorage{
config: cfg,
client: client,
}
return storage, nil
}
func (s *OSSStorage) Upload(ctx context.Context, objectKey string, reader io.Reader, contentType ...string) error {
bucket, err := s.client.Bucket(s.config.Bucket)
if err != nil {
return err
}
options := []oss.Option{}
if len(contentType) > 0 && contentType[0] != "" {
options = append(options, oss.ContentType(contentType[0]))
}
return bucket.PutObject(objectKey, reader, options...)
}
```
### MinIO SDK集成示例
```go
import (
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
func NewMinIOStorage(cfg *config.MinIOConfig) (*MinIOStorage, error) {
client, err := minio.New(cfg.Endpoint, &minio.Options{
Creds: credentials.NewStaticV4(cfg.AccessKeyID, cfg.SecretAccessKey, ""),
Secure: cfg.UseSSL,
})
if err != nil {
return nil, err
}
storage := &MinIOStorage{
config: cfg,
client: client,
}
return storage, nil
}
func (s *MinIOStorage) Upload(ctx context.Context, objectKey string, reader io.Reader, contentType ...string) error {
ct := "application/octet-stream"
if len(contentType) > 0 && contentType[0] != "" {
ct = contentType[0]
}
_, err := s.client.PutObject(ctx, s.config.Bucket, objectKey, reader, -1, minio.PutObjectOptions{
ContentType: ct,
})
return err
}
```
## 示例
完整示例请参考 `examples/storage_example.go`

View File

@@ -2,274 +2,272 @@ package email
import ( import (
"bytes" "bytes"
"context"
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"net" "net"
"net/smtp" "net/smtp"
"sync"
"sync/atomic"
"time" "time"
"git.toowon.com/jimmy/go-common/config" "git.toowon.com/jimmy/go-common/config"
"git.toowon.com/jimmy/go-common/logger"
) )
// Email 邮件发送器 // Email 邮件发送器
type Email struct { type Email struct {
config *config.EmailConfig config *config.EmailConfig
async bool
queue chan emailTask
workers int
wg sync.WaitGroup
closed bool
mu sync.Mutex
dropped atomic.Uint64
}
type emailTask struct {
to []string
subject string
body string
htmlBody string
requestID string
} }
// NewEmail 创建邮件发送器 // NewEmail 创建邮件发送器
func NewEmail(cfg *config.EmailConfig) (*Email, error) { func NewEmail(cfg *config.Config) *Email {
if cfg == nil { if cfg == nil || cfg.Email == nil {
return &Email{config: nil}
}
e := &Email{
config: cfg.Email,
async: cfg.Email.IsAsync(),
workers: cfg.Email.Workers,
}
if e.workers <= 0 {
e.workers = 2
}
queueSize := cfg.Email.QueueSize
if queueSize <= 0 {
queueSize = 1000
}
if e.async {
e.queue = make(chan emailTask, queueSize)
for i := 0; i < e.workers; i++ {
e.wg.Add(1)
go e.worker()
}
}
return e
}
func (e *Email) worker() {
defer e.wg.Done()
for task := range e.queue {
if err := e.SendEmail(task.to, task.subject, task.body, task.htmlBody); err != nil {
fields := map[string]any{
"error": err.Error(),
"request_id": task.requestID,
"to": task.to,
}
logger.FromContext(context.Background()).Error("async email send failed", fields)
}
}
}
func (e *Email) getEmailConfig() (*config.EmailConfig, error) {
if e.config == nil {
return nil, fmt.Errorf("email config is nil") return nil, fmt.Errorf("email config is nil")
} }
if e.config.Host == "" {
if cfg.Host == "" {
return nil, fmt.Errorf("email host is required") return nil, fmt.Errorf("email host is required")
} }
if e.config.Username == "" {
if cfg.Username == "" {
return nil, fmt.Errorf("email username is required") return nil, fmt.Errorf("email username is required")
} }
if e.config.Password == "" {
if cfg.Password == "" {
return nil, fmt.Errorf("email password is required") return nil, fmt.Errorf("email password is required")
} }
if e.config.Port == 0 {
// 设置默认值 e.config.Port = 587
if cfg.Port == 0 {
cfg.Port = 587
} }
if cfg.From == "" { if e.config.From == "" {
cfg.From = cfg.Username e.config.From = e.config.Username
} }
if cfg.Timeout == 0 { if e.config.Timeout == 0 {
cfg.Timeout = 30 e.config.Timeout = 5
} }
return e.config, nil
return &Email{
config: cfg,
}, nil
} }
// Message 邮件消息 // Message 邮件消息
type Message struct { type Message struct {
// To 收件人列表
To []string To []string
// Cc 抄送列表(可选)
Cc []string Cc []string
// Bcc 密送列表(可选)
Bcc []string Bcc []string
// Subject 主题
Subject string Subject string
// Body 正文(纯文本)
Body string Body string
// HTMLBody HTML正文可选如果设置了会优先使用
HTMLBody string HTMLBody string
// Attachments 附件列表(可选)
Attachments []Attachment
} }
// Attachment 附件 // SendEmail 同步发送邮件(验证码等需等待结果的场景)
type Attachment struct { func (e *Email) SendEmail(to []string, subject, body string, htmlBody ...string) error {
// Filename 文件名 cfg, err := e.getEmailConfig()
Filename string if err != nil {
return err
// Content 文件内容 }
Content []byte msg := &Message{To: to, Subject: subject, Body: body}
if len(htmlBody) > 0 && htmlBody[0] != "" {
// ContentType 文件类型application/pdf msg.HTMLBody = htmlBody[0]
ContentType string }
return e.send(msg, cfg)
} }
// SendRaw 发送原始邮件内容 // SendEmailAsync 异步发送邮件HTTP 通知类场景)
// recipients: 收件人列表To、Cc、Bcc的合并列表 func (e *Email) SendEmailAsync(ctx context.Context, to []string, subject, body string, htmlBody ...string) {
// body: 完整的邮件内容MIME格式由外部构建 task := emailTask{
func (e *Email) SendRaw(recipients []string, body []byte) error { to: append([]string(nil), to...),
if len(recipients) == 0 { subject: subject,
body: body,
requestID: logger.RequestIDFromContext(ctx),
}
if len(htmlBody) > 0 {
task.htmlBody = htmlBody[0]
}
if !e.async {
_ = e.SendEmail(task.to, task.subject, task.body, task.htmlBody)
return
}
select {
case e.queue <- task:
default:
e.dropped.Add(1)
logger.FromContext(ctx).Error("email queue full, task dropped", map[string]any{
"to": to,
})
}
}
// Close 关闭异步 worker
func (e *Email) Close() error {
if !e.async {
return nil
}
e.mu.Lock()
if e.closed {
e.mu.Unlock()
return nil
}
e.closed = true
e.mu.Unlock()
close(e.queue)
e.wg.Wait()
return nil
}
func (e *Email) send(msg *Message, cfg *config.EmailConfig) error {
if msg == nil {
return fmt.Errorf("message is nil")
}
if len(msg.To) == 0 {
return fmt.Errorf("recipients are required") return fmt.Errorf("recipients are required")
} }
if msg.Subject == "" {
if len(body) == 0 { return fmt.Errorf("subject is required")
return fmt.Errorf("email body is required") }
if msg.Body == "" && msg.HTMLBody == "" {
return fmt.Errorf("body or HTMLBody is required")
} }
// 连接SMTP服务器 emailBody, err := e.buildEmailBody(msg, cfg)
addr := fmt.Sprintf("%s:%d", e.config.Host, e.config.Port) if err != nil {
auth := smtp.PlainAuth("", e.config.Username, e.config.Password, e.config.Host) return fmt.Errorf("failed to build email body: %w", err)
}
// 创建连接 recipients := append(msg.To, msg.Cc...)
conn, err := net.DialTimeout("tcp", addr, time.Duration(e.config.Timeout)*time.Second) recipients = append(recipients, msg.Bcc...)
addr := net.JoinHostPort(cfg.Host, fmt.Sprintf("%d", cfg.Port))
auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
conn, err := net.DialTimeout("tcp", addr, time.Duration(cfg.Timeout)*time.Second)
if err != nil { if err != nil {
return fmt.Errorf("failed to connect to SMTP server: %w", err) return fmt.Errorf("failed to connect to SMTP server: %w", err)
} }
defer conn.Close() defer conn.Close()
// 创建SMTP客户端 client, err := smtp.NewClient(conn, cfg.Host)
client, err := smtp.NewClient(conn, e.config.Host)
if err != nil { if err != nil {
return fmt.Errorf("failed to create SMTP client: %w", err) return fmt.Errorf("failed to create SMTP client: %w", err)
} }
defer client.Close() defer client.Close()
// TLS/SSL处理 if cfg.UseSSL || cfg.UseTLS {
if e.config.UseSSL { tlsConfig := &tls.Config{ServerName: cfg.Host}
// SSL模式端口通常是465
tlsConfig := &tls.Config{
ServerName: e.config.Host,
}
if err := client.StartTLS(tlsConfig); err != nil {
return fmt.Errorf("failed to start TLS: %w", err)
}
} else if e.config.UseTLS {
// TLS模式STARTTLS端口通常是587
tlsConfig := &tls.Config{
ServerName: e.config.Host,
}
if err := client.StartTLS(tlsConfig); err != nil { if err := client.StartTLS(tlsConfig); err != nil {
return fmt.Errorf("failed to start TLS: %w", err) return fmt.Errorf("failed to start TLS: %w", err)
} }
} }
// 认证
if err := client.Auth(auth); err != nil { if err := client.Auth(auth); err != nil {
return fmt.Errorf("failed to authenticate: %w", err) return fmt.Errorf("failed to authenticate: %w", err)
} }
if err := client.Mail(cfg.From); err != nil {
// 设置发件人
if err := client.Mail(e.config.From); err != nil {
return fmt.Errorf("failed to set sender: %w", err) return fmt.Errorf("failed to set sender: %w", err)
} }
// 设置收件人
for _, to := range recipients { for _, to := range recipients {
if err := client.Rcpt(to); err != nil { if err := client.Rcpt(to); err != nil {
return fmt.Errorf("failed to set recipient %s: %w", to, err) return fmt.Errorf("failed to set recipient %s: %w", to, err)
} }
} }
// 发送邮件内容
writer, err := client.Data() writer, err := client.Data()
if err != nil { if err != nil {
return fmt.Errorf("failed to get data writer: %w", err) return fmt.Errorf("failed to get data writer: %w", err)
} }
if _, err = writer.Write(emailBody); err != nil {
_, err = writer.Write(body) _ = writer.Close()
if err != nil {
writer.Close()
return fmt.Errorf("failed to write email body: %w", err) return fmt.Errorf("failed to write email body: %w", err)
} }
if err = writer.Close(); err != nil {
err = writer.Close()
if err != nil {
return fmt.Errorf("failed to close writer: %w", err) return fmt.Errorf("failed to close writer: %w", err)
} }
// 退出
if err := client.Quit(); err != nil { if err := client.Quit(); err != nil {
return fmt.Errorf("failed to quit: %w", err) return fmt.Errorf("failed to quit: %w", err)
} }
return nil return nil
} }
// Send 发送邮件使用Message结构内部会构建邮件内容 func (e *Email) buildEmailBody(msg *Message, cfg *config.EmailConfig) ([]byte, error) {
// 注意如果需要完全控制邮件内容请使用SendRaw方法
func (e *Email) Send(msg *Message) error {
if msg == nil {
return fmt.Errorf("message is nil")
}
if len(msg.To) == 0 {
return fmt.Errorf("recipients are required")
}
if msg.Subject == "" {
return fmt.Errorf("subject is required")
}
if msg.Body == "" && msg.HTMLBody == "" {
return fmt.Errorf("body or HTMLBody is required")
}
// 构建邮件内容
emailBody, err := e.buildEmailBody(msg)
if err != nil {
return fmt.Errorf("failed to build email body: %w", err)
}
// 合并收件人列表
recipients := append(msg.To, msg.Cc...)
recipients = append(recipients, msg.Bcc...)
// 使用SendRaw发送
return e.SendRaw(recipients, emailBody)
}
// buildEmailBody 构建邮件内容
func (e *Email) buildEmailBody(msg *Message) ([]byte, error) {
var buf bytes.Buffer var buf bytes.Buffer
from := cfg.From
// 邮件头 if cfg.FromName != "" {
from := e.config.From from = fmt.Sprintf("%s <%s>", cfg.FromName, cfg.From)
if e.config.FromName != "" {
from = fmt.Sprintf("%s <%s>", e.config.FromName, e.config.From)
} }
buf.WriteString(fmt.Sprintf("From: %s\r\n", from)) buf.WriteString(fmt.Sprintf("From: %s\r\n", from))
// 收件人
buf.WriteString(fmt.Sprintf("To: %s\r\n", joinEmails(msg.To))) buf.WriteString(fmt.Sprintf("To: %s\r\n", joinEmails(msg.To)))
// 抄送
if len(msg.Cc) > 0 { if len(msg.Cc) > 0 {
buf.WriteString(fmt.Sprintf("Cc: %s\r\n", joinEmails(msg.Cc))) buf.WriteString(fmt.Sprintf("Cc: %s\r\n", joinEmails(msg.Cc)))
} }
// 主题
buf.WriteString(fmt.Sprintf("Subject: %s\r\n", msg.Subject)) buf.WriteString(fmt.Sprintf("Subject: %s\r\n", msg.Subject))
// 内容类型
if msg.HTMLBody != "" { if msg.HTMLBody != "" {
// 多部分邮件HTML + 纯文本)
boundary := "----=_Part_" + fmt.Sprint(time.Now().UnixNano()) boundary := "----=_Part_" + fmt.Sprint(time.Now().UnixNano())
buf.WriteString("MIME-Version: 1.0\r\n") buf.WriteString("MIME-Version: 1.0\r\n")
buf.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary)) buf.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n\r\n", boundary))
buf.WriteString("\r\n") buf.WriteString("--" + boundary + "\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n")
buf.WriteString(msg.Body + "\r\n")
// 纯文本部分 buf.WriteString("--" + boundary + "\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n")
buf.WriteString("--" + boundary + "\r\n") buf.WriteString(msg.HTMLBody + "\r\n")
buf.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n")
buf.WriteString("\r\n")
buf.WriteString(msg.Body)
buf.WriteString("\r\n")
// HTML部分
buf.WriteString("--" + boundary + "\r\n")
buf.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n")
buf.WriteString("\r\n")
buf.WriteString(msg.HTMLBody)
buf.WriteString("\r\n")
buf.WriteString("--" + boundary + "--\r\n") buf.WriteString("--" + boundary + "--\r\n")
} else { } else {
// 纯文本邮件 buf.WriteString("Content-Type: text/plain; charset=UTF-8\r\n\r\n")
buf.WriteString("Content-Type: text/plain; charset=UTF-8\r\n") buf.WriteString(msg.Body + "\r\n")
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n")
buf.WriteString("\r\n")
buf.WriteString(msg.Body)
buf.WriteString("\r\n")
} }
return buf.Bytes(), nil return buf.Bytes(), nil
} }
// joinEmails 连接邮箱地址
func joinEmails(emails []string) string { func joinEmails(emails []string) string {
if len(emails) == 0 { if len(emails) == 0 {
return "" return ""
@@ -280,27 +278,3 @@ func joinEmails(emails []string) string {
} }
return result return result
} }
// SendSimple 发送简单邮件(便捷方法)
// to: 收件人
// subject: 主题
// body: 正文
func (e *Email) SendSimple(to []string, subject, body string) error {
return e.Send(&Message{
To: to,
Subject: subject,
Body: body,
})
}
// SendHTML 发送HTML邮件便捷方法
// to: 收件人
// subject: 主题
// htmlBody: HTML正文
func (e *Email) SendHTML(to []string, subject, htmlBody string) error {
return e.Send(&Message{
To: to,
Subject: subject,
HTMLBody: htmlBody,
})
}

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

@@ -1,3 +1,6 @@
//go:build example
// +build example
package main package main
import ( import (
@@ -65,16 +68,27 @@ func main() {
// 4. 使用CORS配置 // 4. 使用CORS配置
fmt.Println("\n=== CORS Config ===") fmt.Println("\n=== CORS Config ===")
corsConfig := cfg.GetCORS() configCORS := cfg.GetCORS()
if corsConfig != nil { if configCORS != nil {
fmt.Printf("Allowed Origins: %v\n", corsConfig.AllowedOrigins) fmt.Printf("Allowed Origins: %v\n", configCORS.AllowedOrigins)
fmt.Printf("Allowed Methods: %v\n", corsConfig.AllowedMethods) fmt.Printf("Allowed Methods: %v\n", configCORS.AllowedMethods)
fmt.Printf("Max Age: %d\n", corsConfig.MaxAge) fmt.Printf("Max Age: %d\n", configCORS.MaxAge)
} }
// 使用CORS配置创建中间件 // 使用CORS配置创建中间件
var middlewareCORS *middleware.CORSConfig
if configCORS != nil {
middlewareCORS = middleware.NewCORSConfig(
configCORS.AllowedOrigins,
configCORS.AllowedMethods,
configCORS.AllowedHeaders,
configCORS.ExposedHeaders,
configCORS.AllowCredentials,
configCORS.MaxAge,
)
}
chain := middleware.NewChain( chain := middleware.NewChain(
middleware.CORS(corsConfig), middleware.CORS(middlewareCORS),
) )
fmt.Printf("CORS middleware created: %v\n", chain != nil) fmt.Printf("CORS middleware created: %v\n", chain != nil)

View File

@@ -1,56 +0,0 @@
package main
import (
"fmt"
"log"
"time"
"git.toowon.com/jimmy/go-common/datetime"
)
func main() {
// 设置默认时区
err := datetime.SetDefaultTimeZone(datetime.AsiaShanghai)
if err != nil {
log.Fatal(err)
}
// 获取当前时间
now := datetime.Now()
fmt.Printf("Current time: %s\n", datetime.FormatDateTime(now))
// 解析时间字符串
t, err := datetime.ParseDateTime("2024-01-01 12:00:00")
if err != nil {
log.Fatal(err)
}
fmt.Printf("Parsed time: %s\n", datetime.FormatDateTime(t))
// 时区转换
t2, err := datetime.ToTimezone(t, datetime.AmericaNewYork)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Time in New York: %s\n", datetime.FormatDateTime(t2))
// Unix时间戳
unix := datetime.ToUnix(now)
fmt.Printf("Unix timestamp: %d\n", unix)
t3 := datetime.FromUnix(unix)
fmt.Printf("From Unix: %s\n", datetime.FormatDateTime(t3))
// 时间计算
tomorrow := datetime.AddDays(now, 1)
fmt.Printf("Tomorrow: %s\n", datetime.FormatDate(tomorrow))
// 时间范围
startOfDay := datetime.StartOfDay(now)
endOfDay := datetime.EndOfDay(now)
fmt.Printf("Start of day: %s\n", datetime.FormatDateTime(startOfDay))
fmt.Printf("End of day: %s\n", datetime.FormatDateTime(endOfDay))
// 时间差
diff := datetime.DiffDays(now, tomorrow)
fmt.Printf("Days difference: %d\n", diff)
}

View File

@@ -1,74 +0,0 @@
package main
import (
"fmt"
"log"
"time"
"git.toowon.com/jimmy/go-common/datetime"
)
func main() {
// 示例1将当前时间转换为UTC
fmt.Println("=== Example 1: Convert Current Time to UTC ===")
now := time.Now()
utcTime := datetime.ToUTC(now)
fmt.Printf("Local time: %s\n", datetime.FormatDateTime(now))
fmt.Printf("UTC time: %s\n", datetime.FormatDateTime(utcTime))
// 示例2从指定时区转换为UTC
fmt.Println("\n=== Example 2: Convert from Specific Timezone to UTC ===")
// 解析上海时区的时间
shanghaiTime, err := datetime.ParseDateTime("2024-01-01 12:00:00", datetime.AsiaShanghai)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Shanghai time: %s\n", datetime.FormatDateTime(shanghaiTime, datetime.AsiaShanghai))
// 转换为UTC
utcTime2, err := datetime.ToUTCFromTimezone(shanghaiTime, datetime.AsiaShanghai)
if err != nil {
log.Fatal(err)
}
fmt.Printf("UTC time: %s\n", datetime.FormatDateTime(utcTime2, datetime.UTC))
// 示例3解析时间字符串并直接转换为UTC
fmt.Println("\n=== Example 3: Parse and Convert to UTC ===")
utcTime3, err := datetime.ParseDateTimeToUTC("2024-01-01 12:00:00", datetime.AsiaShanghai)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Parsed from Shanghai timezone, UTC: %s\n", datetime.FormatDateTime(utcTime3, datetime.UTC))
// 示例4解析日期并转换为UTC
fmt.Println("\n=== Example 4: Parse Date and Convert to UTC ===")
utcTime4, err := datetime.ParseDateToUTC("2024-01-01", datetime.AsiaShanghai)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Date parsed from Shanghai timezone, UTC: %s\n", datetime.FormatDateTime(utcTime4, datetime.UTC))
// 示例5数据库存储场景
fmt.Println("\n=== Example 5: Database Storage Scenario ===")
// 从请求中获取时间(假设是上海时区)
requestTimeStr := "2024-01-01 12:00:00"
requestTimezone := datetime.AsiaShanghai
// 转换为UTC时间用于数据库存储
dbTime, err := datetime.ParseDateTimeToUTC(requestTimeStr, requestTimezone)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Request time (Shanghai): %s\n", requestTimeStr)
fmt.Printf("Database time (UTC): %s\n", datetime.FormatDateTime(dbTime, datetime.UTC))
// 从数据库读取UTC时间转换为用户时区显示
userTimezone := datetime.AsiaShanghai
displayTime, err := datetime.ToTimezone(dbTime, userTimezone)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Display time (Shanghai): %s\n", datetime.FormatDateTime(displayTime, userTimezone))
}

8
examples/doc.go Normal file
View File

@@ -0,0 +1,8 @@
// Package examples contains build-tagged example programs.
//
// 所有示例程序默认不参与 `go test ./...` 编译,避免多个 main 冲突。
//
// 运行示例:
// go run -tags example ./examples/storage_example.go
package examples

View File

@@ -1,3 +1,6 @@
//go:build example
// +build example
package main package main
import ( import (
@@ -5,117 +8,35 @@ import (
"log" "log"
"git.toowon.com/jimmy/go-common/config" "git.toowon.com/jimmy/go-common/config"
"git.toowon.com/jimmy/go-common/email" "git.toowon.com/jimmy/go-common/factory"
) )
func main() { func main() {
// 加载配置
cfg, err := config.LoadFromFile("./config/example.json") cfg, err := config.LoadFromFile("./config/example.json")
if err != nil { if err != nil {
log.Fatal("Failed to load config:", err) log.Fatal(err)
} }
// 创建邮件发送器 app := factory.New(cfg)
emailConfig := cfg.GetEmail() mail, err := app.Email()
if emailConfig == nil {
log.Fatal("Email config is nil")
}
mailer, err := email.NewEmail(emailConfig)
if err != nil { if err != nil {
log.Fatal("Failed to create email client:", err) log.Fatal(err)
} }
defer mail.Close()
// 示例1发送原始邮件内容推荐最灵活 // 同步发送(验证码等需等待结果
fmt.Println("=== Example 1: Send Raw Email Content ===") err = mail.SendEmail(
// 外部构建完整的邮件内容MIME格式
emailBody := []byte(`From: ` + emailConfig.From + `
To: recipient@example.com
Subject: 原始邮件测试
Content-Type: text/html; charset=UTF-8
<html>
<body>
<h1>这是原始邮件内容</h1>
<p>由外部完全控制邮件格式和内容</p>
</body>
</html>
`)
err = mailer.SendRaw(
[]string{"recipient@example.com"},
emailBody,
)
if err != nil {
log.Printf("Failed to send raw email: %v", err)
} else {
fmt.Println("Raw email sent successfully")
}
// 示例2发送简单邮件便捷方法
fmt.Println("\n=== Example 2: Send Simple Email ===")
err = mailer.SendSimple(
[]string{"recipient@example.com"}, []string{"recipient@example.com"},
"测试邮件", "测试邮件",
"这是一封测试邮件使用Go标准库发送。", "这是一封测试邮件。",
) )
if err != nil { if err != nil {
log.Printf("Failed to send email: %v", err) log.Printf("sync send failed: %v", err)
} else { } else {
fmt.Println("Email sent successfully") fmt.Println("sync email sent")
} }
// 示例3发送HTML邮件 // 异步发送HTTP 通知类)
fmt.Println("\n=== Example 3: Send HTML Email ===") mail.SendEmailAsync(nil, []string{"recipient@example.com"}, "异步通知", "后台发送,不阻塞请求")
htmlBody := ` fmt.Println("async email enqueued")
<html>
<head>
<meta charset="UTF-8">
</head>
<body>
<h1>欢迎使用邮件服务</h1>
<p>这是一封HTML格式的邮件。</p>
<p>支持<strong>富文本</strong>格式。</p>
</body>
</html>
`
err = mailer.SendHTML(
[]string{"recipient@example.com"},
"HTML邮件测试",
htmlBody,
)
if err != nil {
log.Printf("Failed to send HTML email: %v", err)
} else {
fmt.Println("HTML email sent successfully")
}
// 示例4发送完整邮件包含抄送、密送
fmt.Println("\n=== Example 4: Send Full Email ===")
msg := &email.Message{
To: []string{"to@example.com"},
Cc: []string{"cc@example.com"},
Bcc: []string{"bcc@example.com"},
Subject: "完整邮件示例",
Body: "这是纯文本正文",
HTMLBody: `
<html>
<body>
<h1>这是HTML正文</h1>
<p>支持同时发送纯文本和HTML版本。</p>
</body>
</html>
`,
}
err = mailer.Send(msg)
if err != nil {
log.Printf("Failed to send full email: %v", err)
} else {
fmt.Println("Full email sent successfully")
}
fmt.Println("\nNote: Make sure your email configuration is correct and SMTP service is enabled.")
} }

69
examples/excel_example.go Normal file
View File

@@ -0,0 +1,69 @@
//go:build example
// +build example
package main
import (
"fmt"
"log"
"os"
"time"
"git.toowon.com/jimmy/go-common/excel"
"git.toowon.com/jimmy/go-common/factory"
"git.toowon.com/jimmy/go-common/tools"
)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
CreatedAt time.Time `json:"created_at"`
Status int `json:"status"`
}
func main() {
app := factory.New(nil)
ex := app.Excel()
users := []User{
{ID: 1, Name: "Alice", Email: "alice@example.com", CreatedAt: time.Now(), Status: 1},
{ID: 2, Name: "Bob", Email: "bob@example.com", CreatedAt: time.Now(), Status: 0},
}
columns := []excel.ExportColumn{
{Header: "ID", Field: "ID", Width: 10},
{Header: "姓名", Field: "Name", Width: 20},
{Header: "邮箱", Field: "Email", Width: 30},
{
Header: "创建时间",
Field: "CreatedAt",
Format: excel.AdaptTimeFormatter(tools.FormatDateTime),
},
{
Header: "状态",
Field: "Status",
Format: func(value interface{}) string {
if status, ok := value.(int); ok && status == 1 {
return "启用"
}
return "禁用"
},
},
}
if err := ex.ExportToFile("users.xlsx", "用户列表", columns, users); err != nil {
log.Fatal(err)
}
fmt.Println("exported users.xlsx")
f, err := os.Create("users_http.xlsx")
if err != nil {
log.Fatal(err)
}
defer f.Close()
if err := ex.ExportToWriter(f, "用户列表", columns, users); err != nil {
log.Fatal(err)
}
fmt.Println("exported users_http.xlsx")
}

View File

@@ -1,186 +0,0 @@
package main
import (
"context"
"fmt"
"log"
"os"
"time"
"git.toowon.com/jimmy/go-common/factory"
"github.com/redis/go-redis/v9"
)
func main() {
// 方式1直接从配置文件创建工厂推荐
fac, err := factory.NewFactoryFromFile("./config/example.json")
if err != nil {
log.Fatal("Failed to create factory:", err)
}
ctx := context.Background()
// ========== 日志记录(黑盒模式,推荐) ==========
fac.LogInfo("应用启动")
fac.LogDebug("调试信息: %s", "test")
fac.LogWarn("警告信息")
fac.LogError("错误信息: %v", fmt.Errorf("test error"))
// 带字段的日志
fac.LogInfof(map[string]interface{}{
"user_id": 123,
"ip": "192.168.1.1",
}, "用户登录成功")
fac.LogErrorf(map[string]interface{}{
"error_code": 1001,
"user_id": 123,
}, "登录失败: %v", fmt.Errorf("invalid password"))
// ========== 邮件发送(黑盒模式,推荐) ==========
err = fac.SendEmail(
[]string{"user@example.com"},
"验证码",
"您的验证码是123456",
)
if err != nil {
fac.LogError("发送邮件失败: %v", err)
} else {
fac.LogInfo("邮件发送成功")
}
// HTML邮件
err = fac.SendEmail(
[]string{"user@example.com"},
"欢迎",
"纯文本内容",
"<h1>HTML内容</h1>",
)
if err != nil {
fac.LogError("发送HTML邮件失败: %v", err)
}
// ========== 短信发送(黑盒模式,推荐) ==========
resp, err := fac.SendSMS(
[]string{"13800138000"},
map[string]string{"code": "123456"},
)
if err != nil {
fac.LogError("发送短信失败: %v", err)
} else {
fac.LogInfo("短信发送成功: %s", resp.RequestID)
}
// 指定模板代码
resp, err = fac.SendSMS(
[]string{"13800138000"},
map[string]string{"code": "123456"},
"SMS_123456789", // 模板代码
)
if err != nil {
fac.LogError("发送短信失败: %v", err)
}
// ========== 文件上传黑盒模式推荐自动选择OSS或MinIO ==========
file, err := os.Open("test.jpg")
if err == nil {
defer file.Close()
url, err := fac.UploadFile(ctx, "images/test.jpg", file, "image/jpeg")
if err != nil {
fac.LogError("上传文件失败: %v", err)
} else {
fac.LogInfo("文件上传成功: %s", url)
}
}
// ========== 获取文件URL黑盒模式 ==========
// 永久有效
url, err := fac.GetFileURL("images/test.jpg", 0)
if err != nil {
fac.LogError("获取文件URL失败: %v", err)
} else {
fac.LogInfo("文件URL: %s", url)
}
// 临时访问URL1小时后过期
url, err = fac.GetFileURL("images/test.jpg", 3600)
if err != nil {
fac.LogError("获取临时URL失败: %v", err)
} else {
fac.LogInfo("临时URL: %s", url)
}
// ========== Redis操作黑盒模式推荐 ==========
// 设置值(不过期)
err = fac.RedisSet(ctx, "user:123", "value")
if err != nil {
fac.LogError("Redis设置失败: %v", err)
}
// 设置值(带过期时间)
err = fac.RedisSet(ctx, "user:123", "value", time.Hour)
if err != nil {
fac.LogError("Redis设置失败: %v", err)
}
// 获取值
value, err := fac.RedisGet(ctx, "user:123")
if err != nil {
fac.LogError("Redis获取失败: %v", err)
} else {
fac.LogInfo("Redis值: %s", value)
}
// 删除键
err = fac.RedisDelete(ctx, "user:123", "user:456")
if err != nil {
fac.LogError("Redis删除失败: %v", err)
}
// 检查键是否存在
exists, err := fac.RedisExists(ctx, "user:123")
if err != nil {
fac.LogError("Redis检查失败: %v", err)
} else {
fac.LogInfo("键是否存在: %v", exists)
}
// ========== 数据库操作(黑盒模式,获取对象) ==========
db, err := fac.GetDatabase()
if err != nil {
fac.LogError("数据库连接失败: %v", err)
} else {
// 直接使用GORM无需自己实现创建逻辑
var count int64
if err := db.Table("users").Count(&count).Error; err != nil {
fac.LogError("查询用户数量失败: %v", err)
} else {
fac.LogInfo("用户数量: %d", count)
}
}
// ========== Redis操作获取客户端对象黑盒模式 ==========
redisClient, err := fac.GetRedisClient()
if err != nil {
fac.LogError("Redis客户端不可用: %v", err)
} else {
// 直接使用Redis客户端无需自己实现创建逻辑
val, err := redisClient.Get(ctx, "test_key").Result()
if err != nil && err != redis.Nil {
fac.LogError("Redis错误: %v", err)
} else if err == redis.Nil {
fac.LogInfo("Redis键不存在")
} else {
fac.LogInfo("Redis值: %s", val)
}
// 使用高级功能如Hash操作
redisClient.HSet(ctx, "user:123", "name", "John")
name, _ := redisClient.HGet(ctx, "user:123", "name").Result()
fac.LogInfo("Redis Hash值: %s", name)
}
fac.LogInfo("示例执行完成")
}

View File

@@ -1,109 +1,49 @@
//go:build example
// +build example
package main package main
import ( import (
"log" "log"
"net/http" "net/http"
"git.toowon.com/jimmy/go-common/factory"
commonhttp "git.toowon.com/jimmy/go-common/http" commonhttp "git.toowon.com/jimmy/go-common/http"
) )
// 用户结构
type User struct { type User struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Email string `json:"email"` Email string `json:"email"`
} }
// 获取用户列表使用Handler黑盒模式 func main() {
func GetUserList(h *commonhttp.Handler) { if err := factory.Init("config.json"); err != nil {
// 获取分页参数(简洁方式) log.Fatal(err)
pagination := h.ParsePaginationRequest() }
page := pagination.GetPage() app := factory.Default()
pageSize := pagination.GetSize() chain := app.MiddlewareChain()
// 获取查询参数(简洁方式) http.Handle("/users", chain.ThenFunc(listUsers))
_ = h.GetQuery("keyword", "") // 示例:获取查询参数 log.Fatal(http.ListenAndServe(":8080", nil))
}
func listUsers(w http.ResponseWriter, r *http.Request) {
app := factory.Default()
h := app.NewHandler(w, r)
var req struct {
Keyword string `json:"keyword"`
commonhttp.PaginationRequest
}
if err := h.ParseJSON(&req); err != nil {
h.Error("common.invalid_request")
return
}
// 模拟查询数据
users := []User{ users := []User{
{ID: 1, Name: "User1", Email: "user1@example.com"}, {ID: 1, Name: "User1", Email: "user1@example.com"},
{ID: 2, Name: "User2", Email: "user2@example.com"}, {ID: 2, Name: "User2", Email: "user2@example.com"},
} }
total := int64(100) h.SuccessPage(users, 100)
// 返回分页响应(简洁方式)
h.SuccessPage(users, total, page, pageSize)
} }
// 创建用户使用Handler黑盒模式
func CreateUser(h *commonhttp.Handler) {
// 解析请求体(简洁方式)
var req struct {
Name string `json:"name"`
Email string `json:"email"`
}
if err := h.ParseJSON(&req); err != nil {
h.WriteJSON(http.StatusBadRequest, 400, "请求参数解析失败", nil)
return
}
// 参数验证
if req.Name == "" {
h.Error(1001, "用户名不能为空")
return
}
// 模拟创建用户
user := User{
ID: 1,
Name: req.Name,
Email: req.Email,
}
// 返回成功响应(简洁方式)
h.SuccessWithMessage("创建成功", user)
}
// 获取用户详情使用Handler黑盒模式
func GetUser(h *commonhttp.Handler) {
// 获取查询参数(简洁方式)
id := h.GetQueryInt64("id", 0)
if id == 0 {
h.WriteJSON(http.StatusBadRequest, 400, "用户ID不能为空", nil)
return
}
// 模拟查询用户
if id == 1 {
user := User{ID: 1, Name: "User1", Email: "user1@example.com"}
h.Success(user)
} else {
h.Error(1002, "用户不存在")
}
}
func main() {
// 方式1使用HandleFunc包装器推荐最简洁
http.HandleFunc("/users", commonhttp.HandleFunc(func(h *commonhttp.Handler) {
switch h.Request().Method {
case http.MethodGet:
GetUserList(h)
case http.MethodPost:
CreateUser(h)
default:
h.WriteJSON(http.StatusMethodNotAllowed, 405, "方法不支持", nil)
}
}))
// 方式2手动创建Handler需要更多控制时
http.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) {
h := commonhttp.NewHandler(w, r)
GetUser(h)
})
log.Println("Server started on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}

View File

@@ -1,61 +1,57 @@
//go:build example
// +build example
package main package main
import ( import (
"log" "log"
"net/http" "net/http"
"git.toowon.com/jimmy/go-common/factory"
commonhttp "git.toowon.com/jimmy/go-common/http" commonhttp "git.toowon.com/jimmy/go-common/http"
) )
// ListUserRequest 用户列表请求(包含分页字段)
type ListUserRequest struct { type ListUserRequest struct {
Keyword string `json:"keyword"` Keyword string `json:"keyword"`
commonhttp.PaginationRequest // 嵌入分页请求结构 commonhttp.PaginationRequest
} }
// User 用户结构
type User struct { type User struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Email string `json:"email"` Email string `json:"email"`
} }
// 获取用户列表使用Handler和PaginationRequest func main() {
func GetUserList(h *commonhttp.Handler) { if err := factory.Init("config.json"); err != nil {
var req ListUserRequest log.Fatal(err)
}
app := factory.Default()
chain := app.MiddlewareChain()
http.Handle("/users", chain.ThenFunc(listUsers))
log.Fatal(http.ListenAndServe(":8080", nil))
}
// 方式1从JSON请求体解析分页字段会自动解析 func listUsers(w http.ResponseWriter, r *http.Request) {
if h.Request().Method == http.MethodPost { app := factory.Default()
i18n, _ := app.I18n()
h := commonhttp.NewHandler(w, r, commonhttp.WithI18n(i18n))
var req ListUserRequest
if r.Method == http.MethodPost {
if err := h.ParseJSON(&req); err != nil { if err := h.ParseJSON(&req); err != nil {
h.WriteJSON(http.StatusBadRequest, 400, "请求参数解析失败", nil) h.Error("common.invalid_request")
return return
} }
} else { } else {
// 方式2从查询参数解析分页 p := h.Pagination()
pagination := h.ParsePaginationRequest() req.PaginationRequest = *p
req.PaginationRequest = *pagination req.Keyword = r.URL.Query().Get("keyword")
req.Keyword = h.GetQuery("keyword", "")
} }
// 使用分页方法
page := req.GetPage() // 获取页码默认1
size := req.GetSize() // 获取每页数量默认20最大100
_ = req.GetOffset() // 计算偏移量
// 模拟查询数据
users := []User{ users := []User{
{ID: 1, Name: "User1", Email: "user1@example.com"}, {ID: 1, Name: "User1", Email: "user1@example.com"},
{ID: 2, Name: "User2", Email: "user2@example.com"}, {ID: 2, Name: "User2", Email: "user2@example.com"},
} }
total := int64(100) h.SuccessPage(users, 100)
// 返回分页响应
h.SuccessPage(users, total, page, size)
}
func main() {
http.HandleFunc("/users", commonhttp.HandleFunc(GetUserList))
log.Println("Server started on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
} }

32
examples/i18n_example.go Normal file
View File

@@ -0,0 +1,32 @@
//go:build example
// +build example
package main
import (
"fmt"
"log"
"git.toowon.com/jimmy/go-common/factory"
)
func main() {
if err := factory.Init("config.json"); err != nil {
log.Fatal(err)
}
app := factory.Default()
i18n, err := app.I18n()
if err != nil {
log.Fatal(err)
}
if err := i18n.LoadFromDir("locales"); err != nil {
log.Fatal(err)
}
fmt.Println("zh-CN:", i18n.GetMessage("zh-CN", "user.not_found"))
fmt.Println("en-US:", i18n.GetMessage("en-US", "user.not_found"))
fmt.Println("welcome:", i18n.GetMessage("zh-CN", "user.welcome", "Alice"))
fmt.Println("langs:", i18n.GetSupportedLangs())
}

View File

@@ -0,0 +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"
},
"system.internal_error": {
"code": 100003,
"message": "Internal server error"
}
}

View File

@@ -0,0 +1,18 @@
{
"common.success": {
"code": 0,
"message": "成功"
},
"common.invalid_request": {
"code": 100001,
"message": "请求无效"
},
"user.not_found": {
"code": 100002,
"message": "用户不存在"
},
"system.internal_error": {
"code": 100003,
"message": "服务器错误"
}
}

View File

@@ -1,3 +1,6 @@
//go:build example
// +build example
package main package main
import ( import (
@@ -8,45 +11,24 @@ import (
) )
func main() { func main() {
// 加载配置
cfg, err := config.LoadFromFile("./config/example.json") cfg, err := config.LoadFromFile("./config/example.json")
if err != nil { if err != nil {
log.Fatal("Failed to load config:", err) log.Fatal(err)
} }
// 使用工厂创建日志记录器(推荐方式) app := factory.New(cfg)
fac := factory.NewFactory(cfg) logInst, err := app.Logger()
logger, err := fac.GetLogger()
if err != nil { if err != nil {
log.Fatal("Failed to create logger:", err) log.Fatal(err)
} }
defer logInst.Close()
// 示例1基本日志记录 logInst.Info("Application started", nil)
logger.Info("Application started") logInst.Info("User logged in", map[string]any{
logger.Debug("Debug message: %s", "This is a debug message")
logger.Warn("Warning message: %s", "This is a warning")
logger.Error("Error message: %s", "This is an error")
// 示例2带字段的日志记录
logger.Infof(map[string]interface{}{
"user_id": 123, "user_id": 123,
"action": "login", "action": "login",
"ip": "192.168.1.1", })
}, "User logged in successfully") logInst.Error("Failed to connect to database", map[string]any{
"error": "connection timeout",
logger.Errorf(map[string]interface{}{ })
"error_code": 1001,
"module": "database",
}, "Failed to connect to database: %v", "connection timeout")
// 示例3不同级别的日志
logger.Debug("This is a debug log")
logger.Info("This is an info log")
logger.Warn("This is a warn log")
logger.Error("This is an error log")
// 注意Fatal和Panic会终止程序示例中不执行
// logger.Fatal("This would exit the program")
// logger.Panic("This would panic")
} }

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

@@ -1,66 +0,0 @@
package main
import (
"log"
"net/http"
"git.toowon.com/jimmy/go-common/datetime"
commonhttp "git.toowon.com/jimmy/go-common/http"
"git.toowon.com/jimmy/go-common/middleware"
)
// 示例使用CORS和时区中间件
func main() {
// 配置CORS
corsConfig := &middleware.CORSConfig{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{
"Content-Type",
"Authorization",
"X-Requested-With",
"X-Timezone",
},
AllowCredentials: false,
MaxAge: 3600,
}
// 创建中间件链
chain := middleware.NewChain(
middleware.CORS(corsConfig),
middleware.Timezone,
)
// 定义处理器使用Handler模式
handler := chain.ThenFunc(func(w http.ResponseWriter, r *http.Request) {
h := commonhttp.NewHandler(w, r)
apiHandler(h)
})
// 注册路由
http.Handle("/api", handler)
log.Println("Server started on :8080")
log.Println("Try: curl -H 'X-Timezone: America/New_York' http://localhost:8080/api")
log.Fatal(http.ListenAndServe(":8080", nil))
}
// apiHandler 处理API请求使用Handler模式
func apiHandler(h *commonhttp.Handler) {
// 从Handler获取时区
timezone := h.GetTimezone()
// 使用时区进行时间处理
now := datetime.Now(timezone)
startOfDay := datetime.StartOfDay(now, timezone)
endOfDay := datetime.EndOfDay(now, timezone)
// 返回响应
h.Success(map[string]interface{}{
"message": "Hello from API",
"timezone": timezone,
"currentTime": datetime.FormatDateTime(now),
"startOfDay": datetime.FormatDateTime(startOfDay),
"endOfDay": datetime.FormatDateTime(endOfDay),
})
}

View File

@@ -0,0 +1,30 @@
//go:build example
// +build example
package main
import (
"log"
"net/http"
"git.toowon.com/jimmy/go-common/factory"
commonhttp "git.toowon.com/jimmy/go-common/http"
)
func main() {
if err := factory.Init("config.json"); err != nil {
log.Fatal(err)
}
app := factory.Default()
chain := app.MiddlewareChain()
http.Handle("/api/hello", chain.ThenFunc(func(w http.ResponseWriter, r *http.Request) {
h := commonhttp.NewHandler(w, r)
h.Success(map[string]any{
"message": "Hello, World!",
"timezone": h.GetTimezone(),
})
}))
log.Fatal(http.ListenAndServe(":8080", nil))
}

View File

@@ -1,58 +0,0 @@
package main
import (
"fmt"
"log"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"git.toowon.com/jimmy/go-common/migration"
)
func main() {
// 初始化数据库连接
dsn := "user:password@tcp(localhost:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local"
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
log.Fatal(err)
}
// 创建迁移器
migrator := migration.NewMigrator(db)
// 添加迁移
migrator.AddMigration(migration.Migration{
Version: "20240101000001",
Description: "create_users_table",
Up: func(db *gorm.DB) error {
return db.Exec(`
CREATE TABLE IF NOT EXISTS users (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`).Error
},
Down: func(db *gorm.DB) error {
return db.Exec("DROP TABLE IF EXISTS users").Error
},
})
// 执行迁移
if err := migrator.Up(); err != nil {
log.Fatal(err)
}
// 查看迁移状态
status, err := migrator.Status()
if err != nil {
log.Fatal(err)
}
for _, s := range status {
fmt.Printf("Version: %s, Description: %s, Applied: %v\n",
s.Version, s.Description, s.Applied)
}
}

View File

@@ -1,105 +0,0 @@
package main
import (
"fmt"
"log"
"git.toowon.com/jimmy/go-common/migration"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func main() {
// 初始化数据库连接使用SQLite作为示例
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
log.Fatal("Failed to connect to database:", err)
}
// 创建迁移器
migrator := migration.NewMigrator(db)
// 添加一些迁移
migrator.AddMigrations(
migration.Migration{
Version: "20240101000001",
Description: "create_users_table",
Up: func(db *gorm.DB) error {
return db.Exec(`
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL
)
`).Error
},
Down: func(db *gorm.DB) error {
return db.Exec("DROP TABLE IF EXISTS users").Error
},
},
migration.Migration{
Version: "20240101000002",
Description: "add_created_at_to_users",
Up: func(db *gorm.DB) error {
return db.Exec("ALTER TABLE users ADD COLUMN created_at DATETIME").Error
},
Down: func(db *gorm.DB) error {
return db.Exec("ALTER TABLE users DROP COLUMN created_at").Error
},
},
)
// 执行迁移
fmt.Println("=== Executing migrations ===")
err = migrator.Up()
if err != nil {
log.Fatal("Failed to run migrations:", err)
}
// 查看状态
fmt.Println("\n=== Migration status ===")
status, err := migrator.Status()
if err != nil {
log.Fatal("Failed to get status:", err)
}
for _, s := range status {
fmt.Printf("Version: %s, Description: %s, Applied: %v\n",
s.Version, s.Description, s.Applied)
}
// 示例1仅清空迁移记录不回滚数据库变更
fmt.Println("\n=== Example 1: Reset migration records only ===")
fmt.Println("Note: This only clears records, not database changes")
// 直接调用(需要确认标志)
// err = migrator.Reset(true)
// if err != nil {
// log.Fatal("Failed to reset:", err)
// }
// 交互式确认(推荐)
// 取消注释下面的代码来测试交互式重置
// err = migrator.ResetWithConfirm()
// if err != nil {
// log.Fatal("Failed to reset with confirm:", err)
// }
// 示例2回滚所有迁移并清空记录
fmt.Println("\n=== Example 2: Reset all migrations (rollback + clear records) ===")
fmt.Println("Note: This will rollback all migrations and clear records")
// 直接调用(需要确认标志)
// err = migrator.ResetAll(true)
// if err != nil {
// log.Fatal("Failed to reset all:", err)
// }
// 交互式确认(推荐)
// 取消注释下面的代码来测试交互式重置
// err = migrator.ResetAllWithConfirm()
// if err != nil {
// log.Fatal("Failed to reset all with confirm:", err)
// }
fmt.Println("\nNote: Reset functions are commented out for safety.")
fmt.Println("Uncomment the code above to test reset functionality.")
}

View File

@@ -0,0 +1,4 @@
-- Rollback: Drop users table
DROP TABLE IF EXISTS users;

View File

@@ -0,0 +1,14 @@
-- Create users table
-- Created at: 2024-01-01 00:00:01
CREATE TABLE IF NOT EXISTS users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(255) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_username (username),
INDEX idx_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -1,3 +1,6 @@
//go:build example
// +build example
package main package main
import ( import (
@@ -5,98 +8,30 @@ import (
"log" "log"
"git.toowon.com/jimmy/go-common/config" "git.toowon.com/jimmy/go-common/config"
"git.toowon.com/jimmy/go-common/sms" "git.toowon.com/jimmy/go-common/factory"
) )
func main() { func main() {
// 加载配置
cfg, err := config.LoadFromFile("./config/example.json") cfg, err := config.LoadFromFile("./config/example.json")
if err != nil { if err != nil {
log.Fatal("Failed to load config:", err) log.Fatal(err)
} }
// 创建短信发送器 app := factory.New(cfg)
smsConfig := cfg.GetSMS() smsClient, err := app.SMS()
if smsConfig == nil {
log.Fatal("SMS config is nil")
}
smsClient, err := sms.NewSMS(smsConfig)
if err != nil { if err != nil {
log.Fatal("Failed to create SMS client:", err) log.Fatal(err)
} }
defer smsClient.Close()
// 示例1发送原始请求推荐最灵活 params := map[string]string{"code": "123456"}
fmt.Println("=== Example 1: Send Raw SMS Request ===") resp, err := smsClient.SendSMS([]string{"13800138000"}, params)
// 外部构建完整的请求参数
params := map[string]string{
"PhoneNumbers": "13800138000",
"SignName": smsConfig.SignName,
"TemplateCode": smsConfig.TemplateCode,
"TemplateParam": `{"code":"123456","expire":"5"}`,
}
resp, err := smsClient.SendRaw(params)
if err != nil { if err != nil {
log.Printf("Failed to send raw SMS: %v", err) log.Printf("sync send failed: %v", err)
} else { } else {
fmt.Printf("Raw SMS sent successfully, RequestID: %s\n", resp.RequestID) fmt.Printf("sync sms sent, RequestID: %s\n", resp.RequestID)
} }
// 示例2发送简单短信使用配置中的模板代码 smsClient.SendSMSAsync(nil, []string{"13800138000"}, params)
fmt.Println("\n=== Example 2: Send Simple SMS ===") fmt.Println("async sms enqueued")
templateParam := map[string]string{
"code": "123456",
"expire": "5",
}
resp2, err := smsClient.SendSimple(
[]string{"13800138000"},
templateParam,
)
if err != nil {
log.Printf("Failed to send SMS: %v", err)
} else {
fmt.Printf("SMS sent successfully, RequestID: %s\n", resp2.RequestID)
}
// 示例3使用指定模板发送短信
fmt.Println("\n=== Example 3: Send SMS with Template ===")
templateParam3 := map[string]string{
"code": "654321",
"expire": "10",
}
resp3, err := smsClient.SendWithTemplate(
[]string{"13800138000"},
"SMS_123456789", // 使用指定的模板代码
templateParam3,
)
if err != nil {
log.Printf("Failed to send SMS: %v", err)
} else {
fmt.Printf("SMS sent successfully, RequestID: %s\n", resp3.RequestID)
}
// 示例4使用JSON字符串作为模板参数
fmt.Println("\n=== Example 4: Send SMS with JSON String Template Param ===")
req := &sms.SendRequest{
PhoneNumbers: []string{"13800138000"},
TemplateCode: smsConfig.TemplateCode,
TemplateParam: `{"code":"888888","expire":"15"}`, // 直接使用JSON字符串
}
resp4, err := smsClient.Send(req)
if err != nil {
log.Printf("Failed to send SMS: %v", err)
} else {
fmt.Printf("SMS sent successfully, RequestID: %s\n", resp4.RequestID)
}
fmt.Println("\nNote: Make sure your Aliyun SMS service is configured correctly:")
fmt.Println("1. AccessKey ID and Secret are valid")
fmt.Println("2. Sign name is approved")
fmt.Println("3. Template code is approved")
fmt.Println("4. Template parameters match the template definition")
} }

View File

@@ -1,3 +1,6 @@
//go:build example
// +build example
package main package main
import ( import (
@@ -16,28 +19,36 @@ func main() {
log.Fatal("Failed to load config:", err) log.Fatal("Failed to load config:", err)
} }
// 创建存储实例使用OSS // 优先演示本地存储(可直接运行
// 注意需要先实现OSS SDK集成 localStorage, err := storage.NewStorage(storage.StorageTypeLocal, cfg)
ossStorage, err := storage.NewStorage(storage.StorageTypeOSS, cfg)
if err != nil { if err != nil {
log.Printf("Failed to create OSS storage: %v", err) log.Fatal("Failed to create Local storage:", err)
log.Println("Note: OSS SDK integration is required") }
// 继续演示其他功能
} else {
// 创建上传处理器
uploadHandler := storage.NewUploadHandler(storage.UploadHandlerConfig{ uploadHandler := storage.NewUploadHandler(storage.UploadHandlerConfig{
Storage: ossStorage, Storage: localStorage,
MaxFileSize: 10 * 1024 * 1024, // 10MB MaxFileSize: 10 * 1024 * 1024, // 10MB
AllowedExts: []string{".jpg", ".jpeg", ".png", ".gif", ".pdf"}, AllowedExts: []string{".jpg", ".jpeg", ".png", ".gif", ".pdf"},
ObjectPrefix: "uploads/", ObjectPrefix: "uploads/",
}) })
// 创建代理查看处理器 proxyHandler := storage.NewProxyHandler(localStorage)
proxyHandler := storage.NewProxyHandler(ossStorage)
// 创建中间件链 // 创建中间件链
var corsConfig *middleware.CORSConfig
if cfg.GetCORS() != nil {
c := cfg.GetCORS()
corsConfig = middleware.NewCORSConfig(
c.AllowedOrigins,
c.AllowedMethods,
c.AllowedHeaders,
c.ExposedHeaders,
c.AllowCredentials,
c.MaxAge,
)
}
chain := middleware.NewChain( chain := middleware.NewChain(
middleware.CORS(cfg.GetCORS()), middleware.CORS(corsConfig),
middleware.Timezone, middleware.Timezone,
) )
@@ -46,10 +57,13 @@ func main() {
mux.Handle("/upload", chain.Then(uploadHandler)) mux.Handle("/upload", chain.Then(uploadHandler))
mux.Handle("/file", chain.Then(proxyHandler)) mux.Handle("/file", chain.Then(proxyHandler))
log.Println("Storage server started on :8080") log.Println("Local storage server started on :8080")
log.Println("Upload: POST /upload") log.Println("Upload: POST /upload")
log.Println("View: GET /file?key=images/test.jpg") log.Println("View: GET /file?key=uploads/xxx.jpg")
log.Fatal(http.ListenAndServe(":8080", mux))
// 提示OSS 需要你自行集成对应 SDK当前 go-common 中仅提供接口框架)
if _, err := storage.NewStorage(storage.StorageTypeOSS, cfg); err != nil {
log.Printf("OSS storage not ready: %v", err)
} }
// 演示MinIO存储 // 演示MinIO存储
@@ -67,5 +81,6 @@ func main() {
objectKey2 := storage.GenerateObjectKeyWithDate("images", "test.jpg") objectKey2 := storage.GenerateObjectKeyWithDate("images", "test.jpg")
log.Printf("Object key 2: %s", objectKey2) log.Printf("Object key 2: %s", objectKey2)
}
log.Fatal(http.ListenAndServe(":8080", mux))
}

446
excel/excel.go Normal file
View File

@@ -0,0 +1,446 @@
package excel
import (
"fmt"
"io"
"os"
"reflect"
"time"
"git.toowon.com/jimmy/go-common/tools"
"github.com/xuri/excelize/v2"
)
// Excel Excel导出器
type Excel struct {
file *excelize.File
}
// NewExcel 创建Excel导出器
func NewExcel() *Excel {
return &Excel{
file: excelize.NewFile(),
}
}
// ExportColumn 导出列定义
type ExportColumn struct {
// Header 表头名称
Header string
// Field 数据字段名(支持嵌套字段,如 "User.Name"
Field string
// Width 列宽可选0表示自动
Width float64
// Format 格式化函数(可选,用于自定义字段值的格式化)
Format func(value interface{}) string
}
// ExportData 导出数据接口
// 实现此接口的结构体可以直接导出
type ExportData interface {
// GetExportColumns 获取导出列定义
GetExportColumns() []ExportColumn
// GetExportRows 获取导出数据行
GetExportRows() [][]interface{}
}
// ExportToWriter 导出数据到Writer黑盒模式推荐使用
// sheetName: 工作表名称(可选,默认为"Sheet1"
// columns: 列定义
// data: 数据列表可以是结构体切片或实现了ExportData接口的对象
// 返回错误信息
//
// 示例1导出结构体切片
//
// type User struct {
// ID int `json:"id"`
// Name string `json:"name"`
// Email string `json:"email"`
// }
//
// users := []User{
// {ID: 1, Name: "Alice", Email: "alice@example.com"},
// {ID: 2, Name: "Bob", Email: "bob@example.com"},
// }
//
// columns := []ExportColumn{
// {Header: "ID", Field: "ID"},
// {Header: "姓名", Field: "Name"},
// {Header: "邮箱", Field: "Email"},
// }
//
// excel := excel.NewExcel()
// err := excel.ExportToWriter(w, "用户列表", columns, users)
//
// 示例2使用格式化函数
//
// columns := []ExportColumn{
// {Header: "ID", Field: "ID"},
// {Header: "姓名", Field: "Name"},
// {Header: "创建时间", Field: "CreatedAt", Format: func(v interface{}) string {
// if t, ok := v.(time.Time); ok {
// return t.Format("2006-01-02 15:04:05")
// }
// return ""
// }},
// }
//
// excel.ExportToWriter(w, "用户列表", columns, users)
func (e *Excel) ExportToWriter(w io.Writer, sheetName string, columns []ExportColumn, data interface{}) error {
// 每次导出使用新文件,避免同实例多次/并发导出互相污染
e.file = excelize.NewFile()
defer func() {
_ = e.file.Close()
e.file = nil
}()
// 设置工作表名称
if sheetName == "" {
sheetName = "Sheet1"
}
// 检查工作表是否已存在
sheetIndex, err := e.file.GetSheetIndex(sheetName)
if err != nil || sheetIndex == 0 {
// 工作表不存在,需要创建
// 如果sheetName不是"Sheet1",且默认"Sheet1"存在,则删除它
if sheetName != "Sheet1" {
defaultIndex, _ := e.file.GetSheetIndex("Sheet1")
if defaultIndex > 0 {
e.file.DeleteSheet("Sheet1")
}
}
// 创建新工作表
_, err = e.file.NewSheet(sheetName)
if err != nil {
return fmt.Errorf("failed to create sheet: %w", err)
}
// 重新获取工作表索引
sheetIndex, err = e.file.GetSheetIndex(sheetName)
if err != nil {
return fmt.Errorf("failed to get sheet index: %w", err)
}
}
// 设置活动工作表
if sheetIndex > 0 {
e.file.SetActiveSheet(sheetIndex)
}
// 写入表头
headerStyle, _ := e.file.NewStyle(&excelize.Style{
Font: &excelize.Font{
Bold: true,
Size: 12,
},
Fill: excelize.Fill{
Type: "pattern",
Color: []string{"#E0E0E0"},
Pattern: 1,
},
Alignment: &excelize.Alignment{
Horizontal: "center",
Vertical: "center",
},
})
for i, col := range columns {
cell := fmt.Sprintf("%c1", 'A'+i)
e.file.SetCellValue(sheetName, cell, col.Header)
// 设置表头样式
e.file.SetCellStyle(sheetName, cell, cell, headerStyle)
// 设置列宽
if col.Width > 0 {
colName, _ := excelize.ColumnNumberToName(i + 1)
e.file.SetColWidth(sheetName, colName, colName, col.Width)
}
}
// 处理数据
var rows [][]interface{}
// 检查数据是否实现了ExportData接口
if exportData, ok := data.(ExportData); ok {
// 使用接口方法获取数据
rows = exportData.GetExportRows()
// 如果接口返回nil初始化为空切片
if rows == nil {
rows = [][]interface{}{}
}
} else {
// 处理结构体切片包括nil和空切片的情况
rows, err = e.convertDataToRows(data, columns)
if err != nil {
return fmt.Errorf("failed to convert data to rows: %w", err)
}
// 确保rows不为nil
if rows == nil {
rows = [][]interface{}{}
}
}
// 写入数据行
for rowIndex, row := range rows {
for colIndex, value := range row {
cell := fmt.Sprintf("%c%d", 'A'+colIndex, rowIndex+2) // +2 因为第一行是表头
// 应用格式化函数
var cellValue interface{} = value
if colIndex < len(columns) && columns[colIndex].Format != nil {
cellValue = columns[colIndex].Format(value)
}
e.file.SetCellValue(sheetName, cell, cellValue)
}
}
// 自动调整列宽(如果未设置宽度)
for i, col := range columns {
if col.Width == 0 {
colName, _ := excelize.ColumnNumberToName(i + 1)
// 获取列的最大宽度
maxWidth := e.getColumnMaxWidth(sheetName, i+1, len(rows)+1)
if maxWidth > 0 {
e.file.SetColWidth(sheetName, colName, colName, maxWidth)
}
}
}
// 写入到Writer
return e.file.Write(w)
}
// ExportToFile 导出数据到文件
func (e *Excel) ExportToFile(filePath, sheetName string, columns []ExportColumn, data interface{}) error {
f, err := os.Create(filePath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer f.Close()
return e.ExportToWriter(f, sheetName, columns, data)
}
// GetFile 获取Excel文件对象高级功能时使用
// 返回excelize.File对象可用于高级操作
//
// 推荐使用黑盒方法:
// - ExportToWriter()导出到Writer可用于文件、HTTP响应等
//
// 仅在需要使用高级功能时获取对象:
// - 多工作表操作
// - 自定义样式
// - 图表、公式等高级功能
//
// 示例(常用操作,推荐):
//
// excel := excel.NewExcel()
// file, _ := os.Create("users.xlsx")
// defer file.Close()
// excel.ExportToWriter(file, "用户列表", columns, users)
//
// 示例(高级功能):
//
// file := excel.GetFile()
// file.NewSheet("Sheet2")
// file.SetCellValue("Sheet2", "A1", "数据")
func (e *Excel) GetFile() *excelize.File {
if e.file == nil {
e.file = excelize.NewFile()
}
return e.file
}
// convertDataToRows 将数据转换为行数据
// 支持nil、空切片等情况返回空切片而不是错误
func (e *Excel) convertDataToRows(data interface{}, columns []ExportColumn) ([][]interface{}, error) {
// 如果data为nil返回空切片
if data == nil {
return [][]interface{}{}, nil
}
// 使用反射处理数据
val := reflect.ValueOf(data)
if val.Kind() == reflect.Ptr {
// 如果是指针且指向nil返回空切片
if val.IsNil() {
return [][]interface{}{}, nil
}
val = val.Elem()
}
// 如果解引用后仍然是无效值,返回空切片
if !val.IsValid() {
return [][]interface{}{}, nil
}
// 必须是切片类型
if val.Kind() != reflect.Slice {
return nil, fmt.Errorf("data must be a slice, got %v", val.Kind())
}
// 如果是空切片,返回空切片(不返回错误)
if val.Len() == 0 {
return [][]interface{}{}, nil
}
rows := make([][]interface{}, 0, val.Len())
for i := 0; i < val.Len(); i++ {
item := val.Index(i)
if item.Kind() == reflect.Ptr {
// 如果指针指向nil跳过该行或使用空值
if item.IsNil() {
row := make([]interface{}, len(columns))
rows = append(rows, row)
continue
}
item = item.Elem()
}
row := make([]interface{}, len(columns))
for j, col := range columns {
value := e.getFieldValue(item, col.Field)
row[j] = value
}
rows = append(rows, row)
}
return rows, nil
}
// getFieldValue 获取字段值(支持嵌套字段)
func (e *Excel) getFieldValue(v reflect.Value, fieldPath string) interface{} {
if v.Kind() == reflect.Ptr {
v = v.Elem()
}
if v.Kind() != reflect.Struct {
return nil
}
// 处理嵌套字段(如 "User.Name"
fields := splitFieldPath(fieldPath)
current := v
for i, fieldName := range fields {
if current.Kind() == reflect.Ptr {
current = current.Elem()
}
if current.Kind() != reflect.Struct {
return nil
}
field := current.FieldByName(fieldName)
if !field.IsValid() {
return nil
}
// 如果是最后一个字段,返回值
if i == len(fields)-1 {
if field.CanInterface() {
return field.Interface()
}
return nil
}
// 继续嵌套查找
current = field
}
return nil
}
// splitFieldPath 分割字段路径(如 "User.Name" -> ["User", "Name"]
func splitFieldPath(path string) []string {
result := make([]string, 0)
current := ""
for _, char := range path {
if char == '.' {
if current != "" {
result = append(result, current)
current = ""
}
} else {
current += string(char)
}
}
if current != "" {
result = append(result, current)
}
return result
}
// getColumnMaxWidth 获取列的最大宽度(用于自动调整列宽)
func (e *Excel) getColumnMaxWidth(sheetName string, colIndex int, maxRow int) float64 {
maxWidth := 10.0 // 默认最小宽度
for row := 1; row <= maxRow; row++ {
cell, _ := excelize.CoordinatesToCellName(colIndex, row)
value, err := e.file.GetCellValue(sheetName, cell)
if err == nil {
width := float64(len(value)) + 2 // 加2作为边距
if width > maxWidth {
maxWidth = width
}
}
}
// 限制最大宽度
if maxWidth > 50 {
maxWidth = 50
}
return maxWidth
}
// AdaptTimeFormatter 适配器函数将tools包的格式化函数转换为Excel Format字段需要的函数类型
// 允许直接使用tools包的任何格式化函数
//
// 示例:
//
// // 直接使用tools.FormatDate
// Format: excel.AdaptTimeFormatter(tools.FormatDate)
//
// // 使用自定义格式化函数
// Format: excel.AdaptTimeFormatter(func(t time.Time) string {
// return tools.Format(t, "2006-01-02 15:04:05", "Asia/Shanghai")
// })
func AdaptTimeFormatter(fn func(time.Time, ...string) string) func(interface{}) string {
return func(value interface{}) string {
if t, ok := value.(time.Time); ok {
return fn(t)
}
return ""
}
}
// formatDateTime 格式化日期时间(内部便捷函数)
// 用于ExportColumn的Format字段
// layout: 时间格式,如 "2006-01-02 15:04:05"
// timezone: 可选时区,如果为空则使用时间对象本身的时区
// 直接调用 tools.Format() 方法
func formatDateTime(layout string, timezone ...string) func(interface{}) string {
return AdaptTimeFormatter(func(t time.Time, _ ...string) string {
return tools.Format(t, layout, timezone...)
})
}
// formatDate 格式化日期(内部便捷函数)
// 用于ExportColumn的Format字段格式2006-01-02
// 直接调用 tools.FormatDate() 方法
var formatDate = AdaptTimeFormatter(tools.FormatDate)
// formatDateTimeDefault 格式化日期时间(内部便捷函数)
// 用于ExportColumn的Format字段格式2006-01-02 15:04:05
// 直接调用 tools.FormatDateTime() 方法
var formatDateTimeDefault = AdaptTimeFormatter(tools.FormatDateTime)
// formatTime 格式化时间(内部便捷函数)
// 用于ExportColumn的Format字段格式15:04:05
// 直接调用 tools.FormatTime() 方法
var formatTime = AdaptTimeFormatter(tools.FormatTime)

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

@@ -3,12 +3,20 @@ package factory
import ( import (
"context" "context"
"fmt" "fmt"
"io" "net/http"
"sync"
"time" "time"
"git.toowon.com/jimmy/go-common/captcha"
"git.toowon.com/jimmy/go-common/config" "git.toowon.com/jimmy/go-common/config"
"git.toowon.com/jimmy/go-common/email" "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/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" "git.toowon.com/jimmy/go-common/sms"
"git.toowon.com/jimmy/go-common/storage" "git.toowon.com/jimmy/go-common/storage"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
@@ -18,314 +26,114 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// Factory 工厂类,用于从配置创建各种客户端对象 var (
defaultFactory *Factory
)
// Factory 工具库入口:配置加载 + lazy getter
type Factory struct { type Factory struct {
cfg *config.Config cfg *config.Config
storage storage.Storage // 存储实例(延迟初始化) storage storage.Storage
logger *logger.Logger // 日志实例(延迟初始化)
email *email.Email // 邮件客户端(延迟初始化) logger *logger.Logger
sms *sms.SMS // 短信客户端(延迟初始化) email *email.Email
db *gorm.DB // 数据库连接(延迟初始化) sms *sms.SMS
redis *redis.Client // Redis客户端延迟初始化 db *gorm.DB
redis *redis.Client
i18n *i18n.I18n
chain *middleware.Chain
mcp mcp.Manager
captcha captcha.Manager
mu sync.Mutex
} }
// NewFactory 创建工厂实例 // Option Factory 可选项(支持重载模块实现)
func NewFactory(cfg *config.Config) *Factory { type Option func(*Factory)
return &Factory{
cfg: cfg,
}
}
// NewFactoryFromFile 从配置文件创建工厂实例(便捷方法 // Init 从配置文件初始化全局 Factory启动时调用一次
// filePath: 配置文件路径 func Init(filePath string, opts ...Option) error {
func NewFactoryFromFile(filePath string) (*Factory, error) {
cfg, err := config.LoadFromFile(filePath) cfg, err := config.LoadFromFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to load config: %w", err)
}
return NewFactory(cfg), nil
}
// getEmailClient 获取邮件客户端(内部方法,延迟初始化)
func (f *Factory) getEmailClient() (*email.Email, error) {
if f.email != nil {
return f.email, nil
}
if f.cfg.Email == nil {
return nil, fmt.Errorf("email config is nil")
}
e, err := email.NewEmail(f.cfg.Email)
if err != nil {
return nil, fmt.Errorf("failed to create email client: %w", err)
}
f.email = e
return e, nil
}
// SendEmail 发送邮件(黑盒模式)
// to: 收件人列表
// subject: 邮件主题
// body: 邮件正文(纯文本)
// htmlBody: HTML正文可选如果设置了会优先使用
func (f *Factory) SendEmail(to []string, subject, body string, htmlBody ...string) error {
e, err := f.getEmailClient()
if err != nil { if err != nil {
return err return err
} }
defaultFactory = New(cfg, opts...)
msg := &email.Message{ if l, err := defaultFactory.Logger(); err == nil {
To: to, logger.SetDefaultLogger(l)
Subject: subject,
Body: body,
} }
return nil
if len(htmlBody) > 0 && htmlBody[0] != "" {
msg.HTMLBody = htmlBody[0]
}
return e.Send(msg)
} }
// getSMSClient 获取短信客户端(内部方法,延迟初始化) // Default 获取全局 Factory
func (f *Factory) getSMSClient() (*sms.SMS, error) { func Default() *Factory {
if f.sms != nil { return defaultFactory
return f.sms, nil
}
if f.cfg.SMS == nil {
return nil, fmt.Errorf("SMS config is nil")
}
s, err := sms.NewSMS(f.cfg.SMS)
if err != nil {
return nil, fmt.Errorf("failed to create SMS client: %w", err)
}
f.sms = s
return s, nil
} }
// SendSMS 发送短信(黑盒模式) // New 从配置创建 Factory
// phoneNumbers: 手机号列表 func New(cfg *config.Config, opts ...Option) *Factory {
// templateParam: 模板参数map或JSON字符串 f := &Factory{cfg: cfg}
// templateCode: 模板代码(可选,如果为空使用配置中的模板代码) for _, opt := range opts {
func (f *Factory) SendSMS(phoneNumbers []string, templateParam interface{}, templateCode ...string) (*sms.SendResponse, error) { opt(f)
s, err := f.getSMSClient()
if err != nil {
return nil, err
} }
return f
req := &sms.SendRequest{ }
PhoneNumbers: phoneNumbers,
TemplateParam: templateParam, // Config 获取配置
} func (f *Factory) Config() *config.Config {
return f.cfg
if len(templateCode) > 0 && templateCode[0] != "" { }
req.TemplateCode = templateCode[0]
} // NewHandler 创建 HTTP 出参处理器,并自动注入已配置的 i18n若可用
// 能力仍在 *http.Handler 上Success / ErrorFactory 不透传出参方法。
return s.Send(req) 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...)
} }
// getLogger 获取日志记录器(内部方法,延迟初始化)
func (f *Factory) getLogger() (*logger.Logger, error) { func (f *Factory) getLogger() (*logger.Logger, error) {
if f.logger != nil { if f.logger != nil {
return f.logger, nil return f.logger, nil
} }
f.mu.Lock()
var l *logger.Logger defer f.mu.Unlock()
var err error if f.logger != nil {
if f.cfg.Logger == nil { return f.logger, nil
// 如果没有配置,使用默认配置创建
l, err = logger.NewLogger(nil)
} else {
l, err = logger.NewLogger(f.cfg.Logger)
} }
var cfg *config.LoggerConfig
if f.cfg != nil {
cfg = f.cfg.Logger
}
l, err := logger.NewLogger(cfg)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create logger: %w", err) return nil, fmt.Errorf("failed to create logger: %w", err)
} }
f.logger = l f.logger = l
return l, nil return l, nil
} }
// LogDebug 记录调试日志 // Logger 获取日志对象(无 logger 配置时使用默认配置)
// message: 日志消息 func (f *Factory) Logger() (*logger.Logger, error) {
// args: 格式化参数(可选) return f.getLogger()
func (f *Factory) LogDebug(message string, args ...interface{}) {
l, err := f.getLogger()
if err != nil {
// 如果日志初始化失败,使用标准输出
if len(args) > 0 {
fmt.Printf("[DEBUG] "+message+"\n", args...)
} else {
fmt.Printf("[DEBUG] %s\n", message)
}
return
}
if len(args) > 0 {
l.Debug(message, args...)
} else {
l.Debug(message)
}
} }
// LogDebugf 记录调试日志(带字段)
// fields: 日志字段
// message: 日志消息
// args: 格式化参数(可选)
func (f *Factory) LogDebugf(fields map[string]interface{}, message string, args ...interface{}) {
l, err := f.getLogger()
if err != nil {
// 如果日志初始化失败,使用标准输出
if len(args) > 0 {
fmt.Printf("[DEBUG] "+message+"\n", args...)
} else {
fmt.Printf("[DEBUG] %s\n", message)
}
return
}
l.Debugf(fields, message, args...)
}
// LogInfo 记录信息日志
// message: 日志消息
// args: 格式化参数(可选)
func (f *Factory) LogInfo(message string, args ...interface{}) {
l, err := f.getLogger()
if err != nil {
// 如果日志初始化失败,使用标准输出
if len(args) > 0 {
fmt.Printf("[INFO] "+message+"\n", args...)
} else {
fmt.Printf("[INFO] %s\n", message)
}
return
}
if len(args) > 0 {
l.Info(message, args...)
} else {
l.Info(message)
}
}
// LogInfof 记录信息日志(带字段)
// fields: 日志字段
// message: 日志消息
// args: 格式化参数(可选)
func (f *Factory) LogInfof(fields map[string]interface{}, message string, args ...interface{}) {
l, err := f.getLogger()
if err != nil {
// 如果日志初始化失败,使用标准输出
if len(args) > 0 {
fmt.Printf("[INFO] "+message+"\n", args...)
} else {
fmt.Printf("[INFO] %s\n", message)
}
return
}
l.Infof(fields, message, args...)
}
// LogWarn 记录警告日志
// message: 日志消息
// args: 格式化参数(可选)
func (f *Factory) LogWarn(message string, args ...interface{}) {
l, err := f.getLogger()
if err != nil {
// 如果日志初始化失败,使用标准输出
if len(args) > 0 {
fmt.Printf("[WARN] "+message+"\n", args...)
} else {
fmt.Printf("[WARN] %s\n", message)
}
return
}
if len(args) > 0 {
l.Warn(message, args...)
} else {
l.Warn(message)
}
}
// LogWarnf 记录警告日志(带字段)
// fields: 日志字段
// message: 日志消息
// args: 格式化参数(可选)
func (f *Factory) LogWarnf(fields map[string]interface{}, message string, args ...interface{}) {
l, err := f.getLogger()
if err != nil {
// 如果日志初始化失败,使用标准输出
if len(args) > 0 {
fmt.Printf("[WARN] "+message+"\n", args...)
} else {
fmt.Printf("[WARN] %s\n", message)
}
return
}
l.Warnf(fields, message, args...)
}
// LogError 记录错误日志
// message: 日志消息
// args: 格式化参数(可选)
func (f *Factory) LogError(message string, args ...interface{}) {
l, err := f.getLogger()
if err != nil {
// 如果日志初始化失败,使用标准输出
if len(args) > 0 {
fmt.Printf("[ERROR] "+message+"\n", args...)
} else {
fmt.Printf("[ERROR] %s\n", message)
}
return
}
if len(args) > 0 {
l.Error(message, args...)
} else {
l.Error(message)
}
}
// LogErrorf 记录错误日志(带字段)
// fields: 日志字段
// message: 日志消息
// args: 格式化参数(可选)
func (f *Factory) LogErrorf(fields map[string]interface{}, message string, args ...interface{}) {
l, err := f.getLogger()
if err != nil {
// 如果日志初始化失败,使用标准输出
if len(args) > 0 {
fmt.Printf("[ERROR] "+message+"\n", args...)
} else {
fmt.Printf("[ERROR] %s\n", message)
}
return
}
l.Errorf(fields, message, args...)
}
// getDatabase 获取数据库连接对象(内部方法,延迟初始化)
func (f *Factory) getDatabase() (*gorm.DB, error) { func (f *Factory) getDatabase() (*gorm.DB, error) {
if f.db != nil { if f.db != nil {
return f.db, nil return f.db, nil
} }
f.mu.Lock()
if f.cfg.Database == nil { defer f.mu.Unlock()
if f.db != nil {
return f.db, nil
}
if f.cfg == nil || f.cfg.Database == nil {
return nil, fmt.Errorf("database config is nil") return nil, fmt.Errorf("database config is nil")
} }
// 获取DSN
dsn, err := f.cfg.GetDatabaseDSN() dsn, err := f.cfg.GetDatabaseDSN()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get DSN: %w", err) return nil, err
} }
// 根据数据库类型创建连接
var db *gorm.DB var db *gorm.DB
switch f.cfg.Database.Type { switch f.cfg.Database.Type {
case "mysql": case "mysql":
@@ -337,17 +145,13 @@ func (f *Factory) getDatabase() (*gorm.DB, error) {
default: default:
return nil, fmt.Errorf("unsupported database type: %s", f.cfg.Database.Type) return nil, fmt.Errorf("unsupported database type: %s", f.cfg.Database.Type)
} }
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to connect to database: %w", err) return nil, fmt.Errorf("failed to connect to database: %w", err)
} }
// 配置连接池
sqlDB, err := db.DB() sqlDB, err := db.DB()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get sql.DB: %w", err) return nil, err
} }
if f.cfg.Database.MaxOpenConns > 0 { if f.cfg.Database.MaxOpenConns > 0 {
sqlDB.SetMaxOpenConns(f.cfg.Database.MaxOpenConns) sqlDB.SetMaxOpenConns(f.cfg.Database.MaxOpenConns)
} }
@@ -357,232 +161,334 @@ func (f *Factory) getDatabase() (*gorm.DB, error) {
if f.cfg.Database.ConnMaxLifetime > 0 { if f.cfg.Database.ConnMaxLifetime > 0 {
sqlDB.SetConnMaxLifetime(time.Duration(f.cfg.Database.ConnMaxLifetime) * time.Second) sqlDB.SetConnMaxLifetime(time.Duration(f.cfg.Database.ConnMaxLifetime) * time.Second)
} }
f.db = db f.db = db
return db, nil return db, nil
} }
// GetDatabase 获取数据库连接对象(已初始化) // Database 获取 GORM 数据库连接
// 返回已初始化的GORM数据库对象可直接使用 func (f *Factory) Database() (*gorm.DB, error) {
// 注意数据库保持返回GORM对象因为GORM已经提供了很好的抽象
func (f *Factory) GetDatabase() (*gorm.DB, error) {
return f.getDatabase() return f.getDatabase()
} }
// getRedisClient 获取Redis客户端对象内部方法延迟初始化 func (f *Factory) getRedis() (*redis.Client, error) {
func (f *Factory) getRedisClient() (*redis.Client, error) {
if f.redis != nil { if f.redis != nil {
return f.redis, nil return f.redis, nil
} }
f.mu.Lock()
if f.cfg.Redis == nil { defer f.mu.Unlock()
if f.redis != nil {
return f.redis, nil
}
if f.cfg == nil || f.cfg.Redis == nil {
return nil, fmt.Errorf("redis config is nil") return nil, fmt.Errorf("redis config is nil")
} }
// 获取Redis地址
addr := f.cfg.GetRedisAddr() addr := f.cfg.GetRedisAddr()
if addr == "" { if addr == "" {
return nil, fmt.Errorf("redis address is empty") return nil, fmt.Errorf("redis address is empty")
} }
rc := f.cfg.Redis
// 设置默认值
redisConfig := f.cfg.Redis
if redisConfig.PoolSize == 0 {
redisConfig.PoolSize = 10 // 默认连接池大小
}
if redisConfig.MinIdleConns == 0 {
redisConfig.MinIdleConns = 5 // 默认最小空闲连接数
}
if redisConfig.DialTimeout == 0 {
redisConfig.DialTimeout = 5 // 默认连接超时5秒
}
if redisConfig.ReadTimeout == 0 {
redisConfig.ReadTimeout = 3 // 默认读取超时3秒
}
if redisConfig.WriteTimeout == 0 {
redisConfig.WriteTimeout = 3 // 默认写入超时3秒
}
// 创建Redis客户端
client := redis.NewClient(&redis.Options{ client := redis.NewClient(&redis.Options{
Addr: addr, Addr: addr,
Password: redisConfig.Password, Password: rc.Password,
DB: redisConfig.Database, DB: rc.Database,
PoolSize: redisConfig.PoolSize, PoolSize: rc.PoolSize,
MinIdleConns: redisConfig.MinIdleConns, MinIdleConns: rc.MinIdleConns,
MaxRetries: redisConfig.MaxRetries, MaxRetries: rc.MaxRetries,
DialTimeout: time.Duration(redisConfig.DialTimeout) * time.Second, DialTimeout: time.Duration(rc.DialTimeout) * time.Second,
ReadTimeout: time.Duration(redisConfig.ReadTimeout) * time.Second, ReadTimeout: time.Duration(rc.ReadTimeout) * time.Second,
WriteTimeout: time.Duration(redisConfig.WriteTimeout) * time.Second, WriteTimeout: time.Duration(rc.WriteTimeout) * time.Second,
}) })
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(rc.DialTimeout)*time.Second)
// 测试连接
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(redisConfig.DialTimeout)*time.Second)
defer cancel() defer cancel()
if _, err := client.Ping(ctx).Result(); err != nil {
_, err := client.Ping(ctx).Result() _ = client.Close()
if err != nil {
client.Close() // 连接失败时关闭客户端
return nil, fmt.Errorf("failed to connect to redis: %w", err) return nil, fmt.Errorf("failed to connect to redis: %w", err)
} }
f.redis = client f.redis = client
return client, nil return client, nil
} }
// GetRedisClient 获取Redis客户端对象(已初始化) // Redis 获取 Redis 客户端
// 返回已初始化的Redis客户端对象可直接使用 func (f *Factory) Redis() (*redis.Client, error) {
// 注意:推荐使用 RedisGet、RedisSetRedisDelete 等方法直接操作Redis return f.getRedis()
// 如果需要使用Redis的高级功能如Hash、List、Set等可以使用此方法获取客户端对象
func (f *Factory) GetRedisClient() (*redis.Client, error) {
return f.getRedisClient()
} }
// RedisGet 获取Redis值黑盒模式
// key: Redis键
func (f *Factory) RedisGet(ctx context.Context, key string) (string, error) {
client, err := f.getRedisClient()
if err != nil {
return "", err
}
result, err := client.Get(ctx, key).Result()
if err == redis.Nil {
return "", nil // key不存在返回空字符串
}
if err != nil {
return "", fmt.Errorf("failed to get redis key: %w", err)
}
return result, nil
}
// RedisSet 设置Redis值黑盒模式
// key: Redis键
// value: Redis值
// expiration: 过期时间可选0表示不过期
func (f *Factory) RedisSet(ctx context.Context, key string, value interface{}, expiration ...time.Duration) error {
client, err := f.getRedisClient()
if err != nil {
return err
}
var exp time.Duration
if len(expiration) > 0 {
exp = expiration[0]
}
err = client.Set(ctx, key, value, exp).Err()
if err != nil {
return fmt.Errorf("failed to set redis key: %w", err)
}
return nil
}
// RedisDelete 删除Redis键黑盒模式
// keys: Redis键列表
func (f *Factory) RedisDelete(ctx context.Context, keys ...string) error {
if len(keys) == 0 {
return nil
}
client, err := f.getRedisClient()
if err != nil {
return err
}
err = client.Del(ctx, keys...).Err()
if err != nil {
return fmt.Errorf("failed to delete redis keys: %w", err)
}
return nil
}
// RedisExists 检查Redis键是否存在黑盒模式
// key: Redis键
func (f *Factory) RedisExists(ctx context.Context, key string) (bool, error) {
client, err := f.getRedisClient()
if err != nil {
return false, err
}
count, err := client.Exists(ctx, key).Result()
if err != nil {
return false, fmt.Errorf("failed to check redis key existence: %w", err)
}
return count > 0, nil
}
// GetConfig 获取配置对象
func (f *Factory) GetConfig() *config.Config {
return f.cfg
}
// getStorage 获取存储实例(内部方法,延迟初始化)
func (f *Factory) getStorage() (storage.Storage, error) { func (f *Factory) getStorage() (storage.Storage, error) {
if f.storage != nil { if f.storage != nil {
return f.storage, nil return f.storage, nil
} }
f.mu.Lock()
// 根据配置自动选择存储类型 defer f.mu.Unlock()
// 优先级MinIO > OSS if f.storage != nil {
var storageType storage.StorageType return f.storage, nil
if f.cfg.MinIO != nil { }
storageType = storage.StorageTypeMinIO if f.cfg == nil {
} else if f.cfg.OSS != nil { return nil, fmt.Errorf("config is nil")
storageType = storage.StorageTypeOSS }
} else { var storageType storage.StorageType
return nil, fmt.Errorf("no storage config found (OSS or MinIO)") switch {
case f.cfg.GetLocalStorage() != nil:
storageType = storage.StorageTypeLocal
case f.cfg.MinIO != nil:
storageType = storage.StorageTypeMinIO
case f.cfg.OSS != nil:
storageType = storage.StorageTypeOSS
default:
return nil, fmt.Errorf("no storage config found (LocalStorage, OSS or MinIO)")
} }
// 创建存储实例
s, err := storage.NewStorage(storageType, f.cfg) s, err := storage.NewStorage(storageType, f.cfg)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create storage: %w", err) return nil, err
} }
f.storage = s f.storage = s
return s, nil return s, nil
} }
// UploadFile 上传文件 // Storage 获取存储对象
// ctx: 上下文 func (f *Factory) Storage() (storage.Storage, error) {
// objectKey: 对象键(文件路径) return f.getStorage()
// reader: 文件内容
// contentType: 文件类型(可选)
// 返回文件访问URL和错误
func (f *Factory) UploadFile(ctx context.Context, objectKey string, reader io.Reader, contentType ...string) (string, error) {
s, err := f.getStorage()
if err != nil {
return "", err
}
// 上传文件
err = s.Upload(ctx, objectKey, reader, contentType...)
if err != nil {
return "", fmt.Errorf("failed to upload file: %w", err)
}
// 获取文件URL
url, err := s.GetURL(objectKey, 0)
if err != nil {
return "", fmt.Errorf("failed to get file URL: %w", err)
}
return url, nil
} }
// GetFileURL 获取文件访问URLShow方法 func (f *Factory) getEmail() (*email.Email, error) {
// objectKey: 对象键 if f.email != nil {
// expires: 过期时间0表示永久有效 return f.email, nil
func (f *Factory) GetFileURL(objectKey string, expires int64) (string, error) { }
s, err := f.getStorage() f.mu.Lock()
if err != nil { defer f.mu.Unlock()
return "", err if f.email != nil {
return f.email, nil
}
if f.cfg == nil || f.cfg.Email == nil {
return nil, fmt.Errorf("email config is nil")
}
f.email = email.NewEmail(f.cfg)
return f.email, nil
}
// Email 获取邮件客户端
func (f *Factory) Email() (*email.Email, error) {
return f.getEmail()
}
func (f *Factory) getSMS() (*sms.SMS, error) {
if f.sms != nil {
return f.sms, nil
}
f.mu.Lock()
defer f.mu.Unlock()
if f.sms != nil {
return f.sms, nil
}
if f.cfg == nil || f.cfg.SMS == nil {
return nil, fmt.Errorf("sms config is nil")
}
f.sms = sms.NewSMS(f.cfg)
return f.sms, nil
}
// SMS 获取短信客户端
func (f *Factory) SMS() (*sms.SMS, error) {
return f.getSMS()
}
func (f *Factory) getI18n() (*i18n.I18n, error) {
if f.i18n != nil {
return f.i18n, nil
}
f.mu.Lock()
defer f.mu.Unlock()
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
}
// I18n 获取国际化对象
func (f *Factory) I18n() (*i18n.I18n, error) {
return f.getI18n()
}
// Excel 创建 Excel 导出器(每次新建,避免共享可变文件状态)
func (f *Factory) Excel() *excel.Excel {
return excel.NewExcel()
}
// MiddlewareChain 获取默认中间件链
func (f *Factory) MiddlewareChain() *middleware.Chain {
if f.chain != nil {
return f.chain
}
f.mu.Lock()
defer f.mu.Unlock()
if f.chain != nil {
return f.chain
} }
return s.GetURL(objectKey, expires) var mws []func(http.Handler) http.Handler
// logger 无配置时有默认实现;失败则降级为 nilRecovery/Logging 可处理)
l, _ := f.getLoggerUnlocked()
// i18n 为可选:未配置时 Recovery 使用默认文案
i18nInst, _ := f.getI18nUnlocked()
mws = append(mws, middleware.Recovery(&middleware.RecoveryConfig{
Logger: l,
I18n: i18nInst,
}))
mws = append(mws, middleware.RequestID())
mws = append(mws, middleware.Logging(&middleware.LoggingConfig{Logger: l}))
if f.cfg != nil && f.cfg.RateLimit != nil && f.cfg.RateLimit.Enable {
limiter := middleware.NewTokenBucketLimiter(
f.cfg.RateLimit.Rate,
time.Duration(f.cfg.RateLimit.Period)*time.Second,
)
var keyFunc func(r *http.Request) string
if f.cfg.RateLimit.ByIP {
keyFunc = func(r *http.Request) string { return middleware.GetClientIP(r) }
} else if f.cfg.RateLimit.ByUserID {
keyFunc = func(r *http.Request) string { return r.Header.Get("X-User-ID") }
}
mws = append(mws, middleware.RateLimit(&middleware.RateLimitConfig{
Limiter: limiter,
KeyFunc: keyFunc,
}))
}
if f.cfg != nil && f.cfg.CORS != nil {
mws = append(mws, middleware.CORS(&middleware.CORSConfig{
AllowedOrigins: f.cfg.CORS.AllowedOrigins,
AllowedMethods: f.cfg.CORS.AllowedMethods,
AllowedHeaders: f.cfg.CORS.AllowedHeaders,
ExposedHeaders: f.cfg.CORS.ExposedHeaders,
AllowCredentials: f.cfg.CORS.AllowCredentials,
MaxAge: f.cfg.CORS.MaxAge,
}))
}
mws = append(mws, middleware.Language, middleware.Timezone)
f.chain = middleware.NewChain(mws...)
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()
if err != nil {
return nil, err
}
dbType := "mysql"
if f.cfg.Database != nil && f.cfg.Database.Type != "" {
dbType = f.cfg.Database.Type
}
m := migration.NewMigratorWithType(db, dbType)
migrations, err := migration.LoadMigrationsFromFiles(migrationsDir, "*.sql")
if err != nil {
return nil, err
}
m.AddMigrations(migrations...)
return m, nil
} }

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

33
go.mod
View File

@@ -1,12 +1,16 @@
module git.toowon.com/jimmy/go-common module git.toowon.com/jimmy/go-common
go 1.23.0 go 1.25.0
toolchain go1.24.10
require ( 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/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/redis/go-redis/v9 v9.17.1
github.com/xuri/excelize/v2 v2.10.0
golang.org/x/crypto v0.43.0
gorm.io/driver/mysql v1.5.2 gorm.io/driver/mysql v1.5.2
gorm.io/driver/postgres v1.6.0 gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0 gorm.io/driver/sqlite v1.6.0
@@ -19,7 +23,8 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-ini/ini v1.67.0 // indirect github.com/go-ini/ini v1.67.0 // indirect
github.com/go-sql-driver/mysql v1.7.1 // indirect github.com/go-sql-driver/mysql v1.7.1 // indirect
github.com/google/uuid v1.6.0 // 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/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect github.com/jackc/pgx/v5 v5.6.0 // indirect
@@ -34,13 +39,23 @@ require (
github.com/minio/crc64nvme v1.1.0 // indirect github.com/minio/crc64nvme v1.1.0 // indirect
github.com/minio/md5-simd v1.1.2 // indirect github.com/minio/md5-simd v1.1.2 // indirect
github.com/philhofer/fwd v1.2.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect
github.com/richardlehane/mscfb v1.0.4 // indirect
github.com/richardlehane/msoleps v1.0.4 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/rs/xid v1.6.0 // 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/tinylib/msgp v1.3.0 // indirect
golang.org/x/crypto v0.36.0 // indirect github.com/xuri/efp v0.0.1 // indirect
golang.org/x/net v0.38.0 // indirect github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
golang.org/x/sync v0.15.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/sys v0.34.0 // indirect github.com/yuin/gopher-lua v1.1.1 // indirect
golang.org/x/text v0.26.0 // 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.41.0 // indirect
golang.org/x/text v0.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

97
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 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= 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.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 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= 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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -50,33 +60,96 @@ 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/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 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5occ=
github.com/minio/minio-go/v7 v7.0.97/go.mod h1:re5VXuo0pwEtoNLsNuSr0RrLfT/MBtohwdaSmPPSRSk= 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 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.17.1 h1:7tl732FjYPRT9H9aNfyTwKg9iTETjWjGKEJ2t/5iWTs= github.com/redis/go-redis/v9 v9.17.1 h1:7tl732FjYPRT9H9aNfyTwKg9iTETjWjGKEJ2t/5iWTs=
github.com/redis/go-redis/v9 v9.17.1/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/redis/go-redis/v9 v9.17.1/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 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 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= 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/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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tiendc/go-deepcopy v1.7.1 h1:LnubftI6nYaaMOcaz0LphzwraqN8jiWTwm416sitff4=
github.com/tiendc/go-deepcopy v1.7.1/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww= github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= github.com/xuri/excelize/v2 v2.10.0 h1:8aKsP7JD39iKLc6dH5Tw3dgV3sPRh8uRVXu/fMstfW4=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= github.com/xuri/excelize/v2 v2.10.0/go.mod h1:SC5TzhQkaOsTWpANfm+7bJCldzcnU/jrhqkTi/iBHBU=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= 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.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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -1,279 +1,100 @@
package http package http
import ( import (
"context"
"encoding/json"
"io"
"net/http" "net/http"
"strconv"
"git.toowon.com/jimmy/go-common/middleware" "git.toowon.com/jimmy/go-common/i18n"
) )
// Handler HTTP处理器包装器封装ResponseWriter和Request提供简洁的API // Handler HTTP 出参处理器(唯一对外出参方式)
type Handler struct { type Handler struct {
w http.ResponseWriter w http.ResponseWriter
r *http.Request r *http.Request
i18n *i18n.I18n
pagination *PaginationRequest
} }
// NewHandler 创建Handler实例 // HandlerOption Handler 配置项
func NewHandler(w http.ResponseWriter, r *http.Request) *Handler { type HandlerOption func(*Handler)
return &Handler{
w: w, // WithI18n 注入 i18n
r: r, func WithI18n(i *i18n.I18n) HandlerOption {
return func(h *Handler) {
h.i18n = i
} }
} }
// ResponseWriter 获取原始的ResponseWriter需要时使用 // NewHandler 创建 HTTP 出参处理器
func (h *Handler) ResponseWriter() http.ResponseWriter { func NewHandler(w http.ResponseWriter, r *http.Request, opts ...HandlerOption) *Handler {
return h.w h := &Handler{w: w, r: r}
for _, opt := range opts {
opt(h)
}
return h
} }
// Request 获取原始的Request需要时使用 // ParseJSON 解析 JSON 请求体
func (h *Handler) Request() *http.Request { func (h *Handler) ParseJSON(v interface{}) error {
return h.r return ParseJSON(h.r, v)
} }
// Context 获取请求的Context // Pagination 解析并缓存分页参数
func (h *Handler) Context() context.Context { func (h *Handler) Pagination() *PaginationRequest {
return h.r.Context() if h.pagination == nil {
h.pagination = ParsePaginationRequest(h.r)
}
return h.pagination
} }
// ========== 响应方法(黑盒模式) ========== // GetLanguage 从 context 获取语言
func (h *Handler) GetLanguage() string {
return GetLanguage(h.r)
}
// GetTimezone 从 context 获取时区
func (h *Handler) GetTimezone() string {
return GetTimezone(h.r)
}
// Success 成功响应 // Success 成功响应
// data: 响应数据可以为nil
func (h *Handler) Success(data interface{}) { func (h *Handler) Success(data interface{}) {
writeJSON(h.w, http.StatusOK, 0, "success", data) message := "success"
} code := 0
if h.i18n != nil {
// SuccessWithMessage 带消息的成功响应 info := h.i18n.GetMessageInfo(h.GetLanguage(), "common.success")
func (h *Handler) SuccessWithMessage(message string, data interface{}) { if info.Message != "common.success" {
writeJSON(h.w, http.StatusOK, 0, message, data) message = info.Message
} code = info.Code
}
// Error 错误响应 }
// code: 业务错误码非0表示业务错误 writeResponse(h.w, code, message, data)
// message: 错误消息
func (h *Handler) Error(code int, message string) {
writeJSON(h.w, http.StatusOK, code, message, nil)
}
// SystemError 系统错误响应返回HTTP 500
// message: 错误消息
func (h *Handler) SystemError(message string) {
writeJSON(h.w, http.StatusInternalServerError, 500, message, nil)
}
// WriteJSON 写入JSON响应自定义HTTP状态码和业务状态码
// httpCode: HTTP状态码200表示正常500表示系统错误等
// code: 业务状态码0表示成功非0表示业务错误
// message: 响应消息
// data: 响应数据
func (h *Handler) WriteJSON(httpCode, code int, message string, data interface{}) {
writeJSON(h.w, httpCode, code, message, data)
} }
// SuccessPage 分页成功响应 // SuccessPage 分页成功响应
// list: 数据列表 func (h *Handler) SuccessPage(list interface{}, total int64) {
// total: 总记录数 p := h.Pagination()
// page: 当前页码
// pageSize: 每页大小
// message: 响应消息(可选,如果为空则使用默认消息 "success"
func (h *Handler) SuccessPage(list interface{}, total int64, page, pageSize int, message ...string) {
msg := "success"
if len(message) > 0 && message[0] != "" {
msg = message[0]
}
pageData := &PageData{ pageData := &PageData{
List: list, List: list,
Total: total, Total: total,
Page: page, Page: p.GetPage(),
PageSize: pageSize, PageSize: p.GetPageSize(),
} }
h.Success(pageData)
writeJSON(h.w, http.StatusOK, 0, msg, pageData)
} }
// ========== 请求解析方法(黑盒模式) ========== // Error 失败响应messageCode 为 i18n 消息码data 为 null
func (h *Handler) Error(messageCode string, args ...interface{}) {
// ParseJSON 解析JSON请求体 h.ErrorData(messageCode, nil, args...)
// v: 目标结构体指针
func (h *Handler) ParseJSON(v interface{}) error {
body, err := io.ReadAll(h.r.Body)
if err != nil {
return err
}
defer h.r.Body.Close()
if len(body) == 0 {
return nil
}
return json.Unmarshal(body, v)
} }
// GetQuery 获取查询参数 // ErrorData 失败响应并附带 data
// key: 参数名 func (h *Handler) ErrorData(messageCode string, data interface{}, args ...interface{}) {
// defaultValue: 默认值 code := 0
func (h *Handler) GetQuery(key, defaultValue string) string { message := messageCode
value := h.r.URL.Query().Get(key) if h.i18n != nil {
if value == "" { info := h.i18n.GetMessageInfo(h.GetLanguage(), messageCode, args...)
return defaultValue code = info.Code
} message = info.Message
return value
}
// GetQueryInt 获取整数查询参数
// key: 参数名
// defaultValue: 默认值
func (h *Handler) GetQueryInt(key string, defaultValue int) int {
value := h.r.URL.Query().Get(key)
if value == "" {
return defaultValue
}
intValue, err := strconv.Atoi(value)
if err != nil {
return defaultValue
}
return intValue
}
// GetQueryInt64 获取int64查询参数
func (h *Handler) GetQueryInt64(key string, defaultValue int64) int64 {
value := h.r.URL.Query().Get(key)
if value == "" {
return defaultValue
}
intValue, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return defaultValue
}
return intValue
}
// GetQueryBool 获取布尔查询参数
func (h *Handler) GetQueryBool(key string, defaultValue bool) bool {
value := h.r.URL.Query().Get(key)
if value == "" {
return defaultValue
}
boolValue, err := strconv.ParseBool(value)
if err != nil {
return defaultValue
}
return boolValue
}
// GetQueryFloat64 获取float64查询参数
func (h *Handler) GetQueryFloat64(key string, defaultValue float64) float64 {
value := h.r.URL.Query().Get(key)
if value == "" {
return defaultValue
}
floatValue, err := strconv.ParseFloat(value, 64)
if err != nil {
return defaultValue
}
return floatValue
}
// GetFormValue 获取表单值
func (h *Handler) GetFormValue(key, defaultValue string) string {
value := h.r.FormValue(key)
if value == "" {
return defaultValue
}
return value
}
// GetFormInt 获取表单整数
func (h *Handler) GetFormInt(key string, defaultValue int) int {
value := h.r.FormValue(key)
if value == "" {
return defaultValue
}
intValue, err := strconv.Atoi(value)
if err != nil {
return defaultValue
}
return intValue
}
// GetFormInt64 获取表单int64
func (h *Handler) GetFormInt64(key string, defaultValue int64) int64 {
value := h.r.FormValue(key)
if value == "" {
return defaultValue
}
intValue, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return defaultValue
}
return intValue
}
// GetFormBool 获取表单布尔值
func (h *Handler) GetFormBool(key string, defaultValue bool) bool {
value := h.r.FormValue(key)
if value == "" {
return defaultValue
}
boolValue, err := strconv.ParseBool(value)
if err != nil {
return defaultValue
}
return boolValue
}
// GetHeader 获取请求头
func (h *Handler) GetHeader(key, defaultValue string) string {
value := h.r.Header.Get(key)
if value == "" {
return defaultValue
}
return value
}
// ParsePaginationRequest 从请求中解析分页参数
// 支持从查询参数和form表单中解析
// 优先级:查询参数 > form表单
func (h *Handler) ParsePaginationRequest() *PaginationRequest {
return ParsePaginationRequest(h.r)
}
// GetTimezone 从请求的context中获取时区
// 如果使用了middleware.Timezone中间件可以从context中获取时区信息
// 如果未设置,返回默认时区 AsiaShanghai
func (h *Handler) GetTimezone() string {
return middleware.GetTimezoneFromContext(h.r.Context())
}
// HandleFunc 将Handler函数转换为标准的http.HandlerFunc
// 这样可以将Handler函数直接用于http.HandleFunc
// 示例:
//
// http.HandleFunc("/users", http.HandleFunc(func(h *http.Handler) {
// h.Success(data)
// }))
func HandleFunc(fn func(*Handler)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
h := NewHandler(w, r)
fn(h)
} }
writeResponse(h.w, code, message, data)
} }

View File

@@ -1,46 +1,20 @@
package http package http
import ( import (
"encoding/json"
"io"
"net/http" "net/http"
"strconv"
"git.toowon.com/jimmy/go-common/requestctx"
"git.toowon.com/jimmy/go-common/tools"
) )
// getQueryInt 获取整数查询参数内部方法供ParsePaginationRequest使用
func getQueryInt(r *http.Request, key string, defaultValue int) int {
value := r.URL.Query().Get(key)
if value == "" {
return defaultValue
}
intValue, err := strconv.Atoi(value)
if err != nil {
return defaultValue
}
return intValue
}
// getFormInt 获取表单整数内部方法供ParsePaginationRequest使用
func getFormInt(r *http.Request, key string, defaultValue int) int {
value := r.FormValue(key)
if value == "" {
return defaultValue
}
intValue, err := strconv.Atoi(value)
if err != nil {
return defaultValue
}
return intValue
}
// PaginationRequest 分页请求结构 // PaginationRequest 分页请求结构
// 支持从JSON和form中解析分页参数 // 支持从JSON和form中解析分页参数
type PaginationRequest struct { type PaginationRequest struct {
Page int `json:"page" form:"page"` // 页码默认1 Page int `json:"page" form:"page"` // 页码默认1
Size int `json:"size" form:"size"` // 每页数量(兼容旧版本) PageSize int `json:"page_size" form:"page_size"` // 每页数量
PageSize int `json:"page_size" form:"page_size"` // 每页数量(推荐使用) Keyword string `json:"keyword" form:"keyword"` // 关键字
} }
// GetPage 获取页码如果未设置则返回默认值1 // GetPage 获取页码如果未设置则返回默认值1
@@ -51,13 +25,9 @@ func (p *PaginationRequest) GetPage() int {
return p.Page return p.Page
} }
// GetSize 获取每页数量如果未设置则返回默认值20最大限制100 // GetPageSize 获取每页数量如果未设置则返回默认值20最大限制100
// 优先使用 PageSize 字段,如果未设置则使用 Size 字段(兼容旧版本) func (p *PaginationRequest) GetPageSize() int {
func (p *PaginationRequest) GetSize() int {
size := p.PageSize size := p.PageSize
if size <= 0 {
size = p.Size // 兼容旧版本的 Size 字段
}
if size <= 0 { if size <= 0 {
return 20 // 默认20条 return 20 // 默认20条
} }
@@ -69,22 +39,20 @@ func (p *PaginationRequest) GetSize() int {
// GetOffset 计算数据库查询的偏移量 // GetOffset 计算数据库查询的偏移量
func (p *PaginationRequest) GetOffset() int { func (p *PaginationRequest) GetOffset() int {
return (p.GetPage() - 1) * p.GetSize() return (p.GetPage() - 1) * p.GetPageSize()
} }
// getPaginationFromQuery 从查询参数获取分页参数(内部辅助方法) // getPaginationFromQuery 从查询参数获取分页参数(内部辅助方法)
func getPaginationFromQuery(r *http.Request) (page, size, pageSize int) { func getPaginationFromQuery(r *http.Request) (page, pageSize int) {
page = getQueryInt(r, "page", 0) page = tools.ConvertInt(r.URL.Query().Get("page"), 0)
size = getQueryInt(r, "size", 0) pageSize = tools.ConvertInt(r.URL.Query().Get("page_size"), 0)
pageSize = getQueryInt(r, "page_size", 0)
return return
} }
// getPaginationFromForm 从form表单获取分页参数内部辅助方法 // getPaginationFromForm 从form表单获取分页参数内部辅助方法
func getPaginationFromForm(r *http.Request) (page, size, pageSize int) { func getPaginationFromForm(r *http.Request) (page, pageSize int) {
page = getFormInt(r, "page", 0) page = tools.ConvertInt(r.FormValue("page"), 0)
size = getFormInt(r, "size", 0) pageSize = tools.ConvertInt(r.FormValue("page_size"), 0)
pageSize = getFormInt(r, "page_size", 0)
return return
} }
@@ -96,17 +64,14 @@ func ParsePaginationRequest(r *http.Request) *PaginationRequest {
req := &PaginationRequest{} req := &PaginationRequest{}
// 1. 从查询参数解析(优先级最高) // 1. 从查询参数解析(优先级最高)
req.Page, req.Size, req.PageSize = getPaginationFromQuery(r) req.Page, req.PageSize = getPaginationFromQuery(r)
// 2. 如果查询参数中没有尝试从form表单解析 // 2. 如果查询参数中没有尝试从form表单解析
if req.Page == 0 || (req.Size == 0 && req.PageSize == 0) { if req.Page == 0 || req.PageSize == 0 {
page, size, pageSize := getPaginationFromForm(r) page, pageSize := getPaginationFromForm(r)
if req.Page == 0 && page != 0 { if req.Page == 0 && page != 0 {
req.Page = page req.Page = page
} }
if req.Size == 0 && size != 0 {
req.Size = size
}
if req.PageSize == 0 && pageSize != 0 { if req.PageSize == 0 && pageSize != 0 {
req.PageSize = pageSize req.PageSize = pageSize
} }
@@ -114,3 +79,30 @@ func ParsePaginationRequest(r *http.Request) *PaginationRequest {
return req return req
} }
// ParseJSON 解析JSON请求体公共方法
// r: HTTP请求
// v: 目标结构体指针
func ParseJSON(r *http.Request, v interface{}) error {
body, err := io.ReadAll(r.Body)
if err != nil {
return err
}
defer r.Body.Close()
if len(body) == 0 {
return nil
}
return json.Unmarshal(body, v)
}
// GetTimezone 从请求的 context 中获取时区
func GetTimezone(r *http.Request) string {
return requestctx.Timezone(r.Context())
}
// GetLanguage 从请求的 context 中获取语言
func GetLanguage(r *http.Request) string {
return requestctx.Language(r.Context())
}

View File

@@ -8,36 +8,24 @@ import (
// Response 标准响应结构 // Response 标准响应结构
type Response struct { type Response struct {
Code int `json:"code"` // 业务状态码0表示成功
Message string `json:"message"` // 响应消息
Timestamp int64 `json:"timestamp"` // 时间戳
Data interface{} `json:"data"` // 响应数据
}
// PageResponse 分页响应结构
type PageResponse struct {
Code int `json:"code"` Code int `json:"code"`
Message string `json:"message"` Message string `json:"message"`
Timestamp int64 `json:"timestamp"` Timestamp int64 `json:"timestamp"`
Data *PageData `json:"data"` Data interface{} `json:"data"`
} }
// PageData 分页数据 // PageData 分页数据
type PageData struct { type PageData struct {
List interface{} `json:"list"` // 数据列表 List interface{} `json:"list"`
Total int64 `json:"total"` // 总记录数 Total int64 `json:"total"`
Page int `json:"page"` // 当前页码 Page int `json:"page"`
PageSize int `json:"pageSize"` // 每页大小 PageSize int `json:"pageSize"`
} }
// writeJSON 写入JSON响应内部方法 // writeResponse 统一 JSON 出参HTTP 恒 200
// httpCode: HTTP状态码200表示正常500表示系统错误等 func writeResponse(w http.ResponseWriter, code int, message string, data interface{}) {
// code: 业务状态码0表示成功非0表示业务错误
// message: 响应消息
// data: 响应数据
func writeJSON(w http.ResponseWriter, httpCode, code int, message string, data interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(httpCode) w.WriteHeader(http.StatusOK)
response := Response{ response := Response{
Code: code, Code: code,
@@ -46,5 +34,5 @@ func writeJSON(w http.ResponseWriter, httpCode, code int, message string, data i
Data: data, Data: data,
} }
json.NewEncoder(w).Encode(response) _ = json.NewEncoder(w).Encode(response)
} }

286
i18n/i18n.go Normal file
View File

@@ -0,0 +1,286 @@
package i18n
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
)
// MessageInfo 消息信息结构
// 包含业务错误码和消息内容
type MessageInfo struct {
Code int `json:"code"` // 业务错误码
Message string `json:"message"` // 消息内容
}
// I18n 国际化工具
// 支持多语言内容管理,通过语言代码和消息代码获取对应语言的内容
type I18n struct {
messages map[string]map[string]MessageInfo // 存储格式messages[语言][code] = MessageInfo
defaultLang string // 默认语言代码
mu sync.RWMutex // 读写锁,保证并发安全
}
// NewI18n 创建国际化工具实例
// defaultLang: 默认语言代码(如 "zh-CN", "en-US"),当指定语言不存在时使用
func NewI18n(defaultLang string) *I18n {
return &I18n{
messages: make(map[string]map[string]MessageInfo),
defaultLang: defaultLang,
}
}
// LoadFromFile 从单个语言文件加载内容
// filePath: 语言文件路径JSON格式
// lang: 语言代码(如 "zh-CN", "en-US"
//
// 文件格式示例zh-CN.json
//
// {
// "user.not_found": {
// "code": 1001,
// "message": "用户不存在"
// },
// "user.login_success": {
// "code": 0,
// "message": "登录成功"
// },
// "user.welcome": {
// "code": 0,
// "message": "欢迎,%s"
// }
// }
func (i *I18n) LoadFromFile(filePath, lang string) error {
data, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("failed to read file %s: %w", filePath, err)
}
var messages map[string]MessageInfo
if err := json.Unmarshal(data, &messages); err != nil {
return fmt.Errorf("failed to parse JSON file %s: %w", filePath, err)
}
i.mu.Lock()
defer i.mu.Unlock()
if i.messages[lang] == nil {
i.messages[lang] = make(map[string]MessageInfo)
}
// 合并到现有消息中如果key已存在会被覆盖
for k, v := range messages {
i.messages[lang][k] = v
}
return nil
}
// LoadFromDir 从目录加载多个语言文件
// dirPath: 语言文件目录路径
// 文件命名规则:{语言代码}.json如 zh-CN.json, en-US.json
//
// 示例目录结构:
//
// locales/
// zh-CN.json
// en-US.json
// ja-JP.json
func (i *I18n) LoadFromDir(dirPath string) error {
entries, err := os.ReadDir(dirPath)
if err != nil {
return fmt.Errorf("failed to read directory %s: %w", dirPath, err)
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
// 只处理 .json 文件
if !strings.HasSuffix(entry.Name(), ".json") {
continue
}
// 从文件名提取语言代码(去掉 .json 后缀)
lang := strings.TrimSuffix(entry.Name(), ".json")
filePath := filepath.Join(dirPath, entry.Name())
if err := i.LoadFromFile(filePath, lang); err != nil {
return fmt.Errorf("failed to load language file %s: %w", filePath, err)
}
}
return nil
}
// LoadFromMap 从map加载语言内容用于测试或动态加载
// lang: 语言代码
// messages: 消息mapkey为消息代码value为消息信息
//
// 示例:
//
// i18n.LoadFromMap("zh-CN", map[string]MessageInfo{
// "user.not_found": {Code: 1001, Message: "用户不存在"},
// "user.login_success": {Code: 0, Message: "登录成功"},
// })
func (i *I18n) LoadFromMap(lang string, messages map[string]MessageInfo) {
i.mu.Lock()
defer i.mu.Unlock()
if i.messages[lang] == nil {
i.messages[lang] = make(map[string]MessageInfo)
}
// 合并到现有消息中
for k, v := range messages {
i.messages[lang][k] = v
}
}
// GetMessage 获取指定语言和代码的消息内容
// lang: 语言代码(如 "zh-CN", "en-US"
// code: 消息代码(如 "user.not_found"
// args: 可选参数,用于格式化消息(类似 fmt.Sprintf
//
// 返回逻辑:
// 1. 如果指定语言存在该code返回对应内容
// 2. 如果指定语言不存在,尝试使用默认语言
// 3. 如果默认语言也不存在返回code本身作为fallback
//
// 示例:
//
// msg := i18n.GetMessage("zh-CN", "user.not_found")
// // 返回: "用户不存在"
//
// msg := i18n.GetMessage("zh-CN", "user.welcome", "Alice")
// // 如果消息内容是 "欢迎,%s",返回: "欢迎Alice"
func (i *I18n) GetMessage(lang, code string, args ...interface{}) string {
info := i.GetMessageInfo(lang, code, args...)
return info.Message
}
// GetMessageInfo 获取指定语言和代码的完整消息信息包含业务code
// lang: 语言代码(如 "zh-CN", "en-US"
// code: 消息代码(如 "user.not_found"
// args: 可选参数,用于格式化消息(类似 fmt.Sprintf
//
// 返回逻辑:
// 1. 如果指定语言存在该code返回对应的MessageInfo
// 2. 如果指定语言不存在,尝试使用默认语言
// 3. 如果默认语言也不存在返回code本身作为messagecode为0
//
// 示例:
//
// info := i18n.GetMessageInfo("zh-CN", "user.not_found")
// // 返回: MessageInfo{Code: 1001, Message: "用户不存在"}
func (i *I18n) GetMessageInfo(lang, code string, args ...interface{}) MessageInfo {
i.mu.RLock()
defer i.mu.RUnlock()
// 尝试从指定语言获取
if messages, ok := i.messages[lang]; ok {
if msgInfo, ok := messages[code]; ok {
// 格式化消息内容
msgInfo.Message = i.formatMessage(msgInfo.Message, args...)
return msgInfo
}
}
// 如果指定语言不存在该code尝试使用默认语言
if i.defaultLang != "" && i.defaultLang != lang {
if messages, ok := i.messages[i.defaultLang]; ok {
if msgInfo, ok := messages[code]; ok {
// 格式化消息内容
msgInfo.Message = i.formatMessage(msgInfo.Message, args...)
return msgInfo
}
}
}
// 如果都不存在返回code本身作为messagecode为0作为fallback
return MessageInfo{
Code: 0,
Message: code,
}
}
// formatMessage 格式化消息(支持参数替换)
// 如果消息中包含 %s, %d 等格式化占位符,使用 args 进行替换
func (i *I18n) formatMessage(msg string, args ...interface{}) string {
if len(args) == 0 {
return msg
}
// 检查消息中是否包含格式化占位符
if strings.Contains(msg, "%") {
return fmt.Sprintf(msg, args...)
}
return msg
}
// SetDefaultLang 设置默认语言
// lang: 默认语言代码
func (i *I18n) SetDefaultLang(lang string) {
i.mu.Lock()
defer i.mu.Unlock()
i.defaultLang = lang
}
// GetDefaultLang 获取默认语言代码
func (i *I18n) GetDefaultLang() string {
i.mu.RLock()
defer i.mu.RUnlock()
return i.defaultLang
}
// HasLang 检查是否已加载指定语言
// lang: 语言代码
func (i *I18n) HasLang(lang string) bool {
i.mu.RLock()
defer i.mu.RUnlock()
_, ok := i.messages[lang]
return ok
}
// GetSupportedLangs 获取所有已加载的语言代码列表
func (i *I18n) GetSupportedLangs() []string {
i.mu.RLock()
defer i.mu.RUnlock()
langs := make([]string, 0, len(i.messages))
for lang := range i.messages {
langs = append(langs, lang)
}
return langs
}
// ReloadFromFile 重新加载指定语言文件
// filePath: 语言文件路径
// lang: 语言代码
func (i *I18n) ReloadFromFile(filePath, lang string) error {
// 先清除该语言的所有消息
i.mu.Lock()
delete(i.messages, lang)
i.mu.Unlock()
// 重新加载
return i.LoadFromFile(filePath, lang)
}
// ReloadFromDir 重新加载目录中的所有语言文件
// dirPath: 语言文件目录路径
func (i *I18n) ReloadFromDir(dirPath string) error {
// 先清除所有消息
i.mu.Lock()
i.messages = make(map[string]map[string]MessageInfo)
i.mu.Unlock()
// 重新加载
return i.LoadFromDir(dirPath)
}

View File

@@ -1,44 +1,138 @@
package logger package logger
import ( import (
"context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"log"
"os" "os"
"path/filepath" "path/filepath"
"sync"
"sync/atomic"
"time"
"git.toowon.com/jimmy/go-common/config" "git.toowon.com/jimmy/go-common/config"
) )
type ctxKey int
const requestIDKey ctxKey = iota
// WithRequestID 将 Request ID 写入 context
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey, id)
}
// RequestIDFromContext 从 context 读取 Request ID
func RequestIDFromContext(ctx context.Context) string {
if ctx == nil {
return ""
}
if id, ok := ctx.Value(requestIDKey).(string); ok {
return id
}
return ""
}
var (
defaultLogger *Logger
defaultMux sync.RWMutex
)
func init() {
l, err := NewLogger(nil)
if err != nil {
defaultLogger = nil
return
}
defaultLogger = l
}
// SetDefaultLogger 设置全局默认 logger
func SetDefaultLogger(l *Logger) {
defaultMux.Lock()
defer defaultMux.Unlock()
if defaultLogger != nil && defaultLogger != l {
_ = defaultLogger.Close()
}
defaultLogger = l
}
func getDefaultLogger() *Logger {
defaultMux.RLock()
defer defaultMux.RUnlock()
return defaultLogger
}
type logEntry struct {
level string
message string
fields map[string]any
}
// Logger 日志记录器 // Logger 日志记录器
type Logger struct { type Logger struct {
infoLog *log.Logger level string
errorLog *log.Logger writers []io.Writer
warnLog *log.Logger prefix string
debugLog *log.Logger async bool
config *config.LoggerConfig logChan chan logEntry
done chan struct{}
wg sync.WaitGroup
closed bool
mu sync.RWMutex
dropped atomic.Uint64
} }
// NewLogger 创建日志记录器 // NewLogger 创建日志记录器
func NewLogger(cfg *config.LoggerConfig) (*Logger, error) { func NewLogger(cfg *config.LoggerConfig) (*Logger, error) {
if cfg == nil { if cfg == nil {
// 使用默认配置
cfg = &config.LoggerConfig{ cfg = &config.LoggerConfig{
Level: "info", Level: "info",
Output: "stdout", Output: "stdout",
FilePath: "", Async: config.BoolPtr(true),
BufferSize: 1000,
} }
} }
// 设置默认值
if cfg.Level == "" { if cfg.Level == "" {
cfg.Level = "info" cfg.Level = "info"
} }
if cfg.Output == "" { if cfg.Output == "" {
cfg.Output = "stdout" cfg.Output = "stdout"
} }
if cfg.BufferSize <= 0 {
cfg.BufferSize = 1000
}
// 创建输出目标 writers, err := createWriters(cfg)
if err != nil {
return nil, err
}
prefix := ""
if cfg.Prefix != "" {
prefix = cfg.Prefix + " "
}
l := &Logger{
level: cfg.Level,
writers: writers,
prefix: prefix,
async: cfg.IsAsync(),
}
if cfg.IsAsync() {
l.logChan = make(chan logEntry, cfg.BufferSize)
l.done = make(chan struct{})
l.wg.Add(1)
go l.processLogs()
}
return l, nil
}
func createWriters(cfg *config.LoggerConfig) ([]io.Writer, error) {
var writers []io.Writer var writers []io.Writer
switch cfg.Output { switch cfg.Output {
case "stdout": case "stdout":
@@ -49,159 +143,219 @@ func NewLogger(cfg *config.LoggerConfig) (*Logger, error) {
if cfg.FilePath == "" { if cfg.FilePath == "" {
return nil, fmt.Errorf("file path is required when output is file") return nil, fmt.Errorf("file path is required when output is file")
} }
// 确保目录存在 w, err := openLogFile(cfg.FilePath)
dir := filepath.Dir(cfg.FilePath)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("failed to create log directory: %w", err)
}
file, err := os.OpenFile(cfg.FilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to open log file: %w", err) return nil, err
} }
writers = append(writers, file) writers = append(writers, w)
case "both": case "both":
writers = append(writers, os.Stdout) writers = append(writers, os.Stdout)
if cfg.FilePath == "" { if cfg.FilePath == "" {
return nil, fmt.Errorf("file path is required when output is both") return nil, fmt.Errorf("file path is required when output is both")
} }
dir := filepath.Dir(cfg.FilePath) w, err := openLogFile(cfg.FilePath)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("failed to create log directory: %w", err)
}
file, err := os.OpenFile(cfg.FilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to open log file: %w", err) return nil, err
} }
writers = append(writers, file) writers = append(writers, w)
default: default:
return nil, fmt.Errorf("invalid output type: %s", cfg.Output) return nil, fmt.Errorf("invalid output type: %s", cfg.Output)
} }
return writers, nil
}
// 创建多写入器 func openLogFile(path string) (io.Writer, error) {
multiWriter := io.MultiWriter(writers...) dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
// 创建日志前缀 return nil, fmt.Errorf("failed to create log directory: %w", err)
prefix := ""
if cfg.Prefix != "" {
prefix = cfg.Prefix + " "
} }
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666)
// 创建日志记录器 if err != nil {
logger := &Logger{ return nil, fmt.Errorf("failed to open log file: %w", err)
config: cfg,
} }
return f, nil
}
// 根据日志级别创建不同的logger func (l *Logger) processLogs() {
flags := log.LstdFlags defer l.wg.Done()
if cfg.DisableTimestamp { for {
flags = 0 select {
case msg, ok := <-l.logChan:
if !ok {
return
} }
l.writeEntry(msg)
if cfg.Level == "debug" || cfg.Level == "info" || cfg.Level == "warn" || cfg.Level == "error" { case <-l.done:
logger.infoLog = log.New(multiWriter, prefix+"[INFO] ", flags) for {
logger.warnLog = log.New(multiWriter, prefix+"[WARN] ", flags) select {
logger.errorLog = log.New(multiWriter, prefix+"[ERROR] ", flags) case msg, ok := <-l.logChan:
if cfg.Level == "debug" { if !ok {
logger.debugLog = log.New(multiWriter, prefix+"[DEBUG] ", flags) return
}
l.writeEntry(msg)
default:
return
} }
} }
}
}
}
return logger, nil func (l *Logger) isClosed() bool {
l.mu.RLock()
defer l.mu.RUnlock()
return l.closed
}
func (l *Logger) shouldLog(level string) bool {
switch l.level {
case "debug":
return true
case "info":
return level != "debug"
case "error":
return level == "error"
default:
return level != "debug"
}
}
func (l *Logger) emit(level, message string, fields map[string]any) {
if l.isClosed() || !l.shouldLog(level) {
return
}
entry := logEntry{level: level, message: message, fields: fields}
if l.async {
select {
case l.logChan <- entry:
default:
l.dropped.Add(1)
}
return
}
l.writeEntry(entry)
}
func (l *Logger) writeEntry(entry logEntry) {
line := l.formatLine(entry.level, entry.message, entry.fields)
for _, w := range l.writers {
_, _ = fmt.Fprintln(w, line)
}
}
func (l *Logger) formatLine(level, message string, fields map[string]any) string {
ts := time.Now().Format("2006-01-02 15:04:05")
payload := map[string]any{
"time": ts,
"level": level,
"message": message,
}
for k, v := range fields {
payload[k] = v
}
b, err := json.Marshal(payload)
if err != nil {
return fmt.Sprintf("%s[%s] %s %v", l.prefix, level, message, fields)
}
return l.prefix + string(b)
} }
// Debug 记录调试日志 // Debug 记录调试日志
func (l *Logger) Debug(format string, v ...interface{}) { func (l *Logger) Debug(message string, fields map[string]any) {
if l.debugLog != nil { l.emit("debug", message, fields)
l.debugLog.Printf(format, v...)
}
} }
// Info 记录信息日志 // Info 记录信息日志
func (l *Logger) Info(format string, v ...interface{}) { func (l *Logger) Info(message string, fields map[string]any) {
if l.infoLog != nil { l.emit("info", message, fields)
l.infoLog.Printf(format, v...)
}
}
// Warn 记录警告日志
func (l *Logger) Warn(format string, v ...interface{}) {
if l.warnLog != nil {
l.warnLog.Printf(format, v...)
}
} }
// Error 记录错误日志 // Error 记录错误日志
func (l *Logger) Error(format string, v ...interface{}) { func (l *Logger) Error(message string, fields map[string]any) {
if l.errorLog != nil { l.emit("error", message, fields)
l.errorLog.Printf(format, v...)
}
} }
// Fatal 记录致命错误日志并退出程序 // Close 刷盘并关闭异步队列
func (l *Logger) Fatal(format string, v ...interface{}) { func (l *Logger) Close() error {
if l.errorLog != nil { if !l.async {
l.errorLog.Fatalf(format, v...) return nil
} else {
log.Fatalf(format, v...)
} }
l.mu.Lock()
if l.closed {
l.mu.Unlock()
return nil
}
l.closed = true
l.mu.Unlock()
close(l.done)
close(l.logChan)
l.wg.Wait()
return nil
} }
// Panic 记录恐慌日志并触发panic // DroppedCount 返回因队列满而丢弃的日志条数
func (l *Logger) Panic(format string, v ...interface{}) { func (l *Logger) DroppedCount() uint64 {
if l.errorLog != nil { return l.dropped.Load()
l.errorLog.Panicf(format, v...)
} else {
log.Panicf(format, v...)
}
} }
// WithFields 创建带字段的日志记录器(简化版,返回格式化字符串 // ContextLogger 带 context 的 logger自动附加 request_id
func (l *Logger) WithFields(fields map[string]interface{}) *Logger { type ContextLogger struct {
// 返回自身实际使用时可以在format中包含fields base *Logger
return l ctx context.Context
} }
// formatFields 格式化字段 // FromContext 从 context 获取 logger自动附加 request_id
func formatFields(fields map[string]interface{}) string { func FromContext(ctx context.Context) *ContextLogger {
if len(fields) == 0 { base := getDefaultLogger()
return "" if base == nil {
if l, err := NewLogger(nil); err == nil {
base = l
} }
result := "" }
return &ContextLogger{base: base, ctx: ctx}
}
// FromContextWithLogger 使用指定 logger 并从 context 附加 request_id
func FromContextWithLogger(ctx context.Context, base *Logger) *ContextLogger {
if base == nil {
base = getDefaultLogger()
}
return &ContextLogger{base: base, ctx: ctx}
}
func (c *ContextLogger) mergeFields(fields map[string]any) map[string]any {
out := make(map[string]any, len(fields)+1)
for k, v := range fields { for k, v := range fields {
result += fmt.Sprintf("%s=%v ", k, v) out[k] = v
} }
return result if id := RequestIDFromContext(c.ctx); id != "" {
out["request_id"] = id
}
return out
} }
// Debugf 记录调试日志(带字段) // Debug 记录调试日志
func (l *Logger) Debugf(fields map[string]interface{}, format string, v ...interface{}) { func (c *ContextLogger) Debug(message string, fields map[string]any) {
if l.debugLog != nil { if c.base == nil {
fieldStr := formatFields(fields) return
l.debugLog.Printf(fieldStr+format, v...)
} }
c.base.Debug(message, c.mergeFields(fields))
} }
// Infof 记录信息日志(带字段) // Info 记录信息日志
func (l *Logger) Infof(fields map[string]interface{}, format string, v ...interface{}) { func (c *ContextLogger) Info(message string, fields map[string]any) {
if l.infoLog != nil { if c.base == nil {
fieldStr := formatFields(fields) return
l.infoLog.Printf(fieldStr+format, v...)
} }
c.base.Info(message, c.mergeFields(fields))
} }
// Warnf 记录警告日志(带字段) // Error 记录错误日志
func (l *Logger) Warnf(fields map[string]interface{}, format string, v ...interface{}) { func (c *ContextLogger) Error(message string, fields map[string]any) {
if l.warnLog != nil { if c.base == nil {
fieldStr := formatFields(fields) return
l.warnLog.Printf(fieldStr+format, v...)
} }
c.base.Error(message, c.mergeFields(fields))
} }
// Errorf 记录错误日志(带字段)
func (l *Logger) Errorf(fields map[string]interface{}, format string, v ...interface{}) {
if l.errorLog != nil {
fieldStr := formatFields(fields)
l.errorLog.Printf(fieldStr+format, v...)
}
}

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)
}
}

26
middleware/clientip.go Normal file
View File

@@ -0,0 +1,26 @@
package middleware
import "net/http"
// GetClientIP 获取客户端真实 IP
func GetClientIP(r *http.Request) string {
xff := r.Header.Get("X-Forwarded-For")
if xff != "" {
for i := 0; i < len(xff); i++ {
if xff[i] == ',' {
return xff[:i]
}
}
return xff
}
if xri := r.Header.Get("X-Real-IP"); xri != "" {
return xri
}
remoteAddr := r.RemoteAddr
for i := len(remoteAddr) - 1; i >= 0; i-- {
if remoteAddr[i] == ':' {
return remoteAddr[:i]
}
}
return remoteAddr
}

View File

@@ -43,6 +43,36 @@ func DefaultCORSConfig() *CORSConfig {
} }
} }
// NewCORSConfig 从配置参数创建 CORSConfig
// 用于从 config 包的 CORSConfig 转换为 middleware 的 CORSConfig
// 避免循环依赖
func NewCORSConfig(allowedOrigins, allowedMethods, allowedHeaders, exposedHeaders []string, allowCredentials bool, maxAge int) *CORSConfig {
cfg := &CORSConfig{
AllowedOrigins: allowedOrigins,
AllowedMethods: allowedMethods,
AllowedHeaders: allowedHeaders,
ExposedHeaders: exposedHeaders,
AllowCredentials: allowCredentials,
MaxAge: maxAge,
}
// 设置默认值(如果为空)
if len(cfg.AllowedOrigins) == 0 {
cfg.AllowedOrigins = []string{"*"}
}
if len(cfg.AllowedMethods) == 0 {
cfg.AllowedMethods = []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"}
}
if len(cfg.AllowedHeaders) == 0 {
cfg.AllowedHeaders = []string{"Content-Type", "Authorization", "X-Requested-With", "X-Timezone"}
}
if cfg.MaxAge == 0 {
cfg.MaxAge = 86400
}
return cfg
}
// CORS CORS中间件 // CORS CORS中间件
func CORS(config ...*CORSConfig) func(http.Handler) http.Handler { func CORS(config ...*CORSConfig) func(http.Handler) http.Handler {
var cfg *CORSConfig var cfg *CORSConfig

93
middleware/language.go Normal file
View File

@@ -0,0 +1,93 @@
package middleware
import (
"context"
"net/http"
"strings"
"git.toowon.com/jimmy/go-common/requestctx"
)
// LanguageHeaderName 语言请求头名称
const LanguageHeaderName = "X-Language"
// AcceptLanguageHeaderName Accept-Language 请求头名称
const AcceptLanguageHeaderName = "Accept-Language"
// GetLanguageFromContext 从 context 中获取语言
func GetLanguageFromContext(ctx context.Context) string {
return requestctx.Language(ctx)
}
// Language 语言处理中间件
// 从请求头 X-Language 或 Accept-Language 读取语言信息,如果未传递则使用默认语言 zh-CN
// 语言信息会存储到context中可以通过 GetLanguageFromContext 获取
func Language(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 1. 优先从 X-Language 请求头获取(显式指定)
lang := r.Header.Get(LanguageHeaderName)
// 2. 如果未设置,从 Accept-Language 请求头解析
if lang == "" {
acceptLang := r.Header.Get(AcceptLanguageHeaderName)
if acceptLang != "" {
lang = parseAcceptLanguage(acceptLang)
}
}
// 3. 如果都未设置,使用默认语言
if lang == "" {
lang = requestctx.DefaultLanguage
}
ctx := requestctx.WithLanguage(r.Context(), lang)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// LanguageWithDefault 语言处理中间件(可自定义默认语言)
// defaultLanguage: 默认语言,如果未指定则使用 zh-CN
func LanguageWithDefault(defaultLanguage string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 1. 优先从 X-Language 请求头获取(显式指定)
lang := r.Header.Get(LanguageHeaderName)
// 2. 如果未设置,从 Accept-Language 请求头解析
if lang == "" {
acceptLang := r.Header.Get(AcceptLanguageHeaderName)
if acceptLang != "" {
lang = parseAcceptLanguage(acceptLang)
}
}
// 3. 如果都未设置,使用指定的默认语言
if lang == "" {
lang = defaultLanguage
}
ctx := requestctx.WithLanguage(r.Context(), lang)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// parseAcceptLanguage 解析 Accept-Language 请求头
// 返回第一个语言代码(去掉权重信息)
func parseAcceptLanguage(acceptLang string) string {
if acceptLang == "" {
return ""
}
// 分割语言列表
parts := strings.Split(acceptLang, ",")
if len(parts) == 0 {
return ""
}
// 取第一个语言代码,去掉权重信息(如 ";q=0.9"
firstLang := strings.Split(parts[0], ";")[0]
firstLang = strings.TrimSpace(firstLang)
return firstLang
}

66
middleware/logging.go Normal file
View File

@@ -0,0 +1,66 @@
package middleware
import (
"net/http"
"time"
"git.toowon.com/jimmy/go-common/logger"
)
// responseWriter 包装 ResponseWriter 以捕获状态码
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(statusCode int) {
rw.statusCode = statusCode
rw.ResponseWriter.WriteHeader(statusCode)
}
// LoggingConfig 日志中间件配置
type LoggingConfig struct {
Logger *logger.Logger
SkipPaths []string
}
// Logging HTTP 访问日志(固定 Info 级别)
func Logging(config *LoggingConfig) func(http.Handler) http.Handler {
if config == nil {
config = &LoggingConfig{}
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if shouldSkipPath(r.URL.Path, config.SkipPaths) {
next.ServeHTTP(w, r)
return
}
start := time.Now()
rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(rw, r)
fields := map[string]any{
"method": r.Method,
"path": r.URL.Path,
"duration": time.Since(start).Milliseconds(),
}
log := logger.FromContext(r.Context())
if config.Logger != nil {
log = logger.FromContextWithLogger(r.Context(), config.Logger)
}
log.Info("HTTP Request", fields)
})
}
}
func shouldSkipPath(path string, skipPaths []string) bool {
for _, skip := range skipPaths {
if path == skip {
return true
}
}
return false
}

286
middleware/ratelimit.go Normal file
View File

@@ -0,0 +1,286 @@
package middleware
import (
"net/http"
"sync"
"time"
)
// RateLimiter 限流器接口
type RateLimiter interface {
// Allow 检查是否允许请求
// key: 限流键如IP地址、用户ID等
// 返回: 是否允许, 剩余配额, 重置时间
Allow(key string) (allowed bool, remaining int, resetTime time.Time)
}
// tokenBucketLimiter 令牌桶限流器
type tokenBucketLimiter struct {
rate int // 每个窗口期允许的请求数
windowSize time.Duration // 窗口大小
buckets map[string]*bucket
mu sync.RWMutex
cleanupTicker *time.Ticker
stopCleanup chan struct{}
}
// bucket 令牌桶
type bucket struct {
tokens int // 当前令牌数
lastRefill time.Time // 上次填充时间
mu sync.Mutex
}
// NewTokenBucketLimiter 创建令牌桶限流器
func NewTokenBucketLimiter(rate int, windowSize time.Duration) RateLimiter {
limiter := &tokenBucketLimiter{
rate: rate,
windowSize: windowSize,
buckets: make(map[string]*bucket),
stopCleanup: make(chan struct{}),
}
// 启动清理goroutine定期清理过期的bucket
limiter.cleanupTicker = time.NewTicker(windowSize * 2)
go limiter.cleanup()
return limiter
}
// cleanup 定期清理过期的bucket
func (l *tokenBucketLimiter) cleanup() {
for {
select {
case <-l.cleanupTicker.C:
l.mu.Lock()
now := time.Now()
for key, bkt := range l.buckets {
bkt.mu.Lock()
// 如果bucket超过2个窗口期没有使用删除它
if now.Sub(bkt.lastRefill) > l.windowSize*2 {
delete(l.buckets, key)
}
bkt.mu.Unlock()
}
l.mu.Unlock()
case <-l.stopCleanup:
l.cleanupTicker.Stop()
return
}
}
}
// Allow 检查是否允许请求
func (l *tokenBucketLimiter) Allow(key string) (bool, int, time.Time) {
now := time.Now()
// 获取或创建bucket
l.mu.Lock()
bkt, exists := l.buckets[key]
if !exists {
bkt = &bucket{
tokens: l.rate,
lastRefill: now,
}
l.buckets[key] = bkt
}
l.mu.Unlock()
// 尝试消费令牌
bkt.mu.Lock()
defer bkt.mu.Unlock()
// 计算需要填充的令牌数
elapsed := now.Sub(bkt.lastRefill)
if elapsed >= l.windowSize {
// 窗口期已过,重新填充
bkt.tokens = l.rate
bkt.lastRefill = now
}
// 检查是否有可用令牌
if bkt.tokens > 0 {
bkt.tokens--
resetTime := bkt.lastRefill.Add(l.windowSize)
return true, bkt.tokens, resetTime
}
// 没有可用令牌
resetTime := bkt.lastRefill.Add(l.windowSize)
return false, 0, resetTime
}
// RateLimitConfig 限流中间件配置
type RateLimitConfig struct {
// Limiter 限流器(必需)
// 如果为nil会使用默认的令牌桶限流器100请求/分钟)
Limiter RateLimiter
// KeyFunc 生成限流键的函数(可选)
// 默认使用客户端IP作为键
// 可以自定义为用户ID、API Key等
KeyFunc func(r *http.Request) string
// OnRateLimitExceeded 当限流被触发时的回调(可选)
// 可以用于记录日志、发送告警等
OnRateLimitExceeded func(w http.ResponseWriter, r *http.Request, key string)
}
// RateLimit 限流中间件
// 实现基于令牌桶算法的请求限流
//
// 使用方式1使用默认配置100请求/分钟按IP限流
//
// chain := middleware.NewChain(
// middleware.RateLimit(nil),
// )
//
// 使用方式2自定义限流规则
//
// limiter := middleware.NewTokenBucketLimiter(10, time.Minute) // 10请求/分钟
// chain := middleware.NewChain(
// middleware.RateLimit(&middleware.RateLimitConfig{
// Limiter: limiter,
// }),
// )
//
// 使用方式3按用户ID限流
//
// chain := middleware.NewChain(
// middleware.RateLimit(&middleware.RateLimitConfig{
// Limiter: limiter,
// KeyFunc: func(r *http.Request) string {
// // 从请求头或token中获取用户ID
// return r.Header.Get("X-User-ID")
// },
// }),
// )
func RateLimit(config *RateLimitConfig) func(http.Handler) http.Handler {
// 如果没有配置,使用默认配置
if config == nil {
config = &RateLimitConfig{}
}
// 如果没有提供限流器创建默认的100请求/分钟)
if config.Limiter == nil {
config.Limiter = NewTokenBucketLimiter(100, time.Minute)
}
// 如果没有提供KeyFunc使用默认的客户端IP
if config.KeyFunc == nil {
config.KeyFunc = func(r *http.Request) string {
return GetClientIP(r)
}
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 生成限流键
key := config.KeyFunc(r)
if key == "" {
// 如果无法生成键,允许请求通过
next.ServeHTTP(w, r)
return
}
// 检查是否允许请求
allowed, remaining, resetTime := config.Limiter.Allow(key)
// 设置限流相关的响应头
w.Header().Set("X-RateLimit-Limit", formatInt(config.Limiter.(*tokenBucketLimiter).rate))
w.Header().Set("X-RateLimit-Remaining", formatInt(remaining))
w.Header().Set("X-RateLimit-Reset", formatInt64(resetTime.Unix()))
if !allowed {
// 触发限流回调
if config.OnRateLimitExceeded != nil {
config.OnRateLimitExceeded(w, r, key)
}
// 返回429错误
w.Header().Set("Retry-After", formatInt64(int64(time.Until(resetTime).Seconds())))
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
return
}
// 允许请求通过
next.ServeHTTP(w, r)
})
}
}
// RateLimitWithRate 使用指定速率创建限流中间件(便捷函数)
// rate: 每个窗口期允许的请求数
// windowSize: 窗口大小
func RateLimitWithRate(rate int, windowSize time.Duration) func(http.Handler) http.Handler {
return RateLimit(&RateLimitConfig{
Limiter: NewTokenBucketLimiter(rate, windowSize),
})
}
// RateLimitByIP 按IP限流便捷函数
func RateLimitByIP(rate int, windowSize time.Duration) func(http.Handler) http.Handler {
return RateLimit(&RateLimitConfig{
Limiter: NewTokenBucketLimiter(rate, windowSize),
KeyFunc: func(r *http.Request) string {
return GetClientIP(r)
},
})
}
// formatInt 格式化int为字符串
func formatInt(n int) string {
if n == 0 {
return "0"
}
// 简单的int转字符串
var buf [20]byte
i := len(buf) - 1
negative := n < 0
if negative {
n = -n
}
for n > 0 {
buf[i] = byte('0' + n%10)
n /= 10
i--
}
if negative {
buf[i] = '-'
i--
}
return string(buf[i+1:])
}
// formatInt64 格式化int64为字符串
func formatInt64(n int64) string {
if n == 0 {
return "0"
}
// 简单的int64转字符串
var buf [20]byte
i := len(buf) - 1
negative := n < 0
if negative {
n = -n
}
for n > 0 {
buf[i] = byte('0' + n%10)
n /= 10
i--
}
if negative {
buf[i] = '-'
i--
}
return string(buf[i+1:])
}

47
middleware/recovery.go Normal file
View File

@@ -0,0 +1,47 @@
package middleware
import (
"fmt"
"net/http"
"runtime/debug"
commonhttp "git.toowon.com/jimmy/go-common/http"
"git.toowon.com/jimmy/go-common/i18n"
"git.toowon.com/jimmy/go-common/logger"
)
// RecoveryConfig Recovery 中间件配置
type RecoveryConfig struct {
Logger *logger.Logger
I18n *i18n.I18n
}
// Recovery Panic 恢复中间件,响应统一 JSON 200 + system.internal_error
func Recovery(config *RecoveryConfig) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
fields := map[string]any{
"method": r.Method,
"path": r.URL.Path,
"error": fmt.Sprintf("%v", err),
"stack": string(debug.Stack()),
}
log := logger.FromContextWithLogger(r.Context(), nil)
if config != nil && config.Logger != nil {
log = logger.FromContextWithLogger(r.Context(), config.Logger)
}
log.Error("panic recovered", fields)
h := commonhttp.NewHandler(w, r)
if config != nil && config.I18n != nil {
h = commonhttp.NewHandler(w, r, commonhttp.WithI18n(config.I18n))
}
h.Error("system.internal_error")
}
}()
next.ServeHTTP(w, r)
})
}
}

23
middleware/requestid.go Normal file
View File

@@ -0,0 +1,23 @@
package middleware
import (
"net/http"
"git.toowon.com/jimmy/go-common/logger"
"github.com/google/uuid"
)
// RequestID 为每个请求生成或透传 Request ID写入 context 与响应头
func RequestID() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" {
id = uuid.NewString()
}
w.Header().Set("X-Request-ID", id)
ctx := logger.WithRequestID(r.Context(), id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}

View File

@@ -4,79 +4,49 @@ import (
"context" "context"
"net/http" "net/http"
"git.toowon.com/jimmy/go-common/datetime" "git.toowon.com/jimmy/go-common/requestctx"
"git.toowon.com/jimmy/go-common/tools"
) )
// TimezoneKey context中存储时区的key
type timezoneKey struct{}
// TimezoneHeaderName 时区请求头名称 // TimezoneHeaderName 时区请求头名称
const TimezoneHeaderName = "X-Timezone" const TimezoneHeaderName = "X-Timezone"
// DefaultTimezone 默认时区 // GetTimezoneFromContext 从 context 中获取时区
const DefaultTimezone = datetime.AsiaShanghai
// GetTimezoneFromContext 从context中获取时区
func GetTimezoneFromContext(ctx context.Context) string { func GetTimezoneFromContext(ctx context.Context) string {
if tz, ok := ctx.Value(timezoneKey{}).(string); ok && tz != "" { return requestctx.Timezone(ctx)
return tz
}
return DefaultTimezone
} }
// Timezone 时区处理中间件 // Timezone 时区处理中间件
// 从请求头 X-Timezone 读取时区信息,如果未传递则使用默认时区 AsiaShanghai
// 时区信息会存储到context中可以通过 GetTimezoneFromContext 获取
func Timezone(next http.Handler) http.Handler { func Timezone(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 从请求头获取时区
timezone := r.Header.Get(TimezoneHeaderName) timezone := r.Header.Get(TimezoneHeaderName)
// 如果未传递时区信息,使用默认时区
if timezone == "" { if timezone == "" {
timezone = DefaultTimezone timezone = requestctx.DefaultTimezone
} }
if _, err := tools.GetLocation(timezone); err != nil {
// 验证时区是否有效 timezone = requestctx.DefaultTimezone
if _, err := datetime.GetLocation(timezone); err != nil {
// 如果时区无效,使用默认时区
timezone = DefaultTimezone
} }
ctx := requestctx.WithTimezone(r.Context(), timezone)
// 将时区存储到context中
ctx := context.WithValue(r.Context(), timezoneKey{}, timezone)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })
} }
// TimezoneWithDefault 时区处理中间件(可自定义默认时区) // TimezoneWithDefault 时区处理中间件(可自定义默认时区)
// defaultTimezone: 默认时区,如果未指定则使用 AsiaShanghai
func TimezoneWithDefault(defaultTimezone string) func(http.Handler) http.Handler { func TimezoneWithDefault(defaultTimezone string) func(http.Handler) http.Handler {
// 验证默认时区是否有效 if _, err := tools.GetLocation(defaultTimezone); err != nil {
if _, err := datetime.GetLocation(defaultTimezone); err != nil { defaultTimezone = requestctx.DefaultTimezone
defaultTimezone = DefaultTimezone
} }
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 从请求头获取时区
timezone := r.Header.Get(TimezoneHeaderName) timezone := r.Header.Get(TimezoneHeaderName)
// 如果未传递时区信息,使用指定的默认时区
if timezone == "" { if timezone == "" {
timezone = defaultTimezone timezone = defaultTimezone
} }
if _, err := tools.GetLocation(timezone); err != nil {
// 验证时区是否有效
if _, err := datetime.GetLocation(timezone); err != nil {
// 如果时区无效,使用默认时区
timezone = defaultTimezone timezone = defaultTimezone
} }
ctx := requestctx.WithTimezone(r.Context(), timezone)
// 将时区存储到context中
ctx := context.WithValue(r.Context(), timezoneKey{}, timezone)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })
} }
} }

222
migration/helper.go Normal file
View File

@@ -0,0 +1,222 @@
package migration
import (
"fmt"
"os"
"time"
"git.toowon.com/jimmy/go-common/config"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// RunMigrationsFromConfig 从配置文件运行迁移(便捷方法)
//
// 注意推荐使用独立的迁移工具templates/migrate/main.go而不是在应用代码中直接调用。
// 独立工具可以实现零耦合、独立部署。
//
// 此方法主要用于:
// 1. 独立迁移工具内部调用(推荐)
// 2. 简单场景下在应用启动时调用(不推荐,会导致耦合)
//
// 用法:
//
// import "git.toowon.com/jimmy/go-common/migration"
// migration.RunMigrationsFromConfig("config.json", "migrations")
// // 或使用默认迁移目录
// migration.RunMigrationsFromConfig("config.json", "")
func RunMigrationsFromConfig(configFile, migrationsDir string) error {
return RunMigrationsFromConfigWithCommand(configFile, migrationsDir, "up")
}
// RunMigrationsFromConfigWithCommand 从配置文件运行迁移(支持命令,黑盒模式)
//
// 这是最简单的迁移方式,内部自动处理:
// - 配置加载(支持配置文件、默认路径)
// - 数据库连接(自动识别数据库类型)
// - 迁移文件加载和执行
//
// 参数:
// - configFile: 配置文件路径,支持:
// - 空字符串自动查找config.json, ../config.json
// - 相对路径或绝对路径:指定配置文件路径
// - migrationsDir: 迁移文件目录,支持:
// - 空字符串:使用默认目录 "migrations"
// - 相对路径或绝对路径
// - command: 命令,支持 "up", "down", "status"
//
// 使用示例:
//
// // 最简单:使用默认配置和默认迁移目录
// migration.RunMigrationsFromConfigWithCommand("", "", "up")
//
// // 指定配置文件,使用默认迁移目录
// migration.RunMigrationsFromConfigWithCommand("config.json", "", "up")
//
// // 指定配置和迁移目录
// migration.RunMigrationsFromConfigWithCommand("config.json", "scripts/sql", "up")
func RunMigrationsFromConfigWithCommand(configFile, migrationsDir, command string) error {
// 加载配置
cfg, err := loadConfigFromFileOrEnv(configFile)
if err != nil {
return fmt.Errorf("加载配置失败: %w", err)
}
// 连接数据库
db, err := connectDB(cfg)
if err != nil {
return fmt.Errorf("连接数据库失败: %w", err)
}
// 使用默认迁移目录(如果未指定)
if migrationsDir == "" {
migrationsDir = "migrations"
}
// 创建迁移器(传入数据库类型,性能更好)
migrator := NewMigratorWithType(db, cfg.Database.Type)
// 加载迁移文件
migrations, err := LoadMigrationsFromFiles(migrationsDir, "*.sql")
if err != nil {
return fmt.Errorf("加载迁移文件失败: %w", err)
}
if len(migrations) == 0 {
fmt.Printf("在目录 '%s' 中没有找到迁移文件\n", migrationsDir)
return nil
}
migrator.AddMigrations(migrations...)
// 执行命令
switch command {
case "up":
if err := migrator.Up(); err != nil {
return fmt.Errorf("执行迁移失败: %w", err)
}
fmt.Println("✓ 迁移执行成功")
case "down":
if err := migrator.Down(); err != nil {
return fmt.Errorf("回滚迁移失败: %w", err)
}
fmt.Println("✓ 迁移回滚成功")
case "status":
status, err := migrator.Status()
if err != nil {
return fmt.Errorf("获取迁移状态失败: %w", err)
}
printMigrationStatus(status)
default:
return fmt.Errorf("未知命令: %s (支持: up, down, status)", command)
}
return nil
}
// loadConfigFromFileOrEnv 从配置文件加载配置
// 支持指定配置文件路径,或自动查找默认路径
func loadConfigFromFileOrEnv(configFile string) (*config.Config, error) {
// 如果指定了配置文件路径,优先使用
if configFile != "" {
if _, err := os.Stat(configFile); err == nil {
return config.LoadFromFile(configFile)
}
// 如果指定的文件不存在,返回错误
return nil, fmt.Errorf("配置文件不存在: %s", configFile)
}
// 尝试默认路径
defaultPaths := []string{"config.json", "../config.json"}
for _, path := range defaultPaths {
if _, err := os.Stat(path); err == nil {
return config.LoadFromFile(path)
}
}
return nil, fmt.Errorf("未找到配置文件,请指定配置文件路径或确保存在以下文件之一: %v", defaultPaths)
}
// connectDB 连接数据库
// 与 factory.getDatabase 保持一致的实现,避免代码重复
func connectDB(cfg *config.Config) (*gorm.DB, error) {
if cfg.Database == nil {
return nil, fmt.Errorf("数据库配置为空")
}
dsn, err := cfg.GetDatabaseDSN()
if err != nil {
return nil, err
}
var db *gorm.DB
switch cfg.Database.Type {
case "mysql":
db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
case "postgres":
db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
case "sqlite":
db, err = gorm.Open(sqlite.Open(dsn), &gorm.Config{})
default:
return nil, fmt.Errorf("不支持的数据库类型: %s", cfg.Database.Type)
}
if err != nil {
return nil, err
}
// 配置连接池(与 factory.getDatabase 保持一致)
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
// 使用配置文件中的连接池参数,如果没有配置则使用默认值
if cfg.Database.MaxOpenConns > 0 {
sqlDB.SetMaxOpenConns(cfg.Database.MaxOpenConns)
} else {
sqlDB.SetMaxOpenConns(10) // 默认值
}
if cfg.Database.MaxIdleConns > 0 {
sqlDB.SetMaxIdleConns(cfg.Database.MaxIdleConns)
} else {
sqlDB.SetMaxIdleConns(5) // 默认值
}
if cfg.Database.ConnMaxLifetime > 0 {
sqlDB.SetConnMaxLifetime(time.Duration(cfg.Database.ConnMaxLifetime) * time.Second)
} else {
sqlDB.SetConnMaxLifetime(time.Hour) // 默认值
}
return db, nil
}
// printMigrationStatus 打印迁移状态
func printMigrationStatus(status []MigrationStatus) {
if len(status) == 0 {
fmt.Println("没有找到迁移")
return
}
fmt.Println("\n迁移状态:")
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
fmt.Printf("%-20s %-40s %-10s\n", "版本", "描述", "状态")
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
for _, s := range status {
statusText := "待执行"
if s.Applied {
statusText = "✓ 已应用"
}
fmt.Printf("%-20s %-40s %-10s\n", s.Version, s.Description, statusText)
}
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
fmt.Println()
}

View File

@@ -26,6 +26,7 @@ type Migrator struct {
db *gorm.DB db *gorm.DB
migrations []Migration migrations []Migration
tableName string tableName string
dbType string // 数据库类型: mysql, postgres, sqlite
} }
// NewMigrator 创建新的迁移器 // NewMigrator 创建新的迁移器
@@ -41,6 +42,25 @@ func NewMigrator(db *gorm.DB, tableName ...string) *Migrator {
db: db, db: db,
migrations: make([]Migration, 0), migrations: make([]Migration, 0),
tableName: table, tableName: table,
dbType: "", // 未指定时为空,会使用兼容模式
}
}
// NewMigratorWithType 创建新的迁移器(指定数据库类型,性能更好)
// db: GORM数据库连接
// dbType: 数据库类型 ("mysql", "postgres", "sqlite")
// tableName: 存储迁移记录的表名,默认为 "schema_migrations"
func NewMigratorWithType(db *gorm.DB, dbType string, tableName ...string) *Migrator {
table := "schema_migrations"
if len(tableName) > 0 && tableName[0] != "" {
table = tableName[0]
}
return &Migrator{
db: db,
migrations: make([]Migration, 0),
tableName: table,
dbType: dbType,
} }
} }
@@ -56,33 +76,152 @@ func (m *Migrator) AddMigrations(migrations ...Migration) {
// initTable 初始化迁移记录表 // initTable 初始化迁移记录表
func (m *Migrator) initTable() error { func (m *Migrator) initTable() error {
// 检查表是否存在 // 检查表是否存在根据数据库类型使用对应的SQL性能更好
var exists bool var exists bool
err := m.db.Raw(fmt.Sprintf(` var err error
switch m.dbType {
case "mysql":
// MySQL/MariaDB语法
var count int64
err = m.db.Raw(fmt.Sprintf(`
SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = '%s'
`, m.tableName)).Scan(&count).Error
if err == nil {
exists = count > 0
}
case "postgres":
// PostgreSQL语法
err = m.db.Raw(fmt.Sprintf(`
SELECT EXISTS ( SELECT EXISTS (
SELECT FROM information_schema.tables SELECT 1 FROM information_schema.tables
WHERE table_schema = CURRENT_SCHEMA() WHERE table_schema = CURRENT_SCHEMA()
AND table_name = '%s' AND table_name = '%s'
) )
`, m.tableName)).Scan(&exists).Error `, m.tableName)).Scan(&exists).Error
case "sqlite":
// SQLite语法
var count int64
err = m.db.Raw(fmt.Sprintf(`
SELECT COUNT(*) FROM sqlite_master
WHERE type='table' AND name='%s'
`, m.tableName)).Scan(&count).Error
if err == nil {
exists = count > 0
}
default:
// 未指定数据库类型时,使用兼容模式(向后兼容)
// 按顺序尝试不同数据库的语法
var count int64
err = m.db.Raw(fmt.Sprintf(`
SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = '%s'
`, m.tableName)).Scan(&count).Error
if err == nil && count > 0 {
exists = true
} else {
var pgExists bool
err = m.db.Raw(fmt.Sprintf(`
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = CURRENT_SCHEMA()
AND table_name = '%s'
)
`, m.tableName)).Scan(&pgExists).Error
if err == nil {
exists = pgExists
} else {
var sqliteCount int64
err = m.db.Raw(fmt.Sprintf(`
SELECT COUNT(*) FROM sqlite_master
WHERE type='table' AND name='%s'
`, m.tableName)).Scan(&sqliteCount).Error
if err == nil && sqliteCount > 0 {
exists = true
}
}
}
}
// 如果查询失败,假设表不存在,尝试创建
if err != nil { if err != nil {
// 如果查询失败可能是SQLite或其他数据库尝试直接创建
exists = false exists = false
} }
if !exists { if !exists {
// 创建迁移记录表 // 创建迁移记录表(包含执行时间字段)
err = m.db.Exec(fmt.Sprintf(` err = m.db.Exec(fmt.Sprintf(`
CREATE TABLE IF NOT EXISTS %s ( CREATE TABLE IF NOT EXISTS %s (
version VARCHAR(255) PRIMARY KEY, version VARCHAR(255) PRIMARY KEY,
description VARCHAR(255), description VARCHAR(255),
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
execution_time INT COMMENT '执行耗时(ms)'
) )
`, m.tableName)).Error `, m.tableName)).Error
if err != nil { if err != nil {
return fmt.Errorf("failed to create migration table: %w", err) return fmt.Errorf("failed to create migration table: %w", err)
} }
} else {
// 表已存在,检查是否有 execution_time 字段(向后兼容)
// 注意:这个检查可能在某些数据库中失败,但不影响功能
// 如果字段不存在,记录执行时间时会失败,但不影响迁移执行
var hasExecutionTime bool
var columnCount int64
var checkErr error
switch m.dbType {
case "mysql":
// MySQL/MariaDB语法
checkErr = m.db.Raw(fmt.Sprintf(`
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = '%s'
AND column_name = 'execution_time'
`, m.tableName)).Scan(&columnCount).Error
if checkErr == nil {
hasExecutionTime = columnCount > 0
}
case "postgres":
// PostgreSQL语法
checkErr = m.db.Raw(fmt.Sprintf(`
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = CURRENT_SCHEMA()
AND table_name = '%s'
AND column_name = 'execution_time'
`, m.tableName)).Scan(&columnCount).Error
if checkErr == nil {
hasExecutionTime = columnCount > 0
}
case "sqlite":
// SQLite不支持information_schema跳过检查
hasExecutionTime = false
default:
// 兼容模式尝试MySQL语法
checkErr = m.db.Raw(fmt.Sprintf(`
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = '%s'
AND column_name = 'execution_time'
`, m.tableName)).Scan(&columnCount).Error
if checkErr == nil {
hasExecutionTime = columnCount > 0
}
}
if !hasExecutionTime {
// 尝试添加字段(如果失败不影响功能)
// 注意SQLite的ALTER TABLE ADD COLUMN语法略有不同但GORM会处理
_ = m.db.Exec(fmt.Sprintf(`
ALTER TABLE %s
ADD COLUMN execution_time INT
`, m.tableName))
}
} }
return nil return nil
@@ -109,17 +248,34 @@ func (m *Migrator) getAppliedMigrations() (map[string]bool, error) {
} }
// recordMigration 记录迁移 // recordMigration 记录迁移
func (m *Migrator) recordMigration(version, description string, isUp bool) error { func (m *Migrator) recordMigration(version, description string, isUp bool, executionTime ...int) error {
if err := m.initTable(); err != nil { if err := m.initTable(); err != nil {
return err return err
} }
if isUp { if isUp {
// 记录迁移 // 记录迁移(包含执行时间,如果提供了)
err := m.db.Exec(fmt.Sprintf(` var err error
if len(executionTime) > 0 && executionTime[0] > 0 {
// 尝试插入执行时间(如果字段存在)
err = m.db.Exec(fmt.Sprintf(`
INSERT INTO %s (version, description, applied_at, execution_time)
VALUES (?, ?, ?, ?)
`, m.tableName), version, description, time.Now(), executionTime[0]).Error
if err != nil {
// 如果失败(可能是字段不存在),尝试不包含执行时间
err = m.db.Exec(fmt.Sprintf(`
INSERT INTO %s (version, description, applied_at) INSERT INTO %s (version, description, applied_at)
VALUES (?, ?, ?) VALUES (?, ?, ?)
`, m.tableName), version, description, time.Now()).Error `, m.tableName), version, description, time.Now()).Error
}
} else {
// 不包含执行时间
err = m.db.Exec(fmt.Sprintf(`
INSERT INTO %s (version, description, applied_at)
VALUES (?, ?, ?)
`, m.tableName), version, description, time.Now()).Error
}
if err != nil { if err != nil {
return fmt.Errorf("failed to record migration: %w", err) return fmt.Errorf("failed to record migration: %w", err)
} }
@@ -160,6 +316,9 @@ func (m *Migrator) Up() error {
return fmt.Errorf("migration %s has no Up function", migration.Version) return fmt.Errorf("migration %s has no Up function", migration.Version)
} }
// 记录开始时间
startTime := time.Now()
// 开始事务 // 开始事务
tx := m.db.Begin() tx := m.db.Begin()
if tx.Error != nil { if tx.Error != nil {
@@ -172,8 +331,11 @@ func (m *Migrator) Up() error {
return fmt.Errorf("failed to apply migration %s: %w", migration.Version, err) return fmt.Errorf("failed to apply migration %s: %w", migration.Version, err)
} }
// 记录迁移 // 计算执行时间(毫秒)
if err := m.recordMigrationWithDB(tx, migration.Version, migration.Description, true); err != nil { executionTime := int(time.Since(startTime).Milliseconds())
// 记录迁移(包含执行时间)
if err := m.recordMigrationWithDB(tx, migration.Version, migration.Description, true, executionTime); err != nil {
tx.Rollback() tx.Rollback()
return err return err
} }
@@ -183,7 +345,7 @@ func (m *Migrator) Up() error {
return fmt.Errorf("failed to commit migration %s: %w", migration.Version, err) return fmt.Errorf("failed to commit migration %s: %w", migration.Version, err)
} }
fmt.Printf("Applied migration: %s - %s\n", migration.Version, migration.Description) fmt.Printf("Applied migration: %s - %s (耗时: %dms)\n", migration.Version, migration.Description, executionTime)
} }
return nil return nil
@@ -246,12 +408,29 @@ func (m *Migrator) Down() error {
} }
// recordMigrationWithDB 使用指定的数据库连接记录迁移 // recordMigrationWithDB 使用指定的数据库连接记录迁移
func (m *Migrator) recordMigrationWithDB(db *gorm.DB, version, description string, isUp bool) error { func (m *Migrator) recordMigrationWithDB(db *gorm.DB, version, description string, isUp bool, executionTime ...int) error {
if isUp { if isUp {
err := db.Exec(fmt.Sprintf(` var err error
if len(executionTime) > 0 && executionTime[0] > 0 {
// 尝试插入执行时间(如果字段存在)
err = db.Exec(fmt.Sprintf(`
INSERT INTO %s (version, description, applied_at, execution_time)
VALUES (?, ?, ?, ?)
`, m.tableName), version, description, time.Now(), executionTime[0]).Error
if err != nil {
// 如果失败(可能是字段不存在),尝试不包含执行时间
err = db.Exec(fmt.Sprintf(`
INSERT INTO %s (version, description, applied_at) INSERT INTO %s (version, description, applied_at)
VALUES (?, ?, ?) VALUES (?, ?, ?)
`, m.tableName), version, description, time.Now()).Error `, m.tableName), version, description, time.Now()).Error
}
} else {
// 不包含执行时间
err = db.Exec(fmt.Sprintf(`
INSERT INTO %s (version, description, applied_at)
VALUES (?, ?, ?)
`, m.tableName), version, description, time.Now()).Error
}
if err != nil { if err != nil {
return fmt.Errorf("failed to record migration: %w", err) return fmt.Errorf("failed to record migration: %w", err)
} }
@@ -300,10 +479,96 @@ type MigrationStatus struct {
Applied bool Applied bool
} }
// splitSQL 分割SQL语句处理多行SQL、注释等
// 支持单行注释(--)、多行注释(/* */)、按分号分割语句
func splitSQL(content string) []string {
var statements []string
var current strings.Builder
lines := strings.Split(content, "\n")
inMultiLineComment := false
for _, line := range lines {
trimmedLine := strings.TrimSpace(line)
// 跳过空行
if trimmedLine == "" {
continue
}
// 处理多行注释
if strings.HasPrefix(trimmedLine, "/*") {
inMultiLineComment = true
}
if strings.HasSuffix(trimmedLine, "*/") {
inMultiLineComment = false
continue
}
if inMultiLineComment {
continue
}
// 跳过单行注释
if strings.HasPrefix(trimmedLine, "--") {
continue
}
// 添加到当前语句
current.WriteString(line)
current.WriteString("\n")
// 检查是否是完整语句(以分号结尾)
if strings.HasSuffix(trimmedLine, ";") {
stmt := strings.TrimSpace(current.String())
if stmt != "" && !strings.HasPrefix(stmt, "--") {
statements = append(statements, stmt)
}
current.Reset()
}
}
// 添加最后一个语句(如果没有分号结尾)
if current.Len() > 0 {
stmt := strings.TrimSpace(current.String())
if stmt != "" && !strings.HasPrefix(stmt, "--") {
statements = append(statements, stmt)
}
}
return statements
}
// parseMigrationFileName 解析迁移文件名,支持多种格式
// 格式1: 数字前缀 - 01_init_schema.sql
// 格式2: 时间戳 - 20240101000001_create_users.sql
// 格式3: 带.up后缀 - 20240101000001_create_users.up.sql
// 返回: (version, description, error)
func parseMigrationFileName(baseName string) (string, string, error) {
// 移除扩展名
nameWithoutExt := strings.TrimSuffix(baseName, filepath.Ext(baseName))
// 移除 .up 后缀(如果存在)
nameWithoutExt = strings.TrimSuffix(nameWithoutExt, ".up")
// 解析版本号和描述
parts := strings.SplitN(nameWithoutExt, "_", 2)
if len(parts) < 2 {
// 如果只有一个部分,尝试作为版本号(向后兼容)
return nameWithoutExt, baseName, nil
}
version := parts[0]
description := strings.Join(parts[1:], "_")
return version, description, nil
}
// LoadMigrationsFromFiles 从文件系统加载迁移文件 // LoadMigrationsFromFiles 从文件系统加载迁移文件
// dir: 迁移文件目录 // dir: 迁移文件目录
// pattern: 文件命名模式,例如 "*.sql" 或 "*.up.sql" // pattern: 文件命名模式,例如 "*.sql" 或 "*.up.sql"
// 文件命名格式: {version}_{description}.sql 或 {version}_{description}.up.sql // 文件命名格式支持:
// - 数字前缀: 01_init_schema.sql
// - 时间戳: 20240101000001_create_users.sql
// - 带.up后缀: 20240101000001_create_users.up.sql
func LoadMigrationsFromFiles(dir string, pattern string) ([]Migration, error) { func LoadMigrationsFromFiles(dir string, pattern string) ([]Migration, error) {
files, err := filepath.Glob(filepath.Join(dir, pattern)) files, err := filepath.Glob(filepath.Join(dir, pattern))
if err != nil { if err != nil {
@@ -313,19 +578,17 @@ func LoadMigrationsFromFiles(dir string, pattern string) ([]Migration, error) {
migrations := make([]Migration, 0) migrations := make([]Migration, 0)
for _, file := range files { for _, file := range files {
baseName := filepath.Base(file) baseName := filepath.Base(file)
// 移除扩展名
nameWithoutExt := strings.TrimSuffix(baseName, filepath.Ext(baseName))
// 移除 .up 后缀(如果存在)
nameWithoutExt = strings.TrimSuffix(nameWithoutExt, ".up")
// 解析版本号和描述 // 跳过 .down.sql 文件(会在处理 .up.sql 或 .sql 时自动加载)
parts := strings.SplitN(nameWithoutExt, "_", 2) if strings.HasSuffix(baseName, ".down.sql") {
if len(parts) < 2 { continue
return nil, fmt.Errorf("invalid migration file name format: %s (expected: {version}_{description})", baseName)
} }
version := parts[0] // 解析版本号和描述
description := strings.Join(parts[1:], "_") version, description, err := parseMigrationFileName(baseName)
if err != nil {
return nil, fmt.Errorf("invalid migration file name format: %s: %w", baseName, err)
}
// 读取文件内容 // 读取文件内容
content, err := os.ReadFile(file) content, err := os.ReadFile(file)
@@ -343,26 +606,81 @@ func LoadMigrationsFromFiles(dir string, pattern string) ([]Migration, error) {
downSQL = string(downContent) downSQL = string(downContent)
} }
// 创建迁移,使用 SQL 分割功能
migration := Migration{ migration := Migration{
Version: version, Version: version,
Description: description, Description: description,
Up: func(db *gorm.DB) error { Up: func(db *gorm.DB) error {
return db.Exec(sqlContent).Error // 分割 SQL 语句
statements := splitSQL(sqlContent)
if len(statements) == 0 {
return nil // 空文件,跳过
}
// 执行每个 SQL 语句
// 注意:某些 DDL 语句(如 CREATE TABLE在某些数据库中会隐式提交事务
// 因此这里不使用事务,而是逐个执行
for i, stmt := range statements {
stmt = strings.TrimSpace(stmt)
if stmt == "" {
continue
}
if err := db.Exec(stmt).Error; err != nil {
// 如果是表已存在的错误,记录警告但继续执行(向后兼容)
errStr := err.Error()
if strings.Contains(errStr, "already exists") ||
strings.Contains(errStr, "Duplicate") ||
strings.Contains(errStr, "duplicate") {
fmt.Printf("Warning: SQL statement %d in migration %s: %v\n", i+1, version, err)
continue
}
return fmt.Errorf("failed to execute SQL statement %d in migration %s: %w\nSQL: %s", i+1, version, err, stmt)
}
}
return nil
}, },
} }
if downSQL != "" { if downSQL != "" {
migration.Down = func(db *gorm.DB) error { migration.Down = func(db *gorm.DB) error {
return db.Exec(downSQL).Error // 分割 SQL 语句
statements := splitSQL(downSQL)
if len(statements) == 0 {
return nil
}
// 执行每个 SQL 语句
for i, stmt := range statements {
stmt = strings.TrimSpace(stmt)
if stmt == "" {
continue
}
if err := db.Exec(stmt).Error; err != nil {
return fmt.Errorf("failed to execute SQL statement %d in rollback %s: %w\nSQL: %s", i+1, version, err, stmt)
}
}
return nil
} }
} }
migrations = append(migrations, migration) migrations = append(migrations, migration)
} }
// 按版本号排序 // 按版本号排序(支持数字和时间戳混合排序)
sort.Slice(migrations, func(i, j int) bool { sort.Slice(migrations, func(i, j int) bool {
return migrations[i].Version < migrations[j].Version vi, vj := migrations[i].Version, migrations[j].Version
// 尝试按数字排序(如果是数字前缀)
if viNum, err1 := strconv.Atoi(vi); err1 == nil {
if vjNum, err2 := strconv.Atoi(vj); err2 == nil {
return viNum < vjNum
}
}
// 否则按字符串排序
return vi < vj
}) })
return migrations, nil return migrations, nil

41
requestctx/requestctx.go Normal file
View File

@@ -0,0 +1,41 @@
package requestctx
import (
"context"
"git.toowon.com/jimmy/go-common/tools"
)
type languageKey struct{}
type timezoneKey struct{}
const (
DefaultLanguage = "zh-CN"
DefaultTimezone = tools.AsiaShanghai
)
// WithLanguage 写入语言到 context
func WithLanguage(ctx context.Context, lang string) context.Context {
return context.WithValue(ctx, languageKey{}, lang)
}
// Language 从 context 读取语言
func Language(ctx context.Context) string {
if lang, ok := ctx.Value(languageKey{}).(string); ok && lang != "" {
return lang
}
return DefaultLanguage
}
// WithTimezone 写入时区到 context
func WithTimezone(ctx context.Context, tz string) context.Context {
return context.WithValue(ctx, timezoneKey{}, tz)
}
// Timezone 从 context 读取时区
func Timezone(ctx context.Context) string {
if tz, ok := ctx.Value(timezoneKey{}).(string); ok && tz != "" {
return tz
}
return DefaultTimezone
}

View File

@@ -1,6 +1,7 @@
package sms package sms
import ( import (
"context"
"crypto/hmac" "crypto/hmac"
"crypto/sha1" "crypto/sha1"
"encoding/base64" "encoding/base64"
@@ -11,210 +12,128 @@ import (
"net/url" "net/url"
"sort" "sort"
"strings" "strings"
"sync"
"sync/atomic"
"time" "time"
"git.toowon.com/jimmy/go-common/config" "git.toowon.com/jimmy/go-common/config"
"git.toowon.com/jimmy/go-common/logger"
) )
// SendResponse 发送短信响应
type SendResponse struct {
RequestID string `json:"RequestId"`
Code string `json:"Code"`
Message string `json:"Message"`
BizID string `json:"BizId"`
}
// SMS 短信发送器 // SMS 短信发送器
type SMS struct { type SMS struct {
config *config.SMSConfig config *config.SMSConfig
async bool
queue chan smsTask
workers int
wg sync.WaitGroup
closed bool
mu sync.Mutex
dropped atomic.Uint64
}
type smsTask struct {
phones []string
templateParam interface{}
templateCode string
requestID string
} }
// NewSMS 创建短信发送器 // NewSMS 创建短信发送器
func NewSMS(cfg *config.SMSConfig) (*SMS, error) { func NewSMS(cfg *config.Config) *SMS {
if cfg == nil { if cfg == nil || cfg.SMS == nil {
return &SMS{config: nil}
}
s := &SMS{
config: cfg.SMS,
async: cfg.SMS.IsAsync(),
workers: cfg.SMS.Workers,
}
if s.workers <= 0 {
s.workers = 2
}
queueSize := cfg.SMS.QueueSize
if queueSize <= 0 {
queueSize = 1000
}
if s.async {
s.queue = make(chan smsTask, queueSize)
for i := 0; i < s.workers; i++ {
s.wg.Add(1)
go s.worker()
}
}
return s
}
func (s *SMS) worker() {
defer s.wg.Done()
for task := range s.queue {
if _, err := s.SendSMS(task.phones, task.templateParam, task.templateCode); err != nil {
logger.FromContext(context.Background()).Error("async sms send failed", map[string]any{
"error": err.Error(),
"request_id": task.requestID,
"phones": task.phones,
})
}
}
}
func (s *SMS) getSMSConfig() (*config.SMSConfig, error) {
if s.config == nil {
return nil, fmt.Errorf("SMS config is nil") return nil, fmt.Errorf("SMS config is nil")
} }
if s.config.AccessKeyID == "" {
if cfg.AccessKeyID == "" {
return nil, fmt.Errorf("AccessKeyID is required") return nil, fmt.Errorf("AccessKeyID is required")
} }
if s.config.AccessKeySecret == "" {
if cfg.AccessKeySecret == "" {
return nil, fmt.Errorf("AccessKeySecret is required") return nil, fmt.Errorf("AccessKeySecret is required")
} }
if s.config.SignName == "" {
if cfg.SignName == "" {
return nil, fmt.Errorf("SignName is required") return nil, fmt.Errorf("SignName is required")
} }
if s.config.Region == "" {
// 设置默认值 s.config.Region = "cn-hangzhou"
if cfg.Region == "" {
cfg.Region = "cn-hangzhou"
} }
if cfg.Timeout == 0 { if s.config.Timeout == 0 {
cfg.Timeout = 10 s.config.Timeout = 5
} }
return s.config, nil
return &SMS{
config: cfg,
}, nil
} }
// SendRequest 发送短信请求 // SendSMS 同步发送短信
type SendRequest struct { func (s *SMS) SendSMS(phoneNumbers []string, templateParam interface{}, templateCode ...string) (*SendResponse, error) {
// PhoneNumbers 手机号列表 cfg, err := s.getSMSConfig()
PhoneNumbers []string
// TemplateCode 模板代码(如果为空,使用配置中的模板代码)
TemplateCode string
// TemplateParam 模板参数可以是map或JSON字符串
// 如果是map会自动转换为JSON字符串
// 如果是string直接使用必须是有效的JSON字符串
TemplateParam interface{}
// SignName 签名(如果为空,使用配置中的签名)
SignName string
}
// SendResponse 发送短信响应
type SendResponse struct {
// RequestID 请求ID
RequestID string `json:"RequestId"`
// Code 响应码
Code string `json:"Code"`
// Message 响应消息
Message string `json:"Message"`
// BizID 业务ID
BizID string `json:"BizId"`
}
// SendRaw 发送原始请求(允许外部完全控制请求参数)
// params: 请求参数map工具只负责添加必要的系统参数如签名、时间戳等并发送
func (s *SMS) SendRaw(params map[string]string) (*SendResponse, error) {
if params == nil {
params = make(map[string]string)
}
// 确保必要的系统参数存在
if params["Action"] == "" {
params["Action"] = "SendSms"
}
if params["Version"] == "" {
params["Version"] = "2017-05-25"
}
if params["RegionId"] == "" {
params["RegionId"] = s.config.Region
}
if params["AccessKeyId"] == "" {
params["AccessKeyId"] = s.config.AccessKeyID
}
if params["Format"] == "" {
params["Format"] = "JSON"
}
if params["SignatureMethod"] == "" {
params["SignatureMethod"] = "HMAC-SHA1"
}
if params["SignatureVersion"] == "" {
params["SignatureVersion"] = "1.0"
}
if params["SignatureNonce"] == "" {
params["SignatureNonce"] = fmt.Sprint(time.Now().UnixNano())
}
if params["Timestamp"] == "" {
params["Timestamp"] = time.Now().UTC().Format("2006-01-02T15:04:05Z")
}
// 计算签名
signature := s.calculateSignature(params, "POST")
params["Signature"] = signature
// 构建请求URL
endpoint := s.config.Endpoint
if endpoint == "" {
endpoint = "https://dysmsapi.aliyuncs.com"
}
// 发送HTTP请求
formData := url.Values{}
for k, v := range params {
formData.Set(k, v)
}
httpReq, err := http.NewRequest("POST", endpoint, strings.NewReader(formData.Encode()))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err) return nil, err
} }
if len(phoneNumbers) == 0 {
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
httpReq.Header.Set("Accept", "application/json")
client := &http.Client{
Timeout: time.Duration(s.config.Timeout) * time.Second,
}
resp, err := client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
// 读取响应
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
// 解析响应
var sendResp SendResponse
if err := json.Unmarshal(body, &sendResp); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
// 检查响应码
if sendResp.Code != "OK" {
return &sendResp, fmt.Errorf("SMS send failed: Code=%s, Message=%s", sendResp.Code, sendResp.Message)
}
return &sendResp, nil
}
// Send 发送短信使用SendRequest结构
// 注意如果需要完全控制请求参数请使用SendRaw方法
func (s *SMS) Send(req *SendRequest) (*SendResponse, error) {
if req == nil {
return nil, fmt.Errorf("request is nil")
}
if len(req.PhoneNumbers) == 0 {
return nil, fmt.Errorf("phone numbers are required") return nil, fmt.Errorf("phone numbers are required")
} }
// 使用配置中的模板代码和签名(如果请求中未指定) templateCodeValue := cfg.TemplateCode
templateCode := req.TemplateCode if len(templateCode) > 0 && templateCode[0] != "" {
if templateCode == "" { templateCodeValue = templateCode[0]
templateCode = s.config.TemplateCode
} }
if templateCode == "" { if templateCodeValue == "" {
return nil, fmt.Errorf("template code is required") return nil, fmt.Errorf("template code is required")
} }
signName := req.SignName
if signName == "" {
signName = s.config.SignName
}
// 处理模板参数
var templateParamJSON string var templateParamJSON string
if req.TemplateParam != nil { if templateParam != nil {
switch v := req.TemplateParam.(type) { switch v := templateParam.(type) {
case string: case string:
// 直接使用字符串必须是有效的JSON
templateParamJSON = v templateParamJSON = v
case map[string]string:
// 转换为JSON字符串
paramBytes, err := json.Marshal(v)
if err != nil {
return nil, fmt.Errorf("failed to marshal template param: %w", err)
}
templateParamJSON = string(paramBytes)
default: default:
// 尝试JSON序列化
paramBytes, err := json.Marshal(v) paramBytes, err := json.Marshal(v)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to marshal template param: %w", err) return nil, fmt.Errorf("failed to marshal template param: %w", err)
@@ -225,66 +144,120 @@ func (s *SMS) Send(req *SendRequest) (*SendResponse, error) {
templateParamJSON = "{}" templateParamJSON = "{}"
} }
// 构建请求参数 params := map[string]string{
params := make(map[string]string) "Action": "SendSms",
params["PhoneNumbers"] = strings.Join(req.PhoneNumbers, ",") "Version": "2017-05-25",
params["SignName"] = signName "RegionId": cfg.Region,
params["TemplateCode"] = templateCode "AccessKeyId": cfg.AccessKeyID,
params["TemplateParam"] = templateParamJSON "Format": "JSON",
"SignatureMethod": "HMAC-SHA1",
"SignatureVersion": "1.0",
"SignatureNonce": fmt.Sprint(time.Now().UnixNano()),
"Timestamp": time.Now().UTC().Format("2006-01-02T15:04:05Z"),
"PhoneNumbers": strings.Join(phoneNumbers, ","),
"SignName": cfg.SignName,
"TemplateCode": templateCodeValue,
"TemplateParam": templateParamJSON,
}
params["Signature"] = s.calculateSignature(params, "POST", cfg.AccessKeySecret)
// 使用SendRaw发送 endpoint := cfg.Endpoint
return s.SendRaw(params) if endpoint == "" {
endpoint = "https://dysmsapi.aliyuncs.com"
}
formData := url.Values{}
for k, v := range params {
formData.Set(k, v)
}
httpReq, err := http.NewRequest(http.MethodPost, endpoint, strings.NewReader(formData.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
httpReq.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: time.Duration(cfg.Timeout) * time.Second}
resp, err := client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var sendResp SendResponse
if err := json.Unmarshal(body, &sendResp); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
if sendResp.Code != "OK" {
return &sendResp, fmt.Errorf("SMS send failed: Code=%s, Message=%s", sendResp.Code, sendResp.Message)
}
return &sendResp, nil
} }
// calculateSignature 计算签名 // SendSMSAsync 异步发送短信
func (s *SMS) calculateSignature(params map[string]string, method string) string { func (s *SMS) SendSMSAsync(ctx context.Context, phoneNumbers []string, templateParam interface{}, templateCode ...string) {
// 对参数进行排序 code := ""
if len(templateCode) > 0 {
code = templateCode[0]
}
task := smsTask{
phones: append([]string(nil), phoneNumbers...),
templateParam: templateParam,
templateCode: code,
requestID: logger.RequestIDFromContext(ctx),
}
if !s.async {
_, _ = s.SendSMS(task.phones, task.templateParam, task.templateCode)
return
}
select {
case s.queue <- task:
default:
s.dropped.Add(1)
logger.FromContext(ctx).Error("sms queue full, task dropped", map[string]any{
"phones": phoneNumbers,
})
}
}
// Close 关闭异步 worker
func (s *SMS) Close() error {
if !s.async {
return nil
}
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return nil
}
s.closed = true
s.mu.Unlock()
close(s.queue)
s.wg.Wait()
return nil
}
func (s *SMS) calculateSignature(params map[string]string, method, accessKeySecret string) string {
keys := make([]string, 0, len(params)) keys := make([]string, 0, len(params))
for k := range params { for k := range params {
keys = append(keys, k) keys = append(keys, k)
} }
sort.Strings(keys) sort.Strings(keys)
// 构建查询字符串
var queryParts []string var queryParts []string
for _, k := range keys { for _, k := range keys {
v := params[k] queryParts = append(queryParts, url.QueryEscape(k)+"="+url.QueryEscape(params[k]))
// URL编码
encodedKey := url.QueryEscape(k)
encodedValue := url.QueryEscape(v)
queryParts = append(queryParts, encodedKey+"="+encodedValue)
} }
queryString := strings.Join(queryParts, "&") queryString := strings.Join(queryParts, "&")
// 构建待签名字符串
stringToSign := method + "&" + url.QueryEscape("/") + "&" + url.QueryEscape(queryString) stringToSign := method + "&" + url.QueryEscape("/") + "&" + url.QueryEscape(queryString)
// 计算HMAC-SHA1签名 mac := hmac.New(sha1.New, []byte(accessKeySecret+"&"))
mac := hmac.New(sha1.New, []byte(s.config.AccessKeySecret+"&"))
mac.Write([]byte(stringToSign)) mac.Write([]byte(stringToSign))
signature := base64.StdEncoding.EncodeToString(mac.Sum(nil)) return base64.StdEncoding.EncodeToString(mac.Sum(nil))
return signature
}
// SendSimple 发送简单短信(便捷方法)
// phoneNumbers: 手机号列表
// templateParam: 模板参数
func (s *SMS) SendSimple(phoneNumbers []string, templateParam map[string]string) (*SendResponse, error) {
return s.Send(&SendRequest{
PhoneNumbers: phoneNumbers,
TemplateParam: templateParam,
})
}
// SendWithTemplate 使用指定模板发送短信(便捷方法)
// phoneNumbers: 手机号列表
// templateCode: 模板代码
// templateParam: 模板参数
func (s *SMS) SendWithTemplate(phoneNumbers []string, templateCode string, templateParam map[string]string) (*SendResponse, error) {
return s.Send(&SendRequest{
PhoneNumbers: phoneNumbers,
TemplateCode: templateCode,
TemplateParam: templateParam,
})
} }

View File

@@ -39,39 +39,32 @@ func NewUploadHandler(cfg UploadHandlerConfig) *UploadHandler {
} }
// ServeHTTP 处理文件上传请求 // ServeHTTP 处理文件上传请求
// 请求方式: POST
// 表单字段: file (文件)
// 可选字段: prefix (对象键前缀,会覆盖配置中的前缀)
func (h *UploadHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *UploadHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
handler := commonhttp.NewHandler(w, r) handler := commonhttp.NewHandler(w, r)
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
handler.Error(4001, "Method not allowed") handler.Error("common.method_not_allowed")
return return
} }
// 解析multipart表单
err := r.ParseMultipartForm(h.maxFileSize) err := r.ParseMultipartForm(h.maxFileSize)
if err != nil { if err != nil {
handler.Error(4002, fmt.Sprintf("Failed to parse form: %v", err)) handler.Error("common.invalid_request")
return return
} }
// 获取文件
file, header, err := r.FormFile("file") file, header, err := r.FormFile("file")
if err != nil { if err != nil {
handler.Error(4003, fmt.Sprintf("Failed to get file: %v", err)) handler.Error("common.invalid_request")
return return
} }
defer file.Close() defer file.Close()
// 检查文件大小
if h.maxFileSize > 0 && header.Size > h.maxFileSize { if h.maxFileSize > 0 && header.Size > h.maxFileSize {
handler.Error(1001, fmt.Sprintf("File size exceeds limit: %d bytes", h.maxFileSize)) handler.Error("storage.file_too_large")
return return
} }
// 检查文件扩展名
if len(h.allowedExts) > 0 { if len(h.allowedExts) > 0 {
ext := strings.ToLower(filepath.Ext(header.Filename)) ext := strings.ToLower(filepath.Ext(header.Filename))
allowed := false allowed := false
@@ -82,22 +75,19 @@ func (h *UploadHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
} }
if !allowed { if !allowed {
handler.Error(1002, fmt.Sprintf("File extension not allowed. Allowed: %v", h.allowedExts)) handler.Error("storage.invalid_extension")
return return
} }
} }
// 生成对象键
prefix := h.objectPrefix prefix := h.objectPrefix
if r.FormValue("prefix") != "" { if r.FormValue("prefix") != "" {
prefix = r.FormValue("prefix") prefix = r.FormValue("prefix")
} }
// 生成唯一文件名
filename := generateUniqueFilename(header.Filename) filename := generateUniqueFilename(header.Filename)
objectKey := GenerateObjectKey(prefix, filename) objectKey := GenerateObjectKey(prefix, filename)
// 获取文件类型
contentType := header.Header.Get("Content-Type") contentType := header.Header.Get("Content-Type")
if contentType == "" { if contentType == "" {
contentType = mime.TypeByExtension(filepath.Ext(header.Filename)) contentType = mime.TypeByExtension(filepath.Ext(header.Filename))
@@ -106,22 +96,18 @@ func (h *UploadHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
} }
// 上传文件
ctx := r.Context() ctx := r.Context()
err = h.storage.Upload(ctx, objectKey, file, contentType) if err = h.storage.Upload(ctx, objectKey, file, contentType); err != nil {
if err != nil { handler.Error("system.internal_error")
handler.SystemError(fmt.Sprintf("Failed to upload file: %v", err))
return return
} }
// 获取文件URL
fileURL, err := h.storage.GetURL(objectKey, 0) fileURL, err := h.storage.GetURL(objectKey, 0)
if err != nil { if err != nil {
handler.SystemError(fmt.Sprintf("Failed to get file URL: %v", err)) handler.Error("system.internal_error")
return return
} }
// 返回结果
result := UploadResult{ result := UploadResult{
ObjectKey: objectKey, ObjectKey: objectKey,
URL: fileURL, URL: fileURL,
@@ -129,8 +115,7 @@ func (h *UploadHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ContentType: contentType, ContentType: contentType,
UploadTime: time.Now(), UploadTime: time.Now(),
} }
handler.Success(result)
handler.SuccessWithMessage("Upload successful", result)
} }
// generateUniqueFilename 生成唯一文件名 // generateUniqueFilename 生成唯一文件名
@@ -155,18 +140,17 @@ func NewProxyHandler(storage Storage) *ProxyHandler {
// ServeHTTP 处理文件查看请求 // ServeHTTP 处理文件查看请求
// URL参数: key (对象键) // URL参数: key (对象键)
// 注意此方法直接返回文件内容二进制错误时返回标准HTTP错误状态码
func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
handler := commonhttp.NewHandler(w, r)
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
handler.Error(4001, "Method not allowed") http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return return
} }
// 获取对象键 // 获取对象键
objectKey := handler.GetQuery("key", "") objectKey := r.URL.Query().Get("key")
if objectKey == "" { if objectKey == "" {
handler.Error(4004, "Missing parameter: key") http.Error(w, "Missing parameter: key", http.StatusBadRequest)
return return
} }
@@ -174,19 +158,19 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
exists, err := h.storage.Exists(ctx, objectKey) exists, err := h.storage.Exists(ctx, objectKey)
if err != nil { if err != nil {
handler.SystemError(fmt.Sprintf("Failed to check file existence: %v", err)) http.Error(w, fmt.Sprintf("Failed to check file existence: %v", err), http.StatusInternalServerError)
return return
} }
if !exists { if !exists {
handler.Error(4005, "File not found") http.Error(w, "File not found", http.StatusNotFound)
return return
} }
// 获取文件内容 // 获取文件内容
reader, err := h.storage.GetObject(ctx, objectKey) reader, err := h.storage.GetObject(ctx, objectKey)
if err != nil { if err != nil {
handler.SystemError(fmt.Sprintf("Failed to get file: %v", err)) http.Error(w, fmt.Sprintf("Failed to get file: %v", err), http.StatusInternalServerError)
return return
} }
defer reader.Close() defer reader.Close()
@@ -210,7 +194,8 @@ func (h *ProxyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// 复制文件内容到响应 // 复制文件内容到响应
_, err = io.Copy(w, reader) _, err = io.Copy(w, reader)
if err != nil { if err != nil {
handler.SystemError(fmt.Sprintf("Failed to write response: %v", err)) // 如果已经开始写入响应,无法再设置错误状态码
// 这里只能记录错误,无法返回错误响应
return return
} }
} }

222
storage/local.go Normal file
View File

@@ -0,0 +1,222 @@
package storage
import (
"context"
"fmt"
"io"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"git.toowon.com/jimmy/go-common/config"
)
// LocalStorage 本地存储实现
// 将对象写入本地文件夹BaseDir对象键 objectKey 作为相对路径使用。
//
// 典型用法:
// - 上传Upload(ctx, "uploads/2026/01/01/a.png", reader)
// - 查看:配合 ProxyHandler 或 http.FileServer 对外提供访问
type LocalStorage struct {
baseDir string
publicURL string
}
// NewLocalStorage 创建本地存储实例
func NewLocalStorage(cfg *config.LocalStorageConfig) (*LocalStorage, error) {
if cfg == nil {
return nil, fmt.Errorf("LocalStorage config is nil")
}
if strings.TrimSpace(cfg.BaseDir) == "" {
return nil, fmt.Errorf("LocalStorage baseDir is empty")
}
absBase, err := filepath.Abs(cfg.BaseDir)
if err != nil {
return nil, fmt.Errorf("failed to get absolute baseDir: %w", err)
}
// 确保根目录存在
if err := os.MkdirAll(absBase, 0o755); err != nil {
return nil, fmt.Errorf("failed to create baseDir: %w", err)
}
return &LocalStorage{
baseDir: absBase,
publicURL: strings.TrimSpace(cfg.PublicURL),
}, nil
}
// Upload 上传文件到本地文件夹
func (s *LocalStorage) Upload(ctx context.Context, objectKey string, reader io.Reader, contentType ...string) error {
_ = ctx
_ = contentType // 本地写文件不依赖 contentType可由上层自行记录
dstPath, err := s.resolvePath(objectKey)
if err != nil {
return err
}
// 确保目录存在
if err := os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
// 原子写入:先写临时文件,再 rename
tmp, err := os.CreateTemp(filepath.Dir(dstPath), ".upload-*")
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
tmpName := tmp.Name()
defer func() {
_ = tmp.Close()
_ = os.Remove(tmpName)
}()
if _, err := io.Copy(tmp, reader); err != nil {
return fmt.Errorf("failed to write temp file: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("failed to close temp file: %w", err)
}
// 如果目标文件已存在,先删除(保证跨平台兼容 rename 行为)
_ = os.Remove(dstPath)
if err := os.Rename(tmpName, dstPath); err != nil {
return fmt.Errorf("failed to move temp file to destination: %w", err)
}
return nil
}
// GetURL 获取本地文件访问URL
// - 若配置了 publicURL
// - 包含 "{objectKey}" 占位符:替换为 url.QueryEscape(objectKey)
// - 否则认为是 URL 前缀:自动拼接 objectKey用 path.Join 处理斜杠)
//
// - 未配置 publicURL返回 objectKey相对路径
func (s *LocalStorage) GetURL(objectKey string, expires int64) (string, error) {
_ = expires // 本地存储不提供签名URL忽略 expires
cleanKey, err := normalizeObjectKey(objectKey)
if err != nil {
return "", err
}
if s.publicURL == "" {
return cleanKey, nil
}
if strings.Contains(s.publicURL, "{objectKey}") {
return strings.ReplaceAll(s.publicURL, "{objectKey}", url.QueryEscape(cleanKey)), nil
}
// 作为前缀拼接
trimmed := strings.TrimRight(s.publicURL, "/")
return trimmed + "/" + path.Clean("/" + cleanKey)[1:], nil
}
// Delete 删除本地文件
func (s *LocalStorage) Delete(ctx context.Context, objectKey string) error {
_ = ctx
dstPath, err := s.resolvePath(objectKey)
if err != nil {
return err
}
if err := os.Remove(dstPath); err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("failed to delete file: %w", err)
}
return nil
}
// Exists 检查本地文件是否存在
func (s *LocalStorage) Exists(ctx context.Context, objectKey string) (bool, error) {
_ = ctx
dstPath, err := s.resolvePath(objectKey)
if err != nil {
return false, err
}
info, err := os.Stat(dstPath)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, fmt.Errorf("failed to stat file: %w", err)
}
if info.IsDir() {
return false, nil
}
return true, nil
}
// GetObject 获取本地文件内容
func (s *LocalStorage) GetObject(ctx context.Context, objectKey string) (io.ReadCloser, error) {
_ = ctx
dstPath, err := s.resolvePath(objectKey)
if err != nil {
return nil, err
}
f, err := os.Open(dstPath)
if err != nil {
return nil, fmt.Errorf("failed to open file: %w", err)
}
return f, nil
}
func (s *LocalStorage) resolvePath(objectKey string) (string, error) {
cleanKey, err := normalizeObjectKey(objectKey)
if err != nil {
return "", err
}
// 将 URL 风格路径转换为 OS 路径
full := filepath.Join(s.baseDir, filepath.FromSlash(cleanKey))
// 防御:确保仍在 baseDir 下
rel, err := filepath.Rel(s.baseDir, full)
if err != nil {
return "", fmt.Errorf("failed to resolve path: %w", err)
}
if rel == "." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || rel == ".." {
return "", fmt.Errorf("invalid objectKey: %s", objectKey)
}
return full, nil
}
func normalizeObjectKey(objectKey string) (string, error) {
key := strings.TrimSpace(objectKey)
if key == "" {
return "", fmt.Errorf("objectKey is empty")
}
// 兼容 Windows 风格路径,统一为 URL 风格
key = strings.ReplaceAll(key, "\\", "/")
// 清洗路径,去除多余的 . / ..
// 加前缀 "/" 让 Clean 以绝对路径方式处理,避免出现空结果
clean := path.Clean("/" + key)
clean = strings.TrimPrefix(clean, "/")
if clean == "" || clean == "." {
return "", fmt.Errorf("invalid objectKey: %s", objectKey)
}
// 不允许以 "/" 结尾(必须指向文件)
if strings.HasSuffix(clean, "/") {
return "", fmt.Errorf("objectKey cannot be a directory: %s", objectKey)
}
return clean, nil
}

102
storage/local_test.go Normal file
View File

@@ -0,0 +1,102 @@
package storage
import (
"bytes"
"context"
"io"
"testing"
"git.toowon.com/jimmy/go-common/config"
)
func TestLocalStorage_UploadGetExistsDelete(t *testing.T) {
t.Parallel()
cfg := &config.LocalStorageConfig{
BaseDir: t.TempDir(),
PublicURL: "http://localhost:8080/file?key={objectKey}",
}
s, err := NewLocalStorage(cfg)
if err != nil {
t.Fatalf("NewLocalStorage error: %v", err)
}
ctx := context.Background()
objectKey := "uploads/2026/01/30/hello.txt"
body := []byte("hello local storage")
if err := s.Upload(ctx, objectKey, bytes.NewReader(body), "text/plain"); err != nil {
t.Fatalf("Upload error: %v", err)
}
exists, err := s.Exists(ctx, objectKey)
if err != nil {
t.Fatalf("Exists error: %v", err)
}
if !exists {
t.Fatalf("expected exists=true")
}
rc, err := s.GetObject(ctx, objectKey)
if err != nil {
t.Fatalf("GetObject error: %v", err)
}
defer rc.Close()
got, err := io.ReadAll(rc)
if err != nil {
t.Fatalf("ReadAll error: %v", err)
}
if !bytes.Equal(got, body) {
t.Fatalf("content mismatch: got=%q want=%q", string(got), string(body))
}
u, err := s.GetURL(objectKey, 0)
if err != nil {
t.Fatalf("GetURL error: %v", err)
}
if u == "" {
t.Fatalf("expected non-empty url")
}
if err := s.Delete(ctx, objectKey); err != nil {
t.Fatalf("Delete error: %v", err)
}
exists, err = s.Exists(ctx, objectKey)
if err != nil {
t.Fatalf("Exists error: %v", err)
}
if exists {
t.Fatalf("expected exists=false after delete")
}
}
func TestNormalizeObjectKey(t *testing.T) {
t.Parallel()
if _, err := normalizeObjectKey(""); err == nil {
t.Fatalf("expected error for empty objectKey")
}
if _, err := normalizeObjectKey(" "); err == nil {
t.Fatalf("expected error for blank objectKey")
}
if _, err := normalizeObjectKey("."); err == nil {
t.Fatalf("expected error for '.'")
}
clean1, err := normalizeObjectKey("a/b/")
if err != nil {
t.Fatalf("normalizeObjectKey error: %v", err)
}
if clean1 != "a/b" {
t.Fatalf("unexpected clean key: %q", clean1)
}
clean, err := normalizeObjectKey(`\a\..\b\c.txt`)
if err != nil {
t.Fatalf("normalizeObjectKey error: %v", err)
}
if clean != "b/c.txt" {
t.Fatalf("unexpected clean key: %q", clean)
}
}

View File

@@ -39,10 +39,11 @@ type StorageType string
const ( const (
StorageTypeOSS StorageType = "oss" StorageTypeOSS StorageType = "oss"
StorageTypeMinIO StorageType = "minio" StorageTypeMinIO StorageType = "minio"
StorageTypeLocal StorageType = "local"
) )
// NewStorage 创建存储实例 // NewStorage 创建存储实例
// storageType: 存储类型ossminio // storageType: 存储类型oss/minio/local
// cfg: 配置对象 // cfg: 配置对象
func NewStorage(storageType StorageType, cfg *config.Config) (Storage, error) { func NewStorage(storageType StorageType, cfg *config.Config) (Storage, error) {
switch storageType { switch storageType {
@@ -58,6 +59,12 @@ func NewStorage(storageType StorageType, cfg *config.Config) (Storage, error) {
return nil, fmt.Errorf("MinIO config is nil") return nil, fmt.Errorf("MinIO config is nil")
} }
return NewMinIOStorage(minioConfig) return NewMinIOStorage(minioConfig)
case StorageTypeLocal:
localCfg := cfg.GetLocalStorage()
if localCfg == nil {
return nil, fmt.Errorf("LocalStorage config is nil")
}
return NewLocalStorage(localCfg)
default: default:
return nil, fmt.Errorf("unsupported storage type: %s", storageType) return nil, fmt.Errorf("unsupported storage type: %s", storageType)
} }

View File

@@ -0,0 +1,39 @@
# Dockerfile 示例
# 展示如何构建包含迁移工具的 Go 应用
# ===== 构建阶段 =====
FROM golang:1.21 as builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# 编译应用和迁移工具
RUN go build -o bin/server cmd/server/main.go
RUN go build -o bin/migrate cmd/migrate/main.go
# ===== 运行阶段 =====
FROM debian:bookworm-slim
WORKDIR /app
# 安装运行时依赖
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
# 复制二进制文件和迁移文件
COPY --from=builder /app/bin/server .
COPY --from=builder /app/bin/migrate .
COPY --from=builder /app/migrations ./migrations
EXPOSE 8080
# 启动:先执行迁移,再启动应用
CMD ["sh", "-c", "./migrate up && ./server"]
# 注意:
# 1. 配置文件通过 docker-compose volumes 挂载,不打包进镜像
# 2. 镜像只包含二进制文件和迁移SQL文件
# 3. 配置文件在运行时提供,更安全、更灵活

View File

@@ -0,0 +1,78 @@
# 示例 Makefile
# 提供常用的开发和部署命令
.PHONY: help build run migrate-up migrate-down migrate-status docker-build docker-up clean
# 默认目标:显示帮助
help:
@echo "可用命令:"
@echo " make build - 编译应用和迁移工具"
@echo " make run - 运行应用(先执行迁移)"
@echo " make migrate-up - 执行数据库迁移"
@echo " make migrate-down - 回滚数据库迁移"
@echo " make migrate-status - 查看迁移状态"
@echo " make docker-build - 构建 Docker 镜像"
@echo " make docker-up - 启动 Docker 服务"
@echo " make clean - 清理编译文件"
# 编译
build:
@echo "编译应用..."
@mkdir -p bin
@go build -o bin/server cmd/server/main.go
@go build -o bin/migrate cmd/migrate/main.go
@echo "✓ 编译完成"
# 运行应用
run: migrate-up
@echo "启动应用..."
@./bin/server
# 执行迁移
migrate-up: build
@echo "执行数据库迁移..."
@./bin/migrate up
# 回滚迁移
migrate-down: build
@echo "回滚数据库迁移..."
@./bin/migrate down
# 查看迁移状态
migrate-status: build
@./bin/migrate status
# 构建 Docker 镜像
docker-build:
@echo "构建 Docker 镜像..."
@docker build -t myapp:latest .
# 启动 Docker 服务
docker-up:
@echo "启动 Docker 服务..."
@docker-compose up --build
# 清理
clean:
@echo "清理编译文件..."
@rm -rf bin
@echo "✓ 清理完成"
# 开发环境:直接运行(不编译)
dev-migrate-up:
@go run cmd/migrate/main.go up
dev-migrate-down:
@go run cmd/migrate/main.go down
dev-migrate-status:
@go run cmd/migrate/main.go status
# 交叉编译Linux
build-linux:
@echo "交叉编译 Linux 版本..."
@mkdir -p bin
@GOOS=linux GOARCH=amd64 go build -o bin/server-linux cmd/server/main.go
@GOOS=linux GOARCH=amd64 go build -o bin/migrate-linux cmd/migrate/main.go
@echo "✓ Linux 版本编译完成"

17
templates/README.md Normal file
View File

@@ -0,0 +1,17 @@
# 模板
复制到业务项目后,按注释中的挂载点扩展即可。
| 文件 | 默认已接好 | 主要改哪里 |
|------|------------|------------|
| `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
mkdir -p cmd/server cmd/migrate
cp templates/server/main.go cmd/server/main.go
cp templates/migrate/main.go cmd/migrate/main.go
```
对接说明:[INTEGRATION.md](../INTEGRATION.md)。

View File

@@ -0,0 +1,22 @@
# docker-compose.yml 示例
# 展示如何在 docker-compose 中使用迁移工具
version: '3.8'
services:
app:
build: .
ports:
- "8080:8080"
volumes:
# 挂载配置文件(推荐:修改配置无需重启容器)
- ./config.json:/app/config.json:ro
# 启动时先执行迁移,再启动应用
command: sh -c "./migrate up && ./server"
# 使用说明:
# 1. 将此配置添加到你的 docker-compose.yml 中
# 2. 确保你的配置文件config.json包含数据库连接信息
# 3. 修改配置后手动执行迁移docker-compose exec app ./migrate up
# 4. 无需重启容器!

157
templates/migrate/main.go Normal file
View File

@@ -0,0 +1,157 @@
package main
import (
"flag"
"fmt"
"os"
"git.toowon.com/jimmy/go-common/factory"
"git.toowon.com/jimmy/go-common/migration"
)
// 数据库迁移 CLI 脚手架。
//
// 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
//
// 默认已接好Factory 初始化、日志、Database Warmup、Migrator、Close 收口。
// 配置优先级:-config/-dir > 环境变量 CONFIG_FILE/MIGRATIONS_DIR > 默认值。
func main() {
var (
configFile string
migrationsDir string
showHelp bool
)
flag.StringVar(&configFile, "config", "", "配置文件路径(默认 config.json")
flag.StringVar(&configFile, "c", "", "配置文件路径(简写)")
flag.StringVar(&migrationsDir, "dir", "", "迁移目录(默认 migrations")
flag.StringVar(&migrationsDir, "d", "", "迁移目录(简写)")
flag.BoolVar(&showHelp, "help", false, "显示帮助")
flag.BoolVar(&showHelp, "h", false, "显示帮助(简写)")
flag.Parse()
if showHelp {
printHelp()
return
}
command := "up"
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)
printHelp()
os.Exit(1)
}
if configFile == "" {
configFile = envOr("CONFIG_FILE", "config.json")
}
if migrationsDir == "" {
migrationsDir = envOr("MIGRATIONS_DIR", "migrations")
}
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)
}
}
// 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
}
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(`数据库迁移工具
用法:
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
}

99
tools/convertor.go Normal file
View File

@@ -0,0 +1,99 @@
package tools
import "strconv"
// ConvertInt 将字符串转换为int类型
// value: 待转换的字符串
// defaultValue: 转换失败或字符串为空时返回的默认值
func ConvertInt(value string, defaultValue int) int {
if value == "" {
return defaultValue
}
intValue, err := strconv.Atoi(value)
if err != nil {
return defaultValue
}
return intValue
}
// ConvertInt64 将字符串转换为int64类型
// value: 待转换的字符串
// defaultValue: 转换失败或字符串为空时返回的默认值
func ConvertInt64(value string, defaultValue int64) int64 {
if value == "" {
return defaultValue
}
intValue, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return defaultValue
}
return intValue
}
// ConvertUint64 将字符串转换为uint64类型
// value: 待转换的字符串
// defaultValue: 转换失败或字符串为空时返回的默认值
func ConvertUint64(value string, defaultValue uint64) uint64 {
if value == "" {
return defaultValue
}
uintValue, err := strconv.ParseUint(value, 10, 64)
if err != nil {
return defaultValue
}
return uintValue
}
// ConvertUint32 将字符串转换为uint32类型
// value: 待转换的字符串
// defaultValue: 转换失败或字符串为空时返回的默认值
func ConvertUint32(value string, defaultValue uint32) uint32 {
if value == "" {
return defaultValue
}
uintValue, err := strconv.ParseUint(value, 10, 32)
if err != nil {
return defaultValue
}
return uint32(uintValue)
}
// ConvertBool 将字符串转换为bool类型
// value: 待转换的字符串
// defaultValue: 转换失败或字符串为空时返回的默认值
func ConvertBool(value string, defaultValue bool) bool {
if value == "" {
return defaultValue
}
boolValue, err := strconv.ParseBool(value)
if err != nil {
return defaultValue
}
return boolValue
}
// ConvertFloat64 将字符串转换为float64类型
// value: 待转换的字符串
// defaultValue: 转换失败或字符串为空时返回的默认值
func ConvertFloat64(value string, defaultValue float64) float64 {
if value == "" {
return defaultValue
}
floatValue, err := strconv.ParseFloat(value, 64)
if err != nil {
return defaultValue
}
return floatValue
}

85
tools/crypto.go Normal file
View File

@@ -0,0 +1,85 @@
package tools
import (
"crypto/md5"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"math/big"
"golang.org/x/crypto/bcrypt"
)
// HashPassword 使用bcrypt加密密码
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
}
// CheckPassword 验证密码
func CheckPassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
// MD5 计算MD5哈希值
func MD5(text string) string {
hash := md5.Sum([]byte(text))
return hex.EncodeToString(hash[:])
}
// SHA256 计算SHA256哈希值
func SHA256(text string) string {
hash := sha256.Sum256([]byte(text))
return hex.EncodeToString(hash[:])
}
// GenerateRandomString 生成指定长度的随机字符串
func GenerateRandomString(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, length)
for i := range b {
num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
b[i] = charset[num.Int64()]
}
return string(b)
}
// GenerateRandomNumber 生成指定长度的随机数字字符串
func GenerateRandomNumber(length int) string {
const charset = "0123456789"
b := make([]byte, length)
for i := range b {
num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
b[i] = charset[num.Int64()]
}
return string(b)
}
// GenerateSMSCode 生成短信验证码
func GenerateSMSCode() string {
return GenerateRandomNumber(6)
}
// GenerateOrderNo 生成订单号
func GenerateOrderNo(prefix string) string {
timestamp := GetTimestamp()
random := GenerateRandomNumber(6)
return fmt.Sprintf("%s%d%s", prefix, timestamp, random)
}
// GeneratePaymentNo 生成支付单号
func GeneratePaymentNo() string {
return GenerateOrderNo("PAY")
}
// GenerateRefundNo 生成退款单号
func GenerateRefundNo() string {
return GenerateOrderNo("RF")
}
// GenerateTransferNo 生成调拨单号
func GenerateTransferNo() string {
return GenerateOrderNo("TF")
}

View File

@@ -1,4 +1,4 @@
package datetime package tools
import ( import (
"fmt" "fmt"

173
tools/money.go Normal file
View File

@@ -0,0 +1,173 @@
package tools
import (
"fmt"
"math"
)
// MoneyCalculator 金额计算工具(以分为单位)
type MoneyCalculator struct{}
// NewMoneyCalculator 创建金额计算器
func NewMoneyCalculator() *MoneyCalculator {
return &MoneyCalculator{}
}
// YuanToCents 元转分
func (m *MoneyCalculator) YuanToCents(yuan float64) int64 {
return int64(math.Round(yuan * 100))
}
// CentsToYuan 分转元
func (m *MoneyCalculator) CentsToYuan(cents int64) float64 {
return float64(cents) / 100
}
// FormatYuan 格式化显示金额分转元保留2位小数
func (m *MoneyCalculator) FormatYuan(cents int64) string {
return fmt.Sprintf("%.2f", m.CentsToYuan(cents))
}
// Add 金额相加
func (m *MoneyCalculator) Add(amount1, amount2 int64) int64 {
return amount1 + amount2
}
// Subtract 金额相减
func (m *MoneyCalculator) Subtract(amount1, amount2 int64) int64 {
return amount1 - amount2
}
// Multiply 金额乘法(金额 * 数量)
func (m *MoneyCalculator) Multiply(amount int64, quantity int) int64 {
return amount * int64(quantity)
}
// MultiplyFloat 金额乘法(金额 * 浮点数,如折扣)
func (m *MoneyCalculator) MultiplyFloat(amount int64, rate float64) int64 {
return int64(math.Round(float64(amount) * rate))
}
// Divide 金额除法(平均分配)
func (m *MoneyCalculator) Divide(amount int64, count int) int64 {
if count <= 0 {
return 0
}
return amount / int64(count)
}
// CalculateDiscount 计算折扣金额
func (m *MoneyCalculator) CalculateDiscount(originalAmount int64, discountRate float64) int64 {
if discountRate <= 0 || discountRate >= 1 {
return 0
}
return int64(math.Round(float64(originalAmount) * discountRate))
}
// CalculatePercentage 计算百分比金额
func (m *MoneyCalculator) CalculatePercentage(amount int64, percentage float64) int64 {
return int64(math.Round(float64(amount) * percentage / 100))
}
// Max 取最大金额
func (m *MoneyCalculator) Max(amounts ...int64) int64 {
if len(amounts) == 0 {
return 0
}
max := amounts[0]
for _, amount := range amounts[1:] {
if amount > max {
max = amount
}
}
return max
}
// Min 取最小金额
func (m *MoneyCalculator) Min(amounts ...int64) int64 {
if len(amounts) == 0 {
return 0
}
min := amounts[0]
for _, amount := range amounts[1:] {
if amount < min {
min = amount
}
}
return min
}
// IsPositive 判断是否为正数
func (m *MoneyCalculator) IsPositive(amount int64) bool {
return amount > 0
}
// IsZero 判断是否为零
func (m *MoneyCalculator) IsZero(amount int64) bool {
return amount == 0
}
// IsNegative 判断是否为负数
func (m *MoneyCalculator) IsNegative(amount int64) bool {
return amount < 0
}
// Equal 判断金额是否相等
func (m *MoneyCalculator) Equal(amount1, amount2 int64) bool {
return amount1 == amount2
}
// Greater 判断第一个金额是否大于第二个
func (m *MoneyCalculator) Greater(amount1, amount2 int64) bool {
return amount1 > amount2
}
// Less 判断第一个金额是否小于第二个
func (m *MoneyCalculator) Less(amount1, amount2 int64) bool {
return amount1 < amount2
}
// Sum 计算金额总和
func (m *MoneyCalculator) Sum(amounts ...int64) int64 {
var total int64
for _, amount := range amounts {
total += amount
}
return total
}
// SplitAmount 金额分摊(处理分摊不均的情况)
func (m *MoneyCalculator) SplitAmount(totalAmount int64, count int) []int64 {
if count <= 0 {
return []int64{}
}
baseAmount := totalAmount / int64(count)
remainder := totalAmount % int64(count)
amounts := make([]int64, count)
for i := 0; i < count; i++ {
amounts[i] = baseAmount
if int64(i) < remainder {
amounts[i]++
}
}
return amounts
}
// 全局金额计算器实例
var Money = NewMoneyCalculator()
// 便捷函数
func YuanToCents(yuan float64) int64 {
return Money.YuanToCents(yuan)
}
func CentsToYuan(cents int64) float64 {
return Money.CentsToYuan(cents)
}
func FormatYuan(cents int64) string {
return Money.FormatYuan(cents)
}

186
tools/time.go Normal file
View File

@@ -0,0 +1,186 @@
package tools
import (
"time"
)
// TimeInfo 详细时间信息结构
type TimeInfo struct {
UTC string `json:"utc"` // UTC时间
Local string `json:"local"` // 用户时区时间
Unix int64 `json:"unix"` // Unix时间戳
Timezone string `json:"timezone"` // 时区名称
Offset int `json:"offset"` // 时区偏移量(小时)
RFC3339 string `json:"rfc3339"` // RFC3339格式
DateTime string `json:"datetime"` // 日期时间格式
Date string `json:"date"` // 日期格式
Time string `json:"time"` // 时间格式
}
// GetTimestamp 获取当前时间戳(秒)
func GetTimestamp() int64 {
return time.Now().Unix()
}
// GetMillisTimestamp 获取当前时间戳(毫秒)
func GetMillisTimestamp() int64 {
return time.Now().UnixMilli()
}
// FormatTimeWithLayout 格式化时间(自定义格式)
func FormatTimeWithLayout(t time.Time, layout string) string {
if layout == "" {
layout = "2006-01-02 15:04:05"
}
return t.Format(layout)
}
// ParseTime 解析时间字符串
func ParseTime(timeStr, layout string) (time.Time, error) {
if layout == "" {
layout = "2006-01-02 15:04:05"
}
return time.Parse(layout, timeStr)
}
// GetCurrentTime 获取当前时间字符串
func GetCurrentTime() string {
return FormatTimeWithLayout(time.Now(), "")
}
// 注意GetBeginOfDay、GetEndOfDay 已在 datetime.go 中实现为 StartOfDay、EndOfDay
// datetime.go 中的方法支持时区参数,功能更强大,建议使用 datetime.go 中的方法
// GetBeginOfWeek 获取某周的开始时间(周一)
func GetBeginOfWeek(t time.Time) time.Time {
weekday := t.Weekday()
if weekday == time.Sunday {
weekday = 7
}
// 使用 datetime.go 中的 StartOfDay 方法(需要时区,这里使用时间对象本身的时区)
beginDay := t.AddDate(0, 0, int(1-weekday))
return time.Date(beginDay.Year(), beginDay.Month(), beginDay.Day(), 0, 0, 0, 0, beginDay.Location())
}
// GetEndOfWeek 获取某周的结束时间(周日)
func GetEndOfWeek(t time.Time) time.Time {
beginOfWeek := GetBeginOfWeek(t)
endDay := beginOfWeek.AddDate(0, 0, 6)
return time.Date(endDay.Year(), endDay.Month(), endDay.Day(), 23, 59, 59, 999999999, endDay.Location())
}
// 注意GetBeginOfMonth、GetEndOfMonth、GetBeginOfYear、GetEndOfYear 已在 datetime.go 中实现
// datetime.go 中的方法StartOfMonth、EndOfMonth、StartOfYear、EndOfYear支持时区参数功能更强大
// 建议使用 datetime.go 中的方法
// AddHours 增加小时数
func AddHours(t time.Time, hours int) time.Time {
return t.Add(time.Duration(hours) * time.Hour)
}
// AddMinutes 增加分钟数
func AddMinutes(t time.Time, minutes int) time.Time {
return t.Add(time.Duration(minutes) * time.Minute)
}
// 注意DiffDays、DiffHours、DiffMinutes、DiffSeconds 方法已在 datetime.go 中实现
// 请使用 datetime.go 中的方法,它们支持更精确的计算和统一的返回类型
// IsToday 判断是否为今天
func IsToday(t time.Time) bool {
now := time.Now()
tBegin := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
nowBegin := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
return tBegin.Equal(nowBegin)
}
// GenerateTimeInfoWithTimezone 生成详细时间信息(指定时区)
func GenerateTimeInfoWithTimezone(t time.Time, timezone string) TimeInfo {
// 加载时区
loc, err := time.LoadLocation(timezone)
if err != nil {
loc = time.UTC
timezone = "UTC"
}
// 转换为指定时区时间
localTime := t.In(loc)
// 计算时区偏移量
_, offset := localTime.Zone()
offsetHours := offset / 3600
// 预先计算格式化结果,避免重复调用
utcRFC3339 := t.UTC().Format(time.RFC3339)
localRFC3339 := localTime.Format(time.RFC3339)
localDateTime := localTime.Format("2006-01-02 15:04:05")
localDate := localTime.Format("2006-01-02")
localTimeOnly := localTime.Format("15:04:05")
return TimeInfo{
UTC: utcRFC3339,
Local: localRFC3339,
Unix: t.Unix(),
Timezone: timezone,
Offset: offsetHours,
RFC3339: localRFC3339,
DateTime: localDateTime,
Date: localDate,
Time: localTimeOnly,
}
}
// GetUTCTimestamp 获取UTC时间戳
func GetUTCTimestamp() int64 {
return time.Now().UTC().Unix()
}
// GetUTCTimestampFromTime 从指定时间获取UTC时间戳
func GetUTCTimestampFromTime(t time.Time) int64 {
return t.UTC().Unix()
}
// FormatTimeUTC 格式化时间为UTC字符串ISO 8601格式
func FormatTimeUTC(t time.Time) string {
return t.UTC().Format(time.RFC3339)
}
// IsYesterday 判断是否为昨天
func IsYesterday(t time.Time) bool {
yesterday := time.Now().AddDate(0, 0, -1)
tBegin := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
yesterdayBegin := time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(), 0, 0, 0, 0, yesterday.Location())
return tBegin.Equal(yesterdayBegin)
}
// IsTomorrow 判断是否为明天
func IsTomorrow(t time.Time) bool {
tomorrow := time.Now().AddDate(0, 0, 1)
tBegin := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
tomorrowBegin := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 0, 0, 0, 0, tomorrow.Location())
return tBegin.Equal(tomorrowBegin)
}
// GenerateTimeInfoFromContext 从gin.Context中获取用户时区并生成时间信息
func GenerateTimeInfoFromContext(t time.Time, c interface{}) TimeInfo {
// 尝试从context中获取时区
timezone := ""
// 如果传入的是gin.Context尝试获取时区
if ginCtx, ok := c.(interface {
Get(key string) (value interface{}, exists bool)
}); ok {
if tz, exists := ginCtx.Get("user_timezone"); exists {
if tzStr, ok := tz.(string); ok && tzStr != "" {
timezone = tzStr
}
}
}
// 如果没有获取到时区使用默认时区东8区
if timezone == "" {
timezone = "Asia/Shanghai"
}
return GenerateTimeInfoWithTimezone(t, timezone)
}

26
tools/version.go Normal file
View File

@@ -0,0 +1,26 @@
package tools
import (
"os"
)
// Version 版本号 - 在这里修改版本号(默认值)
const DefaultVersion = "1.0.1"
// GetVersion 获取版本号
// 优先从环境变量 DOCKER_TAG 或 VERSION 中读取
// 如果没有设置环境变量,则使用默认版本号
func GetVersion() string {
// 优先从 Docker 标签环境变量读取
if dockerTag := os.Getenv("DOCKER_TAG"); dockerTag != "" {
return dockerTag
}
// 从通用版本环境变量读取
if version := os.Getenv("VERSION"); version != "" {
return version
}
// 使用默认版本号
return DefaultVersion
}