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

103
sc-lktx-backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,103 @@
# Go workspace
go.work
go.work.sum
# Go build output
/bin/
/pkg/
/build/
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
# Go vendor
/vendor/
# IDE - GoLand / IntelliJ IDEA
.idea/
*.iml
*.iws
*.ipr
out/
.idea_modules/
atlassian-ide-plugin.xml
# IDE - VS Code (备用)
.vscode/
*.code-workspace
# macOS
.DS_Store
.AppleDouble
.LSOverride
Icon
._*
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Windows
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
*.stackdump
[Dd]esktop.ini
$RECYCLE.BIN/
# Linux
*~
.fuse_hidden*
.directory
.Trash-*
# Environment & Config
.env
.env.local
.env.*.local
!config/config.example.yaml
*.toml
!config/config.example.toml
# Logs
/logs/
*.log
# Dependency directories
node_modules/
# MinIO data
/minio_data/
# PostgreSQL data
/pgdata/
# Redis dump
dump.rdb
# Docker
docker-compose.override.yml
# Upload & Temp
/uploads/
/tmp/
/temp/
# Air live reload
.air.toml
tmp/
# Coverage
*.coverprofile
coverage.html
coverage.out
server

View File

@@ -0,0 +1,38 @@
# ===== 构建阶段 =====
FROM golang:1.22-alpine AS builder
WORKDIR /app
# 安装构建依赖
RUN apk add --no-cache gcc musl-dev
# 先复制 go.mod 和 go.sum 以利用 Docker 缓存
COPY go.mod go.sum ./
RUN go mod download
# 复制源码并构建
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server ./cmd/server
# ===== 运行阶段 =====
FROM alpine:3.19
WORKDIR /app
# 安装运行时依赖ca-certificates 用于 HTTPS 请求tzdata 用于时区)
RUN apk add --no-cache ca-certificates tzdata && \
cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
echo "Asia/Shanghai" > /etc/timezone
# 从构建阶段复制二进制和配置文件
COPY --from=builder /app/server .
COPY --from=builder /app/config/ ./config/
# 健康检查
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
EXPOSE 8080
# 默认使用生产配置,可通过命令行参数覆盖
CMD ["./server", "config/config-prod.yaml"]

42
sc-lktx-backend/Makefile Normal file
View File

@@ -0,0 +1,42 @@
.PHONY: run build clean deps
# 安装依赖
deps:
go mod tidy
go mod download
# 构建
build:
go build -o bin/server ./cmd/server
# 运行
run:
go run ./cmd/server
# 运行(指定配置)
run-prod:
go run ./cmd/server config/config.yaml
# 清理
clean:
rm -rf bin/
# 测试
test:
go test ./...
# Docker 启动基础设施
docker-up:
docker-compose up -d
# Docker 停止
docker-down:
docker-compose down
# 代码格式化
fmt:
go fmt ./...
# 代码检查
lint:
golangci-lint run ./...

34
sc-lktx-backend/build.bat Normal file
View File

@@ -0,0 +1,34 @@
@echo off
chcp 65001 >nul
setlocal enabledelayedexpansion
set OUTPUT=server
set BIN_DIR=bin
echo === Build Go backend (Linux amd64) ===
set GOOS=linux
set GOARCH=amd64
set CGO_ENABLED=0
go build -ldflags="-s -w" -o "%BIN_DIR%/%OUTPUT%" ./cmd/server/
if errorlevel 1 (
echo Build failed, error code: %errorlevel%
exit /b %errorlevel%
)
echo === Build complete ===
echo Output: %BIN_DIR%/%OUTPUT%
if exist config\config-prod.yaml (
copy config\config-prod.yaml "%BIN_DIR%\" >nul
) else (
echo Warning: config-prod.yaml not found
)
echo === Deploy files ===
dir "%BIN_DIR%"
endlocal

28
sc-lktx-backend/build.sh Normal file
View File

@@ -0,0 +1,28 @@
#!/bin/bash
# 编译 Go 后端到 Linux amd64
# 使用方式:./build.sh
set -e
cd "$(dirname "$0")"
OUTPUT="server"
BIN_DIR="bin"
echo "=== 编译 Go 后端Linux amd64==="
# 设置交叉编译环境
export GOOS=linux
export GOARCH=amd64
export CGO_ENABLED=0
go build -ldflags="-s -w" -o "${BIN_DIR}/${OUTPUT}" ./cmd/server/
echo "=== 编译完成 ==="
echo "输出文件:${BIN_DIR}/${OUTPUT}"
file "${BIN_DIR}/${OUTPUT}"
# 复制配置文件
cp config/config-prod.yaml "${BIN_DIR}/" 2>/dev/null || echo "注意config-prod.yaml 不存在"
echo "=== 部署文件 ==="
ls -lh "${BIN_DIR}/"

View File

