diff --git a/cmd/main.go b/cmd/main.go index dcafada..372b134 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -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() } diff --git a/example.yaml b/example.yaml index e916598..6a1ec5c 100644 --- a/example.yaml +++ b/example.yaml @@ -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 diff --git a/internal/config/config.go b/internal/config/config.go index 5e67758..2f3a9f8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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) +} diff --git a/internal/db/migrations/000012_add_access_logs.down.sql b/internal/db/migrations/000012_add_access_logs.down.sql new file mode 100644 index 0000000..9def1b1 --- /dev/null +++ b/internal/db/migrations/000012_add_access_logs.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS access_logs; \ No newline at end of file diff --git a/internal/db/migrations/000012_add_access_logs.up.sql b/internal/db/migrations/000012_add_access_logs.up.sql new file mode 100644 index 0000000..d85027c --- /dev/null +++ b/internal/db/migrations/000012_add_access_logs.up.sql @@ -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); \ No newline at end of file diff --git a/internal/db/migrations/000013_add_file_image_metadata.down.sql b/internal/db/migrations/000013_add_file_image_metadata.down.sql new file mode 100644 index 0000000..cab8af4 --- /dev/null +++ b/internal/db/migrations/000013_add_file_image_metadata.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS file_image_metadata; \ No newline at end of file diff --git a/internal/db/migrations/000013_add_file_image_metadata.up.sql b/internal/db/migrations/000013_add_file_image_metadata.up.sql new file mode 100644 index 0000000..723b881 --- /dev/null +++ b/internal/db/migrations/000013_add_file_image_metadata.up.sql @@ -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() +); diff --git a/internal/db/migrations/000014_rename_post_stats_view_to_view_count.down.sql b/internal/db/migrations/000014_rename_post_stats_view_to_view_count.down.sql new file mode 100644 index 0000000..761b772 --- /dev/null +++ b/internal/db/migrations/000014_rename_post_stats_view_to_view_count.down.sql @@ -0,0 +1,3 @@ +-- down +ALTER TABLE post_stats + RENAME COLUMN view_count TO view; \ No newline at end of file diff --git a/internal/db/migrations/000014_rename_post_stats_view_to_view_count.up.sql b/internal/db/migrations/000014_rename_post_stats_view_to_view_count.up.sql new file mode 100644 index 0000000..6514861 --- /dev/null +++ b/internal/db/migrations/000014_rename_post_stats_view_to_view_count.up.sql @@ -0,0 +1,4 @@ +ALTER TABLE post_stats + RENAME COLUMN view TO view_count; + +COMMENT ON COLUMN post_stats.view_count IS '文章阅读量'; \ No newline at end of file diff --git a/internal/db/migrations/000015_add_token_version_to_sys_users.down.sql b/internal/db/migrations/000015_add_token_version_to_sys_users.down.sql new file mode 100644 index 0000000..268448c --- /dev/null +++ b/internal/db/migrations/000015_add_token_version_to_sys_users.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE sys_users + DROP COLUMN IF EXISTS token_version; \ No newline at end of file diff --git a/internal/db/migrations/000015_add_token_version_to_sys_users.up.sql b/internal/db/migrations/000015_add_token_version_to_sys_users.up.sql new file mode 100644 index 0000000..f0cd551 --- /dev/null +++ b/internal/db/migrations/000015_add_token_version_to_sys_users.up.sql @@ -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'; \ No newline at end of file diff --git a/internal/db/query/access_logs.sql b/internal/db/query/access_logs.sql new file mode 100644 index 0000000..c809d58 --- /dev/null +++ b/internal/db/query/access_logs.sql @@ -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; diff --git a/internal/db/query/file_image_metadata.sql b/internal/db/query/file_image_metadata.sql new file mode 100644 index 0000000..14c0934 --- /dev/null +++ b/internal/db/query/file_image_metadata.sql @@ -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; \ No newline at end of file diff --git a/internal/db/query/files.sql b/internal/db/query/files.sql index 8cf8acb..ddca7a3 100644 --- a/internal/db/query/files.sql +++ b/internal/db/query/files.sql @@ -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/%'; \ No newline at end of file diff --git a/internal/db/query/post.sql b/internal/db/query/post.sql index 7e5f7d9..9fe3fee 100644 --- a/internal/db/query/post.sql +++ b/internal/db/query/post.sql @@ -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; \ No newline at end of file + 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; \ No newline at end of file diff --git a/internal/db/query/post_stats.sql b/internal/db/query/post_stats.sql index d2f8efa..6b33728 100644 --- a/internal/db/query/post_stats.sql +++ b/internal/db/query/post_stats.sql @@ -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; \ No newline at end of file +SET view_count = post_stats.view_count + 1; \ No newline at end of file diff --git a/internal/db/query/sys_users.sql b/internal/db/query/sys_users.sql index 1f9cd51..1e6764d 100644 --- a/internal/db/query/sys_users.sql +++ b/internal/db/query/sys_users.sql @@ -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 diff --git a/internal/db/schema/access_logs.sql b/internal/db/schema/access_logs.sql new file mode 100644 index 0000000..d85027c --- /dev/null +++ b/internal/db/schema/access_logs.sql @@ -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); \ No newline at end of file diff --git a/internal/db/schema/file_image_metadata.sql b/internal/db/schema/file_image_metadata.sql new file mode 100644 index 0000000..723b881 --- /dev/null +++ b/internal/db/schema/file_image_metadata.sql @@ -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() +); diff --git a/internal/db/schema/post_stats.sql b/internal/db/schema/post_stats.sql index 6b25146..fcd6f17 100644 --- a/internal/db/schema/post_stats.sql +++ b/internal/db/schema/post_stats.sql @@ -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 '文章阅读量'; diff --git a/internal/db/schema/sys_users.sql b/internal/db/schema/sys_users.sql index 565a6d0..2bfb5b6 100644 --- a/internal/db/schema/sys_users.sql +++ b/internal/db/schema/sys_users.sql @@ -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 '更新时间'; \ No newline at end of file diff --git a/internal/db/sqlc/access_logs.sql.go b/internal/db/sqlc/access_logs.sql.go new file mode 100644 index 0000000..c355e63 --- /dev/null +++ b/internal/db/sqlc/access_logs.sql.go @@ -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 +} diff --git a/internal/db/sqlc/copyfrom.go b/internal/db/sqlc/copyfrom.go index 3e855a2..9d715be 100644 --- a/internal/db/sqlc/copyfrom.go +++ b/internal/db/sqlc/copyfrom.go @@ -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 diff --git a/internal/db/sqlc/file_image_metadata.sql.go b/internal/db/sqlc/file_image_metadata.sql.go new file mode 100644 index 0000000..0e66a70 --- /dev/null +++ b/internal/db/sqlc/file_image_metadata.sql.go @@ -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 +} diff --git a/internal/db/sqlc/files.sql.go b/internal/db/sqlc/files.sql.go index 3f3eb0a..26340b7 100644 --- a/internal/db/sqlc/files.sql.go +++ b/internal/db/sqlc/files.sql.go @@ -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 +} diff --git a/internal/db/sqlc/models.go b/internal/db/sqlc/models.go index 30ac92a..b90892b 100644 --- a/internal/db/sqlc/models.go +++ b/internal/db/sqlc/models.go @@ -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"` // 更新时间 diff --git a/internal/db/sqlc/post.sql.go b/internal/db/sqlc/post.sql.go index 54e6655..cc87094 100644 --- a/internal/db/sqlc/post.sql.go +++ b/internal/db/sqlc/post.sql.go @@ -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, diff --git a/internal/db/sqlc/post_stats.sql.go b/internal/db/sqlc/post_stats.sql.go index 8253734..743c395 100644 --- a/internal/db/sqlc/post_stats.sql.go +++ b/internal/db/sqlc/post_stats.sql.go @@ -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 { diff --git a/internal/db/sqlc/sys_users.sql.go b/internal/db/sqlc/sys_users.sql.go index c58180b..9f740fa 100644 --- a/internal/db/sqlc/sys_users.sql.go +++ b/internal/db/sqlc/sys_users.sql.go @@ -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 diff --git a/internal/handler/admin/access_log.go b/internal/handler/admin/access_log.go new file mode 100644 index 0000000..1bdd000 --- /dev/null +++ b/internal/handler/admin/access_log.go @@ -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) +} diff --git a/internal/handler/admin/api.go b/internal/handler/admin/api.go index f33e6af..9332bb1 100644 --- a/internal/handler/admin/api.go +++ b/internal/handler/admin/api.go @@ -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 } diff --git a/internal/handler/admin/auth.go b/internal/handler/admin/auth.go index 23208d2..f1a8ebe 100644 --- a/internal/handler/admin/auth.go +++ b/internal/handler/admin/auth.go @@ -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 } diff --git a/internal/handler/admin/category.go b/internal/handler/admin/category.go index 98306c9..6443022 100644 --- a/internal/handler/admin/category.go +++ b/internal/handler/admin/category.go @@ -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 } diff --git a/internal/handler/admin/file.go b/internal/handler/admin/file.go index 36fe73a..771a491 100644 --- a/internal/handler/admin/file.go +++ b/internal/handler/admin/file.go @@ -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) +} diff --git a/internal/handler/admin/menu.go b/internal/handler/admin/menu.go index 39d11ff..1776100 100644 --- a/internal/handler/admin/menu.go +++ b/internal/handler/admin/menu.go @@ -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 } diff --git a/internal/handler/admin/module.go b/internal/handler/admin/module.go index c433885..1fbc5e3 100644 --- a/internal/handler/admin/module.go +++ b/internal/handler/admin/module.go @@ -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), ), ) diff --git a/internal/handler/admin/post.go b/internal/handler/admin/post.go index 24beb2d..2b937a3 100644 --- a/internal/handler/admin/post.go +++ b/internal/handler/admin/post.go @@ -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 } diff --git a/internal/handler/admin/role.go b/internal/handler/admin/role.go index 70e6732..8a78cee 100644 --- a/internal/handler/admin/role.go +++ b/internal/handler/admin/role.go @@ -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 } diff --git a/internal/handler/admin/tag.go b/internal/handler/admin/tag.go index 56fd441..b5cd67b 100644 --- a/internal/handler/admin/tag.go +++ b/internal/handler/admin/tag.go @@ -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 } diff --git a/internal/handler/admin/user.go b/internal/handler/admin/user.go index 661d6dd..91c700b 100644 --- a/internal/handler/admin/user.go +++ b/internal/handler/admin/user.go @@ -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 } diff --git a/internal/handler/site/post.go b/internal/handler/site/post.go index 2d49338..98b55f1 100644 --- a/internal/handler/site/post.go +++ b/internal/handler/site/post.go @@ -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) +} diff --git a/internal/middleware/access_log.go b/internal/middleware/access_log.go new file mode 100644 index 0000000..8af2788 --- /dev/null +++ b/internal/middleware/access_log.go @@ -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, + }) + }) + }) +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index afe8c5d..00c8826 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -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 diff --git a/internal/middleware/context.go b/internal/middleware/context.go new file mode 100644 index 0000000..2399559 --- /dev/null +++ b/internal/middleware/context.go @@ -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 +} diff --git a/internal/middleware/jwt.go b/internal/middleware/jwt.go index a0c1577..bf067d1 100644 --- a/internal/middleware/jwt.go +++ b/internal/middleware/jwt.go @@ -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 diff --git a/internal/middleware/logger.go b/internal/middleware/logger.go index eec1a7b..f0d50b1 100644 --- a/internal/middleware/logger.go +++ b/internal/middleware/logger.go @@ -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...) diff --git a/internal/middleware/request_context.go b/internal/middleware/request_context.go new file mode 100644 index 0000000..af94ef7 --- /dev/null +++ b/internal/middleware/request_context.go @@ -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)) + }) +} diff --git a/internal/middleware/response_writer.go b/internal/middleware/response_writer.go new file mode 100644 index 0000000..18e4fe3 --- /dev/null +++ b/internal/middleware/response_writer.go @@ -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 +} diff --git a/internal/model/auth/jwt.go b/internal/model/auth/jwt.go index d23212a..1f9103e 100644 --- a/internal/model/auth/jwt.go +++ b/internal/model/auth/jwt.go @@ -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 } diff --git a/internal/model/request/file.go b/internal/model/request/file.go new file mode 100644 index 0000000..75730ee --- /dev/null +++ b/internal/model/request/file.go @@ -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 +} diff --git a/internal/model/request/post.go b/internal/model/request/post.go index 1f42cf3..2b82a34 100644 --- a/internal/model/request/post.go +++ b/internal/model/request/post.go @@ -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"` } diff --git a/internal/model/response/file.go b/internal/model/response/file.go deleted file mode 100644 index d141787..0000000 --- a/internal/model/response/file.go +++ /dev/null @@ -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 -} diff --git a/internal/model/response/user.go b/internal/model/response/user.go index 49c8403..cdc540f 100644 --- a/internal/model/response/user.go +++ b/internal/model/response/user.go @@ -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, diff --git a/internal/pkg/cache/cachekey/key.go b/internal/pkg/cache/cachekey/key.go index 126d548..e595d74 100644 --- a/internal/pkg/cache/cachekey/key.go +++ b/internal/pkg/cache/cachekey/key.go @@ -5,6 +5,9 @@ import "fmt" const ( UserApiPermissionsPattern = "user:api:permissions:*" UserInfoPattern = "user:info:*" + UserAuthStatePattern = "user:auth_state:*" + + AuthRefreshPattern = "auth:refresh:*" // 新增:覆盖 和 user: 两种 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) +} diff --git a/internal/pkg/errs/errs.go b/internal/pkg/errs/errs.go index 65df3af..39e54d6 100644 --- a/internal/pkg/errs/errs.go +++ b/internal/pkg/errs/errs.go @@ -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, "内部服务器错误") ) diff --git a/internal/pkg/httputil/request.go b/internal/pkg/httputil/request.go index 595be73..5413b06 100644 --- a/internal/pkg/httputil/request.go +++ b/internal/pkg/httputil/request.go @@ -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 } diff --git a/internal/pkg/safego/safego.go b/internal/pkg/safego/safego.go new file mode 100644 index 0000000..d45179d --- /dev/null +++ b/internal/pkg/safego/safego.go @@ -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() + }() +} diff --git a/internal/router/router.go b/internal/router/router.go index f128e4a..074586b 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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)))) } diff --git a/internal/service/admin/access_log.go b/internal/service/admin/access_log.go new file mode 100644 index 0000000..86c4375 --- /dev/null +++ b/internal/service/admin/access_log.go @@ -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 +} diff --git a/internal/service/admin/auth.go b/internal/service/admin/auth.go index 0845d45..b56ec17 100644 --- a/internal/service/admin/auth.go +++ b/internal/service/admin/auth.go @@ -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{ diff --git a/internal/service/admin/file.go b/internal/service/admin/file.go index 67dc98f..1deea8f 100644 --- a/internal/service/admin/file.go +++ b/internal/service/admin/file.go @@ -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 } diff --git a/internal/service/admin/menu.go b/internal/service/admin/menu.go index 23905c2..1b6acf7 100644 --- a/internal/service/admin/menu.go +++ b/internal/service/admin/menu.go @@ -71,6 +71,9 @@ func (s *MenuService) Create(ctx context.Context, req request.CreateMenuRequest) return err } + // 清理缓存 这里不清理 超管用户的菜单不会刷新 + _ = s.cache.DelByPrefix(ctx, cachekey.UserInfoPattern) + return nil } diff --git a/internal/service/admin/module.go b/internal/service/admin/module.go index 32e6411..91bd6b8 100644 --- a/internal/service/admin/module.go +++ b/internal/service/admin/module.go @@ -15,5 +15,6 @@ var Module = fx.Module("sys-services", NewAuthService, NewTagService, NewCategoryService, + NewAccessLogService, ), ) diff --git a/internal/service/admin/post.go b/internal/service/admin/post.go index b8a8d8b..c0e3eee 100644 --- a/internal/service/admin/post.go +++ b/internal/service/admin/post.go @@ -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) diff --git a/internal/service/admin/user.go b/internal/service/admin/user.go index 8c231fa..dde3819 100644 --- a/internal/service/admin/user.go +++ b/internal/service/admin/user.go @@ -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 diff --git a/internal/service/web/post.go b/internal/service/web/post.go index 3016498..0b07c07 100644 --- a/internal/service/web/post.go +++ b/internal/service/web/post.go @@ -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) +} diff --git a/sqlc.yaml b/sqlc.yaml index a876470..c5ea95f 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -19,4 +19,8 @@ sql: - db_type: "timestamptz" nullable: true go_type: - type: "*time.Time" \ No newline at end of file + type: "*time.Time" + - db_type: "jsonb" + go_type: + import: "encoding/json" + type: "RawMessage" \ No newline at end of file