You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1299 lines
45 KiB
1299 lines
45 KiB
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_<method>。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)的初始值
|
|
// 业务库 config 表的裸编辑接口(appcfg/*) 2026-09-14 已删:该表只剩服务端自用的算力/运营参数
|
|
// (走 compute/* 接口),客户端要读的参数都进了 svc_config(见 migrate_appparams.go)。
|
|
|
|
// 应用商品配置(内购商品,按 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
|
|
// 会议配置 · 服务编排:会议转写(kind=1)/会议总结(kind=3) 各选哪些第三方服务。
|
|
// 只引用 svc_config 里的服务 id,不存凭据;按 app_name 隔离(没有全局默认层)。
|
|
// 2026-09-12 随「会议记录服务」页一起删过,2026-09-17 并进「会议配置」页恢复。
|
|
case "api_getechoorch":
|
|
this.getEchoOrch(c)
|
|
return
|
|
case "api_saveechoorch":
|
|
this.saveEchoOrch(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 预设,新增服务时选模板自动带出字段。
|
|
// 第三方服务类别(svc_category):后台可增删改;内置 11 个 id 不可改不可删,见 model_svccategory.go。
|
|
case "api_getsvccategories":
|
|
this.getSvcCategories(c)
|
|
return
|
|
case "api_savesvccategory":
|
|
this.saveSvcCategory(c)
|
|
return
|
|
case "api_delsvccategory":
|
|
this.delSvcCategory(c)
|
|
return
|
|
// 跨应用复制服务(含区域分叉):各应用互不干涉之后,「两个应用用同一套凭据」靠它,密文原样搬不经前端。
|
|
case "api_copysvcconfig":
|
|
this.copySvcConfig(c)
|
|
return
|
|
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
|
|
// 下载落地页:读 / 发布分发桶上那份静态 download.html(「版本与分发」页的「下载页」一行)。
|
|
case "api_getdistpage":
|
|
this.getDistPage(c)
|
|
return
|
|
case "api_savedistpage":
|
|
this.saveDistPage(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
|
|
// 用户反馈:App「我的 → 用户反馈」写入业务库 userfeedback,按选中应用查询。
|
|
case "api_getuserfeedbacks":
|
|
this.getUserFeedbacks(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) 流水。
|
|
// - 超管 / 管理员:无消耗,直接发放;
|
|
// - 代理:先原子扣减自己的资源点余额,不足即拒绝;写库失败回滚已扣余额;
|
|
// - 运营:无权赠送。
|
|
//
|
|
// **后台只管控两个量:VIP 天数与算力值。** 翻译 / 会议 / AI 对话的分钟数、字符数不在这里填 ——
|
|
// 它们是消耗侧的原始量,由 comm.ApplyComputeUsage 在上报时按后台配的系数折成算力再扣,
|
|
// 后台没有必要、也不应该按功能分桶发放(那正是 2026-09 停用的旧三桶模型)。
|
|
//
|
|
// trademin / meetmin 仅为**入参兼容**保留(老 admin 容器、历史脚本),非零时按当前系数折成算力
|
|
// 并进同一个 addcompute;新前端只送 compute。
|
|
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"`
|
|
Compute int64 `json:"compute"`
|
|
// 兼容入参:老前端按功能分桶送分钟数,服务端折成算力。
|
|
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.Compute < 0 || req.Trademin < 0 || req.Meetmin < 0 {
|
|
writeErr(c, pb.ErrorCode_ReqParameterError, "赠送数量不能为负")
|
|
return
|
|
}
|
|
if req.Vipday == 0 && req.Compute == 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 防超发),不足即拒绝。
|
|
//
|
|
// ⚠️ 代理余额只有 balance_vipday/trademin/meetmin 三列,**没有算力列**,所以代理走不了
|
|
// compute 这条新路。这段实际已不可达:品牌商(identity=3)于 2026-09-12 整体下线,
|
|
// migrateBrandAccountsToOperator 在 console 启动时把存量全部改成 identity=4 且禁用。
|
|
if identity == pb.Identity_Agent {
|
|
if req.Compute > 0 {
|
|
writeErr(c, pb.ErrorCode_InsufficientPermissions, "代理账号没有算力余额,无法赠送算力")
|
|
return
|
|
}
|
|
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 过期时间:已过期/未开通则从现在起算,否则顺延;与公码奖励口径一致)。
|
|
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
|
|
}
|
|
}
|
|
// 算力:新前端直接给算力值;老前端给的分钟数按**本应用**当前系数折算后并进同一个数。
|
|
//
|
|
// ⚠️ 系数必须用 computeRatesOf(conn) 读,不能用 comm.LoadComputeRates()——后者走包级 mysql
|
|
// 连接,console 从不初始化它,一调就 panic → HTTP 500。理由见 api_compute.go 的 computeRatesOf。
|
|
addCompute := req.Compute
|
|
if req.Trademin > 0 || req.Meetmin > 0 {
|
|
addCompute += comm.LegacyBucketsToCompute(computeRatesOf(conn), req.Trademin*60, req.Meetmin*60, 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)})
|
|
}
|
|
|