'init'
This commit is contained in:
121
sc-lktx-backend/internal/pkg/config/config.go
Normal file
121
sc-lktx-backend/internal/pkg/config/config.go
Normal 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
|
||||
}
|
||||
72
sc-lktx-backend/internal/pkg/database/database.go
Normal file
72
sc-lktx-backend/internal/pkg/database/database.go
Normal 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
|
||||
}
|
||||
58
sc-lktx-backend/internal/pkg/database/init_data.go
Normal file
58
sc-lktx-backend/internal/pkg/database/init_data.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
189
sc-lktx-backend/internal/pkg/dingtalk/dingtalk.go
Normal file
189
sc-lktx-backend/internal/pkg/dingtalk/dingtalk.go
Normal 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
|
||||
}
|
||||
119
sc-lktx-backend/internal/pkg/dingtalk/templates.go
Normal file
119
sc-lktx-backend/internal/pkg/dingtalk/templates.go
Normal 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
|
||||
}
|
||||
120
sc-lktx-backend/internal/pkg/minio/client.go
Normal file
120
sc-lktx-backend/internal/pkg/minio/client.go
Normal 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
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package templates
|
||||
|
||||
// 微信订阅消息模板 ID
|
||||
const (
|
||||
TMPL_VISIT = "ZSbJDf3HzOjAhbN3IRvyljS-ffmR9yzxGdt_yEwYxHI" // 访客预约通知
|
||||
TMPL_RESULT = "7tFHeTmubiHMhAaJGZB5lMLqlhU1VrUUgSjYp1Z05cY" // 访客申请结果通知
|
||||
TMPL_CANCEL = "I_fTh-qE3cY8kTSb4VU3v1cJFEYG9V7eEE8cNBb9C00" // 来访预约取消通知
|
||||
)
|
||||
179
sc-lktx-backend/internal/pkg/wechat/wechat.go
Normal file
179
sc-lktx-backend/internal/pkg/wechat/wechat.go
Normal 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, date2),value 为字段值
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user