@@ -0,0 +1,55 @@
server:
port: 28175
mode: release # debug | release | test
database:
host: postgresql
port: 5432
user: postgres
password: password_cA25Gm
dbname: lktx
sslmode: disable
timezone: Asia/Shanghai
redis:
host: redis
port: 6379
password: "redis_WmWQnZ"
db: 0
minio:
endpoint: minio:9000
access_key: tz9sjGPHppJosRVhGp5Y
secret_key: qXO7ptrIymMhvFfAsfi7rb4vDuyLToMe5ghcnVfM
bucket: lktx
use_ssl: false
base_url: https://storage.sclktx.com
rabbitmq:
host: rabbitmq
port: 5672
user: guest
password: guest
vhost: /
wechat:
app_id: wx4c5f6fdb5bfa3b3a
app_secret: 61f9402825ea5808808edf86736c8748
token: your_wechat_token
encoding_aes_key: your_encoding_aes_key
dingtalk:
app_key: dinglmhcikqiqfh2lyys
app_secret: HksFl_FaIShMhnqpK4dr4-dJX52LOe3SWO3L8GlSVPRcSgSk-GpfTJBXi3QNwMLE
robot_code: dinglmhcikqiqfh2lyys
jwt:
secret: lktx-mp-jwt-secret-key-2024
expire_hours: 72
snowflake:
worker_id: 1
datacenter_id: 1
system:
default_avatar: "https://wx.qlogo.cn/mmhead/B2EfAOZfS1hcmGxwIBm2jqd7g94iaWziaURo0f8bsAu3GKKIsTY8NMtweRMJ8H7YXBQXfibkrCAKp0/0"

View File

@@ -0,0 +1,55 @@
server:
port: 28175
mode: debug # debug | release | test
database:
host: 127.0.0.1
port: 5432
user: postgres
password: 123456
dbname: lktx
sslmode: disable
timezone: Asia/Shanghai
redis:
host: 127.0.0.1
port: 6379
password: "123456"
db: 0
minio:
endpoint: 127.0.0.1:9000
access_key: tz9sjGPHppJosRVhGp5Y
secret_key: qXO7ptrIymMhvFfAsfi7rb4vDuyLToMe5ghcnVfM
bucket: lktx
use_ssl: false
base_url: http://127.0.0.1:9000
rabbitmq:
host: 127.0.0.1
port: 5672
user: guest
password: guest
vhost: /
wechat:
app_id: wx4c5f6fdb5bfa3b3a
app_secret: 61f9402825ea5808808edf86736c8748
token: your_wechat_token
encoding_aes_key: your_encoding_aes_key
dingtalk:
app_key: dinglmhcikqiqfh2lyys
app_secret: HksFl_FaIShMhnqpK4dr4-dJX52LOe3SWO3L8GlSVPRcSgSk-GpfTJBXi3QNwMLE
robot_code: dinglmhcikqiqfh2lyys
jwt:
secret: lktx-mp-jwt-secret-key-2024
expire_hours: 72
snowflake:
worker_id: 1
datacenter_id: 1
system:
default_avatar: "https://wx.qlogo.cn/mmhead/B2EfAOZfS1hcmGxwIBm2jqd7g94iaWziaURo0f8bsAu3GKKIsTY8NMtweRMJ8H7YXBQXfibkrCAKp0/0"

View File

@@ -0,0 +1,58 @@
version: '3.8'
services:
postgres:
image: postgres:16-alpine
container_name: lktx-postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres123
POSTGRES_DB: lktx
TZ: Asia/Shanghai
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
redis:
image: redis:7-alpine
container_name: lktx-redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
restart: unless-stopped
minio:
image: minio/minio:latest
container_name: lktx-minio
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio_data:/data
command: server /data --console-address ":9001"
restart: unless-stopped
rabbitmq:
image: rabbitmq:3-management-alpine
container_name: lktx-rabbitmq
environment:
RABBITMQ_DEFAULT_USER: guest
RABBITMQ_DEFAULT_PASS: guest
ports:
- "5672:5672"
- "15672:15672"
volumes:
- rabbitmq_data:/var/lib/rabbitmq
restart: unless-stopped
volumes:
postgres_data:
redis_data:
minio_data:
rabbitmq_data:

View File

@@ -0,0 +1,2 @@
// Package docs 用于 swagger 文档初始化
package docs

View File

