16 Commits

84 changed files with 6313 additions and 10201 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
.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)。

View File

@@ -1,583 +0,0 @@
# 数据库迁移工具 - 完整指南
## 📌 核心特点
-**独立工具,零耦合** - 与应用代码完全分离
-**生产就绪** - 编译成二进制无需Go环境
-**灵活配置** - 支持命令行参数、环境变量、配置文件
-**Docker友好** - 挂载配置,修改无需重启容器
---
## 🚀 快速开始3步
> **黑盒模式**:迁移工具内部调用 `migration.RunMigrationsFromConfig()` 方法自动处理配置加载、数据库连接、迁移执行等所有细节。你只需要提供配置文件和SQL文件即可。
### 1. 复制迁移工具模板
```bash
mkdir -p cmd/migrate
cp /path/to/go-common/templates/migrate/main.go cmd/migrate/
```
### 2. 创建迁移文件
```bash
mkdir -p migrations
```
创建 `migrations/20240101000001_create_users.sql`
```sql
CREATE TABLE users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### 3. 编译和使用
```bash
# 编译(生产环境推荐)
go build -o bin/migrate cmd/migrate/main.go
# 使用
./bin/migrate up # 使用默认配置
./bin/migrate up -config /path/to/config.json # 指定配置
./bin/migrate status # 查看状态
./bin/migrate -help # 查看帮助
```
---
## 💻 本地使用
### 开发环境
```bash
# 直接运行需要Go环境
go run cmd/migrate/main.go up
go run cmd/migrate/main.go up -config dev.json
# 编译后运行(推荐)
go build -o bin/migrate cmd/migrate/main.go
./bin/migrate up
./bin/migrate up -config config.prod.json
./bin/migrate up -c prod.json -d db/migrations
```
### 命令说明
```bash
./bin/migrate -help
# 输出:
# 用法: migrate [命令] [选项]
#
# 命令:
# up 执行所有待执行的迁移(默认)
# down 回滚最后一个迁移
# status 查看迁移状态
#
# 选项:
# -config, -c 配置文件路径(默认: config.json
# -dir, -d 迁移文件目录(默认: migrations
# -help, -h 显示帮助信息
```
### 配置方式
#### 方式1配置文件推荐开发环境
`config.json`:
```json
{
"database": {
"type": "mysql",
"host": "localhost",
"port": 3306,
"user": "root",
"password": "password",
"database": "mydb"
}
}
```
#### 方式2环境变量推荐生产环境
```bash
# 使用 DATABASE_URL最简单
export DATABASE_URL="mysql://root:password@localhost:3306/mydb"
./bin/migrate up
# 覆盖配置路径
export CONFIG_FILE="/etc/app/config.json"
export MIGRATIONS_DIR="/opt/app/migrations"
./bin/migrate up
```
#### 配置优先级(从高到低)
1. 命令行参数 `-config``-dir`(最高)
2. 环境变量 `CONFIG_FILE``MIGRATIONS_DIR`
3. 环境变量 `DATABASE_URL`
4. 默认值 `config.json``migrations`
---
## 🐳 Docker 使用
### 方式1挂载配置文件推荐
**优势**:修改配置无需重启容器!
#### docker-compose.yml
```yaml
version: '3.8'
services:
app:
build: .
ports:
- "8080:8080"
volumes:
# 挂载配置文件(推荐:修改配置无需重启容器)
- ./config.json:/app/config.json:ro
# 启动时先执行迁移,再启动应用
command: sh -c "./migrate up && ./server"
```
#### 使用方式
```bash
# 1. 启动服务
docker-compose up -d
# 2. 修改配置文件
vim config.json
# 3. 手动执行迁移(无需重启容器!)
docker-compose exec app ./migrate up
# 4. 查看状态
docker-compose exec app ./migrate status
```
### 方式2指定配置文件路径
适用于多环境部署:
```yaml
services:
app:
build: .
volumes:
# 挂载不同环境的配置文件
- ./config.prod.json:/app/config.json:ro
command: sh -c "./migrate up -config /app/config.json && ./server"
```
```bash
# 手动切换环境(修改挂载的配置文件后)
docker-compose exec app ./migrate up
```
### 方式3使用环境变量
无需配置文件(适用于简单场景):
```yaml
services:
app:
build: .
environment:
DATABASE_URL: mysql://root:password@db:3306/mydb
command: sh -c "./migrate up && ./server"
# 注意DATABASE_URL 的值应该指向你的数据库服务
# 例如mysql://user:pass@your-db-host:3306/dbname
```
### Dockerfile
```dockerfile
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/migrate .
COPY --from=builder /app/bin/server .
COPY --from=builder /app/migrations ./migrations
EXPOSE 8080
# 启动:先迁移,再启动应用
CMD ["sh", "-c", "./migrate up && ./server"]
# 注意:配置文件通过 volumes 挂载,不打包进镜像
```
---
## ☸️ Kubernetes 部署
### 使用 Job 执行迁移
```yaml
# k8s-job-migrate.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: db-migrate
spec:
template:
spec:
containers:
- name: migrate
image: myapp:latest
command: ["./migrate", "up", "-config", "/etc/config/database.json"]
volumeMounts:
- name: config
mountPath: /etc/config
readOnly: true
volumes:
- name: config
configMap:
name: app-config
restartPolicy: OnFailure
```
### 部署流程
```bash
# 1. 创建 ConfigMap
kubectl create configmap app-config --from-file=config.json
# 2. 执行迁移
kubectl apply -f k8s-job-migrate.yaml
kubectl wait --for=condition=complete job/db-migrate
# 3. 部署应用
kubectl apply -f k8s-deployment.yaml
```
---
## 🔧 CI/CD 集成
### GitLab CI
```yaml
# .gitlab-ci.yml
stages:
- build
- migrate
- deploy
build:
stage: build
script:
- go build -o bin/migrate cmd/migrate/main.go
- go build -o bin/server cmd/server/main.go
artifacts:
paths:
- bin/
migrate:
stage: migrate
script:
- ./bin/migrate up -config config.prod.json
environment:
name: production
deploy:
stage: deploy
script:
- ./bin/server
```
### GitHub Actions
```yaml
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.21'
- name: Build
run: |
go build -o bin/migrate cmd/migrate/main.go
go build -o bin/server cmd/server/main.go
- name: Run Migrations
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: ./bin/migrate up
- name: Deploy
run: ./bin/server
```
---
## 📁 迁移文件管理
### 文件命名规则
```
migrations/
├── 20240101000001_create_users.sql # Up 迁移
├── 20240101000001_create_users.down.sql # Down 回滚(可选)
├── 20240102000001_add_posts.sql
└── 20240102000001_add_posts.down.sql
```
格式:`{时间戳}_{描述}.sql`
### 创建迁移文件
```bash
# 获取时间戳
date +%Y%m%d%H%M%S
# 输出20240101120000
# 创建迁移文件
vim migrations/20240101120000_create_posts.sql
```
### 迁移文件示例
**Up 文件**
```sql
-- migrations/20240101000001_create_users.sql
CREATE TABLE users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(255) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_users_email ON users(email);
```
**Down 文件**(可选):
```sql
-- migrations/20240101000001_create_users.down.sql
DROP INDEX idx_users_email ON users;
DROP TABLE IF EXISTS users;
```
### 兼容性建议
使用条件语句确保迁移可重复执行:
```sql
-- 创建表
CREATE TABLE IF NOT EXISTS users (...);
-- 添加列
ALTER TABLE posts ADD COLUMN IF NOT EXISTS author_id BIGINT;
-- 创建索引
CREATE INDEX IF NOT EXISTS idx_posts_author ON posts(author_id);
```
---
## 🔍 常见问题
### Q: 为什么不直接在应用代码中调用?
**A**: **耦合度太高!** 独立工具的优势:
- ✅ 应用和迁移完全解耦
- ✅ 可以独立部署和执行
- ✅ 更灵活的部署策略
- ✅ 符合单一职责原则
### Q: 生产环境没有Go怎么办
**A**: **编译成二进制文件**
```bash
# 本地或CI中编译
go build -o bin/migrate cmd/migrate/main.go
# 部署二进制文件不需要Go环境
scp bin/migrate server:/app/
ssh server "/app/migrate up"
```
### Q: Docker中修改配置需要重启吗
**A**: **不需要!** 使用挂载方式:
```yaml
volumes:
- ./config.json:/app/config.json:ro
```
修改后直接执行:
```bash
docker-compose exec app ./migrate up
```
### Q: 如何指定不同的配置文件?
**A**: 使用命令行参数:
```bash
# 开发环境
./migrate up -config config.dev.json
# 测试环境
./migrate up -config config.test.json
# 生产环境
./migrate up -config /etc/app/config.prod.json
```
### Q: 多个实例同时启动会有问题吗?
**A**: 不会。数据库会保证只有一个实例能执行迁移(通过版本号主键)。
### Q: Docker连不上数据库
**A**: 注意主机名:
-`localhost`(容器内无法访问宿主机)
-`db`docker-compose 服务名)
-`host.docker.internal`Mac/Windows 访问宿主机)
---
## 💡 最佳实践
### 1. 开发环境
- 使用 `go run` 快速迭代
- 使用配置文件管理不同环境
```bash
go run cmd/migrate/main.go up -config config.dev.json
```
### 2. 测试环境
- 编译后部署,模拟生产环境
- 使用独立的数据库
```bash
go build -o bin/migrate cmd/migrate/main.go
./bin/migrate up -config config.test.json
```
### 3. 生产环境
- 编译后部署,先执行迁移再启动应用
- 使用环境变量管理敏感信息
```bash
go build -o bin/migrate cmd/migrate/main.go
DATABASE_URL="mysql://..." ./bin/migrate up
./bin/server
```
### 4. Docker 部署
- 多阶段构建,只包含二进制文件
- 挂载配置文件,灵活修改
```dockerfile
FROM golang:1.21 as builder
RUN go build -o bin/migrate cmd/migrate/main.go
FROM debian:bookworm-slim
COPY --from=builder /app/bin/migrate .
CMD ["sh", "-c", "./migrate up && ./server"]
```
### 5. CI/CD
- 在构建阶段编译
- 部署前执行迁移
```yaml
- build: go build -o bin/migrate cmd/migrate/main.go
- migrate: ./bin/migrate up
- deploy: ./bin/server
```
---
## 📊 推荐的项目结构
```
your-project/
├── cmd/
│ ├── migrate/
│ │ └── main.go # 迁移工具(独立)
│ └── server/
│ └── main.go # 应用主程序
├── migrations/ # 迁移SQL文件
│ ├── 20240101000001_create_users.sql
│ └── 20240101000001_create_users.down.sql
├── config.json # 配置文件
├── Dockerfile
├── docker-compose.yml
├── Makefile # 常用命令
└── go.mod
```
---
## 📚 更多资源
- [模板文件](./templates/) - 可直接复制的模板
- [完整文档](./docs/migration.md) - 详细功能文档
- [配置文档](./docs/config.md) - 配置说明
---
## 🎯 总结
使用 GoCommon 的迁移工具,你可以:
1. ✅ 复制一个模板文件到 `cmd/migrate/main.go`
2. ✅ 创建 SQL 迁移文件到 `migrations/`
3. ✅ 编译:`go build -o bin/migrate cmd/migrate/main.go`
4. ✅ 使用:`./bin/migrate up`
**核心优势**
- 独立工具,零耦合
- 生产就绪无需Go环境
- 灵活配置,支持多环境
- Docker友好修改配置无需重启
**开箱即用,灵活强大!** 🎉

View File

@@ -1,346 +0,0 @@
# 快速开始指南
5分钟快速上手 GoCommon 工具库。
## 1. 安装
```bash
# 配置私有仓库
go env -w GOPRIVATE=git.toowon.com
# 安装最新版本
go get git.toowon.com/jimmy/go-common@latest
```
## 2. 创建配置文件
创建 `config.json`
```json
{
"database": {
"type": "mysql",
"host": "localhost",
"port": 3306,
"user": "root",
"password": "password",
"database": "mydb"
},
"redis": {
"host": "localhost",
"port": 6379
},
"logger": {
"level": "info",
"output": "stdout",
"async": true
},
"rateLimit": {
"enable": true,
"rate": 100,
"period": 60,
"byIP": true
}
}
```
## 3. 创建主程序
创建 `main.go`
```go
package main
import (
"net/http"
"time"
"git.toowon.com/jimmy/go-common/factory"
"git.toowon.com/jimmy/go-common/middleware"
commonhttp "git.toowon.com/jimmy/go-common/http"
)
func main() {
// 从配置文件创建工厂(黑盒模式)
fac, err := factory.NewFactoryFromFile("./config.json")
if err != nil {
panic(err)
}
// 使用factory的黑盒方法获取中间件链
// 自动从配置文件读取并配置所有中间件
chain := fac.GetMiddlewareChain()
// (可选)如果项目需要额外的中间件,可以继续添加
// chain.Append(yourAuthMiddleware, yourMetricsMiddleware)
// 注册路由
http.Handle("/api/hello", chain.ThenFunc(handleHello))
http.Handle("/api/users", chain.ThenFunc(handleUsers))
// 启动服务
logger.Info("Server started on :8080")
http.ListenAndServe(":8080", nil)
}
// API处理器 - 问候接口
func handleHello(w http.ResponseWriter, r *http.Request) {
h := commonhttp.NewHandler(w, r)
h.Success(map[string]interface{}{
"message": "Hello, World!",
"timezone": h.GetTimezone(),
})
}
// API处理器 - 用户列表(带分页)
func handleUsers(w http.ResponseWriter, r *http.Request) {
h := commonhttp.NewHandler(w, r)
// 解析分页参数
pagination := h.ParsePaginationRequest()
page := pagination.GetPage()
size := pagination.GetSize()
// 模拟数据
users := []map[string]interface{}{
{"id": 1, "name": "Alice"},
{"id": 2, "name": "Bob"},
}
total := int64(100)
// 返回分页数据
h.SuccessPage(users, total, page, size)
}
```
## 4. 运行
```bash
go run main.go
```
## 5. 测试
```bash
# 测试问候接口
curl http://localhost:8080/api/hello
# 测试分页接口
curl "http://localhost:8080/api/users?page=1&page_size=10"
# 测试时区
curl -H "X-Timezone: America/New_York" http://localhost:8080/api/hello
# 测试限流(快速请求多次)
for i in {1..150}; do curl http://localhost:8080/api/hello; done
```
## 6. 常见使用场景
### 场景1使用数据库
```go
// 获取数据库连接
db, _ := fac.GetDatabase()
// 使用GORM查询
var users []User
db.Find(&users)
```
### 场景2使用Redis
```go
ctx := context.Background()
// 设置值
fac.RedisSet(ctx, "key", "value", time.Hour)
// 获取值
value, _ := fac.RedisGet(ctx, "key")
// 删除值
fac.RedisDelete(ctx, "key")
```
### 场景3发送邮件
```go
// 发送简单邮件
fac.SendEmail(
[]string{"user@example.com"},
"测试邮件",
"这是邮件正文",
)
// 发送HTML邮件
fac.SendEmail(
[]string{"user@example.com"},
"测试邮件",
"纯文本内容",
"<h1>HTML内容</h1>",
)
```
### 场景4上传文件
```go
ctx := context.Background()
// 打开文件
file, _ := os.Open("test.jpg")
defer file.Close()
// 上传文件自动选择OSS或MinIO
url, _ := fac.UploadFile(ctx, "images/test.jpg", file, "image/jpeg")
// 获取文件URL
url, _ := fac.GetFileURL("images/test.jpg", 3600) // 1小时后过期
```
### 场景5记录日志
```go
// 简单日志
fac.LogInfo("用户登录成功")
fac.LogError("登录失败: %v", err)
// 带字段的日志
fac.LogInfof(map[string]interface{}{
"user_id": 123,
"action": "login",
}, "用户操作")
```
### 场景6时间处理
```go
import "git.toowon.com/jimmy/go-common/datetime"
// 获取当前时间(带时区)
timezone := h.GetTimezone()
now := datetime.Now(timezone)
// 格式化时间
str := datetime.FormatDateTime(now)
// 解析时间
t, _ := datetime.ParseDateTime("2024-01-01 00:00:00", timezone)
// 时间计算
tomorrow := datetime.AddDays(now, 1)
startOfDay := datetime.StartOfDay(now, timezone)
```
### 场景7数据库迁移独立工具
```bash
# 1. 复制模板到项目
cp templates/migrate/main.go cmd/migrate/
# 2. 创建迁移文件 migrations/20240101000001_create_users.sql
# 3. 编译并使用
go build -o bin/migrate cmd/migrate/main.go
./bin/migrate up # 执行迁移
./bin/migrate status # 查看状态
```
**特点**:独立工具,零耦合,生产就绪
完整指南:[MIGRATION.md](./MIGRATION.md)
## 7. 更多文档
- [完整文档](./docs/README.md)
- [中间件文档](./docs/middleware.md)
- [工厂模式文档](./docs/factory.md)
- [HTTP工具文档](./docs/http.md)
- [配置文档](./docs/config.md)
## 常见问题
### Q: 如何自定义中间件配置?
查看 [中间件文档](./docs/middleware.md) 了解详细配置选项。
### Q: 如何使用数据库迁移?
查看 [迁移工具文档](./docs/migration.md) 了解数据库版本管理。
### Q: 支持哪些数据库?
支持 MySQL、PostgreSQL、SQLite。
### Q: 日志如何配置异步模式?
在配置文件中设置 `"async": true`,或通过代码配置:
```go
loggerConfig := &config.LoggerConfig{
Async: true,
BufferSize: 1000,
}
```
### Q: 如何添加自定义中间件?
```go
// 获取基础中间件链
chain := fac.GetMiddlewareChain()
// 添加自定义中间件
chain.Append(
yourAuthMiddleware, // 认证中间件
yourMetricsMiddleware, // 指标中间件
// 更多自定义中间件...
)
// 使用扩展后的中间件链
http.Handle("/api/secure", chain.ThenFunc(yourHandler))
```
自定义中间件示例:
```go
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token == "" {
http.Error(w, "Unauthorized", 401)
return
}
// 验证token...
next.ServeHTTP(w, r)
})
}
```
### Q: 如何按用户ID限流
在配置文件中设置:
```json
{
"rateLimit": {
"enable": true,
"rate": 100,
"period": 60,
"byUserID": true,
"byIP": false
}
}
```
中间件会自动从 `X-User-ID` header 中获取用户ID进行限流。
## 下一步
恭喜!你已经掌握了 GoCommon 的基本使用。
建议阅读:
1. [中间件文档](./docs/middleware.md) - 了解更多中间件配置
2. [工厂模式文档](./docs/factory.md) - 深入了解黑盒模式
3. [示例代码](./examples/) - 查看更多实际示例

423
README.md
View File

@@ -1,392 +1,47 @@
# GoCommon - Go通用工具类库
# GoCommon
这是一个Go语言开发的通用工具类库其他Go项目提供常用的工具方法集合。
其他 Go 项目引用的通用工具库。对接请看:**[INTEGRATION.md](./INTEGRATION.md)**
**📖 快速链接**
- [5分钟快速开始](./QUICKSTART.md)
- [数据库迁移指南](./MIGRATION.md) ⭐ 独立工具零耦合Docker友好
- [完整文档](./docs/README.md)
## 🌟 核心特性
### 🎯 **极简调用减少80%重复代码**
- **工厂黑盒模式**:一个配置文件,搞定所有服务初始化
- **Handler黑盒模式**统一的HTTP请求处理无需重复传递 `w``r`
- **中间件链式调用**:一行代码组合多个中间件
### 🚀 **生产级特性,开箱即用**
- **异步日志**:不阻塞请求,高并发性能
- **Panic恢复**自动捕获panic防止服务崩溃
- **令牌桶限流**保护API防止滥用
- **时区自动处理**:统一管理时区,避免时间错乱
### 🔧 **灵活可扩展**
- **默认配置即可用**:传 `nil` 使用默认配置
- **完全可定制**:每个功能都支持自定义配置
- **无侵入设计**:可以独立使用任何模块
### 📦 **零外部依赖(核心功能)**
- email、sms 使用 Go 标准库实现
- 可选依赖gorm数据库、redis、minio
## 功能模块
### 1. 数据库迁移工具 (migration)
提供数据库迁移功能支持MySQL、PostgreSQL、SQLite等数据库。
**🎯 独立工具,零耦合**
- ✅ 编译成独立二进制:`go build -o bin/migrate cmd/migrate/main.go`
- ✅ 生产环境无需Go环境只需二进制文件
- ✅ 与应用代码完全解耦,可独立部署和执行
- ✅ 支持宿主机和Docker零额外配置
### 2. 日期转换工具 (datetime)
提供日期时间转换功能,支持时区设定和多种格式转换。
### 3. HTTP Restful工具 (http)
提供HTTP请求/响应处理工具包含标准化的响应结构、分页支持和HTTP状态码与业务状态码的分离。
### 4. 中间件工具 (middleware)
提供生产级HTTP中间件包括
- **CORS** - 跨域资源共享
- **Timezone** - 时区处理
- **Logging** - 请求日志记录(支持异步)
- **Recovery** - Panic恢复防止服务崩溃
- **RateLimit** - 请求限流(令牌桶算法)
- **Chain** - 中间件链式组合
### 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标准库实现。
---
## 🎯 Factory 黑盒模式(核心设计)
**理念**:外部项目只需传递一个配置文件路径,直接使用黑盒方法,无需获取内部对象。
### 方法分类
| 类型 | 方法 | 使用方式 | 推荐度 |
|------|------|----------|--------|
| **黑盒方法(推荐)** | | | |
| 中间件 | `GetMiddlewareChain()` | 直接使用可Append自定义中间件 | ⭐⭐⭐ |
| 日志 | `LogInfo()`, `LogError()` 等 | 直接调用无需获取logger对象 | ⭐⭐⭐ |
| Redis | `RedisSet()`, `RedisGet()` 等 | 直接调用,覆盖常用操作 | ⭐⭐⭐ |
| 邮件 | `SendEmail()` | 直接调用 | ⭐⭐⭐ |
| 短信 | `SendSMS()` | 直接调用 | ⭐⭐⭐ |
| 存储 | `UploadFile()`, `GetFileURL()` | 直接调用 | ⭐⭐⭐ |
| **Get方法高级功能** | | | |
| 数据库 | `GetDatabase()` | 返回GORM对象用于复杂查询 | ⭐⭐ |
| Redis高级 | `GetRedisClient()` | 返回Redis客户端用于Hash/List/Set等 | ⭐ |
| Logger高级 | `GetLogger()` | 返回Logger对象用于Close等 | ⭐ |
### 使用示例
```go
// 创建工厂(只需配置文件路径)
fac, _ := factory.NewFactoryFromFile("config.json")
// ====== 推荐使用黑盒方法 ======
fac.LogInfo("用户登录")
fac.RedisSet(ctx, "key", "value", time.Hour)
fac.SendEmail([]string{"user@example.com"}, "主题", "内容")
chain := fac.GetMiddlewareChain()
// ====== 仅在需要高级功能时获取对象 ======
db, _ := fac.GetDatabase() // 数据库操作复杂使用GORM
db.Find(&users)
client, _ := fac.GetRedisClient() // Redis高级操作
client.HSet(ctx, "user:1", "name", "Alice")
```
git.toowon.com/jimmy/go-common
```
---
## 安装
### 1. 配置私有仓库(重要)
由于本项目使用私有 Git 仓库,需要先配置 `GOPRIVATE` 环境变量:
```bash
# 使用 go env 命令配置(推荐,永久生效)
go env -w GOPRIVATE=git.toowon.com
# 验证配置
go env GOPRIVATE
go get git.toowon.com/jimmy/go-common@v1.4.0
```
**详细配置说明请参考 [SETUP.md](./SETUP.md)**
**遇到问题?请查看 [故障排除指南](./TROUBLESHOOTING.md)**
### 2. 安装模块
```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)**
---
## 📚 文档导航
- **[快速开始指南](./QUICKSTART.md)** ⭐ - 5分钟快速上手
- [完整文档](./docs/README.md) - 所有模块详细文档
- [故障排除](./TROUBLESHOOTING.md) - 常见问题解决
- [版本管理](./VERSION.md) - 版本发布说明
---
## 快速开始
### 1. 创建配置文件 `config.json`
```json
{
"database": {
"type": "mysql",
"host": "localhost",
"port": 3306,
"user": "root",
"password": "password",
"database": "mydb"
},
"redis": {
"host": "localhost",
"port": 6379
},
"logger": {
"level": "info",
"output": "both",
"filePath": "./logs/app.log",
"async": true
}
}
```
### 2. 使用工厂黑盒模式(最简单,推荐)⭐
```go
package main
import (
"context"
"net/http"
"time"
"git.toowon.com/jimmy/go-common/factory"
commonhttp "git.toowon.com/jimmy/go-common/http"
)
func main() {
// 只需传入配置文件路径
fac, _ := factory.NewFactoryFromFile("config.json")
// 获取配置好的中间件链(黑盒)
chain := fac.GetMiddlewareChain()
// (可选)添加自定义中间件
chain.Append(yourAuthMiddleware)
// 注册路由
http.Handle("/api/hello", chain.ThenFunc(handleHello))
http.ListenAndServe(":8080", nil)
}
func handleHello(w http.ResponseWriter, r *http.Request) {
h := commonhttp.NewHandler(w, r)
fac, _ := factory.NewFactoryFromFile("config.json")
ctx := context.Background()
// 使用黑盒方法(无需获取对象)
fac.LogInfo("处理请求: /api/hello")
fac.RedisSet(ctx, "last_visit", time.Now().String(), time.Hour)
h.Success(map[string]interface{}{
"message": "Hello!",
"timezone": h.GetTimezone(),
})
}
```
### 3. 运行项目
```bash
go run main.go
# 访问 http://localhost:8080/api/hello
```
## 核心功能示例
详细文档请参考:[完整文档](./docs/README.md) | [快速开始](./QUICKSTART.md)
### 数据库迁移
```bash
# 编译独立工具
go build -o bin/migrate cmd/migrate/main.go
# 执行迁移
./bin/migrate up # 默认配置
./bin/migrate up -config /path/to/config.json # 指定配置
./bin/migrate status # 查看状态
```
**详细说明**[数据库迁移指南](./MIGRATION.md) ⭐
### 工厂黑盒模式(推荐)
```go
import "git.toowon.com/jimmy/go-common/factory"
fac, _ := factory.NewFactoryFromFile("config.json")
// 中间件
chain := fac.GetMiddlewareChain()
chain.Append(yourAuthMiddleware) // 添加自定义中间件
// 日志
fac.LogInfo("用户登录成功")
// Redis
fac.RedisSet(ctx, "key", "value", time.Hour)
// 邮件/短信
fac.SendEmail([]string{"user@example.com"}, "主题", "内容")
fac.SendSMS([]string{"13800138000"}, map[string]string{"code": "123456"})
// 文件上传
url, _ := fac.UploadFile(ctx, "images/test.jpg", file, "image/jpeg")
// 数据库(高级功能)
db, _ := fac.GetDatabase()
db.Find(&users)
```
### HTTP处理器
```go
import 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 "git.toowon.com/jimmy/go-common/datetime"
datetime.SetDefaultTimeZone(datetime.AsiaShanghai)
now := datetime.Now()
str := datetime.FormatDateTime(now)
```
更多示例:[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)**
## 最佳实践
### 生产环境配置
```json
{
"logger": {
"async": true, // 开启异步日志
"bufferSize": 1000
},
"database": {
"maxOpenConns": 100, // 连接池配置
"maxIdleConns": 10,
"connMaxLifetime": 3600
},
"rateLimit": {
"enable": true, // 开启限流
"rate": 100,
"period": 60,
"byIP": true
}
}
```
### 使用建议
- ✅ 使用工厂黑盒模式,减少重复代码
- ✅ 生产环境开启异步日志和限流
- ✅ 配置Recovery中间件防止panic
- ✅ 明确指定CORS允许的源
- ❌ 避免在循环中创建logger
- ❌ 避免使用同步日志记录大量日志
## 故障排除
常见问题请查看 [TROUBLESHOOTING.md](./TROUBLESHOOTING.md)
## 贡献指南
欢迎贡献代码!请遵循以下步骤:
1. Fork 本仓库
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 创建 Pull Request
## 许可证
MIT License
## 联系方式
- 作者Jimmy
- 邮箱jimmy@toowon.com
- 项目地址git.toowon.com/jimmy/go-common
---
⭐ 如果这个项目对你有帮助,请给个 Star
## 设计
- **Factory**启动初始化一次getter 取模块对象
- **模块包**`object.Method()`(如 `log.Info``store.Upload`
- **http**:统一出参(`Handler`
- **tools / migration**:无状态或独立 CLI不经 Factory 透传
## 模块
| 包 | 说明 |
|----|------|
| `config` | JSON 配置 |
| `factory` | 入口与 lazy getter |
| `http` | 请求解析、统一响应 |
| `middleware` | CORS、日志、Recovery、限流、语种、时区 |
| `logger` | 异步日志 |
| `storage` | Local / OSS / MinIO |
| `email` / `sms` | SMTP、阿里云短信 |
| `excel` | 导出 |
| `i18n` | 多语言消息 |
| `migration` | SQL 迁移 |
| `tools` | 时间、加密、金额等 |
| `mcp` | MCP Client多 server、`ListTools`/`CallTool` |
| `captcha` | 图形验证码、登录防暴力(可选,`enabled` 开关) |
## 文档
| 文档 | 说明 |
|------|------|
| [INTEGRATION.md](./INTEGRATION.md) | 对接手册 |
| [VERSION.md](./VERSION.md) | 版本与发布 |
| [templates/](./templates/) | 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
# 使用发布脚本(会自动验证版本格式、检查未提交更改等)
./scripts/release.sh v1.0.0 "Release version 1.0.0"
# 推荐:脚本校验并打标签
./scripts/release.sh v1.2.0 "Release version 1.2.0"
# 或手动
git tag -a v1.2.0 -m "Release version 1.2.0"
git push origin v1.2.0
```
### 方式2手动创建标签
发布前:代码已提交;同步更新本文档与 `README.md` 中的版本号。
## 消费方引用
```bash
# 1. 确保所有更改已提交
git add .
git commit -m "Prepare for release v1.0.0"
# 2. 创建版本标签
git tag -a v1.0.0 -m "Release version 1.0.0"
# 3. 推送标签到远程仓库
git push origin v1.0.0
# 或者一次性推送所有标签
git push origin --tags
```
### 验证标签
```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
go get git.toowon.com/jimmy/go-common@v1.3.0 # 生产固定版本
go get git.toowon.com/jimmy/go-common@latest # 开发
```
## 当前版本
当前版本:**v1.0.0**
**v1.4.0**
## 版本历史
- **v1.0.0** (当前版本)
- 初始版本
- 包含所有基础工具类migration、datetime、http、middleware、config、storage、email、sms、factory、logger
- **v1.4.0** — 新增 `captcha` 模块:图形验证码(`Generate`/`Verify`、Redis 存储、场景配置)与登录防暴力(失败计数、触发验证码、临时锁定);`config.CaptchaConfig``factory.Captcha()`/`MustCaptcha()`/`WithCaptcha`/`ModuleCaptcha`**不进默认 middleware**,第三方按需 `enabled` 开关
- **v1.3.0** — 新增 `mcp` 模块MCPModel Context ProtocolClient`mcp.Manager`:多 server、命名空间前缀、白名单、`ListTools`/`CallTool``config.MCPConfig``factory.MCP()`/`MustMCP()`/`WithMCP`;不含路由/抽参/编排(业务侧自行实现)
- **v1.2.0** — DX`MustInit` / `Warmup` / `MustXxx` / `Close` / `NewHandler`Option 注入Excel 每次新建;`templates/server``ErrorData`;文档精简
- **v1.1.0** — 同上 DX 能力首次合入(见上)
- **v1.0.0** — 初始Factory lazy getter、http.Handler 统一出参、logger / email / sms / storage / middleware / migration / tools / i18n / excel

