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 }