@@ -0,0 +1,358 @@
# Easy-Workflow 工作流框架使用指南
## 概述
本项目已集成 easy-workflow 工作流框架,支持可视化的流程编排和灵活的审批流管理。
## 核心概念
### 1. 流程定义 (ProcessDefinition)
流程定义是工作流的模板,描述了一个完整的审批流程结构。
**数据结构:**
```json
{
"process_name": "员工请假",
"process_code": "leave_request",
"source": "办公系统",
"nodes": [
{
"node_id": "Start",
"node_name": "请假申请",
"node_type": 0,
"user_ids": ["$starter"],
"roles": []
},
{
"node_id": "Manager",
"node_name": "主管审批",
"node_type": 1,
"prev_node_ids": ["Start"],
"roles": ["主管"],
"is_cosigned": 0
}
]
}
```
### 2. 节点类型 (NodeType)
- **0 - 开始节点**:流程的起点
- **1 - 审批节点**:需要人工审批的节点
- **2 - 网关节点**:条件分支或并行分支
- **3 - 结束节点**:流程终点
### 3. 流程实例 (ProcessInstance)
每次发起审批时创建的实例,关联具体的业务数据。
### 4. 任务 (Task)
每个审批节点会生成一个或多个待办任务,分配给具体的处理人。
## API 接口
### 流程定义管理
```bash
# 创建流程定义
POST /api/v1/workflow/definitions
Content-Type: application/json
{
"process_name": "访客预约审批",
"process_code": "visitor_approval",
"source": "访客系统",
"nodes_json": "[...]"
}
# 获取流程定义列表
GET /api/v1/workflow/definitions
# 获取流程定义详情
GET /api/v1/workflow/definitions/:id
# 更新流程定义
PUT /api/v1/workflow/definitions/:id
# 删除流程定义
DELETE /api/v1/workflow/definitions/:id
```
### 流程实例管理
```bash
# 启动流程实例
POST /api/v1/workflow/instances
Content-Type: application/json
{
"process_code": "visitor_approval",
"business_type": "appointment",
"business_id": 123,
"variables": {
"days": 3,
"reason": "商务拜访"
}
}
# 获取流程实例列表
GET /api/v1/workflow/instances?business_type=appointment&status=0
# 获取流程实例详情
GET /api/v1/workflow/instances/:id
```
### 任务管理
```bash
# 获取待办任务
GET /api/v1/workflow/tasks/pending
# 审批通过
POST /api/v1/workflow/tasks/:id/approve
Content-Type: application/json
{
"comment": "同意"
}
# 审批拒绝
POST /api/v1/workflow/tasks/:id/reject
Content-Type: application/json
{
"comment": "拒绝,原因:..."
}
```
## 前端使用
### 1. 流程设计器
访问路径:`/subpackages/admin/workflow-designer`
支持功能:
- 创建/编辑流程定义
- 添加/删除节点
- 配置节点类型和审批人
- 设置网关条件
### 2. 流程管理列表
访问路径:`/subpackages/admin/workflow-list`
支持功能:
- 查看所有流程定义
- 编辑/删除流程
- 查看流程实例
### 3. 我的待办
访问路径:`/subpackages/workflow/my-tasks`
支持功能:
- 查看待审批任务
- 快速审批通过/拒绝
### 4. 流程实例详情
访问路径:`/subpackages/admin/workflow-instance-detail?id={instanceId}`
支持功能:
- 查看审批进度时间线
- 查看每个节点的审批意见
- 执行审批操作
## 代码示例
### 发起审批流程
```typescript
import workflowAPI from '@/api/workflow'
// 发起访客预约审批
const instance = await workflowAPI.startInstance({
process_code: 'visitor_approval',
business_type: 'appointment',
business_id: appointmentId,
variables: {
visitDays: 3,
visitorCount: 5
}
})
```
### 获取待办任务
```typescript
// 获取我的待办
const tasks = await workflowAPI.getPendingTasks()
// 遍历处理
tasks.forEach(task => {
console.log(`${task.node_name} - ${task.instance?.process_def?.process_name}`)
})
```
### 审批操作
```typescript
// 审批通过
await workflowAPI.approveTask(taskId, '同意申请')
// 审批拒绝
await workflowAPI.rejectTask(taskId, '拒绝,资料不全')
```
## 高级特性
### 1. 条件网关
```json
{
"node_id": "GW-Day",
"node_name": "请假天数判断",
"node_type": 2,
"gw_config": {
"conditions": [
{
"expression": "$days >= 3",
"node_id": "Manager"
},
{
"expression": "$days < 3",
"node_id": "END"
}
]
}
}
```
### 2. 并行网关
```json
{
"node_id": "GW-Parallel",
"node_name": "并行审批",
"node_type": 2,
"gw_config": {
"inevitable_nodes": ["HR", "DeputyBoss"],
"wait_for_all_prev_node": 1
}
}
```
### 3. 会签节点
```json
{
"node_id": "Board",
"node_name": "董事会审批",
"node_type": 1,
"roles": ["董事 A", "董事 B", "董事 C"],
"is_cosigned": 1
}
```
## 数据库表结构
### wf_process_definitions
- 存储流程定义模板
- 节点配置以 JSON 格式存储
### wf_process_instances
- 存储流程实例
- 关联业务数据
- 记录当前节点
### wf_tasks
- 存储审批任务
- 记录处理人和审批意见
## 迁移现有审批逻辑
### 步骤 1创建流程定义
使用流程设计器创建对应的流程模板。
### 步骤 2修改业务代码
将原有的审批逻辑改为调用工作流引擎:
```go
// 旧代码
approvalHandler.StartInstance(...)
// 新代码
workflowEngine.StartInstance("visitor_approval", "appointment", appointmentID, userID, variables)
```
### 步骤 3数据迁移
将现有的审批记录迁移到新的工作流表(可选)。
## 注意事项
1. **流程变量**:使用 `$` 前缀引用,如 `$days`
2. **角色解析**:审批人可以是具体用户 ID 或角色编码
3. **会签支持**:设置 `is_cosigned=1` 启用会签
4. **网关等待**`wait_for_all_prev_node=1` 表示等待所有前置节点完成
## 常见问题
### Q: 如何添加自定义事件?
A: 在节点的 `node_start_events``node_end_events` 数组中添加事件名称。
### Q: 如何获取审批进度?
A: 调用 `GET /api/v1/workflow/instances/:id` 获取实例详情,包含所有任务记录。
### Q: 支持撤回吗?
A: 当前版本暂不支持,可在流程定义中配置 `revoke_events` 实现。
## 访客预约审批流程示例
### 流程定义(创建时 nodes_json 内容)
```json
[
{"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"]}
]
```
### 流程变量说明
| 变量名 | 类型 | 说明 |
|--------|------|------|
| `$starter` | uint | 发起人的用户 ID引擎自动填入 |
| `$employee_id` | uint | 被访员工用户 ID |
| `$dept_approver_ids` | JSON 数组 | 部门指定审批人 ID 列表,支持多人 |
| `$vp_ids` | JSON 数组 | 分管领导 ID 列表,支持多人,空数组跳过节点 |
### 行为规则
- **`$dept_approver_ids` 为空**:跳过「部门指定人审批」节点
- **`$vp_ids` 为空**:跳过「分管领导审批」节点
- **`$default_approver_ids` 为空**:跳过「兜底审批」节点,流程直接结束
- **多个审批人**:会签模式,需全部通过才流转下一节点
### 兜底审批流程定义visitor_approval_simple
当未匹配到员工或部门未配置审批人时使用:
```json
[
{"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"],"is_cosigned":1},
{"node_id":"End","node_name":"审批完成","node_type":3,"prev_node_ids":["DefaultApproval"]}
]
```
## 技术支持
如有问题,请联系开发团队或查看示例代码。

