This commit is contained in:
huangjin
2026-06-29 17:34:59 +08:00
commit fe3ad20fe2
271 changed files with 51767 additions and 0 deletions

View File

@@ -0,0 +1,121 @@
// 使用 Viper 从 YAML 文件加载配置
package config
import (
"fmt"
"github.com/spf13/viper"
)
// Config 全局配置,聚合所有子模块配置
type Config struct {
Server ServerConfig `mapstructure:"server"`
Database DatabaseConfig `mapstructure:"database"`
Redis RedisConfig `mapstructure:"redis"`
MinIO MinIOConfig `mapstructure:"minio"`
RabbitMQ RabbitMQConfig `mapstructure:"rabbitmq"`
Wechat WechatConfig `mapstructure:"wechat"`
DingTalk DingTalkConfig `mapstructure:"dingtalk"`
JWT JWTConfig `mapstructure:"jwt"`
Snowflake SnowflakeConfig `mapstructure:"snowflake"`
System SystemConfig `mapstructure:"system"`
}
type ServerConfig struct {
Port int `mapstructure:"port"`
Mode string `mapstructure:"mode"` // debug / release / test
}
type DatabaseConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
User string `mapstructure:"user"`
Password string `mapstructure:"password"`
DBName string `mapstructure:"dbname"`
SSLMode string `mapstructure:"sslmode"`
Timezone string `mapstructure:"timezone"`
}
type RedisConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Password string `mapstructure:"password"`
DB int `mapstructure:"db"`
}
type MinIOConfig struct {
Endpoint string `mapstructure:"endpoint"`
AccessKey string `mapstructure:"access_key"`
SecretKey string `mapstructure:"secret_key"`
Bucket string `mapstructure:"bucket"`
UseSSL bool `mapstructure:"use_ssl"`
BaseURL string `mapstructure:"base_url"`
}
type RabbitMQConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
User string `mapstructure:"user"`
Password string `mapstructure:"password"`
VHost string `mapstructure:"vhost"`
}
type WechatConfig struct {
AppID string `mapstructure:"app_id"`
AppSecret string `mapstructure:"app_secret"`
Token string `mapstructure:"token"`
EncodingAESKey string `mapstructure:"encoding_aes_key"`
}
type JWTConfig struct {
Secret string `mapstructure:"secret"`
ExpireHours int `mapstructure:"expire_hours"`
}
type SnowflakeConfig struct {
WorkerID int64 `mapstructure:"worker_id"`
DatacenterID int64 `mapstructure:"datacenter_id"`
}
// DingTalkConfig 钉钉应用配置
type DingTalkConfig struct {
AppKey string `mapstructure:"app_key"`
AppSecret string `mapstructure:"app_secret"`
RobotCode string `mapstructure:"robot_code"`
}
type SystemConfig struct {
DefaultAvatar string `mapstructure:"default_avatar"`
}
// DSN 生成 PostgreSQL 连接字符串
func (d *DatabaseConfig) DSN() string {
return fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%d sslmode=%s TimeZone=%s",
d.Host, d.User, d.Password, d.DBName, d.Port, d.SSLMode, d.Timezone)
}
func (r *RedisConfig) Addr() string {
return fmt.Sprintf("%s:%d", r.Host, r.Port)
}
func (r *RabbitMQConfig) AMQPURL() string {
return fmt.Sprintf("amqp://%s:%s@%s:%d/%s", r.User, r.Password, r.Host, r.Port, r.VHost)
}
// LoadConfig 从 YAML 文件加载配置
func LoadConfig(configPath string) (*Config, error) {
v := viper.New()
v.SetConfigFile(configPath)
v.SetConfigType("yaml")
if err := v.ReadInConfig(); err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
var cfg Config
if err := v.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
return &cfg, nil
}

View File

