feat: update template

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

View File

@@ -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()
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS access_logs;

View File

@@ -0,0 +1,25 @@
CREATE TABLE access_logs
(
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
request_id TEXT,
user_id INTEGER,
ip INET,
user_agent TEXT,
request_method VARCHAR(10) NOT NULL,
request_path TEXT NOT NULL,
referer TEXT,
message TEXT,
status_code INTEGER,
response_time_ms INTEGER,
started_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_access_logs_created_at
ON access_logs (created_at);
CREATE INDEX idx_access_logs_request_path
ON access_logs (request_path);
CREATE INDEX idx_access_logs_ip
ON access_logs (ip);

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS file_image_metadata;

View File

@@ -0,0 +1,9 @@
CREATE TABLE file_image_metadata
(
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
file_id INTEGER UNIQUE NOT NULL,
width INTEGER NOT NULL,
height INTEGER NOT NULL,
format TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

View File

@@ -0,0 +1,3 @@
-- down
ALTER TABLE post_stats
RENAME COLUMN view_count TO view;

View File

@@ -0,0 +1,4 @@
ALTER TABLE post_stats
RENAME COLUMN view TO view_count;
COMMENT ON COLUMN post_stats.view_count IS '文章阅读量';

View File

@@ -0,0 +1,2 @@
ALTER TABLE sys_users
DROP COLUMN IF EXISTS token_version;

View File

@@ -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';

View File

@@ -0,0 +1,16 @@
-- name: CreateAccessLog :exec
INSERT INTO access_logs(request_id, user_id, ip, user_agent, request_method, request_path, referer, status_code,
response_time_ms, started_at, message)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);
-- name: ListAccessLogs :many
SELECT a.*, u.username
FROM access_logs a
LEFT JOIN sys_users u
ON u.id = a.user_id
ORDER BY a.started_at DESC, a.id
LIMIT $1 OFFSET $2;
-- name: CountAccessLogs :one
SELECT COUNT(*)
FROM access_logs;

View File

@@ -0,0 +1,10 @@
-- name: CreateFileImageMetadata :exec
INSERT INTO file_image_metadata(file_id, width, height, format)
VALUES ($1, $2, $3, $4);
-- name: CopyFileImageMetadata :copyfrom
INSERT INTO file_image_metadata(file_id, width, height, format)
VALUES ($1, $2, $3, $4);
-- name: TruncateFileImageMetadata :exec
TRUNCATE TABLE file_image_metadata;

View File

@@ -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/%';

View File

@@ -18,25 +18,25 @@ WITH paginated_posts AS (
updated_at
FROM posts
ORDER BY sort DESC, published_at DESC, id DESC
LIMIT $1 OFFSET $2
)
LIMIT $1 OFFSET $2)
-- 第二步:用这极少量的记录去进行 JOIN
SELECT p.*,
f.file_url AS cover,
c.name AS category_name,
c.id AS category_id,
COALESCE(ps.view, 0) AS view,
f.file_url AS cover,
f.file_size AS cover_size,
c.name AS category_name,
c.id AS category_id,
COALESCE(ps.view_count, 0) AS view_count,
COALESCE(
jsonb_agg(
jsonb_build_object(
'id', t.id,
'name', t.name,
'code', t.code
)
ORDER BY t.sort DESC, t.id
) FILTER (WHERE t.id IS NOT NULL),
'[]'::jsonb
) AS tags
jsonb_agg(
jsonb_build_object(
'id', t.id,
'name', t.name,
'code', t.code
)
ORDER BY t.sort DESC, t.id
) FILTER (WHERE t.id IS NOT NULL),
'[]'::jsonb
) AS tags
FROM paginated_posts p
LEFT JOIN files f ON f.id = p.cover_id
LEFT JOIN post_category pc ON pc.post_id = p.id
@@ -55,9 +55,10 @@ GROUP BY p.id,
p.created_at,
p.updated_at,
f.file_url,
f.file_size,
c.name,
c.id,
ps.view
ps.view_count
ORDER BY p.sort DESC, p.published_at DESC, p.id DESC;
-- name: CountPosts :one
@@ -66,13 +67,14 @@ FROM posts;
-- name: GetPostByID :one
SELECT p.*,
f.file_url AS cover,
f.file_url AS cover,
f.file_size AS cover_size,
c.name AS category_name,
c.id AS category_id,
COALESCE(
array_agg(pt.tag_id ORDER BY pt.tag_id) FILTER (WHERE pt.tag_id IS NOT NULL),
ARRAY[]::integer[]
) AS tags
array_agg(pt.tag_id ORDER BY pt.tag_id) FILTER (WHERE pt.tag_id IS NOT NULL),
ARRAY []::integer[]
) AS tags
FROM posts p
LEFT JOIN files f ON f.id = p.cover_id
LEFT JOIN post_category pc ON pc.post_id = p.id
@@ -81,6 +83,7 @@ FROM posts p
WHERE p.id = $1
GROUP BY p.id,
f.file_url,
f.file_size,
c.name,
c.id
LIMIT 1;
@@ -88,7 +91,7 @@ LIMIT 1;
-- name: UpdatePost :execrows
UPDATE posts
SET title = coalesce(sqlc.narg('title'), title),
cover_id = coalesce(sqlc.narg('cover_id'), cover_id),
cover_id = CASE WHEN @update_cover_id::boolean THEN @cover_id ELSE cover_id END,
slug = coalesce(sqlc.narg('slug'), slug),
content = coalesce(sqlc.narg('content'), content),
summary = coalesce(sqlc.narg('summary'), summary),
@@ -105,12 +108,30 @@ WHERE id = $1;
-- web -------------------------------------------------------
-- name: GetPublicPostBySlug :one
SELECT p.*,
f.file_url AS cover,
COALESCE(ps.view, 0) AS view
SELECT p.id,
p.title,
p.slug,
p.content,
p.summary,
p.published_at,
f.file_url AS cover,
COALESCE(ps.view_count, 0) AS view_count,
c.code AS category_code,
c.name AS category_name,
COALESCE(t.tags, '[]') AS tags
FROM posts p
LEFT JOIN files f ON f.id = p.cover_id
LEFT JOIN post_stats ps ON ps.post_id = p.id
LEFT JOIN post_category pc ON pc.post_id = p.id
LEFT JOIN categories c ON pc.category_id = c.id
LEFT JOIN LATERAL (
SELECT jsonb_agg(
jsonb_build_object('name', tag.name, 'code', tag.code)
) AS tags
FROM post_tag pt
JOIN tags tag ON tag.id = pt.tag_id
WHERE pt.post_id = p.id
) t ON true
WHERE p.slug = $1
AND p.status = 1
AND p.published_at < NOW()
@@ -132,21 +153,22 @@ WITH paginated_posts AS (
ORDER BY sort DESC, published_at DESC, id DESC
LIMIT $1 OFFSET $2)
SELECT p.*,
f.file_url AS cover,
c.name AS category_name,
c.id AS category_id,
COALESCE(ps.view, 0) AS view,
f.file_url AS cover,
c.name AS category_name,
c.id AS category_id,
c.code AS category_code,
COALESCE(ps.view_count, 0) AS view_count,
COALESCE(
jsonb_agg(
jsonb_build_object(
'id', t.id,
'name', t.name,
'code', t.code
)
ORDER BY t.sort DESC, t.id
) FILTER (WHERE t.id IS NOT NULL),
'[]'::jsonb
) AS tags
jsonb_agg(
jsonb_build_object(
'id', t.id,
'name', t.name,
'code', t.code
)
ORDER BY t.sort DESC, t.id
) FILTER (WHERE t.id IS NOT NULL),
'[]'::jsonb
) AS tags
FROM paginated_posts p
LEFT JOIN files f ON f.id = p.cover_id
LEFT JOIN post_category pc ON pc.post_id = p.id
@@ -164,7 +186,8 @@ GROUP BY p.id,
f.file_url,
c.name,
c.id,
ps.view
c.code,
ps.view_count
ORDER BY p.sort DESC, p.published_at DESC, p.id DESC;
-- name: CountPublishedPosts :one
@@ -174,35 +197,90 @@ WHERE status = 1
AND published_at < NOW();
-- name: ListArchives :many
SELECT
p.id,
p.slug,
p.title,
p.published_at,
c."name" AS category_name
FROM
posts p
LEFT JOIN post_category pc ON p.id = pc.post_id
LEFT JOIN categories c ON c.id = pc.category_id
WHERE
p.status = 1
SELECT p.id,
p.slug,
p.title,
p.published_at,
c."name" AS category_name
FROM posts p
LEFT JOIN post_category pc ON p.id = pc.post_id
LEFT JOIN categories c ON c.id = pc.category_id
WHERE p.status = 1
AND p.published_at < NOW()
ORDER BY
p.published_at DESC, p.id DESC;
ORDER BY p.published_at DESC, p.id DESC;
-- name: ListCategoryStats :many
SELECT
c.id,
c.name,
COUNT(p.id) AS post_count
FROM
categories c
LEFT JOIN post_category pc ON c.id = pc.category_id
LEFT JOIN posts p ON p.id = pc.post_id
AND p.status = 1
SELECT c.id,
c.name,
c.code,
COUNT(p.id) AS post_count
FROM categories c
LEFT JOIN post_category pc ON c.id = pc.category_id
LEFT JOIN posts p ON p.id = pc.post_id
AND p.status = 1
AND p.published_at < NOW()
GROUP BY c.id,
c.sort,
c.name,
c.code
ORDER BY c.sort DESC, c.id;
-- name: ListPublishedPostsWithFilters :many
SELECT p.id,
p.title,
p.slug,
p.summary,
p.sort,
p.published_at,
f.file_url AS cover,
c."name" AS category_name,
c.code AS category_code,
COALESCE(t.tags, '[]') AS tags,
COALESCE(ps."view_count", 0) AS view_count
FROM posts p
LEFT JOIN post_category pc ON pc.post_id = p.id
LEFT JOIN categories c ON c.id = pc.category_id
LEFT JOIN post_stats ps ON ps.post_id = p.id
LEFT JOIN files f ON f.id = p.cover_id
LEFT JOIN LATERAL (
SELECT jsonb_agg(
jsonb_build_object('name', tag.name, 'code', tag.code)
) AS tags
FROM post_tag pt
JOIN tags tag ON tag.id = pt.tag_id
WHERE pt.post_id = p.id
) t ON true
WHERE
-- 只显示已发布的文章
p.status = 1
AND p.published_at < NOW()
GROUP BY
c.id,
c.sort,
c.name
ORDER BY c.sort DESC, c.id;
AND
-- category code 过滤NULL 或空字符串时不过滤)
(
COALESCE(sqlc.narg('category_code')::text, '') = ''
OR EXISTS (SELECT 1
FROM post_category pc2
JOIN categories c2 ON c2.id = pc2.category_id
WHERE pc2.post_id = p.id
AND c2.code = sqlc.narg('category_code')::text)
)
AND
-- tag code 过滤NULL 或空字符串时不过滤)
(
COALESCE(sqlc.narg('tag_code')::text, '') = ''
OR EXISTS (SELECT 1
FROM post_tag pt2
JOIN tags t2 ON t2.id = pt2.tag_id
WHERE pt2.post_id = p.id
AND t2.code = sqlc.narg('tag_code')::text)
)
ORDER BY p.sort DESC,
p.published_at DESC,
p.id DESC;
-- name: GetPostsForSitemap :many
SELECT slug, created_at, updated_at
FROM posts
WHERE status = 1
AND published_at < NOW()
ORDER BY sort DESC, published_at DESC, id DESC;