103
sc-lktx-backend/go.mod Normal file
View File

@@ -0,0 +1,103 @@
module com.sclktx/m/v2
go 1.25.0
require (
github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/google/uuid v1.6.0
github.com/minio/minio-go/v7 v7.2.0
github.com/silenceper/wechat/v2 v2.1.13
github.com/sirupsen/logrus v1.9.4
github.com/spf13/viper v1.18.2
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1
github.com/swaggo/swag v1.16.6
go.uber.org/zap v1.27.0
gorm.io/driver/postgres v1.5.4
gorm.io/gorm v1.25.5
)
require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/PuerkitoBio/purell v1.1.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/alibabacloud-go/dingtalk v1.7.38 // indirect
github.com/bradfitz/gomemcache v0.0.0-20220106215444-fb4bf637b56d // indirect
github.com/bytedance/sonic v1.9.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/structs v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.19.6 // indirect
github.com/go-openapi/spec v0.20.4 // indirect
github.com/go-openapi/swag v0.19.15 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/go-redis/redis/v8 v8.11.5 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.4.3 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/richardlehane/mscfb v1.0.6 // indirect
github.com/richardlehane/msoleps v1.0.6 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tidwall/gjson v1.14.4 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tiendc/go-deepcopy v1.7.2 // indirect
github.com/tinylib/msgp v1.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/xuri/efp v0.0.1 // indirect
github.com/xuri/excelize/v2 v2.10.1 // indirect
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.44.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/ini.v1 v1.67.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

509
sc-lktx-backend/go.sum Normal file
View File

