From 6072ec57e862e79b63c431013e6931fe7c526b01 Mon Sep 17 00:00:00 2001 From: Jimmy Xue Date: Thu, 25 Jun 2026 00:03:59 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E9=A1=B9=E7=9B=AE=E7=9A=84?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0,=E4=BC=98=E5=8C=96=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E6=96=B9=E6=B3=95=E4=B8=8E=E4=BD=BF=E7=94=A8=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- INTEGRATION.md | 429 ++++++ MIGRATION.md | 580 -------- QUICKSTART.md | 346 ----- README.md | 438 +------ SETUP.md | 174 --- TROUBLESHOOTING.md | 208 --- VERSION.md | 17 +- config/config.go | 94 +- docs/README.md | 150 --- docs/config.md | 562 -------- docs/datetime.md | 596 --------- docs/email.md | 332 ----- docs/excel.md | 447 ------- docs/factory.md | 1251 ------------------ docs/http.md | 765 ----------- docs/i18n.md | 510 -------- docs/logger.md | 341 ----- docs/middleware.md | 1039 --------------- docs/migration.md | 442 ------- docs/sms.md | 370 ------ docs/storage.md | 571 -------- email/email.go | 233 ++-- examples/email_example.go | 110 +- examples/excel_example.go | 205 +-- examples/factory_blackbox_example.go | 166 --- examples/http_handler_example.go | 116 +- examples/http_pagination_example.go | 56 +- examples/i18n_example.go | 97 +- examples/logger_example.go | 58 +- examples/middleware_simple_example.go | 36 +- examples/migrations/README.md | 139 -- examples/sms_example.go | 95 +- excel/excel.go | 11 + factory/factory.go | 1745 ++++--------------------- go.mod | 2 +- http/handler.go | 95 ++ http/request.go | 16 +- http/response.go | 91 +- logger/logger.go | 530 +++----- middleware/clientip.go | 26 + middleware/language.go | 23 +- middleware/logging.go | 189 +-- middleware/recovery.go | 142 +- middleware/requestid.go | 23 + middleware/timezone.go | 45 +- requestctx/requestctx.go | 41 + sms/sms.go | 208 +-- storage/handler.go | 35 +- templates/README.md | 2 +- 49 files changed, 1663 insertions(+), 12534 deletions(-) create mode 100644 INTEGRATION.md delete mode 100644 MIGRATION.md delete mode 100644 QUICKSTART.md delete mode 100644 SETUP.md delete mode 100644 TROUBLESHOOTING.md delete mode 100644 docs/README.md delete mode 100644 docs/config.md delete mode 100644 docs/datetime.md delete mode 100644 docs/email.md delete mode 100644 docs/excel.md delete mode 100644 docs/factory.md delete mode 100644 docs/http.md delete mode 100644 docs/i18n.md delete mode 100644 docs/logger.md delete mode 100644 docs/middleware.md delete mode 100644 docs/migration.md delete mode 100644 docs/sms.md delete mode 100644 docs/storage.md delete mode 100644 examples/factory_blackbox_example.go delete mode 100644 examples/migrations/README.md create mode 100644 http/handler.go create mode 100644 middleware/clientip.go create mode 100644 middleware/requestid.go create mode 100644 requestctx/requestctx.go diff --git a/INTEGRATION.md b/INTEGRATION.md new file mode 100644 index 0000000..88b6c93 --- /dev/null +++ b/INTEGRATION.md @@ -0,0 +1,429 @@ +# GoCommon 业务项目对接操作手册 + +本文档供**引用 GoCommon 的业务项目**使用。按本手册对接即可,无需重复沟通基础设施实现方式。 + +模块路径:`git.toowon.com/jimmy/go-common` + +> 本文档只描述**目标架构**,重构时一步到位,**不提供过渡期用法,不考虑向后兼容**。 + +--- + +## 1. 对接原则 + +| 原则 | 说明 | +|------|------| +| Factory 只是入口 | 启动时初始化一次,通过 getter 获取各模块**对象** | +| 能力在模块自身 | 使用 `log.Info()`、`db.Find()`、`store.Upload()`,不在 Factory 上堆透传方法 | +| 按需引用 | 用什么模块取什么对象;无状态工具直接 `import tools` | +| 不重写基础设施 | 数据库连接、Redis 客户端、统一 HTTP 出参、中间件链由 GoCommon 提供 | +| 业务只管业务 | Service 返回数据;Handler 交给 `http.Handler` 出参;Migration 只写 SQL 文件 | + +--- + +## 2. 环境准备 + +### 2.1 配置私有模块 + +```bash +go env -w GOPRIVATE=git.toowon.com +go env -w GONOPROXY=git.toowon.com +go env -w GONOSUMDB=git.toowon.com +``` + +### 2.2 Git 认证(SSH 推荐) + +```bash +git config --global url."git@git.toowon.com:".insteadOf "https://git.toowon.com/" +``` + +### 2.3 安装依赖 + +```bash +go get git.toowon.com/jimmy/go-common@v2.0.0 +``` + +在 `go.mod` 中: + +```go +require git.toowon.com/jimmy/go-common v2.0.0 +``` + +配置示例见 [`config/example.json`](./config/example.json)。 + +--- + +## 3. 推荐项目结构 + +```text +your-project/ +├── config.json +├── cmd/ +│ ├── server/main.go +│ └── migrate/main.go # 从 templates/migrate/main.go 复制 +├── migrations/ +│ └── 20240101000001_create_users.sql +├── locales/ # i18n(可选) +│ ├── zh-CN.json +│ └── en-US.json +├── internal/ +│ ├── handler/ +│ └── service/ +└── go.mod +``` + +--- + +## 4. 启动初始化(只做一次) + +```go +package main + +import ( + "log" + "net/http" + + "git.toowon.com/jimmy/go-common/factory" +) + +func main() { + if err := factory.Init("config.json"); err != nil { + log.Fatal(err) + } + + app := factory.Default() + chain := app.MiddlewareChain() + chain.Append(yourAuthMiddleware) + + userSvc, err := NewUserService(app) + if err != nil { + log.Fatal(err) + } + + http.Handle("/api/users", chain.ThenFunc(func(w http.ResponseWriter, r *http.Request) { + listUsers(w, r, userSvc, app) + })) + log.Fatal(http.ListenAndServe(":8080", nil)) +} +``` + +**禁止**在每个 Handler 里 `NewFromFile` / `Init`。全局只初始化一次,通过 `factory.Default()` 或注入 `*factory.Factory`。 + +--- + +## 5. 获取模块对象(Factory getter) + +连接与客户端由 Factory **lazy 初始化并缓存**。业务侧**不要**自行 `gorm.Open` / `redis.NewClient`。 + +| 模块 | API | 用法 | +|------|-----|------| +| 配置 | `app.Config()` | 读原始配置 | +| 数据库 | `app.Database()` | `db.Find(&users)`、`db.Transaction(...)` | +| Redis | `app.Redis()` | `rds.Set(ctx, key, val, ttl)` | +| 日志 | `app.Logger()` | `log.Info(...)`,退出前 `log.Close()` | +| 存储 | `app.Storage()` | `store.Upload(ctx, key, reader)` | +| 邮件 | `app.Email()` | `mail.SendEmail(...)` | +| 短信 | `app.SMS()` | `sms.SendSMS(...)` | +| Excel | `app.Excel()` | `ex.ExportToFile(...)` | +| 国际化 | `app.I18n()` | 供 `http.Handler` 注入;或 `i18n.GetMessage(...)` | +| 中间件链 | `app.MiddlewareChain()` | `chain.Append(...).ThenFunc(...)` | +| 迁移 | `app.Migrator("migrations")` | `m.Up()` / `m.Status()` / `m.Down()` | + +### 注入 Service 示例 + +```go +type UserService struct { + db *gorm.DB + rds *redis.Client +} + +func NewUserService(app *factory.Factory) (*UserService, error) { + db, err := app.Database() + if err != nil { + return nil, err + } + rds, err := app.Redis() + if err != nil { + return nil, err + } + return &UserService{db: db, rds: rds}, nil +} + +func (s *UserService) List(page, size int) (users []User, total int64, err error) { + s.db.Model(&User{}).Count(&total) + err = s.db.Offset((page-1)*size).Limit(size).Find(&users).Error + return +} +``` + +`config.json` 未配置的模块,调用 getter 会返回错误——**只配置使用的模块即可**。 + +--- + +## 6. HTTP 统一出参 + +### 6.1 职责划分 + +| 层级 | 做什么 | +|------|--------| +| Service | 返回 `[]User`、`total`、业务 error | +| Handler | 解析请求、调 Service、通过 `http.Handler` 出参 | +| `http.Handler` | PageData → Response → JSON(结构 / 编码 / 语种 / 时间统一) | + +**禁止**在 Service 拼 JSON,**禁止**在业务项目自定义 `Response` 结构,**禁止**经 Factory 出参(无 `app.Success` / `app.Error`)。 + +### 6.2 标准响应格式 + +```json +{ + "code": 0, + "message": "success", + "timestamp": 1704067200, + "data": {} +} +``` + +分页时 `data`: + +```json +{ + "list": [], + "total": 100, + "page": 1, + "pageSize": 20 +} +``` + +类型定义在 `http` 包:`http.Response`、`http.PageData`。 + +### 6.3 Handler 用法(唯一方式) + +中间件链须包含 `Language`、`Timezone`(`MiddlewareChain()` 已默认组装)。 + +```go +import commonhttp "git.toowon.com/jimmy/go-common/http" + +func listUsers(w http.ResponseWriter, r *http.Request, svc *UserService, app *factory.Factory) { + i18n, _ := app.I18n() + h := commonhttp.NewHandler(w, r, commonhttp.WithI18n(i18n)) + + var req ListUserRequest + if err := h.ParseJSON(&req); err != nil { + h.Error("common.invalid_request") + return + } + + p := h.Pagination() + users, total, err := svc.List(p.GetPage(), p.GetPageSize()) + if err != nil { + h.Error("user.list_failed") + return + } + + h.SuccessPage(users, total) +} +``` + +`http.Handler` 统一负责: + +- `Content-Type: application/json; charset=utf-8` +- 从 context 读取语种、时区 +- 消息码(如 `user.not_found`)经 i18n 转为文案与业务 code +- `timestamp` 按统一时区策略写入 + +### 6.4 请求头约定 + +| Header | 用途 | +|--------|------| +| `Accept-Language` | 响应消息语种 | +| `X-Timezone` | 时区(默认 `Asia/Shanghai`) | + +--- + +## 7. 数据库迁移 + +执行框架已封装,业务**只写 SQL 文件**。 + +### 7.1 推荐:独立 migrate 命令(与 Web 解耦) + +```bash +cp templates/migrate/main.go cmd/migrate/main.go +go build -o bin/migrate cmd/migrate/main.go + +./bin/migrate up +./bin/migrate status +./bin/migrate down +./bin/migrate up -config config.json -dir migrations +``` + +```sql +-- migrations/20240101000001_create_users.sql +CREATE TABLE users ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + username VARCHAR(255) NOT NULL +); +``` + +```sql +-- migrations/20240101000001_create_users.down.sql +DROP TABLE IF EXISTS users; +``` + +独立 CLI 内部调用 `migration.RunMigrationsFromConfigWithCommand`,无需业务实现连接逻辑。 + +### 7.2 可选:经 Factory(开发 / 小项目) + +```go +m, err := app.Migrator("migrations") +if err != nil { + log.Fatal(err) +} +if err := m.Up(); err != nil { + log.Fatal(err) +} +``` + +### 7.3 Docker + +```yaml +command: sh -c "./bin/migrate up && ./bin/server" +volumes: + - ./config.json:/app/config.json:ro + - ./migrations:/app/migrations:ro +``` + +--- + +## 8. 各模块按需对接 + +### 8.1 日志 + +```go +log, _ := app.Logger() +defer log.Close() +log.Info("服务启动") +``` + +### 8.2 存储 + +```go +store, _ := app.Storage() +store.Upload(ctx, "images/a.jpg", fileReader, "image/jpeg") +url, _ := store.GetURL("images/a.jpg", 3600) +``` + +不经 Factory 时:`storage.NewStorage(storage.StorageTypeLocal, cfg)`。 + +### 8.3 邮件 / 短信 + +```go +mail, _ := app.Email() +defer mail.Close() + +// HTTP 通知类:异步,不阻塞请求 +mail.SendEmailAsync(r.Context(), []string{"a@b.com"}, "主题", "正文") + +// 验证码等需等待结果:同步 +mail.SendEmail([]string{"a@b.com"}, "主题", "正文") + +sms, _ := app.SMS() +defer sms.Close() +sms.SendSMSAsync(r.Context(), []string{"13800138000"}, map[string]string{"code": "123456"}) +sms.SendSMS([]string{"13800138000"}, map[string]string{"code": "123456"}) +``` + +### 8.4 Excel + +```go +ex := app.Excel() +ex.ExportToFile("users.xlsx", "用户列表", columns, users) +``` + +### 8.5 国际化 + +```go +i18n, _ := app.I18n() +i18n.LoadFromDir("locales") +msg := i18n.GetMessage("zh-CN", "user.not_found") +``` + +HTTP 出参的 i18n 通过 `NewHandler(..., WithI18n(i18n))` 注入,Handler 内用消息码调用 `h.Error("user.not_found")`。 + +### 8.6 无状态工具(不经 Factory) + +```go +import "git.toowon.com/jimmy/go-common/tools" + +tools.Now() +tools.MD5("text") +tools.YuanToCents(100.5) +``` + +--- + +## 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 内 `factory.Init` / `NewFromFile` +- 业务 Service 内写 HTTP JSON 响应 +- 自行 `gorm.Open` / `redis.NewClient` +- 使用 Factory 透传:`LogInfo`、`RedisSet`、`Success`、`Error`、`Now`、`MD5` 等 +- 直接调用 `http.Success(w, ...)` 包级函数(应使用 `http.Handler`) +- 在业务项目复制 GoCommon 的 Response / 中间件实现 + +--- + +## 11. 故障排除 + +**无法下载模块:** + +```bash +go env -w GOPRIVATE=git.toowon.com +git config --global url."git@git.toowon.com:".insteadOf "https://git.toowon.com/" +go get git.toowon.com/jimmy/go-common@latest +``` + +**依赖错误:** `go clean -modcache && go mod tidy` + +**getter 报 config is nil:** 补充 `config.json` 对应段,或不调用该 getter + +**迁移找不到文件:** 检查 `-dir` 与 SQL 文件命名 + +--- + +## 12. 文档与版本 + +| 文档 | 用途 | +|------|------| +| 本文档 | 业务项目对接 | +| [README.md](./README.md) | 库概述 | +| [VERSION.md](./VERSION.md) | 版本发布 | + +版本升级见 [VERSION.md](./VERSION.md)。 diff --git a/MIGRATION.md b/MIGRATION.md deleted file mode 100644 index a9bb4c3..0000000 --- a/MIGRATION.md +++ /dev/null @@ -1,580 +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 -# 使用环境变量指定配置文件路径 -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. 默认值 `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: - CONFIG_FILE: /app/config.prod.json - MIGRATIONS_DIR: /app/migrations - volumes: - - ./config.prod.json:/app/config.prod.json:ro - command: sh -c "./migrate up && ./server" -``` - -### 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: Create Config File - run: | - echo '${{ secrets.CONFIG_JSON }}' > config.json - - - name: Run Migrations - run: ./bin/migrate up -config config.json - - - 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 -./bin/migrate up -config config.json -./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友好,修改配置无需重启 - -**开箱即用,灵活强大!** 🎉 - diff --git a/QUICKSTART.md b/QUICKSTART.md deleted file mode 100644 index 2b250f5..0000000 --- a/QUICKSTART.md +++ /dev/null @@ -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"}, - "测试邮件", - "纯文本内容", - "

HTML内容

", -) -``` - -### 场景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/) - 查看更多实际示例 - diff --git a/README.md b/README.md index 3cbec00..6ea686b 100644 --- a/README.md +++ b/README.md @@ -1,424 +1,56 @@ -# GoCommon - Go通用工具类库 +# GoCommon - Go 通用工具类库 -这是一个Go语言开发的通用工具类库,为其他Go项目提供常用的工具方法集合。 +供其他 Go 项目引用的通用工具集合。业务项目对接请直接阅读: -**📖 快速链接**: -- [5分钟快速开始](./QUICKSTART.md) -- [数据库迁移指南](./MIGRATION.md) ⭐ 独立工具,零耦合,Docker友好 -- [完整文档](./docs/README.md) +**[业务项目对接操作手册(INTEGRATION.md)](./INTEGRATION.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) -提供文件上传和查看功能,支持本地文件夹(Local)、OSS 和 MinIO 三种存储方式,并提供HTTP处理器。 - -### 7. 邮件工具 (email) -提供SMTP邮件发送功能,支持纯文本和HTML邮件,使用Go标准库实现。 - -### 8. 短信工具 (sms) -提供阿里云短信发送功能,支持模板短信和批量发送,使用Go标准库实现。 - -### 9. Excel导出工具 (excel) -提供数据导出到Excel文件的功能,支持结构体切片、自定义格式化、多工作表等特性。 - -**功能特性**: -- 支持结构体切片自动导出 -- 支持嵌套字段访问(如 "User.Name") -- 支持自定义格式化函数 -- 自动调整列宽和表头样式 -- 支持导出到文件或HTTP响应 - -### 10. 工厂工具 (factory) -提供从配置文件直接创建已初始化客户端对象的功能,包括数据库、Redis、邮件、短信、日志、Excel等,避免调用方重复实现创建逻辑。 - -### 11. 日志工具 (logger) -提供统一的日志记录功能,支持多种日志级别和输出方式,使用Go标准库实现。 - ---- - -## 🎯 Factory 黑盒模式(核心设计) - -**理念**:外部项目只需传递一个配置文件路径,直接使用黑盒方法,无需获取内部对象。 - -### 方法分类 - -| 类型 | 方法 | 使用方式 | 推荐度 | -|------|------|----------|--------| -| **黑盒方法(推荐)** | | | | -| 中间件 | `GetMiddlewareChain()` | 直接使用,可Append自定义中间件 | ⭐⭐⭐ | -| 日志 | `LogInfo()`, `LogError()` 等 | 直接调用,无需获取logger对象 | ⭐⭐⭐ | -| Redis | `RedisSet()`, `RedisGet()` 等 | 直接调用,覆盖常用操作 | ⭐⭐⭐ | -| 邮件 | `SendEmail()` | 直接调用 | ⭐⭐⭐ | -| 短信 | `SendSMS()` | 直接调用 | ⭐⭐⭐ | -| 存储 | `UploadFile()`, `GetFileURL()` | 直接调用 | ⭐⭐⭐ | -| Excel导出 | `ExportToExcel()`, `ExportToExcelFile()` | 直接调用 | ⭐⭐⭐ | -| **Get方法(高级功能)** | | | | -| 数据库 | `GetDatabase()` | 返回GORM对象,用于复杂查询 | ⭐⭐ | -| Redis高级 | `GetRedisClient()` | 返回Redis客户端,用于Hash/List/Set等 | ⭐ | -| Logger高级 | `GetLogger()` | 返回Logger对象,用于Close等 | ⭐ | -| 存储高级 | `GetStorage()` | 返回Storage对象,用于Delete/Exists/GetObject等 | ⭐ | - -### 使用示例 - -```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@v2.0.0 ``` -**详细配置说明请参考 [SETUP.md](./SETUP.md)** +## 设计概要 -**遇到问题?请查看 [故障排除指南](./TROUBLESHOOTING.md)** +- **Factory**:入口,启动时初始化一次,按需 getter 获取模块对象(DB、Redis、Logger 等) +- **各模块包**:能力在对象方法上(`log.Info()`、`store.Upload()`) +- **http 包**:统一 HTTP 出参(Response / PageData / Handler) +- **migration**:独立 CLI 或 Factory 执行 SQL 迁移 +- **tools**:无状态工具函数,直接 import -### 2. 安装模块 +## 功能模块 -```bash -# 安装最新版本(推荐用于开发) -go get git.toowon.com/jimmy/go-common@latest +| 模块 | 包路径 | 说明 | +|------|--------|------| +| 配置 | `config` | JSON 配置加载 | +| 工厂 | `factory` | 统一入口与 lazy getter | +| HTTP | `http` | 请求解析、统一响应 | +| 中间件 | `middleware` | CORS、日志、Recovery、限流、语种、时区 | +| 工具 | `tools` | 时间、加密、金额、类型转换 | +| 日志 | `logger` | 异步日志 | +| 存储 | `storage` | Local / OSS / MinIO | +| 邮件 / 短信 | `email` / `sms` | SMTP、阿里云短信 | +| Excel | `excel` | 数据导出 | +| 国际化 | `i18n` | 多语言消息 | +| 迁移 | `migration` | SQL 版本管理 | -# 安装特定版本(推荐用于生产) -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") - -// Excel导出 -columns := []factory.ExportColumn{ - {Header: "ID", Field: "ID", Width: 10}, - {Header: "姓名", Field: "Name", Width: 20}, - {Header: "邮箱", Field: "Email", Width: 30}, -} -fac.ExportToExcelFile("users.xlsx", "用户列表", columns, users) - -// 数据库(高级功能) -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)) -``` - -### 日期时间 -**推荐方式:通过 factory 使用(黑盒模式)** - -```go -import "git.toowon.com/jimmy/go-common/factory" - -fac, _ := factory.NewFactoryFromFile("config.json") -now := fac.Now("Asia/Shanghai") -str := fac.FormatDateTime(now) -``` - -**或者直接使用 tools 包:** - -```go -import "git.toowon.com/jimmy/go-common/tools" - -tools.SetDefaultTimeZone(tools.AsiaShanghai) -now := tools.Now() -str := tools.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 +| 文档 | 说明 | +|------|------| +| [INTEGRATION.md](./INTEGRATION.md) | 业务项目对接操作手册 | +| [VERSION.md](./VERSION.md) | 版本管理与发布 | +| [templates/](./templates/) | migrate 等脚手架模板 | +| [config/example.json](./config/example.json) | 配置文件示例 | +| [examples/](./examples/) | 代码示例 | ## 许可证 MIT License - -## 联系方式 - -- 作者:Jimmy -- 邮箱:jimmy@toowon.com -- 项目地址:git.toowon.com/jimmy/go-common - ---- - -⭐ 如果这个项目对你有帮助,请给个 Star! - diff --git a/SETUP.md b/SETUP.md deleted file mode 100644 index bf255a4..0000000 --- a/SETUP.md +++ /dev/null @@ -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 - # 如果是 zsh(macOS 默认) - 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 文件配置 -``` - -### 常见问题 - -#### 问题1:go get 失败,提示找不到模块 - -**解决方案:** -1. 确认 GOPRIVATE 已正确配置 -2. 确认 Git 认证已配置 -3. 尝试手动克隆仓库验证: - ```bash - git clone git@git.toowon.com:jimmy/go-common.git - ``` - -#### 问题2:go mod download 失败 - -**解决方案:** -```bash -# 清除模块缓存 -go clean -modcache - -# 重新下载 -go mod download -``` - -#### 问题3:IDE 无法识别模块 - -**解决方案:** -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) - diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md deleted file mode 100644 index 9b186ae..0000000 --- a/TROUBLESHOOTING.md +++ /dev/null @@ -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. 联系项目维护者 - diff --git a/VERSION.md b/VERSION.md index 0ea50e6..c4ca366 100644 --- a/VERSION.md +++ b/VERSION.md @@ -121,16 +121,23 @@ go get -u=minor git.toowon.com/jimmy/go-common ## 当前版本 -当前版本:**v1.0.0** +当前版本:**v2.0.0** ## 版本历史 -- **v1.0.0** (当前版本) +- **v2.0.0** (当前版本,Breaking) + - Factory 精简为 `Init` / `Default()` + lazy getter,删除全部透传方法 + - HTTP 出参统一由 `http.Handler` 负责,删除包级 `Success` / `SystemError` + - Logger API 精简为 `Debug/Info/Error(msg, fields)`,新增 Request ID + `FromContext` + - email / sms 新增异步队列 + `Close()` + - 中间件链默认顺序:Recovery → RequestID → Logging → … + +- **v1.0.0** - 初始版本 - 包含所有基础工具类:migration、datetime、http、middleware、config、storage、email、sms、factory、logger - **v1.1.0** (未发布) - - storage:新增本地文件夹存储(LocalStorage),支持将文件/图片上传到本地目录 - - config:新增 `localStorage` 配置段(`baseDir` / `publicURL`) - - factory:新增 `GetStorage()`,并支持 Local/MinIO/OSS 自动选择(优先级:Local > MinIO > OSS) + - storage:新增本地文件夹存储(LocalStorage) + - config:新增 `localStorage` 配置段 + - factory:支持 Local/MinIO/OSS 自动选择 diff --git a/config/config.go b/config/config.go index e27bef2..2ee2869 100644 --- a/config/config.go +++ b/config/config.go @@ -18,9 +18,16 @@ type Config struct { Email *EmailConfig `json:"email"` SMS *SMSConfig `json:"sms"` Logger *LoggerConfig `json:"logger"` + I18n *I18nConfig `json:"i18n"` RateLimit *RateLimitConfig `json:"rateLimit"` } +// I18nConfig 国际化配置 +type I18nConfig struct { + DefaultLang string `json:"defaultLang"` + LocalesDir string `json:"localesDir"` +} + // LocalStorageConfig 本地存储配置 // 用于将文件保存到本地文件夹(适合开发环境、单机部署等场景) type LocalStorageConfig struct { @@ -208,6 +215,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 短信配置(阿里云短信) @@ -232,11 +256,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 @@ -251,16 +292,21 @@ 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 +} + // RateLimitConfig 限流配置 type RateLimitConfig struct { // Enable 是否启用限流 @@ -384,13 +430,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 } } @@ -400,7 +452,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 } } @@ -412,6 +470,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" } // 限流默认值 @@ -475,6 +541,11 @@ func (c *Config) GetLogger() *LoggerConfig { return c.Logger } +// GetI18n 获取国际化配置 +func (c *Config) GetI18n() *I18nConfig { + return c.I18n +} + // GetDatabaseDSN 获取数据库连接字符串 func (c *Config) GetDatabaseDSN() (string, error) { if c.Database == nil { @@ -549,3 +620,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 +} diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 0685527..0000000 --- a/docs/README.md +++ /dev/null @@ -1,150 +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) - 阿里云短信发送 -- [Excel导出工具](./excel.md) - 数据导出到Excel文件 -- [工厂工具](./factory.md) - 从配置直接创建已初始化客户端对象 -- [日志工具](./logger.md) - 统一的日志记录功能 -- [国际化工具](./i18n.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) - -#### 日期转换 - -**推荐方式:通过 factory 使用(黑盒模式)** - -```go -import "git.toowon.com/jimmy/go-common/factory" - -fac, _ := factory.NewFactoryFromFile("config.json") -now := fac.Now("Asia/Shanghai") -str := fac.FormatDateTime(now) -``` - -**或者直接使用 tools 包:** - -```go -import "git.toowon.com/jimmy/go-common/tools" - -tools.SetDefaultTimeZone(tools.AsiaShanghai) -now := tools.Now() -str := tools.FormatDateTime(now) -``` - -#### HTTP响应(Factory黑盒模式,推荐) - -```go -import ( - "net/http" - "git.toowon.com/jimmy/go-common/factory" - "git.toowon.com/jimmy/go-common/tools" -) - -func GetUser(w http.ResponseWriter, r *http.Request) { - fac, _ := factory.NewFactoryFromFile("config.json") - - // 获取查询参数(使用类型转换方法) - id := tools.ConvertInt64(r.URL.Query().Get("id"), 0) - - // 返回成功响应 - fac.Success(w, data) -} - -http.HandleFunc("/user", 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 - diff --git a/docs/config.md b/docs/config.md deleted file mode 100644 index 56c1abc..0000000 --- a/docs/config.md +++ /dev/null @@ -1,562 +0,0 @@ -# 配置工具文档 - -## 概述 - -配置工具提供了从外部文件加载和管理应用配置的功能,支持数据库、LocalStorage、OSS、Redis、CORS、MinIO、邮件、短信等常用服务的配置。 - -## 功能特性 - -- 支持从外部JSON文件加载配置 -- 支持数据库配置(MySQL、PostgreSQL、SQLite) -- 支持本地存储配置(LocalStorage,文件上传保存到本地文件夹) -- 支持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" - }, - "localStorage": { - "baseDir": "./uploads", - "publicURL": "http://localhost:8080/file?key={objectKey}" - }, - "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) -} -``` - -### 6.1 获取本地存储配置(LocalStorage) - -```go -localCfg := config.GetLocalStorage() -if localCfg != nil { - fmt.Printf("Local baseDir: %s\n", localCfg.BaseDir) - fmt.Printf("Local publicURL: %s\n", localCfg.PublicURL) -} -``` - -## 配置项说明 - -### 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 | 自定义域名 | - -### LocalStorageConfig 本地存储配置 - -| 字段 | 类型 | 说明 | -|------|------|------| -| BaseDir | string | 本地文件保存根目录(必填) | -| PublicURL | string | 对外访问 URL(可选)。包含 `{objectKey}` 占位符时会替换为 `url.QueryEscape(objectKey)`;不包含时作为 URL 前缀拼接 | - -### 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` - diff --git a/docs/datetime.md b/docs/datetime.md deleted file mode 100644 index 809f910..0000000 --- a/docs/datetime.md +++ /dev/null @@ -1,596 +0,0 @@ -# 日期转换工具文档 - -## 概述 - -日期转换工具提供了丰富的日期时间处理功能,支持时区设定、格式转换、时间计算等常用操作。 - -**重要说明**:日期时间功能位于 `tools` 包中,推荐通过 `factory` 包使用(黑盒模式),也可以直接使用 `tools` 包。 - -## 功能特性 - -- 支持时区设定和转换 -- 支持多种时间格式的解析和格式化 -- 提供常用时间格式常量 -- 支持Unix时间戳转换 -- 提供时间计算功能(添加天数、月数、年数等) -- 提供时间范围获取功能(开始/结束时间) -- 支持将任意时区时间转换为UTC时间(用于数据库存储) - -## 使用方法 - -### 方式1:通过 factory 使用(推荐,黑盒模式) - -```go -import "git.toowon.com/jimmy/go-common/factory" - -// 创建工厂 -fac, _ := factory.NewFactoryFromFile("config.json") - -// 获取当前时间 -now := fac.Now("Asia/Shanghai") - -// 格式化时间 -str := fac.FormatDateTime(now) - -// 解析时间 -t, _ := fac.ParseDateTime("2024-01-01 12:00:00", "Asia/Shanghai") - -// 时间计算 -tomorrow := fac.AddDays(now, 1) -``` - -### 方式2:直接使用 tools 包 - -```go -import "git.toowon.com/jimmy/go-common/tools" - -// 设置默认时区为上海时区 -err := tools.SetDefaultTimeZone(tools.AsiaShanghai) -if err != nil { - log.Fatal(err) -} -``` - -### 2. 获取当前时间 - -**通过 factory:** -```go -// 使用默认时区 -now := fac.Now() - -// 使用指定时区 -now := fac.Now("Asia/Shanghai") -now := fac.Now("America/New_York") -``` - -**直接使用 tools:** -```go -// 使用默认时区 -now := tools.Now() - -// 使用指定时区 -now := tools.Now(tools.AsiaShanghai) -now := tools.Now("America/New_York") -``` - -### 3. 解析时间字符串 - -**通过 factory:** -```go -// 使用默认时区解析 -t, err := fac.ParseDateTime("2024-01-01 12:00:00") - -// 使用指定时区解析 -t, err := fac.ParseDateTime("2024-01-01 12:00:00", "Asia/Shanghai") - -// 解析日期 -t, err := fac.ParseDate("2024-01-01") -``` - -**直接使用 tools:** -```go -// 使用默认时区解析 -t, err := tools.Parse("2006-01-02 15:04:05", "2024-01-01 12:00:00") - -// 使用指定时区解析 -t, err := tools.Parse("2006-01-02 15:04:05", "2024-01-01 12:00:00", tools.AsiaShanghai) - -// 使用常用格式解析 -t, err := tools.ParseDateTime("2024-01-01 12:00:00") -t, err := tools.ParseDate("2024-01-01") -``` - -### 4. 格式化时间 - -**通过 factory:** -```go -t := time.Now() - -// 使用默认时区格式化 -str := fac.FormatDateTime(t) -str := fac.FormatDate(t) -str := fac.FormatTime(t) - -// 使用指定时区格式化 -str := fac.FormatDateTime(t, "Asia/Shanghai") -``` - -**直接使用 tools:** -```go -t := time.Now() - -// 使用默认时区格式化 -str := tools.Format(t, "2006-01-02 15:04:05") - -// 使用指定时区格式化 -str := tools.Format(t, "2006-01-02 15:04:05", tools.AsiaShanghai) - -// 使用常用格式 -str := tools.FormatDateTime(t) // "2006-01-02 15:04:05" -str := tools.FormatDate(t) // "2006-01-02" -str := tools.FormatTime(t) // "15:04:05" -``` - -### 5. 时区转换 - -**通过 factory:** -```go -t := time.Now() -t2, err := tools.ToTimezone(t, "Asia/Shanghai") -``` - -**直接使用 tools:** -```go -t := time.Now() -t2, err := tools.ToTimezone(t, tools.AsiaShanghai) -``` - -### 6. Unix时间戳转换 - -**通过 factory:** -```go -t := time.Now() - -// 转换为Unix时间戳(秒) -unix := fac.ToUnix(t) - -// 从Unix时间戳创建时间 -t2 := fac.FromUnix(unix) - -// 转换为Unix毫秒时间戳 -unixMilli := fac.ToUnixMilli(t) - -// 从Unix毫秒时间戳创建时间 -t3 := fac.FromUnixMilli(unixMilli) -``` - -**直接使用 tools:** -```go -t := time.Now() - -// 转换为Unix时间戳(秒) -unix := tools.ToUnix(t) - -// 从Unix时间戳创建时间 -t2 := tools.FromUnix(unix) - -// 转换为Unix毫秒时间戳 -unixMilli := tools.ToUnixMilli(t) - -// 从Unix毫秒时间戳创建时间 -t3 := tools.FromUnixMilli(unixMilli) -``` - -### 7. 时间计算 - -**通过 factory:** -```go -t := time.Now() - -// 添加天数 -t1 := fac.AddDays(t, 7) - -// 添加月数 -t2 := fac.AddMonths(t, 1) - -// 添加年数 -t3 := fac.AddYears(t, 1) -``` - -**直接使用 tools:** -```go -t := time.Now() - -// 添加天数 -t1 := tools.AddDays(t, 7) - -// 添加月数 -t2 := tools.AddMonths(t, 1) - -// 添加年数 -t3 := tools.AddYears(t, 1) -``` - -### 8. 时间范围获取 - -**通过 factory:** -```go -t := time.Now() - -// 获取一天的开始时间(00:00:00) -start := fac.StartOfDay(t) - -// 获取一天的结束时间(23:59:59.999999999) -end := fac.EndOfDay(t) - -// 获取月份的开始时间 -monthStart := fac.StartOfMonth(t) - -// 获取月份的结束时间 -monthEnd := fac.EndOfMonth(t) - -// 获取年份的开始时间 -yearStart := fac.StartOfYear(t) - -// 获取年份的结束时间 -yearEnd := fac.EndOfYear(t) -``` - -**直接使用 tools:** -```go -t := time.Now() - -// 获取一天的开始时间(00:00:00) -start := tools.StartOfDay(t) - -// 获取一天的结束时间(23:59:59.999999999) -end := tools.EndOfDay(t) - -// 获取月份的开始时间 -monthStart := tools.StartOfMonth(t) - -// 获取月份的结束时间 -monthEnd := tools.EndOfMonth(t) - -// 获取年份的开始时间 -yearStart := tools.StartOfYear(t) - -// 获取年份的结束时间 -yearEnd := tools.EndOfYear(t) -``` - -### 9. 时间差计算 - -**通过 factory:** -```go -t1 := time.Now() -t2 := time.Now().Add(24 * time.Hour) - -// 计算天数差 -days := fac.DiffDays(t1, t2) - -// 计算小时差 -hours := fac.DiffHours(t1, t2) - -// 计算分钟差 -minutes := fac.DiffMinutes(t1, t2) - -// 计算秒数差 -seconds := fac.DiffSeconds(t1, t2) -``` - -**直接使用 tools:** -```go -t1 := time.Now() -t2 := time.Now().Add(24 * time.Hour) - -// 计算天数差 -days := tools.DiffDays(t1, t2) - -// 计算小时差 -hours := tools.DiffHours(t1, t2) - -// 计算分钟差 -minutes := tools.DiffMinutes(t1, t2) - -// 计算秒数差 -seconds := tools.DiffSeconds(t1, t2) -``` - -### 10. 转换为UTC时间(用于数据库存储) - -**直接使用 tools(factory 暂未提供):** -```go -// 将任意时区的时间转换为UTC -t := time.Now() // 当前时区的时间 -utcTime := tools.ToUTC(t) - -// 从指定时区转换为UTC -t, _ := tools.ParseDateTime("2024-01-01 12:00:00", tools.AsiaShanghai) -utcTime, err := tools.ToUTCFromTimezone(t, tools.AsiaShanghai) - -// 解析时间字符串并直接转换为UTC -utcTime, err := tools.ParseDateTimeToUTC("2024-01-01 12:00:00", tools.AsiaShanghai) - -// 解析日期并转换为UTC(当天的00:00:00 UTC) -utcTime, err := tools.ParseDateToUTC("2024-01-01", tools.AsiaShanghai) -``` - -## API 参考 - -### 时区常量 - -**通过 tools 包使用:** - -```go -import "git.toowon.com/jimmy/go-common/tools" - -const ( - tools.UTC = "UTC" - tools.AsiaShanghai = "Asia/Shanghai" - tools.AmericaNewYork = "America/New_York" - tools.EuropeLondon = "Europe/London" - tools.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 -``` - -### 主要函数 - -**注意**:以下函数可以通过 `factory` 或 `tools` 包调用。推荐使用 `factory` 的黑盒模式。 - -#### SetDefaultTimeZone(timezone string) error - -设置默认时区(仅 tools 包提供)。 - -**参数:** -- `timezone`: 时区字符串,如 "Asia/Shanghai" - -**返回:** 错误信息 - -**使用方式:** -```go -tools.SetDefaultTimeZone(tools.AsiaShanghai) -``` - -#### Now(timezone ...string) time.Time - -获取当前时间。 - -**参数:** -- `timezone`: 可选,时区字符串,不指定则使用默认时区 - -**返回:** 时间对象 - -**使用方式:** -```go -// 通过 factory -now := fac.Now("Asia/Shanghai") - -// 直接使用 tools -now := tools.Now(tools.AsiaShanghai) -``` - -#### ParseDateTime(value string, timezone ...string) (time.Time, error) - -解析日期时间字符串(2006-01-02 15:04:05)。 - -**参数:** -- `value`: 时间字符串 -- `timezone`: 可选,时区字符串 - -**返回:** 时间对象和错误信息 - -**使用方式:** -```go -// 通过 factory -t, err := fac.ParseDateTime("2024-01-01 12:00:00", "Asia/Shanghai") - -// 直接使用 tools -t, err := tools.ParseDateTime("2024-01-01 12:00:00", tools.AsiaShanghai) -``` - -#### FormatDateTime(t time.Time, timezone ...string) string - -格式化日期时间(2006-01-02 15:04:05)。 - -**参数:** -- `t`: 时间对象 -- `timezone`: 可选,时区字符串 - -**返回:** 格式化后的时间字符串 - -**使用方式:** -```go -// 通过 factory -str := fac.FormatDateTime(t, "Asia/Shanghai") - -// 直接使用 tools -str := tools.FormatDateTime(t, tools.AsiaShanghai) -``` - -更多函数请参考 `factory` 包或 `tools` 包的 API 文档。 - -### 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:通过 factory 使用(推荐) - -```go -package main - -import ( - "fmt" - "log" - - "git.toowon.com/jimmy/go-common/factory" -) - -func main() { - // 创建工厂 - fac, err := factory.NewFactoryFromFile("config.json") - if err != nil { - log.Fatal(err) - } - - // 获取当前时间 - now := fac.Now("Asia/Shanghai") - fmt.Printf("Current time: %s\n", fac.FormatDateTime(now)) - - // 解析时间 - t, err := fac.ParseDateTime("2024-01-01 12:00:00", "Asia/Shanghai") - if err != nil { - log.Fatal(err) - } - - // 格式化时间 - fmt.Printf("Parsed time: %s\n", fac.FormatDateTime(t)) - - // 时间计算 - tomorrow := fac.AddDays(now, 1) - fmt.Printf("Tomorrow: %s\n", fac.FormatDate(tomorrow)) -} -``` - -### 示例2:直接使用 tools 包 - -```go -package main - -import ( - "fmt" - "log" - - "git.toowon.com/jimmy/go-common/tools" -) - -func main() { - // 设置默认时区 - err := tools.SetDefaultTimeZone(tools.AsiaShanghai) - if err != nil { - log.Fatal(err) - } - - // 获取当前时间 - now := tools.Now() - fmt.Printf("Current time: %s\n", tools.FormatDateTime(now)) - - // 时区转换 - t, _ := tools.ParseDateTime("2024-01-01 12:00:00") - t2, _ := tools.ToTimezone(t, tools.AmericaNewYork) - fmt.Printf("Time in New York: %s\n", tools.FormatDateTime(t2)) -} -``` - -### 示例3:UTC转换(数据库存储场景) - -```go -package main - -import ( - "fmt" - "log" - - "git.toowon.com/jimmy/go-common/tools" -) - -func main() { - // 从请求中获取时间(假设是上海时区) - requestTimeStr := "2024-01-01 12:00:00" - requestTimezone := tools.AsiaShanghai - - // 转换为UTC时间(用于数据库存储) - dbTime, err := tools.ParseDateTimeToUTC(requestTimeStr, requestTimezone) - if err != nil { - log.Fatal(err) - } - - fmt.Printf("Request time (Shanghai): %s\n", requestTimeStr) - fmt.Printf("Database time (UTC): %s\n", tools.FormatDateTime(dbTime, tools.UTC)) - - // 从数据库读取UTC时间,转换为用户时区显示 - userTimezone := tools.AsiaShanghai - displayTime, err := tools.ToTimezone(dbTime, userTimezone) - if err != nil { - log.Fatal(err) - } - fmt.Printf("Display time (Shanghai): %s\n", tools.FormatDateTime(displayTime, userTimezone)) -} -``` - -完整示例请参考 `factory` 包中的 datetime 相关方法,通过 `factory` 调用 `tools` 包中的 datetime 功能。 - diff --git a/docs/email.md b/docs/email.md deleted file mode 100644 index bb28d5a..0000000 --- a/docs/email.md +++ /dev/null @@ -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 - - - -

