补齐 HTTP 层:实现用户模块、路由与中间件接线、COS 上传及优雅启动
- 新增用户与上传错误码文件及 Recovery 中间件 - 修复 router.go 导入与中间件链,注册用户路由并补 handler 错误码导入 - 补充用户状态常量与 UserGetByWechatOpenID 查询,修订刷新令牌 dto - 移除 upload 包 OTel 调用,新增 FileUploadCos 上传封装 - 实现 UserService 微信登录/刷新令牌/我的信息/改资料/上传头像 - 裁剪 main.go 配置段并移除 OTel 桥接,补启动与优雅退出 - 新增 configs 示例配置、global 配置结构及 go.mod 依赖
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/pkg/errcode"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Response 统一响应包装
|
||||
type Response struct {
|
||||
Ctx *gin.Context
|
||||
}
|
||||
|
||||
func NewResponse(c *gin.Context) *Response {
|
||||
return &Response{Ctx: c}
|
||||
}
|
||||
|
||||
// ToResponse 成功响应
|
||||
func (r *Response) ToResponse(data interface{}) {
|
||||
if data == nil {
|
||||
data = gin.H{}
|
||||
}
|
||||
r.Ctx.JSON(http.StatusOK, gin.H{
|
||||
"code": errcode.Success.Code(),
|
||||
"msg": errcode.Success.Msg(),
|
||||
"data": data,
|
||||
})
|
||||
}
|
||||
|
||||
// ToErrorResponse 错误响应
|
||||
func (r *Response) ToErrorResponse(err *errcode.Error) {
|
||||
response := gin.H{
|
||||
"code": err.Code(),
|
||||
"msg": err.Msg(),
|
||||
}
|
||||
if details := err.Details(); len(details) > 0 {
|
||||
response["details"] = details
|
||||
}
|
||||
r.Ctx.JSON(err.StatusCode(), response)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package cos
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/global"
|
||||
"github.com/tencentyun/cos-go-sdk-v5"
|
||||
)
|
||||
|
||||
func NewCosClient() (*cos.Client, error) {
|
||||
u, err := url.Parse("https://" + global.CosSetting.CosUploadDomain)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse cos upload domain: %w", err)
|
||||
}
|
||||
b := &cos.BaseURL{BucketURL: u}
|
||||
client := cos.NewClient(b, &http.Client{
|
||||
Transport: &cos.AuthorizationTransport{
|
||||
SecretID: global.CosSetting.CosSecretID,
|
||||
SecretKey: global.CosSetting.CosSecretKey,
|
||||
},
|
||||
})
|
||||
|
||||
return client, nil
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package errcode
|
||||
|
||||
// 公共错误码段位:10000 - 19999
|
||||
//
|
||||
// 细分:
|
||||
// 10000 - 10999 通用 / 参数错误
|
||||
// 11000 - 11999 鉴权 / Token
|
||||
// 12000 - 12999 权限 / 资源
|
||||
// 19000 - 19999 系统级错误(DB、缓存、RPC)
|
||||
|
||||
// 成功
|
||||
var Success = NewError(0, "成功")
|
||||
|
||||
// ===== 通用错误(10000 - 10999) =====
|
||||
var (
|
||||
InvalidParams = NewError(10000, "请求参数错误")
|
||||
NotFound = NewError(10001, "资源不存在")
|
||||
MethodNotAllow = NewError(10002, "请求方法不允许")
|
||||
TooManyRequests = NewError(10003, "请求过于频繁")
|
||||
)
|
||||
|
||||
// ===== 鉴权 / Token(11000 - 11999) =====
|
||||
var (
|
||||
Unauthorized = NewError(11000, "未授权")
|
||||
UnauthorizedTokenError = NewError(11001, "Token 鉴权失败")
|
||||
UnauthorizedTokenTimeout = NewError(11002, "Token 已过期")
|
||||
UnauthorizedTokenGenerate = NewError(11003, "Token 生成失败")
|
||||
UnauthorizedTokenMissing = NewError(11004, "Token 缺失")
|
||||
)
|
||||
|
||||
// ===== 权限(12000 - 12999) =====
|
||||
var (
|
||||
Forbidden = NewError(12000, "无权限访问")
|
||||
)
|
||||
|
||||
// ===== 系统错误(19000 - 19999) =====
|
||||
var (
|
||||
ServerError = NewError(19000, "服务内部错误")
|
||||
DatabaseError = NewError(19001, "数据库错误")
|
||||
CacheError = NewError(19002, "缓存错误")
|
||||
RPCError = NewError(19003, "下游服务调用失败")
|
||||
UnknownError = NewError(19999, "未知错误")
|
||||
)
|
||||
@@ -0,0 +1,77 @@
|
||||
package errcode
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Error 业务错误
|
||||
type Error struct {
|
||||
code int
|
||||
msg string
|
||||
details []string
|
||||
}
|
||||
|
||||
// codes 用于注册时去重检查
|
||||
var codes = map[int]string{}
|
||||
|
||||
// NewError 注册并创建一个错误码
|
||||
func NewError(code int, msg string) *Error {
|
||||
if _, exists := codes[code]; exists {
|
||||
panic(fmt.Sprintf("error code %d already exists, please change", code))
|
||||
}
|
||||
codes[code] = msg
|
||||
return &Error{code: code, msg: msg}
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
return fmt.Sprintf("error code: %d, msg: %s", e.code, e.msg)
|
||||
}
|
||||
|
||||
func (e *Error) Code() int { return e.code }
|
||||
func (e *Error) Msg() string { return e.msg }
|
||||
func (e *Error) Details() []string { return e.details }
|
||||
|
||||
func (e *Error) Msgf(args ...interface{}) string {
|
||||
return fmt.Sprintf(e.msg, args...)
|
||||
}
|
||||
|
||||
// WithDetails 附加详细信息(返回副本)
|
||||
func (e *Error) WithDetails(details ...string) *Error {
|
||||
newError := *e
|
||||
newError.details = append(newError.details, details...)
|
||||
return &newError
|
||||
}
|
||||
|
||||
// StatusCode 业务码 → HTTP 状态码
|
||||
// 仅对少数特殊错误返回非 200,其余统一 200,由前端按业务 code 处理
|
||||
func (e *Error) StatusCode() int {
|
||||
switch e.code {
|
||||
case Success.Code():
|
||||
return http.StatusOK
|
||||
|
||||
// 鉴权类返回 401,便于前端拦截器统一处理跳转登录
|
||||
case Unauthorized.Code(),
|
||||
UnauthorizedTokenError.Code(),
|
||||
UnauthorizedTokenTimeout.Code(),
|
||||
UnauthorizedTokenMissing.Code():
|
||||
return http.StatusUnauthorized
|
||||
|
||||
// 越权/无权限返回 403,便于网关与审计识别越权事件
|
||||
case Forbidden.Code():
|
||||
return http.StatusForbidden
|
||||
|
||||
// 限流返回 429,便于网关识别限流事件
|
||||
case TooManyRequests.Code():
|
||||
return http.StatusTooManyRequests
|
||||
|
||||
// 系统错误返回 500,便于运维监控告警
|
||||
case ServerError.Code(),
|
||||
DatabaseError.Code(),
|
||||
CacheError.Code(),
|
||||
RPCError.Code(),
|
||||
UnknownError.Code():
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
return http.StatusOK
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package errcode
|
||||
|
||||
// 上传模块错误码段位:30000 - 39999
|
||||
//
|
||||
// 细分:
|
||||
// 30000 - 30999 参数 / 校验
|
||||
// 39000 - 39999 服务端错误
|
||||
|
||||
var (
|
||||
UploadFileEmpty = NewError(30000, "上传文件为空")
|
||||
UploadFileExtNotAllow = NewError(30001, "不支持的文件类型")
|
||||
UploadFileTooLarge = NewError(30002, "文件大小超过限制")
|
||||
UploadFileSaveFailed = NewError(39000, "文件保存失败")
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
package errcode
|
||||
|
||||
// 用户模块错误码段位:20000 - 29999
|
||||
//
|
||||
// 细分:
|
||||
// 20000 - 20999 参数错误
|
||||
// 21000 - 21999 鉴权 / 登录
|
||||
// 22000 - 22999 资源不存在
|
||||
// 23000 - 23999 冲突 / 重复
|
||||
// 29000 - 29999 服务端错误
|
||||
|
||||
// ===== 鉴权 / 登录(21000 - 21999) =====
|
||||
var (
|
||||
UserDisabled = NewError(21001, "账号已被禁用")
|
||||
UserWxLoginFailed = NewError(21002, "微信登录失败")
|
||||
UserWxCodeInvalid = NewError(21003, "微信 Code 无效")
|
||||
)
|
||||
|
||||
// ===== 资源不存在(22000 - 22999) =====
|
||||
var (
|
||||
UserNotFound = NewError(22000, "用户不存在")
|
||||
)
|
||||
|
||||
// ===== 服务端错误(29000 - 29999) =====
|
||||
var (
|
||||
UserCreateFailed = NewError(29000, "用户创建失败")
|
||||
UserUpdateFailed = NewError(29001, "用户更新失败")
|
||||
)
|
||||
@@ -0,0 +1,137 @@
|
||||
package lzcwyjwt
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/global"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type TokenType string
|
||||
|
||||
const (
|
||||
TokenTypeAccess TokenType = "access"
|
||||
TokenTypeRefresh TokenType = "refresh"
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
TokenType TokenType `json:"token_type"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
var (
|
||||
ErrTokenInvalid = errors.New("token invalid")
|
||||
ErrTokenExpired = errors.New("token expired")
|
||||
ErrTokenWrongType = errors.New("token type mismatch")
|
||||
)
|
||||
|
||||
func GenerateTokens(userID int64) (accessToken, refreshToken string, err error) {
|
||||
now := time.Now()
|
||||
accessClaims := Claims{
|
||||
UserID: userID,
|
||||
TokenType: TokenTypeAccess,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: global.JWTSetting.Issuer,
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(global.JWTSetting.AccessTokenExpire) * time.Second)),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
},
|
||||
}
|
||||
|
||||
refreshClaims := Claims{
|
||||
UserID: userID,
|
||||
TokenType: TokenTypeRefresh,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: global.JWTSetting.Issuer,
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(global.JWTSetting.RefreshTokenExpire) * time.Second)),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
},
|
||||
}
|
||||
|
||||
accessToken, err = jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString([]byte(global.JWTSetting.SecretKey))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
refreshToken, err = jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString([]byte(global.JWTSetting.SecretKey))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return accessToken, refreshToken, nil
|
||||
}
|
||||
|
||||
func ParseAccessToken(tokenString string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(t *jwt.Token) (interface{}, error) {
|
||||
// 验证签名算法
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, ErrTokenInvalid
|
||||
}
|
||||
return []byte(global.JWTSetting.SecretKey), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
// 区分过期和其他错误
|
||||
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||
return nil, ErrTokenExpired
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, ErrTokenInvalid
|
||||
}
|
||||
|
||||
// 校验签发方,防止跨服务 token 混用。
|
||||
if global.JWTSetting.Issuer != "" && claims.Issuer != global.JWTSetting.Issuer {
|
||||
return nil, ErrTokenInvalid
|
||||
}
|
||||
|
||||
// 校验 token 类型(防止 refresh token 被当 access token 用)
|
||||
if claims.TokenType != TokenTypeAccess {
|
||||
return nil, ErrTokenWrongType
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
|
||||
}
|
||||
|
||||
func ParseRefreshToken(tokenString string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(t *jwt.Token) (interface{}, error) {
|
||||
// 验证签名算法
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, ErrTokenInvalid
|
||||
}
|
||||
return []byte(global.JWTSetting.SecretKey), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
// 区分过期和其他错误
|
||||
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||
return nil, ErrTokenExpired
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, ErrTokenInvalid
|
||||
}
|
||||
|
||||
// 校验签发方,防止跨服务 token 混用。
|
||||
if global.JWTSetting.Issuer != "" && claims.Issuer != global.JWTSetting.Issuer {
|
||||
return nil, ErrTokenInvalid
|
||||
}
|
||||
|
||||
// 校验 token 类型(防止 access token 被当 refresh token 用)
|
||||
if claims.TokenType != TokenTypeRefresh {
|
||||
return nil, ErrTokenWrongType
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package setting
|
||||
|
||||
// ServerSettingS 服务配置。时间字段单位为秒(int),避免 time.Duration 对整数配置按纳秒解析的歧义。
|
||||
type ServerSettingS struct {
|
||||
Domain string // 服务域名(用于 CORS 允许来源,如 test.com)
|
||||
RunMode string
|
||||
HttpPort string
|
||||
ReadTimeout int // 秒
|
||||
WriteTimeout int // 秒
|
||||
}
|
||||
|
||||
type LogSettingS struct {
|
||||
LogSavePath string
|
||||
LogFileName string
|
||||
LogFileExt string
|
||||
MaxSize int
|
||||
MaxBackups int
|
||||
MaxAge int
|
||||
LogLevel string
|
||||
}
|
||||
|
||||
type MysqlSettingS struct {
|
||||
UserName string
|
||||
Password string
|
||||
Host string
|
||||
Port int
|
||||
DBName string
|
||||
MaxIdleConns int
|
||||
MaxOpenConns int
|
||||
ConnMaxLifeTime int
|
||||
ConnMaxIdleTime int
|
||||
}
|
||||
|
||||
type RedisSettingS struct {
|
||||
Host string
|
||||
Port int
|
||||
Password string
|
||||
DB int
|
||||
PoolSize int
|
||||
MaxIdleConns int
|
||||
MinIdleConns int
|
||||
MaxRetryBackoff int
|
||||
MinRetryBackoff int
|
||||
MaxRetries int
|
||||
DialTimeout int
|
||||
ReadTimeout int
|
||||
WriteTimeout int
|
||||
PoolTimeout int
|
||||
}
|
||||
|
||||
type JWTSettingS struct {
|
||||
SecretKey string
|
||||
Issuer string
|
||||
AccessTokenExpire int // 秒
|
||||
RefreshTokenExpire int // 秒
|
||||
}
|
||||
|
||||
type UploadSettingS struct {
|
||||
TmpSavePath string
|
||||
MaxSizeMB int
|
||||
AllowedExtensions []string
|
||||
}
|
||||
|
||||
type CosSettingS struct {
|
||||
CosSecretID string
|
||||
CosSecretKey string
|
||||
CosUploadDomain string
|
||||
CosDownloadDomain string
|
||||
CdnDownloadSecretKey string
|
||||
}
|
||||
|
||||
// WechatMiniSettingS 微信小程序配置(用于 code2session 登录)。
|
||||
type WechatMiniSettingS struct {
|
||||
AppID string
|
||||
AppSecret string
|
||||
}
|
||||
|
||||
func (s *Setting) ReadSection(k string, v any) error {
|
||||
err := s.vp.UnmarshalKey(k, v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package setting
|
||||
|
||||
import "github.com/spf13/viper"
|
||||
|
||||
// Setting 封装 viper 配置读取能力。
|
||||
type Setting struct {
|
||||
vp *viper.Viper
|
||||
}
|
||||
|
||||
// NewSetting 初始化配置读取器,仅从 configs/config.yaml 读取配置。
|
||||
// 所有配置项一律在配置文件中直接填写,不支持环境变量覆盖。
|
||||
func NewSetting() (*Setting, error) {
|
||||
vp := viper.New()
|
||||
vp.AddConfigPath("configs/")
|
||||
vp.SetConfigName("config")
|
||||
vp.SetConfigType("yaml")
|
||||
if err := vp.ReadInConfig(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Setting{vp: vp}, nil
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// Package upload 文件上传、校验、落盘、公开 token 生成的工具函数。
|
||||
package upload
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/global"
|
||||
)
|
||||
|
||||
func FileGetExt(name string) string {
|
||||
return strings.ToLower(path.Ext(name))
|
||||
}
|
||||
|
||||
// 校验扩展名是否在白名单内。
|
||||
func FileCheckAllowExt(ext string, allowed []string) bool {
|
||||
ext = strings.TrimPrefix(strings.ToLower(ext), ".")
|
||||
for _, e := range allowed {
|
||||
if strings.ToLower(e) == ext {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 校验文件大小。maxMB <= 0 表示不限制。
|
||||
func FileCheckMaxSize(size, maxMB int64) bool {
|
||||
if maxMB <= 0 {
|
||||
return true
|
||||
}
|
||||
return size <= maxMB*1024*1024
|
||||
}
|
||||
|
||||
// FileCheckImageMagic 校验文件头 magic bytes,判断内容是否为真实图片(jpeg/png/gif/webp)。
|
||||
// 用于防止伪造扩展名上传非图片文件(如脚本、可执行文件、HTML)。
|
||||
func FileCheckImageMagic(data []byte) bool {
|
||||
if len(data) < 4 {
|
||||
return false
|
||||
}
|
||||
|
||||
// JPEG: FF D8 FF
|
||||
if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
|
||||
return true
|
||||
}
|
||||
|
||||
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
||||
if len(data) >= 8 &&
|
||||
data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 &&
|
||||
data[4] == 0x0D && data[5] == 0x0A && data[6] == 0x1A && data[7] == 0x0A {
|
||||
return true
|
||||
}
|
||||
|
||||
// GIF: "GIF8"
|
||||
if data[0] == 'G' && data[1] == 'I' && data[2] == 'F' && data[3] == '8' {
|
||||
return true
|
||||
}
|
||||
|
||||
// WebP: "RIFF" .... "WEBP"
|
||||
if len(data) >= 12 &&
|
||||
data[0] == 'R' && data[1] == 'I' && data[2] == 'F' && data[3] == 'F' &&
|
||||
data[8] == 'W' && data[9] == 'E' && data[10] == 'B' && data[11] == 'P' {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// GenFileName 生成防冲突随机相对文件名。
|
||||
func FileGenName(originName string) (string, error) {
|
||||
ext := FileGetExt(originName)
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b) + ext, nil
|
||||
}
|
||||
|
||||
// 临时保存从前端获取的图片
|
||||
func FileSaveTmp(ctx context.Context, file *multipart.FileHeader, fileName string, userID int64) (string, error) {
|
||||
|
||||
dir := filepath.Join(global.UploadSetting.TmpSavePath, fmt.Sprint(userID))
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tmpFile := filepath.Join(dir, fileName)
|
||||
|
||||
dst, err := os.Create(tmpFile)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return tmpFile, nil
|
||||
|
||||
}
|
||||
|
||||
// FileUploadCos 将本地文件上传至 COS。
|
||||
// key 为对象键(即写入库中的存储值),localPath 为本地文件路径。
|
||||
// key 或 localPath 为空、client 未初始化均返回 error,由调用方映射业务错误码。
|
||||
func FileUploadCos(ctx context.Context, key, localPath string) error {
|
||||
if key == "" || localPath == "" {
|
||||
return fmt.Errorf("upload cos: empty key or local path")
|
||||
}
|
||||
|
||||
// COS 客户端初始化失败时降级为 nil,此处做保护避免 nil 指针 panic。
|
||||
if global.CosClient == nil {
|
||||
return fmt.Errorf("cos client not initialized")
|
||||
}
|
||||
|
||||
if _, _, err := global.CosClient.Object.Upload(ctx, key, localPath, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 删除 COS 对象。key 为对象键(即 processed_image 存储值)。
|
||||
// key 为空直接返回 nil;client 未初始化返回 error。
|
||||
// 删除为尽力而为的清理操作,内部使用短超时避免网络异常长时间阻塞调用方。
|
||||
func FileDeleteCos(ctx context.Context, key string) error {
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// COS 客户端初始化失败时降级为 nil,此处做保护避免 nil 指针 panic。
|
||||
if global.CosClient == nil {
|
||||
return fmt.Errorf("cos client not initialized")
|
||||
}
|
||||
|
||||
delCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if _, err := global.CosClient.Object.Delete(delCtx, key); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 获取cos访问url
|
||||
func GetCosUrl(fileName string) string {
|
||||
ts := time.Now().Unix()
|
||||
raw := global.CosSetting.CdnDownloadSecretKey + "/" + fileName + strconv.FormatInt(ts, 10)
|
||||
sign := fmt.Sprintf("%x", md5.Sum([]byte(raw)))
|
||||
|
||||
return fmt.Sprintf("https://%s/%s?sign=%s&t=%d",
|
||||
global.CosSetting.CosDownloadDomain,
|
||||
fileName,
|
||||
sign,
|
||||
ts,
|
||||
)
|
||||
}
|
||||
|
||||
// AvatarURL 将头像存储值转为可访问 URL:
|
||||
// 历史通过修改资料接口传入的 http(s) URL 直接透传;COS key 统一转为 CDN 签名 URL。
|
||||
func AvatarURL(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") {
|
||||
return raw
|
||||
}
|
||||
return GetCosUrl(raw)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Package wechat 封装微信小程序登录与微信支付 V3 的真实对接能力,仅依赖 Go 标准库。
|
||||
//
|
||||
// 安全说明:
|
||||
// - AppSecret / APIv3Key / 商户私钥均来自配置(禁止硬编码)。
|
||||
// - 仅访问微信固定域名(api.weixin.qq.com / api.mch.weixin.qq.com),无 SSRF 风险。
|
||||
package wechat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/global"
|
||||
)
|
||||
|
||||
// code2SessionURL 微信小程序登录凭证校验接口。
|
||||
const code2SessionURL = "https://api.weixin.qq.com/sns/jscode2session"
|
||||
|
||||
// code2SessionResp 微信 code2session 响应。
|
||||
type code2SessionResp struct {
|
||||
OpenID string `json:"openid"`
|
||||
SessionKey string `json:"session_key"`
|
||||
UnionID string `json:"unionid"`
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
|
||||
// Code2SessionResult code2session 结果。
|
||||
type Code2SessionResult struct {
|
||||
OpenID string
|
||||
SessionKey string
|
||||
UnionID string
|
||||
}
|
||||
|
||||
// miniHTTPClient 小程序接口 HTTP 客户端(较短超时,微信接口通常很快)。
|
||||
var miniHTTPClient = &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
// Code2Session 使用小程序登录 code 换取 openid / session_key。
|
||||
//
|
||||
// 依赖配置 global.WechatMiniSetting.AppID / AppSecret。
|
||||
// 常见错误:40029 code 无效、45011 频率限制、40163 code 已使用。
|
||||
func Code2Session(ctx context.Context, code string) (*Code2SessionResult, error) {
|
||||
if global.WechatMiniSetting == nil || global.WechatMiniSetting.AppID == "" || global.WechatMiniSetting.AppSecret == "" {
|
||||
return nil, fmt.Errorf("wechat mini not configured")
|
||||
}
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("appid", global.WechatMiniSetting.AppID)
|
||||
q.Set("secret", global.WechatMiniSetting.AppSecret)
|
||||
q.Set("js_code", code)
|
||||
q.Set("grant_type", "authorization_code")
|
||||
|
||||
reqURL := code2SessionURL + "?" + q.Encode()
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("code2session build request: %w", err)
|
||||
}
|
||||
|
||||
response, err := miniHTTPClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("code2session do request: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("code2session read response: %w", err)
|
||||
}
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("code2session http status %d: %s", response.StatusCode, string(data))
|
||||
}
|
||||
|
||||
var r code2SessionResp
|
||||
if err := json.Unmarshal(data, &r); err != nil {
|
||||
return nil, fmt.Errorf("code2session decode: %w", err)
|
||||
}
|
||||
if r.ErrCode != 0 {
|
||||
return nil, &Code2SessionError{Code: r.ErrCode, Msg: r.ErrMsg}
|
||||
}
|
||||
if r.OpenID == "" {
|
||||
return nil, fmt.Errorf("code2session empty openid")
|
||||
}
|
||||
|
||||
return &Code2SessionResult{
|
||||
OpenID: r.OpenID,
|
||||
SessionKey: r.SessionKey,
|
||||
UnionID: r.UnionID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Code2SessionError 微信 code2session 业务错误,携带 errcode 便于上层区分 code 无效等场景。
|
||||
type Code2SessionError struct {
|
||||
Code int
|
||||
Msg string
|
||||
}
|
||||
|
||||
func (e *Code2SessionError) Error() string {
|
||||
return fmt.Sprintf("code2session errcode=%d, errmsg=%s", e.Code, e.Msg)
|
||||
}
|
||||
|
||||
// IsInvalidCode 判断是否为「code 无效 / 已使用 / 过期」类错误。
|
||||
func (e *Code2SessionError) IsInvalidCode() bool {
|
||||
switch e.Code {
|
||||
case 40029, // js_code 无效
|
||||
40163, // code been used
|
||||
41008: // missing code
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user