Browse Source

上传代码

main
liwei1dao 2 months ago
parent
commit
bb3b1c7a3a
  1. 2
      CLAUDE.md
  2. 2
      apps/services/comm/module.go
  3. 40
      apps/services/modules/mcp/core.go
  4. 2
      apps/services/modules/mcp/module.go
  5. 15
      apps/services/modules/mcp/tool_hefeng_weather.go
  6. 20
      apps/services/modules/user/api_binddevice.go
  7. 12
      apps/services/modules/user/api_unbinddevice.go
  8. 296
      apps/services/tools/importappgoods/main.go
  9. 2
      deploy/admin/prod-deploy.sh
  10. 2
      deploy/app/prod-deploy.sh
  11. 2
      deploy/console/prod-deploy.sh

2
CLAUDE.md

@ -89,7 +89,7 @@ compose 的 image 由脚本注入 `IMAGE_NAME` 变量,同样不写死。
`prod-deploy.sh` APPS 里的远端目录 `/home/work/voitrans-a11`。
admin/console 的 `prod-deploy.sh` 已改为**多生产环境档案**(`env_profile()`,同 `dev-deploy.sh` 的写法):
- `yunyan` → 139.224.212.237(上海阿里云,与业务后端 app 同机),目录 `/home/work/starpivot/{admin,console}`,密钥 `~/Documents/key/aliyun_shanghai.pem`
- `yunyan` → 139.224.212.237(上海阿里云,与业务后端 app 同机),目录 `/home/work/starpivot/{admin,console}`,密钥 `/Users/liwei1dao/work/密钥/release_shanghai.pem`
(2026-08-05 由美国机 47.253.88.66 迁来;美国机上的 `yunyan-admin`/`console-a11` 容器仍在跑,待验证后停,脚本已不再指向它)
- `lingpu` → 8.166.114.65(广州阿里云),目录 `/home/work/starpivot/{admin,console}`,密钥 `~/.ssh/lingpu-gz.pem`
- `along` → 47.116.104.181(阿龙正式机,2026-08-16 补),目录 `/home/work/starpivot/{admin,console}`,密钥 `/Users/liwei1dao/liwei/密钥/along/releae-guangzhou.pem`(与测试机同一把);镜像走阿龙仓库 `registry.ymaikj.com`

2
apps/services/comm/module.go