307
captcha/captcha.go Normal file
View File

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

174
captcha/captcha_test.go Normal file
View File

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

17
captcha/errors.go Normal file
View File

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

42
captcha/redis_store.go Normal file
View File

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

View File

@@ -14,10 +14,35 @@ type Config struct {
Redis *RedisConfig `json:"redis"`
CORS *CORSConfig `json:"cors"`
MinIO *MinIOConfig `json:"minio"`
Local *LocalStorageConfig `json:"localStorage"`
Email *EmailConfig `json:"email"`
SMS *SMSConfig `json:"sms"`
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 数据库配置
@@ -192,6 +217,23 @@ type EmailConfig struct {
// 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 短信配置(阿里云短信)
@@ -216,11 +258,28 @@ type SMSConfig struct {
// 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 日志配置
type LoggerConfig struct {
// Level 日志级别: debug, info, warn, error
// Level 日志级别: debug, info, error
Level string `json:"level"`
// Output 输出方式: stdout, stderr, file, both
@@ -235,16 +294,63 @@ type LoggerConfig struct {
// DisableTimestamp 禁用时间戳
DisableTimestamp bool `json:"disableTimestamp"`
// Async 是否使用异步模式(默认false即同步模式
// 异步模式日志写入通过channel异步处理不阻塞调用方
// 同步模式:日志直接写入,会阻塞调用方直到写入完成
Async bool `json:"async"`
// 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 是否启用限流
@@ -263,6 +369,50 @@ type RateLimitConfig struct {
ByUserID bool `json:"byUserID"`
}
// MCPConfig MCPModel Context Protocol工具子系统配置。
// 本库只做 MCP **Client**:连接外部 MCP server容器/服务),发现并调用工具;
// 不包含任何路由/抽参/编排逻辑(那属于业务侧的 Prompt/Agent 编排,由消费方自行实现)。
type MCPConfig struct {
// Enabled 总开关false 或 Servers 为空时 mcp.Manager.Enabled() 返回 false
Enabled bool `json:"enabled"`
// CallTimeoutSeconds 单次连接/列工具/调用工具的超时(秒),默认 60
CallTimeoutSeconds int `json:"callTimeoutSeconds"`
// Servers MCP 服务列表;一条 = 一个独立 MCP server自带多个工具
Servers []MCPServerConfig `json:"servers"`
}
// MCPServerConfig 单个 MCP server容器/服务)的连接配置。
type MCPServerConfig struct {
// Name 逻辑名(用于日志与路由),必填
Name string `json:"name"`
// Transport 传输类型streamable-http默认| sse
Transport string `json:"transport"`
// URL server 的 MCP 端点地址
URL string `json:"url"`
// Enabled 是否启用该 servernil 默认 true
Enabled *bool `json:"enabled"`
// AuthToken 可选Bearer/Basic 凭证;仅 token 时自动补 "Bearer "
AuthToken string `json:"authToken"`
// ToolNamePrefix 工具命名空间前缀,防多 server 工具重名;留空则用 Name
// 工具全名 = "{ToolNamePrefix}__{原始工具名}"
ToolNamePrefix string `json:"toolNamePrefix"`
// AllowedTools 工具白名单(原始工具名,不含前缀);留空表示开放该 server 全部工具
AllowedTools []string `json:"allowedTools"`
}
// IsEnabled 该 server 是否启用nil 默认 true
func (s *MCPServerConfig) IsEnabled() bool {
return s == nil || s.Enabled == nil || *s.Enabled
}
// LoadFromFile 从文件加载配置
// filePath: 配置文件路径(支持绝对路径和相对路径)
func LoadFromFile(filePath string) (*Config, error) {
@@ -368,13 +518,19 @@ func (c *Config) setDefaults() {
// 邮件默认值
if c.Email != nil {
if c.Email.Port == 0 {
c.Email.Port = 587 // 默认使用587端口TLS
c.Email.Port = 587
}
if c.Email.From == "" {
c.Email.From = c.Email.Username
}
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
}
}
@@ -384,7 +540,13 @@ func (c *Config) setDefaults() {
c.SMS.Region = "cn-hangzhou"
}
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
}
}
@@ -396,6 +558,14 @@ func (c *Config) setDefaults() {
if c.Logger.Output == "" {
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"
}
// 限流默认值
@@ -410,6 +580,41 @@ func (c *Config) setDefaults() {
c.RateLimit.ByIP = true // 默认按IP限流
}
}
// MCP 默认值
if c.MCP != nil {
if c.MCP.CallTimeoutSeconds <= 0 {
c.MCP.CallTimeoutSeconds = 60
}
}
// Captcha 默认值
if c.Captcha != nil {
if c.Captcha.Length == 0 {
c.Captcha.Length = 4
}
if c.Captcha.Width == 0 {
c.Captcha.Width = 120
}
if c.Captcha.Height == 0 {
c.Captcha.Height = 40
}
if c.Captcha.TTLSec == 0 {
c.Captcha.TTLSec = 300
}
if c.Captcha.Login == nil {
c.Captcha.Login = &LoginCaptchaConfig{}
}
if c.Captcha.Login.ShowAfterFailures == 0 {
c.Captcha.Login.ShowAfterFailures = 3
}
if c.Captcha.Login.LockAfterFailures == 0 {
c.Captcha.Login.LockAfterFailures = 10
}
if c.Captcha.Login.LockDurationMin == 0 {
c.Captcha.Login.LockDurationMin = 30
}
}
}
// GetDatabase 获取数据库配置
@@ -439,6 +644,11 @@ func (c *Config) GetMinIO() *MinIOConfig {
return c.MinIO
}
// GetLocalStorage 获取本地存储配置
func (c *Config) GetLocalStorage() *LocalStorageConfig {
return c.Local
}
// GetEmail 获取邮件配置
func (c *Config) GetEmail() *EmailConfig {
return c.Email
@@ -454,6 +664,21 @@ func (c *Config) GetLogger() *LoggerConfig {
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 获取数据库连接字符串
func (c *Config) GetDatabaseDSN() (string, error) {
if c.Database == nil {
@@ -528,3 +753,8 @@ func (c *Config) GetRedisAddr() string {
// 构建地址
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",
"domain": "http://localhost:9000"
},
"localStorage": {
"baseDir": "./uploads",
"publicURL": "http://localhost:8080/file?key={objectKey}"
},
"email": {
"host": "smtp.example.com",
"port": 587,
@@ -85,6 +89,47 @@
"period": 60,
"byIP": true,
"byUserID": false
},
"captcha": {
"enabled": false,
"length": 4,
"width": 120,
"height": 40,
"ttlSec": 300,
"login": {
"showAfterFailures": 3,
"lockAfterFailures": 10,
"lockDurationMin": 30
},
"scenes": {
"register": { "mode": "always" },
"change_phone": { "mode": "always" },
"send_sms": { "mode": "always" }
}
},
"mcp": {
"enabled": true,
"callTimeoutSeconds": 60,
"servers": [
{
"name": "fetch-web",
"transport": "streamable-http",
"url": "http://mcp-gateway:1111/mcp/fetch-web",
"enabled": true,
"authToken": "dev-local-token",
"toolNamePrefix": "fetch-web",
"allowedTools": ["fetch_web"]
},
{
"name": "word",
"transport": "sse",
"url": "http://mcp-gateway:1111/mcp/word",
"enabled": false,
"authToken": "dev-local-token",
"toolNamePrefix": "word",
"allowedTools": ["create_document", "add_heading", "add_paragraph"]
}
]
}
}

View File

@@ -1,130 +0,0 @@
# GoCommon 工具类库文档
## 目录
- [数据库迁移工具](./migration.md) - 数据库版本管理和迁移
- [完整使用指南](../MIGRATION.md) ⭐ - 独立工具零耦合Docker友好
- [日期转换工具](./datetime.md) - 日期时间处理和时区转换
- [HTTP Restful工具](./http.md) - HTTP请求响应处理和分页
- [中间件工具](./middleware.md) - 生产级HTTP中间件CORS、时区、日志、Recovery、限流
- [配置工具](./config.md) - 外部配置文件加载和管理
- [存储工具](./storage.md) - 文件上传和查看OSS、MinIO
- [邮件工具](./email.md) - SMTP邮件发送
- [短信工具](./sms.md) - 阿里云短信发送
- [工厂工具](./factory.md) - 从配置直接创建已初始化客户端对象
- [日志工具](./logger.md) - 统一的日志记录功能
## 快速开始
### 安装
```bash
go get git.toowon.com/jimmy/go-common
```
### 使用示例
#### 数据库迁移(独立工具,零耦合)
```bash
# 1. 复制模板templates/migrate/main.go -> cmd/migrate/main.go
# 2. 创建迁移文件migrations/20240101000001_create_users.sql
# 3. 开发环境
go run cmd/migrate/main.go up
# 4. 生产环境(编译后使用,推荐)
go build -o bin/migrate cmd/migrate/main.go
./bin/migrate up # 使用默认配置
./bin/migrate up -config /path/to/config.json # 指定配置
./bin/migrate status # 查看状态
# 5. Docker挂载配置修改无需重启
# docker-compose.yml:
# volumes:
# - ./config.json:/app/config.json:ro
# command: sh -c "./migrate up && ./server"
```
**迁移文件示例**`migrations/20240101000001_create_users.sql`
```sql
CREATE TABLE users (id BIGINT PRIMARY KEY AUTO_INCREMENT, ...);
```
**优势**独立工具零耦合支持命令行参数Docker友好
详细说明:[MIGRATION.md](../MIGRATION.md)
#### 日期转换
```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 (
"time"
"git.toowon.com/jimmy/go-common/middleware"
commonhttp "git.toowon.com/jimmy/go-common/http"
)
// 完整的中间件链
chain := middleware.NewChain(
middleware.Recovery(nil), // Panic恢复
middleware.Logging(nil), // 请求日志
middleware.RateLimitByIP(100, time.Minute), // 限流
middleware.CORS(nil), // CORS
middleware.Timezone, // 时区
)
handler := chain.ThenFunc(func(w http.ResponseWriter, r *http.Request) {
h := commonhttp.NewHandler(w, r)
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,540 +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配置返回config.CORSConfig类型
configCORS := config.GetCORS()
// 转换为middleware.CORSConfig并使用CORS中间件
import "git.toowon.com/jimmy/go-common/middleware"
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(
middleware.CORS(middlewareCORS),
)
```
### 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配置
configCORS := cfg.GetCORS()
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(
middleware.CORS(middlewareCORS),
)
// 使用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,506 +0,0 @@
# 工厂工具文档
## 概述
工厂工具提供了从配置直接创建已初始化客户端对象的功能,并提供了黑盒模式的便捷方法,让调用方无需关心底层实现细节,大大降低业务复杂度。
## 功能特性
- **黑盒模式**:提供直接调用的方法,无需获取客户端对象
- **延迟初始化**:所有客户端在首次使用时才创建
- **自动选择**存储类型OSS/MinIO根据配置自动选择
- **统一接口**:所有操作通过工厂方法调用
- **向后兼容**:保留 `GetXXX()` 方法,需要时可获取对象
## 方法分类总览
### 🌟 推荐使用:黑盒方法(一行代码搞定)
外部项目直接调用,无需获取内部对象:
| 功能 | 方法 | 示例 |
|------|------|------|
| **中间件** | `GetMiddlewareChain()` | `chain := fac.GetMiddlewareChain()` |
| **日志** | `LogInfo()`, `LogError()` 等 | `fac.LogInfo("用户登录")` |
| **Redis** | `RedisSet()`, `RedisGet()` 等 | `fac.RedisSet(ctx, "key", "val", time.Hour)` |
| **邮件** | `SendEmail()` | `fac.SendEmail(to, subject, body)` |
| **短信** | `SendSMS()` | `fac.SendSMS(phones, params)` |
| **存储** | `UploadFile()`, `GetFileURL()` | `fac.UploadFile(ctx, key, file)` |
### 🔧 高级功能Get方法仅在必要时使用
返回客户端对象,用于复杂操作:
| 方法 | 返回类型 | 使用场景 |
|------|----------|----------|
| `GetDatabase()` | `*gorm.DB` | 数据库复杂查询、事务、关联查询等 |
| `GetRedisClient()` | `*redis.Client` | Hash、List、Set、ZSet、Pub/Sub等高级操作 |
| `GetLogger()` | `*logger.Logger` | Close()、设置全局logger等 |
## 使用方法
### 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,341 +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")
```
### 5. 异步/同步模式
#### 同步模式(默认)
```go
// 配置中不设置async或设置为false使用同步模式
// 同步模式:日志直接写入,会阻塞调用方直到写入完成
logger.Info("This is a synchronous log")
```
#### 异步模式
```go
// 配置中设置async为true使用异步模式
// 异步模式日志写入通过channel异步处理不阻塞调用方
// 配置文件示例:
// {
// "logger": {
// "async": true,
// "bufferSize": 1000
// }
// }
// 使用异步模式时程序退出前需要调用Close()确保所有日志写入完成
defer logger.Close()
logger.Info("This is an asynchronous log")
```
**注意:**
- `Fatal``Panic` 方法始终使用同步模式,确保日志写入后再退出/panic
- 异步模式下,程序退出前应调用 `Close()` 方法,确保所有日志写入完成
- 如果channel已满会自动降级为同步写入避免丢失日志
## 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{})
记录错误日志(带字段)。
### (l *Logger) Close() error
优雅关闭logger仅异步模式需要
**说明:**
- 等待所有日志写入完成后再返回
- 同步模式下调用此方法会立即返回,无需等待
- 程序退出前应调用此方法,确保所有日志写入完成
## 配置说明
日志配置通过 `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 |
| Async | bool | 是否使用异步模式 | false同步 |
| BufferSize | int | 异步模式下的缓冲区大小 | 1000 |
## 配置示例
### 输出到标准输出
```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
}
}
```
### 异步模式配置
```json
{
"logger": {
"level": "info",
"output": "file",
"filePath": "./logs/app.log",
"prefix": "app",
"async": true,
"bufferSize": 1000
}
}
```
**说明:**
- `async`: 设置为 `true` 启用异步模式,`false` 或不设置则使用同步模式(默认)
- `bufferSize`: 异步模式下的channel缓冲区大小默认1000。当缓冲区满时新的日志会阻塞直到有空间或降级为同步写入
## 日志级别说明
- **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包性能较好
- 文件输出使用追加模式,不会覆盖已有日志
- 异步模式适合高并发场景,减少日志写入对业务代码的阻塞
- 同步模式适合需要确保日志立即写入的场景(如调试)
5. **异步模式注意事项**
- 异步模式下,程序退出前必须调用 `Close()` 方法,确保所有日志写入完成
- 如果channel缓冲区已满会自动降级为同步写入避免丢失日志
- `Fatal``Panic` 方法始终使用同步模式,确保日志写入后再退出/panic
## 完整示例
```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)
// 如果使用异步模式程序退出前需要关闭logger
// defer logger.Close()
}
```
## 示例
完整示例请参考 `examples/logger_example.go`

File diff suppressed because it is too large Load Diff

View File

@@ -1,442 +0,0 @@
# 数据库迁移工具文档
## 概述
数据库迁移工具提供了数据库版本管理和迁移功能支持MySQL、PostgreSQL、SQLite等数据库。使用GORM作为数据库操作库可以方便地进行数据库结构的版本控制。
## 功能特性
- 支持迁移版本管理
- 支持迁移和回滚操作
- 支持从文件系统加载迁移文件
- 支持迁移状态查询
- 自动创建迁移记录表
- 事务支持,确保迁移的原子性
## 🚀 最简单的使用方式(黑盒模式,推荐)
这是最简单的迁移方式,内部自动处理配置加载、数据库连接、迁移执行等所有细节。
### 方式一:使用独立迁移工具(推荐)
1. **复制迁移工具模板到你的项目**
```bash
mkdir -p cmd/migrate
cp /path/to/go-common/templates/migrate/main.go cmd/migrate/
```
2. **创建迁移文件**
```bash
mkdir -p migrations
# 或使用其他目录,如 scripts/sql
```
创建 `migrations/01_init_schema.sql`
```sql
CREATE TABLE users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
3. **编译和使用**
```bash
# 编译
go build -o bin/migrate cmd/migrate/main.go
# 使用(最简单,使用默认配置和默认迁移目录)
./bin/migrate up
# 指定配置文件
./bin/migrate up -config config.json
# 指定配置和迁移目录
./bin/migrate up -config config.json -dir scripts/sql
# 查看状态
./bin/migrate status
# 回滚
./bin/migrate down
```
**特点**
- ✅ 零配置:使用默认值即可运行
- ✅ 自动查找配置:支持环境变量、默认路径
- ✅ 自动处理:配置加载、数据库连接、迁移执行全自动
### 方式二:在代码中直接调用(简单场景)
```go
import "git.toowon.com/jimmy/go-common/migration"
// 最简单:使用默认配置和默认迁移目录
err := migration.RunMigrationsFromConfigWithCommand("", "", "up")
// 指定配置文件,使用默认迁移目录
err := migration.RunMigrationsFromConfigWithCommand("config.json", "", "up")
// 指定配置和迁移目录
err := migration.RunMigrationsFromConfigWithCommand("config.json", "scripts/sql", "up")
// 查看状态
err := migration.RunMigrationsFromConfigWithCommand("config.json", "migrations", "status")
```
**参数说明**
- `configFile`: 配置文件路径空字符串时自动查找config.json, ../config.json或使用环境变量 DATABASE_URL
- `migrationsDir`: 迁移文件目录,空字符串时使用默认值 "migrations"
- `command`: 命令,支持 "up", "down", "status"
### 方式三使用Factory如果项目已使用Factory
```go
import "git.toowon.com/jimmy/go-common/factory"
fac, _ := factory.NewFactoryFromFile("config.json")
// 使用默认目录 "migrations"
err := fac.RunMigrations()
// 或指定目录
err := fac.RunMigrations("scripts/sql")
```
---
## 详细使用方法(高级功能)
### 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
// 支持的文件命名格式:
// 1. 数字前缀: 01_init_schema.sql
// 2. 时间戳: 20240101000001_create_users.sql
// 3. 带.up后缀: 20240101000001_create_users.up.sql
// 对应的回滚文件: 20240101000001_create_users.down.sql
migrations, err := migration.LoadMigrationsFromFiles("./migrations", "*.sql")
if err != nil {
log.Fatal(err)
}
migrator.AddMigrations(migrations...)
```
**新特性:**
- ✅ 支持数字前缀命名(如 `01_init_schema.sql`
- ✅ 自动分割多行 SQL 语句
- ✅ 自动处理注释(单行 `--` 和多行 `/* */`
- ✅ 记录执行时间(毫秒)
### 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`: 文件匹配模式,如 "*.sql" 或 "*.up.sql"
**返回:** 迁移列表和错误信息
**支持的文件命名格式:**
1. **数字前缀格式**(新支持):
- `01_init_schema.sql`
- `02_init_data.sql`
- `03_add_log_schema.sql`
2. **时间戳格式**(现有):
- `20240101000001_create_users.sql`
- `20240101000002_add_index.sql`
3. **带.up后缀格式**(现有):
- `20240101000001_create_users.up.sql` - 升级文件
- `20240101000001_create_users.down.sql` - 回滚文件(可选)
**新特性:**
- ✅ 自动识别文件命名格式(数字前缀或时间戳)
- ✅ 自动分割多行 SQL 语句(按分号分割)
- ✅ 自动处理注释(单行 `--` 和多行 `/* */`
- ✅ 自动跳过空行和空白字符
- ✅ 支持一个文件包含多个 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,515 +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
- **错误**返回标准HTTP错误状态码和错误消息文本格式
- `400 Bad Request`: 缺少必需参数
- `404 Not Found`: 文件不存在
- `405 Method Not Allowed`: 请求方法不正确
- `500 Internal Server Error`: 系统错误
**注意**`ProxyHandler` 返回的是文件内容二进制而不是JSON响应。错误时使用标准HTTP状态码保持与文件响应的一致性。
### 辅助函数
#### 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)
// 创建中间件链
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(
middleware.CORS(corsConfig),
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 (
"bytes"
"context"
"crypto/tls"
"fmt"
"net"
"net/smtp"
"sync"
"sync/atomic"
"time"
"git.toowon.com/jimmy/go-common/config"
"git.toowon.com/jimmy/go-common/logger"
)
// Email 邮件发送器
type Email struct {
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 创建邮件发送器
func NewEmail(cfg *config.EmailConfig) (*Email, error) {
if cfg == nil {
func NewEmail(cfg *config.Config) *Email {
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")
}
if cfg.Host == "" {
if e.config.Host == "" {
return nil, fmt.Errorf("email host is required")
}
if cfg.Username == "" {
if e.config.Username == "" {
return nil, fmt.Errorf("email username is required")
}
if cfg.Password == "" {
if e.config.Password == "" {
return nil, fmt.Errorf("email password is required")
}
// 设置默认值
if cfg.Port == 0 {
cfg.Port = 587
if e.config.Port == 0 {
e.config.Port = 587
}
if cfg.From == "" {
cfg.From = cfg.Username
if e.config.From == "" {
e.config.From = e.config.Username
}
if cfg.Timeout == 0 {
cfg.Timeout = 30
if e.config.Timeout == 0 {
e.config.Timeout = 5
}
return &Email{
config: cfg,
}, nil
return e.config, nil
}
// Message 邮件消息
type Message struct {
// To 收件人列表
To []string
// Cc 抄送列表(可选)
Cc []string
// Bcc 密送列表(可选)
Bcc []string
// Subject 主题
Subject string
// Body 正文(纯文本)
Body string
// HTMLBody HTML正文可选如果设置了会优先使用
HTMLBody string
// Attachments 附件列表(可选)
Attachments []Attachment
}
// Attachment 附件
type Attachment struct {
// Filename 文件名
Filename string
// Content 文件内容
Content []byte
// ContentType 文件类型application/pdf
ContentType string
// SendEmail 同步发送邮件(验证码等需等待结果的场景)
func (e *Email) SendEmail(to []string, subject, body string, htmlBody ...string) error {
cfg, err := e.getEmailConfig()
if err != nil {
return err
}
msg := &Message{To: to, Subject: subject, Body: body}
if len(htmlBody) > 0 && htmlBody[0] != "" {
msg.HTMLBody = htmlBody[0]
}
return e.send(msg, cfg)
}
// SendRaw 发送原始邮件内容
// recipients: 收件人列表To、Cc、Bcc的合并列表
// body: 完整的邮件内容MIME格式由外部构建
func (e *Email) SendRaw(recipients []string, body []byte) error {
if len(recipients) == 0 {
// SendEmailAsync 异步发送邮件HTTP 通知类场景)
func (e *Email) SendEmailAsync(ctx context.Context, to []string, subject, body string, htmlBody ...string) {
task := emailTask{
to: append([]string(nil), to...),
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")
}
if len(body) == 0 {
return fmt.Errorf("email body is required")
if msg.Subject == "" {
return fmt.Errorf("subject is required")
}
if msg.Body == "" && msg.HTMLBody == "" {
return fmt.Errorf("body or HTMLBody is required")
}
// 连接SMTP服务器
addr := fmt.Sprintf("%s:%d", e.config.Host, e.config.Port)
auth := smtp.PlainAuth("", e.config.Username, e.config.Password, e.config.Host)
emailBody, err := e.buildEmailBody(msg, cfg)
if err != nil {
return fmt.Errorf("failed to build email body: %w", err)
}
// 创建连接
conn, err := net.DialTimeout("tcp", addr, time.Duration(e.config.Timeout)*time.Second)
recipients := append(msg.To, msg.Cc...)
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 {
return fmt.Errorf("failed to connect to SMTP server: %w", err)
}
defer conn.Close()
// 创建SMTP客户端
client, err := smtp.NewClient(conn, e.config.Host)
client, err := smtp.NewClient(conn, cfg.Host)
if err != nil {
return fmt.Errorf("failed to create SMTP client: %w", err)
}
defer client.Close()
// TLS/SSL处理
if e.config.UseSSL {
// 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 cfg.UseSSL || cfg.UseTLS {
tlsConfig := &tls.Config{ServerName: cfg.Host}
if err := client.StartTLS(tlsConfig); err != nil {
return fmt.Errorf("failed to start TLS: %w", err)
}
}
// 认证
if err := client.Auth(auth); err != nil {
return fmt.Errorf("failed to authenticate: %w", err)
}
// 设置发件人
if err := client.Mail(e.config.From); err != nil {
if err := client.Mail(cfg.From); err != nil {
return fmt.Errorf("failed to set sender: %w", err)
}
// 设置收件人
for _, to := range recipients {
if err := client.Rcpt(to); err != nil {
return fmt.Errorf("failed to set recipient %s: %w", to, err)
}
}
// 发送邮件内容
writer, err := client.Data()
if err != nil {
return fmt.Errorf("failed to get data writer: %w", err)
}
_, err = writer.Write(body)
if err != nil {
writer.Close()
if _, err = writer.Write(emailBody); err != nil {
_ = writer.Close()
return fmt.Errorf("failed to write email body: %w", err)
}
err = writer.Close()
if err != nil {
if err = writer.Close(); err != nil {
return fmt.Errorf("failed to close writer: %w", err)
}
// 退出
if err := client.Quit(); err != nil {
return fmt.Errorf("failed to quit: %w", err)
}
return nil
}
// Send 发送邮件使用Message结构内部会构建邮件内容
// 注意如果需要完全控制邮件内容请使用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) {
func (e *Email) buildEmailBody(msg *Message, cfg *config.EmailConfig) ([]byte, error) {
var buf bytes.Buffer
// 邮件头
from := e.config.From
if e.config.FromName != "" {
from = fmt.Sprintf("%s <%s>", e.config.FromName, e.config.From)
from := cfg.From
if cfg.FromName != "" {
from = fmt.Sprintf("%s <%s>", cfg.FromName, cfg.From)
}
buf.WriteString(fmt.Sprintf("From: %s\r\n", from))
// 收件人
buf.WriteString(fmt.Sprintf("To: %s\r\n", joinEmails(msg.To)))
// 抄送
if len(msg.Cc) > 0 {
buf.WriteString(fmt.Sprintf("Cc: %s\r\n", joinEmails(msg.Cc)))
}
// 主题
buf.WriteString(fmt.Sprintf("Subject: %s\r\n", msg.Subject))
// 内容类型
if msg.HTMLBody != "" {
// 多部分邮件HTML + 纯文本)
boundary := "----=_Part_" + fmt.Sprint(time.Now().UnixNano())
buf.WriteString("MIME-Version: 1.0\r\n")
buf.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary))
buf.WriteString("\r\n")
// 纯文本部分
buf.WriteString("--" + boundary + "\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(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n\r\n", boundary))
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(msg.HTMLBody + "\r\n")
buf.WriteString("--" + boundary + "--\r\n")
} else {
// 纯文本邮件
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")
buf.WriteString("Content-Type: text/plain; charset=UTF-8\r\n\r\n")
buf.WriteString(msg.Body + "\r\n")
}
return buf.Bytes(), nil
}
// joinEmails 连接邮箱地址
func joinEmails(emails []string) string {
if len(emails) == 0 {
return ""
@@ -280,27 +278,3 @@ func joinEmails(emails []string) string {
}
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
import (

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
import (
@@ -5,117 +8,35 @@ import (
"log"
"git.toowon.com/jimmy/go-common/config"
"git.toowon.com/jimmy/go-common/email"
"git.toowon.com/jimmy/go-common/factory"
)
func main() {
// 加载配置
cfg, err := config.LoadFromFile("./config/example.json")
if err != nil {
log.Fatal("Failed to load config:", err)
log.Fatal(err)
}
// 创建邮件发送器
emailConfig := cfg.GetEmail()
if emailConfig == nil {
log.Fatal("Email config is nil")
}
mailer, err := email.NewEmail(emailConfig)
app := factory.New(cfg)
mail, err := app.Email()
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 ===")
// 外部构建完整的邮件内容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(
// 同步发送(验证码等需等待结果
err = mail.SendEmail(
[]string{"recipient@example.com"},
"测试邮件",
"这是一封测试邮件使用Go标准库发送。",
"这是一封测试邮件。",
)
if err != nil {
log.Printf("Failed to send email: %v", err)
log.Printf("sync send failed: %v", err)
} else {
fmt.Println("Email sent successfully")
fmt.Println("sync email sent")
}
// 示例3发送HTML邮件
fmt.Println("\n=== Example 3: Send HTML Email ===")
htmlBody := `
<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")
// 异步发送HTTP 通知类)
mail.SendEmailAsync(nil, []string{"recipient@example.com"}, "异步通知", "后台发送,不阻塞请求")
fmt.Println("async email enqueued")
}
// 示例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,172 +0,0 @@
package main
import (
"context"
"log"
"net/http"
"time"
"git.toowon.com/jimmy/go-common/factory"
commonhttp "git.toowon.com/jimmy/go-common/http"
)
// 示例Factory黑盒模式 - 最简化的使用方式
//
// 核心理念:
//
// 外部项目只需要传递一个配置文件路径,
// 直接使用 factory 的黑盒方法,无需获取内部对象
func main() {
// ====== 第1步创建工厂只需要配置文件路径======
fac, err := factory.NewFactoryFromFile("config.json")
if err != nil {
log.Fatal(err)
}
// ====== 第2步使用黑盒方法推荐======
// 1. 获取中间件链(自动配置所有基础中间件)
chain := fac.GetMiddlewareChain()
// 2. 添加项目特定的自定义中间件
chain.Append(authMiddleware, metricsMiddleware)
// 3. 注册路由
http.Handle("/api/users", chain.ThenFunc(handleUsers))
http.Handle("/api/upload", chain.ThenFunc(handleUpload))
// 4. 启动服务
log.Println("Server started on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
// ====== API处理器 ======
// 用户列表
func handleUsers(w http.ResponseWriter, r *http.Request) {
h := commonhttp.NewHandler(w, r)
// 创建工厂(在处理器中也可以复用)
fac, _ := factory.NewFactoryFromFile("config.json")
ctx := context.Background()
// 1. 使用数据库需要获取对象因为GORM很复杂
db, _ := fac.GetDatabase()
var users []map[string]interface{}
db.Table("users").Find(&users)
// 2. 使用Redis黑盒方法推荐
cacheKey := "users:list"
cached, _ := fac.RedisGet(ctx, cacheKey)
if cached != "" {
h.Success(cached)
return
}
// 3. 记录日志(黑盒方法,推荐)
fac.LogInfof(map[string]interface{}{
"action": "list_users",
"count": len(users),
}, "查询用户列表")
// 4. 缓存结果
fac.RedisSet(ctx, cacheKey, users, 5*time.Minute)
h.Success(users)
}
// 文件上传
func handleUpload(w http.ResponseWriter, r *http.Request) {
h := commonhttp.NewHandler(w, r)
fac, _ := factory.NewFactoryFromFile("config.json")
ctx := context.Background()
// 解析上传的文件
file, header, err := r.FormFile("file")
if err != nil {
fac.LogError("文件上传失败: %v", err)
h.Error(400, "文件上传失败")
return
}
defer file.Close()
// 上传文件黑盒方法自动选择OSS或MinIO
objectKey := "uploads/" + header.Filename
url, err := fac.UploadFile(ctx, objectKey, file, header.Header.Get("Content-Type"))
if err != nil {
fac.LogError("文件上传到存储失败: %v", err)
h.Error(500, "文件上传失败")
return
}
// 记录上传日志
fac.LogInfof(map[string]interface{}{
"filename": header.Filename,
"size": header.Size,
"url": url,
}, "文件上传成功")
h.Success(map[string]interface{}{
"url": url,
})
}
// ====== 自定义中间件 ======
// 认证中间件
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fac, _ := factory.NewFactoryFromFile("config.json")
ctx := context.Background()
// 获取token
token := r.Header.Get("Authorization")
if token == "" {
h := commonhttp.NewHandler(w, r)
h.Error(401, "未授权")
return
}
// 从Redis验证token黑盒方法
userID, err := fac.RedisGet(ctx, "token:"+token)
if err != nil || userID == "" {
h := commonhttp.NewHandler(w, r)
h.Error(401, "token无效")
return
}
// 记录日志(黑盒方法)
fac.LogInfof(map[string]interface{}{
"user_id": userID,
"path": r.URL.Path,
}, "用户请求")
// 将用户ID存入context或header
r.Header.Set("X-User-ID", userID)
next.ServeHTTP(w, r)
})
}
// 指标中间件
func metricsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fac, _ := factory.NewFactoryFromFile("config.json")
ctx := context.Background()
start := time.Now()
// 继续处理请求
next.ServeHTTP(w, r)
// 记录请求耗时到Redis黑盒方法
latency := time.Since(start).Milliseconds()
key := "metrics:" + r.URL.Path
fac.RedisSet(ctx, key, latency, time.Minute)
// 记录指标日志(黑盒方法)
fac.LogDebugf(map[string]interface{}{
"path": r.URL.Path,
"latency": latency,
}, "请求指标")
})
}

View File

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

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
import (
@@ -8,57 +11,24 @@ import (
)
func main() {
// 加载配置
cfg, err := config.LoadFromFile("./config/example.json")
if err != nil {
log.Fatal("Failed to load config:", err)
log.Fatal(err)
}
// 方式1使用工厂创建日志记录器推荐方式
fac := factory.NewFactory(cfg)
logger, err := fac.GetLogger()
app := factory.New(cfg)
logInst, err := app.Logger()
if err != nil {
log.Fatal("Failed to create logger:", err)
log.Fatal(err)
}
defer logInst.Close()
// 如果使用异步模式程序退出前需要关闭logger
defer logger.Close()
// 方式2直接使用工厂的日志方法黑盒模式更简单
// fac.LogInfo("Application started")
// fac.LogError("An error occurred")
// 示例1基本日志记录
logger.Info("Application started")
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{}{
logInst.Info("Application started", nil)
logInst.Info("User logged in", map[string]any{
"user_id": 123,
"action": "login",
"ip": "192.168.1.1",
}, "User logged in successfully")
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")
// 示例4异步模式使用
// 如果配置中设置了 "async": true日志会异步写入
// 程序退出前需要调用 Close() 确保所有日志写入完成
// logger.Close()
// 注意Fatal和Panic会终止程序示例中不执行
// logger.Fatal("This would exit the program")
// logger.Panic("This would panic")
})
logInst.Error("Failed to connect to database", map[string]any{
"error": "connection timeout",
})
}

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,42 +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"
"git.toowon.com/jimmy/go-common/middleware"
)
// 示例:简单的中间件配置
// 包括Recovery、Logging、CORS、Timezone
func main() {
// 创建简单的中间件链(使用默认配置)
chain := middleware.NewChain(
middleware.Recovery(nil), // Panic恢复使用默认logger
middleware.Logging(nil), // 请求日志使用默认logger
middleware.CORS(nil), // CORS允许所有源
middleware.Timezone, // 时区处理默认AsiaShanghai
)
if err := factory.Init("config.json"); err != nil {
log.Fatal(err)
}
app := factory.Default()
chain := app.MiddlewareChain()
// 定义API处理器
http.Handle("/api/hello", chain.ThenFunc(func(w http.ResponseWriter, r *http.Request) {
h := commonhttp.NewHandler(w, r)
// 获取时区
timezone := h.GetTimezone()
// 返回响应
h.Success(map[string]interface{}{
h.Success(map[string]any{
"message": "Hello, World!",
"timezone": timezone,
"timezone": h.GetTimezone(),
})
}))
// 启动服务器
addr := ":8080"
log.Printf("Server starting on %s", addr)
log.Printf("Try: http://localhost%s/api/hello", addr)
log.Fatal(http.ListenAndServe(addr, nil))
log.Fatal(http.ListenAndServe(":8080", nil))
}

View File

@@ -1,134 +0,0 @@
# 数据库迁移示例
这个目录包含了数据库迁移的示例SQL文件。
## 文件说明
```
examples/migrations/
├── migrations/ # 迁移文件目录
│ ├── 20240101000001_create_users_table.sql # 迁移SQL
│ └── 20240101000001_create_users_table.down.sql # 回滚SQL
└── README.md # 本文件
```
## 快速开始
### 在你的项目中使用
#### 1. 创建迁移工具
在项目根目录创建 `migrate.go`
```go
package main
import (
"log"
"os"
"git.toowon.com/jimmy/go-common/migration"
)
func main() {
command := "up"
if len(os.Args) > 1 {
command = os.Args[1]
}
err := migration.RunMigrationsFromConfigWithCommand("config.json", "migrations", command)
if err != nil {
log.Fatal(err)
}
}
```
#### 2. 创建迁移文件
```bash
# 创建目录
mkdir -p migrations
# 创建迁移文件(使用时间戳作为版本号)
vim migrations/20240101000001_create_users.sql
```
#### 3. 编写SQL
```sql
-- migrations/20240101000001_create_users.sql
CREATE TABLE users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL
);
```
#### 4. 执行迁移
```bash
# 执行迁移
go run migrate.go up
# 查看状态
go run migrate.go status
# 回滚
go run migrate.go down
```
### 更简单:在应用启动时自动执行
在你的 `main.go` 中:
```go
import "git.toowon.com/jimmy/go-common/migration"
func main() {
// 一行代码,启动时自动迁移
migration.RunMigrationsFromConfig("config.json", "migrations")
// 继续启动应用
startServer()
}
```
## 配置方式
### 方式1配置文件
`config.json`:
```json
{
"database": {
"type": "mysql",
"host": "localhost",
"port": 3306,
"user": "root",
"password": "password",
"database": "mydb"
}
}
```
### 方式2环境变量Docker友好
```bash
DATABASE_URL="mysql://root:password@localhost:3306/mydb" go run migrate.go up
```
**Docker 中**
```yaml
# docker-compose.yml
services:
app:
environment:
DATABASE_URL: mysql://root:password@db:3306/mydb
command: sh -c "go run migrate.go up && ./app"
```
**注意**Docker 中使用服务名(`db`),不是 `localhost`
## 更多信息
- [数据库迁移完整指南](../../MIGRATION.md) ⭐
- [详细功能文档](../../docs/migration.md)

View File

@@ -1,3 +1,6 @@
//go:build example
// +build example
package main
import (
@@ -5,98 +8,30 @@ import (
"log"
"git.toowon.com/jimmy/go-common/config"
"git.toowon.com/jimmy/go-common/sms"
"git.toowon.com/jimmy/go-common/factory"
)
func main() {
// 加载配置
cfg, err := config.LoadFromFile("./config/example.json")
if err != nil {
log.Fatal("Failed to load config:", err)
log.Fatal(err)
}
// 创建短信发送器
smsConfig := cfg.GetSMS()
if smsConfig == nil {
log.Fatal("SMS config is nil")
}
smsClient, err := sms.NewSMS(smsConfig)
app := factory.New(cfg)
smsClient, err := app.SMS()
if err != nil {
log.Fatal("Failed to create SMS client:", err)
log.Fatal(err)
}
defer smsClient.Close()
// 示例1发送原始请求推荐最灵活
fmt.Println("=== Example 1: Send Raw SMS Request ===")
// 外部构建完整的请求参数
params := map[string]string{
"PhoneNumbers": "13800138000",
"SignName": smsConfig.SignName,
"TemplateCode": smsConfig.TemplateCode,
"TemplateParam": `{"code":"123456","expire":"5"}`,
}
resp, err := smsClient.SendRaw(params)
params := map[string]string{"code": "123456"}
resp, err := smsClient.SendSMS([]string{"13800138000"}, params)
if err != nil {
log.Printf("Failed to send raw SMS: %v", err)
log.Printf("sync send failed: %v", err)
} else {
fmt.Printf("Raw SMS sent successfully, RequestID: %s\n", resp.RequestID)
fmt.Printf("sync sms sent, RequestID: %s\n", resp.RequestID)
}
// 示例2发送简单短信使用配置中的模板代码
fmt.Println("\n=== Example 2: Send Simple SMS ===")
templateParam := map[string]string{
"code": "123456",
"expire": "5",
smsClient.SendSMSAsync(nil, []string{"13800138000"}, params)
fmt.Println("async sms enqueued")
}
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
import (
@@ -16,24 +19,20 @@ func main() {
log.Fatal("Failed to load config:", err)
}
// 创建存储实例使用OSS
// 注意需要先实现OSS SDK集成
ossStorage, err := storage.NewStorage(storage.StorageTypeOSS, cfg)
// 优先演示本地存储(可直接运行
localStorage, err := storage.NewStorage(storage.StorageTypeLocal, cfg)
if err != nil {
log.Printf("Failed to create OSS storage: %v", err)
log.Println("Note: OSS SDK integration is required")
// 继续演示其他功能
} else {
// 创建上传处理器
log.Fatal("Failed to create Local storage:", err)
}
uploadHandler := storage.NewUploadHandler(storage.UploadHandlerConfig{
Storage: ossStorage,
Storage: localStorage,
MaxFileSize: 10 * 1024 * 1024, // 10MB
AllowedExts: []string{".jpg", ".jpeg", ".png", ".gif", ".pdf"},
ObjectPrefix: "uploads/",
})
// 创建代理查看处理器
proxyHandler := storage.NewProxyHandler(ossStorage)
proxyHandler := storage.NewProxyHandler(localStorage)
// 创建中间件链
var corsConfig *middleware.CORSConfig
@@ -58,10 +57,13 @@ func main() {
mux.Handle("/upload", chain.Then(uploadHandler))
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("View: GET /file?key=images/test.jpg")
log.Fatal(http.ListenAndServe(":8080", mux))
log.Println("View: GET /file?key=uploads/xxx.jpg")
// 提示OSS 需要你自行集成对应 SDK当前 go-common 中仅提供接口框架)
if _, err := storage.NewStorage(storage.StorageTypeOSS, cfg); err != nil {
log.Printf("OSS storage not ready: %v", err)
}
// 演示MinIO存储
@@ -79,5 +81,6 @@ func main() {
objectKey2 := storage.GenerateObjectKeyWithDate("images", "test.jpg")
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")
}
}

File diff suppressed because it is too large Load Diff

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

View File

@@ -1,279 +1,100 @@
package http
import (
"context"
"encoding/json"
"io"
"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 {
w http.ResponseWriter
r *http.Request
i18n *i18n.I18n
pagination *PaginationRequest
}
// NewHandler 创建Handler实例
func NewHandler(w http.ResponseWriter, r *http.Request) *Handler {
return &Handler{
w: w,
r: r,
// HandlerOption Handler 配置项
type HandlerOption func(*Handler)
// WithI18n 注入 i18n
func WithI18n(i *i18n.I18n) HandlerOption {
return func(h *Handler) {
h.i18n = i
}
}
// ResponseWriter 获取原始的ResponseWriter需要时使用
func (h *Handler) ResponseWriter() http.ResponseWriter {
return h.w
// NewHandler 创建 HTTP 出参处理器
func NewHandler(w http.ResponseWriter, r *http.Request, opts ...HandlerOption) *Handler {
h := &Handler{w: w, r: r}
for _, opt := range opts {
opt(h)
}
return h
}
// Request 获取原始的Request需要时使用
func (h *Handler) Request() *http.Request {
return h.r
// ParseJSON 解析 JSON 请求体
func (h *Handler) ParseJSON(v interface{}) error {
return ParseJSON(h.r, v)
}
// Context 获取请求的Context
func (h *Handler) Context() context.Context {
return h.r.Context()
// Pagination 解析并缓存分页参数
func (h *Handler) Pagination() *PaginationRequest {
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 成功响应
// data: 响应数据可以为nil
func (h *Handler) Success(data interface{}) {
writeJSON(h.w, http.StatusOK, 0, "success", data)
message := "success"
code := 0
if h.i18n != nil {
info := h.i18n.GetMessageInfo(h.GetLanguage(), "common.success")
if info.Message != "common.success" {
message = info.Message
code = info.Code
}
// SuccessWithMessage 带消息的成功响应
func (h *Handler) SuccessWithMessage(message string, data interface{}) {
writeJSON(h.w, http.StatusOK, 0, message, data)
}
// Error 错误响应
// code: 业务错误码非0表示业务错误
// 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)
writeResponse(h.w, code, message, data)
}
// SuccessPage 分页成功响应
// list: 数据列表
// total: 总记录数
// 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]
}
func (h *Handler) SuccessPage(list interface{}, total int64) {
p := h.Pagination()
pageData := &PageData{
List: list,
Total: total,
Page: page,
PageSize: pageSize,
Page: p.GetPage(),
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{}) {
h.ErrorData(messageCode, nil, args...)
}
// ========== 请求解析方法(黑盒模式) ==========
// ParseJSON 解析JSON请求体
// 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 获取查询参数
// key: 参数名
// defaultValue: 默认值
func (h *Handler) GetQuery(key, defaultValue string) string {
value := h.r.URL.Query().Get(key)
if value == "" {
return defaultValue
}
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)
// ErrorData 失败响应并附带 data
func (h *Handler) ErrorData(messageCode string, data interface{}, args ...interface{}) {
code := 0
message := messageCode
if h.i18n != nil {
info := h.i18n.GetMessageInfo(h.GetLanguage(), messageCode, args...)
code = info.Code
message = info.Message
}
writeResponse(h.w, code, message, data)
}

View File

@@ -1,46 +1,20 @@
package http
import (
"encoding/json"
"io"
"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 分页请求结构
// 支持从JSON和form中解析分页参数
type PaginationRequest struct {
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
@@ -51,13 +25,9 @@ func (p *PaginationRequest) GetPage() int {
return p.Page
}
// GetSize 获取每页数量如果未设置则返回默认值20最大限制100
// 优先使用 PageSize 字段,如果未设置则使用 Size 字段(兼容旧版本)
func (p *PaginationRequest) GetSize() int {
// GetPageSize 获取每页数量如果未设置则返回默认值20最大限制100
func (p *PaginationRequest) GetPageSize() int {
size := p.PageSize
if size <= 0 {
size = p.Size // 兼容旧版本的 Size 字段
}
if size <= 0 {
return 20 // 默认20条
}
@@ -69,22 +39,20 @@ func (p *PaginationRequest) GetSize() int {
// GetOffset 计算数据库查询的偏移量
func (p *PaginationRequest) GetOffset() int {
return (p.GetPage() - 1) * p.GetSize()
return (p.GetPage() - 1) * p.GetPageSize()
}
// getPaginationFromQuery 从查询参数获取分页参数(内部辅助方法)
func getPaginationFromQuery(r *http.Request) (page, size, pageSize int) {
page = getQueryInt(r, "page", 0)
size = getQueryInt(r, "size", 0)
pageSize = getQueryInt(r, "page_size", 0)
func getPaginationFromQuery(r *http.Request) (page, pageSize int) {
page = tools.ConvertInt(r.URL.Query().Get("page"), 0)
pageSize = tools.ConvertInt(r.URL.Query().Get("page_size"), 0)
return
}
// getPaginationFromForm 从form表单获取分页参数内部辅助方法
func getPaginationFromForm(r *http.Request) (page, size, pageSize int) {
page = getFormInt(r, "page", 0)
size = getFormInt(r, "size", 0)
pageSize = getFormInt(r, "page_size", 0)
func getPaginationFromForm(r *http.Request) (page, pageSize int) {
page = tools.ConvertInt(r.FormValue("page"), 0)
pageSize = tools.ConvertInt(r.FormValue("page_size"), 0)
return
}
@@ -96,17 +64,14 @@ func ParsePaginationRequest(r *http.Request) *PaginationRequest {
req := &PaginationRequest{}
// 1. 从查询参数解析(优先级最高)
req.Page, req.Size, req.PageSize = getPaginationFromQuery(r)
req.Page, req.PageSize = getPaginationFromQuery(r)
// 2. 如果查询参数中没有尝试从form表单解析
if req.Page == 0 || (req.Size == 0 && req.PageSize == 0) {
page, size, pageSize := getPaginationFromForm(r)
if req.Page == 0 || req.PageSize == 0 {
page, pageSize := getPaginationFromForm(r)
if req.Page == 0 && page != 0 {
req.Page = page
}
if req.Size == 0 && size != 0 {
req.Size = size
}
if req.PageSize == 0 && pageSize != 0 {
req.PageSize = pageSize
}
@@ -114,3 +79,30 @@ func ParsePaginationRequest(r *http.Request) *PaginationRequest {
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 标准响应结构
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"`
Message string `json:"message"`
Timestamp int64 `json:"timestamp"`
Data *PageData `json:"data"`
Data interface{} `json:"data"`
}
// PageData 分页数据
type PageData struct {
List interface{} `json:"list"` // 数据列表
Total int64 `json:"total"` // 总记录数
Page int `json:"page"` // 当前页码
PageSize int `json:"pageSize"` // 每页大小
List interface{} `json:"list"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
// writeJSON 写入JSON响应内部方法
// httpCode: HTTP状态码200表示正常500表示系统错误等
// code: 业务状态码0表示成功非0表示业务错误
// message: 响应消息
// data: 响应数据
func writeJSON(w http.ResponseWriter, httpCode, code int, message string, data interface{}) {
// writeResponse 统一 JSON 出参HTTP 恒 200
func writeResponse(w http.ResponseWriter, code int, message string, data interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(httpCode)
w.WriteHeader(http.StatusOK)
response := Response{
Code: code,
@@ -46,5 +34,5 @@ func writeJSON(w http.ResponseWriter, httpCode, code int, message string, data i
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,72 +1,100 @@
package logger
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"os"
"path/filepath"
"sync"
"sync/atomic"
"time"
"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 全局默认日志记录器
// 用于中间件和其他需要快速日志记录的场景
defaultLogger *Logger
defaultMux sync.RWMutex
)
func init() {
// 初始化默认logger同步模式输出到stdout
var err error
defaultLogger, err = NewLogger(nil)
l, err := NewLogger(nil)
if err != nil {
// 如果初始化失败使用nil后续会降级到标准输出
defaultLogger = nil
return
}
defaultLogger = l
}
// logMessage 异步日志消息结构
type logMessage struct {
level string // debug, info, warn, error
format string
args []interface{}
fields map[string]interface{} // 用于带字段的日志
// 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 日志记录器
type Logger struct {
infoLog *log.Logger
errorLog *log.Logger
warnLog *log.Logger
debugLog *log.Logger
config *config.LoggerConfig
// 异步相关字段
async bool // 是否异步模式
logChan chan *logMessage // 异步日志channel
done chan struct{} // 用于优雅关闭
wg sync.WaitGroup // 等待所有日志写入完成
closed bool // 是否已关闭
closeMux sync.RWMutex // 保护closed字段
level string
writers []io.Writer
prefix string
async bool
logChan chan logEntry
done chan struct{}
wg sync.WaitGroup
closed bool
mu sync.RWMutex
dropped atomic.Uint64
}
// NewLogger 创建日志记录器
func NewLogger(cfg *config.LoggerConfig) (*Logger, error) {
if cfg == nil {
// 使用默认配置
cfg = &config.LoggerConfig{
Level: "info",
Output: "stdout",
FilePath: "",
Async: false, // 默认同步
BufferSize: 1000, // 默认缓冲区大小
Async: config.BoolPtr(true),
BufferSize: 1000,
}
}
// 设置默认值
if cfg.Level == "" {
cfg.Level = "info"
}
@@ -74,10 +102,37 @@ func NewLogger(cfg *config.LoggerConfig) (*Logger, error) {
cfg.Output = "stdout"
}
if cfg.BufferSize <= 0 {
cfg.BufferSize = 1000 // 默认缓冲区大小
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
switch cfg.Output {
case "stdout":
@@ -88,96 +143,56 @@ func NewLogger(cfg *config.LoggerConfig) (*Logger, error) {
if cfg.FilePath == "" {
return nil, fmt.Errorf("file path is required when output is file")
}
// 确保目录存在
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)
w, err := openLogFile(cfg.FilePath)
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":
writers = append(writers, os.Stdout)
if cfg.FilePath == "" {
return nil, fmt.Errorf("file path is required when output is both")
}
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)
w, err := openLogFile(cfg.FilePath)
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:
return nil, fmt.Errorf("invalid output type: %s", cfg.Output)
}
// 创建多写入器
multiWriter := io.MultiWriter(writers...)
// 创建日志前缀
prefix := ""
if cfg.Prefix != "" {
prefix = cfg.Prefix + " "
return writers, nil
}
// 创建日志记录器
logger := &Logger{
config: cfg,
async: cfg.Async,
func openLogFile(path string) (io.Writer, error) {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("failed to create log directory: %w", err)
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666)
if err != nil {
return nil, fmt.Errorf("failed to open log file: %w", err)
}
return f, nil
}
// 根据日志级别创建不同的logger
flags := log.LstdFlags
if cfg.DisableTimestamp {
flags = 0
}
if cfg.Level == "debug" || cfg.Level == "info" || cfg.Level == "warn" || cfg.Level == "error" {
logger.infoLog = log.New(multiWriter, prefix+"[INFO] ", flags)
logger.warnLog = log.New(multiWriter, prefix+"[WARN] ", flags)
logger.errorLog = log.New(multiWriter, prefix+"[ERROR] ", flags)
if cfg.Level == "debug" {
logger.debugLog = log.New(multiWriter, prefix+"[DEBUG] ", flags)
}
}
// 如果启用异步模式启动goroutine处理日志
if cfg.Async {
logger.logChan = make(chan *logMessage, cfg.BufferSize)
logger.done = make(chan struct{})
logger.wg.Add(1)
go logger.processLogs()
}
return logger, nil
}
// processLogs 异步处理日志goroutine
func (l *Logger) processLogs() {
defer l.wg.Done()
for {
select {
case msg := <-l.logChan:
if msg == nil {
// channel已关闭退出
case msg, ok := <-l.logChan:
if !ok {
return
}
l.writeLog(msg)
l.writeEntry(msg)
case <-l.done:
// 收到关闭信号,处理完剩余日志后退出
for {
select {
case msg := <-l.logChan:
if msg == nil {
case msg, ok := <-l.logChan:
if !ok {
return
}
l.writeLog(msg)
l.writeEntry(msg)
default:
return
}
@@ -186,278 +201,161 @@ func (l *Logger) processLogs() {
}
}
// writeLog 实际写入日志(内部方法)
func (l *Logger) writeLog(msg *logMessage) {
var logger *log.Logger
switch msg.level {
case "debug":
logger = l.debugLog
case "info":
logger = l.infoLog
case "warn":
logger = l.warnLog
case "error":
logger = l.errorLog
default:
return
}
if logger == nil {
return
}
// 如果有字段,先格式化字段
format := msg.format
if len(msg.fields) > 0 {
fieldStr := formatFields(msg.fields)
format = fieldStr + format
}
// 写入日志
logger.Printf(format, msg.args...)
}
// isClosed 检查logger是否已关闭
func (l *Logger) isClosed() bool {
l.closeMux.RLock()
defer l.closeMux.RUnlock()
l.mu.RLock()
defer l.mu.RUnlock()
return l.closed
}
// setClosed 设置logger为已关闭状态
func (l *Logger) setClosed() {
l.closeMux.Lock()
defer l.closeMux.Unlock()
l.closed = true
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"
}
}
// log 内部日志方法,根据模式选择同步或异步
func (l *Logger) log(level string, format string, args []interface{}, fields map[string]interface{}) {
// 如果已关闭,直接返回
if l.isClosed() {
func (l *Logger) emit(level, message string, fields map[string]any) {
if l.isClosed() || !l.shouldLog(level) {
return
}
// 如果是异步模式发送到channel
entry := logEntry{level: level, message: message, fields: fields}
if l.async {
// 检查channel是否已关闭
select {
case l.logChan <- &logMessage{
level: level,
format: format,
args: args,
fields: fields,
}:
// 成功发送
case l.logChan <- entry:
default:
// channel已满或已关闭同步写入降级处理
l.writeLog(&logMessage{
level: level,
format: format,
args: args,
fields: fields,
})
l.dropped.Add(1)
}
} else {
// 同步模式,直接写入
l.writeLog(&logMessage{
level: level,
format: format,
args: args,
fields: fields,
})
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 记录调试日志
func (l *Logger) Debug(format string, v ...interface{}) {
l.log("debug", format, v, nil)
func (l *Logger) Debug(message string, fields map[string]any) {
l.emit("debug", message, fields)
}
// Info 记录信息日志
func (l *Logger) Info(format string, v ...interface{}) {
l.log("info", format, v, nil)
}
// Warn 记录警告日志
func (l *Logger) Warn(format string, v ...interface{}) {
l.log("warn", format, v, nil)
func (l *Logger) Info(message string, fields map[string]any) {
l.emit("info", message, fields)
}
// Error 记录错误日志
func (l *Logger) Error(format string, v ...interface{}) {
l.log("error", format, v, nil)
func (l *Logger) Error(message string, fields map[string]any) {
l.emit("error", message, fields)
}
// Fatal 记录致命错误日志并退出程序(始终同步)
func (l *Logger) Fatal(format string, v ...interface{}) {
// Fatal必须同步执行确保日志写入后再退出
if l.errorLog != nil {
l.errorLog.Fatalf(format, v...)
} else {
log.Fatalf(format, v...)
}
}
// Panic 记录恐慌日志并触发panic始终同步
func (l *Logger) Panic(format string, v ...interface{}) {
// Panic必须同步执行确保日志写入后再panic
if l.errorLog != nil {
l.errorLog.Panicf(format, v...)
} else {
log.Panicf(format, v...)
}
}
// WithFields 创建带字段的日志记录器(简化版,返回格式化字符串)
func (l *Logger) WithFields(fields map[string]interface{}) *Logger {
// 返回自身实际使用时可以在format中包含fields
return l
}
// formatFields 格式化字段
func formatFields(fields map[string]interface{}) string {
if len(fields) == 0 {
return ""
}
result := ""
for k, v := range fields {
result += fmt.Sprintf("%s=%v ", k, v)
}
return result
}
// Debugf 记录调试日志(带字段)
func (l *Logger) Debugf(fields map[string]interface{}, format string, v ...interface{}) {
l.log("debug", format, v, fields)
}
// Infof 记录信息日志(带字段)
func (l *Logger) Infof(fields map[string]interface{}, format string, v ...interface{}) {
l.log("info", format, v, fields)
}
// Warnf 记录警告日志(带字段)
func (l *Logger) Warnf(fields map[string]interface{}, format string, v ...interface{}) {
l.log("warn", format, v, fields)
}
// Errorf 记录错误日志(带字段)
func (l *Logger) Errorf(fields map[string]interface{}, format string, v ...interface{}) {
l.log("error", format, v, fields)
}
// Close 优雅关闭logger仅异步模式需要
// 等待所有日志写入完成后再返回
// Close 刷盘并关闭异步队列
func (l *Logger) Close() error {
if !l.async {
// 同步模式不需要关闭
return nil
}
// 检查是否已关闭
if l.isClosed() {
l.mu.Lock()
if l.closed {
l.mu.Unlock()
return nil
}
l.closed = true
l.mu.Unlock()
// 设置关闭状态
l.setClosed()
// 发送关闭信号
close(l.done)
// 关闭channel会触发processLogs退出
close(l.logChan)
// 等待所有日志写入完成
l.wg.Wait()
return nil
}
// ========== 全局默认Logger相关方法 ==========
// SetDefaultLogger 设置全局默认logger
// 用于在应用启动时统一配置logger
func SetDefaultLogger(log *Logger) {
defaultMux.Lock()
defer defaultMux.Unlock()
// 如果之前有logger先关闭它
if defaultLogger != nil {
defaultLogger.Close()
// DroppedCount 返回因队列满而丢弃的日志条数
func (l *Logger) DroppedCount() uint64 {
return l.dropped.Load()
}
defaultLogger = log
// ContextLogger 带 context 的 logger自动附加 request_id
type ContextLogger struct {
base *Logger
ctx context.Context
}
// GetDefaultLogger 获取全局默认logger
func GetDefaultLogger() *Logger {
defaultMux.RLock()
defer defaultMux.RUnlock()
return defaultLogger
// FromContext 从 context 获取 logger自动附加 request_id
func FromContext(ctx context.Context) *ContextLogger {
base := getDefaultLogger()
if base == nil {
if l, err := NewLogger(nil); err == nil {
base = l
}
}
return &ContextLogger{base: base, ctx: ctx}
}
// Default 全局日志方法 - Debug
func Default() *Logger {
return GetDefaultLogger()
// FromContextWithLogger 使用指定 logger 并从 context 附加 request_id
func FromContextWithLogger(ctx context.Context, base *Logger) *ContextLogger {
if base == nil {
base = getDefaultLogger()
}
return &ContextLogger{base: base, ctx: ctx}
}
// ========== 全局便捷日志方法 ==========
// 以下方法使用全局默认logger方便快速记录日志
// Debug 使用全局logger记录调试日志
func Debug(format string, v ...interface{}) {
if log := GetDefaultLogger(); log != nil {
log.Debug(format, v...)
func (c *ContextLogger) mergeFields(fields map[string]any) map[string]any {
out := make(map[string]any, len(fields)+1)
for k, v := range fields {
out[k] = v
}
if id := RequestIDFromContext(c.ctx); id != "" {
out["request_id"] = id
}
return out
}
// Info 使用全局logger记录信息日志
func Info(format string, v ...interface{}) {
if log := GetDefaultLogger(); log != nil {
log.Info(format, v...)
// Debug 记录调试日志
func (c *ContextLogger) Debug(message string, fields map[string]any) {
if c.base == nil {
return
}
c.base.Debug(message, c.mergeFields(fields))
}
// Warn 使用全局logger记录警告日志
func Warn(format string, v ...interface{}) {
if log := GetDefaultLogger(); log != nil {
log.Warn(format, v...)
// Info 记录信息日志
func (c *ContextLogger) Info(message string, fields map[string]any) {
if c.base == nil {
return
}
c.base.Info(message, c.mergeFields(fields))
}
// Error 使用全局logger记录错误日志
func Error(format string, v ...interface{}) {
if log := GetDefaultLogger(); log != nil {
log.Error(format, v...)
}
}
// Debugf 使用全局logger记录调试日志带字段
func Debugf(fields map[string]interface{}, format string, v ...interface{}) {
if log := GetDefaultLogger(); log != nil {
log.Debugf(fields, format, v...)
}
}
// Infof 使用全局logger记录信息日志带字段
func Infof(fields map[string]interface{}, format string, v ...interface{}) {
if log := GetDefaultLogger(); log != nil {
log.Infof(fields, format, v...)
}
}
// Warnf 使用全局logger记录警告日志带字段
func Warnf(fields map[string]interface{}, format string, v ...interface{}) {
if log := GetDefaultLogger(); log != nil {
log.Warnf(fields, format, v...)
}
}
// Errorf 使用全局logger记录错误日志带字段
func Errorf(fields map[string]interface{}, format string, v ...interface{}) {
if log := GetDefaultLogger(); log != nil {
log.Errorf(fields, format, v...)
// Error 记录错误日志
func (c *ContextLogger) Error(message string, fields map[string]any) {
if c.base == nil {
return
}
c.base.Error(message, c.mergeFields(fields))
}

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
}

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
}

View File

@@ -2,17 +2,15 @@ package middleware
import (
"net/http"
"strconv"
"time"
"git.toowon.com/jimmy/go-common/logger"
)
// responseWriter 包装 http.ResponseWriter 以捕获状态码和响应大小
// responseWriter 包装 ResponseWriter 以捕获状态码
type responseWriter struct {
http.ResponseWriter
statusCode int
size int
}
func (rw *responseWriter) WriteHeader(statusCode int) {
@@ -20,202 +18,49 @@ func (rw *responseWriter) WriteHeader(statusCode int) {
rw.ResponseWriter.WriteHeader(statusCode)
}
func (rw *responseWriter) Write(b []byte) (int, error) {
size, err := rw.ResponseWriter.Write(b)
rw.size += size
return size, err
}
// LoggingConfig 日志中间件配置
type LoggingConfig struct {
// Logger 日志记录器可选如果为nil则使用默认logger
Logger *logger.Logger
// SkipPaths 跳过记录的路径列表(如健康检查接口)
SkipPaths []string
// LogRequestBody 是否记录请求体(谨慎使用,可能影响性能)
LogRequestBody bool
// LogResponseBody 是否记录响应体(谨慎使用,可能影响性能和内存)
LogResponseBody bool
}
// Logging HTTP请求日志中间件
// 记录每个HTTP请求的详细信息包括
// - 请求方法、路径、IP、User-Agent
// - 响应状态码、响应大小
// - 请求处理时间
//
// 使用方式1使用默认logger
//
// chain := middleware.NewChain(
// middleware.Logging(nil),
// )
//
// 使用方式2使用自定义logger
//
// myLogger, _ := logger.NewLogger(loggerConfig)
// chain := middleware.NewChain(
// middleware.Logging(&middleware.LoggingConfig{
// Logger: myLogger,
// SkipPaths: []string{"/health", "/metrics"},
// }),
// )
// Logging HTTP 访问日志(固定 Info 级别)
func Logging(config *LoggingConfig) func(http.Handler) http.Handler {
// 如果没有配置,使用默认配置
if config == nil {
config = &LoggingConfig{}
}
// 如果没有提供logger创建一个默认的
if config.Logger == nil {
// 使用默认配置创建logger输出到stdoutinfo级别
defaultLogger, err := logger.NewLogger(nil)
if err != nil {
// 如果创建失败使用nil后面会降级处理
config.Logger = nil
} else {
config.Logger = defaultLogger
}
}
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
}
// 记录开始时间
startTime := time.Now()
// 包装 ResponseWriter 以捕获状态码和响应大小
rw := &responseWriter{
ResponseWriter: w,
statusCode: http.StatusOK, // 默认200
size: 0,
}
// 处理请求
start := time.Now()
rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(rw, r)
// 计算处理时间
duration := time.Since(startTime)
fields := map[string]any{
"method": r.Method,
"path": r.URL.Path,
"duration": time.Since(start).Milliseconds(),
}
// 记录日志
logHTTPRequest(config.Logger, r, rw, duration)
log := logger.FromContext(r.Context())
if config.Logger != nil {
log = logger.FromContextWithLogger(r.Context(), config.Logger)
}
log.Info("HTTP Request", fields)
})
}
}
// shouldSkipPath 检查是否应该跳过该路径
func shouldSkipPath(path string, skipPaths []string) bool {
for _, skipPath := range skipPaths {
if path == skipPath {
for _, skip := range skipPaths {
if path == skip {
return true
}
}
return false
}
// logHTTPRequest 记录HTTP请求日志
func logHTTPRequest(log *logger.Logger, r *http.Request, rw *responseWriter, duration time.Duration) {
// 获取客户端IP
clientIP := GetClientIP(r)
// 构建日志字段
fields := map[string]interface{}{
"method": r.Method,
"path": r.URL.Path,
"query": r.URL.RawQuery,
"status": rw.statusCode,
"size": rw.size,
"duration": duration.Milliseconds(), // 毫秒
"ip": clientIP,
"user_agent": r.UserAgent(),
"referer": r.Referer(),
}
// 构建日志消息
message := "HTTP Request"
// 根据状态码选择日志级别
if log != nil {
// 使用提供的logger
if rw.statusCode >= 500 {
log.Errorf(fields, message)
} else if rw.statusCode >= 400 {
log.Warnf(fields, message)
} else {
log.Infof(fields, message)
}
} else {
// 降级处理:使用标准输出
// 注意:这是同步的,不会有性能问题
if rw.statusCode >= 500 {
logToStdout("ERROR", fields, message)
} else if rw.statusCode >= 400 {
logToStdout("WARN", fields, message)
} else {
logToStdout("INFO", fields, message)
}
}
}
// logToStdout 降级处理输出到标准输出当logger不可用时
func logToStdout(level string, fields map[string]interface{}, message string) {
// 简单的标准输出日志
var fieldStr string
for k, v := range fields {
fieldStr += " " + k + "=" + formatValue(v)
}
println("[" + level + "] " + message + fieldStr)
}
// formatValue 格式化值(用于日志输出)
func formatValue(v interface{}) string {
switch val := v.(type) {
case string:
return val
case int:
return strconv.Itoa(val)
case int64:
return strconv.FormatInt(val, 10)
default:
return ""
}
}
// GetClientIP 获取客户端真实IP
// 优先级X-Forwarded-For > X-Real-IP > RemoteAddr
func GetClientIP(r *http.Request) string {
// 尝试从 X-Forwarded-For 获取
xff := r.Header.Get("X-Forwarded-For")
if xff != "" {
// X-Forwarded-For 可能包含多个IP取第一个
for idx := 0; idx < len(xff); idx++ {
if xff[idx] == ',' {
return xff[:idx]
}
}
return xff
}
// 尝试从 X-Real-IP 获取
xri := r.Header.Get("X-Real-IP")
if xri != "" {
return xri
}
// 使用 RemoteAddr
remoteAddr := r.RemoteAddr
// 移除端口号
for i := len(remoteAddr) - 1; i >= 0; i-- {
if remoteAddr[i] == ':' {
return remoteAddr[:i]
}
}
return remoteAddr
}

View File

@@ -5,145 +5,43 @@ import (
"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 日志记录器可选如果为nil则使用默认logger
Logger *logger.Logger
// EnableStackTrace 是否在日志中包含堆栈跟踪
EnableStackTrace bool
// CustomHandler 自定义错误处理函数(可选)
// 如果设置了,会在记录日志后调用此函数
// 可以用于自定义错误响应格式
CustomHandler func(w http.ResponseWriter, r *http.Request, err interface{})
I18n *i18n.I18n
}
// Recovery Panic恢复中间件
// 捕获HTTP处理过程中的panic记录错误日志并返回500错误
// 防止panic导致整个服务崩溃
//
// 使用方式1使用默认配置
//
// chain := middleware.NewChain(
// middleware.Recovery(nil),
// )
//
// 使用方式2使用自定义配置
//
// myLogger, _ := logger.NewLogger(loggerConfig)
// chain := middleware.NewChain(
// middleware.Recovery(&middleware.RecoveryConfig{
// Logger: myLogger,
// EnableStackTrace: true,
// }),
// )
//
// 使用方式3自定义错误响应
//
// chain := middleware.NewChain(
// middleware.Recovery(&middleware.RecoveryConfig{
// Logger: myLogger,
// CustomHandler: func(w http.ResponseWriter, r *http.Request, err interface{}) {
// // 自定义JSON响应
// w.Header().Set("Content-Type", "application/json")
// w.WriteHeader(http.StatusInternalServerError)
// w.Write([]byte(`{"code":500,"message":"Internal Server Error"}`))
// },
// }),
// )
// Recovery Panic 恢复中间件,响应统一 JSON 200 + system.internal_error
func Recovery(config *RecoveryConfig) func(http.Handler) http.Handler {
// 如果没有配置,使用默认配置
if config == nil {
config = &RecoveryConfig{
EnableStackTrace: true, // 默认启用堆栈跟踪
}
}
// 如果没有提供logger创建一个默认的
if config.Logger == nil {
defaultLogger, err := logger.NewLogger(nil)
if err != nil {
config.Logger = nil
} else {
config.Logger = defaultLogger
}
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
// 记录panic信息
logPanic(config.Logger, r, err, config.EnableStackTrace)
// 如果提供了自定义处理函数,调用它
if config.CustomHandler != nil {
config.CustomHandler(w, r, err)
return
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)
// 默认错误响应
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
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)
})
}
}
// logPanic 记录panic日志
func logPanic(log *logger.Logger, r *http.Request, err interface{}, enableStackTrace bool) {
// 获取堆栈跟踪
var stack string
if enableStackTrace {
stack = string(debug.Stack())
}
// 构建日志字段
fields := map[string]interface{}{
"method": r.Method,
"path": r.URL.Path,
"query": r.URL.RawQuery,
"ip": GetClientIP(r),
"error": fmt.Sprintf("%v", err),
}
// 构建日志消息
message := "Panic recovered"
if enableStackTrace && stack != "" {
message += "\n" + stack
}
// 记录错误日志
if log != nil {
log.Errorf(fields, message)
} else {
// 降级处理:输出到标准错误
fmt.Printf("[ERROR] %s\n", message)
for k, v := range fields {
fmt.Printf(" %s: %v\n", k, v)
}
}
}
// RecoveryWithLogger 使用指定logger的Recovery中间件便捷函数
func RecoveryWithLogger(log *logger.Logger) func(http.Handler) http.Handler {
return Recovery(&RecoveryConfig{
Logger: log,
EnableStackTrace: true,
})
}
// RecoveryWithCustomHandler 使用自定义错误处理的Recovery中间件便捷函数
func RecoveryWithCustomHandler(customHandler func(w http.ResponseWriter, r *http.Request, err interface{})) func(http.Handler) http.Handler {
return Recovery(&RecoveryConfig{
EnableStackTrace: true,
CustomHandler: customHandler,
})
}

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

View File

@@ -34,14 +34,14 @@ func RunMigrationsFromConfig(configFile, migrationsDir string) error {
// RunMigrationsFromConfigWithCommand 从配置文件运行迁移(支持命令,黑盒模式)
//
// 这是最简单的迁移方式,内部自动处理:
// - 配置加载(支持文件、环境变量、默认路径)
// - 配置加载(支持配置文件、默认路径)
// - 数据库连接(自动识别数据库类型)
// - 迁移文件加载和执行
//
// 参数:
// - configFile: 配置文件路径,支持:
// - 空字符串自动查找config.json, ../config.json
// - 环境变量 DATABASE_URL直接使用数据库URL
// - 相对路径或绝对路径:指定配置文件路径
// - migrationsDir: 迁移文件目录,支持:
// - 空字符串:使用默认目录 "migrations"
// - 相对路径或绝对路径
@@ -57,9 +57,6 @@ func RunMigrationsFromConfig(configFile, migrationsDir string) error {
//
// // 指定配置和迁移目录
// migration.RunMigrationsFromConfigWithCommand("config.json", "scripts/sql", "up")
//
// // 使用环境变量
// // DATABASE_URL="mysql://..." migration.RunMigrationsFromConfigWithCommand("", "migrations", "up")
func RunMigrationsFromConfigWithCommand(configFile, migrationsDir, command string) error {
// 加载配置
cfg, err := loadConfigFromFileOrEnv(configFile)
@@ -78,8 +75,8 @@ func RunMigrationsFromConfigWithCommand(configFile, migrationsDir, command strin
migrationsDir = "migrations"
}
// 创建迁移器
migrator := NewMigrator(db)
// 创建迁移器(传入数据库类型,性能更好)
migrator := NewMigratorWithType(db, cfg.Database.Type)
// 加载迁移文件
migrations, err := LoadMigrationsFromFiles(migrationsDir, "*.sql")
@@ -122,22 +119,16 @@ func RunMigrationsFromConfigWithCommand(configFile, migrationsDir, command strin
return nil
}
// loadConfigFromFileOrEnv 从文件或环境变量加载配置
// loadConfigFromFileOrEnv 从配置文件加载配置
// 支持指定配置文件路径,或自动查找默认路径
func loadConfigFromFileOrEnv(configFile string) (*config.Config, error) {
// 优先从环境变量加载
if dbURL := os.Getenv("DATABASE_URL"); dbURL != "" {
return &config.Config{
Database: &config.DatabaseConfig{
DSN: dbURL,
},
}, nil
}
// 尝试从配置文件加载
// 如果指定了配置文件路径,优先使用
if configFile != "" {
if _, err := os.Stat(configFile); err == nil {
return config.LoadFromFile(configFile)
}
// 如果指定的文件不存在,返回错误
return nil, fmt.Errorf("配置文件不存在: %s", configFile)
}
// 尝试默认路径
@@ -148,7 +139,7 @@ func loadConfigFromFileOrEnv(configFile string) (*config.Config, error) {
}
}
return nil, fmt.Errorf("未找到配置文件,也未设置环境变量 DATABASE_URL")
return nil, fmt.Errorf("未找到配置文件,请指定配置文件路径或确保存在以下文件之一: %v", defaultPaths)
}
// connectDB 连接数据库

View File

@@ -26,6 +26,7 @@ type Migrator struct {
db *gorm.DB
migrations []Migration
tableName string
dbType string // 数据库类型: mysql, postgres, sqlite
}
// NewMigrator 创建新的迁移器
@@ -41,6 +42,25 @@ func NewMigrator(db *gorm.DB, tableName ...string) *Migrator {
db: db,
migrations: make([]Migration, 0),
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,18 +76,78 @@ func (m *Migrator) AddMigrations(migrations ...Migration) {
// initTable 初始化迁移记录表
func (m *Migrator) initTable() error {
// 检查表是否存在
// 检查表是否存在根据数据库类型使用对应的SQL性能更好
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 FROM information_schema.tables
SELECT 1 FROM information_schema.tables
WHERE table_schema = CURRENT_SCHEMA()
AND table_name = '%s'
)
`, 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 {
// 如果查询失败可能是SQLite或其他数据库尝试直接创建
exists = false
}
@@ -89,19 +169,57 @@ func (m *Migrator) initTable() error {
// 注意:这个检查可能在某些数据库中失败,但不影响功能
// 如果字段不存在,记录执行时间时会失败,但不影响迁移执行
var hasExecutionTime bool
checkSQL := fmt.Sprintf(`
SELECT COUNT(*) > 0
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)
err = m.db.Raw(checkSQL).Scan(&hasExecutionTime).Error
if err == nil && !hasExecutionTime {
`, 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 COMMENT '执行耗时(ms)'
ADD COLUMN execution_time INT
`, m.tableName))
}
}

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
import (
"context"
"crypto/hmac"
"crypto/sha1"
"encoding/base64"
@@ -11,210 +12,128 @@ import (
"net/url"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"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 短信发送器
type SMS struct {
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 创建短信发送器
func NewSMS(cfg *config.SMSConfig) (*SMS, error) {
if cfg == nil {
func NewSMS(cfg *config.Config) *SMS {
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")
}
if cfg.AccessKeyID == "" {
if s.config.AccessKeyID == "" {
return nil, fmt.Errorf("AccessKeyID is required")
}
if cfg.AccessKeySecret == "" {
if s.config.AccessKeySecret == "" {
return nil, fmt.Errorf("AccessKeySecret is required")
}
if cfg.SignName == "" {
if s.config.SignName == "" {
return nil, fmt.Errorf("SignName is required")
}
// 设置默认值
if cfg.Region == "" {
cfg.Region = "cn-hangzhou"
if s.config.Region == "" {
s.config.Region = "cn-hangzhou"
}
if cfg.Timeout == 0 {
cfg.Timeout = 10
if s.config.Timeout == 0 {
s.config.Timeout = 5
}
return s.config, nil
}
return &SMS{
config: cfg,
}, nil
}
// SendRequest 发送短信请求
type SendRequest struct {
// PhoneNumbers 手机号列表
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()))
// SendSMS 同步发送短信
func (s *SMS) SendSMS(phoneNumbers []string, templateParam interface{}, templateCode ...string) (*SendResponse, error) {
cfg, err := s.getSMSConfig()
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
return nil, err
}
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 {
if len(phoneNumbers) == 0 {
return nil, fmt.Errorf("phone numbers are required")
}
// 使用配置中的模板代码和签名(如果请求中未指定)
templateCode := req.TemplateCode
if templateCode == "" {
templateCode = s.config.TemplateCode
templateCodeValue := cfg.TemplateCode
if len(templateCode) > 0 && templateCode[0] != "" {
templateCodeValue = templateCode[0]
}
if templateCode == "" {
if templateCodeValue == "" {
return nil, fmt.Errorf("template code is required")
}
signName := req.SignName
if signName == "" {
signName = s.config.SignName
}
// 处理模板参数
var templateParamJSON string
if req.TemplateParam != nil {
switch v := req.TemplateParam.(type) {
if templateParam != nil {
switch v := templateParam.(type) {
case string:
// 直接使用字符串必须是有效的JSON
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:
// 尝试JSON序列化
paramBytes, err := json.Marshal(v)
if err != nil {
return nil, fmt.Errorf("failed to marshal template param: %w", err)
@@ -225,66 +144,120 @@ func (s *SMS) Send(req *SendRequest) (*SendResponse, error) {
templateParamJSON = "{}"
}
// 构建请求参数
params := make(map[string]string)
params["PhoneNumbers"] = strings.Join(req.PhoneNumbers, ",")
params["SignName"] = signName
params["TemplateCode"] = templateCode
params["TemplateParam"] = templateParamJSON
params := map[string]string{
"Action": "SendSms",
"Version": "2017-05-25",
"RegionId": cfg.Region,
"AccessKeyId": cfg.AccessKeyID,
"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发送
return s.SendRaw(params)
endpoint := cfg.Endpoint
if endpoint == "" {
endpoint = "https://dysmsapi.aliyuncs.com"
}
// calculateSignature 计算签名
func (s *SMS) calculateSignature(params map[string]string, method string) string {
// 对参数进行排序
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
}
// SendSMSAsync 异步发送短信
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))
for k := range params {
keys = append(keys, k)
}
sort.Strings(keys)
// 构建查询字符串
var queryParts []string
for _, k := range keys {
v := params[k]
// URL编码
encodedKey := url.QueryEscape(k)
encodedValue := url.QueryEscape(v)
queryParts = append(queryParts, encodedKey+"="+encodedValue)
queryParts = append(queryParts, url.QueryEscape(k)+"="+url.QueryEscape(params[k]))
}
queryString := strings.Join(queryParts, "&")
// 构建待签名字符串
stringToSign := method + "&" + url.QueryEscape("/") + "&" + url.QueryEscape(queryString)
// 计算HMAC-SHA1签名
mac := hmac.New(sha1.New, []byte(s.config.AccessKeySecret+"&"))
mac := hmac.New(sha1.New, []byte(accessKeySecret+"&"))
mac.Write([]byte(stringToSign))
signature := 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,
})
return base64.StdEncoding.EncodeToString(mac.Sum(nil))
}

View File

@@ -39,39 +39,32 @@ func NewUploadHandler(cfg UploadHandlerConfig) *UploadHandler {
}
// ServeHTTP 处理文件上传请求
// 请求方式: POST
// 表单字段: file (文件)
// 可选字段: prefix (对象键前缀,会覆盖配置中的前缀)
func (h *UploadHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
handler := commonhttp.NewHandler(w, r)
if r.Method != http.MethodPost {
handler.Error(4001, "Method not allowed")
handler.Error("common.method_not_allowed")
return
}
// 解析multipart表单
err := r.ParseMultipartForm(h.maxFileSize)
if err != nil {
handler.Error(4002, fmt.Sprintf("Failed to parse form: %v", err))
handler.Error("common.invalid_request")
return
}
// 获取文件
file, header, err := r.FormFile("file")
if err != nil {
handler.Error(4003, fmt.Sprintf("Failed to get file: %v", err))
handler.Error("common.invalid_request")
return
}
defer file.Close()
// 检查文件大小
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
}
// 检查文件扩展名
if len(h.allowedExts) > 0 {
ext := strings.ToLower(filepath.Ext(header.Filename))
allowed := false
@@ -82,22 +75,19 @@ func (h *UploadHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}
if !allowed {
handler.Error(1002, fmt.Sprintf("File extension not allowed. Allowed: %v", h.allowedExts))
handler.Error("storage.invalid_extension")
return
}
}
// 生成对象键
prefix := h.objectPrefix
if r.FormValue("prefix") != "" {
prefix = r.FormValue("prefix")
}
// 生成唯一文件名
filename := generateUniqueFilename(header.Filename)
objectKey := GenerateObjectKey(prefix, filename)
// 获取文件类型
contentType := header.Header.Get("Content-Type")
if contentType == "" {
contentType = mime.TypeByExtension(filepath.Ext(header.Filename))
@@ -106,22 +96,18 @@ func (h *UploadHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}
// 上传文件
ctx := r.Context()
err = h.storage.Upload(ctx, objectKey, file, contentType)
if err != nil {
handler.SystemError(fmt.Sprintf("Failed to upload file: %v", err))
if err = h.storage.Upload(ctx, objectKey, file, contentType); err != nil {
handler.Error("system.internal_error")
return
}
// 获取文件URL
fileURL, err := h.storage.GetURL(objectKey, 0)
if err != nil {
handler.SystemError(fmt.Sprintf("Failed to get file URL: %v", err))
handler.Error("system.internal_error")
return
}
// 返回结果
result := UploadResult{
ObjectKey: objectKey,
URL: fileURL,
@@ -129,8 +115,7 @@ func (h *UploadHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ContentType: contentType,
UploadTime: time.Now(),
}
handler.SuccessWithMessage("Upload successful", result)
handler.Success(result)
}
// generateUniqueFilename 生成唯一文件名

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

View File

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

View File

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

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

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

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