Compare commits
4
Commits
759bc3fc35
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c812cfb663 | ||
|
|
97b26635fa | ||
|
|
bef30be3fe | ||
|
|
cd0e13325f |
@@ -0,0 +1,33 @@
|
|||||||
|
# lyra 项目约定
|
||||||
|
|
||||||
|
## Redis 缓存键
|
||||||
|
|
||||||
|
- 格式:`<项目>:cache:<资源>:<查询>:v<版本>`,例如 `CategoryListCacheKey = "lyra:cache:category:list:v1"`。
|
||||||
|
- 常量命名:`<资源><查询>CacheKey`,集中定义在 `internal/pkg/constant/cacheKey.go`,业务代码引用常量,不硬编码键字符串。
|
||||||
|
- 分段含义:项目名(本项目为 `lyra`)隔离不同应用;`cache` 区分缓存与其他 Redis 用途;版本用于隔离结构不兼容的缓存数据。
|
||||||
|
- 参数化查询的键必须包含影响查询结果的条件参数;键中不得出现密码、令牌等敏感信息。
|
||||||
|
|
||||||
|
## 日志
|
||||||
|
|
||||||
|
- 请求链路(middleware、handler、service、model)需要日志时使用 `util.Logger(ctx)`,使用结构化属性记录上下文与错误。
|
||||||
|
- `util` 只从 context 读取 request ID;request ID 由 `RequestIDMiddleware` 注入,不由 `util` 生成。
|
||||||
|
- Gin 调用链向下传递 `c.Request.Context()`,不要继续传 `*gin.Context`。
|
||||||
|
- 禁止在请求处理中使用 `fmt.Printf` 或包级 `slog` 记录业务日志。
|
||||||
|
- 无请求上下文的进程启动、初始化日志可直接使用 `slog`,不强制统一为 `util.Logger`。
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
- 底层错误在合适的处理边界记录一次,避免每层重复记录。
|
||||||
|
- 公开响应只返回约定的业务码与对外文案,不暴露底层错误、SQL、连接信息等内部细节。
|
||||||
|
|
||||||
|
## 启动与组装
|
||||||
|
|
||||||
|
- `cmd/lyra/bootstrap.go` 负责数据库、Redis 等基础设施初始化;`cmd/lyra/dependency.go` 负责依赖组装;`cmd/lyra/main.go` 负责入口流程编排。
|
||||||
|
- `internal/config` 只定义配置结构与加载,不创建客户端;数据库/Redis 初始化留在 `cmd/lyra`,不另建占位包。
|
||||||
|
- 不全局创建数据库或 Redis 客户端,实例由入口创建并注入。
|
||||||
|
- 运行路径以 module 根为工作目录解析(例如 `etc/lyra.toml`)。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
- 默认自动测试应独立于真实 PostgreSQL、Redis 和真实端口。
|
||||||
|
- 需要真实服务的联调测试单独说明和执行,不混入默认测试。
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"lyra/internal/config"
|
||||||
|
"lyra/internal/middleware"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
|
||||||
|
// 注册 pgx 驱动,供 sqlx 以 "pgx" 驱动名使用。
|
||||||
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setLogger(cfg *config.Config) error {
|
||||||
|
var level slog.Level
|
||||||
|
switch cfg.Log.Level {
|
||||||
|
case "debug":
|
||||||
|
level = slog.LevelDebug
|
||||||
|
case "info":
|
||||||
|
level = slog.LevelInfo
|
||||||
|
case "warn":
|
||||||
|
level = slog.LevelWarn
|
||||||
|
case "error":
|
||||||
|
level = slog.LevelError
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("不支持的日志级别: %s", cfg.Log.Level)
|
||||||
|
}
|
||||||
|
logger := slog.New(
|
||||||
|
slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||||
|
Level: level,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
slog.SetDefault(logger)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHTTPRouter(mode string) (*gin.Engine, error) {
|
||||||
|
gin.SetMode(mode)
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(middleware.RequestIDMiddleware())
|
||||||
|
r.Use(middleware.AccessLogMiddleware())
|
||||||
|
r.Use(gin.Recovery())
|
||||||
|
|
||||||
|
// 当前没有反向代理,不信任客户端转发的 IP 头。
|
||||||
|
if err := r.SetTrustedProxies(nil); err != nil {
|
||||||
|
return nil, fmt.Errorf("设置代理信任失败: %w", err)
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDatabase(ctx context.Context, cfg config.DatabaseConfig) (*sqlx.DB, error) {
|
||||||
|
dataSourceName := fmt.Sprintf(
|
||||||
|
"host=%s port=%d user=%s dbname=%s password=%s sslmode=disable",
|
||||||
|
cfg.Host, cfg.Port, cfg.User, cfg.Name, cfg.Password,
|
||||||
|
)
|
||||||
|
db, err := sqlx.ConnectContext(ctx, cfg.Driver, dataSourceName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("连接 PostgreSQL 失败,请检查连接配置、认证和网络")
|
||||||
|
}
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
func newCache(cfg config.CacheConfig) *redis.Client {
|
||||||
|
return redis.NewClient(&redis.Options{
|
||||||
|
Addr: net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)),
|
||||||
|
DB: cfg.DB,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -26,9 +26,10 @@ func buildHandlers(dpd *Dependencies) router.Handlers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildDependencies(cfg *config.Config, db *sqlx.DB, cacheClient *redis.Client) *Dependencies {
|
func buildDependencies(cfg *config.Config, db *sqlx.DB, cacheClient *redis.Client) *Dependencies {
|
||||||
|
cache := cache.NewCache(cacheClient, time.Duration(cfg.Cache.Timeout)*time.Millisecond)
|
||||||
return &Dependencies{
|
return &Dependencies{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Cache: cache.NewCache(cacheClient, time.Duration(cfg.Cache.Timeout)*time.Millisecond),
|
Cache: cache,
|
||||||
CategoryModel: model.NewCategoryModel(db),
|
CategoryModel: model.NewCategoryModel(db, cache),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,57 +3,57 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"log/slog"
|
||||||
"log"
|
|
||||||
"lyra/internal/config"
|
"lyra/internal/config"
|
||||||
"lyra/internal/router"
|
"lyra/internal/router"
|
||||||
"net"
|
"net"
|
||||||
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/jmoiron/sqlx"
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
"github.com/redis/go-redis/v9"
|
|
||||||
|
|
||||||
// 注册 pgx 驱动,供 sqlx 以 "pgx" 驱动名使用。
|
|
||||||
_ "github.com/jackc/pgx/v5/stdlib"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// 加载 .env.local 文件,覆盖环境变量
|
|
||||||
if err := godotenv.Load(".env.local"); err != nil {
|
if err := godotenv.Load(".env.local"); err != nil {
|
||||||
log.Fatal("加载环境变量失败,请检查文件是否存在")
|
slog.Error("加载环境变量失败,请检查文件是否存在及格式是否正确")
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
// 加载配置(internal/config)
|
|
||||||
cfg, err := config.Load("etc/lyra.toml")
|
cfg, err := config.Load("etc/lyra.toml")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("加载配置失败,请检查配置文件和环境变量")
|
slog.Error("加载配置失败,请检查配置文件和环境变量")
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
gin.SetMode(cfg.App.Env)
|
if err := setLogger(cfg); err != nil {
|
||||||
ginEngine := gin.Default()
|
slog.Error("设置日志失败", "error", err)
|
||||||
//没有反向代理,在启动前关闭代理信任:
|
os.Exit(1)
|
||||||
if err := ginEngine.SetTrustedProxies(nil); err != nil {
|
}
|
||||||
log.Fatal("设置代理信任失败,请检查配置")
|
r, err := newHTTPRouter(cfg.App.Env)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("初始化 HTTP 路由失败", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
dataSourceName := fmt.Sprintf("host=%s port=%d user=%s dbname=%s password=%s sslmode=disable", cfg.Database.Host, cfg.Database.Port, cfg.Database.User, cfg.Database.Name, cfg.Database.Password)
|
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
db, err := sqlx.ConnectContext(ctx, cfg.Database.Driver, dataSourceName)
|
|
||||||
|
db, err := newDatabase(ctx, cfg.Database)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("连接 PostgreSQL 失败,请检查连接配置、认证和网络")
|
slog.Error("初始化数据库失败", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
cache := redis.NewClient(&redis.Options{
|
cache := newCache(cfg.Cache)
|
||||||
Addr: net.JoinHostPort(cfg.Cache.Host, strconv.Itoa(cfg.Cache.Port)),
|
|
||||||
DB: cfg.Cache.DB,
|
|
||||||
})
|
|
||||||
defer cache.Close()
|
defer cache.Close()
|
||||||
|
|
||||||
deps := buildDependencies(cfg, db, cache)
|
deps := buildDependencies(cfg, db, cache)
|
||||||
|
|
||||||
handlers := buildHandlers(deps)
|
handlers := buildHandlers(deps)
|
||||||
router.RegisterRoutes(ginEngine, handlers)
|
|
||||||
addr := fmt.Sprintf("%s:%d", cfg.HTTP.Host, cfg.HTTP.Port)
|
router.RegisterRoutes(r, handlers)
|
||||||
if err := ginEngine.Run(addr); err != nil {
|
|
||||||
log.Fatal("启动 HTTP 服务失败,请检查端口是否被占用")
|
if err := r.Run(net.JoinHostPort(cfg.HTTP.Host, strconv.Itoa(cfg.HTTP.Port))); err != nil {
|
||||||
|
slog.Error("启动 HTTP 服务失败", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
name = "lyra"
|
name = "lyra"
|
||||||
env = "debug" #gin支持 release | test | debug 3种mode,如果需要自定义,需要映射到这3种mode中去,gin的默认模式是debug模式
|
env = "debug" #gin支持 release | test | debug 3种mode,如果需要自定义,需要映射到这3种mode中去,gin的默认模式是debug模式
|
||||||
|
|
||||||
|
[log]
|
||||||
|
level = "info" #日志级别,支持 debug | info | warn | error |
|
||||||
|
|
||||||
[http]
|
[http]
|
||||||
host = "0.0.0.0"
|
host = "0.0.0.0"
|
||||||
port = 7777
|
port = 7777
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ go 1.27.1
|
|||||||
require (
|
require (
|
||||||
github.com/Masterminds/squirrel v1.5.4
|
github.com/Masterminds/squirrel v1.5.4
|
||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jackc/pgx/v5 v5.11.0
|
github.com/jackc/pgx/v5 v5.11.0
|
||||||
github.com/jmoiron/sqlx v1.4.0
|
github.com/jmoiron/sqlx v1.4.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
|
|||||||
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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
|||||||
+17
@@ -2,6 +2,7 @@ package cache
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
"encoding/json/v2"
|
"encoding/json/v2"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -66,3 +67,19 @@ func (c *Cache) Set[T any](ctx context.Context, entry Entry[T]) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func GenCacheKey(table, sql string, args ...any) (key string, err error) {
|
||||||
|
prefix := fmt.Sprintf("lyra:cache:%s:", table)
|
||||||
|
h := md5.New()
|
||||||
|
// 写入查询语句
|
||||||
|
h.Write([]byte(sql))
|
||||||
|
// 序列化并写入每个参数
|
||||||
|
for _, arg := range args {
|
||||||
|
jsonData, err := json.Marshal(arg)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("序列化参数失败:%w", err)
|
||||||
|
}
|
||||||
|
h.Write(jsonData)
|
||||||
|
}
|
||||||
|
return prefix + fmt.Sprintf("%x", h.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
App AppConfig `mapstructure:"app"`
|
App AppConfig `mapstructure:"app"`
|
||||||
|
Log LogConfig `mapstructure:"log"`
|
||||||
HTTP HTTPConfig `mapstructure:"http"`
|
HTTP HTTPConfig `mapstructure:"http"`
|
||||||
Database DatabaseConfig `mapstructure:"database"`
|
Database DatabaseConfig `mapstructure:"database"`
|
||||||
Cache CacheConfig `mapstructure:"cache"`
|
Cache CacheConfig `mapstructure:"cache"`
|
||||||
@@ -18,6 +19,9 @@ type AppConfig struct {
|
|||||||
Env string `mapstructure:"env"`
|
Env string `mapstructure:"env"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LogConfig struct {
|
||||||
|
Level string `mapstructure:"level"`
|
||||||
|
}
|
||||||
type HTTPConfig struct {
|
type HTTPConfig struct {
|
||||||
Host string `mapstructure:"host"`
|
Host string `mapstructure:"host"`
|
||||||
Port int `mapstructure:"port"`
|
Port int `mapstructure:"port"`
|
||||||
|
|||||||
@@ -2,5 +2,6 @@
|
|||||||
|
|
||||||
- 负责参数绑定、API DTO 与内部模型的相互转换、响应输出。
|
- 负责参数绑定、API DTO 与内部模型的相互转换、响应输出。
|
||||||
- 引用 internal/api 的 DTO,使用 Gin 完成绑定与响应。
|
- 引用 internal/api 的 DTO,使用 Gin 完成绑定与响应。
|
||||||
|
- 响应统一使用 `httpx.OK` / `httpx.Fail`,遵循 `internal/httpx/AGENTS.md` 的状态码、业务码与错误隔离规则。
|
||||||
- 调用 service 时传递标准 context.Context,不下传 gin.Context。
|
- 调用 service 时传递标准 context.Context,不下传 gin.Context。
|
||||||
- 可按明确业务分包(如 `polaris/`);服务级简单接口保留在根包平铺,不预建空业务包。
|
- 可按明确业务分包(如 `polaris/`);服务级简单接口保留在根包平铺,不预建空业务包。
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"lyra/internal/api"
|
"lyra/internal/api"
|
||||||
"lyra/internal/cache"
|
"lyra/internal/cache"
|
||||||
"lyra/internal/httpx"
|
"lyra/internal/httpx"
|
||||||
"lyra/internal/model"
|
"lyra/internal/model"
|
||||||
|
"lyra/internal/util"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"time"
|
"time"
|
||||||
@@ -35,21 +35,21 @@ func (h *TestHandler) Hello() gin.HandlerFunc {
|
|||||||
Value: categories,
|
Value: categories,
|
||||||
TTL: 5 * time.Minute,
|
TTL: 5 * time.Minute,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
slog.ErrorContext(ctx.Request.Context(), "缓存分类列表失败")
|
util.Logger(ctx.Request.Context()).Error("设置缓存分类列表失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
testResult, _, err := h.cache.Get[[]*model.Category](ctx.Request.Context(), "test:categories")
|
testResult, _, err := h.cache.Get[[]*model.Category](ctx.Request.Context(), "test:categories")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.ErrorContext(ctx.Request.Context(), "获取缓存分类列表失败")
|
util.Logger(ctx.Request.Context()).Error("获取缓存分类列表失败")
|
||||||
}
|
}
|
||||||
if len(testResult) == 0 {
|
if len(testResult) == 0 {
|
||||||
slog.ErrorContext(ctx.Request.Context(), "缓存分类列表为空")
|
util.Logger(ctx.Request.Context()).Error("缓存分类列表为空")
|
||||||
} else {
|
} else {
|
||||||
for _, v := range testResult {
|
for _, v := range testResult {
|
||||||
fmt.Printf("%+v\n", v)
|
fmt.Printf("%+v\n", v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
util.Logger(ctx.Request.Context()).Info("success")
|
||||||
httpx.OK(ctx, toCategoryResponses(categories))
|
httpx.OK(ctx, toCategoryResponses(categories))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
# httpx 包
|
# httpx 包
|
||||||
|
|
||||||
- 提供公共 HTTP 响应辅助。
|
- 提供公共 HTTP 响应辅助,使用现有 `OK` / `Fail` 输出统一响应。
|
||||||
- 出现实际复用需求时再补充具体辅助函数,不预先设计无使用场景的抽象。
|
- `Fail` 区分 HTTP 状态码与业务错误码;公开 message 为空时回退到业务码的默认文案。
|
||||||
|
- 不向公开响应写入内部错误或调试信息,避免 debug 泄漏。
|
||||||
|
- 响应结构来自 internal/api,不在这里重复定义 DTO。
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"lyra/internal/util"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func AccessLogMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
|
util.Logger(ctx).InfoContext(
|
||||||
|
ctx,
|
||||||
|
"http request",
|
||||||
|
"method", c.Request.Method,
|
||||||
|
"path", c.Request.URL.Path,
|
||||||
|
"status", c.Writer.Status(),
|
||||||
|
"latency_ms", time.Since(start).Milliseconds(),
|
||||||
|
"client_ip", c.ClientIP(),
|
||||||
|
"user_agent", c.Request.UserAgent(),
|
||||||
|
"response_size", c.Writer.Size(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"lyra/internal/pkg/constant"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RequestIDMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
requestID := c.GetHeader("X-Request-ID")
|
||||||
|
|
||||||
|
if requestID == "" {
|
||||||
|
requestID = uuid.NewString()
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("X-Request-ID", requestID)
|
||||||
|
|
||||||
|
ctx := context.WithValue(c.Request.Context(), constant.RequestIDKey, requestID)
|
||||||
|
|
||||||
|
c.Request = c.Request.WithContext(ctx)
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,5 +2,9 @@
|
|||||||
|
|
||||||
- 承载数据结构定义与数据访问:使用 Squirrel/SQL 构造查询、sqlx 执行与映射结果,驱动为 pgx stdlib。
|
- 承载数据结构定义与数据访问:使用 Squirrel/SQL 构造查询、sqlx 执行与映射结果,驱动为 pgx stdlib。
|
||||||
- SQL 与查询逻辑显式写在这一层,不使用 ORM 自动持久化。
|
- SQL 与查询逻辑显式写在这一层,不使用 ORM 自动持久化。
|
||||||
- 支持在 service 传入的同一事务内执行操作。
|
- 事务边界由 service 决定;需要同一事务时由 service 传入事务执行。这是设计目标,当前没有现成的事务接口封装。
|
||||||
|
- 查询缓存采用 cache-aside:先读缓存,命中直接返回(空列表也可命中);未命中或缓存读取失败时回源数据库。
|
||||||
|
- 仅数据库查询成功后回填缓存;数据库失败不回填。缓存读写故障只记录日志并降级,不改变数据库查询结果。
|
||||||
|
- 缓存键使用 `internal/pkg/constant` 中的常量,遵循根 `AGENTS.md` 的 Redis 缓存键约定。
|
||||||
|
- 未来写路径应在事务提交后使相关缓存失效;当前 `cache` 包没有删除接口,失效策略属于后续设计约束。
|
||||||
- 使用标准 context.Context,不依赖 Gin,也不依赖 internal/api 的 DTO。
|
- 使用标准 context.Context,不依赖 Gin,也不依赖 internal/api 的 DTO。
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ package model
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"lyra/internal/cache"
|
||||||
|
"lyra/internal/pkg/constant"
|
||||||
|
"lyra/internal/pkg/query"
|
||||||
|
"lyra/internal/util"
|
||||||
|
|
||||||
sq "github.com/Masterminds/squirrel"
|
sq "github.com/Masterminds/squirrel"
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
@@ -13,19 +19,35 @@ type Category struct {
|
|||||||
Slug string `db:"slug"`
|
Slug string `db:"slug"`
|
||||||
Title string `db:"title"`
|
Title string `db:"title"`
|
||||||
Sort int `db:"sort"`
|
Sort int `db:"sort"`
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CategoryModel interface {
|
type CategoryModel interface {
|
||||||
FindAll(ctx context.Context) ([]*Category, error)
|
FindAll(ctx context.Context) ([]*Category, error)
|
||||||
|
ListWithOptions(ctx context.Context, queryOpt query.Option, modelOpts ...ModelOption) (resp []*Link, err error)
|
||||||
}
|
}
|
||||||
type defaultCategoryModel struct {
|
type defaultCategoryModel struct {
|
||||||
db *sqlx.DB
|
db *sqlx.DB
|
||||||
|
table string
|
||||||
|
cache *cache.Cache
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCategoryModel(db *sqlx.DB) CategoryModel {
|
func NewCategoryModel(db *sqlx.DB, cache *cache.Cache) CategoryModel {
|
||||||
return &defaultCategoryModel{db: db}
|
return &defaultCategoryModel{db: db, table: "categories", cache: cache}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const categoryListCacheTTL = 5 * time.Minute
|
||||||
|
|
||||||
func (m *defaultCategoryModel) FindAll(ctx context.Context) ([]*Category, error) {
|
func (m *defaultCategoryModel) FindAll(ctx context.Context) ([]*Category, error) {
|
||||||
|
cached, hit, err := m.cache.Get[[]*Category](ctx, constant.CategoryListCacheKey)
|
||||||
|
if err != nil {
|
||||||
|
util.Logger(ctx).Error("读取分类列表缓存失败", "key", constant.CategoryListCacheKey, "error", err)
|
||||||
|
}
|
||||||
|
if hit {
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
|
||||||
query, args, err := sq.
|
query, args, err := sq.
|
||||||
Select("id", "slug", "title", "sort").
|
Select("id", "slug", "title", "sort").
|
||||||
From("public.categories").
|
From("public.categories").
|
||||||
@@ -42,5 +64,69 @@ func (m *defaultCategoryModel) FindAll(ctx context.Context) ([]*Category, error)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("数据库查询出错: %w", err)
|
return nil, fmt.Errorf("数据库查询出错: %w", err)
|
||||||
}
|
}
|
||||||
return rows, err
|
if err := m.cache.Set(ctx, cache.Entry[[]*Category]{
|
||||||
|
Key: constant.CategoryListCacheKey,
|
||||||
|
Value: rows,
|
||||||
|
TTL: categoryListCacheTTL,
|
||||||
|
}); err != nil {
|
||||||
|
util.Logger(ctx).Error("写入分类列表缓存失败", "key", constant.CategoryListCacheKey, "error", err)
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModelOption func(*sq.SelectBuilder)
|
||||||
|
|
||||||
|
func (m *defaultCategoryModel) ListWithOptions(ctx context.Context, queryOpt query.Option, modelOpts ...ModelOption) (resp []*Link, err error) {
|
||||||
|
// 1.先构建查询语句(还没有deleted字段)
|
||||||
|
// 2.根据queryOpt的参数,设置分页、排序等
|
||||||
|
sqBuilder := sq.Select("id", "slug", "title", "sort").
|
||||||
|
From("categories").Limit(uint64(queryOpt.PageSize)).
|
||||||
|
Offset(uint64(queryOpt.Page)).
|
||||||
|
PlaceholderFormat(sq.Dollar)
|
||||||
|
for _, opt := range modelOpts {
|
||||||
|
opt(new(sqBuilder))
|
||||||
|
}
|
||||||
|
|
||||||
|
query, args, err := sqBuilder.ToSql()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("squirrel 构造SQL出错: %w", err)
|
||||||
|
}
|
||||||
|
cacheKey, err := cache.GenCacheKey(m.table, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result, hit, err := m.cache.Get[[]*Link](ctx, cacheKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if hit {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
if queryOpt.WithCacheWrite {
|
||||||
|
if err := m.cache.Set(ctx, cache.Entry[[]*Link]{
|
||||||
|
Key: cacheKey,
|
||||||
|
Value: result,
|
||||||
|
TTL: queryOpt.CacheTTL,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithCategorySlug(slug string) ModelOption {
|
||||||
|
return func(builder *sq.SelectBuilder) {
|
||||||
|
*builder = builder.Where(sq.Eq{"categories.slug": slug})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func WithCategoryTitle(title string) ModelOption {
|
||||||
|
return func(builder *sq.SelectBuilder) {
|
||||||
|
*builder = builder.Where(sq.Eq{"categories.title": title})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithCategorySort(sort int) ModelOption {
|
||||||
|
return func(builder *sq.SelectBuilder) {
|
||||||
|
*builder = builder.Where(sq.Eq{"categories.sort": sort})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"lyra/internal/cache"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jmoiron/sqlx"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Link struct {
|
||||||
|
ID int64 `db:"id"`
|
||||||
|
CategoryID int64 `db:"category_id"`
|
||||||
|
Title string `db:"title"`
|
||||||
|
Description string `db:"description"`
|
||||||
|
URL string `db:"url"`
|
||||||
|
Icon *string `db:"icon"`
|
||||||
|
Keywords []string `db:"keywords"`
|
||||||
|
Sort int `db:"sort"`
|
||||||
|
Pinned bool `db:"pinned"`
|
||||||
|
PinnedSort *int `db:"pinned_sort"`
|
||||||
|
CreatedAt time.Time `db:"created_at"`
|
||||||
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LinkModel interface {
|
||||||
|
}
|
||||||
|
type defaultLinkModel struct {
|
||||||
|
db *sqlx.DB
|
||||||
|
cache *cache.Cache
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLinkModel(db *sqlx.DB, cache *cache.Cache) CategoryModel {
|
||||||
|
return &defaultCategoryModel{db: db, cache: cache}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package constant
|
||||||
|
|
||||||
|
const CategoryListCacheKey = "lyra:cache:category:list:v1"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package constant
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const RequestIDKey contextKey = "request_id"
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package query
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultPage = 1
|
||||||
|
defaultPageSize = 10
|
||||||
|
defaultIncludeDeleted = false
|
||||||
|
defaultWithCacheWrite = false
|
||||||
|
defaultCacheTTL = 5 * time.Minute
|
||||||
|
defaultNotFoundCacheTTL = 1 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
type Option struct {
|
||||||
|
Page int
|
||||||
|
PageSize int
|
||||||
|
IncludeDeleted bool
|
||||||
|
WithCacheWrite bool
|
||||||
|
CacheTTL time.Duration
|
||||||
|
NotFoundCacheTTL time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type OptionFunc func(*Option)
|
||||||
|
|
||||||
|
func NewOption(opts ...OptionFunc) *Option {
|
||||||
|
queryOpntion := &Option{
|
||||||
|
Page: defaultPage,
|
||||||
|
PageSize: defaultPageSize,
|
||||||
|
IncludeDeleted: defaultIncludeDeleted,
|
||||||
|
WithCacheWrite: defaultWithCacheWrite,
|
||||||
|
CacheTTL: defaultCacheTTL,
|
||||||
|
NotFoundCacheTTL: defaultNotFoundCacheTTL,
|
||||||
|
}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(queryOpntion)
|
||||||
|
}
|
||||||
|
return queryOpntion
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithPage(page int) OptionFunc {
|
||||||
|
return func(o *Option) {
|
||||||
|
if page > 0 {
|
||||||
|
o.Page = page
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithPageSize(pageSize int) OptionFunc {
|
||||||
|
return func(o *Option) {
|
||||||
|
if pageSize > 0 {
|
||||||
|
o.PageSize = pageSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithIncludeDeleted(includeDeleted bool) OptionFunc {
|
||||||
|
return func(o *Option) {
|
||||||
|
o.IncludeDeleted = includeDeleted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithCacheWrite(withCacheWrite bool) OptionFunc {
|
||||||
|
return func(o *Option) {
|
||||||
|
o.WithCacheWrite = withCacheWrite
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithCacheTTL(cacheTTL time.Duration) OptionFunc {
|
||||||
|
return func(o *Option) {
|
||||||
|
if cacheTTL >= 0 {
|
||||||
|
o.CacheTTL = cacheTTL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithNotFoundCacheTTL(notFoundCacheTTL time.Duration) OptionFunc {
|
||||||
|
return func(o *Option) {
|
||||||
|
if notFoundCacheTTL >= 0 {
|
||||||
|
o.NotFoundCacheTTL = notFoundCacheTTL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# postgres 包
|
|
||||||
|
|
||||||
- 负责建立与管理 PostgreSQL 连接,使用 pgx stdlib 驱动。
|
|
||||||
- 连接参数来自 internal/config,连接供 model 层通过 sqlx 执行查询使用。
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
# service 包
|
# service 包
|
||||||
|
|
||||||
- 承载业务规则与事务协调,通过 model 完成数据访问。
|
- 当前目录为占位,尚无 Go 实现;新增代码前先确认职责边界。
|
||||||
- 需要原子性时,在同一事务内协调多次写入,不把各自独立提交的多次写入当成原子业务。
|
- 预期职责:业务规则与事务协调,通过 model 完成数据访问。
|
||||||
|
- 需要原子性时,在同一事务内协调多次写入;当前没有现成事务接口,需在实现时设计,不把各自独立提交的多次写入当成原子业务。
|
||||||
- 使用标准 context.Context,不依赖 Gin,也不依赖 internal/api 的 DTO。
|
- 使用标准 context.Context,不依赖 Gin,也不依赖 internal/api 的 DTO。
|
||||||
- 可按明确业务分包(如 `polaris/`);服务级简单接口保留在根包平铺,不预建空业务包。
|
- 可按明确业务分包(如 `polaris/`);服务级简单接口保留在根包平铺,不预建空业务包。
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"lyra/internal/pkg/constant"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Logger(ctx context.Context) *slog.Logger {
|
||||||
|
logger := slog.Default()
|
||||||
|
|
||||||
|
if requestID, ok := ctx.Value(constant.RequestIDKey).(string); ok {
|
||||||
|
logger = logger.With(string(constant.RequestIDKey), requestID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return logger
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user