新增语言学习后台服务

- 分类管理:支持分类的增删改查
- 单词管理:支持单词的增删改查,包含音标、释义、例句等字段
- 更新 README 项目说明

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 22:38:09 +08:00
parent ea1b2569b5
commit 71a6bc4aa4
30 changed files with 1577 additions and 1 deletions
+68
View File
@@ -0,0 +1,68 @@
package app
import (
"net/http"
"github.com/fish/language/backend/internal/config"
"github.com/fish/language/backend/internal/handlers"
"github.com/fish/language/backend/internal/repository/queries"
"github.com/fish/language/backend/internal/services"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/jackc/pgx/v5/pgxpool"
)
// App wires all dependencies and exposes the HTTP server.
type App struct {
cfg *config.Config
router *chi.Mux
pool *pgxpool.Pool
}
// New creates and initializes a new App.
func New(cfg *config.Config, pool *pgxpool.Pool) *App {
app := &App{
cfg: cfg,
pool: pool,
}
app.setupRouter()
return app
}
func (a *App) setupRouter() {
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.SetHeader("Content-Type", "application/json"))
q := queries.New(a.pool)
categorySvc := services.NewCategoryService(q)
wordSvc := services.NewWordService(q)
categoryHandler := handlers.NewCategoryHandler(categorySvc)
wordHandler := handlers.NewWordHandler(wordSvc)
r.Route("/api/v1", func(api chi.Router) {
categoryHandler.Register(api)
wordHandler.Register(api)
})
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
})
a.router = r
}
// Router returns the underlying chi router for testing.
func (a *App) Router() *chi.Mux {
return a.router
}
// Run starts the HTTP server.
func (a *App) Run() error {
return http.ListenAndServe(a.cfg.HTTPAddr, a.router)
}
+23
View File
@@ -0,0 +1,23 @@
package config
import (
"fmt"
"github.com/kelseyhightower/envconfig"
)
// Config holds all application configuration loaded from environment variables.
type Config struct {
AppEnv string `envconfig:"APP_ENV" default:"development"`
HTTPAddr string `envconfig:"HTTP_ADDR" default:":8080"`
DatabaseURL string `envconfig:"DATABASE_URL" required:"true"`
}
// Load parses environment variables into Config.
func Load() (*Config, error) {
var cfg Config
if err := envconfig.Process("", &cfg); err != nil {
return nil, fmt.Errorf("failed to load config: %w", err)
}
return &cfg, nil
}
@@ -0,0 +1,126 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"github.com/fish/language/backend/internal/models"
"github.com/fish/language/backend/internal/services"
"github.com/go-chi/chi/v5"
)
// CategoryHandler handles HTTP requests for categories.
type CategoryHandler struct {
svc *services.CategoryService
}
// NewCategoryHandler creates a new CategoryHandler.
func NewCategoryHandler(svc *services.CategoryService) *CategoryHandler {
return &CategoryHandler{svc: svc}
}
// Register mounts category routes.
func (h *CategoryHandler) Register(r chi.Router) {
r.Get("/categories", h.List)
r.Post("/categories", h.Create)
r.Get("/categories/{id}", h.Get)
r.Put("/categories/{id}", h.Update)
r.Delete("/categories/{id}", h.Delete)
}
// List returns all categories.
func (h *CategoryHandler) List(w http.ResponseWriter, r *http.Request) {
items, err := h.svc.List(r.Context())
if err != nil {
status, code, msg := MapError(err)
RespondError(w, status, code, msg)
return
}
RespondSuccess(w, items)
}
// Get returns a category by id.
func (h *CategoryHandler) Get(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r)
if err != nil {
RespondError(w, http.StatusBadRequest, 10002, "invalid id")
return
}
item, err := h.svc.Get(r.Context(), id)
if err != nil {
status, code, msg := MapError(err)
RespondError(w, status, code, msg)
return
}
RespondSuccess(w, item)
}
// Create creates a new category.
func (h *CategoryHandler) Create(w http.ResponseWriter, r *http.Request) {
var req models.CreateCategoryRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
RespondError(w, http.StatusBadRequest, 10003, "invalid request body")
return
}
if req.Name == "" {
RespondError(w, http.StatusBadRequest, 10004, "name is required")
return
}
item, err := h.svc.Create(r.Context(), req)
if err != nil {
status, code, msg := MapError(err)
RespondError(w, status, code, msg)
return
}
RespondJSON(w, http.StatusCreated, modelsAPIResponse{Code: 0, Message: "created", Data: item})
}
// Update updates a category.
func (h *CategoryHandler) Update(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r)
if err != nil {
RespondError(w, http.StatusBadRequest, 10002, "invalid id")
return
}
var req models.UpdateCategoryRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
RespondError(w, http.StatusBadRequest, 10003, "invalid request body")
return
}
if req.Name == "" {
RespondError(w, http.StatusBadRequest, 10004, "name is required")
return
}
item, err := h.svc.Update(r.Context(), id, req)
if err != nil {
status, code, msg := MapError(err)
RespondError(w, status, code, msg)
return
}
RespondSuccess(w, item)
}
// Delete removes a category.
func (h *CategoryHandler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r)
if err != nil {
RespondError(w, http.StatusBadRequest, 10002, "invalid id")
return
}
if err := h.svc.Delete(r.Context(), id); err != nil {
status, code, msg := MapError(err)
RespondError(w, status, code, msg)
return
}
RespondSuccess(w, nil)
}
func parseID(r *http.Request) (int32, error) {
idStr := chi.URLParam(r, "id")
id64, err := strconv.ParseInt(idStr, 10, 32)
if err != nil {
return 0, err
}
return int32(id64), nil
}
+40
View File
@@ -0,0 +1,40 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"github.com/jackc/pgx/v5"
)
// RespondJSON writes a JSON response with the given status code.
func RespondJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(data)
}
// RespondSuccess returns the common success envelope.
func RespondSuccess(w http.ResponseWriter, data any) {
RespondJSON(w, http.StatusOK, modelsAPIResponse{Code: 0, Message: "ok", Data: data})
}
// RespondError returns the common error envelope.
func RespondError(w http.ResponseWriter, status int, code int, message string) {
RespondJSON(w, status, modelsAPIResponse{Code: code, Message: message, Data: nil})
}
// MapError maps repository/ service errors to HTTP status and code.
func MapError(err error) (status int, code int, message string) {
if errors.Is(err, pgx.ErrNoRows) {
return http.StatusNotFound, 10001, "resource not found"
}
return http.StatusInternalServerError, 10000, "internal server error"
}
type modelsAPIResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data"`
}
+136
View File
@@ -0,0 +1,136 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"github.com/fish/language/backend/internal/models"
"github.com/fish/language/backend/internal/services"
"github.com/go-chi/chi/v5"
)
// WordHandler handles HTTP requests for words.
type WordHandler struct {
svc *services.WordService
}
// NewWordHandler creates a new WordHandler.
func NewWordHandler(svc *services.WordService) *WordHandler {
return &WordHandler{svc: svc}
}
// Register mounts word routes.
func (h *WordHandler) Register(r chi.Router) {
r.Get("/words", h.List)
r.Post("/words", h.Create)
r.Get("/words/{id}", h.Get)
r.Put("/words/{id}", h.Update)
r.Delete("/words/{id}", h.Delete)
}
// List returns a paginated list of words.
func (h *WordHandler) List(w http.ResponseWriter, r *http.Request) {
req := models.ListWordsRequest{}
if v := r.URL.Query().Get("category_id"); v != "" {
if id64, err := strconv.ParseInt(v, 10, 32); err == nil {
id := int32(id64)
req.CategoryID = &id
}
}
req.Keyword = r.URL.Query().Get("keyword")
if v := r.URL.Query().Get("page"); v != "" {
if page, err := strconv.ParseInt(v, 10, 32); err == nil {
req.Page = int32(page)
}
}
if v := r.URL.Query().Get("page_size"); v != "" {
if ps, err := strconv.ParseInt(v, 10, 32); err == nil {
req.PageSize = int32(ps)
}
}
resp, err := h.svc.List(r.Context(), req)
if err != nil {
status, code, msg := MapError(err)
RespondError(w, status, code, msg)
return
}
RespondSuccess(w, resp)
}
// Get returns a word by id.
func (h *WordHandler) Get(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r)
if err != nil {
RespondError(w, http.StatusBadRequest, 10002, "invalid id")
return
}
item, err := h.svc.Get(r.Context(), id)
if err != nil {
status, code, msg := MapError(err)
RespondError(w, status, code, msg)
return
}
RespondSuccess(w, item)
}
// Create creates a new word.
func (h *WordHandler) Create(w http.ResponseWriter, r *http.Request) {
var req models.CreateWordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
RespondError(w, http.StatusBadRequest, 10003, "invalid request body")
return
}
if req.Word == "" || req.Translation == "" || req.CategoryID == 0 {
RespondError(w, http.StatusBadRequest, 10004, "word, translation and category_id are required")
return
}
item, err := h.svc.Create(r.Context(), req)
if err != nil {
status, code, msg := MapError(err)
RespondError(w, status, code, msg)
return
}
RespondJSON(w, http.StatusCreated, modelsAPIResponse{Code: 0, Message: "created", Data: item})
}
// Update updates a word.
func (h *WordHandler) Update(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r)
if err != nil {
RespondError(w, http.StatusBadRequest, 10002, "invalid id")
return
}
var req models.UpdateWordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
RespondError(w, http.StatusBadRequest, 10003, "invalid request body")
return
}
if req.Word == "" || req.Translation == "" || req.CategoryID == 0 {
RespondError(w, http.StatusBadRequest, 10004, "word, translation and category_id are required")
return
}
item, err := h.svc.Update(r.Context(), id, req)
if err != nil {
status, code, msg := MapError(err)
RespondError(w, status, code, msg)
return
}
RespondSuccess(w, item)
}
// Delete removes a word.
func (h *WordHandler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r)
if err != nil {
RespondError(w, http.StatusBadRequest, 10002, "invalid id")
return
}
if err := h.svc.Delete(r.Context(), id); err != nil {
status, code, msg := MapError(err)
RespondError(w, status, code, msg)
return
}
RespondSuccess(w, nil)
}
+87
View File
@@ -0,0 +1,87 @@
package models
import "time"
// Category represents a word category, such as "农场动物" or "颜色".
type Category struct {
ID int32 `json:"id"`
Name string `json:"name"`
SortOrder int32 `json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// CreateCategoryRequest is the payload for creating a category.
type CreateCategoryRequest struct {
Name string `json:"name"`
SortOrder int32 `json:"sort_order"`
}
// UpdateCategoryRequest is the payload for updating a category.
type UpdateCategoryRequest struct {
Name string `json:"name"`
SortOrder int32 `json:"sort_order"`
}
// Word represents a vocabulary word.
type Word struct {
ID int32 `json:"id"`
CategoryID int32 `json:"category_id"`
Word string `json:"word"`
Translation string `json:"translation"`
Phonetic *string `json:"phonetic,omitempty"`
ExampleSentence *string `json:"example_sentence,omitempty"`
AudioURL *string `json:"audio_url,omitempty"`
ImageURL *string `json:"image_url,omitempty"`
SortOrder int32 `json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// CreateWordRequest is the payload for creating a word.
type CreateWordRequest struct {
CategoryID int32 `json:"category_id"`
Word string `json:"word"`
Translation string `json:"translation"`
Phonetic *string `json:"phonetic,omitempty"`
ExampleSentence *string `json:"example_sentence,omitempty"`
AudioURL *string `json:"audio_url,omitempty"`
ImageURL *string `json:"image_url,omitempty"`
SortOrder int32 `json:"sort_order"`
}
// UpdateWordRequest is the payload for updating a word.
type UpdateWordRequest struct {
CategoryID int32 `json:"category_id"`
Word string `json:"word"`
Translation string `json:"translation"`
Phonetic *string `json:"phonetic,omitempty"`
ExampleSentence *string `json:"example_sentence,omitempty"`
AudioURL *string `json:"audio_url,omitempty"`
ImageURL *string `json:"image_url,omitempty"`
SortOrder int32 `json:"sort_order"`
}
// ListWordsRequest holds query parameters for listing words.
type ListWordsRequest struct {
CategoryID *int32 `json:"category_id,omitempty"`
Keyword string `json:"keyword,omitempty"`
Page int32 `json:"page,omitempty"`
PageSize int32 `json:"page_size,omitempty"`
}
// ListWordsResponse is the paginated response for word listing.
type ListWordsResponse struct {
Words []Word `json:"words"`
Total int64 `json:"total"`
Page int32 `json:"page"`
PageSize int32 `json:"page_size"`
TotalPages int32 `json:"total_pages"`
}
// APIResponse is the common JSON envelope for all API responses.
type APIResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data"`
}
+21
View File
@@ -0,0 +1,21 @@
package repository
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
// NewPool creates a new pgx connection pool.
func NewPool(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
return nil, fmt.Errorf("failed to create connection pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("failed to ping database: %w", err)
}
return pool, nil
}
@@ -0,0 +1,122 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: category.sql
package queries
import (
"context"
)
const createCategory = `-- name: CreateCategory :one
INSERT INTO categories (name, sort_order)
VALUES ($1, $2)
RETURNING id, name, sort_order, created_at, updated_at
`
type CreateCategoryParams struct {
Name string `json:"name"`
SortOrder int32 `json:"sort_order"`
}
func (q *Queries) CreateCategory(ctx context.Context, arg CreateCategoryParams) (Category, error) {
row := q.db.QueryRow(ctx, createCategory, arg.Name, arg.SortOrder)
var i Category
err := row.Scan(
&i.ID,
&i.Name,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const deleteCategory = `-- name: DeleteCategory :exec
DELETE FROM categories WHERE id = $1
`
func (q *Queries) DeleteCategory(ctx context.Context, id int32) error {
_, err := q.db.Exec(ctx, deleteCategory, id)
return err
}
const getCategory = `-- name: GetCategory :one
SELECT id, name, sort_order, created_at, updated_at
FROM categories
WHERE id = $1
`
func (q *Queries) GetCategory(ctx context.Context, id int32) (Category, error) {
row := q.db.QueryRow(ctx, getCategory, id)
var i Category
err := row.Scan(
&i.ID,
&i.Name,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listCategories = `-- name: ListCategories :many
SELECT id, name, sort_order, created_at, updated_at
FROM categories
ORDER BY sort_order ASC, id ASC
`
func (q *Queries) ListCategories(ctx context.Context) ([]Category, error) {
rows, err := q.db.Query(ctx, listCategories)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Category{}
for rows.Next() {
var i Category
if err := rows.Scan(
&i.ID,
&i.Name,
&i.SortOrder,
&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 updateCategory = `-- name: UpdateCategory :one
UPDATE categories
SET name = $2,
sort_order = $3,
updated_at = NOW()
WHERE id = $1
RETURNING id, name, sort_order, created_at, updated_at
`
type UpdateCategoryParams struct {
ID int32 `json:"id"`
Name string `json:"name"`
SortOrder int32 `json:"sort_order"`
}
func (q *Queries) UpdateCategory(ctx context.Context, arg UpdateCategoryParams) (Category, error) {
row := q.db.QueryRow(ctx, updateCategory, arg.ID, arg.Name, arg.SortOrder)
var i Category
err := row.Scan(
&i.ID,
&i.Name,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
+32
View File
@@ -0,0 +1,32 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package queries
import (
"context"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
type DBTX interface {
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
QueryRow(context.Context, string, ...interface{}) pgx.Row
}
func New(db DBTX) *Queries {
return &Queries{db: db}
}
type Queries struct {
db DBTX
}
func (q *Queries) WithTx(tx pgx.Tx) *Queries {
return &Queries{
db: tx,
}
}
@@ -0,0 +1,31 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package queries
import (
"time"
)
type Category struct {
ID int32 `json:"id"`
Name string `json:"name"`
SortOrder int32 `json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Word struct {
ID int32 `json:"id"`
CategoryID int32 `json:"category_id"`
Word string `json:"word"`
Translation string `json:"translation"`
Phonetic *string `json:"phonetic"`
ExampleSentence *string `json:"example_sentence"`
AudioUrl *string `json:"audio_url"`
ImageUrl *string `json:"image_url"`
SortOrder int32 `json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -0,0 +1,25 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
package queries
import (
"context"
)
type Querier interface {
CountWords(ctx context.Context, arg CountWordsParams) (int64, error)
CreateCategory(ctx context.Context, arg CreateCategoryParams) (Category, error)
CreateWord(ctx context.Context, arg CreateWordParams) (Word, error)
DeleteCategory(ctx context.Context, id int32) error
DeleteWord(ctx context.Context, id int32) error
GetCategory(ctx context.Context, id int32) (Category, error)
GetWord(ctx context.Context, id int32) (Word, error)
ListCategories(ctx context.Context) ([]Category, error)
ListWords(ctx context.Context, arg ListWordsParams) ([]Word, error)
UpdateCategory(ctx context.Context, arg UpdateCategoryParams) (Category, error)
UpdateWord(ctx context.Context, arg UpdateWordParams) (Word, error)
}
var _ Querier = (*Queries)(nil)
@@ -0,0 +1,227 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: word.sql
package queries
import (
"context"
)
const countWords = `-- name: CountWords :one
SELECT COUNT(*) AS total
FROM words w
WHERE
($1::int = 0 OR w.category_id = $1)
AND ($2::text = '' OR w.word ILIKE '%' || $2 || '%' OR w.translation ILIKE '%' || $2 || '%')
`
type CountWordsParams struct {
CategoryID int32 `json:"category_id"`
Keyword string `json:"keyword"`
}
func (q *Queries) CountWords(ctx context.Context, arg CountWordsParams) (int64, error) {
row := q.db.QueryRow(ctx, countWords, arg.CategoryID, arg.Keyword)
var total int64
err := row.Scan(&total)
return total, err
}
const createWord = `-- name: CreateWord :one
INSERT INTO words (category_id, word, translation, phonetic, example_sentence, audio_url, image_url, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, category_id, word, translation, phonetic,
example_sentence, audio_url, image_url, sort_order,
created_at, updated_at
`
type CreateWordParams struct {
CategoryID int32 `json:"category_id"`
Word string `json:"word"`
Translation string `json:"translation"`
Phonetic *string `json:"phonetic"`
ExampleSentence *string `json:"example_sentence"`
AudioUrl *string `json:"audio_url"`
ImageUrl *string `json:"image_url"`
SortOrder int32 `json:"sort_order"`
}
func (q *Queries) CreateWord(ctx context.Context, arg CreateWordParams) (Word, error) {
row := q.db.QueryRow(ctx, createWord,
arg.CategoryID,
arg.Word,
arg.Translation,
arg.Phonetic,
arg.ExampleSentence,
arg.AudioUrl,
arg.ImageUrl,
arg.SortOrder,
)
var i Word
err := row.Scan(
&i.ID,
&i.CategoryID,
&i.Word,
&i.Translation,
&i.Phonetic,
&i.ExampleSentence,
&i.AudioUrl,
&i.ImageUrl,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const deleteWord = `-- name: DeleteWord :exec
DELETE FROM words WHERE id = $1
`
func (q *Queries) DeleteWord(ctx context.Context, id int32) error {
_, err := q.db.Exec(ctx, deleteWord, id)
return err
}
const getWord = `-- name: GetWord :one
SELECT id, category_id, word, translation, phonetic,
example_sentence, audio_url, image_url, sort_order,
created_at, updated_at
FROM words
WHERE id = $1
`
func (q *Queries) GetWord(ctx context.Context, id int32) (Word, error) {
row := q.db.QueryRow(ctx, getWord, id)
var i Word
err := row.Scan(
&i.ID,
&i.CategoryID,
&i.Word,
&i.Translation,
&i.Phonetic,
&i.ExampleSentence,
&i.AudioUrl,
&i.ImageUrl,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listWords = `-- name: ListWords :many
SELECT w.id, w.category_id, w.word, w.translation, w.phonetic,
w.example_sentence, w.audio_url, w.image_url, w.sort_order,
w.created_at, w.updated_at
FROM words w
WHERE
($3::int = 0 OR w.category_id = $3)
AND ($4::text = '' OR w.word ILIKE '%' || $4 || '%' OR w.translation ILIKE '%' || $4 || '%')
ORDER BY w.sort_order ASC, w.id ASC
LIMIT $1 OFFSET $2
`
type ListWordsParams struct {
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
CategoryID int32 `json:"category_id"`
Keyword string `json:"keyword"`
}
func (q *Queries) ListWords(ctx context.Context, arg ListWordsParams) ([]Word, error) {
rows, err := q.db.Query(ctx, listWords,
arg.Limit,
arg.Offset,
arg.CategoryID,
arg.Keyword,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Word{}
for rows.Next() {
var i Word
if err := rows.Scan(
&i.ID,
&i.CategoryID,
&i.Word,
&i.Translation,
&i.Phonetic,
&i.ExampleSentence,
&i.AudioUrl,
&i.ImageUrl,
&i.SortOrder,
&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 updateWord = `-- name: UpdateWord :one
UPDATE words
SET category_id = $2,
word = $3,
translation = $4,
phonetic = $5,
example_sentence = $6,
audio_url = $7,
image_url = $8,
sort_order = $9,
updated_at = NOW()
WHERE id = $1
RETURNING id, category_id, word, translation, phonetic,
example_sentence, audio_url, image_url, sort_order,
created_at, updated_at
`
type UpdateWordParams struct {
ID int32 `json:"id"`
CategoryID int32 `json:"category_id"`
Word string `json:"word"`
Translation string `json:"translation"`
Phonetic *string `json:"phonetic"`
ExampleSentence *string `json:"example_sentence"`
AudioUrl *string `json:"audio_url"`
ImageUrl *string `json:"image_url"`
SortOrder int32 `json:"sort_order"`
}
func (q *Queries) UpdateWord(ctx context.Context, arg UpdateWordParams) (Word, error) {
row := q.db.QueryRow(ctx, updateWord,
arg.ID,
arg.CategoryID,
arg.Word,
arg.Translation,
arg.Phonetic,
arg.ExampleSentence,
arg.AudioUrl,
arg.ImageUrl,
arg.SortOrder,
)
var i Word
err := row.Scan(
&i.ID,
&i.CategoryID,
&i.Word,
&i.Translation,
&i.Phonetic,
&i.ExampleSentence,
&i.AudioUrl,
&i.ImageUrl,
&i.SortOrder,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
@@ -0,0 +1,80 @@
package services
import (
"context"
"github.com/fish/language/backend/internal/models"
"github.com/fish/language/backend/internal/repository/queries"
)
// CategoryService handles business logic for categories.
type CategoryService struct {
q *queries.Queries
}
// NewCategoryService creates a new CategoryService.
func NewCategoryService(q *queries.Queries) *CategoryService {
return &CategoryService{q: q}
}
// List returns all categories ordered by sort_order.
func (s *CategoryService) List(ctx context.Context) ([]models.Category, error) {
rows, err := s.q.ListCategories(ctx)
if err != nil {
return nil, err
}
out := make([]models.Category, len(rows))
for i, r := range rows {
out[i] = toCategory(r)
}
return out, nil
}
// Get returns a single category by id.
func (s *CategoryService) Get(ctx context.Context, id int32) (models.Category, error) {
row, err := s.q.GetCategory(ctx, id)
if err != nil {
return models.Category{}, err
}
return toCategory(row), nil
}
// Create creates a new category.
func (s *CategoryService) Create(ctx context.Context, req models.CreateCategoryRequest) (models.Category, error) {
row, err := s.q.CreateCategory(ctx, queries.CreateCategoryParams{
Name: req.Name,
SortOrder: req.SortOrder,
})
if err != nil {
return models.Category{}, err
}
return toCategory(row), nil
}
// Update updates an existing category.
func (s *CategoryService) Update(ctx context.Context, id int32, req models.UpdateCategoryRequest) (models.Category, error) {
row, err := s.q.UpdateCategory(ctx, queries.UpdateCategoryParams{
ID: id,
Name: req.Name,
SortOrder: req.SortOrder,
})
if err != nil {
return models.Category{}, err
}
return toCategory(row), nil
}
// Delete removes a category by id.
func (s *CategoryService) Delete(ctx context.Context, id int32) error {
return s.q.DeleteCategory(ctx, id)
}
func toCategory(c queries.Category) models.Category {
return models.Category{
ID: c.ID,
Name: c.Name,
SortOrder: c.SortOrder,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}
}
+155
View File
@@ -0,0 +1,155 @@
package services
import (
"context"
"math"
"github.com/fish/language/backend/internal/models"
"github.com/fish/language/backend/internal/repository/queries"
)
const defaultPageSize = 20
const maxPageSize = 100
// WordService handles business logic for words.
type WordService struct {
q *queries.Queries
}
// NewWordService creates a new WordService.
func NewWordService(q *queries.Queries) *WordService {
return &WordService{q: q}
}
// List returns a paginated list of words with optional filters.
func (s *WordService) List(ctx context.Context, req models.ListWordsRequest) (models.ListWordsResponse, error) {
page := req.Page
if page < 1 {
page = 1
}
pageSize := req.PageSize
if pageSize < 1 {
pageSize = defaultPageSize
}
if pageSize > maxPageSize {
pageSize = maxPageSize
}
offset := (page - 1) * pageSize
listParams := queries.ListWordsParams{
Limit: pageSize,
Offset: offset,
CategoryID: 0,
Keyword: "",
}
countParams := queries.CountWordsParams{
CategoryID: 0,
Keyword: "",
}
if req.CategoryID != nil && *req.CategoryID > 0 {
listParams.CategoryID = *req.CategoryID
countParams.CategoryID = *req.CategoryID
}
if req.Keyword != "" {
listParams.Keyword = req.Keyword
countParams.Keyword = req.Keyword
}
rows, err := s.q.ListWords(ctx, listParams)
if err != nil {
return models.ListWordsResponse{}, err
}
total, err := s.q.CountWords(ctx, countParams)
if err != nil {
return models.ListWordsResponse{}, err
}
words := make([]models.Word, len(rows))
for i, r := range rows {
words[i] = toWord(r)
}
totalPages := int32(math.Ceil(float64(total) / float64(pageSize)))
return models.ListWordsResponse{
Words: words,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: totalPages,
}, nil
}
// Get returns a single word by id.
func (s *WordService) Get(ctx context.Context, id int32) (models.Word, error) {
row, err := s.q.GetWord(ctx, id)
if err != nil {
return models.Word{}, err
}
return toWord(row), nil
}
// Create creates a new word.
func (s *WordService) Create(ctx context.Context, req models.CreateWordRequest) (models.Word, error) {
row, err := s.q.CreateWord(ctx, queries.CreateWordParams{
CategoryID: req.CategoryID,
Word: req.Word,
Translation: req.Translation,
Phonetic: req.Phonetic,
ExampleSentence: req.ExampleSentence,
AudioUrl: req.AudioURL,
ImageUrl: req.ImageURL,
SortOrder: req.SortOrder,
})
if err != nil {
return models.Word{}, err
}
return toWord(row), nil
}
// Update updates an existing word.
func (s *WordService) Update(ctx context.Context, id int32, req models.UpdateWordRequest) (models.Word, error) {
row, err := s.q.UpdateWord(ctx, queries.UpdateWordParams{
ID: id,
CategoryID: req.CategoryID,
Word: req.Word,
Translation: req.Translation,
Phonetic: req.Phonetic,
ExampleSentence: req.ExampleSentence,
AudioUrl: req.AudioURL,
ImageUrl: req.ImageURL,
SortOrder: req.SortOrder,
})
if err != nil {
return models.Word{}, err
}
return toWord(row), nil
}
// Delete removes a word by id.
func (s *WordService) Delete(ctx context.Context, id int32) error {
return s.q.DeleteWord(ctx, id)
}
func toWord(w queries.Word) models.Word {
return models.Word{
ID: w.ID,
CategoryID: w.CategoryID,
Word: w.Word,
Translation: w.Translation,
Phonetic: nullString(w.Phonetic),
ExampleSentence: nullString(w.ExampleSentence),
AudioURL: nullString(w.AudioUrl),
ImageURL: nullString(w.ImageUrl),
SortOrder: w.SortOrder,
CreatedAt: w.CreatedAt,
UpdatedAt: w.UpdatedAt,
}
}
func nullString(s *string) *string {
if s == nil {
return nil
}
v := *s
return &v
}