293 lines
8.4 KiB
Go
293 lines
8.4 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"stow/backend/internal/store"
|
|
)
|
|
|
|
type DataStore interface {
|
|
CreateAnonymousSession(context.Context) (string, string, error)
|
|
Authenticate(context.Context, string) (string, error)
|
|
CreateItem(context.Context, string, store.NewItem) (store.Item, error)
|
|
GetItem(context.Context, string, string) (store.Item, error)
|
|
ListItems(context.Context, string, int) ([]store.Item, error)
|
|
SearchItems(context.Context, string, string, int) ([]store.Item, error)
|
|
UpdateItem(context.Context, string, string, store.ItemPatch) (store.Item, error)
|
|
DeleteItem(context.Context, string, string) error
|
|
}
|
|
|
|
type Server struct{ store DataStore }
|
|
|
|
func New(dataStore DataStore) http.Handler {
|
|
server := &Server{store: dataStore}
|
|
router := gin.New()
|
|
router.HandleMethodNotAllowed = true
|
|
router.Use(gin.CustomRecovery(func(c *gin.Context, _ any) {
|
|
failure(c, http.StatusInternalServerError, "server_error", "服务暂时不可用")
|
|
}))
|
|
_ = router.SetTrustedProxies(nil)
|
|
router.NoRoute(func(c *gin.Context) { failure(c, http.StatusNotFound, "not_found", "接口不存在") })
|
|
router.NoMethod(func(c *gin.Context) {
|
|
failure(c, http.StatusMethodNotAllowed, "method_not_allowed", "请求方法不支持")
|
|
})
|
|
|
|
v1 := router.Group("/v1")
|
|
v1.GET("/health", func(c *gin.Context) { success(c, http.StatusOK, gin.H{"status": "ok"}) })
|
|
v1.POST("/session/anonymous", server.anonymousSession)
|
|
authorized := v1.Group("")
|
|
authorized.Use(server.requireAuth())
|
|
authorized.POST("/items", server.createItem)
|
|
authorized.GET("/items", server.listItems)
|
|
authorized.GET("/items/:id", server.getItem)
|
|
authorized.PATCH("/items/:id", server.updateItem)
|
|
authorized.DELETE("/items/:id", server.deleteItem)
|
|
authorized.POST("/search", server.search)
|
|
return router
|
|
}
|
|
|
|
func (s *Server) requireAuth() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
header := c.GetHeader("Authorization")
|
|
if !strings.HasPrefix(header, "Bearer ") || strings.TrimSpace(header[7:]) == "" {
|
|
failure(c, http.StatusUnauthorized, "missing_token", "请先创建匿名会话")
|
|
c.Abort()
|
|
return
|
|
}
|
|
userID, err := s.store.Authenticate(c.Request.Context(), strings.TrimSpace(header[7:]))
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
failure(c, http.StatusUnauthorized, "invalid_token", "会话无效或已过期")
|
|
c.Abort()
|
|
return
|
|
}
|
|
if err != nil {
|
|
failure(c, http.StatusInternalServerError, "server_error", "服务暂时不可用")
|
|
c.Abort()
|
|
return
|
|
}
|
|
c.Set("userID", userID)
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
func (s *Server) anonymousSession(c *gin.Context) {
|
|
userID, token, err := s.store.CreateAnonymousSession(c.Request.Context())
|
|
if err != nil {
|
|
failure(c, http.StatusInternalServerError, "server_error", "创建会话失败")
|
|
return
|
|
}
|
|
success(c, http.StatusCreated, gin.H{"user_id": userID, "access_token": token, "token_type": "Bearer"})
|
|
}
|
|
|
|
func (s *Server) createItem(c *gin.Context) {
|
|
var input store.NewItem
|
|
if !readJSON(c, &input) {
|
|
return
|
|
}
|
|
if message := validateNewItem(&input); message != "" {
|
|
failure(c, http.StatusBadRequest, "invalid_item", message)
|
|
return
|
|
}
|
|
item, err := s.store.CreateItem(c.Request.Context(), c.GetString("userID"), input)
|
|
if err != nil {
|
|
failure(c, http.StatusInternalServerError, "server_error", "保存失败")
|
|
return
|
|
}
|
|
success(c, http.StatusCreated, item)
|
|
}
|
|
|
|
func (s *Server) listItems(c *gin.Context) {
|
|
items, err := s.store.ListItems(c.Request.Context(), c.GetString("userID"), parseLimit(c.Query("limit")))
|
|
if err != nil {
|
|
failure(c, http.StatusInternalServerError, "server_error", "读取失败")
|
|
return
|
|
}
|
|
success(c, http.StatusOK, gin.H{"items": items})
|
|
}
|
|
|
|
func (s *Server) getItem(c *gin.Context) {
|
|
item, err := s.store.GetItem(c.Request.Context(), c.GetString("userID"), c.Param("id"))
|
|
if !handleItemError(c, err) {
|
|
return
|
|
}
|
|
success(c, http.StatusOK, item)
|
|
}
|
|
|
|
func (s *Server) updateItem(c *gin.Context) {
|
|
var patch store.ItemPatch
|
|
if !readJSON(c, &patch) {
|
|
return
|
|
}
|
|
if message := validatePatch(patch); message != "" {
|
|
failure(c, http.StatusBadRequest, "invalid_item", message)
|
|
return
|
|
}
|
|
item, err := s.store.UpdateItem(c.Request.Context(), c.GetString("userID"), c.Param("id"), patch)
|
|
if !handleItemError(c, err) {
|
|
return
|
|
}
|
|
success(c, http.StatusOK, item)
|
|
}
|
|
|
|
func (s *Server) deleteItem(c *gin.Context) {
|
|
if !handleItemError(c, s.store.DeleteItem(c.Request.Context(), c.GetString("userID"), c.Param("id"))) {
|
|
return
|
|
}
|
|
success(c, http.StatusOK, nil)
|
|
}
|
|
|
|
func (s *Server) search(c *gin.Context) {
|
|
var request struct {
|
|
Query string `json:"query"`
|
|
Limit int `json:"limit"`
|
|
}
|
|
if !readJSON(c, &request) {
|
|
return
|
|
}
|
|
request.Query = strings.TrimSpace(request.Query)
|
|
if request.Query == "" || len([]rune(request.Query)) > 200 {
|
|
failure(c, http.StatusBadRequest, "invalid_query", "搜索内容需为 1 到 200 字")
|
|
return
|
|
}
|
|
if request.Limit <= 0 {
|
|
request.Limit = 20
|
|
}
|
|
if request.Limit > 100 {
|
|
request.Limit = 100
|
|
}
|
|
items, err := s.store.SearchItems(c.Request.Context(), c.GetString("userID"), request.Query, request.Limit)
|
|
if err != nil {
|
|
failure(c, http.StatusInternalServerError, "server_error", "搜索失败")
|
|
return
|
|
}
|
|
success(c, http.StatusOK, gin.H{"items": items, "query": request.Query})
|
|
}
|
|
|
|
func handleItemError(c *gin.Context, err error) bool {
|
|
if err == nil {
|
|
return true
|
|
}
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
failure(c, http.StatusNotFound, "not_found", "收藏不存在")
|
|
} else if errors.Is(err, store.ErrConflict) {
|
|
failure(c, http.StatusConflict, "conflict", "收藏已更新,请重试")
|
|
} else {
|
|
failure(c, http.StatusInternalServerError, "server_error", "服务暂时不可用")
|
|
}
|
|
return false
|
|
}
|
|
|
|
func readJSON(c *gin.Context, destination any) bool {
|
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
|
|
decoder := json.NewDecoder(c.Request.Body)
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(destination); err != nil {
|
|
failure(c, http.StatusBadRequest, "invalid_json", "请求内容不是有效 JSON")
|
|
return false
|
|
}
|
|
var extra any
|
|
if err := decoder.Decode(&extra); err != io.EOF {
|
|
failure(c, http.StatusBadRequest, "invalid_json", "请求只能包含一个 JSON 对象")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func parseLimit(raw string) int {
|
|
limit, err := strconv.Atoi(raw)
|
|
if err != nil || limit <= 0 {
|
|
return 20
|
|
}
|
|
if limit > 100 {
|
|
return 100
|
|
}
|
|
return limit
|
|
}
|
|
|
|
func validateNewItem(input *store.NewItem) string {
|
|
input.Title = strings.TrimSpace(input.Title)
|
|
input.Summary = strings.TrimSpace(input.Summary)
|
|
if input.Title == "" || len([]rune(input.Title)) > 160 {
|
|
return "标题需为 1 到 160 字"
|
|
}
|
|
if len([]rune(input.Summary)) > 2000 {
|
|
return "摘要不能超过 2000 字"
|
|
}
|
|
if input.PrimaryType == "" {
|
|
input.PrimaryType = "other"
|
|
}
|
|
if !validType(input.PrimaryType) {
|
|
return "无效的分类"
|
|
}
|
|
if !validSource(input.SourceType) {
|
|
return "无效的来源类型"
|
|
}
|
|
if input.SourceURL != nil {
|
|
parsed, err := url.Parse(*input.SourceURL)
|
|
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
|
return "无效的来源链接"
|
|
}
|
|
}
|
|
if len(input.Tags) > 20 {
|
|
return "标签不能超过 20 个"
|
|
}
|
|
for _, tag := range input.Tags {
|
|
if strings.TrimSpace(tag) == "" || len([]rune(tag)) > 40 {
|
|
return "标签需为 1 到 40 字"
|
|
}
|
|
}
|
|
if input.CapturedAt != nil && input.CapturedAt.After(time.Now().Add(24*time.Hour)) {
|
|
return "收藏时间不能晚于明天"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func validatePatch(patch store.ItemPatch) string {
|
|
if patch.Title != nil && (strings.TrimSpace(*patch.Title) == "" || len([]rune(*patch.Title)) > 160) {
|
|
return "标题需为 1 到 160 字"
|
|
}
|
|
if patch.Summary != nil && len([]rune(*patch.Summary)) > 2000 {
|
|
return "摘要不能超过 2000 字"
|
|
}
|
|
if patch.PrimaryType != nil && !validType(*patch.PrimaryType) {
|
|
return "无效的分类"
|
|
}
|
|
if patch.Tags != nil {
|
|
if len(*patch.Tags) > 20 {
|
|
return "标签不能超过 20 个"
|
|
}
|
|
for _, tag := range *patch.Tags {
|
|
if strings.TrimSpace(tag) == "" || len([]rune(tag)) > 40 {
|
|
return "标签需为 1 到 40 字"
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func validType(value string) bool {
|
|
switch value {
|
|
case "place", "product", "content", "event", "task", "document", "conversation", "other":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func validSource(value string) bool {
|
|
switch value {
|
|
case "screenshot", "manual_photo", "share_image", "share_url", "share_text", "manual_url", "manual_text":
|
|
return true
|
|
}
|
|
return false
|
|
}
|