feat: update template

This commit is contained in:
2026-08-19 22:05:49 +08:00
parent 0e674e9d56
commit 5a71093b0c
67 changed files with 2070 additions and 585 deletions

View File

@@ -0,0 +1,42 @@
package admin
import (
"context"
"server/internal/db"
"server/internal/db/sqlc"
"server/internal/model/common"
)
type AccessLogService struct {
store *db.Store
}
func NewAccessLogService(store *db.Store) *AccessLogService {
return &AccessLogService{
store: store,
}
}
func (s *AccessLogService) List(ctx context.Context, p *common.Pagination) (*common.PageResult[sqlc.ListAccessLogsRow], error) {
params := sqlc.ListAccessLogsParams{
Limit: p.PageSize,
Offset: (p.Page - 1) * p.PageSize,
}
total, err := s.store.CountAccessLogs(ctx)
if err != nil {
return nil, err
}
list, err := s.store.ListAccessLogs(ctx, params)
if err != nil {
return nil, err
}
return &common.PageResult[sqlc.ListAccessLogsRow]{
Total: total,
List: list,
}, nil
}

View File

@@ -2,10 +2,13 @@ package admin
import (
"context"
"errors"
"server/internal/config"
"server/internal/db"
"server/internal/db/sqlc"
"server/internal/middleware"
"server/internal/model/auth"
"server/internal/model/enum"
"server/internal/model/request"
"server/internal/model/response"
"server/internal/pkg/cache"
@@ -13,6 +16,7 @@ import (
"server/internal/pkg/errs"
"time"
"github.com/jackc/pgx/v5"
"golang.org/x/crypto/bcrypt"
)
@@ -46,15 +50,31 @@ func comparePasswordHash(passwordHash, inputPassword string) error {
return bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(inputPassword))
}
func (s *AuthService) GetAuthState(ctx context.Context, userID int32) (*sqlc.GetUserAuthStateRow, error) {
// 过期时间使用最短窗口的那个 也就是access_token的过期时间
return cache.GetOrSetJSON[*sqlc.GetUserAuthStateRow](ctx, s.cache, cachekey.UserAuthState(userID), s.cfg.JWTConfig.Expire, func() (*sqlc.GetUserAuthStateRow, error) {
state, err := s.store.GetUserAuthState(ctx, userID)
if err != nil {
return nil, err
}
return &state, nil
})
}
func (s *AuthService) discardRefreshToken(ctx context.Context, userID int32, hash string) {
_ = s.cache.Del(ctx, cachekey.AuthRefresh(hash))
_ = s.cache.SRem(ctx, cachekey.AuthRefreshUser(userID), hash)
}
func (s *AuthService) Login(ctx context.Context, req request.LoginRequest) (*response.LoginResponse, error) {
user, err := s.store.GetUserByAccount(ctx, req.Account)
user, err := s.store.GetUser(ctx, sqlc.GetUserParams{Account: req.Account})
if err != nil {
return nil, errs.ErrInvalidCredentials
}
// 此处判断如果用户id不为1 且状态为0表示用户已被禁用
if user.ID != 1 && user.Status == 0 {
// 判断用户不为超管 且状态为0表示用户已被禁用
if !auth.IsAdmin(user.ID) && user.Status == 0 {
return nil, errs.ErrUserDisabled
}
@@ -62,14 +82,14 @@ func (s *AuthService) Login(ctx context.Context, req request.LoginRequest) (*res
return nil, errs.ErrInvalidCredentials
}
accessToken, accessTokenExp, err := s.jwt.GenerateAccessToken(user.ID)
accessToken, accessTokenExp, err := s.jwt.GenerateAccessToken(user.ID, user.TokenVersion)
if err != nil {
return nil, err
return nil, errs.ErrInternalServer
}
refreshToken, err := s.jwt.GenerateRefreshToken()
if err != nil {
return nil, err
return nil, errs.ErrInternalServer
}
// 哈希
@@ -79,28 +99,29 @@ func (s *AuthService) Login(ctx context.Context, req request.LoginRequest) (*res
refreshTokenExp := now.Add(s.cfg.JWTConfig.RefreshExpire)
refreshTokenRecord := &auth.RefreshTokenRecord{
UserID: user.ID,
CreatedAt: now,
ExpiresAt: refreshTokenExp,
UserID: user.ID,
TokenVersion: user.TokenVersion,
CreatedAt: now,
ExpiresAt: refreshTokenExp,
}
// 存入redis
err = s.cache.SetJSON(ctx, cachekey.AuthRefresh(hash), refreshTokenRecord, s.cfg.JWTConfig.RefreshExpire)
if err != nil {
return nil, err
return nil, errs.ErrInternalServer
}
// 反向索引
err = s.cache.SAdd(ctx, cachekey.AuthRefreshUser(user.ID), hash)
if err != nil {
return nil, err
return nil, errs.ErrInternalServer
}
// 给反向索引设置过期时间 这个过期时间需要覆盖最后一个token的过期时间 所以用最新的就行了
if err = s.cache.Expire(ctx, cachekey.AuthRefreshUser(user.ID), s.cfg.JWTConfig.RefreshExpire); err != nil {
return nil, err
return nil, errs.ErrInternalServer
}
return &response.LoginResponse{
@@ -121,26 +142,7 @@ func (s *AuthService) Logout(ctx context.Context, refreshToken string) error {
}
if ok {
// 清理反向索引
if err = s.cache.SRem(ctx, cachekey.AuthRefreshUser(user.UserID), hash); err != nil {
return err
}
}
// 清理当前登录的token
if err = s.cache.Del(ctx, cachekey.AuthRefresh(hash)); err != nil {
return err
}
return nil
}
func (s *AuthService) GetActiveSysUser(ctx context.Context, id int32) error {
var err error
_, err = s.store.GetActiveUserByID(ctx, id)
if err != nil {
return err
s.discardRefreshToken(ctx, user.UserID, hash)
}
return nil
@@ -152,25 +154,45 @@ func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (*r
// 从redis中获取数据
record, ok, err := cache.GetJSON[auth.RefreshTokenRecord](ctx, s.cache, cachekey.AuthRefresh(hash))
if err != nil {
return nil, errs.ErrInvalidRefreshToken
// redis 错误返回500
return nil, errs.ErrInternalServer
}
if !ok {
return nil, errs.ErrInvalidRefreshToken
}
// 拿到用户信息
authState, err := s.GetAuthState(ctx, record.UserID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// 用户已被删除 惰性清理
s.discardRefreshToken(ctx, record.UserID, hash)
return nil, errs.ErrInvalidRefreshToken
}
return nil, errs.ErrInternalServer
}
// 如果不是超级用户 需要判断用户状态
if record.UserID != 1 {
err = s.GetActiveSysUser(ctx, record.UserID)
if err != nil {
if !auth.IsAdmin(record.UserID) {
if enum.Status(authState.Status) == enum.StatusDisabled {
// 用户被禁用 清理redis缓存
s.discardRefreshToken(ctx, record.UserID, hash)
return nil, errs.ErrInvalidRefreshToken
}
}
// 比对token version 如果不相等 此时 惰性清理掉redis中的缓存
if authState.TokenVersion != record.TokenVersion {
s.discardRefreshToken(ctx, record.UserID, hash)
return nil, errs.ErrInvalidRefreshToken
}
// 获取新的access token
accessToken, accessTokenExp, err := s.jwt.GenerateAccessToken(record.UserID)
accessToken, accessTokenExp, err := s.jwt.GenerateAccessToken(record.UserID, authState.TokenVersion)
if err != nil {
return nil, err
return nil, errs.ErrInternalServer
}
return &response.LoginResponse{

View File

@@ -2,45 +2,93 @@ package admin
import (
"context"
"image"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"server/internal/config"
"server/internal/db"
"server/internal/db/sqlc"
"server/internal/model/common"
"server/internal/model/request"
"server/internal/pkg/errs"
"server/internal/pkg/httputil"
"server/internal/pkg/safego"
"strings"
"sync"
_ "image/jpeg"
_ "image/png"
gonanoid "github.com/matoous/go-nanoid/v2"
)
type FileService struct {
store *db.Store
store *db.Store
config *config.Config
}
func NewFileService(store *db.Store) *FileService {
func NewFileService(store *db.Store, config *config.Config) *FileService {
return &FileService{
store: store,
store: store,
config: config,
}
}
// MakeSavedDir 创建目录并返回
func MakeSavedDir(folder string) (string, error) {
func MakeSavedDir(uploadDir, folder string) (string, error) {
rootDir, err := os.Getwd()
if err != nil {
return "", err
}
uploadDir := filepath.Join(rootDir, "uploads", folder)
dir := filepath.Join(rootDir, uploadDir, folder)
if err = os.MkdirAll(uploadDir, 0755); err != nil {
if err = os.MkdirAll(dir, 0755); err != nil {
return "", err
}
return uploadDir, nil
return dir, nil
}
func (s *FileService) List(ctx context.Context, p *common.Pagination) (*common.PageResult[sqlc.File], error) {
func MakeFilePath(rootDir string, uploadDir string, filePath string) string {
return filepath.Join(rootDir, uploadDir, filePath)
}
// isWebp 判断是否为 WebP 魔数RIFF+WEBPDetectContentType 不识别需手动补
func isWebp(b []byte) bool {
return len(b) >= 12 &&
string(b[0:4]) == "RIFF" &&
string(b[8:12]) == "WEBP"
}
// detectMime 检测文件类型 因为从header里获取的可能是伪造的
func detectMime(file multipart.File) (string, error) {
buf := make([]byte, 512)
n, err := file.Read(buf)
if err != nil {
return "", err
}
mimeType := http.DetectContentType(buf[:n])
// 补识别 DetectContentType 漏掉的格式
if mimeType == "application/octet-stream" && isWebp(buf[:n]) {
mimeType = "image/webp"
}
// 回到文件开头
if _, err := file.Seek(0, io.SeekStart); err != nil {
return "", err
}
return mimeType, nil
}
func (s *FileService) List(ctx context.Context, p *common.Pagination) (*common.PageResult[sqlc.ListFilesRow], error) {
params := sqlc.ListFilesParams{
Limit: p.PageSize,
Offset: (p.Page - 1) * p.PageSize,
@@ -58,12 +106,37 @@ func (s *FileService) List(ctx context.Context, p *common.Pagination) (*common.P
return nil, err
}
return &common.PageResult[sqlc.File]{
return &common.PageResult[sqlc.ListFilesRow]{
List: list,
Total: total,
}, nil
}
// sanitizeOriginalName 过滤文件名中的 < > 和控制字符,防 HTML 注入
func sanitizeOriginalName(name string) string {
var b strings.Builder
b.Grow(len(name))
for _, r := range name {
switch {
case r == '<' || r == '>':
continue
case r < 0x20 || r == 0x7f:
continue // 控制字符
default:
b.WriteRune(r)
}
}
// 限长 255 个字符
runes := []rune(b.String())
if len(runes) > 255 {
runes = runes[:255]
}
return string(runes)
}
func (s *FileService) Upload(ctx context.Context, folder string, file *multipart.FileHeader) (*sqlc.CreateFileRow, error) {
// 生成文件名
fileID, err := gonanoid.New()
@@ -71,7 +144,7 @@ func (s *FileService) Upload(ctx context.Context, folder string, file *multipart
return nil, err
}
savedDir, err := MakeSavedDir(folder)
savedDir, err := MakeSavedDir(s.config.File.UploadDir, folder)
if err != nil {
return nil, err
}
@@ -89,6 +162,44 @@ func (s *FileService) Upload(ctx context.Context, folder string, file *multipart
}
defer src.Close()
// 检测类型
mimeType, err := detectMime(src)
if err != nil {
return nil, err
}
// 内容与扩展名必须匹配,防改后缀绕过
if !request.MatchUploadMime(file.Filename, mimeType) {
return nil, errs.ErrFileTypeNotAllowed
}
var imageMeta *sqlc.CreateFileImageMetadataParams
// 图片需能解码,否则拒绝
if strings.HasPrefix(mimeType, "image/") {
cfg, format, err := image.DecodeConfig(src)
if err != nil {
// webp 标准库无法解码,但 MIME 已验证,跳过元数据
if mimeType != "image/webp" {
return nil, errs.ErrFileTypeNotAllowed
}
} else {
imageMeta = &sqlc.CreateFileImageMetadataParams{
Width: int32(cfg.Width),
Height: int32(cfg.Height),
Format: format,
}
}
// 重置读取位置
_, err = src.Seek(0, io.SeekStart)
if err != nil {
return nil, err
}
}
// 创建目标文件
dst, err := os.Create(savedPath)
if err != nil {
@@ -101,20 +212,135 @@ func (s *FileService) Upload(ctx context.Context, folder string, file *multipart
return nil, err
}
// 净化文件名,全被过滤则用存储名兜底
originalName := sanitizeOriginalName(file.Filename)
if originalName == "" {
originalName = filename
}
params := sqlc.CreateFileParams{
FileName: filename,
FilePath: filePath,
FileUrl: httputil.BuildFileUrl(&filePath),
OriginalName: file.Filename,
OriginalName: originalName,
FolderName: folder,
MimeType: file.Header.Get("Content-Type"),
MimeType: mimeType,
FileSize: file.Size,
}
result, err := s.store.CreateFile(ctx, params)
result, err := db.WithTxResult[*sqlc.CreateFileRow](ctx, s.store, func(q *sqlc.Queries) (*sqlc.CreateFileRow, error) {
result, err := q.CreateFile(ctx, params)
if err != nil {
return nil, err
}
if imageMeta != nil {
meta := *imageMeta
meta.FileID = result.ID
if err = q.CreateFileImageMetadata(ctx, meta); err != nil {
return nil, err
}
}
return &result, nil
})
// 事务失败 清理已保存的文件
if err != nil {
_ = os.Remove(savedPath)
return nil, err
}
return &result, nil
return result, nil
}
func (s *FileService) SyncMetadata(ctx context.Context) error {
files, err := s.store.ListImageFiles(ctx)
if err != nil {
return err
}
if len(files) == 0 {
return nil
}
rootDir, err := os.Getwd()
if err != nil {
return err
}
var (
metadataList []sqlc.CopyFileImageMetadataParams
wg sync.WaitGroup
mu sync.Mutex
)
// 限制并发数量
sem := make(chan struct{}, 10)
for _, f := range files {
wg.Add(1)
safego.Go(func() {
defer wg.Done()
sem <- struct{}{}
defer func() {
<-sem
}()
absolutePath := MakeFilePath(
rootDir,
s.config.File.UploadDir,
f.FilePath,
)
src, err := os.Open(absolutePath)
if err != nil {
return
}
defer src.Close()
cfg, format, err := image.DecodeConfig(src)
if err != nil {
return
}
item := sqlc.CopyFileImageMetadataParams{
FileID: f.ID,
Width: int32(cfg.Width),
Height: int32(cfg.Height),
Format: format,
}
mu.Lock()
metadataList = append(metadataList, item)
mu.Unlock()
})
}
wg.Wait()
if len(metadataList) == 0 {
return nil
}
err = s.store.WithTx(ctx, func(q *sqlc.Queries) error {
if err = q.TruncateFileImageMetadata(ctx); err != nil {
return err
}
if _, err = q.CopyFileImageMetadata(ctx, metadataList); err != nil {
return err
}
return nil
})
if err != nil {
return err
}
return nil
}

View File

@@ -71,6 +71,9 @@ func (s *MenuService) Create(ctx context.Context, req request.CreateMenuRequest)
return err
}
// 清理缓存 这里不清理 超管用户的菜单不会刷新
_ = s.cache.DelByPrefix(ctx, cachekey.UserInfoPattern)
return nil
}

View File

@@ -15,5 +15,6 @@ var Module = fx.Module("sys-services",
NewAuthService,
NewTagService,
NewCategoryService,
NewAccessLogService,
),
)

View File

@@ -55,7 +55,7 @@ func (s *PostService) Create(ctx context.Context, req request.CreatePostRequest)
postID, err := db.WithTxResult(ctx, s.store, func(q *sqlc.Queries) (int32, error) {
params := sqlc.CreatePostParams{
Title: req.Title,
CoverID: &req.CoverID,
CoverID: req.CoverID,
Slug: req.Slug,
Content: req.Content,
Summary: req.Summary,
@@ -103,7 +103,6 @@ func (s *PostService) Update(ctx context.Context, id int32, req request.UpdatePo
return s.store.WithTx(ctx, func(q *sqlc.Queries) error {
params := sqlc.UpdatePostParams{
Title: req.Title,
CoverID: req.CoverID,
Slug: req.Slug,
Content: req.Content,
Summary: req.Summary,
@@ -113,6 +112,13 @@ func (s *PostService) Update(ctx context.Context, id int32, req request.UpdatePo
ID: id,
}
if req.CoverID.Set {
params.UpdateCoverID = true
if req.CoverID.Valid {
params.CoverID = &req.CoverID.Value
}
}
rows, err := q.UpdatePost(ctx, params)
if err != nil {
return dberr.MapUniqueViolation(err, dberr.PostSlugKey, errs.ErrSlugAlreadyExists)

View File

@@ -6,6 +6,7 @@ import (
"server/internal/db"
"server/internal/db/sqlc"
"server/internal/middleware"
"server/internal/model/auth"
"server/internal/model/common"
"server/internal/model/request"
"server/internal/model/response"
@@ -31,7 +32,7 @@ func NewUserService(store *db.Store, jwt *middleware.JWTMiddleware, cache *cache
}
}
// clearUserCache
// clearUserCache 清理单个用户缓存 权限 info 和 鉴权状态
func (s *UserService) clearUserCache(ctx context.Context, id int32) error {
if err := s.cache.Del(ctx, cachekey.UserApiPermissions(id)); err != nil {
return err
@@ -41,6 +42,9 @@ func (s *UserService) clearUserCache(ctx context.Context, id int32) error {
return err
}
if err := s.cache.Del(ctx, cachekey.UserAuthState(id)); err != nil {
return err
}
return nil
}
@@ -64,19 +68,29 @@ func (s *UserService) clearRefreshToken(ctx context.Context, id int32) error {
return err
}
// forceLogout 强制下线bump 版本 + 清缓存(含 auth_state+ 清刷新令牌
func (s *UserService) forceLogout(ctx context.Context, id int32) error {
if err := s.store.IncrementUserTokenVersion(ctx, id); err != nil {
return err
}
_ = s.clearUserCache(ctx, id)
_ = s.clearRefreshToken(ctx, id)
return nil
}
func (s *UserService) GetCurrentUser(ctx context.Context, id int32, isAdmin bool) (*response.UserInfo, error) {
return cache.GetOrSetJSON[*response.UserInfo](ctx, s.cache, cachekey.UserInfo(id), 0, func() (*response.UserInfo, error) {
g, ctx := errgroup.WithContext(ctx)
var (
user sqlc.GetUserByIDRow
user sqlc.GetUserRow
roles []sqlc.SysRole
menus []sqlc.SysMenu
permissions []*string
)
g.Go(func() error {
u, err := s.store.GetUserByID(ctx, id)
u, err := s.store.GetUser(ctx, sqlc.GetUserParams{ID: id})
if err != nil {
return dberr.MapNoRows(err, errs.ErrUserNotFound)
}
@@ -136,8 +150,8 @@ func (s *UserService) GetCurrentUser(ctx context.Context, id int32, isAdmin bool
return nil, err
}
// 如果用户被禁用 返回错误 超管不用管状态
if user.ID != 1 && user.Status != 1 {
// 判断用户不为超管 且状态为0表示用户已被禁用
if !auth.IsAdmin(user.ID) && user.Status == 0 {
return nil, errs.ErrUserDisabled
}
@@ -174,7 +188,7 @@ func (s *UserService) List(ctx context.Context, p request.SearchUserParams) (*co
func (s *UserService) ListRoles(ctx context.Context, id int32) ([]sqlc.SysRole, error) {
// 先查询用户是否存在
_, err := s.store.GetUserByID(ctx, id)
_, err := s.store.GetUser(ctx, sqlc.GetUserParams{ID: id})
if err != nil {
return nil, dberr.MapNoRows(err, errs.ErrUserNotFound)
}
@@ -203,6 +217,19 @@ func (s *UserService) Create(ctx context.Context, req request.CreateUserRequest)
return nil
}
func (s *UserService) KickUser(ctx context.Context, id int32) error {
return s.forceLogout(ctx, id)
}
func (s *UserService) KickAllUsers(ctx context.Context) error {
if err := s.store.IncrementTokenVersionForAllUsers(ctx); err != nil {
return err
}
// 清理所有的token和状态
_ = s.cache.DelByPrefix(ctx, cachekey.AuthRefreshPattern)
return s.cache.DelByPrefix(ctx, cachekey.UserAuthStatePattern)
}
func (s *UserService) Update(ctx context.Context, id int32, req request.UpdateUserRequest) error {
user := sqlc.UpdateUserParams{
Username: req.Username,
@@ -227,7 +254,8 @@ func (s *UserService) Update(ctx context.Context, id int32, req request.UpdateUs
// 如果将用户的状态修改为0则清除用户刷新令牌
if req.Status != nil && *req.Status == 0 {
_ = s.clearRefreshToken(ctx, id)
// 踢下线
_ = s.forceLogout(ctx, id)
}
return nil
@@ -235,7 +263,7 @@ func (s *UserService) Update(ctx context.Context, id int32, req request.UpdateUs
func (s *UserService) SetRoles(ctx context.Context, userID int32, req request.SetUserRolesRequest) error {
// 先查询用户是否存在
_, err := s.store.GetUserByID(ctx, userID)
_, err := s.store.GetUser(ctx, sqlc.GetUserParams{ID: userID})
if err != nil {
return dberr.MapNoRows(err, errs.ErrUserNotFound)
}
@@ -292,13 +320,15 @@ func (s *UserService) UpdatePassword(ctx context.Context, id int32, req request.
}
// 下线当前用户
_ = s.clearRefreshToken(ctx, id)
if err = s.forceLogout(ctx, id); err != nil {
return err
}
return nil
}
func (s *UserService) Delete(ctx context.Context, id int32) error {
isAdmin := middleware.UserIsAdmin(id)
isAdmin := auth.IsAdmin(id)
if isAdmin {
return errs.ErrCannotDeleteSuperAdmin