作品展示页:前端展示 + 后台增删改查
- 新增 projects 表与迁移(唯一 slug 索引) - 后端:公开 GET /api/projects,后台 GET/POST/PUT/DELETE /api/admin/projects - 前端:/projects 双虚线边框卡片页(参考 diygod.cc/projects),后台管理页 - 左栏/手机顶栏/后台侧栏均加「作品」入口 - 补充项目 CRUD 单元测试与后台鉴权拦截测试
This commit is contained in:
@@ -72,6 +72,19 @@ func (s *Store) migrate() error {
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT ''
|
||||
)`,
|
||||
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS projects (
|
||||
id %s,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
slug TEXT NOT NULL,
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
cover_url TEXT NOT NULL DEFAULT '',
|
||||
url TEXT NOT NULL DEFAULT '',
|
||||
repo_url TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'published',
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`, ai),
|
||||
}
|
||||
for _, q := range stmts {
|
||||
if _, err := s.db.Exec(s.db.Q(q)); err != nil {
|
||||
@@ -99,6 +112,7 @@ func (s *Store) migrate() error {
|
||||
{"idx_posts_feed", `CREATE INDEX IF NOT EXISTS idx_posts_feed ON posts(status, published_at DESC)`},
|
||||
{"idx_tags_slug", `CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_slug ON tags(slug)`},
|
||||
{"idx_post_tags_tag", `CREATE INDEX IF NOT EXISTS idx_post_tags_tag ON post_tags(tag_id)`},
|
||||
{"idx_projects_slug", `CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_slug ON projects(slug)`},
|
||||
}
|
||||
for _, ix := range indexes {
|
||||
if _, err := s.db.Exec(s.db.Q(ix.ddl)); err != nil && !strings.Contains(err.Error(), "already exists") {
|
||||
@@ -453,6 +467,169 @@ func (s *Store) uniqueSlug(base string, excludeID int64) string {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) uniqueProjectSlug(base string, excludeID int64) string {
|
||||
base = Slugify(base)
|
||||
if base == "" {
|
||||
base = "project"
|
||||
}
|
||||
candidate := base
|
||||
for i := 2; ; i++ {
|
||||
var id int64
|
||||
err := s.db.QueryRow(s.db.Q(`SELECT id FROM projects WHERE slug = ? AND id <> ?`), candidate, excludeID).Scan(&id)
|
||||
if err == sql.ErrNoRows {
|
||||
return candidate
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%s-%d", base, time.Now().Unix())
|
||||
}
|
||||
candidate = fmt.Sprintf("%s-%d", base, i)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- projects ----------
|
||||
|
||||
func scanProject(row interface{ Scan(...any) error }) (model.Project, error) {
|
||||
var p model.Project
|
||||
err := row.Scan(&p.ID, &p.Title, &p.Slug, &p.Summary, &p.CoverURL, &p.URL,
|
||||
&p.RepoURL, &p.Status, &p.Position, &p.CreatedAt, &p.UpdatedAt)
|
||||
return p, err
|
||||
}
|
||||
|
||||
// ListProjects returns projects with an optional status filter. Pass "" to
|
||||
// get every project (admin), or model.StatusPublished / model.StatusDraft to
|
||||
// narrow. Results are ordered by explicit Position then recency.
|
||||
func (s *Store) ListProjects(status string) ([]model.Project, error) {
|
||||
where := ""
|
||||
args := []any{}
|
||||
if status == model.StatusPublished || status == model.StatusDraft {
|
||||
where = " WHERE status = ?"
|
||||
args = append(args, status)
|
||||
}
|
||||
q := s.db.Q(`SELECT id,title,slug,summary,cover_url,url,repo_url,status,position,created_at,updated_at
|
||||
FROM projects` + where + ` ORDER BY position ASC, created_at DESC`)
|
||||
rows, err := s.db.Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []model.Project{}
|
||||
for rows.Next() {
|
||||
p, err := scanProject(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetProject(id int64) (model.Project, error) {
|
||||
var p model.Project
|
||||
err := s.db.QueryRow(s.db.Q(`SELECT id,title,slug,summary,cover_url,url,repo_url,status,position,created_at,updated_at
|
||||
FROM projects WHERE id = ?`), id).Scan(&p.ID, &p.Title, &p.Slug, &p.Summary,
|
||||
&p.CoverURL, &p.URL, &p.RepoURL, &p.Status, &p.Position, &p.CreatedAt, &p.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return p, ErrNotFound
|
||||
}
|
||||
return p, err
|
||||
}
|
||||
|
||||
func (s *Store) CreateProject(in model.ProjectInput) (model.Project, error) {
|
||||
p := model.Project{
|
||||
Title: strings.TrimSpace(in.Title),
|
||||
Slug: strings.TrimSpace(in.Slug),
|
||||
Summary: strings.TrimSpace(in.Summary),
|
||||
CoverURL: strings.TrimSpace(in.CoverURL),
|
||||
URL: strings.TrimSpace(in.URL),
|
||||
RepoURL: strings.TrimSpace(in.RepoURL),
|
||||
Status: NormalizeStatus(in.Status),
|
||||
Position: in.Position,
|
||||
}
|
||||
if p.Status == "" {
|
||||
p.Status = model.StatusPublished
|
||||
}
|
||||
if p.Slug == "" {
|
||||
p.Slug = Slugify(p.Title)
|
||||
}
|
||||
if p.Slug == "" {
|
||||
p.Slug = "project-" + time.Now().UTC().Format("20060102-150405")
|
||||
}
|
||||
p.Slug = s.uniqueProjectSlug(p.Slug, 0)
|
||||
p.CreatedAt = now()
|
||||
p.UpdatedAt = p.CreatedAt
|
||||
|
||||
q := s.db.Q(`INSERT INTO projects (title,slug,summary,cover_url,url,repo_url,status,position,created_at,updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)`)
|
||||
var id int64
|
||||
if s.db.Dialect == db.Postgres {
|
||||
err := s.db.QueryRow(q, p.Title, p.Slug, p.Summary, p.CoverURL, p.URL, p.RepoURL,
|
||||
p.Status, p.Position, p.CreatedAt, p.UpdatedAt).Scan(&id)
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
} else {
|
||||
res, err := s.db.Exec(q, p.Title, p.Slug, p.Summary, p.CoverURL, p.URL, p.RepoURL,
|
||||
p.Status, p.Position, p.CreatedAt, p.UpdatedAt)
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
id, err = res.LastInsertId()
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
}
|
||||
return s.GetProject(id)
|
||||
}
|
||||
|
||||
func (s *Store) UpdateProject(id int64, in model.ProjectInput) (model.Project, error) {
|
||||
cur, err := s.GetProject(id)
|
||||
if err != nil {
|
||||
return cur, err
|
||||
}
|
||||
p := cur
|
||||
if in.Title != "" {
|
||||
p.Title = strings.TrimSpace(in.Title)
|
||||
}
|
||||
if in.Summary != "" {
|
||||
p.Summary = strings.TrimSpace(in.Summary)
|
||||
}
|
||||
if in.CoverURL != "" {
|
||||
p.CoverURL = strings.TrimSpace(in.CoverURL)
|
||||
}
|
||||
if in.URL != "" {
|
||||
p.URL = strings.TrimSpace(in.URL)
|
||||
}
|
||||
if in.RepoURL != "" {
|
||||
p.RepoURL = strings.TrimSpace(in.RepoURL)
|
||||
}
|
||||
if in.Status != "" {
|
||||
p.Status = NormalizeStatus(in.Status)
|
||||
}
|
||||
if in.Slug != "" && in.Slug != cur.Slug {
|
||||
p.Slug = s.uniqueProjectSlug(in.Slug, id)
|
||||
}
|
||||
p.Position = in.Position
|
||||
p.UpdatedAt = now()
|
||||
|
||||
if _, err := s.db.Exec(s.db.Q(`UPDATE projects SET title=?,slug=?,summary=?,cover_url=?,url=?,repo_url=?,
|
||||
status=?,position=?,updated_at=? WHERE id=?`),
|
||||
p.Title, p.Slug, p.Summary, p.CoverURL, p.URL, p.RepoURL, p.Status, p.Position, p.UpdatedAt, id); err != nil {
|
||||
return p, err
|
||||
}
|
||||
return s.GetProject(id)
|
||||
}
|
||||
|
||||
func (s *Store) DeleteProject(id int64) error {
|
||||
res, err := s.db.Exec(s.db.Q(`DELETE FROM projects WHERE id = ?`), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err == nil && n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- write ----------
|
||||
|
||||
func (s *Store) Create(in model.PostInput) (model.Post, error) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -291,3 +292,86 @@ func TestListWithMaliciousOrderByFailsSafe(t *testing.T) {
|
||||
t.Errorf("unexpected total %d", page.Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectCRUD(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
|
||||
// empty list is a non-nil slice
|
||||
list, err := s.ListProjects("")
|
||||
if err != nil {
|
||||
t.Fatalf("ListProjects: %v", err)
|
||||
}
|
||||
if list == nil {
|
||||
t.Fatal("ListProjects returned nil slice")
|
||||
}
|
||||
|
||||
created, err := s.CreateProject(model.ProjectInput{
|
||||
Title: "Folo",
|
||||
Summary: "This AI RSS reader reads the internet for you",
|
||||
CoverURL: "https://folo.is/cover.webp",
|
||||
URL: "https://folo.is",
|
||||
RepoURL: "https://github.com/DIYgod/RSSHub-Radar",
|
||||
Status: model.StatusPublished,
|
||||
Position: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProject: %v", err)
|
||||
}
|
||||
if created.ID == 0 {
|
||||
t.Fatal("created project has no ID")
|
||||
}
|
||||
if created.Slug != "folo" {
|
||||
t.Errorf("slug = %q, want folo", created.Slug)
|
||||
}
|
||||
|
||||
// duplicate slug gets disambiguated
|
||||
dup, err := s.CreateProject(model.ProjectInput{Title: "Folo", Status: model.StatusPublished})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProject dup: %v", err)
|
||||
}
|
||||
if dup.Slug == created.Slug {
|
||||
t.Errorf("duplicate slug not disambiguated: %q", dup.Slug)
|
||||
}
|
||||
|
||||
// public list excludes drafts; admin ("") includes them
|
||||
draft, err := s.CreateProject(model.ProjectInput{Title: "Secret", Status: model.StatusDraft})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProject draft: %v", err)
|
||||
}
|
||||
pub, _ := s.ListProjects(model.StatusPublished)
|
||||
if len(pub) != 2 {
|
||||
t.Errorf("published list len = %d, want 2", len(pub))
|
||||
}
|
||||
all, _ := s.ListProjects("")
|
||||
if len(all) != 3 {
|
||||
t.Errorf("all list len = %d, want 3", len(all))
|
||||
}
|
||||
|
||||
// update
|
||||
upd, err := s.UpdateProject(created.ID, model.ProjectInput{Summary: "Updated summary", Position: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateProject: %v", err)
|
||||
}
|
||||
if upd.Summary != "Updated summary" || upd.Position != 1 {
|
||||
t.Errorf("update did not apply: %+v", upd)
|
||||
}
|
||||
|
||||
// get
|
||||
got, err := s.GetProject(draft.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProject: %v", err)
|
||||
}
|
||||
if got.Status != model.StatusDraft {
|
||||
t.Errorf("status = %q, want draft", got.Status)
|
||||
}
|
||||
|
||||
// delete
|
||||
if err := s.DeleteProject(created.ID); err != nil {
|
||||
t.Fatalf("DeleteProject: %v", err)
|
||||
}
|
||||
_, err = s.GetProject(created.ID)
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("after delete: got %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user