Simplify admin publishing pipeline

This commit is contained in:
yarnom 2026-06-03 18:18:50 +08:00
parent 13e7e4026d
commit 9186801c7f
37 changed files with 750 additions and 3367 deletions

View file

@ -3,6 +3,7 @@ package admin
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
@ -25,6 +26,25 @@ type PostListOptions struct {
Offset int
}
type AuditLogInput struct {
ActorID *string
ActorUsername string
Action string
ResourceType string
ResourceID string
IPAddress string
UserAgent string
Details map[string]any
}
type AuditLogListOptions struct {
Action string
ResourceType string
Query string
Limit int
Offset int
}
type PostInput struct {
Slug string `json:"slug"`
Title string `json:"title"`
@ -389,28 +409,6 @@ WHERE id = $1`, id))
return job, nil
}
func (s *Store) CreateAsset(ctx context.Context, asset Asset) (Asset, error) {
created, err := scanAsset(s.db.QueryRow(ctx, `
INSERT INTO assets (path, original_name, mime_type, size_bytes, sha256)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (path)
DO UPDATE SET original_name = excluded.original_name,
mime_type = excluded.mime_type,
size_bytes = excluded.size_bytes,
sha256 = excluded.sha256
RETURNING id, path, original_name, mime_type, size_bytes, sha256, created_at, created_by`,
asset.Path,
asset.OriginalName,
asset.MimeType,
asset.SizeBytes,
asset.SHA256,
))
if err != nil {
return Asset{}, fmt.Errorf("create asset: %w", err)
}
return created, nil
}
func (s *Store) PublishedPostsForExport(ctx context.Context) ([]Post, error) {
rows, err := s.db.Query(ctx, `
SELECT id, slug, title, summary, body_markdown, status, cover, version,
@ -473,6 +471,114 @@ WHERE id = $1`, id, log, message)
return nil
}
func (s *Store) CreateAuditLog(ctx context.Context, input AuditLogInput) error {
action := strings.TrimSpace(input.Action)
if action == "" {
return errors.New("audit action is required")
}
details := input.Details
if details == nil {
details = map[string]any{}
}
detailsJSON, err := json.Marshal(details)
if err != nil {
return fmt.Errorf("encode audit details: %w", err)
}
_, err = s.db.Exec(ctx, `
INSERT INTO audit_logs (actor_id, actor_username, action, resource_type, resource_id, ip_address, user_agent, details)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)`,
input.ActorID,
input.ActorUsername,
action,
strings.TrimSpace(input.ResourceType),
strings.TrimSpace(input.ResourceID),
strings.TrimSpace(input.IPAddress),
strings.TrimSpace(input.UserAgent),
string(detailsJSON),
)
if err != nil {
return fmt.Errorf("create audit log: %w", err)
}
return nil
}
func (s *Store) ListAuditLogs(ctx context.Context, opts AuditLogListOptions) ([]AuditLog, error) {
limit := opts.Limit
if limit <= 0 || limit > 200 {
limit = 50
}
offset := opts.Offset
if offset < 0 {
offset = 0
}
args := []any{limit, offset}
where := "true"
if strings.TrimSpace(opts.Action) != "" {
args = append(args, strings.TrimSpace(opts.Action))
where += fmt.Sprintf(" AND action = $%d", len(args))
}
if strings.TrimSpace(opts.ResourceType) != "" {
args = append(args, strings.TrimSpace(opts.ResourceType))
where += fmt.Sprintf(" AND resource_type = $%d", len(args))
}
if strings.TrimSpace(opts.Query) != "" {
args = append(args, "%"+strings.TrimSpace(opts.Query)+"%")
where += fmt.Sprintf(` AND (
actor_username ILIKE $%d OR action ILIKE $%d OR resource_type ILIKE $%d OR resource_id ILIKE $%d OR details::text ILIKE $%d
)`, len(args), len(args), len(args), len(args), len(args))
}
rows, err := s.db.Query(ctx, `
SELECT id, actor_id, actor_username, action, resource_type, resource_id, ip_address, user_agent, details, created_at
FROM audit_logs
WHERE `+where+`
ORDER BY created_at DESC
LIMIT $1 OFFSET $2`, args...)
if err != nil {
return nil, fmt.Errorf("list audit logs: %w", err)
}
defer rows.Close()
var logs []AuditLog
for rows.Next() {
log, err := scanAuditLog(rows)
if err != nil {
return nil, err
}
logs = append(logs, log)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("audit log rows: %w", err)
}
return logs, nil
}
func (s *Store) CountAuditLogs(ctx context.Context, opts AuditLogListOptions) (int, error) {
args := []any{}
where := "true"
if strings.TrimSpace(opts.Action) != "" {
args = append(args, strings.TrimSpace(opts.Action))
where += fmt.Sprintf(" AND action = $%d", len(args))
}
if strings.TrimSpace(opts.ResourceType) != "" {
args = append(args, strings.TrimSpace(opts.ResourceType))
where += fmt.Sprintf(" AND resource_type = $%d", len(args))
}
if strings.TrimSpace(opts.Query) != "" {
args = append(args, "%"+strings.TrimSpace(opts.Query)+"%")
where += fmt.Sprintf(` AND (
actor_username ILIKE $%d OR action ILIKE $%d OR resource_type ILIKE $%d OR resource_id ILIKE $%d OR details::text ILIKE $%d
)`, len(args), len(args), len(args), len(args), len(args))
}
var total int
if err := s.db.QueryRow(ctx, `SELECT count(*) FROM audit_logs WHERE `+where, args...).Scan(&total); err != nil {
return 0, fmt.Errorf("count audit logs: %w", err)
}
return total, nil
}
func validatePostInput(input PostInput, creating bool) error {
if strings.TrimSpace(input.Title) == "" {
return errors.New("title is required")
@ -780,24 +886,35 @@ func scanBuildJob(row postScanner) (BuildJob, error) {
return job, nil
}
func scanAsset(row postScanner) (Asset, error) {
var asset Asset
var createdBy sql.NullString
func scanAuditLog(row postScanner) (AuditLog, error) {
var log AuditLog
var actorID sql.NullString
var details []byte
err := row.Scan(
&asset.ID,
&asset.Path,
&asset.OriginalName,
&asset.MimeType,
&asset.SizeBytes,
&asset.SHA256,
&asset.CreatedAt,
&createdBy,
&log.ID,
&actorID,
&log.ActorUsername,
&log.Action,
&log.ResourceType,
&log.ResourceID,
&log.IPAddress,
&log.UserAgent,
&details,
&log.CreatedAt,
)
if err != nil {
return Asset{}, err
return AuditLog{}, err
}
asset.CreatedBy = nullStringPtr(createdBy)
return asset, nil
log.ActorID = nullStringPtr(actorID)
if len(details) > 0 {
if err := json.Unmarshal(details, &log.Details); err != nil {
return AuditLog{}, fmt.Errorf("decode audit details: %w", err)
}
}
if log.Details == nil {
log.Details = map[string]any{}
}
return log, nil
}
func nullTimePtr(value sql.NullTime) *time.Time {