View File

@@ -18,7 +18,7 @@ WITH visitor AS (
)
INSERT INTO post_stats (
post_id,
view
view_count
)
SELECT
post_id,
@@ -26,4 +26,4 @@ SELECT
FROM visitor
ON CONFLICT (post_id)
DO UPDATE
SET view = post_stats.view + 1;
SET view_count = post_stats.view_count + 1;

View File

@@ -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

View File

@@ -0,0 +1,25 @@
CREATE TABLE access_logs
(
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
request_id TEXT,
user_id INTEGER,
ip INET,
user_agent TEXT,
request_method VARCHAR(10) NOT NULL,
request_path TEXT NOT NULL,
referer TEXT,
message TEXT,
status_code INTEGER,
response_time_ms INTEGER,
started_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_access_logs_created_at
ON access_logs (created_at);
CREATE INDEX idx_access_logs_request_path
ON access_logs (request_path);
CREATE INDEX idx_access_logs_ip
ON access_logs (ip);

View File

@@ -0,0 +1,9 @@
CREATE TABLE file_image_metadata
(
id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
file_id INTEGER UNIQUE NOT NULL,
width INTEGER NOT NULL,
height INTEGER NOT NULL,
format TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

View File

@@ -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 '文章阅读量';

View File

@@ -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 '更新时间';

View File

@@ -0,0 +1,127 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: access_logs.sql
package sqlc
import (
"context"
"net/netip"
"time"
)
const countAccessLogs = `-- name: CountAccessLogs :one
SELECT COUNT(*)
FROM access_logs
`
func (q *Queries) CountAccessLogs(ctx context.Context) (int64, error) {
row := q.db.QueryRow(ctx, countAccessLogs)
var count int64
err := row.Scan(&count)
return count, err
}
const createAccessLog = `-- name: CreateAccessLog :exec
INSERT INTO access_logs(request_id, user_id, ip, user_agent, request_method, request_path, referer, status_code,
response_time_ms, started_at, message)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
`
type CreateAccessLogParams struct {
RequestID *string `json:"request_id"`
UserID *int32 `json:"user_id"`
Ip *netip.Addr `json:"ip"`
UserAgent *string `json:"user_agent"`
RequestMethod string `json:"request_method"`
RequestPath string `json:"request_path"`
Referer *string `json:"referer"`
StatusCode *int32 `json:"status_code"`
ResponseTimeMs *int32 `json:"response_time_ms"`
StartedAt time.Time `json:"started_at"`
Message *string `json:"message"`
}
func (q *Queries) CreateAccessLog(ctx context.Context, arg CreateAccessLogParams) error {
_, err := q.db.Exec(ctx, createAccessLog,
arg.RequestID,
arg.UserID,
arg.Ip,
arg.UserAgent,
arg.RequestMethod,
arg.RequestPath,
arg.Referer,
arg.StatusCode,
arg.ResponseTimeMs,
arg.StartedAt,
arg.Message,
)
return err
}
const listAccessLogs = `-- name: ListAccessLogs :many
SELECT a.id, a.request_id, a.user_id, a.ip, a.user_agent, a.request_method, a.request_path, a.referer, a.message, a.status_code, a.response_time_ms, a.started_at, a.created_at, u.username
FROM access_logs a
LEFT JOIN sys_users u
ON u.id = a.user_id
ORDER BY a.started_at DESC, a.id
LIMIT $1 OFFSET $2
`
type ListAccessLogsParams struct {
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type ListAccessLogsRow struct {
ID int64 `json:"id"`
RequestID *string `json:"request_id"`
UserID *int32 `json:"user_id"`
Ip *netip.Addr `json:"ip"`
UserAgent *string `json:"user_agent"`
RequestMethod string `json:"request_method"`
RequestPath string `json:"request_path"`
Referer *string `json:"referer"`
Message *string `json:"message"`
StatusCode *int32 `json:"status_code"`
ResponseTimeMs *int32 `json:"response_time_ms"`
StartedAt time.Time `json:"started_at"`
CreatedAt time.Time `json:"created_at"`
Username *string `json:"username"`
}
func (q *Queries) ListAccessLogs(ctx context.Context, arg ListAccessLogsParams) ([]ListAccessLogsRow, error) {
rows, err := q.db.Query(ctx, listAccessLogs, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListAccessLogsRow{}
for rows.Next() {
var i ListAccessLogsRow
if err := rows.Scan(
&i.ID,
&i.RequestID,
&i.UserID,
&i.Ip,
&i.UserAgent,
&i.RequestMethod,
&i.RequestPath,
&i.Referer,
&i.Message,
&i.StatusCode,
&i.ResponseTimeMs,
&i.StartedAt,
&i.CreatedAt,
&i.Username,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View File

@@ -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

View File

@@ -0,0 +1,48 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: file_image_metadata.sql
package sqlc
import (
"context"
)
type CopyFileImageMetadataParams struct {
FileID int32 `json:"file_id"`
Width int32 `json:"width"`
Height int32 `json:"height"`
Format string `json:"format"`
}
const createFileImageMetadata = `-- name: CreateFileImageMetadata :exec
INSERT INTO file_image_metadata(file_id, width, height, format)
VALUES ($1, $2, $3, $4)
`
type CreateFileImageMetadataParams struct {
FileID int32 `json:"file_id"`
Width int32 `json:"width"`
Height int32 `json:"height"`
Format string `json:"format"`
}
func (q *Queries) CreateFileImageMetadata(ctx context.Context, arg CreateFileImageMetadataParams) error {
_, err := q.db.Exec(ctx, createFileImageMetadata,
arg.FileID,
arg.Width,
arg.Height,
arg.Format,
)
return err
}
const truncateFileImageMetadata = `-- name: TruncateFileImageMetadata :exec
TRUNCATE TABLE file_image_metadata
`
func (q *Queries) TruncateFileImageMetadata(ctx context.Context) error {
_, err := q.db.Exec(ctx, truncateFileImageMetadata)
return err
}

View File

@@ -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
}

View File

@@ -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"`
// 更新时间

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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

View File

@@ -0,0 +1,48 @@
package admin
import (
"net/http"
"server/internal/model/common"
"server/internal/pkg/httputil"
"server/internal/router"
"server/internal/service/admin"
"github.com/go-chi/chi/v5"
)
type AccessLogHandler struct {
accessLogService *admin.AccessLogService
}
var _ router.Registrar = (*AccessLogHandler)(nil)
func NewAccessLogHandler(accessLogService *admin.AccessLogService) *AccessLogHandler {
return &AccessLogHandler{
accessLogService: accessLogService,
}
}
func (h *AccessLogHandler) Register(r chi.Router) {
r.Route("/access-logs", func(r chi.Router) {
r.Get("/", h.List)
})
}
func (h *AccessLogHandler) List(w http.ResponseWriter, r *http.Request) {
pagination := httputil.Pagination(r)
result, err := h.accessLogService.List(r.Context(), pagination)
if err != nil {
httputil.Fail(w, err)
return
}
resp := common.PageResponse{
Page: pagination.Page,
PageSize: pagination.PageSize,
List: result.List,
Total: result.Total,
}
httputil.OkWithPage(w, &resp)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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),
),
)

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -0,0 +1,58 @@
package middleware
import (
"context"
"net/http"
"server/internal/db"
"server/internal/db/sqlc"
"server/internal/pkg/safego"
"time"
)
type AccessLogMiddleware struct {
store *db.Store
}
func NewAccessLogMiddleware(store *db.Store) *AccessLogMiddleware {
return &AccessLogMiddleware{
store: store,
}
}
func (m *AccessLogMiddleware) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqCtx := GetRequestContext(r.Context())
userCtx := GetUserContext(r.Context())
rw := NewResponseRecorder(w)
next.ServeHTTP(rw, r)
// 写入数据库时是null
userID := &userCtx.UserID
if userCtx.UserID == 0 {
userID = nil
}
durationMs := int32(time.Since(reqCtx.StartTime).Milliseconds())
statusCode := int32(rw.statusCode)
// 异步写入
safego.Go(func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = m.store.Queries.CreateAccessLog(ctx, sqlc.CreateAccessLogParams{
RequestID: &reqCtx.RequestID,
UserID: userID,
Ip: &reqCtx.ClientIp,
UserAgent: &reqCtx.UserAgent,
RequestMethod: reqCtx.Method,
RequestPath: reqCtx.Path,
Message: &rw.errorMsg,
Referer: &reqCtx.Referer,
StatusCode: &statusCode,
ResponseTimeMs: &durationMs,
StartedAt: reqCtx.StartTime,
})
})
})
}

