feat: update template
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"server/internal/config"
|
||||
"server/internal/db"
|
||||
"server/internal/handler"
|
||||
@@ -59,6 +60,8 @@ func main() {
|
||||
middleware.NewJWTMiddleware,
|
||||
middleware.NewAuthMiddleware,
|
||||
middleware.NewLoggerMiddleware,
|
||||
middleware.NewAccessLogMiddleware,
|
||||
middleware.NewRequestContextMiddleware,
|
||||
|
||||
router.NewRouter,
|
||||
),
|
||||
@@ -68,5 +71,11 @@ func main() {
|
||||
fx.Invoke(NewHttpServer),
|
||||
)
|
||||
|
||||
// 装配失败(如 jwt.secret 未配置)以非零码退出,让部署脚本可感知
|
||||
if err := app.Err(); err != nil {
|
||||
slog.Error("application failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
app.Run()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
server:
|
||||
port: 8080
|
||||
host: 127.0.0.1
|
||||
# 是否运行在受信反向代理后;true 才信任 X-Forwarded-For/X-Real-IP,否则忽略(防伪造)
|
||||
trust_proxy: false
|
||||
|
||||
database:
|
||||
host: db_host
|
||||
@@ -17,7 +19,7 @@ redis:
|
||||
key_prefix: ""
|
||||
|
||||
jwt:
|
||||
secret:
|
||||
secret: ""
|
||||
signing_method: HS256
|
||||
expire: 30m
|
||||
refresh_expire: 720h
|
||||
@@ -29,6 +31,8 @@ jwt:
|
||||
|
||||
file:
|
||||
base_url: http://127.0.0.1:8080/static/ #静态文件目录 用于本地访问开放静态资源
|
||||
upload_dir: "uploads"
|
||||
max_upload_size: 10485760 # 10MB
|
||||
|
||||
log:
|
||||
level: debug # debug / info / warn / error
|
||||
|
||||
@@ -11,9 +11,11 @@ import (
|
||||
"github.com/knadh/koanf/providers/file"
|
||||
)
|
||||
|
||||
type Environment string
|
||||
|
||||
const (
|
||||
Development = "development"
|
||||
Production = "production"
|
||||
Development Environment = "development"
|
||||
Production Environment = "production"
|
||||
)
|
||||
|
||||
var K = koanf.New(".")
|
||||
@@ -21,6 +23,8 @@ var K = koanf.New(".")
|
||||
type Server struct {
|
||||
Port string `koanf:"port"`
|
||||
Host string `koanf:"host"`
|
||||
// TrustProxy 是否在受信反代后;true 时才信任代理头获取客户端 IP
|
||||
TrustProxy bool `koanf:"trust_proxy"`
|
||||
}
|
||||
|
||||
type Database struct {
|
||||
@@ -61,24 +65,28 @@ type LogConfig struct {
|
||||
Console bool `koanf:"console"`
|
||||
}
|
||||
|
||||
type FileConfig struct {
|
||||
BaseURL string `koanf:"base_url"`
|
||||
UploadDir string `koanf:"upload_dir"`
|
||||
MaxUploadSize int64 `koanf:"max_upload_size"` // 上传大小上限(字节),0 表示默认 10MB
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Server Server `koanf:"server"`
|
||||
Database Database `koanf:"database"`
|
||||
Redis Redis `koanf:"redis"`
|
||||
JWTConfig JWTConfig `koanf:"jwt"`
|
||||
Log LogConfig `koanf:"log"`
|
||||
Server Server `koanf:"server"`
|
||||
Database Database `koanf:"database"`
|
||||
Redis Redis `koanf:"redis"`
|
||||
JWTConfig JWTConfig `koanf:"jwt"`
|
||||
Log LogConfig `koanf:"log"`
|
||||
File FileConfig `koanf:"file"`
|
||||
}
|
||||
|
||||
// GetEnv 获取环境变量,确保环境变量始终有效
|
||||
func GetEnv() string {
|
||||
func GetEnv() Environment {
|
||||
env := os.Getenv("APP_ENV")
|
||||
if env == "" {
|
||||
env = Development
|
||||
}
|
||||
|
||||
switch env {
|
||||
switch Environment(env) {
|
||||
case Development, Production:
|
||||
return env
|
||||
return Environment(env)
|
||||
default:
|
||||
return Development
|
||||
}
|
||||
@@ -113,3 +121,7 @@ func GetString(key string) string {
|
||||
func GetInt(key string) int {
|
||||
return K.Int(key)
|
||||
}
|
||||
|
||||
func GetBool(key string) bool {
|
||||
return K.Bool(key)
|
||||
}
|
||||
|
||||
1
internal/db/migrations/000012_add_access_logs.down.sql
Normal file
1
internal/db/migrations/000012_add_access_logs.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS access_logs;
|
||||
25
internal/db/migrations/000012_add_access_logs.up.sql
Normal file
25
internal/db/migrations/000012_add_access_logs.up.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE access_logs
|
||||
(
|
||||
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
request_id TEXT,
|
||||
user_id INTEGER,
|
||||
ip INET,
|
||||
user_agent TEXT,
|
||||
request_method VARCHAR(10) NOT NULL,
|
||||
request_path TEXT NOT NULL,
|
||||
referer TEXT,
|
||||
message TEXT,
|
||||
status_code INTEGER,
|
||||
response_time_ms INTEGER,
|
||||
started_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_access_logs_created_at
|
||||
ON access_logs (created_at);
|
||||
|
||||
CREATE INDEX idx_access_logs_request_path
|
||||
ON access_logs (request_path);
|
||||
|
||||
CREATE INDEX idx_access_logs_ip
|
||||
ON access_logs (ip);
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS file_image_metadata;
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE file_image_metadata
|
||||
(
|
||||
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
file_id INTEGER UNIQUE NOT NULL,
|
||||
width INTEGER NOT NULL,
|
||||
height INTEGER NOT NULL,
|
||||
format TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
-- down
|
||||
ALTER TABLE post_stats
|
||||
RENAME COLUMN view_count TO view;
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE post_stats
|
||||
RENAME COLUMN view TO view_count;
|
||||
|
||||
COMMENT ON COLUMN post_stats.view_count IS '文章阅读量';
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE sys_users
|
||||
DROP COLUMN IF EXISTS token_version;
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE sys_users
|
||||
ADD COLUMN IF NOT EXISTS token_version INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN sys_users.token_version IS 'Token version';
|
||||
16
internal/db/query/access_logs.sql
Normal file
16
internal/db/query/access_logs.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- name: CreateAccessLog :exec
|
||||
INSERT INTO access_logs(request_id, user_id, ip, user_agent, request_method, request_path, referer, status_code,
|
||||
response_time_ms, started_at, message)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);
|
||||
|
||||
-- name: ListAccessLogs :many
|
||||
SELECT a.*, u.username
|
||||
FROM access_logs a
|
||||
LEFT JOIN sys_users u
|
||||
ON u.id = a.user_id
|
||||
ORDER BY a.started_at DESC, a.id
|
||||
LIMIT $1 OFFSET $2;
|
||||
|
||||
-- name: CountAccessLogs :one
|
||||
SELECT COUNT(*)
|
||||
FROM access_logs;
|
||||
10
internal/db/query/file_image_metadata.sql
Normal file
10
internal/db/query/file_image_metadata.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- name: CreateFileImageMetadata :exec
|
||||
INSERT INTO file_image_metadata(file_id, width, height, format)
|
||||
VALUES ($1, $2, $3, $4);
|
||||
|
||||
-- name: CopyFileImageMetadata :copyfrom
|
||||
INSERT INTO file_image_metadata(file_id, width, height, format)
|
||||
VALUES ($1, $2, $3, $4);
|
||||
|
||||
-- name: TruncateFileImageMetadata :exec
|
||||
TRUNCATE TABLE file_image_metadata;
|
||||
@@ -1,14 +1,29 @@
|
||||
-- name: CreateFile :one
|
||||
INSERT INTO files(file_name, file_path, original_name, folder_name, mime_type, file_size, file_url)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id,file_url,file_name;
|
||||
RETURNING id,file_url,file_name,file_size;
|
||||
|
||||
-- name: ListFiles :many
|
||||
SELECT *
|
||||
FROM files
|
||||
ORDER BY id
|
||||
SELECT f.*,
|
||||
CASE
|
||||
WHEN m.file_id IS NULL THEN NULL
|
||||
ELSE jsonb_build_object(
|
||||
'width', m.width,
|
||||
'height', m.height,
|
||||
'format', m.format
|
||||
)
|
||||
END AS metadata
|
||||
FROM files f
|
||||
LEFT JOIN file_image_metadata m
|
||||
ON f.id = m.file_id
|
||||
ORDER BY f.id DESC
|
||||
LIMIT $1 OFFSET $2;
|
||||
|
||||
-- name: CountFiles :one
|
||||
SELECT COUNT(*)
|
||||
FROM files;
|
||||
|
||||
-- name: ListImageFiles :many
|
||||
SELECT id, file_path
|
||||
FROM files
|
||||
WHERE mime_type LIKE 'image/%';
|
||||
@@ -18,25 +18,25 @@ WITH paginated_posts AS (
|
||||
updated_at
|
||||
FROM posts
|
||||
ORDER BY sort DESC, published_at DESC, id DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
)
|
||||
LIMIT $1 OFFSET $2)
|
||||
-- 第二步:用这极少量的记录去进行 JOIN
|
||||
SELECT p.*,
|
||||
f.file_url AS cover,
|
||||
c.name AS category_name,
|
||||
c.id AS category_id,
|
||||
COALESCE(ps.view, 0) AS view,
|
||||
f.file_url AS cover,
|
||||
f.file_size AS cover_size,
|
||||
c.name AS category_name,
|
||||
c.id AS category_id,
|
||||
COALESCE(ps.view_count, 0) AS view_count,
|
||||
COALESCE(
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', t.id,
|
||||
'name', t.name,
|
||||
'code', t.code
|
||||
)
|
||||
ORDER BY t.sort DESC, t.id
|
||||
) FILTER (WHERE t.id IS NOT NULL),
|
||||
'[]'::jsonb
|
||||
) AS tags
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', t.id,
|
||||
'name', t.name,
|
||||
'code', t.code
|
||||
)
|
||||
ORDER BY t.sort DESC, t.id
|
||||
) FILTER (WHERE t.id IS NOT NULL),
|
||||
'[]'::jsonb
|
||||
) AS tags
|
||||
FROM paginated_posts p
|
||||
LEFT JOIN files f ON f.id = p.cover_id
|
||||
LEFT JOIN post_category pc ON pc.post_id = p.id
|
||||
@@ -55,9 +55,10 @@ GROUP BY p.id,
|
||||
p.created_at,
|
||||
p.updated_at,
|
||||
f.file_url,
|
||||
f.file_size,
|
||||
c.name,
|
||||
c.id,
|
||||
ps.view
|
||||
ps.view_count
|
||||
ORDER BY p.sort DESC, p.published_at DESC, p.id DESC;
|
||||
|
||||
-- name: CountPosts :one
|
||||
@@ -66,13 +67,14 @@ FROM posts;
|
||||
|
||||
-- name: GetPostByID :one
|
||||
SELECT p.*,
|
||||
f.file_url AS cover,
|
||||
f.file_url AS cover,
|
||||
f.file_size AS cover_size,
|
||||
c.name AS category_name,
|
||||
c.id AS category_id,
|
||||
COALESCE(
|
||||
array_agg(pt.tag_id ORDER BY pt.tag_id) FILTER (WHERE pt.tag_id IS NOT NULL),
|
||||
ARRAY[]::integer[]
|
||||
) AS tags
|
||||
array_agg(pt.tag_id ORDER BY pt.tag_id) FILTER (WHERE pt.tag_id IS NOT NULL),
|
||||
ARRAY []::integer[]
|
||||
) AS tags
|
||||
FROM posts p
|
||||
LEFT JOIN files f ON f.id = p.cover_id
|
||||
LEFT JOIN post_category pc ON pc.post_id = p.id
|
||||
@@ -81,6 +83,7 @@ FROM posts p
|
||||
WHERE p.id = $1
|
||||
GROUP BY p.id,
|
||||
f.file_url,
|
||||
f.file_size,
|
||||
c.name,
|
||||
c.id
|
||||
LIMIT 1;
|
||||
@@ -88,7 +91,7 @@ LIMIT 1;
|
||||
-- name: UpdatePost :execrows
|
||||
UPDATE posts
|
||||
SET title = coalesce(sqlc.narg('title'), title),
|
||||
cover_id = coalesce(sqlc.narg('cover_id'), cover_id),
|
||||
cover_id = CASE WHEN @update_cover_id::boolean THEN @cover_id ELSE cover_id END,
|
||||
slug = coalesce(sqlc.narg('slug'), slug),
|
||||
content = coalesce(sqlc.narg('content'), content),
|
||||
summary = coalesce(sqlc.narg('summary'), summary),
|
||||
@@ -105,12 +108,30 @@ WHERE id = $1;
|
||||
-- web -------------------------------------------------------
|
||||
|
||||
-- name: GetPublicPostBySlug :one
|
||||
SELECT p.*,
|
||||
f.file_url AS cover,
|
||||
COALESCE(ps.view, 0) AS view
|
||||
SELECT p.id,
|
||||
p.title,
|
||||
p.slug,
|
||||
p.content,
|
||||
p.summary,
|
||||
p.published_at,
|
||||
f.file_url AS cover,
|
||||
COALESCE(ps.view_count, 0) AS view_count,
|
||||
c.code AS category_code,
|
||||
c.name AS category_name,
|
||||
COALESCE(t.tags, '[]') AS tags
|
||||
FROM posts p
|
||||
LEFT JOIN files f ON f.id = p.cover_id
|
||||
LEFT JOIN post_stats ps ON ps.post_id = p.id
|
||||
LEFT JOIN post_category pc ON pc.post_id = p.id
|
||||
LEFT JOIN categories c ON pc.category_id = c.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object('name', tag.name, 'code', tag.code)
|
||||
) AS tags
|
||||
FROM post_tag pt
|
||||
JOIN tags tag ON tag.id = pt.tag_id
|
||||
WHERE pt.post_id = p.id
|
||||
) t ON true
|
||||
WHERE p.slug = $1
|
||||
AND p.status = 1
|
||||
AND p.published_at < NOW()
|
||||
@@ -132,21 +153,22 @@ WITH paginated_posts AS (
|
||||
ORDER BY sort DESC, published_at DESC, id DESC
|
||||
LIMIT $1 OFFSET $2)
|
||||
SELECT p.*,
|
||||
f.file_url AS cover,
|
||||
c.name AS category_name,
|
||||
c.id AS category_id,
|
||||
COALESCE(ps.view, 0) AS view,
|
||||
f.file_url AS cover,
|
||||
c.name AS category_name,
|
||||
c.id AS category_id,
|
||||
c.code AS category_code,
|
||||
COALESCE(ps.view_count, 0) AS view_count,
|
||||
COALESCE(
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', t.id,
|
||||
'name', t.name,
|
||||
'code', t.code
|
||||
)
|
||||
ORDER BY t.sort DESC, t.id
|
||||
) FILTER (WHERE t.id IS NOT NULL),
|
||||
'[]'::jsonb
|
||||
) AS tags
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', t.id,
|
||||
'name', t.name,
|
||||
'code', t.code
|
||||
)
|
||||
ORDER BY t.sort DESC, t.id
|
||||
) FILTER (WHERE t.id IS NOT NULL),
|
||||
'[]'::jsonb
|
||||
) AS tags
|
||||
FROM paginated_posts p
|
||||
LEFT JOIN files f ON f.id = p.cover_id
|
||||
LEFT JOIN post_category pc ON pc.post_id = p.id
|
||||
@@ -164,7 +186,8 @@ GROUP BY p.id,
|
||||
f.file_url,
|
||||
c.name,
|
||||
c.id,
|
||||
ps.view
|
||||
c.code,
|
||||
ps.view_count
|
||||
ORDER BY p.sort DESC, p.published_at DESC, p.id DESC;
|
||||
|
||||
-- name: CountPublishedPosts :one
|
||||
@@ -174,35 +197,90 @@ WHERE status = 1
|
||||
AND published_at < NOW();
|
||||
|
||||
-- name: ListArchives :many
|
||||
SELECT
|
||||
p.id,
|
||||
p.slug,
|
||||
p.title,
|
||||
p.published_at,
|
||||
c."name" AS category_name
|
||||
FROM
|
||||
posts p
|
||||
LEFT JOIN post_category pc ON p.id = pc.post_id
|
||||
LEFT JOIN categories c ON c.id = pc.category_id
|
||||
WHERE
|
||||
p.status = 1
|
||||
SELECT p.id,
|
||||
p.slug,
|
||||
p.title,
|
||||
p.published_at,
|
||||
c."name" AS category_name
|
||||
FROM posts p
|
||||
LEFT JOIN post_category pc ON p.id = pc.post_id
|
||||
LEFT JOIN categories c ON c.id = pc.category_id
|
||||
WHERE p.status = 1
|
||||
AND p.published_at < NOW()
|
||||
ORDER BY
|
||||
p.published_at DESC, p.id DESC;
|
||||
ORDER BY p.published_at DESC, p.id DESC;
|
||||
|
||||
-- name: ListCategoryStats :many
|
||||
SELECT
|
||||
c.id,
|
||||
c.name,
|
||||
COUNT(p.id) AS post_count
|
||||
FROM
|
||||
categories c
|
||||
LEFT JOIN post_category pc ON c.id = pc.category_id
|
||||
LEFT JOIN posts p ON p.id = pc.post_id
|
||||
AND p.status = 1
|
||||
SELECT c.id,
|
||||
c.name,
|
||||
c.code,
|
||||
COUNT(p.id) AS post_count
|
||||
FROM categories c
|
||||
LEFT JOIN post_category pc ON c.id = pc.category_id
|
||||
LEFT JOIN posts p ON p.id = pc.post_id
|
||||
AND p.status = 1
|
||||
AND p.published_at < NOW()
|
||||
GROUP BY c.id,
|
||||
c.sort,
|
||||
c.name,
|
||||
c.code
|
||||
ORDER BY c.sort DESC, c.id;
|
||||
|
||||
-- name: ListPublishedPostsWithFilters :many
|
||||
SELECT p.id,
|
||||
p.title,
|
||||
p.slug,
|
||||
p.summary,
|
||||
p.sort,
|
||||
p.published_at,
|
||||
f.file_url AS cover,
|
||||
c."name" AS category_name,
|
||||
c.code AS category_code,
|
||||
COALESCE(t.tags, '[]') AS tags,
|
||||
COALESCE(ps."view_count", 0) AS view_count
|
||||
FROM posts p
|
||||
LEFT JOIN post_category pc ON pc.post_id = p.id
|
||||
LEFT JOIN categories c ON c.id = pc.category_id
|
||||
LEFT JOIN post_stats ps ON ps.post_id = p.id
|
||||
LEFT JOIN files f ON f.id = p.cover_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object('name', tag.name, 'code', tag.code)
|
||||
) AS tags
|
||||
FROM post_tag pt
|
||||
JOIN tags tag ON tag.id = pt.tag_id
|
||||
WHERE pt.post_id = p.id
|
||||
) t ON true
|
||||
WHERE
|
||||
-- 只显示已发布的文章
|
||||
p.status = 1
|
||||
AND p.published_at < NOW()
|
||||
GROUP BY
|
||||
c.id,
|
||||
c.sort,
|
||||
c.name
|
||||
ORDER BY c.sort DESC, c.id;
|
||||
AND
|
||||
-- category code 过滤(NULL 或空字符串时不过滤)
|
||||
(
|
||||
COALESCE(sqlc.narg('category_code')::text, '') = ''
|
||||
OR EXISTS (SELECT 1
|
||||
FROM post_category pc2
|
||||
JOIN categories c2 ON c2.id = pc2.category_id
|
||||
WHERE pc2.post_id = p.id
|
||||
AND c2.code = sqlc.narg('category_code')::text)
|
||||
)
|
||||
AND
|
||||
-- tag code 过滤(NULL 或空字符串时不过滤)
|
||||
(
|
||||
COALESCE(sqlc.narg('tag_code')::text, '') = ''
|
||||
OR EXISTS (SELECT 1
|
||||
FROM post_tag pt2
|
||||
JOIN tags t2 ON t2.id = pt2.tag_id
|
||||
WHERE pt2.post_id = p.id
|
||||
AND t2.code = sqlc.narg('tag_code')::text)
|
||||
)
|
||||
ORDER BY p.sort DESC,
|
||||
p.published_at DESC,
|
||||
p.id DESC;
|
||||
|
||||
-- name: GetPostsForSitemap :many
|
||||
SELECT slug, created_at, updated_at
|
||||
FROM posts
|
||||
WHERE status = 1
|
||||
AND published_at < NOW()
|
||||
ORDER BY sort DESC, published_at DESC, id DESC;
|
||||
@@ -18,7 +18,7 @@ WITH visitor AS (
|
||||
)
|
||||
INSERT INTO post_stats (
|
||||
post_id,
|
||||
view
|
||||
view_count
|
||||
)
|
||||
SELECT
|
||||
post_id,
|
||||
@@ -26,4 +26,4 @@ SELECT
|
||||
FROM visitor
|
||||
ON CONFLICT (post_id)
|
||||
DO UPDATE
|
||||
SET view = post_stats.view + 1;
|
||||
SET view_count = post_stats.view_count + 1;
|
||||
@@ -2,27 +2,13 @@
|
||||
INSERT INTO sys_users (account, username, password_hash, status, avatar_id)
|
||||
VALUES ($1, $2, $3, $4, $5);
|
||||
|
||||
-- name: GetActiveUserByID :one
|
||||
-- 场景:用户登录、获取个人信息、刷新 Token(严格校验 status = 1)
|
||||
SELECT id, account, username, status
|
||||
-- name: GetUserAuthState :one
|
||||
SELECT status,
|
||||
token_version
|
||||
FROM sys_users
|
||||
WHERE id = $1
|
||||
AND status = 1;
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: GetUserByID :one
|
||||
SELECT u.id,
|
||||
u.account,
|
||||
u.username,
|
||||
u.avatar_id,
|
||||
u.status,
|
||||
f.file_url AS avatar_url,
|
||||
u.created_at,
|
||||
u.updated_at
|
||||
FROM sys_users u
|
||||
LEFT JOIN files f ON u.avatar_id = f.id
|
||||
WHERE u.id = $1;
|
||||
|
||||
-- name: GetUserByAccount :one
|
||||
-- name: GetUser :one
|
||||
SELECT u.id,
|
||||
u.account,
|
||||
u.username,
|
||||
@@ -30,11 +16,13 @@ SELECT u.id,
|
||||
u.status,
|
||||
u.avatar_id,
|
||||
f.file_url AS avatar_url,
|
||||
u.token_version,
|
||||
u.created_at,
|
||||
u.updated_at
|
||||
FROM sys_users u
|
||||
LEFT JOIN files f ON u.avatar_id = f.id
|
||||
WHERE u.account = $1;
|
||||
WHERE u.id = $1
|
||||
OR u.account = $2;
|
||||
|
||||
-- name: ListUsers :many
|
||||
SELECT u.id,
|
||||
@@ -63,6 +51,15 @@ SET username = coalesce(sqlc.narg('username'), username),
|
||||
avatar_id = CASE WHEN @update_avatar_id::boolean THEN @avatar_id ELSE avatar_id END
|
||||
WHERE id = sqlc.arg('id');
|
||||
|
||||
-- name: IncrementUserTokenVersion :exec
|
||||
UPDATE sys_users
|
||||
SET token_version = token_version + 1
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: IncrementTokenVersionForAllUsers :exec
|
||||
UPDATE sys_users
|
||||
SET token_version = token_version + 1;
|
||||
|
||||
-- name: UpdateUserPassword :execrows
|
||||
UPDATE sys_users
|
||||
SET password_hash = $2
|
||||
|
||||
25
internal/db/schema/access_logs.sql
Normal file
25
internal/db/schema/access_logs.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE access_logs
|
||||
(
|
||||
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
request_id TEXT,
|
||||
user_id INTEGER,
|
||||
ip INET,
|
||||
user_agent TEXT,
|
||||
request_method VARCHAR(10) NOT NULL,
|
||||
request_path TEXT NOT NULL,
|
||||
referer TEXT,
|
||||
message TEXT,
|
||||
status_code INTEGER,
|
||||
response_time_ms INTEGER,
|
||||
started_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_access_logs_created_at
|
||||
ON access_logs (created_at);
|
||||
|
||||
CREATE INDEX idx_access_logs_request_path
|
||||
ON access_logs (request_path);
|
||||
|
||||
CREATE INDEX idx_access_logs_ip
|
||||
ON access_logs (ip);
|
||||
9
internal/db/schema/file_image_metadata.sql
Normal file
9
internal/db/schema/file_image_metadata.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE file_image_metadata
|
||||
(
|
||||
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
file_id INTEGER UNIQUE NOT NULL,
|
||||
width INTEGER NOT NULL,
|
||||
height INTEGER NOT NULL,
|
||||
format TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
@@ -1,8 +1,8 @@
|
||||
CREATE TABLE post_stats
|
||||
(
|
||||
post_id INTEGER PRIMARY KEY,
|
||||
view INTEGER NOT NULL DEFAULT 0
|
||||
post_id INTEGER PRIMARY KEY,
|
||||
view_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN post_stats.post_id IS '文章id';
|
||||
COMMENT ON COLUMN post_stats.view IS '文章阅读量';
|
||||
COMMENT ON COLUMN post_stats.view_count IS '文章阅读量';
|
||||
|
||||
@@ -6,6 +6,7 @@ CREATE TABLE sys_users
|
||||
password_hash TEXT NOT NULL,
|
||||
status SMALLINT NOT NULL DEFAULT 1,
|
||||
avatar_id INTEGER,
|
||||
token_version INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ,
|
||||
|
||||
@@ -20,12 +21,12 @@ CREATE TRIGGER update_sys_users_updated_at
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
|
||||
COMMENT ON COLUMN sys_users.id IS '主键ID';
|
||||
COMMENT ON COLUMN sys_users.account IS '用户账号';
|
||||
COMMENT ON COLUMN sys_users.username IS '用户名称';
|
||||
COMMENT ON COLUMN sys_users.password_hash IS 'hash密码';
|
||||
COMMENT ON COLUMN sys_users.status IS '用户状态 0:禁用 1:启用';
|
||||
COMMENT ON COLUMN sys_users.avatar_id IS '头像文件id';
|
||||
COMMENT ON COLUMN sys_users.token_version IS 'Token Version';
|
||||
COMMENT ON COLUMN sys_users.created_at IS '创建时间';
|
||||
COMMENT ON COLUMN sys_users.updated_at IS '更新时间';
|
||||
127
internal/db/sqlc/access_logs.sql.go
Normal file
127
internal/db/sqlc/access_logs.sql.go
Normal file
@@ -0,0 +1,127 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: access_logs.sql
|
||||
|
||||
package sqlc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"time"
|
||||
)
|
||||
|
||||
const countAccessLogs = `-- name: CountAccessLogs :one
|
||||
SELECT COUNT(*)
|
||||
FROM access_logs
|
||||
`
|
||||
|
||||
func (q *Queries) CountAccessLogs(ctx context.Context) (int64, error) {
|
||||
row := q.db.QueryRow(ctx, countAccessLogs)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const createAccessLog = `-- name: CreateAccessLog :exec
|
||||
INSERT INTO access_logs(request_id, user_id, ip, user_agent, request_method, request_path, referer, status_code,
|
||||
response_time_ms, started_at, message)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
`
|
||||
|
||||
type CreateAccessLogParams struct {
|
||||
RequestID *string `json:"request_id"`
|
||||
UserID *int32 `json:"user_id"`
|
||||
Ip *netip.Addr `json:"ip"`
|
||||
UserAgent *string `json:"user_agent"`
|
||||
RequestMethod string `json:"request_method"`
|
||||
RequestPath string `json:"request_path"`
|
||||
Referer *string `json:"referer"`
|
||||
StatusCode *int32 `json:"status_code"`
|
||||
ResponseTimeMs *int32 `json:"response_time_ms"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
Message *string `json:"message"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateAccessLog(ctx context.Context, arg CreateAccessLogParams) error {
|
||||
_, err := q.db.Exec(ctx, createAccessLog,
|
||||
arg.RequestID,
|
||||
arg.UserID,
|
||||
arg.Ip,
|
||||
arg.UserAgent,
|
||||
arg.RequestMethod,
|
||||
arg.RequestPath,
|
||||
arg.Referer,
|
||||
arg.StatusCode,
|
||||
arg.ResponseTimeMs,
|
||||
arg.StartedAt,
|
||||
arg.Message,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const listAccessLogs = `-- name: ListAccessLogs :many
|
||||
SELECT a.id, a.request_id, a.user_id, a.ip, a.user_agent, a.request_method, a.request_path, a.referer, a.message, a.status_code, a.response_time_ms, a.started_at, a.created_at, u.username
|
||||
FROM access_logs a
|
||||
LEFT JOIN sys_users u
|
||||
ON u.id = a.user_id
|
||||
ORDER BY a.started_at DESC, a.id
|
||||
LIMIT $1 OFFSET $2
|
||||
`
|
||||
|
||||
type ListAccessLogsParams struct {
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
type ListAccessLogsRow struct {
|
||||
ID int64 `json:"id"`
|
||||
RequestID *string `json:"request_id"`
|
||||
UserID *int32 `json:"user_id"`
|
||||
Ip *netip.Addr `json:"ip"`
|
||||
UserAgent *string `json:"user_agent"`
|
||||
RequestMethod string `json:"request_method"`
|
||||
RequestPath string `json:"request_path"`
|
||||
Referer *string `json:"referer"`
|
||||
Message *string `json:"message"`
|
||||
StatusCode *int32 `json:"status_code"`
|
||||
ResponseTimeMs *int32 `json:"response_time_ms"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Username *string `json:"username"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListAccessLogs(ctx context.Context, arg ListAccessLogsParams) ([]ListAccessLogsRow, error) {
|
||||
rows, err := q.db.Query(ctx, listAccessLogs, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListAccessLogsRow{}
|
||||
for rows.Next() {
|
||||
var i ListAccessLogsRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.RequestID,
|
||||
&i.UserID,
|
||||
&i.Ip,
|
||||
&i.UserAgent,
|
||||
&i.RequestMethod,
|
||||
&i.RequestPath,
|
||||
&i.Referer,
|
||||
&i.Message,
|
||||
&i.StatusCode,
|
||||
&i.ResponseTimeMs,
|
||||
&i.StartedAt,
|
||||
&i.CreatedAt,
|
||||
&i.Username,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -9,6 +9,41 @@ import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// iteratorForCopyFileImageMetadata implements pgx.CopyFromSource.
|
||||
type iteratorForCopyFileImageMetadata struct {
|
||||
rows []CopyFileImageMetadataParams
|
||||
skippedFirstNextCall bool
|
||||
}
|
||||
|
||||
func (r *iteratorForCopyFileImageMetadata) Next() bool {
|
||||
if len(r.rows) == 0 {
|
||||
return false
|
||||
}
|
||||
if !r.skippedFirstNextCall {
|
||||
r.skippedFirstNextCall = true
|
||||
return true
|
||||
}
|
||||
r.rows = r.rows[1:]
|
||||
return len(r.rows) > 0
|
||||
}
|
||||
|
||||
func (r iteratorForCopyFileImageMetadata) Values() ([]interface{}, error) {
|
||||
return []interface{}{
|
||||
r.rows[0].FileID,
|
||||
r.rows[0].Width,
|
||||
r.rows[0].Height,
|
||||
r.rows[0].Format,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r iteratorForCopyFileImageMetadata) Err() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *Queries) CopyFileImageMetadata(ctx context.Context, arg []CopyFileImageMetadataParams) (int64, error) {
|
||||
return q.db.CopyFrom(ctx, []string{"file_image_metadata"}, []string{"file_id", "width", "height", "format"}, &iteratorForCopyFileImageMetadata{rows: arg})
|
||||
}
|
||||
|
||||
// iteratorForCreatePostTag implements pgx.CopyFromSource.
|
||||
type iteratorForCreatePostTag struct {
|
||||
rows []CreatePostTagParams
|
||||
|
||||
48
internal/db/sqlc/file_image_metadata.sql.go
Normal file
48
internal/db/sqlc/file_image_metadata.sql.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: file_image_metadata.sql
|
||||
|
||||
package sqlc
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type CopyFileImageMetadataParams struct {
|
||||
FileID int32 `json:"file_id"`
|
||||
Width int32 `json:"width"`
|
||||
Height int32 `json:"height"`
|
||||
Format string `json:"format"`
|
||||
}
|
||||
|
||||
const createFileImageMetadata = `-- name: CreateFileImageMetadata :exec
|
||||
INSERT INTO file_image_metadata(file_id, width, height, format)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`
|
||||
|
||||
type CreateFileImageMetadataParams struct {
|
||||
FileID int32 `json:"file_id"`
|
||||
Width int32 `json:"width"`
|
||||
Height int32 `json:"height"`
|
||||
Format string `json:"format"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateFileImageMetadata(ctx context.Context, arg CreateFileImageMetadataParams) error {
|
||||
_, err := q.db.Exec(ctx, createFileImageMetadata,
|
||||
arg.FileID,
|
||||
arg.Width,
|
||||
arg.Height,
|
||||
arg.Format,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const truncateFileImageMetadata = `-- name: TruncateFileImageMetadata :exec
|
||||
TRUNCATE TABLE file_image_metadata
|
||||
`
|
||||
|
||||
func (q *Queries) TruncateFileImageMetadata(ctx context.Context) error {
|
||||
_, err := q.db.Exec(ctx, truncateFileImageMetadata)
|
||||
return err
|
||||
}
|
||||
@@ -7,6 +7,7 @@ package sqlc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
const countFiles = `-- name: CountFiles :one
|
||||
@@ -24,7 +25,7 @@ func (q *Queries) CountFiles(ctx context.Context) (int64, error) {
|
||||
const createFile = `-- name: CreateFile :one
|
||||
INSERT INTO files(file_name, file_path, original_name, folder_name, mime_type, file_size, file_url)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id,file_url,file_name
|
||||
RETURNING id,file_url,file_name,file_size
|
||||
`
|
||||
|
||||
type CreateFileParams struct {
|
||||
@@ -41,6 +42,7 @@ type CreateFileRow struct {
|
||||
ID int32 `json:"id"`
|
||||
FileUrl string `json:"file_url"`
|
||||
FileName string `json:"file_name"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateFile(ctx context.Context, arg CreateFileParams) (CreateFileRow, error) {
|
||||
@@ -54,14 +56,29 @@ func (q *Queries) CreateFile(ctx context.Context, arg CreateFileParams) (CreateF
|
||||
arg.FileUrl,
|
||||
)
|
||||
var i CreateFileRow
|
||||
err := row.Scan(&i.ID, &i.FileUrl, &i.FileName)
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.FileUrl,
|
||||
&i.FileName,
|
||||
&i.FileSize,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listFiles = `-- name: ListFiles :many
|
||||
SELECT id, file_name, file_path, file_url, original_name, folder_name, mime_type, file_size, created_at, updated_at
|
||||
FROM files
|
||||
ORDER BY id
|
||||
SELECT f.id, f.file_name, f.file_path, f.file_url, f.original_name, f.folder_name, f.mime_type, f.file_size, f.created_at, f.updated_at,
|
||||
CASE
|
||||
WHEN m.file_id IS NULL THEN NULL
|
||||
ELSE jsonb_build_object(
|
||||
'width', m.width,
|
||||
'height', m.height,
|
||||
'format', m.format
|
||||
)
|
||||
END AS metadata
|
||||
FROM files f
|
||||
LEFT JOIN file_image_metadata m
|
||||
ON f.id = m.file_id
|
||||
ORDER BY f.id DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`
|
||||
|
||||
@@ -70,15 +87,29 @@ type ListFilesParams struct {
|
||||
Offset int32 `json:"offset"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListFiles(ctx context.Context, arg ListFilesParams) ([]File, error) {
|
||||
type ListFilesRow struct {
|
||||
ID int32 `json:"id"`
|
||||
FileName string `json:"file_name"`
|
||||
FilePath string `json:"file_path"`
|
||||
FileUrl string `json:"file_url"`
|
||||
OriginalName string `json:"original_name"`
|
||||
FolderName string `json:"folder_name"`
|
||||
MimeType string `json:"mime_type"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at"`
|
||||
Metadata interface{} `json:"metadata"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListFiles(ctx context.Context, arg ListFilesParams) ([]ListFilesRow, error) {
|
||||
rows, err := q.db.Query(ctx, listFiles, arg.Limit, arg.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []File{}
|
||||
items := []ListFilesRow{}
|
||||
for rows.Next() {
|
||||
var i File
|
||||
var i ListFilesRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.FileName,
|
||||
@@ -90,6 +121,7 @@ func (q *Queries) ListFiles(ctx context.Context, arg ListFilesParams) ([]File, e
|
||||
&i.FileSize,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Metadata,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -100,3 +132,34 @@ func (q *Queries) ListFiles(ctx context.Context, arg ListFilesParams) ([]File, e
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listImageFiles = `-- name: ListImageFiles :many
|
||||
SELECT id, file_path
|
||||
FROM files
|
||||
WHERE mime_type LIKE 'image/%'
|
||||
`
|
||||
|
||||
type ListImageFilesRow struct {
|
||||
ID int32 `json:"id"`
|
||||
FilePath string `json:"file_path"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListImageFiles(ctx context.Context) ([]ListImageFilesRow, error) {
|
||||
rows, err := q.db.Query(ctx, listImageFiles)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListImageFilesRow{}
|
||||
for rows.Next() {
|
||||
var i ListImageFilesRow
|
||||
if err := rows.Scan(&i.ID, &i.FilePath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
@@ -9,6 +9,22 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type AccessLog struct {
|
||||
ID int64 `json:"id"`
|
||||
RequestID *string `json:"request_id"`
|
||||
UserID *int32 `json:"user_id"`
|
||||
Ip *netip.Addr `json:"ip"`
|
||||
UserAgent *string `json:"user_agent"`
|
||||
RequestMethod string `json:"request_method"`
|
||||
RequestPath string `json:"request_path"`
|
||||
Referer *string `json:"referer"`
|
||||
Message *string `json:"message"`
|
||||
StatusCode *int32 `json:"status_code"`
|
||||
ResponseTimeMs *int32 `json:"response_time_ms"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Category struct {
|
||||
// 分类ID
|
||||
ID int32 `json:"id"`
|
||||
@@ -45,6 +61,15 @@ type File struct {
|
||||
UpdatedAt *time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type FileImageMetadatum struct {
|
||||
ID int32 `json:"id"`
|
||||
FileID int32 `json:"file_id"`
|
||||
Width int32 `json:"width"`
|
||||
Height int32 `json:"height"`
|
||||
Format string `json:"format"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Post struct {
|
||||
// 主键ID
|
||||
ID int32 `json:"id"`
|
||||
@@ -81,7 +106,7 @@ type PostStat struct {
|
||||
// 文章id
|
||||
PostID int32 `json:"post_id"`
|
||||
// 文章阅读量
|
||||
View int32 `json:"view"`
|
||||
ViewCount int32 `json:"view_count"`
|
||||
}
|
||||
|
||||
type PostTag struct {
|
||||
@@ -207,6 +232,8 @@ type SysUser struct {
|
||||
Status int16 `json:"status"`
|
||||
// 头像文件id
|
||||
AvatarID *int32 `json:"avatar_id"`
|
||||
// Token Version
|
||||
TokenVersion int32 `json:"token_version"`
|
||||
// 创建时间
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
// 更新时间
|
||||
|
||||
@@ -7,6 +7,7 @@ package sqlc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -85,13 +86,14 @@ func (q *Queries) DeletePost(ctx context.Context, id int32) (int64, error) {
|
||||
|
||||
const getPostByID = `-- name: GetPostByID :one
|
||||
SELECT p.id, p.title, p.cover_id, p.slug, p.content, p.summary, p.status, p.sort, p.published_at, p.created_at, p.updated_at,
|
||||
f.file_url AS cover,
|
||||
f.file_url AS cover,
|
||||
f.file_size AS cover_size,
|
||||
c.name AS category_name,
|
||||
c.id AS category_id,
|
||||
COALESCE(
|
||||
array_agg(pt.tag_id ORDER BY pt.tag_id) FILTER (WHERE pt.tag_id IS NOT NULL),
|
||||
ARRAY[]::integer[]
|
||||
) AS tags
|
||||
array_agg(pt.tag_id ORDER BY pt.tag_id) FILTER (WHERE pt.tag_id IS NOT NULL),
|
||||
ARRAY []::integer[]
|
||||
) AS tags
|
||||
FROM posts p
|
||||
LEFT JOIN files f ON f.id = p.cover_id
|
||||
LEFT JOIN post_category pc ON pc.post_id = p.id
|
||||
@@ -100,6 +102,7 @@ FROM posts p
|
||||
WHERE p.id = $1
|
||||
GROUP BY p.id,
|
||||
f.file_url,
|
||||
f.file_size,
|
||||
c.name,
|
||||
c.id
|
||||
LIMIT 1
|
||||
@@ -118,6 +121,7 @@ type GetPostByIDRow struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at"`
|
||||
Cover *string `json:"cover"`
|
||||
CoverSize *int64 `json:"cover_size"`
|
||||
CategoryName *string `json:"category_name"`
|
||||
CategoryID *int32 `json:"category_id"`
|
||||
Tags interface{} `json:"tags"`
|
||||
@@ -139,6 +143,7 @@ func (q *Queries) GetPostByID(ctx context.Context, id int32) (GetPostByIDRow, er
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Cover,
|
||||
&i.CoverSize,
|
||||
&i.CategoryName,
|
||||
&i.CategoryID,
|
||||
&i.Tags,
|
||||
@@ -146,14 +151,66 @@ func (q *Queries) GetPostByID(ctx context.Context, id int32) (GetPostByIDRow, er
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getPostsForSitemap = `-- name: GetPostsForSitemap :many
|
||||
SELECT slug, created_at, updated_at
|
||||
FROM posts
|
||||
WHERE status = 1
|
||||
AND published_at < NOW()
|
||||
ORDER BY sort DESC, published_at DESC, id DESC
|
||||
`
|
||||
|
||||
type GetPostsForSitemapRow struct {
|
||||
Slug string `json:"slug"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetPostsForSitemap(ctx context.Context) ([]GetPostsForSitemapRow, error) {
|
||||
rows, err := q.db.Query(ctx, getPostsForSitemap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []GetPostsForSitemapRow{}
|
||||
for rows.Next() {
|
||||
var i GetPostsForSitemapRow
|
||||
if err := rows.Scan(&i.Slug, &i.CreatedAt, &i.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getPublicPostBySlug = `-- name: GetPublicPostBySlug :one
|
||||
|
||||
SELECT p.id, p.title, p.cover_id, p.slug, p.content, p.summary, p.status, p.sort, p.published_at, p.created_at, p.updated_at,
|
||||
f.file_url AS cover,
|
||||
COALESCE(ps.view, 0) AS view
|
||||
SELECT p.id,
|
||||
p.title,
|
||||
p.slug,
|
||||
p.content,
|
||||
p.summary,
|
||||
p.published_at,
|
||||
f.file_url AS cover,
|
||||
COALESCE(ps.view_count, 0) AS view_count,
|
||||
c.code AS category_code,
|
||||
c.name AS category_name,
|
||||
COALESCE(t.tags, '[]') AS tags
|
||||
FROM posts p
|
||||
LEFT JOIN files f ON f.id = p.cover_id
|
||||
LEFT JOIN post_stats ps ON ps.post_id = p.id
|
||||
LEFT JOIN post_category pc ON pc.post_id = p.id
|
||||
LEFT JOIN categories c ON pc.category_id = c.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object('name', tag.name, 'code', tag.code)
|
||||
) AS tags
|
||||
FROM post_tag pt
|
||||
JOIN tags tag ON tag.id = pt.tag_id
|
||||
WHERE pt.post_id = p.id
|
||||
) t ON true
|
||||
WHERE p.slug = $1
|
||||
AND p.status = 1
|
||||
AND p.published_at < NOW()
|
||||
@@ -161,19 +218,17 @@ LIMIT 1
|
||||
`
|
||||
|
||||
type GetPublicPostBySlugRow struct {
|
||||
ID int32 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
CoverID *int32 `json:"cover_id"`
|
||||
Slug string `json:"slug"`
|
||||
Content string `json:"content"`
|
||||
Summary string `json:"summary"`
|
||||
Status int16 `json:"status"`
|
||||
Sort *int32 `json:"sort"`
|
||||
PublishedAt time.Time `json:"published_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at"`
|
||||
Cover *string `json:"cover"`
|
||||
View int32 `json:"view"`
|
||||
ID int32 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
Content string `json:"content"`
|
||||
Summary string `json:"summary"`
|
||||
PublishedAt time.Time `json:"published_at"`
|
||||
Cover *string `json:"cover"`
|
||||
ViewCount int32 `json:"view_count"`
|
||||
CategoryCode *string `json:"category_code"`
|
||||
CategoryName *string `json:"category_name"`
|
||||
Tags json.RawMessage `json:"tags"`
|
||||
}
|
||||
|
||||
// web -------------------------------------------------------
|
||||
@@ -183,37 +238,31 @@ func (q *Queries) GetPublicPostBySlug(ctx context.Context, slug string) (GetPubl
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Title,
|
||||
&i.CoverID,
|
||||
&i.Slug,
|
||||
&i.Content,
|
||||
&i.Summary,
|
||||
&i.Status,
|
||||
&i.Sort,
|
||||
&i.PublishedAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Cover,
|
||||
&i.View,
|
||||
&i.ViewCount,
|
||||
&i.CategoryCode,
|
||||
&i.CategoryName,
|
||||
&i.Tags,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listArchives = `-- name: ListArchives :many
|
||||
SELECT
|
||||
p.id,
|
||||
p.slug,
|
||||
p.title,
|
||||
p.published_at,
|
||||
c."name" AS category_name
|
||||
FROM
|
||||
posts p
|
||||
LEFT JOIN post_category pc ON p.id = pc.post_id
|
||||
LEFT JOIN categories c ON c.id = pc.category_id
|
||||
WHERE
|
||||
p.status = 1
|
||||
SELECT p.id,
|
||||
p.slug,
|
||||
p.title,
|
||||
p.published_at,
|
||||
c."name" AS category_name
|
||||
FROM posts p
|
||||
LEFT JOIN post_category pc ON p.id = pc.post_id
|
||||
LEFT JOIN categories c ON c.id = pc.category_id
|
||||
WHERE p.status = 1
|
||||
AND p.published_at < NOW()
|
||||
ORDER BY
|
||||
p.published_at DESC, p.id DESC
|
||||
ORDER BY p.published_at DESC, p.id DESC
|
||||
`
|
||||
|
||||
type ListArchivesRow struct {
|
||||
@@ -251,26 +300,26 @@ func (q *Queries) ListArchives(ctx context.Context) ([]ListArchivesRow, error) {
|
||||
}
|
||||
|
||||
const listCategoryStats = `-- name: ListCategoryStats :many
|
||||
SELECT
|
||||
c.id,
|
||||
c.name,
|
||||
COUNT(p.id) AS post_count
|
||||
FROM
|
||||
categories c
|
||||
LEFT JOIN post_category pc ON c.id = pc.category_id
|
||||
LEFT JOIN posts p ON p.id = pc.post_id
|
||||
AND p.status = 1
|
||||
AND p.published_at < NOW()
|
||||
GROUP BY
|
||||
c.id,
|
||||
c.sort,
|
||||
c.name
|
||||
SELECT c.id,
|
||||
c.name,
|
||||
c.code,
|
||||
COUNT(p.id) AS post_count
|
||||
FROM categories c
|
||||
LEFT JOIN post_category pc ON c.id = pc.category_id
|
||||
LEFT JOIN posts p ON p.id = pc.post_id
|
||||
AND p.status = 1
|
||||
AND p.published_at < NOW()
|
||||
GROUP BY c.id,
|
||||
c.sort,
|
||||
c.name,
|
||||
c.code
|
||||
ORDER BY c.sort DESC, c.id
|
||||
`
|
||||
|
||||
type ListCategoryStatsRow struct {
|
||||
ID int32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
PostCount int64 `json:"post_count"`
|
||||
}
|
||||
|
||||
@@ -283,7 +332,12 @@ func (q *Queries) ListCategoryStats(ctx context.Context) ([]ListCategoryStatsRow
|
||||
items := []ListCategoryStatsRow{}
|
||||
for rows.Next() {
|
||||
var i ListCategoryStatsRow
|
||||
if err := rows.Scan(&i.ID, &i.Name, &i.PostCount); err != nil {
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.Code,
|
||||
&i.PostCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
@@ -309,24 +363,24 @@ WITH paginated_posts AS (
|
||||
updated_at
|
||||
FROM posts
|
||||
ORDER BY sort DESC, published_at DESC, id DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
)
|
||||
LIMIT $1 OFFSET $2)
|
||||
SELECT p.id, p.title, p.cover_id, p.slug, p.summary, p.status, p.sort, p.published_at, p.created_at, p.updated_at,
|
||||
f.file_url AS cover,
|
||||
c.name AS category_name,
|
||||
c.id AS category_id,
|
||||
COALESCE(ps.view, 0) AS view,
|
||||
f.file_url AS cover,
|
||||
f.file_size AS cover_size,
|
||||
c.name AS category_name,
|
||||
c.id AS category_id,
|
||||
COALESCE(ps.view_count, 0) AS view_count,
|
||||
COALESCE(
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', t.id,
|
||||
'name', t.name,
|
||||
'code', t.code
|
||||
)
|
||||
ORDER BY t.sort DESC, t.id
|
||||
) FILTER (WHERE t.id IS NOT NULL),
|
||||
'[]'::jsonb
|
||||
) AS tags
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', t.id,
|
||||
'name', t.name,
|
||||
'code', t.code
|
||||
)
|
||||
ORDER BY t.sort DESC, t.id
|
||||
) FILTER (WHERE t.id IS NOT NULL),
|
||||
'[]'::jsonb
|
||||
) AS tags
|
||||
FROM paginated_posts p
|
||||
LEFT JOIN files f ON f.id = p.cover_id
|
||||
LEFT JOIN post_category pc ON pc.post_id = p.id
|
||||
@@ -345,9 +399,10 @@ GROUP BY p.id,
|
||||
p.created_at,
|
||||
p.updated_at,
|
||||
f.file_url,
|
||||
f.file_size,
|
||||
c.name,
|
||||
c.id,
|
||||
ps.view
|
||||
ps.view_count
|
||||
ORDER BY p.sort DESC, p.published_at DESC, p.id DESC
|
||||
`
|
||||
|
||||
@@ -368,9 +423,10 @@ type ListPostsRow struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at"`
|
||||
Cover *string `json:"cover"`
|
||||
CoverSize *int64 `json:"cover_size"`
|
||||
CategoryName *string `json:"category_name"`
|
||||
CategoryID *int32 `json:"category_id"`
|
||||
View int32 `json:"view"`
|
||||
ViewCount int32 `json:"view_count"`
|
||||
Tags interface{} `json:"tags"`
|
||||
}
|
||||
|
||||
@@ -396,9 +452,10 @@ func (q *Queries) ListPosts(ctx context.Context, arg ListPostsParams) ([]ListPos
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.Cover,
|
||||
&i.CoverSize,
|
||||
&i.CategoryName,
|
||||
&i.CategoryID,
|
||||
&i.View,
|
||||
&i.ViewCount,
|
||||
&i.Tags,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
@@ -427,21 +484,22 @@ WITH paginated_posts AS (
|
||||
ORDER BY sort DESC, published_at DESC, id DESC
|
||||
LIMIT $1 OFFSET $2)
|
||||
SELECT p.id, p.title, p.cover_id, p.slug, p.summary, p.sort, p.published_at,
|
||||
f.file_url AS cover,
|
||||
c.name AS category_name,
|
||||
c.id AS category_id,
|
||||
COALESCE(ps.view, 0) AS view,
|
||||
f.file_url AS cover,
|
||||
c.name AS category_name,
|
||||
c.id AS category_id,
|
||||
c.code AS category_code,
|
||||
COALESCE(ps.view_count, 0) AS view_count,
|
||||
COALESCE(
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', t.id,
|
||||
'name', t.name,
|
||||
'code', t.code
|
||||
)
|
||||
ORDER BY t.sort DESC, t.id
|
||||
) FILTER (WHERE t.id IS NOT NULL),
|
||||
'[]'::jsonb
|
||||
) AS tags
|
||||
jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', t.id,
|
||||
'name', t.name,
|
||||
'code', t.code
|
||||
)
|
||||
ORDER BY t.sort DESC, t.id
|
||||
) FILTER (WHERE t.id IS NOT NULL),
|
||||
'[]'::jsonb
|
||||
) AS tags
|
||||
FROM paginated_posts p
|
||||
LEFT JOIN files f ON f.id = p.cover_id
|
||||
LEFT JOIN post_category pc ON pc.post_id = p.id
|
||||
@@ -459,7 +517,8 @@ GROUP BY p.id,
|
||||
f.file_url,
|
||||
c.name,
|
||||
c.id,
|
||||
ps.view
|
||||
c.code,
|
||||
ps.view_count
|
||||
ORDER BY p.sort DESC, p.published_at DESC, p.id DESC
|
||||
`
|
||||
|
||||
@@ -479,7 +538,8 @@ type ListPublishedPostsRow struct {
|
||||
Cover *string `json:"cover"`
|
||||
CategoryName *string `json:"category_name"`
|
||||
CategoryID *int32 `json:"category_id"`
|
||||
View int32 `json:"view"`
|
||||
CategoryCode *string `json:"category_code"`
|
||||
ViewCount int32 `json:"view_count"`
|
||||
Tags interface{} `json:"tags"`
|
||||
}
|
||||
|
||||
@@ -503,7 +563,8 @@ func (q *Queries) ListPublishedPosts(ctx context.Context, arg ListPublishedPosts
|
||||
&i.Cover,
|
||||
&i.CategoryName,
|
||||
&i.CategoryID,
|
||||
&i.View,
|
||||
&i.CategoryCode,
|
||||
&i.ViewCount,
|
||||
&i.Tags,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
@@ -516,34 +577,141 @@ func (q *Queries) ListPublishedPosts(ctx context.Context, arg ListPublishedPosts
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listPublishedPostsWithFilters = `-- name: ListPublishedPostsWithFilters :many
|
||||
SELECT p.id,
|
||||
p.title,
|
||||
p.slug,
|
||||
p.summary,
|
||||
p.sort,
|
||||
p.published_at,
|
||||
f.file_url AS cover,
|
||||
c."name" AS category_name,
|
||||
c.code AS category_code,
|
||||
COALESCE(t.tags, '[]') AS tags,
|
||||
COALESCE(ps."view_count", 0) AS view_count
|
||||
FROM posts p
|
||||
LEFT JOIN post_category pc ON pc.post_id = p.id
|
||||
LEFT JOIN categories c ON c.id = pc.category_id
|
||||
LEFT JOIN post_stats ps ON ps.post_id = p.id
|
||||
LEFT JOIN files f ON f.id = p.cover_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object('name', tag.name, 'code', tag.code)
|
||||
) AS tags
|
||||
FROM post_tag pt
|
||||
JOIN tags tag ON tag.id = pt.tag_id
|
||||
WHERE pt.post_id = p.id
|
||||
) t ON true
|
||||
WHERE
|
||||
-- 只显示已发布的文章
|
||||
p.status = 1
|
||||
AND p.published_at < NOW()
|
||||
AND
|
||||
-- category code 过滤(NULL 或空字符串时不过滤)
|
||||
(
|
||||
COALESCE($1::text, '') = ''
|
||||
OR EXISTS (SELECT 1
|
||||
FROM post_category pc2
|
||||
JOIN categories c2 ON c2.id = pc2.category_id
|
||||
WHERE pc2.post_id = p.id
|
||||
AND c2.code = $1::text)
|
||||
)
|
||||
AND
|
||||
-- tag code 过滤(NULL 或空字符串时不过滤)
|
||||
(
|
||||
COALESCE($2::text, '') = ''
|
||||
OR EXISTS (SELECT 1
|
||||
FROM post_tag pt2
|
||||
JOIN tags t2 ON t2.id = pt2.tag_id
|
||||
WHERE pt2.post_id = p.id
|
||||
AND t2.code = $2::text)
|
||||
)
|
||||
ORDER BY p.sort DESC,
|
||||
p.published_at DESC,
|
||||
p.id DESC
|
||||
`
|
||||
|
||||
type ListPublishedPostsWithFiltersParams struct {
|
||||
CategoryCode *string `json:"category_code"`
|
||||
TagCode *string `json:"tag_code"`
|
||||
}
|
||||
|
||||
type ListPublishedPostsWithFiltersRow struct {
|
||||
ID int32 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
Summary string `json:"summary"`
|
||||
Sort *int32 `json:"sort"`
|
||||
PublishedAt time.Time `json:"published_at"`
|
||||
Cover *string `json:"cover"`
|
||||
CategoryName *string `json:"category_name"`
|
||||
CategoryCode *string `json:"category_code"`
|
||||
Tags json.RawMessage `json:"tags"`
|
||||
ViewCount int32 `json:"view_count"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListPublishedPostsWithFilters(ctx context.Context, arg ListPublishedPostsWithFiltersParams) ([]ListPublishedPostsWithFiltersRow, error) {
|
||||
rows, err := q.db.Query(ctx, listPublishedPostsWithFilters, arg.CategoryCode, arg.TagCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListPublishedPostsWithFiltersRow{}
|
||||
for rows.Next() {
|
||||
var i ListPublishedPostsWithFiltersRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Title,
|
||||
&i.Slug,
|
||||
&i.Summary,
|
||||
&i.Sort,
|
||||
&i.PublishedAt,
|
||||
&i.Cover,
|
||||
&i.CategoryName,
|
||||
&i.CategoryCode,
|
||||
&i.Tags,
|
||||
&i.ViewCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updatePost = `-- name: UpdatePost :execrows
|
||||
UPDATE posts
|
||||
SET title = coalesce($1, title),
|
||||
cover_id = coalesce($2, cover_id),
|
||||
slug = coalesce($3, slug),
|
||||
content = coalesce($4, content),
|
||||
summary = coalesce($5, summary),
|
||||
status = coalesce($6, status),
|
||||
sort = coalesce($7, sort),
|
||||
published_at = coalesce($8, published_at)
|
||||
WHERE id = $9
|
||||
cover_id = CASE WHEN $2::boolean THEN $3 ELSE cover_id END,
|
||||
slug = coalesce($4, slug),
|
||||
content = coalesce($5, content),
|
||||
summary = coalesce($6, summary),
|
||||
status = coalesce($7, status),
|
||||
sort = coalesce($8, sort),
|
||||
published_at = coalesce($9, published_at)
|
||||
WHERE id = $10
|
||||
`
|
||||
|
||||
type UpdatePostParams struct {
|
||||
Title *string `json:"title"`
|
||||
CoverID *int32 `json:"cover_id"`
|
||||
Slug *string `json:"slug"`
|
||||
Content *string `json:"content"`
|
||||
Summary *string `json:"summary"`
|
||||
Status *int16 `json:"status"`
|
||||
Sort *int32 `json:"sort"`
|
||||
PublishedAt *time.Time `json:"published_at"`
|
||||
ID int32 `json:"id"`
|
||||
Title *string `json:"title"`
|
||||
UpdateCoverID bool `json:"update_cover_id"`
|
||||
CoverID *int32 `json:"cover_id"`
|
||||
Slug *string `json:"slug"`
|
||||
Content *string `json:"content"`
|
||||
Summary *string `json:"summary"`
|
||||
Status *int16 `json:"status"`
|
||||
Sort *int32 `json:"sort"`
|
||||
PublishedAt *time.Time `json:"published_at"`
|
||||
ID int32 `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdatePost(ctx context.Context, arg UpdatePostParams) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, updatePost,
|
||||
arg.Title,
|
||||
arg.UpdateCoverID,
|
||||
arg.CoverID,
|
||||
arg.Slug,
|
||||
arg.Content,
|
||||
|
||||
@@ -30,7 +30,7 @@ WITH visitor AS (
|
||||
)
|
||||
INSERT INTO post_stats (
|
||||
post_id,
|
||||
view
|
||||
view_count
|
||||
)
|
||||
SELECT
|
||||
post_id,
|
||||
@@ -38,7 +38,7 @@ SELECT
|
||||
FROM visitor
|
||||
ON CONFLICT (post_id)
|
||||
DO UPDATE
|
||||
SET view = post_stats.view + 1
|
||||
SET view_count = post_stats.view_count + 1
|
||||
`
|
||||
|
||||
type IncrementPostStatsViewParams struct {
|
||||
|
||||
@@ -78,34 +78,7 @@ func (q *Queries) DeleteUser(ctx context.Context, id int32) (int64, error) {
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const getActiveUserByID = `-- name: GetActiveUserByID :one
|
||||
SELECT id, account, username, status
|
||||
FROM sys_users
|
||||
WHERE id = $1
|
||||
AND status = 1
|
||||
`
|
||||
|
||||
type GetActiveUserByIDRow struct {
|
||||
ID int32 `json:"id"`
|
||||
Account string `json:"account"`
|
||||
Username string `json:"username"`
|
||||
Status int16 `json:"status"`
|
||||
}
|
||||
|
||||
// 场景:用户登录、获取个人信息、刷新 Token(严格校验 status = 1)
|
||||
func (q *Queries) GetActiveUserByID(ctx context.Context, id int32) (GetActiveUserByIDRow, error) {
|
||||
row := q.db.QueryRow(ctx, getActiveUserByID, id)
|
||||
var i GetActiveUserByIDRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Account,
|
||||
&i.Username,
|
||||
&i.Status,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserByAccount = `-- name: GetUserByAccount :one
|
||||
const getUser = `-- name: GetUser :one
|
||||
SELECT u.id,
|
||||
u.account,
|
||||
u.username,
|
||||
@@ -113,14 +86,21 @@ SELECT u.id,
|
||||
u.status,
|
||||
u.avatar_id,
|
||||
f.file_url AS avatar_url,
|
||||
u.token_version,
|
||||
u.created_at,
|
||||
u.updated_at
|
||||
FROM sys_users u
|
||||
LEFT JOIN files f ON u.avatar_id = f.id
|
||||
WHERE u.account = $1
|
||||
WHERE u.id = $1
|
||||
OR u.account = $2
|
||||
`
|
||||
|
||||
type GetUserByAccountRow struct {
|
||||
type GetUserParams struct {
|
||||
ID int32 `json:"id"`
|
||||
Account string `json:"account"`
|
||||
}
|
||||
|
||||
type GetUserRow struct {
|
||||
ID int32 `json:"id"`
|
||||
Account string `json:"account"`
|
||||
Username string `json:"username"`
|
||||
@@ -128,13 +108,14 @@ type GetUserByAccountRow struct {
|
||||
Status int16 `json:"status"`
|
||||
AvatarID *int32 `json:"avatar_id"`
|
||||
AvatarUrl *string `json:"avatar_url"`
|
||||
TokenVersion int32 `json:"token_version"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetUserByAccount(ctx context.Context, account string) (GetUserByAccountRow, error) {
|
||||
row := q.db.QueryRow(ctx, getUserByAccount, account)
|
||||
var i GetUserByAccountRow
|
||||
func (q *Queries) GetUser(ctx context.Context, arg GetUserParams) (GetUserRow, error) {
|
||||
row := q.db.QueryRow(ctx, getUser, arg.ID, arg.Account)
|
||||
var i GetUserRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Account,
|
||||
@@ -143,53 +124,53 @@ func (q *Queries) GetUserByAccount(ctx context.Context, account string) (GetUser
|
||||
&i.Status,
|
||||
&i.AvatarID,
|
||||
&i.AvatarUrl,
|
||||
&i.TokenVersion,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserByID = `-- name: GetUserByID :one
|
||||
SELECT u.id,
|
||||
u.account,
|
||||
u.username,
|
||||
u.avatar_id,
|
||||
u.status,
|
||||
f.file_url AS avatar_url,
|
||||
u.created_at,
|
||||
u.updated_at
|
||||
FROM sys_users u
|
||||
LEFT JOIN files f ON u.avatar_id = f.id
|
||||
WHERE u.id = $1
|
||||
const getUserAuthState = `-- name: GetUserAuthState :one
|
||||
SELECT status,
|
||||
token_version
|
||||
FROM sys_users
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
type GetUserByIDRow struct {
|
||||
ID int32 `json:"id"`
|
||||
Account string `json:"account"`
|
||||
Username string `json:"username"`
|
||||
AvatarID *int32 `json:"avatar_id"`
|
||||
Status int16 `json:"status"`
|
||||
AvatarUrl *string `json:"avatar_url"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt *time.Time `json:"updated_at"`
|
||||
type GetUserAuthStateRow struct {
|
||||
Status int16 `json:"status"`
|
||||
TokenVersion int32 `json:"token_version"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetUserByID(ctx context.Context, id int32) (GetUserByIDRow, error) {
|
||||
row := q.db.QueryRow(ctx, getUserByID, id)
|
||||
var i GetUserByIDRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Account,
|
||||
&i.Username,
|
||||
&i.AvatarID,
|
||||
&i.Status,
|
||||
&i.AvatarUrl,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
func (q *Queries) GetUserAuthState(ctx context.Context, id int32) (GetUserAuthStateRow, error) {
|
||||
row := q.db.QueryRow(ctx, getUserAuthState, id)
|
||||
var i GetUserAuthStateRow
|
||||
err := row.Scan(&i.Status, &i.TokenVersion)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const incrementTokenVersionForAllUsers = `-- name: IncrementTokenVersionForAllUsers :exec
|
||||
UPDATE sys_users
|
||||
SET token_version = token_version + 1
|
||||
`
|
||||
|
||||
func (q *Queries) IncrementTokenVersionForAllUsers(ctx context.Context) error {
|
||||
_, err := q.db.Exec(ctx, incrementTokenVersionForAllUsers)
|
||||
return err
|
||||
}
|
||||
|
||||
const incrementUserTokenVersion = `-- name: IncrementUserTokenVersion :exec
|
||||
UPDATE sys_users
|
||||
SET token_version = token_version + 1
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) IncrementUserTokenVersion(ctx context.Context, id int32) error {
|
||||
_, err := q.db.Exec(ctx, incrementUserTokenVersion, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const listAdminMenus = `-- name: ListAdminMenus :many
|
||||
SELECT id, name, path, component, type, hidden, sort, status, parent_id, icon, created_at, updated_at
|
||||
FROM sys_menus
|
||||
|
||||
48
internal/handler/admin/access_log.go
Normal file
48
internal/handler/admin/access_log.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"server/internal/model/common"
|
||||
"server/internal/pkg/httputil"
|
||||
"server/internal/router"
|
||||
"server/internal/service/admin"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type AccessLogHandler struct {
|
||||
accessLogService *admin.AccessLogService
|
||||
}
|
||||
|
||||
var _ router.Registrar = (*AccessLogHandler)(nil)
|
||||
|
||||
func NewAccessLogHandler(accessLogService *admin.AccessLogService) *AccessLogHandler {
|
||||
return &AccessLogHandler{
|
||||
accessLogService: accessLogService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AccessLogHandler) Register(r chi.Router) {
|
||||
r.Route("/access-logs", func(r chi.Router) {
|
||||
r.Get("/", h.List)
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AccessLogHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
pagination := httputil.Pagination(r)
|
||||
|
||||
result, err := h.accessLogService.List(r.Context(), pagination)
|
||||
if err != nil {
|
||||
httputil.Fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp := common.PageResponse{
|
||||
Page: pagination.Page,
|
||||
PageSize: pagination.PageSize,
|
||||
List: result.List,
|
||||
Total: result.Total,
|
||||
}
|
||||
|
||||
httputil.OkWithPage(w, &resp)
|
||||
}
|
||||
@@ -84,7 +84,7 @@ func (h *ApiHandler) ListApiGroups(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *ApiHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
var req request.CreateApiRequest
|
||||
|
||||
if err := httputil.BindJson(r, &req); err != nil {
|
||||
if err := httputil.BindJson(w, r, &req); err != nil {
|
||||
httputil.Fail(w, err)
|
||||
return
|
||||
}
|
||||
@@ -106,7 +106,7 @@ func (h *ApiHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err = httputil.BindJson(r, &req); err != nil {
|
||||
if err = httputil.BindJson(w, r, &req); err != nil {
|
||||
httputil.Fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"server/internal/config"
|
||||
"server/internal/model/request"
|
||||
@@ -48,7 +49,7 @@ func (h *AuthHandler) clearRefreshTokenCookie(w http.ResponseWriter) {
|
||||
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var req request.LoginRequest
|
||||
|
||||
if err := httputil.BindJson(r, &req); err != nil {
|
||||
if err := httputil.BindJson(w, r, &req); err != nil {
|
||||
httputil.Fail(w, errs.ErrInvalidCredentials)
|
||||
return
|
||||
}
|
||||
@@ -95,9 +96,12 @@ func (h *AuthHandler) RefreshToken(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
res, err := h.authService.RefreshToken(r.Context(), cookie.Value)
|
||||
if err != nil {
|
||||
// 用户被禁用 调用logout
|
||||
_ = h.authService.Logout(r.Context(), cookie.Value)
|
||||
h.clearRefreshTokenCookie(w)
|
||||
|
||||
if errors.Is(err, errs.ErrInvalidRefreshToken) {
|
||||
// 用户被禁用 调用logout
|
||||
_ = h.authService.Logout(r.Context(), cookie.Value)
|
||||
h.clearRefreshTokenCookie(w)
|
||||
}
|
||||
httputil.Fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ func (h *CategoryHandler) ListAll(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *CategoryHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
var req request.CreateCategoryRequest
|
||||
|
||||
if err := httputil.BindJson(r, &req); err != nil {
|
||||
if err := httputil.BindJson(w, r, &req); err != nil {
|
||||
httputil.Fail(w, err)
|
||||
return
|
||||
}
|
||||
@@ -82,7 +82,7 @@ func (h *CategoryHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var req request.UpdateCategoryRequest
|
||||
if err = httputil.BindJson(r, &req); err != nil {
|
||||
if err = httputil.BindJson(w, r, &req); err != nil {
|
||||
httputil.Fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,24 +1,37 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"server/internal/config"
|
||||
"server/internal/model/common"
|
||||
"server/internal/model/request"
|
||||
"server/internal/pkg/errs"
|
||||
"server/internal/pkg/httputil"
|
||||
"server/internal/pkg/safego"
|
||||
"server/internal/pkg/validator"
|
||||
"server/internal/router"
|
||||
"server/internal/service/admin"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type FileHandler struct {
|
||||
service *admin.FileService
|
||||
logger *slog.Logger
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
var _ router.Registrar = (*FileHandler)(nil)
|
||||
|
||||
func NewFileHandler(service *admin.FileService) *FileHandler {
|
||||
func NewFileHandler(service *admin.FileService, logger *slog.Logger, cfg *config.Config) *FileHandler {
|
||||
return &FileHandler{
|
||||
service: service,
|
||||
logger: logger,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +39,7 @@ func (h *FileHandler) Register(r chi.Router) {
|
||||
r.Route("/files", func(r chi.Router) {
|
||||
r.Get("/", h.List)
|
||||
r.Post("/", h.Upload)
|
||||
r.Post("/sync-metadata", h.SyncMetadata)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -49,9 +63,18 @@ func (h *FileHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *FileHandler) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
// 限制上传大小(默认 10MB)
|
||||
maxSize := h.cfg.File.MaxUploadSize
|
||||
if maxSize <= 0 {
|
||||
maxSize = 10 << 20 // 10MB
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxSize)
|
||||
|
||||
folder := r.FormValue("folder")
|
||||
if folder == "" {
|
||||
folder = "/"
|
||||
req := request.UploadFileRequest{Folder: folder}
|
||||
if err := validator.Struct(&req); err != nil {
|
||||
httputil.Fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
_, header, err := r.FormFile("file")
|
||||
@@ -60,6 +83,13 @@ func (h *FileHandler) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// 扩展名白名单
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
if !request.IsAllowedUploadExt(ext) {
|
||||
httputil.Fail(w, errs.ErrFileTypeNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
file, err := h.service.Upload(r.Context(), folder, header)
|
||||
if err != nil {
|
||||
httputil.Fail(w, err)
|
||||
@@ -68,3 +98,13 @@ func (h *FileHandler) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
httputil.Ok(w, file)
|
||||
}
|
||||
|
||||
func (h *FileHandler) SyncMetadata(w http.ResponseWriter, r *http.Request) {
|
||||
safego.Go(func() {
|
||||
if err := h.service.SyncMetadata(context.Background()); err != nil {
|
||||
h.logger.Error("sync metadata failed", "error", err)
|
||||
}
|
||||
})
|
||||
|
||||
httputil.Ok(w)
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ func (h *MenuHandler) ListAll(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *MenuHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
var req request.CreateMenuRequest
|
||||
|
||||
if err := httputil.BindJson(r, &req); err != nil {
|
||||
if err := httputil.BindJson(w, r, &req); err != nil {
|
||||
httputil.Fail(w, err)
|
||||
return
|
||||
}
|
||||
@@ -85,7 +85,7 @@ func (h *MenuHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err = httputil.BindJson(r, &req); err != nil {
|
||||
if err = httputil.BindJson(w, r, &req); err != nil {
|
||||
httputil.Fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -20,5 +20,6 @@ var Module = fx.Module("admin-handlers",
|
||||
router.AsRegistrar(router.AdminRoutes, NewTagHandler),
|
||||
router.AsRegistrar(router.AdminRoutes, NewUserHandler),
|
||||
router.AsRegistrar(router.AdminRoutes, NewSystemHandler),
|
||||
router.AsRegistrar(router.AdminRoutes, NewAccessLogHandler),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -37,6 +37,7 @@ func (h *PostHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := h.postService.List(r.Context(), pagination)
|
||||
if err != nil {
|
||||
httputil.Fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp := common.PageResponse{
|
||||
@@ -68,7 +69,7 @@ func (h *PostHandler) GetPostByID(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *PostHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
var req request.CreatePostRequest
|
||||
|
||||
if err := httputil.BindJson(r, &req); err != nil {
|
||||
if err := httputil.BindJson(w, r, &req); err != nil {
|
||||
httputil.Fail(w, err)
|
||||
return
|
||||
}
|
||||
@@ -94,7 +95,7 @@ func (h *PostHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var req request.UpdatePostRequest
|
||||
|
||||
if err = httputil.BindJson(r, &req); err != nil {
|
||||
if err = httputil.BindJson(w, r, &req); err != nil {
|
||||
httputil.Fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ func (h *RoleHandler) ListAll(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *RoleHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
var req request.CreateRoleRequest
|
||||
|
||||
if err := httputil.BindJson(r, &req); err != nil {
|
||||
if err := httputil.BindJson(w, r, &req); err != nil {
|
||||
httputil.Fail(w, err)
|
||||
return
|
||||
}
|
||||
@@ -122,7 +122,7 @@ func (h *RoleHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err = httputil.BindJson(r, &req); err != nil {
|
||||
if err = httputil.BindJson(w, r, &req); err != nil {
|
||||
httputil.Fail(w, err)
|
||||
return
|
||||
}
|
||||
@@ -144,7 +144,7 @@ func (h *RoleHandler) SetRoleMenus(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err = httputil.BindJson(r, &req); err != nil {
|
||||
if err = httputil.BindJson(w, r, &req); err != nil {
|
||||
httputil.Fail(w, err)
|
||||
return
|
||||
}
|
||||
@@ -166,7 +166,7 @@ func (h *RoleHandler) SetRoleApis(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err = httputil.BindJson(r, &req); err != nil {
|
||||
if err = httputil.BindJson(w, r, &req); err != nil {
|
||||
httputil.Fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ func (h *TagHandler) ListAll(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *TagHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
var req request.CreateTagRequest
|
||||
|
||||
if err := httputil.BindJson(r, &req); err != nil {
|
||||
if err := httputil.BindJson(w, r, &req); err != nil {
|
||||
httputil.Fail(w, err)
|
||||
return
|
||||
}
|
||||
@@ -86,7 +86,7 @@ func (h *TagHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var req request.UpdateTagRequest
|
||||
|
||||
if err = httputil.BindJson(r, &req); err != nil {
|
||||
if err = httputil.BindJson(w, r, &req); err != nil {
|
||||
httputil.Fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ func (h *UserHandler) Register(r chi.Router) {
|
||||
r.Get("/me", h.GetCurrentUser)
|
||||
r.Get("/{id}/roles", h.ListRoles)
|
||||
r.Post("/", h.Create)
|
||||
r.Post("/{id}/logout-all", h.KickUser)
|
||||
r.Post("/logout-all", h.KickAllUsers)
|
||||
r.Patch("/{id}", h.Update)
|
||||
r.Put("/{id}/roles", h.SetRoles)
|
||||
r.Patch("/{id}/password", h.UpdatePassword)
|
||||
@@ -40,17 +42,14 @@ func (h *UserHandler) Register(r chi.Router) {
|
||||
}
|
||||
|
||||
func (h *UserHandler) GetCurrentUser(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := middleware.GetClaims(r.Context())
|
||||
userCtx := middleware.GetUserContext(r.Context())
|
||||
|
||||
if !ok || claims.UserID == 0 {
|
||||
if userCtx == nil || userCtx.UserID == 0 {
|
||||
httputil.Fail(w, errs.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
isAdmin := middleware.IsAdmin(r.Context())
|
||||
uid := claims.UserID
|
||||
|
||||
user, err := h.userService.GetCurrentUser(r.Context(), uid, isAdmin)
|
||||
user, err := h.userService.GetCurrentUser(r.Context(), userCtx.UserID, userCtx.IsAdmin)
|
||||
if err != nil {
|
||||
httputil.Fail(w, err)
|
||||
return
|
||||
@@ -102,7 +101,7 @@ func (h *UserHandler) ListRoles(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
var req request.CreateUserRequest
|
||||
|
||||
if err := httputil.BindJson(r, &req); err != nil {
|
||||
if err := httputil.BindJson(w, r, &req); err != nil {
|
||||
httputil.Fail(w, err)
|
||||
return
|
||||
}
|
||||
@@ -115,6 +114,32 @@ func (h *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
httputil.Ok(w)
|
||||
}
|
||||
|
||||
func (h *UserHandler) KickUser(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := httputil.URLParamInt32(r, "id")
|
||||
if err != nil {
|
||||
httputil.Fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.userService.KickUser(r.Context(), id)
|
||||
if err != nil {
|
||||
httputil.Fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
httputil.Ok(w)
|
||||
}
|
||||
|
||||
func (h *UserHandler) KickAllUsers(w http.ResponseWriter, r *http.Request) {
|
||||
err := h.userService.KickAllUsers(r.Context())
|
||||
if err != nil {
|
||||
httputil.Fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
httputil.Ok(w)
|
||||
}
|
||||
|
||||
func (h *UserHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
var req request.UpdateUserRequest
|
||||
|
||||
@@ -124,7 +149,7 @@ func (h *UserHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err = httputil.BindJson(r, &req); err != nil {
|
||||
if err = httputil.BindJson(w, r, &req); err != nil {
|
||||
httputil.Fail(w, err)
|
||||
return
|
||||
}
|
||||
@@ -146,7 +171,7 @@ func (h *UserHandler) SetRoles(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err = httputil.BindJson(r, &req); err != nil {
|
||||
if err = httputil.BindJson(w, r, &req); err != nil {
|
||||
httputil.Fail(w, err)
|
||||
return
|
||||
}
|
||||
@@ -169,7 +194,7 @@ func (h *UserHandler) UpdatePassword(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if err = httputil.BindJson(r, &req); err != nil {
|
||||
if err = httputil.BindJson(w, r, &req); err != nil {
|
||||
httputil.Fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ package site
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"server/internal/middleware"
|
||||
"server/internal/model/common"
|
||||
"server/internal/model/request"
|
||||
"server/internal/pkg/errs"
|
||||
"server/internal/pkg/httputil"
|
||||
"server/internal/router"
|
||||
@@ -19,21 +20,23 @@ type PostHandler struct {
|
||||
}
|
||||
|
||||
func (h *PostHandler) Register(r chi.Router) {
|
||||
r.Get("/posts", h.ListPage)
|
||||
r.Get("/posts/{slug}", h.GetPost)
|
||||
r.Get("/categories/stats", h.ListCategoryStats)
|
||||
r.Get("/posts", h.List)
|
||||
r.Get("/posts/search", h.ListSearch)
|
||||
r.Get("/posts/archives", h.ListArchives)
|
||||
r.Get("/posts/tags", h.ListPostTags)
|
||||
r.Get("/posts/{slug}", h.GetPost)
|
||||
r.Get("/categories/stats", h.ListCategoryStats)
|
||||
r.Get("/posts/sitemap", h.GetPostsForSitemap)
|
||||
}
|
||||
|
||||
func NewPostHandler(postService *web.PostService) *PostHandler {
|
||||
return &PostHandler{postService: postService}
|
||||
}
|
||||
|
||||
func (h *PostHandler) ListPage(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *PostHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
pagination := httputil.Pagination(r)
|
||||
|
||||
result, err := h.postService.ListPage(r.Context(), pagination)
|
||||
result, err := h.postService.List(r.Context(), pagination)
|
||||
if err != nil {
|
||||
httputil.Fail(w, err)
|
||||
return
|
||||
@@ -49,21 +52,36 @@ func (h *PostHandler) ListPage(w http.ResponseWriter, r *http.Request) {
|
||||
httputil.OkWithPage(w, &resp)
|
||||
}
|
||||
|
||||
func (h *PostHandler) ListSearch(w http.ResponseWriter, r *http.Request) {
|
||||
params := request.SearchPublishedPostsParams{
|
||||
CategoryCode: r.URL.Query().Get("category_code"),
|
||||
TagCode: r.URL.Query().Get("tag_code"),
|
||||
}
|
||||
|
||||
list, err := h.postService.ListSearch(r.Context(), params)
|
||||
if err != nil {
|
||||
httputil.Fail(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
httputil.Ok(w, list)
|
||||
}
|
||||
|
||||
func (h *PostHandler) GetPost(w http.ResponseWriter, r *http.Request) {
|
||||
slug := chi.URLParam(r, "slug")
|
||||
reqCtx := middleware.GetRequestContext(r.Context())
|
||||
|
||||
if slug == "" {
|
||||
httputil.Fail(w, errs.ErrSlugRequired)
|
||||
return
|
||||
}
|
||||
|
||||
ip, err := netip.ParseAddr(httputil.ClientIP(r))
|
||||
if err != nil {
|
||||
httputil.Fail(w, err)
|
||||
if reqCtx == nil {
|
||||
httputil.Fail(w, errs.ErrInternalServer)
|
||||
return
|
||||
}
|
||||
|
||||
post, err := h.postService.GetPost(r.Context(), slug, ip)
|
||||
post, err := h.postService.GetPost(r.Context(), slug, reqCtx.ClientIp)
|
||||
if err != nil {
|
||||
httputil.Fail(w, err)
|
||||
return
|
||||
@@ -101,3 +119,12 @@ func (h *PostHandler) ListPostTags(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
httputil.Ok(w, list)
|
||||
}
|
||||
|
||||
func (h *PostHandler) GetPostsForSitemap(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := h.postService.GetPostsForSitemap(r.Context())
|
||||
if err != nil {
|
||||
httputil.Fail(w, err)
|
||||
return
|
||||
}
|
||||
httputil.Ok(w, list)
|
||||
}
|
||||
|
||||
58
internal/middleware/access_log.go
Normal file
58
internal/middleware/access_log.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"server/internal/db"
|
||||
"server/internal/db/sqlc"
|
||||
"server/internal/pkg/safego"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AccessLogMiddleware struct {
|
||||
store *db.Store
|
||||
}
|
||||
|
||||
func NewAccessLogMiddleware(store *db.Store) *AccessLogMiddleware {
|
||||
return &AccessLogMiddleware{
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *AccessLogMiddleware) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
reqCtx := GetRequestContext(r.Context())
|
||||
userCtx := GetUserContext(r.Context())
|
||||
|
||||
rw := NewResponseRecorder(w)
|
||||
next.ServeHTTP(rw, r)
|
||||
|
||||
// 写入数据库时是null
|
||||
userID := &userCtx.UserID
|
||||
if userCtx.UserID == 0 {
|
||||
userID = nil
|
||||
}
|
||||
|
||||
durationMs := int32(time.Since(reqCtx.StartTime).Milliseconds())
|
||||
statusCode := int32(rw.statusCode)
|
||||
|
||||
// 异步写入
|
||||
safego.Go(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
_ = m.store.Queries.CreateAccessLog(ctx, sqlc.CreateAccessLogParams{
|
||||
RequestID: &reqCtx.RequestID,
|
||||
UserID: userID,
|
||||
Ip: &reqCtx.ClientIp,
|
||||
UserAgent: &reqCtx.UserAgent,
|
||||
RequestMethod: reqCtx.Method,
|
||||
RequestPath: reqCtx.Path,
|
||||
Message: &rw.errorMsg,
|
||||
Referer: &reqCtx.Referer,
|
||||
StatusCode: &statusCode,
|
||||
ResponseTimeMs: &durationMs,
|
||||
StartedAt: reqCtx.StartTime,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -2,47 +2,35 @@ package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"server/internal/config"
|
||||
"server/internal/db"
|
||||
"server/internal/db/sqlc"
|
||||
"server/internal/model/auth"
|
||||
"server/internal/pkg/cache"
|
||||
"server/internal/pkg/cache/cachekey"
|
||||
"server/internal/pkg/errs"
|
||||
"server/internal/pkg/httputil"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
IsAdminKey contextKey = "is_admin"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type AuthMiddleware struct {
|
||||
store *db.Store
|
||||
cache *cache.Caches
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewAuthMiddleware(store *db.Store, cache *cache.Caches) *AuthMiddleware {
|
||||
func NewAuthMiddleware(store *db.Store, cache *cache.Caches, cfg *config.Config) *AuthMiddleware {
|
||||
return &AuthMiddleware{
|
||||
cache: cache,
|
||||
store: store,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func UserIsAdmin(uid int32) bool {
|
||||
if uid == 1 {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func IsAdmin(ctx context.Context) bool {
|
||||
isAdmin, ok := ctx.Value(IsAdminKey).(bool)
|
||||
return ok && isAdmin
|
||||
}
|
||||
|
||||
func (m *AuthMiddleware) hasApiPermission(ctx context.Context, uid int32, requestMethod string, requestPath string) (bool, error) {
|
||||
var (
|
||||
apis []sqlc.ListUserApisRow
|
||||
@@ -72,22 +60,49 @@ func (m *AuthMiddleware) hasApiPermission(ctx context.Context, uid int32, reques
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (m *AuthMiddleware) getAuthState(ctx context.Context, id int32) (*sqlc.GetUserAuthStateRow, error) {
|
||||
return cache.GetOrSetJSON[*sqlc.GetUserAuthStateRow](ctx, m.cache, cachekey.UserAuthState(id), m.cfg.JWTConfig.Expire, func() (*sqlc.GetUserAuthStateRow, error) {
|
||||
state, err := m.store.GetUserAuthState(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &state, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (m *AuthMiddleware) Middleware(router chi.Router) func(handler http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
claims, ok := GetClaims(ctx)
|
||||
if !ok || claims.UserID == 0 {
|
||||
userCtx := GetUserContext(ctx)
|
||||
|
||||
if userCtx == nil || userCtx.UserID == 0 {
|
||||
httputil.Fail(w, errs.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// 判断token version
|
||||
state, err := m.getAuthState(ctx, userCtx.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
httputil.Fail(w, errs.ErrInvalidToken) // 用户被删
|
||||
return
|
||||
}
|
||||
httputil.Fail(w, errs.ErrInternalServer) // 基础设施故障,不杀会话
|
||||
return
|
||||
}
|
||||
|
||||
if state.TokenVersion != userCtx.TokenVersion {
|
||||
httputil.Fail(w, errs.ErrInvalidToken) // 被踢 → 401
|
||||
return
|
||||
}
|
||||
|
||||
// 判断是否有管理员权限
|
||||
isAdmin := UserIsAdmin(claims.UserID)
|
||||
isAdmin := auth.IsAdmin(userCtx.UserID)
|
||||
userCtx.IsAdmin = isAdmin
|
||||
|
||||
if isAdmin {
|
||||
ctx = context.WithValue(ctx, IsAdminKey, isAdmin)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
@@ -97,9 +112,8 @@ func (m *AuthMiddleware) Middleware(router chi.Router) func(handler http.Handler
|
||||
requestMethod := r.Method
|
||||
// https://github.com/go-chi/chi/pull/872
|
||||
api := router.Find(rctx, requestMethod, r.URL.Path)
|
||||
requestPath := strings.TrimPrefix(api, "/api")
|
||||
|
||||
hasPermission, err := m.hasApiPermission(ctx, claims.UserID, requestMethod, requestPath)
|
||||
hasPermission, err := m.hasApiPermission(ctx, userCtx.UserID, requestMethod, api)
|
||||
if err != nil {
|
||||
httputil.Fail(w, err)
|
||||
return
|
||||
|
||||
57
internal/middleware/context.go
Normal file
57
internal/middleware/context.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"time"
|
||||
)
|
||||
|
||||
type requestContextKey struct{}
|
||||
|
||||
type RequestContext struct {
|
||||
RequestID string
|
||||
StartTime time.Time
|
||||
Method string
|
||||
Path string
|
||||
ClientIp netip.Addr
|
||||
UserAgent string
|
||||
Referer string
|
||||
}
|
||||
|
||||
func WithRequestContext(ctx context.Context, req *RequestContext) context.Context {
|
||||
return context.WithValue(ctx, requestContextKey{}, req)
|
||||
}
|
||||
|
||||
func GetRequestContext(ctx context.Context) *RequestContext {
|
||||
v, ok := ctx.Value(requestContextKey{}).(*RequestContext)
|
||||
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------------------
|
||||
|
||||
type userContextKey struct{}
|
||||
|
||||
type UserContext struct {
|
||||
UserID int32
|
||||
IsAdmin bool
|
||||
TokenVersion int32
|
||||
}
|
||||
|
||||
func WithUserContext(ctx context.Context, user *UserContext) context.Context {
|
||||
return context.WithValue(ctx, userContextKey{}, user)
|
||||
}
|
||||
|
||||
func GetUserContext(ctx context.Context) *UserContext {
|
||||
v, ok := ctx.Value(userContextKey{}).(*UserContext)
|
||||
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"server/internal/config"
|
||||
"server/internal/pkg/errs"
|
||||
@@ -19,32 +19,42 @@ type JWTMiddleware struct {
|
||||
cfg *config.JWTConfig
|
||||
}
|
||||
|
||||
type contextKey string
|
||||
|
||||
const UserContextKey contextKey = "user"
|
||||
|
||||
const RefreshTokenType = "refresh"
|
||||
|
||||
type Claims struct {
|
||||
UserID int32 `json:"user_id"`
|
||||
UserID int32 `json:"user_id"`
|
||||
TokenVersion int32 `json:"token_version"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type RefreshClaims struct {
|
||||
UserID int32 `json:"user_id"`
|
||||
Type string `json:"type"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func NewJWTMiddleware(cfg *config.Config) *JWTMiddleware {
|
||||
func NewJWTMiddleware(cfg *config.Config) (*JWTMiddleware, error) {
|
||||
jwtCfg := cfg.JWTConfig
|
||||
|
||||
return &JWTMiddleware{cfg: &jwtCfg}
|
||||
// 不允许空密钥
|
||||
if jwtCfg.Secret == "" {
|
||||
return nil, fmt.Errorf("jwt.secret 未配置:请在 %s.yaml 中设置 jwt.secret", config.GetEnv())
|
||||
}
|
||||
|
||||
m := &JWTMiddleware{cfg: &jwtCfg}
|
||||
|
||||
// 启动时校验签名算法配置
|
||||
if _, err := m.signingMethod(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func GetClaims(ctx context.Context) (*Claims, bool) {
|
||||
claims, ok := ctx.Value(UserContextKey).(*Claims)
|
||||
return claims, ok
|
||||
// signingMethod 返回配置的签名算法(仅支持 HMAC 家族)
|
||||
func (m *JWTMiddleware) signingMethod() (jwt.SigningMethod, error) {
|
||||
switch strings.ToUpper(m.cfg.SigningMethod) {
|
||||
case "", "HS256":
|
||||
return jwt.SigningMethodHS256, nil
|
||||
case "HS384":
|
||||
return jwt.SigningMethodHS384, nil
|
||||
case "HS512":
|
||||
return jwt.SigningMethodHS512, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("不支持的 jwt.signing_method: %q(仅支持 HS256/HS384/HS512)", m.cfg.SigningMethod)
|
||||
}
|
||||
}
|
||||
|
||||
// ParseToken 解析accessToken
|
||||
@@ -52,7 +62,7 @@ func (m *JWTMiddleware) ParseToken(tokenStr string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(
|
||||
tokenStr,
|
||||
&Claims{},
|
||||
func(token *jwt.Token) (interface{}, error) {
|
||||
func(token *jwt.Token) (any, error) {
|
||||
return []byte(m.cfg.Secret), nil
|
||||
},
|
||||
)
|
||||
@@ -96,25 +106,34 @@ func (m *JWTMiddleware) Middleware(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), UserContextKey, claims)
|
||||
userCtx := GetUserContext(r.Context())
|
||||
userCtx.UserID = claims.UserID
|
||||
userCtx.TokenVersion = claims.TokenVersion
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *JWTMiddleware) GenerateAccessToken(userID int32) (string, time.Time, error) {
|
||||
func (m *JWTMiddleware) GenerateAccessToken(userID int32, tokenVersion int32) (string, time.Time, error) {
|
||||
method, err := m.signingMethod()
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
expiresAt := now.Add(m.cfg.Expire)
|
||||
|
||||
accessClaims := Claims{
|
||||
UserID: userID,
|
||||
UserID: userID,
|
||||
TokenVersion: tokenVersion,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(expiresAt),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
},
|
||||
}
|
||||
|
||||
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
|
||||
// 使用配置的签名算法,与 ParseToken 的校验保持一致
|
||||
accessToken := jwt.NewWithClaims(method, accessClaims)
|
||||
token, err := accessToken.SignedString([]byte(m.cfg.Secret))
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
|
||||
@@ -1,100 +1,44 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"server/internal/model/common"
|
||||
"server/internal/pkg/httputil"
|
||||
"time"
|
||||
|
||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||
)
|
||||
|
||||
type LoggerMiddleware struct {
|
||||
}
|
||||
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
bytes int
|
||||
errorMsg string
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(code int) {
|
||||
rw.statusCode = code
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func generateRequestID() string {
|
||||
id, _ := gonanoid.New(16) // 16字符
|
||||
return id
|
||||
}
|
||||
|
||||
// extractErrorMessage 从响应体中提取错误信息
|
||||
func (rw *responseWriter) extractErrorMessage(body []byte) string {
|
||||
var resp common.Response
|
||||
if err := json.Unmarshal(body, &resp); err == nil && resp.Message != "" {
|
||||
return resp.Message
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
|
||||
func (rw *responseWriter) Write(b []byte) (int, error) {
|
||||
n, err := rw.ResponseWriter.Write(b)
|
||||
rw.bytes += n
|
||||
|
||||
// 只在错误状态码且未记录错误时处理
|
||||
if rw.statusCode >= 400 && rw.errorMsg == "" && n > 0 {
|
||||
rw.errorMsg = rw.extractErrorMessage(b[:n])
|
||||
}
|
||||
|
||||
return n, err
|
||||
}
|
||||
|
||||
func NewLoggerMiddleware() *LoggerMiddleware {
|
||||
return &LoggerMiddleware{}
|
||||
}
|
||||
|
||||
func (m *LoggerMiddleware) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
reqCtx := GetRequestContext(r.Context())
|
||||
|
||||
reqID := generateRequestID()
|
||||
rw := NewResponseRecorder(w)
|
||||
|
||||
wrapped := &responseWriter{
|
||||
ResponseWriter: w,
|
||||
statusCode: http.StatusOK,
|
||||
}
|
||||
|
||||
next.ServeHTTP(wrapped, r)
|
||||
|
||||
duration := time.Since(start)
|
||||
|
||||
ip := httputil.ClientIP(r)
|
||||
|
||||
fullPath := r.URL.Path
|
||||
if r.URL.RawQuery != "" {
|
||||
fullPath = fullPath + "?" + r.URL.RawQuery
|
||||
}
|
||||
next.ServeHTTP(rw, r)
|
||||
|
||||
fields := []any{
|
||||
"request_id", reqID,
|
||||
"method", r.Method,
|
||||
"path", fullPath,
|
||||
"status", wrapped.statusCode,
|
||||
"duration", duration,
|
||||
"client_ip", ip,
|
||||
"request_id", reqCtx.RequestID,
|
||||
"method", reqCtx.Method,
|
||||
"path", reqCtx.Path,
|
||||
"status", rw.statusCode,
|
||||
"start_time", reqCtx.StartTime,
|
||||
"duration", time.Since(reqCtx.StartTime),
|
||||
"client_ip", reqCtx.ClientIp,
|
||||
}
|
||||
|
||||
if wrapped.errorMsg != "" {
|
||||
fields = append(fields, "error", wrapped.errorMsg)
|
||||
if rw.errorMsg != "" {
|
||||
fields = append(fields, "error", rw.errorMsg)
|
||||
}
|
||||
|
||||
switch {
|
||||
case wrapped.statusCode >= 500:
|
||||
case rw.statusCode >= 500:
|
||||
slog.Error("request", fields...)
|
||||
case wrapped.statusCode >= 400:
|
||||
case rw.statusCode >= 400:
|
||||
slog.Warn("request", fields...)
|
||||
default:
|
||||
slog.Info("request", fields...)
|
||||
|
||||
53
internal/middleware/request_context.go
Normal file
53
internal/middleware/request_context.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"server/internal/pkg/httputil"
|
||||
"time"
|
||||
|
||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||
)
|
||||
|
||||
func generateRequestID() string {
|
||||
id, _ := gonanoid.New(16) // 16字符
|
||||
return id
|
||||
}
|
||||
|
||||
type RequestContextMiddleware struct {
|
||||
}
|
||||
|
||||
func NewRequestContextMiddleware() *RequestContextMiddleware {
|
||||
return &RequestContextMiddleware{}
|
||||
}
|
||||
|
||||
func (m *RequestContextMiddleware) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
reqCtx := &RequestContext{
|
||||
RequestID: generateRequestID(),
|
||||
StartTime: time.Now(),
|
||||
Method: r.Method,
|
||||
Path: r.URL.RequestURI(),
|
||||
ClientIp: httputil.ClientIP(r),
|
||||
UserAgent: r.UserAgent(),
|
||||
Referer: r.Referer(),
|
||||
}
|
||||
|
||||
userCtx := &UserContext{
|
||||
UserID: 0,
|
||||
IsAdmin: false,
|
||||
TokenVersion: 0,
|
||||
}
|
||||
|
||||
ctx := WithRequestContext(
|
||||
r.Context(),
|
||||
reqCtx,
|
||||
)
|
||||
|
||||
ctx = WithUserContext(
|
||||
ctx,
|
||||
userCtx,
|
||||
)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
47
internal/middleware/response_writer.go
Normal file
47
internal/middleware/response_writer.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"server/internal/model/common"
|
||||
)
|
||||
|
||||
type ResponseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
bytes int
|
||||
errorMsg string
|
||||
}
|
||||
|
||||
func NewResponseRecorder(w http.ResponseWriter) *ResponseWriter {
|
||||
return &ResponseWriter{
|
||||
ResponseWriter: w,
|
||||
statusCode: http.StatusOK,
|
||||
}
|
||||
}
|
||||
|
||||
func (rw *ResponseWriter) WriteHeader(code int) {
|
||||
rw.statusCode = code
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// extractErrorMessage 从响应体中提取错误信息
|
||||
func (rw *ResponseWriter) extractErrorMessage(body []byte) string {
|
||||
var resp common.Response
|
||||
if err := json.Unmarshal(body, &resp); err == nil && resp.Message != "" {
|
||||
return resp.Message
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
|
||||
func (rw *ResponseWriter) Write(b []byte) (int, error) {
|
||||
n, err := rw.ResponseWriter.Write(b)
|
||||
rw.bytes += n
|
||||
|
||||
// 只在错误状态码且未记录错误时处理
|
||||
if rw.statusCode >= 400 && rw.errorMsg == "" && n > 0 {
|
||||
rw.errorMsg = rw.extractErrorMessage(b[:n])
|
||||
}
|
||||
|
||||
return n, err
|
||||
}
|
||||
@@ -3,7 +3,17 @@ package auth
|
||||
import "time"
|
||||
|
||||
type RefreshTokenRecord struct {
|
||||
UserID int32 `json:"user_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
UserID int32 `json:"user_id"`
|
||||
TokenVersion int32 `json:"token_version"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// IsAdmin 判断用户是否是超级管理员
|
||||
func IsAdmin(uid int32) bool {
|
||||
if uid == 1 {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
35
internal/model/request/file.go
Normal file
35
internal/model/request/file.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package request
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type UploadFileRequest struct {
|
||||
Folder string `json:"folder" validate:"omitempty,oneof=cover avatar article common"`
|
||||
}
|
||||
|
||||
// AllowedUploadExts 上传文件扩展名白名单(扩展名 → 期望的真实 MIME)。
|
||||
var AllowedUploadExts = map[string]string{
|
||||
// 图片
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
// 视频
|
||||
".mp4": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
}
|
||||
|
||||
// IsAllowedUploadExt 扩展名是否在白名单内(大小写不敏感)
|
||||
func IsAllowedUploadExt(ext string) bool {
|
||||
_, ok := AllowedUploadExts[strings.ToLower(ext)]
|
||||
return ok
|
||||
}
|
||||
|
||||
// MatchUploadMime 校验实际 MIME 与扩展名匹配
|
||||
func MatchUploadMime(filename, detectedMime string) bool {
|
||||
expected, ok := AllowedUploadExts[strings.ToLower(filepath.Ext(filename))]
|
||||
return ok && detectedMime == expected
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
package request
|
||||
|
||||
import (
|
||||
"server/internal/pkg/validator"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CreatePostRequest struct {
|
||||
Title string `json:"title" validate:"required,min=1"`
|
||||
CoverID int32 `json:"cover_id" validate:"required,min=1"`
|
||||
CoverID *int32 `json:"cover_id" validate:"omitempty"`
|
||||
Slug string `json:"slug" validate:"required,min=0"`
|
||||
Content string `json:"content" validate:"required,min=0"`
|
||||
Summary string `json:"summary" validate:"required,min=0"`
|
||||
@@ -18,15 +19,20 @@ type CreatePostRequest struct {
|
||||
}
|
||||
|
||||
type UpdatePostRequest struct {
|
||||
Title *string `json:"title" validate:"omitempty,min=1"`
|
||||
CoverID *int32 `json:"cover_id" validate:"omitempty,min=1"`
|
||||
Slug *string `json:"slug" validate:"omitempty,min=1"`
|
||||
Content *string `json:"content" validate:"required,min=0"`
|
||||
Summary *string `json:"summary" validate:"omitempty,min=1"`
|
||||
Status *int16 `json:"status" validate:"omitempty,oneof=0 1 2"`
|
||||
View *int32 `json:"view" validate:"omitempty,min=1"`
|
||||
Sort *int32 `json:"sort" validate:"omitempty,min=0"`
|
||||
PublishedAt *time.Time `json:"published_at" validate:"omitempty"`
|
||||
CategoryID *int32 `json:"category_id" validate:"required,min=1"`
|
||||
Tags []int32 `json:"tags" validate:"omitempty,dive,min=1"`
|
||||
Title *string `json:"title" validate:"omitempty,min=1"`
|
||||
CoverID validator.NullInt32 `json:"cover_id" validate:"omitempty"`
|
||||
Slug *string `json:"slug" validate:"omitempty,min=1"`
|
||||
Content *string `json:"content" validate:"required,min=0"`
|
||||
Summary *string `json:"summary" validate:"omitempty,min=1"`
|
||||
Status *int16 `json:"status" validate:"omitempty,oneof=0 1 2"`
|
||||
View *int32 `json:"view" validate:"omitempty,min=1"`
|
||||
Sort *int32 `json:"sort" validate:"omitempty,min=0"`
|
||||
PublishedAt *time.Time `json:"published_at" validate:"omitempty"`
|
||||
CategoryID *int32 `json:"category_id" validate:"required,min=1"`
|
||||
Tags []int32 `json:"tags" validate:"omitempty,dive,min=1"`
|
||||
}
|
||||
|
||||
type SearchPublishedPostsParams struct {
|
||||
CategoryCode string `json:"category_code" validate:"omitempty"`
|
||||
TagCode string `json:"tag_code" validate:"omitempty"`
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"server/internal/db/sqlc"
|
||||
"server/internal/pkg/httputil"
|
||||
)
|
||||
|
||||
func ToFiles(files []sqlc.File) []sqlc.File {
|
||||
result := make([]sqlc.File, len(files))
|
||||
for i := range files {
|
||||
result[i] = files[i]
|
||||
result[i].FilePath = httputil.BuildFileUrl(&files[i].FilePath)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -28,7 +28,7 @@ type LoginResponse struct {
|
||||
RefreshTokenExp time.Time `json:"refresh_token_exp"`
|
||||
}
|
||||
|
||||
func NewUserInfo(user sqlc.GetUserByIDRow, roles []sqlc.SysRole, menus []sqlc.SysMenu, p []*string) *UserInfo {
|
||||
func NewUserInfo(user sqlc.GetUserRow, roles []sqlc.SysRole, menus []sqlc.SysMenu, p []*string) *UserInfo {
|
||||
roleCodes := make([]string, len(roles))
|
||||
for i, role := range roles {
|
||||
roleCodes[i] = role.Code
|
||||
@@ -41,11 +41,16 @@ func NewUserInfo(user sqlc.GetUserByIDRow, roles []sqlc.SysRole, menus []sqlc.Sy
|
||||
}
|
||||
}
|
||||
|
||||
avatarURL := ""
|
||||
if user.AvatarUrl != nil {
|
||||
avatarURL = *user.AvatarUrl
|
||||
}
|
||||
|
||||
return &UserInfo{
|
||||
ID: user.ID,
|
||||
Account: user.Account,
|
||||
Username: user.Username,
|
||||
AvatarUrl: *user.AvatarUrl,
|
||||
AvatarUrl: avatarURL,
|
||||
Roles: roleCodes,
|
||||
Menus: menus,
|
||||
Permissions: permissions,
|
||||
|
||||
7
internal/pkg/cache/cachekey/key.go
vendored
7
internal/pkg/cache/cachekey/key.go
vendored
@@ -5,6 +5,9 @@ import "fmt"
|
||||
const (
|
||||
UserApiPermissionsPattern = "user:api:permissions:*"
|
||||
UserInfoPattern = "user:info:*"
|
||||
UserAuthStatePattern = "user:auth_state:*"
|
||||
|
||||
AuthRefreshPattern = "auth:refresh:*" // 新增:覆盖 <hash> 和 user:<id> 两种 key
|
||||
)
|
||||
|
||||
func UserApiPermissions(id int32) string {
|
||||
@@ -23,3 +26,7 @@ func AuthRefresh(hash string) string {
|
||||
func AuthRefreshUser(userID int32) string {
|
||||
return fmt.Sprintf("auth:refresh:user:%d", userID)
|
||||
}
|
||||
|
||||
func UserAuthState(id int32) string {
|
||||
return fmt.Sprintf("user:auth_state:%d", id)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ var (
|
||||
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, "分类数据不存在")
|
||||
ErrTagNotFound = New(http.StatusNotFound, "标签数据不存在")
|
||||
@@ -49,5 +48,7 @@ var (
|
||||
ErrTagCodeAlreadyExists = New(http.StatusBadRequest, "标签编码不允许重复")
|
||||
ErrMenusPathUniqueIdx = New(http.StatusBadRequest, "菜单路径不允许重复")
|
||||
ErrApiMethodPathAlreadyExists = New(http.StatusBadRequest, "接口方法(method)路径(path)不允许重复")
|
||||
ErrInvalidApiPath = New(http.StatusBadRequest, "接口路径格式不正确,必须以/开头,参数段需为{name}格式")
|
||||
ErrFileTypeNotAllowed = New(http.StatusBadRequest, "该文件类型不允许上传")
|
||||
ErrBodyTooLarge = New(http.StatusRequestEntityTooLarge, "请求体过大")
|
||||
ErrInternalServer = New(http.StatusInternalServerError, "内部服务器错误")
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"server/internal/config"
|
||||
"server/internal/model/common"
|
||||
@@ -58,8 +59,19 @@ func URLParamInt32(r *http.Request, key string) (int32, error) {
|
||||
return int32(n), nil
|
||||
}
|
||||
|
||||
func BindJson(r *http.Request, dest any) error {
|
||||
// maxJSONBodySize JSON 请求体上限(1MB)
|
||||
const maxJSONBodySize = 1 << 20
|
||||
|
||||
func BindJson(w http.ResponseWriter, r *http.Request, dest any) error {
|
||||
// 超限时 Decode 返回 *http.MaxBytesError → 413
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxJSONBodySize)
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(dest); err != nil {
|
||||
var maxBytesErr *http.MaxBytesError
|
||||
if errors.As(err, &maxBytesErr) {
|
||||
return errs.ErrBodyTooLarge
|
||||
}
|
||||
|
||||
if errors.Is(err, io.EOF) {
|
||||
return errs.ErrEmptyBody
|
||||
}
|
||||
@@ -73,17 +85,28 @@ func BindJson(r *http.Request, dest any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClientIP 获取客户端 IP,依次从 X-Forwarded-For、X-Real-IP、RemoteAddr 取值
|
||||
func ClientIP(r *http.Request) string {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
parts := strings.Split(xff, ",")
|
||||
if len(parts) > 0 {
|
||||
return strings.TrimSpace(parts[0])
|
||||
}
|
||||
}
|
||||
// ClientIP 获取客户端 IP;trust_proxy=true 时信任代理头,否则用 RemoteAddr(防伪造)
|
||||
func ClientIP(r *http.Request) netip.Addr {
|
||||
addr, _ := netip.ParseAddr(clientIPString(r))
|
||||
return addr
|
||||
}
|
||||
|
||||
if ip := r.Header.Get("X-Real-IP"); ip != "" {
|
||||
return ip
|
||||
func clientIPString(r *http.Request) string {
|
||||
// 只在 trust_proxy=true 时信任代理头(防伪造)
|
||||
if config.GetBool("server.trust_proxy") {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
parts := strings.Split(xff, ",")
|
||||
for _, v := range parts {
|
||||
if ip := strings.TrimSpace(v); ip != "" {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 再取 X-Real-IP(nginx 单层代理覆盖时最准)
|
||||
if realIP := strings.TrimSpace(r.Header.Get("X-Real-IP")); realIP != "" {
|
||||
return realIP
|
||||
}
|
||||
}
|
||||
|
||||
// RemoteAddr: IP:port
|
||||
@@ -91,7 +114,6 @@ func ClientIP(r *http.Request) string {
|
||||
if err == nil {
|
||||
return host
|
||||
}
|
||||
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
|
||||
18
internal/pkg/safego/safego.go
Normal file
18
internal/pkg/safego/safego.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package safego
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"runtime/debug"
|
||||
)
|
||||
|
||||
// Go 以协程执行 fn,并捕获 panic 记录日志,防止协程崩溃杀死整个进程
|
||||
func Go(fn func()) {
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("goroutine panic recovered", "panic", r, "stack", string(debug.Stack()))
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}()
|
||||
}
|
||||
@@ -27,23 +27,45 @@ type Registrar interface {
|
||||
type Params struct {
|
||||
fx.In
|
||||
|
||||
Jwt *middleware.JWTMiddleware
|
||||
Auth *middleware.AuthMiddleware
|
||||
Logger *middleware.LoggerMiddleware
|
||||
JWT *middleware.JWTMiddleware
|
||||
Auth *middleware.AuthMiddleware
|
||||
Logger *middleware.LoggerMiddleware
|
||||
AccessLog *middleware.AccessLogMiddleware
|
||||
RequestContext *middleware.RequestContextMiddleware
|
||||
Config *config.Config
|
||||
|
||||
AdminPublicRoutes []Registrar `group:"admin_public_routes"`
|
||||
AdminRoutes []Registrar `group:"admin_routes"`
|
||||
SiteRoutes []Registrar `group:"site_routes"`
|
||||
}
|
||||
|
||||
func NewRouter(p Params) *chi.Mux {
|
||||
func NewRouter(p Params, cfg *config.Config) *chi.Mux {
|
||||
mux := chi.NewRouter()
|
||||
|
||||
mux.Use(p.Logger.Middleware)
|
||||
// 中间件顺序很重要 洋葱模型 越靠前的中间件 包裹范围越大
|
||||
// 请求进入:
|
||||
//
|
||||
//A 前置代码
|
||||
// |
|
||||
// B 前置代码
|
||||
// |
|
||||
// C 前置代码
|
||||
// |
|
||||
// Handler
|
||||
// C 后置代码
|
||||
// B 后置代码
|
||||
//A 后置代码
|
||||
//
|
||||
//响应返回:
|
||||
mux.Use(
|
||||
p.RequestContext.Middleware,
|
||||
p.Logger.Middleware,
|
||||
p.AccessLog.Middleware,
|
||||
)
|
||||
|
||||
if config.IsDev() {
|
||||
// 开放静态目录
|
||||
registerStaticFiles(mux)
|
||||
registerStaticFiles(mux, cfg.File.UploadDir)
|
||||
|
||||
// 开发阶段:遍历所有已注册路由的清单接口(无鉴权)
|
||||
mux.Get("/api/admin/routes", ListRoutes(mux))
|
||||
@@ -57,7 +79,7 @@ func NewRouter(p Params) *chi.Mux {
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
// jwt 和 auth中间件
|
||||
r.Use(p.Jwt.Middleware)
|
||||
r.Use(p.JWT.Middleware)
|
||||
r.Use(p.Auth.Middleware(mux))
|
||||
|
||||
// 循环挂载所有后台业务模块
|
||||
@@ -76,10 +98,10 @@ func NewRouter(p Params) *chi.Mux {
|
||||
return mux
|
||||
}
|
||||
|
||||
func registerStaticFiles(r chi.Router) {
|
||||
func registerStaticFiles(r chi.Router, uploadDir string) {
|
||||
rootDir, _ := os.Getwd()
|
||||
|
||||
uploadsDir := filepath.Join(rootDir, "uploads")
|
||||
uploadsDir := filepath.Join(rootDir, uploadDir)
|
||||
|
||||
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir(uploadsDir))))
|
||||
}
|
||||
|
||||
42
internal/service/admin/access_log.go
Normal file
42
internal/service/admin/access_log.go
Normal 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
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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+WEBP),DetectContentType 不识别需手动补
|
||||
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
|
||||
}
|
||||
|
||||
@@ -71,6 +71,9 @@ func (s *MenuService) Create(ctx context.Context, req request.CreateMenuRequest)
|
||||
return err
|
||||
}
|
||||
|
||||
// 清理缓存 这里不清理 超管用户的菜单不会刷新
|
||||
_ = s.cache.DelByPrefix(ctx, cachekey.UserInfoPattern)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -15,5 +15,6 @@ var Module = fx.Module("sys-services",
|
||||
NewAuthService,
|
||||
NewTagService,
|
||||
NewCategoryService,
|
||||
NewAccessLogService,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,13 +2,16 @@ package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
"server/internal/db"
|
||||
"server/internal/db/sqlc"
|
||||
"server/internal/model/common"
|
||||
"server/internal/model/request"
|
||||
"server/internal/model/response"
|
||||
"server/internal/pkg/dberr"
|
||||
"server/internal/pkg/errs"
|
||||
"server/internal/pkg/safego"
|
||||
)
|
||||
|
||||
type PostService struct {
|
||||
@@ -21,7 +24,7 @@ func NewPostService(store *db.Store) *PostService {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PostService) ListPage(ctx context.Context, p *common.Pagination) (*common.PageResult[sqlc.ListPublishedPostsRow], error) {
|
||||
func (s *PostService) List(ctx context.Context, p *common.Pagination) (*common.PageResult[sqlc.ListPublishedPostsRow], error) {
|
||||
params := sqlc.ListPublishedPostsParams{
|
||||
Limit: p.PageSize,
|
||||
Offset: (p.Page - 1) * p.PageSize,
|
||||
@@ -43,15 +46,31 @@ func (s *PostService) ListPage(ctx context.Context, p *common.Pagination) (*comm
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *PostService) ListSearch(ctx context.Context, req request.SearchPublishedPostsParams) ([]sqlc.ListPublishedPostsWithFiltersRow, error) {
|
||||
params := sqlc.ListPublishedPostsWithFiltersParams{
|
||||
CategoryCode: &req.CategoryCode,
|
||||
TagCode: &req.TagCode,
|
||||
}
|
||||
|
||||
return s.store.ListPublishedPostsWithFilters(ctx, params)
|
||||
}
|
||||
|
||||
func (s *PostService) GetPost(ctx context.Context, slug string, ip netip.Addr) (*sqlc.GetPublicPostBySlugRow, error) {
|
||||
post, err := s.store.GetPublicPostBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, dberr.MapNoRows(err, errs.ErrPostNotFound)
|
||||
}
|
||||
|
||||
_ = s.store.IncrementPostStatsView(ctx, sqlc.IncrementPostStatsViewParams{
|
||||
PostID: post.ID,
|
||||
Ip: ip,
|
||||
// 异步统计浏览量,不阻塞响应
|
||||
// 用 WithoutCancel 脱离请求 context:否则 handler 一返回、请求 ctx 被取消,写库会被中断
|
||||
safego.Go(func() {
|
||||
statsCtx := context.WithoutCancel(ctx)
|
||||
if err = s.store.IncrementPostStatsView(statsCtx, sqlc.IncrementPostStatsViewParams{
|
||||
PostID: post.ID,
|
||||
Ip: ip,
|
||||
}); err != nil {
|
||||
slog.Error("increment post stats view failed", "post_id", post.ID, "error", err)
|
||||
}
|
||||
})
|
||||
|
||||
return &post, nil
|
||||
@@ -94,6 +113,11 @@ func (s *PostService) ListArchives(ctx context.Context) ([]response.ArchiveYear,
|
||||
|
||||
lastMonthIndex := len(archive[lastYearIndex].ArchiveMonth) - 1
|
||||
|
||||
categoryName := ""
|
||||
if item.CategoryName != nil {
|
||||
categoryName = *item.CategoryName
|
||||
}
|
||||
|
||||
archive[lastYearIndex].ArchiveMonth[lastMonthIndex].Archive =
|
||||
append(archive[lastYearIndex].ArchiveMonth[lastMonthIndex].Archive, response.ArchivePost{
|
||||
ID: item.ID,
|
||||
@@ -101,7 +125,7 @@ func (s *PostService) ListArchives(ctx context.Context) ([]response.ArchiveYear,
|
||||
Title: item.Title,
|
||||
PublishedAt: item.PublishedAt,
|
||||
PublishedAtDisplay: item.PublishedAt.Format("01-02"),
|
||||
CategoryName: *item.CategoryName,
|
||||
CategoryName: categoryName,
|
||||
})
|
||||
|
||||
archive[lastYearIndex].Total++
|
||||
@@ -113,3 +137,7 @@ func (s *PostService) ListArchives(ctx context.Context) ([]response.ArchiveYear,
|
||||
func (s *PostService) ListPostTags(ctx context.Context) ([]sqlc.Tag, error) {
|
||||
return s.store.ListAllTags(ctx)
|
||||
}
|
||||
|
||||
func (s *PostService) GetPostsForSitemap(ctx context.Context) ([]sqlc.GetPostsForSitemapRow, error) {
|
||||
return s.store.GetPostsForSitemap(ctx)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user