diff --git a/apps/admin/app/pages/dashboard.vue b/apps/admin/app/pages/dashboard.vue
index 7b75f213..9b63d8a2 100644
--- a/apps/admin/app/pages/dashboard.vue
+++ b/apps/admin/app/pages/dashboard.vue
@@ -307,22 +307,18 @@ function panelStatVal(p: Panel, s: [string, string, boolean?, boolean?]) {
}
// ── 请求构造 ──
+// 只传"具体选中值";选"全部"(空值)时不传任何过滤,由后端按登录账号绑定自动收敛
+// (代理/运营 = 我的应用/产品/区域;超管/管理员 = 真·全部)。前端不再手工拼 apps/product_ids 列表。
function gFilter(): Record
{
const req: Record = {}
- const app = filters.app, region = filters.region, product = filters.product
- if (app === '__ALL__') { if (BOUND_APPS.value.length) req.apps = BOUND_APPS.value }
- else if (app !== '') req.app_id = app
- if (region === '__ALL__') { if (BOUND_REGIONS.value.length) req.regions = BOUND_REGIONS.value }
- else if (region !== '') req.region = region
- if (product === '__ALL__') { if (BOUND_PRODUCTS.value.length) req.product_ids = BOUND_PRODUCTS.value }
- else if (product !== '') req.product_id = parseInt(String(product))
+ if (filters.app !== '') req.app_id = filters.app
+ if (filters.region !== '') req.region = filters.region
+ if (filters.product !== '') req.product_id = parseInt(String(filters.product))
return req
}
function deviceFilter(): Record {
const req: Record = {}
- const product = filters.product
- if (product === '__ALL__') { if (BOUND_PRODUCTS.value.length) req.product_ids = BOUND_PRODUCTS.value }
- else if (product !== '') req.product_id = parseInt(String(product))
+ if (filters.product !== '') req.product_id = parseInt(String(filters.product))
return req
}
@@ -425,10 +421,8 @@ function renderChart(p: Panel, rows: any[]) {
async function initFilters() {
// 应用
if (IS_AGENT.value) {
- const opts: { value: string; label: string }[] = []
- if (BOUND_APPS.value.length) opts.push({ value: '__ALL__', label: '全部应用(我的)' })
- BOUND_APPS.value.forEach((name) => opts.push({ value: name, label: name }))
- appOptions.value = opts
+ // 代理只列绑定应用;"全部应用"复用模板里固定的空值项(选它=不传过滤,后端按账号绑定自动收敛为"我的全部")
+ appOptions.value = BOUND_APPS.value.map((name) => ({ value: name, label: name }))
} else {
try {
const da = await consoleApi('apps/list', {})
diff --git a/apps/services/comm/const.go b/apps/services/comm/const.go
index f2a17fac..ffe7ffd6 100644
--- a/apps/services/comm/const.go
+++ b/apps/services/comm/const.go
@@ -105,6 +105,8 @@ const (
const (
Cache_Product = "cache:product" //product 全量缓存(Redis Hash,field=id)
Cache_EchomeetTemplate = "cache:echomeet_template" //echomeet public 公共模板全量缓存(Redis Hash,field=id)
+ Cache_Factory = "cache:factory" //factory 全量缓存(Redis Hash,field=id;console 后台设备域引用数据)
+ Cache_ProductVersion = "cache:product_version" //product_version 全量缓存(Redis Hash,field=版本id;按 productid 内存过滤)
)
// RPC服务接口定义处
diff --git a/apps/services/console b/apps/services/console
index 2ef9d6f0..70cb2f2d 100755
Binary files a/apps/services/console and b/apps/services/console differ
diff --git a/apps/services/modules/console/api_device.go b/apps/services/modules/console/api_device.go
index 531ff497..0df7a7e7 100644
--- a/apps/services/modules/console/api_device.go
+++ b/apps/services/modules/console/api_device.go
@@ -49,6 +49,7 @@ func (this *serverComp) updateProduct(c *gin.Context, sys *appConn) {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
+ this.module.deviceCache.refreshProducts() // 写后刷新产品缓存
writeOK(c, &pb.ApiUpdateProductResp{})
}
@@ -59,6 +60,7 @@ func (this *serverComp) delProduct(c *gin.Context, sys *appConn) {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
+ this.module.deviceCache.refreshProducts() // 写后刷新产品缓存
writeOK(c, &pb.ApiDelProductResp{})
}
@@ -90,6 +92,10 @@ func (this *serverComp) addProductVersion(c *gin.Context, sys *appConn) {
return
}
}
+ this.module.deviceCache.refreshProductVersions() // 写后刷新版本缓存
+ if req.Isuse {
+ this.module.deviceCache.refreshProducts() // Isuse 改了产品当前版本,连带刷新产品缓存
+ }
writeOK(c, &pb.ApiAddProductVersionResp{Version: req.Version})
}
@@ -100,6 +106,7 @@ func (this *serverComp) delProductVersion(c *gin.Context, sys *appConn) {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
+ this.module.deviceCache.refreshProductVersions() // 写后刷新版本缓存
writeOK(c, &pb.ApiDelProductVersionResp{})
}
@@ -131,6 +138,8 @@ func (this *serverComp) addProduct(c *gin.Context, sys *appConn) {
}
// 注:modules/api 这里还会 RpcBroadcast 通知 home 热重载配置;console 不接 RPCX,
// 如需应用感知,走 /console/api/config/notify 发 NATS 事件。
+ this.module.deviceCache.refreshProducts() // 新增产品 + 改了厂家 Productlists,刷新两者缓存
+ this.module.deviceCache.refreshFactorys()
writeOK(c, &pb.ApiAddProductResp{Product: req.Product})
}
@@ -148,6 +157,7 @@ func (this *serverComp) addFactory(c *gin.Context, sys *appConn) {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
+ this.module.deviceCache.refreshFactorys() // 写后刷新厂家缓存
writeOK(c, &pb.ApiAddFactoryResp{Factory: req.Factory})
}
@@ -173,6 +183,7 @@ func (this *serverComp) updateFactory(c *gin.Context, sys *appConn) {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
+ this.module.deviceCache.refreshFactorys() // 写后刷新厂家缓存
writeOK(c, &pb.ApiUpdateFactoryResp{Factory: old})
}
@@ -287,6 +298,7 @@ func (this *serverComp) createFactoryDevics(c *gin.Context, sys *appConn) {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
+ this.module.deviceCache.refreshFactorys() // 批次号 Probatch 变更,刷新厂家缓存
if err = dvAddFactoryDeliveryNote(sys, &pb.DBFactoryDeliveryNote{
Ts: time.Now().Unix(),
Factoryid: req.Factoryid,
diff --git a/apps/services/modules/console/api_stats.go b/apps/services/modules/console/api_stats.go
index 186e7fa0..5c94f554 100644
--- a/apps/services/modules/console/api_stats.go
+++ b/apps/services/modules/console/api_stats.go
@@ -13,7 +13,8 @@ import (
func (this *serverComp) getStatsSummary(c *gin.Context) {
var req statsQueryReq
_ = c.ShouldBindJSON(&req)
- out, err := queryStatSummary(&req)
+ sw, sa := scopeOf(c).statsClause() // 代理/运营:强制收敛到绑定的应用∪产品(含区域)
+ out, err := queryStatSummary(&req, sw, sa)
if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
@@ -25,7 +26,8 @@ func (this *serverComp) getStatsSummary(c *gin.Context) {
func (this *serverComp) getStatsTrend(c *gin.Context) {
var req statsQueryReq
_ = c.ShouldBindJSON(&req)
- rows, err := queryStatTrend(&req)
+ sw, sa := scopeOf(c).statsClause() // 代理/运营:强制收敛到绑定的应用∪产品(含区域)
+ rows, err := queryStatTrend(&req, sw, sa)
if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
diff --git a/apps/services/modules/console/core.go b/apps/services/modules/console/core.go
index 0809906d..8ce4bfd6 100644
--- a/apps/services/modules/console/core.go
+++ b/apps/services/modules/console/core.go
@@ -43,6 +43,11 @@ type consoleClaims struct {
Identity pb.Identity `json:"idt"` // 角色:1超管 2管理员 3代理商 4运营
Username string `json:"usr"`
AccountId uint32 `json:"aid"` // 账号表 id;引导超管为 0
+ // 数据作用域绑定(代理/运营才有;超管/管理员为空=不受限)。随 token 下发,每个请求据此强制隔离,
+ // 避免每请求回查海外账号库。CSV 格式,与 Account.Apps/Products/Regions 一致。
+ Apps string `json:"aps,omitempty"` // 绑定应用名列表
+ Products string `json:"prd,omitempty"` // 绑定产品 id 列表
+ Regions string `json:"rgn,omitempty"` // 绑定区域代码列表
jwt.RegisteredClaims
}
diff --git a/apps/services/modules/console/model_device.go b/apps/services/modules/console/model_device.go
index b2806513..c1d7b55e 100644
--- a/apps/services/modules/console/model_device.go
+++ b/apps/services/modules/console/model_device.go
@@ -58,6 +58,13 @@ func dvProductVersions(sys *appConn, pid uint32) (models []*pb.DBProductVersion,
return
}
+// dvAllProductVersions 取全部产品版本(缓存全量预热用,不按 productid 过滤)。
+func dvAllProductVersions(sys *appConn) (models []*pb.DBProductVersion, err error) {
+ models = make([]*pb.DBProductVersion, 0)
+ err = sys.AdminDB().Find(comm.TableProductVersion, &models, "")
+ return
+}
+
func dvDelFactory(sys *appConn, id uint32) (err error) {
err = sys.AdminDB().Delete(comm.TableFactory, "id=?", id)
return
diff --git a/apps/services/modules/console/model_device_cache.go b/apps/services/modules/console/model_device_cache.go
new file mode 100644
index 00000000..eedf56a2
--- /dev/null
+++ b/apps/services/modules/console/model_device_cache.go
@@ -0,0 +1,175 @@
+package console
+
+/*
+设备管理域(厂家 / 产品 / 版本)只读缓存。
+
+console 主库(supabase)在海外、直连查询慢;这些是「后台维护、低频变更、高频读取」的引用数据,
+后台多个页面(产品/版本/出货等)每次打开都要全量读,直连 DB 就「卡」。本组件把它们缓存到
+与服务同区域的 Redis,使后台读请求恒命中 Redis:
+
+ - 预热:Start() 异步全量写入 Redis(避免海外慢查询阻塞模块启动)
+ - 定时刷新:内部 ticker 每 10 分钟全量刷新(console 服务未装 cron,用 ticker 自驱)
+ - 写后失效:增删改 handler 成功后调用对应 refreshXxx() 同步重写缓存,使紧随其后的列表读到最新
+
+复用 sys/cache(基于 Redis Hash 的全量数据集);DB 回退用 consoleDeviceConn()(=postgres.GetSys())。
+读方法(GetXxx)缓存优先、未命中或异常时回退查 DB,保证 Redis 异常时功能不受影响。
+*/
+
+import (
+ "context"
+ "fmt"
+ "sort"
+ "time"
+
+ "yunyan/comm"
+ "yunyan/lego/core"
+ "yunyan/lego/core/cbase"
+ "yunyan/lego/sys/log"
+ "yunyan/pb"
+ "yunyan/sys/cache"
+)
+
+// 兜底 TTL:正常由写后刷新 + 定时刷新覆盖,TTL 仅作异常情况下的过期保护
+const deviceCacheTTL = time.Hour * 24
+
+type deviceCacheComp struct {
+ cbase.ModuleCompBase
+ module *Console
+}
+
+func (this *deviceCacheComp) Init(service core.IService, module core.IModule, comp core.IModuleComp, opt core.IModuleOptions) (err error) {
+ this.ModuleCompBase.Init(service, module, comp, opt)
+ this.module = module.(*Console)
+ return
+}
+
+func (this *deviceCacheComp) Start() (err error) {
+ if err = this.ModuleCompBase.Start(); err != nil {
+ return
+ }
+ go this.Refresh() // 异步预热
+ go this.loopRefresh() // 每 10 分钟兜底全量刷新
+ return
+}
+
+// loopRefresh console 服务未初始化 cron 子系统,这里用 ticker 自驱定时刷新。
+func (this *deviceCacheComp) loopRefresh() {
+ t := time.NewTicker(time.Minute * 10)
+ defer t.Stop()
+ for range t.C {
+ this.Refresh()
+ }
+}
+
+// Refresh 全量刷新所有数据集。best-effort:单个数据集失败仅记日志,不影响其它。
+func (this *deviceCacheComp) Refresh() {
+ this.refreshFactorys()
+ this.refreshProducts()
+ this.refreshProductVersions()
+}
+
+// ---- 厂家 ----
+
+func (this *deviceCacheComp) refreshFactorys() {
+ models, err := dvFactorys(consoleDeviceConn())
+ if err != nil {
+ this.module.Error("deviceCache refreshFactorys load", log.Field{Key: "err", Value: err.Error()})
+ return
+ }
+ items := make(map[string]any, len(models))
+ for _, m := range models {
+ items[fmt.Sprintf("%d", m.Id)] = m
+ }
+ if err = cache.ReplaceAll(context.Background(), comm.Cache_Factory, items, deviceCacheTTL); err != nil {
+ this.module.Error("deviceCache refreshFactorys write", log.Field{Key: "err", Value: err.Error()})
+ }
+}
+
+// GetFactorys 读全部厂家:缓存优先,缓存为空或异常时回退查 DB。
+// 缓存底层是 Redis Hash(HGetAll 无序),统一按厂家 id 升序,保证列表顺序稳定。
+func (this *deviceCacheComp) GetFactorys() (models []*pb.DBFactory, err error) {
+ if list, cerr := cache.GetAll[pb.DBFactory](context.Background(), comm.Cache_Factory); cerr == nil && len(list) > 0 {
+ models = list
+ } else if models, err = dvFactorys(consoleDeviceConn()); err != nil {
+ return
+ }
+ sort.Slice(models, func(i, j int) bool { return models[i].Id < models[j].Id })
+ return models, nil
+}
+
+// ---- 产品 ----
+
+func (this *deviceCacheComp) refreshProducts() {
+ models, err := dvProducts(consoleDeviceConn())
+ if err != nil {
+ this.module.Error("deviceCache refreshProducts load", log.Field{Key: "err", Value: err.Error()})
+ return
+ }
+ items := make(map[string]any, len(models))
+ for _, m := range models {
+ items[fmt.Sprintf("%d", m.Id)] = m
+ }
+ if err = cache.ReplaceAll(context.Background(), comm.Cache_Product, items, deviceCacheTTL); err != nil {
+ this.module.Error("deviceCache refreshProducts write", log.Field{Key: "err", Value: err.Error()})
+ }
+}
+
+// GetProducts 读全部产品:缓存优先,回退 DB。
+// 缓存底层是 Redis Hash(HGetAll 无序),统一按 厂家id、产品id 升序,保证列表顺序稳定。
+func (this *deviceCacheComp) GetProducts() (models []*pb.DBProduct, err error) {
+ if list, cerr := cache.GetAll[pb.DBProduct](context.Background(), comm.Cache_Product); cerr == nil && len(list) > 0 {
+ models = list
+ } else if models, err = dvProducts(consoleDeviceConn()); err != nil {
+ return
+ }
+ sort.Slice(models, func(i, j int) bool {
+ if models[i].Factoryid != models[j].Factoryid {
+ return models[i].Factoryid < models[j].Factoryid
+ }
+ return models[i].Id < models[j].Id
+ })
+ return models, nil
+}
+
+// GetProduct 按 id 读产品:缓存优先,回退 DB。
+func (this *deviceCacheComp) GetProduct(id uint32) (model *pb.DBProduct, err error) {
+ if v, found, cerr := cache.GetOne[pb.DBProduct](context.Background(), comm.Cache_Product, fmt.Sprintf("%d", id)); cerr == nil && found {
+ return v, nil
+ }
+ return dvProduct(consoleDeviceConn(), id)
+}
+
+// ---- 产品版本 ----
+// 全部版本存一个 Hash(field=版本id),按 productid 在内存过滤。版本数据量小,整表缓存足够。
+
+func (this *deviceCacheComp) refreshProductVersions() {
+ models, err := dvAllProductVersions(consoleDeviceConn())
+ if err != nil {
+ this.module.Error("deviceCache refreshProductVersions load", log.Field{Key: "err", Value: err.Error()})
+ return
+ }
+ items := make(map[string]any, len(models))
+ for _, m := range models {
+ items[fmt.Sprintf("%d", m.Id)] = m
+ }
+ if err = cache.ReplaceAll(context.Background(), comm.Cache_ProductVersion, items, deviceCacheTTL); err != nil {
+ this.module.Error("deviceCache refreshProductVersions write", log.Field{Key: "err", Value: err.Error()})
+ }
+}
+
+// GetProductVersions 读某产品的版本:缓存命中则内存过滤 productid,未命中回退 DB(仅查该产品)。
+// 同样按版本 id 升序,避免 Hash 无序导致版本列表顺序漂移。
+func (this *deviceCacheComp) GetProductVersions(pid uint32) (models []*pb.DBProductVersion, err error) {
+ if list, cerr := cache.GetAll[pb.DBProductVersion](context.Background(), comm.Cache_ProductVersion); cerr == nil && len(list) > 0 {
+ models = make([]*pb.DBProductVersion, 0, len(list))
+ for _, v := range list {
+ if v.Productid == pid {
+ models = append(models, v)
+ }
+ }
+ } else if models, err = dvProductVersions(consoleDeviceConn(), pid); err != nil {
+ return
+ }
+ sort.Slice(models, func(i, j int) bool { return models[i].Id < models[j].Id })
+ return models, nil
+}
diff --git a/apps/services/modules/console/model_stat.go b/apps/services/modules/console/model_stat.go
index 114b8fd9..450731b7 100644
--- a/apps/services/modules/console/model_stat.go
+++ b/apps/services/modules/console/model_stat.go
@@ -209,10 +209,25 @@ func statWhere(req *statsQueryReq) (string, []interface{}) {
return strings.Join(conds, " AND "), args
}
-// queryStatSummary 区间总量(全维度 SUM 成一行)。
-func queryStatSummary(req *statsQueryReq) (*StatMetrics, error) {
+// andWhere 把两个 WHERE 片段用 AND 合并(任一为空则取另一个),并按顺序拼接参数。
+func andWhere(w1 string, a1 []interface{}, w2 string, a2 []interface{}) (string, []interface{}) {
+ switch {
+ case w1 == "" && w2 == "":
+ return "", nil
+ case w1 == "":
+ return w2, a2
+ case w2 == "":
+ return w1, a1
+ default:
+ return "(" + w1 + ") AND (" + w2 + ")", append(append([]interface{}{}, a1...), a2...)
+ }
+}
+
+// queryStatSummary 区间总量(全维度 SUM 成一行)。scopeWhere/scopeArgs 为账号作用域附加条件(与下钻 AND)。
+func queryStatSummary(req *statsQueryReq, scopeWhere string, scopeArgs []interface{}) (*StatMetrics, error) {
out := &StatMetrics{}
where, args := statWhere(req)
+ where, args = andWhere(where, args, scopeWhere, scopeArgs)
tx := postgres.Table(comm.TableStatsGlobalDay).Select(statSumSelect())
if where != "" {
tx = tx.Where(where, args...)
@@ -222,9 +237,10 @@ func queryStatSummary(req *statsQueryReq) (*StatMetrics, error) {
}
// queryStatTrend 按 stat_day 分组的逐日序列(升序)。
-func queryStatTrend(req *statsQueryReq) ([]*StatTrendRow, error) {
+func queryStatTrend(req *statsQueryReq, scopeWhere string, scopeArgs []interface{}) ([]*StatTrendRow, error) {
rows := make([]*StatTrendRow, 0)
where, args := statWhere(req)
+ where, args = andWhere(where, args, scopeWhere, scopeArgs)
tx := postgres.Table(comm.TableStatsGlobalDay).
Select(statSumSelect("stat_day")).
Group("stat_day").Order("stat_day ASC")
diff --git a/apps/services/modules/console/module.go b/apps/services/modules/console/module.go
index db4823d8..5163609d 100644
--- a/apps/services/modules/console/module.go
+++ b/apps/services/modules/console/module.go
@@ -22,11 +22,12 @@ func NewModule() core.IModule {
type Console struct {
modules.ModuleBase
- options *Options
- model *modelComp
- registry *registryComp
- server *serverComp
- stat *statComp
+ options *Options
+ model *modelComp
+ registry *registryComp
+ server *serverComp
+ stat *statComp
+ deviceCache *deviceCacheComp
}
func (this *Console) GetType() core.M_Modules {
@@ -47,9 +48,11 @@ func (this *Console) Init(service core.IService, module core.IModule, options co
func (this *Console) OnInstallComp() {
this.ModuleBase.OnInstallComp()
- // 注册顺序即初始化顺序:model 先建好注册表,registry 再依赖它按应用建连,server 最后对外。
+ // 注册顺序即初始化顺序:model 先建好注册表,registry 再依赖它按应用建连,
+ // deviceCache 预热设备域只读缓存,server 最后对外(读 handler 走 deviceCache)。
this.model = this.RegisterComp(new(modelComp)).(*modelComp)
this.registry = this.RegisterComp(new(registryComp)).(*registryComp)
this.stat = this.RegisterComp(new(statComp)).(*statComp)
+ this.deviceCache = this.RegisterComp(new(deviceCacheComp)).(*deviceCacheComp)
this.server = this.RegisterComp(new(serverComp)).(*serverComp)
}
diff --git a/apps/services/modules/console/scope.go b/apps/services/modules/console/scope.go
new file mode 100644
index 00000000..4977624f
--- /dev/null
+++ b/apps/services/modules/console/scope.go
@@ -0,0 +1,137 @@
+package console
+
+/*
+账号数据作用域(后端强制隔离)。
+
+登录时把账号绑定(apps/products/regions,CSV)写进 JWT,tokenValid 解析后放进 gin.Context。
+每个数据接口用 scopeOf(c) 取出作用域,对查询/结果强制按绑定收敛——无论前端传什么、是否传 X-App-Id,
+代理(3)/运营(4) 都只能拿到自己绑定范围内的数据;超管(1)/管理员(2) 不受限。
+
+语义(与前端既有行为一致):某维度绑定为空 = 该维度不限制;非空 = 仅限列表内。
+*/
+
+import (
+ "strconv"
+ "strings"
+
+ "yunyan/pb"
+
+ "github.com/gin-gonic/gin"
+)
+
+type acctScope struct {
+ unlimited bool // 超管/管理员:不受任何限制
+ apps []string // 绑定应用名(空=应用维度不限)
+ products []uint32 // 绑定产品 id(空=产品维度不限)
+ regions []string // 绑定区域代码(空=区域维度不限)
+}
+
+func ctxStr(c *gin.Context, k string) string {
+ if v, ok := c.Get(k); ok {
+ if s, ok := v.(string); ok {
+ return s
+ }
+ }
+ return ""
+}
+
+func splitCSV(s string) []string {
+ out := make([]string, 0)
+ for _, p := range strings.Split(s, ",") {
+ if p = strings.TrimSpace(p); p != "" {
+ out = append(out, p)
+ }
+ }
+ return out
+}
+
+func splitCSVU32(s string) []uint32 {
+ out := make([]uint32, 0)
+ for _, p := range splitCSV(s) {
+ if n, err := strconv.ParseUint(p, 10, 32); err == nil {
+ out = append(out, uint32(n))
+ }
+ }
+ return out
+}
+
+func containsStr(list []string, v string) bool {
+ for _, x := range list {
+ if x == v {
+ return true
+ }
+ }
+ return false
+}
+
+// scopeOf 从已鉴权的请求上下文解析当前账号的数据作用域。
+func scopeOf(c *gin.Context) acctScope {
+ switch currentIdentity(c) {
+ case pb.Identity_Admin, pb.Identity_Manager:
+ return acctScope{unlimited: true} // 超管/管理员不受工厂/产品/应用限制
+ default:
+ return acctScope{
+ apps: splitCSV(ctxStr(c, "apps")),
+ products: splitCSVU32(ctxStr(c, "products")),
+ regions: splitCSV(ctxStr(c, "regions")),
+ }
+ }
+}
+
+func (s acctScope) allowApp(name string) bool {
+ return s.unlimited || len(s.apps) == 0 || containsStr(s.apps, name)
+}
+
+func (s acctScope) allowProduct(id uint32) bool {
+ if s.unlimited || len(s.products) == 0 {
+ return true
+ }
+ for _, p := range s.products {
+ if p == id {
+ return true
+ }
+ }
+ return false
+}
+
+// filterProducts 把产品列表收敛到绑定范围内。
+func (s acctScope) filterProducts(in []*pb.DBProduct) []*pb.DBProduct {
+ if s.unlimited || len(s.products) == 0 {
+ return in
+ }
+ out := make([]*pb.DBProduct, 0, len(in))
+ for _, p := range in {
+ if s.allowProduct(p.Id) {
+ out = append(out, p)
+ }
+ }
+ return out
+}
+
+// statsClause 返回账号作用域的统计过滤子句(与前端下钻条件 AND,强制收敛、防越权)。
+//
+// 口径:按"最具体的绑定"收敛——有产品绑定就**只按产品**,否则按应用。这样对绑定产品的代理,
+// 「全部产品」(不传产品) 与「选该产品」(传 product_id) 落到同一过滤 product_id IN(绑定),结果一致。
+// 注意:登录/用户/订单等"应用级"指标记在 product_id=0,按产品收敛后对代理显示 0(它们不挂产品);
+// apps 绑定改为只用于"用户查询"页(X-App-Id 校验),不参与仪表盘统计。
+// 区域再 AND 上(含未归属 region='',让产品级空区域行能通过)。超管/管理员或完全无绑定 → 返回空。
+func (s acctScope) statsClause() (string, []interface{}) {
+ if s.unlimited {
+ return "", nil
+ }
+ conds := make([]string, 0, 2)
+ args := make([]interface{}, 0, 2)
+
+ if len(s.products) > 0 {
+ conds = append(conds, "product_id IN ?")
+ args = append(args, s.products)
+ } else if len(s.apps) > 0 {
+ conds = append(conds, "app_id IN ?")
+ args = append(args, s.apps)
+ }
+ if len(s.regions) > 0 {
+ conds = append(conds, "(region IN ? OR region = '')")
+ args = append(args, s.regions)
+ }
+ return strings.Join(conds, " AND "), args
+}
diff --git a/apps/services/modules/console/server.go b/apps/services/modules/console/server.go
index f788a11a..76b827ef 100644
--- a/apps/services/modules/console/server.go
+++ b/apps/services/modules/console/server.go
@@ -167,10 +167,13 @@ func (this *serverComp) tokenValid(c *gin.Context) bool {
if err != nil {
return false
}
- // 解析出的身份存进上下文,供 requireIdentity 与后续 handler 取用。
+ // 解析出的身份与作用域存进上下文,供 requireIdentity / scopeOf 与后续 handler 取用。
c.Set("identity", claims.Identity)
c.Set("username", claims.Username)
c.Set("account_id", claims.AccountId)
+ c.Set("apps", claims.Apps)
+ c.Set("products", claims.Products)
+ c.Set("regions", claims.Regions)
return true
}
@@ -332,7 +335,11 @@ func (this *serverComp) handleWeb(c *gin.Context) {
// 仅重置类要清各应用业务库的 userdevice/product_stat,按 X-App-Id 取该应用业务库填入 sys.service。
switch method {
case "api_resetlicensestatus", "api_batchresetlicensestatus", "api_restfactorydevics":
- svc, err := this.module.registry.getServiceDB(parseAppId(c.GetHeader("X-App-Id")))
+ appId := parseAppId(c.GetHeader("X-App-Id"))
+ if !this.requireAppScope(c, appId) {
+ return
+ }
+ svc, err := this.module.registry.getServiceDB(appId)
if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
@@ -400,6 +407,25 @@ func parseAppId(s string) uint32 {
return uint32(n)
}
+// requireAppScope 校验 X-App-Id 指向的应用在当前账号作用域内(代理/运营)。
+// 越界即写好错误响应并返回 false,调用方直接 return。超管/管理员或未绑定应用维度时直接放行。
+func (this *serverComp) requireAppScope(c *gin.Context, appId uint32) bool {
+ s := scopeOf(c)
+ if s.unlimited || len(s.apps) == 0 {
+ return true
+ }
+ app, err := this.module.model.getApp(appId)
+ if err != nil || app == nil {
+ writeErr(c, pb.ErrorCode_ReqParameterError, "应用不存在或无权访问")
+ return false
+ }
+ if !s.allowApp(app.Name) {
+ writeErr(c, pb.ErrorCode_InsufficientPermissions, "无权访问该应用")
+ return false
+ }
+ return true
+}
+
// ============================ 登录 / 站点信息 ============================
func (this *serverComp) login(c *gin.Context) {
@@ -411,10 +437,20 @@ func (this *serverComp) login(c *gin.Context) {
return
}
now := time.Now()
+ // 作用域绑定(代理/运营才有;超管/管理员为空=不受限):先查出来,既写进 token 供请求级强制隔离,也回传前端控菜单/筛选。
+ var access, apps, regions, products string
+ if accountId > 0 {
+ if acc, e := this.module.model.getAccount(accountId); e == nil && acc != nil {
+ access, apps, regions, products = acc.Access, acc.Apps, acc.Regions, acc.Products
+ }
+ }
claims := &consoleClaims{
Identity: identity,
Username: req.Account,
AccountId: accountId,
+ Apps: apps,
+ Products: products,
+ Regions: regions,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "console",
Subject: fmt.Sprintf("%d", identity),
@@ -429,13 +465,6 @@ func (this *serverComp) login(c *gin.Context) {
writeErr(c, pb.ErrorCode_SystemError, err.Error())
return
}
- // 作用域绑定随登录回传,前端据此限制菜单/筛选器(代理/运营才有;超管/管理员为空=不受限)。
- var access, apps, regions, products string
- if accountId > 0 {
- if acc, e := this.module.model.getAccount(accountId); e == nil && acc != nil {
- access, apps, regions, products = acc.Access, acc.Apps, acc.Regions, acc.Products
- }
- }
// 把绑定应用名解析为 {id,name}:代理无权调 apps/list,但 users.html / 切换器需要注册 id 取 X-App-Id。
appBinds := make([]gin.H, 0)
if apps != "" {
@@ -492,28 +521,52 @@ func (this *serverComp) getSiteInfo(c *gin.Context) {
// 逻辑从 modules/api 的对应 handler 移植;去掉 RPCX session 与 scope 白名单(第一期仅超管),
// 数据访问改为作用于传入的"选中应用"连接 sys。
+// 读 handler 统一走 deviceCache(缓存优先 + DB 回退),设备域引用数据恒命中 Redis,避免直连海外主库的卡顿。
func (this *serverComp) getFactorys(c *gin.Context, sys *appConn) {
- models, err := dvFactorys(sys)
+ models, err := this.module.deviceCache.GetFactorys()
if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
+ // 代理/运营:由绑定产品反推可见厂家(只保留拥有绑定产品的厂家)。
+ if s := scopeOf(c); !s.unlimited && len(s.products) > 0 {
+ allowFid := make(map[uint32]bool)
+ if prods, perr := this.module.deviceCache.GetProducts(); perr == nil {
+ for _, p := range prods {
+ if s.allowProduct(p.Id) {
+ allowFid[p.Factoryid] = true
+ }
+ }
+ }
+ kept := make([]*pb.DBFactory, 0, len(models))
+ for _, f := range models {
+ if allowFid[f.Id] {
+ kept = append(kept, f)
+ }
+ }
+ models = kept
+ }
writeOK(c, &pb.ApiGetFactorysResp{Factorys: models})
}
func (this *serverComp) getProducts(c *gin.Context, sys *appConn) {
- models, err := dvProducts(sys)
+ models, err := this.module.deviceCache.GetProducts()
if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
+ models = scopeOf(c).filterProducts(models) // 代理/运营:仅返回绑定产品
writeOK(c, &pb.ApiGetProductsResp{Products: models})
}
func (this *serverComp) getProduct(c *gin.Context, sys *appConn) {
var req pb.ApiGetProductReq
_ = c.ShouldBindJSON(&req)
- model, err := dvProduct(sys, req.Id)
+ if !scopeOf(c).allowProduct(req.Id) {
+ writeErr(c, pb.ErrorCode_InsufficientPermissions, "无权访问该产品")
+ return
+ }
+ model, err := this.module.deviceCache.GetProduct(req.Id)
if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
@@ -524,7 +577,11 @@ func (this *serverComp) getProduct(c *gin.Context, sys *appConn) {
func (this *serverComp) getProductVersions(c *gin.Context, sys *appConn) {
var req pb.ApiGetProductVersionsReq
_ = c.ShouldBindJSON(&req)
- models, err := dvProductVersions(sys, req.Pid)
+ if !scopeOf(c).allowProduct(req.Pid) {
+ writeErr(c, pb.ErrorCode_InsufficientPermissions, "无权访问该产品")
+ return
+ }
+ models, err := this.module.deviceCache.GetProductVersions(req.Pid)
if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
@@ -539,6 +596,7 @@ func (this *serverComp) delFactory(c *gin.Context, sys *appConn) {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
}
+ this.module.deviceCache.refreshFactorys() // 写后刷新厂家缓存
writeOK(c, &pb.ApiDelFactoryResp{})
}
@@ -792,7 +850,11 @@ func (this *serverComp) delFactoryPublicCode(c *gin.Context, sys *appConn) {
// 据此经 registry.getServiceDB 取该部署的业务库(MySQL)连接,按 uid 直查 user 表返回。
// 业务数据在各应用自己的业务库,故不走 console 主库(supabase)。
func (this *serverComp) getUserInfo(c *gin.Context) {
- conn, err := this.module.registry.getServiceDB(parseAppId(c.GetHeader("X-App-Id")))
+ appId := parseAppId(c.GetHeader("X-App-Id"))
+ if !this.requireAppScope(c, appId) {
+ return
+ }
+ conn, err := this.module.registry.getServiceDB(appId)
if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
@@ -850,7 +912,11 @@ func (this *serverComp) giftUserResource(c *gin.Context) {
writeErr(c, pb.ErrorCode_ReqParameterError, "请至少填写一项赠送数量")
return
}
- conn, err := this.module.registry.getServiceDB(parseAppId(c.GetHeader("X-App-Id")))
+ appId := parseAppId(c.GetHeader("X-App-Id"))
+ if !this.requireAppScope(c, appId) {
+ return
+ }
+ conn, err := this.module.registry.getServiceDB(appId)
if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error())
return
diff --git a/build.sh b/build.sh
new file mode 100755
index 00000000..127dfa72
--- /dev/null
+++ b/build.sh
@@ -0,0 +1,38 @@
+#!/usr/bin/env bash
+# 构建(可选推送)服务镜像。被 deploy/<服务>/deploy.sh 的 build/deploy 动作调用,也可单独用。
+#
+# 用法: [TAG=.. REGISTRY=.. PLATFORM=.. PUSH=0] ./build.sh
+# service : console | admin
+# PUSH=0 : 只构建不推送(本机看效果用;deploy.sh 在 local 环境会自动传 PUSH=0)
+#
+# 镜像名固定 = /yunyan-:,与各 docker-compose.yml 对齐。
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+SERVICE="${1:-}"
+[ -n "$SERVICE" ] || { echo "用法: ./build.sh "; exit 1; }
+
+REGISTRY="${REGISTRY:-docker-registry.ideapsound.com}"
+TAG="${TAG:-latest}"
+PLATFORM="${PLATFORM:-linux/amd64}"
+PUSH="${PUSH:-1}"
+
+# 服务 → 构建上下文 / Dockerfile(见各 Dockerfile 顶部「构建上下文」注释)
+case "$SERVICE" in
+ console) CONTEXT="$REPO_ROOT/apps/services"; DOCKERFILE="$CONTEXT/Dockerfile.console" ;;
+ admin) CONTEXT="$REPO_ROOT/apps/admin"; DOCKERFILE="$CONTEXT/Dockerfile" ;;
+ *) echo "未知服务: $SERVICE(应为 console|admin)"; exit 1 ;;
+esac
+[ -f "$DOCKERFILE" ] || { echo "✗ 找不到 Dockerfile: $DOCKERFILE"; exit 1; }
+
+IMAGE="$REGISTRY/yunyan-$SERVICE:$TAG"
+
+echo ">>> 构建 $IMAGE (platform=$PLATFORM, context=$CONTEXT)"
+docker build --platform "$PLATFORM" -f "$DOCKERFILE" -t "$IMAGE" "$CONTEXT"
+
+if [ "$PUSH" = 1 ]; then
+ echo ">>> 推送 $IMAGE"
+ docker push "$IMAGE"
+else
+ echo ">>> 跳过推送(PUSH=0),镜像已在本地:$IMAGE"
+fi
diff --git a/deploy/.gitignore b/deploy/.gitignore
index af990384..9825eee1 100644
--- a/deploy/.gitignore
+++ b/deploy/.gitignore
@@ -1,11 +1,6 @@
-# 真实环境变量与部署目标(含密钥 / DSN / SSH 密码),勿提交
-env/*.env
-targets/*.conf
-.env
-
-# 保留模板
-!env/example.env
-!targets/example.conf
+# 真实环境变量(含密钥 / DSN / SSH),勿提交;仓库只保留 *.example 模板
+*/env/*.env
+*/.env
# 运行时日志
console/log/
diff --git a/deploy/README.md b/deploy/README.md
index 18ff0548..9cf5bc04 100644
--- a/deploy/README.md
+++ b/deploy/README.md
@@ -1,64 +1,73 @@
# yunyan-sas 部署
-三环境部署:`local`(本机)/ `dev`(开发服务器)/ `prod`(生产服务器),统一入口 `deploy.sh`。
-服务:`yunyan-console`(Go 控制面,:8080)+ `yunyan-admin`(Nuxt 管理前端,:3000)。
+**每个服务目录各一个 `deploy.sh`**,服务名取自所在目录,指令为 ` [action]`。
+每个服务自带单服务 compose + 环境模板;依赖服务(PostgreSQL / Redis / NATS)
+由目标环境**共享提供**,不在 compose 内搭建,只在 env 里填连接地址。
+
+## 服务
+- `console/` — Go 控制面服务(:8080)
+- `admin/` — Nuxt 管理前端(:3000,通过 `CONSOLE_BACKEND` 连 console)
## 目录结构
```
deploy/
-├── deploy.sh # 部署入口
-├── docker-compose.yml # 变量驱动 compose(console + admin)
-├── env/
-│ ├── local.env # 本机运行时变量
-│ ├── dev.env / prod.env # 各环境运行时变量(占位,需填)
-│ └── example.env # 模板
-├── targets/
-│ ├── dev.conf / prod.conf # 远程目标 SSH/目录/仓库(占位,需填)
-│ └── example.conf # 模板
-└── console/confs/console.yaml # console 配置(变量从 .env 注入)
+└── / # console / admin
+ ├── deploy.sh # 本服务部署脚本(服务名 = 目录名)
+ ├── docker-compose.yml # 单服务 compose(变量驱动,无依赖服务)
+ ├── env/
+ │ ├── local.env.example
+ │ ├── dev.env.example
+ │ ├── prod.env.example
+ │ └── (复制填写后的 local.env / dev.env / prod.env)
+ └── (console 额外: confs/console.yaml、log/)
```
-
-## 首次配置(dev / prod)
-1. 填 `env/dev.env`、`env/prod.env`:`POSTGRES_DSN` / `REDIS_*` / `NATS_URL` / `DOCKER_NETWORK` / `ADMIN_PORT` / `CONSOLE_*` / `FIELD_ENCRYPT_KEY`。
- - DSN 密码里的特殊字符要 URL 编码(`&` → `%26`)。
-2. 填 `targets/dev.conf`、`targets/prod.conf`:`SSH_HOST` / `SSH_PORT` / `SSH_USER` / `SSH_KEY` / `REMOTE_DIR` / `REGISTRY_PASS`。
-3. 目标服务器需:装 docker + docker compose v2、能访问私有镜像仓库、SSH 公钥已授权。
+> 两个服务的 `deploy.sh` 内容相同,靠所在目录名区分服务。
## 用法
+进入服务目录运行:
```bash
-# 本机
-./deploy.sh local up # 起服务(访问 http://localhost:3000)
-./deploy.sh local down
-./deploy.sh local logs
-
-# 开发服务器(一键:构建推送镜像 + 同步配置 + 远程拉起)
-./deploy.sh dev deploy
-./deploy.sh dev ps
-./deploy.sh dev logs
+cd deploy/console
+./deploy.sh local up # 本机起 console
+./deploy.sh dev # 一键发布 console 到开发服务器(动作默认 deploy)
+./deploy.sh prod # 一键发布 console 到生产服务器
+./deploy.sh prod logs # 看生产 console 日志
-# 生产服务器
-./deploy.sh prod deploy
-./deploy.sh prod restart
+cd deploy/admin
+./deploy.sh local up # 本机起 admin
+./deploy.sh prod # 发布 admin 到生产服务器
```
-## 操作矩阵
-| 操作 | local | dev / prod |
-|------|-------|-----------|
-| up | 本机 compose up -d | 同步配置 + 远程 pull + up |
-| down / restart / logs / ps | 本机 | 远程 |
-| pull | 本机拉镜像 | 远程拉镜像 |
-| build | 本机构建推送镜像(TAG 取自 env) | 同(构建始终在本机) |
-| deploy | build + up | build + 同步 + 远程 pull + up |
+参数:
+- `env`:`local` | `dev` | `prod`
+- `action`:
+ - `local`:up(默认)| down | restart | logs | ps | pull | build
+ - `dev` / `prod`:deploy(默认,构建推送+同步+远程拉起)| up(仅远程拉起)| build | down | restart | logs | ps | pull
+
+## 首次配置(dev / prod)
+配置分两处:**部署目标/仓库凭据写死在各服务的 `deploy.sh`,运行时配置放 env 文件**。
+
+1. 部署目标 + 私有仓库(改各服务 `deploy.sh` 顶部 `deploy_profile`,[console](console/deploy.sh) / [admin](admin/deploy.sh)):
+ - 按 `dev` / `prod` 段填 `DEPLOY_HOST`(服务器 IP) 和 `REGISTRY_PASS`(仓库密码)
+ - 通用项已给默认:`REGISTRY` / `REGISTRY_USER` / `DEPLOY_PORT` / `DEPLOY_USER` / `DEPLOY_KEY`
+ - `DEPLOY_DIR` 自动 = `/opt/yunyan/<服务名>`,无需填
+ - 注:两个 `deploy.sh` 各填各的,不共享
+2. 运行时配置(每个服务、每个环境一份 env):
+ ```bash
+ cp console/env/dev.env.example console/env/dev.env
+ cp admin/env/dev.env.example admin/env/dev.env
+ ```
+ 需要填:
+ - 依赖服务连接(console):`POSTGRES_DSN` / `REDIS_ADDR`+`REDIS_PASSWORD` / `NATS_URL`
+ - 网络与端口:`DOCKER_NETWORK` / `ADMIN_PORT` / `TAG`
+3. 目标机要求:装 docker + docker compose v2、能访问私有镜像仓库、SSH 公钥已授权、依赖服务在 `DOCKER_NETWORK` 网络内可达。
-## deploy 流程(dev / prod)
-1. 本机 `build.sh` 构建 console + admin 镜像并推送私有仓库(TAG 取自对应 env)。
-2. SSH 到目标机,创建 `REMOTE_DIR`。
-3. scp 同步:`docker-compose.yml` + `env/<环境>.env`(→ 远程 `.env`) + `console/confs/console.yaml`。
-4. 远程:确保 `DOCKER_NETWORK` 网络存在(不存在则自动创建)+ `docker login` 私有仓库。
-5. 远程:`docker compose pull && docker compose up -d`。
+## 部署顺序
+依赖服务(DB/Redis/NATS)共享、由环境预先提供。两个应用服务按序:
+1. 先 `console`(admin 依赖它)
+2. 再 `admin`(同一目标机、同一 `DOCKER_NETWORK`,用容器名 `yunyan-console` 连 console)
## 注意
-- `env/*.env`、`targets/*.conf` 含密钥,已 `.gitignore` 忽略,**不要提交**。
-- prod 的 `TAG` 建议固定版本号(如 `v1.2.0`)便于回滚;prod 的 `FIELD_ENCRYPT_KEY` / `CONSOLE_TOKEN_KEY` 务必改强随机值。
-- redis / nats:在目标机 `DOCKER_NETWORK` 网络内则用容器名(`redis:6379`);否则在 env 填外部 `IP:端口`。
-- console 首启会在主库自动建表(含 serviceconfig / calltranslate 等新表)。
+- `env/*.env`(真实)含密钥/DSN,已 `.gitignore` 忽略;仓库只保留 `*.example`。
+- ⚠️ 部署目标/仓库密码写死在各服务 `deploy.sh`,而它们**会进 git**——`DEPLOY_HOST`、`REGISTRY_PASS` 等会随仓库提交(私有仓库内可接受;公开前务必清理两个 `deploy.sh`)。
+- prod 的 `TAG` 用固定版本号便于回滚;`FIELD_ENCRYPT_KEY` / `CONSOLE_TOKEN_KEY` 用强随机值,环境间不复用。
+- 镜像构建复用仓库根目录 `build.sh`(`deploy.sh` 在 deploy/build 动作时自动调用,目标=服务名);当前仓库**尚无 build.sh**,build/deploy 前需先补。
diff --git a/deploy/admin/deploy.sh b/deploy/admin/deploy.sh
new file mode 100755
index 00000000..5c3ea9b4
--- /dev/null
+++ b/deploy/admin/deploy.sh
@@ -0,0 +1,184 @@
+#!/usr/bin/env bash
+# <服务> 部署脚本(每个服务目录各一份,服务名取自所在目录名)
+#
+# 用法: ./deploy.sh [action] (在 deploy/<服务>/ 下运行)
+# env : local | dev | prod
+# action : dev/prod → deploy(默认) | up | build | pull | down | restart | logs | ps
+# local → up(默认) | build | pull | down | restart | logs | ps
+#
+# deploy = 构建推送镜像 + 同步配置 + 远程 pull + up(一键发布,仅 dev/prod)
+# up = 远程:仅同步配置 + 远程 pull + up(用已推送镜像);本机:起容器
+#
+# 例(在 deploy/console/ 下):
+# ./deploy.sh prod # 一键发布生产 console
+# ./deploy.sh prod logs # 看生产 console 日志
+# ./deploy.sh local up # 本机起 console
+# ./deploy.sh dev build # 只构建推送 dev 镜像
+set -euo pipefail
+
+SVC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # 本服务部署目录(deploy/<服务>)
+SERVICE="$(basename "$SVC_DIR")" # 服务名 = 目录名(console / admin)
+REPO_ROOT="$(cd "$SVC_DIR/../.." && pwd)" # 仓库根(deploy/<服务>/../..)
+
+blue() { printf "\033[34m%s\033[0m\n" "$*"; }
+green() { printf "\033[32m%s\033[0m\n" "$*"; }
+red() { printf "\033[31m%s\033[0m\n" "$*"; }
+
+# ╔══════════════════════════════════════════════════════════════════════╗
+# ║ 部署目标 / 私有仓库配置 —— 写死在这里,改这里即可(按环境) ║
+# ║ console、admin 同机不同目录;DEPLOY_DIR 自动 = /opt/yunyan/<服务名> ║
+# ║ 注意:本文件纳入 git,下面的密码/IP 会随仓库提交(私有仓库内可接受) ║
+# ╚══════════════════════════════════════════════════════════════════════╝
+deploy_profile() {
+ DEPLOY_HOST=""; REGISTRY_PASS="" # 占位,local 用不到;dev/prod 在下面填
+ # —— 各环境通用默认 ——
+ REGISTRY=docker-registry.ideapsound.com # 私有镜像仓库地址
+ REGISTRY_USER=admin # 仓库账号
+ DEPLOY_PORT=22 # 服务器 SSH 端口
+ DEPLOY_USER=root # 服务器 SSH 用户
+ DEPLOY_KEY=~/.ssh/id_rsa # 本机访问服务器的 SSH 私钥路径
+ case "$ENV_NAME" in
+ dev)
+ DEPLOY_HOST="" # ← 开发服务器 IP(必填)
+ REGISTRY_PASS="" # ← 私有仓库密码(必填)
+ ;;
+ prod)
+ DEPLOY_HOST="" # ← 生产服务器 IP(必填)
+ REGISTRY_PASS="" # ← 私有仓库密码(必填)
+ ;;
+ local) ;; # 本机不走 SSH,无需填
+ esac
+ DEPLOY_DIR="/opt/yunyan/$SERVICE" # 远程部署目录(自动按服务名)
+}
+
+usage() {
+ cat >&2 < [action] (在 deploy/$SERVICE/ 下运行)
+ env : local | dev | prod
+ action : dev/prod → deploy(默认) | up | build | pull | down | restart | logs | ps
+ local → up(默认) | build | pull | down | restart | logs | ps
+例:
+ ./deploy.sh prod # 一键发布生产 $SERVICE
+ ./deploy.sh prod logs # 看生产 $SERVICE 日志
+ ./deploy.sh local up # 本机起 $SERVICE
+ ./deploy.sh dev build # 只构建推送 dev 镜像
+EOF
+ exit 1
+}
+
+# ── 参数解析与校验 ──
+ENV_NAME="${1:-}"; ACTION="${2:-}"
+[ -n "$ENV_NAME" ] || usage
+[ -f "$SVC_DIR/docker-compose.yml" ] || { red "✗ $SERVICE 目录缺 docker-compose.yml(deploy.sh 须放在服务目录内)"; exit 1; }
+case "$ENV_NAME" in
+ local|dev|prod) ;;
+ *) red "✗ 未知环境: ${ENV_NAME}(应为 local|dev|prod)"; usage ;;
+esac
+# 默认动作:远程发布默认 deploy,本机默认 up
+[ -n "$ACTION" ] || { [ "$ENV_NAME" = local ] && ACTION=up || ACTION=deploy; }
+
+# 从 ENV_FILE 安全读取单个变量值(去除可能的外层双引号)
+_envget() {
+ { grep -E "^$1=" "$ENV_FILE" || true; } | head -1 | cut -d= -f2- | sed -e 's/^"//' -e 's/"$//'
+}
+
+# 载入 env/<环境>.env —— 现在只放容器运行时配置(DSN / Redis / NATS / 密钥 / 端口 / TAG / 网络)。
+# 部署目标与仓库凭据已写死在 deploy_profile,不再放 env 文件。脚本只需从这里取 compose 仍要的 TAG/网络。
+load_env() {
+ ENV_FILE="$SVC_DIR/env/$ENV_NAME.env"
+ if [ ! -f "$ENV_FILE" ]; then
+ red "✗ 缺少 $SERVICE/env/$ENV_NAME.env —— 请先从 $SERVICE/env/$ENV_NAME.env.example 复制并填写"
+ exit 1
+ fi
+ TAG="$(_envget TAG)"; TAG="${TAG:-latest}"
+ DOCKER_NETWORK="$(_envget DOCKER_NETWORK)"; DOCKER_NETWORK="${DOCKER_NETWORK:-1panel-network}"
+}
+
+# 生成 compose 用 .env = 服务运行时 env 文件 + 注入写死的 REGISTRY(镜像地址替换用)
+# 先滤掉源文件里可能残留的 REGISTRY 行,保证唯一、不依赖 compose 的覆盖顺序。
+gen_env() {
+ { grep -v '^REGISTRY=' "$ENV_FILE" || true; } > "$SVC_DIR/.env"
+ printf 'REGISTRY=%s\n' "$REGISTRY" >> "$SVC_DIR/.env"
+}
+
+# 构建并推送本服务镜像(复用仓库根目录 build.sh,目标 = 服务名)。
+# local 环境只构建不推送(PUSH=0),dev/prod 构建并推送到私有仓库。
+build_push() {
+ local push=1; [ "$ENV_NAME" = local ] && push=0
+ blue ">>> 构建$([ "$push" = 1 ] && echo 推送) $SERVICE 镜像 (TAG=${TAG:-latest}, PUSH=$push)"
+ TAG="${TAG:-latest}" REGISTRY="$REGISTRY" PUSH="$push" "$REPO_ROOT/build.sh" "$SERVICE"
+}
+
+ensure_network_local() {
+ docker network inspect "$DOCKER_NETWORK" >/dev/null 2>&1 || docker network create "$DOCKER_NETWORK"
+}
+
+# 本机 docker compose(gen_env 落为同目录 .env,既做变量替换又做容器 env_file)
+compose_local() {
+ gen_env
+ ensure_network_local
+ ( cd "$SVC_DIR" && docker compose --env-file .env "$@" )
+}
+
+# ── 远程 SSH 基础 ──
+_ssh() { ssh -p "${DEPLOY_PORT:-22}" -i "${DEPLOY_KEY/#\~/$HOME}" -o StrictHostKeyChecking=accept-new "${DEPLOY_USER:-root}@${DEPLOY_HOST}" "$@"; }
+_scp() { scp -P "${DEPLOY_PORT:-22}" -i "${DEPLOY_KEY/#\~/$HOME}" -o StrictHostKeyChecking=accept-new "$@"; }
+_dest() { echo "${DEPLOY_USER:-root}@${DEPLOY_HOST}:$1"; }
+
+# 远程发布:同步配置 + 远程 login + pull + up
+remote_deploy() {
+ : "${DEPLOY_HOST:?请在 deploy.sh 顶部 deploy_profile 的 $ENV_NAME 段填 DEPLOY_HOST}"
+ gen_env
+
+ blue ">>> 同步 $SERVICE 配置到 ${DEPLOY_USER:-root}@${DEPLOY_HOST}:${DEPLOY_DIR}"
+ _ssh "mkdir -p '$DEPLOY_DIR'"
+ _scp "$SVC_DIR/docker-compose.yml" "$(_dest "$DEPLOY_DIR/docker-compose.yml")"
+ _scp "$SVC_DIR/.env" "$(_dest "$DEPLOY_DIR/.env")"
+ # 带 confs/ 的服务(console)一并同步配置并预建 log 目录
+ if [ -d "$SVC_DIR/confs" ]; then
+ _ssh "mkdir -p '$DEPLOY_DIR/confs' '$DEPLOY_DIR/log'"
+ _scp -r "$SVC_DIR/confs/." "$(_dest "$DEPLOY_DIR/confs/")"
+ fi
+
+ blue ">>> 远程拉起 $SERVICE"
+ [ -n "${REGISTRY_PASS:-}" ] && \
+ _ssh "docker login '$REGISTRY' -u '${REGISTRY_USER:-admin}' -p '$REGISTRY_PASS'"
+ _ssh "docker network inspect '$DOCKER_NETWORK' >/dev/null 2>&1 || docker network create '$DOCKER_NETWORK'"
+ _ssh "cd '$DEPLOY_DIR' && docker compose --env-file .env pull && docker compose --env-file .env up -d"
+}
+
+# 远程运维(down/restart/logs/ps/pull)
+remote_compose() {
+ : "${DEPLOY_HOST:?请在 deploy.sh 顶部 deploy_profile 的 $ENV_NAME 段填 DEPLOY_HOST}"
+ _ssh "cd '$DEPLOY_DIR' && docker compose --env-file .env $*"
+}
+
+# ── 分发 ──
+deploy_profile # 写死的部署目标 / 仓库凭据
+load_env # env 文件里的运行时配置(TAG / 网络)
+
+if [ "$ENV_NAME" = local ]; then
+ case "$ACTION" in
+ up) compose_local up -d ;;
+ down) compose_local down ;;
+ restart) compose_local restart ;;
+ logs) compose_local logs -f --tail=120 ;;
+ ps) compose_local ps ;;
+ pull) compose_local pull ;;
+ build) build_push ;;
+ *) red "未知操作: ${ACTION}(local 支持 up|down|restart|logs|ps|pull|build)"; exit 1 ;;
+ esac
+else
+ case "$ACTION" in
+ deploy) build_push; remote_deploy ;;
+ up) remote_deploy ;;
+ build) build_push ;;
+ pull) remote_compose pull ;;
+ down) remote_compose down ;;
+ restart) remote_compose restart ;;
+ ps) remote_compose ps ;;
+ logs) remote_compose logs -f --tail=120 ;;
+ *) red "未知操作: ${ACTION}(dev/prod 支持 deploy|up|build|pull|down|restart|logs|ps)"; exit 1 ;;
+ esac
+fi
+green "✓ $SERVICE $ENV_NAME $ACTION 完成"
diff --git a/deploy/admin/docker-compose.yml b/deploy/admin/docker-compose.yml
new file mode 100644
index 00000000..d5fb52a2
--- /dev/null
+++ b/deploy/admin/docker-compose.yml
@@ -0,0 +1,29 @@
+# admin 服务部署(单服务)
+#
+# 只部署 yunyan-admin(Nuxt 管理前端)。它通过 CONSOLE_BACKEND 连接同网络的 console 服务。
+# 依赖服务由目标环境共享提供,不在此 compose。
+#
+# 不直接 docker compose 调用,统一通过 deploy.sh。
+
+services:
+ yunyan-admin:
+ image: ${REGISTRY:-docker-registry.ideapsound.com}/yunyan-admin:${TAG:-latest}
+ container_name: yunyan-admin
+ restart: unless-stopped
+ platform: ${PLATFORM:-linux/amd64}
+
+ networks:
+ - appnet
+
+ environment:
+ TZ: Asia/Shanghai
+ # server middleware 运行时代理目标(同网络容器名直接解析)
+ CONSOLE_BACKEND: ${CONSOLE_BACKEND:-http://yunyan-console:8080}
+
+ ports:
+ - "${ADMIN_PORT:-3000}:3000"
+
+networks:
+ appnet:
+ name: ${DOCKER_NETWORK:-1panel-network}
+ external: true
diff --git a/deploy/admin/env/dev.env.example b/deploy/admin/env/dev.env.example
new file mode 100644
index 00000000..22ea82c5
--- /dev/null
+++ b/deploy/admin/env/dev.env.example
@@ -0,0 +1,10 @@
+# admin 服务 · dev 开发环境(复制为 dev.env 后填写真实值)
+# 仓库地址 REGISTRY 已写死在 deploy.sh,这里不填
+TAG=dev
+PLATFORM=linux/amd64
+DOCKER_NETWORK=yunyan-net
+ADMIN_PORT=3000
+CONSOLE_BACKEND=http://yunyan-console:8080
+
+# 部署目标(DEPLOY_HOST/USER/KEY/DIR)与仓库凭据(REGISTRY_USER/PASS)已写死在
+# deploy.sh 顶部的 deploy_profile(dev 段),不在此文件填。
diff --git a/deploy/admin/env/local.env.example b/deploy/admin/env/local.env.example
new file mode 100644
index 00000000..31547f4f
--- /dev/null
+++ b/deploy/admin/env/local.env.example
@@ -0,0 +1,8 @@
+# admin 服务 · local 本机环境(复制为 local.env 后使用)
+# 仓库地址 REGISTRY 已写死在 deploy.sh,这里不填
+TAG=latest
+PLATFORM=linux/amd64
+DOCKER_NETWORK=1panel-network
+ADMIN_PORT=3000
+# 连接同网络的 console 服务(容器名);若 console 不在同机/同网络,填其可达地址
+CONSOLE_BACKEND=http://yunyan-console:8080
diff --git a/deploy/admin/env/prod.env.example b/deploy/admin/env/prod.env.example
new file mode 100644
index 00000000..2a7577c8
--- /dev/null
+++ b/deploy/admin/env/prod.env.example
@@ -0,0 +1,10 @@
+# admin 服务 · prod 生产环境(复制为 prod.env 后填写真实值)
+# 仓库地址 REGISTRY 已写死在 deploy.sh,这里不填
+TAG=latest # 生产建议固定版本号,如 v1.2.0
+PLATFORM=linux/amd64
+DOCKER_NETWORK=yunyan-net
+ADMIN_PORT=3000
+CONSOLE_BACKEND=http://yunyan-console:8080
+
+# 部署目标(DEPLOY_HOST/USER/KEY/DIR)与仓库凭据(REGISTRY_USER/PASS)已写死在
+# deploy.sh 顶部的 deploy_profile(prod 段),不在此文件填。
diff --git a/deploy/console/deploy.sh b/deploy/console/deploy.sh
new file mode 100755
index 00000000..5c3ea9b4
--- /dev/null
+++ b/deploy/console/deploy.sh
@@ -0,0 +1,184 @@
+#!/usr/bin/env bash
+# <服务> 部署脚本(每个服务目录各一份,服务名取自所在目录名)
+#
+# 用法: ./deploy.sh [action] (在 deploy/<服务>/ 下运行)
+# env : local | dev | prod
+# action : dev/prod → deploy(默认) | up | build | pull | down | restart | logs | ps
+# local → up(默认) | build | pull | down | restart | logs | ps
+#
+# deploy = 构建推送镜像 + 同步配置 + 远程 pull + up(一键发布,仅 dev/prod)
+# up = 远程:仅同步配置 + 远程 pull + up(用已推送镜像);本机:起容器
+#
+# 例(在 deploy/console/ 下):
+# ./deploy.sh prod # 一键发布生产 console
+# ./deploy.sh prod logs # 看生产 console 日志
+# ./deploy.sh local up # 本机起 console
+# ./deploy.sh dev build # 只构建推送 dev 镜像
+set -euo pipefail
+
+SVC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # 本服务部署目录(deploy/<服务>)
+SERVICE="$(basename "$SVC_DIR")" # 服务名 = 目录名(console / admin)
+REPO_ROOT="$(cd "$SVC_DIR/../.." && pwd)" # 仓库根(deploy/<服务>/../..)
+
+blue() { printf "\033[34m%s\033[0m\n" "$*"; }
+green() { printf "\033[32m%s\033[0m\n" "$*"; }
+red() { printf "\033[31m%s\033[0m\n" "$*"; }
+
+# ╔══════════════════════════════════════════════════════════════════════╗
+# ║ 部署目标 / 私有仓库配置 —— 写死在这里,改这里即可(按环境) ║
+# ║ console、admin 同机不同目录;DEPLOY_DIR 自动 = /opt/yunyan/<服务名> ║
+# ║ 注意:本文件纳入 git,下面的密码/IP 会随仓库提交(私有仓库内可接受) ║
+# ╚══════════════════════════════════════════════════════════════════════╝
+deploy_profile() {
+ DEPLOY_HOST=""; REGISTRY_PASS="" # 占位,local 用不到;dev/prod 在下面填
+ # —— 各环境通用默认 ——
+ REGISTRY=docker-registry.ideapsound.com # 私有镜像仓库地址
+ REGISTRY_USER=admin # 仓库账号
+ DEPLOY_PORT=22 # 服务器 SSH 端口
+ DEPLOY_USER=root # 服务器 SSH 用户
+ DEPLOY_KEY=~/.ssh/id_rsa # 本机访问服务器的 SSH 私钥路径
+ case "$ENV_NAME" in
+ dev)
+ DEPLOY_HOST="" # ← 开发服务器 IP(必填)
+ REGISTRY_PASS="" # ← 私有仓库密码(必填)
+ ;;
+ prod)
+ DEPLOY_HOST="" # ← 生产服务器 IP(必填)
+ REGISTRY_PASS="" # ← 私有仓库密码(必填)
+ ;;
+ local) ;; # 本机不走 SSH,无需填
+ esac
+ DEPLOY_DIR="/opt/yunyan/$SERVICE" # 远程部署目录(自动按服务名)
+}
+
+usage() {
+ cat >&2 < [action] (在 deploy/$SERVICE/ 下运行)
+ env : local | dev | prod
+ action : dev/prod → deploy(默认) | up | build | pull | down | restart | logs | ps
+ local → up(默认) | build | pull | down | restart | logs | ps
+例:
+ ./deploy.sh prod # 一键发布生产 $SERVICE
+ ./deploy.sh prod logs # 看生产 $SERVICE 日志
+ ./deploy.sh local up # 本机起 $SERVICE
+ ./deploy.sh dev build # 只构建推送 dev 镜像
+EOF
+ exit 1
+}
+
+# ── 参数解析与校验 ──
+ENV_NAME="${1:-}"; ACTION="${2:-}"
+[ -n "$ENV_NAME" ] || usage
+[ -f "$SVC_DIR/docker-compose.yml" ] || { red "✗ $SERVICE 目录缺 docker-compose.yml(deploy.sh 须放在服务目录内)"; exit 1; }
+case "$ENV_NAME" in
+ local|dev|prod) ;;
+ *) red "✗ 未知环境: ${ENV_NAME}(应为 local|dev|prod)"; usage ;;
+esac
+# 默认动作:远程发布默认 deploy,本机默认 up
+[ -n "$ACTION" ] || { [ "$ENV_NAME" = local ] && ACTION=up || ACTION=deploy; }
+
+# 从 ENV_FILE 安全读取单个变量值(去除可能的外层双引号)
+_envget() {
+ { grep -E "^$1=" "$ENV_FILE" || true; } | head -1 | cut -d= -f2- | sed -e 's/^"//' -e 's/"$//'
+}
+
+# 载入 env/<环境>.env —— 现在只放容器运行时配置(DSN / Redis / NATS / 密钥 / 端口 / TAG / 网络)。
+# 部署目标与仓库凭据已写死在 deploy_profile,不再放 env 文件。脚本只需从这里取 compose 仍要的 TAG/网络。
+load_env() {
+ ENV_FILE="$SVC_DIR/env/$ENV_NAME.env"
+ if [ ! -f "$ENV_FILE" ]; then
+ red "✗ 缺少 $SERVICE/env/$ENV_NAME.env —— 请先从 $SERVICE/env/$ENV_NAME.env.example 复制并填写"
+ exit 1
+ fi
+ TAG="$(_envget TAG)"; TAG="${TAG:-latest}"
+ DOCKER_NETWORK="$(_envget DOCKER_NETWORK)"; DOCKER_NETWORK="${DOCKER_NETWORK:-1panel-network}"
+}
+
+# 生成 compose 用 .env = 服务运行时 env 文件 + 注入写死的 REGISTRY(镜像地址替换用)
+# 先滤掉源文件里可能残留的 REGISTRY 行,保证唯一、不依赖 compose 的覆盖顺序。
+gen_env() {
+ { grep -v '^REGISTRY=' "$ENV_FILE" || true; } > "$SVC_DIR/.env"
+ printf 'REGISTRY=%s\n' "$REGISTRY" >> "$SVC_DIR/.env"
+}
+
+# 构建并推送本服务镜像(复用仓库根目录 build.sh,目标 = 服务名)。
+# local 环境只构建不推送(PUSH=0),dev/prod 构建并推送到私有仓库。
+build_push() {
+ local push=1; [ "$ENV_NAME" = local ] && push=0
+ blue ">>> 构建$([ "$push" = 1 ] && echo 推送) $SERVICE 镜像 (TAG=${TAG:-latest}, PUSH=$push)"
+ TAG="${TAG:-latest}" REGISTRY="$REGISTRY" PUSH="$push" "$REPO_ROOT/build.sh" "$SERVICE"
+}
+
+ensure_network_local() {
+ docker network inspect "$DOCKER_NETWORK" >/dev/null 2>&1 || docker network create "$DOCKER_NETWORK"
+}
+
+# 本机 docker compose(gen_env 落为同目录 .env,既做变量替换又做容器 env_file)
+compose_local() {
+ gen_env
+ ensure_network_local
+ ( cd "$SVC_DIR" && docker compose --env-file .env "$@" )
+}
+
+# ── 远程 SSH 基础 ──
+_ssh() { ssh -p "${DEPLOY_PORT:-22}" -i "${DEPLOY_KEY/#\~/$HOME}" -o StrictHostKeyChecking=accept-new "${DEPLOY_USER:-root}@${DEPLOY_HOST}" "$@"; }
+_scp() { scp -P "${DEPLOY_PORT:-22}" -i "${DEPLOY_KEY/#\~/$HOME}" -o StrictHostKeyChecking=accept-new "$@"; }
+_dest() { echo "${DEPLOY_USER:-root}@${DEPLOY_HOST}:$1"; }
+
+# 远程发布:同步配置 + 远程 login + pull + up
+remote_deploy() {
+ : "${DEPLOY_HOST:?请在 deploy.sh 顶部 deploy_profile 的 $ENV_NAME 段填 DEPLOY_HOST}"
+ gen_env
+
+ blue ">>> 同步 $SERVICE 配置到 ${DEPLOY_USER:-root}@${DEPLOY_HOST}:${DEPLOY_DIR}"
+ _ssh "mkdir -p '$DEPLOY_DIR'"
+ _scp "$SVC_DIR/docker-compose.yml" "$(_dest "$DEPLOY_DIR/docker-compose.yml")"
+ _scp "$SVC_DIR/.env" "$(_dest "$DEPLOY_DIR/.env")"
+ # 带 confs/ 的服务(console)一并同步配置并预建 log 目录
+ if [ -d "$SVC_DIR/confs" ]; then
+ _ssh "mkdir -p '$DEPLOY_DIR/confs' '$DEPLOY_DIR/log'"
+ _scp -r "$SVC_DIR/confs/." "$(_dest "$DEPLOY_DIR/confs/")"
+ fi
+
+ blue ">>> 远程拉起 $SERVICE"
+ [ -n "${REGISTRY_PASS:-}" ] && \
+ _ssh "docker login '$REGISTRY' -u '${REGISTRY_USER:-admin}' -p '$REGISTRY_PASS'"
+ _ssh "docker network inspect '$DOCKER_NETWORK' >/dev/null 2>&1 || docker network create '$DOCKER_NETWORK'"
+ _ssh "cd '$DEPLOY_DIR' && docker compose --env-file .env pull && docker compose --env-file .env up -d"
+}
+
+# 远程运维(down/restart/logs/ps/pull)
+remote_compose() {
+ : "${DEPLOY_HOST:?请在 deploy.sh 顶部 deploy_profile 的 $ENV_NAME 段填 DEPLOY_HOST}"
+ _ssh "cd '$DEPLOY_DIR' && docker compose --env-file .env $*"
+}
+
+# ── 分发 ──
+deploy_profile # 写死的部署目标 / 仓库凭据
+load_env # env 文件里的运行时配置(TAG / 网络)
+
+if [ "$ENV_NAME" = local ]; then
+ case "$ACTION" in
+ up) compose_local up -d ;;
+ down) compose_local down ;;
+ restart) compose_local restart ;;
+ logs) compose_local logs -f --tail=120 ;;
+ ps) compose_local ps ;;
+ pull) compose_local pull ;;
+ build) build_push ;;
+ *) red "未知操作: ${ACTION}(local 支持 up|down|restart|logs|ps|pull|build)"; exit 1 ;;
+ esac
+else
+ case "$ACTION" in
+ deploy) build_push; remote_deploy ;;
+ up) remote_deploy ;;
+ build) build_push ;;
+ pull) remote_compose pull ;;
+ down) remote_compose down ;;
+ restart) remote_compose restart ;;
+ ps) remote_compose ps ;;
+ logs) remote_compose logs -f --tail=120 ;;
+ *) red "未知操作: ${ACTION}(dev/prod 支持 deploy|up|build|pull|down|restart|logs|ps)"; exit 1 ;;
+ esac
+fi
+green "✓ $SERVICE $ENV_NAME $ACTION 完成"
diff --git a/deploy/console/docker-compose.yml b/deploy/console/docker-compose.yml
new file mode 100644
index 00000000..280d13d3
--- /dev/null
+++ b/deploy/console/docker-compose.yml
@@ -0,0 +1,38 @@
+# console 服务部署(单服务)
+#
+# 只部署 yunyan-console。依赖服务(PostgreSQL / Redis / NATS)由目标环境共享提供,
+# 不在此 compose 内搭建——在 .env 里用地址/容器名连接它们。
+#
+# 不直接 docker compose 调用,统一通过 deploy.sh,
+# 它会把 env/<环境>.env 落为同目录 .env(既做变量替换又做容器 env_file)。
+
+services:
+ yunyan-console:
+ image: ${REGISTRY:-docker-registry.ideapsound.com}/yunyan-console:${TAG:-latest}
+ container_name: yunyan-console
+ restart: unless-stopped
+ platform: ${PLATFORM:-linux/amd64}
+
+ networks:
+ - appnet
+
+ # 配置与日志:confs/console.yaml 随服务同步,log 持久化到部署目录
+ volumes:
+ - ./confs:/app/confs
+ - ./log:/app/log
+
+ env_file:
+ - .env
+
+ environment:
+ TZ: Asia/Shanghai
+
+ expose:
+ - "8080"
+
+# 共享外部网络:依赖的 redis/nats/postgres 在此网络(用容器名)或经 .env 填外部地址。
+# 目标机需预先存在此网络(脚本会自动 docker network create 兜底)。
+networks:
+ appnet:
+ name: ${DOCKER_NETWORK:-1panel-network}
+ external: true
diff --git a/deploy/console/env/dev.env.example b/deploy/console/env/dev.env.example
new file mode 100644
index 00000000..a580f6ce
--- /dev/null
+++ b/deploy/console/env/dev.env.example
@@ -0,0 +1,26 @@
+# console 服务 · dev 开发环境(复制为 dev.env 后填写真实值)
+
+# ── 镜像(仓库地址 REGISTRY 已写死在 deploy.sh,这里不填)──
+TAG=dev
+PLATFORM=linux/amd64
+DOCKER_NETWORK=yunyan-net
+
+# ── 依赖服务(目标环境共享提供,填地址;密码特殊字符 URL 编码 & → %26)──
+POSTGRES_DSN=postgresql://user:password@host:5432/dbname?sslmode=require
+REDIS_ADDR=redis:6379
+REDIS_PASSWORD=
+NATS_URL=nats://nats:4222
+
+# ── COS(可选)──
+COS_SECRET_ID=
+COS_SECRET_KEY=
+
+# ── Console 服务配置 ──
+CONSOLE_TOKEN_KEY=dev-change-me
+CONSOLE_ADMIN_ACCOUNT=admin
+CONSOLE_ADMIN_PASSWORD=change-me
+CONSOLE_SITE_NAME=云言 SaaS 管理平台(开发)
+FIELD_ENCRYPT_KEY=dev-change-me-to-random-key
+
+# 部署目标(DEPLOY_HOST/USER/KEY/DIR)与仓库凭据(REGISTRY_USER/PASS)已写死在
+# deploy.sh 顶部的 deploy_profile(dev 段),不在此文件填。
diff --git a/deploy/console/env/local.env.example b/deploy/console/env/local.env.example
new file mode 100644
index 00000000..5d6bb1fc
--- /dev/null
+++ b/deploy/console/env/local.env.example
@@ -0,0 +1,23 @@
+# console 服务 · local 本机环境(复制为 local.env 后使用)
+
+# ── 镜像(仓库地址 REGISTRY 已写死在 deploy.sh,这里不填)──
+TAG=latest
+PLATFORM=linux/amd64
+DOCKER_NETWORK=1panel-network
+
+# ── 依赖服务(本机/共享环境提供,仅填连接地址,不在本服务搭建)──
+POSTGRES_DSN=postgresql://user:password@host:5432/dbname?sslmode=require
+REDIS_ADDR=redis:6379
+REDIS_PASSWORD=
+NATS_URL=nats://nats:4222
+
+# ── COS(可选)──
+COS_SECRET_ID=
+COS_SECRET_KEY=
+
+# ── Console 服务配置 ──
+CONSOLE_TOKEN_KEY=console-secret-change-me
+CONSOLE_ADMIN_ACCOUNT=admin
+CONSOLE_ADMIN_PASSWORD=change-me
+CONSOLE_SITE_NAME=云言 SaaS 管理平台
+FIELD_ENCRYPT_KEY=change-me-to-random-key
diff --git a/deploy/console/env/prod.env.example b/deploy/console/env/prod.env.example
new file mode 100644
index 00000000..df3a78e8
--- /dev/null
+++ b/deploy/console/env/prod.env.example
@@ -0,0 +1,26 @@
+# console 服务 · prod 生产环境(复制为 prod.env 后填写真实值)
+
+# ── 镜像(仓库地址 REGISTRY 已写死在 deploy.sh,这里不填)──
+TAG=latest # 生产建议固定版本号,如 v1.2.0,便于回滚
+PLATFORM=linux/amd64
+DOCKER_NETWORK=yunyan-net
+
+# ── 依赖服务(目标环境共享提供,填地址;密码特殊字符 URL 编码 & → %26)──
+POSTGRES_DSN=postgresql://user:password@host:5432/dbname?sslmode=require
+REDIS_ADDR=redis:6379
+REDIS_PASSWORD=
+NATS_URL=nats://nats:4222
+
+# ── COS(可选)──
+COS_SECRET_ID=
+COS_SECRET_KEY=
+
+# ── Console 服务配置(密钥务必强随机)──
+CONSOLE_TOKEN_KEY=prod-change-me-to-strong-secret
+CONSOLE_ADMIN_ACCOUNT=admin
+CONSOLE_ADMIN_PASSWORD=change-me
+CONSOLE_SITE_NAME=云言 SaaS 管理平台
+FIELD_ENCRYPT_KEY=prod-change-me-to-strong-random-key
+
+# 部署目标(DEPLOY_HOST/USER/KEY/DIR)与仓库凭据(REGISTRY_USER/PASS)已写死在
+# deploy.sh 顶部的 deploy_profile(prod 段),不在此文件填。
diff --git a/deploy/deploy.sh b/deploy/deploy.sh
deleted file mode 100755
index 52e31048..00000000
--- a/deploy/deploy.sh
+++ /dev/null
@@ -1,136 +0,0 @@
-#!/usr/bin/env bash
-# yunyan-sas 多环境部署入口
-# local —— 本机 docker compose
-# dev / prod —— SSH 同步配置到目标机后远程 docker compose
-#
-# 配置来源:
-# env/<环境>.env 运行时变量(镜像/网络/DB/Redis/NATS/Console 等)
-# targets/<环境>.conf 远程目标(SSH 主机/密钥/部署目录/镜像仓库账号)
-set -euo pipefail
-
-ROOT="$(cd "$(dirname "$0")" && pwd)"
-REPO_ROOT="$(cd "$ROOT/.." && pwd)"
-
-ENV="${1:-}"
-ACTION="${2:-}"
-
-green() { printf "\033[32m%s\033[0m\n" "$*"; }
-blue() { printf "\033[34m%s\033[0m\n" "$*"; }
-red() { printf "\033[31m%s\033[0m\n" "$*"; }
-
-usage() {
- cat <<'EOF'
-用法: ./deploy.sh <环境> <操作>
-
- 环境: local | dev | prod
- 操作:
- up 启动(local 直接起;dev/prod 同步配置后远程起)
- down 停止并移除容器
- pull 拉取最新镜像
- restart 重启容器
- logs 跟随日志
- ps 查看容器状态
- build 构建并推送镜像到私有仓库(调用 ../build.sh,TAG 取自 env)
- deploy 一键发布:build + 同步配置 + 远程 pull + up
-
-示例:
- ./deploy.sh local up # 本机起服务
- ./deploy.sh dev build # 构建推送 dev 镜像
- ./deploy.sh dev deploy # 一键发布到开发服务器
- ./deploy.sh prod deploy # 一键发布到生产服务器
- ./deploy.sh prod logs # 跟随生产日志
-EOF
-}
-
-[ -z "$ENV" ] && { usage; exit 1; }
-case "$ENV" in local|dev|prod) ;; *) red "✗ 未知环境: $ENV"; usage; exit 1 ;; esac
-[ -z "$ACTION" ] && { usage; exit 1; }
-
-ENV_FILE="$ROOT/env/$ENV.env"
-[ -f "$ENV_FILE" ] || { red "✗ 缺少环境变量文件: deploy/env/$ENV.env(参考 env/example.env)"; exit 1; }
-
-# 从 env 取某个 key 的值
-env_val() { grep -E "^$1=" "$ENV_FILE" | head -1 | cut -d= -f2-; }
-TAG="$(env_val TAG)"; TAG="${TAG:-latest}"
-
-# ── 构建并推送镜像(复用根目录 build.sh)──
-do_build() {
- blue ">>> [$ENV] 构建并推送镜像 (TAG=$TAG)"
- TAG="$TAG" "$REPO_ROOT/build.sh" all
-}
-
-# ── 本机 compose ──
-local_compose() {
- cp "$ENV_FILE" "$ROOT/.env"
- ( cd "$ROOT" && docker compose --env-file "$ROOT/.env" "$@" )
-}
-
-local_ensure_network() {
- local net; net="$(env_val DOCKER_NETWORK)"; net="${net:-1panel-network}"
- docker network inspect "$net" >/dev/null 2>&1 || docker network create "$net"
-}
-
-# ── 远程目标 ──
-load_target() {
- local conf="$ROOT/targets/$ENV.conf"
- [ -f "$conf" ] || { red "✗ 缺少部署目标: deploy/targets/$ENV.conf(参考 targets/example.conf)"; exit 1; }
- # shellcheck disable=SC1090
- source "$conf"
- [ -z "${SSH_HOST:-}" ] && { red "✗ targets/$ENV.conf 未配置 SSH_HOST"; exit 1; }
- [ -z "${REMOTE_DIR:-}" ] && { red "✗ targets/$ENV.conf 未配置 REMOTE_DIR"; exit 1; }
- local port="${SSH_PORT:-22}"
- local key="${SSH_KEY/#\~/$HOME}"
- local common="-i $key -o StrictHostKeyChecking=accept-new"
- SSH="ssh -p $port $common ${SSH_USER}@${SSH_HOST}"
- SCP="scp -P $port $common"
-}
-
-remote_sync() {
- blue ">>> [$ENV] 同步配置到 ${SSH_USER}@${SSH_HOST}:${REMOTE_DIR}"
- $SSH "mkdir -p '$REMOTE_DIR/console/confs' '$REMOTE_DIR/console/log'"
- $SCP "$ROOT/docker-compose.yml" "${SSH_USER}@${SSH_HOST}:$REMOTE_DIR/docker-compose.yml"
- $SCP "$ENV_FILE" "${SSH_USER}@${SSH_HOST}:$REMOTE_DIR/.env"
- $SCP "$ROOT/console/confs/console.yaml" "${SSH_USER}@${SSH_HOST}:$REMOTE_DIR/console/confs/console.yaml"
-}
-
-remote_ensure_network() {
- local net; net="$(env_val DOCKER_NETWORK)"; net="${net:-1panel-network}"
- $SSH "docker network inspect '$net' >/dev/null 2>&1 || docker network create '$net'"
-}
-
-remote_login() {
- [ -n "${REGISTRY_PASS:-}" ] && \
- $SSH "docker login '${REGISTRY:-docker-registry.ideapsound.com}' -u '${REGISTRY_USER:-admin}' -p '$REGISTRY_PASS'"
-}
-
-remote_compose() { $SSH "cd '$REMOTE_DIR' && docker compose --env-file .env $*"; }
-
-# ================= 分发 =================
-if [ "$ENV" = "local" ]; then
- case "$ACTION" in
- up) local_ensure_network; local_compose up -d ;;
- down) local_compose down ;;
- pull) local_compose pull ;;
- restart) local_compose restart ;;
- logs) local_compose logs -f --tail=120 ;;
- ps) local_compose ps ;;
- build) do_build ;;
- deploy) do_build; local_ensure_network; local_compose pull; local_compose up -d ;;
- *) red "✗ 未知操作: $ACTION"; usage; exit 1 ;;
- esac
-else
- load_target
- case "$ACTION" in
- up) remote_sync; remote_ensure_network; remote_login; remote_compose pull; remote_compose up -d ;;
- down) remote_compose down ;;
- pull) remote_login; remote_compose pull ;;
- restart) remote_compose restart ;;
- logs) remote_compose logs -f --tail=120 ;;
- ps) remote_compose ps ;;
- build) do_build ;;
- deploy) do_build; remote_sync; remote_ensure_network; remote_login; remote_compose pull; remote_compose up -d ;;
- *) red "✗ 未知操作: $ACTION"; usage; exit 1 ;;
- esac
-fi
-
-green "✓ [$ENV] $ACTION 完成"
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
deleted file mode 100644
index 81cdbe99..00000000
--- a/deploy/docker-compose.yml
+++ /dev/null
@@ -1,63 +0,0 @@
-# yunyan-sas 多环境部署 compose(变量驱动)
-#
-# 不直接用 docker compose 调用,统一通过 deploy.sh:
-# ./deploy.sh local up # 本机
-# ./deploy.sh dev deploy # 部署到开发服务器
-# ./deploy.sh prod deploy # 部署到生产服务器
-#
-# deploy.sh 会把 env/<环境>.env 落为同目录 .env,compose 同时用它做
-# ① 变量替换:REGISTRY / TAG / PLATFORM / DOCKER_NETWORK / ADMIN_PORT
-# ② 容器环境注入:env_file 指向同一个 .env(POSTGRES_DSN / REDIS_* / NATS_URL / CONSOLE_* 等)
-
-services:
- # ── console 后台控制面服务 ──────────────────────────────────
- yunyan-console:
- image: ${REGISTRY:-docker-registry.ideapsound.com}/yunyan-console:${TAG:-latest}
- container_name: yunyan-console
- restart: unless-stopped
- platform: ${PLATFORM:-linux/amd64}
-
- networks:
- - appnet
-
- # deploy 目录下 console/ 挂为 /app(WORKDIR),含 confs/ log/
- volumes:
- - ./console:/app
-
- env_file:
- - .env
-
- environment:
- TZ: Asia/Shanghai
-
- expose:
- - "8080"
-
- # ── admin 管理前端 ─────────────────────────────────────────
- yunyan-admin:
- image: ${REGISTRY:-docker-registry.ideapsound.com}/yunyan-admin:${TAG:-latest}
- container_name: yunyan-admin
- restart: unless-stopped
- platform: ${PLATFORM:-linux/amd64}
-
- networks:
- - appnet
-
- environment:
- TZ: Asia/Shanghai
- # server middleware 运行时代理目标(同网络容器名直接解析)
- CONSOLE_BACKEND: http://yunyan-console:8080
-
- ports:
- - "${ADMIN_PORT:-3000}:3000"
-
- depends_on:
- - yunyan-console
-
-# 外部网络:各环境用各自的网络名(local 用 1panel-network;dev/prod 在 .env 里配 DOCKER_NETWORK)。
-# 该网络需在目标机预先存在(docker network create ),且 redis/nats 可达
-# (同网络容器名,或在 .env 里用 REDIS_ADDR/NATS_URL 填外部地址)。
-networks:
- appnet:
- name: ${DOCKER_NETWORK:-1panel-network}
- external: true
diff --git a/deploy/env/example.env b/deploy/env/example.env
deleted file mode 100644
index 96d7efee..00000000
--- a/deploy/env/example.env
+++ /dev/null
@@ -1,37 +0,0 @@
-# ===== 环境变量模板 =====
-# 复制为 dev.env / prod.env 并填写真实值。真实文件不要提交 git(见 .gitignore)。
-
-# ── 镜像 ──
-REGISTRY=docker-registry.ideapsound.com # 私有镜像仓库地址
-TAG=latest # 镜像 tag(建议 prod 用版本号,如 v1.2.0)
-PLATFORM=linux/amd64 # 目标机 CPU 架构(amd64 / arm64)
-
-# ── Docker 网络 ──
-# 目标机需预先存在此 external 网络:docker network create
-# redis/nats 若在该网络则下面用容器名;否则用外部 IP:端口
-DOCKER_NETWORK=yunyan-net
-ADMIN_PORT=3000 # admin 对外端口
-
-# ── PostgreSQL ──
-POSTGRES_DSN=postgresql://user:password@host:5432/dbname?sslmode=require
-# 注意:密码里的特殊字符要 URL 编码(如 & -> %26)
-
-# ── Redis ──
-REDIS_ADDR=redis:6379
-REDIS_PASSWORD=
-
-# ── NATS ──
-NATS_URL=nats://nats:4222
-
-# ── COS(可选)──
-COS_SECRET_ID=
-COS_SECRET_KEY=
-
-# ── Console 服务配置 ──
-CONSOLE_TOKEN_KEY=change-me-to-a-strong-secret
-CONSOLE_ADMIN_ACCOUNT=admin
-CONSOLE_ADMIN_PASSWORD=change-me
-CONSOLE_SITE_NAME=云言 SaaS 管理平台
-
-# ── 依赖服务配置字段加密密钥(强随机,环境间不要复用)──
-FIELD_ENCRYPT_KEY=change-me-to-a-strong-random-key
diff --git a/deploy/targets/example.conf b/deploy/targets/example.conf
deleted file mode 100644
index e548aedc..00000000
--- a/deploy/targets/example.conf
+++ /dev/null
@@ -1,17 +0,0 @@
-# ===== 远程部署目标模板 =====
-# 复制为 dev.conf / prod.conf 并填真实值。真实文件不要提交 git(含密钥)。
-# deploy.sh 通过这些信息 SSH 到目标机,同步 compose+配置并执行 docker compose。
-
-# ── 目标服务器 SSH ──
-SSH_HOST=1.2.3.4 # 目标服务器 IP / 域名
-SSH_PORT=22
-SSH_USER=root
-SSH_KEY=~/.ssh/id_rsa # SSH 私钥路径(访问密钥)
-
-# ── 远程部署目录(compose / .env / console 配置同步到此)──
-REMOTE_DIR=/opt/yunyan
-
-# ── 私有镜像仓库(远程机 docker login 用)──
-REGISTRY=docker-registry.ideapsound.com
-REGISTRY_USER=admin
-REGISTRY_PASS=change-me