From 35ba5c2753d1f40c01f1d14153c84869037b25a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Dec 2025 01:47:04 +0000 Subject: [PATCH 1/4] Add image upload support for events and repair descriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit implements comprehensive image upload functionality that allows: - Clients to upload images when creating or updating events (problem description) - Members to upload images when committing or altering repairs (repair documentation) Key changes: - Database: Add images TEXT field to event and event_log tables (migration 000008) - Models: Add StringSlice type with SQL Scanner/Valuer for JSON serialization - API: Implement /upload endpoint with multipart file handling and validation - Supports JPEG, PNG, WebP formats - Max file size: 10MB - Content-type and magic byte validation - Uses existing Aliyun OSS storage - DTOs: Add Images []string field to CreateEventRequest, UpdateRequest, CommitRequest, AlterCommitRequest - Service: Add ActOptions struct and ActWithOptions method for passing images with event actions - Repository: Update CRUD operations to handle images JSON serialization - Router: Update Create, Update, Commit, AlterCommit handlers to accept and process images Images are stored separately: - Client images → event.images (problem documentation) - Member images → event_log.images (repair documentation per action) This separation maintains clear ownership and timeline tracking. --- middleware/auth.go | 1 - middleware/event_action.go | 2 +- middleware/huma_auth.go | 6 +- middleware/huma_auth_test.go | 22 ++--- middleware/huma_logger.go | 12 +-- middleware/logger.go | 2 +- .../000008_add_images_to_event.down.sql | 5 + migrations/000008_add_images_to_event.up.sql | 5 + model/db_types.go | 48 ++++++++++ model/dto/event.go | 36 +++---- model/event.go | 16 ++-- repo/event.go | 14 +-- router/common.go | 95 ++++++++++++++++++- router/event.go | 16 +++- router/main.go | 22 ++--- service/event.go | 18 +++- util/event-action.go | 4 + 17 files changed, 252 insertions(+), 72 deletions(-) create mode 100644 migrations/000008_add_images_to_event.down.sql create mode 100644 migrations/000008_add_images_to_event.up.sql create mode 100644 model/db_types.go diff --git a/middleware/auth.go b/middleware/auth.go index a0ee0f2..d21434c 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -108,4 +108,3 @@ func Auth(acceptableRoles ...Role) func(c *gin.Context) { Build()) } } - diff --git a/middleware/event_action.go b/middleware/event_action.go index 223a4c3..8638ada 100644 --- a/middleware/event_action.go +++ b/middleware/event_action.go @@ -31,7 +31,7 @@ func GetClientFromContext(c *gin.Context) (clientId int64, err error) { } /* -Get event and put to context. +Get event and put to context. This middleware should be added to any route that performs event action. */ func EventActionPreProcess(c *gin.Context) { diff --git a/middleware/huma_auth.go b/middleware/huma_auth.go index 5e7bdd0..2523cbf 100644 --- a/middleware/huma_auth.go +++ b/middleware/huma_auth.go @@ -109,7 +109,7 @@ func authenticateToken(authHeader string) (*AuthContext, error) { if err != nil || !tokenParsed.Valid { return nil, err } - + return &AuthContext{ ID: claims.Who, Member: claims.Member, @@ -153,7 +153,7 @@ func authenticateToken(authHeader string) (*AuthContext, error) { Role: userRoles, UserInfo: userinfo, } - + return &AuthContext{ User: user, ID: member.MemberId, @@ -196,4 +196,4 @@ func AuthenticateUserWithContext(ctx context.Context, authHeader string, accepta // Fallback to original implementation for backward compatibility return AuthenticateUser(authHeader, acceptableRoles...) -} \ No newline at end of file +} diff --git a/middleware/huma_auth_test.go b/middleware/huma_auth_test.go index c420b2f..4b6b2e3 100644 --- a/middleware/huma_auth_test.go +++ b/middleware/huma_auth_test.go @@ -183,7 +183,7 @@ func TestHumaAuthMiddleware(t *testing.T) { ShouldFail: false, }, { - CaseID: "1.2", + CaseID: "1.2", Description: "Valid legacy JWT token with admin role", Authorization: util.GenToken("admin", "2333333333"), ExpectedAuth: true, @@ -253,21 +253,21 @@ func TestHumaAuthMiddleware(t *testing.T) { if capturedCtx != nil { authCtx = middleware.GetAuthContextFromHuma(capturedCtx) } - + if tc.ExpectedAuth { if authCtx == nil { t.Errorf("Expected auth context to be set for case %s", tc.CaseID) return } - + if authCtx.Role != tc.ExpectedRole { t.Errorf("Expected role %s, got %s", tc.ExpectedRole, authCtx.Role) } - + if authCtx.ID == "" { t.Error("Expected user ID to be set") } - + if !authCtx.IsLegacyJWT { t.Error("Expected legacy JWT flag to be true") } @@ -302,7 +302,7 @@ func TestRequireAuthMiddleware(t *testing.T) { ExpectedContinue: true, }, { - CaseID: "2.2", + CaseID: "2.2", Description: "Member access with member or admin role required", AuthContext: &middleware.AuthContext{ ID: "member123", @@ -326,7 +326,7 @@ func TestRequireAuthMiddleware(t *testing.T) { Description: "Client access with admin role required", AuthContext: &middleware.AuthContext{ ID: "client123", - Role: "client", + Role: "client", IsLegacyJWT: true, }, RequiredRoles: []middleware.Role{"admin"}, @@ -351,7 +351,7 @@ func TestRequireAuthMiddleware(t *testing.T) { t.Run(tc.CaseID+"_"+tc.Description, func(t *testing.T) { // Create mock context mockCtx := NewMockHumaContext() - + // Set auth context if provided if tc.AuthContext != nil { mockCtx.ctx = context.WithValue(mockCtx.ctx, middleware.GetAuthContextKey(), tc.AuthContext) @@ -408,7 +408,7 @@ func TestGetAuthContextFromHuma(t *testing.T) { for _, tc := range testCases { t.Run(tc.CaseID+"_"+tc.Description, func(t *testing.T) { mockCtx := NewMockHumaContext() - + if tc.SetContext && tc.AuthContext != nil { mockCtx.ctx = context.WithValue(mockCtx.ctx, middleware.GetAuthContextKey(), tc.AuthContext) } @@ -484,7 +484,7 @@ func TestAuthenticateUserWithContext(t *testing.T) { for _, tc := range testCases { t.Run(tc.CaseID+"_"+tc.Description, func(t *testing.T) { ctx := context.Background() - + if tc.ContextAuth != nil { ctx = context.WithValue(ctx, middleware.GetAuthContextKey(), tc.ContextAuth) } @@ -510,4 +510,4 @@ func TestAuthenticateUserWithContext(t *testing.T) { } }) } -} \ No newline at end of file +} diff --git a/middleware/huma_logger.go b/middleware/huma_logger.go index 7481a4f..268e180 100644 --- a/middleware/huma_logger.go +++ b/middleware/huma_logger.go @@ -12,25 +12,25 @@ import ( func HumaLogger() func(ctx huma.Context, next func(huma.Context)) { return func(ctx huma.Context, next func(huma.Context)) { startTime := time.Now() - + // Process request next(ctx) - + endTime := time.Now() latencyTime := endTime.Sub(startTime).Microseconds() - + // Extract user ID from auth context if available var userID string if auth := GetAuthContext(ctx.Context()); auth != nil { userID = auth.ID } - + // Get response status from context status := ctx.Status() if status == 0 { status = 200 // Default to 200 if not set } - + util.Logger.WithFields(logrus.Fields{ "status_code": status, "latency": latencyTime, @@ -43,4 +43,4 @@ func HumaLogger() func(ctx huma.Context, next func(huma.Context)) { "userId": userID, }).Info("HTTP Request") } -} \ No newline at end of file +} diff --git a/middleware/logger.go b/middleware/logger.go index 247da9a..efc7385 100644 --- a/middleware/logger.go +++ b/middleware/logger.go @@ -15,7 +15,7 @@ func Logger(c *gin.Context) { endTime := time.Now() - latencyTime := endTime.Sub(startTime).Microseconds() + latencyTime := endTime.Sub(startTime).Microseconds() util.Logger.WithFields(logrus.Fields{ "status_code": c.Writer.Status(), diff --git a/migrations/000008_add_images_to_event.down.sql b/migrations/000008_add_images_to_event.down.sql new file mode 100644 index 0000000..49dc809 --- /dev/null +++ b/migrations/000008_add_images_to_event.down.sql @@ -0,0 +1,5 @@ +-- Remove images field from event_log table +ALTER TABLE event_log DROP COLUMN images; + +-- Remove images field from event table +ALTER TABLE event DROP COLUMN images; diff --git a/migrations/000008_add_images_to_event.up.sql b/migrations/000008_add_images_to_event.up.sql new file mode 100644 index 0000000..df735fb --- /dev/null +++ b/migrations/000008_add_images_to_event.up.sql @@ -0,0 +1,5 @@ +-- Add images field to event table (client's problem images) +ALTER TABLE event ADD COLUMN images TEXT COMMENT '事件图片(JSON数组)'; + +-- Add images field to event_log table (member's repair images) +ALTER TABLE event_log ADD COLUMN images TEXT COMMENT '维修记录图片(JSON数组)'; diff --git a/model/db_types.go b/model/db_types.go new file mode 100644 index 0000000..1e051d2 --- /dev/null +++ b/model/db_types.go @@ -0,0 +1,48 @@ +package model + +import ( + "database/sql/driver" + "encoding/json" + "fmt" +) + +// StringSlice is a custom type for []string that can be scanned from JSON in database +type StringSlice []string + +// Scan implements sql.Scanner interface +func (s *StringSlice) Scan(value interface{}) error { + if value == nil { + *s = []string{} + return nil + } + + var bytes []byte + switch v := value.(type) { + case []byte: + bytes = v + case string: + bytes = []byte(v) + default: + return fmt.Errorf("unsupported type for StringSlice: %T", value) + } + + // Handle empty or null JSON + if len(bytes) == 0 || string(bytes) == "null" { + *s = []string{} + return nil + } + + return json.Unmarshal(bytes, s) +} + +// Value implements driver.Valuer interface +func (s StringSlice) Value() (driver.Value, error) { + if s == nil { + return "[]", nil + } + bytes, err := json.Marshal(s) + if err != nil { + return nil, err + } + return string(bytes), nil +} diff --git a/model/dto/event.go b/model/dto/event.go index f475951..ba1f4ce 100644 --- a/model/dto/event.go +++ b/model/dto/event.go @@ -5,28 +5,32 @@ type EventID struct { } type CommitRequest struct { - Content string `json:"content"` - Size string `json:"size"` + Content string `json:"content"` + Size string `json:"size"` + Images []string `json:"images,omitempty"` } type AlterCommitRequest struct { - Content string `json:"content"` - Size string `json:"size"` + Content string `json:"content"` + Size string `json:"size"` + Images []string `json:"images,omitempty"` } type UpdateRequest struct { - Phone string `json:"phone" binding:"omitempty,len=11,numeric"` - QQ string `json:"qq" binding:"omitempty,min=5,max=20,numeric"` - Problem string `json:"problem" db:"problem" binding:"omitempty,max=1000"` - Model string `json:"model" binding:"omitempty,max=40"` - ContactPreference string `json:"contactPreference" db:"contact_preference" ` - Size string `json:"size" db:"size" ` + Phone string `json:"phone" binding:"omitempty,len=11,numeric"` + QQ string `json:"qq" binding:"omitempty,min=5,max=20,numeric"` + Problem string `json:"problem" db:"problem" binding:"omitempty,max=1000"` + Images []string `json:"images,omitempty"` + Model string `json:"model" binding:"omitempty,max=40"` + ContactPreference string `json:"contactPreference" db:"contact_preference" ` + Size string `json:"size" db:"size" ` } type CreateEventRequest struct { - ClientId int64 `json:"clientId" db:"client_id"` - Model string `json:"model" binding:"omitempty,max=40"` - Phone string `json:"phone" binding:"required,omitempty,len=11,numeric"` - QQ string `json:"qq" binding:"omitempty,min=5,max=20,numeric"` - ContactPreference string `json:"contactPreference" db:"contact_preference" ` - Problem string `json:"problem" db:"problem" binding:"required,omitempty,max=1000"` + ClientId int64 `json:"clientId" db:"client_id"` + Model string `json:"model" binding:"omitempty,max=40"` + Phone string `json:"phone" binding:"required,omitempty,len=11,numeric"` + QQ string `json:"qq" binding:"omitempty,min=5,max=20,numeric"` + ContactPreference string `json:"contactPreference" db:"contact_preference" ` + Problem string `json:"problem" db:"problem" binding:"required,omitempty,max=1000"` + Images []string `json:"images,omitempty"` } diff --git a/model/event.go b/model/event.go index 7035046..0827693 100644 --- a/model/event.go +++ b/model/event.go @@ -18,6 +18,7 @@ type Event struct { QQ string `json:"qq"` ContactPreference string `json:"contactPreference" db:"contact_preference" ` Problem string `json:"problem" db:"problem"` + Images StringSlice `json:"images,omitempty" db:"images"` MemberId string `json:"memberId" db:"member_id"` Member *PublicMember `json:"member" db:"-"` ClosedBy string `json:"closedById" db:"closed_by"` @@ -49,12 +50,13 @@ type EventEventStatusRelation struct { } type EventLog struct { - EventLogId int64 `json:"logId" db:"event_log_id"` - EventId int64 `json:"-" db:"-"` - Description string `json:"description"` - MemberId string `json:"memberId" db:"member_id"` - Action string `json:"action"` - GmtCreate string `json:"gmtCreate" db:"gmt_create"` + EventLogId int64 `json:"logId" db:"event_log_id"` + EventId int64 `json:"-" db:"-"` + Description string `json:"description"` + Images StringSlice `json:"images,omitempty" db:"images"` + MemberId string `json:"memberId" db:"member_id"` + Action string `json:"action"` + GmtCreate string `json:"gmtCreate" db:"gmt_create"` } type EventActionRelation struct { @@ -72,6 +74,7 @@ type PublicEvent struct { ClientId int64 `json:"clientId" db:"client_id"` Model string `json:"model"` Problem string `json:"problem" db:"event_description"` + Images StringSlice `json:"images,omitempty" db:"images"` MemberId string `json:"-" db:"member_id"` Member *PublicMember `json:"member"` ClosedBy string `json:"-" db:"closed_by"` @@ -91,6 +94,7 @@ func CreatePublicEvent(e Event) PublicEvent { ClientId: e.ClientId, Model: e.Model, Problem: e.Problem, + Images: e.Images, MemberId: e.MemberId, Member: e.Member, ClosedBy: e.ClosedBy, diff --git a/repo/event.go b/repo/event.go index 530bfba..6e18c94 100644 --- a/repo/event.go +++ b/repo/event.go @@ -11,9 +11,9 @@ import ( ) var eventFields = []string{"event_id", "client_id", "model", "phone", "qq", "contact_preference", - "problem", "member_id", "closed_by", "status", "size", "gmt_create", "gmt_modified", "status", "github_issue_id", "github_issue_number"} + "problem", "images", "member_id", "closed_by", "status", "size", "gmt_create", "gmt_modified", "status", "github_issue_id", "github_issue_number"} -var EventLogFields = []string{"event_log_id", "description", "gmt_create", "member_id", "action"} +var EventLogFields = []string{"event_log_id", "description", "images", "gmt_create", "member_id", "action"} func getEventStatement() squirrel.SelectBuilder { prefixedMember := util.Prefixer("member", memberFields) @@ -204,6 +204,7 @@ func UpdateEvent(event *model.Event, eventLog *model.EventLog) error { Set("qq", event.QQ). Set("contact_preference", event.ContactPreference). Set("problem", event.Problem). + Set("images", event.Images). Set("size", event.Size). Set("member_id", event.MemberId). Set("closed_by", event.ClosedBy) @@ -238,12 +239,13 @@ func UpdateEvent(event *model.Event, eventLog *model.EventLog) error { func CreateEvent(event *model.Event) error { event.GmtCreate = util.GetDate() event.GmtModified = util.GetDate() + createEventSql, args, _ := sq.Insert("event").Columns( "client_id", "model", "phone", "qq", - "contact_preference", "problem", "member_id", "closed_by", + "contact_preference", "problem", "images", "member_id", "closed_by", "gmt_create", "gmt_modified").Values( event.ClientId, event.Model, event.Phone, event.QQ, - event.ContactPreference, event.Problem, event.MemberId, event.ClosedBy, + event.ContactPreference, event.Problem, event.Images, event.MemberId, event.ClosedBy, event.GmtCreate, event.GmtModified).ToSql() conn, err := db.Beginx() if err != nil { @@ -265,8 +267,8 @@ func CreateEvent(event *model.Event) error { } func CreateEventLog(eventLog *model.EventLog, conn *sqlx.Tx) error { - sql, args, _ := sq.Insert("event_log").Columns("event_id", "description", "member_id", "gmt_create"). - Values(eventLog.EventId, eventLog.Description, eventLog.MemberId, util.GetDate()).ToSql() + sql, args, _ := sq.Insert("event_log").Columns("event_id", "description", "images", "member_id", "gmt_create"). + Values(eventLog.EventId, eventLog.Description, eventLog.Images, eventLog.MemberId, util.GetDate()).ToSql() var eventLogId int64 err := db.QueryRow(sql+"RETURNING event_log_id", args...).Scan(&eventLogId) if err != nil { diff --git a/router/common.go b/router/common.go index dc8d484..0702b3c 100644 --- a/router/common.go +++ b/router/common.go @@ -2,6 +2,11 @@ package router import ( "context" + "fmt" + "io" + "mime/multipart" + "net/http" + "strings" "github.com/danielgtaylor/huma/v2" "github.com/nbtca/saturday/model/dto" @@ -10,11 +15,93 @@ import ( type CommonRouter struct{} +const ( + MaxUploadSize = 10 << 20 // 10MB + MaxMemory = 32 << 20 // 32MB for multipart parsing + AllowedImageTypes = "image/jpeg,image/png,image/webp,image/jpg" +) + func (CommonRouter) Upload(ctx context.Context, input *UploadFileInput) (*util.CommonResponse[dto.FileUploadResponse], error) { - // TODO: Implement multipart file upload with Huma - // For now, this needs special handling since Huma's multipart support may require custom implementation - // This endpoint may need to remain as Gin for now until Huma multipart is properly implemented - return nil, huma.Error501NotImplemented("Upload endpoint migration pending - requires multipart support") + // Get the underlying HTTP request from context + req, ok := huma.ContextRequest(ctx) + if !ok { + return nil, huma.Error500InternalServerError("Failed to get HTTP request from context") + } + + // Parse multipart form with size limit + if err := req.ParseMultipartForm(MaxMemory); err != nil { + return nil, huma.Error400BadRequest("Failed to parse multipart form: " + err.Error()) + } + defer req.MultipartForm.RemoveAll() + + // Get the file from form + file, header, err := req.FormFile("file") + if err != nil { + return nil, huma.Error400BadRequest("No file provided or invalid field name. Use 'file' as the field name") + } + defer file.Close() + + // Validate file size + if header.Size > MaxUploadSize { + return nil, huma.Error400BadRequest(fmt.Sprintf("File size exceeds maximum allowed size of %d bytes", MaxUploadSize)) + } + + // Validate content type + contentType := header.Header.Get("Content-Type") + if !isAllowedImageType(contentType) { + return nil, huma.Error400BadRequest(fmt.Sprintf("Invalid file type. Allowed types: %s", AllowedImageTypes)) + } + + // Additional validation: check file signature (magic bytes) + if err := validateImageSignature(file); err != nil { + return nil, huma.Error400BadRequest("Invalid image file: " + err.Error()) + } + + // Reset file pointer after signature check + if _, err := file.Seek(0, 0); err != nil { + return nil, huma.Error500InternalServerError("Failed to reset file pointer") + } + + // Upload to Aliyun OSS + url, err := util.Upload(header.Filename, file) + if err != nil { + return nil, huma.Error500InternalServerError("Failed to upload file: " + err.Error()) + } + + return &util.CommonResponse[dto.FileUploadResponse]{ + Data: dto.FileUploadResponse{ + Url: url, + }, + }, nil +} + +func isAllowedImageType(contentType string) bool { + allowedTypes := strings.Split(AllowedImageTypes, ",") + for _, allowedType := range allowedTypes { + if strings.TrimSpace(allowedType) == contentType { + return true + } + } + return false +} + +func validateImageSignature(file multipart.File) error { + // Read first 512 bytes to detect content type + buffer := make([]byte, 512) + n, err := file.Read(buffer) + if err != nil && err != io.EOF { + return fmt.Errorf("failed to read file header: %w", err) + } + + // Detect content type from file content + contentType := http.DetectContentType(buffer[:n]) + + // Validate detected type matches allowed types + if !isAllowedImageType(contentType) { + return fmt.Errorf("file content does not match allowed image types (detected: %s)", contentType) + } + + return nil } var CommonRouterApp = CommonRouter{} diff --git a/router/event.go b/router/event.go index 3bc7bad..a75c6bf 100644 --- a/router/event.go +++ b/router/event.go @@ -193,7 +193,11 @@ func (EventRouter) Commit(ctx context.Context, input *CommitEventInput) (*util.C if input.Body.Size != "" { event.Size = input.Body.Size } - if err := service.EventServiceApp.Act(&event, identity, util.Commit, input.Body.Content); err != nil { + opts := service.ActOptions{ + Description: input.Body.Content, + Images: input.Body.Images, + } + if err := service.EventServiceApp.ActWithOptions(&event, identity, util.Commit, opts); err != nil { return nil, huma.Error422UnprocessableEntity(err.Error()) } return util.MakeCommonResponse(event), nil @@ -214,7 +218,11 @@ func (EventRouter) AlterCommit(ctx context.Context, input *AlterCommitEventInput if input.Body.Size != "" { event.Size = input.Body.Size } - if err := service.EventServiceApp.Act(&event, identity, util.AlterCommit, input.Body.Content); err != nil { + opts := service.ActOptions{ + Description: input.Body.Content, + Images: input.Body.Images, + } + if err := service.EventServiceApp.ActWithOptions(&event, identity, util.AlterCommit, opts); err != nil { return nil, huma.Error422UnprocessableEntity(err.Error()) } return util.MakeCommonResponse(event), nil @@ -301,6 +309,7 @@ func (EventRouter) Create(ctx context.Context, input *CreateClientEventInput) (* QQ: input.Body.QQ, ContactPreference: input.Body.ContactPreference, Problem: input.Body.Problem, + Images: model.StringSlice(input.Body.Images), } err = service.EventServiceApp.CreateEvent(event) if err != nil { @@ -346,6 +355,9 @@ func (er EventRouter) Update(ctx context.Context, input *UpdateClientEventInput) if input.Body.Size != "" { event.Size = input.Body.Size } + if input.Body.Images != nil { + event.Images = model.StringSlice(input.Body.Images) + } if err := service.EventServiceApp.Act(&event, identity, util.Update); err != nil { return nil, huma.Error422UnprocessableEntity(err.Error()) } diff --git a/router/main.go b/router/main.go index 9d092a1..d2dd2e6 100644 --- a/router/main.go +++ b/router/main.go @@ -365,18 +365,16 @@ func SetupRouter() *chi.Mux { Tags: []string{"Event", "Admin"}, }, EventRouterApp.Close) - // TODO: Upload endpoint - needs special multipart handling - // For now, keep as commented until Huma multipart is implemented - /* - huma.Register(api, huma.Operation{ - OperationID: "upload-file", - Method: http.MethodPost, - Path: "/upload", - Summary: "Upload file", - Tags: []string{"Common", "Private"}, - Middlewares: huma.Middlewares{middleware.HumaAuth("member", "admin", "client")}, - }, CommonRouterApp.Upload) - */ + // Upload endpoint - multipart file upload for images + huma.Register(api, huma.Operation{ + OperationID: "upload-file", + Method: http.MethodPost, + Path: "/upload", + Summary: "Upload image file", + Description: "Upload an image file (JPEG, PNG, WebP). Max size: 10MB. Use 'file' as the field name in multipart form.", + Tags: []string{"Common", "Private"}, + Middlewares: huma.Middlewares{middleware.HumaAuth("member", "admin", "client")}, + }, CommonRouterApp.Upload) return router } diff --git a/service/event.go b/service/event.go index 17b8302..c425c6c 100644 --- a/service/event.go +++ b/service/event.go @@ -23,6 +23,11 @@ import ( type EventService struct{} +type ActOptions struct { + Description string + Images []string +} + func (service EventService) GetEventById(id int64) (model.Event, error) { event, err := repo.GetEventById(id) if err != nil { @@ -831,14 +836,21 @@ this function validates the action and then perform action to the event. it also persists the event and event log. */ func (service EventService) Act(event *model.Event, identity model.Identity, action util.Action, description ...string) error { + opts := ActOptions{} + for _, d := range description { + opts.Description = fmt.Sprint(opts.Description, d) + } + return service.ActWithOptions(event, identity, action, opts) +} + +func (service EventService) ActWithOptions(event *model.Event, identity model.Identity, action util.Action, opts ActOptions) error { handler := util.MakeEventActionHandler(action, event, identity) if err := handler.ValidateAction(); err != nil { util.Logger.Error("validate action failed: ", err) return err } - for _, d := range description { - handler.Description = fmt.Sprint(handler.Description, d) - } + handler.Description = opts.Description + handler.Images = opts.Images log := handler.Handle() diff --git a/util/event-action.go b/util/event-action.go index 7824344..39b3cda 100644 --- a/util/event-action.go +++ b/util/event-action.go @@ -39,6 +39,7 @@ type eventActionHandler struct { prevStatus string nextStatus string Description string + Images []string customLog customLogFunc } @@ -52,6 +53,7 @@ var idAndDescriptionLog customLogFunc = func(eh *eventActionHandler) model.Event return eh.createEventLog(createEventLogArgs{ Id: eh.actor.Id, Description: eh.Description, + Images: eh.Images, }) } @@ -183,6 +185,7 @@ func (eh *eventActionHandler) ValidateAction() error { type createEventLogArgs struct { Id string Description string + Images []string } func (eh *eventActionHandler) createEventLog(args createEventLogArgs) model.EventLog { @@ -191,6 +194,7 @@ func (eh *eventActionHandler) createEventLog(args createEventLogArgs) model.Even Action: string(eh.action), MemberId: args.Id, Description: args.Description, + Images: args.Images, GmtCreate: GetDate(), } } From 29fc3248921ccfdf642335191a4daa12ac597494 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Dec 2025 01:54:24 +0000 Subject: [PATCH 2/4] Fix Docker build issues and optimize build process - Improve Dockerfile with better layer caching and explicit GOPROXY - Separate dependency download from source build for faster rebuilds - Add .dockerignore to exclude unnecessary files from build context - Add Dockerfile.vendor as fallback for network-restricted environments Changes to Dockerfile: - Copy go.mod and go.sum first for better dependency caching - Set GOPROXY to proxy.golang.org explicitly - Run go mod download before copying source code - This allows Docker to cache dependencies layer separately Alternative Dockerfile.vendor can be used if network issues persist: go mod vendor docker build -f Dockerfile.vendor -t saturday . --- .dockerignore | 46 ++++++++++++++++++++++++++++++++++++++++++++++ Dockerfile | 17 ++++++++++++++--- Dockerfile.vendor | 29 +++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile.vendor diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..64b3817 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,46 @@ +# Git +.git +.gitignore + +# IDE +.vscode +.idea +*.swp +*.swo + +# Build artifacts +saturday +*.exe +*.dll +*.so +*.dylib + +# Test and coverage +*.test +*.out +coverage.txt + +# Docker +Dockerfile +.dockerignore +docker-compose*.yml + +# Documentation +*.md +!README.md + +# CI/CD +.github +.gitlab-ci.yml + +# Environment files +.env +.env.* +!.env.example + +# Logs +*.log + +# OS +.DS_Store +Thumbs.db diff --git a/Dockerfile b/Dockerfile index f980f03..64c8890 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,19 @@ FROM golang AS builder -COPY . /app WORKDIR /app -RUN go env -w CGO_ENABLED=0 &&\ - go build -v -o saturday . + +# Copy go mod files first for better caching +COPY go.mod go.sum ./ + +# Download dependencies with retry and alternative proxy +RUN go env -w CGO_ENABLED=0 && \ + go env -w GOPROXY=https://proxy.golang.org,direct && \ + go mod download + +# Copy source code +COPY . . + +# Build the application +RUN go build -v -o saturday . FROM alpine:latest AS deploy ENV TZ=Asia/Shanghai diff --git a/Dockerfile.vendor b/Dockerfile.vendor new file mode 100644 index 0000000..0d9ec5c --- /dev/null +++ b/Dockerfile.vendor @@ -0,0 +1,29 @@ +# Alternative Dockerfile using vendored dependencies +# Use this if network issues persist during Docker build +# +# Before building with this Dockerfile, run: go mod vendor +# Then build with: docker build -f Dockerfile.vendor -t saturday . + +FROM golang AS builder +WORKDIR /app + +# Copy everything including vendor directory +COPY . . + +# Build using vendored dependencies +RUN go env -w CGO_ENABLED=0 && \ + go build -v -mod=vendor -o saturday . + +FROM alpine:latest AS deploy +ENV TZ=Asia/Shanghai +RUN apk add --no-cache tzdata && \ + mkdir /app + +WORKDIR /app +COPY --from=builder /app/saturday /app +COPY --from=builder /app/migrations /app/migrations + +ENV Port=80 + +EXPOSE 80 +ENTRYPOINT [ "./saturday" ] From 5513d20d72f8e5654f764c680dbdfcf41186ea59 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Dec 2025 01:55:36 +0000 Subject: [PATCH 3/4] Revert "Fix Docker build issues and optimize build process" This reverts commit 29fc3248921ccfdf642335191a4daa12ac597494. --- .dockerignore | 46 ---------------------------------------------- Dockerfile | 17 +++-------------- Dockerfile.vendor | 29 ----------------------------- 3 files changed, 3 insertions(+), 89 deletions(-) delete mode 100644 .dockerignore delete mode 100644 Dockerfile.vendor diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 64b3817..0000000 --- a/.dockerignore +++ /dev/null @@ -1,46 +0,0 @@ -# Git -.git -.gitignore - -# IDE -.vscode -.idea -*.swp -*.swo - -# Build artifacts -saturday -*.exe -*.dll -*.so -*.dylib - -# Test and coverage -*.test -*.out -coverage.txt - -# Docker -Dockerfile -.dockerignore -docker-compose*.yml - -# Documentation -*.md -!README.md - -# CI/CD -.github -.gitlab-ci.yml - -# Environment files -.env -.env.* -!.env.example - -# Logs -*.log - -# OS -.DS_Store -Thumbs.db diff --git a/Dockerfile b/Dockerfile index 64c8890..f980f03 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,8 @@ FROM golang AS builder +COPY . /app WORKDIR /app - -# Copy go mod files first for better caching -COPY go.mod go.sum ./ - -# Download dependencies with retry and alternative proxy -RUN go env -w CGO_ENABLED=0 && \ - go env -w GOPROXY=https://proxy.golang.org,direct && \ - go mod download - -# Copy source code -COPY . . - -# Build the application -RUN go build -v -o saturday . +RUN go env -w CGO_ENABLED=0 &&\ + go build -v -o saturday . FROM alpine:latest AS deploy ENV TZ=Asia/Shanghai diff --git a/Dockerfile.vendor b/Dockerfile.vendor deleted file mode 100644 index 0d9ec5c..0000000 --- a/Dockerfile.vendor +++ /dev/null @@ -1,29 +0,0 @@ -# Alternative Dockerfile using vendored dependencies -# Use this if network issues persist during Docker build -# -# Before building with this Dockerfile, run: go mod vendor -# Then build with: docker build -f Dockerfile.vendor -t saturday . - -FROM golang AS builder -WORKDIR /app - -# Copy everything including vendor directory -COPY . . - -# Build using vendored dependencies -RUN go env -w CGO_ENABLED=0 && \ - go build -v -mod=vendor -o saturday . - -FROM alpine:latest AS deploy -ENV TZ=Asia/Shanghai -RUN apk add --no-cache tzdata && \ - mkdir /app - -WORKDIR /app -COPY --from=builder /app/saturday /app -COPY --from=builder /app/migrations /app/migrations - -ENV Port=80 - -EXPOSE 80 -ENTRYPOINT [ "./saturday" ] From 97db0dc181b22eef6dd89c9aaa29e7fb2e47c7d7 Mon Sep 17 00:00:00 2001 From: Clas Wen Date: Mon, 15 Dec 2025 19:13:31 +0800 Subject: [PATCH 4/4] fix --- .../000008_add_images_to_event.down.sql | 35 ++++++++ migrations/000008_add_images_to_event.up.sql | 43 ++++++++- router/common.go | 87 +++++-------------- router/input_types.go | 39 +++++---- router/main.go | 2 +- 5 files changed, 124 insertions(+), 82 deletions(-) diff --git a/migrations/000008_add_images_to_event.down.sql b/migrations/000008_add_images_to_event.down.sql index 49dc809..1178511 100644 --- a/migrations/000008_add_images_to_event.down.sql +++ b/migrations/000008_add_images_to_event.down.sql @@ -1,3 +1,38 @@ +-- Recreate event_log_view without images column +DROP VIEW public.event_log_view; +CREATE VIEW public.event_log_view AS + SELECT event_log.event_log_id, + event_log.event_id, + event_log.description, + event_log.member_id, + event_log.gmt_create, + event_action.action + FROM ((public.event_log + LEFT JOIN public.event_event_action_relation ON ((event_log.event_log_id = event_event_action_relation.event_log_id))) + LEFT JOIN public.event_action ON ((event_event_action_relation.event_action_id = event_action.event_action_id))); + +-- Recreate event_view without images column +DROP VIEW public.event_view; +CREATE VIEW public.event_view AS + SELECT event.event_id, + event.client_id, + event.model, + event.phone, + event.qq, + event.contact_preference, + event.problem, + event.member_id, + event.closed_by, + event.gmt_create, + event.gmt_modified, + event.size, + COALESCE(event_status.status, ''::character varying) AS status, + event.github_issue_id, + event.github_issue_number + FROM ((public.event + LEFT JOIN public.event_event_status_relation ON ((event.event_id = event_event_status_relation.event_id))) + LEFT JOIN public.event_status ON ((event_event_status_relation.event_status_id = event_status.event_status_id))); + -- Remove images field from event_log table ALTER TABLE event_log DROP COLUMN images; diff --git a/migrations/000008_add_images_to_event.up.sql b/migrations/000008_add_images_to_event.up.sql index df735fb..ebcfff0 100644 --- a/migrations/000008_add_images_to_event.up.sql +++ b/migrations/000008_add_images_to_event.up.sql @@ -1,5 +1,44 @@ -- Add images field to event table (client's problem images) -ALTER TABLE event ADD COLUMN images TEXT COMMENT '事件图片(JSON数组)'; +ALTER TABLE event ADD COLUMN images TEXT; +COMMENT ON COLUMN event.images IS '事件图片(JSON数组)'; -- Add images field to event_log table (member's repair images) -ALTER TABLE event_log ADD COLUMN images TEXT COMMENT '维修记录图片(JSON数组)'; +ALTER TABLE event_log ADD COLUMN images TEXT; +COMMENT ON COLUMN event_log.images IS '维修记录图片(JSON数组)'; + +-- Recreate event_view to include the new images column +DROP VIEW public.event_view; +CREATE VIEW public.event_view AS + SELECT event.event_id, + event.client_id, + event.model, + event.phone, + event.qq, + event.contact_preference, + event.problem, + event.images, + event.member_id, + event.closed_by, + event.gmt_create, + event.gmt_modified, + event.size, + COALESCE(event_status.status, ''::character varying) AS status, + event.github_issue_id, + event.github_issue_number + FROM ((public.event + LEFT JOIN public.event_event_status_relation ON ((event.event_id = event_event_status_relation.event_id))) + LEFT JOIN public.event_status ON ((event_event_status_relation.event_status_id = event_status.event_status_id))); + +-- Recreate event_log_view to include the new images column +DROP VIEW public.event_log_view; +CREATE VIEW public.event_log_view AS + SELECT event_log.event_log_id, + event_log.event_id, + event_log.description, + event_log.images, + event_log.member_id, + event_log.gmt_create, + event_action.action + FROM ((public.event_log + LEFT JOIN public.event_event_action_relation ON ((event_log.event_log_id = event_event_action_relation.event_log_id))) + LEFT JOIN public.event_action ON ((event_event_action_relation.event_action_id = event_action.event_action_id))); diff --git a/router/common.go b/router/common.go index 0702b3c..44d54a5 100644 --- a/router/common.go +++ b/router/common.go @@ -4,9 +4,7 @@ import ( "context" "fmt" "io" - "mime/multipart" "net/http" - "strings" "github.com/danielgtaylor/huma/v2" "github.com/nbtca/saturday/model/dto" @@ -16,92 +14,55 @@ import ( type CommonRouter struct{} const ( - MaxUploadSize = 10 << 20 // 10MB - MaxMemory = 32 << 20 // 32MB for multipart parsing - AllowedImageTypes = "image/jpeg,image/png,image/webp,image/jpg" + MaxUploadSize = 10 << 20 // 10MB ) func (CommonRouter) Upload(ctx context.Context, input *UploadFileInput) (*util.CommonResponse[dto.FileUploadResponse], error) { - // Get the underlying HTTP request from context - req, ok := huma.ContextRequest(ctx) - if !ok { - return nil, huma.Error500InternalServerError("Failed to get HTTP request from context") - } - - // Parse multipart form with size limit - if err := req.ParseMultipartForm(MaxMemory); err != nil { - return nil, huma.Error400BadRequest("Failed to parse multipart form: " + err.Error()) - } - defer req.MultipartForm.RemoveAll() - - // Get the file from form - file, header, err := req.FormFile("file") - if err != nil { - return nil, huma.Error400BadRequest("No file provided or invalid field name. Use 'file' as the field name") - } - defer file.Close() + // Get the uploaded file from the parsed multipart form + file := input.RawBody.Data().File // Validate file size - if header.Size > MaxUploadSize { + if file.Size > MaxUploadSize { return nil, huma.Error400BadRequest(fmt.Sprintf("File size exceeds maximum allowed size of %d bytes", MaxUploadSize)) } - // Validate content type - contentType := header.Header.Get("Content-Type") - if !isAllowedImageType(contentType) { - return nil, huma.Error400BadRequest(fmt.Sprintf("Invalid file type. Allowed types: %s", AllowedImageTypes)) + // Additional validation: check file signature (magic bytes) + buffer := make([]byte, 512) + n, err := file.Read(buffer) + if err != nil && err != io.EOF { + return nil, huma.Error400BadRequest("Failed to read file header: " + err.Error()) } - // Additional validation: check file signature (magic bytes) - if err := validateImageSignature(file); err != nil { - return nil, huma.Error400BadRequest("Invalid image file: " + err.Error()) + // Detect content type from file content + contentType := http.DetectContentType(buffer[:n]) + allowedTypes := []string{"image/jpeg", "image/png", "image/webp"} + isAllowed := false + for _, allowed := range allowedTypes { + if contentType == allowed { + isAllowed = true + break + } + } + if !isAllowed { + return nil, huma.Error400BadRequest(fmt.Sprintf("Invalid image file type detected: %s. Allowed types: JPEG, PNG, WebP", contentType)) } // Reset file pointer after signature check if _, err := file.Seek(0, 0); err != nil { - return nil, huma.Error500InternalServerError("Failed to reset file pointer") + return nil, huma.Error500InternalServerError("Failed to reset file pointer: " + err.Error()) } // Upload to Aliyun OSS - url, err := util.Upload(header.Filename, file) + url, err := util.Upload(file.Filename, file) if err != nil { return nil, huma.Error500InternalServerError("Failed to upload file: " + err.Error()) } return &util.CommonResponse[dto.FileUploadResponse]{ - Data: dto.FileUploadResponse{ + Body: dto.FileUploadResponse{ Url: url, }, }, nil } -func isAllowedImageType(contentType string) bool { - allowedTypes := strings.Split(AllowedImageTypes, ",") - for _, allowedType := range allowedTypes { - if strings.TrimSpace(allowedType) == contentType { - return true - } - } - return false -} - -func validateImageSignature(file multipart.File) error { - // Read first 512 bytes to detect content type - buffer := make([]byte, 512) - n, err := file.Read(buffer) - if err != nil && err != io.EOF { - return fmt.Errorf("failed to read file header: %w", err) - } - - // Detect content type from file content - contentType := http.DetectContentType(buffer[:n]) - - // Validate detected type matches allowed types - if !isAllowedImageType(contentType) { - return fmt.Errorf("file content does not match allowed image types (detected: %s)", contentType) - } - - return nil -} - var CommonRouterApp = CommonRouter{} diff --git a/router/input_types.go b/router/input_types.go index bf69959..6d81cea 100644 --- a/router/input_types.go +++ b/router/input_types.go @@ -1,6 +1,7 @@ package router import ( + "github.com/danielgtaylor/huma/v2" "github.com/nbtca/saturday/model" "github.com/nbtca/saturday/model/dto" ) @@ -120,8 +121,9 @@ type CommitEventInput struct { MemberAuthInput EventPathInput Body struct { - Content string `json:"content"` - Size string `json:"size" required:"false"` + Content string `json:"content"` + Size string `json:"size" required:"false"` + Images []string `json:"images" required:"false"` } } @@ -129,8 +131,9 @@ type AlterCommitEventInput struct { MemberAuthInput EventPathInput Body struct { - Content string `json:"content"` - Size string `json:"size" required:"false"` + Content string `json:"content"` + Size string `json:"size" required:"false"` + Images []string `json:"images" required:"false"` } } @@ -169,11 +172,12 @@ type GetClientEventByPageInput struct { type CreateClientEventInput struct { ClientAuthInput Body struct { - Model string `json:"model" required:"false"` - Phone string `json:"phone"` - QQ string `json:"qq" required:"false"` - ContactPreference string `json:"contactPreference" required:"false"` - Problem string `json:"problem"` + Model string `json:"model" required:"false"` + Phone string `json:"phone"` + QQ string `json:"qq" required:"false"` + ContactPreference string `json:"contactPreference" required:"false"` + Problem string `json:"problem"` + Images []string `json:"images" required:"false"` } } @@ -181,12 +185,13 @@ type UpdateClientEventInput struct { ClientAuthInput EventPathInput Body struct { - Model string `json:"model" required:"false"` - Phone string `json:"phone"` - QQ string `json:"qq" required:"false"` - ContactPreference string `json:"contactPreference" required:"false"` - Problem string `json:"problem"` - Size string `json:"size" required:"false"` + Model string `json:"model" required:"false"` + Phone string `json:"phone"` + QQ string `json:"qq" required:"false"` + ContactPreference string `json:"contactPreference" required:"false"` + Problem string `json:"problem"` + Size string `json:"size" required:"false"` + Images []string `json:"images" required:"false"` } } @@ -205,7 +210,9 @@ type CreateTokenViaLogtoInput struct { type UploadFileInput struct { AuthenticatedInput - // File upload will be handled specially + RawBody huma.MultipartFormFiles[struct { + File huma.FormFile `form:"file" contentType:"image/jpeg,image/png,image/webp,image/jpg" required:"true" doc:"Image file to upload (max 10MB)"` + }] } // Webhook inputs (these may stay as Gin handlers) diff --git a/router/main.go b/router/main.go index d2dd2e6..9ea828d 100644 --- a/router/main.go +++ b/router/main.go @@ -373,7 +373,7 @@ func SetupRouter() *chi.Mux { Summary: "Upload image file", Description: "Upload an image file (JPEG, PNG, WebP). Max size: 10MB. Use 'file' as the field name in multipart form.", Tags: []string{"Common", "Private"}, - Middlewares: huma.Middlewares{middleware.HumaAuth("member", "admin", "client")}, + Middlewares: huma.Middlewares{middleware.RequireAuth("member", "admin", "client")}, }, CommonRouterApp.Upload) return router