'init'
This commit is contained in:
95
sc-lktx-backend/internal/admin/admin_auth.go
Normal file
95
sc-lktx-backend/internal/admin/admin_auth.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
|
||||
"com.sclktx/m/v2/internal/common/model"
|
||||
"com.sclktx/m/v2/internal/common/response"
|
||||
"com.sclktx/m/v2/internal/common/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AdminAuthHandler struct {
|
||||
db *gorm.DB
|
||||
jwtSecret string
|
||||
expireHours int
|
||||
}
|
||||
|
||||
func NewAdminAuthHandler(db *gorm.DB, jwtSecret string, expireHours int) *AdminAuthHandler {
|
||||
return &AdminAuthHandler{db: db, jwtSecret: jwtSecret, expireHours: expireHours}
|
||||
}
|
||||
|
||||
type AdminLoginRequest struct {
|
||||
Account string `json:"account" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
// @Summary 管理员登录
|
||||
// @Description 管理员账号密码登录,返回JWT token
|
||||
// @Tags 管理员
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body AdminLoginRequest true "管理员登录信息"
|
||||
// @Success 200 {object} response.Response{data=object{token=string,admin_id=uint,account=string,name=string,is_super=bool}} "成功"
|
||||
// @Router /admin/login [post]
|
||||
|
||||
func (h *AdminAuthHandler) Login(c *gin.Context) {
|
||||
var req AdminLoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
var admin model.AdminUser
|
||||
if err := h.db.Where("account = ? AND status = 1", req.Account).First(&admin).Error; err != nil {
|
||||
response.Unauthorized(c, "账号或密码错误")
|
||||
return
|
||||
}
|
||||
|
||||
hashed := fmt.Sprintf("%x", sha256.Sum256([]byte(req.Password)))
|
||||
if admin.Password != hashed {
|
||||
response.Unauthorized(c, "账号或密码错误")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := utils.GenerateAdminToken(admin.ID, admin.Account, h.jwtSecret, h.expireHours)
|
||||
if err != nil {
|
||||
response.InternalError(c, "生成令牌失败")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"token": token,
|
||||
"admin_id": admin.ID,
|
||||
"account": admin.Account,
|
||||
"name": admin.Name,
|
||||
"is_super": admin.IsSuper,
|
||||
})
|
||||
}
|
||||
|
||||
// @Summary 获取管理员信息
|
||||
// @Description 获取当前登录管理员的个人信息
|
||||
// @Tags 管理员
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} response.Response{data=object{id=uint,account=string,name=string,is_super=bool}} "成功"
|
||||
// @Router /admin/profile [get]
|
||||
|
||||
func (h *AdminAuthHandler) GetProfile(c *gin.Context) {
|
||||
adminID, _ := c.Get("admin_id")
|
||||
var admin model.AdminUser
|
||||
if err := h.db.First(&admin, adminID).Error; err != nil {
|
||||
response.NotFound(c, "管理员不存在")
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{
|
||||
"id": admin.ID,
|
||||
"account": admin.Account,
|
||||
"name": admin.Name,
|
||||
"is_super": admin.IsSuper,
|
||||
})
|
||||
}
|
||||
1898
sc-lktx-backend/internal/admin/handler.go
Normal file
1898
sc-lktx-backend/internal/admin/handler.go
Normal file
File diff suppressed because it is too large
Load Diff
380
sc-lktx-backend/internal/auth/handler.go
Normal file
380
sc-lktx-backend/internal/auth/handler.go
Normal file
@@ -0,0 +1,380 @@
|
||||
// 认证模块:微信登录、用户信息获取和更新
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"com.sclktx/m/v2/internal/common/model"
|
||||
"com.sclktx/m/v2/internal/common/response"
|
||||
"com.sclktx/m/v2/internal/common/utils"
|
||||
"com.sclktx/m/v2/internal/pkg/config"
|
||||
"com.sclktx/m/v2/internal/pkg/wechat"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
db *gorm.DB
|
||||
cfg *config.Config
|
||||
wxClient *wechat.MiniProgramClient
|
||||
}
|
||||
|
||||
func NewAuthHandler(db *gorm.DB, cfg *config.Config, wxClient *wechat.MiniProgramClient) *AuthHandler {
|
||||
return &AuthHandler{db: db, cfg: cfg, wxClient: wxClient}
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
Token string `json:"token"`
|
||||
UserInfo *UserInfoVO `json:"user_info"`
|
||||
}
|
||||
|
||||
type UserInfoVO struct {
|
||||
ID uint `json:"id"`
|
||||
Openid string `json:"openid"`
|
||||
Nickname string `json:"nickname"`
|
||||
RealName string `json:"real_name"`
|
||||
Phone string `json:"phone"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
RoleCode string `json:"role"`
|
||||
RoleName string `json:"role_name"`
|
||||
Status int `json:"status"`
|
||||
IsFormalEmployee bool `json:"is_formal_employee"`
|
||||
DepartmentName string `json:"department_name,omitempty"`
|
||||
Position string `json:"position,omitempty"`
|
||||
}
|
||||
|
||||
// Login 微信登录:code 换 openid → 查找或注册用户 → 生成 JWT
|
||||
// @Summary 微信登录
|
||||
// @Description 使用微信小程序 code 登录,返回 JWT token 和用户信息
|
||||
// @Tags 认证
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body LoginRequest true "微信登录code"
|
||||
// @Success 200 {object} response.Response{data=LoginResponse} "登录成功"
|
||||
// @Router /auth/login [post]
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
var req LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 调用微信 code2session 换取 openid
|
||||
session, err := h.wxClient.Code2Session(req.Code)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "微信登录失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
openid := session.OpenID
|
||||
|
||||
var user model.User
|
||||
result := h.db.Where("openid = ?", openid).First(&user)
|
||||
|
||||
if result.Error != nil {
|
||||
// 新用户注册,分配初始角色(默认访客)
|
||||
user = model.User{Openid: openid, Role: "visitor", Status: 1, AvatarURL: h.cfg.System.DefaultAvatar}
|
||||
if err := h.db.Create(&user).Error; err != nil {
|
||||
response.InternalError(c, "创建用户失败")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
roleCode := user.Role
|
||||
if roleCode == "" {
|
||||
roleCode = "visitor"
|
||||
}
|
||||
|
||||
token, err := utils.GenerateToken(user.ID, roleCode, h.cfg.JWT.Secret, h.cfg.JWT.ExpireHours)
|
||||
if err != nil {
|
||||
response.InternalError(c, "生成令牌失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 查询用户部门信息(从 UserDepartment 关联表获取)
|
||||
deptName, position := "", ""
|
||||
var ud model.UserDepartment
|
||||
if err := h.db.Where("user_id = ?", user.ID).First(&ud).Error; err == nil {
|
||||
deptName = ud.DepartmentName
|
||||
position = ud.Position
|
||||
}
|
||||
|
||||
response.Success(c, LoginResponse{
|
||||
Token: token,
|
||||
UserInfo: &UserInfoVO{
|
||||
ID: user.ID, Openid: user.Openid, Nickname: user.Nickname,
|
||||
RealName: user.RealName, Phone: user.Phone, AvatarURL: user.AvatarURL,
|
||||
RoleCode: roleCode, RoleName: roleNameMap(roleCode),
|
||||
Status: user.Status,
|
||||
IsFormalEmployee: roleCode == "employee",
|
||||
DepartmentName: deptName,
|
||||
Position: position,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// GetUserInfo 获取当前用户信息
|
||||
// @Summary 获取当前用户信息
|
||||
// @Description 获取当前登录用户的详细信息,包含角色、部门、职位等
|
||||
// @Tags 认证
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} response.Response{data=UserInfoVO} "用户信息"
|
||||
// @Router /auth/userinfo [get]
|
||||
func (h *AuthHandler) GetUserInfo(c *gin.Context) {
|
||||
userID := utils.GetUserID(c)
|
||||
var user model.User
|
||||
if err := h.db.First(&user, userID).Error; err != nil {
|
||||
response.Unauthorized(c, "用户不存在,请重新登录")
|
||||
return
|
||||
}
|
||||
roleCode := user.Role
|
||||
if roleCode == "" {
|
||||
roleCode = "visitor"
|
||||
}
|
||||
|
||||
// 查询用户部门信息(从 UserDepartment 关联表获取)
|
||||
deptName, position := "", ""
|
||||
var ud model.UserDepartment
|
||||
if err := h.db.Where("user_id = ?", userID).First(&ud).Error; err == nil {
|
||||
deptName = ud.DepartmentName
|
||||
position = ud.Position
|
||||
}
|
||||
|
||||
response.Success(c, UserInfoVO{
|
||||
ID: user.ID, Openid: user.Openid, Nickname: user.Nickname,
|
||||
RealName: user.RealName, Phone: user.Phone, AvatarURL: user.AvatarURL,
|
||||
RoleCode: roleCode, RoleName: roleNameMap(roleCode),
|
||||
Status: user.Status,
|
||||
IsFormalEmployee: roleCode == "employee",
|
||||
DepartmentName: deptName,
|
||||
Position: position,
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateUserInfo 更新用户信息(部分更新)
|
||||
// @Summary 更新用户信息
|
||||
// @Description 部分更新当前用户的昵称、真实姓名、头像
|
||||
// @Tags 认证
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param body body object{nickname=string,real_name=string,avatar_url=string} true "要更新的字段"
|
||||
// @Success 200 {object} response.Response "更新成功"
|
||||
// @Router /auth/userinfo [put]
|
||||
func (h *AuthHandler) UpdateUserInfo(c *gin.Context) {
|
||||
userID := utils.GetUserID(c)
|
||||
var req struct {
|
||||
Nickname string `json:"nickname"`
|
||||
RealName string `json:"real_name"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
updates := map[string]interface{}{}
|
||||
if req.Nickname != "" {
|
||||
updates["nickname"] = req.Nickname
|
||||
}
|
||||
if req.RealName != "" {
|
||||
updates["real_name"] = req.RealName
|
||||
}
|
||||
if req.AvatarURL != "" {
|
||||
updates["avatar_url"] = req.AvatarURL
|
||||
}
|
||||
if err := h.db.Model(&model.User{}).Where("id = ?", userID).Updates(updates).Error; err != nil {
|
||||
response.InternalError(c, "更新失败")
|
||||
return
|
||||
}
|
||||
response.SuccessWithMessage(c, "更新成功", nil)
|
||||
}
|
||||
|
||||
// UpdateAvatar 更新用户头像
|
||||
// @Summary 更新用户头像
|
||||
// @Description 接受微信图片 URL,直接更新用户头像
|
||||
// @Tags 用户
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param userId path int true "用户ID"
|
||||
// @Param body body object{url=string} true "微信图片URL"
|
||||
// @Success 200 {object} response.Response "成功"
|
||||
// @Router /v1/user/{userId}/avatar [put]
|
||||
func (h *AuthHandler) UpdateAvatar(c *gin.Context) {
|
||||
userID, err := strconv.ParseUint(c.Param("userId"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
URL string `json:"url" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
if err := h.db.Model(&model.User{}).Where("id = ?", userID).Update("avatar_url", req.URL).Error; err != nil {
|
||||
response.InternalError(c, "更新失败")
|
||||
return
|
||||
}
|
||||
response.SuccessWithMessage(c, "更新成功", nil)
|
||||
}
|
||||
|
||||
// BindPhone 绑定手机号(微信解密)
|
||||
// @Summary 绑定手机号
|
||||
// @Description 通过微信加密数据解密并绑定手机号
|
||||
// @Tags 认证
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param body body object{code=string,encrypted_data=string,iv=string} true "微信加密数据"
|
||||
// @Success 200 {object} response.Response "绑定成功"
|
||||
// @Router /auth/phone [post]
|
||||
func (h *AuthHandler) BindPhone(c *gin.Context) {
|
||||
userID := utils.GetUserID(c)
|
||||
var req struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
EncryptedData string `json:"encrypted_data" binding:"required"`
|
||||
Iv string `json:"iv" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
// 用 code 换取 session_key
|
||||
session, err := h.wxClient.Code2Session(req.Code)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "微信授权失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 解密手机号
|
||||
phone, err := h.wxClient.DecryptPhone(session.SessionKey, req.EncryptedData, req.Iv)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "手机号解密失败")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]interface{}{
|
||||
"phone": phone, "phone_verified": true,
|
||||
}).Error; err != nil {
|
||||
response.InternalError(c, "绑定失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 自动匹配待确认员工:手机号匹配则升级为正式员工
|
||||
var pending model.PendingEmployee
|
||||
if err := h.db.Where("phone = ? AND is_matched = ?", phone, false).First(&pending).Error; err == nil {
|
||||
now := time.Now()
|
||||
// 创建 UserDepartment
|
||||
ud := model.UserDepartment{
|
||||
UserID: userID,
|
||||
DepartmentID: pending.DepartmentID,
|
||||
DepartmentName: pending.DepartmentName,
|
||||
Position: pending.Position,
|
||||
EmployeeType: pending.EmployeeType,
|
||||
EmployeeStatus: 1,
|
||||
}
|
||||
h.db.Where("user_id = ?", userID).Assign(ud).FirstOrCreate(&model.UserDepartment{UserID: userID})
|
||||
|
||||
// 升级用户角色
|
||||
h.db.Model(&model.User{}).Where("id = ?", userID).Update("role", "employee")
|
||||
|
||||
// 标记已匹配
|
||||
h.db.Model(&pending).Updates(map[string]interface{}{
|
||||
"is_matched": true,
|
||||
"matched_user_id": &userID,
|
||||
"matched_at": &now,
|
||||
})
|
||||
}
|
||||
|
||||
response.SuccessWithMessage(c, "绑定成功", nil)
|
||||
}
|
||||
|
||||
// MockLogin 模拟登录(开发调试用):直接登录 id=1 的用户
|
||||
// @Summary 模拟登录
|
||||
// @Description 直接登录 id=1 的账号,返回与微信登录一致的 token 和用户信息
|
||||
// @Tags 认证
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} response.Response{data=LoginResponse} "登录成功"
|
||||
// @Router /auth/mock-login [post]
|
||||
func (h *AuthHandler) MockLogin(c *gin.Context) {
|
||||
var user model.User
|
||||
if err := h.db.First(&user, 1).Error; err != nil {
|
||||
response.NotFound(c, "用户不存在")
|
||||
return
|
||||
}
|
||||
|
||||
roleCode := user.Role
|
||||
if roleCode == "" {
|
||||
roleCode = "visitor"
|
||||
}
|
||||
|
||||
token, err := utils.GenerateToken(user.ID, roleCode, h.cfg.JWT.Secret, h.cfg.JWT.ExpireHours)
|
||||
if err != nil {
|
||||
response.InternalError(c, "生成令牌失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 查询用户部门信息(从 UserDepartment 关联表获取)
|
||||
deptName, position := "", ""
|
||||
var ud model.UserDepartment
|
||||
if err := h.db.Where("user_id = ?", user.ID).First(&ud).Error; err == nil {
|
||||
deptName = ud.DepartmentName
|
||||
position = ud.Position
|
||||
}
|
||||
|
||||
response.Success(c, LoginResponse{
|
||||
Token: token,
|
||||
UserInfo: &UserInfoVO{
|
||||
ID: user.ID, Openid: user.Openid, Nickname: user.Nickname,
|
||||
RealName: user.RealName, Phone: user.Phone, AvatarURL: user.AvatarURL,
|
||||
RoleCode: roleCode, RoleName: roleNameMap(roleCode),
|
||||
Status: user.Status,
|
||||
IsFormalEmployee: roleCode == "employee",
|
||||
DepartmentName: deptName,
|
||||
Position: position,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) RegisterRoutes(r *gin.RouterGroup) {
|
||||
auth := r.Group("/auth")
|
||||
{
|
||||
auth.POST("/login", h.Login)
|
||||
auth.POST("/mock-login", h.MockLogin)
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterAuthRoutes 注册需要 JWT 认证的 auth 路由
|
||||
func (h *AuthHandler) RegisterAuthRoutes(r *gin.RouterGroup) {
|
||||
auth := r.Group("/auth")
|
||||
{
|
||||
auth.GET("/userinfo", h.GetUserInfo)
|
||||
auth.PUT("/userinfo", h.UpdateUserInfo)
|
||||
auth.POST("/phone", h.BindPhone)
|
||||
}
|
||||
// 头像更新
|
||||
r.PUT("/user/:userId/avatar", h.UpdateAvatar)
|
||||
}
|
||||
|
||||
// roleNameMap 角色编码 → 中文名
|
||||
func roleNameMap(code string) string {
|
||||
switch code {
|
||||
case "employee":
|
||||
return "员工"
|
||||
case "guard":
|
||||
return "保安"
|
||||
default:
|
||||
return "访客"
|
||||
}
|
||||
}
|
||||
34
sc-lktx-backend/internal/common/middleware/admin_jwt.go
Normal file
34
sc-lktx-backend/internal/common/middleware/admin_jwt.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"com.sclktx/m/v2/internal/common/response"
|
||||
"com.sclktx/m/v2/internal/common/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func AdminJWTAuth(secret string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
response.Unauthorized(c, "未提供认证令牌")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
var tokenStr string
|
||||
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
|
||||
tokenStr = authHeader[7:]
|
||||
} else {
|
||||
tokenStr = authHeader
|
||||
}
|
||||
claims, err := utils.ParseAdminToken(tokenStr, secret)
|
||||
if err != nil {
|
||||
response.Unauthorized(c, "管理员令牌无效或已过期")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set("admin_id", claims.AdminID)
|
||||
c.Set("admin_account", claims.Account)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
25
sc-lktx-backend/internal/common/middleware/cors.go
Normal file
25
sc-lktx-backend/internal/common/middleware/cors.go
Normal file
@@ -0,0 +1,25 @@
|
||||
// 中间件:Gin 的中间件是 func(*gin.Context),通过 c.Next() 控制执行链
|
||||
// 执行顺序:请求进入 → 中间件1(c.Next前) → 中间件2(c.Next前) → Handler → 中间件2(c.Next后) → 中间件1(c.Next后) → 响应
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CORS 跨域中间件,允许 H5 调试时跨域访问
|
||||
func CORS() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Expose-Headers", "Content-Length, Content-Type")
|
||||
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
45
sc-lktx-backend/internal/common/middleware/jwt.go
Normal file
45
sc-lktx-backend/internal/common/middleware/jwt.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"com.sclktx/m/v2/internal/common/response"
|
||||
"com.sclktx/m/v2/internal/common/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// JWTAuth JWT 认证中间件
|
||||
// 1. 从 Authorization header 提取 Bearer Token
|
||||
// 2. 解析验证 JWT(签名 + 过期时间)
|
||||
// 3. 将 user_id、role_code、role_codes 注入 gin.Context
|
||||
// 4. 后续 Handler 通过 utils.GetUserID(c) / utils.GetRoleCodes(c) 获取
|
||||
func JWTAuth(secret string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
response.Unauthorized(c, "未提供认证令牌")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
response.Unauthorized(c, "认证格式错误")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := utils.ParseToken(parts[1], secret)
|
||||
if err != nil {
|
||||
response.Unauthorized(c, "认证令牌无效或已过期")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 注入用户信息到 Context
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("role_code", claims.RoleCode)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
31
sc-lktx-backend/internal/common/middleware/logger.go
Normal file
31
sc-lktx-backend/internal/common/middleware/logger.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Logger 请求日志中间件,记录每个请求的方法、路径、状态码、耗时
|
||||
// 在 c.Next() 前后分别记录开始和结束时间
|
||||
func Logger(logger *zap.Logger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
path := c.Request.URL.Path
|
||||
query := c.Request.URL.RawQuery
|
||||
|
||||
c.Next() // 执行后续中间件和 Handler
|
||||
|
||||
cost := time.Since(start)
|
||||
logger.Info("HTTP Request",
|
||||
zap.Int("status", c.Writer.Status()),
|
||||
zap.String("method", c.Request.Method),
|
||||
zap.String("path", path),
|
||||
zap.String("query", query),
|
||||
zap.String("ip", c.ClientIP()),
|
||||
zap.Duration("cost", cost),
|
||||
zap.String("errors", c.Errors.ByType(gin.ErrorTypePrivate).String()),
|
||||
)
|
||||
}
|
||||
}
|
||||
15
sc-lktx-backend/internal/common/model/base.go
Normal file
15
sc-lktx-backend/internal/common/model/base.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// BaseModel 基础模型,所有表内嵌此结构,提供 ID、时间戳、软删除
|
||||
type BaseModel struct {
|
||||
ID uint `gorm:"primarykey;comment:主键ID" json:"id"`
|
||||
CreatedAt time.Time `gorm:"comment:创建时间" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"comment:更新时间" json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间(软删除)" json:"deleted_at,omitempty"`
|
||||
}
|
||||
332
sc-lktx-backend/internal/common/model/models.go
Normal file
332
sc-lktx-backend/internal/common/model/models.go
Normal file
@@ -0,0 +1,332 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// User 用户模型
|
||||
type User struct {
|
||||
BaseModel
|
||||
Openid string `gorm:"uniqueIndex;size:100;not null;comment:微信OpenID" json:"openid"`
|
||||
Nickname string `gorm:"size:50;comment:微信昵称" json:"nickname"`
|
||||
RealName string `gorm:"size:50;comment:真实姓名" json:"real_name"`
|
||||
Phone string `gorm:"size:20;comment:手机号" json:"phone"`
|
||||
PhoneVerified bool `gorm:"default:false;comment:手机号是否已验证" json:"phone_verified"`
|
||||
AvatarURL string `gorm:"size:500;comment:头像URL" json:"avatar_url"`
|
||||
FaceImageURL string `gorm:"size:500;comment:人脸图片URL" json:"face_image_url"`
|
||||
Role string `gorm:"size:20;default:visitor;comment:角色(visitor/employee/guard)" json:"role"`
|
||||
Status int `gorm:"default:1;comment:用户状态(1正常 0禁用)" json:"status"`
|
||||
}
|
||||
|
||||
func (User) TableName() string { return "users" }
|
||||
|
||||
// UserDepartment 员工-部门关联表
|
||||
type UserDepartment struct {
|
||||
ID uint `gorm:"primarykey;comment:主键ID" json:"id"`
|
||||
CreatedAt time.Time `gorm:"comment:创建时间" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"comment:更新时间" json:"updated_at"`
|
||||
UserID uint `gorm:"uniqueIndex;not null;comment:用户ID" json:"user_id"`
|
||||
User *User `gorm:"foreignKey:UserID;comment:关联用户信息" json:"user,omitempty"`
|
||||
DepartmentID uint `gorm:"not null;index;comment:部门ID" json:"department_id"`
|
||||
Department *Department `gorm:"foreignKey:DepartmentID;comment:关联部门信息" json:"department,omitempty"`
|
||||
DepartmentName string `gorm:"size:100;comment:部门名称(冗余)" json:"department_name"`
|
||||
Position string `gorm:"size:100;comment:职位" json:"position"`
|
||||
EmployeeType string `gorm:"size:30;default:employee;comment:员工类型" json:"employee_type"`
|
||||
EmployeeStatus int `gorm:"default:1;comment:员工状态(1在职 0离职)" json:"employee_status"`
|
||||
DingTalkUserID string `gorm:"column:ding_talk_user_id;size:100;comment:钉钉用户unionId(从通讯录导入)" json:"dingtalk_user_id"`
|
||||
}
|
||||
|
||||
func (UserDepartment) TableName() string { return "user_departments" }
|
||||
|
||||
// Visitor 访客信息
|
||||
type Visitor struct {
|
||||
BaseModel
|
||||
UserID *uint `gorm:"comment:关联用户ID(可为空)" json:"user_id"`
|
||||
Company string `gorm:"size:200;comment:访客公司名称" json:"company"`
|
||||
Name string `gorm:"size:50;not null;comment:访客姓名" json:"name"`
|
||||
Phone string `gorm:"size:20;not null;comment:访客电话" json:"phone"`
|
||||
Gender int `gorm:"comment:性别(0未知 1男 2女)" json:"gender"`
|
||||
IDType string `gorm:"size:20;comment:证件类型" json:"id_type"`
|
||||
IDNumber string `gorm:"size:50;comment:证件号码" json:"id_number"`
|
||||
FaceVerified bool `gorm:"default:false;comment:是否已完成人脸验证" json:"face_verified"`
|
||||
PhoneVerified bool `gorm:"default:false;comment:是否已完成手机验证" json:"phone_verified"`
|
||||
}
|
||||
|
||||
func (Visitor) TableName() string { return "visitors" }
|
||||
|
||||
// Appointment 预约
|
||||
type Appointment struct {
|
||||
BaseModel
|
||||
VisitUserID uint `gorm:"comment:被访用户ID" json:"visit_user_id"`
|
||||
CreatorUserID uint `gorm:"comment:创建人用户ID" json:"creator_user_id"`
|
||||
Creator *User `gorm:"foreignKey:CreatorUserID;comment:创建人信息" json:"creator,omitempty"`
|
||||
VisitType string `gorm:"size:50;comment:来访目的" json:"visit_type"`
|
||||
VisitStartTime time.Time `gorm:"comment:预计来访开始时间" json:"visit_start_time"`
|
||||
VisitEndTime time.Time `gorm:"comment:预计来访结束时间" json:"visit_end_time"`
|
||||
Status int `gorm:"default:0;comment:预约状态(0审核中 1通过 2拒绝 3取消)" json:"status"`
|
||||
Remark string `gorm:"type:text;comment:备注" json:"remark"`
|
||||
Comment string `gorm:"type:text;comment:审核意见" json:"comment"`
|
||||
VisitPurpose string `gorm:"type:text;comment:来访目的" json:"visit_purpose"`
|
||||
VisitorCompany string `gorm:"size:200;comment:来访单位名" json:"visitor_company"`
|
||||
QrCodeURL string `gorm:"size:500;comment:入场二维码URL" json:"qr_code_url"`
|
||||
VisitorInfo string `gorm:"type:jsonb;comment:所有访客信息(JSON数组)" json:"visitor_info"`
|
||||
CreatorInfo string `gorm:"type:jsonb;comment:创建人信息(JSON)" json:"creator_info"`
|
||||
EmployeeInfo string `gorm:"type:jsonb;comment:被访人信息(JSON)" json:"employee_info"`
|
||||
GoodsInfo string `gorm:"type:jsonb;comment:携带物品信息(JSON)" json:"goods_info"`
|
||||
AreasInfo string `gorm:"type:jsonb;comment:到访区域信息(JSON)" json:"areas_info"`
|
||||
VehicleInfo string `gorm:"type:jsonb;comment:车辆信息(JSON数组)" json:"vehicle_info"`
|
||||
}
|
||||
|
||||
func (Appointment) TableName() string { return "appointments" }
|
||||
|
||||
// InviteCode 邀请码
|
||||
type InviteCode struct {
|
||||
Code string `gorm:"primaryKey;size:100;comment:邀请码" json:"code"`
|
||||
VisitUserID uint `gorm:"index;comment:被访人用户ID" json:"visit_user_id"`
|
||||
VisitUser *User `gorm:"foreignKey:VisitUserID;comment:被访人信息" json:"visit_user,omitempty"`
|
||||
CreatedAt time.Time `gorm:"comment:创建时间" json:"created_at"`
|
||||
ExpiresAt time.Time `gorm:"comment:过期时间" json:"expires_at"`
|
||||
}
|
||||
|
||||
func (InviteCode) TableName() string { return "invite_codes" }
|
||||
|
||||
// VisitRecord 访问记录
|
||||
type VisitRecord struct {
|
||||
BaseModel
|
||||
AppointmentID uint `gorm:"comment:关联的预约ID" json:"appointment_id"`
|
||||
Appointment *Appointment `gorm:"foreignKey:AppointmentID;comment:关联的预约信息" json:"appointment,omitempty"`
|
||||
GuardID uint `gorm:"comment:登记保安的用户ID" json:"guard_id"`
|
||||
Guard *User `gorm:"foreignKey:GuardID;comment:关联的保安用户信息" json:"guard,omitempty"`
|
||||
CheckType int `gorm:"comment:登记类型(1进场 2出场)" json:"check_type"`
|
||||
CheckTime time.Time `gorm:"comment:登记时间" json:"check_time"`
|
||||
IsValid bool `gorm:"default:true;comment:是否有效" json:"is_valid"`
|
||||
Remark string `gorm:"type:text;comment:备注" json:"remark"`
|
||||
}
|
||||
|
||||
func (VisitRecord) TableName() string { return "visit_records" }
|
||||
|
||||
// Invitation 邀请
|
||||
type Invitation struct {
|
||||
BaseModel
|
||||
EmployeeID uint `gorm:"comment:发起邀请的员工用户ID" json:"employee_id"`
|
||||
Employee *User `gorm:"foreignKey:EmployeeID;comment:关联的员工信息" json:"employee,omitempty"`
|
||||
InviteCode string `gorm:"size:100;uniqueIndex;not null;comment:邀请码" json:"invite_code"`
|
||||
VisitorName string `gorm:"size:50;comment:被邀请访客姓名" json:"visitor_name"`
|
||||
VisitorPhone string `gorm:"size:20;comment:被邀请访客电话" json:"visitor_phone"`
|
||||
VisitType string `gorm:"size:50;comment:来访目的" json:"visit_type"`
|
||||
VisitArea string `gorm:"size:200;comment:允许访问的区域" json:"visit_area"`
|
||||
ValidFrom time.Time `gorm:"comment:邀请生效时间" json:"valid_from"`
|
||||
ValidUntil time.Time `gorm:"comment:邀请失效时间" json:"valid_until"`
|
||||
MaxUseCount int `gorm:"default:1;comment:最大使用次数" json:"max_use_count"`
|
||||
UsedCount int `gorm:"default:0;comment:已使用次数" json:"used_count"`
|
||||
IsActive bool `gorm:"default:true;comment:是否有效" json:"is_active"`
|
||||
}
|
||||
|
||||
func (Invitation) TableName() string { return "invitations" }
|
||||
|
||||
// Banner 首页轮播图
|
||||
type Banner struct {
|
||||
BaseModel
|
||||
ImageURL string `gorm:"size:500;not null;comment:图片地址" json:"image_url"`
|
||||
JumpPath string `gorm:"size:500;comment:跳转链接" json:"jump_path"`
|
||||
IsJump bool `gorm:"default:false;comment:是否可跳转" json:"is_jump"`
|
||||
Sort int `gorm:"default:0;comment:排序号" json:"sort"`
|
||||
IsValid bool `gorm:"default:true;comment:是否有效" json:"is_valid"`
|
||||
}
|
||||
|
||||
func (Banner) TableName() string { return "banners" }
|
||||
|
||||
// Department 部门(树形结构)
|
||||
type Department struct {
|
||||
BaseModel
|
||||
Name string `gorm:"size:100;not null;comment:部门名称" json:"name"`
|
||||
ParentID *uint `gorm:"comment:上级部门ID" json:"parent_id"`
|
||||
Parent *Department `gorm:"foreignKey:ParentID;comment:上级部门" json:"parent,omitempty"`
|
||||
Children []Department `gorm:"foreignKey:ParentID;comment:子部门列表" json:"children,omitempty"`
|
||||
LeaderID *uint `gorm:"comment:部门负责人(部长)用户ID" json:"leader_id"`
|
||||
Leader *User `gorm:"foreignKey:LeaderID;comment:部门负责人信息" json:"leader,omitempty"`
|
||||
DeputyID *uint `gorm:"comment:部门副负责人(副部长)用户ID" json:"deputy_id"`
|
||||
Deputy *User `gorm:"foreignKey:DeputyID;comment:部门副负责人信息" json:"deputy,omitempty"`
|
||||
ApproverUserIDs string `gorm:"type:text;comment:部门指定审批人用户ID列表(JSON数组)" json:"approver_user_ids"`
|
||||
VpUserIDs string `gorm:"type:text;comment:分管领导用户ID列表(JSON数组)" json:"vp_user_ids"`
|
||||
Sort int `gorm:"default:0;comment:排序号" json:"sort"`
|
||||
Status int `gorm:"default:1;comment:状态(1启用 0禁用)" json:"status"`
|
||||
Description string `gorm:"size:200;comment:部门描述" json:"description"`
|
||||
}
|
||||
|
||||
func (Department) TableName() string { return "departments" }
|
||||
|
||||
// EmployeeCertification 角色认证申请
|
||||
type EmployeeCertification struct {
|
||||
BaseModel
|
||||
UserID uint `gorm:"not null;index;comment:申请人用户ID" json:"user_id"`
|
||||
User *User `gorm:"foreignKey:UserID;comment:关联的申请人信息" json:"user,omitempty"`
|
||||
RealName string `gorm:"size:50;not null;comment:真实姓名" json:"real_name"`
|
||||
Phone string `gorm:"size:20;not null;comment:手机号" json:"phone"`
|
||||
Role string `gorm:"size:20;default:员工;comment:角色类型(员工/保安)" json:"role"`
|
||||
Snapshot []string `gorm:"type:text;serializer:json;comment:材料截图URL列表" json:"snapshot"`
|
||||
Position string `gorm:"size:100;comment:职位" json:"position"`
|
||||
DepartmentID *uint `gorm:"comment:分配的部门ID(审核通过后设置)" json:"department_id"`
|
||||
Department *Department `gorm:"foreignKey:DepartmentID;comment:关联的部门信息" json:"department,omitempty"`
|
||||
Status int `gorm:"default:0;comment:审核状态(0待审核 1通过 2拒绝)" json:"status"`
|
||||
ApproverID *uint `gorm:"comment:审批人用户ID" json:"approver_id"`
|
||||
Approver *User `gorm:"foreignKey:ApproverID;comment:关联的审批人信息" json:"approver,omitempty"`
|
||||
Comment string `gorm:"type:text;comment:审核意见" json:"comment"`
|
||||
}
|
||||
|
||||
func (EmployeeCertification) TableName() string { return "employee_certifications" }
|
||||
|
||||
// Notice 系统通知
|
||||
type Notice struct {
|
||||
BaseModel
|
||||
Title string `gorm:"size:200;not null;comment:通知标题" json:"title"`
|
||||
Content string `gorm:"type:text;not null;comment:通知内容" json:"content"`
|
||||
IsValid bool `gorm:"default:true;comment:是否有效" json:"is_valid"`
|
||||
Sort int `gorm:"default:0;comment:排序号" json:"sort"`
|
||||
}
|
||||
|
||||
func (Notice) TableName() string { return "notices" }
|
||||
|
||||
// Notification 用户通知
|
||||
type Notification struct {
|
||||
BaseModel
|
||||
UserID uint `gorm:"index;not null;comment:接收用户ID" json:"user_id"`
|
||||
User *User `gorm:"foreignKey:UserID;comment:关联的用户信息" json:"user,omitempty"`
|
||||
Title string `gorm:"size:200;not null;comment:通知标题" json:"title"`
|
||||
Content string `gorm:"type:text;not null;comment:通知内容" json:"content"`
|
||||
Type string `gorm:"size:50;default:appointment;comment:通知类型(appointment/certification)" json:"type"`
|
||||
RefID *uint `gorm:"comment:关联业务ID" json:"ref_id"`
|
||||
IsRead bool `gorm:"default:false;comment:是否已读" json:"is_read"`
|
||||
}
|
||||
|
||||
func (Notification) TableName() string { return "notifications" }
|
||||
|
||||
// GoodsTemplate 物品模板
|
||||
type GoodsTemplate struct {
|
||||
BaseModel
|
||||
UserID uint `gorm:"index;not null;comment:用户ID" json:"user_id"`
|
||||
Name string `gorm:"size:200;not null;comment:物品名称" json:"name"`
|
||||
Quantity int `gorm:"default:1;comment:物品数量" json:"quantity"`
|
||||
Model string `gorm:"size:100;comment:物品型号" json:"model"`
|
||||
Remark string `gorm:"type:text;comment:物品备注" json:"remark"`
|
||||
}
|
||||
|
||||
func (GoodsTemplate) TableName() string { return "goods_templates" }
|
||||
|
||||
// VehicleInfo 用户车辆信息
|
||||
type VehicleInfo struct {
|
||||
BaseModel
|
||||
UserID uint `gorm:"index;not null;comment:用户ID" json:"user_id"`
|
||||
LicensePlate string `gorm:"size:20;comment:车牌号" json:"license_plate"`
|
||||
Brand string `gorm:"size:50;comment:品牌型号" json:"brand"`
|
||||
Color string `gorm:"size:20;comment:车辆颜色" json:"color"`
|
||||
}
|
||||
|
||||
func (VehicleInfo) TableName() string { return "vehicle_infos" }
|
||||
|
||||
// ==================== 工作流引擎模型 ====================
|
||||
|
||||
// ProcessDefinition 流程定义
|
||||
type ProcessDefinition struct {
|
||||
BaseModel
|
||||
ProcessName string `gorm:"size:100;not null;comment:流程名称" json:"process_name"`
|
||||
ProcessCode string `gorm:"uniqueIndex;size:50;not null;comment:流程编码" json:"process_code"`
|
||||
Source string `gorm:"size:100;comment:流程来源" json:"source"`
|
||||
Description string `gorm:"size:200;comment:流程描述" json:"description"`
|
||||
IsActive bool `gorm:"default:true;comment:是否启用" json:"is_active"`
|
||||
RevokeEvents string `gorm:"type:text;comment:撤销事件JSON" json:"revoke_events"`
|
||||
NodesJSON string `gorm:"type:text;comment:节点配置JSON" json:"nodes_json"`
|
||||
}
|
||||
|
||||
func (ProcessDefinition) TableName() string { return "process_definitions" }
|
||||
|
||||
// ProcessInstance 流程实例
|
||||
type ProcessInstance struct {
|
||||
BaseModel
|
||||
ProcessDefID uint `gorm:"index;not null;comment:流程定义ID" json:"process_def_id"`
|
||||
ProcessDef *ProcessDefinition `gorm:"foreignKey:ProcessDefID" json:"process_def,omitempty"`
|
||||
BusinessType string `gorm:"size:50;not null;comment:业务类型" json:"business_type"`
|
||||
BusinessID uint `gorm:"index;not null;comment:业务ID" json:"business_id"`
|
||||
Status int `gorm:"default:0;comment:实例状态(0运行中 1已完成 2已终止 3已撤销)" json:"status"`
|
||||
StarterID uint `gorm:"comment:发起人用户ID" json:"starter_id"`
|
||||
Starter *User `gorm:"foreignKey:StarterID" json:"starter,omitempty"`
|
||||
CurrentNodeID string `gorm:"size:50;comment:当前节点ID" json:"current_node_id"`
|
||||
Variables string `gorm:"type:text;comment:流程变量JSON" json:"variables"`
|
||||
FinishedAt *time.Time `gorm:"comment:完成时间" json:"finished_at"`
|
||||
}
|
||||
|
||||
func (ProcessInstance) TableName() string { return "process_instances" }
|
||||
|
||||
// Task 任务
|
||||
type Task struct {
|
||||
BaseModel
|
||||
InstanceID uint `gorm:"index;not null;comment:流程实例ID" json:"instance_id"`
|
||||
Instance *ProcessInstance `gorm:"foreignKey:InstanceID" json:"instance,omitempty"`
|
||||
NodeID string `gorm:"size:50;not null;comment:节点ID" json:"node_id"`
|
||||
NodeName string `gorm:"size:100;comment:节点名称" json:"node_name"`
|
||||
Status int `gorm:"default:0;comment:任务状态(0待处理 1已完成 2已拒绝 3已取消)" json:"status"`
|
||||
AssigneeID *uint `gorm:"comment:处理人用户ID" json:"assignee_id"`
|
||||
Assignee *User `gorm:"foreignKey:AssigneeID" json:"assignee,omitempty"`
|
||||
Comment string `gorm:"type:text;comment:处理意见" json:"comment"`
|
||||
HandledAt *time.Time `gorm:"comment:处理时间" json:"handled_at"`
|
||||
}
|
||||
|
||||
func (Task) TableName() string { return "tasks" }
|
||||
|
||||
// AdminUser 后台管理员账号
|
||||
type AdminUser struct {
|
||||
BaseModel
|
||||
Account string `gorm:"uniqueIndex;size:50;not null;comment:登录账号" json:"account"`
|
||||
Password string `gorm:"size:200;not null;comment:密码(SHA256)" json:"-"`
|
||||
Name string `gorm:"size:50;comment:管理员姓名" json:"name"`
|
||||
IsSuper bool `gorm:"default:false;comment:是否超级管理员" json:"is_super"`
|
||||
Status int `gorm:"default:1;comment:状态(1正常 0禁用)" json:"status"`
|
||||
}
|
||||
|
||||
func (AdminUser) TableName() string { return "admin_users" }
|
||||
|
||||
// VisitorArea 到访区域
|
||||
type VisitorArea struct {
|
||||
BaseModel
|
||||
Name string `gorm:"size:100;not null;comment:区域名称" json:"name"`
|
||||
Sort int `gorm:"default:0;comment:排序号" json:"sort"`
|
||||
Status int `gorm:"default:1;comment:状态(1启用 0禁用)" json:"status"`
|
||||
}
|
||||
|
||||
func (VisitorArea) TableName() string { return "visitor_areas" }
|
||||
|
||||
// VisitType 来访目的
|
||||
type VisitType struct {
|
||||
BaseModel
|
||||
Name string `gorm:"size:100;not null;comment:类型名称" json:"name"`
|
||||
Sort int `gorm:"default:0;comment:排序号" json:"sort"`
|
||||
Status int `gorm:"default:1;comment:状态(1启用 0禁用)" json:"status"`
|
||||
}
|
||||
|
||||
func (VisitType) TableName() string { return "visit_types" }
|
||||
|
||||
// PendingEmployee 待确认员工(通讯录导入)
|
||||
type PendingEmployee struct {
|
||||
BaseModel
|
||||
RealName string `gorm:"size:50;not null;index;comment:真实姓名" json:"real_name"`
|
||||
Phone string `gorm:"size:20;not null;index;comment:手机号" json:"phone"`
|
||||
DepartmentID uint `gorm:"not null;comment:部门ID" json:"department_id"`
|
||||
DepartmentName string `gorm:"size:100;comment:部门名称" json:"department_name"`
|
||||
Position string `gorm:"size:100;comment:职位" json:"position"`
|
||||
EmployeeType string `gorm:"size:30;default:employee;comment:员工类型" json:"employee_type"`
|
||||
DingTalkUserID string `gorm:"column:ding_talk_user_id;size:100;comment:钉钉用户unionId" json:"dingtalk_user_id"`
|
||||
IsMatched bool `gorm:"default:false;comment:是否已匹配用户" json:"is_matched"`
|
||||
MatchedUserID *uint `gorm:"comment:匹配到的用户ID" json:"matched_user_id"`
|
||||
MatchedAt *time.Time `gorm:"comment:匹配时间" json:"matched_at"`
|
||||
}
|
||||
|
||||
func (PendingEmployee) TableName() string { return "pending_employees" }
|
||||
|
||||
// GlobalApprover 全局兜底审批人
|
||||
type GlobalApprover struct {
|
||||
BaseModel
|
||||
UserID uint `gorm:"uniqueIndex;not null;comment:审批人用户ID" json:"user_id"`
|
||||
User *User `gorm:"foreignKey:UserID;comment:关联用户信息" json:"user,omitempty"`
|
||||
}
|
||||
|
||||
func (GlobalApprover) TableName() string { return "global_approvers" }
|
||||
61
sc-lktx-backend/internal/common/response/response.go
Normal file
61
sc-lktx-backend/internal/common/response/response.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// 统一 API 响应格式,所有接口使用此包返回数据
|
||||
package response
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Response 统一响应结构:code=200 成功,其他为业务错误码
|
||||
type Response struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"msg"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// PageData 分页数据结构
|
||||
type PageData struct {
|
||||
List interface{} `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// ===== 成功响应 =====
|
||||
|
||||
func Success(c *gin.Context, data interface{}) {
|
||||
c.JSON(http.StatusOK, Response{Code: 200, Message: "success", Data: data})
|
||||
}
|
||||
|
||||
func SuccessWithMessage(c *gin.Context, message string, data interface{}) {
|
||||
c.JSON(http.StatusOK, Response{Code: 200, Message: message, Data: data})
|
||||
}
|
||||
|
||||
func SuccessPage(c *gin.Context, list interface{}, total int64, page, pageSize int) {
|
||||
c.JSON(http.StatusOK, Response{Code: 200, Message: "success", Data: PageData{
|
||||
List: list, Total: total, Page: page, PageSize: pageSize,
|
||||
}})
|
||||
}
|
||||
|
||||
// ===== 错误响应 =====
|
||||
|
||||
func Error(c *gin.Context, code int, message string) {
|
||||
c.JSON(http.StatusOK, Response{Code: code, Message: message})
|
||||
}
|
||||
|
||||
func ErrorWithStatus(c *gin.Context, httpStatus int, code int, message string) {
|
||||
c.JSON(httpStatus, Response{Code: code, Message: message})
|
||||
}
|
||||
|
||||
func BadRequest(c *gin.Context, message string) { Error(c, 400, message) }
|
||||
func Unauthorized(c *gin.Context, message string) {
|
||||
ErrorWithStatus(c, http.StatusUnauthorized, 401, message)
|
||||
}
|
||||
func Forbidden(c *gin.Context, message string) {
|
||||
ErrorWithStatus(c, http.StatusForbidden, 403, message)
|
||||
}
|
||||
func NotFound(c *gin.Context, message string) { ErrorWithStatus(c, http.StatusNotFound, 404, message) }
|
||||
func InternalError(c *gin.Context, message string) {
|
||||
ErrorWithStatus(c, http.StatusInternalServerError, 500, message)
|
||||
}
|
||||
61
sc-lktx-backend/internal/common/utils/context.go
Normal file
61
sc-lktx-backend/internal/common/utils/context.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// 从 gin.Context 提取中间件注入的数据 + 分页工具
|
||||
package utils
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// GetUserID 从 Context 获取用户 ID(支持 uint/float64/string 类型转换)
|
||||
func GetUserID(c *gin.Context) uint {
|
||||
userID, exists := c.Get("user_id")
|
||||
if !exists {
|
||||
return 0
|
||||
}
|
||||
switch v := userID.(type) {
|
||||
case uint:
|
||||
return v
|
||||
case float64:
|
||||
return uint(v)
|
||||
case string:
|
||||
id, _ := strconv.ParseUint(v, 10, 64)
|
||||
return uint(id)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// GetRoleCode 获取主角色编码
|
||||
func GetRoleCode(c *gin.Context) string {
|
||||
roleCode, exists := c.Get("role_code")
|
||||
if !exists {
|
||||
return ""
|
||||
}
|
||||
return roleCode.(string)
|
||||
}
|
||||
|
||||
// HasRole 检查当前用户是否为指定角色
|
||||
func HasRole(c *gin.Context, roleCode string) bool {
|
||||
return GetRoleCode(c) == roleCode
|
||||
}
|
||||
|
||||
// ParsePagination 解析分页参数,默认 page=1, pageSize=10, 最大 1000
|
||||
func ParsePagination(c *gin.Context) (page, pageSize int) {
|
||||
pageStr := c.DefaultQuery("page", "1")
|
||||
pageSizeStr := c.DefaultQuery("page_size", "10")
|
||||
page, _ = strconv.Atoi(pageStr)
|
||||
pageSize, _ = strconv.Atoi(pageSizeStr)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 1000 {
|
||||
pageSize = 10
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// GetOffset 计算 SQL offset
|
||||
func GetOffset(page, pageSize int) int {
|
||||
return (page - 1) * pageSize
|
||||
}
|
||||
101
sc-lktx-backend/internal/common/utils/jwt.go
Normal file
101
sc-lktx-backend/internal/common/utils/jwt.go
Normal file
@@ -0,0 +1,101 @@
|
||||
// JWT Token 生成与解析 + 业务工具函数
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// JWTClaims JWT 载荷,包含用户 ID 和角色列表
|
||||
type JWTClaims struct {
|
||||
UserID uint `json:"user_id"`
|
||||
RoleCode string `json:"role_code"` // 角色(visitor/employee/guard)
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// AdminJWTClaims 管理员 JWT 载荷
|
||||
type AdminJWTClaims struct {
|
||||
AdminID uint `json:"admin_id"`
|
||||
Account string `json:"account"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// GenerateAdminToken 生成管理员 JWT
|
||||
func GenerateAdminToken(adminID uint, account string, secret string, expireHours int) (string, error) {
|
||||
claims := AdminJWTClaims{
|
||||
AdminID: adminID,
|
||||
Account: account,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expireHours) * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "lktx-admin",
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(secret))
|
||||
}
|
||||
|
||||
// ParseAdminToken 解析管理员 JWT
|
||||
func ParseAdminToken(tokenString string, secret string) (*AdminJWTClaims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &AdminJWTClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if claims, ok := token.Claims.(*AdminJWTClaims); ok && token.Valid {
|
||||
return claims, nil
|
||||
}
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
// GenerateToken 生成 JWT(单角色,兼容旧版)
|
||||
func GenerateToken(userID uint, roleCode string, secret string, expireHours int) (string, error) {
|
||||
claims := JWTClaims{
|
||||
UserID: userID,
|
||||
RoleCode: roleCode,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expireHours) * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "lktx-mp",
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(secret))
|
||||
}
|
||||
|
||||
// ParseToken 解析并验证 JWT
|
||||
func ParseToken(tokenString string, secret string) (*JWTClaims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if claims, ok := token.Claims.(*JWTClaims); ok && token.Valid {
|
||||
return claims, nil
|
||||
}
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
// GenerateInviteCode 生成 16 位随机邀请码
|
||||
func GenerateInviteCode() string {
|
||||
b := make([]byte, 8)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// GenerateQRCodeContent 生成二维码内容
|
||||
func GenerateQRCodeContent(appointmentID uint) string {
|
||||
return fmt.Sprintf("LKTX:APPT:%d:%d", appointmentID, time.Now().Unix())
|
||||
}
|
||||
22
sc-lktx-backend/internal/common/utils/validator.go
Normal file
22
sc-lktx-backend/internal/common/utils/validator.go
Normal file
@@ -0,0 +1,22 @@
|
||||
// 数据校验工具
|
||||
package utils
|
||||
|
||||
import "regexp"
|
||||
|
||||
// ValidatePhone 验证中国大陆手机号(1 开头 + 3-9 + 9 位数字)
|
||||
func ValidatePhone(phone string) bool {
|
||||
matched, _ := regexp.MatchString(`^1[3-9]\d{9}$`, phone)
|
||||
return matched
|
||||
}
|
||||
|
||||
// ValidateIDNumber 验证 18 位身份证号格式
|
||||
func ValidateIDNumber(idNumber string) bool {
|
||||
matched, _ := regexp.MatchString(`^[1-9]\d{5}(18|19|20)\d{2}((0[1-9])|(1[0-2]))(([0-2][1-9])|10|20|30|31)\d{3}[\dXx]$`, idNumber)
|
||||
return matched
|
||||
}
|
||||
|
||||
// ValidateLicensePlate 验证中国大陆车牌号
|
||||
func ValidateLicensePlate(plate string) bool {
|
||||
matched, _ := regexp.MatchString(`^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤川青藏琼宁][A-HJ-NP-Z][A-HJ-NP-Z0-9]{4,5}[A-HJ-NP-Z0-9挂学警港澳]$`, plate)
|
||||
return matched
|
||||
}
|
||||
1697
sc-lktx-backend/internal/modules/appointment/handler.go
Normal file
1697
sc-lktx-backend/internal/modules/appointment/handler.go
Normal file
File diff suppressed because it is too large
Load Diff
107
sc-lktx-backend/internal/modules/banner/handler.go
Normal file
107
sc-lktx-backend/internal/modules/banner/handler.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// Banner 模块:首页轮播图接口
|
||||
package banner
|
||||
|
||||
import (
|
||||
"com.sclktx/m/v2/internal/common/model"
|
||||
"com.sclktx/m/v2/internal/common/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// BannerHandler Banner 处理器
|
||||
type BannerHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewBannerHandler 创建 Banner 处理器
|
||||
func NewBannerHandler(db *gorm.DB) *BannerHandler {
|
||||
return &BannerHandler{db: db}
|
||||
}
|
||||
|
||||
// BannerVO Banner 视图对象(只返回前端需要的字段)
|
||||
type BannerVO struct {
|
||||
ImageURL string `json:"image_url"` // 图片地址
|
||||
JumpPath string `json:"jump_path"` // 跳转链接(空字符串表示不可跳转)
|
||||
IsJump bool `json:"is_jump"` // 是否可跳转
|
||||
}
|
||||
|
||||
// @Summary 获取首页轮播图列表
|
||||
// @Description 获取首页轮播图列表(公开接口)
|
||||
// @Tags 公共
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} response.Response{data=[]banner.BannerVO} "success"
|
||||
// @Router /api/v1/banners [get]
|
||||
// GetBanners 获取首页轮播图列表(公开接口)
|
||||
func (h *BannerHandler) GetBanners(c *gin.Context) {
|
||||
var banners []model.Banner
|
||||
if err := h.db.Where("is_valid = ?", true).
|
||||
Order("sort ASC, id DESC").
|
||||
Find(&banners).Error; err != nil {
|
||||
response.InternalError(c, "查询失败")
|
||||
return
|
||||
}
|
||||
|
||||
result := make([]BannerVO, 0, len(banners))
|
||||
for _, b := range banners {
|
||||
result = append(result, BannerVO{
|
||||
ImageURL: b.ImageURL,
|
||||
JumpPath: b.JumpPath,
|
||||
IsJump: b.IsJump,
|
||||
})
|
||||
}
|
||||
|
||||
// 如果没有数据,返回空数组而非 null
|
||||
if result == nil {
|
||||
result = []BannerVO{
|
||||
{
|
||||
ImageURL: "//mmbiz.qpic.cn/mmbiz_jpg/XekA4rfc3lcLOadSAicKvm3ZXFgn9TtZUOh5tSgaJeQrqs6JhWKfszMHBQhXaicPpgVM09U3dnHBfoEriaM9fKPXYCDvsB2fiaib4TAjXMGV2Ql0/0?from=appmsg",
|
||||
JumpPath: "",
|
||||
IsJump: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
// NoticeVO 通知视图对象
|
||||
type NoticeVO struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// @Summary 获取有效通知列表
|
||||
// @Description 获取有效通知列表(公开接口)
|
||||
// @Tags 公共
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} response.Response{data=[]banner.NoticeVO} "success"
|
||||
// @Router /api/v1/notices [get]
|
||||
// GetNotices 获取有效通知列表(公开接口)
|
||||
func (h *BannerHandler) GetNotices(c *gin.Context) {
|
||||
var notices []model.Notice
|
||||
h.db.Where("is_valid = ?", true).Order("sort ASC, id DESC").Find(¬ices)
|
||||
|
||||
result := make([]NoticeVO, 0, len(notices))
|
||||
for _, n := range notices {
|
||||
result = append(result, NoticeVO{
|
||||
ID: n.ID,
|
||||
Title: n.Title,
|
||||
Content: n.Content,
|
||||
})
|
||||
}
|
||||
if result == nil {
|
||||
result = []NoticeVO{}
|
||||
}
|
||||
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
// RegisterRoutes 注册 Banner 路由(公开接口,无需认证)
|
||||
func (h *BannerHandler) RegisterRoutes(r *gin.RouterGroup) {
|
||||
r.GET("/banners", h.GetBanners)
|
||||
r.GET("/notices", h.GetNotices)
|
||||
}
|
||||
236
sc-lktx-backend/internal/modules/certification/handler.go
Normal file
236
sc-lktx-backend/internal/modules/certification/handler.go
Normal file
@@ -0,0 +1,236 @@
|
||||
// 员工认证模块:提交认证申请、查询申请状态、撤回申请
|
||||
package certification
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"com.sclktx/m/v2/internal/common/model"
|
||||
"com.sclktx/m/v2/internal/common/response"
|
||||
"com.sclktx/m/v2/internal/common/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Handler 角色认证处理器
|
||||
type Handler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewHandler 创建角色认证处理器
|
||||
func NewHandler(db *gorm.DB) *Handler {
|
||||
return &Handler{db: db}
|
||||
}
|
||||
|
||||
// SubmitCertificationReq 提交认证申请请求
|
||||
type SubmitCertificationReq struct {
|
||||
RealName string `json:"real_name" binding:"required"`
|
||||
Phone string `json:"phone" binding:"required"`
|
||||
Role string `json:"role" binding:"required"`
|
||||
Snapshot []string `json:"snapshot" binding:"required"`
|
||||
DepartmentID *uint `json:"department_id"`
|
||||
Position string `json:"position"`
|
||||
}
|
||||
|
||||
// SubmitCertification 提交角色认证申请
|
||||
// @Summary 提交角色认证申请
|
||||
// @Description 提交员工角色认证申请,需上传钉钉截图
|
||||
// @Tags 员工认证
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param body body SubmitCertificationReq true "认证申请请求"
|
||||
// @Success 200 {object} response.Response "success"
|
||||
// @Router /api/v1/certification/submit [post]
|
||||
func (h *Handler) SubmitCertification(c *gin.Context) {
|
||||
var req SubmitCertificationReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
userID := utils.GetUserID(c)
|
||||
|
||||
// 检查是否已有待审核或已通过的申请
|
||||
var existing model.EmployeeCertification
|
||||
if err := h.db.Where("user_id = ? AND status IN (0, 1)", userID).First(&existing).Error; err == nil {
|
||||
if existing.Status == 1 {
|
||||
response.BadRequest(c, "您已通过认证,无需重复申请")
|
||||
return
|
||||
}
|
||||
response.BadRequest(c, "您已提交申请,请等待审核")
|
||||
return
|
||||
}
|
||||
|
||||
cert := model.EmployeeCertification{
|
||||
UserID: userID,
|
||||
RealName: req.RealName,
|
||||
Phone: req.Phone,
|
||||
Role: req.Role,
|
||||
Snapshot: req.Snapshot,
|
||||
Position: req.Position,
|
||||
DepartmentID: req.DepartmentID,
|
||||
Status: 0,
|
||||
}
|
||||
|
||||
if err := h.db.Create(&cert).Error; err != nil {
|
||||
response.InternalError(c, "提交失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.SuccessWithMessage(c, "提交成功,请等待管理员审核", cert)
|
||||
}
|
||||
|
||||
// GetMyCertification 获取我的认证申请
|
||||
// @Summary 获取我的认证申请
|
||||
// @Description 获取当前用户的角色认证申请状态
|
||||
// @Tags 员工认证
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} response.Response "success"
|
||||
// @Router /api/v1/certification/my [get]
|
||||
func (h *Handler) GetMyCertification(c *gin.Context) {
|
||||
userID := utils.GetUserID(c)
|
||||
|
||||
var cert model.EmployeeCertification
|
||||
if err := h.db.Preload("Department").Preload("Approver").Where("user_id = ?", userID).Order("created_at DESC").First(&cert).Error; err != nil {
|
||||
response.Success(c, nil)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, cert)
|
||||
}
|
||||
|
||||
// WithdrawCertification 撤回认证申请
|
||||
// @Summary 撤回认证申请
|
||||
// @Description 撤回已提交但尚未处理的认证申请
|
||||
// @Tags 员工认证
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path uint true "认证申请ID"
|
||||
// @Success 200 {object} response.Response "success"
|
||||
// @Router /api/v1/certification/{id}/withdraw [put]
|
||||
func (h *Handler) WithdrawCertification(c *gin.Context) {
|
||||
userID := utils.GetUserID(c)
|
||||
certID := c.Param("id")
|
||||
|
||||
var cert model.EmployeeCertification
|
||||
if err := h.db.First(&cert, certID).Error; err != nil {
|
||||
response.NotFound(c, "申请不存在")
|
||||
return
|
||||
}
|
||||
|
||||
if cert.UserID != userID {
|
||||
response.Forbidden(c, "无权操作")
|
||||
return
|
||||
}
|
||||
|
||||
if cert.Status != 0 {
|
||||
response.BadRequest(c, "该申请已处理,无法撤回")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.Delete(&cert).Error; err != nil {
|
||||
response.InternalError(c, "撤回失败")
|
||||
return
|
||||
}
|
||||
|
||||
response.SuccessWithMessage(c, "已撤回", nil)
|
||||
}
|
||||
|
||||
// AutoVerifyReq 自动认证请求
|
||||
type AutoVerifyReq struct {
|
||||
RealName string `json:"real_name" binding:"required"`
|
||||
Phone string `json:"phone" binding:"required"`
|
||||
}
|
||||
|
||||
// AutoVerify 自动认证:姓名+手机号 → 匹配 pending_employees → 直接认证为员工
|
||||
// @Summary 自动认证为员工
|
||||
// @Description 输入姓名和手机号,系统自动匹配通讯录导入数据,匹配成功直接认证为员工
|
||||
// @Tags 员工认证
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param body body AutoVerifyReq true "认证请求"
|
||||
// @Success 200 {object} response.Response "success"
|
||||
// @Router /api/v1/certification/auto-verify [post]
|
||||
func (h *Handler) AutoVerify(c *gin.Context) {
|
||||
var req AutoVerifyReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "请输入姓名和手机号")
|
||||
return
|
||||
}
|
||||
|
||||
userID := utils.GetUserID(c)
|
||||
|
||||
// 查当前用户角色
|
||||
var user model.User
|
||||
if err := h.db.First(&user, userID).Error; err != nil {
|
||||
response.InternalError(c, "用户不存在")
|
||||
return
|
||||
}
|
||||
if user.Role == "employee" {
|
||||
response.BadRequest(c, "您已经是认证员工,无需重复申请")
|
||||
return
|
||||
}
|
||||
|
||||
// 查找 pending_employees
|
||||
var pending model.PendingEmployee
|
||||
if err := h.db.Where("phone = ?", req.Phone).First(&pending).Error; err != nil {
|
||||
response.BadRequest(c, "未找到您的认证信息,请联系管理员导入通讯录")
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否已被其他用户匹配
|
||||
if pending.IsMatched {
|
||||
if pending.MatchedUserID != nil && *pending.MatchedUserID == userID {
|
||||
// 已匹配当前用户但角色尚未升级 → 修复
|
||||
if user.Role != "employee" {
|
||||
goto doMatch
|
||||
}
|
||||
response.BadRequest(c, "您已认证成为员工,如有问题请联系管理员")
|
||||
} else {
|
||||
response.BadRequest(c, "该手机号已被其他用户认证,如有问题请联系管理员")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
doMatch:
|
||||
// 执行认证
|
||||
now := time.Now()
|
||||
ud := model.UserDepartment{
|
||||
UserID: userID,
|
||||
DepartmentID: pending.DepartmentID,
|
||||
DepartmentName: pending.DepartmentName,
|
||||
Position: pending.Position,
|
||||
EmployeeType: pending.EmployeeType,
|
||||
EmployeeStatus: 1,
|
||||
}
|
||||
h.db.Where("user_id = ?", userID).Assign(ud).FirstOrCreate(&model.UserDepartment{UserID: userID})
|
||||
h.db.Model(&user).Update("role", "employee")
|
||||
h.db.Model(&pending).Updates(map[string]interface{}{
|
||||
"is_matched": true,
|
||||
"matched_user_id": &userID,
|
||||
"matched_at": &now,
|
||||
})
|
||||
|
||||
// 如果用户还没有真实姓名,同步填入
|
||||
if user.RealName == "" {
|
||||
h.db.Model(&user).Update("real_name", req.RealName)
|
||||
}
|
||||
|
||||
response.SuccessWithMessage(c, "认证成功", gin.H{
|
||||
"department_name": pending.DepartmentName,
|
||||
"position": pending.Position,
|
||||
})
|
||||
}
|
||||
|
||||
// RegisterRoutes 注册认证路由(需要 JWT 认证)
|
||||
func (h *Handler) RegisterRoutes(r *gin.RouterGroup) {
|
||||
r.POST("/certification/submit", h.SubmitCertification)
|
||||
r.GET("/certification/my", h.GetMyCertification)
|
||||
r.PUT("/certification/:id/withdraw", h.WithdrawCertification)
|
||||
r.POST("/certification/auto-verify", h.AutoVerify)
|
||||
}
|
||||
266
sc-lktx-backend/internal/modules/upload/handler.go
Normal file
266
sc-lktx-backend/internal/modules/upload/handler.go
Normal file
@@ -0,0 +1,266 @@
|
||||
// Upload 模块:图片上传(支持上传到微信服务器和 MinIO,公开接口,无需权限)
|
||||
package upload
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"com.sclktx/m/v2/internal/common/response"
|
||||
"com.sclktx/m/v2/internal/pkg/minio"
|
||||
"com.sclktx/m/v2/internal/pkg/wechat"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// UploadHandler 上传处理器
|
||||
type UploadHandler struct {
|
||||
wxClient *wechat.MiniProgramClient
|
||||
minioClient *minio.Client
|
||||
}
|
||||
|
||||
// NewUploadHandler 创建上传处理器
|
||||
func NewUploadHandler(wxClient *wechat.MiniProgramClient, minioClient *minio.Client) *UploadHandler {
|
||||
return &UploadHandler{
|
||||
wxClient: wxClient,
|
||||
minioClient: minioClient,
|
||||
}
|
||||
}
|
||||
|
||||
// UploadImgResponse 上传图片返回
|
||||
type UploadImgResponse struct {
|
||||
URL string `json:"url"` // 微信返回的图片 URL
|
||||
}
|
||||
|
||||
// @Summary 上传图片到微信服务器
|
||||
// @Description 上传图片文件到微信服务器(公开接口,无需权限)
|
||||
// @Tags 文件上传
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Param file formData file true "图片文件"
|
||||
// @Success 200 {object} response.Response{data=upload.UploadImgResponse} "success"
|
||||
// @Router /api/v1/upload/image [post]
|
||||
// UploadImage 上传图片文件到微信服务器(公开接口,无需权限)
|
||||
// POST /api/v1/upload/image
|
||||
func (h *UploadHandler) UploadImage(c *gin.Context) {
|
||||
// 获取上传的文件
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
response.BadRequest(c, "请选择要上传的图片文件")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// 读取文件内容
|
||||
imageData, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
response.InternalError(c, "读取文件失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取文件名,如果没有则生成一个
|
||||
filename := header.Filename
|
||||
if filename == "" {
|
||||
filename = "image_" + time.Now().Format("20060102150405") + ".jpg"
|
||||
}
|
||||
|
||||
// 确保文件名有扩展名
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
if ext == "" {
|
||||
// 根据 Content-Type 猜测扩展名
|
||||
contentType := header.Header.Get("Content-Type")
|
||||
switch contentType {
|
||||
case "image/png":
|
||||
filename += ".png"
|
||||
case "image/gif":
|
||||
filename += ".gif"
|
||||
case "image/webp":
|
||||
filename += ".webp"
|
||||
default:
|
||||
filename += ".jpg"
|
||||
}
|
||||
}
|
||||
|
||||
// 上传到微信服务器
|
||||
result, err := h.wxClient.UploadImg(imageData, filename)
|
||||
if err != nil {
|
||||
response.InternalError(c, "上传到微信失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, &UploadImgResponse{URL: result.URL})
|
||||
}
|
||||
|
||||
// UploadImageByURLRequest 通过链接上传请求
|
||||
type UploadImageByURLRequest struct {
|
||||
URL string `json:"url" binding:"required"` // 图片链接
|
||||
}
|
||||
|
||||
// @Summary 通过链接上传图片到微信服务器
|
||||
// @Description 通过图片链接下载后上传到微信服务器(公开接口,无需权限)
|
||||
// @Tags 文件上传
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body upload.UploadImageByURLRequest true "图片链接信息"
|
||||
// @Success 200 {object} response.Response{data=upload.UploadImgResponse} "success"
|
||||
// @Router /api/v1/upload/image-by-url [post]
|
||||
// UploadImageByURL 通过图片链接下载后上传到微信服务器(公开接口,无需权限)
|
||||
// POST /api/v1/upload/image-by-url
|
||||
func (h *UploadHandler) UploadImageByURL(c *gin.Context) {
|
||||
var req UploadImageByURLRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "请提供图片链接(url)")
|
||||
return
|
||||
}
|
||||
|
||||
// 下载图片(使用自定义请求,添加 User-Agent 避免被反爬拦截)
|
||||
httpReq, err := http.NewRequest("GET", req.URL, nil)
|
||||
if err != nil {
|
||||
response.InternalError(c, "创建请求失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
httpReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
response.InternalError(c, "下载图片失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
response.InternalError(c, "下载图片失败,HTTP状态码: "+http.StatusText(resp.StatusCode))
|
||||
return
|
||||
}
|
||||
|
||||
imageData, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
response.InternalError(c, "读取图片数据失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 从 URL 中提取文件名
|
||||
filename := extractFilenameFromURL(req.URL)
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
ext := getExtByContentType(contentType)
|
||||
if !strings.HasSuffix(strings.ToLower(filename), ext) && ext != "" {
|
||||
filename += ext
|
||||
}
|
||||
|
||||
// 上传到微信服务器
|
||||
result, err := h.wxClient.UploadImg(imageData, filename)
|
||||
if err != nil {
|
||||
response.InternalError(c, "上传到微信失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, &UploadImgResponse{URL: result.URL})
|
||||
}
|
||||
|
||||
// extractFilenameFromURL 从 URL 中提取文件名
|
||||
func extractFilenameFromURL(rawURL string) string {
|
||||
// 去掉查询参数
|
||||
if idx := strings.Index(rawURL, "?"); idx != -1 {
|
||||
rawURL = rawURL[:idx]
|
||||
}
|
||||
// 取最后一段作为文件名
|
||||
base := filepath.Base(rawURL)
|
||||
if base == "." || base == "/" || base == "" {
|
||||
return "image_" + time.Now().Format("20060102150405") + ".jpg"
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// getExtByContentType 根据 Content-Type 获取文件扩展名
|
||||
func getExtByContentType(contentType string) string {
|
||||
switch {
|
||||
case strings.Contains(contentType, "image/png"):
|
||||
return ".png"
|
||||
case strings.Contains(contentType, "image/gif"):
|
||||
return ".gif"
|
||||
case strings.Contains(contentType, "image/webp"):
|
||||
return ".webp"
|
||||
case strings.Contains(contentType, "image/svg"):
|
||||
return ".svg"
|
||||
case strings.Contains(contentType, "image/jpeg"):
|
||||
return ".jpg"
|
||||
default:
|
||||
return ".jpg"
|
||||
}
|
||||
}
|
||||
|
||||
// UploadToMinioResponse 上传到 MinIO 返回
|
||||
type UploadToMinioResponse struct {
|
||||
URL string `json:"url"` // MinIO 中的图片 URL
|
||||
}
|
||||
|
||||
// @Summary 上传图片到MinIO对象存储
|
||||
// @Description 上传图片到 MinIO 对象存储(公开接口,无需权限)
|
||||
// @Tags 文件上传
|
||||
// @Accept multipart/form-data
|
||||
// @Produce json
|
||||
// @Param file formData file true "图片文件"
|
||||
// @Success 200 {object} response.Response{data=upload.UploadToMinioResponse} "success"
|
||||
// @Router /api/v1/upload/minio [post]
|
||||
// UploadImageToMinio 上传图片到 MinIO 对象存储(公开接口,无需权限)
|
||||
// POST /api/v1/upload/minio
|
||||
func (h *UploadHandler) UploadImageToMinio(c *gin.Context) {
|
||||
// 获取上传的文件
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
response.BadRequest(c, "请选择要上传的图片文件")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// 读取文件内容
|
||||
imageData, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
response.InternalError(c, "读取文件失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取文件名,如果没有则生成一个
|
||||
filename := header.Filename
|
||||
if filename == "" {
|
||||
filename = "image_" + time.Now().Format("20060102150405") + ".jpg"
|
||||
}
|
||||
|
||||
// 确保文件名有扩展名
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
if ext == "" {
|
||||
// 根据 Content-Type 猜测扩展名
|
||||
contentType := header.Header.Get("Content-Type")
|
||||
switch contentType {
|
||||
case "image/png":
|
||||
filename += ".png"
|
||||
case "image/gif":
|
||||
filename += ".gif"
|
||||
case "image/webp":
|
||||
filename += ".webp"
|
||||
default:
|
||||
filename += ".jpg"
|
||||
}
|
||||
}
|
||||
|
||||
// 上传到 MinIO
|
||||
ctx := context.Background()
|
||||
url, err := h.minioClient.UploadImage(ctx, imageData, filename)
|
||||
if err != nil {
|
||||
response.InternalError(c, "上传到 MinIO 失败:"+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, &UploadToMinioResponse{URL: url})
|
||||
}
|
||||
|
||||
// RegisterRoutes 注册上传路由(公开接口,无需认证和权限)
|
||||
func (h *UploadHandler) RegisterRoutes(r *gin.RouterGroup) {
|
||||
r.POST("/upload/image", h.UploadImage)
|
||||
r.POST("/upload/image-by-url", h.UploadImageByURL)
|
||||
r.POST("/upload/minio", h.UploadImageToMinio)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"com.sclktx/m/v2/internal/common/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ApproverResolver 审批人动态解析器
|
||||
type ApproverResolver struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewApproverResolver(db *gorm.DB) *ApproverResolver {
|
||||
return &ApproverResolver{db: db}
|
||||
}
|
||||
|
||||
// parseJSONIDs 解析 JSON 数组字符串为 uint 切片
|
||||
func parseJSONIDs(jsonStr string) []uint {
|
||||
if jsonStr == "" {
|
||||
return nil
|
||||
}
|
||||
var ids []uint
|
||||
if err := json.Unmarshal([]byte(jsonStr), &ids); err != nil {
|
||||
return nil
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// ResolveDeptApproverIDs 取部门指定审批人(approver_user_ids),空则返回空切片
|
||||
func (r *ApproverResolver) ResolveDeptApproverIDs(deptID uint) ([]uint, error) {
|
||||
var dept model.Department
|
||||
if err := r.db.First(&dept, deptID).Error; err != nil {
|
||||
return nil, fmt.Errorf("部门不存在: %w", err)
|
||||
}
|
||||
return parseJSONIDs(dept.ApproverUserIDs), nil
|
||||
}
|
||||
|
||||
// ResolveVPIDsByDepartment 取部门分管领导(vp_user_ids),空则返回空切片
|
||||
func (r *ApproverResolver) ResolveVPIDsByDepartment(deptID uint) ([]uint, error) {
|
||||
var dept model.Department
|
||||
if err := r.db.First(&dept, deptID).Error; err != nil {
|
||||
return nil, fmt.Errorf("部门不存在: %w", err)
|
||||
}
|
||||
return parseJSONIDs(dept.VpUserIDs), nil
|
||||
}
|
||||
|
||||
// GetGlobalApproverIDs 获取所有全局兜底审批人
|
||||
func (r *ApproverResolver) GetGlobalApproverIDs() ([]uint, error) {
|
||||
var approvers []model.GlobalApprover
|
||||
r.db.Find(&approvers)
|
||||
var ids []uint
|
||||
for _, a := range approvers {
|
||||
ids = append(ids, a.UserID)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
556
sc-lktx-backend/internal/modules/workflow/engine.go
Normal file
556
sc-lktx-backend/internal/modules/workflow/engine.go
Normal file
@@ -0,0 +1,556 @@
|
||||
// 工作流引擎核心逻辑
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"com.sclktx/m/v2/internal/common/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Engine 工作流引擎
|
||||
type Engine struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewEngine 创建工作流引擎
|
||||
func NewEngine(db *gorm.DB) *Engine {
|
||||
return &Engine{db: db}
|
||||
}
|
||||
|
||||
// Node 节点配置(从 nodes_json 解析)
|
||||
type Node struct {
|
||||
NodeID string `json:"node_id"`
|
||||
NodeName string `json:"node_name"`
|
||||
NodeType int `json:"node_type"` // 0-开始 1-审批 2-网关 3-结束
|
||||
PrevNodeIDs []string `json:"prev_node_ids"`
|
||||
UserIDs []string `json:"user_ids"`
|
||||
Roles []string `json:"roles"`
|
||||
GwConfig *GatewayConfig `json:"gw_config"`
|
||||
IsCosigned int `json:"is_cosigned"` // 0-否 1-会签
|
||||
NodeStartEvents []string `json:"node_start_events"`
|
||||
NodeEndEvents []string `json:"node_end_events"`
|
||||
TaskFinishEvents []string `json:"task_finish_events"`
|
||||
}
|
||||
|
||||
// GatewayConfig 网关配置
|
||||
type GatewayConfig struct {
|
||||
Conditions []Condition `json:"conditions"`
|
||||
InevitableNodes []string `json:"inevitable_nodes"`
|
||||
WaitForAllPrevNode int `json:"wait_for_all_prev_node"` // 0-否 1-是
|
||||
}
|
||||
|
||||
// Condition 条件分支
|
||||
type Condition struct {
|
||||
Expression string `json:"expression"`
|
||||
NodeID string `json:"node_id"`
|
||||
}
|
||||
|
||||
// parseNodes 解析流程定义的节点配置
|
||||
func (e *Engine) parseNodes(def *model.ProcessDefinition) ([]Node, error) {
|
||||
if def.NodesJSON == "" {
|
||||
return nil, errors.New("流程节点配置为空")
|
||||
}
|
||||
var nodes []Node
|
||||
if err := json.Unmarshal([]byte(def.NodesJSON), &nodes); err != nil {
|
||||
return nil, fmt.Errorf("解析节点配置失败: %w", err)
|
||||
}
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
// findStartNode 查找开始节点
|
||||
func findStartNode(nodes []Node) *Node {
|
||||
for i := range nodes {
|
||||
if nodes[i].NodeType == 0 {
|
||||
return &nodes[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// findNextNodes 查找下一个节点
|
||||
func findNextNodes(nodes []Node, currentNodeID string) []Node {
|
||||
var next []Node
|
||||
for _, n := range nodes {
|
||||
for _, prevID := range n.PrevNodeIDs {
|
||||
if prevID == currentNodeID {
|
||||
next = append(next, n)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
// findNodeByID 根据节点 ID 查找节点
|
||||
func findNodeByID(nodes []Node, nodeID string) *Node {
|
||||
for i := range nodes {
|
||||
if nodes[i].NodeID == nodeID {
|
||||
return &nodes[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseVariables 解析流程实例中的变量 JSON
|
||||
func (e *Engine) parseVariables(variablesJSON string) map[string]interface{} {
|
||||
var vars map[string]interface{}
|
||||
if variablesJSON != "" {
|
||||
json.Unmarshal([]byte(variablesJSON), &vars)
|
||||
}
|
||||
if vars == nil {
|
||||
vars = make(map[string]interface{})
|
||||
}
|
||||
return vars
|
||||
}
|
||||
|
||||
|
||||
// StartInstanceByCode 启动流程实例(按流程编码)
|
||||
func (e *Engine) StartInstanceByCode(processCode string, businessType string, businessID uint, starterID uint, variables map[string]interface{}) (*model.ProcessInstance, error) {
|
||||
var def model.ProcessDefinition
|
||||
if err := e.db.Where("process_code = ?", processCode).First(&def).Error; err != nil {
|
||||
return nil, fmt.Errorf("流程定义不存在: %s", processCode)
|
||||
}
|
||||
return e.StartInstance(def.ID, businessType, businessID, starterID, variables)
|
||||
}
|
||||
func (e *Engine) StartInstance(defID uint, businessType string, businessID uint, starterID uint, variables map[string]interface{}) (*model.ProcessInstance, error) {
|
||||
// 查询流程定义
|
||||
var def model.ProcessDefinition
|
||||
if err := e.db.First(&def, defID).Error; err != nil {
|
||||
return nil, errors.New("流程定义不存在")
|
||||
}
|
||||
if !def.IsActive {
|
||||
return nil, errors.New("流程定义已禁用")
|
||||
}
|
||||
|
||||
// 解析节点配置
|
||||
nodes, err := e.parseNodes(&def)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 查找开始节点
|
||||
startNode := findStartNode(nodes)
|
||||
if startNode == nil {
|
||||
return nil, errors.New("流程定义缺少开始节点")
|
||||
}
|
||||
|
||||
// 序列化变量
|
||||
variablesJSON := "{}"
|
||||
if variables != nil {
|
||||
b, _ := json.Marshal(variables)
|
||||
variablesJSON = string(b)
|
||||
}
|
||||
|
||||
// 创建流程实例
|
||||
instance := &model.ProcessInstance{
|
||||
ProcessDefID: def.ID,
|
||||
BusinessType: businessType,
|
||||
BusinessID: businessID,
|
||||
Status: 0, // 运行中
|
||||
StarterID: starterID,
|
||||
CurrentNodeID: startNode.NodeID,
|
||||
Variables: variablesJSON,
|
||||
}
|
||||
|
||||
if err := e.db.Create(instance).Error; err != nil {
|
||||
return nil, fmt.Errorf("创建流程实例失败: %w", err)
|
||||
}
|
||||
|
||||
// 流转到下一个节点
|
||||
if err := e.moveToNextNodes(instance, nodes, startNode.NodeID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
// moveToNextNodes 流转到下一个节点,创建任务(使用默认 DB)
|
||||
func (e *Engine) moveToNextNodes(instance *model.ProcessInstance, nodes []Node, currentNodeID string) error {
|
||||
return e.moveToNextNodesTx(e.db, instance, nodes, currentNodeID)
|
||||
}
|
||||
|
||||
// moveToNextNodesTx 流转到下一个节点(支持外部事务)
|
||||
func (e *Engine) moveToNextNodesTx(tx *gorm.DB, instance *model.ProcessInstance, nodes []Node, currentNodeID string) error {
|
||||
nextNodes := findNextNodes(nodes, currentNodeID)
|
||||
|
||||
// 如果没有下一个节点(到达结束节点),标记流程完成
|
||||
if len(nextNodes) == 0 {
|
||||
now := time.Now()
|
||||
if err := tx.Model(instance).Updates(map[string]interface{}{
|
||||
"status": 1, // 已完成
|
||||
"finished_at": now,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return e.updateAppointmentStatus(tx, instance, 1)
|
||||
}
|
||||
|
||||
// 为每个下一个节点创建任务
|
||||
for _, node := range nextNodes {
|
||||
switch node.NodeType {
|
||||
case 1: // 审批节点
|
||||
if err := e.createApprovalTasksTx(tx, instance, nodes, &node); err != nil {
|
||||
return err
|
||||
}
|
||||
case 3: // 结束节点
|
||||
now := time.Now()
|
||||
if err := tx.Model(instance).Updates(map[string]interface{}{
|
||||
"status": 1,
|
||||
"finished_at": now,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return e.updateAppointmentStatus(tx, instance, 1)
|
||||
case 2: // 网关节点 - 自动判断条件
|
||||
if err := e.handleGatewayNodeTx(tx, instance, nodes, &node); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 更新当前节点
|
||||
if len(nextNodes) > 0 {
|
||||
tx.Model(instance).Update("current_node_id", nextNodes[0].NodeID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleGatewayNodeTx 处理网关节点(支持外部事务)
|
||||
func (e *Engine) handleGatewayNodeTx(tx *gorm.DB, instance *model.ProcessInstance, nodes []Node, gwNode *Node) error {
|
||||
if gwNode.GwConfig == nil {
|
||||
return e.moveToNextNodesTx(tx, instance, nodes, gwNode.NodeID)
|
||||
}
|
||||
|
||||
// 解析流程变量
|
||||
var variables map[string]interface{}
|
||||
if instance.Variables != "" {
|
||||
json.Unmarshal([]byte(instance.Variables), &variables)
|
||||
}
|
||||
if variables == nil {
|
||||
variables = make(map[string]interface{})
|
||||
}
|
||||
|
||||
// 评估条件
|
||||
for _, cond := range gwNode.GwConfig.Conditions {
|
||||
if evaluateCondition(cond.Expression, variables) {
|
||||
return e.moveToNextNodesTx(tx, instance, nodes, cond.NodeID)
|
||||
}
|
||||
}
|
||||
|
||||
// 没有条件满足,走必经节点
|
||||
if len(gwNode.GwConfig.InevitableNodes) > 0 {
|
||||
return e.moveToNextNodesTx(tx, instance, nodes, gwNode.GwConfig.InevitableNodes[0])
|
||||
}
|
||||
|
||||
return e.moveToNextNodesTx(tx, instance, nodes, gwNode.NodeID)
|
||||
}
|
||||
|
||||
// createApprovalTasks 为审批节点创建任务(使用默认 DB)
|
||||
func (e *Engine) createApprovalTasks(instance *model.ProcessInstance, nodes []Node, node *Node) error {
|
||||
return e.createApprovalTasksTx(e.db, instance, nodes, node)
|
||||
}
|
||||
|
||||
// createApprovalTasksTx 为审批节点创建任务(支持外部事务)
|
||||
func (e *Engine) createApprovalTasksTx(tx *gorm.DB, instance *model.ProcessInstance, nodes []Node, node *Node) error {
|
||||
// 解析审批人:优先使用 user_ids,其次使用 roles
|
||||
var assigneeIDs []uint
|
||||
variables := e.parseVariables(instance.Variables)
|
||||
|
||||
if len(node.UserIDs) > 0 {
|
||||
for _, uidStr := range node.UserIDs {
|
||||
if strings.HasPrefix(uidStr, "$") {
|
||||
// $变量名 → 从流程变量中取值
|
||||
varName := uidStr[1:]
|
||||
if val, ok := variables[varName]; ok {
|
||||
switch v := val.(type) {
|
||||
case float64:
|
||||
assigneeIDs = append(assigneeIDs, uint(v))
|
||||
case uint:
|
||||
assigneeIDs = append(assigneeIDs, v)
|
||||
case int:
|
||||
assigneeIDs = append(assigneeIDs, uint(v))
|
||||
case string:
|
||||
// 先尝试解析为单个 ID
|
||||
if id, err := strconv.ParseUint(v, 10, 64); err == nil {
|
||||
assigneeIDs = append(assigneeIDs, uint(id))
|
||||
} else {
|
||||
// 再尝试解析为 JSON 数组(如 "[1,2,3]")
|
||||
var ids []uint
|
||||
if err := json.Unmarshal([]byte(v), &ids); err == nil {
|
||||
assigneeIDs = append(assigneeIDs, ids...)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 静态数字 ID
|
||||
var uid uint
|
||||
if _, err := fmt.Sscanf(uidStr, "%d", &uid); err == nil {
|
||||
assigneeIDs = append(assigneeIDs, uid)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(assigneeIDs) == 0 && len(node.Roles) > 0 {
|
||||
// 根据角色编码查找用户
|
||||
var users []model.User
|
||||
tx.Where("role IN ?", node.Roles).Find(&users)
|
||||
for _, u := range users {
|
||||
assigneeIDs = append(assigneeIDs, u.ID)
|
||||
}
|
||||
}
|
||||
|
||||
if len(assigneeIDs) == 0 {
|
||||
// 没有找到审批人 — 跳过此节点,自动流转到下一节点(适用于未配置分管领导等情况)
|
||||
return e.moveToNextNodesTx(tx, instance, nodes, node.NodeID)
|
||||
}
|
||||
|
||||
// 为每个审批人创建任务
|
||||
for _, assigneeID := range assigneeIDs {
|
||||
task := &model.Task{
|
||||
InstanceID: instance.ID,
|
||||
NodeID: node.NodeID,
|
||||
NodeName: node.NodeName,
|
||||
Status: 0, // 待处理
|
||||
AssigneeID: &assigneeID,
|
||||
}
|
||||
if err := tx.Create(task).Error; err != nil {
|
||||
return fmt.Errorf("创建任务失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleGatewayNode 处理网关节点
|
||||
func (e *Engine) handleGatewayNode(instance *model.ProcessInstance, nodes []Node, gwNode *Node) error {
|
||||
if gwNode.GwConfig == nil {
|
||||
return e.moveToNextNodes(instance, nodes, gwNode.NodeID)
|
||||
}
|
||||
|
||||
// 解析流程变量
|
||||
var variables map[string]interface{}
|
||||
if instance.Variables != "" {
|
||||
json.Unmarshal([]byte(instance.Variables), &variables)
|
||||
}
|
||||
if variables == nil {
|
||||
variables = make(map[string]interface{})
|
||||
}
|
||||
|
||||
// 评估条件
|
||||
for _, cond := range gwNode.GwConfig.Conditions {
|
||||
if evaluateCondition(cond.Expression, variables) {
|
||||
return e.moveToNextNodes(instance, nodes, cond.NodeID)
|
||||
}
|
||||
}
|
||||
|
||||
// 没有条件满足,走必经节点
|
||||
if len(gwNode.GwConfig.InevitableNodes) > 0 {
|
||||
return e.moveToNextNodes(instance, nodes, gwNode.GwConfig.InevitableNodes[0])
|
||||
}
|
||||
|
||||
return e.moveToNextNodes(instance, nodes, gwNode.NodeID)
|
||||
}
|
||||
|
||||
// evaluateCondition 简单条件评估(支持 $days>=3 这样的表达式)
|
||||
func evaluateCondition(expression string, variables map[string]interface{}) bool {
|
||||
// 简单实现:解析 "$key op value" 格式
|
||||
var key string
|
||||
var op string
|
||||
var expected float64
|
||||
n, _ := fmt.Sscanf(expression, "$%s %s %f", &key, &op, &expected)
|
||||
if n != 3 {
|
||||
return false
|
||||
}
|
||||
|
||||
val, ok := variables[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
var numVal float64
|
||||
switch v := val.(type) {
|
||||
case float64:
|
||||
numVal = v
|
||||
case int:
|
||||
numVal = float64(v)
|
||||
case json.Number:
|
||||
numVal, _ = v.Float64()
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
switch op {
|
||||
case ">=":
|
||||
return numVal >= expected
|
||||
case ">":
|
||||
return numVal > expected
|
||||
case "<=":
|
||||
return numVal <= expected
|
||||
case "<":
|
||||
return numVal < expected
|
||||
case "==":
|
||||
return numVal == expected
|
||||
case "!=":
|
||||
return numVal != expected
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ApproveTask 审批通过任务
|
||||
func (e *Engine) ApproveTask(taskID uint, userID uint, comment string) error {
|
||||
var task model.Task
|
||||
if err := e.db.First(&task, taskID).Error; err != nil {
|
||||
return errors.New("任务不存在")
|
||||
}
|
||||
if task.Status != 0 {
|
||||
return errors.New("任务已处理")
|
||||
}
|
||||
// 验证审批人身份
|
||||
if task.AssigneeID == nil || *task.AssigneeID != userID {
|
||||
return errors.New("您不是该任务的审批人")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// 开启事务,保证一致性
|
||||
tx := e.db.Begin()
|
||||
|
||||
if err := tx.Model(&task).Updates(map[string]interface{}{
|
||||
"status": 1, // 已完成
|
||||
"comment": comment,
|
||||
"handled_at": now,
|
||||
}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("更新任务失败: %w", err)
|
||||
}
|
||||
|
||||
// 获取实例和流程定义
|
||||
var instance model.ProcessInstance
|
||||
if err := tx.First(&instance, task.InstanceID).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
var def model.ProcessDefinition
|
||||
if err := tx.First(&def, instance.ProcessDefID).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
nodes, err := e.parseNodes(&def)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
// 查找当前节点的定义
|
||||
currentNode := findNodeByID(nodes, task.NodeID)
|
||||
needAllApprovers := currentNode != nil && currentNode.IsCosigned == 1
|
||||
|
||||
if needAllApprovers {
|
||||
// 会签模式(is_cosigned=1):需要所有任务都完成
|
||||
var pendingCount int64
|
||||
tx.Model(&model.Task{}).
|
||||
Where("instance_id = ? AND node_id = ? AND status = 0", task.InstanceID, task.NodeID).
|
||||
Count(&pendingCount)
|
||||
if pendingCount > 0 {
|
||||
tx.Commit()
|
||||
return nil // 等待其他审批人
|
||||
}
|
||||
}
|
||||
// 非会签模式(is_cosigned=0 或不设置):一人通过即流转下一节点
|
||||
// 取消本节点其他待处理任务,防止并发重复创建下一节点
|
||||
if !needAllApprovers {
|
||||
tx.Model(&model.Task{}).
|
||||
Where("instance_id = ? AND node_id = ? AND status = 0 AND id != ?", task.InstanceID, task.NodeID, task.ID).
|
||||
Update("status", 3) // 已取消
|
||||
}
|
||||
|
||||
// 流转到下一个节点(在同一事务中)
|
||||
if err := e.moveToNextNodesTx(tx, &instance, nodes, task.NodeID); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit().Error
|
||||
}
|
||||
|
||||
// RejectTask 审批拒绝任务
|
||||
func (e *Engine) RejectTask(taskID uint, userID uint, comment string) error {
|
||||
var task model.Task
|
||||
if err := e.db.First(&task, taskID).Error; err != nil {
|
||||
return errors.New("任务不存在")
|
||||
}
|
||||
if task.Status != 0 {
|
||||
return errors.New("任务已处理")
|
||||
}
|
||||
// 验证审批人身份
|
||||
if task.AssigneeID == nil || *task.AssigneeID != userID {
|
||||
return errors.New("您不是该任务的审批人")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
tx := e.db.Begin()
|
||||
|
||||
if err := tx.Model(&task).Updates(map[string]interface{}{
|
||||
"status": 2, // 已拒绝
|
||||
"comment": comment,
|
||||
"handled_at": now,
|
||||
}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("更新任务失败: %w", err)
|
||||
}
|
||||
|
||||
// 终止流程实例
|
||||
var instance model.ProcessInstance
|
||||
if err := tx.First(&instance, task.InstanceID).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&instance).Updates(map[string]interface{}{
|
||||
"status": 2, // 已终止
|
||||
"finished_at": now,
|
||||
}).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
// 更新业务状态
|
||||
if err := e.updateAppointmentStatus(tx, &instance, 2); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit().Error
|
||||
}
|
||||
|
||||
// checkNodeCompletion 检查节点所有任务是否完成
|
||||
func (e *Engine) checkNodeCompletion(instanceID uint, nodeID string) (*Node, bool, error) {
|
||||
// 检查是否还有待处理的任务
|
||||
var pendingCount int64
|
||||
e.db.Model(&model.Task{}).
|
||||
Where("instance_id = ? AND node_id = ? AND status = 0", instanceID, nodeID).
|
||||
Count(&pendingCount)
|
||||
|
||||
return nil, pendingCount == 0, nil
|
||||
}
|
||||
|
||||
// updateAppointmentStatus 工作流完成后更新业务表状态
|
||||
func (e *Engine) updateAppointmentStatus(tx *gorm.DB, instance *model.ProcessInstance, apptStatus int) error {
|
||||
if instance.BusinessType != "appointment" {
|
||||
return nil // 只处理预约业务
|
||||
}
|
||||
return tx.Model(&model.Appointment{}).Where("id = ?", instance.BusinessID).Updates(map[string]interface{}{
|
||||
"status": apptStatus,
|
||||
}).Error
|
||||
}
|
||||
787
sc-lktx-backend/internal/modules/workflow/handler.go
Normal file
787
sc-lktx-backend/internal/modules/workflow/handler.go
Normal file
@@ -0,0 +1,787 @@
|
||||
// 工作流模块 HTTP Handler
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"com.sclktx/m/v2/internal/common/model"
|
||||
"com.sclktx/m/v2/internal/common/response"
|
||||
"com.sclktx/m/v2/internal/common/utils"
|
||||
"com.sclktx/m/v2/internal/pkg/dingtalk"
|
||||
"com.sclktx/m/v2/internal/pkg/wechat/templates"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sirupsen/logrus"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Handler 工作流处理器
|
||||
type Handler struct {
|
||||
db *gorm.DB
|
||||
engine *Engine
|
||||
wxSend func(openid, templateID string, data map[string]string, page string) error
|
||||
dingTalk *dingtalk.Client
|
||||
}
|
||||
|
||||
// NewHandler 创建工作流处理器
|
||||
func NewHandler(db *gorm.DB, wxSend func(openid, templateID string, data map[string]string, page string) error, dingTalk *dingtalk.Client) *Handler {
|
||||
return &Handler{
|
||||
db: db,
|
||||
engine: NewEngine(db),
|
||||
wxSend: wxSend,
|
||||
dingTalk: dingTalk,
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 请求/响应结构 ====================
|
||||
|
||||
// CreateDefinitionReq 创建流程定义请求
|
||||
type CreateDefinitionReq struct {
|
||||
ProcessName string `json:"process_name" binding:"required"`
|
||||
ProcessCode string `json:"process_code" binding:"required"`
|
||||
Source string `json:"source"`
|
||||
Description string `json:"description"`
|
||||
RevokeEvents string `json:"revoke_events"`
|
||||
NodesJSON string `json:"nodes_json"`
|
||||
}
|
||||
|
||||
// UpdateDefinitionReq 更新流程定义请求
|
||||
type UpdateDefinitionReq struct {
|
||||
ProcessName string `json:"process_name"`
|
||||
Source string `json:"source"`
|
||||
Description string `json:"description"`
|
||||
IsActive *bool `json:"is_active"`
|
||||
RevokeEvents string `json:"revoke_events"`
|
||||
NodesJSON string `json:"nodes_json"`
|
||||
}
|
||||
|
||||
// StartInstanceReq 启动流程实例请求
|
||||
type StartInstanceReq struct {
|
||||
ProcessCode string `json:"process_code" binding:"required"`
|
||||
BusinessType string `json:"business_type" binding:"required"`
|
||||
BusinessID uint `json:"business_id" binding:"required"`
|
||||
Variables map[string]interface{} `json:"variables"`
|
||||
}
|
||||
|
||||
// TaskActionReq 任务操作请求
|
||||
type TaskActionReq struct {
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
|
||||
// ==================== 路由注册 ====================
|
||||
|
||||
// RegisterRoutes 注册工作流路由(需要 JWT 认证)
|
||||
func (h *Handler) RegisterRoutes(router *gin.RouterGroup) {
|
||||
// 流程定义(管理员操作)
|
||||
router.GET("/workflow/definitions", h.ListDefinitions)
|
||||
router.GET("/workflow/definitions/:id", h.GetDefinition)
|
||||
router.POST("/workflow/definitions", h.CreateDefinition)
|
||||
router.PUT("/workflow/definitions/:id", h.UpdateDefinition)
|
||||
router.DELETE("/workflow/definitions/:id", h.DeleteDefinition)
|
||||
|
||||
// 流程实例
|
||||
router.POST("/workflow/instances", h.StartInstance)
|
||||
router.GET("/workflow/instances", h.ListInstances)
|
||||
router.GET("/workflow/instances/:id", h.GetInstanceDetail)
|
||||
|
||||
// 任务
|
||||
router.GET("/workflow/tasks/pending", h.GetPendingTasks)
|
||||
router.POST("/workflow/tasks/:id/approve", h.ApproveTask)
|
||||
router.POST("/workflow/tasks/:id/reject", h.RejectTask)
|
||||
}
|
||||
|
||||
// ==================== 流程定义管理 ====================
|
||||
|
||||
// ListDefinitions 获取流程定义列表
|
||||
// @Summary 获取流程定义列表
|
||||
// @Description 获取所有流程定义列表
|
||||
// @Tags 工作流
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param page query int false "页码" default(1)
|
||||
// @Param page_size query int false "每页条数" default(10)
|
||||
// @Success 200 {object} response.Response "success"
|
||||
// @Router /api/v1/workflow/definitions [get]
|
||||
func (h *Handler) ListDefinitions(c *gin.Context) {
|
||||
page, pageSize := utils.ParsePagination(c)
|
||||
|
||||
var defs []model.ProcessDefinition
|
||||
var total int64
|
||||
|
||||
query := h.db.Model(&model.ProcessDefinition{})
|
||||
query.Count(&total)
|
||||
query.Offset(utils.GetOffset(page, pageSize)).
|
||||
Limit(pageSize).
|
||||
Order("created_at DESC").
|
||||
Find(&defs)
|
||||
|
||||
response.SuccessPage(c, defs, total, page, pageSize)
|
||||
}
|
||||
|
||||
// GetDefinition 获取流程定义详情
|
||||
// @Summary 获取流程定义详情
|
||||
// @Description 根据ID获取流程定义详情
|
||||
// @Tags 工作流
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path uint true "流程定义ID"
|
||||
// @Success 200 {object} response.Response "success"
|
||||
// @Router /api/v1/workflow/definitions/{id} [get]
|
||||
func (h *Handler) GetDefinition(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
var def model.ProcessDefinition
|
||||
if err := h.db.First(&def, id).Error; err != nil {
|
||||
response.NotFound(c, "流程定义不存在")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, def)
|
||||
}
|
||||
|
||||
// CreateDefinition 创建流程定义
|
||||
// @Summary 创建流程定义
|
||||
// @Description 创建一个新的流程定义(管理员操作)
|
||||
// @Tags 工作流
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param body body CreateDefinitionReq true "创建流程定义请求"
|
||||
// @Success 200 {object} response.Response "success"
|
||||
// @Router /api/v1/workflow/definitions [post]
|
||||
func (h *Handler) CreateDefinition(c *gin.Context) {
|
||||
var req CreateDefinitionReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
// 检查编码唯一性
|
||||
var existCount int64
|
||||
h.db.Model(&model.ProcessDefinition{}).Where("process_code = ?", req.ProcessCode).Count(&existCount)
|
||||
if existCount > 0 {
|
||||
response.BadRequest(c, "流程编码已存在")
|
||||
return
|
||||
}
|
||||
|
||||
def := model.ProcessDefinition{
|
||||
ProcessName: req.ProcessName,
|
||||
ProcessCode: req.ProcessCode,
|
||||
Source: req.Source,
|
||||
Description: req.Description,
|
||||
IsActive: true,
|
||||
RevokeEvents: req.RevokeEvents,
|
||||
NodesJSON: req.NodesJSON,
|
||||
}
|
||||
|
||||
if err := h.db.Create(&def).Error; err != nil {
|
||||
response.InternalError(c, "创建失败")
|
||||
return
|
||||
}
|
||||
|
||||
response.SuccessWithMessage(c, "创建成功", def)
|
||||
}
|
||||
|
||||
// UpdateDefinition 更新流程定义
|
||||
// @Summary 更新流程定义
|
||||
// @Description 更新指定流程定义的信息(管理员操作)
|
||||
// @Tags 工作流
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path uint true "流程定义ID"
|
||||
// @Param body body UpdateDefinitionReq true "更新流程定义请求"
|
||||
// @Success 200 {object} response.Response "success"
|
||||
// @Router /api/v1/workflow/definitions/{id} [put]
|
||||
func (h *Handler) UpdateDefinition(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
var def model.ProcessDefinition
|
||||
if err := h.db.First(&def, id).Error; err != nil {
|
||||
response.NotFound(c, "流程定义不存在")
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateDefinitionReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{}
|
||||
if req.ProcessName != "" {
|
||||
updates["process_name"] = req.ProcessName
|
||||
}
|
||||
if req.Source != "" {
|
||||
updates["source"] = req.Source
|
||||
}
|
||||
if req.Description != "" {
|
||||
updates["description"] = req.Description
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
updates["is_active"] = *req.IsActive
|
||||
}
|
||||
if req.RevokeEvents != "" {
|
||||
updates["revoke_events"] = req.RevokeEvents
|
||||
}
|
||||
if req.NodesJSON != "" {
|
||||
updates["nodes_json"] = req.NodesJSON
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
if err := h.db.Model(&def).Updates(updates).Error; err != nil {
|
||||
response.InternalError(c, "更新失败")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
response.SuccessWithMessage(c, "更新成功", nil)
|
||||
}
|
||||
|
||||
// DeleteDefinition 删除流程定义
|
||||
// @Summary 删除流程定义
|
||||
// @Description 删除指定的流程定义(管理员操作,存在运行中的实例时无法删除)
|
||||
// @Tags 工作流
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path uint true "流程定义ID"
|
||||
// @Success 200 {object} response.Response "success"
|
||||
// @Router /api/v1/workflow/definitions/{id} [delete]
|
||||
func (h *Handler) DeleteDefinition(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否有运行中的实例
|
||||
var activeCount int64
|
||||
h.db.Model(&model.ProcessInstance{}).Where("process_def_id = ? AND status = 0", id).Count(&activeCount)
|
||||
if activeCount > 0 {
|
||||
response.BadRequest(c, "存在运行中的流程实例,无法删除")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.Delete(&model.ProcessDefinition{}, id).Error; err != nil {
|
||||
response.InternalError(c, "删除失败")
|
||||
return
|
||||
}
|
||||
|
||||
response.SuccessWithMessage(c, "删除成功", nil)
|
||||
}
|
||||
|
||||
// ==================== 流程实例管理 ====================
|
||||
|
||||
// StartInstance 启动流程实例
|
||||
// @Summary 启动流程实例
|
||||
// @Description 根据流程定义启动一个新的流程实例
|
||||
// @Tags 工作流
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param body body StartInstanceReq true "启动流程实例请求"
|
||||
// @Success 200 {object} response.Response "success"
|
||||
// @Router /api/v1/workflow/instances [post]
|
||||
func (h *Handler) StartInstance(c *gin.Context) {
|
||||
var req StartInstanceReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
// 查找流程定义
|
||||
var def model.ProcessDefinition
|
||||
if err := h.db.Where("process_code = ?", req.ProcessCode).First(&def).Error; err != nil {
|
||||
response.NotFound(c, "流程定义不存在")
|
||||
return
|
||||
}
|
||||
|
||||
userID := utils.GetUserID(c)
|
||||
|
||||
instance, err := h.engine.StartInstance(def.ID, req.BusinessType, req.BusinessID, userID, req.Variables)
|
||||
if err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.SuccessWithMessage(c, "启动成功", instance)
|
||||
}
|
||||
|
||||
// ListInstances 获取流程实例列表
|
||||
// @Summary 获取流程实例列表
|
||||
// @Description 获取所有流程实例列表,可按业务类型和状态筛选
|
||||
// @Tags 工作流
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param business_type query string false "业务类型"
|
||||
// @Param status query int false "实例状态"
|
||||
// @Param page query int false "页码" default(1)
|
||||
// @Param page_size query int false "每页条数" default(10)
|
||||
// @Success 200 {object} response.Response "success"
|
||||
// @Router /api/v1/workflow/instances [get]
|
||||
func (h *Handler) ListInstances(c *gin.Context) {
|
||||
page, pageSize := utils.ParsePagination(c)
|
||||
businessType := c.Query("business_type")
|
||||
businessIDStr := c.Query("business_id")
|
||||
statusStr := c.Query("status")
|
||||
|
||||
var instances []model.ProcessInstance
|
||||
var total int64
|
||||
|
||||
query := h.db.Model(&model.ProcessInstance{}).
|
||||
Preload("ProcessDef").
|
||||
Preload("Starter")
|
||||
if businessType != "" {
|
||||
query = query.Where("business_type = ?", businessType)
|
||||
}
|
||||
if businessIDStr != "" {
|
||||
businessID, _ := strconv.ParseUint(businessIDStr, 10, 64)
|
||||
if businessID > 0 {
|
||||
query = query.Where("business_id = ?", businessID)
|
||||
}
|
||||
}
|
||||
if statusStr != "" {
|
||||
status, _ := strconv.Atoi(statusStr)
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
|
||||
query.Count(&total)
|
||||
query.Offset(utils.GetOffset(page, pageSize)).
|
||||
Limit(pageSize).
|
||||
Order("created_at DESC").
|
||||
Find(&instances)
|
||||
|
||||
response.SuccessPage(c, instances, total, page, pageSize)
|
||||
}
|
||||
|
||||
// GetInstanceDetail 获取流程实例详情(含任务列表)
|
||||
// @Summary 获取流程实例详情
|
||||
// @Description 获取流程实例详情,包含关联的任务列表
|
||||
// @Tags 工作流
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path uint true "流程实例ID"
|
||||
// @Success 200 {object} response.Response "success"
|
||||
// @Router /api/v1/workflow/instances/{id} [get]
|
||||
func (h *Handler) GetInstanceDetail(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
var instance model.ProcessInstance
|
||||
if err := h.db.Preload("ProcessDef").Preload("Starter").First(&instance, id).Error; err != nil {
|
||||
response.NotFound(c, "流程实例不存在")
|
||||
return
|
||||
}
|
||||
|
||||
var tasks []model.Task
|
||||
h.db.Where("instance_id = ?", id).
|
||||
Preload("Assignee").
|
||||
Order("created_at ASC").
|
||||
Find(&tasks)
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"instance": instance,
|
||||
"tasks": tasks,
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 任务管理 ====================
|
||||
|
||||
// GetPendingTasks 获取当前用户的待办任务
|
||||
// @Summary 获取待办任务
|
||||
// @Description 获取当前用户的待处理流程任务列表
|
||||
// @Tags 工作流
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} response.Response "success"
|
||||
// @Router /api/v1/workflow/tasks/pending [get]
|
||||
func (h *Handler) GetPendingTasks(c *gin.Context) {
|
||||
userID := utils.GetUserID(c)
|
||||
|
||||
var tasks []model.Task
|
||||
h.db.Where("assignee_id = ? AND status = 0", userID).
|
||||
Preload("Instance").
|
||||
Preload("Instance.ProcessDef").
|
||||
Preload("Assignee").
|
||||
Order("created_at DESC").
|
||||
Find(&tasks)
|
||||
|
||||
response.Success(c, tasks)
|
||||
}
|
||||
|
||||
// ApproveTask 审批通过任务
|
||||
// @Summary 审批通过任务
|
||||
// @Description 审批通过指定的流程任务
|
||||
// @Tags 工作流
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path uint true "任务ID"
|
||||
// @Param body body TaskActionReq true "审批请求"
|
||||
// @Success 200 {object} response.Response "success"
|
||||
// @Router /api/v1/workflow/tasks/{id}/approve [post]
|
||||
func (h *Handler) ApproveTask(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
var req TaskActionReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
userID := utils.GetUserID(c)
|
||||
|
||||
if err := h.engine.ApproveTask(uint(id), userID, req.Comment); err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 审批完成后发送通知
|
||||
h.sendAppointmentNotification(uint(id))
|
||||
h.sendDingTalkAfterApproval(uint(id), req.Comment)
|
||||
|
||||
response.SuccessWithMessage(c, "审批通过", nil)
|
||||
}
|
||||
|
||||
// RejectTask 审批拒绝任务
|
||||
// @Summary 审批拒绝任务
|
||||
// @Description 拒绝指定的流程任务
|
||||
// @Tags 工作流
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path uint true "任务ID"
|
||||
// @Param body body TaskActionReq true "拒绝请求"
|
||||
// @Success 200 {object} response.Response "success"
|
||||
// @Router /api/v1/workflow/tasks/{id}/reject [post]
|
||||
func (h *Handler) RejectTask(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
var req TaskActionReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
userID := utils.GetUserID(c)
|
||||
|
||||
if err := h.engine.RejectTask(uint(id), userID, req.Comment); err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// 拒绝后发送通知
|
||||
h.sendAppointmentNotification(uint(id))
|
||||
h.sendDingTalkAfterRejection(uint(id), req.Comment)
|
||||
|
||||
response.SuccessWithMessage(c, "已拒绝", nil)
|
||||
}
|
||||
|
||||
// sendAppointmentNotification 审批结束后发送微信通知给申请人
|
||||
func (h *Handler) sendAppointmentNotification(taskID uint) {
|
||||
var task model.Task
|
||||
if err := h.db.Preload("Instance").First(&task, taskID).Error; err != nil {
|
||||
return
|
||||
}
|
||||
if task.Instance == nil || task.Instance.BusinessType != "appointment" {
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
var appointment model.Appointment
|
||||
if err := h.db.First(&appointment, task.Instance.BusinessID).Error; err != nil {
|
||||
return
|
||||
}
|
||||
// 只发送已通过或已拒绝的通知
|
||||
if appointment.Status != 1 && appointment.Status != 2 {
|
||||
return
|
||||
}
|
||||
|
||||
var creatorUser model.User
|
||||
if err := h.db.First(&creatorUser, appointment.CreatorUserID).Error; err != nil || creatorUser.Openid == "" {
|
||||
return
|
||||
}
|
||||
|
||||
resultText := "已通过"
|
||||
if appointment.Status == 2 {
|
||||
resultText = "已拒绝"
|
||||
}
|
||||
|
||||
// 解析 JSON 字段
|
||||
var employeeInfo map[string]interface{}
|
||||
json.Unmarshal([]byte(appointment.EmployeeInfo), &employeeInfo)
|
||||
var visitorInfo map[string]interface{}
|
||||
json.Unmarshal([]byte(appointment.VisitorInfo), &visitorInfo)
|
||||
company, _ := visitorInfo["company"].(string)
|
||||
employeeName, _ := employeeInfo["name"].(string)
|
||||
|
||||
var areasInfo []string
|
||||
json.Unmarshal([]byte(appointment.AreasInfo), &areasInfo)
|
||||
areaText := strings.Join(areasInfo, "、")
|
||||
|
||||
// 格式化时间(微信 thing 类型字段限 20 字)
|
||||
timeStr := ""
|
||||
if !appointment.VisitStartTime.IsZero() {
|
||||
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||
timeStr = appointment.VisitStartTime.In(loc).Format("01-02 15:04") + "-" + appointment.VisitEndTime.In(loc).Format("15:04")
|
||||
}
|
||||
|
||||
// TMPL_RESULT: 访客申请结果通知(thing 字段限 20 字)
|
||||
trunc := func(s string) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) > 20 {
|
||||
return string(runes[:20])
|
||||
}
|
||||
return s
|
||||
}
|
||||
tmplID := templates.TMPL_RESULT
|
||||
if h.wxSend != nil {
|
||||
h.wxSend(creatorUser.Openid, tmplID, map[string]string{
|
||||
"thing1": trunc(resultText),
|
||||
"thing2": trunc(company),
|
||||
"thing3": trunc(timeStr),
|
||||
"thing8": trunc(employeeName),
|
||||
"thing9": trunc(areaText),
|
||||
}, fmt.Sprintf("/subpackages/appointment/appointment-detail?id=%d", appointment.ID))
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// sendDingTalkAfterApproval 审批通过后发送钉钉消息
|
||||
func (h *Handler) sendDingTalkAfterApproval(taskID uint, comment string) {
|
||||
if h.dingTalk == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var task model.Task
|
||||
if err := h.db.Preload("Instance").First(&task, taskID).Error; err != nil {
|
||||
return
|
||||
}
|
||||
if task.Instance == nil || task.Instance.BusinessType != "appointment" || task.Instance.Status != 0 {
|
||||
return
|
||||
}
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"task_id": taskID,
|
||||
"instance_id": task.InstanceID,
|
||||
}).Info("钉钉:审批通过,开始发送通知")
|
||||
|
||||
go func() {
|
||||
// 获取审批人信息
|
||||
var approverUser model.User
|
||||
if task.AssigneeID == nil {
|
||||
return
|
||||
}
|
||||
if err := h.db.First(&approverUser, *task.AssigneeID).Error; err != nil {
|
||||
logrus.WithError(err).WithField("task_id", taskID).Warn("钉钉:查询审批人信息失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取预约信息
|
||||
var appointment model.Appointment
|
||||
if err := h.db.First(&appointment, task.Instance.BusinessID).Error; err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var visitorInfo struct {
|
||||
Name string `json:"name"`
|
||||
Company string `json:"company"`
|
||||
}
|
||||
json.Unmarshal([]byte(appointment.VisitorInfo), &visitorInfo)
|
||||
visitTime := ""
|
||||
if !appointment.VisitStartTime.IsZero() {
|
||||
visitTime = dingtalk.FormatCSTTime(appointment.VisitStartTime, appointment.VisitEndTime)
|
||||
}
|
||||
|
||||
// 1. 通知被取消的同节点审批人(同一节点非会签模式,其他人审批后当前节点其他人被取消)
|
||||
var cancelledTasks []model.Task
|
||||
if err := h.db.Where("instance_id = ? AND node_id = ? AND status = 3 AND id != ?",
|
||||
task.InstanceID, task.NodeID, task.ID).Find(&cancelledTasks).Error; err == nil && len(cancelledTasks) > 0 {
|
||||
var userIDs []string
|
||||
for _, ct := range cancelledTasks {
|
||||
if ct.AssigneeID != nil {
|
||||
var ud model.UserDepartment
|
||||
if err := h.db.Where("user_id = ?", *ct.AssigneeID).First(&ud).Error; err == nil && ud.DingTalkUserID != "" {
|
||||
userIDs = append(userIDs, ud.DingTalkUserID)
|
||||
} else if err != nil {
|
||||
logrus.WithError(err).WithField("user_id", *ct.AssigneeID).Warn("钉钉:查询同节点取消人 UserDepartment 失败")
|
||||
} else {
|
||||
logrus.WithField("user_id", *ct.AssigneeID).Warn("钉钉:同节点取消人 DingTalkUserID 为空")
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(userIDs) > 0 {
|
||||
title, markdown := dingtalk.BuildSameNodeCancelledMsg(approverUser.RealName, comment, visitTime)
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"user_ids": userIDs,
|
||||
"title": title,
|
||||
}).Info("钉钉:发送同节点取消通知")
|
||||
if err := h.dingTalk.BatchSendRobotMessage(userIDs, title, markdown); err != nil {
|
||||
logrus.WithError(err).Error("钉钉发送同节点取消通知失败")
|
||||
}
|
||||
} else {
|
||||
logrus.WithField("cancelled_count", len(cancelledTasks)).Info("钉钉:同节点已取消任务无有效钉钉ID")
|
||||
}
|
||||
} else {
|
||||
logrus.WithField("has_cancelled", len(cancelledTasks) > 0).Info("钉钉:无同节点取消任务")
|
||||
}
|
||||
|
||||
// 2. 通知下一个节点审批人
|
||||
var pendingTasks []model.Task
|
||||
if err := h.db.Where("instance_id = ? AND status = 0", task.InstanceID).Find(&pendingTasks).Error; err == nil && len(pendingTasks) > 0 {
|
||||
var userIDs []string
|
||||
for _, pt := range pendingTasks {
|
||||
if pt.AssigneeID != nil {
|
||||
var ud model.UserDepartment
|
||||
if err := h.db.Where("user_id = ?", *pt.AssigneeID).First(&ud).Error; err == nil && ud.DingTalkUserID != "" {
|
||||
userIDs = append(userIDs, ud.DingTalkUserID)
|
||||
} else if err != nil {
|
||||
logrus.WithError(err).WithField("user_id", *pt.AssigneeID).Warn("钉钉:查询下一节点审批人 UserDepartment 失败")
|
||||
} else {
|
||||
logrus.WithField("user_id", *pt.AssigneeID).Warn("钉钉:下一节点审批人 DingTalkUserID 为空")
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(userIDs) > 0 {
|
||||
msg := &dingtalk.AppointmentResultMessage{
|
||||
ApproverName: approverUser.RealName,
|
||||
Action: "通过",
|
||||
Comment: comment,
|
||||
AppointmentID: appointment.ID,
|
||||
VisitTime: visitTime,
|
||||
VisitorName: visitorInfo.Name,
|
||||
}
|
||||
title, markdown := dingtalk.BuildApproveNotificationMsg(msg)
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"user_ids": userIDs,
|
||||
"title": title,
|
||||
}).Info("钉钉:发送下一节点审批人通知")
|
||||
if err := h.dingTalk.BatchSendRobotMessage(userIDs, title, markdown); err != nil {
|
||||
logrus.WithError(err).Error("钉钉发送下一节点通知失败")
|
||||
}
|
||||
} else {
|
||||
logrus.WithField("pending_count", len(pendingTasks)).Info("钉钉:下一节点无有效钉钉ID的审批人")
|
||||
}
|
||||
} else {
|
||||
logrus.WithField("has_pending", len(pendingTasks) > 0).Info("钉钉:无下一节点待审批任务")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// sendDingTalkAfterRejection 审批拒绝后发送钉钉消息
|
||||
func (h *Handler) sendDingTalkAfterRejection(taskID uint, comment string) {
|
||||
if h.dingTalk == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var task model.Task
|
||||
if err := h.db.Preload("Instance").First(&task, taskID).Error; err != nil {
|
||||
return
|
||||
}
|
||||
if task.Instance == nil || task.Instance.BusinessType != "appointment" {
|
||||
return
|
||||
}
|
||||
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"task_id": taskID,
|
||||
"instance_id": task.InstanceID,
|
||||
}).Info("钉钉:审批拒绝,开始发送通知")
|
||||
|
||||
go func() {
|
||||
// 获取拒绝人信息
|
||||
var rejector model.User
|
||||
if task.AssigneeID == nil {
|
||||
return
|
||||
}
|
||||
if err := h.db.First(&rejector, *task.AssigneeID).Error; err != nil {
|
||||
logrus.WithError(err).WithField("task_id", taskID).Warn("钉钉:查询拒绝人信息失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取预约信息
|
||||
var appointment model.Appointment
|
||||
if err := h.db.First(&appointment, task.Instance.BusinessID).Error; err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var visitorInfo struct {
|
||||
Name string `json:"name"`
|
||||
Company string `json:"company"`
|
||||
}
|
||||
json.Unmarshal([]byte(appointment.VisitorInfo), &visitorInfo)
|
||||
visitTime := ""
|
||||
if !appointment.VisitStartTime.IsZero() {
|
||||
visitTime = dingtalk.FormatCSTTime(appointment.VisitStartTime, appointment.VisitEndTime)
|
||||
}
|
||||
|
||||
// 查找本实例中其他待审批任务,通知其审批人流程已终止
|
||||
var otherPendingTasks []model.Task
|
||||
if err := h.db.Where("instance_id = ? AND status = 0 AND id != ?", task.InstanceID, task.ID).Find(&otherPendingTasks).Error; err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var userIDs []string
|
||||
for _, pt := range otherPendingTasks {
|
||||
if pt.AssigneeID != nil {
|
||||
var ud model.UserDepartment
|
||||
if err := h.db.Where("user_id = ?", *pt.AssigneeID).First(&ud).Error; err == nil && ud.DingTalkUserID != "" {
|
||||
userIDs = append(userIDs, ud.DingTalkUserID)
|
||||
} else if err != nil {
|
||||
logrus.WithError(err).WithField("user_id", *pt.AssigneeID).Warn("钉钉:查询其他待审批人 UserDepartment 失败")
|
||||
} else {
|
||||
logrus.WithField("user_id", *pt.AssigneeID).Warn("钉钉:其他待审批人 DingTalkUserID 为空")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(userIDs) > 0 {
|
||||
msg := &dingtalk.AppointmentResultMessage{
|
||||
ApproverName: rejector.RealName,
|
||||
Action: "拒绝",
|
||||
Comment: comment,
|
||||
AppointmentID: appointment.ID,
|
||||
VisitTime: visitTime,
|
||||
VisitorName: visitorInfo.Name,
|
||||
}
|
||||
title, markdown := dingtalk.BuildRejectNotificationMsg(msg)
|
||||
logrus.WithFields(logrus.Fields{
|
||||
"user_ids": userIDs,
|
||||
"title": title,
|
||||
}).Info("钉钉:发送拒绝通知给其他待审批人")
|
||||
if err := h.dingTalk.BatchSendRobotMessage(userIDs, title, markdown); err != nil {
|
||||
logrus.WithError(err).Error("钉钉发送拒绝通知失败")
|
||||
}
|
||||
} else {
|
||||
logrus.WithField("other_pending_count", len(otherPendingTasks)).Info("钉钉:拒绝后无其他待审批人需通知")
|
||||
}
|
||||
}()
|
||||
}
|
||||
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