邮件内容

-

这是由外部构建的完整邮件内容

- - -`) - -// 发送邮件(工具只负责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邮件

- - -` - -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正文

", -} - -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端口:通常使用TLS(STARTTLS) - - 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` - diff --git a/docs/excel.md b/docs/excel.md deleted file mode 100644 index 28ba773..0000000 --- a/docs/excel.md +++ /dev/null @@ -1,447 +0,0 @@ -# Excel导出工具文档 - -## 概述 - -Excel导出工具提供了将数据导出到Excel文件的功能,支持结构体切片、自定义格式化、多工作表等特性。通过工厂模式,外部项目可以方便地使用Excel导出功能。 - -## 功能特性 - -- **黑盒模式**:提供直接调用的方法,无需获取Excel对象 -- **延迟初始化**:Excel导出器在首次使用时才创建 -- **支持结构体切片**:自动将结构体切片转换为Excel行数据 -- **支持嵌套字段**:支持访问嵌套结构体字段(如 "User.Name") -- **自定义格式化**:支持自定义字段值的格式化函数 -- **自动列宽**:自动调整列宽以适应内容 -- **表头样式**:自动应用表头样式(加粗、背景色等) -- **智能工作表管理**:自动处理工作表的创建和删除,避免产生空sheet -- **ExportData接口**:支持实现ExportData接口进行高级定制 -- **空数据处理**:即使数据为空(nil或空切片),也会正常生成表头 -- **统一接口**:只暴露 `ExportToWriter` 一个核心方法 - -## 使用方法 - -### 1. 创建工厂 - -```go -import "git.toowon.com/jimmy/go-common/factory" - -// 从配置文件创建 -fac, err := factory.NewFactoryFromFile("./config.json") - -// 或Excel导出不需要配置,可以传nil -fac := factory.NewFactory(nil) -``` - -### 2. 导出结构体切片到文件 - -```go -// 定义结构体 -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"` -} - -// 准备数据 -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: 1}, -} - -// 定义导出列 -columns := []factory.ExportColumn{ - {Header: "ID", Field: "ID", Width: 10}, - {Header: "姓名", Field: "Name", Width: 20}, - {Header: "邮箱", Field: "Email", Width: 30}, - {Header: "创建时间", Field: "CreatedAt", Width: 20}, - {Header: "状态", Field: "Status", Width: 10}, -} - -// 导出到文件 -err := fac.ExportToExcelFile("users.xlsx", "用户列表", columns, users) -if err != nil { - log.Fatal(err) -} -``` - -### 3. 导出到HTTP响应 - -```go -import "net/http" - -func exportUsersHandler(w http.ResponseWriter, r *http.Request) { - fac, _ := factory.NewFactoryFromFile("./config.json") - - 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: 1}, - } - - columns := []factory.ExportColumn{ - {Header: "ID", Field: "ID"}, - {Header: "姓名", Field: "Name"}, - {Header: "邮箱", Field: "Email"}, - } - - // 设置HTTP响应头 - w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") - w.Header().Set("Content-Disposition", "attachment; filename=users.xlsx") - - // 导出到响应 - err := fac.ExportToExcel(w, "用户列表", columns, users) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } -} -``` - -### 4. 使用格式化函数 - -```go -import "git.toowon.com/jimmy/go-common/excel" - -columns := []factory.ExportColumn{ - {Header: "ID", Field: "ID", Width: 10}, - {Header: "姓名", Field: "Name", Width: 20}, - { - Header: "创建时间", - Field: "CreatedAt", - Width: 20, - Format: excel.AdaptTimeFormatter(tools.FormatDateTime), // 使用适配器直接调用tools函数 - }, - { - Header: "状态", - Field: "Status", - Width: 10, - Format: func(value interface{}) string { - // 自定义格式化函数 - if status, ok := value.(int); ok { - if status == 1 { - return "启用" - } - return "禁用" - } - return "" - }, - }, -} - -err := fac.ExportToExcelFile("users.xlsx", "用户列表", columns, users) -``` - -### 5. 使用ExportData接口(高级功能) - -```go -// 实现ExportData接口 -type UserExportData struct { - users []User -} - -// GetExportColumns 获取导出列定义 -func (d *UserExportData) GetExportColumns() []excel.ExportColumn { - return []excel.ExportColumn{ - {Header: "ID", Field: "ID", Width: 10}, - {Header: "姓名", Field: "Name", Width: 20}, - {Header: "邮箱", Field: "Email", Width: 30}, - } -} - -// GetExportRows 获取导出数据行 -func (d *UserExportData) GetExportRows() [][]interface{} { - rows := make([][]interface{}, 0, len(d.users)) - for _, user := range d.users { - row := []interface{}{ - user.ID, - user.Name, - user.Email, - } - rows = append(rows, row) - } - return rows -} - -// 使用 -exportData := &UserExportData{users: users} -columns := exportData.GetExportColumns() -err := fac.ExportToExcelFile("users.xlsx", "用户列表", columns, exportData) -``` - -### 6. 获取Excel对象(高级功能) - -```go -// 获取Excel导出器对象(仅在需要高级功能时使用) -excel, err := fac.GetExcel() -if err != nil { - log.Fatal(err) -} - -// 获取excelize.File对象,用于高级操作 -file := excel.GetFile() - -// 创建多个工作表 -file.NewSheet("Sheet2") -file.SetCellValue("Sheet2", "A1", "数据") - -// 自定义样式 -style, _ := file.NewStyle(&excelize.Style{ - Font: &excelize.Font{ - Bold: true, - Size: 14, - }, -}) -file.SetCellStyle("Sheet2", "A1", "A1", style) -``` - -## API 参考 - -### 工厂方法 - -#### ExportToExcel(w io.Writer, sheetName string, columns []ExportColumn, data interface{}) error - -导出数据到Writer。 - -**参数:** -- `w`: Writer对象(如http.ResponseWriter) -- `sheetName`: 工作表名称(可选,默认为"Sheet1") -- `columns`: 列定义 -- `data`: 数据列表(可以是结构体切片或实现了ExportData接口的对象) - -**返回:** 错误信息 - -**数据为空处理:** -- 支持 `nil`、空切片、指针类型等空数据情况 -- 即使数据为空,表头也会正常生成 - -**工作表处理逻辑:** -- 如果 `sheetName` 为空,默认使用 "Sheet1" -- 如果指定的工作表不存在,会自动创建 -- 使用自定义名称时会自动删除默认的"Sheet1",避免产生空sheet - -**示例:** -```go -fac.ExportToExcel(w, "用户列表", columns, users) -fac.ExportToExcel(w, "空数据", columns, []User{}) // 空数据也会生成表头 -``` - -#### ExportToExcelFile(filePath string, sheetName string, columns []ExportColumn, data interface{}) error - -导出数据到文件。 - -**参数:** -- `filePath`: 文件路径 -- `sheetName`: 工作表名称(可选,默认为"Sheet1") -- `columns`: 列定义 -- `data`: 数据列表(可以是结构体切片或实现了ExportData接口的对象) - -**返回:** 错误信息 - -**实现说明:** -- 此方法内部创建文件并调用 `ExportToWriter` -- 文件相关的封装由工厂方法处理 - -**工作表处理逻辑:** -- 如果 `sheetName` 为空,默认使用 "Sheet1" -- 如果指定的工作表不存在,会自动创建 -- 使用自定义名称时会自动删除默认的"Sheet1",避免产生空sheet - -**示例:** -```go -fac.ExportToExcelFile("users.xlsx", "用户列表", columns, users) -fac.ExportToExcelFile("empty.xlsx", "空数据", columns, []User{}) // 空数据也会生成表头 -``` - -### 高级方法 - -#### GetExcel() (*excel.Excel, error) - -获取Excel导出器对象。 - -**返回:** Excel导出器对象和错误信息 - -**说明:** 仅在需要使用高级功能时使用,推荐使用黑盒方法 - -### 结构体类型 - -#### ExportColumn - -导出列定义。 - -```go -type ExportColumn struct { - Header string // 表头名称 - Field string // 数据字段名(支持嵌套字段,如 "User.Name") - Width float64 // 列宽(可选,0表示自动) - Format func(interface{}) string // 格式化函数(可选) -} -``` - -**字段说明:** -- `Header`: 表头显示的名称 -- `Field`: 数据字段名,支持嵌套字段(如 "User.Name") -- `Width`: 列宽,0表示自动调整 -- `Format`: 格式化函数,用于自定义字段值的显示格式 - -### 格式化函数适配器 - -#### excel.AdaptTimeFormatter(fn func(time.Time, ...string) string) func(interface{}) string - -适配器函数:将tools包的格式化函数转换为Excel Format字段需要的函数类型。 - -**参数:** -- `fn`: tools包的格式化函数(如 `tools.FormatDate`、`tools.FormatDateTime` 等) - -**返回:** Excel Format字段需要的格式化函数 - -**说明:** -- 允许直接使用tools包的任何格式化函数 - -**示例:** -```go -import ( - "git.toowon.com/jimmy/go-common/excel" - "git.toowon.com/jimmy/go-common/tools" -) - -// 使用tools.FormatDate -Format: excel.AdaptTimeFormatter(tools.FormatDate) - -// 使用tools.FormatDateTime -Format: excel.AdaptTimeFormatter(tools.FormatDateTime) - -// 使用tools.FormatTime -Format: excel.AdaptTimeFormatter(tools.FormatTime) - -// 使用自定义格式化函数 -Format: excel.AdaptTimeFormatter(func(t time.Time) string { - return tools.Format(t, "2006-01-02 15:04:05", "Asia/Shanghai") -}) -``` - -### ExportData接口 - -实现此接口的结构体可以直接导出。 - -```go -type ExportData interface { - // GetExportColumns 获取导出列定义 - GetExportColumns() []ExportColumn - // GetExportRows 获取导出数据行 - GetExportRows() [][]interface{} -} -``` - -## 完整示例 - -```go -package main - -import ( - "net/http" - "time" - - "git.toowon.com/jimmy/go-common/excel" - "git.toowon.com/jimmy/go-common/factory" -) - -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 exportUsersHandler(w http.ResponseWriter, r *http.Request) { - fac, _ := factory.NewFactoryFromFile("./config.json") - - // 从数据库或其他数据源获取数据 - 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: 1}, - } - - // 定义导出列 - columns := []factory.ExportColumn{ - {Header: "ID", Field: "ID", Width: 10}, - {Header: "姓名", Field: "Name", Width: 20}, - {Header: "邮箱", Field: "Email", Width: 30}, - { - Header: "创建时间", - Field: "CreatedAt", - Width: 20, - Format: excel.AdaptTimeFormatter(tools.FormatDateTime), - }, - { - Header: "状态", - Field: "Status", - Width: 10, - Format: func(value interface{}) string { - if status, ok := value.(int); ok { - if status == 1 { - return "启用" - } - return "禁用" - } - return "" - }, - }, - } - - // 设置HTTP响应头 - w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") - w.Header().Set("Content-Disposition", "attachment; filename=users.xlsx") - - // 导出到响应 - err := fac.ExportToExcel(w, "用户列表", columns, users) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } -} - -func main() { - http.HandleFunc("/export/users", exportUsersHandler) - http.ListenAndServe(":8080", nil) -} -``` - -## 设计优势 - -1. **降低复杂度**:调用方无需关心Excel文件对象的创建和管理 -2. **延迟初始化**:Excel导出器在首次使用时才创建 -3. **统一接口**:所有操作通过工厂方法调用 -4. **灵活扩展**:支持结构体切片、自定义格式化、ExportData接口等多种方式 -5. **自动优化**:自动调整列宽、应用表头样式等 - -## 注意事项 - -1. **配置**:Excel导出不需要配置,可以传nil创建工厂 -2. **错误处理**:所有方法都可能返回错误,需要正确处理 -3. **延迟初始化**:Excel导出器在首次使用时才创建,首次调用可能稍慢 -4. **字段名匹配**:Field字段名必须与结构体字段名匹配(区分大小写) -5. **嵌套字段**:支持嵌套字段访问(如 "User.Name"),但需要确保字段路径正确 -6. **格式化函数**:格式化函数返回的字符串会直接写入Excel单元格 -7. **列宽设置**:Width为0时会自动调整列宽,但可能影响性能(大数据量时建议设置固定宽度) -8. **工作表处理**:工具会自动处理工作表的创建和删除,确保不会产生空sheet -9. **空数据处理**:即使数据为 `nil` 或空切片,表头也会正常生成 -10. **方法设计**: - - `excel` 包只暴露 `ExportToWriter` 一个核心方法 - - 文件相关的封装由工厂方法 `ExportToExcelFile` 处理 - -## 最佳实践 - -1. **使用工厂方法**:推荐使用 `ExportToExcel()` 和 `ExportToExcelFile()` -2. **设置列宽**:对于大数据量,建议设置固定列宽以提高性能 -3. **使用格式化函数**:对于日期时间、状态等字段,使用格式化函数提高可读性 -4. **错误处理**:始终检查导出方法的返回值 -5. **HTTP响应**:导出到HTTP响应时,记得设置正确的Content-Type和Content-Disposition头 -6. **工作表命名**:推荐使用有意义的工作表名称,工具会自动处理工作表的创建和删除 -7. **空数据场景**:即使查询结果为空,也可以导出包含表头的Excel文件 - -## 示例 - -完整示例请参考 `examples/excel_example.go` - diff --git a/docs/factory.md b/docs/factory.md deleted file mode 100644 index cbabe35..0000000 --- a/docs/factory.md +++ /dev/null @@ -1,1251 +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)` | -| **Excel导出** | `ExportToExcel()`, `ExportToExcelFile()` | `fac.ExportToExcelFile("users.xlsx", "用户列表", columns, users)` | -| **日期时间** | `Now()`, `ParseDateTime()`, `FormatDateTime()` 等 | `fac.Now("Asia/Shanghai")` | -| **时间工具** | `GetTimestamp()`, `IsToday()`, `GetBeginOfWeek()` 等 | `fac.GetTimestamp()` | -| **加密工具** | `HashPassword()`, `MD5()`, `SHA256()`, `GenerateSMSCode()` 等 | `fac.HashPassword("password")` | -| **金额计算** | `YuanToCents()`, `CentsToYuan()`, `FormatYuan()` | `fac.YuanToCents(100.5)` | -| **版本信息** | `GetVersion()` | `fac.GetVersion()` | -| **HTTP响应** | `Success()`, `Error()`, `SuccessPage()` | `fac.Success(w, data)` | -| **HTTP请求** | `ParseJSON()`, `ConvertInt()`, `GetTimezone()` 等 | `fac.ParseJSON(r, &req)` | - -### 🔧 高级功能:Get方法(仅在必要时使用) - -返回客户端对象,用于复杂操作: - -| 方法 | 返回类型 | 使用场景 | -|------|----------|----------| -| `GetDatabase()` | `*gorm.DB` | 数据库复杂查询、事务、关联查询等 | -| `GetRedisClient()` | `*redis.Client` | Hash、List、Set、ZSet、Pub/Sub等高级操作 | -| `GetExcel()` | `*excel.Excel` | 多工作表、自定义样式、图表等高级操作 | -| `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"}, - "验证码", - "纯文本内容", - "

HTML内容

