@@ -23,6 +23,16 @@ services:
|
||||
image: redis:7-alpine
|
||||
ports: ["6379:6379"]
|
||||
|
||||
# --- Jaeger 分布式链路追踪 (all-in-one:OTLP 收集 + 存储 + 查询 UI) ---
|
||||
jaeger:
|
||||
image: jaegertracing/all-in-one:1.60
|
||||
environment:
|
||||
COLLECTOR_OTLP_ENABLED: "true" # 开启 OTLP 收集端点(各服务经此上报 span)
|
||||
ports:
|
||||
- "16686:16686" # Web UI:浏览器看 span 瀑布图
|
||||
- "4318:4318" # OTLP/HTTP 入口(各 Go 服务默认上报到这里)
|
||||
- "4317:4317" # OTLP/gRPC 入口(备用)
|
||||
|
||||
# --- Milvus 向量数据库 (standalone 需 etcd + minio) ---
|
||||
milvus-etcd:
|
||||
image: quay.io/coreos/etcd:v3.5.14
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
go 1.25.0
|
||||
go 1.25.8
|
||||
|
||||
// 后端运行链路的 4 个 Go 模块。sundynix-desktop(Wails) 不入工作区:
|
||||
// 它需先 npm run build 生成 frontend/dist 供 //go:embed,单独 wails build 即可。
|
||||
|
||||
+165
@@ -1,3 +1,123 @@
|
||||
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
cloud.google.com/go/accessapproval v1.13.0/go.mod h1:7bmInw17bQX+ZPi7YmReC3xKymDrMmxXaUnaI6zQOqI=
|
||||
cloud.google.com/go/accesscontextmanager v1.14.0/go.mod h1:VO15iVnsM0FO9Dt8hSFPgkuHRZjq6LEYZq1szJ27U2k=
|
||||
cloud.google.com/go/aiplatform v1.125.0/go.mod h1:yWTZiCunYDnyxeWWD14tDo6+BMlvAUCC5VxuxhvbrVI=
|
||||
cloud.google.com/go/analytics v0.35.0/go.mod h1:V9Qef2N0y8GDqQ9FTlmM2XpDEMYonZJRPSUNGZlPCcc=
|
||||
cloud.google.com/go/apigateway v1.12.0/go.mod h1:f3Sk8Tdh1Ty5HR7kgbWB6Yu1M82LM+nIr5DTMZnLZWk=
|
||||
cloud.google.com/go/apigeeconnect v1.12.0/go.mod h1:mYJekCKZHc2ia5yZX5lwtexTn9CzsOfb6+sh/2hi42Q=
|
||||
cloud.google.com/go/apigeeregistry v1.0.0/go.mod h1:o+j6eA8hYhTWX5gEqMMBVDWY+/QQFrYe/YJBsO19pn0=
|
||||
cloud.google.com/go/appengine v1.14.0/go.mod h1:JMjrVFg+YgfksZCWbtA3TgbKbPfZZtapB9cGL/5WVnM=
|
||||
cloud.google.com/go/area120 v0.15.0/go.mod h1:jD1fw9W4xxIZMY68g7PpbCPleoeGddFs5jPcdhfg3+Y=
|
||||
cloud.google.com/go/artifactregistry v1.25.0/go.mod h1:aMmdtqKVmbuxCCb/NGDJYZHsK6AtqlcyvD05ACzs1n8=
|
||||
cloud.google.com/go/asset v1.27.0/go.mod h1:+HaDReZQAh/0syAf0uTMeUrMfXikr+KKyDtCdvf7j4M=
|
||||
cloud.google.com/go/assuredworkloads v1.18.0/go.mod h1:zBnVYn0E+sDW/mhEmcg1R8+8tguXrtBgmfGY0q34kss=
|
||||
cloud.google.com/go/automl v1.20.0/go.mod h1:OkHxjbVDblDafhwuP8yEkz1xcUJhgcbhbsieCW7GaiI=
|
||||
cloud.google.com/go/baremetalsolution v1.9.0/go.mod h1:o+stutiS8t+HmjNIG92Gkn8H9+5/q27d6lQp7e9GWdg=
|
||||
cloud.google.com/go/batch v1.19.0/go.mod h1:dpWfhLmLQZqsTBAFYjZA3pS04fCY5ttTenZcWmSeILw=
|
||||
cloud.google.com/go/beyondcorp v1.7.0/go.mod h1:vujdO0wfsBV2y1egrJxGtwKZr5P5V6bIHKWp1phWHBY=
|
||||
cloud.google.com/go/bigquery v1.77.0/go.mod h1:J4wuqka/1hEpdJxH2oBrUR0vjTD+r7drGkpcA3yqERM=
|
||||
cloud.google.com/go/bigtable v1.50.0/go.mod h1:RTannV5mvoJM8KscLTfRYMPo84u9/j+C3PSyYJGf5Ic=
|
||||
cloud.google.com/go/billing v1.26.0/go.mod h1:axqDO1uHegh7u5qngkTfqN1djAeLGsWAFAblERgmgEk=
|
||||
cloud.google.com/go/binaryauthorization v1.15.0/go.mod h1:+0CndCJPtcHuVCNok+qQskWvbP5Sp5m6eGL8Vpu5mss=
|
||||
cloud.google.com/go/certificatemanager v1.14.0/go.mod h1:QOA8qRoM6/Ik03+srLnBykenGTy0fk78dnPcx5ZWOW8=
|
||||
cloud.google.com/go/channel v1.26.0/go.mod h1:04T5Wjq+mHlvEUNzExydnBW1vO64q3Q2Wsblp/dpBxY=
|
||||
cloud.google.com/go/cloudbuild v1.30.0/go.mod h1:rg52xEmndQQPiC9NV/8sCaVtKxHMU9D9MeU+oE9VGKA=
|
||||
cloud.google.com/go/clouddms v1.13.0/go.mod h1:aMgrOZ+/EKF/PL+h1sDbS+7fAIYV5rTwD+G/apCeHQk=
|
||||
cloud.google.com/go/cloudtasks v1.18.0/go.mod h1:3KeCxwtGEyaySL7CR3lMmEa2I4mq1ynXdgmfNiO4RYE=
|
||||
cloud.google.com/go/compute v1.64.0/go.mod h1:eHhcRZ6vf70fQCS3VEsiWSh+nQ+tLvSMb7mwLQskgN0=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
cloud.google.com/go/contactcenterinsights v1.22.0/go.mod h1:2Crd36H59Lwkt4gWrLgmnbnF59IIZIa3XYt1gtNqJkQ=
|
||||
cloud.google.com/go/container v1.53.0/go.mod h1:SBOylKhlKYCBFs/8kz2yqRdUW5ctVNHs82JKOTjrB9s=
|
||||
cloud.google.com/go/containeranalysis v0.19.0/go.mod h1:Zq0XHzUIa0oTa7H6aSR8HWqeJnoRI9syUcYJzfozjZQ=
|
||||
cloud.google.com/go/datacatalog v1.32.0/go.mod h1:DE272tynQUwheJeQAyVfV+nO8yrdkuDyOgH2LtOrkWM=
|
||||
cloud.google.com/go/dataflow v0.16.0/go.mod h1:BWhSrIGmsMfuYj3J+nJ2Tw7tplRR6r28kvRiqCD3WlQ=
|
||||
cloud.google.com/go/dataform v1.0.0/go.mod h1:i1a0zkS751kvrY1IIPpUQZ77H5doxx7cs0AP3hnXTMk=
|
||||
cloud.google.com/go/datafusion v1.13.0/go.mod h1:MQdANs3I/4gitzY+mTBx27rrQyMiUg8uc2Z4TPLWWfc=
|
||||
cloud.google.com/go/datalabeling v0.14.0/go.mod h1:DYjvP4RhQ0332YgO22APYlBjCebb+SCaS0e2KApDq/Q=
|
||||
cloud.google.com/go/dataplex v1.35.0/go.mod h1:B7AFwXU1u3sp7FVQ3IFYnQguGTycJS2mF1voE0lLe1o=
|
||||
cloud.google.com/go/dataproc/v2 v2.23.0/go.mod h1:dOzSynzBm7TBf9nIxmJxKAQt5EpdNPcNJomYfbpPhm4=
|
||||
cloud.google.com/go/dataqna v0.13.0/go.mod h1:XiVVFTOEJLBSvm3ILbyjXngGQYpjb/66MSksqz/56fs=
|
||||
cloud.google.com/go/datastore v1.24.0/go.mod h1:cEkLhU6Ti/gauQ7DFrUrG8bQjiMIxi++b5ePiThi5So=
|
||||
cloud.google.com/go/datastream v1.20.0/go.mod h1:uoWTtfP20W8MXuV2DPcl5zqnVsxQ9QEmmBHX858oYTQ=
|
||||
cloud.google.com/go/deploy v1.32.0/go.mod h1:lUG7maG/NkoTXmQ8G1mtcVymnbizfDJh6ER7vljVa/U=
|
||||
cloud.google.com/go/dialogflow v1.82.0/go.mod h1:UtuiGOq9gAlTz9u4Vt+q1syMrx9ANQzTk+lC3WDdSOw=
|
||||
cloud.google.com/go/dlp v1.36.0/go.mod h1:UW92dBhxvqkSKLct+Ril7Y9B4CanS5VLuDwlTGVA9VQ=
|
||||
cloud.google.com/go/documentai v1.48.0/go.mod h1:mGjfbNf0cqCHKgxMZZV7frbfoF9T2hKkU1h88QyOy3c=
|
||||
cloud.google.com/go/domains v0.15.0/go.mod h1:BjoSVNc+LVwoHMnE2fxTQNzGLSWWb6f3a8VAN6+VjVk=
|
||||
cloud.google.com/go/edgecontainer v1.9.0/go.mod h1:mZmgXuMGTGI6RUUTXsOZa+F2rFF21v0JPnuX7LQEqBE=
|
||||
cloud.google.com/go/errorreporting v0.9.0/go.mod h1:V7ojx7z76JITDZNGyDNkIIa9nNEkQzF6Yj+VHl2YF84=
|
||||
cloud.google.com/go/essentialcontacts v1.12.0/go.mod h1:W8fTL17jP6vmsPHQaCT5rOjWGohEssuqDUroxnjST0A=
|
||||
cloud.google.com/go/eventarc v1.23.0/go.mod h1:tIJL0hoWtZXVa5MjcAep/4xB+AXz4AbqQV14ogX5VwU=
|
||||
cloud.google.com/go/filestore v1.15.0/go.mod h1:oD+PvCWu4HqfEdNv65yk2XaLIiP7h4AuAH9Ua5YBRTM=
|
||||
cloud.google.com/go/firestore v1.22.0/go.mod h1:PaM4i7i7ruALSKmlpHXXZaPObcZw0W7ie5UOPr72iTU=
|
||||
cloud.google.com/go/functions v1.24.0/go.mod h1:t40GeqBAQNuqKlHCxmV/pxhyYJnImLcvRa3GBv4tAy0=
|
||||
cloud.google.com/go/gkebackup v1.13.0/go.mod h1:D2MDbHW4V/uKCmS9TnT8hNKX2tPkE/pWp9nSm0TQ9hY=
|
||||
cloud.google.com/go/gkeconnect v1.0.0/go.mod h1:5iWSBQzMIRLwUHUWVhxxcNK45ZPE8ntyBgE0MkavlqQ=
|
||||
cloud.google.com/go/gkehub v0.21.0/go.mod h1:xKePlMrI8LpKErzKMWdH/yQv+GDV60ypCNfTTdT+BN0=
|
||||
cloud.google.com/go/gkemulticloud v1.11.0/go.mod h1:OtfHtgqOgDrXfcdFw8eUkCUI154Q51vvdqZYZV4c4qM=
|
||||
cloud.google.com/go/gsuiteaddons v1.12.0/go.mod h1:rm/XT7wmwOFGn7jmWtVV65QmZCakzTbHLSojIC4Hskg=
|
||||
cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4=
|
||||
cloud.google.com/go/iap v1.17.0/go.mod h1:b+r+yjrss2WmAEzNrQQjlEdD5E9B8c47mOF7XnqT+z0=
|
||||
cloud.google.com/go/ids v1.10.0/go.mod h1:uCSFrXfCnRUKBl5PdE/ZqBNp1+vKSKPWpdYGa61WjpQ=
|
||||
cloud.google.com/go/iot v1.13.0/go.mod h1:62W4n2fe/Ct66NWJEfCB5suZ3XsL5Atx+MxFjScr+9s=
|
||||
cloud.google.com/go/kms v1.31.0/go.mod h1:YIyXZym11R5uovJJt4oN5eUL3oPmirF3yKeIh6QAf4U=
|
||||
cloud.google.com/go/language v1.18.0/go.mod h1:xSeiVB4UiA9wYmFy2GWjf1Mb1K3uR1Yi/80qoqTxH04=
|
||||
cloud.google.com/go/lifesciences v0.15.0/go.mod h1:FwS+QkqPdVWl4SmKUCFozFvsTVWTLH13HCKcwR/MR9U=
|
||||
cloud.google.com/go/logging v1.18.0/go.mod h1:ZGKnpBaURITh+g/uom2VhbiFoFWvejcrHPDhxFtU/gI=
|
||||
cloud.google.com/go/longrunning v1.0.0/go.mod h1:8nqFBPOO1U/XkhWl0I19AMZEphrHi73VNABIpKYaTwM=
|
||||
cloud.google.com/go/managedidentities v1.12.0/go.mod h1:rm72jf/v//0NG73VQNZM1JlV2E95uhJymmSXlgi6hMA=
|
||||
cloud.google.com/go/maps v1.36.0/go.mod h1:Ly0sd/0G1MgKuWpGc2vCBjNZ+fc8iRHzcBWJqrw7Xao=
|
||||
cloud.google.com/go/mediatranslation v0.13.0/go.mod h1:kjZrowuigFr+Bf1HM1TCtp1a3E3kfG1ovPK5VEuaNAQ=
|
||||
cloud.google.com/go/memcache v1.16.0/go.mod h1:y/rXhJiieCF742K958dY29fSfM+Y3wh2thRmWspU2Dg=
|
||||
cloud.google.com/go/metastore v1.19.0/go.mod h1:JGTjGdQ627m2ptDo86XsIKqzzZCk+GG41VEFD7ENsqs=
|
||||
cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM=
|
||||
cloud.google.com/go/networkconnectivity v1.26.0/go.mod h1:Uhzfk7NbiY6RNqV9XFvPWRji58+MkTYsTRfQ3EPtrGg=
|
||||
cloud.google.com/go/networkmanagement v1.28.0/go.mod h1:2YogSU3sD7LvtmWntUAuGARbFQmy3A0En3LrJr69jkU=
|
||||
cloud.google.com/go/networksecurity v0.17.0/go.mod h1:NlMistWENBCFt1v748gUn4v9Rk5AVCTDTFs1VSE3JUg=
|
||||
cloud.google.com/go/notebooks v1.17.0/go.mod h1:NScGIhfQCqLRIlVaUVbm595F6dhqiTl5XS1KaKgitKM=
|
||||
cloud.google.com/go/optimization v1.11.0/go.mod h1:qCWskZMcynh0GBsUrCP6oPwwnUhbwg5UcXvVM9hzOD8=
|
||||
cloud.google.com/go/orchestration v1.16.0/go.mod h1:H7MFVP8Z/dtml39nf43sWYPL/2o7J4tdSZAlJrBuqnQ=
|
||||
cloud.google.com/go/orgpolicy v1.20.0/go.mod h1:9LHqEGx5P5dhansdKTNIEXpM+QbebAIOs66+HUID4aQ=
|
||||
cloud.google.com/go/osconfig v1.21.0/go.mod h1:BofnHqjjvu6lZQv/hqo2+rLCUiY4O6A9UYwwvVrSBjk=
|
||||
cloud.google.com/go/oslogin v1.18.0/go.mod h1:3Oa36T3781Mv+yCSVYlfasi7auHjfPFqvNOd1q92umc=
|
||||
cloud.google.com/go/phishingprotection v0.13.0/go.mod h1:2gyYqwNjePPEocXDkDve3EuJPaRqN/E7fp28K3arR0k=
|
||||
cloud.google.com/go/policytroubleshooter v1.15.0/go.mod h1:yNuROjN6h+2/TE2JOvBBJMjYIjC6j0UYHq8f2kVHlA4=
|
||||
cloud.google.com/go/privatecatalog v0.15.0/go.mod h1:av2b5Rv+oG5ORxUqGlCAYO9s4pXjgc6q2qO9nkTcqT8=
|
||||
cloud.google.com/go/pubsub v1.50.2/go.mod h1:jyCWeZdGFqd4mitSsBERnJcpqaHBsxQoPkNvjj4sp0w=
|
||||
cloud.google.com/go/pubsub/v2 v2.5.1/go.mod h1:Pd+qeabMX+576vQJhTN7TelE4k6kJh15dLU/ptOQ/UA=
|
||||
cloud.google.com/go/pubsublite v1.8.2/go.mod h1:4r8GSa9NznExjuLPEJlF1VjOPOpgf3IT6k8x/YgaOPI=
|
||||
cloud.google.com/go/recaptchaenterprise/v2 v2.26.0/go.mod h1:+ntF70/j7qBa6G/pwmYA0mkBcDeTCXV6WDqUL7GObfs=
|
||||
cloud.google.com/go/recommendationengine v0.14.0/go.mod h1:UP9cN46tDpZ/N57eDYIWeIRHjMOchtiIyjWjV0Dvr3k=
|
||||
cloud.google.com/go/recommender v1.19.0/go.mod h1:LRh+1HJjLx2kDE3S65AIlG/lvwA0llEFWYPD/QtgoaU=
|
||||
cloud.google.com/go/redis v1.23.0/go.mod h1:EUlUT24BAL6LsE1f/N9Bg3LhRCfH+LzwLGbst3KuZRw=
|
||||
cloud.google.com/go/resourcemanager v1.15.0/go.mod h1:ve0VNxPoDU6XxDuEMCjkineb0YzXQXx3mOWwnNckGDE=
|
||||
cloud.google.com/go/resourcesettings v1.8.3/go.mod h1:BzgfXFHIWOOmHe6ZV9+r3OWfpHJgnqXy8jqwx4zTMLw=
|
||||
cloud.google.com/go/retail v1.31.0/go.mod h1:sfq/cT+gfSLuURf/mdVAw5n0pav3hxSP1rT8RfL7Qxk=
|
||||
cloud.google.com/go/run v1.21.0/go.mod h1:Z5wHbyFirI8XU48EPs5XJf/qmVm1SXZEhuS8EvZOuQU=
|
||||
cloud.google.com/go/scheduler v1.16.0/go.mod h1:0hsZg0MZJADyke1lutI0FHAYJR8Dtm8oIivXkmpACkA=
|
||||
cloud.google.com/go/secretmanager v1.20.0/go.mod h1:9OmSuOeiiUicANglrbdKWSnT3gYkRcXuUQDk7dDW0zU=
|
||||
cloud.google.com/go/security v1.25.0/go.mod h1:xKPO7XBfUtgjfzPJeznEhI0gp/ZRJt/ZbWtuMYMeUDk=
|
||||
cloud.google.com/go/securitycenter v1.44.0/go.mod h1:7BMMbSTAddVfiE+HrC8tKS6SuRkyK7FRPlkpAZBRV3U=
|
||||
cloud.google.com/go/servicedirectory v1.17.0/go.mod h1:CtgjXS1idj3s9Q6tB68021Rzk8Q6decV6+ldXC1BoBk=
|
||||
cloud.google.com/go/shell v1.12.0/go.mod h1:TivWrVriy6xQ0wBjNJJridJgODZz8zXUEW2u48kynzY=
|
||||
cloud.google.com/go/spanner v1.92.0/go.mod h1:rCDPfWXNX0h+t484r+crCEaaMKbJfoWkHRDKU3H3+oY=
|
||||
cloud.google.com/go/speech v1.35.0/go.mod h1:shnf33sZbGnQQZyek1fdLOR5rRKV6D3jsNqpqyijvj8=
|
||||
cloud.google.com/go/storagetransfer v1.18.0/go.mod h1:AbGutEym/KNasoiDpSj/CYbigp5yhgosSgwlhGvQNs4=
|
||||
cloud.google.com/go/talent v1.13.0/go.mod h1:GSwli9V25WQdzeuJDJWH9TlQmA8lPFn7yKsxowdxW9Y=
|
||||
cloud.google.com/go/texttospeech v1.21.0/go.mod h1:p/UVJILAo/S5vsJaWZVdDRzNzA7wXIA+hTACvpMeOBk=
|
||||
cloud.google.com/go/tpu v1.13.0/go.mod h1:F5gT5BL22Dhsr05JLHdMjAjj+wcTn3Xtuu4jvq9yFug=
|
||||
cloud.google.com/go/trace v1.16.0/go.mod h1:r+bdAn16dKLSV1G2D5v3e58IlQlizfxWrUfjx7kM7X0=
|
||||
cloud.google.com/go/translate v1.17.0/go.mod h1:3mErnHTQBu9yeLiL35K0HBBuaM6Vk2fD/vyWFz790VU=
|
||||
cloud.google.com/go/video v1.32.0/go.mod h1:KxDL728ZzH+FJwtEb9XkiLTETW5bI37hTWbJiRYeXkk=
|
||||
cloud.google.com/go/videointelligence v1.16.0/go.mod h1:mmX1JpIWzwozaigrdRNjikZc3aFLNHFKh+OFwAdfiW4=
|
||||
cloud.google.com/go/vision/v2 v2.14.0/go.mod h1:ODlLCajJOq4t8thoi1uVvbnfIfix73HsYWhZuIveagQ=
|
||||
cloud.google.com/go/vmmigration v1.15.0/go.mod h1:MP6mQ21ru1usBeCbl805Ioz0Fy+yf3qK2kUkhZ69QQY=
|
||||
cloud.google.com/go/vmwareengine v1.8.0/go.mod h1:e66l90IZhm1yQfYZv+YCWjSNSklQZCRmuEvKL8n3Ua0=
|
||||
cloud.google.com/go/vpcaccess v1.13.0/go.mod h1:4Uus6E/9FYUtIrwBE1wJ1RosKwb02H6kEd9puJ02TL8=
|
||||
cloud.google.com/go/webrisk v1.16.0/go.mod h1:VIQw8smiaMOlget/xOk6niTkNJTiQc5skEmCuAksxJc=
|
||||
cloud.google.com/go/websecurityscanner v1.12.0/go.mod h1:cZSc9HqoFdccL1mqZtPIInOd4R8PBGwI20wdnrz6AO8=
|
||||
cloud.google.com/go/workflows v1.19.0/go.mod h1:TWsrDGgsJy7xAJ07byzHhKKehEWItJG3BivEHVhGH5g=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
|
||||
github.com/blevesearch/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:kDy+zgJFJJoJYBvdfBSiZYBbdsUL0XcjHYWezpQBGPA=
|
||||
github.com/blevesearch/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:9eJDeqxJ3E7WnLebQUlPD7ZjSce7AnDb9vjGmMCbD0A=
|
||||
github.com/blevesearch/goleveldb v1.0.1 h1:iAtV2Cu5s0GD1lwUiekkFHe2gTMCCNVj2foPclDLIFI=
|
||||
@@ -9,27 +129,41 @@ github.com/blevesearch/stempel v0.2.0/go.mod h1:wjeTHqQv+nQdbPuJ/YcvOjTInA2EIc6K
|
||||
github.com/bytedance/sonic v1.12.2/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
|
||||
github.com/bytedance/sonic/loader v0.2.0/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
|
||||
github.com/couchbase/ghistogram v0.1.0 h1:b95QcQTCzjTUocDXp/uMgSNQi8oj1tGwnJ4bODWZnps=
|
||||
github.com/couchbase/ghistogram v0.1.0/go.mod h1:s1Jhy76zqfEecpNWJfWUiKZookAFaiGOEoyzgHt9i7k=
|
||||
github.com/couchbase/moss v0.2.0 h1:VCYrMzFwEryyhRSeI+/b3tRBSeTpi/8gn5Kf6dxqn+o=
|
||||
github.com/couchbase/moss v0.2.0/go.mod h1:9MaHIaRuy9pvLPUJxB8sh8OrLfyDczECVL37grCIubs=
|
||||
github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
|
||||
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/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||
github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
|
||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/klauspost/cpuid v1.2.1 h1:vJi+O/nMdFt0vqm8NZBI6wzALWdA2X+egi0ogNyrC/w=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
||||
github.com/knz/go-libedit v1.10.1 h1:0pHpWtx9vcvC0xGZqEQlQdfSQs7WRlAjuPvk3fOZDCo=
|
||||
github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw=
|
||||
github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181zc=
|
||||
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
|
||||
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
@@ -37,20 +171,35 @@ github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcY
|
||||
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.uber.org/automaxprocs v1.5.3 h1:kWazyxZUrS3Gs4qUpbwo5kEIMGe/DAvi5Z4tl2NW4j8=
|
||||
go.uber.org/automaxprocs v1.5.3/go.mod h1:eRbA25aqJrxAbsLO0xy5jVwPt7FQnRgjW+efnwa1WM0=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
|
||||
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
@@ -61,6 +210,9 @@ golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
@@ -78,16 +230,29 @@ golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
|
||||
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
|
||||
golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
google.golang.org/genproto v0.0.0-20260622175928-b703f567277d h1:CP5omUq8AJTiWMrPKM1WRLJ7zZeXd9OPcQD3TbBNAyY=
|
||||
google.golang.org/genproto v0.0.0-20260622175928-b703f567277d/go.mod h1:DrwuGJgFSEVNpv3S5Q5VxhRTvdnjauw9GtvwVOEARfA=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260610212136-7ab31c22f7ad/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/errgo.v2 v2.1.0 h1:0vLT13EuvQ0hNvakwLuFZ/jYrLp5F3kcWHXdRggjCE8=
|
||||
nullprogram.com/x/optparse v1.0.0 h1:xGFgVi5ZaWOnYdac2foDT3vg0ZZC9ErXFV57mr4OHrI=
|
||||
rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
|
||||
+23
-10
@@ -28,11 +28,20 @@
|
||||
|
||||
> **目标**:任何线上问题在 5 分钟内能定位到根因节点。
|
||||
|
||||
### 1.1 分布式链路追踪 (Tracing) — ⚠️ 缺失
|
||||
### 1.1 分布式链路追踪 (Tracing) — ✅ 已实现
|
||||
|
||||
**现状**:仅有 `X-Request-ID` 透传和 `log.Printf` 散落日志,无法追踪一次请求跨 Gateway → NATS → Dispatcher → MCP 工具的完整调用链。
|
||||
**已落地**:OpenTelemetry 全链路追踪,一次任务从 Gateway → NATS → Dispatcher → MCP 工具的调用链在 Jaeger 里呈现为一棵 span 树。
|
||||
- 启动器 `sundynix-shared/otelx`:各服务 main 调 `otelx.Init(ctx, "服务名")`,OTLP/HTTP 导出到 `OTEL_EXPORTER_OTLP_ENDPOINT`(默认 `localhost:4318`,docker 里的 Jaeger);`OTEL_SDK_DISABLED=true` 整体关闭导出(仍保留传播器)。Jaeger UI: `http://localhost:16686`。
|
||||
- **HTTP 入口**:gateway 挂 `otelgin` 中间件,每个请求一个 server span(链路根 + 提取上游 traceparent)。
|
||||
- **NATS 跨进程传播**(关键):`sundynix-shared/bus` 在 `PublishTask`/`CallTool` 把 W3C traceparent 注入消息头,`ConsumeTasks`/`ServeTool` 抽出续上——这是链路能跨总线连成一棵树的核心。
|
||||
- **Eino 图埋点**:`task.execute` → 每个 `node.<kind>` → `tool.call`/`tool.serve`(跨服务成对)→ `llm.stream`/`llm.generate`,层级与 ExecEvent 对齐。
|
||||
- 实测一次「input→retriever→agent」任务出 14 span / 3 服务,瓶颈(kb_search 692ms、llm 1597ms)一眼可见。
|
||||
|
||||
**需引入**:
|
||||
- **日志 ↔ 链路互跳**:`otelx.SetupSlog(服务名)` 安装链路感知的全局 slog(JSON + service 标签),`traceHandler` 让任意 `slog.InfoContext(ctx,...)` 自动带 `trace_id`/`span_id`。gateway 访问日志、dispatcher 任务生命周期日志均已带 trace_id——Jaeger 里拿 trace_id 即可过滤日志,反之从报错日志跳回链路。实测一致命中。
|
||||
|
||||
> Prometheus(metrics)+ slog(logs,带 trace_id)+ OTel(traces)= 可观测性三件套已闭环。下一步可补:mcp-py 接入、采样策略、legacy log.Printf 渐进迁移到 ctx-aware slog。
|
||||
|
||||
**实现细节(原规划,已照此落地)**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
@@ -108,18 +117,22 @@ Grafana Dashboard 体系:
|
||||
|
||||
## 二、安全加固 (Security Hardening)
|
||||
|
||||
### 2.1 密钥管理 — 🔴 高优先
|
||||
### 2.1 密钥管理 — ⚠️ 部分(API Key 加密已落地)
|
||||
|
||||
**现状**:
|
||||
- `ModelConfig.APIKey` 明文存 PostgreSQL、经 NATS 明文广播到各消费方
|
||||
- JWT 签名密钥硬编码或环境变量
|
||||
- 配置文件中明文 DSN(含密码)
|
||||
**已实现**:
|
||||
- ✅ **LLM `api_key` 加密存储 + 端到端密文**:`sundynix-shared/secrets`(AES-256-GCM,密钥由 `SUNDYNIX_SECRET_KEY` 经 SHA-256 派生)。
|
||||
网关保存时加密落库(`enc:1:` 前缀),密文经 NATS 原样下发,消费方(dispatcher/mcp-go)在 bus 层解密——
|
||||
**磁盘与线缆上均无明文**,仅在构建 LLM 客户端时于内存短暂还原。历史明文行自动透传,下次保存即升级为密文。
|
||||
生产模式(`APP_ENV=production` / `GIN_MODE=release`)下未设 `SUNDYNIX_SECRET_KEY` 直接 fatal(`secrets.MustHaveKeyInProd`);
|
||||
**各服务必须配置相同的密钥**。
|
||||
- ✅ JWT 签名密钥:生产强制 `JWT_SECRET`(未设即 fatal)。
|
||||
|
||||
**需引入**:
|
||||
**仍待引入**:
|
||||
- 配置文件中明文 DSN(含密码)→ 见下表
|
||||
|
||||
| 方案 | 说明 | 推荐 |
|
||||
|---|---|---|
|
||||
| **Vault / SOPS** | API Key 加密存储,运行时经 Vault API 获取 | HashiCorp Vault(生产首选)或 Mozilla SOPS(轻量) |
|
||||
| **Vault / SOPS** | 进一步把 `SUNDYNIX_SECRET_KEY` 等根密钥托管、支持轮换 | HashiCorp Vault(生产首选)或 Mozilla SOPS(轻量) |
|
||||
| **K8s Secrets + CSI** | 集群内密钥挂载 | Secrets Store CSI Driver + Vault Provider |
|
||||
| **NATS TLS** | NATS 客户端↔服务端全链路 TLS,消息传输加密 | 配置 nats-server TLS + 客户端证书 |
|
||||
| **PG SSL** | 数据库连接启用 `sslmode=require` | 当前 DSN 为 `sslmode=disable` |
|
||||
|
||||
+11
-9
@@ -11,12 +11,12 @@
|
||||
|
||||
| 层级 | 模块 | 语言/框架 | 核心依赖 |
|
||||
|---|---|---|---|
|
||||
| **1. 客户端** | `sundynix-desktop` | Wails + React 19 + TypeScript | @xyflow/react (React Flow), shadcn/ui, TailwindCSS 3, lucide-react, react-force-graph-2d, Vite 5 |
|
||||
| **1. 客户端** | `sundynix-desktop` | Wails + React 19 + TypeScript | @xyflow/react (React Flow), 自建 UI 原语(`src/ui`,非 shadcn), TailwindCSS 3, lucide-react, react-force-graph-2d, Vite 5 |
|
||||
| **1b. 运维控制台** | `sundynix-admin` | React 19 + TS + Vite | react-router-dom 7, TailwindCSS |
|
||||
| **2. 网关** | `sundynix-gateway` | Go · Gin | GORM + PostgreSQL 16, Redis 7, NATS JetStream, 雪花 ID, bcrypt 鉴权 |
|
||||
| **3. 消息总线** | NATS Server | Go | NATS 2 (Alpine), JetStream 持久化 |
|
||||
| **4. 调度器** | `sundynix-dispatcher` | Go · CloudWeGo Eino | eino (图编排/ReAct Agent), nats.go, 熔断器, LLM 自动化评测 |
|
||||
| **5a. MCP 工具 (I/O)** | `sundynix-mcp-go` | Go | Milvus SDK, Bleve, Neo4j Driver, UniOffice (.docx), Snowflake ID, GORM |
|
||||
| **5a. MCP 工具 (I/O)** | `sundynix-mcp-go` | Go | Milvus SDK, Bleve, Neo4j Driver, 自建零依赖 OOXML(.docx,刻意不用商业授权的 UniOffice), Snowflake ID, GORM |
|
||||
| **5b. MCP 工具 (算法)** | `sundynix-mcp-py` | Python ≥3.11 | nats-py, python-docx, openpyxl, pypdf, docker (沙箱) |
|
||||
| **共享层** | `sundynix-shared` | Go | nats.go, JetStream, 内嵌 NATS (devnats) |
|
||||
| **基础设施** | Docker Compose | - | NATS, PostgreSQL 16, Redis 7, Milvus 2.4, Neo4j 5, MinIO |
|
||||
@@ -53,7 +53,7 @@
|
||||
|
||||
### 3.3 报告生成
|
||||
- **多步编排**:规划大纲 → 各章节并行(有界并发=4)→ RAG 检索 + LLM 撰写 → 汇聚渲染
|
||||
- **Word 渲染**:UniOffice 渲染 .docx 文档,一键下载
|
||||
- **Word 渲染**:自建零依赖 OOXML 渲染 .docx 文档(不引第三方 Office 库),一键下载
|
||||
- **流式进度**:规划/撰写进度实时流回客户端
|
||||
- **报告源持久化**:title + sections 持久存储,支持导出 Word/PDF/Markdown
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
- **ExecEvent 轨迹**:每个节点的生命周期事件(start/end/error/info)+ 耗时
|
||||
- **双流并行**:Token 流 (`sundynix.streams.<id>`) + 执行事件流 (`sundynix.exec.<id>`) 分离
|
||||
- **实时节点点亮**:前端 SSE 订阅轨迹,逐节点展示执行状态
|
||||
- **Token 流可回放 + 断点续传**:网关把 Token 流落 Redis Stream,SSE 带 `Last-Event-ID`,连晚/刷新/网络抖动重连均不丢 token(Redis 降级时回退 live NATS)
|
||||
|
||||
### 3.6 安全与工程化护栏
|
||||
- **熔断降级** (`CircuitBreaker`):连续失败触发熔断,快速拒绝新任务 + 友好提示
|
||||
@@ -87,6 +88,7 @@
|
||||
- **Wails 原生应用**:macOS/Windows/Linux 跨平台桌面端
|
||||
- **Go/TS 强绑定**:前端调用 Go 后端(本地文件 I/O 等)
|
||||
- **命令面板**:⌘K / Ctrl+K 全局命令面板(键盘优先工作站)
|
||||
- **GitHub Releases 分发 + 自动检查更新**:打 `vX.Y.Z` 标签经 release workflow 自动构建 mac/win 安装包发布到 Releases;桌面端启动查 `releases/latest`,有新版弹横幅引导下载
|
||||
|
||||
---
|
||||
|
||||
@@ -173,15 +175,15 @@
|
||||
| 问题 | 现状 | 建议 |
|
||||
|---|---|---|
|
||||
| **API Key 明文传输/存储** | ModelConfig 中 `api_key` 明文存 PG、经 NATS 广播 | API Key 加密存储(AES-256),NATS 传输走 TLS |
|
||||
| **CORS 配置** | 未见显式 CORS 配置 | 生产环境需严格限制允许源 |
|
||||
| **Rate Limiting** | Redis 存 session/rate limit 但实现程度不明 | 确保关键 API(登录/任务提交)有完善的速率限制 |
|
||||
| **CORS 收紧** | 已有显式 `cors()` + `CORS_ALLOW_ORIGIN`(缺省 `*` 仅供开发) | 生产环境把 `CORS_ALLOW_ORIGIN` 设为具体源 |
|
||||
| **Rate Limiting** | 已实现:Redis 滑动窗口、按客户端 IP 每分钟限流(`middleware.RateLimit` + `store.Allow`) | 可对登录/任务提交等关键 API 单独配更严阈值 |
|
||||
| **依赖安全审计** | 无 dependabot / 漏洞扫描 | 启用 GitHub Dependabot + `go vet` / `govulncheck` CI |
|
||||
|
||||
### 6.5 运维与部署
|
||||
|
||||
| 问题 | 现状 | 建议 |
|
||||
|---|---|---|
|
||||
| **无 CI/CD 流水线** | `.github/` 目录存在但未见完整 workflow | 配置 GitHub Actions:lint → test → build → deploy |
|
||||
| **CI/CD(部分完成)** | ✅ 已加 `ci.yml`(go build+vet+test / 前端 tsc / mcp-py 守卫)+ `release.yml`(tag 触发构建发布到 GitHub Releases) | 后续补:自动部署、govulncheck/Dependabot 安全扫描 |
|
||||
| **无 Kubernetes 部署** | 仅 docker-compose(开发环境) | 补充 Helm Chart 或 Kustomize,面向生产集群 |
|
||||
| **健康检查不统一** | dispatcher 走 NATS ping,其它走 HTTP | 统一为 Kubernetes 标准的 liveness/readiness probe |
|
||||
| **无数据备份策略** | PG/Milvus/Neo4j 数据卷无备份配置 | 配置 pg_dump 定时备份 + 对象存储归档 |
|
||||
@@ -197,7 +199,7 @@
|
||||
| **Wails** | ⭐⭐⭐⭐ | Go+WebView 跨平台桌面端,避免 Electron 臃肿;但 WebView 兼容性不如 Electron |
|
||||
| **Milvus + Bleve + Neo4j** | ⭐⭐⭐⭐ | 三路 RAG 架构领先;但三套存储的运维复杂度高 |
|
||||
| **React Flow** | ⭐⭐⭐⭐⭐ | 低代码编排标配,生态成熟,@xyflow/react v12 性能好 |
|
||||
| **Python MCP 层** | ⭐⭐⭐ | 算法型工具用 Python 合理;但 MCP/MinerU 仍为桩,实际价值待兑现 |
|
||||
| **Python MCP 层** | ⭐⭐⭐ | 算法型工具用 Python 合理;代码沙箱已可用,但 MinerU 多模态解析仍为桩 |
|
||||
|
||||
---
|
||||
|
||||
@@ -229,7 +231,7 @@
|
||||
**主要风险**在于:
|
||||
1. 基础设施复杂度高(6 个存储组件),运维压力大
|
||||
2. 前端缺少测试和路由,随功能增长技术债务会快速累积
|
||||
3. Python MCP 层实现深度不足,核心差异化功能(MinerU OCR、安全沙箱)仍为桩
|
||||
4. 缺少 CI/CD、链路追踪、集中配置等生产级工程化基础设施
|
||||
3. Python MCP 层较薄:MinerU/PaddleOCR 多模态解析仍为桩(代码执行沙箱已实现可用,run_code 已接入自主 agent)
|
||||
4. 链路追踪、集中配置、k8s 部署、API Key 加密等生产级工程化设施待补(CI/CD 已具备)
|
||||
|
||||
**项目成熟度**:处于 **高完成度 MVP** 阶段,核心数据流和关键功能闭环已通,具备向生产环境演进的基础。
|
||||
|
||||
+2420
-1
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,9 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xyflow/react": "^12.3.0",
|
||||
@@ -16,13 +18,18 @@
|
||||
"react-force-graph-2d": "^1.29.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^5.4.0"
|
||||
"vite": "^5.4.0",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { Mock } from "vitest";
|
||||
|
||||
// mock 版本模块:控制 checkUpdate 返回值、监视 openExternal。
|
||||
vi.mock("../lib/version", () => ({
|
||||
checkUpdate: vi.fn(),
|
||||
openExternal: vi.fn(),
|
||||
}));
|
||||
import { UpdateBanner } from "./UpdateBanner";
|
||||
import { checkUpdate, openExternal } from "../lib/version";
|
||||
|
||||
const mockCheck = checkUpdate as Mock;
|
||||
const mockOpen = openExternal as Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCheck.mockReset();
|
||||
mockOpen.mockReset();
|
||||
});
|
||||
|
||||
describe("UpdateBanner", () => {
|
||||
it("无更新(checkUpdate→null)时不渲染任何东西", async () => {
|
||||
mockCheck.mockResolvedValue(null);
|
||||
const { container } = render(<UpdateBanner />);
|
||||
await waitFor(() => expect(mockCheck).toHaveBeenCalled());
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("有更新时显示版本号,点「前往下载」调 openExternal(url)", async () => {
|
||||
mockCheck.mockResolvedValue({ version: "1.2.3", url: "https://rel", notes: "" });
|
||||
render(<UpdateBanner />);
|
||||
expect(await screen.findByText("v1.2.3")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "前往下载" }));
|
||||
expect(mockOpen).toHaveBeenCalledWith("https://rel");
|
||||
});
|
||||
|
||||
it("点忽略后横幅消失", async () => {
|
||||
mockCheck.mockResolvedValue({ version: "1.2.3", url: "https://rel", notes: "" });
|
||||
render(<UpdateBanner />);
|
||||
expect(await screen.findByText("v1.2.3")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByTitle("忽略"));
|
||||
expect(screen.queryByText("v1.2.3")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { Node, Edge } from "@xyflow/react";
|
||||
import { exportDsl, validate } from "./dsl";
|
||||
|
||||
// 构造画布节点的小工具(只填导出/校验关心的字段)。
|
||||
function node(id: string, kind: string, config?: unknown, label?: string): Node {
|
||||
return { id, position: { x: 0, y: 0 }, data: { kind, label, config } } as unknown as Node;
|
||||
}
|
||||
function edge(source: string, target: string, sourceHandle?: string): Edge {
|
||||
return { id: `${source}-${target}`, source, target, sourceHandle } as unknown as Edge;
|
||||
}
|
||||
|
||||
describe("exportDsl", () => {
|
||||
it("把节点映射为 {id,kind,label,config}", () => {
|
||||
const dsl = exportDsl([node("in", "input", { text: "hi" }, "输入")], []);
|
||||
expect(dsl.version).toBe("1");
|
||||
expect(dsl.nodes).toEqual([{ id: "in", kind: "input", label: "输入", config: { text: "hi" } }]);
|
||||
});
|
||||
|
||||
it("缺失 config 时回退为空对象", () => {
|
||||
const dsl = exportDsl([node("in", "input")], []);
|
||||
expect(dsl.nodes[0].config).toEqual({});
|
||||
});
|
||||
|
||||
it("仅在存在 sourceHandle 时才带上该字段(branch 真/假边)", () => {
|
||||
const dsl = exportDsl(
|
||||
[node("b", "branch"), node("t", "agent"), node("f", "agent")],
|
||||
[edge("b", "t", "true"), edge("b", "f")],
|
||||
);
|
||||
expect(dsl.edges[0]).toEqual({ source: "b", target: "t", sourceHandle: "true" });
|
||||
expect(dsl.edges[1]).toEqual({ source: "b", target: "f" });
|
||||
expect("sourceHandle" in dsl.edges[1]).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validate", () => {
|
||||
it("空画布报 error", () => {
|
||||
const issues = validate([], []);
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0].level).toBe("error");
|
||||
});
|
||||
|
||||
it("单节点不报孤立警告(length>1 才校验连线)", () => {
|
||||
const issues = validate([node("in", "input", { text: "hi" })], []);
|
||||
expect(issues.some((i) => i.msg.includes("孤立"))).toBe(false);
|
||||
});
|
||||
|
||||
it("多节点中未连线的节点报孤立 warn", () => {
|
||||
const issues = validate(
|
||||
[node("a", "input", { text: "hi" }), node("b", "agent", { system: "x" })],
|
||||
[], // 都没连
|
||||
);
|
||||
expect(issues.filter((i) => i.msg.includes("孤立"))).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("必填项缺失报 warn(input.text 为必填)", () => {
|
||||
const issues = validate([node("in", "input", { text: "" })], []);
|
||||
expect(issues.some((i) => i.level === "warn" && i.msg.includes("缺必填项"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { ExecEvent } from "./api";
|
||||
import { deriveNodes } from "./run";
|
||||
|
||||
let seq = 0;
|
||||
function ev(node: string, phase: string, extra: Partial<ExecEvent> = {}): ExecEvent {
|
||||
return { seq: seq++, ts: 0, node, kind: extra.kind ?? "system", phase, label: extra.label ?? node, ...extra };
|
||||
}
|
||||
|
||||
describe("deriveNodes(ExecEvent 流 → 节点轨迹)", () => {
|
||||
it("start → running", () => {
|
||||
const [n] = deriveNodes([ev("a", "start")]);
|
||||
expect(n.status).toBe("running");
|
||||
});
|
||||
|
||||
it("start→end → done 且带耗时", () => {
|
||||
const [n] = deriveNodes([ev("a", "start"), ev("a", "end", { ms: 123, detail: "ok" })]);
|
||||
expect(n.status).toBe("done");
|
||||
expect(n.ms).toBe(123);
|
||||
expect(n.detail).toBe("ok");
|
||||
});
|
||||
|
||||
it("error → error 状态", () => {
|
||||
const [n] = deriveNodes([ev("a", "start"), ev("a", "error", { ms: 5, detail: "boom" })]);
|
||||
expect(n.status).toBe("error");
|
||||
expect(n.detail).toBe("boom");
|
||||
});
|
||||
|
||||
it("已 error 的节点不会被后续 start 覆盖回 running", () => {
|
||||
const [n] = deriveNodes([ev("a", "error"), ev("a", "start")]);
|
||||
expect(n.status).toBe("error");
|
||||
});
|
||||
|
||||
it("info 事件累计到 notes", () => {
|
||||
const [n] = deriveNodes([
|
||||
ev("a", "info", { detail: "检索到 3 条" }),
|
||||
ev("a", "info", { detail: "命中向量库" }),
|
||||
]);
|
||||
expect(n.notes).toEqual(["检索到 3 条", "命中向量库"]);
|
||||
});
|
||||
|
||||
it("按首次出现顺序聚合多个节点", () => {
|
||||
const nodes = deriveNodes([ev("first", "start"), ev("second", "start"), ev("first", "end")]);
|
||||
expect(nodes.map((n) => n.node)).toEqual(["first", "second"]);
|
||||
});
|
||||
|
||||
it("后续事件可更新 label", () => {
|
||||
const [n] = deriveNodes([ev("a", "start", { label: "旧" }), ev("a", "end", { label: "新" })]);
|
||||
expect(n.label).toBe("新");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { isNewer, checkUpdate, openExternal, APP_VERSION } from "./version";
|
||||
|
||||
describe("isNewer(语义化版本比较)", () => {
|
||||
it.each([
|
||||
["1.0.1", "1.0.0", true],
|
||||
["1.1.0", "1.0.9", true],
|
||||
["2.0.0", "1.9.9", true],
|
||||
["1.0.0", "1.0.0", false],
|
||||
["1.0.0", "1.0.1", false],
|
||||
["0.9.9", "1.0.0", false],
|
||||
])("isNewer(%s, %s) === %s", (a, b, want) => {
|
||||
expect(isNewer(a as string, b as string)).toBe(want);
|
||||
});
|
||||
|
||||
it("缺省的 patch 位按 0 处理", () => {
|
||||
expect(isNewer("1.1", "1.0.9")).toBe(true);
|
||||
expect(isNewer("1.0", "1.0.0")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkUpdate", () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
function stubFetch(impl: () => Promise<unknown> | unknown) {
|
||||
vi.stubGlobal("fetch", vi.fn().mockImplementation(impl));
|
||||
}
|
||||
|
||||
it("有更新版本 → 返回 ReleaseInfo", async () => {
|
||||
stubFetch(() => ({
|
||||
ok: true,
|
||||
json: async () => ({ tag_name: "v9.9.9", html_url: "https://x/rel", body: "日志" }),
|
||||
}));
|
||||
const r = await checkUpdate();
|
||||
expect(r).toEqual({ version: "9.9.9", url: "https://x/rel", notes: "日志" });
|
||||
});
|
||||
|
||||
it("同版本 → null", async () => {
|
||||
stubFetch(() => ({ ok: true, json: async () => ({ tag_name: `v${APP_VERSION}` }) }));
|
||||
expect(await checkUpdate()).toBeNull();
|
||||
});
|
||||
|
||||
it("更旧版本 → null", async () => {
|
||||
stubFetch(() => ({ ok: true, json: async () => ({ tag_name: "v0.0.1" }) }));
|
||||
expect(await checkUpdate()).toBeNull();
|
||||
});
|
||||
|
||||
it("HTTP 非 2xx(404/403 限流)→ 静默 null", async () => {
|
||||
stubFetch(() => ({ ok: false, status: 404 }));
|
||||
expect(await checkUpdate()).toBeNull();
|
||||
});
|
||||
|
||||
it("网络异常(离线)→ 静默 null", async () => {
|
||||
stubFetch(() => {
|
||||
throw new Error("offline");
|
||||
});
|
||||
expect(await checkUpdate()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("openExternal", () => {
|
||||
afterEach(() => {
|
||||
delete (window as unknown as { runtime?: unknown }).runtime;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("有 Wails runtime → 走 BrowserOpenURL", () => {
|
||||
const spy = vi.fn();
|
||||
(window as unknown as { runtime: { BrowserOpenURL: (u: string) => void } }).runtime = {
|
||||
BrowserOpenURL: spy,
|
||||
};
|
||||
openExternal("https://a");
|
||||
expect(spy).toHaveBeenCalledWith("https://a");
|
||||
});
|
||||
|
||||
it("无 runtime(浏览器模式)→ 回退 window.open", () => {
|
||||
const open = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
openExternal("https://b");
|
||||
expect(open).toHaveBeenCalledWith("https://b", "_blank");
|
||||
});
|
||||
});
|
||||
@@ -37,8 +37,8 @@ export function openExternal(url: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
// isNewer 语义化版本比较:a 是否比 b 新(按 major.minor.patch)。
|
||||
function isNewer(a: string, b: string): boolean {
|
||||
// isNewer 语义化版本比较:a 是否比 b 新(按 major.minor.patch)。导出以便单测覆盖版本判定矩阵。
|
||||
export function isNewer(a: string, b: string): boolean {
|
||||
const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
|
||||
const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// Vitest 全局测试初始化:引入 jest-dom 断言(toBeInTheDocument 等)。
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
@@ -14,5 +14,6 @@
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test"]
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/// <reference types="vitest/config" />
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "node:path";
|
||||
@@ -7,4 +8,11 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: { "@": path.resolve(__dirname, "./src") },
|
||||
},
|
||||
// 单元/组件测试:纯逻辑 + 关键组件(jsdom 环境)。运行:npm test
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: ["./src/test/setup.ts"],
|
||||
include: ["src/**/*.{test,spec}.{ts,tsx}"],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -14,9 +14,18 @@ import (
|
||||
"github.com/sundynix/sundynix-dispatcher/internal/harness"
|
||||
"github.com/sundynix/sundynix-dispatcher/internal/llm"
|
||||
dnats "github.com/sundynix/sundynix-dispatcher/internal/nats"
|
||||
"github.com/sundynix/sundynix-shared/otelx"
|
||||
"github.com/sundynix/sundynix-shared/secrets"
|
||||
)
|
||||
|
||||
func main() {
|
||||
secrets.MustHaveKeyInProd() // 生产须配 SUNDYNIX_SECRET_KEY 以解密下发的 api_key 密文
|
||||
otelx.SetupSlog("sundynix-dispatcher") // 结构化日志 + 链路感知(任务日志带 trace_id)
|
||||
|
||||
// 链路追踪:消费 span 续上 gateway 的 trace,再向下展开节点/工具/LLM span。
|
||||
shutdownTrace, _ := otelx.Init(context.Background(), "sundynix-dispatcher")
|
||||
defer func() { _ = shutdownTrace(context.Background()) }()
|
||||
|
||||
natsURL := envOr("NATS_URL", "nats://localhost:4222")
|
||||
|
||||
pool := llm.NewPool() // LLM Pool: vLLM / Ollama 集群
|
||||
@@ -29,17 +38,12 @@ func main() {
|
||||
sub := dnats.MustConnect(natsURL)
|
||||
defer sub.Close()
|
||||
|
||||
// 配置控制面:启动时取激活模型配置,并订阅热更新。
|
||||
cctx, ccancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
if cfg, _ := sub.RequestModelConfig(cctx); cfg != nil {
|
||||
pool.SetConfig(cfg)
|
||||
} else {
|
||||
log.Println("[dispatcher] 未取到在线模型配置,降级桩运行(控制台配置后将热更新)")
|
||||
}
|
||||
ccancel()
|
||||
// 配置控制面:先订阅热更新,再后台重试拉初始配置(容忍 gateway 晚于本服务启动,
|
||||
// 避免一次性请求扑空后只能干等热更新 → 降级桩跑全程)。
|
||||
if _, err := sub.SubscribeModelConfigUpdated(pool.SetConfig); err != nil {
|
||||
log.Printf("[dispatcher] subscribe model config: %v", err)
|
||||
}
|
||||
go sub.FetchModelConfigWithRetry(context.Background(), pool.SetConfig)
|
||||
|
||||
// sub 同时作为 Token 回流(TokenSink)、MCP 工具调用(ToolCaller)、执行事件(ExecSink)与任务状态回写(StatusSink)出口。
|
||||
orch, err := eino.NewOrchestrator(pool, breaker, eval, sub, sub, sub, sub)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/sundynix/sundynix-dispatcher
|
||||
|
||||
go 1.25.0
|
||||
go 1.25.8
|
||||
|
||||
require (
|
||||
github.com/cloudwego/eino v0.9.9
|
||||
@@ -14,11 +14,14 @@ require (
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/eino-contrib/jsonschema v1.0.3 // indirect
|
||||
github.com/evanphx/json-patch v0.5.2 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/goph/emperror v0.17.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
@@ -42,6 +45,10 @@ require (
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
github.com/yargevad/filepathx v1.0.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
golang.org/x/arch v0.11.0 // indirect
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect
|
||||
|
||||
@@ -16,6 +16,8 @@ github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9V
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/cloudwego/eino v0.9.9 h1:x63hvRif6ANPh9YEPoTIrp1potEeoLQFAjOclKaX/Kg=
|
||||
@@ -38,6 +40,11 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo
|
||||
github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ=
|
||||
github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI=
|
||||
github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -142,6 +149,14 @@ github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJ
|
||||
github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE=
|
||||
github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc=
|
||||
github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
|
||||
go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
|
||||
golang.org/x/arch v0.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4=
|
||||
|
||||
@@ -8,10 +8,13 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/sundynix/sundynix-dispatcher/internal/dsl"
|
||||
"github.com/sundynix/sundynix-dispatcher/internal/harness"
|
||||
"github.com/sundynix/sundynix-shared/contract"
|
||||
"github.com/sundynix/sundynix-shared/otelx"
|
||||
)
|
||||
|
||||
// defaultAgentSystem 是 agent 节点未填系统提示词时的兜底。
|
||||
@@ -99,6 +102,13 @@ func (o *Orchestrator) runGraph(ctx context.Context, t *contract.Task, tr *execT
|
||||
if !active[n.ID] {
|
||||
continue // 被 branch 剪掉的下游,不执行
|
||||
}
|
||||
// 每个节点一个 span:下游的工具调用 / LLM span 会自动挂到本节点之下,
|
||||
// Jaeger 里即呈现「节点 → 工具 → embedding/milvus」的层级。
|
||||
nctx, nspan := otelx.Tracer().Start(ctx, "node."+n.Kind,
|
||||
trace.WithAttributes(
|
||||
attribute.String("sundynix.node.id", n.ID),
|
||||
attribute.String("sundynix.node.kind", n.Kind),
|
||||
))
|
||||
propagate := targetsOf(outE[n.ID]) // 默认激活全部出边;branch 会改写
|
||||
switch n.Kind {
|
||||
case "input":
|
||||
@@ -108,39 +118,40 @@ func (o *Orchestrator) runGraph(ctx context.Context, t *contract.Task, tr *execT
|
||||
tr.info("input:"+n.ID, "system", labelOf(n, "输入"), truncate(b.query, 80))
|
||||
case "memory":
|
||||
if cbool(n.Config, "profile") {
|
||||
b.profile = o.fetchMemory(ctx, b.uid, b.query)
|
||||
b.profile = o.fetchMemory(nctx, b.uid, b.query)
|
||||
}
|
||||
if cbool(n.Config, "history") {
|
||||
b.history = o.fetchHistory(ctx, b.sid)
|
||||
b.history = o.fetchHistory(nctx, b.sid)
|
||||
}
|
||||
tr.info("memory:"+n.ID, "memory", labelOf(n, "记忆"),
|
||||
fmt.Sprintf("画像 %d 字 · 历史 %d 条", len([]rune(b.profile)), len(b.history)))
|
||||
case "retriever":
|
||||
o.retrieverNode(ctx, n, b, tr)
|
||||
o.retrieverNode(nctx, n, b, tr)
|
||||
case "tool":
|
||||
o.execToolNode(ctx, t.ID, n, b, tr)
|
||||
o.execToolNode(nctx, t.ID, n, b, tr)
|
||||
case "agent":
|
||||
sys := firstNonEmpty(cstr(n.Config, "system"), plan.System)
|
||||
if cbool(n.Config, "autonomous") { // 开启自主工具 → ReAct(模型自己选工具)
|
||||
o.runReactAgent(ctx, t.ID, b, sys, n, tr, "agent:"+n.ID)
|
||||
o.runReactAgent(nctx, t.ID, b, sys, n, tr, "agent:"+n.ID)
|
||||
} else {
|
||||
o.runConversation(ctx, t.ID, b, sys, tr, "agent:"+n.ID)
|
||||
o.runConversation(nctx, t.ID, b, sys, tr, "agent:"+n.ID)
|
||||
}
|
||||
case "aggregate":
|
||||
merged := aggregate(cstr(n.Config, "strategy"), append(append([]string{}, b.refs...), b.toolOut...))
|
||||
b.refs, b.toolOut = merged, nil
|
||||
tr.info("aggregate:"+n.ID, "system", labelOf(n, "汇聚"), "策略:"+firstNonEmpty(cstr(n.Config, "strategy"), "拼接"))
|
||||
case "render":
|
||||
o.renderNode(ctx, t.ID, n, b, tr)
|
||||
o.renderNode(nctx, t.ID, n, b, tr)
|
||||
case "branch":
|
||||
propagate = o.branchNode(n, b, outE[n.ID], nodeByID, tr)
|
||||
case "map":
|
||||
o.mapNode(ctx, t.ID, n, b, tr)
|
||||
o.mapNode(nctx, t.ID, n, b, tr)
|
||||
case "output":
|
||||
tr.info("output:"+n.ID, "system", labelOf(n, "输出"), "目标:"+firstNonEmpty(cstr(n.Config, "target"), "屏幕"))
|
||||
default:
|
||||
tr.info(n.Kind+":"+n.ID, "system", labelOf(n, n.Kind), "未识别节点,跳过")
|
||||
}
|
||||
nspan.End()
|
||||
for _, tgt := range propagate {
|
||||
active[tgt] = true
|
||||
}
|
||||
|
||||
@@ -7,16 +7,21 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cloudwego/eino/components/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/sundynix/sundynix-dispatcher/internal/dsl"
|
||||
"github.com/sundynix/sundynix-dispatcher/internal/harness"
|
||||
"github.com/sundynix/sundynix-dispatcher/internal/llm"
|
||||
"github.com/sundynix/sundynix-shared/contract"
|
||||
"github.com/sundynix/sundynix-shared/otelx"
|
||||
)
|
||||
|
||||
// TokenSink 是 Token 流回流出口(由 NATS bus 实现)。
|
||||
@@ -103,6 +108,12 @@ func (o *Orchestrator) Handle(ctx context.Context, t *contract.Task) error {
|
||||
log.Printf("[eino] 跳过空任务(无 id)")
|
||||
return nil
|
||||
}
|
||||
|
||||
// 链路根(dispatcher 侧):续上 gateway 经 NATS 传来的 trace,覆盖整个图执行。
|
||||
ctx, span := otelx.Tracer().Start(ctx, "task.execute",
|
||||
trace.WithAttributes(attribute.String("sundynix.task_id", t.ID)))
|
||||
defer span.End()
|
||||
|
||||
tr := o.tracer(t.ID)
|
||||
defer tr.done()
|
||||
|
||||
@@ -127,13 +138,16 @@ func (o *Orchestrator) Handle(ctx context.Context, t *contract.Task) error {
|
||||
o.finishStatus(t.ID, err)
|
||||
return err
|
||||
}
|
||||
log.Printf("[eino] task %s received (graph=%d bytes), 按图执行(拓扑+连线+分支)...", t.ID, len(t.Graph))
|
||||
// ctx 携带 task.execute span → 这些任务生命周期日志自动带 trace_id,可与 Jaeger 链路互跳。
|
||||
slog.InfoContext(ctx, "task received", "task_id", t.ID, "graph_bytes", len(t.Graph))
|
||||
tr.info("task", "system", "任务受理", fmt.Sprintf("DSL %d 字节,按图执行", len(t.Graph)))
|
||||
|
||||
// 按 DSL 图执行:compose.Graph(EINO_COMPOSE=1)或自研 graph.go(默认);agent 节点流式回流 token。
|
||||
answer, err := o.executeGraph(tctx, t, tr)
|
||||
if err != nil {
|
||||
log.Printf("[eino] task %s graph error: %v", t.ID, err)
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
slog.ErrorContext(ctx, "task graph error", "task_id", t.ID, "err", err.Error())
|
||||
_ = o.sink.CompleteStream(t.ID)
|
||||
o.breaker.Report(false)
|
||||
o.finishStatus(t.ID, err)
|
||||
@@ -143,7 +157,7 @@ func (o *Orchestrator) Handle(ctx context.Context, t *contract.Task) error {
|
||||
if cerr := o.sink.CompleteStream(t.ID); cerr != nil {
|
||||
log.Printf("[eino] complete stream failed: %v", cerr)
|
||||
}
|
||||
log.Printf("[eino] task %s done (%d 字答复)", t.ID, len([]rune(answer)))
|
||||
slog.InfoContext(ctx, "task done", "task_id", t.ID, "answer_runes", len([]rune(answer)))
|
||||
o.breaker.Report(true)
|
||||
o.finishStatus(t.ID, nil)
|
||||
|
||||
|
||||
@@ -13,8 +13,11 @@ import (
|
||||
"github.com/cloudwego/eino-ext/components/model/openai"
|
||||
"github.com/cloudwego/eino/components/model"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/sundynix/sundynix-shared/contract"
|
||||
"github.com/sundynix/sundynix-shared/otelx"
|
||||
)
|
||||
|
||||
// requestTimeout 是单次推理请求的上限。
|
||||
@@ -102,8 +105,15 @@ func (p *Pool) ChatStream(ctx context.Context, msgs []ChatMessage, onToken func(
|
||||
if cm == nil {
|
||||
return fmt.Errorf("no model configured")
|
||||
}
|
||||
ctx, span := otelx.Tracer().Start(ctx, "llm.stream",
|
||||
trace.WithAttributes(
|
||||
attribute.String("llm.model", p.ModelName()),
|
||||
attribute.Int("llm.messages", len(msgs)),
|
||||
))
|
||||
defer span.End()
|
||||
sr, err := cm.Stream(ctx, toSchema(msgs))
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return fmt.Errorf("llm stream: %w", err)
|
||||
}
|
||||
defer sr.Close()
|
||||
@@ -128,8 +138,15 @@ func (p *Pool) Chat(ctx context.Context, msgs []ChatMessage) (string, error) {
|
||||
if cm == nil {
|
||||
return "", fmt.Errorf("no model configured")
|
||||
}
|
||||
ctx, span := otelx.Tracer().Start(ctx, "llm.generate",
|
||||
trace.WithAttributes(
|
||||
attribute.String("llm.model", p.ModelName()),
|
||||
attribute.Int("llm.messages", len(msgs)),
|
||||
))
|
||||
defer span.End()
|
||||
out, err := cm.Generate(ctx, toSchema(msgs))
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return "", fmt.Errorf("llm generate: %w", err)
|
||||
}
|
||||
return out.Content, nil
|
||||
|
||||
@@ -91,4 +91,9 @@ func (s *Subscriber) SubscribeModelConfigUpdated(onUpdate func(*contract.ModelCo
|
||||
return s.inner.SubscribeConfigUpdated(contract.ConfigKindChat, onUpdate)
|
||||
}
|
||||
|
||||
// FetchModelConfigWithRetry 后台重试拉取初始对话模型配置(容忍 gateway 晚于 dispatcher 启动)。
|
||||
func (s *Subscriber) FetchModelConfigWithRetry(ctx context.Context, apply func(*contract.ModelConfig)) {
|
||||
s.inner.RequestConfigWithRetry(ctx, contract.ConfigKindChat, apply)
|
||||
}
|
||||
|
||||
func (s *Subscriber) Close() { s.inner.Close() }
|
||||
|
||||
@@ -11,9 +11,18 @@ import (
|
||||
"github.com/sundynix/sundynix-gateway/internal/router"
|
||||
"github.com/sundynix/sundynix-gateway/internal/store"
|
||||
"github.com/sundynix/sundynix-shared/contract"
|
||||
"github.com/sundynix/sundynix-shared/otelx"
|
||||
"github.com/sundynix/sundynix-shared/secrets"
|
||||
)
|
||||
|
||||
func main() {
|
||||
secrets.MustHaveKeyInProd() // 生产须配 SUNDYNIX_SECRET_KEY 以加密落库的 api_key
|
||||
otelx.SetupSlog("sundynix-gateway") // 结构化日志 + 链路感知(日志带 trace_id)
|
||||
|
||||
// 链路追踪:HTTP 入口 + 跨 NATS 传播的根。退出前 flush 残留 span。
|
||||
shutdownTrace, _ := otelx.Init(context.Background(), "sundynix-gateway")
|
||||
defer func() { _ = shutdownTrace(context.Background()) }()
|
||||
|
||||
natsURL := envOr("NATS_URL", "nats://localhost:4222")
|
||||
pgDSN := envOr("POSTGRES_DSN", "postgres://sundynix:sundynix@localhost:5432/sundynix?sslmode=disable")
|
||||
redisAddr := envOr("REDIS_ADDR", "localhost:6379")
|
||||
|
||||
+26
-15
@@ -1,11 +1,11 @@
|
||||
module github.com/sundynix/sundynix-gateway
|
||||
|
||||
go 1.25.0
|
||||
go 1.25.8
|
||||
|
||||
require (
|
||||
github.com/bwmarrin/snowflake v0.3.0
|
||||
github.com/gin-contrib/sse v0.1.0
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/gin-contrib/sse v1.1.1
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/minio/minio-go/v7 v7.2.0
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
@@ -20,17 +20,20 @@ replace github.com/sundynix/sundynix-shared => ../sundynix-shared
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||
github.com/bytedance/sonic v1.15.1 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/cloudwego/base64x v0.1.7 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.2 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
@@ -40,12 +43,12 @@ require (
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.6 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
@@ -59,20 +62,28 @@ require (
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.1 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/tinylib/msgp v1.6.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/zeebo/xxh3 v1.1.0 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.69.0 // indirect
|
||||
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.11.0 // indirect
|
||||
golang.org/x/arch v0.27.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/ini.v1 v1.67.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -8,14 +8,22 @@ github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgIS
|
||||
github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw=
|
||||
github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
|
||||
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
|
||||
github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
@@ -24,10 +32,21 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
|
||||
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -36,8 +55,14 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
|
||||
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
@@ -64,6 +89,8 @@ github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
|
||||
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -76,6 +103,8 @@ 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/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.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
||||
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||
github.com/minio/highwayhash v1.0.3 h1:kbnuUMoHYyVl7szWjSxJnxw11k2U709jqFPPmIUyD6Q=
|
||||
@@ -115,6 +144,10 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
||||
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/redis/go-redis/v9 v9.20.0 h1:WnQYxLkgO2xiXTCJY0ldIiI8dNqCDlQAG+AtaH7a2a0=
|
||||
github.com/redis/go-redis/v9 v9.20.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
@@ -139,10 +172,24 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.mongodb.org/mongo-driver/v2 v2.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/5qi8=
|
||||
go.mongodb.org/mongo-driver/v2 v2.6.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.69.0 h1:u5gsfBL8t1Km4ROhQKAs0cA0t9CzUE7nfkASj/UjAtI=
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.69.0/go.mod h1:W6FFYCZQuntC5hxVesXpu7Ppd9sT0a84njildAijc+k=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
@@ -153,6 +200,8 @@ 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.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4=
|
||||
golang.org/x/arch v0.11.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU=
|
||||
golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
@@ -168,6 +217,8 @@ golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
|
||||
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -5,14 +5,33 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/sundynix/sundynix-gateway/internal/store"
|
||||
"github.com/sundynix/sundynix-shared/contract"
|
||||
"github.com/sundynix/sundynix-shared/secrets"
|
||||
)
|
||||
|
||||
// maskPrefix 是脱敏展示用的占位前缀;前端把列表里的脱敏 key 原样回传即视为「未改动」。
|
||||
const maskPrefix = "••••"
|
||||
|
||||
// existingModelKey 取某 id 模型库内存储的 api_key(密文,未解密);不存在返回空。
|
||||
func (h *Handler) existingModelKey(ctx context.Context, id string) string {
|
||||
if id == "" {
|
||||
return ""
|
||||
}
|
||||
rows, _ := h.db.ListModels(ctx, "")
|
||||
for _, m := range rows {
|
||||
if m.ID == id {
|
||||
return m.APIKey
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 控制面(运维管理):LLM 模型配置 CRUD + 测试连接 + 变更广播。
|
||||
// 表 sundynix_model 由 Gateway 持有;Dispatcher 经 NATS 取激活配置。
|
||||
|
||||
@@ -34,9 +53,10 @@ func (h *Handler) ListModels(c *gin.Context) {
|
||||
}
|
||||
out := make([]gin.H, 0, len(rows))
|
||||
for _, m := range rows {
|
||||
plain, _ := secrets.Decrypt(m.APIKey) // 库内为密文,脱敏前先还原以展示真实尾 4 位
|
||||
out = append(out, gin.H{
|
||||
"id": m.ID, "kind": m.Kind, "provider": m.Provider, "base_url": m.BaseURL,
|
||||
"model": m.Model, "active": m.Active, "api_key": mask(m.APIKey),
|
||||
"model": m.Model, "active": m.Active, "api_key": mask(plain),
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"models": out})
|
||||
@@ -99,7 +119,19 @@ func (h *Handler) SaveModel(c *gin.Context) {
|
||||
if kind == "" {
|
||||
kind = contract.ConfigKindChat
|
||||
}
|
||||
m := &store.LLMModel{BaseModel: store.BaseModel{ID: b.ID}, Kind: kind, Provider: provider, BaseURL: b.BaseURL, APIKey: b.APIKey, Model: b.Model}
|
||||
// api_key 处理:空或脱敏占位 => 沿用库内既有密文(更新时未改 key);否则视为新明文,加密落库。
|
||||
apiKey := b.APIKey
|
||||
if apiKey == "" || strings.HasPrefix(apiKey, maskPrefix) {
|
||||
apiKey = h.existingModelKey(c.Request.Context(), b.ID) // 已是密文,原样保留(不存在则空)
|
||||
} else {
|
||||
enc, err := secrets.Encrypt(apiKey)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "encrypt api_key: " + err.Error()})
|
||||
return
|
||||
}
|
||||
apiKey = enc
|
||||
}
|
||||
m := &store.LLMModel{BaseModel: store.BaseModel{ID: b.ID}, Kind: kind, Provider: provider, BaseURL: b.BaseURL, APIKey: apiKey, Model: b.Model}
|
||||
if err := h.db.SaveModel(c.Request.Context(), m); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -137,16 +169,14 @@ func (h *Handler) TestModel(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "base_url required"})
|
||||
return
|
||||
}
|
||||
// 若传了已存的 id 但未带 key,用库里的真实 key。
|
||||
// 若未带 key(或回传脱敏占位),用库里的真实 key。
|
||||
key := b.APIKey
|
||||
if key == "" && b.ID != "" {
|
||||
if rows, _ := h.db.ListModels(c.Request.Context(), ""); rows != nil {
|
||||
for _, m := range rows {
|
||||
if m.ID == b.ID {
|
||||
key = m.APIKey
|
||||
}
|
||||
}
|
||||
}
|
||||
if key == "" || strings.HasPrefix(key, maskPrefix) {
|
||||
key = h.existingModelKey(c.Request.Context(), b.ID)
|
||||
}
|
||||
// key 此刻可能是库内密文,也可能是用户新填的明文;Decrypt 对无前缀明文透传,两种都还原成可用明文。
|
||||
if plain, err := secrets.Decrypt(key); err == nil {
|
||||
key = plain
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -36,8 +35,7 @@ var (
|
||||
})
|
||||
)
|
||||
|
||||
// accessLogger 是结构化访问日志器(JSON 到 stderr)。
|
||||
var accessLogger = slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
// 访问日志走全局默认 slog(otelx.SetupSlog 安装,链路感知):用请求 ctx 打点即自动带 trace_id。
|
||||
|
||||
// RequestID 为每个请求生成/透传 X-Request-ID,写入上下文与响应头,供日志关联。
|
||||
func RequestID() gin.HandlerFunc {
|
||||
@@ -73,7 +71,7 @@ func Observe() gin.HandlerFunc {
|
||||
|
||||
uid, _ := c.Get(CtxUserID)
|
||||
rid, _ := c.Get(CtxRequestID)
|
||||
accessLogger.Info("http",
|
||||
slog.InfoContext(c.Request.Context(), "http",
|
||||
"request_id", rid,
|
||||
"method", method,
|
||||
"route", route,
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
|
||||
|
||||
"github.com/sundynix/sundynix-gateway/internal/blob"
|
||||
"github.com/sundynix/sundynix-gateway/internal/handler"
|
||||
@@ -17,10 +18,11 @@ import (
|
||||
// New 构建带有 Guardrail / 限流中间件的 Gin 引擎。
|
||||
func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.Store) *gin.Engine {
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery()) // panic 兜底
|
||||
r.Use(middleware.RequestID()) // 生成/透传 X-Request-ID(日志关联)
|
||||
r.Use(middleware.Observe()) // Prometheus 指标 + 结构化访问日志(替代 gin 默认文本日志)
|
||||
r.Use(cors()) // 桌面端/浏览器跨源访问
|
||||
r.Use(gin.Recovery()) // panic 兜底
|
||||
r.Use(otelgin.Middleware("sundynix-gateway")) // OTel: HTTP server span(链路根 + 提取上游 traceparent)
|
||||
r.Use(middleware.RequestID()) // 生成/透传 X-Request-ID(日志关联)
|
||||
r.Use(middleware.Observe()) // Prometheus 指标 + 结构化访问日志(替代 gin 默认文本日志)
|
||||
r.Use(cors()) // 桌面端/浏览器跨源访问
|
||||
r.Use(middleware.RateLimit(cache))
|
||||
r.Use(middleware.Auth()) // 解析 Bearer JWT,注入已验证 userID(非阻断)
|
||||
r.Use(middleware.Guardrail()) // Harness: Input Guardrail
|
||||
@@ -35,10 +37,10 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
|
||||
api := r.Group("/api/v1")
|
||||
{
|
||||
// —— 公开:鉴权端点 / 健康 / 按 task_id 寻址的 SSE 与导出(EventSource/下载无法带 Bearer)——
|
||||
api.POST("/auth/register", h.Register) // 注册 + 签发 JWT
|
||||
api.POST("/auth/login", h.Login) // 登录 + 签发 JWT
|
||||
api.GET("/auth/me", h.Me) // 当前登录用户(无效令牌 → 401)
|
||||
api.GET("/health", h.Health) // 依赖健康聚合(顶栏五盏灯)
|
||||
api.POST("/auth/register", h.Register) // 注册 + 签发 JWT
|
||||
api.POST("/auth/login", h.Login) // 登录 + 签发 JWT
|
||||
api.GET("/auth/me", h.Me) // 当前登录用户(无效令牌 → 401)
|
||||
api.GET("/health", h.Health) // 依赖健康聚合(顶栏五盏灯)
|
||||
api.GET("/tasks/:id/stream", h.StreamTask) // SSE 回流 Token Stream(task_id 寻址)
|
||||
api.GET("/tasks/:id/exec", h.StreamExec) // SSE 回流执行轨迹(task_id 寻址)
|
||||
api.GET("/kb/ingest/:id/stream", h.KbIngestStream) // 入库进度 SSE(job_id 寻址)
|
||||
@@ -48,25 +50,25 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
|
||||
// —— 受保护:owner 作用域业务,必须携带有效 JWT ——
|
||||
p := api.Group("", middleware.RequireAuth())
|
||||
{
|
||||
p.POST("/tasks", h.SubmitTask) // 解析 DSL 并 Publish 到 NATS(带已验证 uid)
|
||||
p.GET("/tasks/:id", h.TaskStatus) // 任务生命周期状态(UI 轮询 submitted/running/done/failed/timeout)
|
||||
p.PUT("/memory", h.SetMemory) // 偏好记忆登记(→ mcp-go memory_upsert)
|
||||
p.GET("/memory", h.ListMemory) // 列出当前用户偏好(记忆面板)
|
||||
p.DELETE("/memory", h.DeleteMemory) // 软删一条偏好(?key=)
|
||||
p.GET("/kb/list", h.KbList) // 当前用户的知识库列表(owner 隔离)
|
||||
p.POST("/kb/create", h.KbCreate) // 新建知识库
|
||||
p.POST("/kb/ingest", h.KbIngest) // 文本入库
|
||||
p.POST("/tasks", h.SubmitTask) // 解析 DSL 并 Publish 到 NATS(带已验证 uid)
|
||||
p.GET("/tasks/:id", h.TaskStatus) // 任务生命周期状态(UI 轮询 submitted/running/done/failed/timeout)
|
||||
p.PUT("/memory", h.SetMemory) // 偏好记忆登记(→ mcp-go memory_upsert)
|
||||
p.GET("/memory", h.ListMemory) // 列出当前用户偏好(记忆面板)
|
||||
p.DELETE("/memory", h.DeleteMemory) // 软删一条偏好(?key=)
|
||||
p.GET("/kb/list", h.KbList) // 当前用户的知识库列表(owner 隔离)
|
||||
p.POST("/kb/create", h.KbCreate) // 新建知识库
|
||||
p.POST("/kb/ingest", h.KbIngest) // 文本入库
|
||||
p.POST("/kb/ingest_file", h.KbIngestFile) // 文件入库
|
||||
p.POST("/kb/search", h.KbSearch) // 检索台
|
||||
p.GET("/kb/vault", h.KbVault) // 文库列表
|
||||
p.GET("/kb/doc", h.KbDoc) // 取单篇文档
|
||||
p.GET("/kb/links", h.KbLinks) // 某库双链
|
||||
p.POST("/kb/note", h.KbSaveNote) // 新建/编辑笔记
|
||||
p.GET("/kb/graph", h.KbGraph) // 知识图谱三元组
|
||||
p.GET("/agents", h.AgentList) // 我的编排列表(owner 隔离)
|
||||
p.POST("/agents", h.AgentSave) // 保存/更新编排
|
||||
p.DELETE("/agents", h.AgentDelete) // 删除编排
|
||||
p.POST("/reports", h.GenerateReport) // 报告生成
|
||||
p.POST("/kb/search", h.KbSearch) // 检索台
|
||||
p.GET("/kb/vault", h.KbVault) // 文库列表
|
||||
p.GET("/kb/doc", h.KbDoc) // 取单篇文档
|
||||
p.GET("/kb/links", h.KbLinks) // 某库双链
|
||||
p.POST("/kb/note", h.KbSaveNote) // 新建/编辑笔记
|
||||
p.GET("/kb/graph", h.KbGraph) // 知识图谱三元组
|
||||
p.GET("/agents", h.AgentList) // 我的编排列表(owner 隔离)
|
||||
p.POST("/agents", h.AgentSave) // 保存/更新编排
|
||||
p.DELETE("/agents", h.AgentDelete) // 删除编排
|
||||
p.POST("/reports", h.GenerateReport) // 报告生成
|
||||
p.GET("/billing", h.Billing)
|
||||
}
|
||||
|
||||
@@ -78,9 +80,9 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
|
||||
admin.POST("/models/:id/active", h.SetActiveModel)
|
||||
admin.DELETE("/models/:id", h.DeleteModel)
|
||||
admin.POST("/models/test", h.TestModel)
|
||||
admin.GET("/pricing", h.ListPricing) // 各模型计价(token↔真钱)
|
||||
admin.PUT("/pricing", h.SavePricing) // 设置某模型输入/输出单价
|
||||
admin.GET("/status", h.AdminStatus) // 服务状态:基建/服务探活 + MCP 工具注册
|
||||
admin.GET("/pricing", h.ListPricing) // 各模型计价(token↔真钱)
|
||||
admin.PUT("/pricing", h.SavePricing) // 设置某模型输入/输出单价
|
||||
admin.GET("/status", h.AdminStatus) // 服务状态:基建/服务探活 + MCP 工具注册
|
||||
}
|
||||
}
|
||||
return r
|
||||
|
||||
@@ -285,7 +285,10 @@ func (p *Postgres) SaveModel(ctx context.Context, m *LLMModel) error {
|
||||
if m.ID == "" {
|
||||
return p.db.WithContext(ctx).Create(m).Error
|
||||
}
|
||||
return p.db.WithContext(ctx).Save(m).Error
|
||||
// 更新仅覆盖可编辑列:Active 由 SetActiveModel 单独管理,CreatedAt 不动——
|
||||
// 避免整行 Save 把 active 清零(改 key/base_url 时不应顺手取消激活)。
|
||||
return p.db.WithContext(ctx).Model(&LLMModel{}).Where("id = ?", m.ID).
|
||||
Select("Kind", "Provider", "BaseURL", "APIKey", "Model").Updates(m).Error
|
||||
}
|
||||
|
||||
// SetActiveModel 把指定模型设为激活(同 kind 内其余取消),事务保证每 kind 唯一激活。
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
sharedbus "github.com/sundynix/sundynix-shared/bus"
|
||||
"github.com/sundynix/sundynix-shared/contract"
|
||||
@@ -17,9 +16,19 @@ import (
|
||||
"github.com/sundynix/sundynix-mcp-go/internal/memory"
|
||||
"github.com/sundynix/sundynix-mcp-go/internal/rag"
|
||||
"github.com/sundynix/sundynix-mcp-go/internal/search"
|
||||
|
||||
"github.com/sundynix/sundynix-shared/otelx"
|
||||
"github.com/sundynix/sundynix-shared/secrets"
|
||||
)
|
||||
|
||||
func main() {
|
||||
secrets.MustHaveKeyInProd() // 生产须配 SUNDYNIX_SECRET_KEY 以解密下发的 api_key 密文
|
||||
otelx.SetupSlog("sundynix-mcp-go") // 结构化日志 + 链路感知(工具日志带 trace_id)
|
||||
|
||||
// 链路追踪:工具服务端 span 续上 dispatcher 的 trace(tool.call → tool.serve)。
|
||||
shutdownTrace, _ := otelx.Init(context.Background(), "sundynix-mcp-go")
|
||||
defer func() { _ = shutdownTrace(context.Background()) }()
|
||||
|
||||
natsURL := envOr("NATS_URL", "nats://localhost:4222")
|
||||
pgDSN := envOr("POSTGRES_DSN", "postgres://sundynix:sundynix@localhost:5432/sundynix?sslmode=disable")
|
||||
redisAddr := envOr("REDIS_ADDR", "localhost:6379")
|
||||
@@ -42,7 +51,7 @@ func main() {
|
||||
log.Printf("[mcp_go] connected %s", natsURL)
|
||||
|
||||
engine := search.NewHybrid() // LLM Wiki 混合检索:Bleve + Milvus + Neo4j
|
||||
mem := memory.Open(pgDSN) // 偏好记忆:sundynix_user_profile(连不上则降级)
|
||||
mem := memory.Open(pgDSN) // 偏好记忆:sundynix_user_profile(连不上则降级)
|
||||
defer mem.Close()
|
||||
hist := history.Open(redisAddr) // 会话短期历史:Redis(连不上则降级)
|
||||
defer hist.Close()
|
||||
@@ -70,22 +79,16 @@ func main() {
|
||||
ragEngine.SetChat(cfg.BaseURL, cfg.APIKey, cfg.Model)
|
||||
}
|
||||
}
|
||||
cctx, ccancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
if cfg, _ := b.RequestConfig(cctx, contract.ConfigKindEmbedding); cfg != nil {
|
||||
applyEmbed(cfg)
|
||||
} else {
|
||||
log.Println("[mcp_go] 未取到 embedding 控制面配置(用 env 或降级)")
|
||||
}
|
||||
if cfg, _ := b.RequestConfig(cctx, contract.ConfigKindChat); cfg != nil {
|
||||
applyChat(cfg)
|
||||
}
|
||||
ccancel()
|
||||
// 先订阅热更新(控制台改配置即生效)。
|
||||
if _, err := b.SubscribeConfigUpdated(contract.ConfigKindChat, applyChat); err != nil {
|
||||
log.Printf("[mcp_go] subscribe chat config: %v", err)
|
||||
}
|
||||
if _, err := b.SubscribeConfigUpdated(contract.ConfigKindEmbedding, applyEmbed); err != nil {
|
||||
log.Printf("[mcp_go] subscribe embedding config: %v", err)
|
||||
}
|
||||
// 后台重试拉初始配置:容忍 gateway 晚于本服务启动(避免一次性扑空致 RAG 长期降级)。
|
||||
go b.RequestConfigWithRetry(ctx, contract.ConfigKindEmbedding, applyEmbed)
|
||||
go b.RequestConfigWithRetry(ctx, contract.ConfigKindChat, applyChat)
|
||||
|
||||
gw := mcp.NewGateway(b, engine, mem, hist, ragEngine)
|
||||
|
||||
|
||||
+11
-5
@@ -1,6 +1,6 @@
|
||||
module github.com/sundynix/sundynix-mcp-go
|
||||
|
||||
go 1.25.0
|
||||
go 1.25.8
|
||||
|
||||
require (
|
||||
github.com/blevesearch/bleve/v2 v2.4.2
|
||||
@@ -38,9 +38,11 @@ require (
|
||||
github.com/cockroachdb/logtags v0.0.0-20211118104740-dabe8e521a4f // indirect
|
||||
github.com/cockroachdb/redact v1.1.3 // indirect
|
||||
github.com/getsentry/sentry-go v0.12.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551 // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/golang/snappy v0.0.1 // indirect
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
@@ -68,15 +70,19 @@ require (
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
go.etcd.io/bbolt v1.3.7 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20220503193339-ba3ae3f07e29 // indirect
|
||||
google.golang.org/grpc v1.48.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
google.golang.org/genproto v0.0.0-20260622175928-b703f567277d // indirect
|
||||
google.golang.org/grpc v1.81.1 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
|
||||
replace github.com/sundynix/sundynix-shared => ../sundynix-shared
|
||||
|
||||
@@ -111,6 +111,11 @@ github.com/go-faker/faker/v4 v4.1.0 h1:ffuWmpDrducIUOO0QSKSF5Q2dxAht+dhsT9FvVHhP
|
||||
github.com/go-faker/faker/v4 v4.1.0/go.mod h1:uuNc0PSRxF8nMgjGrrrU4Nw5cF30Jc6Kd0/FUTTYbhg=
|
||||
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
|
||||
@@ -142,6 +147,7 @@ github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
|
||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/gomodule/redigo v1.7.1-0.20190724094224-574c33c3df38/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
|
||||
@@ -200,13 +206,16 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o
|
||||
github.com/klauspost/compress v1.8.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
||||
github.com/klauspost/compress v1.9.7/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
@@ -278,7 +287,9 @@ github.com/redis/go-redis/v9 v9.20.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3b
|
||||
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
|
||||
github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
|
||||
github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g=
|
||||
@@ -302,6 +313,7 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
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/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
|
||||
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
@@ -333,6 +345,14 @@ github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.etcd.io/bbolt v1.3.7 h1:j+zJOnnEjF/kyHlDDgGnVL/AIqIJPq8UoB2GSNfkUfQ=
|
||||
go.etcd.io/bbolt v1.3.7/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
|
||||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
@@ -348,6 +368,7 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
|
||||
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
@@ -377,6 +398,7 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20211008194852-3b03d305991f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -388,6 +410,7 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -414,6 +437,7 @@ golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
@@ -422,6 +446,7 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
|
||||
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
@@ -453,6 +478,7 @@ google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEY
|
||||
google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24=
|
||||
google.golang.org/genproto v0.0.0-20220503193339-ba3ae3f07e29 h1:DJUvgAPiJWeMBiT+RzBVcJGQN7bAEWS5UEoMshES9xs=
|
||||
google.golang.org/genproto v0.0.0-20220503193339-ba3ae3f07e29/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
|
||||
google.golang.org/genproto v0.0.0-20260622175928-b703f567277d/go.mod h1:DrwuGJgFSEVNpv3S5Q5VxhRTvdnjauw9GtvwVOEARfA=
|
||||
google.golang.org/grpc v1.12.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
@@ -465,6 +491,7 @@ google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQ
|
||||
google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
|
||||
google.golang.org/grpc v1.48.0 h1:rQOsyJ/8+ufEDJd/Gdsz7HG220Mh9HAhFHRGnIjda0w=
|
||||
google.golang.org/grpc v1.48.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
|
||||
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||
google.golang.org/grpc/examples v0.0.0-20220617181431-3e7b97febc7f h1:rqzndB2lIQGivcXdTuY3Y9NBvr70X+y77woofSRluec=
|
||||
google.golang.org/grpc/examples v0.0.0-20220617181431-3e7b97febc7f/go.mod h1:gxndsbNG1n4TZcHGgsYEfVGnTxqfEdfiDv6/DADXX9o=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
@@ -481,6 +508,7 @@ google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ
|
||||
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -12,12 +12,132 @@ const (
|
||||
chunkMaxRunes = 1000 // 原子硬上限:超大无标点段落兜底窗口切
|
||||
)
|
||||
|
||||
// chunk 把文本切成检索友好的语义块:按段落/句界切成原子 → 贪心打包到目标大小(句末收口)
|
||||
// → 块间加重叠。全程按 rune 操作,杜绝中文 UTF-8 被字节切碎。
|
||||
// chunk 把文本切成检索友好的语义块。Markdown 文档走「标题感知」切块:
|
||||
// 按 #/## 层级切段,块不跨章节,且每块前缀完整标题路径(如「部署 > 环境要求」)补全上下文;
|
||||
// 非 Markdown 走纯语义切块。全程按 rune 操作,杜绝中文 UTF-8 被字节切碎。
|
||||
func chunk(text string) []string {
|
||||
atoms := splitToAtoms(text)
|
||||
packed := packAtoms(atoms, chunkTargetRunes, chunkMinRunes)
|
||||
return addOverlap(packed, chunkOverlapRunes)
|
||||
if hasMarkdownHeadings(text) {
|
||||
return chunkMarkdown(text)
|
||||
}
|
||||
return packSection(text)
|
||||
}
|
||||
|
||||
// packSection 对一段纯文本做语义切块:原子 → 贪心打包(句末收口)→ 块间重叠。
|
||||
func packSection(text string) []string {
|
||||
return addOverlap(packAtoms(splitToAtoms(text), chunkTargetRunes, chunkMinRunes), chunkOverlapRunes)
|
||||
}
|
||||
|
||||
// section 是一个标题层级下的正文段:crumb 为标题路径面包屑(顶到本节,可空=前言)。
|
||||
type section struct {
|
||||
crumb string
|
||||
body string
|
||||
}
|
||||
|
||||
// chunkMarkdown 标题感知切块:按标题层级切成 section(块绝不跨章节),每节内部再语义切块,
|
||||
// 并给每块前缀该节的标题路径——使「## Redis 下的『端口配置』」这类块自带章节语境,显著提升检索命中。
|
||||
func chunkMarkdown(text string) []string {
|
||||
var out []string
|
||||
for _, s := range splitMarkdownSections(text) {
|
||||
for _, body := range packSection(s.body) {
|
||||
if s.crumb != "" {
|
||||
out = append(out, s.crumb+"\n"+body) // 面包屑作为上下文前缀
|
||||
} else {
|
||||
out = append(out, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// splitMarkdownSections 按 ATX 标题(# ~ ######)把文档切成带面包屑的章节,正确跳过代码围栏内的 #。
|
||||
func splitMarkdownSections(text string) []section {
|
||||
lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n")
|
||||
type heading struct {
|
||||
level int
|
||||
title string
|
||||
}
|
||||
var stack []heading
|
||||
var sections []section
|
||||
var body []string
|
||||
inFence, fenceMarker := false, ""
|
||||
|
||||
crumb := func() string {
|
||||
parts := make([]string, len(stack))
|
||||
for i, h := range stack {
|
||||
parts[i] = h.title
|
||||
}
|
||||
return strings.Join(parts, " > ")
|
||||
}
|
||||
flush := func() {
|
||||
if b := strings.TrimSpace(strings.Join(body, "\n")); b != "" {
|
||||
sections = append(sections, section{crumb: crumb(), body: b})
|
||||
}
|
||||
body = body[:0]
|
||||
}
|
||||
|
||||
for _, ln := range lines {
|
||||
t := strings.TrimSpace(ln)
|
||||
if m := fenceMark(t); m != "" { // 代码围栏开/合:围栏内的 # 不当标题
|
||||
if !inFence {
|
||||
inFence, fenceMarker = true, m
|
||||
} else if m == fenceMarker {
|
||||
inFence, fenceMarker = false, ""
|
||||
}
|
||||
body = append(body, ln)
|
||||
continue
|
||||
}
|
||||
if !inFence {
|
||||
if lvl, title, ok := parseHeading(t); ok {
|
||||
flush() // 上一节正文收口
|
||||
for len(stack) > 0 && stack[len(stack)-1].level >= lvl {
|
||||
stack = stack[:len(stack)-1] // 同级/更浅的标题出栈
|
||||
}
|
||||
stack = append(stack, heading{lvl, title})
|
||||
continue
|
||||
}
|
||||
}
|
||||
body = append(body, ln)
|
||||
}
|
||||
flush()
|
||||
return sections
|
||||
}
|
||||
|
||||
// parseHeading 解析 ATX 标题行:返回层级(1-6)、标题文本、是否命中。要求 # 后跟空格(排除 #define 之类)。
|
||||
func parseHeading(line string) (level int, title string, ok bool) {
|
||||
n := 0
|
||||
for n < len(line) && line[n] == '#' {
|
||||
n++
|
||||
}
|
||||
if n == 0 || n > 6 || n >= len(line) || line[n] != ' ' {
|
||||
return 0, "", false
|
||||
}
|
||||
title = strings.TrimRight(strings.TrimSpace(line[n:]), "# ") // 去掉闭合式 ATX 尾部的 #
|
||||
if title == "" {
|
||||
return 0, "", false
|
||||
}
|
||||
return n, title, true
|
||||
}
|
||||
|
||||
// fenceMark 返回代码围栏标记(``` 或 ~~~),非围栏行返回空。
|
||||
func fenceMark(t string) string {
|
||||
switch {
|
||||
case strings.HasPrefix(t, "```"):
|
||||
return "```"
|
||||
case strings.HasPrefix(t, "~~~"):
|
||||
return "~~~"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// hasMarkdownHeadings 快速判断文本是否含 ATX 标题(决定走标题感知还是纯语义切块)。
|
||||
func hasMarkdownHeadings(text string) bool {
|
||||
for _, ln := range strings.Split(text, "\n") {
|
||||
if _, _, ok := parseHeading(strings.TrimSpace(ln)); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// splitToAtoms 把文本切成"原子"(句子/行):在换行与句末标点处断开,去空白;
|
||||
|
||||
@@ -108,6 +108,105 @@ func TestChunkOversizedNoPunct(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Markdown 标题感知切块 ----
|
||||
|
||||
const mdDoc = `# 部署指南
|
||||
本文介绍如何部署本系统的前言段落。
|
||||
|
||||
## 环境要求
|
||||
需要 Go 1.25 与 Docker 运行环境。
|
||||
|
||||
### 内存
|
||||
建议至少 8GB 内存以保证向量库稳定。
|
||||
|
||||
## 启动
|
||||
执行 docker compose up 即可拉起全部服务。
|
||||
`
|
||||
|
||||
func TestChunkMarkdownBreadcrumb(t *testing.T) {
|
||||
chunks := chunk(mdDoc)
|
||||
// 「内存」节的块应带完整三级面包屑。
|
||||
var memChunk string
|
||||
for _, c := range chunks {
|
||||
if strings.Contains(c, "8GB") {
|
||||
memChunk = c
|
||||
}
|
||||
}
|
||||
if memChunk == "" {
|
||||
t.Fatal("未找到内存节的块")
|
||||
}
|
||||
if !strings.HasPrefix(memChunk, "部署指南 > 环境要求 > 内存\n") {
|
||||
t.Fatalf("内存块应以三级面包屑开头,得:%q", memChunk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkMarkdownNoCrossSection(t *testing.T) {
|
||||
for _, c := range chunk(mdDoc) {
|
||||
// 任一块都不应同时含两个不同章节的正文(块不跨章节)。
|
||||
if strings.Contains(c, "8GB") && strings.Contains(c, "docker compose") {
|
||||
t.Fatalf("块跨越了章节边界:%q", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkMarkdownHierarchyReset(t *testing.T) {
|
||||
// 同级 ## 启动 应把更深的 ### 内存 出栈,面包屑回到「部署指南 > 启动」。
|
||||
var startChunk string
|
||||
for _, c := range chunk(mdDoc) {
|
||||
if strings.Contains(c, "docker compose") {
|
||||
startChunk = c
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(startChunk, "部署指南 > 启动\n") {
|
||||
t.Fatalf("启动块面包屑应为两级(内存已出栈),得:%q", startChunk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkMarkdownCodeFenceIgnored(t *testing.T) {
|
||||
doc := strings.Join([]string{
|
||||
"# 脚本说明",
|
||||
"下面给出示例脚本:",
|
||||
"",
|
||||
"```bash",
|
||||
"# 这是注释不是标题",
|
||||
"echo hi",
|
||||
"```",
|
||||
"",
|
||||
"脚本到此结束。",
|
||||
}, "\n")
|
||||
chunks := chunk(doc)
|
||||
for _, c := range chunks {
|
||||
// 围栏内的「# 这是注释不是标题」绝不能被当成标题(不会成为面包屑首行)。
|
||||
if strings.HasPrefix(c, "这是注释不是标题") {
|
||||
t.Fatalf("代码围栏内的 # 被误判为标题:%q", c)
|
||||
}
|
||||
if !strings.HasPrefix(c, "脚本说明\n") {
|
||||
t.Fatalf("块应统一挂在「脚本说明」下,得:%q", c)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(strings.Join(chunks, "\n"), "echo hi") {
|
||||
t.Fatal("代码块正文应被保留")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkPlainTextUnaffected(t *testing.T) {
|
||||
// 无标题文本应与纯语义切块完全一致(向后兼容,不引入面包屑)。
|
||||
plain := strings.Repeat("这是一段没有任何标题的普通中文文本。", 40)
|
||||
got := chunk(plain)
|
||||
want := packSection(plain)
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("纯文本切块数变了:got %d want %d", len(got), len(want))
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("纯文本第 %d 块与语义切块不一致", i)
|
||||
}
|
||||
if strings.Contains(got[i], " > ") {
|
||||
t.Fatalf("纯文本不应出现面包屑:%q", got[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func max0(n int) int {
|
||||
if n < 0 {
|
||||
return 0
|
||||
|
||||
@@ -10,10 +10,24 @@ import (
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"github.com/sundynix/sundynix-shared/contract"
|
||||
"github.com/sundynix/sundynix-shared/secrets"
|
||||
)
|
||||
|
||||
// decryptConfig 在消费侧把配置里的 api_key 从密文还原为明文(控制面以密文过线缆,见 secrets 包)。
|
||||
// 失败(密钥不匹配 / 密文损坏)时清空 api_key 并不再降级阻断——调用方据 Ready() 判定。
|
||||
func decryptConfig(cfg *contract.ModelConfig) {
|
||||
if cfg == nil || cfg.APIKey == "" {
|
||||
return
|
||||
}
|
||||
if plain, err := secrets.Decrypt(cfg.APIKey); err == nil {
|
||||
cfg.APIKey = plain
|
||||
}
|
||||
}
|
||||
|
||||
// Bus 持有 NATS 连接与 JetStream 上下文。
|
||||
type Bus struct {
|
||||
nc *nats.Conn
|
||||
@@ -88,13 +102,27 @@ func (b *Bus) EnsureTaskStream(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// PublishTask 把任务发布到 sundynix.tasks.<id>,返回序列号。
|
||||
// 发布前把链路上下文注入消息头,使 dispatcher 消费时能续上同一条 trace。
|
||||
func (b *Bus) PublishTask(ctx context.Context, t *contract.Task) (uint64, error) {
|
||||
data, err := t.Marshal()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
ack, err := b.js.Publish(ctx, contract.TaskSubject(t.ID), data)
|
||||
ctx, span := tracer().Start(ctx, "nats.publish task",
|
||||
trace.WithSpanKind(trace.SpanKindProducer),
|
||||
trace.WithAttributes(
|
||||
attribute.String("messaging.system", "nats"),
|
||||
attribute.String("messaging.destination.name", contract.TaskSubject(t.ID)),
|
||||
attribute.String("sundynix.task_id", t.ID),
|
||||
))
|
||||
defer span.End()
|
||||
|
||||
msg := nats.NewMsg(contract.TaskSubject(t.ID))
|
||||
msg.Data = data
|
||||
injectTrace(ctx, msg.Header) // 把 traceparent 写进消息头 → 跨总线传播
|
||||
ack, err := b.js.PublishMsg(ctx, msg)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return 0, fmt.Errorf("publish task: %w", err)
|
||||
}
|
||||
return ack.Sequence, nil
|
||||
@@ -174,14 +202,28 @@ func (b *Bus) CallTool(ctx context.Context, subject string, call *contract.ToolC
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal tool call: %w", err)
|
||||
}
|
||||
msg, err := b.nc.RequestWithContext(ctx, subject, data)
|
||||
ctx, span := tracer().Start(ctx, "tool.call "+call.Tool,
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(
|
||||
attribute.String("sundynix.tool", call.Tool),
|
||||
attribute.String("messaging.destination.name", subject),
|
||||
))
|
||||
defer span.End()
|
||||
|
||||
req := nats.NewMsg(subject)
|
||||
req.Data = data
|
||||
injectTrace(ctx, req.Header) // 把链路上下文带给第 5 层工具服务
|
||||
msg, err := b.nc.RequestMsgWithContext(ctx, req)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, fmt.Errorf("call tool %s: %w", subject, err)
|
||||
}
|
||||
var res contract.ToolResult
|
||||
if err := json.Unmarshal(msg.Data, &res); err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, fmt.Errorf("unmarshal tool result: %w", err)
|
||||
}
|
||||
span.SetAttributes(attribute.Bool("sundynix.tool.ok", res.OK))
|
||||
return &res, nil
|
||||
}
|
||||
|
||||
@@ -198,7 +240,17 @@ func (b *Bus) ServeTool(subject, queue string, h ToolHandler) (unsub func() erro
|
||||
respond(m, &contract.ToolResult{OK: false, Error: "bad tool call: " + err.Error()})
|
||||
return
|
||||
}
|
||||
respond(m, h(context.Background(), &call))
|
||||
// 还原上游链路并开服务端 span(成为 dispatcher tool.call span 的子节点)。
|
||||
mctx := extractTrace(context.Background(), m.Header)
|
||||
mctx, span := tracer().Start(mctx, "tool.serve "+call.Tool,
|
||||
trace.WithSpanKind(trace.SpanKindServer),
|
||||
trace.WithAttributes(attribute.String("sundynix.tool", call.Tool)))
|
||||
res := h(mctx, &call)
|
||||
if res != nil {
|
||||
span.SetAttributes(attribute.Bool("sundynix.tool.ok", res.OK))
|
||||
}
|
||||
span.End()
|
||||
respond(m, res)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("serve tool %s: %w", subject, err)
|
||||
@@ -281,9 +333,30 @@ func (b *Bus) RequestConfig(ctx context.Context, kind string) (*contract.ModelCo
|
||||
if !cfg.Ready() {
|
||||
return nil, nil
|
||||
}
|
||||
decryptConfig(&cfg) // 线缆上是密文,消费侧还原
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// RequestConfigWithRetry 后台重试拉取某 kind 的初始配置,直到成功或重试耗尽。
|
||||
// 容忍消费方(dispatcher/mcp-go)早于控制面(gateway)启动——一次性请求扑空后不再干等热更新。
|
||||
// 拿到即调 apply 并返回;ctx 取消或重试上限到则放弃(此后仍可由热更新广播兜底)。
|
||||
func (b *Bus) RequestConfigWithRetry(ctx context.Context, kind string, apply func(*contract.ModelConfig)) {
|
||||
for i := 0; i < 60; i++ {
|
||||
cctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
cfg, _ := b.RequestConfig(cctx, kind)
|
||||
cancel()
|
||||
if cfg != nil {
|
||||
apply(cfg)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ServeConfig 让控制面响应某 kind 的配置请求;provide 返回当前激活配置(可为 nil)。
|
||||
func (b *Bus) ServeConfig(kind string, provide func() *contract.ModelConfig) (unsub func() error, err error) {
|
||||
sub, err := b.nc.Subscribe(contract.ConfigGetSubject(kind), func(m *nats.Msg) {
|
||||
@@ -313,6 +386,7 @@ func (b *Bus) SubscribeConfigUpdated(kind string, onUpdate func(*contract.ModelC
|
||||
sub, err := b.nc.Subscribe(contract.ConfigUpdatedSubject(kind), func(m *nats.Msg) {
|
||||
var cfg contract.ModelConfig
|
||||
if json.Unmarshal(m.Data, &cfg) == nil {
|
||||
decryptConfig(&cfg) // 线缆上是密文,消费侧还原
|
||||
onUpdate(&cfg)
|
||||
}
|
||||
})
|
||||
@@ -342,7 +416,17 @@ func (b *Bus) ConsumeTasks(ctx context.Context, h TaskHandler) (stop func(), err
|
||||
_ = msg.Term() // 脏数据,丢弃不重投
|
||||
return
|
||||
}
|
||||
if err := h(ctx, t); err != nil {
|
||||
// 从消息头还原上游链路,开消费 span(成为 gateway 发布 span 的子节点)。
|
||||
mctx := extractTrace(ctx, nats.Header(msg.Headers()))
|
||||
mctx, span := tracer().Start(mctx, "nats.consume task",
|
||||
trace.WithSpanKind(trace.SpanKindConsumer),
|
||||
trace.WithAttributes(attribute.String("sundynix.task_id", t.ID)))
|
||||
herr := h(mctx, t)
|
||||
if herr != nil {
|
||||
span.RecordError(herr)
|
||||
}
|
||||
span.End()
|
||||
if herr != nil {
|
||||
_ = msg.NakWithDelay(time.Second) // 处理失败,延迟重投
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package bus
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// natsHeaderCarrier 让 OTel 传播器能读写 NATS 消息头(W3C traceparent 等),
|
||||
// 从而把链路上下文跨 NATS 总线带到下游服务——这是分布式追踪能连成一棵树的关键。
|
||||
type natsHeaderCarrier nats.Header
|
||||
|
||||
func (c natsHeaderCarrier) Get(key string) string { return nats.Header(c).Get(key) }
|
||||
func (c natsHeaderCarrier) Set(key, value string) { nats.Header(c).Set(key, value) }
|
||||
func (c natsHeaderCarrier) Keys() []string {
|
||||
out := make([]string, 0, len(c))
|
||||
for k := range c {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// injectTrace 把 ctx 里的链路上下文写进 NATS 消息头(发布前调用)。
|
||||
func injectTrace(ctx context.Context, h nats.Header) {
|
||||
otel.GetTextMapPropagator().Inject(ctx, natsHeaderCarrier(h))
|
||||
}
|
||||
|
||||
// extractTrace 从 NATS 消息头还原上游链路上下文,挂到 ctx 上(消费时调用)。
|
||||
func extractTrace(ctx context.Context, h nats.Header) context.Context {
|
||||
if h == nil {
|
||||
return ctx
|
||||
}
|
||||
return otel.GetTextMapPropagator().Extract(ctx, natsHeaderCarrier(h))
|
||||
}
|
||||
|
||||
// tracer 是 bus 自身的 span 发射器(与业务同 scope,方便 Jaeger 归组)。
|
||||
func tracer() trace.Tracer {
|
||||
return otel.Tracer("github.com/sundynix/sundynix-agentix")
|
||||
}
|
||||
+21
-1
@@ -1,20 +1,40 @@
|
||||
module github.com/sundynix/sundynix-shared
|
||||
|
||||
go 1.25.0
|
||||
go 1.25.8
|
||||
|
||||
require (
|
||||
github.com/nats-io/nats-server/v2 v2.10.20
|
||||
github.com/nats-io/nats.go v1.37.0
|
||||
go.opentelemetry.io/otel v1.44.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0
|
||||
go.opentelemetry.io/otel/sdk v1.44.0
|
||||
go.opentelemetry.io/otel/trace v1.44.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
|
||||
github.com/klauspost/compress v1.18.6 // indirect
|
||||
github.com/minio/highwayhash v1.0.3 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.5.8 // indirect
|
||||
github.com/nats-io/nkeys v0.4.7 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
golang.org/x/time v0.6.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20260622175928-b703f567277d
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260618152121-87f3d3e198d3 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260618152121-87f3d3e198d3 // indirect
|
||||
google.golang.org/grpc v1.81.1 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
|
||||
@@ -1,4 +1,24 @@
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
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/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/minio/highwayhash v1.0.3 h1:kbnuUMoHYyVl7szWjSxJnxw11k2U709jqFPPmIUyD6Q=
|
||||
github.com/minio/highwayhash v1.0.3/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ=
|
||||
github.com/nats-io/jwt/v2 v2.5.8 h1:uvdSzwWiEGWGXf+0Q+70qv6AQdvcvxrv9hPM0RiPamE=
|
||||
@@ -11,9 +31,50 @@ github.com/nats-io/nkeys v0.4.7 h1:RwNJbbIdYCoClSDNY7QVKZlyb/wfT6ugvFCiKy6vDvI=
|
||||
github.com/nats-io/nkeys v0.4.7/go.mod h1:kqXRgRDPlGy7nGaEDMuYzmiJCIAAWDK0IMBtDmGD0nc=
|
||||
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
|
||||
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260618152121-87f3d3e198d3 h1:ctPmKL12ZsoKAlmPUsoW70zEDiYF+/H6aLieXxgAU0k=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260618152121-87f3d3e198d3/go.mod h1:Z4WJ5pJOYWFWcHEQUelD5QaZDknIQkpIL/+fyJOT9+A=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260618152121-87f3d3e198d3 h1:phvBWCAQMGN1945mp5fjCXP6jEF0+a0+4TjokS4sxNY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260618152121-87f3d3e198d3/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
||||
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// Package otelx 是 OpenTelemetry 链路追踪的统一启动器与门面。
|
||||
//
|
||||
// 各服务 main 启动时调一次 Init(ctx, "服务名"),即:
|
||||
// - 注册全局 W3C TraceContext 传播器(即便禁用导出,trace_id 仍能跨服务串起来);
|
||||
// - 把 span 经 OTLP/HTTP 批量导出到 OTEL_EXPORTER_OTLP_ENDPOINT(默认 localhost:4318,即 docker 里的 Jaeger)。
|
||||
//
|
||||
// 业务代码用 otelx.Tracer().Start(ctx, "span 名") 开 span;跨 NATS 的传播由 bus 层自动完成。
|
||||
// 设 OTEL_SDK_DISABLED=true 可整体关闭导出(仍保留传播器,零依赖运行)。
|
||||
package otelx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// scopeName 是本项目所有 span 的 instrumentation scope(Jaeger 里按此归组)。
|
||||
const scopeName = "github.com/sundynix/sundynix-agentix"
|
||||
|
||||
const defaultEndpoint = "localhost:4318" // Jaeger all-in-one 的 OTLP/HTTP 入口
|
||||
|
||||
// Init 初始化全局链路追踪。返回 shutdown,进程退出前调用以 flush 残留 span。
|
||||
// 导出器构建失败 / Jaeger 不在线都不阻断启动(批处理异步丢弃),保证可观测性是「增益」而非「依赖」。
|
||||
func Init(ctx context.Context, serviceName string) (func(context.Context) error, error) {
|
||||
// 传播器始终注册:哪怕本服务不导出,也要把上游 traceparent 透传给下游。
|
||||
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
|
||||
propagation.TraceContext{}, propagation.Baggage{},
|
||||
))
|
||||
|
||||
noop := func(context.Context) error { return nil }
|
||||
|
||||
if strings.EqualFold(os.Getenv("OTEL_SDK_DISABLED"), "true") {
|
||||
log.Printf("[otel] 导出已禁用 (OTEL_SDK_DISABLED=true),service=%s", serviceName)
|
||||
return noop, nil
|
||||
}
|
||||
|
||||
endpoint := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
|
||||
if endpoint == "" {
|
||||
endpoint = defaultEndpoint
|
||||
}
|
||||
// 容忍带 scheme 的写法(http://host:port),otlptracehttp.WithEndpoint 只要 host:port。
|
||||
endpoint = strings.TrimPrefix(strings.TrimPrefix(endpoint, "http://"), "https://")
|
||||
endpoint = strings.TrimSuffix(endpoint, "/")
|
||||
|
||||
exp, err := otlptracehttp.New(ctx,
|
||||
otlptracehttp.WithEndpoint(endpoint),
|
||||
otlptracehttp.WithInsecure(), // 本地 Jaeger 无 TLS
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("[otel] 导出器构建失败(降级为不导出): %v", err)
|
||||
return noop, nil
|
||||
}
|
||||
|
||||
res, _ := resource.Merge(resource.Default(), resource.NewWithAttributes(
|
||||
semconv.SchemaURL, semconv.ServiceName(serviceName),
|
||||
))
|
||||
tp := sdktrace.NewTracerProvider(
|
||||
sdktrace.WithBatcher(exp),
|
||||
sdktrace.WithResource(res),
|
||||
)
|
||||
otel.SetTracerProvider(tp)
|
||||
log.Printf("[otel] 链路追踪已启用 service=%s → otlp http://%s", serviceName, endpoint)
|
||||
return tp.Shutdown, nil
|
||||
}
|
||||
|
||||
// Tracer 返回统一 scope 的 tracer,供业务开 span。
|
||||
func Tracer() trace.Tracer { return otel.Tracer(scopeName) }
|
||||
@@ -0,0 +1,63 @@
|
||||
package otelx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// traceHandler 包装一个 slog.Handler:每条带 ctx 的日志自动附上 trace_id / span_id,
|
||||
// 从而「日志 ↔ 链路」互跳——在 Jaeger 看到一个 span 即可拿 trace_id 过滤日志,反之亦然。
|
||||
// 只有用 *Context 方法(slog.InfoContext 等)且 ctx 里有活跃 span 时才会注入。
|
||||
type traceHandler struct{ slog.Handler }
|
||||
|
||||
func (h traceHandler) Handle(ctx context.Context, r slog.Record) error {
|
||||
if sc := trace.SpanContextFromContext(ctx); sc.HasTraceID() {
|
||||
r.AddAttrs(slog.String("trace_id", sc.TraceID().String()))
|
||||
if sc.HasSpanID() {
|
||||
r.AddAttrs(slog.String("span_id", sc.SpanID().String()))
|
||||
}
|
||||
}
|
||||
return h.Handler.Handle(ctx, r)
|
||||
}
|
||||
|
||||
func (h traceHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
return traceHandler{h.Handler.WithAttrs(attrs)}
|
||||
}
|
||||
func (h traceHandler) WithGroup(name string) slog.Handler {
|
||||
return traceHandler{h.Handler.WithGroup(name)}
|
||||
}
|
||||
|
||||
// SetupSlog 安装一个全局结构化日志器(JSON → stderr),自带 service 标签且链路感知,
|
||||
// 并设为 slog 默认。此后任意 slog.InfoContext(ctx, ...) 都会带 trace_id(若 ctx 有 span)。
|
||||
// 级别经 LOG_LEVEL 控制(debug/info/warn/error,默认 info)。
|
||||
func SetupSlog(serviceName string) *slog.Logger {
|
||||
base := slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: parseLevel(os.Getenv("LOG_LEVEL"))})
|
||||
logger := slog.New(traceHandler{base}).With("service", serviceName)
|
||||
slog.SetDefault(logger)
|
||||
return logger
|
||||
}
|
||||
|
||||
func parseLevel(s string) slog.Level {
|
||||
switch strings.ToLower(s) {
|
||||
case "debug":
|
||||
return slog.LevelDebug
|
||||
case "warn", "warning":
|
||||
return slog.LevelWarn
|
||||
case "error":
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
// TraceID 返回 ctx 当前 span 的 trace id(无则空)——供需要手动带 trace_id 的场景。
|
||||
func TraceID(ctx context.Context) string {
|
||||
if sc := trace.SpanContextFromContext(ctx); sc.HasTraceID() {
|
||||
return sc.TraceID().String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package otelx
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
func newLogger(buf *bytes.Buffer) *slog.Logger {
|
||||
return slog.New(traceHandler{slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelInfo})})
|
||||
}
|
||||
|
||||
func ctxWithSpan(traceHex, spanHex string) context.Context {
|
||||
tid, _ := trace.TraceIDFromHex(traceHex)
|
||||
sid, _ := trace.SpanIDFromHex(spanHex)
|
||||
sc := trace.NewSpanContext(trace.SpanContextConfig{TraceID: tid, SpanID: sid})
|
||||
return trace.ContextWithSpanContext(context.Background(), sc)
|
||||
}
|
||||
|
||||
func TestTraceHandlerInjectsIDs(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := newLogger(&buf)
|
||||
ctx := ctxWithSpan("0123456789abcdef0123456789abcdef", "0123456789abcdef")
|
||||
|
||||
logger.InfoContext(ctx, "hello")
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, `"trace_id":"0123456789abcdef0123456789abcdef"`) {
|
||||
t.Fatalf("expected trace_id in log, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"span_id":"0123456789abcdef"`) {
|
||||
t.Fatalf("expected span_id in log, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraceHandlerNoSpanNoIDs(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := newLogger(&buf)
|
||||
|
||||
logger.InfoContext(context.Background(), "hello")
|
||||
|
||||
if strings.Contains(buf.String(), "trace_id") {
|
||||
t.Fatalf("did not expect trace_id without a span, got: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraceHandlerPreservesWithAttrs(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := newLogger(&buf).With("service", "test-svc")
|
||||
ctx := ctxWithSpan("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbb")
|
||||
|
||||
logger.InfoContext(ctx, "hello")
|
||||
|
||||
out := buf.String()
|
||||
// With(...) 后 trace 注入仍生效(WithAttrs 返回包装后的 handler)。
|
||||
if !strings.Contains(out, `"service":"test-svc"`) || !strings.Contains(out, "trace_id") {
|
||||
t.Fatalf("expected both service and trace_id after With(), got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLevel(t *testing.T) {
|
||||
cases := map[string]slog.Level{
|
||||
"debug": slog.LevelDebug, "info": slog.LevelInfo, "warn": slog.LevelWarn,
|
||||
"error": slog.LevelError, "": slog.LevelInfo, "bogus": slog.LevelInfo,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := parseLevel(in); got != want {
|
||||
t.Errorf("parseLevel(%q)=%v want %v", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// Package secrets 提供对称加密,用于把敏感配置(首要是 LLM api_key)加密后落库 / 过总线。
|
||||
//
|
||||
// 设计:
|
||||
// - AES-256-GCM。密钥由环境变量 SUNDYNIX_SECRET_KEY 经 SHA-256 派生为 32 字节(AEAD 同时保证机密性与完整性)。
|
||||
// - 密文格式 "enc:1:" + base64url(nonce || ciphertext),自带版本前缀便于日后轮换算法。
|
||||
// - 向后兼容:Decrypt 遇到无前缀的值(历史明文行 / 新填的明文)原样返回,不报错——
|
||||
// 于是 PG 里的旧明文 key 仍可用,下一次保存即升级为密文。
|
||||
//
|
||||
// 全链路约定:网关保存时 Encrypt 落库;密文经 DB 读出后原样过 NATS;消费方(dispatcher/mcp-go)
|
||||
// 在 bus 层 Decrypt 还原。因此 api_key 在「磁盘」与「线缆」上都不再是明文,仅在真正构建 LLM
|
||||
// 客户端的内存中短暂还原。各服务必须配置相同的 SUNDYNIX_SECRET_KEY。
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// cipherPrefix 标记一个值是本包产出的密文(含版本号,便于日后算法轮换)。
|
||||
const cipherPrefix = "enc:1:"
|
||||
|
||||
// devDefaultKey 是未设置 SUNDYNIX_SECRET_KEY 时的开发兜底(与 JWT 开发默认对称,便于本地各服务互通)。
|
||||
// 生产环境必须显式设置 SUNDYNIX_SECRET_KEY,否则加密形同虚设。
|
||||
const devDefaultKey = "sundynix-dev-secret-change-me"
|
||||
|
||||
var (
|
||||
gcmOnce sync.Once
|
||||
gcm cipher.AEAD
|
||||
gcmErr error
|
||||
)
|
||||
|
||||
// aead 惰性构建并缓存 AES-256-GCM 实例(密钥来自环境变量,进程内固定)。
|
||||
func aead() (cipher.AEAD, error) {
|
||||
gcmOnce.Do(func() {
|
||||
raw := os.Getenv("SUNDYNIX_SECRET_KEY")
|
||||
if raw == "" {
|
||||
raw = devDefaultKey
|
||||
log.Printf("[secrets] SUNDYNIX_SECRET_KEY 未设置,使用开发默认密钥(生产环境务必设置!)")
|
||||
}
|
||||
sum := sha256.Sum256([]byte(raw)) // 任意长度口令 → 固定 32 字节 AES-256 密钥
|
||||
block, err := aes.NewCipher(sum[:])
|
||||
if err != nil {
|
||||
gcmErr = err
|
||||
return
|
||||
}
|
||||
gcm, gcmErr = cipher.NewGCM(block)
|
||||
})
|
||||
return gcm, gcmErr
|
||||
}
|
||||
|
||||
// Encrypt 加密明文,返回带前缀的密文;空串原样返回(无需加密)。
|
||||
func Encrypt(plain string) (string, error) {
|
||||
if plain == "" {
|
||||
return "", nil
|
||||
}
|
||||
a, err := aead()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce := make([]byte, a.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ct := a.Seal(nonce, nonce, []byte(plain), nil) // 输出 = nonce || 密文+tag
|
||||
return cipherPrefix + base64.RawURLEncoding.EncodeToString(ct), nil
|
||||
}
|
||||
|
||||
// Decrypt 还原密文。无 "enc:" 前缀的值视为历史明文,原样返回(平滑迁移)。
|
||||
func Decrypt(stored string) (string, error) {
|
||||
if !IsEncrypted(stored) {
|
||||
return stored, nil // 历史明文 / 用户刚填的明文,直接用
|
||||
}
|
||||
a, err := aead()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(stored[len(cipherPrefix):])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ns := a.NonceSize()
|
||||
if len(raw) < ns {
|
||||
return "", errors.New("secrets: 密文长度不足")
|
||||
}
|
||||
plain, err := a.Open(nil, raw[:ns], raw[ns:], nil)
|
||||
if err != nil {
|
||||
return "", err // 密钥不匹配 / 密文被篡改
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
|
||||
// IsEncrypted 报告一个值是否为本包产出的密文。
|
||||
func IsEncrypted(s string) bool {
|
||||
return len(s) > len(cipherPrefix) && s[:len(cipherPrefix)] == cipherPrefix
|
||||
}
|
||||
|
||||
// MustHaveKeyInProd 在生产模式(APP_ENV=production/prod 或 GIN_MODE=release)下,
|
||||
// 若未设置 SUNDYNIX_SECRET_KEY 则直接 fatal——杜绝用开发默认密钥加密(形同明文)。
|
||||
// 各处理 api_key 的服务(gateway/dispatcher/mcp-go)应在启动时调用;且必须配置相同的密钥。
|
||||
func MustHaveKeyInProd() {
|
||||
if os.Getenv("SUNDYNIX_SECRET_KEY") != "" {
|
||||
return
|
||||
}
|
||||
env := strings.ToLower(os.Getenv("APP_ENV"))
|
||||
if env == "production" || env == "prod" || strings.ToLower(os.Getenv("GIN_MODE")) == "release" {
|
||||
log.Fatal("[secrets] 生产模式必须设置 SUNDYNIX_SECRET_KEY(32+ 字节强随机),且各服务一致;拒绝以开发默认密钥加密")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package secrets
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// resetForTest 清空惰性缓存的 AEAD,让后续调用按当前环境变量重新派生密钥。
|
||||
func resetForTest() {
|
||||
gcmOnce = sync.Once{}
|
||||
gcm = nil
|
||||
gcmErr = nil
|
||||
}
|
||||
|
||||
func TestEncryptDecryptRoundTrip(t *testing.T) {
|
||||
t.Setenv("SUNDYNIX_SECRET_KEY", "unit-test-key")
|
||||
resetForTest()
|
||||
|
||||
plain := "sk-912cf85b16d04b22bcb95f4576423bfb"
|
||||
ct, err := Encrypt(plain)
|
||||
if err != nil {
|
||||
t.Fatalf("encrypt: %v", err)
|
||||
}
|
||||
if !IsEncrypted(ct) {
|
||||
t.Fatalf("expected ciphertext prefix, got %q", ct)
|
||||
}
|
||||
if ct == plain {
|
||||
t.Fatal("ciphertext must differ from plaintext")
|
||||
}
|
||||
got, err := Decrypt(ct)
|
||||
if err != nil {
|
||||
t.Fatalf("decrypt: %v", err)
|
||||
}
|
||||
if got != plain {
|
||||
t.Fatalf("round trip mismatch: got %q want %q", got, plain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptEmptyIsNoop(t *testing.T) {
|
||||
resetForTest()
|
||||
ct, err := Encrypt("")
|
||||
if err != nil || ct != "" {
|
||||
t.Fatalf("empty should stay empty: %q %v", ct, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptLegacyPlaintextPassthrough(t *testing.T) {
|
||||
resetForTest()
|
||||
// 历史明文行无 enc: 前缀,应原样返回(平滑迁移)。
|
||||
got, err := Decrypt("plain-legacy-key")
|
||||
if err != nil {
|
||||
t.Fatalf("legacy passthrough errored: %v", err)
|
||||
}
|
||||
if got != "plain-legacy-key" {
|
||||
t.Fatalf("legacy passthrough mismatch: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNonceRandomized(t *testing.T) {
|
||||
t.Setenv("SUNDYNIX_SECRET_KEY", "unit-test-key")
|
||||
resetForTest()
|
||||
a, _ := Encrypt("same-input")
|
||||
b, _ := Encrypt("same-input")
|
||||
if a == b {
|
||||
t.Fatal("two encryptions of same input must differ (random nonce)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrongKeyFailsClosed(t *testing.T) {
|
||||
t.Setenv("SUNDYNIX_SECRET_KEY", "key-A")
|
||||
resetForTest()
|
||||
ct, _ := Encrypt("secret")
|
||||
|
||||
t.Setenv("SUNDYNIX_SECRET_KEY", "key-B")
|
||||
resetForTest()
|
||||
if _, err := Decrypt(ct); err == nil {
|
||||
t.Fatal("decrypt with wrong key must error, not silently succeed")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user