chore: initial commit

This commit is contained in:
2026-07-22 17:50:33 +08:00
parent 82936a8987
commit ee9f50b859
107 changed files with 8346 additions and 0 deletions

31
internal/pkg/cache/cache.go vendored Normal file
View File

@@ -0,0 +1,31 @@
package cache
import (
"server/internal/db/sqlc"
"github.com/maypok86/otter/v2"
)
type Caches struct {
SysUserApisCache *otter.Cache[int32, []db.GetSysUserApisRow]
}
func NewCaches() *Caches {
sysUserApisCache := otter.Must(&otter.Options[int32, []db.GetSysUserApisRow]{
MaximumSize: 1_000,
})
return &Caches{
SysUserApisCache: sysUserApisCache,
}
}
// ClearSysUserCache 清理单个用户缓存
func (c *Caches) ClearSysUserCache(userID int32) {
c.SysUserApisCache.Invalidate(userID)
}
// ClearAllSysUserCache 清理所有用户缓存
func (c *Caches) ClearAllSysUserCache() {
c.SysUserApisCache.InvalidateAll()
}

View File

@@ -0,0 +1,49 @@
package dberr
import (
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
const (
SysUserAccountKey = "sys_users_account_key"
SysRoleCodeKey = "sys_roles_code_key"
PostSlugKey = "posts_slug_key"
SysPermissionsCodeKey = "sys_permissions_code_key"
SysApisMethodPathKey = "sys_apis_method_path_key"
CategoryCodeKey = "categories_code_key"
)
func MapRowsAffected(rows int64, err error, notFoundErr error) error {
if err != nil {
return err
}
if rows == 0 {
return notFoundErr
}
return nil
}
func MapNoRows(err error, notFoundErr error) error {
if errors.Is(err, pgx.ErrNoRows) {
return notFoundErr
}
return err
}
func MapUniqueViolation(err error, constraint string, uniqueErr error) error {
if err == nil {
return nil
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) &&
pgErr.Code == "23505" &&
pgErr.ConstraintName == constraint {
return uniqueErr
}
return err
}

48
internal/pkg/errs/errs.go Normal file
View File

@@ -0,0 +1,48 @@
package errs
import "net/http"
type AppError struct {
HTTPCode int
Msg string
}
func (e *AppError) Error() string {
return e.Msg
}
func New(httpCode int, msg string) *AppError {
return &AppError{
HTTPCode: httpCode,
Msg: msg,
}
}
var (
ErrInvalidCredentials = New(http.StatusUnauthorized, "用户名或密码错误")
ErrUnauthenticated = New(http.StatusUnauthorized, "用户未登录或登录已失效")
ErrUnauthorized = New(http.StatusUnauthorized, "认证失败:无法获取当前用户信息")
ErrInvalidToken = New(http.StatusUnauthorized, "登录凭证无效")
ErrInvalidTokenClaims = New(http.StatusUnauthorized, "登录凭证解析失败")
ErrInvalidRefreshToken = New(http.StatusBadRequest, "invalid_grant")
ErrExpiredRefreshToken = New(http.StatusBadRequest, "invalid_grant")
ErrUserNotFound = New(http.StatusNotFound, "用户数据不存在")
ErrCategoryNotFound = New(http.StatusNotFound, "分类数据不存在")
ErrSysApiNotFound = New(http.StatusNotFound, "接口数据不存在")
ErrPostNotFound = New(http.StatusNotFound, "文章数据不存在")
ErrSysMenuNotFound = New(http.StatusNotFound, "菜单数据不存在")
ErrSysRoleNotFound = New(http.StatusNotFound, "角色数据不存在")
ErrCannotDeleteSuperAdmin = New(http.StatusForbidden, "超级管理员账号无法被删除")
ErrSlugRequired = New(http.StatusBadRequest, "slug不能为空")
ErrIDRequired = New(http.StatusBadRequest, "id不能为空")
ErrPermissionDenied = New(http.StatusForbidden, "没有权限访问该资源")
ErrInvalidID = New(http.StatusBadRequest, "id非法请检查传入的id")
ErrEmptyBody = New(http.StatusBadRequest, "请求内容(body)不能为空")
ErrInvalidJSON = New(http.StatusBadRequest, "请求数据格式错误")
ErrAccountAlreadyExists = New(http.StatusBadRequest, "账号已存在")
ErrCodeAlreadyExists = New(http.StatusBadRequest, "角色编码已存在")
ErrSlugAlreadyExists = New(http.StatusBadRequest, "slug已存在")
ErrPermissionCodeAlreadyExists = New(http.StatusBadRequest, "权限编码已存在")
ErrCategoryCodeAlreadyExists = New(http.StatusBadRequest, "分类编码已存在")
ErrSysApiMethodPathAlreadyExists = New(http.StatusBadRequest, "接口方法(method)路径(path)已存在")
)

View File

@@ -0,0 +1,90 @@
package httputil
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"server/internal/config"
"server/internal/model/common"
"server/internal/pkg/errs"
"server/internal/pkg/validator"
"strconv"
"github.com/go-chi/chi/v5"
)
// Pagination 分页请求参数
func Pagination(r *http.Request) *common.Pagination {
q := r.URL.Query()
// page 默认 1
page := int32(1)
if p := q.Get("page"); p != "" {
if v, err := strconv.Atoi(p); err == nil && v >= 1 {
page = int32(v)
}
}
// pageSize 默认 10
pageSize := int32(10)
if ps := q.Get("page_size"); ps != "" {
if v, err := strconv.Atoi(ps); err == nil && v >= 1 && v <= 100 {
pageSize = int32(v)
}
}
return &common.Pagination{
Page: page,
PageSize: pageSize,
}
}
func URLParamInt32(r *http.Request, key string) (int32, error) {
v := chi.URLParam(r, key)
if v == "" {
return 0, errs.ErrIDRequired
}
n, err := strconv.ParseInt(v, 10, 32)
if err != nil {
return 0, errs.ErrInvalidID
}
return int32(n), nil
}
func BindJson(r *http.Request, dest any) error {
if err := json.NewDecoder(r.Body).Decode(dest); err != nil {
if errors.Is(err, io.EOF) {
return errs.ErrEmptyBody
}
return errs.ErrInvalidJSON
}
if err := validator.Struct(dest); err != nil {
return err
}
return nil
}
func BuildFileUrl(path *string) string {
if path == nil || *path == "" {
return ""
}
baseURL := config.GetString("file.base_url")
if baseURL == "" {
return ""
}
result, err := url.JoinPath(baseURL, *path)
if err != nil {
return ""
}
return result
}

