diff --git a/AGENTS.md b/AGENTS.md index 9c98f2b..6345eae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,13 +22,16 @@ ``` service.xpcool.com/ -├── api/ # HTTP 契约与 Swagger 元数据(api/user/v1, api/admin/v1) +├── api/ # HTTP 契约与 Swagger 元数据 +│ ├── user/v1/ # /api/v1 用户端 API(登录公开,其余走 UserAuth) +│ ├── admin/v1/ # /admin/v1 管理端 API(AdminAuth + X-Permission) +│ └── common/v1/ # /api/common/v1 公共接口(给前端调用,公开无鉴权) ├── common/ # 公共可复用模块(不依赖 internal,可独立抽取成库) │ └── tools/ # 工具模块,按子功能分包(见下文) ├── internal/ -│ ├── cmd/ # 启动引导 +│ ├── cmd/ # 启动引导(路由分组注册在此) │ ├── consts/ # 错误码与常量 -│ ├── controller/ # API→service 适配层(不写业务) +│ ├── controller/ # API→service 适配层(不写业务;含 common/ 公共接口实现) │ ├── service/ # 领域用例(业务逻辑直接写这里,不用 logic/) │ ├── dao/ # gf gen dao 生成,禁止手改 │ ├── model/ # entity/ do/ dto/ vo/(entity、do 生成,禁止手改) @@ -40,6 +43,12 @@ service.xpcool.com/ └── utility/ # (预留)跨切面辅助 ``` +## 公共接口(api/common/v1,前端调用) + +- 路由前缀 `/api/common/v1`,**公开、无鉴权**(登录接口外的通用能力),实现于 `internal/controller/common`。 +- 当前端点:`GET/POST /tools/*`(uuid、md5、random、time、ip),底层复用 `common/tools` Go 包。 +- 新增公共接口:在 `api/common/v1` 加 Req/Res(g.Meta 带 path/method),在 `internal/controller/common` 加方法,`internal/cmd/cmd.go` 的 `/api/common/v1` 分组会自动绑定。 + ## 公共工具模块 common/tools - 规则:**不依赖 internal/**,只薄封装 GoFrame 内置组件;新工具优先复用内置(gmd5/gaes/gdes/guid/grand/gtime/gconv/gstr/gfile...),避免重复造轮子。 @@ -84,6 +93,8 @@ go test ./... ## 注意事项 +- ⚠️ **gtime v2.10.2 格式化**:`Time.Format("Y-m-d H:i:s")` 是 PHP 风格;传 Go layout(`2006-01-02`)要用 `Time.Layout(...)`。工具包 `common/tools/timex` 已统一封装。 +- ⚠️ **gf v2.10.2 包名与旧版不同**:AES/DES 在 `crypto/gaes`、`crypto/gdes`(无 gcrypto);UUID 在 `util/guid`(无 guuid);无 gslicer(用标准库 slices)。 - 配置文件按 `GF_GCFG_FILE` 切换;`manifest/config/config.yaml` 不入库。 - 生产环境强密码:`JWT_SECRET`、数据库口令。 - 变更涉及 API 时同步更新 `api/` 下的 Swagger 元数据注释。 diff --git a/PROJECT_STRUCTURE.md b/PROJECT_STRUCTURE.md index cf942b9..e691236 100644 --- a/PROJECT_STRUCTURE.md +++ b/PROJECT_STRUCTURE.md @@ -4,14 +4,15 @@ service.xpcool.com/ ├── api/ # HTTP contracts and Swagger metadata │ ├── user/v1/ # /api/v1 - client-facing API -│ └── admin/v1/ # /admin/v1 - administration API +│ ├── admin/v1/ # /admin/v1 - administration API +│ └── common/v1/ # /api/common/v1 - public API for frontends (no auth) ├── common/ # public reusable module (no internal/ deps) │ └── tools/ # utility toolbox: md5, cryptox, uuid, random, │ # timex, convertx, strx, slicex, ip, filex ├── internal/ │ ├── cmd/ # application bootstrap and route isolation │ ├── consts/ # application error codes and constants -│ ├── controller/ # API-to-service adapters only +│ ├── controller/ # API-to-service adapters only (incl. common/ public API) │ ├── service/ # domain use cases and provider interfaces │ ├── dao/ # generated by gf gen dao; never hand edited │ ├── model/ diff --git a/api/common/v1/tools.go b/api/common/v1/tools.go new file mode 100644 index 0000000..9bd84b9 --- /dev/null +++ b/api/common/v1/tools.go @@ -0,0 +1,65 @@ +// Package v1 defines the public common API contracts served at /api/common/v1. +// +// These endpoints are open to any frontend client (mini/h5/app) and require +// no authentication. Implementations live in internal/controller/common and +// reuse the common/tools Go packages as their backend. +package v1 + +import "github.com/gogf/gf/v2/frame/g" + +// UUIDReq generates a unique ID. +type UUIDReq struct { + g.Meta `path:"/tools/uuid" method:"get" tags:"Common/Tools" summary:"Generate a unique ID"` + Short bool `json:"short"` // true: 8-char short code; false: 32-char ID +} + +// UUIDRes is the response of UUIDReq. +type UUIDRes struct { + UUID string `json:"uuid"` +} + +// MD5Req computes an MD5 digest. +type MD5Req struct { + g.Meta `path:"/tools/md5" method:"post" tags:"Common/Tools" summary:"Compute MD5 digest"` + Text string `json:"text" v:"required#text required"` +} + +// MD5Res is the response of MD5Req. +type MD5Res struct { + MD5 string `json:"md5"` +} + +// RandomReq generates a random string. +type RandomReq struct { + g.Meta `path:"/tools/random" method:"get" tags:"Common/Tools" summary:"Generate a random string"` + Length int `json:"length" d:"16" v:"min:1|max:128#length must be 1-128"` + Type string `json:"type" d:"alnum" v:"in:alnum,digits,letters#unsupported type"` +} + +// RandomRes is the response of RandomReq. +type RandomRes struct { + Value string `json:"value"` +} + +// TimeReq returns the current server time. +type TimeReq struct { + g.Meta `path:"/tools/time" method:"get" tags:"Common/Tools" summary:"Current server time"` +} + +// TimeRes is the response of TimeReq. +type TimeRes struct { + Timestamp int64 `json:"timestamp"` // unix seconds + DateTime string `json:"dateTime"` // 2006-01-02 15:04:05 + Date string `json:"date"` // 2006-01-02 +} + +// IPReq returns the caller's IP information. +type IPReq struct { + g.Meta `path:"/tools/ip" method:"get" tags:"Common/Tools" summary:"Client IP info"` +} + +// IPRes is the response of IPReq. +type IPRes struct { + IP string `json:"ip"` + Internal bool `json:"internal"` // whether the IP is a private/internal address +} diff --git a/common/tools/timex/timex.go b/common/tools/timex/timex.go index d21af18..a6ce097 100644 --- a/common/tools/timex/timex.go +++ b/common/tools/timex/timex.go @@ -17,14 +17,17 @@ func Now() *gtime.Time { return gtime.Now() } -// Format returns t formatted with the given layout. +// Format returns t formatted with the given Go layout. // When layout is empty, LayoutDateTime is used. +// +// Note: gtime v2.10.2's Format() takes PHP-style format ("Y-m-d H:i:s"), +// so this helper uses the Layout() method which accepts Go layouts. func Format(t *gtime.Time, layout ...string) string { ly := LayoutDateTime if len(layout) > 0 && layout[0] != "" { ly = layout[0] } - return t.Format(ly) + return t.Layout(ly) } // Timestamp returns the current Unix timestamp in seconds. @@ -34,10 +37,10 @@ func Timestamp() int64 { // StartOfDay returns the beginning (00:00:00) of the day containing t. func StartOfDay(t *gtime.Time) *gtime.Time { - return gtime.NewFromStr(t.Format(LayoutDate) + " 00:00:00") + return gtime.NewFromStr(t.Layout(LayoutDate) + " 00:00:00") } // EndOfDay returns the end (23:59:59) of the day containing t. func EndOfDay(t *gtime.Time) *gtime.Time { - return gtime.NewFromStr(t.Format(LayoutDate) + " 23:59:59") + return gtime.NewFromStr(t.Layout(LayoutDate) + " 23:59:59") } diff --git a/docs/change-log/2026-08-24.md b/docs/change-log/2026-08-24.md index 3a058f4..4cbdbf9 100644 --- a/docs/change-log/2026-08-24.md +++ b/docs/change-log/2026-08-24.md @@ -46,3 +46,40 @@ - 后续每次任务完成后:更新 `docs/change-log/`(当日文件追加)+ 提交 git,确保其他账号 clone 即恢复上下文。 - `convertx` 依赖 gconv 的"转换失败返回零值"行为(无法区分"0"与非法输入),需要严格转换的场景应在 service 层先校验。 - 本次变更尚未 git 提交,建议尽快 commit。 + +--- + +## 追加(17:30):方向纠正 — 「公共模块」实为公共接口 + +### 请求 + +用户澄清:要新增的是**给前端调用的公共 HTTP 接口**(此前误建成 Go 工具包),确认应规划到 `api/` 契约层。 + +### 变更 + +新增文件: + +- `api/common/v1/tools.go` — 公共接口契约:`UUIDReq/Res`、`MD5Req/Res`、`RandomReq/Res`、`TimeReq/Res`、`IPReq/Res`(g.Meta 路由元数据) +- `internal/controller/common/tools.go` — 控制器实现,薄适配层,复用 `common/tools/*` Go 包 + +修改文件: + +- `internal/cmd/cmd.go` — 新增公开分组 `s.Group("/api/common/v1", ...)`(Recover+CORS+HandlerResponse,**无鉴权**) +- `common/tools/timex/timex.go` — 修复:`Format` 内部改用 `Layout()` 方法(见决策) +- `internal/controller/common/tools.go` — `Time` 用 `now.Layout(...)` +- `AGENTS.md` — 目录树与新增「公共接口」小节、注意事项补 gtime 与包名差异 +- `PROJECT_STRUCTURE.md` — 目录树补充 `api/common/v1` + +验证:`go build ./...`、`go vet` 通过;**实际启动服务冒烟测试** 5 个端点全部返回正确(含修复后 time 格式化)。 + +### 决策与理由 + +- **路由前缀 `/api/common/v1`**:与 `/api/v1`(user)、`/admin/v1`(admin) 平级的独立公开前缀,天然不套登录鉴权;前端三个端(mini/h5/app)通用。 +- **契约层放 `api/common/v1`,实现放 `internal/controller/common`**:与 user/admin 完全同构;公共接口不需要 service 层(纯工具计算),controller 直接复用 `common/tools` 包,避免过度分层。 +- **🐛 gtime v2.10.2 大坑(已修复)**:`Time.Format()` 参数是 **PHP 风格**(`"Y-m-d H:i:s"`),传 Go layout(`"2006-01-02 15:04:05"`)会原样输出!Go layout 必须用 `Time.Layout()`。`common/tools/timex` 已统一封装为 `Layout` 语义,调用方直接 `timex.Format(t, layout...)` 即可。 +- **冒烟测试教训**:`go run` 会 spawn 子进程,`kill %1` 只杀包装进程,残留的 `main.exe` 会继续占用 8000 端口导致后续测试打到旧代码——杀进程需 `netstat -ano | grep :8000` 找 PID 后 `Stop-Process`。 + +### 待办与风险 + +- 本次追加变更同样未提交 git,与上文合并为一次 commit。 +- 公共接口已开放无鉴权能力(md5/random/uuid 等),后续新增接口时需评审是否应限流/加签名,避免被滥用。 diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index 18ded07..6fd3c15 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -8,6 +8,7 @@ import ( "github.com/gogf/gf/v2/os/gcmd" adminctl "service.xpcool.com/internal/controller/admin" + commonctl "service.xpcool.com/internal/controller/common" "service.xpcool.com/internal/controller/hello" userctl "service.xpcool.com/internal/controller/user" "service.xpcool.com/internal/library/jwt" @@ -33,6 +34,10 @@ var ( hello.NewV1(), ) }) + s.Group("/api/common/v1", func(group *ghttp.RouterGroup) { + group.Middleware(middleware.Recover, middleware.CORS, ghttp.MiddlewareHandlerResponse) + group.Bind(commonctl.New()) // Public common/tools API, no auth required. + }) s.Group("/api/v1", func(group *ghttp.RouterGroup) { group.Middleware(middleware.Recover, middleware.CORS, ghttp.MiddlewareHandlerResponse) group.Bind(userctl.New()) // Login and refresh routes are public. diff --git a/internal/controller/common/tools.go b/internal/controller/common/tools.go new file mode 100644 index 0000000..eb37454 --- /dev/null +++ b/internal/controller/common/tools.go @@ -0,0 +1,66 @@ +// Package common implements the public common API (/api/common/v1). +// These controllers are thin adapters over the common/tools Go packages and +// require no authentication. +package common + +import ( + "context" + + "github.com/gogf/gf/v2/frame/g" + + commonv1 "service.xpcool.com/api/common/v1" + "service.xpcool.com/common/tools/ip" + "service.xpcool.com/common/tools/md5" + "service.xpcool.com/common/tools/random" + "service.xpcool.com/common/tools/timex" + "service.xpcool.com/common/tools/uuid" +) + +// Controller implements the /api/common/v1 endpoints. +type Controller struct{} + +// New creates a common API controller. +func New() *Controller { return &Controller{} } + +// UUID generates a unique ID (32-char by default, 8-char when short=true). +func (c *Controller) UUID(ctx context.Context, req *commonv1.UUIDReq) (res *commonv1.UUIDRes, err error) { + if req.Short { + return &commonv1.UUIDRes{UUID: uuid.Short(8)}, nil + } + return &commonv1.UUIDRes{UUID: uuid.New()}, nil +} + +// MD5 computes the MD5 digest of the given text. +func (c *Controller) MD5(ctx context.Context, req *commonv1.MD5Req) (res *commonv1.MD5Res, err error) { + return &commonv1.MD5Res{MD5: md5.Md5Hex(req.Text)}, nil +} + +// Random generates a random string of the requested type and length. +func (c *Controller) Random(ctx context.Context, req *commonv1.RandomReq) (res *commonv1.RandomRes, err error) { + var value string + switch req.Type { + case "digits": + value = random.Digits(req.Length) + case "letters": + value = random.Letters(req.Length) + default: + value = random.String(req.Length) + } + return &commonv1.RandomRes{Value: value}, nil +} + +// Time returns the current server timestamp and formatted time. +func (c *Controller) Time(ctx context.Context, req *commonv1.TimeReq) (res *commonv1.TimeRes, err error) { + now := timex.Now() + return &commonv1.TimeRes{ + Timestamp: now.Timestamp(), + DateTime: now.Layout(timex.LayoutDateTime), + Date: now.Layout(timex.LayoutDate), + }, nil +} + +// IP returns the caller's IP and whether it is an internal address. +func (c *Controller) IP(ctx context.Context, req *commonv1.IPReq) (res *commonv1.IPRes, err error) { + clientIP := g.RequestFromCtx(ctx).GetClientIp() + return &commonv1.IPRes{IP: clientIP, Internal: ip.IsInternal(clientIP)}, nil +}