Compare commits
	
		
			62 Commits
		
	
	
		
			bd8729d473
			...
			main
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| c4ca0175dd | |||
| 193d77b5b7 | |||
| 0c88c76417 | |||
| 843bd8a814 | |||
| 348220bc7b | |||
| d6c18f0774 | |||
| e1c76fd8ec | |||
| bc6a960451 | |||
| 4e87436cc0 | |||
| 942ffa29a1 | |||
| b44e1a0e7c | |||
| d22ddac9cd | |||
| ccab7c98e4 | |||
| 3334537663 | |||
| 0c35e2ce7d | |||
| db11438f5c | |||
| 9f3e800e59 | |||
| 8d8310fd2c | |||
| 12c6dc515f | |||
| c2c2383305 | |||
| 4a92324774 | |||
| a4bd19f950 | |||
| f71d04f8af | |||
| 4b10efb13c | |||
| b4c70d4d9c | |||
| f624a8bf5e | |||
| 8ce553a9e4 | |||
| 5b064b4015 | |||
| 6228534155 | |||
| d235130d11 | |||
| f0982839e0 | |||
| ff8a8d2b97 | |||
| f2078ea54a | |||
| c463875fba | |||
| 7c5232e71b | |||
| 2c9b4777ae | |||
| 93f67812ae | |||
| e5b75e3879 | |||
| 67575c17bc | |||
| 7ac9e49212 | |||
| ff45c59946 | |||
| 8d48576305 | |||
| af8689d627 | |||
| 2910c9186a | |||
| b09d32b1d7 | |||
| 403d46b777 | |||
| 85bd5254c1 | |||
| 5050f76066 | |||
| 1ee3e638f7 | |||
| 94e8768424 | |||
| 675711cdcf | |||
| e66ee67cf7 | |||
| 40eb57ee47 | |||
| 6a8e8f1f7d | |||
| 5c83c19bce | |||
| 86c9073da8 | |||
| 43c1839345 | |||
| f62cc1c4a9 | |||
| f6d2069e1a | |||
| f33e14f60f | |||
| d6f275b2d1 | |||
| d8de5a68eb | 
							
								
								
									
										18
									
								
								AGENTS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								AGENTS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | ||||
| <!-- OPENSPEC:START --> | ||||
| # OpenSpec Instructions | ||||
|  | ||||
| These instructions are for AI assistants working in this project. | ||||
|  | ||||
| Always open `@/openspec/AGENTS.md` when the request: | ||||
| - Mentions planning or proposals (words like proposal, spec, change, plan) | ||||
| - Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work | ||||
| - Sounds ambiguous and you need the authoritative spec before coding | ||||
|  | ||||
| Use `@/openspec/AGENTS.md` to learn: | ||||
| - How to create and apply change proposals | ||||
| - Spec format and conventions | ||||
| - Project structure and guidelines | ||||
|  | ||||
| Keep this managed block so 'openspec update' can refresh the instructions. | ||||
|  | ||||
| <!-- OPENSPEC:END --> | ||||
| @@ -7,7 +7,7 @@ app: | ||||
| # 服务器配置 | ||||
| server: | ||||
|   port: 8080 # 服务器监听端口 | ||||
|   mode: "debug" # 运行模式: debug, release, test | ||||
|   mode: "debug" # 服务运行模式: debug, release, test | ||||
|  | ||||
| # 日志配置 | ||||
| log: | ||||
| @@ -48,8 +48,10 @@ chirp_stack: | ||||
|   api_host: "http://localhost:8080" # ChirpStack API 主机地址 | ||||
|   api_token: "your_chirpstack_api_token" # ChirpStack API Token | ||||
|   fport: 10 # ChirpStack FPort | ||||
|   api_timeout: 5 # API 请求超时时间 (秒) | ||||
|   collection_request_timeout: 10 # 采集请求超时时间 (秒) | ||||
|   api_timeout: 10 # ChirpStack API请求超时时间(秒) | ||||
|   # 等待设备上行响应的超时时间(秒)。 | ||||
|   # 对于LoRaWAN这种延迟较高的网络,建议设置为5分钟 (300秒) 或更长。 | ||||
|   collection_request_timeout: 300 | ||||
|  | ||||
| # 任务调度配置 | ||||
| task: | ||||
| @@ -62,12 +64,28 @@ lora: | ||||
|  | ||||
| # Lora Mesh 配置 | ||||
| lora_mesh: | ||||
|   uart_port: "/dev/ttyUSB0" # UART 串口路径 | ||||
|   baud_rate: 115200 # 波特率 | ||||
|   timeout: 5 # 超时时间 (秒) | ||||
|   lora_mesh_mode: "transparent" # Lora Mesh 模式: transparent, command | ||||
|   max_chunk_size: 200 # 最大数据块大小 | ||||
|   reassembly_timeout: 10 # 重组超时时间 (秒) | ||||
|   # 主节点串口 | ||||
|   uart_port: "COM7" | ||||
|   # LoRa模块的通信波特率 | ||||
|   baud_rate: 9600 | ||||
|   # 等待LoRa模块AT指令响应的超时时间(ms) | ||||
|   timeout: 50 | ||||
|   # LoRa Mesh 模块发送模式(EC: 透传; ED: 完整数据包) | ||||
|   # e.g. | ||||
|   #   EC: 接收端只会接收到消息, 不会接收到请求头 | ||||
|   #       e.g. 发送: EC 05 02 01 48 65 6c 6c 6f | ||||
|   #            (EC + 05(消息长度) + 0201(地址) + "Hello"(消息本体)) | ||||
|   #            接收: 48 65 6c 6c 6f ("Hello") | ||||
|   #   ED: 接收端会接收完整数据包,包含自定义协议头和地址信息。 | ||||
|   #       e.g. 发送: ED 05 12 34 01 00 01 02 03 | ||||
|   #            (ED(帧头) + 05(Length, 即 1(总包数)+1(当前包序号)+3(数据块)) + 12 34(目标地址) + 01(总包数) + 00(当前包序号) + 01 02 03(数据块)) | ||||
|   #            接收: ED 05 12 34 01 00 01 02 03 56 78(56 78 是发送方地址,会自动拼接到消息末尾) | ||||
|   lora_mesh_mode: "ED" | ||||
|   # 单包最大用户数据数据长度, 模块限制240, 去掉两位自定义包头, 还剩238 | ||||
|   max_chunk_size: 238 | ||||
|   #分片重组超时时间(秒)。如果在一个分片到达后,超过这个时间 | ||||
|   # 还没收到完整的包,则认为接收失败。 | ||||
|   reassembly_timeout: 30 | ||||
|  | ||||
| # 通知服务配置 | ||||
| notify: | ||||
| @@ -91,3 +109,7 @@ notify: | ||||
|     enabled: false # 是否启用飞书通知 | ||||
|     appID: "cli_xxxxxxxxxx" # 应用 ID | ||||
|     appSecret: "your_lark_app_secret" # 应用密钥 | ||||
|  | ||||
| # 定时采集配置 | ||||
| collection: | ||||
|   interval: 1 # 采集间隔 (分钟) | ||||
|   | ||||
							
								
								
									
										10
									
								
								config.yml
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								config.yml
									
									
									
									
									
								
							| @@ -8,11 +8,11 @@ app: | ||||
| # HTTP 服务配置 | ||||
| server: | ||||
|   port: 8086 | ||||
|   mode: "release" # Gin 运行模式: "debug", "release", "test" | ||||
|   mode: "release" # 服务运行模式: "debug", "release", "test" | ||||
|  | ||||
| # 日志配置 | ||||
| log: | ||||
|   level: "debug" # 日志级别: "debug", "info", "warn", "error", "dpanic", "panic", "fatal" | ||||
|   level: "info" # 日志级别: "debug", "info", "warn", "error", "dpanic", "panic", "fatal" | ||||
|   format: "console" # 日志格式: "console" 或 "json" | ||||
|   enable_file: true # 是否启用文件日志 | ||||
|   file_path: "./app_logs/app.log" # 日志文件路径 | ||||
| @@ -86,4 +86,8 @@ lora_mesh: | ||||
|   max_chunk_size: 238 | ||||
|   #分片重组超时时间(秒)。如果在一个分片到达后,超过这个时间 | ||||
|   # 还没收到完整的包,则认为接收失败。 | ||||
|   reassembly_timeout: 30 | ||||
|   reassembly_timeout: 30 | ||||
|  | ||||
| # 定时采集配置 | ||||
| collection: | ||||
|   interval: 1 # 采集间隔 (分钟) | ||||
							
								
								
									
										661
									
								
								docs/docs.go
									
									
									
									
									
								
							
							
						
						
									
										661
									
								
								docs/docs.go
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -6,7 +6,7 @@ definitions: | ||||
|         - $ref: '#/definitions/controller.ResponseCode' | ||||
|         description: 业务状态码 | ||||
|       data: | ||||
|         description: 业务数据 | ||||
|         description: 业务数据, omitempty表示如果为空则不序列化 | ||||
|       message: | ||||
|         description: 提示信息 | ||||
|         type: string | ||||
| @@ -17,6 +17,7 @@ definitions: | ||||
|     - 2001 | ||||
|     - 4000 | ||||
|     - 4001 | ||||
|     - 4003 | ||||
|     - 4004 | ||||
|     - 4009 | ||||
|     - 5000 | ||||
| @@ -26,6 +27,7 @@ definitions: | ||||
|       CodeBadRequest: 请求参数错误 | ||||
|       CodeConflict: 资源冲突 | ||||
|       CodeCreated: 创建成功 | ||||
|       CodeForbidden: 禁止访问 | ||||
|       CodeInternalError: 服务器内部错误 | ||||
|       CodeNotFound: 资源未找到 | ||||
|       CodeServiceUnavailable: 服务不可用 | ||||
| @@ -36,6 +38,7 @@ definitions: | ||||
|     - 创建成功 | ||||
|     - 请求参数错误 | ||||
|     - 未授权 | ||||
|     - 禁止访问 | ||||
|     - 资源未找到 | ||||
|     - 资源冲突 | ||||
|     - 服务器内部错误 | ||||
| @@ -45,6 +48,7 @@ definitions: | ||||
|     - CodeCreated | ||||
|     - CodeBadRequest | ||||
|     - CodeUnauthorized | ||||
|     - CodeForbidden | ||||
|     - CodeNotFound | ||||
|     - CodeConflict | ||||
|     - CodeInternalError | ||||
| @@ -70,10 +74,23 @@ definitions: | ||||
|         type: string | ||||
|     type: object | ||||
|   dto.AssignEmptyPensToBatchRequest: | ||||
|     properties: | ||||
|       pen_ids: | ||||
|         description: 待分配的猪栏ID列表 | ||||
|         example: | ||||
|         - 1 | ||||
|         - 2 | ||||
|         - 3 | ||||
|         items: | ||||
|           type: integer | ||||
|         minItems: 1 | ||||
|         type: array | ||||
|     required: | ||||
|     - pen_ids | ||||
|     type: object | ||||
|   dto.BuyPigsRequest: | ||||
|     properties: | ||||
|       penID: | ||||
|       pen_id: | ||||
|         description: 猪栏ID | ||||
|         type: integer | ||||
|       quantity: | ||||
| @@ -83,27 +100,27 @@ definitions: | ||||
|       remarks: | ||||
|         description: 备注 | ||||
|         type: string | ||||
|       totalPrice: | ||||
|       total_price: | ||||
|         description: 总价 | ||||
|         minimum: 0 | ||||
|         type: number | ||||
|       tradeDate: | ||||
|       trade_date: | ||||
|         description: 交易日期 | ||||
|         type: string | ||||
|       traderName: | ||||
|       trader_name: | ||||
|         description: 交易方名称 | ||||
|         type: string | ||||
|       unitPrice: | ||||
|       unit_price: | ||||
|         description: 单价 | ||||
|         minimum: 0 | ||||
|         type: number | ||||
|     required: | ||||
|     - penID | ||||
|     - pen_id | ||||
|     - quantity | ||||
|     - totalPrice | ||||
|     - tradeDate | ||||
|     - traderName | ||||
|     - unitPrice | ||||
|     - total_price | ||||
|     - trade_date | ||||
|     - trader_name | ||||
|     - unit_price | ||||
|     type: object | ||||
|   dto.CreateAreaControllerRequest: | ||||
|     properties: | ||||
| @@ -192,6 +209,7 @@ definitions: | ||||
|         type: string | ||||
|       execute_num: | ||||
|         example: 10 | ||||
|         minimum: 0 | ||||
|         type: integer | ||||
|       execution_type: | ||||
|         allOf: | ||||
| @@ -349,6 +367,15 @@ definitions: | ||||
|       pagination: | ||||
|         $ref: '#/definitions/dto.PaginationDTO' | ||||
|     type: object | ||||
|   dto.ListNotificationResponse: | ||||
|     properties: | ||||
|       list: | ||||
|         items: | ||||
|           $ref: '#/definitions/dto.NotificationDTO' | ||||
|         type: array | ||||
|       pagination: | ||||
|         $ref: '#/definitions/dto.PaginationDTO' | ||||
|     type: object | ||||
|   dto.ListPendingCollectionResponse: | ||||
|     properties: | ||||
|       list: | ||||
| @@ -412,6 +439,16 @@ definitions: | ||||
|       pagination: | ||||
|         $ref: '#/definitions/dto.PaginationDTO' | ||||
|     type: object | ||||
|   dto.ListPlansResponse: | ||||
|     properties: | ||||
|       plans: | ||||
|         items: | ||||
|           $ref: '#/definitions/dto.PlanResponse' | ||||
|         type: array | ||||
|       total: | ||||
|         example: 100 | ||||
|         type: integer | ||||
|     type: object | ||||
|   dto.ListRawMaterialPurchaseResponse: | ||||
|     properties: | ||||
|       list: | ||||
| @@ -545,18 +582,45 @@ definitions: | ||||
|       remarks: | ||||
|         description: 备注 | ||||
|         type: string | ||||
|       toPenID: | ||||
|       to_pen_id: | ||||
|         description: 目标猪栏ID | ||||
|         type: integer | ||||
|     required: | ||||
|     - quantity | ||||
|     - toPenID | ||||
|     - to_pen_id | ||||
|     type: object | ||||
|   dto.NotificationDTO: | ||||
|     properties: | ||||
|       alarm_timestamp: | ||||
|         type: string | ||||
|       created_at: | ||||
|         type: string | ||||
|       error_message: | ||||
|         type: string | ||||
|       id: | ||||
|         type: integer | ||||
|       level: | ||||
|         $ref: '#/definitions/zapcore.Level' | ||||
|       message: | ||||
|         type: string | ||||
|       notifier_type: | ||||
|         $ref: '#/definitions/notify.NotifierType' | ||||
|       status: | ||||
|         $ref: '#/definitions/models.NotificationStatus' | ||||
|       title: | ||||
|         type: string | ||||
|       to_address: | ||||
|         type: string | ||||
|       updated_at: | ||||
|         type: string | ||||
|       user_id: | ||||
|         type: integer | ||||
|     type: object | ||||
|   dto.PaginationDTO: | ||||
|     properties: | ||||
|       page: | ||||
|         type: integer | ||||
|       pageSize: | ||||
|       page_size: | ||||
|         type: integer | ||||
|       total: | ||||
|         type: integer | ||||
| @@ -662,10 +726,10 @@ definitions: | ||||
|       create_time: | ||||
|         description: 创建时间 | ||||
|         type: string | ||||
|       currentTotalPigsInPens: | ||||
|       current_total_pigs_in_pens: | ||||
|         description: 当前存栏总数 | ||||
|         type: integer | ||||
|       currentTotalQuantity: | ||||
|       current_total_quantity: | ||||
|         description: 当前总数 | ||||
|         type: integer | ||||
|       end_date: | ||||
| @@ -843,6 +907,8 @@ definitions: | ||||
|         type: integer | ||||
|       plan_id: | ||||
|         type: integer | ||||
|       plan_name: | ||||
|         type: string | ||||
|       started_at: | ||||
|         type: string | ||||
|       status: | ||||
| @@ -878,6 +944,10 @@ definitions: | ||||
|       name: | ||||
|         example: 猪舍温度控制计划 | ||||
|         type: string | ||||
|       plan_type: | ||||
|         allOf: | ||||
|         - $ref: '#/definitions/models.PlanType' | ||||
|         example: 自定义任务 | ||||
|       status: | ||||
|         allOf: | ||||
|         - $ref: '#/definitions/models.PlanStatus' | ||||
| @@ -938,25 +1008,25 @@ definitions: | ||||
|     type: object | ||||
|   dto.ReclassifyPenToNewBatchRequest: | ||||
|     properties: | ||||
|       penID: | ||||
|       pen_id: | ||||
|         description: 待划拨的猪栏ID | ||||
|         type: integer | ||||
|       remarks: | ||||
|         description: 备注 | ||||
|         type: string | ||||
|       toBatchID: | ||||
|       to_batch_id: | ||||
|         description: 目标猪批次ID | ||||
|         type: integer | ||||
|     required: | ||||
|     - penID | ||||
|     - toBatchID | ||||
|     - pen_id | ||||
|     - to_batch_id | ||||
|     type: object | ||||
|   dto.RecordCullRequest: | ||||
|     properties: | ||||
|       happenedAt: | ||||
|       happened_at: | ||||
|         description: 发生时间 | ||||
|         type: string | ||||
|       penID: | ||||
|       pen_id: | ||||
|         description: 猪栏ID | ||||
|         type: integer | ||||
|       quantity: | ||||
| @@ -967,16 +1037,16 @@ definitions: | ||||
|         description: 备注 | ||||
|         type: string | ||||
|     required: | ||||
|     - happenedAt | ||||
|     - penID | ||||
|     - happened_at | ||||
|     - pen_id | ||||
|     - quantity | ||||
|     type: object | ||||
|   dto.RecordDeathRequest: | ||||
|     properties: | ||||
|       happenedAt: | ||||
|       happened_at: | ||||
|         description: 发生时间 | ||||
|         type: string | ||||
|       penID: | ||||
|       pen_id: | ||||
|         description: 猪栏ID | ||||
|         type: integer | ||||
|       quantity: | ||||
| @@ -987,16 +1057,16 @@ definitions: | ||||
|         description: 备注 | ||||
|         type: string | ||||
|     required: | ||||
|     - happenedAt | ||||
|     - penID | ||||
|     - happened_at | ||||
|     - pen_id | ||||
|     - quantity | ||||
|     type: object | ||||
|   dto.RecordSickPigCullRequest: | ||||
|     properties: | ||||
|       happenedAt: | ||||
|       happened_at: | ||||
|         description: 发生时间 | ||||
|         type: string | ||||
|       penID: | ||||
|       pen_id: | ||||
|         description: 猪栏ID | ||||
|         type: integer | ||||
|       quantity: | ||||
| @@ -1006,22 +1076,22 @@ definitions: | ||||
|       remarks: | ||||
|         description: 备注 | ||||
|         type: string | ||||
|       treatmentLocation: | ||||
|       treatment_location: | ||||
|         allOf: | ||||
|         - $ref: '#/definitions/models.PigBatchSickPigTreatmentLocation' | ||||
|         description: 治疗地点 | ||||
|     required: | ||||
|     - happenedAt | ||||
|     - penID | ||||
|     - happened_at | ||||
|     - pen_id | ||||
|     - quantity | ||||
|     - treatmentLocation | ||||
|     - treatment_location | ||||
|     type: object | ||||
|   dto.RecordSickPigDeathRequest: | ||||
|     properties: | ||||
|       happenedAt: | ||||
|       happened_at: | ||||
|         description: 发生时间 | ||||
|         type: string | ||||
|       penID: | ||||
|       pen_id: | ||||
|         description: 猪栏ID | ||||
|         type: integer | ||||
|       quantity: | ||||
| @@ -1031,22 +1101,22 @@ definitions: | ||||
|       remarks: | ||||
|         description: 备注 | ||||
|         type: string | ||||
|       treatmentLocation: | ||||
|       treatment_location: | ||||
|         allOf: | ||||
|         - $ref: '#/definitions/models.PigBatchSickPigTreatmentLocation' | ||||
|         description: 治疗地点 | ||||
|     required: | ||||
|     - happenedAt | ||||
|     - penID | ||||
|     - happened_at | ||||
|     - pen_id | ||||
|     - quantity | ||||
|     - treatmentLocation | ||||
|     - treatment_location | ||||
|     type: object | ||||
|   dto.RecordSickPigRecoveryRequest: | ||||
|     properties: | ||||
|       happenedAt: | ||||
|       happened_at: | ||||
|         description: 发生时间 | ||||
|         type: string | ||||
|       penID: | ||||
|       pen_id: | ||||
|         description: 猪栏ID | ||||
|         type: integer | ||||
|       quantity: | ||||
| @@ -1056,22 +1126,22 @@ definitions: | ||||
|       remarks: | ||||
|         description: 备注 | ||||
|         type: string | ||||
|       treatmentLocation: | ||||
|       treatment_location: | ||||
|         allOf: | ||||
|         - $ref: '#/definitions/models.PigBatchSickPigTreatmentLocation' | ||||
|         description: 治疗地点 | ||||
|     required: | ||||
|     - happenedAt | ||||
|     - penID | ||||
|     - happened_at | ||||
|     - pen_id | ||||
|     - quantity | ||||
|     - treatmentLocation | ||||
|     - treatment_location | ||||
|     type: object | ||||
|   dto.RecordSickPigsRequest: | ||||
|     properties: | ||||
|       happenedAt: | ||||
|       happened_at: | ||||
|         description: 发生时间 | ||||
|         type: string | ||||
|       penID: | ||||
|       pen_id: | ||||
|         description: 猪栏ID | ||||
|         type: integer | ||||
|       quantity: | ||||
| @@ -1081,19 +1151,19 @@ definitions: | ||||
|       remarks: | ||||
|         description: 备注 | ||||
|         type: string | ||||
|       treatmentLocation: | ||||
|       treatment_location: | ||||
|         allOf: | ||||
|         - $ref: '#/definitions/models.PigBatchSickPigTreatmentLocation' | ||||
|         description: 治疗地点 | ||||
|     required: | ||||
|     - happenedAt | ||||
|     - penID | ||||
|     - happened_at | ||||
|     - pen_id | ||||
|     - quantity | ||||
|     - treatmentLocation | ||||
|     - treatment_location | ||||
|     type: object | ||||
|   dto.SellPigsRequest: | ||||
|     properties: | ||||
|       penID: | ||||
|       pen_id: | ||||
|         description: 猪栏ID | ||||
|         type: integer | ||||
|       quantity: | ||||
| @@ -1103,27 +1173,27 @@ definitions: | ||||
|       remarks: | ||||
|         description: 备注 | ||||
|         type: string | ||||
|       totalPrice: | ||||
|       total_price: | ||||
|         description: 总价 | ||||
|         minimum: 0 | ||||
|         type: number | ||||
|       tradeDate: | ||||
|       trade_date: | ||||
|         description: 交易日期 | ||||
|         type: string | ||||
|       traderName: | ||||
|       trader_name: | ||||
|         description: 交易方名称 | ||||
|         type: string | ||||
|       unitPrice: | ||||
|       unit_price: | ||||
|         description: 单价 | ||||
|         minimum: 0 | ||||
|         type: number | ||||
|     required: | ||||
|     - penID | ||||
|     - pen_id | ||||
|     - quantity | ||||
|     - totalPrice | ||||
|     - tradeDate | ||||
|     - traderName | ||||
|     - unitPrice | ||||
|     - total_price | ||||
|     - trade_date | ||||
|     - trader_name | ||||
|     - unit_price | ||||
|     type: object | ||||
|   dto.SendTestNotificationRequest: | ||||
|     properties: | ||||
| @@ -1246,10 +1316,10 @@ definitions: | ||||
|     type: object | ||||
|   dto.TransferPigsAcrossBatchesRequest: | ||||
|     properties: | ||||
|       destBatchID: | ||||
|       dest_batch_id: | ||||
|         description: 目标猪批次ID | ||||
|         type: integer | ||||
|       fromPenID: | ||||
|       from_pen_id: | ||||
|         description: 源猪栏ID | ||||
|         type: integer | ||||
|       quantity: | ||||
| @@ -1259,18 +1329,18 @@ definitions: | ||||
|       remarks: | ||||
|         description: 备注 | ||||
|         type: string | ||||
|       toPenID: | ||||
|       to_pen_id: | ||||
|         description: 目标猪栏ID | ||||
|         type: integer | ||||
|     required: | ||||
|     - destBatchID | ||||
|     - fromPenID | ||||
|     - dest_batch_id | ||||
|     - from_pen_id | ||||
|     - quantity | ||||
|     - toPenID | ||||
|     - to_pen_id | ||||
|     type: object | ||||
|   dto.TransferPigsWithinBatchRequest: | ||||
|     properties: | ||||
|       fromPenID: | ||||
|       from_pen_id: | ||||
|         description: 源猪栏ID | ||||
|         type: integer | ||||
|       quantity: | ||||
| @@ -1280,13 +1350,13 @@ definitions: | ||||
|       remarks: | ||||
|         description: 备注 | ||||
|         type: string | ||||
|       toPenID: | ||||
|       to_pen_id: | ||||
|         description: 目标猪栏ID | ||||
|         type: integer | ||||
|     required: | ||||
|     - fromPenID | ||||
|     - from_pen_id | ||||
|     - quantity | ||||
|     - toPenID | ||||
|     - to_pen_id | ||||
|     type: object | ||||
|   dto.UpdateAreaControllerRequest: | ||||
|     properties: | ||||
| @@ -1403,6 +1473,7 @@ definitions: | ||||
|         type: string | ||||
|       execute_num: | ||||
|         example: 10 | ||||
|         minimum: 0 | ||||
|         type: integer | ||||
|       execution_type: | ||||
|         allOf: | ||||
| @@ -1557,6 +1628,24 @@ definitions: | ||||
|     - ReasonTypePreventive | ||||
|     - ReasonTypeTreatment | ||||
|     - ReasonTypeHealthCare | ||||
|   models.NotificationStatus: | ||||
|     enum: | ||||
|     - 发送成功 | ||||
|     - 发送失败 | ||||
|     - 已跳过 | ||||
|     type: string | ||||
|     x-enum-comments: | ||||
|       NotificationStatusFailed: 通知发送失败 | ||||
|       NotificationStatusSkipped: 通知因某些原因被跳过(例如:用户未配置联系方式) | ||||
|       NotificationStatusSuccess: 通知已成功发送 | ||||
|     x-enum-descriptions: | ||||
|     - 通知已成功发送 | ||||
|     - 通知发送失败 | ||||
|     - 通知因某些原因被跳过(例如:用户未配置联系方式) | ||||
|     x-enum-varnames: | ||||
|     - NotificationStatusSuccess | ||||
|     - NotificationStatusFailed | ||||
|     - NotificationStatusSkipped | ||||
|   models.PenStatus: | ||||
|     enum: | ||||
|     - 空闲 | ||||
| @@ -1754,6 +1843,14 @@ definitions: | ||||
|     - PlanStatusEnabled | ||||
|     - PlanStatusStopped | ||||
|     - PlanStatusFailed | ||||
|   models.PlanType: | ||||
|     enum: | ||||
|     - 自定义任务 | ||||
|     - 系统任务 | ||||
|     type: string | ||||
|     x-enum-varnames: | ||||
|     - PlanTypeCustom | ||||
|     - PlanTypeSystem | ||||
|   models.SensorType: | ||||
|     enum: | ||||
|     - 信号强度 | ||||
| @@ -1801,19 +1898,23 @@ definitions: | ||||
|     - 计划分析 | ||||
|     - 等待 | ||||
|     - 下料 | ||||
|     - 全量采集 | ||||
|     type: string | ||||
|     x-enum-comments: | ||||
|       TaskPlanAnalysis: 解析Plan的Task列表并添加到待执行队列的特殊任务 | ||||
|       TaskTypeFullCollection: 新增的全量采集任务 | ||||
|       TaskTypeReleaseFeedWeight: 下料口释放指定重量任务 | ||||
|       TaskTypeWaiting: 等待任务 | ||||
|     x-enum-descriptions: | ||||
|     - 解析Plan的Task列表并添加到待执行队列的特殊任务 | ||||
|     - 等待任务 | ||||
|     - 下料口释放指定重量任务 | ||||
|     - 新增的全量采集任务 | ||||
|     x-enum-varnames: | ||||
|     - TaskPlanAnalysis | ||||
|     - TaskTypeWaiting | ||||
|     - TaskTypeReleaseFeedWeight | ||||
|     - TaskTypeFullCollection | ||||
|   models.ValueDescriptor: | ||||
|     properties: | ||||
|       multiplier: | ||||
| @@ -1827,16 +1928,53 @@ definitions: | ||||
|     type: object | ||||
|   notify.NotifierType: | ||||
|     enum: | ||||
|     - smtp | ||||
|     - wechat | ||||
|     - lark | ||||
|     - log | ||||
|     - 邮件 | ||||
|     - 企业微信 | ||||
|     - 飞书 | ||||
|     - 日志 | ||||
|     type: string | ||||
|     x-enum-varnames: | ||||
|     - NotifierTypeSMTP | ||||
|     - NotifierTypeWeChat | ||||
|     - NotifierTypeLark | ||||
|     - NotifierTypeLog | ||||
|   repository.PlanTypeFilter: | ||||
|     enum: | ||||
|     - 所有任务 | ||||
|     - 自定义任务 | ||||
|     - 系统任务 | ||||
|     type: string | ||||
|     x-enum-varnames: | ||||
|     - PlanTypeFilterAll | ||||
|     - PlanTypeFilterCustom | ||||
|     - PlanTypeFilterSystem | ||||
|   zapcore.Level: | ||||
|     enum: | ||||
|     - -1 | ||||
|     - 0 | ||||
|     - 1 | ||||
|     - 2 | ||||
|     - 3 | ||||
|     - 4 | ||||
|     - 5 | ||||
|     - -1 | ||||
|     - 5 | ||||
|     - 6 | ||||
|     - 7 | ||||
|     format: int32 | ||||
|     type: integer | ||||
|     x-enum-varnames: | ||||
|     - DebugLevel | ||||
|     - InfoLevel | ||||
|     - WarnLevel | ||||
|     - ErrorLevel | ||||
|     - DPanicLevel | ||||
|     - PanicLevel | ||||
|     - FatalLevel | ||||
|     - _minLevel | ||||
|     - _maxLevel | ||||
|     - InvalidLevel | ||||
|     - _numLevels | ||||
| info: | ||||
|   contact: | ||||
|     email: divano@example.com | ||||
| @@ -2282,7 +2420,7 @@ paths: | ||||
|         name: page | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: pageSize | ||||
|         name: page_size | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: received_success | ||||
| @@ -2327,7 +2465,7 @@ paths: | ||||
|         name: page | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: pageSize | ||||
|         name: page_size | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: pen_id | ||||
| @@ -2372,7 +2510,7 @@ paths: | ||||
|         name: page | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: pageSize | ||||
|         name: page_size | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: pig_batch_id | ||||
| @@ -2400,6 +2538,105 @@ paths: | ||||
|       summary: 获取用药记录列表 | ||||
|       tags: | ||||
|       - 数据监控 | ||||
|   /api/v1/monitor/notifications: | ||||
|     get: | ||||
|       description: 根据提供的过滤条件,分页获取通知列表 | ||||
|       parameters: | ||||
|       - in: query | ||||
|         name: end_time | ||||
|         type: string | ||||
|       - enum: | ||||
|         - -1 | ||||
|         - 0 | ||||
|         - 1 | ||||
|         - 2 | ||||
|         - 3 | ||||
|         - 4 | ||||
|         - 5 | ||||
|         - -1 | ||||
|         - 5 | ||||
|         - 6 | ||||
|         - 7 | ||||
|         format: int32 | ||||
|         in: query | ||||
|         name: level | ||||
|         type: integer | ||||
|         x-enum-varnames: | ||||
|         - DebugLevel | ||||
|         - InfoLevel | ||||
|         - WarnLevel | ||||
|         - ErrorLevel | ||||
|         - DPanicLevel | ||||
|         - PanicLevel | ||||
|         - FatalLevel | ||||
|         - _minLevel | ||||
|         - _maxLevel | ||||
|         - InvalidLevel | ||||
|         - _numLevels | ||||
|       - enum: | ||||
|         - 邮件 | ||||
|         - 企业微信 | ||||
|         - 飞书 | ||||
|         - 日志 | ||||
|         in: query | ||||
|         name: notifier_type | ||||
|         type: string | ||||
|         x-enum-varnames: | ||||
|         - NotifierTypeSMTP | ||||
|         - NotifierTypeWeChat | ||||
|         - NotifierTypeLark | ||||
|         - NotifierTypeLog | ||||
|       - in: query | ||||
|         name: order_by | ||||
|         type: string | ||||
|       - in: query | ||||
|         name: page | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: page_size | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: start_time | ||||
|         type: string | ||||
|       - enum: | ||||
|         - 发送成功 | ||||
|         - 发送失败 | ||||
|         - 已跳过 | ||||
|         in: query | ||||
|         name: status | ||||
|         type: string | ||||
|         x-enum-comments: | ||||
|           NotificationStatusFailed: 通知发送失败 | ||||
|           NotificationStatusSkipped: 通知因某些原因被跳过(例如:用户未配置联系方式) | ||||
|           NotificationStatusSuccess: 通知已成功发送 | ||||
|         x-enum-descriptions: | ||||
|         - 通知已成功发送 | ||||
|         - 通知发送失败 | ||||
|         - 通知因某些原因被跳过(例如:用户未配置联系方式) | ||||
|         x-enum-varnames: | ||||
|         - NotificationStatusSuccess | ||||
|         - NotificationStatusFailed | ||||
|         - NotificationStatusSkipped | ||||
|       - in: query | ||||
|         name: user_id | ||||
|         type: integer | ||||
|       produces: | ||||
|       - application/json | ||||
|       responses: | ||||
|         "200": | ||||
|           description: OK | ||||
|           schema: | ||||
|             allOf: | ||||
|             - $ref: '#/definitions/controller.Response' | ||||
|             - properties: | ||||
|                 data: | ||||
|                   $ref: '#/definitions/dto.ListNotificationResponse' | ||||
|               type: object | ||||
|       security: | ||||
|       - BearerAuth: [] | ||||
|       summary: 批量查询通知 | ||||
|       tags: | ||||
|       - 数据监控 | ||||
|   /api/v1/monitor/pending-collections: | ||||
|     get: | ||||
|       description: 根据提供的过滤条件,分页获取待采集请求 | ||||
| @@ -2417,7 +2654,7 @@ paths: | ||||
|         name: page | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: pageSize | ||||
|         name: page_size | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: start_time | ||||
| @@ -2462,7 +2699,7 @@ paths: | ||||
|         name: page | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: pageSize | ||||
|         name: page_size | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: pig_batch_id | ||||
| @@ -2504,7 +2741,7 @@ paths: | ||||
|         name: page | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: pageSize | ||||
|         name: page_size | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: pig_batch_id | ||||
| @@ -2552,7 +2789,7 @@ paths: | ||||
|         name: page | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: pageSize | ||||
|         name: page_size | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: pig_batch_id | ||||
| @@ -2594,7 +2831,7 @@ paths: | ||||
|         name: page | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: pageSize | ||||
|         name: page_size | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: pen_id | ||||
| @@ -2648,7 +2885,7 @@ paths: | ||||
|         name: page | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: pageSize | ||||
|         name: page_size | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: pen_id | ||||
| @@ -2693,7 +2930,7 @@ paths: | ||||
|         name: page | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: pageSize | ||||
|         name: page_size | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: plan_id | ||||
| @@ -2735,7 +2972,7 @@ paths: | ||||
|         name: page | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: pageSize | ||||
|         name: page_size | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: raw_material_id | ||||
| @@ -2777,7 +3014,7 @@ paths: | ||||
|         name: page | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: pageSize | ||||
|         name: page_size | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: raw_material_id | ||||
| @@ -2825,7 +3062,7 @@ paths: | ||||
|         name: page | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: pageSize | ||||
|         name: page_size | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: sensor_type | ||||
| @@ -2864,7 +3101,7 @@ paths: | ||||
|         name: page | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: pageSize | ||||
|         name: page_size | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: plan_execution_log_id | ||||
| @@ -2912,7 +3149,7 @@ paths: | ||||
|         name: page | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: pageSize | ||||
|         name: page_size | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: start_time | ||||
| @@ -2957,7 +3194,7 @@ paths: | ||||
|         name: page | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: pageSize | ||||
|         name: page_size | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: pig_batch_id | ||||
| @@ -2999,7 +3236,7 @@ paths: | ||||
|         name: page | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: pageSize | ||||
|         name: page_size | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: pen_id | ||||
| @@ -3752,7 +3989,7 @@ paths: | ||||
|     post: | ||||
|       consumes: | ||||
|       - application/json | ||||
|       description: 创建一个新的猪舍 | ||||
|       description: 根据提供的信息创建一个新猪舍 | ||||
|       parameters: | ||||
|       - description: 猪舍信息 | ||||
|         in: body | ||||
| @@ -3858,7 +4095,28 @@ paths: | ||||
|       - 猪场管理 | ||||
|   /api/v1/plans: | ||||
|     get: | ||||
|       description: 获取所有计划的列表 | ||||
|       description: 获取所有计划的列表,支持按类型过滤和分页 | ||||
|       parameters: | ||||
|       - description: 页码 | ||||
|         in: query | ||||
|         name: page | ||||
|         type: integer | ||||
|       - description: 每页大小 | ||||
|         in: query | ||||
|         name: page_size | ||||
|         type: integer | ||||
|       - description: 计划类型 | ||||
|         enum: | ||||
|         - 所有任务 | ||||
|         - 自定义任务 | ||||
|         - 系统任务 | ||||
|         in: query | ||||
|         name: plan_type | ||||
|         type: string | ||||
|         x-enum-varnames: | ||||
|         - PlanTypeFilterAll | ||||
|         - PlanTypeFilterCustom | ||||
|         - PlanTypeFilterSystem | ||||
|       produces: | ||||
|       - application/json | ||||
|       responses: | ||||
| @@ -3869,9 +4127,7 @@ paths: | ||||
|             - $ref: '#/definitions/controller.Response' | ||||
|             - properties: | ||||
|                 data: | ||||
|                   items: | ||||
|                     $ref: '#/definitions/dto.PlanResponse' | ||||
|                   type: array | ||||
|                   $ref: '#/definitions/dto.ListPlansResponse' | ||||
|               type: object | ||||
|       security: | ||||
|       - BearerAuth: [] | ||||
| @@ -3908,7 +4164,7 @@ paths: | ||||
|       - 计划管理 | ||||
|   /api/v1/plans/{id}: | ||||
|     delete: | ||||
|       description: 根据计划ID删除计划。(软删除) | ||||
|       description: 根据计划ID删除计划。(软删除)系统计划不允许删除。 | ||||
|       parameters: | ||||
|       - description: 计划ID | ||||
|         in: path | ||||
| @@ -3955,7 +4211,7 @@ paths: | ||||
|     put: | ||||
|       consumes: | ||||
|       - application/json | ||||
|       description: 根据计划ID更新计划的详细信息。 | ||||
|       description: 根据计划ID更新计划的详细信息。系统计划不允许修改。 | ||||
|       parameters: | ||||
|       - description: 计划ID | ||||
|         in: path | ||||
| @@ -3987,7 +4243,7 @@ paths: | ||||
|       - 计划管理 | ||||
|   /api/v1/plans/{id}/start: | ||||
|     post: | ||||
|       description: 根据计划ID启动一个计划的执行。 | ||||
|       description: 根据计划ID启动一个计划的执行。系统计划不允许手动启动。 | ||||
|       parameters: | ||||
|       - description: 计划ID | ||||
|         in: path | ||||
| @@ -4008,7 +4264,7 @@ paths: | ||||
|       - 计划管理 | ||||
|   /api/v1/plans/{id}/stop: | ||||
|     post: | ||||
|       description: 根据计划ID停止一个正在执行的计划。 | ||||
|       description: 根据计划ID停止一个正在执行的计划。系统计划不能被停止。 | ||||
|       parameters: | ||||
|       - description: 计划ID | ||||
|         in: path | ||||
| @@ -4054,59 +4310,6 @@ paths: | ||||
|       summary: 创建新用户 | ||||
|       tags: | ||||
|       - 用户管理 | ||||
|   /api/v1/users/{id}/history: | ||||
|     get: | ||||
|       description: 根据用户ID,分页获取该用户的操作审计日志。支持与通用日志查询接口相同的过滤和排序参数。 | ||||
|       parameters: | ||||
|       - description: 用户ID | ||||
|         in: path | ||||
|         name: id | ||||
|         required: true | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: action_type | ||||
|         type: string | ||||
|       - in: query | ||||
|         name: end_time | ||||
|         type: string | ||||
|       - in: query | ||||
|         name: order_by | ||||
|         type: string | ||||
|       - in: query | ||||
|         name: page | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: pageSize | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: start_time | ||||
|         type: string | ||||
|       - in: query | ||||
|         name: status | ||||
|         type: string | ||||
|       - in: query | ||||
|         name: user_id | ||||
|         type: integer | ||||
|       - in: query | ||||
|         name: username | ||||
|         type: string | ||||
|       produces: | ||||
|       - application/json | ||||
|       responses: | ||||
|         "200": | ||||
|           description: 业务码为200代表成功获取 | ||||
|           schema: | ||||
|             allOf: | ||||
|             - $ref: '#/definitions/controller.Response' | ||||
|             - properties: | ||||
|                 data: | ||||
|                   $ref: '#/definitions/dto.ListUserActionLogResponse' | ||||
|               type: object | ||||
|       security: | ||||
|       - BearerAuth: [] | ||||
|       summary: 获取指定用户的操作历史 | ||||
|       tags: | ||||
|       - 用户管理 | ||||
|   /api/v1/users/{id}/notifications/test: | ||||
|     post: | ||||
|       consumes: | ||||
|   | ||||
							
								
								
									
										55
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										55
									
								
								go.mod
									
									
									
									
									
								
							| @@ -3,23 +3,21 @@ module git.huangwc.com/pig/pig-farm-controller | ||||
| go 1.25 | ||||
|  | ||||
| require ( | ||||
| 	github.com/gin-gonic/gin v1.10.1 | ||||
| 	github.com/go-openapi/errors v0.22.2 | ||||
| 	github.com/go-openapi/runtime v0.28.0 | ||||
| 	github.com/go-openapi/strfmt v0.23.0 | ||||
| 	github.com/go-openapi/swag v0.24.1 | ||||
| 	github.com/go-openapi/swag v0.25.1 | ||||
| 	github.com/go-openapi/validate v0.24.0 | ||||
| 	github.com/golang-jwt/jwt/v5 v5.3.0 | ||||
| 	github.com/google/uuid v1.6.0 | ||||
| 	github.com/labstack/echo/v4 v4.13.4 | ||||
| 	github.com/panjf2000/ants/v2 v2.11.3 | ||||
| 	github.com/robfig/cron/v3 v3.0.1 | ||||
| 	github.com/stretchr/testify v1.11.1 | ||||
| 	github.com/swaggo/files v1.0.1 | ||||
| 	github.com/swaggo/gin-swagger v1.6.1 | ||||
| 	github.com/swaggo/swag v1.16.6 | ||||
| 	github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 | ||||
| 	go.uber.org/zap v1.27.0 | ||||
| 	golang.org/x/crypto v0.42.0 | ||||
| 	golang.org/x/crypto v0.43.0 | ||||
| 	google.golang.org/protobuf v1.36.9 | ||||
| 	gopkg.in/natefinch/lumberjack.v2 v2.2.1 | ||||
| 	gopkg.in/yaml.v2 v2.4.0 | ||||
| @@ -39,25 +37,26 @@ require ( | ||||
| 	github.com/cloudwego/base64x v0.1.6 // indirect | ||||
| 	github.com/davecgh/go-spew v1.1.1 // indirect | ||||
| 	github.com/gabriel-vasile/mimetype v1.4.10 // indirect | ||||
| 	github.com/ghodss/yaml v1.0.0 // indirect | ||||
| 	github.com/gin-contrib/sse v1.1.0 // indirect | ||||
| 	github.com/go-logr/logr v1.4.1 // indirect | ||||
| 	github.com/go-logr/stdr v1.2.2 // indirect | ||||
| 	github.com/go-openapi/analysis v0.23.0 // indirect | ||||
| 	github.com/go-openapi/jsonpointer v0.22.0 // indirect | ||||
| 	github.com/go-openapi/jsonreference v0.21.1 // indirect | ||||
| 	github.com/go-openapi/jsonpointer v0.22.1 // indirect | ||||
| 	github.com/go-openapi/jsonreference v0.21.2 // indirect | ||||
| 	github.com/go-openapi/loads v0.22.0 // indirect | ||||
| 	github.com/go-openapi/spec v0.21.0 // indirect | ||||
| 	github.com/go-openapi/swag/cmdutils v0.24.0 // indirect | ||||
| 	github.com/go-openapi/swag/conv v0.24.0 // indirect | ||||
| 	github.com/go-openapi/swag/fileutils v0.24.0 // indirect | ||||
| 	github.com/go-openapi/swag/jsonname v0.24.0 // indirect | ||||
| 	github.com/go-openapi/swag/jsonutils v0.24.0 // indirect | ||||
| 	github.com/go-openapi/swag/loading v0.24.0 // indirect | ||||
| 	github.com/go-openapi/swag/mangling v0.24.0 // indirect | ||||
| 	github.com/go-openapi/swag/netutils v0.24.0 // indirect | ||||
| 	github.com/go-openapi/swag/stringutils v0.24.0 // indirect | ||||
| 	github.com/go-openapi/swag/typeutils v0.24.0 // indirect | ||||
| 	github.com/go-openapi/swag/yamlutils v0.24.0 // indirect | ||||
| 	github.com/go-openapi/spec v0.22.0 // indirect | ||||
| 	github.com/go-openapi/swag/cmdutils v0.25.1 // indirect | ||||
| 	github.com/go-openapi/swag/conv v0.25.1 // indirect | ||||
| 	github.com/go-openapi/swag/fileutils v0.25.1 // indirect | ||||
| 	github.com/go-openapi/swag/jsonname v0.25.1 // indirect | ||||
| 	github.com/go-openapi/swag/jsonutils v0.25.1 // indirect | ||||
| 	github.com/go-openapi/swag/loading v0.25.1 // indirect | ||||
| 	github.com/go-openapi/swag/mangling v0.25.1 // indirect | ||||
| 	github.com/go-openapi/swag/netutils v0.25.1 // indirect | ||||
| 	github.com/go-openapi/swag/stringutils v0.25.1 // indirect | ||||
| 	github.com/go-openapi/swag/typeutils v0.25.1 // indirect | ||||
| 	github.com/go-openapi/swag/yamlutils v0.25.1 // indirect | ||||
| 	github.com/go-playground/locales v0.14.1 // indirect | ||||
| 	github.com/go-playground/universal-translator v0.18.1 // indirect | ||||
| 	github.com/go-playground/validator/v10 v10.27.0 // indirect | ||||
| @@ -72,8 +71,10 @@ require ( | ||||
| 	github.com/josharian/intern v1.0.0 // indirect | ||||
| 	github.com/json-iterator/go v1.1.12 // indirect | ||||
| 	github.com/klauspost/cpuid/v2 v2.3.0 // indirect | ||||
| 	github.com/labstack/gommon v0.4.2 // indirect | ||||
| 	github.com/leodido/go-urn v1.4.0 // indirect | ||||
| 	github.com/mailru/easyjson v0.9.1 // indirect | ||||
| 	github.com/mattn/go-colorable v0.1.14 // indirect | ||||
| 	github.com/mattn/go-isatty v0.0.20 // indirect | ||||
| 	github.com/mattn/go-sqlite3 v1.14.22 // indirect | ||||
| 	github.com/mitchellh/mapstructure v1.5.0 // indirect | ||||
| @@ -85,20 +86,26 @@ require ( | ||||
| 	github.com/pmezard/go-difflib v1.0.0 // indirect | ||||
| 	github.com/rogpeppe/go-internal v1.14.1 // indirect | ||||
| 	github.com/stretchr/objx v0.5.2 // indirect | ||||
| 	github.com/swaggo/echo-swagger v1.4.1 // indirect | ||||
| 	github.com/swaggo/files/v2 v2.0.2 // indirect | ||||
| 	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect | ||||
| 	github.com/ugorji/go/codec v1.3.0 // indirect | ||||
| 	github.com/valyala/bytebufferpool v1.0.0 // indirect | ||||
| 	github.com/valyala/fasttemplate v1.2.2 // indirect | ||||
| 	go.mongodb.org/mongo-driver v1.14.0 // indirect | ||||
| 	go.opentelemetry.io/otel v1.24.0 // indirect | ||||
| 	go.opentelemetry.io/otel/metric v1.24.0 // indirect | ||||
| 	go.opentelemetry.io/otel/trace v1.24.0 // indirect | ||||
| 	go.uber.org/multierr v1.10.0 // indirect | ||||
| 	go.yaml.in/yaml/v3 v3.0.4 // indirect | ||||
| 	golang.org/x/arch v0.21.0 // indirect | ||||
| 	golang.org/x/mod v0.28.0 // indirect | ||||
| 	golang.org/x/net v0.44.0 // indirect | ||||
| 	golang.org/x/mod v0.29.0 // indirect | ||||
| 	golang.org/x/net v0.46.0 // indirect | ||||
| 	golang.org/x/sync v0.17.0 // indirect | ||||
| 	golang.org/x/sys v0.36.0 // indirect | ||||
| 	golang.org/x/text v0.29.0 // indirect | ||||
| 	golang.org/x/tools v0.37.0 // indirect | ||||
| 	golang.org/x/sys v0.37.0 // indirect | ||||
| 	golang.org/x/text v0.30.0 // indirect | ||||
| 	golang.org/x/time v0.11.0 // indirect | ||||
| 	golang.org/x/tools v0.38.0 // indirect | ||||
| 	gopkg.in/yaml.v3 v3.0.1 // indirect | ||||
| 	gorm.io/driver/mysql v1.5.6 // indirect | ||||
| ) | ||||
|   | ||||
							
								
								
									
										64
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										64
									
								
								go.sum
									
									
									
									
									
								
							| @@ -17,6 +17,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c | ||||
| github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||||
| github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= | ||||
| github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= | ||||
| github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= | ||||
| github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= | ||||
| github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= | ||||
| github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= | ||||
| github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= | ||||
| @@ -34,40 +36,70 @@ github.com/go-openapi/errors v0.22.2 h1:rdxhzcBUazEcGccKqbY1Y7NS8FDcMyIRr0934jrY | ||||
| github.com/go-openapi/errors v0.22.2/go.mod h1:+n/5UdIqdVnLIJ6Q9Se8HNGUXYaY6CN8ImWzfi/Gzp0= | ||||
| github.com/go-openapi/jsonpointer v0.22.0 h1:TmMhghgNef9YXxTu1tOopo+0BGEytxA+okbry0HjZsM= | ||||
| github.com/go-openapi/jsonpointer v0.22.0/go.mod h1:xt3jV88UtExdIkkL7NloURjRQjbeUgcxFblMjq2iaiU= | ||||
| github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk= | ||||
| github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM= | ||||
| github.com/go-openapi/jsonreference v0.21.1 h1:bSKrcl8819zKiOgxkbVNRUBIr6Wwj9KYrDbMjRs0cDA= | ||||
| github.com/go-openapi/jsonreference v0.21.1/go.mod h1:PWs8rO4xxTUqKGu+lEvvCxD5k2X7QYkKAepJyCmSTT8= | ||||
| github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU= | ||||
| github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ= | ||||
| github.com/go-openapi/loads v0.22.0 h1:ECPGd4jX1U6NApCGG1We+uEozOAvXvJSF4nnwHZ8Aco= | ||||
| github.com/go-openapi/loads v0.22.0/go.mod h1:yLsaTCS92mnSAZX5WWoxszLj0u+Ojl+Zs5Stn1oF+rs= | ||||
| github.com/go-openapi/runtime v0.28.0 h1:gpPPmWSNGo214l6n8hzdXYhPuJcGtziTOgUpvsFWGIQ= | ||||
| github.com/go-openapi/runtime v0.28.0/go.mod h1:QN7OzcS+XuYmkQLw05akXk0jRH/eZ3kb18+1KwW9gyc= | ||||
| github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= | ||||
| github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= | ||||
| github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw= | ||||
| github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0= | ||||
| github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c= | ||||
| github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4= | ||||
| github.com/go-openapi/swag v0.24.1 h1:DPdYTZKo6AQCRqzwr/kGkxJzHhpKxZ9i/oX0zag+MF8= | ||||
| github.com/go-openapi/swag v0.24.1/go.mod h1:sm8I3lCPlspsBBwUm1t5oZeWZS0s7m/A+Psg0ooRU0A= | ||||
| github.com/go-openapi/swag v0.25.1 h1:6uwVsx+/OuvFVPqfQmOOPsqTcm5/GkBhNwLqIR916n8= | ||||
| github.com/go-openapi/swag v0.25.1/go.mod h1:bzONdGlT0fkStgGPd3bhZf1MnuPkf2YAys6h+jZipOo= | ||||
| github.com/go-openapi/swag/cmdutils v0.24.0 h1:KlRCffHwXFI6E5MV9n8o8zBRElpY4uK4yWyAMWETo9I= | ||||
| github.com/go-openapi/swag/cmdutils v0.24.0/go.mod h1:uxib2FAeQMByyHomTlsP8h1TtPd54Msu2ZDU/H5Vuf8= | ||||
| github.com/go-openapi/swag/cmdutils v0.25.1 h1:nDke3nAFDArAa631aitksFGj2omusks88GF1VwdYqPY= | ||||
| github.com/go-openapi/swag/cmdutils v0.25.1/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= | ||||
| github.com/go-openapi/swag/conv v0.24.0 h1:ejB9+7yogkWly6pnruRX45D1/6J+ZxRu92YFivx54ik= | ||||
| github.com/go-openapi/swag/conv v0.24.0/go.mod h1:jbn140mZd7EW2g8a8Y5bwm8/Wy1slLySQQ0ND6DPc2c= | ||||
| github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0= | ||||
| github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs= | ||||
| github.com/go-openapi/swag/fileutils v0.24.0 h1:U9pCpqp4RUytnD689Ek/N1d2N/a//XCeqoH508H5oak= | ||||
| github.com/go-openapi/swag/fileutils v0.24.0/go.mod h1:3SCrCSBHyP1/N+3oErQ1gP+OX1GV2QYFSnrTbzwli90= | ||||
| github.com/go-openapi/swag/fileutils v0.25.1 h1:rSRXapjQequt7kqalKXdcpIegIShhTPXx7yw0kek2uU= | ||||
| github.com/go-openapi/swag/fileutils v0.25.1/go.mod h1:+NXtt5xNZZqmpIpjqcujqojGFek9/w55b3ecmOdtg8M= | ||||
| github.com/go-openapi/swag/jsonname v0.24.0 h1:2wKS9bgRV/xB8c62Qg16w4AUiIrqqiniJFtZGi3dg5k= | ||||
| github.com/go-openapi/swag/jsonname v0.24.0/go.mod h1:GXqrPzGJe611P7LG4QB9JKPtUZ7flE4DOVechNaDd7Q= | ||||
| github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU= | ||||
| github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo= | ||||
| github.com/go-openapi/swag/jsonutils v0.24.0 h1:F1vE1q4pg1xtO3HTyJYRmEuJ4jmIp2iZ30bzW5XgZts= | ||||
| github.com/go-openapi/swag/jsonutils v0.24.0/go.mod h1:vBowZtF5Z4DDApIoxcIVfR8v0l9oq5PpYRUuteVu6f0= | ||||
| github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8= | ||||
| github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo= | ||||
| github.com/go-openapi/swag/loading v0.24.0 h1:ln/fWTwJp2Zkj5DdaX4JPiddFC5CHQpvaBKycOlceYc= | ||||
| github.com/go-openapi/swag/loading v0.24.0/go.mod h1:gShCN4woKZYIxPxbfbyHgjXAhO61m88tmjy0lp/LkJk= | ||||
| github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw= | ||||
| github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc= | ||||
| github.com/go-openapi/swag/mangling v0.24.0 h1:PGOQpViCOUroIeak/Uj/sjGAq9LADS3mOyjznmHy2pk= | ||||
| github.com/go-openapi/swag/mangling v0.24.0/go.mod h1:Jm5Go9LHkycsz0wfoaBDkdc4CkpuSnIEf62brzyCbhc= | ||||
| github.com/go-openapi/swag/mangling v0.25.1 h1:XzILnLzhZPZNtmxKaz/2xIGPQsBsvmCjrJOWGNz/ync= | ||||
| github.com/go-openapi/swag/mangling v0.25.1/go.mod h1:CdiMQ6pnfAgyQGSOIYnZkXvqhnnwOn997uXZMAd/7mQ= | ||||
| github.com/go-openapi/swag/netutils v0.24.0 h1:Bz02HRjYv8046Ycg/w80q3g9QCWeIqTvlyOjQPDjD8w= | ||||
| github.com/go-openapi/swag/netutils v0.24.0/go.mod h1:WRgiHcYTnx+IqfMCtu0hy9oOaPR0HnPbmArSRN1SkZM= | ||||
| github.com/go-openapi/swag/netutils v0.25.1 h1:2wFLYahe40tDUHfKT1GRC4rfa5T1B4GWZ+msEFA4Fl4= | ||||
| github.com/go-openapi/swag/netutils v0.25.1/go.mod h1:CAkkvqnUJX8NV96tNhEQvKz8SQo2KF0f7LleiJwIeRE= | ||||
| github.com/go-openapi/swag/stringutils v0.24.0 h1:i4Z/Jawf9EvXOLUbT97O0HbPUja18VdBxeadyAqS1FM= | ||||
| github.com/go-openapi/swag/stringutils v0.24.0/go.mod h1:5nUXB4xA0kw2df5PRipZDslPJgJut+NjL7D25zPZ/4w= | ||||
| github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw= | ||||
| github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg= | ||||
| github.com/go-openapi/swag/typeutils v0.24.0 h1:d3szEGzGDf4L2y1gYOSSLeK6h46F+zibnEas2Jm/wIw= | ||||
| github.com/go-openapi/swag/typeutils v0.24.0/go.mod h1:q8C3Kmk/vh2VhpCLaoR2MVWOGP8y7Jc8l82qCTd1DYI= | ||||
| github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA= | ||||
| github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8= | ||||
| github.com/go-openapi/swag/yamlutils v0.24.0 h1:bhw4894A7Iw6ne+639hsBNRHg9iZg/ISrOVr+sJGp4c= | ||||
| github.com/go-openapi/swag/yamlutils v0.24.0/go.mod h1:DpKv5aYuaGm/sULePoeiG8uwMpZSfReo1HR3Ik0yaG8= | ||||
| github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk= | ||||
| github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg= | ||||
| github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58= | ||||
| github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ= | ||||
| github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= | ||||
| @@ -116,10 +148,16 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= | ||||
| github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= | ||||
| github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= | ||||
| github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= | ||||
| github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= | ||||
| github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= | ||||
| github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= | ||||
| github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= | ||||
| github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= | ||||
| github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= | ||||
| github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= | ||||
| github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= | ||||
| github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= | ||||
| github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= | ||||
| github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= | ||||
| github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= | ||||
| github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= | ||||
| @@ -159,8 +197,14 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO | ||||
| github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= | ||||
| github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= | ||||
| github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= | ||||
| github.com/swaggo/echo-swagger v1.4.1 h1:Yf0uPaJWp1uRtDloZALyLnvdBeoEL5Kc7DtnjzO/TUk= | ||||
| github.com/swaggo/echo-swagger v1.4.1/go.mod h1:C8bSi+9yH2FLZsnhqMZLIZddpUxZdBYuNHbtaS1Hljc= | ||||
| github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= | ||||
| github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= | ||||
| github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw= | ||||
| github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM= | ||||
| github.com/swaggo/files/v2 v2.0.2 h1:Bq4tgS/yxLB/3nwOMcul5oLEUKa877Ykgz3CJMVbQKU= | ||||
| github.com/swaggo/files/v2 v2.0.2/go.mod h1:TVqetIzZsO9OhHX1Am9sRf9LdrFZqoK49N37KON/jr0= | ||||
| github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY= | ||||
| github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw= | ||||
| github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= | ||||
| @@ -171,6 +215,10 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS | ||||
| github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= | ||||
| github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= | ||||
| github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= | ||||
| github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= | ||||
| github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= | ||||
| github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= | ||||
| github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= | ||||
| github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= | ||||
| go.mongodb.org/mongo-driver v1.14.0 h1:P98w8egYRjYe3XDjxhYJagTokP/H6HzlsnojRgZRd80= | ||||
| go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c= | ||||
| @@ -188,21 +236,29 @@ go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= | ||||
| go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= | ||||
| go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= | ||||
| go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= | ||||
| go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= | ||||
| go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= | ||||
| golang.org/x/arch v0.21.0 h1:iTC9o7+wP6cPWpDWkivCvQFGAHDQ59SrSxsLPcnkArw= | ||||
| golang.org/x/arch v0.21.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= | ||||
| golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= | ||||
| golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= | ||||
| golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= | ||||
| golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= | ||||
| golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= | ||||
| golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= | ||||
| golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= | ||||
| golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= | ||||
| golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= | ||||
| golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= | ||||
| golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= | ||||
| golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= | ||||
| golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= | ||||
| golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= | ||||
| golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= | ||||
| golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= | ||||
| golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= | ||||
| golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= | ||||
| golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= | ||||
| golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||
| golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||
| golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= | ||||
| @@ -216,6 +272,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||
| golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= | ||||
| golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= | ||||
| golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= | ||||
| golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= | ||||
| golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= | ||||
| golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= | ||||
| golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= | ||||
| @@ -225,11 +283,17 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= | ||||
| golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= | ||||
| golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= | ||||
| golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= | ||||
| golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= | ||||
| golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= | ||||
| golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= | ||||
| golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= | ||||
| golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= | ||||
| golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= | ||||
| golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= | ||||
| golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= | ||||
| golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= | ||||
| golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= | ||||
| golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= | ||||
| golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | ||||
| google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= | ||||
| google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= | ||||
|   | ||||
| @@ -27,69 +27,63 @@ import ( | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/service" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/webhook" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/domain/audit" | ||||
| 	domain_device "git.huangwc.com/pig/pig-farm-controller/internal/domain/device" | ||||
| 	domain_notify "git.huangwc.com/pig/pig-farm-controller/internal/domain/notify" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/domain/task" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/domain/scheduler" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/domain/token" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/config" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" | ||||
|  | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"github.com/labstack/echo/v4" | ||||
| 	"github.com/labstack/echo/v4/middleware" | ||||
| ) | ||||
|  | ||||
| // API 结构体定义了 HTTP 服务器及其依赖 | ||||
| type API struct { | ||||
| 	engine              *gin.Engine                    // Gin 引擎实例,用于处理 HTTP 请求 | ||||
| 	logger              *logs.Logger                   // 日志记录器,用于输出日志信息 | ||||
| 	userRepo            repository.UserRepository      // 用户数据仓库接口,用于用户数据操作 | ||||
| 	tokenService        token.TokenService             // Token 服务接口,用于 JWT token 的生成和解析 | ||||
| 	auditService        audit.Service                  // 审计服务,用于记录用户操作 | ||||
| 	httpServer          *http.Server                   // 标准库的 HTTP 服务器实例,用于启动和停止服务 | ||||
| 	config              config.ServerConfig            // API 服务器的配置,使用 infra/config 包中的 ServerConfig | ||||
| 	userController      *user.Controller               // 用户控制器实例 | ||||
| 	deviceController    *device.Controller             // 设备控制器实例 | ||||
| 	planController      *plan.Controller               // 计划控制器实例 | ||||
| 	pigFarmController   *management.PigFarmController  // 猪场管理控制器实例 | ||||
| 	pigBatchController  *management.PigBatchController // 猪群控制器实例 | ||||
| 	monitorController   *monitor.Controller            // 数据监控控制器实例 | ||||
| 	listenHandler       webhook.ListenHandler          // 设备上行事件监听器 | ||||
| 	analysisTaskManager *task.AnalysisPlanTaskManager  // 计划触发器管理器实例 | ||||
| 	echo                *echo.Echo                         // Echo 引擎实例,用于处理 HTTP 请求 | ||||
| 	logger              *logs.Logger                       // 日志记录器,用于输出日志信息 | ||||
| 	userRepo            repository.UserRepository          // 用户数据仓库接口,用于用户数据操作 | ||||
| 	tokenService        token.Service                      // Token 服务接口,用于 JWT token 的生成和解析 | ||||
| 	auditService        audit.Service                      // 审计服务,用于记录用户操作 | ||||
| 	httpServer          *http.Server                       // 标准库的 HTTP 服务器实例,用于启动和停止服务 | ||||
| 	config              config.ServerConfig                // API 服务器的配置,使用 infra/config 包中的 ServerConfig | ||||
| 	userController      *user.Controller                   // 用户控制器实例 | ||||
| 	deviceController    *device.Controller                 // 设备控制器实例 | ||||
| 	planController      *plan.Controller                   // 计划控制器实例 | ||||
| 	pigFarmController   *management.PigFarmController      // 猪场管理控制器实例 | ||||
| 	pigBatchController  *management.PigBatchController     // 猪群控制器实例 | ||||
| 	monitorController   *monitor.Controller                // 数据监控控制器实例 | ||||
| 	listenHandler       webhook.ListenHandler              // 设备上行事件监听器 | ||||
| 	analysisTaskManager *scheduler.AnalysisPlanTaskManager // 计划触发器管理器实例 | ||||
| } | ||||
|  | ||||
| // NewAPI 创建并返回一个新的 API 实例 | ||||
| // 负责初始化 Gin 引擎、设置全局中间件,并注入所有必要的依赖。 | ||||
| // 负责初始化 Echo 引擎、设置全局中间件,并注入所有必要的依赖。 | ||||
| func NewAPI(cfg config.ServerConfig, | ||||
| 	logger *logs.Logger, | ||||
| 	userRepo repository.UserRepository, | ||||
| 	deviceRepository repository.DeviceRepository, | ||||
| 	areaControllerRepository repository.AreaControllerRepository, | ||||
| 	deviceTemplateRepository repository.DeviceTemplateRepository, | ||||
| 	planRepository repository.PlanRepository, | ||||
| 	pigFarmService service.PigFarmService, | ||||
| 	pigBatchService service.PigBatchService, | ||||
| 	monitorService service.MonitorService, | ||||
| 	tokenService token.TokenService, | ||||
| 	deviceService service.DeviceService, | ||||
| 	planService service.PlanService, | ||||
| 	userService service.UserService, | ||||
| 	tokenService token.Service, | ||||
| 	auditService audit.Service, | ||||
| 	notifyService domain_notify.Service, | ||||
| 	deviceService domain_device.Service, | ||||
| 	listenHandler webhook.ListenHandler, | ||||
| 	analysisTaskManager *task.AnalysisPlanTaskManager) *API { | ||||
| 	// 设置 Gin 模式,例如 gin.ReleaseMode (生产模式) 或 gin.DebugMode (开发模式) | ||||
| 	// 从配置中获取 Gin 模式 | ||||
| 	gin.SetMode(cfg.Mode) | ||||
| ) *API { | ||||
| 	// 使用 echo.New() 创建一个 Echo 引擎实例 | ||||
| 	e := echo.New() | ||||
|  | ||||
| 	// 使用 gin.New() 创建一个 Gin 引擎实例,而不是 gin.Default() | ||||
| 	// 这样可以手动添加所需的中间件,避免 gin.Default() 默认包含的 Logger 和 Recovery 中间件 | ||||
| 	engine := gin.New() | ||||
| 	// 根据配置设置 Echo 的调试模式 | ||||
| 	e.Debug = cfg.Mode == "debug" | ||||
|  | ||||
| 	// 添加 Gin Recovery 中间件,用于捕获 panic 并恢复,防止服务崩溃 | ||||
| 	// gin.Logger() 已移除,因为我们使用自定义的 logger | ||||
| 	engine.Use(gin.Recovery()) | ||||
| 	// 添加 Echo Recovery 中间件,用于捕获 panic 并恢复,防止服务崩溃 | ||||
| 	// Echo 的 Logger 中间件默认会记录请求信息,如果需要自定义,可以替换 | ||||
| 	e.Use(middleware.Recover()) | ||||
|  | ||||
| 	// 初始化 API 结构体 | ||||
| 	api := &API{ | ||||
| 		engine:        engine, | ||||
| 		echo:          e, | ||||
| 		logger:        logger, | ||||
| 		userRepo:      userRepo, | ||||
| 		tokenService:  tokenService, | ||||
| @@ -97,11 +91,11 @@ func NewAPI(cfg config.ServerConfig, | ||||
| 		config:        cfg, | ||||
| 		listenHandler: listenHandler, | ||||
| 		// 在 NewAPI 中初始化用户控制器,并将其作为 API 结构体的成员 | ||||
| 		userController: user.NewController(userRepo, monitorService, logger, tokenService, notifyService), | ||||
| 		userController: user.NewController(userService, logger), | ||||
| 		// 在 NewAPI 中初始化设备控制器,并将其作为 API 结构体的成员 | ||||
| 		deviceController: device.NewController(deviceRepository, areaControllerRepository, deviceTemplateRepository, deviceService, logger), | ||||
| 		deviceController: device.NewController(deviceService, logger), | ||||
| 		// 在 NewAPI 中初始化计划控制器,并将其作为 API 结构体的成员 | ||||
| 		planController: plan.NewController(logger, planRepository, analysisTaskManager), | ||||
| 		planController: plan.NewController(logger, planService), | ||||
| 		// 在 NewAPI 中初始化猪场管理控制器 | ||||
| 		pigFarmController: management.NewPigFarmController(logger, pigFarmService), | ||||
| 		// 在 NewAPI 中初始化猪群控制器 | ||||
| @@ -123,8 +117,8 @@ func (a *API) Start() { | ||||
|  | ||||
| 	// 初始化标准库的 http.Server 实例 | ||||
| 	a.httpServer = &http.Server{ | ||||
| 		Addr:    addr,     // 服务器监听的地址从配置中获取 | ||||
| 		Handler: a.engine, // 将 Gin 引擎作为 HTTP 请求的处理程序 | ||||
| 		Addr:    addr,   // 服务器监听的地址从配置中获取 | ||||
| 		Handler: a.echo, // 将 Echo 引擎作为 HTTP 请求的处理程序 | ||||
| 	} | ||||
|  | ||||
| 	// 在独立的 goroutine 中启动服务器 | ||||
|   | ||||
| @@ -1,65 +1,65 @@ | ||||
| package api | ||||
|  | ||||
| import ( | ||||
| 	"net/http" | ||||
| 	"net/http/pprof" | ||||
|  | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/middleware" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	swaggerFiles "github.com/swaggo/files" | ||||
| 	ginSwagger "github.com/swaggo/gin-swagger" | ||||
| 	"github.com/labstack/echo/v4" | ||||
| 	echoSwagger "github.com/swaggo/echo-swagger" | ||||
| ) | ||||
|  | ||||
| // setupRoutes 设置所有 API 路由 | ||||
| // 在此方法中,使用已初始化的控制器实例将其路由注册到 Gin 引擎中。 | ||||
| // 在此方法中,使用已初始化的控制器实例将其路由注册到 Echo 引擎中。 | ||||
| func (a *API) setupRoutes() { | ||||
| 	a.logger.Info("开始初始化所有 API 路由") | ||||
|  | ||||
| 	// --- Public Routes --- | ||||
| 	// 这些路由不需要身份验证 | ||||
|  | ||||
| 	// 用户注册和登录 | ||||
| 	a.engine.POST("/api/v1/users", a.userController.CreateUser)  // 注册新用户 | ||||
| 	a.engine.POST("/api/v1/users/login", a.userController.Login) // 用户登录 | ||||
| 	a.logger.Info("公开接口注册成功:用户注册、登录") | ||||
| 	a.echo.POST("/api/v1/users", a.userController.CreateUser)  // 注册新用户 | ||||
| 	a.echo.POST("/api/v1/users/login", a.userController.Login) // 用户登录 | ||||
| 	a.logger.Debug("公开接口注册成功:用户注册、登录") | ||||
|  | ||||
| 	// 注册 pprof 路由 | ||||
| 	pprofGroup := a.engine.Group("/debug/pprof") | ||||
| 	pprofGroup := a.echo.Group("/debug/pprof") | ||||
| 	{ | ||||
| 		pprofGroup.GET("/", gin.WrapF(pprof.Index))                   // pprof 索引页 | ||||
| 		pprofGroup.GET("/cmdline", gin.WrapF(pprof.Cmdline))          // pprof 命令行参数 | ||||
| 		pprofGroup.GET("/profile", gin.WrapF(pprof.Profile))          // pprof CPU profile | ||||
| 		pprofGroup.POST("/symbol", gin.WrapF(pprof.Symbol))           // pprof 符号查找 (POST) | ||||
| 		pprofGroup.GET("/symbol", gin.WrapF(pprof.Symbol))            // pprof 符号查找 (GET) | ||||
| 		pprofGroup.GET("/trace", gin.WrapF(pprof.Trace))              // pprof 跟踪 | ||||
| 		pprofGroup.GET("/allocs", gin.WrapH(pprof.Handler("allocs"))) // pprof 内存分配 | ||||
| 		pprofGroup.GET("/block", gin.WrapH(pprof.Handler("block")))   // pprof 阻塞 | ||||
| 		pprofGroup.GET("/goroutine", gin.WrapH(pprof.Handler("goroutine"))) | ||||
| 		pprofGroup.GET("/heap", gin.WrapH(pprof.Handler("heap")))   // pprof 堆内存 | ||||
| 		pprofGroup.GET("/mutex", gin.WrapH(pprof.Handler("mutex"))) // pprof 互斥锁 | ||||
| 		pprofGroup.GET("/threadcreate", gin.WrapH(pprof.Handler("threadcreate"))) | ||||
| 		pprofGroup.GET("/", echo.WrapHandler(http.HandlerFunc(pprof.Index)))          // pprof 索引页 | ||||
| 		pprofGroup.GET("/cmdline", echo.WrapHandler(http.HandlerFunc(pprof.Cmdline))) // pprof 命令行参数 | ||||
| 		pprofGroup.GET("/profile", echo.WrapHandler(http.HandlerFunc(pprof.Profile))) // pprof CPU profile | ||||
| 		pprofGroup.POST("/symbol", echo.WrapHandler(http.HandlerFunc(pprof.Symbol)))  // pprof 符号查找 (POST) | ||||
| 		pprofGroup.GET("/symbol", echo.WrapHandler(http.HandlerFunc(pprof.Symbol)))   // pprof 符号查找 (GET) | ||||
| 		pprofGroup.GET("/trace", echo.WrapHandler(http.HandlerFunc(pprof.Trace)))     // pprof 跟踪 | ||||
| 		pprofGroup.GET("/allocs", echo.WrapHandler(pprof.Handler("allocs")))          // pprof 内存分配 | ||||
| 		pprofGroup.GET("/block", echo.WrapHandler(pprof.Handler("block")))            // pprof 阻塞 | ||||
| 		pprofGroup.GET("/goroutine", echo.WrapHandler(pprof.Handler("goroutine"))) | ||||
| 		pprofGroup.GET("/heap", echo.WrapHandler(pprof.Handler("heap")))   // pprof 堆内存 | ||||
| 		pprofGroup.GET("/mutex", echo.WrapHandler(pprof.Handler("mutex"))) // pprof 互斥锁 | ||||
| 		pprofGroup.GET("/threadcreate", echo.WrapHandler(pprof.Handler("threadcreate"))) | ||||
| 	} | ||||
| 	a.logger.Info("pprof 接口注册成功") | ||||
| 	a.logger.Debug("pprof 接口注册成功") | ||||
|  | ||||
| 	// 上行事件监听路由 | ||||
| 	a.engine.POST("/upstream", gin.WrapH(a.listenHandler.Handler())) // 处理设备上行事件 | ||||
| 	a.logger.Info("上行事件监听接口注册成功") | ||||
| 	a.echo.POST("/upstream", echo.WrapHandler(a.listenHandler.Handler())) // 处理设备上行事件 | ||||
| 	a.logger.Debug("上行事件监听接口注册成功") | ||||
|  | ||||
| 	// 添加 Swagger UI 路由, Swagger UI可在 /swagger/index.html 上找到 | ||||
| 	a.engine.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) // Swagger UI 接口 | ||||
| 	a.logger.Info("Swagger UI 接口注册成功") | ||||
| 	a.echo.GET("/swagger/*any", echoSwagger.WrapHandler) // Swagger UI 接口 | ||||
| 	a.logger.Debug("Swagger UI 接口注册成功") | ||||
|  | ||||
| 	// --- Authenticated Routes --- | ||||
| 	// 所有在此注册的路由都需要通过 JWT 身份验证 | ||||
| 	authGroup := a.engine.Group("/api/v1") | ||||
| 	authGroup := a.echo.Group("/api/v1") | ||||
| 	authGroup.Use(middleware.AuthMiddleware(a.tokenService, a.userRepo)) // 1. 身份认证中间件 | ||||
| 	authGroup.Use(middleware.AuditLogMiddleware(a.auditService))         // 2. 审计日志中间件 | ||||
| 	{ | ||||
| 		// 用户相关路由组 | ||||
| 		userGroup := authGroup.Group("/users") | ||||
| 		{ | ||||
| 			userGroup.GET("/:id/history", a.userController.ListUserHistory) // 获取用户操作历史 | ||||
| 			userGroup.POST("/:id/notifications/test", a.userController.SendTestNotification) | ||||
| 		} | ||||
| 		a.logger.Info("用户相关接口注册成功 (需要认证和审计)") | ||||
| 		a.logger.Debug("用户相关接口注册成功 (需要认证和审计)") | ||||
|  | ||||
| 		// 设备相关路由组 | ||||
| 		deviceGroup := authGroup.Group("/devices") | ||||
| @@ -71,7 +71,7 @@ func (a *API) setupRoutes() { | ||||
| 			deviceGroup.DELETE("/:id", a.deviceController.DeleteDevice)               // 删除设备 | ||||
| 			deviceGroup.POST("/manual-control/:id", a.deviceController.ManualControl) // 手动控制设备 | ||||
| 		} | ||||
| 		a.logger.Info("设备相关接口注册成功 (需要认证和审计)") | ||||
| 		a.logger.Debug("设备相关接口注册成功 (需要认证和审计)") | ||||
|  | ||||
| 		// 区域主控相关路由组 | ||||
| 		areaControllerGroup := authGroup.Group("/area-controllers") | ||||
| @@ -82,7 +82,7 @@ func (a *API) setupRoutes() { | ||||
| 			areaControllerGroup.PUT("/:id", a.deviceController.UpdateAreaController)    // 更新区域主控 | ||||
| 			areaControllerGroup.DELETE("/:id", a.deviceController.DeleteAreaController) // 删除区域主控 | ||||
| 		} | ||||
| 		a.logger.Info("区域主控相关接口注册成功 (需要认证和审计)") | ||||
| 		a.logger.Debug("区域主控相关接口注册成功 (需要认证和审计)") | ||||
|  | ||||
| 		// 设备模板相关路由组 | ||||
| 		deviceTemplateGroup := authGroup.Group("/device-templates") | ||||
| @@ -93,7 +93,7 @@ func (a *API) setupRoutes() { | ||||
| 			deviceTemplateGroup.PUT("/:id", a.deviceController.UpdateDeviceTemplate)    // 更新设备模板 | ||||
| 			deviceTemplateGroup.DELETE("/:id", a.deviceController.DeleteDeviceTemplate) // 删除设备模板 | ||||
| 		} | ||||
| 		a.logger.Info("设备模板相关接口注册成功 (需要认证和审计)") | ||||
| 		a.logger.Debug("设备模板相关接口注册成功 (需要认证和审计)") | ||||
|  | ||||
| 		// 计划相关路由组 | ||||
| 		planGroup := authGroup.Group("/plans") | ||||
| @@ -106,7 +106,7 @@ func (a *API) setupRoutes() { | ||||
| 			planGroup.POST("/:id/start", a.planController.StartPlan) // 启动计划 | ||||
| 			planGroup.POST("/:id/stop", a.planController.StopPlan)   // 停止计划 | ||||
| 		} | ||||
| 		a.logger.Info("计划相关接口注册成功 (需要认证和审计)") | ||||
| 		a.logger.Debug("计划相关接口注册成功 (需要认证和审计)") | ||||
|  | ||||
| 		// 猪舍相关路由组 | ||||
| 		pigHouseGroup := authGroup.Group("/pig-houses") | ||||
| @@ -117,7 +117,7 @@ func (a *API) setupRoutes() { | ||||
| 			pigHouseGroup.PUT("/:id", a.pigFarmController.UpdatePigHouse)    // 更新猪舍 | ||||
| 			pigHouseGroup.DELETE("/:id", a.pigFarmController.DeletePigHouse) // 删除猪舍 | ||||
| 		} | ||||
| 		a.logger.Info("猪舍相关接口注册成功 (需要认证和审计)") | ||||
| 		a.logger.Debug("猪舍相关接口注册成功 (需要认证和审计)") | ||||
|  | ||||
| 		// 猪圈相关路由组 | ||||
| 		penGroup := authGroup.Group("/pens") | ||||
| @@ -129,7 +129,7 @@ func (a *API) setupRoutes() { | ||||
| 			penGroup.DELETE("/:id", a.pigFarmController.DeletePen)           // 删除猪圈 | ||||
| 			penGroup.PUT("/:id/status", a.pigFarmController.UpdatePenStatus) // 更新猪圈状态 | ||||
| 		} | ||||
| 		a.logger.Info("猪圈相关接口注册成功 (需要认证和审计)") | ||||
| 		a.logger.Debug("猪圈相关接口注册成功 (需要认证和审计)") | ||||
|  | ||||
| 		// 猪群相关路由组 | ||||
| 		pigBatchGroup := authGroup.Group("/pig-batches") | ||||
| @@ -154,7 +154,7 @@ func (a *API) setupRoutes() { | ||||
| 			pigBatchGroup.POST("/record-death/:id", a.pigBatchController.RecordDeath)                                     // 记录正常猪只死亡事件 | ||||
| 			pigBatchGroup.POST("/record-cull/:id", a.pigBatchController.RecordCull)                                       // 记录正常猪只淘汰事件 | ||||
| 		} | ||||
| 		a.logger.Info("猪群相关接口注册成功 (需要认证和审计)") | ||||
| 		a.logger.Debug("猪群相关接口注册成功 (需要认证和审计)") | ||||
|  | ||||
| 		// 数据监控相关路由组 | ||||
| 		monitorGroup := authGroup.Group("/monitor") | ||||
| @@ -176,7 +176,10 @@ func (a *API) setupRoutes() { | ||||
| 			monitorGroup.GET("/pig-sick-logs", a.monitorController.ListPigSickLogs) | ||||
| 			monitorGroup.GET("/pig-purchases", a.monitorController.ListPigPurchases) | ||||
| 			monitorGroup.GET("/pig-sales", a.monitorController.ListPigSales) | ||||
| 			monitorGroup.GET("/notifications", a.monitorController.ListNotifications) | ||||
| 		} | ||||
| 		a.logger.Info("数据监控相关接口注册成功 (需要认证和审计)") | ||||
| 		a.logger.Debug("数据监控相关接口注册成功 (需要认证和审计)") | ||||
| 	} | ||||
|  | ||||
| 	a.logger.Debug("所有接口注册成功") | ||||
| } | ||||
|   | ||||
| @@ -4,21 +4,21 @@ import ( | ||||
| 	"errors" | ||||
|  | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"github.com/labstack/echo/v4" | ||||
| ) | ||||
|  | ||||
| var ( | ||||
| 	// ErrUserNotFoundInContext 表示在 gin.Context 中未找到用户信息。 | ||||
| 	// ErrUserNotFoundInContext 表示在 context 中未找到用户信息。 | ||||
| 	ErrUserNotFoundInContext = errors.New("context中未找到用户信息") | ||||
| 	// ErrInvalidUserType 表示从 gin.Context 中获取的用户信息类型不正确。 | ||||
| 	// ErrInvalidUserType 表示从 context 中获取的用户信息类型不正确。 | ||||
| 	ErrInvalidUserType = errors.New("context中用户信息类型不正确") | ||||
| ) | ||||
|  | ||||
| // GetOperatorIDFromContext 从 gin.Context 中提取操作者ID。 | ||||
| // GetOperatorIDFromContext 从 echo.Context 中提取操作者ID。 | ||||
| // 假设操作者ID是由 AuthMiddleware 存储到 context 中的 *models.User 对象的 ID 字段。 | ||||
| func GetOperatorIDFromContext(c *gin.Context) (uint, error) { | ||||
| 	userVal, exists := c.Get(models.ContextUserKey.String()) | ||||
| 	if !exists { | ||||
| func GetOperatorIDFromContext(c echo.Context) (uint, error) { | ||||
| 	userVal := c.Get(models.ContextUserKey.String()) | ||||
| 	if userVal == nil { | ||||
| 		return 0, ErrUserNotFoundInContext | ||||
| 	} | ||||
|  | ||||
| @@ -30,11 +30,11 @@ func GetOperatorIDFromContext(c *gin.Context) (uint, error) { | ||||
| 	return user.ID, nil | ||||
| } | ||||
|  | ||||
| // GetOperatorFromContext 从 gin.Context 中提取操作者。 | ||||
| // 假设操作者是由 AuthMiddleware 存储到 context 中的 *models.User 对象的  字段。 | ||||
| func GetOperatorFromContext(c *gin.Context) (*models.User, error) { | ||||
| 	userVal, exists := c.Get(models.ContextUserKey.String()) | ||||
| 	if !exists { | ||||
| // GetOperatorFromContext 从 echo.Context 中提取操作者。 | ||||
| // 假设操作者是由 AuthMiddleware 存储到 context 中的 *models.User 对象的字段。 | ||||
| func GetOperatorFromContext(c echo.Context) (*models.User, error) { | ||||
| 	userVal := c.Get(models.ContextUserKey.String()) | ||||
| 	if userVal == nil { | ||||
| 		return nil, ErrUserNotFoundInContext | ||||
| 	} | ||||
|  | ||||
|   | ||||
| @@ -1,44 +1,30 @@ | ||||
| package device | ||||
|  | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	"errors" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
|  | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/controller" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/dto" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/domain/device" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/service" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"github.com/labstack/echo/v4" | ||||
| 	"gorm.io/gorm" | ||||
| ) | ||||
|  | ||||
| // Controller 设备控制器,封装了所有与设备和区域主控相关的业务逻辑 | ||||
| type Controller struct { | ||||
| 	deviceRepo         repository.DeviceRepository | ||||
| 	areaControllerRepo repository.AreaControllerRepository | ||||
| 	deviceTemplateRepo repository.DeviceTemplateRepository | ||||
| 	deviceService      device.Service | ||||
| 	logger             *logs.Logger | ||||
| 	deviceService service.DeviceService | ||||
| 	logger        *logs.Logger | ||||
| } | ||||
|  | ||||
| // NewController 创建一个新的设备控制器实例 | ||||
| func NewController( | ||||
| 	deviceRepo repository.DeviceRepository, | ||||
| 	areaControllerRepo repository.AreaControllerRepository, | ||||
| 	deviceTemplateRepo repository.DeviceTemplateRepository, | ||||
| 	deviceService device.Service, | ||||
| 	deviceService service.DeviceService, | ||||
| 	logger *logs.Logger, | ||||
| ) *Controller { | ||||
| 	return &Controller{ | ||||
| 		deviceRepo:         deviceRepo, | ||||
| 		areaControllerRepo: areaControllerRepo, | ||||
| 		deviceTemplateRepo: deviceTemplateRepo, | ||||
| 		deviceService:      deviceService, | ||||
| 		logger:             logger, | ||||
| 		deviceService: deviceService, | ||||
| 		logger:        logger, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @@ -54,58 +40,22 @@ func NewController( | ||||
| // @Param        device body dto.CreateDeviceRequest true "设备信息" | ||||
| // @Success      200 {object} controller.Response{data=dto.DeviceResponse} | ||||
| // @Router       /api/v1/devices [post] | ||||
| func (c *Controller) CreateDevice(ctx *gin.Context) { | ||||
| func (c *Controller) CreateDevice(ctx echo.Context) error { | ||||
| 	const actionType = "创建设备" | ||||
| 	var req dto.CreateDeviceRequest | ||||
| 	if err := ctx.ShouldBindJSON(&req); err != nil { | ||||
| 	if err := ctx.Bind(&req); err != nil { | ||||
| 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req) | ||||
| 	} | ||||
|  | ||||
| 	propertiesJSON, err := json.Marshal(req.Properties) | ||||
| 	resp, err := c.deviceService.CreateDevice(&req) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 序列化属性失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "属性字段格式错误", actionType, "属性序列化失败", req.Properties) | ||||
| 		return | ||||
| 		c.logger.Errorf("%s: 服务层创建失败: %v", actionType, err) | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "创建设备失败: "+err.Error(), actionType, "服务层创建失败", req) | ||||
| 	} | ||||
|  | ||||
| 	device := &models.Device{ | ||||
| 		Name:             req.Name, | ||||
| 		DeviceTemplateID: req.DeviceTemplateID, | ||||
| 		AreaControllerID: req.AreaControllerID, | ||||
| 		Location:         req.Location, | ||||
| 		Properties:       propertiesJSON, | ||||
| 	} | ||||
|  | ||||
| 	if err := device.SelfCheck(); err != nil { | ||||
| 		c.logger.Errorf("%s: 设备属性自检失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "设备属性不符合要求: "+err.Error(), actionType, "设备属性自检失败", device) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if err := c.deviceRepo.Create(device); err != nil { | ||||
| 		c.logger.Errorf("%s: 数据库操作失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "创建设备失败: "+err.Error(), actionType, "数据库创建失败", device) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	createdDevice, err := c.deviceRepo.FindByID(device.ID) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 重新加载创建的设备失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "设备创建成功,但重新加载设备失败", actionType, "重新加载设备失败", device) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	resp, err := dto.NewDeviceResponse(createdDevice) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 序列化响应失败: %v, Device: %+v", actionType, err, createdDevice) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "设备创建成功,但响应生成失败", actionType, "响应序列化失败", createdDevice) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	c.logger.Infof("%s: 设备创建成功, ID: %d", actionType, device.ID) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "设备创建成功", resp, actionType, "设备创建成功", resp) | ||||
| 	c.logger.Infof("%s: 设备创建成功, ID: %d", actionType, resp.ID) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "设备创建成功", resp, actionType, "设备创建成功", resp) | ||||
| } | ||||
|  | ||||
| // GetDevice godoc | ||||
| @@ -117,42 +67,22 @@ func (c *Controller) CreateDevice(ctx *gin.Context) { | ||||
| // @Param        id path string true "设备ID" | ||||
| // @Success      200 {object} controller.Response{data=dto.DeviceResponse} | ||||
| // @Router       /api/v1/devices/{id} [get] | ||||
| func (c *Controller) GetDevice(ctx *gin.Context) { | ||||
| func (c *Controller) GetDevice(ctx echo.Context) error { | ||||
| 	const actionType = "获取设备" | ||||
| 	deviceID := ctx.Param("id") | ||||
|  | ||||
| 	if deviceID == "" { | ||||
| 		c.logger.Errorf("%s: 设备ID为空", actionType) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "设备ID不能为空", actionType, "设备ID为空", nil) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	device, err := c.deviceRepo.FindByIDString(deviceID) | ||||
| 	resp, err := c.deviceService.GetDevice(deviceID) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, gorm.ErrRecordNotFound) { | ||||
| 			c.logger.Warnf("%s: 设备不存在, ID: %s", actionType, deviceID) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备未找到", actionType, "设备不存在", deviceID) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备未找到", actionType, "设备不存在", deviceID) | ||||
| 		} | ||||
| 		if strings.Contains(err.Error(), "无效的设备ID格式") { | ||||
| 			c.logger.Errorf("%s: 设备ID格式错误: %v, ID: %s", actionType, err, deviceID) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, err.Error(), actionType, "设备ID格式错误", deviceID) | ||||
| 			return | ||||
| 		} | ||||
| 		c.logger.Errorf("%s: 数据库查询失败: %v, ID: %s", actionType, err, deviceID) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取设备信息失败: "+err.Error(), actionType, "数据库查询失败", deviceID) | ||||
| 		return | ||||
| 		c.logger.Errorf("%s: 服务层获取失败: %v, ID: %s", actionType, err, deviceID) | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取设备信息失败: "+err.Error(), actionType, "服务层获取失败", deviceID) | ||||
| 	} | ||||
|  | ||||
| 	resp, err := dto.NewDeviceResponse(device) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 序列化响应失败: %v, Device: %+v", actionType, err, device) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取设备信息失败: 内部数据格式错误", actionType, "响应序列化失败", device) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	c.logger.Infof("%s: 获取设备信息成功, ID: %d", actionType, device.ID) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取设备信息成功", resp, actionType, "获取设备信息成功", resp) | ||||
| 	c.logger.Infof("%s: 获取设备信息成功, ID: %d", actionType, resp.ID) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取设备信息成功", resp, actionType, "获取设备信息成功", resp) | ||||
| } | ||||
|  | ||||
| // ListDevices godoc | ||||
| @@ -163,24 +93,16 @@ func (c *Controller) GetDevice(ctx *gin.Context) { | ||||
| // @Produce      json | ||||
| // @Success      200 {object} controller.Response{data=[]dto.DeviceResponse} | ||||
| // @Router       /api/v1/devices [get] | ||||
| func (c *Controller) ListDevices(ctx *gin.Context) { | ||||
| func (c *Controller) ListDevices(ctx echo.Context) error { | ||||
| 	const actionType = "获取设备列表" | ||||
| 	devices, err := c.deviceRepo.ListAll() | ||||
| 	resp, err := c.deviceService.ListDevices() | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 数据库查询失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取设备列表失败: "+err.Error(), actionType, "数据库查询失败", nil) | ||||
| 		return | ||||
| 		c.logger.Errorf("%s: 服务层获取列表失败: %v", actionType, err) | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取设备列表失败: "+err.Error(), actionType, "服务层获取列表失败", nil) | ||||
| 	} | ||||
|  | ||||
| 	resp, err := dto.NewListDeviceResponse(devices) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 序列化响应失败: %v, Devices: %+v", actionType, err, devices) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取设备列表失败: 内部数据格式错误", actionType, "响应序列化失败", devices) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	c.logger.Infof("%s: 获取设备列表成功, 数量: %d", actionType, len(devices)) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取设备列表成功", resp, actionType, "获取设备列表成功", resp) | ||||
| 	c.logger.Infof("%s: 获取设备列表成功, 数量: %d", actionType, len(resp)) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取设备列表成功", resp, actionType, "获取设备列表成功", resp) | ||||
| } | ||||
|  | ||||
| // UpdateDevice godoc | ||||
| @@ -194,75 +116,28 @@ func (c *Controller) ListDevices(ctx *gin.Context) { | ||||
| // @Param        device body dto.UpdateDeviceRequest true "要更新的设备信息" | ||||
| // @Success      200 {object} controller.Response{data=dto.DeviceResponse} | ||||
| // @Router       /api/v1/devices/{id} [put] | ||||
| func (c *Controller) UpdateDevice(ctx *gin.Context) { | ||||
| func (c *Controller) UpdateDevice(ctx echo.Context) error { | ||||
| 	const actionType = "更新设备" | ||||
| 	deviceID := ctx.Param("id") | ||||
|  | ||||
| 	existingDevice, err := c.deviceRepo.FindByIDString(deviceID) | ||||
| 	var req dto.UpdateDeviceRequest | ||||
| 	if err := ctx.Bind(&req); err != nil { | ||||
| 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req) | ||||
| 	} | ||||
|  | ||||
| 	resp, err := c.deviceService.UpdateDevice(deviceID, &req) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, gorm.ErrRecordNotFound) { | ||||
| 			c.logger.Warnf("%s: 设备不存在, ID: %s", actionType, deviceID) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备未找到", actionType, "设备不存在", deviceID) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备未找到", actionType, "设备不存在", deviceID) | ||||
| 		} | ||||
| 		if strings.Contains(err.Error(), "无效的设备ID格式") { | ||||
| 			c.logger.Errorf("%s: 设备ID格式错误: %v, ID: %s", actionType, err, deviceID) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, err.Error(), actionType, "设备ID格式错误", deviceID) | ||||
| 			return | ||||
| 		} | ||||
| 		c.logger.Errorf("%s: 数据库查询失败: %v, ID: %s", actionType, err, deviceID) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新设备失败: "+err.Error(), actionType, "数据库查询失败", deviceID) | ||||
| 		return | ||||
| 		c.logger.Errorf("%s: 服务层更新失败: %v, ID: %s", actionType, err, deviceID) | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新设备失败: "+err.Error(), actionType, "服务层更新失败", deviceID) | ||||
| 	} | ||||
|  | ||||
| 	var req dto.UpdateDeviceRequest | ||||
| 	if err := ctx.ShouldBindJSON(&req); err != nil { | ||||
| 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	propertiesJSON, err := json.Marshal(req.Properties) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 序列化属性失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "属性字段格式错误", actionType, "属性序列化失败", req.Properties) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	existingDevice.Name = req.Name | ||||
| 	existingDevice.DeviceTemplateID = req.DeviceTemplateID | ||||
| 	existingDevice.AreaControllerID = req.AreaControllerID | ||||
| 	existingDevice.Location = req.Location | ||||
| 	existingDevice.Properties = propertiesJSON | ||||
|  | ||||
| 	if err := existingDevice.SelfCheck(); err != nil { | ||||
| 		c.logger.Errorf("%s: 设备属性自检失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "设备属性不符合要求: "+err.Error(), actionType, "设备属性自检失败", existingDevice) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if err := c.deviceRepo.Update(existingDevice); err != nil { | ||||
| 		c.logger.Errorf("%s: 数据库更新失败: %v, Device: %+v", actionType, err, existingDevice) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新设备失败: "+err.Error(), actionType, "数据库更新失败", existingDevice) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	updatedDevice, err := c.deviceRepo.FindByID(existingDevice.ID) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 重新加载更新的设备失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "设备更新成功,但重新加载设备失败", actionType, "重新加载设备失败", existingDevice) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	resp, err := dto.NewDeviceResponse(updatedDevice) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 序列化响应失败: %v, Device: %+v", actionType, err, updatedDevice) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "设备更新成功,但响应生成失败", actionType, "响应序列化失败", updatedDevice) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	c.logger.Infof("%s: 设备更新成功, ID: %d", actionType, existingDevice.ID) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "设备更新成功", resp, actionType, "设备更新成功", resp) | ||||
| 	c.logger.Infof("%s: 设备更新成功, ID: %d", actionType, resp.ID) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "设备更新成功", resp, actionType, "设备更新成功", resp) | ||||
| } | ||||
|  | ||||
| // DeleteDevice godoc | ||||
| @@ -274,37 +149,21 @@ func (c *Controller) UpdateDevice(ctx *gin.Context) { | ||||
| // @Param        id path string true "设备ID" | ||||
| // @Success      200 {object} controller.Response | ||||
| // @Router       /api/v1/devices/{id} [delete] | ||||
| func (c *Controller) DeleteDevice(ctx *gin.Context) { | ||||
| func (c *Controller) DeleteDevice(ctx echo.Context) error { | ||||
| 	const actionType = "删除设备" | ||||
| 	deviceID := ctx.Param("id") | ||||
|  | ||||
| 	idUint, err := strconv.ParseUint(deviceID, 10, 64) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 设备ID格式错误: %v, ID: %s", actionType, err, deviceID) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的设备ID格式", actionType, "设备ID格式错误", deviceID) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	_, err = c.deviceRepo.FindByIDString(deviceID) | ||||
| 	if err != nil { | ||||
| 	if err := c.deviceService.DeleteDevice(deviceID); err != nil { | ||||
| 		if errors.Is(err, gorm.ErrRecordNotFound) { | ||||
| 			c.logger.Warnf("%s: 设备不存在, ID: %s", actionType, deviceID) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备未找到", actionType, "设备不存在", deviceID) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备未找到", actionType, "设备不存在", deviceID) | ||||
| 		} | ||||
| 		c.logger.Errorf("%s: 查找设备失败: %v, ID: %s", actionType, err, deviceID) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除设备失败: 查找设备时发生内部错误", actionType, "数据库查询失败", deviceID) | ||||
| 		return | ||||
| 		c.logger.Errorf("%s: 服务层删除失败: %v, ID: %s", actionType, err, deviceID) | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除设备失败: "+err.Error(), actionType, "服务层删除失败", deviceID) | ||||
| 	} | ||||
|  | ||||
| 	if err := c.deviceRepo.Delete(uint(idUint)); err != nil { | ||||
| 		c.logger.Errorf("%s: 数据库删除失败: %v, ID: %d", actionType, err, idUint) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除设备失败: "+err.Error(), actionType, "数据库删除失败", deviceID) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	c.logger.Infof("%s: 设备删除成功, ID: %d", actionType, idUint) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "设备删除成功", nil, actionType, "设备删除成功", deviceID) | ||||
| 	c.logger.Infof("%s: 设备删除成功, ID: %s", actionType, deviceID) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "设备删除成功", nil, actionType, "设备删除成功", deviceID) | ||||
| } | ||||
|  | ||||
| // ManualControl godoc | ||||
| @@ -318,60 +177,26 @@ func (c *Controller) DeleteDevice(ctx *gin.Context) { | ||||
| // @Param        manualControl body dto.ManualControlDeviceRequest true "手动控制指令" | ||||
| // @Success      200 {object} controller.Response | ||||
| // @Router       /api/v1/devices/manual-control/{id} [post] | ||||
| func (c *Controller) ManualControl(ctx *gin.Context) { | ||||
| func (c *Controller) ManualControl(ctx echo.Context) error { | ||||
| 	const actionType = "手动控制设备" | ||||
| 	deviceID := ctx.Param("id") | ||||
|  | ||||
| 	var req dto.ManualControlDeviceRequest | ||||
| 	if err := ctx.ShouldBindJSON(&req); err != nil { | ||||
| 	if err := ctx.Bind(&req); err != nil { | ||||
| 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req) | ||||
| 	} | ||||
|  | ||||
| 	dev, err := c.deviceRepo.FindByIDString(deviceID) | ||||
| 	if err != nil { | ||||
| 	if err := c.deviceService.ManualControl(deviceID, &req); err != nil { | ||||
| 		if errors.Is(err, gorm.ErrRecordNotFound) { | ||||
| 			c.logger.Warnf("%s: 设备不存在, ID: %s", actionType, deviceID) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备未找到", actionType, "设备不存在", deviceID) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备未找到", actionType, "设备不存在", deviceID) | ||||
| 		} | ||||
| 		if strings.Contains(err.Error(), "无效的设备ID格式") { | ||||
| 			c.logger.Errorf("%s: 设备ID格式错误: %v, ID: %s", actionType, err, deviceID) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, err.Error(), actionType, "设备ID格式错误", deviceID) | ||||
| 			return | ||||
| 		} | ||||
| 		c.logger.Errorf("%s: 数据库查询失败: %v, ID: %s", actionType, err, deviceID) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "手动控制失败: "+err.Error(), actionType, "数据库查询失败", deviceID) | ||||
| 		return | ||||
| 		c.logger.Errorf("%s: 服务层手动控制失败: %v, ID: %s", actionType, err, deviceID) | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "手动控制失败: "+err.Error(), actionType, "服务层手动控制失败", deviceID) | ||||
| 	} | ||||
|  | ||||
| 	c.logger.Infof("%s: 接收到指令, 设备ID: %s, 动作: %s", actionType, deviceID, req.Action) | ||||
| 	if req.Action == nil { | ||||
| 		err = c.deviceService.Collect(dev.AreaControllerID, []*models.Device{dev}) | ||||
| 		if err != nil { | ||||
| 			c.logger.Errorf("%s: 获取设备状态失败: %v, 设备ID: %s", actionType, err, deviceID) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取设备状态失败: "+err.Error(), actionType, "获取设备状态失败", deviceID) | ||||
| 		} | ||||
| 	} else { | ||||
| 		action := device.DeviceActionStart | ||||
| 		switch *req.Action { | ||||
| 		case "off": | ||||
| 			action = device.DeviceActionStop | ||||
| 		case "on": | ||||
| 		default: | ||||
| 			c.logger.Errorf("%s: 无效的动作: %s, 设备ID: %s", actionType, *req.Action, deviceID) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的动作: "+*req.Action, actionType, "无效的动作", req.Action) | ||||
| 		} | ||||
| 		err = c.deviceService.Switch(dev, action) | ||||
| 		if err != nil { | ||||
| 			c.logger.Errorf("%s: 设备控制失败: %v, 设备ID: %s", actionType, err, deviceID) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "设备控制失败: "+err.Error(), actionType, "设备控制失败", deviceID) | ||||
| 			return | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "指令已发送", map[string]interface{}{"device_id": deviceID}, actionType, "指令发送成功", gin.H{"device_id": deviceID, "action": req.Action}) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "指令已发送", nil, actionType, "指令发送成功", nil) | ||||
| } | ||||
|  | ||||
| // --- Controller Methods: Area Controllers --- | ||||
| @@ -386,50 +211,22 @@ func (c *Controller) ManualControl(ctx *gin.Context) { | ||||
| // @Param        areaController body dto.CreateAreaControllerRequest true "区域主控信息" | ||||
| // @Success      200 {object} controller.Response{data=dto.AreaControllerResponse} | ||||
| // @Router       /api/v1/area-controllers [post] | ||||
| func (c *Controller) CreateAreaController(ctx *gin.Context) { | ||||
| func (c *Controller) CreateAreaController(ctx echo.Context) error { | ||||
| 	const actionType = "创建区域主控" | ||||
| 	var req dto.CreateAreaControllerRequest | ||||
| 	if err := ctx.ShouldBindJSON(&req); err != nil { | ||||
| 	if err := ctx.Bind(&req); err != nil { | ||||
| 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req) | ||||
| 	} | ||||
|  | ||||
| 	propertiesJSON, err := json.Marshal(req.Properties) | ||||
| 	resp, err := c.deviceService.CreateAreaController(&req) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 序列化属性失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "属性字段格式错误", actionType, "属性序列化失败", req.Properties) | ||||
| 		return | ||||
| 		c.logger.Errorf("%s: 服务层创建失败: %v", actionType, err) | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "创建区域主控失败: "+err.Error(), actionType, "服务层创建失败", req) | ||||
| 	} | ||||
|  | ||||
| 	ac := &models.AreaController{ | ||||
| 		Name:       req.Name, | ||||
| 		NetworkID:  req.NetworkID, | ||||
| 		Location:   req.Location, | ||||
| 		Properties: propertiesJSON, | ||||
| 	} | ||||
|  | ||||
| 	if err := ac.SelfCheck(); err != nil { | ||||
| 		c.logger.Errorf("%s: 区域主控自检失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "区域主控参数不符合要求: "+err.Error(), actionType, "区域主控自检失败", ac) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if err := c.areaControllerRepo.Create(ac); err != nil { | ||||
| 		c.logger.Errorf("%s: 数据库操作失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "创建区域主控失败: "+err.Error(), actionType, "数据库创建失败", ac) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	resp, err := dto.NewAreaControllerResponse(ac) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 序列化响应失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "区域主控创建成功,但响应生成失败", actionType, "响应序列化失败", ac) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	c.logger.Infof("%s: 区域主控创建成功, ID: %d", actionType, ac.ID) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "区域主控创建成功", resp, actionType, "区域主控创建成功", resp) | ||||
| 	c.logger.Infof("%s: 区域主控创建成功, ID: %d", actionType, resp.ID) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "区域主控创建成功", resp, actionType, "区域主控创建成功", resp) | ||||
| } | ||||
|  | ||||
| // GetAreaController godoc | ||||
| @@ -441,38 +238,22 @@ func (c *Controller) CreateAreaController(ctx *gin.Context) { | ||||
| // @Param        id path string true "区域主控ID" | ||||
| // @Success      200 {object} controller.Response{data=dto.AreaControllerResponse} | ||||
| // @Router       /api/v1/area-controllers/{id} [get] | ||||
| func (c *Controller) GetAreaController(ctx *gin.Context) { | ||||
| func (c *Controller) GetAreaController(ctx echo.Context) error { | ||||
| 	const actionType = "获取区域主控" | ||||
| 	acID := ctx.Param("id") | ||||
|  | ||||
| 	idUint, err := strconv.ParseUint(acID, 10, 64) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 区域主控ID格式错误: %v, ID: %s", actionType, err, acID) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的区域主控ID格式", actionType, "ID格式错误", acID) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	ac, err := c.areaControllerRepo.FindByID(uint(idUint)) | ||||
| 	resp, err := c.deviceService.GetAreaController(acID) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, gorm.ErrRecordNotFound) { | ||||
| 			c.logger.Warnf("%s: 区域主控不存在, ID: %s", actionType, acID) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "区域主控未找到", actionType, "区域主控不存在", acID) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "区域主控未找到", actionType, "区域主控不存在", acID) | ||||
| 		} | ||||
| 		c.logger.Errorf("%s: 数据库查询失败: %v, ID: %s", actionType, err, acID) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取区域主控信息失败: "+err.Error(), actionType, "数据库查询失败", acID) | ||||
| 		return | ||||
| 		c.logger.Errorf("%s: 服务层获取失败: %v, ID: %s", actionType, err, acID) | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取区域主控信息失败: "+err.Error(), actionType, "服务层获取失败", acID) | ||||
| 	} | ||||
|  | ||||
| 	resp, err := dto.NewAreaControllerResponse(ac) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 序列化响应失败: %v, AreaController: %+v", actionType, err, ac) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取区域主控信息失败: 内部数据格式错误", actionType, "响应序列化失败", ac) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	c.logger.Infof("%s: 获取区域主控信息成功, ID: %d", actionType, ac.ID) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取区域主控信息成功", resp, actionType, "获取区域主控信息成功", resp) | ||||
| 	c.logger.Infof("%s: 获取区域主控信息成功, ID: %d", actionType, resp.ID) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取区域主控信息成功", resp, actionType, "获取区域主控信息成功", resp) | ||||
| } | ||||
|  | ||||
| // ListAreaControllers godoc | ||||
| @@ -483,24 +264,16 @@ func (c *Controller) GetAreaController(ctx *gin.Context) { | ||||
| // @Produce      json | ||||
| // @Success      200 {object} controller.Response{data=[]dto.AreaControllerResponse} | ||||
| // @Router       /api/v1/area-controllers [get] | ||||
| func (c *Controller) ListAreaControllers(ctx *gin.Context) { | ||||
| func (c *Controller) ListAreaControllers(ctx echo.Context) error { | ||||
| 	const actionType = "获取区域主控列表" | ||||
| 	acs, err := c.areaControllerRepo.ListAll() | ||||
| 	resp, err := c.deviceService.ListAreaControllers() | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 数据库查询失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取区域主控列表失败: "+err.Error(), actionType, "数据库查询失败", nil) | ||||
| 		return | ||||
| 		c.logger.Errorf("%s: 服务层获取列表失败: %v", actionType, err) | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取区域主控列表失败: "+err.Error(), actionType, "服务层获取列表失败", nil) | ||||
| 	} | ||||
|  | ||||
| 	resp, err := dto.NewListAreaControllerResponse(acs) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 序列化响应失败: %v, AreaControllers: %+v", actionType, err, acs) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取区域主控列表失败: 内部数据格式错误", actionType, "响应序列化失败", acs) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	c.logger.Infof("%s: 获取区域主控列表成功, 数量: %d", actionType, len(acs)) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取区域主控列表成功", resp, actionType, "获取区域主控列表成功", resp) | ||||
| 	c.logger.Infof("%s: 获取区域主控列表成功, 数量: %d", actionType, len(resp)) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取区域主控列表成功", resp, actionType, "获取区域主控列表成功", resp) | ||||
| } | ||||
|  | ||||
| // UpdateAreaController godoc | ||||
| @@ -514,69 +287,28 @@ func (c *Controller) ListAreaControllers(ctx *gin.Context) { | ||||
| // @Param        areaController body dto.UpdateAreaControllerRequest true "要更新的区域主控信息" | ||||
| // @Success      200 {object} controller.Response{data=dto.AreaControllerResponse} | ||||
| // @Router       /api/v1/area-controllers/{id} [put] | ||||
| func (c *Controller) UpdateAreaController(ctx *gin.Context) { | ||||
| func (c *Controller) UpdateAreaController(ctx echo.Context) error { | ||||
| 	const actionType = "更新区域主控" | ||||
| 	acID := ctx.Param("id") | ||||
|  | ||||
| 	idUint, err := strconv.ParseUint(acID, 10, 64) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 区域主控ID格式错误: %v, ID: %s", actionType, err, acID) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的区域主控ID格式", actionType, "ID格式错误", acID) | ||||
| 		return | ||||
| 	var req dto.UpdateAreaControllerRequest | ||||
| 	if err := ctx.Bind(&req); err != nil { | ||||
| 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req) | ||||
| 	} | ||||
|  | ||||
| 	existingAC, err := c.areaControllerRepo.FindByID(uint(idUint)) | ||||
| 	resp, err := c.deviceService.UpdateAreaController(acID, &req) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, gorm.ErrRecordNotFound) { | ||||
| 			c.logger.Warnf("%s: 区域主控不存在, ID: %s", actionType, acID) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "区域主控未找到", actionType, "区域主控不存在", acID) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "区域主控未找到", actionType, "区域主控不存在", acID) | ||||
| 		} | ||||
| 		c.logger.Errorf("%s: 数据库查询失败: %v, ID: %s", actionType, err, acID) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新区域主控失败: "+err.Error(), actionType, "数据库查询失败", acID) | ||||
| 		return | ||||
| 		c.logger.Errorf("%s: 服务层更新失败: %v, ID: %s", actionType, err, acID) | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新区域主控失败: "+err.Error(), actionType, "服务层更新失败", acID) | ||||
| 	} | ||||
|  | ||||
| 	var req dto.UpdateAreaControllerRequest | ||||
| 	if err := ctx.ShouldBindJSON(&req); err != nil { | ||||
| 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	propertiesJSON, err := json.Marshal(req.Properties) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 序列化属性失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "属性字段格式错误", actionType, "属性序列化失败", req.Properties) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	existingAC.Name = req.Name | ||||
| 	existingAC.NetworkID = req.NetworkID | ||||
| 	existingAC.Location = req.Location | ||||
| 	existingAC.Properties = propertiesJSON | ||||
|  | ||||
| 	if err := existingAC.SelfCheck(); err != nil { | ||||
| 		c.logger.Errorf("%s: 区域主控自检失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "区域主控参数不符合要求: "+err.Error(), actionType, "区域主控自检失败", existingAC) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if err := c.areaControllerRepo.Update(existingAC); err != nil { | ||||
| 		c.logger.Errorf("%s: 数据库更新失败: %v, AreaController: %+v", actionType, err, existingAC) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新区域主控失败: "+err.Error(), actionType, "数据库更新失败", existingAC) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	resp, err := dto.NewAreaControllerResponse(existingAC) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 序列化响应失败: %v, AreaController: %+v", actionType, err, existingAC) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "区域主控更新成功,但响应生成失败", actionType, "响应序列化失败", existingAC) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	c.logger.Infof("%s: 区域主控更新成功, ID: %d", actionType, existingAC.ID) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "区域主控更新成功", resp, actionType, "区域主控更新成功", resp) | ||||
| 	c.logger.Infof("%s: 区域主控更新成功, ID: %d", actionType, resp.ID) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "区域主控更新成功", resp, actionType, "区域主控更新成功", resp) | ||||
| } | ||||
|  | ||||
| // DeleteAreaController godoc | ||||
| @@ -588,37 +320,21 @@ func (c *Controller) UpdateAreaController(ctx *gin.Context) { | ||||
| // @Param        id path string true "区域主控ID" | ||||
| // @Success      200 {object} controller.Response | ||||
| // @Router       /api/v1/area-controllers/{id} [delete] | ||||
| func (c *Controller) DeleteAreaController(ctx *gin.Context) { | ||||
| func (c *Controller) DeleteAreaController(ctx echo.Context) error { | ||||
| 	const actionType = "删除区域主控" | ||||
| 	acID := ctx.Param("id") | ||||
|  | ||||
| 	idUint, err := strconv.ParseUint(acID, 10, 64) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 区域主控ID格式错误: %v, ID: %s", actionType, err, acID) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的区域主控ID格式", actionType, "ID格式错误", acID) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	_, err = c.areaControllerRepo.FindByID(uint(idUint)) | ||||
| 	if err != nil { | ||||
| 	if err := c.deviceService.DeleteAreaController(acID); err != nil { | ||||
| 		if errors.Is(err, gorm.ErrRecordNotFound) { | ||||
| 			c.logger.Warnf("%s: 区域主控不存在, ID: %s", actionType, acID) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "区域主控未找到", actionType, "区域主控不存在", acID) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "区域主控未找到", actionType, "区域主控不存在", acID) | ||||
| 		} | ||||
| 		c.logger.Errorf("%s: 查找区域主控失败: %v, ID: %s", actionType, err, acID) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除区域主控失败: 查找时发生内部错误", actionType, "数据库查询失败", acID) | ||||
| 		return | ||||
| 		c.logger.Errorf("%s: 服务层删除失败: %v, ID: %s", actionType, err, acID) | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除区域主控失败: "+err.Error(), actionType, "服务层删除失败", acID) | ||||
| 	} | ||||
|  | ||||
| 	if err := c.areaControllerRepo.Delete(uint(idUint)); err != nil { | ||||
| 		c.logger.Errorf("%s: 数据库删除失败: %v, ID: %d", actionType, err, idUint) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除区域主控失败: "+err.Error(), actionType, "数据库删除失败", acID) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	c.logger.Infof("%s: 区域主控删除成功, ID: %d", actionType, idUint) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "区域主控删除成功", nil, actionType, "区域主控删除成功", acID) | ||||
| 	c.logger.Infof("%s: 区域主控删除成功, ID: %s", actionType, acID) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "区域主控删除成功", nil, actionType, "区域主控删除成功", acID) | ||||
| } | ||||
|  | ||||
| // --- Controller Methods: Device Templates --- | ||||
| @@ -633,59 +349,22 @@ func (c *Controller) DeleteAreaController(ctx *gin.Context) { | ||||
| // @Param        deviceTemplate body dto.CreateDeviceTemplateRequest true "设备模板信息" | ||||
| // @Success      200 {object} controller.Response{data=dto.DeviceTemplateResponse} | ||||
| // @Router       /api/v1/device-templates [post] | ||||
| func (c *Controller) CreateDeviceTemplate(ctx *gin.Context) { | ||||
| func (c *Controller) CreateDeviceTemplate(ctx echo.Context) error { | ||||
| 	const actionType = "创建设备模板" | ||||
| 	var req dto.CreateDeviceTemplateRequest | ||||
| 	if err := ctx.ShouldBindJSON(&req); err != nil { | ||||
| 	if err := ctx.Bind(&req); err != nil { | ||||
| 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req) | ||||
| 	} | ||||
|  | ||||
| 	commandsJSON, err := json.Marshal(req.Commands) | ||||
| 	resp, err := c.deviceService.CreateDeviceTemplate(&req) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 序列化命令失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "命令字段格式错误", actionType, "命令序列化失败", req.Commands) | ||||
| 		return | ||||
| 		c.logger.Errorf("%s: 服务层创建失败: %v", actionType, err) | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "创建设备模板失败: "+err.Error(), actionType, "服务层创建失败", req) | ||||
| 	} | ||||
|  | ||||
| 	valuesJSON, err := json.Marshal(req.Values) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 序列化值描述符失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "值描述符字段格式错误", actionType, "值描述符序列化失败", req.Values) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	deviceTemplate := &models.DeviceTemplate{ | ||||
| 		Name:         req.Name, | ||||
| 		Manufacturer: req.Manufacturer, | ||||
| 		Description:  req.Description, | ||||
| 		Category:     req.Category, | ||||
| 		Commands:     commandsJSON, | ||||
| 		Values:       valuesJSON, | ||||
| 	} | ||||
|  | ||||
| 	if err := deviceTemplate.SelfCheck(); err != nil { | ||||
| 		c.logger.Errorf("%s: 设备模板自检失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "设备模板参数不符合要求: "+err.Error(), actionType, "设备模板自检失败", deviceTemplate) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if err := c.deviceTemplateRepo.Create(deviceTemplate); err != nil { | ||||
| 		c.logger.Errorf("%s: 数据库操作失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "创建设备模板失败: "+err.Error(), actionType, "数据库创建失败", deviceTemplate) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	resp, err := dto.NewDeviceTemplateResponse(deviceTemplate) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 序列化响应失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "设备模板创建成功,但响应生成失败", actionType, "响应序列化失败", deviceTemplate) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	c.logger.Infof("%s: 设备模板创建成功, ID: %d", actionType, deviceTemplate.ID) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "设备模板创建成功", resp, actionType, "设备模板创建成功", resp) | ||||
| 	c.logger.Infof("%s: 设备模板创建成功, ID: %d", actionType, resp.ID) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "设备模板创建成功", resp, actionType, "设备模板创建成功", resp) | ||||
| } | ||||
|  | ||||
| // GetDeviceTemplate godoc | ||||
| @@ -697,38 +376,22 @@ func (c *Controller) CreateDeviceTemplate(ctx *gin.Context) { | ||||
| // @Param        id path string true "设备模板ID" | ||||
| // @Success      200 {object} controller.Response{data=dto.DeviceTemplateResponse} | ||||
| // @Router       /api/v1/device-templates/{id} [get] | ||||
| func (c *Controller) GetDeviceTemplate(ctx *gin.Context) { | ||||
| func (c *Controller) GetDeviceTemplate(ctx echo.Context) error { | ||||
| 	const actionType = "获取设备模板" | ||||
| 	dtID := ctx.Param("id") | ||||
|  | ||||
| 	idUint, err := strconv.ParseUint(dtID, 10, 64) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 设备模板ID格式错误: %v, ID: %s", actionType, err, dtID) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的设备模板ID格式", actionType, "ID格式错误", dtID) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	deviceTemplate, err := c.deviceTemplateRepo.FindByID(uint(idUint)) | ||||
| 	resp, err := c.deviceService.GetDeviceTemplate(dtID) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, gorm.ErrRecordNotFound) { | ||||
| 			c.logger.Warnf("%s: 设备模板不存在, ID: %s", actionType, dtID) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备模板未找到", actionType, "设备模板不存在", dtID) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备模板未找到", actionType, "设备模板不存在", dtID) | ||||
| 		} | ||||
| 		c.logger.Errorf("%s: 数据库查询失败: %v, ID: %s", actionType, err, dtID) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取设备模板信息失败: "+err.Error(), actionType, "数据库查询失败", dtID) | ||||
| 		return | ||||
| 		c.logger.Errorf("%s: 服务层获取失败: %v, ID: %s", actionType, err, dtID) | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取设备模板信息失败: "+err.Error(), actionType, "服务层获取失败", dtID) | ||||
| 	} | ||||
|  | ||||
| 	resp, err := dto.NewDeviceTemplateResponse(deviceTemplate) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 序列化响应失败: %v, DeviceTemplate: %+v", actionType, err, deviceTemplate) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取设备模板信息失败: 内部数据格式错误", actionType, "响应序列化失败", deviceTemplate) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	c.logger.Infof("%s: 获取设备模板信息成功, ID: %d", actionType, deviceTemplate.ID) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取设备模板信息成功", resp, actionType, "获取设备模板信息成功", resp) | ||||
| 	c.logger.Infof("%s: 获取设备模板信息成功, ID: %d", actionType, resp.ID) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取设备模板信息成功", resp, actionType, "获取设备模板信息成功", resp) | ||||
| } | ||||
|  | ||||
| // ListDeviceTemplates godoc | ||||
| @@ -739,24 +402,16 @@ func (c *Controller) GetDeviceTemplate(ctx *gin.Context) { | ||||
| // @Produce      json | ||||
| // @Success      200 {object} controller.Response{data=[]dto.DeviceTemplateResponse} | ||||
| // @Router       /api/v1/device-templates [get] | ||||
| func (c *Controller) ListDeviceTemplates(ctx *gin.Context) { | ||||
| func (c *Controller) ListDeviceTemplates(ctx echo.Context) error { | ||||
| 	const actionType = "获取设备模板列表" | ||||
| 	deviceTemplates, err := c.deviceTemplateRepo.ListAll() | ||||
| 	resp, err := c.deviceService.ListDeviceTemplates() | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 数据库查询失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取设备模板列表失败: "+err.Error(), actionType, "数据库查询失败", nil) | ||||
| 		return | ||||
| 		c.logger.Errorf("%s: 服务层获取列表失败: %v", actionType, err) | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取设备模板列表失败: "+err.Error(), actionType, "服务层获取列表失败", nil) | ||||
| 	} | ||||
|  | ||||
| 	resp, err := dto.NewListDeviceTemplateResponse(deviceTemplates) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 序列化响应失败: %v, DeviceTemplates: %+v", actionType, err, deviceTemplates) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取设备模板列表失败: 内部数据格式错误", actionType, "响应序列化失败", deviceTemplates) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	c.logger.Infof("%s: 获取设备模板列表成功, 数量: %d", actionType, len(deviceTemplates)) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取设备模板列表成功", resp, actionType, "获取设备模板列表成功", resp) | ||||
| 	c.logger.Infof("%s: 获取设备模板列表成功, 数量: %d", actionType, len(resp)) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取设备模板列表成功", resp, actionType, "获取设备模板列表成功", resp) | ||||
| } | ||||
|  | ||||
| // UpdateDeviceTemplate godoc | ||||
| @@ -770,78 +425,28 @@ func (c *Controller) ListDeviceTemplates(ctx *gin.Context) { | ||||
| // @Param        deviceTemplate body dto.UpdateDeviceTemplateRequest true "要更新的设备模板信息" | ||||
| // @Success      200 {object} controller.Response{data=dto.DeviceTemplateResponse} | ||||
| // @Router       /api/v1/device-templates/{id} [put] | ||||
| func (c *Controller) UpdateDeviceTemplate(ctx *gin.Context) { | ||||
| func (c *Controller) UpdateDeviceTemplate(ctx echo.Context) error { | ||||
| 	const actionType = "更新设备模板" | ||||
| 	dtID := ctx.Param("id") | ||||
|  | ||||
| 	idUint, err := strconv.ParseUint(dtID, 10, 64) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 设备模板ID格式错误: %v, ID: %s", actionType, err, dtID) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的设备模板ID格式", actionType, "ID格式错误", dtID) | ||||
| 		return | ||||
| 	var req dto.UpdateDeviceTemplateRequest | ||||
| 	if err := ctx.Bind(&req); err != nil { | ||||
| 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req) | ||||
| 	} | ||||
|  | ||||
| 	existingDeviceTemplate, err := c.deviceTemplateRepo.FindByID(uint(idUint)) | ||||
| 	resp, err := c.deviceService.UpdateDeviceTemplate(dtID, &req) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, gorm.ErrRecordNotFound) { | ||||
| 			c.logger.Warnf("%s: 设备模板不存在, ID: %s", actionType, dtID) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备模板未找到", actionType, "设备模板不存在", dtID) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备模板未找到", actionType, "设备模板不存在", dtID) | ||||
| 		} | ||||
| 		c.logger.Errorf("%s: 数据库查询失败: %v, ID: %s", actionType, err, dtID) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新设备模板失败: "+err.Error(), actionType, "数据库查询失败", dtID) | ||||
| 		return | ||||
| 		c.logger.Errorf("%s: 服务层更新失败: %v, ID: %s", actionType, err, dtID) | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新设备模板失败: "+err.Error(), actionType, "服务层更新失败", dtID) | ||||
| 	} | ||||
|  | ||||
| 	var req dto.UpdateDeviceTemplateRequest | ||||
| 	if err := ctx.ShouldBindJSON(&req); err != nil { | ||||
| 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	commandsJSON, err := json.Marshal(req.Commands) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 序列化命令失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "命令字段格式错误", actionType, "命令序列化失败", req.Commands) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	valuesJSON, err := json.Marshal(req.Values) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 序列化值描述符失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "值描述符字段格式错误", actionType, "值描述符序列化失败", req.Values) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	existingDeviceTemplate.Name = req.Name | ||||
| 	existingDeviceTemplate.Manufacturer = req.Manufacturer | ||||
| 	existingDeviceTemplate.Description = req.Description | ||||
| 	existingDeviceTemplate.Category = req.Category | ||||
| 	existingDeviceTemplate.Commands = commandsJSON | ||||
| 	existingDeviceTemplate.Values = valuesJSON | ||||
|  | ||||
| 	if err := existingDeviceTemplate.SelfCheck(); err != nil { | ||||
| 		c.logger.Errorf("%s: 设备模板自检失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "设备模板参数不符合要求: "+err.Error(), actionType, "设备模板自检失败", existingDeviceTemplate) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	if err := c.deviceTemplateRepo.Update(existingDeviceTemplate); err != nil { | ||||
| 		c.logger.Errorf("%s: 数据库更新失败: %v, DeviceTemplate: %+v", actionType, err, existingDeviceTemplate) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新设备模板失败: "+err.Error(), actionType, "数据库更新失败", existingDeviceTemplate) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	resp, err := dto.NewDeviceTemplateResponse(existingDeviceTemplate) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 序列化响应失败: %v, DeviceTemplate: %+v", actionType, err, existingDeviceTemplate) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "设备模板更新成功,但响应生成失败", actionType, "响应序列化失败", existingDeviceTemplate) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	c.logger.Infof("%s: 设备模板更新成功, ID: %d", actionType, existingDeviceTemplate.ID) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "设备模板更新成功", resp, actionType, "设备模板更新成功", resp) | ||||
| 	c.logger.Infof("%s: 设备模板更新成功, ID: %d", actionType, resp.ID) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "设备模板更新成功", resp, actionType, "设备模板更新成功", resp) | ||||
| } | ||||
|  | ||||
| // DeleteDeviceTemplate godoc | ||||
| @@ -853,43 +458,19 @@ func (c *Controller) UpdateDeviceTemplate(ctx *gin.Context) { | ||||
| // @Param        id path string true "设备模板ID" | ||||
| // @Success      200 {object} controller.Response | ||||
| // @Router       /api/v1/device-templates/{id} [delete] | ||||
| func (c *Controller) DeleteDeviceTemplate(ctx *gin.Context) { | ||||
| func (c *Controller) DeleteDeviceTemplate(ctx echo.Context) error { | ||||
| 	const actionType = "删除设备模板" | ||||
| 	dtID := ctx.Param("id") | ||||
|  | ||||
| 	idUint, err := strconv.ParseUint(dtID, 10, 64) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 设备模板ID格式错误: %v, ID: %s", actionType, err, dtID) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的设备模板ID格式", actionType, "ID格式错误", dtID) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	// 在尝试删除之前,先检查设备模板是否存在 | ||||
| 	_, err = c.deviceTemplateRepo.FindByID(uint(idUint)) | ||||
| 	if err != nil { | ||||
| 	if err := c.deviceService.DeleteDeviceTemplate(dtID); err != nil { | ||||
| 		if errors.Is(err, gorm.ErrRecordNotFound) { | ||||
| 			c.logger.Warnf("%s: 设备模板不存在, ID: %s", actionType, dtID) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备模板未找到", actionType, "设备模板不存在", dtID) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备模板未找到", actionType, "设备模板不存在", dtID) | ||||
| 		} | ||||
| 		c.logger.Errorf("%s: 查找设备模板失败: %v, ID: %s", actionType, err, dtID) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除设备模板失败: 查找时发生内部错误", actionType, "数据库查询失败", dtID) | ||||
| 		return | ||||
| 		c.logger.Errorf("%s: 服务层删除失败: %v, ID: %s", actionType, err, dtID) | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除设备模板失败: "+err.Error(), actionType, "服务层删除失败", dtID) | ||||
| 	} | ||||
|  | ||||
| 	// 调用仓库层的删除方法,该方法会检查模板是否被使用 | ||||
| 	if err := c.deviceTemplateRepo.Delete(uint(idUint)); err != nil { | ||||
| 		c.logger.Errorf("%s: 数据库删除失败: %v, ID: %d", actionType, err, idUint) | ||||
| 		// 如果错误信息包含“设备模板正在被设备使用,无法删除”,则返回特定的错误码 | ||||
| 		if strings.Contains(err.Error(), "设备模板正在被设备使用,无法删除") { | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, err.Error(), actionType, "设备模板正在使用", dtID) | ||||
| 		} else { | ||||
| 			// 其他数据库错误 | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除设备模板失败: "+err.Error(), actionType, "数据库删除失败", dtID) | ||||
| 		} | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	c.logger.Infof("%s: 设备模板删除成功, ID: %d", actionType, idUint) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "设备模板删除成功", nil, actionType, "设备模板删除成功", dtID) | ||||
| 	c.logger.Infof("%s: 设备模板删除成功, ID: %s", actionType, dtID) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "设备模板删除成功", nil, actionType, "设备模板删除成功", dtID) | ||||
| } | ||||
|   | ||||
| @@ -1,741 +0,0 @@ | ||||
| package device_test | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"encoding/json" | ||||
| 	"errors" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"net/http/httptest" | ||||
| 	"testing" | ||||
| 	"time" | ||||
|  | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/controller" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/controller/device" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| 	"github.com/stretchr/testify/mock" | ||||
| 	"gorm.io/datatypes" | ||||
| 	"gorm.io/gorm" | ||||
| ) | ||||
|  | ||||
| // MockDeviceRepository 是 DeviceRepository 接口的模拟实现 | ||||
| type MockDeviceRepository struct { | ||||
| 	mock.Mock | ||||
| } | ||||
|  | ||||
| // CreateTx 模拟 DeviceRepository 的 CreateTx 方法 | ||||
| func (m *MockDeviceRepository) Create(device *models.Device) error { | ||||
| 	args := m.Called(device) | ||||
| 	return args.Error(0) | ||||
| } | ||||
|  | ||||
| // FindByID 模拟 DeviceRepository 的 FindByID 方法 | ||||
| func (m *MockDeviceRepository) FindByID(id uint) (*models.Device, error) { | ||||
| 	args := m.Called(id) | ||||
| 	if args.Get(0) == nil { | ||||
| 		return nil, args.Error(1) | ||||
| 	} | ||||
| 	return args.Get(0).(*models.Device), args.Error(1) | ||||
| } | ||||
|  | ||||
| // FindByIDString 模拟 DeviceRepository 的 FindByIDString 方法 | ||||
| func (m *MockDeviceRepository) FindByIDString(id string) (*models.Device, error) { | ||||
| 	args := m.Called(id) | ||||
| 	if args.Get(0) == nil { | ||||
| 		return nil, args.Error(1) | ||||
| 	} | ||||
| 	return args.Get(0).(*models.Device), args.Error(1) | ||||
| } | ||||
|  | ||||
| // ListAll 模拟 DeviceRepository 的 ListAll 方法 | ||||
| func (m *MockDeviceRepository) ListAll() ([]*models.Device, error) { | ||||
| 	args := m.Called() | ||||
| 	if args.Get(0) == nil { | ||||
| 		return nil, args.Error(1) | ||||
| 	} | ||||
| 	return args.Get(0).([]*models.Device), args.Error(1) | ||||
| } | ||||
|  | ||||
| // ListByParentID 模拟 DeviceRepository 的 ListByParentID 方法 | ||||
| func (m *MockDeviceRepository) ListByParentID(parentID *uint) ([]*models.Device, error) { | ||||
| 	args := m.Called(parentID) | ||||
| 	if args.Get(0) == nil { | ||||
| 		return nil, args.Error(1) | ||||
| 	} | ||||
| 	return args.Get(0).([]*models.Device), args.Error(1) | ||||
| } | ||||
|  | ||||
| // Update 模拟 DeviceRepository 的 Update 方法 | ||||
| func (m *MockDeviceRepository) Update(device *models.Device) error { | ||||
| 	args := m.Called(device) | ||||
| 	return args.Error(0) | ||||
| } | ||||
|  | ||||
| // Delete 模拟 DeviceRepository 的 Delete 方法 | ||||
| func (m *MockDeviceRepository) Delete(id uint) error { | ||||
| 	args := m.Called(id) | ||||
| 	return args.Error(0) | ||||
| } | ||||
|  | ||||
| // testCase 结构体定义了所有测试用例的通用参数 | ||||
| type testCase struct { | ||||
| 	name             string | ||||
| 	httpMethod       string // 新增字段:HTTP 方法 | ||||
| 	requestBody      interface{} | ||||
| 	paramID          string // URL 中的 ID 参数 | ||||
| 	mockRepoSetup    func(*MockDeviceRepository) | ||||
| 	expectedStatus   int // HTTP 状态码 | ||||
| 	expectedCode     int // 业务状态码 | ||||
| 	expectedMessage  string | ||||
| 	expectedDataFunc func(interface{}) bool // 用于验证 data 字段的函数 | ||||
| } | ||||
|  | ||||
| // runTest 是一个辅助函数,用于执行单个测试用例 | ||||
| func runTest(t *testing.T, tc testCase, controllerMethod func(*gin.Context, *MockDeviceRepository)) { | ||||
| 	// 初始化 Gin 上下文 | ||||
| 	w := httptest.NewRecorder() | ||||
| 	ctx, _ := gin.CreateTestContext(w) | ||||
|  | ||||
| 	// 设置请求体和 HTTP 方法 | ||||
| 	if tc.requestBody != nil { | ||||
| 		jsonBody, _ := json.Marshal(tc.requestBody) | ||||
| 		ctx.Request = httptest.NewRequest(tc.httpMethod, "/", io.NopCloser(bytes.NewBuffer(jsonBody))) | ||||
| 		ctx.Request.Header.Set("Content-Type", "application/json") | ||||
| 	} else { | ||||
| 		// 对于没有请求体的请求 (GET, DELETE, 或没有 body 的 POST/PUT) | ||||
| 		ctx.Request = httptest.NewRequest(tc.httpMethod, "/", nil) | ||||
| 	} | ||||
|  | ||||
| 	// 设置 URL 参数 | ||||
| 	if tc.paramID != "" { | ||||
| 		ctx.Params = append(ctx.Params, gin.Param{Key: "id", Value: tc.paramID}) | ||||
| 	} | ||||
|  | ||||
| 	// 创建 Mock Repository | ||||
| 	mockRepo := new(MockDeviceRepository) | ||||
| 	// 设置 Mock 行为 | ||||
| 	tc.mockRepoSetup(mockRepo) | ||||
|  | ||||
| 	// 调用被测试的方法,并传入 mockRepo | ||||
| 	controllerMethod(ctx, mockRepo) | ||||
|  | ||||
| 	// 解析响应体 | ||||
| 	var responseBody controller.Response | ||||
| 	err := json.Unmarshal(w.Body.Bytes(), &responseBody) | ||||
| 	assert.NoError(t, err) | ||||
|  | ||||
| 	// 断言 HTTP 状态码始终为 200 OK | ||||
| 	assert.Equal(t, tc.expectedStatus, w.Code) | ||||
|  | ||||
| 	// 断言业务状态码和消息 | ||||
| 	assert.Equal(t, tc.expectedCode, responseBody.Code) | ||||
| 	assert.Equal(t, tc.expectedMessage, responseBody.Message) | ||||
|  | ||||
| 	// 断言数据字段 | ||||
| 	if tc.expectedDataFunc != nil { | ||||
| 		var data interface{} | ||||
| 		// 只有当 responseBody.Data 不为 nil 且其底层类型为 []byte 时才尝试 Unmarshal | ||||
| 		if responseBody.Data != nil { | ||||
| 			if byteData, ok := responseBody.Data.([]byte); ok { | ||||
| 				err = json.Unmarshal(byteData, &data) | ||||
| 				assert.NoError(t, err, "无法解析响应数据") // 增加对 Unmarshal 错误的断言 | ||||
| 			} else { | ||||
| 				// 如果 Data 不为 nil 但也不是 []byte,这通常不应该发生 | ||||
| 				// 但为了健壮性,直接将原始 interface{} 赋值给 data | ||||
| 				data = responseBody.Data | ||||
| 			} | ||||
| 		} | ||||
| 		assert.True(t, tc.expectedDataFunc(data), "数据字段验证失败") | ||||
| 	} | ||||
|  | ||||
| 	// 验证 Mock 期望是否都已满足 | ||||
| 	mockRepo.AssertExpectations(t) | ||||
| } | ||||
|  | ||||
| func TestCreateDevice(t *testing.T) { | ||||
| 	gin.SetMode(gin.TestMode) | ||||
|  | ||||
| 	tests := []testCase{ | ||||
| 		{ | ||||
| 			name:       "成功创建区域主控", | ||||
| 			httpMethod: http.MethodPost, | ||||
| 			requestBody: device.CreateDeviceRequest{ | ||||
| 				Name:       "主控A", | ||||
| 				Type:       models.DeviceTypeAreaController, | ||||
| 				Location:   "猪舍1", | ||||
| 				Properties: controller.Properties(`{"lora_address":"0x1234"}`), | ||||
| 			}, | ||||
| 			mockRepoSetup: func(m *MockDeviceRepository) { | ||||
| 				m.On("CreateTx", mock.MatchedBy(func(dev *models.Device) bool { | ||||
| 					// 检查 Name 字段 | ||||
| 					nameMatch := dev.Name == "主控A" | ||||
| 					// 检查 Type 字段 | ||||
| 					typeMatch := dev.Type == models.DeviceTypeAreaController | ||||
| 					// 检查 Location 字段 | ||||
| 					locationMatch := dev.Location == "猪舍1" | ||||
| 					// 检查 Properties 字段的字节内容 | ||||
| 					expectedProperties := controller.Properties(`{"lora_address":"0x1234"}`) | ||||
| 					propertiesMatch := bytes.Equal(dev.Properties, expectedProperties) | ||||
|  | ||||
| 					return nameMatch && typeMatch && locationMatch && propertiesMatch | ||||
| 				})).Return(nil).Run(func(args mock.Arguments) { | ||||
| 					// 模拟 GORM 自动填充 ID | ||||
| 					arg := args.Get(0).(*models.Device) | ||||
| 					arg.ID = 1 | ||||
| 					arg.CreatedAt = time.Now() | ||||
| 					arg.UpdatedAt = time.Now() | ||||
| 				}).Once() | ||||
| 			}, | ||||
| 			expectedStatus:  http.StatusOK, | ||||
| 			expectedCode:    controller.CodeCreated, | ||||
| 			expectedMessage: "设备创建成功", | ||||
| 			expectedDataFunc: func(data interface{}) bool { | ||||
| 				dataMap, ok := data.(map[string]interface{}) | ||||
| 				if !ok { | ||||
| 					return false | ||||
| 				} | ||||
| 				return dataMap["id"] != nil && | ||||
| 					dataMap["name"] == "主控A" && | ||||
| 					dataMap["type"] == string(models.DeviceTypeAreaController) && | ||||
| 					dataMap["properties"] != nil | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:       "成功创建普通设备", | ||||
| 			httpMethod: http.MethodPost, | ||||
| 			requestBody: device.CreateDeviceRequest{ | ||||
| 				Name:       "温度传感器", | ||||
| 				Type:       models.DeviceTypeDevice, | ||||
| 				SubType:    models.SubTypeSensorTemp, | ||||
| 				ParentID:   func() *uint { id := uint(1); return &id }(), | ||||
| 				Location:   "猪舍1-A区", | ||||
| 				Properties: controller.Properties(`{"bus_id":1,"bus_address":10}`), | ||||
| 			}, | ||||
| 			mockRepoSetup: func(m *MockDeviceRepository) { | ||||
| 				m.On("CreateTx", mock.Anything).Return(nil).Run(func(args mock.Arguments) { | ||||
| 					arg := args.Get(0).(*models.Device) | ||||
| 					arg.ID = 2 | ||||
| 					arg.CreatedAt = time.Now() | ||||
| 					arg.UpdatedAt = time.Now() | ||||
| 				}).Once() | ||||
| 			}, | ||||
| 			expectedStatus:  http.StatusOK, | ||||
| 			expectedCode:    controller.CodeCreated, | ||||
| 			expectedMessage: "设备创建成功", | ||||
| 			expectedDataFunc: func(data interface{}) bool { | ||||
| 				dataMap, ok := data.(map[string]interface{}) | ||||
| 				if !ok { | ||||
| 					return false | ||||
| 				} | ||||
| 				return dataMap["id"] != nil && | ||||
| 					dataMap["name"] == "温度传感器" && | ||||
| 					dataMap["type"] == string(models.DeviceTypeDevice) && | ||||
| 					dataMap["sub_type"] == string(models.SubTypeSensorTemp) && | ||||
| 					dataMap["parent_id"] != nil && | ||||
| 					dataMap["properties"] != nil | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:       "请求参数绑定失败", | ||||
| 			httpMethod: http.MethodPost, | ||||
| 			requestBody: device.CreateDeviceRequest{ | ||||
| 				Name: "", // 缺少必填字段 Name | ||||
| 				Type: models.DeviceTypeAreaController, | ||||
| 			}, | ||||
| 			mockRepoSetup:    func(m *MockDeviceRepository) {}, | ||||
| 			expectedStatus:   http.StatusOK, | ||||
| 			expectedCode:     controller.CodeBadRequest, | ||||
| 			expectedMessage:  "Key: 'CreateDeviceRequest.Name' Error:Field validation for 'Name' failed on the 'required' tag", | ||||
| 			expectedDataFunc: func(data interface{}) bool { return data == nil }, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:       "数据库创建失败", | ||||
| 			httpMethod: http.MethodPost, | ||||
| 			requestBody: device.CreateDeviceRequest{ | ||||
| 				Name: "失败设备", | ||||
| 				Type: models.DeviceTypeDevice, | ||||
| 			}, | ||||
| 			mockRepoSetup: func(m *MockDeviceRepository) { | ||||
| 				m.On("CreateTx", mock.Anything).Return(errors.New("db error")).Once() | ||||
| 			}, | ||||
| 			expectedStatus:   http.StatusOK, | ||||
| 			expectedCode:     controller.CodeInternalError, | ||||
| 			expectedMessage:  "创建设备失败", | ||||
| 			expectedDataFunc: func(data interface{}) bool { return data == nil }, | ||||
| 		}, | ||||
| 		// 新增:Properties字段JSON格式无效 | ||||
| 		{ | ||||
| 			name:       "Properties字段JSON格式无效", | ||||
| 			httpMethod: http.MethodPost, | ||||
| 			requestBody: device.CreateDeviceRequest{ | ||||
| 				Name:       "无效JSON设备", | ||||
| 				Type:       models.DeviceTypeDevice, | ||||
| 				Properties: controller.Properties(`{invalid json}`), | ||||
| 			}, | ||||
| 			mockRepoSetup: func(m *MockDeviceRepository) { | ||||
| 				// 期望 CreateTx 方法被调用,并返回一个模拟的数据库错误 | ||||
| 				// 这个错误模拟的是数据库层因为 Properties 字段的 JSON 格式无效而拒绝保存 | ||||
| 				m.On("CreateTx", mock.Anything).Return(errors.New("database error: invalid json format")).Run(func(args mock.Arguments) { | ||||
| 					dev := args.Get(0).(*models.Device) | ||||
| 					assert.Equal(t, "无效JSON设备", dev.Name) | ||||
| 					assert.Equal(t, models.DeviceTypeDevice, dev.Type) | ||||
| 					expectedProperties := controller.Properties(`{invalid json}`) | ||||
| 					assert.True(t, bytes.Equal(dev.Properties, expectedProperties), "Properties should match") | ||||
| 				}).Once() | ||||
| 			}, | ||||
| 			expectedStatus:   http.StatusOK,                // HTTP status is 200 OK for business errors | ||||
| 			expectedCode:     controller.CodeInternalError, // Business code for internal server error | ||||
| 			expectedMessage:  "创建设备失败",                     // The message returned by the controller | ||||
| 			expectedDataFunc: func(data interface{}) bool { return data == nil }, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tc := range tests { | ||||
| 		t.Run(tc.name, func(t *testing.T) { | ||||
| 			runTest(t, tc, func(ctx *gin.Context, repo *MockDeviceRepository) { | ||||
| 				device.NewController(repo, logs.NewSilentLogger()).CreateDevice(ctx) | ||||
| 			}) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestGetDevice(t *testing.T) { | ||||
| 	gin.SetMode(gin.TestMode) | ||||
|  | ||||
| 	tests := []testCase{ | ||||
| 		{ | ||||
| 			name:        "成功获取设备", | ||||
| 			httpMethod:  http.MethodGet, | ||||
| 			requestBody: nil, | ||||
| 			paramID:     "1", | ||||
| 			mockRepoSetup: func(m *MockDeviceRepository) { | ||||
| 				m.On("FindByIDString", "1").Return(&models.Device{ | ||||
| 					Model: gorm.Model{ | ||||
| 						ID:        1, | ||||
| 						CreatedAt: time.Now(), | ||||
| 						UpdatedAt: time.Now(), | ||||
| 					}, | ||||
| 					Name:       "测试设备", | ||||
| 					Type:       models.DeviceTypeAreaController, | ||||
| 					Location:   "测试地点", | ||||
| 					Properties: datatypes.JSON(`{"key":"value"}`), | ||||
| 				}, nil).Once() | ||||
| 			}, | ||||
| 			expectedStatus:  http.StatusOK, | ||||
| 			expectedCode:    controller.CodeSuccess, | ||||
| 			expectedMessage: "获取设备信息成功", | ||||
| 			expectedDataFunc: func(data interface{}) bool { | ||||
| 				dataMap, ok := data.(map[string]interface{}) | ||||
| 				if !ok { | ||||
| 					return false | ||||
| 				} | ||||
| 				return dataMap["id"] == float64(1) && | ||||
| 					dataMap["name"] == "测试设备" && | ||||
| 					dataMap["properties"] != nil | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:        "设备未找到", | ||||
| 			httpMethod:  http.MethodGet, | ||||
| 			requestBody: nil, | ||||
| 			paramID:     "999", | ||||
| 			mockRepoSetup: func(m *MockDeviceRepository) { | ||||
| 				m.On("FindByIDString", "999").Return(nil, gorm.ErrRecordNotFound).Once() | ||||
| 			}, | ||||
| 			expectedStatus:   http.StatusOK, | ||||
| 			expectedCode:     controller.CodeNotFound, | ||||
| 			expectedMessage:  "设备未找到", | ||||
| 			expectedDataFunc: func(data interface{}) bool { return data == nil }, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:        "ID格式无效", | ||||
| 			httpMethod:  http.MethodGet, | ||||
| 			requestBody: nil, | ||||
| 			paramID:     "abc", | ||||
| 			mockRepoSetup: func(m *MockDeviceRepository) { | ||||
| 				m.On("FindByIDString", "abc").Return(nil, errors.New("无效的设备ID格式")).Once() | ||||
| 			}, | ||||
| 			expectedStatus:   http.StatusOK, | ||||
| 			expectedCode:     controller.CodeBadRequest, | ||||
| 			expectedMessage:  "无效的设备ID格式", | ||||
| 			expectedDataFunc: func(data interface{}) bool { return data == nil }, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:        "数据库查询失败", | ||||
| 			httpMethod:  http.MethodGet, | ||||
| 			requestBody: nil, | ||||
| 			paramID:     "1", | ||||
| 			mockRepoSetup: func(m *MockDeviceRepository) { | ||||
| 				m.On("FindByIDString", "1").Return(nil, errors.New("db error")).Once() | ||||
| 			}, | ||||
| 			expectedStatus:   http.StatusOK, | ||||
| 			expectedCode:     controller.CodeInternalError, | ||||
| 			expectedMessage:  "获取设备信息失败", | ||||
| 			expectedDataFunc: func(data interface{}) bool { return data == nil }, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tc := range tests { | ||||
| 		t.Run(tc.name, func(t *testing.T) { | ||||
| 			runTest(t, tc, func(ctx *gin.Context, repo *MockDeviceRepository) { | ||||
| 				device.NewController(repo, logs.NewSilentLogger()).GetDevice(ctx) | ||||
| 			}) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestListDevices(t *testing.T) { | ||||
| 	gin.SetMode(gin.TestMode) | ||||
|  | ||||
| 	tests := []testCase{ | ||||
| 		{ | ||||
| 			name:        "成功获取空列表", | ||||
| 			httpMethod:  http.MethodGet, | ||||
| 			requestBody: nil, | ||||
| 			paramID:     "", | ||||
| 			mockRepoSetup: func(m *MockDeviceRepository) { | ||||
| 				m.On("ListAll").Return([]*models.Device{}, nil).Once() | ||||
| 			}, | ||||
| 			expectedStatus:  http.StatusOK, | ||||
| 			expectedCode:    controller.CodeSuccess, | ||||
| 			expectedMessage: "获取设备列表成功", | ||||
| 			expectedDataFunc: func(data interface{}) bool { | ||||
| 				s, ok := data.([]interface{}) | ||||
| 				return ok && len(s) == 0 | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:        "成功获取包含设备的列表", | ||||
| 			httpMethod:  http.MethodGet, | ||||
| 			requestBody: nil, | ||||
| 			paramID:     "", | ||||
| 			mockRepoSetup: func(m *MockDeviceRepository) { | ||||
| 				m.On("ListAll").Return([]*models.Device{ | ||||
| 					{ | ||||
| 						Model: gorm.Model{ | ||||
| 							ID:        1, | ||||
| 							CreatedAt: time.Now(), | ||||
| 							UpdatedAt: time.Now(), | ||||
| 						}, | ||||
| 						Name: "设备1", | ||||
| 						Type: models.DeviceTypeAreaController, | ||||
| 					}, | ||||
| 					{ | ||||
| 						Model: gorm.Model{ | ||||
| 							ID:        2, | ||||
| 							CreatedAt: time.Now(), | ||||
| 							UpdatedAt: time.Now(), | ||||
| 						}, | ||||
| 						Name:     "设备2", | ||||
| 						Type:     models.DeviceTypeDevice, | ||||
| 						SubType:  models.SubTypeFan, | ||||
| 						ParentID: func() *uint { id := uint(1); return &id }(), | ||||
| 					}, | ||||
| 				}, nil).Once() | ||||
| 			}, | ||||
| 			expectedStatus:  http.StatusOK, | ||||
| 			expectedCode:    controller.CodeSuccess, | ||||
| 			expectedMessage: "获取设备列表成功", | ||||
| 			expectedDataFunc: func(data interface{}) bool { | ||||
| 				dataList, ok := data.([]interface{}) | ||||
| 				if !ok { | ||||
| 					return false | ||||
| 				} | ||||
| 				// 检查长度 | ||||
| 				if len(dataList) != 2 { | ||||
| 					return false | ||||
| 				} | ||||
| 				// 检查第一个设备 | ||||
| 				item1, ok1 := dataList[0].(map[string]interface{}) | ||||
| 				if !ok1 || item1["id"] != float64(1) || item1["name"] != "设备1" { | ||||
| 					return false | ||||
| 				} | ||||
| 				// 检查第二个设备 | ||||
| 				item2, ok2 := dataList[1].(map[string]interface{}) | ||||
| 				if !ok2 || item2["id"] != float64(2) || item2["name"] != "设备2" { | ||||
| 					return false | ||||
| 				} | ||||
| 				return true | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:        "数据库查询失败", | ||||
| 			httpMethod:  http.MethodGet, | ||||
| 			requestBody: nil, | ||||
| 			paramID:     "", | ||||
| 			mockRepoSetup: func(m *MockDeviceRepository) { | ||||
| 				m.On("ListAll").Return(nil, errors.New("db error")).Once() | ||||
| 			}, | ||||
| 			expectedStatus:   http.StatusOK, | ||||
| 			expectedCode:     controller.CodeInternalError, | ||||
| 			expectedMessage:  "获取设备列表失败", | ||||
| 			expectedDataFunc: func(data interface{}) bool { return data == nil }, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tc := range tests { | ||||
| 		t.Run(tc.name, func(t *testing.T) { | ||||
| 			runTest(t, tc, func(ctx *gin.Context, repo *MockDeviceRepository) { | ||||
| 				device.NewController(repo, logs.NewSilentLogger()).ListDevices(ctx) | ||||
| 			}) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestUpdateDevice(t *testing.T) { | ||||
| 	gin.SetMode(gin.TestMode) | ||||
|  | ||||
| 	tests := []testCase{ | ||||
| 		{ | ||||
| 			name:       "成功更新设备", | ||||
| 			httpMethod: http.MethodPut, | ||||
| 			requestBody: device.UpdateDeviceRequest{ | ||||
| 				Name:       "更新后的主控", | ||||
| 				Type:       models.DeviceTypeAreaController, | ||||
| 				Location:   "新地点", | ||||
| 				Properties: controller.Properties(`{"lora_address":"0x5678"}`), | ||||
| 			}, | ||||
| 			paramID: "1", | ||||
| 			mockRepoSetup: func(m *MockDeviceRepository) { | ||||
| 				// 模拟 FindByIDString 找到设备 | ||||
| 				m.On("FindByIDString", "1").Return(&models.Device{ | ||||
| 					Model: gorm.Model{ | ||||
| 						ID:        1, | ||||
| 						CreatedAt: time.Now(), | ||||
| 						UpdatedAt: time.Now(), | ||||
| 					}, | ||||
| 					Name:       "旧主控", | ||||
| 					Type:       models.DeviceTypeAreaController, | ||||
| 					Location:   "旧地点", | ||||
| 					Properties: datatypes.JSON(`{"lora_address":"0x1234"}`), | ||||
| 				}, nil).Once() | ||||
| 				// 模拟 Update 成功 | ||||
| 				m.On("Update", mock.AnythingOfType("*models.Device")).Return(nil).Once() | ||||
| 			}, | ||||
| 			expectedStatus:  http.StatusOK, | ||||
| 			expectedCode:    controller.CodeSuccess, | ||||
| 			expectedMessage: "设备更新成功", | ||||
| 			expectedDataFunc: func(data interface{}) bool { | ||||
| 				dataMap, ok := data.(map[string]interface{}) | ||||
| 				if !ok { | ||||
| 					return false | ||||
| 				} | ||||
| 				return dataMap["id"] == float64(1) && | ||||
| 					dataMap["name"] == "更新后的主控" && | ||||
| 					dataMap["location"] == "新地点" && | ||||
| 					dataMap["properties"] != nil | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:       "请求参数绑定失败", | ||||
| 			httpMethod: http.MethodPut, | ||||
| 			requestBody: device.UpdateDeviceRequest{ | ||||
| 				Name: "", // 缺少必填字段 Name | ||||
| 				Type: models.DeviceTypeAreaController, | ||||
| 			}, | ||||
| 			paramID: "1", | ||||
| 			mockRepoSetup: func(m *MockDeviceRepository) { | ||||
| 				// 模拟 FindByIDString 找到设备,以便进入参数绑定阶段 | ||||
| 				m.On("FindByIDString", "1").Return(&models.Device{Model: gorm.Model{ID: 1}}, nil).Once() | ||||
| 			}, | ||||
| 			expectedStatus:   http.StatusOK, | ||||
| 			expectedCode:     controller.CodeBadRequest, | ||||
| 			expectedMessage:  "Key: 'UpdateDeviceRequest.Name' Error:Field validation for 'Name' failed on the 'required' tag", | ||||
| 			expectedDataFunc: func(data interface{}) bool { return data == nil }, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:       "设备未找到", | ||||
| 			httpMethod: http.MethodPut, | ||||
| 			requestBody: device.UpdateDeviceRequest{ | ||||
| 				Name: "任意名称", Type: models.DeviceTypeAreaController, | ||||
| 			}, | ||||
| 			paramID: "999", | ||||
| 			mockRepoSetup: func(m *MockDeviceRepository) { | ||||
| 				m.On("FindByIDString", "999").Return(nil, gorm.ErrRecordNotFound).Once() | ||||
| 			}, | ||||
| 			expectedStatus:   http.StatusOK, | ||||
| 			expectedCode:     controller.CodeNotFound, | ||||
| 			expectedMessage:  "设备未找到", | ||||
| 			expectedDataFunc: func(data interface{}) bool { return data == nil }, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:       "ID格式无效", | ||||
| 			httpMethod: http.MethodPut, | ||||
| 			requestBody: device.UpdateDeviceRequest{ | ||||
| 				Name: "任意名称", Type: models.DeviceTypeAreaController, | ||||
| 			}, | ||||
| 			paramID: "abc", | ||||
| 			mockRepoSetup: func(m *MockDeviceRepository) { | ||||
| 				m.On("FindByIDString", "abc").Return(nil, errors.New("无效的设备ID格式")).Once() | ||||
| 			}, | ||||
| 			expectedStatus:   http.StatusOK, | ||||
| 			expectedCode:     controller.CodeBadRequest, | ||||
| 			expectedMessage:  "无效的设备ID格式", | ||||
| 			expectedDataFunc: func(data interface{}) bool { return data == nil }, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:       "数据库更新失败", | ||||
| 			httpMethod: http.MethodPut, | ||||
| 			requestBody: device.UpdateDeviceRequest{ | ||||
| 				Name: "更新失败设备", Type: models.DeviceTypeAreaController, | ||||
| 			}, | ||||
| 			paramID: "1", | ||||
| 			mockRepoSetup: func(m *MockDeviceRepository) { | ||||
| 				m.On("FindByIDString", "1").Return(&models.Device{Model: gorm.Model{ID: 1}}, nil).Once() | ||||
| 				m.On("Update", mock.AnythingOfType("*models.Device")).Return(errors.New("db error")).Once() | ||||
| 			}, | ||||
| 			expectedStatus:   http.StatusOK, | ||||
| 			expectedCode:     controller.CodeInternalError, | ||||
| 			expectedMessage:  "更新设备失败", | ||||
| 			expectedDataFunc: func(data interface{}) bool { return data == nil }, | ||||
| 		}, | ||||
| 		// 新增:Properties字段JSON格式无效 | ||||
| 		{ | ||||
| 			name:       "Properties字段JSON格式无效", | ||||
| 			httpMethod: http.MethodPut, | ||||
| 			requestBody: device.UpdateDeviceRequest{ | ||||
| 				Name:       "无效JSON设备", | ||||
| 				Type:       models.DeviceTypeDevice, | ||||
| 				Properties: controller.Properties(`{invalid json}`), | ||||
| 			}, | ||||
| 			paramID: "1", | ||||
| 			mockRepoSetup: func(m *MockDeviceRepository) { | ||||
| 				// 模拟 FindByIDString 找到设备,以便进入参数绑定阶段 | ||||
| 				m.On("FindByIDString", "1").Return(&models.Device{Model: gorm.Model{ID: 1}}, nil).Once() | ||||
| 				// 期望 Update 方法被调用,并返回一个模拟的数据库错误 | ||||
| 				m.On("Update", mock.Anything).Return(errors.New("database error: invalid json format")).Run(func(args mock.Arguments) { | ||||
| 					dev := args.Get(0).(*models.Device) | ||||
| 					assert.Equal(t, "无效JSON设备", dev.Name) | ||||
| 					assert.Equal(t, models.DeviceTypeDevice, dev.Type) | ||||
| 					expectedProperties := controller.Properties(`{invalid json}`) | ||||
| 					assert.True(t, bytes.Equal(dev.Properties, expectedProperties), "Properties should match") | ||||
| 				}).Once() | ||||
| 			}, | ||||
| 			expectedStatus:   http.StatusOK, | ||||
| 			expectedCode:     controller.CodeInternalError, // Expected to be internal server error due to DB error | ||||
| 			expectedMessage:  "更新设备失败",                     // The message returned by the controller | ||||
| 			expectedDataFunc: func(data interface{}) bool { return data == nil }, | ||||
| 		}, | ||||
| 		// 新增:成功更新设备的ParentID | ||||
| 		{ | ||||
| 			name:       "成功更新设备的ParentID", | ||||
| 			httpMethod: http.MethodPut, | ||||
| 			requestBody: device.UpdateDeviceRequest{ | ||||
| 				Name:       "更新ParentID设备", | ||||
| 				Type:       models.DeviceTypeDevice, | ||||
| 				ParentID:   func() *uint { id := uint(10); return &id }(), | ||||
| 				Location:   "新地点", | ||||
| 				Properties: controller.Properties(`{"key":"value"}`), | ||||
| 			}, | ||||
| 			paramID: "1", | ||||
| 			mockRepoSetup: func(m *MockDeviceRepository) { | ||||
| 				// 模拟 FindByIDString 找到设备 | ||||
| 				m.On("FindByIDString", "1").Return(&models.Device{ | ||||
| 					Model: gorm.Model{ | ||||
| 						ID:        1, | ||||
| 						CreatedAt: time.Now(), | ||||
| 						UpdatedAt: time.Now(), | ||||
| 					}, | ||||
| 					Name:       "旧设备", | ||||
| 					Type:       models.DeviceTypeDevice, | ||||
| 					ParentID:   func() *uint { id := uint(1); return &id }(), | ||||
| 					Location:   "旧地点", | ||||
| 					Properties: datatypes.JSON(`{"old_key":"old_value"}`), | ||||
| 				}, nil).Once() | ||||
| 				// 模拟 Update 成功,并验证 ParentID 被更新 | ||||
| 				m.On("Update", mock.MatchedBy(func(dev *models.Device) bool { | ||||
| 					return dev.ID == 1 && *dev.ParentID == 10 | ||||
| 				})).Return(nil).Once() | ||||
| 			}, | ||||
| 			expectedStatus:  http.StatusOK, | ||||
| 			expectedCode:    controller.CodeSuccess, | ||||
| 			expectedMessage: "设备更新成功", | ||||
| 			expectedDataFunc: func(data interface{}) bool { | ||||
| 				dataMap, ok := data.(map[string]interface{}) | ||||
| 				if !ok { | ||||
| 					return false | ||||
| 				} | ||||
| 				return dataMap["id"] == float64(1) && | ||||
| 					dataMap["parent_id"] == float64(10) && | ||||
| 					dataMap["properties"] != nil | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tc := range tests { | ||||
| 		t.Run(tc.name, func(t *testing.T) { | ||||
| 			runTest(t, tc, func(ctx *gin.Context, repo *MockDeviceRepository) { | ||||
| 				device.NewController(repo, logs.NewSilentLogger()).UpdateDevice(ctx) | ||||
| 			}) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestDeleteDevice(t *testing.T) { | ||||
| 	gin.SetMode(gin.TestMode) | ||||
|  | ||||
| 	tests := []testCase{ | ||||
| 		{ | ||||
| 			name:        "成功删除设备", | ||||
| 			httpMethod:  http.MethodDelete, | ||||
| 			requestBody: nil, | ||||
| 			paramID:     "1", | ||||
| 			mockRepoSetup: func(m *MockDeviceRepository) { | ||||
| 				m.On("Delete", uint(1)).Return(nil).Once() | ||||
| 			}, | ||||
| 			expectedStatus:   http.StatusOK, | ||||
| 			expectedCode:     controller.CodeSuccess, | ||||
| 			expectedMessage:  "设备删除成功", | ||||
| 			expectedDataFunc: func(data interface{}) bool { return data == nil }, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:             "ID格式无效", | ||||
| 			httpMethod:       http.MethodDelete, | ||||
| 			requestBody:      nil, | ||||
| 			paramID:          "abc", | ||||
| 			mockRepoSetup:    func(m *MockDeviceRepository) {}, | ||||
| 			expectedStatus:   http.StatusOK, | ||||
| 			expectedCode:     controller.CodeBadRequest, | ||||
| 			expectedMessage:  "无效的设备ID格式", | ||||
| 			expectedDataFunc: func(data interface{}) bool { return data == nil }, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:        "数据库删除失败", | ||||
| 			httpMethod:  http.MethodDelete, | ||||
| 			requestBody: nil, | ||||
| 			paramID:     "1", | ||||
| 			mockRepoSetup: func(m *MockDeviceRepository) { | ||||
| 				m.On("Delete", uint(1)).Return(errors.New("db error")).Once() | ||||
| 			}, | ||||
| 			expectedStatus:   http.StatusOK, | ||||
| 			expectedCode:     controller.CodeInternalError, | ||||
| 			expectedMessage:  "删除设备失败", | ||||
| 			expectedDataFunc: func(data interface{}) bool { return data == nil }, | ||||
| 		}, | ||||
| 		// 新增:删除设备未找到 | ||||
| 		{ | ||||
| 			name:        "删除设备未找到", | ||||
| 			httpMethod:  http.MethodDelete, | ||||
| 			requestBody: nil, | ||||
| 			paramID:     "999", | ||||
| 			mockRepoSetup: func(m *MockDeviceRepository) { | ||||
| 				m.On("Delete", uint(999)).Return(gorm.ErrRecordNotFound).Once() | ||||
| 			}, | ||||
| 			expectedStatus:   http.StatusOK, | ||||
| 			expectedCode:     controller.CodeInternalError, // 当前控制器逻辑会将 ErrRecordNotFound 视为内部错误 | ||||
| 			expectedMessage:  "删除设备失败", | ||||
| 			expectedDataFunc: func(data interface{}) bool { return data == nil }, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tc := range tests { | ||||
| 		t.Run(tc.name, func(t *testing.T) { | ||||
| 			runTest(t, tc, func(ctx *gin.Context, repo *MockDeviceRepository) { | ||||
| 				device.NewController(repo, logs.NewSilentLogger()).DeleteDevice(ctx) | ||||
| 			}) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
| @@ -7,39 +7,39 @@ import ( | ||||
|  | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/controller" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/service" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"github.com/labstack/echo/v4" | ||||
| ) | ||||
|  | ||||
| // mapAndSendError 统一映射服务层错误并发送响应。 | ||||
| // 这个函数将服务层返回的错误转换为控制器层应返回的HTTP状态码和审计信息。 | ||||
| func mapAndSendError(c *PigBatchController, ctx *gin.Context, action string, err error, id uint) { | ||||
| func mapAndSendError(c *PigBatchController, ctx echo.Context, action string, err error, id uint) error { | ||||
| 	if errors.Is(err, service.ErrPigBatchNotFound) || | ||||
| 		errors.Is(err, service.ErrPenNotFound) || | ||||
| 		errors.Is(err, service.ErrPenNotAssociatedWithBatch) { | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), action, err.Error(), id) | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), action, err.Error(), id) | ||||
| 	} else if errors.Is(err, service.ErrInvalidOperation) || | ||||
| 		errors.Is(err, service.ErrPigBatchActive) || | ||||
| 		errors.Is(err, service.ErrPigBatchNotActive) || | ||||
| 		errors.Is(err, service.ErrPenOccupiedByOtherBatch) || | ||||
| 		errors.Is(err, service.ErrPenStatusInvalidForAllocation) || | ||||
| 		errors.Is(err, service.ErrPenNotEmpty) { | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), action, err.Error(), id) | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), action, err.Error(), id) | ||||
| 	} else { | ||||
| 		c.logger.Errorf("操作[%s]业务逻辑失败: %v", action, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, fmt.Sprintf("操作失败: %v", err), action, err.Error(), id) | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, fmt.Sprintf("操作失败: %v", err), action, err.Error(), id) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // idExtractorFunc 定义了一个函数类型,用于从gin.Context中提取主ID。 | ||||
| type idExtractorFunc func(ctx *gin.Context) (uint, error) | ||||
| // idExtractorFunc 定义了一个函数类型,用于从echo.Context中提取主ID。 | ||||
| type idExtractorFunc func(ctx echo.Context) (uint, error) | ||||
|  | ||||
| // extractOperatorAndPrimaryID 封装了从gin.Context中提取操作员ID和主ID的通用逻辑。 | ||||
| // extractOperatorAndPrimaryID 封装了从echo.Context中提取操作员ID和主ID的通用逻辑。 | ||||
| // 它负责处理ID提取过程中的错误,并发送相应的HTTP响应。 | ||||
| // | ||||
| // 参数: | ||||
| // | ||||
| //	c: *PigBatchController - 控制器实例,用于访问其日志。 | ||||
| //	ctx: *gin.Context - Gin上下文。 | ||||
| //	ctx: echo.Context - Echo上下文。 | ||||
| //	action: string - 当前操作的描述,用于日志和审计。 | ||||
| //	idExtractor: idExtractorFunc - 可选函数,用于从ctx中提取主ID。如果为nil,则尝试从":id"路径参数中提取。 | ||||
| // | ||||
| @@ -47,26 +47,24 @@ type idExtractorFunc func(ctx *gin.Context) (uint, error) | ||||
| // | ||||
| //	operatorID: uint - 提取到的操作员ID。 | ||||
| //	primaryID: uint - 提取到的主ID。 | ||||
| //	ok: bool - 如果ID提取成功且没有发送错误响应,则为true。 | ||||
| //	err: error - 如果ID提取失败或发送错误响应,则返回错误。 | ||||
| func extractOperatorAndPrimaryID( | ||||
| 	c *PigBatchController, | ||||
| 	ctx *gin.Context, | ||||
| 	ctx echo.Context, | ||||
| 	action string, | ||||
| 	idExtractor idExtractorFunc, | ||||
| ) (operatorID uint, primaryID uint, ok bool) { | ||||
| ) (operatorID uint, primaryID uint, err error) { | ||||
| 	// 1. 获取操作员ID | ||||
| 	operatorID, err := controller.GetOperatorIDFromContext(ctx) | ||||
| 	operatorID, err = controller.GetOperatorIDFromContext(ctx) | ||||
| 	if err != nil { | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeUnauthorized, "未授权", action, "无法获取操作员ID", nil) | ||||
| 		return 0, 0, false | ||||
| 		return 0, 0, controller.SendErrorWithAudit(ctx, controller.CodeUnauthorized, "未授权", action, "无法获取操作员ID", nil) | ||||
| 	} | ||||
|  | ||||
| 	// 2. 提取主ID | ||||
| 	if idExtractor != nil { | ||||
| 		primaryID, err = idExtractor(ctx) | ||||
| 		if err != nil { | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", err.Error()) | ||||
| 			return 0, 0, false | ||||
| 			return 0, 0, controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", err.Error()) | ||||
| 		} | ||||
| 	} else { // 默认从 ":id" 路径参数提取 | ||||
| 		idParam := ctx.Param("id") | ||||
| @@ -75,165 +73,155 @@ func extractOperatorAndPrimaryID( | ||||
| 		} else { | ||||
| 			parsedID, err := strconv.ParseUint(idParam, 10, 32) | ||||
| 			if err != nil { | ||||
| 				controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", idParam) | ||||
| 				return 0, 0, false | ||||
| 				return 0, 0, controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", idParam) | ||||
| 			} | ||||
| 			primaryID = uint(parsedID) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return operatorID, primaryID, true | ||||
| 	return operatorID, primaryID, nil | ||||
| } | ||||
|  | ||||
| // handleAPIRequest 封装了控制器中处理带有请求体和路径参数的API请求的通用逻辑。 | ||||
| // 它负责请求体绑定、操作员ID获取、服务层调用、错误映射和响应发送。 | ||||
| func handleAPIRequest[Req any]( | ||||
| 	c *PigBatchController, | ||||
| 	ctx *gin.Context, | ||||
| 	ctx echo.Context, | ||||
| 	action string, | ||||
| 	reqDTO Req, | ||||
| 	serviceExecutor func(ctx *gin.Context, operatorID uint, primaryID uint, req Req) error, | ||||
| 	serviceExecutor func(ctx echo.Context, operatorID uint, primaryID uint, req Req) error, | ||||
| 	successMsg string, | ||||
| 	idExtractor idExtractorFunc, | ||||
| ) { | ||||
| ) error { | ||||
| 	// 1. 绑定请求体 | ||||
| 	if err := ctx.ShouldBindJSON(&reqDTO); err != nil { | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体", action, "请求体绑定失败", reqDTO) | ||||
| 		return | ||||
| 	if err := ctx.Bind(&reqDTO); err != nil { | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体", action, "请求体绑定失败", reqDTO) | ||||
| 	} | ||||
|  | ||||
| 	// 2. 提取操作员ID和主ID | ||||
| 	operatorID, primaryID, ok := extractOperatorAndPrimaryID(c, ctx, action, idExtractor) | ||||
| 	if !ok { | ||||
| 		return // 错误已在 extractOperatorAndPrimaryID 中处理 | ||||
| 	operatorID, primaryID, err := extractOperatorAndPrimaryID(c, ctx, action, idExtractor) | ||||
| 	if err != nil { | ||||
| 		return err // 错误已在 extractOperatorAndPrimaryID 中处理 | ||||
| 	} | ||||
|  | ||||
| 	// 3. 执行服务层逻辑 | ||||
| 	err := serviceExecutor(ctx, operatorID, primaryID, reqDTO) | ||||
| 	err = serviceExecutor(ctx, operatorID, primaryID, reqDTO) | ||||
| 	if err != nil { | ||||
| 		mapAndSendError(c, ctx, action, err, primaryID) | ||||
| 		return | ||||
| 		return mapAndSendError(c, ctx, action, err, primaryID) | ||||
| 	} | ||||
|  | ||||
| 	// 4. 发送成功响应 | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, successMsg, nil, action, successMsg, primaryID) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, successMsg, nil, action, successMsg, primaryID) | ||||
| } | ||||
|  | ||||
| // handleNoBodyAPIRequest 封装了处理不带请求体,但有路径参数和操作员ID的API请求的通用逻辑。 | ||||
| func handleNoBodyAPIRequest( | ||||
| 	c *PigBatchController, | ||||
| 	ctx *gin.Context, | ||||
| 	ctx echo.Context, | ||||
| 	action string, | ||||
| 	serviceExecutor func(ctx *gin.Context, operatorID uint, primaryID uint) error, | ||||
| 	serviceExecutor func(ctx echo.Context, operatorID uint, primaryID uint) error, | ||||
| 	successMsg string, | ||||
| 	idExtractor idExtractorFunc, | ||||
| ) { | ||||
| ) error { | ||||
| 	// 1. 提取操作员ID和主ID | ||||
| 	operatorID, primaryID, ok := extractOperatorAndPrimaryID(c, ctx, action, idExtractor) | ||||
| 	if !ok { | ||||
| 		return // 错误已在 extractOperatorAndPrimaryID 中处理 | ||||
| 	operatorID, primaryID, err := extractOperatorAndPrimaryID(c, ctx, action, idExtractor) | ||||
| 	if err != nil { | ||||
| 		return err // 错误已在 extractOperatorAndPrimaryID 中处理 | ||||
| 	} | ||||
|  | ||||
| 	// 2. 执行服务层逻辑 | ||||
| 	err := serviceExecutor(ctx, operatorID, primaryID) | ||||
| 	err = serviceExecutor(ctx, operatorID, primaryID) | ||||
| 	if err != nil { | ||||
| 		mapAndSendError(c, ctx, action, err, primaryID) | ||||
| 		return | ||||
| 		return mapAndSendError(c, ctx, action, err, primaryID) | ||||
| 	} | ||||
|  | ||||
| 	// 3. 发送成功响应 | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, successMsg, nil, action, successMsg, primaryID) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, successMsg, nil, action, successMsg, primaryID) | ||||
| } | ||||
|  | ||||
| // handleAPIRequestWithResponse 封装了控制器中处理带有请求体、路径参数并返回响应DTO的API请求的通用逻辑。 | ||||
| func handleAPIRequestWithResponse[Req any, Resp any]( | ||||
| 	c *PigBatchController, | ||||
| 	ctx *gin.Context, | ||||
| 	ctx echo.Context, | ||||
| 	action string, | ||||
| 	reqDTO Req, | ||||
| 	serviceExecutor func(ctx *gin.Context, operatorID uint, primaryID uint, req Req) (Resp, error), // serviceExecutor现在返回Resp | ||||
| 	serviceExecutor func(ctx echo.Context, operatorID uint, primaryID uint, req Req) (Resp, error), // serviceExecutor现在返回Resp | ||||
| 	successMsg string, | ||||
| 	idExtractor idExtractorFunc, | ||||
| ) { | ||||
| ) error { | ||||
| 	// 1. 绑定请求体 | ||||
| 	if err := ctx.ShouldBindJSON(&reqDTO); err != nil { | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, fmt.Sprintf("无效的请求体: %v", err), action, fmt.Sprintf("请求体绑定失败: %v", err), reqDTO) | ||||
| 		return | ||||
| 	if err := ctx.Bind(&reqDTO); err != nil { | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, fmt.Sprintf("无效的请求体: %v", err), action, fmt.Sprintf("请求体绑定失败: %v", err), reqDTO) | ||||
| 	} | ||||
|  | ||||
| 	// 2. 提取操作员ID和主ID | ||||
| 	operatorID, primaryID, ok := extractOperatorAndPrimaryID(c, ctx, action, idExtractor) | ||||
| 	if !ok { | ||||
| 		return // 错误已在 extractOperatorAndPrimaryID 中处理 | ||||
| 	operatorID, primaryID, err := extractOperatorAndPrimaryID(c, ctx, action, idExtractor) | ||||
| 	if err != nil { | ||||
| 		return err // 错误已在 extractOperatorAndPrimaryID 中处理 | ||||
| 	} | ||||
|  | ||||
| 	// 3. 执行服务层逻辑 | ||||
| 	respDTO, err := serviceExecutor(ctx, operatorID, primaryID, reqDTO) | ||||
| 	if err != nil { | ||||
| 		mapAndSendError(c, ctx, action, err, primaryID) | ||||
| 		return | ||||
| 		return mapAndSendError(c, ctx, action, err, primaryID) | ||||
| 	} | ||||
|  | ||||
| 	// 4. 发送成功响应 | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, successMsg, respDTO, action, successMsg, primaryID) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, successMsg, respDTO, action, successMsg, primaryID) | ||||
| } | ||||
|  | ||||
| // handleNoBodyAPIRequestWithResponse 封装了处理不带请求体,但有路径参数和操作员ID,并返回响应DTO的API请求的通用逻辑。 | ||||
| func handleNoBodyAPIRequestWithResponse[Resp any]( | ||||
| 	c *PigBatchController, | ||||
| 	ctx *gin.Context, | ||||
| 	ctx echo.Context, | ||||
| 	action string, | ||||
| 	serviceExecutor func(ctx *gin.Context, operatorID uint, primaryID uint) (Resp, error), // serviceExecutor现在返回Resp | ||||
| 	serviceExecutor func(ctx echo.Context, operatorID uint, primaryID uint) (Resp, error), // serviceExecutor现在返回Resp | ||||
| 	successMsg string, | ||||
| 	idExtractor idExtractorFunc, | ||||
| ) { | ||||
| ) error { | ||||
| 	// 1. 提取操作员ID和主ID | ||||
| 	operatorID, primaryID, ok := extractOperatorAndPrimaryID(c, ctx, action, idExtractor) | ||||
| 	if !ok { | ||||
| 		return // 错误已在 extractOperatorAndPrimaryID 中处理 | ||||
| 	operatorID, primaryID, err := extractOperatorAndPrimaryID(c, ctx, action, idExtractor) | ||||
| 	if err != nil { | ||||
| 		return err // 错误已在 extractOperatorAndPrimaryID 中处理 | ||||
| 	} | ||||
|  | ||||
| 	// 2. 执行服务层逻辑 | ||||
| 	respDTO, err := serviceExecutor(ctx, operatorID, primaryID) | ||||
| 	if err != nil { | ||||
| 		mapAndSendError(c, ctx, action, err, primaryID) | ||||
| 		return | ||||
| 		return mapAndSendError(c, ctx, action, err, primaryID) | ||||
| 	} | ||||
|  | ||||
| 	// 3. 发送成功响应 | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, successMsg, respDTO, action, successMsg, primaryID) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, successMsg, respDTO, action, successMsg, primaryID) | ||||
| } | ||||
|  | ||||
| // handleQueryAPIRequestWithResponse 封装了处理带有查询参数并返回响应DTO的API请求的通用逻辑。 | ||||
| func handleQueryAPIRequestWithResponse[Query any, Resp any]( | ||||
| 	c *PigBatchController, | ||||
| 	ctx *gin.Context, | ||||
| 	ctx echo.Context, | ||||
| 	action string, | ||||
| 	queryDTO Query, | ||||
| 	serviceExecutor func(ctx *gin.Context, operatorID uint, query Query) (Resp, error), // serviceExecutor现在接收queryDTO | ||||
| 	serviceExecutor func(ctx echo.Context, operatorID uint, query Query) (Resp, error), // serviceExecutor现在接收queryDTO | ||||
| 	successMsg string, | ||||
| ) { | ||||
| ) error { | ||||
| 	// 1. 绑定查询参数 | ||||
| 	if err := ctx.ShouldBindQuery(&queryDTO); err != nil { | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数", action, "查询参数绑定失败", queryDTO) | ||||
| 		return | ||||
| 	if err := ctx.Bind(&queryDTO); err != nil { | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数", action, "查询参数绑定失败", queryDTO) | ||||
| 	} | ||||
|  | ||||
| 	// 2. 获取操作员ID | ||||
| 	operatorID, err := controller.GetOperatorIDFromContext(ctx) | ||||
| 	if err != nil { | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeUnauthorized, "未授权", action, "无法获取操作员ID", nil) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeUnauthorized, "未授权", action, "无法获取操作员ID", nil) | ||||
| 	} | ||||
|  | ||||
| 	// 3. 执行服务层逻辑 | ||||
| 	respDTO, err := serviceExecutor(ctx, operatorID, queryDTO) | ||||
| 	if err != nil { | ||||
| 		// 对于列表查询,通常没有primaryID,所以传递0 | ||||
| 		mapAndSendError(c, ctx, action, err, 0) | ||||
| 		return | ||||
| 		return mapAndSendError(c, ctx, action, err, 0) | ||||
| 	} | ||||
|  | ||||
| 	// 4. 发送成功响应 | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, successMsg, respDTO, action, successMsg, nil) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, successMsg, respDTO, action, successMsg, nil) | ||||
| } | ||||
|   | ||||
| @@ -7,7 +7,7 @@ import ( | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/service" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" | ||||
|  | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"github.com/labstack/echo/v4" | ||||
| ) | ||||
|  | ||||
| // PigBatchController 负责处理猪批次相关的API请求 | ||||
| @@ -34,13 +34,13 @@ func NewPigBatchController(logger *logs.Logger, service service.PigBatchService) | ||||
| // @Param        body body dto.PigBatchCreateDTO true "猪批次信息" | ||||
| // @Success      201 {object} controller.Response{data=dto.PigBatchResponseDTO} "创建成功" | ||||
| // @Router       /api/v1/pig-batches [post] | ||||
| func (c *PigBatchController) CreatePigBatch(ctx *gin.Context) { | ||||
| func (c *PigBatchController) CreatePigBatch(ctx echo.Context) error { | ||||
| 	const action = "创建猪批次" | ||||
| 	var req dto.PigBatchCreateDTO | ||||
|  | ||||
| 	handleAPIRequestWithResponse( | ||||
| 	return handleAPIRequestWithResponse( | ||||
| 		c, ctx, action, &req, | ||||
| 		func(ctx *gin.Context, operatorID uint, primaryID uint, req *dto.PigBatchCreateDTO) (*dto.PigBatchResponseDTO, error) { | ||||
| 		func(ctx echo.Context, operatorID uint, primaryID uint, req *dto.PigBatchCreateDTO) (*dto.PigBatchResponseDTO, error) { | ||||
| 			// 对于创建操作,primaryID通常不从路径中获取,而是由服务层生成 | ||||
| 			return c.service.CreatePigBatch(operatorID, req) | ||||
| 		}, | ||||
| @@ -58,12 +58,12 @@ func (c *PigBatchController) CreatePigBatch(ctx *gin.Context) { | ||||
| // @Param        id path int true "猪批次ID" | ||||
| // @Success      200 {object} controller.Response{data=dto.PigBatchResponseDTO} "获取成功" | ||||
| // @Router       /api/v1/pig-batches/{id} [get] | ||||
| func (c *PigBatchController) GetPigBatch(ctx *gin.Context) { | ||||
| func (c *PigBatchController) GetPigBatch(ctx echo.Context) error { | ||||
| 	const action = "获取猪批次" | ||||
|  | ||||
| 	handleNoBodyAPIRequestWithResponse( | ||||
| 	return handleNoBodyAPIRequestWithResponse( | ||||
| 		c, ctx, action, | ||||
| 		func(ctx *gin.Context, operatorID uint, primaryID uint) (*dto.PigBatchResponseDTO, error) { | ||||
| 		func(ctx echo.Context, operatorID uint, primaryID uint) (*dto.PigBatchResponseDTO, error) { | ||||
| 			return c.service.GetPigBatch(primaryID) | ||||
| 		}, | ||||
| 		"获取成功", | ||||
| @@ -82,13 +82,13 @@ func (c *PigBatchController) GetPigBatch(ctx *gin.Context) { | ||||
| // @Param        body body dto.PigBatchUpdateDTO true "猪批次信息" | ||||
| // @Success      200 {object} controller.Response{data=dto.PigBatchResponseDTO} "更新成功" | ||||
| // @Router       /api/v1/pig-batches/{id} [put] | ||||
| func (c *PigBatchController) UpdatePigBatch(ctx *gin.Context) { | ||||
| func (c *PigBatchController) UpdatePigBatch(ctx echo.Context) error { | ||||
| 	const action = "更新猪批次" | ||||
| 	var req dto.PigBatchUpdateDTO | ||||
|  | ||||
| 	handleAPIRequestWithResponse( | ||||
| 	return handleAPIRequestWithResponse( | ||||
| 		c, ctx, action, &req, | ||||
| 		func(ctx *gin.Context, operatorID uint, primaryID uint, req *dto.PigBatchUpdateDTO) (*dto.PigBatchResponseDTO, error) { | ||||
| 		func(ctx echo.Context, operatorID uint, primaryID uint, req *dto.PigBatchUpdateDTO) (*dto.PigBatchResponseDTO, error) { | ||||
| 			return c.service.UpdatePigBatch(primaryID, req) | ||||
| 		}, | ||||
| 		"更新成功", | ||||
| @@ -105,12 +105,12 @@ func (c *PigBatchController) UpdatePigBatch(ctx *gin.Context) { | ||||
| // @Param        id path int true "猪批次ID" | ||||
| // @Success      200 {object} controller.Response "删除成功" | ||||
| // @Router       /api/v1/pig-batches/{id} [delete] | ||||
| func (c *PigBatchController) DeletePigBatch(ctx *gin.Context) { | ||||
| func (c *PigBatchController) DeletePigBatch(ctx echo.Context) error { | ||||
| 	const action = "删除猪批次" | ||||
|  | ||||
| 	handleNoBodyAPIRequest( | ||||
| 	return handleNoBodyAPIRequest( | ||||
| 		c, ctx, action, | ||||
| 		func(ctx *gin.Context, operatorID uint, primaryID uint) error { | ||||
| 		func(ctx echo.Context, operatorID uint, primaryID uint) error { | ||||
| 			return c.service.DeletePigBatch(primaryID) | ||||
| 		}, | ||||
| 		"删除成功", | ||||
| @@ -127,13 +127,13 @@ func (c *PigBatchController) DeletePigBatch(ctx *gin.Context) { | ||||
| // @Param        is_active query bool false "是否活跃 (true/false)" | ||||
| // @Success      200 {object} controller.Response{data=[]dto.PigBatchResponseDTO} "获取成功" | ||||
| // @Router       /api/v1/pig-batches [get] | ||||
| func (c *PigBatchController) ListPigBatches(ctx *gin.Context) { | ||||
| func (c *PigBatchController) ListPigBatches(ctx echo.Context) error { | ||||
| 	const action = "获取猪批次列表" | ||||
| 	var query dto.PigBatchQueryDTO | ||||
|  | ||||
| 	handleQueryAPIRequestWithResponse( | ||||
| 	return handleQueryAPIRequestWithResponse( | ||||
| 		c, ctx, action, &query, | ||||
| 		func(ctx *gin.Context, operatorID uint, query *dto.PigBatchQueryDTO) ([]*dto.PigBatchResponseDTO, error) { | ||||
| 		func(ctx echo.Context, operatorID uint, query *dto.PigBatchQueryDTO) ([]*dto.PigBatchResponseDTO, error) { | ||||
| 			return c.service.ListPigBatches(query.IsActive) | ||||
| 		}, | ||||
| 		"获取成功", | ||||
| @@ -151,13 +151,13 @@ func (c *PigBatchController) ListPigBatches(ctx *gin.Context) { | ||||
| // @Param        body body dto.AssignEmptyPensToBatchRequest true "待分配的猪栏ID列表" | ||||
| // @Success      200 {object} controller.Response "分配成功" | ||||
| // @Router       /api/v1/pig-batches/assign-pens/{id} [post] | ||||
| func (c *PigBatchController) AssignEmptyPensToBatch(ctx *gin.Context) { | ||||
| func (c *PigBatchController) AssignEmptyPensToBatch(ctx echo.Context) error { | ||||
| 	const action = "为猪批次分配空栏" | ||||
| 	var req dto.AssignEmptyPensToBatchRequest | ||||
|  | ||||
| 	handleAPIRequest( | ||||
| 	return handleAPIRequest( | ||||
| 		c, ctx, action, &req, | ||||
| 		func(ctx *gin.Context, operatorID uint, primaryID uint, req *dto.AssignEmptyPensToBatchRequest) error { | ||||
| 		func(ctx echo.Context, operatorID uint, primaryID uint, req *dto.AssignEmptyPensToBatchRequest) error { | ||||
| 			return c.service.AssignEmptyPensToBatch(primaryID, req.PenIDs, operatorID) | ||||
| 		}, | ||||
| 		"分配成功", | ||||
| @@ -176,18 +176,18 @@ func (c *PigBatchController) AssignEmptyPensToBatch(ctx *gin.Context) { | ||||
| // @Param        body body dto.ReclassifyPenToNewBatchRequest true "划拨请求信息 (包含目标批次ID、猪栏ID和备注)" | ||||
| // @Success      200 {object} controller.Response "划拨成功" | ||||
| // @Router       /api/v1/pig-batches/reclassify-pen/{fromBatchID} [post] | ||||
| func (c *PigBatchController) ReclassifyPenToNewBatch(ctx *gin.Context) { | ||||
| func (c *PigBatchController) ReclassifyPenToNewBatch(ctx echo.Context) error { | ||||
| 	const action = "划拨猪栏到新批次" | ||||
| 	var req dto.ReclassifyPenToNewBatchRequest | ||||
|  | ||||
| 	handleAPIRequest( | ||||
| 	return handleAPIRequest( | ||||
| 		c, ctx, action, &req, | ||||
| 		func(ctx *gin.Context, operatorID uint, primaryID uint, req *dto.ReclassifyPenToNewBatchRequest) error { | ||||
| 		func(ctx echo.Context, operatorID uint, primaryID uint, req *dto.ReclassifyPenToNewBatchRequest) error { | ||||
| 			// primaryID 在这里是 fromBatchID | ||||
| 			return c.service.ReclassifyPenToNewBatch(primaryID, req.ToBatchID, req.PenID, operatorID, req.Remarks) | ||||
| 		}, | ||||
| 		"划拨成功", | ||||
| 		func(ctx *gin.Context) (uint, error) { // 自定义ID提取器,从 ":fromBatchID" 路径参数提取 | ||||
| 		func(ctx echo.Context) (uint, error) { // 自定义ID提取器,从 ":fromBatchID" 路径参数提取 | ||||
| 			idParam := ctx.Param("fromBatchID") | ||||
| 			parsedID, err := strconv.ParseUint(idParam, 10, 32) | ||||
| 			if err != nil { | ||||
| @@ -208,22 +208,22 @@ func (c *PigBatchController) ReclassifyPenToNewBatch(ctx *gin.Context) { | ||||
| // @Param        penID path int true "待移除的猪栏ID" | ||||
| // @Success      200 {object} controller.Response "移除成功" | ||||
| // @Router       /api/v1/pig-batches/remove-pen/{penID}/{batchID} [delete] | ||||
| func (c *PigBatchController) RemoveEmptyPenFromBatch(ctx *gin.Context) { | ||||
| func (c *PigBatchController) RemoveEmptyPenFromBatch(ctx echo.Context) error { | ||||
| 	const action = "从猪批次移除空栏" | ||||
|  | ||||
| 	handleNoBodyAPIRequest( | ||||
| 	return handleNoBodyAPIRequest( | ||||
| 		c, ctx, action, | ||||
| 		func(ctx *gin.Context, operatorID uint, primaryID uint) error { | ||||
| 		func(ctx echo.Context, operatorID uint, primaryID uint) error { | ||||
| 			// primaryID 在这里是 batchID | ||||
| 			penIDParam := ctx.Param("penID") | ||||
| 			penID, err := strconv.ParseUint(penIDParam, 10, 32) | ||||
| 			parsedPenID, err := strconv.ParseUint(penIDParam, 10, 32) | ||||
| 			if err != nil { | ||||
| 				return err // 返回错误,因为 penID 格式无效 | ||||
| 			} | ||||
| 			return c.service.RemoveEmptyPenFromBatch(primaryID, uint(penID)) | ||||
| 			return c.service.RemoveEmptyPenFromBatch(primaryID, uint(parsedPenID)) | ||||
| 		}, | ||||
| 		"移除成功", | ||||
| 		func(ctx *gin.Context) (uint, error) { // 自定义ID提取器,从 ":batchID" 路径参数提取 | ||||
| 		func(ctx echo.Context) (uint, error) { // 自定义ID提取器,从 ":batchID" 路径参数提取 | ||||
| 			idParam := ctx.Param("batchID") | ||||
| 			parsedID, err := strconv.ParseUint(idParam, 10, 32) | ||||
| 			if err != nil { | ||||
| @@ -245,13 +245,13 @@ func (c *PigBatchController) RemoveEmptyPenFromBatch(ctx *gin.Context) { | ||||
| // @Param        body body dto.MovePigsIntoPenRequest true "移入猪只请求信息 (包含目标猪栏ID、数量和备注)" | ||||
| // @Success      200 {object} controller.Response "移入成功" | ||||
| // @Router       /api/v1/pig-batches/move-pigs-into-pen/{id} [post] | ||||
| func (c *PigBatchController) MovePigsIntoPen(ctx *gin.Context) { | ||||
| func (c *PigBatchController) MovePigsIntoPen(ctx echo.Context) error { | ||||
| 	const action = "将猪只移入猪栏" | ||||
| 	var req dto.MovePigsIntoPenRequest | ||||
|  | ||||
| 	handleAPIRequest( | ||||
| 	return handleAPIRequest( | ||||
| 		c, ctx, action, &req, | ||||
| 		func(ctx *gin.Context, operatorID uint, primaryID uint, req *dto.MovePigsIntoPenRequest) error { | ||||
| 		func(ctx echo.Context, operatorID uint, primaryID uint, req *dto.MovePigsIntoPenRequest) error { | ||||
| 			return c.service.MovePigsIntoPen(primaryID, req.ToPenID, req.Quantity, operatorID, req.Remarks) | ||||
| 		}, | ||||
| 		"移入成功", | ||||
|   | ||||
| @@ -2,7 +2,7 @@ package management | ||||
|  | ||||
| import ( | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/dto" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"github.com/labstack/echo/v4" | ||||
| ) | ||||
|  | ||||
| // RecordSickPigs godoc | ||||
| @@ -16,13 +16,13 @@ import ( | ||||
| // @Param        body body dto.RecordSickPigsRequest true "记录病猪请求信息" | ||||
| // @Success      200 {object} controller.Response "记录成功" | ||||
| // @Router       /api/v1/pig-batches/record-sick-pigs/{id} [post] | ||||
| func (c *PigBatchController) RecordSickPigs(ctx *gin.Context) { | ||||
| func (c *PigBatchController) RecordSickPigs(ctx echo.Context) error { | ||||
| 	const action = "记录新增病猪事件" | ||||
| 	var req dto.RecordSickPigsRequest | ||||
|  | ||||
| 	handleAPIRequest( | ||||
| 	return handleAPIRequest( | ||||
| 		c, ctx, action, &req, | ||||
| 		func(ctx *gin.Context, operatorID uint, primaryID uint, req *dto.RecordSickPigsRequest) error { | ||||
| 		func(ctx echo.Context, operatorID uint, primaryID uint, req *dto.RecordSickPigsRequest) error { | ||||
| 			return c.service.RecordSickPigs(operatorID, primaryID, req.PenID, req.Quantity, req.TreatmentLocation, req.HappenedAt, req.Remarks) | ||||
| 		}, | ||||
| 		"记录成功", | ||||
| @@ -41,13 +41,13 @@ func (c *PigBatchController) RecordSickPigs(ctx *gin.Context) { | ||||
| // @Param        body body dto.RecordSickPigRecoveryRequest true "记录病猪康复请求信息" | ||||
| // @Success      200 {object} controller.Response "记录成功" | ||||
| // @Router       /api/v1/pig-batches/record-sick-pig-recovery/{id} [post] | ||||
| func (c *PigBatchController) RecordSickPigRecovery(ctx *gin.Context) { | ||||
| func (c *PigBatchController) RecordSickPigRecovery(ctx echo.Context) error { | ||||
| 	const action = "记录病猪康复事件" | ||||
| 	var req dto.RecordSickPigRecoveryRequest | ||||
|  | ||||
| 	handleAPIRequest( | ||||
| 	return handleAPIRequest( | ||||
| 		c, ctx, action, &req, | ||||
| 		func(ctx *gin.Context, operatorID uint, primaryID uint, req *dto.RecordSickPigRecoveryRequest) error { | ||||
| 		func(ctx echo.Context, operatorID uint, primaryID uint, req *dto.RecordSickPigRecoveryRequest) error { | ||||
| 			return c.service.RecordSickPigRecovery(operatorID, primaryID, req.PenID, req.Quantity, req.TreatmentLocation, req.HappenedAt, req.Remarks) | ||||
| 		}, | ||||
| 		"记录成功", | ||||
| @@ -66,13 +66,13 @@ func (c *PigBatchController) RecordSickPigRecovery(ctx *gin.Context) { | ||||
| // @Param        body body dto.RecordSickPigDeathRequest true "记录病猪死亡请求信息" | ||||
| // @Success      200 {object} controller.Response "记录成功" | ||||
| // @Router       /api/v1/pig-batches/record-sick-pig-death/{id} [post] | ||||
| func (c *PigBatchController) RecordSickPigDeath(ctx *gin.Context) { | ||||
| func (c *PigBatchController) RecordSickPigDeath(ctx echo.Context) error { | ||||
| 	const action = "记录病猪死亡事件" | ||||
| 	var req dto.RecordSickPigDeathRequest | ||||
|  | ||||
| 	handleAPIRequest( | ||||
| 	return handleAPIRequest( | ||||
| 		c, ctx, action, &req, | ||||
| 		func(ctx *gin.Context, operatorID uint, primaryID uint, req *dto.RecordSickPigDeathRequest) error { | ||||
| 		func(ctx echo.Context, operatorID uint, primaryID uint, req *dto.RecordSickPigDeathRequest) error { | ||||
| 			return c.service.RecordSickPigDeath(operatorID, primaryID, req.PenID, req.Quantity, req.TreatmentLocation, req.HappenedAt, req.Remarks) | ||||
| 		}, | ||||
| 		"记录成功", | ||||
| @@ -91,13 +91,13 @@ func (c *PigBatchController) RecordSickPigDeath(ctx *gin.Context) { | ||||
| // @Param        body body dto.RecordSickPigCullRequest true "记录病猪淘汰请求信息" | ||||
| // @Success      200 {object} controller.Response "记录成功" | ||||
| // @Router       /api/v1/pig-batches/record-sick-pig-cull/{id} [post] | ||||
| func (c *PigBatchController) RecordSickPigCull(ctx *gin.Context) { | ||||
| func (c *PigBatchController) RecordSickPigCull(ctx echo.Context) error { | ||||
| 	const action = "记录病猪淘汰事件" | ||||
| 	var req dto.RecordSickPigCullRequest | ||||
|  | ||||
| 	handleAPIRequest( | ||||
| 	return handleAPIRequest( | ||||
| 		c, ctx, action, &req, | ||||
| 		func(ctx *gin.Context, operatorID uint, primaryID uint, req *dto.RecordSickPigCullRequest) error { | ||||
| 		func(ctx echo.Context, operatorID uint, primaryID uint, req *dto.RecordSickPigCullRequest) error { | ||||
| 			return c.service.RecordSickPigCull(operatorID, primaryID, req.PenID, req.Quantity, req.TreatmentLocation, req.HappenedAt, req.Remarks) | ||||
| 		}, | ||||
| 		"记录成功", | ||||
| @@ -116,13 +116,13 @@ func (c *PigBatchController) RecordSickPigCull(ctx *gin.Context) { | ||||
| // @Param        body body dto.RecordDeathRequest true "记录正常猪只死亡请求信息" | ||||
| // @Success      200 {object} controller.Response "记录成功" | ||||
| // @Router       /api/v1/pig-batches/record-death/{id} [post] | ||||
| func (c *PigBatchController) RecordDeath(ctx *gin.Context) { | ||||
| func (c *PigBatchController) RecordDeath(ctx echo.Context) error { | ||||
| 	const action = "记录正常猪只死亡事件" | ||||
| 	var req dto.RecordDeathRequest | ||||
|  | ||||
| 	handleAPIRequest( | ||||
| 	return handleAPIRequest( | ||||
| 		c, ctx, action, &req, | ||||
| 		func(ctx *gin.Context, operatorID uint, primaryID uint, req *dto.RecordDeathRequest) error { | ||||
| 		func(ctx echo.Context, operatorID uint, primaryID uint, req *dto.RecordDeathRequest) error { | ||||
| 			return c.service.RecordDeath(operatorID, primaryID, req.PenID, req.Quantity, req.HappenedAt, req.Remarks) | ||||
| 		}, | ||||
| 		"记录成功", | ||||
| @@ -141,13 +141,13 @@ func (c *PigBatchController) RecordDeath(ctx *gin.Context) { | ||||
| // @Param        body body dto.RecordCullRequest true "记录正常猪只淘汰请求信息" | ||||
| // @Success      200 {object} controller.Response "记录成功" | ||||
| // @Router       /api/v1/pig-batches/record-cull/{id} [post] | ||||
| func (c *PigBatchController) RecordCull(ctx *gin.Context) { | ||||
| func (c *PigBatchController) RecordCull(ctx echo.Context) error { | ||||
| 	const action = "记录正常猪只淘汰事件" | ||||
| 	var req dto.RecordCullRequest | ||||
|  | ||||
| 	handleAPIRequest( | ||||
| 	return handleAPIRequest( | ||||
| 		c, ctx, action, &req, | ||||
| 		func(ctx *gin.Context, operatorID uint, primaryID uint, req *dto.RecordCullRequest) error { | ||||
| 		func(ctx echo.Context, operatorID uint, primaryID uint, req *dto.RecordCullRequest) error { | ||||
| 			return c.service.RecordCull(operatorID, primaryID, req.PenID, req.Quantity, req.HappenedAt, req.Remarks) | ||||
| 		}, | ||||
| 		"记录成功", | ||||
|   | ||||
| @@ -2,7 +2,7 @@ package management | ||||
|  | ||||
| import ( | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/dto" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"github.com/labstack/echo/v4" | ||||
| ) | ||||
|  | ||||
| // SellPigs godoc | ||||
| @@ -16,13 +16,13 @@ import ( | ||||
| // @Param        body body dto.SellPigsRequest true "卖猪请求信息" | ||||
| // @Success      200 {object} controller.Response "卖猪成功" | ||||
| // @Router       /api/v1/pig-batches/sell-pigs/{id} [post] | ||||
| func (c *PigBatchController) SellPigs(ctx *gin.Context) { | ||||
| func (c *PigBatchController) SellPigs(ctx echo.Context) error { | ||||
| 	const action = "卖猪" | ||||
| 	var req dto.SellPigsRequest | ||||
|  | ||||
| 	handleAPIRequest( | ||||
| 	return handleAPIRequest( | ||||
| 		c, ctx, action, &req, | ||||
| 		func(ctx *gin.Context, operatorID uint, primaryID uint, req *dto.SellPigsRequest) error { | ||||
| 		func(ctx echo.Context, operatorID uint, primaryID uint, req *dto.SellPigsRequest) error { | ||||
| 			return c.service.SellPigs(primaryID, req.PenID, req.Quantity, req.UnitPrice, req.TotalPrice, req.TraderName, req.TradeDate, req.Remarks, operatorID) | ||||
| 		}, | ||||
| 		"卖猪成功", | ||||
| @@ -41,13 +41,13 @@ func (c *PigBatchController) SellPigs(ctx *gin.Context) { | ||||
| // @Param        body body dto.BuyPigsRequest true "买猪请求信息" | ||||
| // @Success      200 {object} controller.Response "买猪成功" | ||||
| // @Router       /api/v1/pig-batches/buy-pigs/{id} [post] | ||||
| func (c *PigBatchController) BuyPigs(ctx *gin.Context) { | ||||
| func (c *PigBatchController) BuyPigs(ctx echo.Context) error { | ||||
| 	const action = "买猪" | ||||
| 	var req dto.BuyPigsRequest | ||||
|  | ||||
| 	handleAPIRequest( | ||||
| 	return handleAPIRequest( | ||||
| 		c, ctx, action, &req, | ||||
| 		func(ctx *gin.Context, operatorID uint, primaryID uint, req *dto.BuyPigsRequest) error { | ||||
| 		func(ctx echo.Context, operatorID uint, primaryID uint, req *dto.BuyPigsRequest) error { | ||||
| 			return c.service.BuyPigs(primaryID, req.PenID, req.Quantity, req.UnitPrice, req.TotalPrice, req.TraderName, req.TradeDate, req.Remarks, operatorID) | ||||
| 		}, | ||||
| 		"买猪成功", | ||||
|   | ||||
| @@ -4,7 +4,7 @@ import ( | ||||
| 	"strconv" | ||||
|  | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/dto" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"github.com/labstack/echo/v4" | ||||
| ) | ||||
|  | ||||
| // TransferPigsAcrossBatches godoc | ||||
| @@ -18,18 +18,18 @@ import ( | ||||
| // @Param        body body dto.TransferPigsAcrossBatchesRequest true "跨群调栏请求信息" | ||||
| // @Success      200 {object} controller.Response "调栏成功" | ||||
| // @Router       /api/v1/pig-batches/transfer-across-batches/{sourceBatchID} [post] | ||||
| func (c *PigBatchController) TransferPigsAcrossBatches(ctx *gin.Context) { | ||||
| func (c *PigBatchController) TransferPigsAcrossBatches(ctx echo.Context) error { | ||||
| 	const action = "跨猪群调栏" | ||||
| 	var req dto.TransferPigsAcrossBatchesRequest | ||||
|  | ||||
| 	handleAPIRequest( | ||||
| 	return handleAPIRequest( | ||||
| 		c, ctx, action, &req, | ||||
| 		func(ctx *gin.Context, operatorID uint, primaryID uint, req *dto.TransferPigsAcrossBatchesRequest) error { | ||||
| 		func(ctx echo.Context, operatorID uint, primaryID uint, req *dto.TransferPigsAcrossBatchesRequest) error { | ||||
| 			// primaryID 在这里是 sourceBatchID | ||||
| 			return c.service.TransferPigsAcrossBatches(primaryID, req.DestBatchID, req.FromPenID, req.ToPenID, req.Quantity, operatorID, req.Remarks) | ||||
| 		}, | ||||
| 		"调栏成功", | ||||
| 		func(ctx *gin.Context) (uint, error) { // 自定义ID提取器,从 ":sourceBatchID" 路径参数提取 | ||||
| 		func(ctx echo.Context) (uint, error) { // 自定义ID提取器,从 ":sourceBatchID" 路径参数提取 | ||||
| 			idParam := ctx.Param("sourceBatchID") | ||||
| 			parsedID, err := strconv.ParseUint(idParam, 10, 32) | ||||
| 			if err != nil { | ||||
| @@ -51,13 +51,13 @@ func (c *PigBatchController) TransferPigsAcrossBatches(ctx *gin.Context) { | ||||
| // @Param        body body dto.TransferPigsWithinBatchRequest true "群内调栏请求信息" | ||||
| // @Success      200 {object} controller.Response "调栏成功" | ||||
| // @Router       /api/v1/pig-batches/transfer-within-batch/{id} [post] | ||||
| func (c *PigBatchController) TransferPigsWithinBatch(ctx *gin.Context) { | ||||
| func (c *PigBatchController) TransferPigsWithinBatch(ctx echo.Context) error { | ||||
| 	const action = "群内调栏" | ||||
| 	var req dto.TransferPigsWithinBatchRequest | ||||
|  | ||||
| 	handleAPIRequest( | ||||
| 	return handleAPIRequest( | ||||
| 		c, ctx, action, &req, | ||||
| 		func(ctx *gin.Context, operatorID uint, primaryID uint, req *dto.TransferPigsWithinBatchRequest) error { | ||||
| 		func(ctx echo.Context, operatorID uint, primaryID uint, req *dto.TransferPigsWithinBatchRequest) error { | ||||
| 			// primaryID 在这里是 batchID | ||||
| 			return c.service.TransferPigsWithinBatch(primaryID, req.FromPenID, req.ToPenID, req.Quantity, operatorID, req.Remarks) | ||||
| 		}, | ||||
|   | ||||
| @@ -8,7 +8,7 @@ import ( | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/dto" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/service" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"github.com/labstack/echo/v4" | ||||
| ) | ||||
|  | ||||
| // --- 控制器定义 --- | ||||
| @@ -31,7 +31,7 @@ func NewPigFarmController(logger *logs.Logger, service service.PigFarmService) * | ||||
|  | ||||
| // CreatePigHouse godoc | ||||
| // @Summary      创建猪舍 | ||||
| // @Description  创建一个新的猪舍 | ||||
| // @Description  根据提供的信息创建一个新猪舍 | ||||
| // @Tags         猪场管理 | ||||
| // @Security     BearerAuth | ||||
| // @Accept       json | ||||
| @@ -39,27 +39,21 @@ func NewPigFarmController(logger *logs.Logger, service service.PigFarmService) * | ||||
| // @Param        body body dto.CreatePigHouseRequest true "猪舍信息" | ||||
| // @Success      201 {object} controller.Response{data=dto.PigHouseResponse} "创建成功" | ||||
| // @Router       /api/v1/pig-houses [post] | ||||
| func (c *PigFarmController) CreatePigHouse(ctx *gin.Context) { | ||||
| func (c *PigFarmController) CreatePigHouse(ctx echo.Context) error { | ||||
| 	const action = "创建猪舍" | ||||
| 	var req dto.CreatePigHouseRequest | ||||
| 	if err := ctx.ShouldBindJSON(&req); err != nil { | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体", action, "请求体绑定失败", req) | ||||
| 		return | ||||
| 	if err := ctx.Bind(&req); err != nil { | ||||
| 		c.logger.Errorf("%s: 参数绑定失败: %v", action, err) | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体", action, "请求体绑定失败", req) | ||||
| 	} | ||||
|  | ||||
| 	house, err := c.service.CreatePigHouse(req.Name, req.Description) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 业务逻辑失败: %v", action, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "创建猪舍失败", action, "业务逻辑失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "创建猪舍失败", action, "业务逻辑失败", req) | ||||
| 	} | ||||
|  | ||||
| 	resp := dto.PigHouseResponse{ | ||||
| 		ID:          house.ID, | ||||
| 		Name:        house.Name, | ||||
| 		Description: house.Description, | ||||
| 	} | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "创建成功", resp, action, "创建成功", resp) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "创建成功", house, action, "创建成功", house) | ||||
| } | ||||
|  | ||||
| // GetPigHouse godoc | ||||
| @@ -71,31 +65,23 @@ func (c *PigFarmController) CreatePigHouse(ctx *gin.Context) { | ||||
| // @Param        id path int true "猪舍ID" | ||||
| // @Success      200 {object} controller.Response{data=dto.PigHouseResponse} "获取成功" | ||||
| // @Router       /api/v1/pig-houses/{id} [get] | ||||
| func (c *PigFarmController) GetPigHouse(ctx *gin.Context) { | ||||
| func (c *PigFarmController) GetPigHouse(ctx echo.Context) error { | ||||
| 	const action = "获取猪舍" | ||||
| 	id, err := strconv.ParseUint(ctx.Param("id"), 10, 32) | ||||
| 	if err != nil { | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", ctx.Param("id")) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", ctx.Param("id")) | ||||
| 	} | ||||
|  | ||||
| 	house, err := c.service.GetPigHouseByID(uint(id)) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, service.ErrHouseNotFound) { | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "猪舍不存在", action, "猪舍不存在", id) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "猪舍不存在", action, "猪舍不存在", id) | ||||
| 		} | ||||
| 		c.logger.Errorf("%s: 业务逻辑失败: %v", action, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取猪舍失败", action, "业务逻辑失败", id) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取猪舍失败", action, "业务逻辑失败", id) | ||||
| 	} | ||||
|  | ||||
| 	resp := dto.PigHouseResponse{ | ||||
| 		ID:          house.ID, | ||||
| 		Name:        house.Name, | ||||
| 		Description: house.Description, | ||||
| 	} | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取成功", resp, action, "获取成功", resp) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取成功", house, action, "获取成功", house) | ||||
| } | ||||
|  | ||||
| // ListPigHouses godoc | ||||
| @@ -106,25 +92,15 @@ func (c *PigFarmController) GetPigHouse(ctx *gin.Context) { | ||||
| // @Produce      json | ||||
| // @Success      200 {object} controller.Response{data=[]dto.PigHouseResponse} "获取成功" | ||||
| // @Router       /api/v1/pig-houses [get] | ||||
| func (c *PigFarmController) ListPigHouses(ctx *gin.Context) { | ||||
| func (c *PigFarmController) ListPigHouses(ctx echo.Context) error { | ||||
| 	const action = "获取猪舍列表" | ||||
| 	houses, err := c.service.ListPigHouses() | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 业务逻辑失败: %v", action, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取列表失败", action, "业务逻辑失败", nil) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取列表失败", action, "业务逻辑失败", nil) | ||||
| 	} | ||||
|  | ||||
| 	var resp []dto.PigHouseResponse | ||||
| 	for _, house := range houses { | ||||
| 		resp = append(resp, dto.PigHouseResponse{ | ||||
| 			ID:          house.ID, | ||||
| 			Name:        house.Name, | ||||
| 			Description: house.Description, | ||||
| 		}) | ||||
| 	} | ||||
|  | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取成功", resp, action, "获取成功", resp) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取成功", houses, action, "获取成功", houses) | ||||
| } | ||||
|  | ||||
| // UpdatePigHouse godoc | ||||
| @@ -138,37 +114,28 @@ func (c *PigFarmController) ListPigHouses(ctx *gin.Context) { | ||||
| // @Param        body body dto.UpdatePigHouseRequest true "猪舍信息" | ||||
| // @Success      200 {object} controller.Response{data=dto.PigHouseResponse} "更新成功" | ||||
| // @Router       /api/v1/pig-houses/{id} [put] | ||||
| func (c *PigFarmController) UpdatePigHouse(ctx *gin.Context) { | ||||
| func (c *PigFarmController) UpdatePigHouse(ctx echo.Context) error { | ||||
| 	const action = "更新猪舍" | ||||
| 	id, err := strconv.ParseUint(ctx.Param("id"), 10, 32) | ||||
| 	if err != nil { | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", ctx.Param("id")) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", ctx.Param("id")) | ||||
| 	} | ||||
|  | ||||
| 	var req dto.UpdatePigHouseRequest | ||||
| 	if err := ctx.ShouldBindJSON(&req); err != nil { | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体", action, "请求体绑定失败", req) | ||||
| 		return | ||||
| 	if err := ctx.Bind(&req); err != nil { | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体", action, "请求体绑定失败", req) | ||||
| 	} | ||||
|  | ||||
| 	house, err := c.service.UpdatePigHouse(uint(id), req.Name, req.Description) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, service.ErrHouseNotFound) { | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "猪舍不存在", action, "猪舍不存在", id) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "猪舍不存在", action, "猪舍不存在", id) | ||||
| 		} | ||||
| 		c.logger.Errorf("%s: 业务逻辑失败: %v", action, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新失败", action, "业务逻辑失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新失败", action, "业务逻辑失败", req) | ||||
| 	} | ||||
|  | ||||
| 	resp := dto.PigHouseResponse{ | ||||
| 		ID:          house.ID, | ||||
| 		Name:        house.Name, | ||||
| 		Description: house.Description, | ||||
| 	} | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "更新成功", resp, action, "更新成功", resp) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "更新成功", house, action, "更新成功", house) | ||||
| } | ||||
|  | ||||
| // DeletePigHouse godoc | ||||
| @@ -180,30 +147,26 @@ func (c *PigFarmController) UpdatePigHouse(ctx *gin.Context) { | ||||
| // @Param        id path int true "猪舍ID" | ||||
| // @Success      200 {object} controller.Response "删除成功" | ||||
| // @Router       /api/v1/pig-houses/{id} [delete] | ||||
| func (c *PigFarmController) DeletePigHouse(ctx *gin.Context) { | ||||
| func (c *PigFarmController) DeletePigHouse(ctx echo.Context) error { | ||||
| 	const action = "删除猪舍" | ||||
| 	id, err := strconv.ParseUint(ctx.Param("id"), 10, 32) | ||||
| 	if err != nil { | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", ctx.Param("id")) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", ctx.Param("id")) | ||||
| 	} | ||||
|  | ||||
| 	if err := c.service.DeletePigHouse(uint(id)); err != nil { | ||||
| 		if errors.Is(err, service.ErrHouseNotFound) { | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "猪舍不存在", action, "猪舍不存在", id) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "猪舍不存在", action, "猪舍不存在", id) | ||||
| 		} | ||||
| 		// 检查是否是业务逻辑错误 | ||||
| 		if errors.Is(err, service.ErrHouseContainsPens) { | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), action, err.Error(), id) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), action, err.Error(), id) | ||||
| 		} | ||||
| 		c.logger.Errorf("%s: 业务逻辑失败: %v", action, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除失败", action, "业务逻辑失败", id) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除失败", action, "业务逻辑失败", id) | ||||
| 	} | ||||
|  | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "删除成功", nil, action, "删除成功", id) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "删除成功", nil, action, "删除成功", id) | ||||
| } | ||||
|  | ||||
| // --- 猪栏 (Pen) API 实现 --- | ||||
| @@ -218,34 +181,24 @@ func (c *PigFarmController) DeletePigHouse(ctx *gin.Context) { | ||||
| // @Param        body body dto.CreatePenRequest true "猪栏信息" | ||||
| // @Success      201 {object} controller.Response{data=dto.PenResponse} "创建成功" | ||||
| // @Router       /api/v1/pens [post] | ||||
| func (c *PigFarmController) CreatePen(ctx *gin.Context) { | ||||
| func (c *PigFarmController) CreatePen(ctx echo.Context) error { | ||||
| 	const action = "创建猪栏" | ||||
| 	var req dto.CreatePenRequest | ||||
| 	if err := ctx.ShouldBindJSON(&req); err != nil { | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体", action, "请求体绑定失败", req) | ||||
| 		return | ||||
| 	if err := ctx.Bind(&req); err != nil { | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体", action, "请求体绑定失败", req) | ||||
| 	} | ||||
|  | ||||
| 	pen, err := c.service.CreatePen(req.PenNumber, req.HouseID, req.Capacity) | ||||
| 	if err != nil { | ||||
| 		// 检查是否是业务逻辑错误 | ||||
| 		if errors.Is(err, service.ErrHouseNotFound) { | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), action, err.Error(), req) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), action, err.Error(), req) | ||||
| 		} | ||||
| 		c.logger.Errorf("%s: 业务逻辑失败: %v", action, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "创建猪栏失败", action, "业务逻辑失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "创建猪栏失败", action, "业务逻辑失败", req) | ||||
| 	} | ||||
|  | ||||
| 	resp := dto.PenResponse{ | ||||
| 		ID:        pen.ID, | ||||
| 		PenNumber: pen.PenNumber, | ||||
| 		HouseID:   pen.HouseID, | ||||
| 		Capacity:  pen.Capacity, | ||||
| 		Status:    pen.Status, | ||||
| 	} | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "创建成功", resp, action, "创建成功", resp) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "创建成功", pen, action, "创建成功", pen) | ||||
| } | ||||
|  | ||||
| // GetPen godoc | ||||
| @@ -257,26 +210,23 @@ func (c *PigFarmController) CreatePen(ctx *gin.Context) { | ||||
| // @Param        id path int true "猪栏ID" | ||||
| // @Success      200 {object} controller.Response{data=dto.PenResponse} "获取成功" | ||||
| // @Router       /api/v1/pens/{id} [get] | ||||
| func (c *PigFarmController) GetPen(ctx *gin.Context) { | ||||
| func (c *PigFarmController) GetPen(ctx echo.Context) error { | ||||
| 	const action = "获取猪栏" | ||||
| 	id, err := strconv.ParseUint(ctx.Param("id"), 10, 32) | ||||
| 	if err != nil { | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", ctx.Param("id")) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", ctx.Param("id")) | ||||
| 	} | ||||
|  | ||||
| 	pen, err := c.service.GetPenByID(uint(id)) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, service.ErrPenNotFound) { | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "猪栏不存在", action, "猪栏不存在", id) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "猪栏不存在", action, "猪栏不存在", id) | ||||
| 		} | ||||
| 		c.logger.Errorf("%s: 业务逻辑失败: %v", action, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取猪栏失败", action, "业务逻辑失败", id) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取猪栏失败", action, "业务逻辑失败", id) | ||||
| 	} | ||||
|  | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取成功", pen, action, "获取成功", pen) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取成功", pen, action, "获取成功", pen) | ||||
| } | ||||
|  | ||||
| // ListPens godoc | ||||
| @@ -287,16 +237,15 @@ func (c *PigFarmController) GetPen(ctx *gin.Context) { | ||||
| // @Produce      json | ||||
| // @Success      200 {object} controller.Response{data=[]dto.PenResponse} "获取成功" | ||||
| // @Router       /api/v1/pens [get] | ||||
| func (c *PigFarmController) ListPens(ctx *gin.Context) { | ||||
| func (c *PigFarmController) ListPens(ctx echo.Context) error { | ||||
| 	const action = "获取猪栏列表" | ||||
| 	pens, err := c.service.ListPens() | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 业务逻辑失败: %v", action, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取列表失败", action, "业务逻辑失败", nil) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取列表失败", action, "业务逻辑失败", nil) | ||||
| 	} | ||||
|  | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取成功", pens, action, "获取成功", pens) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取成功", pens, action, "获取成功", pens) | ||||
| } | ||||
|  | ||||
| // UpdatePen godoc | ||||
| @@ -310,41 +259,29 @@ func (c *PigFarmController) ListPens(ctx *gin.Context) { | ||||
| // @Param        body body dto.UpdatePenRequest true "猪栏信息" | ||||
| // @Success      200 {object} controller.Response{data=dto.PenResponse} "更新成功" | ||||
| // @Router       /api/v1/pens/{id} [put] | ||||
| func (c *PigFarmController) UpdatePen(ctx *gin.Context) { | ||||
| func (c *PigFarmController) UpdatePen(ctx echo.Context) error { | ||||
| 	const action = "更新猪栏" | ||||
| 	id, err := strconv.ParseUint(ctx.Param("id"), 10, 32) | ||||
| 	if err != nil { | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", ctx.Param("id")) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", ctx.Param("id")) | ||||
| 	} | ||||
|  | ||||
| 	var req dto.UpdatePenRequest | ||||
| 	if err := ctx.ShouldBindJSON(&req); err != nil { | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体", action, "请求体绑定失败", req) | ||||
| 		return | ||||
| 	if err := ctx.Bind(&req); err != nil { | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体", action, "请求体绑定失败", req) | ||||
| 	} | ||||
|  | ||||
| 	pen, err := c.service.UpdatePen(uint(id), req.PenNumber, req.HouseID, req.Capacity, req.Status) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, service.ErrPenNotFound) { | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "猪栏不存在", action, "猪栏不存在", id) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "猪栏不存在", action, "猪栏不存在", id) | ||||
| 		} | ||||
| 		// 其他业务逻辑错误可以在这里添加处理 | ||||
| 		c.logger.Errorf("%s: 业务逻辑失败: %v", action, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新失败", action, "业务逻辑失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新失败", action, "业务逻辑失败", req) | ||||
| 	} | ||||
|  | ||||
| 	resp := dto.PenResponse{ | ||||
| 		ID:         pen.ID, | ||||
| 		PenNumber:  pen.PenNumber, | ||||
| 		HouseID:    pen.HouseID, | ||||
| 		Capacity:   pen.Capacity, | ||||
| 		Status:     pen.Status, | ||||
| 		PigBatchID: pen.PigBatchID, | ||||
| 	} | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "更新成功", resp, action, "更新成功", resp) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "更新成功", pen, action, "更新成功", pen) | ||||
| } | ||||
|  | ||||
| // DeletePen godoc | ||||
| @@ -356,30 +293,26 @@ func (c *PigFarmController) UpdatePen(ctx *gin.Context) { | ||||
| // @Param        id path int true "猪栏ID" | ||||
| // @Success      200 {object} controller.Response "删除成功" | ||||
| // @Router       /api/v1/pens/{id} [delete] | ||||
| func (c *PigFarmController) DeletePen(ctx *gin.Context) { | ||||
| func (c *PigFarmController) DeletePen(ctx echo.Context) error { | ||||
| 	const action = "删除猪栏" | ||||
| 	id, err := strconv.ParseUint(ctx.Param("id"), 10, 32) | ||||
| 	if err != nil { | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", ctx.Param("id")) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", ctx.Param("id")) | ||||
| 	} | ||||
|  | ||||
| 	if err := c.service.DeletePen(uint(id)); err != nil { | ||||
| 		if errors.Is(err, service.ErrPenNotFound) { | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "猪栏不存在", action, "猪栏不存在", id) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "猪栏不存在", action, "猪栏不存在", id) | ||||
| 		} | ||||
| 		// 检查是否是业务逻辑错误 | ||||
| 		if errors.Is(err, service.ErrPenInUse) { | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), action, err.Error(), id) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), action, err.Error(), id) | ||||
| 		} | ||||
| 		c.logger.Errorf("%s: 业务逻辑失败: %v", action, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除失败", action, "业务逻辑失败", id) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除失败", action, "业务逻辑失败", id) | ||||
| 	} | ||||
|  | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "删除成功", nil, action, "删除成功", id) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "删除成功", nil, action, "删除成功", id) | ||||
| } | ||||
|  | ||||
| // UpdatePenStatus godoc | ||||
| @@ -393,41 +326,28 @@ func (c *PigFarmController) DeletePen(ctx *gin.Context) { | ||||
| // @Param        body body dto.UpdatePenStatusRequest true "新的猪栏状态" | ||||
| // @Success      200 {object} controller.Response{data=dto.PenResponse} "更新成功" | ||||
| // @Router       /api/v1/pens/{id}/status [put] | ||||
| func (c *PigFarmController) UpdatePenStatus(ctx *gin.Context) { | ||||
| func (c *PigFarmController) UpdatePenStatus(ctx echo.Context) error { | ||||
| 	const action = "更新猪栏状态" | ||||
| 	id, err := strconv.ParseUint(ctx.Param("id"), 10, 32) | ||||
| 	if err != nil { | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", ctx.Param("id")) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", ctx.Param("id")) | ||||
| 	} | ||||
|  | ||||
| 	var req dto.UpdatePenStatusRequest | ||||
| 	if err := ctx.ShouldBindJSON(&req); err != nil { | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体", action, "请求体绑定失败", req) | ||||
| 		return | ||||
| 	if err := ctx.Bind(&req); err != nil { | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体", action, "请求体绑定失败", req) | ||||
| 	} | ||||
|  | ||||
| 	pen, err := c.service.UpdatePenStatus(uint(id), req.Status) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, service.ErrPenNotFound) { | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), action, err.Error(), id) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), action, err.Error(), id) | ||||
| 		} else if errors.Is(err, service.ErrPenStatusInvalidForOccupiedPen) || errors.Is(err, service.ErrPenStatusInvalidForUnoccupiedPen) { | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), action, err.Error(), id) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), action, err.Error(), id) | ||||
| 		} | ||||
| 		c.logger.Errorf("%s: 业务逻辑失败: %v", action, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新猪栏状态失败", action, err.Error(), id) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新猪栏状态失败", action, err.Error(), id) | ||||
| 	} | ||||
|  | ||||
| 	resp := dto.PenResponse{ | ||||
| 		ID:         pen.ID, | ||||
| 		PenNumber:  pen.PenNumber, | ||||
| 		HouseID:    pen.HouseID, | ||||
| 		Capacity:   pen.Capacity, | ||||
| 		Status:     pen.Status, | ||||
| 		PigBatchID: pen.PigBatchID, | ||||
| 	} | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "更新成功", resp, action, "更新成功", resp) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "更新成功", pen, action, "更新成功", pen) | ||||
| } | ||||
|   | ||||
| @@ -7,9 +7,8 @@ import ( | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/dto" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/service" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"github.com/labstack/echo/v4" | ||||
| ) | ||||
|  | ||||
| // Controller 监控控制器,封装了所有与数据监控相关的业务逻辑 | ||||
| @@ -35,43 +34,28 @@ func NewController(monitorService service.MonitorService, logger *logs.Logger) * | ||||
| // @Param        query query dto.ListSensorDataRequest true "查询参数" | ||||
| // @Success      200 {object} controller.Response{data=dto.ListSensorDataResponse} | ||||
| // @Router       /api/v1/monitor/sensor-data [get] | ||||
| func (c *Controller) ListSensorData(ctx *gin.Context) { | ||||
| func (c *Controller) ListSensorData(ctx echo.Context) error { | ||||
| 	const actionType = "获取传感器数据列表" | ||||
|  | ||||
| 	var req dto.ListSensorDataRequest | ||||
| 	if err := ctx.ShouldBindQuery(&req); err != nil { | ||||
| 	if err := ctx.Bind(&req); err != nil { | ||||
| 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||
| 	} | ||||
|  | ||||
| 	opts := repository.SensorDataListOptions{ | ||||
| 		DeviceID:  req.DeviceID, | ||||
| 		OrderBy:   req.OrderBy, | ||||
| 		StartTime: req.StartTime, | ||||
| 		EndTime:   req.EndTime, | ||||
| 	} | ||||
| 	if req.SensorType != nil { | ||||
| 		sensorType := models.SensorType(*req.SensorType) | ||||
| 		opts.SensorType = &sensorType | ||||
| 	} | ||||
|  | ||||
| 	data, total, err := c.monitorService.ListSensorData(opts, req.Page, req.PageSize) | ||||
| 	resp, err := c.monitorService.ListSensorData(&req) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, repository.ErrInvalidPagination) { | ||||
| 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||
| 		} | ||||
|  | ||||
| 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取传感器数据失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取传感器数据失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||
| 	} | ||||
|  | ||||
| 	resp := dto.NewListSensorDataResponse(data, total, req.Page, req.PageSize) | ||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取传感器数据成功", resp, actionType, "获取传感器数据成功", req) | ||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取传感器数据成功", resp, actionType, "获取传感器数据成功", req) | ||||
| } | ||||
|  | ||||
| // ListDeviceCommandLogs godoc | ||||
| @@ -83,40 +67,28 @@ func (c *Controller) ListSensorData(ctx *gin.Context) { | ||||
| // @Param        query query dto.ListDeviceCommandLogRequest true "查询参数" | ||||
| // @Success      200 {object} controller.Response{data=dto.ListDeviceCommandLogResponse} | ||||
| // @Router       /api/v1/monitor/device-command-logs [get] | ||||
| func (c *Controller) ListDeviceCommandLogs(ctx *gin.Context) { | ||||
| func (c *Controller) ListDeviceCommandLogs(ctx echo.Context) error { | ||||
| 	const actionType = "获取设备命令日志列表" | ||||
|  | ||||
| 	var req dto.ListDeviceCommandLogRequest | ||||
| 	if err := ctx.ShouldBindQuery(&req); err != nil { | ||||
| 	if err := ctx.Bind(&req); err != nil { | ||||
| 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||
| 	} | ||||
|  | ||||
| 	opts := repository.DeviceCommandLogListOptions{ | ||||
| 		DeviceID:        req.DeviceID, | ||||
| 		ReceivedSuccess: req.ReceivedSuccess, | ||||
| 		OrderBy:         req.OrderBy, | ||||
| 		StartTime:       req.StartTime, | ||||
| 		EndTime:         req.EndTime, | ||||
| 	} | ||||
|  | ||||
| 	data, total, err := c.monitorService.ListDeviceCommandLogs(opts, req.Page, req.PageSize) | ||||
| 	resp, err := c.monitorService.ListDeviceCommandLogs(&req) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, repository.ErrInvalidPagination) { | ||||
| 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||
| 		} | ||||
|  | ||||
| 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取设备命令日志失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取设备命令日志失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||
| 	} | ||||
|  | ||||
| 	resp := dto.NewListDeviceCommandLogResponse(data, total, req.Page, req.PageSize) | ||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取设备命令日志成功", resp, actionType, "获取设备命令日志成功", req) | ||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取设备命令日志成功", resp, actionType, "获取设备命令日志成功", req) | ||||
| } | ||||
|  | ||||
| // ListPlanExecutionLogs godoc | ||||
| @@ -128,43 +100,28 @@ func (c *Controller) ListDeviceCommandLogs(ctx *gin.Context) { | ||||
| // @Param        query query dto.ListPlanExecutionLogRequest true "查询参数" | ||||
| // @Success      200 {object} controller.Response{data=dto.ListPlanExecutionLogResponse} | ||||
| // @Router       /api/v1/monitor/plan-execution-logs [get] | ||||
| func (c *Controller) ListPlanExecutionLogs(ctx *gin.Context) { | ||||
| func (c *Controller) ListPlanExecutionLogs(ctx echo.Context) error { | ||||
| 	const actionType = "获取计划执行日志列表" | ||||
|  | ||||
| 	var req dto.ListPlanExecutionLogRequest | ||||
| 	if err := ctx.ShouldBindQuery(&req); err != nil { | ||||
| 	if err := ctx.Bind(&req); err != nil { | ||||
| 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||
| 	} | ||||
|  | ||||
| 	opts := repository.PlanExecutionLogListOptions{ | ||||
| 		PlanID:    req.PlanID, | ||||
| 		OrderBy:   req.OrderBy, | ||||
| 		StartTime: req.StartTime, | ||||
| 		EndTime:   req.EndTime, | ||||
| 	} | ||||
| 	if req.Status != nil { | ||||
| 		status := models.ExecutionStatus(*req.Status) | ||||
| 		opts.Status = &status | ||||
| 	} | ||||
|  | ||||
| 	data, total, err := c.monitorService.ListPlanExecutionLogs(opts, req.Page, req.PageSize) | ||||
| 	resp, err := c.monitorService.ListPlanExecutionLogs(&req) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, repository.ErrInvalidPagination) { | ||||
| 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||
| 		} | ||||
|  | ||||
| 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取计划执行日志失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取计划执行日志失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||
| 	} | ||||
|  | ||||
| 	resp := dto.NewListPlanExecutionLogResponse(data, total, req.Page, req.PageSize) | ||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取计划执行日志成功", resp, actionType, "获取计划执行日志成功", req) | ||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取计划执行日志成功", resp, actionType, "获取计划执行日志成功", req) | ||||
| } | ||||
|  | ||||
| // ListTaskExecutionLogs godoc | ||||
| @@ -176,44 +133,28 @@ func (c *Controller) ListPlanExecutionLogs(ctx *gin.Context) { | ||||
| // @Param        query query dto.ListTaskExecutionLogRequest true "查询参数" | ||||
| // @Success      200 {object} controller.Response{data=dto.ListTaskExecutionLogResponse} | ||||
| // @Router       /api/v1/monitor/task-execution-logs [get] | ||||
| func (c *Controller) ListTaskExecutionLogs(ctx *gin.Context) { | ||||
| func (c *Controller) ListTaskExecutionLogs(ctx echo.Context) error { | ||||
| 	const actionType = "获取任务执行日志列表" | ||||
|  | ||||
| 	var req dto.ListTaskExecutionLogRequest | ||||
| 	if err := ctx.ShouldBindQuery(&req); err != nil { | ||||
| 	if err := ctx.Bind(&req); err != nil { | ||||
| 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||
| 	} | ||||
|  | ||||
| 	opts := repository.TaskExecutionLogListOptions{ | ||||
| 		PlanExecutionLogID: req.PlanExecutionLogID, | ||||
| 		TaskID:             req.TaskID, | ||||
| 		OrderBy:            req.OrderBy, | ||||
| 		StartTime:          req.StartTime, | ||||
| 		EndTime:            req.EndTime, | ||||
| 	} | ||||
| 	if req.Status != nil { | ||||
| 		status := models.ExecutionStatus(*req.Status) | ||||
| 		opts.Status = &status | ||||
| 	} | ||||
|  | ||||
| 	data, total, err := c.monitorService.ListTaskExecutionLogs(opts, req.Page, req.PageSize) | ||||
| 	resp, err := c.monitorService.ListTaskExecutionLogs(&req) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, repository.ErrInvalidPagination) { | ||||
| 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||
| 		} | ||||
|  | ||||
| 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取任务执行日志失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取任务执行日志失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||
| 	} | ||||
|  | ||||
| 	resp := dto.NewListTaskExecutionLogResponse(data, total, req.Page, req.PageSize) | ||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取任务执行日志成功", resp, actionType, "获取任务执行日志成功", req) | ||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取任务执行日志成功", resp, actionType, "获取任务执行日志成功", req) | ||||
| } | ||||
|  | ||||
| // ListPendingCollections godoc | ||||
| @@ -225,43 +166,28 @@ func (c *Controller) ListTaskExecutionLogs(ctx *gin.Context) { | ||||
| // @Param        query query dto.ListPendingCollectionRequest true "查询参数" | ||||
| // @Success      200 {object} controller.Response{data=dto.ListPendingCollectionResponse} | ||||
| // @Router       /api/v1/monitor/pending-collections [get] | ||||
| func (c *Controller) ListPendingCollections(ctx *gin.Context) { | ||||
| func (c *Controller) ListPendingCollections(ctx echo.Context) error { | ||||
| 	const actionType = "获取待采集请求列表" | ||||
|  | ||||
| 	var req dto.ListPendingCollectionRequest | ||||
| 	if err := ctx.ShouldBindQuery(&req); err != nil { | ||||
| 	if err := ctx.Bind(&req); err != nil { | ||||
| 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||
| 	} | ||||
|  | ||||
| 	opts := repository.PendingCollectionListOptions{ | ||||
| 		DeviceID:  req.DeviceID, | ||||
| 		OrderBy:   req.OrderBy, | ||||
| 		StartTime: req.StartTime, | ||||
| 		EndTime:   req.EndTime, | ||||
| 	} | ||||
| 	if req.Status != nil { | ||||
| 		status := models.PendingCollectionStatus(*req.Status) | ||||
| 		opts.Status = &status | ||||
| 	} | ||||
|  | ||||
| 	data, total, err := c.monitorService.ListPendingCollections(opts, req.Page, req.PageSize) | ||||
| 	resp, err := c.monitorService.ListPendingCollections(&req) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, repository.ErrInvalidPagination) { | ||||
| 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||
| 		} | ||||
|  | ||||
| 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取待采集请求失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取待采集请求失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||
| 	} | ||||
|  | ||||
| 	resp := dto.NewListPendingCollectionResponse(data, total, req.Page, req.PageSize) | ||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取待采集请求成功", resp, actionType, "获取待采集请求成功", req) | ||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取待采集请求成功", resp, actionType, "获取待采集请求成功", req) | ||||
| } | ||||
|  | ||||
| // ListUserActionLogs godoc | ||||
| @@ -273,45 +199,28 @@ func (c *Controller) ListPendingCollections(ctx *gin.Context) { | ||||
| // @Param        query query dto.ListUserActionLogRequest true "查询参数" | ||||
| // @Success      200 {object} controller.Response{data=dto.ListUserActionLogResponse} | ||||
| // @Router       /api/v1/monitor/user-action-logs [get] | ||||
| func (c *Controller) ListUserActionLogs(ctx *gin.Context) { | ||||
| func (c *Controller) ListUserActionLogs(ctx echo.Context) error { | ||||
| 	const actionType = "获取用户操作日志列表" | ||||
|  | ||||
| 	var req dto.ListUserActionLogRequest | ||||
| 	if err := ctx.ShouldBindQuery(&req); err != nil { | ||||
| 	if err := ctx.Bind(&req); err != nil { | ||||
| 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||
| 	} | ||||
|  | ||||
| 	opts := repository.UserActionLogListOptions{ | ||||
| 		UserID:     req.UserID, | ||||
| 		Username:   req.Username, | ||||
| 		ActionType: req.ActionType, | ||||
| 		OrderBy:    req.OrderBy, | ||||
| 		StartTime:  req.StartTime, | ||||
| 		EndTime:    req.EndTime, | ||||
| 	} | ||||
| 	if req.Status != nil { | ||||
| 		status := models.AuditStatus(*req.Status) | ||||
| 		opts.Status = &status | ||||
| 	} | ||||
|  | ||||
| 	data, total, err := c.monitorService.ListUserActionLogs(opts, req.Page, req.PageSize) | ||||
| 	resp, err := c.monitorService.ListUserActionLogs(&req) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, repository.ErrInvalidPagination) { | ||||
| 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||
| 		} | ||||
|  | ||||
| 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取用户操作日志失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取用户操作日志失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||
| 	} | ||||
|  | ||||
| 	resp := dto.NewListUserActionLogResponse(data, total, req.Page, req.PageSize) | ||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取用户操作日志成功", resp, actionType, "获取用户操作日志成功", req) | ||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取用户操作日志成功", resp, actionType, "获取用户操作日志成功", req) | ||||
| } | ||||
|  | ||||
| // ListRawMaterialPurchases godoc | ||||
| @@ -323,40 +232,28 @@ func (c *Controller) ListUserActionLogs(ctx *gin.Context) { | ||||
| // @Param        query query dto.ListRawMaterialPurchaseRequest true "查询参数" | ||||
| // @Success      200 {object} controller.Response{data=dto.ListRawMaterialPurchaseResponse} | ||||
| // @Router       /api/v1/monitor/raw-material-purchases [get] | ||||
| func (c *Controller) ListRawMaterialPurchases(ctx *gin.Context) { | ||||
| func (c *Controller) ListRawMaterialPurchases(ctx echo.Context) error { | ||||
| 	const actionType = "获取原料采购记录列表" | ||||
|  | ||||
| 	var req dto.ListRawMaterialPurchaseRequest | ||||
| 	if err := ctx.ShouldBindQuery(&req); err != nil { | ||||
| 	if err := ctx.Bind(&req); err != nil { | ||||
| 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||
| 	} | ||||
|  | ||||
| 	opts := repository.RawMaterialPurchaseListOptions{ | ||||
| 		RawMaterialID: req.RawMaterialID, | ||||
| 		Supplier:      req.Supplier, | ||||
| 		OrderBy:       req.OrderBy, | ||||
| 		StartTime:     req.StartTime, | ||||
| 		EndTime:       req.EndTime, | ||||
| 	} | ||||
|  | ||||
| 	data, total, err := c.monitorService.ListRawMaterialPurchases(opts, req.Page, req.PageSize) | ||||
| 	resp, err := c.monitorService.ListRawMaterialPurchases(&req) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, repository.ErrInvalidPagination) { | ||||
| 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||
| 		} | ||||
|  | ||||
| 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取原料采购记录失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取原料采购记录失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||
| 	} | ||||
|  | ||||
| 	resp := dto.NewListRawMaterialPurchaseResponse(data, total, req.Page, req.PageSize) | ||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取原料采购记录成功", resp, actionType, "获取原料采购记录成功", req) | ||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取原料采购记录成功", resp, actionType, "获取原料采购记录成功", req) | ||||
| } | ||||
|  | ||||
| // ListRawMaterialStockLogs godoc | ||||
| @@ -368,44 +265,28 @@ func (c *Controller) ListRawMaterialPurchases(ctx *gin.Context) { | ||||
| // @Param        query query dto.ListRawMaterialStockLogRequest true "查询参数" | ||||
| // @Success      200 {object} controller.Response{data=dto.ListRawMaterialStockLogResponse} | ||||
| // @Router       /api/v1/monitor/raw-material-stock-logs [get] | ||||
| func (c *Controller) ListRawMaterialStockLogs(ctx *gin.Context) { | ||||
| func (c *Controller) ListRawMaterialStockLogs(ctx echo.Context) error { | ||||
| 	const actionType = "获取原料库存日志列表" | ||||
|  | ||||
| 	var req dto.ListRawMaterialStockLogRequest | ||||
| 	if err := ctx.ShouldBindQuery(&req); err != nil { | ||||
| 	if err := ctx.Bind(&req); err != nil { | ||||
| 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||
| 	} | ||||
|  | ||||
| 	opts := repository.RawMaterialStockLogListOptions{ | ||||
| 		RawMaterialID: req.RawMaterialID, | ||||
| 		SourceID:      req.SourceID, | ||||
| 		OrderBy:       req.OrderBy, | ||||
| 		StartTime:     req.StartTime, | ||||
| 		EndTime:       req.EndTime, | ||||
| 	} | ||||
| 	if req.SourceType != nil { | ||||
| 		sourceType := models.StockLogSourceType(*req.SourceType) | ||||
| 		opts.SourceType = &sourceType | ||||
| 	} | ||||
|  | ||||
| 	data, total, err := c.monitorService.ListRawMaterialStockLogs(opts, req.Page, req.PageSize) | ||||
| 	resp, err := c.monitorService.ListRawMaterialStockLogs(&req) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, repository.ErrInvalidPagination) { | ||||
| 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||
| 		} | ||||
|  | ||||
| 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取原料库存日志失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取原料库存日志失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||
| 	} | ||||
|  | ||||
| 	resp := dto.NewListRawMaterialStockLogResponse(data, total, req.Page, req.PageSize) | ||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取原料库存日志成功", resp, actionType, "获取原料库存日志成功", req) | ||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取原料库存日志成功", resp, actionType, "获取原料库存日志成功", req) | ||||
| } | ||||
|  | ||||
| // ListFeedUsageRecords godoc | ||||
| @@ -417,41 +298,28 @@ func (c *Controller) ListRawMaterialStockLogs(ctx *gin.Context) { | ||||
| // @Param        query query dto.ListFeedUsageRecordRequest true "查询参数" | ||||
| // @Success      200 {object} controller.Response{data=dto.ListFeedUsageRecordResponse} | ||||
| // @Router       /api/v1/monitor/feed-usage-records [get] | ||||
| func (c *Controller) ListFeedUsageRecords(ctx *gin.Context) { | ||||
| func (c *Controller) ListFeedUsageRecords(ctx echo.Context) error { | ||||
| 	const actionType = "获取饲料使用记录列表" | ||||
|  | ||||
| 	var req dto.ListFeedUsageRecordRequest | ||||
| 	if err := ctx.ShouldBindQuery(&req); err != nil { | ||||
| 	if err := ctx.Bind(&req); err != nil { | ||||
| 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||
| 	} | ||||
|  | ||||
| 	opts := repository.FeedUsageRecordListOptions{ | ||||
| 		PenID:         req.PenID, | ||||
| 		FeedFormulaID: req.FeedFormulaID, | ||||
| 		OperatorID:    req.OperatorID, | ||||
| 		OrderBy:       req.OrderBy, | ||||
| 		StartTime:     req.StartTime, | ||||
| 		EndTime:       req.EndTime, | ||||
| 	} | ||||
|  | ||||
| 	data, total, err := c.monitorService.ListFeedUsageRecords(opts, req.Page, req.PageSize) | ||||
| 	resp, err := c.monitorService.ListFeedUsageRecords(&req) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, repository.ErrInvalidPagination) { | ||||
| 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||
| 		} | ||||
|  | ||||
| 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取饲料使用记录失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取饲料使用记录失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||
| 	} | ||||
|  | ||||
| 	resp := dto.NewListFeedUsageRecordResponse(data, total, req.Page, req.PageSize) | ||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取饲料使用记录成功", resp, actionType, "获取饲料使用记录成功", req) | ||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取饲料使用记录成功", resp, actionType, "获取饲料使用记录成功", req) | ||||
| } | ||||
|  | ||||
| // ListMedicationLogs godoc | ||||
| @@ -463,45 +331,28 @@ func (c *Controller) ListFeedUsageRecords(ctx *gin.Context) { | ||||
| // @Param        query query dto.ListMedicationLogRequest true "查询参数" | ||||
| // @Success      200 {object} controller.Response{data=dto.ListMedicationLogResponse} | ||||
| // @Router       /api/v1/monitor/medication-logs [get] | ||||
| func (c *Controller) ListMedicationLogs(ctx *gin.Context) { | ||||
| func (c *Controller) ListMedicationLogs(ctx echo.Context) error { | ||||
| 	const actionType = "获取用药记录列表" | ||||
|  | ||||
| 	var req dto.ListMedicationLogRequest | ||||
| 	if err := ctx.ShouldBindQuery(&req); err != nil { | ||||
| 	if err := ctx.Bind(&req); err != nil { | ||||
| 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||
| 	} | ||||
|  | ||||
| 	opts := repository.MedicationLogListOptions{ | ||||
| 		PigBatchID:   req.PigBatchID, | ||||
| 		MedicationID: req.MedicationID, | ||||
| 		OperatorID:   req.OperatorID, | ||||
| 		OrderBy:      req.OrderBy, | ||||
| 		StartTime:    req.StartTime, | ||||
| 		EndTime:      req.EndTime, | ||||
| 	} | ||||
| 	if req.Reason != nil { | ||||
| 		reason := models.MedicationReasonType(*req.Reason) | ||||
| 		opts.Reason = &reason | ||||
| 	} | ||||
|  | ||||
| 	data, total, err := c.monitorService.ListMedicationLogs(opts, req.Page, req.PageSize) | ||||
| 	resp, err := c.monitorService.ListMedicationLogs(&req) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, repository.ErrInvalidPagination) { | ||||
| 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||
| 		} | ||||
|  | ||||
| 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取用药记录失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取用药记录失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||
| 	} | ||||
|  | ||||
| 	resp := dto.NewListMedicationLogResponse(data, total, req.Page, req.PageSize) | ||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取用药记录成功", resp, actionType, "获取用药记录成功", req) | ||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取用药记录成功", resp, actionType, "获取用药记录成功", req) | ||||
| } | ||||
|  | ||||
| // ListPigBatchLogs godoc | ||||
| @@ -513,44 +364,28 @@ func (c *Controller) ListMedicationLogs(ctx *gin.Context) { | ||||
| // @Param        query query dto.ListPigBatchLogRequest true "查询参数" | ||||
| // @Success      200 {object} controller.Response{data=dto.ListPigBatchLogResponse} | ||||
| // @Router       /api/v1/monitor/pig-batch-logs [get] | ||||
| func (c *Controller) ListPigBatchLogs(ctx *gin.Context) { | ||||
| func (c *Controller) ListPigBatchLogs(ctx echo.Context) error { | ||||
| 	const actionType = "获取猪批次日志列表" | ||||
|  | ||||
| 	var req dto.ListPigBatchLogRequest | ||||
| 	if err := ctx.ShouldBindQuery(&req); err != nil { | ||||
| 	if err := ctx.Bind(&req); err != nil { | ||||
| 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||
| 	} | ||||
|  | ||||
| 	opts := repository.PigBatchLogListOptions{ | ||||
| 		PigBatchID: req.PigBatchID, | ||||
| 		OperatorID: req.OperatorID, | ||||
| 		OrderBy:    req.OrderBy, | ||||
| 		StartTime:  req.StartTime, | ||||
| 		EndTime:    req.EndTime, | ||||
| 	} | ||||
| 	if req.ChangeType != nil { | ||||
| 		changeType := models.LogChangeType(*req.ChangeType) | ||||
| 		opts.ChangeType = &changeType | ||||
| 	} | ||||
|  | ||||
| 	data, total, err := c.monitorService.ListPigBatchLogs(opts, req.Page, req.PageSize) | ||||
| 	resp, err := c.monitorService.ListPigBatchLogs(&req) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, repository.ErrInvalidPagination) { | ||||
| 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||
| 		} | ||||
|  | ||||
| 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取猪批次日志失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取猪批次日志失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||
| 	} | ||||
|  | ||||
| 	resp := dto.NewListPigBatchLogResponse(data, total, req.Page, req.PageSize) | ||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取猪批次日志成功", resp, actionType, "获取猪批次日志成功", req) | ||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取猪批次日志成功", resp, actionType, "获取猪批次日志成功", req) | ||||
| } | ||||
|  | ||||
| // ListWeighingBatches godoc | ||||
| @@ -562,39 +397,28 @@ func (c *Controller) ListPigBatchLogs(ctx *gin.Context) { | ||||
| // @Param        query query dto.ListWeighingBatchRequest true "查询参数" | ||||
| // @Success      200 {object} controller.Response{data=dto.ListWeighingBatchResponse} | ||||
| // @Router       /api/v1/monitor/weighing-batches [get] | ||||
| func (c *Controller) ListWeighingBatches(ctx *gin.Context) { | ||||
| func (c *Controller) ListWeighingBatches(ctx echo.Context) error { | ||||
| 	const actionType = "获取批次称重记录列表" | ||||
|  | ||||
| 	var req dto.ListWeighingBatchRequest | ||||
| 	if err := ctx.ShouldBindQuery(&req); err != nil { | ||||
| 	if err := ctx.Bind(&req); err != nil { | ||||
| 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||
| 	} | ||||
|  | ||||
| 	opts := repository.WeighingBatchListOptions{ | ||||
| 		PigBatchID: req.PigBatchID, | ||||
| 		OrderBy:    req.OrderBy, | ||||
| 		StartTime:  req.StartTime, | ||||
| 		EndTime:    req.EndTime, | ||||
| 	} | ||||
|  | ||||
| 	data, total, err := c.monitorService.ListWeighingBatches(opts, req.Page, req.PageSize) | ||||
| 	resp, err := c.monitorService.ListWeighingBatches(&req) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, repository.ErrInvalidPagination) { | ||||
| 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||
| 		} | ||||
|  | ||||
| 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取批次称重记录失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取批次称重记录失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||
| 	} | ||||
|  | ||||
| 	resp := dto.NewListWeighingBatchResponse(data, total, req.Page, req.PageSize) | ||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取批次称重记录成功", resp, actionType, "获取批次称重记录成功", req) | ||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取批次称重记录成功", resp, actionType, "获取批次称重记录成功", req) | ||||
| } | ||||
|  | ||||
| // ListWeighingRecords godoc | ||||
| @@ -606,41 +430,28 @@ func (c *Controller) ListWeighingBatches(ctx *gin.Context) { | ||||
| // @Param        query query dto.ListWeighingRecordRequest true "查询参数" | ||||
| // @Success      200 {object} controller.Response{data=dto.ListWeighingRecordResponse} | ||||
| // @Router       /api/v1/monitor/weighing-records [get] | ||||
| func (c *Controller) ListWeighingRecords(ctx *gin.Context) { | ||||
| func (c *Controller) ListWeighingRecords(ctx echo.Context) error { | ||||
| 	const actionType = "获取单次称重记录列表" | ||||
|  | ||||
| 	var req dto.ListWeighingRecordRequest | ||||
| 	if err := ctx.ShouldBindQuery(&req); err != nil { | ||||
| 	if err := ctx.Bind(&req); err != nil { | ||||
| 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||
| 	} | ||||
|  | ||||
| 	opts := repository.WeighingRecordListOptions{ | ||||
| 		WeighingBatchID: req.WeighingBatchID, | ||||
| 		PenID:           req.PenID, | ||||
| 		OperatorID:      req.OperatorID, | ||||
| 		OrderBy:         req.OrderBy, | ||||
| 		StartTime:       req.StartTime, | ||||
| 		EndTime:         req.EndTime, | ||||
| 	} | ||||
|  | ||||
| 	data, total, err := c.monitorService.ListWeighingRecords(opts, req.Page, req.PageSize) | ||||
| 	resp, err := c.monitorService.ListWeighingRecords(&req) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, repository.ErrInvalidPagination) { | ||||
| 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||
| 		} | ||||
|  | ||||
| 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取单次称重记录失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取单次称重记录失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||
| 	} | ||||
|  | ||||
| 	resp := dto.NewListWeighingRecordResponse(data, total, req.Page, req.PageSize) | ||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取单次称重记录成功", resp, actionType, "获取单次称重记录成功", req) | ||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取单次称重记录成功", resp, actionType, "获取单次称重记录成功", req) | ||||
| } | ||||
|  | ||||
| // ListPigTransferLogs godoc | ||||
| @@ -652,46 +463,28 @@ func (c *Controller) ListWeighingRecords(ctx *gin.Context) { | ||||
| // @Param        query query dto.ListPigTransferLogRequest true "查询参数" | ||||
| // @Success      200 {object} controller.Response{data=dto.ListPigTransferLogResponse} | ||||
| // @Router       /api/v1/monitor/pig-transfer-logs [get] | ||||
| func (c *Controller) ListPigTransferLogs(ctx *gin.Context) { | ||||
| func (c *Controller) ListPigTransferLogs(ctx echo.Context) error { | ||||
| 	const actionType = "获取猪只迁移日志列表" | ||||
|  | ||||
| 	var req dto.ListPigTransferLogRequest | ||||
| 	if err := ctx.ShouldBindQuery(&req); err != nil { | ||||
| 	if err := ctx.Bind(&req); err != nil { | ||||
| 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||
| 	} | ||||
|  | ||||
| 	opts := repository.PigTransferLogListOptions{ | ||||
| 		PigBatchID:    req.PigBatchID, | ||||
| 		PenID:         req.PenID, | ||||
| 		OperatorID:    req.OperatorID, | ||||
| 		CorrelationID: req.CorrelationID, | ||||
| 		OrderBy:       req.OrderBy, | ||||
| 		StartTime:     req.StartTime, | ||||
| 		EndTime:       req.EndTime, | ||||
| 	} | ||||
| 	if req.TransferType != nil { | ||||
| 		transferType := models.PigTransferType(*req.TransferType) | ||||
| 		opts.TransferType = &transferType | ||||
| 	} | ||||
|  | ||||
| 	data, total, err := c.monitorService.ListPigTransferLogs(opts, req.Page, req.PageSize) | ||||
| 	resp, err := c.monitorService.ListPigTransferLogs(&req) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, repository.ErrInvalidPagination) { | ||||
| 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||
| 		} | ||||
|  | ||||
| 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取猪只迁移日志失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取猪只迁移日志失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||
| 	} | ||||
|  | ||||
| 	resp := dto.NewListPigTransferLogResponse(data, total, req.Page, req.PageSize) | ||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取猪只迁移日志成功", resp, actionType, "获取猪只迁移日志成功", req) | ||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取猪只迁移日志成功", resp, actionType, "获取猪只迁移日志成功", req) | ||||
| } | ||||
|  | ||||
| // ListPigSickLogs godoc | ||||
| @@ -703,49 +496,28 @@ func (c *Controller) ListPigTransferLogs(ctx *gin.Context) { | ||||
| // @Param        query query dto.ListPigSickLogRequest true "查询参数" | ||||
| // @Success      200 {object} controller.Response{data=dto.ListPigSickLogResponse} | ||||
| // @Router       /api/v1/monitor/pig-sick-logs [get] | ||||
| func (c *Controller) ListPigSickLogs(ctx *gin.Context) { | ||||
| func (c *Controller) ListPigSickLogs(ctx echo.Context) error { | ||||
| 	const actionType = "获取病猪日志列表" | ||||
|  | ||||
| 	var req dto.ListPigSickLogRequest | ||||
| 	if err := ctx.ShouldBindQuery(&req); err != nil { | ||||
| 	if err := ctx.Bind(&req); err != nil { | ||||
| 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||
| 	} | ||||
|  | ||||
| 	opts := repository.PigSickLogListOptions{ | ||||
| 		PigBatchID: req.PigBatchID, | ||||
| 		PenID:      req.PenID, | ||||
| 		OperatorID: req.OperatorID, | ||||
| 		OrderBy:    req.OrderBy, | ||||
| 		StartTime:  req.StartTime, | ||||
| 		EndTime:    req.EndTime, | ||||
| 	} | ||||
| 	if req.Reason != nil { | ||||
| 		reason := models.PigBatchSickPigReasonType(*req.Reason) | ||||
| 		opts.Reason = &reason | ||||
| 	} | ||||
| 	if req.TreatmentLocation != nil { | ||||
| 		treatmentLocation := models.PigBatchSickPigTreatmentLocation(*req.TreatmentLocation) | ||||
| 		opts.TreatmentLocation = &treatmentLocation | ||||
| 	} | ||||
|  | ||||
| 	data, total, err := c.monitorService.ListPigSickLogs(opts, req.Page, req.PageSize) | ||||
| 	resp, err := c.monitorService.ListPigSickLogs(&req) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, repository.ErrInvalidPagination) { | ||||
| 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||
| 		} | ||||
|  | ||||
| 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取病猪日志失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取病猪日志失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||
| 	} | ||||
|  | ||||
| 	resp := dto.NewListPigSickLogResponse(data, total, req.Page, req.PageSize) | ||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取病猪日志成功", resp, actionType, "获取病猪日志成功", req) | ||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取病猪日志成功", resp, actionType, "获取病猪日志成功", req) | ||||
| } | ||||
|  | ||||
| // ListPigPurchases godoc | ||||
| @@ -757,41 +529,28 @@ func (c *Controller) ListPigSickLogs(ctx *gin.Context) { | ||||
| // @Param        query query dto.ListPigPurchaseRequest true "查询参数" | ||||
| // @Success      200 {object} controller.Response{data=dto.ListPigPurchaseResponse} | ||||
| // @Router       /api/v1/monitor/pig-purchases [get] | ||||
| func (c *Controller) ListPigPurchases(ctx *gin.Context) { | ||||
| func (c *Controller) ListPigPurchases(ctx echo.Context) error { | ||||
| 	const actionType = "获取猪只采购记录列表" | ||||
|  | ||||
| 	var req dto.ListPigPurchaseRequest | ||||
| 	if err := ctx.ShouldBindQuery(&req); err != nil { | ||||
| 	if err := ctx.Bind(&req); err != nil { | ||||
| 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||
| 	} | ||||
|  | ||||
| 	opts := repository.PigPurchaseListOptions{ | ||||
| 		PigBatchID: req.PigBatchID, | ||||
| 		Supplier:   req.Supplier, | ||||
| 		OperatorID: req.OperatorID, | ||||
| 		OrderBy:    req.OrderBy, | ||||
| 		StartTime:  req.StartTime, | ||||
| 		EndTime:    req.EndTime, | ||||
| 	} | ||||
|  | ||||
| 	data, total, err := c.monitorService.ListPigPurchases(opts, req.Page, req.PageSize) | ||||
| 	resp, err := c.monitorService.ListPigPurchases(&req) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, repository.ErrInvalidPagination) { | ||||
| 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||
| 		} | ||||
|  | ||||
| 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取猪只采购记录失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取猪只采购记录失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||
| 	} | ||||
|  | ||||
| 	resp := dto.NewListPigPurchaseResponse(data, total, req.Page, req.PageSize) | ||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取猪只采购记录成功", resp, actionType, "获取猪只采购记录成功", req) | ||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取猪只采购记录成功", resp, actionType, "获取猪只采购记录成功", req) | ||||
| } | ||||
|  | ||||
| // ListPigSales godoc | ||||
| @@ -803,39 +562,59 @@ func (c *Controller) ListPigPurchases(ctx *gin.Context) { | ||||
| // @Param        query query dto.ListPigSaleRequest true "查询参数" | ||||
| // @Success      200 {object} controller.Response{data=dto.ListPigSaleResponse} | ||||
| // @Router       /api/v1/monitor/pig-sales [get] | ||||
| func (c *Controller) ListPigSales(ctx *gin.Context) { | ||||
| func (c *Controller) ListPigSales(ctx echo.Context) error { | ||||
| 	const actionType = "获取猪只售卖记录列表" | ||||
|  | ||||
| 	var req dto.ListPigSaleRequest | ||||
| 	if err := ctx.ShouldBindQuery(&req); err != nil { | ||||
| 	if err := ctx.Bind(&req); err != nil { | ||||
| 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||
| 	} | ||||
|  | ||||
| 	opts := repository.PigSaleListOptions{ | ||||
| 		PigBatchID: req.PigBatchID, | ||||
| 		Buyer:      req.Buyer, | ||||
| 		OperatorID: req.OperatorID, | ||||
| 		OrderBy:    req.OrderBy, | ||||
| 		StartTime:  req.StartTime, | ||||
| 		EndTime:    req.EndTime, | ||||
| 	} | ||||
|  | ||||
| 	data, total, err := c.monitorService.ListPigSales(opts, req.Page, req.PageSize) | ||||
| 	resp, err := c.monitorService.ListPigSales(&req) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, repository.ErrInvalidPagination) { | ||||
| 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||
| 			return | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||
| 		} | ||||
|  | ||||
| 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取猪只售卖记录失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取猪只售卖记录失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||
| 	} | ||||
|  | ||||
| 	resp := dto.NewListPigSaleResponse(data, total, req.Page, req.PageSize) | ||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取猪只售卖记录成功", resp, actionType, "获取猪只售卖记录成功", req) | ||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取猪只售卖记录成功", resp, actionType, "获取猪只售卖记录成功", req) | ||||
| } | ||||
|  | ||||
| // ListNotifications godoc | ||||
| // @Summary      批量查询通知 | ||||
| // @Description  根据提供的过滤条件,分页获取通知列表 | ||||
| // @Tags         数据监控 | ||||
| // @Security     BearerAuth | ||||
| // @Produce      json | ||||
| // @Param        query query dto.ListNotificationRequest true "查询参数" | ||||
| // @Success      200 {object} controller.Response{data=dto.ListNotificationResponse} | ||||
| // @Router       /api/v1/monitor/notifications [get] | ||||
| func (c *Controller) ListNotifications(ctx echo.Context) error { | ||||
| 	const actionType = "批量查询通知" | ||||
|  | ||||
| 	var req dto.ListNotificationRequest | ||||
| 	if err := ctx.Bind(&req); err != nil { | ||||
| 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||
| 	} | ||||
|  | ||||
| 	resp, err := c.monitorService.ListNotifications(&req) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, repository.ErrInvalidPagination) { | ||||
| 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||
| 		} | ||||
|  | ||||
| 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "批量查询通知失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||
| 	} | ||||
|  | ||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "批量查询通知成功", resp, actionType, "批量查询通知成功", req) | ||||
| } | ||||
|   | ||||
| @@ -6,29 +6,24 @@ import ( | ||||
|  | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/controller" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/dto" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/domain/task" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/service" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"gorm.io/gorm" | ||||
| 	"github.com/labstack/echo/v4" | ||||
| ) | ||||
|  | ||||
| // --- Controller 定义 --- | ||||
| // --- 控制器定义 --- | ||||
|  | ||||
| // Controller 定义了计划相关的控制器 | ||||
| type Controller struct { | ||||
| 	logger                  *logs.Logger | ||||
| 	planRepo                repository.PlanRepository | ||||
| 	analysisPlanTaskManager *task.AnalysisPlanTaskManager | ||||
| 	logger      *logs.Logger | ||||
| 	planService service.PlanService | ||||
| } | ||||
|  | ||||
| // NewController 创建一个新的 Controller 实例 | ||||
| func NewController(logger *logs.Logger, planRepo repository.PlanRepository, analysisPlanTaskManager *task.AnalysisPlanTaskManager) *Controller { | ||||
| func NewController(logger *logs.Logger, planService service.PlanService) *Controller { | ||||
| 	return &Controller{ | ||||
| 		logger:                  logger, | ||||
| 		planRepo:                planRepo, | ||||
| 		analysisPlanTaskManager: analysisPlanTaskManager, | ||||
| 		logger:      logger, | ||||
| 		planService: planService, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @@ -44,55 +39,28 @@ func NewController(logger *logs.Logger, planRepo repository.PlanRepository, anal | ||||
| // @Param        plan body dto.CreatePlanRequest true "计划信息" | ||||
| // @Success      200 {object} controller.Response{data=dto.PlanResponse} "业务码为201代表创建成功" | ||||
| // @Router       /api/v1/plans [post] | ||||
| func (c *Controller) CreatePlan(ctx *gin.Context) { | ||||
| func (c *Controller) CreatePlan(ctx echo.Context) error { | ||||
| 	var req dto.CreatePlanRequest | ||||
| 	const actionType = "创建计划" | ||||
| 	if err := ctx.ShouldBindJSON(&req); err != nil { | ||||
| 	if err := ctx.Bind(&req); err != nil { | ||||
| 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req) | ||||
| 	} | ||||
|  | ||||
| 	// 使用已有的转换函数,它已经包含了验证和重排逻辑 | ||||
| 	planToCreate, err := dto.NewPlanFromCreateRequest(&req) | ||||
| 	// 调用服务层创建计划 | ||||
| 	resp, err := c.planService.CreatePlan(&req) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 计划数据校验失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "计划数据校验失败: "+err.Error(), actionType, "计划数据校验失败", req) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	// --- 自动判断 ContentType --- | ||||
| 	if len(req.SubPlanIDs) > 0 { | ||||
| 		planToCreate.ContentType = models.PlanContentTypeSubPlans | ||||
| 	} else { | ||||
| 		// 如果 SubPlanIDs 未提供,则默认为 Tasks 类型(即使 Tasks 字段也未提供) | ||||
| 		planToCreate.ContentType = models.PlanContentTypeTasks | ||||
| 	} | ||||
|  | ||||
| 	// 调用仓库方法创建计划 | ||||
| 	if err := c.planRepo.CreatePlan(planToCreate); err != nil { | ||||
| 		c.logger.Errorf("%s: 数据库创建计划失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "创建计划失败: "+err.Error(), actionType, "数据库创建计划失败", planToCreate) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	// 创建成功后,调用 manager 确保触发器任务定义存在,但不立即加入待执行队列 | ||||
| 	if err := c.analysisPlanTaskManager.EnsureAnalysisTaskDefinition(planToCreate.ID); err != nil { | ||||
| 		// 这是一个非阻塞性错误,我们只记录日志,因为主流程(创建计划)已经成功 | ||||
| 		c.logger.Errorf("为新创建的计划 %d 确保触发器任务定义失败: %v", planToCreate.ID, err) | ||||
| 	} | ||||
|  | ||||
| 	// 使用已有的转换函数将创建后的模型转换为响应对象 | ||||
| 	resp, err := dto.NewPlanToResponse(planToCreate) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 序列化响应失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "计划创建成功,但响应生成失败", actionType, "响应序列化失败", planToCreate) | ||||
| 		return | ||||
| 		c.logger.Errorf("%s: 服务层创建计划失败: %v", actionType, err) | ||||
| 		// 根据服务层返回的错误类型,转换为相应的HTTP状态码 | ||||
| 		if errors.Is(err, service.ErrPlanNotFound) { | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "计划数据校验失败或关联计划不存在", req) | ||||
| 		} | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "创建计划失败: "+err.Error(), actionType, "服务层创建计划失败", req) | ||||
| 	} | ||||
|  | ||||
| 	// 使用统一的成功响应函数 | ||||
| 	c.logger.Infof("%s: 计划创建成功, ID: %d", actionType, planToCreate.ID) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "计划创建成功", resp, actionType, "计划创建成功", resp) | ||||
| 	c.logger.Infof("%s: 计划创建成功, ID: %d", actionType, resp.ID) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "计划创建成功", resp, actionType, "计划创建成功", resp) | ||||
| } | ||||
|  | ||||
| // GetPlan godoc | ||||
| @@ -104,87 +72,62 @@ func (c *Controller) CreatePlan(ctx *gin.Context) { | ||||
| // @Param        id path int true "计划ID" | ||||
| // @Success      200 {object} controller.Response{data=dto.PlanResponse} "业务码为200代表成功获取" | ||||
| // @Router       /api/v1/plans/{id} [get] | ||||
| func (c *Controller) GetPlan(ctx *gin.Context) { | ||||
| func (c *Controller) GetPlan(ctx echo.Context) error { | ||||
| 	const actionType = "获取计划详情" | ||||
| 	// 1. 从 URL 路径中获取 ID | ||||
| 	idStr := ctx.Param("id") | ||||
| 	id, err := strconv.ParseUint(idStr, 10, 32) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 计划ID格式错误: %v, ID: %s", actionType, err, idStr) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的计划ID格式", actionType, "计划ID格式错误", idStr) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的计划ID格式", actionType, "计划ID格式错误", idStr) | ||||
| 	} | ||||
|  | ||||
| 	// 2. 调用仓库层获取计划详情 | ||||
| 	plan, err := c.planRepo.GetPlanByID(uint(id)) | ||||
| 	// 调用服务层获取计划详情 | ||||
| 	resp, err := c.planService.GetPlanByID(uint(id)) | ||||
| 	if err != nil { | ||||
| 		// 判断是否为“未找到”错误 | ||||
| 		if errors.Is(err, gorm.ErrRecordNotFound) { | ||||
| 			c.logger.Warnf("%s: 计划不存在, ID: %d", actionType, id) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "计划不存在", actionType, "计划不存在", id) | ||||
| 			return | ||||
| 		c.logger.Errorf("%s: 服务层获取计划详情失败: %v, ID: %d", actionType, err, id) | ||||
| 		if errors.Is(err, service.ErrPlanNotFound) { | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "计划不存在", id) | ||||
| 		} | ||||
| 		// 其他数据库错误视为内部错误 | ||||
| 		c.logger.Errorf("%s: 数据库查询失败: %v, ID: %d", actionType, err, id) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取计划详情时发生内部错误", actionType, "数据库查询失败", id) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	// 3. 将模型转换为响应 DTO | ||||
| 	resp, err := dto.NewPlanToResponse(plan) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 序列化响应失败: %v, Plan: %+v", actionType, err, plan) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取计划详情失败: 内部数据格式错误", actionType, "响应序列化失败", plan) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取计划详情失败: "+err.Error(), actionType, "服务层获取计划详情失败", id) | ||||
| 	} | ||||
|  | ||||
| 	// 4. 发送成功响应 | ||||
| 	c.logger.Infof("%s: 获取计划详情成功, ID: %d", actionType, id) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取计划详情成功", resp, actionType, "获取计划详情成功", resp) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取计划详情成功", resp, actionType, "获取计划详情成功", resp) | ||||
| } | ||||
|  | ||||
| // ListPlans godoc | ||||
| // @Summary      获取计划列表 | ||||
| // @Description  获取所有计划的列表 | ||||
| // @Description  获取所有计划的列表,支持按类型过滤和分页 | ||||
| // @Tags         计划管理 | ||||
| // @Security     BearerAuth | ||||
| // @Produce      json | ||||
| // @Success      200 {object} controller.Response{data=[]dto.PlanResponse} "业务码为200代表成功获取列表" | ||||
| // @Param        query query dto.ListPlansQuery false "查询参数" | ||||
| // @Success      200 {object} controller.Response{data=dto.ListPlansResponse} "业务码为200代表成功获取列表" | ||||
| // @Router       /api/v1/plans [get] | ||||
| func (c *Controller) ListPlans(ctx *gin.Context) { | ||||
| func (c *Controller) ListPlans(ctx echo.Context) error { | ||||
| 	const actionType = "获取计划列表" | ||||
| 	// 1. 调用仓库层获取所有计划 | ||||
| 	plans, err := c.planRepo.ListBasicPlans() | ||||
| 	var query dto.ListPlansQuery | ||||
| 	if err := ctx.Bind(&query); err != nil { | ||||
| 		c.logger.Errorf("%s: 查询参数绑定失败: %v", actionType, err) | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "查询参数绑定失败", query) | ||||
| 	} | ||||
|  | ||||
| 	// 调用服务层获取计划列表 | ||||
| 	resp, err := c.planService.ListPlans(&query) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 数据库查询失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取计划列表时发生内部错误", actionType, "数据库查询失败", nil) | ||||
| 		return | ||||
| 		c.logger.Errorf("%s: 服务层获取计划列表失败: %v", actionType, err) | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取计划列表失败: "+err.Error(), actionType, "服务层获取计划列表失败", nil) | ||||
| 	} | ||||
|  | ||||
| 	// 2. 将模型转换为响应 DTO | ||||
| 	planResponses := make([]dto.PlanResponse, 0, len(plans)) | ||||
| 	for _, p := range plans { | ||||
| 		resp, err := dto.NewPlanToResponse(&p) | ||||
| 		if err != nil { | ||||
| 			c.logger.Errorf("%s: 序列化响应失败: %v, Plan: %+v", actionType, err, p) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取计划列表失败: 内部数据格式错误", actionType, "响应序列化失败", p) | ||||
| 			return | ||||
| 		} | ||||
| 		planResponses = append(planResponses, *resp) | ||||
| 	} | ||||
|  | ||||
| 	// 3. 构造并发送成功响应 | ||||
| 	resp := dto.ListPlansResponse{ | ||||
| 		Plans: planResponses, | ||||
| 		Total: len(planResponses), | ||||
| 	} | ||||
| 	c.logger.Infof("%s: 获取计划列表成功, 数量: %d", actionType, len(planResponses)) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取计划列表成功", resp, actionType, "获取计划列表成功", resp) | ||||
| 	c.logger.Infof("%s: 获取计划列表成功, 数量: %d", actionType, len(resp.Plans)) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取计划列表成功", resp, actionType, "获取计划列表成功", resp) | ||||
| } | ||||
|  | ||||
| // UpdatePlan godoc | ||||
| // @Summary      更新计划 | ||||
| // @Description  根据计划ID更新计划的详细信息。 | ||||
| // @Description  根据计划ID更新计划的详细信息。系统计划不允许修改。 | ||||
| // @Tags         计划管理 | ||||
| // @Security     BearerAuth | ||||
| // @Accept       json | ||||
| @@ -193,275 +136,148 @@ func (c *Controller) ListPlans(ctx *gin.Context) { | ||||
| // @Param        plan body dto.UpdatePlanRequest true "更新后的计划信息" | ||||
| // @Success      200 {object} controller.Response{data=dto.PlanResponse} "业务码为200代表更新成功" | ||||
| // @Router       /api/v1/plans/{id} [put] | ||||
| func (c *Controller) UpdatePlan(ctx *gin.Context) { | ||||
| func (c *Controller) UpdatePlan(ctx echo.Context) error { | ||||
| 	const actionType = "更新计划" | ||||
| 	// 1. 从 URL 路径中获取 ID | ||||
| 	idStr := ctx.Param("id") | ||||
| 	id, err := strconv.ParseUint(idStr, 10, 32) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 计划ID格式错误: %v, ID: %s", actionType, err, idStr) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的计划ID格式", actionType, "计划ID格式错误", idStr) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的计划ID格式", actionType, "计划ID格式错误", idStr) | ||||
| 	} | ||||
|  | ||||
| 	// 2. 绑定请求体 | ||||
| 	var req dto.UpdatePlanRequest | ||||
| 	if err := ctx.ShouldBindJSON(&req); err != nil { | ||||
| 	if err := ctx.Bind(&req); err != nil { | ||||
| 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req) | ||||
| 	} | ||||
|  | ||||
| 	// 3. 将请求转换为模型(转换函数带校验) | ||||
| 	planToUpdate, err := dto.NewPlanFromUpdateRequest(&req) | ||||
| 	// 调用服务层更新计划 | ||||
| 	resp, err := c.planService.UpdatePlan(uint(id), &req) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 计划数据校验失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "计划数据校验失败: "+err.Error(), actionType, "计划数据校验失败", req) | ||||
| 		return | ||||
| 	} | ||||
| 	planToUpdate.ID = uint(id) // 确保ID被设置 | ||||
|  | ||||
| 	// --- 自动判断 ContentType --- | ||||
| 	if len(req.SubPlanIDs) > 0 { | ||||
| 		planToUpdate.ContentType = models.PlanContentTypeSubPlans | ||||
| 	} else { | ||||
| 		// 如果 SubPlanIDs 未提供,则默认为 Tasks 类型(即使 Tasks 字段也未提供) | ||||
| 		planToUpdate.ContentType = models.PlanContentTypeTasks | ||||
| 	} | ||||
|  | ||||
| 	// 4. 检查计划是否存在 | ||||
| 	_, err = c.planRepo.GetBasicPlanByID(uint(id)) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, gorm.ErrRecordNotFound) { | ||||
| 			c.logger.Warnf("%s: 计划不存在, ID: %d", actionType, id) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "计划不存在", actionType, "计划不存在", id) | ||||
| 			return | ||||
| 		c.logger.Errorf("%s: 服务层更新计划失败: %v, ID: %d", actionType, err, id) | ||||
| 		if errors.Is(err, service.ErrPlanNotFound) { | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "计划不存在", id) | ||||
| 		} else if errors.Is(err, service.ErrPlanCannotBeModified) { | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeForbidden, err.Error(), actionType, "系统计划不允许修改", id) | ||||
| 		} | ||||
| 		c.logger.Errorf("%s: 获取计划信息失败: %v, ID: %d", actionType, err, id) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取计划信息时发生内部错误", actionType, "数据库查询失败", id) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新计划失败: "+err.Error(), actionType, "服务层更新计划失败", req) | ||||
| 	} | ||||
|  | ||||
| 	// 5. 调用仓库方法更新计划 | ||||
| 	// 只要是更新任务,就重置执行计数器 | ||||
| 	planToUpdate.ExecuteCount = 0 // 重置计数器 | ||||
| 	c.logger.Infof("计划 #%d 被更新,执行计数器已重置为 0。", planToUpdate.ID) | ||||
|  | ||||
| 	if err := c.planRepo.UpdatePlan(planToUpdate); err != nil { | ||||
| 		c.logger.Errorf("%s: 数据库更新计划失败: %v, Plan: %+v", actionType, err, planToUpdate) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新计划失败: "+err.Error(), actionType, "数据库更新计划失败", planToUpdate) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	// 更新成功后,调用 manager 确保触发器任务定义存在 | ||||
| 	if err := c.analysisPlanTaskManager.EnsureAnalysisTaskDefinition(planToUpdate.ID); err != nil { | ||||
| 		// 这是一个非阻塞性错误,我们只记录日志 | ||||
| 		c.logger.Errorf("为更新后的计划 %d 确保触发器任务定义失败: %v", planToUpdate.ID, err) | ||||
| 	} | ||||
|  | ||||
| 	// 6. 获取更新后的完整计划用于响应 | ||||
| 	updatedPlan, err := c.planRepo.GetPlanByID(uint(id)) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 获取更新后计划详情失败: %v, ID: %d", actionType, err, id) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取更新后计划详情时发生内部错误", actionType, "获取更新后计划详情失败", id) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	// 7. 将模型转换为响应 DTO | ||||
| 	resp, err := dto.NewPlanToResponse(updatedPlan) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 序列化响应失败: %v, Updated Plan: %+v", actionType, err, updatedPlan) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "计划更新成功,但响应生成失败", actionType, "响应序列化失败", updatedPlan) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	// 8. 发送成功响应 | ||||
| 	c.logger.Infof("%s: 计划更新成功, ID: %d", actionType, updatedPlan.ID) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "计划更新成功", resp, actionType, "计划更新成功", resp) | ||||
| 	// 9. 发送成功响应 | ||||
| 	c.logger.Infof("%s: 计划更新成功, ID: %d", actionType, resp.ID) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "计划更新成功", resp, actionType, "计划更新成功", resp) | ||||
| } | ||||
|  | ||||
| // DeletePlan godoc | ||||
| // @Summary      删除计划 | ||||
| // @Description  根据计划ID删除计划。(软删除) | ||||
| // @Description  根据计划ID删除计划。(软删除)系统计划不允许删除。 | ||||
| // @Tags         计划管理 | ||||
| // @Security     BearerAuth | ||||
| // @Produce      json | ||||
| // @Param        id path int true "计划ID" | ||||
| // @Success      200 {object} controller.Response "业务码为200代表删除成功" | ||||
| // @Router       /api/v1/plans/{id} [delete] | ||||
| func (c *Controller) DeletePlan(ctx *gin.Context) { | ||||
| func (c *Controller) DeletePlan(ctx echo.Context) error { | ||||
| 	const actionType = "删除计划" | ||||
| 	// 1. 从 URL 路径中获取 ID | ||||
| 	idStr := ctx.Param("id") | ||||
| 	id, err := strconv.ParseUint(idStr, 10, 32) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 计划ID格式错误: %v, ID: %s", actionType, err, idStr) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的计划ID格式", actionType, "计划ID格式错误", idStr) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的计划ID格式", actionType, "计划ID格式错误", idStr) | ||||
| 	} | ||||
|  | ||||
| 	// 2. 检查计划是否存在 | ||||
| 	plan, err := c.planRepo.GetBasicPlanByID(uint(id)) | ||||
| 	// 调用服务层删除计划 | ||||
| 	err = c.planService.DeletePlan(uint(id)) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, gorm.ErrRecordNotFound) { | ||||
| 			c.logger.Warnf("%s: 计划不存在, ID: %d", actionType, id) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "计划不存在", actionType, "计划不存在", id) | ||||
| 			return | ||||
| 		c.logger.Errorf("%s: 服务层删除计划失败: %v, ID: %d", actionType, err, id) | ||||
| 		if errors.Is(err, service.ErrPlanNotFound) { | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "计划不存在", id) | ||||
| 		} else if errors.Is(err, service.ErrPlanCannotBeDeleted) { | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeForbidden, err.Error(), actionType, "系统计划不允许删除", id) | ||||
| 		} | ||||
| 		c.logger.Errorf("%s: 获取计划信息失败: %v, ID: %d", actionType, err, id) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取计划信息时发生内部错误", actionType, "数据库查询失败", id) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除计划失败: "+err.Error(), actionType, "服务层删除计划失败", id) | ||||
| 	} | ||||
|  | ||||
| 	// 3. 停止这个计划 | ||||
| 	if plan.Status == models.PlanStatusEnabled { | ||||
| 		if err := c.planRepo.StopPlanTransactionally(uint(id)); err != nil { | ||||
| 			c.logger.Errorf("%s: 停止计划失败: %v, ID: %d", actionType, err, id) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "停止计划时发生内部错误: "+err.Error(), actionType, "停止计划失败", id) | ||||
| 			return | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// 4. 调用仓库层删除计划 | ||||
| 	if err := c.planRepo.DeletePlan(uint(id)); err != nil { | ||||
| 		c.logger.Errorf("%s: 数据库删除失败: %v, ID: %d", actionType, err, id) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除计划时发生内部错误", actionType, "数据库删除失败", id) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	// 5. 发送成功响应 | ||||
| 	// 6. 发送成功响应 | ||||
| 	c.logger.Infof("%s: 计划删除成功, ID: %d", actionType, id) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "计划删除成功", nil, actionType, "计划删除成功", id) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "计划删除成功", nil, actionType, "计划删除成功", id) | ||||
| } | ||||
|  | ||||
| // StartPlan godoc | ||||
| // @Summary      启动计划 | ||||
| // @Description  根据计划ID启动一个计划的执行。 | ||||
| // @Description  根据计划ID启动一个计划的执行。系统计划不允许手动启动。 | ||||
| // @Tags         计划管理 | ||||
| // @Security     BearerAuth | ||||
| // @Produce      json | ||||
| // @Param        id path int true "计划ID" | ||||
| // @Success      200 {object} controller.Response "业务码为200代表成功启动计划" | ||||
| // @Router       /api/v1/plans/{id}/start [post] | ||||
| func (c *Controller) StartPlan(ctx *gin.Context) { | ||||
| func (c *Controller) StartPlan(ctx echo.Context) error { | ||||
| 	const actionType = "启动计划" | ||||
| 	// 1. 从 URL 路径中获取 ID | ||||
| 	idStr := ctx.Param("id") | ||||
| 	id, err := strconv.ParseUint(idStr, 10, 32) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 计划ID格式错误: %v, ID: %s", actionType, err, idStr) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的计划ID格式", actionType, "计划ID格式错误", idStr) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的计划ID格式", actionType, "计划ID格式错误", idStr) | ||||
| 	} | ||||
|  | ||||
| 	// 2. 检查计划是否存在 | ||||
| 	plan, err := c.planRepo.GetBasicPlanByID(uint(id)) | ||||
| 	// 调用服务层启动计划 | ||||
| 	err = c.planService.StartPlan(uint(id)) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, gorm.ErrRecordNotFound) { | ||||
| 			c.logger.Warnf("%s: 计划不存在, ID: %d", actionType, id) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "计划不存在", actionType, "计划不存在", id) | ||||
| 			return | ||||
| 		c.logger.Errorf("%s: 服务层启动计划失败: %v, ID: %d", actionType, err, id) | ||||
| 		if errors.Is(err, service.ErrPlanNotFound) { | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "计划不存在", id) | ||||
| 		} else if errors.Is(err, service.ErrPlanCannotBeStarted) { | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeForbidden, err.Error(), actionType, "系统计划不允许手动启动", id) | ||||
| 		} else if errors.Is(err, service.ErrPlanAlreadyEnabled) { | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, err.Error(), actionType, "计划已处于启动状态", id) | ||||
| 		} | ||||
| 		c.logger.Errorf("%s: 获取计划信息失败: %v, ID: %d", actionType, err, id) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取计划信息时发生内部错误", actionType, "数据库查询失败", id) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	// 3. 检查计划当前状态 | ||||
| 	if plan.Status == models.PlanStatusEnabled { | ||||
| 		c.logger.Warnf("%s: 计划已处于启动状态,无需重复操作, ID: %d", actionType, id) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "计划已处于启动状态,无需重复操作", actionType, "计划已处于启动状态", id) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	// 4. 检查并重置执行计数器,然后更新计划状态为“已启动” | ||||
| 	// 只有当计划是从非 Enabled 状态(如 Disabled, Stopeed, Failed)启动时,才需要重置计数器 | ||||
| 	if plan.Status != models.PlanStatusEnabled { | ||||
| 		// 如果计划是从停止或失败状态重新启动,且计数器不为0,则重置执行计数 | ||||
| 		if plan.ExecuteCount > 0 { | ||||
| 			if err := c.planRepo.UpdateExecuteCount(plan.ID, 0); err != nil { | ||||
| 				c.logger.Errorf("%s: 重置计划执行计数失败: %v, ID: %d", actionType, err, plan.ID) | ||||
| 				controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "重置计划执行计数失败", actionType, "重置执行计数失败", plan.ID) | ||||
| 				return | ||||
| 			} | ||||
| 			c.logger.Infof("计划 #%d 的执行计数器已重置为 0。", plan.ID) | ||||
| 		} | ||||
|  | ||||
| 		// 更新计划状态为“已启动” | ||||
| 		if err := c.planRepo.UpdatePlanStatus(plan.ID, models.PlanStatusEnabled); err != nil { | ||||
| 			c.logger.Errorf("%s: 更新计划状态失败: %v, ID: %d", actionType, err, plan.ID) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新计划状态失败", actionType, "更新计划状态失败", plan.ID) | ||||
| 			return | ||||
| 		} | ||||
| 		c.logger.Infof("已成功更新计划 #%d 的状态为 '已启动'。", plan.ID) | ||||
| 	} else { | ||||
| 		// 如果计划已经处于 Enabled 状态,则无需更新 | ||||
| 		c.logger.Infof("计划 #%d 已处于启动状态,无需重复操作。", plan.ID) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "计划已处于启动状态,无需重复操作", actionType, "计划已处于启动状态", plan.ID) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	// 5. 为计划创建或更新触发器 | ||||
| 	if err := c.analysisPlanTaskManager.CreateOrUpdateTrigger(plan.ID); err != nil { | ||||
| 		// 此处错误不回滚状态,因为状态更新已成功,但需要明确告知用户触发器创建失败 | ||||
| 		c.logger.Errorf("%s: 创建或更新触发器失败: %v, ID: %d", actionType, err, plan.ID) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "计划状态已更新,但创建执行触发器失败,请检查计划配置或稍后重试", actionType, "创建执行触发器失败", plan.ID) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "启动计划失败: "+err.Error(), actionType, "服务层启动计划失败", id) | ||||
| 	} | ||||
|  | ||||
| 	// 6. 发送成功响应 | ||||
| 	c.logger.Infof("%s: 计划已成功启动, ID: %d", actionType, id) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "计划已成功启动", nil, actionType, "计划已成功启动", id) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "计划已成功启动", nil, actionType, "计划已成功启动", id) | ||||
| } | ||||
|  | ||||
| // StopPlan godoc | ||||
| // @Summary      停止计划 | ||||
| // @Description  根据计划ID停止一个正在执行的计划。 | ||||
| // @Description  根据计划ID停止一个正在执行的计划。系统计划不能被停止。 | ||||
| // @Tags         计划管理 | ||||
| // @Security     BearerAuth | ||||
| // @Produce      json | ||||
| // @Param        id path int true "计划ID" | ||||
| // @Success      200 {object} controller.Response "业务码为200代表成功停止计划" | ||||
| // @Router       /api/v1/plans/{id}/stop [post] | ||||
| func (c *Controller) StopPlan(ctx *gin.Context) { | ||||
| func (c *Controller) StopPlan(ctx echo.Context) error { | ||||
| 	const actionType = "停止计划" | ||||
| 	// 1. 从 URL 路径中获取 ID | ||||
| 	idStr := ctx.Param("id") | ||||
| 	id, err := strconv.ParseUint(idStr, 10, 32) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 计划ID格式错误: %v, ID: %s", actionType, err, idStr) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的计划ID格式", actionType, "计划ID格式错误", idStr) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的计划ID格式", actionType, "计划ID格式错误", idStr) | ||||
| 	} | ||||
|  | ||||
| 	// 2. 检查计划是否存在 | ||||
| 	plan, err := c.planRepo.GetBasicPlanByID(uint(id)) | ||||
| 	// 调用服务层停止计划 | ||||
| 	err = c.planService.StopPlan(uint(id)) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, gorm.ErrRecordNotFound) { | ||||
| 			c.logger.Warnf("%s: 计划不存在, ID: %d", actionType, id) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "计划不存在", actionType, "计划不存在", id) | ||||
| 			return | ||||
| 		c.logger.Errorf("%s: 服务层停止计划失败: %v, ID: %d", actionType, err, id) | ||||
| 		if errors.Is(err, service.ErrPlanNotFound) { | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "计划不存在", id) | ||||
| 		} else if errors.Is(err, service.ErrPlanCannotBeStopped) { | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeForbidden, err.Error(), actionType, "系统计划不允许停止", id) | ||||
| 		} else if errors.Is(err, service.ErrPlanNotEnabled) { | ||||
| 			return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, err.Error(), actionType, "计划未启用", id) | ||||
| 		} | ||||
| 		c.logger.Errorf("%s: 获取计划信息失败: %v, ID: %d", actionType, err, id) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取计划信息时发生内部错误", actionType, "数据库查询失败", id) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "停止计划失败: "+err.Error(), actionType, "服务层停止计划失败", id) | ||||
| 	} | ||||
|  | ||||
| 	// 3. 检查计划当前状态 | ||||
| 	if plan.Status != models.PlanStatusEnabled { | ||||
| 		c.logger.Warnf("%s: 计划当前不是启用状态, ID: %d, Status: %s", actionType, id, plan.Status) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "计划当前不是启用状态", actionType, "计划未启用", id) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	// 4. 调用仓库层方法,该方法内部处理事务 | ||||
| 	if err := c.planRepo.StopPlanTransactionally(uint(id)); err != nil { | ||||
| 		c.logger.Errorf("%s: 停止计划失败: %v, ID: %d", actionType, err, id) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "停止计划时发生内部错误: "+err.Error(), actionType, "停止计划失败", id) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	// 5. 发送成功响应 | ||||
| 	// 6. 发送成功响应 | ||||
| 	c.logger.Infof("%s: 计划已成功停止, ID: %d", actionType, id) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "计划已成功停止", nil, actionType, "计划已成功停止", id) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "计划已成功停止", nil, actionType, "计划已成功停止", id) | ||||
| } | ||||
|   | ||||
| @@ -1,827 +0,0 @@ | ||||
| package plan | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"encoding/json" | ||||
| 	"errors" | ||||
| 	"net/http" | ||||
| 	"net/http/httptest" | ||||
| 	"strconv" | ||||
| 	"testing" | ||||
|  | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/controller" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| 	"gorm.io/gorm" | ||||
| ) | ||||
|  | ||||
| // MockPlanRepository 是 repository.PlanRepository 的一个模拟实现,用于测试 | ||||
| type MockPlanRepository struct { | ||||
| 	// CreatePlanFunc 模拟 CreatePlan 方法的行为 | ||||
| 	CreatePlanFunc func(plan *models.Plan) error | ||||
| 	// GetPlanByIDFunc 模拟 GetPlanByID 方法的行为 | ||||
| 	GetPlanByIDFunc func(id uint) (*models.Plan, error) | ||||
| 	// GetBasicPlanByIDFunc 模拟 GetBasicPlanByID 方法的行为 | ||||
| 	GetBasicPlanByIDFunc func(id uint) (*models.Plan, error) | ||||
| 	// ListBasicPlansFunc 模拟 ListBasicPlans 方法的行为 | ||||
| 	ListBasicPlansFunc func() ([]models.Plan, error) | ||||
| 	// UpdatePlanFunc 模拟 UpdatePlan 方法的行为 | ||||
| 	UpdatePlanFunc func(plan *models.Plan) error | ||||
| 	// DeletePlanFunc 模拟 DeletePlan 方法的行为 | ||||
| 	DeletePlanFunc func(id uint) error | ||||
| } | ||||
|  | ||||
| // ListBasicPlans 实现了 MockPlanRepository 接口的 ListBasicPlans 方法 | ||||
| func (m *MockPlanRepository) ListBasicPlans() ([]models.Plan, error) { | ||||
| 	return m.ListBasicPlansFunc() | ||||
| } | ||||
|  | ||||
| // GetBasicPlanByID 实现了 MockPlanRepository 接口的 GetBasicPlanByID 方法 | ||||
| func (m *MockPlanRepository) GetBasicPlanByID(id uint) (*models.Plan, error) { | ||||
| 	return m.GetBasicPlanByIDFunc(id) | ||||
| } | ||||
|  | ||||
| // GetPlanByID 实现了 MockPlanRepository 接口的 GetPlanByID 方法 | ||||
| func (m *MockPlanRepository) GetPlanByID(id uint) (*models.Plan, error) { | ||||
| 	return m.GetPlanByIDFunc(id) | ||||
| } | ||||
|  | ||||
| // CreatePlan 实现了 MockPlanRepository 接口的 CreatePlan 方法 | ||||
| func (m *MockPlanRepository) CreatePlan(plan *models.Plan) error { | ||||
| 	return m.CreatePlanFunc(plan) | ||||
| } | ||||
|  | ||||
| // UpdatePlan 实现了 MockPlanRepository 接口的 UpdatePlan 方法 | ||||
| func (m *MockPlanRepository) UpdatePlan(plan *models.Plan) error { | ||||
| 	return m.UpdatePlanFunc(plan) | ||||
| } | ||||
|  | ||||
| // DeletePlan 实现了 MockPlanRepository 接口的 DeletePlan 方法 | ||||
| func (m *MockPlanRepository) DeletePlan(id uint) error { | ||||
| 	return m.DeletePlanFunc(id) | ||||
| } | ||||
|  | ||||
| // setupTestRouter 创建一个用于测试的 gin 引擎和控制器实例 | ||||
| func setupTestRouter(repo repository.PlanRepository) *gin.Engine { | ||||
| 	gin.SetMode(gin.TestMode) | ||||
| 	router := gin.Default() | ||||
| 	logger := logs.NewSilentLogger() | ||||
| 	planController := NewController(logger, repo) | ||||
| 	router.POST("/plans", planController.CreatePlan) | ||||
| 	router.GET("/plans/:id", planController.GetPlan) | ||||
| 	router.GET("/plans", planController.ListPlans) | ||||
| 	router.PUT("/plans/:id", planController.UpdatePlan) | ||||
| 	router.DELETE("/plans/:id", planController.DeletePlan) | ||||
| 	return router | ||||
| } | ||||
|  | ||||
| // TestController_CreatePlan 测试 CreatePlan 方法 | ||||
| func TestController_CreatePlan(t *testing.T) { | ||||
| 	t.Run("成功-创建包含任务的计划", func(t *testing.T) { | ||||
| 		// Arrange (准备阶段) | ||||
| 		// 模拟仓库行为:CreatePlan 成功时,为计划和任务分配ID | ||||
| 		mockRepo := &MockPlanRepository{ | ||||
| 			CreatePlanFunc: func(plan *models.Plan) error { | ||||
| 				plan.ID = 1 | ||||
| 				for i := range plan.Tasks { | ||||
| 					plan.Tasks[i].ID = uint(i + 1) | ||||
| 					plan.Tasks[i].PlanID = plan.ID | ||||
| 				} | ||||
| 				return nil | ||||
| 			}, | ||||
| 		} | ||||
| 		// 设置 Gin 路由器,并注入模拟仓库 | ||||
| 		router := setupTestRouter(mockRepo) | ||||
|  | ||||
| 		// 准备请求体 | ||||
| 		reqBody := CreatePlanRequest{ | ||||
| 			Name:          "Test Plan with Tasks", | ||||
| 			ExecutionType: models.PlanExecutionTypeManual, | ||||
| 			ContentType:   models.PlanContentTypeTasks, | ||||
| 			Tasks: []TaskRequest{ | ||||
| 				{Name: "Task 1", ExecutionOrder: 1, Type: models.TaskTypeWaiting}, | ||||
| 			}, | ||||
| 		} | ||||
| 		bodyBytes, _ := json.Marshal(reqBody) | ||||
|  | ||||
| 		// 创建 HTTP 请求 | ||||
| 		req, _ := http.NewRequest(http.MethodPost, "/plans", bytes.NewBuffer(bodyBytes)) | ||||
| 		req.Header.Set("Content-Type", "application/json") | ||||
| 		w := httptest.NewRecorder() | ||||
|  | ||||
| 		// Act (执行阶段) | ||||
| 		// 发送 HTTP 请求到路由器 | ||||
| 		router.ServeHTTP(w, req) | ||||
|  | ||||
| 		// Assert (断言阶段) | ||||
| 		// 验证 HTTP 状态码 | ||||
| 		assert.Equal(t, http.StatusOK, w.Code) | ||||
|  | ||||
| 		// 解析响应体 | ||||
| 		var resp controller.Response | ||||
| 		err := json.Unmarshal(w.Body.Bytes(), &resp) | ||||
| 		assert.NoError(t, err) | ||||
|  | ||||
| 		// 验证业务响应码和消息 | ||||
| 		assert.Equal(t, controller.CodeCreated, resp.Code) | ||||
| 		assert.Equal(t, "计划创建成功", resp.Message) | ||||
|  | ||||
| 		// 验证返回数据中的计划ID | ||||
| 		dataMap, ok := resp.Data.(map[string]interface{}) | ||||
| 		assert.True(t, ok) | ||||
| 		assert.Equal(t, float64(1), dataMap["id"]) | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| // TestController_GetPlan 是为 GetPlan 方法新增的单元测试函数 | ||||
| func TestController_GetPlan(t *testing.T) { | ||||
| 	t.Run("成功-获取计划详情", func(t *testing.T) { | ||||
| 		// Arrange (准备阶段) | ||||
| 		// 模拟仓库行为:GetPlanByID 成功时返回一个计划 | ||||
| 		mockRepo := &MockPlanRepository{ | ||||
| 			GetPlanByIDFunc: func(id uint) (*models.Plan, error) { | ||||
| 				assert.Equal(t, uint(1), id) | ||||
| 				return &models.Plan{ | ||||
| 					Model:       gorm.Model{ID: 1}, | ||||
| 					Name:        "Test Plan", | ||||
| 					ContentType: models.PlanContentTypeTasks, | ||||
| 				}, nil | ||||
| 			}, | ||||
| 		} | ||||
| 		// 设置 Gin 路由器 | ||||
| 		router := setupTestRouter(mockRepo) | ||||
| 		w := httptest.NewRecorder() | ||||
| 		// 创建 HTTP 请求 | ||||
| 		req, _ := http.NewRequest(http.MethodGet, "/plans/1", nil) | ||||
|  | ||||
| 		// Act (执行阶段) | ||||
| 		router.ServeHTTP(w, req) | ||||
|  | ||||
| 		// Assert (断言阶段) | ||||
| 		assert.Equal(t, http.StatusOK, w.Code) | ||||
|  | ||||
| 		var resp controller.Response | ||||
| 		err := json.Unmarshal(w.Body.Bytes(), &resp) | ||||
| 		assert.NoError(t, err) | ||||
|  | ||||
| 		assert.Equal(t, controller.CodeSuccess, resp.Code) | ||||
| 		dataMap, ok := resp.Data.(map[string]interface{}) | ||||
| 		assert.True(t, ok) | ||||
| 		assert.Equal(t, float64(1), dataMap["id"]) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("成功-获取内容为空的计划详情", func(t *testing.T) { | ||||
| 		// Arrange (准备阶段) | ||||
| 		// 模拟仓库行为:GetPlanByID 成功时返回一个任务列表为空的计划 | ||||
| 		mockRepo := &MockPlanRepository{ | ||||
| 			GetPlanByIDFunc: func(id uint) (*models.Plan, error) { | ||||
| 				assert.Equal(t, uint(3), id) | ||||
| 				return &models.Plan{ | ||||
| 					Model:       gorm.Model{ID: 3}, | ||||
| 					Name:        "Empty Plan", | ||||
| 					ContentType: models.PlanContentTypeTasks, | ||||
| 					Tasks:       []models.Task{}, // 任务列表为空 | ||||
| 				}, nil | ||||
| 			}, | ||||
| 		} | ||||
| 		router := setupTestRouter(mockRepo) | ||||
| 		w := httptest.NewRecorder() | ||||
| 		req, _ := http.NewRequest(http.MethodGet, "/plans/3", nil) | ||||
|  | ||||
| 		// Act (执行阶段) | ||||
| 		router.ServeHTTP(w, req) | ||||
|  | ||||
| 		// Assert (断言阶段) | ||||
| 		assert.Equal(t, http.StatusOK, w.Code) | ||||
|  | ||||
| 		var resp controller.Response | ||||
| 		err := json.Unmarshal(w.Body.Bytes(), &resp) | ||||
| 		assert.NoError(t, err) | ||||
|  | ||||
| 		assert.Equal(t, controller.CodeSuccess, resp.Code) | ||||
|  | ||||
| 		dataMap, ok := resp.Data.(map[string]interface{}) | ||||
| 		assert.True(t, ok) | ||||
| 		assert.Equal(t, float64(3), dataMap["id"]) | ||||
| 		assert.Equal(t, "Empty Plan", dataMap["name"]) | ||||
|  | ||||
| 		// 关键断言:因为 omitempty 标签,当 tasks 列表为空时,该字段不应该出现在JSON中 | ||||
| 		_, ok = dataMap["tasks"] | ||||
| 		assert.False(t, ok, "当任务列表为空时,'tasks' 字段因为 omitempty 标签,不应该出现在JSON响应中") | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("失败-计划不存在", func(t *testing.T) { | ||||
| 		// Arrange (准备阶段) | ||||
| 		// 模拟仓库行为:GetPlanByID 返回记录未找到错误 | ||||
| 		mockRepo := &MockPlanRepository{ | ||||
| 			GetPlanByIDFunc: func(id uint) (*models.Plan, error) { | ||||
| 				return nil, gorm.ErrRecordNotFound | ||||
| 			}, | ||||
| 		} | ||||
| 		router := setupTestRouter(mockRepo) | ||||
| 		w := httptest.NewRecorder() | ||||
| 		req, _ := http.NewRequest(http.MethodGet, "/plans/999", nil) | ||||
|  | ||||
| 		// Act (执行阶段) | ||||
| 		router.ServeHTTP(w, req) | ||||
|  | ||||
| 		// Assert (断言阶段) | ||||
| 		assert.Equal(t, http.StatusOK, w.Code) | ||||
|  | ||||
| 		var resp controller.Response | ||||
| 		err := json.Unmarshal(w.Body.Bytes(), &resp) | ||||
| 		assert.NoError(t, err) | ||||
|  | ||||
| 		assert.Equal(t, controller.CodeNotFound, resp.Code) | ||||
| 		assert.Equal(t, "计划不存在", resp.Message) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("失败-无效的ID格式", func(t *testing.T) { | ||||
| 		// Arrange (准备阶段) | ||||
| 		// 模拟仓库为空,因为预期不会调用仓库方法 | ||||
| 		mockRepo := &MockPlanRepository{} | ||||
| 		router := setupTestRouter(mockRepo) | ||||
| 		w := httptest.NewRecorder() | ||||
| 		// 创建带有无效ID格式的 HTTP 请求 | ||||
| 		req, _ := http.NewRequest(http.MethodGet, "/plans/abc", nil) | ||||
|  | ||||
| 		// Act (执行阶段) | ||||
| 		router.ServeHTTP(w, req) | ||||
|  | ||||
| 		// Assert (断言阶段) | ||||
| 		assert.Equal(t, http.StatusOK, w.Code) | ||||
|  | ||||
| 		var resp controller.Response | ||||
| 		err := json.Unmarshal(w.Body.Bytes(), &resp) | ||||
| 		assert.NoError(t, err) | ||||
|  | ||||
| 		assert.Equal(t, controller.CodeBadRequest, resp.Code) | ||||
| 		assert.Equal(t, "无效的计划ID格式", resp.Message) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("失败-仓库层内部错误", func(t *testing.T) { | ||||
| 		// Arrange (准备阶段) | ||||
| 		internalErr := errors.New("database connection lost") | ||||
| 		// 模拟仓库行为:GetPlanByID 返回内部错误 | ||||
| 		mockRepo := &MockPlanRepository{ | ||||
| 			GetPlanByIDFunc: func(id uint) (*models.Plan, error) { | ||||
| 				return nil, internalErr | ||||
| 			}, | ||||
| 		} | ||||
| 		router := setupTestRouter(mockRepo) | ||||
| 		w := httptest.NewRecorder() | ||||
| 		req, _ := http.NewRequest(http.MethodGet, "/plans/1", nil) | ||||
|  | ||||
| 		// Act (执行阶段) | ||||
| 		router.ServeHTTP(w, req) | ||||
|  | ||||
| 		// Assert (断言阶段) | ||||
| 		assert.Equal(t, http.StatusOK, w.Code) | ||||
|  | ||||
| 		var resp controller.Response | ||||
| 		err := json.Unmarshal(w.Body.Bytes(), &resp) | ||||
| 		assert.NoError(t, err) | ||||
|  | ||||
| 		assert.Equal(t, controller.CodeInternalError, resp.Code) | ||||
| 		assert.Equal(t, "获取计划详情时发生内部错误", resp.Message) | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| // TestController_ListPlans 测试 ListPlans 方法 | ||||
| func TestController_ListPlans(t *testing.T) { | ||||
| 	t.Run("成功-获取计划列表", func(t *testing.T) { | ||||
| 		// Arrange (准备阶段) | ||||
| 		// 模拟返回的计划列表 | ||||
| 		mockPlans := []models.Plan{ | ||||
| 			{Model: gorm.Model{ID: 1}, Name: "Plan 1", ContentType: models.PlanContentTypeTasks}, | ||||
| 			{Model: gorm.Model{ID: 2}, Name: "Plan 2", ContentType: models.PlanContentTypeTasks}, | ||||
| 		} | ||||
| 		// 模拟仓库行为:ListBasicPlans 成功时返回计划列表 | ||||
| 		mockRepo := &MockPlanRepository{ | ||||
| 			ListBasicPlansFunc: func() ([]models.Plan, error) { | ||||
| 				return mockPlans, nil | ||||
| 			}, | ||||
| 		} | ||||
| 		router := setupTestRouter(mockRepo) | ||||
| 		w := httptest.NewRecorder() | ||||
| 		req, _ := http.NewRequest(http.MethodGet, "/plans", nil) | ||||
|  | ||||
| 		// Act (执行阶段) | ||||
| 		router.ServeHTTP(w, req) | ||||
|  | ||||
| 		// Assert (断言阶段) | ||||
| 		assert.Equal(t, http.StatusOK, w.Code) | ||||
|  | ||||
| 		var resp controller.Response | ||||
| 		err := json.Unmarshal(w.Body.Bytes(), &resp) | ||||
| 		assert.NoError(t, err) | ||||
|  | ||||
| 		assert.Equal(t, controller.CodeSuccess, resp.Code) | ||||
| 		assert.Equal(t, "获取计划列表成功", resp.Message) | ||||
|  | ||||
| 		dataBytes, err := json.Marshal(resp.Data) | ||||
| 		assert.NoError(t, err) | ||||
| 		var listResp ListPlansResponse | ||||
| 		err = json.Unmarshal(dataBytes, &listResp) | ||||
| 		assert.NoError(t, err) | ||||
|  | ||||
| 		assert.Equal(t, 2, listResp.Total) | ||||
| 		assert.Len(t, listResp.Plans, 2) | ||||
| 		assert.Equal(t, uint(1), listResp.Plans[0].ID) | ||||
| 		assert.Equal(t, "Plan 1", listResp.Plans[0].Name) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("成功-返回空列表", func(t *testing.T) { | ||||
| 		// Arrange (准备阶段) | ||||
| 		// 模拟仓库行为:ListBasicPlans 返回空列表 | ||||
| 		mockRepo := &MockPlanRepository{ | ||||
| 			ListBasicPlansFunc: func() ([]models.Plan, error) { | ||||
| 				return []models.Plan{}, nil | ||||
| 			}, | ||||
| 		} | ||||
| 		router := setupTestRouter(mockRepo) | ||||
| 		w := httptest.NewRecorder() | ||||
| 		req, _ := http.NewRequest(http.MethodGet, "/plans", nil) | ||||
|  | ||||
| 		// Act (执行阶段) | ||||
| 		router.ServeHTTP(w, req) | ||||
|  | ||||
| 		// Assert (断言阶段) | ||||
| 		assert.Equal(t, http.StatusOK, w.Code) | ||||
|  | ||||
| 		var resp controller.Response | ||||
| 		err := json.Unmarshal(w.Body.Bytes(), &resp) | ||||
| 		assert.NoError(t, err) | ||||
|  | ||||
| 		assert.Equal(t, controller.CodeSuccess, resp.Code) | ||||
|  | ||||
| 		dataBytes, err := json.Marshal(resp.Data) | ||||
| 		assert.NoError(t, err) | ||||
| 		var listResp ListPlansResponse | ||||
| 		err = json.Unmarshal(dataBytes, &listResp) | ||||
| 		assert.NoError(t, err) | ||||
|  | ||||
| 		assert.Equal(t, 0, listResp.Total) | ||||
| 		assert.Len(t, listResp.Plans, 0) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("失败-仓库层返回错误", func(t *testing.T) { | ||||
| 		// Arrange (准备阶段) | ||||
| 		dbErr := errors.New("db error") | ||||
| 		// 模拟仓库行为:ListBasicPlans 返回数据库错误 | ||||
| 		mockRepo := &MockPlanRepository{ | ||||
| 			ListBasicPlansFunc: func() ([]models.Plan, error) { | ||||
| 				return nil, dbErr | ||||
| 			}, | ||||
| 		} | ||||
| 		router := setupTestRouter(mockRepo) | ||||
| 		w := httptest.NewRecorder() | ||||
| 		req, _ := http.NewRequest(http.MethodGet, "/plans", nil) | ||||
|  | ||||
| 		// Act (执行阶段) | ||||
| 		router.ServeHTTP(w, req) | ||||
|  | ||||
| 		// Assert (断言阶段) | ||||
| 		assert.Equal(t, http.StatusOK, w.Code) | ||||
|  | ||||
| 		var resp controller.Response | ||||
| 		err := json.Unmarshal(w.Body.Bytes(), &resp) | ||||
| 		assert.NoError(t, err) | ||||
|  | ||||
| 		assert.Equal(t, controller.CodeInternalError, resp.Code) | ||||
| 		assert.Equal(t, "获取计划列表时发生内部错误", resp.Message) | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| // TestController_UpdatePlan 是 UpdatePlan 的测试函数 | ||||
| func TestController_UpdatePlan(t *testing.T) { | ||||
| 	t.Run("成功-更新计划", func(t *testing.T) { | ||||
| 		// Arrange (准备阶段) | ||||
| 		planID := uint(1) | ||||
| 		updatedName := "Updated Plan Name" | ||||
| 		// 模拟一个已存在的计划 | ||||
| 		mockPlan := &models.Plan{ | ||||
| 			Model:       gorm.Model{ID: planID}, | ||||
| 			Name:        "Original Plan", | ||||
| 			Description: "Original Description", | ||||
| 			ContentType: models.PlanContentTypeTasks, | ||||
| 		} | ||||
| 		// 配置模拟仓库的行为 | ||||
| 		mockRepo := &MockPlanRepository{ | ||||
| 			// 模拟 GetBasicPlanByID 成功返回现有计划 | ||||
| 			GetBasicPlanByIDFunc: func(id uint) (*models.Plan, error) { | ||||
| 				assert.Equal(t, planID, id) | ||||
| 				return mockPlan, nil | ||||
| 			}, | ||||
| 			// 模拟 UpdatePlan 成功更新计划,并更新 mockPlan 的名称 | ||||
| 			UpdatePlanFunc: func(plan *models.Plan) error { | ||||
| 				assert.Equal(t, planID, plan.ID) | ||||
| 				assert.Equal(t, updatedName, plan.Name) | ||||
| 				mockPlan.Name = plan.Name // 模拟更新操作 | ||||
| 				return nil | ||||
| 			}, | ||||
| 			// 模拟 GetPlanByID 返回更新后的计划 | ||||
| 			GetPlanByIDFunc: func(id uint) (*models.Plan, error) { | ||||
| 				assert.Equal(t, planID, id) | ||||
| 				return mockPlan, nil // 返回已更新的 mockPlan | ||||
| 			}, | ||||
| 		} | ||||
| 		// 设置 Gin 路由器,并注入模拟仓库 | ||||
| 		router := setupTestRouter(mockRepo) | ||||
|  | ||||
| 		// 准备更新请求体 | ||||
| 		reqBody := UpdatePlanRequest{ | ||||
| 			Name:          updatedName, | ||||
| 			Description:   "Updated Description", | ||||
| 			ExecutionType: models.PlanExecutionTypeAutomatic, | ||||
| 			ContentType:   models.PlanContentTypeTasks, | ||||
| 		} | ||||
| 		bodyBytes, _ := json.Marshal(reqBody) | ||||
|  | ||||
| 		// 创建 HTTP PUT 请求 | ||||
| 		req, _ := http.NewRequest(http.MethodPut, "/plans/"+strconv.Itoa(int(planID)), bytes.NewBuffer(bodyBytes)) | ||||
| 		req.Header.Set("Content-Type", "application/json") | ||||
| 		w := httptest.NewRecorder() | ||||
|  | ||||
| 		// Act (执行阶段) | ||||
| 		// 发送 HTTP 请求到路由器 | ||||
| 		router.ServeHTTP(w, req) | ||||
|  | ||||
| 		// Assert (断言阶段) | ||||
| 		// 验证 HTTP 状态码 | ||||
| 		assert.Equal(t, http.StatusOK, w.Code) | ||||
|  | ||||
| 		// 解析响应体 | ||||
| 		var resp controller.Response | ||||
| 		err := json.Unmarshal(w.Body.Bytes(), &resp) | ||||
| 		assert.NoError(t, err) | ||||
|  | ||||
| 		// 验证业务响应码、消息和返回数据 | ||||
| 		assert.Equal(t, controller.CodeSuccess, resp.Code) | ||||
| 		assert.Equal(t, "计划更新成功", resp.Message) | ||||
|  | ||||
| 		dataMap, ok := resp.Data.(map[string]interface{}) | ||||
| 		assert.True(t, ok) | ||||
| 		assert.Equal(t, float64(planID), dataMap["id"]) | ||||
| 		assert.Equal(t, updatedName, dataMap["name"]) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("失败-无效的ID格式", func(t *testing.T) { | ||||
| 		// Arrange (准备阶段) | ||||
| 		// 模拟仓库为空,因为预期不会调用仓库方法 | ||||
| 		mockRepo := &MockPlanRepository{} | ||||
| 		router := setupTestRouter(mockRepo) | ||||
| 		w := httptest.NewRecorder() | ||||
| 		// 创建带有无效ID格式的 HTTP PUT 请求 | ||||
| 		req, _ := http.NewRequest(http.MethodPut, "/plans/abc", nil) | ||||
|  | ||||
| 		// Act (执行阶段) | ||||
| 		router.ServeHTTP(w, req) | ||||
|  | ||||
| 		// Assert (断言阶段) | ||||
| 		assert.Equal(t, http.StatusOK, w.Code) | ||||
|  | ||||
| 		var resp controller.Response | ||||
| 		err := json.Unmarshal(w.Body.Bytes(), &resp) | ||||
| 		assert.NoError(t, err) | ||||
|  | ||||
| 		assert.Equal(t, controller.CodeBadRequest, resp.Code) | ||||
| 		assert.Equal(t, "无效的计划ID格式", resp.Message) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("失败-请求体绑定失败", func(t *testing.T) { | ||||
| 		// Arrange (准备阶段) | ||||
| 		planID := uint(1) | ||||
| 		// 模拟仓库为空,因为预期不会调用仓库方法(请求体绑定失败发生在控制器内部) | ||||
| 		mockRepo := &MockPlanRepository{} | ||||
| 		router := setupTestRouter(mockRepo) | ||||
|  | ||||
| 		// 准备一个无效的 JSON 请求体,例如 execution_type 类型错误 | ||||
| 		reqBody := `{\"name\": \"Updated Plan Name\",}` | ||||
| 		req, _ := http.NewRequest(http.MethodPut, "/plans/"+strconv.Itoa(int(planID)), bytes.NewBufferString(reqBody)) | ||||
| 		req.Header.Set("Content-Type", "application/json") | ||||
| 		w := httptest.NewRecorder() | ||||
|  | ||||
| 		// Act (执行阶段) | ||||
| 		router.ServeHTTP(w, req) | ||||
|  | ||||
| 		// Assert (断言阶段) | ||||
| 		assert.Equal(t, http.StatusOK, w.Code) | ||||
|  | ||||
| 		var resp controller.Response | ||||
| 		err := json.Unmarshal(w.Body.Bytes(), &resp) | ||||
| 		assert.NoError(t, err) | ||||
|  | ||||
| 		assert.Equal(t, controller.CodeBadRequest, resp.Code) | ||||
| 		assert.Contains(t, resp.Message, "无效的请求体") | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("失败-计划不存在", func(t *testing.T) { | ||||
| 		// Arrange (准备阶段) | ||||
| 		planID := uint(999) | ||||
| 		// 模拟仓库行为:GetBasicPlanByID 返回记录未找到错误 | ||||
| 		mockRepo := &MockPlanRepository{ | ||||
| 			GetBasicPlanByIDFunc: func(id uint) (*models.Plan, error) { | ||||
| 				assert.Equal(t, planID, id) | ||||
| 				return nil, gorm.ErrRecordNotFound | ||||
| 			}, | ||||
| 		} | ||||
| 		router := setupTestRouter(mockRepo) | ||||
|  | ||||
| 		// 准备有效的请求体 | ||||
| 		reqBody := UpdatePlanRequest{ | ||||
| 			Name:          "Updated Plan Name", | ||||
| 			Description:   "Updated Description", | ||||
| 			ExecutionType: models.PlanExecutionTypeAutomatic, | ||||
| 			ContentType:   models.PlanContentTypeTasks, | ||||
| 		} | ||||
| 		bodyBytes, _ := json.Marshal(reqBody) | ||||
|  | ||||
| 		// 创建 HTTP PUT 请求 | ||||
| 		req, _ := http.NewRequest(http.MethodPut, "/plans/"+strconv.Itoa(int(planID)), bytes.NewBuffer(bodyBytes)) | ||||
| 		req.Header.Set("Content-Type", "application/json") | ||||
| 		w := httptest.NewRecorder() | ||||
|  | ||||
| 		// Act (执行阶段) | ||||
| 		router.ServeHTTP(w, req) | ||||
|  | ||||
| 		// Assert (断言阶段) | ||||
| 		assert.Equal(t, http.StatusOK, w.Code) | ||||
|  | ||||
| 		var resp controller.Response | ||||
| 		err := json.Unmarshal(w.Body.Bytes(), &resp) | ||||
| 		assert.NoError(t, err) | ||||
|  | ||||
| 		assert.Equal(t, controller.CodeNotFound, resp.Code) | ||||
| 		assert.Equal(t, "计划不存在", resp.Message) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("失败-计划数据校验失败", func(t *testing.T) { | ||||
| 		// Arrange (准备阶段) | ||||
| 		planID := uint(1) | ||||
| 		// 模拟一个已存在的计划 | ||||
| 		mockPlan := &models.Plan{ | ||||
| 			Model:       gorm.Model{ID: planID}, | ||||
| 			Name:        "Original Plan", | ||||
| 			Description: "Original Description", | ||||
| 			ContentType: models.PlanContentTypeTasks, | ||||
| 		} | ||||
| 		// 配置模拟仓库行为:GetBasicPlanByID 成功返回现有计划 | ||||
| 		mockRepo := &MockPlanRepository{ | ||||
| 			GetBasicPlanByIDFunc: func(id uint) (*models.Plan, error) { | ||||
| 				return mockPlan, nil | ||||
| 			}, | ||||
| 		} | ||||
| 		router := setupTestRouter(mockRepo) | ||||
|  | ||||
| 		// 准备一个会导致 PlanFromUpdateRequest 校验失败的请求体。 | ||||
| 		// 这里通过提供重复的 ExecutionOrder 来触发 ValidateExecutionOrder 错误。 | ||||
| 		reqBody := UpdatePlanRequest{ | ||||
| 			Name:          "Invalid Plan", | ||||
| 			ExecutionType: models.PlanExecutionTypeAutomatic, | ||||
| 			ContentType:   models.PlanContentTypeTasks, // 设置为任务类型 | ||||
| 			Tasks: []TaskRequest{ | ||||
| 				{Name: "Task 1", ExecutionOrder: 1, Type: models.TaskTypeWaiting}, | ||||
| 				{Name: "Task 2", ExecutionOrder: 1, Type: models.TaskTypeWaiting}, // 重复的执行顺序 | ||||
| 			}, | ||||
| 		} | ||||
| 		bodyBytes, _ := json.Marshal(reqBody) | ||||
|  | ||||
| 		// 创建 HTTP PUT 请求 | ||||
| 		req, _ := http.NewRequest(http.MethodPut, "/plans/"+strconv.Itoa(int(planID)), bytes.NewBuffer(bodyBytes)) | ||||
| 		req.Header.Set("Content-Type", "application/json") | ||||
| 		w := httptest.NewRecorder() | ||||
|  | ||||
| 		// Act (执行阶段) | ||||
| 		router.ServeHTTP(w, req) | ||||
|  | ||||
| 		// Assert (断言阶段) | ||||
| 		assert.Equal(t, http.StatusOK, w.Code) | ||||
|  | ||||
| 		var resp controller.Response | ||||
| 		err := json.Unmarshal(w.Body.Bytes(), &resp) | ||||
| 		assert.NoError(t, err) | ||||
|  | ||||
| 		assert.Equal(t, controller.CodeBadRequest, resp.Code) | ||||
| 		assert.Contains(t, resp.Message, "计划数据校验失败") | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("失败-仓库层更新失败", func(t *testing.T) { | ||||
| 		// Arrange (准备阶段) | ||||
| 		planID := uint(1) | ||||
| 		// 模拟一个已存在的计划 | ||||
| 		mockPlan := &models.Plan{ | ||||
| 			Model:       gorm.Model{ID: planID}, | ||||
| 			Name:        "Original Plan", | ||||
| 			Description: "Original Description", | ||||
| 			ContentType: models.PlanContentTypeTasks, | ||||
| 		} | ||||
| 		updateErr := errors.New("failed to update in repository") | ||||
| 		// 配置模拟仓库行为 | ||||
| 		mockRepo := &MockPlanRepository{ | ||||
| 			// 模拟 GetBasicPlanByID 成功返回现有计划 | ||||
| 			GetBasicPlanByIDFunc: func(id uint) (*models.Plan, error) { | ||||
| 				return mockPlan, nil | ||||
| 			}, | ||||
| 			// 模拟 UpdatePlan 返回更新失败错误 | ||||
| 			UpdatePlanFunc: func(plan *models.Plan) error { | ||||
| 				return updateErr // 模拟更新失败 | ||||
| 			}, | ||||
| 		} | ||||
| 		router := setupTestRouter(mockRepo) | ||||
|  | ||||
| 		// 准备有效的请求体 | ||||
| 		reqBody := UpdatePlanRequest{ | ||||
| 			Name:          "Updated Plan Name", | ||||
| 			Description:   "Updated Description", | ||||
| 			ExecutionType: models.PlanExecutionTypeAutomatic, | ||||
| 			ContentType:   models.PlanContentTypeTasks, | ||||
| 		} | ||||
| 		bodyBytes, _ := json.Marshal(reqBody) | ||||
|  | ||||
| 		// 创建 HTTP PUT 请求 | ||||
| 		req, _ := http.NewRequest(http.MethodPut, "/plans/"+strconv.Itoa(int(planID)), bytes.NewBuffer(bodyBytes)) | ||||
| 		req.Header.Set("Content-Type", "application/json") | ||||
| 		w := httptest.NewRecorder() | ||||
|  | ||||
| 		// Act (执行阶段) | ||||
| 		router.ServeHTTP(w, req) | ||||
|  | ||||
| 		// Assert (断言阶段) | ||||
| 		assert.Equal(t, http.StatusOK, w.Code) | ||||
|  | ||||
| 		var resp controller.Response | ||||
| 		err := json.Unmarshal(w.Body.Bytes(), &resp) | ||||
| 		assert.NoError(t, err) | ||||
|  | ||||
| 		assert.Equal(t, controller.CodeBadRequest, resp.Code) | ||||
| 		assert.Equal(t, "更新计划失败: "+updateErr.Error(), resp.Message) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("失败-获取更新后计划失败", func(t *testing.T) { | ||||
| 		// Arrange (准备阶段) | ||||
| 		planID := uint(1) | ||||
| 		// 模拟一个已存在的计划 | ||||
| 		mockPlan := &models.Plan{ | ||||
| 			Model:       gorm.Model{ID: planID}, | ||||
| 			Name:        "Original Plan", | ||||
| 			Description: "Original Description", | ||||
| 			ContentType: models.PlanContentTypeTasks, | ||||
| 		} | ||||
| 		getUpdatedErr := errors.New("failed to get updated plan from repository") | ||||
| 		// 配置模拟仓库行为 | ||||
| 		mockRepo := &MockPlanRepository{ | ||||
| 			// 模拟 GetBasicPlanByID 成功返回现有计划 | ||||
| 			GetBasicPlanByIDFunc: func(id uint) (*models.Plan, error) { | ||||
| 				return mockPlan, nil | ||||
| 			}, | ||||
| 			// 模拟 UpdatePlan 成功 | ||||
| 			UpdatePlanFunc: func(plan *models.Plan) error { | ||||
| 				return nil // 模拟成功更新 | ||||
| 			}, | ||||
| 			// 模拟 GetPlanByID 返回获取失败错误 | ||||
| 			GetPlanByIDFunc: func(id uint) (*models.Plan, error) { | ||||
| 				return nil, getUpdatedErr // 模拟获取更新后计划失败 | ||||
| 			}, | ||||
| 		} | ||||
| 		router := setupTestRouter(mockRepo) | ||||
|  | ||||
| 		// 准备有效的请求体 | ||||
| 		reqBody := UpdatePlanRequest{ | ||||
| 			Name:          "Updated Plan Name", | ||||
| 			Description:   "Updated Description", | ||||
| 			ExecutionType: models.PlanExecutionTypeAutomatic, | ||||
| 			ContentType:   models.PlanContentTypeTasks, | ||||
| 		} | ||||
| 		bodyBytes, _ := json.Marshal(reqBody) | ||||
|  | ||||
| 		// 创建 HTTP PUT 请求 | ||||
| 		req, _ := http.NewRequest(http.MethodPut, "/plans/"+strconv.Itoa(int(planID)), bytes.NewBuffer(bodyBytes)) | ||||
| 		req.Header.Set("Content-Type", "application/json") | ||||
| 		w := httptest.NewRecorder() | ||||
|  | ||||
| 		// Act (执行阶段) | ||||
| 		router.ServeHTTP(w, req) | ||||
|  | ||||
| 		// Assert (断言阶段) | ||||
| 		assert.Equal(t, http.StatusOK, w.Code) | ||||
|  | ||||
| 		var resp controller.Response | ||||
| 		err := json.Unmarshal(w.Body.Bytes(), &resp) | ||||
| 		assert.NoError(t, err) | ||||
|  | ||||
| 		assert.Equal(t, controller.CodeInternalError, resp.Code) | ||||
| 		assert.Equal(t, "获取更新后计划详情时发生内部错误", resp.Message) | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| // TestController_DeletePlan 是 DeletePlan 的单元测试 | ||||
| func TestController_DeletePlan(t *testing.T) { | ||||
| 	t.Run("成功-删除计划", func(t *testing.T) { | ||||
| 		// Arrange (准备阶段) | ||||
| 		// 模拟仓库行为:DeletePlan 成功 | ||||
| 		mockRepo := &MockPlanRepository{ | ||||
| 			DeletePlanFunc: func(id uint) error { | ||||
| 				assert.Equal(t, uint(1), id) | ||||
| 				return nil // 模拟成功删除 | ||||
| 			}, | ||||
| 		} | ||||
| 		router := setupTestRouter(mockRepo) | ||||
| 		w := httptest.NewRecorder() | ||||
| 		req, _ := http.NewRequest(http.MethodDelete, "/plans/1", nil) | ||||
|  | ||||
| 		// Act (执行阶段) | ||||
| 		router.ServeHTTP(w, req) | ||||
|  | ||||
| 		// Assert (断言阶段) | ||||
| 		assert.Equal(t, http.StatusOK, w.Code) | ||||
|  | ||||
| 		var resp controller.Response | ||||
| 		err := json.Unmarshal(w.Body.Bytes(), &resp) | ||||
| 		assert.NoError(t, err) | ||||
|  | ||||
| 		assert.Equal(t, controller.CodeSuccess, resp.Code) | ||||
| 		assert.Equal(t, "计划删除成功", resp.Message) | ||||
| 		assert.Nil(t, resp.Data) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("失败-计划不存在", func(t *testing.T) { | ||||
| 		// Arrange (准备阶段) | ||||
| 		// 模拟仓库行为:DeletePlan 返回记录未找到错误 | ||||
| 		mockRepo := &MockPlanRepository{ | ||||
| 			DeletePlanFunc: func(id uint) error { | ||||
| 				return gorm.ErrRecordNotFound // 模拟未找到记录 | ||||
| 			}, | ||||
| 		} | ||||
| 		router := setupTestRouter(mockRepo) | ||||
| 		w := httptest.NewRecorder() | ||||
| 		req, _ := http.NewRequest(http.MethodDelete, "/plans/999", nil) | ||||
|  | ||||
| 		// Act (执行阶段) | ||||
| 		router.ServeHTTP(w, req) | ||||
|  | ||||
| 		// Assert (断言阶段) | ||||
| 		assert.Equal(t, http.StatusOK, w.Code) | ||||
|  | ||||
| 		var resp controller.Response | ||||
| 		err := json.Unmarshal(w.Body.Bytes(), &resp) | ||||
| 		assert.NoError(t, err) | ||||
|  | ||||
| 		assert.Equal(t, controller.CodeInternalError, resp.Code) | ||||
| 		assert.Equal(t, "删除计划时发生内部错误", resp.Message) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("失败-无效的ID格式", func(t *testing.T) { | ||||
| 		// Arrange (准备阶段) | ||||
| 		// 模拟仓库为空,因为预期不会调用仓库方法 | ||||
| 		mockRepo := &MockPlanRepository{} | ||||
| 		router := setupTestRouter(mockRepo) | ||||
| 		w := httptest.NewRecorder() | ||||
| 		// 创建带有无效ID格式的 HTTP DELETE 请求 | ||||
| 		req, _ := http.NewRequest(http.MethodDelete, "/plans/abc", nil) | ||||
|  | ||||
| 		// Act (执行阶段) | ||||
| 		router.ServeHTTP(w, req) | ||||
|  | ||||
| 		// Assert (断言阶段) | ||||
| 		assert.Equal(t, http.StatusOK, w.Code) | ||||
|  | ||||
| 		var resp controller.Response | ||||
| 		err := json.Unmarshal(w.Body.Bytes(), &resp) | ||||
| 		assert.NoError(t, err) | ||||
|  | ||||
| 		assert.Equal(t, controller.CodeBadRequest, resp.Code) | ||||
| 		assert.Equal(t, "无效的计划ID格式", resp.Message) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("失败-仓库层内部错误", func(t *testing.T) { | ||||
| 		// Arrange (准备阶段) | ||||
| 		internalErr := errors.New("something went wrong") | ||||
| 		// 模拟仓库行为:DeletePlan 返回内部错误 | ||||
| 		mockRepo := &MockPlanRepository{ | ||||
|  | ||||
| 			DeletePlanFunc: func(id uint) error { | ||||
| 				return internalErr // 模拟内部错误 | ||||
| 			}, | ||||
| 		} | ||||
| 		router := setupTestRouter(mockRepo) | ||||
| 		w := httptest.NewRecorder() | ||||
| 		req, _ := http.NewRequest(http.MethodDelete, "/plans/1", nil) | ||||
|  | ||||
| 		// Act (执行阶段) | ||||
| 		router.ServeHTTP(w, req) | ||||
|  | ||||
| 		// Assert (断言阶段) | ||||
| 		assert.Equal(t, http.StatusOK, w.Code) | ||||
|  | ||||
| 		var resp controller.Response | ||||
| 		err := json.Unmarshal(w.Body.Bytes(), &resp) | ||||
| 		assert.NoError(t, err) | ||||
|  | ||||
| 		assert.Equal(t, controller.CodeInternalError, resp.Code) | ||||
| 		assert.Equal(t, "删除计划时发生内部错误", resp.Message) | ||||
| 	}) | ||||
| } | ||||
| @@ -4,7 +4,7 @@ import ( | ||||
| 	"net/http" | ||||
|  | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"github.com/labstack/echo/v4" | ||||
| ) | ||||
|  | ||||
| // --- 业务状态码 --- | ||||
| @@ -18,6 +18,7 @@ const ( | ||||
| 	// 客户端错误状态码 (4000-4999) | ||||
| 	CodeBadRequest   ResponseCode = 4000 // 请求参数错误 | ||||
| 	CodeUnauthorized ResponseCode = 4001 // 未授权 | ||||
| 	CodeForbidden    ResponseCode = 4003 // 禁止访问 | ||||
| 	CodeNotFound     ResponseCode = 4004 // 资源未找到 | ||||
| 	CodeConflict     ResponseCode = 4009 // 资源冲突 | ||||
|  | ||||
| @@ -30,14 +31,15 @@ const ( | ||||
|  | ||||
| // Response 定义统一的API响应结构体 | ||||
| type Response struct { | ||||
| 	Code    ResponseCode `json:"code"`    // 业务状态码 | ||||
| 	Message string       `json:"message"` // 提示信息 | ||||
| 	Data    interface{}  `json:"data"`    // 业务数据 | ||||
| 	Code    ResponseCode `json:"code"`           // 业务状态码 | ||||
| 	Message string       `json:"message"`        // 提示信息 | ||||
| 	Data    interface{}  `json:"data,omitempty"` // 业务数据, omitempty表示如果为空则不序列化 | ||||
| } | ||||
|  | ||||
| // SendResponse 发送统一格式的JSON响应 (基础函数,不带审计) | ||||
| func SendResponse(ctx *gin.Context, code ResponseCode, message string, data interface{}) { | ||||
| 	ctx.JSON(http.StatusOK, Response{ | ||||
| // 所有的业务API都应该使用这个函数返回,以确保HTTP状态码始终为200 OK。 | ||||
| func SendResponse(c echo.Context, code ResponseCode, message string, data interface{}) error { | ||||
| 	return c.JSON(http.StatusOK, Response{ | ||||
| 		Code:    code, | ||||
| 		Message: message, | ||||
| 		Data:    data, | ||||
| @@ -45,51 +47,63 @@ func SendResponse(ctx *gin.Context, code ResponseCode, message string, data inte | ||||
| } | ||||
|  | ||||
| // SendErrorResponse 发送统一格式的错误响应 (基础函数,不带审计) | ||||
| func SendErrorResponse(ctx *gin.Context, code ResponseCode, message string) { | ||||
| 	SendResponse(ctx, code, message, nil) | ||||
| // HTTP状态码为200 OK,通过业务码表示错误。 | ||||
| func SendErrorResponse(c echo.Context, code ResponseCode, message string) error { | ||||
| 	return SendResponse(c, code, message, nil) | ||||
| } | ||||
|  | ||||
| // SendErrorWithStatus 发送带有指定HTTP状态码的错误响应。 | ||||
| // 这个函数主要用于中间件或特殊场景(如认证失败),在这些场景下需要返回非200的HTTP状态码。 | ||||
| func SendErrorWithStatus(c echo.Context, httpStatus int, code ResponseCode, message string) error { | ||||
| 	return c.JSON(httpStatus, Response{ | ||||
| 		Code:    code, | ||||
| 		Message: message, | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| // --- 带审计功能的响应函数 --- | ||||
|  | ||||
| // setAuditDetails 是一个内部辅助函数,用于在 gin.Context 中设置业务相关的审计信息。 | ||||
| func setAuditDetails(c *gin.Context, actionType, description string, targetResource interface{}) { | ||||
| // setAuditDetails 是一个内部辅助函数,用于在 echo.Context 中统一设置所有业务相关的审计信息。 | ||||
| func setAuditDetails(c echo.Context, actionType, description string, targetResource interface{}, status models.AuditStatus, resultDetails string) { | ||||
| 	// 只有当 actionType 不为空时,才设置审计信息,这作为触发审计的标志 | ||||
| 	if actionType != "" { | ||||
| 		c.Set(models.ContextAuditActionType.String(), actionType) | ||||
| 		c.Set(models.ContextAuditDescription.String(), description) | ||||
| 		c.Set(models.ContextAuditTargetResource.String(), targetResource) | ||||
| 		c.Set(models.ContextAuditStatus.String(), status) | ||||
| 		c.Set(models.ContextAuditResultDetails.String(), resultDetails) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // SendSuccessWithAudit 发送成功的响应,并设置审计日志所需的信息。 | ||||
| // 这是控制器中用于记录成功操作并返回响应的首选函数。 | ||||
| func SendSuccessWithAudit( | ||||
| 	ctx *gin.Context, // Gin上下文,用于处理HTTP请求和响应 | ||||
| 	c echo.Context, // Echo上下文,用于处理HTTP请求和响应 | ||||
| 	code ResponseCode, // 业务状态码,表示操作结果 | ||||
| 	message string, // 提示信息,向用户展示操作结果的文本描述 | ||||
| 	data interface{}, // 业务数据,操作成功后返回的具体数据 | ||||
| 	actionType string, // 审计操作类型,例如"创建用户", "更新配置" | ||||
| 	description string, // 审计描述,对操作的详细说明 | ||||
| 	targetResource interface{}, // 审计目标资源,被操作的资源对象或其标识 | ||||
| ) { | ||||
| ) error { | ||||
| 	// 1. 设置审计信息 | ||||
| 	setAuditDetails(ctx, actionType, description, targetResource) | ||||
| 	setAuditDetails(c, actionType, description, targetResource, models.AuditStatusSuccess, "") | ||||
| 	// 2. 发送响应 | ||||
| 	SendResponse(ctx, code, message, data) | ||||
| 	return SendResponse(c, code, message, data) | ||||
| } | ||||
|  | ||||
| // SendErrorWithAudit 发送失败的响应,并设置审计日志所需的信息。 | ||||
| // 这是控制器中用于记录失败操作并返回响应的首选函数。 | ||||
| func SendErrorWithAudit( | ||||
| 	ctx *gin.Context, // Gin上下文,用于处理HTTP请求和响应 | ||||
| 	c echo.Context, // Echo上下文,用于处理HTTP请求和响应 | ||||
| 	code ResponseCode, // 业务状态码,表示操作结果 | ||||
| 	message string, // 提示信息,向用户展示操作结果的文本描述 | ||||
| 	actionType string, // 审计操作类型,例如"登录失败", "删除失败" | ||||
| 	description string, // 审计描述,对操作的详细说明 | ||||
| 	targetResource interface{}, // 审计目标资源,被操作的资源对象或其标识 | ||||
| ) { | ||||
| ) error { | ||||
| 	// 1. 设置审计信息 | ||||
| 	setAuditDetails(ctx, actionType, description, targetResource) | ||||
| 	setAuditDetails(c, actionType, description, targetResource, models.AuditStatusFailed, message) | ||||
| 	// 2. 发送响应 | ||||
| 	SendErrorResponse(ctx, code, message) | ||||
| 	return SendErrorResponse(c, code, message) | ||||
| } | ||||
|   | ||||
| @@ -1,44 +1,29 @@ | ||||
| package user | ||||
|  | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"strconv" | ||||
|  | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/controller" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/dto" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/service" | ||||
| 	domain_notify "git.huangwc.com/pig/pig-farm-controller/internal/domain/notify" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/domain/token" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"gorm.io/gorm" | ||||
| 	"github.com/labstack/echo/v4" | ||||
| ) | ||||
|  | ||||
| // Controller 用户控制器 | ||||
| type Controller struct { | ||||
| 	userRepo       repository.UserRepository | ||||
| 	monitorService service.MonitorService | ||||
| 	tokenService   token.TokenService | ||||
| 	notifyService  domain_notify.Service | ||||
| 	logger         *logs.Logger | ||||
| 	userService service.UserService | ||||
| 	logger      *logs.Logger | ||||
| } | ||||
|  | ||||
| // NewController 创建用户控制器实例 | ||||
| func NewController( | ||||
| 	userRepo repository.UserRepository, | ||||
| 	monitorService service.MonitorService, | ||||
| 	userService service.UserService, | ||||
| 	logger *logs.Logger, | ||||
| 	tokenService token.TokenService, | ||||
| 	notifyService domain_notify.Service, | ||||
| ) *Controller { | ||||
| 	return &Controller{ | ||||
| 		userRepo:       userRepo, | ||||
| 		monitorService: monitorService, | ||||
| 		tokenService:   tokenService, | ||||
| 		notifyService:  notifyService, | ||||
| 		logger:         logger, | ||||
| 		userService: userService, | ||||
| 		logger:      logger, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @@ -53,38 +38,20 @@ func NewController( | ||||
| // @Param        user body dto.CreateUserRequest true "用户信息" | ||||
| // @Success      200 {object} controller.Response{data=dto.CreateUserResponse} "业务码为201代表创建成功" | ||||
| // @Router       /api/v1/users [post] | ||||
| func (c *Controller) CreateUser(ctx *gin.Context) { | ||||
| func (c *Controller) CreateUser(ctx echo.Context) error { | ||||
| 	var req dto.CreateUserRequest | ||||
| 	if err := ctx.ShouldBindJSON(&req); err != nil { | ||||
| 	if err := ctx.Bind(&req); err != nil { | ||||
| 		c.logger.Errorf("创建用户: 参数绑定失败: %v", err) | ||||
| 		controller.SendErrorResponse(ctx, controller.CodeBadRequest, err.Error()) | ||||
| 		return | ||||
| 		return controller.SendErrorResponse(ctx, controller.CodeBadRequest, err.Error()) | ||||
| 	} | ||||
|  | ||||
| 	user := &models.User{ | ||||
| 		Username: req.Username, | ||||
| 		Password: req.Password, // 密码会在 BeforeSave 钩子中哈希 | ||||
| 	resp, err := c.userService.CreateUser(&req) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("创建用户: 服务层调用失败: %v", err) | ||||
| 		return controller.SendErrorResponse(ctx, controller.CodeInternalError, err.Error()) | ||||
| 	} | ||||
|  | ||||
| 	if err := c.userRepo.Create(user); err != nil { | ||||
| 		c.logger.Errorf("创建用户: 创建用户失败: %v", err) | ||||
|  | ||||
| 		// 尝试查询用户,以判断是否是用户名重复导致的错误 | ||||
| 		_, findErr := c.userRepo.FindByUsername(req.Username) | ||||
| 		if findErr == nil { // 如果能找到用户,说明是用户名重复 | ||||
| 			controller.SendErrorResponse(ctx, controller.CodeConflict, "用户名已存在") | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		// 其他创建失败的情况 | ||||
| 		controller.SendErrorResponse(ctx, controller.CodeInternalError, "创建用户失败") | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	controller.SendResponse(ctx, controller.CodeCreated, "用户创建成功", dto.CreateUserResponse{ | ||||
| 		Username: user.Username, | ||||
| 		ID:       user.ID, | ||||
| 	}) | ||||
| 	return controller.SendResponse(ctx, controller.CodeCreated, "用户创建成功", resp) | ||||
| } | ||||
|  | ||||
| // Login godoc | ||||
| @@ -96,110 +63,20 @@ func (c *Controller) CreateUser(ctx *gin.Context) { | ||||
| // @Param        credentials body dto.LoginRequest true "登录凭证" | ||||
| // @Success      200 {object} controller.Response{data=dto.LoginResponse} "业务码为200代表登录成功" | ||||
| // @Router       /api/v1/users/login [post] | ||||
| func (c *Controller) Login(ctx *gin.Context) { | ||||
| func (c *Controller) Login(ctx echo.Context) error { | ||||
| 	var req dto.LoginRequest | ||||
| 	if err := ctx.ShouldBindJSON(&req); err != nil { | ||||
| 	if err := ctx.Bind(&req); err != nil { | ||||
| 		c.logger.Errorf("登录: 参数绑定失败: %v", err) | ||||
| 		controller.SendErrorResponse(ctx, controller.CodeBadRequest, err.Error()) | ||||
| 		return | ||||
| 		return controller.SendErrorResponse(ctx, controller.CodeBadRequest, err.Error()) | ||||
| 	} | ||||
|  | ||||
| 	// 使用新的方法,通过唯一标识符(用户名、邮箱等)查找用户 | ||||
| 	user, err := c.userRepo.FindUserForLogin(req.Identifier) | ||||
| 	resp, err := c.userService.Login(&req) | ||||
| 	if err != nil { | ||||
| 		if err == gorm.ErrRecordNotFound { | ||||
| 			controller.SendErrorResponse(ctx, controller.CodeUnauthorized, "登录凭证不正确") | ||||
| 			return | ||||
| 		} | ||||
| 		c.logger.Errorf("登录: 查询用户失败: %v", err) | ||||
| 		controller.SendErrorResponse(ctx, controller.CodeInternalError, "登录失败") | ||||
| 		return | ||||
| 		c.logger.Errorf("登录: 服务层调用失败: %v", err) | ||||
| 		return controller.SendErrorResponse(ctx, controller.CodeUnauthorized, err.Error()) | ||||
| 	} | ||||
|  | ||||
| 	if !user.CheckPassword(req.Password) { | ||||
| 		controller.SendErrorResponse(ctx, controller.CodeUnauthorized, "登录凭证不正确") | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	// 登录成功,生成 JWT token | ||||
| 	tokenString, err := c.tokenService.GenerateToken(user.ID) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("登录: 生成令牌失败: %v", err) | ||||
| 		controller.SendErrorResponse(ctx, controller.CodeInternalError, "登录失败,无法生成认证信息") | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	controller.SendResponse(ctx, controller.CodeSuccess, "登录成功", dto.LoginResponse{ | ||||
| 		Username: user.Username, | ||||
| 		ID:       user.ID, | ||||
| 		Token:    tokenString, | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| // ListUserHistory godoc | ||||
| // @Summary      获取指定用户的操作历史 | ||||
| // @Description  根据用户ID,分页获取该用户的操作审计日志。支持与通用日志查询接口相同的过滤和排序参数。 | ||||
| // @Tags         用户管理 | ||||
| // @Security     BearerAuth | ||||
| // @Produce      json | ||||
| // @Param        id    path      int  true  "用户ID" | ||||
| // @Param        query query     dto.ListUserActionLogRequest false "查询参数 (除了 user_id,它被路径中的ID覆盖)" | ||||
| // @Success      200   {object}  controller.Response{data=dto.ListUserActionLogResponse} "业务码为200代表成功获取" | ||||
| // @Router       /api/v1/users/{id}/history [get] | ||||
| func (c *Controller) ListUserHistory(ctx *gin.Context) { | ||||
| 	const actionType = "获取用户操作历史" | ||||
|  | ||||
| 	// 1. 解析路径中的用户ID,它的优先级最高 | ||||
| 	userIDStr := ctx.Param("id") | ||||
| 	userID, err := strconv.ParseUint(userIDStr, 10, 64) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 无效的用户ID格式: %v, ID: %s", actionType, err, userIDStr) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的用户ID格式", actionType, "无效的用户ID格式", userIDStr) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	// 2. 绑定通用的查询请求 DTO | ||||
| 	var req dto.ListUserActionLogRequest | ||||
| 	if err := ctx.ShouldBindQuery(&req); err != nil { | ||||
| 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	// 3. 准备 Service 调用参数,并强制使用路径中的 UserID | ||||
| 	uid := uint(userID) | ||||
| 	req.UserID = &uid // 强制覆盖 | ||||
|  | ||||
| 	opts := repository.UserActionLogListOptions{ | ||||
| 		UserID:     req.UserID, | ||||
| 		Username:   req.Username, | ||||
| 		ActionType: req.ActionType, | ||||
| 		OrderBy:    req.OrderBy, | ||||
| 		StartTime:  req.StartTime, | ||||
| 		EndTime:    req.EndTime, | ||||
| 	} | ||||
| 	if req.Status != nil { | ||||
| 		status := models.AuditStatus(*req.Status) | ||||
| 		opts.Status = &status | ||||
| 	} | ||||
|  | ||||
| 	// 4. 调用 monitorService,复用其业务逻辑 | ||||
| 	data, total, err := c.monitorService.ListUserActionLogs(opts, req.Page, req.PageSize) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, repository.ErrInvalidPagination) { | ||||
| 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | ||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", opts) | ||||
| 			return | ||||
| 		} | ||||
| 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取用户历史记录失败", actionType, "服务层查询失败", opts) | ||||
| 		return | ||||
| 	} | ||||
|  | ||||
| 	// 5. 使用复用的 DTO 构建并发送成功响应 | ||||
| 	resp := dto.NewListUserActionLogResponse(data, total, req.Page, req.PageSize) | ||||
| 	c.logger.Infof("%s: 成功获取用户 %d 的操作历史, 数量: %d", actionType, userID, len(data)) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取用户操作历史成功", resp, actionType, "获取用户操作历史成功", opts) | ||||
| 	return controller.SendResponse(ctx, controller.CodeSuccess, "登录成功", resp) | ||||
| } | ||||
|  | ||||
| // SendTestNotification godoc | ||||
| @@ -213,34 +90,31 @@ func (c *Controller) ListUserHistory(ctx *gin.Context) { | ||||
| // @Param        body body      dto.SendTestNotificationRequest  true  "请求体" | ||||
| // @Success      200  {object}  controller.Response{data=string}  "成功响应" | ||||
| // @Router       /api/v1/users/{id}/notifications/test [post] | ||||
| func (c *Controller) SendTestNotification(ctx *gin.Context) { | ||||
| func (c *Controller) SendTestNotification(ctx echo.Context) error { | ||||
| 	const actionType = "发送测试通知" | ||||
|  | ||||
| 	// 1. 从 URL 中获取用户 ID | ||||
| 	userID, err := strconv.ParseUint(ctx.Param("id"), 10, 32) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 无效的用户ID格式: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的用户ID格式", actionType, "无效的用户ID格式", ctx.Param("id")) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的用户ID格式", actionType, "无效的用户ID格式", ctx.Param("id")) | ||||
| 	} | ||||
|  | ||||
| 	// 2. 从请求体 (JSON Body) 中获取要测试的通知类型 | ||||
| 	var req dto.SendTestNotificationRequest | ||||
| 	if err := ctx.ShouldBindJSON(&req); err != nil { | ||||
| 	if err := ctx.Bind(&req); err != nil { | ||||
| 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "请求体格式错误或缺少 'type' 字段: "+err.Error(), actionType, "请求体绑定失败", req) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "请求体格式错误或缺少 'type' 字段: "+err.Error(), actionType, "请求体绑定失败", req) | ||||
| 	} | ||||
|  | ||||
| 	// 3. 调用领域服务 | ||||
| 	err = c.notifyService.SendTestMessage(uint(userID), req.Type) | ||||
| 	// 3. 调用服务层 | ||||
| 	err = c.userService.SendTestNotification(uint(userID), &req) | ||||
| 	if err != nil { | ||||
| 		c.logger.Errorf("%s: 服务层调用失败: %v", actionType, err) | ||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "发送测试消息失败: "+err.Error(), actionType, "服务层调用失败", gin.H{"userID": userID, "type": req.Type}) | ||||
| 		return | ||||
| 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "发送测试消息失败: "+err.Error(), actionType, "服务层调用失败", map[string]interface{}{"userID": userID, "type": req.Type}) | ||||
| 	} | ||||
|  | ||||
| 	// 4. 返回成功响应 | ||||
| 	c.logger.Infof("%s: 成功为用户 %d 发送类型为 %s 的测试消息", actionType, userID, req.Type) | ||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "测试消息已发送,请检查您的接收端。", nil, actionType, "测试消息发送成功", gin.H{"userID": userID, "type": req.Type}) | ||||
| 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "测试消息已发送,请检查您的接收端。", nil, actionType, "测试消息发送成功", map[string]interface{}{"userID": userID, "type": req.Type}) | ||||
| } | ||||
|   | ||||
| @@ -1,450 +0,0 @@ | ||||
| package user_test | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"encoding/json" | ||||
| 	"errors" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"net/http/httptest" | ||||
| 	"testing" | ||||
|  | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/controller" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/controller/user" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/service/token" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| 	"github.com/stretchr/testify/mock" | ||||
| 	"gorm.io/gorm" | ||||
| ) | ||||
|  | ||||
| // MockUserRepository 是 UserRepository 接口的模拟实现 | ||||
| type MockUserRepository struct { | ||||
| 	mock.Mock | ||||
| } | ||||
|  | ||||
| // CreateTx 模拟 UserRepository 的 CreateTx 方法 | ||||
| func (m *MockUserRepository) Create(user *models.User) error { | ||||
| 	args := m.Called(user) | ||||
| 	return args.Error(0) | ||||
| } | ||||
|  | ||||
| // FindByUsername 模拟 UserRepository 的 FindByUsername 方法 | ||||
| // 返回类型改回 *models.User | ||||
| func (m *MockUserRepository) FindByUsername(username string) (*models.User, error) { | ||||
| 	args := m.Called(username) | ||||
| 	if args.Get(0) == nil { | ||||
| 		return nil, args.Error(1) | ||||
| 	} | ||||
| 	return args.Get(0).(*models.User), args.Error(1) | ||||
| } | ||||
|  | ||||
| // FindByID 模拟 UserRepository 的 FindByID 方法 | ||||
| func (m *MockUserRepository) FindByID(id uint) (*models.User, error) { | ||||
| 	args := m.Called(id) | ||||
| 	if args.Get(0) == nil { | ||||
| 		return nil, args.Error(1) | ||||
| 	} | ||||
| 	return args.Get(0).(*models.User), args.Error(1) | ||||
| } | ||||
|  | ||||
| // MockTokenService 是 token.TokenService 接口的模拟实现 | ||||
| type MockTokenService struct { | ||||
| 	mock.Mock | ||||
| } | ||||
|  | ||||
| // GenerateToken 模拟 TokenService 的 GenerateToken 方法 | ||||
| func (m *MockTokenService) GenerateToken(userID uint) (string, error) { | ||||
| 	args := m.Called(userID) | ||||
| 	return args.String(0), args.Error(1) | ||||
| } | ||||
|  | ||||
| // ParseToken 模拟 TokenService 的 ParseToken 方法 | ||||
| func (m *MockTokenService) ParseToken(tokenString string) (*token.Claims, error) { | ||||
| 	args := m.Called(tokenString) | ||||
| 	if args.Get(0) == nil { | ||||
| 		return nil, args.Error(1) | ||||
| 	} | ||||
| 	return args.Get(0).(*token.Claims), args.Error(1) | ||||
| } | ||||
|  | ||||
| // TestCreateUser 测试 CreateUser 方法 | ||||
| func TestCreateUser(t *testing.T) { | ||||
| 	gin.SetMode(gin.TestMode) // 设置 Gin 为测试模式 | ||||
|  | ||||
| 	// 创建一个不输出日志的真实 logs.Logger 实例 | ||||
| 	silentLogger := logs.NewSilentLogger() | ||||
|  | ||||
| 	tests := []struct { | ||||
| 		name             string | ||||
| 		requestBody      user.CreateUserRequest | ||||
| 		mockRepoSetup    func(*MockUserRepository) | ||||
| 		expectedResponse map[string]interface{} | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name: "成功创建用户", | ||||
| 			requestBody: user.CreateUserRequest{ | ||||
| 				Username: "testuser", | ||||
| 				Password: "password123", | ||||
| 			}, | ||||
| 			mockRepoSetup: func(m *MockUserRepository) { | ||||
| 				// 模拟 CreateTx 成功 | ||||
| 				m.On("CreateTx", mock.AnythingOfType("*models.User")).Return(nil).Run(func(args mock.Arguments) { | ||||
| 					// 模拟数据库自动填充 ID | ||||
| 					userArg := args.Get(0).(*models.User) | ||||
| 					userArg.ID = 1 // 设置一个非零的 ID | ||||
| 				}).Once() | ||||
| 				// 在成功创建用户的路径下,FindByUsername 不会被调用,因此这里不需要设置其期望 | ||||
| 			}, | ||||
| 			expectedResponse: map[string]interface{}{ | ||||
| 				"code":    float64(controller.CodeCreated), // 修改这里:使用自定义状态码 | ||||
| 				"message": "用户创建成功", | ||||
| 				"data": map[string]interface{}{ | ||||
| 					"username": "testuser", | ||||
| 					// "id":       mock.Anything, // 移除这里的 id,在断言时单独检查 | ||||
| 				}, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "请求参数绑定失败_密码过短", | ||||
| 			requestBody: user.CreateUserRequest{ | ||||
| 				Username: "testuser2", | ||||
| 				Password: "123", // 密码少于6位 | ||||
| 			}, | ||||
| 			mockRepoSetup: func(m *MockUserRepository) { | ||||
| 				// 不会调用 CreateTx 或 FindByUsername | ||||
| 			}, | ||||
| 			expectedResponse: map[string]interface{}{ | ||||
| 				"code":    float64(controller.CodeBadRequest), | ||||
| 				"message": "Key: 'CreateUserRequest.Password' Error:Field validation for 'Password' failed on the 'min' tag", | ||||
| 				"data":    nil, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "请求参数绑定失败_缺少用户名", | ||||
| 			requestBody: user.CreateUserRequest{ | ||||
| 				Password: "password123", | ||||
| 			}, | ||||
| 			mockRepoSetup: func(m *MockUserRepository) { | ||||
| 				// 不会调用 CreateTx 或 FindByUsername | ||||
| 			}, | ||||
| 			expectedResponse: map[string]interface{}{ | ||||
| 				"code":    float64(controller.CodeBadRequest), | ||||
| 				"message": "Key: 'CreateUserRequest.Username' Error:Field validation for 'Username' failed on the 'required' tag", | ||||
| 				"data":    nil, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "用户名已存在", | ||||
| 			requestBody: user.CreateUserRequest{ | ||||
| 				Username: "existinguser", | ||||
| 				Password: "password123", | ||||
| 			}, | ||||
| 			mockRepoSetup: func(m *MockUserRepository) { | ||||
| 				// 模拟 CreateTx 失败,因为用户名已存在 | ||||
| 				m.On("CreateTx", mock.AnythingOfType("*models.User")).Return(errors.New("duplicate entry")).Once() | ||||
| 				// 模拟 FindByUsername 找到用户,确认是用户名重复 | ||||
| 				m.On("FindByUsername", "existinguser").Return(&models.User{Username: "existinguser"}, nil).Once() | ||||
| 			}, | ||||
| 			expectedResponse: map[string]interface{}{ | ||||
| 				"code":    float64(controller.CodeConflict), | ||||
| 				"message": "用户名已存在", | ||||
| 				"data":    nil, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "创建用户失败_通用数据库错误", | ||||
| 			requestBody: user.CreateUserRequest{ | ||||
| 				Username: "db_error_user", | ||||
| 				Password: "password123", | ||||
| 			}, | ||||
| 			mockRepoSetup: func(m *MockUserRepository) { | ||||
| 				// 模拟 CreateTx 失败,通用数据库错误 | ||||
| 				m.On("CreateTx", mock.AnythingOfType("*models.User")).Return(errors.New("database error")).Once() | ||||
| 				// 模拟 FindByUsername 找不到用户,确认不是用户名重复 | ||||
| 				m.On("FindByUsername", "db_error_user").Return(nil, gorm.ErrRecordNotFound).Once() | ||||
| 			}, | ||||
| 			expectedResponse: map[string]interface{}{ | ||||
| 				"code":    float64(controller.CodeInternalError), | ||||
| 				"message": "创建用户失败", | ||||
| 				"data":    nil, | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			// 初始化 Gin 上下文和记录器 | ||||
| 			w := httptest.NewRecorder() | ||||
| 			ctx, _ := gin.CreateTestContext(w) | ||||
| 			ctx.Request = httptest.NewRequest(http.MethodPost, "/users", nil) // URL 路径不重要,因为我们不测试路由 | ||||
|  | ||||
| 			// 设置请求体 | ||||
| 			jsonBody, _ := json.Marshal(tt.requestBody) | ||||
| 			ctx.Request.Body = io.NopCloser(bytes.NewBuffer(jsonBody)) | ||||
| 			ctx.Request.Header.Set("Content-Type", "application/json") | ||||
|  | ||||
| 			// 创建 Mock UserRepository | ||||
| 			mockRepo := new(MockUserRepository) | ||||
|  | ||||
| 			// 设置 Mock UserRepository 行为 | ||||
| 			tt.mockRepoSetup(mockRepo) | ||||
|  | ||||
| 			// 创建控制器实例,使用静默日志器 | ||||
| 			userController := user.NewController(mockRepo, silentLogger, nil) // tokenService 在 CreateUser 中未使用,设为 nil | ||||
|  | ||||
| 			// 调用被测试的方法 | ||||
| 			userController.CreateUser(ctx) | ||||
|  | ||||
| 			// 解析响应体 | ||||
| 			var responseBody map[string]interface{} | ||||
| 			err := json.Unmarshal(w.Body.Bytes(), &responseBody) | ||||
| 			assert.NoError(t, err) | ||||
|  | ||||
| 			// 断言响应体中的 code 字段 | ||||
| 			assert.Equal(t, tt.expectedResponse["code"], responseBody["code"]) | ||||
|  | ||||
| 			// 断言响应内容 (除了 code 字段) | ||||
| 			if tt.expectedResponse["code"] == float64(controller.CodeCreated) { | ||||
| 				// 确保 data 字段存在且是 map[string]interface{} 类型 | ||||
| 				data, ok := responseBody["data"].(map[string]interface{}) | ||||
| 				assert.True(t, ok, "响应体中的 data 字段应为 map[string]interface{}") | ||||
| 				// 确保 id 字段存在且不为零 | ||||
| 				id, idOk := data["id"].(float64) | ||||
| 				assert.True(t, idOk, "响应体中的 data.id 字段应为 float64 类型") | ||||
| 				assert.NotEqual(t, float64(0), id, "响应体中的 data.id 不应为零") | ||||
|  | ||||
| 				// 移除 ID 字段以便进行通用断言 | ||||
| 				delete(responseBody["data"].(map[string]interface{}), "id") | ||||
| 				// 移除 expectedResponse 中的 id 字段,因为我们已经单独验证了 | ||||
| 				if expectedData, ok := tt.expectedResponse["data"].(map[string]interface{}); ok { | ||||
| 					delete(expectedData, "id") | ||||
| 				} | ||||
| 			} | ||||
| 			// 移除 code 字段以便进行通用断言 | ||||
| 			delete(responseBody, "code") | ||||
| 			delete(tt.expectedResponse, "code") | ||||
| 			assert.Equal(t, tt.expectedResponse, responseBody) | ||||
|  | ||||
| 			// 验证 Mock 期望是否都已满足 | ||||
| 			mockRepo.AssertExpectations(t) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // TestLogin 测试 Login 方法 | ||||
| func TestLogin(t *testing.T) { | ||||
| 	// 设置release模式阻止废话日志 | ||||
| 	gin.SetMode(gin.ReleaseMode) | ||||
|  | ||||
| 	// 创建一个不输出日志的真实 logs.Logger 实例 | ||||
| 	silentLogger := logs.NewSilentLogger() | ||||
|  | ||||
| 	tests := []struct { | ||||
| 		name                  string | ||||
| 		requestBody           user.LoginRequest | ||||
| 		mockRepoSetup         func(*MockUserRepository) | ||||
| 		mockTokenServiceSetup func(*MockTokenService) | ||||
| 		expectedResponse      map[string]interface{} | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name: "成功登录", | ||||
| 			requestBody: user.LoginRequest{ | ||||
| 				Username: "loginuser", | ||||
| 				Password: "correctpassword", | ||||
| 			}, | ||||
| 			mockRepoSetup: func(m *MockUserRepository) { | ||||
| 				mockUser := &models.User{ | ||||
| 					Model:    gorm.Model{ID: 1}, | ||||
| 					Username: "loginuser", | ||||
| 					Password: "correctpassword", // 明文密码,BeforeCreate 会哈希它 | ||||
| 				} | ||||
| 				// 调用 BeforeCreate 钩子来哈希密码 | ||||
| 				_ = mockUser.BeforeCreate(nil) | ||||
| 				m.On("FindByUsername", "loginuser").Return(mockUser, nil).Once() | ||||
| 			}, | ||||
| 			mockTokenServiceSetup: func(m *MockTokenService) { | ||||
| 				m.On("GenerateToken", uint(1)).Return("mocked_token", nil).Once() | ||||
| 			}, | ||||
| 			expectedResponse: map[string]interface{}{ | ||||
| 				"code":    float64(controller.CodeSuccess), | ||||
| 				"message": "登录成功", | ||||
| 				"data": map[string]interface{}{ | ||||
| 					"username": "loginuser", | ||||
| 					"id":       float64(1), | ||||
| 					"token":    "mocked_token", | ||||
| 				}, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "请求参数绑定失败_缺少用户名", | ||||
| 			requestBody: user.LoginRequest{ | ||||
| 				Username: "", // 缺少用户名 | ||||
| 				Password: "password", | ||||
| 			}, | ||||
| 			mockRepoSetup:         func(m *MockUserRepository) {}, | ||||
| 			mockTokenServiceSetup: func(m *MockTokenService) {}, | ||||
| 			expectedResponse: map[string]interface{}{ | ||||
| 				"code":    float64(controller.CodeBadRequest), | ||||
| 				"message": "Key: 'LoginRequest.Username' Error:Field validation for 'Username' failed on the 'required' tag", | ||||
| 				"data":    nil, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "请求参数绑定失败_缺少密码", | ||||
| 			requestBody: user.LoginRequest{ | ||||
| 				Username: "testuser", | ||||
| 				Password: "", // 缺少密码 | ||||
| 			}, | ||||
| 			mockRepoSetup:         func(m *MockUserRepository) {}, | ||||
| 			mockTokenServiceSetup: func(m *MockTokenService) {}, | ||||
| 			expectedResponse: map[string]interface{}{ | ||||
| 				"code":    float64(controller.CodeBadRequest), | ||||
| 				"message": "Key: 'LoginRequest.Password' Error:Field validation for 'Password' failed on the 'required' tag", | ||||
| 				"data":    nil, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "用户不存在", | ||||
| 			requestBody: user.LoginRequest{ | ||||
| 				Username: "nonexistent", | ||||
| 				Password: "anypassword", | ||||
| 			}, | ||||
| 			mockRepoSetup: func(m *MockUserRepository) { | ||||
| 				m.On("FindByUsername", "nonexistent").Return(nil, gorm.ErrRecordNotFound).Once() | ||||
| 			}, | ||||
| 			mockTokenServiceSetup: func(m *MockTokenService) {}, | ||||
| 			expectedResponse: map[string]interface{}{ | ||||
| 				"code":    float64(controller.CodeUnauthorized), | ||||
| 				"message": "用户名或密码不正确", | ||||
| 				"data":    nil, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "查询用户失败_通用数据库错误", | ||||
| 			requestBody: user.LoginRequest{ | ||||
| 				Username: "dberroruser", | ||||
| 				Password: "password", | ||||
| 			}, | ||||
| 			mockRepoSetup: func(m *MockUserRepository) { | ||||
| 				m.On("FindByUsername", "dberroruser").Return(nil, errors.New("database connection error")).Once() | ||||
| 			}, | ||||
| 			mockTokenServiceSetup: func(m *MockTokenService) {}, expectedResponse: map[string]interface{}{ | ||||
| 				"code":    float64(controller.CodeInternalError), | ||||
| 				"message": "登录失败", | ||||
| 				"data":    nil, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "密码不正确", | ||||
| 			requestBody: user.LoginRequest{ | ||||
| 				Username: "loginuser", | ||||
| 				Password: "wrongpassword", | ||||
| 			}, | ||||
| 			mockRepoSetup: func(m *MockUserRepository) { | ||||
| 				mockUser := &models.User{ | ||||
| 					Model:    gorm.Model{ID: 1}, | ||||
| 					Username: "loginuser", | ||||
| 					Password: "correctpassword", // 明文密码,BeforeCreate 会哈希它 | ||||
| 				} | ||||
| 				// 调用 BeforeCreate 钩子来哈希密码 | ||||
| 				_ = mockUser.BeforeCreate(nil) | ||||
| 				m.On("FindByUsername", "loginuser").Return(mockUser, nil).Once() | ||||
| 			}, | ||||
| 			mockTokenServiceSetup: func(m *MockTokenService) {}, | ||||
| 			expectedResponse: map[string]interface{}{ | ||||
| 				"code":    float64(controller.CodeUnauthorized), | ||||
| 				"message": "用户名或密码不正确", | ||||
| 				"data":    nil, | ||||
| 			}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "生成Token失败", | ||||
| 			requestBody: user.LoginRequest{ | ||||
| 				Username: "loginuser", | ||||
| 				Password: "correctpassword", | ||||
| 			}, | ||||
| 			mockRepoSetup: func(m *MockUserRepository) { | ||||
| 				mockUser := &models.User{ | ||||
| 					Model:    gorm.Model{ID: 1}, | ||||
| 					Username: "loginuser", | ||||
| 					Password: "correctpassword", // 明文密码,BeforeCreate 会哈希它 | ||||
| 				} | ||||
| 				// 调用 BeforeCreate 钩子来哈希密码 | ||||
| 				_ = mockUser.BeforeCreate(nil) | ||||
| 				m.On("FindByUsername", "loginuser").Return(mockUser, nil).Once() | ||||
| 			}, | ||||
| 			mockTokenServiceSetup: func(m *MockTokenService) { | ||||
| 				m.On("GenerateToken", uint(1)).Return("", errors.New("jwt error")).Once() | ||||
| 			}, | ||||
| 			expectedResponse: map[string]interface{}{ | ||||
| 				"code":    float64(controller.CodeInternalError), | ||||
| 				"message": "登录失败,无法生成认证信息", | ||||
| 				"data":    nil, | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 			// 初始化 Gin 上下文和记录器 | ||||
| 			w := httptest.NewRecorder() | ||||
| 			ctx, _ := gin.CreateTestContext(w) | ||||
| 			ctx.Request = httptest.NewRequest(http.MethodPost, "/login", nil) // URL 路径不重要,因为我们不测试路由 | ||||
|  | ||||
| 			// 设置请求体 | ||||
| 			jsonBody, _ := json.Marshal(tt.requestBody) | ||||
| 			ctx.Request.Body = io.NopCloser(bytes.NewBuffer(jsonBody)) | ||||
| 			ctx.Request.Header.Set("Content-Type", "application/json") | ||||
|  | ||||
| 			// 创建 Mock | ||||
| 			mockRepo := new(MockUserRepository) | ||||
| 			mockTokenService := new(MockTokenService) | ||||
|  | ||||
| 			// 设置 Mock 行为 | ||||
| 			tt.mockRepoSetup(mockRepo) | ||||
| 			tt.mockTokenServiceSetup(mockTokenService) | ||||
|  | ||||
| 			// 创建控制器实例 | ||||
| 			userController := user.NewController(mockRepo, silentLogger, mockTokenService) | ||||
|  | ||||
| 			// 调用被测试的方法 | ||||
| 			userController.Login(ctx) | ||||
|  | ||||
| 			// 解析响应体 | ||||
| 			var responseBody map[string]interface{} | ||||
| 			err := json.Unmarshal(w.Body.Bytes(), &responseBody) | ||||
| 			assert.NoError(t, err) | ||||
|  | ||||
| 			// 断言响应体中的 code 字段 | ||||
| 			assert.Equal(t, tt.expectedResponse["code"], responseBody["code"]) | ||||
|  | ||||
| 			// 断言响应内容 (除了 code 字段) | ||||
| 			if tt.expectedResponse["code"] == float64(controller.CodeSuccess) { | ||||
| 				// 确保 data 字段存在且是 map[string]interface{} 类型 | ||||
| 				data, ok := responseBody["data"].(map[string]interface{}) | ||||
| 				assert.True(t, ok, "响应体中的 data 字段应为 map[string]interface{}") | ||||
|  | ||||
| 				// 验证 id 和 token 存在 | ||||
| 				assert.NotNil(t, data["id"]) | ||||
| 				assert.NotNil(t, data["token"]) | ||||
|  | ||||
| 				// 移除 ID 和 Token 字段以便进行通用断言 | ||||
| 				delete(responseBody["data"].(map[string]interface{}), "id") | ||||
| 				delete(tt.expectedResponse["data"].(map[string]interface{}), "id") | ||||
| 				delete(responseBody["data"].(map[string]interface{}), "token") | ||||
| 				delete(tt.expectedResponse["data"].(map[string]interface{}), "token") | ||||
| 			} | ||||
| 			// 移除 code 字段以便进行通用断言 | ||||
| 			delete(responseBody, "code") | ||||
| 			delete(tt.expectedResponse, "code") | ||||
| 			assert.Equal(t, tt.expectedResponse, responseBody) | ||||
|  | ||||
| 			// 验证 Mock 期望是否都已满足 | ||||
| 			mockRepo.AssertExpectations(t) | ||||
| 			mockTokenService.AssertExpectations(t) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
| @@ -4,20 +4,20 @@ import "git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||
|  | ||||
| // CreateDeviceRequest 定义了创建设备时需要传入的参数 | ||||
| type CreateDeviceRequest struct { | ||||
| 	Name             string                 `json:"name" binding:"required"` | ||||
| 	DeviceTemplateID uint                   `json:"device_template_id" binding:"required"` | ||||
| 	AreaControllerID uint                   `json:"area_controller_id" binding:"required"` | ||||
| 	Location         string                 `json:"location,omitempty"` | ||||
| 	Properties       map[string]interface{} `json:"properties,omitempty"` | ||||
| 	Name             string                 `json:"name" validate:"required"` | ||||
| 	DeviceTemplateID uint                   `json:"device_template_id" validate:"required"` | ||||
| 	AreaControllerID uint                   `json:"area_controller_id" validate:"required"` | ||||
| 	Location         string                 `json:"location,omitempty" validate:"omitempty"` | ||||
| 	Properties       map[string]interface{} `json:"properties,omitempty" validate:"omitempty"` | ||||
| } | ||||
|  | ||||
| // UpdateDeviceRequest 定义了更新设备时需要传入的参数 | ||||
| type UpdateDeviceRequest struct { | ||||
| 	Name             string                 `json:"name" binding:"required"` | ||||
| 	DeviceTemplateID uint                   `json:"device_template_id" binding:"required"` | ||||
| 	AreaControllerID uint                   `json:"area_controller_id" binding:"required"` | ||||
| 	Location         string                 `json:"location,omitempty"` | ||||
| 	Properties       map[string]interface{} `json:"properties,omitempty"` | ||||
| 	Name             string                 `json:"name" validate:"required"` | ||||
| 	DeviceTemplateID uint                   `json:"device_template_id" validate:"required"` | ||||
| 	AreaControllerID uint                   `json:"area_controller_id" validate:"required"` | ||||
| 	Location         string                 `json:"location,omitempty" validate:"omitempty"` | ||||
| 	Properties       map[string]interface{} `json:"properties,omitempty" validate:"omitempty"` | ||||
| } | ||||
|  | ||||
| // ManualControlDeviceRequest 定义了手动控制设备时需要传入的参数 | ||||
| @@ -28,38 +28,38 @@ type ManualControlDeviceRequest struct { | ||||
|  | ||||
| // CreateAreaControllerRequest 定义了创建区域主控时需要传入的参数 | ||||
| type CreateAreaControllerRequest struct { | ||||
| 	Name       string                 `json:"name" binding:"required"` | ||||
| 	NetworkID  string                 `json:"network_id" binding:"required"` | ||||
| 	Location   string                 `json:"location,omitempty"` | ||||
| 	Properties map[string]interface{} `json:"properties,omitempty"` | ||||
| 	Name       string                 `json:"name" validate:"required"` | ||||
| 	NetworkID  string                 `json:"network_id" validate:"required"` | ||||
| 	Location   string                 `json:"location,omitempty" validate:"omitempty"` | ||||
| 	Properties map[string]interface{} `json:"properties,omitempty" validate:"omitempty"` | ||||
| } | ||||
|  | ||||
| // UpdateAreaControllerRequest 定义了更新区域主控时需要传入的参数 | ||||
| type UpdateAreaControllerRequest struct { | ||||
| 	Name       string                 `json:"name" binding:"required"` | ||||
| 	NetworkID  string                 `json:"network_id" binding:"required"` | ||||
| 	Location   string                 `json:"location,omitempty"` | ||||
| 	Properties map[string]interface{} `json:"properties,omitempty"` | ||||
| 	Name       string                 `json:"name" validate:"required"` | ||||
| 	NetworkID  string                 `json:"network_id" validate:"required"` | ||||
| 	Location   string                 `json:"location,omitempty" validate:"omitempty"` | ||||
| 	Properties map[string]interface{} `json:"properties,omitempty" validate:"omitempty"` | ||||
| } | ||||
|  | ||||
| // CreateDeviceTemplateRequest 定义了创建设备模板时需要传入的参数 | ||||
| type CreateDeviceTemplateRequest struct { | ||||
| 	Name         string                   `json:"name" binding:"required"` | ||||
| 	Manufacturer string                   `json:"manufacturer,omitempty"` | ||||
| 	Description  string                   `json:"description,omitempty"` | ||||
| 	Category     models.DeviceCategory    `json:"category" binding:"required"` | ||||
| 	Commands     map[string]interface{}   `json:"commands" binding:"required"` | ||||
| 	Values       []models.ValueDescriptor `json:"values,omitempty"` | ||||
| 	Name         string                   `json:"name" validate:"required"` | ||||
| 	Manufacturer string                   `json:"manufacturer,omitempty" validate:"omitempty"` | ||||
| 	Description  string                   `json:"description,omitempty" validate:"omitempty"` | ||||
| 	Category     models.DeviceCategory    `json:"category" validate:"required"` | ||||
| 	Commands     map[string]interface{}   `json:"commands" validate:"required"` | ||||
| 	Values       []models.ValueDescriptor `json:"values,omitempty" validate:"omitempty,dive"` | ||||
| } | ||||
|  | ||||
| // UpdateDeviceTemplateRequest 定义了更新设备模板时需要传入的参数 | ||||
| type UpdateDeviceTemplateRequest struct { | ||||
| 	Name         string                   `json:"name" binding:"required"` | ||||
| 	Manufacturer string                   `json:"manufacturer,omitempty"` | ||||
| 	Description  string                   `json:"description,omitempty"` | ||||
| 	Category     models.DeviceCategory    `json:"category" binding:"required"` | ||||
| 	Commands     map[string]interface{}   `json:"commands" binding:"required"` | ||||
| 	Values       []models.ValueDescriptor `json:"values,omitempty"` | ||||
| 	Name         string                   `json:"name" validate:"required"` | ||||
| 	Manufacturer string                   `json:"manufacturer,omitempty" validate:"omitempty"` | ||||
| 	Description  string                   `json:"description,omitempty" validate:"omitempty"` | ||||
| 	Category     models.DeviceCategory    `json:"category" validate:"required"` | ||||
| 	Commands     map[string]interface{}   `json:"commands" validate:"required"` | ||||
| 	Values       []models.ValueDescriptor `json:"values,omitempty" validate:"omitempty,dive"` | ||||
| } | ||||
|  | ||||
| // DeviceResponse 定义了返回给客户端的单个设备信息的结构 | ||||
|   | ||||
| @@ -53,14 +53,20 @@ func NewListDeviceCommandLogResponse(data []models.DeviceCommandLog, total int64 | ||||
| } | ||||
|  | ||||
| // NewListPlanExecutionLogResponse 从模型数据创建列表响应 DTO | ||||
| func NewListPlanExecutionLogResponse(data []models.PlanExecutionLog, total int64, page, pageSize int) *ListPlanExecutionLogResponse { | ||||
| 	dtos := make([]PlanExecutionLogDTO, len(data)) | ||||
| 	for i, item := range data { | ||||
| func NewListPlanExecutionLogResponse(planLogs []models.PlanExecutionLog, plans []models.Plan, total int64, page, pageSize int) *ListPlanExecutionLogResponse { | ||||
| 	planId2Name := make(map[uint]string) | ||||
| 	for _, plan := range plans { | ||||
| 		planId2Name[plan.ID] = plan.Name | ||||
| 	} | ||||
|  | ||||
| 	dtos := make([]PlanExecutionLogDTO, len(planLogs)) | ||||
| 	for i, item := range planLogs { | ||||
| 		dtos[i] = PlanExecutionLogDTO{ | ||||
| 			ID:        item.ID, | ||||
| 			CreatedAt: item.CreatedAt, | ||||
| 			UpdatedAt: item.UpdatedAt, | ||||
| 			PlanID:    item.PlanID, | ||||
| 			PlanName:  planId2Name[item.PlanID], | ||||
| 			Status:    item.Status, | ||||
| 			StartedAt: item.StartedAt, | ||||
| 			EndedAt:   item.EndedAt, | ||||
|   | ||||
| @@ -13,20 +13,20 @@ import ( | ||||
| type PaginationDTO struct { | ||||
| 	Total    int64 `json:"total"` | ||||
| 	Page     int   `json:"page"` | ||||
| 	PageSize int   `json:"pageSize"` | ||||
| 	PageSize int   `json:"page_size"` | ||||
| } | ||||
|  | ||||
| // --- SensorData --- | ||||
|  | ||||
| // ListSensorDataRequest 定义了获取传感器数据列表的请求参数 | ||||
| type ListSensorDataRequest struct { | ||||
| 	Page       int        `form:"page,default=1"` | ||||
| 	PageSize   int        `form:"pageSize,default=10"` | ||||
| 	DeviceID   *uint      `form:"device_id"` | ||||
| 	SensorType *string    `form:"sensor_type"` | ||||
| 	StartTime  *time.Time `form:"start_time"` | ||||
| 	EndTime    *time.Time `form:"end_time"` | ||||
| 	OrderBy    string     `form:"order_by"` | ||||
| 	Page       int        `json:"page" query:"page"` | ||||
| 	PageSize   int        `json:"page_size" query:"page_size"` | ||||
| 	DeviceID   *uint      `json:"device_id" query:"device_id"` | ||||
| 	SensorType *string    `json:"sensor_type" query:"sensor_type"` | ||||
| 	StartTime  *time.Time `json:"start_time" query:"start_time"` | ||||
| 	EndTime    *time.Time `json:"end_time" query:"end_time"` | ||||
| 	OrderBy    string     `json:"order_by" query:"order_by"` | ||||
| } | ||||
|  | ||||
| // SensorDataDTO 是用于API响应的传感器数据结构 | ||||
| @@ -48,13 +48,13 @@ type ListSensorDataResponse struct { | ||||
|  | ||||
| // ListDeviceCommandLogRequest 定义了获取设备命令日志列表的请求参数 | ||||
| type ListDeviceCommandLogRequest struct { | ||||
| 	Page            int        `form:"page,default=1"` | ||||
| 	PageSize        int        `form:"pageSize,default=10"` | ||||
| 	DeviceID        *uint      `form:"device_id"` | ||||
| 	ReceivedSuccess *bool      `form:"received_success"` | ||||
| 	StartTime       *time.Time `form:"start_time"` | ||||
| 	EndTime         *time.Time `form:"end_time"` | ||||
| 	OrderBy         string     `form:"order_by"` | ||||
| 	Page            int        `json:"page" query:"page"` | ||||
| 	PageSize        int        `json:"page_size" query:"page_size"` | ||||
| 	DeviceID        *uint      `json:"device_id" query:"device_id"` | ||||
| 	ReceivedSuccess *bool      `json:"received_success" query:"received_success"` | ||||
| 	StartTime       *time.Time `json:"start_time" query:"start_time"` | ||||
| 	EndTime         *time.Time `json:"end_time" query:"end_time"` | ||||
| 	OrderBy         string     `json:"order_by" query:"order_by"` | ||||
| } | ||||
|  | ||||
| // DeviceCommandLogDTO 是用于API响应的设备命令日志结构 | ||||
| @@ -76,13 +76,13 @@ type ListDeviceCommandLogResponse struct { | ||||
|  | ||||
| // ListPlanExecutionLogRequest 定义了获取计划执行日志列表的请求参数 | ||||
| type ListPlanExecutionLogRequest struct { | ||||
| 	Page      int        `form:"page,default=1"` | ||||
| 	PageSize  int        `form:"pageSize,default=10"` | ||||
| 	PlanID    *uint      `form:"plan_id"` | ||||
| 	Status    *string    `form:"status"` | ||||
| 	StartTime *time.Time `form:"start_time"` | ||||
| 	EndTime   *time.Time `form:"end_time"` | ||||
| 	OrderBy   string     `form:"order_by"` | ||||
| 	Page      int        `json:"page" query:"page"` | ||||
| 	PageSize  int        `json:"page_size" query:"page_size"` | ||||
| 	PlanID    *uint      `json:"plan_id" query:"plan_id"` | ||||
| 	Status    *string    `json:"status" query:"status"` | ||||
| 	StartTime *time.Time `json:"start_time" query:"start_time"` | ||||
| 	EndTime   *time.Time `json:"end_time" query:"end_time"` | ||||
| 	OrderBy   string     `json:"order_by" query:"order_by"` | ||||
| } | ||||
|  | ||||
| // PlanExecutionLogDTO 是用于API响应的计划执行日志结构 | ||||
| @@ -91,6 +91,7 @@ type PlanExecutionLogDTO struct { | ||||
| 	CreatedAt time.Time              `json:"created_at"` | ||||
| 	UpdatedAt time.Time              `json:"updated_at"` | ||||
| 	PlanID    uint                   `json:"plan_id"` | ||||
| 	PlanName  string                 `json:"plan_name"` | ||||
| 	Status    models.ExecutionStatus `json:"status"` | ||||
| 	StartedAt time.Time              `json:"started_at"` | ||||
| 	EndedAt   time.Time              `json:"ended_at"` | ||||
| @@ -107,14 +108,14 @@ type ListPlanExecutionLogResponse struct { | ||||
|  | ||||
| // ListTaskExecutionLogRequest 定义了获取任务执行日志列表的请求参数 | ||||
| type ListTaskExecutionLogRequest struct { | ||||
| 	Page               int        `form:"page,default=1"` | ||||
| 	PageSize           int        `form:"pageSize,default=10"` | ||||
| 	PlanExecutionLogID *uint      `form:"plan_execution_log_id"` | ||||
| 	TaskID             *int       `form:"task_id"` | ||||
| 	Status             *string    `form:"status"` | ||||
| 	StartTime          *time.Time `form:"start_time"` | ||||
| 	EndTime            *time.Time `form:"end_time"` | ||||
| 	OrderBy            string     `form:"order_by"` | ||||
| 	Page               int        `json:"page" query:"page"` | ||||
| 	PageSize           int        `json:"page_size" query:"page_size"` | ||||
| 	PlanExecutionLogID *uint      `json:"plan_execution_log_id" query:"plan_execution_log_id"` | ||||
| 	TaskID             *int       `json:"task_id" query:"task_id"` | ||||
| 	Status             *string    `json:"status" query:"status"` | ||||
| 	StartTime          *time.Time `json:"start_time" query:"start_time"` | ||||
| 	EndTime            *time.Time `json:"end_time" query:"end_time"` | ||||
| 	OrderBy            string     `json:"order_by" query:"order_by"` | ||||
| } | ||||
|  | ||||
| // TaskDTO 是用于API响应的简化版任务结构 | ||||
| @@ -148,13 +149,13 @@ type ListTaskExecutionLogResponse struct { | ||||
|  | ||||
| // ListPendingCollectionRequest 定义了获取待采集请求列表的请求参数 | ||||
| type ListPendingCollectionRequest struct { | ||||
| 	Page      int        `form:"page,default=1"` | ||||
| 	PageSize  int        `form:"pageSize,default=10"` | ||||
| 	DeviceID  *uint      `form:"device_id"` | ||||
| 	Status    *string    `form:"status"` | ||||
| 	StartTime *time.Time `form:"start_time"` | ||||
| 	EndTime   *time.Time `form:"end_time"` | ||||
| 	OrderBy   string     `form:"order_by"` | ||||
| 	Page      int        `json:"page" query:"page"` | ||||
| 	PageSize  int        `json:"page_size" query:"page_size"` | ||||
| 	DeviceID  *uint      `json:"device_id" query:"device_id"` | ||||
| 	Status    *string    `json:"status" query:"status"` | ||||
| 	StartTime *time.Time `json:"start_time" query:"start_time"` | ||||
| 	EndTime   *time.Time `json:"end_time" query:"end_time"` | ||||
| 	OrderBy   string     `json:"order_by" query:"order_by"` | ||||
| } | ||||
|  | ||||
| // PendingCollectionDTO 是用于API响应的待采集请求结构 | ||||
| @@ -177,15 +178,15 @@ type ListPendingCollectionResponse struct { | ||||
|  | ||||
| // ListUserActionLogRequest 定义了获取用户操作日志列表的请求参数 | ||||
| type ListUserActionLogRequest struct { | ||||
| 	Page       int        `form:"page,default=1"` | ||||
| 	PageSize   int        `form:"pageSize,default=10"` | ||||
| 	UserID     *uint      `form:"user_id"` | ||||
| 	Username   *string    `form:"username"` | ||||
| 	ActionType *string    `form:"action_type"` | ||||
| 	Status     *string    `form:"status"` | ||||
| 	StartTime  *time.Time `form:"start_time"` | ||||
| 	EndTime    *time.Time `form:"end_time"` | ||||
| 	OrderBy    string     `form:"order_by"` | ||||
| 	Page       int        `json:"page" query:"page"` | ||||
| 	PageSize   int        `json:"page_size" query:"page_size"` | ||||
| 	UserID     *uint      `json:"user_id" query:"user_id"` | ||||
| 	Username   *string    `json:"username" query:"username"` | ||||
| 	ActionType *string    `json:"action_type" query:"action_type"` | ||||
| 	Status     *string    `json:"status" query:"status"` | ||||
| 	StartTime  *time.Time `json:"start_time" query:"start_time"` | ||||
| 	EndTime    *time.Time `json:"end_time" query:"end_time"` | ||||
| 	OrderBy    string     `json:"order_by" query:"order_by"` | ||||
| } | ||||
|  | ||||
| // UserActionLogDTO 是用于API响应的用户操作日志结构 | ||||
| @@ -214,13 +215,13 @@ type ListUserActionLogResponse struct { | ||||
|  | ||||
| // ListRawMaterialPurchaseRequest 定义了获取原料采购列表的请求参数 | ||||
| type ListRawMaterialPurchaseRequest struct { | ||||
| 	Page          int        `form:"page,default=1"` | ||||
| 	PageSize      int        `form:"pageSize,default=10"` | ||||
| 	RawMaterialID *uint      `form:"raw_material_id"` | ||||
| 	Supplier      *string    `form:"supplier"` | ||||
| 	StartTime     *time.Time `form:"start_time"` | ||||
| 	EndTime       *time.Time `form:"end_time"` | ||||
| 	OrderBy       string     `form:"order_by"` | ||||
| 	Page          int        `json:"page" query:"page"` | ||||
| 	PageSize      int        `json:"page_size" query:"page_size"` | ||||
| 	RawMaterialID *uint      `json:"raw_material_id" query:"raw_material_id"` | ||||
| 	Supplier      *string    `json:"supplier" query:"supplier"` | ||||
| 	StartTime     *time.Time `json:"start_time" query:"start_time"` | ||||
| 	EndTime       *time.Time `json:"end_time" query:"end_time"` | ||||
| 	OrderBy       string     `json:"order_by" query:"order_by"` | ||||
| } | ||||
|  | ||||
| // RawMaterialDTO 是用于API响应的简化版原料结构 | ||||
| @@ -252,14 +253,14 @@ type ListRawMaterialPurchaseResponse struct { | ||||
|  | ||||
| // ListRawMaterialStockLogRequest 定义了获取原料库存日志列表的请求参数 | ||||
| type ListRawMaterialStockLogRequest struct { | ||||
| 	Page          int        `form:"page,default=1"` | ||||
| 	PageSize      int        `form:"pageSize,default=10"` | ||||
| 	RawMaterialID *uint      `form:"raw_material_id"` | ||||
| 	SourceType    *string    `form:"source_type"` | ||||
| 	SourceID      *uint      `form:"source_id"` | ||||
| 	StartTime     *time.Time `form:"start_time"` | ||||
| 	EndTime       *time.Time `form:"end_time"` | ||||
| 	OrderBy       string     `form:"order_by"` | ||||
| 	Page          int        `json:"page" query:"page"` | ||||
| 	PageSize      int        `json:"page_size" query:"page_size"` | ||||
| 	RawMaterialID *uint      `json:"raw_material_id" query:"raw_material_id"` | ||||
| 	SourceType    *string    `json:"source_type" query:"source_type"` | ||||
| 	SourceID      *uint      `json:"source_id" query:"source_id"` | ||||
| 	StartTime     *time.Time `json:"start_time" query:"start_time"` | ||||
| 	EndTime       *time.Time `json:"end_time" query:"end_time"` | ||||
| 	OrderBy       string     `json:"order_by" query:"order_by"` | ||||
| } | ||||
|  | ||||
| // RawMaterialStockLogDTO 是用于API响应的原料库存日志结构 | ||||
| @@ -283,14 +284,14 @@ type ListRawMaterialStockLogResponse struct { | ||||
|  | ||||
| // ListFeedUsageRecordRequest 定义了获取饲料使用记录列表的请求参数 | ||||
| type ListFeedUsageRecordRequest struct { | ||||
| 	Page          int        `form:"page,default=1"` | ||||
| 	PageSize      int        `form:"pageSize,default=10"` | ||||
| 	PenID         *uint      `form:"pen_id"` | ||||
| 	FeedFormulaID *uint      `form:"feed_formula_id"` | ||||
| 	OperatorID    *uint      `form:"operator_id"` | ||||
| 	StartTime     *time.Time `form:"start_time"` | ||||
| 	EndTime       *time.Time `form:"end_time"` | ||||
| 	OrderBy       string     `form:"order_by"` | ||||
| 	Page          int        `json:"page" query:"page"` | ||||
| 	PageSize      int        `json:"page_size" query:"page_size"` | ||||
| 	PenID         *uint      `json:"pen_id" query:"pen_id"` | ||||
| 	FeedFormulaID *uint      `json:"feed_formula_id" query:"feed_formula_id"` | ||||
| 	OperatorID    *uint      `json:"operator_id" query:"operator_id"` | ||||
| 	StartTime     *time.Time `json:"start_time" query:"start_time"` | ||||
| 	EndTime       *time.Time `json:"end_time" query:"end_time"` | ||||
| 	OrderBy       string     `json:"order_by" query:"order_by"` | ||||
| } | ||||
|  | ||||
| // PenDTO 是用于API响应的简化版猪栏结构 | ||||
| @@ -328,15 +329,15 @@ type ListFeedUsageRecordResponse struct { | ||||
|  | ||||
| // ListMedicationLogRequest 定义了获取用药记录列表的请求参数 | ||||
| type ListMedicationLogRequest struct { | ||||
| 	Page         int        `form:"page,default=1"` | ||||
| 	PageSize     int        `form:"pageSize,default=10"` | ||||
| 	PigBatchID   *uint      `form:"pig_batch_id"` | ||||
| 	MedicationID *uint      `form:"medication_id"` | ||||
| 	Reason       *string    `form:"reason"` | ||||
| 	OperatorID   *uint      `form:"operator_id"` | ||||
| 	StartTime    *time.Time `form:"start_time"` | ||||
| 	EndTime      *time.Time `form:"end_time"` | ||||
| 	OrderBy      string     `form:"order_by"` | ||||
| 	Page         int        `json:"page" query:"page"` | ||||
| 	PageSize     int        `json:"page_size" query:"page_size"` | ||||
| 	PigBatchID   *uint      `json:"pig_batch_id" query:"pig_batch_id"` | ||||
| 	MedicationID *uint      `json:"medication_id" query:"medication_id"` | ||||
| 	Reason       *string    `json:"reason" query:"reason"` | ||||
| 	OperatorID   *uint      `json:"operator_id" query:"operator_id"` | ||||
| 	StartTime    *time.Time `json:"start_time" query:"start_time"` | ||||
| 	EndTime      *time.Time `json:"end_time" query:"end_time"` | ||||
| 	OrderBy      string     `json:"order_by" query:"order_by"` | ||||
| } | ||||
|  | ||||
| // MedicationDTO 是用于API响应的简化版药品结构 | ||||
| @@ -369,14 +370,14 @@ type ListMedicationLogResponse struct { | ||||
|  | ||||
| // ListPigBatchLogRequest 定义了获取猪批次日志列表的请求参数 | ||||
| type ListPigBatchLogRequest struct { | ||||
| 	Page       int        `form:"page,default=1"` | ||||
| 	PageSize   int        `form:"pageSize,default=10"` | ||||
| 	PigBatchID *uint      `form:"pig_batch_id"` | ||||
| 	ChangeType *string    `form:"change_type"` | ||||
| 	OperatorID *uint      `form:"operator_id"` | ||||
| 	StartTime  *time.Time `form:"start_time"` | ||||
| 	EndTime    *time.Time `form:"end_time"` | ||||
| 	OrderBy    string     `form:"order_by"` | ||||
| 	Page       int        `json:"page" query:"page"` | ||||
| 	PageSize   int        `json:"page_size" query:"page_size"` | ||||
| 	PigBatchID *uint      `json:"pig_batch_id" query:"pig_batch_id"` | ||||
| 	ChangeType *string    `json:"change_type" query:"change_type"` | ||||
| 	OperatorID *uint      `json:"operator_id" query:"operator_id"` | ||||
| 	StartTime  *time.Time `json:"start_time" query:"start_time"` | ||||
| 	EndTime    *time.Time `json:"end_time" query:"end_time"` | ||||
| 	OrderBy    string     `json:"order_by" query:"order_by"` | ||||
| } | ||||
|  | ||||
| // PigBatchLogDTO 是用于API响应的猪批次日志结构 | ||||
| @@ -404,12 +405,12 @@ type ListPigBatchLogResponse struct { | ||||
|  | ||||
| // ListWeighingBatchRequest 定义了获取批次称重记录列表的请求参数 | ||||
| type ListWeighingBatchRequest struct { | ||||
| 	Page       int        `form:"page,default=1"` | ||||
| 	PageSize   int        `form:"pageSize,default=10"` | ||||
| 	PigBatchID *uint      `form:"pig_batch_id"` | ||||
| 	StartTime  *time.Time `form:"start_time"` | ||||
| 	EndTime    *time.Time `form:"end_time"` | ||||
| 	OrderBy    string     `form:"order_by"` | ||||
| 	Page       int        `json:"page" query:"page"` | ||||
| 	PageSize   int        `json:"page_size" query:"page_size"` | ||||
| 	PigBatchID *uint      `json:"pig_batch_id" query:"pig_batch_id"` | ||||
| 	StartTime  *time.Time `json:"start_time" query:"start_time"` | ||||
| 	EndTime    *time.Time `json:"end_time" query:"end_time"` | ||||
| 	OrderBy    string     `json:"order_by" query:"order_by"` | ||||
| } | ||||
|  | ||||
| // WeighingBatchDTO 是用于API响应的批次称重记录结构 | ||||
| @@ -432,14 +433,14 @@ type ListWeighingBatchResponse struct { | ||||
|  | ||||
| // ListWeighingRecordRequest 定义了获取单次称重记录列表的请求参数 | ||||
| type ListWeighingRecordRequest struct { | ||||
| 	Page            int        `form:"page,default=1"` | ||||
| 	PageSize        int        `form:"pageSize,default=10"` | ||||
| 	WeighingBatchID *uint      `form:"weighing_batch_id"` | ||||
| 	PenID           *uint      `form:"pen_id"` | ||||
| 	OperatorID      *uint      `form:"operator_id"` | ||||
| 	StartTime       *time.Time `form:"start_time"` | ||||
| 	EndTime         *time.Time `form:"end_time"` | ||||
| 	OrderBy         string     `form:"order_by"` | ||||
| 	Page            int        `json:"page" query:"page"` | ||||
| 	PageSize        int        `json:"page_size" query:"page_size"` | ||||
| 	WeighingBatchID *uint      `json:"weighing_batch_id" query:"weighing_batch_id"` | ||||
| 	PenID           *uint      `json:"pen_id" query:"pen_id"` | ||||
| 	OperatorID      *uint      `json:"operator_id" query:"operator_id"` | ||||
| 	StartTime       *time.Time `json:"start_time" query:"start_time"` | ||||
| 	EndTime         *time.Time `json:"end_time" query:"end_time"` | ||||
| 	OrderBy         string     `json:"order_by" query:"order_by"` | ||||
| } | ||||
|  | ||||
| // WeighingRecordDTO 是用于API响应的单次称重记录结构 | ||||
| @@ -465,16 +466,16 @@ type ListWeighingRecordResponse struct { | ||||
|  | ||||
| // ListPigTransferLogRequest 定义了获取猪只迁移日志列表的请求参数 | ||||
| type ListPigTransferLogRequest struct { | ||||
| 	Page          int        `form:"page,default=1"` | ||||
| 	PageSize      int        `form:"pageSize,default=10"` | ||||
| 	PigBatchID    *uint      `form:"pig_batch_id"` | ||||
| 	PenID         *uint      `form:"pen_id"` | ||||
| 	TransferType  *string    `form:"transfer_type"` | ||||
| 	OperatorID    *uint      `form:"operator_id"` | ||||
| 	CorrelationID *string    `form:"correlation_id"` | ||||
| 	StartTime     *time.Time `form:"start_time"` | ||||
| 	EndTime       *time.Time `form:"end_time"` | ||||
| 	OrderBy       string     `form:"order_by"` | ||||
| 	Page          int        `json:"page" query:"page"` | ||||
| 	PageSize      int        `json:"page_size" query:"page_size"` | ||||
| 	PigBatchID    *uint      `json:"pig_batch_id" query:"pig_batch_id"` | ||||
| 	PenID         *uint      `json:"pen_id" query:"pen_id"` | ||||
| 	TransferType  *string    `json:"transfer_type" query:"transfer_type"` | ||||
| 	OperatorID    *uint      `json:"operator_id" query:"operator_id"` | ||||
| 	CorrelationID *string    `json:"correlation_id" query:"correlation_id"` | ||||
| 	StartTime     *time.Time `json:"start_time" query:"start_time"` | ||||
| 	EndTime       *time.Time `json:"end_time" query:"end_time"` | ||||
| 	OrderBy       string     `json:"order_by" query:"order_by"` | ||||
| } | ||||
|  | ||||
| // PigTransferLogDTO 是用于API响应的猪只迁移日志结构 | ||||
| @@ -502,16 +503,16 @@ type ListPigTransferLogResponse struct { | ||||
|  | ||||
| // ListPigSickLogRequest 定义了获取病猪日志列表的请求参数 | ||||
| type ListPigSickLogRequest struct { | ||||
| 	Page              int        `form:"page,default=1"` | ||||
| 	PageSize          int        `form:"pageSize,default=10"` | ||||
| 	PigBatchID        *uint      `form:"pig_batch_id"` | ||||
| 	PenID             *uint      `form:"pen_id"` | ||||
| 	Reason            *string    `form:"reason"` | ||||
| 	TreatmentLocation *string    `form:"treatment_location"` | ||||
| 	OperatorID        *uint      `form:"operator_id"` | ||||
| 	StartTime         *time.Time `form:"start_time"` | ||||
| 	EndTime           *time.Time `form:"end_time"` | ||||
| 	OrderBy           string     `form:"order_by"` | ||||
| 	Page              int        `json:"page" query:"page"` | ||||
| 	PageSize          int        `json:"page_size" query:"page_size"` | ||||
| 	PigBatchID        *uint      `json:"pig_batch_id" query:"pig_batch_id"` | ||||
| 	PenID             *uint      `json:"pen_id" query:"pen_id"` | ||||
| 	Reason            *string    `json:"reason" query:"reason"` | ||||
| 	TreatmentLocation *string    `json:"treatment_location" query:"treatment_location"` | ||||
| 	OperatorID        *uint      `json:"operator_id" query:"operator_id"` | ||||
| 	StartTime         *time.Time `json:"start_time" query:"start_time"` | ||||
| 	EndTime           *time.Time `json:"end_time" query:"end_time"` | ||||
| 	OrderBy           string     `json:"order_by" query:"order_by"` | ||||
| } | ||||
|  | ||||
| // PigSickLogDTO 是用于API响应的病猪日志结构 | ||||
| @@ -541,14 +542,14 @@ type ListPigSickLogResponse struct { | ||||
|  | ||||
| // ListPigPurchaseRequest 定义了获取猪只采购记录列表的请求参数 | ||||
| type ListPigPurchaseRequest struct { | ||||
| 	Page       int        `form:"page,default=1"` | ||||
| 	PageSize   int        `form:"pageSize,default=10"` | ||||
| 	PigBatchID *uint      `form:"pig_batch_id"` | ||||
| 	Supplier   *string    `form:"supplier"` | ||||
| 	OperatorID *uint      `form:"operator_id"` | ||||
| 	StartTime  *time.Time `form:"start_time"` | ||||
| 	EndTime    *time.Time `form:"end_time"` | ||||
| 	OrderBy    string     `form:"order_by"` | ||||
| 	Page       int        `json:"page" query:"page"` | ||||
| 	PageSize   int        `json:"page_size" query:"page_size"` | ||||
| 	PigBatchID *uint      `json:"pig_batch_id" query:"pig_batch_id"` | ||||
| 	Supplier   *string    `json:"supplier" query:"supplier"` | ||||
| 	OperatorID *uint      `json:"operator_id" query:"operator_id"` | ||||
| 	StartTime  *time.Time `json:"start_time" query:"start_time"` | ||||
| 	EndTime    *time.Time `json:"end_time" query:"end_time"` | ||||
| 	OrderBy    string     `json:"order_by" query:"order_by"` | ||||
| } | ||||
|  | ||||
| // PigPurchaseDTO 是用于API响应的猪只采购记录结构 | ||||
| @@ -576,14 +577,14 @@ type ListPigPurchaseResponse struct { | ||||
|  | ||||
| // ListPigSaleRequest 定义了获取猪只销售记录列表的请求参数 | ||||
| type ListPigSaleRequest struct { | ||||
| 	Page       int        `form:"page,default=1"` | ||||
| 	PageSize   int        `form:"pageSize,default=10"` | ||||
| 	PigBatchID *uint      `form:"pig_batch_id"` | ||||
| 	Buyer      *string    `form:"buyer"` | ||||
| 	OperatorID *uint      `form:"operator_id"` | ||||
| 	StartTime  *time.Time `form:"start_time"` | ||||
| 	EndTime    *time.Time `form:"end_time"` | ||||
| 	OrderBy    string     `form:"order_by"` | ||||
| 	Page       int        `json:"page" query:"page"` | ||||
| 	PageSize   int        `json:"page_size" query:"page_size"` | ||||
| 	PigBatchID *uint      `json:"pig_batch_id" query:"pig_batch_id"` | ||||
| 	Buyer      *string    `json:"buyer" query:"buyer"` | ||||
| 	OperatorID *uint      `json:"operator_id" query:"operator_id"` | ||||
| 	StartTime  *time.Time `json:"start_time" query:"start_time"` | ||||
| 	EndTime    *time.Time `json:"end_time" query:"end_time"` | ||||
| 	OrderBy    string     `json:"order_by" query:"order_by"` | ||||
| } | ||||
|  | ||||
| // PigSaleDTO 是用于API响应的猪只销售记录结构 | ||||
|   | ||||
							
								
								
									
										36
									
								
								internal/app/dto/notification_converter.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								internal/app/dto/notification_converter.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,36 @@ | ||||
| package dto | ||||
|  | ||||
| import ( | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||
| 	"go.uber.org/zap/zapcore" | ||||
| ) | ||||
|  | ||||
| // NewListNotificationResponse 从模型数据创建通知列表响应 DTO | ||||
| func NewListNotificationResponse(data []models.Notification, total int64, page, pageSize int) *ListNotificationResponse { | ||||
| 	dtos := make([]NotificationDTO, len(data)) | ||||
| 	for i, item := range data { | ||||
| 		dtos[i] = NotificationDTO{ | ||||
| 			ID:             item.ID, | ||||
| 			CreatedAt:      item.CreatedAt, | ||||
| 			UpdatedAt:      item.UpdatedAt, | ||||
| 			NotifierType:   item.NotifierType, | ||||
| 			UserID:         item.UserID, | ||||
| 			Title:          item.Title, | ||||
| 			Message:        item.Message, | ||||
| 			Level:          zapcore.Level(item.Level), | ||||
| 			AlarmTimestamp: item.AlarmTimestamp, | ||||
| 			ToAddress:      item.ToAddress, | ||||
| 			Status:         item.Status, | ||||
| 			ErrorMessage:   item.ErrorMessage, | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return &ListNotificationResponse{ | ||||
| 		List: dtos, | ||||
| 		Pagination: PaginationDTO{ | ||||
| 			Total:    total, | ||||
| 			Page:     page, | ||||
| 			PageSize: pageSize, | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
| @@ -1,9 +1,50 @@ | ||||
| package dto | ||||
|  | ||||
| import "git.huangwc.com/pig/pig-farm-controller/internal/infra/notify" | ||||
| import ( | ||||
| 	"time" | ||||
|  | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/notify" | ||||
| 	"go.uber.org/zap/zapcore" | ||||
| ) | ||||
|  | ||||
| // SendTestNotificationRequest 定义了发送测试通知请求的 JSON 结构 | ||||
| type SendTestNotificationRequest struct { | ||||
| 	// Type 指定要测试的通知渠道 | ||||
| 	Type notify.NotifierType `json:"type" binding:"required"` | ||||
| 	Type notify.NotifierType `json:"type" validate:"required"` | ||||
| } | ||||
|  | ||||
| // ListNotificationRequest 定义了获取通知列表的请求参数 | ||||
| type ListNotificationRequest struct { | ||||
| 	Page         int                        `json:"page" query:"page"` | ||||
| 	PageSize     int                        `json:"page_size" query:"page_size"` | ||||
| 	UserID       *uint                      `json:"user_id" query:"user_id"` | ||||
| 	NotifierType *notify.NotifierType       `json:"notifier_type" query:"notifier_type"` | ||||
| 	Status       *models.NotificationStatus `json:"status" query:"status"` | ||||
| 	Level        *zapcore.Level             `json:"level" query:"level"` | ||||
| 	StartTime    *time.Time                 `json:"start_time" query:"start_time"` | ||||
| 	EndTime      *time.Time                 `json:"end_time" query:"end_time"` | ||||
| 	OrderBy      string                     `json:"order_by" query:"order_by"` | ||||
| } | ||||
|  | ||||
| // NotificationDTO 是用于API响应的通知结构 | ||||
| type NotificationDTO struct { | ||||
| 	ID             uint                      `json:"id"` | ||||
| 	CreatedAt      time.Time                 `json:"created_at"` | ||||
| 	UpdatedAt      time.Time                 `json:"updated_at"` | ||||
| 	NotifierType   notify.NotifierType       `json:"notifier_type"` | ||||
| 	UserID         uint                      `json:"user_id"` | ||||
| 	Title          string                    `json:"title"` | ||||
| 	Message        string                    `json:"message"` | ||||
| 	Level          zapcore.Level             `json:"level"` | ||||
| 	AlarmTimestamp time.Time                 `json:"alarm_timestamp"` | ||||
| 	ToAddress      string                    `json:"to_address"` | ||||
| 	Status         models.NotificationStatus `json:"status"` | ||||
| 	ErrorMessage   string                    `json:"error_message"` | ||||
| } | ||||
|  | ||||
| // ListNotificationResponse 是获取通知列表的响应结构 | ||||
| type ListNotificationResponse struct { | ||||
| 	List       []NotificationDTO `json:"list"` | ||||
| 	Pagination PaginationDTO     `json:"pagination"` | ||||
| } | ||||
|   | ||||
| @@ -8,11 +8,11 @@ import ( | ||||
|  | ||||
| // PigBatchCreateDTO 定义了创建猪批次的请求结构 | ||||
| type PigBatchCreateDTO struct { | ||||
| 	BatchNumber  string                    `json:"batch_number" binding:"required"`        // 批次编号,必填 | ||||
| 	OriginType   models.PigBatchOriginType `json:"origin_type" binding:"required"`         // 批次来源,必填 | ||||
| 	StartDate    time.Time                 `json:"start_date" binding:"required"`          // 批次开始日期,必填 | ||||
| 	InitialCount int                       `json:"initial_count" binding:"required,min=1"` // 初始数量,必填,最小为1 | ||||
| 	Status       models.PigBatchStatus     `json:"status" binding:"required"`              // 批次状态,必填 | ||||
| 	BatchNumber  string                    `json:"batch_number" validate:"required"`        // 批次编号,必填 | ||||
| 	OriginType   models.PigBatchOriginType `json:"origin_type" validate:"required"`         // 批次来源,必填 | ||||
| 	StartDate    time.Time                 `json:"start_date" validate:"required"`          // 批次开始日期,必填 | ||||
| 	InitialCount int                       `json:"initial_count" validate:"required,min=1"` // 初始数量,必填,最小为1 | ||||
| 	Status       models.PigBatchStatus     `json:"status" validate:"required"`              // 批次状态,必填 | ||||
| } | ||||
|  | ||||
| // PigBatchUpdateDTO 定义了更新猪批次的请求结构 | ||||
| @@ -27,136 +27,136 @@ type PigBatchUpdateDTO struct { | ||||
|  | ||||
| // PigBatchQueryDTO 定义了查询猪批次的请求结构 | ||||
| type PigBatchQueryDTO struct { | ||||
| 	IsActive *bool `json:"is_active" form:"is_active"` // 是否活跃,可选,用于URL查询参数 | ||||
| 	IsActive *bool `json:"is_active" query:"is_active"` // 是否活跃,可选,用于URL查询参数 | ||||
| } | ||||
|  | ||||
| // PigBatchResponseDTO 定义了猪批次信息的响应结构 | ||||
| type PigBatchResponseDTO struct { | ||||
| 	ID                     uint                      `json:"id"`                     // 批次ID | ||||
| 	BatchNumber            string                    `json:"batch_number"`           // 批次编号 | ||||
| 	OriginType             models.PigBatchOriginType `json:"origin_type"`            // 批次来源 | ||||
| 	StartDate              time.Time                 `json:"start_date"`             // 批次开始日期 | ||||
| 	EndDate                time.Time                 `json:"end_date"`               // 批次结束日期 | ||||
| 	InitialCount           int                       `json:"initial_count"`          // 初始数量 | ||||
| 	Status                 models.PigBatchStatus     `json:"status"`                 // 批次状态 | ||||
| 	IsActive               bool                      `json:"is_active"`              // 是否活跃 | ||||
| 	CurrentTotalQuantity   int                       `json:"currentTotalQuantity"`   // 当前总数 | ||||
| 	CurrentTotalPigsInPens int                       `json:"currentTotalPigsInPens"` // 当前存栏总数 | ||||
| 	CreateTime             time.Time                 `json:"create_time"`            // 创建时间 | ||||
| 	UpdateTime             time.Time                 `json:"update_time"`            // 更新时间 | ||||
| 	ID                     uint                      `json:"id"`                         // 批次ID | ||||
| 	BatchNumber            string                    `json:"batch_number"`               // 批次编号 | ||||
| 	OriginType             models.PigBatchOriginType `json:"origin_type"`                // 批次来源 | ||||
| 	StartDate              time.Time                 `json:"start_date"`                 // 批次开始日期 | ||||
| 	EndDate                time.Time                 `json:"end_date"`                   // 批次结束日期 | ||||
| 	InitialCount           int                       `json:"initial_count"`              // 初始数量 | ||||
| 	Status                 models.PigBatchStatus     `json:"status"`                     // 批次状态 | ||||
| 	IsActive               bool                      `json:"is_active"`                  // 是否活跃 | ||||
| 	CurrentTotalQuantity   int                       `json:"current_total_quantity"`     // 当前总数 | ||||
| 	CurrentTotalPigsInPens int                       `json:"current_total_pigs_in_pens"` // 当前存栏总数 | ||||
| 	CreateTime             time.Time                 `json:"create_time"`                // 创建时间 | ||||
| 	UpdateTime             time.Time                 `json:"update_time"`                // 更新时间 | ||||
| } | ||||
|  | ||||
| // AssignEmptyPensToBatchRequest 用于为猪批次分配空栏的请求体 | ||||
| type AssignEmptyPensToBatchRequest struct { | ||||
| 	PenIDs []uint `json:"penIDs" binding:"required,min=1" example:"[1,2,3]"` // 待分配的猪栏ID列表 | ||||
| 	PenIDs []uint `json:"pen_ids" validate:"required,min=1,dive" example:"1,2,3"` // 待分配的猪栏ID列表 | ||||
| } | ||||
|  | ||||
| // ReclassifyPenToNewBatchRequest 用于将猪栏划拨到新批次的请求体 | ||||
| type ReclassifyPenToNewBatchRequest struct { | ||||
| 	ToBatchID uint   `json:"toBatchID" binding:"required"` // 目标猪批次ID | ||||
| 	PenID     uint   `json:"penID" binding:"required"`     // 待划拨的猪栏ID | ||||
| 	Remarks   string `json:"remarks"`                      // 备注 | ||||
| 	ToBatchID uint   `json:"to_batch_id" validate:"required"` // 目标猪批次ID | ||||
| 	PenID     uint   `json:"pen_id" validate:"required"`      // 待划拨的猪栏ID | ||||
| 	Remarks   string `json:"remarks"`                         // 备注 | ||||
| } | ||||
|  | ||||
| // RemoveEmptyPenFromBatchRequest 用于从猪批次移除空栏的请求体 | ||||
| type RemoveEmptyPenFromBatchRequest struct { | ||||
| 	PenID uint `json:"penID" binding:"required"` // 待移除的猪栏ID | ||||
| 	PenID uint `json:"pen_id" validate:"required"` // 待移除的猪栏ID | ||||
| } | ||||
|  | ||||
| // MovePigsIntoPenRequest 用于将猪只从“虚拟库存”移入指定猪栏的请求体 | ||||
| type MovePigsIntoPenRequest struct { | ||||
| 	ToPenID  uint   `json:"toPenID" binding:"required"`        // 目标猪栏ID | ||||
| 	Quantity int    `json:"quantity" binding:"required,min=1"` // 移入猪只数量 | ||||
| 	Remarks  string `json:"remarks"`                           // 备注 | ||||
| 	ToPenID  uint   `json:"to_pen_id" validate:"required"`      // 目标猪栏ID | ||||
| 	Quantity int    `json:"quantity" validate:"required,min=1"` // 移入猪只数量 | ||||
| 	Remarks  string `json:"remarks"`                            // 备注 | ||||
| } | ||||
|  | ||||
| // SellPigsRequest 用于处理卖猪的请求体 | ||||
| type SellPigsRequest struct { | ||||
| 	PenID      uint      `json:"penID" binding:"required"`            // 猪栏ID | ||||
| 	Quantity   int       `json:"quantity" binding:"required,min=1"`   // 卖出猪只数量 | ||||
| 	UnitPrice  float64   `json:"unitPrice" binding:"required,min=0"`  // 单价 | ||||
| 	TotalPrice float64   `json:"totalPrice" binding:"required,min=0"` // 总价 | ||||
| 	TraderName string    `json:"traderName" binding:"required"`       // 交易方名称 | ||||
| 	TradeDate  time.Time `json:"tradeDate" binding:"required"`        // 交易日期 | ||||
| 	Remarks    string    `json:"remarks"`                             // 备注 | ||||
| 	PenID      uint      `json:"pen_id" validate:"required"`            // 猪栏ID | ||||
| 	Quantity   int       `json:"quantity" validate:"required,min=1"`    // 卖出猪只数量 | ||||
| 	UnitPrice  float64   `json:"unit_price" validate:"required,min=0"`  // 单价 | ||||
| 	TotalPrice float64   `json:"total_price" validate:"required,min=0"` // 总价 | ||||
| 	TraderName string    `json:"trader_name" validate:"required"`       // 交易方名称 | ||||
| 	TradeDate  time.Time `json:"trade_date" validate:"required"`        // 交易日期 | ||||
| 	Remarks    string    `json:"remarks"`                               // 备注 | ||||
| } | ||||
|  | ||||
| // BuyPigsRequest 用于处理买猪的请求体 | ||||
| type BuyPigsRequest struct { | ||||
| 	PenID      uint      `json:"penID" binding:"required"`            // 猪栏ID | ||||
| 	Quantity   int       `json:"quantity" binding:"required,min=1"`   // 买入猪只数量 | ||||
| 	UnitPrice  float64   `json:"unitPrice" binding:"required,min=0"`  // 单价 | ||||
| 	TotalPrice float64   `json:"totalPrice" binding:"required,min=0"` // 总价 | ||||
| 	TraderName string    `json:"traderName" binding:"required"`       // 交易方名称 | ||||
| 	TradeDate  time.Time `json:"tradeDate" binding:"required"`        // 交易日期 | ||||
| 	Remarks    string    `json:"remarks"`                             // 备注 | ||||
| 	PenID      uint      `json:"pen_id" validate:"required"`            // 猪栏ID | ||||
| 	Quantity   int       `json:"quantity" validate:"required,min=1"`    // 买入猪只数量 | ||||
| 	UnitPrice  float64   `json:"unit_price" validate:"required,min=0"`  // 单价 | ||||
| 	TotalPrice float64   `json:"total_price" validate:"required,min=0"` // 总价 | ||||
| 	TraderName string    `json:"trader_name" validate:"required"`       // 交易方名称 | ||||
| 	TradeDate  time.Time `json:"trade_date" validate:"required"`        // 交易日期 | ||||
| 	Remarks    string    `json:"remarks"`                               // 备注 | ||||
| } | ||||
|  | ||||
| // TransferPigsAcrossBatchesRequest 用于跨猪群调栏的请求体 | ||||
| type TransferPigsAcrossBatchesRequest struct { | ||||
| 	DestBatchID uint   `json:"destBatchID" binding:"required"`    // 目标猪批次ID | ||||
| 	FromPenID   uint   `json:"fromPenID" binding:"required"`      // 源猪栏ID | ||||
| 	ToPenID     uint   `json:"toPenID" binding:"required"`        // 目标猪栏ID | ||||
| 	Quantity    uint   `json:"quantity" binding:"required,min=1"` // 调栏猪只数量 | ||||
| 	Remarks     string `json:"remarks"`                           // 备注 | ||||
| 	DestBatchID uint   `json:"dest_batch_id" validate:"required"`  // 目标猪批次ID | ||||
| 	FromPenID   uint   `json:"from_pen_id" validate:"required"`    // 源猪栏ID | ||||
| 	ToPenID     uint   `json:"to_pen_id" validate:"required"`      // 目标猪栏ID | ||||
| 	Quantity    uint   `json:"quantity" validate:"required,min=1"` // 调栏猪只数量 | ||||
| 	Remarks     string `json:"remarks"`                            // 备注 | ||||
| } | ||||
|  | ||||
| // TransferPigsWithinBatchRequest 用于群内调栏的请求体 | ||||
| type TransferPigsWithinBatchRequest struct { | ||||
| 	FromPenID uint   `json:"fromPenID" binding:"required"`      // 源猪栏ID | ||||
| 	ToPenID   uint   `json:"toPenID" binding:"required"`        // 目标猪栏ID | ||||
| 	Quantity  uint   `json:"quantity" binding:"required,min=1"` // 调栏猪只数量 | ||||
| 	Remarks   string `json:"remarks"`                           // 备注 | ||||
| 	FromPenID uint   `json:"from_pen_id" validate:"required"`    // 源猪栏ID | ||||
| 	ToPenID   uint   `json:"to_pen_id" validate:"required"`      // 目标猪栏ID | ||||
| 	Quantity  uint   `json:"quantity" validate:"required,min=1"` // 调栏猪只数量 | ||||
| 	Remarks   string `json:"remarks"`                            // 备注 | ||||
| } | ||||
|  | ||||
| // RecordSickPigsRequest 用于记录新增病猪事件的请求体 | ||||
| type RecordSickPigsRequest struct { | ||||
| 	PenID             uint                                    `json:"penID" binding:"required"`             // 猪栏ID | ||||
| 	Quantity          int                                     `json:"quantity" binding:"required,min=1"`    // 病猪数量 | ||||
| 	TreatmentLocation models.PigBatchSickPigTreatmentLocation `json:"treatmentLocation" binding:"required"` // 治疗地点 | ||||
| 	HappenedAt        time.Time                               `json:"happenedAt" binding:"required"`        // 发生时间 | ||||
| 	Remarks           string                                  `json:"remarks"`                              // 备注 | ||||
| 	PenID             uint                                    `json:"pen_id" validate:"required"`             // 猪栏ID | ||||
| 	Quantity          int                                     `json:"quantity" validate:"required,min=1"`     // 病猪数量 | ||||
| 	TreatmentLocation models.PigBatchSickPigTreatmentLocation `json:"treatment_location" validate:"required"` // 治疗地点 | ||||
| 	HappenedAt        time.Time                               `json:"happened_at" validate:"required"`        // 发生时间 | ||||
| 	Remarks           string                                  `json:"remarks"`                                // 备注 | ||||
| } | ||||
|  | ||||
| // RecordSickPigRecoveryRequest 用于记录病猪康复事件的请求体 | ||||
| type RecordSickPigRecoveryRequest struct { | ||||
| 	PenID             uint                                    `json:"penID" binding:"required"`             // 猪栏ID | ||||
| 	Quantity          int                                     `json:"quantity" binding:"required,min=1"`    // 康复猪数量 | ||||
| 	TreatmentLocation models.PigBatchSickPigTreatmentLocation `json:"treatmentLocation" binding:"required"` // 治疗地点 | ||||
| 	HappenedAt        time.Time                               `json:"happenedAt" binding:"required"`        // 发生时间 | ||||
| 	Remarks           string                                  `json:"remarks"`                              // 备注 | ||||
| 	PenID             uint                                    `json:"pen_id" validate:"required"`             // 猪栏ID | ||||
| 	Quantity          int                                     `json:"quantity" validate:"required,min=1"`     // 康复猪数量 | ||||
| 	TreatmentLocation models.PigBatchSickPigTreatmentLocation `json:"treatment_location" validate:"required"` // 治疗地点 | ||||
| 	HappenedAt        time.Time                               `json:"happened_at" validate:"required"`        // 发生时间 | ||||
| 	Remarks           string                                  `json:"remarks"`                                // 备注 | ||||
| } | ||||
|  | ||||
| // RecordSickPigDeathRequest 用于记录病猪死亡事件的请求体 | ||||
| type RecordSickPigDeathRequest struct { | ||||
| 	PenID             uint                                    `json:"penID" binding:"required"`             // 猪栏ID | ||||
| 	Quantity          int                                     `json:"quantity" binding:"required,min=1"`    // 死亡猪数量 | ||||
| 	TreatmentLocation models.PigBatchSickPigTreatmentLocation `json:"treatmentLocation" binding:"required"` // 治疗地点 | ||||
| 	HappenedAt        time.Time                               `json:"happenedAt" binding:"required"`        // 发生时间 | ||||
| 	Remarks           string                                  `json:"remarks"`                              // 备注 | ||||
| 	PenID             uint                                    `json:"pen_id" validate:"required"`             // 猪栏ID | ||||
| 	Quantity          int                                     `json:"quantity" validate:"required,min=1"`     // 死亡猪数量 | ||||
| 	TreatmentLocation models.PigBatchSickPigTreatmentLocation `json:"treatment_location" validate:"required"` // 治疗地点 | ||||
| 	HappenedAt        time.Time                               `json:"happened_at" validate:"required"`        // 发生时间 | ||||
| 	Remarks           string                                  `json:"remarks"`                                // 备注 | ||||
| } | ||||
|  | ||||
| // RecordSickPigCullRequest 用于记录病猪淘汰事件的请求体 | ||||
| type RecordSickPigCullRequest struct { | ||||
| 	PenID             uint                                    `json:"penID" binding:"required"`             // 猪栏ID | ||||
| 	Quantity          int                                     `json:"quantity" binding:"required,min=1"`    // 淘汰猪数量 | ||||
| 	TreatmentLocation models.PigBatchSickPigTreatmentLocation `json:"treatmentLocation" binding:"required"` // 治疗地点 | ||||
| 	HappenedAt        time.Time                               `json:"happenedAt" binding:"required"`        // 发生时间 | ||||
| 	Remarks           string                                  `json:"remarks"`                              // 备注 | ||||
| 	PenID             uint                                    `json:"pen_id" validate:"required"`             // 猪栏ID | ||||
| 	Quantity          int                                     `json:"quantity" validate:"required,min=1"`     // 淘汰猪数量 | ||||
| 	TreatmentLocation models.PigBatchSickPigTreatmentLocation `json:"treatment_location" validate:"required"` // 治疗地点 | ||||
| 	HappenedAt        time.Time                               `json:"happened_at" validate:"required"`        // 发生时间 | ||||
| 	Remarks           string                                  `json:"remarks"`                                // 备注 | ||||
| } | ||||
|  | ||||
| // RecordDeathRequest 用于记录正常猪只死亡事件的请求体 | ||||
| type RecordDeathRequest struct { | ||||
| 	PenID      uint      `json:"penID" binding:"required"`          // 猪栏ID | ||||
| 	Quantity   int       `json:"quantity" binding:"required,min=1"` // 死亡猪数量 | ||||
| 	HappenedAt time.Time `json:"happenedAt" binding:"required"`     // 发生时间 | ||||
| 	Remarks    string    `json:"remarks"`                           // 备注 | ||||
| 	PenID      uint      `json:"pen_id" validate:"required"`         // 猪栏ID | ||||
| 	Quantity   int       `json:"quantity" validate:"required,min=1"` // 死亡猪数量 | ||||
| 	HappenedAt time.Time `json:"happened_at" validate:"required"`    // 发生时间 | ||||
| 	Remarks    string    `json:"remarks"`                            // 备注 | ||||
| } | ||||
|  | ||||
| // RecordCullRequest 用于记录正常猪只淘汰事件的请求体 | ||||
| type RecordCullRequest struct { | ||||
| 	PenID      uint      `json:"penID" binding:"required"`          // 猪栏ID | ||||
| 	Quantity   int       `json:"quantity" binding:"required,min=1"` // 淘汰猪数量 | ||||
| 	HappenedAt time.Time `json:"happenedAt" binding:"required"`     // 发生时间 | ||||
| 	Remarks    string    `json:"remarks"`                           // 备注 | ||||
| 	PenID      uint      `json:"pen_id" validate:"required"`         // 猪栏ID | ||||
| 	Quantity   int       `json:"quantity" validate:"required,min=1"` // 淘汰猪数量 | ||||
| 	HappenedAt time.Time `json:"happened_at" validate:"required"`    // 发生时间 | ||||
| 	Remarks    string    `json:"remarks"`                            // 备注 | ||||
| } | ||||
|   | ||||
| @@ -22,32 +22,32 @@ type PenResponse struct { | ||||
|  | ||||
| // CreatePigHouseRequest 定义了创建猪舍的请求结构 | ||||
| type CreatePigHouseRequest struct { | ||||
| 	Name        string `json:"name" binding:"required"` | ||||
| 	Name        string `json:"name" validate:"required"` | ||||
| 	Description string `json:"description"` | ||||
| } | ||||
|  | ||||
| // UpdatePigHouseRequest 定义了更新猪舍的请求结构 | ||||
| type UpdatePigHouseRequest struct { | ||||
| 	Name        string `json:"name" binding:"required"` | ||||
| 	Name        string `json:"name" validate:"required"` | ||||
| 	Description string `json:"description"` | ||||
| } | ||||
|  | ||||
| // CreatePenRequest 定义了创建猪栏的请求结构 | ||||
| type CreatePenRequest struct { | ||||
| 	PenNumber string `json:"pen_number" binding:"required"` | ||||
| 	HouseID   uint   `json:"house_id" binding:"required"` | ||||
| 	Capacity  int    `json:"capacity" binding:"required"` | ||||
| 	PenNumber string `json:"pen_number" validate:"required"` | ||||
| 	HouseID   uint   `json:"house_id" validate:"required"` | ||||
| 	Capacity  int    `json:"capacity" validate:"required"` | ||||
| } | ||||
|  | ||||
| // UpdatePenRequest 定义了更新猪栏的请求结构 | ||||
| type UpdatePenRequest struct { | ||||
| 	PenNumber string           `json:"pen_number" binding:"required"` | ||||
| 	HouseID   uint             `json:"house_id" binding:"required"` | ||||
| 	Capacity  int              `json:"capacity" binding:"required"` | ||||
| 	Status    models.PenStatus `json:"status" binding:"required,oneof=空闲 使用中 病猪栏 康复栏 清洗消毒 维修中"` // 添加oneof校验 | ||||
| 	PenNumber string           `json:"pen_number" validate:"required"` | ||||
| 	HouseID   uint             `json:"house_id" validate:"required"` | ||||
| 	Capacity  int              `json:"capacity" validate:"required"` | ||||
| 	Status    models.PenStatus `json:"status" validate:"required,oneof=空闲 使用中 病猪栏 康复栏 清洗消毒 维修中"` // 添加oneof校验 | ||||
| } | ||||
|  | ||||
| // UpdatePenStatusRequest 定义了更新猪栏状态的请求结构 | ||||
| type UpdatePenStatusRequest struct { | ||||
| 	Status models.PenStatus `json:"status" binding:"required,oneof=空闲 使用中 病猪栏 康复栏 清洗消毒 维修中" example:"病猪栏"` | ||||
| 	Status models.PenStatus `json:"status" validate:"required,oneof=空闲 使用中 病猪栏 康复栏 清洗消毒 维修中" example:"病猪栏"` | ||||
| } | ||||
|   | ||||
| @@ -17,6 +17,7 @@ func NewPlanToResponse(plan *models.Plan) (*PlanResponse, error) { | ||||
| 		ID:             plan.ID, | ||||
| 		Name:           plan.Name, | ||||
| 		Description:    plan.Description, | ||||
| 		PlanType:       plan.PlanType, | ||||
| 		ExecutionType:  plan.ExecutionType, | ||||
| 		Status:         plan.Status, | ||||
| 		ExecuteNum:     plan.ExecuteNum, | ||||
| @@ -64,7 +65,7 @@ func NewPlanFromCreateRequest(req *CreatePlanRequest) (*models.Plan, error) { | ||||
| 		ExecutionType:  req.ExecutionType, | ||||
| 		ExecuteNum:     req.ExecuteNum, | ||||
| 		CronExpression: req.CronExpression, | ||||
| 		// ContentType 在控制器中设置,此处不再处理 | ||||
| 		// ContentType 和 PlanType 在控制器中设置,此处不再处理 | ||||
| 	} | ||||
|  | ||||
| 	// 处理子计划 (通过ID引用) | ||||
| @@ -116,7 +117,7 @@ func NewPlanFromUpdateRequest(req *UpdatePlanRequest) (*models.Plan, error) { | ||||
| 		ExecutionType:  req.ExecutionType, | ||||
| 		ExecuteNum:     req.ExecuteNum, | ||||
| 		CronExpression: req.CronExpression, | ||||
| 		// ContentType 在控制器中设置,此处不再处理 | ||||
| 		// ContentType 和 PlanType 在控制器中设置,此处不再处理 | ||||
| 	} | ||||
|  | ||||
| 	// 处理子计划 (通过ID引用) | ||||
|   | ||||
| @@ -1,16 +1,26 @@ | ||||
| package dto | ||||
|  | ||||
| import "git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||
| import ( | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" | ||||
| ) | ||||
|  | ||||
| // ListPlansQuery 定义了获取计划列表时的查询参数 | ||||
| type ListPlansQuery struct { | ||||
| 	PlanType repository.PlanTypeFilter `json:"plan_type" query:"planType"`  // 计划类型 | ||||
| 	Page     int                       `json:"page" query:"page"`           // 页码 | ||||
| 	PageSize int                       `json:"page_size" query:"page_size"` // 每页大小 | ||||
| } | ||||
|  | ||||
| // CreatePlanRequest 定义创建计划请求的结构体 | ||||
| type CreatePlanRequest struct { | ||||
| 	Name           string                   `json:"name" binding:"required" example:"猪舍温度控制计划"` | ||||
| 	Name           string                   `json:"name" validate:"required" example:"猪舍温度控制计划"` | ||||
| 	Description    string                   `json:"description" example:"根据温度自动调节风扇和加热器"` | ||||
| 	ExecutionType  models.PlanExecutionType `json:"execution_type" binding:"required" example:"自动"` | ||||
| 	ExecuteNum     uint                     `json:"execute_num,omitempty" example:"10"` | ||||
| 	CronExpression string                   `json:"cron_expression" example:"0 0 6 * * *"` | ||||
| 	SubPlanIDs     []uint                   `json:"sub_plan_ids,omitempty"` | ||||
| 	Tasks          []TaskRequest            `json:"tasks,omitempty"` | ||||
| 	ExecutionType  models.PlanExecutionType `json:"execution_type" validate:"required" example:"自动"` | ||||
| 	ExecuteNum     uint                     `json:"execute_num,omitempty" validate:"omitempty,min=0" example:"10"` | ||||
| 	CronExpression string                   `json:"cron_expression" validate:"omitempty,cron" example:"0 0 6 * * *"` | ||||
| 	SubPlanIDs     []uint                   `json:"sub_plan_ids,omitempty" validate:"omitempty,dive"` | ||||
| 	Tasks          []TaskRequest            `json:"tasks,omitempty" validate:"omitempty,dive"` | ||||
| } | ||||
|  | ||||
| // PlanResponse 定义计划详情响应的结构体 | ||||
| @@ -18,6 +28,7 @@ type PlanResponse struct { | ||||
| 	ID             uint                     `json:"id" example:"1"` | ||||
| 	Name           string                   `json:"name" example:"猪舍温度控制计划"` | ||||
| 	Description    string                   `json:"description" example:"根据温度自动调节风扇和加热器"` | ||||
| 	PlanType       models.PlanType          `json:"plan_type" example:"自定义任务"` | ||||
| 	ExecutionType  models.PlanExecutionType `json:"execution_type" example:"自动"` | ||||
| 	Status         models.PlanStatus        `json:"status" example:"已启用"` | ||||
| 	ExecuteNum     uint                     `json:"execute_num" example:"10"` | ||||
| @@ -31,18 +42,18 @@ type PlanResponse struct { | ||||
| // ListPlansResponse 定义获取计划列表响应的结构体 | ||||
| type ListPlansResponse struct { | ||||
| 	Plans []PlanResponse `json:"plans"` | ||||
| 	Total int            `json:"total" example:"100"` | ||||
| 	Total int64          `json:"total" example:"100"` | ||||
| } | ||||
|  | ||||
| // UpdatePlanRequest 定义更新计划请求的结构体 | ||||
| type UpdatePlanRequest struct { | ||||
| 	Name           string                   `json:"name" example:"猪舍温度控制计划V2"` | ||||
| 	Description    string                   `json:"description" example:"更新后的描述"` | ||||
| 	ExecutionType  models.PlanExecutionType `json:"execution_type" binding:"required" example:"自动"` | ||||
| 	ExecuteNum     uint                     `json:"execute_num,omitempty" example:"10"` | ||||
| 	CronExpression string                   `json:"cron_expression" example:"0 0 6 * * *"` | ||||
| 	SubPlanIDs     []uint                   `json:"sub_plan_ids,omitempty"` | ||||
| 	Tasks          []TaskRequest            `json:"tasks,omitempty"` | ||||
| 	ExecutionType  models.PlanExecutionType `json:"execution_type" validate:"required" example:"自动"` | ||||
| 	ExecuteNum     uint                     `json:"execute_num,omitempty" validate:"omitempty,min=0" example:"10"` | ||||
| 	CronExpression string                   `json:"cron_expression" validate:"omitempty,cron" example:"0 0 6 * * *"` | ||||
| 	SubPlanIDs     []uint                   `json:"sub_plan_ids,omitempty" validate:"omitempty,dive"` | ||||
| 	Tasks          []TaskRequest            `json:"tasks,omitempty" validate:"omitempty,dive"` | ||||
| } | ||||
|  | ||||
| // SubPlanResponse 定义子计划响应结构体 | ||||
|   | ||||
| @@ -2,15 +2,15 @@ package dto | ||||
|  | ||||
| // CreateUserRequest 定义创建用户请求的结构体 | ||||
| type CreateUserRequest struct { | ||||
| 	Username string `json:"username" binding:"required" example:"newuser"` | ||||
| 	Password string `json:"password" binding:"required" example:"password123"` | ||||
| 	Username string `json:"username" validate:"required" example:"newuser"` | ||||
| 	Password string `json:"password" validate:"required" example:"password123"` | ||||
| } | ||||
|  | ||||
| // LoginRequest 定义登录请求的结构体 | ||||
| type LoginRequest struct { | ||||
| 	// Identifier 可以是用户名、邮箱、手机号、微信号或飞书账号 | ||||
| 	Identifier string `json:"identifier" binding:"required" example:"testuser"` | ||||
| 	Password   string `json:"password" binding:"required" example:"password123"` | ||||
| 	Identifier string `json:"identifier" validate:"required" example:"testuser"` | ||||
| 	Password   string `json:"password" validate:"required" example:"password123"` | ||||
| } | ||||
|  | ||||
| // CreateUserResponse 定义创建用户成功响应的结构体 | ||||
|   | ||||
| @@ -1,117 +1,59 @@ | ||||
| package middleware | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"encoding/json" | ||||
| 	"io" | ||||
| 	"strconv" | ||||
|  | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/domain/audit" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"github.com/labstack/echo/v4" | ||||
| ) | ||||
|  | ||||
| type auditResponse struct { | ||||
| 	Code    int    `json:"code"` | ||||
| 	Message string `json:"message"` | ||||
| } | ||||
| // AuditLogMiddleware 创建一个Echo中间件,用于在请求结束后记录用户操作审计日志。 | ||||
| // 它依赖于控制器通过调用 SendSuccessWithAudit 或 SendErrorWithAudit 在上下文中设置的审计信息。 | ||||
| func AuditLogMiddleware(auditService audit.Service) echo.MiddlewareFunc { | ||||
| 	return func(next echo.HandlerFunc) echo.HandlerFunc { | ||||
| 		return func(c echo.Context) error { | ||||
| 			// 首先执行请求链中的后续处理程序(即业务控制器) | ||||
| 			err := next(c) | ||||
|  | ||||
| // AuditLogMiddleware 创建一个Gin中间件,用于在请求结束后记录用户操作审计日志 | ||||
| func AuditLogMiddleware(auditService audit.Service) gin.HandlerFunc { | ||||
| 	return func(c *gin.Context) { | ||||
| 		// 使用自定义的 response body writer 来捕获响应体 | ||||
| 		blw := &bodyLogWriter{body: bytes.NewBufferString(""), ResponseWriter: c.Writer} | ||||
| 		c.Writer = blw | ||||
| 			// --- 在这里,请求已经处理完毕 --- | ||||
|  | ||||
| 		// 首先执行请求链中的后续处理程序(即业务控制器) | ||||
| 		c.Next() | ||||
|  | ||||
| 		// --- 在这里,请求已经处理完毕 --- | ||||
|  | ||||
| 		// 从上下文中尝试获取由控制器设置的业务审计信息 | ||||
| 		actionType, exists := c.Get(models.ContextAuditActionType.String()) | ||||
| 		if !exists { | ||||
| 			// 如果上下文中没有 actionType,说明此接口无需记录审计日志,直接返回 | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		// 从 Gin Context 中获取用户对象 | ||||
| 		userCtx, userExists := c.Get(models.ContextUserKey.String()) | ||||
| 		var user *models.User | ||||
| 		if userExists { | ||||
| 			user, _ = userCtx.(*models.User) | ||||
| 		} | ||||
|  | ||||
| 		// 构建 RequestContext | ||||
| 		reqCtx := audit.RequestContext{ | ||||
| 			ClientIP:   c.ClientIP(), | ||||
| 			HTTPPath:   c.Request.URL.Path, | ||||
| 			HTTPMethod: c.Request.Method, | ||||
| 		} | ||||
|  | ||||
| 		// 获取其他审计信息 | ||||
| 		description, _ := c.Get(models.ContextAuditDescription.String()) | ||||
| 		targetResource, _ := c.Get(models.ContextAuditTargetResource.String()) | ||||
|  | ||||
| 		// 默认操作状态为成功 | ||||
| 		status := models.AuditStatusSuccess | ||||
| 		resultDetails := "" | ||||
|  | ||||
| 		// 尝试从捕获的响应体中解析平台响应 | ||||
| 		var platformResponse auditResponse | ||||
| 		if err := json.Unmarshal(blw.body.Bytes(), &platformResponse); err == nil { | ||||
| 			// 如果解析成功,根据平台状态码判断操作是否失败 | ||||
| 			// 成功状态码范围是 2000-2999 | ||||
| 			if platformResponse.Code < 2000 || platformResponse.Code >= 3000 { | ||||
| 				status = models.AuditStatusFailed | ||||
| 				resultDetails = platformResponse.Message | ||||
| 			// 从上下文中尝试获取由控制器设置的业务审计信息 | ||||
| 			actionType, exists := c.Get(models.ContextAuditActionType.String()).(string) | ||||
| 			if !exists || actionType == "" { | ||||
| 				// 如果上下文中没有 actionType,说明此接口无需记录审计日志,直接返回 | ||||
| 				return err | ||||
| 			} | ||||
| 		} else { | ||||
| 			// 如果响应体不是预期的平台响应格式,或者解析失败,则记录原始HTTP状态码作为详情 | ||||
| 			// 并且如果HTTP状态码不是2xx,则标记为失败 | ||||
| 			if c.Writer.Status() < 200 || c.Writer.Status() >= 300 { | ||||
| 				status = models.AuditStatusFailed | ||||
| 			} | ||||
| 			resultDetails = "HTTP Status: " + strconv.Itoa(c.Writer.Status()) + ", Body Parse Error: " + err.Error() | ||||
| 		} | ||||
|  | ||||
| 		// 调用审计服务记录日志(异步) | ||||
| 		auditService.LogAction( | ||||
| 			user, | ||||
| 			reqCtx, | ||||
| 			actionType.(string), | ||||
| 			description.(string), | ||||
| 			targetResource, | ||||
| 			status, | ||||
| 			resultDetails, | ||||
| 		) | ||||
| 			// 从 Context 中获取用户对象 | ||||
| 			var user *models.User | ||||
| 			if userCtx := c.Get(models.ContextUserKey.String()); userCtx != nil { | ||||
| 				user, _ = userCtx.(*models.User) | ||||
| 			} | ||||
|  | ||||
| 			// 构建 RequestContext | ||||
| 			reqCtx := audit.RequestContext{ | ||||
| 				ClientIP:   c.RealIP(), | ||||
| 				HTTPPath:   c.Request().URL.Path, | ||||
| 				HTTPMethod: c.Request().Method, | ||||
| 			} | ||||
|  | ||||
| 			// 直接从上下文中获取所有其他审计信息 | ||||
| 			description, _ := c.Get(models.ContextAuditDescription.String()).(string) | ||||
| 			targetResource := c.Get(models.ContextAuditTargetResource.String()) | ||||
| 			status, _ := c.Get(models.ContextAuditStatus.String()).(models.AuditStatus) | ||||
| 			resultDetails, _ := c.Get(models.ContextAuditResultDetails.String()).(string) | ||||
|  | ||||
| 			// 调用审计服务记录日志(异步) | ||||
| 			auditService.LogAction( | ||||
| 				user, | ||||
| 				reqCtx, | ||||
| 				actionType, | ||||
| 				description, | ||||
| 				targetResource, | ||||
| 				status, | ||||
| 				resultDetails, | ||||
| 			) | ||||
|  | ||||
| 			return err | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // bodyLogWriter 是一个自定义的 gin.ResponseWriter,用于捕获响应体 | ||||
| // 这对于在操作失败时记录详细的错误信息非常有用 | ||||
| type bodyLogWriter struct { | ||||
| 	gin.ResponseWriter | ||||
| 	body *bytes.Buffer | ||||
| } | ||||
|  | ||||
| func (w bodyLogWriter) Write(b []byte) (int, error) { | ||||
| 	w.body.Write(b) | ||||
| 	return w.ResponseWriter.Write(b) | ||||
| } | ||||
|  | ||||
| func (w bodyLogWriter) WriteString(s string) (int, error) { | ||||
| 	w.body.WriteString(s) | ||||
| 	return w.ResponseWriter.WriteString(s) | ||||
| } | ||||
|  | ||||
| // ReadBody 用于安全地读取请求体,并防止其被重复读取 | ||||
| func ReadBody(c *gin.Context) ([]byte, error) { | ||||
| 	bodyBytes, err := io.ReadAll(c.Request.Body) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	// 将读取的内容放回 Body 中,以便后续的处理函数可以再次读取 | ||||
| 	c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) | ||||
| 	return bodyBytes, nil | ||||
| } | ||||
|   | ||||
| @@ -1,61 +1,60 @@ | ||||
| // Package middleware 存放 gin 中间件 | ||||
| // Package middleware 存放中间件 | ||||
| package middleware | ||||
|  | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"net/http" | ||||
| 	"strings" | ||||
|  | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/controller" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/domain/token" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" | ||||
| 	"github.com/gin-gonic/gin" | ||||
| 	"github.com/labstack/echo/v4" | ||||
| 	"gorm.io/gorm" | ||||
| ) | ||||
|  | ||||
| // AuthMiddleware 创建一个Gin中间件,用于JWT身份验证 | ||||
| // AuthMiddleware 创建一个Echo中间件,用于JWT身份验证 | ||||
| // 它依赖于 TokenService 来解析和验证 token,并使用 UserRepository 来获取完整的用户信息 | ||||
| func AuthMiddleware(tokenService token.TokenService, userRepo repository.UserRepository) gin.HandlerFunc { | ||||
| 	return func(c *gin.Context) { | ||||
| 		// 从 Authorization header 获取 token | ||||
| 		authHeader := c.GetHeader("Authorization") | ||||
| 		if authHeader == "" { | ||||
| 			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "请求未包含授权标头"}) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		// 授权标头的格式应为 "Bearer <token>" | ||||
| 		parts := strings.Split(authHeader, " ") | ||||
| 		if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { | ||||
| 			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "授权标头格式不正确"}) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		tokenString := parts[1] | ||||
|  | ||||
| 		// 解析和验证 token | ||||
| 		claims, err := tokenService.ParseToken(tokenString) | ||||
| 		if err != nil { | ||||
| 			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "无效的Token"}) | ||||
| 			return | ||||
| 		} | ||||
|  | ||||
| 		// 根据 token 中的用户ID,从数据库中获取完整的用户信息 | ||||
| 		user, err := userRepo.FindByID(claims.UserID) | ||||
| 		if err != nil { | ||||
| 			if err == gorm.ErrRecordNotFound { | ||||
| 				// Token有效,但对应的用户已不存在 | ||||
| 				c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "授权用户不存在"}) | ||||
| 				return | ||||
| func AuthMiddleware(tokenService token.Service, userRepo repository.UserRepository) echo.MiddlewareFunc { | ||||
| 	return func(next echo.HandlerFunc) echo.HandlerFunc { | ||||
| 		return func(c echo.Context) error { | ||||
| 			// 从 Authorization header 获取 token | ||||
| 			authHeader := c.Request().Header.Get("Authorization") | ||||
| 			if authHeader == "" { | ||||
| 				return controller.SendErrorWithStatus(c, http.StatusUnauthorized, controller.CodeUnauthorized, "请求未包含授权标头") | ||||
| 			} | ||||
| 			// 其他数据库查询错误 | ||||
| 			c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "获取用户信息失败"}) | ||||
| 			return | ||||
|  | ||||
| 			// 授权标头的格式应为 "Bearer <token>" | ||||
| 			parts := strings.Split(authHeader, " ") | ||||
| 			if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { | ||||
| 				return controller.SendErrorWithStatus(c, http.StatusUnauthorized, controller.CodeUnauthorized, "授权标头格式不正确") | ||||
| 			} | ||||
|  | ||||
| 			tokenString := parts[1] | ||||
|  | ||||
| 			// 解析和验证 token | ||||
| 			claims, err := tokenService.ParseToken(tokenString) | ||||
| 			if err != nil { | ||||
| 				return controller.SendErrorWithStatus(c, http.StatusUnauthorized, controller.CodeUnauthorized, "无效的Token") | ||||
| 			} | ||||
|  | ||||
| 			// 根据 token 中的用户ID,从数据库中获取完整的用户信息 | ||||
| 			user, err := userRepo.FindByID(claims.UserID) | ||||
| 			if err != nil { | ||||
| 				if errors.Is(err, gorm.ErrRecordNotFound) { | ||||
| 					// Token有效,但对应的用户已不存在 | ||||
| 					return controller.SendErrorWithStatus(c, http.StatusUnauthorized, controller.CodeUnauthorized, "授权用户不存在") | ||||
| 				} | ||||
| 				// 其他数据库查询错误 | ||||
| 				return controller.SendErrorWithStatus(c, http.StatusInternalServerError, controller.CodeInternalError, "获取用户信息失败") | ||||
| 			} | ||||
|  | ||||
| 			// 将完整的用户对象存储在 context 中,以便后续的处理函数使用 | ||||
| 			c.Set(models.ContextUserKey.String(), user) | ||||
|  | ||||
| 			// 继续处理请求链中的下一个处理程序 | ||||
| 			return next(c) | ||||
| 		} | ||||
|  | ||||
| 		// 将完整的用户对象存储在 context 中,以便后续的处理函数使用 | ||||
| 		c.Set(models.ContextUserKey.String(), user) | ||||
|  | ||||
| 		// 继续处理请求链中的下一个处理程序 | ||||
| 		c.Next() | ||||
| 	} | ||||
| } | ||||
|   | ||||
							
								
								
									
										373
									
								
								internal/app/service/device_service.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										373
									
								
								internal/app/service/device_service.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,373 @@ | ||||
| package service | ||||
|  | ||||
| import ( | ||||
| 	"encoding/json" | ||||
| 	"errors" | ||||
| 	"strconv" | ||||
|  | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/dto" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/domain/device" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" | ||||
| ) | ||||
|  | ||||
| // DeviceService 定义了应用层的设备服务接口,用于协调设备相关的业务逻辑。 | ||||
| type DeviceService interface { | ||||
| 	CreateDevice(req *dto.CreateDeviceRequest) (*dto.DeviceResponse, error) | ||||
| 	GetDevice(id string) (*dto.DeviceResponse, error) | ||||
| 	ListDevices() ([]*dto.DeviceResponse, error) | ||||
| 	UpdateDevice(id string, req *dto.UpdateDeviceRequest) (*dto.DeviceResponse, error) | ||||
| 	DeleteDevice(id string) error | ||||
| 	ManualControl(id string, req *dto.ManualControlDeviceRequest) error | ||||
|  | ||||
| 	CreateAreaController(req *dto.CreateAreaControllerRequest) (*dto.AreaControllerResponse, error) | ||||
| 	GetAreaController(id string) (*dto.AreaControllerResponse, error) | ||||
| 	ListAreaControllers() ([]*dto.AreaControllerResponse, error) | ||||
| 	UpdateAreaController(id string, req *dto.UpdateAreaControllerRequest) (*dto.AreaControllerResponse, error) | ||||
| 	DeleteAreaController(id string) error | ||||
|  | ||||
| 	CreateDeviceTemplate(req *dto.CreateDeviceTemplateRequest) (*dto.DeviceTemplateResponse, error) | ||||
| 	GetDeviceTemplate(id string) (*dto.DeviceTemplateResponse, error) | ||||
| 	ListDeviceTemplates() ([]*dto.DeviceTemplateResponse, error) | ||||
| 	UpdateDeviceTemplate(id string, req *dto.UpdateDeviceTemplateRequest) (*dto.DeviceTemplateResponse, error) | ||||
| 	DeleteDeviceTemplate(id string) error | ||||
| } | ||||
|  | ||||
| // deviceService 是 DeviceService 接口的具体实现。 | ||||
| type deviceService struct { | ||||
| 	deviceRepo         repository.DeviceRepository | ||||
| 	areaControllerRepo repository.AreaControllerRepository | ||||
| 	deviceTemplateRepo repository.DeviceTemplateRepository | ||||
| 	deviceDomainSvc    device.Service // 依赖领域服务 | ||||
| } | ||||
|  | ||||
| // NewDeviceService 创建一个新的 DeviceService 实例。 | ||||
| func NewDeviceService( | ||||
| 	deviceRepo repository.DeviceRepository, | ||||
| 	areaControllerRepo repository.AreaControllerRepository, | ||||
| 	deviceTemplateRepo repository.DeviceTemplateRepository, | ||||
| 	deviceDomainSvc device.Service, | ||||
| ) DeviceService { | ||||
| 	return &deviceService{ | ||||
| 		deviceRepo:         deviceRepo, | ||||
| 		areaControllerRepo: areaControllerRepo, | ||||
| 		deviceTemplateRepo: deviceTemplateRepo, | ||||
| 		deviceDomainSvc:    deviceDomainSvc, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // --- Devices --- | ||||
|  | ||||
| func (s *deviceService) CreateDevice(req *dto.CreateDeviceRequest) (*dto.DeviceResponse, error) { | ||||
| 	propertiesJSON, err := json.Marshal(req.Properties) | ||||
| 	if err != nil { | ||||
| 		return nil, err // Consider wrapping this error for better context | ||||
| 	} | ||||
|  | ||||
| 	device := &models.Device{ | ||||
| 		Name:             req.Name, | ||||
| 		DeviceTemplateID: req.DeviceTemplateID, | ||||
| 		AreaControllerID: req.AreaControllerID, | ||||
| 		Location:         req.Location, | ||||
| 		Properties:       propertiesJSON, | ||||
| 	} | ||||
|  | ||||
| 	if err := device.SelfCheck(); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	if err := s.deviceRepo.Create(device); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	createdDevice, err := s.deviceRepo.FindByID(device.ID) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return dto.NewDeviceResponse(createdDevice) | ||||
| } | ||||
|  | ||||
| func (s *deviceService) GetDevice(id string) (*dto.DeviceResponse, error) { | ||||
| 	device, err := s.deviceRepo.FindByIDString(id) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return dto.NewDeviceResponse(device) | ||||
| } | ||||
|  | ||||
| func (s *deviceService) ListDevices() ([]*dto.DeviceResponse, error) { | ||||
| 	devices, err := s.deviceRepo.ListAll() | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return dto.NewListDeviceResponse(devices) | ||||
| } | ||||
|  | ||||
| func (s *deviceService) UpdateDevice(id string, req *dto.UpdateDeviceRequest) (*dto.DeviceResponse, error) { | ||||
| 	existingDevice, err := s.deviceRepo.FindByIDString(id) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	propertiesJSON, err := json.Marshal(req.Properties) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	existingDevice.Name = req.Name | ||||
| 	existingDevice.DeviceTemplateID = req.DeviceTemplateID | ||||
| 	existingDevice.AreaControllerID = req.AreaControllerID | ||||
| 	existingDevice.Location = req.Location | ||||
| 	existingDevice.Properties = propertiesJSON | ||||
|  | ||||
| 	if err := existingDevice.SelfCheck(); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	if err := s.deviceRepo.Update(existingDevice); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	updatedDevice, err := s.deviceRepo.FindByID(existingDevice.ID) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return dto.NewDeviceResponse(updatedDevice) | ||||
| } | ||||
|  | ||||
| func (s *deviceService) DeleteDevice(id string) error { | ||||
| 	idUint, err := strconv.ParseUint(id, 10, 64) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	// Check if device exists before deleting | ||||
| 	_, err = s.deviceRepo.FindByID(uint(idUint)) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	return s.deviceRepo.Delete(uint(idUint)) | ||||
| } | ||||
|  | ||||
| func (s *deviceService) ManualControl(id string, req *dto.ManualControlDeviceRequest) error { | ||||
| 	dev, err := s.deviceRepo.FindByIDString(id) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	if req.Action == nil { | ||||
| 		return s.deviceDomainSvc.Collect(dev.AreaControllerID, []*models.Device{dev}) | ||||
| 	} else { | ||||
| 		action := device.DeviceActionStart | ||||
| 		switch *req.Action { | ||||
| 		case "off": | ||||
| 			action = device.DeviceActionStop | ||||
| 		case "on": | ||||
| 			action = device.DeviceActionStart | ||||
| 		default: | ||||
| 			return errors.New("invalid action") | ||||
| 		} | ||||
| 		return s.deviceDomainSvc.Switch(dev, action) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // --- Area Controllers --- | ||||
|  | ||||
| func (s *deviceService) CreateAreaController(req *dto.CreateAreaControllerRequest) (*dto.AreaControllerResponse, error) { | ||||
| 	propertiesJSON, err := json.Marshal(req.Properties) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	ac := &models.AreaController{ | ||||
| 		Name:       req.Name, | ||||
| 		NetworkID:  req.NetworkID, | ||||
| 		Location:   req.Location, | ||||
| 		Properties: propertiesJSON, | ||||
| 	} | ||||
|  | ||||
| 	if err := ac.SelfCheck(); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	if err := s.areaControllerRepo.Create(ac); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return dto.NewAreaControllerResponse(ac) | ||||
| } | ||||
|  | ||||
| func (s *deviceService) GetAreaController(id string) (*dto.AreaControllerResponse, error) { | ||||
| 	idUint, err := strconv.ParseUint(id, 10, 64) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	ac, err := s.areaControllerRepo.FindByID(uint(idUint)) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return dto.NewAreaControllerResponse(ac) | ||||
| } | ||||
|  | ||||
| func (s *deviceService) ListAreaControllers() ([]*dto.AreaControllerResponse, error) { | ||||
| 	acs, err := s.areaControllerRepo.ListAll() | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return dto.NewListAreaControllerResponse(acs) | ||||
| } | ||||
|  | ||||
| func (s *deviceService) UpdateAreaController(id string, req *dto.UpdateAreaControllerRequest) (*dto.AreaControllerResponse, error) { | ||||
| 	idUint, err := strconv.ParseUint(id, 10, 64) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	existingAC, err := s.areaControllerRepo.FindByID(uint(idUint)) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	propertiesJSON, err := json.Marshal(req.Properties) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	existingAC.Name = req.Name | ||||
| 	existingAC.NetworkID = req.NetworkID | ||||
| 	existingAC.Location = req.Location | ||||
| 	existingAC.Properties = propertiesJSON | ||||
|  | ||||
| 	if err := existingAC.SelfCheck(); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	if err := s.areaControllerRepo.Update(existingAC); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return dto.NewAreaControllerResponse(existingAC) | ||||
| } | ||||
|  | ||||
| func (s *deviceService) DeleteAreaController(id string) error { | ||||
| 	idUint, err := strconv.ParseUint(id, 10, 64) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	_, err = s.areaControllerRepo.FindByID(uint(idUint)) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	return s.areaControllerRepo.Delete(uint(idUint)) | ||||
| } | ||||
|  | ||||
| // --- Device Templates --- | ||||
|  | ||||
| func (s *deviceService) CreateDeviceTemplate(req *dto.CreateDeviceTemplateRequest) (*dto.DeviceTemplateResponse, error) { | ||||
| 	commandsJSON, err := json.Marshal(req.Commands) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	valuesJSON, err := json.Marshal(req.Values) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	deviceTemplate := &models.DeviceTemplate{ | ||||
| 		Name:         req.Name, | ||||
| 		Manufacturer: req.Manufacturer, | ||||
| 		Description:  req.Description, | ||||
| 		Category:     req.Category, | ||||
| 		Commands:     commandsJSON, | ||||
| 		Values:       valuesJSON, | ||||
| 	} | ||||
|  | ||||
| 	if err := deviceTemplate.SelfCheck(); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	if err := s.deviceTemplateRepo.Create(deviceTemplate); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return dto.NewDeviceTemplateResponse(deviceTemplate) | ||||
| } | ||||
|  | ||||
| func (s *deviceService) GetDeviceTemplate(id string) (*dto.DeviceTemplateResponse, error) { | ||||
| 	idUint, err := strconv.ParseUint(id, 10, 64) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	deviceTemplate, err := s.deviceTemplateRepo.FindByID(uint(idUint)) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return dto.NewDeviceTemplateResponse(deviceTemplate) | ||||
| } | ||||
|  | ||||
| func (s *deviceService) ListDeviceTemplates() ([]*dto.DeviceTemplateResponse, error) { | ||||
| 	deviceTemplates, err := s.deviceTemplateRepo.ListAll() | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return dto.NewListDeviceTemplateResponse(deviceTemplates) | ||||
| } | ||||
|  | ||||
| func (s *deviceService) UpdateDeviceTemplate(id string, req *dto.UpdateDeviceTemplateRequest) (*dto.DeviceTemplateResponse, error) { | ||||
| 	idUint, err := strconv.ParseUint(id, 10, 64) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	existingDeviceTemplate, err := s.deviceTemplateRepo.FindByID(uint(idUint)) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	commandsJSON, err := json.Marshal(req.Commands) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	valuesJSON, err := json.Marshal(req.Values) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	existingDeviceTemplate.Name = req.Name | ||||
| 	existingDeviceTemplate.Manufacturer = req.Manufacturer | ||||
| 	existingDeviceTemplate.Description = req.Description | ||||
| 	existingDeviceTemplate.Category = req.Category | ||||
| 	existingDeviceTemplate.Commands = commandsJSON | ||||
| 	existingDeviceTemplate.Values = valuesJSON | ||||
|  | ||||
| 	if err := existingDeviceTemplate.SelfCheck(); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	if err := s.deviceTemplateRepo.Update(existingDeviceTemplate); err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return dto.NewDeviceTemplateResponse(existingDeviceTemplate) | ||||
| } | ||||
|  | ||||
| func (s *deviceService) DeleteDeviceTemplate(id string) error { | ||||
| 	idUint, err := strconv.ParseUint(id, 10, 64) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	_, err = s.deviceTemplateRepo.FindByID(uint(idUint)) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	return s.deviceTemplateRepo.Delete(uint(idUint)) | ||||
| } | ||||
| @@ -1,29 +1,31 @@ | ||||
| package service | ||||
|  | ||||
| import ( | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/dto" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" | ||||
| ) | ||||
|  | ||||
| // MonitorService 定义了监控相关的业务逻辑服务接口 | ||||
| type MonitorService interface { | ||||
| 	ListSensorData(opts repository.SensorDataListOptions, page, pageSize int) ([]models.SensorData, int64, error) | ||||
| 	ListDeviceCommandLogs(opts repository.DeviceCommandLogListOptions, page, pageSize int) ([]models.DeviceCommandLog, int64, error) | ||||
| 	ListPlanExecutionLogs(opts repository.PlanExecutionLogListOptions, page, pageSize int) ([]models.PlanExecutionLog, int64, error) | ||||
| 	ListTaskExecutionLogs(opts repository.TaskExecutionLogListOptions, page, pageSize int) ([]models.TaskExecutionLog, int64, error) | ||||
| 	ListPendingCollections(opts repository.PendingCollectionListOptions, page, pageSize int) ([]models.PendingCollection, int64, error) | ||||
| 	ListUserActionLogs(opts repository.UserActionLogListOptions, page, pageSize int) ([]models.UserActionLog, int64, error) | ||||
| 	ListRawMaterialPurchases(opts repository.RawMaterialPurchaseListOptions, page, pageSize int) ([]models.RawMaterialPurchase, int64, error) | ||||
| 	ListRawMaterialStockLogs(opts repository.RawMaterialStockLogListOptions, page, pageSize int) ([]models.RawMaterialStockLog, int64, error) | ||||
| 	ListFeedUsageRecords(opts repository.FeedUsageRecordListOptions, page, pageSize int) ([]models.FeedUsageRecord, int64, error) | ||||
| 	ListMedicationLogs(opts repository.MedicationLogListOptions, page, pageSize int) ([]models.MedicationLog, int64, error) | ||||
| 	ListPigBatchLogs(opts repository.PigBatchLogListOptions, page, pageSize int) ([]models.PigBatchLog, int64, error) | ||||
| 	ListWeighingBatches(opts repository.WeighingBatchListOptions, page, pageSize int) ([]models.WeighingBatch, int64, error) | ||||
| 	ListWeighingRecords(opts repository.WeighingRecordListOptions, page, pageSize int) ([]models.WeighingRecord, int64, error) | ||||
| 	ListPigTransferLogs(opts repository.PigTransferLogListOptions, page, pageSize int) ([]models.PigTransferLog, int64, error) | ||||
| 	ListPigSickLogs(opts repository.PigSickLogListOptions, page, pageSize int) ([]models.PigSickLog, int64, error) | ||||
| 	ListPigPurchases(opts repository.PigPurchaseListOptions, page, pageSize int) ([]models.PigPurchase, int64, error) | ||||
| 	ListPigSales(opts repository.PigSaleListOptions, page, pageSize int) ([]models.PigSale, int64, error) | ||||
| 	ListSensorData(req *dto.ListSensorDataRequest) (*dto.ListSensorDataResponse, error) | ||||
| 	ListDeviceCommandLogs(req *dto.ListDeviceCommandLogRequest) (*dto.ListDeviceCommandLogResponse, error) | ||||
| 	ListPlanExecutionLogs(req *dto.ListPlanExecutionLogRequest) (*dto.ListPlanExecutionLogResponse, error) | ||||
| 	ListTaskExecutionLogs(req *dto.ListTaskExecutionLogRequest) (*dto.ListTaskExecutionLogResponse, error) | ||||
| 	ListPendingCollections(req *dto.ListPendingCollectionRequest) (*dto.ListPendingCollectionResponse, error) | ||||
| 	ListUserActionLogs(req *dto.ListUserActionLogRequest) (*dto.ListUserActionLogResponse, error) | ||||
| 	ListRawMaterialPurchases(req *dto.ListRawMaterialPurchaseRequest) (*dto.ListRawMaterialPurchaseResponse, error) | ||||
| 	ListRawMaterialStockLogs(req *dto.ListRawMaterialStockLogRequest) (*dto.ListRawMaterialStockLogResponse, error) | ||||
| 	ListFeedUsageRecords(req *dto.ListFeedUsageRecordRequest) (*dto.ListFeedUsageRecordResponse, error) | ||||
| 	ListMedicationLogs(req *dto.ListMedicationLogRequest) (*dto.ListMedicationLogResponse, error) | ||||
| 	ListPigBatchLogs(req *dto.ListPigBatchLogRequest) (*dto.ListPigBatchLogResponse, error) | ||||
| 	ListWeighingBatches(req *dto.ListWeighingBatchRequest) (*dto.ListWeighingBatchResponse, error) | ||||
| 	ListWeighingRecords(req *dto.ListWeighingRecordRequest) (*dto.ListWeighingRecordResponse, error) | ||||
| 	ListPigTransferLogs(req *dto.ListPigTransferLogRequest) (*dto.ListPigTransferLogResponse, error) | ||||
| 	ListPigSickLogs(req *dto.ListPigSickLogRequest) (*dto.ListPigSickLogResponse, error) | ||||
| 	ListPigPurchases(req *dto.ListPigPurchaseRequest) (*dto.ListPigPurchaseResponse, error) | ||||
| 	ListPigSales(req *dto.ListPigSaleRequest) (*dto.ListPigSaleResponse, error) | ||||
| 	ListNotifications(req *dto.ListNotificationRequest) (*dto.ListNotificationResponse, error) | ||||
| } | ||||
|  | ||||
| // monitorService 是 MonitorService 接口的具体实现 | ||||
| @@ -31,6 +33,7 @@ type monitorService struct { | ||||
| 	sensorDataRepo        repository.SensorDataRepository | ||||
| 	deviceCommandLogRepo  repository.DeviceCommandLogRepository | ||||
| 	executionLogRepo      repository.ExecutionLogRepository | ||||
| 	planRepository        repository.PlanRepository | ||||
| 	pendingCollectionRepo repository.PendingCollectionRepository | ||||
| 	userActionLogRepo     repository.UserActionLogRepository | ||||
| 	rawMaterialRepo       repository.RawMaterialRepository | ||||
| @@ -40,6 +43,7 @@ type monitorService struct { | ||||
| 	pigTransferLogRepo    repository.PigTransferLogRepository | ||||
| 	pigSickLogRepo        repository.PigSickLogRepository | ||||
| 	pigTradeRepo          repository.PigTradeRepository | ||||
| 	notificationRepo      repository.NotificationRepository | ||||
| } | ||||
|  | ||||
| // NewMonitorService 创建一个新的 MonitorService 实例 | ||||
| @@ -47,6 +51,7 @@ func NewMonitorService( | ||||
| 	sensorDataRepo repository.SensorDataRepository, | ||||
| 	deviceCommandLogRepo repository.DeviceCommandLogRepository, | ||||
| 	executionLogRepo repository.ExecutionLogRepository, | ||||
| 	planRepository repository.PlanRepository, | ||||
| 	pendingCollectionRepo repository.PendingCollectionRepository, | ||||
| 	userActionLogRepo repository.UserActionLogRepository, | ||||
| 	rawMaterialRepo repository.RawMaterialRepository, | ||||
| @@ -56,11 +61,13 @@ func NewMonitorService( | ||||
| 	pigTransferLogRepo repository.PigTransferLogRepository, | ||||
| 	pigSickLogRepo repository.PigSickLogRepository, | ||||
| 	pigTradeRepo repository.PigTradeRepository, | ||||
| 	notificationRepo repository.NotificationRepository, | ||||
| ) MonitorService { | ||||
| 	return &monitorService{ | ||||
| 		sensorDataRepo:        sensorDataRepo, | ||||
| 		deviceCommandLogRepo:  deviceCommandLogRepo, | ||||
| 		executionLogRepo:      executionLogRepo, | ||||
| 		planRepository:        planRepository, | ||||
| 		pendingCollectionRepo: pendingCollectionRepo, | ||||
| 		userActionLogRepo:     userActionLogRepo, | ||||
| 		rawMaterialRepo:       rawMaterialRepo, | ||||
| @@ -70,90 +77,398 @@ func NewMonitorService( | ||||
| 		pigTransferLogRepo:    pigTransferLogRepo, | ||||
| 		pigSickLogRepo:        pigSickLogRepo, | ||||
| 		pigTradeRepo:          pigTradeRepo, | ||||
| 		notificationRepo:      notificationRepo, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // ListSensorData 负责处理查询传感器数据列表的业务逻辑 | ||||
| func (s *monitorService) ListSensorData(opts repository.SensorDataListOptions, page, pageSize int) ([]models.SensorData, int64, error) { | ||||
| 	return s.sensorDataRepo.List(opts, page, pageSize) | ||||
| func (s *monitorService) ListSensorData(req *dto.ListSensorDataRequest) (*dto.ListSensorDataResponse, error) { | ||||
| 	opts := repository.SensorDataListOptions{ | ||||
| 		DeviceID:  req.DeviceID, | ||||
| 		OrderBy:   req.OrderBy, | ||||
| 		StartTime: req.StartTime, | ||||
| 		EndTime:   req.EndTime, | ||||
| 	} | ||||
| 	if req.SensorType != nil { | ||||
| 		sensorType := models.SensorType(*req.SensorType) | ||||
| 		opts.SensorType = &sensorType | ||||
| 	} | ||||
|  | ||||
| 	data, total, err := s.sensorDataRepo.List(opts, req.Page, req.PageSize) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return dto.NewListSensorDataResponse(data, total, req.Page, req.PageSize), nil | ||||
| } | ||||
|  | ||||
| // ListDeviceCommandLogs 负责处理查询设备命令日志列表的业务逻辑 | ||||
| func (s *monitorService) ListDeviceCommandLogs(opts repository.DeviceCommandLogListOptions, page, pageSize int) ([]models.DeviceCommandLog, int64, error) { | ||||
| 	return s.deviceCommandLogRepo.List(opts, page, pageSize) | ||||
| func (s *monitorService) ListDeviceCommandLogs(req *dto.ListDeviceCommandLogRequest) (*dto.ListDeviceCommandLogResponse, error) { | ||||
| 	opts := repository.DeviceCommandLogListOptions{ | ||||
| 		DeviceID:        req.DeviceID, | ||||
| 		ReceivedSuccess: req.ReceivedSuccess, | ||||
| 		OrderBy:         req.OrderBy, | ||||
| 		StartTime:       req.StartTime, | ||||
| 		EndTime:         req.EndTime, | ||||
| 	} | ||||
|  | ||||
| 	data, total, err := s.deviceCommandLogRepo.List(opts, req.Page, req.PageSize) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return dto.NewListDeviceCommandLogResponse(data, total, req.Page, req.PageSize), nil | ||||
| } | ||||
|  | ||||
| // ListPlanExecutionLogs 负责处理查询计划执行日志列表的业务逻辑 | ||||
| func (s *monitorService) ListPlanExecutionLogs(opts repository.PlanExecutionLogListOptions, page, pageSize int) ([]models.PlanExecutionLog, int64, error) { | ||||
| 	return s.executionLogRepo.ListPlanExecutionLogs(opts, page, pageSize) | ||||
| func (s *monitorService) ListPlanExecutionLogs(req *dto.ListPlanExecutionLogRequest) (*dto.ListPlanExecutionLogResponse, error) { | ||||
| 	opts := repository.PlanExecutionLogListOptions{ | ||||
| 		PlanID:    req.PlanID, | ||||
| 		OrderBy:   req.OrderBy, | ||||
| 		StartTime: req.StartTime, | ||||
| 		EndTime:   req.EndTime, | ||||
| 	} | ||||
| 	if req.Status != nil { | ||||
| 		status := models.ExecutionStatus(*req.Status) | ||||
| 		opts.Status = &status | ||||
| 	} | ||||
|  | ||||
| 	planLogs, total, err := s.executionLogRepo.ListPlanExecutionLogs(opts, req.Page, req.PageSize) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	planIds := make([]uint, 0, len(planLogs)) | ||||
| 	for _, datum := range planLogs { | ||||
| 		has := false | ||||
| 		for _, id := range planIds { | ||||
| 			if id == datum.PlanID { | ||||
| 				has = true | ||||
| 				break | ||||
| 			} | ||||
| 		} | ||||
| 		if !has { | ||||
| 			planIds = append(planIds, datum.PlanID) | ||||
| 		} | ||||
| 	} | ||||
| 	plans, err := s.planRepository.GetPlansByIDs(planIds) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return dto.NewListPlanExecutionLogResponse(planLogs, plans, total, req.Page, req.PageSize), nil | ||||
| } | ||||
|  | ||||
| // ListTaskExecutionLogs 负责处理查询任务执行日志列表的业务逻辑 | ||||
| func (s *monitorService) ListTaskExecutionLogs(opts repository.TaskExecutionLogListOptions, page, pageSize int) ([]models.TaskExecutionLog, int64, error) { | ||||
| 	return s.executionLogRepo.ListTaskExecutionLogs(opts, page, pageSize) | ||||
| func (s *monitorService) ListTaskExecutionLogs(req *dto.ListTaskExecutionLogRequest) (*dto.ListTaskExecutionLogResponse, error) { | ||||
| 	opts := repository.TaskExecutionLogListOptions{ | ||||
| 		PlanExecutionLogID: req.PlanExecutionLogID, | ||||
| 		TaskID:             req.TaskID, | ||||
| 		OrderBy:            req.OrderBy, | ||||
| 		StartTime:          req.StartTime, | ||||
| 		EndTime:            req.EndTime, | ||||
| 	} | ||||
| 	if req.Status != nil { | ||||
| 		status := models.ExecutionStatus(*req.Status) | ||||
| 		opts.Status = &status | ||||
| 	} | ||||
|  | ||||
| 	data, total, err := s.executionLogRepo.ListTaskExecutionLogs(opts, req.Page, req.PageSize) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return dto.NewListTaskExecutionLogResponse(data, total, req.Page, req.PageSize), nil | ||||
| } | ||||
|  | ||||
| // ListPendingCollections 负责处理查询待采集请求列表的业务逻辑 | ||||
| func (s *monitorService) ListPendingCollections(opts repository.PendingCollectionListOptions, page, pageSize int) ([]models.PendingCollection, int64, error) { | ||||
| 	return s.pendingCollectionRepo.List(opts, page, pageSize) | ||||
| func (s *monitorService) ListPendingCollections(req *dto.ListPendingCollectionRequest) (*dto.ListPendingCollectionResponse, error) { | ||||
| 	opts := repository.PendingCollectionListOptions{ | ||||
| 		DeviceID:  req.DeviceID, | ||||
| 		OrderBy:   req.OrderBy, | ||||
| 		StartTime: req.StartTime, | ||||
| 		EndTime:   req.EndTime, | ||||
| 	} | ||||
| 	if req.Status != nil { | ||||
| 		status := models.PendingCollectionStatus(*req.Status) | ||||
| 		opts.Status = &status | ||||
| 	} | ||||
|  | ||||
| 	data, total, err := s.pendingCollectionRepo.List(opts, req.Page, req.PageSize) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return dto.NewListPendingCollectionResponse(data, total, req.Page, req.PageSize), nil | ||||
| } | ||||
|  | ||||
| // ListUserActionLogs 负责处理查询用户操作日志列表的业务逻辑 | ||||
| func (s *monitorService) ListUserActionLogs(opts repository.UserActionLogListOptions, page, pageSize int) ([]models.UserActionLog, int64, error) { | ||||
| 	return s.userActionLogRepo.List(opts, page, pageSize) | ||||
| func (s *monitorService) ListUserActionLogs(req *dto.ListUserActionLogRequest) (*dto.ListUserActionLogResponse, error) { | ||||
| 	opts := repository.UserActionLogListOptions{ | ||||
| 		UserID:     req.UserID, | ||||
| 		Username:   req.Username, | ||||
| 		ActionType: req.ActionType, | ||||
| 		OrderBy:    req.OrderBy, | ||||
| 		StartTime:  req.StartTime, | ||||
| 		EndTime:    req.EndTime, | ||||
| 	} | ||||
| 	if req.Status != nil { | ||||
| 		status := models.AuditStatus(*req.Status) | ||||
| 		opts.Status = &status | ||||
| 	} | ||||
|  | ||||
| 	data, total, err := s.userActionLogRepo.List(opts, req.Page, req.PageSize) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return dto.NewListUserActionLogResponse(data, total, req.Page, req.PageSize), nil | ||||
| } | ||||
|  | ||||
| // ListRawMaterialPurchases 负责处理查询原料采购记录列表的业务逻辑 | ||||
| func (s *monitorService) ListRawMaterialPurchases(opts repository.RawMaterialPurchaseListOptions, page, pageSize int) ([]models.RawMaterialPurchase, int64, error) { | ||||
| 	return s.rawMaterialRepo.ListRawMaterialPurchases(opts, page, pageSize) | ||||
| func (s *monitorService) ListRawMaterialPurchases(req *dto.ListRawMaterialPurchaseRequest) (*dto.ListRawMaterialPurchaseResponse, error) { | ||||
| 	opts := repository.RawMaterialPurchaseListOptions{ | ||||
| 		RawMaterialID: req.RawMaterialID, | ||||
| 		Supplier:      req.Supplier, | ||||
| 		OrderBy:       req.OrderBy, | ||||
| 		StartTime:     req.StartTime, | ||||
| 		EndTime:       req.EndTime, | ||||
| 	} | ||||
|  | ||||
| 	data, total, err := s.rawMaterialRepo.ListRawMaterialPurchases(opts, req.Page, req.PageSize) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return dto.NewListRawMaterialPurchaseResponse(data, total, req.Page, req.PageSize), nil | ||||
| } | ||||
|  | ||||
| // ListRawMaterialStockLogs 负责处理查询原料库存日志列表的业务逻辑 | ||||
| func (s *monitorService) ListRawMaterialStockLogs(opts repository.RawMaterialStockLogListOptions, page, pageSize int) ([]models.RawMaterialStockLog, int64, error) { | ||||
| 	return s.rawMaterialRepo.ListRawMaterialStockLogs(opts, page, pageSize) | ||||
| func (s *monitorService) ListRawMaterialStockLogs(req *dto.ListRawMaterialStockLogRequest) (*dto.ListRawMaterialStockLogResponse, error) { | ||||
| 	opts := repository.RawMaterialStockLogListOptions{ | ||||
| 		RawMaterialID: req.RawMaterialID, | ||||
| 		SourceID:      req.SourceID, | ||||
| 		OrderBy:       req.OrderBy, | ||||
| 		StartTime:     req.StartTime, | ||||
| 		EndTime:       req.EndTime, | ||||
| 	} | ||||
| 	if req.SourceType != nil { | ||||
| 		sourceType := models.StockLogSourceType(*req.SourceType) | ||||
| 		opts.SourceType = &sourceType | ||||
| 	} | ||||
|  | ||||
| 	data, total, err := s.rawMaterialRepo.ListRawMaterialStockLogs(opts, req.Page, req.PageSize) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return dto.NewListRawMaterialStockLogResponse(data, total, req.Page, req.PageSize), nil | ||||
| } | ||||
|  | ||||
| // ListFeedUsageRecords 负责处理查询饲料使用记录列表的业务逻辑 | ||||
| func (s *monitorService) ListFeedUsageRecords(opts repository.FeedUsageRecordListOptions, page, pageSize int) ([]models.FeedUsageRecord, int64, error) { | ||||
| 	return s.rawMaterialRepo.ListFeedUsageRecords(opts, page, pageSize) | ||||
| func (s *monitorService) ListFeedUsageRecords(req *dto.ListFeedUsageRecordRequest) (*dto.ListFeedUsageRecordResponse, error) { | ||||
| 	opts := repository.FeedUsageRecordListOptions{ | ||||
| 		PenID:         req.PenID, | ||||
| 		FeedFormulaID: req.FeedFormulaID, | ||||
| 		OperatorID:    req.OperatorID, | ||||
| 		OrderBy:       req.OrderBy, | ||||
| 		StartTime:     req.StartTime, | ||||
| 		EndTime:       req.EndTime, | ||||
| 	} | ||||
|  | ||||
| 	data, total, err := s.rawMaterialRepo.ListFeedUsageRecords(opts, req.Page, req.PageSize) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return dto.NewListFeedUsageRecordResponse(data, total, req.Page, req.PageSize), nil | ||||
| } | ||||
|  | ||||
| // ListMedicationLogs 负责处理查询用药记录列表的业务逻辑 | ||||
| func (s *monitorService) ListMedicationLogs(opts repository.MedicationLogListOptions, page, pageSize int) ([]models.MedicationLog, int64, error) { | ||||
| 	return s.medicationRepo.ListMedicationLogs(opts, page, pageSize) | ||||
| func (s *monitorService) ListMedicationLogs(req *dto.ListMedicationLogRequest) (*dto.ListMedicationLogResponse, error) { | ||||
| 	opts := repository.MedicationLogListOptions{ | ||||
| 		PigBatchID:   req.PigBatchID, | ||||
| 		MedicationID: req.MedicationID, | ||||
| 		OperatorID:   req.OperatorID, | ||||
| 		OrderBy:      req.OrderBy, | ||||
| 		StartTime:    req.StartTime, | ||||
| 		EndTime:      req.EndTime, | ||||
| 	} | ||||
| 	if req.Reason != nil { | ||||
| 		reason := models.MedicationReasonType(*req.Reason) | ||||
| 		opts.Reason = &reason | ||||
| 	} | ||||
|  | ||||
| 	data, total, err := s.medicationRepo.ListMedicationLogs(opts, req.Page, req.PageSize) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return dto.NewListMedicationLogResponse(data, total, req.Page, req.PageSize), nil | ||||
| } | ||||
|  | ||||
| // ListPigBatchLogs 负责处理查询猪批次日志列表的业务逻辑 | ||||
| func (s *monitorService) ListPigBatchLogs(opts repository.PigBatchLogListOptions, page, pageSize int) ([]models.PigBatchLog, int64, error) { | ||||
| 	return s.pigBatchLogRepo.List(opts, page, pageSize) | ||||
| func (s *monitorService) ListPigBatchLogs(req *dto.ListPigBatchLogRequest) (*dto.ListPigBatchLogResponse, error) { | ||||
| 	opts := repository.PigBatchLogListOptions{ | ||||
| 		PigBatchID: req.PigBatchID, | ||||
| 		OperatorID: req.OperatorID, | ||||
| 		OrderBy:    req.OrderBy, | ||||
| 		StartTime:  req.StartTime, | ||||
| 		EndTime:    req.EndTime, | ||||
| 	} | ||||
| 	if req.ChangeType != nil { | ||||
| 		changeType := models.LogChangeType(*req.ChangeType) | ||||
| 		opts.ChangeType = &changeType | ||||
| 	} | ||||
|  | ||||
| 	data, total, err := s.pigBatchLogRepo.List(opts, req.Page, req.PageSize) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return dto.NewListPigBatchLogResponse(data, total, req.Page, req.PageSize), nil | ||||
| } | ||||
|  | ||||
| // ListWeighingBatches 负责处理查询批次称重记录列表的业务逻辑 | ||||
| func (s *monitorService) ListWeighingBatches(opts repository.WeighingBatchListOptions, page, pageSize int) ([]models.WeighingBatch, int64, error) { | ||||
| 	return s.pigBatchRepo.ListWeighingBatches(opts, page, pageSize) | ||||
| func (s *monitorService) ListWeighingBatches(req *dto.ListWeighingBatchRequest) (*dto.ListWeighingBatchResponse, error) { | ||||
| 	opts := repository.WeighingBatchListOptions{ | ||||
| 		PigBatchID: req.PigBatchID, | ||||
| 		OrderBy:    req.OrderBy, | ||||
| 		StartTime:  req.StartTime, | ||||
| 		EndTime:    req.EndTime, | ||||
| 	} | ||||
|  | ||||
| 	data, total, err := s.pigBatchRepo.ListWeighingBatches(opts, req.Page, req.PageSize) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return dto.NewListWeighingBatchResponse(data, total, req.Page, req.PageSize), nil | ||||
| } | ||||
|  | ||||
| // ListWeighingRecords 负责处理查询单次称重记录列表的业务逻辑 | ||||
| func (s *monitorService) ListWeighingRecords(opts repository.WeighingRecordListOptions, page, pageSize int) ([]models.WeighingRecord, int64, error) { | ||||
| 	return s.pigBatchRepo.ListWeighingRecords(opts, page, pageSize) | ||||
| func (s *monitorService) ListWeighingRecords(req *dto.ListWeighingRecordRequest) (*dto.ListWeighingRecordResponse, error) { | ||||
| 	opts := repository.WeighingRecordListOptions{ | ||||
| 		WeighingBatchID: req.WeighingBatchID, | ||||
| 		PenID:           req.PenID, | ||||
| 		OperatorID:      req.OperatorID, | ||||
| 		OrderBy:         req.OrderBy, | ||||
| 		StartTime:       req.StartTime, | ||||
| 		EndTime:         req.EndTime, | ||||
| 	} | ||||
|  | ||||
| 	data, total, err := s.pigBatchRepo.ListWeighingRecords(opts, req.Page, req.PageSize) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return dto.NewListWeighingRecordResponse(data, total, req.Page, req.PageSize), nil | ||||
| } | ||||
|  | ||||
| // ListPigTransferLogs 负责处理查询猪只迁移日志列表的业务逻辑 | ||||
| func (s *monitorService) ListPigTransferLogs(opts repository.PigTransferLogListOptions, page, pageSize int) ([]models.PigTransferLog, int64, error) { | ||||
| 	return s.pigTransferLogRepo.ListPigTransferLogs(opts, page, pageSize) | ||||
| func (s *monitorService) ListPigTransferLogs(req *dto.ListPigTransferLogRequest) (*dto.ListPigTransferLogResponse, error) { | ||||
| 	opts := repository.PigTransferLogListOptions{ | ||||
| 		PigBatchID:    req.PigBatchID, | ||||
| 		PenID:         req.PenID, | ||||
| 		OperatorID:    req.OperatorID, | ||||
| 		CorrelationID: req.CorrelationID, | ||||
| 		OrderBy:       req.OrderBy, | ||||
| 		StartTime:     req.StartTime, | ||||
| 		EndTime:       req.EndTime, | ||||
| 	} | ||||
| 	if req.TransferType != nil { | ||||
| 		transferType := models.PigTransferType(*req.TransferType) | ||||
| 		opts.TransferType = &transferType | ||||
| 	} | ||||
|  | ||||
| 	data, total, err := s.pigTransferLogRepo.ListPigTransferLogs(opts, req.Page, req.PageSize) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return dto.NewListPigTransferLogResponse(data, total, req.Page, req.PageSize), nil | ||||
| } | ||||
|  | ||||
| // ListPigSickLogs 负责处理查询病猪日志列表的业务逻辑 | ||||
| func (s *monitorService) ListPigSickLogs(opts repository.PigSickLogListOptions, page, pageSize int) ([]models.PigSickLog, int64, error) { | ||||
| 	return s.pigSickLogRepo.ListPigSickLogs(opts, page, pageSize) | ||||
| func (s *monitorService) ListPigSickLogs(req *dto.ListPigSickLogRequest) (*dto.ListPigSickLogResponse, error) { | ||||
| 	opts := repository.PigSickLogListOptions{ | ||||
| 		PigBatchID: req.PigBatchID, | ||||
| 		PenID:      req.PenID, | ||||
| 		OperatorID: req.OperatorID, | ||||
| 		OrderBy:    req.OrderBy, | ||||
| 		StartTime:  req.StartTime, | ||||
| 		EndTime:    req.EndTime, | ||||
| 	} | ||||
| 	if req.Reason != nil { | ||||
| 		reason := models.PigBatchSickPigReasonType(*req.Reason) | ||||
| 		opts.Reason = &reason | ||||
| 	} | ||||
| 	if req.TreatmentLocation != nil { | ||||
| 		treatmentLocation := models.PigBatchSickPigTreatmentLocation(*req.TreatmentLocation) | ||||
| 		opts.TreatmentLocation = &treatmentLocation | ||||
| 	} | ||||
|  | ||||
| 	data, total, err := s.pigSickLogRepo.ListPigSickLogs(opts, req.Page, req.PageSize) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return dto.NewListPigSickLogResponse(data, total, req.Page, req.PageSize), nil | ||||
| } | ||||
|  | ||||
| // ListPigPurchases 负责处理查询猪只采购记录列表的业务逻辑 | ||||
| func (s *monitorService) ListPigPurchases(opts repository.PigPurchaseListOptions, page, pageSize int) ([]models.PigPurchase, int64, error) { | ||||
| 	return s.pigTradeRepo.ListPigPurchases(opts, page, pageSize) | ||||
| func (s *monitorService) ListPigPurchases(req *dto.ListPigPurchaseRequest) (*dto.ListPigPurchaseResponse, error) { | ||||
| 	opts := repository.PigPurchaseListOptions{ | ||||
| 		PigBatchID: req.PigBatchID, | ||||
| 		Supplier:   req.Supplier, | ||||
| 		OperatorID: req.OperatorID, | ||||
| 		OrderBy:    req.OrderBy, | ||||
| 		StartTime:  req.StartTime, | ||||
| 		EndTime:    req.EndTime, | ||||
| 	} | ||||
|  | ||||
| 	data, total, err := s.pigTradeRepo.ListPigPurchases(opts, req.Page, req.PageSize) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return dto.NewListPigPurchaseResponse(data, total, req.Page, req.PageSize), nil | ||||
| } | ||||
|  | ||||
| // ListPigSales 负责处理查询猪只销售记录列表的业务逻辑 | ||||
| func (s *monitorService) ListPigSales(opts repository.PigSaleListOptions, page, pageSize int) ([]models.PigSale, int64, error) { | ||||
| 	return s.pigTradeRepo.ListPigSales(opts, page, pageSize) | ||||
| func (s *monitorService) ListPigSales(req *dto.ListPigSaleRequest) (*dto.ListPigSaleResponse, error) { | ||||
| 	opts := repository.PigSaleListOptions{ | ||||
| 		PigBatchID: req.PigBatchID, | ||||
| 		Buyer:      req.Buyer, | ||||
| 		OperatorID: req.OperatorID, | ||||
| 		OrderBy:    req.OrderBy, | ||||
| 		StartTime:  req.StartTime, | ||||
| 		EndTime:    req.EndTime, | ||||
| 	} | ||||
|  | ||||
| 	data, total, err := s.pigTradeRepo.ListPigSales(opts, req.Page, req.PageSize) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return dto.NewListPigSaleResponse(data, total, req.Page, req.PageSize), nil | ||||
| } | ||||
|  | ||||
| // ListNotifications 负责处理查询通知列表的业务逻辑 | ||||
| func (s *monitorService) ListNotifications(req *dto.ListNotificationRequest) (*dto.ListNotificationResponse, error) { | ||||
| 	opts := repository.NotificationListOptions{ | ||||
| 		UserID:       req.UserID, | ||||
| 		NotifierType: req.NotifierType, | ||||
| 		Level:        req.Level, | ||||
| 		StartTime:    req.StartTime, | ||||
| 		EndTime:      req.EndTime, | ||||
| 		OrderBy:      req.OrderBy, | ||||
| 		Status:       req.Status, | ||||
| 	} | ||||
|  | ||||
| 	data, total, err := s.notificationRepo.List(opts, req.Page, req.PageSize) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	return dto.NewListNotificationResponse(data, total, req.Page, req.PageSize), nil | ||||
| } | ||||
|   | ||||
| @@ -16,20 +16,20 @@ import ( | ||||
| // PigFarmService 提供了猪场资产管理的业务逻辑 | ||||
| type PigFarmService interface { | ||||
| 	// PigHouse methods | ||||
| 	CreatePigHouse(name, description string) (*models.PigHouse, error) | ||||
| 	GetPigHouseByID(id uint) (*models.PigHouse, error) | ||||
| 	ListPigHouses() ([]models.PigHouse, error) | ||||
| 	UpdatePigHouse(id uint, name, description string) (*models.PigHouse, error) | ||||
| 	CreatePigHouse(name, description string) (*dto.PigHouseResponse, error) | ||||
| 	GetPigHouseByID(id uint) (*dto.PigHouseResponse, error) | ||||
| 	ListPigHouses() ([]dto.PigHouseResponse, error) | ||||
| 	UpdatePigHouse(id uint, name, description string) (*dto.PigHouseResponse, error) | ||||
| 	DeletePigHouse(id uint) error | ||||
|  | ||||
| 	// Pen methods | ||||
| 	CreatePen(penNumber string, houseID uint, capacity int) (*models.Pen, error) | ||||
| 	CreatePen(penNumber string, houseID uint, capacity int) (*dto.PenResponse, error) | ||||
| 	GetPenByID(id uint) (*dto.PenResponse, error) | ||||
| 	ListPens() ([]*dto.PenResponse, error) | ||||
| 	UpdatePen(id uint, penNumber string, houseID uint, capacity int, status models.PenStatus) (*models.Pen, error) | ||||
| 	UpdatePen(id uint, penNumber string, houseID uint, capacity int, status models.PenStatus) (*dto.PenResponse, error) | ||||
| 	DeletePen(id uint) error | ||||
| 	// UpdatePenStatus 更新猪栏状态 | ||||
| 	UpdatePenStatus(id uint, newStatus models.PenStatus) (*models.Pen, error) | ||||
| 	UpdatePenStatus(id uint, newStatus models.PenStatus) (*dto.PenResponse, error) | ||||
| } | ||||
|  | ||||
| type pigFarmService struct { | ||||
| @@ -60,24 +60,51 @@ func NewPigFarmService(farmRepository repository.PigFarmRepository, | ||||
|  | ||||
| // --- PigHouse Implementation --- | ||||
|  | ||||
| func (s *pigFarmService) CreatePigHouse(name, description string) (*models.PigHouse, error) { | ||||
| func (s *pigFarmService) CreatePigHouse(name, description string) (*dto.PigHouseResponse, error) { | ||||
| 	house := &models.PigHouse{ | ||||
| 		Name:        name, | ||||
| 		Description: description, | ||||
| 	} | ||||
| 	err := s.farmRepository.CreatePigHouse(house) | ||||
| 	return house, err | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return &dto.PigHouseResponse{ | ||||
| 		ID:          house.ID, | ||||
| 		Name:        house.Name, | ||||
| 		Description: house.Description, | ||||
| 	}, nil | ||||
| } | ||||
|  | ||||
| func (s *pigFarmService) GetPigHouseByID(id uint) (*models.PigHouse, error) { | ||||
| 	return s.farmRepository.GetPigHouseByID(id) | ||||
| func (s *pigFarmService) GetPigHouseByID(id uint) (*dto.PigHouseResponse, error) { | ||||
| 	house, err := s.farmRepository.GetPigHouseByID(id) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return &dto.PigHouseResponse{ | ||||
| 		ID:          house.ID, | ||||
| 		Name:        house.Name, | ||||
| 		Description: house.Description, | ||||
| 	}, nil | ||||
| } | ||||
|  | ||||
| func (s *pigFarmService) ListPigHouses() ([]models.PigHouse, error) { | ||||
| 	return s.farmRepository.ListPigHouses() | ||||
| func (s *pigFarmService) ListPigHouses() ([]dto.PigHouseResponse, error) { | ||||
| 	houses, err := s.farmRepository.ListPigHouses() | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	var resp []dto.PigHouseResponse | ||||
| 	for _, house := range houses { | ||||
| 		resp = append(resp, dto.PigHouseResponse{ | ||||
| 			ID:          house.ID, | ||||
| 			Name:        house.Name, | ||||
| 			Description: house.Description, | ||||
| 		}) | ||||
| 	} | ||||
| 	return resp, nil | ||||
| } | ||||
|  | ||||
| func (s *pigFarmService) UpdatePigHouse(id uint, name, description string) (*models.PigHouse, error) { | ||||
| func (s *pigFarmService) UpdatePigHouse(id uint, name, description string) (*dto.PigHouseResponse, error) { | ||||
| 	house := &models.PigHouse{ | ||||
| 		Model:       gorm.Model{ID: id}, | ||||
| 		Name:        name, | ||||
| @@ -91,7 +118,15 @@ func (s *pigFarmService) UpdatePigHouse(id uint, name, description string) (*mod | ||||
| 		return nil, ErrHouseNotFound | ||||
| 	} | ||||
| 	// 返回更新后的完整信息 | ||||
| 	return s.farmRepository.GetPigHouseByID(id) | ||||
| 	updatedHouse, err := s.farmRepository.GetPigHouseByID(id) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return &dto.PigHouseResponse{ | ||||
| 		ID:          updatedHouse.ID, | ||||
| 		Name:        updatedHouse.Name, | ||||
| 		Description: updatedHouse.Description, | ||||
| 	}, nil | ||||
| } | ||||
|  | ||||
| func (s *pigFarmService) DeletePigHouse(id uint) error { | ||||
| @@ -117,7 +152,7 @@ func (s *pigFarmService) DeletePigHouse(id uint) error { | ||||
|  | ||||
| // --- Pen Implementation --- | ||||
|  | ||||
| func (s *pigFarmService) CreatePen(penNumber string, houseID uint, capacity int) (*models.Pen, error) { | ||||
| func (s *pigFarmService) CreatePen(penNumber string, houseID uint, capacity int) (*dto.PenResponse, error) { | ||||
| 	// 业务逻辑:验证所属猪舍是否存在 | ||||
| 	_, err := s.farmRepository.GetPigHouseByID(houseID) | ||||
| 	if err != nil { | ||||
| @@ -134,7 +169,16 @@ func (s *pigFarmService) CreatePen(penNumber string, houseID uint, capacity int) | ||||
| 		Status:    models.PenStatusEmpty, | ||||
| 	} | ||||
| 	err = s.penRepository.CreatePen(pen) | ||||
| 	return pen, err | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return &dto.PenResponse{ | ||||
| 		ID:        pen.ID, | ||||
| 		PenNumber: pen.PenNumber, | ||||
| 		HouseID:   pen.HouseID, | ||||
| 		Capacity:  pen.Capacity, | ||||
| 		Status:    pen.Status, | ||||
| 	}, nil | ||||
| } | ||||
|  | ||||
| func (s *pigFarmService) GetPenByID(id uint) (*dto.PenResponse, error) { | ||||
| @@ -197,7 +241,7 @@ func (s *pigFarmService) ListPens() ([]*dto.PenResponse, error) { | ||||
| 	return response, nil | ||||
| } | ||||
|  | ||||
| func (s *pigFarmService) UpdatePen(id uint, penNumber string, houseID uint, capacity int, status models.PenStatus) (*models.Pen, error) { | ||||
| func (s *pigFarmService) UpdatePen(id uint, penNumber string, houseID uint, capacity int, status models.PenStatus) (*dto.PenResponse, error) { | ||||
| 	// 业务逻辑:验证所属猪舍是否存在 | ||||
| 	_, err := s.farmRepository.GetPigHouseByID(houseID) | ||||
| 	if err != nil { | ||||
| @@ -222,7 +266,18 @@ func (s *pigFarmService) UpdatePen(id uint, penNumber string, houseID uint, capa | ||||
| 		return nil, ErrPenNotFound | ||||
| 	} | ||||
| 	// 返回更新后的完整信息 | ||||
| 	return s.penRepository.GetPenByID(id) | ||||
| 	updatedPen, err := s.penRepository.GetPenByID(id) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return &dto.PenResponse{ | ||||
| 		ID:         updatedPen.ID, | ||||
| 		PenNumber:  updatedPen.PenNumber, | ||||
| 		HouseID:    updatedPen.HouseID, | ||||
| 		Capacity:   updatedPen.Capacity, | ||||
| 		Status:     updatedPen.Status, | ||||
| 		PigBatchID: updatedPen.PigBatchID, | ||||
| 	}, nil | ||||
| } | ||||
|  | ||||
| func (s *pigFarmService) DeletePen(id uint) error { | ||||
| @@ -260,7 +315,7 @@ func (s *pigFarmService) DeletePen(id uint) error { | ||||
| } | ||||
|  | ||||
| // UpdatePenStatus 更新猪栏状态 | ||||
| func (s *pigFarmService) UpdatePenStatus(id uint, newStatus models.PenStatus) (*models.Pen, error) { | ||||
| func (s *pigFarmService) UpdatePenStatus(id uint, newStatus models.PenStatus) (*dto.PenResponse, error) { | ||||
| 	var updatedPen *models.Pen | ||||
| 	err := s.uow.ExecuteInTransaction(func(tx *gorm.DB) error { | ||||
| 		pen, err := s.penRepository.GetPenByIDTx(tx, id) | ||||
| @@ -310,5 +365,12 @@ func (s *pigFarmService) UpdatePenStatus(id uint, newStatus models.PenStatus) (* | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return updatedPen, nil | ||||
| 	return &dto.PenResponse{ | ||||
| 		ID:         updatedPen.ID, | ||||
| 		PenNumber:  updatedPen.PenNumber, | ||||
| 		HouseID:    updatedPen.HouseID, | ||||
| 		Capacity:   updatedPen.Capacity, | ||||
| 		Status:     updatedPen.Status, | ||||
| 		PigBatchID: updatedPen.PigBatchID, | ||||
| 	}, nil | ||||
| } | ||||
|   | ||||
							
								
								
									
										344
									
								
								internal/app/service/plan_service.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										344
									
								
								internal/app/service/plan_service.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,344 @@ | ||||
| package service | ||||
|  | ||||
| import ( | ||||
| 	"errors" | ||||
|  | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/dto" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/domain/scheduler" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" | ||||
| 	"gorm.io/gorm" | ||||
| ) | ||||
|  | ||||
| var ( | ||||
| 	// ErrPlanNotFound 表示未找到计划 | ||||
| 	ErrPlanNotFound = errors.New("计划不存在") | ||||
| 	// ErrPlanCannotBeModified 表示计划不允许修改 | ||||
| 	ErrPlanCannotBeModified = errors.New("系统计划不允许修改") | ||||
| 	// ErrPlanCannotBeDeleted 表示计划不允许删除 | ||||
| 	ErrPlanCannotBeDeleted = errors.New("系统计划不允许删除") | ||||
| 	// ErrPlanCannotBeStarted 表示计划不允许手动启动 | ||||
| 	ErrPlanCannotBeStarted = errors.New("系统计划不允许手动启动") | ||||
| 	// ErrPlanAlreadyEnabled 表示计划已处于启动状态 | ||||
| 	ErrPlanAlreadyEnabled = errors.New("计划已处于启动状态,无需重复操作") | ||||
| 	// ErrPlanNotEnabled 表示计划未处于启动状态 | ||||
| 	ErrPlanNotEnabled = errors.New("计划当前不是启用状态") | ||||
| 	// ErrPlanCannotBeStopped 表示计划不允许停止 | ||||
| 	ErrPlanCannotBeStopped = errors.New("系统计划不允许停止") | ||||
| ) | ||||
|  | ||||
| // PlanService 定义了计划相关的应用服务接口 | ||||
| type PlanService interface { | ||||
| 	// CreatePlan 创建一个新的计划 | ||||
| 	CreatePlan(req *dto.CreatePlanRequest) (*dto.PlanResponse, error) | ||||
| 	// GetPlanByID 根据ID获取计划详情 | ||||
| 	GetPlanByID(id uint) (*dto.PlanResponse, error) | ||||
| 	// ListPlans 获取计划列表,支持过滤和分页 | ||||
| 	ListPlans(query *dto.ListPlansQuery) (*dto.ListPlansResponse, error) | ||||
| 	// UpdatePlan 更新计划 | ||||
| 	UpdatePlan(id uint, req *dto.UpdatePlanRequest) (*dto.PlanResponse, error) | ||||
| 	// DeletePlan 删除计划(软删除) | ||||
| 	DeletePlan(id uint) error | ||||
| 	// StartPlan 启动计划 | ||||
| 	StartPlan(id uint) error | ||||
| 	// StopPlan 停止计划 | ||||
| 	StopPlan(id uint) error | ||||
| } | ||||
|  | ||||
| // planService 是 PlanService 接口的实现 | ||||
| type planService struct { | ||||
| 	logger                  *logs.Logger | ||||
| 	planRepo                repository.PlanRepository | ||||
| 	analysisPlanTaskManager *scheduler.AnalysisPlanTaskManager | ||||
| } | ||||
|  | ||||
| // NewPlanService 创建一个新的 PlanService 实例 | ||||
| func NewPlanService( | ||||
| 	logger *logs.Logger, | ||||
| 	planRepo repository.PlanRepository, | ||||
| 	analysisPlanTaskManager *scheduler.AnalysisPlanTaskManager, | ||||
| ) PlanService { | ||||
| 	return &planService{ | ||||
| 		logger:                  logger, | ||||
| 		planRepo:                planRepo, | ||||
| 		analysisPlanTaskManager: analysisPlanTaskManager, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // CreatePlan 创建一个新的计划 | ||||
| func (s *planService) CreatePlan(req *dto.CreatePlanRequest) (*dto.PlanResponse, error) { | ||||
| 	const actionType = "服务层:创建计划" | ||||
|  | ||||
| 	// 使用已有的转换函数,它已经包含了验证和重排逻辑 | ||||
| 	planToCreate, err := dto.NewPlanFromCreateRequest(req) | ||||
| 	if err != nil { | ||||
| 		s.logger.Errorf("%s: 计划数据校验失败: %v", actionType, err) | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	// --- 业务规则处理 --- | ||||
| 	// 1. 设置计划类型:用户创建的计划永远是自定义计划 | ||||
| 	planToCreate.PlanType = models.PlanTypeCustom | ||||
|  | ||||
| 	// 2. 自动判断 ContentType | ||||
| 	if len(req.SubPlanIDs) > 0 { | ||||
| 		planToCreate.ContentType = models.PlanContentTypeSubPlans | ||||
| 	} else { | ||||
| 		// 如果 SubPlanIDs 未提供,则默认为 Tasks 类型(即使 Tasks 字段也未提供) | ||||
| 		planToCreate.ContentType = models.PlanContentTypeTasks | ||||
| 	} | ||||
|  | ||||
| 	// 调用仓库方法创建计划 | ||||
| 	if err := s.planRepo.CreatePlan(planToCreate); err != nil { | ||||
| 		s.logger.Errorf("%s: 数据库创建计划失败: %v", actionType, err) | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	// 创建成功后,调用 manager 确保触发器任务定义存在,但不立即加入待执行队列 | ||||
| 	if err := s.analysisPlanTaskManager.EnsureAnalysisTaskDefinition(planToCreate.ID); err != nil { | ||||
| 		// 这是一个非阻塞性错误,我们只记录日志,因为主流程(创建计划)已经成功 | ||||
| 		s.logger.Errorf("为新创建的计划 %d 确保触发器任务定义失败: %v", planToCreate.ID, err) | ||||
| 	} | ||||
|  | ||||
| 	// 使用已有的转换函数将创建后的模型转换为响应对象 | ||||
| 	resp, err := dto.NewPlanToResponse(planToCreate) | ||||
| 	if err != nil { | ||||
| 		s.logger.Errorf("%s: 序列化响应失败: %v, Plan: %+v", actionType, err, planToCreate) | ||||
| 		return nil, errors.New("计划创建成功,但响应生成失败") | ||||
| 	} | ||||
|  | ||||
| 	s.logger.Infof("%s: 计划创建成功, ID: %d", actionType, planToCreate.ID) | ||||
| 	return resp, nil | ||||
| } | ||||
|  | ||||
| // GetPlanByID 根据ID获取计划详情 | ||||
| func (s *planService) GetPlanByID(id uint) (*dto.PlanResponse, error) { | ||||
| 	const actionType = "服务层:获取计划详情" | ||||
|  | ||||
| 	plan, err := s.planRepo.GetPlanByID(id) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, gorm.ErrRecordNotFound) { | ||||
| 			s.logger.Warnf("%s: 计划不存在, ID: %d", actionType, id) | ||||
| 			return nil, ErrPlanNotFound | ||||
| 		} | ||||
| 		s.logger.Errorf("%s: 数据库查询失败: %v, ID: %d", actionType, err, id) | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	resp, err := dto.NewPlanToResponse(plan) | ||||
| 	if err != nil { | ||||
| 		s.logger.Errorf("%s: 序列化响应失败: %v, Plan: %+v", actionType, err, plan) | ||||
| 		return nil, errors.New("获取计划详情失败: 内部数据格式错误") | ||||
| 	} | ||||
|  | ||||
| 	s.logger.Infof("%s: 获取计划详情成功, ID: %d", actionType, id) | ||||
| 	return resp, nil | ||||
| } | ||||
|  | ||||
| // ListPlans 获取计划列表,支持过滤和分页 | ||||
| func (s *planService) ListPlans(query *dto.ListPlansQuery) (*dto.ListPlansResponse, error) { | ||||
| 	const actionType = "服务层:获取计划列表" | ||||
|  | ||||
| 	opts := repository.ListPlansOptions{PlanType: query.PlanType} | ||||
| 	plans, total, err := s.planRepo.ListPlans(opts, query.Page, query.PageSize) | ||||
| 	if err != nil { | ||||
| 		s.logger.Errorf("%s: 数据库查询失败: %v", actionType, err) | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	planResponses := make([]dto.PlanResponse, 0, len(plans)) | ||||
| 	for _, p := range plans { | ||||
| 		resp, err := dto.NewPlanToResponse(&p) | ||||
| 		if err != nil { | ||||
| 			s.logger.Errorf("%s: 序列化单个计划响应失败: %v, Plan: %+v", actionType, err, p) | ||||
| 			// 这里选择跳过有问题的计划,并记录错误,而不是中断整个列表的返回 | ||||
| 			continue | ||||
| 		} | ||||
| 		planResponses = append(planResponses, *resp) | ||||
| 	} | ||||
|  | ||||
| 	resp := &dto.ListPlansResponse{ | ||||
| 		Plans: planResponses, | ||||
| 		Total: total, | ||||
| 	} | ||||
| 	s.logger.Infof("%s: 获取计划列表成功, 数量: %d", actionType, len(planResponses)) | ||||
| 	return resp, nil | ||||
| } | ||||
|  | ||||
| // UpdatePlan 更新计划 | ||||
| func (s *planService) UpdatePlan(id uint, req *dto.UpdatePlanRequest) (*dto.PlanResponse, error) { | ||||
| 	const actionType = "服务层:更新计划" | ||||
|  | ||||
| 	existingPlan, err := s.planRepo.GetBasicPlanByID(id) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, gorm.ErrRecordNotFound) { | ||||
| 			s.logger.Warnf("%s: 计划不存在, ID: %d", actionType, id) | ||||
| 			return nil, ErrPlanNotFound | ||||
| 		} | ||||
| 		s.logger.Errorf("%s: 获取计划信息失败: %v, ID: %d", actionType, err, id) | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	if existingPlan.PlanType == models.PlanTypeSystem { | ||||
| 		s.logger.Warnf("%s: 尝试修改系统计划, ID: %d", actionType, id) | ||||
| 		return nil, ErrPlanCannotBeModified | ||||
| 	} | ||||
|  | ||||
| 	planToUpdate, err := dto.NewPlanFromUpdateRequest(req) | ||||
| 	if err != nil { | ||||
| 		s.logger.Errorf("%s: 计划数据校验失败: %v", actionType, err) | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	planToUpdate.ID = id // 确保ID被设置 | ||||
|  | ||||
| 	if len(req.SubPlanIDs) > 0 { | ||||
| 		planToUpdate.ContentType = models.PlanContentTypeSubPlans | ||||
| 	} else { | ||||
| 		planToUpdate.ContentType = models.PlanContentTypeTasks | ||||
| 	} | ||||
|  | ||||
| 	// 只要是更新任务,就重置执行计数器 | ||||
| 	planToUpdate.ExecuteCount = 0 | ||||
| 	s.logger.Infof("计划 #%d 被更新,执行计数器已重置为 0。", planToUpdate.ID) | ||||
|  | ||||
| 	if err := s.planRepo.UpdatePlan(planToUpdate); err != nil { | ||||
| 		s.logger.Errorf("%s: 数据库更新计划失败: %v, Plan: %+v", actionType, err, planToUpdate) | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	if err := s.analysisPlanTaskManager.EnsureAnalysisTaskDefinition(planToUpdate.ID); err != nil { | ||||
| 		s.logger.Errorf("为更新后的计划 %d 确保触发器任务定义失败: %v", planToUpdate.ID, err) | ||||
| 	} | ||||
|  | ||||
| 	updatedPlan, err := s.planRepo.GetPlanByID(id) | ||||
| 	if err != nil { | ||||
| 		s.logger.Errorf("%s: 获取更新后计划详情失败: %v, ID: %d", actionType, err, id) | ||||
| 		return nil, errors.New("获取更新后计划详情时发生内部错误") | ||||
| 	} | ||||
|  | ||||
| 	resp, err := dto.NewPlanToResponse(updatedPlan) | ||||
| 	if err != nil { | ||||
| 		s.logger.Errorf("%s: 序列化响应失败: %v, Updated Plan: %+v", actionType, err, updatedPlan) | ||||
| 		return nil, errors.New("计划更新成功,但响应生成失败") | ||||
| 	} | ||||
|  | ||||
| 	s.logger.Infof("%s: 计划更新成功, ID: %d", actionType, updatedPlan.ID) | ||||
| 	return resp, nil | ||||
| } | ||||
|  | ||||
| // DeletePlan 删除计划(软删除) | ||||
| func (s *planService) DeletePlan(id uint) error { | ||||
| 	const actionType = "服务层:删除计划" | ||||
|  | ||||
| 	plan, err := s.planRepo.GetBasicPlanByID(id) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, gorm.ErrRecordNotFound) { | ||||
| 			s.logger.Warnf("%s: 计划不存在, ID: %d", actionType, id) | ||||
| 			return ErrPlanNotFound | ||||
| 		} | ||||
| 		s.logger.Errorf("%s: 获取计划信息失败: %v, ID: %d", actionType, err, id) | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	if plan.PlanType == models.PlanTypeSystem { | ||||
| 		s.logger.Warnf("%s: 尝试删除系统计划, ID: %d", actionType, id) | ||||
| 		return ErrPlanCannotBeDeleted | ||||
| 	} | ||||
|  | ||||
| 	if plan.Status == models.PlanStatusEnabled { | ||||
| 		if err := s.planRepo.StopPlanTransactionally(id); err != nil { | ||||
| 			s.logger.Errorf("%s: 停止计划失败: %v, ID: %d", actionType, err, id) | ||||
| 			return err | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if err := s.planRepo.DeletePlan(id); err != nil { | ||||
| 		s.logger.Errorf("%s: 数据库删除失败: %v, ID: %d", actionType, err, id) | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	s.logger.Infof("%s: 计划删除成功, ID: %d", actionType, id) | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // StartPlan 启动计划 | ||||
| func (s *planService) StartPlan(id uint) error { | ||||
| 	const actionType = "服务层:启动计划" | ||||
|  | ||||
| 	plan, err := s.planRepo.GetBasicPlanByID(id) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, gorm.ErrRecordNotFound) { | ||||
| 			s.logger.Warnf("%s: 计划不存在, ID: %d", actionType, id) | ||||
| 			return ErrPlanNotFound | ||||
| 		} | ||||
| 		s.logger.Errorf("%s: 获取计划信息失败: %v, ID: %d", actionType, err, id) | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	if plan.PlanType == models.PlanTypeSystem { | ||||
| 		s.logger.Warnf("%s: 尝试手动启动系统计划, ID: %d", actionType, id) | ||||
| 		return ErrPlanCannotBeStarted | ||||
| 	} | ||||
| 	if plan.Status == models.PlanStatusEnabled { | ||||
| 		s.logger.Warnf("%s: 计划已处于启动状态,无需重复操作, ID: %d", actionType, id) | ||||
| 		return ErrPlanAlreadyEnabled | ||||
| 	} | ||||
|  | ||||
| 	if plan.Status != models.PlanStatusEnabled { | ||||
| 		if plan.ExecuteCount > 0 { | ||||
| 			if err := s.planRepo.UpdateExecuteCount(plan.ID, 0); err != nil { | ||||
| 				s.logger.Errorf("%s: 重置计划执行计数失败: %v, ID: %d", actionType, err, plan.ID) | ||||
| 				return err | ||||
| 			} | ||||
| 			s.logger.Infof("计划 #%d 的执行计数器已重置为 0。", plan.ID) | ||||
| 		} | ||||
|  | ||||
| 		if err := s.planRepo.UpdatePlanStatus(plan.ID, models.PlanStatusEnabled); err != nil { | ||||
| 			s.logger.Errorf("%s: 更新计划状态失败: %v, ID: %d", actionType, err, plan.ID) | ||||
| 			return err | ||||
| 		} | ||||
| 		s.logger.Infof("已成功更新计划 #%d 的状态为 '已启动'。", plan.ID) | ||||
| 	} | ||||
|  | ||||
| 	if err := s.analysisPlanTaskManager.CreateOrUpdateTrigger(plan.ID); err != nil { | ||||
| 		s.logger.Errorf("%s: 创建或更新触发器失败: %v, ID: %d", actionType, err, plan.ID) | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	s.logger.Infof("%s: 计划已成功启动, ID: %d", actionType, id) | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // StopPlan 停止计划 | ||||
| func (s *planService) StopPlan(id uint) error { | ||||
| 	const actionType = "服务层:停止计划" | ||||
|  | ||||
| 	plan, err := s.planRepo.GetBasicPlanByID(id) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, gorm.ErrRecordNotFound) { | ||||
| 			s.logger.Warnf("%s: 计划不存在, ID: %d", actionType, id) | ||||
| 			return ErrPlanNotFound | ||||
| 		} | ||||
| 		s.logger.Errorf("%s: 获取计划信息失败: %v, ID: %d", actionType, err, id) | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	if plan.PlanType == models.PlanTypeSystem { | ||||
| 		s.logger.Warnf("%s: 尝试停止系统计划, ID: %d", actionType, id) | ||||
| 		return ErrPlanCannotBeStopped | ||||
| 	} | ||||
|  | ||||
| 	if plan.Status != models.PlanStatusEnabled { | ||||
| 		s.logger.Warnf("%s: 计划当前不是启用状态, ID: %d, Status: %s", actionType, id, plan.Status) | ||||
| 		return ErrPlanNotEnabled | ||||
| 	} | ||||
|  | ||||
| 	if err := s.planRepo.StopPlanTransactionally(id); err != nil { | ||||
| 		s.logger.Errorf("%s: 停止计划失败: %v, ID: %d", actionType, err, id) | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	s.logger.Infof("%s: 计划已成功停止, ID: %d", actionType, id) | ||||
| 	return nil | ||||
| } | ||||
							
								
								
									
										110
									
								
								internal/app/service/user_service.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								internal/app/service/user_service.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,110 @@ | ||||
| package service | ||||
|  | ||||
| import ( | ||||
| 	"errors" | ||||
|  | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/dto" | ||||
| 	domain_notify "git.huangwc.com/pig/pig-farm-controller/internal/domain/notify" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/domain/token" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" | ||||
| 	"gorm.io/gorm" | ||||
| ) | ||||
|  | ||||
| // UserService 定义用户服务接口 | ||||
| type UserService interface { | ||||
| 	CreateUser(req *dto.CreateUserRequest) (*dto.CreateUserResponse, error) | ||||
| 	Login(req *dto.LoginRequest) (*dto.LoginResponse, error) | ||||
| 	SendTestNotification(userID uint, req *dto.SendTestNotificationRequest) error | ||||
| } | ||||
|  | ||||
| // userService 实现了 UserService 接口 | ||||
| type userService struct { | ||||
| 	userRepo      repository.UserRepository | ||||
| 	tokenService  token.Service | ||||
| 	notifyService domain_notify.Service | ||||
| 	logger        *logs.Logger | ||||
| } | ||||
|  | ||||
| // NewUserService 创建并返回一个新的 UserService 实例 | ||||
| func NewUserService( | ||||
| 	userRepo repository.UserRepository, | ||||
| 	tokenService token.Service, | ||||
| 	notifyService domain_notify.Service, | ||||
| 	logger *logs.Logger, | ||||
| ) UserService { | ||||
| 	return &userService{ | ||||
| 		userRepo:      userRepo, | ||||
| 		tokenService:  tokenService, | ||||
| 		notifyService: notifyService, | ||||
| 		logger:        logger, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // CreateUser 创建新用户 | ||||
| func (s *userService) CreateUser(req *dto.CreateUserRequest) (*dto.CreateUserResponse, error) { | ||||
| 	user := &models.User{ | ||||
| 		Username: req.Username, | ||||
| 		Password: req.Password, // 密码会在 BeforeSave 钩子中哈希 | ||||
| 	} | ||||
|  | ||||
| 	if err := s.userRepo.Create(user); err != nil { | ||||
| 		s.logger.Errorf("创建用户: 创建用户失败: %v", err) | ||||
|  | ||||
| 		// 尝试查询用户,以判断是否是用户名重复导致的错误 | ||||
| 		_, findErr := s.userRepo.FindByUsername(req.Username) | ||||
| 		if findErr == nil { // 如果能找到用户,说明是用户名重复 | ||||
| 			return nil, errors.New("用户名已存在") | ||||
| 		} | ||||
|  | ||||
| 		// 其他创建失败的情况 | ||||
| 		return nil, errors.New("创建用户失败") | ||||
| 	} | ||||
|  | ||||
| 	return &dto.CreateUserResponse{ | ||||
| 		Username: user.Username, | ||||
| 		ID:       user.ID, | ||||
| 	}, nil | ||||
| } | ||||
|  | ||||
| // Login 用户登录 | ||||
| func (s *userService) Login(req *dto.LoginRequest) (*dto.LoginResponse, error) { | ||||
| 	// 使用新的方法,通过唯一标识符(用户名、邮箱等)查找用户 | ||||
| 	user, err := s.userRepo.FindUserForLogin(req.Identifier) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, gorm.ErrRecordNotFound) { | ||||
| 			return nil, errors.New("登录凭证不正确") | ||||
| 		} | ||||
| 		s.logger.Errorf("登录: 查询用户失败: %v", err) | ||||
| 		return nil, errors.New("登录失败") | ||||
| 	} | ||||
|  | ||||
| 	if !user.CheckPassword(req.Password) { | ||||
| 		return nil, errors.New("登录凭证不正确") | ||||
| 	} | ||||
|  | ||||
| 	// 登录成功,生成 JWT token | ||||
| 	tokenString, err := s.tokenService.GenerateToken(user.ID) | ||||
| 	if err != nil { | ||||
| 		s.logger.Errorf("登录: 生成令牌失败: %v", err) | ||||
| 		return nil, errors.New("登录失败,无法生成认证信息") | ||||
| 	} | ||||
|  | ||||
| 	return &dto.LoginResponse{ | ||||
| 		Username: user.Username, | ||||
| 		ID:       user.ID, | ||||
| 		Token:    tokenString, | ||||
| 	}, nil | ||||
| } | ||||
|  | ||||
| // SendTestNotification 发送测试通知 | ||||
| func (s *userService) SendTestNotification(userID uint, req *dto.SendTestNotificationRequest) error { | ||||
| 	err := s.notifyService.SendTestMessage(userID, req.Type) | ||||
| 	if err != nil { | ||||
| 		s.logger.Errorf("发送测试通知: 服务层调用失败: %v", err) | ||||
| 		return errors.New("发送测试消息失败: " + err.Error()) | ||||
| 	} | ||||
| 	s.logger.Infof("发送测试通知: 成功为用户 %d 发送类型为 %s 的测试消息", userID, req.Type) | ||||
| 	return nil | ||||
| } | ||||
| @@ -5,328 +5,91 @@ import ( | ||||
| 	"os" | ||||
| 	"os/signal" | ||||
| 	"syscall" | ||||
| 	"time" | ||||
|  | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/api" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/service" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/webhook" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/domain/audit" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/domain/device" | ||||
| 	domain_notify "git.huangwc.com/pig/pig-farm-controller/internal/domain/notify" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/domain/pig" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/domain/task" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/domain/token" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/config" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/database" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/notify" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/transport" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/transport/lora" | ||||
| ) | ||||
|  | ||||
| // Application 是整个应用的核心,封装了所有组件和生命周期。 | ||||
| type Application struct { | ||||
| 	Config   *config.Config | ||||
| 	Logger   *logs.Logger | ||||
| 	Storage  database.Storage | ||||
| 	Executor *task.Scheduler | ||||
| 	API      *api.API // 添加 API 对象 | ||||
| 	Config *config.Config | ||||
| 	Logger *logs.Logger | ||||
| 	API    *api.API | ||||
|  | ||||
| 	// 新增的仓库和管理器字段,以便在 initializePendingTasks 中访问 | ||||
| 	planRepo                repository.PlanRepository | ||||
| 	pendingTaskRepo         repository.PendingTaskRepository | ||||
| 	executionLogRepo        repository.ExecutionLogRepository | ||||
| 	pendingCollectionRepo   repository.PendingCollectionRepository | ||||
| 	analysisPlanTaskManager *task.AnalysisPlanTaskManager | ||||
|  | ||||
| 	// Lora Mesh 监听器 | ||||
| 	loraMeshCommunicator transport.Listener | ||||
|  | ||||
| 	// 通知服务 | ||||
| 	NotifyService domain_notify.Service | ||||
| 	Infra  *Infrastructure | ||||
| 	Domain *DomainServices | ||||
| 	App    *AppServices | ||||
| } | ||||
|  | ||||
| // NewApplication 创建并初始化一个新的 Application 实例。 | ||||
| // 这是应用的“组合根”,所有依赖都在这里被创建和注入。 | ||||
| func NewApplication(configPath string) (*Application, error) { | ||||
| 	//  加载配置 | ||||
| 	// 1. 初始化基本组件: 配置和日志 | ||||
| 	cfg := config.NewConfig() | ||||
| 	if err := cfg.Load(configPath); err != nil { | ||||
| 		return nil, fmt.Errorf("无法加载配置: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	//  初始化日志记录器 | ||||
| 	logger := logs.NewLogger(cfg.Log) | ||||
|  | ||||
| 	//  初始化数据库存储 | ||||
| 	storage, err := initStorage(cfg.Database, logger) | ||||
| 	// 2. 初始化所有分层服务 | ||||
| 	infra, err := initInfrastructure(cfg, logger) | ||||
| 	if err != nil { | ||||
| 		return nil, err // 错误已在 initStorage 中被包装 | ||||
| 		return nil, fmt.Errorf("初始化基础设施失败: %w", err) | ||||
| 	} | ||||
| 	domain := initDomainServices(cfg, infra, logger) | ||||
| 	appServices := initAppServices(infra, domain, logger) | ||||
|  | ||||
| 	//  初始化 Token 服务 | ||||
| 	tokenService := token.NewTokenService([]byte(cfg.App.JWTSecret)) | ||||
|  | ||||
| 	// --- 仓库对象初始化 --- | ||||
| 	userRepo := repository.NewGormUserRepository(storage.GetDB()) | ||||
| 	deviceRepo := repository.NewGormDeviceRepository(storage.GetDB()) | ||||
| 	areaControllerRepo := repository.NewGormAreaControllerRepository(storage.GetDB()) | ||||
| 	deviceTemplateRepo := repository.NewGormDeviceTemplateRepository(storage.GetDB()) | ||||
| 	planRepo := repository.NewGormPlanRepository(storage.GetDB()) | ||||
| 	pendingTaskRepo := repository.NewGormPendingTaskRepository(storage.GetDB()) | ||||
| 	executionLogRepo := repository.NewGormExecutionLogRepository(storage.GetDB()) | ||||
| 	sensorDataRepo := repository.NewGormSensorDataRepository(storage.GetDB()) | ||||
| 	deviceCommandLogRepo := repository.NewGormDeviceCommandLogRepository(storage.GetDB()) | ||||
| 	pendingCollectionRepo := repository.NewGormPendingCollectionRepository(storage.GetDB()) | ||||
| 	userActionLogRepo := repository.NewGormUserActionLogRepository(storage.GetDB()) | ||||
| 	pigBatchRepo := repository.NewGormPigBatchRepository(storage.GetDB()) | ||||
| 	pigBatchLogRepo := repository.NewGormPigBatchLogRepository(storage.GetDB()) | ||||
| 	pigFarmRepo := repository.NewGormPigFarmRepository(storage.GetDB()) | ||||
| 	pigPenRepo := repository.NewGormPigPenRepository(storage.GetDB()) | ||||
| 	pigTransferLogRepo := repository.NewGormPigTransferLogRepository(storage.GetDB()) | ||||
| 	pigTradeRepo := repository.NewGormPigTradeRepository(storage.GetDB()) | ||||
| 	pigSickPigLogRepo := repository.NewGormPigSickLogRepository(storage.GetDB()) | ||||
| 	medicationLogRepo := repository.NewGormMedicationLogRepository(storage.GetDB()) | ||||
| 	rawMaterialRepo := repository.NewGormRawMaterialRepository(storage.GetDB()) | ||||
|  | ||||
| 	// 初始化事务管理器 | ||||
| 	unitOfWork := repository.NewGormUnitOfWork(storage.GetDB(), logger) | ||||
|  | ||||
| 	// 初始化猪群管理领域 | ||||
| 	pigPenTransferManager := pig.NewPigPenTransferManager(pigPenRepo, pigTransferLogRepo, pigBatchRepo) | ||||
| 	pigTradeManager := pig.NewPigTradeManager(pigTradeRepo) | ||||
| 	pigSickManager := pig.NewSickPigManager(pigSickPigLogRepo, medicationLogRepo) | ||||
| 	pigBatchDomain := pig.NewPigBatchService(pigBatchRepo, pigBatchLogRepo, unitOfWork, | ||||
| 		pigPenTransferManager, pigTradeManager, pigSickManager) | ||||
|  | ||||
| 	// --- 业务逻辑处理器初始化 --- | ||||
| 	pigFarmService := service.NewPigFarmService(pigFarmRepo, pigPenRepo, pigBatchRepo, pigBatchDomain, unitOfWork, logger) | ||||
| 	pigBatchService := service.NewPigBatchService(pigBatchDomain, logger) | ||||
| 	monitorService := service.NewMonitorService( | ||||
| 		sensorDataRepo, | ||||
| 		deviceCommandLogRepo, | ||||
| 		executionLogRepo, | ||||
| 		pendingCollectionRepo, | ||||
| 		userActionLogRepo, | ||||
| 		rawMaterialRepo, | ||||
| 		medicationLogRepo, | ||||
| 		pigBatchRepo, | ||||
| 		pigBatchLogRepo, | ||||
| 		pigTransferLogRepo, | ||||
| 		pigSickPigLogRepo, | ||||
| 		pigTradeRepo, | ||||
| 	) | ||||
|  | ||||
| 	// 初始化审计服务 | ||||
| 	auditService := audit.NewService(userActionLogRepo, logger) | ||||
|  | ||||
| 	// 初始化通知服务 | ||||
| 	notifyService, err := initNotifyService(cfg.Notify, logger, userRepo) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("初始化通知服务失败: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	// --- 初始化 LoRa 相关组件 --- | ||||
| 	var listenHandler webhook.ListenHandler | ||||
| 	var comm transport.Communicator | ||||
| 	var loraListener transport.Listener | ||||
|  | ||||
| 	if cfg.Lora.Mode == config.LoraMode_LoRaWAN { | ||||
| 		logger.Info("当前运行模式: lora_wan。初始化 ChirpStack 监听器和传输层。") | ||||
| 		listenHandler = webhook.NewChirpStackListener(logger, sensorDataRepo, deviceRepo, areaControllerRepo, deviceCommandLogRepo, pendingCollectionRepo) | ||||
| 		comm = lora.NewChirpStackTransport(cfg.ChirpStack, logger) | ||||
| 		loraListener = lora.NewPlaceholderTransport(logger) | ||||
| 	} else { | ||||
| 		logger.Info("当前运行模式: lora_mesh。初始化 LoRa Mesh 传输层和占位符监听器。") | ||||
| 		listenHandler = webhook.NewPlaceholderListener(logger) | ||||
| 		tp, err := lora.NewLoRaMeshUartPassthroughTransport(cfg.LoraMesh, logger, areaControllerRepo, pendingCollectionRepo, deviceRepo, sensorDataRepo) | ||||
| 		loraListener = tp | ||||
| 		comm = tp | ||||
| 		if err != nil { | ||||
| 			return nil, fmt.Errorf("无法初始化 LoRa Mesh 模块: %w", err) | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// 初始化计划触发器管理器 | ||||
| 	analysisPlanTaskManager := task.NewAnalysisPlanTaskManager(planRepo, pendingTaskRepo, executionLogRepo, logger) | ||||
|  | ||||
| 	// 初始化通用设备服务 | ||||
| 	generalDeviceService := device.NewGeneralDeviceService( | ||||
| 		deviceRepo, | ||||
| 		deviceCommandLogRepo, | ||||
| 		pendingCollectionRepo, | ||||
| 		logger, | ||||
| 		comm, | ||||
| 	) | ||||
|  | ||||
| 	//  初始化任务执行器 | ||||
| 	executor := task.NewScheduler( | ||||
| 		pendingTaskRepo, | ||||
| 		executionLogRepo, | ||||
| 		deviceRepo, | ||||
| 		sensorDataRepo, | ||||
| 		planRepo, | ||||
| 		analysisPlanTaskManager, | ||||
| 		logger, | ||||
| 		generalDeviceService, | ||||
| 		time.Duration(cfg.Task.Interval)*time.Second, | ||||
| 		cfg.Task.NumWorkers, | ||||
| 	) | ||||
|  | ||||
| 	//  初始化 API 服务器 | ||||
| 	// 3. 初始化 API 入口点 | ||||
| 	apiServer := api.NewAPI( | ||||
| 		cfg.Server, | ||||
| 		logger, | ||||
| 		userRepo, | ||||
| 		deviceRepo, | ||||
| 		areaControllerRepo, | ||||
| 		deviceTemplateRepo, | ||||
| 		planRepo, | ||||
| 		pigFarmService, | ||||
| 		pigBatchService, | ||||
| 		monitorService, | ||||
| 		tokenService, | ||||
| 		auditService, | ||||
| 		notifyService, | ||||
| 		generalDeviceService, | ||||
| 		listenHandler, | ||||
| 		analysisPlanTaskManager, | ||||
| 		infra.Repos.UserRepo, | ||||
| 		appServices.PigFarmService, | ||||
| 		appServices.PigBatchService, | ||||
| 		appServices.MonitorService, | ||||
| 		appServices.DeviceService, | ||||
| 		appServices.PlanService, | ||||
| 		appServices.UserService, | ||||
| 		infra.TokenService, | ||||
| 		appServices.AuditService, | ||||
| 		infra.Lora.ListenHandler, | ||||
| 	) | ||||
|  | ||||
| 	//  组装 Application 对象 | ||||
| 	// 4. 组装 Application 对象 | ||||
| 	app := &Application{ | ||||
| 		Config:                  cfg, | ||||
| 		Logger:                  logger, | ||||
| 		Storage:                 storage, | ||||
| 		Executor:                executor, | ||||
| 		API:                     apiServer, | ||||
| 		planRepo:                planRepo, | ||||
| 		pendingTaskRepo:         pendingTaskRepo, | ||||
| 		executionLogRepo:        executionLogRepo, | ||||
| 		pendingCollectionRepo:   pendingCollectionRepo, | ||||
| 		analysisPlanTaskManager: analysisPlanTaskManager, | ||||
| 		loraMeshCommunicator:    loraListener, | ||||
| 		NotifyService:           notifyService, | ||||
| 		Config: cfg, | ||||
| 		Logger: logger, | ||||
| 		API:    apiServer, | ||||
| 		Infra:  infra, | ||||
| 		Domain: domain, | ||||
| 		App:    appServices, | ||||
| 	} | ||||
|  | ||||
| 	return app, nil | ||||
| } | ||||
|  | ||||
| // initNotifyService 根据配置初始化并返回一个通知领域服务。 | ||||
| // 它确保至少有一个 LogNotifier 总是可用,并根据配置启用其他通知器。 | ||||
| func initNotifyService( | ||||
| 	cfg config.NotifyConfig, | ||||
| 	log *logs.Logger, | ||||
| 	userRepo repository.UserRepository, | ||||
| ) (domain_notify.Service, error) { | ||||
| 	var availableNotifiers []notify.Notifier | ||||
|  | ||||
| 	// 1. 总是创建 LogNotifier 作为所有告警的最终记录渠道 | ||||
| 	logNotifier := notify.NewLogNotifier(log) | ||||
| 	availableNotifiers = append(availableNotifiers, logNotifier) | ||||
| 	log.Info("Log通知器已启用 (作为所有告警的最终记录渠道)") | ||||
|  | ||||
| 	// 2. 根据配置,按需创建并收集所有启用的其他 Notifier 实例 | ||||
| 	if cfg.SMTP.Enabled { | ||||
| 		smtpNotifier := notify.NewSMTPNotifier( | ||||
| 			cfg.SMTP.Host, | ||||
| 			cfg.SMTP.Port, | ||||
| 			cfg.SMTP.Username, | ||||
| 			cfg.SMTP.Password, | ||||
| 			cfg.SMTP.Sender, | ||||
| 		) | ||||
| 		availableNotifiers = append(availableNotifiers, smtpNotifier) | ||||
| 		log.Info("SMTP通知器已启用") | ||||
| 	} | ||||
|  | ||||
| 	if cfg.WeChat.Enabled { | ||||
| 		wechatNotifier := notify.NewWechatNotifier( | ||||
| 			cfg.WeChat.CorpID, | ||||
| 			cfg.WeChat.AgentID, | ||||
| 			cfg.WeChat.Secret, | ||||
| 		) | ||||
| 		availableNotifiers = append(availableNotifiers, wechatNotifier) | ||||
| 		log.Info("企业微信通知器已启用") | ||||
| 	} | ||||
|  | ||||
| 	if cfg.Lark.Enabled { | ||||
| 		larkNotifier := notify.NewLarkNotifier( | ||||
| 			cfg.Lark.AppID, | ||||
| 			cfg.Lark.AppSecret, | ||||
| 		) | ||||
| 		availableNotifiers = append(availableNotifiers, larkNotifier) | ||||
| 		log.Info("飞书通知器已启用") | ||||
| 	} | ||||
|  | ||||
| 	// 3. 动态确定首选通知器 | ||||
| 	var primaryNotifier notify.Notifier | ||||
| 	primaryNotifierType := notify.NotifierType(cfg.Primary) | ||||
|  | ||||
| 	// 检查用户指定的主渠道是否已启用 | ||||
| 	for _, n := range availableNotifiers { | ||||
| 		if n.Type() == primaryNotifierType { | ||||
| 			primaryNotifier = n | ||||
| 			break | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// 如果用户指定的主渠道未启用或未指定,则自动选择第一个可用的 (这将是 LogNotifier,如果其他都未启用) | ||||
| 	if primaryNotifier == nil { | ||||
| 		primaryNotifier = availableNotifiers[0] // 确保总能找到一个,因为 LogNotifier 总是存在的 | ||||
| 		log.Warnf("配置的首选渠道 '%s' 未启用或未指定,已自动降级使用 '%s' 作为首选渠道。", cfg.Primary, primaryNotifier.Type()) | ||||
| 	} | ||||
|  | ||||
| 	// 4. 使用创建的 Notifier 列表来组装领域服务 | ||||
| 	notifyService, err := domain_notify.NewFailoverService( | ||||
| 		log, | ||||
| 		userRepo, | ||||
| 		availableNotifiers, | ||||
| 		primaryNotifier.Type(), | ||||
| 		cfg.FailureThreshold, | ||||
| 	) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("创建故障转移通知服务失败: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	log.Infof("通知服务初始化成功,首选渠道: %s, 故障阈值: %d", primaryNotifier.Type(), cfg.FailureThreshold) | ||||
| 	return notifyService, nil | ||||
| } | ||||
|  | ||||
| // Start 启动应用的所有组件并阻塞,直到接收到关闭信号。 | ||||
| func (app *Application) Start() error { | ||||
| 	app.Logger.Info("应用启动中...") | ||||
|  | ||||
| 	// -- 启动 LoRa Mesh 监听器 | ||||
| 	if err := app.loraMeshCommunicator.Listen(); err != nil { | ||||
| 	// 1. 启动底层监听器 | ||||
| 	if err := app.Infra.Lora.LoraListener.Listen(); err != nil { | ||||
| 		return fmt.Errorf("启动 LoRa Mesh 监听器失败: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	// --- 清理待采集任务 --- | ||||
| 	if err := app.initializePendingCollections(); err != nil { | ||||
| 		// 这是一个非致命错误,记录它,但应用应继续启动 | ||||
| 		app.Logger.Error(err) | ||||
| 	// 2. 初始化应用状态 (清理、刷新任务等) | ||||
| 	if err := app.initializeState(); err != nil { | ||||
| 		return fmt.Errorf("初始化应用状态失败: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	// --- 初始化待执行任务列表 --- | ||||
| 	if err := app.initializePendingTasks( | ||||
| 		app.planRepo,                // 传入 planRepo | ||||
| 		app.pendingTaskRepo,         // 传入 pendingTaskRepo | ||||
| 		app.executionLogRepo,        // 传入 executionLogRepo | ||||
| 		app.analysisPlanTaskManager, // 传入 analysisPlanTaskManager | ||||
| 		app.Logger,                  // 传入 logger | ||||
| 	); err != nil { | ||||
| 		return fmt.Errorf("初始化待执行任务列表失败: %w", err) | ||||
| 	} | ||||
| 	// 3. 启动后台工作协程 | ||||
| 	app.Domain.Scheduler.Start() | ||||
|  | ||||
| 	// 启动任务执行器 | ||||
| 	app.Executor.Start() | ||||
|  | ||||
| 	// 启动 API 服务器 | ||||
| 	// 4. 启动 API 服务器 | ||||
| 	app.API.Start() | ||||
|  | ||||
| 	// 等待关闭信号 | ||||
| 	// 5. 等待关闭信号 | ||||
| 	quit := make(chan os.Signal, 1) | ||||
| 	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) | ||||
| 	<-quit | ||||
| @@ -343,15 +106,15 @@ func (app *Application) Stop() error { | ||||
| 	app.API.Stop() | ||||
|  | ||||
| 	// 关闭任务执行器 | ||||
| 	app.Executor.Stop() | ||||
| 	app.Domain.Scheduler.Stop() | ||||
|  | ||||
| 	// 断开数据库连接 | ||||
| 	if err := app.Storage.Disconnect(); err != nil { | ||||
| 	if err := app.Infra.Storage.Disconnect(); err != nil { | ||||
| 		app.Logger.Errorw("数据库连接断开失败", "error", err) | ||||
| 	} | ||||
|  | ||||
| 	// 关闭 LoRa Mesh 监听器 | ||||
| 	if err := app.loraMeshCommunicator.Stop(); err != nil { | ||||
| 	if err := app.Infra.Lora.LoraListener.Stop(); err != nil { | ||||
| 		app.Logger.Errorw("LoRa Mesh 监听器关闭失败", "error", err) | ||||
| 	} | ||||
|  | ||||
| @@ -361,135 +124,3 @@ func (app *Application) Stop() error { | ||||
| 	app.Logger.Info("应用已成功关闭") | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // initializePendingCollections 在应用启动时处理所有未完成的采集请求。 | ||||
| // 我们的策略是:任何在程序重启前仍处于“待处理”状态的请求,都应被视为已失败。 | ||||
| // 这保证了系统在每次启动时都处于一个干净、确定的状态。 | ||||
| func (app *Application) initializePendingCollections() error { | ||||
| 	app.Logger.Info("开始清理所有未完成的采集请求...") | ||||
|  | ||||
| 	// 直接将所有 'pending' 状态的请求更新为 'timed_out'。 | ||||
| 	count, err := app.pendingCollectionRepo.MarkAllPendingAsTimedOut() | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("清理未完成的采集请求失败: %v", err) | ||||
| 	} else if count > 0 { | ||||
| 		app.Logger.Infof("成功将 %d 个未完成的采集请求标记为超时。", count) | ||||
| 	} else { | ||||
| 		app.Logger.Info("没有需要清理的采集请求。") | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // initializePendingTasks 在应用启动时清理并刷新待执行任务列表。 | ||||
| func (app *Application) initializePendingTasks( | ||||
| 	planRepo repository.PlanRepository, | ||||
| 	pendingTaskRepo repository.PendingTaskRepository, | ||||
| 	executionLogRepo repository.ExecutionLogRepository, | ||||
| 	analysisPlanTaskManager *task.AnalysisPlanTaskManager, | ||||
| 	logger *logs.Logger, | ||||
| ) error { | ||||
| 	logger.Info("开始初始化待执行任务列表...") | ||||
|  | ||||
| 	// 阶段一:修正因崩溃导致状态不一致的固定次数计划 | ||||
| 	logger.Info("阶段一:开始修正因崩溃导致状态不一致的固定次数计划...") | ||||
| 	plansToCorrect, err := planRepo.FindPlansWithPendingTasks() | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("查找需要修正的计划失败: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	for _, plan := range plansToCorrect { | ||||
| 		logger.Infof("发现需要修正的计划 #%d (名称: %s)。", plan.ID, plan.Name) | ||||
|  | ||||
| 		// 更新计划的执行计数 | ||||
| 		plan.ExecuteCount++ | ||||
| 		logger.Infof("计划 #%d 执行计数已从 %d 更新为 %d。", plan.ID, plan.ExecuteCount-1, plan.ExecuteCount) | ||||
|  | ||||
| 		if plan.ExecutionType == models.PlanExecutionTypeManual || | ||||
| 			(plan.ExecutionType == models.PlanExecutionTypeAutomatic && plan.ExecuteCount >= plan.ExecuteNum) { | ||||
| 			// 更新计划状态为已停止 | ||||
| 			plan.Status = models.PlanStatusStopped | ||||
| 			logger.Infof("计划 #%d 状态已更新为 '执行完毕'。", plan.ID) | ||||
|  | ||||
| 		} | ||||
| 		// 保存更新后的计划 | ||||
| 		if err := planRepo.UpdatePlan(plan); err != nil { | ||||
| 			logger.Errorf("修正计划 #%d 状态失败: %v", plan.ID, err) | ||||
| 			// 这是一个非阻塞性错误,继续处理其他计划 | ||||
| 		} | ||||
| 	} | ||||
| 	logger.Info("阶段一:固定次数计划修正完成。") | ||||
|  | ||||
| 	// 阶段二:清理所有待执行任务和相关日志 | ||||
| 	logger.Info("阶段二:开始清理所有待执行任务和相关日志...") | ||||
|  | ||||
| 	// --- 新增逻辑:处理因崩溃导致状态不一致的计划主表状态 --- | ||||
| 	// 1. 查找所有未完成的计划执行日志 (状态为 Started 或 Waiting) | ||||
| 	incompletePlanLogs, err := executionLogRepo.FindIncompletePlanExecutionLogs() | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("查找未完成的计划执行日志失败: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	// 2. 收集所有受影响的唯一 PlanID | ||||
| 	affectedPlanIDs := make(map[uint]struct{}) | ||||
| 	for _, log := range incompletePlanLogs { | ||||
| 		affectedPlanIDs[log.PlanID] = struct{}{} | ||||
| 	} | ||||
|  | ||||
| 	// 3. 对于每个受影响的 PlanID,重置其 execute_count 并将其状态设置为 Failed | ||||
| 	for planID := range affectedPlanIDs { | ||||
| 		logger.Warnf("检测到计划 #%d 在应用崩溃前处于未完成状态,将重置其计数并标记为失败。", planID) | ||||
| 		// 使用 UpdatePlanStateAfterExecution 来更新主表状态,避免影响关联数据 | ||||
| 		if err := planRepo.UpdatePlanStateAfterExecution(planID, 0, models.PlanStatusFailed); err != nil { | ||||
| 			logger.Errorf("重置计划 #%d 计数并标记为失败时出错: %v", planID, err) | ||||
| 			// 这是一个非阻塞性错误,继续处理其他计划 | ||||
| 		} | ||||
| 	} | ||||
| 	logger.Info("阶段二:计划主表状态修正完成。") | ||||
|  | ||||
| 	// 直接调用新的方法来更新计划执行日志状态为失败 | ||||
| 	if err := executionLogRepo.FailAllIncompletePlanExecutionLogs(); err != nil { | ||||
| 		logger.Errorf("更新所有未完成计划执行日志状态为失败失败: %v", err) | ||||
| 		// 这是一个非阻塞性错误,继续执行 | ||||
| 	} | ||||
|  | ||||
| 	// 直接调用新的方法来更新任务执行日志状态为取消 | ||||
| 	if err := executionLogRepo.CancelAllIncompleteTaskExecutionLogs(); err != nil { | ||||
| 		logger.Errorf("更新所有未完成任务执行日志状态为取消失败: %v", err) | ||||
| 		// 这是一个非阻塞性错误,继续执行 | ||||
| 	} | ||||
|  | ||||
| 	// 清空待执行列表 | ||||
| 	if err := pendingTaskRepo.ClearAllPendingTasks(); err != nil { | ||||
| 		return fmt.Errorf("清空待执行任务列表失败: %w", err) | ||||
| 	} | ||||
| 	logger.Info("阶段二:待执行任务和相关日志清理完成。") | ||||
|  | ||||
| 	// 阶段三:初始刷新 | ||||
| 	logger.Info("阶段三:开始刷新待执行列表...") | ||||
| 	if err := analysisPlanTaskManager.Refresh(); err != nil { | ||||
| 		return fmt.Errorf("刷新待执行任务列表失败: %w", err) | ||||
| 	} | ||||
| 	logger.Info("阶段三:待执行任务列表初始化完成。") | ||||
|  | ||||
| 	logger.Info("待执行任务列表初始化完成。") | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // initStorage 封装了数据库的初始化、连接和迁移逻辑。 | ||||
| func initStorage(cfg config.DatabaseConfig, logger *logs.Logger) (database.Storage, error) { | ||||
| 	// 创建存储实例 | ||||
| 	storage := database.NewStorage(cfg, logger) | ||||
| 	if err := storage.Connect(); err != nil { | ||||
| 		// 错误已在 Connect 内部被记录,这里只需包装并返回 | ||||
| 		return nil, fmt.Errorf("数据库连接失败: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	// 执行数据库迁移 | ||||
| 	if err := storage.Migrate(models.GetAllModels()...); err != nil { | ||||
| 		return nil, fmt.Errorf("数据库迁移失败: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	logger.Info("数据库初始化完成。") | ||||
| 	return storage, nil | ||||
| } | ||||
|   | ||||
							
								
								
									
										373
									
								
								internal/core/component_initializers.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										373
									
								
								internal/core/component_initializers.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,373 @@ | ||||
| package core | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"time" | ||||
|  | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/service" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/webhook" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/domain/audit" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/domain/device" | ||||
| 	domain_notify "git.huangwc.com/pig/pig-farm-controller/internal/domain/notify" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/domain/pig" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/domain/scheduler" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/domain/task" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/domain/token" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/config" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/database" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/notify" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/transport" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/transport/lora" | ||||
| 	"gorm.io/gorm" | ||||
| ) | ||||
|  | ||||
| // Infrastructure 聚合了所有基础设施层的组件。 | ||||
| type Infrastructure struct { | ||||
| 	Storage       database.Storage | ||||
| 	Repos         *Repositories | ||||
| 	Lora          *LoraComponents | ||||
| 	NotifyService domain_notify.Service | ||||
| 	TokenService  token.Service | ||||
| } | ||||
|  | ||||
| // initInfrastructure 初始化所有基础设施层组件。 | ||||
| func initInfrastructure(cfg *config.Config, logger *logs.Logger) (*Infrastructure, error) { | ||||
| 	storage, err := initStorage(cfg.Database, logger) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	repos := initRepositories(storage.GetDB(), logger) | ||||
|  | ||||
| 	lora, err := initLora(cfg, logger, repos) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
|  | ||||
| 	notifyService, err := initNotifyService(cfg.Notify, logger, repos.UserRepo, repos.NotificationRepo) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("初始化通知服务失败: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	tokenService := token.NewTokenService([]byte(cfg.App.JWTSecret)) | ||||
|  | ||||
| 	return &Infrastructure{ | ||||
| 		Storage:       storage, | ||||
| 		Repos:         repos, | ||||
| 		Lora:          lora, | ||||
| 		NotifyService: notifyService, | ||||
| 		TokenService:  tokenService, | ||||
| 	}, nil | ||||
| } | ||||
|  | ||||
| // Repositories 聚合了所有的仓库实例。 | ||||
| type Repositories struct { | ||||
| 	UserRepo              repository.UserRepository | ||||
| 	DeviceRepo            repository.DeviceRepository | ||||
| 	AreaControllerRepo    repository.AreaControllerRepository | ||||
| 	DeviceTemplateRepo    repository.DeviceTemplateRepository | ||||
| 	PlanRepo              repository.PlanRepository | ||||
| 	PendingTaskRepo       repository.PendingTaskRepository | ||||
| 	ExecutionLogRepo      repository.ExecutionLogRepository | ||||
| 	SensorDataRepo        repository.SensorDataRepository | ||||
| 	DeviceCommandLogRepo  repository.DeviceCommandLogRepository | ||||
| 	PendingCollectionRepo repository.PendingCollectionRepository | ||||
| 	UserActionLogRepo     repository.UserActionLogRepository | ||||
| 	PigBatchRepo          repository.PigBatchRepository | ||||
| 	PigBatchLogRepo       repository.PigBatchLogRepository | ||||
| 	PigFarmRepo           repository.PigFarmRepository | ||||
| 	PigPenRepo            repository.PigPenRepository | ||||
| 	PigTransferLogRepo    repository.PigTransferLogRepository | ||||
| 	PigTradeRepo          repository.PigTradeRepository | ||||
| 	PigSickPigLogRepo     repository.PigSickLogRepository | ||||
| 	MedicationLogRepo     repository.MedicationLogRepository | ||||
| 	RawMaterialRepo       repository.RawMaterialRepository | ||||
| 	NotificationRepo      repository.NotificationRepository | ||||
| 	UnitOfWork            repository.UnitOfWork | ||||
| } | ||||
|  | ||||
| // initRepositories 初始化所有的仓库。 | ||||
| func initRepositories(db *gorm.DB, logger *logs.Logger) *Repositories { | ||||
| 	return &Repositories{ | ||||
| 		UserRepo:              repository.NewGormUserRepository(db), | ||||
| 		DeviceRepo:            repository.NewGormDeviceRepository(db), | ||||
| 		AreaControllerRepo:    repository.NewGormAreaControllerRepository(db), | ||||
| 		DeviceTemplateRepo:    repository.NewGormDeviceTemplateRepository(db), | ||||
| 		PlanRepo:              repository.NewGormPlanRepository(db), | ||||
| 		PendingTaskRepo:       repository.NewGormPendingTaskRepository(db), | ||||
| 		ExecutionLogRepo:      repository.NewGormExecutionLogRepository(db), | ||||
| 		SensorDataRepo:        repository.NewGormSensorDataRepository(db), | ||||
| 		DeviceCommandLogRepo:  repository.NewGormDeviceCommandLogRepository(db), | ||||
| 		PendingCollectionRepo: repository.NewGormPendingCollectionRepository(db), | ||||
| 		UserActionLogRepo:     repository.NewGormUserActionLogRepository(db), | ||||
| 		PigBatchRepo:          repository.NewGormPigBatchRepository(db), | ||||
| 		PigBatchLogRepo:       repository.NewGormPigBatchLogRepository(db), | ||||
| 		PigFarmRepo:           repository.NewGormPigFarmRepository(db), | ||||
| 		PigPenRepo:            repository.NewGormPigPenRepository(db), | ||||
| 		PigTransferLogRepo:    repository.NewGormPigTransferLogRepository(db), | ||||
| 		PigTradeRepo:          repository.NewGormPigTradeRepository(db), | ||||
| 		PigSickPigLogRepo:     repository.NewGormPigSickLogRepository(db), | ||||
| 		MedicationLogRepo:     repository.NewGormMedicationLogRepository(db), | ||||
| 		RawMaterialRepo:       repository.NewGormRawMaterialRepository(db), | ||||
| 		NotificationRepo:      repository.NewGormNotificationRepository(db), | ||||
| 		UnitOfWork:            repository.NewGormUnitOfWork(db, logger), | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // DomainServices 聚合了所有的领域服务实例。 | ||||
| type DomainServices struct { | ||||
| 	PigPenTransferManager   pig.PigPenTransferManager | ||||
| 	PigTradeManager         pig.PigTradeManager | ||||
| 	PigSickManager          pig.SickPigManager | ||||
| 	PigBatchDomain          pig.PigBatchService | ||||
| 	GeneralDeviceService    device.Service | ||||
| 	taskFactory             scheduler.TaskFactory | ||||
| 	AnalysisPlanTaskManager *scheduler.AnalysisPlanTaskManager | ||||
| 	Scheduler               *scheduler.Scheduler | ||||
| } | ||||
|  | ||||
| // initDomainServices 初始化所有的领域服务。 | ||||
| func initDomainServices(cfg *config.Config, infra *Infrastructure, logger *logs.Logger) *DomainServices { | ||||
| 	// 猪群管理相关 | ||||
| 	pigPenTransferManager := pig.NewPigPenTransferManager(infra.Repos.PigPenRepo, infra.Repos.PigTransferLogRepo, infra.Repos.PigBatchRepo) | ||||
| 	pigTradeManager := pig.NewPigTradeManager(infra.Repos.PigTradeRepo) | ||||
| 	pigSickManager := pig.NewSickPigManager(infra.Repos.PigSickPigLogRepo, infra.Repos.MedicationLogRepo) | ||||
| 	pigBatchDomain := pig.NewPigBatchService(infra.Repos.PigBatchRepo, infra.Repos.PigBatchLogRepo, infra.Repos.UnitOfWork, | ||||
| 		pigPenTransferManager, pigTradeManager, pigSickManager) | ||||
|  | ||||
| 	// 通用设备服务 | ||||
| 	generalDeviceService := device.NewGeneralDeviceService( | ||||
| 		infra.Repos.DeviceRepo, | ||||
| 		infra.Repos.DeviceCommandLogRepo, | ||||
| 		infra.Repos.PendingCollectionRepo, | ||||
| 		logger, | ||||
| 		infra.Lora.Comm, | ||||
| 	) | ||||
|  | ||||
| 	// 计划任务管理器 | ||||
| 	analysisPlanTaskManager := scheduler.NewAnalysisPlanTaskManager(infra.Repos.PlanRepo, infra.Repos.PendingTaskRepo, infra.Repos.ExecutionLogRepo, logger) | ||||
|  | ||||
| 	// 任务工厂 | ||||
| 	taskFactory := task.NewTaskFactory(logger, infra.Repos.SensorDataRepo, infra.Repos.DeviceRepo, generalDeviceService) | ||||
|  | ||||
| 	// 任务执行器 | ||||
| 	planScheduler := scheduler.NewScheduler( | ||||
| 		infra.Repos.PendingTaskRepo, | ||||
| 		infra.Repos.ExecutionLogRepo, | ||||
| 		infra.Repos.DeviceRepo, | ||||
| 		infra.Repos.SensorDataRepo, | ||||
| 		infra.Repos.PlanRepo, | ||||
| 		analysisPlanTaskManager, | ||||
| 		taskFactory, | ||||
| 		logger, | ||||
| 		generalDeviceService, | ||||
| 		time.Duration(cfg.Task.Interval)*time.Second, | ||||
| 		cfg.Task.NumWorkers, | ||||
| 	) | ||||
|  | ||||
| 	return &DomainServices{ | ||||
| 		PigPenTransferManager:   pigPenTransferManager, | ||||
| 		PigTradeManager:         pigTradeManager, | ||||
| 		PigSickManager:          pigSickManager, | ||||
| 		PigBatchDomain:          pigBatchDomain, | ||||
| 		GeneralDeviceService:    generalDeviceService, | ||||
| 		AnalysisPlanTaskManager: analysisPlanTaskManager, | ||||
| 		taskFactory:             taskFactory, | ||||
| 		Scheduler:               planScheduler, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // AppServices 聚合了所有的应用服务实例。 | ||||
| type AppServices struct { | ||||
| 	PigFarmService  service.PigFarmService | ||||
| 	PigBatchService service.PigBatchService | ||||
| 	MonitorService  service.MonitorService | ||||
| 	DeviceService   service.DeviceService | ||||
| 	PlanService     service.PlanService | ||||
| 	UserService     service.UserService | ||||
| 	AuditService    audit.Service | ||||
| } | ||||
|  | ||||
| // initAppServices 初始化所有的应用服务。 | ||||
| func initAppServices(infra *Infrastructure, domainServices *DomainServices, logger *logs.Logger) *AppServices { | ||||
| 	pigFarmService := service.NewPigFarmService(infra.Repos.PigFarmRepo, infra.Repos.PigPenRepo, infra.Repos.PigBatchRepo, domainServices.PigBatchDomain, infra.Repos.UnitOfWork, logger) | ||||
| 	pigBatchService := service.NewPigBatchService(domainServices.PigBatchDomain, logger) | ||||
| 	monitorService := service.NewMonitorService( | ||||
| 		infra.Repos.SensorDataRepo, | ||||
| 		infra.Repos.DeviceCommandLogRepo, | ||||
| 		infra.Repos.ExecutionLogRepo, | ||||
| 		infra.Repos.PlanRepo, | ||||
| 		infra.Repos.PendingCollectionRepo, | ||||
| 		infra.Repos.UserActionLogRepo, | ||||
| 		infra.Repos.RawMaterialRepo, | ||||
| 		infra.Repos.MedicationLogRepo, | ||||
| 		infra.Repos.PigBatchRepo, | ||||
| 		infra.Repos.PigBatchLogRepo, | ||||
| 		infra.Repos.PigTransferLogRepo, | ||||
| 		infra.Repos.PigSickPigLogRepo, | ||||
| 		infra.Repos.PigTradeRepo, | ||||
| 		infra.Repos.NotificationRepo, | ||||
| 	) | ||||
| 	deviceService := service.NewDeviceService( | ||||
| 		infra.Repos.DeviceRepo, | ||||
| 		infra.Repos.AreaControllerRepo, | ||||
| 		infra.Repos.DeviceTemplateRepo, | ||||
| 		domainServices.GeneralDeviceService, | ||||
| 	) | ||||
| 	auditService := audit.NewService(infra.Repos.UserActionLogRepo, logger) | ||||
| 	planService := service.NewPlanService(logger, infra.Repos.PlanRepo, domainServices.AnalysisPlanTaskManager) | ||||
| 	userService := service.NewUserService(infra.Repos.UserRepo, infra.TokenService, infra.NotifyService, logger) | ||||
|  | ||||
| 	return &AppServices{ | ||||
| 		PigFarmService:  pigFarmService, | ||||
| 		PigBatchService: pigBatchService, | ||||
| 		MonitorService:  monitorService, | ||||
| 		DeviceService:   deviceService, | ||||
| 		AuditService:    auditService, | ||||
| 		PlanService:     planService, | ||||
| 		UserService:     userService, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // LoraComponents 聚合了所有 LoRa 相关组件。 | ||||
| type LoraComponents struct { | ||||
| 	ListenHandler webhook.ListenHandler | ||||
| 	Comm          transport.Communicator | ||||
| 	LoraListener  transport.Listener | ||||
| } | ||||
|  | ||||
| // initLora 根据配置初始化 LoRa 相关组件。 | ||||
| func initLora( | ||||
| 	cfg *config.Config, | ||||
| 	logger *logs.Logger, | ||||
| 	repos *Repositories, | ||||
| ) (*LoraComponents, error) { | ||||
| 	var listenHandler webhook.ListenHandler | ||||
| 	var comm transport.Communicator | ||||
| 	var loraListener transport.Listener | ||||
|  | ||||
| 	if cfg.Lora.Mode == config.LoraMode_LoRaWAN { | ||||
| 		logger.Info("当前运行模式: lora_wan。初始化 ChirpStack 监听器和传输层。") | ||||
| 		listenHandler = webhook.NewChirpStackListener(logger, repos.SensorDataRepo, repos.DeviceRepo, repos.AreaControllerRepo, repos.DeviceCommandLogRepo, repos.PendingCollectionRepo) | ||||
| 		comm = lora.NewChirpStackTransport(cfg.ChirpStack, logger) | ||||
| 		loraListener = lora.NewPlaceholderTransport(logger) | ||||
| 	} else { | ||||
| 		logger.Info("当前运行模式: lora_mesh。初始化 LoRa Mesh 传输层和占位符监听器。") | ||||
| 		listenHandler = webhook.NewPlaceholderListener(logger) | ||||
| 		tp, err := lora.NewLoRaMeshUartPassthroughTransport(cfg.LoraMesh, logger, repos.AreaControllerRepo, repos.PendingCollectionRepo, repos.DeviceRepo, repos.SensorDataRepo) | ||||
| 		if err != nil { | ||||
| 			return nil, fmt.Errorf("无法初始化 LoRa Mesh 模块: %w", err) | ||||
| 		} | ||||
| 		loraListener = tp | ||||
| 		comm = tp | ||||
| 	} | ||||
|  | ||||
| 	return &LoraComponents{ | ||||
| 		ListenHandler: listenHandler, | ||||
| 		Comm:          comm, | ||||
| 		LoraListener:  loraListener, | ||||
| 	}, nil | ||||
| } | ||||
|  | ||||
| // initNotifyService 根据配置初始化并返回一个通知领域服务。 | ||||
| // 它确保至少有一个 LogNotifier 总是可用,并根据配置启用其他通知器。 | ||||
| func initNotifyService( | ||||
| 	cfg config.NotifyConfig, | ||||
| 	log *logs.Logger, | ||||
| 	userRepo repository.UserRepository, | ||||
| 	notificationRepo repository.NotificationRepository, | ||||
| ) (domain_notify.Service, error) { | ||||
| 	var availableNotifiers []notify.Notifier | ||||
|  | ||||
| 	// 1. 总是创建 LogNotifier 作为所有告警的最终记录渠道 | ||||
| 	logNotifier := notify.NewLogNotifier(log) | ||||
| 	availableNotifiers = append(availableNotifiers, logNotifier) | ||||
| 	log.Info("Log通知器已启用 (作为所有告警的最终记录渠道)") | ||||
|  | ||||
| 	// 2. 根据配置,按需创建并收集所有启用的其他 Notifier 实例 | ||||
| 	if cfg.SMTP.Enabled { | ||||
| 		smtpNotifier := notify.NewSMTPNotifier( | ||||
| 			cfg.SMTP.Host, | ||||
| 			cfg.SMTP.Port, | ||||
| 			cfg.SMTP.Username, | ||||
| 			cfg.SMTP.Password, | ||||
| 			cfg.SMTP.Sender, | ||||
| 		) | ||||
| 		availableNotifiers = append(availableNotifiers, smtpNotifier) | ||||
| 		log.Info("SMTP通知器已启用") | ||||
| 	} | ||||
|  | ||||
| 	if cfg.WeChat.Enabled { | ||||
| 		wechatNotifier := notify.NewWechatNotifier( | ||||
| 			cfg.WeChat.CorpID, | ||||
| 			cfg.WeChat.AgentID, | ||||
| 			cfg.WeChat.Secret, | ||||
| 		) | ||||
| 		availableNotifiers = append(availableNotifiers, wechatNotifier) | ||||
| 		log.Info("企业微信通知器已启用") | ||||
| 	} | ||||
|  | ||||
| 	if cfg.Lark.Enabled { | ||||
| 		larkNotifier := notify.NewLarkNotifier( | ||||
| 			cfg.Lark.AppID, | ||||
| 			cfg.Lark.AppSecret, | ||||
| 		) | ||||
| 		availableNotifiers = append(availableNotifiers, larkNotifier) | ||||
| 		log.Info("飞书通知器已启用") | ||||
| 	} | ||||
|  | ||||
| 	// 3. 动态确定首选通知器 | ||||
| 	var primaryNotifier notify.Notifier | ||||
| 	primaryNotifierType := notify.NotifierType(cfg.Primary) | ||||
|  | ||||
| 	// 检查用户指定的主渠道是否已启用 | ||||
| 	for _, n := range availableNotifiers { | ||||
| 		if n.Type() == primaryNotifierType { | ||||
| 			primaryNotifier = n | ||||
| 			break | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	// 如果用户指定的主渠道未启用或未指定,则自动选择第一个可用的 (这将是 LogNotifier,如果其他都未启用) | ||||
| 	if primaryNotifier == nil { | ||||
| 		primaryNotifier = availableNotifiers[0] // 确保总能找到一个,因为 LogNotifier 总是存在的 | ||||
| 		log.Warnf("配置的首选渠道 '%s' 未启用或未指定,已自动降级使用 '%s' 作为首选渠道。", cfg.Primary, primaryNotifier.Type()) | ||||
| 	} | ||||
|  | ||||
| 	// 4. 使用创建的 Notifier 列表和 notificationRepo 来组装领域服务 | ||||
| 	notifyService, err := domain_notify.NewFailoverService( | ||||
| 		log, | ||||
| 		userRepo, | ||||
| 		availableNotifiers, | ||||
| 		primaryNotifier.Type(), | ||||
| 		cfg.FailureThreshold, | ||||
| 		notificationRepo, | ||||
| 	) | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("创建故障转移通知服务失败: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	log.Infof("通知服务初始化成功,首选渠道: %s, 故障阈值: %d", primaryNotifier.Type(), cfg.FailureThreshold) | ||||
| 	return notifyService, nil | ||||
| } | ||||
|  | ||||
| // initStorage 封装了数据库的初始化、连接和迁移逻辑。 | ||||
| func initStorage(cfg config.DatabaseConfig, logger *logs.Logger) (database.Storage, error) { | ||||
| 	// 创建存储实例 | ||||
| 	storage := database.NewStorage(cfg, logger) | ||||
| 	if err := storage.Connect(); err != nil { | ||||
| 		// 错误已在 Connect 内部被记录,这里只需包装并返回 | ||||
| 		return nil, fmt.Errorf("数据库连接失败: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	// 执行数据库迁移 | ||||
| 	if err := storage.Migrate(models.GetAllModels()...); err != nil { | ||||
| 		return nil, fmt.Errorf("数据库迁移失败: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	logger.Info("数据库初始化完成。") | ||||
| 	return storage, nil | ||||
| } | ||||
							
								
								
									
										232
									
								
								internal/core/data_initializer.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										232
									
								
								internal/core/data_initializer.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,232 @@ | ||||
| package core | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
|  | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" | ||||
| ) | ||||
|  | ||||
| const ( | ||||
| 	// PlanNameTimedFullDataCollection 是定时全量数据采集计划的名称 | ||||
| 	PlanNameTimedFullDataCollection = "定时全量数据采集" | ||||
| ) | ||||
|  | ||||
| // initializeState 在应用启动时准备其初始数据状态。 | ||||
| // 这包括清理任何因上次异常关闭而留下的悬空任务或请求。 | ||||
| func (app *Application) initializeState() error { | ||||
| 	// 初始化预定义系统计划 (致命错误) | ||||
| 	if err := app.initializeSystemPlans(); err != nil { | ||||
| 		return fmt.Errorf("初始化预定义系统计划失败: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	// 清理待采集任务 (非致命错误) | ||||
| 	if err := app.initializePendingCollections(); err != nil { | ||||
| 		app.Logger.Errorw("清理待采集任务时发生非致命错误", "error", err) | ||||
| 	} | ||||
|  | ||||
| 	// 初始化待执行任务列表 (致命错误) | ||||
| 	if err := app.initializePendingTasks(); err != nil { | ||||
| 		return fmt.Errorf("初始化待执行任务列表失败: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // initializeSystemPlans 确保预定义的系统计划在数据库中存在并保持最新。 | ||||
| func (app *Application) initializeSystemPlans() error { | ||||
| 	app.Logger.Info("开始检查并更新预定义的系统计划...") | ||||
|  | ||||
| 	// 动态构建预定义计划列表 | ||||
| 	predefinedSystemPlans := app.getPredefinedSystemPlans() | ||||
|  | ||||
| 	// 1. 获取所有已存在的系统计划 | ||||
| 	existingPlans, _, err := app.Infra.Repos.PlanRepo.ListPlans(repository.ListPlansOptions{ | ||||
| 		PlanType: repository.PlanTypeFilterSystem, | ||||
| 	}, 1, 99999) // 使用一个较大的 pageSize 来获取所有系统计划 | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("获取现有系统计划失败: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	// 2. 为了方便查找, 将现有计划名放入一个 map | ||||
| 	existingPlanMap := make(map[string]*models.Plan) | ||||
| 	for i := range existingPlans { | ||||
| 		existingPlanMap[existingPlans[i].Name] = &existingPlans[i] | ||||
| 	} | ||||
|  | ||||
| 	// 3. 遍历预定义的计划列表 | ||||
| 	for i := range predefinedSystemPlans { | ||||
| 		predefinedPlan := &predefinedSystemPlans[i] // 获取可修改的指针 | ||||
|  | ||||
| 		if foundExistingPlan, ok := existingPlanMap[predefinedPlan.Name]; ok { | ||||
| 			// 如果计划存在,则进行无差别更新 | ||||
| 			app.Logger.Infof("预定义计划 '%s' 已存在,正在进行无差别更新...", predefinedPlan.Name) | ||||
|  | ||||
| 			// 将数据库中已存在的计划的ID和运行时状态字段赋值给预定义计划 | ||||
| 			predefinedPlan.ID = foundExistingPlan.ID | ||||
| 			predefinedPlan.ExecuteCount = foundExistingPlan.ExecuteCount | ||||
| 			predefinedPlan.Status = foundExistingPlan.Status | ||||
|  | ||||
| 			if err := app.Infra.Repos.PlanRepo.UpdatePlan(predefinedPlan); err != nil { | ||||
| 				return fmt.Errorf("更新预定义计划 '%s' 失败: %w", predefinedPlan.Name, err) | ||||
| 			} else { | ||||
| 				app.Logger.Infof("成功更新预定义计划 '%s'。", predefinedPlan.Name) | ||||
| 			} | ||||
| 		} else { | ||||
| 			// 如果计划不存在, 则创建 | ||||
| 			app.Logger.Infof("预定义计划 '%s' 不存在,正在创建...", predefinedPlan.Name) | ||||
| 			if err := app.Infra.Repos.PlanRepo.CreatePlan(predefinedPlan); err != nil { | ||||
| 				return fmt.Errorf("创建预定义计划 '%s' 失败: %w", predefinedPlan.Name, err) | ||||
| 			} else { | ||||
| 				app.Logger.Infof("成功创建预定义计划 '%s'。", predefinedPlan.Name) | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	app.Logger.Info("预定义系统计划检查完成。") | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // getPredefinedSystemPlans 返回一个基于当前配置的预定义系统计划列表。 | ||||
| func (app *Application) getPredefinedSystemPlans() []models.Plan { | ||||
|  | ||||
| 	// 根据配置创建定时全量采集计划 | ||||
| 	interval := app.Config.Collection.Interval | ||||
| 	if interval <= 0 { | ||||
| 		interval = 1 // 确保间隔至少为1分钟 | ||||
| 	} | ||||
| 	cronExpression := fmt.Sprintf("*/%d * * * *", interval) | ||||
| 	timedCollectionPlan := models.Plan{ | ||||
| 		Name:           PlanNameTimedFullDataCollection, | ||||
| 		Description:    fmt.Sprintf("这是一个系统预定义的计划, 每 %d 分钟自动触发一次全量数据采集。", app.Config.Collection.Interval), | ||||
| 		PlanType:       models.PlanTypeSystem, | ||||
| 		ExecutionType:  models.PlanExecutionTypeAutomatic, | ||||
| 		CronExpression: cronExpression, | ||||
| 		Status:         models.PlanStatusEnabled, | ||||
| 		ContentType:    models.PlanContentTypeTasks, | ||||
| 		Tasks: []models.Task{ | ||||
| 			{ | ||||
| 				Name:           "全量采集", | ||||
| 				Description:    "触发一次全量数据采集", | ||||
| 				ExecutionOrder: 1, | ||||
| 				Type:           models.TaskTypeFullCollection, | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	return []models.Plan{timedCollectionPlan} | ||||
| } | ||||
|  | ||||
| // initializePendingCollections 在应用启动时处理所有未完成的采集请求。 | ||||
| // 我们的策略是:任何在程序重启前仍处于“待处理”状态的请求,都应被视为已失败。 | ||||
| // 这保证了系统在每次启动时都处于一个干净、确定的状态。 | ||||
| func (app *Application) initializePendingCollections() error { | ||||
| 	app.Logger.Info("开始清理所有未完成的采集请求...") | ||||
|  | ||||
| 	// 直接将所有 'pending' 状态的请求更新为 'timed_out'。 | ||||
| 	count, err := app.Infra.Repos.PendingCollectionRepo.MarkAllPendingAsTimedOut() | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("清理未完成的采集请求失败: %v", err) | ||||
| 	} else if count > 0 { | ||||
| 		app.Logger.Infof("成功将 %d 个未完成的采集请求标记为超时。", count) | ||||
| 	} else { | ||||
| 		app.Logger.Info("没有需要清理的采集请求。") | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // initializePendingTasks 在应用启动时清理并刷新待执行任务列表。 | ||||
| func (app *Application) initializePendingTasks() error { | ||||
| 	logger := app.Logger | ||||
| 	planRepo := app.Infra.Repos.PlanRepo | ||||
| 	pendingTaskRepo := app.Infra.Repos.PendingTaskRepo | ||||
| 	executionLogRepo := app.Infra.Repos.ExecutionLogRepo | ||||
| 	analysisPlanTaskManager := app.Domain.AnalysisPlanTaskManager | ||||
|  | ||||
| 	logger.Info("开始初始化待执行任务列表...") | ||||
|  | ||||
| 	// 阶段一:修正因崩溃导致状态不一致的固定次数计划 | ||||
| 	logger.Info("阶段一:开始修正因崩溃导致状态不一致的固定次数计划...") | ||||
| 	plansToCorrect, err := planRepo.FindPlansWithPendingTasks() | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("查找需要修正的计划失败: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	for _, plan := range plansToCorrect { | ||||
| 		logger.Infof("发现需要修正的计划 #%d (名称: %s)。", plan.ID, plan.Name) | ||||
|  | ||||
| 		// 更新计划的执行计数 | ||||
| 		plan.ExecuteCount++ | ||||
| 		logger.Infof("计划 #%d 执行计数已从 %d 更新为 %d。", plan.ID, plan.ExecuteCount-1, plan.ExecuteCount) | ||||
|  | ||||
| 		if plan.ExecutionType == models.PlanExecutionTypeManual || | ||||
| 			(plan.ExecutionType == models.PlanExecutionTypeAutomatic && plan.ExecuteCount >= plan.ExecuteNum) { | ||||
| 			// 更新计划状态为已停止 | ||||
| 			plan.Status = models.PlanStatusStopped | ||||
| 			logger.Infof("计划 #%d 状态已更新为 '执行完毕'。", plan.ID) | ||||
|  | ||||
| 		} | ||||
| 		// 保存更新后的计划 | ||||
| 		if err := planRepo.UpdatePlan(plan); err != nil { | ||||
| 			logger.Errorf("修正计划 #%d 状态失败: %v", plan.ID, err) | ||||
| 			// 这是一个非阻塞性错误,继续处理其他计划 | ||||
| 		} | ||||
| 	} | ||||
| 	logger.Info("阶段一:固定次数计划修正完成。") | ||||
|  | ||||
| 	// 阶段二:清理所有待执行任务和相关日志 | ||||
| 	logger.Info("阶段二:开始清理所有待执行任务和相关日志...") | ||||
|  | ||||
| 	// --- 新增逻辑:处理因崩溃导致状态不一致的计划主表状态 --- | ||||
| 	// 1. 查找所有未完成的计划执行日志 (状态为 Started 或 Waiting) | ||||
| 	incompletePlanLogs, err := executionLogRepo.FindIncompletePlanExecutionLogs() | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("查找未完成的计划执行日志失败: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	// 2. 收集所有受影响的唯一 PlanID | ||||
| 	affectedPlanIDs := make(map[uint]struct{}) | ||||
| 	for _, log := range incompletePlanLogs { | ||||
| 		affectedPlanIDs[log.PlanID] = struct{}{} | ||||
| 	} | ||||
|  | ||||
| 	// 3. 对于每个受影响的 PlanID,重置其 execute_count 并将其状态设置为 Failed | ||||
| 	for planID := range affectedPlanIDs { | ||||
| 		logger.Warnf("检测到计划 #%d 在应用崩溃前处于未完成状态,将重置其计数并标记为失败。", planID) | ||||
| 		// 使用 UpdatePlanStateAfterExecution 来更新主表状态,避免影响关联数据 | ||||
| 		if err := planRepo.UpdatePlanStateAfterExecution(planID, 0, models.PlanStatusFailed); err != nil { | ||||
| 			logger.Errorf("重置计划 #%d 计数并标记为失败时出错: %v", planID, err) | ||||
| 			// 这是一个非阻塞性错误,继续处理其他计划 | ||||
| 		} | ||||
| 	} | ||||
| 	logger.Info("阶段二:计划主表状态修正完成。") | ||||
|  | ||||
| 	// 直接调用新的方法来更新计划执行日志状态为失败 | ||||
| 	if err := executionLogRepo.FailAllIncompletePlanExecutionLogs(); err != nil { | ||||
| 		logger.Errorf("更新所有未完成计划执行日志状态为失败失败: %v", err) | ||||
| 		// 这是一个非阻塞性错误,继续执行 | ||||
| 	} | ||||
|  | ||||
| 	// 直接调用新的方法来更新任务执行日志状态为取消 | ||||
| 	if err := executionLogRepo.CancelAllIncompleteTaskExecutionLogs(); err != nil { | ||||
| 		logger.Errorf("更新所有未完成任务执行日志状态为取消失败: %v", err) | ||||
| 		// 这是一个非阻塞性错误,继续执行 | ||||
| 	} | ||||
|  | ||||
| 	// 清空待执行列表 | ||||
| 	if err := pendingTaskRepo.ClearAllPendingTasks(); err != nil { | ||||
| 		return fmt.Errorf("清空待执行任务列表失败: %w", err) | ||||
| 	} | ||||
| 	logger.Info("阶段二:待执行任务和相关日志清理完成。") | ||||
|  | ||||
| 	// 阶段三:初始刷新 | ||||
| 	logger.Info("阶段三:开始刷新待执行列表...") | ||||
| 	if err := analysisPlanTaskManager.Refresh(); err != nil { | ||||
| 		return fmt.Errorf("刷新待执行任务列表失败: %w", err) | ||||
| 	} | ||||
| 	logger.Info("阶段三:待执行任务列表初始化完成。") | ||||
|  | ||||
| 	logger.Info("待执行任务列表初始化完成。") | ||||
| 	return nil | ||||
| } | ||||
| @@ -33,6 +33,7 @@ type failoverService struct { | ||||
| 	primaryNotifier  notify.Notifier | ||||
| 	failureThreshold int | ||||
| 	failureCounters  *sync.Map // 使用 sync.Map 来安全地并发读写失败计数, key: userID (uint), value: counter (int) | ||||
| 	notificationRepo repository.NotificationRepository | ||||
| } | ||||
|  | ||||
| // NewFailoverService 创建一个新的故障转移通知服务 | ||||
| @@ -42,6 +43,7 @@ func NewFailoverService( | ||||
| 	notifiers []notify.Notifier, | ||||
| 	primaryNotifierType notify.NotifierType, | ||||
| 	failureThreshold int, | ||||
| 	notificationRepo repository.NotificationRepository, | ||||
| ) (Service, error) { | ||||
| 	notifierMap := make(map[notify.NotifierType]notify.Notifier) | ||||
| 	for _, n := range notifiers { | ||||
| @@ -60,6 +62,7 @@ func NewFailoverService( | ||||
| 		primaryNotifier:  primaryNotifier, | ||||
| 		failureThreshold: failureThreshold, | ||||
| 		failureCounters:  &sync.Map{}, | ||||
| 		notificationRepo: notificationRepo, | ||||
| 	}, nil | ||||
| } | ||||
|  | ||||
| @@ -128,11 +131,15 @@ func (s *failoverService) sendAlarmToUser(userID uint, content notify.AlarmConte | ||||
| 		primaryType := s.primaryNotifier.Type() | ||||
| 		addr := getAddressForNotifier(primaryType, user.Contact) | ||||
| 		if addr == "" { | ||||
| 			// 记录跳过通知 | ||||
| 			s.recordNotificationAttempt(userID, primaryType, content, "", models.NotificationStatusSkipped, fmt.Errorf("用户未配置首选通知方式 '%s' 的地址", primaryType)) | ||||
| 			return fmt.Errorf("用户未配置首选通知方式 '%s' 的地址", primaryType) | ||||
| 		} | ||||
|  | ||||
| 		err = s.primaryNotifier.Send(content, addr) | ||||
| 		if err == nil { | ||||
| 			// 记录成功通知 | ||||
| 			s.recordNotificationAttempt(userID, primaryType, content, addr, models.NotificationStatusSuccess, nil) | ||||
| 			if failureCount > 0 { | ||||
| 				s.log.Infow("首选渠道发送恢复正常", "userID", userID, "notifierType", primaryType) | ||||
| 				s.failureCounters.Store(userID, 0) | ||||
| @@ -140,6 +147,8 @@ func (s *failoverService) sendAlarmToUser(userID uint, content notify.AlarmConte | ||||
| 			return nil | ||||
| 		} | ||||
|  | ||||
| 		// 记录失败通知 | ||||
| 		s.recordNotificationAttempt(userID, primaryType, content, addr, models.NotificationStatusFailed, err) | ||||
| 		newFailureCount := failureCount + 1 | ||||
| 		s.failureCounters.Store(userID, newFailureCount) | ||||
| 		s.log.Warnw("首选渠道发送失败", "userID", userID, "notifierType", primaryType, "error", err, "failureCount", newFailureCount) | ||||
| @@ -152,13 +161,19 @@ func (s *failoverService) sendAlarmToUser(userID uint, content notify.AlarmConte | ||||
| 		for _, notifier := range s.notifiers { | ||||
| 			addr := getAddressForNotifier(notifier.Type(), user.Contact) | ||||
| 			if addr == "" { | ||||
| 				// 记录跳过通知 | ||||
| 				s.recordNotificationAttempt(userID, notifier.Type(), content, "", models.NotificationStatusSkipped, fmt.Errorf("用户未配置通知方式 '%s' 的地址", notifier.Type())) | ||||
| 				continue | ||||
| 			} | ||||
| 			if err := notifier.Send(content, addr); err == nil { | ||||
| 				// 记录成功通知 | ||||
| 				s.recordNotificationAttempt(userID, notifier.Type(), content, addr, models.NotificationStatusSuccess, nil) | ||||
| 				s.log.Infow("广播通知成功", "userID", userID, "notifierType", notifier.Type()) | ||||
| 				s.failureCounters.Store(userID, 0) | ||||
| 				return nil | ||||
| 			} | ||||
| 			// 记录失败通知 | ||||
| 			s.recordNotificationAttempt(userID, notifier.Type(), content, addr, models.NotificationStatusFailed, err) | ||||
| 			lastErr = err | ||||
| 			s.log.Warnw("广播通知:渠道发送失败", "userID", userID, "notifierType", notifier.Type(), "error", err) | ||||
| 		} | ||||
| @@ -185,6 +200,13 @@ func (s *failoverService) SendTestMessage(userID uint, notifierType notify.Notif | ||||
| 	addr := getAddressForNotifier(notifierType, user.Contact) | ||||
| 	if addr == "" { | ||||
| 		s.log.Warnw("发送测试消息失败:缺少地址", "userID", userID, "notifierType", notifierType) | ||||
| 		// 记录跳过通知 | ||||
| 		s.recordNotificationAttempt(userID, notifierType, notify.AlarmContent{ | ||||
| 			Title:     "通知服务测试", | ||||
| 			Message:   fmt.Sprintf("这是一条来自【%s】渠道的测试消息。如果您收到此消息,说明您的配置正确。", notifierType), | ||||
| 			Level:     zap.InfoLevel, | ||||
| 			Timestamp: time.Now(), | ||||
| 		}, "", models.NotificationStatusFailed, fmt.Errorf("用户未配置通知方式 '%s' 的地址", notifierType)) | ||||
| 		return fmt.Errorf("用户未配置通知方式 '%s' 的地址", notifierType) | ||||
| 	} | ||||
|  | ||||
| @@ -199,10 +221,14 @@ func (s *failoverService) SendTestMessage(userID uint, notifierType notify.Notif | ||||
| 	err = notifier.Send(testContent, addr) | ||||
| 	if err != nil { | ||||
| 		s.log.Errorw("发送测试消息失败", "userID", userID, "notifierType", notifierType, "error", err) | ||||
| 		// 记录失败通知 | ||||
| 		s.recordNotificationAttempt(userID, notifierType, testContent, addr, models.NotificationStatusFailed, err) | ||||
| 		return err | ||||
| 	} | ||||
|  | ||||
| 	s.log.Infow("发送测试消息成功", "userID", userID, "notifierType", notifierType) | ||||
| 	// 记录成功通知 | ||||
| 	s.recordNotificationAttempt(userID, notifierType, testContent, addr, models.NotificationStatusSuccess, nil) | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| @@ -221,3 +247,46 @@ func getAddressForNotifier(notifierType notify.NotifierType, contact models.Cont | ||||
| 		return "" | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // recordNotificationAttempt 记录一次通知发送尝试的结果 | ||||
| // userID: 接收通知的用户ID | ||||
| // notifierType: 使用的通知器类型 | ||||
| // content: 通知内容 | ||||
| // toAddress: 实际发送到的地址 | ||||
| // status: 发送尝试的状态 (成功、失败、跳过) | ||||
| // err: 如果发送失败,记录的错误信息 | ||||
| func (s *failoverService) recordNotificationAttempt( | ||||
| 	userID uint, | ||||
| 	notifierType notify.NotifierType, | ||||
| 	content notify.AlarmContent, | ||||
| 	toAddress string, | ||||
| 	status models.NotificationStatus, | ||||
| 	err error, | ||||
| ) { | ||||
| 	errorMessage := "" | ||||
| 	if err != nil { | ||||
| 		errorMessage = err.Error() | ||||
| 	} | ||||
|  | ||||
| 	notification := &models.Notification{ | ||||
| 		NotifierType:   notifierType, | ||||
| 		UserID:         userID, | ||||
| 		Title:          content.Title, | ||||
| 		Message:        content.Message, | ||||
| 		Level:          models.LogLevel(content.Level), | ||||
| 		AlarmTimestamp: content.Timestamp, | ||||
| 		ToAddress:      toAddress, | ||||
| 		Status:         status, | ||||
| 		ErrorMessage:   errorMessage, | ||||
| 	} | ||||
|  | ||||
| 	if saveErr := s.notificationRepo.Create(notification); saveErr != nil { | ||||
| 		s.log.Errorw("无法保存通知发送记录到数据库", | ||||
| 			"userID", userID, | ||||
| 			"notifierType", notifierType, | ||||
| 			"status", status, | ||||
| 			"originalError", errorMessage, | ||||
| 			"saveError", saveErr, | ||||
| 		) | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| package task | ||||
| package scheduler | ||||
| 
 | ||||
| import ( | ||||
| 	"fmt" | ||||
| @@ -1,4 +1,4 @@ | ||||
| package task | ||||
| package scheduler | ||||
| 
 | ||||
| import ( | ||||
| 	"errors" | ||||
| @@ -83,6 +83,7 @@ type Scheduler struct { | ||||
| 	deviceRepo              repository.DeviceRepository | ||||
| 	sensorDataRepo          repository.SensorDataRepository | ||||
| 	planRepo                repository.PlanRepository | ||||
| 	taskFactory             TaskFactory | ||||
| 	analysisPlanTaskManager *AnalysisPlanTaskManager | ||||
| 	progressTracker         *ProgressTracker | ||||
| 	deviceService           device.Service | ||||
| @@ -100,6 +101,7 @@ func NewScheduler( | ||||
| 	sensorDataRepo repository.SensorDataRepository, | ||||
| 	planRepo repository.PlanRepository, | ||||
| 	analysisPlanTaskManager *AnalysisPlanTaskManager, | ||||
| 	taskFactory TaskFactory, | ||||
| 	logger *logs.Logger, | ||||
| 	deviceService device.Service, | ||||
| 	interval time.Duration, | ||||
| @@ -112,6 +114,7 @@ func NewScheduler( | ||||
| 		sensorDataRepo:          sensorDataRepo, | ||||
| 		planRepo:                planRepo, | ||||
| 		analysisPlanTaskManager: analysisPlanTaskManager, | ||||
| 		taskFactory:             taskFactory, | ||||
| 		logger:                  logger, | ||||
| 		deviceService:           deviceService, | ||||
| 		pollingInterval:         interval, | ||||
| @@ -271,7 +274,7 @@ func (s *Scheduler) runTask(claimedLog *models.TaskExecutionLog) error { | ||||
| 
 | ||||
| 	} else { | ||||
| 		// 执行普通任务 | ||||
| 		task := s.taskFactory(claimedLog) | ||||
| 		task := s.taskFactory.Production(claimedLog) | ||||
| 
 | ||||
| 		if err := task.Execute(); err != nil { | ||||
| 			s.logger.Errorf("[严重] 任务执行失败, 日志ID: %d, 错误: %v", claimedLog.ID, err) | ||||
| @@ -283,20 +286,6 @@ func (s *Scheduler) runTask(claimedLog *models.TaskExecutionLog) error { | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| // taskFactory 会根据任务类型初始化对应任务 | ||||
| func (s *Scheduler) taskFactory(claimedLog *models.TaskExecutionLog) Task { | ||||
| 	switch claimedLog.Task.Type { | ||||
| 	case models.TaskTypeWaiting: | ||||
| 		return NewDelayTask(s.logger, claimedLog) | ||||
| 	case models.TaskTypeReleaseFeedWeight: | ||||
| 		return NewReleaseFeedWeightTask(claimedLog, s.sensorDataRepo, s.deviceRepo, s.deviceService, s.logger) | ||||
| 
 | ||||
| 	default: | ||||
| 		// TODO 这里直接panic合适吗? 不过这个场景确实不该出现任何异常的任务类型 | ||||
| 		panic("不支持的任务类型") | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| // analysisPlan 解析Plan并将解析出的Task列表插入待执行队列中 | ||||
| func (s *Scheduler) analysisPlan(claimedLog *models.TaskExecutionLog) error { | ||||
| 	// 创建Plan执行记录 | ||||
| @@ -399,12 +388,27 @@ func (s *Scheduler) handlePlanTermination(planLogID uint, reason string) { | ||||
| 		s.logger.Errorf("取消计划 %d 的后续任务日志时出错: %v", planLogID, err) | ||||
| 	} | ||||
| 
 | ||||
| 	// 4. 将计划本身的状态更新为失败 | ||||
| 	// 4. 获取计划执行日志以获取顶层 PlanID | ||||
| 	planLog, err := s.executionLogRepo.FindPlanExecutionLogByID(planLogID) | ||||
| 	if err != nil { | ||||
| 		s.logger.Errorf("无法找到计划执行日志 %d 以更新父计划状态: %v", planLogID, err) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	// 5. 获取顶层计划的详细信息,以检查其类型 | ||||
| 	topLevelPlan, err := s.planRepo.GetBasicPlanByID(planLog.PlanID) | ||||
| 	if err != nil { | ||||
| 		s.logger.Errorf("获取顶层计划 %d 的基本信息失败: %v", planLog.PlanID, err) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	// 6. 如果是系统任务,则不修改计划状态 | ||||
| 	if topLevelPlan.PlanType == models.PlanTypeSystem { | ||||
| 		s.logger.Warnf("系统任务 %d (日志ID: %d) 执行失败,但根据策略不修改其计划状态。", topLevelPlan.ID, planLogID) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	// 7. 将计划本身的状态更新为失败 (仅对非系统任务执行) | ||||
| 	if err := s.planRepo.UpdatePlanStatus(planLog.PlanID, models.PlanStatusFailed); err != nil { | ||||
| 		s.logger.Errorf("更新计划 %d 状态为 '失败' 时出错: %v", planLog.PlanID, err) | ||||
| 	} | ||||
							
								
								
									
										23
									
								
								internal/domain/scheduler/task.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								internal/domain/scheduler/task.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| package scheduler | ||||
|  | ||||
| import "git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||
|  | ||||
| // Task 定义了所有可被调度器执行的任务必须实现的接口。 | ||||
| type Task interface { | ||||
| 	// Execute 是任务的核心执行逻辑。 | ||||
| 	// ctx: 用于控制任务的超时或取消。 | ||||
| 	// log: 包含了当前任务执行的完整上下文信息,包括从数据库中加载的任务参数等。 | ||||
| 	// 返回的 error 表示任务是否执行成功。调度器会根据返回的 error 是否为 nil 来决定任务状态。 | ||||
| 	Execute() error | ||||
|  | ||||
| 	// OnFailure 定义了当 Execute 方法返回错误时,需要执行的回滚或清理逻辑。 | ||||
| 	// log: 任务执行的上下文。 | ||||
| 	// executeErr: 从 Execute 方法返回的原始错误。 | ||||
| 	OnFailure(executeErr error) | ||||
| } | ||||
|  | ||||
| // TaskFactory 是一个工厂接口,用于根据任务执行日志创建任务实例。 | ||||
| type TaskFactory interface { | ||||
| 	// Production 根据指定的任务执行日志创建一个任务实例。 | ||||
| 	Production(claimedLog *models.TaskExecutionLog) Task | ||||
| } | ||||
| @@ -4,6 +4,7 @@ import ( | ||||
| 	"fmt" | ||||
| 	"time" | ||||
|  | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/domain/scheduler" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||
| ) | ||||
| @@ -19,7 +20,7 @@ type DelayTask struct { | ||||
| 	logger        *logs.Logger | ||||
| } | ||||
|  | ||||
| func NewDelayTask(logger *logs.Logger, executionTask *models.TaskExecutionLog) Task { | ||||
| func NewDelayTask(logger *logs.Logger, executionTask *models.TaskExecutionLog) scheduler.Task { | ||||
| 	return &DelayTask{ | ||||
| 		executionTask: executionTask, | ||||
| 		logger:        logger, | ||||
|   | ||||
| @@ -1,61 +0,0 @@ | ||||
| package task_test | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
| 	"testing" | ||||
| 	"time" | ||||
|  | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/service/task" | ||||
| ) | ||||
|  | ||||
| func TestNewDelayTask(t *testing.T) { | ||||
| 	id := "test-delay-task-1" | ||||
| 	duration := 100 * time.Millisecond | ||||
| 	priority := 1 | ||||
|  | ||||
| 	dt := task.NewDelayTask(id, duration, priority) | ||||
|  | ||||
| 	if dt.GetID() != id { | ||||
| 		t.Errorf("期望任务ID为 %s, 实际为 %s", id, dt.GetID()) | ||||
| 	} | ||||
| 	if dt.GetPriority() != priority { | ||||
| 		t.Errorf("期望任务优先级为 %d, 实际为 %d", priority, dt.GetPriority()) | ||||
| 	} | ||||
| 	if dt.IsDone() != false { | ||||
| 		t.Error("任务初始状态不应为已完成") | ||||
| 	} | ||||
| 	// 动态生成的描述,需要匹配 GetDescription 的实现 | ||||
| 	expectedDesc := fmt.Sprintf("延迟任务,ID: %s,延迟时间: %s", id, duration) | ||||
| 	if dt.GetDescription() != expectedDesc { | ||||
| 		t.Errorf("期望任务描述为 %s, 实际为 %s", expectedDesc, dt.GetDescription()) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestDelayTaskExecute(t *testing.T) { | ||||
| 	id := "test-delay-task-execute" | ||||
| 	duration := 50 * time.Millisecond // 使用较短的延迟以加快测试速度 | ||||
| 	priority := 1 | ||||
|  | ||||
| 	dt := task.NewDelayTask(id, duration, priority) | ||||
|  | ||||
| 	if dt.IsDone() { | ||||
| 		t.Error("任务执行前不应为已完成状态") | ||||
| 	} | ||||
|  | ||||
| 	startTime := time.Now() | ||||
| 	err := dt.Execute() | ||||
| 	endTime := time.Now() | ||||
|  | ||||
| 	if err != nil { | ||||
| 		t.Errorf("Execute 方法返回错误: %v", err) | ||||
| 	} | ||||
| 	if !dt.IsDone() { | ||||
| 		t.Error("任务执行后应为已完成状态") | ||||
| 	} | ||||
|  | ||||
| 	// 验证延迟时间大致正确,允许一些误差 | ||||
| 	elapsed := endTime.Sub(startTime) | ||||
| 	if elapsed < duration || elapsed > duration*2 { | ||||
| 		t.Errorf("期望执行时间在 %v 左右, 但实际耗时 %v", duration, elapsed) | ||||
| 	} | ||||
| } | ||||
							
								
								
									
										93
									
								
								internal/domain/task/full_collection_task.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								internal/domain/task/full_collection_task.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,93 @@ | ||||
| package task | ||||
|  | ||||
| import ( | ||||
| 	"fmt" | ||||
|  | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/domain/device" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" | ||||
| ) | ||||
|  | ||||
| // FullCollectionTask 实现了 scheduler.Task 接口,用于执行一次全量的设备数据采集 | ||||
| type FullCollectionTask struct { | ||||
| 	log           *models.TaskExecutionLog | ||||
| 	deviceRepo    repository.DeviceRepository | ||||
| 	deviceService device.Service | ||||
| 	logger        *logs.Logger | ||||
| } | ||||
|  | ||||
| // NewFullCollectionTask 创建一个全量采集任务实例 | ||||
| func NewFullCollectionTask( | ||||
| 	log *models.TaskExecutionLog, | ||||
| 	deviceRepo repository.DeviceRepository, | ||||
| 	deviceService device.Service, | ||||
| 	logger *logs.Logger, | ||||
| ) *FullCollectionTask { | ||||
| 	return &FullCollectionTask{ | ||||
| 		log:           log, | ||||
| 		deviceRepo:    deviceRepo, | ||||
| 		deviceService: deviceService, | ||||
| 		logger:        logger, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // Execute 是任务的核心执行逻辑 | ||||
| func (t *FullCollectionTask) Execute() error { | ||||
| 	t.logger.Infow("开始执行全量采集任务", "task_id", t.log.TaskID, "task_type", t.log.Task.Type, "log_id", t.log.ID) | ||||
|  | ||||
| 	sensors, err := t.deviceRepo.ListAllSensors() | ||||
| 	if err != nil { | ||||
| 		return fmt.Errorf("全量采集任务: 从数据库获取所有传感器失败: %w", err) | ||||
| 	} | ||||
|  | ||||
| 	if len(sensors) == 0 { | ||||
| 		t.logger.Infow("全量采集任务: 未发现任何传感器设备,跳过本次采集", "task_id", t.log.TaskID, "task_type", t.log.Task.Type, "log_id", t.log.ID) | ||||
| 		return nil | ||||
| 	} | ||||
|  | ||||
| 	sensorsByController := make(map[uint][]*models.Device) | ||||
| 	for _, sensor := range sensors { | ||||
| 		sensorsByController[sensor.AreaControllerID] = append(sensorsByController[sensor.AreaControllerID], sensor) | ||||
| 	} | ||||
|  | ||||
| 	var firstError error | ||||
| 	for controllerID, controllerSensors := range sensorsByController { | ||||
| 		t.logger.Infow("全量采集任务: 准备为区域主控下的传感器下发采集指令", | ||||
| 			"task_id", t.log.TaskID, | ||||
| 			"task_type", t.log.Task.Type, | ||||
| 			"log_id", t.log.ID, | ||||
| 			"controller_id", controllerID, | ||||
| 			"sensor_count", len(controllerSensors), | ||||
| 		) | ||||
| 		if err := t.deviceService.Collect(controllerID, controllerSensors); err != nil { | ||||
| 			t.logger.Errorw("全量采集任务: 为区域主控下发采集指令失败", | ||||
| 				"task_id", t.log.TaskID, | ||||
| 				"task_type", t.log.Task.Type, | ||||
| 				"log_id", t.log.ID, | ||||
| 				"controller_id", controllerID, | ||||
| 				"error", err, | ||||
| 			) | ||||
| 			if firstError == nil { | ||||
| 				firstError = err // 保存第一个错误 | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if firstError != nil { | ||||
| 		return fmt.Errorf("全量采集任务执行期间发生错误: %w", firstError) | ||||
| 	} | ||||
|  | ||||
| 	t.logger.Infow("全量采集任务执行完成", "task_id", t.log.TaskID, "task_type", t.log.Task.Type, "log_id", t.log.ID) | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // OnFailure 定义了当 Execute 方法返回错误时,需要执行的回滚或清理逻辑 | ||||
| func (t *FullCollectionTask) OnFailure(executeErr error) { | ||||
| 	t.logger.Errorw("全量采集任务执行失败", | ||||
| 		"task_id", t.log.TaskID, | ||||
| 		"task_type", t.log.Task.Type, | ||||
| 		"log_id", t.log.ID, | ||||
| 		"error", executeErr, | ||||
| 	) | ||||
| } | ||||
| @@ -6,6 +6,7 @@ import ( | ||||
| 	"time" | ||||
|  | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/domain/device" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/domain/scheduler" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" | ||||
| @@ -40,12 +41,12 @@ func NewReleaseFeedWeightTask( | ||||
| 	deviceRepo repository.DeviceRepository, | ||||
| 	deviceService device.Service, | ||||
| 	logger *logs.Logger, | ||||
| ) Task { | ||||
| ) scheduler.Task { | ||||
| 	return &ReleaseFeedWeightTask{ | ||||
| 		claimedLog:     claimedLog, | ||||
| 		deviceRepo:     deviceRepo, | ||||
| 		sensorDataRepo: sensorDataRepo, | ||||
| 		feedPort:       deviceService, // 直接注入 | ||||
| 		feedPort:       deviceService, | ||||
| 		logger:         logger, | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -1,30 +1,45 @@ | ||||
| package task | ||||
|  | ||||
| import ( | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/domain/device" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/domain/scheduler" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" | ||||
| ) | ||||
|  | ||||
| // Task 定义了所有可被调度器执行的任务必须实现的接口。 | ||||
| type Task interface { | ||||
| 	// Execute 是任务的核心执行逻辑。 | ||||
| 	// ctx: 用于控制任务的超时或取消。 | ||||
| 	// log: 包含了当前任务执行的完整上下文信息,包括从数据库中加载的任务参数等。 | ||||
| 	// 返回的 error 表示任务是否执行成功。调度器会根据返回的 error 是否为 nil 来决定任务状态。 | ||||
| 	Execute() error | ||||
|  | ||||
| 	// OnFailure 定义了当 Execute 方法返回错误时,需要执行的回滚或清理逻辑。 | ||||
| 	// log: 任务执行的上下文。 | ||||
| 	// executeErr: 从 Execute 方法返回的原始错误。 | ||||
| 	OnFailure(executeErr error) | ||||
| type taskFactory struct { | ||||
| 	logger         *logs.Logger | ||||
| 	sensorDataRepo repository.SensorDataRepository | ||||
| 	deviceRepo     repository.DeviceRepository | ||||
| 	deviceService  device.Service | ||||
| } | ||||
|  | ||||
| // TaskFactory 是一个任务组装工厂, 可以根据Task类型获取到对应的初始化函数 | ||||
| var TaskFactory = func(tt models.TaskType) Task { | ||||
| 	switch tt { | ||||
| 	case models.TaskTypeWaiting: | ||||
| 		return &DelayTask{} | ||||
| 	default: | ||||
| 		// 出现位置任务类型说明业务逻辑出现重大问题, 一个异常任务被创建了出来 | ||||
| 		panic("发现未知任务类型") | ||||
| func NewTaskFactory( | ||||
| 	logger *logs.Logger, | ||||
| 	sensorDataRepo repository.SensorDataRepository, | ||||
| 	deviceRepo repository.DeviceRepository, | ||||
| 	deviceService device.Service, | ||||
| ) scheduler.TaskFactory { | ||||
| 	return &taskFactory{ | ||||
| 		logger:         logger, | ||||
| 		sensorDataRepo: sensorDataRepo, | ||||
| 		deviceRepo:     deviceRepo, | ||||
| 		deviceService:  deviceService, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (t *taskFactory) Production(claimedLog *models.TaskExecutionLog) scheduler.Task { | ||||
| 	switch claimedLog.Task.Type { | ||||
| 	case models.TaskTypeWaiting: | ||||
| 		return NewDelayTask(t.logger, claimedLog) | ||||
| 	case models.TaskTypeReleaseFeedWeight: | ||||
| 		return NewReleaseFeedWeightTask(claimedLog, t.sensorDataRepo, t.deviceRepo, t.deviceService, t.logger) | ||||
| 	case models.TaskTypeFullCollection: | ||||
| 		return NewFullCollectionTask(claimedLog, t.deviceRepo, t.deviceService, t.logger) | ||||
| 	default: | ||||
| 		// TODO 这里直接panic合适吗? 不过这个场景确实不该出现任何异常的任务类型 | ||||
| 		t.logger.Panicf("不支持的任务类型: %s", claimedLog.Task.Type) | ||||
| 		panic("不支持的任务类型") // 显式panic防编译器报错 | ||||
| 	} | ||||
| } | ||||
|   | ||||
| @@ -13,19 +13,19 @@ type Claims struct { | ||||
| 	jwt.RegisteredClaims | ||||
| } | ||||
|  | ||||
| // TokenService 定义了 token 操作的接口 | ||||
| type TokenService interface { | ||||
| // Service 定义了 token 操作的接口 | ||||
| type Service interface { | ||||
| 	GenerateToken(userID uint) (string, error) | ||||
| 	ParseToken(tokenString string) (*Claims, error) | ||||
| } | ||||
|  | ||||
| // tokenService 是 TokenService 接口的实现 | ||||
| // tokenService 是 Service 接口的实现 | ||||
| type tokenService struct { | ||||
| 	secret []byte | ||||
| } | ||||
|  | ||||
| // NewTokenService 创建并返回一个新的 TokenService 实例 | ||||
| func NewTokenService(secret []byte) TokenService { | ||||
| // NewTokenService 创建并返回一个新的 Service 实例 | ||||
| func NewTokenService(secret []byte) Service { | ||||
| 	return &tokenService{secret: secret} | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -1,107 +0,0 @@ | ||||
| package token_test | ||||
|  | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"testing" | ||||
| 	"time" | ||||
|  | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/service/token" | ||||
| 	"github.com/golang-jwt/jwt/v5" | ||||
| ) | ||||
|  | ||||
| func TestGenerateToken(t *testing.T) { | ||||
| 	// 使用一个测试密钥初始化 TokenService | ||||
| 	testSecret := []byte("test_secret_key") | ||||
| 	service := token.NewTokenService(testSecret) | ||||
|  | ||||
| 	userID := uint(123) | ||||
| 	tokenString, err := service.GenerateToken(userID) | ||||
|  | ||||
| 	if err != nil { | ||||
| 		t.Fatalf("生成令牌失败: %v", err) | ||||
| 	} | ||||
|  | ||||
| 	if tokenString == "" { | ||||
| 		t.Fatal("生成的令牌字符串为空") | ||||
| 	} | ||||
|  | ||||
| 	// 解析 token 以确保其有效性及声明 | ||||
| 	claims, err := service.ParseToken(tokenString) | ||||
| 	if err != nil { | ||||
| 		t.Fatalf("生成后解析令牌失败: %v", err) | ||||
| 	} | ||||
|  | ||||
| 	if claims.UserID != userID { | ||||
| 		t.Errorf("期望用户ID %d, 实际为 %d", userID, claims.UserID) | ||||
| 	} | ||||
|  | ||||
| 	// 检查 token 是否未过期 (在合理范围内) | ||||
| 	if claims.ExpiresAt == nil || claims.ExpiresAt.Time.Before(time.Now().Add(-time.Minute)) { | ||||
| 		t.Errorf("令牌过期时间无效或已过期") | ||||
| 	} | ||||
|  | ||||
| 	if claims.Issuer != "pig-farm-controller" { | ||||
| 		t.Errorf("期望签发者 \"pig-farm-controller\", 实际为 \"%s\"", claims.Issuer) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func TestParseToken(t *testing.T) { | ||||
| 	// 使用两个不同的测试密钥 | ||||
| 	correctSecret := []byte("the_correct_secret") | ||||
| 	wrongSecret := []byte("a_very_wrong_secret") | ||||
|  | ||||
| 	serviceWithCorrectKey := token.NewTokenService(correctSecret) | ||||
| 	serviceWithWrongKey := token.NewTokenService(wrongSecret) | ||||
|  | ||||
| 	userID := uint(456) | ||||
|  | ||||
| 	// 1. 生成一个有效的 token | ||||
| 	validToken, err := serviceWithCorrectKey.GenerateToken(userID) | ||||
| 	if err != nil { | ||||
| 		t.Fatalf("为解析测试生成有效令牌失败: %v", err) | ||||
| 	} | ||||
|  | ||||
| 	// 测试用例 1: 使用正确的密钥成功解析 | ||||
| 	claims, err := serviceWithCorrectKey.ParseToken(validToken) | ||||
| 	if err != nil { | ||||
| 		t.Errorf("使用正确密钥解析有效令牌失败: %v", err) | ||||
| 	} | ||||
| 	if claims.UserID != userID { | ||||
| 		t.Errorf("解析有效令牌时期望用户ID %d, 实际为 %d", userID, claims.UserID) | ||||
| 	} | ||||
|  | ||||
| 	// 测试用例 2: 无效 token (例如, 格式错误的字符串) | ||||
| 	invalidTokenString := "this.is.not.a.valid.jwt" | ||||
| 	_, err = serviceWithCorrectKey.ParseToken(invalidTokenString) | ||||
| 	if err == nil { | ||||
| 		t.Error("解析格式错误的令牌意外成功") | ||||
| 	} | ||||
|  | ||||
| 	// 测试用C:\Users\divano\Desktop\work\AA-Pig\pig-farm-controller\internal\infra\repository\plan_repository_test.go例 3: 过期 token | ||||
| 	expiredClaims := token.Claims{ | ||||
| 		UserID: userID, | ||||
| 		RegisteredClaims: jwt.RegisteredClaims{ | ||||
| 			ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Hour)), // 1 小时前 | ||||
| 			Issuer:    "pig-farm-controller", | ||||
| 		}, | ||||
| 	} | ||||
| 	expiredTokenClaims := jwt.NewWithClaims(jwt.SigningMethodHS256, expiredClaims) | ||||
| 	expiredTokenString, err := expiredTokenClaims.SignedString(correctSecret) | ||||
| 	if err != nil { | ||||
| 		t.Fatalf("生成过期令牌失败: %v", err) | ||||
| 	} | ||||
| 	_, err = serviceWithCorrectKey.ParseToken(expiredTokenString) | ||||
| 	if err == nil { | ||||
| 		t.Error("解析过期令牌意外成功") | ||||
| 	} | ||||
|  | ||||
| 	// 新增测试用例 4: 使用错误的密钥解析 | ||||
| 	_, err = serviceWithWrongKey.ParseToken(validToken) | ||||
| 	if err == nil { | ||||
| 		t.Error("使用错误密钥解析令牌意外成功") | ||||
| 	} | ||||
| 	// 我们可以更精确地检查错误类型,以确保它是签名错误 | ||||
| 	if !errors.Is(err, jwt.ErrTokenSignatureInvalid) { | ||||
| 		t.Errorf("期望得到签名无效错误 (ErrTokenSignatureInvalid),但得到了: %v", err) | ||||
| 	} | ||||
| } | ||||
| @@ -44,6 +44,9 @@ type Config struct { | ||||
|  | ||||
| 	// Notify 通知服务配置 | ||||
| 	Notify NotifyConfig `yaml:"notify"` | ||||
|  | ||||
| 	// Collection 定时采集配置 | ||||
| 	Collection CollectionConfig `yaml:"collection"` | ||||
| } | ||||
|  | ||||
| // AppConfig 代表应用基础配置 | ||||
| @@ -195,10 +198,20 @@ type LarkConfig struct { | ||||
| 	AppSecret string `yaml:"appSecret"` | ||||
| } | ||||
|  | ||||
| // CollectionConfig 代表定时采集配置 | ||||
| type CollectionConfig struct { | ||||
| 	// Interval 采集间隔(分钟), 默认 1 | ||||
| 	Interval int `yaml:"interval"` | ||||
| } | ||||
|  | ||||
| // NewConfig 创建并返回一个新的配置实例 | ||||
| func NewConfig() *Config { | ||||
| 	// 默认值可以在这里设置,但我们优先使用配置文件中的值 | ||||
| 	return &Config{} | ||||
| 	return &Config{ | ||||
| 		Collection: CollectionConfig{ | ||||
| 			Interval: 1, // 默认为1分钟 | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // Load 从指定路径加载配置文件 | ||||
|   | ||||
| @@ -171,18 +171,19 @@ func (ps *PostgresStorage) creatingHyperTable() error { | ||||
| 		{models.PigSickLog{}, "happened_at"}, | ||||
| 		{models.PigPurchase{}, "purchase_date"}, | ||||
| 		{models.PigSale{}, "sale_date"}, | ||||
| 		{models.Notification{}, "alarm_timestamp"}, | ||||
| 	} | ||||
|  | ||||
| 	for _, table := range tablesToConvert { | ||||
| 		tableName := table.model.TableName() | ||||
| 		chunkInterval := "1 days" // 统一设置为1天 | ||||
| 		ps.logger.Infow("准备将表转换为超表", "table", tableName, "chunk_interval", chunkInterval) | ||||
| 		ps.logger.Debugw("准备将表转换为超表", "table", tableName, "chunk_interval", chunkInterval) | ||||
| 		sql := fmt.Sprintf("SELECT create_hypertable('%s', '%s', chunk_time_interval => INTERVAL '%s', if_not_exists => TRUE);", tableName, table.timeColumn, chunkInterval) | ||||
| 		if err := ps.db.Exec(sql).Error; err != nil { | ||||
| 			ps.logger.Errorw("转换为超表失败", "table", tableName, "error", err) | ||||
| 			return fmt.Errorf("将 %s 转换为超表失败: %w", tableName, err) | ||||
| 		} | ||||
| 		ps.logger.Infow("成功将表转换为超表 (或已转换)", "table", tableName) | ||||
| 		ps.logger.Debugw("成功将表转换为超表 (或已转换)", "table", tableName) | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| @@ -211,6 +212,7 @@ func (ps *PostgresStorage) applyCompressionPolicies() error { | ||||
| 		{models.PigSickLog{}, "pig_batch_id"}, | ||||
| 		{models.PigPurchase{}, "pig_batch_id"}, | ||||
| 		{models.PigSale{}, "pig_batch_id"}, | ||||
| 		{models.Notification{}, "user_id"}, | ||||
| 	} | ||||
|  | ||||
| 	for _, policy := range policies { | ||||
| @@ -218,22 +220,23 @@ func (ps *PostgresStorage) applyCompressionPolicies() error { | ||||
| 		compressAfter := "3 days" // 统一设置为2天后(即进入第3天)开始压缩 | ||||
|  | ||||
| 		// 1. 开启表的压缩设置,并指定分段列 | ||||
| 		ps.logger.Infow("为表启用压缩设置", "table", tableName, "segment_by", policy.segmentColumn) | ||||
| 		ps.logger.Debugw("为表启用压缩设置", "table", tableName, "segment_by", policy.segmentColumn) | ||||
| 		// 使用 + 而非Sprintf以规避goland静态检查报错 | ||||
| 		alterSQL := "ALTER TABLE" + " " + tableName + " SET (timescaledb.compress, timescaledb.compress_segmentby = '" + policy.segmentColumn + "');" | ||||
| 		if err := ps.db.Exec(alterSQL).Error; err != nil { | ||||
| 			// 忽略错误,因为这个设置可能是不可变的,重复执行会报错 | ||||
| 			ps.logger.Warnw("启用压缩设置时遇到问题 (可能已设置,可忽略)", "table", tableName, "error", err) | ||||
| 		} | ||||
| 		ps.logger.Debugw("成功为表启用压缩设置 (或已启用)", "table", tableName) | ||||
|  | ||||
| 		// 2. 添加压缩策略 | ||||
| 		ps.logger.Infow("为表添加压缩策略", "table", tableName, "compress_after", compressAfter) | ||||
| 		ps.logger.Debugw("为表添加压缩策略", "table", tableName, "compress_after", compressAfter) | ||||
| 		policySQL := fmt.Sprintf("SELECT add_compression_policy('%s', INTERVAL '%s', if_not_exists => TRUE);", tableName, compressAfter) | ||||
| 		if err := ps.db.Exec(policySQL).Error; err != nil { | ||||
| 			ps.logger.Errorw("添加压缩策略失败", "table", tableName, "error", err) | ||||
| 			return fmt.Errorf("为 %s 添加压缩策略失败: %w", tableName, err) | ||||
| 		} | ||||
| 		ps.logger.Infow("成功为表添加压缩策略 (或已存在)", "table", tableName) | ||||
| 		ps.logger.Debugw("成功为表添加压缩策略 (或已存在)", "table", tableName) | ||||
| 	} | ||||
|  | ||||
| 	return nil | ||||
| @@ -245,22 +248,22 @@ func (ps *PostgresStorage) creatingIndex() error { | ||||
| 	// 如果索引已存在,此命令不会报错 | ||||
|  | ||||
| 	// 为 sensor_data 表的 data 字段创建 GIN 索引 | ||||
| 	ps.logger.Info("正在为 sensor_data 表的 data 字段创建 GIN 索引") | ||||
| 	ps.logger.Debug("正在为 sensor_data 表的 data 字段创建 GIN 索引") | ||||
| 	ginSensorDataIndexSQL := "CREATE INDEX IF NOT EXISTS idx_sensor_data_data_gin ON sensor_data USING GIN (data);" | ||||
| 	if err := ps.db.Exec(ginSensorDataIndexSQL).Error; err != nil { | ||||
| 		ps.logger.Errorw("为 sensor_data 的 data 字段创建 GIN 索引失败", "error", err) | ||||
| 		return fmt.Errorf("为 sensor_data 的 data 字段创建 GIN 索引失败: %w", err) | ||||
| 	} | ||||
| 	ps.logger.Info("成功为 sensor_data 的 data 字段创建 GIN 索引 (或已存在)") | ||||
| 	ps.logger.Debug("成功为 sensor_data 的 data 字段创建 GIN 索引 (或已存在)") | ||||
|  | ||||
| 	// 为 tasks.parameters 创建 GIN 索引 | ||||
| 	ps.logger.Info("正在为 tasks 表的 parameters 字段创建 GIN 索引") | ||||
| 	ps.logger.Debug("正在为 tasks 表的 parameters 字段创建 GIN 索引") | ||||
| 	taskGinIndexSQL := "CREATE INDEX IF NOT EXISTS idx_tasks_parameters_gin ON tasks USING GIN (parameters);" | ||||
| 	if err := ps.db.Exec(taskGinIndexSQL).Error; err != nil { | ||||
| 		ps.logger.Errorw("为 tasks 的 parameters 字段创建 GIN 索引失败", "error", err) | ||||
| 		return fmt.Errorf("为 tasks 的 parameters 字段创建 GIN 索引失败: %w", err) | ||||
| 	} | ||||
| 	ps.logger.Info("成功为 tasks 的 parameters 字段创建 GIN 索引 (或已存在)") | ||||
| 	ps.logger.Debug("成功为 tasks 的 parameters 字段创建 GIN 索引 (或已存在)") | ||||
|  | ||||
| 	return nil | ||||
| } | ||||
|   | ||||
| @@ -1,166 +0,0 @@ | ||||
| package logs_test | ||||
|  | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"context" | ||||
| 	"encoding/json" | ||||
| 	"errors" | ||||
| 	"strings" | ||||
| 	"testing" | ||||
| 	"time" | ||||
|  | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/config" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| 	"go.uber.org/zap" | ||||
| 	"go.uber.org/zap/zapcore" | ||||
| ) | ||||
|  | ||||
| // captureOutput 是一个辅助函数,用于捕获 logger 的输出到内存缓冲区 | ||||
| func captureOutput(cfg config.LogConfig) (*logs.Logger, *bytes.Buffer) { | ||||
| 	var buf bytes.Buffer | ||||
|  | ||||
| 	encoder := logs.GetEncoder(cfg.Format) | ||||
|  | ||||
| 	writer := zapcore.AddSync(&buf) | ||||
|  | ||||
| 	level := zap.NewAtomicLevel() | ||||
| 	_ = level.UnmarshalText([]byte(cfg.Level)) | ||||
|  | ||||
| 	core := zapcore.NewCore(encoder, writer, level) | ||||
| 	// 匹配 logs.go 中 NewLogger 的行为,添加调用者信息 | ||||
| 	zapLogger := zap.New(core, zap.AddCaller(), zap.AddCallerSkip(1)) | ||||
|  | ||||
| 	logger := &logs.Logger{SugaredLogger: zapLogger.Sugar()} | ||||
| 	return logger, &buf | ||||
| } | ||||
| func TestNewLogger(t *testing.T) { | ||||
| 	t.Run("日志级别应生效", func(t *testing.T) { | ||||
| 		// 1. 创建一个级别为 WARN 的 logger | ||||
| 		logger, buf := captureOutput(config.LogConfig{Level: "warn", Format: "console"}) | ||||
|  | ||||
| 		// 2. 调用不同级别的日志方法 | ||||
| 		logger.Info("这条 info 日志不应被打印") | ||||
| 		logger.Warn("这条 warn 日志应该被打印") | ||||
|  | ||||
| 		// 3. 断言输出 | ||||
| 		output := buf.String() | ||||
| 		assert.NotContains(t, output, "这条 info 日志不应被打印") | ||||
| 		assert.Contains(t, output, "这条 warn 日志应该被打印") | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("JSON 格式应生效", func(t *testing.T) { | ||||
| 		// 1. 创建一个格式为 JSON 的 logger | ||||
| 		logger, buf := captureOutput(config.LogConfig{Level: "info", Format: "json"}) | ||||
|  | ||||
| 		// 2. 打印一条日志 | ||||
| 		logger.Info("测试json输出") | ||||
|  | ||||
| 		// 3. 断言输出 | ||||
| 		output := buf.String() | ||||
| 		// 验证它是否是合法的 JSON,并且包含预期的键值对 | ||||
| 		var logEntry map[string]interface{} | ||||
| 		// 注意:由于日志库可能会在行尾添加换行符,我们先 trim space | ||||
| 		err := json.Unmarshal([]byte(strings.TrimSpace(output)), &logEntry) | ||||
| 		assert.NoError(t, err, "日志输出应为合法的JSON") | ||||
| 		assert.Equal(t, "INFO", logEntry["level"]) | ||||
| 		assert.Equal(t, "测试json输出", logEntry["msg"]) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("文件日志构造函数不应 panic", func(t *testing.T) { | ||||
| 		// 这个测试保持原样,只验证构造函数在启用文件时不会崩溃 | ||||
| 		// 注意:我们不在单元测试中实际写入文件 | ||||
| 		cfgFile := config.LogConfig{ | ||||
| 			Level:      "info", | ||||
| 			EnableFile: true, | ||||
| 			FilePath:   "test.log", // 在测试环境中,这个文件不会被真正创建 | ||||
| 		} | ||||
| 		assert.NotPanics(t, func() { logs.NewLogger(cfgFile) }) | ||||
| 	}) | ||||
| } | ||||
|  | ||||
| func TestLogger_Write_ForGin(t *testing.T) { | ||||
| 	logger, buf := captureOutput(config.LogConfig{Level: "info"}) | ||||
|  | ||||
| 	ginLog := "[GIN-debug] Listening and serving HTTP on :8080\n" | ||||
| 	_, err := logger.Write([]byte(ginLog)) | ||||
|  | ||||
| 	assert.NoError(t, err) | ||||
| 	output := buf.String() | ||||
| 	// logger.Write 会将 gin 的日志转为 info 级别 | ||||
| 	assert.Contains(t, output, "INFO") | ||||
| 	assert.Contains(t, output, strings.TrimSpace(ginLog)) | ||||
| } | ||||
|  | ||||
| func TestGormLogger(t *testing.T) { | ||||
| 	logger, buf := captureOutput(config.LogConfig{Level: "debug"}) // 设置为 debug 以捕获所有级别 | ||||
| 	gormLogger := logs.NewGormLogger(logger) | ||||
|  | ||||
| 	// 模拟 GORM 的 Trace 调用参数 | ||||
| 	ctx := context.Background() | ||||
| 	sql := "SELECT * FROM users WHERE id = 1" | ||||
| 	rows := int64(1) | ||||
| 	fc := func() (string, int64) { | ||||
| 		return sql, rows | ||||
| 	} | ||||
|  | ||||
| 	t.Run("慢查询应记录为警告", func(t *testing.T) { | ||||
| 		buf.Reset() | ||||
| 		// 模拟一个耗时超过 200ms 的查询 | ||||
| 		begin := time.Now().Add(-300 * time.Millisecond) | ||||
| 		gormLogger.Trace(ctx, begin, fc, nil) | ||||
|  | ||||
| 		output := buf.String() | ||||
| 		assert.Contains(t, output, "WARN", "应包含 WARN 级别") | ||||
| 		assert.Contains(t, output, "[GORM] slow query", "应包含慢查询信息") | ||||
| 		assert.Contains(t, output, "SELECT * FROM users WHERE id = 1", "应包含 SQL 语句") | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("普通错误应记录为Error", func(t *testing.T) { | ||||
| 		buf.Reset() | ||||
| 		queryError := errors.New("syntax error") | ||||
| 		gormLogger.Trace(ctx, time.Now(), fc, queryError) | ||||
|  | ||||
| 		output := buf.String() | ||||
| 		assert.Contains(t, output, "ERROR") | ||||
| 		assert.Contains(t, output, "[GORM] error: syntax error") | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("当SkipErrRecordNotFound为true时应跳过RecordNotFound错误", func(t *testing.T) { | ||||
| 		buf.Reset() | ||||
| 		// 确保默认设置是 true | ||||
| 		gormLogger.SkipErrRecordNotFound = true | ||||
| 		// 错误必须包含 "record not found" 字符串以匹配 logs.go 中的判断逻辑 | ||||
| 		queryError := errors.New("record not found") | ||||
| 		gormLogger.Trace(ctx, time.Now(), fc, queryError) | ||||
|  | ||||
| 		assert.Empty(t, buf.String(), "开启 SkipErrRecordNotFound 后,record not found 错误不应产生任何日志") | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("当SkipErrRecordNotFound为false时应记录RecordNotFound错误", func(t *testing.T) { | ||||
| 		buf.Reset() | ||||
| 		// 手动将 SkipErrRecordNotFound 设置为 false | ||||
| 		gormLogger.SkipErrRecordNotFound = false | ||||
|  | ||||
| 		queryError := errors.New("record not found") | ||||
| 		gormLogger.Trace(ctx, time.Now(), fc, queryError) | ||||
|  | ||||
| 		// 恢复设置,避免影响其他测试 | ||||
| 		gormLogger.SkipErrRecordNotFound = true | ||||
|  | ||||
| 		output := buf.String() | ||||
| 		assert.NotEmpty(t, output, "关闭 SkipErrRecordNotFound 后,record not found 错误应该产生日志") | ||||
| 		assert.Contains(t, output, "ERROR") | ||||
| 		assert.Contains(t, output, "[GORM] error: record not found") | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("正常查询应记录为Debug", func(t *testing.T) { | ||||
| 		buf.Reset() | ||||
| 		// 模拟一个快速查询 | ||||
| 		gormLogger.Trace(ctx, time.Now(), fc, nil) | ||||
|  | ||||
| 		output := buf.String() | ||||
| 		assert.Contains(t, output, "DEBUG") // 正常查询是 Debug 级别 | ||||
| 		assert.Contains(t, output, "[GORM] trace") | ||||
| 	}) | ||||
| } | ||||
| @@ -171,7 +171,10 @@ const ( | ||||
| 	ContextAuditActionType     AuditContextKey = "auditActionType" | ||||
| 	ContextAuditTargetResource AuditContextKey = "auditTargetResource" | ||||
| 	ContextAuditDescription    AuditContextKey = "auditDescription" | ||||
| 	ContextUserKey             AuditContextKey = "user" | ||||
| 	ContextAuditStatus         AuditContextKey = "auditStatus" | ||||
| 	ContextAuditResultDetails  AuditContextKey = "auditResultDetails" | ||||
|  | ||||
| 	ContextUserKey AuditContextKey = "user" | ||||
| ) | ||||
|  | ||||
| func (a AuditContextKey) String() string { | ||||
|   | ||||
| @@ -59,6 +59,9 @@ func GetAllModels() []interface{} { | ||||
| 		// Medication Models | ||||
| 		&Medication{}, | ||||
| 		&MedicationLog{}, | ||||
|  | ||||
| 		// Notification Models | ||||
| 		&Notification{}, | ||||
| 	} | ||||
| } | ||||
|  | ||||
|   | ||||
							
								
								
									
										77
									
								
								internal/infra/models/notify.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										77
									
								
								internal/infra/models/notify.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,77 @@ | ||||
| package models | ||||
|  | ||||
| import ( | ||||
| 	"database/sql/driver" | ||||
| 	"errors" | ||||
| 	"time" | ||||
|  | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/notify" | ||||
| 	"go.uber.org/zap/zapcore" | ||||
| 	"gorm.io/gorm" | ||||
| ) | ||||
|  | ||||
| // NotificationStatus 定义了通知发送尝试的状态枚举。 | ||||
| type NotificationStatus string | ||||
|  | ||||
| const ( | ||||
| 	NotificationStatusSuccess NotificationStatus = "发送成功" // 通知已成功发送 | ||||
| 	NotificationStatusFailed  NotificationStatus = "发送失败" // 通知发送失败 | ||||
| 	NotificationStatusSkipped NotificationStatus = "已跳过"  // 通知因某些原因被跳过(例如:用户未配置联系方式) | ||||
| ) | ||||
|  | ||||
| // LogLevel is a custom type for zapcore.Level to handle database scanning and valuing. | ||||
| type LogLevel zapcore.Level | ||||
|  | ||||
| // Scan implements the sql.Scanner interface. | ||||
| func (l *LogLevel) Scan(value interface{}) error { | ||||
| 	var s string | ||||
| 	switch v := value.(type) { | ||||
| 	case []byte: | ||||
| 		s = string(v) | ||||
| 	case string: | ||||
| 		s = v | ||||
| 	default: | ||||
| 		return errors.New("LogLevel的类型无效") | ||||
| 	} | ||||
|  | ||||
| 	var zl zapcore.Level | ||||
| 	if err := zl.UnmarshalText([]byte(s)); err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	*l = LogLevel(zl) | ||||
| 	return nil | ||||
| } | ||||
|  | ||||
| // Value implements the driver.Valuer interface. | ||||
| func (l LogLevel) Value() (driver.Value, error) { | ||||
| 	return (zapcore.Level)(l).String(), nil | ||||
| } | ||||
|  | ||||
| // Notification 表示已发送或尝试发送的通知记录。 | ||||
| type Notification struct { | ||||
| 	gorm.Model | ||||
|  | ||||
| 	// NotifierType 通知器类型 (例如:"邮件", "企业微信", "飞书", "日志") | ||||
| 	NotifierType notify.NotifierType `gorm:"type:varchar(20);not null;index" json:"notifier_type"` | ||||
| 	// UserID 接收通知的用户ID,用于追溯通知记录到特定用户 | ||||
| 	UserID uint `gorm:"index" json:"user_id"` // 增加 UserID 字段,并添加索引 | ||||
| 	// Title 通知标题 | ||||
| 	Title string `gorm:"type:varchar(255);not null" json:"title"` | ||||
| 	// Message 通知内容 | ||||
| 	Message string `gorm:"type:text;not null" json:"message"` | ||||
| 	// Level 通知级别 (例如:INFO, WARN, ERROR) | ||||
| 	Level LogLevel `gorm:"type:varchar(10);not null" json:"level"` | ||||
| 	// AlarmTimestamp 通知内容生成时的时间戳,与 ID 构成复合主键 | ||||
| 	AlarmTimestamp time.Time `gorm:"primaryKey;not null" json:"alarm_timestamp"` | ||||
| 	// ToAddress 接收地址 (例如:邮箱地址, 企业微信ID, 日志标识符) | ||||
| 	ToAddress string `gorm:"type:varchar(255);not null" json:"to_address"` | ||||
| 	// Status 通知发送尝试的状态 (例如:"待发送", "发送成功", "发送失败", "已跳过") | ||||
| 	Status NotificationStatus `gorm:"type:varchar(20);not null;default:'待发送'" json:"status"` | ||||
| 	// ErrorMessage 如果通知发送失败,此字段存储错误信息 | ||||
| 	ErrorMessage string `gorm:"type:text" json:"error_message"` | ||||
| } | ||||
|  | ||||
| // TableName 指定 Notification 模型的表名。 | ||||
| func (Notification) TableName() string { | ||||
| 	return "notifications" | ||||
| } | ||||
| @@ -34,6 +34,7 @@ const ( | ||||
| 	TaskPlanAnalysis          TaskType = "计划分析" // 解析Plan的Task列表并添加到待执行队列的特殊任务 | ||||
| 	TaskTypeWaiting           TaskType = "等待"   // 等待任务 | ||||
| 	TaskTypeReleaseFeedWeight TaskType = "下料"   // 下料口释放指定重量任务 | ||||
| 	TaskTypeFullCollection    TaskType = "全量采集" // 新增的全量采集任务 | ||||
| ) | ||||
|  | ||||
| // -- Task Parameters -- | ||||
| @@ -52,12 +53,20 @@ const ( | ||||
| 	PlanStatusFailed   PlanStatus = "执行失败" // 执行失败 | ||||
| ) | ||||
|  | ||||
| type PlanType string | ||||
|  | ||||
| const ( | ||||
| 	PlanTypeCustom PlanType = "自定义任务" | ||||
| 	PlanTypeSystem PlanType = "系统任务" | ||||
| ) | ||||
|  | ||||
| // Plan 代表系统中的一个计划,可以包含子计划或任务 | ||||
| type Plan struct { | ||||
| 	gorm.Model | ||||
|  | ||||
| 	Name          string            `gorm:"not null" json:"name"` | ||||
| 	Description   string            `json:"description"` | ||||
| 	PlanType      PlanType          `gorm:"not null;index" json:"plan_type"` // 任务类型, 包括系统任务和用户自定义任务 | ||||
| 	ExecutionType PlanExecutionType `gorm:"not null;index" json:"execution_type"` | ||||
| 	Status        PlanStatus        `gorm:"default:'已禁用';index" json:"status"` // 计划是否被启动 | ||||
| 	ExecuteNum    uint              `gorm:"default:0" json:"execute_num"`      // 计划预期执行次数 | ||||
|   | ||||
| @@ -1,202 +0,0 @@ | ||||
| package models_test | ||||
|  | ||||
| import ( | ||||
| 	"sort" | ||||
| 	"testing" | ||||
|  | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| ) | ||||
|  | ||||
| func TestPlan_ReorderSteps(t *testing.T) { | ||||
| 	type testCase struct { | ||||
| 		name           string | ||||
| 		initialPlan    *models.Plan | ||||
| 		expectedOrders []int | ||||
| 	} | ||||
|  | ||||
| 	testCases := []testCase{ | ||||
| 		// --- Test Cases for Tasks --- | ||||
| 		{ | ||||
| 			name: "Tasks: 完美顺序", | ||||
| 			initialPlan: &models.Plan{ | ||||
| 				ContentType: models.PlanContentTypeTasks, | ||||
| 				Tasks: []models.Task{ | ||||
| 					{ExecutionOrder: 1}, | ||||
| 					{ExecutionOrder: 2}, | ||||
| 					{ExecutionOrder: 3}, | ||||
| 				}, | ||||
| 			}, | ||||
| 			expectedOrders: []int{1, 2, 3}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "Tasks: 有间断", | ||||
| 			initialPlan: &models.Plan{ | ||||
| 				ContentType: models.PlanContentTypeTasks, | ||||
| 				Tasks: []models.Task{ | ||||
| 					{ExecutionOrder: 1}, | ||||
| 					{ExecutionOrder: 3}, | ||||
| 					{ExecutionOrder: 5}, | ||||
| 				}, | ||||
| 			}, | ||||
| 			expectedOrders: []int{1, 2, 3}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "Tasks: 从0开始", | ||||
| 			initialPlan: &models.Plan{ | ||||
| 				ContentType: models.PlanContentTypeTasks, | ||||
| 				Tasks: []models.Task{ | ||||
| 					{ExecutionOrder: 0}, | ||||
| 					{ExecutionOrder: 1}, | ||||
| 					{ExecutionOrder: 2}, | ||||
| 				}, | ||||
| 			}, | ||||
| 			expectedOrders: []int{1, 2, 3}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "Tasks: 完全无序", | ||||
| 			initialPlan: &models.Plan{ | ||||
| 				ContentType: models.PlanContentTypeTasks, | ||||
| 				Tasks: []models.Task{ | ||||
| 					{ExecutionOrder: 8}, | ||||
| 					{ExecutionOrder: 2}, | ||||
| 					{ExecutionOrder: 4}, | ||||
| 				}, | ||||
| 			}, | ||||
| 			expectedOrders: []int{1, 2, 3}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "Tasks: 包含负数", | ||||
| 			initialPlan: &models.Plan{ | ||||
| 				ContentType: models.PlanContentTypeTasks, | ||||
| 				Tasks: []models.Task{ | ||||
| 					{ExecutionOrder: -5}, | ||||
| 					{ExecutionOrder: 10}, | ||||
| 					{ExecutionOrder: 2}, | ||||
| 				}, | ||||
| 			}, | ||||
| 			expectedOrders: []int{1, 2, 3}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "Tasks: 空切片", | ||||
| 			initialPlan: &models.Plan{ | ||||
| 				ContentType: models.PlanContentTypeTasks, | ||||
| 				Tasks:       []models.Task{}, | ||||
| 			}, | ||||
| 			expectedOrders: []int{}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "Tasks: 单个元素", | ||||
| 			initialPlan: &models.Plan{ | ||||
| 				ContentType: models.PlanContentTypeTasks, | ||||
| 				Tasks: []models.Task{ | ||||
| 					{ExecutionOrder: 100}, | ||||
| 				}, | ||||
| 			}, | ||||
| 			expectedOrders: []int{1}, | ||||
| 		}, | ||||
| 		// --- Test Cases for SubPlans --- | ||||
| 		{ | ||||
| 			name: "SubPlans: 完美顺序", | ||||
| 			initialPlan: &models.Plan{ | ||||
| 				ContentType: models.PlanContentTypeSubPlans, | ||||
| 				SubPlans: []models.SubPlan{ | ||||
| 					{ExecutionOrder: 1}, | ||||
| 					{ExecutionOrder: 2}, | ||||
| 					{ExecutionOrder: 3}, | ||||
| 				}, | ||||
| 			}, | ||||
| 			expectedOrders: []int{1, 2, 3}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "SubPlans: 有间断", | ||||
| 			initialPlan: &models.Plan{ | ||||
| 				ContentType: models.PlanContentTypeSubPlans, | ||||
| 				SubPlans: []models.SubPlan{ | ||||
| 					{ExecutionOrder: 1}, | ||||
| 					{ExecutionOrder: 3}, | ||||
| 					{ExecutionOrder: 5}, | ||||
| 				}, | ||||
| 			}, | ||||
| 			expectedOrders: []int{1, 2, 3}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "SubPlans: 从0开始", | ||||
| 			initialPlan: &models.Plan{ | ||||
| 				ContentType: models.PlanContentTypeSubPlans, | ||||
| 				SubPlans: []models.SubPlan{ | ||||
| 					{ExecutionOrder: 0}, | ||||
| 					{ExecutionOrder: 1}, | ||||
| 					{ExecutionOrder: 2}, | ||||
| 				}, | ||||
| 			}, | ||||
| 			expectedOrders: []int{1, 2, 3}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "SubPlans: 完全无序", | ||||
| 			initialPlan: &models.Plan{ | ||||
| 				ContentType: models.PlanContentTypeSubPlans, | ||||
| 				SubPlans: []models.SubPlan{ | ||||
| 					{ExecutionOrder: 8}, | ||||
| 					{ExecutionOrder: 2}, | ||||
| 					{ExecutionOrder: 4}, | ||||
| 				}, | ||||
| 			}, | ||||
| 			expectedOrders: []int{1, 2, 3}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "SubPlans: 包含负数", | ||||
| 			initialPlan: &models.Plan{ | ||||
| 				ContentType: models.PlanContentTypeSubPlans, | ||||
| 				SubPlans: []models.SubPlan{ | ||||
| 					{ExecutionOrder: -5}, | ||||
| 					{ExecutionOrder: 10}, | ||||
| 					{ExecutionOrder: 2}, | ||||
| 				}, | ||||
| 			}, | ||||
| 			expectedOrders: []int{1, 2, 3}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "SubPlans: 空切片", | ||||
| 			initialPlan: &models.Plan{ | ||||
| 				ContentType: models.PlanContentTypeSubPlans, | ||||
| 				SubPlans:    []models.SubPlan{}, | ||||
| 			}, | ||||
| 			expectedOrders: []int{}, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name: "SubPlans: 单个元素", | ||||
| 			initialPlan: &models.Plan{ | ||||
| 				ContentType: models.PlanContentTypeSubPlans, | ||||
| 				SubPlans: []models.SubPlan{ | ||||
| 					{ExecutionOrder: 100}, | ||||
| 				}, | ||||
| 			}, | ||||
| 			expectedOrders: []int{1}, | ||||
| 		}, | ||||
| 	} | ||||
|  | ||||
| 	for _, tc := range testCases { | ||||
| 		t.Run(tc.name, func(t *testing.T) { | ||||
| 			// 调用被测试的方法 | ||||
| 			tc.initialPlan.ReorderSteps() | ||||
|  | ||||
| 			// 提取并验证最终的顺序 | ||||
| 			finalOrders := make([]int, 0) | ||||
| 			if tc.initialPlan.ContentType == models.PlanContentTypeTasks { | ||||
| 				for _, task := range tc.initialPlan.Tasks { | ||||
| 					finalOrders = append(finalOrders, task.ExecutionOrder) | ||||
| 				} | ||||
| 			} else if tc.initialPlan.ContentType == models.PlanContentTypeSubPlans { | ||||
| 				for _, subPlan := range tc.initialPlan.SubPlans { | ||||
| 					finalOrders = append(finalOrders, subPlan.ExecutionOrder) | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			// 对 finalOrders 进行排序,以确保比较的一致性,因为 ReorderSteps 后的顺序是固定的 | ||||
| 			sort.Ints(finalOrders) | ||||
|  | ||||
| 			assert.Equal(t, tc.expectedOrders, finalOrders, "The final execution orders should be a continuous sequence starting from 1.") | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
| @@ -1,74 +0,0 @@ | ||||
| // Package models_test 包含对 models 包的单元测试 | ||||
| package models_test | ||||
|  | ||||
| import ( | ||||
| 	"testing" | ||||
|  | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| 	"golang.org/x/crypto/bcrypt" | ||||
| ) | ||||
|  | ||||
| func TestUser_CheckPassword(t *testing.T) { | ||||
| 	plainPassword := "my-secret-password" | ||||
|  | ||||
| 	// 1. 生成一个密码哈希用于测试 | ||||
| 	hashedPassword, err := bcrypt.GenerateFromPassword([]byte(plainPassword), bcrypt.DefaultCost) | ||||
| 	assert.NoError(t, err, "生成密码哈希不应出错") | ||||
|  | ||||
| 	user := &models.User{ | ||||
| 		Password: string(hashedPassword), | ||||
| 	} | ||||
|  | ||||
| 	t.Run("密码正确", func(t *testing.T) { | ||||
| 		// 2. 使用正确的明文密码进行校验 | ||||
| 		match := user.CheckPassword(plainPassword) | ||||
| 		assert.True(t, match, "正确的密码应该校验通过") | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("密码错误", func(t *testing.T) { | ||||
| 		// 3. 使用错误的明文密码进行校验 | ||||
| 		match := user.CheckPassword("wrong-password") | ||||
| 		assert.False(t, match, "错误的密码应该校验失败") | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("空密码", func(t *testing.T) { | ||||
| 		// 4. 使用空字符串作为密码进行校验 | ||||
| 		match := user.CheckPassword("") | ||||
| 		assert.False(t, match, "空密码应该校验失败") | ||||
| 	}) | ||||
| } | ||||
| func TestUser_BeforeCreate(t *testing.T) { | ||||
| 	t.Run("密码应被成功哈希", func(t *testing.T) { | ||||
| 		plainPassword := "securepassword123" | ||||
| 		user := &models.User{ | ||||
| 			Username: "testuser", | ||||
| 			Password: plainPassword, | ||||
| 		} | ||||
|  | ||||
| 		// 模拟 GORM 钩子调用 | ||||
| 		err := user.BeforeCreate(nil) // GORM 钩子通常接收 *gorm.DB,这里我们传入 nil,因为 BeforeCreate 不依赖 DB | ||||
| 		assert.NoError(t, err, "BeforeCreate 不应返回错误") | ||||
|  | ||||
| 		// 验证密码是否已被哈希(不再是明文) | ||||
| 		assert.NotEqual(t, plainPassword, user.Password, "密码应已被哈希") | ||||
|  | ||||
| 		// 验证哈希后的密码是否能被正确校验 | ||||
| 		assert.True(t, user.CheckPassword(plainPassword), "哈希后的密码应能通过校验") | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("空密码不应被哈希", func(t *testing.T) { | ||||
| 		plainPassword := "" | ||||
| 		user := &models.User{ | ||||
| 			Username: "empty_pass_user", | ||||
| 			Password: plainPassword, | ||||
| 		} | ||||
|  | ||||
| 		// 模拟 GORM 钩子调用 | ||||
| 		err := user.BeforeCreate(nil) | ||||
| 		assert.NoError(t, err, "BeforeCreate 不应返回错误") | ||||
|  | ||||
| 		// 验证密码仍然是空字符串 | ||||
| 		assert.Equal(t, plainPassword, user.Password, "空密码不应被哈希") | ||||
| 	}) | ||||
| } | ||||
| @@ -23,6 +23,9 @@ type DeviceRepository interface { | ||||
| 	// ListAll 获取所有设备的列表 | ||||
| 	ListAll() ([]*models.Device, error) | ||||
|  | ||||
| 	// ListAllSensors 获取所有传感器类型的设备列表 | ||||
| 	ListAllSensors() ([]*models.Device, error) | ||||
|  | ||||
| 	// ListByAreaControllerID 根据区域主控 ID 列出所有子设备。 | ||||
| 	ListByAreaControllerID(areaControllerID uint) ([]*models.Device, error) | ||||
|  | ||||
| @@ -84,6 +87,19 @@ func (r *gormDeviceRepository) ListAll() ([]*models.Device, error) { | ||||
| 	return devices, nil | ||||
| } | ||||
|  | ||||
| // ListAllSensors 检索归类为传感器的所有设备 | ||||
| func (r *gormDeviceRepository) ListAllSensors() ([]*models.Device, error) { | ||||
| 	var sensors []*models.Device | ||||
| 	err := r.db.Preload("AreaController").Preload("DeviceTemplate"). | ||||
| 		Joins("JOIN device_templates ON device_templates.id = devices.device_template_id"). | ||||
| 		Where("device_templates.category = ?", models.CategorySensor). | ||||
| 		Find(&sensors).Error | ||||
| 	if err != nil { | ||||
| 		return nil, fmt.Errorf("查询所有传感器失败: %w", err) | ||||
| 	} | ||||
| 	return sensors, nil | ||||
| } | ||||
|  | ||||
| // ListByAreaControllerID 根据区域主控 ID 列出所有子设备 | ||||
| func (r *gormDeviceRepository) ListByAreaControllerID(areaControllerID uint) ([]*models.Device, error) { | ||||
| 	var devices []*models.Device | ||||
|   | ||||
| @@ -1,38 +0,0 @@ | ||||
| package repository_test | ||||
|  | ||||
| import ( | ||||
| 	"os" | ||||
| 	"testing" | ||||
|  | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| 	"gorm.io/driver/sqlite" | ||||
| 	"gorm.io/gorm" | ||||
| ) | ||||
|  | ||||
| // setupTestDB 是一个共享的辅助函数,用于为集成测试创建一个干净的、内存中的 SQLite 数据库实例。 | ||||
| func setupTestDB(t *testing.T) *gorm.DB { | ||||
| 	// "file::memory:?cache=shared" 是 GORM 连接内存 SQLite 的标准方式,确保在同一测试中的不同连接可以访问相同的数据,而我们显然不需要这个 | ||||
| 	db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) | ||||
| 	assert.NoError(t, err, "连接内存数据库时发生错误") | ||||
|  | ||||
| 	// 自动迁移所有需要的表结构 | ||||
| 	err = db.AutoMigrate(models.GetAllModels()...) | ||||
| 	assert.NoError(t, err, "数据库迁移时发生错误") | ||||
|  | ||||
| 	return db | ||||
| } | ||||
|  | ||||
| // TestMain 是一个特殊的函数,它会在包内的所有测试运行之前被调用。 | ||||
| // 我们可以在这里进行一些全局的设置和清理工作。 | ||||
| func TestMain(m *testing.M) { | ||||
| 	// 在所有测试运行前可以执行一些设置代码 | ||||
|  | ||||
| 	// 运行包中的所有测试 | ||||
| 	code := m.Run() | ||||
|  | ||||
| 	// 在所有测试运行后可以执行一些清理代码 | ||||
|  | ||||
| 	// 退出测试 | ||||
| 	os.Exit(code) | ||||
| } | ||||
							
								
								
									
										111
									
								
								internal/infra/repository/notification_repository.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								internal/infra/repository/notification_repository.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,111 @@ | ||||
| package repository | ||||
|  | ||||
| import ( | ||||
| 	"time" | ||||
|  | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/notify" | ||||
| 	"go.uber.org/zap/zapcore" | ||||
| 	"gorm.io/gorm" | ||||
| ) | ||||
|  | ||||
| // NotificationListOptions 定义了查询通知列表时的可选参数 | ||||
| type NotificationListOptions struct { | ||||
| 	UserID       *uint                      // 按用户ID过滤 | ||||
| 	NotifierType *notify.NotifierType       // 按通知器类型过滤 | ||||
| 	Status       *models.NotificationStatus // 按通知状态过滤 (例如:"success", "failed") | ||||
| 	Level        *zapcore.Level             // 按通知等级过滤 (例如:"info", "warning", "error") | ||||
| 	StartTime    *time.Time                 // 通知内容生成时间范围 - 开始时间 (对应 AlarmTimestamp) | ||||
| 	EndTime      *time.Time                 // 通知内容生成时间范围 - 结束时间 (对应 AlarmTimestamp) | ||||
| 	OrderBy      string                     // 排序字段,例如 "alarm_timestamp DESC" | ||||
| } | ||||
|  | ||||
| // NotificationRepository 定义了与通知记录相关的数据库操作接口。 | ||||
| type NotificationRepository interface { | ||||
| 	// Create 将一条新的通知记录插入数据库。 | ||||
| 	Create(notification *models.Notification) error | ||||
| 	// CreateInTx 在给定的事务中插入一条新的通知记录。 | ||||
| 	CreateInTx(tx *gorm.DB, notification *models.Notification) error | ||||
| 	// BatchCreate 批量插入多条通知记录。 | ||||
| 	BatchCreate(notifications []*models.Notification) error | ||||
| 	// List 支持分页和过滤的通知列表查询。 | ||||
| 	// 返回通知列表、总记录数和错误。 | ||||
| 	List(opts NotificationListOptions, page, pageSize int) ([]models.Notification, int64, error) | ||||
| } | ||||
|  | ||||
| // gormNotificationRepository 是 NotificationRepository 的 GORM 实现。 | ||||
| type gormNotificationRepository struct { | ||||
| 	db *gorm.DB | ||||
| } | ||||
|  | ||||
| // NewGormNotificationRepository 创建一个新的 NotificationRepository GORM 实现实例。 | ||||
| func NewGormNotificationRepository(db *gorm.DB) NotificationRepository { | ||||
| 	return &gormNotificationRepository{db: db} | ||||
| } | ||||
|  | ||||
| // Create 将一条新的通知记录插入数据库。 | ||||
| func (r *gormNotificationRepository) Create(notification *models.Notification) error { | ||||
| 	return r.db.Create(notification).Error | ||||
| } | ||||
|  | ||||
| // CreateInTx 在给定的事务中插入一条新的通知记录。 | ||||
| func (r *gormNotificationRepository) CreateInTx(tx *gorm.DB, notification *models.Notification) error { | ||||
| 	return tx.Create(notification).Error | ||||
| } | ||||
|  | ||||
| // BatchCreate 批量插入多条通知记录。 | ||||
| func (r *gormNotificationRepository) BatchCreate(notifications []*models.Notification) error { | ||||
| 	// GORM 的 Create 方法在传入切片时会自动进行批量插入 | ||||
| 	return r.db.Create(¬ifications).Error | ||||
| } | ||||
|  | ||||
| // List 实现了分页和过滤查询通知记录的功能 | ||||
| func (r *gormNotificationRepository) List(opts NotificationListOptions, page, pageSize int) ([]models.Notification, int64, error) { | ||||
| 	// --- 校验分页参数 --- | ||||
| 	if page <= 0 || pageSize <= 0 { | ||||
| 		return nil, 0, ErrInvalidPagination // 复用已定义的错误 | ||||
| 	} | ||||
|  | ||||
| 	var results []models.Notification | ||||
| 	var total int64 | ||||
|  | ||||
| 	query := r.db.Model(&models.Notification{}) | ||||
|  | ||||
| 	// --- 应用过滤条件 --- | ||||
| 	if opts.UserID != nil { | ||||
| 		query = query.Where("user_id = ?", *opts.UserID) | ||||
| 	} | ||||
| 	if opts.NotifierType != nil { | ||||
| 		query = query.Where("notifier_type = ?", *opts.NotifierType) | ||||
| 	} | ||||
| 	if opts.Status != nil { | ||||
| 		query = query.Where("status = ?", *opts.Status) | ||||
| 	} | ||||
| 	if opts.Level != nil { | ||||
| 		query = query.Where("level = ?", opts.Level.String()) | ||||
| 	} | ||||
| 	if opts.StartTime != nil { | ||||
| 		query = query.Where("alarm_timestamp >= ?", *opts.StartTime) | ||||
| 	} | ||||
| 	if opts.EndTime != nil { | ||||
| 		query = query.Where("alarm_timestamp <= ?", *opts.EndTime) | ||||
| 	} | ||||
|  | ||||
| 	// --- 计算总数 --- | ||||
| 	if err := query.Count(&total).Error; err != nil { | ||||
| 		return nil, 0, err | ||||
| 	} | ||||
|  | ||||
| 	// --- 应用排序条件 --- | ||||
| 	orderBy := "alarm_timestamp DESC" // 默认按时间倒序 | ||||
| 	if opts.OrderBy != "" { | ||||
| 		orderBy = opts.OrderBy | ||||
| 	} | ||||
| 	query = query.Order(orderBy) | ||||
|  | ||||
| 	// --- 分页 --- | ||||
| 	offset := (page - 1) * pageSize | ||||
| 	err := query.Limit(pageSize).Offset(offset).Find(&results).Error | ||||
|  | ||||
| 	return results, total, err | ||||
| } | ||||
| @@ -21,15 +21,31 @@ var ( | ||||
| 	ErrDeleteWithReferencedPlan = errors.New("禁止删除正在被引用的计划") | ||||
| ) | ||||
|  | ||||
| // PlanTypeFilter 定义计划类型的过滤器 | ||||
| type PlanTypeFilter string | ||||
|  | ||||
| const ( | ||||
| 	PlanTypeFilterAll    PlanTypeFilter = "所有任务" | ||||
| 	PlanTypeFilterCustom PlanTypeFilter = "自定义任务" | ||||
| 	PlanTypeFilterSystem PlanTypeFilter = "系统任务" | ||||
| ) | ||||
|  | ||||
| // ListPlansOptions 定义了查询计划时的可选参数 | ||||
| type ListPlansOptions struct { | ||||
| 	PlanType PlanTypeFilter | ||||
| } | ||||
|  | ||||
| // PlanRepository 定义了与计划模型相关的数据库操作接口 | ||||
| // 这是为了让业务逻辑层依赖于抽象,而不是具体的数据库实现 | ||||
| type PlanRepository interface { | ||||
| 	// ListBasicPlans 获取所有计划的基本信息,不包含子计划和任务详情 | ||||
| 	ListBasicPlans() ([]models.Plan, error) | ||||
| 	// ListPlans 获取计划列表,支持过滤和分页 | ||||
| 	ListPlans(opts ListPlansOptions, page, pageSize int) ([]models.Plan, int64, error) | ||||
| 	// GetBasicPlanByID 根据ID获取计划的基本信息,不包含子计划和任务详情 | ||||
| 	GetBasicPlanByID(id uint) (*models.Plan, error) | ||||
| 	// GetPlanByID 根据ID获取计划,包含子计划和任务详情 | ||||
| 	GetPlanByID(id uint) (*models.Plan, error) | ||||
| 	// GetPlansByIDs 根据ID列表获取计划,不包含子计划和任务详情 | ||||
| 	GetPlansByIDs(ids []uint) ([]models.Plan, error) | ||||
| 	// CreatePlan 创建一个新的计划 | ||||
| 	CreatePlan(plan *models.Plan) error | ||||
| 	// UpdatePlan 更新计划,包括子计划和任务 | ||||
| @@ -81,15 +97,37 @@ func NewGormPlanRepository(db *gorm.DB) PlanRepository { | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // ListBasicPlans 获取所有计划的基本信息,不包含子计划和任务详情 | ||||
| func (r *gormPlanRepository) ListBasicPlans() ([]models.Plan, error) { | ||||
| 	var plans []models.Plan | ||||
| 	// GORM 默认不会加载关联,除非使用 Preload,所以直接 Find 即可满足要求 | ||||
| 	result := r.db.Find(&plans) | ||||
| 	if result.Error != nil { | ||||
| 		return nil, result.Error | ||||
| // ListPlans 获取计划列表,支持过滤和分页 | ||||
| func (r *gormPlanRepository) ListPlans(opts ListPlansOptions, page, pageSize int) ([]models.Plan, int64, error) { | ||||
| 	if page <= 0 || pageSize <= 0 { | ||||
| 		return nil, 0, ErrInvalidPagination | ||||
| 	} | ||||
| 	return plans, nil | ||||
|  | ||||
| 	var plans []models.Plan | ||||
| 	var total int64 | ||||
|  | ||||
| 	query := r.db.Model(&models.Plan{}) | ||||
|  | ||||
| 	switch opts.PlanType { | ||||
| 	case PlanTypeFilterCustom: | ||||
| 		query = query.Where("plan_type = ?", models.PlanTypeCustom) | ||||
| 	case PlanTypeFilterSystem: | ||||
| 		query = query.Where("plan_type = ?", models.PlanTypeSystem) | ||||
| 	case PlanTypeFilterAll: | ||||
| 		// 不添加 plan_type 的过滤条件 | ||||
| 	default: | ||||
| 		// 默认查询自定义 | ||||
| 		query = query.Where("plan_type = ?", models.PlanTypeCustom) | ||||
| 	} | ||||
|  | ||||
| 	if err := query.Count(&total).Error; err != nil { | ||||
| 		return nil, 0, err | ||||
| 	} | ||||
|  | ||||
| 	offset := (page - 1) * pageSize | ||||
| 	err := query.Limit(pageSize).Offset(offset).Order("id DESC").Find(&plans).Error | ||||
|  | ||||
| 	return plans, total, err | ||||
| } | ||||
|  | ||||
| // GetBasicPlanByID 根据ID获取计划的基本信息,不包含子计划和任务详情 | ||||
| @@ -103,6 +141,19 @@ func (r *gormPlanRepository) GetBasicPlanByID(id uint) (*models.Plan, error) { | ||||
| 	return &plan, nil | ||||
| } | ||||
|  | ||||
| // GetPlansByIDs 根据ID列表获取计划,不包含子计划和任务详情 | ||||
| func (r *gormPlanRepository) GetPlansByIDs(ids []uint) ([]models.Plan, error) { | ||||
| 	var plans []models.Plan | ||||
| 	if len(ids) == 0 { | ||||
| 		return plans, nil | ||||
| 	} | ||||
| 	err := r.db.Where("id IN ?", ids).Find(&plans).Error | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 	return plans, nil | ||||
| } | ||||
|  | ||||
| // GetPlanByID 根据ID获取计划,包含子计划和任务详情 | ||||
| func (r *gormPlanRepository) GetPlanByID(id uint) (*models.Plan, error) { | ||||
| 	var plan models.Plan | ||||
|   | ||||
							
								
								
									
										6
									
								
								internal/infra/repository/repository.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								internal/infra/repository/repository.go
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| package repository | ||||
|  | ||||
| import "errors" | ||||
|  | ||||
| // ErrInvalidPagination 表示分页参数无效 | ||||
| var ErrInvalidPagination = errors.New("无效的分页参数:page和page_size必须为大于0") | ||||
| @@ -1,16 +1,12 @@ | ||||
| package repository | ||||
|  | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"time" | ||||
|  | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||
| 	"gorm.io/gorm" | ||||
| ) | ||||
|  | ||||
| // ErrInvalidPagination 表示分页参数无效 | ||||
| var ErrInvalidPagination = errors.New("无效的分页参数:page和pageSize必须为大于0") | ||||
|  | ||||
| // SensorDataListOptions 定义了查询传感器数据列表时的可选参数 | ||||
| type SensorDataListOptions struct { | ||||
| 	DeviceID   *uint | ||||
|   | ||||
| @@ -1,78 +0,0 @@ | ||||
| // Package repository_test 包含对 repository 包的集成测试 | ||||
| package repository_test | ||||
|  | ||||
| import ( | ||||
| 	"testing" | ||||
|  | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" | ||||
| 	"github.com/stretchr/testify/assert" | ||||
| 	"gorm.io/gorm" | ||||
| ) | ||||
|  | ||||
| func TestGormUserRepository(t *testing.T) { | ||||
| 	db := setupTestDB(t) | ||||
| 	repo := repository.NewGormUserRepository(db) | ||||
|  | ||||
| 	plainPassword := "my-secret-password" | ||||
| 	userToCreate := &models.User{ | ||||
| 		Username: "testuser", | ||||
| 		Password: plainPassword, // 我们提供的是明文密码 | ||||
| 	} | ||||
|  | ||||
| 	t.Run("创建 - 成功创建并验证密码哈希", func(t *testing.T) { | ||||
| 		err := repo.Create(userToCreate) | ||||
| 		assert.NoError(t, err) | ||||
|  | ||||
| 		// 验证用户已被创建 | ||||
| 		assert.NotZero(t, userToCreate.ID) | ||||
|  | ||||
| 		// 从数据库中直接取回记录,以验证 BeforeSave 钩子是否生效 | ||||
| 		var savedUser models.User | ||||
| 		db.First(&savedUser, userToCreate.ID) | ||||
|  | ||||
| 		// 验证密码字段存储的不是明文 | ||||
| 		assert.NotEqual(t, plainPassword, savedUser.Password, "数据库中存储的密码不应是明文") | ||||
|  | ||||
| 		// 验证存储的哈希是正确的 | ||||
| 		assert.True(t, savedUser.CheckPassword(plainPassword), "存储的密码哈希应该能与原明文匹配") | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("创建 - 用户名冲突", func(t *testing.T) { | ||||
| 		// 尝试创建一个同名用户 | ||||
| 		duplicateUser := &models.User{Username: "testuser", Password: "anypassword"} | ||||
| 		err := repo.Create(duplicateUser) | ||||
|  | ||||
| 		// 我们期望一个错误,因为用户名是唯一的 | ||||
| 		assert.Error(t, err, "创建同名用户应该返回错误") | ||||
| 		// 更精确地,可以检查是否是唯一键冲突错误 | ||||
| 		assert.Contains(t, err.Error(), "UNIQUE constraint failed: users.username", "错误信息应包含唯一键冲突") | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("按用户名查找 - 找到用户", func(t *testing.T) { | ||||
| 		foundUser, err := repo.FindByUsername("testuser") | ||||
| 		assert.NoError(t, err) | ||||
| 		assert.NotNil(t, foundUser) | ||||
| 		assert.Equal(t, userToCreate.ID, foundUser.ID) | ||||
| 		assert.Equal(t, "testuser", foundUser.Username) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("按用户名查找 - 未找到用户", func(t *testing.T) { | ||||
| 		_, err := repo.FindByUsername("nonexistent") | ||||
| 		assert.Error(t, err, "查找不存在的用户应该返回错误") | ||||
| 		assert.ErrorIs(t, err, gorm.ErrRecordNotFound, "错误类型应为 gorm.ErrRecordNotFound") | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("按ID查找 - 找到用户", func(t *testing.T) { | ||||
| 		foundUser, err := repo.FindByID(userToCreate.ID) | ||||
| 		assert.NoError(t, err) | ||||
| 		assert.NotNil(t, foundUser) | ||||
| 		assert.Equal(t, userToCreate.ID, foundUser.ID) | ||||
| 	}) | ||||
|  | ||||
| 	t.Run("按ID查找 - 未找到用户", func(t *testing.T) { | ||||
| 		_, err := repo.FindByID(99999) | ||||
| 		assert.Error(t, err, "查找不存在的ID应该返回错误") | ||||
| 		assert.ErrorIs(t, err, gorm.ErrRecordNotFound, "错误类型应为 gorm.ErrRecordNotFound") | ||||
| 	}) | ||||
| } | ||||
							
								
								
									
										454
									
								
								openspec/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										454
									
								
								openspec/AGENTS.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,454 @@ | ||||
| # OpenSpec Instructions | ||||
|  | ||||
| Instructions for AI coding assistants using OpenSpec for spec-driven development. | ||||
|  | ||||
| ## TL;DR Quick Checklist | ||||
|  | ||||
| - Search existing work: `openspec spec list --long`, `openspec list` (use `rg` only for full-text search) | ||||
| - Decide scope: new capability vs modify existing capability | ||||
| - Pick a unique `change-id`: kebab-case, verb-led (`add-`, `update-`, `remove-`, `refactor-`) | ||||
| - Scaffold: `proposal.md`, `tasks.md`, `design.md` (only if needed), and delta specs per affected capability | ||||
| - Write deltas: use `## ADDED|MODIFIED|REMOVED|RENAMED Requirements`; include at least one `#### Scenario:` per requirement | ||||
| - Validate: `openspec validate [change-id] --strict` and fix issues | ||||
| - Request approval: Do not start implementation until proposal is approved | ||||
|  | ||||
| ## Three-Stage Workflow | ||||
|  | ||||
| ### Stage 1: Creating Changes | ||||
| Create proposal when you need to: | ||||
| - Add features or functionality | ||||
| - Make breaking changes (API, schema) | ||||
| - Change architecture or patterns   | ||||
| - Optimize performance (changes behavior) | ||||
| - Update security patterns | ||||
|  | ||||
| Triggers (examples): | ||||
| - "Help me create a change proposal" | ||||
| - "Help me plan a change" | ||||
| - "Help me create a proposal" | ||||
| - "I want to create a spec proposal" | ||||
| - "I want to create a spec" | ||||
|  | ||||
| Loose matching guidance: | ||||
| - Contains one of: `proposal`, `change`, `spec` | ||||
| - With one of: `create`, `plan`, `make`, `start`, `help` | ||||
|  | ||||
| Skip proposal for: | ||||
| - Bug fixes (restore intended behavior) | ||||
| - Typos, formatting, comments | ||||
| - Dependency updates (non-breaking) | ||||
| - Configuration changes | ||||
| - Tests for existing behavior | ||||
|  | ||||
| **Workflow** | ||||
| 1. Review `openspec/project.md`, `openspec list`, and `openspec list --specs` to understand current context. | ||||
| 2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, optional `design.md`, and spec deltas under `openspec/changes/<id>/`. | ||||
| 3. Draft spec deltas using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement. | ||||
| 4. Run `openspec validate <id> --strict` and resolve any issues before sharing the proposal. | ||||
|  | ||||
| ### Stage 2: Implementing Changes | ||||
| Track these steps as TODOs and complete them one by one. | ||||
| 1. **Read proposal.md** - Understand what's being built | ||||
| 2. **Read design.md** (if exists) - Review technical decisions | ||||
| 3. **Read tasks.md** - Get implementation checklist | ||||
| 4. **Implement tasks sequentially** - Complete in order | ||||
| 5. **Confirm completion** - Ensure every item in `tasks.md` is finished before updating statuses | ||||
| 6. **Update checklist** - After all work is done, set every task to `- [x]` so the list reflects reality | ||||
| 7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved | ||||
|  | ||||
| ### Stage 3: Archiving Changes | ||||
| After deployment, create separate PR to: | ||||
| - Move `changes/[name]/` → `changes/archive/YYYY-MM-DD-[name]/` | ||||
| - Update `specs/` if capabilities changed | ||||
| - Use `openspec archive <change-id> --skip-specs --yes` for tooling-only changes (always pass the change ID explicitly) | ||||
| - Run `openspec validate --strict` to confirm the archived change passes checks | ||||
|  | ||||
| ## Before Any Task | ||||
|  | ||||
| **Context Checklist:** | ||||
| - [ ] Read relevant specs in `specs/[capability]/spec.md` | ||||
| - [ ] Check pending changes in `changes/` for conflicts | ||||
| - [ ] Read `openspec/project.md` for conventions | ||||
| - [ ] Run `openspec list` to see active changes | ||||
| - [ ] Run `openspec list --specs` to see existing capabilities | ||||
|  | ||||
| **Before Creating Specs:** | ||||
| - Always check if capability already exists | ||||
| - Prefer modifying existing specs over creating duplicates | ||||
| - Use `openspec show [spec]` to review current state | ||||
| - If request is ambiguous, ask 1–2 clarifying questions before scaffolding | ||||
|  | ||||
| ### Search Guidance | ||||
| - Enumerate specs: `openspec spec list --long` (or `--json` for scripts) | ||||
| - Enumerate changes: `openspec list` (or `openspec change list --json` - deprecated but available) | ||||
| - Show details: | ||||
|   - Spec: `openspec show <spec-id> --type spec` (use `--json` for filters) | ||||
|   - Change: `openspec show <change-id> --json --deltas-only` | ||||
| - Full-text search (use ripgrep): `rg -n "Requirement:|Scenario:" openspec/specs` | ||||
|  | ||||
| ## Quick Start | ||||
|  | ||||
| ### CLI Commands | ||||
|  | ||||
| ```bash | ||||
| # Essential commands | ||||
| openspec list                  # List active changes | ||||
| openspec list --specs          # List specifications | ||||
| openspec show [item]           # Display change or spec | ||||
| openspec validate [item]       # Validate changes or specs | ||||
| openspec archive <change-id> [--yes|-y]   # Archive after deployment (add --yes for non-interactive runs) | ||||
|  | ||||
| # Project management | ||||
| openspec init [path]           # Initialize OpenSpec | ||||
| openspec update [path]         # Update instruction files | ||||
|  | ||||
| # Interactive mode | ||||
| openspec show                  # Prompts for selection | ||||
| openspec validate              # Bulk validation mode | ||||
|  | ||||
| # Debugging | ||||
| openspec show [change] --json --deltas-only | ||||
| openspec validate [change] --strict | ||||
| ``` | ||||
|  | ||||
| ### Command Flags | ||||
|  | ||||
| - `--json` - Machine-readable output | ||||
| - `--type change|spec` - Disambiguate items | ||||
| - `--strict` - Comprehensive validation | ||||
| - `--no-interactive` - Disable prompts | ||||
| - `--skip-specs` - Archive without spec updates | ||||
| - `--yes`/`-y` - Skip confirmation prompts (non-interactive archive) | ||||
|  | ||||
| ## Directory Structure | ||||
|  | ||||
| ``` | ||||
| openspec/ | ||||
| ├── project.md              # Project conventions | ||||
| ├── specs/                  # Current truth - what IS built | ||||
| │   └── [capability]/       # Single focused capability | ||||
| │       ├── spec.md         # Requirements and scenarios | ||||
| │       └── design.md       # Technical patterns | ||||
| ├── changes/                # Proposals - what SHOULD change | ||||
| │   ├── [change-name]/ | ||||
| │   │   ├── proposal.md     # Why, what, impact | ||||
| │   │   ├── tasks.md        # Implementation checklist | ||||
| │   │   ├── design.md       # Technical decisions (optional; see criteria) | ||||
| │   │   └── specs/          # Delta changes | ||||
| │   │       └── [capability]/ | ||||
| │   │           └── spec.md # ADDED/MODIFIED/REMOVED | ||||
| │   └── archive/            # Completed changes | ||||
| ``` | ||||
|  | ||||
| ## Creating Change Proposals | ||||
|  | ||||
| ### Decision Tree | ||||
|  | ||||
| ``` | ||||
| New request? | ||||
| ├─ Bug fix restoring spec behavior? → Fix directly | ||||
| ├─ Typo/format/comment? → Fix directly   | ||||
| ├─ New feature/capability? → Create proposal | ||||
| ├─ Breaking change? → Create proposal | ||||
| ├─ Architecture change? → Create proposal | ||||
| └─ Unclear? → Create proposal (safer) | ||||
| ``` | ||||
|  | ||||
| ### Proposal Structure | ||||
|  | ||||
| 1. **Create directory:** `changes/[change-id]/` (kebab-case, verb-led, unique) | ||||
|  | ||||
| 2. **Write proposal.md:** | ||||
| ```markdown | ||||
| ## Why | ||||
| [1-2 sentences on problem/opportunity] | ||||
|  | ||||
| ## What Changes | ||||
| - [Bullet list of changes] | ||||
| - [Mark breaking changes with **BREAKING**] | ||||
|  | ||||
| ## Impact | ||||
| - Affected specs: [list capabilities] | ||||
| - Affected code: [key files/systems] | ||||
| ``` | ||||
|  | ||||
| 3. **Create spec deltas:** `specs/[capability]/spec.md` | ||||
| ```markdown | ||||
| ## ADDED Requirements | ||||
| ### Requirement: New Feature | ||||
| The system SHALL provide... | ||||
|  | ||||
| #### Scenario: Success case | ||||
| - **WHEN** user performs action | ||||
| - **THEN** expected result | ||||
|  | ||||
| ## MODIFIED Requirements | ||||
| ### Requirement: Existing Feature | ||||
| [Complete modified requirement] | ||||
|  | ||||
| ## REMOVED Requirements | ||||
| ### Requirement: Old Feature | ||||
| **Reason**: [Why removing] | ||||
| **Migration**: [How to handle] | ||||
| ``` | ||||
| If multiple capabilities are affected, create multiple delta files under `changes/[change-id]/specs/<capability>/spec.md`—one per capability. | ||||
|  | ||||
| 4. **Create tasks.md:** | ||||
| ```markdown | ||||
| ## 1. Implementation | ||||
| - [ ] 1.1 Create database schema | ||||
| - [ ] 1.2 Implement API endpoint | ||||
| - [ ] 1.3 Add frontend component | ||||
| - [ ] 1.4 Write tests | ||||
| ``` | ||||
|  | ||||
| 5. **Create design.md when needed:** | ||||
| Create `design.md` if any of the following apply; otherwise omit it: | ||||
| - Cross-cutting change (multiple services/modules) or a new architectural pattern | ||||
| - New external dependency or significant data model changes | ||||
| - Security, performance, or migration complexity | ||||
| - Ambiguity that benefits from technical decisions before coding | ||||
|  | ||||
| Minimal `design.md` skeleton: | ||||
| ```markdown | ||||
| ## Context | ||||
| [Background, constraints, stakeholders] | ||||
|  | ||||
| ## Goals / Non-Goals | ||||
| - Goals: [...] | ||||
| - Non-Goals: [...] | ||||
|  | ||||
| ## Decisions | ||||
| - Decision: [What and why] | ||||
| - Alternatives considered: [Options + rationale] | ||||
|  | ||||
| ## Risks / Trade-offs | ||||
| - [Risk] → Mitigation | ||||
|  | ||||
| ## Migration Plan | ||||
| [Steps, rollback] | ||||
|  | ||||
| ## Open Questions | ||||
| - [...] | ||||
| ``` | ||||
|  | ||||
| ## Spec File Format | ||||
|  | ||||
| ### Critical: Scenario Formatting | ||||
|  | ||||
| **CORRECT** (use #### headers): | ||||
| ```markdown | ||||
| #### Scenario: User login success | ||||
| - **WHEN** valid credentials provided | ||||
| - **THEN** return JWT token | ||||
| ``` | ||||
|  | ||||
| **WRONG** (don't use bullets or bold): | ||||
| ```markdown | ||||
| - **Scenario: User login**  ❌ | ||||
| **Scenario**: User login     ❌ | ||||
| ### Scenario: User login      ❌ | ||||
| ``` | ||||
|  | ||||
| Every requirement MUST have at least one scenario. | ||||
|  | ||||
| ### Requirement Wording | ||||
| - Use SHALL/MUST for normative requirements (avoid should/may unless intentionally non-normative) | ||||
|  | ||||
| ### Delta Operations | ||||
|  | ||||
| - `## ADDED Requirements` - New capabilities | ||||
| - `## MODIFIED Requirements` - Changed behavior | ||||
| - `## REMOVED Requirements` - Deprecated features | ||||
| - `## RENAMED Requirements` - Name changes | ||||
|  | ||||
| Headers matched with `trim(header)` - whitespace ignored. | ||||
|  | ||||
| #### When to use ADDED vs MODIFIED | ||||
| - ADDED: Introduces a new capability or sub-capability that can stand alone as a requirement. Prefer ADDED when the change is orthogonal (e.g., adding "Slash Command Configuration") rather than altering the semantics of an existing requirement. | ||||
| - MODIFIED: Changes the behavior, scope, or acceptance criteria of an existing requirement. Always paste the full, updated requirement content (header + all scenarios). The archiver will replace the entire requirement with what you provide here; partial deltas will drop previous details. | ||||
| - RENAMED: Use when only the name changes. If you also change behavior, use RENAMED (name) plus MODIFIED (content) referencing the new name. | ||||
|  | ||||
| Common pitfall: Using MODIFIED to add a new concern without including the previous text. This causes loss of detail at archive time. If you aren’t explicitly changing the existing requirement, add a new requirement under ADDED instead. | ||||
|  | ||||
| Authoring a MODIFIED requirement correctly: | ||||
| 1) Locate the existing requirement in `openspec/specs/<capability>/spec.md`. | ||||
| 2) Copy the entire requirement block (from `### Requirement: ...` through its scenarios). | ||||
| 3) Paste it under `## MODIFIED Requirements` and edit to reflect the new behavior. | ||||
| 4) Ensure the header text matches exactly (whitespace-insensitive) and keep at least one `#### Scenario:`. | ||||
|  | ||||
| Example for RENAMED: | ||||
| ```markdown | ||||
| ## RENAMED Requirements | ||||
| - FROM: `### Requirement: Login` | ||||
| - TO: `### Requirement: User Authentication` | ||||
| ``` | ||||
|  | ||||
| ## Troubleshooting | ||||
|  | ||||
| ### Common Errors | ||||
|  | ||||
| **"Change must have at least one delta"** | ||||
| - Check `changes/[name]/specs/` exists with .md files | ||||
| - Verify files have operation prefixes (## ADDED Requirements) | ||||
|  | ||||
| **"Requirement must have at least one scenario"** | ||||
| - Check scenarios use `#### Scenario:` format (4 hashtags) | ||||
| - Don't use bullet points or bold for scenario headers | ||||
|  | ||||
| **Silent scenario parsing failures** | ||||
| - Exact format required: `#### Scenario: Name` | ||||
| - Debug with: `openspec show [change] --json --deltas-only` | ||||
|  | ||||
| ### Validation Tips | ||||
|  | ||||
| ```bash | ||||
| # Always use strict mode for comprehensive checks | ||||
| openspec validate [change] --strict | ||||
|  | ||||
| # Debug delta parsing | ||||
| openspec show [change] --json | jq '.deltas' | ||||
|  | ||||
| # Check specific requirement | ||||
| openspec show [spec] --json -r 1 | ||||
| ``` | ||||
|  | ||||
| ## Happy Path Script | ||||
|  | ||||
| ```bash | ||||
| # 1) Explore current state | ||||
| openspec spec list --long | ||||
| openspec list | ||||
| # Optional full-text search: | ||||
| # rg -n "Requirement:|Scenario:" openspec/specs | ||||
| # rg -n "^#|Requirement:" openspec/changes | ||||
|  | ||||
| # 2) Choose change id and scaffold | ||||
| CHANGE=add-two-factor-auth | ||||
| mkdir -p openspec/changes/$CHANGE/{specs/auth} | ||||
| printf "## Why\n...\n\n## What Changes\n- ...\n\n## Impact\n- ...\n" > openspec/changes/$CHANGE/proposal.md | ||||
| printf "## 1. Implementation\n- [ ] 1.1 ...\n" > openspec/changes/$CHANGE/tasks.md | ||||
|  | ||||
| # 3) Add deltas (example) | ||||
| cat > openspec/changes/$CHANGE/specs/auth/spec.md << 'EOF' | ||||
| ## ADDED Requirements | ||||
| ### Requirement: Two-Factor Authentication | ||||
| Users MUST provide a second factor during login. | ||||
|  | ||||
| #### Scenario: OTP required | ||||
| - **WHEN** valid credentials are provided | ||||
| - **THEN** an OTP challenge is required | ||||
| EOF | ||||
|  | ||||
| # 4) Validate | ||||
| openspec validate $CHANGE --strict | ||||
| ``` | ||||
|  | ||||
| ## Multi-Capability Example | ||||
|  | ||||
| ``` | ||||
| openspec/changes/add-2fa-notify/ | ||||
| ├── proposal.md | ||||
| ├── tasks.md | ||||
| └── specs/ | ||||
|     ├── auth/ | ||||
|     │   └── spec.md   # ADDED: Two-Factor Authentication | ||||
|     └── notifications/ | ||||
|         └── spec.md   # ADDED: OTP email notification | ||||
| ``` | ||||
|  | ||||
| auth/spec.md | ||||
| ```markdown | ||||
| ## ADDED Requirements | ||||
| ### Requirement: Two-Factor Authentication | ||||
| ... | ||||
| ``` | ||||
|  | ||||
| notifications/spec.md | ||||
| ```markdown | ||||
| ## ADDED Requirements | ||||
| ### Requirement: OTP Email Notification | ||||
| ... | ||||
| ``` | ||||
|  | ||||
| ## Best Practices | ||||
|  | ||||
| ### Simplicity First | ||||
| - Default to <100 lines of new code | ||||
| - Single-file implementations until proven insufficient | ||||
| - Avoid frameworks without clear justification | ||||
| - Choose boring, proven patterns | ||||
|  | ||||
| ### Complexity Triggers | ||||
| Only add complexity with: | ||||
| - Performance data showing current solution too slow | ||||
| - Concrete scale requirements (>1000 users, >100MB data) | ||||
| - Multiple proven use cases requiring abstraction | ||||
|  | ||||
| ### Clear References | ||||
| - Use `file.ts:42` format for code locations | ||||
| - Reference specs as `specs/auth/spec.md` | ||||
| - Link related changes and PRs | ||||
|  | ||||
| ### Capability Naming | ||||
| - Use verb-noun: `user-auth`, `payment-capture` | ||||
| - Single purpose per capability | ||||
| - 10-minute understandability rule | ||||
| - Split if description needs "AND" | ||||
|  | ||||
| ### Change ID Naming | ||||
| - Use kebab-case, short and descriptive: `add-two-factor-auth` | ||||
| - Prefer verb-led prefixes: `add-`, `update-`, `remove-`, `refactor-` | ||||
| - Ensure uniqueness; if taken, append `-2`, `-3`, etc. | ||||
|  | ||||
| ## Tool Selection Guide | ||||
|  | ||||
| | Task | Tool | Why | | ||||
| |------|------|-----| | ||||
| | Find files by pattern | Glob | Fast pattern matching | | ||||
| | Search code content | Grep | Optimized regex search | | ||||
| | Read specific files | Read | Direct file access | | ||||
| | Explore unknown scope | Task | Multi-step investigation | | ||||
|  | ||||
| ## Error Recovery | ||||
|  | ||||
| ### Change Conflicts | ||||
| 1. Run `openspec list` to see active changes | ||||
| 2. Check for overlapping specs | ||||
| 3. Coordinate with change owners | ||||
| 4. Consider combining proposals | ||||
|  | ||||
| ### Validation Failures | ||||
| 1. Run with `--strict` flag | ||||
| 2. Check JSON output for details | ||||
| 3. Verify spec file format | ||||
| 4. Ensure scenarios properly formatted | ||||
|  | ||||
| ### Missing Context | ||||
| 1. Read project.md first | ||||
| 2. Check related specs | ||||
| 3. Review recent archives | ||||
| 4. Ask for clarification | ||||
|  | ||||
| ## Quick Reference | ||||
|  | ||||
| ### Stage Indicators | ||||
| - `changes/` - Proposed, not yet built | ||||
| - `specs/` - Built and deployed | ||||
| - `archive/` - Completed changes | ||||
|  | ||||
| ### File Purposes | ||||
| - `proposal.md` - Why and what | ||||
| - `tasks.md` - Implementation steps | ||||
| - `design.md` - Technical decisions | ||||
| - `spec.md` - Requirements and behavior | ||||
|  | ||||
| ### CLI Essentials | ||||
| ```bash | ||||
| openspec list              # What's in progress? | ||||
| openspec show [item]       # View details | ||||
| openspec validate --strict # Is it correct? | ||||
| openspec archive <change-id> [--yes|-y]  # Mark complete (add --yes for automation) | ||||
| ``` | ||||
|  | ||||
| Remember: Specs are truth. Changes are proposals. Keep them in sync. | ||||
| @@ -0,0 +1,81 @@ | ||||
| ## Context | ||||
|  | ||||
| 当前 API 服务基于 Gin 构建。本次任务的目标是将其完整迁移到 Echo 框架,同时保持功能和接口的完全向后兼容。这包括路由、请求处理、中间件、Swagger 文档和 pprof 分析工具。 | ||||
|  | ||||
| ## Goals / Non-Goals | ||||
|  | ||||
| - **Goals**: | ||||
|     - 成功将 Web 框架从 Gin 迁移到 Echo v4。 | ||||
|     - 保持所有现有 API 端点的路径、方法和行为不变。 | ||||
|     - 确保所有自定义中间件(认证、审计日志)功能正常。 | ||||
|     - 确保 Swagger UI 可以在 `/swagger/index.html` 正常访问。 | ||||
|     - 确保 pprof 调试端点在 `/debug/pprof/*` 路径下正常工作。 | ||||
| - **Non-Goals**: | ||||
|     - 增加任何新的 API 端点或功能。 | ||||
|     - 修改任何现有的 API 请求/响应模型。 | ||||
|     - 在本次变更中引入新的业务逻辑。 | ||||
|  | ||||
| ## Decisions | ||||
|  | ||||
| 以下是从 Gin 到 Echo 的关键组件映射决策: | ||||
|   | ||||
| 1.  **框架实例**: | ||||
|     - **From**: `gin.SetMode(cfg.Mode)`, `engine := gin.New()`, `engine.Use(gin.Recovery())` | ||||
|     - **To**: `e := echo.New()`, `e.Debug = (cfg.Mode == "debug")`, `e.Use(middleware.Recover())` | ||||
|     - **Rationale**: `echo.New()` 提供了干净的实例。Echo 的 `Debug` 属性控制调试模式,可以根据配置设置。Echo 提供了内置的 `middleware.Recover()` 来替代 Gin 的 Recovery 中间件。 | ||||
|  | ||||
| 2.  **上下文对象 (Context) 与处理器签名**: | ||||
|     - **From**: `func(c *gin.Context)` | ||||
|     - **To**: `func(c echo.Context) error` | ||||
|     - **Rationale**: 这是两个框架的核心区别。所有控制器处理函数签名都需要更新。常见方法映射如下: | ||||
|         - `ctx.ShouldBindJSON(&req)` -> `c.Bind(&req)` (Echo 的 `Bind` 更通用) | ||||
|         - `ctx.Param("id")` -> `c.Param("id")` | ||||
|         - `ctx.GetHeader("Authorization")` -> `c.Request().Header.Get("Authorization")` | ||||
|         - `ctx.Set/Get("key", value)` -> `c.Set/Get("key")` | ||||
|         - `ctx.ClientIP()` -> `c.RealIP()` | ||||
|         - `controller.SendResponse(ctx, ...)` -> `return controller.SendResponse(c, ...)` | ||||
|         - `ctx.AbortWithStatusJSON(...)` -> 对于需要返回特定HTTP状态码的场景(如认证中间件),将使用一个专门的辅助函数 `return controller.SendErrorWithStatus(c, http.StatusUnauthorized, ...)`。 | ||||
|  | ||||
| 3.  **中间件 (Middleware)**: | ||||
|     - **From**: `func AuthMiddleware(...) gin.HandlerFunc { return func(c *gin.Context) { ... } }` | ||||
|     - **To**: `func AuthMiddleware(...) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { ...; return next(c) } } }` | ||||
|     - **Rationale**: Echo 的中间件是一个包装器模式。我们需要将现有的 `AuthMiddleware` 和 `AuditLogMiddleware` 逻辑迁移到这个新的结构中。 | ||||
|  | ||||
| 4.  **Swagger 集成**: | ||||
|     - **From**: `github.com/swaggo/gin-swagger` | ||||
|     - **To**: `github.com/swaggo/echo-swagger` | ||||
|     - **Rationale**: 这是 `swaggo` 官方为 Echo 提供的适配库,可以无缝替换。 | ||||
|  | ||||
| 5.  **Pprof 与其他 `net/http` 处理器集成**: | ||||
|     - **From**: `gin.WrapH` 和 `gin.WrapF` | ||||
|     - **To**: `echo.WrapHandler` 和 `echo.WrapFunc` | ||||
|     - **Rationale**: Echo 提供了类似的 `net/http` 处理器包装函数。 | ||||
|  | ||||
| 6.  **控制器辅助函数与审计逻辑重构**: | ||||
|     - **Affected Files**: `response.go`, `auth_utils.go`, `controller_helpers.go` | ||||
|     - **Change**: | ||||
|         - 所有辅助函数中的 `*gin.Context` 都将替换为 `echo.Context`。 | ||||
|         - **`response.go` 将被重构**:`setAuditDetails` 函数将成为设置所有审计信息(包括操作状态和失败详情)的唯一入口。`SendSuccessWithAudit` 和 `SendErrorWithAudit` 会调用它来将最终结果存入 `echo.Context`。 | ||||
|         - `controller_helpers.go` 中的泛型辅助函数将修改为返回 `error`,以适配 Echo 的错误处理链。 | ||||
|     - **Rationale**: 这种重构使得审计逻辑更加清晰和内聚,避免了在中间件中进行复杂的响应体捕获。 | ||||
|  | ||||
| 7.  **DTO 注解 (Annotations)**: | ||||
|     - **From**: Gin 相关的注解,主要包括 `binding:"..."` 和 `form:"..."`。 | ||||
|     - **To**: Echo 兼容的注解,主要包括 `validate:"..."` 和 `query:"..."`。 | ||||
|     - **Rationale**: Gin 使用 `binding` 标签进行请求参数绑定和验证,`form` 标签用于表单或查询参数绑定。Echo 框架通常结合 `go-playground/validator` 库进行验证,其对应的标签为 `validate`。对于查询参数,Echo 默认使用 `query` 标签。 | ||||
|     - **通用修改规则**: | ||||
|         - `json:"..."` 标签保持不变。 | ||||
|         - `example:"..."` 标签保持不变。 | ||||
|         - 将 `binding:"required"` 替换为 `validate:"required"`。 | ||||
|         - 将 `form:"field,default=value"` 替换为 `query:"field"`。`default` 行为需在代码中手动实现(如在 DTO 构造函数中设置默认值),标签中不再需要。 | ||||
|         - 将 `form:"field"` 替换为 `query:"field"`。 | ||||
|         - 对于 `json:"...,omitempty"` 的字段,在 `validate` 标签中也添加 `omitempty`。 | ||||
|         - 对于结构体切片或数组字段,在 `validate` 标签中添加 `dive` 以递归验证切片元素。 | ||||
|         - 根据字段的业务含义,添加更具体的 `validate` 规则(例如 `min=0`, `cron` 等)。 | ||||
|  | ||||
| ## Risks / Trade-offs | ||||
|  | ||||
| - **Risk**: 迁移工作量大,可能遗漏某些 Gin 特有的功能或上下文用法,导致运行时错误。 | ||||
| - **Mitigation**: 采用逐个文件、逐个控制器修改的方式,每修改完一部分就进行编译检查。在完成所有编码后,进行全面的手动 API 测试。 | ||||
| - **Risk (Resolved)**: `AuditLogMiddleware` 中间件最初的设计依赖于捕获响应体,这在 Echo 中难以实现。 | ||||
| - **Resolution**: 我们通过重构 `response.go` 解决了这个问题。现在,控制器在调用响应函数时,会将最终的操作状态(成功/失败)和结果详情直接存入 `echo.Context`。`AuditLogMiddleware` 只需从上下文中读取这些信息即可,**完全消除了捕获和解析响应体的需要**,使得设计更加清晰和高效。 | ||||
| @@ -0,0 +1,26 @@ | ||||
| ## Why | ||||
|  | ||||
| 本项目当前使用 Gin 作为核心 Web 框架。Gin 的路由系统存在一些限制,例如无法优雅地支持类似 `/:id/action` 和 `/:other_id/other-action` 这种在同一层级使用不同动态参数的路由模式。为了解决此问题并利用更现代、灵活的路由和中间件系统,我们计划将框架迁移到 Echo (v4)。本次变更仅进行框架替换,暂不修改现有路由结构。 | ||||
|  | ||||
| ## What Changes | ||||
|  | ||||
| - **核心框架替换**: 将 `github.com/gin-gonic/gin` 的所有引用替换为 `github.com/labstack/echo/v4`。 | ||||
| - **API 路由重写**: 更新 `internal/app/api/router.go` 以使用 Echo 的路由注册方式。 | ||||
| - **上下文对象适配**: 在所有 Controller 和 Middleware 中,将 `*gin.Context` 替换为 `echo.Context`,并调整相关方法调用。 | ||||
| - **中间件迁移**: 将现有的 Gin 中间件 (`AuthMiddleware`, `AuditLogMiddleware`) 适配为 Echo 的中间件格式。 | ||||
| - **Swagger 文档适配**: 将 `gin-swagger` 替换为 Echo 兼容的 `echo-swagger`,确保 API 文档能够正常生成和访问。 | ||||
| - **Pprof 路由适配**: 确保性能分析工具 pprof 的路由在 Echo 框架下正常工作。 | ||||
|  | ||||
| **BREAKING**: 这是一项纯粹的技术栈重构,**不应该**对外部 API 消费者产生任何破坏性影响。所有 API 端点、请求/响应格式将保持完全兼容。 | ||||
|  | ||||
| ## Impact | ||||
|  | ||||
| - **Affected specs**: 无。此变更是技术实现层面的重构,不改变任何已定义的功能规约。 | ||||
| - **Affected code**: | ||||
|     - `go.mod` / `go.sum`: 依赖项变更。 | ||||
|     - `config.yml` / `config.example.yml`: 更新 `mode` 配置项的注释。 | ||||
|     - `internal/app/api/api.go` | ||||
|     - `internal/app/api/router.go` | ||||
|     - `internal/app/middleware/auth.go` | ||||
|     - `internal/app/middleware/audit.go` | ||||
|     - `internal/app/controller/**/*.go`: 所有控制器及其辅助函数。 | ||||
| @@ -0,0 +1,17 @@ | ||||
| # HTTP Server Specification | ||||
|  | ||||
| 本文档概述了 HTTP 服务器的需求。 | ||||
|  | ||||
| ## MODIFIED Requirements | ||||
|  | ||||
| ### Requirement: API 服务器框架已更新 | ||||
|  | ||||
| - **说明**: 底层 Web 框架从 Gin 迁移到 Echo。所有现有的 API 端点 **MUST** 保持功能齐全和向后兼容。 | ||||
| - **理由**: 为了提高路由灵活性并使技术栈现代化。这是一次技术重构,不会改变任何外部 API 行为。 | ||||
| - **影响**: 高。影响核心请求处理、路由和中间件。 | ||||
| - **受影响的端点**: 全部。 | ||||
|  | ||||
| #### Scenario: 所有现有的 API 端点保持功能齐全和向后兼容 | ||||
| - **假如**: API 服务器在迁移到 Echo 后正在运行。 | ||||
| - **当**: 客户端向任何现有的 API 端点(例如, `POST /api/v1/users/login`)发送请求。 | ||||
| - **那么**: 服务器处理该请求并返回与使用 Gin 框架时完全相同的响应(状态码、头部和正文格式)。 | ||||
| @@ -0,0 +1,355 @@ | ||||
| ## 任务清单:Gin 到 Echo 迁移 | ||||
|  | ||||
| - [x] **1. 配置文件 (无代码依赖)** | ||||
|     - [x] 修改 `config.yml` 中 `mode` 配置项的注释,将 "Gin 运行模式" 改为 "服务运行模式"。 | ||||
|     - [x] 修改 `config.example.yml` 中 `mode` 配置项的注释,保持与 `config.yml` 一致。 | ||||
|  | ||||
| - [x] **2. 控制器辅助函数 (最基础的依赖)** | ||||
|     - [x] **`internal/infra/models/execution.go`** | ||||
|         - [x] 添加 `ContextAuditStatus` 和 `ContextAuditResultDetails` 常量。 | ||||
|     - [x] **`internal/app/controller/response.go`** | ||||
|         - [x] 将 `*gin.Context` 参数全部替换为 `echo.Context`。 | ||||
|         - [x] 修改响应函数,使其返回 `error`。 | ||||
|         - [x] **新增 `SendErrorWithStatus` 函数**,用于在中间件等场景下发送带有特定HTTP状态码的错误响应。 | ||||
|         - [x] **重构 `setAuditDetails` 函数**,使其成为统一设置所有审计信息(包括操作状态和失败详情)的唯一入口。 | ||||
|         - [x] 更新 `SendSuccessWithAudit` 和 `SendErrorWithAudit` 以调用重构后的 `setAuditDetails`。 | ||||
|     - [x] **`internal/app/controller/auth_utils.go`** | ||||
|         - [x] 将 `*gin.Context` 参数全部替换为 `echo.Context`。 | ||||
|         - [x] 适配 `Get...FromContext` 系列函数,使用 `c.Get("key")` 提取数据。 | ||||
|  | ||||
| - [x] **3. 中间件 (`internal/app/middleware`)** | ||||
|     - [x] **`auth.go`** | ||||
|         - [x] 迁移到 Echo 中间件格式。 | ||||
|         - [x] **使用 `controller.SendErrorWithStatus`** 在认证失败时返回 `401` 或 `500` HTTP状态码。 | ||||
|     - [x] **`audit.go`** | ||||
|         - [x] **极大简化并迁移到 Echo 中间件格式**。 | ||||
|         - [x] **移除所有响应体捕获和解析的逻辑** (`bodyLogWriter`, `auditResponse` 等)。 | ||||
|         - [x] 在 `next(c)` 调用后,**直接从 `echo.Context` 中获取**由 `response.go` 设置好的最终审计状态和结果详情。 | ||||
|  | ||||
| - [x] **4. 控制器 (`internal/app/controller/...`)** | ||||
|     - [x] **通用修改**:对所有控制器文件执行以下操作: | ||||
|         - [x] 将 `import "github.com/gin-gonic/gin"` 替换为 `import "github.com/labstack/echo/v4"`。 | ||||
|         - [x] 将所有处理函数签名从 `func(c *gin.Context)` 修改为 `func(c echo.Context) error`。 | ||||
|         - [x] 将 `c.ShouldBindJSON(&req)` 或 `c.ShouldBindQuery(&req)` 替换为 | ||||
|           `if err := c.Bind(&req); err != nil { ... }`。 | ||||
|         - [x] 将 `c.Param("id")` 替换为 `c.Param("id")` (用法相同,检查返回值即可)。 | ||||
|         - [x] 将 `controller.SendResponse(c, ...)` 和 `controller.SendErrorResponse(c, ...)` 调用修改为 | ||||
|           `return controller.SendResponse(c, ...)` 和 `return controller.SendErrorResponse(c, ...)`。 | ||||
|     - [x] **文件清单** (按依赖顺序建议): | ||||
|         - [x] `internal/app/controller/management/controller_helpers.go` (注意:其中的泛型辅助函数也需要修改为返回 | ||||
|           `error`) | ||||
|         - [x] `internal/app/controller/device/device_controller.go` | ||||
|         - [x] `internal/app/controller/management/pig_farm_controller.go` | ||||
|         - [x] `internal/app/controller/management/pig_batch_controller.go` | ||||
|         - [x] `internal/app/controller/management/pig_batch_health_controller.go` | ||||
|         - [x] `internal/app/controller/management/pig_batch_trade_controller.go` | ||||
|         - [x] `internal/app/controller/management/pig_batch_transfer_controller.go` | ||||
|         - [x] `internal/app/controller/monitor/monitor_controller.go` | ||||
|         - [x] `internal/app/controller/plan/plan_controller.go` | ||||
|         - [x] `internal/app/controller/user/user_controller.go` | ||||
|  | ||||
| - [x] **5. DTO 结构体注解** | ||||
|     - [x] **通用修改规则**: | ||||
|         - [x] `json:"..."` 标签保持不变。 | ||||
|         - [x] `example:"..."` 标签保持不变。 | ||||
|         - [x] 将 `binding:"required"` 替换为 `validate:"required"`。 | ||||
|         - [x] 将 `form:"field,default=value"` 替换为 `query:"field"`。`default` 行为需在代码中手动实现(如在 DTO 构造函数中设置默认值),标签中不再需要。 | ||||
|         - [x] 将 `form:"field"` 替换为 `query:"field"`。 | ||||
|         - [x] 对于 `json:"...,omitempty"` 的字段,在 `validate` 标签中也添加 `omitempty`。 | ||||
|         - [x] 对于结构体切片或数组字段,在 `validate` 标签中添加 `dive` 以递归验证切片元素。 | ||||
|         - [x] 根据字段的业务含义,添加更具体的 `validate` 规则(例如 `min=0`, `cron` 等)。 | ||||
|  | ||||
|     - [x] **文件清单** (按 `internal/app/dto` 目录下的文件顺序): | ||||
|         - [x] `internal/app/dto/plan_dto.go` | ||||
|             - [x] `ListPlansQuery.PlanType`: `form:"planType,default=自定义任务"` -> `query:"planType"` | ||||
|             - [x] `ListPlansQuery.Page`: `form:"page,default=1"` -> `query:"page"` | ||||
|             - [x] `ListPlansQuery.PageSize`: `form:"pageSize,default=10"` -> `query:"pageSize"` | ||||
|             - [x] `CreatePlanRequest.Name`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `CreatePlanRequest.ExecutionType`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `CreatePlanRequest.ExecuteNum`: 添加 `validate:"omitempty,min=0"` | ||||
|             - [x] `CreatePlanRequest.CronExpression`: 添加 `validate:"omitempty,cron"` | ||||
|             - [x] `CreatePlanRequest.SubPlanIDs`: 添加 `validate:"omitempty,dive"` | ||||
|             - [x] `CreatePlanRequest.Tasks`: 添加 `validate:"omitempty,dive"` | ||||
|             - [x] `UpdatePlanRequest.ExecutionType`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `UpdatePlanRequest.ExecuteNum`: 添加 `validate:"omitempty,min=0"` | ||||
|             - [x] `UpdatePlanRequest.CronExpression`: 添加 `validate:"omitempty,cron"` | ||||
|             - [x] `UpdatePlanRequest.SubPlanIDs`: 添加 `validate:"omitempty,dive"` | ||||
|             - [x] `UpdatePlanRequest.Tasks`: 添加 `validate:"omitempty,dive"` | ||||
|         - [x] `internal/app/dto/user_dto.go` | ||||
|             - [x] `CreateUserRequest.Username`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `CreateUserRequest.Password`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `LoginRequest.Identifier`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `LoginRequest.Password`: `binding:"required"` -> `validate:"required"` | ||||
|         - [x] `internal/app/dto/device_dto.go` | ||||
|             - [x] `CreateDeviceRequest.Name`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `CreateDeviceRequest.DeviceTemplateID`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `CreateDeviceRequest.AreaControllerID`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `CreateDeviceRequest.Location`: `json:"location,omitempty"` -> `validate:"omitempty"` | ||||
|             - [x] `CreateDeviceRequest.Properties`: `json:"properties,omitempty"` -> `validate:"omitempty"` | ||||
|             - [x] `UpdateDeviceRequest.Name`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `UpdateDeviceRequest.DeviceTemplateID`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `UpdateDeviceRequest.AreaControllerID`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `UpdateDeviceRequest.Location`: `json:"location,omitempty"` -> `validate:"omitempty"` | ||||
|             - [x] `UpdateDeviceRequest.Properties`: `json:"properties,omitempty"` -> `validate:"omitempty"` | ||||
|             - [x] `CreateAreaControllerRequest.Name`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `CreateAreaControllerRequest.NetworkID`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `CreateAreaControllerRequest.Location`: `json:"location,omitempty"` -> `validate:"omitempty"` | ||||
|             - [x] `CreateAreaControllerRequest.Properties`: `json:"properties,omitempty"` -> `validate:"omitempty"` | ||||
|             - [x] `UpdateAreaControllerRequest.Name`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `UpdateAreaControllerRequest.NetworkID`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `UpdateAreaControllerRequest.Location`: `json:"location,omitempty"` -> `validate:"omitempty"` | ||||
|             - [x] `UpdateAreaControllerRequest.Properties`: `json:"properties,omitempty"` -> `validate:"omitempty"` | ||||
|             - [x] `CreateDeviceTemplateRequest.Name`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `CreateDeviceTemplateRequest.Manufacturer`: `json:"manufacturer,omitempty"` -> `validate:"omitempty"` | ||||
|             - [x] `CreateDeviceTemplateRequest.Description`: `json:"description,omitempty"` -> `validate:"omitempty"` | ||||
|             - [x] `CreateDeviceTemplateRequest.Category`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `CreateDeviceTemplateRequest.Commands`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `CreateDeviceTemplateRequest.Values`: `json:"values,omitempty"` -> `validate:"omitempty,dive"` | ||||
|             - [x] `UpdateDeviceTemplateRequest.Name`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `UpdateDeviceTemplateRequest.Manufacturer`: `json:"manufacturer,omitempty"` -> `validate:"omitempty"` | ||||
|             - [x] `UpdateDeviceTemplateRequest.Description`: `json:"description,omitempty"` -> `validate:"omitempty"` | ||||
|             - [x] `UpdateDeviceTemplateRequest.Category`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `UpdateDeviceTemplateRequest.Commands`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `UpdateDeviceTemplateRequest.Values`: `json:"values,omitempty"` -> `validate:"omitempty,dive"` | ||||
|         - [x] `internal/app/dto/monitor_dto.go` | ||||
|             - [x] `ListSensorDataRequest.Page`: `form:"page,default=1"` -> `query:"page"` | ||||
|             - [x] `ListSensorDataRequest.PageSize`: `form:"pageSize,default=10"` -> `query:"pageSize"` | ||||
|             - [x] `ListSensorDataRequest.DeviceID`: `form:"device_id"` -> `query:"device_id"` | ||||
|             - [x] `ListSensorDataRequest.SensorType`: `form:"sensor_type"` -> `query:"sensor_type"` | ||||
|             - [x] `ListSensorDataRequest.StartTime`: `form:"start_time"` -> `query:"start_time"` | ||||
|             - [x] `ListSensorDataRequest.EndTime`: `form:"end_time"` -> `query:"end_time"` | ||||
|             - [x] `ListSensorDataRequest.OrderBy`: `form:"order_by"` -> `query:"order_by"` | ||||
|             - [x] `ListDeviceCommandLogRequest.Page`: `form:"page,default=1"` -> `query:"page"` | ||||
|             - [x] `ListDeviceCommandLogRequest.PageSize`: `form:"pageSize,default=10"` -> `query:"pageSize"` | ||||
|             - [x] `ListDeviceCommandLogRequest.DeviceID`: `form:"device_id"` -> `query:"device_id"` | ||||
|             - [x] `ListDeviceCommandLogRequest.ReceivedSuccess`: `form:"received_success"` -> `query:"received_success"` | ||||
|             - [x] `ListDeviceCommandLogRequest.StartTime`: `form:"start_time"` -> `query:"start_time"` | ||||
|             - [x] `ListDeviceCommandLogRequest.EndTime`: `form:"end_time"` -> `query:"end_time"` | ||||
|             - [x] `ListDeviceCommandLogRequest.OrderBy`: `form:"order_by"` -> `query:"order_by"` | ||||
|             - [x] `ListPlanExecutionLogRequest.Page`: `form:"page,default=1"` -> `query:"page"` | ||||
|             - [x] `ListPlanExecutionLogRequest.PageSize`: `form:"pageSize,default=10"` -> `query:"pageSize"` | ||||
|             - [x] `ListPlanExecutionLogRequest.PlanID`: `form:"plan_id"` -> `query:"plan_id"` | ||||
|             - [x] `ListPlanExecutionLogRequest.Status`: `form:"status"` -> `query:"status"` | ||||
|             - [x] `ListPlanExecutionLogRequest.StartTime`: `form:"start_time"` -> `query:"start_time"` | ||||
|             - [x] `ListPlanExecutionLogRequest.EndTime`: `form:"end_time"` -> `query:"end_time"` | ||||
|             - [x] `ListPlanExecutionLogRequest.OrderBy`: `form:"order_by"` -> `query:"order_by"` | ||||
|             - [x] `ListTaskExecutionLogRequest.Page`: `form:"page,default=1"` -> `query:"page"` | ||||
|             - [x] `ListTaskExecutionLogRequest.PageSize`: `form:"pageSize,default=10"` -> `query:"pageSize"` | ||||
|             - [x] `ListTaskExecutionLogRequest.PlanExecutionLogID`: `form:"plan_execution_log_id"` -> `query:"plan_execution_log_id"` | ||||
|             - [x] `ListTaskExecutionLogRequest.TaskID`: `form:"task_id"` -> `query:"task_id"` | ||||
|             - [x] `ListTaskExecutionLogRequest.Status`: `form:"status"` -> `query:"status"` | ||||
|             - [x] `ListTaskExecutionLogRequest.StartTime`: `form:"start_time"` -> `query:"start_time"` | ||||
|             - [x] `ListTaskExecutionLogRequest.EndTime`: `form:"end_time"` -> `query:"end_time"` | ||||
|             - [x] `ListTaskExecutionLogRequest.OrderBy`: `form:"order_by"` -> `query:"order_by"` | ||||
|             - [x] `ListPendingCollectionRequest.Page`: `form:"page,default=1"` -> `query:"page"` | ||||
|             - [x] `ListPendingCollectionRequest.PageSize`: `form:"pageSize,default=10"` -> `query:"pageSize"` | ||||
|             - [x] `ListPendingCollectionRequest.DeviceID`: `form:"device_id"` -> `query:"device_id"` | ||||
|             - [x] `ListPendingCollectionRequest.Status`: `form:"status"` -> `query:"status"` | ||||
|             - [x] `ListPendingCollectionRequest.StartTime`: `form:"start_time"` -> `query:"start_time"` | ||||
|             - [x] `ListPendingCollectionRequest.EndTime`: `form:"end_time"` -> `query:"end_time"` | ||||
|             - [x] `ListPendingCollectionRequest.OrderBy`: `form:"order_by"` -> `query:"order_by"` | ||||
|             - [x] `ListUserActionLogRequest.Page`: `form:"page,default=1"` -> `query:"page"` | ||||
|             - [x] `ListUserActionLogRequest.PageSize`: `form:"pageSize,default=10"` -> `query:"pageSize"` | ||||
|             - [x] `ListUserActionLogRequest.UserID`: `form:"user_id"` -> `query:"user_id"` | ||||
|             - [x] `ListUserActionLogRequest.Username`: `form:"username"` -> `query:"username"` | ||||
|             - [x] `ListUserActionLogRequest.ActionType`: `form:"action_type"` -> `query:"action_type"` | ||||
|             - [x] `ListUserActionLogRequest.Status`: `form:"status"` -> `query:"status"` | ||||
|             - [x] `ListUserActionLogRequest.StartTime`: `form:"start_time"` -> `query:"start_time"` | ||||
|             - [x] `ListUserActionLogRequest.EndTime`: `form:"end_time"` -> `query:"end_time"` | ||||
|             - [x] `ListUserActionLogRequest.OrderBy`: `form:"order_by"` -> `query:"order_by"` | ||||
|             - [x] `ListRawMaterialPurchaseRequest.Page`: `form:"page,default=1"` -> `query:"page"` | ||||
|             - [x] `ListRawMaterialPurchaseRequest.PageSize`: `form:"pageSize,default=10"` -> `query:"pageSize"` | ||||
|             - [x] `ListRawMaterialPurchaseRequest.RawMaterialID`: `form:"raw_material_id"` -> `query:"raw_material_id"` | ||||
|             - [x] `ListRawMaterialPurchaseRequest.Supplier`: `form:"supplier"` -> `query:"supplier"` | ||||
|             - [x] `ListRawMaterialPurchaseRequest.StartTime`: `form:"start_time"` -> `query:"start_time"` | ||||
|             - [x] `ListRawMaterialPurchaseRequest.EndTime`: `form:"end_time"` -> `query:"end_time"` | ||||
|             - [x] `ListRawMaterialPurchaseRequest.OrderBy`: `form:"order_by"` -> `query:"order_by"` | ||||
|             - [x] `ListRawMaterialStockLogRequest.Page`: `form:"page,default=1"` -> `query:"page"` | ||||
|             - [x] `ListRawMaterialStockLogRequest.PageSize`: `form:"pageSize,default=10"` -> `query:"pageSize"` | ||||
|             - [x] `ListRawMaterialStockLogRequest.RawMaterialID`: `form:"raw_material_id"` -> `query:"raw_material_id"` | ||||
|             - [x] `ListRawMaterialStockLogRequest.SourceType`: `form:"source_type"` -> `query:"source_type"` | ||||
|             - [x] `ListRawMaterialStockLogRequest.SourceID`: `form:"source_id"` -> `query:"source_id"` | ||||
|             - [x] `ListRawMaterialStockLogRequest.StartTime`: `form:"start_time"` -> `query:"start_time"` | ||||
|             - [x] `ListRawMaterialStockLogRequest.EndTime`: `form:"end_time"` -> `query:"end_time"` | ||||
|             - [x] `ListRawMaterialStockLogRequest.OrderBy`: `form:"order_by"` -> `query:"order_by"` | ||||
|             - [x] `ListFeedUsageRecordRequest.Page`: `form:"page,default=1"` -> `query:"page"` | ||||
|             - [x] `ListFeedUsageRecordRequest.PageSize`: `form:"pageSize,default=10"` -> `query:"pageSize"` | ||||
|             - [x] `ListFeedUsageRecordRequest.PenID`: `form:"pen_id"` -> `query:"pen_id"` | ||||
|             - [x] `ListFeedUsageRecordRequest.FeedFormulaID`: `form:"feed_formula_id"` -> `query:"feed_formula_id"` | ||||
|             - [x] `ListFeedUsageRecordRequest.OperatorID`: `form:"operator_id"` -> `query:"operator_id"` | ||||
|             - [x] `ListFeedUsageRecordRequest.StartTime`: `form:"start_time"` -> `query:"start_time"` | ||||
|             - [x] `ListFeedUsageRecordRequest.EndTime`: `form:"end_time"` -> `query:"end_time"` | ||||
|             - [x] `ListFeedUsageRecordRequest.OrderBy`: `form:"order_by"` -> `query:"order_by"` | ||||
|             - [x] `ListMedicationLogRequest.Page`: `form:"page,default=1"` -> `query:"page"` | ||||
|             - [x] `ListMedicationLogRequest.PageSize`: `form:"pageSize,default=10"` -> `query:"pageSize"` | ||||
|             - [x] `ListMedicationLogRequest.PigBatchID`: `form:"pig_batch_id"` -> `query:"pig_batch_id"` | ||||
|             - [x] `ListMedicationLogRequest.MedicationID`: `form:"medication_id"` -> `query:"medication_id"` | ||||
|             - [x] `ListMedicationLogRequest.Reason`: `form:"reason"` -> `query:"reason"` | ||||
|             - [x] `ListMedicationLogRequest.OperatorID`: `form:"operator_id"` -> `query:"operator_id"` | ||||
|             - [x] `ListMedicationLogRequest.StartTime`: `form:"start_time"` -> `query:"start_time"` | ||||
|             - [x] `ListMedicationLogRequest.EndTime`: `form:"end_time"` -> `query:"end_time"` | ||||
|             - [x] `ListMedicationLogRequest.OrderBy`: `form:"order_by"` -> `query:"order_by"` | ||||
|             - [x] `ListPigBatchLogRequest.Page`: `form:"page,default=1"` -> `query:"page"` | ||||
|             - [x] `ListPigBatchLogRequest.PageSize`: `form:"pageSize,default=10"` -> `query:"pageSize"` | ||||
|             - [x] `ListPigBatchLogRequest.PigBatchID`: `form:"pig_batch_id"` -> `query:"pig_batch_id"` | ||||
|             - [x] `ListPigBatchLogRequest.ChangeType`: `form:"change_type"` -> `query:"change_type"` | ||||
|             - [x] `ListPigBatchLogRequest.OperatorID`: `form:"operator_id"` -> `query:"operator_id"` | ||||
|             - [x] `ListPigBatchLogRequest.StartTime`: `form:"start_time"` -> `query:"start_time"` | ||||
|             - [x] `ListPigBatchLogRequest.EndTime`: `form:"end_time"` -> `query:"end_time"` | ||||
|             - [x] `ListPigBatchLogRequest.OrderBy`: `form:"order_by"` -> `query:"order_by"` | ||||
|             - [x] `ListWeighingBatchRequest.Page`: `form:"page,default=1"` -> `query:"page"` | ||||
|             - [x] `ListWeighingBatchRequest.PageSize`: `form:"pageSize,default=10"` -> `query:"pageSize"` | ||||
|             - [x] `ListWeighingBatchRequest.PigBatchID`: `form:"pig_batch_id"` -> `query:"pig_batch_id"` | ||||
|             - [x] `ListWeighingBatchRequest.StartTime`: `form:"start_time"` -> `query:"start_time"` | ||||
|             - [x] `ListWeighingBatchRequest.EndTime`: `form:"end_time"` -> `query:"end_time"` | ||||
|             - [x] `ListWeighingBatchRequest.OrderBy`: `form:"order_by"` -> `query:"order_by"` | ||||
|             - [x] `ListWeighingRecordRequest.Page`: `form:"page,default=1"` -> `query:"page"` | ||||
|             - [x] `ListWeighingRecordRequest.PageSize`: `form:"pageSize,default=10"` -> `query:"pageSize"` | ||||
|             - [x] `ListWeighingRecordRequest.WeighingBatchID`: `form:"weighing_batch_id"` -> `query:"weighing_batch_id"` | ||||
|             - [x] `ListWeighingRecordRequest.PenID`: `form:"pen_id"` -> `query:"pen_id"` | ||||
|             - [x] `ListWeighingRecordRequest.OperatorID`: `form:"operator_id"` -> `query:"operator_id"` | ||||
|             - [x] `ListWeighingRecordRequest.StartTime`: `form:"start_time"` -> `query:"start_time"` | ||||
|             - [x] `ListWeighingRecordRequest.EndTime`: `form:"end_time"` -> `query:"end_time"` | ||||
|             - [x] `ListWeighingRecordRequest.OrderBy`: `form:"order_by"` -> `query:"order_by"` | ||||
|             - [x] `ListPigTransferLogRequest.Page`: `form:"page,default=1"` -> `query:"page"` | ||||
|             - [x] `ListPigTransferLogRequest.PageSize`: `form:"pageSize,default=10"` -> `query:"pageSize"` | ||||
|             - [x] `ListPigTransferLogRequest.PigBatchID`: `form:"pig_batch_id"` -> `query:"pig_batch_id"` | ||||
|             - [x] `ListPigTransferLogRequest.PenID`: `form:"pen_id"` -> `query:"pen_id"` | ||||
|             - [x] `ListPigTransferLogRequest.TransferType`: `form:"transfer_type"` -> `query:"transfer_type"` | ||||
|             - [x] `ListPigTransferLogRequest.OperatorID`: `form:"operator_id"` -> `query:"operator_id"` | ||||
|             - [x] `ListPigTransferLogRequest.CorrelationID`: `form:"correlation_id"` -> `query:"correlation_id"` | ||||
|             - [x] `ListPigTransferLogRequest.StartTime`: `form:"start_time"` -> `query:"start_time"` | ||||
|             - [x] `ListPigTransferLogRequest.EndTime`: `form:"end_time"` -> `query:"end_time"` | ||||
|             - [x] `ListPigTransferLogRequest.OrderBy`: `form:"order_by"` -> `query:"order_by"` | ||||
|             - [x] `ListPigSickLogRequest.Page`: `form:"page,default=1"` -> `query:"page"` | ||||
|             - [x] `ListPigSickLogRequest.PageSize`: `form:"pageSize,default=10"` -> `query:"pageSize"` | ||||
|             - [x] `ListPigSickLogRequest.PigBatchID`: `form:"pig_batch_id"` -> `query:"pig_batch_id"` | ||||
|             - [x] `ListPigSickLogRequest.PenID`: `form:"pen_id"` -> `query:"pen_id"` | ||||
|             - [x] `ListPigSickLogRequest.Reason`: `form:"reason"` -> `query:"reason"` | ||||
|             - [x] `ListPigSickLogRequest.TreatmentLocation`: `form:"treatment_location"` -> `query:"treatment_location"` | ||||
|             - [x] `ListPigSickLogRequest.OperatorID`: `form:"operator_id"` -> `query:"operator_id"` | ||||
|             - [x] `ListPigSickLogRequest.StartTime`: `form:"start_time"` -> `query:"start_time"` | ||||
|             - [x] `ListPigSickLogRequest.EndTime`: `form:"end_time"` -> `query:"end_time"` | ||||
|             - [x] `ListPigSickLogRequest.OrderBy`: `form:"order_by"` -> `query:"order_by"` | ||||
|             - [x] `ListPigPurchaseRequest.Page`: `form:"page,default=1"` -> `query:"page"` | ||||
|             - [x] `ListPigPurchaseRequest.PageSize`: `form:"pageSize,default=10"` -> `query:"pageSize"` | ||||
|             - [x] `ListPigPurchaseRequest.PigBatchID`: `form:"pig_batch_id"` -> `query:"pig_batch_id"` | ||||
|             - [x] `ListPigPurchaseRequest.Supplier`: `form:"supplier"` -> `query:"supplier"` | ||||
|             - [x] `ListPigPurchaseRequest.OperatorID`: `form:"operator_id"` -> `query:"operator_id"` | ||||
|             - [x] `ListPigPurchaseRequest.StartTime`: `form:"start_time"` -> `query:"start_time"` | ||||
|             - [x] `ListPigPurchaseRequest.EndTime`: `form:"end_time"` -> `query:"end_time"` | ||||
|             - [x] `ListPigPurchaseRequest.OrderBy`: `form:"order_by"` -> `query:"order_by"` | ||||
|             - [x] `ListPigSaleRequest.Page`: `form:"page,default=1"` -> `query:"page"` | ||||
|             - [x] `ListPigSaleRequest.PageSize`: `form:"pageSize,default=10"` -> `query:"pageSize"` | ||||
|             - [x] `ListPigSaleRequest.PigBatchID`: `form:"pig_batch_id"` -> `query:"pig_batch_id"` | ||||
|             - [x] `ListPigSaleRequest.Buyer`: `form:"buyer"` -> `query:"buyer"` | ||||
|             - [x] `ListPigSaleRequest.OperatorID`: `form:"operator_id"` -> `query:"operator_id"` | ||||
|             - [x] `ListPigSaleRequest.StartTime`: `form:"start_time"` -> `query:"start_time"` | ||||
|             - [x] `ListPigSaleRequest.EndTime`: `form:"end_time"` -> `query:"end_time"` | ||||
|             - [x] `ListPigSaleRequest.OrderBy`: `form:"order_by"` -> `query:"order_by"` | ||||
|         - [x] `internal/app/dto/pig_farm_dto.go` | ||||
|             - [x] `CreatePigHouseRequest.Name`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `UpdatePigHouseRequest.Name`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `CreatePenRequest.PenNumber`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `CreatePenRequest.HouseID`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `CreatePenRequest.Capacity`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `UpdatePenRequest.PenNumber`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `UpdatePenRequest.HouseID`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `UpdatePenRequest.Capacity`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `UpdatePenRequest.Status`: `binding:"required,oneof=空闲 使用中 病猪栏 康复栏 清洗消毒 维修中"` -> `validate:"required,oneof=空闲 使用中 病猪栏 康复栏 清洗消毒 维修中"` | ||||
|             - [x] `UpdatePenStatusRequest.Status`: `binding:"required,oneof=空闲 使用中 病猪栏 康复栏 清洗消毒 维修中"` -> `validate:"required,oneof=空闲 使用中 病猪栏 康复栏 清洗消毒 维修中"` | ||||
|         - [x] `internal/app/dto/pig_batch_dto.go` | ||||
|             - [x] `PigBatchCreateDTO.BatchNumber`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `PigBatchCreateDTO.OriginType`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `PigBatchCreateDTO.StartDate`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `PigBatchCreateDTO.InitialCount`: `binding:"required,min=1"` -> `validate:"required,min=1"` | ||||
|             - [x] `PigBatchCreateDTO.Status`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `PigBatchQueryDTO.IsActive`: `form:"is_active"` -> `query:"is_active"` | ||||
|             - [x] `AssignEmptyPensToBatchRequest.PenIDs`: `binding:"required,min=1"` -> `validate:"required,min=1,dive"` | ||||
|             - [x] `ReclassifyPenToNewBatchRequest.ToBatchID`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `ReclassifyPenToNewBatchRequest.PenID`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `RemoveEmptyPenFromBatchRequest.PenID`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `MovePigsIntoPenRequest.ToPenID`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `MovePigsIntoPenRequest.Quantity`: `binding:"required,min=1"` -> `validate:"required,min=1"` | ||||
|             - [x] `SellPigsRequest.PenID`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `SellPigsRequest.Quantity`: `binding:"required,min=1"` -> `validate:"required,min=1"` | ||||
|             - [x] `SellPigsRequest.UnitPrice`: `binding:"required,min=0"` -> `validate:"required,min=0"` | ||||
|             - [x] `SellPigsRequest.TotalPrice`: `binding:"required,min=0"` -> `validate:"required,min=0"` | ||||
|             - [x] `SellPigsRequest.TraderName`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `SellPigsRequest.TradeDate`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `BuyPigsRequest.PenID`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `BuyPigsRequest.Quantity`: `binding:"required,min=1"` -> `validate:"required,min=1"` | ||||
|             - [x] `BuyPigsRequest.UnitPrice`: `binding:"required,min=0"` -> `validate:"required,min=0"` | ||||
|             - [x] `BuyPigsRequest.TotalPrice`: `binding:"required,min=0"` -> `validate:"required,min=0"` | ||||
|             - [x] `BuyPigsRequest.TraderName`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `BuyPigsRequest.TradeDate`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `TransferPigsAcrossBatchesRequest.DestBatchID`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `TransferPigsAcrossBatchesRequest.FromPenID`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `TransferPigsAcrossBatchesRequest.ToPenID`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `TransferPigsAcrossBatchesRequest.Quantity`: `binding:"required,min=1"` -> `validate:"required,min=1"` | ||||
|             - [x] `TransferPigsWithinBatchRequest.FromPenID`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `TransferPigsWithinBatchRequest.ToPenID`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `TransferPigsWithinBatchRequest.Quantity`: `binding:"required,min=1"` -> `validate:"required,min=1"` | ||||
|             - [x] `RecordSickPigsRequest.PenID`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `RecordSickPigsRequest.Quantity`: `binding:"required,min=1"` -> `validate:"required,min=1"` | ||||
|             - [x] `RecordSickPigsRequest.TreatmentLocation`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `RecordSickPigsRequest.HappenedAt`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `RecordSickPigRecoveryRequest.PenID`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `RecordSickPigRecoveryRequest.Quantity`: `binding:"required,min=1"` -> `validate:"required,min=1"` | ||||
|             - [x] `RecordSickPigRecoveryRequest.TreatmentLocation`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `RecordSickPigRecoveryRequest.HappenedAt`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `RecordSickPigDeathRequest.PenID`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `RecordSickPigDeathRequest.Quantity`: `binding:"required,min=1"` -> `validate:"required,min=1"` | ||||
|             - [x] `RecordSickPigDeathRequest.TreatmentLocation`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `RecordSickPigDeathRequest.HappenedAt`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `RecordSickPigCullRequest.PenID`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `RecordSickPigCullRequest.Quantity`: `binding:"required,min=1"` -> `validate:"required,min=1"` | ||||
|             - [x] `RecordSickPigCullRequest.TreatmentLocation`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `RecordSickPigCullRequest.HappenedAt`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `RecordDeathRequest.PenID`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `RecordDeathRequest.Quantity`: `binding:"required,min=1"` -> `validate:"required,min=1"` | ||||
|             - [x] `RecordDeathRequest.HappenedAt`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `RecordCullRequest.PenID`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `RecordCullRequest.Quantity`: `binding:"required,min=1"` -> `validate:"required,min=1"` | ||||
|             - [x] `RecordCullRequest.HappenedAt`: `binding:"required"` -> `validate:"required"` | ||||
|         - [x] `internal/app/dto/notification_dto.go` | ||||
|             - [x] `SendTestNotificationRequest.Type`: `binding:"required"` -> `validate:"required"` | ||||
|             - [x] `ListNotificationRequest.Page`: `form:"page,default=1"` -> `query:"page"` | ||||
|             - [x] `ListNotificationRequest.PageSize`: `form:"pageSize,default=10"` -> `query:"pageSize"` | ||||
|             - [x] `ListNotificationRequest.UserID`: `form:"user_id"` -> `query:"user_id"` | ||||
|             - [x] `ListNotificationRequest.NotifierType`: `form:"notifier_type"` -> `query:"notifier_type"` | ||||
|             - [x] `ListNotificationRequest.Status`: `form:"status"` -> `query:"status"` | ||||
|             - [x] `ListNotificationRequest.Level`: `form:"level"` -> `query:"level"` | ||||
|             - [x] `ListNotificationRequest.StartTime`: `form:"start_time"` -> `query:"start_time"` | ||||
|             - [x] `ListNotificationRequest.EndTime`: `form:"end_time"` -> `query:"end_time"` | ||||
|             - [x] `ListNotificationRequest.OrderBy`: `form:"order_by"` -> `query:"order_by"` | ||||
|         - [x] `internal/app/dto/plan_converter.go` (跳过,非 DTO 结构体) | ||||
|         - [x] `internal/app/dto/device_converter.go` (跳过,非 DTO 结构体) | ||||
|         - [x] `internal/app/dto/monitor_converter.go` (跳过,非 DTO 结构体) | ||||
|         - [x] `internal/app/dto/notification_converter.go` (跳过,非 DTO 结构体) | ||||
|  | ||||
| - [x] **6. 核心 API 层 (`internal/app/api`)** | ||||
|     - [x] **`router.go`** | ||||
|         - [x] 将所有 `router.GET`, `router.POST` 等 Gin 路由注册方法替换为 Echo 的 `e.GET`, `e.POST` 等方法。 | ||||
|         - [x] 将 Swagger 路由 `router.GET("/swagger/*", ginSwagger.WrapHandler(swaggerFiles.Handler))` 替换为 | ||||
|           `e.GET("/swagger/*", echoSwagger.WrapHandler)`。 | ||||
|         - [x] 将 pprof 路由的 `gin.WrapH` 和 `gin.WrapF` 调用替换为 `echo.WrapHandler` 和 `echo.WrapFunc`。 | ||||
|     - [x] **`api.go`** | ||||
|         - [x] 将 `engine *gin.Engine` 替换为 `engine *echo.Echo`。 | ||||
|         - [x] 更新 `NewAPI` 函数: | ||||
|             - [x] 将 `gin.SetMode(cfg.Mode)` 替换为 `e.Debug = (cfg.Mode == "debug")`。 | ||||
|             - [x] 将 `gin.New()` 替换为 `echo.New()`。 | ||||
|             - [x] 将 `engine.Use(middleware.Recover())` 替换为 `e.Use(middleware.Recover())`。 | ||||
|  | ||||
| - [x] **7. 依赖管理** | ||||
|     - [x] 在 `go.mod` 中移除 `github.com/gin-gonic/gin`。 | ||||
|     - [x] 在 `go.mod` 中移除 `github.com/swaggo/gin-swagger`。 | ||||
|     - [x] 在 `go.mod` 中添加 `github.com/labstack/echo/v4`。 | ||||
|     - [x] 在 `go.mod` 中添加 `github.com/swaggo/echo-swagger`。 | ||||
|     - [x] 执行 `go mod tidy` 清理依赖项。 | ||||
|  | ||||
| - [x] **8. 验证** | ||||
|     - [x] 运行 `go build ./...` 确保项目能够成功编译。 | ||||
|     - [x] 启动服务,手动测试所有 API 端点,验证功能是否与迁移前一致。 | ||||
|     - [x] 访问 `/swagger/index.html`,确认 Swagger UI 是否正常工作。 | ||||
|     - [x] (可选) 访问 `/debug/pprof/`,确认 pprof 路由是否正常。 | ||||
| @@ -0,0 +1,414 @@ | ||||
| # `monitor` 模块重构设计 | ||||
|  | ||||
| ## Context | ||||
|  | ||||
| 当前, `monitor` 模块的数据转换逻辑(例如, 将 `repository` 层返回的 `models` 实体转换为 `dto` 对象)主要存在于 | ||||
| `internal/app/controller/monitor/monitor_controller.go` 文件中。 | ||||
|  | ||||
| 这种设计导致了以下问题: | ||||
|  | ||||
| - **职责不清**:控制器层承担了过多的数据处理任务, 违反了“关注点分离”原则。控制器应主要负责处理 HTTP 请求、参数绑定和调用服务, | ||||
|   而非执行业务或数据转换逻辑。 | ||||
| - **代码重复**:如果未来有其他服务需要类似的数据转换, 可能会导致代码重复。 | ||||
| - **可测试性差**:由于转换逻辑与 `echo.Context` 紧密耦合, 对其进行单元测试变得更加复杂。 | ||||
|  | ||||
| ## Goals / Non-Goals | ||||
|  | ||||
| ### Goals | ||||
|  | ||||
| - **迁移数据转换逻辑**:将 `monitor` 模块中所有的数据转换逻辑从控制器层 (`monitor_controller.go`) 迁移到服务层 ( | ||||
|   `monitor_service.go`)。 | ||||
| - **统一服务层接口**:使服务层的方法直接接收请求 DTO, 并返回响应 DTO, 从而使服务本身成为一个完整的、自包含的业务逻辑单元。 | ||||
| - **简化控制器**:精简控制器中的代码, 使其只关注其核心职责:请求处理和响应发送。 | ||||
|  | ||||
| ### Non-Goals | ||||
|  | ||||
| - **不修改业务逻辑**:本次重构不涉及任何已有业务规则的变更。例如, `ListPlanExecutionLogs` 中获取关联计划信息的逻辑必须保持不变。 | ||||
| - **不改变 API 契约**:API 的请求参数和响应结构对最终用户保持不变。 | ||||
| - **不引入新的依赖**:仅在现有框架和依赖下进行代码调整。 | ||||
|  | ||||
| ## Decisions | ||||
|  | ||||
| - **决策:在服务层完成 DTO 转换** | ||||
|     - **理由**:服务层是封装业务逻辑的核心, 将数据从领域模型 (`models`) 转换为外部表示 (`dto`) | ||||
|       是业务服务的一部分。这样做可以确保任何调用该服务的客户端(无论是控制器、gRPC 服务还是其他服务)都能获得一致的、随时可用的数据结构。 | ||||
|     - **替代方案**:曾考虑在 `dto` 包中创建一个独立的转换层。但最终认为, 将转换逻辑内聚到服务层更能体现其业务属性, | ||||
|       因为服务层最清楚需要暴露哪些数据以及如何组织这些数据。 | ||||
|  | ||||
| - **决策:修改服务层接口以直接处理 DTO** | ||||
|     - **具体实现**:计划将 `MonitorService` 接口中的所有 `List...` 方法签名从 | ||||
|       `ListSomething(opts repository.ListOptions, page, pageSize int) ([]models.Something, int64, error)` 修改为 | ||||
|       `ListSomething(req *dto.ListSomethingRequest) (*dto.ListSomethingResponse, error)`。 | ||||
|     - **理由**:这种设计将极大地简化控制器与服务之间的交互。控制器将不再需要手动构建 `repository.ListOptions` | ||||
|       或在调用服务后手动组装响应 DTO。它只需传递请求 DTO, 然后直接使用服务返回的响应 DTO, 从而实现彻底的解耦。 | ||||
|  | ||||
| ## Risks / Trade-offs | ||||
|  | ||||
| - **风险:意外修改或丢失现有业务逻辑** | ||||
|     - **描述**:在移动代码的过程中, 尤其是像 `ListPlanExecutionLogs` 这样包含特定业务逻辑(获取关联 `plans`)的方法, | ||||
|       存在逻辑被无意中删除或修改的风险。 | ||||
|     - **缓解措施**: | ||||
|         1. **代码审查**:在重构前后仔细比对原有逻辑, 确保其被完整地迁移到了新的服务层方法中。 | ||||
|         2. **保留原有实现**:在新的服务层方法中, 将严格按照控制器中原有的顺序——先构建查询选项, 再调用仓库, | ||||
|            最后进行数据转换——来组织代码, 确保逻辑的等效性。 | ||||
|         3. **测试**:在完成重构后, 必须进行完整的回归测试, 确保所有受影响的 API 端点的行为与重构前完全一致。 | ||||
|  | ||||
| ## Migration Plan | ||||
|  | ||||
| 本次重构将按以下步骤进行: | ||||
|  | ||||
| 1. **修改服务层 (`internal/app/service/monitor_service.go`)** | ||||
|     - **更新接口**:修改 `MonitorService` 接口中所有 `List...` 方法的签名, 使其接收请求 DTO 并返回响应 DTO。 | ||||
|     - **实现数据转换**:在每个 `List...` 方法的实现中, 添加从请求 DTO 到 `repository.ListOptions` 的转换逻辑, 以及从业仓库返回的 | ||||
|       `models` 到响应 DTO 的转换逻辑。对于 `ListPlanExecutionLogs` 等方法, 确保原有的附加业务逻辑(如查询关联 `Plan` | ||||
|       信息)被完整保留。 | ||||
|  | ||||
| 2. **修改控制器层 (`internal/app/controller/monitor/monitor_controller.go`)** | ||||
|     - **移除转换逻辑**:删除所有手动构建 `repository.ListOptions` 和调用 `dto.NewList...Response` 的代码。 | ||||
|     - **更新服务调用**:修改对 `monitorService` 的调用, 使其传递完整的请求 DTO, 并直接处理返回的响应 DTO。 | ||||
|     - **简化日志**:调整日志记录, 以便从服务层返回的 DTO 中获取列表长度和总记录数。 | ||||
|  | ||||
| 3. **验证** | ||||
|     - 通过静态代码分析和审查, 确认代码风格和逻辑的正确性。 | ||||
|     - 进行完整的单元测试和集成测试, 以确保重构没有引入任何回归问题。 | ||||
|  | ||||
| ## Open Questions | ||||
|  | ||||
| - 暂无。 | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## `device` 模块重构设计 | ||||
|  | ||||
| ### Context | ||||
|  | ||||
| `device_controller.go` 当前直接依赖多个 `repository` 和 `domain.Service`,并在其方法内部执行了大量本应属于应用服务层的逻辑,包括: | ||||
|  | ||||
| - **直接的数据库操作**:调用 `repository` 的 `Create`, `Update`, `Delete`, `Find` 等方法。 | ||||
| - **领域模型实例化**:通过 `&models.Device{...}` 直接创建数据库模型。 | ||||
| - **内部字段序列化**:对 `Properties`, `Commands`, `Values` 等字段执行 `json.Marshal`。 | ||||
| - **业务规则验证**:调用 `model.SelfCheck()`。 | ||||
| - **复杂的错误处理**:通过 `errors.Is` 和 `strings.Contains` 解析底层数据库错误。 | ||||
| - **DTO 转换**:在方法末尾调用 `dto.New...Response`。 | ||||
|  | ||||
| 这种设计导致控制器与基础设施层和领域层紧密耦合,违反了分层架构的原则。 | ||||
|  | ||||
| ### Goals / Non-Goals | ||||
|  | ||||
| #### Goals | ||||
|  | ||||
| - **创建应用服务层**:引入一个新的 `internal/app/service/device_service.go` 来封装业务逻辑。 | ||||
| - **迁移业务逻辑**:将上述所有在控制器中识别出的业务逻辑和数据处理任务,全部迁移到新的 `DeviceService` 中。 | ||||
| - **简化控制器**:使 `device_controller.go` 只负责 HTTP 请求处理和对新 `DeviceService` 的调用。 | ||||
| - **保持领域服务纯粹**:确保 `internal/domain/device/device_service.go` 继续专注于核心领域逻辑,不与 DTO 发生耦合。 | ||||
|  | ||||
| #### Non-Goals | ||||
|  | ||||
| - **不改变领域服务**:不对 `domain.device.Service` 的接口和实现进行任何修改。 | ||||
| - **不改变 API 契约**:对外暴露的 API 接口、请求和响应格式保持不变。 | ||||
|  | ||||
| ### Decisions | ||||
|  | ||||
| - **决策:引入新的应用服务 `DeviceService`** | ||||
|     - **理由**:这是解决控制器职责过重和分层不清问题的标准做法。该服务将作为应用层门面,协调 `repository` 和 | ||||
|       `domain.Service`,并为控制器提供一个清晰、稳定的接口。 | ||||
|     - **结构**:`DeviceService` 将依赖于 `DeviceRepository`, `AreaControllerRepository`, `DeviceTemplateRepository` 和 | ||||
|       `domain.device.Service`。 | ||||
|  | ||||
| - **决策:`DeviceService` 接口全面采用 DTO** | ||||
|     - **具体实现**:接口方法将接收 `dto.Create...Request` 等请求 DTO,并返回 `*dto....Response` 响应 DTO。 | ||||
|     - **理由**:这与 `monitor` 模块的重构决策一致,可以确保应用服务层的接口统一、清晰,并与上层(控制器)和下层(领域/仓库)完全解耦。 | ||||
|  | ||||
| ### Migration Plan | ||||
|  | ||||
| 1. **创建 `internal/app/service/device_service.go` 文件** | ||||
|     - 定义 `DeviceService` 接口,为控制器中的每个处理器方法(`CreateDevice`, `UpdateDevice`, `GetDevice`, `ListDevices`, | ||||
|       `DeleteDevice`, `ManualControl` 等)创建相应的方法。 | ||||
|     - 定义 `deviceService` 结构体,并实现 `DeviceService` 接口。 | ||||
|     - **`Create/Update` 方法实现**: | ||||
|         1. 接收请求 DTO。 | ||||
|         2. 执行 `json.Marshal` 转换 `Properties` 等字段。 | ||||
|         3. 创建 `models.Xxx` 实例。 | ||||
|         4. 调用 `model.SelfCheck()`。 | ||||
|         5. 调用 `repository.Create/Update`。 | ||||
|         6. 调用 `repository.FindByID` 重新加载模型(确保关联数据完整)。 | ||||
|         7. 调用 `dto.New...Response` 将模型转换为响应 DTO 并返回。 | ||||
|     - **`Get/List` 方法实现**: | ||||
|         1. 调用 `repository.Find/List`。 | ||||
|         2. 调用 `dto.New...Response` 转换并返回。 | ||||
|     - **`Delete` 方法实现**: | ||||
|         1. 调用 `repository.Delete`。 | ||||
|         2. 捕获并转换特定的“资源被使用”错误。 | ||||
|     - **`ManualControl` 方法实现**: | ||||
|         1. 调用 `repository.FindByIDString` 加载模型。 | ||||
|         2. 实现 `action` 字符串到 `device.DeviceAction` 的映射。 | ||||
|         3. 调用 `domain.device.Service.Switch/Collect`。 | ||||
|  | ||||
| 2. **修改 `internal/app/controller/device/device_controller.go`** | ||||
|     - **更新依赖**:将 `Controller` 的依赖从多个 `repository` 和 `domain.Service` 替换为唯一的 | ||||
|       `app/service.DeviceService`。 | ||||
|     - **简化所有处理器方法**: | ||||
|         1. 移除所有业务逻辑(`json.Marshal`, `SelfCheck`, `repository` 调用, `dto` 转换等)。 | ||||
|         2. 每个方法仅保留:参数绑定、调用 `c.deviceService.Method(req)`、错误处理和成功响应。 | ||||
|  | ||||
| 3. **修改 `internal/core/component_initializers.go`** | ||||
|     - 在 `AppServices` 结构体中增加 `DeviceService service.DeviceService` 字段。 | ||||
|     - 在 `initAppServices` 函数中,调用 `service.NewDeviceService` 创建实例,并将其注入到 `AppServices` 中。 | ||||
|  | ||||
| 4. **修改 `internal/app/api/api.go`** | ||||
|     - 更新 `NewAPI` 函数的参数,使其接收新的 `app/service.DeviceService`。 | ||||
|     - 更新 `device.NewController` 的调用,将多个仓库和领域服务的依赖替换为单一的 `DeviceService` 依赖。 | ||||
|  | ||||
| ### Open Questions | ||||
|  | ||||
| - 暂无。 | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## `pig-farm` 模块重构设计 | ||||
|  | ||||
| ### Context | ||||
|  | ||||
| 与 `monitor` 模块类似, `pig_farm_controller.go` 当前包含了将 `service` 层返回的 `models.PigHouse` 和 `models.Pen` | ||||
| 实体手动转换为 `dto.PigHouseResponse` 和 `dto.PenResponse` 的逻辑。此外, | ||||
| 控制器还处理了部分本应由服务层处理的业务错误判断 (例如 `service.ErrHouseNotFound`)。 | ||||
|  | ||||
| 这种模式导致了与 `monitor` 模块相同的职责不清、代码重复和可测试性差的问题。 | ||||
|  | ||||
| ### Goals / Non-Goals | ||||
|  | ||||
| #### Goals | ||||
|  | ||||
| - **迁移数据转换逻辑**: 将 `pig-farm` 模块中所有的数据转换逻辑从控制器层 (`pig_farm_controller.go`) 迁移到服务层 ( | ||||
|   `pig_farm_service.go`)。 | ||||
| - **统一服务层接口**: 修改 `PigFarmService` 接口, 使其直接返回响应 DTO (`dto.XxxResponse`)。 | ||||
| - **简化控制器**: 精简 `PigFarmController` 中的代码, 移除所有 `models` 到 `dto` 的转换代码, 使其直接使用服务层返回的 | ||||
|   DTO。 | ||||
|  | ||||
| #### Non-Goals | ||||
|  | ||||
| - **不修改业务逻辑**: 本次重构严格保证业务逻辑不变。服务层将精确复制控制器层现有的转换逻辑, 不增加或减少任何字段。 | ||||
| - **不改变 API 契约**: API 的请求和响应对最终用户保持完全一致。 | ||||
|  | ||||
| ### Decisions | ||||
|  | ||||
| - **决策:在服务层完成 `models` 到 `dto` 的转换** | ||||
|     - **理由**: 与其他模块保持一致, 将数据转换视为服务层业务逻辑的一部分。这确保了服务接口的稳定性和调用方的便利性。 | ||||
|     - **具体实现**: `pig_farm_service.go` 中的方法在从 `repository` 获取 `models` 实体后, 将其转换为对应的 `dto` 再返回。 | ||||
|  | ||||
| ### Migration Plan | ||||
|  | ||||
| 1. **修改 `internal/app/service/pig_farm_service.go`** | ||||
|     - **更新 `PigFarmService` 接口**: | ||||
|         - `CreatePigHouse(...) (*models.PigHouse, error)` -> `CreatePigHouse(...) (*dto.PigHouseResponse, error)` | ||||
|         - `GetPigHouseByID(...) (*models.PigHouse, error)` -> `GetPigHouseByID(...) (*dto.PigHouseResponse, error)` | ||||
|         - `ListPigHouses(...) ([]models.PigHouse, error)` -> `ListPigHouses(...) ([]dto.PigHouseResponse, error)` | ||||
|         - `UpdatePigHouse(...) (*models.PigHouse, error)` -> `UpdatePigHouse(...) (*dto.PigHouseResponse, error)` | ||||
|         - `CreatePen(...) (*models.Pen, error)` -> `CreatePen(...) (*dto.PenResponse, error)` | ||||
|         - `UpdatePen(...) (*models.Pen, error)` -> `UpdatePen(...) (*dto.PenResponse, error)` | ||||
|         - `UpdatePenStatus(...) (*models.Pen, error)` -> `UpdatePenStatus(...) (*dto.PenResponse, error)` | ||||
|     - **实现数据转换**: | ||||
|         - 在上述每个方法的实现中, 在从 `repository` 获得 `models` 对象后, 添加代码将其转换为对应的 `dto.XxxResponse` 对象。 | ||||
|         - 转换逻辑将严格按照 `pig_farm_controller.go` 中现有的实现, 确保字段一一对应, 无任何增删。 | ||||
|         - 例如, 在 `UpdatePigHouse` 中: | ||||
|  | ||||
|  | ||||
| 2. **修改 `internal/app/controller/management/pig_farm_controller.go`** | ||||
|     - **移除 DTO 转换代码**: | ||||
|         - 在 `CreatePigHouse`, `GetPigHouse`, `UpdatePigHouse` 方法中, 删除手动创建 `dto.PigHouseResponse` 的代码。 | ||||
|         - 在 `ListPigHouses` 方法中, 删除用于遍历 `houses` 并创建 `[]dto.PigHouseResponse` 的 `for` 循环。 | ||||
|         - 在 `CreatePen`, `UpdatePen`, `UpdatePenStatus` 方法中, 删除手动创建 `dto.PenResponse` 的代码。 | ||||
|     - **更新服务调用**: | ||||
|         - 将服务层返回的 DTO 对象直接传递给 `controller.SendSuccessWithAudit`。 | ||||
|  | ||||
|  | ||||
| 3. **验证** | ||||
|     - 通过代码审查确认转换逻辑被精确迁移。 | ||||
|     - 运行相关测试, 并通过手动 API 测试验证端点行为与重构前完全一致。 | ||||
|  | ||||
| ### Open Questions | ||||
|  | ||||
| - 暂无。 | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## `plan` 模块重构设计 | ||||
|  | ||||
| ### Context | ||||
|  | ||||
| `plan_controller.go` 当前包含了大量的业务逻辑,这违反了控制器层应只负责请求处理和响应发送的原则。具体问题包括: | ||||
|  | ||||
| - **业务规则判断**:控制器中直接判断计划类型(如 `models.PlanTypeSystem`)、计划状态(如 `models.PlanStatusEnabled`)以及 | ||||
|   `ContentType` 的自动判断。 | ||||
| - **领域对象创建与转换**:控制器直接使用 `dto.NewPlanFromCreateRequest` 和 `dto.NewPlanFromUpdateRequest` 将请求 DTO 转换为 | ||||
|   `models.Plan`,并在响应前将 `models.Plan` 转换为 `dto.PlanResponse`。 | ||||
| - **直接调用仓库层**:控制器直接调用 `planRepo` 的 `CreatePlan`, `GetPlanByID`, `ListPlans`, `UpdatePlan`, `DeletePlan`, | ||||
|   `GetBasicPlanByID`, `UpdatePlanStatus`, `UpdateExecuteCount`, `StopPlanTransactionally` 等方法。 | ||||
| - **协调领域服务**:控制器直接协调 `analysisPlanTaskManager` 的 `EnsureAnalysisTaskDefinition` 和 `CreateOrUpdateTrigger` | ||||
|   方法。 | ||||
| - **错误处理**:控制器直接通过 `errors.Is(err, gorm.ErrRecordNotFound)` 判断仓库层错误,并根据错误类型返回不同的 HTTP 状态码。 | ||||
| - **执行计数器重置**:在 `UpdatePlan` 和 `StartPlan` 中,控制器直接处理 `ExecuteCount` 的重置逻辑。 | ||||
|  | ||||
| 这种设计导致控制器层职责过重,业务逻辑分散,难以维护和测试。 | ||||
|  | ||||
| ### Goals / Non-Goals | ||||
|  | ||||
| #### Goals | ||||
|  | ||||
| - **创建应用服务层**:引入一个新的 `internal/app/service/plan_service.go` 来封装 `plan` 模块的所有业务逻辑。 | ||||
| - **迁移业务逻辑**:将 `plan_controller.go` 中识别出的所有业务规则判断、领域对象创建与转换、对仓库层的直接调用、对 | ||||
|   `analysisPlanTaskManager` 的协调以及错误处理逻辑,全部迁移到新的 `PlanService` 中。 | ||||
| - **简化控制器**:使 `plan_controller.go` 只负责 HTTP 请求处理、参数绑定、调用新的 `PlanService` 方法,并处理服务层返回的 | ||||
|   DTO。 | ||||
| - **统一服务层接口**:`PlanService` 的方法将接收 DTO 作为输入,并返回 DTO 作为输出,实现服务层接口的标准化。 | ||||
|  | ||||
| #### Non-Goals | ||||
|  | ||||
| - **不修改业务逻辑**:本次重构不涉及任何已有业务规则的变更。所有业务逻辑将原封不动地从控制器迁移到服务层。 | ||||
| - **不改变 API 契约**:对外暴露的 API 接口、请求参数和响应结构对最终用户保持不变。 | ||||
| - **不改变领域服务**:不对 `internal/domain/scheduler/analysis_plan_task_manager.go` 的接口和实现进行任何修改。 | ||||
| - **不改变仓库层接口**:不对 `internal/infra/repository/plan_repository.go` 的接口进行任何修改。 | ||||
|  | ||||
| ### Decisions | ||||
|  | ||||
| - **决策:引入新的应用服务 `PlanService`** | ||||
|     - **理由**:这是解决控制器职责过重和分层不清问题的标准做法。`PlanService` 将作为应用层门面,协调 `PlanRepository` 和 | ||||
|       `AnalysisPlanTaskManager`,并为控制器提供一个清晰、稳定的接口。 | ||||
|     - **结构**:`PlanService` 将依赖于 `PlanRepository` 和 `AnalysisPlanTaskManager`。 | ||||
|  | ||||
| - **决策:`PlanService` 接口全面采用 DTO** | ||||
|     - **具体实现**:接口方法将接收 `dto.CreatePlanRequest`, `dto.UpdatePlanRequest`, `dto.ListPlansQuery` 等请求 DTO,并返回 | ||||
|       `*dto.PlanResponse`, `*dto.ListPlansResponse` 等响应 DTO。 | ||||
|     - **理由**:这与 `monitor`、`device` 和 `pig-farm` 模块的重构决策一致,可以确保应用服务层的接口统一、清晰,并与上层(控制器)和下层(领域/仓库)完全解耦。服务层内部将负责 | ||||
|       `DTO` 到 `models` 的转换以及 `models` 到 `DTO` 的转换。 | ||||
|  | ||||
| - **决策:将控制器中的业务规则判断和错误处理下沉到服务层** | ||||
|     - **理由**:控制器应专注于 HTTP 协议相关的职责。所有业务规则的判断(如计划类型、状态检查、ContentType | ||||
|       自动判断、执行计数器重置)以及对底层错误的具体判断(如 `gorm.ErrRecordNotFound` | ||||
|       )都属于业务逻辑范畴,应由服务层处理。服务层将返回更抽象的业务错误,控制器只需根据这些抽象错误进行统一的 HTTP 响应处理。 | ||||
|  | ||||
| ### Risks / Trade-offs | ||||
|  | ||||
| - **风险:意外修改或丢失现有业务逻辑** | ||||
|     - **描述**:在将控制器中分散的业务逻辑迁移到服务层时,存在逻辑被无意中删除、修改或遗漏的风险,尤其是在处理计划状态转换、执行计数器重置和 | ||||
|       `ContentType` 自动判断等复杂逻辑时。 | ||||
|     - **缓解措施**: | ||||
|         1. **逐行迁移与比对**:在迁移过程中,将控制器中的每一段业务逻辑代码逐行复制到服务层,并仔细比对,确保逻辑的等效性。 | ||||
|         2. **详细注释**:在服务层中对迁移过来的业务逻辑添加详细注释,解释其来源和作用。 | ||||
|         3. **回归测试**:在完成重构后,必须进行完整的回归测试,确保所有受影响的 API 端点的行为与重构前完全一致。 | ||||
|  | ||||
| ### Migration Plan | ||||
|  | ||||
| 1. **创建 `internal/app/service/plan_service.go` 文件**: | ||||
|     - 定义 `PlanService` 接口,包含 `CreatePlan`, `GetPlanByID`, `ListPlans`, `UpdatePlan`, `DeletePlan`, `StartPlan`, | ||||
|       `StopPlan` 等方法。 | ||||
|     - 定义 `planService` 结构体,并实现 `PlanService` 接口。 | ||||
|     - 在 `planService` 的实现中,将 `plan_controller.go` 中所有相关的业务逻辑(包括 DTO 转换、业务规则判断、对 `planRepo` 和 | ||||
|       `analysisPlanTaskManager` 的调用、错误处理)精确迁移到对应的方法中。 | ||||
|  | ||||
| 2. **修改 `internal/app/controller/plan/plan_controller.go`**: | ||||
|     - 更新 `Controller` 结构体,将 `planRepo` 和 `analysisPlanTaskManager` 替换为 `service.PlanService`。 | ||||
|     - 修改 `NewController` 函数,注入 `service.PlanService`。 | ||||
|     - 简化所有处理器方法,移除所有业务逻辑,只保留请求参数绑定、调用 `service.PlanService` 方法、错误处理和响应构建。 | ||||
|  | ||||
| 3. **修改 `internal/core/component_initializers.go`**: | ||||
|     - 在 `AppServices` 结构体中添加 `PlanService service.PlanService` 字段。 | ||||
|     - 在 `initAppServices` 函数中,初始化 `PlanService` 实例,并将其注入到 `AppServices` 中。 | ||||
|  | ||||
| 4. **修改 `internal/app/api/api.go`**: | ||||
|     - 更新 `NewAPI` 函数的参数,移除 `planRepository` 和 `analysisTaskManager`,添加 `service.PlanService`。 | ||||
|     - 更新 `plan.NewController` 的调用,传入新的 `service.PlanService` 依赖。 | ||||
|  | ||||
| ### Open Questions | ||||
|  | ||||
| - 暂无。 | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## `user` 模块重构设计 | ||||
|  | ||||
| ### Context | ||||
|  | ||||
| `user_controller.go` 当前直接依赖 `repository.UserRepository`、`token.Service` 和 `domain_notify.Service` | ||||
| ,并在其方法内部执行了大量本应属于应用服务层的逻辑,包括: | ||||
|  | ||||
| - **直接的数据库操作**:调用 `userRepo` 的 `Create`, `FindByUsername`, `FindUserForLogin` 等方法。 | ||||
| - **领域模型实例化**:通过 `&models.User{...}` 直接创建数据库模型。 | ||||
| - **业务规则验证**:例如在 `CreateUser` 中判断用户名是否重复,在 `Login` 中进行密码验证。 | ||||
| - **协调领域服务**:在 `Login` 中协调 `tokenService` 生成 JWT,在 `SendTestNotification` 中协调 `domain_notify.Service` | ||||
|   发送测试消息。 | ||||
| - **复杂的错误处理**:通过 `errors.Is` 和 `gorm.ErrRecordNotFound` 解析底层错误。 | ||||
| - **DTO 转换**:在方法末尾将 `models.User` 转换为 `dto.CreateUserResponse` 或 `dto.LoginResponse`。 | ||||
|  | ||||
| 这种设计导致控制器与基础设施层和领域层紧密耦合,违反了分层架构的原则。 | ||||
|  | ||||
| ### Goals / Non-Goals | ||||
|  | ||||
| #### Goals | ||||
|  | ||||
| - **创建应用服务层**:引入一个新的 `internal/app/service/user_service.go` 来封装业务逻辑。 | ||||
| - **迁移业务逻辑**:将上述所有在控制器中识别出的业务逻辑和数据处理任务,全部迁移到新的 `UserService` 中。 | ||||
| - **简化控制器**:使 `user_controller.go` 只负责 HTTP 请求处理和对新 `UserService` 的调用。 | ||||
| - **保持领域服务纯粹**:确保 `internal/domain/token.Service` 和 `internal/domain/notify.Service` 继续专注于核心领域逻辑,不与 | ||||
|   DTO 发生耦合。 | ||||
|  | ||||
| #### Non-Goals | ||||
|  | ||||
| - **不修改业务逻辑**:本次重构不涉及任何已有业务规则的变更。所有业务逻辑将原封不动地从控制器迁移到服务层。 | ||||
| - **不改变 API 契约**:对外暴露的 API 接口、请求和响应格式保持不变。 | ||||
| - **不改变领域服务**:不对 `domain.token.Service` 和 `domain.notify.Service` 的接口和实现进行任何修改。 | ||||
| - **不改变仓库层接口**:不对 `internal/infra/repository/user_repository.go` 的接口进行任何修改。 | ||||
| - **不涉及 `ListUserHistory` 方法**:该方法已从重构范围中移除。 | ||||
|  | ||||
| ### Decisions | ||||
|  | ||||
| - **决策:引入新的应用服务 `UserService`** | ||||
|     - **理由**:这是解决控制器职责过重和分层不清问题的标准做法。该服务将作为应用层门面,协调 `UserRepository`、 | ||||
|       `token.Service` 和 `domain_notify.Service`,并为控制器提供一个清晰、稳定的接口。 | ||||
|     - **结构**:`UserService` 将依赖于 `repository.UserRepository`, `token.Service`, `domain_notify.Service` 和 | ||||
|       `logs.Logger`。 | ||||
|  | ||||
| - **决策:`UserService` 接口全面采用 DTO** | ||||
|     - **具体实现**:接口方法将接收 `dto.CreateUserRequest`, `dto.LoginRequest`, `dto.SendTestNotificationRequest` 等请求 | ||||
|       DTO,并返回 `*dto.CreateUserResponse`, `*dto.LoginResponse` 等响应 DTO。 | ||||
|     - **理由**:这与 `monitor`、`device`、`pig-farm` 和 `plan` 模块的重构决策一致,可以确保应用服务层的接口统一、清晰,并与上层(控制器)和下层(领域/仓库)完全解耦。服务层内部将负责 | ||||
|       DTO 到 `models` 的转换以及 `models` 到 DTO 的转换。 | ||||
|  | ||||
| - **决策:将控制器中的业务规则判断和错误处理下沉到服务层** | ||||
|     - **理由**:控制器应专注于 HTTP 协议相关的职责。所有业务规则的判断(如用户名重复检查、密码验证)以及对底层错误的具体判断(如 | ||||
|       `gorm.ErrRecordNotFound`)都属于业务逻辑范畴,应由服务层处理。服务层将返回更抽象的业务错误,控制器只需根据这些抽象错误进行统一的 | ||||
|       HTTP 响应处理。 | ||||
|  | ||||
| ### Risks / Trade-offs | ||||
|  | ||||
| - **风险:意外修改或丢失现有业务逻辑** | ||||
|     - **描述**:在将控制器中分散的业务逻辑迁移到服务层时,存在逻辑被无意中删除、修改或遗漏的风险,尤其是在处理用户创建、登录和通知发送等复杂逻辑时。 | ||||
|     - **缓解措施**: | ||||
|         1. **逐行迁移与比对**:在迁移过程中,将控制器中的每一段业务逻辑代码逐行复制到服务层,并仔细比对,确保逻辑的等效性。 | ||||
|         2. **详细注释**:在服务层中对迁移过来的业务逻辑添加详细注释,解释其来源和作用。 | ||||
|         3. **回归测试**:在完成重构后,必须进行完整的回归测试,确保所有受影响的 API 端点的行为与重构前完全一致。 | ||||
|  | ||||
| ### Migration Plan | ||||
|  | ||||
| 1. **创建 `internal/app/service/user_service.go` 文件**: | ||||
|     - 定义 `UserService` 接口,包含 `CreateUser`, `Login`, `SendTestNotification` 等方法。 | ||||
|     - 定义 `userService` 结构体,并实现 `UserService` 接口。 | ||||
|     - 在 `userService` 的实现中,将 `user_controller.go` 中所有相关的业务逻辑(包括 DTO 转换、业务规则判断、对 `userRepo`、 | ||||
|       `tokenService` 和 `notifyService` 的调用、错误处理)精确迁移到对应的方法中。 | ||||
|  | ||||
| 2. **修改 `internal/app/controller/user/user_controller.go`**: | ||||
|     - 更新 `Controller` 结构体,将 `userRepo`, `tokenService`, `notifyService` 替换为 `service.UserService`。 | ||||
|     - 修改 `NewController` 函数,注入 `service.UserService`。 | ||||
|     - 简化所有处理器方法,移除所有业务逻辑,只保留请求参数绑定、调用 `service.UserService` 方法、错误处理和响应构建。 | ||||
|  | ||||
| 3. **修改 `internal/core/component_initializers.go`**: | ||||
|     - 在 `AppServices` 结构体中添加 `UserService service.UserService` 字段。 | ||||
|     - 在 `initAppServices` 函数中,初始化 `UserService` 实例,并将其注入到 `AppServices` 中。 | ||||
|  | ||||
| 4. **修改 `internal/app/api/api.go`**: | ||||
|     - 更新 `NewAPI` 函数的参数,移除 `userRepo`, `tokenService`, `notifyService`,添加 `service.UserService`。 | ||||
|     - 更新 `user.NewController` 的调用,传入新的 `service.UserService` 依赖。 | ||||
|  | ||||
| ### Open Questions | ||||
|  | ||||
| - 暂无。 | ||||
| @@ -0,0 +1,46 @@ | ||||
| ## Why | ||||
| 当前项目中,控制器层与服务层、仓库层之间存在严重的领域侵入问题。具体表现为: | ||||
| 1.  **服务层直接吐出数据库模型:** 导致控制器层直接感知并操作领域模型,增加了控制器与数据持久化细节的耦合。 | ||||
| 2.  **服务层接收数据库对象或仓库层特定结构:** 控制器层直接构建数据库模型或仓库层查询选项并传递给服务层/仓库层,使得服务层接口不够抽象,且控制器承担了不应有的数据转换职责。 | ||||
| 3.  **业务逻辑散落在控制器层:** 控制器层包含了大量的业务规则判断、领域对象的创建与验证、以及对仓库层和领域服务的直接协调,这违反了控制器层应只做数据校验、绑定解析和调用服务层方法的原则,导致业务逻辑分散、难以维护和测试。 | ||||
|     *   **控制器直接进行领域模型内部字段的序列化/反序列化:** 例如,控制器直接对 `req.Properties` 进行 `json.Marshal` 操作,将领域模型的内部结构(如 JSON 字符串存储)暴露给控制器。 | ||||
|     *   **控制器直接实例化领域模型对象:** 控制器直接通过 `&models.Xxx{...}` 实例化领域模型对象,而非通过服务层进行创建。 | ||||
|     *   **控制器通过检查底层(仓库层或服务层)的特定错误类型或错误信息来执行业务判断:** 例如,通过 `strings.Contains(err.Error(), "...")` 或 `errors.Is(err, service.ErrXxx)` 来判断具体的业务错误类型,使得控制器与底层实现细节紧密耦合。 | ||||
|  | ||||
| 这些问题导致了代码的紧密耦合、可维护性差、测试困难,并且不利于后续的业务扩展和架构演进。 | ||||
|  | ||||
| ## What Changes | ||||
| 本次重构旨在解决上述领域侵入问题,明确各层的职责,提升代码质量。主要变更包括: | ||||
| -   **服务层接口标准化:** 确保服务层方法只接收 DTO 或基本参数,并只返回 DTO 或业务领域对象(而非数据库模型)。 | ||||
| -   **控制器层职责收敛:** 控制器层将仅负责请求参数的绑定与校验、调用服务层方法,并将服务层返回的 DTO 转换为 HTTP 响应。所有业务逻辑、领域对象的创建与验证、以及与仓库层的直接交互都将从控制器层移除并下沉到服务层。 | ||||
|     *   **移除控制器中的领域模型内部字段序列化/反序列化逻辑:** 将此类操作下沉到服务层或专门的转换器中。 | ||||
|     *   **移除控制器中直接实例化领域模型对象的逻辑:** 领域模型的创建应通过服务层完成。 | ||||
|     *   **优化控制器中的业务错误处理:** 服务层将返回更抽象的业务错误,控制器层根据这些抽象错误进行统一的 HTTP 响应处理,避免直接依赖仓库层或服务层内部的具体错误类型或错误信息。 | ||||
| -   **DTO 转换逻辑下沉:** 将数据库模型与 DTO 之间的转换逻辑从控制器层移动到服务层内部或专门的转换器中。 | ||||
| -   **业务错误处理优化:** 服务层将返回更抽象的业务错误,控制器层根据这些抽象错误进行统一的 HTTP 响应处理,避免直接依赖仓库层或服务层内部的具体错误类型。 | ||||
|  | ||||
| **BREAKING**:本次变更将涉及服务层接口的修改,以及控制器层对服务层调用的调整,可能对依赖这些接口的代码造成影响。 | ||||
|  | ||||
| ## Impact | ||||
| -   **Affected specs:** | ||||
|     -   `specs/monitor/spec.md` (如果存在,需要更新服务层返回 DTO 的要求) | ||||
|     -   `specs/device/spec.md` (如果存在,需要更新服务层返回 DTO 的要求) | ||||
|     -   `specs/pig-farm/spec.md` (如果存在,需要更新服务层返回 DTO 的要求) | ||||
|     -   `specs/plan/spec.md` (如果存在,需要更新服务层返回 DTO 的要求) | ||||
|     -   `specs/user/spec.md` (如果存在,需要更新服务层返回 DTO 的要求) | ||||
| -   **Affected code:** | ||||
|     -   `internal/app/controller/monitor/monitor_controller.go` | ||||
|     -   `internal/app/controller/device/device_controller.go` | ||||
|     -   `internal/app/controller/management/pig_farm_controller.go` | ||||
|     -   `internal/app/controller/plan/plan_controller.go` | ||||
|     -   `internal/app/controller/user/user_controller.go` | ||||
|     -   `internal/app/service/monitor_service.go` (及其实现) | ||||
|     -   `internal/app/service/device_service.go` (及其实现) | ||||
|     -   `internal/app/service/pig_farm_service.go` (及其实现) | ||||
|     -   `internal/app/service/plan_service.go` (及其实现) | ||||
|     -   `internal/app/service/user_service.go` (及其实现) | ||||
|     -   `internal/infra/repository/*.go` (可能需要调整接口,以适应服务层接收 DTO 的变化) | ||||
|     -   `internal/infra/models/*.go` (可能需要添加或修改 DTO 转换方法) | ||||
|     -   `internal/app/dto/*.go` (可能需要添加新的 DTO 或修改现有 DTO 的构造函数) | ||||
|     -   `internal/core/component_initializers.go` | ||||
|     -   `internal/app/api/api.go` | ||||
| @@ -0,0 +1,70 @@ | ||||
| # 业务逻辑分层重构规范 | ||||
|  | ||||
| ## Purpose | ||||
| 本规范旨在明确业务逻辑分层重构的目标、变更内容和预期行为,以解决控制器层职责过重、代码耦合严重、可维护性差的问题。通过本次重构,我们将实现各层职责的清晰划分,提升代码质量和可测试性。 | ||||
|  | ||||
| ## ADDED Requirements | ||||
|  | ||||
| ### Requirement: 服务层接口标准化 | ||||
| - **说明**: 服务层方法现在 **MUST** 只接收数据传输对象 (DTO) 或基本参数,并 **MUST** 只返回 DTO 或业务领域对象,不再直接暴露数据库模型。 | ||||
| - **理由**: 减少服务层与持久化细节的耦合,提高接口的抽象性和稳定性。 | ||||
| - **影响**: 高。所有调用服务层的方法都需要调整。 | ||||
| - **受影响的模块**: `monitor`, `device`, `pig-farm`, `plan`, `user`。 | ||||
|  | ||||
| #### Scenario: 服务层方法接收 DTO 作为输入 | ||||
| - **假如**: `UserService` 的 `CreateUser` 方法被调用。 | ||||
| - **当**: `CreateUser` 方法接收 `dto.CreateUserRequest` 作为参数。 | ||||
| - **那么**: `UserService` 内部负责将 `dto.CreateUserRequest` 转换为 `models.User` 进行处理。 | ||||
|  | ||||
| #### Scenario: 服务层方法返回 DTO 作为输出 | ||||
| - **假如**: `UserService` 的 `CreateUser` 方法执行成功。 | ||||
| - **当**: `CreateUser` 方法返回 `*dto.CreateUserResponse`。 | ||||
| - **那么**: 调用方可以直接使用 `dto.CreateUserResponse`,无需进行额外的模型转换。 | ||||
|  | ||||
| ### Requirement: 控制器层职责收敛 | ||||
| - **说明**: 控制器层现在 **MUST** 仅负责 HTTP 请求的参数绑定与校验、调用服务层方法,并将服务层返回的 DTO 转换为 HTTP 响应。所有业务逻辑、领域对象的创建与验证、以及与仓库层的直接交互都 **MUST** 从控制器层移除并下沉到服务层。 | ||||
| - **理由**: 遵循“关注点分离”原则,使控制器层专注于 HTTP 协议处理,提高代码的可维护性和可测试性。 | ||||
| - **影响**: 高。所有控制器方法都需要大幅简化。 | ||||
| - **受影响的模块**: `monitor`, `device`, `pig-farm`, `plan`, `user`。 | ||||
|  | ||||
| #### Scenario: 控制器不再直接进行领域模型内部字段的序列化/反序列化 | ||||
| - **假如**: `DeviceController` 的 `CreateDevice` 方法被调用。 | ||||
| - **当**: `CreateDevice` 方法不再包含 `json.Marshal` 或 `json.Unmarshal` 等操作来处理 `Properties` 等字段。 | ||||
| - **那么**: 这些序列化/反序列化逻辑已下沉到 `DeviceService` 中。 | ||||
|  | ||||
| #### Scenario: 控制器不再直接实例化领域模型对象 | ||||
| - **假如**: `UserController` 的 `CreateUser` 方法被调用。 | ||||
| - **当**: `CreateUser` 方法不再包含 `&models.User{...}` 这样的代码。 | ||||
| - **那么**: 领域模型的创建已通过 `UserService` 完成。 | ||||
|  | ||||
| #### Scenario: 控制器不再直接调用仓库层方法 | ||||
| - **假如**: `PlanController` 的 `ListPlans` 方法被调用。 | ||||
| - **当**: `ListPlans` 方法不再直接调用 `planRepo.ListPlans`。 | ||||
| - **那么**: `PlanService` 负责协调 `PlanRepository`。 | ||||
|  | ||||
| #### Scenario: 控制器不再直接进行业务规则判断 | ||||
| - **假如**: `PlanController` 的 `UpdatePlan` 方法被调用。 | ||||
| - **当**: `UpdatePlan` 方法不再包含对计划类型、状态或 `ContentType` 的直接判断逻辑。 | ||||
| - **那么**: 这些业务规则判断已下沉到 `PlanService` 中。 | ||||
|  | ||||
| ### Requirement: DTO 转换逻辑下沉 | ||||
| - **说明**: 数据库模型与 DTO 之间的转换逻辑 **MUST** 从控制器层移动到服务层内部或专门的转换器中。 | ||||
| - **理由**: 确保数据转换逻辑与业务逻辑紧密结合,避免控制器层承担不必要的职责。 | ||||
| - **影响**: 中。主要影响数据流转和转换点。 | ||||
| - **受影响的模块**: `monitor`, `device`, `pig-farm`, `plan`, `user`。 | ||||
|  | ||||
| #### Scenario: 服务层负责将数据库模型转换为响应 DTO | ||||
| - **假如**: `PigFarmService` 的 `GetPigHouseByID` 方法从 `repository` 获取到 `models.PigHouse`。 | ||||
| - **当**: `GetPigHouseByID` 方法在返回前将 `models.PigHouse` 转换为 `dto.PigHouseResponse`。 | ||||
| - **那么**: 控制器直接接收 `dto.PigHouseResponse`。 | ||||
|  | ||||
| ### Requirement: 业务错误处理优化 | ||||
| - **说明**: 服务层现在 **MUST** 返回更抽象的业务错误,控制器层 **MUST** 根据这些抽象错误进行统一的 HTTP 响应处理,避免直接依赖仓库层或服务层内部的具体错误类型或错误信息。 | ||||
| - **理由**: 提高错误处理的一致性和可维护性,解耦控制器与底层错误实现。 | ||||
| - **影响**: 中。影响错误处理流程。 | ||||
| - **受影响的模块**: `monitor`, `device`, `pig-farm`, `plan`, `user`。 | ||||
|  | ||||
| #### Scenario: 服务层返回抽象业务错误 | ||||
| - **假如**: `UserService` 的 `CreateUser` 方法因用户名重复而失败。 | ||||
| - **当**: `UserService` 返回一个表示“用户名已存在”的抽象错误(例如自定义错误类型或包装后的错误)。 | ||||
| - **那么**: `UserController` 接收到此抽象错误后,可以统一转换为相应的 HTTP 状态码和错误信息,而无需解析底层 `gorm.ErrDuplicatedKey` 等具体错误。 | ||||
| @@ -0,0 +1,123 @@ | ||||
| ## 1. 准备工作 | ||||
|  | ||||
| - [x] 1.1 阅读并理解 `openspec/changes/refactor-business-logic-layering/proposal.md`。 | ||||
| - [x] 1.2 阅读并理解 `openspec/changes/refactor-business-logic-layering/design.md`。 | ||||
| - [x] 1.3 阅读并理解 'AGENTS.md' | ||||
|  | ||||
| ## 2. 统一服务层接口输入输出为 DTO | ||||
|  | ||||
| ### 2.1 `monitor` 模块 | ||||
|  | ||||
| - [x] 2.1.1 **修改 `internal/app/service/monitor_service.go`:** | ||||
|     - [x] 将所有 `List...` 方法的 `opts repository.ListOptions` 参数替换为服务层自定义的查询 DTO 或一系列基本参数。 | ||||
|     - [x] 将所有 `List...` 方法的返回值 `[]models.Xxx` 替换为 `[]dto.XxxResponse`。 | ||||
|     - [x] 调整 `List...` 方法的实现,在服务层内部将服务层查询 DTO 转换为 `repository.ListOptions`。 | ||||
|     - [x] 调整 `List...` 方法的实现,在服务层内部将 `repository` 返回的 `models` 对象转换为 `dto.XxxResponse`。 | ||||
| - [x] 2.1.2 **修改 `internal/app/controller/monitor/monitor_controller.go`:** | ||||
|     - [x] 移除控制器中构建 `repository.ListOptions` 的逻辑。 | ||||
|     - [x] 移除控制器中将 `models` 转换为 `dto.NewList...Response` 的逻辑。 | ||||
|     - [x] 移除控制器中直接使用 `models` 进行枚举类型转换的逻辑,将其下沉到服务层或 DTO 转换逻辑中。 | ||||
|     - [x] 调整服务层方法的调用,使其接收新的服务层查询 DTO 或基本参数,并直接处理服务层返回的 `dto.XxxResponse`。 | ||||
|  | ||||
| ### 2.2 `device` 模块 | ||||
|  | ||||
| - [x] 2.2.1 **创建并修改 `internal/app/service/device_service.go`:** | ||||
|     - [x] 定义 `DeviceService` 接口,包含 `CreateDevice`, `UpdateDevice`, `CreateAreaController`, `UpdateAreaController`, | ||||
|       `CreateDeviceTemplate`, `UpdateDeviceTemplate`, `GetDevice`, `ListDevices`, `GetAreaController`, | ||||
|       `ListAreaControllers`, `GetDeviceTemplate`, `ListDeviceTemplates`, `ManualControl` 等方法。 | ||||
|     - [x] 为 `CreateDevice`, `UpdateDevice`, `CreateAreaController`, `UpdateAreaController`, `CreateDeviceTemplate`, | ||||
|       `UpdateDeviceTemplate`, `ManualControl` 方法定义并接收 DTO 作为输入。 | ||||
|     - [x] 将 `GetDevice`, `ListDevices`, `GetAreaController`, `ListAreaControllers`, `GetDeviceTemplate`, | ||||
|       `ListDeviceTemplates` 方法的返回值 `models.Xxx` 或 `[]models.Xxx` 替换为 `dto.XxxResponse` 或 `[]dto.XxxResponse`。 | ||||
|     - [x] 实现 `DeviceService` 接口。 | ||||
|     - [x] 在此服务层内部将输入 DTO 转换为 `models` 对象。 | ||||
|     - [x] 在此服务层内部将 `repository` 或 `domain` 层返回的 `models` 对象转换为 `dto.XxxResponse`。 | ||||
|     - [x] 将控制器中 `SelfCheck()` 验证逻辑移入此服务层。 | ||||
|     - [x] 将控制器中 `Properties`, `Commands`, `Values` 的 JSON 序列化逻辑移入此服务层。 | ||||
|     - [x] 将控制器中 `ManualControl` 的业务逻辑(如动作映射)移入此服务层。 | ||||
|     - [x] 将控制器中直接调用 `repository` 方法的逻辑移入此服务层。 | ||||
|     - [x] 将控制器中通过检查 `repository` 错误信息处理业务规则的逻辑移入此服务层。 | ||||
|     - [x] 调整此服务层对 `internal/domain/device.Service` 的调用,确保传递的是 `models` 或领域对象,而不是 DTO。 | ||||
| - [x] 2.2.2 **修改 `internal/app/controller/device/device_controller.go`:** | ||||
|     - [x] 引入并使用新创建的 `internal/app/service.DeviceService`。 | ||||
|     - [x] 移除控制器中直接创建 `models.Device`, `models.AreaController`, `models.DeviceTemplate` 对象的逻辑。 | ||||
|     - [x] 移除控制器中直接调用 `SelfCheck()` 的逻辑。 | ||||
|     - [x] 移除控制器中直接调用 `repository` 方法的逻辑。 | ||||
|     - [x] 移除控制器中通过检查 `repository` 错误信息处理业务规则的逻辑。 | ||||
|     - [x] 移除控制器中 `Properties`, `Commands`, `Values` 的 JSON 序列化逻辑。 | ||||
|     - [x] 调整服务层方法的调用,使其接收新的服务层输入 DTO 或基本参数,并直接处理服务层返回的 `dto.XxxResponse`。 | ||||
| - [x] 2.2.3 **修改 `internal/core/component_initializers.go`**:创建并提供新的 `DeviceService`。 | ||||
| - [x] 2.2.4 **修改 `internal/app/api/api.go`**:更新 `DeviceController` 的依赖注入。 | ||||
|  | ||||
| ### 2.3 `pig-farm` 模块 | ||||
|  | ||||
| - [x] 2.3.1 **修改 `internal/app/service/pig_farm_service.go`:** | ||||
|     - [x] 将 `CreatePigHouse`, `GetPigHouseByID`, `ListPigHouses`, `UpdatePigHouse`, `CreatePen`, `GetPenByID`, | ||||
|       `ListPens`, `UpdatePen`, `UpdatePenStatus` 方法的返回值 `models.Xxx` 或 `[]models.Xxx` 替换为 `dto.XxxResponse` 或 | ||||
|       `[]dto.XxxResponse`。 | ||||
|     - [x] 在服务层内部将 `repository` 返回的 `models` 对象转换为 `dto.XxxResponse`。 | ||||
|     - [x] 将控制器中处理服务层特定业务错误(如 `service.ErrHouseNotFound`)的逻辑移入服务层,服务层应返回更抽象的错误或直接返回 | ||||
|       DTO。 | ||||
| - [x] 2.3.2 **修改 `internal/app/controller/management/pig_farm_controller.go`:** | ||||
|     - [x] 移除控制器中手动将领域实体转换为 DTO 的逻辑。 | ||||
|     - [x] 移除控制器中直接处理服务层特定业务错误类型的逻辑。 | ||||
|     - [x] 调整服务层方法的调用,使其直接处理服务层返回的 `dto.XxxResponse`。 | ||||
|  | ||||
| ### 2.4 `plan` 模块 | ||||
|  | ||||
| - [x] 2.4.1 **创建并修改 `internal/app/service/plan_service.go`:** | ||||
|     - [x] 定义 `PlanService` 接口,包含 `CreatePlan`, `GetPlanByID`, `ListPlans`, `UpdatePlan`, `DeletePlan`, | ||||
|       `StartPlan`, `StopPlan` 等方法。 | ||||
|     - [x] 为 `CreatePlan`, `UpdatePlan` 方法定义并接收 DTO 作为输入。 | ||||
|     - [x] 将 `GetPlanByID`, `ListPlans` 方法的返回值 `models.Plan` 或 `[]models.Plan` 替换为 `dto.PlanResponse` 或 | ||||
|       `[]dto.PlanResponse`。 | ||||
|     - [x] 调整 `ListPlans` 方法的 `opts repository.ListPlansOptions` 参数替换为服务层自定义的查询 DTO 或一系列基本参数。 | ||||
|     - [x] 调整 `DeletePlan`, `StartPlan`, `StopPlan` 方法,使其接收 DTO 或基本参数,并封装所有业务逻辑。 | ||||
|     - [x] 实现 `PlanService` 接口。 | ||||
|     - [x] 在服务层内部将输入 DTO 转换为 `models` 对象。 | ||||
|     - [x] 在服务层内部将 `repository` 返回的 `models` 对象转换为 `dto.XxxResponse`。 | ||||
|     - [x] 将 `internal/app/controller/plan/plan_controller.go` 中所有的业务规则判断(计划类型检查、状态检查、执行计数器重置、ContentType | ||||
|       自动判断)移入服务层。 | ||||
|     - [x] 将 `internal/app/controller/plan/plan_controller.go` 中对 `repository` 方法的直接调用移入服务层。 | ||||
|     - [x] 将 `internal/app/controller/plan/plan_controller.go` 中对 `analysisPlanTaskManager` 的协调移入服务层。 | ||||
|     - [x] 将 `internal/app/controller/plan/plan_controller.go` 中处理仓库层特有错误(`gorm.ErrRecordNotFound`)的逻辑移入服务层。 | ||||
| - [x] 2.4.2 **修改 `internal/app/controller/plan/plan_controller.go`:** | ||||
|     - [x] 引入并使用新创建的 `plan_service`。 | ||||
|     - [x] 移除控制器中直接创建 `models.Plan` 对象和 `repository.ListPlansOptions` 的逻辑。 | ||||
|     - [x] 移除控制器中所有的业务规则判断。 | ||||
|     - [x] 移除控制器中直接调用 `repository` 方法的逻辑。 | ||||
|     - [x] 移除控制器中直接协调 `analysisPlanTaskManager` 的逻辑。 | ||||
|     - [x] 移除控制器中直接处理仓库层特有错误的逻辑。 | ||||
|     - [x] 调整服务层方法的调用,使其接收新的服务层输入 DTO 或基本参数,并直接处理服务层返回的 `dto.XxxResponse`。 | ||||
| - [x] 2.4.3 **修改 `internal/core/component_initializers.go`**:创建并提供新的 `PlanService`。 | ||||
| - [x] 2.4.4 **修改 `internal/app/api/api.go`**:更新 `PlanController` 的依赖注入。 | ||||
|  | ||||
| ### 2.5 `user` 模块 | ||||
|  | ||||
| - [x] 2.5.1 **创建并修改 `internal/app/service/user_service.go`:** | ||||
|     - [x] 定义 `UserService` 接口,包含 `CreateUser`, `Login`, `SendTestNotification` 等方法。 | ||||
|     - [x] 为 `CreateUser`, `Login` 方法定义并接收 DTO 作为输入。 | ||||
|     - [x] 将 `CreateUser`, `Login` 方法的返回值 `models.User` 替换为 `dto.CreateUserResponse` 或 `dto.LoginResponse`。 | ||||
|     - [x] 调整 `SendTestNotification` 方法,使其接收 DTO 或基本参数,并封装所有业务逻辑。 | ||||
|     - [x] 实现 `UserService` 接口。 | ||||
|     - [x] 在服务层内部将输入 DTO 转换为 `models` 对象。 | ||||
|     - [x] 在服务层内部将 `repository` 返回的 `models` 对象转换为 `dto.XxxResponse`。 | ||||
|     - [x] 将 `CreateUser` 中处理用户名重复的业务逻辑从控制器移入服务层。 | ||||
|     - [x] 将 `Login` 中进行密码验证的业务逻辑和协调 `tokenService` 的逻辑从控制器移入服务层。 | ||||
|     - [x] 将 `SendTestNotification` 中调用 `domain_notify.Service` 的逻辑移入服务层。 | ||||
|     - [x] 将控制器中通过检查底层(仓库层或服务层)的特定错误类型或错误信息来执行业务判断的逻辑移入服务层。 | ||||
| - [x] 2.5.2 **修改 `internal/app/controller/user/user_controller.go`:** | ||||
|     - [x] 引入并使用新创建的 `user_service`。 | ||||
|     - [x] 移除控制器中直接创建 `models.User` 对象的逻辑。 | ||||
|     - [x] 移除控制器中处理用户名重复的业务逻辑。 | ||||
|     - [x] 移除控制器中进行密码验证的业务逻辑和协调 `tokenService` 的逻辑。 | ||||
|     - [x] 移除控制器中通过检查底层(仓库层或服务层)的特定错误类型或错误信息来执行业务判断的逻辑。 | ||||
|     - [x] 调整服务层方法的调用,使其接收新的服务层输入 DTO 或基本参数,并直接处理服务层返回的 `dto.XxxResponse`。 | ||||
| - [x] 2.5.2 **修改 `internal/core/component_initializers.go`**:创建并提供新的 `UserService`。 | ||||
| - [x] 2.5.3 **修改 `internal/app/api/api.go`**:更新 `UserController` 的依赖注入。 | ||||
|  | ||||
| ## 3. 验证与测试 | ||||
|  | ||||
| - [x] 3.1 运行所有单元测试和集成测试,确保重构没有引入新的问题。 | ||||
| - [x] 3.2 针对受影响的 API 接口进行手动测试,验证功能是否正常。 | ||||
| - [x] 3.3 确保日志输出和审计记录仍然准确无误. | ||||
							
								
								
									
										31
									
								
								openspec/project.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								openspec/project.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | ||||
| # Project Context | ||||
|  | ||||
| ## Purpose | ||||
| [Describe your project's purpose and goals] | ||||
|  | ||||
| ## Tech Stack | ||||
| - [List your primary technologies] | ||||
| - [e.g., TypeScript, React, Node.js] | ||||
|  | ||||
| ## Project Conventions | ||||
|  | ||||
| ### Code Style | ||||
| [Describe your code style preferences, formatting rules, and naming conventions] | ||||
|  | ||||
| ### Architecture Patterns | ||||
| [Document your architectural decisions and patterns] | ||||
|  | ||||
| ### Testing Strategy | ||||
| [Explain your testing approach and requirements] | ||||
|  | ||||
| ### Git Workflow | ||||
| [Describe your branching strategy and commit conventions] | ||||
|  | ||||
| ## Domain Context | ||||
| [Add domain-specific knowledge that AI assistants need to understand] | ||||
|  | ||||
| ## Important Constraints | ||||
| [List any technical, business, or regulatory constraints] | ||||
|  | ||||
| ## External Dependencies | ||||
| [Document key external services, APIs, or systems] | ||||
							
								
								
									
										69
									
								
								openspec/specs/business-logic-layering/spec.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								openspec/specs/business-logic-layering/spec.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,69 @@ | ||||
| # business-logic-layering Specification | ||||
|  | ||||
| ## Purpose | ||||
| TBD - created by archiving change refactor-business-logic-layering. Update Purpose after archive. | ||||
| ## Requirements | ||||
| ### Requirement: 服务层接口标准化 | ||||
| - **说明**: 服务层方法现在 **MUST** 只接收数据传输对象 (DTO) 或基本参数,并 **MUST** 只返回 DTO 或业务领域对象,不再直接暴露数据库模型。 | ||||
| - **理由**: 减少服务层与持久化细节的耦合,提高接口的抽象性和稳定性。 | ||||
| - **影响**: 高。所有调用服务层的方法都需要调整。 | ||||
| - **受影响的模块**: `monitor`, `device`, `pig-farm`, `plan`, `user`。 | ||||
|  | ||||
| #### Scenario: 服务层方法接收 DTO 作为输入 | ||||
| - **假如**: `UserService` 的 `CreateUser` 方法被调用。 | ||||
| - **当**: `CreateUser` 方法接收 `dto.CreateUserRequest` 作为参数。 | ||||
| - **那么**: `UserService` 内部负责将 `dto.CreateUserRequest` 转换为 `models.User` 进行处理。 | ||||
|  | ||||
| #### Scenario: 服务层方法返回 DTO 作为输出 | ||||
| - **假如**: `UserService` 的 `CreateUser` 方法执行成功。 | ||||
| - **当**: `CreateUser` 方法返回 `*dto.CreateUserResponse`。 | ||||
| - **那么**: 调用方可以直接使用 `dto.CreateUserResponse`,无需进行额外的模型转换。 | ||||
|  | ||||
| ### Requirement: 控制器层职责收敛 | ||||
| - **说明**: 控制器层现在 **MUST** 仅负责 HTTP 请求的参数绑定与校验、调用服务层方法,并将服务层返回的 DTO 转换为 HTTP 响应。所有业务逻辑、领域对象的创建与验证、以及与仓库层的直接交互都 **MUST** 从控制器层移除并下沉到服务层。 | ||||
| - **理由**: 遵循“关注点分离”原则,使控制器层专注于 HTTP 协议处理,提高代码的可维护性和可测试性。 | ||||
| - **影响**: 高。所有控制器方法都需要大幅简化。 | ||||
| - **受影响的模块**: `monitor`, `device`, `pig-farm`, `plan`, `user`。 | ||||
|  | ||||
| #### Scenario: 控制器不再直接进行领域模型内部字段的序列化/反序列化 | ||||
| - **假如**: `DeviceController` 的 `CreateDevice` 方法被调用。 | ||||
| - **当**: `CreateDevice` 方法不再包含 `json.Marshal` 或 `json.Unmarshal` 等操作来处理 `Properties` 等字段。 | ||||
| - **那么**: 这些序列化/反序列化逻辑已下沉到 `DeviceService` 中。 | ||||
|  | ||||
| #### Scenario: 控制器不再直接实例化领域模型对象 | ||||
| - **假如**: `UserController` 的 `CreateUser` 方法被调用。 | ||||
| - **当**: `CreateUser` 方法不再包含 `&models.User{...}` 这样的代码。 | ||||
| - **那么**: 领域模型的创建已通过 `UserService` 完成。 | ||||
|  | ||||
| #### Scenario: 控制器不再直接调用仓库层方法 | ||||
| - **假如**: `PlanController` 的 `ListPlans` 方法被调用。 | ||||
| - **当**: `ListPlans` 方法不再直接调用 `planRepo.ListPlans`。 | ||||
| - **那么**: `PlanService` 负责协调 `PlanRepository`。 | ||||
|  | ||||
| #### Scenario: 控制器不再直接进行业务规则判断 | ||||
| - **假如**: `PlanController` 的 `UpdatePlan` 方法被调用。 | ||||
| - **当**: `UpdatePlan` 方法不再包含对计划类型、状态或 `ContentType` 的直接判断逻辑。 | ||||
| - **那么**: 这些业务规则判断已下沉到 `PlanService` 中。 | ||||
|  | ||||
| ### Requirement: DTO 转换逻辑下沉 | ||||
| - **说明**: 数据库模型与 DTO 之间的转换逻辑 **MUST** 从控制器层移动到服务层内部或专门的转换器中。 | ||||
| - **理由**: 确保数据转换逻辑与业务逻辑紧密结合,避免控制器层承担不必要的职责。 | ||||
| - **影响**: 中。主要影响数据流转和转换点。 | ||||
| - **受影响的模块**: `monitor`, `device`, `pig-farm`, `plan`, `user`。 | ||||
|  | ||||
| #### Scenario: 服务层负责将数据库模型转换为响应 DTO | ||||
| - **假如**: `PigFarmService` 的 `GetPigHouseByID` 方法从 `repository` 获取到 `models.PigHouse`。 | ||||
| - **当**: `GetPigHouseByID` 方法在返回前将 `models.PigHouse` 转换为 `dto.PigHouseResponse`。 | ||||
| - **那么**: 控制器直接接收 `dto.PigHouseResponse`。 | ||||
|  | ||||
| ### Requirement: 业务错误处理优化 | ||||
| - **说明**: 服务层现在 **MUST** 返回更抽象的业务错误,控制器层 **MUST** 根据这些抽象错误进行统一的 HTTP 响应处理,避免直接依赖仓库层或服务层内部的具体错误类型或错误信息。 | ||||
| - **理由**: 提高错误处理的一致性和可维护性,解耦控制器与底层错误实现。 | ||||
| - **影响**: 中。影响错误处理流程。 | ||||
| - **受影响的模块**: `monitor`, `device`, `pig-farm`, `plan`, `user`。 | ||||
|  | ||||
| #### Scenario: 服务层返回抽象业务错误 | ||||
| - **假如**: `UserService` 的 `CreateUser` 方法因用户名重复而失败。 | ||||
| - **当**: `UserService` 返回一个表示“用户名已存在”的抽象错误(例如自定义错误类型或包装后的错误)。 | ||||
| - **那么**: `UserController` 接收到此抽象错误后,可以统一转换为相应的 HTTP 状态码和错误信息,而无需解析底层 `gorm.ErrDuplicatedKey` 等具体错误。 | ||||
|  | ||||
							
								
								
									
										17
									
								
								openspec/specs/http-server/spec.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								openspec/specs/http-server/spec.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| # HTTP Server Capability Specification | ||||
|  | ||||
| ## Purpose | ||||
| 该规范描述了本项目中 HTTP 服务器的功能和设计目标。它确保了 API 的可靠性和可维护性。 | ||||
| ## Requirements | ||||
| ### Requirement: API 服务器框架已更新 | ||||
|  | ||||
| - **说明**: 底层 Web 框架从 Gin 迁移到 Echo。所有现有的 API 端点 **MUST** 保持功能齐全和向后兼容。 | ||||
| - **理由**: 为了提高路由灵活性并使技术栈现代化。这是一次技术重构,不会改变任何外部 API 行为。 | ||||
| - **影响**: 高。影响核心请求处理、路由和中间件。 | ||||
| - **受影响的端点**: 全部。 | ||||
|  | ||||
| #### Scenario: 所有现有的 API 端点保持功能齐全和向后兼容 | ||||
| - **假如**: API 服务器在迁移到 Echo 后正在运行。 | ||||
| - **当**: 客户端向任何现有的 API 端点(例如, `POST /api/v1/users/login`)发送请求。 | ||||
| - **那么**: 服务器处理该请求并返回与使用 Gin 框架时完全相同的响应(状态码、头部和正文格式)。 | ||||
|  | ||||
		Reference in New Issue
	
	Block a user