View File

@@ -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

View File

@@ -0,0 +1,57 @@
package middleware
import (
"context"
"net/netip"
"time"
)
type requestContextKey struct{}
type RequestContext struct {
RequestID string
StartTime time.Time
Method string
Path string
ClientIp netip.Addr
UserAgent string
Referer string
}
func WithRequestContext(ctx context.Context, req *RequestContext) context.Context {
return context.WithValue(ctx, requestContextKey{}, req)
}
func GetRequestContext(ctx context.Context) *RequestContext {
v, ok := ctx.Value(requestContextKey{}).(*RequestContext)
if !ok {
return nil
}
return v
}
// ----------------------------------------------------------------------------------------
type userContextKey struct{}
type UserContext struct {
UserID int32
IsAdmin bool
TokenVersion int32
}
func WithUserContext(ctx context.Context, user *UserContext) context.Context {
return context.WithValue(ctx, userContextKey{}, user)
}
func GetUserContext(ctx context.Context) *UserContext {
v, ok := ctx.Value(userContextKey{}).(*UserContext)
if !ok {
return nil
}
return v
}

View File

@@ -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

View File

@@ -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...)

View File

@@ -0,0 +1,53 @@
package middleware
import (
"net/http"
"server/internal/pkg/httputil"
"time"
gonanoid "github.com/matoous/go-nanoid/v2"
)
func generateRequestID() string {
id, _ := gonanoid.New(16) // 16字符
return id
}
type RequestContextMiddleware struct {
}
func NewRequestContextMiddleware() *RequestContextMiddleware {
return &RequestContextMiddleware{}
}
func (m *RequestContextMiddleware) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqCtx := &RequestContext{
RequestID: generateRequestID(),
StartTime: time.Now(),
Method: r.Method,
Path: r.URL.RequestURI(),
ClientIp: httputil.ClientIP(r),
UserAgent: r.UserAgent(),
Referer: r.Referer(),
}
userCtx := &UserContext{
UserID: 0,
IsAdmin: false,
TokenVersion: 0,
}
ctx := WithRequestContext(
r.Context(),
reqCtx,
)
ctx = WithUserContext(
ctx,
userCtx,
)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