", -) -``` - -### 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) - -// 获取临时访问URL(1小时后过期) -url, _ := fac.GetFileURL("images/test.jpg", 3600) -``` - -### 6. Excel导出(黑盒模式,推荐) - -```go -import ( - "net/http" - "time" - "git.toowon.com/jimmy/go-common/excel" -) - -// 定义结构体 -type User struct { - ID int `json:"id"` - Name string `json:"name"` - Email string `json:"email"` - CreatedAt time.Time `json:"created_at"` -} - -// 准备数据 -users := []User{ - {ID: 1, Name: "Alice", Email: "alice@example.com", CreatedAt: time.Now()}, - {ID: 2, Name: "Bob", Email: "bob@example.com", CreatedAt: time.Now()}, -} - -// 定义导出列 -columns := []factory.ExportColumn{ - {Header: "ID", Field: "ID", Width: 10}, - {Header: "姓名", Field: "Name", Width: 20}, - {Header: "邮箱", Field: "Email", Width: 30}, - { - Header: "创建时间", - Field: "CreatedAt", - Width: 20, - Format: excel.AdaptTimeFormatter(tools.FormatDateTime), - }, -} - -// 导出到文件 -err := fac.ExportToExcelFile("users.xlsx", "用户列表", columns, users) - -// 导出到HTTP响应 -func exportUsersHandler(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") - w.Header().Set("Content-Disposition", "attachment; filename=users.xlsx") - fac.ExportToExcel(w, "用户列表", columns, users) -} -``` - -### 7. 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") -``` - -### 8. 数据库操作(黑盒模式) - -```go -// 获取数据库对象(已初始化,黑盒模式) -db, err := fac.GetDatabase() -if err != nil { - log.Fatal(err) -} - -// 直接使用GORM,无需自己实现创建逻辑 -var users []User -db.Find(&users) -db.Create(&user) -``` - -### 9. 日期时间操作(黑盒模式) - -```go -// 获取当前时间 -now := fac.Now("Asia/Shanghai") - -// 解析时间 -t, _ := fac.ParseDateTime("2024-01-01 12:00:00", "Asia/Shanghai") - -// 格式化时间 -str := fac.FormatDateTime(now) - -// 时间计算 -tomorrow := fac.AddDays(now, 1) -startOfDay := fac.StartOfDay(now, "Asia/Shanghai") -endOfDay := fac.EndOfDay(now, "Asia/Shanghai") - -// Unix时间戳 -unix := fac.ToUnix(now) -t2 := fac.FromUnix(unix, "Asia/Shanghai") -``` - -### 10. 时间操作(黑盒模式) - -```go -// 时间戳 -timestamp := fac.GetTimestamp() // 当前时间戳(秒) -millisTimestamp := fac.GetMillisTimestamp() // 当前时间戳(毫秒) -utcTimestamp := fac.GetUTCTimestamp() // UTC时间戳 - -// 自定义格式格式化 -str := fac.FormatTimeWithLayout(now, "2006年01月02日 15:04:05") - -// 自定义格式解析 -t, _ := fac.ParseTime("2024-01-01 12:00:00", "2006-01-02 15:04:05") - -// 时间计算(补充) -nextHour := fac.AddHours(now, 1) -nextMinute := fac.AddMinutes(now, 30) - -// 周相关 -beginOfWeek := fac.GetBeginOfWeek(now) -endOfWeek := fac.GetEndOfWeek(now) - -// 时间判断 -if fac.IsToday(t) { - fmt.Println("是今天") -} -if fac.IsYesterday(t) { - fmt.Println("是昨天") -} - -// 生成详细时间信息 -timeInfo := fac.GenerateTimeInfoWithTimezone(now, "Asia/Shanghai") -fmt.Printf("UTC: %s, Local: %s, Unix: %d\n", timeInfo.UTC, timeInfo.Local, timeInfo.Unix) -``` - -### 11. 金额计算(黑盒模式) - -```go -// 元转分 -cents := fac.YuanToCents(100.5) // 10050 - -// 分转元 -yuan := fac.CentsToYuan(10050) // 100.5 - -// 格式化显示 -str := fac.FormatYuan(10050) // "100.50" -``` - -### 12. 版本信息(黑盒模式) - -```go -version := fac.GetVersion() -fmt.Println("当前版本:", version) -``` - -### 13. HTTP响应(黑盒模式,推荐) - -```go -import "net/http" - -// 成功响应 -fac.Success(w, data) -fac.Success(w, data, "操作成功") - -// 分页响应 -fac.SuccessPage(w, users, total, page, pageSize) - -// 错误响应 -fac.Error(w, 1001, "用户不存在") -fac.SystemError(w, "系统错误") -``` - -### 14. HTTP请求解析(黑盒模式,推荐) - -```go -import "net/http" - -// 解析JSON -var req UserRequest -fac.ParseJSON(r, &req) - -// 获取查询参数(使用类型转换方法) -id := fac.ConvertInt64(r.URL.Query().Get("id"), 0) -uid := fac.ConvertUint64(r.URL.Query().Get("uid"), 0) -userId := fac.ConvertUint32(r.URL.Query().Get("user_id"), 0) -keyword := r.URL.Query().Get("keyword") // 字符串直接获取 - -// 获取表单参数 -age := fac.ConvertInt(r.FormValue("age"), 0) -isActive := fac.ConvertBool(r.FormValue("is_active"), false) - -// 获取时区(需要配合middleware.Timezone使用) -timezone := fac.GetTimezone(r) -``` - -### 15. 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和错误信息 - -### Excel导出方法(黑盒模式) - -#### ExportToExcel(w io.Writer, sheetName string, columns []ExportColumn, data interface{}) error - -导出数据到Writer。 - -**参数:** -- `w`: Writer对象(如http.ResponseWriter) -- `sheetName`: 工作表名称(可选,默认为"Sheet1") -- `columns`: 列定义 -- `data`: 数据列表(可以是结构体切片或实现了ExportData接口的对象) - -**返回:** 错误信息 - -**示例:** -```go -fac.ExportToExcel(w, "用户列表", columns, users) -``` - -#### ExportToExcelFile(filePath string, sheetName string, columns []ExportColumn, data interface{}) error - -导出数据到文件。 - -**参数:** -- `filePath`: 文件路径 -- `sheetName`: 工作表名称(可选,默认为"Sheet1") -- `columns`: 列定义 -- `data`: 数据列表 - -**返回:** 错误信息 - -**示例:** -```go -fac.ExportToExcelFile("users.xlsx", "用户列表", columns, users) -``` - -#### GetExcel() (*excel.Excel, error) - -获取Excel导出器对象(高级功能时使用)。 - -**返回:** Excel导出器对象和错误信息 - -**说明:** -- 仅在需要使用高级功能时使用 -- 推荐使用黑盒方法:`ExportToExcel()`、`ExportToExcelFile()` - -**详细说明请参考:[Excel导出工具文档](./excel.md)** - -### 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 - -获取配置对象。 - -**返回:** 配置对象 - -### HTTP响应方法(黑盒模式) - -#### Success(w http.ResponseWriter, data interface{}, message ...string) - -发送成功响应。 - -**参数:** -- `w`: HTTP响应写入器 -- `data`: 响应数据 -- `message`: 可选,成功消息(如果不提供,使用默认消息) - -**示例:** -```go -fac.Success(w, user) // 使用默认消息 -fac.Success(w, user, "获取成功") // 自定义消息 -``` - -#### Error(w http.ResponseWriter, code int, message string) - -发送错误响应。 - -**参数:** -- `w`: HTTP响应写入器 -- `code`: 业务错误码 -- `message`: 错误消息 - -#### SystemError(w http.ResponseWriter, message string) - -发送系统错误响应(HTTP 500)。 - -**参数:** -- `w`: HTTP响应写入器 -- `message`: 错误消息 - -#### SuccessPage(w http.ResponseWriter, data interface{}, total int64, page, pageSize int, message ...string) - -发送分页成功响应。 - -**参数:** -- `w`: HTTP响应写入器 -- `data`: 响应数据列表 -- `total`: 总记录数 -- `page`: 当前页码 -- `pageSize`: 每页大小 -- `message`: 可选,成功消息 - -### HTTP请求方法(黑盒模式) - -#### ParseJSON(r *http.Request, v interface{}) error - -解析JSON请求体。 - -**参数:** -- `r`: HTTP请求 -- `v`: 目标结构体指针 - -#### GetTimezone(r *http.Request) string - -从请求的context中获取时区(需要配合middleware.Timezone使用)。 - -### 类型转换方法(黑盒模式) - -#### ConvertInt(value string, defaultValue int) int - -将字符串转换为int类型。 - -**参数:** -- `value`: 待转换的字符串 -- `defaultValue`: 转换失败或字符串为空时返回的默认值 - -**示例:** -```go -// 从查询参数获取整数 -id := fac.ConvertInt(r.URL.Query().Get("id"), 0) - -// 从表单获取整数 -age := fac.ConvertInt(r.FormValue("age"), 0) -``` - -#### ConvertInt64(value string, defaultValue int64) int64 - -将字符串转换为int64类型。 - -**示例:** -```go -id := fac.ConvertInt64(r.URL.Query().Get("id"), 0) -``` - -#### ConvertUint64(value string, defaultValue uint64) uint64 - -将字符串转换为uint64类型。 - -**示例:** -```go -uid := fac.ConvertUint64(r.URL.Query().Get("uid"), 0) -``` - -#### ConvertUint32(value string, defaultValue uint32) uint32 - -将字符串转换为uint32类型。 - -**示例:** -```go -userId := fac.ConvertUint32(r.URL.Query().Get("user_id"), 0) -``` - -#### ConvertBool(value string, defaultValue bool) bool - -将字符串转换为bool类型。 - -**示例:** -```go -isActive := fac.ConvertBool(r.URL.Query().Get("is_active"), false) -``` - -#### ConvertFloat64(value string, defaultValue float64) float64 - -将字符串转换为float64类型。 - -**示例:** -```go -price := fac.ConvertFloat64(r.URL.Query().Get("price"), 0.0) -``` - -### 日期时间工具方法(黑盒模式) - -#### Now(timezone ...string) time.Time - -获取当前时间。 - -**参数:** -- `timezone`: 可选,时区字符串(如 "Asia/Shanghai"),不指定则使用默认时区 - -#### ParseDateTime(value string, timezone ...string) (time.Time, error) - -解析日期时间字符串(格式:2006-01-02 15:04:05)。 - -#### ParseDate(value string, timezone ...string) (time.Time, error) - -解析日期字符串(格式:2006-01-02)。 - -#### FormatDateTime(t time.Time, timezone ...string) string - -格式化日期时间(格式:2006-01-02 15:04:05)。 - -#### FormatDate(t time.Time, timezone ...string) string - -格式化日期(格式:2006-01-02)。 - -#### FormatTime(t time.Time, timezone ...string) string - -格式化时间(格式:15:04:05)。 - -#### 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 - -获取一天的开始时间(00:00:00)。 - -#### EndOfDay(t time.Time, timezone ...string) time.Time - -获取一天的结束时间(23:59:59.999999999)。 - -#### 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 - -计算两个时间之间的秒数差。 - -### 加密工具方法(黑盒模式) - -#### 密码加密 - -##### HashPassword(password string) (string, error) - -使用bcrypt加密密码。 - -**参数:** -- `password`: 原始密码 - -**返回:** 加密后的密码哈希值和错误信息 - -**示例:** -```go -hashedPassword, err := fac.HashPassword("myPassword") -``` - -##### CheckPassword(password, hash string) bool - -验证密码。 - -**参数:** -- `password`: 原始密码 -- `hash`: 加密后的密码哈希值 - -**返回:** 密码是否正确 - -**示例:** -```go -isValid := fac.CheckPassword("myPassword", hashedPassword) -``` - -#### 哈希计算 - -##### MD5(text string) string - -计算MD5哈希值。 - -**参数:** -- `text`: 要计算哈希的文本 - -**返回:** MD5哈希值(十六进制字符串) - -**示例:** -```go -hash := fac.MD5("text") -``` - -##### SHA256(text string) string - -计算SHA256哈希值。 - -**参数:** -- `text`: 要计算哈希的文本 - -**返回:** SHA256哈希值(十六进制字符串) - -**示例:** -```go -hash := fac.SHA256("text") -``` - -#### 随机字符串生成 - -##### GenerateRandomString(length int) string - -生成指定长度的随机字符串。 - -**参数:** -- `length`: 字符串长度 - -**返回:** 随机字符串(包含大小写字母和数字) - -**示例:** -```go -randomStr := fac.GenerateRandomString(16) -``` - -##### GenerateRandomNumber(length int) string - -生成指定长度的随机数字字符串。 - -**参数:** -- `length`: 字符串长度 - -**返回:** 随机数字字符串 - -**示例:** -```go -randomNum := fac.GenerateRandomNumber(6) -``` - -#### 业务相关随机码生成 - -##### GenerateSMSCode() string - -生成短信验证码。 - -**返回:** 6位数字验证码 - -**示例:** -```go -code := fac.GenerateSMSCode() -``` - -##### GenerateOrderNo(prefix string) string - -生成订单号。 - -**参数:** -- `prefix`: 订单号前缀 - -**返回:** 订单号(格式:前缀+时间戳+6位随机数) - -**示例:** -```go -orderNo := fac.GenerateOrderNo("ORD") -``` - -##### GeneratePaymentNo() string - -生成支付单号。 - -**返回:** 支付单号(格式:PAY+时间戳+6位随机数) - -**示例:** -```go -paymentNo := fac.GeneratePaymentNo() -``` - -##### GenerateRefundNo() string - -生成退款单号。 - -**返回:** 退款单号(格式:RF+时间戳+6位随机数) - -**示例:** -```go -refundNo := fac.GenerateRefundNo() -``` - -##### GenerateTransferNo() string - -生成调拨单号。 - -**返回:** 调拨单号(格式:TF+时间戳+6位随机数) - -**示例:** -```go -transferNo := fac.GenerateTransferNo() -``` - -### 时间工具方法(黑盒模式) - -**说明**:Time 工具提供基础时间操作、时间戳、时间判断等功能,与 DateTime 工具的区别: -- **DateTime**:专注于时区相关、格式化、解析、UTC转换 -- **Time**:专注于基础时间操作、时间戳、时间判断、时间信息生成 - -#### 时间戳方法 - -##### GetTimestamp() int64 - -获取当前时间戳(秒)。 - -##### GetMillisTimestamp() int64 - -获取当前时间戳(毫秒)。 - -##### GetUTCTimestamp() int64 - -获取UTC时间戳(秒)。 - -##### GetUTCTimestampFromTime(t time.Time) int64 - -从指定时间获取UTC时间戳(秒)。 - -#### 格式化方法(自定义格式) - -##### FormatTimeWithLayout(t time.Time, layout string) string - -格式化时间(自定义格式)。 - -**参数:** -- `t`: 时间对象 -- `layout`: 时间格式,如 "2006-01-02 15:04:05",如果为空则使用默认格式 - -##### FormatTimeUTC(t time.Time) string - -格式化时间为UTC字符串(ISO 8601格式)。 - -##### GetCurrentTime() string - -获取当前时间字符串(使用默认格式 "2006-01-02 15:04:05")。 - -#### 解析方法(自定义格式) - -##### ParseTime(timeStr, layout string) (time.Time, error) - -解析时间字符串(自定义格式)。 - -**参数:** -- `timeStr`: 时间字符串 -- `layout`: 时间格式,如 "2006-01-02 15:04:05",如果为空则使用默认格式 - -#### 时间计算(补充 DateTime 的 Add 系列) - -##### AddHours(t time.Time, hours int) time.Time - -增加小时数。 - -##### AddMinutes(t time.Time, minutes int) time.Time - -增加分钟数。 - -#### 时间范围(周相关) - -##### GetBeginOfWeek(t time.Time) time.Time - -获取某周的开始时间(周一)。 - -##### GetEndOfWeek(t time.Time) time.Time - -获取某周的结束时间(周日)。 - -#### 时间判断 - -##### IsToday(t time.Time) bool - -判断是否为今天。 - -##### IsYesterday(t time.Time) bool - -判断是否为昨天。 - -##### IsTomorrow(t time.Time) bool - -判断是否为明天。 - -#### 时间信息生成 - -##### GenerateTimeInfoWithTimezone(t time.Time, timezone string) TimeInfo - -生成详细时间信息(指定时区)。 - -**参数:** -- `t`: 时间对象 -- `timezone`: 时区字符串,如 "Asia/Shanghai" - -**返回:** TimeInfo 结构体,包含: -- `UTC`: UTC时间(RFC3339格式) -- `Local`: 本地时间(RFC3339格式) -- `Unix`: Unix时间戳(秒) -- `Timezone`: 时区名称 -- `Offset`: 时区偏移量(小时) -- `RFC3339`: RFC3339格式时间 -- `DateTime`: 日期时间格式(2006-01-02 15:04:05) -- `Date`: 日期格式(2006-01-02) -- `Time`: 时间格式(15:04:05) - -### 金额工具方法(黑盒模式) - -#### GetMoneyCalculator() *tools.MoneyCalculator - -获取金额计算器实例。 - -#### YuanToCents(yuan float64) int64 - -元转分。 - -**示例:** -```go -cents := fac.YuanToCents(100.5) // 返回 10050 -``` - -#### CentsToYuan(cents int64) float64 - -分转元。 - -**示例:** -```go -yuan := fac.CentsToYuan(10050) // 返回 100.5 -``` - -#### FormatYuan(cents int64) string - -格式化显示金额(分转元,保留2位小数)。 - -**示例:** -```go -str := fac.FormatYuan(10050) // 返回 "100.50" -``` - -### 版本工具方法(黑盒模式) - -#### GetVersion() string - -获取版本号。 - -**说明:** -- 优先从环境变量 `DOCKER_TAG` 或 `VERSION` 中读取 -- 如果没有设置环境变量,则使用默认版本号 - -## 设计优势 - -### 优势总结 - -1. **降低复杂度**:调用方无需关心客户端对象的创建和管理 -2. **延迟初始化**:所有客户端在首次使用时才创建,提高性能 -3. **自动选择**:存储类型根据配置自动选择,无需手动指定 -4. **统一接口**:所有操作通过工厂方法调用,接口统一 -5. **容错处理**:日志初始化失败时自动回退到标准输出 -6. **代码简洁**:只提供黑盒模式方法,保持代码简洁清晰 - -## 注意事项 - -1. **配置检查**:工厂方法会自动检查配置是否存在,如果配置为nil会返回错误 -2. **错误处理**:所有方法都可能返回错误,需要正确处理 -3. **延迟初始化**:所有客户端在首次使用时才创建,首次调用可能稍慢 -4. **存储选择**:存储类型根据配置自动选择(优先级:MinIO > OSS) -5. **数据库对象**:数据库保持返回GORM对象,因为GORM已经提供了很好的抽象 -6. **黑盒模式**:所有功能都通过工厂方法直接调用,无需获取底层客户端对象 - -## 示例 - -完整示例请参考 `examples/factory_example.go` diff --git a/docs/http.md b/docs/http.md deleted file mode 100644 index 6335c27..0000000 --- a/docs/http.md +++ /dev/null @@ -1,765 +0,0 @@ -# HTTP Restful工具文档 - -## 概述 - -HTTP Restful工具提供了标准化的HTTP请求和响应处理功能,提供公共方法供外部调用,保持低耦合。 - -## 功能特性 - -- **低耦合设计**:提供公共方法,不封装Handler结构 -- **标准化的响应结构**:`{code, message, timestamp, data}` -- **分离HTTP状态码和业务状态码** -- **支持分页响应** -- **提供便捷的请求参数解析方法** -- **支持JSON请求体解析** -- **Factory黑盒模式**:推荐使用 `factory.Success()` 等方法 - -## 响应结构 - -### 标准响应结构 - -```json -{ - "code": 0, - "message": "success", - "timestamp": 1704067200, - "data": {} -} -``` - -**结构体类型(暴露在 factory 中):** - -```go -// 在 factory 包中可以直接使用 -type Response struct { - Code int `json:"code"` // 业务状态码,0表示成功 - Message string `json:"message"` // 响应消息 - Timestamp int64 `json:"timestamp"` // 时间戳 - Data interface{} `json:"data"` // 响应数据 -} -``` - -### 分页响应结构 - -```json -{ - "code": 0, - "message": "success", - "timestamp": 1704067200, - "data": { - "list": [], - "total": 100, - "page": 1, - "pageSize": 10 - } -} -``` - -**结构体类型(暴露在 factory 中):** - -```go -// 在 factory 包中可以直接使用 -type PageData struct { - List interface{} `json:"list"` // 数据列表 - Total int64 `json:"total"` // 总记录数 - Page int `json:"page"` // 当前页码 - PageSize int `json:"pageSize"` // 每页大小 -} - -type PageResponse struct { - Code int `json:"code"` - Message string `json:"message"` - Timestamp int64 `json:"timestamp"` - Data *PageData `json:"data"` -} -``` - -### 使用暴露的结构体 - -外部项目可以直接使用 `factory.Response`、`factory.PageData` 等类型: - -```go -import "git.toowon.com/jimmy/go-common/factory" - -// 创建标准响应对象 -response := factory.Response{ - Code: 0, - Message: "success", - Data: userData, -} - -// 创建分页数据对象 -pageData := &factory.PageData{ - List: users, - Total: 100, - Page: 1, - PageSize: 20, -} - -// 传递给 Success 方法 -fac.Success(w, pageData) -``` - -## 使用方法 - -### 方式一:使用Factory黑盒方法(推荐)⭐ - -这是最简单的方式,直接使用 `factory.Success()` 等方法: - -```go -import ( - "net/http" - "git.toowon.com/jimmy/go-common/factory" - commonhttp "git.toowon.com/jimmy/go-common/http" - "git.toowon.com/jimmy/go-common/tools" -) - -func GetUser(w http.ResponseWriter, r *http.Request) { - fac, _ := factory.NewFactoryFromFile("config.json") - - // 获取查询参数(使用类型转换方法) - id := tools.ConvertInt64(r.URL.Query().Get("id"), 0) - - // 返回成功响应(使用factory方法) - fac.Success(w, data) -} - -func CreateUser(w http.ResponseWriter, r *http.Request) { - fac, _ := factory.NewFactoryFromFile("config.json") - - // 解析JSON(使用公共方法) - var req struct { - Name string `json:"name"` - } - if err := commonhttp.ParseJSON(r, &req); err != nil { - commonhttp.WriteJSON(w, http.StatusBadRequest, 400, "请求参数解析失败", nil) - return - } - - // 返回成功响应(使用factory方法) - fac.Success(w, data, "创建成功") -} - -func GetUserList(w http.ResponseWriter, r *http.Request) { - fac, _ := factory.NewFactoryFromFile("config.json") - - // 获取分页参数(使用factory方法,推荐) - pagination := fac.ParsePaginationRequest(r) - page := pagination.GetPage() - pageSize := pagination.GetPageSize() - - // 获取查询参数(直接使用HTTP原生方法) - keyword := r.URL.Query().Get("keyword") - - // 查询数据 - list, total := getDataList(keyword, page, pageSize) - - // 返回分页响应(使用factory方法) - fac.SuccessPage(w, list, total, page, pageSize) -} - -// 注册路由 -http.HandleFunc("/user", GetUser) -http.HandleFunc("/users", GetUserList) -``` - -### 方式二:直接使用公共方法 - -如果不想使用Factory,可以直接使用 `http` 包的公共方法: - -```go -import ( - "net/http" - commonhttp "git.toowon.com/jimmy/go-common/http" - "git.toowon.com/jimmy/go-common/tools" -) - -func GetUser(w http.ResponseWriter, r *http.Request) { - // 获取查询参数 - id := tools.ConvertInt64(r.URL.Query().Get("id"), 0) - - // 返回成功响应 - commonhttp.Success(w, data) -} - -func CreateUser(w http.ResponseWriter, r *http.Request) { - // 解析JSON - var req struct { - Name string `json:"name"` - } - if err := commonhttp.ParseJSON(r, &req); err != nil { - commonhttp.WriteJSON(w, http.StatusBadRequest, 400, "请求参数解析失败", nil) - return - } - - // 返回成功响应 - commonhttp.Success(w, data, "创建成功") -} -``` - -### 成功响应 - -```go -// 使用Factory(推荐) -fac.Success(w, data) // 只有数据,使用默认消息 "success" -fac.Success(w, data, "操作成功") // 数据+消息 - -// 或直接使用公共方法 -commonhttp.Success(w, data) // 只有数据 -commonhttp.Success(w, data, "操作成功") // 数据+消息 -``` - -### 错误响应 - -```go -// 使用Factory(推荐) -fac.Error(w, 1001, "用户不存在") // 业务错误(HTTP 200,业务code非0) -fac.SystemError(w, "服务器内部错误") // 系统错误(HTTP 500) - -// 或直接使用公共方法 -commonhttp.Error(w, 1001, "用户不存在") -commonhttp.SystemError(w, "服务器内部错误") -commonhttp.WriteJSON(w, http.StatusBadRequest, 400, "请求参数错误", nil) // 自定义HTTP状态码(仅公共方法) -``` - -### 分页响应 - -```go -// 使用Factory(推荐) -fac.SuccessPage(w, list, total, page, pageSize) -fac.SuccessPage(w, list, total, page, pageSize, "查询成功") - -// 或直接使用公共方法 -commonhttp.SuccessPage(w, list, total, page, pageSize) -commonhttp.SuccessPage(w, list, total, page, pageSize, "查询成功") -``` - -### 解析请求 - -#### 解析JSON请求体 - -```go -// 使用公共方法 -var req struct { - Name string `json:"name"` - Email string `json:"email"` -} - -if err := commonhttp.ParseJSON(r, &req); err != nil { - commonhttp.WriteJSON(w, http.StatusBadRequest, 400, "请求参数解析失败", nil) - return -} -``` - -#### 获取查询参数 - -```go -import "git.toowon.com/jimmy/go-common/tools" - -// 字符串直接获取 -name := r.URL.Query().Get("name") - -// 使用类型转换方法 -id := tools.ConvertInt(r.URL.Query().Get("id"), 0) -userId := tools.ConvertInt64(r.URL.Query().Get("userId"), 0) -isActive := tools.ConvertBool(r.URL.Query().Get("isActive"), false) -price := tools.ConvertFloat64(r.URL.Query().Get("price"), 0.0) -``` - -#### 获取表单参数 - -```go -import "git.toowon.com/jimmy/go-common/tools" - -// 字符串直接获取 -name := r.FormValue("name") - -// 使用类型转换方法 -age := tools.ConvertInt(r.FormValue("age"), 0) -userId := tools.ConvertInt64(r.FormValue("userId"), 0) -isActive := tools.ConvertBool(r.FormValue("isActive"), false) -``` - -#### 获取请求头 - -```go -// 直接使用HTTP原生方法 -token := r.Header.Get("Authorization") -contentType := r.Header.Get("Content-Type") -if contentType == "" { - contentType = "application/json" // 设置默认值 -} -``` - -#### 获取分页参数 - -**方式1:使用 PaginationRequest 结构(推荐)** - -```go -// 定义请求结构(包含分页字段) -type ListUserRequest struct { - Keyword string `json:"keyword"` - commonhttp.PaginationRequest // 嵌入分页请求结构 -} - -// 从JSON请求体解析(分页字段会自动解析) -var req ListUserRequest -if err := commonhttp.ParseJSON(r, &req); err != nil { - commonhttp.WriteJSON(w, http.StatusBadRequest, 400, "请求参数解析失败", nil) - return -} - -// 使用分页方法 -page := req.GetPage() // 获取页码(默认1) -pageSize := req.GetPageSize() // 获取每页数量(默认20,最大100) -offset := req.GetOffset() // 计算偏移量 -``` - -**方式2:从查询参数/form解析分页** - -```go -// 使用公共方法 -pagination := commonhttp.ParsePaginationRequest(r) -page := pagination.GetPage() -pageSize := pagination.GetPageSize() -offset := pagination.GetOffset() -``` - -#### 获取时区 - -```go -// 使用公共方法 -// 如果使用了middleware.Timezone中间件,可以从context中获取时区信息 -// 如果未设置,返回默认时区 AsiaShanghai -timezone := commonhttp.GetTimezone(r) -``` - -## 完整示例 - -### 使用Factory(推荐) - -```go -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/tools" -) - -// 用户结构 -type User struct { - ID int64 `json:"id"` - Name string `json:"name"` - Email string `json:"email"` -} - -// 用户列表接口 -func GetUserList(w http.ResponseWriter, r *http.Request) { - fac, _ := factory.NewFactoryFromFile("config.json") - - // 获取分页参数(使用公共方法) - pagination := commonhttp.ParsePaginationRequest(r) - page := pagination.GetPage() - pageSize := pagination.GetPageSize() - - // 获取查询参数(直接使用HTTP原生方法) - keyword := r.URL.Query().Get("keyword") - - // 查询数据 - users, total := queryUsers(keyword, page, pageSize) - - // 返回分页响应(使用factory方法) - fac.SuccessPage(w, users, total, page, pageSize) -} - -// 创建用户接口 -func CreateUser(w http.ResponseWriter, r *http.Request) { - fac, _ := factory.NewFactoryFromFile("config.json") - - // 解析请求体(使用公共方法) - var req struct { - Name string `json:"name"` - Email string `json:"email"` - } - - if err := commonhttp.ParseJSON(r, &req); err != nil { - fac.WriteJSON(w, http.StatusBadRequest, 400, "请求参数解析失败", nil) - return - } - - // 参数验证 - if req.Name == "" { - fac.Error(w, 1001, "用户名不能为空") - return - } - - // 创建用户 - user, err := createUser(req.Name, req.Email) - if err != nil { - fac.SystemError(w, "创建用户失败") - return - } - - // 返回成功响应(使用factory方法) - fac.Success(w, user, "创建成功") -} - -// 获取用户详情接口 -func GetUser(w http.ResponseWriter, r *http.Request) { - fac, _ := factory.NewFactoryFromFile("config.json") - - // 获取查询参数(使用类型转换方法) - id := tools.ConvertInt64(r.URL.Query().Get("id"), 0) - - if id == 0 { - commonhttp.WriteJSON(w, http.StatusBadRequest, 400, "用户ID不能为空", nil) - return - } - - // 查询用户 - user, err := getUserByID(id) - if err != nil { - fac.SystemError(w, "查询用户失败") - return - } - - if user == nil { - fac.Error(w, 1002, "用户不存在") - return - } - - // 返回成功响应(使用factory方法) - fac.Success(w, user) -} - -func main() { - http.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - GetUserList(w, r) - case http.MethodPost: - CreateUser(w, r) - default: - commonhttp.WriteJSON(w, http.StatusMethodNotAllowed, 405, "方法不支持", nil) - } - }) - - http.HandleFunc("/user", GetUser) - - log.Println("Server started on :8080") - log.Fatal(http.ListenAndServe(":8080", nil)) -} -``` - -## API 参考 - -### Factory HTTP响应结构体(暴露给外部项目使用) - -#### Response - -标准响应结构体,外部项目可以直接使用 `factory.Response`。 - -**字段:** -- `Code`: 业务状态码,0表示成功 -- `Message`: 响应消息 -- `Timestamp`: 时间戳(Unix时间戳,秒) -- `Data`: 响应数据 - -**示例:** -```go -response := factory.Response{ - Code: 0, - Message: "success", - Data: userData, -} -``` - -#### PageData - -分页数据结构体,外部项目可以直接使用 `factory.PageData`。 - -**字段:** -- `List`: 数据列表 -- `Total`: 总记录数 -- `Page`: 当前页码 -- `PageSize`: 每页大小 - -**示例:** -```go -pageData := &factory.PageData{ - List: users, - Total: 100, - Page: 1, - PageSize: 20, -} -fac.Success(w, pageData) -``` - -#### PageResponse - -分页响应结构体,外部项目可以直接使用 `factory.PageResponse`。 - -**字段:** -- `Code`: 业务状态码 -- `Message`: 响应消息 -- `Timestamp`: 时间戳 -- `Data`: 分页数据(*PageData) - -### Factory HTTP响应方法(推荐使用) - -#### (f *Factory) Success(w http.ResponseWriter, data interface{}, message ...string) - -成功响应,HTTP 200,业务code 0。 - -**参数:** -- `data`: 响应数据,可以为nil -- `message`: 响应消息(可选),如果为空则使用默认消息 "success" - -**示例:** -```go -fac.Success(w, data) // 只有数据,使用默认消息 "success" -fac.Success(w, data, "操作成功") // 数据+消息 -``` - -#### (f *Factory) Error(w http.ResponseWriter, code int, message string) - -业务错误响应,HTTP 200,业务code非0。 - -**示例:** -```go -fac.Error(w, 1001, "用户不存在") -``` - -#### (f *Factory) SystemError(w http.ResponseWriter, message string) - -系统错误响应,HTTP 500,业务code 500。 - -**示例:** -```go -fac.SystemError(w, "服务器内部错误") -``` - -#### (f *Factory) WriteJSON(w http.ResponseWriter, httpCode, code int, message string, data interface{}) - -写入JSON响应(自定义)。 - -**参数:** -- `httpCode`: HTTP状态码 -- `code`: 业务状态码 -- `message`: 响应消息 -- `data`: 响应数据 - -**说明:** -- 此方法不在 Factory 中,直接使用 `commonhttp.WriteJSON()` -- 用于需要自定义HTTP状态码的场景(如 400, 401, 403, 404 等) - -**示例:** -```go -commonhttp.WriteJSON(w, http.StatusBadRequest, 400, "请求参数错误", nil) -commonhttp.WriteJSON(w, http.StatusUnauthorized, 401, "未登录", nil) -``` - -#### (f *Factory) SuccessPage(w http.ResponseWriter, list interface{}, total int64, page, pageSize int, message ...string) - -分页成功响应。 - -**参数:** -- `list`: 数据列表 -- `total`: 总记录数 -- `page`: 当前页码 -- `pageSize`: 每页大小 -- `message`: 响应消息(可选,如果为空则使用默认消息 "success") - -**示例:** -```go -fac.SuccessPage(w, users, total, page, pageSize) -fac.SuccessPage(w, users, total, page, pageSize, "查询成功") -``` - -### HTTP公共方法(直接使用) - -#### WriteJSON(w http.ResponseWriter, httpCode, code int, message string, data interface{}) - -写入JSON响应(自定义HTTP状态码和业务状态码)。 - -**说明:** -- 此方法不在 Factory 中,直接使用 `commonhttp.WriteJSON()` -- 用于需要自定义HTTP状态码的场景(如 400, 401, 403, 404 等) - -**示例:** -```go -commonhttp.WriteJSON(w, http.StatusBadRequest, 400, "请求参数错误", nil) -commonhttp.WriteJSON(w, http.StatusUnauthorized, 401, "未登录", nil) -``` - -#### ParseJSON(r *http.Request, v interface{}) error - -解析JSON请求体。 - -**示例:** -```go -var req CreateUserRequest -if err := commonhttp.ParseJSON(r, &req); err != nil { - // 处理错误 -} -``` - -#### 获取查询参数和表单参数 - -**推荐方式:使用类型转换工具** - -```go -import "git.toowon.com/jimmy/go-common/tools" - -// 字符串直接使用HTTP原生方法 -name := r.URL.Query().Get("name") -if name == "" { - name = "default" // 设置默认值 -} - -// 类型转换使用tools包 -id := tools.ConvertInt(r.URL.Query().Get("id"), 0) -userId := tools.ConvertInt64(r.URL.Query().Get("userId"), 0) -isActive := tools.ConvertBool(r.URL.Query().Get("isActive"), false) -price := tools.ConvertFloat64(r.URL.Query().Get("price"), 0.0) - -// 表单参数类似 -age := tools.ConvertInt(r.FormValue("age"), 0) -``` - -**类型转换方法说明:** - -- `tools.ConvertInt(value string, defaultValue int) int` - 转换为int -- `tools.ConvertInt64(value string, defaultValue int64) int64` - 转换为int64 -- `tools.ConvertUint64(value string, defaultValue uint64) uint64` - 转换为uint64 -- `tools.ConvertUint32(value string, defaultValue uint32) uint32` - 转换为uint32 -- `tools.ConvertBool(value string, defaultValue bool) bool` - 转换为bool -- `tools.ConvertFloat64(value string, defaultValue float64) float64` - 转换为float64 - -**获取请求头:** - -```go -// 直接使用HTTP原生方法 -token := r.Header.Get("Authorization") -contentType := r.Header.Get("Content-Type") -``` - -#### ParsePaginationRequest(r *http.Request) *PaginationRequest - -从请求中解析分页参数。 - -**说明:** -- 支持从查询参数和form表单中解析 -- 优先级:查询参数 > form表单 -- 如果请求体是JSON格式且包含分页字段,建议先使用`ParseJSON`解析完整请求体到包含`PaginationRequest`的结构体中 - -#### GetTimezone(r *http.Request) string - -从请求的context中获取时区。 - -**说明:** -- 如果使用了middleware.Timezone中间件,可以从context中获取时区信息 -- 如果未设置,返回默认时区 AsiaShanghai - -#### Success(w http.ResponseWriter, data interface{}, message ...string) - -成功响应(公共方法)。 - -**参数:** -- `data`: 响应数据,可以为nil -- `message`: 响应消息(可选),如果为空则使用默认消息 "success" - -**示例:** -```go -commonhttp.Success(w, data) // 只有数据 -commonhttp.Success(w, data, "操作成功") // 数据+消息 -``` - -#### Error(w http.ResponseWriter, code int, message string) - -错误响应(公共方法)。 - -#### SystemError(w http.ResponseWriter, message string) - -系统错误响应(公共方法)。 - -#### WriteJSON(w http.ResponseWriter, httpCode, code int, message string, data interface{}) - -写入JSON响应(公共方法,不在Factory中)。 - -**说明:** -- 用于需要自定义HTTP状态码的场景 -- 直接使用 `commonhttp.WriteJSON()`,不在 Factory 中 - -#### SuccessPage(w http.ResponseWriter, list interface{}, total int64, page, pageSize int, message ...string) - -分页成功响应(公共方法)。 - -### 分页请求结构 - -#### PaginationRequest - -分页请求结构,支持从JSON和form中解析分页参数。 - -**字段:** -- `Page`: 页码(默认1) -- `PageSize`: 每页数量 - -**方法:** -- `GetPage() int`: 获取页码,如果未设置则返回默认值1 -- `GetPageSize() int`: 获取每页数量,如果未设置则返回默认值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. **推荐使用Factory**: - - 使用 `factory.Success()` 等方法,代码更简洁 - - 直接使用 `http` 包的公共方法,保持低耦合 - - 不需要Handler结构,减少不必要的封装 - -## 示例 - -完整示例请参考: -- `examples/http_handler_example.go` - 使用Factory和公共方法 -- `examples/http_pagination_example.go` - 分页示例 -- `examples/factory_blackbox_example.go` - Factory黑盒模式示例 diff --git a/docs/i18n.md b/docs/i18n.md deleted file mode 100644 index 8002653..0000000 --- a/docs/i18n.md +++ /dev/null @@ -1,510 +0,0 @@ -# 国际化工具文档 - -## 概述 - -国际化工具(i18n)提供多语言内容管理功能,支持从文件加载语言内容,通过语言代码和消息代码获取对应语言的内容。 - -## 功能特性 - -- **多语言支持**:支持加载多个语言文件 -- **文件加载**:支持从目录或单个文件加载语言内容 -- **语言回退**:当指定语言不存在时,自动回退到默认语言 -- **参数替换**:支持在消息中使用格式化参数(类似 fmt.Sprintf) -- **并发安全**:使用读写锁保证并发安全 -- **动态加载**:支持重新加载语言文件 - -## 快速开始 - -### 1. 创建语言文件 - -创建语言文件目录 `locales/`,并创建对应的语言文件: - -**locales/zh-CN.json**: -```json -{ - "user.not_found": { - "code": 100002, - "message": "用户不存在" - }, - "user.login_success": { - "code": 0, - "message": "登录成功" - }, - "user.welcome": { - "code": 0, - "message": "欢迎,%s" - }, - "error.invalid_params": { - "code": 100001, - "message": "参数无效" - } -} -``` - -**locales/en-US.json**: -```json -{ - "user.not_found": { - "code": 100002, - "message": "User not found" - }, - "user.login_success": { - "code": 0, - "message": "Login successful" - }, - "user.welcome": { - "code": 0, - "message": "Welcome, %s" - }, - "error.invalid_params": { - "code": 100001, - "message": "Invalid parameters" - } -} -``` - -### 2. 初始化并使用 - -```go -package main - -import ( - "git.toowon.com/jimmy/go-common/factory" -) - -func main() { - // 创建工厂实例 - fac, _ := factory.NewFactoryFromFile("config.json") - - // 初始化国际化工具,设置默认语言为中文 - fac.InitI18n("zh-CN") - - // 从目录加载所有语言文件 - fac.LoadI18nFromDir("locales") - - // 获取消息 - msg := fac.GetMessage("zh-CN", "user.not_found") - // 返回: "用户不存在" - - // 带参数的消息 - msg = fac.GetMessage("zh-CN", "user.welcome", "Alice") - // 返回: "欢迎,Alice" - - // 在HTTP handler中使用Error方法(推荐) - // fac.Error(w, r, 0, "user.not_found") - // 会自动从语言文件获取业务code和国际化消息 - // 返回: {"code": 100002, "message": "用户不存在"} -} -``` - -## API 文档 - -### 初始化方法 - -#### InitI18n - -初始化国际化工具,设置默认语言。 - -```go -fac.InitI18n(defaultLang string) -``` - -**参数**: -- `defaultLang`: 默认语言代码(如 "zh-CN", "en-US") - -**示例**: -```go -fac.InitI18n("zh-CN") -``` - -### 加载方法 - -#### LoadI18nFromDir - -从目录加载多个语言文件(推荐方式)。 - -```go -fac.LoadI18nFromDir(dirPath string) error -``` - -**参数**: -- `dirPath`: 语言文件目录路径 - -**文件命名规则**: -- 文件必须以 `.json` 结尾 -- 文件名(去掉 `.json` 后缀)作为语言代码 -- 例如:`zh-CN.json` 对应语言代码 `zh-CN` - -**示例**: -```go -fac.LoadI18nFromDir("locales") -``` - -#### LoadI18nFromFile - -从单个语言文件加载内容。 - -```go -fac.LoadI18nFromFile(filePath, lang string) error -``` - -**参数**: -- `filePath`: 语言文件路径(JSON格式) -- `lang`: 语言代码(如 "zh-CN", "en-US") - -**示例**: -```go -fac.LoadI18nFromFile("locales/zh-CN.json", "zh-CN") -fac.LoadI18nFromFile("locales/en-US.json", "en-US") -``` - -### 错误响应方法 - -#### Error - -错误响应(自动国际化,推荐使用)。 - -```go -fac.Error(w, r, code int, message string, args ...interface{}) -``` - -**参数**: -- `w`: ResponseWriter -- `r`: HTTP请求(用于获取语言信息) -- `code`: 业务错误码(如果message是消息代码,此参数会被语言文件中的code覆盖) -- `message`: 错误消息或消息代码(如果i18n已初始化且message是消息代码格式,会自动获取国际化消息和业务code) -- `args`: 可选参数,用于格式化消息(类似 fmt.Sprintf,仅在message是消息代码时使用) - -**使用逻辑**: -1. 如果i18n已初始化,且message看起来是消息代码(包含点号,如 "user.not_found"), - 则从请求context中获取语言,并尝试从语言文件中获取国际化消息和业务code -2. 如果获取到国际化消息,使用语言文件中的code作为响应code,使用国际化消息作为响应message -3. 如果未获取到或i18n未初始化,使用传入的code和message - -**示例**: -```go -// 方式1:传入消息代码(推荐,自动获取业务code和国际化消息) -fac.Error(w, r, 0, "user.not_found") -// 如果请求语言是 zh-CN,且语言文件中 "user.not_found" 的 code 是 100002, -// 返回: {"code": 100002, "message": "用户不存在"} -// 如果请求语言是 en-US,返回: {"code": 100002, "message": "User not found"} -// 注意:业务code在所有语言中保持一致 - -// 方式2:带参数的消息代码 -fac.Error(w, r, 0, "user.welcome", "Alice") -// 如果消息内容是 "欢迎,%s",返回: {"code": 0, "message": "欢迎,Alice"} - -// 方式3:直接传入消息文本(不使用国际化) -fac.Error(w, r, 500, "系统错误") -// 返回: {"code": 500, "message": "系统错误"} -``` - -### 获取消息方法 - -#### GetMessage - -获取指定语言和代码的消息内容(推荐使用)。 - -```go -fac.GetMessage(lang, code string, args ...interface{}) string -``` - -**参数**: -- `lang`: 语言代码(如 "zh-CN", "en-US") -- `code`: 消息代码(如 "user.not_found") -- `args`: 可选参数,用于格式化消息(类似 fmt.Sprintf) - -**返回逻辑**: -1. 如果指定语言存在该code,返回对应内容 -2. 如果指定语言不存在,尝试使用默认语言 -3. 如果默认语言也不存在,返回code本身(作为fallback) - -**示例**: -```go -// 简单消息 -msg := fac.GetMessage("zh-CN", "user.not_found") -// 返回: "用户不存在" - -// 带参数的消息 -msg := fac.GetMessage("zh-CN", "user.welcome", "Alice") -// 如果消息内容是 "欢迎,%s",返回: "欢迎,Alice" -``` - -### GetLanguage - -从请求的context中获取语言代码(推荐使用)。 - -```go -fac.GetLanguage(r *http.Request) string -``` - -**参数**: -- `r`: HTTP请求 - -**返回逻辑**: -1. 从请求context中获取语言(由middleware.Language中间件设置) -2. 如果未设置,返回默认语言 zh-CN - -**示例**: -```go -lang := fac.GetLanguage(r) -// 可能返回: "zh-CN", "en-US" 等 -``` - -### 高级功能 - -#### GetI18n - -获取国际化工具对象(仅在需要高级功能时使用)。 - -```go -fac.GetI18n() (*i18n.I18n, error) -``` - -**返回**:国际化工具对象 - -**高级功能示例**: -```go -i18n, _ := fac.GetI18n() - -// 检查语言是否存在 -hasLang := i18n.HasLang("en-US") - -// 获取所有支持的语言 -langs := i18n.GetSupportedLangs() - -// 重新加载语言文件 -i18n.ReloadFromFile("locales/zh-CN.json", "zh-CN") - -// 动态设置默认语言 -i18n.SetDefaultLang("en-US") -``` - -## 使用场景 - -### 场景1:HTTP API 响应消息(推荐使用 Error 方法) - -**推荐方式**:使用 `Error` 方法,自动从请求中获取语言并返回国际化消息和业务code: - -```go -func handleLogin(w http.ResponseWriter, r *http.Request) { - fac, _ := factory.NewFactoryFromFile("config.json") - - // 验证用户 - user, err := validateUser(r) - if err != nil { - // 直接传入消息代码,自动获取业务code和国际化消息(推荐) - // 会自动从请求context获取语言(由middleware.Language中间件设置) - fac.Error(w, r, 0, "user.not_found") - // 返回: {"code": 100002, "message": "用户不存在"} 或 {"code": 100002, "message": "User not found"} - return - } - - // 成功消息 - lang := fac.GetLanguage(r) - msg := fac.GetMessage(lang, "user.login_success") - fac.Success(w, user, msg) -} -``` - -### 场景2:带参数的消息 - -```go -func handleWelcome(w http.ResponseWriter, r *http.Request) { - fac, _ := factory.NewFactoryFromFile("config.json") - lang := getLangFromRequest(r) - - username := "Alice" - // 消息内容: "欢迎,%s" - msg := fac.GetMessage(lang, "user.welcome", username) - // 返回: "欢迎,Alice" 或 "Welcome, Alice" - - fac.Success(w, nil, msg) -} -``` - -### 场景3:错误消息国际化(推荐使用 Error 方法) - -**推荐方式**:使用 `Error` 方法,自动获取业务code和国际化消息: - -```go -func handleError(w http.ResponseWriter, r *http.Request, messageCode string) { - fac, _ := factory.NewFactoryFromFile("config.json") - - // 直接传入消息代码,自动获取业务code和国际化消息(推荐) - // 会自动从请求context获取语言并查找对应的国际化消息和业务code - fac.Error(w, r, 0, "error."+messageCode) - // 例如:fac.Error(w, r, 0, "error.invalid_params") - // 返回: {"code": 100001, "message": "参数无效"} 或 {"code": 100001, "message": "Invalid parameters"} -} -``` - -## 语言文件格式 - -语言文件必须是 JSON 格式,每个消息包含业务code和消息内容: - -```json -{ - "user.not_found": { - "code": 100002, - "message": "用户不存在" - }, - "user.login_success": { - "code": 0, - "message": "登录成功" - }, - "user.welcome": { - "code": 0, - "message": "欢迎,%s" - }, - "error.invalid_params": { - "code": 100001, - "message": "参数无效" - } -} -``` - -**注意事项**: -- key(消息代码)建议使用点号分隔的层级结构(如 `user.not_found`) -- value 是一个对象,包含: - - `code`: 业务错误码(整数),用于业务逻辑判断,所有语言的同一消息代码应使用相同的code - - `message`: 消息内容(字符串),支持格式化占位符(如 `%s`, `%d`) -- 所有语言文件应该包含相同的 key,确保所有语言都有对应的翻译 -- **重要**:同一消息代码在所有语言文件中的 `code` 必须保持一致,只有 `message` 会根据语言变化 - -## 最佳实践 - -### 1. 消息代码命名规范 - -建议使用层级结构,便于管理和查找: - -``` -模块.功能.状态 -例如: -- user.not_found -- user.login_success -- order.created -- order.paid -- error.invalid_params -- error.server_error -``` - -### 2. 默认语言设置 - -建议将最常用的语言设置为默认语言,确保在语言不存在时有合理的回退: - -```go -fac.InitI18n("zh-CN") // 中文作为默认语言 -``` - -### 3. 语言代码规范 - -建议使用标准的语言代码格式: -- `zh-CN`: 简体中文 -- `zh-TW`: 繁体中文 -- `en-US`: 美式英语 -- `en-GB`: 英式英语 -- `ja-JP`: 日语 -- `ko-KR`: 韩语 - -### 4. 文件组织 - -建议将所有语言文件放在统一的目录下: - -``` -project/ - locales/ - zh-CN.json - en-US.json - ja-JP.json -``` - -### 5. 参数使用 - -对于需要动态内容的消息,使用格式化参数: - -```json -{ - "user.welcome": { - "code": 0, - "message": "欢迎,%s" - }, - "order.total": { - "code": 0, - "message": "订单总额:%.2f 元" - }, - "message.count": { - "code": 0, - "message": "您有 %d 条新消息" - } -} -``` - -使用方式: -```go -// 使用 GetMessage -msg := fac.GetMessage("zh-CN", "user.welcome", "Alice") -msg := fac.GetMessage("zh-CN", "order.total", 99.99) -msg := fac.GetMessage("zh-CN", "message.count", 5) - -// 使用 Error 方法(推荐) -fac.Error(w, r, 0, "user.welcome", "Alice") -fac.Error(w, r, 0, "message.count", 5) -``` - -### 6. 业务Code管理 - -**重要原则**:同一消息代码在所有语言文件中的业务code必须保持一致。 - -```json -// zh-CN.json -{ - "user.not_found": { - "code": 100002, // 业务code - "message": "用户不存在" - } -} - -// en-US.json -{ - "user.not_found": { - "code": 100002, // 必须与zh-CN.json中的code相同 - "message": "User not found" // 只有message会根据语言变化 - } -} -``` - -这样设计的好处: -- 调用端可以根据返回的 `code` 进行业务逻辑判断,不受语言影响 -- 所有语言的同一错误使用相同的业务code,便于统一管理 - -## 常见问题 - -### Q1: 如果语言文件不存在会怎样? - -A: 如果语言文件不存在,`LoadI18nFromFile` 或 `LoadI18nFromDir` 会返回错误。建议在初始化时检查错误。 - -### Q2: 如果消息代码不存在会怎样? - -A: `GetMessage` 和 `Error` 会按以下顺序查找: -1. 指定语言的消息 -2. 默认语言的消息 -3. 如果都不存在: - - `GetMessage` 返回消息代码本身(作为fallback) - - `Error` 使用传入的code参数和消息代码作为message - -### Q5: 业务code在不同语言中必须相同吗? - -A: **是的,必须相同**。同一消息代码在所有语言文件中的业务code必须保持一致,只有message内容会根据语言变化。这样调用端可以根据code进行业务逻辑判断,不受语言影响。 - -### Q3: 如何支持动态加载语言文件? - -A: 可以使用 `GetI18n()` 获取对象,然后调用 `ReloadFromFile()` 或 `ReloadFromDir()` 方法。 - -### Q4: 是否支持嵌套的消息结构? - -A: 当前版本只支持扁平结构(key-value),不支持嵌套。如果需要嵌套结构,建议使用点号分隔的层级命名(如 `user.profile.name`)。 - -## 完整示例 - -参考 [examples/i18n_example.go](../examples/i18n_example.go) diff --git a/docs/logger.md b/docs/logger.md deleted file mode 100644 index 48f52a0..0000000 --- a/docs/logger.md +++ /dev/null @@ -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` - diff --git a/docs/middleware.md b/docs/middleware.md deleted file mode 100644 index 057333a..0000000 --- a/docs/middleware.md +++ /dev/null @@ -1,1039 +0,0 @@ -# 中间件工具文档 - -## 概述 - -中间件工具提供了常用的HTTP中间件功能,包括CORS处理、时区管理、请求日志、Panic恢复和限流等。 - -## 功能特性 - -- **CORS中间件**:支持跨域资源共享配置 -- **时区中间件**:从请求头读取时区信息,支持默认时区设置 -- **日志中间件**:自动记录每个HTTP请求的详细信息 -- **Recovery中间件**:捕获panic并恢复,防止服务崩溃 -- **限流中间件**:基于令牌桶算法的请求限流 -- **中间件链**:提供便捷的中间件链式调用 - -## CORS中间件 - -### 功能说明 - -CORS中间件用于处理跨域资源共享,支持: -- 配置允许的源(支持通配符) -- 配置允许的HTTP方法 -- 配置允许的请求头 -- 配置暴露的响应头 -- 支持凭证传递 -- 预检请求缓存时间设置 - -### 使用方法 - -#### 基本使用(默认配置) - -```go -import ( - "net/http" - "git.toowon.com/jimmy/go-common/middleware" -) - -func main() { - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // 处理请求 - }) - - // 使用默认CORS配置 - corsHandler := middleware.CORS()(handler) - - http.Handle("/api", corsHandler) - http.ListenAndServe(":8080", nil) -} -``` - -#### 自定义配置 - -```go -import ( - "net/http" - "git.toowon.com/jimmy/go-common/middleware" -) - -func main() { - // 自定义CORS配置 - corsConfig := &middleware.CORSConfig{ - AllowedOrigins: []string{ - "https://example.com", - "https://app.example.com", - "*.example.com", // 支持通配符 - }, - AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, - AllowedHeaders: []string{ - "Content-Type", - "Authorization", - "X-Requested-With", - "X-Timezone", - }, - ExposedHeaders: []string{"X-Total-Count"}, - AllowCredentials: true, - MaxAge: 3600, // 1小时 - } - - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // 处理请求 - }) - - corsHandler := middleware.CORS(corsConfig)(handler) - - http.Handle("/api", corsHandler) - http.ListenAndServe(":8080", nil) -} -``` - -#### 允许所有源(开发环境) - -```go -corsConfig := &middleware.CORSConfig{ - AllowedOrigins: []string{"*"}, - AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, - AllowedHeaders: []string{"*"}, -} - -corsHandler := middleware.CORS(corsConfig)(handler) -``` - -### CORSConfig 配置说明 - -| 字段 | 类型 | 说明 | 默认值 | -|------|------|------|--------| -| AllowedOrigins | []string | 允许的源,支持 "*" 和 "*.example.com" | ["*"] | -| AllowedMethods | []string | 允许的HTTP方法 | ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"] | -| AllowedHeaders | []string | 允许的请求头 | ["Content-Type", "Authorization", "X-Requested-With", "X-Timezone"] | -| ExposedHeaders | []string | 暴露给客户端的响应头 | [] | -| AllowCredentials | bool | 是否允许发送凭证 | false | -| MaxAge | int | 预检请求缓存时间(秒) | 86400 | - -### 注意事项 - -1. 如果 `AllowCredentials` 为 `true`,`AllowedOrigins` 不能使用 "*",必须指定具体的源 -2. 通配符支持:`"*.example.com"` 会匹配 `"https://app.example.com"` 等子域名 -3. 预检请求(OPTIONS)会自动处理,无需在业务代码中处理 - -## 时区中间件 - -### 功能说明 - -时区中间件用于从请求头读取时区信息,并存储到context中,方便后续使用。 - -- 从请求头 `X-Timezone` 读取时区 -- 如果未传递时区信息,使用默认时区 `AsiaShanghai` -- 时区信息存储到context中,可通过Handler的`GetTimezone()`方法获取 -- 自动验证时区有效性,无效时区会回退到默认时区 - -### 使用方法 - -#### 基本使用(默认时区 AsiaShanghai) - -```go -import ( - "net/http" - "git.toowon.com/jimmy/go-common/middleware" - commonhttp "git.toowon.com/jimmy/go-common/http" - "git.toowon.com/jimmy/go-common/tools" -) - -func handler(w http.ResponseWriter, r *http.Request) { - h := commonhttp.NewHandler(w, r) - - // 从Handler获取时区 - timezone := h.GetTimezone() - - // 使用时区 - now := tools.Now(timezone) - tools.FormatDateTime(now, timezone) - - h.Success(map[string]interface{}{ - "timezone": timezone, - "time": tools.FormatDateTime(now), - }) -} - -func main() { - handler := middleware.Timezone(http.HandlerFunc(handler)) - http.Handle("/api", handler) - http.ListenAndServe(":8080", nil) -} -``` - -#### 自定义默认时区 - -```go -import ( - "net/http" - "git.toowon.com/jimmy/go-common/middleware" - "git.toowon.com/jimmy/go-common/tools" -) - -func handler(w http.ResponseWriter, r *http.Request) { - // 处理请求 -} - -func main() { - // 使用自定义默认时区 - handler := middleware.TimezoneWithDefault(tools.UTC)(http.HandlerFunc(handler)) - http.Handle("/api", handler) - http.ListenAndServe(":8080", nil) -} -``` - -#### 在业务代码中使用时区 - -**推荐方式:通过 factory 使用(黑盒模式)** - -```go -import ( - "net/http" - "git.toowon.com/jimmy/go-common/factory" -) - -func GetUserList(w http.ResponseWriter, r *http.Request) { - fac, _ := factory.NewFactoryFromFile("config.json") - - // 从请求中获取时区 - timezone := fac.GetTimezone(r) - - // 使用时区进行时间处理 - now := fac.Now(timezone) - - // 查询数据时使用时区 - startTime := fac.StartOfDay(now, timezone) - endTime := fac.EndOfDay(now, timezone) - - // 返回数据 - fac.Success(w, map[string]interface{}{ - "timezone": timezone, - "startTime": fac.FormatDateTime(startTime), - "endTime": fac.FormatDateTime(endTime), - }) -} -``` - -**或者直接使用 tools 包:** - -```go -import ( - "net/http" - commonhttp "git.toowon.com/jimmy/go-common/http" - "git.toowon.com/jimmy/go-common/tools" -) - -func GetUserList(w http.ResponseWriter, r *http.Request) { - h := commonhttp.NewHandler(w, r) - - // 从Handler获取时区 - timezone := h.GetTimezone() - - // 使用时区进行时间处理 - now := tools.Now(timezone) - - // 查询数据时使用时区 - startTime := tools.StartOfDay(now, timezone) - endTime := tools.EndOfDay(now, timezone) - - // 返回数据 - h.Success(map[string]interface{}{ - "timezone": timezone, - "startTime": tools.FormatDateTime(startTime), - "endTime": tools.FormatDateTime(endTime), - }) -} -``` - -### 请求头格式 - -客户端需要在请求头中传递时区信息: - -``` -X-Timezone: Asia/Shanghai -``` - -支持的时区格式(IANA时区数据库): -- `Asia/Shanghai` -- `America/New_York` -- `Europe/London` -- `UTC` -- 等等 - -### 注意事项 - -1. 如果请求头中未传递 `X-Timezone`,默认使用 `AsiaShanghai` -2. 如果传递的时区无效,会自动回退到默认时区 -3. 时区信息存储在context中,可以在整个请求生命周期中使用 -4. 建议在CORS配置中包含 `X-Timezone` 请求头 - -## 日志中间件 - -### 功能说明 - -日志中间件用于自动记录每个HTTP请求的详细信息,帮助监控和调试应用。 - -记录内容包括: -- 请求方法、路径、查询参数 -- 响应状态码、响应大小 -- 请求处理时间(毫秒) -- 客户端IP地址(支持X-Forwarded-For) -- User-Agent、Referer等信息 - -### 使用方法 - -#### 基本使用(使用默认logger) - -```go -import ( - "net/http" - "git.toowon.com/jimmy/go-common/middleware" -) - -func main() { - chain := middleware.NewChain( - middleware.Logging(nil), // 使用默认配置 - middleware.CORS(), - middleware.Timezone, - ) - - handler := chain.ThenFunc(apiHandler) - http.Handle("/api", handler) - http.ListenAndServe(":8080", nil) -} -``` - -#### 使用自定义logger - -```go -import ( - "git.toowon.com/jimmy/go-common/middleware" - "git.toowon.com/jimmy/go-common/logger" -) - -func main() { - // 创建自定义logger(异步模式,输出到文件) - loggerConfig := &logger.LoggerConfig{ - Level: "info", - Output: "file", - FilePath: "./logs/app.log", - Async: true, // 异步模式,不阻塞请求 - BufferSize: 1000, - } - myLogger, _ := logger.NewLogger(loggerConfig) - - // 配置日志中间件 - loggingConfig := &middleware.LoggingConfig{ - Logger: myLogger, - SkipPaths: []string{"/health", "/metrics"}, // 跳过健康检查接口 - } - - chain := middleware.NewChain( - middleware.Logging(loggingConfig), - middleware.CORS(), - middleware.Timezone, - ) - - handler := chain.ThenFunc(apiHandler) - http.Handle("/api", handler) - http.ListenAndServe(":8080", nil) -} -``` - -### LoggingConfig 配置说明 - -| 字段 | 类型 | 说明 | 默认值 | -|------|------|------|--------| -| Logger | *logger.Logger | 日志记录器 | 创建默认logger(stdout) | -| SkipPaths | []string | 跳过记录的路径列表 | [] | -| LogRequestBody | bool | 是否记录请求体(慎用) | false | -| LogResponseBody | bool | 是否记录响应体(慎用) | false | - -### 日志输出示例 - -``` -[INFO] HTTP Request method=GET path=/api/users query= status=200 size=1024 duration=45 ip=192.168.1.100 user_agent=Mozilla/5.0 referer= -[WARN] HTTP Request method=POST path=/api/users query= status=400 size=128 duration=12 ip=192.168.1.100 user_agent=PostmanRuntime/7.29 -[ERROR] HTTP Request method=GET path=/api/error query= status=500 size=256 duration=89 ip=192.168.1.100 user_agent=curl/7.64.1 referer= -``` - -### 注意事项 - -1. **异步模式推荐**:生产环境建议使用异步logger,避免日志写入阻塞请求 -2. **跳过路径**:健康检查、监控接口等高频接口建议跳过日志记录 -3. **日志级别**:根据状态码自动选择日志级别(5xx=ERROR, 4xx=WARN, 2xx-3xx=INFO) -4. **客户端IP**:自动从X-Forwarded-For、X-Real-IP或RemoteAddr获取真实IP - -## Recovery中间件 - -### 功能说明 - -Recovery中间件用于捕获HTTP处理过程中的panic,防止panic导致整个服务崩溃。 - -功能包括: -- 捕获panic并恢复服务 -- 记录panic信息和堆栈跟踪 -- 返回500错误响应 -- 支持自定义错误处理 - -### 使用方法 - -#### 基本使用(使用默认配置) - -```go -import ( - "net/http" - "git.toowon.com/jimmy/go-common/middleware" -) - -func main() { - chain := middleware.NewChain( - middleware.Recovery(nil), // 使用默认配置 - middleware.Logging(nil), - middleware.CORS(), - middleware.Timezone, - ) - - handler := chain.ThenFunc(apiHandler) - http.Handle("/api", handler) - http.ListenAndServe(":8080", nil) -} -``` - -#### 使用自定义logger - -```go -import ( - "git.toowon.com/jimmy/go-common/middleware" - "git.toowon.com/jimmy/go-common/logger" -) - -func main() { - myLogger, _ := logger.NewLogger(nil) - - recoveryConfig := &middleware.RecoveryConfig{ - Logger: myLogger, - EnableStackTrace: true, // 启用堆栈跟踪 - } - - chain := middleware.NewChain( - middleware.Recovery(recoveryConfig), - middleware.Logging(nil), - ) - - handler := chain.ThenFunc(apiHandler) - http.Handle("/api", handler) - http.ListenAndServe(":8080", nil) -} -``` - -#### 自定义错误响应 - -```go -import ( - "net/http" - commonhttp "git.toowon.com/jimmy/go-common/http" - "git.toowon.com/jimmy/go-common/middleware" -) - -func main() { - recoveryConfig := &middleware.RecoveryConfig{ - EnableStackTrace: true, - CustomHandler: func(w http.ResponseWriter, r *http.Request, err interface{}) { - // 使用统一的JSON响应格式 - h := commonhttp.NewHandler(w, r) - h.SystemError("服务器内部错误") - }, - } - - chain := middleware.NewChain( - middleware.Recovery(recoveryConfig), - ) - - handler := chain.ThenFunc(apiHandler) - http.Handle("/api", handler) - http.ListenAndServe(":8080", nil) -} -``` - -### RecoveryConfig 配置说明 - -| 字段 | 类型 | 说明 | 默认值 | -|------|------|------|--------| -| Logger | *logger.Logger | 日志记录器 | 创建默认logger | -| EnableStackTrace | bool | 是否记录堆栈跟踪 | true | -| CustomHandler | func(...) | 自定义错误处理函数 | nil(返回500文本) | - -### 注意事项 - -1. **放在最外层**:Recovery中间件应该放在中间件链的最前面,以捕获所有panic -2. **日志记录**:建议配置logger,确保panic信息被记录下来 -3. **堆栈跟踪**:生产环境建议启用,方便排查问题 -4. **自定义响应**:可以自定义错误响应格式,统一错误处理 - -## 限流中间件 - -### 功能说明 - -限流中间件用于限制请求频率,防止API被滥用或遭受攻击。 - -特性: -- 基于令牌桶算法 -- 支持按IP、用户ID等维度限流 -- 自动设置限流响应头 -- 内存存储,自动清理过期数据 - -### 使用方法 - -#### 基本使用(默认配置:100请求/分钟) - -```go -import ( - "net/http" - "git.toowon.com/jimmy/go-common/middleware" -) - -func main() { - chain := middleware.NewChain( - middleware.Recovery(nil), - middleware.Logging(nil), - middleware.RateLimit(nil), // 默认100请求/分钟,按IP限流 - middleware.CORS(), - ) - - handler := chain.ThenFunc(apiHandler) - http.Handle("/api", handler) - http.ListenAndServe(":8080", nil) -} -``` - -#### 自定义限流规则 - -```go -import ( - "time" - "git.toowon.com/jimmy/go-common/middleware" -) - -func main() { - // 创建限流器:10请求/分钟 - limiter := middleware.NewTokenBucketLimiter(10, time.Minute) - - rateLimitConfig := &middleware.RateLimitConfig{ - Limiter: limiter, - } - - chain := middleware.NewChain( - middleware.RateLimit(rateLimitConfig), - ) - - handler := chain.ThenFunc(apiHandler) - http.Handle("/api", handler) - http.ListenAndServe(":8080", nil) -} -``` - -#### 按用户ID限流 - -```go -import ( - "net/http" - "time" - "git.toowon.com/jimmy/go-common/middleware" -) - -func main() { - limiter := middleware.NewTokenBucketLimiter(100, time.Minute) - - rateLimitConfig := &middleware.RateLimitConfig{ - Limiter: limiter, - KeyFunc: func(r *http.Request) string { - // 从请求头或JWT token中获取用户ID - userID := r.Header.Get("X-User-ID") - if userID != "" { - return "user:" + userID - } - // 如果没有用户ID,使用IP - return "ip:" + r.RemoteAddr - }, - OnRateLimitExceeded: func(w http.ResponseWriter, r *http.Request, key string) { - // 记录限流事件 - println("Rate limit exceeded for:", key) - }, - } - - chain := middleware.NewChain( - middleware.RateLimit(rateLimitConfig), - ) - - handler := chain.ThenFunc(apiHandler) - http.Handle("/api", handler) - http.ListenAndServe(":8080", nil) -} -``` - -#### 便捷函数 - -```go -// 按IP限流:10请求/分钟 -chain := middleware.NewChain( - middleware.RateLimitByIP(10, time.Minute), -) - -// 或使用自定义速率 -chain := middleware.NewChain( - middleware.RateLimitWithRate(50, time.Minute), -) -``` - -### RateLimitConfig 配置说明 - -| 字段 | 类型 | 说明 | 默认值 | -|------|------|------|--------| -| Limiter | RateLimiter | 限流器实例 | 100请求/分钟 | -| KeyFunc | func(*http.Request) string | 生成限流键的函数 | 使用客户端IP | -| OnRateLimitExceeded | func(...) | 限流触发回调 | nil | - -### 响应头说明 - -限流中间件会自动设置以下响应头: - -| 响应头 | 说明 | -|--------|------| -| X-RateLimit-Limit | 窗口期内允许的请求数 | -| X-RateLimit-Remaining | 当前窗口剩余配额 | -| X-RateLimit-Reset | 配额重置时间(Unix时间戳) | -| Retry-After | 限流时,建议重试的等待时间(秒) | - -### 响应示例 - -正常请求: -``` -HTTP/1.1 200 OK -X-RateLimit-Limit: 100 -X-RateLimit-Remaining: 95 -X-RateLimit-Reset: 1640000000 -``` - -触发限流: -``` -HTTP/1.1 429 Too Many Requests -X-RateLimit-Limit: 100 -X-RateLimit-Remaining: 0 -X-RateLimit-Reset: 1640000000 -Retry-After: 45 -Too Many Requests -``` - -### 注意事项 - -1. **内存存储**:当前实现使用内存存储,适用于单机部署。如需分布式限流,建议使用Redis -2. **键设计**:合理设计限流键,可以按IP、用户、API等维度限流 -3. **清理机制**:自动清理过期的限流数据,避免内存泄漏 -4. **算法选择**:使用令牌桶算法,支持突发流量 - -## 中间件链 - -### 功能说明 - -中间件链提供便捷的中间件组合方式,支持链式调用。 - -### 使用方法 - -```go -import ( - "net/http" - "git.toowon.com/jimmy/go-common/middleware" -) - -func handler(w http.ResponseWriter, r *http.Request) { - // 处理请求 -} - -func main() { - // 创建中间件链 - chain := middleware.NewChain( - middleware.CORS(), - middleware.Timezone, - ) - - // 应用到处理器 - handler := chain.ThenFunc(handler) - - http.Handle("/api", handler) - http.ListenAndServe(":8080", nil) -} -``` - -#### 链式追加中间件 - -```go -chain := middleware.NewChain(middleware.CORS()) -chain.Append(middleware.Timezone) - -handler := chain.ThenFunc(handler) -``` - -## 完整示例 - -### 示例1:完整的生产级中间件配置 - -```go -package main - -import ( - "log" - "net/http" - "time" - - "git.toowon.com/jimmy/go-common/middleware" - "git.toowon.com/jimmy/go-common/logger" - commonhttp "git.toowon.com/jimmy/go-common/http" - "git.toowon.com/jimmy/go-common/tools" -) - -func apiHandler(w http.ResponseWriter, r *http.Request) { - h := commonhttp.NewHandler(w, r) - - // 从Handler获取时区 - timezone := h.GetTimezone() - now := tools.Now(timezone) - - h.Success(map[string]interface{}{ - "message": "Hello", - "timezone": timezone, - "time": tools.FormatDateTime(now), - }) -} - -func main() { - // 1. 配置logger(异步模式,输出到文件) - loggerConfig := &logger.LoggerConfig{ - Level: "info", - Output: "both", // 同时输出到stdout和文件 - FilePath: "./logs/app.log", - Async: true, - BufferSize: 1000, - } - myLogger, err := logger.NewLogger(loggerConfig) - if err != nil { - log.Fatal(err) - } - defer myLogger.Close() // 确保程序退出时关闭logger - - // 2. 配置CORS - corsConfig := &middleware.CORSConfig{ - AllowedOrigins: []string{"*"}, - AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, - AllowedHeaders: []string{"Content-Type", "Authorization", "X-Timezone"}, - } - - // 3. 配置日志中间件 - loggingConfig := &middleware.LoggingConfig{ - Logger: myLogger, - SkipPaths: []string{"/health", "/metrics"}, - } - - // 4. 配置Recovery中间件 - recoveryConfig := &middleware.RecoveryConfig{ - Logger: myLogger, - EnableStackTrace: true, - CustomHandler: func(w http.ResponseWriter, r *http.Request, err interface{}) { - h := commonhttp.NewHandler(w, r) - h.SystemError("服务器内部错误") - }, - } - - // 5. 配置限流中间件(100请求/分钟) - rateLimiter := middleware.NewTokenBucketLimiter(100, time.Minute) - rateLimitConfig := &middleware.RateLimitConfig{ - Limiter: rateLimiter, - OnRateLimitExceeded: func(w http.ResponseWriter, r *http.Request, key string) { - myLogger.Warnf(map[string]interface{}{ - "key": key, - "path": r.URL.Path, - }, "Rate limit exceeded") - }, - } - - // 6. 创建中间件链(顺序很重要!) - chain := middleware.NewChain( - middleware.Recovery(recoveryConfig), // 最外层:捕获panic - middleware.Logging(loggingConfig), // 日志记录 - middleware.RateLimit(rateLimitConfig), // 限流 - middleware.CORS(corsConfig), // CORS处理 - middleware.Timezone, // 时区处理 - ) - - // 7. 应用中间件 - http.Handle("/api", chain.ThenFunc(apiHandler)) - http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte("OK")) - }) - - log.Println("Server started on :8080") - log.Fatal(http.ListenAndServe(":8080", nil)) -} -``` - -### 示例2:基础的CORS + 时区中间件 - -```go -package main - -import ( - "log" - "net/http" - - "git.toowon.com/jimmy/go-common/middleware" - commonhttp "git.toowon.com/jimmy/go-common/http" - "git.toowon.com/jimmy/go-common/tools" -) - -func apiHandler(w http.ResponseWriter, r *http.Request) { - h := commonhttp.NewHandler(w, r) - - // 从Handler获取时区 - timezone := h.GetTimezone() - now := tools.Now(timezone) - - h.Success(map[string]interface{}{ - "message": "Hello", - "timezone": timezone, - "time": tools.FormatDateTime(now), - }) -} - -func main() { - // 创建简单的中间件链 - chain := middleware.NewChain( - middleware.CORS(nil), // 使用默认CORS配置 - middleware.Timezone, // 使用默认时区 - ) - - http.Handle("/api", chain.ThenFunc(apiHandler)) - - log.Println("Server started on :8080") - log.Fatal(http.ListenAndServe(":8080", nil)) -} -``` - -### 示例2:与路由框架集成 - -```go -package main - -import ( - "net/http" - - "git.toowon.com/jimmy/go-common/middleware" - commonhttp "git.toowon.com/jimmy/go-common/http" -) - -func main() { - mux := http.NewServeMux() - - // 创建中间件链 - chain := middleware.NewChain( - middleware.CORS(), - middleware.Timezone, - ) - - // 应用中间件到所有路由 - mux.Handle("/api/users", chain.ThenFunc(getUsers)) - mux.Handle("/api/posts", chain.ThenFunc(getPosts)) - - http.ListenAndServe(":8080", mux) -} - -func getUsers(w http.ResponseWriter, r *http.Request) { - h := commonhttp.NewHandler(w, r) - timezone := h.GetTimezone() - // 处理逻辑 - h.Success(nil) -} - -func getPosts(w http.ResponseWriter, r *http.Request) { - h := commonhttp.NewHandler(w, r) - timezone := h.GetTimezone() - // 处理逻辑 - h.Success(nil) -} -``` - -## API 参考 - -### CORS中间件 - -#### CORS(config ...*CORSConfig) func(http.Handler) http.Handler - -创建CORS中间件。 - -**参数:** -- `config`: 可选的CORS配置,不指定则使用默认配置 - -**返回:** 中间件函数 - -#### DefaultCORSConfig() *CORSConfig - -返回默认的CORS配置。 - -### 时区中间件 - -#### Timezone(next http.Handler) http.Handler - -时区处理中间件(默认时区为 AsiaShanghai)。 - -#### TimezoneWithDefault(defaultTimezone string) func(http.Handler) http.Handler - -时区处理中间件(可自定义默认时区)。 - -**参数:** -- `defaultTimezone`: 默认时区字符串 - -**返回:** 中间件函数 - -#### GetTimezoneFromContext(ctx context.Context) string - -从context中获取时区。 - -### 日志中间件 - -#### Logging(config *LoggingConfig) func(http.Handler) http.Handler - -创建日志中间件。 - -**参数:** -- `config`: 日志配置,nil则使用默认配置 - -**返回:** 中间件函数 - -### Recovery中间件 - -#### Recovery(config *RecoveryConfig) func(http.Handler) http.Handler - -创建Recovery中间件。 - -**参数:** -- `config`: Recovery配置,nil则使用默认配置 - -**返回:** 中间件函数 - -#### RecoveryWithLogger(log *logger.Logger) func(http.Handler) http.Handler - -使用指定logger的Recovery中间件(便捷函数)。 - -#### RecoveryWithCustomHandler(customHandler func(...)) func(http.Handler) http.Handler - -使用自定义错误处理的Recovery中间件(便捷函数)。 - -### 限流中间件 - -#### RateLimit(config *RateLimitConfig) func(http.Handler) http.Handler - -创建限流中间件。 - -**参数:** -- `config`: 限流配置,nil则使用默认配置(100请求/分钟) - -**返回:** 中间件函数 - -#### NewTokenBucketLimiter(rate int, windowSize time.Duration) RateLimiter - -创建令牌桶限流器。 - -**参数:** -- `rate`: 每个窗口期允许的请求数 -- `windowSize`: 窗口大小 - -**返回:** 限流器实例 - -#### RateLimitWithRate(rate int, windowSize time.Duration) func(http.Handler) http.Handler - -使用指定速率创建限流中间件(便捷函数)。 - -#### RateLimitByIP(rate int, windowSize time.Duration) func(http.Handler) http.Handler - -按IP限流(便捷函数)。 - -### 中间件链 - -#### NewChain(middlewares ...func(http.Handler) http.Handler) *Chain - -创建新的中间件链。 - -#### (c *Chain) Then(handler http.Handler) http.Handler - -将中间件链应用到处理器。 - -#### (c *Chain) ThenFunc(handler http.HandlerFunc) http.Handler - -将中间件链应用到处理器函数。 - -#### (c *Chain) Append(middlewares ...func(http.Handler) http.Handler) *Chain - -追加中间件到链中。 - -## 注意事项 - -### 1. CORS配置 -- 生产环境建议明确指定允许的源,避免使用 "*" -- 如果使用凭证(cookies),必须明确指定源,不能使用 "*" -- CORS中间件应该在Recovery和Logging之后,以便正确处理预检请求 - -### 2. 时区处理 -- 时区信息存储在context中,确保中间件在处理器之前执行 -- 时区验证失败时会自动回退到默认时区,不会返回错误 -- 建议在CORS配置中包含 `X-Timezone` 请求头 - -### 3. 日志记录 -- **生产环境推荐异步模式**:避免日志写入阻塞请求,提升性能 -- **跳过高频接口**:健康检查、监控接口等高频接口建议跳过日志 -- **日志轮转**:使用文件输出时,建议配合日志轮转工具(如logrotate) -- **敏感信息**:不要记录请求体和响应体,避免泄露敏感信息 - -### 4. Panic恢复 -- **放在最外层**:Recovery中间件应该放在中间件链的最前面 -- **记录日志**:务必配置logger,确保panic信息被记录 -- **监控告警**:建议将panic事件接入监控系统,及时发现问题 -- **堆栈跟踪**:生产环境建议启用,方便排查问题 - -### 5. 限流配置 -- **合理设置阈值**:根据实际业务需求设置限流阈值 -- **分布式部署**:当前实现使用内存存储,适用于单机。分布式部署建议使用Redis -- **键设计**:合理设计限流键,可以按IP、用户、API等维度限流 -- **响应头**:客户端可以根据X-RateLimit-*响应头实现智能重试 - -### 6. 中间件顺序(推荐) -建议的中间件顺序(从外到内): -1. **Recovery** - 最外层,捕获所有panic -2. **Logging** - 记录所有请求(包括限流的请求) -3. **RateLimit** - 限流保护 -4. **CORS** - 处理跨域 -5. **Timezone** - 时区处理 -6. **业务中间件** - 认证、授权等 - -```go -chain := middleware.NewChain( - middleware.Recovery(recoveryConfig), // 1. Panic恢复 - middleware.Logging(loggingConfig), // 2. 日志记录 - middleware.RateLimit(rateLimitConfig), // 3. 限流 - middleware.CORS(corsConfig), // 4. CORS - middleware.Timezone, // 5. 时区 -) -``` - -### 7. 性能考虑 -- **异步日志**:使用异步logger,避免IO阻塞 -- **限流算法**:令牌桶算法支持突发流量 -- **自动清理**:限流数据会自动清理,避免内存泄漏 -- **跳过路径**:合理使用SkipPaths,减少不必要的处理 - -### 8. 生产环境建议 -- 使用异步logger,配置日志文件和轮转 -- 启用Recovery中间件,配置告警 -- 根据业务设置合理的限流阈值 -- 配置监控指标(请求量、错误率、限流触发次数等) -- 定期review日志,优化性能瓶颈 - diff --git a/docs/migration.md b/docs/migration.md deleted file mode 100644 index 1bc1d9c..0000000 --- a/docs/migration.md +++ /dev/null @@ -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) -- `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` - diff --git a/docs/sms.md b/docs/sms.md deleted file mode 100644 index c858017..0000000 --- a/docs/sms.md +++ /dev/null @@ -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` - diff --git a/docs/storage.md b/docs/storage.md deleted file mode 100644 index 7fa7252..0000000 --- a/docs/storage.md +++ /dev/null @@ -1,571 +0,0 @@ -# 存储工具文档 - -## 概述 - -存储工具提供了文件上传和查看功能,支持 **本地文件夹(Local)**、OSS 和 MinIO 三种存储方式,并提供HTTP处理器用于文件上传和代理查看。 - -## 功能特性 - -- 支持本地文件夹存储(Local) -- 支持OSS对象存储(阿里云、腾讯云、AWS、七牛云等) -- 支持MinIO对象存储 -- 提供统一的存储接口 -- 支持文件上传HTTP处理器 -- 支持文件代理查看HTTP处理器 -- 支持文件大小和扩展名限制 -- 自动生成唯一文件名 -- 支持自定义对象键前缀 - -## 使用方法 - -### 0. 工厂调用方式(推荐) - -当你使用 `factory` 黑盒模式时,外部项目无需关心底层是 Local/MinIO/OSS: - -```go -import ( - "context" - "os" - - "git.toowon.com/jimmy/go-common/factory" - "git.toowon.com/jimmy/go-common/storage" -) - -fac, _ := factory.NewFactoryFromFile("./config.json") - -f, _ := os.Open("test.jpg") -defer f.Close() - -objectKey := storage.GenerateObjectKeyWithDate("uploads/images", "test.jpg") -url, err := fac.UploadFile(context.Background(), objectKey, f, "image/jpeg") -if err != nil { - panic(err) -} -_ = url -``` - -### 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) -} - -// 创建本地存储实例 -localStorage, err := storage.NewStorage(storage.StorageTypeLocal, 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) -``` - -### 2.1 本地存储配置示例 - -`config.json` 增加 `localStorage` 配置段: - -```json -{ - "localStorage": { - "baseDir": "./uploads", - "publicURL": "http://localhost:8080/file?key={objectKey}" - } -} -``` - -说明: -- **baseDir**:文件保存根目录 -- **publicURL**:用于 `GetURL()` 返回对外可访问的 URL - - 推荐配合本文的 `ProxyHandler`,示例 `http://localhost:8080/file?key={objectKey}` - - `{objectKey}` 会自动做 `url.QueryEscape` 处理 - -### 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) -``` - -**本地存储建议搭配:** -- `POST /upload` 上传文件(返回 `url`) -- `GET /file?key=...` 通过代理读取本地文件并返回二进制内容 - -**查看请求示例:** -``` -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` - diff --git a/email/email.go b/email/email.go index 111b4a5..2a0972e 100644 --- a/email/email.go +++ b/email/email.go @@ -2,18 +2,38 @@ 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 创建邮件发送器 @@ -21,28 +41,55 @@ func NewEmail(cfg *config.Config) *Email { if cfg == nil || cfg.Email == nil { return &Email{config: nil} } - return &Email{config: cfg.Email} + 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) + } + } } -// getEmailConfig 获取邮件配置(内部方法) func (e *Email) getEmailConfig() (*config.EmailConfig, error) { if e.config == nil { return nil, fmt.Errorf("email config is nil") } - if e.config.Host == "" { return nil, fmt.Errorf("email host is required") } - if e.config.Username == "" { return nil, fmt.Errorf("email username is required") } - if e.config.Password == "" { return nil, fmt.Errorf("email password is required") } - - // 设置默认值 if e.config.Port == 0 { e.config.Port = 587 } @@ -50,224 +97,177 @@ func (e *Email) getEmailConfig() (*config.EmailConfig, error) { e.config.From = e.config.Username } if e.config.Timeout == 0 { - e.config.Timeout = 30 + e.config.Timeout = 5 } - 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正文(可选,如果设置了会优先使用) + To []string + Cc []string + Bcc []string + Subject string + Body string HTMLBody string } -// SendEmail 发送邮件 -// to: 收件人列表 -// subject: 邮件主题 -// body: 邮件正文(纯文本) -// htmlBody: HTML正文(可选,如果设置了会优先使用) +// 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, - } - + msg := &Message{To: to, Subject: subject, Body: body} if len(htmlBody) > 0 && htmlBody[0] != "" { msg.HTMLBody = htmlBody[0] } - return e.send(msg, cfg) } -// send 发送邮件(内部方法) +// 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 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, cfg) if err != nil { return fmt.Errorf("failed to build email body: %w", err) } - // 合并收件人列表 recipients := append(msg.To, msg.Cc...) recipients = append(recipients, msg.Bcc...) - // 连接SMTP服务器 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, cfg.Host) if err != nil { return fmt.Errorf("failed to create SMTP client: %w", err) } defer client.Close() - // TLS/SSL处理 - if cfg.UseSSL { - // SSL模式(端口通常是465) - tlsConfig := &tls.Config{ - ServerName: cfg.Host, - } - if err := client.StartTLS(tlsConfig); err != nil { - return fmt.Errorf("failed to start TLS: %w", err) - } - } else if cfg.UseTLS { - // TLS模式(STARTTLS,端口通常是587) - tlsConfig := &tls.Config{ - ServerName: cfg.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(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(emailBody) - 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 } -// buildEmailBody 构建邮件内容 func (e *Email) buildEmailBody(msg *Message, cfg *config.EmailConfig) ([]byte, error) { var buf bytes.Buffer - - // 邮件头 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 "" @@ -278,4 +278,3 @@ func joinEmails(emails []string) string { } return result } - diff --git a/examples/email_example.go b/examples/email_example.go index 82134af..0e2c784 100644 --- a/examples/email_example.go +++ b/examples/email_example.go @@ -8,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 - - - -

