package console import ( "context" "encoding/json" "errors" "fmt" "net/http" "path/filepath" "strconv" "strings" "sync" "time" "yunyan/comm" "yunyan/lego/core" "yunyan/lego/core/cbase" "yunyan/lego/sys/log" "yunyan/lego/sys/mysql" "yunyan/pb" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v4" ) // serverComp 控制面对外 HTTP 服务:静态页 + 注册表 API + /web/api/api_*(按 X-App-Id 直连应用库)+ 配置事件下发。 type serverComp struct { cbase.ModuleCompBase module *Console options *Options srv *http.Server // analyzing 记录正在跑历史分析的 app_id(值无意义),防止同一应用并发分析—— // analyzeAppHistory 含 DELETE+重写,并发会互相清表,必须串行。重复点击直接拒绝。 analyzing sync.Map // deploying 记录正在升级/部署的部署 id(值无意义),防止同一部署并发 SSH 部署—— // docker compose up 并发会互相拉起/删容器,必须串行。重复点击直接拒绝。 deploying sync.Map } func (this *serverComp) 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) this.options = opt.(*Options) gin.SetMode(gin.ReleaseMode) eng := gin.New() eng.Use(gin.Recovery()) eng.Use(cors()) this.routes(eng) this.srv = &http.Server{Addr: this.options.HTTP.Addr, Handler: eng} return } func (this *serverComp) Start() (err error) { if err = this.ModuleCompBase.Start(); err != nil { return } go func() { if e := this.srv.ListenAndServe(); e != nil && e != http.ErrServerClosed { log.Errorf("console: HTTP 服务退出: %v", e) } }() log.Infof("console: HTTP 服务监听 %s(静态目录 %s)", this.options.HTTP.Addr, this.options.StaticDir) return nil } func (this *serverComp) Destroy() (err error) { if this.srv != nil { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = this.srv.Shutdown(ctx) } return this.ModuleCompBase.Destroy() } func (this *serverComp) routes(eng *gin.Engine) { eng.GET("/console/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 0, "msg": "ok"}) }) eng.GET("/", func(c *gin.Context) { c.Redirect(http.StatusFound, "/console/") }) // 兼容现有前端调用约定:/web/api/api_。login/getsiteinfo 放行,其余需登录态。 web := eng.Group("/web/api", this.authWeb()) web.POST("/:method", this.handleWeb) // /console/api:需登录态,再按角色细分。 mgr := eng.Group("/console/api", this.authStrict()) // 当前登录账号信息(侧边栏底部展示:名称/身份/代理资源点余额)——任意登录角色可取自己的。 mgr.POST("/me", this.accountMe) // 自助改密:任意登录角色改自己的密码(校验旧密码)。引导超管改不了,见 handler 说明。 mgr.POST("/accounts/changepwd", this.accountsChangePwd) // 应用注册表 CRUD + 配置事件:超管 + 管理员(代理商/运营不能改应用接入)。 apps := mgr.Group("", this.requireIdentity(pb.Identity_Admin, pb.Identity_Manager)) // 应用/产品(按应用名分组的父层):超管 + 管理员。 apps.POST("/products/list", this.productsList) apps.POST("/products/add", this.productsAdd) apps.POST("/products/update", this.productsUpdate) apps.POST("/products/del", this.productsDel) apps.POST("/apps/list", this.appsList) apps.POST("/apps/test", this.appsTest) apps.POST("/apps/analyze", this.appsAnalyze) apps.POST("/apps/upgrade", this.appsUpgrade) // 远程升级:SSH 到部署环境 → docker login/pull/up 重启(NDJSON 流式回显) apps.POST("/apps/tags", this.appsTags) // 列镜像仓库可用 tag(升级弹窗下拉选版本) apps.POST("/apps/add", this.appsAdd) apps.POST("/apps/update", this.appsUpdate) apps.POST("/apps/del", this.appsDel) apps.POST("/apps/assign", this.appsAssign) apps.POST("/apps/bindenv", this.appsBindEnv) // 应用×服务 基础设施/运行配置:应用服务配置页读写(默认值由业务服务启动 seed)。 apps.POST("/svcconf/list", this.svcRuntimeList) apps.POST("/svcconf/services", this.svcRuntimeServices) apps.POST("/svcconf/save", this.svcRuntimeSave) apps.POST("/svcconf/del", this.svcRuntimeDel) // 应用业务功能配置(登录/短信/邮件/支付…):读写应用业务库(app_module_config),非公共库。 apps.POST("/modulecfg/catalog", this.moduleCfgCatalog) apps.POST("/modulecfg/list", this.moduleCfgList) apps.POST("/modulecfg/save", this.moduleCfgSave) apps.POST("/modulecfg/del", this.moduleCfgDel) apps.POST("/modulecfg/reset", this.moduleCfgReset) // 还原为业务服务配置文件(yaml)的初始值 // 应用环境配置(应用自有 key/value 配置,结构同全局环境配置但无区域):读写应用业务库(config)。 apps.POST("/appcfg/list", this.appCfgList) apps.POST("/appcfg/add", this.appCfgAdd) apps.POST("/appcfg/update", this.appCfgUpdate) apps.POST("/appcfg/del", this.appCfgDel) // 应用商品配置(内购商品,按 app_name 隔离、存 console 公共库;与部署环境无关) apps.POST("/appgoods/list", this.appGoodsList) apps.POST("/appgoods/add", this.appGoodsAdd) apps.POST("/appgoods/update", this.appGoodsUpdate) apps.POST("/appgoods/del", this.appGoodsDel) // 会议记录服务配置(识别/翻译/总结优先级 + 凭据 + 识别语言 + 总结模型清单;存应用业务库)。 // 部署环境(区域)管理:与应用注册同权限(超管 + 管理员)。 apps.POST("/envs/list", this.envList) apps.POST("/envs/test", this.envTest) apps.POST("/envs/add", this.envAdd) apps.POST("/envs/update", this.envUpdate) apps.POST("/envs/del", this.envDel) apps.POST("/config/notify", this.configNotify) // 给代理账号充值/调整资源点余额:超管 + 管理员(与赠送给代理同义)。 apps.POST("/accounts/topup", this.accountsTopup) // 账号管理:仅超管(pb.Identity 注明 Admin 为唯一可管理后台账号者)。 acc := mgr.Group("/accounts", this.requireIdentity(pb.Identity_Admin)) acc.POST("/list", this.accountsList) acc.POST("/add", this.accountsAdd) acc.POST("/update", this.accountsUpdate) acc.POST("/del", this.accountsDel) acc.POST("/resetpwd", this.accountsResetPwd) // 静态后台页用 NoRoute 兜底:API 路由优先,未命中再当 /console/ 下的静态文件, // 避免 gin 的 catch-all 通配与 /console/api、/console/health 冲突。 eng.NoRoute(this.serveStatic) } // serveStatic 服务 StaticDir 下 /console/ 前缀的静态资源(含 SPA 入口 index.html)。 func (this *serverComp) serveStatic(c *gin.Context) { p := c.Request.URL.Path if !strings.HasPrefix(p, "/console/") { c.Status(http.StatusNotFound) return } rel := strings.TrimPrefix(p, "/console/") if rel == "" || strings.HasSuffix(rel, "/") { rel += "index.html" } if strings.Contains(rel, "..") { // 防目录穿越 c.Status(http.StatusBadRequest) return } c.File(filepath.Join(this.options.StaticDir, filepath.Clean(rel))) } // ============================ 鉴权中间件 ============================ // authWeb 用于 /web/api:放行 api_login / api_getsiteinfo,其余校验 JWT。 func (this *serverComp) authWeb() gin.HandlerFunc { return func(c *gin.Context) { method := c.Param("method") if method == "api_login" || method == "api_getsiteinfo" { c.Next() return } if !this.tokenValid(c) { c.AbortWithStatusJSON(http.StatusOK, &comm.HttpResult{Code: pb.ErrorCode_NoLogin, Message: "未登录"}) return } c.Next() } } // authStrict 用于注册表/事件接口:一律校验 JWT。 func (this *serverComp) authStrict() gin.HandlerFunc { return func(c *gin.Context) { if !this.tokenValid(c) { c.AbortWithStatusJSON(http.StatusOK, &comm.HttpResult{Code: pb.ErrorCode_NoLogin, Message: "未登录"}) return } c.Next() } } func (this *serverComp) tokenValid(c *gin.Context) bool { tok := c.GetHeader("Authorization") if tok == "" { return false } claims, err := parseToken(tok, []byte(this.options.TokenKey)) if err != nil { return false } // 解析出的身份与作用域存进上下文,供 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) c.Set("channelid", claims.Channelid) return true } // currentIdentity 取当前登录者角色(需在 authWeb/authStrict 之后);未登录返回 Null。 func currentIdentity(c *gin.Context) pb.Identity { if v, ok := c.Get("identity"); ok { if idt, ok := v.(pb.Identity); ok { return idt } } return pb.Identity_Identity_Null } // currentAccountId 取当前登录者的后台账号 id(yaml 引导超管为 0)。 func currentAccountId(c *gin.Context) uint32 { if v, ok := c.Get("account_id"); ok { if id, ok := v.(uint32); ok { return id } } return 0 } // requireIdentity 角色守卫:当前登录者角色须在 allowed 集合内,否则 403(无权限)。 // 用在 authStrict 之后(依赖其写入的 identity)。 func (this *serverComp) requireIdentity(allowed ...pb.Identity) gin.HandlerFunc { return func(c *gin.Context) { idt := currentIdentity(c) for _, a := range allowed { if idt == a { c.Next() return } } c.AbortWithStatusJSON(http.StatusOK, &comm.HttpResult{Code: pb.ErrorCode_InsufficientPermissions, Message: "无权限"}) } } // ============================ /web/api 派发 ============================ func (this *serverComp) handleWeb(c *gin.Context) { method := c.Param("method") switch method { case "api_login": this.login(c) return case "api_getsiteinfo": this.getSiteInfo(c) return // 当前账号可选的应用部署列表:前端应用下拉统一调它(品牌商/渠道商按产品绑定的应用推导)。 case "api_getmyapps": this.getMyApps(c) return // 统计 dashboard:数据在 console 自己的公共库,不需选中应用,先于设备域分派。 case "api_getstatssummary": this.getStatsSummary(c) return case "api_getstatstrend": this.getStatsTrend(c) return // 月结算:结算单在 console 主库,与选中应用无关(生成时才逐个应用业务库聚合订单)。 case "api_getsettlements": this.getSettlements(c) return case "api_gensettlement": this.genSettlement(c) return case "api_confirmsettlement": this.confirmSettlement(c) return // 统计日志:console 从 NATS 收到的埋点接收流水,数据在 console 自己的 Redis,与选中应用无关。 case "api_getstatlogs": this.getStatLogs(c) return case "api_clearstatlogs": this.clearStatLogs(c) return // 文件直传:签发阿里云 OSS 预签名 PUT URL(配置读自「第三方服务配置→存储→阿里云 OSS」)。 // 路由名沿用 api_getcostoken(前端 useApi.uploadFile 复用),实现见 api_upload.go。 case "api_getcostoken": this.getUploadURL(c) return // 服务配置域:会议模板等共享配置存 console 主库(supabase),与选中应用无关。 // (「全局环境配置」global_config 已于 2026-09-12 下线:凭据统一收进 svc_config, // 见 migrate_envtosvc.go 与 modules/user 的 appEnvForUser。) case "api_getmeettemplates": this.getMeetTemplates(c) return case "api_getmeettemplate": this.getMeetTemplate(c) return case "api_addmeettemplate": this.addMeetTemplate(c) return case "api_updatemeettemplate": this.updateMeetTemplate(c) return case "api_delmeettemplate": this.delMeetTemplate(c) return case "api_syncmeettemplate": this.syncMeetTemplate(c) return // 第三方服务配置(svcconfig):服务字段定义/默认值 + 区域字段分叉(值覆盖/专属字段/停用字段) + 多区域同步,存 console 主库,与选中应用无关。 case "api_getsvcconfigs": this.getSvcConfigs(c) return case "api_addsvcconfig": this.addSvcConfig(c) return case "api_updatesvcconfig": this.updateSvcConfig(c) return case "api_delsvcconfig": this.delSvcConfig(c) return // 平台系统服务配置(sysconfig):对象存储/邮件/翻译,平台自用,存 console 主库 sys_service_config 表,与 svc_config 分离。 case "api_getsysconfigs": this.getSysConfigs(c) return case "api_savesysconfig": this.saveSysConfig(c) return case "api_setsysdefault": this.setSysDefault(c) return case "api_delsysconfig": this.delSysConfig(c) return case "api_testsysemail": this.testSysEmail(c) return case "api_getsvcregionoverride": this.getSvcRegionOverride(c) return case "api_savesvcregionoverride": this.saveSvcRegionOverride(c) return case "api_syncsvctoregins": this.syncSvcToRegions(c) return // 第三方服务巡检(svcinspect):定时校验 svc_config 配置 + 探测服务商可用性,结果存 svc_health。 case "api_getsvchealth": this.getSvcHealth(c) return case "api_runsvcinspect": this.runSvcInspect(c) return case "api_getsvcalerts": this.getSvcAlerts(c) return // 第三方服务商模板(svctemplate):数据驱动的字段 schema 预设,新增服务时选模板自动带出字段。 case "api_getsvctemplates": this.getSvcTemplates(c) return case "api_addsvctemplate": this.addSvcTemplate(c) return case "api_updatesvctemplate": this.updateSvcTemplate(c) return case "api_delsvctemplate": this.delSvcTemplate(c) return // MCP 工具发现:连上 MCP 服务列出它暴露的工具(服务配置页填完 url 点一下、agent 绑定时勾工具都用它)。 case "api_getmcptools": this.getMcpTools(c) return // TTS 音色表(代码常量,见 comm/ttsvoice.go):后台 agent 配音色时用它渲染候选,不落库。 case "api_getttsvoices": this.getTTSVoices(c) return // 应用发版与入口控制(app_release):版本/强更/送审版本/游客显隐,按 app_name 一应用一行、不分渠道。 // 两个保存接口各写一组字段,共用同一行——别合成一个「整行保存」,会互相清空。 case "api_getapprelease": this.getAppRelease(c) return case "api_saveappversion": this.saveAppVersion(c) return case "api_saveapptourists": this.saveAppTourists(c) return // 渠道分发(channelapp):各应用在各分发渠道的下载地址/上架状态/支付渠道开关,存 console 主库,按 app_name 作用域。 case "api_getchannelapps": this.getChannelApps(c) return case "api_savechannelapp": this.saveChannelApp(c) return case "api_delchannelapp": this.delChannelApp(c) return case "api_getchannelpaystatus": this.getChannelPayStatus(c) return // 邮件下发(生产设备码清单):multipart 表单 + 全局 email 系统,与选中应用无关。 case "api_sendsmtpemail": this.sendSmtpEmail(c) return // 用户查询:按选中应用(X-App-Id)直连其业务库查 user 表,与设备域(supabase)无关。 case "api_getuserinfo": this.getUserInfo(c) return // 用户列表:分页 + 条件筛选,同样按选中应用直连其业务库。 case "api_getusers": this.getUsers(c) return // 修改用户可运营参数(状态/VIP/积分余额)。仅超管/管理员——代理发资源必须走赠送那条扣余额的路。 case "api_updateuser": this.updateUser(c) return // 赠送资源点:给选中应用业务库的终端用户加 VIP天/翻译分/会议分;代理扣自己余额。 case "api_giftuserresource": this.giftUserResource(c) return // 会员与算力(2026-09):换算系数/闸门存选中应用业务库 config 表;余额调整与流水在业务库 user/useruselog。 case "api_getcomputeconfig": this.getComputeConfig(c) return case "api_savecomputeconfig": this.saveComputeConfig(c) return case "api_adjustusercompute": this.adjustUserCompute(c) return case "api_getusercomputelogs": this.getUserComputeLogs(c) return case "api_getcomputestats": this.getComputeStats(c) return } // 设备管理域:厂家/产品/设备码/公码/出货单都在 console 主库(supabase),无需选应用。 sys := consoleDeviceConn() // 仅重置类要清各应用业务库的 userdevice/product_stat,按 X-App-Id 取该应用业务库填入 sys.service。 switch method { case "api_resetlicensestatus", "api_batchresetlicensestatus", "api_restfactorydevics": 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 } sys.service = svc } switch method { // 芯片厂商(方案商的 vendor 指向它;原 SolutionVendor 枚举转表) case "api_getchipvendors": this.getChipVendors(c, sys) case "api_addchipvendor": this.addChipVendor(c, sys) case "api_updatechipvendor": this.updateChipVendor(c, sys) case "api_delchipvendor": this.delChipVendor(c, sys) // 代工厂/组装厂 case "api_getoemfactories": this.getOemFactories(c, sys) case "api_addoemfactory": this.addOemFactory(c, sys) case "api_updateoemfactory": this.updateOemFactory(c, sys) case "api_deloemfactory": this.delOemFactory(c, sys) // 方案商 case "api_getsolutionproviders": this.getSolutionProviders(c, sys) case "api_getsolutionprovider": this.getSolutionProvider(c, sys) case "api_addsolutionprovider": this.addSolutionProvider(c, sys) case "api_updatesolutionprovider": this.updateSolutionProvider(c, sys) case "api_delsolutionprovider": this.delSolutionProvider(c, sys) // 产品 case "api_getproducts": this.getProducts(c, sys) case "api_getproduct": this.getProduct(c, sys) case "api_getproductversions": this.getProductVersions(c, sys) case "api_addproduct": this.addProduct(c, sys) case "api_updateproduct": this.updateProduct(c, sys) case "api_delproduct": this.delProduct(c, sys) case "api_addproductversion": this.addProductVersion(c, sys) case "api_delproductversion": this.delProductVersion(c, sys) // 品牌商(原厂家) // 渠道商(挂在品牌商下) case "api_getchannels": this.getChannels(c, sys) case "api_getchannel": this.getChannel(c, sys) case "api_addchannel": this.addChannel(c, sys) case "api_updatechannel": this.updateChannel(c, sys) case "api_delchannel": this.delChannel(c, sys) case "api_auditchannel": this.auditChannel(c, sys) // 授权码 / 生产批次 case "api_previewauthcodes": this.previewAuthCodes(c, sys) case "api_createauthcodes": this.createAuthCodes(c, sys) case "api_disableauthcodes": this.disableAuthCodes(c, sys) case "api_revokeproductionbatch": this.revokeProductionBatch(c, sys) case "api_getauthcodes": this.getAuthCodes(c, sys) case "api_getauthcode": this.getAuthCode(c, sys) case "api_getproductionbatches": this.getProductionBatches(c, sys) case "api_resetlicensestatus": this.resetLicenseStatus(c, sys) case "api_batchresetlicensestatus": this.batchResetLicenseStatus(c, sys) case "api_restfactorydevics": this.restFactoryDevics(c, sys) // 品牌商公码 default: writeErr(c, pb.ErrorCode_NoFindServiceHandleFunc, "无此接口: "+method) } } func parseAppId(s string) uint32 { n, _ := strconv.ParseUint(strings.TrimSpace(s), 10, 32) return uint32(n) } // requireAppScope 校验 X-App-Id 指向的应用在当前账号作用域内(代理/运营)。 // 越界即写好错误响应并返回 false,调用方直接 return。超管/管理员或未绑定应用维度时直接放行。 func (this *serverComp) requireAppScope(c *gin.Context, appId uint32) bool { s := scopeOf(c) // 品牌商/渠道商账号的应用名单来自「名下产品绑定的应用」;其余角色是账号手工勾选的 apps。 // 沿用全局的「非空才限制」语义:名单为空(如产品都还没绑应用)时不拦,避免升级后老账号全被挡。 allowed := this.accountAppNames(c) if s.unlimited || len(allowed) == 0 { return true } app, err := this.module.model.getApp(appId) if err != nil || app == nil { writeErr(c, pb.ErrorCode_ReqParameterError, "应用不存在或无权访问") return false } // 名单里的既可能是部署注册名(历史数据),也可能是应用名(后台按应用勾选,国内/海外同名一条),两者都算命中。 if !containsStr(allowed, app.Name) && !(app.AppName != "" && containsStr(allowed, app.AppName)) { writeErr(c, pb.ErrorCode_InsufficientPermissions, "无权访问该应用") return false } return true } // ============================ 登录 / 站点信息 ============================ func (this *serverComp) login(c *gin.Context) { var req pb.ApiLoginReq _ = c.ShouldBindJSON(&req) identity, accountId, ok := this.authenticate(req.Account, req.Password) if !ok { writeErr(c, pb.ErrorCode_ReqParameterError, "账号或密码错误") return } now := time.Now() // 作用域绑定(代理/运营才有;超管/管理员为空=不受限):先查出来,既写进 token 供请求级强制隔离,也回传前端控菜单/筛选。 var access, apps, regions, products, channelid 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 channelid = acc.Channelid } } claims := &consoleClaims{ Identity: identity, Username: req.Account, AccountId: accountId, Apps: apps, Products: products, Regions: regions, Channelid: channelid, RegisteredClaims: jwt.RegisteredClaims{ Issuer: "console", Subject: fmt.Sprintf("%d", identity), ExpiresAt: jwt.NewNumericDate(now.Add(24 * time.Hour)), NotBefore: jwt.NewNumericDate(now), IssuedAt: jwt.NewNumericDate(now), ID: req.Account, }, } ts, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(this.options.TokenKey)) if err != nil { writeErr(c, pb.ErrorCode_SystemError, err.Error()) return } // 把绑定应用名解析为 {id,name}:代理无权调 apps/list,但 users.html / 切换器需要注册 id 取 X-App-Id。 // 渠道商账号不靠手工勾选,而是按「名下产品绑定的应用」推导(见 scope_app.go)。 // 超管/管理员无绑定 → 留空(它们走 apps/list,且这里若下发全量会让前端误设 X-App-Id)。 appBinds := make([]gin.H, 0) bindNames := splitCSV(apps) if len(bindNames) > 0 { appBinds = this.visibleAppBinds(bindNames, false) } writeOK(c, gin.H{ "account": req.Account, "account_id": accountId, // 账号表 id(0 = yaml 引导超管,库里没有记录);后台侧栏账号卡展示用 "identity": identity, "token": ts, "avatar": "https://gw.alipayobjects.com/zos/rmsportal/BiazfanxmamNRoxxVxka.png", "access": access, "apps": apps, "regions": regions, "products": products, "channelid": channelid, // 渠道商账号绑定的渠道商短码(空=不绑定) "appbinds": appBinds, // [{id,name,region}]:代理用它取应用注册 id }) } // authenticate 校验账号密码,返回角色与账号 id。 // 顺序:先匹配 yaml 引导超管(AdminAccount/AdminPassword,account_id=0,保证首登可用); // 再查 console_account 表(bcrypt 校验,须 Enabled)。 func (this *serverComp) authenticate(username, password string) (pb.Identity, uint32, bool) { if username == this.options.AdminAccount && password == this.options.AdminPassword { return pb.Identity_Admin, 0, true } acc, err := this.module.model.getAccountByName(username) if err != nil || acc == nil || acc.Id == 0 || !acc.Enabled { return pb.Identity_Identity_Null, 0, false } if !checkPassword(acc.Password, password) { return pb.Identity_Identity_Null, 0, false } return acc.Identity, acc.Id, true } func (this *serverComp) getSiteInfo(c *gin.Context) { writeOK(c, &pb.ApiGetSiteInfoResp{Sitename: this.options.SiteName, CurrencyType: 0}) } // ============================ 设备管理域 handlers ============================ // // 逻辑从 modules/api 的对应 handler 移植;去掉 RPCX session 与 scope 白名单(第一期仅超管), // 数据访问改为作用于传入的"选中应用"连接 sys。 // 读 handler 统一走 deviceCache(缓存优先 + DB 回退),设备域引用数据恒命中 Redis,避免直连海外主库的卡顿。 // ---- 方案商(读)---- func (this *serverComp) getSolutionProviders(c *gin.Context, sys *appConn) { models, err := dvSolutionProviders(sys) if err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } writeOK(c, &pb.ApiGetSolutionProvidersResp{Providers: models}) } func (this *serverComp) getSolutionProvider(c *gin.Context, sys *appConn) { var req pb.ApiGetSolutionProviderReq _ = c.ShouldBindJSON(&req) model, err := dvSolutionProvider(sys, req.Id) if err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } writeOK(c, &pb.ApiGetSolutionProviderResp{Provider: model}) } func (this *serverComp) getProducts(c *gin.Context, sys *appConn) { 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) 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 } writeOK(c, &pb.ApiGetProductResp{Product: model}) } func (this *serverComp) getProductVersions(c *gin.Context, sys *appConn) { var req pb.ApiGetProductVersionsReq _ = c.ShouldBindJSON(&req) 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 } writeOK(c, &pb.ApiGetProductVersionsResp{Versions: models}) } func (this *serverComp) getAuthCodes(c *gin.Context, sys *appConn) { var req pb.ApiGetAuthCodesReq _ = c.ShouldBindJSON(&req) // productid == 0 = 「全部产品」视图(后台 MAC查询 的默认状态)。 // device_mac 合表(2026-09-04)之前必须带 pid 才知道查哪张分表,现在是全局一张表, // dvAuthCodesPaged → comm.ScopeToProduct(0,…) 会原样放行不拼 productid 条件。 // ⚠️ 这里不再拦,意味着调用方能一次拉到全部产品的设备;按产品收敛的责任落在 // 前端下拉(产品受限的运营账号不给「全部产品」这一项)与页面权限上。 var ( conds = make([]string, 0, 7) args []interface{} ) if req.Code != "" { conds = append(conds, "code=?") args = append(args, req.Code) } if req.Devicemac != "" { conds = append(conds, "devicemac=?") args = append(args, req.Devicemac) } if req.Batchno != "" { conds = append(conds, "batchno=?") args = append(args, req.Batchno) } if cid := effectiveChannelId(c, req.Channelid); cid != "" { conds = append(conds, "channelid=?") args = append(args, cid) } // status: -1 仅未使用, 1 已使用, 2 已过期, 0 不筛选 switch req.Status { case -1: conds = append(conds, "status=?") args = append(args, 0) case 1, 2: conds = append(conds, "status=?") args = append(args, req.Status) } if req.Uid != "" { conds = append(conds, "uid=?") args = append(args, req.Uid) } query := "" if len(conds) > 0 { query = strings.Join(conds, " and ") } sortBy := strings.ToLower(strings.TrimSpace(req.Sortby)) if sortBy != "usedtime" && sortBy != "createtime" { sortBy = "createtime" } order := strings.ToLower(strings.TrimSpace(req.Order)) if order != "asc" { order = "desc" } limit := int(req.Limit) if limit <= 0 { limit = 200 } if limit > 2000 { limit = 2000 } offset := int(req.Offset) if offset < 0 { offset = 0 } orderClause := fmt.Sprintf("%s %s", sortBy, order) models, total, err := dvAuthCodesPaged(sys, req.Productid, query, orderClause, limit, offset, args...) if err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } writeOK(c, &pb.ApiGetAuthCodesResp{Devices: models, Total: total}) } func (this *serverComp) getAuthCode(c *gin.Context, sys *appConn) { var req pb.ApiGetAuthCodeReq _ = c.ShouldBindJSON(&req) model, err := dvAuthCode(sys, req.Productid, req.Devicemac) if err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } writeOK(c, &pb.ApiGetAuthCodeResp{Device: model}) } func (this *serverComp) getProductionBatches(c *gin.Context, sys *appConn) { var req pb.ApiGetProductionBatchesReq _ = c.ShouldBindJSON(&req) query := "1=1" args := make([]interface{}, 0) if req.Productid > 0 { query += " AND productid = ?" args = append(args, req.Productid) } if cid := effectiveChannelId(c, req.Channelid); cid != "" { query += " AND channelid = ?" args = append(args, cid) } if req.Start > 0 { query += " AND ts >= ?" args = append(args, req.Start) } if req.End > 0 { query += " AND ts <= ?" args = append(args, req.End) } limit := int(req.Limit) if limit <= 0 || limit > 2000 { limit = 500 } query += " ORDER BY ts DESC LIMIT ?" args = append(args, limit) notes, err := dvProductionBatches(sys, query, args...) if err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } var totalDevices int64 for _, n := range notes { totalDevices += int64(n.Devicenum) } writeOK(c, &pb.ApiGetProductionBatchesResp{ Notes: notes, TotalBatch: int64(len(notes)), TotalDevices: totalDevices, }) } func (this *serverComp) resetLicenseStatus(c *gin.Context, sys *appConn) { var req pb.ApiResetLicenseStatusReq _ = c.ShouldBindJSON(&req) // 走完整重置(删 userdevice + 清 license + 扣激活统计),与批量复用同一 resetOneLicense。 if err := resetOneLicense(sys, req.Productid, req.License); err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } writeOK(c, &pb.ApiResetLicenseStatusResp{}) } // ============================ 用户查询 ============================ // getUserInfo 用户查询:前端选定「应用 + 区域」即定位到唯一注册项(X-App-Id), // 据此经 registry.getServiceDB 取该部署的业务库(MySQL)连接,按 uid 直查 user 表返回。 // 业务数据在各应用自己的业务库,故不走 console 主库(supabase)。 func (this *serverComp) getUserInfo(c *gin.Context) { 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 } var req struct { Uid string `json:"uid"` } _ = c.ShouldBindJSON(&req) uid := strings.TrimSpace(req.Uid) if uid == "" { writeErr(c, pb.ErrorCode_ReqParameterError, "请输入要查询的用户UID") return } user := &pb.DBUser{} if err := conn.FindOne(comm.TableUser, user, "uid=?", uid); err != nil { if errors.Is(err, mysql.ErrNoDocuments) { writeErr(c, pb.ErrorCode_UserSessionNobeing, "未找到该用户") return } writeErr(c, pb.ErrorCode_DBError, err.Error()) return } user.Password = "" // 不下发密码 // 附上实名认证明细(同库的 useridverify 表)。身份证号只有掩码,全文本就不落库。 // 同证件账号数:本次选的口径是「一证可绑多号、不拦,但后台可查」,所以这里按号码 // 指纹回查同库还有几个账号用了同一张证件,>1 即值得运营看一眼。 out := map[string]interface{}{} if b, err := json.Marshal(user); err == nil { _ = json.Unmarshal(b, &out) } iv := &pb.DBUserIdVerify{} if err := conn.FindOne(comm.TableUserIdVerify, iv, "uid=?", uid); err == nil { out["idverify_realname"] = iv.Realname out["idverify_idcardmask"] = iv.Idcardmask out["idverify_provider"] = iv.Provider if iv.Idcardhash != "" { same := make([]*pb.DBUserIdVerify, 0) if err := conn.Find(comm.TableUserIdVerify, &same, "idcardhash=? AND verifytime>0", iv.Idcardhash); err == nil { out["idverify_samecardusers"] = len(same) } } } else if !errors.Is(err, mysql.ErrNoDocuments) { // 明细查不到不影响主查询,记日志即可。 this.module.Errorln("getUserInfo: 查实名明细失败:", err) } // 会员与算力(2026-09):user.vipexptime 只记自购;设备赋予的 VIP 与算力挂在 device_mac 行上, // 这里按当前绑定关系跨库拼出「有效 VIP」与各设备的权益快照。 for k, v := range userComputeSummary(conn, user) { out[k] = v } writeOK(c, out) } // giftUserResource 赠送资源点给终端用户:直连选中应用(X-App-Id)业务库,给 user 加 // VIP天/翻译分钟/会议分钟,并写 useruselog(AdminGive) 流水。 // - 超管 / 管理员:无消耗,直接发放; // - 代理:先原子扣减自己的资源点余额,不足即拒绝;写库失败回滚已扣余额; // - 运营:无权赠送。 func (this *serverComp) giftUserResource(c *gin.Context) { identity := currentIdentity(c) if identity != pb.Identity_Admin && identity != pb.Identity_Manager && identity != pb.Identity_Agent { writeErr(c, pb.ErrorCode_InsufficientPermissions, "当前角色无权赠送资源点") return } var req struct { Uid string `json:"uid"` Vipday int64 `json:"vipday"` Trademin int64 `json:"trademin"` Meetmin int64 `json:"meetmin"` } _ = c.ShouldBindJSON(&req) uid := strings.TrimSpace(req.Uid) if uid == "" { writeErr(c, pb.ErrorCode_ReqParameterError, "请输入要赠送的用户UID") return } if req.Vipday < 0 || req.Trademin < 0 || req.Meetmin < 0 { writeErr(c, pb.ErrorCode_ReqParameterError, "赠送数量不能为负") return } if req.Vipday == 0 && req.Trademin == 0 && req.Meetmin == 0 { writeErr(c, pb.ErrorCode_ReqParameterError, "请至少填写一项赠送数量") return } 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 } user := &pb.DBUser{} if err := conn.FindOne(comm.TableUser, user, "uid=?", uid); err != nil { if errors.Is(err, mysql.ErrNoDocuments) { writeErr(c, pb.ErrorCode_UserSessionNobeing, "未找到该用户") return } writeErr(c, pb.ErrorCode_DBError, err.Error()) return } now := time.Now().Unix() accId := currentAccountId(c) // 代理:先原子扣减自己的资源点余额(CAS 防超发),不足即拒绝。 if identity == pb.Identity_Agent { ok, e := this.module.model.agentDeductBalance(accId, req.Vipday, req.Trademin, req.Meetmin, now) if e != nil { writeErr(c, pb.ErrorCode_DBError, e.Error()) return } if !ok { writeErr(c, pb.ErrorCode_InsufficientPermissions, "您的资源点余额不足") return } } // 应用到用户余额(VIP 过期时间:已过期/未开通则从现在起算,否则顺延;与公码奖励口径一致)。 addTradeSec := req.Trademin * 60 addMeetSec := req.Meetmin * 60 if req.Vipday > 0 { addVipSec := req.Vipday * 24 * 60 * 60 if user.Vipexptime == 0 || user.Vipexptime < now { user.Vipexptime = now + addVipSec } else { user.Vipexptime += addVipSec } } // 2026-09 起三个额度桶停用:翻译分 / 会议分按当前换算系数折成算力发到用户余额。 addCompute := comm.LegacyBucketsToCompute(comm.LoadComputeRates(), addTradeSec, addMeetSec, 0) user.Computebalance += addCompute if err := conn.Save(comm.TableUser, user); err != nil { // 代理:写库失败回滚已扣余额,避免用户没到账却扣了代理。 if identity == pb.Identity_Agent { _, _ = this.module.model.agentAddBalance(accId, req.Vipday, req.Trademin, req.Meetmin, now) } writeErr(c, pb.ErrorCode_DBError, "赠送失败: "+err.Error()) return } // 流水(业务库 useruselog)。已到账,失败不回滚,仅吞掉。 _ = conn.Insert(comm.TableUserUseLog, &pb.DBUserUseLog{ Uid: uid, Ts: now, Logtype: pb.UserLogType_AdminGive, Addvipday: req.Vipday, Compute: addCompute, Extra: fmt.Sprintf("console gift by identity=%d account=%d", int32(identity), accId), }) resp := gin.H{ "uid": uid, "vipexptime": user.Vipexptime, "computebalance": user.Computebalance, "addcompute": addCompute, } // 代理:回传扣减后的最新余额,前端据此刷新侧边栏。 if identity == pb.Identity_Agent { if acc, e := this.module.model.getAccount(accId); e == nil && acc != nil { resp["balance_vipday"] = acc.BalanceVipday resp["balance_trademin"] = acc.BalanceTrademin resp["balance_meetmin"] = acc.BalanceMeetmin } } writeOK(c, resp) } // accountMe 返回当前登录账号的展示信息(侧边栏底部用):用户名、角色、代理资源点余额。 func (this *serverComp) accountMe(c *gin.Context) { username, _ := c.Get("username") resp := gin.H{"username": username, "identity": currentIdentity(c)} if accId := currentAccountId(c); accId > 0 { if acc, err := this.module.model.getAccount(accId); err == nil && acc != nil { resp["balance_vipday"] = acc.BalanceVipday resp["balance_trademin"] = acc.BalanceTrademin resp["balance_meetmin"] = acc.BalanceMeetmin resp["channelid"] = acc.Channelid } } writeOK(c, resp) } // accountsTopup 管理员给代理账号充值 / 调整资源点余额(按类增量,可正可负;任一类不能调成负数)。 func (this *serverComp) accountsTopup(c *gin.Context) { var req struct { AccountId uint32 `json:"account_id"` Vipday int64 `json:"vipday"` Trademin int64 `json:"trademin"` Meetmin int64 `json:"meetmin"` } if err := c.ShouldBindJSON(&req); err != nil { writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) return } if req.AccountId == 0 { writeErr(c, pb.ErrorCode_ReqParameterError, "缺少账号id") return } if req.Vipday == 0 && req.Trademin == 0 && req.Meetmin == 0 { writeErr(c, pb.ErrorCode_ReqParameterError, "请至少填写一项调整数量") return } acc, err := this.module.model.getAccount(req.AccountId) if err != nil || acc == nil || acc.Id == 0 { writeErr(c, pb.ErrorCode_DBError, "账号不存在") return } if acc.Identity != pb.Identity_Agent { writeErr(c, pb.ErrorCode_ReqParameterError, "只能给代理账号充值资源点") return } ok, e := this.module.model.agentAddBalance(req.AccountId, req.Vipday, req.Trademin, req.Meetmin, time.Now().Unix()) if e != nil { writeErr(c, pb.ErrorCode_DBError, e.Error()) return } if !ok { writeErr(c, pb.ErrorCode_ReqParameterError, "调整后余额不能为负") return } acc, _ = this.module.model.getAccount(req.AccountId) writeOK(c, gin.H{ "balance_vipday": acc.BalanceVipday, "balance_trademin": acc.BalanceTrademin, "balance_meetmin": acc.BalanceMeetmin, }) } // ============================ 注册表 CRUD ============================ func (this *serverComp) appsList(c *gin.Context) { models, err := this.module.model.listApps() if err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } writeOK(c, models) } func (this *serverComp) appsAdd(c *gin.Context) { var model AppRegistry if err := c.ShouldBindJSON(&model); err != nil { writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) return } if msg := checkDeployment(&model); msg != "" { writeErr(c, pb.ErrorCode_ReqParameterError, msg) return } model.Id = 0 now := time.Now().Unix() model.Createtime = now model.Updatetime = now if err := this.module.model.addApp(&model); err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } writeOK(c, &model) } func (this *serverComp) appsUpdate(c *gin.Context) { var model AppRegistry if err := c.ShouldBindJSON(&model); err != nil { writeErr(c, pb.ErrorCode_ReqParameterError, err.Error()) return } if model.Id == 0 { writeErr(c, pb.ErrorCode_ReqParameterError, "id 必填") return } if msg := checkDeployment(&model); msg != "" { writeErr(c, pb.ErrorCode_ReqParameterError, msg) return } model.Updatetime = time.Now().Unix() if err := this.module.model.saveApp(&model); err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } this.module.registry.invalidate(model.Id) // 配置变了,失效连接缓存 writeOK(c, &model) } func (this *serverComp) appsDel(c *gin.Context) { var req struct { Id uint32 `json:"id"` } if err := c.ShouldBindJSON(&req); err != nil || req.Id == 0 { writeErr(c, pb.ErrorCode_ReqParameterError, "id 必填") return } if err := this.module.model.delApp(req.Id); err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } this.module.registry.invalidate(req.Id) writeOK(c, gin.H{"id": req.Id}) } // appsAssign 轻量「归类」:只把某部署行的 app_name 改成目标应用(空=移回未分组),不做连通性复检。 // 用于把历史部署行归到应用名下,避免走完整表单+测试连接。 func (this *serverComp) appsAssign(c *gin.Context) { var req struct { Id uint32 `json:"id"` AppName string `json:"app_name"` } if err := c.ShouldBindJSON(&req); err != nil || req.Id == 0 { writeErr(c, pb.ErrorCode_ReqParameterError, "id 必填") return } model, err := this.module.model.getApp(req.Id) if err != nil { writeErr(c, pb.ErrorCode_DBError, "部署不存在: "+err.Error()) return } model.AppName = req.AppName model.Updatetime = time.Now().Unix() if err := this.module.model.saveApp(model); err != nil { writeErr(c, pb.ErrorCode_DBError, err.Error()) return } writeOK(c, model) } // ============================ 配置事件下发 ============================ func (this *serverComp) configNotify(c *gin.Context) { var req struct { AppId uint32 `json:"app_id"` Type string `json:"type"` Action string `json:"action"` Payload json.RawMessage `json:"payload"` } if err := c.ShouldBindJSON(&req); err != nil || req.AppId == 0 { writeErr(c, pb.ErrorCode_ReqParameterError, "app_id 必填") return } app, err := this.module.model.getApp(req.AppId) if err != nil { writeErr(c, pb.ErrorCode_DBError, "应用未注册: "+err.Error()) return } ev := &ConfigEvent{ AppId: req.AppId, Type: req.Type, Action: req.Action, Payload: req.Payload, Ts: time.Now().Unix(), } if err := publishConfigEvent(app, ev); err != nil { writeErr(c, pb.ErrorCode_SystemError, "事件发布失败: "+err.Error()) return } writeOK(c, gin.H{"subject": eventSubject(app)}) }