package gateway import ( "encoding/json" "fmt" "io" "mime/multipart" "net/http" "os" "path/filepath" "sync" "time" "yunyan/comm" "yunyan/lego/core" "yunyan/lego/core/cbase" "yunyan/lego/utils/crypto/aes" _ "yunyan/swagger" "yunyan/utils" "yunyan/lego/sys/log" "yunyan/lego/sys/pools" "yunyan/pb" "github.com/gin-gonic/gin" swaggerfiles "github.com/swaggo/files" ginSwagger "github.com/swaggo/gin-swagger" ) // writeReply 统一处理 RPC 响应写回 HTTP: // 1. 根据业务服务上报的 reply.Encrypted 字段判断是否加密 // 2. 调用 AES-CBC 对整个 body 加密并 base64,改写 reply.Body // 3. 密文是 base64 文本而非 JSON,把 Content-Type 改成 text/plain,避免客户端/中间层按 application/json 自动解析密文而报错 // 4. 设置 X-Encrypted: 1 响应头,便于前端协议层识别并解密 func (this *WSServiceComp) writeReply(c *gin.Context, reply *pb.Rpc_GatewayHttpRouteResp) { if reply.Encrypted && this.options.EncryptKey != "" && len(reply.Body) > 0 { cipher := aes.AesEncryptCBC(string(reply.Body), this.options.EncryptKey) reply.Body = []byte(cipher) reply.ContentType = "text/plain; charset=utf-8" c.Header("X-Encrypted", "1") } c.Data(http.StatusOK, reply.ContentType, reply.Body) } type WSServiceComp struct { cbase.ModuleCompBase options *Options module *Gateway // 网关 gin *gin.Engine tslock sync.Mutex // 锁 userTsMap map[string]int64 //用户ts映射 } // Init websocket服务初始化 func (this *WSServiceComp) Init(service core.IService, module core.IModule, comp core.IModuleComp, options core.IModuleOptions) (err error) { err = this.ModuleCompBase.Init(service, module, comp, options) this.options = options.(*Options) this.module = module.(*Gateway) this.userTsMap = make(map[string]int64) this.gin = gin.Default() this.gin.MaxMultipartMemory = 500 << 20 // 设置上传文件大小为32M this.gin.Use(cors()) //设置允许跨域 this.gin.Static("/avatar", "./avatar") this.gin.Static("/console", "./console") this.gin.Static("/docs", "./docs") // 会议纪要分享落地页(share.ymaikj.com 反代到本端口)。catch-all,理由见 share_page.go。 this.gin.GET("/share/*any", this.sharePage) this.gin.Any("/api/:param1/:param2", this.api) this.gin.Any("/web/:param1/:param2", this.web) this.gin.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerfiles.Handler)) go this.gin.Run(fmt.Sprintf(":%d", this.options.ListenPort)) return } // 后台 func (this *WSServiceComp) web(c *gin.Context) { var ( param1, param2 string body []byte params string tokenString string uid string file *multipart.FileHeader meta map[string]string = make(map[string]string) args *pb.Rpc_GatewayHttpRouteReq = pools.GetForType(httpReqTyoe).(*pb.Rpc_GatewayHttpRouteReq) reply *pb.Rpc_GatewayHttpRouteResp = pools.GetForType(httpRespTyoe).(*pb.Rpc_GatewayHttpRouteResp) err error ) defer func() { pools.PutForType(httpReqTyoe, args) pools.PutForType(httpRespTyoe, reply) }() meta[comm.SessionMeta_IP] = c.ClientIP() meta["route"] = "web" // 标记为后台管理请求 param1 = c.Param("param1") param2 = c.Param("param2") tokenString = c.Request.Header.Get("Authorization") if tokenString != "" { if parsedClaims, err := parseToken(tokenString, []byte(this.options.TokenKey)); err == nil { uid = parsedClaims.ID meta[comm.SessionMeta_UserId] = uid meta["identity"] = parsedClaims.Subject // 身份: 1=超管 2=管理员 3=代理 } } log.Debugln("ContentType:", c.ContentType()) switch c.Request.Method { case http.MethodGet: params := make(map[string]interface{}) for key, values := range c.Request.URL.Query() { if len(values) > 0 { params[key] = values[0] } } body, err = json.Marshal(params) if err != nil { c.JSON(http.StatusOK, &comm.HttpResult{ Code: pb.ErrorCode_ReqParameterError, Message: err.Error(), }) this.module.Errorln(err) return } case http.MethodPost: if c.ContentType() == "multipart/form-data" { params := make(map[string]interface{}) if form, err := c.MultipartForm(); err == nil { // 遍历表单参数 for key, values := range form.Value { for _, value := range values { // fmt.Printf("Key: %s, Value: %s\n", key, value) params[key] = value } } } body, err = json.Marshal(params) if err != nil { c.JSON(http.StatusOK, &comm.HttpResult{ Code: pb.ErrorCode_ReqParameterError, Message: err.Error(), }) this.module.Errorln(err) return } } else { if body, err = c.GetRawData(); err != nil { c.JSON(http.StatusOK, &comm.HttpResult{ Code: pb.ErrorCode_ReqParameterError, Message: err.Error(), }) this.module.Errorln(err) return } } } args.MsgName = param2 args.Message = body args.Meta = meta stime := time.Now() if args.MsgName == "" { this.module.Error("[UserResponse]", log.Field{Key: "serviceTag", Value: this.module.service.GetId()}, log.Field{Key: "servicePath", Value: param1}, log.Field{Key: "req", Value: string(body)}, ) return } // console 运维接口(重载/重置业务配置)只允许经 /api/ 入口携带 HMAC 签名调用。 // 本入口(/web/)只校验"有没有用户登录"、不验签,若放行则任何登录用户都能重置生产配置—— // 而业务侧的权限拦截器对这些路由是豁免的(其身份由网关验签背书),故必须在此堵死。 if comm.IsConsoleOnlyRoute(args.MsgName) { this.module.Error("[UserResponse] console 运维接口不允许从 /web 入口调用", log.Field{Key: "msg", Value: args.MsgName}, log.Field{Key: "ip", Value: c.ClientIP()}, ) c.JSON(http.StatusOK, &comm.HttpResult{ Code: pb.ErrorCode_ReqParameterError, Message: "forbidden", }) return } if uid == "" && (args.MsgName != "api_login" && args.MsgName != "api_getsiteinfo") { //登录和验证 放过去 this.module.Error("[ApiResponse]", log.Field{Key: "req", Value: params}, log.Field{Key: "err", Value: "no login"}, ) c.JSON(http.StatusOK, &comm.HttpResult{ Code: pb.ErrorCode_NoLogin, Message: pb.ErrorCode_NoLogin.String(), }) return } if c.ContentType() == "multipart/form-data" { //检查是否有文件上传 file, err = c.FormFile("file") if err != nil { if err != http.ErrMissingFile { c.JSON(http.StatusBadRequest, gin.H{"error": "文件读取失败: " + err.Error()}) return } err = nil } else { //临时处理 // 打开上传的文件 src, err := file.Open() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "无法打开上传文件"}) return } defer src.Close() // 构造临时文件路径 tempDir := os.TempDir() timestamp := time.Now().UnixNano() tempPath := filepath.Join(tempDir, fmt.Sprintf("upload_%d_%s", timestamp, file.Filename)) // 创建目标文件 dst, err := os.Create(tempPath) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "创建临时文件失败"}) return } defer dst.Close() // 拷贝内容到临时文件 if _, err := io.Copy(dst, src); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "保存临时文件失败"}) return } args.Meta["file_name"] = file.Filename args.Meta["file_path"] = tempPath //传递临时文件地址 // 发送处理通知 } } if err = this.module.Service().RpcCall(c, param1, string(comm.Rpc_GatewayHttpRoute), args, reply); err != nil { this.module.Error("[ApiResponse]", log.Field{Key: "ip", Value: c.ClientIP()}, log.Field{Key: "req", Value: params}, log.Field{Key: "err", Value: err.Error()}, ) c.JSON(http.StatusOK, &comm.HttpResult{ Code: pb.ErrorCode_SystemError, Message: err.Error(), }) return } this.module.Debug("[API]", log.Field{Key: "t", Value: time.Since(stime).Milliseconds()}, log.Field{Key: "ip", Value: c.ClientIP()}, log.Field{Key: "req", Value: body}, log.Field{Key: "reply", Value: reply.String()}, ) this.writeReply(c, reply) } // 业务 func (this *WSServiceComp) api(c *gin.Context) { var ( param1, param2 string body []byte signString, tsString, tokenString string uid string ts int64 meta map[string]string = make(map[string]string) file *multipart.FileHeader args *pb.Rpc_GatewayHttpRouteReq = pools.GetForType(httpReqTyoe).(*pb.Rpc_GatewayHttpRouteReq) reply *pb.Rpc_GatewayHttpRouteResp = pools.GetForType(httpRespTyoe).(*pb.Rpc_GatewayHttpRouteResp) err error ) defer func() { pools.PutForType(httpReqTyoe, args) pools.PutForType(httpRespTyoe, reply) }() meta[comm.SessionMeta_IP] = c.ClientIP() param1 = c.Param("param1") param2 = c.Param("param2") if this.options.IsSign { tsString = c.Request.Header.Get("Ts") signString = c.Request.Header.Get("Sign") if tsString == "" || signString == "" { c.JSON(http.StatusOK, &comm.HttpResult{ Code: pb.ErrorCode_ReqParameterError, Message: "Sign is empty", }) this.module.Errorln("Sign is empty") return } // 验证签名 if signString != ParamSign(this.options.SignKey, tsString) { c.JSON(http.StatusOK, &comm.HttpResult{ Code: pb.ErrorCode_ReqParameterError, Message: "Sign error", }) this.module.Errorln("Sign error") return } } tokenString = c.Request.Header.Get("Authorization") if tokenString != "" { if parsedClaims, err := parseToken(tokenString, []byte(this.options.TokenKey)); err == nil { uid = parsedClaims.ID meta[comm.SessionMeta_UserId] = uid if this.options.IsSign { //防止消息被复用 ts = utils.ToInt64(tsString) this.tslock.Lock() if ts <= this.userTsMap[uid] { this.module.Error("[ApiResponse] Ts error", log.Field{Key: "ts", Value: ts}, log.Field{Key: "lastts", Value: this.userTsMap[uid]}, ) c.JSON(http.StatusOK, &comm.HttpResult{ Code: pb.ErrorCode_ReqParameterError, Message: "Ts error", }) this.module.Errorln("Ts error") this.tslock.Unlock() return } this.userTsMap[uid] = ts this.tslock.Unlock() } } } if c.Request.Method == http.MethodGet { params := make(map[string]interface{}) for key, values := range c.Request.URL.Query() { if len(values) > 0 { params[key] = values[0] } } body, err = json.Marshal(params) if err != nil { c.JSON(http.StatusOK, &comm.HttpResult{ Code: pb.ErrorCode_ReqParameterError, Message: err.Error(), }) this.module.Errorln(err) return } } else if c.Request.Method == http.MethodPost { if c.ContentType() == "multipart/form-data" { params := make(map[string]interface{}) if form, err := c.MultipartForm(); err == nil { // 遍历表单参数 for key, values := range form.Value { for _, value := range values { // fmt.Printf("Key: %s, Value: %s\n", key, value) params[key] = value } } } body, err = json.Marshal(params) if err != nil { c.JSON(http.StatusOK, &comm.HttpResult{ Code: pb.ErrorCode_ReqParameterError, Message: err.Error(), }) this.module.Errorln(err) return } } else if c.ContentType() == "application/x-www-form-urlencoded" { params := make(map[string]interface{}) if err = c.Request.ParseForm(); err == nil { for key, values := range c.Request.PostForm { if len(values) > 0 { params[key] = values[0] } } } body, err = json.Marshal(params) if err != nil { c.JSON(http.StatusOK, &comm.HttpResult{ Code: pb.ErrorCode_ReqParameterError, Message: err.Error(), }) this.module.Errorln(err) return } } else { if body, err = c.GetRawData(); err != nil { c.JSON(http.StatusOK, &comm.HttpResult{ Code: pb.ErrorCode_ReqParameterError, Message: err.Error(), }) this.module.Errorln(err) return } } } args.MsgName = param2 args.Message = body args.Meta = meta stime := time.Now() if args.MsgName == "" { this.module.Error("[UserResponse]", log.Field{Key: "serviceTag", Value: this.module.service.GetId()}, log.Field{Key: "servicePath", Value: param1}, log.Field{Key: "req", Value: string(body)}, ) return } // console 运维接口(如重载业务配置):不走用户登录,改用 ${FIELD_ENCRYPT_KEY} 的 HMAC 签名放行。 // 必须在登录判定之前处理——它本就没有用户身份;签名不合法则直接拒绝,不给任何降级路径。 if comm.IsConsoleOnlyRoute(args.MsgName) { // 参数(如模块名)走请求头并已并入签名,故不必为这类接口新增 pb 字段; // 验签通过后搬进 Meta,业务侧 handler 用 session.GetMateToString 取。 consoleArg := c.Request.Header.Get(comm.ConsoleArgHeader) if err := comm.VerifyConsoleCall( os.Getenv("FIELD_ENCRYPT_KEY"), c.Request.Header.Get(comm.ConsoleTsHeader), c.Request.Header.Get(comm.ConsoleSignHeader), args.MsgName, consoleArg, time.Now(), ); err != nil { this.module.Error("[ApiResponse] console 运维接口验签失败", log.Field{Key: "msg", Value: args.MsgName}, log.Field{Key: "ip", Value: c.ClientIP()}, log.Field{Key: "err", Value: err.Error()}, ) c.JSON(http.StatusOK, &comm.HttpResult{ Code: pb.ErrorCode_ReqParameterError, Message: "console sign invalid", }) return } args.Meta[comm.SessionMeta_ConsoleArg] = consoleArg } else if isWhite := isInWhiteList(args.MsgName, this.options.WhiteList); uid == "" && !isWhite { this.module.Error("[ApiResponse]", log.Field{Key: "req", Value: string(body)}, log.Field{Key: "err", Value: "no login"}, ) c.JSON(http.StatusOK, &comm.HttpResult{ Code: pb.ErrorCode_NoLogin, Message: pb.ErrorCode_NoLogin.String(), }) return } if c.ContentType() == "multipart/form-data" { //检查是否有文件上传 file, err = c.FormFile("file") if err != nil { if err != http.ErrMissingFile { c.JSON(http.StatusBadRequest, gin.H{"error": "文件读取失败: " + err.Error()}) return } err = nil } else { //临时处理 // 打开上传的文件 src, err := file.Open() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "无法打开上传文件"}) return } defer src.Close() // 构造临时文件路径 tempDir := os.TempDir() timestamp := time.Now().UnixNano() tempPath := filepath.Join(tempDir, fmt.Sprintf("upload_%d_%s", timestamp, file.Filename)) // 创建目标文件 dst, err := os.Create(tempPath) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "创建临时文件失败"}) return } defer dst.Close() // 拷贝内容到临时文件 if _, err := io.Copy(dst, src); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "保存临时文件失败"}) return } args.Meta["file_name"] = file.Filename args.Meta["file_path"] = tempPath //传递临时文件地址 // 发送处理通知 } } if err = this.module.Service().RpcCall(c, param1, string(comm.Rpc_GatewayHttpRoute), args, reply); err != nil { this.module.Error("[ApiResponse]", log.Field{Key: "ip", Value: c.ClientIP()}, log.Field{Key: "req", Value: string(body)}, log.Field{Key: "err", Value: err.Error()}, ) c.JSON(http.StatusOK, &comm.HttpResult{ Code: pb.ErrorCode_SystemError, Message: err.Error(), }) return } this.module.Debug("[ApiResponse]", log.Field{Key: "name", Value: args.MsgName}, log.Field{Key: "t", Value: time.Since(stime).Milliseconds()}, log.Field{Key: "ip", Value: c.ClientIP()}, log.Field{Key: "req", Value: string(body)}, // log.Field{Key: "reply", Value: reply.String()}, ) this.writeReply(c, reply) }