这是原始邮件内容

-

由外部完全控制邮件格式和内容

- - -`) - - 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格式的邮件。

-

支持富文本格式。

- - -` - - err = mailer.SendHTML( - []string{"recipient@example.com"}, - "HTML邮件测试", - htmlBody, - ) - if err != nil { - log.Printf("Failed to send HTML email: %v", err) - } else { - fmt.Println("HTML email sent successfully") - } - - // 示例4:发送完整邮件(包含抄送、密送) - fmt.Println("\n=== Example 4: Send Full Email ===") - msg := &email.Message{ - To: []string{"to@example.com"}, - Cc: []string{"cc@example.com"}, - Bcc: []string{"bcc@example.com"}, - Subject: "完整邮件示例", - Body: "这是纯文本正文", - HTMLBody: ` - - -

这是HTML正文

-

支持同时发送纯文本和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.") + // 异步发送(HTTP 通知类) + mail.SendEmailAsync(nil, []string{"recipient@example.com"}, "异步通知", "后台发送,不阻塞请求") + fmt.Println("async email enqueued") } - diff --git a/examples/excel_example.go b/examples/excel_example.go index 6b46e41..7ac9701 100644 --- a/examples/excel_example.go +++ b/examples/excel_example.go @@ -6,7 +6,7 @@ package main import ( "fmt" "log" - "net/http" + "os" "time" "git.toowon.com/jimmy/go-common/excel" @@ -14,7 +14,6 @@ import ( "git.toowon.com/jimmy/go-common/tools" ) -// User 用户结构体示例 type User struct { ID int `json:"id"` Name string `json:"name"` @@ -24,213 +23,47 @@ type User struct { } func main() { - // 创建工厂(可选,Excel导出不需要配置) - fac, err := factory.NewFactoryFromFile("./config/example.json") - if err != nil { - // Excel导出不需要配置,可以传nil - fac = factory.NewFactory(nil) - } - - // 示例1:导出结构体切片到文件 - fmt.Println("=== Example 1: Export Struct Slice to File ===") - example1(fac) - - // 示例2:导出到HTTP响应 - fmt.Println("\n=== Example 2: Export to HTTP Response ===") - example2(fac) - - // 示例3:使用格式化函数 - fmt.Println("\n=== Example 3: Export with Format Functions ===") - example3(fac) - - // 示例4:使用ExportData接口 - fmt.Println("\n=== Example 4: Export with ExportData Interface ===") - example4(fac) -} - -// 示例1:导出结构体切片到文件 -func example1(fac *factory.Factory) { - 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().Add(-24 * time.Hour), Status: 1}, - {ID: 3, Name: "Charlie", Email: "charlie@example.com", CreatedAt: time.Now().Add(-48 * time.Hour), Status: 0}, - } - - columns := []factory.ExportColumn{ - {Header: "ID", Field: "ID", Width: 10}, - {Header: "姓名", Field: "Name", Width: 20}, - {Header: "邮箱", Field: "Email", Width: 30}, - {Header: "创建时间", Field: "CreatedAt", Width: 20}, - {Header: "状态", Field: "Status", Width: 10}, - } - - err := fac.ExportToExcelFile("users.xlsx", "用户列表", columns, users) - if err != nil { - log.Printf("Failed to export to file: %v", err) - } else { - fmt.Println("Excel file exported successfully: users.xlsx") - } -} - -// 示例2:导出到HTTP响应 -func example2(fac *factory.Factory) { - // 模拟HTTP响应 - w := &mockResponseWriter{} + 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: 1}, + {ID: 2, Name: "Bob", Email: "bob@example.com", CreatedAt: time.Now(), Status: 0}, } - columns := []factory.ExportColumn{ - {Header: "ID", Field: "ID"}, - {Header: "姓名", Field: "Name"}, - {Header: "邮箱", Field: "Email"}, - } - - // 设置HTTP响应头 - w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") - w.Header().Set("Content-Disposition", "attachment; filename=users.xlsx") - - err := fac.ExportToExcel(w, "用户列表", columns, users) - if err != nil { - log.Printf("Failed to export to HTTP response: %v", err) - } else { - fmt.Printf("Excel exported to HTTP response successfully, size: %d bytes\n", len(w.data)) - } -} - -// 示例3:使用格式化函数 -func example3(fac *factory.Factory) { - 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().Add(-24 * time.Hour), Status: 0}, - } - - columns := []factory.ExportColumn{ + columns := []excel.ExportColumn{ {Header: "ID", Field: "ID", Width: 10}, {Header: "姓名", Field: "Name", Width: 20}, {Header: "邮箱", Field: "Email", Width: 30}, { Header: "创建时间", Field: "CreatedAt", - Width: 20, - Format: excel.AdaptTimeFormatter(tools.FormatDateTime), // 使用适配器直接调用tools函数 - }, - { - Header: "状态", - Field: "Status", - Width: 10, - Format: func(value interface{}) string { - // 自定义格式化函数 - if status, ok := value.(int); ok { - if status == 1 { - return "启用" - } - return "禁用" - } - return "" - }, - }, - } - - err := fac.ExportToExcelFile("users_formatted.xlsx", "用户列表", columns, users) - if err != nil { - log.Printf("Failed to export with format: %v", err) - } else { - fmt.Println("Excel file exported with format successfully: users_formatted.xlsx") - } -} - -// 示例4:使用ExportData接口 -func example4(fac *factory.Factory) { - // 创建实现了ExportData接口的数据对象 - exportData := &UserExportData{ - 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: 1}, - }, - } - - // 使用接口方法获取列定义和数据 - columns := exportData.GetExportColumns() - - err := fac.ExportToExcelFile("users_interface.xlsx", "用户列表", columns, exportData) - if err != nil { - log.Printf("Failed to export with interface: %v", err) - } else { - fmt.Println("Excel file exported with interface successfully: users_interface.xlsx") - } -} - -// UserExportData 实现了ExportData接口的用户导出数据 -type UserExportData struct { - users []User -} - -// GetExportColumns 获取导出列定义 -func (d *UserExportData) GetExportColumns() []excel.ExportColumn { - return []excel.ExportColumn{ - {Header: "ID", Field: "ID", Width: 10}, - {Header: "姓名", Field: "Name", Width: 20}, - {Header: "邮箱", Field: "Email", Width: 30}, - { - Header: "创建时间", - Field: "CreatedAt", - Width: 20, Format: excel.AdaptTimeFormatter(tools.FormatDateTime), }, { Header: "状态", Field: "Status", - Width: 10, Format: func(value interface{}) string { - if status, ok := value.(int); ok { - if status == 1 { - return "启用" - } - return "禁用" + if status, ok := value.(int); ok && status == 1 { + return "启用" } - return "" + return "禁用" }, }, } -} -// GetExportRows 获取导出数据行 -func (d *UserExportData) GetExportRows() [][]interface{} { - rows := make([][]interface{}, 0, len(d.users)) - for _, user := range d.users { - row := []interface{}{ - user.ID, - user.Name, - user.Email, - user.CreatedAt, - user.Status, - } - rows = append(rows, row) + if err := ex.ExportToFile("users.xlsx", "用户列表", columns, users); err != nil { + log.Fatal(err) } - return rows -} + fmt.Println("exported users.xlsx") -// mockResponseWriter 模拟HTTP响应写入器(用于示例) -type mockResponseWriter struct { - header http.Header - data []byte -} - -func (w *mockResponseWriter) Header() http.Header { - if w.header == nil { - w.header = make(http.Header) + f, err := os.Create("users_http.xlsx") + if err != nil { + log.Fatal(err) } - return w.header -} - -func (w *mockResponseWriter) Write(data []byte) (int, error) { - w.data = append(w.data, data...) - return len(data), nil -} - -func (w *mockResponseWriter) WriteHeader(statusCode int) { - // 模拟实现 + defer f.Close() + if err := ex.ExportToWriter(f, "用户列表", columns, users); err != nil { + log.Fatal(err) + } + fmt.Println("exported users_http.xlsx") } diff --git a/examples/factory_blackbox_example.go b/examples/factory_blackbox_example.go deleted file mode 100644 index 2910b18..0000000 --- a/examples/factory_blackbox_example.go +++ /dev/null @@ -1,166 +0,0 @@ -package main - -import ( - "context" - "log" - "net/http" - "time" - - "git.toowon.com/jimmy/go-common/factory" -) - -// 示例: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) { - // 创建工厂(在处理器中也可以复用) - 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 != "" { - fac.Success(w, cached) - return - } - - // 3. 记录日志(黑盒方法,推荐) - fac.LogInfof(map[string]interface{}{ - "action": "list_users", - "count": len(users), - }, "查询用户列表") - - // 4. 缓存结果 - fac.RedisSet(ctx, cacheKey, users, 5*time.Minute) - - fac.Success(w, users) -} - -// 文件上传 -func handleUpload(w http.ResponseWriter, r *http.Request) { - fac, _ := factory.NewFactoryFromFile("config.json") - ctx := context.Background() - - // 解析上传的文件 - file, header, err := r.FormFile("file") - if err != nil { - fac.LogError("文件上传失败: %v", err) - fac.Error(w, 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) - fac.Error(w, 500, "文件上传失败") - return - } - - // 记录上传日志 - fac.LogInfof(map[string]interface{}{ - "filename": header.Filename, - "size": header.Size, - "url": url, - }, "文件上传成功") - - fac.Success(w, 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 == "" { - fac.Error(w, 401, "未授权") - return - } - - // 从Redis验证token(黑盒方法) - userID, err := fac.RedisGet(ctx, "token:"+token) - if err != nil || userID == "" { - fac.Error(w, 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, - }, "请求指标") - }) -} diff --git a/examples/http_handler_example.go b/examples/http_handler_example.go index c754192..7882d87 100644 --- a/examples/http_handler_example.go +++ b/examples/http_handler_example.go @@ -1,3 +1,6 @@ +//go:build example +// +build example + package main import ( @@ -6,107 +9,44 @@ import ( "git.toowon.com/jimmy/go-common/factory" commonhttp "git.toowon.com/jimmy/go-common/http" - "git.toowon.com/jimmy/go-common/tools" ) -// 用户结构 type User struct { ID int64 `json:"id"` Name string `json:"name"` Email string `json:"email"` } -// 获取用户列表(使用公共方法和factory) -func GetUserList(w http.ResponseWriter, r *http.Request) { - fac, _ := factory.NewFactoryFromFile("config.json") +func main() { + if err := factory.Init("config.json"); err != nil { + log.Fatal(err) + } + app := factory.Default() + chain := app.MiddlewareChain() - // 获取分页参数(使用公共方法) - pagination := commonhttp.ParsePaginationRequest(r) - page := pagination.GetPage() - pageSize := pagination.GetPageSize() + http.Handle("/users", chain.ThenFunc(listUsers)) + log.Fatal(http.ListenAndServe(":8080", nil)) +} - // 获取查询参数(使用公共方法) - _ = r.URL.Query().Get("keyword") // 示例:获取查询参数 +func listUsers(w http.ResponseWriter, r *http.Request) { + app := factory.Default() + i18n, _ := app.I18n() + h := commonhttp.NewHandler(w, r, commonhttp.WithI18n(i18n)) - // 模拟查询数据 + var req struct { + Keyword string `json:"keyword"` + commonhttp.PaginationRequest + } + if err := h.ParseJSON(&req); err != nil { + h.Error("common.invalid_request") + return + } + + p := h.Pagination() users := []User{ {ID: 1, Name: "User1", Email: "user1@example.com"}, {ID: 2, Name: "User2", Email: "user2@example.com"}, } - total := int64(100) - - // 返回分页响应(使用factory方法) - fac.SuccessPage(w, users, total, page, pageSize) -} - -// 创建用户(使用公共方法和factory) -func CreateUser(w http.ResponseWriter, r *http.Request) { - fac, _ := factory.NewFactoryFromFile("config.json") - - // 解析请求体(使用公共方法) - var req struct { - Name string `json:"name"` - Email string `json:"email"` - } - - if err := commonhttp.ParseJSON(r, &req); err != nil { - commonhttp.WriteJSON(w, http.StatusBadRequest, 400, "请求参数解析失败", nil) - return - } - - // 参数验证 - if req.Name == "" { - fac.Error(w, 1001, "用户名不能为空") - return - } - - // 模拟创建用户 - user := User{ - ID: 1, - Name: req.Name, - Email: req.Email, - } - - // 返回成功响应(使用factory方法,统一Success方法) - fac.Success(w, user, "创建成功") -} - -// 获取用户详情(使用公共方法和factory) -func GetUser(w http.ResponseWriter, r *http.Request) { - fac, _ := factory.NewFactoryFromFile("config.json") - - // 获取查询参数(使用公共方法) - id := tools.ConvertInt64(r.URL.Query().Get("id"), 0) - - if id == 0 { - commonhttp.WriteJSON(w, http.StatusBadRequest, 400, "用户ID不能为空", nil) - return - } - - // 模拟查询用户 - if id == 1 { - user := User{ID: 1, Name: "User1", Email: "user1@example.com"} - fac.Success(w, user) - } else { - fac.Error(w, 1002, "用户不存在") - } -} - -func main() { - // 使用标准http.HandleFunc - http.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - GetUserList(w, r) - case http.MethodPost: - CreateUser(w, r) - default: - commonhttp.WriteJSON(w, http.StatusMethodNotAllowed, 405, "方法不支持", nil) - } - }) - - http.HandleFunc("/user", GetUser) - - log.Println("Server started on :8080") - log.Fatal(http.ListenAndServe(":8080", nil)) + h.SuccessPage(users, 100) + _ = p } diff --git a/examples/http_pagination_example.go b/examples/http_pagination_example.go index ea127b1..04f73ae 100644 --- a/examples/http_pagination_example.go +++ b/examples/http_pagination_example.go @@ -1,3 +1,6 @@ +//go:build example +// +build example + package main import ( @@ -8,56 +11,47 @@ import ( commonhttp "git.toowon.com/jimmy/go-common/http" ) -// ListUserRequest 用户列表请求(包含分页字段) type ListUserRequest struct { - Keyword string `json:"keyword"` - commonhttp.PaginationRequest // 嵌入分页请求结构 + Keyword string `json:"keyword"` + commonhttp.PaginationRequest } -// User 用户结构 type User struct { ID int64 `json:"id"` Name string `json:"name"` Email string `json:"email"` } -// 获取用户列表(使用公共方法和factory) -func GetUserList(w http.ResponseWriter, r *http.Request) { - fac, _ := factory.NewFactoryFromFile("config.json") - 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请求体解析(分页字段会自动解析) +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 := commonhttp.ParseJSON(r, &req); err != nil { - commonhttp.WriteJSON(w, http.StatusBadRequest, 400, "请求参数解析失败", nil) + if err := h.ParseJSON(&req); err != nil { + h.Error("common.invalid_request") return } } else { - // 方式2:从查询参数解析分页 - pagination := commonhttp.ParsePaginationRequest(r) - req.PaginationRequest = *pagination + p := h.Pagination() + req.PaginationRequest = *p req.Keyword = r.URL.Query().Get("keyword") } - // 使用分页方法 - page := req.GetPage() // 获取页码(默认1) - pageSize := req.GetPageSize() // 获取每页数量(默认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) - - // 返回分页响应(使用factory方法) - fac.SuccessPage(w, users, total, page, pageSize) -} - -func main() { - http.HandleFunc("/users", GetUserList) - - log.Println("Server started on :8080") - log.Fatal(http.ListenAndServe(":8080", nil)) + h.SuccessPage(users, 100) } diff --git a/examples/i18n_example.go b/examples/i18n_example.go index 75f7296..af3295e 100644 --- a/examples/i18n_example.go +++ b/examples/i18n_example.go @@ -1,3 +1,6 @@ +//go:build example +// +build example + package main import ( @@ -8,94 +11,22 @@ import ( ) func main() { - // 创建工厂实例 - fac, err := factory.NewFactoryFromFile("config.json") + if err := factory.Init("config.json"); err != nil { + log.Fatal(err) + } + app := factory.Default() + + i18n, err := app.I18n() if err != nil { log.Fatal(err) } - // ====== 方式1:从目录加载多个语言文件(推荐) ====== - // 目录结构: - // locales/ - // zh-CN.json - // en-US.json - // ja-JP.json - - fac.InitI18n("zh-CN") // 设置默认语言为中文 - if err := fac.LoadI18nFromDir("locales"); err != nil { + if err := i18n.LoadFromDir("locales"); err != nil { log.Fatal(err) } - // 使用示例 - fmt.Println("=== 示例1:简单消息 ===") - msg1 := fac.GetMessage("zh-CN", "user.not_found") - fmt.Printf("中文: %s\n", msg1) - - msg2 := fac.GetMessage("en-US", "user.not_found") - fmt.Printf("英文: %s\n", msg2) - - // ====== 方式2:从单个文件加载 ====== - fmt.Println("\n=== 示例2:从单个文件加载 ===") - fac2, _ := factory.NewFactoryFromFile("config.json") - fac2.InitI18n("zh-CN") - fac2.LoadI18nFromFile("locales/zh-CN.json", "zh-CN") - fac2.LoadI18nFromFile("locales/en-US.json", "en-US") - - msg3 := fac2.GetMessage("zh-CN", "user.login_success") - fmt.Printf("中文: %s\n", msg3) - - // ====== 示例3:带参数的消息 ====== - fmt.Println("\n=== 示例3:带参数的消息 ===") - // 如果消息内容是 "欢迎,%s",可以使用参数 - msg4 := fac.GetMessage("zh-CN", "user.welcome", "Alice") - fmt.Printf("中文: %s\n", msg4) - - msg5 := fac.GetMessage("en-US", "user.welcome", "Alice") - fmt.Printf("英文: %s\n", msg5) - - // ====== 示例4:语言回退机制 ====== - fmt.Println("\n=== 示例4:语言回退机制 ===") - // 如果请求的语言不存在,会使用默认语言 - msg6 := fac.GetMessage("fr-FR", "user.not_found") // fr-FR 不存在,使用默认语言 zh-CN - fmt.Printf("法语(不存在,回退到默认语言): %s\n", msg6) - - // ====== 示例5:高级功能 ====== - fmt.Println("\n=== 示例5:高级功能 ===") - i18n, _ := fac.GetI18n() - langs := i18n.GetSupportedLangs() - fmt.Printf("支持的语言: %v\n", langs) - - hasLang := i18n.HasLang("en-US") - fmt.Printf("是否支持英文: %v\n", hasLang) - - // ====== 示例6:在HTTP处理中使用 ====== - fmt.Println("\n=== 示例6:在HTTP处理中使用 ===") - // 在实际HTTP处理中,可以从请求头获取语言 - // lang := r.Header.Get("Accept-Language") // 例如: "zh-CN,en-US;q=0.9" - // 简化示例,假设从请求中获取到语言代码 - userLang := "zh-CN" - errorMsg := fac.GetMessage(userLang, "user.not_found") - fmt.Printf("错误消息: %s\n", errorMsg) - - successMsg := fac.GetMessage(userLang, "user.login_success") - fmt.Printf("成功消息: %s\n", successMsg) - - // ====== 示例7:通过错误码直接返回国际化消息(推荐) ====== - fmt.Println("\n=== 示例7:通过错误码直接返回国际化消息 ===") - // 在实际HTTP处理中,Error 方法会自动识别消息代码并返回国际化消息 - // 需要确保使用了 middleware.Language 中间件(factory.GetMiddlewareChain() 已包含) - // - // 示例代码(在HTTP handler中): - // func handleGetUser(w http.ResponseWriter, r *http.Request) { - // fac, _ := factory.NewFactoryFromFile("config.json") - // - // // 如果用户不存在,直接传入消息代码,会自动获取国际化消息 - // fac.Error(w, r, 404, "user.not_found") - // // 自动从context获取语言(由middleware.Language设置),返回对应语言的错误消息 - // // 返回: {"code": 404, "message": "用户不存在", "message_code": "user.not_found"} - // } - // - // 如果请求头是 Accept-Language: zh-CN,返回: {"code": 404, "message": "用户不存在", "message_code": "user.not_found"} - // 如果请求头是 Accept-Language: en-US,返回: {"code": 404, "message": "User not found", "message_code": "user.not_found"} - fmt.Println("Error 方法会自动识别消息代码并返回国际化消息和消息代码") + 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()) } diff --git a/examples/logger_example.go b/examples/logger_example.go index b3aa820..d8036e0 100644 --- a/examples/logger_example.go +++ b/examples/logger_example.go @@ -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", + }) } - diff --git a/examples/middleware_simple_example.go b/examples/middleware_simple_example.go index 3d74c14..a26b157 100644 --- a/examples/middleware_simple_example.go +++ b/examples/middleware_simple_example.go @@ -1,40 +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) { - // 获取时区(使用公共方法) - timezone := commonhttp.GetTimezone(r) - - // 返回响应(使用公共方法) - commonhttp.Success(w, map[string]interface{}{ + h := commonhttp.NewHandler(w, r) + 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)) } - diff --git a/examples/migrations/README.md b/examples/migrations/README.md deleted file mode 100644 index 59bdd1f..0000000 --- a/examples/migrations/README.md +++ /dev/null @@ -1,139 +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:使用配置文件(推荐) - -```bash -# 使用默认配置文件 config.json -go run migrate.go up - -# 或指定配置文件路径 -go run migrate.go up -config /path/to/config.json -``` - -**Docker 中**: -```yaml -# docker-compose.yml -services: - app: - volumes: - # 挂载配置文件 - - ./config.json:/app/config.json:ro - command: sh -c "go run migrate.go up && ./app" -``` - -**注意**:配置文件中的数据库主机应使用服务名(`db`),不是 `localhost` - -## 更多信息 - -- [数据库迁移完整指南](../../MIGRATION.md) ⭐ -- [详细功能文档](../../docs/migration.md) diff --git a/examples/sms_example.go b/examples/sms_example.go index 273e392..263ef88 100644 --- a/examples/sms_example.go +++ b/examples/sms_example.go @@ -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", - } - - 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") + smsClient.SendSMSAsync(nil, []string{"13800138000"}, params) + fmt.Println("async sms enqueued") } - diff --git a/excel/excel.go b/excel/excel.go index 6615496..cce15a4 100644 --- a/excel/excel.go +++ b/excel/excel.go @@ -3,6 +3,7 @@ package excel import ( "fmt" "io" + "os" "reflect" "time" @@ -210,6 +211,16 @@ func (e *Excel) ExportToWriter(w io.Writer, sheetName string, columns []ExportCo 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对象,可用于高级操作 // diff --git a/factory/factory.go b/factory/factory.go index 5d5d107..8e13af5 100644 --- a/factory/factory.go +++ b/factory/factory.go @@ -3,23 +3,19 @@ package factory import ( "context" "fmt" - "io" "net/http" - "os" - "strings" + "sync" "time" "git.toowon.com/jimmy/go-common/config" "git.toowon.com/jimmy/go-common/email" "git.toowon.com/jimmy/go-common/excel" - commonhttp "git.toowon.com/jimmy/go-common/http" "git.toowon.com/jimmy/go-common/i18n" "git.toowon.com/jimmy/go-common/logger" "git.toowon.com/jimmy/go-common/middleware" "git.toowon.com/jimmy/go-common/migration" "git.toowon.com/jimmy/go-common/sms" "git.toowon.com/jimmy/go-common/storage" - "git.toowon.com/jimmy/go-common/tools" "github.com/redis/go-redis/v9" "gorm.io/driver/mysql" "gorm.io/driver/postgres" @@ -27,396 +23,111 @@ import ( "gorm.io/gorm" ) -// ========== HTTP响应结构体(暴露给外部项目使用) ========== +var ( + defaultFactory *Factory +) -// Response 标准响应结构(暴露给外部项目使用) -// 外部项目可以直接使用 factory.Response 创建响应对象 -// -// 示例: -// -// response := factory.Response{ -// Code: 0, -// Message: "success", -// Data: data, -// } -type Response = commonhttp.Response - -// PageResponse 分页响应结构(暴露给外部项目使用) -// 外部项目可以直接使用 factory.PageResponse 创建分页响应对象 -type PageResponse = commonhttp.PageResponse - -// PageData 分页数据(暴露给外部项目使用) -// 外部项目可以直接使用 factory.PageData 创建分页数据对象 -// -// 示例: -// -// pageData := &factory.PageData{ -// List: users, -// Total: 100, -// Page: 1, -// PageSize: 20, -// } -// fac.Success(w, pageData) -type PageData = commonhttp.PageData - -// ========== HTTP请求结构体(暴露给外部项目使用) ========== - -// PaginationRequest 分页请求结构(暴露给外部项目使用) -// 外部项目可以直接使用 factory.PaginationRequest 创建分页请求对象 -// -// 示例: -// -// type ListUserRequest struct { -// Keyword string `json:"keyword"` -// factory.PaginationRequest // 嵌入分页请求结构 -// } -type PaginationRequest = commonhttp.PaginationRequest - -// ========== Time工具结构体(暴露给外部项目使用) ========== - -// TimeInfo 详细时间信息结构(暴露给外部项目使用) -// 外部项目可以直接使用 factory.TimeInfo 创建时间信息对象 -type TimeInfo = tools.TimeInfo - -// Factory 工厂类 - 黑盒模式设计 -// -// 核心理念: -// -// 外部项目只需传递一个配置文件路径,即可直接使用所有功能, -// 无需关心内部实现细节。 -// -// 推荐使用的黑盒方法: -// - GetMiddlewareChain():获取配置好的中间件链 -// - LogInfo(), LogError():记录日志 -// - RedisSet(), RedisGet():操作Redis -// - SendEmail(), SendSMS():发送邮件和短信 -// - UploadFile(), GetFileURL():文件上传和访问 -// -// 需要获取客户端对象的场景(高级功能): -// - GetDatabase():数据库操作(GORM已经是很好的抽象) -// - GetRedisClient():Redis高级操作(Hash, List, Set, ZSet等) -// - GetLogger():Logger高级功能(Close等) -// -// 使用示例: -// -// // 1. 创建工厂(传入配置文件路径) -// fac, _ := factory.NewFactoryFromFile("config.json") -// -// // 2. 直接使用黑盒方法(推荐) -// fac.LogInfo("用户登录成功") -// fac.RedisSet(ctx, "session:123", "data", time.Hour) -// fac.SendEmail([]string{"user@example.com"}, "主题", "内容") -// chain := fac.GetMiddlewareChain() -// chain.Append(yourAuthMiddleware) // 添加自定义中间件 -// -// // 3. 获取客户端对象(仅在需要高级功能时) -// db, _ := fac.GetDatabase() -// db.Find(&users) -// -// Factory 工厂类,用于从配置创建各种客户端对象 +// Factory 工具库入口:配置加载 + lazy getter type Factory struct { cfg *config.Config - storage storage.Storage // 存储实例(延迟初始化) - logger *logger.Logger // 日志实例(延迟初始化) - email *email.Email // 邮件客户端(延迟初始化) - sms *sms.SMS // 短信客户端(延迟初始化) - db *gorm.DB // 数据库连接(延迟初始化) - redis *redis.Client // Redis客户端(延迟初始化) - i18n *i18n.I18n // 国际化工具(延迟初始化) - excel *excel.Excel // Excel导出器(延迟初始化) + storage storage.Storage + + logger *logger.Logger + email *email.Email + sms *sms.SMS + db *gorm.DB + redis *redis.Client + i18n *i18n.I18n + excel *excel.Excel + chain *middleware.Chain + + mu sync.Mutex } -// NewFactory 创建工厂实例 -func NewFactory(cfg *config.Config) *Factory { - return &Factory{ - cfg: cfg, +// Option Factory 可选项(支持重载模块实现) +type Option func(*Factory) + +// WithStorage 注入自定义存储实现 +func WithStorage(s storage.Storage) Option { + return func(f *Factory) { + f.storage = s } } -// NewFactoryFromFile 从配置文件创建工厂实例(便捷方法) -// filePath: 配置文件路径 -func NewFactoryFromFile(filePath string) (*Factory, error) { +// Init 从配置文件初始化全局 Factory(启动时调用一次) +func Init(filePath string, opts ...Option) error { cfg, err := config.LoadFromFile(filePath) - if err != nil { - return nil, fmt.Errorf("failed to load config: %w", err) - } - return NewFactory(cfg), nil -} - -// getEmailClient 获取邮件客户端(内部方法,延迟初始化) -func (f *Factory) getEmailClient() (*email.Email, error) { - if f.email != nil { - return f.email, nil - } - - f.email = email.NewEmail(f.cfg) - return f.email, nil -} - -// SendEmail 发送邮件(黑盒模式,推荐使用) -// 自动使用配置文件中的SMTP配置发送邮件 -// to: 收件人列表 -// subject: 邮件主题 -// body: 邮件正文(纯文本) -// htmlBody: HTML正文(可选,如果设置了会优先使用) -func (f *Factory) SendEmail(to []string, subject, body string, htmlBody ...string) error { - e, err := f.getEmailClient() if err != nil { return err } - return e.SendEmail(to, subject, body, htmlBody...) -} - -// getSMSClient 获取短信客户端(内部方法,延迟初始化) -func (f *Factory) getSMSClient() (*sms.SMS, error) { - if f.sms != nil { - return f.sms, nil + defaultFactory = New(cfg, opts...) + if l, err := defaultFactory.Logger(); err == nil { + logger.SetDefaultLogger(l) } - - f.sms = sms.NewSMS(f.cfg) - return f.sms, nil + return nil } -// SendSMS 发送短信(黑盒模式,推荐使用) -// 自动使用配置文件中的阿里云短信配置发送短信 -// phoneNumbers: 手机号列表 -// templateParam: 模板参数(map或JSON字符串) -// templateCode: 模板代码(可选,如果为空使用配置中的模板代码) -func (f *Factory) SendSMS(phoneNumbers []string, templateParam interface{}, templateCode ...string) (*sms.SendResponse, error) { - s, err := f.getSMSClient() - if err != nil { - return nil, err +// Default 获取全局 Factory +func Default() *Factory { + return defaultFactory +} + +// New 从配置创建 Factory +func New(cfg *config.Config, opts ...Option) *Factory { + f := &Factory{cfg: cfg} + for _, opt := range opts { + opt(f) } - return s.SendSMS(phoneNumbers, templateParam, templateCode...) + return f +} + +// Config 获取配置 +func (f *Factory) Config() *config.Config { + return f.cfg } -// getLogger 获取日志记录器(内部方法,延迟初始化) func (f *Factory) getLogger() (*logger.Logger, error) { if f.logger != nil { return f.logger, nil } - - var l *logger.Logger - var err error - if f.cfg.Logger == nil { - // 如果没有配置,使用默认配置创建 - l, err = logger.NewLogger(nil) - } else { - l, err = logger.NewLogger(f.cfg.Logger) + f.mu.Lock() + defer f.mu.Unlock() + if f.logger != nil { + return f.logger, nil } - + var cfg *config.LoggerConfig + if f.cfg != nil { + cfg = f.cfg.Logger + } + l, err := logger.NewLogger(cfg) if err != nil { return nil, fmt.Errorf("failed to create logger: %w", err) } - f.logger = l return l, nil } -// GetLogger 获取日志记录器对象(不推荐直接使用) -// -// ⚠️ 不推荐直接使用此方法,推荐使用黑盒方法: -// - LogDebug, LogInfo, LogWarn, LogError(记录简单日志) -// - LogDebugf, LogInfof, LogWarnf, LogErrorf(记录带字段的日志) -// -// 仅在以下高级场景时使用: -// - 需要调用 Close() 方法关闭logger -// - 需要使用logger的其他高级功能 -// -// 示例(不推荐): -// -// logger, _ := factory.GetLogger() -// defer logger.Close() -// -// 示例(推荐): -// -// factory.LogInfo("用户登录成功") -// factory.LogErrorf(map[string]interface{}{"user_id": 123}, "登录失败") -func (f *Factory) GetLogger() (*logger.Logger, error) { +// Logger 获取日志对象 +func (f *Factory) Logger() (*logger.Logger, error) { return f.getLogger() } -// LogDebug 记录调试日志(黑盒模式,推荐使用) -// 自动使用配置文件中的logger配置 -// message: 日志消息 -// args: 格式化参数(可选) -func (f *Factory) LogDebug(message string, args ...interface{}) { - l, err := f.getLogger() - if err != nil { - // 如果日志初始化失败,使用标准输出 - if len(args) > 0 { - fmt.Printf("[DEBUG] "+message+"\n", args...) - } else { - fmt.Printf("[DEBUG] %s\n", message) - } - return - } - if len(args) > 0 { - l.Debug(message, args...) - } else { - l.Debug(message) - } -} - -// LogDebugf 记录调试日志(带字段,黑盒模式,推荐使用) -// 自动使用配置文件中的logger配置 -// fields: 日志字段 -// message: 日志消息 -// args: 格式化参数(可选) -func (f *Factory) LogDebugf(fields map[string]interface{}, message string, args ...interface{}) { - l, err := f.getLogger() - if err != nil { - // 如果日志初始化失败,使用标准输出 - if len(args) > 0 { - fmt.Printf("[DEBUG] "+message+"\n", args...) - } else { - fmt.Printf("[DEBUG] %s\n", message) - } - return - } - l.Debugf(fields, message, args...) -} - -// LogInfo 记录信息日志(黑盒模式,推荐使用) -// 自动使用配置文件中的logger配置 -// message: 日志消息 -// args: 格式化参数(可选) -func (f *Factory) LogInfo(message string, args ...interface{}) { - l, err := f.getLogger() - if err != nil { - // 如果日志初始化失败,使用标准输出 - if len(args) > 0 { - fmt.Printf("[INFO] "+message+"\n", args...) - } else { - fmt.Printf("[INFO] %s\n", message) - } - return - } - if len(args) > 0 { - l.Info(message, args...) - } else { - l.Info(message) - } -} - -// LogInfof 记录信息日志(带字段,黑盒模式,推荐使用) -// 自动使用配置文件中的logger配置 -// fields: 日志字段 -// message: 日志消息 -// args: 格式化参数(可选) -func (f *Factory) LogInfof(fields map[string]interface{}, message string, args ...interface{}) { - l, err := f.getLogger() - if err != nil { - // 如果日志初始化失败,使用标准输出 - if len(args) > 0 { - fmt.Printf("[INFO] "+message+"\n", args...) - } else { - fmt.Printf("[INFO] %s\n", message) - } - return - } - l.Infof(fields, message, args...) -} - -// LogWarn 记录警告日志(黑盒模式,推荐使用) -// 自动使用配置文件中的logger配置 -// message: 日志消息 -// args: 格式化参数(可选) -func (f *Factory) LogWarn(message string, args ...interface{}) { - l, err := f.getLogger() - if err != nil { - // 如果日志初始化失败,使用标准输出 - if len(args) > 0 { - fmt.Printf("[WARN] "+message+"\n", args...) - } else { - fmt.Printf("[WARN] %s\n", message) - } - return - } - if len(args) > 0 { - l.Warn(message, args...) - } else { - l.Warn(message) - } -} - -// LogWarnf 记录警告日志(带字段,黑盒模式,推荐使用) -// 自动使用配置文件中的logger配置 -// fields: 日志字段 -// message: 日志消息 -// args: 格式化参数(可选) -func (f *Factory) LogWarnf(fields map[string]interface{}, message string, args ...interface{}) { - l, err := f.getLogger() - if err != nil { - // 如果日志初始化失败,使用标准输出 - if len(args) > 0 { - fmt.Printf("[WARN] "+message+"\n", args...) - } else { - fmt.Printf("[WARN] %s\n", message) - } - return - } - l.Warnf(fields, message, args...) -} - -// LogError 记录错误日志(黑盒模式,推荐使用) -// 自动使用配置文件中的logger配置 -// message: 日志消息 -// args: 格式化参数(可选) -func (f *Factory) LogError(message string, args ...interface{}) { - l, err := f.getLogger() - if err != nil { - // 如果日志初始化失败,使用标准输出 - if len(args) > 0 { - fmt.Printf("[ERROR] "+message+"\n", args...) - } else { - fmt.Printf("[ERROR] %s\n", message) - } - return - } - if len(args) > 0 { - l.Error(message, args...) - } else { - l.Error(message) - } -} - -// LogErrorf 记录错误日志(带字段,黑盒模式,推荐使用) -// 自动使用配置文件中的logger配置 -// fields: 日志字段 -// message: 日志消息 -// args: 格式化参数(可选) -func (f *Factory) LogErrorf(fields map[string]interface{}, message string, args ...interface{}) { - l, err := f.getLogger() - if err != nil { - // 如果日志初始化失败,使用标准输出 - if len(args) > 0 { - fmt.Printf("[ERROR] "+message+"\n", args...) - } else { - fmt.Printf("[ERROR] %s\n", message) - } - return - } - l.Errorf(fields, message, args...) -} - -// getDatabase 获取数据库连接对象(内部方法,延迟初始化) func (f *Factory) getDatabase() (*gorm.DB, error) { if f.db != nil { return f.db, nil } - - if f.cfg.Database == nil { + f.mu.Lock() + defer f.mu.Unlock() + if f.db != nil { + return f.db, nil + } + if f.cfg == nil || f.cfg.Database == nil { return nil, fmt.Errorf("database config is nil") } - - // 获取DSN dsn, err := f.cfg.GetDatabaseDSN() if err != nil { - return nil, fmt.Errorf("failed to get DSN: %w", err) + return nil, err } - - // 根据数据库类型创建连接 var db *gorm.DB switch f.cfg.Database.Type { case "mysql": @@ -428,17 +139,13 @@ func (f *Factory) getDatabase() (*gorm.DB, error) { default: return nil, fmt.Errorf("unsupported database type: %s", f.cfg.Database.Type) } - if err != nil { return nil, fmt.Errorf("failed to connect to database: %w", err) } - - // 配置连接池 sqlDB, err := db.DB() if err != nil { - return nil, fmt.Errorf("failed to get sql.DB: %w", err) + return nil, err } - if f.cfg.Database.MaxOpenConns > 0 { sqlDB.SetMaxOpenConns(f.cfg.Database.MaxOpenConns) } @@ -448,1240 +155,250 @@ func (f *Factory) getDatabase() (*gorm.DB, error) { if f.cfg.Database.ConnMaxLifetime > 0 { sqlDB.SetConnMaxLifetime(time.Duration(f.cfg.Database.ConnMaxLifetime) * time.Second) } - f.db = db return db, nil } -// GetDatabase 获取数据库连接对象(推荐使用) -// 返回已初始化的GORM数据库对象,可直接使用 -// -// ℹ️ 数据库操作保持使用 GORM 对象,因为: -// - 数据库操作非常复杂多样(查询、插入、更新、删除、事务等) -// - GORM 已经提供了很好的抽象和 API -// - 无需在 factory 中重复封装所有数据库方法 -// -// 示例: -// -// db, _ := factory.GetDatabase() -// db.Find(&users) -// db.Create(&user) -// db.Transaction(func(tx *gorm.DB) error { ... }) -func (f *Factory) GetDatabase() (*gorm.DB, error) { +// Database 获取 GORM 数据库连接 +func (f *Factory) Database() (*gorm.DB, error) { return f.getDatabase() } -// getRedisClient 获取Redis客户端对象(内部方法,延迟初始化) -func (f *Factory) getRedisClient() (*redis.Client, error) { +func (f *Factory) getRedis() (*redis.Client, error) { if f.redis != nil { return f.redis, nil } - - if f.cfg.Redis == nil { + f.mu.Lock() + defer f.mu.Unlock() + if f.redis != nil { + return f.redis, nil + } + if f.cfg == nil || f.cfg.Redis == nil { return nil, fmt.Errorf("redis config is nil") } - - // 获取Redis地址 addr := f.cfg.GetRedisAddr() if addr == "" { return nil, fmt.Errorf("redis address is empty") } - - // 设置默认值 - redisConfig := f.cfg.Redis - if redisConfig.PoolSize == 0 { - redisConfig.PoolSize = 10 // 默认连接池大小 - } - if redisConfig.MinIdleConns == 0 { - redisConfig.MinIdleConns = 5 // 默认最小空闲连接数 - } - if redisConfig.DialTimeout == 0 { - redisConfig.DialTimeout = 5 // 默认连接超时5秒 - } - if redisConfig.ReadTimeout == 0 { - redisConfig.ReadTimeout = 3 // 默认读取超时3秒 - } - if redisConfig.WriteTimeout == 0 { - redisConfig.WriteTimeout = 3 // 默认写入超时3秒 - } - - // 创建Redis客户端 + rc := f.cfg.Redis client := redis.NewClient(&redis.Options{ Addr: addr, - Password: redisConfig.Password, - DB: redisConfig.Database, - PoolSize: redisConfig.PoolSize, - MinIdleConns: redisConfig.MinIdleConns, - MaxRetries: redisConfig.MaxRetries, - DialTimeout: time.Duration(redisConfig.DialTimeout) * time.Second, - ReadTimeout: time.Duration(redisConfig.ReadTimeout) * time.Second, - WriteTimeout: time.Duration(redisConfig.WriteTimeout) * time.Second, + Password: rc.Password, + DB: rc.Database, + PoolSize: rc.PoolSize, + MinIdleConns: rc.MinIdleConns, + MaxRetries: rc.MaxRetries, + DialTimeout: time.Duration(rc.DialTimeout) * time.Second, + ReadTimeout: time.Duration(rc.ReadTimeout) * time.Second, + WriteTimeout: time.Duration(rc.WriteTimeout) * time.Second, }) - - // 测试连接 - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(redisConfig.DialTimeout)*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(rc.DialTimeout)*time.Second) defer cancel() - - _, err := client.Ping(ctx).Result() - if err != nil { - client.Close() // 连接失败时关闭客户端 + if _, err := client.Ping(ctx).Result(); err != nil { + _ = client.Close() return nil, fmt.Errorf("failed to connect to redis: %w", err) } - f.redis = client return client, nil } -// GetRedisClient 获取Redis客户端对象(高级功能时使用) -// 返回已初始化的Redis客户端对象 -// -// ℹ️ 推荐使用黑盒方法: -// - RedisGet, RedisSet, RedisDelete, RedisExists(常用操作) -// -// 仅在需要使用高级功能时获取客户端: -// - Hash 操作(HSet, HGet, HGetAll 等) -// - List 操作(LPush, RPush, LRange 等) -// - Set 操作(SAdd, SMembers 等) -// - ZSet 操作(ZAdd, ZRange 等) -// - 其他高级功能 -// -// 示例(常用操作,推荐): -// -// factory.RedisSet(ctx, "key", "value", time.Hour) -// value, _ := factory.RedisGet(ctx, "key") -// -// 示例(高级功能): -// -// client, _ := factory.GetRedisClient() -// client.HSet(ctx, "user:1", "name", "Alice") -// client.LPush(ctx, "queue", "task1") -func (f *Factory) GetRedisClient() (*redis.Client, error) { - return f.getRedisClient() +// Redis 获取 Redis 客户端 +func (f *Factory) Redis() (*redis.Client, error) { + return f.getRedis() } -// RedisGet 获取Redis值(黑盒模式,推荐使用) -// 自动使用配置文件中的Redis配置 -// key: Redis键 -func (f *Factory) RedisGet(ctx context.Context, key string) (string, error) { - client, err := f.getRedisClient() - if err != nil { - return "", err - } - - result, err := client.Get(ctx, key).Result() - if err == redis.Nil { - return "", nil // key不存在,返回空字符串 - } - if err != nil { - return "", fmt.Errorf("failed to get redis key: %w", err) - } - - return result, nil -} - -// RedisSet 设置Redis值(黑盒模式,推荐使用) -// 自动使用配置文件中的Redis配置 -// key: Redis键 -// value: Redis值 -// expiration: 过期时间(可选,0表示不过期) -func (f *Factory) RedisSet(ctx context.Context, key string, value interface{}, expiration ...time.Duration) error { - client, err := f.getRedisClient() - if err != nil { - return err - } - - var exp time.Duration - if len(expiration) > 0 { - exp = expiration[0] - } - - err = client.Set(ctx, key, value, exp).Err() - if err != nil { - return fmt.Errorf("failed to set redis key: %w", err) - } - - return nil -} - -// RedisDelete 删除Redis键(黑盒模式,推荐使用) -// 自动使用配置文件中的Redis配置 -// keys: Redis键列表 -func (f *Factory) RedisDelete(ctx context.Context, keys ...string) error { - if len(keys) == 0 { - return nil - } - - client, err := f.getRedisClient() - if err != nil { - return err - } - - err = client.Del(ctx, keys...).Err() - if err != nil { - return fmt.Errorf("failed to delete redis keys: %w", err) - } - - return nil -} - -// RedisExists 检查Redis键是否存在(黑盒模式,推荐使用) -// 自动使用配置文件中的Redis配置 -// key: Redis键 -func (f *Factory) RedisExists(ctx context.Context, key string) (bool, error) { - client, err := f.getRedisClient() - if err != nil { - return false, err - } - - count, err := client.Exists(ctx, key).Result() - if err != nil { - return false, fmt.Errorf("failed to check redis key existence: %w", err) - } - - return count > 0, nil -} - -// GetConfig 获取配置对象 -func (f *Factory) GetConfig() *config.Config { - return f.cfg -} - -// getStorage 获取存储实例(内部方法,延迟初始化) func (f *Factory) getStorage() (storage.Storage, error) { if f.storage != nil { return f.storage, nil } - - // 根据配置自动选择存储类型 - // 优先级:Local > MinIO > OSS + f.mu.Lock() + defer f.mu.Unlock() + if f.storage != nil { + return f.storage, nil + } + if f.cfg == nil { + return nil, fmt.Errorf("config is nil") + } var storageType storage.StorageType - if f.cfg.GetLocalStorage() != nil { + switch { + case f.cfg.GetLocalStorage() != nil: storageType = storage.StorageTypeLocal - } else if f.cfg.MinIO != nil { + case f.cfg.MinIO != nil: storageType = storage.StorageTypeMinIO - } else if f.cfg.OSS != nil { + case f.cfg.OSS != nil: storageType = storage.StorageTypeOSS - } else { + default: return nil, fmt.Errorf("no storage config found (LocalStorage, OSS or MinIO)") } - - // 创建存储实例 s, err := storage.NewStorage(storageType, f.cfg) if err != nil { - return nil, fmt.Errorf("failed to create storage: %w", err) + return nil, err } - f.storage = s return s, nil } -// GetStorage 获取存储实例对象(高级功能时使用) -// 通常推荐使用黑盒方法: -// - UploadFile() -// - GetFileURL() -// -// 如需自定义上传/查看行为(例如 Delete/Exists/GetObject),可使用此方法获取底层存储对象。 -func (f *Factory) GetStorage() (storage.Storage, error) { +// Storage 获取存储对象 +func (f *Factory) Storage() (storage.Storage, error) { return f.getStorage() } -// UploadFile 上传文件(黑盒模式,推荐使用) -// 自动根据配置选择存储类型(OSS 或 MinIO),无需关心内部实现 -// ctx: 上下文 -// objectKey: 对象键(文件路径) -// reader: 文件内容 -// contentType: 文件类型(可选) -// 返回文件访问URL和错误 -func (f *Factory) UploadFile(ctx context.Context, objectKey string, reader io.Reader, contentType ...string) (string, error) { - s, err := f.getStorage() - if err != nil { - return "", err +func (f *Factory) getEmail() (*email.Email, error) { + if f.email != nil { + return f.email, nil } - - // 上传文件 - err = s.Upload(ctx, objectKey, reader, contentType...) - if err != nil { - return "", fmt.Errorf("failed to upload file: %w", err) + f.mu.Lock() + defer f.mu.Unlock() + if f.email != nil { + return f.email, nil } - - // 获取文件URL - url, err := s.GetURL(objectKey, 0) - if err != nil { - return "", fmt.Errorf("failed to get file URL: %w", err) + if f.cfg == nil || f.cfg.Email == nil { + return nil, fmt.Errorf("email config is nil") } - - return url, nil + f.email = email.NewEmail(f.cfg) + return f.email, nil } -// GetFileURL 获取文件访问URL(黑盒模式,推荐使用) -// 自动根据配置选择存储类型,返回文件的访问URL -// objectKey: 对象键(文件路径) -// expires: 过期时间(秒),0表示永久有效 -func (f *Factory) GetFileURL(objectKey string, expires int64) (string, error) { - s, err := f.getStorage() - if err != nil { - return "", err - } - - return s.GetURL(objectKey, expires) +// Email 获取邮件客户端 +func (f *Factory) Email() (*email.Email, error) { + return f.getEmail() } -// GetMiddlewareChain 获取配置好的中间件链(黑盒模式) -// 自动包含:Recovery、Logging、RateLimit(如果配置了)、CORS(如果配置了)、Timezone -// 返回已配置好的中间件链,可以通过 Append() 方法添加自定义中间件 -// -// 示例1:直接使用 -// -// chain := factory.GetMiddlewareChain() -// http.Handle("/api/users", chain.ThenFunc(handleUsers)) -// -// 示例2:添加自定义中间件 -// -// chain := factory.GetMiddlewareChain() -// chain.Append(yourCustomMiddleware1, yourCustomMiddleware2) -// http.Handle("/api/users", chain.ThenFunc(handleUsers)) -func (f *Factory) GetMiddlewareChain() *middleware.Chain { - var middlewares []func(http.Handler) http.Handler +func (f *Factory) getSMS() (*sms.SMS, error) { + if f.sms != nil { + return f.sms, nil + } + f.mu.Lock() + defer f.mu.Unlock() + if f.sms != nil { + return f.sms, nil + } + if f.cfg == nil || f.cfg.SMS == nil { + return nil, fmt.Errorf("sms config is nil") + } + f.sms = sms.NewSMS(f.cfg) + return f.sms, nil +} - // 1. Recovery 中间件(必需,防止panic导致服务崩溃) - l, _ := f.getLogger() // 获取logger,如果失败会使用默认logger - middlewares = append(middlewares, middleware.Recovery(&middleware.RecoveryConfig{ - Logger: l, - })) +// SMS 获取短信客户端 +func (f *Factory) SMS() (*sms.SMS, error) { + return f.getSMS() +} - // 2. Logging 中间件(必需,记录所有请求) - middlewares = append(middlewares, middleware.Logging(&middleware.LoggingConfig{ - Logger: l, - })) - - // 3. RateLimit 中间件(如果配置了限流) - if f.cfg != nil && f.cfg.RateLimit != nil { - if f.cfg.RateLimit.Enable { - // 从配置创建限流中间件 - limiter := middleware.NewTokenBucketLimiter( - f.cfg.RateLimit.Rate, - time.Duration(f.cfg.RateLimit.Period)*time.Second, - ) - var keyFunc func(r *http.Request) string - if f.cfg.RateLimit.ByIP { - keyFunc = func(r *http.Request) string { - return middleware.GetClientIP(r) - } - } else if f.cfg.RateLimit.ByUserID { - keyFunc = func(r *http.Request) string { - return r.Header.Get("X-User-ID") - } - } - middlewares = append(middlewares, middleware.RateLimit(&middleware.RateLimitConfig{ - Limiter: limiter, - KeyFunc: keyFunc, - })) +func (f *Factory) getI18n() (*i18n.I18n, error) { + if f.i18n != nil { + return f.i18n, nil + } + f.mu.Lock() + defer f.mu.Unlock() + if f.i18n != nil { + return f.i18n, nil + } + if f.cfg == nil || f.cfg.I18n == nil { + return nil, fmt.Errorf("i18n config is nil") + } + i := i18n.NewI18n(f.cfg.I18n.DefaultLang) + if f.cfg.I18n.LocalesDir != "" { + if err := i.LoadFromDir(f.cfg.I18n.LocalesDir); err != nil { + return nil, err } } + f.i18n = i + return i, nil +} + +// I18n 获取国际化对象 +func (f *Factory) I18n() (*i18n.I18n, error) { + return f.getI18n() +} + +func (f *Factory) getExcel() *excel.Excel { + if f.excel != nil { + return f.excel + } + f.mu.Lock() + defer f.mu.Unlock() + if f.excel == nil { + f.excel = excel.NewExcel() + } + return f.excel +} + +// Excel 获取 Excel 导出器 +func (f *Factory) Excel() *excel.Excel { + return f.getExcel() +} + +// MiddlewareChain 获取默认中间件链 +func (f *Factory) MiddlewareChain() *middleware.Chain { + if f.chain != nil { + return f.chain + } + f.mu.Lock() + defer f.mu.Unlock() + if f.chain != nil { + return f.chain + } + + var mws []func(http.Handler) http.Handler + l, _ := f.getLogger() + i18nInst, _ := f.getI18n() + + mws = append(mws, middleware.Recovery(&middleware.RecoveryConfig{ + Logger: l, + I18n: i18nInst, + })) + mws = append(mws, middleware.RequestID()) + mws = append(mws, middleware.Logging(&middleware.LoggingConfig{Logger: l})) + + if f.cfg != nil && f.cfg.RateLimit != nil && f.cfg.RateLimit.Enable { + limiter := middleware.NewTokenBucketLimiter( + f.cfg.RateLimit.Rate, + time.Duration(f.cfg.RateLimit.Period)*time.Second, + ) + var keyFunc func(r *http.Request) string + if f.cfg.RateLimit.ByIP { + keyFunc = func(r *http.Request) string { return middleware.GetClientIP(r) } + } else if f.cfg.RateLimit.ByUserID { + keyFunc = func(r *http.Request) string { return r.Header.Get("X-User-ID") } + } + mws = append(mws, middleware.RateLimit(&middleware.RateLimitConfig{ + Limiter: limiter, + KeyFunc: keyFunc, + })) + } - // 4. CORS 中间件(如果配置了) if f.cfg != nil && f.cfg.CORS != nil { - corsConfig := &middleware.CORSConfig{ + mws = append(mws, middleware.CORS(&middleware.CORSConfig{ AllowedOrigins: f.cfg.CORS.AllowedOrigins, AllowedMethods: f.cfg.CORS.AllowedMethods, AllowedHeaders: f.cfg.CORS.AllowedHeaders, ExposedHeaders: f.cfg.CORS.ExposedHeaders, AllowCredentials: f.cfg.CORS.AllowCredentials, MaxAge: f.cfg.CORS.MaxAge, - } - middlewares = append(middlewares, middleware.CORS(corsConfig)) + })) } - // 5. Language 中间件(必需,处理语言) - middlewares = append(middlewares, middleware.Language) - - // 6. Timezone 中间件(必需,处理时区) - middlewares = append(middlewares, middleware.Timezone) - - return middleware.NewChain(middlewares...) + mws = append(mws, middleware.Language, middleware.Timezone) + f.chain = middleware.NewChain(mws...) + return f.chain } -// RunMigrations 执行数据库迁移(黑盒模式,推荐使用) -// 自动发现并执行指定目录下的所有迁移文件 -// migrationsDir: 迁移文件目录(如 "migrations" 或 "scripts/sql") -// -// 支持的文件命名格式: -// - 数字前缀: 01_init_schema.sql -// - 时间戳: 20240101000001_create_users.sql -// - 带.up后缀: 20240101000001_create_users.up.sql -// -// 示例: -// -// fac, _ := factory.NewFactoryFromFile("config.json") -// err := fac.RunMigrations("migrations") -// if err != nil { -// log.Fatal(err) -// } -func (f *Factory) RunMigrations(migrationsDir string) error { - // 获取数据库连接 +// Migrator 创建迁移器并加载指定目录下的 SQL 文件 +func (f *Factory) Migrator(migrationsDir string) (*migration.Migrator, error) { db, err := f.getDatabase() if err != nil { - return fmt.Errorf("failed to get database: %w", err) + return nil, err } - - // 创建迁移器(传入数据库类型,性能更好) - dbType := "mysql" // 默认值 + dbType := "mysql" if f.cfg.Database != nil && f.cfg.Database.Type != "" { dbType = f.cfg.Database.Type } - migrator := migration.NewMigratorWithType(db, dbType) - - // 自动发现并加载迁移文件 + m := migration.NewMigratorWithType(db, dbType) migrations, err := migration.LoadMigrationsFromFiles(migrationsDir, "*.sql") if err != nil { - return fmt.Errorf("failed to load migrations: %w", err) + return nil, err } - - if len(migrations) == 0 { - f.LogInfo("在目录 '%s' 中没有找到迁移文件", migrationsDir) - return nil - } - - migrator.AddMigrations(migrations...) - - // 执行迁移 - if err := migrator.Up(); err != nil { - return fmt.Errorf("failed to run migrations: %w", err) - } - - f.LogInfo("迁移执行成功: %d 个迁移文件", len(migrations)) - return nil -} - -// GetMigrationStatus 获取迁移状态(黑盒模式,推荐使用) -// migrationsDir: 迁移文件目录 -// 返回迁移状态列表,包含版本、描述、是否已应用等信息 -// -// 示例: -// -// fac, _ := factory.NewFactoryFromFile("config.json") -// status, err := fac.GetMigrationStatus("migrations") -// if err != nil { -// log.Fatal(err) -// } -// for _, s := range status { -// fmt.Printf("Version: %s, Applied: %v\n", s.Version, s.Applied) -// } -func (f *Factory) GetMigrationStatus(migrationsDir string) ([]migration.MigrationStatus, error) { - // 获取数据库连接 - db, err := f.getDatabase() - if err != nil { - return nil, fmt.Errorf("failed to get database: %w", err) - } - - // 创建迁移器(传入数据库类型,性能更好) - dbType := "mysql" // 默认值 - if f.cfg.Database != nil && f.cfg.Database.Type != "" { - dbType = f.cfg.Database.Type - } - migrator := migration.NewMigratorWithType(db, dbType) - - // 加载迁移文件 - migrations, err := migration.LoadMigrationsFromFiles(migrationsDir, "*.sql") - if err != nil { - return nil, fmt.Errorf("failed to load migrations: %w", err) - } - - migrator.AddMigrations(migrations...) - - // 获取状态 - status, err := migrator.Status() - if err != nil { - return nil, fmt.Errorf("failed to get migration status: %w", err) - } - - return status, nil -} - -// ========== HTTP响应方法(黑盒模式,推荐使用) ========== -// -// 这些方法直接调用 http 包的公共方法,保持低耦合。 -// 推荐直接使用 factory.Success() 等方法,而不是通过 handler。 - -// Success 成功响应(黑盒模式,推荐使用) -// w: ResponseWriter -// data: 响应数据,可以为nil -// message: 响应消息(可选),如果为空则使用默认消息 "success" -// -// 示例: -// -// fac, _ := factory.NewFactoryFromFile("config.json") -// http.HandleFunc("/user", func(w http.ResponseWriter, r *http.Request) { -// fac.Success(w, user) // 只有数据 -// fac.Success(w, user, "查询成功") // 数据+消息 -// }) -func (f *Factory) Success(w http.ResponseWriter, data interface{}, message ...string) { - commonhttp.Success(w, data, message...) -} - -// SuccessPage 分页成功响应(黑盒模式,推荐使用) -// w: ResponseWriter -// list: 数据列表 -// total: 总记录数 -// page: 当前页码 -// pageSize: 每页大小 -// message: 响应消息(可选),如果为空则使用默认消息 "success" -func (f *Factory) SuccessPage(w http.ResponseWriter, list interface{}, total int64, page, pageSize int, message ...string) { - commonhttp.SuccessPage(w, list, total, page, pageSize, message...) -} - -// Error 错误响应(黑盒模式,推荐使用) -// w: ResponseWriter -// r: HTTP请求(用于获取语言信息和i18n处理) -// code: 业务错误码(如果message是消息代码,此参数会被语言文件中的code覆盖) -// message: 错误消息或消息代码(如果i18n已初始化且message是消息代码格式,会自动获取国际化消息和业务code) -// args: 可选参数,用于格式化消息(类似 fmt.Sprintf,仅在message是消息代码时使用) -// -// 使用逻辑: -// 1. 如果i18n已初始化,且message看起来是消息代码(包含点号,如 "user.not_found"), -// 则从请求context中获取语言,并尝试从语言文件中获取国际化消息和业务code -// 2. 如果获取到国际化消息,使用语言文件中的code作为响应code,使用国际化消息作为响应message -// 3. 如果未获取到或i18n未初始化,使用传入的code和message -// -// 示例: -// -// // 方式1:直接传入消息代码(推荐,自动国际化) -// fac.Error(w, r, 0, "user.not_found") -// // 如果请求语言是 zh-CN,且语言文件中 "user.not_found" 的 code 是 1001, -// // 返回: {"code": 1001, "message": "用户不存在"} -// // 如果请求语言是 en-US,返回: {"code": 1001, "message": "User not found"} -// -// // 方式2:带参数的消息代码 -// fac.Error(w, r, 0, "user.welcome", "Alice") -// // 如果消息内容是 "欢迎,%s",返回: {"code": 0, "message": "欢迎,Alice"} -// -// // 方式3:直接传入消息文本(不使用国际化) -// fac.Error(w, r, 500, "系统错误") -// // 返回: {"code": 500, "message": "系统错误"} -func (f *Factory) Error(w http.ResponseWriter, r *http.Request, code int, message string, args ...interface{}) { - // 判断message是否是消息代码(简单判断:包含点号) - isMessageCode := strings.Contains(message, ".") - - var finalCode int - var finalMessage string - - if isMessageCode { - // 尝试从i18n获取国际化消息和业务code - if i, err := f.getI18n(); err == nil { - // i18n已初始化,获取语言并查找消息 - lang := f.GetLanguage(r) - if lang == "" { - lang = i.GetDefaultLang() - } - msgInfo := i.GetMessageInfo(lang, message, args...) - // 如果获取到了国际化消息(不是返回code本身),使用国际化消息和业务code - if msgInfo.Message != message { - finalCode = msgInfo.Code - finalMessage = msgInfo.Message - } else { - // 消息代码不存在,使用传入的code和消息代码作为消息 - finalCode = code - finalMessage = message - } - } else { - // i18n未初始化,使用传入的code和消息代码作为消息 - finalCode = code - finalMessage = message - } - } else { - // 不是消息代码格式,使用传入的code和消息 - finalCode = code - finalMessage = message - } - - commonhttp.Error(w, finalCode, finalMessage) -} - -// SystemError 系统错误响应(返回HTTP 500)(黑盒模式,推荐使用) -// w: ResponseWriter -// message: 错误消息 -func (f *Factory) SystemError(w http.ResponseWriter, message string) { - commonhttp.SystemError(w, message) -} - -// ========== HTTP请求解析方法(黑盒模式,推荐使用) ========== -// -// 这些方法直接调用 http 包的公共方法,保持低耦合。 -// 推荐直接使用 factory.ParseJSON()、factory.ConvertInt() 等方法。 - -// ParseJSON 解析JSON请求体(黑盒模式,推荐使用) -// r: HTTP请求 -// v: 目标结构体指针 -// -// 示例: -// -// var req struct { -// Name string `json:"name"` -// } -// if err := fac.ParseJSON(r, &req); err != nil { -// fac.Error(w, 400, "请求参数解析失败") -// return -// } -func (f *Factory) ParseJSON(r *http.Request, v interface{}) error { - return commonhttp.ParseJSON(r, v) -} - -// ParsePaginationRequest 从请求中解析分页参数(黑盒模式,推荐使用) -// r: HTTP请求 -// 支持从查询参数和form表单中解析 -// 优先级:查询参数 > form表单 -// -// 示例: -// -// pagination := fac.ParsePaginationRequest(r) -// page := pagination.GetPage() -// pageSize := pagination.GetSize() -func (f *Factory) ParsePaginationRequest(r *http.Request) *PaginationRequest { - return commonhttp.ParsePaginationRequest(r) -} - -// ConvertInt 将字符串转换为int类型(黑盒模式,推荐使用) -// value: 待转换的字符串 -// defaultValue: 转换失败或字符串为空时返回的默认值 -func (f *Factory) ConvertInt(value string, defaultValue int) int { - return tools.ConvertInt(value, defaultValue) -} - -// ConvertInt64 将字符串转换为int64类型(黑盒模式,推荐使用) -// value: 待转换的字符串 -// defaultValue: 转换失败或字符串为空时返回的默认值 -func (f *Factory) ConvertInt64(value string, defaultValue int64) int64 { - return tools.ConvertInt64(value, defaultValue) -} - -// ConvertUint64 将字符串转换为uint64类型(黑盒模式,推荐使用) -// value: 待转换的字符串 -// defaultValue: 转换失败或字符串为空时返回的默认值 -func (f *Factory) ConvertUint64(value string, defaultValue uint64) uint64 { - return tools.ConvertUint64(value, defaultValue) -} - -// ConvertUint32 将字符串转换为uint32类型(黑盒模式,推荐使用) -// value: 待转换的字符串 -// defaultValue: 转换失败或字符串为空时返回的默认值 -func (f *Factory) ConvertUint32(value string, defaultValue uint32) uint32 { - return tools.ConvertUint32(value, defaultValue) -} - -// ConvertBool 将字符串转换为bool类型(黑盒模式,推荐使用) -// value: 待转换的字符串 -// defaultValue: 转换失败或字符串为空时返回的默认值 -func (f *Factory) ConvertBool(value string, defaultValue bool) bool { - return tools.ConvertBool(value, defaultValue) -} - -// ConvertFloat64 将字符串转换为float64类型(黑盒模式,推荐使用) -// value: 待转换的字符串 -// defaultValue: 转换失败或字符串为空时返回的默认值 -func (f *Factory) ConvertFloat64(value string, defaultValue float64) float64 { - return tools.ConvertFloat64(value, defaultValue) -} - -// GetTimezone 从请求的context中获取时区(黑盒模式,推荐使用) -// r: HTTP请求 -// 如果使用了middleware.Timezone中间件,可以从context中获取时区信息 -// 如果未设置,返回默认时区 AsiaShanghai -func (f *Factory) GetTimezone(r *http.Request) string { - return commonhttp.GetTimezone(r) -} - -// GetLanguage 从请求的context中获取语言(黑盒模式,推荐使用) -// r: HTTP请求 -// 如果使用了middleware.Language中间件,可以从context中获取语言信息 -// 如果未设置,返回默认语言 zh-CN -func (f *Factory) GetLanguage(r *http.Request) string { - return commonhttp.GetLanguage(r) -} - -// ========== Tools工具方法(黑盒模式,推荐使用) ========== -// -// 这些方法直接调用 tools 包的公共方法,保持低耦合。 -// factory 只负责方法暴露,具体业务在 tools 包中实现。 - -// ========== Version 版本工具 ========== - -// GetVersion 获取版本号(黑盒模式,推荐使用) -// 优先从环境变量 DOCKER_TAG 或 VERSION 中读取 -// 如果没有设置环境变量,则使用默认版本号 -func (f *Factory) GetVersion() string { - return tools.GetVersion() -} - -// ========== Money 金额工具 ========== - -// GetMoneyCalculator 获取金额计算器(黑盒模式,推荐使用) -// 返回金额计算器实例,可用于金额计算操作 -func (f *Factory) GetMoneyCalculator() *tools.MoneyCalculator { - return tools.NewMoneyCalculator() -} - -// YuanToCents 元转分(黑盒模式,推荐使用) -func (f *Factory) YuanToCents(yuan float64) int64 { - return tools.YuanToCents(yuan) -} - -// CentsToYuan 分转元(黑盒模式,推荐使用) -func (f *Factory) CentsToYuan(cents int64) float64 { - return tools.CentsToYuan(cents) -} - -// FormatYuan 格式化显示金额(分转元,保留2位小数)(黑盒模式,推荐使用) -func (f *Factory) FormatYuan(cents int64) string { - return tools.FormatYuan(cents) -} - -// ========== DateTime 日期时间工具 ========== - -// Now 获取当前时间(使用指定时区)(黑盒模式,推荐使用) -func (f *Factory) Now(timezone ...string) time.Time { - return tools.Now(timezone...) -} - -// ParseDateTime 解析日期时间字符串(2006-01-02 15:04:05)(黑盒模式,推荐使用) -func (f *Factory) ParseDateTime(value string, timezone ...string) (time.Time, error) { - return tools.ParseDateTime(value, timezone...) -} - -// ParseDate 解析日期字符串(2006-01-02)(黑盒模式,推荐使用) -func (f *Factory) ParseDate(value string, timezone ...string) (time.Time, error) { - return tools.ParseDate(value, timezone...) -} - -// FormatDateTime 格式化日期时间(2006-01-02 15:04:05)(黑盒模式,推荐使用) -func (f *Factory) FormatDateTime(t time.Time, timezone ...string) string { - return tools.FormatDateTime(t, timezone...) -} - -// FormatDate 格式化日期(2006-01-02)(黑盒模式,推荐使用) -func (f *Factory) FormatDate(t time.Time, timezone ...string) string { - return tools.FormatDate(t, timezone...) -} - -// FormatTime 格式化时间(15:04:05)(黑盒模式,推荐使用) -func (f *Factory) FormatTime(t time.Time, timezone ...string) string { - return tools.FormatTime(t, timezone...) -} - -// ToUnix 转换为Unix时间戳(黑盒模式,推荐使用) -func (f *Factory) ToUnix(t time.Time) int64 { - return tools.ToUnix(t) -} - -// FromUnix 从Unix时间戳创建时间(黑盒模式,推荐使用) -func (f *Factory) FromUnix(sec int64, timezone ...string) time.Time { - return tools.FromUnix(sec, timezone...) -} - -// ToUnixMilli 转换为Unix毫秒时间戳(黑盒模式,推荐使用) -func (f *Factory) ToUnixMilli(t time.Time) int64 { - return tools.ToUnixMilli(t) -} - -// FromUnixMilli 从Unix毫秒时间戳创建时间(黑盒模式,推荐使用) -func (f *Factory) FromUnixMilli(msec int64, timezone ...string) time.Time { - return tools.FromUnixMilli(msec, timezone...) -} - -// AddDays 添加天数(黑盒模式,推荐使用) -func (f *Factory) AddDays(t time.Time, days int) time.Time { - return tools.AddDays(t, days) -} - -// AddMonths 添加月数(黑盒模式,推荐使用) -func (f *Factory) AddMonths(t time.Time, months int) time.Time { - return tools.AddMonths(t, months) -} - -// AddYears 添加年数(黑盒模式,推荐使用) -func (f *Factory) AddYears(t time.Time, years int) time.Time { - return tools.AddYears(t, years) -} - -// StartOfDay 获取一天的开始时间(00:00:00)(黑盒模式,推荐使用) -func (f *Factory) StartOfDay(t time.Time, timezone ...string) time.Time { - return tools.StartOfDay(t, timezone...) -} - -// EndOfDay 获取一天的结束时间(23:59:59.999999999)(黑盒模式,推荐使用) -func (f *Factory) EndOfDay(t time.Time, timezone ...string) time.Time { - return tools.EndOfDay(t, timezone...) -} - -// StartOfMonth 获取月份的开始时间(黑盒模式,推荐使用) -func (f *Factory) StartOfMonth(t time.Time, timezone ...string) time.Time { - return tools.StartOfMonth(t, timezone...) -} - -// EndOfMonth 获取月份的结束时间(黑盒模式,推荐使用) -func (f *Factory) EndOfMonth(t time.Time, timezone ...string) time.Time { - return tools.EndOfMonth(t, timezone...) -} - -// StartOfYear 获取年份的开始时间(黑盒模式,推荐使用) -func (f *Factory) StartOfYear(t time.Time, timezone ...string) time.Time { - return tools.StartOfYear(t, timezone...) -} - -// EndOfYear 获取年份的结束时间(黑盒模式,推荐使用) -func (f *Factory) EndOfYear(t time.Time, timezone ...string) time.Time { - return tools.EndOfYear(t, timezone...) -} - -// DiffDays 计算两个时间之间的天数差(黑盒模式,推荐使用) -func (f *Factory) DiffDays(t1, t2 time.Time) int { - return tools.DiffDays(t1, t2) -} - -// DiffHours 计算两个时间之间的小时差(黑盒模式,推荐使用) -func (f *Factory) DiffHours(t1, t2 time.Time) int64 { - return tools.DiffHours(t1, t2) -} - -// DiffMinutes 计算两个时间之间的分钟差(黑盒模式,推荐使用) -func (f *Factory) DiffMinutes(t1, t2 time.Time) int64 { - return tools.DiffMinutes(t1, t2) -} - -// DiffSeconds 计算两个时间之间的秒数差(黑盒模式,推荐使用) -func (f *Factory) DiffSeconds(t1, t2 time.Time) int64 { - return tools.DiffSeconds(t1, t2) -} - -// ========== Time 时间工具(黑盒模式,推荐使用) ========== -// -// 这些方法提供基础时间操作、时间戳、时间判断等功能。 -// 与 DateTime 工具的区别: -// - DateTime: 专注于时区相关、格式化、解析、UTC转换 -// - Time: 专注于基础时间操作、时间戳、时间判断、时间信息生成 - -// ========== 时间戳方法 ========== - -// GetTimestamp 获取当前时间戳(秒)(黑盒模式,推荐使用) -func (f *Factory) GetTimestamp() int64 { - return tools.GetTimestamp() -} - -// GetMillisTimestamp 获取当前时间戳(毫秒)(黑盒模式,推荐使用) -func (f *Factory) GetMillisTimestamp() int64 { - return tools.GetMillisTimestamp() -} - -// GetUTCTimestamp 获取UTC时间戳(秒)(黑盒模式,推荐使用) -func (f *Factory) GetUTCTimestamp() int64 { - return tools.GetUTCTimestamp() -} - -// GetUTCTimestampFromTime 从指定时间获取UTC时间戳(秒)(黑盒模式,推荐使用) -func (f *Factory) GetUTCTimestampFromTime(t time.Time) int64 { - return tools.GetUTCTimestampFromTime(t) -} - -// ========== 格式化方法(自定义格式) ========== - -// FormatTimeWithLayout 格式化时间(自定义格式)(黑盒模式,推荐使用) -// layout: 时间格式,如 "2006-01-02 15:04:05",如果为空则使用默认格式 -func (f *Factory) FormatTimeWithLayout(t time.Time, layout string) string { - return tools.FormatTimeWithLayout(t, layout) -} - -// FormatTimeUTC 格式化时间为UTC字符串(ISO 8601格式)(黑盒模式,推荐使用) -func (f *Factory) FormatTimeUTC(t time.Time) string { - return tools.FormatTimeUTC(t) -} - -// GetCurrentTime 获取当前时间字符串(黑盒模式,推荐使用) -// 使用默认格式 "2006-01-02 15:04:05" -func (f *Factory) GetCurrentTime() string { - return tools.GetCurrentTime() -} - -// ========== 解析方法(自定义格式) ========== - -// ParseTime 解析时间字符串(自定义格式)(黑盒模式,推荐使用) -// timeStr: 时间字符串 -// layout: 时间格式,如 "2006-01-02 15:04:05",如果为空则使用默认格式 -func (f *Factory) ParseTime(timeStr, layout string) (time.Time, error) { - return tools.ParseTime(timeStr, layout) -} - -// ========== 时间计算(补充 DateTime 的 Add 系列) ========== - -// AddHours 增加小时数(黑盒模式,推荐使用) -func (f *Factory) AddHours(t time.Time, hours int) time.Time { - return tools.AddHours(t, hours) -} - -// AddMinutes 增加分钟数(黑盒模式,推荐使用) -func (f *Factory) AddMinutes(t time.Time, minutes int) time.Time { - return tools.AddMinutes(t, minutes) -} - -// ========== 时间范围(周相关) ========== - -// GetBeginOfWeek 获取某周的开始时间(周一)(黑盒模式,推荐使用) -func (f *Factory) GetBeginOfWeek(t time.Time) time.Time { - return tools.GetBeginOfWeek(t) -} - -// GetEndOfWeek 获取某周的结束时间(周日)(黑盒模式,推荐使用) -func (f *Factory) GetEndOfWeek(t time.Time) time.Time { - return tools.GetEndOfWeek(t) -} - -// ========== 时间判断 ========== - -// IsToday 判断是否为今天(黑盒模式,推荐使用) -func (f *Factory) IsToday(t time.Time) bool { - return tools.IsToday(t) -} - -// IsYesterday 判断是否为昨天(黑盒模式,推荐使用) -func (f *Factory) IsYesterday(t time.Time) bool { - return tools.IsYesterday(t) -} - -// IsTomorrow 判断是否为明天(黑盒模式,推荐使用) -func (f *Factory) IsTomorrow(t time.Time) bool { - return tools.IsTomorrow(t) -} - -// ========== 时间信息生成 ========== - -// GenerateTimeInfoWithTimezone 生成详细时间信息(指定时区)(黑盒模式,推荐使用) -// 返回包含UTC时间、本地时间、时间戳、时区信息等的完整时间信息结构 -func (f *Factory) GenerateTimeInfoWithTimezone(t time.Time, timezone string) TimeInfo { - return tools.GenerateTimeInfoWithTimezone(t, timezone) -} - -// ========== Crypto 加密工具(黑盒模式,推荐使用) ========== -// -// 这些方法提供密码加密、哈希计算、随机字符串生成等功能。 - -// ========== 密码加密 ========== - -// HashPassword 使用bcrypt加密密码(黑盒模式,推荐使用) -// password: 原始密码 -// 返回: 加密后的密码哈希值 -func (f *Factory) HashPassword(password string) (string, error) { - return tools.HashPassword(password) -} - -// CheckPassword 验证密码(黑盒模式,推荐使用) -// password: 原始密码 -// hash: 加密后的密码哈希值 -// 返回: 密码是否正确 -func (f *Factory) CheckPassword(password, hash string) bool { - return tools.CheckPassword(password, hash) -} - -// ========== 哈希计算 ========== - -// MD5 计算MD5哈希值(黑盒模式,推荐使用) -// text: 要计算哈希的文本 -// 返回: MD5哈希值(十六进制字符串) -func (f *Factory) MD5(text string) string { - return tools.MD5(text) -} - -// SHA256 计算SHA256哈希值(黑盒模式,推荐使用) -// text: 要计算哈希的文本 -// 返回: SHA256哈希值(十六进制字符串) -func (f *Factory) SHA256(text string) string { - return tools.SHA256(text) -} - -// ========== 随机字符串生成 ========== - -// GenerateRandomString 生成指定长度的随机字符串(黑盒模式,推荐使用) -// length: 字符串长度 -// 返回: 随机字符串(包含大小写字母和数字) -func (f *Factory) GenerateRandomString(length int) string { - return tools.GenerateRandomString(length) -} - -// GenerateRandomNumber 生成指定长度的随机数字字符串(黑盒模式,推荐使用) -// length: 字符串长度 -// 返回: 随机数字字符串 -func (f *Factory) GenerateRandomNumber(length int) string { - return tools.GenerateRandomNumber(length) -} - -// ========== 业务相关随机码生成 ========== - -// GenerateSMSCode 生成短信验证码(黑盒模式,推荐使用) -// 返回: 6位数字验证码 -func (f *Factory) GenerateSMSCode() string { - return tools.GenerateSMSCode() -} - -// GenerateOrderNo 生成订单号(黑盒模式,推荐使用) -// prefix: 订单号前缀 -// 返回: 订单号(格式:前缀+时间戳+6位随机数) -func (f *Factory) GenerateOrderNo(prefix string) string { - return tools.GenerateOrderNo(prefix) -} - -// GeneratePaymentNo 生成支付单号(黑盒模式,推荐使用) -// 返回: 支付单号(格式:PAY+时间戳+6位随机数) -func (f *Factory) GeneratePaymentNo() string { - return tools.GeneratePaymentNo() -} - -// GenerateRefundNo 生成退款单号(黑盒模式,推荐使用) -// 返回: 退款单号(格式:RF+时间戳+6位随机数) -func (f *Factory) GenerateRefundNo() string { - return tools.GenerateRefundNo() -} - -// GenerateTransferNo 生成调拨单号(黑盒模式,推荐使用) -// 返回: 调拨单号(格式:TF+时间戳+6位随机数) -func (f *Factory) GenerateTransferNo() string { - return tools.GenerateTransferNo() -} - -// ========== I18n 国际化工具(黑盒模式,推荐使用) ========== -// -// 这些方法提供多语言内容管理功能,支持从文件加载语言内容,通过语言代码和消息代码获取对应语言的内容。 - -// getI18n 获取国际化工具实例(内部方法,延迟初始化) -func (f *Factory) getI18n() (*i18n.I18n, error) { - if f.i18n != nil { - return f.i18n, nil - } - - // 如果没有配置,返回错误 - return nil, fmt.Errorf("i18n not initialized, please call InitI18n first") -} - -// InitI18n 初始化国际化工具(黑盒模式,推荐使用) -// defaultLang: 默认语言代码(如 "zh-CN", "en-US") -// 初始化后可以调用 LoadI18nFromDir 或 LoadI18nFromFile 加载语言文件 -// -// 示例: -// -// fac, _ := factory.NewFactoryFromFile("config.json") -// fac.InitI18n("zh-CN") -// fac.LoadI18nFromDir("locales") -func (f *Factory) InitI18n(defaultLang string) { - f.i18n = i18n.NewI18n(defaultLang) -} - -// LoadI18nFromDir 从目录加载多个语言文件(黑盒模式,推荐使用) -// dirPath: 语言文件目录路径 -// 文件命名规则:{语言代码}.json(如 zh-CN.json, en-US.json) -// -// 文件格式示例(zh-CN.json): -// -// { -// "user.not_found": "用户不存在", -// "user.login_success": "登录成功", -// "user.welcome": "欢迎,%s" -// } -// -// 示例: -// -// fac.InitI18n("zh-CN") -// fac.LoadI18nFromDir("locales") -func (f *Factory) LoadI18nFromDir(dirPath string) error { - i, err := f.getI18n() - if err != nil { - return err - } - return i.LoadFromDir(dirPath) -} - -// LoadI18nFromFile 从单个语言文件加载内容(黑盒模式,推荐使用) -// filePath: 语言文件路径(JSON格式) -// lang: 语言代码(如 "zh-CN", "en-US") -// -// 示例: -// -// fac.InitI18n("zh-CN") -// fac.LoadI18nFromFile("locales/zh-CN.json", "zh-CN") -// fac.LoadI18nFromFile("locales/en-US.json", "en-US") -func (f *Factory) LoadI18nFromFile(filePath, lang string) error { - i, err := f.getI18n() - if err != nil { - return err - } - return i.LoadFromFile(filePath, lang) -} - -// GetMessage 获取指定语言和代码的消息内容(黑盒模式,推荐使用) -// lang: 语言代码(如 "zh-CN", "en-US") -// code: 消息代码(如 "user.not_found") -// args: 可选参数,用于格式化消息(类似 fmt.Sprintf) -// -// 返回逻辑: -// 1. 如果指定语言存在该code,返回对应内容 -// 2. 如果指定语言不存在,尝试使用默认语言 -// 3. 如果默认语言也不存在,返回code本身(作为fallback) -// -// 示例: -// -// // 简单消息 -// msg := fac.GetMessage("zh-CN", "user.not_found") -// // 返回: "用户不存在" -// -// // 带参数的消息 -// msg := fac.GetMessage("zh-CN", "user.welcome", "Alice") -// // 如果消息内容是 "欢迎,%s",返回: "欢迎,Alice" -func (f *Factory) GetMessage(lang, code string, args ...interface{}) string { - i, err := f.getI18n() - if err != nil { - // 如果未初始化,返回code本身 - return code - } - return i.GetMessage(lang, code, args...) -} - -// GetI18n 获取国际化工具对象(高级功能时使用) -// 返回已初始化的国际化工具对象 -// -// ℹ️ 推荐使用黑盒方法: -// - GetMessage():获取消息内容 -// - LoadI18nFromDir():加载语言文件目录 -// - LoadI18nFromFile():加载单个语言文件 -// -// 仅在需要使用高级功能时获取对象: -// - HasLang():检查语言是否存在 -// - GetSupportedLangs():获取所有支持的语言 -// - ReloadFromFile():重新加载语言文件 -// - SetDefaultLang():动态设置默认语言 -// -// 示例(常用操作,推荐): -// -// fac.InitI18n("zh-CN") -// fac.LoadI18nFromDir("locales") -// msg := fac.GetMessage("zh-CN", "user.not_found") -// -// 示例(高级功能): -// -// i18n, _ := fac.GetI18n() -// langs := i18n.GetSupportedLangs() -// hasLang := i18n.HasLang("en-US") -func (f *Factory) GetI18n() (*i18n.I18n, error) { - return f.getI18n() -} - -// ========== Excel 导出工具(黑盒模式,推荐使用) ========== -// -// 这些方法提供数据导出到Excel的功能,支持结构体切片和自定义数据格式。 - -// getExcelClient 获取Excel导出器实例(内部方法,延迟初始化) -func (f *Factory) getExcelClient() (*excel.Excel, error) { - if f.excel != nil { - return f.excel, nil - } - - f.excel = excel.NewExcel() - return f.excel, nil -} - -// GetExcel 获取Excel导出器对象(高级功能时使用) -// 返回已初始化的Excel导出器对象 -// -// ℹ️ 推荐使用黑盒方法: -// - ExportToExcel():导出到Writer -// - ExportToExcelFile():导出到文件 -// -// 仅在需要使用高级功能时获取对象: -// - 多工作表操作 -// - 自定义样式 -// - 图表、公式等高级功能 -// -// 示例(常用操作,推荐): -// -// fac.ExportToExcel(w, "用户列表", columns, users) -// -// 示例(高级功能): -// -// excel, _ := fac.GetExcel() -// file := excel.GetFile() -// file.NewSheet("Sheet2") -func (f *Factory) GetExcel() (*excel.Excel, error) { - return f.getExcelClient() -} - -// ExportColumn Excel导出列定义(暴露给外部项目使用) -// 外部项目可以直接使用 factory.ExportColumn 创建列定义 -type ExportColumn = excel.ExportColumn - -// ExportToExcel 导出数据到Writer(黑盒模式,推荐使用) -// w: Writer对象(如http.ResponseWriter) -// 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 := []factory.ExportColumn{ -// {Header: "ID", Field: "ID"}, -// {Header: "姓名", Field: "Name"}, -// {Header: "邮箱", Field: "Email"}, -// } -// -// fac.ExportToExcel(w, "用户列表", columns, users) -// -// 示例2:使用格式化函数 -// -// columns := []factory.ExportColumn{ -// {Header: "ID", Field: "ID"}, -// {Header: "姓名", Field: "Name"}, -// {Header: "创建时间", Field: "CreatedAt", Format: excel.FormatDateTimeDefault}, -// } -// -// fac.ExportToExcel(w, "用户列表", columns, users) -func (f *Factory) ExportToExcel(w io.Writer, sheetName string, columns []ExportColumn, data interface{}) error { - e, err := f.getExcelClient() - if err != nil { - return err - } - return e.ExportToWriter(w, sheetName, columns, data) -} - -// ExportToExcelFile 导出数据到文件(黑盒模式,推荐使用) -// filePath: 文件路径 -// sheetName: 工作表名称(可选,默认为"Sheet1") -// columns: 列定义 -// data: 数据列表(可以是结构体切片或实现了ExportData接口的对象) -// 返回错误信息 -// -// 示例: -// -// fac.ExportToExcelFile("users.xlsx", "用户列表", columns, users) -// -// 注意:此方法内部创建文件并调用 ExportToWriter,确保行为与 ExportToExcel 一致 -func (f *Factory) ExportToExcelFile(filePath string, sheetName string, columns []ExportColumn, data interface{}) error { - e, err := f.getExcelClient() - if err != nil { - return err - } - - // 创建文件 - file, err := os.Create(filePath) - if err != nil { - return fmt.Errorf("failed to create file: %w", err) - } - defer file.Close() - - // 调用 ExportToWriter,复用核心逻辑 - return e.ExportToWriter(file, sheetName, columns, data) + m.AddMigrations(migrations...) + return m, nil } diff --git a/go.mod b/go.mod index 8f29c6e..98f3dab 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.24.0 toolchain go1.24.10 require ( + github.com/google/uuid v1.6.0 github.com/minio/minio-go/v7 v7.0.97 github.com/redis/go-redis/v9 v9.17.1 github.com/xuri/excelize/v2 v2.10.0 @@ -21,7 +22,6 @@ 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/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 diff --git a/http/handler.go b/http/handler.go new file mode 100644 index 0000000..69f6a18 --- /dev/null +++ b/http/handler.go @@ -0,0 +1,95 @@ +package http + +import ( + "net/http" + + "git.toowon.com/jimmy/go-common/i18n" +) + +// Handler HTTP 出参处理器(唯一对外出参方式) +type Handler struct { + w http.ResponseWriter + r *http.Request + i18n *i18n.I18n + pagination *PaginationRequest +} + +// HandlerOption Handler 配置项 +type HandlerOption func(*Handler) + +// WithI18n 注入 i18n +func WithI18n(i *i18n.I18n) HandlerOption { + return func(h *Handler) { + h.i18n = i + } +} + +// 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 +} + +// ParseJSON 解析 JSON 请求体 +func (h *Handler) ParseJSON(v interface{}) error { + return ParseJSON(h.r, v) +} + +// 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 成功响应 +func (h *Handler) Success(data interface{}) { + 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 + } + } + writeResponse(h.w, code, message, data) +} + +// SuccessPage 分页成功响应 +func (h *Handler) SuccessPage(list interface{}, total int64) { + p := h.Pagination() + pageData := &PageData{ + List: list, + Total: total, + Page: p.GetPage(), + PageSize: p.GetPageSize(), + } + h.Success(pageData) +} + +// Error 失败响应(messageCode 为 i18n 消息码) +func (h *Handler) Error(messageCode string, 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, nil) +} diff --git a/http/request.go b/http/request.go index 293327e..8ba5d10 100644 --- a/http/request.go +++ b/http/request.go @@ -5,7 +5,7 @@ import ( "io" "net/http" - "git.toowon.com/jimmy/go-common/middleware" + "git.toowon.com/jimmy/go-common/requestctx" "git.toowon.com/jimmy/go-common/tools" ) @@ -97,18 +97,12 @@ func ParseJSON(r *http.Request, v interface{}) error { return json.Unmarshal(body, v) } -// GetTimezone 从请求的context中获取时区(公共方法) -// r: HTTP请求 -// 如果使用了middleware.Timezone中间件,可以从context中获取时区信息 -// 如果未设置,返回默认时区 AsiaShanghai +// GetTimezone 从请求的 context 中获取时区 func GetTimezone(r *http.Request) string { - return middleware.GetTimezoneFromContext(r.Context()) + return requestctx.Timezone(r.Context()) } -// GetLanguage 从请求的context中获取语言(公共方法) -// r: HTTP请求 -// 如果使用了middleware.Language中间件,可以从context中获取语言信息 -// 如果未设置,返回默认语言 zh-CN +// GetLanguage 从请求的 context 中获取语言 func GetLanguage(r *http.Request) string { - return middleware.GetLanguageFromContext(r.Context()) + return requestctx.Language(r.Context()) } diff --git a/http/response.go b/http/response.go index 434bb9c..036e509 100644 --- a/http/response.go +++ b/http/response.go @@ -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"` + Code int `json:"code"` + Message string `json:"message"` + Timestamp int64 `json:"timestamp"` + 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,60 +34,5 @@ func writeJSON(w http.ResponseWriter, httpCode, code int, message string, data i Data: data, } - json.NewEncoder(w).Encode(response) -} - -// Success 成功响应(公共方法) -// w: ResponseWriter -// data: 响应数据,可以为nil -// message: 响应消息(可选),如果为空则使用默认消息 "success" -// -// 使用方式: -// -// Success(w, data) // 只有数据,使用默认消息 "success" -// Success(w, data, "查询成功") // 数据+消息 -func Success(w http.ResponseWriter, data interface{}, message ...string) { - msg := "success" - if len(message) > 0 && message[0] != "" { - msg = message[0] - } - writeJSON(w, http.StatusOK, 0, msg, data) -} - -// SuccessPage 分页成功响应(公共方法) -// w: ResponseWriter -// list: 数据列表 -// total: 总记录数 -// page: 当前页码 -// pageSize: 每页大小 -// message: 响应消息(可选),如果为空则使用默认消息 "success" -func SuccessPage(w http.ResponseWriter, list interface{}, total int64, page, pageSize int, message ...string) { - msg := "success" - if len(message) > 0 && message[0] != "" { - msg = message[0] - } - - pageData := &PageData{ - List: list, - Total: total, - Page: page, - PageSize: pageSize, - } - - writeJSON(w, http.StatusOK, 0, msg, pageData) -} - -// Error 错误响应(公共方法) -// w: ResponseWriter -// code: 业务错误码,非0表示业务错误 -// message: 错误消息 -func Error(w http.ResponseWriter, code int, message string) { - writeJSON(w, http.StatusOK, code, message, nil) -} - -// SystemError 系统错误响应(返回HTTP 500)(公共方法) -// w: ResponseWriter -// message: 错误消息 -func SystemError(w http.ResponseWriter, message string) { - writeJSON(w, http.StatusInternalServerError, 500, message, nil) + _ = json.NewEncoder(w).Encode(response) } diff --git a/logger/logger.go b/logger/logger.go index a9acd55..63417c4 100644 --- a/logger/logger.go +++ b/logger/logger.go @@ -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 + " " - } - - // 创建日志记录器 - logger := &Logger{ - config: cfg, - async: cfg.Async, - } - - // 根据日志级别创建不同的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 + return writers, nil +} + +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 } -// 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相关方法 ========== +// DroppedCount 返回因队列满而丢弃的日志条数 +func (l *Logger) DroppedCount() uint64 { + return l.dropped.Load() +} -// SetDefaultLogger 设置全局默认logger -// 用于在应用启动时统一配置logger -func SetDefaultLogger(log *Logger) { - defaultMux.Lock() - defer defaultMux.Unlock() +// ContextLogger 带 context 的 logger(自动附加 request_id) +type ContextLogger struct { + base *Logger + ctx context.Context +} - // 如果之前有logger,先关闭它 - if defaultLogger != nil { - defaultLogger.Close() +// 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 + } } - - defaultLogger = log + return &ContextLogger{base: base, ctx: ctx} } -// GetDefaultLogger 获取全局默认logger -func GetDefaultLogger() *Logger { - defaultMux.RLock() - defer defaultMux.RUnlock() - return defaultLogger -} - -// Default 全局日志方法 - Debug -func Default() *Logger { - return GetDefaultLogger() -} - -// ========== 全局便捷日志方法 ========== -// 以下方法使用全局默认logger,方便快速记录日志 - -// Debug 使用全局logger记录调试日志 -func Debug(format string, v ...interface{}) { - if log := GetDefaultLogger(); log != nil { - log.Debug(format, v...) +// FromContextWithLogger 使用指定 logger 并从 context 附加 request_id +func FromContextWithLogger(ctx context.Context, base *Logger) *ContextLogger { + if base == nil { + base = getDefaultLogger() } + return &ContextLogger{base: base, ctx: ctx} } -// Info 使用全局logger记录信息日志 -func Info(format string, v ...interface{}) { - if log := GetDefaultLogger(); log != nil { - log.Info(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 } -// Warn 使用全局logger记录警告日志 -func Warn(format string, v ...interface{}) { - if log := GetDefaultLogger(); log != nil { - log.Warn(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)) } -// Error 使用全局logger记录错误日志 -func Error(format string, v ...interface{}) { - if log := GetDefaultLogger(); log != nil { - log.Error(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)) } -// 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)) } diff --git a/middleware/clientip.go b/middleware/clientip.go new file mode 100644 index 0000000..ad40a44 --- /dev/null +++ b/middleware/clientip.go @@ -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 +} diff --git a/middleware/language.go b/middleware/language.go index 6c1fa34..2e90378 100644 --- a/middleware/language.go +++ b/middleware/language.go @@ -4,10 +4,9 @@ import ( "context" "net/http" "strings" -) -// LanguageKey context中存储语言的key -type languageKey struct{} + "git.toowon.com/jimmy/go-common/requestctx" +) // LanguageHeaderName 语言请求头名称 const LanguageHeaderName = "X-Language" @@ -15,15 +14,9 @@ const LanguageHeaderName = "X-Language" // AcceptLanguageHeaderName Accept-Language 请求头名称 const AcceptLanguageHeaderName = "Accept-Language" -// DefaultLanguage 默认语言 -const DefaultLanguage = "zh-CN" - -// GetLanguageFromContext 从context中获取语言 +// GetLanguageFromContext 从 context 中获取语言 func GetLanguageFromContext(ctx context.Context) string { - if lang, ok := ctx.Value(languageKey{}).(string); ok && lang != "" { - return lang - } - return DefaultLanguage + return requestctx.Language(ctx) } // Language 语言处理中间件 @@ -44,11 +37,10 @@ func Language(next http.Handler) http.Handler { // 3. 如果都未设置,使用默认语言 if lang == "" { - lang = DefaultLanguage + lang = requestctx.DefaultLanguage } - // 将语言存储到context中 - ctx := context.WithValue(r.Context(), languageKey{}, lang) + ctx := requestctx.WithLanguage(r.Context(), lang) next.ServeHTTP(w, r.WithContext(ctx)) }) } @@ -74,8 +66,7 @@ func LanguageWithDefault(defaultLanguage string) func(http.Handler) http.Handler lang = defaultLanguage } - // 将语言存储到context中 - ctx := context.WithValue(r.Context(), languageKey{}, lang) + ctx := requestctx.WithLanguage(r.Context(), lang) next.ServeHTTP(w, r.WithContext(ctx)) }) } diff --git a/middleware/logging.go b/middleware/logging.go index da1e15d..5f4e12d 100644 --- a/middleware/logging.go +++ b/middleware/logging.go @@ -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 跳过记录的路径列表(如健康检查接口) + Logger *logger.Logger 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(输出到stdout,info级别) - 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 -} diff --git a/middleware/recovery.go b/middleware/recovery.go index 1ed966b..8497b46 100644 --- a/middleware/recovery.go +++ b/middleware/recovery.go @@ -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中间件配置 +// 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, - }) -} - diff --git a/middleware/requestid.go b/middleware/requestid.go new file mode 100644 index 0000000..45445c4 --- /dev/null +++ b/middleware/requestid.go @@ -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)) + }) + } +} diff --git a/middleware/timezone.go b/middleware/timezone.go index 97725d4..7badee4 100644 --- a/middleware/timezone.go +++ b/middleware/timezone.go @@ -4,77 +4,48 @@ import ( "context" "net/http" + "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 = tools.AsiaShanghai - -// GetTimezoneFromContext 从context中获取时区 +// 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 := tools.GetLocation(timezone); err != nil { - // 如果时区无效,使用默认时区 - timezone = DefaultTimezone + 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 := tools.GetLocation(defaultTimezone); err != nil { - defaultTimezone = DefaultTimezone + 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 := 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)) }) } diff --git a/requestctx/requestctx.go b/requestctx/requestctx.go new file mode 100644 index 0000000..62f9c5b --- /dev/null +++ b/requestctx/requestctx.go @@ -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 +} diff --git a/sms/sms.go b/sms/sms.go index 7c6188e..acb779b 100644 --- a/sms/sms.go +++ b/sms/sms.go @@ -1,6 +1,7 @@ package sms import ( + "context" "crypto/hmac" "crypto/sha1" "encoding/base64" @@ -11,29 +12,40 @@ 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 请求ID RequestID string `json:"RequestId"` - - // Code 响应码 - Code string `json:"Code"` - - // Message 响应消息 - Message string `json:"Message"` - - // BizID 业务ID - BizID string `json:"BizId"` + 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 创建短信发送器 @@ -41,81 +53,87 @@ func NewSMS(cfg *config.Config) *SMS { if cfg == nil || cfg.SMS == nil { return &SMS{config: nil} } - return &SMS{config: cfg.SMS} + 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, + }) + } + } } -// getSMSConfig 获取短信配置(内部方法) func (s *SMS) getSMSConfig() (*config.SMSConfig, error) { if s.config == nil { return nil, fmt.Errorf("SMS config is nil") } - if s.config.AccessKeyID == "" { return nil, fmt.Errorf("AccessKeyID is required") } - if s.config.AccessKeySecret == "" { return nil, fmt.Errorf("AccessKeySecret is required") } - if s.config.SignName == "" { return nil, fmt.Errorf("SignName is required") } - - // 设置默认值 if s.config.Region == "" { s.config.Region = "cn-hangzhou" } if s.config.Timeout == 0 { - s.config.Timeout = 10 + s.config.Timeout = 5 } - return s.config, nil } -// SendSMS 发送短信 -// phoneNumbers: 手机号列表 -// templateParam: 模板参数(map或JSON字符串) -// templateCode: 模板代码(可选,如果为空使用配置中的模板代码) +// SendSMS 同步发送短信 func (s *SMS) SendSMS(phoneNumbers []string, templateParam interface{}, templateCode ...string) (*SendResponse, error) { cfg, err := s.getSMSConfig() if err != nil { return nil, err } - if len(phoneNumbers) == 0 { return nil, fmt.Errorf("phone numbers are required") } - // 使用配置中的模板代码(如果请求中未指定) - templateCodeValue := "" + templateCodeValue := cfg.TemplateCode if len(templateCode) > 0 && templateCode[0] != "" { templateCodeValue = templateCode[0] - } else { - templateCodeValue = cfg.TemplateCode } if templateCodeValue == "" { return nil, fmt.Errorf("template code is required") } - signName := cfg.SignName - - // 处理模板参数 var templateParamJSON string 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) @@ -126,104 +144,120 @@ func (s *SMS) SendSMS(phoneNumbers []string, templateParam interface{}, template templateParamJSON = "{}" } - // 构建请求参数 - params := make(map[string]string) - params["Action"] = "SendSms" - params["Version"] = "2017-05-25" - params["RegionId"] = cfg.Region - params["AccessKeyId"] = cfg.AccessKeyID - params["Format"] = "JSON" - params["SignatureMethod"] = "HMAC-SHA1" - params["SignatureVersion"] = "1.0" - params["SignatureNonce"] = fmt.Sprint(time.Now().UnixNano()) - params["Timestamp"] = time.Now().UTC().Format("2006-01-02T15:04:05Z") - params["PhoneNumbers"] = strings.Join(phoneNumbers, ",") - params["SignName"] = signName - params["TemplateCode"] = templateCodeValue - 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) - // 计算签名 - signature := s.calculateSignature(params, "POST", cfg.AccessKeySecret) - params["Signature"] = signature - - // 构建请求URL endpoint := cfg.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())) + 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, - } - + client := &http.Client{Timeout: time.Duration(cfg.Timeout) * time.Second} resp, err := client.Do(httpReq) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() - // 读取响应 body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } - // 解析响应 var sendResp SendResponse if err := json.Unmarshal(body, &sendResp); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } - - // 检查响应码 if sendResp.Code != "OK" { return &sendResp, fmt.Errorf("SMS send failed: Code=%s, Message=%s", sendResp.Code, sendResp.Message) } - return &sendResp, nil } -// calculateSignature 计算签名 +// SendSMSAsync 异步发送短信 +func (s *SMS) 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(accessKeySecret+"&")) mac.Write([]byte(stringToSign)) - signature := base64.StdEncoding.EncodeToString(mac.Sum(nil)) - - return signature + return base64.StdEncoding.EncodeToString(mac.Sum(nil)) } - diff --git a/storage/handler.go b/storage/handler.go index 7b36a12..faae5cf 100644 --- a/storage/handler.go +++ b/storage/handler.go @@ -39,37 +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 { - commonhttp.Error(w, 4001, "Method not allowed") + handler.Error("common.method_not_allowed") return } - // 解析multipart表单 err := r.ParseMultipartForm(h.maxFileSize) if err != nil { - commonhttp.Error(w, 4002, fmt.Sprintf("Failed to parse form: %v", err)) + handler.Error("common.invalid_request") return } - // 获取文件 file, header, err := r.FormFile("file") if err != nil { - commonhttp.Error(w, 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 { - commonhttp.Error(w, 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 @@ -80,22 +75,19 @@ func (h *UploadHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } if !allowed { - commonhttp.Error(w, 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)) @@ -104,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 { - commonhttp.SystemError(w, 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 { - commonhttp.SystemError(w, fmt.Sprintf("Failed to get file URL: %v", err)) + handler.Error("system.internal_error") return } - // 返回结果 result := UploadResult{ ObjectKey: objectKey, URL: fileURL, @@ -127,8 +115,7 @@ func (h *UploadHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ContentType: contentType, UploadTime: time.Now(), } - - commonhttp.Success(w, result, "Upload successful") + handler.Success(result) } // generateUniqueFilename 生成唯一文件名 diff --git a/templates/README.md b/templates/README.md index 64f7c8b..880dc59 100644 --- a/templates/README.md +++ b/templates/README.md @@ -37,4 +37,4 @@ cp templates/Makefile.example Makefile ## 完整文档 -详细使用说明请查看:[MIGRATION.md](../MIGRATION.md) +详细使用说明请查看:[INTEGRATION.md](../INTEGRATION.md) 第 7 节「数据库迁移」