@@ -0,0 +1,72 @@
// 数据库连接管理 + GORM AutoMigrate
package database
import (
"log"
"com.sclktx/m/v2/internal/common/model"
"com.sclktx/m/v2/internal/pkg/config"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var DB *gorm.DB
// InitDB 初始化 PostgreSQL 连接并执行自动迁移
func InitDB(cfg *config.DatabaseConfig) *gorm.DB {
var err error
logLevel := logger.Info
if cfg.SSLMode == "" {
cfg.SSLMode = "disable"
}
DB, err = gorm.Open(postgres.Open(cfg.DSN()), &gorm.Config{
Logger: logger.Default.LogMode(logLevel),
DisableForeignKeyConstraintWhenMigrating: true,
})
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
if err := AutoMigrate(DB); err != nil {
log.Fatalf("Failed to auto migrate: %v", err)
}
return DB
}
// AutoMigrate 自动迁移所有模型对应的数据库表
func AutoMigrate(db *gorm.DB) error {
return db.AutoMigrate(
&model.User{},
&model.Visitor{},
&model.Appointment{},
&model.VisitRecord{},
&model.Invitation{},
&model.Banner{},
&model.InviteCode{},
&model.Notice{},
&model.Notification{},
&model.AdminUser{},
&model.Department{},
&model.EmployeeCertification{},
// 工作流引擎
&model.ProcessDefinition{},
&model.ProcessInstance{},
&model.Task{},
&model.GoodsTemplate{},
&model.VehicleInfo{},
&model.UserDepartment{},
&model.VisitorArea{},
&model.VisitType{},
&model.PendingEmployee{},
&model.GlobalApprover{},
)
}
func GetDB() *gorm.DB {
return DB
}

View File

@@ -0,0 +1,58 @@
package database
import (
"log"
"com.sclktx/m/v2/internal/common/model"
"gorm.io/gorm"
)
// InitData 初始化基础数据
func InitData(db *gorm.DB) {
initProcessDefinitions(db)
}
func initProcessDefinitions(db *gorm.DB) {
defs := []model.ProcessDefinition{
{
ProcessName: "访客预约审批",
ProcessCode: "visitor_approval",
Source: "访客系统",
Description: "访客预约三级审批:公司接待员工 → 部门指定人 → 分管领导",
IsActive: true,
NodesJSON: `[
{"node_id":"Start","node_name":"发起预约","node_type":0,"user_ids":["$starter"]},
{"node_id":"Employee","node_name":"公司接待员工审批","node_type":1,"prev_node_ids":["Start"],"user_ids":["$employee_id"]},
{"node_id":"DeptApprover","node_name":"部门指定人审批","node_type":1,"prev_node_ids":["Employee"],"user_ids":["$dept_approver_ids"]},
{"node_id":"VPApproval","node_name":"分管领导审批","node_type":1,"prev_node_ids":["DeptApprover"],"user_ids":["$vp_ids"]},
{"node_id":"End","node_name":"审批完成","node_type":3,"prev_node_ids":["VPApproval"]}
]`,
},
{
ProcessName: "访客预约审批(兜底)",
ProcessCode: "visitor_approval_simple",
Source: "访客系统",
Description: "访客预约兜底审批:直接推送给全局兜底审批人",
IsActive: true,
NodesJSON: `[
{"node_id":"Start","node_name":"发起预约","node_type":0,"user_ids":["$starter"]},
{"node_id":"DefaultApproval","node_name":"兜底审批","node_type":1,"prev_node_ids":["Start"],"user_ids":["$default_approver_ids"]},
{"node_id":"End","node_name":"审批完成","node_type":3,"prev_node_ids":["DefaultApproval"]}
]`,
},
}
for _, def := range defs {
var count int64
db.Model(&model.ProcessDefinition{}).Where("process_code = ?", def.ProcessCode).Count(&count)
if count > 0 {
log.Printf("[init] 流程定义已存在,跳过: %s", def.ProcessCode)
continue
}
if err := db.Create(&def).Error; err != nil {
log.Printf("[init] 创建流程定义失败 %s: %v", def.ProcessCode, err)
} else {
log.Printf("[init] 创建流程定义成功: %s", def.ProcessCode)
}
}
}

View File

@@ -0,0 +1,189 @@
// 钉钉开放平台 API 封装
package dingtalk
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"time"
myconfig "com.sclktx/m/v2/internal/pkg/config"
"github.com/sirupsen/logrus"
)
// Client 钉钉客户端
type Client struct {
cfg *myconfig.DingTalkConfig
httpCl *http.Client
token string
tokenExp time.Time
tokenLock sync.RWMutex
}
// NewClient 创建钉钉客户端
func NewClient(cfg *myconfig.DingTalkConfig) *Client {
return &Client{
cfg: cfg,
httpCl: &http.Client{Timeout: 10 * time.Second},
}
}
// tokenResponse 获取 access_token 的响应
type tokenResponse struct {
AccessToken string `json:"accessToken"`
ExpireIn int64 `json:"expireIn"` // 秒
}
// getAccessToken 获取钉钉 access_token带缓存
func (c *Client) getAccessToken() (string, error) {
c.tokenLock.RLock()
if c.token != "" && time.Now().Before(c.tokenExp) {
c.tokenLock.RUnlock()
return c.token, nil
}
c.tokenLock.RUnlock()
c.tokenLock.Lock()
defer c.tokenLock.Unlock()
// 双重检查
if c.token != "" && time.Now().Before(c.tokenExp) {
return c.token, nil
}
body := map[string]string{
"appKey": c.cfg.AppKey,
"appSecret": c.cfg.AppSecret,
}
data, _ := json.Marshal(body)
logrus.Debug("钉钉获取 access_token 开始")
resp, err := c.httpCl.Post("https://api.dingtalk.com/v1.0/oauth2/accessToken", "application/json", bytes.NewReader(data))
if err != nil {
logrus.WithError(err).Error("钉钉获取 access_token HTTP 请求失败")
return "", fmt.Errorf("dingtalk getAccessToken request failed: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
logrus.WithFields(logrus.Fields{
"status_code": resp.StatusCode,
"response": string(respBody),
}).Debug("钉钉获取 access_token 响应")
var tr tokenResponse
if err := json.Unmarshal(respBody, &tr); err != nil {
logrus.WithError(err).WithField("response", string(respBody)).Error("钉钉获取 access_token 解析失败")
return "", fmt.Errorf("dingtalk getAccessToken parse failed: %s", string(respBody))
}
if tr.AccessToken == "" {
logrus.WithField("response", string(respBody)).Error("钉钉获取 access_token 返回空")
return "", fmt.Errorf("dingtalk getAccessToken empty token: %s", string(respBody))
}
c.token = tr.AccessToken
c.tokenExp = time.Now().Add(time.Duration(tr.ExpireIn-60) * time.Second) // 提前 60 秒刷新
logrus.WithFields(logrus.Fields{
"expire_in": tr.ExpireIn,
}).Info("钉钉 access_token 获取成功")
return c.token, nil
}
// RobotBatchSendReq 批量发送机器人消息请求
type RobotBatchSendReq struct {
RobotCode string `json:"robotCode"`
UserIDs []string `json:"userIds"`
MsgKey string `json:"msgKey"`
MsgParam string `json:"msgParam"`
}
// BatchSendRobotMessage 批量发送人与机器人会话中机器人消息
// userIDs: 钉钉用户 unionId 列表
// title: 消息标题
// markdown: 消息内容Markdown 格式)
func (c *Client) BatchSendRobotMessage(userIDs []string, title, markdown string) error {
if len(userIDs) == 0 {
logrus.Warn("钉钉 BatchSendRobotMessage 被调用但 userIDs 为空")
return nil
}
if c.cfg.RobotCode == "" {
logrus.Warn("钉钉 BatchSendRobotMessage 被调用但 RobotCode 为空")
return nil
}
logrus.WithFields(logrus.Fields{
"user_ids": userIDs,
"title": title,
"robot_code": c.cfg.RobotCode,
}).Info("钉钉 BatchSendRobotMessage 开始")
token, err := c.getAccessToken()
if err != nil {
logrus.WithError(err).Error("钉钉 BatchSendRobotMessage 获取 token 失败")
return err
}
msgParam := map[string]string{
"title": title,
"text": markdown,
}
msgParamJSON, _ := json.Marshal(msgParam)
req := RobotBatchSendReq{
RobotCode: c.cfg.RobotCode,
UserIDs: userIDs,
MsgKey: "sampleMarkdown",
MsgParam: string(msgParamJSON),
}
reqBody, _ := json.Marshal(req)
logrus.WithFields(logrus.Fields{
"request_body": string(reqBody),
}).Debug("钉钉 BatchSendRobotMessage 请求体")
httpReq, err := http.NewRequest("POST",
"https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend",
bytes.NewReader(reqBody))
if err != nil {
logrus.WithError(err).Error("钉钉 BatchSendRobotMessage 创建请求失败")
return fmt.Errorf("dingtalk BatchSendRobotMessage create request failed: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("x-acs-dingtalk-access-token", token)
resp, err := c.httpCl.Do(httpReq)
if err != nil {
logrus.WithError(err).Error("钉钉 BatchSendRobotMessage HTTP 请求失败")
return fmt.Errorf("dingtalk BatchSendRobotMessage request failed: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
logrus.WithFields(logrus.Fields{
"status_code": resp.StatusCode,
"response": string(respBody),
}).Debug("钉钉 BatchSendRobotMessage 响应")
// 成功时钉钉返回 200 空 body 或 processQueryKey
if resp.StatusCode >= 400 {
errMsg := fmt.Errorf("dingtalk BatchSendRobotMessage failed: status=%d body=%s", resp.StatusCode, string(respBody))
logrus.WithFields(logrus.Fields{
"status_code": resp.StatusCode,
"response": string(respBody),
}).Error("钉钉 BatchSendRobotMessage 请求返回错误状态码")
return errMsg
}
logrus.WithFields(logrus.Fields{
"user_ids": userIDs,
"title": title,
"response": string(respBody),
}).Info("钉钉机器人消息发送成功")
return nil
}

View File

@@ -0,0 +1,119 @@
// 访客预约相关钉钉消息模板
package dingtalk
import (
"fmt"
"time"
)
// FormatCSTTime 格式化时间为 Asia/Shanghai 时区的 "01-02 15:04 ~ 15:04" 格式
func FormatCSTTime(start, end time.Time) string {
loc, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
loc = time.UTC
}
start = start.In(loc)
end = end.In(loc)
if start.IsZero() {
return ""
}
return start.Format("01-02 15:04") + " ~ " + end.Format("15:04")
}
// AppointmentMessage 访客预约消息参数
type AppointmentMessage struct {
VisitorList string // 所有访客信息(已格式化,每行一条用<br>连接)
EmployeeName string
VisitTime string
VisitPurpose string
Remark string
VehicleInfo string // 车辆信息(已格式化,每行一条用<br>连接)
AppointmentID uint
}
// AppointmentResultMessage 预约审批结果消息参数
type AppointmentResultMessage struct {
ApproverName string
Action string // "通过" / "拒绝"
Comment string
AppointmentID uint
VisitTime string
VisitorName string
}
// BuildNewAppointmentMsg 构建新预约提醒消息(发送给被访员工)
func BuildNewAppointmentMsg(m *AppointmentMessage) (title, markdown string) {
title = "📋 新的访客预约"
text := "有新的访客预约<br><br>"
// 访客
text += "【访客】<br>"
if m.VisitorList != "" {
text += m.VisitorList + "<br>"
}
// 接待信息
text += "**接待员工:** " + m.EmployeeName + "<br>"
text += "**访问时间:** " + m.VisitTime + "<br>"
if m.VisitPurpose != "" {
text += "**来访目的:** " + m.VisitPurpose + "<br>"
}
text += "<br>"
// 车辆
if m.VehicleInfo != "" {
text += "**车辆信息:**<br>" + m.VehicleInfo
}
// 备注
if m.Remark != "" {
text += "**备注:** " + m.Remark
}
text += "<br><br>请及时前往微信小程序“四川凌空天行”处理预约申请。"
return title, text
}
// BuildApproveNotificationMsg 构建审批通知消息(发送给下一节点审批人)
func BuildApproveNotificationMsg(m *AppointmentResultMessage) (title, markdown string) {
title = fmt.Sprintf("✅ 审批通过 - %s", m.ApproverName)
text := "审批通过<br><br>"
text += "**审批人:** " + m.ApproverName + "<br>"
text += "**结果:** 通过<br>"
if m.Comment != "" {
text += "**审批意见:** " + m.Comment + "<br>"
}
text += "**访问时间:** " + m.VisitTime + "<br>"
text += "**访客:** " + m.VisitorName + "<br>"
text += "<br>请前往小程序处理您的审批任务。"
return title, text
}
// BuildRejectNotificationMsg 构建审批拒绝通知消息(发送给下一节点审批人)
func BuildRejectNotificationMsg(m *AppointmentResultMessage) (title, markdown string) {
title = fmt.Sprintf("❌ 审批已终止 - %s", m.ApproverName)
text := "审批已终止<br><br>"
text += "**拒绝人:** " + m.ApproverName + "<br>"
text += "**结果:** 拒绝<br>"
if m.Comment != "" {
text += "**拒绝原因:** " + m.Comment + "<br>"
}
text += "**访问时间:** " + m.VisitTime + "<br>"
text += "**访客:** " + m.VisitorName + "<br>"
text += "<br>该预约申请已被拒绝,无需继续处理。"
return title, text
}
// BuildSameNodeCancelledMsg 构建同节点已取消通知消息
// 当同一节点有多人审批(非会签),其中一人已审时,通知其他待审人
func BuildSameNodeCancelledMsg(approvedUserName, comment, visitTime string) (title, markdown string) {
title = fmt.Sprintf(" %s 已审批", approvedUserName)
text := "审批状态更新<br><br>"
text += "**已审批人:** " + approvedUserName + "<br>"
if comment != "" {
text += "**备注:** " + comment + "<br>"
}
text += "**访问时间:** " + visitTime + "<br>"
text += "<br>该节点已有其他审批人处理,此任务已自动取消。"
return title, text
}

View File

@@ -0,0 +1,120 @@
// MinIO 对象存储客户端封装
package minio
import (
"bytes"
"context"
"fmt"
"io"
"path/filepath"
"time"
myconfig "com.sclktx/m/v2/internal/pkg/config"
"github.com/google/uuid"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
// Client MinIO 客户端
type Client struct {
client *minio.Client
config *myconfig.MinIOConfig
}
// NewClient 创建 MinIO 客户端
func NewClient(cfg *myconfig.MinIOConfig) (*Client, error) {
minioClient, err := minio.New(cfg.Endpoint, &minio.Options{
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
Secure: cfg.UseSSL,
})
if err != nil {
return nil, fmt.Errorf("创建 MinIO 客户端失败:%w", err)
}
// 确保 bucket 存在
ctx := context.Background()
exists, err := minioClient.BucketExists(ctx, cfg.Bucket)
if err != nil {
return nil, fmt.Errorf("检查 bucket 失败:%w", err)
}
if !exists {
// 创建 bucket
err = minioClient.MakeBucket(ctx, cfg.Bucket, minio.MakeBucketOptions{})
if err != nil {
return nil, fmt.Errorf("创建 bucket 失败:%w", err)
}
}
return &Client{
client: minioClient,
config: cfg,
}, nil
}
// UploadFile 上传文件到 MinIO
// 返回文件的公开访问 URL
func (c *Client) UploadFile(ctx context.Context, reader io.Reader, objectName string, contentType string) (string, error) {
// 上传文件
info, err := c.client.PutObject(ctx, c.config.Bucket, objectName, reader, -1, minio.PutObjectOptions{
ContentType: contentType,
})
if err != nil {
return "", fmt.Errorf("上传文件失败:%w", err)
}
// 生成公开访问 URL不带签名永久有效
// 如果配置了 base_url可带协议如 https://),直接使用
// 否则用 endpoint需自行保证协议正确
urlBase := c.config.BaseURL
if urlBase == "" {
urlBase = c.config.Endpoint
}
url := fmt.Sprintf("%s/%s/%s", urlBase, c.config.Bucket, info.Key)
return url, nil
}
// UploadImage 上传图片文件
// 自动生成唯一的文件名UUID + 时间戳 + 扩展名)
func (c *Client) UploadImage(ctx context.Context, data []byte, filename string) (string, error) {
// 生成唯一的文件名
ext := filepath.Ext(filename)
if ext == "" {
ext = ".jpg" // 默认扩展名
}
// 使用 UUID + 时间戳生成唯一文件名
objectName := fmt.Sprintf("images/%s_%s%s",
time.Now().Format("20060102150405"),
uuid.New().String()[:8],
ext,
)
// 判断内容类型
contentType := "image/jpeg"
switch ext {
case ".png":
contentType = "image/png"
case ".gif":
contentType = "image/gif"
case ".webp":
contentType = "image/webp"
case ".jpg", ".jpeg":
contentType = "image/jpeg"
}
// 上传
reader := bytes.NewReader(data)
return c.UploadFile(ctx, reader, objectName, contentType)
}
// GetClient 获取原始 MinIO 客户端
func (c *Client) GetClient() *minio.Client {
return c.client
}
// GetConfig 获取配置
func (c *Client) GetConfig() *myconfig.MinIOConfig {
return c.config
}

View File

@@ -0,0 +1,8 @@
package templates
// 微信订阅消息模板 ID
const (
TMPL_VISIT = "ZSbJDf3HzOjAhbN3IRvyljS-ffmR9yzxGdt_yEwYxHI" // 访客预约通知
TMPL_RESULT = "7tFHeTmubiHMhAaJGZB5lMLqlhU1VrUUgSjYp1Z05cY" // 访客申请结果通知
TMPL_CANCEL = "I_fTh-qE3cY8kTSb4VU3v1cJFEYG9V7eEE8cNBb9C00" // 来访预约取消通知
)

View File

@@ -0,0 +1,179 @@
// 微信小程序 SDK 封装,使用 silenceper/wechat/v2
package wechat
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"strings"
myconfig "com.sclktx/m/v2/internal/pkg/config"
"github.com/silenceper/wechat/v2"
"github.com/silenceper/wechat/v2/cache"
miniProgram "github.com/silenceper/wechat/v2/miniprogram"
"github.com/silenceper/wechat/v2/miniprogram/auth"
"github.com/silenceper/wechat/v2/miniprogram/subscribe"
wechatConfig "github.com/silenceper/wechat/v2/miniprogram/config"
"github.com/sirupsen/logrus"
)
// MiniProgramClient 微信小程序客户端
type MiniProgramClient struct {
mp *miniProgram.MiniProgram
}
// NewMiniProgramClient 创建小程序客户端
func NewMiniProgramClient(cfg *myconfig.WechatConfig) *MiniProgramClient {
// 开启 wechat SDK 日志(开发阶段使用 DebugLevel生产环境可改为 WarnLevel
logrus.SetOutput(os.Stdout)
logrus.SetLevel(logrus.DebugLevel)
logrus.SetFormatter(&logrus.TextFormatter{
FullTimestamp: true,
TimestampFormat: "2006-01-02 15:04:05",
})
wc := wechat.NewWechat()
// 使用内存缓存 access_token生产环境可换 Redis
memoryCache := cache.NewMemory()
mp := wc.GetMiniProgram(&wechatConfig.Config{
AppID: cfg.AppID,
AppSecret: cfg.AppSecret,
Cache: memoryCache,
})
return &MiniProgramClient{mp: mp}
}
// Code2Session 用 wx.login 返回的 code 换取 openid 和 session_key
func (c *MiniProgramClient) Code2Session(code string) (*auth.ResCode2Session, error) {
result, err := c.mp.GetAuth().Code2Session(code)
if err != nil {
return nil, fmt.Errorf("code2session failed: %w", err)
}
if result.ErrCode != 0 {
return nil, fmt.Errorf("code2session error: %d %s", result.ErrCode, result.ErrMsg)
}
return &result, nil
}
// DecryptPhone 解密手机号(需 session_key + encrypted_data + iv
func (c *MiniProgramClient) DecryptPhone(sessionKey, encryptedData, iv string) (string, error) {
phoneInfo, err := c.mp.GetEncryptor().Decrypt(sessionKey, encryptedData, iv)
if err != nil {
return "", fmt.Errorf("decrypt phone failed: %w", err)
}
return phoneInfo.PhoneNumber, nil
}
// GetAccessToken 获取小程序 access_token
func (c *MiniProgramClient) GetAccessToken() (string, error) {
token, err := c.mp.GetContext().GetAccessToken()
if err != nil {
return "", fmt.Errorf("get access_token failed: %w", err)
}
return token, nil
}
// UploadImgResponse 微信上传图片接口返回结构
type UploadImgResponse struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
URL string `json:"url"` // 微信返回的图片 URL
}
// UploadImg 上传图片到微信服务器(作为永久素材)
// imageData: 图片二进制数据
// filename: 文件名
func (c *MiniProgramClient) UploadImg(imageData []byte, filename string) (*UploadImgResponse, error) {
// 获取 access_token
accessToken, err := c.GetAccessToken()
if err != nil {
return nil, err
}
// 构造 multipart/form-data 请求
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
// 添加 media 字段(微信要求的字段名)
part, err := writer.CreateFormFile("media", filename)
if err != nil {
return nil, fmt.Errorf("create form file failed: %w", err)
}
if _, err := part.Write(imageData); err != nil {
return nil, fmt.Errorf("write image data failed: %w", err)
}
if err := writer.Close(); err != nil {
return nil, fmt.Errorf("close writer failed: %w", err)
}
// 发送请求
url := fmt.Sprintf("https://api.weixin.qq.com/cgi-bin/media/uploadimg?access_token=%s", accessToken)
req, err := http.NewRequest("POST", url, body)
if err != nil {
return nil, fmt.Errorf("create request failed: %w", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("upload img request failed: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response failed: %w", err)
}
var result UploadImgResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("parse response failed: %w, body: %s", err, string(respBody))
}
if result.ErrCode != 0 {
return nil, fmt.Errorf("wechat upload img error: %d %s", result.ErrCode, result.ErrMsg)
}
// 微信返回的 URL 是 http:// 协议,统一替换为 https://
result.URL = strings.Replace(result.URL, "http://", "https://", 1)
return &result, nil
}
// SendSubscribeMessage 发送微信订阅消息
// openid: 接收用户的 openid
// templateID: 模板 ID
// data: 模板数据key 为模板字段名(如 thing1, date2value 为字段值
func (c *MiniProgramClient) SendSubscribeMessage(openid, templateID string, data map[string]string, page string) error {
msg := &subscribe.Message{
ToUser: openid,
TemplateID: templateID,
Page: page,
Data: make(map[string]*subscribe.DataItem),
}
for k, v := range data {
msg.Data[k] = &subscribe.DataItem{Value: v}
}
err := c.mp.GetSubscribe().Send(msg)
if err != nil {
logrus.WithFields(logrus.Fields{
"openid": openid,
"template_id": templateID,
}).Errorf("微信订阅消息推送失败: %v", err)
} else {
logrus.WithFields(logrus.Fields{
"openid": openid,
"template_id": templateID,
}).Info("微信订阅消息推送成功")
}
return err
}