Compare commits
	
		
			58 Commits
		
	
	
		
			bd8729d473
			...
			main
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 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: | server: | ||||||
|   port: 8080 # 服务器监听端口 |   port: 8080 # 服务器监听端口 | ||||||
|   mode: "debug" # 运行模式: debug, release, test |   mode: "debug" # 服务运行模式: debug, release, test | ||||||
|  |  | ||||||
| # 日志配置 | # 日志配置 | ||||||
| log: | log: | ||||||
| @@ -48,8 +48,10 @@ chirp_stack: | |||||||
|   api_host: "http://localhost:8080" # ChirpStack API 主机地址 |   api_host: "http://localhost:8080" # ChirpStack API 主机地址 | ||||||
|   api_token: "your_chirpstack_api_token" # ChirpStack API Token |   api_token: "your_chirpstack_api_token" # ChirpStack API Token | ||||||
|   fport: 10 # ChirpStack FPort |   fport: 10 # ChirpStack FPort | ||||||
|   api_timeout: 5 # API 请求超时时间 (秒) |   api_timeout: 10 # ChirpStack API请求超时时间(秒) | ||||||
|   collection_request_timeout: 10 # 采集请求超时时间 (秒) |   # 等待设备上行响应的超时时间(秒)。 | ||||||
|  |   # 对于LoRaWAN这种延迟较高的网络,建议设置为5分钟 (300秒) 或更长。 | ||||||
|  |   collection_request_timeout: 300 | ||||||
|  |  | ||||||
| # 任务调度配置 | # 任务调度配置 | ||||||
| task: | task: | ||||||
| @@ -62,12 +64,28 @@ lora: | |||||||
|  |  | ||||||
| # Lora Mesh 配置 | # Lora Mesh 配置 | ||||||
| lora_mesh: | lora_mesh: | ||||||
|   uart_port: "/dev/ttyUSB0" # UART 串口路径 |   # 主节点串口 | ||||||
|   baud_rate: 115200 # 波特率 |   uart_port: "COM7" | ||||||
|   timeout: 5 # 超时时间 (秒) |   # LoRa模块的通信波特率 | ||||||
|   lora_mesh_mode: "transparent" # Lora Mesh 模式: transparent, command |   baud_rate: 9600 | ||||||
|   max_chunk_size: 200 # 最大数据块大小 |   # 等待LoRa模块AT指令响应的超时时间(ms) | ||||||
|   reassembly_timeout: 10 # 重组超时时间 (秒) |   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: | notify: | ||||||
| @@ -91,3 +109,7 @@ notify: | |||||||
|     enabled: false # 是否启用飞书通知 |     enabled: false # 是否启用飞书通知 | ||||||
|     appID: "cli_xxxxxxxxxx" # 应用 ID |     appID: "cli_xxxxxxxxxx" # 应用 ID | ||||||
|     appSecret: "your_lark_app_secret" # 应用密钥 |     appSecret: "your_lark_app_secret" # 应用密钥 | ||||||
|  |  | ||||||
|  | # 定时采集配置 | ||||||
|  | collection: | ||||||
|  |   interval: 1 # 采集间隔 (分钟) | ||||||
|   | |||||||
| @@ -8,11 +8,11 @@ app: | |||||||
| # HTTP 服务配置 | # HTTP 服务配置 | ||||||
| server: | server: | ||||||
|   port: 8086 |   port: 8086 | ||||||
|   mode: "release" # Gin 运行模式: "debug", "release", "test" |   mode: "release" # 服务运行模式: "debug", "release", "test" | ||||||
|  |  | ||||||
| # 日志配置 | # 日志配置 | ||||||
| log: | log: | ||||||
|   level: "debug" # 日志级别: "debug", "info", "warn", "error", "dpanic", "panic", "fatal" |   level: "info" # 日志级别: "debug", "info", "warn", "error", "dpanic", "panic", "fatal" | ||||||
|   format: "console" # 日志格式: "console" 或 "json" |   format: "console" # 日志格式: "console" 或 "json" | ||||||
|   enable_file: true # 是否启用文件日志 |   enable_file: true # 是否启用文件日志 | ||||||
|   file_path: "./app_logs/app.log" # 日志文件路径 |   file_path: "./app_logs/app.log" # 日志文件路径 | ||||||
| @@ -87,3 +87,7 @@ lora_mesh: | |||||||
|   #分片重组超时时间(秒)。如果在一个分片到达后,超过这个时间 |   #分片重组超时时间(秒)。如果在一个分片到达后,超过这个时间 | ||||||
|   # 还没收到完整的包,则认为接收失败。 |   # 还没收到完整的包,则认为接收失败。 | ||||||
|   reassembly_timeout: 30 |   reassembly_timeout: 30 | ||||||
|  |  | ||||||
|  | # 定时采集配置 | ||||||
|  | collection: | ||||||
|  |   interval: 1 # 采集间隔 (分钟) | ||||||
							
								
								
									
										647
									
								
								docs/docs.go
									
									
									
									
									
								
							
							
						
						
									
										647
									
								
								docs/docs.go
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										55
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										55
									
								
								go.mod
									
									
									
									
									
								
							| @@ -3,23 +3,21 @@ module git.huangwc.com/pig/pig-farm-controller | |||||||
| go 1.25 | go 1.25 | ||||||
|  |  | ||||||
| require ( | require ( | ||||||
| 	github.com/gin-gonic/gin v1.10.1 |  | ||||||
| 	github.com/go-openapi/errors v0.22.2 | 	github.com/go-openapi/errors v0.22.2 | ||||||
| 	github.com/go-openapi/runtime v0.28.0 | 	github.com/go-openapi/runtime v0.28.0 | ||||||
| 	github.com/go-openapi/strfmt v0.23.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/go-openapi/validate v0.24.0 | ||||||
| 	github.com/golang-jwt/jwt/v5 v5.3.0 | 	github.com/golang-jwt/jwt/v5 v5.3.0 | ||||||
| 	github.com/google/uuid v1.6.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/panjf2000/ants/v2 v2.11.3 | ||||||
| 	github.com/robfig/cron/v3 v3.0.1 | 	github.com/robfig/cron/v3 v3.0.1 | ||||||
| 	github.com/stretchr/testify v1.11.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/swaggo/swag v1.16.6 | ||||||
| 	github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 | 	github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 | ||||||
| 	go.uber.org/zap v1.27.0 | 	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 | 	google.golang.org/protobuf v1.36.9 | ||||||
| 	gopkg.in/natefinch/lumberjack.v2 v2.2.1 | 	gopkg.in/natefinch/lumberjack.v2 v2.2.1 | ||||||
| 	gopkg.in/yaml.v2 v2.4.0 | 	gopkg.in/yaml.v2 v2.4.0 | ||||||
| @@ -39,25 +37,26 @@ require ( | |||||||
| 	github.com/cloudwego/base64x v0.1.6 // indirect | 	github.com/cloudwego/base64x v0.1.6 // indirect | ||||||
| 	github.com/davecgh/go-spew v1.1.1 // indirect | 	github.com/davecgh/go-spew v1.1.1 // indirect | ||||||
| 	github.com/gabriel-vasile/mimetype v1.4.10 // 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/gin-contrib/sse v1.1.0 // indirect | ||||||
| 	github.com/go-logr/logr v1.4.1 // indirect | 	github.com/go-logr/logr v1.4.1 // indirect | ||||||
| 	github.com/go-logr/stdr v1.2.2 // indirect | 	github.com/go-logr/stdr v1.2.2 // indirect | ||||||
| 	github.com/go-openapi/analysis v0.23.0 // indirect | 	github.com/go-openapi/analysis v0.23.0 // indirect | ||||||
| 	github.com/go-openapi/jsonpointer v0.22.0 // indirect | 	github.com/go-openapi/jsonpointer v0.22.1 // indirect | ||||||
| 	github.com/go-openapi/jsonreference v0.21.1 // indirect | 	github.com/go-openapi/jsonreference v0.21.2 // indirect | ||||||
| 	github.com/go-openapi/loads v0.22.0 // indirect | 	github.com/go-openapi/loads v0.22.0 // indirect | ||||||
| 	github.com/go-openapi/spec v0.21.0 // indirect | 	github.com/go-openapi/spec v0.22.0 // indirect | ||||||
| 	github.com/go-openapi/swag/cmdutils v0.24.0 // indirect | 	github.com/go-openapi/swag/cmdutils v0.25.1 // indirect | ||||||
| 	github.com/go-openapi/swag/conv v0.24.0 // indirect | 	github.com/go-openapi/swag/conv v0.25.1 // indirect | ||||||
| 	github.com/go-openapi/swag/fileutils v0.24.0 // indirect | 	github.com/go-openapi/swag/fileutils v0.25.1 // indirect | ||||||
| 	github.com/go-openapi/swag/jsonname v0.24.0 // indirect | 	github.com/go-openapi/swag/jsonname v0.25.1 // indirect | ||||||
| 	github.com/go-openapi/swag/jsonutils v0.24.0 // indirect | 	github.com/go-openapi/swag/jsonutils v0.25.1 // indirect | ||||||
| 	github.com/go-openapi/swag/loading v0.24.0 // indirect | 	github.com/go-openapi/swag/loading v0.25.1 // indirect | ||||||
| 	github.com/go-openapi/swag/mangling v0.24.0 // indirect | 	github.com/go-openapi/swag/mangling v0.25.1 // indirect | ||||||
| 	github.com/go-openapi/swag/netutils v0.24.0 // indirect | 	github.com/go-openapi/swag/netutils v0.25.1 // indirect | ||||||
| 	github.com/go-openapi/swag/stringutils v0.24.0 // indirect | 	github.com/go-openapi/swag/stringutils v0.25.1 // indirect | ||||||
| 	github.com/go-openapi/swag/typeutils v0.24.0 // indirect | 	github.com/go-openapi/swag/typeutils v0.25.1 // indirect | ||||||
| 	github.com/go-openapi/swag/yamlutils v0.24.0 // indirect | 	github.com/go-openapi/swag/yamlutils v0.25.1 // indirect | ||||||
| 	github.com/go-playground/locales v0.14.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/universal-translator v0.18.1 // indirect | ||||||
| 	github.com/go-playground/validator/v10 v10.27.0 // 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/josharian/intern v1.0.0 // indirect | ||||||
| 	github.com/json-iterator/go v1.1.12 // indirect | 	github.com/json-iterator/go v1.1.12 // indirect | ||||||
| 	github.com/klauspost/cpuid/v2 v2.3.0 // 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/leodido/go-urn v1.4.0 // indirect | ||||||
| 	github.com/mailru/easyjson v0.9.1 // 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-isatty v0.0.20 // indirect | ||||||
| 	github.com/mattn/go-sqlite3 v1.14.22 // indirect | 	github.com/mattn/go-sqlite3 v1.14.22 // indirect | ||||||
| 	github.com/mitchellh/mapstructure v1.5.0 // 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/pmezard/go-difflib v1.0.0 // indirect | ||||||
| 	github.com/rogpeppe/go-internal v1.14.1 // indirect | 	github.com/rogpeppe/go-internal v1.14.1 // indirect | ||||||
| 	github.com/stretchr/objx v0.5.2 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect | ||||||
| 	github.com/ugorji/go/codec v1.3.0 // 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.mongodb.org/mongo-driver v1.14.0 // indirect | ||||||
| 	go.opentelemetry.io/otel v1.24.0 // indirect | 	go.opentelemetry.io/otel v1.24.0 // indirect | ||||||
| 	go.opentelemetry.io/otel/metric v1.24.0 // indirect | 	go.opentelemetry.io/otel/metric v1.24.0 // indirect | ||||||
| 	go.opentelemetry.io/otel/trace v1.24.0 // indirect | 	go.opentelemetry.io/otel/trace v1.24.0 // indirect | ||||||
| 	go.uber.org/multierr v1.10.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/arch v0.21.0 // indirect | ||||||
| 	golang.org/x/mod v0.28.0 // indirect | 	golang.org/x/mod v0.29.0 // indirect | ||||||
| 	golang.org/x/net v0.44.0 // indirect | 	golang.org/x/net v0.46.0 // indirect | ||||||
| 	golang.org/x/sync v0.17.0 // indirect | 	golang.org/x/sync v0.17.0 // indirect | ||||||
| 	golang.org/x/sys v0.36.0 // indirect | 	golang.org/x/sys v0.37.0 // indirect | ||||||
| 	golang.org/x/text v0.29.0 // indirect | 	golang.org/x/text v0.30.0 // indirect | ||||||
| 	golang.org/x/tools v0.37.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 | 	gopkg.in/yaml.v3 v3.0.1 // indirect | ||||||
| 	gorm.io/driver/mysql v1.5.6 // 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/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 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= | ||||||
| github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= | 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 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= | ||||||
| github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= | github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= | ||||||
| github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= | 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/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 h1:TmMhghgNef9YXxTu1tOopo+0BGEytxA+okbry0HjZsM= | ||||||
| github.com/go-openapi/jsonpointer v0.22.0/go.mod h1:xt3jV88UtExdIkkL7NloURjRQjbeUgcxFblMjq2iaiU= | 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 h1:bSKrcl8819zKiOgxkbVNRUBIr6Wwj9KYrDbMjRs0cDA= | ||||||
| github.com/go-openapi/jsonreference v0.21.1/go.mod h1:PWs8rO4xxTUqKGu+lEvvCxD5k2X7QYkKAepJyCmSTT8= | 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 h1:ECPGd4jX1U6NApCGG1We+uEozOAvXvJSF4nnwHZ8Aco= | ||||||
| github.com/go-openapi/loads v0.22.0/go.mod h1:yLsaTCS92mnSAZX5WWoxszLj0u+Ojl+Zs5Stn1oF+rs= | 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 h1:gpPPmWSNGo214l6n8hzdXYhPuJcGtziTOgUpvsFWGIQ= | ||||||
| github.com/go-openapi/runtime v0.28.0/go.mod h1:QN7OzcS+XuYmkQLw05akXk0jRH/eZ3kb18+1KwW9gyc= | 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 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= | ||||||
| github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= | 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 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c= | ||||||
| github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4= | 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 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.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 h1:KlRCffHwXFI6E5MV9n8o8zBRElpY4uK4yWyAMWETo9I= | ||||||
| github.com/go-openapi/swag/cmdutils v0.24.0/go.mod h1:uxib2FAeQMByyHomTlsP8h1TtPd54Msu2ZDU/H5Vuf8= | 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 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.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 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.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 h1:2wKS9bgRV/xB8c62Qg16w4AUiIrqqiniJFtZGi3dg5k= | ||||||
| github.com/go-openapi/swag/jsonname v0.24.0/go.mod h1:GXqrPzGJe611P7LG4QB9JKPtUZ7flE4DOVechNaDd7Q= | 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 h1:F1vE1q4pg1xtO3HTyJYRmEuJ4jmIp2iZ30bzW5XgZts= | ||||||
| github.com/go-openapi/swag/jsonutils v0.24.0/go.mod h1:vBowZtF5Z4DDApIoxcIVfR8v0l9oq5PpYRUuteVu6f0= | 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 h1:ln/fWTwJp2Zkj5DdaX4JPiddFC5CHQpvaBKycOlceYc= | ||||||
| github.com/go-openapi/swag/loading v0.24.0/go.mod h1:gShCN4woKZYIxPxbfbyHgjXAhO61m88tmjy0lp/LkJk= | 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 h1:PGOQpViCOUroIeak/Uj/sjGAq9LADS3mOyjznmHy2pk= | ||||||
| github.com/go-openapi/swag/mangling v0.24.0/go.mod h1:Jm5Go9LHkycsz0wfoaBDkdc4CkpuSnIEf62brzyCbhc= | 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 h1:Bz02HRjYv8046Ycg/w80q3g9QCWeIqTvlyOjQPDjD8w= | ||||||
| github.com/go-openapi/swag/netutils v0.24.0/go.mod h1:WRgiHcYTnx+IqfMCtu0hy9oOaPR0HnPbmArSRN1SkZM= | 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 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.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 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.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 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.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 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58= | ||||||
| github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ= | 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= | 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/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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= | ||||||
| github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= | 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 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= | ||||||
| github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= | 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 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= | ||||||
| github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= | 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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= | ||||||
| github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= | 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= | 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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= | ||||||
| github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= | 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 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= | ||||||
| github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= | 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 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY= | ||||||
| github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw= | 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= | 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/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 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= | ||||||
| github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= | 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= | 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 h1:P98w8egYRjYe3XDjxhYJagTokP/H6HzlsnojRgZRd80= | ||||||
| go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c= | 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/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 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= | ||||||
| go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= | 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 h1:iTC9o7+wP6cPWpDWkivCvQFGAHDQ59SrSxsLPcnkArw= | ||||||
| golang.org/x/arch v0.21.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= | 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-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.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 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= | ||||||
| golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= | 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.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 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= | ||||||
| golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= | 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-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-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.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.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 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= | ||||||
| golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= | 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-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.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= | ||||||
| golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= | 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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||||||
| golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= | 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.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-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.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= | ||||||
| golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= | 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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= | ||||||
| golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= | 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.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-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.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.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 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= | ||||||
| golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= | 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= | 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 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= | ||||||
| google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= | google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= | ||||||
|   | |||||||
| @@ -27,23 +27,22 @@ import ( | |||||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/service" | 	"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/app/webhook" | ||||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/domain/audit" | 	"git.huangwc.com/pig/pig-farm-controller/internal/domain/audit" | ||||||
| 	domain_device "git.huangwc.com/pig/pig-farm-controller/internal/domain/device" | 	"git.huangwc.com/pig/pig-farm-controller/internal/domain/scheduler" | ||||||
| 	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/token" | 	"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/config" | ||||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" | 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" | ||||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" | 	"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 服务器及其依赖 | // API 结构体定义了 HTTP 服务器及其依赖 | ||||||
| type API struct { | type API struct { | ||||||
| 	engine              *gin.Engine                    // Gin 引擎实例,用于处理 HTTP 请求 | 	echo                *echo.Echo                         // Echo 引擎实例,用于处理 HTTP 请求 | ||||||
| 	logger              *logs.Logger                       // 日志记录器,用于输出日志信息 | 	logger              *logs.Logger                       // 日志记录器,用于输出日志信息 | ||||||
| 	userRepo            repository.UserRepository          // 用户数据仓库接口,用于用户数据操作 | 	userRepo            repository.UserRepository          // 用户数据仓库接口,用于用户数据操作 | ||||||
| 	tokenService        token.TokenService             // Token 服务接口,用于 JWT token 的生成和解析 | 	tokenService        token.Service                      // Token 服务接口,用于 JWT token 的生成和解析 | ||||||
| 	auditService        audit.Service                      // 审计服务,用于记录用户操作 | 	auditService        audit.Service                      // 审计服务,用于记录用户操作 | ||||||
| 	httpServer          *http.Server                       // 标准库的 HTTP 服务器实例,用于启动和停止服务 | 	httpServer          *http.Server                       // 标准库的 HTTP 服务器实例,用于启动和停止服务 | ||||||
| 	config              config.ServerConfig                // API 服务器的配置,使用 infra/config 包中的 ServerConfig | 	config              config.ServerConfig                // API 服务器的配置,使用 infra/config 包中的 ServerConfig | ||||||
| @@ -54,42 +53,37 @@ type API struct { | |||||||
| 	pigBatchController  *management.PigBatchController     // 猪群控制器实例 | 	pigBatchController  *management.PigBatchController     // 猪群控制器实例 | ||||||
| 	monitorController   *monitor.Controller                // 数据监控控制器实例 | 	monitorController   *monitor.Controller                // 数据监控控制器实例 | ||||||
| 	listenHandler       webhook.ListenHandler              // 设备上行事件监听器 | 	listenHandler       webhook.ListenHandler              // 设备上行事件监听器 | ||||||
| 	analysisTaskManager *task.AnalysisPlanTaskManager  // 计划触发器管理器实例 | 	analysisTaskManager *scheduler.AnalysisPlanTaskManager // 计划触发器管理器实例 | ||||||
| } | } | ||||||
|  |  | ||||||
| // NewAPI 创建并返回一个新的 API 实例 | // NewAPI 创建并返回一个新的 API 实例 | ||||||
| // 负责初始化 Gin 引擎、设置全局中间件,并注入所有必要的依赖。 | // 负责初始化 Echo 引擎、设置全局中间件,并注入所有必要的依赖。 | ||||||
| func NewAPI(cfg config.ServerConfig, | func NewAPI(cfg config.ServerConfig, | ||||||
| 	logger *logs.Logger, | 	logger *logs.Logger, | ||||||
| 	userRepo repository.UserRepository, | 	userRepo repository.UserRepository, | ||||||
| 	deviceRepository repository.DeviceRepository, |  | ||||||
| 	areaControllerRepository repository.AreaControllerRepository, |  | ||||||
| 	deviceTemplateRepository repository.DeviceTemplateRepository, |  | ||||||
| 	planRepository repository.PlanRepository, |  | ||||||
| 	pigFarmService service.PigFarmService, | 	pigFarmService service.PigFarmService, | ||||||
| 	pigBatchService service.PigBatchService, | 	pigBatchService service.PigBatchService, | ||||||
| 	monitorService service.MonitorService, | 	monitorService service.MonitorService, | ||||||
| 	tokenService token.TokenService, | 	deviceService service.DeviceService, | ||||||
|  | 	planService service.PlanService, | ||||||
|  | 	userService service.UserService, | ||||||
|  | 	tokenService token.Service, | ||||||
| 	auditService audit.Service, | 	auditService audit.Service, | ||||||
| 	notifyService domain_notify.Service, |  | ||||||
| 	deviceService domain_device.Service, |  | ||||||
| 	listenHandler webhook.ListenHandler, | 	listenHandler webhook.ListenHandler, | ||||||
| 	analysisTaskManager *task.AnalysisPlanTaskManager) *API { | ) *API { | ||||||
| 	// 设置 Gin 模式,例如 gin.ReleaseMode (生产模式) 或 gin.DebugMode (开发模式) | 	// 使用 echo.New() 创建一个 Echo 引擎实例 | ||||||
| 	// 从配置中获取 Gin 模式 | 	e := echo.New() | ||||||
| 	gin.SetMode(cfg.Mode) |  | ||||||
|  |  | ||||||
| 	// 使用 gin.New() 创建一个 Gin 引擎实例,而不是 gin.Default() | 	// 根据配置设置 Echo 的调试模式 | ||||||
| 	// 这样可以手动添加所需的中间件,避免 gin.Default() 默认包含的 Logger 和 Recovery 中间件 | 	e.Debug = cfg.Mode == "debug" | ||||||
| 	engine := gin.New() |  | ||||||
|  |  | ||||||
| 	// 添加 Gin Recovery 中间件,用于捕获 panic 并恢复,防止服务崩溃 | 	// 添加 Echo Recovery 中间件,用于捕获 panic 并恢复,防止服务崩溃 | ||||||
| 	// gin.Logger() 已移除,因为我们使用自定义的 logger | 	// Echo 的 Logger 中间件默认会记录请求信息,如果需要自定义,可以替换 | ||||||
| 	engine.Use(gin.Recovery()) | 	e.Use(middleware.Recover()) | ||||||
|  |  | ||||||
| 	// 初始化 API 结构体 | 	// 初始化 API 结构体 | ||||||
| 	api := &API{ | 	api := &API{ | ||||||
| 		engine:        engine, | 		echo:          e, | ||||||
| 		logger:        logger, | 		logger:        logger, | ||||||
| 		userRepo:      userRepo, | 		userRepo:      userRepo, | ||||||
| 		tokenService:  tokenService, | 		tokenService:  tokenService, | ||||||
| @@ -97,11 +91,11 @@ func NewAPI(cfg config.ServerConfig, | |||||||
| 		config:        cfg, | 		config:        cfg, | ||||||
| 		listenHandler: listenHandler, | 		listenHandler: listenHandler, | ||||||
| 		// 在 NewAPI 中初始化用户控制器,并将其作为 API 结构体的成员 | 		// 在 NewAPI 中初始化用户控制器,并将其作为 API 结构体的成员 | ||||||
| 		userController: user.NewController(userRepo, monitorService, logger, tokenService, notifyService), | 		userController: user.NewController(userService, logger), | ||||||
| 		// 在 NewAPI 中初始化设备控制器,并将其作为 API 结构体的成员 | 		// 在 NewAPI 中初始化设备控制器,并将其作为 API 结构体的成员 | ||||||
| 		deviceController: device.NewController(deviceRepository, areaControllerRepository, deviceTemplateRepository, deviceService, logger), | 		deviceController: device.NewController(deviceService, logger), | ||||||
| 		// 在 NewAPI 中初始化计划控制器,并将其作为 API 结构体的成员 | 		// 在 NewAPI 中初始化计划控制器,并将其作为 API 结构体的成员 | ||||||
| 		planController: plan.NewController(logger, planRepository, analysisTaskManager), | 		planController: plan.NewController(logger, planService), | ||||||
| 		// 在 NewAPI 中初始化猪场管理控制器 | 		// 在 NewAPI 中初始化猪场管理控制器 | ||||||
| 		pigFarmController: management.NewPigFarmController(logger, pigFarmService), | 		pigFarmController: management.NewPigFarmController(logger, pigFarmService), | ||||||
| 		// 在 NewAPI 中初始化猪群控制器 | 		// 在 NewAPI 中初始化猪群控制器 | ||||||
| @@ -124,7 +118,7 @@ func (a *API) Start() { | |||||||
| 	// 初始化标准库的 http.Server 实例 | 	// 初始化标准库的 http.Server 实例 | ||||||
| 	a.httpServer = &http.Server{ | 	a.httpServer = &http.Server{ | ||||||
| 		Addr:    addr,   // 服务器监听的地址从配置中获取 | 		Addr:    addr,   // 服务器监听的地址从配置中获取 | ||||||
| 		Handler: a.engine, // 将 Gin 引擎作为 HTTP 请求的处理程序 | 		Handler: a.echo, // 将 Echo 引擎作为 HTTP 请求的处理程序 | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// 在独立的 goroutine 中启动服务器 | 	// 在独立的 goroutine 中启动服务器 | ||||||
|   | |||||||
| @@ -1,65 +1,65 @@ | |||||||
| package api | package api | ||||||
|  |  | ||||||
| import ( | import ( | ||||||
|  | 	"net/http" | ||||||
| 	"net/http/pprof" | 	"net/http/pprof" | ||||||
|  |  | ||||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/middleware" | 	"git.huangwc.com/pig/pig-farm-controller/internal/app/middleware" | ||||||
| 	"github.com/gin-gonic/gin" | 	"github.com/labstack/echo/v4" | ||||||
| 	swaggerFiles "github.com/swaggo/files" | 	echoSwagger "github.com/swaggo/echo-swagger" | ||||||
| 	ginSwagger "github.com/swaggo/gin-swagger" |  | ||||||
| ) | ) | ||||||
|  |  | ||||||
| // setupRoutes 设置所有 API 路由 | // setupRoutes 设置所有 API 路由 | ||||||
| // 在此方法中,使用已初始化的控制器实例将其路由注册到 Gin 引擎中。 | // 在此方法中,使用已初始化的控制器实例将其路由注册到 Echo 引擎中。 | ||||||
| func (a *API) setupRoutes() { | func (a *API) setupRoutes() { | ||||||
|  | 	a.logger.Info("开始初始化所有 API 路由") | ||||||
|  |  | ||||||
| 	// --- Public Routes --- | 	// --- Public Routes --- | ||||||
| 	// 这些路由不需要身份验证 | 	// 这些路由不需要身份验证 | ||||||
|  |  | ||||||
| 	// 用户注册和登录 | 	// 用户注册和登录 | ||||||
| 	a.engine.POST("/api/v1/users", a.userController.CreateUser)  // 注册新用户 | 	a.echo.POST("/api/v1/users", a.userController.CreateUser)  // 注册新用户 | ||||||
| 	a.engine.POST("/api/v1/users/login", a.userController.Login) // 用户登录 | 	a.echo.POST("/api/v1/users/login", a.userController.Login) // 用户登录 | ||||||
| 	a.logger.Info("公开接口注册成功:用户注册、登录") | 	a.logger.Debug("公开接口注册成功:用户注册、登录") | ||||||
|  |  | ||||||
| 	// 注册 pprof 路由 | 	// 注册 pprof 路由 | ||||||
| 	pprofGroup := a.engine.Group("/debug/pprof") | 	pprofGroup := a.echo.Group("/debug/pprof") | ||||||
| 	{ | 	{ | ||||||
| 		pprofGroup.GET("/", gin.WrapF(pprof.Index))                   // pprof 索引页 | 		pprofGroup.GET("/", echo.WrapHandler(http.HandlerFunc(pprof.Index)))          // pprof 索引页 | ||||||
| 		pprofGroup.GET("/cmdline", gin.WrapF(pprof.Cmdline))          // pprof 命令行参数 | 		pprofGroup.GET("/cmdline", echo.WrapHandler(http.HandlerFunc(pprof.Cmdline))) // pprof 命令行参数 | ||||||
| 		pprofGroup.GET("/profile", gin.WrapF(pprof.Profile))          // pprof CPU profile | 		pprofGroup.GET("/profile", echo.WrapHandler(http.HandlerFunc(pprof.Profile))) // pprof CPU profile | ||||||
| 		pprofGroup.POST("/symbol", gin.WrapF(pprof.Symbol))           // pprof 符号查找 (POST) | 		pprofGroup.POST("/symbol", echo.WrapHandler(http.HandlerFunc(pprof.Symbol)))  // pprof 符号查找 (POST) | ||||||
| 		pprofGroup.GET("/symbol", gin.WrapF(pprof.Symbol))            // pprof 符号查找 (GET) | 		pprofGroup.GET("/symbol", echo.WrapHandler(http.HandlerFunc(pprof.Symbol)))   // pprof 符号查找 (GET) | ||||||
| 		pprofGroup.GET("/trace", gin.WrapF(pprof.Trace))              // pprof 跟踪 | 		pprofGroup.GET("/trace", echo.WrapHandler(http.HandlerFunc(pprof.Trace)))     // pprof 跟踪 | ||||||
| 		pprofGroup.GET("/allocs", gin.WrapH(pprof.Handler("allocs"))) // pprof 内存分配 | 		pprofGroup.GET("/allocs", echo.WrapHandler(pprof.Handler("allocs")))          // pprof 内存分配 | ||||||
| 		pprofGroup.GET("/block", gin.WrapH(pprof.Handler("block")))   // pprof 阻塞 | 		pprofGroup.GET("/block", echo.WrapHandler(pprof.Handler("block")))            // pprof 阻塞 | ||||||
| 		pprofGroup.GET("/goroutine", gin.WrapH(pprof.Handler("goroutine"))) | 		pprofGroup.GET("/goroutine", echo.WrapHandler(pprof.Handler("goroutine"))) | ||||||
| 		pprofGroup.GET("/heap", gin.WrapH(pprof.Handler("heap")))   // pprof 堆内存 | 		pprofGroup.GET("/heap", echo.WrapHandler(pprof.Handler("heap")))   // pprof 堆内存 | ||||||
| 		pprofGroup.GET("/mutex", gin.WrapH(pprof.Handler("mutex"))) // pprof 互斥锁 | 		pprofGroup.GET("/mutex", echo.WrapHandler(pprof.Handler("mutex"))) // pprof 互斥锁 | ||||||
| 		pprofGroup.GET("/threadcreate", gin.WrapH(pprof.Handler("threadcreate"))) | 		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.echo.POST("/upstream", echo.WrapHandler(a.listenHandler.Handler())) // 处理设备上行事件 | ||||||
| 	a.logger.Info("上行事件监听接口注册成功") | 	a.logger.Debug("上行事件监听接口注册成功") | ||||||
|  |  | ||||||
| 	// 添加 Swagger UI 路由, Swagger UI可在 /swagger/index.html 上找到 | 	// 添加 Swagger UI 路由, Swagger UI可在 /swagger/index.html 上找到 | ||||||
| 	a.engine.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) // Swagger UI 接口 | 	a.echo.GET("/swagger/*any", echoSwagger.WrapHandler) // Swagger UI 接口 | ||||||
| 	a.logger.Info("Swagger UI 接口注册成功") | 	a.logger.Debug("Swagger UI 接口注册成功") | ||||||
|  |  | ||||||
| 	// --- Authenticated Routes --- | 	// --- Authenticated Routes --- | ||||||
| 	// 所有在此注册的路由都需要通过 JWT 身份验证 | 	// 所有在此注册的路由都需要通过 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.AuthMiddleware(a.tokenService, a.userRepo)) // 1. 身份认证中间件 | ||||||
| 	authGroup.Use(middleware.AuditLogMiddleware(a.auditService))         // 2. 审计日志中间件 | 	authGroup.Use(middleware.AuditLogMiddleware(a.auditService))         // 2. 审计日志中间件 | ||||||
| 	{ | 	{ | ||||||
| 		// 用户相关路由组 | 		// 用户相关路由组 | ||||||
| 		userGroup := authGroup.Group("/users") | 		userGroup := authGroup.Group("/users") | ||||||
| 		{ | 		{ | ||||||
| 			userGroup.GET("/:id/history", a.userController.ListUserHistory) // 获取用户操作历史 |  | ||||||
| 			userGroup.POST("/:id/notifications/test", a.userController.SendTestNotification) | 			userGroup.POST("/:id/notifications/test", a.userController.SendTestNotification) | ||||||
| 		} | 		} | ||||||
| 		a.logger.Info("用户相关接口注册成功 (需要认证和审计)") | 		a.logger.Debug("用户相关接口注册成功 (需要认证和审计)") | ||||||
|  |  | ||||||
| 		// 设备相关路由组 | 		// 设备相关路由组 | ||||||
| 		deviceGroup := authGroup.Group("/devices") | 		deviceGroup := authGroup.Group("/devices") | ||||||
| @@ -71,7 +71,7 @@ func (a *API) setupRoutes() { | |||||||
| 			deviceGroup.DELETE("/:id", a.deviceController.DeleteDevice)               // 删除设备 | 			deviceGroup.DELETE("/:id", a.deviceController.DeleteDevice)               // 删除设备 | ||||||
| 			deviceGroup.POST("/manual-control/:id", a.deviceController.ManualControl) // 手动控制设备 | 			deviceGroup.POST("/manual-control/:id", a.deviceController.ManualControl) // 手动控制设备 | ||||||
| 		} | 		} | ||||||
| 		a.logger.Info("设备相关接口注册成功 (需要认证和审计)") | 		a.logger.Debug("设备相关接口注册成功 (需要认证和审计)") | ||||||
|  |  | ||||||
| 		// 区域主控相关路由组 | 		// 区域主控相关路由组 | ||||||
| 		areaControllerGroup := authGroup.Group("/area-controllers") | 		areaControllerGroup := authGroup.Group("/area-controllers") | ||||||
| @@ -82,7 +82,7 @@ func (a *API) setupRoutes() { | |||||||
| 			areaControllerGroup.PUT("/:id", a.deviceController.UpdateAreaController)    // 更新区域主控 | 			areaControllerGroup.PUT("/:id", a.deviceController.UpdateAreaController)    // 更新区域主控 | ||||||
| 			areaControllerGroup.DELETE("/:id", a.deviceController.DeleteAreaController) // 删除区域主控 | 			areaControllerGroup.DELETE("/:id", a.deviceController.DeleteAreaController) // 删除区域主控 | ||||||
| 		} | 		} | ||||||
| 		a.logger.Info("区域主控相关接口注册成功 (需要认证和审计)") | 		a.logger.Debug("区域主控相关接口注册成功 (需要认证和审计)") | ||||||
|  |  | ||||||
| 		// 设备模板相关路由组 | 		// 设备模板相关路由组 | ||||||
| 		deviceTemplateGroup := authGroup.Group("/device-templates") | 		deviceTemplateGroup := authGroup.Group("/device-templates") | ||||||
| @@ -93,7 +93,7 @@ func (a *API) setupRoutes() { | |||||||
| 			deviceTemplateGroup.PUT("/:id", a.deviceController.UpdateDeviceTemplate)    // 更新设备模板 | 			deviceTemplateGroup.PUT("/:id", a.deviceController.UpdateDeviceTemplate)    // 更新设备模板 | ||||||
| 			deviceTemplateGroup.DELETE("/:id", a.deviceController.DeleteDeviceTemplate) // 删除设备模板 | 			deviceTemplateGroup.DELETE("/:id", a.deviceController.DeleteDeviceTemplate) // 删除设备模板 | ||||||
| 		} | 		} | ||||||
| 		a.logger.Info("设备模板相关接口注册成功 (需要认证和审计)") | 		a.logger.Debug("设备模板相关接口注册成功 (需要认证和审计)") | ||||||
|  |  | ||||||
| 		// 计划相关路由组 | 		// 计划相关路由组 | ||||||
| 		planGroup := authGroup.Group("/plans") | 		planGroup := authGroup.Group("/plans") | ||||||
| @@ -106,7 +106,7 @@ func (a *API) setupRoutes() { | |||||||
| 			planGroup.POST("/:id/start", a.planController.StartPlan) // 启动计划 | 			planGroup.POST("/:id/start", a.planController.StartPlan) // 启动计划 | ||||||
| 			planGroup.POST("/:id/stop", a.planController.StopPlan)   // 停止计划 | 			planGroup.POST("/:id/stop", a.planController.StopPlan)   // 停止计划 | ||||||
| 		} | 		} | ||||||
| 		a.logger.Info("计划相关接口注册成功 (需要认证和审计)") | 		a.logger.Debug("计划相关接口注册成功 (需要认证和审计)") | ||||||
|  |  | ||||||
| 		// 猪舍相关路由组 | 		// 猪舍相关路由组 | ||||||
| 		pigHouseGroup := authGroup.Group("/pig-houses") | 		pigHouseGroup := authGroup.Group("/pig-houses") | ||||||
| @@ -117,7 +117,7 @@ func (a *API) setupRoutes() { | |||||||
| 			pigHouseGroup.PUT("/:id", a.pigFarmController.UpdatePigHouse)    // 更新猪舍 | 			pigHouseGroup.PUT("/:id", a.pigFarmController.UpdatePigHouse)    // 更新猪舍 | ||||||
| 			pigHouseGroup.DELETE("/:id", a.pigFarmController.DeletePigHouse) // 删除猪舍 | 			pigHouseGroup.DELETE("/:id", a.pigFarmController.DeletePigHouse) // 删除猪舍 | ||||||
| 		} | 		} | ||||||
| 		a.logger.Info("猪舍相关接口注册成功 (需要认证和审计)") | 		a.logger.Debug("猪舍相关接口注册成功 (需要认证和审计)") | ||||||
|  |  | ||||||
| 		// 猪圈相关路由组 | 		// 猪圈相关路由组 | ||||||
| 		penGroup := authGroup.Group("/pens") | 		penGroup := authGroup.Group("/pens") | ||||||
| @@ -129,7 +129,7 @@ func (a *API) setupRoutes() { | |||||||
| 			penGroup.DELETE("/:id", a.pigFarmController.DeletePen)           // 删除猪圈 | 			penGroup.DELETE("/:id", a.pigFarmController.DeletePen)           // 删除猪圈 | ||||||
| 			penGroup.PUT("/:id/status", a.pigFarmController.UpdatePenStatus) // 更新猪圈状态 | 			penGroup.PUT("/:id/status", a.pigFarmController.UpdatePenStatus) // 更新猪圈状态 | ||||||
| 		} | 		} | ||||||
| 		a.logger.Info("猪圈相关接口注册成功 (需要认证和审计)") | 		a.logger.Debug("猪圈相关接口注册成功 (需要认证和审计)") | ||||||
|  |  | ||||||
| 		// 猪群相关路由组 | 		// 猪群相关路由组 | ||||||
| 		pigBatchGroup := authGroup.Group("/pig-batches") | 		pigBatchGroup := authGroup.Group("/pig-batches") | ||||||
| @@ -154,7 +154,7 @@ func (a *API) setupRoutes() { | |||||||
| 			pigBatchGroup.POST("/record-death/:id", a.pigBatchController.RecordDeath)                                     // 记录正常猪只死亡事件 | 			pigBatchGroup.POST("/record-death/:id", a.pigBatchController.RecordDeath)                                     // 记录正常猪只死亡事件 | ||||||
| 			pigBatchGroup.POST("/record-cull/:id", a.pigBatchController.RecordCull)                                       // 记录正常猪只淘汰事件 | 			pigBatchGroup.POST("/record-cull/:id", a.pigBatchController.RecordCull)                                       // 记录正常猪只淘汰事件 | ||||||
| 		} | 		} | ||||||
| 		a.logger.Info("猪群相关接口注册成功 (需要认证和审计)") | 		a.logger.Debug("猪群相关接口注册成功 (需要认证和审计)") | ||||||
|  |  | ||||||
| 		// 数据监控相关路由组 | 		// 数据监控相关路由组 | ||||||
| 		monitorGroup := authGroup.Group("/monitor") | 		monitorGroup := authGroup.Group("/monitor") | ||||||
| @@ -176,7 +176,10 @@ func (a *API) setupRoutes() { | |||||||
| 			monitorGroup.GET("/pig-sick-logs", a.monitorController.ListPigSickLogs) | 			monitorGroup.GET("/pig-sick-logs", a.monitorController.ListPigSickLogs) | ||||||
| 			monitorGroup.GET("/pig-purchases", a.monitorController.ListPigPurchases) | 			monitorGroup.GET("/pig-purchases", a.monitorController.ListPigPurchases) | ||||||
| 			monitorGroup.GET("/pig-sales", a.monitorController.ListPigSales) | 			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" | 	"errors" | ||||||
|  |  | ||||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||||
| 	"github.com/gin-gonic/gin" | 	"github.com/labstack/echo/v4" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| var ( | var ( | ||||||
| 	// ErrUserNotFoundInContext 表示在 gin.Context 中未找到用户信息。 | 	// ErrUserNotFoundInContext 表示在 context 中未找到用户信息。 | ||||||
| 	ErrUserNotFoundInContext = errors.New("context中未找到用户信息") | 	ErrUserNotFoundInContext = errors.New("context中未找到用户信息") | ||||||
| 	// ErrInvalidUserType 表示从 gin.Context 中获取的用户信息类型不正确。 | 	// ErrInvalidUserType 表示从 context 中获取的用户信息类型不正确。 | ||||||
| 	ErrInvalidUserType = errors.New("context中用户信息类型不正确") | 	ErrInvalidUserType = errors.New("context中用户信息类型不正确") | ||||||
| ) | ) | ||||||
|  |  | ||||||
| // GetOperatorIDFromContext 从 gin.Context 中提取操作者ID。 | // GetOperatorIDFromContext 从 echo.Context 中提取操作者ID。 | ||||||
| // 假设操作者ID是由 AuthMiddleware 存储到 context 中的 *models.User 对象的 ID 字段。 | // 假设操作者ID是由 AuthMiddleware 存储到 context 中的 *models.User 对象的 ID 字段。 | ||||||
| func GetOperatorIDFromContext(c *gin.Context) (uint, error) { | func GetOperatorIDFromContext(c echo.Context) (uint, error) { | ||||||
| 	userVal, exists := c.Get(models.ContextUserKey.String()) | 	userVal := c.Get(models.ContextUserKey.String()) | ||||||
| 	if !exists { | 	if userVal == nil { | ||||||
| 		return 0, ErrUserNotFoundInContext | 		return 0, ErrUserNotFoundInContext | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -30,11 +30,11 @@ func GetOperatorIDFromContext(c *gin.Context) (uint, error) { | |||||||
| 	return user.ID, nil | 	return user.ID, nil | ||||||
| } | } | ||||||
|  |  | ||||||
| // GetOperatorFromContext 从 gin.Context 中提取操作者。 | // GetOperatorFromContext 从 echo.Context 中提取操作者。 | ||||||
| // 假设操作者是由 AuthMiddleware 存储到 context 中的 *models.User 对象的字段。 | // 假设操作者是由 AuthMiddleware 存储到 context 中的 *models.User 对象的字段。 | ||||||
| func GetOperatorFromContext(c *gin.Context) (*models.User, error) { | func GetOperatorFromContext(c echo.Context) (*models.User, error) { | ||||||
| 	userVal, exists := c.Get(models.ContextUserKey.String()) | 	userVal := c.Get(models.ContextUserKey.String()) | ||||||
| 	if !exists { | 	if userVal == nil { | ||||||
| 		return nil, ErrUserNotFoundInContext | 		return nil, ErrUserNotFoundInContext | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,42 +1,28 @@ | |||||||
| package device | package device | ||||||
|  |  | ||||||
| import ( | import ( | ||||||
| 	"encoding/json" |  | ||||||
| 	"errors" | 	"errors" | ||||||
| 	"strconv" |  | ||||||
| 	"strings" |  | ||||||
|  |  | ||||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/controller" | 	"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/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/logs" | ||||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | 	"github.com/labstack/echo/v4" | ||||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" |  | ||||||
| 	"github.com/gin-gonic/gin" |  | ||||||
| 	"gorm.io/gorm" | 	"gorm.io/gorm" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| // Controller 设备控制器,封装了所有与设备和区域主控相关的业务逻辑 | // Controller 设备控制器,封装了所有与设备和区域主控相关的业务逻辑 | ||||||
| type Controller struct { | type Controller struct { | ||||||
| 	deviceRepo         repository.DeviceRepository | 	deviceService service.DeviceService | ||||||
| 	areaControllerRepo repository.AreaControllerRepository |  | ||||||
| 	deviceTemplateRepo repository.DeviceTemplateRepository |  | ||||||
| 	deviceService      device.Service |  | ||||||
| 	logger        *logs.Logger | 	logger        *logs.Logger | ||||||
| } | } | ||||||
|  |  | ||||||
| // NewController 创建一个新的设备控制器实例 | // NewController 创建一个新的设备控制器实例 | ||||||
| func NewController( | func NewController( | ||||||
| 	deviceRepo repository.DeviceRepository, | 	deviceService service.DeviceService, | ||||||
| 	areaControllerRepo repository.AreaControllerRepository, |  | ||||||
| 	deviceTemplateRepo repository.DeviceTemplateRepository, |  | ||||||
| 	deviceService device.Service, |  | ||||||
| 	logger *logs.Logger, | 	logger *logs.Logger, | ||||||
| ) *Controller { | ) *Controller { | ||||||
| 	return &Controller{ | 	return &Controller{ | ||||||
| 		deviceRepo:         deviceRepo, |  | ||||||
| 		areaControllerRepo: areaControllerRepo, |  | ||||||
| 		deviceTemplateRepo: deviceTemplateRepo, |  | ||||||
| 		deviceService: deviceService, | 		deviceService: deviceService, | ||||||
| 		logger:        logger, | 		logger:        logger, | ||||||
| 	} | 	} | ||||||
| @@ -54,58 +40,22 @@ func NewController( | |||||||
| // @Param        device body dto.CreateDeviceRequest true "设备信息" | // @Param        device body dto.CreateDeviceRequest true "设备信息" | ||||||
| // @Success      200 {object} controller.Response{data=dto.DeviceResponse} | // @Success      200 {object} controller.Response{data=dto.DeviceResponse} | ||||||
| // @Router       /api/v1/devices [post] | // @Router       /api/v1/devices [post] | ||||||
| func (c *Controller) CreateDevice(ctx *gin.Context) { | func (c *Controller) CreateDevice(ctx echo.Context) error { | ||||||
| 	const actionType = "创建设备" | 	const actionType = "创建设备" | ||||||
| 	var req dto.CreateDeviceRequest | 	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) | 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	propertiesJSON, err := json.Marshal(req.Properties) | 	resp, err := c.deviceService.CreateDevice(&req) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		c.logger.Errorf("%s: 序列化属性失败: %v", actionType, err) | 		c.logger.Errorf("%s: 服务层创建失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "属性字段格式错误", actionType, "属性序列化失败", req.Properties) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "创建设备失败: "+err.Error(), actionType, "服务层创建失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	device := &models.Device{ | 	c.logger.Infof("%s: 设备创建成功, ID: %d", actionType, resp.ID) | ||||||
| 		Name:             req.Name, | 	return controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "设备创建成功", resp, actionType, "设备创建成功", resp) | ||||||
| 		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) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // GetDevice godoc | // GetDevice godoc | ||||||
| @@ -117,42 +67,22 @@ func (c *Controller) CreateDevice(ctx *gin.Context) { | |||||||
| // @Param        id path string true "设备ID" | // @Param        id path string true "设备ID" | ||||||
| // @Success      200 {object} controller.Response{data=dto.DeviceResponse} | // @Success      200 {object} controller.Response{data=dto.DeviceResponse} | ||||||
| // @Router       /api/v1/devices/{id} [get] | // @Router       /api/v1/devices/{id} [get] | ||||||
| func (c *Controller) GetDevice(ctx *gin.Context) { | func (c *Controller) GetDevice(ctx echo.Context) error { | ||||||
| 	const actionType = "获取设备" | 	const actionType = "获取设备" | ||||||
| 	deviceID := ctx.Param("id") | 	deviceID := ctx.Param("id") | ||||||
|  |  | ||||||
| 	if deviceID == "" { | 	resp, err := c.deviceService.GetDevice(deviceID) | ||||||
| 		c.logger.Errorf("%s: 设备ID为空", actionType) |  | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "设备ID不能为空", actionType, "设备ID为空", nil) |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	device, err := c.deviceRepo.FindByIDString(deviceID) |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if errors.Is(err, gorm.ErrRecordNotFound) { | 		if errors.Is(err, gorm.ErrRecordNotFound) { | ||||||
| 			c.logger.Warnf("%s: 设备不存在, ID: %s", actionType, deviceID) | 			c.logger.Warnf("%s: 设备不存在, ID: %s", actionType, deviceID) | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备未找到", actionType, "设备不存在", deviceID) | 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备未找到", actionType, "设备不存在", deviceID) | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
| 		if strings.Contains(err.Error(), "无效的设备ID格式") { | 		c.logger.Errorf("%s: 服务层获取失败: %v, ID: %s", actionType, err, deviceID) | ||||||
| 			c.logger.Errorf("%s: 设备ID格式错误: %v, ID: %s", actionType, err, deviceID) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取设备信息失败: "+err.Error(), actionType, "服务层获取失败", 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 |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	resp, err := dto.NewDeviceResponse(device) | 	c.logger.Infof("%s: 获取设备信息成功, ID: %d", actionType, resp.ID) | ||||||
| 	if err != nil { | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取设备信息成功", resp, actionType, "获取设备信息成功", resp) | ||||||
| 		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) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // ListDevices godoc | // ListDevices godoc | ||||||
| @@ -163,24 +93,16 @@ func (c *Controller) GetDevice(ctx *gin.Context) { | |||||||
| // @Produce      json | // @Produce      json | ||||||
| // @Success      200 {object} controller.Response{data=[]dto.DeviceResponse} | // @Success      200 {object} controller.Response{data=[]dto.DeviceResponse} | ||||||
| // @Router       /api/v1/devices [get] | // @Router       /api/v1/devices [get] | ||||||
| func (c *Controller) ListDevices(ctx *gin.Context) { | func (c *Controller) ListDevices(ctx echo.Context) error { | ||||||
| 	const actionType = "获取设备列表" | 	const actionType = "获取设备列表" | ||||||
| 	devices, err := c.deviceRepo.ListAll() | 	resp, err := c.deviceService.ListDevices() | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		c.logger.Errorf("%s: 数据库查询失败: %v", actionType, err) | 		c.logger.Errorf("%s: 服务层获取列表失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取设备列表失败: "+err.Error(), actionType, "数据库查询失败", nil) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取设备列表失败: "+err.Error(), actionType, "服务层获取列表失败", nil) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	resp, err := dto.NewListDeviceResponse(devices) | 	c.logger.Infof("%s: 获取设备列表成功, 数量: %d", actionType, len(resp)) | ||||||
| 	if err != nil { | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取设备列表成功", resp, actionType, "获取设备列表成功", resp) | ||||||
| 		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) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // UpdateDevice godoc | // UpdateDevice godoc | ||||||
| @@ -194,75 +116,28 @@ func (c *Controller) ListDevices(ctx *gin.Context) { | |||||||
| // @Param        device body dto.UpdateDeviceRequest true "要更新的设备信息" | // @Param        device body dto.UpdateDeviceRequest true "要更新的设备信息" | ||||||
| // @Success      200 {object} controller.Response{data=dto.DeviceResponse} | // @Success      200 {object} controller.Response{data=dto.DeviceResponse} | ||||||
| // @Router       /api/v1/devices/{id} [put] | // @Router       /api/v1/devices/{id} [put] | ||||||
| func (c *Controller) UpdateDevice(ctx *gin.Context) { | func (c *Controller) UpdateDevice(ctx echo.Context) error { | ||||||
| 	const actionType = "更新设备" | 	const actionType = "更新设备" | ||||||
| 	deviceID := ctx.Param("id") | 	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 err != nil { | ||||||
| 		if errors.Is(err, gorm.ErrRecordNotFound) { | 		if errors.Is(err, gorm.ErrRecordNotFound) { | ||||||
| 			c.logger.Warnf("%s: 设备不存在, ID: %s", actionType, deviceID) | 			c.logger.Warnf("%s: 设备不存在, ID: %s", actionType, deviceID) | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备未找到", actionType, "设备不存在", deviceID) | 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备未找到", actionType, "设备不存在", deviceID) | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
| 		if strings.Contains(err.Error(), "无效的设备ID格式") { | 		c.logger.Errorf("%s: 服务层更新失败: %v, ID: %s", actionType, err, deviceID) | ||||||
| 			c.logger.Errorf("%s: 设备ID格式错误: %v, ID: %s", actionType, err, deviceID) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新设备失败: "+err.Error(), actionType, "服务层更新失败", 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 |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	var req dto.UpdateDeviceRequest | 	c.logger.Infof("%s: 设备更新成功, ID: %d", actionType, resp.ID) | ||||||
| 	if err := ctx.ShouldBindJSON(&req); err != nil { | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "设备更新成功", resp, actionType, "设备更新成功", resp) | ||||||
| 		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) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // DeleteDevice godoc | // DeleteDevice godoc | ||||||
| @@ -274,37 +149,21 @@ func (c *Controller) UpdateDevice(ctx *gin.Context) { | |||||||
| // @Param        id path string true "设备ID" | // @Param        id path string true "设备ID" | ||||||
| // @Success      200 {object} controller.Response | // @Success      200 {object} controller.Response | ||||||
| // @Router       /api/v1/devices/{id} [delete] | // @Router       /api/v1/devices/{id} [delete] | ||||||
| func (c *Controller) DeleteDevice(ctx *gin.Context) { | func (c *Controller) DeleteDevice(ctx echo.Context) error { | ||||||
| 	const actionType = "删除设备" | 	const actionType = "删除设备" | ||||||
| 	deviceID := ctx.Param("id") | 	deviceID := ctx.Param("id") | ||||||
|  |  | ||||||
| 	idUint, err := strconv.ParseUint(deviceID, 10, 64) | 	if err := c.deviceService.DeleteDevice(deviceID); err != nil { | ||||||
| 	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 errors.Is(err, gorm.ErrRecordNotFound) { | 		if errors.Is(err, gorm.ErrRecordNotFound) { | ||||||
| 			c.logger.Warnf("%s: 设备不存在, ID: %s", actionType, deviceID) | 			c.logger.Warnf("%s: 设备不存在, ID: %s", actionType, deviceID) | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备未找到", actionType, "设备不存在", deviceID) | 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备未找到", actionType, "设备不存在", deviceID) | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
| 		c.logger.Errorf("%s: 查找设备失败: %v, ID: %s", actionType, err, deviceID) | 		c.logger.Errorf("%s: 服务层删除失败: %v, ID: %s", actionType, err, deviceID) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除设备失败: 查找设备时发生内部错误", actionType, "数据库查询失败", deviceID) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除设备失败: "+err.Error(), actionType, "服务层删除失败", deviceID) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if err := c.deviceRepo.Delete(uint(idUint)); err != nil { | 	c.logger.Infof("%s: 设备删除成功, ID: %s", actionType, deviceID) | ||||||
| 		c.logger.Errorf("%s: 数据库删除失败: %v, ID: %d", actionType, err, idUint) | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "设备删除成功", nil, actionType, "设备删除成功", deviceID) | ||||||
| 		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) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // ManualControl godoc | // ManualControl godoc | ||||||
| @@ -318,60 +177,26 @@ func (c *Controller) DeleteDevice(ctx *gin.Context) { | |||||||
| // @Param        manualControl body dto.ManualControlDeviceRequest true "手动控制指令" | // @Param        manualControl body dto.ManualControlDeviceRequest true "手动控制指令" | ||||||
| // @Success      200 {object} controller.Response | // @Success      200 {object} controller.Response | ||||||
| // @Router       /api/v1/devices/manual-control/{id} [post] | // @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 = "手动控制设备" | 	const actionType = "手动控制设备" | ||||||
| 	deviceID := ctx.Param("id") | 	deviceID := ctx.Param("id") | ||||||
|  |  | ||||||
| 	var req dto.ManualControlDeviceRequest | 	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) | 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	dev, err := c.deviceRepo.FindByIDString(deviceID) | 	if err := c.deviceService.ManualControl(deviceID, &req); err != nil { | ||||||
| 	if err != nil { |  | ||||||
| 		if errors.Is(err, gorm.ErrRecordNotFound) { | 		if errors.Is(err, gorm.ErrRecordNotFound) { | ||||||
| 			c.logger.Warnf("%s: 设备不存在, ID: %s", actionType, deviceID) | 			c.logger.Warnf("%s: 设备不存在, ID: %s", actionType, deviceID) | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备未找到", actionType, "设备不存在", deviceID) | 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备未找到", actionType, "设备不存在", deviceID) | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
| 		if strings.Contains(err.Error(), "无效的设备ID格式") { | 		c.logger.Errorf("%s: 服务层手动控制失败: %v, ID: %s", actionType, err, deviceID) | ||||||
| 			c.logger.Errorf("%s: 设备ID格式错误: %v, ID: %s", actionType, err, deviceID) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "手动控制失败: "+err.Error(), actionType, "服务层手动控制失败", 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.Infof("%s: 接收到指令, 设备ID: %s, 动作: %s", actionType, deviceID, req.Action) | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "指令已发送", nil, actionType, "指令发送成功", nil) | ||||||
| 	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}) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // --- Controller Methods: Area Controllers --- | // --- Controller Methods: Area Controllers --- | ||||||
| @@ -386,50 +211,22 @@ func (c *Controller) ManualControl(ctx *gin.Context) { | |||||||
| // @Param        areaController body dto.CreateAreaControllerRequest true "区域主控信息" | // @Param        areaController body dto.CreateAreaControllerRequest true "区域主控信息" | ||||||
| // @Success      200 {object} controller.Response{data=dto.AreaControllerResponse} | // @Success      200 {object} controller.Response{data=dto.AreaControllerResponse} | ||||||
| // @Router       /api/v1/area-controllers [post] | // @Router       /api/v1/area-controllers [post] | ||||||
| func (c *Controller) CreateAreaController(ctx *gin.Context) { | func (c *Controller) CreateAreaController(ctx echo.Context) error { | ||||||
| 	const actionType = "创建区域主控" | 	const actionType = "创建区域主控" | ||||||
| 	var req dto.CreateAreaControllerRequest | 	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) | 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	propertiesJSON, err := json.Marshal(req.Properties) | 	resp, err := c.deviceService.CreateAreaController(&req) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		c.logger.Errorf("%s: 序列化属性失败: %v", actionType, err) | 		c.logger.Errorf("%s: 服务层创建失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "属性字段格式错误", actionType, "属性序列化失败", req.Properties) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "创建区域主控失败: "+err.Error(), actionType, "服务层创建失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	ac := &models.AreaController{ | 	c.logger.Infof("%s: 区域主控创建成功, ID: %d", actionType, resp.ID) | ||||||
| 		Name:       req.Name, | 	return controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "区域主控创建成功", resp, actionType, "区域主控创建成功", resp) | ||||||
| 		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) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // GetAreaController godoc | // GetAreaController godoc | ||||||
| @@ -441,38 +238,22 @@ func (c *Controller) CreateAreaController(ctx *gin.Context) { | |||||||
| // @Param        id path string true "区域主控ID" | // @Param        id path string true "区域主控ID" | ||||||
| // @Success      200 {object} controller.Response{data=dto.AreaControllerResponse} | // @Success      200 {object} controller.Response{data=dto.AreaControllerResponse} | ||||||
| // @Router       /api/v1/area-controllers/{id} [get] | // @Router       /api/v1/area-controllers/{id} [get] | ||||||
| func (c *Controller) GetAreaController(ctx *gin.Context) { | func (c *Controller) GetAreaController(ctx echo.Context) error { | ||||||
| 	const actionType = "获取区域主控" | 	const actionType = "获取区域主控" | ||||||
| 	acID := ctx.Param("id") | 	acID := ctx.Param("id") | ||||||
|  |  | ||||||
| 	idUint, err := strconv.ParseUint(acID, 10, 64) | 	resp, err := c.deviceService.GetAreaController(acID) | ||||||
| 	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)) |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if errors.Is(err, gorm.ErrRecordNotFound) { | 		if errors.Is(err, gorm.ErrRecordNotFound) { | ||||||
| 			c.logger.Warnf("%s: 区域主控不存在, ID: %s", actionType, acID) | 			c.logger.Warnf("%s: 区域主控不存在, ID: %s", actionType, acID) | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "区域主控未找到", actionType, "区域主控不存在", acID) | 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "区域主控未找到", actionType, "区域主控不存在", acID) | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
| 		c.logger.Errorf("%s: 数据库查询失败: %v, ID: %s", actionType, err, acID) | 		c.logger.Errorf("%s: 服务层获取失败: %v, ID: %s", actionType, err, acID) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取区域主控信息失败: "+err.Error(), actionType, "数据库查询失败", acID) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取区域主控信息失败: "+err.Error(), actionType, "服务层获取失败", acID) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	resp, err := dto.NewAreaControllerResponse(ac) | 	c.logger.Infof("%s: 获取区域主控信息成功, ID: %d", actionType, resp.ID) | ||||||
| 	if err != nil { | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取区域主控信息成功", resp, actionType, "获取区域主控信息成功", resp) | ||||||
| 		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) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // ListAreaControllers godoc | // ListAreaControllers godoc | ||||||
| @@ -483,24 +264,16 @@ func (c *Controller) GetAreaController(ctx *gin.Context) { | |||||||
| // @Produce      json | // @Produce      json | ||||||
| // @Success      200 {object} controller.Response{data=[]dto.AreaControllerResponse} | // @Success      200 {object} controller.Response{data=[]dto.AreaControllerResponse} | ||||||
| // @Router       /api/v1/area-controllers [get] | // @Router       /api/v1/area-controllers [get] | ||||||
| func (c *Controller) ListAreaControllers(ctx *gin.Context) { | func (c *Controller) ListAreaControllers(ctx echo.Context) error { | ||||||
| 	const actionType = "获取区域主控列表" | 	const actionType = "获取区域主控列表" | ||||||
| 	acs, err := c.areaControllerRepo.ListAll() | 	resp, err := c.deviceService.ListAreaControllers() | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		c.logger.Errorf("%s: 数据库查询失败: %v", actionType, err) | 		c.logger.Errorf("%s: 服务层获取列表失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取区域主控列表失败: "+err.Error(), actionType, "数据库查询失败", nil) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取区域主控列表失败: "+err.Error(), actionType, "服务层获取列表失败", nil) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	resp, err := dto.NewListAreaControllerResponse(acs) | 	c.logger.Infof("%s: 获取区域主控列表成功, 数量: %d", actionType, len(resp)) | ||||||
| 	if err != nil { | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取区域主控列表成功", resp, actionType, "获取区域主控列表成功", resp) | ||||||
| 		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) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // UpdateAreaController godoc | // UpdateAreaController godoc | ||||||
| @@ -514,69 +287,28 @@ func (c *Controller) ListAreaControllers(ctx *gin.Context) { | |||||||
| // @Param        areaController body dto.UpdateAreaControllerRequest true "要更新的区域主控信息" | // @Param        areaController body dto.UpdateAreaControllerRequest true "要更新的区域主控信息" | ||||||
| // @Success      200 {object} controller.Response{data=dto.AreaControllerResponse} | // @Success      200 {object} controller.Response{data=dto.AreaControllerResponse} | ||||||
| // @Router       /api/v1/area-controllers/{id} [put] | // @Router       /api/v1/area-controllers/{id} [put] | ||||||
| func (c *Controller) UpdateAreaController(ctx *gin.Context) { | func (c *Controller) UpdateAreaController(ctx echo.Context) error { | ||||||
| 	const actionType = "更新区域主控" | 	const actionType = "更新区域主控" | ||||||
| 	acID := ctx.Param("id") | 	acID := ctx.Param("id") | ||||||
|  |  | ||||||
| 	idUint, err := strconv.ParseUint(acID, 10, 64) | 	var req dto.UpdateAreaControllerRequest | ||||||
| 	if err != nil { | 	if err := ctx.Bind(&req); err != nil { | ||||||
| 		c.logger.Errorf("%s: 区域主控ID格式错误: %v, ID: %s", actionType, err, acID) | 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的区域主控ID格式", actionType, "ID格式错误", acID) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	existingAC, err := c.areaControllerRepo.FindByID(uint(idUint)) | 	resp, err := c.deviceService.UpdateAreaController(acID, &req) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if errors.Is(err, gorm.ErrRecordNotFound) { | 		if errors.Is(err, gorm.ErrRecordNotFound) { | ||||||
| 			c.logger.Warnf("%s: 区域主控不存在, ID: %s", actionType, acID) | 			c.logger.Warnf("%s: 区域主控不存在, ID: %s", actionType, acID) | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "区域主控未找到", actionType, "区域主控不存在", acID) | 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "区域主控未找到", actionType, "区域主控不存在", acID) | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
| 		c.logger.Errorf("%s: 数据库查询失败: %v, ID: %s", actionType, err, acID) | 		c.logger.Errorf("%s: 服务层更新失败: %v, ID: %s", actionType, err, acID) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新区域主控失败: "+err.Error(), actionType, "数据库查询失败", acID) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新区域主控失败: "+err.Error(), actionType, "服务层更新失败", acID) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	var req dto.UpdateAreaControllerRequest | 	c.logger.Infof("%s: 区域主控更新成功, ID: %d", actionType, resp.ID) | ||||||
| 	if err := ctx.ShouldBindJSON(&req); err != nil { | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "区域主控更新成功", resp, actionType, "区域主控更新成功", resp) | ||||||
| 		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) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // DeleteAreaController godoc | // DeleteAreaController godoc | ||||||
| @@ -588,37 +320,21 @@ func (c *Controller) UpdateAreaController(ctx *gin.Context) { | |||||||
| // @Param        id path string true "区域主控ID" | // @Param        id path string true "区域主控ID" | ||||||
| // @Success      200 {object} controller.Response | // @Success      200 {object} controller.Response | ||||||
| // @Router       /api/v1/area-controllers/{id} [delete] | // @Router       /api/v1/area-controllers/{id} [delete] | ||||||
| func (c *Controller) DeleteAreaController(ctx *gin.Context) { | func (c *Controller) DeleteAreaController(ctx echo.Context) error { | ||||||
| 	const actionType = "删除区域主控" | 	const actionType = "删除区域主控" | ||||||
| 	acID := ctx.Param("id") | 	acID := ctx.Param("id") | ||||||
|  |  | ||||||
| 	idUint, err := strconv.ParseUint(acID, 10, 64) | 	if err := c.deviceService.DeleteAreaController(acID); err != nil { | ||||||
| 	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 errors.Is(err, gorm.ErrRecordNotFound) { | 		if errors.Is(err, gorm.ErrRecordNotFound) { | ||||||
| 			c.logger.Warnf("%s: 区域主控不存在, ID: %s", actionType, acID) | 			c.logger.Warnf("%s: 区域主控不存在, ID: %s", actionType, acID) | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "区域主控未找到", actionType, "区域主控不存在", acID) | 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "区域主控未找到", actionType, "区域主控不存在", acID) | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
| 		c.logger.Errorf("%s: 查找区域主控失败: %v, ID: %s", actionType, err, acID) | 		c.logger.Errorf("%s: 服务层删除失败: %v, ID: %s", actionType, err, acID) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除区域主控失败: 查找时发生内部错误", actionType, "数据库查询失败", acID) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除区域主控失败: "+err.Error(), actionType, "服务层删除失败", acID) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if err := c.areaControllerRepo.Delete(uint(idUint)); err != nil { | 	c.logger.Infof("%s: 区域主控删除成功, ID: %s", actionType, acID) | ||||||
| 		c.logger.Errorf("%s: 数据库删除失败: %v, ID: %d", actionType, err, idUint) | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "区域主控删除成功", nil, actionType, "区域主控删除成功", acID) | ||||||
| 		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) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // --- Controller Methods: Device Templates --- | // --- Controller Methods: Device Templates --- | ||||||
| @@ -633,59 +349,22 @@ func (c *Controller) DeleteAreaController(ctx *gin.Context) { | |||||||
| // @Param        deviceTemplate body dto.CreateDeviceTemplateRequest true "设备模板信息" | // @Param        deviceTemplate body dto.CreateDeviceTemplateRequest true "设备模板信息" | ||||||
| // @Success      200 {object} controller.Response{data=dto.DeviceTemplateResponse} | // @Success      200 {object} controller.Response{data=dto.DeviceTemplateResponse} | ||||||
| // @Router       /api/v1/device-templates [post] | // @Router       /api/v1/device-templates [post] | ||||||
| func (c *Controller) CreateDeviceTemplate(ctx *gin.Context) { | func (c *Controller) CreateDeviceTemplate(ctx echo.Context) error { | ||||||
| 	const actionType = "创建设备模板" | 	const actionType = "创建设备模板" | ||||||
| 	var req dto.CreateDeviceTemplateRequest | 	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) | 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	commandsJSON, err := json.Marshal(req.Commands) | 	resp, err := c.deviceService.CreateDeviceTemplate(&req) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		c.logger.Errorf("%s: 序列化命令失败: %v", actionType, err) | 		c.logger.Errorf("%s: 服务层创建失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "命令字段格式错误", actionType, "命令序列化失败", req.Commands) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "创建设备模板失败: "+err.Error(), actionType, "服务层创建失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	valuesJSON, err := json.Marshal(req.Values) | 	c.logger.Infof("%s: 设备模板创建成功, ID: %d", actionType, resp.ID) | ||||||
| 	if err != nil { | 	return controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "设备模板创建成功", resp, actionType, "设备模板创建成功", resp) | ||||||
| 		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) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // GetDeviceTemplate godoc | // GetDeviceTemplate godoc | ||||||
| @@ -697,38 +376,22 @@ func (c *Controller) CreateDeviceTemplate(ctx *gin.Context) { | |||||||
| // @Param        id path string true "设备模板ID" | // @Param        id path string true "设备模板ID" | ||||||
| // @Success      200 {object} controller.Response{data=dto.DeviceTemplateResponse} | // @Success      200 {object} controller.Response{data=dto.DeviceTemplateResponse} | ||||||
| // @Router       /api/v1/device-templates/{id} [get] | // @Router       /api/v1/device-templates/{id} [get] | ||||||
| func (c *Controller) GetDeviceTemplate(ctx *gin.Context) { | func (c *Controller) GetDeviceTemplate(ctx echo.Context) error { | ||||||
| 	const actionType = "获取设备模板" | 	const actionType = "获取设备模板" | ||||||
| 	dtID := ctx.Param("id") | 	dtID := ctx.Param("id") | ||||||
|  |  | ||||||
| 	idUint, err := strconv.ParseUint(dtID, 10, 64) | 	resp, err := c.deviceService.GetDeviceTemplate(dtID) | ||||||
| 	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)) |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if errors.Is(err, gorm.ErrRecordNotFound) { | 		if errors.Is(err, gorm.ErrRecordNotFound) { | ||||||
| 			c.logger.Warnf("%s: 设备模板不存在, ID: %s", actionType, dtID) | 			c.logger.Warnf("%s: 设备模板不存在, ID: %s", actionType, dtID) | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备模板未找到", actionType, "设备模板不存在", dtID) | 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备模板未找到", actionType, "设备模板不存在", dtID) | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
| 		c.logger.Errorf("%s: 数据库查询失败: %v, ID: %s", actionType, err, dtID) | 		c.logger.Errorf("%s: 服务层获取失败: %v, ID: %s", actionType, err, dtID) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取设备模板信息失败: "+err.Error(), actionType, "数据库查询失败", dtID) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取设备模板信息失败: "+err.Error(), actionType, "服务层获取失败", dtID) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	resp, err := dto.NewDeviceTemplateResponse(deviceTemplate) | 	c.logger.Infof("%s: 获取设备模板信息成功, ID: %d", actionType, resp.ID) | ||||||
| 	if err != nil { | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取设备模板信息成功", resp, actionType, "获取设备模板信息成功", resp) | ||||||
| 		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) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // ListDeviceTemplates godoc | // ListDeviceTemplates godoc | ||||||
| @@ -739,24 +402,16 @@ func (c *Controller) GetDeviceTemplate(ctx *gin.Context) { | |||||||
| // @Produce      json | // @Produce      json | ||||||
| // @Success      200 {object} controller.Response{data=[]dto.DeviceTemplateResponse} | // @Success      200 {object} controller.Response{data=[]dto.DeviceTemplateResponse} | ||||||
| // @Router       /api/v1/device-templates [get] | // @Router       /api/v1/device-templates [get] | ||||||
| func (c *Controller) ListDeviceTemplates(ctx *gin.Context) { | func (c *Controller) ListDeviceTemplates(ctx echo.Context) error { | ||||||
| 	const actionType = "获取设备模板列表" | 	const actionType = "获取设备模板列表" | ||||||
| 	deviceTemplates, err := c.deviceTemplateRepo.ListAll() | 	resp, err := c.deviceService.ListDeviceTemplates() | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		c.logger.Errorf("%s: 数据库查询失败: %v", actionType, err) | 		c.logger.Errorf("%s: 服务层获取列表失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取设备模板列表失败: "+err.Error(), actionType, "数据库查询失败", nil) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取设备模板列表失败: "+err.Error(), actionType, "服务层获取列表失败", nil) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	resp, err := dto.NewListDeviceTemplateResponse(deviceTemplates) | 	c.logger.Infof("%s: 获取设备模板列表成功, 数量: %d", actionType, len(resp)) | ||||||
| 	if err != nil { | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取设备模板列表成功", resp, actionType, "获取设备模板列表成功", resp) | ||||||
| 		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) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // UpdateDeviceTemplate godoc | // UpdateDeviceTemplate godoc | ||||||
| @@ -770,78 +425,28 @@ func (c *Controller) ListDeviceTemplates(ctx *gin.Context) { | |||||||
| // @Param        deviceTemplate body dto.UpdateDeviceTemplateRequest true "要更新的设备模板信息" | // @Param        deviceTemplate body dto.UpdateDeviceTemplateRequest true "要更新的设备模板信息" | ||||||
| // @Success      200 {object} controller.Response{data=dto.DeviceTemplateResponse} | // @Success      200 {object} controller.Response{data=dto.DeviceTemplateResponse} | ||||||
| // @Router       /api/v1/device-templates/{id} [put] | // @Router       /api/v1/device-templates/{id} [put] | ||||||
| func (c *Controller) UpdateDeviceTemplate(ctx *gin.Context) { | func (c *Controller) UpdateDeviceTemplate(ctx echo.Context) error { | ||||||
| 	const actionType = "更新设备模板" | 	const actionType = "更新设备模板" | ||||||
| 	dtID := ctx.Param("id") | 	dtID := ctx.Param("id") | ||||||
|  |  | ||||||
| 	idUint, err := strconv.ParseUint(dtID, 10, 64) | 	var req dto.UpdateDeviceTemplateRequest | ||||||
| 	if err != nil { | 	if err := ctx.Bind(&req); err != nil { | ||||||
| 		c.logger.Errorf("%s: 设备模板ID格式错误: %v, ID: %s", actionType, err, dtID) | 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的设备模板ID格式", actionType, "ID格式错误", dtID) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	existingDeviceTemplate, err := c.deviceTemplateRepo.FindByID(uint(idUint)) | 	resp, err := c.deviceService.UpdateDeviceTemplate(dtID, &req) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if errors.Is(err, gorm.ErrRecordNotFound) { | 		if errors.Is(err, gorm.ErrRecordNotFound) { | ||||||
| 			c.logger.Warnf("%s: 设备模板不存在, ID: %s", actionType, dtID) | 			c.logger.Warnf("%s: 设备模板不存在, ID: %s", actionType, dtID) | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备模板未找到", actionType, "设备模板不存在", dtID) | 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备模板未找到", actionType, "设备模板不存在", dtID) | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
| 		c.logger.Errorf("%s: 数据库查询失败: %v, ID: %s", actionType, err, dtID) | 		c.logger.Errorf("%s: 服务层更新失败: %v, ID: %s", actionType, err, dtID) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新设备模板失败: "+err.Error(), actionType, "数据库查询失败", dtID) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新设备模板失败: "+err.Error(), actionType, "服务层更新失败", dtID) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	var req dto.UpdateDeviceTemplateRequest | 	c.logger.Infof("%s: 设备模板更新成功, ID: %d", actionType, resp.ID) | ||||||
| 	if err := ctx.ShouldBindJSON(&req); err != nil { | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "设备模板更新成功", resp, actionType, "设备模板更新成功", resp) | ||||||
| 		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) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // DeleteDeviceTemplate godoc | // DeleteDeviceTemplate godoc | ||||||
| @@ -853,43 +458,19 @@ func (c *Controller) UpdateDeviceTemplate(ctx *gin.Context) { | |||||||
| // @Param        id path string true "设备模板ID" | // @Param        id path string true "设备模板ID" | ||||||
| // @Success      200 {object} controller.Response | // @Success      200 {object} controller.Response | ||||||
| // @Router       /api/v1/device-templates/{id} [delete] | // @Router       /api/v1/device-templates/{id} [delete] | ||||||
| func (c *Controller) DeleteDeviceTemplate(ctx *gin.Context) { | func (c *Controller) DeleteDeviceTemplate(ctx echo.Context) error { | ||||||
| 	const actionType = "删除设备模板" | 	const actionType = "删除设备模板" | ||||||
| 	dtID := ctx.Param("id") | 	dtID := ctx.Param("id") | ||||||
|  |  | ||||||
| 	idUint, err := strconv.ParseUint(dtID, 10, 64) | 	if err := c.deviceService.DeleteDeviceTemplate(dtID); err != nil { | ||||||
| 	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 errors.Is(err, gorm.ErrRecordNotFound) { | 		if errors.Is(err, gorm.ErrRecordNotFound) { | ||||||
| 			c.logger.Warnf("%s: 设备模板不存在, ID: %s", actionType, dtID) | 			c.logger.Warnf("%s: 设备模板不存在, ID: %s", actionType, dtID) | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备模板未找到", actionType, "设备模板不存在", dtID) | 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "设备模板未找到", actionType, "设备模板不存在", dtID) | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
| 		c.logger.Errorf("%s: 查找设备模板失败: %v, ID: %s", actionType, err, dtID) | 		c.logger.Errorf("%s: 服务层删除失败: %v, ID: %s", actionType, err, dtID) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除设备模板失败: 查找时发生内部错误", actionType, "数据库查询失败", dtID) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除设备模板失败: "+err.Error(), actionType, "服务层删除失败", dtID) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// 调用仓库层的删除方法,该方法会检查模板是否被使用 | 	c.logger.Infof("%s: 设备模板删除成功, ID: %s", actionType, dtID) | ||||||
| 	if err := c.deviceTemplateRepo.Delete(uint(idUint)); err != nil { | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "设备模板删除成功", nil, actionType, "设备模板删除成功", dtID) | ||||||
| 		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) |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -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/controller" | ||||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/service" | 	"git.huangwc.com/pig/pig-farm-controller/internal/app/service" | ||||||
| 	"github.com/gin-gonic/gin" | 	"github.com/labstack/echo/v4" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| // mapAndSendError 统一映射服务层错误并发送响应。 | // mapAndSendError 统一映射服务层错误并发送响应。 | ||||||
| // 这个函数将服务层返回的错误转换为控制器层应返回的HTTP状态码和审计信息。 | // 这个函数将服务层返回的错误转换为控制器层应返回的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) || | 	if errors.Is(err, service.ErrPigBatchNotFound) || | ||||||
| 		errors.Is(err, service.ErrPenNotFound) || | 		errors.Is(err, service.ErrPenNotFound) || | ||||||
| 		errors.Is(err, service.ErrPenNotAssociatedWithBatch) { | 		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) || | 	} else if errors.Is(err, service.ErrInvalidOperation) || | ||||||
| 		errors.Is(err, service.ErrPigBatchActive) || | 		errors.Is(err, service.ErrPigBatchActive) || | ||||||
| 		errors.Is(err, service.ErrPigBatchNotActive) || | 		errors.Is(err, service.ErrPigBatchNotActive) || | ||||||
| 		errors.Is(err, service.ErrPenOccupiedByOtherBatch) || | 		errors.Is(err, service.ErrPenOccupiedByOtherBatch) || | ||||||
| 		errors.Is(err, service.ErrPenStatusInvalidForAllocation) || | 		errors.Is(err, service.ErrPenStatusInvalidForAllocation) || | ||||||
| 		errors.Is(err, service.ErrPenNotEmpty) { | 		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 { | 	} else { | ||||||
| 		c.logger.Errorf("操作[%s]业务逻辑失败: %v", action, err) | 		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。 | // idExtractorFunc 定义了一个函数类型,用于从echo.Context中提取主ID。 | ||||||
| type idExtractorFunc func(ctx *gin.Context) (uint, error) | type idExtractorFunc func(ctx echo.Context) (uint, error) | ||||||
|  |  | ||||||
| // extractOperatorAndPrimaryID 封装了从gin.Context中提取操作员ID和主ID的通用逻辑。 | // extractOperatorAndPrimaryID 封装了从echo.Context中提取操作员ID和主ID的通用逻辑。 | ||||||
| // 它负责处理ID提取过程中的错误,并发送相应的HTTP响应。 | // 它负责处理ID提取过程中的错误,并发送相应的HTTP响应。 | ||||||
| // | // | ||||||
| // 参数: | // 参数: | ||||||
| // | // | ||||||
| //	c: *PigBatchController - 控制器实例,用于访问其日志。 | //	c: *PigBatchController - 控制器实例,用于访问其日志。 | ||||||
| //	ctx: *gin.Context - Gin上下文。 | //	ctx: echo.Context - Echo上下文。 | ||||||
| //	action: string - 当前操作的描述,用于日志和审计。 | //	action: string - 当前操作的描述,用于日志和审计。 | ||||||
| //	idExtractor: idExtractorFunc - 可选函数,用于从ctx中提取主ID。如果为nil,则尝试从":id"路径参数中提取。 | //	idExtractor: idExtractorFunc - 可选函数,用于从ctx中提取主ID。如果为nil,则尝试从":id"路径参数中提取。 | ||||||
| // | // | ||||||
| @@ -47,26 +47,24 @@ type idExtractorFunc func(ctx *gin.Context) (uint, error) | |||||||
| // | // | ||||||
| //	operatorID: uint - 提取到的操作员ID。 | //	operatorID: uint - 提取到的操作员ID。 | ||||||
| //	primaryID: uint - 提取到的主ID。 | //	primaryID: uint - 提取到的主ID。 | ||||||
| //	ok: bool - 如果ID提取成功且没有发送错误响应,则为true。 | //	err: error - 如果ID提取失败或发送错误响应,则返回错误。 | ||||||
| func extractOperatorAndPrimaryID( | func extractOperatorAndPrimaryID( | ||||||
| 	c *PigBatchController, | 	c *PigBatchController, | ||||||
| 	ctx *gin.Context, | 	ctx echo.Context, | ||||||
| 	action string, | 	action string, | ||||||
| 	idExtractor idExtractorFunc, | 	idExtractor idExtractorFunc, | ||||||
| ) (operatorID uint, primaryID uint, ok bool) { | ) (operatorID uint, primaryID uint, err error) { | ||||||
| 	// 1. 获取操作员ID | 	// 1. 获取操作员ID | ||||||
| 	operatorID, err := controller.GetOperatorIDFromContext(ctx) | 	operatorID, err = controller.GetOperatorIDFromContext(ctx) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeUnauthorized, "未授权", action, "无法获取操作员ID", nil) | 		return 0, 0, controller.SendErrorWithAudit(ctx, controller.CodeUnauthorized, "未授权", action, "无法获取操作员ID", nil) | ||||||
| 		return 0, 0, false |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// 2. 提取主ID | 	// 2. 提取主ID | ||||||
| 	if idExtractor != nil { | 	if idExtractor != nil { | ||||||
| 		primaryID, err = idExtractor(ctx) | 		primaryID, err = idExtractor(ctx) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", err.Error()) | 			return 0, 0, controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", err.Error()) | ||||||
| 			return 0, 0, false |  | ||||||
| 		} | 		} | ||||||
| 	} else { // 默认从 ":id" 路径参数提取 | 	} else { // 默认从 ":id" 路径参数提取 | ||||||
| 		idParam := ctx.Param("id") | 		idParam := ctx.Param("id") | ||||||
| @@ -75,165 +73,155 @@ func extractOperatorAndPrimaryID( | |||||||
| 		} else { | 		} else { | ||||||
| 			parsedID, err := strconv.ParseUint(idParam, 10, 32) | 			parsedID, err := strconv.ParseUint(idParam, 10, 32) | ||||||
| 			if err != nil { | 			if err != nil { | ||||||
| 				controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", idParam) | 				return 0, 0, controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", idParam) | ||||||
| 				return 0, 0, false |  | ||||||
| 			} | 			} | ||||||
| 			primaryID = uint(parsedID) | 			primaryID = uint(parsedID) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	return operatorID, primaryID, true | 	return operatorID, primaryID, nil | ||||||
| } | } | ||||||
|  |  | ||||||
| // handleAPIRequest 封装了控制器中处理带有请求体和路径参数的API请求的通用逻辑。 | // handleAPIRequest 封装了控制器中处理带有请求体和路径参数的API请求的通用逻辑。 | ||||||
| // 它负责请求体绑定、操作员ID获取、服务层调用、错误映射和响应发送。 | // 它负责请求体绑定、操作员ID获取、服务层调用、错误映射和响应发送。 | ||||||
| func handleAPIRequest[Req any]( | func handleAPIRequest[Req any]( | ||||||
| 	c *PigBatchController, | 	c *PigBatchController, | ||||||
| 	ctx *gin.Context, | 	ctx echo.Context, | ||||||
| 	action string, | 	action string, | ||||||
| 	reqDTO Req, | 	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, | 	successMsg string, | ||||||
| 	idExtractor idExtractorFunc, | 	idExtractor idExtractorFunc, | ||||||
| ) { | ) error { | ||||||
| 	// 1. 绑定请求体 | 	// 1. 绑定请求体 | ||||||
| 	if err := ctx.ShouldBindJSON(&reqDTO); err != nil { | 	if err := ctx.Bind(&reqDTO); err != nil { | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体", action, "请求体绑定失败", reqDTO) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体", action, "请求体绑定失败", reqDTO) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// 2. 提取操作员ID和主ID | 	// 2. 提取操作员ID和主ID | ||||||
| 	operatorID, primaryID, ok := extractOperatorAndPrimaryID(c, ctx, action, idExtractor) | 	operatorID, primaryID, err := extractOperatorAndPrimaryID(c, ctx, action, idExtractor) | ||||||
| 	if !ok { | 	if err != nil { | ||||||
| 		return // 错误已在 extractOperatorAndPrimaryID 中处理 | 		return err // 错误已在 extractOperatorAndPrimaryID 中处理 | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// 3. 执行服务层逻辑 | 	// 3. 执行服务层逻辑 | ||||||
| 	err := serviceExecutor(ctx, operatorID, primaryID, reqDTO) | 	err = serviceExecutor(ctx, operatorID, primaryID, reqDTO) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		mapAndSendError(c, ctx, action, err, primaryID) | 		return mapAndSendError(c, ctx, action, err, primaryID) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// 4. 发送成功响应 | 	// 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请求的通用逻辑。 | // handleNoBodyAPIRequest 封装了处理不带请求体,但有路径参数和操作员ID的API请求的通用逻辑。 | ||||||
| func handleNoBodyAPIRequest( | func handleNoBodyAPIRequest( | ||||||
| 	c *PigBatchController, | 	c *PigBatchController, | ||||||
| 	ctx *gin.Context, | 	ctx echo.Context, | ||||||
| 	action string, | 	action string, | ||||||
| 	serviceExecutor func(ctx *gin.Context, operatorID uint, primaryID uint) error, | 	serviceExecutor func(ctx echo.Context, operatorID uint, primaryID uint) error, | ||||||
| 	successMsg string, | 	successMsg string, | ||||||
| 	idExtractor idExtractorFunc, | 	idExtractor idExtractorFunc, | ||||||
| ) { | ) error { | ||||||
| 	// 1. 提取操作员ID和主ID | 	// 1. 提取操作员ID和主ID | ||||||
| 	operatorID, primaryID, ok := extractOperatorAndPrimaryID(c, ctx, action, idExtractor) | 	operatorID, primaryID, err := extractOperatorAndPrimaryID(c, ctx, action, idExtractor) | ||||||
| 	if !ok { | 	if err != nil { | ||||||
| 		return // 错误已在 extractOperatorAndPrimaryID 中处理 | 		return err // 错误已在 extractOperatorAndPrimaryID 中处理 | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// 2. 执行服务层逻辑 | 	// 2. 执行服务层逻辑 | ||||||
| 	err := serviceExecutor(ctx, operatorID, primaryID) | 	err = serviceExecutor(ctx, operatorID, primaryID) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		mapAndSendError(c, ctx, action, err, primaryID) | 		return mapAndSendError(c, ctx, action, err, primaryID) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// 3. 发送成功响应 | 	// 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请求的通用逻辑。 | // handleAPIRequestWithResponse 封装了控制器中处理带有请求体、路径参数并返回响应DTO的API请求的通用逻辑。 | ||||||
| func handleAPIRequestWithResponse[Req any, Resp any]( | func handleAPIRequestWithResponse[Req any, Resp any]( | ||||||
| 	c *PigBatchController, | 	c *PigBatchController, | ||||||
| 	ctx *gin.Context, | 	ctx echo.Context, | ||||||
| 	action string, | 	action string, | ||||||
| 	reqDTO Req, | 	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, | 	successMsg string, | ||||||
| 	idExtractor idExtractorFunc, | 	idExtractor idExtractorFunc, | ||||||
| ) { | ) error { | ||||||
| 	// 1. 绑定请求体 | 	// 1. 绑定请求体 | ||||||
| 	if err := ctx.ShouldBindJSON(&reqDTO); err != nil { | 	if err := ctx.Bind(&reqDTO); err != nil { | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, fmt.Sprintf("无效的请求体: %v", err), action, fmt.Sprintf("请求体绑定失败: %v", err), reqDTO) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, fmt.Sprintf("无效的请求体: %v", err), action, fmt.Sprintf("请求体绑定失败: %v", err), reqDTO) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// 2. 提取操作员ID和主ID | 	// 2. 提取操作员ID和主ID | ||||||
| 	operatorID, primaryID, ok := extractOperatorAndPrimaryID(c, ctx, action, idExtractor) | 	operatorID, primaryID, err := extractOperatorAndPrimaryID(c, ctx, action, idExtractor) | ||||||
| 	if !ok { | 	if err != nil { | ||||||
| 		return // 错误已在 extractOperatorAndPrimaryID 中处理 | 		return err // 错误已在 extractOperatorAndPrimaryID 中处理 | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// 3. 执行服务层逻辑 | 	// 3. 执行服务层逻辑 | ||||||
| 	respDTO, err := serviceExecutor(ctx, operatorID, primaryID, reqDTO) | 	respDTO, err := serviceExecutor(ctx, operatorID, primaryID, reqDTO) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		mapAndSendError(c, ctx, action, err, primaryID) | 		return mapAndSendError(c, ctx, action, err, primaryID) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// 4. 发送成功响应 | 	// 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请求的通用逻辑。 | // handleNoBodyAPIRequestWithResponse 封装了处理不带请求体,但有路径参数和操作员ID,并返回响应DTO的API请求的通用逻辑。 | ||||||
| func handleNoBodyAPIRequestWithResponse[Resp any]( | func handleNoBodyAPIRequestWithResponse[Resp any]( | ||||||
| 	c *PigBatchController, | 	c *PigBatchController, | ||||||
| 	ctx *gin.Context, | 	ctx echo.Context, | ||||||
| 	action string, | 	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, | 	successMsg string, | ||||||
| 	idExtractor idExtractorFunc, | 	idExtractor idExtractorFunc, | ||||||
| ) { | ) error { | ||||||
| 	// 1. 提取操作员ID和主ID | 	// 1. 提取操作员ID和主ID | ||||||
| 	operatorID, primaryID, ok := extractOperatorAndPrimaryID(c, ctx, action, idExtractor) | 	operatorID, primaryID, err := extractOperatorAndPrimaryID(c, ctx, action, idExtractor) | ||||||
| 	if !ok { | 	if err != nil { | ||||||
| 		return // 错误已在 extractOperatorAndPrimaryID 中处理 | 		return err // 错误已在 extractOperatorAndPrimaryID 中处理 | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// 2. 执行服务层逻辑 | 	// 2. 执行服务层逻辑 | ||||||
| 	respDTO, err := serviceExecutor(ctx, operatorID, primaryID) | 	respDTO, err := serviceExecutor(ctx, operatorID, primaryID) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		mapAndSendError(c, ctx, action, err, primaryID) | 		return mapAndSendError(c, ctx, action, err, primaryID) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// 3. 发送成功响应 | 	// 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请求的通用逻辑。 | // handleQueryAPIRequestWithResponse 封装了处理带有查询参数并返回响应DTO的API请求的通用逻辑。 | ||||||
| func handleQueryAPIRequestWithResponse[Query any, Resp any]( | func handleQueryAPIRequestWithResponse[Query any, Resp any]( | ||||||
| 	c *PigBatchController, | 	c *PigBatchController, | ||||||
| 	ctx *gin.Context, | 	ctx echo.Context, | ||||||
| 	action string, | 	action string, | ||||||
| 	queryDTO Query, | 	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, | 	successMsg string, | ||||||
| ) { | ) error { | ||||||
| 	// 1. 绑定查询参数 | 	// 1. 绑定查询参数 | ||||||
| 	if err := ctx.ShouldBindQuery(&queryDTO); err != nil { | 	if err := ctx.Bind(&queryDTO); err != nil { | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数", action, "查询参数绑定失败", queryDTO) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数", action, "查询参数绑定失败", queryDTO) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// 2. 获取操作员ID | 	// 2. 获取操作员ID | ||||||
| 	operatorID, err := controller.GetOperatorIDFromContext(ctx) | 	operatorID, err := controller.GetOperatorIDFromContext(ctx) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeUnauthorized, "未授权", action, "无法获取操作员ID", nil) | 		return controller.SendErrorWithAudit(ctx, controller.CodeUnauthorized, "未授权", action, "无法获取操作员ID", nil) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// 3. 执行服务层逻辑 | 	// 3. 执行服务层逻辑 | ||||||
| 	respDTO, err := serviceExecutor(ctx, operatorID, queryDTO) | 	respDTO, err := serviceExecutor(ctx, operatorID, queryDTO) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		// 对于列表查询,通常没有primaryID,所以传递0 | 		// 对于列表查询,通常没有primaryID,所以传递0 | ||||||
| 		mapAndSendError(c, ctx, action, err, 0) | 		return mapAndSendError(c, ctx, action, err, 0) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// 4. 发送成功响应 | 	// 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/app/service" | ||||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" | 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" | ||||||
|  |  | ||||||
| 	"github.com/gin-gonic/gin" | 	"github.com/labstack/echo/v4" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| // PigBatchController 负责处理猪批次相关的API请求 | // PigBatchController 负责处理猪批次相关的API请求 | ||||||
| @@ -34,13 +34,13 @@ func NewPigBatchController(logger *logs.Logger, service service.PigBatchService) | |||||||
| // @Param        body body dto.PigBatchCreateDTO true "猪批次信息" | // @Param        body body dto.PigBatchCreateDTO true "猪批次信息" | ||||||
| // @Success      201 {object} controller.Response{data=dto.PigBatchResponseDTO} "创建成功" | // @Success      201 {object} controller.Response{data=dto.PigBatchResponseDTO} "创建成功" | ||||||
| // @Router       /api/v1/pig-batches [post] | // @Router       /api/v1/pig-batches [post] | ||||||
| func (c *PigBatchController) CreatePigBatch(ctx *gin.Context) { | func (c *PigBatchController) CreatePigBatch(ctx echo.Context) error { | ||||||
| 	const action = "创建猪批次" | 	const action = "创建猪批次" | ||||||
| 	var req dto.PigBatchCreateDTO | 	var req dto.PigBatchCreateDTO | ||||||
|  |  | ||||||
| 	handleAPIRequestWithResponse( | 	return handleAPIRequestWithResponse( | ||||||
| 		c, ctx, action, &req, | 		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通常不从路径中获取,而是由服务层生成 | 			// 对于创建操作,primaryID通常不从路径中获取,而是由服务层生成 | ||||||
| 			return c.service.CreatePigBatch(operatorID, req) | 			return c.service.CreatePigBatch(operatorID, req) | ||||||
| 		}, | 		}, | ||||||
| @@ -58,12 +58,12 @@ func (c *PigBatchController) CreatePigBatch(ctx *gin.Context) { | |||||||
| // @Param        id path int true "猪批次ID" | // @Param        id path int true "猪批次ID" | ||||||
| // @Success      200 {object} controller.Response{data=dto.PigBatchResponseDTO} "获取成功" | // @Success      200 {object} controller.Response{data=dto.PigBatchResponseDTO} "获取成功" | ||||||
| // @Router       /api/v1/pig-batches/{id} [get] | // @Router       /api/v1/pig-batches/{id} [get] | ||||||
| func (c *PigBatchController) GetPigBatch(ctx *gin.Context) { | func (c *PigBatchController) GetPigBatch(ctx echo.Context) error { | ||||||
| 	const action = "获取猪批次" | 	const action = "获取猪批次" | ||||||
|  |  | ||||||
| 	handleNoBodyAPIRequestWithResponse( | 	return handleNoBodyAPIRequestWithResponse( | ||||||
| 		c, ctx, action, | 		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) | 			return c.service.GetPigBatch(primaryID) | ||||||
| 		}, | 		}, | ||||||
| 		"获取成功", | 		"获取成功", | ||||||
| @@ -82,13 +82,13 @@ func (c *PigBatchController) GetPigBatch(ctx *gin.Context) { | |||||||
| // @Param        body body dto.PigBatchUpdateDTO true "猪批次信息" | // @Param        body body dto.PigBatchUpdateDTO true "猪批次信息" | ||||||
| // @Success      200 {object} controller.Response{data=dto.PigBatchResponseDTO} "更新成功" | // @Success      200 {object} controller.Response{data=dto.PigBatchResponseDTO} "更新成功" | ||||||
| // @Router       /api/v1/pig-batches/{id} [put] | // @Router       /api/v1/pig-batches/{id} [put] | ||||||
| func (c *PigBatchController) UpdatePigBatch(ctx *gin.Context) { | func (c *PigBatchController) UpdatePigBatch(ctx echo.Context) error { | ||||||
| 	const action = "更新猪批次" | 	const action = "更新猪批次" | ||||||
| 	var req dto.PigBatchUpdateDTO | 	var req dto.PigBatchUpdateDTO | ||||||
|  |  | ||||||
| 	handleAPIRequestWithResponse( | 	return handleAPIRequestWithResponse( | ||||||
| 		c, ctx, action, &req, | 		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) | 			return c.service.UpdatePigBatch(primaryID, req) | ||||||
| 		}, | 		}, | ||||||
| 		"更新成功", | 		"更新成功", | ||||||
| @@ -105,12 +105,12 @@ func (c *PigBatchController) UpdatePigBatch(ctx *gin.Context) { | |||||||
| // @Param        id path int true "猪批次ID" | // @Param        id path int true "猪批次ID" | ||||||
| // @Success      200 {object} controller.Response "删除成功" | // @Success      200 {object} controller.Response "删除成功" | ||||||
| // @Router       /api/v1/pig-batches/{id} [delete] | // @Router       /api/v1/pig-batches/{id} [delete] | ||||||
| func (c *PigBatchController) DeletePigBatch(ctx *gin.Context) { | func (c *PigBatchController) DeletePigBatch(ctx echo.Context) error { | ||||||
| 	const action = "删除猪批次" | 	const action = "删除猪批次" | ||||||
|  |  | ||||||
| 	handleNoBodyAPIRequest( | 	return handleNoBodyAPIRequest( | ||||||
| 		c, ctx, action, | 		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) | 			return c.service.DeletePigBatch(primaryID) | ||||||
| 		}, | 		}, | ||||||
| 		"删除成功", | 		"删除成功", | ||||||
| @@ -127,13 +127,13 @@ func (c *PigBatchController) DeletePigBatch(ctx *gin.Context) { | |||||||
| // @Param        is_active query bool false "是否活跃 (true/false)" | // @Param        is_active query bool false "是否活跃 (true/false)" | ||||||
| // @Success      200 {object} controller.Response{data=[]dto.PigBatchResponseDTO} "获取成功" | // @Success      200 {object} controller.Response{data=[]dto.PigBatchResponseDTO} "获取成功" | ||||||
| // @Router       /api/v1/pig-batches [get] | // @Router       /api/v1/pig-batches [get] | ||||||
| func (c *PigBatchController) ListPigBatches(ctx *gin.Context) { | func (c *PigBatchController) ListPigBatches(ctx echo.Context) error { | ||||||
| 	const action = "获取猪批次列表" | 	const action = "获取猪批次列表" | ||||||
| 	var query dto.PigBatchQueryDTO | 	var query dto.PigBatchQueryDTO | ||||||
|  |  | ||||||
| 	handleQueryAPIRequestWithResponse( | 	return handleQueryAPIRequestWithResponse( | ||||||
| 		c, ctx, action, &query, | 		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) | 			return c.service.ListPigBatches(query.IsActive) | ||||||
| 		}, | 		}, | ||||||
| 		"获取成功", | 		"获取成功", | ||||||
| @@ -151,13 +151,13 @@ func (c *PigBatchController) ListPigBatches(ctx *gin.Context) { | |||||||
| // @Param        body body dto.AssignEmptyPensToBatchRequest true "待分配的猪栏ID列表" | // @Param        body body dto.AssignEmptyPensToBatchRequest true "待分配的猪栏ID列表" | ||||||
| // @Success      200 {object} controller.Response "分配成功" | // @Success      200 {object} controller.Response "分配成功" | ||||||
| // @Router       /api/v1/pig-batches/assign-pens/{id} [post] | // @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 = "为猪批次分配空栏" | 	const action = "为猪批次分配空栏" | ||||||
| 	var req dto.AssignEmptyPensToBatchRequest | 	var req dto.AssignEmptyPensToBatchRequest | ||||||
|  |  | ||||||
| 	handleAPIRequest( | 	return handleAPIRequest( | ||||||
| 		c, ctx, action, &req, | 		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) | 			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和备注)" | // @Param        body body dto.ReclassifyPenToNewBatchRequest true "划拨请求信息 (包含目标批次ID、猪栏ID和备注)" | ||||||
| // @Success      200 {object} controller.Response "划拨成功" | // @Success      200 {object} controller.Response "划拨成功" | ||||||
| // @Router       /api/v1/pig-batches/reclassify-pen/{fromBatchID} [post] | // @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 = "划拨猪栏到新批次" | 	const action = "划拨猪栏到新批次" | ||||||
| 	var req dto.ReclassifyPenToNewBatchRequest | 	var req dto.ReclassifyPenToNewBatchRequest | ||||||
|  |  | ||||||
| 	handleAPIRequest( | 	return handleAPIRequest( | ||||||
| 		c, ctx, action, &req, | 		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 | 			// primaryID 在这里是 fromBatchID | ||||||
| 			return c.service.ReclassifyPenToNewBatch(primaryID, req.ToBatchID, req.PenID, operatorID, req.Remarks) | 			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") | 			idParam := ctx.Param("fromBatchID") | ||||||
| 			parsedID, err := strconv.ParseUint(idParam, 10, 32) | 			parsedID, err := strconv.ParseUint(idParam, 10, 32) | ||||||
| 			if err != nil { | 			if err != nil { | ||||||
| @@ -208,22 +208,22 @@ func (c *PigBatchController) ReclassifyPenToNewBatch(ctx *gin.Context) { | |||||||
| // @Param        penID path int true "待移除的猪栏ID" | // @Param        penID path int true "待移除的猪栏ID" | ||||||
| // @Success      200 {object} controller.Response "移除成功" | // @Success      200 {object} controller.Response "移除成功" | ||||||
| // @Router       /api/v1/pig-batches/remove-pen/{penID}/{batchID} [delete] | // @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 = "从猪批次移除空栏" | 	const action = "从猪批次移除空栏" | ||||||
|  |  | ||||||
| 	handleNoBodyAPIRequest( | 	return handleNoBodyAPIRequest( | ||||||
| 		c, ctx, action, | 		c, ctx, action, | ||||||
| 		func(ctx *gin.Context, operatorID uint, primaryID uint) error { | 		func(ctx echo.Context, operatorID uint, primaryID uint) error { | ||||||
| 			// primaryID 在这里是 batchID | 			// primaryID 在这里是 batchID | ||||||
| 			penIDParam := ctx.Param("penID") | 			penIDParam := ctx.Param("penID") | ||||||
| 			penID, err := strconv.ParseUint(penIDParam, 10, 32) | 			parsedPenID, err := strconv.ParseUint(penIDParam, 10, 32) | ||||||
| 			if err != nil { | 			if err != nil { | ||||||
| 				return err // 返回错误,因为 penID 格式无效 | 				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") | 			idParam := ctx.Param("batchID") | ||||||
| 			parsedID, err := strconv.ParseUint(idParam, 10, 32) | 			parsedID, err := strconv.ParseUint(idParam, 10, 32) | ||||||
| 			if err != nil { | 			if err != nil { | ||||||
| @@ -245,13 +245,13 @@ func (c *PigBatchController) RemoveEmptyPenFromBatch(ctx *gin.Context) { | |||||||
| // @Param        body body dto.MovePigsIntoPenRequest true "移入猪只请求信息 (包含目标猪栏ID、数量和备注)" | // @Param        body body dto.MovePigsIntoPenRequest true "移入猪只请求信息 (包含目标猪栏ID、数量和备注)" | ||||||
| // @Success      200 {object} controller.Response "移入成功" | // @Success      200 {object} controller.Response "移入成功" | ||||||
| // @Router       /api/v1/pig-batches/move-pigs-into-pen/{id} [post] | // @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 = "将猪只移入猪栏" | 	const action = "将猪只移入猪栏" | ||||||
| 	var req dto.MovePigsIntoPenRequest | 	var req dto.MovePigsIntoPenRequest | ||||||
|  |  | ||||||
| 	handleAPIRequest( | 	return handleAPIRequest( | ||||||
| 		c, ctx, action, &req, | 		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) | 			return c.service.MovePigsIntoPen(primaryID, req.ToPenID, req.Quantity, operatorID, req.Remarks) | ||||||
| 		}, | 		}, | ||||||
| 		"移入成功", | 		"移入成功", | ||||||
|   | |||||||
| @@ -2,7 +2,7 @@ package management | |||||||
|  |  | ||||||
| import ( | import ( | ||||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/dto" | 	"git.huangwc.com/pig/pig-farm-controller/internal/app/dto" | ||||||
| 	"github.com/gin-gonic/gin" | 	"github.com/labstack/echo/v4" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| // RecordSickPigs godoc | // RecordSickPigs godoc | ||||||
| @@ -16,13 +16,13 @@ import ( | |||||||
| // @Param        body body dto.RecordSickPigsRequest true "记录病猪请求信息" | // @Param        body body dto.RecordSickPigsRequest true "记录病猪请求信息" | ||||||
| // @Success      200 {object} controller.Response "记录成功" | // @Success      200 {object} controller.Response "记录成功" | ||||||
| // @Router       /api/v1/pig-batches/record-sick-pigs/{id} [post] | // @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 = "记录新增病猪事件" | 	const action = "记录新增病猪事件" | ||||||
| 	var req dto.RecordSickPigsRequest | 	var req dto.RecordSickPigsRequest | ||||||
|  |  | ||||||
| 	handleAPIRequest( | 	return handleAPIRequest( | ||||||
| 		c, ctx, action, &req, | 		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) | 			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 "记录病猪康复请求信息" | // @Param        body body dto.RecordSickPigRecoveryRequest true "记录病猪康复请求信息" | ||||||
| // @Success      200 {object} controller.Response "记录成功" | // @Success      200 {object} controller.Response "记录成功" | ||||||
| // @Router       /api/v1/pig-batches/record-sick-pig-recovery/{id} [post] | // @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 = "记录病猪康复事件" | 	const action = "记录病猪康复事件" | ||||||
| 	var req dto.RecordSickPigRecoveryRequest | 	var req dto.RecordSickPigRecoveryRequest | ||||||
|  |  | ||||||
| 	handleAPIRequest( | 	return handleAPIRequest( | ||||||
| 		c, ctx, action, &req, | 		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) | 			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 "记录病猪死亡请求信息" | // @Param        body body dto.RecordSickPigDeathRequest true "记录病猪死亡请求信息" | ||||||
| // @Success      200 {object} controller.Response "记录成功" | // @Success      200 {object} controller.Response "记录成功" | ||||||
| // @Router       /api/v1/pig-batches/record-sick-pig-death/{id} [post] | // @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 = "记录病猪死亡事件" | 	const action = "记录病猪死亡事件" | ||||||
| 	var req dto.RecordSickPigDeathRequest | 	var req dto.RecordSickPigDeathRequest | ||||||
|  |  | ||||||
| 	handleAPIRequest( | 	return handleAPIRequest( | ||||||
| 		c, ctx, action, &req, | 		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) | 			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 "记录病猪淘汰请求信息" | // @Param        body body dto.RecordSickPigCullRequest true "记录病猪淘汰请求信息" | ||||||
| // @Success      200 {object} controller.Response "记录成功" | // @Success      200 {object} controller.Response "记录成功" | ||||||
| // @Router       /api/v1/pig-batches/record-sick-pig-cull/{id} [post] | // @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 = "记录病猪淘汰事件" | 	const action = "记录病猪淘汰事件" | ||||||
| 	var req dto.RecordSickPigCullRequest | 	var req dto.RecordSickPigCullRequest | ||||||
|  |  | ||||||
| 	handleAPIRequest( | 	return handleAPIRequest( | ||||||
| 		c, ctx, action, &req, | 		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) | 			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 "记录正常猪只死亡请求信息" | // @Param        body body dto.RecordDeathRequest true "记录正常猪只死亡请求信息" | ||||||
| // @Success      200 {object} controller.Response "记录成功" | // @Success      200 {object} controller.Response "记录成功" | ||||||
| // @Router       /api/v1/pig-batches/record-death/{id} [post] | // @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 = "记录正常猪只死亡事件" | 	const action = "记录正常猪只死亡事件" | ||||||
| 	var req dto.RecordDeathRequest | 	var req dto.RecordDeathRequest | ||||||
|  |  | ||||||
| 	handleAPIRequest( | 	return handleAPIRequest( | ||||||
| 		c, ctx, action, &req, | 		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) | 			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 "记录正常猪只淘汰请求信息" | // @Param        body body dto.RecordCullRequest true "记录正常猪只淘汰请求信息" | ||||||
| // @Success      200 {object} controller.Response "记录成功" | // @Success      200 {object} controller.Response "记录成功" | ||||||
| // @Router       /api/v1/pig-batches/record-cull/{id} [post] | // @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 = "记录正常猪只淘汰事件" | 	const action = "记录正常猪只淘汰事件" | ||||||
| 	var req dto.RecordCullRequest | 	var req dto.RecordCullRequest | ||||||
|  |  | ||||||
| 	handleAPIRequest( | 	return handleAPIRequest( | ||||||
| 		c, ctx, action, &req, | 		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) | 			return c.service.RecordCull(operatorID, primaryID, req.PenID, req.Quantity, req.HappenedAt, req.Remarks) | ||||||
| 		}, | 		}, | ||||||
| 		"记录成功", | 		"记录成功", | ||||||
|   | |||||||
| @@ -2,7 +2,7 @@ package management | |||||||
|  |  | ||||||
| import ( | import ( | ||||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/dto" | 	"git.huangwc.com/pig/pig-farm-controller/internal/app/dto" | ||||||
| 	"github.com/gin-gonic/gin" | 	"github.com/labstack/echo/v4" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| // SellPigs godoc | // SellPigs godoc | ||||||
| @@ -16,13 +16,13 @@ import ( | |||||||
| // @Param        body body dto.SellPigsRequest true "卖猪请求信息" | // @Param        body body dto.SellPigsRequest true "卖猪请求信息" | ||||||
| // @Success      200 {object} controller.Response "卖猪成功" | // @Success      200 {object} controller.Response "卖猪成功" | ||||||
| // @Router       /api/v1/pig-batches/sell-pigs/{id} [post] | // @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 = "卖猪" | 	const action = "卖猪" | ||||||
| 	var req dto.SellPigsRequest | 	var req dto.SellPigsRequest | ||||||
|  |  | ||||||
| 	handleAPIRequest( | 	return handleAPIRequest( | ||||||
| 		c, ctx, action, &req, | 		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) | 			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 "买猪请求信息" | // @Param        body body dto.BuyPigsRequest true "买猪请求信息" | ||||||
| // @Success      200 {object} controller.Response "买猪成功" | // @Success      200 {object} controller.Response "买猪成功" | ||||||
| // @Router       /api/v1/pig-batches/buy-pigs/{id} [post] | // @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 = "买猪" | 	const action = "买猪" | ||||||
| 	var req dto.BuyPigsRequest | 	var req dto.BuyPigsRequest | ||||||
|  |  | ||||||
| 	handleAPIRequest( | 	return handleAPIRequest( | ||||||
| 		c, ctx, action, &req, | 		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) | 			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" | 	"strconv" | ||||||
|  |  | ||||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/dto" | 	"git.huangwc.com/pig/pig-farm-controller/internal/app/dto" | ||||||
| 	"github.com/gin-gonic/gin" | 	"github.com/labstack/echo/v4" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| // TransferPigsAcrossBatches godoc | // TransferPigsAcrossBatches godoc | ||||||
| @@ -18,18 +18,18 @@ import ( | |||||||
| // @Param        body body dto.TransferPigsAcrossBatchesRequest true "跨群调栏请求信息" | // @Param        body body dto.TransferPigsAcrossBatchesRequest true "跨群调栏请求信息" | ||||||
| // @Success      200 {object} controller.Response "调栏成功" | // @Success      200 {object} controller.Response "调栏成功" | ||||||
| // @Router       /api/v1/pig-batches/transfer-across-batches/{sourceBatchID} [post] | // @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 = "跨猪群调栏" | 	const action = "跨猪群调栏" | ||||||
| 	var req dto.TransferPigsAcrossBatchesRequest | 	var req dto.TransferPigsAcrossBatchesRequest | ||||||
|  |  | ||||||
| 	handleAPIRequest( | 	return handleAPIRequest( | ||||||
| 		c, ctx, action, &req, | 		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 | 			// primaryID 在这里是 sourceBatchID | ||||||
| 			return c.service.TransferPigsAcrossBatches(primaryID, req.DestBatchID, req.FromPenID, req.ToPenID, req.Quantity, operatorID, req.Remarks) | 			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") | 			idParam := ctx.Param("sourceBatchID") | ||||||
| 			parsedID, err := strconv.ParseUint(idParam, 10, 32) | 			parsedID, err := strconv.ParseUint(idParam, 10, 32) | ||||||
| 			if err != nil { | 			if err != nil { | ||||||
| @@ -51,13 +51,13 @@ func (c *PigBatchController) TransferPigsAcrossBatches(ctx *gin.Context) { | |||||||
| // @Param        body body dto.TransferPigsWithinBatchRequest true "群内调栏请求信息" | // @Param        body body dto.TransferPigsWithinBatchRequest true "群内调栏请求信息" | ||||||
| // @Success      200 {object} controller.Response "调栏成功" | // @Success      200 {object} controller.Response "调栏成功" | ||||||
| // @Router       /api/v1/pig-batches/transfer-within-batch/{id} [post] | // @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 = "群内调栏" | 	const action = "群内调栏" | ||||||
| 	var req dto.TransferPigsWithinBatchRequest | 	var req dto.TransferPigsWithinBatchRequest | ||||||
|  |  | ||||||
| 	handleAPIRequest( | 	return handleAPIRequest( | ||||||
| 		c, ctx, action, &req, | 		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 | 			// primaryID 在这里是 batchID | ||||||
| 			return c.service.TransferPigsWithinBatch(primaryID, req.FromPenID, req.ToPenID, req.Quantity, operatorID, req.Remarks) | 			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/dto" | ||||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/service" | 	"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/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 | // CreatePigHouse godoc | ||||||
| // @Summary      创建猪舍 | // @Summary      创建猪舍 | ||||||
| // @Description  创建一个新的猪舍 | // @Description  根据提供的信息创建一个新猪舍 | ||||||
| // @Tags         猪场管理 | // @Tags         猪场管理 | ||||||
| // @Security     BearerAuth | // @Security     BearerAuth | ||||||
| // @Accept       json | // @Accept       json | ||||||
| @@ -39,27 +39,21 @@ func NewPigFarmController(logger *logs.Logger, service service.PigFarmService) * | |||||||
| // @Param        body body dto.CreatePigHouseRequest true "猪舍信息" | // @Param        body body dto.CreatePigHouseRequest true "猪舍信息" | ||||||
| // @Success      201 {object} controller.Response{data=dto.PigHouseResponse} "创建成功" | // @Success      201 {object} controller.Response{data=dto.PigHouseResponse} "创建成功" | ||||||
| // @Router       /api/v1/pig-houses [post] | // @Router       /api/v1/pig-houses [post] | ||||||
| func (c *PigFarmController) CreatePigHouse(ctx *gin.Context) { | func (c *PigFarmController) CreatePigHouse(ctx echo.Context) error { | ||||||
| 	const action = "创建猪舍" | 	const action = "创建猪舍" | ||||||
| 	var req dto.CreatePigHouseRequest | 	var req dto.CreatePigHouseRequest | ||||||
| 	if err := ctx.ShouldBindJSON(&req); err != nil { | 	if err := ctx.Bind(&req); err != nil { | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体", action, "请求体绑定失败", req) | 		c.logger.Errorf("%s: 参数绑定失败: %v", action, err) | ||||||
| 		return | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体", action, "请求体绑定失败", req) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	house, err := c.service.CreatePigHouse(req.Name, req.Description) | 	house, err := c.service.CreatePigHouse(req.Name, req.Description) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		c.logger.Errorf("%s: 业务逻辑失败: %v", action, err) | 		c.logger.Errorf("%s: 业务逻辑失败: %v", action, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "创建猪舍失败", action, "业务逻辑失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "创建猪舍失败", action, "业务逻辑失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	resp := dto.PigHouseResponse{ | 	return controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "创建成功", house, action, "创建成功", house) | ||||||
| 		ID:          house.ID, |  | ||||||
| 		Name:        house.Name, |  | ||||||
| 		Description: house.Description, |  | ||||||
| 	} |  | ||||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "创建成功", resp, action, "创建成功", resp) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // GetPigHouse godoc | // GetPigHouse godoc | ||||||
| @@ -71,31 +65,23 @@ func (c *PigFarmController) CreatePigHouse(ctx *gin.Context) { | |||||||
| // @Param        id path int true "猪舍ID" | // @Param        id path int true "猪舍ID" | ||||||
| // @Success      200 {object} controller.Response{data=dto.PigHouseResponse} "获取成功" | // @Success      200 {object} controller.Response{data=dto.PigHouseResponse} "获取成功" | ||||||
| // @Router       /api/v1/pig-houses/{id} [get] | // @Router       /api/v1/pig-houses/{id} [get] | ||||||
| func (c *PigFarmController) GetPigHouse(ctx *gin.Context) { | func (c *PigFarmController) GetPigHouse(ctx echo.Context) error { | ||||||
| 	const action = "获取猪舍" | 	const action = "获取猪舍" | ||||||
| 	id, err := strconv.ParseUint(ctx.Param("id"), 10, 32) | 	id, err := strconv.ParseUint(ctx.Param("id"), 10, 32) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", ctx.Param("id")) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", ctx.Param("id")) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	house, err := c.service.GetPigHouseByID(uint(id)) | 	house, err := c.service.GetPigHouseByID(uint(id)) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if errors.Is(err, service.ErrHouseNotFound) { | 		if errors.Is(err, service.ErrHouseNotFound) { | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "猪舍不存在", action, "猪舍不存在", id) | 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "猪舍不存在", action, "猪舍不存在", id) | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
| 		c.logger.Errorf("%s: 业务逻辑失败: %v", action, err) | 		c.logger.Errorf("%s: 业务逻辑失败: %v", action, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取猪舍失败", action, "业务逻辑失败", id) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取猪舍失败", action, "业务逻辑失败", id) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	resp := dto.PigHouseResponse{ | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取成功", house, action, "获取成功", house) | ||||||
| 		ID:          house.ID, |  | ||||||
| 		Name:        house.Name, |  | ||||||
| 		Description: house.Description, |  | ||||||
| 	} |  | ||||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取成功", resp, action, "获取成功", resp) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // ListPigHouses godoc | // ListPigHouses godoc | ||||||
| @@ -106,25 +92,15 @@ func (c *PigFarmController) GetPigHouse(ctx *gin.Context) { | |||||||
| // @Produce      json | // @Produce      json | ||||||
| // @Success      200 {object} controller.Response{data=[]dto.PigHouseResponse} "获取成功" | // @Success      200 {object} controller.Response{data=[]dto.PigHouseResponse} "获取成功" | ||||||
| // @Router       /api/v1/pig-houses [get] | // @Router       /api/v1/pig-houses [get] | ||||||
| func (c *PigFarmController) ListPigHouses(ctx *gin.Context) { | func (c *PigFarmController) ListPigHouses(ctx echo.Context) error { | ||||||
| 	const action = "获取猪舍列表" | 	const action = "获取猪舍列表" | ||||||
| 	houses, err := c.service.ListPigHouses() | 	houses, err := c.service.ListPigHouses() | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		c.logger.Errorf("%s: 业务逻辑失败: %v", action, err) | 		c.logger.Errorf("%s: 业务逻辑失败: %v", action, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取列表失败", action, "业务逻辑失败", nil) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取列表失败", action, "业务逻辑失败", nil) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	var resp []dto.PigHouseResponse | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取成功", houses, action, "获取成功", houses) | ||||||
| 	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) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // UpdatePigHouse godoc | // UpdatePigHouse godoc | ||||||
| @@ -138,37 +114,28 @@ func (c *PigFarmController) ListPigHouses(ctx *gin.Context) { | |||||||
| // @Param        body body dto.UpdatePigHouseRequest true "猪舍信息" | // @Param        body body dto.UpdatePigHouseRequest true "猪舍信息" | ||||||
| // @Success      200 {object} controller.Response{data=dto.PigHouseResponse} "更新成功" | // @Success      200 {object} controller.Response{data=dto.PigHouseResponse} "更新成功" | ||||||
| // @Router       /api/v1/pig-houses/{id} [put] | // @Router       /api/v1/pig-houses/{id} [put] | ||||||
| func (c *PigFarmController) UpdatePigHouse(ctx *gin.Context) { | func (c *PigFarmController) UpdatePigHouse(ctx echo.Context) error { | ||||||
| 	const action = "更新猪舍" | 	const action = "更新猪舍" | ||||||
| 	id, err := strconv.ParseUint(ctx.Param("id"), 10, 32) | 	id, err := strconv.ParseUint(ctx.Param("id"), 10, 32) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", ctx.Param("id")) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", ctx.Param("id")) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	var req dto.UpdatePigHouseRequest | 	var req dto.UpdatePigHouseRequest | ||||||
| 	if err := ctx.ShouldBindJSON(&req); err != nil { | 	if err := ctx.Bind(&req); err != nil { | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体", action, "请求体绑定失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体", action, "请求体绑定失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	house, err := c.service.UpdatePigHouse(uint(id), req.Name, req.Description) | 	house, err := c.service.UpdatePigHouse(uint(id), req.Name, req.Description) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if errors.Is(err, service.ErrHouseNotFound) { | 		if errors.Is(err, service.ErrHouseNotFound) { | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "猪舍不存在", action, "猪舍不存在", id) | 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "猪舍不存在", action, "猪舍不存在", id) | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
| 		c.logger.Errorf("%s: 业务逻辑失败: %v", action, err) | 		c.logger.Errorf("%s: 业务逻辑失败: %v", action, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新失败", action, "业务逻辑失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新失败", action, "业务逻辑失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	resp := dto.PigHouseResponse{ | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "更新成功", house, action, "更新成功", house) | ||||||
| 		ID:          house.ID, |  | ||||||
| 		Name:        house.Name, |  | ||||||
| 		Description: house.Description, |  | ||||||
| 	} |  | ||||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "更新成功", resp, action, "更新成功", resp) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // DeletePigHouse godoc | // DeletePigHouse godoc | ||||||
| @@ -180,30 +147,26 @@ func (c *PigFarmController) UpdatePigHouse(ctx *gin.Context) { | |||||||
| // @Param        id path int true "猪舍ID" | // @Param        id path int true "猪舍ID" | ||||||
| // @Success      200 {object} controller.Response "删除成功" | // @Success      200 {object} controller.Response "删除成功" | ||||||
| // @Router       /api/v1/pig-houses/{id} [delete] | // @Router       /api/v1/pig-houses/{id} [delete] | ||||||
| func (c *PigFarmController) DeletePigHouse(ctx *gin.Context) { | func (c *PigFarmController) DeletePigHouse(ctx echo.Context) error { | ||||||
| 	const action = "删除猪舍" | 	const action = "删除猪舍" | ||||||
| 	id, err := strconv.ParseUint(ctx.Param("id"), 10, 32) | 	id, err := strconv.ParseUint(ctx.Param("id"), 10, 32) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", ctx.Param("id")) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", ctx.Param("id")) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if err := c.service.DeletePigHouse(uint(id)); err != nil { | 	if err := c.service.DeletePigHouse(uint(id)); err != nil { | ||||||
| 		if errors.Is(err, service.ErrHouseNotFound) { | 		if errors.Is(err, service.ErrHouseNotFound) { | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "猪舍不存在", action, "猪舍不存在", id) | 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "猪舍不存在", action, "猪舍不存在", id) | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
| 		// 检查是否是业务逻辑错误 | 		// 检查是否是业务逻辑错误 | ||||||
| 		if errors.Is(err, service.ErrHouseContainsPens) { | 		if errors.Is(err, service.ErrHouseContainsPens) { | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), action, err.Error(), id) | 			return controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), action, err.Error(), id) | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
| 		c.logger.Errorf("%s: 业务逻辑失败: %v", action, err) | 		c.logger.Errorf("%s: 业务逻辑失败: %v", action, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除失败", action, "业务逻辑失败", id) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除失败", action, "业务逻辑失败", id) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "删除成功", nil, action, "删除成功", id) | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "删除成功", nil, action, "删除成功", id) | ||||||
| } | } | ||||||
|  |  | ||||||
| // --- 猪栏 (Pen) API 实现 --- | // --- 猪栏 (Pen) API 实现 --- | ||||||
| @@ -218,34 +181,24 @@ func (c *PigFarmController) DeletePigHouse(ctx *gin.Context) { | |||||||
| // @Param        body body dto.CreatePenRequest true "猪栏信息" | // @Param        body body dto.CreatePenRequest true "猪栏信息" | ||||||
| // @Success      201 {object} controller.Response{data=dto.PenResponse} "创建成功" | // @Success      201 {object} controller.Response{data=dto.PenResponse} "创建成功" | ||||||
| // @Router       /api/v1/pens [post] | // @Router       /api/v1/pens [post] | ||||||
| func (c *PigFarmController) CreatePen(ctx *gin.Context) { | func (c *PigFarmController) CreatePen(ctx echo.Context) error { | ||||||
| 	const action = "创建猪栏" | 	const action = "创建猪栏" | ||||||
| 	var req dto.CreatePenRequest | 	var req dto.CreatePenRequest | ||||||
| 	if err := ctx.ShouldBindJSON(&req); err != nil { | 	if err := ctx.Bind(&req); err != nil { | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体", action, "请求体绑定失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体", action, "请求体绑定失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	pen, err := c.service.CreatePen(req.PenNumber, req.HouseID, req.Capacity) | 	pen, err := c.service.CreatePen(req.PenNumber, req.HouseID, req.Capacity) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		// 检查是否是业务逻辑错误 | 		// 检查是否是业务逻辑错误 | ||||||
| 		if errors.Is(err, service.ErrHouseNotFound) { | 		if errors.Is(err, service.ErrHouseNotFound) { | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), action, err.Error(), req) | 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), action, err.Error(), req) | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
| 		c.logger.Errorf("%s: 业务逻辑失败: %v", action, err) | 		c.logger.Errorf("%s: 业务逻辑失败: %v", action, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "创建猪栏失败", action, "业务逻辑失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "创建猪栏失败", action, "业务逻辑失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	resp := dto.PenResponse{ | 	return controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "创建成功", pen, action, "创建成功", pen) | ||||||
| 		ID:        pen.ID, |  | ||||||
| 		PenNumber: pen.PenNumber, |  | ||||||
| 		HouseID:   pen.HouseID, |  | ||||||
| 		Capacity:  pen.Capacity, |  | ||||||
| 		Status:    pen.Status, |  | ||||||
| 	} |  | ||||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "创建成功", resp, action, "创建成功", resp) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // GetPen godoc | // GetPen godoc | ||||||
| @@ -257,26 +210,23 @@ func (c *PigFarmController) CreatePen(ctx *gin.Context) { | |||||||
| // @Param        id path int true "猪栏ID" | // @Param        id path int true "猪栏ID" | ||||||
| // @Success      200 {object} controller.Response{data=dto.PenResponse} "获取成功" | // @Success      200 {object} controller.Response{data=dto.PenResponse} "获取成功" | ||||||
| // @Router       /api/v1/pens/{id} [get] | // @Router       /api/v1/pens/{id} [get] | ||||||
| func (c *PigFarmController) GetPen(ctx *gin.Context) { | func (c *PigFarmController) GetPen(ctx echo.Context) error { | ||||||
| 	const action = "获取猪栏" | 	const action = "获取猪栏" | ||||||
| 	id, err := strconv.ParseUint(ctx.Param("id"), 10, 32) | 	id, err := strconv.ParseUint(ctx.Param("id"), 10, 32) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", ctx.Param("id")) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", ctx.Param("id")) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	pen, err := c.service.GetPenByID(uint(id)) | 	pen, err := c.service.GetPenByID(uint(id)) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if errors.Is(err, service.ErrPenNotFound) { | 		if errors.Is(err, service.ErrPenNotFound) { | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "猪栏不存在", action, "猪栏不存在", id) | 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "猪栏不存在", action, "猪栏不存在", id) | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
| 		c.logger.Errorf("%s: 业务逻辑失败: %v", action, err) | 		c.logger.Errorf("%s: 业务逻辑失败: %v", action, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取猪栏失败", action, "业务逻辑失败", id) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取猪栏失败", action, "业务逻辑失败", id) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取成功", pen, action, "获取成功", pen) | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取成功", pen, action, "获取成功", pen) | ||||||
| } | } | ||||||
|  |  | ||||||
| // ListPens godoc | // ListPens godoc | ||||||
| @@ -287,16 +237,15 @@ func (c *PigFarmController) GetPen(ctx *gin.Context) { | |||||||
| // @Produce      json | // @Produce      json | ||||||
| // @Success      200 {object} controller.Response{data=[]dto.PenResponse} "获取成功" | // @Success      200 {object} controller.Response{data=[]dto.PenResponse} "获取成功" | ||||||
| // @Router       /api/v1/pens [get] | // @Router       /api/v1/pens [get] | ||||||
| func (c *PigFarmController) ListPens(ctx *gin.Context) { | func (c *PigFarmController) ListPens(ctx echo.Context) error { | ||||||
| 	const action = "获取猪栏列表" | 	const action = "获取猪栏列表" | ||||||
| 	pens, err := c.service.ListPens() | 	pens, err := c.service.ListPens() | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		c.logger.Errorf("%s: 业务逻辑失败: %v", action, err) | 		c.logger.Errorf("%s: 业务逻辑失败: %v", action, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取列表失败", action, "业务逻辑失败", nil) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取列表失败", action, "业务逻辑失败", nil) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取成功", pens, action, "获取成功", pens) | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取成功", pens, action, "获取成功", pens) | ||||||
| } | } | ||||||
|  |  | ||||||
| // UpdatePen godoc | // UpdatePen godoc | ||||||
| @@ -310,41 +259,29 @@ func (c *PigFarmController) ListPens(ctx *gin.Context) { | |||||||
| // @Param        body body dto.UpdatePenRequest true "猪栏信息" | // @Param        body body dto.UpdatePenRequest true "猪栏信息" | ||||||
| // @Success      200 {object} controller.Response{data=dto.PenResponse} "更新成功" | // @Success      200 {object} controller.Response{data=dto.PenResponse} "更新成功" | ||||||
| // @Router       /api/v1/pens/{id} [put] | // @Router       /api/v1/pens/{id} [put] | ||||||
| func (c *PigFarmController) UpdatePen(ctx *gin.Context) { | func (c *PigFarmController) UpdatePen(ctx echo.Context) error { | ||||||
| 	const action = "更新猪栏" | 	const action = "更新猪栏" | ||||||
| 	id, err := strconv.ParseUint(ctx.Param("id"), 10, 32) | 	id, err := strconv.ParseUint(ctx.Param("id"), 10, 32) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", ctx.Param("id")) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", ctx.Param("id")) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	var req dto.UpdatePenRequest | 	var req dto.UpdatePenRequest | ||||||
| 	if err := ctx.ShouldBindJSON(&req); err != nil { | 	if err := ctx.Bind(&req); err != nil { | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体", action, "请求体绑定失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体", action, "请求体绑定失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	pen, err := c.service.UpdatePen(uint(id), req.PenNumber, req.HouseID, req.Capacity, req.Status) | 	pen, err := c.service.UpdatePen(uint(id), req.PenNumber, req.HouseID, req.Capacity, req.Status) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if errors.Is(err, service.ErrPenNotFound) { | 		if errors.Is(err, service.ErrPenNotFound) { | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "猪栏不存在", action, "猪栏不存在", id) | 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "猪栏不存在", action, "猪栏不存在", id) | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
| 		// 其他业务逻辑错误可以在这里添加处理 | 		// 其他业务逻辑错误可以在这里添加处理 | ||||||
| 		c.logger.Errorf("%s: 业务逻辑失败: %v", action, err) | 		c.logger.Errorf("%s: 业务逻辑失败: %v", action, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新失败", action, "业务逻辑失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新失败", action, "业务逻辑失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	resp := dto.PenResponse{ | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "更新成功", pen, action, "更新成功", pen) | ||||||
| 		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) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // DeletePen godoc | // DeletePen godoc | ||||||
| @@ -356,30 +293,26 @@ func (c *PigFarmController) UpdatePen(ctx *gin.Context) { | |||||||
| // @Param        id path int true "猪栏ID" | // @Param        id path int true "猪栏ID" | ||||||
| // @Success      200 {object} controller.Response "删除成功" | // @Success      200 {object} controller.Response "删除成功" | ||||||
| // @Router       /api/v1/pens/{id} [delete] | // @Router       /api/v1/pens/{id} [delete] | ||||||
| func (c *PigFarmController) DeletePen(ctx *gin.Context) { | func (c *PigFarmController) DeletePen(ctx echo.Context) error { | ||||||
| 	const action = "删除猪栏" | 	const action = "删除猪栏" | ||||||
| 	id, err := strconv.ParseUint(ctx.Param("id"), 10, 32) | 	id, err := strconv.ParseUint(ctx.Param("id"), 10, 32) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", ctx.Param("id")) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", ctx.Param("id")) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if err := c.service.DeletePen(uint(id)); err != nil { | 	if err := c.service.DeletePen(uint(id)); err != nil { | ||||||
| 		if errors.Is(err, service.ErrPenNotFound) { | 		if errors.Is(err, service.ErrPenNotFound) { | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "猪栏不存在", action, "猪栏不存在", id) | 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "猪栏不存在", action, "猪栏不存在", id) | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
| 		// 检查是否是业务逻辑错误 | 		// 检查是否是业务逻辑错误 | ||||||
| 		if errors.Is(err, service.ErrPenInUse) { | 		if errors.Is(err, service.ErrPenInUse) { | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), action, err.Error(), id) | 			return controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), action, err.Error(), id) | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
| 		c.logger.Errorf("%s: 业务逻辑失败: %v", action, err) | 		c.logger.Errorf("%s: 业务逻辑失败: %v", action, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除失败", action, "业务逻辑失败", id) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除失败", action, "业务逻辑失败", id) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "删除成功", nil, action, "删除成功", id) | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "删除成功", nil, action, "删除成功", id) | ||||||
| } | } | ||||||
|  |  | ||||||
| // UpdatePenStatus godoc | // UpdatePenStatus godoc | ||||||
| @@ -393,41 +326,28 @@ func (c *PigFarmController) DeletePen(ctx *gin.Context) { | |||||||
| // @Param        body body dto.UpdatePenStatusRequest true "新的猪栏状态" | // @Param        body body dto.UpdatePenStatusRequest true "新的猪栏状态" | ||||||
| // @Success      200 {object} controller.Response{data=dto.PenResponse} "更新成功" | // @Success      200 {object} controller.Response{data=dto.PenResponse} "更新成功" | ||||||
| // @Router       /api/v1/pens/{id}/status [put] | // @Router       /api/v1/pens/{id}/status [put] | ||||||
| func (c *PigFarmController) UpdatePenStatus(ctx *gin.Context) { | func (c *PigFarmController) UpdatePenStatus(ctx echo.Context) error { | ||||||
| 	const action = "更新猪栏状态" | 	const action = "更新猪栏状态" | ||||||
| 	id, err := strconv.ParseUint(ctx.Param("id"), 10, 32) | 	id, err := strconv.ParseUint(ctx.Param("id"), 10, 32) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", ctx.Param("id")) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的ID格式", action, "ID格式错误", ctx.Param("id")) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	var req dto.UpdatePenStatusRequest | 	var req dto.UpdatePenStatusRequest | ||||||
| 	if err := ctx.ShouldBindJSON(&req); err != nil { | 	if err := ctx.Bind(&req); err != nil { | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体", action, "请求体绑定失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体", action, "请求体绑定失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	pen, err := c.service.UpdatePenStatus(uint(id), req.Status) | 	pen, err := c.service.UpdatePenStatus(uint(id), req.Status) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if errors.Is(err, service.ErrPenNotFound) { | 		if errors.Is(err, service.ErrPenNotFound) { | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), action, err.Error(), id) | 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), action, err.Error(), id) | ||||||
| 			return |  | ||||||
| 		} else if errors.Is(err, service.ErrPenStatusInvalidForOccupiedPen) || errors.Is(err, service.ErrPenStatusInvalidForUnoccupiedPen) { | 		} else if errors.Is(err, service.ErrPenStatusInvalidForOccupiedPen) || errors.Is(err, service.ErrPenStatusInvalidForUnoccupiedPen) { | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), action, err.Error(), id) | 			return controller.SendErrorWithAudit(ctx, controller.CodeConflict, err.Error(), action, err.Error(), id) | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
| 		c.logger.Errorf("%s: 业务逻辑失败: %v", action, err) | 		c.logger.Errorf("%s: 业务逻辑失败: %v", action, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新猪栏状态失败", action, err.Error(), id) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新猪栏状态失败", action, err.Error(), id) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	resp := dto.PenResponse{ | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "更新成功", pen, action, "更新成功", pen) | ||||||
| 		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) |  | ||||||
| } | } | ||||||
|   | |||||||
| @@ -7,9 +7,8 @@ import ( | |||||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/dto" | 	"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/app/service" | ||||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/logs" | 	"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" | 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" | ||||||
| 	"github.com/gin-gonic/gin" | 	"github.com/labstack/echo/v4" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| // Controller 监控控制器,封装了所有与数据监控相关的业务逻辑 | // Controller 监控控制器,封装了所有与数据监控相关的业务逻辑 | ||||||
| @@ -35,43 +34,28 @@ func NewController(monitorService service.MonitorService, logger *logs.Logger) * | |||||||
| // @Param        query query dto.ListSensorDataRequest true "查询参数" | // @Param        query query dto.ListSensorDataRequest true "查询参数" | ||||||
| // @Success      200 {object} controller.Response{data=dto.ListSensorDataResponse} | // @Success      200 {object} controller.Response{data=dto.ListSensorDataResponse} | ||||||
| // @Router       /api/v1/monitor/sensor-data [get] | // @Router       /api/v1/monitor/sensor-data [get] | ||||||
| func (c *Controller) ListSensorData(ctx *gin.Context) { | func (c *Controller) ListSensorData(ctx echo.Context) error { | ||||||
| 	const actionType = "获取传感器数据列表" | 	const actionType = "获取传感器数据列表" | ||||||
|  |  | ||||||
| 	var req dto.ListSensorDataRequest | 	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) | 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	opts := repository.SensorDataListOptions{ | 	resp, err := c.monitorService.ListSensorData(&req) | ||||||
| 		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) |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if errors.Is(err, repository.ErrInvalidPagination) { | 		if errors.Is(err, repository.ErrInvalidPagination) { | ||||||
| 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | 			return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取传感器数据失败: "+err.Error(), actionType, "服务层查询失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取传感器数据失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	resp := dto.NewListSensorDataResponse(data, total, req.Page, req.PageSize) | 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total) | ||||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total) | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取传感器数据成功", resp, actionType, "获取传感器数据成功", req) | ||||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取传感器数据成功", resp, actionType, "获取传感器数据成功", req) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // ListDeviceCommandLogs godoc | // ListDeviceCommandLogs godoc | ||||||
| @@ -83,40 +67,28 @@ func (c *Controller) ListSensorData(ctx *gin.Context) { | |||||||
| // @Param        query query dto.ListDeviceCommandLogRequest true "查询参数" | // @Param        query query dto.ListDeviceCommandLogRequest true "查询参数" | ||||||
| // @Success      200 {object} controller.Response{data=dto.ListDeviceCommandLogResponse} | // @Success      200 {object} controller.Response{data=dto.ListDeviceCommandLogResponse} | ||||||
| // @Router       /api/v1/monitor/device-command-logs [get] | // @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 = "获取设备命令日志列表" | 	const actionType = "获取设备命令日志列表" | ||||||
|  |  | ||||||
| 	var req dto.ListDeviceCommandLogRequest | 	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) | 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	opts := repository.DeviceCommandLogListOptions{ | 	resp, err := c.monitorService.ListDeviceCommandLogs(&req) | ||||||
| 		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) |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if errors.Is(err, repository.ErrInvalidPagination) { | 		if errors.Is(err, repository.ErrInvalidPagination) { | ||||||
| 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | 			return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取设备命令日志失败: "+err.Error(), actionType, "服务层查询失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取设备命令日志失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	resp := dto.NewListDeviceCommandLogResponse(data, total, req.Page, req.PageSize) | 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total) | ||||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total) | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取设备命令日志成功", resp, actionType, "获取设备命令日志成功", req) | ||||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取设备命令日志成功", resp, actionType, "获取设备命令日志成功", req) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // ListPlanExecutionLogs godoc | // ListPlanExecutionLogs godoc | ||||||
| @@ -128,43 +100,28 @@ func (c *Controller) ListDeviceCommandLogs(ctx *gin.Context) { | |||||||
| // @Param        query query dto.ListPlanExecutionLogRequest true "查询参数" | // @Param        query query dto.ListPlanExecutionLogRequest true "查询参数" | ||||||
| // @Success      200 {object} controller.Response{data=dto.ListPlanExecutionLogResponse} | // @Success      200 {object} controller.Response{data=dto.ListPlanExecutionLogResponse} | ||||||
| // @Router       /api/v1/monitor/plan-execution-logs [get] | // @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 = "获取计划执行日志列表" | 	const actionType = "获取计划执行日志列表" | ||||||
|  |  | ||||||
| 	var req dto.ListPlanExecutionLogRequest | 	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) | 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	opts := repository.PlanExecutionLogListOptions{ | 	resp, err := c.monitorService.ListPlanExecutionLogs(&req) | ||||||
| 		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) |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if errors.Is(err, repository.ErrInvalidPagination) { | 		if errors.Is(err, repository.ErrInvalidPagination) { | ||||||
| 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | 			return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取计划执行日志失败: "+err.Error(), actionType, "服务层查询失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取计划执行日志失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	resp := dto.NewListPlanExecutionLogResponse(data, total, req.Page, req.PageSize) | 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total) | ||||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total) | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取计划执行日志成功", resp, actionType, "获取计划执行日志成功", req) | ||||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取计划执行日志成功", resp, actionType, "获取计划执行日志成功", req) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // ListTaskExecutionLogs godoc | // ListTaskExecutionLogs godoc | ||||||
| @@ -176,44 +133,28 @@ func (c *Controller) ListPlanExecutionLogs(ctx *gin.Context) { | |||||||
| // @Param        query query dto.ListTaskExecutionLogRequest true "查询参数" | // @Param        query query dto.ListTaskExecutionLogRequest true "查询参数" | ||||||
| // @Success      200 {object} controller.Response{data=dto.ListTaskExecutionLogResponse} | // @Success      200 {object} controller.Response{data=dto.ListTaskExecutionLogResponse} | ||||||
| // @Router       /api/v1/monitor/task-execution-logs [get] | // @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 = "获取任务执行日志列表" | 	const actionType = "获取任务执行日志列表" | ||||||
|  |  | ||||||
| 	var req dto.ListTaskExecutionLogRequest | 	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) | 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	opts := repository.TaskExecutionLogListOptions{ | 	resp, err := c.monitorService.ListTaskExecutionLogs(&req) | ||||||
| 		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) |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if errors.Is(err, repository.ErrInvalidPagination) { | 		if errors.Is(err, repository.ErrInvalidPagination) { | ||||||
| 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | 			return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取任务执行日志失败: "+err.Error(), actionType, "服务层查询失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取任务执行日志失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	resp := dto.NewListTaskExecutionLogResponse(data, total, req.Page, req.PageSize) | 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total) | ||||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total) | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取任务执行日志成功", resp, actionType, "获取任务执行日志成功", req) | ||||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取任务执行日志成功", resp, actionType, "获取任务执行日志成功", req) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // ListPendingCollections godoc | // ListPendingCollections godoc | ||||||
| @@ -225,43 +166,28 @@ func (c *Controller) ListTaskExecutionLogs(ctx *gin.Context) { | |||||||
| // @Param        query query dto.ListPendingCollectionRequest true "查询参数" | // @Param        query query dto.ListPendingCollectionRequest true "查询参数" | ||||||
| // @Success      200 {object} controller.Response{data=dto.ListPendingCollectionResponse} | // @Success      200 {object} controller.Response{data=dto.ListPendingCollectionResponse} | ||||||
| // @Router       /api/v1/monitor/pending-collections [get] | // @Router       /api/v1/monitor/pending-collections [get] | ||||||
| func (c *Controller) ListPendingCollections(ctx *gin.Context) { | func (c *Controller) ListPendingCollections(ctx echo.Context) error { | ||||||
| 	const actionType = "获取待采集请求列表" | 	const actionType = "获取待采集请求列表" | ||||||
|  |  | ||||||
| 	var req dto.ListPendingCollectionRequest | 	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) | 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	opts := repository.PendingCollectionListOptions{ | 	resp, err := c.monitorService.ListPendingCollections(&req) | ||||||
| 		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) |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if errors.Is(err, repository.ErrInvalidPagination) { | 		if errors.Is(err, repository.ErrInvalidPagination) { | ||||||
| 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | 			return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取待采集请求失败: "+err.Error(), actionType, "服务层查询失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取待采集请求失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	resp := dto.NewListPendingCollectionResponse(data, total, req.Page, req.PageSize) | 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total) | ||||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total) | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取待采集请求成功", resp, actionType, "获取待采集请求成功", req) | ||||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取待采集请求成功", resp, actionType, "获取待采集请求成功", req) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // ListUserActionLogs godoc | // ListUserActionLogs godoc | ||||||
| @@ -273,45 +199,28 @@ func (c *Controller) ListPendingCollections(ctx *gin.Context) { | |||||||
| // @Param        query query dto.ListUserActionLogRequest true "查询参数" | // @Param        query query dto.ListUserActionLogRequest true "查询参数" | ||||||
| // @Success      200 {object} controller.Response{data=dto.ListUserActionLogResponse} | // @Success      200 {object} controller.Response{data=dto.ListUserActionLogResponse} | ||||||
| // @Router       /api/v1/monitor/user-action-logs [get] | // @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 = "获取用户操作日志列表" | 	const actionType = "获取用户操作日志列表" | ||||||
|  |  | ||||||
| 	var req dto.ListUserActionLogRequest | 	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) | 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	opts := repository.UserActionLogListOptions{ | 	resp, err := c.monitorService.ListUserActionLogs(&req) | ||||||
| 		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) |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if errors.Is(err, repository.ErrInvalidPagination) { | 		if errors.Is(err, repository.ErrInvalidPagination) { | ||||||
| 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | 			return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取用户操作日志失败: "+err.Error(), actionType, "服务层查询失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取用户操作日志失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	resp := dto.NewListUserActionLogResponse(data, total, req.Page, req.PageSize) | 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total) | ||||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total) | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取用户操作日志成功", resp, actionType, "获取用户操作日志成功", req) | ||||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取用户操作日志成功", resp, actionType, "获取用户操作日志成功", req) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // ListRawMaterialPurchases godoc | // ListRawMaterialPurchases godoc | ||||||
| @@ -323,40 +232,28 @@ func (c *Controller) ListUserActionLogs(ctx *gin.Context) { | |||||||
| // @Param        query query dto.ListRawMaterialPurchaseRequest true "查询参数" | // @Param        query query dto.ListRawMaterialPurchaseRequest true "查询参数" | ||||||
| // @Success      200 {object} controller.Response{data=dto.ListRawMaterialPurchaseResponse} | // @Success      200 {object} controller.Response{data=dto.ListRawMaterialPurchaseResponse} | ||||||
| // @Router       /api/v1/monitor/raw-material-purchases [get] | // @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 = "获取原料采购记录列表" | 	const actionType = "获取原料采购记录列表" | ||||||
|  |  | ||||||
| 	var req dto.ListRawMaterialPurchaseRequest | 	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) | 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	opts := repository.RawMaterialPurchaseListOptions{ | 	resp, err := c.monitorService.ListRawMaterialPurchases(&req) | ||||||
| 		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) |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if errors.Is(err, repository.ErrInvalidPagination) { | 		if errors.Is(err, repository.ErrInvalidPagination) { | ||||||
| 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | 			return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取原料采购记录失败: "+err.Error(), actionType, "服务层查询失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取原料采购记录失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	resp := dto.NewListRawMaterialPurchaseResponse(data, total, req.Page, req.PageSize) | 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total) | ||||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total) | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取原料采购记录成功", resp, actionType, "获取原料采购记录成功", req) | ||||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取原料采购记录成功", resp, actionType, "获取原料采购记录成功", req) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // ListRawMaterialStockLogs godoc | // ListRawMaterialStockLogs godoc | ||||||
| @@ -368,44 +265,28 @@ func (c *Controller) ListRawMaterialPurchases(ctx *gin.Context) { | |||||||
| // @Param        query query dto.ListRawMaterialStockLogRequest true "查询参数" | // @Param        query query dto.ListRawMaterialStockLogRequest true "查询参数" | ||||||
| // @Success      200 {object} controller.Response{data=dto.ListRawMaterialStockLogResponse} | // @Success      200 {object} controller.Response{data=dto.ListRawMaterialStockLogResponse} | ||||||
| // @Router       /api/v1/monitor/raw-material-stock-logs [get] | // @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 = "获取原料库存日志列表" | 	const actionType = "获取原料库存日志列表" | ||||||
|  |  | ||||||
| 	var req dto.ListRawMaterialStockLogRequest | 	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) | 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	opts := repository.RawMaterialStockLogListOptions{ | 	resp, err := c.monitorService.ListRawMaterialStockLogs(&req) | ||||||
| 		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) |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if errors.Is(err, repository.ErrInvalidPagination) { | 		if errors.Is(err, repository.ErrInvalidPagination) { | ||||||
| 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | 			return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取原料库存日志失败: "+err.Error(), actionType, "服务层查询失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取原料库存日志失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	resp := dto.NewListRawMaterialStockLogResponse(data, total, req.Page, req.PageSize) | 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total) | ||||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total) | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取原料库存日志成功", resp, actionType, "获取原料库存日志成功", req) | ||||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取原料库存日志成功", resp, actionType, "获取原料库存日志成功", req) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // ListFeedUsageRecords godoc | // ListFeedUsageRecords godoc | ||||||
| @@ -417,41 +298,28 @@ func (c *Controller) ListRawMaterialStockLogs(ctx *gin.Context) { | |||||||
| // @Param        query query dto.ListFeedUsageRecordRequest true "查询参数" | // @Param        query query dto.ListFeedUsageRecordRequest true "查询参数" | ||||||
| // @Success      200 {object} controller.Response{data=dto.ListFeedUsageRecordResponse} | // @Success      200 {object} controller.Response{data=dto.ListFeedUsageRecordResponse} | ||||||
| // @Router       /api/v1/monitor/feed-usage-records [get] | // @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 = "获取饲料使用记录列表" | 	const actionType = "获取饲料使用记录列表" | ||||||
|  |  | ||||||
| 	var req dto.ListFeedUsageRecordRequest | 	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) | 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	opts := repository.FeedUsageRecordListOptions{ | 	resp, err := c.monitorService.ListFeedUsageRecords(&req) | ||||||
| 		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) |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if errors.Is(err, repository.ErrInvalidPagination) { | 		if errors.Is(err, repository.ErrInvalidPagination) { | ||||||
| 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | 			return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取饲料使用记录失败: "+err.Error(), actionType, "服务层查询失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取饲料使用记录失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	resp := dto.NewListFeedUsageRecordResponse(data, total, req.Page, req.PageSize) | 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total) | ||||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total) | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取饲料使用记录成功", resp, actionType, "获取饲料使用记录成功", req) | ||||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取饲料使用记录成功", resp, actionType, "获取饲料使用记录成功", req) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // ListMedicationLogs godoc | // ListMedicationLogs godoc | ||||||
| @@ -463,45 +331,28 @@ func (c *Controller) ListFeedUsageRecords(ctx *gin.Context) { | |||||||
| // @Param        query query dto.ListMedicationLogRequest true "查询参数" | // @Param        query query dto.ListMedicationLogRequest true "查询参数" | ||||||
| // @Success      200 {object} controller.Response{data=dto.ListMedicationLogResponse} | // @Success      200 {object} controller.Response{data=dto.ListMedicationLogResponse} | ||||||
| // @Router       /api/v1/monitor/medication-logs [get] | // @Router       /api/v1/monitor/medication-logs [get] | ||||||
| func (c *Controller) ListMedicationLogs(ctx *gin.Context) { | func (c *Controller) ListMedicationLogs(ctx echo.Context) error { | ||||||
| 	const actionType = "获取用药记录列表" | 	const actionType = "获取用药记录列表" | ||||||
|  |  | ||||||
| 	var req dto.ListMedicationLogRequest | 	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) | 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	opts := repository.MedicationLogListOptions{ | 	resp, err := c.monitorService.ListMedicationLogs(&req) | ||||||
| 		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) |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if errors.Is(err, repository.ErrInvalidPagination) { | 		if errors.Is(err, repository.ErrInvalidPagination) { | ||||||
| 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | 			return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取用药记录失败: "+err.Error(), actionType, "服务层查询失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取用药记录失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	resp := dto.NewListMedicationLogResponse(data, total, req.Page, req.PageSize) | 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total) | ||||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total) | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取用药记录成功", resp, actionType, "获取用药记录成功", req) | ||||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取用药记录成功", resp, actionType, "获取用药记录成功", req) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // ListPigBatchLogs godoc | // ListPigBatchLogs godoc | ||||||
| @@ -513,44 +364,28 @@ func (c *Controller) ListMedicationLogs(ctx *gin.Context) { | |||||||
| // @Param        query query dto.ListPigBatchLogRequest true "查询参数" | // @Param        query query dto.ListPigBatchLogRequest true "查询参数" | ||||||
| // @Success      200 {object} controller.Response{data=dto.ListPigBatchLogResponse} | // @Success      200 {object} controller.Response{data=dto.ListPigBatchLogResponse} | ||||||
| // @Router       /api/v1/monitor/pig-batch-logs [get] | // @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 = "获取猪批次日志列表" | 	const actionType = "获取猪批次日志列表" | ||||||
|  |  | ||||||
| 	var req dto.ListPigBatchLogRequest | 	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) | 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	opts := repository.PigBatchLogListOptions{ | 	resp, err := c.monitorService.ListPigBatchLogs(&req) | ||||||
| 		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) |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if errors.Is(err, repository.ErrInvalidPagination) { | 		if errors.Is(err, repository.ErrInvalidPagination) { | ||||||
| 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | 			return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取猪批次日志失败: "+err.Error(), actionType, "服务层查询失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取猪批次日志失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	resp := dto.NewListPigBatchLogResponse(data, total, req.Page, req.PageSize) | 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total) | ||||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total) | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取猪批次日志成功", resp, actionType, "获取猪批次日志成功", req) | ||||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取猪批次日志成功", resp, actionType, "获取猪批次日志成功", req) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // ListWeighingBatches godoc | // ListWeighingBatches godoc | ||||||
| @@ -562,39 +397,28 @@ func (c *Controller) ListPigBatchLogs(ctx *gin.Context) { | |||||||
| // @Param        query query dto.ListWeighingBatchRequest true "查询参数" | // @Param        query query dto.ListWeighingBatchRequest true "查询参数" | ||||||
| // @Success      200 {object} controller.Response{data=dto.ListWeighingBatchResponse} | // @Success      200 {object} controller.Response{data=dto.ListWeighingBatchResponse} | ||||||
| // @Router       /api/v1/monitor/weighing-batches [get] | // @Router       /api/v1/monitor/weighing-batches [get] | ||||||
| func (c *Controller) ListWeighingBatches(ctx *gin.Context) { | func (c *Controller) ListWeighingBatches(ctx echo.Context) error { | ||||||
| 	const actionType = "获取批次称重记录列表" | 	const actionType = "获取批次称重记录列表" | ||||||
|  |  | ||||||
| 	var req dto.ListWeighingBatchRequest | 	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) | 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	opts := repository.WeighingBatchListOptions{ | 	resp, err := c.monitorService.ListWeighingBatches(&req) | ||||||
| 		PigBatchID: req.PigBatchID, |  | ||||||
| 		OrderBy:    req.OrderBy, |  | ||||||
| 		StartTime:  req.StartTime, |  | ||||||
| 		EndTime:    req.EndTime, |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	data, total, err := c.monitorService.ListWeighingBatches(opts, req.Page, req.PageSize) |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if errors.Is(err, repository.ErrInvalidPagination) { | 		if errors.Is(err, repository.ErrInvalidPagination) { | ||||||
| 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | 			return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取批次称重记录失败: "+err.Error(), actionType, "服务层查询失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取批次称重记录失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	resp := dto.NewListWeighingBatchResponse(data, total, req.Page, req.PageSize) | 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total) | ||||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total) | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取批次称重记录成功", resp, actionType, "获取批次称重记录成功", req) | ||||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取批次称重记录成功", resp, actionType, "获取批次称重记录成功", req) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // ListWeighingRecords godoc | // ListWeighingRecords godoc | ||||||
| @@ -606,41 +430,28 @@ func (c *Controller) ListWeighingBatches(ctx *gin.Context) { | |||||||
| // @Param        query query dto.ListWeighingRecordRequest true "查询参数" | // @Param        query query dto.ListWeighingRecordRequest true "查询参数" | ||||||
| // @Success      200 {object} controller.Response{data=dto.ListWeighingRecordResponse} | // @Success      200 {object} controller.Response{data=dto.ListWeighingRecordResponse} | ||||||
| // @Router       /api/v1/monitor/weighing-records [get] | // @Router       /api/v1/monitor/weighing-records [get] | ||||||
| func (c *Controller) ListWeighingRecords(ctx *gin.Context) { | func (c *Controller) ListWeighingRecords(ctx echo.Context) error { | ||||||
| 	const actionType = "获取单次称重记录列表" | 	const actionType = "获取单次称重记录列表" | ||||||
|  |  | ||||||
| 	var req dto.ListWeighingRecordRequest | 	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) | 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	opts := repository.WeighingRecordListOptions{ | 	resp, err := c.monitorService.ListWeighingRecords(&req) | ||||||
| 		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) |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if errors.Is(err, repository.ErrInvalidPagination) { | 		if errors.Is(err, repository.ErrInvalidPagination) { | ||||||
| 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | 			return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取单次称重记录失败: "+err.Error(), actionType, "服务层查询失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取单次称重记录失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	resp := dto.NewListWeighingRecordResponse(data, total, req.Page, req.PageSize) | 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total) | ||||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total) | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取单次称重记录成功", resp, actionType, "获取单次称重记录成功", req) | ||||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取单次称重记录成功", resp, actionType, "获取单次称重记录成功", req) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // ListPigTransferLogs godoc | // ListPigTransferLogs godoc | ||||||
| @@ -652,46 +463,28 @@ func (c *Controller) ListWeighingRecords(ctx *gin.Context) { | |||||||
| // @Param        query query dto.ListPigTransferLogRequest true "查询参数" | // @Param        query query dto.ListPigTransferLogRequest true "查询参数" | ||||||
| // @Success      200 {object} controller.Response{data=dto.ListPigTransferLogResponse} | // @Success      200 {object} controller.Response{data=dto.ListPigTransferLogResponse} | ||||||
| // @Router       /api/v1/monitor/pig-transfer-logs [get] | // @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 = "获取猪只迁移日志列表" | 	const actionType = "获取猪只迁移日志列表" | ||||||
|  |  | ||||||
| 	var req dto.ListPigTransferLogRequest | 	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) | 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	opts := repository.PigTransferLogListOptions{ | 	resp, err := c.monitorService.ListPigTransferLogs(&req) | ||||||
| 		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) |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if errors.Is(err, repository.ErrInvalidPagination) { | 		if errors.Is(err, repository.ErrInvalidPagination) { | ||||||
| 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | 			return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取猪只迁移日志失败: "+err.Error(), actionType, "服务层查询失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取猪只迁移日志失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	resp := dto.NewListPigTransferLogResponse(data, total, req.Page, req.PageSize) | 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total) | ||||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total) | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取猪只迁移日志成功", resp, actionType, "获取猪只迁移日志成功", req) | ||||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取猪只迁移日志成功", resp, actionType, "获取猪只迁移日志成功", req) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // ListPigSickLogs godoc | // ListPigSickLogs godoc | ||||||
| @@ -703,49 +496,28 @@ func (c *Controller) ListPigTransferLogs(ctx *gin.Context) { | |||||||
| // @Param        query query dto.ListPigSickLogRequest true "查询参数" | // @Param        query query dto.ListPigSickLogRequest true "查询参数" | ||||||
| // @Success      200 {object} controller.Response{data=dto.ListPigSickLogResponse} | // @Success      200 {object} controller.Response{data=dto.ListPigSickLogResponse} | ||||||
| // @Router       /api/v1/monitor/pig-sick-logs [get] | // @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 = "获取病猪日志列表" | 	const actionType = "获取病猪日志列表" | ||||||
|  |  | ||||||
| 	var req dto.ListPigSickLogRequest | 	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) | 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	opts := repository.PigSickLogListOptions{ | 	resp, err := c.monitorService.ListPigSickLogs(&req) | ||||||
| 		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) |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if errors.Is(err, repository.ErrInvalidPagination) { | 		if errors.Is(err, repository.ErrInvalidPagination) { | ||||||
| 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | 			return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取病猪日志失败: "+err.Error(), actionType, "服务层查询失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取病猪日志失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	resp := dto.NewListPigSickLogResponse(data, total, req.Page, req.PageSize) | 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total) | ||||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total) | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取病猪日志成功", resp, actionType, "获取病猪日志成功", req) | ||||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取病猪日志成功", resp, actionType, "获取病猪日志成功", req) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // ListPigPurchases godoc | // ListPigPurchases godoc | ||||||
| @@ -757,41 +529,28 @@ func (c *Controller) ListPigSickLogs(ctx *gin.Context) { | |||||||
| // @Param        query query dto.ListPigPurchaseRequest true "查询参数" | // @Param        query query dto.ListPigPurchaseRequest true "查询参数" | ||||||
| // @Success      200 {object} controller.Response{data=dto.ListPigPurchaseResponse} | // @Success      200 {object} controller.Response{data=dto.ListPigPurchaseResponse} | ||||||
| // @Router       /api/v1/monitor/pig-purchases [get] | // @Router       /api/v1/monitor/pig-purchases [get] | ||||||
| func (c *Controller) ListPigPurchases(ctx *gin.Context) { | func (c *Controller) ListPigPurchases(ctx echo.Context) error { | ||||||
| 	const actionType = "获取猪只采购记录列表" | 	const actionType = "获取猪只采购记录列表" | ||||||
|  |  | ||||||
| 	var req dto.ListPigPurchaseRequest | 	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) | 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	opts := repository.PigPurchaseListOptions{ | 	resp, err := c.monitorService.ListPigPurchases(&req) | ||||||
| 		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) |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if errors.Is(err, repository.ErrInvalidPagination) { | 		if errors.Is(err, repository.ErrInvalidPagination) { | ||||||
| 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | 			return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取猪只采购记录失败: "+err.Error(), actionType, "服务层查询失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取猪只采购记录失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	resp := dto.NewListPigPurchaseResponse(data, total, req.Page, req.PageSize) | 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total) | ||||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total) | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取猪只采购记录成功", resp, actionType, "获取猪只采购记录成功", req) | ||||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取猪只采购记录成功", resp, actionType, "获取猪只采购记录成功", req) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // ListPigSales godoc | // ListPigSales godoc | ||||||
| @@ -803,39 +562,59 @@ func (c *Controller) ListPigPurchases(ctx *gin.Context) { | |||||||
| // @Param        query query dto.ListPigSaleRequest true "查询参数" | // @Param        query query dto.ListPigSaleRequest true "查询参数" | ||||||
| // @Success      200 {object} controller.Response{data=dto.ListPigSaleResponse} | // @Success      200 {object} controller.Response{data=dto.ListPigSaleResponse} | ||||||
| // @Router       /api/v1/monitor/pig-sales [get] | // @Router       /api/v1/monitor/pig-sales [get] | ||||||
| func (c *Controller) ListPigSales(ctx *gin.Context) { | func (c *Controller) ListPigSales(ctx echo.Context) error { | ||||||
| 	const actionType = "获取猪只售卖记录列表" | 	const actionType = "获取猪只售卖记录列表" | ||||||
|  |  | ||||||
| 	var req dto.ListPigSaleRequest | 	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) | 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "参数绑定失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	opts := repository.PigSaleListOptions{ | 	resp, err := c.monitorService.ListPigSales(&req) | ||||||
| 		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) |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if errors.Is(err, repository.ErrInvalidPagination) { | 		if errors.Is(err, repository.ErrInvalidPagination) { | ||||||
| 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | 			c.logger.Warnf("%s: 无效的分页参数: %v", actionType, err) | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | 			return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的分页参数: "+err.Error(), actionType, "无效分页参数", req) | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | 		c.logger.Errorf("%s: 服务层查询失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取猪只售卖记录失败: "+err.Error(), actionType, "服务层查询失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取猪只售卖记录失败: "+err.Error(), actionType, "服务层查询失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	resp := dto.NewListPigSaleResponse(data, total, req.Page, req.PageSize) | 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(resp.List), resp.Pagination.Total) | ||||||
| 	c.logger.Infof("%s: 成功, 获取到 %d 条记录, 总计 %d 条", actionType, len(data), total) | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取猪只售卖记录成功", resp, actionType, "获取猪只售卖记录成功", req) | ||||||
| 	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/controller" | ||||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/dto" | 	"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/logs" | ||||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | 	"github.com/labstack/echo/v4" | ||||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" |  | ||||||
| 	"github.com/gin-gonic/gin" |  | ||||||
| 	"gorm.io/gorm" |  | ||||||
| ) | ) | ||||||
|  |  | ||||||
| // --- Controller 定义 --- | // --- 控制器定义 --- | ||||||
|  |  | ||||||
| // Controller 定义了计划相关的控制器 | // Controller 定义了计划相关的控制器 | ||||||
| type Controller struct { | type Controller struct { | ||||||
| 	logger      *logs.Logger | 	logger      *logs.Logger | ||||||
| 	planRepo                repository.PlanRepository | 	planService service.PlanService | ||||||
| 	analysisPlanTaskManager *task.AnalysisPlanTaskManager |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // NewController 创建一个新的 Controller 实例 | // 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{ | 	return &Controller{ | ||||||
| 		logger:      logger, | 		logger:      logger, | ||||||
| 		planRepo:                planRepo, | 		planService: planService, | ||||||
| 		analysisPlanTaskManager: analysisPlanTaskManager, |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -44,55 +39,28 @@ func NewController(logger *logs.Logger, planRepo repository.PlanRepository, anal | |||||||
| // @Param        plan body dto.CreatePlanRequest true "计划信息" | // @Param        plan body dto.CreatePlanRequest true "计划信息" | ||||||
| // @Success      200 {object} controller.Response{data=dto.PlanResponse} "业务码为201代表创建成功" | // @Success      200 {object} controller.Response{data=dto.PlanResponse} "业务码为201代表创建成功" | ||||||
| // @Router       /api/v1/plans [post] | // @Router       /api/v1/plans [post] | ||||||
| func (c *Controller) CreatePlan(ctx *gin.Context) { | func (c *Controller) CreatePlan(ctx echo.Context) error { | ||||||
| 	var req dto.CreatePlanRequest | 	var req dto.CreatePlanRequest | ||||||
| 	const actionType = "创建计划" | 	const actionType = "创建计划" | ||||||
| 	if err := ctx.ShouldBindJSON(&req); err != nil { | 	if err := ctx.Bind(&req); err != nil { | ||||||
| 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// 使用已有的转换函数,它已经包含了验证和重排逻辑 | 	// 调用服务层创建计划 | ||||||
| 	planToCreate, err := dto.NewPlanFromCreateRequest(&req) | 	resp, err := c.planService.CreatePlan(&req) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		c.logger.Errorf("%s: 计划数据校验失败: %v", actionType, err) | 		c.logger.Errorf("%s: 服务层创建计划失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "计划数据校验失败: "+err.Error(), actionType, "计划数据校验失败", req) | 		// 根据服务层返回的错误类型,转换为相应的HTTP状态码 | ||||||
| 		return | 		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) | ||||||
| 	// --- 自动判断 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.Infof("%s: 计划创建成功, ID: %d", actionType, planToCreate.ID) | 	c.logger.Infof("%s: 计划创建成功, ID: %d", actionType, resp.ID) | ||||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "计划创建成功", resp, actionType, "计划创建成功", resp) | 	return controller.SendSuccessWithAudit(ctx, controller.CodeCreated, "计划创建成功", resp, actionType, "计划创建成功", resp) | ||||||
| } | } | ||||||
|  |  | ||||||
| // GetPlan godoc | // GetPlan godoc | ||||||
| @@ -104,87 +72,62 @@ func (c *Controller) CreatePlan(ctx *gin.Context) { | |||||||
| // @Param        id path int true "计划ID" | // @Param        id path int true "计划ID" | ||||||
| // @Success      200 {object} controller.Response{data=dto.PlanResponse} "业务码为200代表成功获取" | // @Success      200 {object} controller.Response{data=dto.PlanResponse} "业务码为200代表成功获取" | ||||||
| // @Router       /api/v1/plans/{id} [get] | // @Router       /api/v1/plans/{id} [get] | ||||||
| func (c *Controller) GetPlan(ctx *gin.Context) { | func (c *Controller) GetPlan(ctx echo.Context) error { | ||||||
| 	const actionType = "获取计划详情" | 	const actionType = "获取计划详情" | ||||||
| 	// 1. 从 URL 路径中获取 ID | 	// 1. 从 URL 路径中获取 ID | ||||||
| 	idStr := ctx.Param("id") | 	idStr := ctx.Param("id") | ||||||
| 	id, err := strconv.ParseUint(idStr, 10, 32) | 	id, err := strconv.ParseUint(idStr, 10, 32) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		c.logger.Errorf("%s: 计划ID格式错误: %v, ID: %s", actionType, err, idStr) | 		c.logger.Errorf("%s: 计划ID格式错误: %v, ID: %s", actionType, err, idStr) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的计划ID格式", actionType, "计划ID格式错误", idStr) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的计划ID格式", actionType, "计划ID格式错误", idStr) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// 2. 调用仓库层获取计划详情 | 	// 调用服务层获取计划详情 | ||||||
| 	plan, err := c.planRepo.GetPlanByID(uint(id)) | 	resp, err := c.planService.GetPlanByID(uint(id)) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		// 判断是否为“未找到”错误 | 		c.logger.Errorf("%s: 服务层获取计划详情失败: %v, ID: %d", actionType, err, id) | ||||||
| 		if errors.Is(err, gorm.ErrRecordNotFound) { | 		if errors.Is(err, service.ErrPlanNotFound) { | ||||||
| 			c.logger.Warnf("%s: 计划不存在, ID: %d", actionType, id) | 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "计划不存在", id) | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "计划不存在", actionType, "计划不存在", id) |  | ||||||
| 			return |  | ||||||
| 		} | 		} | ||||||
| 		// 其他数据库错误视为内部错误 | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取计划详情失败: "+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 |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// 4. 发送成功响应 | 	// 4. 发送成功响应 | ||||||
| 	c.logger.Infof("%s: 获取计划详情成功, ID: %d", actionType, id) | 	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 | // ListPlans godoc | ||||||
| // @Summary      获取计划列表 | // @Summary      获取计划列表 | ||||||
| // @Description  获取所有计划的列表 | // @Description  获取所有计划的列表,支持按类型过滤和分页 | ||||||
| // @Tags         计划管理 | // @Tags         计划管理 | ||||||
| // @Security     BearerAuth | // @Security     BearerAuth | ||||||
| // @Produce      json | // @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] | // @Router       /api/v1/plans [get] | ||||||
| func (c *Controller) ListPlans(ctx *gin.Context) { | func (c *Controller) ListPlans(ctx echo.Context) error { | ||||||
| 	const actionType = "获取计划列表" | 	const actionType = "获取计划列表" | ||||||
| 	// 1. 调用仓库层获取所有计划 | 	var query dto.ListPlansQuery | ||||||
| 	plans, err := c.planRepo.ListBasicPlans() | 	if err := ctx.Bind(&query); err != nil { | ||||||
| 	if err != nil { | 		c.logger.Errorf("%s: 查询参数绑定失败: %v", actionType, err) | ||||||
| 		c.logger.Errorf("%s: 数据库查询失败: %v", actionType, err) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的查询参数: "+err.Error(), actionType, "查询参数绑定失败", query) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取计划列表时发生内部错误", actionType, "数据库查询失败", nil) |  | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// 2. 将模型转换为响应 DTO | 	// 调用服务层获取计划列表 | ||||||
| 	planResponses := make([]dto.PlanResponse, 0, len(plans)) | 	resp, err := c.planService.ListPlans(&query) | ||||||
| 	for _, p := range plans { |  | ||||||
| 		resp, err := dto.NewPlanToResponse(&p) |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 			c.logger.Errorf("%s: 序列化响应失败: %v, Plan: %+v", actionType, err, p) | 		c.logger.Errorf("%s: 服务层获取计划列表失败: %v", actionType, err) | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取计划列表失败: 内部数据格式错误", actionType, "响应序列化失败", p) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取计划列表失败: "+err.Error(), actionType, "服务层获取计划列表失败", nil) | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
| 		planResponses = append(planResponses, *resp) |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// 3. 构造并发送成功响应 | 	c.logger.Infof("%s: 获取计划列表成功, 数量: %d", actionType, len(resp.Plans)) | ||||||
| 	resp := dto.ListPlansResponse{ | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取计划列表成功", resp, actionType, "获取计划列表成功", resp) | ||||||
| 		Plans: planResponses, |  | ||||||
| 		Total: len(planResponses), |  | ||||||
| 	} |  | ||||||
| 	c.logger.Infof("%s: 获取计划列表成功, 数量: %d", actionType, len(planResponses)) |  | ||||||
| 	controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "获取计划列表成功", resp, actionType, "获取计划列表成功", resp) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // UpdatePlan godoc | // UpdatePlan godoc | ||||||
| // @Summary      更新计划 | // @Summary      更新计划 | ||||||
| // @Description  根据计划ID更新计划的详细信息。 | // @Description  根据计划ID更新计划的详细信息。系统计划不允许修改。 | ||||||
| // @Tags         计划管理 | // @Tags         计划管理 | ||||||
| // @Security     BearerAuth | // @Security     BearerAuth | ||||||
| // @Accept       json | // @Accept       json | ||||||
| @@ -193,275 +136,148 @@ func (c *Controller) ListPlans(ctx *gin.Context) { | |||||||
| // @Param        plan body dto.UpdatePlanRequest true "更新后的计划信息" | // @Param        plan body dto.UpdatePlanRequest true "更新后的计划信息" | ||||||
| // @Success      200 {object} controller.Response{data=dto.PlanResponse} "业务码为200代表更新成功" | // @Success      200 {object} controller.Response{data=dto.PlanResponse} "业务码为200代表更新成功" | ||||||
| // @Router       /api/v1/plans/{id} [put] | // @Router       /api/v1/plans/{id} [put] | ||||||
| func (c *Controller) UpdatePlan(ctx *gin.Context) { | func (c *Controller) UpdatePlan(ctx echo.Context) error { | ||||||
| 	const actionType = "更新计划" | 	const actionType = "更新计划" | ||||||
| 	// 1. 从 URL 路径中获取 ID | 	// 1. 从 URL 路径中获取 ID | ||||||
| 	idStr := ctx.Param("id") | 	idStr := ctx.Param("id") | ||||||
| 	id, err := strconv.ParseUint(idStr, 10, 32) | 	id, err := strconv.ParseUint(idStr, 10, 32) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		c.logger.Errorf("%s: 计划ID格式错误: %v, ID: %s", actionType, err, idStr) | 		c.logger.Errorf("%s: 计划ID格式错误: %v, ID: %s", actionType, err, idStr) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的计划ID格式", actionType, "计划ID格式错误", idStr) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的计划ID格式", actionType, "计划ID格式错误", idStr) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// 2. 绑定请求体 | 	// 2. 绑定请求体 | ||||||
| 	var req dto.UpdatePlanRequest | 	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) | 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的请求体: "+err.Error(), actionType, "请求体绑定失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// 3. 将请求转换为模型(转换函数带校验) | 	// 调用服务层更新计划 | ||||||
| 	planToUpdate, err := dto.NewPlanFromUpdateRequest(&req) | 	resp, err := c.planService.UpdatePlan(uint(id), &req) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		c.logger.Errorf("%s: 计划数据校验失败: %v", actionType, err) | 		c.logger.Errorf("%s: 服务层更新计划失败: %v, ID: %d", actionType, err, id) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "计划数据校验失败: "+err.Error(), actionType, "计划数据校验失败", req) | 		if errors.Is(err, service.ErrPlanNotFound) { | ||||||
| 		return | 			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) | ||||||
| 		} | 		} | ||||||
| 	planToUpdate.ID = uint(id) // 确保ID被设置 | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "更新计划失败: "+err.Error(), actionType, "服务层更新计划失败", req) | ||||||
|  |  | ||||||
| 	// --- 自动判断 ContentType --- |  | ||||||
| 	if len(req.SubPlanIDs) > 0 { |  | ||||||
| 		planToUpdate.ContentType = models.PlanContentTypeSubPlans |  | ||||||
| 	} else { |  | ||||||
| 		// 如果 SubPlanIDs 未提供,则默认为 Tasks 类型(即使 Tasks 字段也未提供) |  | ||||||
| 		planToUpdate.ContentType = models.PlanContentTypeTasks |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// 4. 检查计划是否存在 | 	// 9. 发送成功响应 | ||||||
| 	_, err = c.planRepo.GetBasicPlanByID(uint(id)) | 	c.logger.Infof("%s: 计划更新成功, ID: %d", actionType, resp.ID) | ||||||
| 	if err != nil { | 	return controller.SendSuccessWithAudit(ctx, controller.CodeSuccess, "计划更新成功", resp, actionType, "计划更新成功", resp) | ||||||
| 		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) |  | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取计划信息时发生内部错误", actionType, "数据库查询失败", id) |  | ||||||
| 		return |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	// 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) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // DeletePlan godoc | // DeletePlan godoc | ||||||
| // @Summary      删除计划 | // @Summary      删除计划 | ||||||
| // @Description  根据计划ID删除计划。(软删除) | // @Description  根据计划ID删除计划。(软删除)系统计划不允许删除。 | ||||||
| // @Tags         计划管理 | // @Tags         计划管理 | ||||||
| // @Security     BearerAuth | // @Security     BearerAuth | ||||||
| // @Produce      json | // @Produce      json | ||||||
| // @Param        id path int true "计划ID" | // @Param        id path int true "计划ID" | ||||||
| // @Success      200 {object} controller.Response "业务码为200代表删除成功" | // @Success      200 {object} controller.Response "业务码为200代表删除成功" | ||||||
| // @Router       /api/v1/plans/{id} [delete] | // @Router       /api/v1/plans/{id} [delete] | ||||||
| func (c *Controller) DeletePlan(ctx *gin.Context) { | func (c *Controller) DeletePlan(ctx echo.Context) error { | ||||||
| 	const actionType = "删除计划" | 	const actionType = "删除计划" | ||||||
| 	// 1. 从 URL 路径中获取 ID | 	// 1. 从 URL 路径中获取 ID | ||||||
| 	idStr := ctx.Param("id") | 	idStr := ctx.Param("id") | ||||||
| 	id, err := strconv.ParseUint(idStr, 10, 32) | 	id, err := strconv.ParseUint(idStr, 10, 32) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		c.logger.Errorf("%s: 计划ID格式错误: %v, ID: %s", actionType, err, idStr) | 		c.logger.Errorf("%s: 计划ID格式错误: %v, ID: %s", actionType, err, idStr) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的计划ID格式", actionType, "计划ID格式错误", idStr) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的计划ID格式", actionType, "计划ID格式错误", idStr) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// 2. 检查计划是否存在 | 	// 调用服务层删除计划 | ||||||
| 	plan, err := c.planRepo.GetBasicPlanByID(uint(id)) | 	err = c.planService.DeletePlan(uint(id)) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if errors.Is(err, gorm.ErrRecordNotFound) { | 		c.logger.Errorf("%s: 服务层删除计划失败: %v, ID: %d", actionType, err, id) | ||||||
| 			c.logger.Warnf("%s: 计划不存在, ID: %d", actionType, id) | 		if errors.Is(err, service.ErrPlanNotFound) { | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "计划不存在", actionType, "计划不存在", id) | 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "计划不存在", id) | ||||||
| 			return | 		} 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) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "删除计划失败: "+err.Error(), actionType, "服务层删除计划失败", id) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取计划信息时发生内部错误", actionType, "数据库查询失败", id) |  | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// 3. 停止这个计划 | 	// 6. 发送成功响应 | ||||||
| 	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. 发送成功响应 |  | ||||||
| 	c.logger.Infof("%s: 计划删除成功, ID: %d", actionType, id) | 	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 | // StartPlan godoc | ||||||
| // @Summary      启动计划 | // @Summary      启动计划 | ||||||
| // @Description  根据计划ID启动一个计划的执行。 | // @Description  根据计划ID启动一个计划的执行。系统计划不允许手动启动。 | ||||||
| // @Tags         计划管理 | // @Tags         计划管理 | ||||||
| // @Security     BearerAuth | // @Security     BearerAuth | ||||||
| // @Produce      json | // @Produce      json | ||||||
| // @Param        id path int true "计划ID" | // @Param        id path int true "计划ID" | ||||||
| // @Success      200 {object} controller.Response "业务码为200代表成功启动计划" | // @Success      200 {object} controller.Response "业务码为200代表成功启动计划" | ||||||
| // @Router       /api/v1/plans/{id}/start [post] | // @Router       /api/v1/plans/{id}/start [post] | ||||||
| func (c *Controller) StartPlan(ctx *gin.Context) { | func (c *Controller) StartPlan(ctx echo.Context) error { | ||||||
| 	const actionType = "启动计划" | 	const actionType = "启动计划" | ||||||
| 	// 1. 从 URL 路径中获取 ID | 	// 1. 从 URL 路径中获取 ID | ||||||
| 	idStr := ctx.Param("id") | 	idStr := ctx.Param("id") | ||||||
| 	id, err := strconv.ParseUint(idStr, 10, 32) | 	id, err := strconv.ParseUint(idStr, 10, 32) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		c.logger.Errorf("%s: 计划ID格式错误: %v, ID: %s", actionType, err, idStr) | 		c.logger.Errorf("%s: 计划ID格式错误: %v, ID: %s", actionType, err, idStr) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的计划ID格式", actionType, "计划ID格式错误", idStr) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的计划ID格式", actionType, "计划ID格式错误", idStr) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// 2. 检查计划是否存在 | 	// 调用服务层启动计划 | ||||||
| 	plan, err := c.planRepo.GetBasicPlanByID(uint(id)) | 	err = c.planService.StartPlan(uint(id)) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if errors.Is(err, gorm.ErrRecordNotFound) { | 		c.logger.Errorf("%s: 服务层启动计划失败: %v, ID: %d", actionType, err, id) | ||||||
| 			c.logger.Warnf("%s: 计划不存在, ID: %d", actionType, id) | 		if errors.Is(err, service.ErrPlanNotFound) { | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "计划不存在", actionType, "计划不存在", id) | 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "计划不存在", id) | ||||||
| 			return | 		} 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) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "启动计划失败: "+err.Error(), actionType, "服务层启动计划失败", 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 |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// 6. 发送成功响应 | 	// 6. 发送成功响应 | ||||||
| 	c.logger.Infof("%s: 计划已成功启动, ID: %d", actionType, id) | 	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 | // StopPlan godoc | ||||||
| // @Summary      停止计划 | // @Summary      停止计划 | ||||||
| // @Description  根据计划ID停止一个正在执行的计划。 | // @Description  根据计划ID停止一个正在执行的计划。系统计划不能被停止。 | ||||||
| // @Tags         计划管理 | // @Tags         计划管理 | ||||||
| // @Security     BearerAuth | // @Security     BearerAuth | ||||||
| // @Produce      json | // @Produce      json | ||||||
| // @Param        id path int true "计划ID" | // @Param        id path int true "计划ID" | ||||||
| // @Success      200 {object} controller.Response "业务码为200代表成功停止计划" | // @Success      200 {object} controller.Response "业务码为200代表成功停止计划" | ||||||
| // @Router       /api/v1/plans/{id}/stop [post] | // @Router       /api/v1/plans/{id}/stop [post] | ||||||
| func (c *Controller) StopPlan(ctx *gin.Context) { | func (c *Controller) StopPlan(ctx echo.Context) error { | ||||||
| 	const actionType = "停止计划" | 	const actionType = "停止计划" | ||||||
| 	// 1. 从 URL 路径中获取 ID | 	// 1. 从 URL 路径中获取 ID | ||||||
| 	idStr := ctx.Param("id") | 	idStr := ctx.Param("id") | ||||||
| 	id, err := strconv.ParseUint(idStr, 10, 32) | 	id, err := strconv.ParseUint(idStr, 10, 32) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		c.logger.Errorf("%s: 计划ID格式错误: %v, ID: %s", actionType, err, idStr) | 		c.logger.Errorf("%s: 计划ID格式错误: %v, ID: %s", actionType, err, idStr) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的计划ID格式", actionType, "计划ID格式错误", idStr) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的计划ID格式", actionType, "计划ID格式错误", idStr) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// 2. 检查计划是否存在 | 	// 调用服务层停止计划 | ||||||
| 	plan, err := c.planRepo.GetBasicPlanByID(uint(id)) | 	err = c.planService.StopPlan(uint(id)) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if errors.Is(err, gorm.ErrRecordNotFound) { | 		c.logger.Errorf("%s: 服务层停止计划失败: %v, ID: %d", actionType, err, id) | ||||||
| 			c.logger.Warnf("%s: 计划不存在, ID: %d", actionType, id) | 		if errors.Is(err, service.ErrPlanNotFound) { | ||||||
| 			controller.SendErrorWithAudit(ctx, controller.CodeNotFound, "计划不存在", actionType, "计划不存在", id) | 			return controller.SendErrorWithAudit(ctx, controller.CodeNotFound, err.Error(), actionType, "计划不存在", id) | ||||||
| 			return | 		} 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) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "停止计划失败: "+err.Error(), actionType, "服务层停止计划失败", id) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "获取计划信息时发生内部错误", actionType, "数据库查询失败", id) |  | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// 3. 检查计划当前状态 | 	// 6. 发送成功响应 | ||||||
| 	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. 发送成功响应 |  | ||||||
| 	c.logger.Infof("%s: 计划已成功停止, ID: %d", actionType, id) | 	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" | 	"net/http" | ||||||
|  |  | ||||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | 	"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) | 	// 客户端错误状态码 (4000-4999) | ||||||
| 	CodeBadRequest   ResponseCode = 4000 // 请求参数错误 | 	CodeBadRequest   ResponseCode = 4000 // 请求参数错误 | ||||||
| 	CodeUnauthorized ResponseCode = 4001 // 未授权 | 	CodeUnauthorized ResponseCode = 4001 // 未授权 | ||||||
|  | 	CodeForbidden    ResponseCode = 4003 // 禁止访问 | ||||||
| 	CodeNotFound     ResponseCode = 4004 // 资源未找到 | 	CodeNotFound     ResponseCode = 4004 // 资源未找到 | ||||||
| 	CodeConflict     ResponseCode = 4009 // 资源冲突 | 	CodeConflict     ResponseCode = 4009 // 资源冲突 | ||||||
|  |  | ||||||
| @@ -32,12 +33,13 @@ const ( | |||||||
| type Response struct { | type Response struct { | ||||||
| 	Code    ResponseCode `json:"code"`           // 业务状态码 | 	Code    ResponseCode `json:"code"`           // 业务状态码 | ||||||
| 	Message string       `json:"message"`        // 提示信息 | 	Message string       `json:"message"`        // 提示信息 | ||||||
| 	Data    interface{}  `json:"data"`    // 业务数据 | 	Data    interface{}  `json:"data,omitempty"` // 业务数据, omitempty表示如果为空则不序列化 | ||||||
| } | } | ||||||
|  |  | ||||||
| // SendResponse 发送统一格式的JSON响应 (基础函数,不带审计) | // SendResponse 发送统一格式的JSON响应 (基础函数,不带审计) | ||||||
| func SendResponse(ctx *gin.Context, code ResponseCode, message string, data interface{}) { | // 所有的业务API都应该使用这个函数返回,以确保HTTP状态码始终为200 OK。 | ||||||
| 	ctx.JSON(http.StatusOK, Response{ | func SendResponse(c echo.Context, code ResponseCode, message string, data interface{}) error { | ||||||
|  | 	return c.JSON(http.StatusOK, Response{ | ||||||
| 		Code:    code, | 		Code:    code, | ||||||
| 		Message: message, | 		Message: message, | ||||||
| 		Data:    data, | 		Data:    data, | ||||||
| @@ -45,51 +47,63 @@ func SendResponse(ctx *gin.Context, code ResponseCode, message string, data inte | |||||||
| } | } | ||||||
|  |  | ||||||
| // SendErrorResponse 发送统一格式的错误响应 (基础函数,不带审计) | // SendErrorResponse 发送统一格式的错误响应 (基础函数,不带审计) | ||||||
| func SendErrorResponse(ctx *gin.Context, code ResponseCode, message string) { | // HTTP状态码为200 OK,通过业务码表示错误。 | ||||||
| 	SendResponse(ctx, code, message, nil) | 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 中设置业务相关的审计信息。 | // setAuditDetails 是一个内部辅助函数,用于在 echo.Context 中统一设置所有业务相关的审计信息。 | ||||||
| func setAuditDetails(c *gin.Context, actionType, description string, targetResource interface{}) { | func setAuditDetails(c echo.Context, actionType, description string, targetResource interface{}, status models.AuditStatus, resultDetails string) { | ||||||
| 	// 只有当 actionType 不为空时,才设置审计信息,这作为触发审计的标志 | 	// 只有当 actionType 不为空时,才设置审计信息,这作为触发审计的标志 | ||||||
| 	if actionType != "" { | 	if actionType != "" { | ||||||
| 		c.Set(models.ContextAuditActionType.String(), actionType) | 		c.Set(models.ContextAuditActionType.String(), actionType) | ||||||
| 		c.Set(models.ContextAuditDescription.String(), description) | 		c.Set(models.ContextAuditDescription.String(), description) | ||||||
| 		c.Set(models.ContextAuditTargetResource.String(), targetResource) | 		c.Set(models.ContextAuditTargetResource.String(), targetResource) | ||||||
|  | 		c.Set(models.ContextAuditStatus.String(), status) | ||||||
|  | 		c.Set(models.ContextAuditResultDetails.String(), resultDetails) | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| // SendSuccessWithAudit 发送成功的响应,并设置审计日志所需的信息。 | // SendSuccessWithAudit 发送成功的响应,并设置审计日志所需的信息。 | ||||||
| // 这是控制器中用于记录成功操作并返回响应的首选函数。 | // 这是控制器中用于记录成功操作并返回响应的首选函数。 | ||||||
| func SendSuccessWithAudit( | func SendSuccessWithAudit( | ||||||
| 	ctx *gin.Context, // Gin上下文,用于处理HTTP请求和响应 | 	c echo.Context, // Echo上下文,用于处理HTTP请求和响应 | ||||||
| 	code ResponseCode, // 业务状态码,表示操作结果 | 	code ResponseCode, // 业务状态码,表示操作结果 | ||||||
| 	message string, // 提示信息,向用户展示操作结果的文本描述 | 	message string, // 提示信息,向用户展示操作结果的文本描述 | ||||||
| 	data interface{}, // 业务数据,操作成功后返回的具体数据 | 	data interface{}, // 业务数据,操作成功后返回的具体数据 | ||||||
| 	actionType string, // 审计操作类型,例如"创建用户", "更新配置" | 	actionType string, // 审计操作类型,例如"创建用户", "更新配置" | ||||||
| 	description string, // 审计描述,对操作的详细说明 | 	description string, // 审计描述,对操作的详细说明 | ||||||
| 	targetResource interface{}, // 审计目标资源,被操作的资源对象或其标识 | 	targetResource interface{}, // 审计目标资源,被操作的资源对象或其标识 | ||||||
| ) { | ) error { | ||||||
| 	// 1. 设置审计信息 | 	// 1. 设置审计信息 | ||||||
| 	setAuditDetails(ctx, actionType, description, targetResource) | 	setAuditDetails(c, actionType, description, targetResource, models.AuditStatusSuccess, "") | ||||||
| 	// 2. 发送响应 | 	// 2. 发送响应 | ||||||
| 	SendResponse(ctx, code, message, data) | 	return SendResponse(c, code, message, data) | ||||||
| } | } | ||||||
|  |  | ||||||
| // SendErrorWithAudit 发送失败的响应,并设置审计日志所需的信息。 | // SendErrorWithAudit 发送失败的响应,并设置审计日志所需的信息。 | ||||||
| // 这是控制器中用于记录失败操作并返回响应的首选函数。 | // 这是控制器中用于记录失败操作并返回响应的首选函数。 | ||||||
| func SendErrorWithAudit( | func SendErrorWithAudit( | ||||||
| 	ctx *gin.Context, // Gin上下文,用于处理HTTP请求和响应 | 	c echo.Context, // Echo上下文,用于处理HTTP请求和响应 | ||||||
| 	code ResponseCode, // 业务状态码,表示操作结果 | 	code ResponseCode, // 业务状态码,表示操作结果 | ||||||
| 	message string, // 提示信息,向用户展示操作结果的文本描述 | 	message string, // 提示信息,向用户展示操作结果的文本描述 | ||||||
| 	actionType string, // 审计操作类型,例如"登录失败", "删除失败" | 	actionType string, // 审计操作类型,例如"登录失败", "删除失败" | ||||||
| 	description string, // 审计描述,对操作的详细说明 | 	description string, // 审计描述,对操作的详细说明 | ||||||
| 	targetResource interface{}, // 审计目标资源,被操作的资源对象或其标识 | 	targetResource interface{}, // 审计目标资源,被操作的资源对象或其标识 | ||||||
| ) { | ) error { | ||||||
| 	// 1. 设置审计信息 | 	// 1. 设置审计信息 | ||||||
| 	setAuditDetails(ctx, actionType, description, targetResource) | 	setAuditDetails(c, actionType, description, targetResource, models.AuditStatusFailed, message) | ||||||
| 	// 2. 发送响应 | 	// 2. 发送响应 | ||||||
| 	SendErrorResponse(ctx, code, message) | 	return SendErrorResponse(c, code, message) | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,43 +1,28 @@ | |||||||
| package user | package user | ||||||
|  |  | ||||||
| import ( | import ( | ||||||
| 	"errors" |  | ||||||
| 	"strconv" | 	"strconv" | ||||||
|  |  | ||||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/controller" | 	"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/dto" | ||||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/service" | 	"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/logs" | ||||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | 	"github.com/labstack/echo/v4" | ||||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" |  | ||||||
| 	"github.com/gin-gonic/gin" |  | ||||||
| 	"gorm.io/gorm" |  | ||||||
| ) | ) | ||||||
|  |  | ||||||
| // Controller 用户控制器 | // Controller 用户控制器 | ||||||
| type Controller struct { | type Controller struct { | ||||||
| 	userRepo       repository.UserRepository | 	userService service.UserService | ||||||
| 	monitorService service.MonitorService |  | ||||||
| 	tokenService   token.TokenService |  | ||||||
| 	notifyService  domain_notify.Service |  | ||||||
| 	logger      *logs.Logger | 	logger      *logs.Logger | ||||||
| } | } | ||||||
|  |  | ||||||
| // NewController 创建用户控制器实例 | // NewController 创建用户控制器实例 | ||||||
| func NewController( | func NewController( | ||||||
| 	userRepo repository.UserRepository, | 	userService service.UserService, | ||||||
| 	monitorService service.MonitorService, |  | ||||||
| 	logger *logs.Logger, | 	logger *logs.Logger, | ||||||
| 	tokenService token.TokenService, |  | ||||||
| 	notifyService domain_notify.Service, |  | ||||||
| ) *Controller { | ) *Controller { | ||||||
| 	return &Controller{ | 	return &Controller{ | ||||||
| 		userRepo:       userRepo, | 		userService: userService, | ||||||
| 		monitorService: monitorService, |  | ||||||
| 		tokenService:   tokenService, |  | ||||||
| 		notifyService:  notifyService, |  | ||||||
| 		logger:      logger, | 		logger:      logger, | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| @@ -53,38 +38,20 @@ func NewController( | |||||||
| // @Param        user body dto.CreateUserRequest true "用户信息" | // @Param        user body dto.CreateUserRequest true "用户信息" | ||||||
| // @Success      200 {object} controller.Response{data=dto.CreateUserResponse} "业务码为201代表创建成功" | // @Success      200 {object} controller.Response{data=dto.CreateUserResponse} "业务码为201代表创建成功" | ||||||
| // @Router       /api/v1/users [post] | // @Router       /api/v1/users [post] | ||||||
| func (c *Controller) CreateUser(ctx *gin.Context) { | func (c *Controller) CreateUser(ctx echo.Context) error { | ||||||
| 	var req dto.CreateUserRequest | 	var req dto.CreateUserRequest | ||||||
| 	if err := ctx.ShouldBindJSON(&req); err != nil { | 	if err := ctx.Bind(&req); err != nil { | ||||||
| 		c.logger.Errorf("创建用户: 参数绑定失败: %v", err) | 		c.logger.Errorf("创建用户: 参数绑定失败: %v", err) | ||||||
| 		controller.SendErrorResponse(ctx, controller.CodeBadRequest, err.Error()) | 		return controller.SendErrorResponse(ctx, controller.CodeBadRequest, err.Error()) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	user := &models.User{ | 	resp, err := c.userService.CreateUser(&req) | ||||||
| 		Username: req.Username, | 	if err != nil { | ||||||
| 		Password: req.Password, // 密码会在 BeforeSave 钩子中哈希 | 		c.logger.Errorf("创建用户: 服务层调用失败: %v", err) | ||||||
|  | 		return controller.SendErrorResponse(ctx, controller.CodeInternalError, err.Error()) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if err := c.userRepo.Create(user); err != nil { | 	return controller.SendResponse(ctx, controller.CodeCreated, "用户创建成功", resp) | ||||||
| 		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, |  | ||||||
| 	}) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // Login godoc | // Login godoc | ||||||
| @@ -96,110 +63,20 @@ func (c *Controller) CreateUser(ctx *gin.Context) { | |||||||
| // @Param        credentials body dto.LoginRequest true "登录凭证" | // @Param        credentials body dto.LoginRequest true "登录凭证" | ||||||
| // @Success      200 {object} controller.Response{data=dto.LoginResponse} "业务码为200代表登录成功" | // @Success      200 {object} controller.Response{data=dto.LoginResponse} "业务码为200代表登录成功" | ||||||
| // @Router       /api/v1/users/login [post] | // @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 | 	var req dto.LoginRequest | ||||||
| 	if err := ctx.ShouldBindJSON(&req); err != nil { | 	if err := ctx.Bind(&req); err != nil { | ||||||
| 		c.logger.Errorf("登录: 参数绑定失败: %v", err) | 		c.logger.Errorf("登录: 参数绑定失败: %v", err) | ||||||
| 		controller.SendErrorResponse(ctx, controller.CodeBadRequest, err.Error()) | 		return controller.SendErrorResponse(ctx, controller.CodeBadRequest, err.Error()) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// 使用新的方法,通过唯一标识符(用户名、邮箱等)查找用户 | 	resp, err := c.userService.Login(&req) | ||||||
| 	user, err := c.userRepo.FindUserForLogin(req.Identifier) |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		if err == gorm.ErrRecordNotFound { | 		c.logger.Errorf("登录: 服务层调用失败: %v", err) | ||||||
| 			controller.SendErrorResponse(ctx, controller.CodeUnauthorized, "登录凭证不正确") | 		return controller.SendErrorResponse(ctx, controller.CodeUnauthorized, err.Error()) | ||||||
| 			return |  | ||||||
| 		} |  | ||||||
| 		c.logger.Errorf("登录: 查询用户失败: %v", err) |  | ||||||
| 		controller.SendErrorResponse(ctx, controller.CodeInternalError, "登录失败") |  | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	if !user.CheckPassword(req.Password) { | 	return controller.SendResponse(ctx, controller.CodeSuccess, "登录成功", resp) | ||||||
| 		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) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // SendTestNotification godoc | // SendTestNotification godoc | ||||||
| @@ -213,34 +90,31 @@ func (c *Controller) ListUserHistory(ctx *gin.Context) { | |||||||
| // @Param        body body      dto.SendTestNotificationRequest  true  "请求体" | // @Param        body body      dto.SendTestNotificationRequest  true  "请求体" | ||||||
| // @Success      200  {object}  controller.Response{data=string}  "成功响应" | // @Success      200  {object}  controller.Response{data=string}  "成功响应" | ||||||
| // @Router       /api/v1/users/{id}/notifications/test [post] | // @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 = "发送测试通知" | 	const actionType = "发送测试通知" | ||||||
|  |  | ||||||
| 	// 1. 从 URL 中获取用户 ID | 	// 1. 从 URL 中获取用户 ID | ||||||
| 	userID, err := strconv.ParseUint(ctx.Param("id"), 10, 32) | 	userID, err := strconv.ParseUint(ctx.Param("id"), 10, 32) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		c.logger.Errorf("%s: 无效的用户ID格式: %v", actionType, err) | 		c.logger.Errorf("%s: 无效的用户ID格式: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的用户ID格式", actionType, "无效的用户ID格式", ctx.Param("id")) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "无效的用户ID格式", actionType, "无效的用户ID格式", ctx.Param("id")) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// 2. 从请求体 (JSON Body) 中获取要测试的通知类型 | 	// 2. 从请求体 (JSON Body) 中获取要测试的通知类型 | ||||||
| 	var req dto.SendTestNotificationRequest | 	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) | 		c.logger.Errorf("%s: 参数绑定失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "请求体格式错误或缺少 'type' 字段: "+err.Error(), actionType, "请求体绑定失败", req) | 		return controller.SendErrorWithAudit(ctx, controller.CodeBadRequest, "请求体格式错误或缺少 'type' 字段: "+err.Error(), actionType, "请求体绑定失败", req) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// 3. 调用领域服务 | 	// 3. 调用服务层 | ||||||
| 	err = c.notifyService.SendTestMessage(uint(userID), req.Type) | 	err = c.userService.SendTestNotification(uint(userID), &req) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		c.logger.Errorf("%s: 服务层调用失败: %v", actionType, err) | 		c.logger.Errorf("%s: 服务层调用失败: %v", actionType, err) | ||||||
| 		controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "发送测试消息失败: "+err.Error(), actionType, "服务层调用失败", gin.H{"userID": userID, "type": req.Type}) | 		return controller.SendErrorWithAudit(ctx, controller.CodeInternalError, "发送测试消息失败: "+err.Error(), actionType, "服务层调用失败", map[string]interface{}{"userID": userID, "type": req.Type}) | ||||||
| 		return |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// 4. 返回成功响应 | 	// 4. 返回成功响应 | ||||||
| 	c.logger.Infof("%s: 成功为用户 %d 发送类型为 %s 的测试消息", actionType, userID, req.Type) | 	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 定义了创建设备时需要传入的参数 | // CreateDeviceRequest 定义了创建设备时需要传入的参数 | ||||||
| type CreateDeviceRequest struct { | type CreateDeviceRequest struct { | ||||||
| 	Name             string                 `json:"name" binding:"required"` | 	Name             string                 `json:"name" validate:"required"` | ||||||
| 	DeviceTemplateID uint                   `json:"device_template_id" binding:"required"` | 	DeviceTemplateID uint                   `json:"device_template_id" validate:"required"` | ||||||
| 	AreaControllerID uint                   `json:"area_controller_id" binding:"required"` | 	AreaControllerID uint                   `json:"area_controller_id" validate:"required"` | ||||||
| 	Location         string                 `json:"location,omitempty"` | 	Location         string                 `json:"location,omitempty" validate:"omitempty"` | ||||||
| 	Properties       map[string]interface{} `json:"properties,omitempty"` | 	Properties       map[string]interface{} `json:"properties,omitempty" validate:"omitempty"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // UpdateDeviceRequest 定义了更新设备时需要传入的参数 | // UpdateDeviceRequest 定义了更新设备时需要传入的参数 | ||||||
| type UpdateDeviceRequest struct { | type UpdateDeviceRequest struct { | ||||||
| 	Name             string                 `json:"name" binding:"required"` | 	Name             string                 `json:"name" validate:"required"` | ||||||
| 	DeviceTemplateID uint                   `json:"device_template_id" binding:"required"` | 	DeviceTemplateID uint                   `json:"device_template_id" validate:"required"` | ||||||
| 	AreaControllerID uint                   `json:"area_controller_id" binding:"required"` | 	AreaControllerID uint                   `json:"area_controller_id" validate:"required"` | ||||||
| 	Location         string                 `json:"location,omitempty"` | 	Location         string                 `json:"location,omitempty" validate:"omitempty"` | ||||||
| 	Properties       map[string]interface{} `json:"properties,omitempty"` | 	Properties       map[string]interface{} `json:"properties,omitempty" validate:"omitempty"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // ManualControlDeviceRequest 定义了手动控制设备时需要传入的参数 | // ManualControlDeviceRequest 定义了手动控制设备时需要传入的参数 | ||||||
| @@ -28,38 +28,38 @@ type ManualControlDeviceRequest struct { | |||||||
|  |  | ||||||
| // CreateAreaControllerRequest 定义了创建区域主控时需要传入的参数 | // CreateAreaControllerRequest 定义了创建区域主控时需要传入的参数 | ||||||
| type CreateAreaControllerRequest struct { | type CreateAreaControllerRequest struct { | ||||||
| 	Name       string                 `json:"name" binding:"required"` | 	Name       string                 `json:"name" validate:"required"` | ||||||
| 	NetworkID  string                 `json:"network_id" binding:"required"` | 	NetworkID  string                 `json:"network_id" validate:"required"` | ||||||
| 	Location   string                 `json:"location,omitempty"` | 	Location   string                 `json:"location,omitempty" validate:"omitempty"` | ||||||
| 	Properties map[string]interface{} `json:"properties,omitempty"` | 	Properties map[string]interface{} `json:"properties,omitempty" validate:"omitempty"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // UpdateAreaControllerRequest 定义了更新区域主控时需要传入的参数 | // UpdateAreaControllerRequest 定义了更新区域主控时需要传入的参数 | ||||||
| type UpdateAreaControllerRequest struct { | type UpdateAreaControllerRequest struct { | ||||||
| 	Name       string                 `json:"name" binding:"required"` | 	Name       string                 `json:"name" validate:"required"` | ||||||
| 	NetworkID  string                 `json:"network_id" binding:"required"` | 	NetworkID  string                 `json:"network_id" validate:"required"` | ||||||
| 	Location   string                 `json:"location,omitempty"` | 	Location   string                 `json:"location,omitempty" validate:"omitempty"` | ||||||
| 	Properties map[string]interface{} `json:"properties,omitempty"` | 	Properties map[string]interface{} `json:"properties,omitempty" validate:"omitempty"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // CreateDeviceTemplateRequest 定义了创建设备模板时需要传入的参数 | // CreateDeviceTemplateRequest 定义了创建设备模板时需要传入的参数 | ||||||
| type CreateDeviceTemplateRequest struct { | type CreateDeviceTemplateRequest struct { | ||||||
| 	Name         string                   `json:"name" binding:"required"` | 	Name         string                   `json:"name" validate:"required"` | ||||||
| 	Manufacturer string                   `json:"manufacturer,omitempty"` | 	Manufacturer string                   `json:"manufacturer,omitempty" validate:"omitempty"` | ||||||
| 	Description  string                   `json:"description,omitempty"` | 	Description  string                   `json:"description,omitempty" validate:"omitempty"` | ||||||
| 	Category     models.DeviceCategory    `json:"category" binding:"required"` | 	Category     models.DeviceCategory    `json:"category" validate:"required"` | ||||||
| 	Commands     map[string]interface{}   `json:"commands" binding:"required"` | 	Commands     map[string]interface{}   `json:"commands" validate:"required"` | ||||||
| 	Values       []models.ValueDescriptor `json:"values,omitempty"` | 	Values       []models.ValueDescriptor `json:"values,omitempty" validate:"omitempty,dive"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // UpdateDeviceTemplateRequest 定义了更新设备模板时需要传入的参数 | // UpdateDeviceTemplateRequest 定义了更新设备模板时需要传入的参数 | ||||||
| type UpdateDeviceTemplateRequest struct { | type UpdateDeviceTemplateRequest struct { | ||||||
| 	Name         string                   `json:"name" binding:"required"` | 	Name         string                   `json:"name" validate:"required"` | ||||||
| 	Manufacturer string                   `json:"manufacturer,omitempty"` | 	Manufacturer string                   `json:"manufacturer,omitempty" validate:"omitempty"` | ||||||
| 	Description  string                   `json:"description,omitempty"` | 	Description  string                   `json:"description,omitempty" validate:"omitempty"` | ||||||
| 	Category     models.DeviceCategory    `json:"category" binding:"required"` | 	Category     models.DeviceCategory    `json:"category" validate:"required"` | ||||||
| 	Commands     map[string]interface{}   `json:"commands" binding:"required"` | 	Commands     map[string]interface{}   `json:"commands" validate:"required"` | ||||||
| 	Values       []models.ValueDescriptor `json:"values,omitempty"` | 	Values       []models.ValueDescriptor `json:"values,omitempty" validate:"omitempty,dive"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // DeviceResponse 定义了返回给客户端的单个设备信息的结构 | // DeviceResponse 定义了返回给客户端的单个设备信息的结构 | ||||||
|   | |||||||
| @@ -53,14 +53,20 @@ func NewListDeviceCommandLogResponse(data []models.DeviceCommandLog, total int64 | |||||||
| } | } | ||||||
|  |  | ||||||
| // NewListPlanExecutionLogResponse 从模型数据创建列表响应 DTO | // NewListPlanExecutionLogResponse 从模型数据创建列表响应 DTO | ||||||
| func NewListPlanExecutionLogResponse(data []models.PlanExecutionLog, total int64, page, pageSize int) *ListPlanExecutionLogResponse { | func NewListPlanExecutionLogResponse(planLogs []models.PlanExecutionLog, plans []models.Plan, total int64, page, pageSize int) *ListPlanExecutionLogResponse { | ||||||
| 	dtos := make([]PlanExecutionLogDTO, len(data)) | 	planId2Name := make(map[uint]string) | ||||||
| 	for i, item := range data { | 	for _, plan := range plans { | ||||||
|  | 		planId2Name[plan.ID] = plan.Name | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	dtos := make([]PlanExecutionLogDTO, len(planLogs)) | ||||||
|  | 	for i, item := range planLogs { | ||||||
| 		dtos[i] = PlanExecutionLogDTO{ | 		dtos[i] = PlanExecutionLogDTO{ | ||||||
| 			ID:        item.ID, | 			ID:        item.ID, | ||||||
| 			CreatedAt: item.CreatedAt, | 			CreatedAt: item.CreatedAt, | ||||||
| 			UpdatedAt: item.UpdatedAt, | 			UpdatedAt: item.UpdatedAt, | ||||||
| 			PlanID:    item.PlanID, | 			PlanID:    item.PlanID, | ||||||
|  | 			PlanName:  planId2Name[item.PlanID], | ||||||
| 			Status:    item.Status, | 			Status:    item.Status, | ||||||
| 			StartedAt: item.StartedAt, | 			StartedAt: item.StartedAt, | ||||||
| 			EndedAt:   item.EndedAt, | 			EndedAt:   item.EndedAt, | ||||||
|   | |||||||
| @@ -20,13 +20,13 @@ type PaginationDTO struct { | |||||||
|  |  | ||||||
| // ListSensorDataRequest 定义了获取传感器数据列表的请求参数 | // ListSensorDataRequest 定义了获取传感器数据列表的请求参数 | ||||||
| type ListSensorDataRequest struct { | type ListSensorDataRequest struct { | ||||||
| 	Page       int        `form:"page,default=1"` | 	Page       int        `query:"page"` | ||||||
| 	PageSize   int        `form:"pageSize,default=10"` | 	PageSize   int        `query:"pageSize"` | ||||||
| 	DeviceID   *uint      `form:"device_id"` | 	DeviceID   *uint      `query:"device_id"` | ||||||
| 	SensorType *string    `form:"sensor_type"` | 	SensorType *string    `query:"sensor_type"` | ||||||
| 	StartTime  *time.Time `form:"start_time"` | 	StartTime  *time.Time `query:"start_time"` | ||||||
| 	EndTime    *time.Time `form:"end_time"` | 	EndTime    *time.Time `query:"end_time"` | ||||||
| 	OrderBy    string     `form:"order_by"` | 	OrderBy    string     `query:"order_by"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // SensorDataDTO 是用于API响应的传感器数据结构 | // SensorDataDTO 是用于API响应的传感器数据结构 | ||||||
| @@ -48,13 +48,13 @@ type ListSensorDataResponse struct { | |||||||
|  |  | ||||||
| // ListDeviceCommandLogRequest 定义了获取设备命令日志列表的请求参数 | // ListDeviceCommandLogRequest 定义了获取设备命令日志列表的请求参数 | ||||||
| type ListDeviceCommandLogRequest struct { | type ListDeviceCommandLogRequest struct { | ||||||
| 	Page            int        `form:"page,default=1"` | 	Page            int        `query:"page"` | ||||||
| 	PageSize        int        `form:"pageSize,default=10"` | 	PageSize        int        `query:"pageSize"` | ||||||
| 	DeviceID        *uint      `form:"device_id"` | 	DeviceID        *uint      `query:"device_id"` | ||||||
| 	ReceivedSuccess *bool      `form:"received_success"` | 	ReceivedSuccess *bool      `query:"received_success"` | ||||||
| 	StartTime       *time.Time `form:"start_time"` | 	StartTime       *time.Time `query:"start_time"` | ||||||
| 	EndTime         *time.Time `form:"end_time"` | 	EndTime         *time.Time `query:"end_time"` | ||||||
| 	OrderBy         string     `form:"order_by"` | 	OrderBy         string     `query:"order_by"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // DeviceCommandLogDTO 是用于API响应的设备命令日志结构 | // DeviceCommandLogDTO 是用于API响应的设备命令日志结构 | ||||||
| @@ -76,13 +76,13 @@ type ListDeviceCommandLogResponse struct { | |||||||
|  |  | ||||||
| // ListPlanExecutionLogRequest 定义了获取计划执行日志列表的请求参数 | // ListPlanExecutionLogRequest 定义了获取计划执行日志列表的请求参数 | ||||||
| type ListPlanExecutionLogRequest struct { | type ListPlanExecutionLogRequest struct { | ||||||
| 	Page      int        `form:"page,default=1"` | 	Page      int        `query:"page"` | ||||||
| 	PageSize  int        `form:"pageSize,default=10"` | 	PageSize  int        `query:"pageSize"` | ||||||
| 	PlanID    *uint      `form:"plan_id"` | 	PlanID    *uint      `query:"plan_id"` | ||||||
| 	Status    *string    `form:"status"` | 	Status    *string    `query:"status"` | ||||||
| 	StartTime *time.Time `form:"start_time"` | 	StartTime *time.Time `query:"start_time"` | ||||||
| 	EndTime   *time.Time `form:"end_time"` | 	EndTime   *time.Time `query:"end_time"` | ||||||
| 	OrderBy   string     `form:"order_by"` | 	OrderBy   string     `query:"order_by"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // PlanExecutionLogDTO 是用于API响应的计划执行日志结构 | // PlanExecutionLogDTO 是用于API响应的计划执行日志结构 | ||||||
| @@ -91,6 +91,7 @@ type PlanExecutionLogDTO struct { | |||||||
| 	CreatedAt time.Time              `json:"created_at"` | 	CreatedAt time.Time              `json:"created_at"` | ||||||
| 	UpdatedAt time.Time              `json:"updated_at"` | 	UpdatedAt time.Time              `json:"updated_at"` | ||||||
| 	PlanID    uint                   `json:"plan_id"` | 	PlanID    uint                   `json:"plan_id"` | ||||||
|  | 	PlanName  string                 `json:"plan_name"` | ||||||
| 	Status    models.ExecutionStatus `json:"status"` | 	Status    models.ExecutionStatus `json:"status"` | ||||||
| 	StartedAt time.Time              `json:"started_at"` | 	StartedAt time.Time              `json:"started_at"` | ||||||
| 	EndedAt   time.Time              `json:"ended_at"` | 	EndedAt   time.Time              `json:"ended_at"` | ||||||
| @@ -107,14 +108,14 @@ type ListPlanExecutionLogResponse struct { | |||||||
|  |  | ||||||
| // ListTaskExecutionLogRequest 定义了获取任务执行日志列表的请求参数 | // ListTaskExecutionLogRequest 定义了获取任务执行日志列表的请求参数 | ||||||
| type ListTaskExecutionLogRequest struct { | type ListTaskExecutionLogRequest struct { | ||||||
| 	Page               int        `form:"page,default=1"` | 	Page               int        `query:"page"` | ||||||
| 	PageSize           int        `form:"pageSize,default=10"` | 	PageSize           int        `query:"pageSize"` | ||||||
| 	PlanExecutionLogID *uint      `form:"plan_execution_log_id"` | 	PlanExecutionLogID *uint      `query:"plan_execution_log_id"` | ||||||
| 	TaskID             *int       `form:"task_id"` | 	TaskID             *int       `query:"task_id"` | ||||||
| 	Status             *string    `form:"status"` | 	Status             *string    `query:"status"` | ||||||
| 	StartTime          *time.Time `form:"start_time"` | 	StartTime          *time.Time `query:"start_time"` | ||||||
| 	EndTime            *time.Time `form:"end_time"` | 	EndTime            *time.Time `query:"end_time"` | ||||||
| 	OrderBy            string     `form:"order_by"` | 	OrderBy            string     `query:"order_by"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // TaskDTO 是用于API响应的简化版任务结构 | // TaskDTO 是用于API响应的简化版任务结构 | ||||||
| @@ -148,13 +149,13 @@ type ListTaskExecutionLogResponse struct { | |||||||
|  |  | ||||||
| // ListPendingCollectionRequest 定义了获取待采集请求列表的请求参数 | // ListPendingCollectionRequest 定义了获取待采集请求列表的请求参数 | ||||||
| type ListPendingCollectionRequest struct { | type ListPendingCollectionRequest struct { | ||||||
| 	Page      int        `form:"page,default=1"` | 	Page      int        `query:"page"` | ||||||
| 	PageSize  int        `form:"pageSize,default=10"` | 	PageSize  int        `query:"pageSize"` | ||||||
| 	DeviceID  *uint      `form:"device_id"` | 	DeviceID  *uint      `query:"device_id"` | ||||||
| 	Status    *string    `form:"status"` | 	Status    *string    `query:"status"` | ||||||
| 	StartTime *time.Time `form:"start_time"` | 	StartTime *time.Time `query:"start_time"` | ||||||
| 	EndTime   *time.Time `form:"end_time"` | 	EndTime   *time.Time `query:"end_time"` | ||||||
| 	OrderBy   string     `form:"order_by"` | 	OrderBy   string     `query:"order_by"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // PendingCollectionDTO 是用于API响应的待采集请求结构 | // PendingCollectionDTO 是用于API响应的待采集请求结构 | ||||||
| @@ -177,15 +178,15 @@ type ListPendingCollectionResponse struct { | |||||||
|  |  | ||||||
| // ListUserActionLogRequest 定义了获取用户操作日志列表的请求参数 | // ListUserActionLogRequest 定义了获取用户操作日志列表的请求参数 | ||||||
| type ListUserActionLogRequest struct { | type ListUserActionLogRequest struct { | ||||||
| 	Page       int        `form:"page,default=1"` | 	Page       int        `query:"page"` | ||||||
| 	PageSize   int        `form:"pageSize,default=10"` | 	PageSize   int        `query:"pageSize"` | ||||||
| 	UserID     *uint      `form:"user_id"` | 	UserID     *uint      `query:"user_id"` | ||||||
| 	Username   *string    `form:"username"` | 	Username   *string    `query:"username"` | ||||||
| 	ActionType *string    `form:"action_type"` | 	ActionType *string    `query:"action_type"` | ||||||
| 	Status     *string    `form:"status"` | 	Status     *string    `query:"status"` | ||||||
| 	StartTime  *time.Time `form:"start_time"` | 	StartTime  *time.Time `query:"start_time"` | ||||||
| 	EndTime    *time.Time `form:"end_time"` | 	EndTime    *time.Time `query:"end_time"` | ||||||
| 	OrderBy    string     `form:"order_by"` | 	OrderBy    string     `query:"order_by"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // UserActionLogDTO 是用于API响应的用户操作日志结构 | // UserActionLogDTO 是用于API响应的用户操作日志结构 | ||||||
| @@ -214,13 +215,13 @@ type ListUserActionLogResponse struct { | |||||||
|  |  | ||||||
| // ListRawMaterialPurchaseRequest 定义了获取原料采购列表的请求参数 | // ListRawMaterialPurchaseRequest 定义了获取原料采购列表的请求参数 | ||||||
| type ListRawMaterialPurchaseRequest struct { | type ListRawMaterialPurchaseRequest struct { | ||||||
| 	Page          int        `form:"page,default=1"` | 	Page          int        `query:"page"` | ||||||
| 	PageSize      int        `form:"pageSize,default=10"` | 	PageSize      int        `query:"pageSize"` | ||||||
| 	RawMaterialID *uint      `form:"raw_material_id"` | 	RawMaterialID *uint      `query:"raw_material_id"` | ||||||
| 	Supplier      *string    `form:"supplier"` | 	Supplier      *string    `query:"supplier"` | ||||||
| 	StartTime     *time.Time `form:"start_time"` | 	StartTime     *time.Time `query:"start_time"` | ||||||
| 	EndTime       *time.Time `form:"end_time"` | 	EndTime       *time.Time `query:"end_time"` | ||||||
| 	OrderBy       string     `form:"order_by"` | 	OrderBy       string     `query:"order_by"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // RawMaterialDTO 是用于API响应的简化版原料结构 | // RawMaterialDTO 是用于API响应的简化版原料结构 | ||||||
| @@ -252,14 +253,14 @@ type ListRawMaterialPurchaseResponse struct { | |||||||
|  |  | ||||||
| // ListRawMaterialStockLogRequest 定义了获取原料库存日志列表的请求参数 | // ListRawMaterialStockLogRequest 定义了获取原料库存日志列表的请求参数 | ||||||
| type ListRawMaterialStockLogRequest struct { | type ListRawMaterialStockLogRequest struct { | ||||||
| 	Page          int        `form:"page,default=1"` | 	Page          int        `query:"page"` | ||||||
| 	PageSize      int        `form:"pageSize,default=10"` | 	PageSize      int        `query:"pageSize"` | ||||||
| 	RawMaterialID *uint      `form:"raw_material_id"` | 	RawMaterialID *uint      `query:"raw_material_id"` | ||||||
| 	SourceType    *string    `form:"source_type"` | 	SourceType    *string    `query:"source_type"` | ||||||
| 	SourceID      *uint      `form:"source_id"` | 	SourceID      *uint      `query:"source_id"` | ||||||
| 	StartTime     *time.Time `form:"start_time"` | 	StartTime     *time.Time `query:"start_time"` | ||||||
| 	EndTime       *time.Time `form:"end_time"` | 	EndTime       *time.Time `query:"end_time"` | ||||||
| 	OrderBy       string     `form:"order_by"` | 	OrderBy       string     `query:"order_by"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // RawMaterialStockLogDTO 是用于API响应的原料库存日志结构 | // RawMaterialStockLogDTO 是用于API响应的原料库存日志结构 | ||||||
| @@ -283,14 +284,14 @@ type ListRawMaterialStockLogResponse struct { | |||||||
|  |  | ||||||
| // ListFeedUsageRecordRequest 定义了获取饲料使用记录列表的请求参数 | // ListFeedUsageRecordRequest 定义了获取饲料使用记录列表的请求参数 | ||||||
| type ListFeedUsageRecordRequest struct { | type ListFeedUsageRecordRequest struct { | ||||||
| 	Page          int        `form:"page,default=1"` | 	Page          int        `query:"page"` | ||||||
| 	PageSize      int        `form:"pageSize,default=10"` | 	PageSize      int        `query:"pageSize"` | ||||||
| 	PenID         *uint      `form:"pen_id"` | 	PenID         *uint      `query:"pen_id"` | ||||||
| 	FeedFormulaID *uint      `form:"feed_formula_id"` | 	FeedFormulaID *uint      `query:"feed_formula_id"` | ||||||
| 	OperatorID    *uint      `form:"operator_id"` | 	OperatorID    *uint      `query:"operator_id"` | ||||||
| 	StartTime     *time.Time `form:"start_time"` | 	StartTime     *time.Time `query:"start_time"` | ||||||
| 	EndTime       *time.Time `form:"end_time"` | 	EndTime       *time.Time `query:"end_time"` | ||||||
| 	OrderBy       string     `form:"order_by"` | 	OrderBy       string     `query:"order_by"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // PenDTO 是用于API响应的简化版猪栏结构 | // PenDTO 是用于API响应的简化版猪栏结构 | ||||||
| @@ -328,15 +329,15 @@ type ListFeedUsageRecordResponse struct { | |||||||
|  |  | ||||||
| // ListMedicationLogRequest 定义了获取用药记录列表的请求参数 | // ListMedicationLogRequest 定义了获取用药记录列表的请求参数 | ||||||
| type ListMedicationLogRequest struct { | type ListMedicationLogRequest struct { | ||||||
| 	Page         int        `form:"page,default=1"` | 	Page         int        `query:"page"` | ||||||
| 	PageSize     int        `form:"pageSize,default=10"` | 	PageSize     int        `query:"pageSize"` | ||||||
| 	PigBatchID   *uint      `form:"pig_batch_id"` | 	PigBatchID   *uint      `query:"pig_batch_id"` | ||||||
| 	MedicationID *uint      `form:"medication_id"` | 	MedicationID *uint      `query:"medication_id"` | ||||||
| 	Reason       *string    `form:"reason"` | 	Reason       *string    `query:"reason"` | ||||||
| 	OperatorID   *uint      `form:"operator_id"` | 	OperatorID   *uint      `query:"operator_id"` | ||||||
| 	StartTime    *time.Time `form:"start_time"` | 	StartTime    *time.Time `query:"start_time"` | ||||||
| 	EndTime      *time.Time `form:"end_time"` | 	EndTime      *time.Time `query:"end_time"` | ||||||
| 	OrderBy      string     `form:"order_by"` | 	OrderBy      string     `query:"order_by"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // MedicationDTO 是用于API响应的简化版药品结构 | // MedicationDTO 是用于API响应的简化版药品结构 | ||||||
| @@ -369,14 +370,14 @@ type ListMedicationLogResponse struct { | |||||||
|  |  | ||||||
| // ListPigBatchLogRequest 定义了获取猪批次日志列表的请求参数 | // ListPigBatchLogRequest 定义了获取猪批次日志列表的请求参数 | ||||||
| type ListPigBatchLogRequest struct { | type ListPigBatchLogRequest struct { | ||||||
| 	Page       int        `form:"page,default=1"` | 	Page       int        `query:"page"` | ||||||
| 	PageSize   int        `form:"pageSize,default=10"` | 	PageSize   int        `query:"pageSize"` | ||||||
| 	PigBatchID *uint      `form:"pig_batch_id"` | 	PigBatchID *uint      `query:"pig_batch_id"` | ||||||
| 	ChangeType *string    `form:"change_type"` | 	ChangeType *string    `query:"change_type"` | ||||||
| 	OperatorID *uint      `form:"operator_id"` | 	OperatorID *uint      `query:"operator_id"` | ||||||
| 	StartTime  *time.Time `form:"start_time"` | 	StartTime  *time.Time `query:"start_time"` | ||||||
| 	EndTime    *time.Time `form:"end_time"` | 	EndTime    *time.Time `query:"end_time"` | ||||||
| 	OrderBy    string     `form:"order_by"` | 	OrderBy    string     `query:"order_by"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // PigBatchLogDTO 是用于API响应的猪批次日志结构 | // PigBatchLogDTO 是用于API响应的猪批次日志结构 | ||||||
| @@ -404,12 +405,12 @@ type ListPigBatchLogResponse struct { | |||||||
|  |  | ||||||
| // ListWeighingBatchRequest 定义了获取批次称重记录列表的请求参数 | // ListWeighingBatchRequest 定义了获取批次称重记录列表的请求参数 | ||||||
| type ListWeighingBatchRequest struct { | type ListWeighingBatchRequest struct { | ||||||
| 	Page       int        `form:"page,default=1"` | 	Page       int        `query:"page"` | ||||||
| 	PageSize   int        `form:"pageSize,default=10"` | 	PageSize   int        `query:"pageSize"` | ||||||
| 	PigBatchID *uint      `form:"pig_batch_id"` | 	PigBatchID *uint      `query:"pig_batch_id"` | ||||||
| 	StartTime  *time.Time `form:"start_time"` | 	StartTime  *time.Time `query:"start_time"` | ||||||
| 	EndTime    *time.Time `form:"end_time"` | 	EndTime    *time.Time `query:"end_time"` | ||||||
| 	OrderBy    string     `form:"order_by"` | 	OrderBy    string     `query:"order_by"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // WeighingBatchDTO 是用于API响应的批次称重记录结构 | // WeighingBatchDTO 是用于API响应的批次称重记录结构 | ||||||
| @@ -432,14 +433,14 @@ type ListWeighingBatchResponse struct { | |||||||
|  |  | ||||||
| // ListWeighingRecordRequest 定义了获取单次称重记录列表的请求参数 | // ListWeighingRecordRequest 定义了获取单次称重记录列表的请求参数 | ||||||
| type ListWeighingRecordRequest struct { | type ListWeighingRecordRequest struct { | ||||||
| 	Page            int        `form:"page,default=1"` | 	Page            int        `query:"page"` | ||||||
| 	PageSize        int        `form:"pageSize,default=10"` | 	PageSize        int        `query:"pageSize"` | ||||||
| 	WeighingBatchID *uint      `form:"weighing_batch_id"` | 	WeighingBatchID *uint      `query:"weighing_batch_id"` | ||||||
| 	PenID           *uint      `form:"pen_id"` | 	PenID           *uint      `query:"pen_id"` | ||||||
| 	OperatorID      *uint      `form:"operator_id"` | 	OperatorID      *uint      `query:"operator_id"` | ||||||
| 	StartTime       *time.Time `form:"start_time"` | 	StartTime       *time.Time `query:"start_time"` | ||||||
| 	EndTime         *time.Time `form:"end_time"` | 	EndTime         *time.Time `query:"end_time"` | ||||||
| 	OrderBy         string     `form:"order_by"` | 	OrderBy         string     `query:"order_by"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // WeighingRecordDTO 是用于API响应的单次称重记录结构 | // WeighingRecordDTO 是用于API响应的单次称重记录结构 | ||||||
| @@ -465,16 +466,16 @@ type ListWeighingRecordResponse struct { | |||||||
|  |  | ||||||
| // ListPigTransferLogRequest 定义了获取猪只迁移日志列表的请求参数 | // ListPigTransferLogRequest 定义了获取猪只迁移日志列表的请求参数 | ||||||
| type ListPigTransferLogRequest struct { | type ListPigTransferLogRequest struct { | ||||||
| 	Page          int        `form:"page,default=1"` | 	Page          int        `query:"page"` | ||||||
| 	PageSize      int        `form:"pageSize,default=10"` | 	PageSize      int        `query:"pageSize"` | ||||||
| 	PigBatchID    *uint      `form:"pig_batch_id"` | 	PigBatchID    *uint      `query:"pig_batch_id"` | ||||||
| 	PenID         *uint      `form:"pen_id"` | 	PenID         *uint      `query:"pen_id"` | ||||||
| 	TransferType  *string    `form:"transfer_type"` | 	TransferType  *string    `query:"transfer_type"` | ||||||
| 	OperatorID    *uint      `form:"operator_id"` | 	OperatorID    *uint      `query:"operator_id"` | ||||||
| 	CorrelationID *string    `form:"correlation_id"` | 	CorrelationID *string    `query:"correlation_id"` | ||||||
| 	StartTime     *time.Time `form:"start_time"` | 	StartTime     *time.Time `query:"start_time"` | ||||||
| 	EndTime       *time.Time `form:"end_time"` | 	EndTime       *time.Time `query:"end_time"` | ||||||
| 	OrderBy       string     `form:"order_by"` | 	OrderBy       string     `query:"order_by"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // PigTransferLogDTO 是用于API响应的猪只迁移日志结构 | // PigTransferLogDTO 是用于API响应的猪只迁移日志结构 | ||||||
| @@ -502,16 +503,16 @@ type ListPigTransferLogResponse struct { | |||||||
|  |  | ||||||
| // ListPigSickLogRequest 定义了获取病猪日志列表的请求参数 | // ListPigSickLogRequest 定义了获取病猪日志列表的请求参数 | ||||||
| type ListPigSickLogRequest struct { | type ListPigSickLogRequest struct { | ||||||
| 	Page              int        `form:"page,default=1"` | 	Page              int        `query:"page"` | ||||||
| 	PageSize          int        `form:"pageSize,default=10"` | 	PageSize          int        `query:"pageSize"` | ||||||
| 	PigBatchID        *uint      `form:"pig_batch_id"` | 	PigBatchID        *uint      `query:"pig_batch_id"` | ||||||
| 	PenID             *uint      `form:"pen_id"` | 	PenID             *uint      `query:"pen_id"` | ||||||
| 	Reason            *string    `form:"reason"` | 	Reason            *string    `query:"reason"` | ||||||
| 	TreatmentLocation *string    `form:"treatment_location"` | 	TreatmentLocation *string    `query:"treatment_location"` | ||||||
| 	OperatorID        *uint      `form:"operator_id"` | 	OperatorID        *uint      `query:"operator_id"` | ||||||
| 	StartTime         *time.Time `form:"start_time"` | 	StartTime         *time.Time `query:"start_time"` | ||||||
| 	EndTime           *time.Time `form:"end_time"` | 	EndTime           *time.Time `query:"end_time"` | ||||||
| 	OrderBy           string     `form:"order_by"` | 	OrderBy           string     `query:"order_by"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // PigSickLogDTO 是用于API响应的病猪日志结构 | // PigSickLogDTO 是用于API响应的病猪日志结构 | ||||||
| @@ -541,14 +542,14 @@ type ListPigSickLogResponse struct { | |||||||
|  |  | ||||||
| // ListPigPurchaseRequest 定义了获取猪只采购记录列表的请求参数 | // ListPigPurchaseRequest 定义了获取猪只采购记录列表的请求参数 | ||||||
| type ListPigPurchaseRequest struct { | type ListPigPurchaseRequest struct { | ||||||
| 	Page       int        `form:"page,default=1"` | 	Page       int        `query:"page"` | ||||||
| 	PageSize   int        `form:"pageSize,default=10"` | 	PageSize   int        `query:"pageSize"` | ||||||
| 	PigBatchID *uint      `form:"pig_batch_id"` | 	PigBatchID *uint      `query:"pig_batch_id"` | ||||||
| 	Supplier   *string    `form:"supplier"` | 	Supplier   *string    `query:"supplier"` | ||||||
| 	OperatorID *uint      `form:"operator_id"` | 	OperatorID *uint      `query:"operator_id"` | ||||||
| 	StartTime  *time.Time `form:"start_time"` | 	StartTime  *time.Time `query:"start_time"` | ||||||
| 	EndTime    *time.Time `form:"end_time"` | 	EndTime    *time.Time `query:"end_time"` | ||||||
| 	OrderBy    string     `form:"order_by"` | 	OrderBy    string     `query:"order_by"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // PigPurchaseDTO 是用于API响应的猪只采购记录结构 | // PigPurchaseDTO 是用于API响应的猪只采购记录结构 | ||||||
| @@ -576,14 +577,14 @@ type ListPigPurchaseResponse struct { | |||||||
|  |  | ||||||
| // ListPigSaleRequest 定义了获取猪只销售记录列表的请求参数 | // ListPigSaleRequest 定义了获取猪只销售记录列表的请求参数 | ||||||
| type ListPigSaleRequest struct { | type ListPigSaleRequest struct { | ||||||
| 	Page       int        `form:"page,default=1"` | 	Page       int        `query:"page"` | ||||||
| 	PageSize   int        `form:"pageSize,default=10"` | 	PageSize   int        `query:"pageSize"` | ||||||
| 	PigBatchID *uint      `form:"pig_batch_id"` | 	PigBatchID *uint      `query:"pig_batch_id"` | ||||||
| 	Buyer      *string    `form:"buyer"` | 	Buyer      *string    `query:"buyer"` | ||||||
| 	OperatorID *uint      `form:"operator_id"` | 	OperatorID *uint      `query:"operator_id"` | ||||||
| 	StartTime  *time.Time `form:"start_time"` | 	StartTime  *time.Time `query:"start_time"` | ||||||
| 	EndTime    *time.Time `form:"end_time"` | 	EndTime    *time.Time `query:"end_time"` | ||||||
| 	OrderBy    string     `form:"order_by"` | 	OrderBy    string     `query:"order_by"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // PigSaleDTO 是用于API响应的猪只销售记录结构 | // 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 | 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 结构 | // SendTestNotificationRequest 定义了发送测试通知请求的 JSON 结构 | ||||||
| type SendTestNotificationRequest struct { | type SendTestNotificationRequest struct { | ||||||
| 	// Type 指定要测试的通知渠道 | 	// Type 指定要测试的通知渠道 | ||||||
| 	Type notify.NotifierType `json:"type" binding:"required"` | 	Type notify.NotifierType `json:"type" validate:"required"` | ||||||
|  | } | ||||||
|  |  | ||||||
|  | // ListNotificationRequest 定义了获取通知列表的请求参数 | ||||||
|  | type ListNotificationRequest struct { | ||||||
|  | 	Page         int                        `query:"page"` | ||||||
|  | 	PageSize     int                        `query:"pageSize"` | ||||||
|  | 	UserID       *uint                      `query:"user_id"` | ||||||
|  | 	NotifierType *notify.NotifierType       `query:"notifier_type"` | ||||||
|  | 	Status       *models.NotificationStatus `query:"status"` | ||||||
|  | 	Level        *zapcore.Level             `query:"level"` | ||||||
|  | 	StartTime    *time.Time                 `query:"start_time"` | ||||||
|  | 	EndTime      *time.Time                 `query:"end_time"` | ||||||
|  | 	OrderBy      string                     `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 定义了创建猪批次的请求结构 | // PigBatchCreateDTO 定义了创建猪批次的请求结构 | ||||||
| type PigBatchCreateDTO struct { | type PigBatchCreateDTO struct { | ||||||
| 	BatchNumber  string                    `json:"batch_number" binding:"required"`        // 批次编号,必填 | 	BatchNumber  string                    `json:"batch_number" validate:"required"`        // 批次编号,必填 | ||||||
| 	OriginType   models.PigBatchOriginType `json:"origin_type" binding:"required"`         // 批次来源,必填 | 	OriginType   models.PigBatchOriginType `json:"origin_type" validate:"required"`         // 批次来源,必填 | ||||||
| 	StartDate    time.Time                 `json:"start_date" binding:"required"`          // 批次开始日期,必填 | 	StartDate    time.Time                 `json:"start_date" validate:"required"`          // 批次开始日期,必填 | ||||||
| 	InitialCount int                       `json:"initial_count" binding:"required,min=1"` // 初始数量,必填,最小为1 | 	InitialCount int                       `json:"initial_count" validate:"required,min=1"` // 初始数量,必填,最小为1 | ||||||
| 	Status       models.PigBatchStatus     `json:"status" binding:"required"`              // 批次状态,必填 | 	Status       models.PigBatchStatus     `json:"status" validate:"required"`              // 批次状态,必填 | ||||||
| } | } | ||||||
|  |  | ||||||
| // PigBatchUpdateDTO 定义了更新猪批次的请求结构 | // PigBatchUpdateDTO 定义了更新猪批次的请求结构 | ||||||
| @@ -27,7 +27,7 @@ type PigBatchUpdateDTO struct { | |||||||
|  |  | ||||||
| // PigBatchQueryDTO 定义了查询猪批次的请求结构 | // PigBatchQueryDTO 定义了查询猪批次的请求结构 | ||||||
| type PigBatchQueryDTO struct { | type PigBatchQueryDTO struct { | ||||||
| 	IsActive *bool `json:"is_active" form:"is_active"` // 是否活跃,可选,用于URL查询参数 | 	IsActive *bool `json:"is_active" query:"is_active"` // 是否活跃,可选,用于URL查询参数 | ||||||
| } | } | ||||||
|  |  | ||||||
| // PigBatchResponseDTO 定义了猪批次信息的响应结构 | // PigBatchResponseDTO 定义了猪批次信息的响应结构 | ||||||
| @@ -48,115 +48,115 @@ type PigBatchResponseDTO struct { | |||||||
|  |  | ||||||
| // AssignEmptyPensToBatchRequest 用于为猪批次分配空栏的请求体 | // AssignEmptyPensToBatchRequest 用于为猪批次分配空栏的请求体 | ||||||
| type AssignEmptyPensToBatchRequest struct { | type AssignEmptyPensToBatchRequest struct { | ||||||
| 	PenIDs []uint `json:"penIDs" binding:"required,min=1" example:"[1,2,3]"` // 待分配的猪栏ID列表 | 	PenIDs []uint `json:"penIDs" validate:"required,min=1,dive" example:"[1,2,3]"` // 待分配的猪栏ID列表 | ||||||
| } | } | ||||||
|  |  | ||||||
| // ReclassifyPenToNewBatchRequest 用于将猪栏划拨到新批次的请求体 | // ReclassifyPenToNewBatchRequest 用于将猪栏划拨到新批次的请求体 | ||||||
| type ReclassifyPenToNewBatchRequest struct { | type ReclassifyPenToNewBatchRequest struct { | ||||||
| 	ToBatchID uint   `json:"toBatchID" binding:"required"` // 目标猪批次ID | 	ToBatchID uint   `json:"toBatchID" validate:"required"` // 目标猪批次ID | ||||||
| 	PenID     uint   `json:"penID" binding:"required"`     // 待划拨的猪栏ID | 	PenID     uint   `json:"penID" validate:"required"`     // 待划拨的猪栏ID | ||||||
| 	Remarks   string `json:"remarks"`                       // 备注 | 	Remarks   string `json:"remarks"`                       // 备注 | ||||||
| } | } | ||||||
|  |  | ||||||
| // RemoveEmptyPenFromBatchRequest 用于从猪批次移除空栏的请求体 | // RemoveEmptyPenFromBatchRequest 用于从猪批次移除空栏的请求体 | ||||||
| type RemoveEmptyPenFromBatchRequest struct { | type RemoveEmptyPenFromBatchRequest struct { | ||||||
| 	PenID uint `json:"penID" binding:"required"` // 待移除的猪栏ID | 	PenID uint `json:"penID" validate:"required"` // 待移除的猪栏ID | ||||||
| } | } | ||||||
|  |  | ||||||
| // MovePigsIntoPenRequest 用于将猪只从“虚拟库存”移入指定猪栏的请求体 | // MovePigsIntoPenRequest 用于将猪只从“虚拟库存”移入指定猪栏的请求体 | ||||||
| type MovePigsIntoPenRequest struct { | type MovePigsIntoPenRequest struct { | ||||||
| 	ToPenID  uint   `json:"toPenID" binding:"required"`        // 目标猪栏ID | 	ToPenID  uint   `json:"toPenID" validate:"required"`        // 目标猪栏ID | ||||||
| 	Quantity int    `json:"quantity" binding:"required,min=1"` // 移入猪只数量 | 	Quantity int    `json:"quantity" validate:"required,min=1"` // 移入猪只数量 | ||||||
| 	Remarks  string `json:"remarks"`                            // 备注 | 	Remarks  string `json:"remarks"`                            // 备注 | ||||||
| } | } | ||||||
|  |  | ||||||
| // SellPigsRequest 用于处理卖猪的请求体 | // SellPigsRequest 用于处理卖猪的请求体 | ||||||
| type SellPigsRequest struct { | type SellPigsRequest struct { | ||||||
| 	PenID      uint      `json:"penID" binding:"required"`            // 猪栏ID | 	PenID      uint      `json:"penID" validate:"required"`            // 猪栏ID | ||||||
| 	Quantity   int       `json:"quantity" binding:"required,min=1"`   // 卖出猪只数量 | 	Quantity   int       `json:"quantity" validate:"required,min=1"`   // 卖出猪只数量 | ||||||
| 	UnitPrice  float64   `json:"unitPrice" binding:"required,min=0"`  // 单价 | 	UnitPrice  float64   `json:"unitPrice" validate:"required,min=0"`  // 单价 | ||||||
| 	TotalPrice float64   `json:"totalPrice" binding:"required,min=0"` // 总价 | 	TotalPrice float64   `json:"totalPrice" validate:"required,min=0"` // 总价 | ||||||
| 	TraderName string    `json:"traderName" binding:"required"`       // 交易方名称 | 	TraderName string    `json:"traderName" validate:"required"`       // 交易方名称 | ||||||
| 	TradeDate  time.Time `json:"tradeDate" binding:"required"`        // 交易日期 | 	TradeDate  time.Time `json:"tradeDate" validate:"required"`        // 交易日期 | ||||||
| 	Remarks    string    `json:"remarks"`                              // 备注 | 	Remarks    string    `json:"remarks"`                              // 备注 | ||||||
| } | } | ||||||
|  |  | ||||||
| // BuyPigsRequest 用于处理买猪的请求体 | // BuyPigsRequest 用于处理买猪的请求体 | ||||||
| type BuyPigsRequest struct { | type BuyPigsRequest struct { | ||||||
| 	PenID      uint      `json:"penID" binding:"required"`            // 猪栏ID | 	PenID      uint      `json:"penID" validate:"required"`            // 猪栏ID | ||||||
| 	Quantity   int       `json:"quantity" binding:"required,min=1"`   // 买入猪只数量 | 	Quantity   int       `json:"quantity" validate:"required,min=1"`   // 买入猪只数量 | ||||||
| 	UnitPrice  float64   `json:"unitPrice" binding:"required,min=0"`  // 单价 | 	UnitPrice  float64   `json:"unitPrice" validate:"required,min=0"`  // 单价 | ||||||
| 	TotalPrice float64   `json:"totalPrice" binding:"required,min=0"` // 总价 | 	TotalPrice float64   `json:"totalPrice" validate:"required,min=0"` // 总价 | ||||||
| 	TraderName string    `json:"traderName" binding:"required"`       // 交易方名称 | 	TraderName string    `json:"traderName" validate:"required"`       // 交易方名称 | ||||||
| 	TradeDate  time.Time `json:"tradeDate" binding:"required"`        // 交易日期 | 	TradeDate  time.Time `json:"tradeDate" validate:"required"`        // 交易日期 | ||||||
| 	Remarks    string    `json:"remarks"`                              // 备注 | 	Remarks    string    `json:"remarks"`                              // 备注 | ||||||
| } | } | ||||||
|  |  | ||||||
| // TransferPigsAcrossBatchesRequest 用于跨猪群调栏的请求体 | // TransferPigsAcrossBatchesRequest 用于跨猪群调栏的请求体 | ||||||
| type TransferPigsAcrossBatchesRequest struct { | type TransferPigsAcrossBatchesRequest struct { | ||||||
| 	DestBatchID uint   `json:"destBatchID" binding:"required"`    // 目标猪批次ID | 	DestBatchID uint   `json:"destBatchID" validate:"required"`    // 目标猪批次ID | ||||||
| 	FromPenID   uint   `json:"fromPenID" binding:"required"`      // 源猪栏ID | 	FromPenID   uint   `json:"fromPenID" validate:"required"`      // 源猪栏ID | ||||||
| 	ToPenID     uint   `json:"toPenID" binding:"required"`        // 目标猪栏ID | 	ToPenID     uint   `json:"toPenID" validate:"required"`        // 目标猪栏ID | ||||||
| 	Quantity    uint   `json:"quantity" binding:"required,min=1"` // 调栏猪只数量 | 	Quantity    uint   `json:"quantity" validate:"required,min=1"` // 调栏猪只数量 | ||||||
| 	Remarks     string `json:"remarks"`                            // 备注 | 	Remarks     string `json:"remarks"`                            // 备注 | ||||||
| } | } | ||||||
|  |  | ||||||
| // TransferPigsWithinBatchRequest 用于群内调栏的请求体 | // TransferPigsWithinBatchRequest 用于群内调栏的请求体 | ||||||
| type TransferPigsWithinBatchRequest struct { | type TransferPigsWithinBatchRequest struct { | ||||||
| 	FromPenID uint   `json:"fromPenID" binding:"required"`      // 源猪栏ID | 	FromPenID uint   `json:"fromPenID" validate:"required"`      // 源猪栏ID | ||||||
| 	ToPenID   uint   `json:"toPenID" binding:"required"`        // 目标猪栏ID | 	ToPenID   uint   `json:"toPenID" validate:"required"`        // 目标猪栏ID | ||||||
| 	Quantity  uint   `json:"quantity" binding:"required,min=1"` // 调栏猪只数量 | 	Quantity  uint   `json:"quantity" validate:"required,min=1"` // 调栏猪只数量 | ||||||
| 	Remarks   string `json:"remarks"`                            // 备注 | 	Remarks   string `json:"remarks"`                            // 备注 | ||||||
| } | } | ||||||
|  |  | ||||||
| // RecordSickPigsRequest 用于记录新增病猪事件的请求体 | // RecordSickPigsRequest 用于记录新增病猪事件的请求体 | ||||||
| type RecordSickPigsRequest struct { | type RecordSickPigsRequest struct { | ||||||
| 	PenID             uint                                    `json:"penID" binding:"required"`             // 猪栏ID | 	PenID             uint                                    `json:"penID" validate:"required"`             // 猪栏ID | ||||||
| 	Quantity          int                                     `json:"quantity" binding:"required,min=1"`    // 病猪数量 | 	Quantity          int                                     `json:"quantity" validate:"required,min=1"`    // 病猪数量 | ||||||
| 	TreatmentLocation models.PigBatchSickPigTreatmentLocation `json:"treatmentLocation" binding:"required"` // 治疗地点 | 	TreatmentLocation models.PigBatchSickPigTreatmentLocation `json:"treatmentLocation" validate:"required"` // 治疗地点 | ||||||
| 	HappenedAt        time.Time                               `json:"happenedAt" binding:"required"`        // 发生时间 | 	HappenedAt        time.Time                               `json:"happenedAt" validate:"required"`        // 发生时间 | ||||||
| 	Remarks           string                                  `json:"remarks"`                               // 备注 | 	Remarks           string                                  `json:"remarks"`                               // 备注 | ||||||
| } | } | ||||||
|  |  | ||||||
| // RecordSickPigRecoveryRequest 用于记录病猪康复事件的请求体 | // RecordSickPigRecoveryRequest 用于记录病猪康复事件的请求体 | ||||||
| type RecordSickPigRecoveryRequest struct { | type RecordSickPigRecoveryRequest struct { | ||||||
| 	PenID             uint                                    `json:"penID" binding:"required"`             // 猪栏ID | 	PenID             uint                                    `json:"penID" validate:"required"`             // 猪栏ID | ||||||
| 	Quantity          int                                     `json:"quantity" binding:"required,min=1"`    // 康复猪数量 | 	Quantity          int                                     `json:"quantity" validate:"required,min=1"`    // 康复猪数量 | ||||||
| 	TreatmentLocation models.PigBatchSickPigTreatmentLocation `json:"treatmentLocation" binding:"required"` // 治疗地点 | 	TreatmentLocation models.PigBatchSickPigTreatmentLocation `json:"treatmentLocation" validate:"required"` // 治疗地点 | ||||||
| 	HappenedAt        time.Time                               `json:"happenedAt" binding:"required"`        // 发生时间 | 	HappenedAt        time.Time                               `json:"happenedAt" validate:"required"`        // 发生时间 | ||||||
| 	Remarks           string                                  `json:"remarks"`                               // 备注 | 	Remarks           string                                  `json:"remarks"`                               // 备注 | ||||||
| } | } | ||||||
|  |  | ||||||
| // RecordSickPigDeathRequest 用于记录病猪死亡事件的请求体 | // RecordSickPigDeathRequest 用于记录病猪死亡事件的请求体 | ||||||
| type RecordSickPigDeathRequest struct { | type RecordSickPigDeathRequest struct { | ||||||
| 	PenID             uint                                    `json:"penID" binding:"required"`             // 猪栏ID | 	PenID             uint                                    `json:"penID" validate:"required"`             // 猪栏ID | ||||||
| 	Quantity          int                                     `json:"quantity" binding:"required,min=1"`    // 死亡猪数量 | 	Quantity          int                                     `json:"quantity" validate:"required,min=1"`    // 死亡猪数量 | ||||||
| 	TreatmentLocation models.PigBatchSickPigTreatmentLocation `json:"treatmentLocation" binding:"required"` // 治疗地点 | 	TreatmentLocation models.PigBatchSickPigTreatmentLocation `json:"treatmentLocation" validate:"required"` // 治疗地点 | ||||||
| 	HappenedAt        time.Time                               `json:"happenedAt" binding:"required"`        // 发生时间 | 	HappenedAt        time.Time                               `json:"happenedAt" validate:"required"`        // 发生时间 | ||||||
| 	Remarks           string                                  `json:"remarks"`                               // 备注 | 	Remarks           string                                  `json:"remarks"`                               // 备注 | ||||||
| } | } | ||||||
|  |  | ||||||
| // RecordSickPigCullRequest 用于记录病猪淘汰事件的请求体 | // RecordSickPigCullRequest 用于记录病猪淘汰事件的请求体 | ||||||
| type RecordSickPigCullRequest struct { | type RecordSickPigCullRequest struct { | ||||||
| 	PenID             uint                                    `json:"penID" binding:"required"`             // 猪栏ID | 	PenID             uint                                    `json:"penID" validate:"required"`             // 猪栏ID | ||||||
| 	Quantity          int                                     `json:"quantity" binding:"required,min=1"`    // 淘汰猪数量 | 	Quantity          int                                     `json:"quantity" validate:"required,min=1"`    // 淘汰猪数量 | ||||||
| 	TreatmentLocation models.PigBatchSickPigTreatmentLocation `json:"treatmentLocation" binding:"required"` // 治疗地点 | 	TreatmentLocation models.PigBatchSickPigTreatmentLocation `json:"treatmentLocation" validate:"required"` // 治疗地点 | ||||||
| 	HappenedAt        time.Time                               `json:"happenedAt" binding:"required"`        // 发生时间 | 	HappenedAt        time.Time                               `json:"happenedAt" validate:"required"`        // 发生时间 | ||||||
| 	Remarks           string                                  `json:"remarks"`                               // 备注 | 	Remarks           string                                  `json:"remarks"`                               // 备注 | ||||||
| } | } | ||||||
|  |  | ||||||
| // RecordDeathRequest 用于记录正常猪只死亡事件的请求体 | // RecordDeathRequest 用于记录正常猪只死亡事件的请求体 | ||||||
| type RecordDeathRequest struct { | type RecordDeathRequest struct { | ||||||
| 	PenID      uint      `json:"penID" binding:"required"`          // 猪栏ID | 	PenID      uint      `json:"penID" validate:"required"`          // 猪栏ID | ||||||
| 	Quantity   int       `json:"quantity" binding:"required,min=1"` // 死亡猪数量 | 	Quantity   int       `json:"quantity" validate:"required,min=1"` // 死亡猪数量 | ||||||
| 	HappenedAt time.Time `json:"happenedAt" binding:"required"`     // 发生时间 | 	HappenedAt time.Time `json:"happenedAt" validate:"required"`     // 发生时间 | ||||||
| 	Remarks    string    `json:"remarks"`                            // 备注 | 	Remarks    string    `json:"remarks"`                            // 备注 | ||||||
| } | } | ||||||
|  |  | ||||||
| // RecordCullRequest 用于记录正常猪只淘汰事件的请求体 | // RecordCullRequest 用于记录正常猪只淘汰事件的请求体 | ||||||
| type RecordCullRequest struct { | type RecordCullRequest struct { | ||||||
| 	PenID      uint      `json:"penID" binding:"required"`          // 猪栏ID | 	PenID      uint      `json:"penID" validate:"required"`          // 猪栏ID | ||||||
| 	Quantity   int       `json:"quantity" binding:"required,min=1"` // 淘汰猪数量 | 	Quantity   int       `json:"quantity" validate:"required,min=1"` // 淘汰猪数量 | ||||||
| 	HappenedAt time.Time `json:"happenedAt" binding:"required"`     // 发生时间 | 	HappenedAt time.Time `json:"happenedAt" validate:"required"`     // 发生时间 | ||||||
| 	Remarks    string    `json:"remarks"`                            // 备注 | 	Remarks    string    `json:"remarks"`                            // 备注 | ||||||
| } | } | ||||||
|   | |||||||
| @@ -22,32 +22,32 @@ type PenResponse struct { | |||||||
|  |  | ||||||
| // CreatePigHouseRequest 定义了创建猪舍的请求结构 | // CreatePigHouseRequest 定义了创建猪舍的请求结构 | ||||||
| type CreatePigHouseRequest struct { | type CreatePigHouseRequest struct { | ||||||
| 	Name        string `json:"name" binding:"required"` | 	Name        string `json:"name" validate:"required"` | ||||||
| 	Description string `json:"description"` | 	Description string `json:"description"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // UpdatePigHouseRequest 定义了更新猪舍的请求结构 | // UpdatePigHouseRequest 定义了更新猪舍的请求结构 | ||||||
| type UpdatePigHouseRequest struct { | type UpdatePigHouseRequest struct { | ||||||
| 	Name        string `json:"name" binding:"required"` | 	Name        string `json:"name" validate:"required"` | ||||||
| 	Description string `json:"description"` | 	Description string `json:"description"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // CreatePenRequest 定义了创建猪栏的请求结构 | // CreatePenRequest 定义了创建猪栏的请求结构 | ||||||
| type CreatePenRequest struct { | type CreatePenRequest struct { | ||||||
| 	PenNumber string `json:"pen_number" binding:"required"` | 	PenNumber string `json:"pen_number" validate:"required"` | ||||||
| 	HouseID   uint   `json:"house_id" binding:"required"` | 	HouseID   uint   `json:"house_id" validate:"required"` | ||||||
| 	Capacity  int    `json:"capacity" binding:"required"` | 	Capacity  int    `json:"capacity" validate:"required"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // UpdatePenRequest 定义了更新猪栏的请求结构 | // UpdatePenRequest 定义了更新猪栏的请求结构 | ||||||
| type UpdatePenRequest struct { | type UpdatePenRequest struct { | ||||||
| 	PenNumber string           `json:"pen_number" binding:"required"` | 	PenNumber string           `json:"pen_number" validate:"required"` | ||||||
| 	HouseID   uint             `json:"house_id" binding:"required"` | 	HouseID   uint             `json:"house_id" validate:"required"` | ||||||
| 	Capacity  int              `json:"capacity" binding:"required"` | 	Capacity  int              `json:"capacity" validate:"required"` | ||||||
| 	Status    models.PenStatus `json:"status" binding:"required,oneof=空闲 使用中 病猪栏 康复栏 清洗消毒 维修中"` // 添加oneof校验 | 	Status    models.PenStatus `json:"status" validate:"required,oneof=空闲 使用中 病猪栏 康复栏 清洗消毒 维修中"` // 添加oneof校验 | ||||||
| } | } | ||||||
|  |  | ||||||
| // UpdatePenStatusRequest 定义了更新猪栏状态的请求结构 | // UpdatePenStatusRequest 定义了更新猪栏状态的请求结构 | ||||||
| type UpdatePenStatusRequest struct { | 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, | 		ID:             plan.ID, | ||||||
| 		Name:           plan.Name, | 		Name:           plan.Name, | ||||||
| 		Description:    plan.Description, | 		Description:    plan.Description, | ||||||
|  | 		PlanType:       plan.PlanType, | ||||||
| 		ExecutionType:  plan.ExecutionType, | 		ExecutionType:  plan.ExecutionType, | ||||||
| 		Status:         plan.Status, | 		Status:         plan.Status, | ||||||
| 		ExecuteNum:     plan.ExecuteNum, | 		ExecuteNum:     plan.ExecuteNum, | ||||||
| @@ -64,7 +65,7 @@ func NewPlanFromCreateRequest(req *CreatePlanRequest) (*models.Plan, error) { | |||||||
| 		ExecutionType:  req.ExecutionType, | 		ExecutionType:  req.ExecutionType, | ||||||
| 		ExecuteNum:     req.ExecuteNum, | 		ExecuteNum:     req.ExecuteNum, | ||||||
| 		CronExpression: req.CronExpression, | 		CronExpression: req.CronExpression, | ||||||
| 		// ContentType 在控制器中设置,此处不再处理 | 		// ContentType 和 PlanType 在控制器中设置,此处不再处理 | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// 处理子计划 (通过ID引用) | 	// 处理子计划 (通过ID引用) | ||||||
| @@ -116,7 +117,7 @@ func NewPlanFromUpdateRequest(req *UpdatePlanRequest) (*models.Plan, error) { | |||||||
| 		ExecutionType:  req.ExecutionType, | 		ExecutionType:  req.ExecutionType, | ||||||
| 		ExecuteNum:     req.ExecuteNum, | 		ExecuteNum:     req.ExecuteNum, | ||||||
| 		CronExpression: req.CronExpression, | 		CronExpression: req.CronExpression, | ||||||
| 		// ContentType 在控制器中设置,此处不再处理 | 		// ContentType 和 PlanType 在控制器中设置,此处不再处理 | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// 处理子计划 (通过ID引用) | 	// 处理子计划 (通过ID引用) | ||||||
|   | |||||||
| @@ -1,16 +1,26 @@ | |||||||
| package dto | 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 `query:"planType"` // 计划类型 | ||||||
|  | 	Page     int                       `query:"page"`     // 页码 | ||||||
|  | 	PageSize int                       `query:"pageSize"` // 每页大小 | ||||||
|  | } | ||||||
|  |  | ||||||
| // CreatePlanRequest 定义创建计划请求的结构体 | // CreatePlanRequest 定义创建计划请求的结构体 | ||||||
| type CreatePlanRequest struct { | type CreatePlanRequest struct { | ||||||
| 	Name           string                   `json:"name" binding:"required" example:"猪舍温度控制计划"` | 	Name           string                   `json:"name" validate:"required" example:"猪舍温度控制计划"` | ||||||
| 	Description    string                   `json:"description" example:"根据温度自动调节风扇和加热器"` | 	Description    string                   `json:"description" example:"根据温度自动调节风扇和加热器"` | ||||||
| 	ExecutionType  models.PlanExecutionType `json:"execution_type" binding:"required" example:"自动"` | 	ExecutionType  models.PlanExecutionType `json:"execution_type" validate:"required" example:"自动"` | ||||||
| 	ExecuteNum     uint                     `json:"execute_num,omitempty" example:"10"` | 	ExecuteNum     uint                     `json:"execute_num,omitempty" validate:"omitempty,min=0" example:"10"` | ||||||
| 	CronExpression string                   `json:"cron_expression" example:"0 0 6 * * *"` | 	CronExpression string                   `json:"cron_expression" validate:"omitempty,cron" example:"0 0 6 * * *"` | ||||||
| 	SubPlanIDs     []uint                   `json:"sub_plan_ids,omitempty"` | 	SubPlanIDs     []uint                   `json:"sub_plan_ids,omitempty" validate:"omitempty,dive"` | ||||||
| 	Tasks          []TaskRequest            `json:"tasks,omitempty"` | 	Tasks          []TaskRequest            `json:"tasks,omitempty" validate:"omitempty,dive"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // PlanResponse 定义计划详情响应的结构体 | // PlanResponse 定义计划详情响应的结构体 | ||||||
| @@ -18,6 +28,7 @@ type PlanResponse struct { | |||||||
| 	ID             uint                     `json:"id" example:"1"` | 	ID             uint                     `json:"id" example:"1"` | ||||||
| 	Name           string                   `json:"name" example:"猪舍温度控制计划"` | 	Name           string                   `json:"name" example:"猪舍温度控制计划"` | ||||||
| 	Description    string                   `json:"description" example:"根据温度自动调节风扇和加热器"` | 	Description    string                   `json:"description" example:"根据温度自动调节风扇和加热器"` | ||||||
|  | 	PlanType       models.PlanType          `json:"plan_type" example:"自定义任务"` | ||||||
| 	ExecutionType  models.PlanExecutionType `json:"execution_type" example:"自动"` | 	ExecutionType  models.PlanExecutionType `json:"execution_type" example:"自动"` | ||||||
| 	Status         models.PlanStatus        `json:"status" example:"已启用"` | 	Status         models.PlanStatus        `json:"status" example:"已启用"` | ||||||
| 	ExecuteNum     uint                     `json:"execute_num" example:"10"` | 	ExecuteNum     uint                     `json:"execute_num" example:"10"` | ||||||
| @@ -31,18 +42,18 @@ type PlanResponse struct { | |||||||
| // ListPlansResponse 定义获取计划列表响应的结构体 | // ListPlansResponse 定义获取计划列表响应的结构体 | ||||||
| type ListPlansResponse struct { | type ListPlansResponse struct { | ||||||
| 	Plans []PlanResponse `json:"plans"` | 	Plans []PlanResponse `json:"plans"` | ||||||
| 	Total int            `json:"total" example:"100"` | 	Total int64          `json:"total" example:"100"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // UpdatePlanRequest 定义更新计划请求的结构体 | // UpdatePlanRequest 定义更新计划请求的结构体 | ||||||
| type UpdatePlanRequest struct { | type UpdatePlanRequest struct { | ||||||
| 	Name           string                   `json:"name" example:"猪舍温度控制计划V2"` | 	Name           string                   `json:"name" example:"猪舍温度控制计划V2"` | ||||||
| 	Description    string                   `json:"description" example:"更新后的描述"` | 	Description    string                   `json:"description" example:"更新后的描述"` | ||||||
| 	ExecutionType  models.PlanExecutionType `json:"execution_type" binding:"required" example:"自动"` | 	ExecutionType  models.PlanExecutionType `json:"execution_type" validate:"required" example:"自动"` | ||||||
| 	ExecuteNum     uint                     `json:"execute_num,omitempty" example:"10"` | 	ExecuteNum     uint                     `json:"execute_num,omitempty" validate:"omitempty,min=0" example:"10"` | ||||||
| 	CronExpression string                   `json:"cron_expression" example:"0 0 6 * * *"` | 	CronExpression string                   `json:"cron_expression" validate:"omitempty,cron" example:"0 0 6 * * *"` | ||||||
| 	SubPlanIDs     []uint                   `json:"sub_plan_ids,omitempty"` | 	SubPlanIDs     []uint                   `json:"sub_plan_ids,omitempty" validate:"omitempty,dive"` | ||||||
| 	Tasks          []TaskRequest            `json:"tasks,omitempty"` | 	Tasks          []TaskRequest            `json:"tasks,omitempty" validate:"omitempty,dive"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // SubPlanResponse 定义子计划响应结构体 | // SubPlanResponse 定义子计划响应结构体 | ||||||
|   | |||||||
| @@ -2,15 +2,15 @@ package dto | |||||||
|  |  | ||||||
| // CreateUserRequest 定义创建用户请求的结构体 | // CreateUserRequest 定义创建用户请求的结构体 | ||||||
| type CreateUserRequest struct { | type CreateUserRequest struct { | ||||||
| 	Username string `json:"username" binding:"required" example:"newuser"` | 	Username string `json:"username" validate:"required" example:"newuser"` | ||||||
| 	Password string `json:"password" binding:"required" example:"password123"` | 	Password string `json:"password" validate:"required" example:"password123"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // LoginRequest 定义登录请求的结构体 | // LoginRequest 定义登录请求的结构体 | ||||||
| type LoginRequest struct { | type LoginRequest struct { | ||||||
| 	// Identifier 可以是用户名、邮箱、手机号、微信号或飞书账号 | 	// Identifier 可以是用户名、邮箱、手机号、微信号或飞书账号 | ||||||
| 	Identifier string `json:"identifier" binding:"required" example:"testuser"` | 	Identifier string `json:"identifier" validate:"required" example:"testuser"` | ||||||
| 	Password   string `json:"password" binding:"required" example:"password123"` | 	Password   string `json:"password" validate:"required" example:"password123"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // CreateUserResponse 定义创建用户成功响应的结构体 | // CreateUserResponse 定义创建用户成功响应的结构体 | ||||||
|   | |||||||
| @@ -1,117 +1,59 @@ | |||||||
| package middleware | package middleware | ||||||
|  |  | ||||||
| import ( | import ( | ||||||
| 	"bytes" |  | ||||||
| 	"encoding/json" |  | ||||||
| 	"io" |  | ||||||
| 	"strconv" |  | ||||||
|  |  | ||||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/domain/audit" | 	"git.huangwc.com/pig/pig-farm-controller/internal/domain/audit" | ||||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||||
| 	"github.com/gin-gonic/gin" | 	"github.com/labstack/echo/v4" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| type auditResponse struct { | // AuditLogMiddleware 创建一个Echo中间件,用于在请求结束后记录用户操作审计日志。 | ||||||
| 	Code    int    `json:"code"` | // 它依赖于控制器通过调用 SendSuccessWithAudit 或 SendErrorWithAudit 在上下文中设置的审计信息。 | ||||||
| 	Message string `json:"message"` | func AuditLogMiddleware(auditService audit.Service) echo.MiddlewareFunc { | ||||||
| } | 	return func(next echo.HandlerFunc) echo.HandlerFunc { | ||||||
|  | 		return func(c echo.Context) error { | ||||||
| // 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() | 			err := next(c) | ||||||
|  |  | ||||||
| 			// --- 在这里,请求已经处理完毕 --- | 			// --- 在这里,请求已经处理完毕 --- | ||||||
|  |  | ||||||
| 			// 从上下文中尝试获取由控制器设置的业务审计信息 | 			// 从上下文中尝试获取由控制器设置的业务审计信息 | ||||||
| 		actionType, exists := c.Get(models.ContextAuditActionType.String()) | 			actionType, exists := c.Get(models.ContextAuditActionType.String()).(string) | ||||||
| 		if !exists { | 			if !exists || actionType == "" { | ||||||
| 				// 如果上下文中没有 actionType,说明此接口无需记录审计日志,直接返回 | 				// 如果上下文中没有 actionType,说明此接口无需记录审计日志,直接返回 | ||||||
| 			return | 				return err | ||||||
| 			} | 			} | ||||||
|  |  | ||||||
| 		// 从 Gin Context 中获取用户对象 | 			// 从 Context 中获取用户对象 | ||||||
| 		userCtx, userExists := c.Get(models.ContextUserKey.String()) |  | ||||||
| 			var user *models.User | 			var user *models.User | ||||||
| 		if userExists { | 			if userCtx := c.Get(models.ContextUserKey.String()); userCtx != nil { | ||||||
| 				user, _ = userCtx.(*models.User) | 				user, _ = userCtx.(*models.User) | ||||||
| 			} | 			} | ||||||
|  |  | ||||||
| 			// 构建 RequestContext | 			// 构建 RequestContext | ||||||
| 			reqCtx := audit.RequestContext{ | 			reqCtx := audit.RequestContext{ | ||||||
| 			ClientIP:   c.ClientIP(), | 				ClientIP:   c.RealIP(), | ||||||
| 			HTTPPath:   c.Request.URL.Path, | 				HTTPPath:   c.Request().URL.Path, | ||||||
| 			HTTPMethod: c.Request.Method, | 				HTTPMethod: c.Request().Method, | ||||||
| 			} | 			} | ||||||
|  |  | ||||||
| 		// 获取其他审计信息 | 			// 直接从上下文中获取所有其他审计信息 | ||||||
| 		description, _ := c.Get(models.ContextAuditDescription.String()) | 			description, _ := c.Get(models.ContextAuditDescription.String()).(string) | ||||||
| 		targetResource, _ := c.Get(models.ContextAuditTargetResource.String()) | 			targetResource := c.Get(models.ContextAuditTargetResource.String()) | ||||||
|  | 			status, _ := c.Get(models.ContextAuditStatus.String()).(models.AuditStatus) | ||||||
| 		// 默认操作状态为成功 | 			resultDetails, _ := c.Get(models.ContextAuditResultDetails.String()).(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 |  | ||||||
| 			} |  | ||||||
| 		} 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( | 			auditService.LogAction( | ||||||
| 				user, | 				user, | ||||||
| 				reqCtx, | 				reqCtx, | ||||||
| 			actionType.(string), | 				actionType, | ||||||
| 			description.(string), | 				description, | ||||||
| 				targetResource, | 				targetResource, | ||||||
| 				status, | 				status, | ||||||
| 				resultDetails, | 				resultDetails, | ||||||
| 			) | 			) | ||||||
| 	} |  | ||||||
| } |  | ||||||
|  |  | ||||||
| // bodyLogWriter 是一个自定义的 gin.ResponseWriter,用于捕获响应体 | 			return err | ||||||
| // 这对于在操作失败时记录详细的错误信息非常有用 |  | ||||||
| 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,33 +1,34 @@ | |||||||
| // Package middleware 存放 gin 中间件 | // Package middleware 存放中间件 | ||||||
| package middleware | package middleware | ||||||
|  |  | ||||||
| import ( | import ( | ||||||
|  | 	"errors" | ||||||
| 	"net/http" | 	"net/http" | ||||||
| 	"strings" | 	"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/domain/token" | ||||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" | 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" | ||||||
| 	"github.com/gin-gonic/gin" | 	"github.com/labstack/echo/v4" | ||||||
| 	"gorm.io/gorm" | 	"gorm.io/gorm" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| // AuthMiddleware 创建一个Gin中间件,用于JWT身份验证 | // AuthMiddleware 创建一个Echo中间件,用于JWT身份验证 | ||||||
| // 它依赖于 TokenService 来解析和验证 token,并使用 UserRepository 来获取完整的用户信息 | // 它依赖于 TokenService 来解析和验证 token,并使用 UserRepository 来获取完整的用户信息 | ||||||
| func AuthMiddleware(tokenService token.TokenService, userRepo repository.UserRepository) gin.HandlerFunc { | func AuthMiddleware(tokenService token.Service, userRepo repository.UserRepository) echo.MiddlewareFunc { | ||||||
| 	return func(c *gin.Context) { | 	return func(next echo.HandlerFunc) echo.HandlerFunc { | ||||||
|  | 		return func(c echo.Context) error { | ||||||
| 			// 从 Authorization header 获取 token | 			// 从 Authorization header 获取 token | ||||||
| 		authHeader := c.GetHeader("Authorization") | 			authHeader := c.Request().Header.Get("Authorization") | ||||||
| 			if authHeader == "" { | 			if authHeader == "" { | ||||||
| 			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "请求未包含授权标头"}) | 				return controller.SendErrorWithStatus(c, http.StatusUnauthorized, controller.CodeUnauthorized, "请求未包含授权标头") | ||||||
| 			return |  | ||||||
| 			} | 			} | ||||||
|  |  | ||||||
| 			// 授权标头的格式应为 "Bearer <token>" | 			// 授权标头的格式应为 "Bearer <token>" | ||||||
| 			parts := strings.Split(authHeader, " ") | 			parts := strings.Split(authHeader, " ") | ||||||
| 			if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { | 			if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { | ||||||
| 			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "授权标头格式不正确"}) | 				return controller.SendErrorWithStatus(c, http.StatusUnauthorized, controller.CodeUnauthorized, "授权标头格式不正确") | ||||||
| 			return |  | ||||||
| 			} | 			} | ||||||
|  |  | ||||||
| 			tokenString := parts[1] | 			tokenString := parts[1] | ||||||
| @@ -35,27 +36,25 @@ func AuthMiddleware(tokenService token.TokenService, userRepo repository.UserRep | |||||||
| 			// 解析和验证 token | 			// 解析和验证 token | ||||||
| 			claims, err := tokenService.ParseToken(tokenString) | 			claims, err := tokenService.ParseToken(tokenString) | ||||||
| 			if err != nil { | 			if err != nil { | ||||||
| 			c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "无效的Token"}) | 				return controller.SendErrorWithStatus(c, http.StatusUnauthorized, controller.CodeUnauthorized, "无效的Token") | ||||||
| 			return |  | ||||||
| 			} | 			} | ||||||
|  |  | ||||||
| 			// 根据 token 中的用户ID,从数据库中获取完整的用户信息 | 			// 根据 token 中的用户ID,从数据库中获取完整的用户信息 | ||||||
| 			user, err := userRepo.FindByID(claims.UserID) | 			user, err := userRepo.FindByID(claims.UserID) | ||||||
| 			if err != nil { | 			if err != nil { | ||||||
| 			if err == gorm.ErrRecordNotFound { | 				if errors.Is(err, gorm.ErrRecordNotFound) { | ||||||
| 					// Token有效,但对应的用户已不存在 | 					// Token有效,但对应的用户已不存在 | ||||||
| 				c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "授权用户不存在"}) | 					return controller.SendErrorWithStatus(c, http.StatusUnauthorized, controller.CodeUnauthorized, "授权用户不存在") | ||||||
| 				return |  | ||||||
| 				} | 				} | ||||||
| 				// 其他数据库查询错误 | 				// 其他数据库查询错误 | ||||||
| 			c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "获取用户信息失败"}) | 				return controller.SendErrorWithStatus(c, http.StatusInternalServerError, controller.CodeInternalError, "获取用户信息失败") | ||||||
| 			return |  | ||||||
| 			} | 			} | ||||||
|  |  | ||||||
| 			// 将完整的用户对象存储在 context 中,以便后续的处理函数使用 | 			// 将完整的用户对象存储在 context 中,以便后续的处理函数使用 | ||||||
| 			c.Set(models.ContextUserKey.String(), user) | 			c.Set(models.ContextUserKey.String(), user) | ||||||
|  |  | ||||||
| 			// 继续处理请求链中的下一个处理程序 | 			// 继续处理请求链中的下一个处理程序 | ||||||
| 		c.Next() | 			return next(c) | ||||||
|  | 		} | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										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 | package service | ||||||
|  |  | ||||||
| import ( | 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/models" | ||||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" | 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| // MonitorService 定义了监控相关的业务逻辑服务接口 | // MonitorService 定义了监控相关的业务逻辑服务接口 | ||||||
| type MonitorService interface { | type MonitorService interface { | ||||||
| 	ListSensorData(opts repository.SensorDataListOptions, page, pageSize int) ([]models.SensorData, int64, error) | 	ListSensorData(req *dto.ListSensorDataRequest) (*dto.ListSensorDataResponse, error) | ||||||
| 	ListDeviceCommandLogs(opts repository.DeviceCommandLogListOptions, page, pageSize int) ([]models.DeviceCommandLog, int64, error) | 	ListDeviceCommandLogs(req *dto.ListDeviceCommandLogRequest) (*dto.ListDeviceCommandLogResponse, error) | ||||||
| 	ListPlanExecutionLogs(opts repository.PlanExecutionLogListOptions, page, pageSize int) ([]models.PlanExecutionLog, int64, error) | 	ListPlanExecutionLogs(req *dto.ListPlanExecutionLogRequest) (*dto.ListPlanExecutionLogResponse, error) | ||||||
| 	ListTaskExecutionLogs(opts repository.TaskExecutionLogListOptions, page, pageSize int) ([]models.TaskExecutionLog, int64, error) | 	ListTaskExecutionLogs(req *dto.ListTaskExecutionLogRequest) (*dto.ListTaskExecutionLogResponse, error) | ||||||
| 	ListPendingCollections(opts repository.PendingCollectionListOptions, page, pageSize int) ([]models.PendingCollection, int64, error) | 	ListPendingCollections(req *dto.ListPendingCollectionRequest) (*dto.ListPendingCollectionResponse, error) | ||||||
| 	ListUserActionLogs(opts repository.UserActionLogListOptions, page, pageSize int) ([]models.UserActionLog, int64, error) | 	ListUserActionLogs(req *dto.ListUserActionLogRequest) (*dto.ListUserActionLogResponse, error) | ||||||
| 	ListRawMaterialPurchases(opts repository.RawMaterialPurchaseListOptions, page, pageSize int) ([]models.RawMaterialPurchase, int64, error) | 	ListRawMaterialPurchases(req *dto.ListRawMaterialPurchaseRequest) (*dto.ListRawMaterialPurchaseResponse, error) | ||||||
| 	ListRawMaterialStockLogs(opts repository.RawMaterialStockLogListOptions, page, pageSize int) ([]models.RawMaterialStockLog, int64, error) | 	ListRawMaterialStockLogs(req *dto.ListRawMaterialStockLogRequest) (*dto.ListRawMaterialStockLogResponse, error) | ||||||
| 	ListFeedUsageRecords(opts repository.FeedUsageRecordListOptions, page, pageSize int) ([]models.FeedUsageRecord, int64, error) | 	ListFeedUsageRecords(req *dto.ListFeedUsageRecordRequest) (*dto.ListFeedUsageRecordResponse, error) | ||||||
| 	ListMedicationLogs(opts repository.MedicationLogListOptions, page, pageSize int) ([]models.MedicationLog, int64, error) | 	ListMedicationLogs(req *dto.ListMedicationLogRequest) (*dto.ListMedicationLogResponse, error) | ||||||
| 	ListPigBatchLogs(opts repository.PigBatchLogListOptions, page, pageSize int) ([]models.PigBatchLog, int64, error) | 	ListPigBatchLogs(req *dto.ListPigBatchLogRequest) (*dto.ListPigBatchLogResponse, error) | ||||||
| 	ListWeighingBatches(opts repository.WeighingBatchListOptions, page, pageSize int) ([]models.WeighingBatch, int64, error) | 	ListWeighingBatches(req *dto.ListWeighingBatchRequest) (*dto.ListWeighingBatchResponse, error) | ||||||
| 	ListWeighingRecords(opts repository.WeighingRecordListOptions, page, pageSize int) ([]models.WeighingRecord, int64, error) | 	ListWeighingRecords(req *dto.ListWeighingRecordRequest) (*dto.ListWeighingRecordResponse, error) | ||||||
| 	ListPigTransferLogs(opts repository.PigTransferLogListOptions, page, pageSize int) ([]models.PigTransferLog, int64, error) | 	ListPigTransferLogs(req *dto.ListPigTransferLogRequest) (*dto.ListPigTransferLogResponse, error) | ||||||
| 	ListPigSickLogs(opts repository.PigSickLogListOptions, page, pageSize int) ([]models.PigSickLog, int64, error) | 	ListPigSickLogs(req *dto.ListPigSickLogRequest) (*dto.ListPigSickLogResponse, error) | ||||||
| 	ListPigPurchases(opts repository.PigPurchaseListOptions, page, pageSize int) ([]models.PigPurchase, int64, error) | 	ListPigPurchases(req *dto.ListPigPurchaseRequest) (*dto.ListPigPurchaseResponse, error) | ||||||
| 	ListPigSales(opts repository.PigSaleListOptions, page, pageSize int) ([]models.PigSale, int64, error) | 	ListPigSales(req *dto.ListPigSaleRequest) (*dto.ListPigSaleResponse, error) | ||||||
|  | 	ListNotifications(req *dto.ListNotificationRequest) (*dto.ListNotificationResponse, error) | ||||||
| } | } | ||||||
|  |  | ||||||
| // monitorService 是 MonitorService 接口的具体实现 | // monitorService 是 MonitorService 接口的具体实现 | ||||||
| @@ -31,6 +33,7 @@ type monitorService struct { | |||||||
| 	sensorDataRepo        repository.SensorDataRepository | 	sensorDataRepo        repository.SensorDataRepository | ||||||
| 	deviceCommandLogRepo  repository.DeviceCommandLogRepository | 	deviceCommandLogRepo  repository.DeviceCommandLogRepository | ||||||
| 	executionLogRepo      repository.ExecutionLogRepository | 	executionLogRepo      repository.ExecutionLogRepository | ||||||
|  | 	planRepository        repository.PlanRepository | ||||||
| 	pendingCollectionRepo repository.PendingCollectionRepository | 	pendingCollectionRepo repository.PendingCollectionRepository | ||||||
| 	userActionLogRepo     repository.UserActionLogRepository | 	userActionLogRepo     repository.UserActionLogRepository | ||||||
| 	rawMaterialRepo       repository.RawMaterialRepository | 	rawMaterialRepo       repository.RawMaterialRepository | ||||||
| @@ -40,6 +43,7 @@ type monitorService struct { | |||||||
| 	pigTransferLogRepo    repository.PigTransferLogRepository | 	pigTransferLogRepo    repository.PigTransferLogRepository | ||||||
| 	pigSickLogRepo        repository.PigSickLogRepository | 	pigSickLogRepo        repository.PigSickLogRepository | ||||||
| 	pigTradeRepo          repository.PigTradeRepository | 	pigTradeRepo          repository.PigTradeRepository | ||||||
|  | 	notificationRepo      repository.NotificationRepository | ||||||
| } | } | ||||||
|  |  | ||||||
| // NewMonitorService 创建一个新的 MonitorService 实例 | // NewMonitorService 创建一个新的 MonitorService 实例 | ||||||
| @@ -47,6 +51,7 @@ func NewMonitorService( | |||||||
| 	sensorDataRepo repository.SensorDataRepository, | 	sensorDataRepo repository.SensorDataRepository, | ||||||
| 	deviceCommandLogRepo repository.DeviceCommandLogRepository, | 	deviceCommandLogRepo repository.DeviceCommandLogRepository, | ||||||
| 	executionLogRepo repository.ExecutionLogRepository, | 	executionLogRepo repository.ExecutionLogRepository, | ||||||
|  | 	planRepository repository.PlanRepository, | ||||||
| 	pendingCollectionRepo repository.PendingCollectionRepository, | 	pendingCollectionRepo repository.PendingCollectionRepository, | ||||||
| 	userActionLogRepo repository.UserActionLogRepository, | 	userActionLogRepo repository.UserActionLogRepository, | ||||||
| 	rawMaterialRepo repository.RawMaterialRepository, | 	rawMaterialRepo repository.RawMaterialRepository, | ||||||
| @@ -56,11 +61,13 @@ func NewMonitorService( | |||||||
| 	pigTransferLogRepo repository.PigTransferLogRepository, | 	pigTransferLogRepo repository.PigTransferLogRepository, | ||||||
| 	pigSickLogRepo repository.PigSickLogRepository, | 	pigSickLogRepo repository.PigSickLogRepository, | ||||||
| 	pigTradeRepo repository.PigTradeRepository, | 	pigTradeRepo repository.PigTradeRepository, | ||||||
|  | 	notificationRepo repository.NotificationRepository, | ||||||
| ) MonitorService { | ) MonitorService { | ||||||
| 	return &monitorService{ | 	return &monitorService{ | ||||||
| 		sensorDataRepo:        sensorDataRepo, | 		sensorDataRepo:        sensorDataRepo, | ||||||
| 		deviceCommandLogRepo:  deviceCommandLogRepo, | 		deviceCommandLogRepo:  deviceCommandLogRepo, | ||||||
| 		executionLogRepo:      executionLogRepo, | 		executionLogRepo:      executionLogRepo, | ||||||
|  | 		planRepository:        planRepository, | ||||||
| 		pendingCollectionRepo: pendingCollectionRepo, | 		pendingCollectionRepo: pendingCollectionRepo, | ||||||
| 		userActionLogRepo:     userActionLogRepo, | 		userActionLogRepo:     userActionLogRepo, | ||||||
| 		rawMaterialRepo:       rawMaterialRepo, | 		rawMaterialRepo:       rawMaterialRepo, | ||||||
| @@ -70,90 +77,398 @@ func NewMonitorService( | |||||||
| 		pigTransferLogRepo:    pigTransferLogRepo, | 		pigTransferLogRepo:    pigTransferLogRepo, | ||||||
| 		pigSickLogRepo:        pigSickLogRepo, | 		pigSickLogRepo:        pigSickLogRepo, | ||||||
| 		pigTradeRepo:          pigTradeRepo, | 		pigTradeRepo:          pigTradeRepo, | ||||||
|  | 		notificationRepo:      notificationRepo, | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| // ListSensorData 负责处理查询传感器数据列表的业务逻辑 | // ListSensorData 负责处理查询传感器数据列表的业务逻辑 | ||||||
| func (s *monitorService) ListSensorData(opts repository.SensorDataListOptions, page, pageSize int) ([]models.SensorData, int64, error) { | func (s *monitorService) ListSensorData(req *dto.ListSensorDataRequest) (*dto.ListSensorDataResponse, error) { | ||||||
| 	return s.sensorDataRepo.List(opts, page, pageSize) | 	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 负责处理查询设备命令日志列表的业务逻辑 | // ListDeviceCommandLogs 负责处理查询设备命令日志列表的业务逻辑 | ||||||
| func (s *monitorService) ListDeviceCommandLogs(opts repository.DeviceCommandLogListOptions, page, pageSize int) ([]models.DeviceCommandLog, int64, error) { | func (s *monitorService) ListDeviceCommandLogs(req *dto.ListDeviceCommandLogRequest) (*dto.ListDeviceCommandLogResponse, error) { | ||||||
| 	return s.deviceCommandLogRepo.List(opts, page, pageSize) | 	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 负责处理查询计划执行日志列表的业务逻辑 | // ListPlanExecutionLogs 负责处理查询计划执行日志列表的业务逻辑 | ||||||
| func (s *monitorService) ListPlanExecutionLogs(opts repository.PlanExecutionLogListOptions, page, pageSize int) ([]models.PlanExecutionLog, int64, error) { | func (s *monitorService) ListPlanExecutionLogs(req *dto.ListPlanExecutionLogRequest) (*dto.ListPlanExecutionLogResponse, error) { | ||||||
| 	return s.executionLogRepo.ListPlanExecutionLogs(opts, page, pageSize) | 	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 负责处理查询任务执行日志列表的业务逻辑 | // ListTaskExecutionLogs 负责处理查询任务执行日志列表的业务逻辑 | ||||||
| func (s *monitorService) ListTaskExecutionLogs(opts repository.TaskExecutionLogListOptions, page, pageSize int) ([]models.TaskExecutionLog, int64, error) { | func (s *monitorService) ListTaskExecutionLogs(req *dto.ListTaskExecutionLogRequest) (*dto.ListTaskExecutionLogResponse, error) { | ||||||
| 	return s.executionLogRepo.ListTaskExecutionLogs(opts, page, pageSize) | 	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 负责处理查询待采集请求列表的业务逻辑 | // ListPendingCollections 负责处理查询待采集请求列表的业务逻辑 | ||||||
| func (s *monitorService) ListPendingCollections(opts repository.PendingCollectionListOptions, page, pageSize int) ([]models.PendingCollection, int64, error) { | func (s *monitorService) ListPendingCollections(req *dto.ListPendingCollectionRequest) (*dto.ListPendingCollectionResponse, error) { | ||||||
| 	return s.pendingCollectionRepo.List(opts, page, pageSize) | 	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 负责处理查询用户操作日志列表的业务逻辑 | // ListUserActionLogs 负责处理查询用户操作日志列表的业务逻辑 | ||||||
| func (s *monitorService) ListUserActionLogs(opts repository.UserActionLogListOptions, page, pageSize int) ([]models.UserActionLog, int64, error) { | func (s *monitorService) ListUserActionLogs(req *dto.ListUserActionLogRequest) (*dto.ListUserActionLogResponse, error) { | ||||||
| 	return s.userActionLogRepo.List(opts, page, pageSize) | 	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 负责处理查询原料采购记录列表的业务逻辑 | // ListRawMaterialPurchases 负责处理查询原料采购记录列表的业务逻辑 | ||||||
| func (s *monitorService) ListRawMaterialPurchases(opts repository.RawMaterialPurchaseListOptions, page, pageSize int) ([]models.RawMaterialPurchase, int64, error) { | func (s *monitorService) ListRawMaterialPurchases(req *dto.ListRawMaterialPurchaseRequest) (*dto.ListRawMaterialPurchaseResponse, error) { | ||||||
| 	return s.rawMaterialRepo.ListRawMaterialPurchases(opts, page, pageSize) | 	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 负责处理查询原料库存日志列表的业务逻辑 | // ListRawMaterialStockLogs 负责处理查询原料库存日志列表的业务逻辑 | ||||||
| func (s *monitorService) ListRawMaterialStockLogs(opts repository.RawMaterialStockLogListOptions, page, pageSize int) ([]models.RawMaterialStockLog, int64, error) { | func (s *monitorService) ListRawMaterialStockLogs(req *dto.ListRawMaterialStockLogRequest) (*dto.ListRawMaterialStockLogResponse, error) { | ||||||
| 	return s.rawMaterialRepo.ListRawMaterialStockLogs(opts, page, pageSize) | 	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 负责处理查询饲料使用记录列表的业务逻辑 | // ListFeedUsageRecords 负责处理查询饲料使用记录列表的业务逻辑 | ||||||
| func (s *monitorService) ListFeedUsageRecords(opts repository.FeedUsageRecordListOptions, page, pageSize int) ([]models.FeedUsageRecord, int64, error) { | func (s *monitorService) ListFeedUsageRecords(req *dto.ListFeedUsageRecordRequest) (*dto.ListFeedUsageRecordResponse, error) { | ||||||
| 	return s.rawMaterialRepo.ListFeedUsageRecords(opts, page, pageSize) | 	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 负责处理查询用药记录列表的业务逻辑 | // ListMedicationLogs 负责处理查询用药记录列表的业务逻辑 | ||||||
| func (s *monitorService) ListMedicationLogs(opts repository.MedicationLogListOptions, page, pageSize int) ([]models.MedicationLog, int64, error) { | func (s *monitorService) ListMedicationLogs(req *dto.ListMedicationLogRequest) (*dto.ListMedicationLogResponse, error) { | ||||||
| 	return s.medicationRepo.ListMedicationLogs(opts, page, pageSize) | 	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 负责处理查询猪批次日志列表的业务逻辑 | // ListPigBatchLogs 负责处理查询猪批次日志列表的业务逻辑 | ||||||
| func (s *monitorService) ListPigBatchLogs(opts repository.PigBatchLogListOptions, page, pageSize int) ([]models.PigBatchLog, int64, error) { | func (s *monitorService) ListPigBatchLogs(req *dto.ListPigBatchLogRequest) (*dto.ListPigBatchLogResponse, error) { | ||||||
| 	return s.pigBatchLogRepo.List(opts, page, pageSize) | 	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 负责处理查询批次称重记录列表的业务逻辑 | // ListWeighingBatches 负责处理查询批次称重记录列表的业务逻辑 | ||||||
| func (s *monitorService) ListWeighingBatches(opts repository.WeighingBatchListOptions, page, pageSize int) ([]models.WeighingBatch, int64, error) { | func (s *monitorService) ListWeighingBatches(req *dto.ListWeighingBatchRequest) (*dto.ListWeighingBatchResponse, error) { | ||||||
| 	return s.pigBatchRepo.ListWeighingBatches(opts, page, pageSize) | 	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 负责处理查询单次称重记录列表的业务逻辑 | // ListWeighingRecords 负责处理查询单次称重记录列表的业务逻辑 | ||||||
| func (s *monitorService) ListWeighingRecords(opts repository.WeighingRecordListOptions, page, pageSize int) ([]models.WeighingRecord, int64, error) { | func (s *monitorService) ListWeighingRecords(req *dto.ListWeighingRecordRequest) (*dto.ListWeighingRecordResponse, error) { | ||||||
| 	return s.pigBatchRepo.ListWeighingRecords(opts, page, pageSize) | 	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 负责处理查询猪只迁移日志列表的业务逻辑 | // ListPigTransferLogs 负责处理查询猪只迁移日志列表的业务逻辑 | ||||||
| func (s *monitorService) ListPigTransferLogs(opts repository.PigTransferLogListOptions, page, pageSize int) ([]models.PigTransferLog, int64, error) { | func (s *monitorService) ListPigTransferLogs(req *dto.ListPigTransferLogRequest) (*dto.ListPigTransferLogResponse, error) { | ||||||
| 	return s.pigTransferLogRepo.ListPigTransferLogs(opts, page, pageSize) | 	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 负责处理查询病猪日志列表的业务逻辑 | // ListPigSickLogs 负责处理查询病猪日志列表的业务逻辑 | ||||||
| func (s *monitorService) ListPigSickLogs(opts repository.PigSickLogListOptions, page, pageSize int) ([]models.PigSickLog, int64, error) { | func (s *monitorService) ListPigSickLogs(req *dto.ListPigSickLogRequest) (*dto.ListPigSickLogResponse, error) { | ||||||
| 	return s.pigSickLogRepo.ListPigSickLogs(opts, page, pageSize) | 	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 负责处理查询猪只采购记录列表的业务逻辑 | // ListPigPurchases 负责处理查询猪只采购记录列表的业务逻辑 | ||||||
| func (s *monitorService) ListPigPurchases(opts repository.PigPurchaseListOptions, page, pageSize int) ([]models.PigPurchase, int64, error) { | func (s *monitorService) ListPigPurchases(req *dto.ListPigPurchaseRequest) (*dto.ListPigPurchaseResponse, error) { | ||||||
| 	return s.pigTradeRepo.ListPigPurchases(opts, page, pageSize) | 	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 负责处理查询猪只销售记录列表的业务逻辑 | // ListPigSales 负责处理查询猪只销售记录列表的业务逻辑 | ||||||
| func (s *monitorService) ListPigSales(opts repository.PigSaleListOptions, page, pageSize int) ([]models.PigSale, int64, error) { | func (s *monitorService) ListPigSales(req *dto.ListPigSaleRequest) (*dto.ListPigSaleResponse, error) { | ||||||
| 	return s.pigTradeRepo.ListPigSales(opts, page, pageSize) | 	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 提供了猪场资产管理的业务逻辑 | // PigFarmService 提供了猪场资产管理的业务逻辑 | ||||||
| type PigFarmService interface { | type PigFarmService interface { | ||||||
| 	// PigHouse methods | 	// PigHouse methods | ||||||
| 	CreatePigHouse(name, description string) (*models.PigHouse, error) | 	CreatePigHouse(name, description string) (*dto.PigHouseResponse, error) | ||||||
| 	GetPigHouseByID(id uint) (*models.PigHouse, error) | 	GetPigHouseByID(id uint) (*dto.PigHouseResponse, error) | ||||||
| 	ListPigHouses() ([]models.PigHouse, error) | 	ListPigHouses() ([]dto.PigHouseResponse, error) | ||||||
| 	UpdatePigHouse(id uint, name, description string) (*models.PigHouse, error) | 	UpdatePigHouse(id uint, name, description string) (*dto.PigHouseResponse, error) | ||||||
| 	DeletePigHouse(id uint) error | 	DeletePigHouse(id uint) error | ||||||
|  |  | ||||||
| 	// Pen methods | 	// 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) | 	GetPenByID(id uint) (*dto.PenResponse, error) | ||||||
| 	ListPens() ([]*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 | 	DeletePen(id uint) error | ||||||
| 	// UpdatePenStatus 更新猪栏状态 | 	// UpdatePenStatus 更新猪栏状态 | ||||||
| 	UpdatePenStatus(id uint, newStatus models.PenStatus) (*models.Pen, error) | 	UpdatePenStatus(id uint, newStatus models.PenStatus) (*dto.PenResponse, error) | ||||||
| } | } | ||||||
|  |  | ||||||
| type pigFarmService struct { | type pigFarmService struct { | ||||||
| @@ -60,24 +60,51 @@ func NewPigFarmService(farmRepository repository.PigFarmRepository, | |||||||
|  |  | ||||||
| // --- PigHouse Implementation --- | // --- 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{ | 	house := &models.PigHouse{ | ||||||
| 		Name:        name, | 		Name:        name, | ||||||
| 		Description: description, | 		Description: description, | ||||||
| 	} | 	} | ||||||
| 	err := s.farmRepository.CreatePigHouse(house) | 	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) { | func (s *pigFarmService) GetPigHouseByID(id uint) (*dto.PigHouseResponse, error) { | ||||||
| 	return s.farmRepository.GetPigHouseByID(id) | 	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) { | func (s *pigFarmService) ListPigHouses() ([]dto.PigHouseResponse, error) { | ||||||
| 	return s.farmRepository.ListPigHouses() | 	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{ | 	house := &models.PigHouse{ | ||||||
| 		Model:       gorm.Model{ID: id}, | 		Model:       gorm.Model{ID: id}, | ||||||
| 		Name:        name, | 		Name:        name, | ||||||
| @@ -91,7 +118,15 @@ func (s *pigFarmService) UpdatePigHouse(id uint, name, description string) (*mod | |||||||
| 		return nil, ErrHouseNotFound | 		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 { | func (s *pigFarmService) DeletePigHouse(id uint) error { | ||||||
| @@ -117,7 +152,7 @@ func (s *pigFarmService) DeletePigHouse(id uint) error { | |||||||
|  |  | ||||||
| // --- Pen Implementation --- | // --- 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) | 	_, err := s.farmRepository.GetPigHouseByID(houseID) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| @@ -134,7 +169,16 @@ func (s *pigFarmService) CreatePen(penNumber string, houseID uint, capacity int) | |||||||
| 		Status:    models.PenStatusEmpty, | 		Status:    models.PenStatusEmpty, | ||||||
| 	} | 	} | ||||||
| 	err = s.penRepository.CreatePen(pen) | 	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) { | func (s *pigFarmService) GetPenByID(id uint) (*dto.PenResponse, error) { | ||||||
| @@ -197,7 +241,7 @@ func (s *pigFarmService) ListPens() ([]*dto.PenResponse, error) { | |||||||
| 	return response, nil | 	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) | 	_, err := s.farmRepository.GetPigHouseByID(houseID) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| @@ -222,7 +266,18 @@ func (s *pigFarmService) UpdatePen(id uint, penNumber string, houseID uint, capa | |||||||
| 		return nil, ErrPenNotFound | 		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 { | func (s *pigFarmService) DeletePen(id uint) error { | ||||||
| @@ -260,7 +315,7 @@ func (s *pigFarmService) DeletePen(id uint) error { | |||||||
| } | } | ||||||
|  |  | ||||||
| // UpdatePenStatus 更新猪栏状态 | // 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 | 	var updatedPen *models.Pen | ||||||
| 	err := s.uow.ExecuteInTransaction(func(tx *gorm.DB) error { | 	err := s.uow.ExecuteInTransaction(func(tx *gorm.DB) error { | ||||||
| 		pen, err := s.penRepository.GetPenByIDTx(tx, id) | 		pen, err := s.penRepository.GetPenByIDTx(tx, id) | ||||||
| @@ -310,5 +365,12 @@ func (s *pigFarmService) UpdatePenStatus(id uint, newStatus models.PenStatus) (* | |||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, err | 		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" | ||||||
| 	"os/signal" | 	"os/signal" | ||||||
| 	"syscall" | 	"syscall" | ||||||
| 	"time" |  | ||||||
|  |  | ||||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/app/api" | 	"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/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/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 是整个应用的核心,封装了所有组件和生命周期。 | // Application 是整个应用的核心,封装了所有组件和生命周期。 | ||||||
| type Application struct { | type Application struct { | ||||||
| 	Config *config.Config | 	Config *config.Config | ||||||
| 	Logger *logs.Logger | 	Logger *logs.Logger | ||||||
| 	Storage  database.Storage | 	API    *api.API | ||||||
| 	Executor *task.Scheduler |  | ||||||
| 	API      *api.API // 添加 API 对象 |  | ||||||
|  |  | ||||||
| 	// 新增的仓库和管理器字段,以便在 initializePendingTasks 中访问 | 	Infra  *Infrastructure | ||||||
| 	planRepo                repository.PlanRepository | 	Domain *DomainServices | ||||||
| 	pendingTaskRepo         repository.PendingTaskRepository | 	App    *AppServices | ||||||
| 	executionLogRepo        repository.ExecutionLogRepository |  | ||||||
| 	pendingCollectionRepo   repository.PendingCollectionRepository |  | ||||||
| 	analysisPlanTaskManager *task.AnalysisPlanTaskManager |  | ||||||
|  |  | ||||||
| 	// Lora Mesh 监听器 |  | ||||||
| 	loraMeshCommunicator transport.Listener |  | ||||||
|  |  | ||||||
| 	// 通知服务 |  | ||||||
| 	NotifyService domain_notify.Service |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // NewApplication 创建并初始化一个新的 Application 实例。 | // NewApplication 创建并初始化一个新的 Application 实例。 | ||||||
| // 这是应用的“组合根”,所有依赖都在这里被创建和注入。 | // 这是应用的“组合根”,所有依赖都在这里被创建和注入。 | ||||||
| func NewApplication(configPath string) (*Application, error) { | func NewApplication(configPath string) (*Application, error) { | ||||||
| 	//  加载配置 | 	// 1. 初始化基本组件: 配置和日志 | ||||||
| 	cfg := config.NewConfig() | 	cfg := config.NewConfig() | ||||||
| 	if err := cfg.Load(configPath); err != nil { | 	if err := cfg.Load(configPath); err != nil { | ||||||
| 		return nil, fmt.Errorf("无法加载配置: %w", err) | 		return nil, fmt.Errorf("无法加载配置: %w", err) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	//  初始化日志记录器 |  | ||||||
| 	logger := logs.NewLogger(cfg.Log) | 	logger := logs.NewLogger(cfg.Log) | ||||||
|  |  | ||||||
| 	//  初始化数据库存储 | 	// 2. 初始化所有分层服务 | ||||||
| 	storage, err := initStorage(cfg.Database, logger) | 	infra, err := initInfrastructure(cfg, logger) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, err // 错误已在 initStorage 中被包装 | 		return nil, fmt.Errorf("初始化基础设施失败: %w", err) | ||||||
| 	} | 	} | ||||||
|  | 	domain := initDomainServices(cfg, infra, logger) | ||||||
|  | 	appServices := initAppServices(infra, domain, logger) | ||||||
|  |  | ||||||
| 	//  初始化 Token 服务 | 	// 3. 初始化 API 入口点 | ||||||
| 	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 服务器 |  | ||||||
| 	apiServer := api.NewAPI( | 	apiServer := api.NewAPI( | ||||||
| 		cfg.Server, | 		cfg.Server, | ||||||
| 		logger, | 		logger, | ||||||
| 		userRepo, | 		infra.Repos.UserRepo, | ||||||
| 		deviceRepo, | 		appServices.PigFarmService, | ||||||
| 		areaControllerRepo, | 		appServices.PigBatchService, | ||||||
| 		deviceTemplateRepo, | 		appServices.MonitorService, | ||||||
| 		planRepo, | 		appServices.DeviceService, | ||||||
| 		pigFarmService, | 		appServices.PlanService, | ||||||
| 		pigBatchService, | 		appServices.UserService, | ||||||
| 		monitorService, | 		infra.TokenService, | ||||||
| 		tokenService, | 		appServices.AuditService, | ||||||
| 		auditService, | 		infra.Lora.ListenHandler, | ||||||
| 		notifyService, |  | ||||||
| 		generalDeviceService, |  | ||||||
| 		listenHandler, |  | ||||||
| 		analysisPlanTaskManager, |  | ||||||
| 	) | 	) | ||||||
|  |  | ||||||
| 	//  组装 Application 对象 | 	// 4. 组装 Application 对象 | ||||||
| 	app := &Application{ | 	app := &Application{ | ||||||
| 		Config: cfg, | 		Config: cfg, | ||||||
| 		Logger: logger, | 		Logger: logger, | ||||||
| 		Storage:                 storage, |  | ||||||
| 		Executor:                executor, |  | ||||||
| 		API:    apiServer, | 		API:    apiServer, | ||||||
| 		planRepo:                planRepo, | 		Infra:  infra, | ||||||
| 		pendingTaskRepo:         pendingTaskRepo, | 		Domain: domain, | ||||||
| 		executionLogRepo:        executionLogRepo, | 		App:    appServices, | ||||||
| 		pendingCollectionRepo:   pendingCollectionRepo, |  | ||||||
| 		analysisPlanTaskManager: analysisPlanTaskManager, |  | ||||||
| 		loraMeshCommunicator:    loraListener, |  | ||||||
| 		NotifyService:           notifyService, |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	return app, nil | 	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 启动应用的所有组件并阻塞,直到接收到关闭信号。 | // Start 启动应用的所有组件并阻塞,直到接收到关闭信号。 | ||||||
| func (app *Application) Start() error { | func (app *Application) Start() error { | ||||||
| 	app.Logger.Info("应用启动中...") | 	app.Logger.Info("应用启动中...") | ||||||
|  |  | ||||||
| 	// -- 启动 LoRa Mesh 监听器 | 	// 1. 启动底层监听器 | ||||||
| 	if err := app.loraMeshCommunicator.Listen(); err != nil { | 	if err := app.Infra.Lora.LoraListener.Listen(); err != nil { | ||||||
| 		return fmt.Errorf("启动 LoRa Mesh 监听器失败: %w", err) | 		return fmt.Errorf("启动 LoRa Mesh 监听器失败: %w", err) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// --- 清理待采集任务 --- | 	// 2. 初始化应用状态 (清理、刷新任务等) | ||||||
| 	if err := app.initializePendingCollections(); err != nil { | 	if err := app.initializeState(); err != nil { | ||||||
| 		// 这是一个非致命错误,记录它,但应用应继续启动 | 		return fmt.Errorf("初始化应用状态失败: %w", err) | ||||||
| 		app.Logger.Error(err) |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// --- 初始化待执行任务列表 --- | 	// 3. 启动后台工作协程 | ||||||
| 	if err := app.initializePendingTasks( | 	app.Domain.Scheduler.Start() | ||||||
| 		app.planRepo,                // 传入 planRepo |  | ||||||
| 		app.pendingTaskRepo,         // 传入 pendingTaskRepo |  | ||||||
| 		app.executionLogRepo,        // 传入 executionLogRepo |  | ||||||
| 		app.analysisPlanTaskManager, // 传入 analysisPlanTaskManager |  | ||||||
| 		app.Logger,                  // 传入 logger |  | ||||||
| 	); err != nil { |  | ||||||
| 		return fmt.Errorf("初始化待执行任务列表失败: %w", err) |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	// 启动任务执行器 | 	// 4. 启动 API 服务器 | ||||||
| 	app.Executor.Start() |  | ||||||
|  |  | ||||||
| 	// 启动 API 服务器 |  | ||||||
| 	app.API.Start() | 	app.API.Start() | ||||||
|  |  | ||||||
| 	// 等待关闭信号 | 	// 5. 等待关闭信号 | ||||||
| 	quit := make(chan os.Signal, 1) | 	quit := make(chan os.Signal, 1) | ||||||
| 	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) | 	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) | ||||||
| 	<-quit | 	<-quit | ||||||
| @@ -343,15 +106,15 @@ func (app *Application) Stop() error { | |||||||
| 	app.API.Stop() | 	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) | 		app.Logger.Errorw("数据库连接断开失败", "error", err) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	// 关闭 LoRa Mesh 监听器 | 	// 关闭 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) | 		app.Logger.Errorw("LoRa Mesh 监听器关闭失败", "error", err) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -361,135 +124,3 @@ func (app *Application) Stop() error { | |||||||
| 	app.Logger.Info("应用已成功关闭") | 	app.Logger.Info("应用已成功关闭") | ||||||
| 	return nil | 	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 | 	primaryNotifier  notify.Notifier | ||||||
| 	failureThreshold int | 	failureThreshold int | ||||||
| 	failureCounters  *sync.Map // 使用 sync.Map 来安全地并发读写失败计数, key: userID (uint), value: counter (int) | 	failureCounters  *sync.Map // 使用 sync.Map 来安全地并发读写失败计数, key: userID (uint), value: counter (int) | ||||||
|  | 	notificationRepo repository.NotificationRepository | ||||||
| } | } | ||||||
|  |  | ||||||
| // NewFailoverService 创建一个新的故障转移通知服务 | // NewFailoverService 创建一个新的故障转移通知服务 | ||||||
| @@ -42,6 +43,7 @@ func NewFailoverService( | |||||||
| 	notifiers []notify.Notifier, | 	notifiers []notify.Notifier, | ||||||
| 	primaryNotifierType notify.NotifierType, | 	primaryNotifierType notify.NotifierType, | ||||||
| 	failureThreshold int, | 	failureThreshold int, | ||||||
|  | 	notificationRepo repository.NotificationRepository, | ||||||
| ) (Service, error) { | ) (Service, error) { | ||||||
| 	notifierMap := make(map[notify.NotifierType]notify.Notifier) | 	notifierMap := make(map[notify.NotifierType]notify.Notifier) | ||||||
| 	for _, n := range notifiers { | 	for _, n := range notifiers { | ||||||
| @@ -60,6 +62,7 @@ func NewFailoverService( | |||||||
| 		primaryNotifier:  primaryNotifier, | 		primaryNotifier:  primaryNotifier, | ||||||
| 		failureThreshold: failureThreshold, | 		failureThreshold: failureThreshold, | ||||||
| 		failureCounters:  &sync.Map{}, | 		failureCounters:  &sync.Map{}, | ||||||
|  | 		notificationRepo: notificationRepo, | ||||||
| 	}, nil | 	}, nil | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -128,11 +131,15 @@ func (s *failoverService) sendAlarmToUser(userID uint, content notify.AlarmConte | |||||||
| 		primaryType := s.primaryNotifier.Type() | 		primaryType := s.primaryNotifier.Type() | ||||||
| 		addr := getAddressForNotifier(primaryType, user.Contact) | 		addr := getAddressForNotifier(primaryType, user.Contact) | ||||||
| 		if addr == "" { | 		if addr == "" { | ||||||
|  | 			// 记录跳过通知 | ||||||
|  | 			s.recordNotificationAttempt(userID, primaryType, content, "", models.NotificationStatusSkipped, fmt.Errorf("用户未配置首选通知方式 '%s' 的地址", primaryType)) | ||||||
| 			return fmt.Errorf("用户未配置首选通知方式 '%s' 的地址", primaryType) | 			return fmt.Errorf("用户未配置首选通知方式 '%s' 的地址", primaryType) | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		err = s.primaryNotifier.Send(content, addr) | 		err = s.primaryNotifier.Send(content, addr) | ||||||
| 		if err == nil { | 		if err == nil { | ||||||
|  | 			// 记录成功通知 | ||||||
|  | 			s.recordNotificationAttempt(userID, primaryType, content, addr, models.NotificationStatusSuccess, nil) | ||||||
| 			if failureCount > 0 { | 			if failureCount > 0 { | ||||||
| 				s.log.Infow("首选渠道发送恢复正常", "userID", userID, "notifierType", primaryType) | 				s.log.Infow("首选渠道发送恢复正常", "userID", userID, "notifierType", primaryType) | ||||||
| 				s.failureCounters.Store(userID, 0) | 				s.failureCounters.Store(userID, 0) | ||||||
| @@ -140,6 +147,8 @@ func (s *failoverService) sendAlarmToUser(userID uint, content notify.AlarmConte | |||||||
| 			return nil | 			return nil | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
|  | 		// 记录失败通知 | ||||||
|  | 		s.recordNotificationAttempt(userID, primaryType, content, addr, models.NotificationStatusFailed, err) | ||||||
| 		newFailureCount := failureCount + 1 | 		newFailureCount := failureCount + 1 | ||||||
| 		s.failureCounters.Store(userID, newFailureCount) | 		s.failureCounters.Store(userID, newFailureCount) | ||||||
| 		s.log.Warnw("首选渠道发送失败", "userID", userID, "notifierType", primaryType, "error", err, "failureCount", 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 { | 		for _, notifier := range s.notifiers { | ||||||
| 			addr := getAddressForNotifier(notifier.Type(), user.Contact) | 			addr := getAddressForNotifier(notifier.Type(), user.Contact) | ||||||
| 			if addr == "" { | 			if addr == "" { | ||||||
|  | 				// 记录跳过通知 | ||||||
|  | 				s.recordNotificationAttempt(userID, notifier.Type(), content, "", models.NotificationStatusSkipped, fmt.Errorf("用户未配置通知方式 '%s' 的地址", notifier.Type())) | ||||||
| 				continue | 				continue | ||||||
| 			} | 			} | ||||||
| 			if err := notifier.Send(content, addr); err == nil { | 			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.log.Infow("广播通知成功", "userID", userID, "notifierType", notifier.Type()) | ||||||
| 				s.failureCounters.Store(userID, 0) | 				s.failureCounters.Store(userID, 0) | ||||||
| 				return nil | 				return nil | ||||||
| 			} | 			} | ||||||
|  | 			// 记录失败通知 | ||||||
|  | 			s.recordNotificationAttempt(userID, notifier.Type(), content, addr, models.NotificationStatusFailed, err) | ||||||
| 			lastErr = err | 			lastErr = err | ||||||
| 			s.log.Warnw("广播通知:渠道发送失败", "userID", userID, "notifierType", notifier.Type(), "error", 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) | 	addr := getAddressForNotifier(notifierType, user.Contact) | ||||||
| 	if addr == "" { | 	if addr == "" { | ||||||
| 		s.log.Warnw("发送测试消息失败:缺少地址", "userID", userID, "notifierType", notifierType) | 		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) | 		return fmt.Errorf("用户未配置通知方式 '%s' 的地址", notifierType) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -199,10 +221,14 @@ func (s *failoverService) SendTestMessage(userID uint, notifierType notify.Notif | |||||||
| 	err = notifier.Send(testContent, addr) | 	err = notifier.Send(testContent, addr) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		s.log.Errorw("发送测试消息失败", "userID", userID, "notifierType", notifierType, "error", err) | 		s.log.Errorw("发送测试消息失败", "userID", userID, "notifierType", notifierType, "error", err) | ||||||
|  | 		// 记录失败通知 | ||||||
|  | 		s.recordNotificationAttempt(userID, notifierType, testContent, addr, models.NotificationStatusFailed, err) | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	s.log.Infow("发送测试消息成功", "userID", userID, "notifierType", notifierType) | 	s.log.Infow("发送测试消息成功", "userID", userID, "notifierType", notifierType) | ||||||
|  | 	// 记录成功通知 | ||||||
|  | 	s.recordNotificationAttempt(userID, notifierType, testContent, addr, models.NotificationStatusSuccess, nil) | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -221,3 +247,46 @@ func getAddressForNotifier(notifierType notify.NotifierType, contact models.Cont | |||||||
| 		return "" | 		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 ( | import ( | ||||||
| 	"fmt" | 	"fmt" | ||||||
| @@ -1,4 +1,4 @@ | |||||||
| package task | package scheduler | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"errors" | 	"errors" | ||||||
| @@ -83,6 +83,7 @@ type Scheduler struct { | |||||||
| 	deviceRepo              repository.DeviceRepository | 	deviceRepo              repository.DeviceRepository | ||||||
| 	sensorDataRepo          repository.SensorDataRepository | 	sensorDataRepo          repository.SensorDataRepository | ||||||
| 	planRepo                repository.PlanRepository | 	planRepo                repository.PlanRepository | ||||||
|  | 	taskFactory             TaskFactory | ||||||
| 	analysisPlanTaskManager *AnalysisPlanTaskManager | 	analysisPlanTaskManager *AnalysisPlanTaskManager | ||||||
| 	progressTracker         *ProgressTracker | 	progressTracker         *ProgressTracker | ||||||
| 	deviceService           device.Service | 	deviceService           device.Service | ||||||
| @@ -100,6 +101,7 @@ func NewScheduler( | |||||||
| 	sensorDataRepo repository.SensorDataRepository, | 	sensorDataRepo repository.SensorDataRepository, | ||||||
| 	planRepo repository.PlanRepository, | 	planRepo repository.PlanRepository, | ||||||
| 	analysisPlanTaskManager *AnalysisPlanTaskManager, | 	analysisPlanTaskManager *AnalysisPlanTaskManager, | ||||||
|  | 	taskFactory TaskFactory, | ||||||
| 	logger *logs.Logger, | 	logger *logs.Logger, | ||||||
| 	deviceService device.Service, | 	deviceService device.Service, | ||||||
| 	interval time.Duration, | 	interval time.Duration, | ||||||
| @@ -112,6 +114,7 @@ func NewScheduler( | |||||||
| 		sensorDataRepo:          sensorDataRepo, | 		sensorDataRepo:          sensorDataRepo, | ||||||
| 		planRepo:                planRepo, | 		planRepo:                planRepo, | ||||||
| 		analysisPlanTaskManager: analysisPlanTaskManager, | 		analysisPlanTaskManager: analysisPlanTaskManager, | ||||||
|  | 		taskFactory:             taskFactory, | ||||||
| 		logger:                  logger, | 		logger:                  logger, | ||||||
| 		deviceService:           deviceService, | 		deviceService:           deviceService, | ||||||
| 		pollingInterval:         interval, | 		pollingInterval:         interval, | ||||||
| @@ -271,7 +274,7 @@ func (s *Scheduler) runTask(claimedLog *models.TaskExecutionLog) error { | |||||||
| 
 | 
 | ||||||
| 	} else { | 	} else { | ||||||
| 		// 执行普通任务 | 		// 执行普通任务 | ||||||
| 		task := s.taskFactory(claimedLog) | 		task := s.taskFactory.Production(claimedLog) | ||||||
| 
 | 
 | ||||||
| 		if err := task.Execute(); err != nil { | 		if err := task.Execute(); err != nil { | ||||||
| 			s.logger.Errorf("[严重] 任务执行失败, 日志ID: %d, 错误: %v", claimedLog.ID, err) | 			s.logger.Errorf("[严重] 任务执行失败, 日志ID: %d, 错误: %v", claimedLog.ID, err) | ||||||
| @@ -283,20 +286,6 @@ func (s *Scheduler) runTask(claimedLog *models.TaskExecutionLog) error { | |||||||
| 	return nil | 	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列表插入待执行队列中 | // analysisPlan 解析Plan并将解析出的Task列表插入待执行队列中 | ||||||
| func (s *Scheduler) analysisPlan(claimedLog *models.TaskExecutionLog) error { | func (s *Scheduler) analysisPlan(claimedLog *models.TaskExecutionLog) error { | ||||||
| 	// 创建Plan执行记录 | 	// 创建Plan执行记录 | ||||||
| @@ -399,12 +388,27 @@ func (s *Scheduler) handlePlanTermination(planLogID uint, reason string) { | |||||||
| 		s.logger.Errorf("取消计划 %d 的后续任务日志时出错: %v", planLogID, err) | 		s.logger.Errorf("取消计划 %d 的后续任务日志时出错: %v", planLogID, err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// 4. 将计划本身的状态更新为失败 | 	// 4. 获取计划执行日志以获取顶层 PlanID | ||||||
| 	planLog, err := s.executionLogRepo.FindPlanExecutionLogByID(planLogID) | 	planLog, err := s.executionLogRepo.FindPlanExecutionLogByID(planLogID) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		s.logger.Errorf("无法找到计划执行日志 %d 以更新父计划状态: %v", planLogID, err) | 		s.logger.Errorf("无法找到计划执行日志 %d 以更新父计划状态: %v", planLogID, err) | ||||||
| 		return | 		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 { | 	if err := s.planRepo.UpdatePlanStatus(planLog.PlanID, models.PlanStatusFailed); err != nil { | ||||||
| 		s.logger.Errorf("更新计划 %d 状态为 '失败' 时出错: %v", planLog.PlanID, err) | 		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" | 	"fmt" | ||||||
| 	"time" | 	"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/logs" | ||||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||||
| ) | ) | ||||||
| @@ -19,7 +20,7 @@ type DelayTask struct { | |||||||
| 	logger        *logs.Logger | 	logger        *logs.Logger | ||||||
| } | } | ||||||
|  |  | ||||||
| func NewDelayTask(logger *logs.Logger, executionTask *models.TaskExecutionLog) Task { | func NewDelayTask(logger *logs.Logger, executionTask *models.TaskExecutionLog) scheduler.Task { | ||||||
| 	return &DelayTask{ | 	return &DelayTask{ | ||||||
| 		executionTask: executionTask, | 		executionTask: executionTask, | ||||||
| 		logger:        logger, | 		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" | 	"time" | ||||||
|  |  | ||||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/domain/device" | 	"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/logs" | ||||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" | 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" | ||||||
| @@ -40,12 +41,12 @@ func NewReleaseFeedWeightTask( | |||||||
| 	deviceRepo repository.DeviceRepository, | 	deviceRepo repository.DeviceRepository, | ||||||
| 	deviceService device.Service, | 	deviceService device.Service, | ||||||
| 	logger *logs.Logger, | 	logger *logs.Logger, | ||||||
| ) Task { | ) scheduler.Task { | ||||||
| 	return &ReleaseFeedWeightTask{ | 	return &ReleaseFeedWeightTask{ | ||||||
| 		claimedLog:     claimedLog, | 		claimedLog:     claimedLog, | ||||||
| 		deviceRepo:     deviceRepo, | 		deviceRepo:     deviceRepo, | ||||||
| 		sensorDataRepo: sensorDataRepo, | 		sensorDataRepo: sensorDataRepo, | ||||||
| 		feedPort:       deviceService, // 直接注入 | 		feedPort:       deviceService, | ||||||
| 		logger:         logger, | 		logger:         logger, | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,30 +1,45 @@ | |||||||
| package task | package task | ||||||
|  |  | ||||||
| import ( | 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/models" | ||||||
|  | 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/repository" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| // Task 定义了所有可被调度器执行的任务必须实现的接口。 | type taskFactory struct { | ||||||
| type Task interface { | 	logger         *logs.Logger | ||||||
| 	// Execute 是任务的核心执行逻辑。 | 	sensorDataRepo repository.SensorDataRepository | ||||||
| 	// ctx: 用于控制任务的超时或取消。 | 	deviceRepo     repository.DeviceRepository | ||||||
| 	// log: 包含了当前任务执行的完整上下文信息,包括从数据库中加载的任务参数等。 | 	deviceService  device.Service | ||||||
| 	// 返回的 error 表示任务是否执行成功。调度器会根据返回的 error 是否为 nil 来决定任务状态。 |  | ||||||
| 	Execute() error |  | ||||||
|  |  | ||||||
| 	// OnFailure 定义了当 Execute 方法返回错误时,需要执行的回滚或清理逻辑。 |  | ||||||
| 	// log: 任务执行的上下文。 |  | ||||||
| 	// executeErr: 从 Execute 方法返回的原始错误。 |  | ||||||
| 	OnFailure(executeErr error) |  | ||||||
| } | } | ||||||
|  |  | ||||||
| // TaskFactory 是一个任务组装工厂, 可以根据Task类型获取到对应的初始化函数 | func NewTaskFactory( | ||||||
| var TaskFactory = func(tt models.TaskType) Task { | 	logger *logs.Logger, | ||||||
| 	switch tt { | 	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: | 	case models.TaskTypeWaiting: | ||||||
| 		return &DelayTask{} | 		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: | 	default: | ||||||
| 		// 出现位置任务类型说明业务逻辑出现重大问题, 一个异常任务被创建了出来 | 		// TODO 这里直接panic合适吗? 不过这个场景确实不该出现任何异常的任务类型 | ||||||
| 		panic("发现未知任务类型") | 		t.logger.Panicf("不支持的任务类型: %s", claimedLog.Task.Type) | ||||||
|  | 		panic("不支持的任务类型") // 显式panic防编译器报错 | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|   | |||||||
| @@ -13,19 +13,19 @@ type Claims struct { | |||||||
| 	jwt.RegisteredClaims | 	jwt.RegisteredClaims | ||||||
| } | } | ||||||
|  |  | ||||||
| // TokenService 定义了 token 操作的接口 | // Service 定义了 token 操作的接口 | ||||||
| type TokenService interface { | type Service interface { | ||||||
| 	GenerateToken(userID uint) (string, error) | 	GenerateToken(userID uint) (string, error) | ||||||
| 	ParseToken(tokenString string) (*Claims, error) | 	ParseToken(tokenString string) (*Claims, error) | ||||||
| } | } | ||||||
|  |  | ||||||
| // tokenService 是 TokenService 接口的实现 | // tokenService 是 Service 接口的实现 | ||||||
| type tokenService struct { | type tokenService struct { | ||||||
| 	secret []byte | 	secret []byte | ||||||
| } | } | ||||||
|  |  | ||||||
| // NewTokenService 创建并返回一个新的 TokenService 实例 | // NewTokenService 创建并返回一个新的 Service 实例 | ||||||
| func NewTokenService(secret []byte) TokenService { | func NewTokenService(secret []byte) Service { | ||||||
| 	return &tokenService{secret: secret} | 	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 通知服务配置 | ||||||
| 	Notify NotifyConfig `yaml:"notify"` | 	Notify NotifyConfig `yaml:"notify"` | ||||||
|  |  | ||||||
|  | 	// Collection 定时采集配置 | ||||||
|  | 	Collection CollectionConfig `yaml:"collection"` | ||||||
| } | } | ||||||
|  |  | ||||||
| // AppConfig 代表应用基础配置 | // AppConfig 代表应用基础配置 | ||||||
| @@ -195,10 +198,20 @@ type LarkConfig struct { | |||||||
| 	AppSecret string `yaml:"appSecret"` | 	AppSecret string `yaml:"appSecret"` | ||||||
| } | } | ||||||
|  |  | ||||||
|  | // CollectionConfig 代表定时采集配置 | ||||||
|  | type CollectionConfig struct { | ||||||
|  | 	// Interval 采集间隔(分钟), 默认 1 | ||||||
|  | 	Interval int `yaml:"interval"` | ||||||
|  | } | ||||||
|  |  | ||||||
| // NewConfig 创建并返回一个新的配置实例 | // NewConfig 创建并返回一个新的配置实例 | ||||||
| func NewConfig() *Config { | func NewConfig() *Config { | ||||||
| 	// 默认值可以在这里设置,但我们优先使用配置文件中的值 | 	// 默认值可以在这里设置,但我们优先使用配置文件中的值 | ||||||
| 	return &Config{} | 	return &Config{ | ||||||
|  | 		Collection: CollectionConfig{ | ||||||
|  | 			Interval: 1, // 默认为1分钟 | ||||||
|  | 		}, | ||||||
|  | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| // Load 从指定路径加载配置文件 | // Load 从指定路径加载配置文件 | ||||||
|   | |||||||
| @@ -171,18 +171,19 @@ func (ps *PostgresStorage) creatingHyperTable() error { | |||||||
| 		{models.PigSickLog{}, "happened_at"}, | 		{models.PigSickLog{}, "happened_at"}, | ||||||
| 		{models.PigPurchase{}, "purchase_date"}, | 		{models.PigPurchase{}, "purchase_date"}, | ||||||
| 		{models.PigSale{}, "sale_date"}, | 		{models.PigSale{}, "sale_date"}, | ||||||
|  | 		{models.Notification{}, "alarm_timestamp"}, | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	for _, table := range tablesToConvert { | 	for _, table := range tablesToConvert { | ||||||
| 		tableName := table.model.TableName() | 		tableName := table.model.TableName() | ||||||
| 		chunkInterval := "1 days" // 统一设置为1天 | 		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) | 		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 { | 		if err := ps.db.Exec(sql).Error; err != nil { | ||||||
| 			ps.logger.Errorw("转换为超表失败", "table", tableName, "error", err) | 			ps.logger.Errorw("转换为超表失败", "table", tableName, "error", err) | ||||||
| 			return fmt.Errorf("将 %s 转换为超表失败: %w", tableName, err) | 			return fmt.Errorf("将 %s 转换为超表失败: %w", tableName, err) | ||||||
| 		} | 		} | ||||||
| 		ps.logger.Infow("成功将表转换为超表 (或已转换)", "table", tableName) | 		ps.logger.Debugw("成功将表转换为超表 (或已转换)", "table", tableName) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	return nil | 	return nil | ||||||
| @@ -211,6 +212,7 @@ func (ps *PostgresStorage) applyCompressionPolicies() error { | |||||||
| 		{models.PigSickLog{}, "pig_batch_id"}, | 		{models.PigSickLog{}, "pig_batch_id"}, | ||||||
| 		{models.PigPurchase{}, "pig_batch_id"}, | 		{models.PigPurchase{}, "pig_batch_id"}, | ||||||
| 		{models.PigSale{}, "pig_batch_id"}, | 		{models.PigSale{}, "pig_batch_id"}, | ||||||
|  | 		{models.Notification{}, "user_id"}, | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	for _, policy := range policies { | 	for _, policy := range policies { | ||||||
| @@ -218,22 +220,23 @@ func (ps *PostgresStorage) applyCompressionPolicies() error { | |||||||
| 		compressAfter := "3 days" // 统一设置为2天后(即进入第3天)开始压缩 | 		compressAfter := "3 days" // 统一设置为2天后(即进入第3天)开始压缩 | ||||||
|  |  | ||||||
| 		// 1. 开启表的压缩设置,并指定分段列 | 		// 1. 开启表的压缩设置,并指定分段列 | ||||||
| 		ps.logger.Infow("为表启用压缩设置", "table", tableName, "segment_by", policy.segmentColumn) | 		ps.logger.Debugw("为表启用压缩设置", "table", tableName, "segment_by", policy.segmentColumn) | ||||||
| 		// 使用 + 而非Sprintf以规避goland静态检查报错 | 		// 使用 + 而非Sprintf以规避goland静态检查报错 | ||||||
| 		alterSQL := "ALTER TABLE" + " " + tableName + " SET (timescaledb.compress, timescaledb.compress_segmentby = '" + policy.segmentColumn + "');" | 		alterSQL := "ALTER TABLE" + " " + tableName + " SET (timescaledb.compress, timescaledb.compress_segmentby = '" + policy.segmentColumn + "');" | ||||||
| 		if err := ps.db.Exec(alterSQL).Error; err != nil { | 		if err := ps.db.Exec(alterSQL).Error; err != nil { | ||||||
| 			// 忽略错误,因为这个设置可能是不可变的,重复执行会报错 | 			// 忽略错误,因为这个设置可能是不可变的,重复执行会报错 | ||||||
| 			ps.logger.Warnw("启用压缩设置时遇到问题 (可能已设置,可忽略)", "table", tableName, "error", err) | 			ps.logger.Warnw("启用压缩设置时遇到问题 (可能已设置,可忽略)", "table", tableName, "error", err) | ||||||
| 		} | 		} | ||||||
|  | 		ps.logger.Debugw("成功为表启用压缩设置 (或已启用)", "table", tableName) | ||||||
|  |  | ||||||
| 		// 2. 添加压缩策略 | 		// 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) | 		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 { | 		if err := ps.db.Exec(policySQL).Error; err != nil { | ||||||
| 			ps.logger.Errorw("添加压缩策略失败", "table", tableName, "error", err) | 			ps.logger.Errorw("添加压缩策略失败", "table", tableName, "error", err) | ||||||
| 			return fmt.Errorf("为 %s 添加压缩策略失败: %w", tableName, err) | 			return fmt.Errorf("为 %s 添加压缩策略失败: %w", tableName, err) | ||||||
| 		} | 		} | ||||||
| 		ps.logger.Infow("成功为表添加压缩策略 (或已存在)", "table", tableName) | 		ps.logger.Debugw("成功为表添加压缩策略 (或已存在)", "table", tableName) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	return nil | 	return nil | ||||||
| @@ -245,22 +248,22 @@ func (ps *PostgresStorage) creatingIndex() error { | |||||||
| 	// 如果索引已存在,此命令不会报错 | 	// 如果索引已存在,此命令不会报错 | ||||||
|  |  | ||||||
| 	// 为 sensor_data 表的 data 字段创建 GIN 索引 | 	// 为 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);" | 	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 { | 	if err := ps.db.Exec(ginSensorDataIndexSQL).Error; err != nil { | ||||||
| 		ps.logger.Errorw("为 sensor_data 的 data 字段创建 GIN 索引失败", "error", err) | 		ps.logger.Errorw("为 sensor_data 的 data 字段创建 GIN 索引失败", "error", err) | ||||||
| 		return fmt.Errorf("为 sensor_data 的 data 字段创建 GIN 索引失败: %w", 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 索引 | 	// 为 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);" | 	taskGinIndexSQL := "CREATE INDEX IF NOT EXISTS idx_tasks_parameters_gin ON tasks USING GIN (parameters);" | ||||||
| 	if err := ps.db.Exec(taskGinIndexSQL).Error; err != nil { | 	if err := ps.db.Exec(taskGinIndexSQL).Error; err != nil { | ||||||
| 		ps.logger.Errorw("为 tasks 的 parameters 字段创建 GIN 索引失败", "error", err) | 		ps.logger.Errorw("为 tasks 的 parameters 字段创建 GIN 索引失败", "error", err) | ||||||
| 		return fmt.Errorf("为 tasks 的 parameters 字段创建 GIN 索引失败: %w", err) | 		return fmt.Errorf("为 tasks 的 parameters 字段创建 GIN 索引失败: %w", err) | ||||||
| 	} | 	} | ||||||
| 	ps.logger.Info("成功为 tasks 的 parameters 字段创建 GIN 索引 (或已存在)") | 	ps.logger.Debug("成功为 tasks 的 parameters 字段创建 GIN 索引 (或已存在)") | ||||||
|  |  | ||||||
| 	return nil | 	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,6 +171,9 @@ const ( | |||||||
| 	ContextAuditActionType     AuditContextKey = "auditActionType" | 	ContextAuditActionType     AuditContextKey = "auditActionType" | ||||||
| 	ContextAuditTargetResource AuditContextKey = "auditTargetResource" | 	ContextAuditTargetResource AuditContextKey = "auditTargetResource" | ||||||
| 	ContextAuditDescription    AuditContextKey = "auditDescription" | 	ContextAuditDescription    AuditContextKey = "auditDescription" | ||||||
|  | 	ContextAuditStatus         AuditContextKey = "auditStatus" | ||||||
|  | 	ContextAuditResultDetails  AuditContextKey = "auditResultDetails" | ||||||
|  |  | ||||||
| 	ContextUserKey AuditContextKey = "user" | 	ContextUserKey AuditContextKey = "user" | ||||||
| ) | ) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -59,6 +59,9 @@ func GetAllModels() []interface{} { | |||||||
| 		// Medication Models | 		// Medication Models | ||||||
| 		&Medication{}, | 		&Medication{}, | ||||||
| 		&MedicationLog{}, | 		&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列表并添加到待执行队列的特殊任务 | 	TaskPlanAnalysis          TaskType = "计划分析" // 解析Plan的Task列表并添加到待执行队列的特殊任务 | ||||||
| 	TaskTypeWaiting           TaskType = "等待"   // 等待任务 | 	TaskTypeWaiting           TaskType = "等待"   // 等待任务 | ||||||
| 	TaskTypeReleaseFeedWeight TaskType = "下料"   // 下料口释放指定重量任务 | 	TaskTypeReleaseFeedWeight TaskType = "下料"   // 下料口释放指定重量任务 | ||||||
|  | 	TaskTypeFullCollection    TaskType = "全量采集" // 新增的全量采集任务 | ||||||
| ) | ) | ||||||
|  |  | ||||||
| // -- Task Parameters -- | // -- Task Parameters -- | ||||||
| @@ -52,12 +53,20 @@ const ( | |||||||
| 	PlanStatusFailed   PlanStatus = "执行失败" // 执行失败 | 	PlanStatusFailed   PlanStatus = "执行失败" // 执行失败 | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  | type PlanType string | ||||||
|  |  | ||||||
|  | const ( | ||||||
|  | 	PlanTypeCustom PlanType = "自定义任务" | ||||||
|  | 	PlanTypeSystem PlanType = "系统任务" | ||||||
|  | ) | ||||||
|  |  | ||||||
| // Plan 代表系统中的一个计划,可以包含子计划或任务 | // Plan 代表系统中的一个计划,可以包含子计划或任务 | ||||||
| type Plan struct { | type Plan struct { | ||||||
| 	gorm.Model | 	gorm.Model | ||||||
|  |  | ||||||
| 	Name          string            `gorm:"not null" json:"name"` | 	Name          string            `gorm:"not null" json:"name"` | ||||||
| 	Description   string            `json:"description"` | 	Description   string            `json:"description"` | ||||||
|  | 	PlanType      PlanType          `gorm:"not null;index" json:"plan_type"` // 任务类型, 包括系统任务和用户自定义任务 | ||||||
| 	ExecutionType PlanExecutionType `gorm:"not null;index" json:"execution_type"` | 	ExecutionType PlanExecutionType `gorm:"not null;index" json:"execution_type"` | ||||||
| 	Status        PlanStatus        `gorm:"default:'已禁用';index" json:"status"` // 计划是否被启动 | 	Status        PlanStatus        `gorm:"default:'已禁用';index" json:"status"` // 计划是否被启动 | ||||||
| 	ExecuteNum    uint              `gorm:"default:0" json:"execute_num"`      // 计划预期执行次数 | 	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 获取所有设备的列表 | ||||||
| 	ListAll() ([]*models.Device, error) | 	ListAll() ([]*models.Device, error) | ||||||
|  |  | ||||||
|  | 	// ListAllSensors 获取所有传感器类型的设备列表 | ||||||
|  | 	ListAllSensors() ([]*models.Device, error) | ||||||
|  |  | ||||||
| 	// ListByAreaControllerID 根据区域主控 ID 列出所有子设备。 | 	// ListByAreaControllerID 根据区域主控 ID 列出所有子设备。 | ||||||
| 	ListByAreaControllerID(areaControllerID uint) ([]*models.Device, error) | 	ListByAreaControllerID(areaControllerID uint) ([]*models.Device, error) | ||||||
|  |  | ||||||
| @@ -84,6 +87,19 @@ func (r *gormDeviceRepository) ListAll() ([]*models.Device, error) { | |||||||
| 	return devices, nil | 	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 列出所有子设备 | // ListByAreaControllerID 根据区域主控 ID 列出所有子设备 | ||||||
| func (r *gormDeviceRepository) ListByAreaControllerID(areaControllerID uint) ([]*models.Device, error) { | func (r *gormDeviceRepository) ListByAreaControllerID(areaControllerID uint) ([]*models.Device, error) { | ||||||
| 	var devices []*models.Device | 	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("禁止删除正在被引用的计划") | 	ErrDeleteWithReferencedPlan = errors.New("禁止删除正在被引用的计划") | ||||||
| ) | ) | ||||||
|  |  | ||||||
|  | // PlanTypeFilter 定义计划类型的过滤器 | ||||||
|  | type PlanTypeFilter string | ||||||
|  |  | ||||||
|  | const ( | ||||||
|  | 	PlanTypeFilterAll    PlanTypeFilter = "所有任务" | ||||||
|  | 	PlanTypeFilterCustom PlanTypeFilter = "自定义任务" | ||||||
|  | 	PlanTypeFilterSystem PlanTypeFilter = "系统任务" | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | // ListPlansOptions 定义了查询计划时的可选参数 | ||||||
|  | type ListPlansOptions struct { | ||||||
|  | 	PlanType PlanTypeFilter | ||||||
|  | } | ||||||
|  |  | ||||||
| // PlanRepository 定义了与计划模型相关的数据库操作接口 | // PlanRepository 定义了与计划模型相关的数据库操作接口 | ||||||
| // 这是为了让业务逻辑层依赖于抽象,而不是具体的数据库实现 | // 这是为了让业务逻辑层依赖于抽象,而不是具体的数据库实现 | ||||||
| type PlanRepository interface { | type PlanRepository interface { | ||||||
| 	// ListBasicPlans 获取所有计划的基本信息,不包含子计划和任务详情 | 	// ListPlans 获取计划列表,支持过滤和分页 | ||||||
| 	ListBasicPlans() ([]models.Plan, error) | 	ListPlans(opts ListPlansOptions, page, pageSize int) ([]models.Plan, int64, error) | ||||||
| 	// GetBasicPlanByID 根据ID获取计划的基本信息,不包含子计划和任务详情 | 	// GetBasicPlanByID 根据ID获取计划的基本信息,不包含子计划和任务详情 | ||||||
| 	GetBasicPlanByID(id uint) (*models.Plan, error) | 	GetBasicPlanByID(id uint) (*models.Plan, error) | ||||||
| 	// GetPlanByID 根据ID获取计划,包含子计划和任务详情 | 	// GetPlanByID 根据ID获取计划,包含子计划和任务详情 | ||||||
| 	GetPlanByID(id uint) (*models.Plan, error) | 	GetPlanByID(id uint) (*models.Plan, error) | ||||||
|  | 	// GetPlansByIDs 根据ID列表获取计划,不包含子计划和任务详情 | ||||||
|  | 	GetPlansByIDs(ids []uint) ([]models.Plan, error) | ||||||
| 	// CreatePlan 创建一个新的计划 | 	// CreatePlan 创建一个新的计划 | ||||||
| 	CreatePlan(plan *models.Plan) error | 	CreatePlan(plan *models.Plan) error | ||||||
| 	// UpdatePlan 更新计划,包括子计划和任务 | 	// UpdatePlan 更新计划,包括子计划和任务 | ||||||
| @@ -81,15 +97,37 @@ func NewGormPlanRepository(db *gorm.DB) PlanRepository { | |||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| // ListBasicPlans 获取所有计划的基本信息,不包含子计划和任务详情 | // ListPlans 获取计划列表,支持过滤和分页 | ||||||
| func (r *gormPlanRepository) ListBasicPlans() ([]models.Plan, error) { | func (r *gormPlanRepository) ListPlans(opts ListPlansOptions, page, pageSize int) ([]models.Plan, int64, error) { | ||||||
| 	var plans []models.Plan | 	if page <= 0 || pageSize <= 0 { | ||||||
| 	// GORM 默认不会加载关联,除非使用 Preload,所以直接 Find 即可满足要求 | 		return nil, 0, ErrInvalidPagination | ||||||
| 	result := r.db.Find(&plans) |  | ||||||
| 	if result.Error != nil { |  | ||||||
| 		return nil, result.Error |  | ||||||
| 	} | 	} | ||||||
| 	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获取计划的基本信息,不包含子计划和任务详情 | // GetBasicPlanByID 根据ID获取计划的基本信息,不包含子计划和任务详情 | ||||||
| @@ -103,6 +141,19 @@ func (r *gormPlanRepository) GetBasicPlanByID(id uint) (*models.Plan, error) { | |||||||
| 	return &plan, nil | 	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获取计划,包含子计划和任务详情 | // GetPlanByID 根据ID获取计划,包含子计划和任务详情 | ||||||
| func (r *gormPlanRepository) GetPlanByID(id uint) (*models.Plan, error) { | func (r *gormPlanRepository) GetPlanByID(id uint) (*models.Plan, error) { | ||||||
| 	var plan models.Plan | 	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和pageSize必须为大于0") | ||||||
| @@ -1,16 +1,12 @@ | |||||||
| package repository | package repository | ||||||
|  |  | ||||||
| import ( | import ( | ||||||
| 	"errors" |  | ||||||
| 	"time" | 	"time" | ||||||
|  |  | ||||||
| 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | 	"git.huangwc.com/pig/pig-farm-controller/internal/infra/models" | ||||||
| 	"gorm.io/gorm" | 	"gorm.io/gorm" | ||||||
| ) | ) | ||||||
|  |  | ||||||
| // ErrInvalidPagination 表示分页参数无效 |  | ||||||
| var ErrInvalidPagination = errors.New("无效的分页参数:page和pageSize必须为大于0") |  | ||||||
|  |  | ||||||
| // SensorDataListOptions 定义了查询传感器数据列表时的可选参数 | // SensorDataListOptions 定义了查询传感器数据列表时的可选参数 | ||||||
| type SensorDataListOptions struct { | type SensorDataListOptions struct { | ||||||
| 	DeviceID   *uint | 	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