View File

@@ -0,0 +1,47 @@
package middleware
import (
"encoding/json"
"net/http"
"server/internal/model/common"
)
type ResponseWriter struct {
http.ResponseWriter
statusCode int
bytes int
errorMsg string
}
func NewResponseRecorder(w http.ResponseWriter) *ResponseWriter {
return &ResponseWriter{
ResponseWriter: w,
statusCode: http.StatusOK,
}
}
func (rw *ResponseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
// extractErrorMessage 从响应体中提取错误信息
func (rw *ResponseWriter) extractErrorMessage(body []byte) string {
var resp common.Response
if err := json.Unmarshal(body, &resp); err == nil && resp.Message != "" {
return resp.Message
}
return string(body)
}
func (rw *ResponseWriter) Write(b []byte) (int, error) {
n, err := rw.ResponseWriter.Write(b)
rw.bytes += n
// 只在错误状态码且未记录错误时处理
if rw.statusCode >= 400 && rw.errorMsg == "" && n > 0 {
rw.errorMsg = rw.extractErrorMessage(b[:n])
}
return n, err
}

View File

@@ -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
}

View File

@@ -0,0 +1,35 @@
package request
import (
"path/filepath"
"strings"
)
type UploadFileRequest struct {
Folder string `json:"folder" validate:"omitempty,oneof=cover avatar article common"`
}
// AllowedUploadExts 上传文件扩展名白名单(扩展名 → 期望的真实 MIME
var AllowedUploadExts = map[string]string{
// 图片
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
// 视频
".mp4": "video/mp4",
".webm": "video/webm",
}
// IsAllowedUploadExt 扩展名是否在白名单内(大小写不敏感)
func IsAllowedUploadExt(ext string) bool {
_, ok := AllowedUploadExts[strings.ToLower(ext)]
return ok
}
// MatchUploadMime 校验实际 MIME 与扩展名匹配
func MatchUploadMime(filename, detectedMime string) bool {
expected, ok := AllowedUploadExts[strings.ToLower(filepath.Ext(filename))]
return ok && detectedMime == expected
}

