diff --git a/apps/admin/app/components/AppLoading.vue b/apps/admin/app/components/AppLoading.vue new file mode 100644 index 00000000..86eafee7 --- /dev/null +++ b/apps/admin/app/components/AppLoading.vue @@ -0,0 +1,31 @@ + + + + + diff --git a/apps/admin/app/composables/useApi.ts b/apps/admin/app/composables/useApi.ts index b35af30e..0ecfb26a 100644 --- a/apps/admin/app/composables/useApi.ts +++ b/apps/admin/app/composables/useApi.ts @@ -51,9 +51,18 @@ async function request( return result.data } +// 读操作前缀:以这些开头的 api 方法视为只读,不触发处理中遮罩;其余(add/update/del/save/create…)视为写操作。 +const READONLY_RE = /^(get|list|load|query|fetch|search|count|stat|export|check)/i +function isMutating(method: string): boolean { + return !READONLY_RE.test(method) +} + export function useApi() { const auth = useAuthStore() + const { begin, end } = useLoading() // setup 期捕获全局遮罩控制 + // 写操作(addproduct/updateproduct/delproduct/addproductversion… 及厂家/账号等增删改) + // 统一套全屏遮罩:请求期间挡住整页、拦截点击,从根上杜绝重复提交/误点。只读 getXxx 不遮罩。 async function webApi(method: string, data?: unknown): Promise { const extraHeaders: Record = {} @@ -61,7 +70,15 @@ export function useApi() { extraHeaders['X-App-Id'] = String(auth.currentAppId) } - return request(`/web/api/api_${method}`, data ?? {}, extraHeaders) + if (!isMutating(method)) { + return request(`/web/api/api_${method}`, data ?? {}, extraHeaders) + } + begin() + try { + return await request(`/web/api/api_${method}`, data ?? {}, extraHeaders) + } finally { + end() + } } async function consoleApi(path: string, data?: unknown): Promise { diff --git a/apps/admin/app/composables/useLoading.ts b/apps/admin/app/composables/useLoading.ts new file mode 100644 index 00000000..d9187b16 --- /dev/null +++ b/apps/admin/app/composables/useLoading.ts @@ -0,0 +1,16 @@ +// 全局「处理中」遮罩状态:引用计数(支持并发请求叠加),>0 即显示遮罩。 +// 与 useToast 同样用 Nuxt useState 做 SSR 安全的全局共享状态。 +// begin()/end() 成对调用(见 useApi 对写操作的包装),end 取 max(0, n-1) 防计数为负。 +export function useLoading() { + const count = useState('app:loading', () => 0) + const loading = computed(() => count.value > 0) + + function begin() { + count.value++ + } + function end() { + count.value = Math.max(0, count.value - 1) + } + + return { count, loading, begin, end } +} diff --git a/apps/admin/app/layouts/default.vue b/apps/admin/app/layouts/default.vue index 15bf0bea..899dfb68 100644 --- a/apps/admin/app/layouts/default.vue +++ b/apps/admin/app/layouts/default.vue @@ -106,6 +106,9 @@ + + +
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