Browse Source

上传服务端代码优化

main
liwei1dao 4 months ago
parent
commit
ce15363123
  1. 31
      apps/admin/app/components/AppLoading.vue
  2. 17
      apps/admin/app/composables/useApi.ts
  3. 16
      apps/admin/app/composables/useLoading.ts
  4. 3
      apps/admin/app/layouts/default.vue
  5. 22
      apps/admin/app/pages/dashboard.vue
  6. 2
      apps/services/comm/const.go
  7. BIN
      apps/services/console
  8. 12
      apps/services/modules/console/api_device.go
  9. 6
      apps/services/modules/console/api_stats.go
  10. 5
      apps/services/modules/console/core.go
  11. 7
      apps/services/modules/console/model_device.go
  12. 175
      apps/services/modules/console/model_device_cache.go
  13. 22
      apps/services/modules/console/model_stat.go
  14. 5
      apps/services/modules/console/module.go
  15. 137
      apps/services/modules/console/scope.go
  16. 96
      apps/services/modules/console/server.go
  17. 38
      build.sh
  18. 11
      deploy/.gitignore
  19. 105
      deploy/README.md
  20. 184
      deploy/admin/deploy.sh
  21. 29
      deploy/admin/docker-compose.yml
  22. 10
      deploy/admin/env/dev.env.example
  23. 8
      deploy/admin/env/local.env.example
  24. 10
      deploy/admin/env/prod.env.example
  25. 184
      deploy/console/deploy.sh
  26. 38
      deploy/console/docker-compose.yml
  27. 26
      deploy/console/env/dev.env.example
  28. 23
      deploy/console/env/local.env.example
  29. 26
      deploy/console/env/prod.env.example
  30. 136
      deploy/deploy.sh
  31. 63
      deploy/docker-compose.yml
  32. 37
      deploy/env/example.env
  33. 17
      deploy/targets/example.conf

31
apps/admin/app/components/AppLoading.vue

@ -0,0 +1,31 @@
<template>
<!-- 全屏「处理中」遮罩:覆盖整页并拦截点击,写操作进行中显示,杜绝重复提交/误点 -->
<Transition name="fade">
<div
v-if="loading"
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/30 backdrop-blur-[1px]"
aria-busy="true"
role="alert"
>
<div class="flex flex-col items-center gap-3 rounded-2xl bg-white px-10 py-7 shadow-xl">
<span class="loading loading-spinner loading-lg text-primary"></span>
<span class="text-sm text-slate-600">处理中,请稍候…</span>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
const { loading } = useLoading()
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

17
apps/admin/app/composables/useApi.ts

