first commit
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
// Package config 负责加载与解析应用配置。
|
||||
// 优先级:默认值 < 配置文件 < 环境变量。
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// Config 是应用的顶层配置。
|
||||
type Config struct {
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
MySQL MySQLConfig `mapstructure:"mysql"`
|
||||
Logging LoggingConfig `mapstructure:"logging"`
|
||||
Admin AdminConfig `mapstructure:"admin"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port int `mapstructure:"port"`
|
||||
Mode string `mapstructure:"mode"`
|
||||
}
|
||||
|
||||
type MySQLConfig struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
User string `mapstructure:"user"`
|
||||
Password string `mapstructure:"password"`
|
||||
Database string `mapstructure:"database"`
|
||||
|
||||
// 连接与字符集参数。
|
||||
Charset string `mapstructure:"charset"`
|
||||
ParseTime string `mapstructure:"parse_time"`
|
||||
Loc string `mapstructure:"loc"`
|
||||
|
||||
// 连接池参数。
|
||||
MaxIdleConns int `mapstructure:"max_idle_conns"`
|
||||
MaxOpenConns int `mapstructure:"max_open_conns"`
|
||||
ConnMaxLifetime int `mapstructure:"conn_max_lifetime"` // 单位:秒
|
||||
}
|
||||
|
||||
// DSN 根据各独立字段拼装 MySQL 连接串。
|
||||
// 优先级:显式字段 < 环境变量;缺省项使用合理默认值。
|
||||
func (d MySQLConfig) DSN() string {
|
||||
charset := d.Charset
|
||||
if charset == "" {
|
||||
charset = "utf8mb4"
|
||||
}
|
||||
parseTime := d.ParseTime
|
||||
if parseTime == "" {
|
||||
parseTime = "True"
|
||||
}
|
||||
loc := d.Loc
|
||||
if loc == "" {
|
||||
loc = "Local"
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=%s&loc=%s",
|
||||
d.User, d.Password, d.Host, d.Port, d.Database, charset, parseTime, loc,
|
||||
)
|
||||
}
|
||||
|
||||
type LoggingConfig struct {
|
||||
Level string `mapstructure:"level"`
|
||||
Format string `mapstructure:"format"`
|
||||
}
|
||||
|
||||
// AdminConfig 后台管理相关配置。
|
||||
type AdminConfig struct {
|
||||
JWTSecret string `mapstructure:"jwt_secret"`
|
||||
TokenTTL string `mapstructure:"token_ttl"`
|
||||
}
|
||||
|
||||
// Load 从 configPath 加载配置(为空则默认 configs/config.yaml)。
|
||||
// 配置文件中使用 ${ENV} 占位符的字段会被对应环境变量替换,
|
||||
// 敏感信息(dsn/secret/token)据此注入,禁止硬编码。
|
||||
func Load(configPath string) (*Config, error) {
|
||||
if configPath == "" {
|
||||
configPath = "configs/config.yaml"
|
||||
}
|
||||
raw, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config %s: %w", configPath, err)
|
||||
}
|
||||
// 先展开 ${ENV} 占位符,再交给 viper 解析。
|
||||
expanded := os.ExpandEnv(string(raw))
|
||||
|
||||
v := viper.New()
|
||||
v.SetConfigType("yaml")
|
||||
if err := v.ReadConfig(strings.NewReader(expanded)); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := v.Unmarshal(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal config: %w", err)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Package errors 定义业务错误类型与哨兵错误。
|
||||
// 业务错误携带可被前端消费的业务码(Code),并支持 errors.As 提取。
|
||||
package errors
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// BizError 是带业务码的业务错误。
|
||||
type BizError struct {
|
||||
Code int
|
||||
Msg string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *BizError) Error() string {
|
||||
if e.Err != nil {
|
||||
return fmt.Sprintf("%s: %v", e.Msg, e.Err)
|
||||
}
|
||||
return e.Msg
|
||||
}
|
||||
|
||||
func (e *BizError) Unwrap() error { return e.Err }
|
||||
|
||||
// New 构造一个不带原始 error 的业务错误。
|
||||
func New(code int, msg string) *BizError {
|
||||
return &BizError{Code: code, Msg: msg}
|
||||
}
|
||||
|
||||
// Wrap 在传播错误时附加上下文与业务码,保留错误链路。
|
||||
func Wrap(code int, msg string, err error) *BizError {
|
||||
return &BizError{Code: code, Msg: msg, Err: err}
|
||||
}
|
||||
|
||||
// Is 判断 err 链中是否包含 target,语义与标准库 errors.Is 一致。
|
||||
// 它沿 Unwrap 链遍历,因此能正确识别被 Wrap 包装后的目标错误
|
||||
// (如 gorm.ErrRecordNotFound 或本包的哨兵错误 ErrNotFound)。
|
||||
func Is(err, target error) bool {
|
||||
return stderrors.Is(err, target)
|
||||
}
|
||||
|
||||
// 常用哨兵错误,供各层直接引用。
|
||||
var (
|
||||
ErrBadRequest = New(40000, "bad request")
|
||||
ErrUnauthorized = New(40100, "unauthorized")
|
||||
ErrForbidden = New(40300, "forbidden")
|
||||
ErrNotFound = New(40400, "not found")
|
||||
ErrServer = New(50000, "internal server error")
|
||||
)
|
||||
|
||||
// CodeOf 从 err 中提取业务码,非业务错误返回 50000。
|
||||
func CodeOf(err error) int {
|
||||
var be *BizError
|
||||
if err != nil && asBizError(err, &be) {
|
||||
return be.Code
|
||||
}
|
||||
return 50000
|
||||
}
|
||||
|
||||
// MsgOf 从 err 中提取可读消息,非业务错误返回内部错误提示。
|
||||
func MsgOf(err error) string {
|
||||
var be *BizError
|
||||
if err != nil && asBizError(err, &be) {
|
||||
return be.Msg
|
||||
}
|
||||
return "internal server error"
|
||||
}
|
||||
|
||||
// asBizError 兼容被 fmt.Errorf("%w") 包装后的 BizError。
|
||||
func asBizError(err error, target **BizError) bool {
|
||||
for err != nil {
|
||||
if b, ok := err.(*BizError); ok {
|
||||
*target = b
|
||||
return true
|
||||
}
|
||||
u, ok := err.(interface{ Unwrap() error })
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
err = u.Unwrap()
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Package response 提供统一的 HTTP 响应结构与写入辅助。
|
||||
// 所有 handler 的成功响应走 OK,错误统一由 ErrorHandler 中间件转换(见 middleware)。
|
||||
package response
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"portal-mini-go/internal/pkg/errors"
|
||||
)
|
||||
|
||||
// Response 是统一的响应结构。
|
||||
type Response struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// OK 写入成功响应(HTTP 200 + 业务码 0)。
|
||||
func OK(c *gin.Context, data interface{}) {
|
||||
c.JSON(http.StatusOK, Response{Code: 0, Msg: "ok", Data: data})
|
||||
}
|
||||
|
||||
// Error 把业务错误转为统一响应体。通常在 ErrorHandler 中间件中调用;
|
||||
// 若 handler 内部需要直接返回也可使用。
|
||||
func Error(c *gin.Context, err error) {
|
||||
c.JSON(http.StatusOK, Response{Code: errors.CodeOf(err), Msg: errors.MsgOf(err)})
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Package session 提供会话存储能力。
|
||||
// 当前默认实现为进程内内存存储(无 Redis 环境),后续可替换为 Redis 实现。
|
||||
package session
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"portal-mini-go/internal/pkg/errors"
|
||||
)
|
||||
|
||||
// Store 会话存储接口,便于后续切换为 Redis 等实现。
|
||||
type Store interface {
|
||||
Set(ctx context.Context, key string, userID uint64, ttl time.Duration) error
|
||||
Get(ctx context.Context, key string) (uint64, error)
|
||||
Delete(ctx context.Context, key string) error
|
||||
}
|
||||
|
||||
// MemoryStore 进程内会话存储,适用于无 Redis 的本地/测试环境。
|
||||
// 注意:多实例部署时各进程不共享,仅供单实例使用。
|
||||
type MemoryStore struct {
|
||||
mu sync.RWMutex
|
||||
data map[string]memoryEntry
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type memoryEntry struct {
|
||||
userID uint64
|
||||
expireAt time.Time
|
||||
}
|
||||
|
||||
// NewMemoryStore 创建内存会话存储。
|
||||
func NewMemoryStore() *MemoryStore {
|
||||
return &MemoryStore{
|
||||
data: make(map[string]memoryEntry),
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// Set 写入会话,key 映射到 userID,ttl 后过期。
|
||||
func (m *MemoryStore) Set(_ context.Context, key string, userID uint64, ttl time.Duration) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.data[key] = memoryEntry{userID: userID, expireAt: m.now().Add(ttl)}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get 读取会话对应的 userID;不存在或已过期返回 ErrUnauthorized。
|
||||
func (m *MemoryStore) Get(_ context.Context, key string) (uint64, error) {
|
||||
m.mu.RLock()
|
||||
e, ok := m.data[key]
|
||||
m.mu.RUnlock()
|
||||
if !ok {
|
||||
return 0, errors.ErrUnauthorized
|
||||
}
|
||||
if m.now().After(e.expireAt) {
|
||||
m.mu.Lock()
|
||||
delete(m.data, key)
|
||||
m.mu.Unlock()
|
||||
return 0, errors.ErrUnauthorized
|
||||
}
|
||||
return e.userID, nil
|
||||
}
|
||||
|
||||
// Delete 删除会话。
|
||||
func (m *MemoryStore) Delete(_ context.Context, key string) error {
|
||||
m.mu.Lock()
|
||||
delete(m.data, key)
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Package token 提供极简 HS256 JWT 生成与校验(仅依赖标准库)。
|
||||
// 后台管理用于登录态签发与中间件校验;小程序登录态请见各小程序 service。
|
||||
package token
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var b64 = base64.RawURLEncoding
|
||||
|
||||
// Claims 是后台 JWT 的载荷。
|
||||
type Claims struct {
|
||||
AdminID string `json:"admin_id"`
|
||||
Exp int64 `json:"exp"`
|
||||
}
|
||||
|
||||
const header = `{"alg":"HS256","typ":"JWT"}`
|
||||
|
||||
// Generate 签发一个 HS256 token。
|
||||
func Generate(secret, adminID string, ttl time.Duration) (string, error) {
|
||||
payload, err := json.Marshal(Claims{AdminID: adminID, Exp: time.Now().Add(ttl).Unix()})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
signingInput := b64.EncodeToString([]byte(header)) + "." + b64.EncodeToString(payload)
|
||||
return signingInput + "." + sign(secret, signingInput), nil
|
||||
}
|
||||
|
||||
// Parse 校验 token 签名与有效期,返回 Claims。
|
||||
func Parse(secret, tokenStr string) (*Claims, error) {
|
||||
parts := strings.Split(tokenStr, ".")
|
||||
if len(parts) != 3 {
|
||||
return nil, errors.New("invalid token format")
|
||||
}
|
||||
expected := sign(secret, parts[0]+"."+parts[1])
|
||||
if !hmac.Equal([]byte(expected), []byte(parts[2])) {
|
||||
return nil, errors.New("bad signature")
|
||||
}
|
||||
raw, err := b64.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var claims Claims
|
||||
if err := json.Unmarshal(raw, &claims); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if claims.Exp < time.Now().Unix() {
|
||||
return nil, errors.New("token expired")
|
||||
}
|
||||
return &claims, nil
|
||||
}
|
||||
|
||||
func sign(secret, input string) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(input))
|
||||
return b64.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Package wechat 封装微信小程序相关调用(此处为 code2session 实现)。
|
||||
package wechat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"portal-mini-go/internal/pkg/errors"
|
||||
)
|
||||
|
||||
const code2SessionURL = "https://api.weixin.qq.com/sns/jscode2session"
|
||||
|
||||
// SessionInfo 是 code2session 返回的会话信息。
|
||||
type SessionInfo struct {
|
||||
OpenID string
|
||||
UnionID string
|
||||
SessionKey string
|
||||
}
|
||||
|
||||
// Client 微信调用客户端。凭证(appid/secret)由调用方按请求传入,
|
||||
// 不再依赖静态配置,方便后台动态纳管多个小程序。
|
||||
type Client struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewClient() *Client {
|
||||
return &Client{client: &http.Client{}}
|
||||
}
|
||||
|
||||
// Code2Session 用 appID + secret + 登录 code 换取 openid / session_key。
|
||||
func (c *Client) Code2Session(ctx context.Context, appID, secret, code string) (*SessionInfo, error) {
|
||||
q := url.Values{}
|
||||
q.Set("appid", appID)
|
||||
q.Set("secret", secret)
|
||||
q.Set("js_code", code)
|
||||
q.Set("grant_type", "authorization_code")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, code2SessionURL+"?"+q.Encode(), nil)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(50000, "build wechat request", err)
|
||||
}
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(50000, "call wechat code2session", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(50000, "read wechat response", err)
|
||||
}
|
||||
|
||||
var r struct {
|
||||
OpenID string `json:"openid"`
|
||||
UnionID string `json:"unionid"`
|
||||
SessionKey string `json:"session_key"`
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &r); err != nil {
|
||||
return nil, errors.Wrap(50000, "decode wechat response", err)
|
||||
}
|
||||
if r.ErrCode != 0 {
|
||||
slog.Warn("wechat code2session error", slog.Int("errcode", r.ErrCode), slog.String("errmsg", r.ErrMsg))
|
||||
return nil, errors.Wrap(50000, fmt.Sprintf("wechat code2session: %s", r.ErrMsg), nil)
|
||||
}
|
||||
|
||||
return &SessionInfo{OpenID: r.OpenID, UnionID: r.UnionID, SessionKey: r.SessionKey}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user