From ba1700efd44704c9b213df7c2429781ea2299794 Mon Sep 17 00:00:00 2001 From: yokowu <18836617@qq.com> Date: Tue, 29 Sep 2026 16:58:36 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=20Agent=20=E7=94=A8=E6=88=B7=E5=8F=8D=E9=A6=88=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- monkeyai/backend/api/admin.yaml | 142 ++++++ monkeyai/backend/api/agent.yaml | 88 ++++ monkeyai/backend/internal/app/app.go | 19 +- monkeyai/backend/internal/app/billing.go | 2 + monkeyai/backend/internal/feedback/admin.go | 165 ++++++ monkeyai/backend/internal/feedback/agent.go | 153 ++++++ monkeyai/backend/internal/feedback/cleanup.go | 63 +++ .../backend/internal/feedback/feedback.go | 291 +++++++++++ .../internal/feedback/feedback_test.go | 109 ++++ monkeyai/backend/internal/feedback/query.sql | 103 ++++ monkeyai/backend/internal/feedback/sqlc/db.go | 32 ++ .../backend/internal/feedback/sqlc/models.go | 38 ++ .../internal/feedback/sqlc/query.sql.go | 476 ++++++++++++++++++ .../backend/internal/model/sqlc/models.go | 10 +- .../backend/internal/model/sqlc/query.sql.go | 84 ++-- .../migrations/000009_feedbacks.down.sql | 2 + .../migrations/000009_feedbacks.up.sql | 38 ++ monkeyai/backend/schema/schema.sql | 235 +++++---- monkeyai/backend/sqlc.yaml | 12 + 19 files changed, 1915 insertions(+), 147 deletions(-) create mode 100644 monkeyai/backend/internal/feedback/admin.go create mode 100644 monkeyai/backend/internal/feedback/agent.go create mode 100644 monkeyai/backend/internal/feedback/cleanup.go create mode 100644 monkeyai/backend/internal/feedback/feedback.go create mode 100644 monkeyai/backend/internal/feedback/feedback_test.go create mode 100644 monkeyai/backend/internal/feedback/query.sql create mode 100644 monkeyai/backend/internal/feedback/sqlc/db.go create mode 100644 monkeyai/backend/internal/feedback/sqlc/models.go create mode 100644 monkeyai/backend/internal/feedback/sqlc/query.sql.go create mode 100644 monkeyai/backend/migrations/000009_feedbacks.down.sql create mode 100644 monkeyai/backend/migrations/000009_feedbacks.up.sql diff --git a/monkeyai/backend/api/admin.yaml b/monkeyai/backend/api/admin.yaml index fd93ae4ae..0f31d02ad 100644 --- a/monkeyai/backend/api/admin.yaml +++ b/monkeyai/backend/api/admin.yaml @@ -2108,6 +2108,101 @@ paths: $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalError' + /api/admin/v1/feedback: + get: + summary: 分页查询用户反馈 + parameters: + - name: page + in: query + schema: {type: integer, minimum: 1, maximum: 1000000, default: 1} + - name: page_size + in: query + schema: {type: integer, minimum: 1, maximum: 100, default: 20} + - name: category + in: query + schema: {type: string, enum: [bug, feature, experience, other]} + - name: state + in: query + schema: {type: string, enum: [uploading, new, resolved, ignored, failed]} + responses: + '200': + description: 反馈分页 + content: + application/json: + schema: + $ref: '#/components/schemas/FeedbackPage' + '400': {$ref: '#/components/responses/ResourceError400'} + '401': {$ref: '#/components/responses/ResourceError401'} + '403': {$ref: '#/components/responses/ResourceError403'} + '500': {$ref: '#/components/responses/ResourceError500'} + /api/admin/v1/feedback/{id}: + parameters: + - name: id + in: path + required: true + schema: {type: string, format: uuid} + get: + summary: 读取用户反馈详情 + responses: + '200': + description: 反馈详情 + content: + application/json: + schema: + $ref: '#/components/schemas/Feedback' + '401': {$ref: '#/components/responses/ResourceError401'} + '403': {$ref: '#/components/responses/ResourceError403'} + '404': {$ref: '#/components/responses/ResourceError404'} + '500': {$ref: '#/components/responses/ResourceError500'} + patch: + summary: 更新用户反馈状态 + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [state] + properties: + state: + type: string + enum: [uploading, new, resolved, ignored, failed] + responses: + '200': + description: 更新后的反馈 + content: + application/json: + schema: + $ref: '#/components/schemas/Feedback' + '400': {$ref: '#/components/responses/ResourceError400'} + '401': {$ref: '#/components/responses/ResourceError401'} + '403': {$ref: '#/components/responses/ResourceError403'} + '404': {$ref: '#/components/responses/ResourceError404'} + '500': {$ref: '#/components/responses/ResourceError500'} + /api/admin/v1/feedback/{id}/attachments/{attachmentID}: + get: + summary: 下载用户反馈图片 + parameters: + - name: id + in: path + required: true + schema: {type: string, format: uuid} + - name: attachmentID + in: path + required: true + schema: {type: string, format: uuid} + responses: + '200': + description: 反馈图片 + content: + image/png: {schema: {type: string, format: binary}} + image/jpeg: {schema: {type: string, format: binary}} + image/webp: {schema: {type: string, format: binary}} + '401': {$ref: '#/components/responses/ResourceError401'} + '403': {$ref: '#/components/responses/ResourceError403'} + '404': {$ref: '#/components/responses/ResourceError404'} + '500': {$ref: '#/components/responses/ResourceError500'} /api/admin/v1/rules: get: summary: 查询Rule @@ -4499,6 +4594,53 @@ components: schema: $ref: "#/components/schemas/ErrorResponse" schemas: + FeedbackPage: + type: object + additionalProperties: false + required: [items, total, page, page_size] + properties: + items: + type: array + items: + $ref: '#/components/schemas/Feedback' + total: {type: integer, format: int64, minimum: 0} + page: {type: integer, minimum: 1} + page_size: {type: integer, minimum: 1} + Feedback: + type: object + additionalProperties: false + required: [id, user_id, category, content, platform, client_version, state, created_at, updated_at, attachments] + properties: + id: {type: string, format: uuid} + user_id: {type: string, format: uuid} + category: {type: string, enum: [bug, feature, experience, other]} + content: {type: string, maxLength: 5000} + rating: {type: [integer, 'null'], minimum: 1, maximum: 5} + platform: {type: string, enum: [desktop, mobile, web, other]} + client_version: {type: string, maxLength: 64} + state: {type: string, enum: [uploading, new, resolved, ignored, failed]} + request_id: {type: string} + created_at: {type: string, format: date-time} + updated_at: {type: string, format: date-time} + attachments: + type: array + items: + $ref: '#/components/schemas/FeedbackAttachment' + FeedbackAttachment: + type: object + additionalProperties: false + required: [id, feedback_id, mime_type, byte_size, width, height, sha256, state, downloadable, created_at] + properties: + id: {type: string, format: uuid} + feedback_id: {type: string, format: uuid} + mime_type: {type: string, enum: [image/png, image/jpeg, image/webp]} + byte_size: {type: integer, format: int64, minimum: 1} + width: {type: integer, minimum: 1} + height: {type: integer, minimum: 1} + sha256: {type: string, pattern: '^[0-9a-f]{64}$'} + state: {type: string, enum: [pending, ready, failed]} + downloadable: {type: boolean} + created_at: {type: string, format: date-time} EmailAuthError: type: object required: diff --git a/monkeyai/backend/api/agent.yaml b/monkeyai/backend/api/agent.yaml index b9ad928b6..9ac264bac 100644 --- a/monkeyai/backend/api/agent.yaml +++ b/monkeyai/backend/api/agent.yaml @@ -2792,6 +2792,41 @@ paths: image/webp: {schema: {type: string, format: binary}} '401': {$ref: '#/components/responses/Unauthorized'} '404': {$ref: '#/components/responses/ResourceError404'} + /api/v1/feedback: + post: + summary: 提交用户反馈 + description: >- + 使用 Agent OAuth access token 提交文字反馈和可选截图。请求使用 multipart/form-data,最多上传 3 张 + PNG、JPEG 或 WebP 图片,单张最多 5 MiB,总请求最多 16 MiB。content 和 images 至少提供一项。 + parameters: + - name: Idempotency-Key + in: header + required: false + description: 建议客户端为可重试请求提供;同一用户相同 Key 和请求内容只创建一条反馈。 + schema: {type: string, maxLength: 128} + requestBody: + required: true + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/FeedbackMultipartInput' + responses: + '201': + description: 反馈已创建 + content: + application/json: + schema: + $ref: '#/components/schemas/FeedbackSubmission' + '200': + description: 幂等键命中已有反馈 + content: + application/json: + schema: + $ref: '#/components/schemas/FeedbackSubmission' + '400': {$ref: '#/components/responses/ResourceError400'} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/ResourceError409'} + '500': {$ref: '#/components/responses/ResourceError500'} components: securitySchemes: ModelInvocationKey: @@ -2992,6 +3027,59 @@ components: schema: $ref: "#/components/schemas/ErrorResponse" schemas: + FeedbackMultipartInput: + type: object + additionalProperties: false + required: [category] + properties: + category: + type: string + enum: [bug, feature, experience, other] + content: + type: string + maxLength: 5000 + rating: + type: integer + minimum: 1 + maximum: 5 + platform: + type: string + enum: [desktop, mobile, web, other] + default: other + client_version: + type: string + maxLength: 64 + images: + type: array + maxItems: 3 + items: + type: string + format: binary + description: PNG、JPEG 或 WebP 图片;单张最多 5 MiB。 + FeedbackSubmission: + type: object + additionalProperties: false + required: [id, created_at, attachments] + properties: + id: {type: string, format: uuid} + created_at: {type: string, format: date-time} + attachments: + type: array + items: + $ref: '#/components/schemas/FeedbackAttachment' + FeedbackAttachment: + type: object + additionalProperties: false + required: [id, mime_type, byte_size, width, height, downloadable] + properties: + id: {type: string, format: uuid} + mime_type: + type: string + enum: [image/png, image/jpeg, image/webp] + byte_size: {type: integer, format: int64, minimum: 1} + width: {type: integer, minimum: 1} + height: {type: integer, minimum: 1} + downloadable: {type: boolean} Endpoint: type: object required: [machine_id, device_name, alias, display_name, platform, os_version, arch, client_version, protocol_version, status, online, created_at, updated_at, last_seen_at, revoked_at] diff --git a/monkeyai/backend/internal/app/app.go b/monkeyai/backend/internal/app/app.go index 80037cf31..34d408174 100644 --- a/monkeyai/backend/internal/app/app.go +++ b/monkeyai/backend/internal/app/app.go @@ -18,6 +18,7 @@ import ( "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/database" "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/endpoint" "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/expert" + "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/feedback" "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/group" "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/httpapi" "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/identity" @@ -49,6 +50,7 @@ type App struct { images *imagegen.Service inputs *imagegen.Inputs endpoints *endpoint.Service + feedback *feedback.Service } func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, error) { @@ -86,6 +88,7 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err images: handler.(*applicationHandler).images, inputs: handler.(*applicationHandler).inputs, endpoints: handler.(*applicationHandler).endpoints, + feedback: handler.(*applicationHandler).feedback, database: pool, shutdownTimeout: cfg.ShutdownTimeout, }, nil @@ -127,6 +130,7 @@ func newApplicationHandler(ctx context.Context, logger *slog.Logger, pool *pgxpo if err != nil { return nil, fmt.Errorf("初始化资源 Bucket: %w", err) } + feedbacks := feedback.NewService(pool, storage) imageRepo := imagegen.NewPostgres(pool) imageInputs := imagegen.NewInputs(imageRepo, storage) imageOutputs := imagegen.NewOutputs(imageRepo, storage) @@ -171,6 +175,7 @@ func newApplicationHandler(ctx context.Context, logger *slog.Logger, pool *pgxpo connectors.RegisterAdmin(admin) experts.RegisterAdmin(admin) resources.RegisterAdmin(admin) + feedbacks.RegisterAdmin(admin) agent := chi.NewRouter() agent.Use(identities.RequireAgent) @@ -187,6 +192,7 @@ func newApplicationHandler(ctx context.Context, logger *slog.Logger, pool *pgxpo connectors.RegisterAgent(agent) resources.RegisterAgent(agent) charges.RegisterAgent(agent) + feedbacks.RegisterAgent(agent) router := chi.NewRouter() modelProxy := proxy.NewProxy(modelResolver{service: models}, logger).WithBilling(modelBilling{service: charges}).WithUsageRecorder(modelUsageRecorder{models: modelRepo}) @@ -211,7 +217,7 @@ func newApplicationHandler(ctx context.Context, logger *slog.Logger, pool *pgxpo } })(identities.AuthRouter()) router.Mount("/", httpapi.New(logger, readiness{pool: pool, storage: storage, endpoints: endpoints}, admin, agent, auth)) - return &applicationHandler{Handler: router, billing: charges, proxy: modelProxy, images: imageService, inputs: imageInputs, endpoints: endpoints}, nil + return &applicationHandler{Handler: router, billing: charges, proxy: modelProxy, images: imageService, inputs: imageInputs, endpoints: endpoints, feedback: feedbacks}, nil } type modelResolver struct { @@ -245,7 +251,16 @@ func (a *App) Run(ctx context.Context) error { go func() { defer close(imageDone); a.images.Run(workerCtx) }() cleanupDone := make(chan struct{}) go func() { defer close(cleanupDone); runImageCleanup(workerCtx, a.inputs) }() - defer func() { stopWorker(); <-workerDone; <-observeDone; <-imageDone; <-cleanupDone }() + feedbackCleanupDone := make(chan struct{}) + go func() { defer close(feedbackCleanupDone); _ = a.feedback.Run(workerCtx, time.Hour) }() + defer func() { + stopWorker() + <-workerDone + <-observeDone + <-imageDone + <-cleanupDone + <-feedbackCleanupDone + }() result := make(chan error, len(a.servers)) for _, server := range a.servers { diff --git a/monkeyai/backend/internal/app/billing.go b/monkeyai/backend/internal/app/billing.go index 0f0c33ec9..7126e0cf7 100644 --- a/monkeyai/backend/internal/app/billing.go +++ b/monkeyai/backend/internal/app/billing.go @@ -9,6 +9,7 @@ import ( "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/billing" "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/endpoint" + "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/feedback" "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/imagegen" "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/mcp" "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/model" @@ -23,6 +24,7 @@ type applicationHandler struct { images *imagegen.Service inputs *imagegen.Inputs endpoints *endpoint.Service + feedback *feedback.Service } type modelBilling struct{ service *billing.Service } type modelUsageRecorder struct{ models *model.Postgres } diff --git a/monkeyai/backend/internal/feedback/admin.go b/monkeyai/backend/internal/feedback/admin.go new file mode 100644 index 000000000..550503bff --- /dev/null +++ b/monkeyai/backend/internal/feedback/admin.go @@ -0,0 +1,165 @@ +package feedback + +import ( + "errors" + "io" + "log/slog" + "net/http" + "slices" + "strconv" + "strings" + + "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/feedback/sqlc" + "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/resource" + "github.com/go-chi/chi/v5" + "github.com/jackc/pgx/v5" +) + +func (s *Service) RegisterAdmin(router chi.Router) { + router.Get("/feedback", s.list) + router.Get("/feedback/{id}", s.detail) + router.Get("/feedback/{id}/attachments/{attachmentID}", s.download) + router.Patch("/feedback/{id}", s.patch) +} + +func (s *Service) list(w http.ResponseWriter, r *http.Request) { + page, size, err := resource.PageParams(r) + if err != nil { + resource.Fail(w, err) + return + } + category, state, err := feedbackFilters(r) + if err != nil { + resource.Fail(w, err) + return + } + q := sqlc.New(s.pool) + items, err := q.ListFeedbacks(r.Context(), sqlc.ListFeedbacksParams{ + Category: category, State: state, PageLimit: int32(size), PageOffset: int32((page - 1) * size), + }) + if err != nil { + resource.Fail(w, err) + return + } + total, err := q.CountFeedbacks(r.Context(), sqlc.CountFeedbacksParams{Category: category, State: state}) + if err != nil { + resource.Fail(w, err) + return + } + out := make([]Feedback, 0, len(items)) + for _, item := range items { + out = append(out, summary(item)) + } + resource.JSON(w, http.StatusOK, map[string]any{"items": out, "total": total, "page": page, "page_size": size}) +} + +func feedbackFilters(r *http.Request) (string, string, error) { + category := strings.TrimSpace(r.URL.Query().Get("category")) + if category != "" && !slices.Contains(categories, category) { + return "", "", resource.Invalid("category 无效") + } + state := strings.TrimSpace(r.URL.Query().Get("state")) + if state != "" && !slices.Contains(states, state) { + return "", "", resource.Invalid("state 无效") + } + return category, state, nil +} + +func (s *Service) detail(w http.ResponseWriter, r *http.Request) { + row, err := sqlc.New(s.pool).FeedbackByID(r.Context(), chi.URLParam(r, "id")) + if errors.Is(err, pgx.ErrNoRows) { + resource.Fail(w, resource.NotFound) + return + } + if err != nil { + resource.Fail(w, err) + return + } + out, err := s.read(r.Context(), row) + if err != nil { + resource.Fail(w, err) + return + } + resource.JSON(w, http.StatusOK, out) +} + +func (s *Service) patch(w http.ResponseWriter, r *http.Request) { + var in struct { + State string `json:"state"` + } + if err := resource.Decode(w, r, &in); err != nil { + resource.Fail(w, err) + return + } + if !slices.Contains(patchStates, in.State) { + resource.Fail(w, resource.Invalid("反馈状态无效")) + return + } + row, err := sqlc.New(s.pool).UpdateFeedbackState(r.Context(), sqlc.UpdateFeedbackStateParams{ID: chi.URLParam(r, "id"), State: in.State}) + if errors.Is(err, pgx.ErrNoRows) { + resource.Fail(w, resource.NotFound) + return + } + if err != nil { + resource.Fail(w, err) + return + } + out, err := s.read(r.Context(), row) + if err != nil { + resource.Fail(w, err) + return + } + resource.JSON(w, http.StatusOK, out) +} + +func (s *Service) download(w http.ResponseWriter, r *http.Request) { + row, err := sqlc.New(s.pool).AttachmentForDownload(r.Context(), sqlc.AttachmentForDownloadParams{ + FeedbackID: chi.URLParam(r, "id"), AttachmentID: chi.URLParam(r, "attachmentID"), + }) + if errors.Is(err, pgx.ErrNoRows) { + resource.Fail(w, resource.NotFound) + return + } + if err != nil { + resource.Fail(w, err) + return + } + body, err := s.storage.Get(r.Context(), row.ObjectKey) + if err != nil { + resource.Fail(w, storageFailure()) + return + } + defer func() { + if closeErr := body.Close(); closeErr != nil && r.Context().Err() == nil { + slog.WarnContext(r.Context(), "关闭反馈附件读取流失败", "feedback_id", row.FeedbackID, "attachment_id", row.ID, "error_type", errorType(closeErr)) + } + }() + w.Header().Set("Content-Type", row.MimeType) + w.Header().Set("Content-Length", strconv.FormatInt(row.ByteSize, 10)) + w.Header().Set("Content-Disposition", "inline; filename=feedback-attachment."+extension(row.MimeType)) + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Cache-Control", "private, no-store") + w.Header().Set("ETag", `"`+row.Sha256+`"`) + if _, err := io.Copy(w, body); err != nil { + slog.WarnContext(r.Context(), "传输反馈附件失败", "feedback_id", row.FeedbackID, "attachment_id", row.ID, "error_type", errorType(err)) + } +} + +func summary(row sqlc.Feedback) Feedback { + return Feedback{ + ID: row.ID, UserID: row.UserID, Category: row.Category, Content: row.Content, Rating: row.Rating, + Platform: row.Platform, ClientVersion: row.ClientVersion, State: row.State, RequestID: row.RequestID, + CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, Attachments: []Attachment{}, + } +} + +func extension(mime string) string { + switch mime { + case "image/jpeg": + return "jpeg" + case "image/webp": + return "webp" + default: + return "png" + } +} diff --git a/monkeyai/backend/internal/feedback/agent.go b/monkeyai/backend/internal/feedback/agent.go new file mode 100644 index 000000000..40d0240c6 --- /dev/null +++ b/monkeyai/backend/internal/feedback/agent.go @@ -0,0 +1,153 @@ +package feedback + +import ( + "bytes" + "image" + _ "image/jpeg" + _ "image/png" + "io" + "log/slog" + "mime/multipart" + "net/http" + "strconv" + "strings" + "time" + + "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/identity" + "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/resource" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + _ "golang.org/x/image/webp" +) + +type AgentResponse struct { + ID string `json:"id"` + CreatedAt time.Time `json:"created_at"` + Attachments []AgentAttachment `json:"attachments"` +} + +type AgentAttachment struct { + ID string `json:"id"` + MIMEType string `json:"mime_type"` + ByteSize int64 `json:"byte_size"` + Width int32 `json:"width"` + Height int32 `json:"height"` + Downloadable bool `json:"downloadable"` +} + +func agentResponse(in Feedback) AgentResponse { + attachments := make([]AgentAttachment, 0, len(in.Attachments)) + for _, attachment := range in.Attachments { + attachments = append(attachments, AgentAttachment{ + ID: attachment.ID, MIMEType: attachment.MIMEType, ByteSize: attachment.ByteSize, + Width: attachment.Width, Height: attachment.Height, Downloadable: attachment.Downloadable, + }) + } + return AgentResponse{ID: in.ID, CreatedAt: in.CreatedAt, Attachments: attachments} +} + +func (s *Service) RegisterAgent(router chi.Router) { + router.Post("/feedback", s.create) +} + +func (s *Service) create(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, maxRequestBytes) + parseErr := r.ParseMultipartForm(1 << 20) + if r.MultipartForm != nil { + defer func() { + if err := r.MultipartForm.RemoveAll(); err != nil { + slog.WarnContext(r.Context(), "清理反馈上传临时文件失败", "error", err) + } + }() + } + if parseErr != nil { + resource.Fail(w, resource.Invalid("反馈请求无效或超过 16 MiB")) + return + } + + rating, err := parseRating(r.FormValue("rating")) + if err != nil { + resource.Fail(w, err) + return + } + files := r.MultipartForm.File["images"] + if len(files) > maxAttachments { + resource.Fail(w, resource.Invalid("最多上传 3 张图片")) + return + } + images := make([]Image, 0, len(files)) + for _, header := range files { + image, err := readImage(header) + if err != nil { + resource.Fail(w, err) + return + } + images = append(images, image) + } + + user, _ := identity.UserFromContext(r.Context()) + idempotencyKey := strings.TrimSpace(r.Header.Get("Idempotency-Key")) + platform := strings.TrimSpace(r.FormValue("platform")) + if platform == "" { + platform = "other" + } + out, created, err := s.Submit(r.Context(), user.ID, Request{ + Category: strings.TrimSpace(r.FormValue("category")), Content: r.FormValue("content"), Rating: rating, + Platform: platform, ClientVersion: r.FormValue("client_version"), + RequestID: middleware.GetReqID(r.Context()), IdempotencyKey: idempotencyKey, Images: images, + }) + if err != nil { + resource.Fail(w, err) + return + } + status := http.StatusOK + if created { + status = http.StatusCreated + } + resource.JSON(w, status, agentResponse(out)) +} + +func parseRating(value string) (*int32, error) { + value = strings.TrimSpace(value) + if value == "" { + return nil, nil + } + n, err := strconv.ParseInt(value, 10, 32) + if err != nil || n < 1 || n > 5 { + return nil, resource.Invalid("评分必须为 1 至 5") + } + rating := int32(n) + return &rating, nil +} + +func readImage(header *multipart.FileHeader) (Image, error) { + if header.Size > maxImageBytes { + return Image{}, resource.Invalid("单张图片不能超过 5 MiB") + } + file, err := header.Open() + if err != nil { + return Image{}, resource.Invalid("读取图片失败") + } + defer func() { _ = file.Close() }() + data, err := io.ReadAll(io.LimitReader(file, maxImageBytes+1)) + if err != nil || len(data) == 0 || len(data) > maxImageBytes { + return Image{}, resource.Invalid("单张图片不能超过 5 MiB") + } + return decodeImage(data) +} + +func decodeImage(data []byte) (Image, error) { + config, _, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil || config.Width <= 0 || config.Height <= 0 || int64(config.Width)*int64(config.Height) > maxImagePixels { + return Image{}, resource.Invalid("图片格式、尺寸或像素数无效") + } + decoded, format, err := image.Decode(bytes.NewReader(data)) + if err != nil || decoded.Bounds().Dx() != config.Width || decoded.Bounds().Dy() != config.Height { + return Image{}, resource.Invalid("图片内容无效") + } + if format != "png" && format != "jpeg" && format != "webp" { + return Image{}, resource.Invalid("图片仅支持 PNG、JPEG 或 WebP") + } + mimeType := "image/" + format + return Image{Data: data, Format: format, MIMEType: mimeType, ByteSize: int64(len(data)), Width: config.Width, Height: config.Height, SHA256: sha256Hex(data)}, nil +} diff --git a/monkeyai/backend/internal/feedback/cleanup.go b/monkeyai/backend/internal/feedback/cleanup.go new file mode 100644 index 000000000..ff23f4b28 --- /dev/null +++ b/monkeyai/backend/internal/feedback/cleanup.go @@ -0,0 +1,63 @@ +package feedback + +import ( + "context" + "errors" + "log/slog" + "time" + + "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/feedback/sqlc" +) + +// Cleanup 删除进程崩溃或上传失败留下的 pending/failed 附件对象及记录。 +func (s *Service) Cleanup(ctx context.Context) error { + cutoff := s.now().Add(-cleanupAge) + var firstErr error + if _, err := sqlc.New(s.pool).MarkStaleUploading(ctx, cutoff); err != nil { + firstErr = err + } + rows, err := sqlc.New(s.pool).ListStaleAttachments(ctx, sqlc.ListStaleAttachmentsParams{ + CreatedAt: cutoff, Limit: cleanupBatchSize, + }) + if err != nil { + return err + } + for _, row := range rows { + if err := s.storage.Delete(ctx, row.ObjectKey); err != nil { + if firstErr == nil { + firstErr = storageFailure() + } + slog.WarnContext(ctx, "清理过期反馈附件对象失败", "feedback_id", row.FeedbackID, "attachment_id", row.ID, "error_type", errorType(err)) + continue + } + if _, err := sqlc.New(s.pool).DeleteAttachment(ctx, row.ID); err != nil { + if firstErr == nil { + firstErr = err + } + slog.WarnContext(ctx, "清理过期反馈附件记录失败", "feedback_id", row.FeedbackID, "attachment_id", row.ID, "error_type", errorType(err)) + } + } + return firstErr +} + +// Run 按周期执行补偿清理,ctx 结束后返回。 +func (s *Service) Run(ctx context.Context, interval time.Duration) error { + if interval <= 0 { + interval = time.Hour + } + if err := s.Cleanup(ctx); err != nil && !errors.Is(err, context.Canceled) { + slog.WarnContext(ctx, "反馈附件初次清理失败", "error", err) + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + if err := s.Cleanup(ctx); err != nil && !errors.Is(err, context.Canceled) { + slog.WarnContext(ctx, "反馈附件周期清理失败", "error", err) + } + } + } +} diff --git a/monkeyai/backend/internal/feedback/feedback.go b/monkeyai/backend/internal/feedback/feedback.go new file mode 100644 index 000000000..a550009e8 --- /dev/null +++ b/monkeyai/backend/internal/feedback/feedback.go @@ -0,0 +1,291 @@ +package feedback + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log/slog" + "slices" + "strings" + "time" + "unicode/utf8" + + "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/feedback/sqlc" + "github.com/chaitin/MonkeyCode/monkeyai/backend/internal/resource" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +const ( + maxContentRunes = 5000 + maxVersionRunes = 64 + maxIdempotency = 128 + maxAttachments = 3 + maxImageBytes = 5 << 20 + maxRequestBytes = 16 << 20 + maxImagePixels = 25_000_000 + cleanupAge = 24 * time.Hour + cleanupBatchSize = 100 +) + +var categories = []string{"bug", "feature", "experience", "other"} +var platforms = []string{"desktop", "mobile", "web", "other"} +var states = []string{"uploading", "new", "resolved", "ignored", "failed"} +var patchStates = []string{"new", "resolved", "ignored"} + +// Image 是已完成真实格式校验的上传图片。 +type Image struct { + Data []byte + Format string + MIMEType string + ByteSize int64 + Width int + Height int + SHA256 string +} + +type Request struct { + Category string + Content string + Rating *int32 + Platform string + ClientVersion string + RequestID string + IdempotencyKey string + Images []Image +} + +type Feedback struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Category string `json:"category"` + Content string `json:"content"` + Rating *int32 `json:"rating,omitempty"` + Platform string `json:"platform"` + ClientVersion string `json:"client_version"` + State string `json:"state"` + RequestID string `json:"request_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Attachments []Attachment `json:"attachments"` +} + +type Attachment struct { + ID string `json:"id"` + FeedbackID string `json:"feedback_id"` + MIMEType string `json:"mime_type"` + ByteSize int64 `json:"byte_size"` + Width int32 `json:"width"` + Height int32 `json:"height"` + SHA256 string `json:"sha256"` + State string `json:"state"` + Downloadable bool `json:"downloadable"` + CreatedAt time.Time `json:"created_at"` +} + +type Service struct { + pool *pgxpool.Pool + storage resource.Storage + now func() time.Time +} + +func NewService(pool *pgxpool.Pool, storage resource.Storage) *Service { + return &Service{pool: pool, storage: storage, now: time.Now} +} + +func (s *Service) Submit(ctx context.Context, userID string, in Request) (Feedback, bool, error) { + if err := validateRequest(userID, in); err != nil { + return Feedback{}, false, err + } + hash, err := requestHash(in) + if err != nil { + return Feedback{}, false, err + } + id := resource.ID() + row, err := sqlc.New(s.pool).CreateFeedback(ctx, sqlc.CreateFeedbackParams{ + ID: id, UserID: userID, Category: in.Category, Content: in.Content, Rating: in.Rating, + Platform: in.Platform, ClientVersion: in.ClientVersion, RequestID: in.RequestID, + IdempotencyKey: in.IdempotencyKey, RequestHash: hash, + }) + if errors.Is(err, pgx.ErrNoRows) && in.IdempotencyKey != "" { + row, err = sqlc.New(s.pool).FeedbackByIdempotency(ctx, sqlc.FeedbackByIdempotencyParams{ + UserID: userID, IdempotencyKey: in.IdempotencyKey, + }) + if err != nil { + return Feedback{}, false, err + } + if row.RequestHash != hash { + return Feedback{}, false, &resource.Error{Status: 409, Code: "idempotency_conflict", Message: "幂等键已用于其他反馈请求"} + } + switch row.State { + case "uploading": + return Feedback{}, false, &resource.Error{Status: 409, Code: "feedback_in_progress", Message: "反馈仍在处理中,请稍后重试"} + case "failed": + return Feedback{}, false, &resource.Error{Status: 500, Code: "feedback_failed", Message: "反馈处理失败,请稍后重试"} + } + out, err := s.read(ctx, row) + return out, false, err + } + if err != nil { + return Feedback{}, false, err + } + + attachmentIDs := make([]string, 0, len(in.Images)) + objectKeys := make([]string, 0, len(in.Images)) + for _, image := range in.Images { + attachmentID := resource.ID() + key := fmt.Sprintf("feedback/%s/%s/%s.%s", userID, row.ID, attachmentID, image.Format) + if err := sqlc.New(s.pool).CreateAttachment(ctx, sqlc.CreateAttachmentParams{ + ID: attachmentID, FeedbackID: row.ID, ObjectKey: key, MimeType: image.MIMEType, + ByteSize: image.ByteSize, Width: int32(image.Width), Height: int32(image.Height), Sha256: image.SHA256, + }); err != nil { + return Feedback{}, true, s.fail(ctx, row.ID, attachmentIDs, objectKeys, err) + } + attachmentIDs = append(attachmentIDs, attachmentID) + objectKeys = append(objectKeys, key) + if err := s.storage.Put(ctx, key, image.Data, image.MIMEType); err != nil { + return Feedback{}, true, s.fail(ctx, row.ID, attachmentIDs, objectKeys, storageFailure()) + } + count, err := sqlc.New(s.pool).SetAttachmentReady(ctx, sqlc.SetAttachmentReadyParams{ID: attachmentID, FeedbackID: row.ID}) + if err != nil { + return Feedback{}, true, s.fail(ctx, row.ID, attachmentIDs, objectKeys, err) + } + if count != 1 { + return Feedback{}, true, s.fail(ctx, row.ID, attachmentIDs, objectKeys, errors.New("反馈附件状态更新失败")) + } + } + if _, err := sqlc.New(s.pool).SetFeedbackState(ctx, sqlc.SetFeedbackStateParams{ID: row.ID, State: "new"}); err != nil { + return Feedback{}, true, s.fail(ctx, row.ID, attachmentIDs, objectKeys, err) + } + row, err = sqlc.New(s.pool).FeedbackByID(ctx, row.ID) + if err != nil { + return Feedback{}, true, err + } + out, err := s.read(ctx, row) + return out, true, err +} + +func (s *Service) read(ctx context.Context, row sqlc.Feedback) (Feedback, error) { + attachments, err := sqlc.New(s.pool).ListAttachments(ctx, row.ID) + if err != nil { + return Feedback{}, err + } + out := Feedback{ + ID: row.ID, UserID: row.UserID, Category: row.Category, Content: row.Content, Rating: row.Rating, + Platform: row.Platform, ClientVersion: row.ClientVersion, State: row.State, RequestID: row.RequestID, + CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, Attachments: make([]Attachment, 0, len(attachments)), + } + for _, attachment := range attachments { + out.Attachments = append(out.Attachments, publicAttachment(attachment, row.State)) + } + return out, nil +} + +func publicAttachment(row sqlc.FeedbackAttachment, feedbackState string) Attachment { + return Attachment{ + ID: row.ID, FeedbackID: row.FeedbackID, MIMEType: row.MimeType, ByteSize: row.ByteSize, + Width: row.Width, Height: row.Height, SHA256: row.Sha256, State: row.State, + Downloadable: row.State == "ready" && feedbackState != "uploading" && feedbackState != "failed", + CreatedAt: row.CreatedAt, + } +} + +func (s *Service) fail(ctx context.Context, feedbackID string, attachmentIDs []string, objectKeys []string, cause error) error { + for _, attachmentID := range attachmentIDs { + if _, err := sqlc.New(s.pool).SetAttachmentFailed(ctx, sqlc.SetAttachmentFailedParams{ID: attachmentID, FeedbackID: feedbackID}); err != nil { + slog.ErrorContext(ctx, "标记反馈附件失败", "feedback_id", feedbackID, "attachment_id", attachmentID, "error", err) + } + } + if _, err := sqlc.New(s.pool).SetFeedbackState(ctx, sqlc.SetFeedbackStateParams{ID: feedbackID, State: "failed"}); err != nil { + slog.ErrorContext(ctx, "标记反馈失败", "feedback_id", feedbackID, "error", err) + } + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) + defer cancel() + for _, key := range objectKeys { + if err := s.storage.Delete(cleanupCtx, key); err != nil { + slog.WarnContext(ctx, "补偿删除反馈附件失败", "feedback_id", feedbackID, "error_type", fmt.Sprintf("%T", err)) + } + } + return cause +} + +func validateRequest(userID string, in Request) error { + if userID == "" || !slices.Contains(categories, in.Category) || !slices.Contains(platforms, in.Platform) { + return resource.Invalid("反馈分类或平台无效") + } + if !utf8.ValidString(in.Content) || utf8.RuneCountInString(in.Content) > maxContentRunes { + return resource.Invalid("反馈内容不能超过 5000 个字符") + } + if strings.TrimSpace(in.Content) == "" && len(in.Images) == 0 { + return resource.Invalid("反馈内容和图片不能同时为空") + } + if utf8.RuneCountInString(in.ClientVersion) > maxVersionRunes { + return resource.Invalid("客户端版本不能超过 64 个字符") + } + if utf8.RuneCountInString(in.IdempotencyKey) > maxIdempotency { + return resource.Invalid("幂等键不能超过 128 个字符") + } + if in.Rating != nil && (*in.Rating < 1 || *in.Rating > 5) { + return resource.Invalid("评分必须为 1 至 5") + } + if len(in.Images) > maxAttachments { + return resource.Invalid("最多上传 3 张图片") + } + var total int64 + for _, image := range in.Images { + if image.Format != "png" && image.Format != "jpeg" && image.Format != "webp" { + return resource.Invalid("图片仅支持 PNG、JPEG 或 WebP") + } + if image.MIMEType != "image/"+image.Format || len(image.Data) == 0 || len(image.Data) > maxImageBytes { + return resource.Invalid("图片大小或格式无效") + } + if image.Width <= 0 || image.Height <= 0 || int64(image.Width)*int64(image.Height) > maxImagePixels { + return resource.Invalid("图片尺寸或像素数无效") + } + digest := sha256.Sum256(image.Data) + if image.SHA256 != hex.EncodeToString(digest[:]) { + return resource.Invalid("图片内容校验失败") + } + total += int64(len(image.Data)) + } + if total > 15<<20 { + return resource.Invalid("图片总大小不能超过 15 MiB") + } + return nil +} + +func storageFailure() error { + return errors.New("对象存储操作失败") +} + +func errorType(err error) string { + return fmt.Sprintf("%T", err) +} + +func sha256Hex(data []byte) string { + digest := sha256.Sum256(data) + return hex.EncodeToString(digest[:]) +} + +func requestHash(in Request) (string, error) { + images := make([]string, 0, len(in.Images)) + for _, image := range in.Images { + images = append(images, image.SHA256) + } + data, err := json.Marshal(struct { + Category string `json:"category"` + Content string `json:"content"` + Rating *int32 `json:"rating"` + Platform string `json:"platform"` + ClientVersion string `json:"client_version"` + Images []string `json:"images"` + }{in.Category, in.Content, in.Rating, in.Platform, in.ClientVersion, images}) + if err != nil { + return "", err + } + digest := sha256.Sum256(data) + return hex.EncodeToString(digest[:]), nil +} diff --git a/monkeyai/backend/internal/feedback/feedback_test.go b/monkeyai/backend/internal/feedback/feedback_test.go new file mode 100644 index 000000000..1e1777a5c --- /dev/null +++ b/monkeyai/backend/internal/feedback/feedback_test.go @@ -0,0 +1,109 @@ +package feedback + +import ( + "bytes" + "context" + "image" + "image/color" + "image/png" + "io" + "sync" + "testing" +) + +type memoryStorage struct { + mu sync.Mutex + data map[string][]byte +} + +func newMemoryStorage() *memoryStorage { + return &memoryStorage{data: make(map[string][]byte)} +} + +func (s *memoryStorage) Put(_ context.Context, key string, data []byte, _ string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.data[key] = bytes.Clone(data) + return nil +} + +func (s *memoryStorage) Get(_ context.Context, key string) (io.ReadCloser, error) { + s.mu.Lock() + defer s.mu.Unlock() + data, ok := s.data[key] + if !ok { + return nil, io.EOF + } + return io.NopCloser(bytes.NewReader(bytes.Clone(data))), nil +} + +func (s *memoryStorage) Delete(_ context.Context, key string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.data, key) + return nil +} + +func (s *memoryStorage) Ping(context.Context) error { return nil } + +func TestMemoryStorageRoundTrip(t *testing.T) { + storage := newMemoryStorage() + want := []byte("feedback") + if err := storage.Put(context.Background(), "key", want, "text/plain"); err != nil { + t.Fatal(err) + } + reader, err := storage.Get(context.Background(), "key") + if err != nil { + t.Fatal(err) + } + defer reader.Close() + got, err := io.ReadAll(reader) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, want) { + t.Fatalf("storage data = %q, want %q", got, want) + } +} + +func TestDecodeImageUsesContent(t *testing.T) { + imageData := newPNG(t, 2, 3) + got, err := decodeImage(imageData) + if err != nil { + t.Fatal(err) + } + if got.Format != "png" || got.MIMEType != "image/png" || got.Width != 2 || got.Height != 3 { + t.Fatalf("decoded image = %+v", got) + } + if got.SHA256 != sha256Hex(imageData) { + t.Fatal("decoded image hash mismatch") + } +} + +func TestDecodeImageRejectsSVG(t *testing.T) { + if _, err := decodeImage([]byte(``)); err == nil { + t.Fatal("SVG was accepted") + } +} + +func TestValidateRequestRequiresContentOrImage(t *testing.T) { + err := validateRequest("user", Request{Category: "bug", Platform: "web"}) + if err == nil { + t.Fatal("empty feedback was accepted") + } +} + +func newPNG(t *testing.T, width, height int) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, width, height)) + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + img.SetRGBA(x, y, color.RGBA{R: uint8(x + 1), G: uint8(y + 1), A: 255}) + } + } + var buffer bytes.Buffer + if err := png.Encode(&buffer, img); err != nil { + t.Fatal(err) + } + return buffer.Bytes() +} diff --git a/monkeyai/backend/internal/feedback/query.sql b/monkeyai/backend/internal/feedback/query.sql new file mode 100644 index 000000000..423151145 --- /dev/null +++ b/monkeyai/backend/internal/feedback/query.sql @@ -0,0 +1,103 @@ +-- name: CreateFeedback :one +INSERT INTO feedbacks ( + id, user_id, category, content, rating, platform, client_version, + state, request_id, idempotency_key, request_hash +) VALUES ($1, $2, $3, $4, $5, $6, $7, 'uploading', $8, $9, $10) +ON CONFLICT DO NOTHING +RETURNING id, user_id, category, content, rating, platform, client_version, + state, request_id, idempotency_key, request_hash, created_at, updated_at; + +-- name: FeedbackByID :one +SELECT id, user_id, category, content, rating, platform, client_version, + state, request_id, idempotency_key, request_hash, created_at, updated_at +FROM feedbacks +WHERE id = $1; + +-- name: FeedbackByIdempotency :one +SELECT id, user_id, category, content, rating, platform, client_version, + state, request_id, idempotency_key, request_hash, created_at, updated_at +FROM feedbacks +WHERE user_id = $1 AND idempotency_key = $2 AND idempotency_key <> ''; + +-- name: ListFeedbacks :many +SELECT id, user_id, category, content, rating, platform, client_version, + state, request_id, idempotency_key, request_hash, created_at, updated_at +FROM feedbacks +WHERE (sqlc.arg(category)::text = '' OR feedbacks.category = sqlc.arg(category)::text) + AND (sqlc.arg(state)::text = '' OR feedbacks.state = sqlc.arg(state)::text) +ORDER BY created_at DESC, id DESC +LIMIT sqlc.arg(page_limit)::int OFFSET sqlc.arg(page_offset)::int; + +-- name: CountFeedbacks :one +SELECT count(*) +FROM feedbacks +WHERE (sqlc.arg(category)::text = '' OR feedbacks.category = sqlc.arg(category)::text) + AND (sqlc.arg(state)::text = '' OR feedbacks.state = sqlc.arg(state)::text); + +-- name: ListAttachments :many +SELECT id, feedback_id, object_key, mime_type, byte_size, width, height, + sha256, state, created_at +FROM feedback_attachments +WHERE feedback_id = $1 +ORDER BY created_at ASC, id ASC; + +-- name: AttachmentForDownload :one +SELECT a.id, a.feedback_id, a.object_key, a.mime_type, a.byte_size, + a.width, a.height, a.sha256, a.state, a.created_at +FROM feedback_attachments AS a +JOIN feedbacks AS f ON f.id = a.feedback_id +WHERE f.id = sqlc.arg(feedback_id) + AND a.id = sqlc.arg(attachment_id) + AND f.state NOT IN ('uploading', 'failed') + AND a.state = 'ready'; + +-- name: CreateAttachment :exec +INSERT INTO feedback_attachments ( + id, feedback_id, object_key, mime_type, byte_size, width, height, sha256, state +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending'); + +-- name: SetAttachmentReady :execrows +UPDATE feedback_attachments +SET state = 'ready' +WHERE id = $1 AND feedback_id = $2 AND state = 'pending'; + +-- name: SetAttachmentFailed :execrows +UPDATE feedback_attachments +SET state = 'failed' +WHERE id = $1 AND feedback_id = $2 AND state <> 'failed'; + +-- name: SetFeedbackState :execrows +UPDATE feedbacks +SET state = $2, updated_at = now() +WHERE id = $1; + +-- name: UpdateFeedbackState :one +UPDATE feedbacks +SET state = $2, updated_at = now() +WHERE id = $1 +RETURNING id, user_id, category, content, rating, platform, client_version, + state, request_id, idempotency_key, request_hash, created_at, updated_at; + +-- name: DeleteAttachment :execrows +DELETE FROM feedback_attachments +WHERE id = $1; + +-- name: ListStaleAttachments :many +SELECT id, feedback_id, object_key, mime_type, byte_size, width, height, + sha256, state, created_at +FROM feedback_attachments +WHERE state IN ('pending', 'failed') AND created_at < $1 +ORDER BY created_at ASC +LIMIT $2; + +-- name: MarkStaleUploading :execrows +WITH stale AS ( + UPDATE feedbacks AS f + SET state = 'failed', updated_at = now() + WHERE f.state = 'uploading' AND f.created_at < $1 + RETURNING id +) +UPDATE feedback_attachments AS a +SET state = 'failed' +FROM stale +WHERE a.feedback_id = stale.id AND a.state <> 'failed'; diff --git a/monkeyai/backend/internal/feedback/sqlc/db.go b/monkeyai/backend/internal/feedback/sqlc/db.go new file mode 100644 index 000000000..7a5650746 --- /dev/null +++ b/monkeyai/backend/internal/feedback/sqlc/db.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package sqlc + +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, + } +} diff --git a/monkeyai/backend/internal/feedback/sqlc/models.go b/monkeyai/backend/internal/feedback/sqlc/models.go new file mode 100644 index 000000000..53e190adc --- /dev/null +++ b/monkeyai/backend/internal/feedback/sqlc/models.go @@ -0,0 +1,38 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package sqlc + +import ( + "time" +) + +type Feedback struct { + ID string + UserID string + Category string + Content string + Rating *int32 + Platform string + ClientVersion string + State string + RequestID string + IdempotencyKey string + RequestHash string + CreatedAt time.Time + UpdatedAt time.Time +} + +type FeedbackAttachment struct { + ID string + FeedbackID string + ObjectKey string + MimeType string + ByteSize int64 + Width int32 + Height int32 + Sha256 string + State string + CreatedAt time.Time +} diff --git a/monkeyai/backend/internal/feedback/sqlc/query.sql.go b/monkeyai/backend/internal/feedback/sqlc/query.sql.go new file mode 100644 index 000000000..8e0e411a7 --- /dev/null +++ b/monkeyai/backend/internal/feedback/sqlc/query.sql.go @@ -0,0 +1,476 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: query.sql + +package sqlc + +import ( + "context" + "time" +) + +const attachmentForDownload = `-- name: AttachmentForDownload :one +SELECT a.id, a.feedback_id, a.object_key, a.mime_type, a.byte_size, + a.width, a.height, a.sha256, a.state, a.created_at +FROM feedback_attachments AS a +JOIN feedbacks AS f ON f.id = a.feedback_id +WHERE f.id = $1 + AND a.id = $2 + AND f.state NOT IN ('uploading', 'failed') + AND a.state = 'ready' +` + +type AttachmentForDownloadParams struct { + FeedbackID string + AttachmentID string +} + +func (q *Queries) AttachmentForDownload(ctx context.Context, arg AttachmentForDownloadParams) (FeedbackAttachment, error) { + row := q.db.QueryRow(ctx, attachmentForDownload, arg.FeedbackID, arg.AttachmentID) + var i FeedbackAttachment + err := row.Scan( + &i.ID, + &i.FeedbackID, + &i.ObjectKey, + &i.MimeType, + &i.ByteSize, + &i.Width, + &i.Height, + &i.Sha256, + &i.State, + &i.CreatedAt, + ) + return i, err +} + +const countFeedbacks = `-- name: CountFeedbacks :one +SELECT count(*) +FROM feedbacks +WHERE ($1::text = '' OR feedbacks.category = $1::text) + AND ($2::text = '' OR feedbacks.state = $2::text) +` + +type CountFeedbacksParams struct { + Category string + State string +} + +func (q *Queries) CountFeedbacks(ctx context.Context, arg CountFeedbacksParams) (int64, error) { + row := q.db.QueryRow(ctx, countFeedbacks, arg.Category, arg.State) + var count int64 + err := row.Scan(&count) + return count, err +} + +const createAttachment = `-- name: CreateAttachment :exec +INSERT INTO feedback_attachments ( + id, feedback_id, object_key, mime_type, byte_size, width, height, sha256, state +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending') +` + +type CreateAttachmentParams struct { + ID string + FeedbackID string + ObjectKey string + MimeType string + ByteSize int64 + Width int32 + Height int32 + Sha256 string +} + +func (q *Queries) CreateAttachment(ctx context.Context, arg CreateAttachmentParams) error { + _, err := q.db.Exec(ctx, createAttachment, + arg.ID, + arg.FeedbackID, + arg.ObjectKey, + arg.MimeType, + arg.ByteSize, + arg.Width, + arg.Height, + arg.Sha256, + ) + return err +} + +const createFeedback = `-- name: CreateFeedback :one +INSERT INTO feedbacks ( + id, user_id, category, content, rating, platform, client_version, + state, request_id, idempotency_key, request_hash +) VALUES ($1, $2, $3, $4, $5, $6, $7, 'uploading', $8, $9, $10) +ON CONFLICT DO NOTHING +RETURNING id, user_id, category, content, rating, platform, client_version, + state, request_id, idempotency_key, request_hash, created_at, updated_at +` + +type CreateFeedbackParams struct { + ID string + UserID string + Category string + Content string + Rating *int32 + Platform string + ClientVersion string + RequestID string + IdempotencyKey string + RequestHash string +} + +func (q *Queries) CreateFeedback(ctx context.Context, arg CreateFeedbackParams) (Feedback, error) { + row := q.db.QueryRow(ctx, createFeedback, + arg.ID, + arg.UserID, + arg.Category, + arg.Content, + arg.Rating, + arg.Platform, + arg.ClientVersion, + arg.RequestID, + arg.IdempotencyKey, + arg.RequestHash, + ) + var i Feedback + err := row.Scan( + &i.ID, + &i.UserID, + &i.Category, + &i.Content, + &i.Rating, + &i.Platform, + &i.ClientVersion, + &i.State, + &i.RequestID, + &i.IdempotencyKey, + &i.RequestHash, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteAttachment = `-- name: DeleteAttachment :execrows +DELETE FROM feedback_attachments +WHERE id = $1 +` + +func (q *Queries) DeleteAttachment(ctx context.Context, id string) (int64, error) { + result, err := q.db.Exec(ctx, deleteAttachment, id) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const feedbackByID = `-- name: FeedbackByID :one +SELECT id, user_id, category, content, rating, platform, client_version, + state, request_id, idempotency_key, request_hash, created_at, updated_at +FROM feedbacks +WHERE id = $1 +` + +func (q *Queries) FeedbackByID(ctx context.Context, id string) (Feedback, error) { + row := q.db.QueryRow(ctx, feedbackByID, id) + var i Feedback + err := row.Scan( + &i.ID, + &i.UserID, + &i.Category, + &i.Content, + &i.Rating, + &i.Platform, + &i.ClientVersion, + &i.State, + &i.RequestID, + &i.IdempotencyKey, + &i.RequestHash, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const feedbackByIdempotency = `-- name: FeedbackByIdempotency :one +SELECT id, user_id, category, content, rating, platform, client_version, + state, request_id, idempotency_key, request_hash, created_at, updated_at +FROM feedbacks +WHERE user_id = $1 AND idempotency_key = $2 AND idempotency_key <> '' +` + +type FeedbackByIdempotencyParams struct { + UserID string + IdempotencyKey string +} + +func (q *Queries) FeedbackByIdempotency(ctx context.Context, arg FeedbackByIdempotencyParams) (Feedback, error) { + row := q.db.QueryRow(ctx, feedbackByIdempotency, arg.UserID, arg.IdempotencyKey) + var i Feedback + err := row.Scan( + &i.ID, + &i.UserID, + &i.Category, + &i.Content, + &i.Rating, + &i.Platform, + &i.ClientVersion, + &i.State, + &i.RequestID, + &i.IdempotencyKey, + &i.RequestHash, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listAttachments = `-- name: ListAttachments :many +SELECT id, feedback_id, object_key, mime_type, byte_size, width, height, + sha256, state, created_at +FROM feedback_attachments +WHERE feedback_id = $1 +ORDER BY created_at ASC, id ASC +` + +func (q *Queries) ListAttachments(ctx context.Context, feedbackID string) ([]FeedbackAttachment, error) { + rows, err := q.db.Query(ctx, listAttachments, feedbackID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FeedbackAttachment{} + for rows.Next() { + var i FeedbackAttachment + if err := rows.Scan( + &i.ID, + &i.FeedbackID, + &i.ObjectKey, + &i.MimeType, + &i.ByteSize, + &i.Width, + &i.Height, + &i.Sha256, + &i.State, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listFeedbacks = `-- name: ListFeedbacks :many +SELECT id, user_id, category, content, rating, platform, client_version, + state, request_id, idempotency_key, request_hash, created_at, updated_at +FROM feedbacks +WHERE ($1::text = '' OR feedbacks.category = $1::text) + AND ($2::text = '' OR feedbacks.state = $2::text) +ORDER BY created_at DESC, id DESC +LIMIT $4::int OFFSET $3::int +` + +type ListFeedbacksParams struct { + Category string + State string + PageOffset int32 + PageLimit int32 +} + +func (q *Queries) ListFeedbacks(ctx context.Context, arg ListFeedbacksParams) ([]Feedback, error) { + rows, err := q.db.Query(ctx, listFeedbacks, + arg.Category, + arg.State, + arg.PageOffset, + arg.PageLimit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Feedback{} + for rows.Next() { + var i Feedback + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Category, + &i.Content, + &i.Rating, + &i.Platform, + &i.ClientVersion, + &i.State, + &i.RequestID, + &i.IdempotencyKey, + &i.RequestHash, + &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 listStaleAttachments = `-- name: ListStaleAttachments :many +SELECT id, feedback_id, object_key, mime_type, byte_size, width, height, + sha256, state, created_at +FROM feedback_attachments +WHERE state IN ('pending', 'failed') AND created_at < $1 +ORDER BY created_at ASC +LIMIT $2 +` + +type ListStaleAttachmentsParams struct { + CreatedAt time.Time + Limit int32 +} + +func (q *Queries) ListStaleAttachments(ctx context.Context, arg ListStaleAttachmentsParams) ([]FeedbackAttachment, error) { + rows, err := q.db.Query(ctx, listStaleAttachments, arg.CreatedAt, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []FeedbackAttachment{} + for rows.Next() { + var i FeedbackAttachment + if err := rows.Scan( + &i.ID, + &i.FeedbackID, + &i.ObjectKey, + &i.MimeType, + &i.ByteSize, + &i.Width, + &i.Height, + &i.Sha256, + &i.State, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const markStaleUploading = `-- name: MarkStaleUploading :execrows +WITH stale AS ( + UPDATE feedbacks AS f + SET state = 'failed', updated_at = now() + WHERE f.state = 'uploading' AND f.created_at < $1 + RETURNING id +) +UPDATE feedback_attachments AS a +SET state = 'failed' +FROM stale +WHERE a.feedback_id = stale.id AND a.state <> 'failed' +` + +func (q *Queries) MarkStaleUploading(ctx context.Context, createdAt time.Time) (int64, error) { + result, err := q.db.Exec(ctx, markStaleUploading, createdAt) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const setAttachmentFailed = `-- name: SetAttachmentFailed :execrows +UPDATE feedback_attachments +SET state = 'failed' +WHERE id = $1 AND feedback_id = $2 AND state <> 'failed' +` + +type SetAttachmentFailedParams struct { + ID string + FeedbackID string +} + +func (q *Queries) SetAttachmentFailed(ctx context.Context, arg SetAttachmentFailedParams) (int64, error) { + result, err := q.db.Exec(ctx, setAttachmentFailed, arg.ID, arg.FeedbackID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const setAttachmentReady = `-- name: SetAttachmentReady :execrows +UPDATE feedback_attachments +SET state = 'ready' +WHERE id = $1 AND feedback_id = $2 AND state = 'pending' +` + +type SetAttachmentReadyParams struct { + ID string + FeedbackID string +} + +func (q *Queries) SetAttachmentReady(ctx context.Context, arg SetAttachmentReadyParams) (int64, error) { + result, err := q.db.Exec(ctx, setAttachmentReady, arg.ID, arg.FeedbackID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const setFeedbackState = `-- name: SetFeedbackState :execrows +UPDATE feedbacks +SET state = $2, updated_at = now() +WHERE id = $1 +` + +type SetFeedbackStateParams struct { + ID string + State string +} + +func (q *Queries) SetFeedbackState(ctx context.Context, arg SetFeedbackStateParams) (int64, error) { + result, err := q.db.Exec(ctx, setFeedbackState, arg.ID, arg.State) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const updateFeedbackState = `-- name: UpdateFeedbackState :one +UPDATE feedbacks +SET state = $2, updated_at = now() +WHERE id = $1 +RETURNING id, user_id, category, content, rating, platform, client_version, + state, request_id, idempotency_key, request_hash, created_at, updated_at +` + +type UpdateFeedbackStateParams struct { + ID string + State string +} + +func (q *Queries) UpdateFeedbackState(ctx context.Context, arg UpdateFeedbackStateParams) (Feedback, error) { + row := q.db.QueryRow(ctx, updateFeedbackState, arg.ID, arg.State) + var i Feedback + err := row.Scan( + &i.ID, + &i.UserID, + &i.Category, + &i.Content, + &i.Rating, + &i.Platform, + &i.ClientVersion, + &i.State, + &i.RequestID, + &i.IdempotencyKey, + &i.RequestHash, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/monkeyai/backend/internal/model/sqlc/models.go b/monkeyai/backend/internal/model/sqlc/models.go index 04739426f..20a0271ef 100644 --- a/monkeyai/backend/internal/model/sqlc/models.go +++ b/monkeyai/backend/internal/model/sqlc/models.go @@ -15,11 +15,6 @@ type Model struct { ModelID string DisplayName string Protocol string - Kind string - Provider string - ProviderOptions []byte - ImageConfig []byte - ImagePricing []byte BaseUrl string ApiKey string AdvancedConfig []byte @@ -28,4 +23,9 @@ type Model struct { CreatedAt time.Time UpdatedAt time.Time DeletedAt *time.Time + Kind string + Provider string + ProviderOptions []byte + ImageConfig []byte + ImagePricing []byte } diff --git a/monkeyai/backend/internal/model/sqlc/query.sql.go b/monkeyai/backend/internal/model/sqlc/query.sql.go index 388a8bcef..134528b85 100644 --- a/monkeyai/backend/internal/model/sqlc/query.sql.go +++ b/monkeyai/backend/internal/model/sqlc/query.sql.go @@ -17,7 +17,7 @@ INSERT INTO models (ownership_type, owner_user_id, model_id, display_name, proto advanced_config, credit_multiplier, kind, provider, provider_options, image_config, image_pricing, enabled) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, TRUE) RETURNING - id, ownership_type, owner_user_id, model_id, display_name, protocol, kind, provider, provider_options, image_config, image_pricing, base_url, api_key, advanced_config, credit_multiplier, enabled, created_at, updated_at, deleted_at + id, ownership_type, owner_user_id, model_id, display_name, protocol, base_url, api_key, advanced_config, credit_multiplier, enabled, created_at, updated_at, deleted_at, kind, provider, provider_options, image_config, image_pricing ` type CreateModelParams struct { @@ -62,11 +62,6 @@ func (q *Queries) CreateModel(ctx context.Context, arg CreateModelParams) (Model &i.ModelID, &i.DisplayName, &i.Protocol, - &i.Kind, - &i.Provider, - &i.ProviderOptions, - &i.ImageConfig, - &i.ImagePricing, &i.BaseUrl, &i.ApiKey, &i.AdvancedConfig, @@ -75,6 +70,11 @@ func (q *Queries) CreateModel(ctx context.Context, arg CreateModelParams) (Model &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.Kind, + &i.Provider, + &i.ProviderOptions, + &i.ImageConfig, + &i.ImagePricing, ) return i, err } @@ -116,7 +116,7 @@ func (q *Queries) DeleteModel(ctx context.Context, arg DeleteModelParams) (pgcon const getModel = `-- name: GetModel :one SELECT - id, ownership_type, owner_user_id, model_id, display_name, protocol, kind, provider, provider_options, image_config, image_pricing, base_url, api_key, advanced_config, credit_multiplier, enabled, created_at, updated_at, deleted_at + id, ownership_type, owner_user_id, model_id, display_name, protocol, base_url, api_key, advanced_config, credit_multiplier, enabled, created_at, updated_at, deleted_at, kind, provider, provider_options, image_config, image_pricing FROM models WHERE @@ -134,11 +134,6 @@ func (q *Queries) GetModel(ctx context.Context, id string) (Model, error) { &i.ModelID, &i.DisplayName, &i.Protocol, - &i.Kind, - &i.Provider, - &i.ProviderOptions, - &i.ImageConfig, - &i.ImagePricing, &i.BaseUrl, &i.ApiKey, &i.AdvancedConfig, @@ -147,6 +142,11 @@ func (q *Queries) GetModel(ctx context.Context, id string) (Model, error) { &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.Kind, + &i.Provider, + &i.ProviderOptions, + &i.ImageConfig, + &i.ImagePricing, ) return i, err } @@ -225,7 +225,7 @@ WITH RECURSIVE user_groups ( AND g.deleted_at IS NULL ) SELECT - m.id, m.ownership_type, m.owner_user_id, m.model_id, m.display_name, m.protocol, m.kind, m.provider, m.provider_options, m.image_config, m.image_pricing, m.base_url, m.api_key, m.advanced_config, m.credit_multiplier, m.enabled, m.created_at, m.updated_at, m.deleted_at + m.id, m.ownership_type, m.owner_user_id, m.model_id, m.display_name, m.protocol, m.base_url, m.api_key, m.advanced_config, m.credit_multiplier, m.enabled, m.created_at, m.updated_at, m.deleted_at, m.kind, m.provider, m.provider_options, m.image_config, m.image_pricing FROM models m WHERE @@ -279,11 +279,6 @@ func (q *Queries) ListAvailable(ctx context.Context, arg ListAvailableParams) ([ &i.ModelID, &i.DisplayName, &i.Protocol, - &i.Kind, - &i.Provider, - &i.ProviderOptions, - &i.ImageConfig, - &i.ImagePricing, &i.BaseUrl, &i.ApiKey, &i.AdvancedConfig, @@ -292,6 +287,11 @@ func (q *Queries) ListAvailable(ctx context.Context, arg ListAvailableParams) ([ &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.Kind, + &i.Provider, + &i.ProviderOptions, + &i.ImageConfig, + &i.ImagePricing, ); err != nil { return nil, err } @@ -393,7 +393,7 @@ func (q *Queries) ListGroups(ctx context.Context) ([]ListGroupsRow, error) { const listModels = `-- name: ListModels :many SELECT - id, ownership_type, owner_user_id, model_id, display_name, protocol, kind, provider, provider_options, image_config, image_pricing, base_url, api_key, advanced_config, credit_multiplier, enabled, created_at, updated_at, deleted_at + id, ownership_type, owner_user_id, model_id, display_name, protocol, base_url, api_key, advanced_config, credit_multiplier, enabled, created_at, updated_at, deleted_at, kind, provider, provider_options, image_config, image_pricing FROM models WHERE @@ -420,11 +420,6 @@ func (q *Queries) ListModels(ctx context.Context, dollar_1 string) ([]Model, err &i.ModelID, &i.DisplayName, &i.Protocol, - &i.Kind, - &i.Provider, - &i.ProviderOptions, - &i.ImageConfig, - &i.ImagePricing, &i.BaseUrl, &i.ApiKey, &i.AdvancedConfig, @@ -433,6 +428,11 @@ func (q *Queries) ListModels(ctx context.Context, dollar_1 string) ([]Model, err &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.Kind, + &i.Provider, + &i.ProviderOptions, + &i.ImageConfig, + &i.ImagePricing, ); err != nil { return nil, err } @@ -641,7 +641,7 @@ WITH RECURSIVE user_groups(group_id) AS ( JOIN groups parent ON parent.id = g.parent_id WHERE parent.deleted_at IS NULL AND g.deleted_at IS NULL ) -SELECT m.id, m.ownership_type, m.owner_user_id, m.model_id, m.display_name, m.protocol, m.kind, m.provider, m.provider_options, m.image_config, m.image_pricing, m.base_url, m.api_key, m.advanced_config, m.credit_multiplier, m.enabled, m.created_at, m.updated_at, m.deleted_at +SELECT m.id, m.ownership_type, m.owner_user_id, m.model_id, m.display_name, m.protocol, m.base_url, m.api_key, m.advanced_config, m.credit_multiplier, m.enabled, m.created_at, m.updated_at, m.deleted_at, m.kind, m.provider, m.provider_options, m.image_config, m.image_pricing FROM models m JOIN users u ON u.id = $1 WHERE (m.model_id || '@' || m.id::text = $2::text @@ -680,11 +680,6 @@ func (q *Queries) ResolveModel(ctx context.Context, arg ResolveModelParams) (Mod &i.ModelID, &i.DisplayName, &i.Protocol, - &i.Kind, - &i.Provider, - &i.ProviderOptions, - &i.ImageConfig, - &i.ImagePricing, &i.BaseUrl, &i.ApiKey, &i.AdvancedConfig, @@ -693,6 +688,11 @@ func (q *Queries) ResolveModel(ctx context.Context, arg ResolveModelParams) (Mod &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.Kind, + &i.Provider, + &i.ProviderOptions, + &i.ImageConfig, + &i.ImagePricing, ) return i, err } @@ -708,7 +708,7 @@ WHERE AND ownership_type = 'system' AND deleted_at IS NULL RETURNING - id, ownership_type, owner_user_id, model_id, display_name, protocol, kind, provider, provider_options, image_config, image_pricing, base_url, api_key, advanced_config, credit_multiplier, enabled, created_at, updated_at, deleted_at + id, ownership_type, owner_user_id, model_id, display_name, protocol, base_url, api_key, advanced_config, credit_multiplier, enabled, created_at, updated_at, deleted_at, kind, provider, provider_options, image_config, image_pricing ` type SetEnabledParams struct { @@ -726,11 +726,6 @@ func (q *Queries) SetEnabled(ctx context.Context, arg SetEnabledParams) (Model, &i.ModelID, &i.DisplayName, &i.Protocol, - &i.Kind, - &i.Provider, - &i.ProviderOptions, - &i.ImageConfig, - &i.ImagePricing, &i.BaseUrl, &i.ApiKey, &i.AdvancedConfig, @@ -739,6 +734,11 @@ func (q *Queries) SetEnabled(ctx context.Context, arg SetEnabledParams) (Model, &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.Kind, + &i.Provider, + &i.ProviderOptions, + &i.ImageConfig, + &i.ImagePricing, ) return i, err } @@ -779,7 +779,7 @@ WHERE AND owner_user_id = $10 AND deleted_at IS NULL RETURNING - id, ownership_type, owner_user_id, model_id, display_name, protocol, kind, provider, provider_options, image_config, image_pricing, base_url, api_key, advanced_config, credit_multiplier, enabled, created_at, updated_at, deleted_at + id, ownership_type, owner_user_id, model_id, display_name, protocol, base_url, api_key, advanced_config, credit_multiplier, enabled, created_at, updated_at, deleted_at, kind, provider, provider_options, image_config, image_pricing ` type UpdateModelParams struct { @@ -826,11 +826,6 @@ func (q *Queries) UpdateModel(ctx context.Context, arg UpdateModelParams) (Model &i.ModelID, &i.DisplayName, &i.Protocol, - &i.Kind, - &i.Provider, - &i.ProviderOptions, - &i.ImageConfig, - &i.ImagePricing, &i.BaseUrl, &i.ApiKey, &i.AdvancedConfig, @@ -839,6 +834,11 @@ func (q *Queries) UpdateModel(ctx context.Context, arg UpdateModelParams) (Model &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, + &i.Kind, + &i.Provider, + &i.ProviderOptions, + &i.ImageConfig, + &i.ImagePricing, ) return i, err } diff --git a/monkeyai/backend/migrations/000009_feedbacks.down.sql b/monkeyai/backend/migrations/000009_feedbacks.down.sql new file mode 100644 index 000000000..abb25f875 --- /dev/null +++ b/monkeyai/backend/migrations/000009_feedbacks.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS feedback_attachments; +DROP TABLE IF EXISTS feedbacks; diff --git a/monkeyai/backend/migrations/000009_feedbacks.up.sql b/monkeyai/backend/migrations/000009_feedbacks.up.sql new file mode 100644 index 000000000..b33d348eb --- /dev/null +++ b/monkeyai/backend/migrations/000009_feedbacks.up.sql @@ -0,0 +1,38 @@ +CREATE TABLE feedbacks ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users (id), + category text NOT NULL CHECK (category IN ('bug', 'feature', 'experience', 'other')), + content text NOT NULL DEFAULT '' CHECK (char_length(content) <= 5000), + rating integer CHECK (rating IS NULL OR rating BETWEEN 1 AND 5), + platform text NOT NULL CHECK (platform IN ('desktop', 'mobile', 'web', 'other')), + client_version text NOT NULL DEFAULT '' CHECK (char_length(client_version) <= 64), + state text NOT NULL DEFAULT 'uploading' CHECK (state IN ('uploading', 'new', 'resolved', 'ignored', 'failed')), + request_id text NOT NULL DEFAULT '', + idempotency_key text NOT NULL DEFAULT '', + request_hash text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE feedback_attachments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + feedback_id uuid NOT NULL REFERENCES feedbacks (id) ON DELETE CASCADE, + object_key text NOT NULL, + mime_type text NOT NULL CHECK (mime_type IN ('image/png', 'image/jpeg', 'image/webp')), + byte_size bigint NOT NULL CHECK (byte_size > 0 AND byte_size <= 5242880), + width integer NOT NULL CHECK (width > 0), + height integer NOT NULL CHECK (height > 0), + sha256 text NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'), + state text NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'ready', 'failed')), + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX feedbacks_user_idempotency_key_uidx + ON feedbacks (user_id, idempotency_key) + WHERE idempotency_key <> ''; +CREATE INDEX feedbacks_user_created_at_idx + ON feedbacks (user_id, created_at DESC); +CREATE INDEX feedbacks_created_at_idx + ON feedbacks (created_at DESC); +CREATE INDEX feedback_attachments_state_created_at_idx + ON feedback_attachments (state, created_at); diff --git a/monkeyai/backend/schema/schema.sql b/monkeyai/backend/schema/schema.sql index 045332556..80d9dad3a 100644 --- a/monkeyai/backend/schema/schema.sql +++ b/monkeyai/backend/schema/schema.sql @@ -86,11 +86,6 @@ CREATE TABLE models ( model_id text NOT NULL, display_name text NOT NULL, protocol text NOT NULL, - kind text NOT NULL DEFAULT 'text', - provider text NOT NULL DEFAULT 'passthrough', - provider_options jsonb NOT NULL DEFAULT '{}'::jsonb, - image_config jsonb, - image_pricing jsonb, base_url text NOT NULL, api_key text NOT NULL, advanced_config jsonb NOT NULL, @@ -101,18 +96,7 @@ CREATE TABLE models ( deleted_at timestamptz, CONSTRAINT models_ownership_type_check CHECK (ownership_type IN ('system', 'user')), CONSTRAINT models_protocol_check CHECK ( - protocol IN ('openai_chat_completions', 'openai_responses', 'anthropic', 'image_generation') - ), - CONSTRAINT models_kind_check CHECK (kind IN ('text', 'image')), - CONSTRAINT models_provider_options_check CHECK (jsonb_typeof(provider_options) = 'object'), - CONSTRAINT models_image_config_check CHECK (image_config IS NULL OR jsonb_typeof(image_config) = 'object'), - CONSTRAINT models_image_pricing_check CHECK (image_pricing IS NULL OR jsonb_typeof(image_pricing) = 'object'), - CONSTRAINT models_kind_config_check CHECK ( - (kind = 'text' AND provider = 'passthrough' AND protocol <> 'image_generation' - AND image_config IS NULL AND image_pricing IS NULL) - OR (kind = 'image' AND provider <> 'passthrough' AND protocol = 'image_generation' - AND image_config IS NOT NULL AND image_pricing IS NOT NULL - AND advanced_config = '{}'::jsonb AND credit_multiplier = 1) + protocol IN ('openai_chat_completions', 'openai_responses', 'anthropic') ), CONSTRAINT models_advanced_config_check CHECK (jsonb_typeof(advanced_config) = 'object'), CONSTRAINT models_credit_multiplier_check CHECK (credit_multiplier > 0) @@ -451,7 +435,7 @@ CREATE TABLE billing_transactions ( user_id uuid NOT NULL REFERENCES users(id), account_id uuid NOT NULL REFERENCES credit_accounts(id), session_id uuid REFERENCES sessions(id), - category text NOT NULL CHECK(category IN ('model','tool','image')), + category text NOT NULL CHECK(category IN ('model','tool')), resource_id uuid NOT NULL, connector_id uuid REFERENCES connectors(id), item_name text NOT NULL, @@ -478,85 +462,6 @@ CREATE TABLE billing_transactions ( CHECK(amount IS NULL OR amount >= 0) ); -CREATE TABLE image_calls ( - id uuid PRIMARY KEY REFERENCES billing_transactions(id), - user_id uuid NOT NULL REFERENCES users(id), - model_id uuid NOT NULL REFERENCES models(id), - request_id text, - status text NOT NULL CHECK (status IN ('succeeded', 'failed', 'cancelled')), - generated_images bigint NOT NULL DEFAULT 0 CHECK (generated_images >= 0), - error_code text, - started_at timestamptz NOT NULL, - completed_at timestamptz -); - -CREATE TABLE image_inputs ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - user_id uuid NOT NULL REFERENCES users(id), - object_key text NOT NULL UNIQUE, - mime_type text NOT NULL, - width integer NOT NULL CHECK (width > 0), - height integer NOT NULL CHECK (height > 0), - byte_size bigint NOT NULL CHECK (byte_size > 0), - sha256 text NOT NULL, - created_at timestamptz NOT NULL DEFAULT now(), - expires_at timestamptz NOT NULL -); - -CREATE TABLE image_jobs ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - user_id uuid NOT NULL REFERENCES users(id), - model_id uuid NOT NULL REFERENCES models(id), - billing_transaction_id uuid UNIQUE REFERENCES billing_transactions(id), - provider text NOT NULL, - operation text NOT NULL CHECK (operation IN ('generate', 'edit')), - provider_job_id text, - provider_request_id text, - status text NOT NULL CHECK (status IN ('created', 'reserved', 'submitted', 'running', 'succeeded', 'failed', 'unknown')), - request_hash text NOT NULL, - idempotency_key text, - requested_images integer NOT NULL CHECK (requested_images > 0), - generated_images integer NOT NULL DEFAULT 0 CHECK (generated_images >= 0), - quality text NOT NULL, - aspect_ratio text NOT NULL, - request_config jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(request_config) = 'object'), - pricing_snapshot jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(pricing_snapshot) = 'object'), - usage jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(usage) = 'object'), - error_code text, - created_at timestamptz NOT NULL DEFAULT now(), - submitted_at timestamptz, - completed_at timestamptz -); - -CREATE TABLE image_job_inputs ( - job_id uuid NOT NULL REFERENCES image_jobs(id), - input_id uuid NOT NULL REFERENCES image_inputs(id), - PRIMARY KEY (job_id, input_id) -); - -CREATE TABLE image_outputs ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - job_id uuid NOT NULL REFERENCES image_jobs(id), - ordinal integer NOT NULL CHECK (ordinal >= 0), - object_key text NOT NULL UNIQUE, - UNIQUE (job_id, ordinal), - mime_type text NOT NULL, - width integer NOT NULL CHECK (width > 0), - height integer NOT NULL CHECK (height > 0), - byte_size bigint NOT NULL CHECK (byte_size > 0), - sha256 text NOT NULL, - seed bigint, - created_at timestamptz NOT NULL DEFAULT now(), - expires_at timestamptz NOT NULL, - purged_at timestamptz -); -CREATE INDEX image_calls_user_started_idx ON image_calls(user_id, started_at DESC); -CREATE INDEX image_inputs_user_expires_idx ON image_inputs(user_id, expires_at); -CREATE INDEX image_inputs_expiration_idx ON image_inputs(expires_at); -CREATE UNIQUE INDEX image_jobs_idempotency_idx ON image_jobs(user_id, idempotency_key) WHERE idempotency_key IS NOT NULL; -CREATE INDEX image_jobs_pending_idx ON image_jobs(status, created_at) WHERE status IN ('created', 'reserved', 'submitted', 'running', 'unknown'); -CREATE INDEX image_outputs_expiration_idx ON image_outputs(expires_at) WHERE purged_at IS NULL; - CREATE TABLE credit_ledger_entries ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), account_id uuid NOT NULL REFERENCES credit_accounts (id), @@ -590,7 +495,7 @@ CREATE TABLE credit_ledger_entries ( entry_type IN ('charge', 'grant', 'refund', 'reset', 'adjustment') ), CONSTRAINT credit_ledger_entries_category_check CHECK ( - category IN ('model', 'tool', 'image', 'other') + category IN ('model', 'tool', 'other') ), CONSTRAINT credit_ledger_entries_source_type_check CHECK ( source_type IS NULL @@ -801,3 +706,137 @@ ALTER TABLE users DROP COLUMN billing_group_id; ALTER TABLE credit_accounts DROP CONSTRAINT credit_accounts_amount_check, ADD CONSTRAINT credit_accounts_amount_check CHECK (frozen >= 0); + +ALTER TABLE models + ADD COLUMN kind text NOT NULL DEFAULT 'text', + ADD COLUMN provider text NOT NULL DEFAULT 'passthrough', + ADD COLUMN provider_options jsonb NOT NULL DEFAULT '{}'::jsonb, + ADD COLUMN image_config jsonb, + ADD COLUMN image_pricing jsonb; + +ALTER TABLE models + DROP CONSTRAINT models_protocol_check, + ADD CONSTRAINT models_protocol_check CHECK ( + protocol IN ('openai_chat_completions', 'openai_responses', 'anthropic', 'image_generation') + ), + ADD CONSTRAINT models_kind_check CHECK (kind IN ('text', 'image')), + ADD CONSTRAINT models_provider_options_check CHECK (jsonb_typeof(provider_options) = 'object'), + ADD CONSTRAINT models_image_config_check CHECK (image_config IS NULL OR jsonb_typeof(image_config) = 'object'), + ADD CONSTRAINT models_image_pricing_check CHECK (image_pricing IS NULL OR jsonb_typeof(image_pricing) = 'object'), + ADD CONSTRAINT models_kind_config_check CHECK ( + (kind = 'text' AND provider = 'passthrough' AND protocol <> 'image_generation' + AND image_config IS NULL AND image_pricing IS NULL) + OR (kind = 'image' AND provider <> 'passthrough' AND protocol = 'image_generation' + AND image_config IS NOT NULL AND image_pricing IS NOT NULL + AND advanced_config = '{}'::jsonb AND credit_multiplier = 1) + ); + +ALTER TABLE billing_transactions DROP CONSTRAINT billing_transactions_category_check; + +ALTER TABLE billing_transactions ADD CONSTRAINT billing_transactions_category_check CHECK (category IN ('model', 'tool', 'image')); + +ALTER TABLE credit_ledger_entries DROP CONSTRAINT credit_ledger_entries_category_check; + +ALTER TABLE credit_ledger_entries ADD CONSTRAINT credit_ledger_entries_category_check CHECK (category IN ('model', 'tool', 'image', 'other')); + +CREATE TABLE image_calls ( + id uuid PRIMARY KEY REFERENCES billing_transactions(id), + user_id uuid NOT NULL REFERENCES users(id), + model_id uuid NOT NULL REFERENCES models(id), + request_id text, + status text NOT NULL CHECK (status IN ('succeeded', 'failed', 'cancelled')), + generated_images bigint NOT NULL DEFAULT 0 CHECK (generated_images >= 0), + error_code text, + started_at timestamptz NOT NULL, + completed_at timestamptz +); + +CREATE TABLE image_inputs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id), + object_key text NOT NULL UNIQUE, + mime_type text NOT NULL, + width integer NOT NULL CHECK (width > 0), + height integer NOT NULL CHECK (height > 0), + byte_size bigint NOT NULL CHECK (byte_size > 0), + sha256 text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL +); + +CREATE TABLE image_jobs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id), + model_id uuid NOT NULL REFERENCES models(id), + billing_transaction_id uuid UNIQUE REFERENCES billing_transactions(id), + provider text NOT NULL, + operation text NOT NULL CHECK (operation IN ('generate', 'edit')), + provider_job_id text, + provider_request_id text, + status text NOT NULL CHECK (status IN ('created', 'reserved', 'submitted', 'running', 'succeeded', 'failed', 'unknown')), + request_hash text NOT NULL, + idempotency_key text, + requested_images integer NOT NULL CHECK (requested_images > 0), + generated_images integer NOT NULL DEFAULT 0 CHECK (generated_images >= 0), + quality text NOT NULL, + aspect_ratio text NOT NULL, + request_config jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(request_config) = 'object'), + pricing_snapshot jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(pricing_snapshot) = 'object'), + usage jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(usage) = 'object'), + error_code text, + created_at timestamptz NOT NULL DEFAULT now(), + submitted_at timestamptz, + completed_at timestamptz +); + +CREATE TABLE image_job_inputs ( + job_id uuid NOT NULL REFERENCES image_jobs(id), + input_id uuid NOT NULL REFERENCES image_inputs(id), + PRIMARY KEY (job_id, input_id) +); + +CREATE TABLE image_outputs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + job_id uuid NOT NULL REFERENCES image_jobs(id), + ordinal integer NOT NULL CHECK (ordinal >= 0), + object_key text NOT NULL UNIQUE, + UNIQUE (job_id, ordinal), + mime_type text NOT NULL, + width integer NOT NULL CHECK (width > 0), + height integer NOT NULL CHECK (height > 0), + byte_size bigint NOT NULL CHECK (byte_size > 0), + sha256 text NOT NULL, + seed bigint, + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, + purged_at timestamptz +); + +CREATE TABLE feedbacks ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users (id), + category text NOT NULL CHECK (category IN ('bug', 'feature', 'experience', 'other')), + content text NOT NULL DEFAULT '' CHECK (char_length(content) <= 5000), + rating integer CHECK (rating IS NULL OR rating BETWEEN 1 AND 5), + platform text NOT NULL CHECK (platform IN ('desktop', 'mobile', 'web', 'other')), + client_version text NOT NULL DEFAULT '' CHECK (char_length(client_version) <= 64), + state text NOT NULL DEFAULT 'uploading' CHECK (state IN ('uploading', 'new', 'resolved', 'ignored', 'failed')), + request_id text NOT NULL DEFAULT '', + idempotency_key text NOT NULL DEFAULT '', + request_hash text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE feedback_attachments ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + feedback_id uuid NOT NULL REFERENCES feedbacks (id) ON DELETE CASCADE, + object_key text NOT NULL, + mime_type text NOT NULL CHECK (mime_type IN ('image/png', 'image/jpeg', 'image/webp')), + byte_size bigint NOT NULL CHECK (byte_size > 0 AND byte_size <= 5242880), + width integer NOT NULL CHECK (width > 0), + height integer NOT NULL CHECK (height > 0), + sha256 text NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'), + state text NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'ready', 'failed')), + created_at timestamptz NOT NULL DEFAULT now() +); diff --git a/monkeyai/backend/sqlc.yaml b/monkeyai/backend/sqlc.yaml index 6fb709cc8..a93490c65 100644 --- a/monkeyai/backend/sqlc.yaml +++ b/monkeyai/backend/sqlc.yaml @@ -204,6 +204,18 @@ sql: emit_empty_slices: true omit_unused_structs: true query_parameter_limit: 1 +- engine: postgresql + schema: schema/schema.sql + queries: internal/feedback/query.sql + gen: + go: + package: sqlc + out: internal/feedback/sqlc + sql_package: pgx/v5 + emit_pointers_for_null_types: true + emit_empty_slices: true + omit_unused_structs: true + query_parameter_limit: 1 overrides: go: overrides: