first commit
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
// Package admin 是后台管理的 HTTP 接入层。
|
||||
package admin
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"portal-mini-go/internal/middleware"
|
||||
"portal-mini-go/internal/pkg/errors"
|
||||
"portal-mini-go/internal/pkg/response"
|
||||
"portal-mini-go/internal/pkg/token"
|
||||
)
|
||||
|
||||
// Handler 后台请求处理。
|
||||
type Handler struct {
|
||||
jwtSecret string
|
||||
tokenTTL time.Duration
|
||||
}
|
||||
|
||||
func NewHandler(jwtSecret string, tokenTTL time.Duration) *Handler {
|
||||
return &Handler{jwtSecret: jwtSecret, tokenTTL: tokenTTL}
|
||||
}
|
||||
|
||||
type LoginReq struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
// Login 后台登录(示例固定账号,生产应接 DB + 密码哈希校验)。
|
||||
func (h *Handler) Login(c *gin.Context) {
|
||||
var req LoginReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.Error(errors.ErrBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Username != "admin" || req.Password != "admin123" {
|
||||
c.Error(errors.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
t, err := token.Generate(h.jwtSecret, "1", h.tokenTTL)
|
||||
if err != nil {
|
||||
c.Error(errors.Wrap(50000, "gen token", err))
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"token": t})
|
||||
}
|
||||
|
||||
// Info 受保护接口示例,需携带有效 Bearer token。
|
||||
func (h *Handler) Info(c *gin.Context) {
|
||||
response.OK(c, gin.H{"admin_id": middleware.AdminID(c)})
|
||||
}
|
||||
|
||||
// Register 注册后台路由。g 已带 /api/admin 前缀与 JWT 鉴权中间件。
|
||||
func Register(g *gin.RouterGroup, h *Handler) {
|
||||
g.POST("/login", h.Login)
|
||||
g.GET("/info", h.Info)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Package miniapphdl 是后台管理小程序的 HTTP 接入层。
|
||||
package miniapphdl
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"portal-mini-go/internal/pkg/errors"
|
||||
"portal-mini-go/internal/pkg/response"
|
||||
miniappsvc "portal-mini-go/internal/service/admin/miniapp"
|
||||
)
|
||||
|
||||
// Handler 小程序管理请求处理。
|
||||
type Handler struct {
|
||||
svc *miniappsvc.Service
|
||||
}
|
||||
|
||||
func NewHandler(svc *miniappsvc.Service) *Handler { return &Handler{svc: svc} }
|
||||
|
||||
// Create 新建小程序。
|
||||
func (h *Handler) Create(c *gin.Context) {
|
||||
var req miniappsvc.CreateReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.Error(errors.ErrBadRequest)
|
||||
return
|
||||
}
|
||||
m, err := h.svc.Create(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
response.OK(c, m)
|
||||
}
|
||||
|
||||
// Update 更新小程序。
|
||||
func (h *Handler) Update(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req miniappsvc.UpdateReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.Error(errors.ErrBadRequest)
|
||||
return
|
||||
}
|
||||
m, err := h.svc.Update(c.Request.Context(), id, req)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
response.OK(c, m)
|
||||
}
|
||||
|
||||
// Get 查询单个小程序。
|
||||
func (h *Handler) Get(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
m, err := h.svc.GetByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
response.OK(c, m)
|
||||
}
|
||||
|
||||
// List 分页列表小程序。支持 query: status、page、size。
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
status, _ := strconv.ParseInt(c.DefaultQuery("status", "-1"), 10, 8)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
list, total, err := h.svc.List(c.Request.Context(), miniappsvc.ListReq{
|
||||
Status: int8(status),
|
||||
Page: page,
|
||||
Size: size,
|
||||
})
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"list": list, "total": total, "page": page, "size": size})
|
||||
}
|
||||
|
||||
// Delete 删除小程序。
|
||||
func (h *Handler) Delete(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.svc.Delete(c.Request.Context(), id); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
response.OK(c, nil)
|
||||
}
|
||||
|
||||
// parseID 从路径参数解析并校验 uint64 主键。
|
||||
func parseID(c *gin.Context) (uint64, bool) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.Error(errors.ErrBadRequest)
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// Register 注册后台小程序管理路由。g 已带 /api/admin 前缀与 JWT 鉴权中间件。
|
||||
func Register(g *gin.RouterGroup, h *Handler) {
|
||||
rg := g.Group("/miniapps")
|
||||
{
|
||||
rg.POST("", h.Create)
|
||||
rg.PUT("/:id", h.Update)
|
||||
rg.GET("/:id", h.Get)
|
||||
rg.GET("", h.List)
|
||||
rg.DELETE("/:id", h.Delete)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Package app1hdl 是小程序 app1 的 HTTP 接入层。
|
||||
package app1hdl
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"portal-mini-go/internal/middleware"
|
||||
"portal-mini-go/internal/pkg/errors"
|
||||
"portal-mini-go/internal/pkg/response"
|
||||
app1svc "portal-mini-go/internal/service/miniapp/app1"
|
||||
)
|
||||
|
||||
// Handler app1 请求处理。
|
||||
type Handler struct {
|
||||
svc *app1svc.Service
|
||||
}
|
||||
|
||||
func NewHandler(svc *app1svc.Service) *Handler { return &Handler{svc: svc} }
|
||||
|
||||
type LoginReq struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
}
|
||||
|
||||
// Login 处理小程序登录请求。app_id 由 MiniAppAuth 中间件从请求头校验并注入。
|
||||
func (h *Handler) Login(c *gin.Context) {
|
||||
var req LoginReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.Error(errors.ErrBadRequest)
|
||||
return
|
||||
}
|
||||
appID := middleware.AppID(c)
|
||||
token, u, err := h.svc.Login(c.Request.Context(), appID, req.Code)
|
||||
if err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"token": token, "user": u})
|
||||
}
|
||||
|
||||
// Register 注册 app1 的路由。g 已带 /api/ma 前缀与 MiniAppAuth 中间件(校验请求头 app_id)。
|
||||
func Register(g *gin.RouterGroup, h *Handler) {
|
||||
g.POST("/login", h.Login)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"portal-mini-go/internal/pkg/errors"
|
||||
"portal-mini-go/internal/pkg/token"
|
||||
)
|
||||
|
||||
const adminIDCtxKey = "admin_id"
|
||||
|
||||
// AdminAuth 校验后台请求的 Bearer JWT,校验通过后将 admin_id 注入 context。
|
||||
func AdminAuth(jwtSecret string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
auth := c.GetHeader("Authorization")
|
||||
const prefix = "Bearer "
|
||||
if !strings.HasPrefix(auth, prefix) {
|
||||
c.Error(errors.ErrUnauthorized)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
claims, err := token.Parse(jwtSecret, strings.TrimPrefix(auth, prefix))
|
||||
if err != nil {
|
||||
c.Error(errors.ErrUnauthorized)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set(adminIDCtxKey, claims.AdminID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// AdminID 从 gin.Context 取出已认证的后台管理员 ID。
|
||||
func AdminID(c *gin.Context) string {
|
||||
if v, ok := c.Get(adminIDCtxKey); ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Package middleware 提供 HTTP 中间件。
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"portal-mini-go/internal/pkg/errors"
|
||||
"portal-mini-go/internal/repository/miniapp"
|
||||
)
|
||||
|
||||
const (
|
||||
appIDCtxKey = "app_id"
|
||||
miniIDCtxKey = "mini_id"
|
||||
)
|
||||
|
||||
// headerAppID 是小程序在请求头中携带的微信 AppID 字段名。
|
||||
const HeaderAppID = "X-App-ID"
|
||||
|
||||
// MiniAppAuth 校验请求头中的 app_id 是否对应一个已启用且未删除的被纳管小程序,
|
||||
// 通过则将 app_id 与小程序的数据库主键注入 context,供后续 handler/service 使用。
|
||||
// 用户身份最终由 (mini_id, open_id) 唯一确定。
|
||||
func MiniAppAuth(repo *miniapp.Repository) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
appID := c.GetHeader(HeaderAppID)
|
||||
if appID == "" {
|
||||
c.Error(errors.ErrBadRequest)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
mp, err := repo.GetActiveByAppID(c.Request.Context(), appID)
|
||||
if err != nil {
|
||||
c.Error(errors.ErrNotFound)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set(appIDCtxKey, appID)
|
||||
c.Set(miniIDCtxKey, mp.ID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// AppID 从 gin.Context 取出已校验的 appID。
|
||||
func AppID(c *gin.Context) string {
|
||||
if v, ok := c.Get(appIDCtxKey); ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// MiniID 从 gin.Context 取出已校验的小程序数据库主键。
|
||||
func MiniID(c *gin.Context) uint64 {
|
||||
if v, ok := c.Get(miniIDCtxKey); ok {
|
||||
if id, ok := v.(uint64); ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"portal-mini-go/internal/pkg/response"
|
||||
)
|
||||
|
||||
// ErrorHandler 统一把 handler 通过 c.Error(err) 挂载的错误转换为响应。
|
||||
// 规范要求:handler 内不直接散落 c.JSON 处理错误,集中在此转换。
|
||||
func ErrorHandler() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Next()
|
||||
if len(c.Errors) == 0 {
|
||||
return
|
||||
}
|
||||
// 取最后一个(最外层)错误转换。
|
||||
last := c.Errors[len(c.Errors)-1].Err
|
||||
response.Error(c, last)
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Recovery 捕获 handler 中的 panic,记录结构化日志并以 500 响应,
|
||||
// 符合规范:recover 仅允许用于框架中间件。
|
||||
func Recovery() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("panic recovered",
|
||||
slog.String("path", c.Request.URL.Path),
|
||||
slog.Any("err", r),
|
||||
slog.String("stack", string(debug.Stack())),
|
||||
)
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
|
||||
"code": 50000,
|
||||
"msg": "internal server error",
|
||||
"data": nil,
|
||||
})
|
||||
}
|
||||
}()
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const traceIDKey = "trace_id"
|
||||
|
||||
// Trace 透传或生成 trace_id,并注入可携带 trace_id 的 slog 日志句柄,
|
||||
// 供后续日志统一携带链路标识。
|
||||
func Trace() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
tid := c.GetHeader("X-Trace-ID")
|
||||
if tid == "" {
|
||||
tid = newTraceID()
|
||||
}
|
||||
c.Set(traceIDKey, tid)
|
||||
c.Set("slog_ctx", slog.With(slog.String("trace_id", tid)))
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func newTraceID() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// TraceID 从 gin.Context 取出 trace_id。
|
||||
func TraceID(c *gin.Context) string {
|
||||
if v, ok := c.Get(traceIDKey); ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// SlogCtx 返回携带 trace_id 的 slog 日志句柄(无则回退默认)。
|
||||
func SlogCtx(c *gin.Context) *slog.Logger {
|
||||
if v, ok := c.Get("slog_ctx"); ok {
|
||||
if l, ok := v.(*slog.Logger); ok {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return slog.Default()
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Package model 定义领域模型。
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// MiniProgram 是被后台纳管的微信小程序配置。
|
||||
// 一个租户/运营方可在此登记多个小程序,AppID 全局唯一。
|
||||
type MiniProgram struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Name string `gorm:"type:varchar(128);not null" json:"name"`
|
||||
Description string `gorm:"type:varchar(512)" json:"description"`
|
||||
AppID string `gorm:"column:app_id;type:varchar(64);uniqueIndex;not null" json:"app_id"`
|
||||
AppSecret string `gorm:"column:app_secret;type:varchar(128);not null" json:"-"`
|
||||
Status int8 `gorm:"type:tinyint;not null;default:1" json:"status"`
|
||||
Ext string `gorm:"type:text" json:"ext"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
func (MiniProgram) TableName() string { return "mini_programs" }
|
||||
|
||||
// 小程序状态。
|
||||
const (
|
||||
MiniProgramStatusDisabled int8 = 0 // 停用
|
||||
MiniProgramStatusEnabled int8 = 1 // 启用
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
// Package model 定义领域模型。
|
||||
// common 下为跨小程序共享的物理表结构;用户数据通过 MiniID 列关联所属小程序,
|
||||
// 各小程序用户互不关联(open_id 在不同 appid 下天然互不冲突,故无需额外隔离键)。
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// User 是小程序端用户。MiniID 关联被纳管的小程序(mini_programs.id),
|
||||
// 与 open_id 共同唯一标识一个用户:(mini_id, open_id)。
|
||||
type User struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
MiniID uint64 `gorm:"column:mini_id;index:idx_mini_openid" json:"mini_id"`
|
||||
OpenID string `gorm:"type:varchar(64);index:idx_mini_openid" json:"openid"`
|
||||
UnionID string `gorm:"type:varchar(64);index" json:"union_id"`
|
||||
Nickname string `gorm:"type:varchar(128)" json:"nickname"`
|
||||
Avatar string `gorm:"type:varchar(512)" json:"avatar"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (User) TableName() string { return "users" }
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Package miniapp 提供被纳管小程序的数据访问。
|
||||
package miniapp
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"portal-mini-go/internal/model"
|
||||
"portal-mini-go/internal/pkg/errors"
|
||||
)
|
||||
|
||||
// Repository 小程序数据访问。
|
||||
type Repository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func New(db *gorm.DB) *Repository { return &Repository{db: db} }
|
||||
|
||||
// Create 新建小程序。AppID 已存在时返回可读业务错误。
|
||||
// 注意:软删除记录仍占用 app_id 唯一索引,故此处一并查软删除态。
|
||||
func (r *Repository) Create(ctx context.Context, m *model.MiniProgram) error {
|
||||
if _, err := r.GetByAppID(ctx, m.AppID); err == nil {
|
||||
return errors.New(40900, "app_id 已存在")
|
||||
} else if !errors.Is(err, errors.ErrNotFound) {
|
||||
return err
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Create(m).Error; err != nil {
|
||||
return errors.Wrap(50000, "create mini program", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update 按 ID 更新小程序非零值字段(cols 为待更新列)。
|
||||
func (r *Repository) Update(ctx context.Context, id uint64, cols map[string]interface{}) error {
|
||||
res := r.db.WithContext(ctx).
|
||||
Model(&model.MiniProgram{}).
|
||||
Where("id = ?", id).
|
||||
Updates(cols)
|
||||
if res.Error != nil {
|
||||
return errors.Wrap(50000, "update mini program", res.Error)
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return errors.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByID 按 ID 查询小程序,软删除记录不可见。
|
||||
func (r *Repository) GetByID(ctx context.Context, id uint64) (*model.MiniProgram, error) {
|
||||
var m model.MiniProgram
|
||||
if err := r.db.WithContext(ctx).Where("id = ?", id).First(&m).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
return nil, errors.Wrap(50000, "query mini program", err)
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// GetByAppID 按 AppID 查询(含软删除态),不存在返回 ErrNotFound。
|
||||
// 用于唯一性预校验与按 AppID 取配置。
|
||||
func (r *Repository) GetByAppID(ctx context.Context, appID string) (*model.MiniProgram, error) {
|
||||
var m model.MiniProgram
|
||||
if err := r.db.WithContext(ctx).Unscoped().Where("app_id = ?", appID).First(&m).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
return nil, errors.Wrap(50000, "query mini program by app_id", err)
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// GetActiveByAppID 按 AppID 查询“已启用且未删除”的小程序,
|
||||
// 用于登录鉴权与 code2session 取凭证;不存在/已停用/已删除均返回 ErrNotFound。
|
||||
func (r *Repository) GetActiveByAppID(ctx context.Context, appID string) (*model.MiniProgram, error) {
|
||||
var m model.MiniProgram
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("app_id = ? AND status = ?", appID, model.MiniProgramStatusEnabled).
|
||||
First(&m).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
return nil, errors.Wrap(50000, "query active mini program", err)
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// List 分页查询,status < 0 表示不过滤。
|
||||
func (r *Repository) List(ctx context.Context, status int8, page, size int) ([]model.MiniProgram, int64, error) {
|
||||
q := r.db.WithContext(ctx).Model(&model.MiniProgram{})
|
||||
if status >= 0 {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, errors.Wrap(50000, "count mini program", err)
|
||||
}
|
||||
var list []model.MiniProgram
|
||||
if err := q.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&list).Error; err != nil {
|
||||
return nil, 0, errors.Wrap(50000, "list mini program", err)
|
||||
}
|
||||
return list, total, nil
|
||||
}
|
||||
|
||||
// Delete 软删除小程序。
|
||||
func (r *Repository) Delete(ctx context.Context, id uint64) error {
|
||||
res := r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.MiniProgram{})
|
||||
if res.Error != nil {
|
||||
return errors.Wrap(50000, "delete mini program", res.Error)
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return errors.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Package user 提供用户数据访问。所有查询强制按 mini_id 过滤,
|
||||
// 从数据层保证各小程序用户体系相互独立。
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"portal-mini-go/internal/model"
|
||||
"portal-mini-go/internal/pkg/errors"
|
||||
)
|
||||
|
||||
type Repository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func New(db *gorm.DB) *Repository { return &Repository{db: db} }
|
||||
|
||||
// UpsertByOpenID 按 mini_id + openid 创建或返回已有用户。
|
||||
func (r *Repository) UpsertByOpenID(ctx context.Context, miniID uint64, openID, unionID, nickname, avatar string) (*model.User, error) {
|
||||
var u model.User
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("mini_id = ? AND openid = ?", miniID, openID).
|
||||
Attrs(model.User{MiniID: miniID, OpenID: openID, UnionID: unionID, Nickname: nickname, Avatar: avatar}).
|
||||
FirstOrCreate(&u).Error
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(50000, "upsert user", err)
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// GetByID 按 mini_id + id 查询用户,跨小程序查询直接返回未找到。
|
||||
func (r *Repository) GetByID(ctx context.Context, miniID uint64, id uint64) (*model.User, error) {
|
||||
var u model.User
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("mini_id = ? AND id = ?", miniID, id).
|
||||
First(&u).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
return nil, errors.Wrap(50000, "query user", err)
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// UpdateProfile 更新用户资料,限定在 mini_id 范围内。
|
||||
func (r *Repository) UpdateProfile(ctx context.Context, miniID uint64, id uint64, nickname, avatar string) error {
|
||||
res := r.db.WithContext(ctx).
|
||||
Model(&model.User{}).
|
||||
Where("mini_id = ? AND id = ?", miniID, id).
|
||||
Updates(map[string]interface{}{"nickname": nickname, "avatar": avatar})
|
||||
if res.Error != nil {
|
||||
return errors.Wrap(50000, "update user", res.Error)
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return errors.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Package router 装配 gin 引擎与各模块路由。
|
||||
// 路由分层:/api/ma(小程序,靠请求头 app_id 鉴权)、/api/admin(后台)。
|
||||
package router
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
adminhdl "portal-mini-go/internal/handler/admin"
|
||||
miniapphdl "portal-mini-go/internal/handler/admin/miniapp"
|
||||
app1hdl "portal-mini-go/internal/handler/miniapp/app1"
|
||||
"portal-mini-go/internal/middleware"
|
||||
"portal-mini-go/internal/pkg/config"
|
||||
"portal-mini-go/internal/pkg/response"
|
||||
"portal-mini-go/internal/pkg/session"
|
||||
"portal-mini-go/internal/pkg/wechat"
|
||||
miniapprepo "portal-mini-go/internal/repository/miniapp"
|
||||
"portal-mini-go/internal/repository/user"
|
||||
miniappsvc "portal-mini-go/internal/service/admin/miniapp"
|
||||
app1svc "portal-mini-go/internal/service/miniapp/app1"
|
||||
)
|
||||
|
||||
// New 构造并装配整个 HTTP 服务。
|
||||
func New(cfg *config.Config, db *gorm.DB, store session.Store) *gin.Engine {
|
||||
if cfg.Server.Mode == "release" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.Use(middleware.Recovery(), middleware.Trace(), middleware.ErrorHandler())
|
||||
|
||||
r.GET("/healthz", func(c *gin.Context) {
|
||||
response.OK(c, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
// 共享基础设施:微信客户端、用户仓储、小程序仓储。
|
||||
wx := wechat.NewClient()
|
||||
userRepo := user.New(db)
|
||||
miniRepo := miniapprepo.New(db)
|
||||
|
||||
// 小程序分组:先校验请求头 app_id 身份,再挂载各小程序业务路由。
|
||||
ma := r.Group("/api/ma")
|
||||
ma.Use(middleware.MiniAppAuth(miniRepo))
|
||||
{
|
||||
app1Svc := app1svc.New(userRepo, miniRepo, wx, store)
|
||||
app1hdl.Register(ma, app1hdl.NewHandler(app1Svc))
|
||||
// 新增小程序时,在此依次注册 app2..app10:
|
||||
// app2Svc := app2svc.New(userRepo, miniRepo, wx, store)
|
||||
// app2hdl.Register(ma, app2hdl.NewHandler(app2Svc))
|
||||
}
|
||||
|
||||
// 后台管理分组:JWT 鉴权。
|
||||
ad := r.Group("/api/admin")
|
||||
ad.Use(middleware.AdminAuth(cfg.Admin.JWTSecret))
|
||||
{
|
||||
adminhdl.Register(ad, adminhdl.NewHandler(cfg.Admin.JWTSecret, parseTTL(cfg.Admin.TokenTTL)))
|
||||
|
||||
// 小程序管理:被后台纳管的微信小程序配置(增删改查)。
|
||||
miniSvc := miniappsvc.New(miniRepo)
|
||||
miniapphdl.Register(ad, miniapphdl.NewHandler(miniSvc))
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func parseTTL(s string) time.Duration {
|
||||
if d, err := time.ParseDuration(s); err == nil {
|
||||
return d
|
||||
}
|
||||
return 24 * time.Hour
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Package miniappsvc 是后台管理小程序的业务逻辑。
|
||||
package miniappsvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"portal-mini-go/internal/model"
|
||||
"portal-mini-go/internal/pkg/errors"
|
||||
"portal-mini-go/internal/repository/miniapp"
|
||||
)
|
||||
|
||||
// Service 小程序管理业务服务,依赖小程序仓储。
|
||||
type Service struct {
|
||||
repo *miniapp.Repository
|
||||
}
|
||||
|
||||
func New(repo *miniapp.Repository) *Service { return &Service{repo: repo} }
|
||||
|
||||
// CreateReq 新建小程序请求。
|
||||
type CreateReq struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
AppID string `json:"app_id" binding:"required"`
|
||||
AppSecret string `json:"app_secret" binding:"required"`
|
||||
Status int8 `json:"status"`
|
||||
Ext string `json:"ext"`
|
||||
}
|
||||
|
||||
// Create 新建小程序,缺省状态为启用。
|
||||
func (s *Service) Create(ctx context.Context, req CreateReq) (*model.MiniProgram, error) {
|
||||
if req.Status == 0 {
|
||||
req.Status = model.MiniProgramStatusEnabled
|
||||
}
|
||||
if err := validateExt(req.Ext); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := &model.MiniProgram{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
AppID: req.AppID,
|
||||
AppSecret: req.AppSecret,
|
||||
Status: req.Status,
|
||||
Ext: req.Ext,
|
||||
}
|
||||
if err := s.repo.Create(ctx, m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// UpdateReq 更新小程序请求;指针区分“未提供”与“置空”。
|
||||
type UpdateReq struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
AppSecret *string `json:"app_secret"`
|
||||
Status *int8 `json:"status"`
|
||||
Ext *string `json:"ext"`
|
||||
}
|
||||
|
||||
// Update 按 ID 局部更新小程序。
|
||||
func (s *Service) Update(ctx context.Context, id uint64, req UpdateReq) (*model.MiniProgram, error) {
|
||||
cols := map[string]interface{}{}
|
||||
if req.Name != nil {
|
||||
cols["name"] = *req.Name
|
||||
}
|
||||
if req.Description != nil {
|
||||
cols["description"] = *req.Description
|
||||
}
|
||||
if req.AppSecret != nil {
|
||||
cols["app_secret"] = *req.AppSecret
|
||||
}
|
||||
if req.Status != nil {
|
||||
cols["status"] = *req.Status
|
||||
}
|
||||
if req.Ext != nil {
|
||||
if err := validateExt(*req.Ext); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cols["ext"] = *req.Ext
|
||||
}
|
||||
if len(cols) == 0 {
|
||||
return s.repo.GetByID(ctx, id)
|
||||
}
|
||||
if err := s.repo.Update(ctx, id, cols); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// GetByID 按 ID 查询小程序。
|
||||
func (s *Service) GetByID(ctx context.Context, id uint64) (*model.MiniProgram, error) {
|
||||
return s.repo.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
// ListReq 列表查询条件。
|
||||
type ListReq struct {
|
||||
Status int8
|
||||
Page int
|
||||
Size int
|
||||
}
|
||||
|
||||
// List 分页查询小程序。
|
||||
func (s *Service) List(ctx context.Context, req ListReq) ([]model.MiniProgram, int64, error) {
|
||||
page, size := req.Page, req.Size
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if size <= 0 {
|
||||
size = 20
|
||||
}
|
||||
return s.repo.List(ctx, req.Status, page, size)
|
||||
}
|
||||
|
||||
// Delete 软删除小程序。
|
||||
func (s *Service) Delete(ctx context.Context, id uint64) error {
|
||||
return s.repo.Delete(ctx, id)
|
||||
}
|
||||
|
||||
// validateExt 校验扩展字段为合法 JSON(允许为空字符串)。
|
||||
func validateExt(s string) error {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
if !json.Valid([]byte(s)) {
|
||||
return errors.New(40000, "ext 必须是合法 JSON 字符串")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Package app1svc 是小程序 app1 的业务逻辑。
|
||||
// 其余小程序(app2..app10)按相同目录结构各自建包,互不耦合。
|
||||
package app1svc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"time"
|
||||
|
||||
"portal-mini-go/internal/model"
|
||||
"portal-mini-go/internal/pkg/errors"
|
||||
"portal-mini-go/internal/pkg/session"
|
||||
"portal-mini-go/internal/pkg/wechat"
|
||||
"portal-mini-go/internal/repository/miniapp"
|
||||
"portal-mini-go/internal/repository/user"
|
||||
)
|
||||
|
||||
const sessionKeyPrefix = "ma:session:"
|
||||
|
||||
// Service app1 业务服务,依赖用户仓储、小程序仓储、微信客户端与会话存储。
|
||||
type Service struct {
|
||||
userRepo *user.Repository
|
||||
miniRepo *miniapp.Repository
|
||||
wx *wechat.Client
|
||||
store session.Store
|
||||
}
|
||||
|
||||
func New(userRepo *user.Repository, miniRepo *miniapp.Repository, wx *wechat.Client, store session.Store) *Service {
|
||||
return &Service{userRepo: userRepo, miniRepo: miniRepo, wx: wx, store: store}
|
||||
}
|
||||
|
||||
// Login 完成小程序登录:按 app_id 取凭证 -> code2session -> 落库用户 -> 签发会话 token。
|
||||
// app_id 由接入层(MiniAppAuth 中间件)校验过为已启用小程序,此处用于查出 secret 与小程序主键。
|
||||
func (s *Service) Login(ctx context.Context, appID, code string) (string, *model.User, error) {
|
||||
mp, err := s.miniRepo.GetActiveByAppID(ctx, appID)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
info, err := s.wx.Code2Session(ctx, appID, mp.AppSecret, code)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
u, err := s.userRepo.UpsertByOpenID(ctx, mp.ID, info.OpenID, info.UnionID, "", "")
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
token, err := newSessionToken()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if err := s.store.Set(ctx, sessionKeyPrefix+token, u.ID, 7*24*time.Hour); err != nil {
|
||||
return "", nil, errors.Wrap(50000, "save session", err)
|
||||
}
|
||||
return token, u, nil
|
||||
}
|
||||
|
||||
func newSessionToken() (string, error) {
|
||||
b := make([]byte, 24)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", errors.Wrap(50000, "gen token", err)
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
Reference in New Issue
Block a user