@ -51,9 +51,18 @@ async function request<T = unknown>(
return result.data 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() { export function useApi() {
const auth = useAuthStore() const auth = useAuthStore()
const { begin, end } = useLoading() // setup 期捕获全局遮罩控制
// 写操作(addproduct/updateproduct/delproduct/addproductversion… 及厂家/账号等增删改)
// 统一套全屏遮罩:请求期间挡住整页、拦截点击,从根上杜绝重复提交/误点。只读 getXxx 不遮罩。
async function webApi<T = unknown>(method: string, data?: unknown): Promise<T> { async function webApi<T = unknown>(method: string, data?: unknown): Promise<T> {
const extraHeaders: Record<string, string> = {} const extraHeaders: Record<string, string> = {}
@ -61,8 +70,16 @@ export function useApi() {
extraHeaders['X-App-Id'] = String(auth.currentAppId) extraHeaders['X-App-Id'] = String(auth.currentAppId)
} }
if (!isMutating(method)) {
return request<T>(`/web/api/api_${method}`, data ?? {}, extraHeaders) return request<T>(`/web/api/api_${method}`, data ?? {}, extraHeaders)
} }
begin()
try {
return await request<T>(`/web/api/api_${method}`, data ?? {}, extraHeaders)
} finally {
end()
}
}
async function consoleApi<T = unknown>(path: string, data?: unknown): Promise<T> { async function consoleApi<T = unknown>(path: string, data?: unknown): Promise<T> {
return request<T>(`/console/api/${path}`, data ?? {}) return request<T>(`/console/api/${path}`, data ?? {})

16
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<number>('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 }
}

3
apps/admin/app/layouts/default.vue

@ -106,6 +106,9 @@
<!-- Toast --> <!-- Toast -->
<AppToast /> <AppToast />
<!-- 全局处理中遮罩(写操作期间显示,防重复点击) -->
<AppLoading />
<!-- 页面内容 --> <!-- 页面内容 -->
<div class="p-6"> <div class="p-6">
<slot /> <slot />

22
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<string, unknown> { function gFilter(): Record<string, unknown> {
const req: Record<string, unknown> = {} const req: Record<string, unknown> = {}
const app = filters.app, region = filters.region, product = filters.product if (filters.app !== '') req.app_id = filters.app
if (app === '__ALL__') { if (BOUND_APPS.value.length) req.apps = BOUND_APPS.value } if (filters.region !== '') req.region = filters.region
else if (app !== '') req.app_id = app if (filters.product !== '') req.product_id = parseInt(String(filters.product))
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))
return req return req
} }
function deviceFilter(): Record<string, unknown> { function deviceFilter(): Record<string, unknown> {
const req: Record<string, unknown> = {} const req: Record<string, unknown> = {}
const product = filters.product if (filters.product !== '') req.product_id = parseInt(String(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))
return req return req
} }
@ -425,10 +421,8 @@ function renderChart(p: Panel, rows: any[]) {
async function initFilters() { async function initFilters() {
// 应用 // 应用
if (IS_AGENT.value) { if (IS_AGENT.value) {
const opts: { value: string; label: string }[] = [] // 代理只列绑定应用;"全部应用"复用模板里固定的空值项(选它=不传过滤,后端按账号绑定自动收敛为"我的全部")
if (BOUND_APPS.value.length) opts.push({ value: '__ALL__', label: '全部应用(我的)' }) appOptions.value = BOUND_APPS.value.map((name) => ({ value: name, label: name }))
BOUND_APPS.value.forEach((name) => opts.push({ value: name, label: name }))
appOptions.value = opts
} else { } else {
try { try {
const da = await consoleApi<any[]>('apps/list', {}) const da = await consoleApi<any[]>('apps/list', {})

2
apps/services/comm/const.go

@ -105,6 +105,8 @@ const (
const ( const (
Cache_Product = "cache:product" //product 全量缓存(Redis Hash,field=id) Cache_Product = "cache:product" //product 全量缓存(Redis Hash,field=id)
Cache_EchomeetTemplate = "cache:echomeet_template" //echomeet public 公共模板全量缓存(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服务接口定义处 // RPC服务接口定义处

BIN
apps/services/console

Binary file not shown.

12
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()) writeErr(c, pb.ErrorCode_DBError, err.Error())
return return
} }
this.module.deviceCache.refreshProducts() // 写后刷新产品缓存
writeOK(c, &pb.ApiUpdateProductResp{}) writeOK(c, &pb.ApiUpdateProductResp{})
} }
@ -59,6 +60,7 @@ func (this *serverComp) delProduct(c *gin.Context, sys *appConn) {
writeErr(c, pb.ErrorCode_DBError, err.Error()) writeErr(c, pb.ErrorCode_DBError, err.Error())
return return
} }
this.module.deviceCache.refreshProducts() // 写后刷新产品缓存
writeOK(c, &pb.ApiDelProductResp{}) writeOK(c, &pb.ApiDelProductResp{})
} }
@ -90,6 +92,10 @@ func (this *serverComp) addProductVersion(c *gin.Context, sys *appConn) {
return return
} }
} }
this.module.deviceCache.refreshProductVersions() // 写后刷新版本缓存
if req.Isuse {
this.module.deviceCache.refreshProducts() // Isuse 改了产品当前版本,连带刷新产品缓存
}
writeOK(c, &pb.ApiAddProductVersionResp{Version: req.Version}) 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()) writeErr(c, pb.ErrorCode_DBError, err.Error())
return return
} }
this.module.deviceCache.refreshProductVersions() // 写后刷新版本缓存
writeOK(c, &pb.ApiDelProductVersionResp{}) writeOK(c, &pb.ApiDelProductVersionResp{})
} }
@ -131,6 +138,8 @@ func (this *serverComp) addProduct(c *gin.Context, sys *appConn) {
} }
// 注:modules/api 这里还会 RpcBroadcast 通知 home 热重载配置;console 不接 RPCX, // 注:modules/api 这里还会 RpcBroadcast 通知 home 热重载配置;console 不接 RPCX,
// 如需应用感知,走 /console/api/config/notify 发 NATS 事件。 // 如需应用感知,走 /console/api/config/notify 发 NATS 事件。
this.module.deviceCache.refreshProducts() // 新增产品 + 改了厂家 Productlists,刷新两者缓存
this.module.deviceCache.refreshFactorys()
writeOK(c, &pb.ApiAddProductResp{Product: req.Product}) 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()) writeErr(c, pb.ErrorCode_DBError, err.Error())
return return
} }
this.module.deviceCache.refreshFactorys() // 写后刷新厂家缓存
writeOK(c, &pb.ApiAddFactoryResp{Factory: req.Factory}) 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()) writeErr(c, pb.ErrorCode_DBError, err.Error())
return return
} }
this.module.deviceCache.refreshFactorys() // 写后刷新厂家缓存
writeOK(c, &pb.ApiUpdateFactoryResp{Factory: old}) 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()) writeErr(c, pb.ErrorCode_DBError, err.Error())
return return
} }
this.module.deviceCache.refreshFactorys() // 批次号 Probatch 变更,刷新厂家缓存
if err = dvAddFactoryDeliveryNote(sys, &pb.DBFactoryDeliveryNote{ if err = dvAddFactoryDeliveryNote(sys, &pb.DBFactoryDeliveryNote{
Ts: time.Now().Unix(), Ts: time.Now().Unix(),
Factoryid: req.Factoryid, Factoryid: req.Factoryid,

6
apps/services/modules/console/api_stats.go

@ -13,7 +13,8 @@ import (
func (this *serverComp) getStatsSummary(c *gin.Context) { func (this *serverComp) getStatsSummary(c *gin.Context) {
var req statsQueryReq var req statsQueryReq
_ = c.ShouldBindJSON(&req) _ = c.ShouldBindJSON(&req)
out, err := queryStatSummary(&req) sw, sa := scopeOf(c).statsClause() // 代理/运营:强制收敛到绑定的应用∪产品(含区域)
out, err := queryStatSummary(&req, sw, sa)
if err != nil { if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error()) writeErr(c, pb.ErrorCode_DBError, err.Error())
return return
@ -25,7 +26,8 @@ func (this *serverComp) getStatsSummary(c *gin.Context) {
func (this *serverComp) getStatsTrend(c *gin.Context) { func (this *serverComp) getStatsTrend(c *gin.Context) {
var req statsQueryReq var req statsQueryReq
_ = c.ShouldBindJSON(&req) _ = c.ShouldBindJSON(&req)
rows, err := queryStatTrend(&req) sw, sa := scopeOf(c).statsClause() // 代理/运营:强制收敛到绑定的应用∪产品(含区域)
rows, err := queryStatTrend(&req, sw, sa)
if err != nil { if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error()) writeErr(c, pb.ErrorCode_DBError, err.Error())
return return

5
apps/services/modules/console/core.go

@ -43,6 +43,11 @@ type consoleClaims struct {
Identity pb.Identity `json:"idt"` // 角色:1超管 2管理员 3代理商 4运营 Identity pb.Identity `json:"idt"` // 角色:1超管 2管理员 3代理商 4运营
Username string `json:"usr"` Username string `json:"usr"`
AccountId uint32 `json:"aid"` // 账号表 id;引导超管为 0 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 jwt.RegisteredClaims
} }

7
apps/services/modules/console/model_device.go

@ -58,6 +58,13 @@ func dvProductVersions(sys *appConn, pid uint32) (models []*pb.DBProductVersion,
return 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) { func dvDelFactory(sys *appConn, id uint32) (err error) {
err = sys.AdminDB().Delete(comm.TableFactory, "id=?", id) err = sys.AdminDB().Delete(comm.TableFactory, "id=?", id)
return return

175
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
}

22
apps/services/modules/console/model_stat.go

@ -209,10 +209,25 @@ func statWhere(req *statsQueryReq) (string, []interface{}) {
return strings.Join(conds, " AND "), args return strings.Join(conds, " AND "), args
} }
// queryStatSummary 区间总量(全维度 SUM 成一行)。 // andWhere 把两个 WHERE 片段用 AND 合并(任一为空则取另一个),并按顺序拼接参数。
func queryStatSummary(req *statsQueryReq) (*StatMetrics, error) { 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{} out := &StatMetrics{}
where, args := statWhere(req) where, args := statWhere(req)
where, args = andWhere(where, args, scopeWhere, scopeArgs)
tx := postgres.Table(comm.TableStatsGlobalDay).Select(statSumSelect()) tx := postgres.Table(comm.TableStatsGlobalDay).Select(statSumSelect())
if where != "" { if where != "" {
tx = tx.Where(where, args...) tx = tx.Where(where, args...)
@ -222,9 +237,10 @@ func queryStatSummary(req *statsQueryReq) (*StatMetrics, error) {
} }
// queryStatTrend 按 stat_day 分组的逐日序列(升序)。 // queryStatTrend 按 stat_day 分组的逐日序列(升序)。
func queryStatTrend(req *statsQueryReq) ([]*StatTrendRow, error) { func queryStatTrend(req *statsQueryReq, scopeWhere string, scopeArgs []interface{}) ([]*StatTrendRow, error) {
rows := make([]*StatTrendRow, 0) rows := make([]*StatTrendRow, 0)
where, args := statWhere(req) where, args := statWhere(req)
where, args = andWhere(where, args, scopeWhere, scopeArgs)
tx := postgres.Table(comm.TableStatsGlobalDay). tx := postgres.Table(comm.TableStatsGlobalDay).
Select(statSumSelect("stat_day")). Select(statSumSelect("stat_day")).
Group("stat_day").Order("stat_day ASC") Group("stat_day").Order("stat_day ASC")

5
apps/services/modules/console/module.go

@ -27,6 +27,7 @@ type Console struct {
registry *registryComp registry *registryComp
server *serverComp server *serverComp
stat *statComp stat *statComp
deviceCache *deviceCacheComp
} }
func (this *Console) GetType() core.M_Modules { 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() { func (this *Console) OnInstallComp() {
this.ModuleBase.OnInstallComp() this.ModuleBase.OnInstallComp()
// 注册顺序即初始化顺序:model 先建好注册表,registry 再依赖它按应用建连,server 最后对外。 // 注册顺序即初始化顺序:model 先建好注册表,registry 再依赖它按应用建连,
// deviceCache 预热设备域只读缓存,server 最后对外(读 handler 走 deviceCache)。
this.model = this.RegisterComp(new(modelComp)).(*modelComp) this.model = this.RegisterComp(new(modelComp)).(*modelComp)
this.registry = this.RegisterComp(new(registryComp)).(*registryComp) this.registry = this.RegisterComp(new(registryComp)).(*registryComp)
this.stat = this.RegisterComp(new(statComp)).(*statComp) this.stat = this.RegisterComp(new(statComp)).(*statComp)
this.deviceCache = this.RegisterComp(new(deviceCacheComp)).(*deviceCacheComp)
this.server = this.RegisterComp(new(serverComp)).(*serverComp) this.server = this.RegisterComp(new(serverComp)).(*serverComp)
} }

137
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
}

96
apps/services/modules/console/server.go

@ -167,10 +167,13 @@ func (this *serverComp) tokenValid(c *gin.Context) bool {
if err != nil { if err != nil {
return false return false
} }
// 解析出的身份存进上下文,供 requireIdentity 与后续 handler 取用。 // 解析出的身份与作用域存进上下文,供 requireIdentity / scopeOf 与后续 handler 取用。
c.Set("identity", claims.Identity) c.Set("identity", claims.Identity)
c.Set("username", claims.Username) c.Set("username", claims.Username)
c.Set("account_id", claims.AccountId) c.Set("account_id", claims.AccountId)
c.Set("apps", claims.Apps)
c.Set("products", claims.Products)
c.Set("regions", claims.Regions)
return true return true
} }
@ -332,7 +335,11 @@ func (this *serverComp) handleWeb(c *gin.Context) {
// 仅重置类要清各应用业务库的 userdevice/product_stat,按 X-App-Id 取该应用业务库填入 sys.service。 // 仅重置类要清各应用业务库的 userdevice/product_stat,按 X-App-Id 取该应用业务库填入 sys.service。
switch method { switch method {
case "api_resetlicensestatus", "api_batchresetlicensestatus", "api_restfactorydevics": 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 { if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error()) writeErr(c, pb.ErrorCode_DBError, err.Error())
return return
@ -400,6 +407,25 @@ func parseAppId(s string) uint32 {
return uint32(n) 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) { func (this *serverComp) login(c *gin.Context) {
@ -411,10 +437,20 @@ func (this *serverComp) login(c *gin.Context) {
return return
} }
now := time.Now() 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{ claims := &consoleClaims{
Identity: identity, Identity: identity,
Username: req.Account, Username: req.Account,
AccountId: accountId, AccountId: accountId,
Apps: apps,
Products: products,
Regions: regions,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
Issuer: "console", Issuer: "console",
Subject: fmt.Sprintf("%d", identity), Subject: fmt.Sprintf("%d", identity),
@ -429,13 +465,6 @@ func (this *serverComp) login(c *gin.Context) {
writeErr(c, pb.ErrorCode_SystemError, err.Error()) writeErr(c, pb.ErrorCode_SystemError, err.Error())
return 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。 // 把绑定应用名解析为 {id,name}:代理无权调 apps/list,但 users.html / 切换器需要注册 id 取 X-App-Id。
appBinds := make([]gin.H, 0) appBinds := make([]gin.H, 0)
if apps != "" { if apps != "" {
@ -492,28 +521,52 @@ func (this *serverComp) getSiteInfo(c *gin.Context) {
// 逻辑从 modules/api 的对应 handler 移植;去掉 RPCX session 与 scope 白名单(第一期仅超管), // 逻辑从 modules/api 的对应 handler 移植;去掉 RPCX session 与 scope 白名单(第一期仅超管),
// 数据访问改为作用于传入的"选中应用"连接 sys。 // 数据访问改为作用于传入的"选中应用"连接 sys。
// 读 handler 统一走 deviceCache(缓存优先 + DB 回退),设备域引用数据恒命中 Redis,避免直连海外主库的卡顿。
func (this *serverComp) getFactorys(c *gin.Context, sys *appConn) { func (this *serverComp) getFactorys(c *gin.Context, sys *appConn) {
models, err := dvFactorys(sys) models, err := this.module.deviceCache.GetFactorys()
if err != nil { if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error()) writeErr(c, pb.ErrorCode_DBError, err.Error())
return 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}) writeOK(c, &pb.ApiGetFactorysResp{Factorys: models})
} }
func (this *serverComp) getProducts(c *gin.Context, sys *appConn) { func (this *serverComp) getProducts(c *gin.Context, sys *appConn) {
models, err := dvProducts(sys) models, err := this.module.deviceCache.GetProducts()
if err != nil { if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error()) writeErr(c, pb.ErrorCode_DBError, err.Error())
return return
} }
models = scopeOf(c).filterProducts(models) // 代理/运营:仅返回绑定产品
writeOK(c, &pb.ApiGetProductsResp{Products: models}) writeOK(c, &pb.ApiGetProductsResp{Products: models})
} }
func (this *serverComp) getProduct(c *gin.Context, sys *appConn) { func (this *serverComp) getProduct(c *gin.Context, sys *appConn) {
var req pb.ApiGetProductReq var req pb.ApiGetProductReq
_ = c.ShouldBindJSON(&req) _ = 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 { if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error()) writeErr(c, pb.ErrorCode_DBError, err.Error())
return return
@ -524,7 +577,11 @@ func (this *serverComp) getProduct(c *gin.Context, sys *appConn) {
func (this *serverComp) getProductVersions(c *gin.Context, sys *appConn) { func (this *serverComp) getProductVersions(c *gin.Context, sys *appConn) {
var req pb.ApiGetProductVersionsReq var req pb.ApiGetProductVersionsReq
_ = c.ShouldBindJSON(&req) _ = 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 { if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error()) writeErr(c, pb.ErrorCode_DBError, err.Error())
return return
@ -539,6 +596,7 @@ func (this *serverComp) delFactory(c *gin.Context, sys *appConn) {
writeErr(c, pb.ErrorCode_DBError, err.Error()) writeErr(c, pb.ErrorCode_DBError, err.Error())
return return
} }
this.module.deviceCache.refreshFactorys() // 写后刷新厂家缓存
writeOK(c, &pb.ApiDelFactoryResp{}) writeOK(c, &pb.ApiDelFactoryResp{})
} }
@ -792,7 +850,11 @@ func (this *serverComp) delFactoryPublicCode(c *gin.Context, sys *appConn) {
// 据此经 registry.getServiceDB 取该部署的业务库(MySQL)连接,按 uid 直查 user 表返回。 // 据此经 registry.getServiceDB 取该部署的业务库(MySQL)连接,按 uid 直查 user 表返回。
// 业务数据在各应用自己的业务库,故不走 console 主库(supabase)。 // 业务数据在各应用自己的业务库,故不走 console 主库(supabase)。
func (this *serverComp) getUserInfo(c *gin.Context) { 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 { if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error()) writeErr(c, pb.ErrorCode_DBError, err.Error())
return return
@ -850,7 +912,11 @@ func (this *serverComp) giftUserResource(c *gin.Context) {
writeErr(c, pb.ErrorCode_ReqParameterError, "请至少填写一项赠送数量") writeErr(c, pb.ErrorCode_ReqParameterError, "请至少填写一项赠送数量")
return 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 { if err != nil {
writeErr(c, pb.ErrorCode_DBError, err.Error()) writeErr(c, pb.ErrorCode_DBError, err.Error())
return return

38
build.sh

@ -0,0 +1,38 @@
#!/usr/bin/env bash
# 构建(可选推送)服务镜像。被 deploy/<服务>/deploy.sh 的 build/deploy 动作调用,也可单独用。
#
# 用法: [TAG=.. REGISTRY=.. PLATFORM=.. PUSH=0] ./build.sh <service>
# service : console | admin
# PUSH=0 : 只构建不推送(本机看效果用;deploy.sh 在 local 环境会自动传 PUSH=0)
#
# 镜像名固定 = <REGISTRY>/yunyan-<service>:<TAG>,与各 docker-compose.yml 对齐。
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SERVICE="${1:-}"
[ -n "$SERVICE" ] || { echo "用法: ./build.sh <console|admin>"; 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

11
deploy/.gitignore

@ -1,11 +1,6 @@
# 真实环境变量与部署目标(含密钥 / DSN / SSH 密码),勿提交 # 真实环境变量(含密钥 / DSN / SSH),勿提交;仓库只保留 *.example 模板
env/*.env */env/*.env
targets/*.conf */.env
.env
# 保留模板
!env/example.env
!targets/example.conf
# 运行时日志 # 运行时日志
console/log/ console/log/

105
deploy/README.md

@ -1,64 +1,73 @@
# yunyan-sas 部署 # yunyan-sas 部署
三环境部署:`local`(本机)/ `dev`(开发服务器)/ `prod`(生产服务器),统一入口 `deploy.sh`。 **每个服务目录各一个 `deploy.sh`**,服务名取自所在目录,指令为 `<env> [action]`。
服务:`yunyan-console`(Go 控制面,:8080)+ `yunyan-admin`(Nuxt 管理前端,:3000)。 每个服务自带单服务 compose + 环境模板;依赖服务(PostgreSQL / Redis / NATS)
由目标环境**共享提供**,不在 compose 内搭建,只在 env 里填连接地址。
## 服务
- `console/` — Go 控制面服务(:8080)
- `admin/` — Nuxt 管理前端(:3000,通过 `CONSOLE_BACKEND` 连 console)
## 目录结构 ## 目录结构
``` ```
deploy/ deploy/
├── deploy.sh # 部署入口 └── <service>/ # console / admin
├── docker-compose.yml # 变量驱动 compose(console + admin) ├── deploy.sh # 本服务部署脚本(服务名 = 目录名)
├── env/ ├── docker-compose.yml # 单服务 compose(变量驱动,无依赖服务)
│ ├── local.env # 本机运行时变量 ├── env/
│ ├── dev.env / prod.env # 各环境运行时变量(占位,需填) │ ├── local.env.example
│ └── example.env # 模板 │ ├── dev.env.example
├── targets/ │ ├── prod.env.example
│ ├── dev.conf / prod.conf # 远程目标 SSH/目录/仓库(占位,需填) │ └── (复制填写后的 local.env / dev.env / prod.env)
│ └── example.conf # 模板 └── (console 额外: confs/console.yaml、log/)
└── console/confs/console.yaml # console 配置(变量从 .env 注入)
``` ```
> 两个服务的 `deploy.sh` 内容相同,靠所在目录名区分服务。
## 首次配置(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 公钥已授权。
## 用法 ## 用法
进入服务目录运行:
```bash ```bash
# 本机 cd deploy/console
./deploy.sh local up # 起服务(访问 http://localhost:3000) ./deploy.sh local up # 本机起 console
./deploy.sh local down ./deploy.sh dev # 一键发布 console 到开发服务器(动作默认 deploy)
./deploy.sh local logs ./deploy.sh prod # 一键发布 console 到生产服务器
./deploy.sh prod logs # 看生产 console 日志
# 开发服务器(一键:构建推送镜像 + 同步配置 + 远程拉起)
./deploy.sh dev deploy
./deploy.sh dev ps
./deploy.sh dev logs
# 生产服务器 cd deploy/admin
./deploy.sh prod deploy ./deploy.sh local up # 本机起 admin
./deploy.sh prod restart ./deploy.sh prod # 发布 admin 到生产服务器
``` ```
## 操作矩阵 参数:
| 操作 | local | dev / prod | - `env`:`local` | `dev` | `prod`
|------|-------|-----------| - `action`:
| up | 本机 compose up -d | 同步配置 + 远程 pull + up | - `local`:up(默认)| down | restart | logs | ps | pull | build
| down / restart / logs / ps | 本机 | 远程 | - `dev` / `prod`:deploy(默认,构建推送+同步+远程拉起)| up(仅远程拉起)| build | down | restart | logs | ps | pull
| pull | 本机拉镜像 | 远程拉镜像 |
| build | 本机构建推送镜像(TAG 取自 env) | 同(构建始终在本机) | ## 首次配置(dev / prod)
| deploy | build + up | build + 同步 + 远程 pull + up | 配置分两处:**部署目标/仓库凭据写死在各服务的 `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)。 依赖服务(DB/Redis/NATS)共享、由环境预先提供。两个应用服务按序:
2. SSH 到目标机,创建 `REMOTE_DIR`。 1. 先 `console`(admin 依赖它)
3. scp 同步:`docker-compose.yml` + `env/<环境>.env`(→ 远程 `.env`) + `console/confs/console.yaml`。 2. 再 `admin`(同一目标机、同一 `DOCKER_NETWORK`,用容器名 `yunyan-console` 连 console)
4. 远程:确保 `DOCKER_NETWORK` 网络存在(不存在则自动创建)+ `docker login` 私有仓库。
5. 远程:`docker compose pull && docker compose up -d`。
## 注意 ## 注意
- `env/*.env`、`targets/*.conf` 含密钥,已 `.gitignore` 忽略,**不要提交**。 - `env/*.env`(真实)含密钥/DSN,已 `.gitignore` 忽略;仓库只保留 `*.example`。
- prod 的 `TAG` 建议固定版本号(如 `v1.2.0`)便于回滚;prod 的 `FIELD_ENCRYPT_KEY` / `CONSOLE_TOKEN_KEY` 务必改强随机值。 - ⚠️ 部署目标/仓库密码写死在各服务 `deploy.sh`,而它们**会进 git**——`DEPLOY_HOST`、`REGISTRY_PASS` 等会随仓库提交(私有仓库内可接受;公开前务必清理两个 `deploy.sh`)。
- redis / nats:在目标机 `DOCKER_NETWORK` 网络内则用容器名(`redis:6379`);否则在 env 填外部 `IP:端口`。 - prod 的 `TAG` 用固定版本号便于回滚;`FIELD_ENCRYPT_KEY` / `CONSOLE_TOKEN_KEY` 用强随机值,环境间不复用。
- console 首启会在主库自动建表(含 serviceconfig / calltranslate 等新表)。 - 镜像构建复用仓库根目录 `build.sh`(`deploy.sh` 在 deploy/build 动作时自动调用,目标=服务名);当前仓库**尚无 build.sh**,build/deploy 前需先补。

184
deploy/admin/deploy.sh

@ -0,0 +1,184 @@
#!/usr/bin/env bash
# <服务> 部署脚本(每个服务目录各一份,服务名取自所在目录名)
#
# 用法: ./deploy.sh <env> [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 <<EOF
用法: ./deploy.sh <env> [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 完成"

29
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

10
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 段),不在此文件填。

8
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

10
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 段),不在此文件填。

184
deploy/console/deploy.sh

@ -0,0 +1,184 @@
#!/usr/bin/env bash
# <服务> 部署脚本(每个服务目录各一份,服务名取自所在目录名)
#
# 用法: ./deploy.sh <env> [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 <<EOF
用法: ./deploy.sh <env> [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 完成"

38
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

26
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 段),不在此文件填。

23
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

26
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 段),不在此文件填。

136
deploy/deploy.sh

@ -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 完成"

63
deploy/docker-compose.yml

@ -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 <name>),且 redis/nats 可达
# (同网络容器名,或在 .env 里用 REDIS_ADDR/NATS_URL 填外部地址)。
networks:
appnet:
name: ${DOCKER_NETWORK:-1panel-network}
external: true

37
deploy/env/example.env

@ -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 <name>
# 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

17
deploy/targets/example.conf

@ -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
Loading…
Cancel
Save