@@ -0,0 +1,509 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do24zMOGGqYVWgw0s9NtiylnJglOeEB5UJo=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5/go.mod h1:tWnyE9AjF8J8qqLk645oUmVUnFybApTQWklQmi5tY6g=
github.com/alibabacloud-go/darabonba-array v0.1.0/go.mod h1:BLKxr0brnggqOJPqT09DFJ8g3fsDshapUD3C3aOEFaI=
github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8=
github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.0.12/go.mod h1:cgtLEj8i4ddXMcQgq4PnpVQvlzS+y5B+QtdSfmcLM3A=
github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ=
github.com/alibabacloud-go/darabonba-string v1.0.2/go.mod h1:93cTfV3vuPhhEwGGpKKqhVW4jLe7tDpo3LUM0i0g6mA=
github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6pb/Qy8c+lqua8cFpEy7g39NRRqOWc3rOwAy8m5Y2BY=
github.com/alibabacloud-go/debug v1.0.0/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
github.com/alibabacloud-go/debug v1.0.1/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
github.com/alibabacloud-go/dingtalk v1.7.38 h1:7tx5KFKmOxrFCoxue3/cUWfFTjCRrmyo5BHgJFPrQxQ=
github.com/alibabacloud-go/dingtalk v1.7.38/go.mod h1:uP5klG3rdfmG3sjk9c7WpegtGgvqzEnM9VKfsX+gO3Q=
github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE=
github.com/alibabacloud-go/gateway-dingtalk v1.0.2/go.mod h1:JUvHpkJtlPFpgJcfXqc9Y4mk2JnoRn5XpKbRz38jJho=
github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws=
github.com/alibabacloud-go/openapi-util v0.1.1/go.mod h1:/UehBSE2cf1gYT43GV4E+RxTdLRzURImCYY0aRmlXpw=
github.com/alibabacloud-go/openapi-util v0.1.2/go.mod h1:/UehBSE2cf1gYT43GV4E+RxTdLRzURImCYY0aRmlXpw=
github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg=
github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
github.com/alibabacloud-go/tea v1.1.8/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
github.com/alibabacloud-go/tea v1.1.11/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
github.com/alibabacloud-go/tea v1.1.20/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk=
github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE=
github.com/alibabacloud-go/tea-utils/v2 v2.0.1/go.mod h1:U5MTY10WwlquGPS34DOeomUGBB0gXbLueiq5Trwu0C4=
github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4=
github.com/alibabacloud-go/tea-utils/v2 v2.0.6/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
github.com/alibabacloud-go/tea-utils/v2 v2.0.8/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
github.com/alibabacloud-go/tea-xml v1.1.3/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8=
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk=
github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
github.com/alicebob/miniredis/v2 v2.30.0 h1:uA3uhDbCxfO9+DI/DuGeAMr9qI+noVWwGPNTFuKID5M=
github.com/alicebob/miniredis/v2 v2.30.0/go.mod h1:84TWKZlxYkfgMucPBf5SOQBYJceZeQRFIaQgNMiCX6Q=
github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw=
github.com/aliyun/credentials-go v1.3.1/go.mod h1:8jKYhQuDawt8x2+fusqa1Y6mPxemTsBEN04dgcAcYz0=
github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM=
github.com/aliyun/credentials-go v1.4.6/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U=
github.com/bradfitz/gomemcache v0.0.0-20220106215444-fb4bf637b56d h1:pVrfxiGfwelyab6n21ZBkbkmbevaf+WvMIiR7sr97hw=
github.com/bradfitz/gomemcache v0.0.0-20220106215444-fb4bf637b56d/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/clbanning/mxj/v2 v2.5.5/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs=
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY=
github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.2.0 h1:RCJM0R1XOsRs+A3x3UCaf3ZYbByDaLjFeAi+YCQEPhs=
github.com/minio/minio-go/v7 v7.2.0/go.mod h1:EU9hENAStx/xXduNdrGO5e4X5vk19NtgB+RIPjZO8o0=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/ginkgo/v2 v2.0.0/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8=
github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo=
github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg=
github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/silenceper/wechat/v2 v2.1.13 h1:4JCnQKFtw7t5DNTZPa/ITralevehQnl6BapjBp3W3Ns=
github.com/silenceper/wechat/v2 v2.1.13/go.mod h1:7Iu3EhQYVtDUJAj+ZVRy8yom75ga7aDWv8RurLkVm0s=
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/tidwall/gjson v1.14.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44=
github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ=
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w=
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0=
github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE=
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/gopher-lua v0.0.0-20220504180219-658193537a64 h1:5mLPGnFdSsevFRFc9q3yYbBkB6tsm4aCwwQV/j1JQAQ=
github.com/yuin/gopher-lua v0.0.0-20220504180219-658193537a64/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200509044756-6aff5f38e54f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200509030707-2212a7e161a5/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY=
gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0=
gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.67.2 h1:JtOSMb9OuaCZKr7h5D/h6iii14sK0hLbplTc6frx4Ss=
gopkg.in/ini.v1 v1.67.2/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo=
gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0=
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View 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,
})
}

File diff suppressed because it is too large Load Diff

View 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 "访客"
}
}

View 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()
}
}

View 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()
}
}

View 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()
}
}

View 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()),
)
}
}

View 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"`
}

View 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" }

View 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)
}

View 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
}

View 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())
}

View 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
}

File diff suppressed because it is too large Load Diff

View 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(&notices)
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)
}

View 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)
}

View 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)
}

View File

@@ -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
}

View 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
}

View 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("钉钉:拒绝后无其他待审批人需通知")
}
}()
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
import requests, os
token = os.environ.get("ADMIN_TOKEN", "")
if not token:
token = input("ADMIN_TOKEN: ").strip()
resp = requests.get("http://localhost:28175/v1/admin/departments-all",
headers={"Authorization": f"Bearer {token}"})
for d in resp.json().get("data", []):
print(f'{d["id"]:>3d} | {d["name"]}')

View File