View File

@@ -0,0 +1,65 @@
package httputil
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"server/internal/model/common"
"server/internal/pkg/errs"
validatorI18 "server/internal/pkg/validator"
"strings"
"github.com/go-playground/validator/v10"
)
func respondWithJSON(w http.ResponseWriter, code int, data any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
if err := json.NewEncoder(w).Encode(data); err != nil {
slog.Error("respondWithJSON: failed to encode response", "error", err)
}
}
func Ok(w http.ResponseWriter, data ...any) {
resp := common.Response{
Message: "ok",
}
if len(data) > 0 && data[0] != nil {
resp.Data = data[0]
}
respondWithJSON(w, http.StatusOK, resp)
}
func OkWithPage(w http.ResponseWriter, pageResp *common.PageResponse) {
pageResp.Message = "ok"
respondWithJSON(w, http.StatusOK, pageResp)
}
// Fail 响应失败
func Fail(w http.ResponseWriter, err error) {
var msg string
// 默认code 500
httpStatusCode := http.StatusInternalServerError
var validationErrs validator.ValidationErrors
var appErr *errs.AppError
if errors.As(err, &validationErrs) {
httpStatusCode = http.StatusBadRequest
msgs := validatorI18.Translate(err)
msg = strings.Join(msgs, "; ")
} else if errors.As(err, &appErr) {
httpStatusCode = appErr.HTTPCode
msg = appErr.Msg
} else {
msg = err.Error()
}
resp := common.Response{
Message: msg,
}
respondWithJSON(w, httpStatusCode, resp)
}