View File

@@ -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"`
}

View File

@@ -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
}

View File

@@ -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,

View File

@@ -5,6 +5,9 @@ import "fmt"
const (
UserApiPermissionsPattern = "user:api:permissions:*"
UserInfoPattern = "user:info:*"
UserAuthStatePattern = "user:auth_state:*"
AuthRefreshPattern = "auth:refresh:*" // 新增:覆盖 <hash> 和 user:<id> 两种 key
)
func UserApiPermissions(id int32) string {
@@ -23,3 +26,7 @@ func AuthRefresh(hash string) string {
func AuthRefreshUser(userID int32) string {
return fmt.Sprintf("auth:refresh:user:%d", userID)
}
func UserAuthState(id int32) string {
return fmt.Sprintf("user:auth_state:%d", id)
}

View File

@@ -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, "内部服务器错误")
)

View File

@@ -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 获取客户端 IPtrust_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-IPnginx 单层代理覆盖时最准)
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
}

View File

@@ -0,0 +1,18 @@
package safego
import (
"log/slog"
"runtime/debug"
)
// Go 以协程执行 fn并捕获 panic 记录日志,防止协程崩溃杀死整个进程
func Go(fn func()) {
go func() {
defer func() {
if r := recover(); r != nil {
slog.Error("goroutine panic recovered", "panic", r, "stack", string(debug.Stack()))
}
}()
fn()
}()
}

View File

@@ -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))))
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)
}

View File

@@ -19,4 +19,8 @@ sql:
- db_type: "timestamptz"
nullable: true
go_type:
type: "*time.Time"
type: "*time.Time"
- db_type: "jsonb"
go_type:
import: "encoding/json"
type: "RawMessage"