@@ -0,0 +1,20 @@
"""Remove audit refs and functions from Appointments.vue"""
with open('mp-sc-admin/src/pages/Appointments.vue', 'rb') as f:
content = f.read()
# Lines to remove - the refs
old1 = b"const auditVisible = ref(false)\r\nconst auditTarget = ref<any>(null)\r\nconst auditStatus = ref(1)\r\nconst auditComment = ref('')\r\nconst auditing = ref(false)\r\n"
new1 = b""
content = content.replace(old1, new1)
# The functions handleAudit + submitAudit
old2 = b"function handleAudit(row, status) {\r\n auditTarget.value = row; auditStatus.value = status; auditComment.value = ''; auditVisible.value = true\r\n}\r\nasync function submitAudit() {\r\n auditing.value = true\r\n try {\r\n const status = auditStatus.value\r\n"
content = content.replace(old2, b"")
# Find the closing part of submitAudit and remove it
old3 = b" await request.Put(`/v1/admin/appointments/${auditTarget.value.id}/audit`, { status, comment: auditComment.value })\r\n ElMessage.success(`${text}\xe6\x88\x90\xe5\x8a\x9f`)\r\n auditVisible.value = false\r\n await fetchData()\r\n } catch (err) { ElMessage.error(err.message || '\xe6\x93\x8d\xe4\xbd\x9c\xe5\xa4\xb1\xe8\xb4\xa5') }\r\n finally { auditing.value = false }\r\n}\r\n"
content = content.replace(old3, b"")
with open('mp-sc-admin/src/pages/Appointments.vue', 'wb') as f:
f.write(content)
print('done')

View File

@@ -0,0 +1,44 @@
"""Move workflow-designer menu item from 业务管理 to 访客模块"""
with open('mp-sc-admin/src/pages/Layout.vue', 'rb') as f:
content = f.read()
# File uses \r\n line endings, so search with \r\n
old = (
b" { path: '/pending-employees', label: '\xe9\x80\x9a\xe8\xae\xaf\xe5\xbd\x95\xe5\xaf\xbc\xe5\x85\xa5', icon: 'i-carbon-import-export' },\n"
b" { path: '/workflow-designer', label: '\xe6\xb5\x81\xe7\xa8\x8b\xe8\xae\xbe\xe8\xae\xa1\xe5\x99\xa8', icon: 'i-carbon-diagram' },\r\n"
b" ],\r\n"
b" },\r\n"
b" {\r\n"
b" label: '\xe8\xae\xbf\xe5\xae\xa2\xe6\xa8\xa1\xe5\x9d\x97',\r\n"
b" items: [\r\n"
b" { path: '/appointments', label: '\xe9\xa2\x84\xe7\xba\xa6\xe7\xae\xa1\xe7\x90\x86', icon: 'i-carbon-calendar' },\r\n"
b" { path: '/visit-types', label: '\xe6\x9d\xa5\xe8\xae\xbf\xe7\x9b\xae\xe7\x9a\x84', icon: 'i-carbon-list-dropdown' },\r\n"
b" { path: '/visitor-areas', label: '\xe5\x88\xb0\xe8\xae\xbf\xe5\x8c\xba\xe5\x9f\x9f', icon: 'i-carbon-location' },\r\n"
b" { path: '/config', label: '\xe5\xae\xa1\xe6\x89\xb9\xe9\x85\x8d\xe7\xbd\xae', icon: 'i-carbon-settings-adjust' },"
)
new = (
b" { path: '/pending-employees', label: '\xe9\x80\x9a\xe8\xae\xaf\xe5\xbd\x95\xe5\xaf\xbc\xe5\x85\xa5', icon: 'i-carbon-import-export' },\n"
b" ],\r\n"
b" },\r\n"
b" {\r\n"
b" label: '\xe8\xae\xbf\xe5\xae\xa2\xe6\xa8\xa1\xe5\x9d\x97',\r\n"
b" items: [\r\n"
b" { path: '/appointments', label: '\xe9\xa2\x84\xe7\xba\xa6\xe7\xae\xa1\xe7\x90\x86', icon: 'i-carbon-calendar' },\r\n"
b" { path: '/visit-types', label: '\xe6\x9d\xa5\xe8\xae\xbf\xe7\x9b\xae\xe7\x9a\x84', icon: 'i-carbon-list-dropdown' },\r\n"
b" { path: '/visitor-areas', label: '\xe5\x88\xb0\xe8\xae\xbf\xe5\x8c\xba\xe5\x9f\x9f', icon: 'i-carbon-location' },\r\n"
b" { path: '/workflow-designer', label: '\xe6\xb5\x81\xe7\xa8\x8b\xe8\xae\xbe\xe8\xae\xa1\xe5\x99\xa8', icon: 'i-carbon-diagram' },\r\n"
b" { path: '/config', label: '\xe5\xae\xa1\xe6\x89\xb9\xe9\x85\x8d\xe7\xbd\xae', icon: 'i-carbon-settings-adjust' },"
)
if old in content:
content = content.replace(old, new)
with open('mp-sc-admin/src/pages/Layout.vue', 'wb') as f:
f.write(content)
print('done - replaced successfully')
else:
print('pattern not found')
idx = content.find(b'pending-employees')
if idx >= 0:
# Show bytes around it
print(content[idx:idx+400])