View File

@@ -0,0 +1,76 @@
package logger
import (
"io"
"log/slog"
"os"
"strings"
"server/internal/config"
"gopkg.in/natefinch/lumberjack.v2"
)
// New 根据配置创建 slog.Logger并将其设置为默认 logger。
// 日志会写入文件(自动按大小/时间滚动),并可选同时输出到控制台。
func New(cfg *config.Config) *slog.Logger {
logCfg := cfg.Log
// 文件滚动写入
fileWriter := &lumberjack.Logger{
Filename: defaultStr(logCfg.Filename, "logs/app.log"),
MaxSize: defaultInt(logCfg.MaxSize, 100), // MB
MaxBackups: defaultInt(logCfg.MaxBackups, 30),
MaxAge: defaultInt(logCfg.MaxAge, 30), // days
Compress: logCfg.Compress,
}
var writer io.Writer = fileWriter
if logCfg.Console {
writer = io.MultiWriter(os.Stdout, fileWriter)
}
opts := &slog.HandlerOptions{
Level: parseLevel(logCfg.Level),
AddSource: false,
}
// 生产环境用 JSON便于日志采集/检索;开发环境用文本更易读
var handler slog.Handler
if config.IsDev() {
handler = slog.NewTextHandler(writer, opts)
} else {
handler = slog.NewJSONHandler(writer, opts)
}
l := slog.New(handler)
slog.SetDefault(l) // 让全局 slog.Info/Error 也走同一套配置
return l
}
func parseLevel(level string) slog.Level {
switch strings.ToLower(level) {
case "debug":
return slog.LevelDebug
case "warn", "warning":
return slog.LevelWarn
case "error":
return slog.LevelError
default:
return slog.LevelInfo
}
}
func defaultStr(v, def string) string {
if v == "" {
return def
}
return v
}
func defaultInt(v, def int) int {
if v <= 0 {
return def
}
return v
}

View File

@@ -0,0 +1,28 @@
package validator
import "encoding/json"
// NullInt32 Valid(false) + Set(true) = null | Valid(false) + Set(false) = nil
type NullInt32 struct {
Value int32
Valid bool
Set bool
}
func (i *NullInt32) UnmarshalJSON(data []byte) error {
i.Set = true
if string(data) == "null" {
i.Valid = false
return nil
}
var temp int32
if err := json.Unmarshal(data, &temp); err != nil {
return err
}
i.Value = temp
i.Valid = true
return nil
}

View File

@@ -0,0 +1,66 @@
package validator
import (
"errors"
"reflect"
"github.com/go-playground/locales/zh"
ut "github.com/go-playground/universal-translator"
"github.com/go-playground/validator/v10"
zhtranslations "github.com/go-playground/validator/v10/translations/zh"
)
var (
trans ut.Translator
validate *validator.Validate
)
func InitValidatorZh() {
langZh := zh.New()
uni := ut.New(langZh, langZh)
var found bool
trans, found = uni.GetTranslator("zh")
if !found {
panic("translator 'zh' not found")
}
err := zhtranslations.RegisterDefaultTranslations(validate, trans)
if err != nil {
panic("failed to register zh translations: " + err.Error())
}
}
func init() {
validate = validator.New()
// 注册自定义标签名称
validate.RegisterTagNameFunc(func(field reflect.StructField) string {
name := field.Tag.Get("json")
if name == "-" {
return ""
}
return name
})
// 初始化中文翻译器
InitValidatorZh()
}
func Translate(err error) []string {
var validateErrs validator.ValidationErrors
if errors.As(err, &validateErrs) {
messages := make([]string, 0, len(validateErrs))
for _, e := range validateErrs {
messages = append(messages, e.Translate(trans))
}
return messages
}
// 非校验错误 直接返回
return []string{err.Error()}
}
func Struct(s any) error {
return validate.Struct(s)
}