@ -37,7 +37,7 @@ type StatEvent struct {
ChannelId string // 渠道商ID(短码):设备类事件填该设备授权码上的渠道商;其余事件填用户最后绑定设备的渠道商(DBUser.Lastbindchannelid);无归属填 ""
Uid string // 触发用户;用于登录/活跃/付费用户的去重统计
Count int64 // 通用次数,<=0 时按 1 计
Count int64 // 通用次数:0 视为未指定按 1 计;负数保留,用于冲正(如解绑 -1)
Amount int64 // 金额(分):订单类事件
Second int64 // 时长(秒):翻译 / 会议
Words int64 // 字数:翻译

40
apps/services/modules/mcp/core.go

@ -2,12 +2,14 @@ package mcp
import (
"context"
"yunyan/lego/sys/log"
"fmt"
"net/http"
"runtime/debug"
"time"
"yunyan/lego/sys/log"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
@ -32,6 +34,42 @@ func stringInArray(target string, array []string) bool {
return false
}
// safeLogErrorf 记录错误日志,日志系统未初始化等异常不向外扩散
func safeLogErrorf(format string, args ...interface{}) {
defer func() { _ = recover() }()
log.Errorf(format, args...)
}
// safeToolHandler 包装工具处理函数,保证任何情况下都给大模型返回一个可读的结果:
// 1. 捕获 handler 内部 panic(参数异常导致的空指针、数组越界等),不让单个工具打崩整条请求;
// 2. 兜底 handler 返回 (nil, nil) 的情况——mcp-go 的 request_handler 会直接解引用结果(*result),
// result 为 nil 时必然 panic;
// 3. 把 error 转成工具错误结果(IsError),让模型能看到失败原因并自行重试/换参数,
// 而不是整条 JSON-RPC 请求失败。
func safeToolHandler(name string, handler server.ToolHandlerFunc) server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (result *mcp.CallToolResult, err error) {
defer func() {
if r := recover(); r != nil {
//先给出结果,再记录日志,保证日志系统本身出问题也不影响返回
result = mcp.NewToolResultError(fmt.Sprintf("工具 %s 执行异常,请确认调用参数是否正确后重试", name))
err = nil
safeLogErrorf("tool %s panic recovered: %v\nstack: %s", name, r, string(debug.Stack()))
}
}()
if result, err = handler(ctx, request); err != nil {
safeLogErrorf("tool %s exec err: %v", name, err)
result = mcp.NewToolResultError(fmt.Sprintf("工具 %s 执行失败: %s", name, err.Error()))
err = nil
return
}
if result == nil {
safeLogErrorf("tool %s return nil result", name)
result = mcp.NewToolResultError(fmt.Sprintf("工具 %s 未返回结果,请确认调用参数是否正确后重试", name))
}
return
}
}
func isValidDate(dateStr string) bool {
_, err := time.Parse("2006-01-02", dateStr)
return err == nil

2
apps/services/modules/mcp/module.go

@ -154,7 +154,7 @@ func (this *Mcp) run() {
func (this *Mcp) AddTool(group string, tool mcp.Tool, handler server.ToolHandlerFunc) bool {
if tools, ok := this.options.Groups[group]; ok { //不存在 组注册
if stringInArray(tool.Name, tools) { //存在 菜注册
this.server.AddTool(tool, handler)
this.server.AddTool(tool, safeToolHandler(tool.Name, handler))
return true
}
}

15
apps/services/modules/mcp/tool_hefeng_weather.go

@ -89,28 +89,31 @@ func (this *tool_hefeng_weather) Handl(ctx context.Context, request mcp.CallTool
}
}
if city, err = request.RequireString("city"); err != nil || city == "" {
err = fmt.Errorf("必要参数丢失")
err = nil
result = mcp.NewToolResultError("缺少必要参数 city,请补充需要查询的城市名称后重试")
return
}
if cityresult, err = hefengweather.CityLookup(city); err != nil || cityresult.Code != "200" {
context = fmt.Sprintf("查询目标城市信息失败 city:%s,请重新确认城市名是否正确", city)
//城市查询失败(网络错误、返回码非200、没有匹配到城市)统一按工具错误返回,交给模型自行纠正参数
if cityresult, err = hefengweather.CityLookup(city); err != nil || cityresult == nil || cityresult.Code != "200" || len(cityresult.Location) == 0 {
log.Error("获取天气信息失败",
log.Field{Key: "city", Value: city},
log.Field{Key: "day", Value: day},
log.Field{Key: "err", Value: err},
)
err = nil
result = mcp.NewToolResultError(fmt.Sprintf("查询目标城市信息失败 city:%s,请重新确认城市名是否正确", city))
return
}
weatherresult, err = hefengweather.QueryWeather(cityresult.Location[0].ID)
if err != nil && weatherresult.Code != "200" {
context = fmt.Sprintf("查询目标城市天气信息失败 city:%s,请重新确认城市名是否正确", city)
if weatherresult, err = hefengweather.QueryWeather(cityresult.Location[0].ID); err != nil || weatherresult == nil || weatherresult.Code != "200" {
this.module.Debug("tool_hefeng_weather fail",
log.Field{Key: "city", Value: city},
log.Field{Key: "day", Value: day},
log.Field{Key: "err", Value: err},
)
err = nil
result = mcp.NewToolResultError(fmt.Sprintf("查询目标城市天气信息失败 city:%s,请重新确认城市名是否正确", city))
return
} else {
context = utils.ToString(map[string]interface{}{

20
apps/services/modules/user/api_binddevice.go

@ -108,6 +108,17 @@ func (this *apiComp) BindDevice(session comm.IUserSession, req *pb.UserBindDevic
for _, v := range divices {
if v.License == model.Code {
this.module.model.delDevice(v.Id) // 删除设备
// 接管即原用户被动解绑:按原设备自身的产品/渠道冲正 -1,
// 否则同一台设备每换一次账号就多计一次绑定。
// 不带 Uid:原用户并未操作,不应被计入当日活跃用户。
if this.module.analyze != nil {
this.module.analyze.Report(&comm.StatEvent{
Type: comm.StatEventDeviceBind,
ProductId: uint32(v.Productid),
ChannelId: v.Channelid,
Count: -1,
})
}
}
}
}
@ -175,9 +186,14 @@ func (this *apiComp) BindDevice(session comm.IUserSession, req *pb.UserBindDevic
user.Lastbindproductid = product.Id
user.Lastbindchannelid = model.Channelid
userSaved := false
// 设备激活 + 绑定埋点
// 设备激活 + 绑定埋点。
// 激活是「设备一生一次」的事件:只在 codestate==0 首激时记,换绑/接管不再计——
// 与上面 model.Usedtime 只在 0→1 时写保持同一口径,否则激活数被换绑次数放大。
// 绑定是净增流量:这里 +1,解绑/被接管处 -1(见 api_unbinddevice.go 与上面自动解绑分支)。
if this.module.analyze != nil {
this.module.analyze.Report(&comm.StatEvent{Type: comm.StatEventDeviceActive, Uid: session.GetUserId(), ProductId: uint32(product.Id), ChannelId: model.Channelid})
if codestate == 0 {
this.module.analyze.Report(&comm.StatEvent{Type: comm.StatEventDeviceActive, Uid: session.GetUserId(), ProductId: uint32(product.Id), ChannelId: model.Channelid})
}
this.module.analyze.Report(&comm.StatEvent{Type: comm.StatEventDeviceBind, Uid: session.GetUserId(), ProductId: uint32(product.Id), ChannelId: model.Channelid})
}
this.module.Debug("BindDevice",

12
apps/services/modules/user/api_unbinddevice.go

@ -85,6 +85,18 @@ func (this *apiComp) UnBindDevice(session comm.IUserSession, req *pb.UserUnBindD
}
return
}
// 解绑冲正 -1:绑定数是「当日净增」口径,只加不减会让绑了又解的设备永久留在统计里。
// 产品/渠道取被解绑设备自身的归属(绑定时固化),保证 +1 与 -1 落在同一分区。
// 激活不冲正:激活是设备一生一次的事件,解绑也不会把授权码 status 归零。
if this.module.analyze != nil {
this.module.analyze.Report(&comm.StatEvent{
Type: comm.StatEventDeviceBind,
Uid: session.GetUserId(),
ProductId: uint32(divice.Productid),
ChannelId: divice.Channelid,
Count: -1,
})
}
resp = &pb.UserUnBindDeviceResp{}
return
}

296
apps/services/tools/importappgoods/main.go

@ -0,0 +1,296 @@
// 一次性迁移工具:把各应用**业务库**(MySQL)的 goods 表(pb.DBGoods)拷贝到
// console 主库(Postgres)的公共商品表 app_goods(comm.AppGoods),按 app_name 隔离。
//
// · 业务库只读——绝不修改/删除应用本地 goods 表;
// · 目标表按 (app_name, goods_id) 判重:默认跳过已存在的商品(幂等,可重复执行),
// 加 -update 则用业务库数据覆盖已存在行(仅覆盖商品字段,不动 id/app_name/createtime);
// · -dry 只打印将要做什么,不写库。
//
// 用法示例(DSN 也可用环境变量 MYSQL_DSN / PG_DSN 传,避免写进命令历史):
//
// go run ./tools/importappgoods -mysql '<业务库DSN>' -pg '<console主库DSN>' \
// -apps 'earphone=Voitrans,lumi=Lumi,deapsound=Deapsound,deepglass=Deepglass,echomeet=Echomeet' -dry
//
// -apps 是「MySQL 库名=应用名」的映射,应用名须与 console 注册表 app_registry.app_name 一致,
// 否则后台按应用筛选时看不到这批商品。
package main
import (
"flag"
"fmt"
"os"
"sort"
"strings"
"time"
"yunyan/comm"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// srcGoods 业务库 goods 表的读取结构(字段取自 pb.DBGoods,只读用,故不引 pb 以免 gorm tag 干扰)。
type srcGoods struct {
Id string `gorm:"column:id"`
Enable bool `gorm:"column:enable"`
Name string `gorm:"column:name"`
Localname string `gorm:"column:localname"`
Price int32 `gorm:"column:price"`
Usagetype int32 `gorm:"column:usagetype"`
Ainum int64 `gorm:"column:ainum"`
Meetnum int64 `gorm:"column:meetnum"`
Tradenum int64 `gorm:"column:tradenum"`
Viptime int32 `gorm:"column:viptime"`
}
func main() {
var (
mysqlDsn = flag.String("mysql", os.Getenv("MYSQL_DSN"), "业务库 MySQL DSN(库名部分会被 -apps 的库名逐个替换)")
pgDsn = flag.String("pg", os.Getenv("PG_DSN"), "console 主库 Postgres DSN")
appsArg = flag.String("apps", "", "MySQL库名=应用名,逗号分隔,如 earphone=Voitrans,lumi=Lumi")
dry = flag.Bool("dry", false, "只打印计划,不写库")
verify = flag.Bool("verify", false, "只读校验:逐条比对业务库与 app_goods 的字段是否一致")
update = flag.Bool("update", false, "已存在的商品用业务库数据覆盖(默认跳过)")
)
flag.Parse()
if *mysqlDsn == "" || *pgDsn == "" || *appsArg == "" {
fmt.Fprintln(os.Stderr, "缺少参数:-mysql / -pg / -apps 必填")
flag.Usage()
os.Exit(2)
}
pairs, err := parseApps(*appsArg)
if err != nil {
fatal(err)
}
pg, err := gorm.Open(postgres.Open(withExecQueryMode(*pgDsn)), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
fatal(fmt.Errorf("连接 console 主库失败: %w", err))
}
if sqlDB, err := pg.DB(); err == nil { // 与 lego/sys/postgres 一样对 pooler 客气点
sqlDB.SetMaxOpenConns(2)
sqlDB.SetMaxIdleConns(1)
}
// 表通常已由 console 启动时建好;只在确实缺表时建(不能直接 AutoMigrate——
// 走 Supabase pooler 时它对已存在的表会报 42P07 relation already exists)。
// 用 to_regclass 按 search_path 找表:gorm 的 HasTable 查的是 CURRENT_SCHEMA(),
// 经 Supabase pooler 连接时它与实际 search_path 不一致,会把已存在的表判成不存在。
var tableExists bool
if err := pg.Raw("SELECT to_regclass(?) IS NOT NULL", comm.TableAppGoods).Scan(&tableExists).Error; err != nil {
fatal(fmt.Errorf("检查表 %s 是否存在失败: %w", comm.TableAppGoods, err))
}
if !tableExists {
if err := pg.Table(comm.TableAppGoods).AutoMigrate(&comm.AppGoods{}); err != nil {
fatal(fmt.Errorf("建表 %s 失败: %w", comm.TableAppGoods, err))
}
fmt.Printf("已创建表 %s\n", comm.TableAppGoods)
}
// 注册表里的应用名,用于提醒拼写不一致(只提醒,不阻断)
var registered []string
if err := pg.Table("app_registry").Order("app_name").Pluck("app_name", &registered).Error; err == nil {
fmt.Printf("console 注册表现有应用: %s\n", strings.Join(registered, ", "))
}
now := time.Now().Unix()
var totalIns, totalUpd, totalSkip int
for _, p := range pairs {
dsn := replaceMySQLDB(*mysqlDsn, p.db)
src, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
fmt.Printf("✗ %-12s 连接业务库 %s 失败: %v\n", p.app, p.db, err)
continue
}
var rows []srcGoods
if err := src.Table(comm.TableGoods).Order("id").Find(&rows).Error; err != nil {
fmt.Printf("✗ %-12s 读取 %s.goods 失败: %v\n", p.app, p.db, err)
closeDB(src)
continue
}
closeDB(src)
if !contains(registered, p.app) && len(registered) > 0 {
fmt.Printf("⚠ %-12s 不在 console 注册表 app_registry 中,后台按应用筛选可能看不到\n", p.app)
}
var ins, upd, skip int
for i, r := range rows {
var exist comm.AppGoods
err := pg.Table(comm.TableAppGoods).Where("app_name=? AND goods_id=?", p.app, r.Id).Take(&exist).Error
if *verify {
switch {
case err == gorm.ErrRecordNotFound:
fmt.Printf("✗ %-12s %s 目标表缺失\n", p.app, r.Id)
ins++
case err != nil:
fmt.Printf("✗ %-12s 查询 %s 失败: %v\n", p.app, r.Id, err)
default:
if d := diffGoods(&r, &exist); d != "" {
fmt.Printf("✗ %-12s %s 字段不一致: %s\n", p.app, r.Id, d)
upd++
} else {
skip++
}
}
continue
}
switch {
case err == gorm.ErrRecordNotFound:
m := &comm.AppGoods{
AppName: p.app, GoodsId: r.Id, Enable: r.Enable, Name: r.Name,
Price: r.Price, Usagetype: r.Usagetype, Ainum: r.Ainum, Meetnum: r.Meetnum,
Tradenum: r.Tradenum, Viptime: r.Viptime, Localname: r.Localname,
Sort: int32((i + 1) * 10), Remark: "由业务库 " + p.db + ".goods 导入",
Createtime: now, Updatetime: now,
}
if !*dry {
if err := pg.Table(comm.TableAppGoods).Create(m).Error; err != nil {
fmt.Printf("✗ %-12s 写入 %s 失败: %v\n", p.app, r.Id, err)
continue
}
}
ins++
case err != nil:
fmt.Printf("✗ %-12s 查询 %s 失败: %v\n", p.app, r.Id, err)
case *update:
exist.Enable, exist.Name, exist.Price, exist.Usagetype = r.Enable, r.Name, r.Price, r.Usagetype
exist.Ainum, exist.Meetnum, exist.Tradenum, exist.Viptime = r.Ainum, r.Meetnum, r.Tradenum, r.Viptime
exist.Localname, exist.Updatetime = r.Localname, now
if !*dry {
if err := pg.Table(comm.TableAppGoods).Save(&exist).Error; err != nil {
fmt.Printf("✗ %-12s 更新 %s 失败: %v\n", p.app, r.Id, err)
continue
}
}
upd++
default:
skip++
}
}
if *verify {
fmt.Printf("· %-12s(%s) 源 %2d 条 → 一致 %2d 不一致 %2d 缺失 %2d\n", p.app, p.db, len(rows), skip, upd, ins)
} else {
fmt.Printf("· %-12s(%s) 源 %2d 条 → 新增 %2d 更新 %2d 跳过(已存在) %2d\n", p.app, p.db, len(rows), ins, upd, skip)
}
totalIns, totalUpd, totalSkip = totalIns+ins, totalUpd+upd, totalSkip+skip
}
switch {
case *verify:
fmt.Printf("\n校验完成:一致 %d,不一致 %d,缺失 %d\n", totalSkip, totalUpd, totalIns)
case *dry:
fmt.Printf("\nDRY-RUN(未写库):新增 %d,更新 %d,跳过 %d\n", totalIns, totalUpd, totalSkip)
default:
fmt.Printf("\n已写入:新增 %d,更新 %d,跳过 %d\n", totalIns, totalUpd, totalSkip)
}
// 目标表现状
type stat struct {
AppName string
N int64
}
var stats []stat
if err := pg.Table(comm.TableAppGoods).Select("app_name, count(*) as n").Group("app_name").Order("app_name").Scan(&stats).Error; err == nil {
fmt.Println("app_goods 现有数据:")
for _, s := range stats {
fmt.Printf(" %-12s %d\n", s.AppName, s.N)
}
}
}
// diffGoods 比对源(业务库)与目标(app_goods)的商品字段,返回不一致项的说明(空串=完全一致)。
// 只比对「拷贝过来的」字段,app_goods 独有的 sort/remark/时间戳不参与。
func diffGoods(s *srcGoods, d *comm.AppGoods) string {
var bad []string
cmp := func(name string, a, b any) {
if fmt.Sprint(a) != fmt.Sprint(b) {
bad = append(bad, fmt.Sprintf("%s(%v→%v)", name, a, b))
}
}
cmp("enable", s.Enable, d.Enable)
cmp("name", s.Name, d.Name)
cmp("price", s.Price, d.Price)
cmp("usagetype", s.Usagetype, d.Usagetype)
cmp("ainum", s.Ainum, d.Ainum)
cmp("meetnum", s.Meetnum, d.Meetnum)
cmp("tradenum", s.Tradenum, d.Tradenum)
cmp("viptime", s.Viptime, d.Viptime)
if s.Localname != d.Localname {
bad = append(bad, "localname")
}
return strings.Join(bad, " ")
}
// withExecQueryMode 与 lego/sys/postgres 保持一致:Supabase 的事务型 pooler(6543)
// 不保留会话级 prepared statement,pgx 默认的 cache_statement 会随机报
// "prepared statement ... does not exist/already exists",必须改用 cache_describe。
func withExecQueryMode(dsn string) string {
if strings.Contains(dsn, "default_query_exec_mode") {
return dsn
}
sep := "?"
if strings.Contains(dsn, "?") {
sep = "&"
}
return dsn + sep + "default_query_exec_mode=cache_describe"
}
type appPair struct{ db, app string }
func parseApps(s string) (out []appPair, err error) {
for _, seg := range strings.Split(s, ",") {
seg = strings.TrimSpace(seg)
if seg == "" {
continue
}
db, app, ok := strings.Cut(seg, "=")
if !ok || strings.TrimSpace(db) == "" || strings.TrimSpace(app) == "" {
return nil, fmt.Errorf("-apps 片段格式应为 库名=应用名,收到: %q", seg)
}
out = append(out, appPair{strings.TrimSpace(db), strings.TrimSpace(app)})
}
if len(out) == 0 {
return nil, fmt.Errorf("-apps 为空")
}
sort.Slice(out, func(i, j int) bool { return out[i].app < out[j].app })
return out, nil
}
// replaceMySQLDB 把 DSN 里 ")/" 之后、"?" 之前的库名换成目标库(同一实例多库共用一份账号)。
func replaceMySQLDB(dsn, db string) string {
i := strings.LastIndex(dsn, ")/")
if i < 0 {
return dsn
}
head := dsn[:i+2]
rest := dsn[i+2:]
if j := strings.Index(rest, "?"); j >= 0 {
return head + db + rest[j:]
}
return head + db
}
func contains(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}
func closeDB(db *gorm.DB) {
if sqlDB, err := db.DB(); err == nil {
_ = sqlDB.Close()
}
}
func fatal(err error) {
fmt.Fprintln(os.Stderr, "✗", err)
os.Exit(1)
}

2
deploy/admin/prod-deploy.sh

@ -46,7 +46,7 @@ env_profile() {
ENV_DESC="云雁生产环境 · 上海阿里云"
registry_profile yunyan || return 1 # 镜像仓库:云雁
DEPLOY_HOST="139.224.212.237" # ← 云雁生产服务器 IP(上海)
DEPLOY_KEY=/Users/liwei/Documents/key/aliyun_shanghai.pem
DEPLOY_KEY="/Users/liwei1dao/work/密钥/release_shanghai.pem"
DEPLOY_DIR="/home/work/starpivot/admin" # 远端部署目录(其中 docker-compose.yml/.env 由你自己维护)
;;
lingpu)

2
deploy/app/prod-deploy.sh

@ -88,7 +88,7 @@ region_profile() { # 远端部署目录/容器名
DEPLOY_HOST="139.224.212.237" # ← 上海生产服务器 IP(必填)
DEPLOY_PORT=22
DEPLOY_USER=root
DEPLOY_KEY=/Users/liwei/Documents/key/aliyun_shanghai.pem # ← 上海服务器 SSH 私钥(各区域不同)
DEPLOY_KEY="/Users/liwei1dao/work/密钥/release_shanghai.pem" # ← 上海服务器 SSH 私钥(各区域不同)
;;
japan)
DEPLOY_HOST="8.216.34.101" # ← 日本生产服务器 IP(必填)

2
deploy/console/prod-deploy.sh

@ -46,7 +46,7 @@ env_profile() {
ENV_DESC="云雁生产环境 · 上海阿里云"
registry_profile yunyan || return 1 # 镜像仓库:云雁
DEPLOY_HOST="139.224.212.237" # ← 云雁生产服务器 IP(上海)
DEPLOY_KEY=/Users/liwei/Documents/key/aliyun_shanghai.pem
DEPLOY_KEY="/Users/liwei1dao/work/密钥/release_shanghai.pem"
DEPLOY_DIR="/home/work/starpivot/console" # 远端部署目录(其中 docker-compose.yml/.env/confs 由你自己维护)
;;
lingpu)

Loading…
Cancel
Save