View File

@@ -0,0 +1,100 @@
# 生成 Swagger 文档
# 用法: .\scripts\gen-swagger.ps1
$swag = "$env:USERPROFILE\go\bin\swag.exe"
$project = "E:\workspaces\sc-lktx-mp\sc-lktx-backend"
$output = "$project\docs"
Write-Host "=== 1. 生成 Swagger 文档 ==="
& $swag init -g cmd/server/main.go --output $output --parseDependency --parseDepth 1
if ($LASTEXITCODE -ne 0 -and $LASTEXITCODE -ne 1) {
Write-Host "swag init failed with code $LASTEXITCODE" -ForegroundColor Red
exit $LASTEXITCODE
}
Write-Host "=== 2. 转换鉴权格式 (apiKey -> Bearer, 全局继承) ==="
$json = Get-Content "$output\swagger.json" -Raw | ConvertFrom-Json
$bearerScheme = @{
type = "http"
scheme = "bearer"
bearerFormat = "JWT"
}
$json.PSObject.Properties.Remove('securityDefinitions')
$components = $json.components
if (-not $components) {
$components = New-Object PSObject
$json | Add-Member -Name 'components' -Value $components -MemberType NoteProperty -Force
}
$components | Add-Member -Name 'securitySchemes' -Value @{ BearerAuth = $bearerScheme } -MemberType NoteProperty -Force
# 移除所有接口上的 security避免覆盖全局继承
function Remove-SecurityFromPaths($obj) {
if ($obj.PSObject.Properties.Name -contains 'security') {
$obj.PSObject.Properties.Remove('security')
}
foreach ($prop in $obj.PSObject.Properties) {
if ($prop.Value -is [PSCustomObject]) {
Remove-SecurityFromPaths $prop.Value
} elseif ($prop.Value -is [System.Collections.ICollection]) {
foreach ($item in $prop.Value) {
if ($item -is [PSCustomObject]) {
Remove-SecurityFromPaths $item
}
}
}
}
}
Remove-SecurityFromPaths $json.paths
# 添加全局 security所有接口继承此鉴权
$json | Add-Member -Name 'security' -Value @(@{ BearerAuth = @() }) -MemberType NoteProperty -Force
$json | ConvertTo-Json -Depth 32 | Set-Content "$output\swagger.json"
# 同步修改 docs.go
$docsPath = "$output\docs.go"
$content = Get-Content $docsPath -Raw
# 替换 securityDefinitions 为 components/securitySchemes (bearer 格式)
$oldSecurity = '"securityDefinitions": {
"BearerAuth": {
"type": "apiKey",
"name": "Authorization",
"in": "header"
}
},'
$newSecurity = '"components": {
"securitySchemes": {
"BearerAuth": {
"bearerFormat": "JWT",
"type": "http",
"scheme": "bearer"
}
}
},
"security": [
{
"BearerAuth": []
}
],'
$content = $content.Replace($oldSecurity, $newSecurity)
# 移除所有接口上的 security避免覆盖全局继承
$content = $content -replace '\s{12,}"security": \[\s*\{\s*"BearerAuth":\s*\[\]\s*\}\s*\],?\s*', "`r`n"
Write-Host " 已移除接口级 security"
Set-Content $docsPath $content
Write-Host " docs.go 同步更新(全局继承)"
# 清理临时文件,只保留 docs.go
Remove-Item "$output\swagger.json", "$output\swagger.yaml" -Force -ErrorAction SilentlyContinue
Write-Host " 已清理 swagger.json / swagger.yaml"
Write-Host "=== 3. 验证编译 ==="
Set-Location $project
go build ./...
Write-Host "=== 完成 ===" -ForegroundColor Green

View File

