Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f00b83e86 |
@@ -89,6 +89,7 @@ go run ./cmd/server -config config.json -addr :8080
|
|||||||
| Excel | `Excel()` | 每次新建导出器 |
|
| Excel | `Excel()` | 每次新建导出器 |
|
||||||
| 国际化 | `I18n()` / `MustI18n()` | 一般由 `NewHandler` 注入 |
|
| 国际化 | `I18n()` / `MustI18n()` | 一般由 `NewHandler` 注入 |
|
||||||
| MCP 工具 | `MCP()` / `MustMCP()` | `ListTools` / `CallTool`(见 §8) |
|
| MCP 工具 | `MCP()` / `MustMCP()` | `ListTools` / `CallTool`(见 §8) |
|
||||||
|
| 图形验证码 | `Captcha()` / `MustCaptcha()` | `Generate` / `Verify` / 登录防暴力(见 §8) |
|
||||||
| HTTP 出参 | `NewHandler(w, r)` | `Success` / `Error` / `ErrorData` |
|
| HTTP 出参 | `NewHandler(w, r)` | `Success` / `Error` / `ErrorData` |
|
||||||
| 中间件 | `MiddlewareChain()` | `Append` / `ThenFunc` |
|
| 中间件 | `MiddlewareChain()` | `Append` / `ThenFunc` |
|
||||||
| 迁移 | `Migrator(dir)` | Up / Down / Status |
|
| 迁移 | `Migrator(dir)` | Up / Down / Status |
|
||||||
@@ -205,6 +206,41 @@ ctx = mcp.WithRequestID(ctx, requestID)
|
|||||||
加一条配置,不改代码;`allowedTools` 留空表示开放该 server 全部工具。完整字段见
|
加一条配置,不改代码;`allowedTools` 留空表示开放该 server 全部工具。完整字段见
|
||||||
[`config/example.json`](./config/example.json) 的 `mcp` 段。
|
[`config/example.json`](./config/example.json) 的 `mcp` 段。
|
||||||
|
|
||||||
|
### 图形验证码(Captcha)
|
||||||
|
|
||||||
|
可选模块:`config.captcha.enabled=false`(或未配置)时 `Captcha().Enabled()` 为 false,**不影响 middleware**;业务 handler 按需调用即可。
|
||||||
|
|
||||||
|
```go
|
||||||
|
cap := app.MustCaptcha() // enabled=true 时需配置 redis
|
||||||
|
|
||||||
|
// 1. 生成(注册/改手机号等发短信前)
|
||||||
|
result, _ := cap.Generate(ctx, captcha.SceneRegister)
|
||||||
|
// 返回 result.ID + result.ImageBase64 给前端展示
|
||||||
|
|
||||||
|
// 2. 校验(一次性消费)
|
||||||
|
if err := cap.Verify(ctx, captcha.VerifyRequest{
|
||||||
|
Scene: captcha.SceneRegister, ID: captchaID, Answer: userInput,
|
||||||
|
}); err != nil {
|
||||||
|
h.Error("captcha.invalid")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 验证通过后再 app.MustSMS().SendSMS(...)
|
||||||
|
|
||||||
|
// 3. 登录防暴力(失败 N 次后要求验证码,M 次后锁定)
|
||||||
|
key := username // 或 username+IP,由业务决定
|
||||||
|
if need, _ := cap.LoginNeedCaptcha(ctx, key); need {
|
||||||
|
// 要求前端传 captchaId + captchaCode,并 Verify
|
||||||
|
}
|
||||||
|
status, err := cap.RecordLoginFailure(ctx, key)
|
||||||
|
if status.Locked { h.Error("captcha.login_locked"); return }
|
||||||
|
// 登录成功后
|
||||||
|
_ = cap.ClearLoginFailures(ctx, key)
|
||||||
|
```
|
||||||
|
|
||||||
|
场景常量:`SceneRegister` / `SceneChangePhone` / `SceneLogin` / `SceneSendSMS`。
|
||||||
|
`scenes.register.mode=always` 表示该场景始终要求图形验证码;登录场景由 `login.showAfterFailures` 控制「失败后才要求」。
|
||||||
|
完整配置见 [`config/example.json`](./config/example.json) 的 `captcha` 段。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. 最小 config.json
|
## 9. 最小 config.json
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ git.toowon.com/jimmy/go-common
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
go env -w GOPRIVATE=git.toowon.com
|
go env -w GOPRIVATE=git.toowon.com
|
||||||
go get git.toowon.com/jimmy/go-common@v1.3.0
|
go get git.toowon.com/jimmy/go-common@v1.4.0
|
||||||
```
|
```
|
||||||
|
|
||||||
## 设计
|
## 设计
|
||||||
@@ -34,6 +34,7 @@ go get git.toowon.com/jimmy/go-common@v1.3.0
|
|||||||
| `migration` | SQL 迁移 |
|
| `migration` | SQL 迁移 |
|
||||||
| `tools` | 时间、加密、金额等 |
|
| `tools` | 时间、加密、金额等 |
|
||||||
| `mcp` | MCP Client(多 server、`ListTools`/`CallTool`) |
|
| `mcp` | MCP Client(多 server、`ListTools`/`CallTool`) |
|
||||||
|
| `captcha` | 图形验证码、登录防暴力(可选,`enabled` 开关) |
|
||||||
|
|
||||||
## 文档
|
## 文档
|
||||||
|
|
||||||
|
|||||||
@@ -24,10 +24,11 @@ go get git.toowon.com/jimmy/go-common@latest # 开发
|
|||||||
|
|
||||||
## 当前版本
|
## 当前版本
|
||||||
|
|
||||||
**v1.3.0**
|
**v1.4.0**
|
||||||
|
|
||||||
## 版本历史
|
## 版本历史
|
||||||
|
|
||||||
|
- **v1.4.0** — 新增 `captcha` 模块:图形验证码(`Generate`/`Verify`、Redis 存储、场景配置)与登录防暴力(失败计数、触发验证码、临时锁定);`config.CaptchaConfig`;`factory.Captcha()`/`MustCaptcha()`/`WithCaptcha`/`ModuleCaptcha`;**不进默认 middleware**,第三方按需 `enabled` 开关
|
||||||
- **v1.3.0** — 新增 `mcp` 模块:MCP(Model Context Protocol)Client(`mcp.Manager`:多 server、命名空间前缀、白名单、`ListTools`/`CallTool`);`config.MCPConfig`;`factory.MCP()`/`MustMCP()`/`WithMCP`;不含路由/抽参/编排(业务侧自行实现)
|
- **v1.3.0** — 新增 `mcp` 模块:MCP(Model Context Protocol)Client(`mcp.Manager`:多 server、命名空间前缀、白名单、`ListTools`/`CallTool`);`config.MCPConfig`;`factory.MCP()`/`MustMCP()`/`WithMCP`;不含路由/抽参/编排(业务侧自行实现)
|
||||||
- **v1.2.0** — DX:`MustInit` / `Warmup` / `MustXxx` / `Close` / `NewHandler`;Option 注入;Excel 每次新建;`templates/server`;`ErrorData`;文档精简
|
- **v1.2.0** — DX:`MustInit` / `Warmup` / `MustXxx` / `Close` / `NewHandler`;Option 注入;Excel 每次新建;`templates/server`;`ErrorData`;文档精简
|
||||||
- **v1.1.0** — 同上 DX 能力首次合入(见上)
|
- **v1.1.0** — 同上 DX 能力首次合入(见上)
|
||||||
|
|||||||
307
captcha/captcha.go
Normal file
307
captcha/captcha.go
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
package captcha
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.toowon.com/jimmy/go-common/config"
|
||||||
|
"github.com/mojocn/base64Captcha"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Scene 验证码业务场景。
|
||||||
|
type Scene string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SceneRegister Scene = "register"
|
||||||
|
SceneChangePhone Scene = "change_phone"
|
||||||
|
SceneLogin Scene = "login"
|
||||||
|
SceneSendSMS Scene = "send_sms"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GenerateResult 生成图形验证码的返回。
|
||||||
|
type GenerateResult struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ImageBase64 string `json:"imageBase64"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyRequest 校验图形验证码的请求。
|
||||||
|
type VerifyRequest struct {
|
||||||
|
Scene Scene
|
||||||
|
ID string
|
||||||
|
Answer string
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginGuardStatus 登录防暴力状态。
|
||||||
|
type LoginGuardStatus struct {
|
||||||
|
FailCount int `json:"failCount"`
|
||||||
|
NeedCaptcha bool `json:"needCaptcha"`
|
||||||
|
Locked bool `json:"locked"`
|
||||||
|
RemainingSec int `json:"remainingSec"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager 图形验证码子系统(`app.Captcha()` 取得)。
|
||||||
|
//
|
||||||
|
// 职责:
|
||||||
|
// - 生成/校验图形验证码(Redis 存储,一次性消费);
|
||||||
|
// - 登录失败计数、触发验证码、临时锁定;
|
||||||
|
// - Enabled() 反映 config.captcha.enabled 且 Redis 可用。
|
||||||
|
//
|
||||||
|
// 不含路由与业务编排:消费方在 handler 中按需调用 Generate / Verify / RecordLoginFailure。
|
||||||
|
type Manager interface {
|
||||||
|
Enabled() bool
|
||||||
|
Generate(ctx context.Context, scene Scene) (*GenerateResult, error)
|
||||||
|
Verify(ctx context.Context, req VerifyRequest) error
|
||||||
|
SceneRequiresCaptcha(scene Scene) bool
|
||||||
|
LoginNeedCaptcha(ctx context.Context, key string) (bool, error)
|
||||||
|
IsLoginLocked(ctx context.Context, key string) (bool, int, error)
|
||||||
|
RecordLoginFailure(ctx context.Context, key string) (LoginGuardStatus, error)
|
||||||
|
ClearLoginFailures(ctx context.Context, key string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type manager struct {
|
||||||
|
enabled bool
|
||||||
|
cfg *config.CaptchaConfig
|
||||||
|
rdb *redis.Client
|
||||||
|
captcha *base64Captcha.Captcha
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager 根据配置与 Redis 构建图形验证码子系统。
|
||||||
|
// cfg 为 nil 或 enabled=false 时返回 Enabled()=false 的安全实例,其余方法返回 ErrDisabled。
|
||||||
|
// enabled=true 但 rdb=nil 时同样视为不可用(Enabled()=false)。
|
||||||
|
func NewManager(rdb *redis.Client, cfg *config.CaptchaConfig) Manager {
|
||||||
|
m := &manager{cfg: cfg}
|
||||||
|
if cfg == nil || !cfg.Enabled || rdb == nil {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeCaptchaConfig(cfg)
|
||||||
|
|
||||||
|
ttl := time.Duration(cfg.TTLSec) * time.Second
|
||||||
|
store := newRedisStore(rdb, ttl)
|
||||||
|
driver := base64Captcha.NewDriverDigit(cfg.Height, cfg.Width, cfg.Length, 0.7, 80)
|
||||||
|
m.enabled = true
|
||||||
|
m.rdb = rdb
|
||||||
|
m.captcha = base64Captcha.NewCaptcha(driver, store)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCaptchaConfig(cfg *config.CaptchaConfig) {
|
||||||
|
if cfg.Length == 0 {
|
||||||
|
cfg.Length = 4
|
||||||
|
}
|
||||||
|
if cfg.Width == 0 {
|
||||||
|
cfg.Width = 120
|
||||||
|
}
|
||||||
|
if cfg.Height == 0 {
|
||||||
|
cfg.Height = 40
|
||||||
|
}
|
||||||
|
if cfg.TTLSec == 0 {
|
||||||
|
cfg.TTLSec = 300
|
||||||
|
}
|
||||||
|
if cfg.Login == nil {
|
||||||
|
cfg.Login = &config.LoginCaptchaConfig{}
|
||||||
|
}
|
||||||
|
if cfg.Login.ShowAfterFailures == 0 {
|
||||||
|
cfg.Login.ShowAfterFailures = 3
|
||||||
|
}
|
||||||
|
if cfg.Login.LockAfterFailures == 0 {
|
||||||
|
cfg.Login.LockAfterFailures = 10
|
||||||
|
}
|
||||||
|
if cfg.Login.LockDurationMin == 0 {
|
||||||
|
cfg.Login.LockDurationMin = 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *manager) Enabled() bool {
|
||||||
|
return m != nil && m.enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *manager) Generate(ctx context.Context, scene Scene) (*GenerateResult, error) {
|
||||||
|
if err := m.requireEnabled(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = ctx
|
||||||
|
|
||||||
|
id, b64s, _, err := m.captcha.Generate()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("captcha generate: %w", err)
|
||||||
|
}
|
||||||
|
return &GenerateResult{
|
||||||
|
ID: id,
|
||||||
|
ImageBase64: b64s,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *manager) Verify(ctx context.Context, req VerifyRequest) error {
|
||||||
|
if err := m.requireEnabled(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = ctx
|
||||||
|
|
||||||
|
id := strings.TrimSpace(req.ID)
|
||||||
|
answer := strings.TrimSpace(req.Answer)
|
||||||
|
if id == "" || answer == "" {
|
||||||
|
return ErrInvalid
|
||||||
|
}
|
||||||
|
if !m.captcha.Verify(id, answer, true) {
|
||||||
|
return ErrInvalid
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *manager) SceneRequiresCaptcha(scene Scene) bool {
|
||||||
|
if !m.Enabled() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if scene == SceneLogin {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
mode := m.sceneMode(scene)
|
||||||
|
return mode == "always"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *manager) LoginNeedCaptcha(ctx context.Context, key string) (bool, error) {
|
||||||
|
if !m.Enabled() {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
loginCfg := m.loginConfig()
|
||||||
|
if loginCfg.ShowAfterFailures <= 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
locked, _, err := m.IsLoginLocked(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if locked {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := m.getLoginFailCount(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return count >= loginCfg.ShowAfterFailures, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *manager) IsLoginLocked(ctx context.Context, key string) (bool, int, error) {
|
||||||
|
if !m.Enabled() {
|
||||||
|
return false, 0, nil
|
||||||
|
}
|
||||||
|
ttl, err := m.rdb.TTL(ctx, loginLockKey(key)).Result()
|
||||||
|
if err != nil {
|
||||||
|
return false, 0, err
|
||||||
|
}
|
||||||
|
if ttl <= 0 {
|
||||||
|
return false, 0, nil
|
||||||
|
}
|
||||||
|
return true, int(ttl.Seconds()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *manager) RecordLoginFailure(ctx context.Context, key string) (LoginGuardStatus, error) {
|
||||||
|
status := LoginGuardStatus{}
|
||||||
|
if !m.Enabled() {
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
loginCfg := m.loginConfig()
|
||||||
|
lockKey := loginLockKey(key)
|
||||||
|
failKey := loginFailKey(key)
|
||||||
|
|
||||||
|
if ttl, err := m.rdb.TTL(ctx, lockKey).Result(); err != nil {
|
||||||
|
return status, err
|
||||||
|
} else if ttl > 0 {
|
||||||
|
status.Locked = true
|
||||||
|
status.NeedCaptcha = true
|
||||||
|
status.RemainingSec = int(ttl.Seconds())
|
||||||
|
return status, ErrLoginLocked
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := m.rdb.Incr(ctx, failKey).Result()
|
||||||
|
if err != nil {
|
||||||
|
return status, err
|
||||||
|
}
|
||||||
|
if count == 1 {
|
||||||
|
_ = m.rdb.Expire(ctx, failKey, time.Duration(loginCfg.LockDurationMin)*time.Minute).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
status.FailCount = int(count)
|
||||||
|
status.NeedCaptcha = loginCfg.ShowAfterFailures > 0 && int(count) >= loginCfg.ShowAfterFailures
|
||||||
|
|
||||||
|
if loginCfg.LockAfterFailures > 0 && int(count) >= loginCfg.LockAfterFailures {
|
||||||
|
lockTTL := time.Duration(loginCfg.LockDurationMin) * time.Minute
|
||||||
|
if err := m.rdb.Set(ctx, lockKey, "1", lockTTL).Err(); err != nil {
|
||||||
|
return status, err
|
||||||
|
}
|
||||||
|
_ = m.rdb.Del(ctx, failKey).Err()
|
||||||
|
status.Locked = true
|
||||||
|
status.NeedCaptcha = true
|
||||||
|
status.RemainingSec = int(lockTTL.Seconds())
|
||||||
|
return status, ErrLoginLocked
|
||||||
|
}
|
||||||
|
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *manager) ClearLoginFailures(ctx context.Context, key string) error {
|
||||||
|
if !m.Enabled() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return m.rdb.Del(ctx, loginFailKey(key), loginLockKey(key)).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *manager) requireEnabled() error {
|
||||||
|
if !m.Enabled() {
|
||||||
|
return ErrDisabled
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *manager) sceneMode(scene Scene) string {
|
||||||
|
if m.cfg == nil || m.cfg.Scenes == nil {
|
||||||
|
return defaultSceneMode(scene)
|
||||||
|
}
|
||||||
|
if sc, ok := m.cfg.Scenes[string(scene)]; ok && sc.Mode != "" {
|
||||||
|
return sc.Mode
|
||||||
|
}
|
||||||
|
return defaultSceneMode(scene)
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultSceneMode(scene Scene) string {
|
||||||
|
switch scene {
|
||||||
|
case SceneRegister, SceneChangePhone, SceneSendSMS:
|
||||||
|
return "always"
|
||||||
|
default:
|
||||||
|
return "never"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *manager) loginConfig() config.LoginCaptchaConfig {
|
||||||
|
if m.cfg == nil || m.cfg.Login == nil {
|
||||||
|
return config.LoginCaptchaConfig{
|
||||||
|
ShowAfterFailures: 3,
|
||||||
|
LockAfterFailures: 10,
|
||||||
|
LockDurationMin: 30,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return *m.cfg.Login
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *manager) getLoginFailCount(ctx context.Context, key string) (int, error) {
|
||||||
|
n, err := m.rdb.Get(ctx, loginFailKey(key)).Int()
|
||||||
|
if err == redis.Nil {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginFailKey(key string) string {
|
||||||
|
return "captcha:login:fail:" + key
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginLockKey(key string) string {
|
||||||
|
return "captcha:login:lock:" + key
|
||||||
|
}
|
||||||
174
captcha/captcha_test.go
Normal file
174
captcha/captcha_test.go
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
package captcha_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.toowon.com/jimmy/go-common/captcha"
|
||||||
|
"git.toowon.com/jimmy/go-common/config"
|
||||||
|
"github.com/alicebob/miniredis/v2"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDisabledManager(t *testing.T) {
|
||||||
|
m := captcha.NewManager(nil, nil)
|
||||||
|
if m.Enabled() {
|
||||||
|
t.Fatal("expected disabled")
|
||||||
|
}
|
||||||
|
_, err := m.Generate(context.Background(), captcha.SceneRegister)
|
||||||
|
if err != captcha.ErrDisabled {
|
||||||
|
t.Fatalf("Generate err = %v, want ErrDisabled", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateAndVerify(t *testing.T) {
|
||||||
|
s, err := miniredis.Run()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
rdb := redis.NewClient(&redis.Options{Addr: s.Addr()})
|
||||||
|
cfg := &config.CaptchaConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Length: 4,
|
||||||
|
Width: 120,
|
||||||
|
Height: 40,
|
||||||
|
TTLSec: 300,
|
||||||
|
Login: &config.LoginCaptchaConfig{},
|
||||||
|
}
|
||||||
|
m := captcha.NewManager(rdb, cfg)
|
||||||
|
if !m.Enabled() {
|
||||||
|
t.Fatal("expected enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
result, err := m.Generate(ctx, captcha.SceneRegister)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.ID == "" || result.ImageBase64 == "" {
|
||||||
|
t.Fatal("expected id and image")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 redis 读取答案用于测试
|
||||||
|
answer, err := rdb.Get(ctx, "captcha:code:"+result.ID).Result()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := m.Verify(ctx, captcha.VerifyRequest{
|
||||||
|
Scene: captcha.SceneRegister,
|
||||||
|
ID: result.ID,
|
||||||
|
Answer: answer,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Verify: %v", err)
|
||||||
|
}
|
||||||
|
if err := m.Verify(ctx, captcha.VerifyRequest{
|
||||||
|
Scene: captcha.SceneRegister,
|
||||||
|
ID: result.ID,
|
||||||
|
Answer: answer,
|
||||||
|
}); err != captcha.ErrInvalid {
|
||||||
|
t.Fatalf("second Verify err = %v, want ErrInvalid", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginGuard(t *testing.T) {
|
||||||
|
s, err := miniredis.Run()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
rdb := redis.NewClient(&redis.Options{Addr: s.Addr()})
|
||||||
|
cfg := &config.CaptchaConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Length: 4,
|
||||||
|
Width: 120,
|
||||||
|
Height: 40,
|
||||||
|
TTLSec: 300,
|
||||||
|
Login: &config.LoginCaptchaConfig{
|
||||||
|
ShowAfterFailures: 2,
|
||||||
|
LockAfterFailures: 4,
|
||||||
|
LockDurationMin: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
m := captcha.NewManager(rdb, cfg)
|
||||||
|
ctx := context.Background()
|
||||||
|
key := "alice"
|
||||||
|
|
||||||
|
status, err := m.RecordLoginFailure(ctx, key)
|
||||||
|
if err != nil || status.FailCount != 1 || status.NeedCaptcha {
|
||||||
|
t.Fatalf("first failure: status=%+v err=%v", status, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
status, err = m.RecordLoginFailure(ctx, key)
|
||||||
|
if err != nil || status.FailCount != 2 || !status.NeedCaptcha {
|
||||||
|
t.Fatalf("second failure: status=%+v err=%v", status, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
need, err := m.LoginNeedCaptcha(ctx, key)
|
||||||
|
if err != nil || !need {
|
||||||
|
t.Fatalf("LoginNeedCaptcha = %v, err = %v", need, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
_, _ = m.RecordLoginFailure(ctx, key)
|
||||||
|
}
|
||||||
|
status, err = m.RecordLoginFailure(ctx, key)
|
||||||
|
if err != captcha.ErrLoginLocked || !status.Locked {
|
||||||
|
t.Fatalf("lock: status=%+v err=%v", status, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
locked, remaining, err := m.IsLoginLocked(ctx, key)
|
||||||
|
if err != nil || !locked || remaining <= 0 {
|
||||||
|
t.Fatalf("IsLoginLocked = %v, %d, err = %v", locked, remaining, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.ClearLoginFailures(ctx, key); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
locked, _, _ = m.IsLoginLocked(ctx, key)
|
||||||
|
if locked {
|
||||||
|
t.Fatal("expected unlocked after clear")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSceneRequiresCaptcha(t *testing.T) {
|
||||||
|
cfg := &config.CaptchaConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Scenes: map[string]config.CaptchaSceneConfig{
|
||||||
|
"register": {Mode: "always"},
|
||||||
|
"login": {Mode: "never"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
m := captcha.NewManager(redis.NewClient(&redis.Options{Addr: "127.0.0.1:1"}), cfg)
|
||||||
|
if !m.SceneRequiresCaptcha(captcha.SceneRegister) {
|
||||||
|
t.Fatal("register should require captcha")
|
||||||
|
}
|
||||||
|
if m.SceneRequiresCaptcha(captcha.SceneLogin) {
|
||||||
|
t.Fatal("login should not always require captcha")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRedisStoreTTL(t *testing.T) {
|
||||||
|
s, err := miniredis.Run()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
rdb := redis.NewClient(&redis.Options{Addr: s.Addr()})
|
||||||
|
cfg := &config.CaptchaConfig{Enabled: true, TTLSec: 1, Login: &config.LoginCaptchaConfig{}}
|
||||||
|
m := captcha.NewManager(rdb, cfg)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
result, err := m.Generate(ctx, captcha.SceneRegister)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s.FastForward(2 * time.Second)
|
||||||
|
if err := m.Verify(ctx, captcha.VerifyRequest{ID: result.ID, Answer: "x"}); err != captcha.ErrInvalid {
|
||||||
|
t.Fatalf("expired verify err = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
17
captcha/errors.go
Normal file
17
captcha/errors.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package captcha
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrDisabled 图形验证码子系统未启用。
|
||||||
|
ErrDisabled = errors.New("captcha disabled")
|
||||||
|
|
||||||
|
// ErrInvalid 验证码错误或已失效。
|
||||||
|
ErrInvalid = errors.New("captcha invalid")
|
||||||
|
|
||||||
|
// ErrRequired 当前场景要求验证码但未提供或校验失败。
|
||||||
|
ErrRequired = errors.New("captcha required")
|
||||||
|
|
||||||
|
// ErrLoginLocked 登录失败次数过多,账号或 IP 已被临时锁定。
|
||||||
|
ErrLoginLocked = errors.New("login locked")
|
||||||
|
)
|
||||||
42
captcha/redis_store.go
Normal file
42
captcha/redis_store.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package captcha
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
const codeKeyPrefix = "captcha:code:"
|
||||||
|
|
||||||
|
// redisStore 实现 base64Captcha.Store,将验证码答案存入 Redis。
|
||||||
|
type redisStore struct {
|
||||||
|
client *redis.Client
|
||||||
|
ttl time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRedisStore(client *redis.Client, ttl time.Duration) *redisStore {
|
||||||
|
return &redisStore{client: client, ttl: ttl}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *redisStore) Set(id string, value string) error {
|
||||||
|
return s.client.Set(context.Background(), codeKeyPrefix+id, value, s.ttl).Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *redisStore) Get(id string, clear bool) string {
|
||||||
|
ctx := context.Background()
|
||||||
|
key := codeKeyPrefix + id
|
||||||
|
val, err := s.client.Get(ctx, key).Result()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if clear {
|
||||||
|
_ = s.client.Del(ctx, key).Err()
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *redisStore) Verify(id, answer string, clear bool) bool {
|
||||||
|
val := s.Get(id, clear)
|
||||||
|
return val != "" && val == answer
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ type Config struct {
|
|||||||
I18n *I18nConfig `json:"i18n"`
|
I18n *I18nConfig `json:"i18n"`
|
||||||
RateLimit *RateLimitConfig `json:"rateLimit"`
|
RateLimit *RateLimitConfig `json:"rateLimit"`
|
||||||
MCP *MCPConfig `json:"mcp"`
|
MCP *MCPConfig `json:"mcp"`
|
||||||
|
Captcha *CaptchaConfig `json:"captcha"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// I18nConfig 国际化配置
|
// I18nConfig 国际化配置
|
||||||
@@ -308,6 +309,48 @@ func (c *LoggerConfig) IsAsync() bool {
|
|||||||
return *c.Async
|
return *c.Async
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CaptchaConfig 图形验证码配置(可选模块;enabled=false 或未配置时不启用)
|
||||||
|
type CaptchaConfig struct {
|
||||||
|
// Enabled 总开关
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
|
||||||
|
// Length 验证码字符数,默认 4
|
||||||
|
Length int `json:"length"`
|
||||||
|
|
||||||
|
// Width 图片宽度(像素),默认 120
|
||||||
|
Width int `json:"width"`
|
||||||
|
|
||||||
|
// Height 图片高度(像素),默认 40
|
||||||
|
Height int `json:"height"`
|
||||||
|
|
||||||
|
// TTLSec 验证码有效期(秒),默认 300
|
||||||
|
TTLSec int `json:"ttlSec"`
|
||||||
|
|
||||||
|
// Login 登录防暴力配置(失败 N 次后要求验证码,M 次后锁定)
|
||||||
|
Login *LoginCaptchaConfig `json:"login"`
|
||||||
|
|
||||||
|
// Scenes 各场景是否强制图形验证码(mode: always | never;login 场景由 Login 配置控制)
|
||||||
|
Scenes map[string]CaptchaSceneConfig `json:"scenes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginCaptchaConfig 登录防暴力配置
|
||||||
|
type LoginCaptchaConfig struct {
|
||||||
|
// ShowAfterFailures 失败多少次后开始要求图形验证码,默认 3
|
||||||
|
ShowAfterFailures int `json:"showAfterFailures"`
|
||||||
|
|
||||||
|
// LockAfterFailures 失败多少次后临时锁定,默认 10
|
||||||
|
LockAfterFailures int `json:"lockAfterFailures"`
|
||||||
|
|
||||||
|
// LockDurationMin 锁定时长(分钟),默认 30
|
||||||
|
LockDurationMin int `json:"lockDurationMin"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaptchaSceneConfig 单场景验证码策略
|
||||||
|
type CaptchaSceneConfig struct {
|
||||||
|
// Mode always=该场景始终要求图形验证码;never=不要求(login 场景忽略此项)
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
}
|
||||||
|
|
||||||
// RateLimitConfig 限流配置
|
// RateLimitConfig 限流配置
|
||||||
type RateLimitConfig struct {
|
type RateLimitConfig struct {
|
||||||
// Enable 是否启用限流
|
// Enable 是否启用限流
|
||||||
@@ -544,6 +587,34 @@ func (c *Config) setDefaults() {
|
|||||||
c.MCP.CallTimeoutSeconds = 60
|
c.MCP.CallTimeoutSeconds = 60
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Captcha 默认值
|
||||||
|
if c.Captcha != nil {
|
||||||
|
if c.Captcha.Length == 0 {
|
||||||
|
c.Captcha.Length = 4
|
||||||
|
}
|
||||||
|
if c.Captcha.Width == 0 {
|
||||||
|
c.Captcha.Width = 120
|
||||||
|
}
|
||||||
|
if c.Captcha.Height == 0 {
|
||||||
|
c.Captcha.Height = 40
|
||||||
|
}
|
||||||
|
if c.Captcha.TTLSec == 0 {
|
||||||
|
c.Captcha.TTLSec = 300
|
||||||
|
}
|
||||||
|
if c.Captcha.Login == nil {
|
||||||
|
c.Captcha.Login = &LoginCaptchaConfig{}
|
||||||
|
}
|
||||||
|
if c.Captcha.Login.ShowAfterFailures == 0 {
|
||||||
|
c.Captcha.Login.ShowAfterFailures = 3
|
||||||
|
}
|
||||||
|
if c.Captcha.Login.LockAfterFailures == 0 {
|
||||||
|
c.Captcha.Login.LockAfterFailures = 10
|
||||||
|
}
|
||||||
|
if c.Captcha.Login.LockDurationMin == 0 {
|
||||||
|
c.Captcha.Login.LockDurationMin = 30
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDatabase 获取数据库配置
|
// GetDatabase 获取数据库配置
|
||||||
@@ -603,6 +674,11 @@ func (c *Config) GetMCP() *MCPConfig {
|
|||||||
return c.MCP
|
return c.MCP
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetCaptcha 获取图形验证码配置
|
||||||
|
func (c *Config) GetCaptcha() *CaptchaConfig {
|
||||||
|
return c.Captcha
|
||||||
|
}
|
||||||
|
|
||||||
// GetDatabaseDSN 获取数据库连接字符串
|
// GetDatabaseDSN 获取数据库连接字符串
|
||||||
func (c *Config) GetDatabaseDSN() (string, error) {
|
func (c *Config) GetDatabaseDSN() (string, error) {
|
||||||
if c.Database == nil {
|
if c.Database == nil {
|
||||||
|
|||||||
@@ -90,6 +90,23 @@
|
|||||||
"byIP": true,
|
"byIP": true,
|
||||||
"byUserID": false
|
"byUserID": false
|
||||||
},
|
},
|
||||||
|
"captcha": {
|
||||||
|
"enabled": false,
|
||||||
|
"length": 4,
|
||||||
|
"width": 120,
|
||||||
|
"height": 40,
|
||||||
|
"ttlSec": 300,
|
||||||
|
"login": {
|
||||||
|
"showAfterFailures": 3,
|
||||||
|
"lockAfterFailures": 10,
|
||||||
|
"lockDurationMin": 30
|
||||||
|
},
|
||||||
|
"scenes": {
|
||||||
|
"register": { "mode": "always" },
|
||||||
|
"change_phone": { "mode": "always" },
|
||||||
|
"send_sms": { "mode": "always" }
|
||||||
|
}
|
||||||
|
},
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"callTimeoutSeconds": 60,
|
"callTimeoutSeconds": 60,
|
||||||
|
|||||||
62
examples/captcha_example.go
Normal file
62
examples/captcha_example.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
//go:build example
|
||||||
|
// +build example
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"git.toowon.com/jimmy/go-common/captcha"
|
||||||
|
"git.toowon.com/jimmy/go-common/config"
|
||||||
|
"git.toowon.com/jimmy/go-common/factory"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg, err := config.LoadFromFile("./config/example.json")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
app := factory.New(cfg)
|
||||||
|
cap, err := app.Captcha()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cap.Enabled() {
|
||||||
|
fmt.Println("captcha disabled — set captcha.enabled=true and configure redis to enable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 1. 注册场景:生成图形验证码
|
||||||
|
result, err := cap.Generate(ctx, captcha.SceneRegister)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("captcha id: %s\n", result.ID)
|
||||||
|
fmt.Printf("captcha image length: %d (base64)\n", len(result.ImageBase64))
|
||||||
|
|
||||||
|
// 2. 发短信前校验(业务侧在 handler 中调用;此处演示 API)
|
||||||
|
// 实际使用时 answer 来自用户输入
|
||||||
|
_ = cap.Verify(ctx, captcha.VerifyRequest{
|
||||||
|
Scene: captcha.SceneRegister,
|
||||||
|
ID: result.ID,
|
||||||
|
Answer: "user-input",
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. 登录防暴力
|
||||||
|
key := "user@example.com"
|
||||||
|
status, _ := cap.RecordLoginFailure(ctx, key)
|
||||||
|
fmt.Printf("login failures: %d, needCaptcha: %v, locked: %v\n",
|
||||||
|
status.FailCount, status.NeedCaptcha, status.Locked)
|
||||||
|
|
||||||
|
need, _ := cap.LoginNeedCaptcha(ctx, key)
|
||||||
|
fmt.Printf("login need captcha: %v\n", need)
|
||||||
|
|
||||||
|
// 登录成功后清除
|
||||||
|
_ = cap.ClearLoginFailures(ctx, key)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.toowon.com/jimmy/go-common/captcha"
|
||||||
"git.toowon.com/jimmy/go-common/config"
|
"git.toowon.com/jimmy/go-common/config"
|
||||||
"git.toowon.com/jimmy/go-common/email"
|
"git.toowon.com/jimmy/go-common/email"
|
||||||
"git.toowon.com/jimmy/go-common/excel"
|
"git.toowon.com/jimmy/go-common/excel"
|
||||||
@@ -42,6 +43,7 @@ type Factory struct {
|
|||||||
i18n *i18n.I18n
|
i18n *i18n.I18n
|
||||||
chain *middleware.Chain
|
chain *middleware.Chain
|
||||||
mcp mcp.Manager
|
mcp mcp.Manager
|
||||||
|
captcha captcha.Manager
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
@@ -437,6 +439,41 @@ func (f *Factory) MCP() (mcp.Manager, error) {
|
|||||||
return f.getMCP()
|
return f.getMCP()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *Factory) getCaptcha() (captcha.Manager, error) {
|
||||||
|
if f.captcha != nil {
|
||||||
|
return f.captcha, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg *config.CaptchaConfig
|
||||||
|
if f.cfg != nil {
|
||||||
|
cfg = f.cfg.Captcha
|
||||||
|
}
|
||||||
|
|
||||||
|
var rdb *redis.Client
|
||||||
|
if cfg != nil && cfg.Enabled {
|
||||||
|
var err error
|
||||||
|
rdb, err = f.getRedis()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("captcha enabled but redis unavailable: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
if f.captcha != nil {
|
||||||
|
return f.captcha, nil
|
||||||
|
}
|
||||||
|
f.captcha = captcha.NewManager(rdb, cfg)
|
||||||
|
return f.captcha, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Captcha 获取图形验证码子系统(Generate / Verify / 登录防暴力)。
|
||||||
|
// config.captcha 未配置或 enabled=false 时返回 Enabled()=false 的安全实例。
|
||||||
|
// enabled=true 时需配置 redis,否则返回 error。
|
||||||
|
func (f *Factory) Captcha() (captcha.Manager, error) {
|
||||||
|
return f.getCaptcha()
|
||||||
|
}
|
||||||
|
|
||||||
// Migrator 创建迁移器并加载指定目录下的 SQL 文件
|
// Migrator 创建迁移器并加载指定目录下的 SQL 文件
|
||||||
func (f *Factory) Migrator(migrationsDir string) (*migration.Migrator, error) {
|
func (f *Factory) Migrator(migrationsDir string) (*migration.Migrator, error) {
|
||||||
db, err := f.getDatabase()
|
db, err := f.getDatabase()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"git.toowon.com/jimmy/go-common/captcha"
|
||||||
"git.toowon.com/jimmy/go-common/email"
|
"git.toowon.com/jimmy/go-common/email"
|
||||||
"git.toowon.com/jimmy/go-common/i18n"
|
"git.toowon.com/jimmy/go-common/i18n"
|
||||||
"git.toowon.com/jimmy/go-common/logger"
|
"git.toowon.com/jimmy/go-common/logger"
|
||||||
@@ -26,6 +27,7 @@ const (
|
|||||||
ModuleSMS Module = "sms"
|
ModuleSMS Module = "sms"
|
||||||
ModuleI18n Module = "i18n"
|
ModuleI18n Module = "i18n"
|
||||||
ModuleMCP Module = "mcp"
|
ModuleMCP Module = "mcp"
|
||||||
|
ModuleCaptcha Module = "captcha"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MustInit 从配置文件初始化全局 Factory;失败则 panic(适合 main 启动)
|
// MustInit 从配置文件初始化全局 Factory;失败则 panic(适合 main 启动)
|
||||||
@@ -60,6 +62,8 @@ func (f *Factory) Warmup(modules ...Module) error {
|
|||||||
_, err = f.I18n()
|
_, err = f.I18n()
|
||||||
case ModuleMCP:
|
case ModuleMCP:
|
||||||
_, err = f.MCP()
|
_, err = f.MCP()
|
||||||
|
case ModuleCaptcha:
|
||||||
|
_, err = f.Captcha()
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unknown module: %s", m)
|
return fmt.Errorf("unknown module: %s", m)
|
||||||
}
|
}
|
||||||
@@ -198,3 +202,12 @@ func (f *Factory) MustMCP() mcp.Manager {
|
|||||||
}
|
}
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MustCaptcha 获取图形验证码子系统对象;失败则 panic(适合启动期注入)
|
||||||
|
func (f *Factory) MustCaptcha() captcha.Manager {
|
||||||
|
c, err := f.Captcha()
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package factory
|
package factory
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"git.toowon.com/jimmy/go-common/captcha"
|
||||||
"git.toowon.com/jimmy/go-common/email"
|
"git.toowon.com/jimmy/go-common/email"
|
||||||
"git.toowon.com/jimmy/go-common/i18n"
|
"git.toowon.com/jimmy/go-common/i18n"
|
||||||
"git.toowon.com/jimmy/go-common/logger"
|
"git.toowon.com/jimmy/go-common/logger"
|
||||||
@@ -74,3 +75,10 @@ func WithMCP(m mcp.Manager) Option {
|
|||||||
f.mcp = m
|
f.mcp = m
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithCaptcha 注入自定义图形验证码实现(如测试用的 fake 实现)
|
||||||
|
func WithCaptcha(c captcha.Manager) Option {
|
||||||
|
return func(f *Factory) {
|
||||||
|
f.captcha = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
5
go.mod
5
go.mod
@@ -3,9 +3,11 @@ module git.toowon.com/jimmy/go-common
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/alicebob/miniredis/v2 v2.38.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/minio/minio-go/v7 v7.0.97
|
github.com/minio/minio-go/v7 v7.0.97
|
||||||
github.com/modelcontextprotocol/go-sdk v1.6.1
|
github.com/modelcontextprotocol/go-sdk v1.6.1
|
||||||
|
github.com/mojocn/base64Captcha v1.3.6
|
||||||
github.com/redis/go-redis/v9 v9.17.1
|
github.com/redis/go-redis/v9 v9.17.1
|
||||||
github.com/xuri/excelize/v2 v2.10.0
|
github.com/xuri/excelize/v2 v2.10.0
|
||||||
golang.org/x/crypto v0.43.0
|
golang.org/x/crypto v0.43.0
|
||||||
@@ -21,6 +23,7 @@ require (
|
|||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/go-ini/ini v1.67.0 // indirect
|
github.com/go-ini/ini v1.67.0 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.7.1 // indirect
|
github.com/go-sql-driver/mysql v1.7.1 // indirect
|
||||||
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||||
github.com/google/jsonschema-go v0.4.3 // indirect
|
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
@@ -47,6 +50,8 @@ require (
|
|||||||
github.com/xuri/efp v0.0.1 // indirect
|
github.com/xuri/efp v0.0.1 // indirect
|
||||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
|
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||||
|
golang.org/x/image v0.25.0 // indirect
|
||||||
golang.org/x/net v0.46.0 // indirect
|
golang.org/x/net v0.46.0 // indirect
|
||||||
golang.org/x/oauth2 v0.35.0 // indirect
|
golang.org/x/oauth2 v0.35.0 // indirect
|
||||||
golang.org/x/sync v0.17.0 // indirect
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
|
|||||||
40
go.sum
40
go.sum
@@ -1,3 +1,5 @@
|
|||||||
|
github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw=
|
||||||
|
github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
@@ -19,6 +21,8 @@ github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrt
|
|||||||
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||||
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||||
@@ -58,6 +62,8 @@ github.com/minio/minio-go/v7 v7.0.97 h1:lqhREPyfgHTB/ciX8k2r8k0D93WaFqxbJX36UZq5
|
|||||||
github.com/minio/minio-go/v7 v7.0.97/go.mod h1:re5VXuo0pwEtoNLsNuSr0RrLfT/MBtohwdaSmPPSRSk=
|
github.com/minio/minio-go/v7 v7.0.97/go.mod h1:re5VXuo0pwEtoNLsNuSr0RrLfT/MBtohwdaSmPPSRSk=
|
||||||
github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
|
github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
|
||||||
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
|
github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
|
||||||
|
github.com/mojocn/base64Captcha v1.3.6 h1:gZEKu1nsKpttuIAQgWHO+4Mhhls8cAKyiV2Ew03H+Tw=
|
||||||
|
github.com/mojocn/base64Captcha v1.3.6/go.mod h1:i5CtHvm+oMbj1UzEPXaA8IH/xHFZ3DGY3Wh3dBpZ28E=
|
||||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
@@ -94,22 +100,56 @@ github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBL
|
|||||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||||
|
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||||
|
golang.org/x/image v0.13.0/go.mod h1:6mmbMOeV28HuMTgA6OSRkdXKYw/t5W9Uwn2Yv1r3Yxk=
|
||||||
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
|
||||||
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
|||||||
Reference in New Issue
Block a user