diff --git a/AGENTS.md b/AGENTS.md index 0ae3ba1..82e7a1d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ - 验收范围:`docs/12-验收与迭代规划.md` 2. 检查 `git status`,保留用户已有改动;不要顺手格式化、重写或删除无关文件。 3. 区分三类内容: - - `frontend/platform_admin`、`backend/{api,worker,iot-client,iot-server}` 是当前业务实现。 + - `frontend/platform_admin`、`backend/{api,worker,iot-gateway,iot-server}` 是当前业务实现。 - `docs/10-技术实现规划.md` 中尚不存在的目录和系统属于规划,不得为完成局部任务擅自创建空壳工程。 4. 需求不明确时以文档中的系统主责、数据主责和安全规则为边界;不得把标为“待确认”的事项自行固化为政策。 5. 文档之间出现冲突时按以下顺序处理: @@ -29,7 +29,7 @@ - `backend/api`:Gin HTTP API/BFF、鉴权、同步业务事务、数据库事实和审计。 - `backend/worker`:Outbox 投递、Redis Streams 消费、告警、派单、通知、对账和超时任务。当前主要是 Mock 边界,未确定持久化消息契约前不要伪造完整实现。 -- `backend/iot-client`:管理系统/App 的设备上下行 HTTP 边界;不得绕过 API 的业务鉴权和安全状态校验。 +- `backend/iot-gateway`:API/Worker 与 IoT Server 的无状态上下行 HTTP 轻网关;不得保存业务事实或绕过 API 的业务鉴权和安全状态校验。 - `backend/iot-server`:内嵌 Mochi MQTT Broker、厂商协议、遥测、设备命令和回执;本地配置不代表已完成真实设备联调。 - `frontend/platform_admin`:平台级治理、运营、财务、安全、主数据和审计界面;后端资源契约是其资源和操作能力的依据。 - `docs`:产品和技术基线;涉及业务口径、实体、状态、权限或跨端流程的变更必须同步更新相应文档。 @@ -60,7 +60,7 @@ platforms/ backend/ api/ # 当前:同步 HTTP API/BFF worker/ # 当前:异步任务和事件消费 - iot-client/ # 当前:管理系统/App 的设备上下行接口 + iot-gateway/ # 当前:API/Worker 与 IoT Server 的无状态上下行网关 iot-server/ # 当前:MQTT 与设备协议边界 contracts/ openapi/ # 规划:HTTP 契约 @@ -147,7 +147,7 @@ go build ./cmd/main/main.go # Worker / IoT(改动对应模块时) cd backend/worker && go test ./... && go build ./cmd/main/main.go -cd backend/iot-client && go test ./... && go build ./cmd/main/main.go +cd backend/iot-gateway && go test ./... && go build ./cmd/main/main.go cd backend/iot-server && go test ./... && go build ./cmd/main/main.go # 平台总后台 diff --git a/backend/README.md b/backend/README.md index a96bdd3..c5d147a 100644 --- a/backend/README.md +++ b/backend/README.md @@ -6,7 +6,7 @@ | --- | --- | | `api` | Gin HTTP API / BFF、同步事务、JWT 鉴权与统一响应 | | `worker` | Outbox 投递、超时扫描与可重试异步任务 | -| `iot-client` | 管理系统/App 的设备上下行 HTTP 边界、幂等受理与状态查询 | +| `iot-gateway` | API/Worker 与 IoT Server 之间的无状态上下行 HTTP 轻网关 | | `iot-server` | 内嵌 Mochi MQTT Broker、厂商二进制协议、遥测、命令与回执 | 四个进程沿用统一的工程机制;API 是命令、Outbox 和上行报文的数据库事实主责,Worker 负责投递,IoT Client 负责系统接口,IoT Server 负责设备协议。 diff --git a/backend/api/etc/heqi_dev.yaml b/backend/api/etc/heqi_dev.yaml index ccb20e2..6fe6ec4 100644 --- a/backend/api/etc/heqi_dev.yaml +++ b/backend/api/etc/heqi_dev.yaml @@ -50,4 +50,3 @@ Payment: IoT: InternalServiceToken: change-me-iot-internal-token - ClientBaseURL: http://127.0.0.1:12429 diff --git a/backend/api/internal/config/config.go b/backend/api/internal/config/config.go index 1312244..c529839 100644 --- a/backend/api/internal/config/config.go +++ b/backend/api/internal/config/config.go @@ -66,10 +66,9 @@ type PaymentConfig struct { Wechat WechatPayConfig `yaml:"Wechat"` } -// IoTConfig 保存 API、Worker 与 IoT Client 间的内部认证和地址。 +// IoTConfig 保存 API 与 IoT 链路间的内部认证配置。 type IoTConfig struct { InternalServiceToken string `yaml:"InternalServiceToken"` - ClientBaseURL string `yaml:"ClientBaseURL"` } // SrvConfig 与仓库现有进程的配置结构保持一致。 @@ -110,8 +109,8 @@ func New(srvKey string) { if Spec.Payment.ExpireMinutes <= 0 || Spec.Payment.RefundWindowDays <= 0 { panic("Payment expiration and refund window must be greater than zero") } - if len(strings.TrimSpace(Spec.IoT.InternalServiceToken)) < 16 || !strings.HasPrefix(Spec.IoT.ClientBaseURL, "http") { - panic("IoT internal token and client base URL are required") + if len(strings.TrimSpace(Spec.IoT.InternalServiceToken)) < 16 { + panic("IoT internal token is required") } registerURL, err := url.ParseRequestURI(Spec.Global.UserRegisterURL) if err != nil || (registerURL.Scheme != "http" && registerURL.Scheme != "https") || registerURL.Host == "" { diff --git a/backend/go.work b/backend/go.work index 6e604f8..a1c7668 100644 --- a/backend/go.work +++ b/backend/go.work @@ -3,6 +3,6 @@ go 1.26.1 use ( ./api ./worker - ./iot-client + ./iot-gateway ./iot-server ) diff --git a/backend/go.work.sum b/backend/go.work.sum index 5d31711..2c304e3 100644 --- a/backend/go.work.sum +++ b/backend/go.work.sum @@ -2,15 +2,26 @@ cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/agiledragon/gomonkey v2.0.2+incompatible h1:eXKi9/piiC3cjJD1658mEE2o3NjkJ5vDLgYjCQu0Xlw= +github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= +github.com/alicebob/miniredis/v2 v2.23.0/go.mod h1:XNqvJdQJv5mSuVMc0ynneafpnL/zv52acZ6kqeS0t88= github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= +github.com/cockroachdb/errors v1.11.1/go.mod h1:8MUxA3Gi6b25tYlFEBGLf+D8aISL+M4MIpiWMSNRfxw= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= +github.com/cockroachdb/pebble v1.1.0/go.mod h1:sEHm5NOXxyiAoKWhoFxT8xMgd/f3RA6qUqQ1BXKrh2E= +github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= +github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ= +github.com/dgraph-io/badger/v4 v4.2.0/go.mod h1:qfCqhPoWDFJRx1gp5QwwyGo8xk1lbHUxvK9nK0OGAak= +github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elastic/elastic-transport-go/v8 v8.9.0 h1:KeT/2P54F0xS0S8Y3Pf+tFDg4HmBgReQMB+BMz8dDAs= @@ -25,18 +36,24 @@ github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +github.com/getsentry/sentry-go v0.18.0/go.mod h1:Kgon4Mby+FJ7ZWHFUAZgVaIa8sxHtnRJRLTXZr51aKQ= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= github.com/godbus/dbus/v5 v5.0.4 h1:9349emZab16e7zQvpmsbtjc18ykshndd8y2PG3sgJbA= github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I= github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw= github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1 h1:qnpSQwGEnkcRpTqNOIR6bJbR0gAorgP9CSALpRcKoAA= github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1/go.mod h1:lXGCsh6c22WGtjr+qGHj1otzZpV/1kwTMAqkwZsnWRU= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0 h1:pRhl55Yx1eC7BZ1N+BBWwnKaMyD8uC+34TLdndZMAKk= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0/go.mod h1:XKMd7iuf/RGPSMJ/U4HP0zS2Z9Fh8Ps9a+6X26m/tmI= +github.com/jinzhu/copier v0.3.5/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e h1:a+PGEeXb+exwBS3NboqXHyxarD9kaboBbrSp+7GuBuc= github.com/jordanlewis/gcassert v0.0.0-20250430164644-389ef753e22e/go.mod h1:ZybsQk6DWyN5t7An1MuPm1gtSZ1xDaTXS9ZjIOxvQrk= github.com/kisielk/errcheck v1.5.0 h1:e8esj/e4R+SAOwFwN+n3zr0nYeCyeweozKfO23MvHzY= @@ -46,6 +63,7 @@ github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBF github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/nats-io/nats.go v1.52.0 h1:n3avV4VBsCgsdwh71TppsTwtv+QdPs7ntSKM8qJLGsc= @@ -55,6 +73,7 @@ github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZd github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30 h1:BHT1/DKsYDGkUgQ2jmMaozVcdk+sVfz0+1ZJq4zkWgw= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= @@ -67,8 +86,6 @@ github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0leargg github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/rogpeppe/fastuuid v1.2.0 h1:Ppwyp6VYCF1nvBTXL3trRso7mXMlRrw9ooo375wvi2s= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= -github.com/rs/xid v1.4.0 h1:qd7wPTDkN6KQx2VmMBLrpHkiyQwgFXRnkOLacUiaSNY= -github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= @@ -87,12 +104,16 @@ github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gi github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= github.com/yuin/goldmark v1.2.1 h1:ruQGxdhGHe7FWOJPT0mKs5+pD2Xs1Bm/kdGlHO04FmM= +github.com/yuin/gopher-lua v0.0.0-20210529063254-f4c35e4016d9/go.mod h1:E1AXubJBdNmFERAOucpDIxNzeGfLzg0mYh+UfMWdChA= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= +go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ= go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8= 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/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= diff --git a/backend/iot-client/README.md b/backend/iot-client/README.md deleted file mode 100644 index 1e9bfdb..0000000 --- a/backend/iot-client/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# IoT Client - -管理系统与 App 的上下行设备接口边界。外部业务请求应先经过 Platform API 的 JWT、角色、对象归属和安全状态校验;API/Worker 使用内部令牌调用本服务。命令要求 `identity`、`idempotency_key`、过期时间,并返回可查询状态,不能把 HTTP 受理视为设备执行成功。 - -设备上行报文由 IoT Server 回调本服务,再转交 Platform API 持久化和审计。 diff --git a/backend/iot-client/go.mod b/backend/iot-client/go.mod deleted file mode 100644 index bd440ab..0000000 --- a/backend/iot-client/go.mod +++ /dev/null @@ -1,5 +0,0 @@ -module git.apinb.com/heqiapp/platforms/backend/iot-client - -go 1.26.1 - -require gopkg.in/yaml.v3 v3.0.1 diff --git a/backend/iot-gateway/README.md b/backend/iot-gateway/README.md new file mode 100644 index 0000000..81b853d --- /dev/null +++ b/backend/iot-gateway/README.md @@ -0,0 +1,5 @@ +# IoT Gateway + +API/Worker 与 IoT Server 之间的无状态轻网关。外部业务请求必须先经过 Platform API 的 JWT、角色、对象归属和安全状态校验;Worker 使用内部令牌经本服务下发命令。网关不保存命令、幂等或设备事实,只透传 `identity`、`idempotency_key` 和过期时间,并返回 IoT Server 的派发结果。 + +设备上行报文由 IoT Server 回调本服务,再转交 Platform API 持久化和审计;命令与幂等事实、最终状态查询统一由 API/PostgreSQL 提供。 diff --git a/backend/iot-client/cmd/main/main.go b/backend/iot-gateway/cmd/main/main.go similarity index 60% rename from backend/iot-client/cmd/main/main.go rename to backend/iot-gateway/cmd/main/main.go index ed77a0a..497ea0e 100644 --- a/backend/iot-client/cmd/main/main.go +++ b/backend/iot-gateway/cmd/main/main.go @@ -3,15 +3,15 @@ package main import ( "context" "flag" - "git.apinb.com/heqiapp/platforms/backend/iot-client/internal/config" - "git.apinb.com/heqiapp/platforms/backend/iot-client/internal/service" + "git.apinb.com/heqiapp/platforms/backend/iot-gateway/internal/config" + "git.apinb.com/heqiapp/platforms/backend/iot-gateway/internal/service" "log" "os/signal" "syscall" ) func main() { - path := flag.String("config", "etc/platform_iot_client_dev.yaml", "YAML 配置文件") + path := flag.String("config", "etc/platform_iot_gateway_dev.yaml", "YAML 配置文件") flag.Parse() cfg, err := config.Load(*path) if err != nil { diff --git a/backend/iot-client/etc/platform_iot_client_dev.yaml b/backend/iot-gateway/etc/platform_iot_gateway_dev.yaml similarity index 87% rename from backend/iot-client/etc/platform_iot_client_dev.yaml rename to backend/iot-gateway/etc/platform_iot_gateway_dev.yaml index fc4f838..9661649 100644 --- a/backend/iot-client/etc/platform_iot_client_dev.yaml +++ b/backend/iot-gateway/etc/platform_iot_gateway_dev.yaml @@ -1,4 +1,4 @@ -Service: platform-iot-client +Service: platform-iot-gateway HTTP: Address: 127.0.0.1:12429 InternalToken: change-me-iot-internal-token diff --git a/backend/iot-gateway/go.mod b/backend/iot-gateway/go.mod new file mode 100644 index 0000000..3ba3510 --- /dev/null +++ b/backend/iot-gateway/go.mod @@ -0,0 +1,5 @@ +module git.apinb.com/heqiapp/platforms/backend/iot-gateway + +go 1.26.1 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/backend/iot-client/go.sum b/backend/iot-gateway/go.sum similarity index 100% rename from backend/iot-client/go.sum rename to backend/iot-gateway/go.sum diff --git a/backend/iot-client/internal/config/config.go b/backend/iot-gateway/internal/config/config.go similarity index 100% rename from backend/iot-client/internal/config/config.go rename to backend/iot-gateway/internal/config/config.go diff --git a/backend/iot-client/internal/service/service.go b/backend/iot-gateway/internal/service/service.go similarity index 72% rename from backend/iot-client/internal/service/service.go rename to backend/iot-gateway/internal/service/service.go index b1e30ec..e12cbcf 100644 --- a/backend/iot-client/internal/service/service.go +++ b/backend/iot-gateway/internal/service/service.go @@ -9,20 +9,15 @@ import ( "fmt" "io" "net/http" - "strings" - "sync" "time" - "git.apinb.com/heqiapp/platforms/backend/iot-client/internal/config" + "git.apinb.com/heqiapp/platforms/backend/iot-gateway/internal/config" ) type Service struct { - cfg config.Config - client *http.Client - mu sync.RWMutex - byIdentity map[string]Command - byIdempotency map[string]string - http *http.Server + cfg config.Config + client *http.Client + http *http.Server } type Command struct { Identity string `json:"identity"` @@ -37,11 +32,6 @@ type Command struct { Controller byte `json:"controller"` Loop byte `json:"loop"` Component byte `json:"component"` - Status string `json:"status"` - PacketNumber uint16 `json:"packet_number,omitempty"` - ErrorCode string `json:"error_code,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` } type deviceEnvelope struct { Type, Topic, DeviceID, ReceivedAt, PayloadHex string @@ -49,13 +39,12 @@ type deviceEnvelope struct { } func New(cfg config.Config) *Service { - return &Service{cfg: cfg, client: &http.Client{Timeout: 12 * time.Second}, byIdentity: map[string]Command{}, byIdempotency: map[string]string{}} + return &Service{cfg: cfg, client: &http.Client{Timeout: 12 * time.Second}} } func (s *Service) Run(ctx context.Context) error { mux := http.NewServeMux() mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }) mux.HandleFunc("/v1/device-commands", s.commands) - mux.HandleFunc("/v1/device-commands/", s.commandStatus) mux.HandleFunc("/internal/v1/device-messages", s.deviceMessage) s.http = &http.Server{Addr: s.cfg.HTTP.Address, Handler: mux, ReadHeaderTimeout: 5 * time.Second} go func() { @@ -95,56 +84,12 @@ func (s *Service) commands(w http.ResponseWriter, r *http.Request) { http.Error(w, "command expired", 422) return } - s.mu.Lock() - if existingID, ok := s.byIdempotency[cmd.IdempotencyKey]; ok { - existing := s.byIdentity[existingID] - s.mu.Unlock() - writeJSON(w, http.StatusOK, existing) - return - } - cmd.Status = "accepted" - cmd.CreatedAt = time.Now().UTC() - cmd.UpdatedAt = cmd.CreatedAt - s.byIdentity[cmd.Identity] = cmd - s.byIdempotency[cmd.IdempotencyKey] = cmd.Identity - s.mu.Unlock() status, packet, err := s.dispatch(r.Context(), cmd) - s.mu.Lock() - current := s.byIdentity[cmd.Identity] - current.UpdatedAt = time.Now().UTC() if err != nil { - current.Status = "dispatch_failed" - current.ErrorCode = "IOT_DISPATCH_FAILED" - } else { - current.Status = status - current.PacketNumber = packet - } - s.byIdentity[cmd.Identity] = current - s.mu.Unlock() - if err != nil { - writeJSON(w, http.StatusBadGateway, current) + writeJSON(w, http.StatusBadGateway, map[string]any{"identity": cmd.Identity, "status": "dispatch_failed", "error_code": "IOT_DISPATCH_FAILED"}) return } - writeJSON(w, http.StatusAccepted, current) -} -func (s *Service) commandStatus(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", 405) - return - } - if !s.authorized(r) { - http.Error(w, "unauthorized", 401) - return - } - identity := strings.TrimPrefix(r.URL.Path, "/v1/device-commands/") - s.mu.RLock() - cmd, ok := s.byIdentity[identity] - s.mu.RUnlock() - if !ok { - http.Error(w, "not found", 404) - return - } - writeJSON(w, 200, cmd) + writeJSON(w, http.StatusAccepted, map[string]any{"identity": cmd.Identity, "status": status, "packet_number": packet}) } func (s *Service) deviceMessage(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || !s.authorized(r) { diff --git a/backend/iot-client/internal/service/service_test.go b/backend/iot-gateway/internal/service/service_test.go similarity index 60% rename from backend/iot-client/internal/service/service_test.go rename to backend/iot-gateway/internal/service/service_test.go index 4f1de02..57aa93f 100644 --- a/backend/iot-client/internal/service/service_test.go +++ b/backend/iot-gateway/internal/service/service_test.go @@ -8,10 +8,10 @@ import ( "testing" "time" - "git.apinb.com/heqiapp/platforms/backend/iot-client/internal/config" + "git.apinb.com/heqiapp/platforms/backend/iot-gateway/internal/config" ) -func TestDispatchPreservesIdempotency(t *testing.T) { +func TestDispatchForwardsCommandMetadata(t *testing.T) { calls := 0 upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ @@ -28,7 +28,24 @@ func TestDispatchPreservesIdempotency(t *testing.T) { } } -func TestCommandsDoesNotDispatchDuplicateIdempotencyKey(t *testing.T) { +func TestDeviceMessageForwardsToPlatformAPI(t *testing.T) { + var received bool + platform := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + received = r.URL.Path == "/heqi/internal/v1/iot/device-messages" && r.Header.Get("X-Heqi-Iot-Token") == "token" + w.WriteHeader(http.StatusAccepted) + })) + defer platform.Close() + srv := New(config.Config{HTTP: config.HTTP{InternalToken: "token"}, Upstream: config.Upstream{PlatformAPIURL: platform.URL, Token: "token"}}) + request := httptest.NewRequest(http.MethodPost, "/internal/v1/device-messages", bytes.NewBufferString(`{"type":"device_message","receivedAt":"2026-08-03T12:00:00Z"}`)) + request.Header.Set("X-Heqi-Iot-Token", "token") + response := httptest.NewRecorder() + srv.deviceMessage(response, request) + if response.Code != http.StatusAccepted || !received { + t.Fatalf("status=%d received=%v body=%s", response.Code, received, response.Body.String()) + } +} + +func TestCommandsForwardsEveryRequestWithoutLocalState(t *testing.T) { calls := 0 upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ @@ -48,7 +65,7 @@ func TestCommandsDoesNotDispatchDuplicateIdempotencyKey(t *testing.T) { t.Fatalf("unexpected status %d: %s", response.Code, response.Body.String()) } } - if calls != 1 { - t.Fatalf("duplicate command dispatched %d times", calls) + if calls != 2 { + t.Fatalf("stateless gateway forwarded %d requests, want 2", calls) } } diff --git a/backend/worker/cmd/main/main.go b/backend/worker/cmd/main/main.go index 819bd3a..a0d8e4d 100644 --- a/backend/worker/cmd/main/main.go +++ b/backend/worker/cmd/main/main.go @@ -94,7 +94,7 @@ func dispatchIoTOutbox(ctx context.Context) { } payload["identity"] = outbox.CommandIdentity body, _ := json.Marshal(payload) - dispatch, err := http.NewRequestWithContext(ctx, http.MethodPost, config.Spec.IoTAPI.ClientBaseURL+"/v1/device-commands", bytes.NewReader(body)) + dispatch, err := http.NewRequestWithContext(ctx, http.MethodPost, config.Spec.IoTAPI.GatewayBaseURL+"/v1/device-commands", bytes.NewReader(body)) if err != nil { continue } @@ -102,7 +102,7 @@ func dispatchIoTOutbox(ctx context.Context) { dispatch.Header.Set("X-Heqi-Iot-Token", config.Spec.IoTAPI.Token) dispatchResponse, err := client.Do(dispatch) if err != nil { - completeIoTOutbox(ctx, client, outbox.Identity, false, 0, "IOT_CLIENT_UNAVAILABLE") + completeIoTOutbox(ctx, client, outbox.Identity, false, 0, "IOT_GATEWAY_UNAVAILABLE") continue } var result struct { @@ -114,7 +114,7 @@ func dispatchIoTOutbox(ctx context.Context) { success := (status == http.StatusAccepted || status == http.StatusOK) && decodeErr == nil errorCode := "" if !success { - errorCode = "IOT_CLIENT_REJECTED" + errorCode = "IOT_GATEWAY_REJECTED" } completeIoTOutbox(ctx, client, outbox.Identity, success, result.PacketNumber, errorCode) } diff --git a/backend/worker/etc/platform_worker_dev.yaml b/backend/worker/etc/platform_worker_dev.yaml index 3c4243a..7682562 100644 --- a/backend/worker/etc/platform_worker_dev.yaml +++ b/backend/worker/etc/platform_worker_dev.yaml @@ -13,7 +13,7 @@ PaymentAPI: IntervalSeconds: 60 IoTAPI: PlatformBaseURL: http://127.0.0.1:12426 - ClientBaseURL: http://127.0.0.1:12429 + GatewayBaseURL: http://127.0.0.1:12429 Token: change-me-iot-internal-token IntervalMilliseconds: 500 SecretKey: change-me-to-a-random-string diff --git a/backend/worker/internal/config/config.go b/backend/worker/internal/config/config.go index cafdc9c..eb15533 100644 --- a/backend/worker/internal/config/config.go +++ b/backend/worker/internal/config/config.go @@ -23,7 +23,7 @@ type PaymentAPIConfig struct { } type IoTAPIConfig struct { PlatformBaseURL string `yaml:"PlatformBaseURL"` - ClientBaseURL string `yaml:"ClientBaseURL"` + GatewayBaseURL string `yaml:"GatewayBaseURL"` Token string `yaml:"Token"` IntervalMilliseconds int `yaml:"IntervalMilliseconds"` } @@ -37,7 +37,7 @@ func New(srvKey string) { if Spec.PaymentAPI.BaseURL == "" || Spec.PaymentAPI.Token == "" || Spec.PaymentAPI.IntervalSeconds <= 0 { panic("PaymentAPI configuration is required") } - if Spec.IoTAPI.PlatformBaseURL == "" || Spec.IoTAPI.ClientBaseURL == "" || Spec.IoTAPI.Token == "" || Spec.IoTAPI.IntervalMilliseconds <= 0 { + if Spec.IoTAPI.PlatformBaseURL == "" || Spec.IoTAPI.GatewayBaseURL == "" || Spec.IoTAPI.Token == "" || Spec.IoTAPI.IntervalMilliseconds <= 0 { panic("IoTAPI configuration is required") } conf.PrintInfo(Spec.Addr) diff --git a/checking/02-总体测试计划.md b/checking/02-总体测试计划.md index e7175ad..d14439c 100644 --- a/checking/02-总体测试计划.md +++ b/checking/02-总体测试计划.md @@ -23,7 +23,7 @@ | Web | `frontend/site` | 官网内容、导航、响应式、静态资源、可访问性、SEO 基础、构建与托管 Worker | | 后端 | `backend/api` | Gin API/BFF、JWT、权限、同步事务、状态动作、金额、幂等、审计、上传和数据库事实 | | 后端 | `backend/worker` | 配置、启动、Mock 循环、优雅退出;未来 Outbox/Streams 的投递、重试、死信和积压 | -| 后端 | `backend/iot-client`、`backend/iot-server` | 配置、启动、幂等命令、协议编解码、MQTT TLS、身份、遥测、回执和断线重连 | +| 后端 | `backend/iot-gateway`、`backend/iot-server` | 配置、启动、无状态转发、协议编解码、MQTT TLS、身份、遥测、回执和断线重连 | 不修改 `sample/`。真实支付、短信、地图、对象存储、电子合同、MQTT 厂商接入等,以受控沙箱或 Mock 验证;生产联调另设上线前检查点。 diff --git a/checking/05-执行批次与质量门禁.md b/checking/05-执行批次与质量门禁.md index 0c277b4..f489f43 100644 --- a/checking/05-执行批次与质量门禁.md +++ b/checking/05-执行批次与质量门禁.md @@ -56,7 +56,7 @@ npm run build ```bash cd backend/api && go test ./... && go vet ./... && go build ./cmd/main/main.go cd backend/worker && go test ./... && go vet ./... && go build ./cmd/main/main.go -cd backend/iot-client && go test ./... && go vet ./... && go build ./cmd/main/main.go +cd backend/iot-gateway && go test ./... && go vet ./... && go build ./cmd/main/main.go cd backend/iot-server && go test ./... && go vet ./... && go build ./cmd/main/main.go ``` diff --git a/checking/README.md b/checking/README.md index 188853a..72ea557 100644 --- a/checking/README.md +++ b/checking/README.md @@ -24,6 +24,6 @@ ## 计划边界 -- `backend/worker` 已投递 IoT Outbox;`backend/iot-client` 与内嵌 Mochi MQTT Broker 的 `backend/iot-server` 已落地系统接口和 MQTT 协议边界。真实设备证书和厂商硬件仍需在联调环境验收。 +- `backend/worker` 已投递 IoT Outbox;无状态 `backend/iot-gateway` 与内嵌 Mochi MQTT Broker 的 `backend/iot-server` 已落地系统接口和 MQTT 协议边界。真实设备证书和厂商硬件仍需在联调环境验收。 - `frontend/site` 是官网,按展示、响应式、可访问性、链接、构建和托管 Worker 测试;不把它当成业务事实写入端。 - 本计划不把尚未实现或文档中“待确认”的政策当作通过标准。此类项进入阻塞/待决清单,由产品、安全、财务或法务确认后再固化用例。 diff --git a/docs/10-技术实现规划.md b/docs/10-技术实现规划.md index 29fe738..36c7cfc 100644 --- a/docs/10-技术实现规划.md +++ b/docs/10-技术实现规划.md @@ -53,7 +53,7 @@ flowchart LR | --- | --- | --- | | `api` | 用户端和五个管理系统的 HTTP API、鉴权、同步业务事务 | 无状态部署;所有写操作支持幂等键、事务和审计 | | `worker` | 事件消费、告警、派单、推送、对账、超时扫描、轨迹异常识别 | Redis Streams 消费组;重试、死信、幂等消费和可观测的积压告警 | -| `iot-client` | 管理系统/App 的设备上下行 HTTP 边界、幂等受理与状态查询 | 不绕过 API 的 JWT、角色、对象归属与安全状态校验 | +| `iot-gateway` | API/Worker 与 IoT Server 的无状态上下行 HTTP 轻网关 | 不保存业务事实;不绕过 API 的 JWT、角色、对象归属与安全状态校验 | | `iot-server` | 内嵌 Mochi MQTT Broker、厂商协议适配、遥测校验、命令下发与回执 | 保持设备会话一致性;每设备认证与 Topic ACL;协议版本、设备身份、LRC8 与 AES 校验 | 关键业务采用“数据库事务 + Outbox 事件表 + Worker 投递”的模式:先在 PostgreSQL 提交业务事实与待投递事件,再异步写入 Redis Streams。这样 Redis 故障或 Worker 重启不会丢失订单、告警、支付或设备命令的业务事实。 @@ -63,7 +63,7 @@ flowchart LR | 基线 | 路径 | 使用要求 | | --- | --- | --- | | 前端工程基线 | 现有 Vue 管理端 | Vue 管理系统统一前端框架、路由、状态管理、请求封装、权限指令、表格表单、主题、错误处理、国际化与测试规范 | -| 后端工程基线 | `backend/{api,worker,iot-client,iot-server}` | Go API、Worker 与两个 IoT 进程统一沿用配置、日志、错误码、认证、任务、测试和发布规范 | +| 后端工程基线 | `backend/{api,worker,iot-gateway,iot-server}` | Go API、Worker 与两个 IoT 进程统一沿用配置、日志、错误码、认证、任务、测试和发布规范 | 业务项目应通过共享包、模板或上游同步机制复用标准库,禁止将标准库目录复制到每个子项目后自行漂移。标准库升级需要记录版本、影响范围、兼容策略和回滚方式。 @@ -98,7 +98,7 @@ platforms/ backend/ api/ # Go HTTP API、BFF、同步领域事务 worker/ # Go 异步任务:派单、告警、通知、对账、超时扫描 - iot-client/ # 管理系统/App 的设备上下行接口 + iot-gateway/ # API/Worker 与 IoT Server 的无状态上下行网关 iot-server/ # MQTT 会话、厂商协议、设备命令、遥测与回执 contracts/ openapi/ # HTTP API 契约及生成配置 @@ -114,7 +114,7 @@ platforms/ performance/ # 遥测、订单、轨迹与消息积压压测 ``` -其中 `apps/user_app`、`apps/service_app`、`backend/{api,worker,iot-client,iot-server}` 与当前管理端目录已经落地;其余标记为规划的目录仍不得因局部任务提前创建空壳。 +其中 `apps/user_app`、`apps/service_app`、`backend/{api,worker,iot-gateway,iot-server}` 与当前管理端目录已经落地;其余标记为规划的目录仍不得因局部任务提前创建空壳。 ## 6. 后端领域划分 diff --git a/docs/11-数据接口与安全.md b/docs/11-数据接口与安全.md index c1be48f..996f3fb 100644 --- a/docs/11-数据接口与安全.md +++ b/docs/11-数据接口与安全.md @@ -44,7 +44,7 @@ ## 3. IoT 协议与可靠性 - 设备厂商 V1.8 二进制帧保持 `0x5E` 起始、`0x5B` 结束、大端序、数据包 AES-128 与 LRC8 规则不变,并作为 MQTT payload 传输;Topic 使用 `devices/{deviceId}/{up|down|ack}`,QoS 1,下行命令禁止 retained。 -- `iot-server` 使用 Mochi MQTT v2 内嵌 Broker,只处理 MQTT 会话、每设备认证/Topic ACL 和设备协议;`iot-client` 只提供系统侧上下行接口;命令、Outbox、原始上行和回执事实由 API 持久化,Worker 负责可重试投递。 +- `iot-server` 使用 Mochi MQTT v2 内嵌 Broker,只处理 MQTT 会话、每设备认证/Topic ACL 和设备协议;`iot-gateway` 是不保存业务事实的无状态上下行轻网关;命令、幂等、Outbox、原始上行和回执事实由 API 持久化,Worker 负责可重试投递。 - 协议封面版本与变更记录冲突时以最新 V1.8 变更记录和绿色标注为兼容实现依据;重复子标识等歧义必须保留原始报文并按设备型号配置解析,不得静默猜测。 - 设备采用 MQTT over TLS,设备身份使用每设备证书或短期轮换令牌;禁止共享默认密钥。 diff --git a/scripts/build-backend.sh b/scripts/build-backend.sh index 139dce6..4bbec18 100644 --- a/scripts/build-backend.sh +++ b/scripts/build-backend.sh @@ -2,6 +2,6 @@ set -Eeuo pipefail mkdir -p ./output -for module in api worker iot-client iot-server; do +for module in api worker iot-gateway iot-server; do (cd "./backend/${module}" && GOARCH=amd64 GOOS=linux go build -o "../../output/${module}" ./cmd/main/main.go) done diff --git a/scripts/run_iot.sh b/scripts/run_iot.sh index a4e9383..0f81e53 100644 --- a/scripts/run_iot.sh +++ b/scripts/run_iot.sh @@ -6,6 +6,6 @@ PIDS=() cleanup(){ trap - EXIT INT TERM; for pid in "${PIDS[@]}"; do kill "${pid}" 2>/dev/null || true; done; for pid in "${PIDS[@]}"; do wait "${pid}" 2>/dev/null || true; done; } trap cleanup EXIT INT TERM (cd "${PROJECT_ROOT}/backend/iot-server" && go run ./cmd/main/main.go -config etc/platform_iot_server_dev.yaml) & PIDS+=("$!") -(cd "${PROJECT_ROOT}/backend/iot-client" && go run ./cmd/main/main.go -config etc/platform_iot_client_dev.yaml) & PIDS+=("$!") -echo "IoT Server :12428 与 IoT Client :12429 已启动。" +(cd "${PROJECT_ROOT}/backend/iot-gateway" && go run ./cmd/main/main.go -config etc/platform_iot_gateway_dev.yaml) & PIDS+=("$!") +echo "IoT Server :12428 与 IoT Gateway :12429 已启动。" wait -n "${PIDS[@]}"