@@ -0,0 +1,181 @@
"""
从通讯录 Excel 提取四川凌空员工数据 调用 admin API 导入
用法: python import_employees.py <xlsx文件路径> [--dry-run]
需要管理员 JWT三种方式
方式1环境变量 ADMIN_TOKEN="xxx"
方式2环境变量 ADMIN_ACCOUNT="admin" ADMIN_PASSWORD="xxx"
方式3都不设则提示手动输入账号密码
"""
import os
import sys
import json
import requests
import openpyxl
API_BASE = os.getenv("API_BASE", "http://localhost:28175/v1/admin")
TOKEN = os.getenv("ADMIN_TOKEN", "")
def login_and_get_token():
account = os.getenv("ADMIN_ACCOUNT", "")
password = os.getenv("ADMIN_PASSWORD", "")
if not account or not password:
account = input("管理员账号: ").strip()
password = input("管理员密码: ").strip()
resp = requests.post(API_BASE.replace("/admin", "") + "/admin/login", json={
"account": account,
"password": password,
})
data = resp.json()
if data.get("code") != 200:
print(f"登录失败: {data.get('msg')}")
sys.exit(1)
token = data["data"]["token"]
print(f" 登录成功")
return token
def get_headers():
global TOKEN
if not TOKEN:
TOKEN = login_and_get_token()
return {"Authorization": f"Bearer {TOKEN}"}
def get_dept_map():
"""从后台获取部门列表,建立 部门名称 ID 的映射"""
resp = requests.get(f"{API_BASE}/departments-all", headers=get_headers())
data = resp.json().get("data", [])
print(f" 找到 {len(data)} 个部门")
dept_map = {}
for d in data:
dept_map[d["name"]] = d["id"]
return dept_map
def parse_excel(path):
"""解析 Excel返回所有员工列表不过滤公司"""
wb = openpyxl.load_workbook(path, data_only=True)
ws = wb.active
employees = []
for row in ws.iter_rows(min_row=4, values_only=True):
name = str(row[1] or "").strip()
phone = str(row[2] or "").strip()
dept_full = str(row[3] or "").strip()
position = str(row[4] or "").strip()
if not name or not phone:
continue
# 标准化手机号
phone_clean = phone.replace("+86-", "").replace("+86", "").strip()
# 取第一个部门路径
dept_first = dept_full.split(",")[0].strip() if dept_full else ""
employees.append({
"real_name": name,
"phone": phone_clean,
"department_full": dept_first,
"position": position,
})
return employees
def match_department(dept_full, dept_map):
"""尝试多种方式匹配部门"""
# 1. 精确匹配完整名称
if dept_full in dept_map:
return dept_full, dept_map[dept_full]
# 2. 带分隔符的:取最后一级名
for sep in ["-", "", "", "/", ""]:
if sep in dept_full:
sub = dept_full.rsplit(sep, 1)[-1].strip()
if sub in dept_map:
return sub, dept_map[sub]
# 3. 模糊匹配dept_map 中有包含关系的
for name, did in dept_map.items():
if name in dept_full or dept_full in name:
return name, did
return None, None
def import_employees(employees, dept_map, dry_run=False):
"""批量导入"""
items = []
skipped_no_dept = 0
for emp in employees:
matched_name, dept_id = match_department(emp["department_full"], dept_map)
if not dept_id:
print(f" 跳过 {emp['real_name']}: 未匹配部门 '{emp['department_full']}'")
skipped_no_dept += 1
continue
items.append({
"real_name": emp["real_name"],
"phone": emp["phone"],
"department_id": dept_id,
"department_name": matched_name,
"position": emp["position"],
})
if dry_run:
print(f"\n[dry-run] 将导入 {len(items)} 人,跳过 {skipped_no_dept} 人(无匹配部门)")
for item in items[:5]:
print(f" {item['real_name']} | {item['phone']} | {item['department_name']} | {item['position']}")
if len(items) > 5:
print(f" ... 共 {len(items)}")
return
batch_size = 50
total_imported = 0
for i in range(0, len(items), batch_size):
batch = items[i:i + batch_size]
resp = requests.post(f"{API_BASE}/employees/import", json={"employees": batch}, headers=get_headers())
result = resp.json()
if result.get("code") == 200:
data = result.get("data", {})
total_imported += data.get("imported", 0)
print(f" 批次 {i//batch_size + 1}: 导入 {data.get('imported')} / 跳过 {data.get('skipped')}")
else:
print(f" 批次 {i//batch_size + 1}: 失败 - {result.get('msg')}")
print(f"\n 共导入 {total_imported}")
if __name__ == "__main__":
if len(sys.argv) < 2:
print("用法: python import_employees.py <xlsx文件路径> [--dry-run] [--region 关键词]")
print(" --region 关键词 只导入部门包含此关键词的员工(默认: 四川)")
sys.exit(1)
path = sys.argv[1]
dry_run = "--dry-run" in sys.argv
# 解析 region 参数
region_filter = None
for i, arg in enumerate(sys.argv):
if arg == "--region" and i + 1 < len(sys.argv):
region_filter = sys.argv[i + 1]
if region_filter is None:
region_filter = "四川" # 默认只导入四川
print(f" 获取部门列表...")
dept_map = get_dept_map()
print("\n 解析 Excel...")
employees = parse_excel(path)
# 按区域过滤
if region_filter:
before = len(employees)
employees = [e for e in employees if region_filter in e["department_full"]]
print(f"{before} 名员工,按区域 '{region_filter}' 过滤后: {len(employees)}")
else:
print(f" 提取到 {len(employees)} 名员工")
print("\n 导入中...")
import_employees(employees, dept_map, dry_run)
if dry_run:
print("\n 去掉 --dry-run 执行实际导入")

View File

@@ -0,0 +1 @@
go run com.sclktx/m/v2/cmd/server

1
sc-lktx-backend/start.sh Normal file
View File

@@ -0,0 +1 @@
go run com.sclktx/m/v2/cmd/server